2.1.7 · D2OOP Fundamentals

Visual walkthrough — Properties — `@property`, `@setter`, `@deleter` for controlled access

2,283 words10 min readBack to topic

This unpacks [[2.1.07 Properties — @property, @setter, @deleter for controlled access (Hinglish)|the parent topic]] and leans on Encapsulation and Access Modifiers, Descriptors __get__ __set__ __delete__, and Class vs Instance Attributes.


Step 0 — Before anything: __getattribute__ runs first

WHAT. The very first thing that happens when you type obj.x is not the descriptor search. Python calls a hidden method ==__getattribute__== — the single gatekeeper for every attribute read. It is that method which then runs the ladder we build below. If the whole ladder fails and raises AttributeError, Python calls a fallback method ==__getattr__== (only-on-failure).

WHY. A reader might think "descriptor lookup is step one." It is not — it lives inside __getattribute__. Knowing this prevents the false belief that you can't intercept lookup earlier (you can, by overriding __getattribute__), and explains where __getattr__ fits.

PICTURE. One door (__getattribute__) that every read passes through; it runs the ladder; only if the ladder throws does the emergency exit __getattr__ open.

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

Step 1 — What "storage" even means: dictionaries along the MRO

WHAT. __getattribute__ needs places to look. Every object carries a private notebook, its instance dictionary obj.__dict__. Its class has a notebook too — but a class can inherit from base classes, so there is a chain of class notebooks: the MRO (Method Resolution Order), the exact ordered list of classes Python searches. A dictionary here is a lookup table: name → value.

WHY. The simplification "one class notebook" is not quite true: Python walks type(obj).__mro__ — this class, then its parents, in order — checking each one's __dict__ until it finds the name. You cannot understand who wins until you know there are many class notebooks plus one instance notebook.

PICTURE. The MRO is a stack of class notebooks (child on top, then parents); beside it sits the single instance notebook.

Figure — Properties — `@property`, `@setter`, `@deleter` for controlled access
Recall

Why "MRO" and not just "the class dict"? ::: A class can inherit; Python searches this class then each base in __mro__ order, so a property defined on a parent still counts.


Step 2 — A plain attribute: the instance notebook wins

WHAT. With no property anywhere in the MRO, obj.x looks up the name x. __getattribute__ scans the MRO for a special x, finds nothing, then reads x straight out of obj.__dict__.

WHY. This is the baseline the parent note calls "start with a plain attribute." No function runs — you get the stored object back. We show it first so the change in Step 6 is obvious.

PICTURE. The arrow scans the class chain, finds no x, drops to the instance notebook, returns the raw value 25.

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

Here obj.__dict__['x'] means "the entry named x in this object's personal notebook" — and nothing runs, so setting obj.x = -300 would also succeed silently. That is the danger.


Step 3 — A descriptor: an object that reacts to lookup

WHAT. A descriptor is any object that lives in a class notebook (somewhere in the MRO) and defines at least one of three special methods: __get__, __set__, __delete__. When the scan finds such an object, it does not hand it back — it calls the matching method.

WHY. This is the mechanism that lets a value "react." A plain integer can't run code when read; a descriptor can. Properties are built on this. See Descriptors __get__ __set__ __delete__.

PICTURE. The three slots of a descriptor and which access each one intercepts.

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

Step 4 — Deletion has its own routing (del obj.x)

WHAT. Writes and deletes travel a parallel path via __setattr__/__delattr__. For del obj.x, Python asks: is x on the class (in the MRO) a data descriptor with __delete__? If so, call descriptor.__delete__(obj). Only if there is no such data descriptor does del fall through to removing x from obj.__dict__.

WHY. A reader might assume del obj.x always erases the instance entry. Not with a property: a property is a data descriptor, so del obj.x is hijacked into your @x.deleter — which is why the parent note can turn del acc.balance into a controlled "close account" action instead of a raw wipe.

PICTURE. Two exits for del: with a data-descriptor property → __delete__ runs; without one → the instance entry is popped.

Figure — Properties — `@property`, `@setter`, `@deleter` for controlled access
  • property.__delete__(obj) — routes to your @x.deleter; if you defined none, it raises AttributeError.
  • del obj.__dict__['x'] — the raw fallback that only happens when no data descriptor guards x.

Step 5 — The lookup order (the ranking rule)

WHAT. Here is the exact order __getattribute__ uses for obj.x. Read it as a priority list — the first rank that matches, wins. "Class" here means "found anywhere along the MRO."

WHY. In Step 2 there was no descriptor, so rank 1 was empty and the instance (rank 2) won — that's why plain attributes behave normally. The magic of the next step is that a property fills rank 1, jumping above the instance notebook.

PICTURE. A ladder: the search climbs down from the top rung, stopping at the first rung that holds an x.

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

Each rung means:

  • rank 1 — is there an x anywhere in the MRO that has __set__/__delete__? If so, call it. Done.
  • rank 2 — otherwise, is x in this object's own notebook? Return it.
  • rank 3 — otherwise a read-only descriptor (__get__ only) in the MRO?
  • rank 4 — otherwise a plain class attribute (e.g. a method, a shared constant).
  • else — raise AttributeError, which hands off to __getattr__ (Step 0).

Step 6 — Install a property: rank 1 fills up

WHAT. Now we put a property object into the class notebook under the name x. Because it defines __set__, it is a data descriptor → it occupies rank 1.

WHY. This is the central result forming. Reading obj.x now hits rank 1 first, so property.__get__ runs your getter. Writing obj.x = v hits property.__set__, which runs your setter — validation and all.

PICTURE. Same ladder as Step 5, but rank 1 is now occupied. The search stops at the top rung and never reaches the instance notebook.

Figure — Properties — `@property`, `@setter`, `@deleter` for controlled access
  • property.__set__ — the rank-1 write hook. It cannot be skipped from outside.
  • your setter runs — this is where if value < -273.15: raise lives.

Step 7 — The edge case: sneaking into the instance notebook

WHAT. The natural attack: "What if I bypass the setter and jam x straight into the instance notebook?" — e.g. obj.__dict__['x'] = -300 (going around __setattr__). Does that beat the property?

WHY. This is exactly the scenario the parent note calls the guarantee. A reader might assume the instance (rank 2) could override the class. It can't — because a data descriptor is rank 1, strictly above rank 2.

PICTURE. The value -300 sits inside the instance notebook, but the read still stops at rank 1 and calls the getter. The smuggled value is shadowed — invisible on read.

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

Step 8 — The degenerate case: no setter means no __set__? (it still blocks)

WHAT. A read-only property (only @property, no @setter). Subtle point: it still defines __set__ — but that __set__ immediately raises AttributeError.

WHY. A reader might guess "no setter ⇒ not a data descriptor ⇒ falls to rank 2 ⇒ writing lands in the instance dict." Wrong. The property type always implements __set__; when you gave no fset, that method's job is to refuse. So it stays rank 1 and blocks the write instead of silently storing it.

PICTURE. The write arrow hits rank 1's __set__, which throws — it never reaches the instance notebook.

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

This is why acc.balance = 50 raises rather than quietly creating acc.__dict__['balance'].


The one-picture summary

Everything compressed: obj.x enters through __getattribute__, which runs the ladder over the MRO plus the instance dict. A data-descriptor property occupies the top rung, so read → getter, write → setter (or refusal), delete → deleter — and the instance notebook below is always shadowed. That single geometry is the guarantee of controlled access.

Figure — Properties — `@property`, `@setter`, `@deleter` for controlled access
Recall Feynman retelling — the whole walkthrough in plain words

Every time you type obj.x, one gatekeeper — __getattribute__ — opens the door and runs a search; only if that whole search fails does an emergency method, __getattr__, get a turn. The search looks in two kinds of places: your object's personal notebook, and a chain of class notebooks (this class, then its parents, in MRO order). Normally it finds nothing special in the class chain and just reads the value out of your personal notebook — plain and unguarded. Now we drop a special reactive object called a property into the class notebook. Because it can react to writes, Python promotes it to the top of the priority ladder — above your personal notebook. From now on, reading runs the getter, writing runs the setter (which validates), and even deleting is hijacked into your deleter instead of erasing the entry. If a sneaky person crams a bad value straight into your personal notebook, nobody sees it: the read stops at the top rung and never looks down. And if you never wrote a setter, the property doesn't vanish — its built-in __set__ simply slams the door with an error. That top-rung position, held because a property is a data descriptor, is the entire reason controlled access can't be dodged.

Recall

What runs first on any obj.x, before the ladder? ::: __getattribute__ — it always runs and contains the ladder; __getattr__ runs only if the ladder raises AttributeError. Does Python search only type(obj).__dict__? ::: No — it walks the whole MRO (type(obj).__mro__), checking each class's dict in order. Which rung (rank) does a property occupy, and why? ::: Rank 1 (data descriptor) — it defines __set__, so it sits above the instance dict. Where does del obj.x go when x is a property? ::: To the property's __delete__ (your @x.deleter), not to popping obj.__dict__['x']. Why can't smuggling a value into obj.__dict__ bypass a property? ::: Reads stop at rank 1 (the property's getter) before ever reaching rank 2 (the instance dict). Does a read-only property lack __set__? ::: No — property always defines __set__; with no fset it raises AttributeError, keeping it rank 1. For a plain method (non-data descriptor), does the instance dict override it? ::: Yes — a same-named instance entry (rank 2) beats a non-data descriptor (rank 3).