2.1.7 · D3OOP Fundamentals

Worked examples — Properties — `@property`, `@setter`, `@deleter` for controlled access

3,414 words16 min readBack to topic

The scenario matrix

Every property you will ever write lands in one of these cells. We cover them all.

# Cell (case class) What it stresses Example that hits it
A Pure read (@property only) Getter returns stored value; write is blocked Ex 1
B Screened write (@setter with validation) Accept good values, reject bad ones Ex 2
C Boundary / limiting value The exact edge (== the limit) — accept or reject? Ex 2 (the -273.15 edge)
D Zero / degenerate input Empty string, 0, None, negative Ex 3
E Computed, read-only No stored field at all — value derived on the fly Ex 4
F Cached + invalidation Compute once, but stay consistent when source changes Ex 5
G Controlled delete (@deleter) del obj.x runs cleanup, not a blind wipe Ex 6
H The recursion trap Setter that accidentally calls itself Ex 7
I Real-world word problem Multiple properties keeping an invariant together Ex 8
J Exam-style twist Descriptor-lookup order: property vs instance __dict__ Ex 9

Prerequisite ideas we lean on: Encapsulation and Access Modifiers, Invariants and Class Design, Descriptors __get__ __set__ __delete__, Class vs Instance Attributes.


Ex 1 — Pure read (cell A)

Forecast: Guess — after p = Person("Ada"), what does p.name = "Bob" do? (Hold your guess.)

Steps.

  1. Store the real data in a backing field self._name.
    • Why this step? The leading underscore is the convention for "internal, hands off." Keeping the public name (name) separate from the storage name (_name) is what lets the property intercept access.
  2. Define a getter with @property, returning self._name.
    • Why this step? @property turns the method into a read-accessor: p.name now runs this function instead of returning a stored object.
  3. Define no setter.
    • Why this step? With no fset filled in, Python has nothing to run for p.name = ..., so it raises AttributeError. That is exactly our "read-only to the outside" rule.
class Person:
    def __init__(self, name):
        self._name = name
    @property
    def name(self):
        return self._name
 
p = Person("Ada")
p.name          # "Ada"
p.name = "Bob"  # AttributeError: property 'name' has no setter

Ex 2 — Screened write + the exact boundary (cells B & C)

Forecast: Should t.celsius = -273.15 succeed or fail? What about -273.16?

Steps.

  1. Getter returns self._celsius; setter takes value.
    • Why this step? We need a place to screen the value before it lands in storage — that place is fset.
  2. Guard: if value < -273.15: raise ValueError.
    • Why this step? We use strict < (not <=) because the limit itself is a valid physical temperature. Choosing the comparison operator is the boundary decision — this is cell C.
  3. Only after passing the guard, store self._celsius = value.
    • Why this step? Storing last guarantees a rejected value never corrupts the object.
class Temperature:
    def __init__(self, c):
        self.celsius = c            # goes THROUGH the setter
    @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

Ex 3 — Zero / degenerate inputs (cell D)

Forecast: Which of these four gets through? "ada", "", None, 0.

Steps.

  1. First check the type: if not isinstance(value, str).
    • Why this step? 0 and None are not strings. Checking type first stops us from calling string methods on a non-string (which would crash for a confusing reason instead of a clear one).
  2. Then check emptiness: if not value.strip().
    • Why this step? "" and " " (spaces only) are degenerate — technically strings but useless as names. .strip() collapses whitespace-only to empty.
  3. Store the cleaned value value.strip().
    • Why this step? Storing the trimmed form keeps the invariant "no leading/trailing spaces" true forever.
class Account:
    @property
    def username(self):
        return self._username
    @username.setter
    def username(self, value):
        if not isinstance(value, str):
            raise TypeError("username must be a string")
        if not value.strip():
            raise ValueError("username cannot be empty")
        self._username = value.strip()

Ex 4 — Computed, read-only (no stored field) (cell E)

The figure below shows a mint-filled circle. A lavender arrow runs from the centre outward — that is the radius r, the only number we store. A coral double-arrow stretches all the way across — that is the diameter, and you can see by eye it is exactly two radii long, so diameter = 2r. The mint shading is the area pi r^2. Nothing on this picture is stored except the lavender radius; the coral diameter and the mint area are drawn fresh from r — exactly what "computed property" means.

Figure — Properties — `@property`, `@setter`, `@deleter` for controlled access
Figure — Ex 4: the radius (lavender) is stored; the diameter (coral, = 2r) and area (mint fill, = πr²) are computed from it on every read.

Forecast: After c = Circle(2), if you write c.diameter = 10, does r change to 5? Guess.

Steps.

  1. Store only the primitive self._r.
    • Why this step? Radius is the single source of truth; everything else is derived. Storing derived values would risk them going out of sync (see Ex 5).
  2. diameter returns 2 * self._r; area returns .
    • Why this step? These are pure functions of r. Look at the figure: the diameter (coral) is just twice the radius (lavender), and the area (mint fill) is . No storage needed.
  3. Give neither a setter.
    • Why this step? Setting diameter = 10 is ambiguous — should r become 5? We refuse to guess; the property is read-only, so it raises AttributeError.
import math
class Circle:
    def __init__(self, r):
        self._r = r
    @property
    def diameter(self):
        return 2 * self._r
    @property
    def area(self):
        return math.pi * self._r ** 2

Ex 5 — Cache with invalidation (cell F)

The figure below is the decision flow. A butter box (call c.area) leads into a lavender diamond asking _area is None?. On yes you take the coral SLOW path: compute pi r^2 and store it. On no you take the mint FAST path: just hand back the cached number. The dotted arrow at the bottom is the crucial bit — the radius setter (coral) reaches up and sets _area = None again, which forces the next read back onto the slow path so it recomputes. That dotted arrow is cache invalidation.

Figure — Properties — `@property`, `@setter`, `@deleter` for controlled access
Figure — Ex 5: first read takes the slow compute-and-store path; later reads take the fast cached path; the radius setter invalidates the cache (dotted arrow) so stale values can never leak.

Forecast: Read the area for r=2, then set c.radius = 3. Does the next c.area show the new area or the stale one?

Steps.

  1. In __init__, set the sentinel self._area = None alongside self._r.
    • Why this step? The cache needs a "not computed yet" marker before any read happens. We choose None as that sentinel because a real area is a positive number, so None can never be mistaken for a valid cached value. Without this line, the very first c.area would hit if self._area is None with _area undefinedAttributeError.
  2. area getter: if self._area is None, compute and store it; else return the cached value.
    • Why this step? The None sentinel means "not computed yet." Look at the figure — the first call takes the slow path (compute), later calls take the fast path (return cache).
  3. radius setter sets self._area = None.
    • Why this step? This is the whole point of cell F. Changing the source invalidates the cache (the dotted arrow in the figure). Without this line, area would keep returning the old value forever — a silent bug.
  4. radius getter returns self._r.
    • Why this step? We still want normal read access to the radius.
import math
class Circle:
    def __init__(self, r):
        self._r = r
        self._area = None          # sentinel: "not computed yet"
    @property
    def area(self):
        if self._area is None:
            self._area = math.pi * 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      # invalidate!
 
c = Circle(2)
c.area          # computes -> ~12.566
c.radius = 3
c.area          # recomputes -> ~28.274
Recall When to reach for the built-in instead

Python ships functools.cached_property for exactly the compute-once case — but it does not auto-invalidate. If your source can change, you still write the manual setter shown here.


Ex 6 — Controlled delete (cell G)

Forecast: After del f.path, is f.path gone (AttributeError) or None?

Steps.

  1. In __init__, store the path in the backing field self._path = path.
    • Why this step? The property's getter, setter, and deleter all read and write self._path. If __init__ did not create it first, the very first f.path read would find no _path attribute → AttributeError. The backing field must exist before any accessor runs.
  2. Getter returns self._path; setter stores into self._path.
    • Why this step? Normal read/write of the path, both routed through the single backing field.
  3. @path.deleter runs cleanup and sets self._path = None.
    • Why this step? del f.path now routes into our function. We turn a destructive operation into a controlled decommission — the guard's "Delete" duty. We reset to None (not del self._path) so the getter still works afterwards.
class TempFile:
    def __init__(self, path):
        self._path = path
    @property
    def path(self):
        return self._path
    @path.setter
    def path(self, v):
        self._path = v
    @path.deleter
    def path(self):
        print("cleaning up", self._path)
        self._path = None
 
f = TempFile("/tmp/x")
del f.path      # prints "cleaning up /tmp/x"
f.path          # None  (attribute still exists!)

Ex 7 — The recursion trap (cell H)

Forecast: RecursionError, AttributeError, or works fine?

Steps.

  1. Trace one assignment obj.value = 5.
    • Why this step? obj.value = 5 triggers the setter. Inside, self.value = value is itself an assignment to value → triggers the setter again → and again…
  2. Count the depth.
    • Why this step? Each call adds a stack frame; Python halts at its recursion limit and raises RecursionError.
  3. Fix: assign to the different backing name self._value.
    • Why this step? _value is a plain instance attribute with no property, so its assignment does not re-enter the setter. The loop is broken.
class Broken:
    @property
    def value(self):
        return self._value
    @value.setter
    def value(self, v):
        self.value = v          # 😱 calls the setter again -> RecursionError

Ex 8 — Real-world word problem (cell I)

Forecast: Start with 2 × 3 (area 6). Set width = 5. What is the new area?

Steps.

  1. In __init__, assign through the property setters: self.width = w and self.height = h.
    • Why this step? Routing construction through the setters means the positivity check runs from birth — you can never build a rectangle with a bad side. This is the same "validate from construction" idea as Ex 2's temperature.
  2. Screen both width and height in their setters: reject value <= 0.
    • Why this step? A rectangle with a non-positive side is degenerate; blocking it upholds the invariant "sides are real lengths." (See Invariants and Class Design.)
  3. Make area a computed property: self._w * self._h.
    • Why this step? Because area is derived, it can never disagree with the sides — it's recomputed on every read. This sidesteps the whole "stale data" problem for free.
  4. No setter on area.
    • Why this step? Setting area is ambiguous (which side changes?), so it stays read-only — same reasoning as Ex 4.
class Rectangle:
    def __init__(self, w, h):
        self.width = w
        self.height = h
    @property
    def width(self):
        return self._w
    @width.setter
    def width(self, v):
        if v <= 0:
            raise ValueError("width must be positive")
        self._w = v
    @property
    def height(self):
        return self._h
    @height.setter
    def height(self, v):
        if v <= 0:
            raise ValueError("height must be positive")
        self._h = v
    @property
    def area(self):
        return self._w * self._h
 
r = Rectangle(2, 3)
r.area          # 6
r.width = 5
r.area          # 15  (recomputed, invariant held)
r.width = 0     # ValueError: width must be positive

Ex 9 — Exam-style twist: property vs instance dict (cell J)

The figure below stacks the five attribute-lookup steps as a ladder. Reading c.x starts at the top butter box (search the class for the name x). It reaches the mint box at step 2: "is this a data descriptor?" — and a property, because it defines __set__, is a data descriptor, so lookup stops right here and returns 42. The coral box at step 3 (the instance __dict__['x'] = 999) sits below step 2, so it is never reached. That ordering is the whole guarantee.

Figure — Properties — `@property`, `@setter`, `@deleter` for controlled access
Figure — Ex 9: attribute lookup order. A property is a data descriptor caught at step 2, above the instance __dict__ at step 3, so the property always wins.

Forecast: Does c.x return 42 or 999? Commit before reading on.

Steps.

  1. Recall the lookup order from the parent note: class MRO → data descriptor → instance __dict__ → non-data descriptor → AttributeError.
    • Why this step? The order is the entire answer. Because a property defines __set__/__delete__, it is a data descriptor, so it is checked before the instance __dict__. (See Descriptors __get__ __set__ __delete__.)
  2. Force a value into the instance dict with c.__dict__['x'] = 999, bypassing normal assignment.
    • Why this step? We cannot use c.x = 999, because that would run the property machinery (and here there's no setter, so it would raise AttributeError). Writing straight into __dict__ is the only way to smuggle a same-named entry in — the sneakiest attack a property must survive.
  3. Read c.x and see which layer answers.
    • Why this step? Python finds the property on the class at step 2, calls its __get__, returns 42, and never descends to the dict entry at step 3. Look at the figure: step 2 sits above step 3.
class C:
    @property
    def x(self):
        return 42
 
c = C()
c.__dict__['x'] = 999   # sneak a value into the instance dict
c.x                     # 42  -- the property (data descriptor) wins!
c.__dict__['x']         # 999 -- the smuggled value is still sitting there, just ignored
Recall Contrast with Java

In Getters and Setters in Java vs Python, Java can't do this "upgrade a field into a guarded accessor without changing callers" trick — you commit to getX()/setX() up front. Python's descriptor protocol is what makes the seamless upgrade possible.



Flashcards

Why use strict < not <= when rejecting temperatures below −273.15?
The limit itself is a valid physical temperature, so the edge must be accepted; <= would wrongly reject it.
In Ex 5, what single line prevents the cached area from becoming stale?
self._area = None inside the radius setter (cache invalidation).
Why does c.x return 42 even when c.__dict__['x'] is 999?
A property is a data descriptor, checked before the instance dict in attribute lookup (step 2 beats step 3).
Would a non-data descriptor (only __get__) also beat an instance dict entry of the same name?
No — a non-data descriptor loses to the instance dict; only a data descriptor (has __set__) wins.
What crashes if a setter does self.value = v instead of self._value = v?
RecursionError — the assignment re-triggers the setter endlessly.
After del f.path with a @path.deleter that sets _path=None, what is f.path?
None — the attribute still exists via the getter; delete was turned into a controlled reset.
Why give area no setter in the Rectangle example?
Setting area is ambiguous (which side changes?), and computing it keeps the invariant area == w*h automatically true.