Chapter 26

Classes — data and behaviour together

`class`, `__init__` and `self`, methods, a printable form with `__repr__`, equality with `__eq__`, class level against instance level, and when a class is not wanted.

38 minPython 3.12
  1. 1Encounter
  2. 2Understand
  3. 3Worked
  4. 4Predict
  5. 5Apply
  6. 6Stretch

The problem we are solving

Until now we have held the several properties of one thing in a dictionary.

python
order = {"name": "pen", "price": 15.0, "quantity": 3}

print(order["nmae"])
text
KeyError: 'nmae'

The error was caught, which is something — but only when that line ran. Hidden inside a condition, it might first appear a month later.

And the problem is not only spelling. Any key can be put in a dictionary, so nothing anywhere states what an order line ought to contain. The function that totals it is somewhere else, and nobody checks that the dictionary has every field before calling it. The data is in one place, what can be done with it in another, and which belongs to which exists only in your head.

python
class OrderLine:
    def __init__(self, name, price, quantity):
        self.name = name
        self.price = price
        self.quantity = quantity

    def total(self):
        return round(self.price * self.quantity, 2)


line = OrderLine("pen", 15.0, 3)

print(line.name)
print(line.total())
text
pen
45.0

A class is a mould: it states what a thing of this kind contains, and what can be done with it.

By the end of this chapter you can

  • Write a class, and say what __init__ and self do
  • Write methods, and say how they differ from ordinary functions
  • Give an object a printable form with __repr__
  • Say what separates a class attribute from an instance attribute — and avoid a familiar trap
  • Make two objects comparable with __eq__
  • Say when a class is not wanted

Prerequisites: JSON and CSV.


What self really is

self is not a magic word — it is the first parameter, and what lands in it is the object the method was called on.

Writing line.total() means "run OrderLine's total method, and give it line as self". Which is why self.price inside gives that particular line's price.

Forget it and the message reads oddly at first:

python
class OrderLine:
    def __init__(self, name):
        self.name = name

    def shout():
        return "hello"


line = OrderLine("pen")
print(line.shout())
text
TypeError: OrderLine.shout() takes 0 positional arguments but 1 was given

"One was given" — but the brackets were empty! What was given is line itself, silently, and shout left no room to receive it.

__init__ is an ordinary method too, with a special name: Python calls it just after the object is made. Inside it, self.name = name sets the attributes.

A misspelling is now caught at once

python
class OrderLine:
    def __init__(self, name):
        self.name = name


line = OrderLine("pen")
print(line.nmae)
text
AttributeError: 'OrderLine' object has no attribute 'nmae'

The difference from a dictionary's KeyError is subtle but real: any key can go into a dictionary, so order["nmae"] = 5 would have raised nothing at all — it would simply have made a new key. Attributes can be added to objects too, but reading __init__ shows what is supposed to be there.

And what is inside can be looked at:

python
class OrderLine:
    def __init__(self, name):
        self.name = name


line = OrderLine("pen")

print(type(line))
print(isinstance(line, OrderLine))
print(line.__dict__)
text
<class '__main__.OrderLine'>
True
{'name': 'pen'}

__dict__ shows that there really is a dictionary inside the object. The class did not remove the dictionary — it gave it a name and a shape.

__repr__ — what printing shows

Without a __repr__:

python
class OrderLine:
    def __init__(self, name):
        self.name = name


print(OrderLine("pen"))
text
<__main__.OrderLine object at 0x00000213E88A9DF0>

The number at the end is a memory address, and it differs on every run. When debugging it is of no use whatsoever.

python
class OrderLine:
    def __init__(self, name, price):
        self.name = name
        self.price = price

    def __repr__(self):
        return f"OrderLine(name={self.name!r}, price={self.price!r})"


line = OrderLine("pen", 15.0)
print(line)
print([line, line])
text
OrderLine(name='pen', price=15.0)
[OrderLine(name='pen', price=15.0), OrderLine(name='pen', price=15.0)]

The second line is the real benefit: it works inside a list too. Printing a list makes Python call each item's __repr__, never its __str__. Which is why, of the two, __repr__ is the one to write first.

The convention is to give text that looks like the code that built the object. Using !r puts quotes around the text inside — chapter twenty-four's reason again.

Equality — the default is not what you want

python
class OrderLine:
    def __init__(self, name):
        self.name = name


a = OrderLine("pen")
b = OrderLine("pen")

print(a == b)
print(a == a)
text
False
True

The two objects hold the same thing, and == is false. Because the default equality is identity — == behaves exactly like is, asking "is this the very same object?".

Changing it means saying what equal means:

python
class OrderLine:
    def __init__(self, name):
        self.name = name

    def __eq__(self, other):
        return self.name == other.name


a = OrderLine("pen")
b = OrderLine("pen")

print(a == b)
print(a is b)
text
True
False

Now == says "alike" and is says "the same one" — chapter twelve's distinction, this time in a type of your own.

One caution: writing __eq__ leaves the object unhashable by default, so it cannot go in a set or be a dictionary key. The next chapter's dataclass does both jobs in one line.

Class level and instance level

python
class Basket:
    TAX_RATE = 0.15
    items = []

    def add(self, name):
        self.items.append(name)


a = Basket()
b = Basket()
a.add("pen")
b.add("bag")

print(a.items)
print(b.items)
print(a.items is b.items)
text
['pen', 'bag']
['pen', 'bag']
True

Two separate baskets, one list.

Recognise it? This is chapter twenty-one's mutable default trap in new clothes. The items = [] just under the class line runs once, when the class is created — not once per object. So the list belongs to the class, and every instance shares it.

The fix has the same shape too: whatever belongs to each object, build inside __init__.

python
class Basket:
    TAX_RATE = 0.15

    def __init__(self):
        self.items = []

    def add(self, name):
        self.items.append(name)


a = Basket()
b = Basket()
a.add("pen")
b.add("bag")

print(a.items)
print(b.items)
print(a.TAX_RATE, b.TAX_RATE)
text
['pen']
['bag']
0.15 0.15

TAX_RATE stayed at class level, and rightly so — it cannot be changed and it is the same for every basket. The rule in one line: constants at class level; everything mutable in __init__.

When not to write a class

Having learnt classes there is a pull towards making everything one. A few signs that the class is surplus:

The class has one method and an __init__. That is a function, split in two for no reason.

No methods at all, only data. The next chapter's dataclass is made for exactly this, in far fewer lines.

The object never changes and holds one thing. A tuple, or a value, will do.

A class earns its place where state and behaviour genuinely belong together: some data that changes, and a few operations that always run on that data.


A complete example

python
"""An order built from two small classes."""

TAX_RATE = 0.15


class OrderLine:
    """One item on an order: what it is, what it costs, how many."""

    def __init__(self, name, price, quantity):
        if quantity < 1:
            raise ValueError(f"quantity must be at least 1: {quantity}")
        self.name = name
        self.price = price
        self.quantity = quantity

    def total(self):
        """Price for this line, tax included."""
        return round(self.price * self.quantity * (1 + TAX_RATE), 2)

    def __repr__(self):
        return f"OrderLine({self.name!r}, {self.price!r}, {self.quantity!r})"


class Order:
    """A named collection of lines, and the questions you ask about it."""

    def __init__(self, customer):
        self.customer = customer
        self.lines = []

    def add(self, name, price, quantity):
        self.lines.append(OrderLine(name, price, quantity))
        return self

    def total(self):
        return round(sum(line.total() for line in self.lines), 2)

    def largest(self):
        """The most expensive line, or None for an empty order."""
        if not self.lines:
            return None
        return max(self.lines, key=lambda line: line.total())

    def __repr__(self):
        return f"Order({self.customer!r}, {len(self.lines)} lines)"


def main():
    order = Order("rafi")
    order.add("pen", 15.0, 3)
    order.add("bag", 850.0, 1)
    order.add("ink", 120.0, 2)

    for line in order.lines:
        print(f"{line.name:<6} {line.total():>9.2f}")

    print(f"{'total':<6} {order.total():>9.2f}")
    print()
    print(order)
    print("largest:", order.largest())

    try:
        order.add("clip", 5.0, 0)
    except ValueError as err:
        print("rejected:", err)


if __name__ == "__main__":
    main()
text
pen        51.75
bag       977.50
ink       276.00
total    1305.25

Order('rafi', 3 lines)
largest: OrderLine('bag', 850.0, 1)
rejected: quantity must be at least 1: 0

Four things worth looking at.

__init__ validates. With quantity < 1 the object is never created at all. This is the most useful thing a class offers: having an OrderLine means its quantity is at least one — nowhere it is used needs to check again. A dictionary cannot make that promise.

Order does not know how a line is priced. It calls line.total() and that is all. How tax applies is OrderLine's business, and changing it changes one place.

Both classes have a __repr__, and largest()'s output shows what it is worth. Without it, that last line would have been a memory address, saying nothing at all.

add returns itself (return self). That makes order.add(...).add(...) possible. It is not used here, deliberately — having the option and stretching every line with it are different things.


When it breaks

TypeError: ... takes 0 positional arguments but 1 was given The method is missing self. Give it as the first parameter.

AttributeError: 'X' object has no attribute 'y' A misspelling, or self.y was never set in __init__. print(obj.__dict__) shows what is actually there.

TypeError: X() takes no arguments There is no __init__, or the name is misspelled (__init__, not __int__, two underscores each side).

Every object is sharing one list The list is at class level. Move it inside __init__.

Two objects hold the same thing and == is false The default equality is identity. Write __eq__ — or take the next chapter's dataclass.

After writing __eq__ it will not go in a set Writing __eq__ removes the default hash. dataclass(frozen=True) gives both.

Printing gives <__main__.X object at 0x...> There is no __repr__.