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.
- 1Encounter
- 2Understand
- 3Worked
- 4Predict
- 5Apply
- 6Stretch
The problem we are solving
Until now we have held the several properties of one thing in a dictionary.
order = {"name": "pen", "price": 15.0, "quantity": 3}
print(order["nmae"])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.
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())pen
45.0A 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__andselfdo - 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:
class OrderLine:
def __init__(self, name):
self.name = name
def shout():
return "hello"
line = OrderLine("pen")
print(line.shout())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
class OrderLine:
def __init__(self, name):
self.name = name
line = OrderLine("pen")
print(line.nmae)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:
class OrderLine:
def __init__(self, name):
self.name = name
line = OrderLine("pen")
print(type(line))
print(isinstance(line, OrderLine))
print(line.__dict__)<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__:
class OrderLine:
def __init__(self, name):
self.name = name
print(OrderLine("pen"))<__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.
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])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
class OrderLine:
def __init__(self, name):
self.name = name
a = OrderLine("pen")
b = OrderLine("pen")
print(a == b)
print(a == a)False
TrueThe 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:
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)True
FalseNow == 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
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)['pen', 'bag']
['pen', 'bag']
TrueTwo 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__.
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)['pen']
['bag']
0.15 0.15TAX_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
"""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()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: 0Four 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__.
Step 4 of 6 — Predict
Check your understanding
shout() is called with no arguments. What happens?
class OrderLine:
def __init__(self, name):
self.name = name
def shout():
return "hello"
line = OrderLine("pen")
print(line.shout())- AA `TypeError` — it takes zero, and one was given
- B`hello` prints
- CA `TypeError` — it takes one, and zero were given
- DAn `AttributeError`
Two separate baskets, one item each. What is printed?
class Basket:
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)- A['pen', 'bag'] ['pen', 'bag']
- B['pen'] ['bag']
- C['pen'] ['pen', 'bag']
- D['pen', 'bag'] []
The two objects hold the same name. What is printed?
class OrderLine:
def __init__(self, name):
self.name = name
a = OrderLine("pen")
b = OrderLine("pen")
print(a == b)
print(a == a)- AFalse True
- BTrue True
- CFalse False
- DA `TypeError`
Answering needs an account
Sign in to check your answers
The questions are above, and working them out in your head is the part that matters. Sign in to see the answers, the explanations and the three-level hints.
Your turn
Write two classes.
Book — built from title, author and pages. It raises a ValueError when pages is below one. Give it a __repr__, and an __eq__ that calls two books equal when the title and author match.
Shelf — collects books, and offers add(book), total_pages(), longest(), and by_author(name) returning a list.
Then five experiments:
- Move
Shelf'sbooks = []out of__init__and under theclassline. Make two separate shelves, add a book to one, then print the other. What do you see? - Delete the
__repr__and print a list of books. How much harder is debugging? - Delete
__eq__and compare two books with the same title and author. - Remove
selffrom one method and call it. How many arguments does the message say were given? - Make a
Bookand writebook.pagse = 100(misspelled). Is there an error? Thenprint(book.__dict__)— what do you see?
That last one matters because it shows the limit of a class. A misspelling is caught when reading, not when writing — setting a new attribute is always allowed. The next chapter shows that type hints and a checker can close that gap too.
Step 6 of 6
Stretch — the chapter quiz
Ten questions from easy to hard. The last ones are difficult on purpose.
Sign in to take the quiz