Today's outcomes
- Use __init__ to establish an object contract.
- Choose instance versus class attributes.
- Keep independently created objects independent.
Before you start: Complete Day 19 and its practical challenge.
Understand the concept
Initialization establishes a valid starting state
An initializer named __init__ runs during ordinary instance creation after the new instance exists. Its job is to establish fields and validate the inputs required by the object's contract. It should return None rather than a replacement object. Construction and initialization are related stages; describing init as the initializer is more precise than assuming it creates the object itself.
With StudyPlan('Alex', 30), the arguments reach the initializer, and self identifies the new instance. A valid plan can then be used immediately. This avoids yesterday's half-prepared object whose methods depended on the caller remembering several assignments.
Validate before exposing a usable object. If a required name is blank or a target is negative, raise an explicit error. A caller should not receive a valid-looking plan with defaults silently substituted for rejected input.
Instance and class attributes have different ownership
Assigning self.sessions = [] inside init creates a new list for each instance. Defining sessions = [] in the class body instead creates a shared class-level list. Instances that access that class attribute can mutate the same list, causing one learner's recording to appear in another learner's state.
A class attribute is suitable for shared metadata or a deliberately shared resource. It is usually unsuitable for a per-instance mutable history. Attribute lookup can find a class attribute when the instance does not have its own field of that name. Assigning an instance attribute can shadow the class attribute; mutating a looked-up shared list does not automatically create an independent instance list.
Store one source of truth when possible
If sessions already stores each duration, total_minutes can calculate sum(sessions) rather than maintaining another mutable total field. Duplicated state introduces a reconciliation obligation: every update must keep both fields synchronized. For this small plan, deriving the total is clearer.
For very large histories, a cached total may be practical, but its update and consistency contract must be tested. Do not choose redundancy merely because accessing a precomputed number feels more sophisticated.
Worked example
class StudyPlan:
category = "learning" # Shared descriptive metadata.
def __init__(self, learner, target_days):
if not isinstance(learner, str) or not learner.strip():
raise ValueError("learner must be nonempty text")
if type(target_days) is not int or target_days < 1:
raise ValueError("target_days must be a positive integer")
self.learner = learner.strip()
self.target_days = target_days
self.sessions = [] # New list for this instance.
def record(self, minutes):
if type(minutes) is not int or minutes < 0:
raise ValueError("minutes must be a nonnegative integer")
self.sessions.append(minutes)
def total_minutes(self):
return sum(self.sessions)
alex = StudyPlan("Alex", 30)
mira = StudyPlan("Mira", 30)
alex.record(60)
assert alex.total_minutes() == 60
assert mira.total_minutes() == 0
assert alex.sessions is not mira.sessions
assert alex.category == mira.category == "learning"
print(alex.learner, alex.total_minutes())
| Field | Ownership | Reason |
|---|---|---|
| category | Class metadata | Same description applies to these plans |
| learner | Instance | Each learner can differ |
| target_days | Instance | Each plan can have a different target |
| sessions | Instance list | Each history must stay independent |
| total_minutes result | Derived | Calculated from the stored history |
The independence test is meaningful because equal empty lists would otherwise look harmless. Identity shows the two histories are different objects before either is populated.
Guided practice
- Predict: move sessions into the class body and remove the instance assignment. What happens after alex.record(60)? Recreate the experiment separately, observe the shared list, then restore the correct version.
- Modify: add a known-zero session. The list should record the event even though its contribution to the total is zero. Explain why an event count and duration total answer different questions.
- Reconstruct: initialize a StudyGoal from yesterday with target and completed_minutes in init. Ensure invalid targets raise before any valid object is returned.
Use two-instance tests for every per-instance mutable collection. A single object cannot reveal that another object would accidentally share its history.
Daily challenge
Build StudyGoal(target) with a positive actual integer target, a per-instance list of recorded durations, record(minutes), and remaining(). Derive completed minutes from the list. Accept a recorded zero and over-target totals. Reject negative durations and Booleans before updating.
| Case | Required behavior |
|---|---|
| New target 60 | Empty history; remaining 60 |
| Record 0 then 30 | History [0, 30]; remaining 30 |
| Record another 45 | Total 75; remaining zero |
| Separate new target 60 | Still empty history and remaining 60 |
| Invalid target 0 or True | ValueError during initialization |
Keep history accessible for inspection in this learning version, but describe the public interface your callers should normally use. Python attributes are not automatically protected merely because they appear inside a class.
Reference solution
Compare after attempting the challenge
class StudyGoal:
def __init__(self, target):
if type(target) is not int or target < 1:
raise ValueError("target must be a positive integer")
self.target = target
self.sessions = []
def record(self, minutes):
if type(minutes) is not int or minutes < 0:
raise ValueError("minutes must be a nonnegative integer")
self.sessions.append(minutes)
def remaining(self):
return max(0, self.target - sum(self.sessions))
first, second = StudyGoal(60), StudyGoal(60)
first.record(0)
first.record(30)
assert first.sessions == [0, 30] and first.remaining() == 30
first.record(45)
assert first.remaining() == 0
assert second.sessions == [] and second.remaining() == 60
for invalid in [0, True]:
try:
StudyGoal(invalid)
except ValueError:
pass
else:
raise AssertionError("Invalid target should fail")
print("Initialized goals remain independent")
The target contract deliberately excludes bool despite its integer ancestry. The list is constructed inside each initializer call, avoiding the shared-state bug rather than trying to repair it later.
Debug and explain
If one learner sees another's data, inspect class-body mutable attributes and mutable defaults. If an object exists without required fields, inspect initialization and alternate creation paths. If totals disagree with the history, inspect duplicated state or direct external mutations.
Explain why init returns None, why sessions belongs to the instance, and which invariant record preserves. Tomorrow you will reuse and specialize behavior through inheritance while deciding whether the relationship genuinely justifies it.
Review and next step
Save the two-instance test and invalid-creation cases. Rebuild a validated initializer during your next review. Day 21 introduces inheritance and composition as choices for representing relationships, not compulsory upgrades.
Sources and verification
Original teaching examples checked with Python 3.12.14. Library behavior reviewed against these primary references on October 3, 2026. Day 26's examples also use Beautiful Soup 4.14.3.
Check your understanding
Five questions, 20 points each. Pass at 85% or above, which requires all five correct. Feedback explains each answer; revise the lesson and retry when needed.
Review your practical work
Check these statements after running your own challenge. This is your self-review; the website does not execute or assess your Python code.
Pass the knowledge check and finish the practical self-review to save completion.