Python __init__ Practice Problems & Exercises
Practice: __init__ and Object Construction — Two-Phase Creation at Engineering Depth
← Back to lessonEasy
Predict what each print statement outputs. This is the classic mutable-default-argument trap — it applies equally to __init__ parameters.
def make_tag(text, attrs={}):
attrs["text"] = text
return attrs
a = make_tag("hello")
b = make_tag("world")
print(a)
print(b)
print(a is b)Solution
{'text': 'world'}
{'text': 'world'}
True
Explanation: Python evaluates default argument values once at function-definition time. The {} is created once and reused. The first call sets attrs["text"] = "hello". The second call receives the same dict and overwrites the value with "world". Both a and b reference the identical object, so a is b is True and both print {'text': 'world'}. Fix: use attrs=None and set attrs = attrs or {} inside the function body.
def make_tag(text, attrs={}):
attrs["text"] = text
return attrs
a = make_tag("hello")
b = make_tag("world")
print(a)
print(b)
print(a is b)Expected Output
{'text': 'world'}\n{'text': 'world'}\nTrueHints
Hint 1: Default argument values are evaluated once when the function is defined, not on each call.
Hint 2: Both calls share the same dict object. The second call overwrites the "text" key.
Hint 3: a is b is True because both names point to the same default dict.
The Config class has the classic mutable-default-argument bug. Fix __init__ so every instance gets its own independent settings dictionary.
class Config:
def __init__(self, settings=None):
self.settings = settings if settings is not None else {}
def set(self, key, value):
self.settings[key] = value
c1 = Config()
c2 = Config()
c1.set("debug", True)
print(c2.settings)Solution
class Config:
def __init__(self, settings=None):
self.settings = settings if settings is not None else {}
def set(self, key, value):
self.settings[key] = value
Explanation: The idiomatic fix is to use None as the default sentinel and create a fresh {} inside the function body. Each call now allocates a new dict and assigns it to self.settings. Callers who explicitly pass a dict can still do so (Config({"key": "val"})) — and that shared dict is their responsibility. The sentinel pattern is universally used in Python codebases wherever mutable defaults would otherwise cause bugs.
class Config:
def __init__(self, settings={}):
self.settings = settings
def set(self, key, value):
self.settings[key] = value
c1 = Config()
c2 = Config()
c1.set("debug", True)
# BUG: c2.settings should be empty but it is not
print(c2.settings) # should print {}Expected Output
{}Hints
Hint 1: The shared default dict means c1 and c2 both point to the same object.
Hint 2: Change the default to None and assign self.settings = settings if settings is not None else {}.
Hint 3: Never use mutable objects ([], {}, set()) as default values in __init__ or any function.
Complete Dog.__init__ by calling the parent Animal.__init__ using super(). The dog always makes a "woof" sound.
class Animal:
def __init__(self, name, sound):
self.name = name
self.sound = sound
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name, "woof")
self.breed = breed
def describe(self):
return f"{self.name} ({self.breed}) says {self.sound}"
d = Dog("Rex", "Labrador")
print(d.describe())Solution
class Animal:
def __init__(self, name, sound):
self.name = name
self.sound = sound
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name, "woof")
self.breed = breed
def describe(self):
return f"{self.name} ({self.breed}) says {self.sound}"
Explanation: super().__init__(name, "woof") delegates to Animal.__init__, which sets self.name and self.sound. Then Dog.__init__ adds self.breed. Without the super() call, self.name and self.sound would never be set, and describe would raise AttributeError. Always call super().__init__ in subclass constructors to ensure the parent class initialises its state correctly.
class Animal:
def __init__(self, name, sound):
self.name = name
self.sound = sound
class Dog(Animal):
def __init__(self, name, breed):
# TODO: call Animal.__init__ with name and "woof"
self.breed = breed
def describe(self):
return f"{self.name} ({self.breed}) says {self.sound}"
d = Dog("Rex", "Labrador")
print(d.describe())Expected Output
Rex (Labrador) says woofHints
Hint 1: Call super().__init__(name, "woof") inside Dog.__init__ before or after setting self.breed.
Hint 2: super() returns a proxy for the parent class, so its __init__ sets self.name and self.sound.
Hint 3: After calling super().__init__, Dog.__init__ can set self.breed = breed.
Identify which constructor calls are valid and which raise TypeError. Then explain why keyword-only parameters improve API design.
class Connection:
def __init__(self, host, port, *, timeout=30, retries=3):
self.host = host
self.port = port
self.timeout = timeout
self.retries = retries
def __str__(self):
return f"{self.host}:{self.port} (timeout={self.timeout}, retries={self.retries})"
c1 = Connection("db.local", 5432)
c3 = Connection("db.local", 5432, timeout=60)
print(c1)
print(c3)
try:
c2 = Connection("db.local", 5432, 60) # positional for keyword-only param
except TypeError as e:
print(f"c2 error: {e}")Solution
class Connection:
def __init__(self, host, port, *, timeout=30, retries=3):
self.host = host
self.port = port
self.timeout = timeout
self.retries = retries
def __str__(self):
return f"{self.host}:{self.port} (timeout={self.timeout}, retries={self.retries})"
Explanation: The bare * in a parameter list marks all following parameters as keyword-only. timeout and retries cannot be passed positionally — callers must write timeout=60. This prevents common ordering mistakes (swapping timeout and retries) and makes call sites self-documenting. c1 uses defaults; c3 overrides only timeout; c2 raises TypeError: __init__() takes 3 positional arguments but 4 were given.
class Connection:
def __init__(self, host, port, *, timeout=30, retries=3):
self.host = host
self.port = port
self.timeout = timeout
self.retries = retries
def __str__(self):
return f"{self.host}:{self.port} (timeout={self.timeout}, retries={self.retries})"
# Which of these calls will raise TypeError?
# Uncomment to test each one:
# c1 = Connection("db.local", 5432)
# c2 = Connection("db.local", 5432, 60)
# c3 = Connection("db.local", 5432, timeout=60)
# c4 = Connection("db.local", 5432, timeout=60, retries=5)
c1 = Connection("db.local", 5432)
c3 = Connection("db.local", 5432, timeout=60)
print(c1)
print(c3)Expected Output
db.local:5432 (timeout=30, retries=3)\ndb.local:5432 (timeout=60, retries=3)Hints
Hint 1: Parameters after the bare * are keyword-only — they must be passed by name.
Hint 2: Connection("db.local", 5432, 60) passes 60 as a positional argument for timeout, which is forbidden.
Hint 3: c1 and c3 are valid; c2 raises TypeError because keyword-only args cannot be passed positionally.
Medium
Add invariant validation to BankAccount.__init__ so that invalid arguments raise ValueError with clear messages.
class BankAccount:
def __init__(self, owner, balance=0):
if not isinstance(owner, str) or not owner.strip():
raise ValueError("owner must be a non-empty string")
if not isinstance(balance, (int, float)) or balance < 0:
raise ValueError("balance must be non-negative")
self.owner = owner
self.balance = balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit amount must be positive")
self.balance += amount
def __str__(self):
return f"Account({self.owner!r}, balance={self.balance})"
acc = BankAccount("Alice", 100)
print(acc)
try:
bad = BankAccount("", 100)
except ValueError as e:
print(e)
try:
bad2 = BankAccount("Bob", -50)
except ValueError as e:
print(e)Solution
class BankAccount:
def __init__(self, owner, balance=0):
if not isinstance(owner, str) or not owner.strip():
raise ValueError("owner must be a non-empty string")
if not isinstance(balance, (int, float)) or balance < 0:
raise ValueError("balance must be non-negative")
self.owner = owner
self.balance = balance
Explanation: Validating in __init__ ensures the object is always in a valid state after construction — there is never a "partially constructed" instance with invalid data. Checking both type and value gives better error messages than letting a downstream operation fail mysteriously. The owner.strip() check rejects whitespace-only strings. Storing attributes only after all validations pass prevents partial state if a later check fails.
class BankAccount:
def __init__(self, owner, balance=0):
# TODO: validate owner is non-empty string
# TODO: validate balance is non-negative number
self.owner = owner
self.balance = balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit amount must be positive")
self.balance += amount
def __str__(self):
return f"Account({self.owner!r}, balance={self.balance})"
acc = BankAccount("Alice", 100)
print(acc)
try:
bad = BankAccount("", 100)
except ValueError as e:
print(e)
try:
bad2 = BankAccount("Bob", -50)
except ValueError as e:
print(e)Expected Output
Account('Alice', balance=100)\nowner must be a non-empty string\nbalance must be non-negativeHints
Hint 1: Check if owner is a string with isinstance(owner, str) and that it is truthy (not empty).
Hint 2: Check balance >= 0 and that it is a number with isinstance(balance, (int, float)).
Hint 3: Raise ValueError with a descriptive message for each invalid case.
Trace the creation sequence for Special(5) and Special(-1), predicting every print line in order.
class Logged:
def __new__(cls, *args, **kwargs):
print(f"__new__ called for {cls.__name__}")
instance = super().__new__(cls)
return instance
def __init__(self, value):
print(f"__init__ called with value={value}")
self.value = value
class Special(Logged):
def __new__(cls, value):
if value < 0:
print("Returning None — refusing negative value")
return None
return super().__new__(cls)
obj = Special(5)
print(type(obj))
bad = Special(-1)
print(bad)Solution
__new__ called for Special
__init__ called with value=5
<class '__main__.Special'>
Returning None — refusing negative value
None
Explanation: Python's object creation protocol calls __new__ first. If it returns an instance of the expected class, __init__ is called on it. For Special(5): Special.__new__ delegates to Logged.__new__ (which prints and creates the instance), then __init__ initialises it. For Special(-1): Special.__new__ prints the refusal message and returns None. Because None is not an instance of Special, Python skips __init__. The variable bad holds None. This pattern is unusual; prefer raising an exception in __init__ over returning None from __new__ unless you genuinely need to prevent allocation.
class Logged:
def __new__(cls, *args, **kwargs):
print(f"__new__ called for {cls.__name__}")
instance = super().__new__(cls)
return instance
def __init__(self, value):
print(f"__init__ called with value={value}")
self.value = value
class Special(Logged):
def __new__(cls, value):
if value < 0:
print("Returning None — refusing negative value")
return None
return super().__new__(cls)
obj = Special(5)
print(type(obj))
bad = Special(-1)
print(bad)Expected Output
__new__ called for Special\n__init__ called with value=5\n<class '__main__.Special'>\nReturning None — refusing negative value\nNoneHints
Hint 1: __new__ runs before __init__. If __new__ returns None (or a non-instance), __init__ is NOT called.
Hint 2: When Special(-1) is created, __new__ returns None so __init__ is skipped entirely.
Hint 3: Python only calls __init__ if __new__ returns an instance of the class.
Implement two alternative constructors for Config as class methods: one from a plain dict and one from a JSON string.
import json
class Config:
def __init__(self, data):
self.data = data
@classmethod
def from_dict(cls, d):
return cls(dict(d))
@classmethod
def from_json_string(cls, json_str):
return cls(json.loads(json_str))
def get(self, key, default=None):
return self.data.get(key, default)
c1 = Config.from_dict({"host": "localhost", "port": 5432})
c2 = Config.from_json_string('{"debug": true, "level": "info"}')
print(c1.get("host"))
print(c2.get("level"))Solution
import json
class Config:
def __init__(self, data):
self.data = data
@classmethod
def from_dict(cls, d):
return cls(dict(d))
@classmethod
def from_json_string(cls, json_str):
return cls(json.loads(json_str))
def get(self, key, default=None):
return self.data.get(key, default)
Explanation: Multiple @classmethod constructors are the Pythonic way to offer several construction paths without overloading a single __init__ with complex logic. json.loads parses the JSON string into a Python dict, which is then passed directly to __init__. Using dict(d) in from_dict creates a shallow copy so mutations to the original dict do not affect the Config instance. Using cls(...) keeps the pattern subclass-compatible — a hypothetical LoggedConfig(Config) would get the right type back from both factory methods.
import json
class Config:
def __init__(self, data):
self.data = data
@classmethod
def from_dict(cls, d):
# TODO: return a Config whose data is a copy of d
pass
@classmethod
def from_json_string(cls, json_str):
# TODO: parse json_str and return a Config
pass
def get(self, key, default=None):
return self.data.get(key, default)
c1 = Config.from_dict({"host": "localhost", "port": 5432})
c2 = Config.from_json_string('{"debug": true, "level": "info"}')
print(c1.get("host"))
print(c2.get("level"))Expected Output
localhost\ninfoHints
Hint 1: from_dict should return cls(dict(d)) — using dict() creates a shallow copy.
Hint 2: from_json_string should call json.loads(json_str) and pass the result to cls(...).
Hint 3: Use cls(...) not Config(...) so subclasses of Config work correctly.
Trace through the cooperative __init__ chain in this multiple-inheritance scenario. Predict the output and understand why *args/**kwargs forwarding is required.
import datetime
class TimestampMixin:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.created_at = datetime.datetime.now().isoformat()
class LogMixin:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.log = []
def add_log(self, msg):
self.log.append(msg)
class Service(TimestampMixin, LogMixin):
def __init__(self, name):
super().__init__()
self.name = name
s = Service("payments")
s.add_log("started")
print(s.name)
print(len(s.log))
print(isinstance(s.created_at, str))Solution
payments
1
True
Explanation: Python's MRO for Service is [Service, TimestampMixin, LogMixin, object]. When Service.__init__ calls super().__init__(), control flows to TimestampMixin.__init__, which calls super().__init__() again — this reaches LogMixin.__init__ (not object), because MRO is linear. LogMixin then calls super().__init__() which finally hits object.__init__. Each step adds its attributes to self. The *args/**kwargs forwarding ensures that arguments can be threaded through the chain without any mixin needing to know the full parameter list. After the chain, Service.__init__ sets self.name.
class TimestampMixin:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
import datetime
self.created_at = datetime.datetime.now().isoformat()
class LogMixin:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.log = []
def add_log(self, msg):
self.log.append(msg)
class Service(TimestampMixin, LogMixin):
def __init__(self, name):
super().__init__()
self.name = name
s = Service("payments")
s.add_log("started")
print(s.name)
print(len(s.log))
print(isinstance(s.created_at, str))Expected Output
payments\n1\nTrueHints
Hint 1: Each mixin passes *args and **kwargs through via super().__init__(*args, **kwargs), allowing cooperative MRO chaining.
Hint 2: Python MRO for Service is: Service → TimestampMixin → LogMixin → object.
Hint 3: Every __init__ in the chain eventually reaches object.__init__(), which accepts no extra args.
Hard
Implement __init_subclass__ to auto-register every subclass of Plugin into a class-level registry. The plugin name can be provided at class definition time via a keyword argument.
class Plugin:
_registry = {}
def __init_subclass__(cls, plugin_name=None, **kwargs):
super().__init_subclass__(**kwargs)
key = plugin_name if plugin_name is not None else cls.__name__
Plugin._registry[key] = cls
@classmethod
def get(cls, name):
return cls._registry.get(name)
class CSVPlugin(Plugin, plugin_name="csv"):
def run(self):
return "CSV processing"
class JSONPlugin(Plugin, plugin_name="json"):
def run(self):
return "JSON processing"
class AutoNamed(Plugin):
def run(self):
return "auto"
print(Plugin.get("csv")().run())
print(Plugin.get("json")().run())
print(Plugin.get("AutoNamed")().run())Solution
class Plugin:
_registry = {}
def __init_subclass__(cls, plugin_name=None, **kwargs):
super().__init_subclass__(**kwargs)
key = plugin_name if plugin_name is not None else cls.__name__
Plugin._registry[key] = cls
@classmethod
def get(cls, name):
return cls._registry.get(name)
Explanation: __init_subclass__ is a class-creation hook called on the base class whenever a new subclass is defined — not when instances are created. Keywords passed at class definition time (plugin_name="csv") are forwarded as arguments to __init_subclass__. This is the standard "plugin registry" pattern that replaces metaclasses for many use cases. Passing **kwargs through to super().__init_subclass__ keeps the chain cooperative so multiple inheritance works correctly.
class Plugin:
_registry = {}
def __init_subclass__(cls, plugin_name=None, **kwargs):
super().__init_subclass__(**kwargs)
# TODO: register cls under plugin_name (or cls.__name__ if not provided)
pass
@classmethod
def get(cls, name):
return cls._registry.get(name)
class CSVPlugin(Plugin, plugin_name="csv"):
def run(self):
return "CSV processing"
class JSONPlugin(Plugin, plugin_name="json"):
def run(self):
return "JSON processing"
class AutoNamed(Plugin):
def run(self):
return "auto"
print(Plugin.get("csv")().run())
print(Plugin.get("json")().run())
print(Plugin.get("AutoNamed")().run())Expected Output
CSV processing\nJSON processing\nautoHints
Hint 1: __init_subclass__ is called on the base class whenever a subclass is defined.
Hint 2: The plugin_name keyword argument is passed at class definition time: class Foo(Plugin, plugin_name="foo").
Hint 3: If plugin_name is None, fall back to cls.__name__ for the registry key.
Implement the connection property so that the expensive _open_connection call is deferred until first access and then cached for all subsequent accesses.
import time
class ExpensiveResource:
def __init__(self, config):
self.config = config
self._connection = None
@property
def connection(self):
if self._connection is None:
self._connection = self._open_connection()
return self._connection
def _open_connection(self):
time.sleep(0.01)
return f"connection({self.config['host']})"
def query(self, sql):
return f"{self.connection} -> {sql}"
r = ExpensiveResource({"host": "db.local"})
print(r._connection is None)
print(r.query("SELECT 1"))
print(r.query("SELECT 2"))Solution
class ExpensiveResource:
def __init__(self, config):
self.config = config
self._connection = None
@property
def connection(self):
if self._connection is None:
self._connection = self._open_connection()
return self._connection
def _open_connection(self):
# expensive I/O
return f"connection({self.config['host']})"
def query(self, sql):
return f"{self.connection} -> {sql}"
Explanation: Lazy initialisation is a form of deferred construction. The real object (_connection) is not created in __init__ but on first property access. The None sentinel lets the property distinguish "not yet opened" from a valid connection. Once opened, the same object is returned on every subsequent access without repeating the expensive call. This pattern is pervasive in ORMs (SQLAlchemy sessions), database connection pools, and any system where object creation has significant cost.
import time
class ExpensiveResource:
def __init__(self, config):
self.config = config
self._connection = None # not yet opened
@property
def connection(self):
# TODO: open connection lazily on first access; cache it
pass
def _open_connection(self):
time.sleep(0.01) # simulate I/O
return f"connection({self.config['host']})"
def query(self, sql):
return f"{self.connection} -> {sql}"
r = ExpensiveResource({"host": "db.local"})
# connection not opened yet
print(r._connection is None)
# first access opens it
print(r.query("SELECT 1"))
# second access reuses cached connection
print(r.query("SELECT 2"))Expected Output
True\nconnection(db.local) -> SELECT 1\nconnection(db.local) -> SELECT 2Hints
Hint 1: In the property, check if self._connection is None. If so, call self._open_connection() and assign to self._connection.
Hint 2: Return self._connection at the end of the property. The cache means _open_connection is only called once.
Hint 3: This is the "lazy initialisation" pattern — defer expensive work until the value is actually needed.
Implement __reduce__ so that Matrix instances can be serialised with pickle and correctly reconstructed via __init__.
import pickle
class Matrix:
def __init__(self, rows):
if not rows or not all(len(r) == len(rows[0]) for r in rows):
raise ValueError("All rows must have equal length")
self._rows = [list(r) for r in rows]
self._shape = (len(rows), len(rows[0]))
def __getitem__(self, idx):
row, col = idx
return self._rows[row][col]
def __reduce__(self):
return (self.__class__, (self._rows,))
def __repr__(self):
return f"Matrix({self._rows!r})"
m = Matrix([[1, 2], [3, 4]])
data = pickle.dumps(m)
m2 = pickle.loads(data)
print(m2[0, 0])
print(m2[1, 1])
print(repr(m2))Solution
def __reduce__(self):
return (self.__class__, (self._rows,))
Explanation: Pickle needs to know how to serialise and deserialise objects. By default it tries __dict__-based serialisation, which fails if the class has __slots__, derived attributes, or private state. __reduce__ returns a (callable, args) tuple — pickle stores this tuple and, on load, calls callable(*args) to reconstruct the object. Returning (self.__class__, (self._rows,)) instructs pickle to call Matrix(self._rows) during unpickling, which goes through full validation in __init__. This ensures the reconstructed object is always valid and that all derived attributes (like _shape) are recomputed correctly.
import pickle
class Matrix:
def __init__(self, rows):
# rows is a list of lists
if not rows or not all(len(r) == len(rows[0]) for r in rows):
raise ValueError("All rows must have equal length")
self._rows = [list(r) for r in rows]
self._shape = (len(rows), len(rows[0]))
def __getitem__(self, idx):
row, col = idx
return self._rows[row][col]
def __reduce__(self):
# TODO: return (callable, args) so pickle can reconstruct this object
pass
def __repr__(self):
return f"Matrix({self._rows!r})"
m = Matrix([[1, 2], [3, 4]])
data = pickle.dumps(m)
m2 = pickle.loads(data)
print(m2[0, 0])
print(m2[1, 1])
print(repr(m2))Expected Output
1\n4\nMatrix([[1, 2], [3, 4]])Hints
Hint 1: __reduce__ should return a 2-tuple: (callable, args_tuple). Pickle calls callable(*args_tuple) to reconstruct.
Hint 2: Return (self.__class__, (self._rows,)) — this tells pickle to call Matrix(self._rows) on load.
Hint 3: Because Matrix.__init__ validates and copies rows, reconstruction via __init__ is safe and correct.
