Constructor and Destructor¶
__init__ (initializer) and __del__ (destructor) manage object lifecycle in Python. Despite common usage, __init__ is not the true constructor — __new__ is. Understanding when each runs — and when __del__ does not — is essential for writing reliable classes.
Mental Model
__new__ builds the empty house (allocates memory), __init__ moves the furniture in (sets attributes). Most classes only need __init__ -- override __new__ only for singletons, immutable types, or when you need to control which object is returned. The "destructor" __del__ is unreliable; never depend on it for resource cleanup.
Object Lifecycle¶
Every Python object follows a predictable lifecycle: creation, initialization, use, and eventual destruction. __new__ always runs at creation; __init__ runs only if __new__ returns a proper instance of the class. The destructor (__del__) does not guarantee teardown.
flowchart TD
A[Class called] --> B["__new__ creates object"]
B --> C["__init__ initializes object"]
C --> D[Object in use]
D --> E[No more references]
E --> F[Garbage collector runs]
F -->|maybe| G["__del__ runs"]
F -->|maybe not| H[Object silently freed]
Core Insight
__new__ creates the object and __init__ initializes it — both are deterministic. __del__ is non-deterministic — it may or may not run, and you cannot control when. Design accordingly.
Initializer: __init__¶
1. Purpose¶
Initializes new objects immediately after __new__ creates them. Despite being commonly called the "constructor," __init__ does not construct the object — it receives an already-created instance and sets up its attributes.
```python class Student: def init(self, name, major): self.name = name self.major = major
alice = Student("Alice", "Math") ```
2. Automatic Call¶
Python automatically calls __init__ during instantiation.
```python
These are equivalent:¶
a = Student("Lee", "Math")
Student.init(a, "Lee", "Math")¶
```
3. Why It Matters¶
Every class should define __init__ for clarity. Without it, attributes must be assigned dynamically — leading to fragile, error-prone code.
Without __init__¶
1. Dynamic Assignment¶
```python class Student: pass
a = Student() a.name = "Lee" a.major = "Math" ```
2. Problems¶
- No enforcement of required fields
- Runtime errors from typos
- Unclear object requirements
- Violates encapsulation
3. Fragile Code¶
```python b = Student() b.name = "Kim"
Forgot to set major!¶
print(b.major) # AttributeError ```
__init__ Patterns¶
1. Simple Assignment¶
python
class Car:
def __init__(self, speed, color):
self.speed = speed
self.color = color
2. With Defaults¶
python
class Car:
def __init__(self, speed=0, color="black"):
self.speed = speed
self.color = color
3. With Validation¶
python
class Student:
def __init__(self, name, major):
if not name:
raise ValueError("Name required")
self.name = name
self.major = major
The True Constructor: __new__¶
When you call Student("Alice", "Math"), Python performs two steps internally. First __new__ creates the object, then __init__ initializes it.
1. Two-Step Process¶
```python class Demo: def new(cls, args, *kwargs): print("Creating instance (new)") return super().new(cls)
def __init__(self):
print("Initializing instance (__init__)")
d = Demo()
Creating instance (new)¶
Initializing instance (init)¶
```
2. Why __new__ Uses cls, Not self¶
__new__ receives the class as its first argument because no instance exists yet — it is the method responsible for creating one. Using cls makes this explicit. Writing self would be misleading: the first argument is the class object, not an instance.
| Method | First argument | What it receives |
|---|---|---|
__new__ |
cls |
The class being instantiated |
__init__ |
self |
The instance being initialized |
3. When to Override __new__¶
__new__ behaves like a class method (it receives cls), but does not require the @classmethod decorator — Python treats it as a special hook in the object creation protocol. Most classes never need to override __new__. Use it only when you need to control object creation itself:
Immutable type subclasses — values must be set before the object exists:
```python class UpperStr(str): def new(cls, value): return super().new(cls, value.upper())
s = UpperStr("hello") print(s) # HELLO — cannot do this in init ```
Singleton pattern — only one instance ever exists:
```python class Singleton: _instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
```
Think of it as: __new__ decides what object exists; __init__ decides how it is configured. In 99% of classes, __init__ alone is enough.
Instantiation Is Just Method Calls
When you write obj = Student("Alice", "Math"), Python executes:
python
obj = Student.__new__(Student, "Alice", "Math")
if isinstance(obj, Student):
Student.__init__(obj, "Alice", "Math")
Both __new__ and __init__ are ordinary method calls in Python's data model — there is no special "construction" mechanism. This is why you can override either one, and why the entire object lifecycle is customizable through the same protocol system that powers attribute access, iteration, and context managers.
Introspection with vars()¶
1. View Attributes¶
```python alice = Student("Alice", "Statistics") print(vars(alice))
{'name': 'Alice', 'major': 'Statistics'}¶
```
2. vars() vs __dict__¶
For simple objects, vars(obj) and obj.__dict__ return the same result — but they serve different abstraction levels. vars() is the public interface; __dict__ is the implementation detail. This follows Python's design pattern of separating interface from mechanism (len() vs __len__(), str() vs __str__()).
python
print(alice.__dict__) # Same output as vars(alice) for simple objects
print(vars() ) # Works in local scope too — no __dict__ equivalent
3. When to Use Which¶
Use vars() for general debugging and introspection. Use __dict__ only when you need direct access to the internal attribute storage — such as metaprogramming or dynamically injecting attributes. Some objects (those using __slots__) have no __dict__ at all.
Destructor: __del__¶
Most Python developers never use __del__ directly. It exists for resource cleanup, but Python does not guarantee that __del__ will ever run — not just that timing is unpredictable, but that it may never execute at all. If program correctness depends on cleanup, never rely on __del__.
1. Basic Syntax¶
```python class IceCream: def init(self, name, price): self.name = name self.price = price print(f"{self.name} created")
def __del__(self):
print(f"{self.name} destroyed")
```
2. Reference Counting¶
__del__ runs only when the last reference is removed.
python
obj1 = IceCream("Cone", 1500)
obj2 = obj1 # Two references
del obj1 # __del__ NOT called yet
del obj2 # Now __del__ is called
3. Why It Fails¶
__del__ can fail to run in several common scenarios:
- Circular references: objects referencing each other may never be collected
- Program exit: Python may skip
__del__entirely at interpreter shutdown - Multiple references: the object lives as long as any reference exists
```python class Node: def init(self, value): self.value = value self.next = None
a = Node(1) b = Node(2) a.next = b b.next = a # Circular reference — destructors may never run ```
Creation is explicit and guaranteed; destruction is implicit and best-effort.
Proper Cleanup: Context Managers¶
Since __del__ is unreliable, Python provides context managers for deterministic resource cleanup.
1. The with Statement¶
```python class FileHandler: def enter(self): self.file = open("data.txt", "w") return self.file
def __exit__(self, exc_type, exc_val, exc_tb):
self.file.close()
with FileHandler() as f: f.write("data")
Guaranteed cleanup — even if an exception occurs¶
```
2. Explicit Close Methods¶
```python class Resource: def init(self): self.resource = acquire_resource()
def close(self):
release_resource(self.resource)
r = Resource() try: pass # Use resource finally: r.close() ```
3. contextlib¶
```python from contextlib import contextmanager
@contextmanager def managed_resource(): resource = acquire_resource() try: yield resource finally: release_resource(resource) ```
Key Takeaways¶
__new__creates the object;__init__initializes it — know the difference.- Always define
__init__to enforce required fields and validate input. vars()and__dict__inspect object state for debugging.__del__is non-deterministic — never rely on it for critical cleanup.- Use context managers (
withstatement) for guaranteed resource management.
Exercises¶
Exercise 1.
Create a FileHandler class whose __init__ takes a filename and opens it in write mode. Add a write(text) method. Implement __del__ to close the file and print a message. Show the lifecycle by creating an instance, writing, and deleting it. Discuss why __del__ is unreliable compared to context managers.
Solution to Exercise 1
class FileHandler:
def __init__(self, filename):
self.filename = filename
self.file = open(filename, 'w')
print(f"Opened {filename}")
def write(self, text):
self.file.write(text)
def __del__(self):
if not self.file.closed:
self.file.close()
print(f"Closed {self.filename}")
# Lifecycle demo
fh = FileHandler("/tmp/test_lifecycle.txt")
fh.write("Hello")
del fh # Closed /tmp/test_lifecycle.txt
# __del__ is unreliable because:
# 1. Timing of garbage collection is unpredictable
# 2. May not run if interpreter crashes
# 3. Context managers provide deterministic cleanup
Exercise 2.
Write a ConnectionPool class whose __init__ creates a list of N simulated connections (just strings). Add a get_connection() method (pops from the list) and __del__ that prints how many connections were not returned. Create a pool of 3, take 2, and delete the pool.
Solution to Exercise 2
class ConnectionPool:
def __init__(self, size):
self.connections = [f"conn_{i}" for i in range(size)]
self.total = size
print(f"Pool created with {size} connections")
def get_connection(self):
if self.connections:
return self.connections.pop()
raise RuntimeError("No connections available")
def __del__(self):
unreturned = self.total - len(self.connections)
if unreturned:
print(f"Warning: {unreturned} connections were not returned")
pool = ConnectionPool(3)
c1 = pool.get_connection()
c2 = pool.get_connection()
del pool # Warning: 2 connections were not returned
Exercise 3.
Design a Timer class where __init__ records the start time. Add an elapsed() method returning seconds since creation. Implement the class as a context manager (__enter__ returns self, __exit__ prints elapsed time). Show both usage patterns: manual elapsed() calls and with statement.
Solution to Exercise 3
import time
class Timer:
def __init__(self):
self.start = time.time()
def elapsed(self):
return time.time() - self.start
def __enter__(self):
return self
def __exit__(self, *args):
print(f"Elapsed: {self.elapsed():.4f}s")
return False
# Manual usage
t = Timer()
time.sleep(0.1)
print(f"Manual: {t.elapsed():.4f}s")
# Context manager usage
with Timer() as t:
time.sleep(0.1)
Exercise 4.
Explain why __init__ is called an "initializer" rather than a "constructor" in Python. What actually constructs the object, and when does __init__ receive it? How does this distinction matter in practice?
Solution to Exercise 4
# In Python, the true constructor is __new__, not __init__.
#
# __new__ creates and returns the new object instance.
# __init__ receives the already-created instance (self) and
# sets up its attributes.
#
# The call MyClass(args) does two things:
# 1. obj = MyClass.__new__(MyClass) — creates the object
# 2. obj.__init__(args) — initializes its state
#
# This distinction matters because:
# - __new__ controls object creation (useful for singletons,
# immutable types like int/str subclasses)
# - __init__ only configures an already-existing object
# - If __new__ returns an instance of a different class,
# __init__ is not called at all
#
# For most classes, you only need __init__. Override __new__
# only when you need to control the creation step itself.
Exercise 5.
Create a class Tracker that logs every instantiation. Use a class attribute _instances (a list) and have __init__ append self to it. Add a class method count() that returns the number of instances created. Then implement __del__ to remove self from the list. Create three instances, delete one, and verify the count. Discuss why relying on __del__ for this tracking is fragile in production code.
Solution to Exercise 5
class Tracker:
_instances = []
def __init__(self, name):
self.name = name
Tracker._instances.append(self)
print(f"Created {self.name} (total: {Tracker.count()})")
def __del__(self):
if self in Tracker._instances:
Tracker._instances.remove(self)
print(f"Destroyed {self.name}")
@classmethod
def count(cls):
return len(cls._instances)
a = Tracker("A") # Created A (total: 1)
b = Tracker("B") # Created B (total: 2)
c = Tracker("C") # Created C (total: 3)
print(f"Count: {Tracker.count()}") # 3
del b # Destroyed B
print(f"Count: {Tracker.count()}") # 2
# Why this is fragile:
# 1. __del__ may not run (circular references, interpreter shutdown)
# 2. _instances holds strong references, preventing garbage collection
# 3. In production, use weakref.WeakSet instead of a list:
#
# import weakref
# class Tracker:
# _instances = weakref.WeakSet()
#
# WeakSet does not prevent garbage collection and automatically
# removes entries when objects are collected — no __del__ needed.