Skip to main content
Advertisement

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

ConceptDescriptionAccess
Class variableShared by all instancesClassName.var or self.var
Instance variableData unique to each instanceself.var
__init__Object initialization methodCalled automatically on instance creation
selfReference to the instance itselfFirst parameter of all instance methods
__dict__Instance/class attribute dictionaryobj.__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.

Advertisement