Session 27 of the Python Fundamentals series, covered in depth. It covers three decorators that reshape how a class exposes behavior: @staticmethod, for a namespaced helper that takes no self; @classmethod, for cls-based alternate constructors such as from_string; and @property, which exposes a computed value that reads like a plain attribute, together with a matching setter that validates every assignment. The traps are assigning to a property that has no setter, which raises AttributeError: property '...' has no setter; forgetting @staticmethod and getting a positional-argument TypeError; and putting validation in a raw attribute, where nothing guards it. Every snippet and error message was executed and copied verbatim from CPython 3.12.
Subject: Python Fundamentals · 103 slides · code lesson
Open the interactive version of this deck · Homework for this lesson
Title
Python Fundamentals - Session 27
Three decorators that reshape how a class exposes behavior
Objectives
You already write classes with __init__ and regular methods that take self. This session adds three decorated methods. By the end you can:
@staticmethod - a helper in the class's namespace that takes no self.@classmethod and use it as an alternate constructor like from_string.@property so obj.area runs code.AttributeError.Warm-up
Discussion prompt
Before we open Session 27 - classmethod, staticmethod & property: without looking back, what was the main idea of Session 26 - Inheritance & Composition, and what could you do by the end of it that you could not do before?
Hint: One sentence for the idea, one for the skill. If the second one is blank, that is the part to revisit.
Answer:
Session 26 of the Python Fundamentals series, in depth. Two ways to reuse classes: inheritance (a Dog IS-A Animal - subclassing, overriding methods, and super() to reuse the parent) and composition (a Car HAS-A Engine - building objects out of other objects).
Section
Part 1
Concept
Every method you have written so far takes self as its first parameter - the instance the method was called on.
Sometimes a function belongs near a class but needs no instance at all. That is what @staticmethod is for.
Counterexample
Discussion prompt
Every method you have written so far takes self as its first parameter - the instance the method was called on.
That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.
Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.
Answer:
Sometimes a function belongs near a class but needs no instance at all. That is what @staticmethod is for.
Concept
The @name line sitting directly above a def is a decorator - it wraps the method to change how it behaves. You have used them without naming them; here they are the whole point.
@staticmethod, @classmethod, and @property are all built in - no import needed.
Analogy
Discussion prompt
Explain The @ line is a decorator by analogy to something with no Python Fundamentals in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.
Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.
Answer:
@staticmethod, @classmethod, and @property are all built in - no import needed.
Concept
Put @staticmethod on the line above a method and drop self. It becomes a plain function that lives inside the class.
static method — A method with no self and no cls, marked @staticmethod. It cannot see any instance or class data - it just takes its arguments and returns a result.
Ranking
Put in order
Put the moves of A Celsius-to-Fahrenheit helper into the order they have to happen.
Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. It has no self - it takes just a number c and returns a number.
Worked example
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
@staticmethod
def c_to_f(c):
return c * 9 / 5 + 32
print(Temperature.c_to_f(100))
print(Temperature.c_to_f(0))The @staticmethod line marks c_to_f
Why: It has no self - it takes just a number c and returns a number.
Call it on the class name
Why: You do not need a Temperature instance to convert a number.
Read the output
Why: Verified by execution: 212.0 then 32.0.
| call | c | returns |
|---|---|---|
| Temperature.c_to_f(100) | 100 | 212.0 |
| Temperature.c_to_f(0) | 0 | 32.0 |
Comparison
Comparison matrix
From A Celsius-to-Fahrenheit helper: refill the c column from what you know. The rest of the table is as it appeared.
| call | c | returns |
|---|---|---|
| Temperature.c_to_f(100) | 100 | 212.0 |
| Temperature.c_to_f(0) | 0 | 32.0 |
Concept
A static method works both ways: Temperature.c_to_f(100) or t.c_to_f(100). Because it takes no self, the instance is ignored either way.
Explain it
Discussion prompt
Explain Call it on the class or an instance to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.
Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.
Answer:
A static method works both ways: Temperature.c_to_f(100) or t.c_to_f(100). Because it takes no self, the instance is ignored either way.
Step zero
Discussion prompt
Static works from both — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.
Hint: It starts with: Call on the class
Answer:
Worked example
class MathUtil:
@staticmethod
def add(a, b):
return a + b
print(MathUtil.add(2, 3))
u = MathUtil()
print(u.add(2, 3))Call on the class
Why: MathUtil.add(2, 3) runs the helper directly.
Call on an instance
Why: u.add(2, 3) works too - the instance u is not passed in, because there is no self.
Read the output
Why: Verified by execution: 5 then 5 - the same answer both ways.
| call | returns |
|---|---|
| MathUtil.add(2, 3) | 5 |
| u.add(2, 3) | 5 |
Trade off
Comparison matrix
From Static works from both: every row here is a choice with a cost. Fill the returns column, then say which row you would actually pick and what you give up for it.
| call | returns |
|---|---|
| MathUtil.add(2, 3) | 5 |
| u.add(2, 3) | 5 |
Intuition
Think of a static method as an ordinary function you filed inside the class for tidiness - grouped with the data it relates to.
It cannot touch any instance's attributes. Give it inputs, it gives you a result. Nothing more.
Section
Part 2
Concept
Mark a method with @classmethod and its first parameter is cls - the class itself, not an instance.
class method — A method marked @classmethod whose first parameter cls is the class. It can read class-level data and, most usefully, build and return a new instance with cls(...).
Definition probe
Sort into buckets
Every line below is part of the definition of static method or of class method — one or the other, never both. Put each where it belongs.
Pattern
Predict first
The table runs: start | 0 · Student("A") | 1 · Student("B") | 2
In Count instances with cls, given the rows so far: what is the next one — the row where event is how_many()?
Correct: how_many() | 2
| event | count |
|---|---|
| start | 0 |
| Student("A") | 1 |
| Student("B") | 2 |
| how_many() | 2 |
Why: The relationship between the columns, not the individual numbers, is what generates the next row. It belongs to the class, shared by all instances - each __init__ bumps it.
Worked example
class Student:
count = 0
def __init__(self, name):
self.name = name
Student.count += 1
@classmethod
def how_many(cls):
return cls.count
Student("A")
Student("B")
print(Student.how_many())count is a class-level attribute
Why: It belongs to the class, shared by all instances - each __init__ bumps it.
how_many reads cls.count
Why: cls is the class, so cls.count is the shared counter.
Read the output
Why: Verified by execution: after two Students, prints 2.
| event | count |
|---|---|
| start | 0 |
| Student("A") | 1 |
| Student("B") | 2 |
| how_many() | 2 |
Discrimination
Sort into buckets
Sort these by count, from memory, without looking back at Count instances with cls. Telling them apart on the spot is the skill; the table is only where the answer happens to be written down.
Concept
The best use of @classmethod is a second way to build an object. __init__ takes the normal arguments; a class method can build one from a different input.
By convention these are named from_something. Inside, they call cls(...) to make and return the new instance.
Fill the middle
Fill in the blanks
From Student.from_string — one line has had its right-hand side removed. Put it back.
class Student:
def __init__(self, name, grade):
self.name = name
self.grade = grade
@classmethod
def from_string(cls, text):
name, grade = text.split(",")
return cls(name, int(grade))
s = Student.from_string("Ana,90")
print(s.name)
print(s.grade)
Why: self.grade is what everything below it consumes, so the wrong expression here fails later and somewhere else. text.split(",") gives ["Ana", "90"], unpacked into name and grade.
Worked example
class Student:
def __init__(self, name, grade):
self.name = name
self.grade = grade
@classmethod
def from_string(cls, text):
name, grade = text.split(",")
return cls(name, int(grade))
s = Student.from_string("Ana,90")
print(s.name)
print(s.grade)Split the raw text
Why: text.split(",") gives ["Ana", "90"], unpacked into name and grade.
cls(name, int(grade)) builds the object
Why: cls is Student, so this runs __init__ and returns a real Student.
Read the output
Why: Verified by execution: Ana then 90 - grade is an int, not the string "90".
| step | value |
|---|---|
| text | "Ana,90" |
| name | "Ana" |
| int(grade) | 90 |
| s.name / s.grade | Ana / 90 |
Error analysis
Annotate
Walk the callouts on Student.from_string. Each one is a place this is easy to get subtly wrong.
Step zero
Discussion prompt
Money.from_dollars — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.
Hint: It starts with: __init__ stores cents
Answer:
Worked example
class Money:
def __init__(self, cents):
self.cents = cents
@classmethod
def from_dollars(cls, dollars):
return cls(round(dollars * 100))
m = Money.from_dollars(4.50)
print(m.cents)__init__ stores cents
Why: The object's real state is an integer count of cents.
from_dollars converts then builds
Why: 4.50 dollars -> round(450.0) -> 450 cents, wrapped by cls(...).
Read the output
Why: Verified by execution: 450. Two ways to build the same class - by cents or by dollars.
| call | dollars | cents |
|---|---|---|
| Money(450) | - | 450 |
| Money.from_dollars(4.50) | 4.50 | 450 |
Blank canvas
Draw it
Draw what Money.from_dollars just did — the shape of it, not the line-by-line working. One picture, labels only where you need them. Then check it against the steps: anything you could not draw is a step you followed rather than understood.
Worked example
class Date:
def __init__(self, y, m, d):
self.y, self.m, self.d = y, m, d
@classmethod
def from_string(cls, s):
y, m, d = s.split("-")
return cls(int(y), int(m), int(d))
def __repr__(self):
return f"Date({self.y}, {self.m}, {self.d})"
print(Date.from_string("2026-07-15"))Split on the dashes
Why: "2026-07-15".split("-") gives three string pieces.
Convert each to int and build
Why: cls(int(y), int(m), int(d)) makes a Date with numeric fields.
Read the output
Why: Verified by execution: Date(2026, 7, 15).
| piece | int |
|---|---|
| "2026" | 2026 |
| "07" | 7 |
| "15" | 15 |
Pattern
Step through it
Step through Date.from_string one row at a time. What is driving the change, and what would the row after the last one be?
Concept
Inside a class method you could type Student(...), but cls(...) is better: if someone later subclasses Student, cls is that subclass, so from_string builds the right type automatically.
Intuition
self was 'the object I was called on'. cls is the parallel idea one level up: 'the class I was called on'.
So cls(...) means 'make one of me' without hard-coding a name - a factory that always produces the current class.
Section
Part 3
Concept
A class can hold all three. The decorator (or lack of one) decides what implicit first argument the method receives.
Concept
The difference is entirely in that first parameter: an instance, the class, or nothing.
| decorator | first parameter | can see |
|---|---|---|
| (none) | self | this instance's data |
| @classmethod | cls | the class and its class-level data |
| @staticmethod | (none) | only its own arguments |
Sorting
Sort into buckets
These are the pieces of Session 27 - classmethod, staticmethod & property, out of order. Put each one back under the part of the lesson it belongs to.
Section
Part 4
Concept
A @property lets a method be read like an attribute - no parentheses. obj.area runs the method and gives back its result.
property — A method marked @property that you access as if it were a plain attribute. Reading obj.name runs the method's body and returns its value.
Matching
Match the pairs
Match each term to the definition this lesson gave it — not the one you would guess from the word.
Why: These are the working definitions of static method, class method, property as Session 27 - classmethod, staticmethod & property uses them. Pairing them correctly is the test of whether you could state each one with the slide switched off.
Ranking
Put in order
Put the moves of Circle.area as a property into the order they have to happen.
Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. So c.area (no parentheses) runs the body and returns the number.
Worked example
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def area(self):
return 3.14159 * self.radius ** 2
c = Circle(10)
print(c.area)
c.radius = 2
print(c.area)area is decorated with @property
Why: So c.area (no parentheses) runs the body and returns the number.
Change radius, read area again
Why: area is computed fresh each read, so it tracks the current radius.
Read the output
Why: Verified by execution: 314.159 then 12.56636.
| radius | c.area |
|---|---|
| 10 | 314.159 |
| 2 | 12.56636 |
Concept
That is the whole point: to the caller, c.area looks and feels like stored data, even though a calculation runs behind it.
So you can turn a plain attribute into a computed one later without changing any code that reads it.
Step zero
Discussion prompt
A property tracks its backing data — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.
Hint: It starts with: area multiplies the two backing fields
Answer:
Worked example
class Box:
def __init__(self, w, h):
self._w = w
self._h = h
@property
def area(self):
return self._w * self._h
b = Box(3, 4)
print(b.area)
b._w = 5
print(b.area)area multiplies the two backing fields
Why: _w and _h hold the real data; area derives from them.
Change _w, read area again
Why: Nothing is cached - the next read recomputes with the new _w.
Read the output
Why: Verified by execution: 12 then 20.
| _w | _h | b.area |
|---|---|---|
| 3 | 4 | 12 |
| 5 | 4 | 20 |
Intuition
A property is a disguise. Callers see a simple attribute; inside, a method decides what that attribute means.
That disguise is what lets you add logic - computing, checking, converting - without asking every caller to switch from obj.x to obj.x().
Concept
Start with a plain attribute. Upgrade to a @property only when a read needs to compute something, or a write needs to be checked - the property is the place to put that logic.
The win: callers keep writing obj.x, unaware the rules changed underneath them.
Section
Part 5
Concept
Add a second method decorated with @name.setter and it runs whenever someone does obj.name = value. The assigned value arrives as a parameter.
Now the equals sign is not just storing data - it is running your code.
Hypothesis
Predict first
celsius getter and setter is about to be worked. State your hypothesis first: which rule or definition decides this one, and what is the first move it forces? Then watch whether the example agrees with you.
Correct: Getter returns the backing _celsius
Why: Reading t.celsius runs this and hands back the stored number.
A hypothesis you wrote down is falsifiable; a vague sense of how it will go is not. If the example opens somewhere else, that gap is the thing worth chasing.
Worked example
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("below absolute zero")
self._celsius = value
t = Temperature(20)
print(t.celsius)
t.celsius = 30
print(t.celsius)Getter returns the backing _celsius
Why: Reading t.celsius runs this and hands back the stored number.
Setter guards, then stores
Why: t.celsius = 30 calls the setter with value=30; it passes the check and stores it.
Read the output
Why: Verified by execution: 20 then 30.
| action | value | t.celsius |
|---|---|---|
| Temperature(20) | 20 | 20 |
| read | - | 20 |
| t.celsius = 30 | 30 | 30 |
Comparison
Comparison matrix
From celsius getter and setter: refill the t.celsius column from what you know. The rest of the table is as it appeared.
| action | value | t.celsius |
|---|---|---|
| Temperature(20) | 20 | 20 |
| read | - | 20 |
| t.celsius = 30 | 30 | 30 |
Concept
Because the setter runs on every assignment, it is the one place to reject bad values - raise an error and the assignment never happens.
Fill the middle
Fill in the blanks
From The setter rejects a bad value — one line has had its right-hand side removed. Put it back.
Traceback (most recent call last):
File "temp.py", line 16, in <module>
t.celsius = -300
^^^^^^^^^
File "temp.py", line 12, in celsius
raise ValueError("below absolute zero")
ValueError: below absolute zero
Why: t.celsius is what everything below it consumes, so the wrong expression here fails later and somewhere else. The setter's check is True, so it raises instead of storing.
Worked example
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("below absolute zero")
self._celsius = value
t = Temperature(20)
t.celsius = -300-300 is below absolute zero
Why: The setter's check is True, so it raises instead of storing.
The assignment is refused
Why: Verified by execution: the program stops with the traceback below - _celsius stays 20.
Traceback (most recent call last):
File "temp.py", line 16, in <module>
t.celsius = -300
^^^^^^^^^
File "temp.py", line 12, in celsius
raise ValueError("below absolute zero")
ValueError: below absolute zero| assignment | check | result |
|---|---|---|
| t.celsius = 30 | 30 < -273.15 is False | stored |
| t.celsius = -300 | -300 < -273.15 is True | ValueError |
Concept
If __init__ assigns self.value = value and value is a property, that assignment goes through the setter too - so the guard protects construction, not just later edits.
Pattern
Predict first
The table runs: Score(85) | 85 | 85 · read | - | 85
In Building through the setter, given the rows so far: what is the next one — the row where action is s.value = 92?
Correct: s.value = 92 | 92 | 92
| action | v | s.value |
|---|---|---|
| Score(85) | 85 | 85 |
| read | - | 85 |
| s.value = 92 | 92 | 92 |
Why: The relationship between the columns, not the individual numbers, is what generates the next row. value is a property, so this runs the setter - Score(85) is validated on the way in.
Worked example
class Score:
def __init__(self, value):
self.value = value
@property
def value(self):
return self._value
@value.setter
def value(self, v):
if not 0 <= v <= 100:
raise ValueError("score must be 0-100")
self._value = v
s = Score(85)
print(s.value)
s.value = 92
print(s.value)__init__ assigns self.value
Why: value is a property, so this runs the setter - Score(85) is validated on the way in.
Both reads and later writes flow through
Why: s.value reads via the getter; s.value = 92 writes via the setter.
Read the output
Why: Verified by execution: 85 then 92. Score(150) would raise ValueError: score must be 0-100.
| action | v | s.value |
|---|---|---|
| Score(85) | 85 | 85 |
| read | - | 85 |
| s.value = 92 | 92 | 92 |
Pattern
Step through it
Step through Building through the setter one row at a time. What is driving the change, and what would the row after the last one be?
Anomaly
Predict first
A student writes this, and it looks reasonable:
You meant to keep the value valid, but stored it as a plain attribute.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: self.celsius is just data; = stores -300 with no guard, even though it is impossible.
Make celsius a property with a setter that validates.
Why: self.celsius is just data; = stores -300 with no guard, even though it is impossible.
Trap
You meant to keep the value valid, but stored it as a plain attribute.
class Temp:
def __init__(self, celsius):
self.celsius = celsius
t = Temp(20)
t.celsius = -300
print(t.celsius)Nothing checks the assignment
Why: self.celsius is just data; = stores -300 with no guard, even though it is impossible.
| assignment | check | t.celsius |
|---|---|---|
| t.celsius = -300 | none | -300 (verified) |
Make celsius a property with a setter that validates.
class Temp:
def __init__(self, celsius):
self.celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("below absolute zero")
self._celsius = valueEvery assignment runs the guard
Why: Now t.celsius = -300 raises ValueError: below absolute zero, so the bad value is never stored.
| assignment | check | result |
|---|---|---|
| t.celsius = -300 | -300 < -273.15 | ValueError (blocked) |
Section
Part 6
Concept
Define @property but no matching setter, and the value can be read but not assigned. Trying to set it raises an AttributeError.
This is exactly what you want for a computed or protected value - area, balance, an id.
Fill the middle
Fill in the blanks
From A read-only balance — one line has had its right-hand side removed. Put it back.
Traceback (most recent call last):
File "acct.py", line 11, in <module>
a.balance = 200
^^^^^^^^^
AttributeError: property 'balance' of 'Account' object has no setter
Why: a.balance is what everything below it consumes, so the wrong expression here fails later and somewhere else. Reading a.balance works; assigning to it has nothing to run.
Worked example
class Account:
def __init__(self, balance):
self._balance = balance
@property
def balance(self):
return self._balance
a = Account(100)
print(a.balance)
a.balance = 200balance has a getter but no setter
Why: Reading a.balance works; assigning to it has nothing to run.
Assigning fails
Why: Verified by execution: it prints 100, then the assignment raises the traceback below.
Traceback (most recent call last):
File "acct.py", line 11, in <module>
a.balance = 200
^^^^^^^^^
AttributeError: property 'balance' of 'Account' object has no setter| action | result |
|---|---|
| print(a.balance) | 100 |
| a.balance = 200 | AttributeError: ... has no setter |
Concept
A read-only property can be computed from another. Store celsius; expose fahrenheit as a property with no setter, derived on every read.
Step zero
Discussion prompt
fahrenheit derived from celsius — before any calculation: what is the plan? Name the moves in order, in plain English, without doing the arithmetic.
Hint: It starts with: celsius is the stored, settable value
Answer:
Worked example
class Temperature:
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
self._celsius = value
@property
def fahrenheit(self):
return self._celsius * 9 / 5 + 32
t = Temperature()
t.celsius = 100
print(t.fahrenheit)
t.celsius = 0
print(t.fahrenheit)celsius is the stored, settable value
Why: Its setter records _celsius.
fahrenheit is computed and read-only
Why: No setter - it is derived from _celsius each time you read it.
Read the output
Why: Verified by execution: 212.0 then 32.0.
| t.celsius | t.fahrenheit |
|---|---|
| 100 | 212.0 |
| 0 | 32.0 |
Blank canvas
Draw it
Draw what fahrenheit derived from celsius just did — the shape of it, not the line-by-line working. One picture, labels only where you need them. Then check it against the steps: anything you could not draw is a step you followed rather than understood.
Anomaly
Predict first
A student writes this, and it looks reasonable:
You expose a computed value, then try to assign to it.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: area is computed from radius; there is no method to run for an assignment, so Python raises.
Set the input, not the computed output - change radius and let area follow.
Why: area is computed from radius; there is no method to run for an assignment, so Python raises.
Trap
You expose a computed value, then try to assign to it.
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def area(self):
return 3.14159 * self.radius ** 2
c = Circle(10)
c.area = 50area has no setter
Why: area is computed from radius; there is no method to run for an assignment, so Python raises.
Traceback (most recent call last):
File "circle.py", line 10, in <module>
c.area = 50
^^^^^^
AttributeError: property 'area' of 'Circle' object has no setter| you write | result |
|---|---|
| c.area = 50 | AttributeError: ... has no setter |
Set the input, not the computed output - change radius and let area follow.
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def area(self):
return 3.14159 * self.radius ** 2
c = Circle(10)
c.radius = 4
print(c.area)Assign radius; read area
Why: Verified by execution: c.area is 50.26544. area is derived, so you steer it through its input.
| c.radius | c.area |
|---|---|
| 10 | 314.159 |
| 4 | 50.26544 |
Break the constraint
Discussion prompt
The rule this trap just fixed:
Verified by execution: c.area is 50.26544. area is derived, so you steer it through its input.
Now break it on purpose. Build a case that violates it and follow the consequences until something visibly fails. Where does the failure first show up — and would you have noticed it if you had not been looking?
Hint: The dangerous rules are the ones whose violation still produces an answer. If yours fails loudly, try to find one that fails quietly.
Answer:
area is computed from radius; there is no method to run for an assignment, so Python raises.
Section
Part 7
Anomaly
Predict first
A student writes this, and it looks reasonable:
A helper that takes no instance data, but you left off @staticmethod.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: u.add(2, 3) secretly passes u as the first argument, so add gets three values for two parameters.
Mark it @staticmethod so no instance is passed.
Why: u.add(2, 3) secretly passes u as the first argument, so add gets three values for two parameters.
Trap
A helper that takes no instance data, but you left off @staticmethod.
class MathUtil:
def add(a, b):
return a + b
print(MathUtil.add(2, 3))
u = MathUtil()
print(u.add(2, 3))On an instance it breaks
Why: u.add(2, 3) secretly passes u as the first argument, so add gets three values for two parameters.
Traceback (most recent call last):
File "m.py", line 7, in <module>
print(u.add(2, 3))
^^^^^^^^^^^
TypeError: MathUtil.add() takes 2 positional arguments but 3 were given| call | result |
|---|---|
| MathUtil.add(2, 3) | 5 (works by luck) |
| u.add(2, 3) | TypeError: ... 3 were given |
Mark it @staticmethod so no instance is passed.
class MathUtil:
@staticmethod
def add(a, b):
return a + b
print(MathUtil.add(2, 3))
u = MathUtil()
print(u.add(2, 3))Both calls work
Why: Verified by execution: 5 then 5. With @staticmethod, u is not passed in, so add gets exactly a and b.
| call | returns |
|---|---|
| MathUtil.add(2, 3) | 5 |
| u.add(2, 3) | 5 |
Two truths and a lie
Sort into buckets
Some of these hold up and some are the exact mistakes this lesson is built to prevent. Sort them.
self as its first parameter - the instance the method was called on.; @staticmethod, @classmethod, and @property are all built in - no import needed.; A static method works both ways: Temperature.c_to_f(100) or t.c_to_f(100). Because it takes no self, the instance is ignored either way.Concept
When a property named celsius needs somewhere to store its data, the convention is a single leading underscore: _celsius. The property is the public door; _celsius is the private room behind it.
The underscore is a signal to other programmers - 'internal, do not touch directly' - not an enforced lock.
Explain it
Discussion prompt
Explain The underscore backing field to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.
Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.
Answer:
When a property named celsius needs somewhere to store its data, the convention is a single leading underscore: _celsius. The property is the public door; _celsius is the private room behind it.
Concept
The getter must return self._celsius, not self.celsius. Returning self.celsius would read the property again, which reads the property again - endless recursion until Python stops with a RecursionError.
The underscore field breaks that loop: the property is the public door, _celsius is the actual storage behind it.
Analogy
Discussion prompt
Explain Do not name the property and its field the same by analogy to something with no Python Fundamentals in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.
Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.
Answer:
The underscore field breaks that loop: the property is the public door, _celsius is the actual storage behind it.
Concept
A @property can call a @staticmethod for its computation, keeping the formula in one reusable place.
Counterexample
Discussion prompt
A @property can call a @staticmethod for its computation, keeping the formula in one reusable place.
That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.
Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.
Ranking
Put in order
Put the moves of A property backed by a static helper into the order they have to happen.
Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. It takes a number and adds 8% - usable with or without an instance.
Worked example
class Price:
def __init__(self, amount):
self.amount = amount
@staticmethod
def with_tax(amount):
return amount + amount * 0.08
@property
def total(self):
return Price.with_tax(self.amount)
p = Price(100)
print(p.total)
print(Price.with_tax(50))with_tax is a static formula
Why: It takes a number and adds 8% - usable with or without an instance.
total reads like an attribute
Why: p.total runs the property, which calls the static helper on the instance's amount.
Read the output
Why: Verified by execution: 108.0 then 54.0.
| call | amount | result |
|---|---|---|
| p.total | 100 | 108.0 |
| Price.with_tax(50) | 50 | 54.0 |
Error analysis
Annotate
Walk the callouts on A property backed by a static helper. Each one is a place this is easy to get subtly wrong.
Section
Part 8
Pattern
Needs this instance's data? plain method with self
Why: The default - it reads or changes attributes on self.
Builds an instance or reads class data? @classmethod with cls
Why: Alternate constructors (from_string) and class-wide counters live here.
Needs neither self nor cls? @staticmethod
Why: A pure helper filed inside the class for tidiness.
Should read like an attribute? @property
Why: Expose a computed value as obj.name, no parentheses.
Pattern
1. Store the real data in _name
Why: The single leading underscore marks it as the private backing field.
2. @property def name(self): return self._name
Why: The public read - callers use obj.name with no parentheses.
3. @name.setter def name(self, value): check, then store
Why: Raise on a bad value; otherwise set self._name = value.
4. Assign self.name in __init__ so construction is validated too
Why: The setter runs on that assignment, so no object is ever built invalid.
Pattern
1. @classmethod def from_x(cls, raw):
Why: Name it from_ the kind of input it accepts.
2. Parse raw into the pieces __init__ needs
Why: Split a string, convert types, pull fields apart.
3. return cls(...) with those pieces
Why: cls(...) runs __init__ and returns the new instance - subclass-friendly.
Real world
Discussion prompt
Outside this lesson: where does Session 27 - classmethod, staticmethod & property actually turn up? Name one concrete situation — a job, a piece of software someone ships, a decision somebody has to make — and say which part of An alternate constructor is doing the work in it.
Hint: Vague is the failure mode here. "Engineering" is not a situation; "deciding whether this build is fast enough to ship" is.
Answer:
Session 27 of the Python Fundamentals series, in depth. Three decorators that reshape how a class exposes behavior: @staticmethod for a namespaced helper that takes no self, @classmethod for cls-based alternate constructors like from_string, and @property to expose a computed value that reads like a plain attribute - plus a matching setter that validates every assignment.
Check
No instance is made - trace the call.
class Util:
@staticmethod
def triple(n):
return n * 3
print(Util.triple(4))| call | n | returns |
|---|---|---|
| Util.triple(4) | 4 | ? |
Check your understanding
What does this print?
Answer: A
Why: triple is a @staticmethod, so it takes only n. Util.triple(4) returns 4 * 3 = 12. Verified by execution.
Check
cls is the class itself.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
@classmethod
def from_string(cls, s):
x, y = s.split(",")
return cls(int(x), int(y))
p = Point.from_string("3,4")
print(p.x)| s | x | p.x |
|---|---|---|
| "3,4" | "3" | ? |
Check your understanding
What does print(p.x) show?
Answer: A
Why: from_string splits "3,4", converts with int, and returns cls(3, 4) - a real Point whose x is the integer 3. Verified by execution.
Check
Note there are no parentheses on total.
class Cart:
def __init__(self, price, qty):
self.price = price
self.qty = qty
@property
def total(self):
return self.price * self.qty
c = Cart(5, 3)
print(c.total)| price | qty | c.total |
|---|---|---|
| 5 | 3 | ? |
Check your understanding
What does print(c.total) show?
Answer: A
Why: Because total is a @property, c.total runs the method and returns 5 * 3 = 15 - no parentheses needed. Verified by execution.
Check
area is a property with only a getter.
class Sq:
def __init__(self, side):
self.side = side
@property
def area(self):
return self.side ** 2
s = Sq(3)
s.area = 100| line | result |
|---|---|
| Sq(3) | ok |
| s.area (read) | 9 |
| s.area = 100 | ? |
Check your understanding
What happens on s.area = 100?
Answer: A
Why: area is a property with no matching setter, so assigning to it raises AttributeError: property 'area' of 'Sq' object has no setter. Verified by execution.
Trade off
Comparison matrix
From Check: no setter: every row here is a choice with a cost. Fill the result column, then say which row you would actually pick and what you give up for it.
| line | result |
|---|---|
| Sq(3) | ok |
| s.area (read) | 9 |
| s.area = 100 | ? |
Check
The setter guards the value.
class Score:
def __init__(self, v):
self.v = v
@property
def v(self):
return self._v
@v.setter
def v(self, value):
if not 0 <= value <= 100:
raise ValueError("score must be 0-100")
self._v = value
s = Score(150)| step | value | 0<=v<=100 |
|---|---|---|
| Score(150) | 150 | False |
Check your understanding
What happens when Score(150) runs?
Answer: A
Why: __init__ does self.v = v, which runs the setter with value=150; the check fails and it raises ValueError: score must be 0-100. Verified by execution.
Check
count is shared at the class level.
class Dog:
count = 0
def __init__(self):
Dog.count += 1
@classmethod
def total(cls):
return cls.count
Dog()
Dog()
Dog()
print(Dog.total())| event | count |
|---|---|
| start | 0 |
| 3 x Dog() | ? |
| total() | ? |
Check your understanding
What does print(Dog.total()) show?
Answer: A
Why: Each Dog() bumps the shared Dog.count, so after three it is 3; total() returns cls.count = 3. Verified by execution.
Comparison
Comparison matrix
From Check: cls counter: refill the count column from what you know. The rest of the table is as it appeared.
| event | count |
|---|---|
| start | 0 |
| 3 x Dog() | ? |
| total() | ? |
Check
fahrenheit has no setter.
class T:
@property
def celsius(self):
return self._c
@celsius.setter
def celsius(self, v):
self._c = v
@property
def fahrenheit(self):
return self._c * 9 / 5 + 32
t = T()
t.celsius = 100
print(t.fahrenheit)| t.celsius | t.fahrenheit |
|---|---|
| 100 | ? |
Check your understanding
What does print(t.fahrenheit) show?
Answer: A
Why: celsius = 100 stores _c = 100; reading fahrenheit computes 100 * 9 / 5 + 32 = 212.0. It is only read here, so the missing setter is fine. Verified by execution.
Connect it up
Draw it
One page, no notation unless you need it: draw how these connect — @staticmethod: A Namespaced Helper · @classmethod: Alternate Constructors · Three Kinds of Method · @property: A Computed Attribute · The Setter: Validating Assignment · Read-Only Properties. Put an arrow wherever one of them is what makes another possible, and label the arrow with why.
Recap
Three decorators reshape a class: @staticmethod (no self), @classmethod (cls, for alternate constructors), and @property (a computed attribute, with an optional validating setter).
| You write | It means |
|---|---|
| @staticmethod def f(a): | a helper with no self; call on class or instance |
| @classmethod def from_x(cls, s): | build and return cls(...) from other input |
| @property def area(self): | read obj.area like an attribute; runs the method |
| @area.setter def area(self, v): | obj.area = v runs code - validate here |
| property, no setter | read-only; assigning raises AttributeError |
Reach for a property + setter when an attribute needs a guard; a raw attribute stores anything unchecked. Next session we build on this with dunder methods and operator overloading.
Want this taught 1-on-1? Alexander tutors Python Fundamentals — $55/session, free consultation.