This week you are writing a library: a small ORM that other developers call. Their bugs will arrive as your bug reports, and a surprising number of those reports come down to one thing — two names pointing at one object when someone expected two objects.
So we start with the model itself, stated precisely, because for the rest of the week you will reason about it rather than guess.
How to use this course
Seven days is a pace, not a deadline. Each day is a module, and the library carries over from one to the next.
Core first. Sections headed Going further, and asides, are extras that nothing later depends on.
Help comes in steps. Every exercise has hints — the idea, then a first step — before its solution. That goes for AI assistants too: ask for an explanation rather than the code, check what you’re told, then do it yourself.
This is one destination, not the next step for everyone. This course is for people who write code that other developers call. If you want to build tools or analyse data, the intermediate course’s Where next table has better next moves.
Ready? A quick check
Three tasks from the earlier courses. If one stops you, reread the lesson named in its hint before Day 2. This course relies throughout on three lessons the beginner course marks Explore: Dunder Methods, Inheritance and Arguments in Depth. If you skipped them, now is the time.
1. Predict both lines.
from dataclasses import dataclass, FrozenInstanceError
@dataclass(frozen=True)
class Point:
x: int
y: int
p = Point(1, 2)
try:
p.x = 5
except FrozenInstanceError:
print("frozen")
print(p == Point(1, 2), {p: "found"}[Point(1, 2)])Check your prediction
frozen
True foundA frozen dataclass refuses assignment, and it’s hashable — so an equal
Point finds the same dictionary entry. From the beginner
Dataclasses lesson.
2. Fix. Each handler should report its own event: this should print
handling save.
handlers = {}
for event in ["save", "delete"]:
handlers[event] = lambda: f"handling {event}"
print(handlers["save"]())Check your fix
handlers = {}
for event in ["save", "delete"]:
handlers[event] = lambda event=event: f"handling {event}"
print(handlers["save"]())handling saveBoth lambdas read the same event when they’re called — after the loop left it
at "delete". A default argument is evaluated when each lambda is made. From
the intermediate Functions That Build Functions lesson.
3. Write a Money class with a __repr__ and an __eq__, so this prints
[Money(250)] True:
# class Money: ...
print([Money(250)], Money(250) == Money(250))Check your answer
class Money:
def __init__(self, cents):
self.cents = cents
def __repr__(self):
return f"Money({self.cents})"
def __eq__(self, other):
return isinstance(other, Money) and self.cents == other.cents
print([Money(250)], Money(250) == Money(250))[Money(250)] TrueA list shows its items with __repr__; == calls __eq__. Without __eq__,
two separate objects are never equal. From the beginner Dunder
Methods lesson.
Names refer, they don’t contain
A name is a label attached to an object. post = Post() doesn’t put a Post
inside post; it makes post refer to one. Assignment never copies the object,
and a second assignment doesn’t touch the first object at all — it moves the
label.
Two operations look similar and are not:
- Rebinding (
name = something) points a name at a different object. - Mutation (
name.append(...),name.x = 1,name[k] = v) changes the object every name for it can see.
first = ["a", "b"]
second = first # a second label on the same list
second.append("c")
print("first: ", first)
print("second:", second)
print("same object?", first is second)
third = list(first) # a new list, built from the old one
third.append("d")
print("first: ", first)
print("third: ", third)
print("same object?", first is third)second.append("c") shows up in first because there is only one list.
third is a different list, so third.append("d") doesn’t.
is and == ask different questions
a is b asks is this the same object? a == b asks do these have the same
value? Two lists can be equal without being the same object. Identity is always
reflexive — a is a is true for everything — but equality is user-defined and
need not be: x = float("nan"); x == x is False. Use is for None and
other sentinels, and when the identity of an object is genuinely the question.
Use == for values.
Arguments are references too
Passing an argument binds a new local name to the caller’s object. So a function can mutate what it was given, but assigning to the parameter only moves that function’s own label.
def add_tag(tags, tag):
tags.append(tag) # mutates the caller's list
def replace_tags(tags, new_tags):
tags = list(new_tags) # rebinds this function's local name only
return tags
post_tags = ["python"]
add_tag(post_tags, "orm")
print("after add_tag: ", post_tags)
returned = replace_tags(post_tags, ["gone"])
print("after replace: ", post_tags)
print("what was returned:", returned)Neither is wrong. What matters is that a library says which one it does. A
method named add_tag is expected to mutate; a function named with_tag is
expected to return something new and leave its argument alone. Breaking that
expectation is the kind of API bug this week is about.
Identity is not a value you should store
id(x) gives a number that is unique while the object is alive. Once an
object is freed, a new object can be given the same id. That makes ids useless
as long-lived keys, which is worth knowing before you reach for one as a cache
key.
print("a fresh list:", id([1, 2, 3]) == id([4, 5, 6]))
kept = [1, 2, 3]
print("while both are alive:", id(kept) == id([4, 5, 6]))The first line often prints True: nothing holds the first list, so it is freed
immediately and the second list lands at the same address. The second line keeps
the first list alive, so the ids must differ. “Often” is doing real work in that
sentence — this is an implementation detail, and the lesson to take is simply
not to depend on it.
There’s a related trap in the other direction: some objects are shared on
purpose, so is can be surprisingly True.
a, b = 256, 256
print("256 is 256:", a is b)
c, d = int("1000"), int("1000")
print("1000 is 1000:", c is d)
print("1000 == 1000:", c == d)CPython keeps one object for each small integer, so a is b is True. Build
two larger integers at run time and you get two objects. Neither result is
promised by the language, which is the point: is on numbers is a bug waiting
for a different Python.
Where this lands in your library
By the end of today your Model.get() returns the same object for the same
row — an identity map. Two calls give you one object, so a change made
through one name is visible through the other:
post = Post.get(connection, 1)
again = Post.get(connection, 1)
post.views = 10
print(again.views) # 10, because `again` is `post`
That is a deliberate design choice with a cost, and by Friday you will be able to argue both sides of it. What you can’t afford is making it by accident — which is exactly what happens in today’s bug hunt.
Make with_tag keep its promise
with_tag is supposed to return a new list and leave its argument untouched.
It doesn’t. The tests say what it should do; make them pass without changing
them.
import unittest
def with_tag(tags, tag):
tags.append(tag)
return tags
class WithTag(unittest.TestCase):
def test_returns_the_tag_added(self):
self.assertEqual(with_tag(["python"], "orm"), ["python", "orm"])
def test_leaves_the_original_alone(self):
original = ["python"]
with_tag(original, "orm")
self.assertEqual(original, ["python"])
def test_returns_a_different_object(self):
original = ["python"]
self.assertIsNot(with_tag(original, "orm"), original)
unittest.TextTestRunner(verbosity=2).run(
unittest.defaultTestLoader.loadTestsFromTestCase(WithTag)
)Hint 1: the idea
tags is just another name for the caller’s list, so tags.append(tag)
changes their list — which is the mutation this lesson is about. To leave it
alone, build a new list holding the old elements plus the tag, and return
that.
Hint 2: a first step
Run the tests and note which two fail. The fix is one line: any way of making a
new list from tags works, as long as the tag is added to the new list rather
than the old one.
Show one solution
Build a new list, then add to that:
def with_tag(tags, tag):
return [*tags, tag][*tags, tag] makes a new list holding the same elements plus one. `list(tags)
- [tag]
andtags + [tag]` also work. What all three have in common is that nothing touches the object the caller passed in.
Note what is not copied: the elements themselves are the same objects as before. That’s the subject of the next lesson.
What you learned
- A name refers to an object; assignment rebinds the name and never copies.
- Mutation is visible through every name for that object, including a caller’s.
iscompares identity,==compares value, andid()is only meaningful while the object is alive.- Sharing an object is a design decision. Your library will make one today.