◈ python mapVol 1 · Ch 12/14
Volume 1 Python, from the metal up · chapter 12

12Data with behaviour

In Chapter 11 we mastered the object's memory. We sized it to the byte, and with __slots__ we even packed its fields wall-to-wall. In this one we ask the older question that memory never touched: why does a song's data live in one place and the code that plays it in another? A class is where the two finally move in together. Here's the plan. We build a Song and a Playlist from scratch, then watch a class stamp out objects that each carry their own data. We pin down the single rule that decides which of two attributes Python finds first. And the whole way through we keep asking the one thing that actually matters — when you write blinding.play(), where does Python go looking, and in what order? By the end you'll have taught Python what +, == and len() mean on objects you designed. So Blinding Lights (200s) added to Titanium (245s) returns a 445-second playlist that runs on rules you wrote. No magic anywhere, just a lookup you can trace by hand.

iolinked · chapter 12 — the checkpoints6 steps
$ sections covered in Data with behaviour
01Data and behaviour, finally in one place
02self is not magic — it's the instance
03Two kinds of attribute, one lookup rule
04Inheritance: borrow a class, change one thing
05Polymorphism: the object picks the method
06Dunders, @property, and the privacy that isn't

01Data and behaviour, finally in one place

Let's start with the crack we've been stepping over for nine chapters. You can describe a song two ways, and they never quite meet. Its data lives in a dict — {"title": "Blinding Lights", "artist": "The Weeknd", "seconds": 200} (chapter 7). Its behaviour lives somewhere else entirely, in a free-standing function that plays it (chapter 9). The two travel apart, and that gap is the quiet flaw. Every function has to be handed the right dict, by hand, every single time. And nothing on Earth stops you handing it the wrong one. Slip a pizza-order dict into your play-song function and Python doesn't so much as flinch. It runs happily along until it reaches for a "title" that was never there and dies mid-show with a KeyError. The pairing was your discipline to keep, and discipline is exactly the stuff bugs are made of.

Now watch the dict try to defend itself: it can't. To Python a song-dict and a pizza-dict are the very same type. Both are merely dict. So type(order) can no more tell a plate of food from a Weeknd single than a cardboard box can tell socks from a violin. There's no seam between them, no stamp that says this one is a song. What you're missing isn't more data. It's a kind.

THE ONE IDEA TO CARRY FORWARD
A class is a blueprint that names, in one place, what every song has (title, artist, seconds) and what every song can do (play). An object — also called an instance — is one concrete thing stamped out of that blueprint, its data filled in. From here on, a song's data and the behaviour that acts on it stop travelling apart.

A class ends the separation. You write the blueprint once — class Song — describing what every song has and what every song can do. Then you stamp concrete objects out of it, each with its own data poured in. One blueprint can build a thousand objects, the way one architect's drawing builds a whole street of houses. And the behaviour comes welded on. To play a song you no longer go hunting for the right function and feed it the right dict. You just ask the object itself, blinding.play(), and it already knows everything it needs to know about itself.

Two loose piles, welded into one kindfigure
before — two loose piles, nothing checks DATA — a dict (ch 7) song = { 'title': 'Blinding Lights', 'artist': 'The Weeknd', ... } BEHAVIOUR — a free function (ch 9) def play(song): return f'Playing {song[...]}' hand it a pizza-dict → nothing checks → KeyError, mid-show after — one object, one kind Song object @0x…a0 title 'Blinding Lights' artist 'The Weeknd' seconds 200 play() → 'Playing Blinding Lights by The Weeknd' data + behaviour, welded into one kind type(obj) is Song · no pizza can pass for this
Fig 1 — Before, a song's data (a dict) and its behaviour (a free function) were two loose piles you had to pair by hand — and Python checked nothing. A class welds them into one object with its own kind: Song.

Here's the part that should steady you: you have not left the memory model. This is chapter 3's picture again, unchanged. Each object is a box on the heap; blinding is just a name holding a reference to it. id(blinding) still returns its address, two objects still carry two different ids, and aliasing still bites — point a second name at the same object and both names see every change. You didn't switch to some alien paradigm. You learned to design your own shape of box for the heap you already understood.

One blueprint, a whole playlist of objects▸ drag me
the blueprint — written once class Song title artist seconds __init__() play() what every Song HAS + CAN DO Song(…) the heap — every object lives here (ch 11) Song @0x…a0 Blinding Lights The Weeknd 200 s Song @0x…e8 Titanium David Guetta 245 s Song @0x…40 One More Time Daft Punk 320 s Song @0x…98 Levels Avicii 199 s Song @0x…10 Clarity Zedd 271 s Song @0x…78 Wake Me Up Avicii 249 s 1 class → 2 objects
2 objects
Slide it up. Every new card is its own object on the heap — its own data, its own address — yet they all share the single play() written once in the blueprint. One drawing, a whole street of houses.
Fig 2 — One blueprint, many objects. Each stamped object is its own box on the heap with its own data and its own address; the behaviour is written once, in the class. Drag from a single object up to a whole playlist.

In code it's almost anticlimactic, since the whole idea fits in a dozen lines. The blueprint is the class block, and each Song(…) call stamps out one object:

song.pypython
class Song:                                # the blueprint — written once
    def __init__(self, title, artist, seconds):
        self.title   = title                # store each value ON this object
        self.artist  = artist               # (self = the object being built —
        self.seconds = seconds              #  unpacked in full next section)

    def play(self):                        # behaviour now lives WITH the data
        return f"Playing {self.title} by {self.artist}"

blinding = Song("Blinding Lights", "The Weeknd", 200)   # object no. 1
titanium = Song("Titanium", "David Guetta", 245)        # object no. 2

print(blinding.play())          # Playing Blinding Lights by The Weeknd
print(type(blinding).__name__)   # Song  — its own kind, not just 'dict'

Run it and Python confirms the new shape. blinding.play() prints its line without ever being handed a dict, and type(blinding) answers Song — its own kind at last, not one more anonymous dict. Two objects, blinding and titanium, keep their own data cleanly apart. Nudge blinding.seconds and titanium.seconds doesn't so much as twitch.

⚠ MOST BEGINNERS THINK…the stamp copies… nothing
The word stamp does the misleading. Calling Song(…) presses a full copy of the blueprint into the new object — so once a song is built it owns everything the class block wrote, and a later edit to Song can never reach objects that already exist.
TYPE THIS — 10 SECONDS
class Song:
    platform = "iolinked FM"     # written in the class body

a, b = Song(), Song()
Song.platform = "iolinked AM"    # edit the CLASS -- after both objects exist
print(a.platform, b.platform)
print(a.__dict__)
iolinked AM iolinked AM
{}
Stamping copies nothing, and that {} is the receipt — an object's own drawer starts empty, so every read of a.platform is a live lookup on the class at that instant and both existing songs feel the edit immediately; only assignment onto the instance — self.x = … inside a method, a.x = … outside — ever puts something in the drawer.
SYNTAX · class — the blueprint, part by partsix parts in a fixed order: the statement, the shared shelf, __init__, self, the method, the call
class Name: — 1 the statement. Runs once, builds one class object shared_name = value — 2 class attribute: one copy, seen by every object def __init__(self, param): — 3 the initializer. You never call it yourself self.attribute = param — 4 instance attribute: one fresh set per object def method(self): — 5 behaviour, living beside the data return self.attribute obj = Name(argument) — 6 instantiation: build a blank object, then run __init__ obj.attribute — read this object's own data obj.method() — run the behaviour. The ( ) press GO
the statementclass Song: ends in a colon, and the body is indented. Running it builds one class object and binds the name Song to it.
__init__Two underscores each side. Python calls it for you, straight after building the blank object, with that object as self.
selfParameter number one, and nothing more exotic than that. It arrives holding whichever object sits left of the dot at the call.
the two homesself.title = … writes into this object's drawer. A bare platform = … in the class body writes one shared copy on the class.
the callSong(…) stamps an object; blinding.play() runs behaviour. Drop either pair of parentheses and nothing happens — you just named the thing.
you type
class Song:                                       # 1 - the class statement mints a new kind
    platform = "iolinked FM"                      # 2 - class attribute: one copy, shared

    def __init__(self, title, artist, seconds):   # 3 - the initializer, called for you
        self.title = title                        # 4 - instance attributes: one set per object
        self.artist = artist
        self.seconds = seconds

    def play(self):                               # 5 - a method: a function that takes the instance
        return f"Playing {self.title} by {self.artist}"

blinding = Song("Blinding Lights", "The Weeknd", 200)   # 6 - instantiation

print(blinding.play())
print(blinding.title, "|", blinding.platform)
print(type(blinding).__name__, isinstance(blinding, Song))
print(blinding.__dict__)
print(sorted(k for k in Song.__dict__ if not k.startswith("__")))
you see
Playing Blinding Lights by The Weeknd
Blinding Lights | iolinked FM
Song True
{'title': 'Blinding Lights', 'artist': 'The Weeknd', 'seconds': 200}
['platform', 'play']
where beginners trip
  • Leave self out of the def line and the first call dies: TypeError: Song.play() takes 0 positional arguments but 1 was given. Python passed the object; your method had nowhere to put it.
  • Write title = title instead of self.title = title and you set a local that dies at the return. The object stays empty, and the crash comes later.
  • __init__ takes two underscores on each side. _init_ is a perfectly legal ordinary method that Python will never call.
  • Song is the blueprint; Song(…) is one object. Printing the bare name shows the class, not an instance.
  • Everything indented under class belongs to it. Un-indent a def by four spaces and it quietly becomes a free function again.
Wait — a dict already bundles title, artist and seconds under one name. So isn't a class just a dict wearing a tie? Two things a dict can never do: it can't carry its own play() along for the ride, and to Python it has no kind — a song-dict and a pizza-dict are both flatly dict, indistinguishable. A class fixes both at once: the behaviour rides inside the object, and type(blinding) proudly answers Song.
↺ reframe
A class isn't a fancier container — it's a new noun in your program's language. Before today you had exactly one kind of box, the dict, impersonating songs, orders, users, everything, and hoping you never mixed them up. Now Song is a kind unto itself: isinstance(blinding, Song) draws a line no pizza-order can cross. You stopped storing songs and started minting them.
YOU'VE BEEN STAMPING OBJECTS SINCE LINE ONE
This isn't a new world bolted onto Python — it's the world you were already standing in. type(200) is int; type("Blinding Lights") is str; str and int are themselves classes, and every string you've ever typed is an object stamped from the str blueprint — which is the whole reason "blinding".upper() works: the behaviour rides along, exactly like play(). All class Song does is let you author a blueprint of your own.

✗ The myth

Objects are a whole new paradigm — to write a class you have to switch your brain out of "variables and memory" and into some separate mode of "objects and messages."

✓ The reality

It's the same heap and the same references from chapter 3. An object is just a box on the heap that happens to bundle named data with the functions that act on it. blinding is still a name holding a reference; id() still works; aliasing still bites. You never left the memory model — you designed a new shape of box for it.

The deeper cut — where the data lives, and where the behaviour lives

Both figures draw every object clutching its own private play(), and that is a labelled simplification — true enough to picture, refined here once. Peek inside a real instance and only the data is actually there. blinding.__dict__ is {'title': 'Blinding Lights', 'artist': 'The Weeknd', 'seconds': 200}, with no play anywhere in it. The function lives exactly once, up on the class itself: 'play' in Song.__dict__ is True, while 'play' in blinding.__dict__ is False. So a thousand Song objects do not haul around a thousand copies of play(). They hold a thousand small bags of data and all reach back to the one shared function the instant you call it. That's why the slider can stamp object after object almost for free: new data is cheap, and the behaviour was paid for a single time. It also plants the first clue to a mystery. If there's only one play() for every song, how does blinding.play() know it's Blinding Lights and not Titanium? The object is quietly slipped in as an argument, and that handoff is the entire next section.

The cost of behaviour, at scale▸ drag me
a playlist of Song objects — how many copies of play() must exist? 10,000,000 songs on the heap data bags on the heap: 10,000,000 — those you truly pay for; only the code is shared for free. IF EACH OBJECT KEPT ITS OWN play() 10,000,000 copies of the one identical function WITH ONE SHARED BLUEPRINT 1 function — written once, on the class DUPLICATED CODE SITTING IN RAM ~1.36 GB ≈ ten million clones of the same bytecode One class collapses 10,000,000 identical functions into 1 — the dict could hold the data, never the shared behaviour.
10,000,000 songs
Slide from a single song up to ten million. The data bags keep growing — those you genuinely pay for. But play() never multiplies: it is written once on the class and every object reaches back to that one copy. The gap between the two big numbers is the reason a class beats a bare dict — a dict can carry the data, never the shared behaviour.
Fig 2b — The price of behaviour, at scale. A naive object model would copy every method into every instance; Python's shared blueprint keeps exactly one. Drag from one song to ten million and watch the duplicate-code counter explode across the orders of magnitude while the shared copy never budges off 1.

You wrote self three times inside __init__ and never once passed it — yet blinding.play() somehow knows it is Blinding Lights and not Titanium. self isn't magic. It's the instance, quietly handed in. →

TYPE THIS · save it, then run itsong_grows.py — one class, four stages, run after each one
# song_grows.py -- one class, four stages. Nothing is thrown away; each stage adds one part.

# ---- stage 1: the bare class. A new kind, and nothing else. ----
class Song:
    pass

s1 = Song()
print("stage 1:", type(s1).__name__, "|", repr(s1).split(" 0x")[0] + " 0x...>")

# ---- stage 2: __init__ pours the data in ----
class Song:
    def __init__(self, title, artist, seconds):
        self.title = title
        self.artist = artist
        self.seconds = seconds

s2 = Song("Blinding Lights", "The Weeknd", 200)
print("stage 2:", s2.title, "|", s2.__dict__)

# ---- stage 3: a method -- behaviour welded to the data ----
class Song:
    def __init__(self, title, artist, seconds):
        self.title = title
        self.artist = artist
        self.seconds = seconds

    def play(self):
        return f"Playing {self.title} by {self.artist}"

s3 = Song("Blinding Lights", "The Weeknd", 200)
print("stage 3:", s3.play(), "|", Song.play(s3))

# ---- stage 4: __repr__ -- the object learns to say what it is ----
class Song:
    def __init__(self, title, artist, seconds):
        self.title = title
        self.artist = artist
        self.seconds = seconds

    def play(self):
        return f"Playing {self.title} by {self.artist}"

    def __repr__(self):
        return f"Song({self.title!r}, {self.artist!r}, {self.seconds})"

library = [Song("Blinding Lights", "The Weeknd", 200),
           Song("Titanium", "David Guetta", 245)]
print("stage 4:", library[0])
print("stage 4:", library)
print("stage 4:", sum(s.seconds for s in library), "seconds of music")
stage 1: Song | <__main__.Song object at 0x...>
stage 2: Blinding Lights | {'title': 'Blinding Lights', 'artist': 'The Weeknd', 'seconds': 200}
stage 3: Playing Blinding Lights by The Weeknd | Playing Blinding Lights by The Weeknd
stage 4: Song('Blinding Lights', 'The Weeknd', 200)
stage 4: [Song('Blinding Lights', 'The Weeknd', 200), Song('Titanium', 'David Guetta', 245)]
stage 4: 445 seconds of music
Save it as song_grows.py and run it. Then do the thing that makes this work: comment out stages 2, 3 and 4, run, uncomment stage 2, run again, and keep going. You are watching one class grow a part at a time, and each stage answers the complaint the stage before it left you with. Stage 1 is a kind and nothing else — Song() already builds a real object with a real address, which is all class ever promised. It prints as <__main__.Song object at 0x…>, and that hex tail is id() from chapter 3; the little .split(" 0x") is only there so the line reads the same on your machine as on mine. Stage 2 adds __init__, and now s2.__dict__ shows the data actually landing in the object's own drawer. Stage 3 adds a method, and prints it both wayss3.play() and Song.play(s3) return the identical string, which is the whole of the next section in one line. Stage 4 adds __repr__, and watch what that buys: the object stops describing its address and starts describing itself, even from inside a list. One thing to try. Delete the __repr__ from stage 4, run it again, and look at what a list of songs turns into.
Wait — if type(blinding) is Song, what is type(Song)? It's type — the blueprint that stamps out blueprints. And type(type) is type again: the "stamped-from" tower climbs Song → type → type and simply stops at itself. Your class isn't standing outside the object world looking in — it's an object too, stamped from type the same way blinding was stamped from Song.
YOU ALREADY OWN THOUSANDS OF CLASSES
"blinding".upper() rides the exact rail you just built: it's str.upper("blinding")"BLINDING", the receiver slipped in as the first argument, no differently from Song.play(blinding). Every int, str and list you've touched since chapter 0 was an object stamped from a class — class Song just lets you author one of your own.
A CLASS BODY IS A SCRIPT, NOT A DECLARATION
Drop a print("building…") between class Song: and the first def and it fires exactly once — the moment Python reads the class, before a single object exists. The class block isn't a passive announcement the way it is in other languages; it's ordinary code run top-to-bottom to build the blueprint object, methods and all.
↺ the class keyword is sugar
class Song: … is a convenient spelling for a single function call: Song = type("Song", (), {…}) builds the very same class with no class block at all. And that type() is the one you've used since chapter 2 to ask "what kind is this?" — the kind-asker and the class-maker are the same function wearing two hats. Minting a new noun is just calling type.

02self is not magic — it's the instance

Two questions hide inside one innocent line, Song("Blinding Lights", "The Weeknd", 200). The first is what actually runs? Calling a class isn't like calling a function that returns a value you cooked up by hand. Python does two things, in order. First it builds a blank Song object on the heap, an empty little record with no title, no artist, no length yet. Then it automatically calls that class's __init__ method, the initializer, to fill the blank in. You never call __init__ yourself. Naming the class and opening a parenthesis is the whole ritual, and Python supplies the rest.

The second question is the one everyone trips on: what is self? Count the parameters in the definition. def __init__(self, title, artist, seconds) names four of them, yet you handed the call only three arguments. Where did the fourth come from? It came from Python. The extra parameter, self, is the brand-new blank object itself. Python delivers it to __init__ as the very first argument, so the initializer has something to fill. That's why self.title = title reads, quite literally, as: on this particular object, make an attribute named title and set it. And every call to Song(…) builds a different blank object. So every call hands __init__ a different self. That's the entire reason your blinding and your titanium can hold different titles, even though they ran the exact same three lines of code.

THE ONE IDEA TO CARRY FORWARD
self is not a spell, a keyword, or a hidden pointer the language weaves in behind your back. It is the instance — the object you're building or acting on — arriving as an ordinary first parameter. Once you see that, every "mysterious" thing about methods collapses into one plain rewrite rule.
One call, two hidden steps: create blank, then initializefigure
calling a class is two steps, not one — build a blank object, then fill it in Song("Blinding Lights", "The Weeknd", 200) 1 · __new__ blank on the heap title · artist · seconds · hands it in as self 2 · __init__ same object, filled title Blinding Lights artist The Weeknd seconds 200 self on the left and self on the right are the same object — one gets filled into the other
Fig 04 — Song(…) first creates a blank object (that's __new__), then passes it into __init__ as self to be filled. __init__ doesn't build the object — it initializes the one already built.
Created first, initialized second
Step one is pure allocation: Python carves out a blank Song-shaped record on the heap — the right size, a type pointer already set to Song, and not one field filled in yet. Then step two hands that same blank object to __init__ as self to be filled. So __init__ is badly named if you read it as "make one" — it initializes an object that already exists. (The allocation step is itself a method, __new__ — a name you will almost never write or call by hand, so spend no memory on it; what matters is that blank-then-fill is two steps, and that split is exactly where self comes from.)

Now the second half of the section, and the part that dissolves the last of the mystery. A method is nothing exotic. It's an ordinary function — the same kind you met back in chapter 9 — that happens to live inside a class and take the instance as its first parameter. When you write blinding.play(), the dot is pure sugar (we'll refine that word in a moment). Python quietly rewrites it into Song.play(blinding). It looks up play on the class, then ships whatever sits to the left of the dot in as the first argument, self. That's the whole trick. The object doesn't "contain" the method and doesn't "run" it. It just becomes the first thing passed to a function that has always lived on the class. One rewrite rule, zero magic.

SYNTAX · the dot — two spellings of exactly one callobj.method() IS Class.method(obj) — both run below, and both print the same line
obj.method(arg) — Type 1: what you write. The dot fills self Class.method(obj, arg) — Type 2: what Python runs. You fill self by hand def method(self, arg): — self is parameter no. 1 — count them obj.method — no ( ) → a bound method: function + receiver obj.method.__func__ — the one plain function stored on the class obj.method.__self__ — the object it was stapled to
Type 1The dot form. Python looks method up on the class, then ships whatever stands left of the dot in as the first argument.
Type 2The honest form. Identical work, one keystroke longer, and nothing hidden. Useful when you want to say plainly which class's version you mean.
count themdef play(self) names one parameter; blinding.play() passes zero arguments. The books balance because the dot supplied the missing one.
the stapleblinding.play is a small wrapper minted fresh each time, holding __func__ (the shared function) and __self__ (this object). Calling it slots one into the other.
no protectionSong.play(titanium) is legal and runs. self is only ever whatever arrived first — the dot saves a keystroke, it does not add a rule.
you type
class Song:
    def __init__(self, title, seconds):
        self.title = title
        self.seconds = seconds

    def play(self):
        return f"Playing {self.title}"

blinding = Song("Blinding Lights", 200)
titanium = Song("Titanium", 245)

print(blinding.play())                 # the dot form
print(Song.play(blinding))             # the honest form -- identical work
print(blinding.play() == Song.play(blinding))

print(type(Song.play).__name__, type(blinding.play).__name__)
print(blinding.play.__func__ is Song.play)
print(blinding.play.__self__ is blinding)
print(Song.play(titanium))             # self is only ever "whatever came first"
you see
Playing Blinding Lights
Playing Blinding Lights
True
function method
True
True
Playing Titanium
where beginners trip
  • Forget self in the def line and the dot form breaks: TypeError: Song.play() takes 0 positional arguments but 1 was given.
  • Call Song.play() with nothing in the parentheses and you get TypeError: Song.play() missing 1 required positional argument: 'self'. Type 2 means you pass the object.
  • blinding.play without parentheses returns the bound method and prints nothing useful. The ( ) are a separate operator, exactly as in chapter 9.
  • self is not a keyword — keyword.iskeyword("self") is False. Rename it me and it works; do it anyway and every reader on earth stumbles.
  • type(Song.play) is function but type(blinding.play) is method. Same code, two envelopes: one wants self from you, one already has it.
song.pypython
class Song:
    def __init__(self, title, artist, seconds):   # 4 parameters...
        self.title = title       # ...but the call passes 3 — Python supplies self
        self.artist = artist
        self.seconds = seconds
    def play(self):
        return f"▶ {self.title} — {self.artist} — {self.seconds} s"

blinding = Song("Blinding Lights", "The Weeknd", 200)   # self = the new object

blinding.play()          # what you write
Song.play(blinding)      # what Python actually runs — identical result

blinding.play            # no () → the method object itself (ch 9)
blinding.play()          # the () are what make it run
Pick an instance — watch the same one method bind a different self▸ drag me
you write blinding.play() Python rewrites the dot-call what runs Song.play(blinding) class Song def play(self): return f"▶ {self.title} …" one function, stored once on the class — every song shares it blinding — one instance, its own data title Blinding Lights artist The Weeknd seconds 200 arrives as self → ▶ Blinding Lights — The Weeknd — 200 s self is blinding → True
blinding
Slide between the three songs. The method in the class box never changes — there is only one play. All that changes is which instance rides in as self, and so which object's title, artist, and seconds the line reads.
Fig 04b — One function on the class, many selves. obj.play() always means Song.play(obj); the object left of the dot is the only thing that varies.
self is not a keyword
Nowhere in Python's grammar does the word self appear — import keyword; keyword.iskeyword("self") answers False. It is only the conventional name of a method's first parameter. Rename it me and me.title = title works exactly the same. But every Python reader on earth expects self, so writing anything else is like spelling your own name wrong on purpose. Use self.
Wait — if self is just an ordinary first argument, what stops me from handing play the wrong object? Nothing does. Song.play(titanium) runs perfectly and prints Titanium's line — because self is only ever "whatever you passed first." The dot-form blinding.play() simply saves you from typing the receiver twice; it doesn't add a rule, it removes a keystroke.
↺ reframe
Stop picturing a method as a verb that lives inside an object, waiting to be told to act on itself. Picture it instead as a plain function bolted to the class, and the dot as a delivery service: obj.method(x) is a shipping label that means "run Class.method, and put obj in the first box." The object isn't the actor — it's the first parcel.

✗ The myth

self is special Python magic — a keyword the interpreter injects, a secret handle you must invoke to reach "this object."

✓ The reality

self is an everyday parameter with a boring name, and the only "magic" is a single rewrite the interpreter performs: obj.m(x) becomes type(obj).m(obj, x). You could do it by hand every time and Python wouldn't notice the difference.

The deeper cut — calling the dot "just sugar" is a labelled simplification

Saying blinding.play is "just" a text rewrite into Song.play(blinding) is a labelled simplification, true enough to reason with. Here's the precise version. Accessing a function through an instance actually produces a small object of its own, a bound method. Ask for blinding.play and Python doesn't return the raw function. It manufactures a wrapper that staples two things together. The first is __func__, the one function stored on the class (blinding.play.__func__ is Song.playTrue). The second is __self__, the receiver (blinding.play.__self__ is blindingTrue). Calling that wrapper slots __self__ in as the first argument for you. The staple is fresh each time, so blinding.play is blinding.play is actually False — two separate wrappers around the same underlying function. And the machinery that mints them is the descriptor protocol. Functions implement __get__, and instance.method secretly calls function.__get__(instance, cls), which hands back the bound method. So the "rewrite" is real. It's just performed by an object handshake rather than string-editing. And everywhere in this book we'll keep saying "the dot passes self," now that you know what performs the passing.

Wait — the interactive said play is stored once on the class, shared by every song — yet each song clearly has its own title. So where does title actually live, if not next to play? And if I ever set an attribute of the same name on both the class and the instance, which one wins when I write blinding.title?

Two homes, one search. Every dotted name — blinding.title, blinding.play — sends Python down a single, fixed lookup path that checks the instance first and the class second. Learn that one rule and attribute access never surprises you again. →

NOW WRITE IT YOURSELFbuild a Track class from a written spec — attributes, a method, and a readable repr
Here is a spec, the way a spec actually arrives: in words, with no code attached. Write a class called Track for the iolinked library. Every track is built from a title, an artist and a running time in seconds, plus one optional flag, explicit, which defaults to False. Give it a docstring — one line, imperative, chapter 9's habit. Then three things to build, in order. First, the initializer: four parameters after self, each stored on the object so that two tracks never share a value. Second, a method label() that returns one display line in exactly this shape: bad guy - Billie Eilish - 3:14 [E]. The minutes come from divmod(self.seconds, 60), the seconds are zero-padded to two digits, and the [E] appears only for explicit tracks. Third, a __repr__ — the same one you met in stage 4 of song_grows.py — returning Track('bad guy', 'Billie Eilish', 194), quotes and all, so a list of tracks prints something a human can read. Now prove all three. Build bad_guy (explicit) and levels (not), print both labels, print the bare object, print a list holding both, and then call Track.label(levels) — the Type 2 spelling — to show it lands in the same place. Finish by printing bad_guy.__dict__. Two hints, no more: the quotes in the repr come free from !r inside the f-string, and {secs:02d} is what pads 14 to 14 and 3 to 03.
show the solution
class Track:
    """One track in the iolinked library."""

    def __init__(self, title, artist, seconds, explicit=False):
        self.title = title
        self.artist = artist
        self.seconds = seconds
        self.explicit = explicit

    def label(self):
        minutes, secs = divmod(self.seconds, 60)
        mark = " [E]" if self.explicit else ""
        return f"{self.title} - {self.artist} - {minutes}:{secs:02d}{mark}"

    def __repr__(self):
        return f"Track({self.title!r}, {self.artist!r}, {self.seconds})"

bad_guy = Track("bad guy", "Billie Eilish", 194, explicit=True)
levels = Track("Levels", "Avicii", 203)

print(bad_guy.label())
print(levels.label())
print(bad_guy)
print([bad_guy, levels])
print(Track.label(levels))
print(bad_guy.__dict__)
print(sorted(k for k in Track.__dict__ if not k.startswith("_")))

# ------------- what it prints -------------
bad guy - Billie Eilish - 3:14 [E]
Levels - Avicii - 3:23
Track('bad guy', 'Billie Eilish', 194)
[Track('bad guy', 'Billie Eilish', 194), Track('Levels', 'Avicii', 203)]
Levels - Avicii - 3:23
{'title': 'bad guy', 'artist': 'Billie Eilish', 'seconds': 194, 'explicit': True}
['label']
Wait — is blinding.play one fixed thing? No — blinding.play is blinding.play is False. Every time you touch the dot, Python mints a fresh little wrapper that staples the one shared function to this receiver. The function underneath never moves (blinding.play.__func__ is Song.playTrue); it's the throwaway staple that's new each time.
ONE def, TWO types
Reach the same method two ways and you get two different kinds of thing: type(Song.play) is function (the raw def — you pass self), while type(blinding.play) is method (a wrapper that fills self in for you). Same code, two envelopes — which is exactly why blinding.play() and Song.play(blinding) both work and land in the same place.
THE "MAGIC" IS A STRUCT WITH TWO FIELDS
A bound method isn't a spell — it's a wrapper you can take apart. blinding.play.__func__ is the one function stored on the class (is Song.playTrue) and blinding.play.__self__ is the receiver (is blindingTrue). Two fields, both printable. Calling the wrapper just slots __self__ in as the first argument — nothing is hidden from you.
Trace3 step the machine — idea · code · memory move together
The dot is a rewrite — blinding.play() becomes Song.play(blinding)
A method is just a function that lives on the class. Writing blinding.play() makes Python find play on Song and pass the object on the left of the dot in as the first argument — the parameter named self. self isn't magic; it is the instance. Press Next and watch one call get found, rewritten, and run.
1 rewriteself = blinding0 magic
The dot-call, rewritten and runwrite
blinding.play()
Python rewrites the dot ↓
Song.play(blinding)
blinding · instance
titleBlinding Lights
artistThe Weeknd
seconds200
self
class Song
def play(self):
return f"▶ {self.title} …"
one function · shared by every song
returns
In plain words
Under the hood
OPALU · the step
Program · song.py
play frame · self
Memory · resolving the dot
registers — the running state
bound method one function + one receiver
__func__
__self__
returns
what's happening
beat 1 / 1

03Two kinds of attribute, one lookup rule

Every time you wrote self.title = ... inside __init__, you dropped a value into one object's own private drawer. That drawer is a little dict Python keeps on each instance, its __dict__. Those are instance attributes: one fresh set per object, exactly right for the data that differs song to song. But now picture a value that is the same for every song in the library — the station stamp "iolinked FM", identical on all ten thousand of them. Store it as an instance attribute and you pay for ten thousand identical copies on the heap. Renaming the station then means rewriting every one. That is pure waste, and it points straight at a need: one value that lives in a single place yet every instance can read. Write a name straight in the class bodyplatform = "iolinked FM" below — and you get exactly that. It is stored once, on the class object itself, and seen by every instance through the one copy. That's a class attribute, one shelf shared by the whole warehouse. (Note the deliberately stripped-down Song here: two fields, title and seconds, no artist. It's a small drawer so the lookup rule stays the star, and it behaves identically with three. Build blinding = Song("Blinding Lights", 200) and blinding.__dict__ holds exactly {'title': 'Blinding Lights', 'seconds': 200}.)

So when you read blinding.platform, Python doesn't guess and it doesn't search everywhere. It runs one fixed rule, the same one every single time. (1) Look in the instance's own __dict__: nothing there. (2) That's a miss, so climb to the class: found. First hit wins, lookup stops. That two-step is the entire machinery of the dot. Learn it once and attribute access never surprises you again.

attrs.pypython
class Song:
    platform = "iolinked FM"        # class attribute — one copy, on the class
    def __init__(self, title, seconds):
        self.title = title          # instance attributes — one set per object
        self.seconds = seconds

blinding = Song("Blinding Lights", 200)
titanium = Song("Titanium", 245)

print(blinding.platform)            # iolinked FM   — missed the instance, found the class
blinding.platform = "offline mix"   # plants an INSTANCE attribute
print(blinding.platform)            # offline mix   — its own copy now shadows the class
print(titanium.platform)            # iolinked FM   — the class copy never moved
THE ONE RULE TO CARRY FORWARD
Reading obj.name checks the instance's own __dict__ first, then climbs to the class. The first place that has name answers, and the climb stops there. Instance data sits in front; class data sits behind. Everything else in this section is a consequence of that one order.
Reading blinding.platform — two stops, first hit winsfigure
blinding.platform ? 1 — check the instance's own dict blinding.__dict__ title: 'Blinding Lights' seconds: 200 platform? ✗ not in here 2 — climb to the class class Song platform = 'iolinked FM' play() ✓ found — lookup stops
Fig 3a — The dot never searches at random: it reads the instance's own drawer, and only on a miss does it climb to the class. Here platform isn't in blinding, so the climb finds the class copy — the first and only hit.

That same rule explains the trickiest move in the whole topic — shadowing. When you ran blinding.platform = "offline mix", Python did not reach up and rewrite the class's copy. Assigning through an instance can only ever write into that instance's drawer, so it dropped a brand-new platform key into blinding.__dict__. Now step 1 finds it and the climb never happens: blinding sees "offline mix". Meanwhile titanium, whose drawer has no such key, still climbs and reads "iolinked FM" — and Song.platform itself never budged. Only an assignment to the class, Song.platform = ..., moves the shared copy. Peel the shadow off with del blinding.platform and the class value shines through again, untouched the whole time.

SYNTAX · two homes for a value — the class shelf and the object's drawerthe shared-counter idiom, the shadowing trap, and the one line that silently breaks a tally
class Name: shared = value — class attribute: written once, on the class count = 0 — the shared-tally idiom starts here def __init__(self): self.own = value — instance attribute: one per object Name.count += 1 — name the CLASS to move the shared copy obj.shared — read: miss the drawer, climb, find the shelf obj.shared = other — write: plants a SHADOW in the drawer del obj.shared — peel the shadow off; the shelf shines through Name.shared = other — the only way to move the shared copy self.count += 1 — the trap: reads the shelf, writes the drawer
class attributeBorn in the class body, stored once on the class object. A thousand instances read the same value, and renaming it is one edit.
instance attributeBorn in __init__ as self.x = …. One fresh set per object — the right home for anything that differs object to object.
the readobj.name checks the instance's __dict__ first, then climbs to the class. First hit wins, and the climb stops there.
the writeAssignment through an instance never reaches the class. It always drops a key into that object's own drawer — which is exactly what shadowing is.
the tallyBecause writes stay local, a class-wide counter must be moved by naming the class: Song.count += 1, never self.count += 1.
you type
class Song:
    platform = "iolinked FM"          # one shared value, stored on the class
    count = 0                         # one shared tally

    def __init__(self, title):
        self.title = title            # one per object
        Song.count += 1               # name the CLASS to move the shared copy

blinding = Song("Blinding Lights")
titanium = Song("Titanium")

print(Song.count, blinding.count, titanium.count)
blinding.platform = "offline mix"     # plants an INSTANCE attribute
print(blinding.platform, "|", titanium.platform, "|", Song.platform)
print(blinding.__dict__)
del blinding.platform                 # peel the shadow off
print(blinding.platform)

class Broken:
    count = 0
    def __init__(self):
        self.count += 1               # reads the class, writes the instance

b1, b2 = Broken(), Broken()
print(Broken.count, b1.count, b2.count)
you see
2 2 2
offline mix | iolinked FM | iolinked FM
{'title': 'Blinding Lights', 'platform': 'offline mix'}
iolinked FM
0 1 1
where beginners trip
  • self.count += 1 reads the class value, adds one, and binds the result onto the instance. The shared tally never moves — watch Broken below print 0 1 1.
  • A mutable class attribute is one object. songs = [] in the class body gives every instance the same list, and .append mutates it for all of them.
  • blinding.platform = … looks like it edits the class. It does not. Only Song.platform = … moves the shared copy.
  • del blinding.platform deletes the shadow, not the class value. Run it twice and the second raises AttributeError — there is no local copy left to delete.
  • Reading a class attribute through an instance is fine and idiomatic. It is writing through the instance that quietly changes the meaning of the name.
One class attribute, two instances — turn it, then shadow it▸ drag me
class Song volume = 70 one shared copy, stored on the class each instance climbs to the class blinding volume = 90 (its own copy) blinding.volume → 70 titanium (borrows the class copy) titanium.volume → 70
blinding.volume → 70 · titanium.volume → 70
Slide Song.volume and both instances move together — they're reading the one class copy. Now tick the box: blinding plants its own volume, its arrow to the class goes dim, and it freezes at 90 while titanium still follows the class. One shadow, one instance, no effect on the other.
Fig 3b — A single number on the class serves every instance — until one instance grows its own copy and stops climbing. Shadowing is local: it changes what that object sees and nothing else.
Wait — if assigning through an instance always writes to the instance, what does blinding.plays += 1 do to a counter I put on the class? Something sneaky. += is read-then-write: Python reads plays by the normal rule (misses the instance, climbs, finds the class's 0), adds one, and binds the result back onto the instance. So the shared class counter never moves — you've silently planted a per-object plays = 1 that shadows it. Everyone reaching for "a class-wide tally" via self.x += 1 has been bitten by this exact line.

✗ The myth

Every object carries its own copy of every method. A thousand Songs means a thousand play() functions crowding memory.

✓ The reality

Methods live once, on the class — play isn't in any instance's __dict__, it's in Song.__dict__. The dot climbs and borrows it at call time. That's why fixing a bug in the class instantly fixes it for every instance already alive, and why blinding.play and titanium.play are literally the same function underneath.

The memory bill falls straight out of this. Make a thousand songs and you get a thousand tiny per-instance dicts of data, each just a handful of keys. On top of that sits one class object, carrying the shared attributes and every method exactly once. The behaviour doesn't multiply. Only the data does. (If even those little dicts feel too heavy at scale, __slots__ deletes them and packs the fields into a fixed row — chapter 11.)

ERROR CLINICyou will meet these — decoded
Traceback (most recent call last):
  File "song.py", line 6, in <module>
    blinding = Song("Blinding Lights", 200)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: Song() takes no arguments
You wrote an initializer — but as _init_, one underscore a side. Python is really saying it looked for __init__, found none, and fell back to object's, which accepts nothing. Your _init_ sits on the class as an ordinary method nobody will ever call — the class isn't broken, the hook is misspelled.
Two underscores each side: __init__. When a class refuses arguments you know it takes, check that spelling first.
Traceback (most recent call last):
  File "song.py", line 6, in <module>
    blinding = Song("Blinding Lights", "The Weeknd", 200)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: Song.__init__() takes 3 positional arguments but 4 were given
You count three arguments in your call and can't see a fourth anywhere. Both numbers include the invisible passenger: def __init__(self, title, artist) names 3 slots counting self, and the call delivered 4 values counting the brand-new blank object Python passed in first. The books balance the moment you count self on both sides.
Here the def is one parameter short — add seconds. When this message reads off by exactly one, the one is self.
Traceback (most recent call last):
  File "song.py", line 8, in <module>
    print(Song.title)
          ^^^^^^^^^^
AttributeError: type object 'Song' has no attribute 'title'
The climb runs one way — this object's drawer first, then up to the class, never back down. title is born in __init__ as self.title = …, so it lives in each song's own drawer; the class object holds only what the class body wrote (platform, the methods). You asked the blueprint for one particular house's address — and note the wording type object: the error itself confirms Song is an object too.
Per-object data is read off an object — blinding.title. Ask Song only for what the class body defined.
TYPE THIS · save it, then run itbank_account.py — from Ajai's own OOP notes, cleaned: guarded deposits, refused overdrafts
# bank_account.py -- from Ajai's OOP notes, cleaned. The balance is the object's business.

class BankAccount:
    bank = "iolinked Savings"              # class attribute: one copy for every account
    opened = 0                             # a shared tally of accounts ever opened

    def __init__(self, owner, initial_balance=0):
        if initial_balance < 0:
            raise ValueError("an account cannot open in the red")
        self.owner = owner                 # instance attribute: one per account
        self._balance = initial_balance    # one underscore: internal, please knock
        BankAccount.opened += 1            # name the CLASS to move the shared tally

    def deposit(self, amount):
        if amount <= 0:
            return False                   # the guard the first draft was missing
        self._balance += amount
        return True

    def withdraw(self, amount):
        if amount <= 0 or amount > self._balance:
            return False                   # refuse; never let the balance go negative
        self._balance -= amount
        return True

    def get_balance(self):
        return self._balance


acct = BankAccount("Ajai", 100)
print(acct.bank, "|", acct.owner, "|", acct.get_balance())

print("deposit   50 ->", acct.deposit(50), "| balance", acct.get_balance())
print("withdraw  75 ->", acct.withdraw(75), "| balance", acct.get_balance())
print("withdraw 500 ->", acct.withdraw(500), "| balance", acct.get_balance())
print("deposit  -20 ->", acct.deposit(-20), "| balance", acct.get_balance())

second = BankAccount("Anu", 20)
second.deposit(5)
print(second.owner, second.get_balance(), "|", acct.owner, acct.get_balance())
print("accounts opened:", BankAccount.opened, "| shared bank:", second.bank)

try:
    BankAccount("Ghost", -5)
except ValueError as e:
    print("ValueError:", e)
iolinked Savings | Ajai | 100
deposit   50 -> True | balance 150
withdraw  75 -> True | balance 75
withdraw 500 -> False | balance 75
deposit  -20 -> False | balance 75
Anu 25 | Ajai 75
accounts opened: 2 | shared bank: iolinked Savings
ValueError: an account cannot open in the red
This one is from Ajai's notes — his BankAccount, kept in its original shape and repaired in three places. Save it as bank_account.py and run it, then read the run in his order: 100, deposit 50, 150, withdraw 75, 75. That is the exact sequence in his file, and it still works line for line. Now the three repairs, because a fixed bug is a lesson. One, his draft misspelled the parameter intial_balance; harmless until the day a caller writes BankAccount(initial_balance=100) and gets a TypeError for a word nobody can see is wrong. Two, his deposit had no guard, so deposit(-20) was a silent withdrawal that skipped the overdraft check entirely — here it returns False and the balance holds at 75. Three, his balance was self.__balance with two underscores, presented as private; section 06 shows that double underscore is a rename, not a lock, so we use the honest single underscore that means internal, please knock. What has not changed is his real point, and it is the right one: the balance is the object's business. Nothing outside the class adds or subtracts; callers ask deposit and withdraw, which can refuse. Notice too where each value lives — bank and opened sit once on the class, while owner and _balance sit in each account's own drawer. Two things to try. Add a transfer(self, other, amount) that withdraws here and deposits there, and returns False unless both succeed. Then set acct._balance = -999 from outside and watch Python allow it — that underscore was a handshake, and this chapter never lies about which promises the interpreter actually keeps.
Two shelves, and you can inspect both
blinding.__dict__ (or vars(blinding)) shows only the instance's own data; Song.__dict__ holds the class attributes and the methods. The dot is just an automatic search across the two, in that order — nothing is hidden from you, and you can print either drawer to see exactly where a name lives.
A list as a class attribute is ONE list
Write songs = [] in the class body and you've built a single list, once, on the class — the shared shelf. Every instance's .songs.append(...) reads that same list by the climb and mutates it in place, so all objects pile into one bucket (the exact aliasing trap from chapter 3). Data that belongs to each object must be born in __init__: self.songs = [] runs afresh per object.
shared_list.pypython
class Playlist:
    songs = []              # ONE list, built once, living on the class

a = Playlist()
b = Playlist()
a.songs.append("Blinding Lights")
print(b.songs)              # ['Blinding Lights']  — b never touched it, yet there it is
print(a.songs is b.songs)   # True  — one shared list, not two

# the fix: give every object its own list in __init__
class Playlist:
    def __init__(self):
        self.songs = []    # a fresh list per object — no sharing
↺ reframe
Picture the class as a shared shelf bolted to the wall and each instance as a desk in front of it. Reading a name, you glance at your desk first; if it's bare, you look up at the shelf. Writing a name always puts a sticky note on your own desk — never on the shelf. That's the whole model: instances write locally, read locally-then-shared, and the shelf is only ever changed by naming the class directly.
The deeper cut — "instance first" is a useful lie, and the climb doesn't stop at one class

"Check the instance dict first" is a labelled simplification. It's true for every ordinary attribute you'll write, and worth building your model on, but it's not the literal order. The full rule has a step in front of it. Certain objects defined on the class, called data descriptors (a property is the everyday one), get to answer before the instance dict is even consulted. Force a same-named key into an instance's __dict__ and a class property still wins. It's a data descriptor, so it outranks your drawer. Plain values and plain methods are not data descriptors. That's exactly why, for them, the instance genuinely does come first and shadowing works as shown. So the precise order is: data descriptors on the class → the instance's own dict → everything else on the class.

And "climb to the class" is really "climb the chain of classes." Python walks a fixed, ordered list — type(obj).__mro__, the method resolution order — checking each class in turn until a hit. For a plain Song that list is just [Song, object], two rungs, which is why it reads like a single hop. But add a parent class and the ladder grows a rung. The very same first-hit-wins search now reaches past Song into whatever it was built from. That longer climb is not a new mechanism. It's this one, extended, and it is precisely the machinery that makes inheritance work.

One class, its behaviour shared by every instance that climbs to it. But what happens when you need a blueprint that's almost a Song — every attribute and method identical, save one twist? You won't copy the whole class. You'll borrow it, add a rung to that ladder, and change exactly one thing. →

NOW WRITE IT YOURSELFthe playlist that swallowed another playlist — find the shared object, then repair it
Here is a class that reads perfectly and is quietly broken. class Playlist: with songs = [] written straight in the class body, and one method, def add(self, title): self.songs.append(title). Build two playlists, night and gym. Add "Blinding Lights" to night and "Titanium" to gym. Before you run a line, write down what you expect each playlist to hold. Now run it. Both hold both songs, and neither of them asked for that. Your job is three steps. First, prove where the memory lives: print night.songs is gym.songs, then print Playlist.songs, then print night.__dict__ — and sit with that last one, because the instance's own drawer comes back completely empty. Nothing was ever stored on either object; both .songs reads climbed to the class and found the one list. Second, explain the asymmetry in one sentence: why does .append reach the shared list when a plain assignment like night.songs = [] would not? Third, repair it so each playlist owns its own list, without changing a single one of the four calling lines. Then re-run all three proofs and watch is flip to False and the drawer fill up. One hint and no more: the class body runs once, but __init__ runs once per object — and that difference is the entire bug.
show the solution
# the bug, run honestly
class Playlist:
    songs = []                    # ONE list, built once, living on the class

    def add(self, title):
        self.songs.append(title)  # climbs to the class, then mutates in place

night = Playlist()
gym = Playlist()
night.add("Blinding Lights")
gym.add("Titanium")

print("night:", night.songs)
print("gym  :", gym.songs)
print("same list?", night.songs is gym.songs)
print("on the class:", Playlist.songs)
print("night's own drawer:", night.__dict__)

# the fix: the list is born in __init__, so every object gets its own
class Playlist:
    def __init__(self):
        self.songs = []

    def add(self, title):
        self.songs.append(title)

night = Playlist()
gym = Playlist()
night.add("Blinding Lights")
gym.add("Titanium")

print()
print("night:", night.songs)
print("gym  :", gym.songs)
print("same list?", night.songs is gym.songs)
print("on the class:", "songs" in Playlist.__dict__)
print("night's own drawer:", night.__dict__)

# ------------- what it prints -------------
night: ['Blinding Lights', 'Titanium']
gym  : ['Blinding Lights', 'Titanium']
same list? True
on the class: ['Blinding Lights', 'Titanium']
night's own drawer: {}

night: ['Blinding Lights']
gym  : ['Titanium']
same list? False
on the class: False
night's own drawer: {'songs': ['Blinding Lights']}
Wait — the dot searches a dict — so can I open the dict myself? Yes. blinding.__dict__ is a live, ordinary dictionary: run blinding.__dict__['mood'] = 'nocturne' and now blinding.mood reads 'nocturne'. Attribute access is nothing but an automatic lookup over a drawer you're allowed to reach into by hand.
A THOUSAND SONGS, ONE play()
Make a thousand Songs and you get a thousand tiny data dicts — but play lives once, in Song.__dict__, and in no instance's drawer. That's why patching a bug in play() fixes every song already alive in the same instant: none of them was ever holding a copy to go stale. Behaviour doesn't multiply; only data does.
↺ writing never climbs
Reading blinding.platform may climb from desk to shelf — but blinding.platform = "offline mix" can only ever scribble on blinding's own desk. Assignment through an instance never reaches the class. That one asymmetry is the whole model: read locally-then-shared, but always write local. Shadowing isn't a special feature — it's just a local write that a later read happens to find first.
Trace3 step the machine — idea · code · memory move together
Where obj.x is found — the dot walks instance → class → object
Reading blinding.x is never a guess. Python walks one fixed ladder: the instance's own __dict__, then the class, then up the MRO to object. First hit wins and the climb stops; if every rung misses, it raises AttributeError. This trace walks the three plain cases — hit on the instance, hit on the class, total miss. Hold the surprising fourth from the prose above in view: a write like blinding.platform = 'offline mix' plants a key in rung 0, so the very next read wins there and never climbs — shadowing is just the ladder stopping one rung sooner. Press Next and watch the search arrow light the rung that answers.
3 rungsfirst hit winselse AttributeError
The dot's search pathattribute lookupidle
blinding.x?
1 · instanceblinding.__dict__
titleseconds
2 · classSong.__dict__
platformplay__init__
3 · top of MROobject
__repr____eq____class__
press Next to send the dot searching
In plain words
Under the hood
SCANALU · the membership test
Program · attrs.py
Memory · the search state
registers — where the dot is looking
blinding.__dict__ the drawer being searched
what's happening
beat 1 / 1

04Inheritance: borrow a class, change one thing

A live recording is still a song. Same title, same artist, same running time, the same idea of pressing play — plus one new thing, a venue. So how do you build a LiveSong class? The lazy answer is to copy the whole Song class and paste in a venue. You'd regret it twice. Every bug you ever wrote in Song now lives in two places, and the day you fix one you'll forget the other. There is a better move, and it's the backbone of every object system ever built: inheritance, which means say what's new and borrow the rest.

One line does it: class LiveSong(Song):. That parenthesis names Song the parent (or base class) and makes LiveSong its child (or subclass). The child starts life with everything the parent defines, even __init__ itself, if the child never writes its own. From there the child can do exactly three things. It can add something the parent lacks (the venue). It can override a method by defining its own version (a different play()). Or it can extend one with super().__init__(title, artist, seconds), which means "run Song's initializer on this same self first, then keep going with my own lines." Add, override, extend. That's the whole toolkit.

SYNTAX · inheritance — say what is new, borrow the restthe three moves (add, override, extend) plus the two questions: isinstance and issubclass
class Child(Parent): — the parenthesis names the parent def brand_new(self): — ADD: something the parent never had def existing(self): — OVERRIDE: same name, found first, parent's never runs def __init__(self, a, b): super().__init__(a) — EXTEND: run the parent's setup on this same self self.extra = b — then add your own isinstance(obj, Parent) — is-a? True for the class or ANY ancestor type(obj) is Parent — is-exactly? a different, stricter question issubclass(Child, Parent) — the same question, asked of classes Child.__mro__ — the search order: Child, Parent, …, object
addThe child writes a method the parent lacks. Nothing else changes; the parent has no idea the child exists.
overrideSame name, new body. The child's version is simply the first one found walking the MRO, so the parent's never runs.
extendsuper().__init__(…) runs the parent's initializer on this same object, then your lines continue. Replace versus extend is the whole choice.
isinstanceAsks is-a, and accepts ancestors: a Remix is a Song. For the exact class and nothing derived, ask type(obj) is Song.
issubclassThe class-level twin. issubclass(Remix, Song) is True; the reverse is False, because inheritance runs one way only.
you type
class Song:
    def __init__(self, title, artist, seconds):
        self.title, self.artist, self.seconds = title, artist, seconds

    def play(self):
        return f"Playing {self.title} by {self.artist}"

    def encore(self):
        return f"One more time: {self.title}!"

class Remix(Song):                                   # say what is new, borrow the rest
    def __init__(self, title, artist, seconds, remixer):
        super().__init__(title, artist, seconds)     # the parent's setup, same self
        self.remixer = remixer                       # then the new field

    def play(self):                                  # override: same name, new body
        return f"Playing {self.title} ({self.remixer} Remix)"

blinding = Song("Blinding Lights", "The Weeknd", 200)
kaskade = Remix("Blinding Lights", "The Weeknd", 200, "Kaskade")

print(blinding.play())
print(kaskade.play())
print(kaskade.encore())              # inherited -- Remix never wrote it
print(kaskade.artist)                # set by Song.__init__, reached through super()
print(isinstance(kaskade, Remix), isinstance(kaskade, Song), type(kaskade) is Song)
print(issubclass(Remix, Song), issubclass(Song, Remix))
print([c.__name__ for c in Remix.__mro__])
you see
Playing Blinding Lights by The Weeknd
Playing Blinding Lights (Kaskade Remix)
One more time: Blinding Lights!
The Weeknd
True True False
True False
['Remix', 'Song', 'object']
where beginners trip
  • Write your own __init__ and it replaces the parent's. Skip super().__init__(…) and self.artist is never set — the object is born half-built.
  • super().__init__(title, artist, seconds) passes no self. super() already knows which object it is standing on.
  • Nothing is copied at class time. Add a method to the parent later and every existing child object can call it immediately.
  • type(kaskade) is Song is False even though isinstance(kaskade, Song) is True. Two different questions; pick the one you actually mean.
  • Inheritance is for is-a, never for needs-the-code. If the child is not honestly a kind of the parent, you want a field, not a base class.
THE ONE IDEA TO CARRY FORWARD
A child IS-A parent. It writes down only what's different and borrows the rest by pointing at the parent — nothing is copied. "Override" is nothing mystical: it just means the child's version is the first one Python finds as it walks a fixed search path called the MRO. Learn that one search and inheritance stops being magic.
The chain: LiveSong → Song → objectfigure
class object the ancestor of every class class Song __init__ · play() · encore() class LiveSong(Song) __init__ · play() ← overrides · venue inherits inherits super() parent's setup, same self MRO — search order 1 · LiveSong 2 · Song 3 · object first match wins
Fig 04 — LiveSong inherits from Song, which inherits from object — the built-in root every class ends at. super() is how the child extends the parent's setup instead of throwing it away; the MRO is the fixed top-down path Python walks to find any method.

So when you write gig.play(), where does Python actually look? It walks that path, the MRO or method resolution order: a fixed list of classes searched strictly in sequence — LiveSong, then Song, then object. The first class that defines play wins, and the search stops dead. That is the entire meaning of "override." The child's play is found before the parent's, so the parent's never runs. Ask for a name the child doesn't have — gig.encore() — and the search falls straight through to Song. And because a LiveSong genuinely is a Song, the built-in test isinstance(gig, Song) — "was this object stamped from this class, or from any ancestor of it?" — comes back True.

inherit.pypython
class Song:                                  # the parent — every song can play
    def __init__(self, title, artist, seconds):
        self.title, self.artist = title, artist
        self.seconds = seconds
    def play(self):
        return f"Now playing {self.title} by {self.artist}"
    def encore(self):                        # only Song defines this one
        return f"One more time: {self.title}!"

class LiveSong(Song):                        # a Song, plus a venue
    def __init__(self, title, artist, seconds, venue):
        super().__init__(title, artist, seconds)  # run Song's setup first
        self.venue = venue                        # then add the new bit
    def play(self):                          # override the parent's play
        return f"Playing {self.title} live at {self.venue}"

studio = Song("Blinding Lights", "The Weeknd", 200)
gig    = LiveSong("Blinding Lights", "The Weeknd", 210, "Coachella")

print(gig.play())              # Playing Blinding Lights live at Coachella
print(gig.encore())            # One more time: Blinding Lights!   (inherited)
print(gig.seconds)             # 210 — set by Song.__init__, via super()
print(isinstance(gig, Song))     # True — a LiveSong IS a Song
print([c.__name__ for c in LiveSong.__mro__])   # ['LiveSong', 'Song', 'object']

Notice the studio cut is the canonical 200 s you've carried since chapter 0. This live take runs a touch longer at 210 s: same song, different recording, and the venue is the only genuinely new field. Everything else was borrowed.

TYPE THIS · save it, then run itlive_set.py — one parent, three children, and the mistake that costs everyone an hour
# live_set.py -- one parent, three children, and the only three moves: add, override, extend.

class Song:
    def __init__(self, title, artist, seconds):
        self.title = title
        self.artist = artist
        self.seconds = seconds

    def play(self):
        return f"Playing {self.title} by {self.artist}"

    def encore(self):
        return f"One more time: {self.title}!"

    def __repr__(self):
        return f"{type(self).__name__}({self.title!r}, {self.seconds})"


class LiveSong(Song):                          # EXTEND: the parent's setup, plus a venue
    def __init__(self, title, artist, seconds, venue):
        super().__init__(title, artist, seconds)
        self.venue = venue

    def play(self):                            # OVERRIDE: same name, new body
        return f"Playing {self.title} live at {self.venue}"


class Remix(Song):                             # ADD: a new field, and no play() at all
    def __init__(self, title, artist, seconds, remixer):
        super().__init__(f"{title} ({remixer} Remix)", artist, seconds)
        self.remixer = remixer


class Careless(Song):                          # the classic mistake, made on purpose
    def __init__(self, title):
        self.title = title                     # no super() -- artist is never set


setlist = [
    Song("Blinding Lights", "The Weeknd", 200),
    LiveSong("Titanium", "David Guetta", 245, "Tomorrowland"),
    Remix("Blinding Lights", "The Weeknd", 200, "Kaskade"),
]

for track in setlist:
    print(f"{type(track).__name__:9} {track.play()}")

print()
print(setlist)
print("encore, inherited by all three :", setlist[1].encore())
print("seconds, set through super()   :", setlist[1].seconds)
print("isinstance / issubclass        :", isinstance(setlist[1], Song), issubclass(Remix, Song))
print("LiveSong MRO                   :", [c.__name__ for c in LiveSong.__mro__])

print()
broken = Careless("Levels")
try:
    print(broken.play())
except AttributeError as e:
    print("AttributeError:", e)
Song      Playing Blinding Lights by The Weeknd
LiveSong  Playing Titanium live at Tomorrowland
Remix     Playing Blinding Lights (Kaskade Remix) by The Weeknd

[Song('Blinding Lights', 200), LiveSong('Titanium', 245), Remix('Blinding Lights (Kaskade Remix)', 200)]
encore, inherited by all three : One more time: Titanium!
seconds, set through super()   : 245
isinstance / issubclass        : True True
LiveSong MRO                   : ['LiveSong', 'Song', 'object']

AttributeError: 'Careless' object has no attribute 'artist'
Save it as live_set.py and run it. One parent and three children, each demonstrating exactly one of the three moves. LiveSong extendssuper().__init__ runs Song's setup on this same object, then self.venue is added — and it overrides play(). Remix only adds: it rewrites the title on the way past and never defines a play() at all, which is why its line still reads by The Weeknd in Song's own words, with the remixed title threaded through. That single line is the whole idea: method borrowed from the parent, data owned by the child. Read the __repr__ too — it is written once on Song, yet the list prints Song(…), LiveSong(…) and Remix(…), because type(self).__name__ asks the object, at run time, what it actually is. Then there is Careless, and it is on purpose. It writes its own __init__ and forgets super(), so self.artist is never set. Python builds the object happily and says nothing. The bill arrives later, somewhere else in your program: AttributeError: 'Careless' object has no attribute 'artist'. Writing your own __init__ replaces the parent's; super() is the only way to extend it instead. Two things to try. Give Remix its own play() and watch the third line change while the first two stand still. Then add super().__init__(title, "unknown", 0) to Careless and watch the AttributeError disappear.
Forget super().__init__() and the parent never runs
If LiveSong.__init__ skips super().__init__(title, artist, seconds), then no code ever sets self.title. The object is born half-built, and the very next gig.play() dies with AttributeError: 'LiveSong' object has no attribute 'title'. Remember the split: writing your own __init__ replaces the parent's; super() is the one way to extend it instead.
Call a method — watch the search stop at the first class that has it▸ drag me
Python searches top-down and stops at the first class that defines the method MRO class LiveSong defines __init__, play ✓ found — runs here class Song defines __init__, play, encore has it — but never reached class object defines __str__, __repr__, … not reached gig.play() → LiveSong.play (child override wins)
gig.play()
Slide through the three calls. play is found in LiveSong (override) — Python never even looks at Song's play. encore misses LiveSong and lands in Song. str(gig) misses both and resolves all the way up in object, the class everything inherits from.
Fig 04b — One rule, three outcomes: the method that runs is simply the first one found walking LiveSong → Song → object. "Override," "inherited," and "built-in default" are the same search stopping at different rungs.
↺ reframe
Stop picturing a subclass as a photocopy of the parent with a few edits. Picture a fallback chain: Python asks LiveSong "do you have play?", and only if the answer is no does it ask Song, then object. Inheritance isn't copying behaviour down into the child — it's the child knowing who to ask next.

✗ The myth

A subclass copies its parent's methods into itself — inheritance is a tidy, one-time copy-paste that happens when you write class LiveSong(Song).

✓ The reality

Nothing is copied, ever. The child stores only what's new; every other name is found live, at call time by walking the MRO up to the parent. Add a method to Song after a LiveSong already exists and that existing object can call it immediately — because it was never holding a copy, only the knowledge of where to look.

isinstance asks "is-a", not "is-exactly"
isinstance(gig, Song) is True because it accepts the object's own class or any ancestor. When you need the exact class and nothing derived from it, that's a different question: type(gig) is Song (which is False here — gig is a LiveSong). The class-level twin is issubclass(LiveSong, Song), also True.
Wait — the search starts at the object's own class and happens at call time. So one identical line, track.play(), could run LiveSong's code or plain Song's code depending only on which object happens to be sitting in track right now. Couldn't it?
The deeper cut — "the child inherits everything" is a labelled simplification

All through this section we've said a child "gets" or "has" the parent's methods. That's a labelled simplification, clean enough to reason with. Here's the exact truth underneath it. A class copies nothing. It stores only the names written in its own body, in its __dict__. Any other lookup falls through, live, along the MRO to whichever class actually defines the name. The proof is almost eerie. Bolt a new method onto the parent after a child object already exists, and that old object can suddenly call it:

live_lookup.pypython
gig = LiveSong("Blinding Lights", "The Weeknd", 210, "Coachella")
Song.remix = lambda self: f"remix of {self.title}"   # add to the PARENT, now
print(gig.remix())        # remix of Blinding Lights — the child sees it instantly

If the method had been copied at class-definition time, gig could never have seen a method that didn't exist yet. It sees it because attribute lookup is a fresh walk up the MRO on every access. It's a search, not a snapshot.

The deeper cut — two parents, and what super() really means

Python allows more than one parent: class KaraokeTrack(Song, LyricsMixin):. The MRO is then computed by the C3 linearization algorithm, which guarantees two things. Every child appears before its parents, and the parents you listed keep their left-to-right order. Read it any time with LiveSong.__mro__ or LiveSong.mro(). Here's the subtlety that trips everyone. super() does not mean "my parent." It means "the next class in the MRO after me." In a diamond — two classes sharing one grandparent — that distinction is exactly what makes cooperative super().__init__() chains run each ancestor's initializer once, never twice:

diamond.pypython
class Track:
    def __init__(self): print("Track")
class Recorded(Track):
    def __init__(self): print("Recorded"); super().__init__()
class Timed(Track):
    def __init__(self): print("Timed"); super().__init__()
class LiveCut(Recorded, Timed):
    def __init__(self): print("LiveCut"); super().__init__()

print([c.__name__ for c in LiveCut.__mro__])
# ['LiveCut', 'Recorded', 'Timed', 'Track', 'object']
LiveCut()   # prints LiveCut, Recorded, Timed, Track — and Track just ONCE

Inside Recorded, super() points at Timed, not at Track, because Timed is what comes next in LiveCut's MRO. That's how the diamond collapses to a single line through every class. And sometimes Python cannot build a consistent order at all. Say class Bad(A, B) where B is itself a subclass of A, listed in the conflicting order. Then the class statement fails on the spot with TypeError: Cannot create a consistent method resolution order (MRO) for bases A, B.

That question in the curio — one line, several possible methods, the object itself deciding which runs — has a name, and it's the last big idea of this chapter. Next: how a bare loop over a pile of mismatched objects gets each one to do the right thing, with not a single if-check in sight.

SYNTAX · is-a or has-a — the decision you make before you type classthe same two classes, wired both ways: one honest, one that inherits what it merely owns
class Playlist: — HAS-A: composition. The parts live in a field def __init__(self, name): self.songs = [] — a playlist HAS songs def add(self, song): self.songs.append(song) — delegate to the part you hold class LiveSong(Song): — IS-A: inheritance. A live cut IS a song class BadPlaylist(Song): — the wrong tree: inheriting what you merely own — now a playlist has a title and a running time — the test, in one question — Can you say "a B IS A A" and mean every word? inherit Can you only say "a B HAS A A"? hold it in a field
has-aThe object holds the other one in a field and calls its methods. This is composition, and it is the default answer far more often than people expect.
is-aThe object honestly is a kind of the other, usable anywhere the parent is. Only then does inheritance pay for itself.
the smellIf the child has to ignore, disable or explain away a parent's method, the relationship was never is-a. BadPlaylist inherits a seconds it can only lie about.
subclassing listTempting and usually wrong. Hold a list in self.songs instead, then add __len__ and __getitem__ so your own type still behaves like one.
both at onceReal designs use both, cheerfully. A Playlist has Songs, while a LiveSong is one. The two are answers to different questions.
you type
class Song:
    def __init__(self, title, seconds):
        self.title, self.seconds = title, seconds

class Playlist:                          # HAS-A: a playlist holds songs
    def __init__(self, name):
        self.name = name
        self.songs = []                  # the parts live in a field

    def add(self, song):
        self.songs.append(song)
        return self

    def total_seconds(self):
        return sum(s.seconds for s in self.songs)

class LiveSong(Song):                    # IS-A: a live cut is a song, with a venue
    def __init__(self, title, seconds, venue):
        super().__init__(title, seconds)
        self.venue = venue

night = Playlist("Night drive")
night.add(Song("Blinding Lights", 200)).add(LiveSong("Titanium", 245, "Tomorrowland"))
print(night.name, len(night.songs), night.total_seconds())
print(isinstance(night, Song), isinstance(night.songs[1], Song))

class BadPlaylist(Song):                 # the wrong tree: inheriting what you merely own
    pass

wrong = BadPlaylist("Night drive", 0)
print(wrong.title, wrong.seconds)        # a playlist with a running time of its own?
print(isinstance(wrong, Song))           # True -- and that is exactly the bug
you see
Night drive 2 445
False True
Night drive 0
True
where beginners trip
  • "I need the parent's code" is not a reason to inherit. It is a reason to hold an instance of it and call it — the child would inherit the parent's identity too.
  • isinstance(night, Song) is False, and that is the design working. Nothing that expects a song will ever be handed a playlist by mistake.
  • Inherit wrongly and every method you never wanted comes along. BadPlaylist("Night drive", 0) is a playlist with its own running time — nonsense that type-checks.
  • Composition needs no ceremony: a field and a few short methods that pass work through. That plainness is the point, not a missing feature.
  • Deep hierarchies are the usual symptom. Three or four levels of class almost always mean a has-a was written as an is-a somewhere near the top.
Wait — teach the parent a new trick after the child object already exists — does the old child see it? Instantly. Build gig, then run Song.remix = lambda self: f"remix of {self.title}", and gig.remix() works right away. Nothing was copied when you wrote class LiveSong(Song); every dotted name is a fresh walk up the ladder, so a method that didn't exist a second ago is found the instant it does.
object IS THE FLOOR EVERYTHING STANDS ON
object.__mro__ is just (object,) — the ladder's bottom rung, ancestor of every class in the language. Before you write a line, dir(object) already lists 24 inherited names — __eq__, __repr__, __hash__, and more — which is why even a bare Song can be printed and compared. And it's universal: isinstance(5, object), isinstance("x", object), even isinstance(Song, object) are all True.
SOME CLASS DEFINITIONS ARE ILLEGAL
Ask for a contradiction — class Bad(A, B) where B is already a subclass of A, listed in the conflicting order — and Python won't even build it. The class statement itself dies with TypeError: Cannot create a consistent method resolution order. The search ladder must collapse to a single straight line; if no consistent order exists, you find out at definition, not from some baffling call far downstream.
↺ super() is not "my parent"
It means "the next class on the MRO after me." In a plain chain those coincide, so the shorthand is harmless. But in a diamond — LiveCut(Recorded, Timed), both built from Track — inside Recorded, super() points at Timed, a sibling, not an ancestor. That's the precise trick that threads one line through all four classes and runs Track's setup once, never twice: ['LiveCut','Recorded','Timed','Track','object'].

05Polymorphism: the object picks the method

Here is the payoff the whole chapter has been climbing toward. One line — track.play() — is the entire call site. Yet it produces three different results, one per song, with not a single if in sight. That is polymorphism (Greek for "many shapes"): the same call, answered differently depending on what object is standing left of the dot. The machinery underneath is dynamic dispatch. It's dynamic because it happens at run time, not when you wrote the line. It's dispatch because Python has to route the call to exactly one piece of code. When the interpreter reaches track.play(), it looks at the actual object track is bound to at that instant. It starts the method hunt at that object's own class and walks up the MRO — the method-resolution order you met a section ago — until it finds a play. The caller never chooses which method runs. The object does.

PROGRAM TO THE VERB, NOT THE TYPE
The loop below asks every track to .play() and depends on nothing else — not its class, not its length, not how it was built. It knows that a track plays; it never learns how. That "knows-that-not-how" is abstraction, and it's the twin of polymorphism: because the object carries its own behaviour, you can drop a brand-new kind of track into the playlist and this loop never changes a character.

We built Song and its subclass LiveSong earlier in the chapter — LiveSong overrides play() to announce a venue. Now add one more, Remix, and watch it closely: it defines no play() of its own. It only rewrites the title in its __init__. Then we line all three up and call the same method on each:

dispatch.pypython
class Remix(Song):                      # inherits play() untouched — defines none of its own
    def __init__(self, title, artist, seconds, remixer):
        super().__init__(f"{title} ({remixer} Remix)", artist, seconds)

playlist = [
    Song("Blinding Lights", "The Weeknd", 200),              # studio original
    LiveSong("Titanium", "David Guetta", 245, "Tomorrowland"),
    Remix("Blinding Lights", "The Weeknd", 200, "Kaskade"),
]

for track in playlist:
    print(track.play())        # one line — three behaviours
# Playing Blinding Lights by The Weeknd
# Playing Titanium live at Tomorrowland
# Playing Blinding Lights (Kaskade Remix) by The Weeknd
One call site, three destinationsfigure
for track in playlist: track.play() a Song a LiveSong a Remix Song.play() its own method LiveSong.play() overrides Song's Song.play() found by climbing ↑ the MRO Playing Blinding Lights by The Weeknd Playing Titanium live at Tomorrowland Playing Blinding Lights (Kaskade Remix)… the object left of the dot chooses the method — decided at run time
Fig 05a — One call site, three methods. Each object routes .play() through its own class's MRO, so the object — not the caller — decides what runs. The Remix owns no play(), so its call climbs one step to Song.

That third column is the one to sit with. The Remix has no play(), so the search climbs its MRO — Remix → Song → object — and stops at Song. But here's the sleight of hand: the MRO only decided which function to run. It never touched self. Song's code executes, yet self is still the Remix, so self.title is the remixed title the Remix wrote in its own __init__. Method borrowed from the parent, data owned by the child. Drive the slider and watch the search start in a different place, and for the Remix, watch it climb.

Same call, three objects — watch where the search starts and where it lands▸ drag me
MRO: Song → object climb ↑ object no play() up here class Song ✓ play() runs here class LiveSong(Song) def play(self): … (overrides) class Remix(Song) (no play() of its own) call site track = Song("Blinding Lights", …) track.play() Playing Blinding Lights by The Weeknd
Song
One line, track.play(), three behaviours. The object picks where the search starts; the MRO picks the path it walks. For the Remix the start class is empty, so the search climbs to Song — yet the words it prints are the Remix's.
Fig 05b — The green outline marks the class whose play() actually runs. Move from Song to LiveSong to Remix: the search begins at the object's own class every time, and only climbs when that class has nothing to offer.
Wait — if the Remix runs Song's play(), how does it print the remix's title and not some plain "Blinding Lights"? Because the MRO only chose which function to run — it never swapped out self. Inside Song.play, self is still your Remix object, and its self.title was rewritten to "Blinding Lights (Kaskade Remix)" back in the Remix's __init__. Verify it yourself: type(remix).play is Song.play is True (the function really is Song's), while remix.play.__self__ is remix is also True (the data is still the Remix's).
↺ the dot is a question, not a command
Reading track.play(), it's tempting to think you've told Python which code to run. You haven't. You named a verb and asked the object, at that exact moment, "what does play mean for you?" The call site supplies the verb; the object supplies the meaning. That's why the very same line does three different things — you're not issuing three commands, you're asking three objects the same question.

And notice what the loop never does: it never asks track what type it is. It only asks it to .play(). Anything that answers that request qualifies — related by inheritance or not. This is duck typing: if it walks like a duck and quacks like a duck, treat it as a duck. A Podcast class that shares no ancestry with Song at all, but happens to define its own play(), drops into the playlist and the loop is none the wiser.

NOW WRITE IT YOURSELFfour scenarios, four calls — and the code that has to back each answer up
No syntax this time, just the decision, which is the part that actually costs you when it goes wrong. For each pair below, answer IS-A (write class B(A)), HAS-A (hold one in a field), or NEITHER (unrelated types that merely share a verb) — and write one sentence of reason before you look. 1. Album and Song. 2. AcousticVersion and Song. 3. Playlist and the built-in list. 4. PodcastEpisode and Song, given that both define play(). Now make the answers stand up in code. Write all four classes, small ones, then prove each verdict with a line that would fail if you had chosen differently: for the has-a cases print isinstance(album, Song) and isinstance(pl, list) and show them coming back False; for the is-a case print isinstance(ac, Song) coming back True and show AcousticVersion overriding play(); for the last one, drop a Song and a PodcastEpisode into the same playlist and loop over them calling .play() with no if anywhere. That last loop is the answer to scenario 4 in one line: they share a verb, not a bloodline. One hint. Ask the question out loud in plain English — "an album is a song" is obviously false the moment you hear it, and that is the entire test.
show the solution
# four scenarios, and the code that settles each one

class Song:
    def __init__(self, title, artist, seconds):
        self.title, self.artist, self.seconds = title, artist, seconds

    def play(self):
        return f"Playing {self.title} by {self.artist}"

    def __repr__(self):
        return f"Song({self.title!r})"

# 1 - Album and Song -> HAS-A. An album is not a song; it holds songs.
class Album:
    def __init__(self, title, songs):
        self.title = title
        self.songs = list(songs)

    def total_seconds(self):
        return sum(s.seconds for s in self.songs)

# 2 - AcousticVersion and Song -> IS-A. The same thing, one twist.
class AcousticVersion(Song):
    def play(self):
        return f"Playing {self.title} (acoustic) by {self.artist}"

# 3 - Playlist and list -> HAS-A. Hold a list; do not become one.
class Playlist:
    def __init__(self, name):
        self.name = name
        self.items = []

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

    def __len__(self):
        return len(self.items)

# 4 - PodcastEpisode and Song -> NEITHER. No shared ancestor, just the same verb.
class PodcastEpisode:
    def __init__(self, show, host, seconds):
        self.show, self.host, self.seconds = show, host, seconds

    def play(self):
        return f"Streaming {self.show}, hosted by {self.host}"


bl = Song("Blinding Lights", "The Weeknd", 200)
ac = AcousticVersion("Titanium", "David Guetta", 230)
ep = PodcastEpisode("Darknet Diaries", "Jack Rhysider", 3600)

album = Album("After Hours", [bl, ac])
print("1 has-a :", album.title, "|", len(album.songs), "songs |", album.total_seconds(), "s |",
      "isinstance(album, Song) ->", isinstance(album, Song))
print("2 is-a  :", ac.play(), "| isinstance(ac, Song) ->", isinstance(ac, Song))

pl = Playlist("Night drive")
pl.add(bl)
pl.add(ep)
print("3 has-a :", len(pl), "items | isinstance(pl, list) ->", isinstance(pl, list))
print("4 duck  :", [item.play() for item in pl.items])

# ------------- what it prints -------------
1 has-a : After Hours | 2 songs | 430 s | isinstance(album, Song) -> False
2 is-a  : Playing Titanium (acoustic) by David Guetta | isinstance(ac, Song) -> True
3 has-a : 2 items | isinstance(pl, list) -> False
4 duck  : ['Playing Blinding Lights by The Weeknd', 'Streaming Darknet Diaries, hosted by Jack Rhysider']
🦆 DUCK TYPING
Inheritance is one way to promise a method exists — but merely having the method is another, and Python is happy with either. class Podcast: with its own play() returning "Streaming Darknet Diaries, hosted by Jack Rhysider" isn't a Song, isn't a subclass of anything special, yet playlist + [Podcast(…)] loops without a hitch. The loop asked for a behaviour, not a bloodline.

✗ The myth

Polymorphism means writing a fork in the road — if isinstance(track, LiveSong): … elif isinstance(track, Remix): … — so the caller keeps a running list of every track type and what to do with each.

✓ The reality

Polymorphism is what deletes that ladder. The caller writes one line, track.play(), and each object carries its own answer. Add a Podcast, or a hundred new track types, and the call site doesn't gain a single branch — the if/elif tower is exactly the thing you traded away.

Duck typing is a convention, though. Nothing stops you from dropping in a track that forgot to define play(), and you'd only find out when the loop crashes on that item, halfway through the playlist. Want the promise enforced up front? An abstract base class declares a required method with no body, and Python then refuses to build any object whose class hasn't supplied one. One new scrap of syntax below. A line beginning with @ is a decorator, and it is not vague at all. It's a single rewrite rule, in the same spirit as class unfolding to a type() call. @d written above def f means exactly f = d(f): define the function, hand it to d, and rebind the name to whatever d returns. So @abstractmethod over def play runs play = abstractmethod(play). All abstractmethod does is set one flag on the function object, play.__isabstractmethod__ = True. The enforcement lives in ABC. It installs a metaclass (the class-builder type from this chapter's opener, doing extra work at build time — you never write one yourself) that scans for any still-flagged method and raises TypeError the instant you try to instantiate. A tag, yes, but a tag that reduces to one plain function call and one boolean, nothing hand-wavy.

enforce.pypython
from abc import ABC, abstractmethod

class Playable(ABC):          # ABC supplies the enforcement machinery
    @abstractmethod
    def play(self): ...       # a promise with no body

Playable()   # TypeError: Can't instantiate abstract class Playable
             #            without an implementation for abstract method 'play'
             # a subclass that forgets play() fails the very same way —
             # at build time, not as a surprise crash mid-playlist
The deeper cut — "the object picks the method" is a labelled simplification

Said plainly, "the object picks the method" is true enough to build your whole model on. But let's refine it once, precisely. Python doesn't really inspect the object. It inspects type(object) and searches that type's __mro__ for the name. The object matters only because it tells Python which type to start from. That's why dispatch follows the object's real class, never the variable. Rebind track to a different object and the identical line routes somewhere new.

There's a subtler catch, too. Attribute lookup for track.play isn't a plain "search the class." Python's __getattribute__ checks the type's MRO for a data descriptor first, then the instance's own __dict__, and only then the MRO again for a class attribute. A plain method is a function, a non-data descriptor, so an instance attribute of the same name will quietly shadow it. Set track.play = lambda: "shush" and track.play() returns "shush", no class method in sight (del track.play restores it). So it isn't that the class always wins. The class wins unless the instance overrode it.

Finding the function is only half the story. Reaching it through the instance triggers the descriptor protocol, which binds self for you and hands back a bound method whose __self__ is your object. That's exactly why the Remix's data rode along into Song's code. And one last honest caveat: this is single dispatch. Only the one object left of the dot steers. Languages with multiple dispatch choose a method from the types of all arguments at once. Python offers that only opt-in, via functools.singledispatch or third-party libraries. The dot itself always dispatches on precisely one object.

The object decides what .play() means. But could it also decide what +, ==, and len() mean — and hide its own insides while it's at it? Next: dunders, @property, and the privacy that isn't. →

SYNTAX · three kinds of method — what arrives in the first slotinstance method gets self, classmethod gets cls, staticmethod gets nothing at all
class Name: def method(self): — instance method: self = the OBJECT @classmethod — the decorator: from_string = classmethod(from_string) def from_string(cls, text): return cls() — cls = the CLASS. Build with it, never hard-code the name @staticmethod def helper(x): — no self, no cls. Just a function that lives here Name.from_string(text) — an alternate constructor: a second way in Name.helper(x) · obj.helper(x) — both work; nothing is passed for you
instance methodThe default. Called on an object, and the object arrives as self. Use it whenever the answer depends on this particular thing.
classmethodThe class arrives as cls. The everyday use is an alternate constructor: Song.from_string("…") parses a row and hands back a finished object.
why cls, not SongBecause cls follows the caller. Remix.from_string(…) builds a Remix; hard-coding Song(…) would silently build the wrong kind for every subclass.
staticmethodNeither is passed. It is a plain function parked inside the class for namespacingSong.mmss(245) reads better than a loose mmss floating in the module.
the decorator@classmethod above def f means exactly f = classmethod(f) — the same one-line rewrite you met with @abstractmethod. It stores a different kind of object on the class, and that object decides what fills slot one.
you type
class Song:
    def __init__(self, title, artist, seconds):
        self.title, self.artist, self.seconds = title, artist, seconds

    def play(self):                                  # instance method: self is the object
        return f"Playing {self.title}"

    @classmethod
    def from_string(cls, row):                       # cls is the CLASS itself
        title, artist, seconds = row.split(" - ")
        return cls(title, artist, int(seconds))      # cls(...) builds the right kind

    @staticmethod
    def mmss(seconds):                               # no self, no cls: a plain helper
        minutes, secs = divmod(seconds, 60)
        return f"{minutes}:{secs:02d}"

class Remix(Song):
    pass

blinding = Song.from_string("Blinding Lights - The Weeknd - 200")
kaskade = Remix.from_string("Blinding Lights - The Weeknd - 205")

print(blinding.title, blinding.seconds, Song.mmss(blinding.seconds))
print(type(blinding).__name__, type(kaskade).__name__)     # cls followed the caller
print(Song.mmss(245), blinding.mmss(245))
print(Song.from_string.__self__ is Song, Remix.from_string.__self__ is Remix)
print(type(Song.__dict__["mmss"]).__name__, type(Song.__dict__["from_string"]).__name__)
you see
Blinding Lights 200 3:20
Song Remix
4:05 4:05
True True
staticmethod classmethod
where beginners trip
  • Write @classmethod and then forget cls in the parameter list, and the first call fails — Python still passes the class, and your slot one is already taken by text.
  • A classmethod that returns Song(…) instead of cls(…) looks identical until someone subclasses you. Then every subclass factory quietly hands back the parent.
  • A staticmethod that reaches for self or cls raises NameError. If it needs either one, it was never static.
  • @staticmethod is not a performance trick. It is a readability claim: this helper needs nothing from the object or the class.
  • The decorator lines carry no parentheses: @classmethod, not @classmethod(). The second form calls it with no arguments and fails immediately.
Wait — the line track.play() carries no hint of which code runs — so who decides? The object, at that instant — never the variable. Rebind track to a Song, a LiveSong, a Remix, and the identical characters route three different ways. Python doesn't read your variable's declared "type" (there isn't one); it reads type(track) right now and starts the search from there.
THE DOT STEERS ON EXACTLY ONE OBJECT
track.play() dispatches on track and nothing else — Python is a single-dispatch language. Want the method chosen from the types of all the arguments at once? That's multiple dispatch, and Python offers it only opt-in, through functools.singledispatch or a library. The bare dot always picks on precisely one object: the one to its left.
AN INSTANCE CAN HIDE A METHOD
Methods usually live on the class — but don't always win. Set track.play = lambda: "shush" and track.play() returns "shush": a plain method is a non-data descriptor, so an instance attribute of the same name shadows it. del track.play and the real method shines back through. So it isn't "the class always wins" — it's "the class wins unless the instance overrode it."

06Dunders, @property, and the privacy that isn't

Here's a secret you've kept from yourself all course. Every time you wrote len(playlist) or a + b or print(song), you were calling a method. You just never saw its name. Python keeps a set of reserved method names wrapped in double underscores — dunders, for double underscore — and each one is a hook into a piece of the language's own grammar. Define the hook on your class and your object plugs straight into the syntax everyone else's objects use. len(x) quietly runs x.__len__(). a + b runs a.__add__(b). print(x) asks x.__str__() for a friendly string, and if you never wrote one, falls back to x.__repr__().

Skip those hooks and your objects stay second-class. Print a bare Song with no __repr__ and Python shrugs out something like <__main__.Song object at 0x1d8…>: the class name, then the object's address in memory. That hex number is id(song) from chapter 3, written in base 16, honest but useless to you. But define the hooks and the class comes alive. len(party) just works. + merges two playlists (that's operator overloading). And == starts comparing what the songs are instead of falling back to whether they're the very same object, chapter 4's is. You stop describing your object to Python and start teaching Python to speak your object. And + is where that lesson turns literal. On a Playlist it merges, two song lists glued end to end. But drop below the playlist to the samples those songs are actually built from, and the very same + is forced into real arithmetic on the metal.

SYNTAX · dunders I — printing, equality, length__repr__ and __str__, __eq__ (and what == does without it), __len__ — all four run below
class Name: def __repr__(self): — repr(x), the shell, and every container return f"Name({self.field!r})" — !r keeps the quotes: paste it back and rebuild def __str__(self): — print(x) and str(x). Optional: falls back to __repr__ return def __eq__(self, other): — x == y if not isinstance(other, Name): return NotImplemented — "not mine" — let the other side try return self.field == other.field def __len__(self): — len(x). Must return a non-negative int return len(self.items)
__repr__Aimed at the programmer: unambiguous, ideally something you could paste back into the shell. Write this one first; it is the fallback for everything.
__str__Aimed at the reader: pretty, short, no quotes. Skip it and print(x) quietly uses __repr__ instead, which is why __repr__ comes first.
containersA list, dict or tuple always prints each element's __repr__, however lovely your __str__ is. That is not a bug; a container is showing you its contents, not addressing you.
__eq__Without it, == falls back to identity — two songs with identical data compare False because they are two objects. With it, you decide which fields count as sameness.
__len__Wires len(x) to your own count, and gives you truthiness for free: an object with __len__ returning 0 is falsy in an if.
you type
class Song:                                   # no dunders of its own
    def __init__(self, title, artist, seconds):
        self.title, self.artist, self.seconds = title, artist, seconds

class Track:                                  # the same data, wired into the language
    def __init__(self, title, artist, seconds):
        self.title, self.artist, self.seconds = title, artist, seconds

    def __repr__(self):                       # for the programmer: unambiguous
        return f"Track({self.title!r}, {self.artist!r}, {self.seconds})"

    def __str__(self):                        # for the human reading the screen
        return f"{self.title} - {self.artist}"

    def __eq__(self, other):                  # what == means for two Tracks
        if not isinstance(other, Track):
            return NotImplemented             # "not mine" -- let the other side try
        return (self.title, self.artist) == (other.title, other.artist)

class Playlist:
    def __init__(self, tracks):
        self.tracks = list(tracks)

    def __len__(self):                        # len(pl)
        return len(self.tracks)

a = Song("Blinding Lights", "The Weeknd", 200)
b = Song("Blinding Lights", "The Weeknd", 200)
print(a == b, a is b)                         # no __eq__: == falls back to identity

x = Track("Blinding Lights", "The Weeknd", 200)
y = Track("Blinding Lights", "The Weeknd", 200)
print(x == y, x is y)                         # same value, two objects
print(str(x))
print(repr(x))
print(x)                                      # print asks __str__ ...
print([x, y])                                 # ... a container always asks __repr__
print(len(Playlist([x, y])))
print(x == "Blinding Lights")                 # NotImplemented -> Python falls back
you see
False False
True False
Blinding Lights - The Weeknd
Track('Blinding Lights', 'The Weeknd', 200)
Blinding Lights - The Weeknd
[Track('Blinding Lights', 'The Weeknd', 200), Track('Blinding Lights', 'The Weeknd', 200)]
2
False
where beginners trip
  • Define __eq__ and Python sets __hash__ to None. Your objects turn unhashable, and a set or dict key raises TypeError. Add __hash__ over the same fields.
  • Return NotImplemented, never False, for a type you do not handle. False slams the door; NotImplemented lets Python ask the other operand.
  • NotImplemented is a value you return. NotImplementedError is an exception you raise. Different words, different jobs, and the confusion is universal.
  • __str__ and __repr__ must return a str. Return an int and Python raises TypeError: __str__ returned non-string (type int).
  • __len__ must hand back a non-negative int. Return a float and len() refuses it outright.
THE ONE IDEA TO CARRY FORWARD
A dunder is not magic — it's a named socket. The syntax a + b is sugar; the real event is the plain method call a.__add__(b). Learn which socket each scrap of syntax reaches for, and you can wire any object you build into +, len(), ==, [ ], for, and print — the whole language, not a roped-off corner of it.
An object is a board of sockets the syntax plugs intofigure
you write your Song object answers len(s) s + t s == t s[0] print(s) __len__ __add__ __eq__ __getitem__ __repr__
Fig 06 — The operators and built-ins are plugs; the dunder methods are the sockets on your object. Wire up a socket and that piece of syntax lights up for your type — no socket, and Python falls back to its dull defaults.
SYNTAX · @dataclass — the boilerplate, written for youone decorator writes __init__, __repr__ and __eq__ — and default_factory fixes the mutable default
from dataclasses import dataclass, field @dataclass class Name: title: str — a field: name and type, no self, no assignment seconds: int = 0 — a default; must follow the fields without one songs: list = field(default_factory=list) — a FRESH list per object @dataclass(frozen=True, order=True) — immutable, and comparable field by field class Other: value: int — what the decorator writes for you — __init__ from the fields, in order __repr__ Name(title='…', seconds=200) __eq__ field by field, not identity __lt__ … only with order=True
the fieldsWritten as annotations in the class body, not assignments in __init__. The decorator reads them in order and generates the initializer from them.
what you get__init__, __repr__ and __eq__, free and correct. Three of the four you hand-wrote a moment ago, for one line of import and one decorator.
default_factoryThe cure for chapter 9's mutable-default trap, at class level. field(default_factory=list) calls list() once per object instead of sharing one list.
frozen / orderfrozen=True makes assignment raise and the object hashable. order=True adds the four comparisons, so sorted() works on the fields in declaration order.
when to hand-writeWhen the class is mostly behaviour, or the initializer has to validate, transform or compute. A dataclass is for objects whose job is to hold fields.
you type
from dataclasses import dataclass, field

@dataclass
class Song:
    title: str
    artist: str
    seconds: int = 0

@dataclass
class Playlist:
    name: str
    songs: list = field(default_factory=list)   # a FRESH list for every object

a = Song("Blinding Lights", "The Weeknd", 200)
b = Song("Blinding Lights", "The Weeknd", 200)
print(a)                                        # __repr__, written for you
print(a == b, a is b)                           # __eq__, field by field
print(a.title, a.seconds)

night, gym = Playlist("Night"), Playlist("Gym")
night.songs.append(a)
print(len(night.songs), len(gym.songs), night.songs is gym.songs)
print(Playlist("Empty"))

@dataclass(frozen=True, order=True)
class Sample:
    value: int

print(sorted([Sample(3), Sample(-1), Sample(2)]))
try:
    Sample(1).value = 9
except Exception as e:
    print(type(e).__name__ + ":", e)
you see
Song(title='Blinding Lights', artist='The Weeknd', seconds=200)
True False
Blinding Lights 200
1 0 False
Playlist(name='Empty', songs=[])
[Sample(value=-1), Sample(value=2), Sample(value=3)]
FrozenInstanceError: cannot assign to field 'value'
where beginners trip
  • songs: list = [] is refused the moment the class is built: ValueError: mutable default <class 'list'> for field songs is not allowed: use default_factory. Chapter 9's trap, caught for you.
  • A field with a default cannot precede one without. Put seconds: int = 0 above artist: str and you get TypeError: non-default argument 'artist' follows default argument — the same rule as any def line.
  • The annotation is not enforced. Song(1, 2, 3) builds happily — str and int here are documentation for humans and type-checkers, not a runtime guard.
  • A field with no annotation is not a field at all. Write title = "" without the : str and the decorator ignores it entirely.
  • order=True compares fields in declaration order, top to bottom. If that is not the ordering you meant, write __lt__ yourself.
songs.pypython
class Song:
    def __init__(self, title, artist, seconds):
        self.title, self.artist, self.seconds = title, artist, seconds
    def __repr__(self):                     # what the shell and containers show
        return f"Song({self.title!r}, {self.artist!r}, {self.seconds})"
    def __eq__(self, other):                # what == means for two Songs
        if not isinstance(other, Song):
            return NotImplemented          # "not mine" — let the other side try
        return (self.title, self.artist) == (other.title, other.artist)

class Playlist:
    def __init__(self, songs):
        self.songs = list(songs)            # our own copy — no aliasing (ch 3)
    def __len__(self):                     # len(pl)
        return len(self.songs)
    def __add__(self, other):              # pl + other  → a merged Playlist
        return Playlist(self.songs + other.songs)
    def __getitem__(self, i):             # pl[i] — and, for free, looping
        return self.songs[i]

night = Playlist([Song("Blinding Lights", "The Weeknd", 200)])
gym   = Playlist([Song("Titanium", "David Guetta ft. Sia", 245)])

print(len(night + gym))       # 2   — __add__ builds it, __len__ counts it
print((night + gym)[1])       # Song('Titanium', 'David Guetta ft. Sia', 245)
for song in night + gym:      # __getitem__ drives the loop — no __iter__ needed
    print(song.title)          # Blinding Lights  /  Titanium
print(Song("Blinding Lights", "The Weeknd", 200)
      == Song("Blinding Lights", "The Weeknd", 200))   # True — value, not identity
Wait — I defined __getitem__ but never wrote an iterator, so how did for song in night + gym run? Python has a fallback older than the iterator protocol: an object with no __iter__ is looped by calling __getitem__(0), __getitem__(1), counting upward until an IndexError signals the end. One socket, two powers — indexing and iteration.

Now zoom that same Playlist down one floor. A Song carries a seconds200 for "Blinding Lights" — but that duration is human metadata, a label stapled to the object. It is not the music. The music is a waveform: a long run of numbers, each one the position of the speaker cone at a single instant. In mono 16-bit PCM every sample is a signed 16-bit integer — -32768..32767, with 0 the cone at rest — and the array('h') type packs them two bytes apiece. At 44100 samples a second, those 200 seconds come to about 8.8 million samples. The array array('h', [0, 12000, 24000, 18000, -6000, -22000, -14000, 3000]) is one eight-sample sliver of them. Those bytes are the metal the playlist has been standing on all along. And a sample is an object like any other, so it too can own an __add__.

SYNTAX · dunders II — arithmetic and order__add__ and __lt__, the NotImplemented handshake, and the free sorted() that falls out
class Name: def __add__(self, other): — a + b → type(a).__add__(a, b) if isinstance(other, Name): return … a new object … — build and return; never mutate self return NotImplemented — declined → Python tries type(b).__radd__ def __lt__(self, other): — a < b, and everything built on it return self.key < other.key sorted(objects) · min(objects) · max(objects) — all three reach for __lt__ alone sorted(objects, key=lambda o: o.field) — or sort by a key, with no dunder at all (ch 10) @functools.total_ordering — define __eq__ and __lt__, get the other four free
__add__Runs on a + b. Return a new object and leave both operands alone — that is what everyone expects from +, and breaking it is a cruel surprise.
NotImplementedThe honest decline. It is not an error: Python takes it as "I don't handle this" and tries the right operand's __radd__ before giving up.
the TypeErrorOnly when both sides decline does Python raise unsupported operand type(s) for +. That message is the sound of two objects both saying "not mine."
__lt__One method, and sorted(), min() and max() all start working on your objects. They ask nothing else — ordering in Python is built on less than alone.
total_orderingWrite __eq__ and __lt__, add the decorator, and Python synthesises <=, >, >=. Four methods you never have to keep consistent by hand.
you type
class Song:
    def __init__(self, title, seconds):
        self.title, self.seconds = title, seconds

    def __repr__(self):
        return f"Song({self.title!r}, {self.seconds})"

    def __lt__(self, other):                  # one method, and sorted() starts working
        if not isinstance(other, Song):
            return NotImplemented
        return self.seconds < other.seconds

    def __add__(self, other):                 # song + song -> a Playlist
        if isinstance(other, Song):
            return Playlist([self, other])
        return NotImplemented                 # honest: "not mine, try yours"

class Playlist:
    def __init__(self, songs):
        self.songs = list(songs)

    def __repr__(self):
        return f"Playlist({self.songs!r})"

    def __len__(self):
        return len(self.songs)

    def __add__(self, other):
        return Playlist(self.songs + other.songs)

a = Song("Blinding Lights", 200)
b = Song("Titanium", 245)
c = Song("bad guy", 194)

print(a < b, b < a)
print(sorted([a, b, c]))
print(min([a, b, c]), "|", max([a, b, c]))
print(a + b, "|", len(a + b))
print(len(Playlist([a, b]) + Playlist([c])))
try:
    a + 5
except TypeError as e:
    print("TypeError:", e)
you see
True False
[Song('bad guy', 194), Song('Blinding Lights', 200), Song('Titanium', 245)]
Song('bad guy', 194) | Song('Titanium', 245)
Playlist([Song('Blinding Lights', 200), Song('Titanium', 245)]) | 2
3
TypeError: unsupported operand type(s) for +: 'Song' and 'int'
where beginners trip
  • Mutating self inside __add__ and returning it makes a + b silently change a. Build something new; that is what + promises.
  • Return False instead of NotImplemented and mixed-type comparisons break in ways that are miserable to trace — the other operand never gets its turn.
  • sorted() needs __lt__, not __eq__. A class with equality and no ordering raises TypeError: '<' not supported between instances of 'Song' and 'Song'.
  • You do not always need a dunder to sort. sorted(songs, key=lambda s: s.seconds) works on any object, and is the right call when the order is one report's idea of order, not the type's.
  • += falls back to __add__ and rebinds the name. Define __iadd__ only when you truly mean in-place mutation, and know it changes what the caller is holding.
The same +, one floor down: mixing two samples on the metalfigure
mix.pypython
from array import array

class Sample:                              # one 16-bit PCM sample = a cone position
    __slots__ = ("v",)                     # ch 11: no per-sample __dict__
    LO, HI = -32768, 32767              # the hard rails a 16-bit sample can reach
    def __init__(self, v):
        self.v = v
    def __add__(self, other):              # a + b  →  mix the two cones
        mixed = self.v + other.v            # Python int: grows freely, never overflows
        if   mixed > Sample.HI: mixed = Sample.HI   # but the metal saturates:
        elif mixed < Sample.LO: mixed = Sample.LO   # clip to the rail, don't wrap
        return Sample(mixed)
    def __repr__(self):
        return f"Sample({self.v})"

track = array('h', [0, 12000, 24000, 18000, -6000, -22000, -14000, 3000])
print(Sample(track[2]) + Sample(track[3]))   # 24000 + 18000 = 42000 → Sample(32767), clipped
print(Sample(track[0]) + Sample(track[4]))   # 0 + (-6000) = Sample(-6000), safely inside the rails
Fig 06c — Sample.__add__ is operator overloading doing genuine arithmetic — not a merge but a mix, and one bound by the machine's ceiling. 24000 + 18000 is 42000, which no 16-bit sample can hold, so it clips to the +32767 rail (with a matching -32768 at the floor) — the same saturation a real mixing desk performs when two loud tracks stack.
NOW WRITE IT YOURSELFteach a Track equality and order — then repair the hash you just broke
Start from a plain class: Track, holding title, artist and seconds, with a __repr__ that prints Track('bad guy', 194). Build four of them: a and b identical (Blinding Lights, The Weeknd, 200), plus Titanium at 245 and bad guy at 194. Four jobs, in this order, running the proofs as you go. One — equality by value. Right now a == b is False, because == without __eq__ compares identity. Write __eq__ so two tracks are equal when title and artist match, and return NotImplemented for anything that is not a Track. Prove it: a == b is True, a is b is still False, and a == "Blinding Lights" comes back a clean False rather than an exception. Two — repair the hash. Now put all four in a set. It raises TypeError: unhashable type, because defining __eq__ set __hash__ to None. Fix it by hashing the same fields you compared, then show len({a, b, c}) is 2 and that {a: "night drive"}[b] finds the entry. Three — order. Write __lt__ so tracks sort by running time, with the title breaking ties, and check that sorted(), min() and max() all come alive from that one method. Four — the other four comparisons. Add @total_ordering from functools and confirm >= and <= now work without another line. One hint: a tuple compares element by element, so (self.seconds, self.title) < (other.seconds, other.title) is the whole of job three.
show the solution
from functools import total_ordering

@total_ordering
class Track:
    def __init__(self, title, artist, seconds):
        self.title, self.artist, self.seconds = title, artist, seconds

    def __repr__(self):
        return f"Track({self.title!r}, {self.seconds})"

    def __eq__(self, other):
        if not isinstance(other, Track):
            return NotImplemented
        return (self.title, self.artist) == (other.title, other.artist)

    def __hash__(self):                       # __eq__ took the free one away
        return hash((self.title, self.artist))

    def __lt__(self, other):
        if not isinstance(other, Track):
            return NotImplemented
        return (self.seconds, self.title) < (other.seconds, other.title)

a = Track("Blinding Lights", "The Weeknd", 200)
b = Track("Blinding Lights", "The Weeknd", 200)
c = Track("Titanium", "David Guetta", 245)
d = Track("bad guy", "Billie Eilish", 194)

print("value equality :", a == b, "| identity:", a is b)
print("a != c         :", a != c)
print("sorted         :", sorted([a, c, d]))
print("min / max      :", min([a, c, d]), "|", max([a, c, d]))
print("total_ordering :", a < c, c >= a, d <= a)
print("set dedupes    :", len({a, b, c}))
print("foreign type   :", a == "Blinding Lights")
print("dict key       :", {a: "night drive"}[b])

# ------------- what it prints -------------
value equality : True | identity: False
a != c         : True
sorted         : [Track('bad guy', 194), Track('Blinding Lights', 200), Track('Titanium', 245)]
min / max      : Track('bad guy', 194) | Track('Titanium', 245)
total_ordering : True True True
set dedupes    : 2
foreign type   : False
dict key       : night drive
Step through an operation — watch the sugar melt into a method call▸ drag me
every operator and built-in is sugar over a method call · step through six of them YOU WRITE PYTHON RUNS YOU GET BACK len(night) night.__len__() 1
1 / 6
Slide through the six. Notice the middle line is always a plain method call on an object — the top line is only ever a prettier way to type it. That gap is the whole idea of a dunder.
Fig 06b — len, [ ], +, ==, print, and a property, each desugared to the exact method Python dispatches — with the real value it returns.
Clip, don't wrap — the sample carries a ceiling the playlist never did
Python's own int never overflows (chapter 0): 24000 + 18000 is simply 42000, free to keep growing. But a sample is 16 bits with fixed rails, so a mix that overshoots must saturate — pin to the nearest rail — rather than wrap around to some huge negative, which would fire as a violent click in the speaker. That is why Sample.__add__ tests the bound by hand before returning: here operator overloading isn't decorative sugar, it's the type enforcing its own physics. Merging two playlists can never fail this way — mixing two samples can, and that is exactly why + on the metal has to earn its meaning.

Two refinements turn a workable class into a graceful one. The first is @property, and by the rewrite rule you just met it is plain. @property over def minutes runs minutes = property(minutes), storing a property object on the class where the method's name used to be. That property object is a data descriptor: it carries a __get__ (and, once you add a setter, a __set__). That one fact is the whole trick. When you write bl.minutes, section 03's lookup runs, but a data descriptor on the class outranks the instance's own __dict__ — the single refinement to the instance-first rule. Python finds minutes on the class and calls type(bl).__dict__['minutes'].__get__(bl, Song), which runs your getter and hands back its value, recomputed fresh on every access. So minutes is derived from _seconds and can never drift out of sync with it. It was never stored. It is answered on demand. Add a matching @minutes.setter (again just minutes = minutes.setter(func)) and the descriptor's __set__ fires on bl.minutes = 5. That's the perfect place to reject a negative running time before it can corrupt your data.

SYNTAX · @property — a method that reads like datathe getter, the setter that validates, the read-only value — and why not get_/set_
class Name: def __init__(self, seconds): self.seconds = seconds — this already goes through the setter @property def seconds(self): — the getter. Read as obj.seconds, no ( ) return self._seconds @seconds.setter def seconds(self, value): — runs on every obj.seconds = … if value < 0: raise ValueError() — refuse the bad value at the door self._seconds = value @property def minutes(self): — no setter → read-only, computed fresh each time return round(self._seconds / 60, 2)
the getter@property over def minutes is just minutes = property(minutes). It stores a property object on the class where the method's name used to be.
computed, not storedbl.minutes runs the body on every read. It is derived from _seconds, so it can never drift out of sync — there is no second copy to go stale.
the setter@seconds.setter is seconds = seconds.setter(func). It fires on assignment, which is the one place a guard can stop a bad value before it lands.
read-onlyLeave the setter off and assignment raises AttributeError: property 'minutes' of 'Song' object has no setter. Published, readable, and frozen — in three lines.
why not get_/set_Because the call site never has to change. Start with a plain self.seconds; the day it needs validating, turn it into a property and every bl.seconds already written keeps working.
you type
class Song:
    def __init__(self, title, seconds):
        self.title = title
        self.seconds = seconds              # this goes through the setter below

    @property
    def seconds(self):                      # the getter: reads like plain data
        return self._seconds

    @seconds.setter
    def seconds(self, value):               # runs on every assignment
        if value < 0:
            raise ValueError("a song cannot run negative time")
        self._seconds = value

    @property
    def minutes(self):                      # computed on demand, never stored
        return round(self._seconds / 60, 2)

bl = Song("Blinding Lights", 200)
print(bl.seconds, bl.minutes)               # no parentheses at the call site
bl.seconds = 245
print(bl.seconds, bl.minutes)               # recomputed -- it cannot drift
print(bl.__dict__)                          # only _seconds was ever stored
try:
    bl.seconds = -1
except ValueError as e:
    print("ValueError:", e)
try:
    bl.minutes = 5
except AttributeError as e:
    print("AttributeError:", e)
print(type(Song.__dict__["minutes"]).__name__)
you see
200 3.33
245 4.08
{'title': 'Blinding Lights', '_seconds': 245}
ValueError: a song cannot run negative time
AttributeError: property 'minutes' of 'Song' object has no setter
property
where beginners trip
  • The getter and the underlying field must have different names. self.seconds backed by self.seconds calls the setter from inside the setter, forever, until RecursionError.
  • The setter's def reuses the property's name: @seconds.setter above def seconds(self, value). Rename it and you get two unrelated attributes.
  • A property is a data descriptor, so it outranks the instance's own __dict__ — the one refinement to section 03's instance-first rule. You cannot shadow it by assigning.
  • Properties belong on the class, never on an instance. Setting bl.minutes = property(…) stores a plain object; the protocol only fires for class attributes.
  • Do not hide expensive work behind a property. Readers expect obj.x to be cheap; if it queries a database, make it a method with parentheses and be honest about the cost.

The second refinement is that leading underscore in _seconds. That single character is Python's whole notion of encapsulation: a naming convention that whispers "internal — reach me through the property, not directly." Nothing stops you from touching bl._seconds. The underscore is a handshake between programmers, enforced by politeness, not by the interpreter. And that sets up the single biggest misconception in the language. A great many people believe a second underscore upgrades that handshake into a lock. It does not.

property.pypython
class Song:
    def __init__(self, title, seconds):
        self.title = title
        self._seconds = seconds        # _ = "internal"; go through the property

    @property
    def minutes(self):                 # a method that READS like an attribute
        return self._seconds / 60

    @minutes.setter
    def minutes(self, value):          # runs on:  bl.minutes = ...
        if value < 0:
            raise ValueError("a song can't run negative time")
        self._seconds = value * 60

bl = Song("Blinding Lights", 200)
print(bl.minutes)      # 3.3333333333333335  — no parentheses, computed now
bl.minutes = 5         # goes through the setter, which validates first
print(bl._seconds)     # 300  — the setter wrote it back in seconds
A property with no setter is read-only
Leave off the @minutes.setter and the line bl.minutes = 5 raises AttributeError: property 'minutes' of 'Song' object has no setter. That's the cleanest way to publish a value the outside world can read but never write — computed, exposed, and frozen, all at once.

Now the lock that isn't. Try to make an attribute truly private and you meet name mangling, the trick nearly everyone misreads as access control. Give an attribute two leading underscores inside a class, and the compiler quietly rewrites the name. Inside class BankAccount, self.__balance is stored under the mangled name _BankAccount__balance. The double underscore was never a keep-out sign. It's a rename, invented so a subclass can't accidentally clobber a base class's __balance with one of its own. Step through what that actually does when someone pokes at it from outside:

"Making it private" — step through what really happens▸ drag me
YOU TYPE inside the class: self.__balance = 100 acct · a BankAccount object _BankAccount__balance 100 __balance ← brand-new, unrelated attribute -100 ✗ no attribute is named __balance RESULT stored under the mangled name _BankAccount__balance
1 / 4
Watch the real slot (gold) never move. A read of __balance from outside can't find it; a write doesn't touch it either — it just mints a red impostor beside it. There is no locked door here, only a rename.
Fig 06c — "Private" in Python is a naming convention plus a compiler rename — never an access check. The real balance sits untouched under its mangled name the entire time.
mangling.pypython
class BankAccount:
    def __init__(self):
        self.__balance = 100            # actually stored as _BankAccount__balance

acct = BankAccount()
# acct.__balance                       → AttributeError: no such name exists
print(acct._BankAccount__balance)     # 100   — the mangled name is the real one
acct.__balance = -100                  # NO error — creates a brand-new attribute
print(acct._BankAccount__balance)     # 100   — the real balance never moved
print(acct.__balance)                 # -100  — the impostor you just created

✗ The myth

A double underscore makes an attribute private, so acct.__balance = -100 from outside the class is blocked — an error, a locked door, tampering refused.

✓ The reality

Python has no private-access error. Reading acct.__balance fails only because nothing literally carries that name; writing it silently mints a new, unrelated attribute while the real balance rests safe under its mangled name. A rename, not a lock — privacy enforced by convention, not the interpreter.

Define __repr__ before __str__
Aim __repr__ at the programmer: unambiguous, and ideally something you could paste back into the shell to rebuild the object — which is exactly why {self.title!r} keeps the quotes. Add __str__ only when end users need something prettier. And remember that containers always use __repr__: a list of Songs prints each element's repr even if you gave the class a lovely __str__.
↺ reframe
Stop picturing +, len(), [ ], and == as operators that act on your object from the outside. They're method calls your object answers from the inside. The language isn't a fixed list of things you're permitted to do to data — it's a set of questions, and every dunder you define is your object volunteering an answer.
The deeper cut — four edges that bite in real code

One — define __eq__ and you silently lose __hash__. The instant you write __eq__, Python sets your class's __hash__ to None, and instances turn unhashable. A set literal or a dict key then raises TypeError: unhashable type (chapter 7). The fix is to hash the same fields you compared: def __hash__(self): return hash((self.title, self.artist)), so equal songs hash alike and dedupe correctly inside a set.

Two — return NotImplemented, never False, for a foreign type. When other isn't a Song, our __eq__ hands back the special NotImplemented sentinel. That isn't a failure. It's Python's way of saying "I don't handle this one, let the other operand try." Python then calls other.__eq__(self), the reflected dispatch that makes mixed-type comparisons work. Return a flat False and you'd slam that door shut.

Three — there is no method overloading. Write two def play(self) with different arguments in one class and the second simply replaces the first. A class body just binds names top to bottom, last one wins, so calling the old signature raises TypeError. Want two calling styles? Reach for default or *args parameters (chapter 9), not a second def.

Four — one __lt__ can buy all six comparisons. Decorate the class with @functools.total_ordering and define just __eq__ and __lt__. Python then synthesises <=, >, >=, and != from those two. That single __lt__ is also exactly what sorted(playlist.songs) reaches for. Teach a Song to be less than another and your whole library sorts itself.

Your Playlist now speaks the whole language — +, len(), [ ], ==, sorting, printing. But hand one of its methods a value it never expected and the program still dies mid-song, spilling a traceback down the screen. Next chapter, errors & robustness: how Python raises the alarm when something breaks, and how to catch a failure in flight instead of letting it take the whole playlist down with it. →

SAY IT BACKthe chapter in five breaths
  1. A class puts data and behaviour in one place: the blueprint is written once, and every Song(…) stamps an object onto the heap with its own drawer of data and its own kind.
  2. self is just the first parameterblinding.play() is Song.play(blinding), the object left of the dot shipped in as argument number one.
  3. Every dotted read runs one fixed climb — this object's drawer first, then the class, first hit wins — and a plain write never climbs: it lands in the object's own drawer (until section 06's @property steps in front of it).
  4. Inheritance copies nothing: a child stores only what's new, every call walks the MRO fresh at that moment — so the object, never the caller, picks which play() runs.
  5. A dunder is a named socket into the syntax — wire up __add__, __len__, __eq__ and your type speaks +, len() and == — and "privacy" is a rename plus a handshake, never a lock.
You already owned the pieces: chapter 3 gave you the heap and references every object here still lives by; chapter 7 gave you the dict that became each instance's __dict__ drawer; chapter 9 gave you functions — a method is one with the instance as its first argument; chapters 10 and 11 gave you the comprehensions, sorted() and __slots__ the Playlist's methods are made of. This chapter didn't hand you new parts — it moved them in together.
TYPE THIS · the chapter's capstone — save it, then run itplaylist.py — a Playlist HAS Songs, and speaks len(), [ ], +, for and print
# playlist.py -- the capstone. A Playlist HAS Songs, and speaks Python's own language.

class Song:
    def __init__(self, title, artist, seconds):
        self.title = title
        self.artist = artist
        self.seconds = seconds

    def __repr__(self):
        return f"Song({self.title!r}, {self.artist!r}, {self.seconds})"

    def __str__(self):
        minutes, secs = divmod(self.seconds, 60)
        return f"{self.title} - {self.artist} ({minutes}:{secs:02d})"


class Playlist:
    def __init__(self, name, songs=None):
        self.name = name
        self.songs = list(songs) if songs else []      # our own list, never the caller's

    # --- behaviour, built from chapters 6 to 10 ---
    def add(self, song):
        self.songs.append(song)                        # ch 6
        return self                                    # so calls can chain

    def remove(self, title):
        before = len(self.songs)
        self.songs = [s for s in self.songs if s.title != title]    # ch 10
        return before - len(self.songs)

    def total_seconds(self):
        return sum(s.seconds for s in self.songs)      # ch 10

    def longest(self):
        return max(self.songs, key=lambda s: s.seconds) if self.songs else None

    def by_artist(self):
        index = {}
        for s in self.songs:
            index.setdefault(s.artist, []).append(s.title)          # ch 7
        return index

    def sorted_by_length(self):
        return sorted(self.songs, key=lambda s: s.seconds)          # ch 10

    # --- the sockets, from this chapter ---
    def __len__(self):
        return len(self.songs)

    def __getitem__(self, i):
        return self.songs[i]

    def __add__(self, other):
        return Playlist(f"{self.name} + {other.name}", self.songs + other.songs)

    def __str__(self):
        minutes, secs = divmod(self.total_seconds(), 60)
        lines = [f"{self.name} - {len(self)} songs - {minutes}:{secs:02d}"]
        lines += [f"  {i}. {song}" for i, song in enumerate(self.songs, 1)]
        return "\n".join(lines)


night = Playlist("Night drive")
night.add(Song("Blinding Lights", "The Weeknd", 200))
night.add(Song("Titanium", "David Guetta", 245))
night.add(Song("Levels", "Avicii", 203))

print(night)
print()
print("len(night)         ->", len(night))
print("night[0]           ->", night[0])
print("total_seconds()    ->", night.total_seconds())
print("longest()          ->", night.longest())
print("sorted_by_length() ->", [s.title for s in night.sorted_by_length()])
print("by_artist()        ->", night.by_artist())
print("remove('Levels')   ->", night.remove("Levels"), "removed,", len(night), "left")

gym = Playlist("Gym", [Song("Uptown Funk", "Mark Ronson", 270)])
both = night + gym
print()
print(both)
print("looping the object ->", [s.title for s in both])
Night drive - 3 songs - 10:48
  1. Blinding Lights - The Weeknd (3:20)
  2. Titanium - David Guetta (4:05)
  3. Levels - Avicii (3:23)

len(night)         -> 3
night[0]           -> Blinding Lights - The Weeknd (3:20)
total_seconds()    -> 648
longest()          -> Titanium - David Guetta (4:05)
sorted_by_length() -> ['Blinding Lights', 'Levels', 'Titanium']
by_artist()        -> {'The Weeknd': ['Blinding Lights'], 'David Guetta': ['Titanium'], 'Avicii': ['Levels']}
remove('Levels')   -> 1 removed, 2 left

Night drive + Gym - 3 songs - 11:55
  1. Blinding Lights - The Weeknd (3:20)
  2. Titanium - David Guetta (4:05)
  3. Uptown Funk - Mark Ronson (4:30)
looping the object -> ['Blinding Lights', 'Titanium', 'Uptown Funk']
Save it as playlist.py and run it. This is the chapter's capstone, and every thread of the course arrives in it at once. The design decision is the first line of the class: a Playlist HAS songs, so they live in a field, and it is emphatically not a subclass of Song or of list. Then look at what fills the methods — nothing new, all of it yours already. add is .append from chapter 6; remove is a list comprehension with a condition, chapter 10; total_seconds is a generator expression inside sum; longest is max with a key= lambda; by_artist is dict.setdefault from chapter 7; sorted_by_length is sorted(key=…). The only genuinely new thing this chapter added is where those lines now live: inside an object that owns the data they run on. Then the sockets. __len__ makes len(night) work; __getitem__ makes night[0] work and, for free, makes for song in both loop — no __iter__ anywhere; __add__ merges two playlists into a third, naming it as it goes; and __str__ builds the whole multi-line listing with enumerate and "\n".join. Notice add returns self, which is why calls can chain. Notice too that __init__ does list(songs) rather than storing the caller's list — chapter 3's aliasing, refused on purpose. Three things to try. Add a shuffle() using random.sample that returns a new Playlist and leaves this one in order. Give Song a __lt__ on seconds and watch sorted(night) start working with no key at all. Then add a Podcast class with its own __str__, drop one into the songs list, and see how much of this still runs — and exactly which line is the first to complain.
Wait — you've been calling dunders since chapter 0. 200 + 245 is really (200).__add__(245)445; "a" + "b" is str.__add__. Even a bare integer is an object answering a method — the + between two numbers is the same socket you wire up on Playlist, just soldered in at the factory. Overloading + isn't inventing a new power; it's joining a club int was already in. And that socket can hold arithmetic genuinely unlike int's. Wrap a single 8-bit value (chapter 0's eight switches, holding 0–255) in a Byte and give it def __add__(self, other): return Byte((self.v + other.v) % 256). Now Byte(200) + Byte(245) does not give 445 — a byte cannot hold 445 — it wraps to Byte(189), the exact overflow real hardware performs. Same +, same socket, but you taught it byte arithmetic instead of adding seconds.
DEFINE __eq__, LOSE YOUR HASH
The instant you write __eq__, Python quietly sets __hash__ to None and your objects turn unhashable{song} or using one as a dict key then raises TypeError: unhashable type. The one-line cure is to hash the same fields you compared: def __hash__(self): return hash((self.title, self.artist)) — so equal songs hash alike and dedupe correctly inside a set.
↺ the ugly default is an old friend
Print a Song with no __repr__ and Python shrugs out <__main__.Song object at 0x1d8…>. That hex tail isn't noise — it is id(song) from chapter 3, written in base 16: the object's address on the heap. Python isn't hiding behind gibberish; it's telling you the one true thing it knows — where the object lives — because you haven't yet taught it anything friendlier to say.
Trace3 step the machine — idea · code · memory move together
Every operator is sugar — a + b is really type(a).__add__(a, b)
The + is not an operation on your objects — it is a request routed to a method. Python looks up __add__ on the type of the left operand, then calls it with both operands as plain arguments. This trace follows the case where the left operand answers. When it instead returns NotImplemented (the "not mine, you try" sentinel from the deeper cut), the fork you can't see here fires: Python turns to the right operand and calls type(b).__radd__(b, a) — the reflected dispatch — and only if that also declines does it raise TypeError. Press Next and watch a bare + desugar, dispatch, and return.
operator → dunderlookup on type(a)left operand leads
Operator → dunder dispatcha + b → type(a).__add__(a,b)expression
a+b
desugars
to
type(a) · class Playlist
len(a)__len__
a + b__add__
a == b__eq__
print(a)__repr__
the real call
a + b evaluates to
In plain words
Under the hood
OPALU · the operation
Program · dunder_dispatch.py
variables · type
Memory · dispatch state
registers — operands & the resolved method
merged Playlist · the object __add__ returns
what's happening
beat 1 / 1
Trace3 step the machine — idea · code · memory move together
Every + gets a second chance — __radd__, the reflected fork
5 + Money(3) works even though int has never heard of Money. Python asks the left operand first; when int.__add__ returns the sentinel NotImplemented — a signal, not an error — it forks to the right operand's __radd__. Only if both decline is TypeError raised.
2 chancessentinel NotImplementedthen TypeError
a + b has a second chance — the dispatch forkstart
5 + Money(3)
LEFT operand · 5
int.__add__(5, m)
the fork is idle
RIGHT operand · Money(3)
Money.__radd__(m, 5)
result: pending
In plain words
Under the hood
OPALU · the dispatch
Program · money.py
Memory · dispatch outcomes
two chances, then a verdict
call Python is trying now
what's happening
beat 1 / 1
PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 12, in working code

Chapter 12: an object bundles data together with the functions that act on it. Press Next and watch instances take shape, methods mutate their own self, and Python's dunder hooks quietly wire your classes into +, ==, len() and print.

a class is a blueprint
Define once with __init__; stamp out as many independent instances as you like.
objects that hold state
Methods read and mutate self; a @property computes a value on demand.
inheritance & polymorphism
Children reuse parents via super(), override to specialise, and answer the same call in their own way.
dunder methods — hooking into syntax
Special __methods__ let your objects work with print, ==, + and len().
class-level tools
Alternate constructors, namespaced helpers, and boilerplate-free data holders.
end of chapter 12 · six sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked