Object Lifecycle¶
Lifecycle dunder methods control how objects are created, initialized, and destroyed.
Mental Model
Object creation is a two-phase process: __new__ allocates the blank object (like pouring a foundation), and __init__ furnishes it with state (like moving in the furniture). Destruction via __del__ is unreliable -- think of it as a "maybe" cleanup. For deterministic resource management, use context managers instead.
Overview: Object Creation Flow¶
MyClass(args)
↓
MyClass.__new__(cls, args) → Creates instance
↓
MyClass.__init__(self, args) → Initializes instance
↓
(object used)
↓
MyClass.__del__(self) → Called before destruction (unreliable)
__new__: Object Creation¶
__new__ is a static method that creates and returns a new instance.
Basic new¶
```python class MyClass: def new(cls, args, *kwargs): print(f"Creating instance of {cls.name}") instance = super().new(cls) return instance
def __init__(self, value):
print(f"Initializing with value={value}")
self.value = value
obj = MyClass(42)
Output:¶
Creating instance of MyClass¶
Initializing with value=42¶
```
When to Use new¶
__new__ is rarely needed, but useful for:
- Subclassing immutable types (str, int, tuple)
- Implementing singletons
- Object caching/flyweight pattern
- Custom metaclasses
Subclassing Immutable Types¶
Immutable types must be modified in __new__ because __init__ is too late.
```python class UpperStr(str): """String that's always uppercase."""
def __new__(cls, value):
# Must modify in __new__ - str is immutable
instance = super().__new__(cls, value.upper())
return instance
s = UpperStr("hello")
print(s) # HELLO
print(type(s)) #
```python class EvenInt(int): """Integer that rounds to nearest even number."""
def __new__(cls, value):
# Round to nearest even
rounded = round(value / 2) * 2
return super().__new__(cls, rounded)
print(EvenInt(3)) # 4 print(EvenInt(4)) # 4 print(EvenInt(5)) # 6 ```
```python class NamedTuple(tuple): """Simple named tuple implementation."""
def __new__(cls, name, values):
instance = super().__new__(cls, values)
instance.name = name # Can add attributes after creation
return instance
point = NamedTuple("origin", (0, 0)) print(point) # (0, 0) print(point.name) # origin ```
Singleton Pattern¶
Ensure only one instance of a class exists.
```python class Singleton: _instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, value=None):
# Note: __init__ runs every time!
if value is not None:
self.value = value
a = Singleton(1) b = Singleton(2)
print(a is b) # True print(a.value) # 2 (overwritten by second init) print(id(a), id(b)) # Same id ```
init Runs Every Time
A common singleton bug: __init__ runs every time you call the class, even when __new__ returns the existing instance. In the example above, Singleton(2) does not create a new object, but it still executes __init__, overwriting value from 1 to 2. The BetterSingleton pattern below guards against this with an _initialized flag. Always consider whether repeated __init__ calls could corrupt your singleton's state.
Singleton with Init Guard¶
```python class BetterSingleton: _instance = None _initialized = False
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, value):
if not BetterSingleton._initialized:
self.value = value
BetterSingleton._initialized = True
a = BetterSingleton(1) b = BetterSingleton(2)
print(a.value) # 1 (preserved) print(b.value) # 1 (same object) ```
Object Caching / Flyweight Pattern¶
Reuse existing objects for identical values.
```python class CachedInt: _cache = {}
def __new__(cls, value):
if value in cls._cache:
return cls._cache[value]
instance = super().__new__(cls)
instance.value = value
cls._cache[value] = instance
return instance
def __init__(self, value):
pass # Already initialized in __new__
def __repr__(self):
return f"CachedInt({self.value})"
a = CachedInt(5) b = CachedInt(5) c = CachedInt(10)
print(a is b) # True (same cached object) print(a is c) # False (different value) ```
LRU Cache for Limited Memory¶
```python from collections import OrderedDict
class LRUCachedObject: _cache = OrderedDict() _max_size = 100
def __new__(cls, key):
if key in cls._cache:
# Move to end (most recently used)
cls._cache.move_to_end(key)
return cls._cache[key]
instance = super().__new__(cls)
instance.key = key
cls._cache[key] = instance
if len(cls._cache) > cls._max_size:
cls._cache.popitem(last=False) # Remove oldest
return instance
```
__init__: Object Initialization¶
__init__ initializes an already-created instance.
Basic init¶
```python class Person: def init(self, name, age): self.name = name self.age = age self._validate()
def _validate(self):
if self.age < 0:
raise ValueError("Age cannot be negative")
p = Person("Alice", 30) print(p.name) # Alice ```
init Must Return None¶
```python class Wrong: def init(self, value): self.value = value return self # TypeError!
TypeError: init() should return None, not 'Wrong'¶
```
Flexible Initialization¶
```python class Connection: def init(self, host=None, port=None, *, url=None): if url: # Parse URL self.host, self.port = self._parse_url(url) else: self.host = host or 'localhost' self.port = port or 8080
def _parse_url(self, url):
# Simplified parsing
parts = url.replace('://', ':').split(':')
return parts[1], int(parts[2])
def __repr__(self):
return f"Connection({self.host}:{self.port})"
Multiple initialization patterns¶
c1 = Connection('example.com', 443) c2 = Connection(url='https://api.example.com:8443') c3 = Connection() # defaults
print(c1) # Connection(example.com:443) print(c2) # Connection(api.example.com:8443) print(c3) # Connection(localhost:8080) ```
__del__: Object Destruction¶
__del__ is called when an object is about to be garbage-collected. Avoid __del__ in real code unless you have a thorough understanding of Python's garbage collector. Its timing is unpredictable, it may never run (circular references, interpreter shutdown), and exceptions raised inside it are silently ignored. Use context managers (with statements) for reliable cleanup instead.
Basic del¶
```python class Resource: def init(self, name): self.name = name print(f"Acquiring {name}")
def __del__(self):
print(f"Releasing {self.name}")
r = Resource("database connection")
Acquiring database connection¶
del r
Releasing database connection¶
```
Why del is Unreliable¶
```python
Problem 1: Timing is unpredictable¶
class Unreliable: def del(self): print("Destructor called")
obj = Unreliable() obj = None # May or may not trigger del immediately
Problem 2: Circular references may prevent del¶
class Node: def init(self): self.ref = None
def __del__(self):
print("Node destroyed")
a = Node() b = Node() a.ref = b b.ref = a # Circular reference del a, b # del may never be called!
Problem 3: Exceptions in del are ignored¶
class BadDestructor: def del(self): raise RuntimeError("Oops!") # Silently ignored
obj = BadDestructor() del obj # No exception raised, just a warning ```
Better Alternative: Context Managers¶
```python class Resource: def init(self, name): self.name = name
def __enter__(self):
print(f"Acquiring {self.name}")
return self
def __exit__(self, exc_type, exc_val, exc_tb):
print(f"Releasing {self.name}")
return False
def use(self):
print(f"Using {self.name}")
Guaranteed cleanup with context manager¶
with Resource("database") as r: r.use()
Releasing happens even if exception occurs¶
```
Complete Lifecycle Example¶
```python class TrackedObject: _count = 0
def __new__(cls, name):
print(f"1. __new__: Creating instance of {cls.__name__}")
instance = super().__new__(cls)
return instance
def __init__(self, name):
print(f"2. __init__: Initializing with name={name}")
self.name = name
TrackedObject._count += 1
self.id = TrackedObject._count
def __repr__(self):
return f"TrackedObject(name={self.name!r}, id={self.id})"
def __del__(self):
print(f"3. __del__: Destroying {self.name} (id={self.id})")
print("Creating object:") obj = TrackedObject("test") print(f"Object: {obj}") print("\nDeleting object:") del obj print("Done")
Output:¶
Creating object:¶
1. new: Creating instance of TrackedObject¶
2. init: Initializing with name=test¶
Object: TrackedObject(name='test', id=1)¶
¶
Deleting object:¶
3. del: Destroying test (id=1)¶
Done¶
```
Factory Methods Using new¶
```python class Shape: def new(cls, shape_type, *args): if cls is not Shape: # Called on subclass, proceed normally return super().new(cls)
# Factory: create appropriate subclass
if shape_type == 'circle':
return Circle.__new__(Circle)
elif shape_type == 'square':
return Square.__new__(Square)
else:
raise ValueError(f"Unknown shape: {shape_type}")
class Circle(Shape): def init(self, shape_type, radius): self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
class Square(Shape): def init(self, shape_type, side): self.side = side
def area(self):
return self.side ** 2
Factory usage¶
c = Shape('circle', 5)
s = Shape('square', 4)
print(type(c)) #
init_subclass: Customizing Subclass Creation¶
Python 3.6+ provides __init_subclass__ for customizing subclass behavior.
```python class Plugin: _registry = {}
def __init_subclass__(cls, name=None, **kwargs):
super().__init_subclass__(**kwargs)
plugin_name = name or cls.__name__.lower()
Plugin._registry[plugin_name] = cls
print(f"Registered plugin: {plugin_name}")
@classmethod
def get_plugin(cls, name):
return cls._registry.get(name)
class AudioPlugin(Plugin, name='audio'): pass
class VideoPlugin(Plugin): # Uses class name pass
Output:¶
Registered plugin: audio¶
Registered plugin: videoplugin¶
print(Plugin._registry)
{'audio': , 'videoplugin': }¶
```
Key Takeaways¶
__new__creates instances; use for immutables, singletons, caching__init__initializes instances; most common place for setup__del__is unreliable; prefer context managers for cleanup__new__receives class,__init__receives instance__new__must return an instance;__init__must returnNone- For reliable cleanup, use
withstatements and__enter__/__exit__ - Use
__init_subclass__to customize subclass creation (Python 3.6+)
Runnable Example: sequence_protocol_example.py¶
```python """ TUTORIAL: The Sequence Protocol - Making Custom Objects Behave Like Lists
This tutorial teaches you how to implement len and getitem dunder methods to make a custom class support the sequence protocol. We'll build a FrenchDeck class that behaves like a sequence - you can call len() on it, index into it, and slice it, just like with built-in lists and tuples.
Key Learning Goals: - Understand why dunder methods make Python more intuitive - Learn how len and getitem enable sequence behavior - See how minimal code enables powerful functionality """
import collections
if name == "main":
print("=" * 70)
print("TUTORIAL: The Sequence Protocol - Custom Sequence Objects")
print("=" * 70)
# ============ EXAMPLE 1: Understanding Namedtuples ============
print("\n# Example 1: Creating a Card with namedtuple")
print("=" * 70)
Card = collections.namedtuple('Card', ['rank', 'suit'])
# A namedtuple is a lightweight data structure. It's perfect for simple objects
# that need readable field names instead of just index positions.
card1 = Card('7', 'hearts')
card2 = Card('A', 'spades')
print(f"Card 1: {card1}")
print(f"Card 2: {card2}")
print(f"Accessing card1.rank: {card1.rank}")
print(f"Accessing card1.suit: {card1.suit}")
print("""
WHY: A namedtuple is perfect here because a card is just two pieces of data.
It's more readable than Card(7, 'hearts') and faster than a full class.
""")
# ============ EXAMPLE 2: Building the FrenchDeck Class ============
print("\n# Example 2: Implementing the Sequence Protocol")
print("=" * 70)
class FrenchDeck:
"""
A standard 52-card French deck that implements the sequence protocol.
By implementing __len__ and __getitem__, instances can be used wherever
Python expects a sequence (like lists or tuples). This is a powerful example
of "duck typing" - if it walks like a sequence and quacks like a sequence,
Python will treat it as one.
"""
# Class attributes: these are defined once and shared by all instances
ranks = [str(n) for n in range(2, 11)] + list('JQKA') # 2-10, J, Q, K, A
suits = 'spades diamonds clubs hearts'.split()
def __init__(self):
"""
Initialize the deck with all 52 cards.
We create cards by combining each suit with each rank.
The order matters: suits loop on the outside, ranks on the inside.
This creates: [Card(2, spades), Card(3, spades), ..., Card(A, hearts)]
"""
self._cards = [Card(rank, suit)
for suit in self.suits
for rank in self.ranks]
def __len__(self):
"""
Return the number of cards in the deck.
By implementing __len__, our deck now supports:
- len(deck) # returns 52
- if deck: # works as a boolean (empty deck = False)
- for loop iteration works better
This is a tiny method but incredibly powerful. Python now treats
FrenchDeck as a sequence!
"""
return len(self._cards)
def __getitem__(self, position):
"""
Return a card at a specific position or a slice of cards.
By implementing __getitem__, our deck now supports:
- deck[0] # get first card
- deck[-1] # get last card
- deck[1:3] # slice operations work automatically!
- for card in deck: # iteration works too
This is the key to making our class behave like a real sequence.
We don't need separate slicing logic - Python handles it for us
because __getitem__ can receive slice objects.
"""
return self._cards[position]
# ============ EXAMPLE 3: Creating and Using a Deck ============
print("\n# Example 3: Creating a FrenchDeck")
print("=" * 70)
deck = FrenchDeck()
print(f"Number of cards in deck: {len(deck)}")
print(f"First card: {deck[0]}")
print(f"Last card: {deck[-1]}")
print(f"""
WHY: We don't need to write special methods for these operations.
By implementing __getitem__ with a list inside, Python automatically
gives us indexing, negative indexing, and even slicing!
""")
# ============ EXAMPLE 4: Slicing Works Automatically ============
print("\n# Example 4: Slicing Operations (Free From __getitem__)")
print("=" * 70)
first_three = deck[0:3]
print(f"First three cards: {first_three}")
last_five = deck[-5:]
print(f"Last five cards: {last_five}")
every_nth = deck[::13] # Every 13th card (one from each suit, roughly)
print(f"Every 13th card (samples): {every_nth}")
print("""
WHY: Slicing is automatic! When you write deck[0:3], Python calls
__getitem__ with a slice(0, 3) object, and that gets passed to our
internal list's __getitem__, which already knows how to handle slices.
""")
# ============ EXAMPLE 5: Iteration Works Automatically ============
print("\n# Example 5: Iteration (Also Free From __getitem__)")
print("=" * 70)
print("First 5 cards when iterating:")
for i, card in enumerate(deck):
if i < 5:
print(f" Card {i}: {card}")
else:
break
print(" ...")
print(f"Total cards iterated: {len(deck)}")
print("""
WHY: For loops work because Python falls back to __getitem__ when __iter__
isn't defined. It starts at index 0 and keeps incrementing until it gets
an IndexError. This is how iteration works on sequences!
""")
# ============ EXAMPLE 6: Boolean Context (Free From __len__) ============
print("\n# Example 6: Using Deck in Boolean Context")
print("=" * 70)
non_empty_deck = FrenchDeck()
empty_list = []
print(f"if deck: {bool(non_empty_deck)} (deck has {len(non_empty_deck)} cards)")
print(f"if empty_list: {bool(empty_list)} (list is empty)")
print("""
WHY: Python uses __len__ to determine truthiness. Any object with a
non-zero length is truthy. By implementing __len__, we get free boolean
behavior!
""")
# ============ EXAMPLE 7: The Complete Picture ============
print("\n# Example 7: What We Get With Just Two Methods")
print("=" * 70)
print("""
By implementing just __len__ and __getitem__, FrenchDeck gets:
✓ len(deck) - calls __len__
✓ deck[i] - calls __getitem__
✓ deck[i:j] - calls __getitem__ with slice object
✓ deck[-1] - negative indexing
✓ for card in deck: - iteration
✓ if deck: ... - boolean context
✓ reversed(deck) - calls __getitem__ with negative indices
✓ list(deck) - conversion to list
✓ print(deck[0]) - card representation
This is the power of the sequence protocol. Python recognizes the pattern
and automatically enables a whole family of operations.
KEY INSIGHT: By following Python's protocols (implementing the right dunder
methods), we write less code and users can interact with our objects using
all the standard Python operations they already know.
""")
# ============ EXAMPLE 8: Practical Use Case ============
print("\n# Example 8: Practical Code with Our Deck")
print("=" * 70)
# Because we implemented the sequence protocol, we can use standard Python tools
import random
small_hand = random.sample(deck, 5)
print(f"Random hand of 5 cards: {small_hand}")
# Convert to list if needed (though it's already sequence-like)
as_list = list(deck[:3])
print(f"First 3 cards as list: {as_list}")
# Use in any place that expects sequences
def show_first_card(sequence):
"""Works with any sequence"""
if sequence:
return sequence[0]
return None
print(f"Works with functions expecting sequences: {show_first_card(deck)}")
print("""
WHY: Because we implemented the sequence protocol correctly, FrenchDeck
instances work seamlessly with all standard Python tools and functions
that expect sequences. This is duck typing in action.
""")
print("\n" + "=" * 70)
print("KEY TAKEAWAYS")
print("=" * 70)
print("""
1. DUNDER METHODS ARE PROTOCOLS: Implementing __len__ and __getitem__
is not about those specific methods - it's about implementing the
sequence protocol, which tells Python "I'm sequence-like."
2. MINIMAL CODE, MAXIMUM POWER: With just two methods, we unlocked
indexing, slicing, iteration, boolean context, and more.
3. DUCK TYPING: We don't inherit from list, we don't have a special base
class - we just implement the right methods. Python doesn't care about
our type, only our behavior.
4. USERS GET FAMILIAR PYTHON: Since our deck behaves like a sequence,
users can use all the standard Python operations they already know.
No learning curve required.
""")
```
Exercises¶
Exercise 1.
Create a class TrackedObject that prints a message in __init__ when created and in __del__ when destroyed. Create an instance, assign it to a second variable, then delete the first variable. Observe that __del__ is NOT called until all references are gone.
Solution to Exercise 1
class TrackedObject:
def __init__(self, name):
self.name = name
print(f"Created: {self.name}")
def __del__(self):
print(f"Destroyed: {self.name}")
obj = TrackedObject("alpha") # Created: alpha
ref = obj # Second reference
del obj
print("obj deleted, ref still exists")
# __del__ NOT called yet
del ref
# Now __del__ is called: Destroyed: alpha
Exercise 2.
Write a Singleton class where __new__ ensures only one instance is ever created. If an instance already exists, __new__ returns the existing one. Demonstrate that creating multiple instances all return the same object (same id()).
Solution to Exercise 2
class Singleton:
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, value=None):
self.value = value
a = Singleton("first")
b = Singleton("second")
print(a is b) # True
print(id(a) == id(b)) # True
print(a.value) # "second" — __init__ ran again
Exercise 3.
Build a Connection class that uses __init__ to open a simulated connection and __del__ to close it. Also implement __enter__ and __exit__ so it works as a context manager. Demonstrate both usage patterns and explain why the context manager approach is preferred.
Solution to Exercise 3
class Connection:
def __init__(self, url):
self.url = url
self.connected = True
print(f"Connected to {url}")
def __del__(self):
if self.connected:
self.close()
def close(self):
self.connected = False
print(f"Disconnected from {self.url}")
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
return False
# Context manager (preferred — deterministic cleanup)
with Connection("https://api.example.com") as conn:
print(f"Using: {conn.connected}")
# Automatically disconnected