2.1.4 · D3OOP Fundamentals

Worked examples — Instance methods, class methods (`@classmethod`), static methods (`@staticmethod`)

2,686 words12 min readBack to topic

We only assume the vocabulary already built in OOP Fundamentals - Classes and Objects and Instance vs Class attributes. If a word is new, we rebuild it here from zero.


The scenario matrix

Every question this topic can ask you falls into one of these cells. Think of the columns as which method kind and the rows as what twist the question adds.

# Twist / edge case Instance method (self) Class method (cls) Static method (none)
A Plain call, no inheritance Ex 1 Ex 1 Ex 1
B Called through the class vs through an instance Ex 2 Ex 2
C Subclass overrides an attribute Ex 3
D Factory / alternate constructor with subclass Ex 4 Ex 4 (the wrong way)
E Missing / wrong first argument (degenerate call) Ex 5 Ex 5 Ex 5
F Assigning vs reading shared state (shadowing) Ex 6 Ex 6
G Real-world word problem Ex 7 Ex 7 Ex 7
H Exam twist: static calling classmethod, MRO surprise Ex 8 Ex 8
Figure — Instance methods, class methods (`@classmethod`), static methods (`@staticmethod`)

Ex 1 — Cell A: all three, plain call, one class

Steps.

  1. c.area(). Why this step? area has self as its first parameter, so Python secretly rewrites c.area() into Circle.area(c) — the cookie c slides into self. Then , which rounds to 12.566.
  2. Circle.unit(). Why this step? unit is a @classmethod, so cls is filled with Circle (the cutter itself, no cookie needed). It runs cls(1) = Circle(1), a new circle with .r == 1. So .r prints 1.
  3. Circle.is_valid(-1). Why this step? is_valid is @staticmethodnothing is auto-injected. The -1 you typed lands directly in r. Then -1 > 0 is False.
Recall

Line 1 output ::: 12.566 Line 3 output ::: False


Ex 2 — Cell B: instance method called through the class

Steps.

  1. c.area(). Why this step? Recall the descriptor __get__ we defined above — the hidden mechanism that fills the first slot. When you write c.area, that mechanism binds c into self automatically, producing Circle.area(c).
  2. Circle.area(c). Why this step? Here you supply the object manually. There is no auto-injection when you go through the class name, so you must pass c yourself.
  3. Both compute .
Recall

Are the two outputs equal? ::: Yes, both are 12.566.


Ex 3 — Cell C: subclass overrides an attribute (why cls, not the class name)

Steps.

  1. Animal.describe(). Why this step? Call comes through Animal, so cls = Animal, and cls.species reads "generic".
  2. Dog.describe(). Why this step? Dog did not define describe, so Python walks the MRO (the child-up-to-parent search list we defined above) and finds Animal.describe. But the call came through Dog, so cls = Dog. Now cls.species reads Dog.species = "dog". See Inheritance and method resolution order (MRO).
  3. Why not hard-code Animal.species? Then step 2 would ignore the override and wrongly say "generic". cls is what makes the method inheritance-aware.
Recall

Animal.describe() ::: "I am a generic" Dog.describe() ::: "I am a dog"


Ex 4 — Cell D: factory the right way vs the wrong way

Steps.

  1. Star.triangle_good(). Why this step? It is a @classmethod; calling through Star sets cls = Star, so cls(3) = Star(3). The type is Star.
  2. Star.triangle_bad(). Why this step? It is @staticmethod — it cannot see the calling class and literally names Shape. So it builds a Shape even though you called through Star. The type is Shape.
  3. Why this matters. A subclass wanting Star-flavoured objects silently gets base Shape objects — a bug that hides until much later. This is the killer reason factories use @classmethod. See Factory design pattern.
Recall

type(Star.triangle_good()).__name__ ::: "Star" type(Star.triangle_bad()).__name__ ::: "Shape"


Ex 5 — Cell E: the degenerate calls (missing / extra first argument)

Steps.

  1. (i) a.inst(). Why? Auto-fills self = a. ✅ runs.
  2. (ii) A.inst(). Why? Through the class name there is no auto-injection, and no argument is supplied, so self has nothing. → TypeError: inst() missing 1 required positional argument: 'self'. ❌.
  3. (iii) a.cm(). Why? @classmethod injects cls = A no matter whether you call through a or A. ✅ runs.
  4. (iv) a.st(). Why? @staticmethod injects nothing and needs nothing. ✅ runs.
  5. (v) A.st(99). Why? Static wants zero parameters, but you handed it 99. → TypeError: st() takes 0 positional arguments but 1 was given. ❌.
Recall

Which two lines raise TypeError? ::: (ii) A.inst() and (v) A.st(99).


Ex 6 — Cell F: reading vs assigning shared state (shadowing)

Steps.

  1. a.bump_wrong() twice. Why this step? self.total += 1 expands to self.total = self.total + 1. The right side reads total (falls back to the class → 0, then 1), but the assignment always creates an instance attribute on a. So the class's total never moves; only a.total grows to 2.
  2. print(Counter.total, a.total)0 2. Why? Class stayed 0; a now shadows it with 2.
  3. Counter.bump_right(). Why this step? cls.total += 1 targets the class attribute, so Counter.total becomes 1.
  4. print(Counter.total, b.total)1 1. Why? b never got its own attribute, so b.total falls back to the class value 1.
Recall

Line 1 output ::: 0 2 Line 2 output ::: 1 1


Ex 7 — Cell G: real-world word problem (parsing dates & bank fees)

Steps.

  1. from_dollars_cents(100, 50). Why classmethod? It builds an account a second way; cls(...) keeps it subclass-friendly. Balance dollars.
  2. acc.yearly_interest(). Why instance? Interest depends on this account's balance: dollars/year.
  3. Account.is_legal(-3). Why static? The legality rule needs neither an account nor the class — just the number. is False.
Recall

acc.balance ::: 100.5 acc.yearly_interest() ::: 5.025 Account.is_legal(-3) ::: False


Ex 8 — Cell H: exam twist (a static method that reaches back into the class, and an MRO surprise)

Steps.

  1. Base.label()cls = Base, so cls.tag = "B". Output "B".
  2. Sub.label()cls = Sub (inheritance-aware), so cls.tag = "S". Output "S".
  3. Sub.shout(). Why the trap? shout is a @staticmethod: it has no cls. Inside it we hard-coded Base.label(), which forces cls = Base"B", then appends "!". Output "B!"not "S!", even though you called through Sub. A static method can't respect the calling class; that is exactly what makes it wrong for anything inheritance-sensitive.
Recall

Base.label() ::: "B" Sub.label() ::: "S" Sub.shout() ::: "B!"


The whole matrix in one diagram

The flowchart below is the decision you make every time you write a method: start at the top with the question "what does this method actually need?", and each branch drops you onto the right kind. The two leaf notes (factory, loses subclass awareness) are the payoff cells D and H we just worked — they show why the branch choice matters, not just what it is called.

this object data

the class itself

neither

builds objects

names a class by hand

What does the method need?

instance method -> self

classmethod -> cls

staticmethod -> nothing

subclass-friendly factory

loses subclass awareness

Read it as: need this cookie's dataself (Ex 1, 2, 6, 7). Need the cutter, maybe to stamp new cookiescls (Ex 3, 4, 7). Need neither → static (Ex 1, 5, 8) — but notice the dashed danger branch: a static method that names a class by hand cannot follow a subclass, which is precisely the Ex 8 trap.


Flashcards

Rewrite c.area() as an explicit function call.
Circle.area(c) — the object becomes the first positional argument.
Why does A.inst() raise TypeError while a.inst() runs?
Through the class name nothing is auto-injected, so self is left empty.
Does self.total += 1 change the class attribute total?
No — the assignment creates a per-instance attribute that shadows the class one.
Why does a @staticmethod factory break subclasses?
It can't see the calling class, so it hard-codes the base class instead of building a subclass instance.
What does Sub.shout() return if shout is static and calls Base.label()?
"B!" — static has no cls, so it uses Base, ignoring Sub.