The Advanced Object Model
Expert · 18 min read · ▶ live playground · ✦ checkpoint
@property looks polite, almost decorative. Under it, Python is running one of the deepest rules in the language: classes are objects, attributes can be managed by other objects, and object creation itself has steps you can intercept.
Properties are descriptors
A descriptor is an object that controls attribute access through special methods such as __get__ and __set__. You already used the friendly version in Encapsulation: a property lets outside code write card.credit while the class runs method code behind the scenes.
Here is the reveal: property itself is a descriptor. hasattr(obj, name) asks whether an object has an attribute with that text name, so hasattr(LunchCard.credit, "__get__") checks whether the property object has a __get__ hook.
class LunchCard:
def __init__(self, owner, credit):
self.owner = owner
self._credit = credit
@property
def credit(self):
return self._credit
print(type(LunchCard.credit).__name__)
print(hasattr(LunchCard.credit, "__get__"))
print(hasattr(LunchCard.credit, "__set__"))propertyTrueTrue
That last True does not mean a read-only property accepts assignment. A property object has the set hook, but without a setter its default behavior is to reject assignment.
Read the dot as a small interview. When Python sees card.credit, it does not only check the instance's stored attributes. It also checks the class. If the class has a descriptor named credit, Python lets that descriptor answer the access.
Think of a descriptor as a gate clerk attached to a blueprint attribute. The name still looks like an attribute from the outside, but the clerk can inspect, compute, validate, or store the value somewhere else.
Write a small descriptor
A hook is a method Python calls at a specific moment. Descriptors use hooks. __get__ runs when the attribute is read. __set__ runs when the attribute is assigned. __set_name__ runs when Python creates the owner class, so the descriptor can learn which attribute name it was assigned to.
This example uses three attribute helpers. getattr(obj, name, default) reads an attribute using a text name and returns the default if it is missing. setattr(obj, name, value) assigns an attribute using a text name. obj.__dict__ is the usual instance namespace dictionary, the place where many instance attributes are stored.
class PositiveScore:
def __set_name__(self, owner, name):
self.storage_name = "_" + name
def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.storage_name, 0)
def __set__(self, instance, value):
if value < 0:
raise ValueError("score cannot be negative")
setattr(instance, self.storage_name, value)
class Player:
score = PositiveScore()
def __init__(self, name, score):
self.name = name
self.score = score
mina = Player("Mina", 10)
print(type(Player.score).__name__)
print(mina.score)
mina.score = 25
print(mina.score)
try:
mina.score = -3
except ValueError as error:
print("Rejected:", error)
print(mina.__dict__)PositiveScore1025Rejected: score cannot be negative{'name': 'Mina', '_score': 25}
Player.score lives on the class. mina._score lives on the instance. The descriptor connects them. That is why mina.score = 25 can run validation and then store _score in the instance namespace.
You do not need a custom descriptor for every validation rule. @property is the normal tool for one class. Descriptors become useful when the same managed-attribute behavior should be reused across many classes.
Classes are created too
A class statement feels like it declares a blueprint. Under the hood, it builds one. A metaclass is the class that creates classes. If a class is a house blueprint, a metaclass is the blueprint workshop that makes blueprints.
Most classes are made by type. You have used type(value) to inspect an object. The same built-in can also create a class when you give it a name, base classes, the parent classes it inherits from, and a class namespace.
A class namespace is the mapping of names created inside the class body before the class object exists.
def badge_init(self, player):
self.player = player
PracticeBadge = type(
"PracticeBadge",
(),
{
"category": "practice",
"__init__": badge_init,
"label": lambda self: f"{self.player}: {self.category}",
},
)
badge = PracticeBadge("Noor")
print(PracticeBadge.__name__)
print(type(PracticeBadge).__name__)
print(badge.label())
class AuditMeta(type):
def __new__(metaclass, name, bases, namespace):
namespace["created_by"] = "AuditMeta"
return super().__new__(metaclass, name, bases, namespace)
class Quest(metaclass=AuditMeta):
pass
print(type(Quest).__name__)
print(Quest.created_by)PracticeBadgetypeNoor: practiceAuditMetaAuditMeta
That type(...) call is the longhand shape of class creation: class name, parent classes, class namespace. A normal class PracticeBadge: statement performs that work for you in a cleaner form.
AuditMeta is a custom metaclass. It receives the class name, base classes, and namespace, adds one class attribute, then asks type to finish building the class. In day-to-day code, class decorators and plain base classes are usually easier. Metaclasses are for framework-level tools that need to control class creation itself.
__new__ builds, __init__ fills
__init__ is familiar now, but its name is a little misleading. It does not create the object. __new__ is the special method that creates and returns a new instance. __init__ is the setup method that fills an already-created instance with starting state.
class Player:
def __new__(cls, name):
print("__new__ builds", cls.__name__)
instance = super().__new__(cls)
return instance
def __init__(self, name):
print("__init__ fills", name)
self.name = name
player = Player("Mina")
print(player.name)
print(type(player).__name__)__new__ builds Player__init__ fills MinaMinaPlayer
Python calls the class, the metaclass handles the call, __new__ builds the blank instance, and __init__ fills it. Most classes only need __init__. You reach for __new__ when creation itself matters, often with immutable objects or framework internals.
Try it - watch the object model
Run this, then change ScoreField to reject scores above 100. You are editing a reusable descriptor, not the Player class body.
The playground shows three layers in order. Class creation binds the descriptor. Instance creation runs __new__, then __init__. Attribute access goes through the descriptor and stores data on the instance.
The data model view
The data model is Python's set of protocols and hook methods that let objects answer normal syntax and built-ins. Dunder methods are not random magic names. They are the official buttons Python presses when you use the language.
Here is the full map at course level, not a reference-manual list of every dunder name:
- Identity and type:
id(obj),is,type(obj), classes, metaclasses, and the heap model from 26.1. - Namespaces and attributes: instance attributes, class attributes,
__dict__,__slots__, properties, and descriptors all shapeobj.attr. - Construction:
Class(...), metaclass__new__, instance__new__, and instance__init__create and fill objects. - Representation and comparison:
__repr__,__str__,__eq__, and ordering hooks let objects print and compare like native values. - Operators and calls:
__add__, other operator hooks, and__call__let objects answer+and parentheses. - Containers and iteration:
__len__,__getitem__,__contains__,__iter__,__next__, and generators make objects work withlen, indexing,in, and loops. - Context and cleanup:
__enter__,__exit__, async context methods, and generator cleanup patterns let objects manage setup and teardown. - Class relationships: inheritance, MRO,
super(), mixins, class methods, static methods, and descriptors decide where behavior is found.
That is the fuller object model: Python does not bolt special behavior onto objects from the outside. The object tells Python how to behave by exposing agreed-upon hooks. A reference-level tour would list hundreds of details; this map gives you the whole shape you need for real code.
Checkpoint
Answer all three to mark this lesson complete
Now the class machinery has fewer shadows: descriptors manage attributes, metaclasses build classes, and __new__ creates objects before __init__ fills them. Next, you look at the performance edge: when pure Python is enough, when caching helps, and when you reach beyond the default runtime.