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.
Last updated: August 2026

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:

instance_ref=ClassName(arguments...)\text{instance\_ref} = \text{ClassName}(\text{arguments...})

The Two-Stage Object Creation Process

When Python executes ClassName(*args, **kwargs), it performs a two-stage sequence under the hood:

  1. Memory Allocation (__new__): Python calls the static allocator method __new__(cls, *args, **kwargs) to create an empty, uninitialized object in memory.
  2. 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: self must 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 for self.
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 self is not a reserved Python keyword (syntactically, any valid identifier like this, me, or obj works), PEP 8 mandates the name self. Using anything other than self is considered non-idiomatic and bad practice.


5. Instance Variables vs. Class Variables

Python maintains two distinct categories of attributes within object models:

FeatureInstance VariablesClass Variables
Definition LocationInside methods using self.var = value (typically in __init__)Directly inside the class body, outside all methods
Storage LocationStored in the instance dictionary (instance.__dict__)Stored in the class dictionary (ClassName.__dict__)
Scope & LifetimeUnique to that specific instance; allocated when createdShared across all instances of the class; allocated on definition
Access MechanismAccessible via self.var or instance.varAccessible via ClassName.var or instance.var
Primary Use CaseUnique 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:

  1. Step 1: Check Instance Namespace: Python inspects instance.__dict__. If the key 'attribute' exists, its value is returned immediately.
  2. Step 2: Check Class Namespace: If not found in the instance, Python checks instance.__class__.__dict__ (the class where the instance was spawned).
  3. Step 3: Check Base Classes: If not found in the class, Python searches parent base classes according to the Method Resolution Order (MRO).
  4. 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__() using self.items = [].

Loading diagram...
Attribute Lookup Resolution and Shadowing Mechanism
Test Your Knowledge

A developer writes the following class definition and method invocation:

class Vehicle:
    def accelerate(self, speed_increase):
        self.speed += speed_increase

car = Vehicle()
car.speed = 40
car.accelerate(15)
Which of the following expressions is exactly equivalent to the invocation car.accelerate(15) at the Python interpreter level?

A
B
C
D
Test Your Knowledge

Consider the following Python code:

class Worker:
    quota = 50

w1 = Worker()
w2 = Worker()

w1.quota += 10
Worker.quota += 5

print(w1.quota, w2.quota, Worker.quota)
What is printed to the standard output?

A
B
C
D
Test Your Knowledge

Analyze the following Python snippet:

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 output is produced when this script executes?

A
B
C
D
Test Your Knowledge

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)

A
B
C
D