5.1 Classes, Instances, and Attributes
Key Takeaways
- A class is a user-defined blueprint defining data and behaviors, while an instance is a concrete, distinct runtime object allocated in memory.
- Instantiation invokes `__new__()` to allocate memory and `__init__(self, ...)` to initialize instance attributes, returning the newly created instance.
- The `self` parameter is an explicit reference to the instance bound automatically during method calls, translating `obj.method(arg)` into `ClassName.method(obj, arg)`.
- Instance variables are unique to each instance and stored in `self.__dict__`, whereas class variables are defined in the class body and shared across all instances via `ClassName.__dict__`.
- Attribute lookup searches the instance namespace first before checking the class namespace; assigning to `self.var` when `var` is a class attribute creates a new instance variable that shadows the class attribute without altering the class default.
Classes, Instances, and Attributes
Object-Oriented Programming (OOP) is a foundational programming paradigm in Python that structures software design around data models, known as objects, rather than functions and sequential logic alone. In Python, object-oriented concepts are deeply integrated into the runtime architecture: nearly every construct—including integers, strings, lists, functions, and modules—is an object instantiated from a corresponding class.
1. The OOP Paradigm in Python: Classes vs. Instances
To understand object orientation in Python, you must maintain a strict conceptual distinction between a class and an instance:
- Class (Blueprint / Type): A class is a programmer-defined template or schema that bundles data (attributes) and behavior (methods) together. It defines what state its instances can hold and what operations they can perform.
- Instance (Object / Realization): An instance is a concrete, individual realization of a class created during runtime. Each instance occupies its own dedicated block of memory, maintains its own independent state, and shares method definitions with all other instances of that class.
# Checking types and object identities
class Server:
pass
server_alpha = Server()
server_beta = Server()
print(type(server_alpha)) # <class '__main__.Server'>
print(type(server_alpha) is Server) # True
print(server_alpha is server_beta) # False (distinct memory locations)
print(hex(id(server_alpha))) # e.g., 0x104b2a8d0
print(hex(id(server_beta))) # e.g., 0x104b2a950
2. The class Keyword and Class Definition
A class is defined using the class keyword followed by the class identifier and a colon. The body of the class is indented and executed immediately upon definition to build the class object.
class Device:
"""A class representing a managed network hardware device."""
device_category = "Hardware" # Class attribute evaluated at definition time
# Inspecting the class object itself
print(Device.__name__) # 'Device'
print(Device.__doc__) # 'A class representing a managed network hardware device.'
By convention (PEP 8), class names use CapWords (PascalCase) naming (e.g., NetworkSwitch, UserProfile), distinguishing them from lowercase function and variable names.
3. Object Instantiation and the __init__() Initializer
Creating an instance of a class is called instantiation. Instantiation is performed by calling the class name as if it were a function:
The Two-Stage Object Creation Process
When Python executes ClassName(*args, **kwargs), it performs a two-stage sequence under the hood:
- Memory Allocation (
__new__): Python calls the static allocator method__new__(cls, *args, **kwargs)to create an empty, uninitialized object in memory. - Initialization (
__init__): Python immediately passes the newly created object as the first argument (self) to__init__(self, *args, **kwargs)to initialize its attributes.
class Packet:
def __init__(self, source_ip, destination_ip, payload_size):
# Initialize instance attributes
self.source_ip = source_ip
self.destination_ip = destination_ip
self.payload_size = payload_size
self.is_encrypted = False
# Instantiating two independent Packet objects
p1 = Packet("192.168.1.10", "10.0.0.1", 1024)
p2 = Packet("172.16.0.5", "8.8.8.8", 512)
print(p1.source_ip, p1.payload_size) # 192.168.1.10 1024
print(p2.source_ip, p2.payload_size) # 172.16.0.5 512
The Strict Return Rule of __init__
A critical rule tested on the PCAP exam is that __init__() must return None (or omit an explicit return statement). Returning any value other than None from __init__() raises a TypeError at runtime:
class FaultyClass:
def __init__(self, value):
self.value = value
return 42 # ILLEGAL: TypeError raised during instantiation!
try:
obj = FaultyClass(10)
except TypeError as err:
print("Instantiation Error:", err)
# Output: Instantiation Error: __init__() should return None, not 'int'
4. The self Parameter Mechanism
Unlike languages like C++ or Java where the object reference (this) is implicit, Python requires the reference to the active object instance to be explicitly declared as the first parameter in all regular instance method signatures.
Parameter Declaration vs. Method Invocation
- In Definition:
selfmust be written as the first parameter of every instance method. - In Invocation: Python automatically binds the calling instance and passes it as
self. You do not supply an argument forself.
class Account:
def __init__(self, account_id, initial_balance=0.0):
self.account_id = account_id
self.balance = initial_balance
def deposit(self, amount):
self.balance += amount
return self.balance
def get_summary(self):
return f"Account {self.account_id}: Balance ${self.balance:.2f}"
acc = Account("ACC-9021", 150.0)
# Syntactic Sugar Invocation
acc.deposit(50.0)
# Behind the scenes, Python translates the above to:
# Account.deposit(acc, 50.0)
print(acc.get_summary()) # Account ACC-9021: Balance $200.00
Equivalence of Invocation Forms
The expression instance.method(arg) is syntactic sugar for Class.method(instance, arg). Both forms are completely valid in Python:
# Method called via instance (implicit self binding)
result1 = acc.deposit(25.0)
# Method called via Class (explicit instance argument passed to self)
result2 = Account.deposit(acc, 25.0)
print(result1, result2) # 225.0 250.0
Naming Convention: While
selfis not a reserved Python keyword (syntactically, any valid identifier likethis,me, orobjworks), PEP 8 mandates the nameself. Using anything other thanselfis considered non-idiomatic and bad practice.
5. Instance Variables vs. Class Variables
Python maintains two distinct categories of attributes within object models:
| Feature | Instance Variables | Class Variables |
|---|---|---|
| Definition Location | Inside methods using self.var = value (typically in __init__) | Directly inside the class body, outside all methods |
| Storage Location | Stored in the instance dictionary (instance.__dict__) | Stored in the class dictionary (ClassName.__dict__) |
| Scope & Lifetime | Unique to that specific instance; allocated when created | Shared across all instances of the class; allocated on definition |
| Access Mechanism | Accessible via self.var or instance.var | Accessible via ClassName.var or instance.var |
| Primary Use Case | Unique object state (e.g., ID, balance, username, dimensions) | Shared constants, default configurations, or instance counters |
class DatabaseConnection:
# Class variables (shared state)
default_port = 5432
active_connections = 0
def __init__(self, host, database):
# Instance variables (unique state)
self.host = host
self.database = database
DatabaseConnection.active_connections += 1
conn1 = DatabaseConnection("db1.internal", "users")
conn2 = DatabaseConnection("db2.internal", "orders")
print(conn1.host, conn1.default_port) # db1.internal 5432
print(conn2.host, conn2.default_port) # db2.internal 5432
print("Total connections:", DatabaseConnection.active_connections) # 2
6. Attribute Lookup Hierarchy and Namespace Resolution
When you access an attribute on an instance using dot notation (instance.attribute), Python resolves the symbol through a deterministic lookup pipeline:
- Step 1: Check Instance Namespace: Python inspects
instance.__dict__. If the key'attribute'exists, its value is returned immediately. - Step 2: Check Class Namespace: If not found in the instance, Python checks
instance.__class__.__dict__(the class where the instance was spawned). - Step 3: Check Base Classes: If not found in the class, Python searches parent base classes according to the Method Resolution Order (MRO).
- Step 4: Raise Error: If the attribute is nowhere in the hierarchy, Python raises an
AttributeError.
Access: obj.attr
│
▼
[1. In obj.__dict__?] ──▶ YES ──▶ Return obj.__dict__['attr']
│ NO
▼
[2. In Class.__dict__?] ──▶ YES ──▶ Return Class.__dict__['attr']
│ NO
▼
[3. In Base Classes?] ──▶ YES ──▶ Return BaseClass.__dict__['attr']
│ NO
▼
[4. Raise AttributeError]
7. Attribute Shadowing and Mutation Gotchas
One of the most heavily tested areas on the PCAP exam is the subtle difference between reading a class variable through an instance versus assigning to a class variable through an instance.
The Shadowing Mechanism
When you read inst.var, Python falls back to the class variable if var is not in inst.__dict__. However, when you assign inst.var = new_value, Python does not modify the class variable. Instead, it creates a new instance variable in inst.__dict__ that shadows (hides) the class variable for that specific instance.
class Config:
timeout = 30 # Class variable
c1 = Config()
c2 = Config()
print(c1.timeout, c2.timeout, Config.timeout) # 30 30 30
# Modifying through instance c1
c1.timeout = 60
# What happened?
print(c1.timeout) # 60 (reads from c1.__dict__['timeout'])
print(c2.timeout) # 30 (falls back to Config.__dict__['timeout'])
print(Config.timeout) # 30 (class variable remains unchanged!)
# Verify internal dictionaries
print(c1.__dict__) # {'timeout': 60}
print(c2.__dict__) # {}
print('timeout' in Config.__dict__) # True
Modifying Class Variables Directly
To modify a class variable so that the change reflects across all instances (that have not shadowed the attribute), you must assign to the class attribute directly via the class name:
Config.timeout = 45
print(c1.timeout) # 60 (still shadowed by c1's instance variable)
print(c2.timeout) # 45 (reflects the updated class variable)
The Mutable Class Variable Trap
If a class variable holds a mutable object (such as a list, dictionary, or set), modifying the object in-place via an instance (e.g., inst.items.append(val)) mutates the single shared object in memory for all instances:
class Inventory:
shared_items = [] # Class variable containing a mutable list!
def __init__(self, owner):
self.owner = owner
inv1 = Inventory("Warehouse A")
inv2 = Inventory("Warehouse B")
# In-place mutation does not assign a new attribute; it mutates the shared list
inv1.shared_items.append("Widget")
print(inv1.shared_items) # ['Widget']
print(inv2.shared_items) # ['Widget'] -- Unexpected shared state!
Best Practice: To give each instance its own independent collection, always initialize mutable data structures inside
__init__()usingself.items = [].
A developer writes the following class definition and method invocation:
Which of the following expressions is exactly equivalent to the invocation class Vehicle:
def accelerate(self, speed_increase):
self.speed += speed_increase
car = Vehicle()
car.speed = 40
car.accelerate(15)
car.accelerate(15) at the Python interpreter level?
Consider the following Python code:
What is printed to the standard output?class Worker:
quota = 50
w1 = Worker()
w2 = Worker()
w1.quota += 10
Worker.quota += 5
print(w1.quota, w2.quota, Worker.quota)
Analyze the following Python snippet:
What output is produced when this script executes?class Team:
members = []
def __init__(self, name):
self.name = name
t1 = Team("Alpha")
t2 = Team("Beta")
t1.members.append("Alice")
t2.members.append("Bob")
print(len(t1.members), len(t2.members))
What happens when an __init__() method in a Python class attempts to return a value other than None?
class Sample:
def __init__(self, val):
self.val = val
return val
s = Sample(100)