Classes and Instances¶
Understanding the building blocks of OOP: classes define structure, instances carry data.
Mental Model
A class is a cookie cutter; an instance is a cookie. The cutter defines the shape (attributes and methods), but each cookie carries its own filling (instance data). Many cookies can come from the same cutter, yet each one is independent -- changing the frosting on one does not affect the others.
Core Insight
A class couples state and behavior — the data and the operations on it live together, so each object controls its own state.
Defining a Class¶
1. Basic Structure¶
A class groups data and behavior into a single definition.
```python class Dog: # Class attribute species = "Canis familiaris"
def __init__(self, name):
# Instance attribute
self.name = name
def bark(self):
return f"{self.name} says woof!"
```
2. Components¶
A class definition includes:
- Class attributes: data shared across all instances
__init__: initializer that sets up instance state- Methods: functions that operate on instance data via
self
3. What a Class Defines¶
A class specifies how objects store data (attributes) and what they can do (methods). Class attributes exist on the class itself, but instance-specific data does not exist until an object is created.
Creating Instances¶
1. Instantiation¶
Calling a class creates a new instance. Each call produces a distinct object.
```python dog1 = Dog("Rex") dog2 = Dog("Max")
print(dog1.name) # Rex print(dog2.name) # Max ```
2. Independence¶
Each instance carries its own state. Modifying one does not affect others.
```python print(dog1.bark()) # Rex says woof! print(dog2.bark()) # Max says woof!
Different objects¶
print(dog1 is dog2) # False ```
3. Self Reference¶
Inside a method, self refers to the instance the method was called on. This is how methods access and modify instance-specific data.
Class vs Instance Attributes¶
1. Class Attributes¶
Defined in the class body, shared by all instances.
```python class MyClass: class_var = "shared"
def __init__(self):
self.instance_var = "unique"
```
2. Instance Attributes¶
Defined in __init__ via self, unique to each object.
```python obj1 = MyClass() obj2 = MyClass()
print(obj1.class_var) # shared print(obj1.instance_var) # unique ```
3. Lookup Order and Shadowing¶
When you write obj.attr, Python follows a lookup chain:
Attribute Lookup Mechanism
text
obj.attr →
1. obj.__dict__ (instance namespace)
2. type(obj).__dict__ (class namespace)
3. base classes (via MRO)
4. descriptors (methods, properties)
This is why methods are found on the class (step 2), instance attributes override class attributes (step 1 wins), and self is automatically passed — because type(obj).__dict__['method'].__get__(obj, type(obj)) creates a bound method that injects obj as the first argument.
Python searches the instance namespace first, then the class namespace. Assigning to an instance attribute with the same name as a class attribute creates a shadow — the class attribute remains unchanged for other instances.
python
obj1.class_var = "new" # Creates instance attribute — shadows class_var
print(obj1.class_var) # "new" (instance)
print(obj2.class_var) # "shared" (class — unchanged)
print(MyClass.class_var) # "shared" (class — unchanged)
Encapsulation¶
1. Public Attributes¶
By default, all attributes are public and can be accessed directly.
```python class Car: def init(self, brand, model, year): self.brand = brand self.model = model self.year = year self.odometer = 0
def drive(self, miles):
self.odometer += miles
return f"Drove {miles} miles. Total: {self.odometer}"
car = Car("Toyota", "Camry", 2020) print(car.drive(100)) # Drove 100 miles. Total: 100 ```
2. Name Mangling with __var¶
Double underscores trigger name mangling — Python rewrites self.__var to self._ClassName__var. This is designed to avoid accidental name collisions in inheritance, not to enforce true access restriction.
```python class Base: def init(self): self.__value = 10
class Child(Base): def init(self): super().init() self.__value = 20
c = Child() print(c._Base__value) # 10 — Base's version preserved print(c._Child__value) # 20 — no collision ```
3. Access Conventions¶
Python has no true private variables — it uses conventions and name rewriting.
| Name | Meaning | Enforcement |
|---|---|---|
self.var |
Public API | None |
self._var |
Internal use (convention) | None — a warning to users |
self.__var |
Avoid name collisions | Name mangling |
Single underscore warns users ("please don't touch"); double underscore protects against accidental override in subclasses.
Key Takeaways¶
- A class defines how objects store data (attributes) and what they can do (methods).
- Class attributes are shared; instance attributes are unique to each object.
selfrefers to the current instance inside methods.- Python searches instance namespace before class namespace — assignments create shadows.
- Encapsulation protects internal state via private attributes and methods.
Exercises¶
Exercise 1.
Create a Dog class with instance attributes name, breed, and age. Add methods bark() (returns a string), birthday() (increments age), and __str__. Create two dogs and show they are independent instances with separate state.
Solution to Exercise 1
class Dog:
def __init__(self, name, breed, age):
self.name = name
self.breed = breed
self.age = age
def bark(self):
return f"{self.name} says: Woof!"
def birthday(self):
self.age += 1
def __str__(self):
return f"{self.name} ({self.breed}, {self.age} years)"
d1 = Dog("Buddy", "Lab", 3)
d2 = Dog("Max", "Poodle", 5)
print(d1) # Buddy (Lab, 3 years)
print(d2.bark()) # Max says: Woof!
d1.birthday()
print(d1.age) # 4
print(d2.age) # 5 — independent
Exercise 2.
Write a BankAccount class with owner and balance attributes. Add deposit(amount), withdraw(amount) (raises ValueError if insufficient funds), and __repr__ methods. Create two accounts, perform transactions, and show balances are independent.
Solution to Exercise 2
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
self.balance += amount
def withdraw(self, amount):
if amount > self.balance:
raise ValueError("Insufficient funds")
self.balance -= amount
def __repr__(self):
return f"BankAccount('{self.owner}', {self.balance})"
a1 = BankAccount("Alice", 1000)
a2 = BankAccount("Bob", 500)
a1.deposit(200)
a2.withdraw(100)
print(a1) # BankAccount('Alice', 1200)
print(a2) # BankAccount('Bob', 400)
Exercise 3.
Build a Classroom class that stores a name and a list of students. Add enroll(student_name), drop(student_name), and roster() (returns sorted list) methods. Show that isinstance() confirms the type, and two classrooms maintain separate student lists.
Solution to Exercise 3
class Classroom:
def __init__(self, name):
self.name = name
self.students = []
def enroll(self, student_name):
if student_name not in self.students:
self.students.append(student_name)
def drop(self, student_name):
self.students.remove(student_name)
def roster(self):
return sorted(self.students)
c1 = Classroom("Math 101")
c2 = Classroom("Physics 201")
c1.enroll("Alice")
c1.enroll("Bob")
c2.enroll("Charlie")
print(c1.roster()) # ['Alice', 'Bob']
print(c2.roster()) # ['Charlie'] — independent
print(isinstance(c1, Classroom)) # True
Exercise 4.
Explain the difference between class attributes and instance attributes. What happens when you assign obj.x = 5 if x is already a class attribute? Write a short example demonstrating attribute shadowing and show that the class attribute is unaffected.
Solution to Exercise 4
class Config:
debug = False # Class attribute
c1 = Config()
c2 = Config()
# Before shadowing — both see the class attribute
print(c1.debug) # False
print(c2.debug) # False
# Assign to instance — creates a shadow
c1.debug = True
print(c1.debug) # True (instance attribute)
print(c2.debug) # False (class attribute — unchanged)
print(Config.debug) # False (class attribute — unchanged)
# When you assign obj.x = value, Python creates or updates
# an instance attribute. The class attribute with the same name
# is not modified — it is merely hidden (shadowed) for that
# specific instance. Other instances and the class itself
# continue to see the original class attribute.
Exercise 5.
Explain how self gets passed automatically when you call dog.bark(). Using what you know about attribute lookup and method binding, describe the steps Python takes from dog.bark() to executing the bark function with dog as the first argument. Verify your explanation by calling Dog.bark(dog) directly and showing it produces the same result.
Solution to Exercise 5
class Dog:
def __init__(self, name):
self.name = name
def bark(self):
return f"{self.name} says woof!"
dog = Dog("Rex")
# Normal call — self is passed automatically
print(dog.bark()) # Rex says woof!
# Manual call — equivalent to what Python does internally
print(Dog.bark(dog)) # Rex says woof!
# What happens step by step:
# 1. Python looks up 'bark' in dog.__dict__ — not found
# 2. Python looks up 'bark' in type(dog).__dict__ (Dog.__dict__) — found
# 3. Dog.bark is a function, so the descriptor protocol kicks in:
# Dog.bark.__get__(dog, Dog) → bound method
# 4. The bound method wraps the function with dog as the first argument
# 5. Calling the bound method passes dog as 'self'
#
# This is why dog.bark() and Dog.bark(dog) produce the same result.
# 'self' is not magic — it is the result of method binding through
# the descriptor protocol.
# Verify they are the same
assert dog.bark() == Dog.bark(dog)