Objects and errors
Classes, Attributes and Methods
How a class works as a namespace and a factory, why a shared class attribute bites, and what __repr__, __eq__ and @property decide for your objects.
A task starts life as a dictionary. {"title": "write the report", "estimate": 90}
is readable, quick to build, and fine until the day a caller passes
{"title": "write the report", "estimate": "90"} and a function three modules away
does arithmetic on a string. Nothing in that program had an opinion about what a task is
allowed to contain.
A class is where the opinion lives. It gives a set of values a name, a place to state what they mean, and one function that runs every time a new one is built. That is the whole idea. The rest of Python's object model is a set of hooks the interpreter calls at predictable moments, and once you know which moment belongs to which hook, the class mostly writes itself.
One Task class carries the rest of this lesson: a title, an estimate, and some
time logged against it. Building it properly means answering questions you will meet in every
codebase you touch, about self, about attributes that turn out to be shared,
about computed values, and about what happens when somebody prints your object or sorts a
list of them.
A class is a namespace with a call operator
The class statement runs a block and binds the result to a name. Everything
defined in that block becomes an entry in the class's own namespace, which is a dictionary you
can read:
class Task:
default_estimate = 30
def __init__(self, title, estimate=None):
self.title = title
self.estimate = estimate if estimate is not None else self.default_estimate
self.spent = 0
print(Task.default_estimate)
print(sorted(k for k in Task.__dict__ if not k.startswith("__")))
task = Task("write the report")
print(task.title, task.estimate, task.spent)
print(task.__dict__)
print(type(task), isinstance(task, Task))
30
['default_estimate']
write the report 30 0
{'title': 'write the report', 'estimate': 30, 'spent': 0}
<class '__main__.Task'> True
Read the class attribute on the first line and the instance data two lines later. Those are
two different dictionaries. Methods such as __init__ live in
Task.__dict__, and the per-instance data lives in task.__dict__.
That split is what lets a thousand instances share one copy of the code and still hold
different values.
Calling a class is not special syntax either. A class is a callable object, and calling it
runs __new__ to allocate the instance, then __init__ with that new
instance as its first argument. Task does not define __new__, so
object.__new__ allocates and __init__ fills in. When someone tells
you __init__ is the constructor, this is the correction: it initialises an object
that already exists.
self is the first argument, whether you type it or not
A method is a function stored on the class, and the instance is passed in as its first positional argument. Nothing more mystical than that:
class Task:
def __init__(self, title):
self.title = title
def rename(self, title):
self.title = title
return self
task = Task("write the report")
print(task.rename("report v2").title)
print(Task.rename(task, "report v3").title)
print(task.rename)
report v2
report v3
<bound method Task.rename of <__main__.Task object at 0x109361fd0>>
Looking up rename on an instance returns a bound method: a small wrapper that
remembers task and supplies it as the first argument on every call. That wrapper
is why task.rename("report v2") and Task.rename(task, "report v3")
reach the same function with the same two arguments. self is not a keyword. It is
a parameter name the language and the community agreed on, and leaving it out produces an
error that says exactly what happened:
File "/home/ana/lab.py", line 7, in <module>
task.rename("x")
~~~~~~~~~~~^^^^^
TypeError: Task.rename() takes 1 positional argument but 2 were given
The instance arrived whether the signature wanted it or not. This error reads as a counting problem because that is what it is, and the fix is a missing first parameter rather than a missing argument at the call site.
Class attributes are shared, and a mutable one is a trap
Attribute lookup on an instance checks the instance dictionary first, then the class, then its bases. Reading finds the class value when the instance has none of its own. Appending to it is not a read, and that difference is where the trouble starts:
class Task:
default_estimate = 30
tags = []
def __init__(self, title):
self.title = title
def tag(self, name):
self.tags.append(name)
first = Task("write the report")
second = Task("review the report")
first.tag("writing")
print(first.tags, second.tags)
print(Task.tags)
print(first.__dict__)
first.default_estimate = 15
print(first.default_estimate, Task.default_estimate)
print(first.__dict__)
['writing'] ['writing']
['writing']
{'title': 'write the report'}
15 30
{'title': 'write the report', 'default_estimate': 15}
Tagging the first task tagged the second one, because self.tags found the single
list on the class and .append() mutated it in place. Two instances, one list,
and no error to point at it. The last two lines show the contrast: assigning
first.default_estimate = 15 created an entry in first.__dict__ and
left Task.default_estimate at 30. Assignment writes to the instance; mutation
writes to whatever object the lookup returned.
Class attributes are the right tool for constants and for immutable defaults shared by
every instance. They are the wrong tool for anything a method will modify, and the fix is one
line in __init__: self.tags = []. Module 3 showed the same trap in
function defaults, and it is the same underlying rule: a default value that is created once
and shared is a bug waiting for a second caller.
A property is a method that reads like an attribute
Some values need to be computed but should never be stored. Time remaining on a task is one
of them, because keeping a remaining attribute in sync with
estimate and spent means every future method has to remember to
update it:
class Task:
def __init__(self, title, estimate):
self.title = title
self.estimate = estimate
self.spent = 0
@property
def remaining(self):
return max(self.estimate - self.spent, 0)
@property
def done(self):
return self.spent >= self.estimate
task = Task("write the report", 90)
print(task.remaining, task.done)
task.spent = 90
print(task.remaining, task.done)
90 False
0 True
task.remaining looks like an attribute and runs a function. There is no stale
copy to forget, no remaining() call to leave off, and no way to assign it by
accident, because a property without a setter raises
AttributeError: property 'remaining' of 'Task' object has no setter. The cost is
that a reader cannot tell a cheap lookup from a function call, so put I/O, queries and loops
behind a normal method where the call syntax warns the reader that work happens.
Without __repr__ you are debugging memory addresses
A class with no methods still works, and printing it tells you nothing:
class Bare:
pass
bare = Bare()
bare.title = "write the report"
print(bare)
print(bare.title)
<__main__.Bare object at 0x105595fd0>
write the report
That hex number identifies the object and nothing else, and it changes on every run. You
will see it inside lists, inside dictionaries and inside tracebacks, which is why
__repr__ is the dunder worth writing first. It should return a string that looks like the call which would rebuild the
object, with the fields that identify it:
class Task:
def __init__(self, title, estimate):
self.title = title
self.estimate = estimate
self.spent = 0
def __repr__(self):
return f"Task({self.title!r}, estimate={self.estimate}, spent={self.spent})"
print(Task("write the report", 90))
print([Task("write the report", 90), Task("review it", 20)])
Task('write the report', estimate=90, spent=0)
[Task('write the report', estimate=90, spent=0), Task('review it', estimate=20, spent=0)]
Use !r on the string so quotes appear and an empty title shows up as
'' instead of as a gap between two commas. __str__ is the other half
of this pair: it is what print uses when it exists, meant for a human reading
output. A service rarely needs it, and a class with a good __repr__ and no
__str__ is well served, because print falls back to the repr.
__eq__ decides what equal means, and __hash__ follows it
By default, two objects are equal only if they are the same object, which is rarely what a caller means by equal:
class Unhashable:
def __init__(self, n):
self.n = n
def __eq__(self, other):
return isinstance(other, Unhashable) and self.n == other.n
print(Unhashable.__hash__)
try:
{Unhashable(1)}
except TypeError as exc:
print(type(exc).__name__, exc)
task = Unhashable(1)
try:
task < Unhashable(2)
except TypeError as exc:
print(type(exc).__name__, exc)
None
TypeError cannot use 'Unhashable' as a set element (unhashable type: 'Unhashable')
TypeError '<' not supported between instances of 'Unhashable' and 'Unhashable'
Defining __eq__ sets __hash__ to None on that class,
so the objects stop being usable as dictionary keys and set members. That is deliberate:
objects that compare equal must hash equal, and Python cannot know which fields you compared.
Define __hash__ over the same fields, and only for objects whose compared fields
do not change afterwards. Change them after the object is in a dictionary and lookups start
missing, because the hash was computed at insertion time.
Returning NotImplemented rather than False for an unrelated type
is what lets Python try the other operand's __eq__. Returning
False there ends the negotiation early and can break comparisons with types you
never thought about. Ordering is a separate hook, and without __lt__ the
TypeError above is what sorted() gives you:
class Task:
def __init__(self, title, estimate):
self.title = title
self.estimate = estimate
self.spent = 0
@property
def remaining(self):
return max(self.estimate - self.spent, 0)
def __repr__(self):
return f"Task({self.title!r}, remaining={self.remaining})"
def __eq__(self, other):
if not isinstance(other, Task):
return NotImplemented
return (self.title, self.estimate) == (other.title, other.estimate)
def __hash__(self):
return hash((self.title, self.estimate))
def __lt__(self, other):
if not isinstance(other, Task):
return NotImplemented
return self.remaining < other.remaining
first = Task("a", 60)
first.spent = 50
second = Task("b", 60)
second.spent = 10
print(first == Task("a", 60), first == "a")
print(sorted([first, second]))
True False
[Task('a', remaining=10), Task('b', remaining=50)]
__eq__ and __lt__ can disagree, and here they do: the two tasks are
not equal, and they sort by remaining time rather than by title. Every comparison in Python is an
explicit hook, so there is no inconsistency to detect, only a design decision to make once and
document. Note also that __lt__ uses a computed property. If
spent changes while a list of tasks is being sorted, the sort order is whatever
it was at the moment each comparison ran.
classmethod and staticmethod say what the method needs
Two more decorators exist, and the honest way to choose between them is to ask what the method needs access to:
class Task:
def __init__(self, title, estimate):
self.title = title
self.estimate = estimate
self.spent = 0
def __repr__(self):
return f"Task({self.title!r}, estimate={self.estimate})"
@classmethod
def from_row(cls, row):
title, estimate = row.split("|")
return cls(title.strip(), int(estimate))
@staticmethod
def parse_estimate(text):
text = text.strip().lower()
if text.endswith("h"):
return int(float(text[:-1]) * 60)
return int(text)
print(Task.from_row("write the report|90"))
print(Task.parse_estimate(" 1.5h "))
print(Task.parse_estimate("45"))
Task('write the report', estimate=90)
90
45
from_row receives the class as cls and calls cls(...)
instead of Task(...). That one substitution is the reason to prefer it over a
hardcoded constructor call: a subclass that changes __init__ inherits a working
from_row. Alternative constructors that parse a row, a string or a config dict are
the standard use, and naming them from_* tells the reader what they do.
parse_estimate receives nothing but its arguments. It is a function that happens
to be filed under Task, useful when callers would otherwise hunt for it in a
utilities module, and harmless when it is genuinely about tasks. If it does not mention a task
anywhere in its body, a module-level function is the more honest home, and a
@staticmethod used to avoid writing self is a normal function wearing
a costume.
Write a Sprint class holding a name and a list of tasks. It must satisfy
all of these:
Sprint("week 12")starts empty, and no two sprints share a list.add(title, estimate)appends a task to that sprint and returns it.remaining_minutesis a property summing the remaining time of every task in the sprint.__repr__shows the name and the number of tasks.from_lines(name, lines)is a classmethod that builds a sprint from"title|estimate"strings.
Then answer two questions. Why does tasks = [] in the class body break the
first requirement? And if you add __eq__ comparing sprint names without also
defining __hash__, what stops working?
Show the solution and the reasoning
The list belongs in __init__ because that is the only place that runs once
per instance. A list in the class body is created once, when the class statement executes,
and every sprint that appends to it appends to the same object. The test is
Sprint("a").tasks is Sprint("b").tasks, and the correct answer is
False.
class Task:
def __init__(self, title, estimate):
self.title = title
self.estimate = estimate
self.spent = 0
@property
def remaining(self):
return max(self.estimate - self.spent, 0)
def log(self, minutes):
self.spent += minutes
return self
def __repr__(self):
return f"Task({self.title!r}, estimate={self.estimate}, spent={self.spent})"
class Sprint:
def __init__(self, name):
self.name = name
self.tasks = []
def add(self, title, estimate):
task = Task(title, estimate)
self.tasks.append(task)
return task
@property
def remaining_minutes(self):
return sum(task.remaining for task in self.tasks)
def __repr__(self):
return f"Sprint({self.name!r}, tasks={len(self.tasks)})"
@classmethod
def from_lines(cls, name, lines):
sprint = cls(name)
for line in lines:
title, estimate = line.split("|")
sprint.add(title.strip(), int(estimate))
return sprint
python3 lab10.py
Sprint('week 12', tasks=2)
remaining: 80
[Task('write the report', estimate=90, spent=30), Task('review the report', estimate=20, spent=0)]
other sprint: Sprint('week 13', tasks=0)
shared list: False
remaining_minutes is a property because a sprint does not own that number.
It is derived from the tasks, and storing it would mean updating it in add,
in Task.log, and in any future method that touches an estimate. Deriving it on
each read costs one pass over a list that is usually small, which is the right trade until
the list is large enough to measure.
from_lines uses cls(name) so that a subclass of
Sprint gets its own type back, and it delegates to add rather than
building Task objects itself. Both choices mean there is one place where a task
is added to a sprint, and changing that rule changes it for every caller.
The second question is the __hash__ trap. Adding __eq__ sets
__hash__ to None on that class, so a set of sprints fails:
hash: None
TypeError cannot use 'SprintWithEq' as a set element (unhashable type: 'SprintWithEq')
The fix is to define __hash__ over the same field you compare, which works
here only because name is a string that does not change. If equality depended on
the task list, the object could not be hashed safely at all, because the list changes after
the hash is taken. When that happens, leave the class unhashable and let the
TypeError tell the caller that a sprint is not a dictionary key.
Progress
Mark this lesson done and it stays marked in this browser.