Lesson 25. Construction
Mission link: Most classes need only __init__, and the ones that need __new__ need it for a specific, recognisable reason. Knowing which is which prevents both the copied __new__ nobody can explain and the constructor that takes eleven arguments because nobody named the alternatives.
Primary source: The Python Language Reference, Basic customization
Prerequisites: Lesson 12, Lesson 22
Warm-up
- ▢ Lesson 12: where does validation go in a dataclass?
Check
__post_init__, which runs after the generated __init__.
- ▢ What does
Order(10)do, in two steps?
Check
Creates the object, then initialises it. Those are two different methods, which is the subject of this lesson.
Know this
Order(10) calls the class, and the class's type runs two methods:
obj = Order.__new__(Order, 10) # allocate and return an instance
if isinstance(obj, Order):
obj.__init__(10) # initialise it, return None
__new__ creates, __init__ configures. __new__ receives the class and returns an instance; __init__ receives the instance and returns None. If __new__ returns something that is not an instance of the class, __init__ is skipped entirely, which is a real source of confusion in code that returns a cached object.
For ordinary classes, only __init__ is needed. The default __new__ does the right thing and there is no reason to touch it.
Three cases where __new__ is required
Subclassing an immutable type. By the time __init__ runs, the value already exists and cannot be changed:
class Upper(str):
def __new__(cls, value: str):
return super().__new__(cls, value.upper())
Upper("abc") # 'ABC'
The two methods sit on opposite sides of one line. __new__ is the only one that runs while the value is still undecided, which is why it is the only one that can decide it.
The same applies to int, float, tuple, bytes and frozenset. Doing this in __init__ has no effect, because self is already the finished string.
Returning an existing instance, such as a cache or a singleton. Note the consequence: __init__ still runs on the cached instance unless you guard it, which quietly reinitialises shared state.
Controlling allocation, which in practice means __slots__ interacting with immutable bases, copy and pickle protocols, and metaclass-adjacent machinery. If you are not doing one of those, you do not need it.
Everything else that looks like a reason usually is not. "Validate before creating" is __init__ raising, which is equivalent from the caller's side. "Return a subclass depending on the arguments" is a classmethod or a module-level function, which is both clearer and inspectable.
Named constructors beat argument soup
@dataclass(frozen=True)
class Order:
id: int
amount: Decimal
country: str
@classmethod
def from_row(cls, row: sqlite3.Row) -> "Order":
return cls(id=row["id"], amount=Decimal(row["amount"]), country=row["country"])
@classmethod
def from_json(cls, payload: dict[str, object]) -> "Order":
...
__init__ takes the canonical fields, and every other way of building the object gets a name. Three properties make this better than one flexible constructor: the name says which source the data came from, each one can validate what only it can validate, and cls rather than Order means subclasses get working constructors for free.
This is the standard library's own pattern: dict.fromkeys, datetime.fromisoformat, Decimal.from_float, Path.cwd, int.from_bytes.
__init_subclass__, the hook that replaces most metaclasses
class Plugin:
registry: ClassVar[dict[str, type["Plugin"]]] = {}
def __init_subclass__(cls, /, name: str, **kwargs) -> None:
super().__init_subclass__(**kwargs)
Plugin.registry[name] = cls
class CsvPlugin(Plugin, name="csv"): ...
class JsonPlugin(Plugin, name="json"): ...
Plugin.registry # {'csv': <class 'CsvPlugin'>, 'json': <class 'JsonPlugin'>}
It runs once per subclass, at class-creation time, and receives keyword arguments passed in the class header. Registration, subclass validation, and "every subclass must define handles" all live here, and none of them need a metaclass. Lesson 27 makes that argument fully.
__set_name__, from lesson 23, is the same idea for descriptors: a class-creation hook instead of a metaclass.
__del__ is not a destructor
def __del__(self):
self.file.close() # do not rely on this
__del__ runs when the object is collected, which is unspecified in timing, may be at interpreter shutdown when module globals are already gone, and never happens at all for objects in a reference cycle in some situations. Exceptions raised inside it are printed and swallowed.
Release resources with a context manager from lesson 11. __del__ is acceptable as a last-resort warning that something was not closed, and unacceptable as the mechanism that closes it.
Practice
-
▢ Why does this print
abcrather thanABC?class Upper(str): def __init__(self, value): self = value.upper() print(Upper("abc"))
Check
Two independent reasons, and both are worth seeing.
str is immutable, so by the time __init__ runs the string already exists with the original characters. And assigning to self rebinds a local name, which is lesson 1: it cannot change what the caller received.
The fix is __new__, because creation is the only moment an immutable value can be chosen.
-
▢ Find the bug.
class Connection: _instance = None def __new__(cls, dsn): if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance def __init__(self, dsn): self.dsn = dsn self.pool = []
Hint
Create two connections with different arguments, then look at the first one.
Check
__init__ runs on every call, including the ones that returned the cached instance. So Connection("db2") reassigns dsn on the single shared object and replaces pool with an empty list, silently discarding whatever the first caller was using.
Guards exist (if hasattr(self, "dsn"): return), and they make a confusing class more confusing. The better answers: a module-level function returning a memoised connection, functools.cache on a factory function, or dependency injection so the object's lifetime is the caller's decision.
-
▢ Which of these need
__new__?- a) A
Celsiusclass that rejects values below absolute zero - b) A
Durationsubclass ofintthat stores seconds and clamps negatives to zero - c) A
Colourclass returning a shared instance for each named colour - d) A
Configclass assembled from three different file formats
- a) A
Check
- a) No. Raise in
__init__, or use__post_init__in a dataclass. - b) Yes.
intis immutable, so the value must be chosen at creation. - c) Yes, technically, and consider not doing it: a module-level
by_namedict or a cached factory function achieves the same interning without hiding it in the constructor. - d) No. Three classmethods,
from_toml,from_json,from_env, each with a name.
-
▢ Replace this with named constructors, and say what improves.
class Order: def __init__(self, row=None, payload=None, id=None, amount=None): if row is not None: self.id, self.amount = row["id"], Decimal(row["amount"]) elif payload is not None: self.id, self.amount = payload["id"], Decimal(str(payload["amount"])) else: self.id, self.amount = id, amount
Check
@dataclass(frozen=True)
class Order:
id: int
amount: Decimal
@classmethod
def from_row(cls, row) -> "Order":
return cls(id=row["id"], amount=Decimal(row["amount"]))
@classmethod
def from_payload(cls, payload) -> "Order":
return cls(id=payload["id"], amount=Decimal(str(payload["amount"])))
What improves: every parameter is now required and typed, so a checker can verify the call; Order() with no arguments is no longer legal; the three code paths cannot be reached by accident; and each converter can raise a message naming its own source. The if/elif chain also had a fourth reachable state, all arguments None, which produced an object with id=None.
-
▢ What replaces a metaclass here, and why is it better?
Requirement: every subclass of
Handlermust define arouteattribute, and must register itself in a lookup table.
Check
class Handler:
registry: ClassVar[dict[str, type["Handler"]]] = {}
def __init_subclass__(cls, **kwargs) -> None:
super().__init_subclass__(**kwargs)
if not getattr(cls, "route", None):
raise TypeError(f"{cls.__name__} must define a route")
Handler.registry[cls.route] = cls
Better because it is ordinary code in the ordinary place: readers find it in the class they already have open, it does not change the class's type, it composes with other base classes without metaclass conflicts, and both the check and the registration run at import time where the error is easy to attribute.
- ▢ A colleague closes a file in
__del__"as a safety net". What do you tell them?
Check
That it is not a net. Its timing is unspecified, so the file can stay open long past the last use; at interpreter shutdown the module globals it needs may already be None; an object in a reference cycle may never reach it; and any exception it raises is printed and discarded, so a failure to flush is invisible.
The mechanism is a context manager, which runs on every path out of the block. __del__ is defensible only as a warning: log that something was not closed, so the missing with gets fixed.
Real-world reps
- [ ] Search code you own for
__new__and, for each, decide which of the three legitimate reasons applies. Any that match none can become__init__. - [ ] Find a constructor with mutually exclusive optional parameters and split it into named classmethods. Count how many arguments each one needs.
- [ ] Find a class with a registry populated by a decorator or a metaclass and try
__init_subclass__instead. - [ ] Look for
__del__in any codebase and check what it releases. If it is a resource, find out whether callers use awith. - [ ] Tomorrow: subclass
strorintfor a domain value and confirm that only__new__can shape it.
Going further
- Basic customization:
__new__,__init__,__del__and their contracts __init_subclass__: the class-creation hook, and how keyword arguments reach it- PEP 487, Simpler customisation of class creation: the case for
__init_subclass__and__set_name__over metaclasses object.__del__: the documented warnings about timing and shutdown- Resources
Not landing? Reread the primary source at the top, since this lesson compresses it and compression is where understanding leaks. Check the glossary for any term that felt slippery.
If the lesson itself is unclear rather than the material, that is a defect: open an issue.