Skip to main content

05.1 - Classes & Objects

Theory 30 min Intermediate

What is Object-Oriented Programming?

OOP organizes code around objects — entities that bundle data (attributes) and behavior (methods) together.

The 4 pillars of OOP:

PillarDefinition
EncapsulationBundle data and methods; hide internal state
InheritanceCreate new classes based on existing ones
PolymorphismDifferent classes respond to the same interface
AbstractionExpose only what is necessary

Defining a Class

class BankAccount:
"""Represents a bank account with balance management."""

# Class variable — shared by ALL instances
bank_name = "Python National Bank"
_account_count = 0

def __init__(self, owner: str, initial_balance: float = 0.0):
"""Initialize the account."""
# Instance variables — unique to each instance
self.owner = owner
self._balance = initial_balance # "private" by convention
self._transactions = []

BankAccount._account_count += 1

def deposit(self, amount: float) -> float:
"""Add money to the account."""
if amount <= 0:
raise ValueError("Deposit amount must be positive")
self._balance += amount
self._transactions.append(f"+{amount:.2f}")
return self._balance

def withdraw(self, amount: float) -> float:
"""Remove money from the account."""
if amount <= 0:
raise ValueError("Withdrawal must be positive")
if amount > self._balance:
raise ValueError(f"Insufficient funds (balance: {self._balance:.2f})")
self._balance -= amount
self._transactions.append(f"-{amount:.2f}")
return self._balance

def get_balance(self) -> float:
"""Return current balance."""
return self._balance

def get_history(self) -> list:
"""Return transaction history."""
return list(self._transactions)

@classmethod
def get_account_count(cls) -> int:
"""Return total number of accounts created."""
return cls._account_count

@staticmethod
def is_valid_amount(amount) -> bool:
"""Check if an amount is a valid positive number."""
return isinstance(amount, (int, float)) and amount > 0

def __repr__(self) -> str:
return f"BankAccount(owner='{self.owner}', balance={self._balance:.2f})"

def __str__(self) -> str:
return f"{self.owner}'s account — ${self._balance:,.2f}"

Creating and Using Objects

# Creating instances
alice_account = BankAccount("Alice", 1000.0)
bob_account = BankAccount("Bob")

# Using methods
alice_account.deposit(500)
alice_account.withdraw(200)
print(alice_account) # Alice's account — $1,300.00
print(repr(alice_account)) # BankAccount(owner='Alice', balance=1300.00)

# Class method
BankAccount.get_account_count() # 2

# Static method
BankAccount.is_valid_amount(100) # True
BankAccount.is_valid_amount(-5) # False

Instance vs Class vs Static Methods

┌─────────────────────────────────────────────────────────────┐
│ Method Types │
│ │
│ Instance method: def f(self, ...) → accesses instance │
│ Class method: @classmethod │
│ def f(cls, ...) → accesses class │
│ Static method: @staticmethod │
│ def f(...) → no self or cls │
└─────────────────────────────────────────────────────────────┘
class Temperature:
def __init__(self, celsius):
self.celsius = celsius

# Instance method
def to_fahrenheit(self):
return (self.celsius * 9/5) + 32

# Class method — alternative constructor
@classmethod
def from_fahrenheit(cls, fahrenheit):
return cls((fahrenheit - 32) * 5/9)

# Static method — utility, no state needed
@staticmethod
def is_freezing(celsius):
return celsius <= 0


t = Temperature(100)
t.to_fahrenheit() # 212.0

t2 = Temperature.from_fahrenheit(32)
t2.celsius # 0.0

Temperature.is_freezing(-5) # True

Properties: Controlled Attribute Access

@property creates a getter; @x.setter creates a setter with validation:

class Circle:
def __init__(self, radius):
self._radius = radius # store in private attribute

@property
def radius(self):
"""Get radius (always positive)."""
return self._radius

@radius.setter
def radius(self, value):
"""Set radius with validation."""
if value < 0:
raise ValueError("Radius cannot be negative")
self._radius = value

@property
def area(self):
"""Computed property — no setter needed."""
import math
return math.pi * self._radius ** 2

@property
def diameter(self):
return self._radius * 2


c = Circle(5)
c.radius # 5
c.area # 78.54...
c.radius = 10 # uses setter
c.radius = -1 # ValueError!

Dataclasses (Python 3.7+)

For simple data-holding classes, @dataclass eliminates boilerplate:

from dataclasses import dataclass, field
from typing import ClassVar

@dataclass
class Product:
name: str
price: float
quantity: int = 0
tags: list = field(default_factory=list)
_discount: float = field(default=0.0, repr=False)

# Class variable
currency: ClassVar[str] = "CAD"

@property
def total_value(self):
return self.price * self.quantity

def apply_discount(self, percent):
self._discount = percent / 100
self.price *= (1 - self._discount)


p = Product("Laptop", 999.99, 5)
print(p) # Product(name='Laptop', price=999.99, quantity=5, tags=[])
p.total_value # 4999.95

Key Vocabulary

TermDefinition
ClassBlueprint for creating objects
Object / InstanceA specific realization of a class
__init__Constructor — called when creating an instance
selfReference to the current instance
Instance variableAttribute unique to each object: self.x
Class variableAttribute shared by all instances
@classmethodMethod that receives the class (cls) as first argument
@staticmethodMethod with no access to instance or class
@propertyTurns a method into a read-only attribute accessor
@dataclassDecorator that auto-generates __init__, __repr__, __eq__

Summary

  • Classes are blueprints; objects are instances created from them
  • __init__ sets up instance state using self.attribute = value
  • Instance methods take self; class methods take cls; static methods take neither
  • Use _name for protected and __name for name-mangled (private) attributes
  • @property provides controlled access to attributes with validation
  • @dataclass auto-generates boilerplate for data-holding classes