2.1.7 · D4OOP Fundamentals

Exercises — Properties — `@property`, `@setter`, `@deleter` for controlled access

2,829 words13 min readBack to topic

The one picture to keep in your head the whole way down:

Figure — Properties — `@property`, `@setter`, `@deleter` for controlled access
Figure s01 — the property as a guarded slot. Left box: the caller writing 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 C
Recall Solution 1.1
  • Line A runs the setter fset (because x on 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 into c.__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.deleterdel obj.x, obj.x, obj.x = v.

Recall Solution 1.2
  • @propertyobj.x (read / get)
  • @x.setterobj.x = v (write / set)
  • @x.deleterdel 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, stores 5.
  • (ii) p.age = -1ValueError raised; _age is unchanged, still 5.
  • (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 >= 18

p.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:

  1. Search type(c).__mro__ for x. Found: it's a data descriptor (property defines __get__ and __set__).
  2. 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) _area is None → prints computing, computes π·2² ≈ 12.566370614359172, caches it, returns it.
  • (2) _area is no longer Noneprints nothing, returns the cached ≈ 12.5664.
  • c.radius = 3 runs the setter, which invalidates the cache: _area = None.
  • (3) _area is None again → prints computing, 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 computes C·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 raises ValueError. ✔ 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)_balance becomes 150.
  • (2) a.balance = 5AttributeError (no setter). Outsiders can't set balance directly; only deposit/internal code changes it via _balance.
  • (3) deposit(-10)ValueError; _balance unchanged, still 150.
  • (4) del a.balance → prints closed, 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 * k

Because scale assigns through the setters:

  • scale(0) → new width = 0, setter sees 0 <= 0ValueError. 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) then scale(2) → width 6, height 8, 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.val

How 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.