Skip to main content

The class Statement and init

In Lesson 1, James saw the dataclass ceiling: when behavior outgrows data storage, a dataclass needs to become a class. Now he writes one.

Emma pulls up a blank file. "Forget @dataclass for this lesson. You are building Note from scratch. Every line that the decorator wrote for you, you write by hand."

"Why?" James asks. "If the decorator does the same thing, why not keep using it?"

"Because when you add custom initialization logic later, like computing word_count from body automatically, you need to understand what __init__ does. You cannot customize what you do not understand."

TDG Still Applies

Same cycle, same tools. When you specify a class, you are specifying method signatures instead of function signatures. Your /tdg command works on classes too.


If you're new to programming

A class is a blueprint for creating objects. The __init__ method runs automatically when you create an object, setting up its initial state. The word self refers to the specific object being created or used.

If you've coded before

Python classes use __init__ as the initializer (not a constructor; __new__ is the constructor). self is not a keyword but a convention for the first parameter of instance methods, which Python passes automatically. Type annotations on self.attribute go in __init__, not on the class body (unlike dataclass fields).


The Dataclass You Know

Here is the dataclass from Chapter 51, the version you have used for seven chapters:

from dataclasses import dataclass, field

@dataclass
class Note:
title: str
body: str
word_count: int
author: str = "Anonymous"
is_draft: bool = True
tags: list[str] = field(default_factory=list)

Six lines. Python reads the field annotations and writes an __init__ that accepts those fields as arguments, a __repr__ that prints them, and an __eq__ that compares them. You never see this code, but it runs every time you create a Note.


The Same Object, Written as a Class

class Note:
def __init__(
self,
title: str,
body: str,
word_count: int,
author: str = "Anonymous",
is_draft: bool = True,
tags: list[str] | None = None,
) -> None:
self.title: str = title
self.body: str = body
self.word_count: int = word_count
self.author: str = author
self.is_draft: bool = is_draft
self.tags: list[str] = tags if tags is not None else []

Create a file called note_class.py with this code. Add these lines at the bottom and run it:

note1 = Note("Grocery List", "Milk, eggs, bread", 3)
note2 = Note("Meeting Notes", "Discuss Q3 targets", 3, author="James")

print(note1.title)
print(note1.author)
print(note2.author)
print(note1.tags)

Output:

Grocery List
Anonymous
James
[]

Each call to Note(...) creates a new object and runs __init__ with that object as self. The first note gets the default author "Anonymous". The second gets "James". They are independent objects with independent state.


What self Actually Is

The word self is not a Python keyword. It is a variable name, and by universal convention, it is always the first parameter of every method in a class. Python fills it in automatically when you call a method.

When you write:

note1 = Note("Grocery List", "Milk, eggs, bread", 3)

Python does this behind the scenes:

# 1. Create a new empty Note object
# 2. Call Note.__init__(that_new_object, "Grocery List", "Milk, eggs, bread", 3)
# 3. Inside __init__, self IS that_new_object
# 4. self.title = "Grocery List" sets the attribute on that specific object

When you later create note2, Python creates a different object and passes that object as self. Each call to __init__ works on its own instance.

Try this in your file:

print(note1 is note2)

Output:

False

Two different objects. Same class. Independent state. self is what connects a method to the specific object it operates on.


Side-by-Side Comparison

AspectDataclassClass
Lines of code612
You writeField annotations__init__ with self.field = field
Python generates__init__, __repr__, __eq__Nothing (you write everything)
Default valuesfield(default_factory=list)tags if tags is not None else []
Type annotationsOn the field directlyOn self.field inside __init__
Custom init logicPossible with __post_init__Directly in __init__

The class version is longer. That is the trade-off: you gain full control over initialization, but you write more code. In the next lesson, you will add methods that justify this control.


The Mutable Default Trap

Notice one difference in the class version:

# Dataclass handles this with field(default_factory=list)
tags: list[str] = field(default_factory=list)

# Class handles this manually
def __init__(self, ..., tags: list[str] | None = None) -> None:
self.tags: list[str] = tags if tags is not None else []

Why None instead of []? Because default arguments in Python are created once and shared across all calls. If you wrote tags: list[str] = [], every Note created without explicit tags would share the same list object. Adding a tag to one note would add it to all of them.

Try it yourself to see the bug:

# BAD: shared default list
class BrokenNote:
def __init__(self, title: str, tags: list[str] = []) -> None:
self.title = title
self.tags = tags

a = BrokenNote("First")
b = BrokenNote("Second")
a.tags.append("python")
print(b.tags)

Output:

['python']

Note b has "python" in its tags even though you only added it to a. Both notes share the same list. The None pattern avoids this by creating a fresh list inside __init__ for each instance.


PRIMM-AI+ Practice: Trace the Instance

Predict [AI-FREE]

Press Shift+Tab to enter Plan Mode before predicting.

Read this code. Without running it, predict what each print statement outputs. Write your predictions and a confidence score from 1 to 5.

class Counter:
def __init__(self, start: int = 0) -> None:
self.value: int = start

c1 = Counter()
c2 = Counter(10)
c1.value = 5
print(c1.value)
print(c2.value)
print(c1 is c2)
Check your predictions
5
10
False

c1 starts at 0, then is reassigned to 5. c2 starts at 10 and is never changed. They are two separate objects (c1 is c2 is False). Changing c1.value does not affect c2.value because each instance has its own value attribute.

Run

Press Shift+Tab to exit Plan Mode.

Create a file called counter_practice.py with the code above. Run uv run python counter_practice.py. Confirm the output matches your prediction.

Investigate

In Claude Code, type:

/investigate @counter_practice.py

Ask: "What happens if I add a method increment(self) that adds 1 to self.value? Does calling c1.increment() affect c2?" Verify the AI's answer by adding the method and testing.

Modify

Change Counter so it takes a step parameter in __init__ (default 1). Add an increment method that adds self.step to self.value. Create two counters with different steps and verify they increment independently.

Make [Mastery Gate]

Write a Temperature class from scratch:

  1. __init__ takes celsius: float
  2. Store self.celsius
  3. Create three instances: freezing (0), boiling (100), room (22)
  4. Print each instance's celsius value
  5. Change one instance's value and verify the others are unchanged

All three instances must be independent. Verify with is checks.


Try With AI

Opening Claude Code

If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.

Prompt 1: Convert a Dataclass

Convert this dataclass to a regular class with __init__:

@dataclass
class Book:
title: str
author: str
pages: int
genre: str = "Unknown"
read: bool = False

Show me both versions side by side. Explain every line
in the class version that the dataclass was generating
automatically.

Compare the AI's conversion to the pattern you learned in this lesson. Check that it handles the default values correctly and uses self.field = field for each attribute.

What you're learning: You are verifying the AI's understanding of the dataclass-to-class conversion matches your own, catching any shortcuts or mistakes.

Prompt 2: Spot the Mutable Default Bug

I wrote this class:

class Team:
def __init__(self, name: str, members: list[str] = []) -> None:
self.name = name
self.members = members

Create two Team instances without passing members. Add
"Alice" to the first team. Show what happens to the
second team and explain why.

Run the AI's example code yourself. The bug should match what you learned about the mutable default trap.

What you're learning: You are using the AI to generate a demonstration of a specific bug pattern, then verifying the explanation matches your understanding. This reinforces the None default pattern.

Prompt 3: Write init for Your Domain

I work in [describe your professional domain]. Design a
class for a concept from my domain (e.g., Patient,
Invoice, Shipment, Student). Write the __init__ method
with typed parameters, defaults where appropriate, and
the None pattern for any mutable defaults. Explain each
design choice.

Review the AI's design choices. Does it use None for mutable defaults? Are the type annotations specific enough? Would you change any defaults?

What you're learning: You are transferring the __init__ pattern to your own professional domain, evaluating the AI's design choices against the principles from this lesson.


Emma watches James finish his Temperature class. "Three instances, three independent values. What is self doing in your __init__?"

James points at the screen. "It is the specific object being created. When I write Temperature(100), Python makes a new object and passes it to __init__ as self. Then self.celsius = celsius stores 100 on that object. When I write Temperature(0), it is a different self, a different object."

"Good. Now compare your class to the dataclass version. What did you trade?"

"Lines of code. The dataclass is shorter. But in my class, I can see every assignment. I can add validation, computation, anything I want inside __init__."

Emma hesitates. "I still debate the threshold sometimes. When a dataclass has one method and five fields, is it worth converting? Probably not. When it has four methods with validation logic? Probably yes. The gray area in between is where I have to think."

James grins. "In the warehouse, we had assembly instructions for every product line. The simple products had a one-page sheet: parts, snap together, done. The complex products had a full manual: parts, order of assembly, quality checkpoints, testing procedures. The one-page sheet is the dataclass. The full manual is the class."

"Assembly instructions," Emma says. "Not bad. The dataclass is a pre-printed template. __init__ is custom assembly instructions you write yourself. And now that you can write them, your Note needs something it could never have as a dataclass: methods that enforce rules. That is the next lesson."