Exercises — Properties — `@property`, `@setter`, `@deleter` for controlled access
The one picture to keep in your head the whole way down:

obj.x or
obj.x = v, which looks like plain data in/out. Centre (red box) = the property itself,
holding three little functions: fget (get), fset (screen/validate), fdel (close/clean up).
Right box: the actual storage self._x (the backing field). The black arrows show the flow —
the caller never touches storage directly; every access is routed through the red property,
which is exactly why it can validate, compute, or block.
The red slot is the property. From outside, obj.x and obj.x = v look like plain data
in/out. Inside, three little functions (fget, fset, fdel) get to decide what really happens.
Level 1 — Recognition
Goal: identify what each decorator does and what a property object even is.
Exercise 1.1 (L1)
Given the class below, which lines run a function (not a plain dictionary lookup)?
class C:
@property
def x(self):
return self._x
@x.setter
def x(self, v):
self._x = v
c = C()
c.x = 10 # line A
y = c.x # line B
c._x = 99 # line CRecall Solution 1.1
- Line A runs the setter
fset(becausexon the class is a data descriptor). - Line B runs the getter
fget. - Line C is a plain instance-dict write — the name is
_x, and there is no descriptor named_x, so nothing intercepts it. It just stores intoc.__dict__['_x'].
Answer: A and B run functions; C does not.
Exercise 1.2 (L1)
Match each decorator to the operation it intercepts:
@property, @x.setter, @x.deleter ↔ del obj.x, obj.x, obj.x = v.
Recall Solution 1.2
@property→obj.x(read / get)@x.setter→obj.x = v(write / set)@x.deleter→del obj.x(delete)
Mnemonic from the parent note: Get-See, Set-Screen, Del-Decommission.
Exercise 1.3 (L1)
True or false: a property is an instance attribute stored in obj.__dict__.
Recall Solution 1.3
False. A property lives on the class (type(obj).__dict__['x']), not on the instance.
That placement is exactly why it can intercept access — Python's attribute lookup searches the
class chain for descriptors before trusting the instance dict.
Level 2 — Application
Goal: write a working property that validates.
Exercise 2.1 (L2)
Write a class Person whose age property rejects any value below 0 (raise ValueError) and
accepts everything >= 0. Then predict the result of:
p = Person(30)
p.age = 5 # (i)
p.age = -1 # (ii)
print(p.age) # (iii) -- what prints, assuming (ii) was caught?Recall Solution 2.1
class Person:
def __init__(self, age):
self.age = age # goes THROUGH the setter → validated at birth
@property
def age(self):
return self._age
@age.setter
def age(self, value):
if value < 0:
raise ValueError("age cannot be negative")
self._age = value- (i)
p.age = 5→ valid, stores5. - (ii)
p.age = -1→ValueErrorraised;_ageis unchanged, still5. - (iii) prints
5.
Key point: because the setter raises before the assignment self._age = value, a rejected
value never touches storage — the old valid value survives. This preserves the invariant
"age ≥ 0" at all times.
Exercise 2.2 (L2)
Add a read-only computed property is_adult to Person that returns True when age >= 18.
What happens on p.is_adult = True?
Recall Solution 2.2
@property
def is_adult(self):
return self._age >= 18p.is_adult = True raises AttributeError: can't set attribute (or property 'is_adult' has no setter on newer Pythons) — because no @is_adult.setter was defined. That is exactly
the guarantee you want: a derived flag cannot be set inconsistently with age.
Level 3 — Analysis
Goal: reason about lookup order, staleness, and side effects.
Exercise 3.1 (L3)
A property is a data descriptor. Given:
class C:
@property
def x(self):
return "from property"
c = C()
c.__dict__['x'] = "from instance dict" # sneak a value into the instance
print(c.x)What prints, and why?
Recall Solution 3.1
Prints from property.
Reason — the descriptor lookup order:
- Search
type(c).__mro__forx. Found: it's a data descriptor (property defines__get__and__set__). - A data descriptor wins over the instance dict, so
x.__get__(c, C)runs and the sneaked-in dict value at step 3 is never consulted.
This is the whole reason properties are trustworthy: you cannot bypass the getter by poking the
instance __dict__. (See Descriptors __get__ __set__ __delete__.)
Exercise 3.2 (L3)
This Circle caches its area. Trace exactly what prints:
class Circle:
def __init__(self, r):
self._r = r
self._area = None
@property
def area(self):
if self._area is None:
print("computing")
self._area = 3.141592653589793 * self._r ** 2
return self._area
@property
def radius(self):
return self._r
@radius.setter
def radius(self, v):
self._r = v
self._area = None
c = Circle(2)
c.area # (1)
c.area # (2)
c.radius = 3
c.area # (3)Recall Solution 3.2
- (1)
_areaisNone→ printscomputing, computesπ·2² ≈ 12.566370614359172, caches it, returns it. - (2)
_areais no longerNone→ prints nothing, returns the cached≈ 12.5664. c.radius = 3runs the setter, which invalidates the cache:_area = None.- (3)
_areaisNoneagain → printscomputing, computesπ·3² ≈ 28.274333882308138.
So the output is: computing (once), silence, computing (again). The setter's job is to keep
the derived value from going stale. Compare with functools.cached_property, which caches
but does not auto-invalidate.
Level 4 — Synthesis
Goal: combine getter + setter + deleter to enforce a real invariant.
Exercise 4.1 (L4)
Build a Temperature class storing only celsius internally, exposing:
celsius— read/write, rejecting anything below absolute zero-273.15.fahrenheit— read/write. Reading computesC·9/5 + 32. Writing must convert back and store into celsius, reusing the celsius setter's validation (so a below-absolute-zero Fahrenheit is also rejected).
Then evaluate: for t = Temperature(25), what is t.fahrenheit? After t.fahrenheit = 212,
what is t.celsius? Does t.fahrenheit = -500 raise?
Recall Solution 4.1
class Temperature:
def __init__(self, celsius):
self.celsius = celsius # validated at birth
@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
@property
def fahrenheit(self):
return self._celsius * 9/5 + 32
@fahrenheit.setter
def fahrenheit(self, value):
self.celsius = (value - 32) * 5/9 # reuse celsius validation!t = Temperature(25)→t.fahrenheit = 25·9/5 + 32 = 45 + 32 =77.0.t.fahrenheit = 212→ celsius= (212 - 32)·5/9 = 180·5/9 =100.0.t.fahrenheit = -500→ celsius= (-500 - 32)·5/9 = -532·5/9 ≈ -295.56, which is below-273.15→ the celsius setter raisesValueError. ✔ The invariant is enforced no matter which scale you write through, because there is exactly one validation gate.
Design note: routing Fahrenheit writes through self.celsius (not self._celsius) means the
validation lives in one place — a core idea of Invariants and Class Design.
Exercise 4.2 (L4)
Extend Account: balance is read-only to outsiders, and del acc.balance runs a controlled
"close": prints "closed" and sets balance to 0. Provide deposit(amount) that raises
ValueError for negative amounts. Trace:
a = Account(100)
a.deposit(50) # (1)
a.balance = 5 # (2)
a.deposit(-10) # (3)
del a.balance # (4)
print(a.balance) # (5)Recall Solution 4.2
class Account:
def __init__(self, balance):
self._balance = balance
@property
def balance(self):
return self._balance
@balance.deleter
def balance(self):
print("closed")
self._balance = 0
def deposit(self, amount):
if amount < 0:
raise ValueError("negative deposit")
self._balance += amount- (1)
deposit(50)→_balancebecomes150. - (2)
a.balance = 5→AttributeError(no setter). Outsiders can't set balance directly; onlydeposit/internal code changes it via_balance. - (3)
deposit(-10)→ValueError;_balanceunchanged, still150. - (4)
del a.balance→ printsclosed, sets_balance = 0. - (5) prints
0.
"Read-only" blocks outside assignment but not internal mutation via _balance — read-only to
the world, editable by trusted methods. (See Encapsulation and Access Modifiers.)
Level 5 — Mastery
Goal: predict subtle behaviour and design correctly under constraints.
Exercise 5.1 (L5)
Consider a rectangle whose invariant is width > 0 and height > 0, with a read-only
computed area. A student wants a single method scale(k) that multiplies both sides by k.
Write the class, and explain what should happen for scale(0) and scale(-2). Compute the area
of Rect(3, 4) after scale(2).
Recall Solution 5.1
class Rect:
def __init__(self, width, height):
self.width = width # each goes through its setter
self.height = height
@property
def width(self):
return self._width
@width.setter
def width(self, v):
if v <= 0:
raise ValueError("width must be positive")
self._width = v
@property
def height(self):
return self._height
@height.setter
def height(self, v):
if v <= 0:
raise ValueError("height must be positive")
self._height = v
@property
def area(self):
return self._width * self._height
def scale(self, k):
self.width = self._width * k # routed → validated
self.height = self._height * kBecause scale assigns through the setters:
scale(0)→ new width= 0, setter sees0 <= 0→ValueError. Good: a zero-area degenerate rectangle violates the invariant, so it's blocked.scale(-2)→ new width negative →ValueError. Blocked. Both degenerate/negative cases are handled by the same positivity gate — no special-casing needed.Rect(3, 4)thenscale(2)→ width6, height8,area = 6·8 =48.
Subtlety worth noting: scale sets width first. If the new width passes but a bad k would
fail height, width has already changed → the object is left half-scaled. For true atomicity
you'd validate both before assigning either. This is the frontier of
Invariants and Class Design: an invariant must hold after every public operation completes,
not just per-attribute.
Exercise 5.2 (L5)
Predict the output of the program below. Background you may use: a plain @property is a data
descriptor (it defines __set__ — the "can't set attribute" raiser), so it wins over the
instance __dict__ on every access. By contrast functools.cached_property is a non-data
descriptor (only __get__): on first access it writes the computed result into the instance
__dict__ under the same name, and thereafter the instance-dict entry shadows it.
class A:
@property
def val(self):
print("prop")
return 1
a = A()
a.val
a.valHow many times does prop print — and how would the answer change if val were a
functools.cached_property instead?
Recall Solution 5.2
With a plain @property, prop prints twice — once per access.
Why: as a data descriptor, the property beats the instance dict on every read, so
fget re-runs each time a.val is evaluated. There is no automatic caching and nowhere for a
cache to live and win — even if a value sat in a.__dict__['val'], the data descriptor at
step 2 of the lookup would still intercept before step 3 (the instance dict) is ever consulted.
If val were a @functools.cached_property instead, prop would print once: on the first
access it stores 1 into a.__dict__['val']; being a non-data descriptor, it lets the
instance-dict entry shadow it on every subsequent read, so fget never runs again. That is the
exact behavioural fork between functools.cached_property and @property.
Recall One-line self-test recap
Property is a class-level data descriptor ::: it beats the instance dict, so getter/setter always run.
Backing field uses _x ::: assigning self.x inside the setter would recurse forever.
Missing setter ::: obj.x = v raises AttributeError — that's how you make a value read-only.
Route all writes through one setter ::: keeps a single validation gate so the invariant can't drift.
Plain @property vs cached_property ::: property recomputes every time; cached_property stores in the instance dict and recomputes never.