Matheion

MA-PY1 · Module 3.1

Objects and classes

Topics: OOP · Classes · Methods

Overview

Module 3.1 — Objects and classes

Scroll Core ideas → worked snippets → Practice (quiz). Attempt each question before Discuss / reveal. Use any Python REPL, script, or notebook you like.

Concepts

Core ideas — bundling state and behaviour with classes

So far you have written functions that take data as arguments and return results. That works well for small scripts. As programs grow, you often notice the same problem: related data and the operations on that data get passed around separately, duplicated in every function signature, or stored in globals that are hard to reason about.

Classes solve that by letting you define a new type — a blueprint that bundles attributes (the data a thing carries) and methods (functions written to work on that data). A class is the recipe; an object (also called an instance) is one concrete thing built from that recipe.


From plain data to a class

Imagine tracking a simple counter. Without classes you might write:

def make_counter(start):
    return {"value": start}

def incr(counter, step=1):
    counter["value"] += step
    return counter["value"]

The counter “lives” in a dictionary, and you pass that dictionary into every function. It works, but the connection between counter and incr is easy to break — nothing stops you from calling incr(some_unrelated_dict, 5).

A class keeps the data and the operations in one place:

class Bump:
    def __init__(self, start=0):
        self.value = start

    def incr(self, step=1):
        self.value += step
        return self.value
  • class Bump: defines a new type named Bump.
  • __init__ runs when you construct a new object. It sets up the object’s starting state.
  • self inside the class body refers to the particular object being built or updated.
  • self.value = start creates an instance attribute — data that belongs to this object only.

You create an object by calling the class like a function:

a = Bump(10)   # a is an object (instance) of type Bump
a.incr()       # call a method on that object
print(a.value)

Here a is an object. Bump is the class. Saying “a is an instance of Bump” means a was created from the Bump blueprint and has its own value.


Attributes, methods, and dot notation

Once you have an object, you access its data and behaviour with dot notation:

| Syntax | Meaning | |--------|---------| | obj.attr | read an attribute (data stored on the object) | | obj.method(args) | call a method (a function defined on the class, operating on this object) |

A method looks like a function defined inside the class, but its first parameter is always the receiver — the object the call is “on”. By convention that parameter is named self.


Objects and self

Here is a slightly richer example — a tiny wallet class we will reuse throughout this module:

class StudyWallet:
    def __init__(self, balance=0):
        self.balance = balance

    def deposit(self, amount):
        self.balance += amount

Why StudyWallet and not just Wallet? The Study prefix is a habit in this course for practice types — small invented classes we use to teach an idea, not a library you import. You will see the same pattern soon with StudyLog. In your own projects, a plain name like Wallet or Account is perfectly normal; Python does not require the prefix.

First we define the class (the blueprint). Then we create an object and call a method:

acct = StudyWallet(100)   # construct one wallet; acct is an object
acct.deposit(50)          # ask that object to run its deposit method
print(acct.balance)       # 150

Nothing magical happened before this point — acct did not exist until StudyWallet(100) ran. deposit is just a method that adds to self.balance.

When you write acct.deposit(50), Python automatically passes acct as the first argument to deposit. These two lines are equivalent:

acct.deposit(50)
StudyWallet.deposit(acct, 50)

That is what people mean when they say acct.deposit(50) is syntactic sugar for StudyWallet.deposit(acct, 50): you write the short form; Python supplies the receiver instance as self.

  • self is only a naming convention for that first parameter. You might occasionally see me or this in other codebases — stick with self in your own classes unless you have a strong reason not to.
  • Instance attributes (such as self.balance) belong to one object. Two wallets do not share one balance unless you deliberately design that (see class attributes below).

__init__ is initialisation—not “allocation magic”

When you call StudyWallet(100), Python allocates an object, then calls __init__(self, 100), which sets self.balance = 100. Use __init__ to validate arguments, initialise containers, attach timestamps, and raise on invalid combinations.

No separate “declaration” keyword for fields—whatever you self.whatever = first time becomes the attribute footprint.

Method vs plain function vs static pattern

Inside the class keyword def with self → bound method.

You will later encounter @staticmethod and @classmethod — ignore until orchestration complexity demands.

Representation — __str__ vs __repr__

  • repr(obj) targets developers: unambiguous, often echoing eval-able-ish constructor text.
  • str(obj) targets humans in UI/logs.

Define __repr__ first; add __str__ only when prettier wording buys clarity.

Encapsulation in Python culture

Traditionally _underscored names signal “please treat as internal.” Name mangling on __dunder exists but is not armour—tests and discipline matter more than hiding.

Prefer thin public APIs: few methods manipulate few fields cleanly.

Class attributes vs instance attributes

  • self.x = … attaches data to this instance only.
  • A name assigned in the class body but not through self (e.g. class Widget: counter = 0) is a class attribute—one slot shared by all instances unless an instance later shadows it with self.counter.

Danger: class Bad: items = [] shares one mutable list across instances. Give each object its own list in __init__: self.items = [].

Designing for self-study

Start every class sketch with sentences:

  1. What noun does it model?
  2. What fields constitute minimal consistent state?
  3. Which methods mutate state (commands) versus report derived facts (queries)?

Some designers call query/command separation—not law for toy code, helpful mental scaffolding.

Self-study checklist

  • Instantiate two objects; mutate one—prove other unaffected.
  • Articulate why def __init__(self, items=[]): is unsafe; use items=None and build a new list per call.
  • Decide what repr should expose for Point(3, 4).

Examples

Worked snippets — objects, methods, lifecycle

Minimal class with __init__ and a method

class Bump:
    """A tiny counter you can step forward."""

    def __init__(self, start=0):
        if start < 0:
            raise ValueError("start must be non-negative")
        self.value = start

    def incr(self, step=1):
        self.value += step
        return self.value


a = Bump(10)
print(a.incr(), a.incr(5))

Instances are independent

u = Bump(2)
v = Bump(2)
u.incr(100)
print(u.value, v.value)   # only u moved

__repr__ for debugging-friendly output

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Point({self.x!r}, {self.y!r})"

p = Point(3, 4)
print([p])   # list shows repr, not memory address

Optional user string:

    def __str__(self):
        return f"({self.x}, {self.y})"

Query vs command split (readability pattern)

class Rect:
    def __init__(self, w, h):
        if w <= 0 or h <= 0:
            raise ValueError("dimensions must be positive")
        self.w = w
        self.h = h

    def area(self):
        return self.w * self.h

    def scale(self, factor):
        """Mutate both sides by the same multiplicative factor."""
        if factor <= 0:
            raise ValueError("factor")
        self.w *= factor
        self.h *= factor


r = Rect(2, 3)
print(r.area())
r.scale(2)
print(r.w, r.h, r.area())

Guarding invalid amounts (expanded)

class StudyWallet:
    def __init__(self, balance=0):
        if balance < 0:
            raise ValueError("balance")
        self.balance = balance

    def deposit(self, amount):
        if amount < 0:
            return False
        self.balance += amount
        return True

    def withdraw(self, amount):
        if amount < 0 or amount > self.balance:
            return False
        self.balance -= amount
        return True

Class attribute vs instance list (contrast)

class Shared:
    stash = []           # everyone shares this list object

class Separate:
    def __init__(self):
        self.stash = []

s1, s2 = Shared(), Shared()
s1.stash.append(1)
print(s2.stash)   # [1] — surprise if unintended

a, b = Separate(), Separate()
a.stash.append(1)
print(b.stash)    # [] — independent lists

Practice check — objects and classes

Multiple-choice check from the Matheion app lesson. Pick an answer for each question, then submit.

  1. 1.Given `acct = StudyWallet(100)`, calling `acct.deposit(50)` is shorthand for which call?

  2. 2.What is the main role of `__init__`?

  3. 3.Why is this class dangerous? ```python class StudyBasket: items = [] def add(self, x): self.items.append(x) ```

  4. 4.After `p = Point(1, 2)` and `q = Point(1, 2)` (two separate instances), changing `p.x = 9` does what to `q.x`?

  5. 5.Which statement best describes `__repr__` versus `__str__`?