Skip to content

My bank opened the same account twice

  • Python · Core
  • 35 min read, about 1 h 50 min with practice
  • Concept
  • Asked often: high
  • Reviewed 2026-11

You build accounts for a small bank. An Account has an owner, a balance and a log. A Savings account adds interest. An Audited account writes “audit on” to its log. The bank asks for an account that is both, so you write:

class AuditedSavings(Savings, Audited):
def __init__(self, owner, balance=0, rate=0.02):
Savings.__init__(self, owner, balance, rate)
Audited.__init__(self, owner, balance)

Every new account is added to the bank’s list, OPENED. You open one for Ana:

>>> AuditedSavings("ana", 100)
>>> OPENED
['ana', 'ana']

The bank now thinks Ana has two accounts. Where did the second one come from?

  • Input: an owner, a starting balance, and for savings, an interest rate.
  • Output: account objects. Savings and Audited reuse Account’s code. AuditedSavings is both, and opening one adds the owner to OPENED once.
  • Also: an account prints as Account('ana', 100), and two accounts with the same type, owner and balance are equal.
  • The hard part: with two parents that share a grandparent, each __init__ must still run exactly once.

Example: AuditedSavings("ana", 100) leaves OPENED == ["ana"] and log == ["open ana", "audit on"].

Hints open one at a time:

Hint 1 of 3

Each parent calls Account.__init__ itself, so it runs once per parent: twice.

Hint 2 of 3

Python works out one order for all the classes, called the MRO. Look at AuditedSavings.__mro__.

Hint 3 of 3

super() calls the next class in that order, not always the parent you wrote. So each class runs once.

A class is a template for objects. __init__ runs when you make one and sets its attributes. Methods are functions that get the object as self.

class Account:
def __init__(self, owner, balance=0):
self.owner = owner # an attribute of this object
self.balance = balance
def deposit(self, amount): # a method: a.deposit(5) passes a as self
self.balance += amount

Inheritance reuses a class. class Savings(Account): gets every method of Account, and can add or replace some. isinstance(s, Account) is true for a Savings.

The MRO (method resolution order) is the order Python searches classes for a method. Every class has one: AuditedSavings.__mro__. Python builds it so each class comes before its parents, and every class appears once:

PositionClassIts __init__ calls…
1AuditedSavingsnone of its own: Python uses the next one
2Savingssuper().__init__ → the next class in this MRO
3Auditedsuper().__init__ → the next one
4Accountsets owner, balance and log, and adds to OPENED
5objectPython’s base for every class

super() means “the next class in the MRO of this object”. In a plain Savings, that’s Account. In an AuditedSavings, it’s Audited. That’s how one chain of calls visits every class once.

Dunder methods (“double underscore”) let your objects work with Python’s own syntax: __repr__ for how it prints, __eq__ for ==, __len__ for len(), __add__ for +, __contains__ for in, and __iter__ for for.

Every rung makes an account in O(1). What changes is how much code is repeated, and how many times the shared parent runs.

Okay

Copy the class

The idea: Write Savings as a full copy of Account, plus interest.

On Ana’s account: Savings("ana", 100) works, and add_interest() adds 2.

OPENED: list[str] = [] # every account the bank has opened
class Account:
def __init__(self, owner: str, balance: int = 0) -> None:
self.owner = owner
self.balance = balance
self.log = [f"open {owner}"]
OPENED.append(owner)
def deposit(self, amount: int) -> None:
self.balance += amount
self.log.append(f"deposit {amount}")
class Savings: # a copy of Account, plus interest
def __init__(self, owner: str, balance: int = 0, rate: float = 0.02) -> None:
self.owner = owner
self.balance = balance
self.log = [f"open {owner}"]
OPENED.append(owner)
self.rate = rate
def deposit(self, amount: int) -> None:
self.balance += amount
self.log.append(f"deposit {amount}")
def add_interest(self) -> None:
self.deposit(round(self.balance * self.rate))
Time
O(1)
Space
O(1)
Why
Each account holds a few attributes.

Where it breaks: deposit lives in two places, so a fix in one misses the other. A Savings isn’t an Account: isinstance says no, so code that takes any account rejects it. And it prints as <Savings object at 0x…>.

Better

Inherit, and call the parent by name

The idea: class Savings(Account) reuses the code, and Account.__init__(self, …) sets up the shared part.

The waste in Okay: the same methods written twice.

One parent works perfectly. Savings("bo", 50) runs Account.__init__ once, and isinstance(s, Account) is true.

Highlighted lines are new in this rung.

OPENED: list[str] = [] # every account the bank has opened
class Account:
def __init__(self, owner: str, balance: int = 0) -> None:
self.owner = owner
self.balance = balance
self.log = [f"open {owner}"]
OPENED.append(owner)
def deposit(self, amount: int) -> None:
self.balance += amount
self.log.append(f"deposit {amount}")
class Savings(Account):
def __init__(self, owner: str, balance: int = 0, rate: float = 0.02) -> None:
Account.__init__(self, owner, balance)
self.rate = rate
def add_interest(self) -> None:
self.deposit(round(self.balance * self.rate))
class Audited(Account):
def __init__(self, owner: str, balance: int = 0) -> None:
Account.__init__(self, owner, balance)
self.log.append("audit on")
class AuditedSavings(Savings, Audited):
def __init__(self, owner: str, balance: int = 0, rate: float = 0.02) -> None:
Savings.__init__(self, owner, balance, rate)
Audited.__init__(self, owner, balance)
Time
O(1)
Space
O(1)
Why
The same attributes; less code.
What changed from the previous rung
OPENED: list[str] = [] # every account the bank has opened
class Account:
def __init__(self, owner: str, balance: int = 0) -> None:
self.owner = owner
self.balance = balance
self.log = [f"open {owner}"]
OPENED.append(owner)
def deposit(self, amount: int) -> None:
self.balance += amount
self.log.append(f"deposit {amount}")
class Savings: # a copy of Account, plus interest
class Savings(Account):
def __init__(self, owner: str, balance: int = 0, rate: float = 0.02) -> None:
self.owner = owner
self.balance = balance
self.log = [f"open {owner}"]
OPENED.append(owner)
Account.__init__(self, owner, balance)
self.rate = rate
def deposit(self, amount: int) -> None:
self.balance += amount
self.log.append(f"deposit {amount}")
def add_interest(self) -> None:
self.deposit(round(self.balance * self.rate))
class Audited(Account):
def __init__(self, owner: str, balance: int = 0) -> None:
Account.__init__(self, owner, balance)
self.log.append("audit on")
class AuditedSavings(Savings, Audited):
def __init__(self, owner: str, balance: int = 0, rate: float = 0.02) -> None:
Savings.__init__(self, owner, balance, rate)
Audited.__init__(self, owner, balance)

Where it breaks: with two parents. AuditedSavings calls Savings.__init__ and Audited.__init__, and each calls Account.__init__. It runs twice, so OPENED holds Ana twice. Any setup in Account is done, then undone and done again.

Best

super(), __repr__ and __eq__

The idea: Each __init__ calls super().__init__, so Python visits every class once in MRO order; add __repr__ and __eq__.

The waste in Better: each class decides who comes next, when Python already knows the right order.

On Ana’s account: Savings.__init__ calls super(), which is Audited for this object. Audited calls super(), which is Account. So Account runs once, then “audit on” is added, then the rate is set. AuditedSavings needs no __init__ at all.

__repr__ uses type(self).__name__, so every subclass prints its own name. __eq__ returns NotImplemented for things that aren’t accounts, so Python can try the other side.

Highlighted lines are new in this rung.

OPENED: list[str] = [] # every account the bank has opened
class Account:
def __init__(self, owner: str, balance: int = 0) -> None:
self.owner = owner
self.balance = balance
self.log = [f"open {owner}"]
OPENED.append(owner)
def deposit(self, amount: int) -> None:
self.balance += amount
self.log.append(f"deposit {amount}")
def __repr__(self) -> str:
return f"{type(self).__name__}({self.owner!r}, {self.balance})"
def __eq__(self, other: object) -> bool:
if not isinstance(other, Account):
return NotImplemented
return (type(self), self.owner, self.balance) == (type(other), other.owner, other.balance)
class Savings(Account):
def __init__(self, owner: str, balance: int = 0, rate: float = 0.02) -> None:
super().__init__(owner, balance)
self.rate = rate
def add_interest(self) -> None:
self.deposit(round(self.balance * self.rate))
class Audited(Account):
def __init__(self, owner: str, balance: int = 0) -> None:
super().__init__(owner, balance)
self.log.append("audit on")
class AuditedSavings(Savings, Audited):
pass # Python runs Savings, then Audited, then Account: each __init__ once
Time
O(1)
Space
O(1)
Why
Each __init__ runs once.
What changed from the previous rung
OPENED: list[str] = [] # every account the bank has opened
class Account:
def __init__(self, owner: str, balance: int = 0) -> None:
self.owner = owner
self.balance = balance
self.log = [f"open {owner}"]
OPENED.append(owner)
def deposit(self, amount: int) -> None:
self.balance += amount
self.log.append(f"deposit {amount}")
def __repr__(self) -> str:
return f"{type(self).__name__}({self.owner!r}, {self.balance})"
def __eq__(self, other: object) -> bool:
if not isinstance(other, Account):
return NotImplemented
return (type(self), self.owner, self.balance) == (type(other), other.owner, other.balance)
class Savings(Account):
def __init__(self, owner: str, balance: int = 0, rate: float = 0.02) -> None:
Account.__init__(self, owner, balance)
super().__init__(owner, balance)
self.rate = rate
def add_interest(self) -> None:
self.deposit(round(self.balance * self.rate))
class Audited(Account):
def __init__(self, owner: str, balance: int = 0) -> None:
Account.__init__(self, owner, balance)
super().__init__(owner, balance)
self.log.append("audit on")
class AuditedSavings(Savings, Audited):
def __init__(self, owner: str, balance: int = 0, rate: float = 0.02) -> None:
Savings.__init__(self, owner, balance, rate)
Audited.__init__(self, owner, balance)
pass # Python runs Savings, then Audited, then Account: each __init__ once

When Best isn’t best: deep class trees are hard to follow. If Audited only adds logging, a small helper object that the account holds is often clearer than another parent. Prefer composition, and keep inheritance shallow.

The starter is the Better rung. Change it so that opening an AuditedSavings adds the owner to OPENED once. Then add __repr__ and __eq__ to Account. The tests make objects and then check them.

Open each account once

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: AuditedSavings("ana", 100); OPENED → ['ana']
  2. Not run yet: AuditedSavings("ana", 100).log → ['open ana', 'audit on']
  3. Not run yet: AuditedSavings("ana", 100, rate=0.05).rate → 0.05
  4. Not run yet: repr(Account("ana", 100)) → "Account('ana', 100)"
  5. Not run yet: Account("ana", 100) == Account("ana", 100) → True
  6. Not run yet: Extra test
  7. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
WayRepeated codeisinstance(s, Account)Two parentsUse it when
Okay: copiesAll of itNoNot possibleNever
Better: Parent.__init__NoneYesShared parent runs twiceOne parent only, in old code
Best: super()NoneYesEach class onceAlways
  • __eq__ without __hash__: defining __eq__ makes objects unhashable, so they can’t go in a set or be dict keys. Add __hash__ if they never change, or use a frozen dataclass.
  • Return NotImplemented, don’t raise: from __eq__ or __add__ when the other value is a type you don’t handle. Python then tries the other side, or gives a clear TypeError.
  • Class attributes are shared: class A: items = [] is one list for every object. Set lists in __init__.
  • Different arguments in a chain: when parents take different arguments, pass the extras along with **kwargs so each super() call can take its own.
  • __repr__ or __str__: __repr__ is for developers and should look like the code that makes the object. __str__ is for users; print() uses it and falls back to __repr__.

Questions to ask first

  • Is this “is a” (inheritance) or “has a” (composition)?
  • Will objects be compared, sorted, or used as dict keys?

A 60-second walkthrough

“The bug is calling Account.__init__ by name from both parents. With multiple inheritance, Python builds one method resolution order, here AuditedSavings, Savings, Audited, Account, object. super() doesn’t mean ‘my parent’: it means the next class in the MRO of this object. So if every class calls super().__init__, each runs exactly once. I’d also add __repr__ for debugging and __eq__ that returns NotImplemented for other types.”

Likely follow-ups

  • How is the MRO built? With the C3 linearization: each class comes before its parents, and the order of the parents in the class line is kept.
  • Composition or inheritance? Inheritance for a true “is a” with shared behaviour. Composition, holding another object, for everything else; it is easier to change.
  • @classmethod or @staticmethod? The next lesson covers both.

Three problems, from easy to hard. Each has hints, tests you can run here, and its own Okay, Better and Best answers. Try before you open them.

Write a Money class that holds cents and a currency. Two Money objects with the same cents and currency are equal. + adds two amounts of the same currency, and raises ValueError("can't add USD to EUR") for different ones. Adding anything that isn’t Money is a TypeError.

Hint 1 of 3

Without __eq__, == checks whether two names point to the same object.

Hint 2 of 3

__add__(self, other) runs for self + other. Return a new Money.

Hint 3 of 3

Return NotImplemented for other types; Python then raises the TypeError for you.

Money that adds up

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: Money(5, "EUR") == Money(5, "EUR") → True
  2. Not run yet: Money(1, "EUR") + Money(2, "EUR") → "Money(3, 'EUR')"
  3. Not run yet: Money(1, "EUR") + Money(2, "USD") → raises ValueError
  4. Not run yet: Extra test
  5. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Only __repr__

The idea: Store the two values and print them nicely.

Money(5, 'EUR') prints well.

class Money:
def __init__(self, cents: int, currency: str) -> None:
self.cents = cents
self.currency = currency
def __repr__(self) -> str:
return f"Money({self.cents}, {self.currency!r})"
Time
O(1)
Space
O(1)
Why
Two attributes.

Where it breaks: Money(5, "EUR") == Money(5, "EUR") is False: without __eq__, == asks whether they are the same object. And + is a TypeError.

Better

__eq__ and __add__

The idea: Compare cents and currency, and add the cents.

The waste in Okay: the object can’t take part in == or +.

Highlighted lines are new in this rung.

class Money:
def __init__(self, cents: int, currency: str) -> None:
self.cents = cents
self.currency = currency
def __repr__(self) -> str:
return f"Money({self.cents}, {self.currency!r})"
def __eq__(self, other: object) -> bool:
if not isinstance(other, Money):
return NotImplemented
return (self.cents, self.currency) == (other.cents, other.currency)
def __add__(self, other: "Money") -> "Money":
return Money(self.cents + other.cents, self.currency)
Time
O(1)
Space
O(1)
Why
One sum.
What changed from the previous rung
class Money:
def __init__(self, cents: int, currency: str) -> None:
self.cents = cents
self.currency = currency
def __repr__(self) -> str:
return f"Money({self.cents}, {self.currency!r})"
def __eq__(self, other: object) -> bool:
if not isinstance(other, Money):
return NotImplemented
return (self.cents, self.currency) == (other.cents, other.currency)
def __add__(self, other: "Money") -> "Money":
return Money(self.cents + other.cents, self.currency)

Where it breaks: 1 EUR + 2 USD gives 3 EUR, without a word.

Best

Check the currency and the type

The idea: Raise ValueError for a different currency; return NotImplemented for other types.

The waste in Better: it trusts both amounts to be in the same currency.

Highlighted lines are new in this rung.

class Money:
def __init__(self, cents: int, currency: str) -> None:
self.cents = cents
self.currency = currency
def __repr__(self) -> str:
return f"Money({self.cents}, {self.currency!r})"
def __eq__(self, other: object) -> bool:
if not isinstance(other, Money):
return NotImplemented
return (self.cents, self.currency) == (other.cents, other.currency)
def __add__(self, other: object) -> "Money":
if not isinstance(other, Money):
return NotImplemented
if other.currency != self.currency:
raise ValueError(f"can't add {other.currency} to {self.currency}")
return Money(self.cents + other.cents, self.currency)
Time
O(1)
Space
O(1)
Why
Two checks and one sum.
What changed from the previous rung
class Money:
def __init__(self, cents: int, currency: str) -> None:
self.cents = cents
self.currency = currency
def __repr__(self) -> str:
return f"Money({self.cents}, {self.currency!r})"
def __eq__(self, other: object) -> bool:
if not isinstance(other, Money):
return NotImplemented
return (self.cents, self.currency) == (other.cents, other.currency)
def __add__(self, other: "Money") -> "Money":
def __add__(self, other: object) -> "Money":
if not isinstance(other, Money):
return NotImplemented
if other.currency != self.currency:
raise ValueError(f"can't add {other.currency} to {self.currency}")
return Money(self.cents + other.cents, self.currency)

When Best isn’t best: for real money, a frozen dataclass gives __eq__ and __hash__ at no extra cost, and libraries like py-moneyed handle rounding and currencies.

Write Circle(r) and Rect(w, h), each with an area() method, and total_area(shapes), rounded to 2 places. Adding a new shape must not mean changing total_area. A shape that forgets to define area() should fail with a clear NotImplementedError.

Hint 1 of 3

A chain of if kind == ... must change for every new shape.

Hint 2 of 3

If every shape has area(), total_area just calls it.

Hint 3 of 3

A base class Shape can raise NotImplementedError in its own area().

Shapes that know their area

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: total_area([Circle(1), Rect(2, 3)]) → 9.14
  2. Not run yet: total_area([]) → 0
  3. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Dicts and an if per kind

The idea: Keep shapes as dicts, and add up their areas with an if for each kind.

Circles and rectangles add up right.

import math
def total_area(shapes: list[dict]) -> float:
total = 0.0
for s in shapes:
if s["kind"] == "circle":
total += math.pi * s["r"] ** 2
elif s["kind"] == "rect":
total += s["w"] * s["h"]
return round(total, 2)
Time
O(n)
Space
O(1)
Why
One look at each shape.

Where it breaks: a square is silently skipped. Every new kind means editing total_area.

Better

A class per shape

The idea: Each class has its own area(), and total_area calls it.

The waste in Okay: total_area knows every kind of shape.

Now total_area never changes. Each shape answers for itself; this is called polymorphism.

Highlighted lines are new in this rung.

import math
class Circle:
def __init__(self, r: float) -> None:
self.r = r
def area(self) -> float:
return math.pi * self.r**2
class Rect:
def __init__(self, w: float, h: float) -> None:
self.w = w
self.h = h
def area(self) -> float:
return self.w * self.h
def total_area(shapes: list) -> float:
return round(sum(s.area() for s in shapes), 2)
Time
O(n)
Space
O(1)
Why
One call per shape.
What changed from the previous rung
import math
def total_area(shapes: list[dict]) -> float:
total = 0.0
for s in shapes:
if s["kind"] == "circle":
total += math.pi * s["r"] ** 2
elif s["kind"] == "rect":
total += s["w"] * s["h"]
return round(total, 2)
class Circle:
def __init__(self, r: float) -> None:
self.r = r
def area(self) -> float:
return math.pi * self.r**2
class Rect:
def __init__(self, w: float, h: float) -> None:
self.w = w
self.h = h
def area(self) -> float:
return self.w * self.h
def total_area(shapes: list) -> float:
return round(sum(s.area() for s in shapes), 2)

Where it breaks: nothing says a shape must have area(). One that forgets fails deep inside sum() with AttributeError.

Best

A base class

The idea: Shape.area() raises NotImplementedError, and a Square can reuse Rect with super().

The waste in Better: the shared rule, “every shape has an area”, isn’t written anywhere.

Highlighted lines are new in this rung.

import math
class Shape:
def area(self) -> float:
raise NotImplementedError(f"{type(self).__name__} must define area()")
class Circle(Shape):
def __init__(self, r: float) -> None:
self.r = r
def area(self) -> float:
return math.pi * self.r**2
class Rect(Shape):
def __init__(self, w: float, h: float) -> None:
self.w = w
self.h = h
def area(self) -> float:
return self.w * self.h
class Square(Rect):
def __init__(self, side: float) -> None:
super().__init__(side, side)
def total_area(shapes: list[Shape]) -> float:
return round(sum(s.area() for s in shapes), 2)
Time
O(n)
Space
O(1)
Why
One call per shape.
What changed from the previous rung
import math
class Circle:
class Shape:
def area(self) -> float:
raise NotImplementedError(f"{type(self).__name__} must define area()")
class Circle(Shape):
def __init__(self, r: float) -> None:
self.r = r
def area(self) -> float:
return math.pi * self.r**2
class Rect:
class Rect(Shape):
def __init__(self, w: float, h: float) -> None:
self.w = w
self.h = h
def area(self) -> float:
return self.w * self.h
def total_area(shapes: list) -> float:
class Square(Rect):
def __init__(self, side: float) -> None:
super().__init__(side, side)
def total_area(shapes: list[Shape]) -> float:
return round(sum(s.area() for s in shapes), 2)

When Best isn’t best: abc.ABC with @abstractmethod stops you making a shape without area() at all, which is stricter. The ABCs and Protocols lesson compares the two.

3. A stack that works with len(), in and for (hard)

Section titled “3. A stack that works with len(), in and for (hard)”

Write a Stack class with push(item). Make Python’s own tools work on it: len(s), x in s, for x in s (top of the stack first), list(s), and if s: for an empty stack. It prints as Stack([1, 2, 3]).

Hint 1 of 3

len() calls __len__, and in calls __contains__.

Hint 2 of 3

for and list() call __iter__, which returns an iterator.

Hint 3 of 3

An object with __len__ counts as false when it returns 0.

A stack that works with len(), in and for

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: s.push(1); s.push(2); len(s) → 2
  2. Not run yet: s.push(1); [1 in s, 3 in s] → [True, False]
  3. Not run yet: push 1, 2, 3; list(s) → [3, 2, 1]
  4. Not run yet: Extra test
  5. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Methods with our own names

The idea: Add size() and has() methods.

s.size() and s.has(1) work.

class Stack:
def __init__(self) -> None:
self.items: list = []
def push(self, item: object) -> None:
self.items.append(item)
def size(self) -> int:
return len(self.items)
def has(self, item: object) -> bool:
return item in self.items
Time
O(n) for has
Space
O(n)
Why
has() checks the items one by one.

Where it breaks: len(s) is a TypeError. Every user must learn your method names instead of Python’s.

Better

__len__ and __contains__

The idea: Name the methods so len() and in call them.

The waste in Okay: it invents names for things Python already has names for.

len(s) and 1 in s work, and so does if s:, through __len__.

Highlighted lines are new in this rung.

class Stack:
def __init__(self) -> None:
self.items: list = []
def push(self, item: object) -> None:
self.items.append(item)
def __len__(self) -> int:
return len(self.items)
def __contains__(self, item: object) -> bool:
return item in self.items
Time
O(n) for in
Space
O(n)
Why
in checks the items one by one.
What changed from the previous rung
class Stack:
def __init__(self) -> None:
self.items: list = []
def push(self, item: object) -> None:
self.items.append(item)
def size(self) -> int:
def __len__(self) -> int:
return len(self.items)
def has(self, item: object) -> bool:
def __contains__(self, item: object) -> bool:
return item in self.items

Where it breaks: for x in s and list(s) raise TypeError: 'Stack' object is not iterable.

Best

__iter__ and __repr__ too

The idea: __iter__ returns reversed(self.items), so for loops see the top first.

The waste in Better: a container you can’t loop over.

Highlighted lines are new in this rung.

from collections.abc import Iterator
class Stack:
def __init__(self) -> None:
self.items: list = []
def push(self, item: object) -> None:
self.items.append(item)
def __len__(self) -> int:
return len(self.items)
def __contains__(self, item: object) -> bool:
return item in self.items
def __iter__(self) -> Iterator:
return reversed(self.items) # top of the stack first
def __repr__(self) -> str:
return f"Stack({self.items!r})"
Time
O(n) to loop
Space
O(n)
Why
reversed() walks the list without copying it.
What changed from the previous rung
from collections.abc import Iterator
class Stack:
def __init__(self) -> None:
self.items: list = []
def push(self, item: object) -> None:
self.items.append(item)
def __len__(self) -> int:
return len(self.items)
def __contains__(self, item: object) -> bool:
return item in self.items
def __iter__(self) -> Iterator:
return reversed(self.items) # top of the stack first
def __repr__(self) -> str:
return f"Stack({self.items!r})"

When Best isn’t best: a plain list already is a stack, with append() and pop(). Write a class only when you need rules a list can’t keep.

A few quick questions. Ones you miss are remembered, so you can find your weak spots later.

  1. Predict the outputWhat does this print?
    class A:
        def hi(self): return "A"
    class B(A):
        def hi(self): return "B" + super().hi()
    class C(A):
        def hi(self): return "C" + super().hi()
    class D(B, C):
        pass
    print(D().hi())
  2. Spot the bugSavings("bo").balance raises AttributeError. Why?
    class Account:
        def __init__(self, owner):
            self.owner = owner
            self.balance = 0
    
    class Savings(Account):
        def __init__(self, owner, rate=0.02):
            self.rate = rate
  3. Predict the outputWhat does this print?
    class Point:
        def __init__(self, x):
            self.x = x
    print(Point(1) == Point(1))
  4. Multiple choiceYour __add__ gets a value it can’t add, like a str. What should it do?

Add these to your review deck. Each card comes back a little later every time you get it right.

  • class B(A): inherits; super().method() calls the next class in the MRO.
  • Cls.__mro__ shows the order Python searches. With super(), every class in it runs once.
  • __repr__ for printing, __eq__ for == (return NotImplemented for other types).
  • __len__, __contains__, __iter__, __add__ make len(), in, for and + work on your objects.
  • Defining __eq__ removes __hash__; prefer composition over deep inheritance.

Can you do these now?

If you’re not sure of any of these, scroll back to that part before moving on.

Status: Not started