Classes and Instances
Object-Oriented Programming (OOP) is a programming paradigm that bundles data and the functions that process it together into a single unit called an object. Python was designed from the ground up to support OOP, and everything in it is an object. In this chapter, we'll start with the fundamentals of classes and instances to understand the core of Python OOP.
What Is a Class?
A class is a blueprint (template) for creating objects. Think of a fish-shaped waffle mold — the mold itself is the class, and each waffle made from it is an instance (object).
- Class: A template that defines an object's attributes (data) and behaviors (methods)
- Instance: An individual object actually created from a class
- Attribute: Data that an object holds (variables)
- Method: Actions that an object can perform (functions)
Defining a Class with the class Keyword
class Dog:
"""A class representing a dog"""
# Class variable (shared by all instances)
species = "Canis familiaris"
# Initialization method (constructor)
def __init__(self, name: str, age: int):
# Instance variables (unique to each instance)
self.name = name
self.age = age
# Instance method
def bark(self) -> str:
return f"{self.name} barks: Woof woof!"
def describe(self) -> str:
return f"{self.name} is {self.age} years old, species: {self.species}."
# Creating instances
dog1 = Dog("Rex", 3)
dog2 = Dog("Choco", 5)
print(dog1.bark()) # Rex barks: Woof woof!
print(dog2.describe()) # Choco is 5 years old, species: Canis familiaris.
print(dog1.species) # Canis familiaris
print(Dog.species) # Canis familiaris (access directly from the class)
The __init__ Method: Object Initialization
__init__ is the initialization method automatically called when an object is created. It is used to set instance variables.
class Person:
def __init__(self, name: str, age: int, email: str = ""):
self.name = name # Required parameter
self.age = age # Required parameter
self.email = email # Optional parameter with default value
self._history = [] # Internal attribute not set from outside
def introduce(self) -> str:
return f"Hello, I'm {self.name} and I'm {self.age} years old."
# Creating instances in various ways
p1 = Person("Alice", 25)
p2 = Person("Bob", 30, "bob@example.com")
p3 = Person(name="Charlie", age=22, email="charlie@example.com")
print(p1.introduce())
print(p2.email) # bob@example.com
print(p3.name) # Charlie
The self Parameter: Referring to the Instance Itself
self is a reference to the instance on which the method is called. Python automatically passes the calling instance as the first argument when a method is invoked.
class Counter:
def __init__(self):
self.count = 0
def increment(self):
self.count += 1 # self.count is this instance's count attribute
return self # Return self for method chaining
def reset(self):
self.count = 0
return self
def value(self) -> int:
return self.count
c1 = Counter()
c2 = Counter()
# Each instance has independent state
c1.increment().increment().increment()
c2.increment()
print(c1.value()) # 3
print(c2.value()) # 1
# How self works — calling an unbound method directly
Counter.increment(c1)
print(c1.value()) # 4 (unbound method call also works)
Instance Variables vs Class Variables
Understanding this distinction is critical. Confusion here leads to subtle bugs.
class Student:
# Class variable: shared by all instances
school = "Python High School"
total_students = 0
def __init__(self, name: str, grade: int):
# Instance variables: independent for each instance
self.name = name
self.grade = grade
Student.total_students += 1 # Modifying the class variable
@classmethod
def get_total(cls) -> int:
return cls.total_students
s1 = Student("Alice", 10)
s2 = Student("Bob", 11)
s3 = Student("Charlie", 10)
print(Student.total_students) # 3
print(Student.get_total()) # 3
# Class variables are accessible from all instances
print(s1.school) # Python High School
print(s2.school) # Python High School
The Pitfall: Instance Variable Shadowing a Class Variable
class Config:
debug = False # Class variable
def __init__(self, name: str):
self.name = name
cfg1 = Config("app1")
cfg2 = Config("app2")
# Changing the class variable → affects all instances
Config.debug = True
print(cfg1.debug) # True
print(cfg2.debug) # True
# "Modifying" a class variable via an instance creates an instance variable!
cfg1.debug = False # Creates an instance variable debug on cfg1 only
print(cfg1.debug) # False (instance variable)
print(cfg2.debug) # True (still the class variable)
print(Config.debug) # True (class variable unchanged)
# Lookup order: instance variable → class variable
print(cfg1.__dict__) # {'name': 'app1', 'debug': False}
print(Config.__dict__) # {..., 'debug': True, ...}
The Danger of Mutable Class Variables
# Incorrect example ❌
class BadList:
items = [] # Mutable class variable — shared by all instances!
def add(self, item):
self.items.append(item) # Mutates the shared list!
bad1 = BadList()
bad2 = BadList()
bad1.add("apple")
print(bad2.items) # ['apple'] — unintended sharing!
# Correct example ✅
class GoodList:
def __init__(self):
self.items = [] # Declare as instance variable
def add(self, item):
self.items.append(item)
good1 = GoodList()
good2 = GoodList()
good1.add("apple")
print(good2.items) # [] — independent
Inspecting Instance Attributes with __dict__
class Rectangle:
shape_type = "Rectangle" # Class variable
def __init__(self, width: float, height: float):
self.width = width
self.height = height
def area(self) -> float:
return self.width * self.height
rect = Rectangle(5.0, 3.0)
# Check the instance's attribute dictionary
print(rect.__dict__) # {'width': 5.0, 'height': 3.0}
# Check the class's attribute dictionary
print(Rectangle.__dict__)
# {'shape_type': 'Rectangle', '__init__': <function ...>, 'area': <function ...>, ...}
# Using hasattr, getattr, setattr, delattr
print(hasattr(rect, 'width')) # True
print(hasattr(rect, 'color')) # False
print(getattr(rect, 'width')) # 5.0
print(getattr(rect, 'color', 'white')) # 'white' (default value)
setattr(rect, 'color', 'blue')
print(rect.color) # blue
print(rect.__dict__) # {'width': 5.0, 'height': 3.0, 'color': 'blue'}
delattr(rect, 'color')
print(rect.__dict__) # {'width': 5.0, 'height': 3.0}
Practical Example: Bank Account Class
from datetime import datetime
class BankAccount:
"""A class representing a bank account"""
interest_rate = 0.02 # Annual interest rate (class variable)
_account_count = 0 # Total number of accounts (class variable)
def __init__(self, owner: str, initial_balance: float = 0.0):
if initial_balance < 0:
raise ValueError("Initial balance must be non-negative.")
BankAccount._account_count += 1
self.owner = owner
self._balance = initial_balance
self._account_number = f"ACC-{BankAccount._account_count:04d}"
self._transactions: list[dict] = []
self._created_at = datetime.now()
def deposit(self, amount: float) -> None:
if amount <= 0:
raise ValueError("Deposit amount must be positive.")
self._balance += amount
self._record_transaction("Deposit", amount)
print(f"[{self._account_number}] Deposited ${amount:,.2f}. Balance: ${self._balance:,.2f}")
def withdraw(self, amount: float) -> None:
if amount <= 0:
raise ValueError("Withdrawal amount must be positive.")
if amount > self._balance:
raise ValueError(f"Insufficient funds. Current balance: ${self._balance:,.2f}")
self._balance -= amount
self._record_transaction("Withdrawal", amount)
print(f"[{self._account_number}] Withdrew ${amount:,.2f}. Balance: ${self._balance:,.2f}")
def get_balance(self) -> float:
return self._balance
def apply_interest(self) -> None:
interest = self._balance * self.interest_rate
self._balance += interest
self._record_transaction("Interest", interest)
print(f"Interest applied: ${interest:,.2f}. Balance: ${self._balance:,.2f}")
def get_statement(self) -> str:
lines = [
"=== Account Statement ===",
f"Account Number: {self._account_number}",
f"Owner: {self.owner}",
f"Opened: {self._created_at.strftime('%Y-%m-%d')}",
f"Current Balance: ${self._balance:,.2f}",
"Transactions:",
]
for tx in self._transactions:
lines.append(f" {tx['time']} | {tx['type']}: ${tx['amount']:,.2f}")
return "\n".join(lines)
def _record_transaction(self, tx_type: str, amount: float) -> None:
self._transactions.append({
"type": tx_type,
"amount": amount,
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
})
@classmethod
def get_account_count(cls) -> int:
return cls._account_count
def __repr__(self) -> str:
return f"BankAccount(owner={self.owner!r}, balance={self._balance})"
# Usage example
acc1 = BankAccount("Alice", 1_000)
acc2 = BankAccount("Bob", 500)
acc1.deposit(200)
acc1.withdraw(150)
acc1.apply_interest()
print(acc1.get_statement())
print(f"\nTotal accounts: {BankAccount.get_account_count()}")
Practical Example: Student Class
from typing import Optional
class Student:
"""A class for managing student information"""
_grade_scale = {
'A+': 4.5, 'A': 4.0,
'B+': 3.5, 'B': 3.0,
'C+': 2.5, 'C': 2.0,
'D': 1.0, 'F': 0.0,
}
def __init__(self, student_id: str, name: str, major: str):
self.student_id = student_id
self.name = name
self.major = major
self._grades: dict[str, str] = {} # {subject: grade}
def add_grade(self, subject: str, grade: str) -> None:
if grade not in self._grade_scale:
raise ValueError(f"Invalid grade: {grade}")
self._grades[subject] = grade
print(f"{self.name} — {subject}: {grade} recorded")
def calculate_gpa(self) -> Optional[float]:
if not self._grades:
return None
total = sum(self._grade_scale[g] for g in self._grades.values())
return round(total / len(self._grades), 2)
def get_transcript(self) -> str:
gpa = self.calculate_gpa()
lines = [
f"Student ID: {self.student_id}",
f"Name: {self.name}",
f"Major: {self.major}",
"Transcript:",
]
for subject, grade in self._grades.items():
points = self._grade_scale[grade]
lines.append(f" {subject}: {grade} ({points})")
lines.append(f"GPA: {gpa if gpa is not None else 'N/A'}")
return "\n".join(lines)
def __str__(self) -> str:
gpa = self.calculate_gpa()
return f"Student({self.student_id}, {self.name}, GPA={gpa})"
# Usage example
student = Student("2024001", "Alice Kim", "Computer Science")
student.add_grade("Data Structures", "A+")
student.add_grade("Algorithms", "A")
student.add_grade("Databases", "B+")
student.add_grade("Operating Systems", "A")
print(student.get_transcript())
print(f"\nGPA: {student.calculate_gpa()}")
print(str(student))
Expert Tips
1. Validate thoroughly in __init__
class Temperature:
def __init__(self, celsius: float):
if celsius < -273.15:
raise ValueError(f"Temperature below absolute zero ({celsius}°C) is impossible.")
self._celsius = celsius
@property
def celsius(self) -> float:
return self._celsius
@property
def fahrenheit(self) -> float:
return self._celsius * 9/5 + 32
@property
def kelvin(self) -> float:
return self._celsius + 273.15
2. Use vars() to get the attribute dictionary
class Point:
def __init__(self, x: float, y: float, z: float = 0.0):
self.x = x
self.y = y
self.z = z
p = Point(1.0, 2.0, 3.0)
print(vars(p)) # {'x': 1.0, 'y': 2.0, 'z': 3.0}
# Create an instance from a dictionary
data = {"x": 4.0, "y": 5.0, "z": 6.0}
p2 = Point(**data)
print(p2.x, p2.y, p2.z) # 4.0 5.0 6.0
3. Dynamically adding methods after class definition (Monkey Patching)
class Cat:
def __init__(self, name: str):
self.name = name
def meow(self) -> str:
return f"{self.name}: Meow~"
# Dynamically add a method to the class
Cat.meow = meow
c = Cat("Nabi")
print(c.meow()) # Nabi: Meow~
4. Automate subclass registration with __init_subclass__
class Plugin:
_registry: dict[str, type] = {}
def __init_subclass__(cls, plugin_name: str = "", **kwargs):
super().__init_subclass__(**kwargs)
if plugin_name:
Plugin._registry[plugin_name] = cls
@classmethod
def get_plugin(cls, name: str) -> type | None:
return cls._registry.get(name)
class AudioPlugin(Plugin, plugin_name="audio"):
pass
class VideoPlugin(Plugin, plugin_name="video"):
pass
print(Plugin._registry)
# {'audio': <class 'AudioPlugin'>, 'video': <class 'VideoPlugin'>}
print(Plugin.get_plugin("audio")) # <class 'AudioPlugin'>
5. Get dynamic class information with __class__
class Animal:
def describe(self) -> str:
return f"I am an instance of the {self.__class__.__name__} class."
class Dog(Animal):
pass
class Cat(Animal):
pass
d = Dog()
c = Cat()
print(d.describe()) # I am an instance of the Dog class.
print(c.describe()) # I am an instance of the Cat class.
Summary
| Concept | Description | Access |
|---|---|---|
| Class variable | Shared by all instances | ClassName.var or self.var |
| Instance variable | Data unique to each instance | self.var |
__init__ | Object initialization method | Called automatically on instance creation |
self | Reference to the instance itself | First parameter of all instance methods |
__dict__ | Instance/class attribute dictionary | obj.__dict__, Class.__dict__ |
In the next chapter, we'll explore the differences between instance methods, class methods, and static methods, and when to use each one correctly.