2.1.2 · D2OOP Fundamentals

Visual walkthrough — `__init__` constructor — initializing attributes

2,426 words11 min readBack to topic

Prerequisites you may want open: __new__ vs __init__, self and instance attributes, and the parent OOP Fundamentals.


Step 1 — Start with the raw problem: an empty box

WHY start here? Because you cannot fill something that does not yet exist. Every object's life begins as an empty container; the whole point of __init__ is to walk up to this empty box and pack it.

PICTURE — Figure s01: a grey rounded box labelled "obj (in memory)", empty inside, with a red note beneath reading box.name -> would fail. The box has an identity (a place in memory) but zero attributes — no name, no age. Trying to read box.name right now would fail.

Figure — `__init__` constructor — initializing attributes

Step 2 — Calling the class is a function call in disguise

WHY does a class respond to ()? Because a class is itself an object — an instance of the built-in type. Anything you can call with () must have a __call__ behind it. For classes, that __call__ lives on type and it is the conductor of the whole build.

PICTURE — Figure s02: a grey box "your code" holding Person("Asha", 30) on the left, an orange arrow labelled "invokes" pointing right to an orange box "the conductor" holding type.__call__. Your source text flows into type.__call__, which will now run three sub-steps for you.

Figure — `__init__` constructor — initializing attributes

Step 3 — __call__ asks __new__ to cast the empty shell

WHY this and not __init__ first? You cannot pack a box that has not been built. __new__'s job is allocation (make the shell); __init__'s job is initialization (fill it). Order matters — shell first, contents second. See __new__ vs __init__ for the full contrast.

PICTURE — Figure s03: an orange box "type.call (step: allocate)" on the left, a blue arrow labelled __new__(Person) pointing right to a blue box "obj = new box (empty)", with a note "still no attributes". __new__(Person) returns a fresh, still-empty box labelled obj.

Figure — `__init__` constructor — initializing attributes

Step 4 — __call__ hands the box to __init__, with obj sitting in self

WHY does self appear "for free"? You never typed self in Person("Asha", 30) — yet inside __init__ it holds your object. That is because type.__call__ inserts obj into seat 0 automatically. Everything after self lines up with the arguments you passed.

PICTURE — Figure s04: three horizontal rows. Each row shows a value on the left ("obj", ""Asha"", "30"), a coloured arrow, and a parameter box on the right (self = obj, name = "Asha", age = 30). A note marks seat 0 (self) as inserted automatically. The arguments slot into parameters: seat 0 gets obj (→ self), seat 1 gets "Asha" (→ name), seat 2 gets 30 (→ age).

Figure — `__init__` constructor — initializing attributes

Step 5 — Inside __init__: copy locals onto the box (this is the whole point)

WHY copy at all? The parameters name and age are locals — they vanish the instant __init__ returns (Step 6). Writing self.name = name moves that value onto self, where it lives as long as the object lives.

PICTURE — Figure s05: a grey "local frame" box on the left holding name = "Asha" and age = 30, two green arrows labelled self.name=name and self.age=age pointing right into a green "self (the box)" holding the same two attributes, marked "persists!". The green arrows carry each local into the box as a labelled attribute. The box that was empty in Step 1 now reads name = "Asha", age = 30.

Figure — `__init__` constructor — initializing attributes

Step 6 — __init__ returns None; __call__ returns the box

WHY must __init__ return None? Its contract is "fill, don't produce." The object was already produced by __new__ in Step 3. If __init__ tried to return some other value, Python raises TypeError, because type.__call__ is only willing to return the object it built.

PICTURE — Figure s06: a faded grey "local frame (evaporates)" box on the left, a solid green "filled obj" box in the middle holding name="Asha" and age=30, a blue arrow labelled return obj pointing to a blue box "p", with a red note "None ignored". The local frame evaporates; the filled box escapes along the blue arrow into p.

Figure — `__init__` constructor — initializing attributes

Step 7 — Edge case A: no __init__ defined

WHY does it still work? __new__ in Step 3 already produced a usable (if empty) box. With nothing to fill it, that empty box escapes straight to p — it just has no instance attributes of its own (only whatever it inherits).

PICTURE — Figure s07: an orange "call" box, a blue arrow labelled __new__ to an empty box, then a dashed grey arrow "no init — skipped" jumping over to a blue "p" box holding the still-empty object. The empty box bypasses the fill stage entirely.

Figure — `__init__` constructor — initializing attributes

Step 8 — Edge case B: validation fails inside __init__

WHY is this a feature, not a bug? __init__ is the single gate every object passes through. If it throws, no object reaches p — the half-built box is discarded. So a rejected box can never leak into your program; invalid objects simply cannot exist.

PICTURE — Figure s08: a green "init" box with a red burst labelled "raise ValueError", a red X over the arrow toward "p", and the half-built box crossed out as "discarded". The error stops the assembly line before anything escapes.

Figure — `__init__` constructor — initializing attributes

Step 9 — Edge case C: the mutable-default pitfall

WHY does the leak happen — and when? The default [] is evaluated once, when the class body is first executed (i.e. at class-definition time, as Python reads the def line), not each time you call the class. That single list is stored on the function and reused as the default for every call that omits items. So all those boxes share the same list — appending through one shows up in all. Fix: use items=None, then build a fresh [] inside Step 5. Full story: Mutable default argument pitfall.

PICTURE — Figure s09: one orange list object at the top (created at class-definition time), with three separate boxes below all pointing arrows at that same single list — a shared leak. Every default-using box points at one list, not its own.

Figure — `__init__` constructor — initializing attributes

Step 10 — Edge case D: returning a non-None

WHY does it fail? type.__call__ returns the object it built; __init__ is only allowed to return None. Returning anything else makes Python raise TypeError at construction time.

PICTURE — Figure s10: a green "init" box emitting return 7 toward a red "TypeError" stop-sign; the path to "p" is blocked. A non-None return halts construction with a TypeError.

Figure — `__init__` constructor — initializing attributes

The one-picture summary

PICTURE — Figure s11: a left-to-right pipeline of five boxes — "Person(args)" (grey), "type.call" (orange), "new (empty box)" (blue), "init (fill box)" (green), "return filled obj" (blue) — connected by black arrows, captioned "class-call -> conductor -> shell -> fill -> hand back". Everything above compressed into one frame. Read it left to right and you have re-derived the constructor.

Figure — `__init__` constructor — initializing attributes
Recall Feynman: retell the walkthrough to a 12-year-old

You order a lunchbox. Saying Person("Asha", 30) is like handing your order to a kitchen manager (type.__call__). The manager first tells a worker to grab an empty box (__new__) — no food yet. Then the manager gives that exact box, plus your order slip ("Asha, 30"), to a packer (__init__). The packer is told which box to fill — that's self, meaning "this box, not any other." The packer tapes labels inside: name = Asha, age = 30. When the packer is done they say nothing (they return None) — their job was only to fill, not to make. The manager throws away that silence and hands your filled box back to you (p). If the packer ever finds something wrong ("age below zero!"), they stop and the box is thrown out — so a broken box never reaches your table. That's the entire constructor.


Connections

  • OOP Fundamentals — this walkthrough is the core mechanism of object creation.
  • __new__ vs __init__ — Steps 3 vs 4–5 made explicit.
  • self and instance attributes — Step 5's self.name = name in depth.
  • Class vs Instance Attributes — the attributes taped in Step 5 are instance attributes.
  • Properties and computed attributes — alternative to computing values in Step 5.
  • Mutable default argument pitfall — Step 9 above.
  • Dunder methods__init__, __new__, __call__ are all dunders.
  • इसे Hinglish में पढ़ो →