Code Trace: Class Attribute vs Instance Attribute Shadowing
Interview Question: "What does each print statement output, and why? Explain how Python resolves attribute lookups through
__dict__, why assigning toselfcreates variable shadowing, and what happens when using the in-place+=operator on class attributes."class Counter:count = 0def __init__(self):Counter.count += 1def reset(self):self.count = 0c1 = Counter()c2 = Counter()print(Counter.count)c1.reset()print(c1.count)print(Counter.count)print(c2.count)
This question is a favorite in Python system design and architecture interviews. While beginners assume variable resolution in Python works like C++ or Java static members, senior engineers understand that Python attribute access is governed by runtime namespace dictionary searches (__dict__).
1. Exact Output
2
0
2
2
2. Step-by-Step Namespace Trace (__dict__)
To understand why this output occurs, inspect the underlying __dict__ namespaces of the class and instances at each stage:
+-----------------------------------------------------------------------------------+
| 1. AFTER INSTANTIATION: c1 = Counter(); c2 = Counter() |
| Counter.__dict__ contains: {'count': 2, ...} |
| c1.__dict__ is: {} (EMPTY!) |
| c2.__dict__ is: {} (EMPTY!) |
+-----------------------------------------------------------------------------------+
| 2. FIRST PRINT: print(Counter.count) |
| Direct lookup in Counter.__dict__['count'] -> PRINTS: 2 |
+-----------------------------------------------------------------------------------+
| 3. INVOCATION: c1.reset() |
| Executes: self.count = 0 |
| THE TRAP: Assignment targets c1's local namespace! |
| c1.__dict__ becomes: {'count': 0} |
| Counter.__dict__ is UNTOUCHED: {'count': 2, ...} |
+-----------------------------------------------------------------------------------+
| 4. SECOND PRINT: print(c1.count) |
| Lookup finds 'count' directly in c1.__dict__ -> PRINTS: 0 (Shadowed!) |
+-----------------------------------------------------------------------------------+
| 5. THIRD PRINT: print(Counter.count) |
| Direct lookup in Counter.__dict__['count'] -> PRINTS: 2 |
+-----------------------------------------------------------------------------------+
| 6. FOURTH PRINT: print(c2.count) |
| c2.__dict__ is empty -> Falls back to Counter.__dict__ -> PRINTS: 2 |
+-----------------------------------------------------------------------------------+
3. Python's 5-Tier Attribute Lookup Protocol
When code evaluates an expression like obj.attribute, the Python interpreter searches through namespaces in a strict order:
- Data Descriptors: Checked on the class of
objand its MRO (any class attribute defining both__get__and__set__, such as a@property). - Instance Dictionary (
obj.__dict__): If the attribute is found in the instance's own dictionary, its value is immediately returned. - Non-Data Descriptors & Class Attributes: If not in
obj.__dict__, Python checkstype(obj).__dict__(methods, class attributes,@classmethod). - Base Classes (MRO): Traverses the superclasses in C3 Linearization order.
- Fallback (
__getattr__): If all lookups fail, Python callsobj.__getattr__("attribute")if defined, or raises anAttributeError.
The Asymmetry Between Reading and Writing:
- Reading (
print(obj.count)): Searchesc1.__dict__first; if missing, falls back toCounter.__dict__. - Writing (
self.count = 0): Always writes directly toself.__dict__. It never writes toCounter.__dict__. This creates a local instance attribute that permanently "shadows" (masks) the class attribute for that specific instance.
4. The Dangerous += Shadowing Trap
A notorious bug in production Python services involves modifying class-level counters using self:
class Worker:
tasks_processed = 0
def process(self):
# SUBTLE TRAP: What does this do?
self.tasks_processed += 1
What happens under the hood?
The in-place operator self.tasks_processed += 1 expands to:
- Right-Hand Side (
self.tasks_processed + 1): Python readsself.tasks_processed. Sinceself.__dict__is empty, it readsWorker.tasks_processed(). . - Left-Hand Side (
self.tasks_processed = 1): Python executes an assignment toself! - The Result: It creates a brand-new instance attribute
tasks_processed = 1insideself.__dict__. Worker.tasks_processedremains stuck at0forever! Every worker object now maintains an isolated instance counter instead of updating the shared telemetry metric.
The Proper Fix:
To mutate a class attribute, either reference the class directly or use type(self):
Counter.count = 0 # Explicit class reference
type(self).count = 0 # Polymorphic class reference
self.__class__.count = 0 # Alternative
5. Mutable Class Attributes: The Silent In-Place Mutation Trap
While assigning an immutable integer (self.count = 0) creates a shadowing variable, modifying a mutable object (like a list or dict) exhibits the complete opposite behavior:
class Service:
shared_cache = [] # Mutable Class Attribute
def add_data(self, item):
# Does this shadow?
self.shared_cache.append(item)
- When calling
self.shared_cache.append(item), there is no assignment operator (=). - Python reads
self.shared_cache, finds the shared list inService.__dict__, and mutates that exact memory buffer in place. self.__dict__remains completely empty.- Every single instance of
Servicenow sees the appended data! This is why mutable default values in classes and functions are considered a major anti-pattern.
6. Runnable Python Verification Code
"""
Standalone Python verification for Class vs Instance Attribute Shadowing.
Run with: python attribute_shadowing_trace.py
"""
class Counter:
count = 0
def __init__(self):
Counter.count += 1
def reset(self):
self.count = 0
if __name__ == "__main__":
c1 = Counter()
c2 = Counter()
# Step 1: Initial state
print("Initial Class Count:", Counter.count)
print("c1.__dict__:", c1.__dict__)
print("c2.__dict__:", c2.__dict__)
assert Counter.count == 2
assert c1.__dict__ == {}
# Step 2: c1 resets (Shadowing occurs)
c1.reset()
print("\nAfter c1.reset():")
print("c1.count: ", c1.count)
print("Counter.count: ", Counter.count)
print("c2.count: ", c2.count)
print("c1.__dict__: ", c1.__dict__)
print("c2.__dict__: ", c2.__dict__)
assert c1.count == 0
assert Counter.count == 2
assert c2.count == 2
assert c1.__dict__ == {"count": 0}
assert c2.__dict__ == {}
# Step 3: Demonstrating the += trap
class TrapDemo:
val = 10
def bad_increment(self):
self.val += 1
t = TrapDemo()
t.bad_increment()
print("\n+= Trap Demonstration:")
print("TrapDemo.val (Class):", TrapDemo.val)
print("t.val (Instance): ", t.val)
assert TrapDemo.val == 10 # Class attribute was NOT incremented!
assert t.val == 11 # Instance attribute shadowed it!
print("Verification passed successfully.")
7. Concise Staff-Level Interview Answer
"The output is
2,0,2, and2.In Python, class attributes live in the class's namespace (
Counter.__dict__), while instance attributes reside in the object's instance dictionary (self.__dict__).After instantiating
c1andc2,Counter.countequals 2, while both instance dictionaries are completely empty. Whenc1.reset()executesself.count = 0, Python performs an assignment targetingc1's local namespace. This injects'count': 0intoc1.__dict__, shadowing the class attribute forc1while leavingCounter.__dict__['count']untouched at 2.When reading
c1.count, Python finds the local instance attribute (0). When readingc2.count, becausec2.__dict__is empty, Python falls back to the class blueprint and retrieves the shared class attribute (2).To modify a shared class attribute safely from an instance method, you must explicitly qualify it via
Counter.count = 0ortype(self).count = 0to prevent creating an unintentional shadowing attribute."