Object-Oriented Programming#

Object-Oriented Programming (OOP) is a programming paradigm that packages data and the behaviors that operate on that data into independent units called “objects.”

A real-world analogy:

  • A class is like a “car blueprint” — it defines what parts (attributes) and capabilities (methods) a car has
  • An object is “an actual car built from the blueprint” — each car can have a different color and mileage

The four core OOP concepts:

ConceptDescription
EncapsulationBundle data and methods together; hide internal details
InheritanceA subclass inherits attributes and methods from a parent class and can extend them
PolymorphismObjects of different classes can be used through the same interface
AbstractionExpose only the necessary interface; hide implementation details

Defining classes and creating objects#

Use the class keyword to define a class. __init__ is the initializer (constructor), called automatically when an object is created:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Dog:
    def __init__(self, name, age):
        # self represents the object itself
        # self.name is an "instance attribute" — each object has its own copy
        self.name = name
        self.age = age

    def bark(self):
        print(f"{self.name} says: Woof!")

    def info(self):
        print(f"{self.name}, {self.age} years old")


# Create objects (instantiation)
dog1 = Dog("Snowball", 3)
dog2 = Dog("Blackbean", 5)

dog1.bark()       # Snowball says: Woof!
dog2.info()       # Blackbean, 5 years old

# Access attributes directly
print(dog1.name)  # Snowball
print(dog2.age)   # 5

What is self?#

self represents “this object itself” and is the first parameter of every instance method. Python passes it in automatically when you call a method — you don’t need to supply it manually:

1
2
3
dog1.bark()
# Equivalent to:
Dog.bark(dog1)

Class attributes vs instance attributes#

  • Instance attributes: each object has its own copy, defined with self.attribute_name inside __init__
  • Class attributes: shared by all objects, defined directly inside the class body
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Car:
    # Class attributes (shared by all Car objects)
    total_cars = 0
    wheels = 4

    def __init__(self, brand, color):
        # Instance attributes (each car is independent)
        self.brand = brand
        self.color = color
        Car.total_cars += 1   # increment the counter each time a car is created

    def info(self):
        print(f"{self.color} {self.brand}, {self.wheels} wheels")


car1 = Car("Toyota", "Red")
car2 = Car("BMW", "Black")

car1.info()              # Red Toyota, 4 wheels
car2.info()              # Black BMW, 4 wheels
print(Car.total_cars)    # 2 (accessed via the class)
print(car1.total_cars)   # 2 (also accessible via an instance)

Class methods and static methods#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Circle:
    pi = 3.14159

    def __init__(self, radius):
        self.radius = radius

    # Regular instance method: works with instance attributes; first parameter is self
    def area(self):
        return Circle.pi * self.radius ** 2

    # Class method (@classmethod): works with class attributes; first parameter is cls
    @classmethod
    def from_diameter(cls, diameter):
        """Create a circle from diameter (alternative constructor)"""
        return cls(diameter / 2)

    # Static method (@staticmethod): needs neither self nor cls; logically belongs to the class
    @staticmethod
    def is_valid_radius(r):
        return r > 0


c1 = Circle(5)
c2 = Circle.from_diameter(10)  # diameter 10, equivalent to Circle(5)

print(c1.area())                    # 78.53975
print(c2.radius)                    # 5.0
print(Circle.is_valid_radius(-1))   # False

Encapsulation#

Encapsulation protects data by preventing external code from directly modifying an object’s internal state in ways that would produce invalid values.

Python uses naming conventions to signal access levels:

NotationMeaning
self.namePublic attribute — accessible anywhere
self._nameProtected attribute (convention) — signals “not recommended for direct external access”
self.__namePrivate attribute — Python renames it to _ClassName__name, making it hard to access from outside
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class BankAccount:
    def __init__(self, owner, balance):
        self.owner = owner         # public
        self._balance = balance    # protected (not recommended to modify directly from outside)

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit amount must be greater than 0")
        self._balance += amount
        print(f"Deposited {amount:,}, balance: {self._balance:,}")

    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("Withdrawal amount must be greater than 0")
        if amount > self._balance:
            raise ValueError("Insufficient funds")
        self._balance -= amount
        print(f"Withdrew {amount:,}, balance: {self._balance:,}")

    def get_balance(self):
        return self._balance


acc = BankAccount("Alice", 10000)
acc.deposit(5000)      # Deposited 5,000, balance: 15,000
acc.withdraw(3000)     # Withdrew 3,000, balance: 12,000
print(acc.get_balance())  # 12000

@property: natural-looking attribute access with validation#

@property lets you access a value like an attribute while running validation logic behind the scenes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age   # this triggers the age setter

    @property
    def age(self):
        """getter: called when reading age"""
        return self._age

    @age.setter
    def age(self, value):
        """setter: called when setting age; adds validation"""
        if not isinstance(value, int):
            raise TypeError("Age must be an integer")
        if value < 0 or value > 150:
            raise ValueError(f"Age {value} is unreasonable")
        self._age = value


p = Person("Alice", 25)
print(p.age)   # 25 (via getter, written just like a regular attribute)
p.age = 26     # via setter
print(p.age)   # 26

try:
    p.age = -5
except ValueError as e:
    print(e)   # Age -5 is unreasonable

Inheritance#

Inheritance lets a subclass directly acquire the attributes and methods of a parent class, then override or extend them for its own needs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class Animal:
    def __init__(self, name, sound):
        self.name = name
        self.sound = sound

    def speak(self):
        print(f"{self.name} says: {self.sound}!")

    def __str__(self):
        return f"{type(self).__name__} ({self.name})"


class Dog(Animal):
    def __init__(self, name):
        super().__init__(name, "Woof")  # call the parent __init__

    # Override the parent method
    def speak(self):
        print(f"{self.name} enthusiastically says: {self.sound}!!")

    # Method added by the subclass
    def fetch(self):
        print(f"{self.name} goes to fetch the ball!")


class Cat(Animal):
    def __init__(self, name):
        super().__init__(name, "Meow")

    def speak(self):
        print(f"{self.name} lazily says: {self.sound}~")

    def purr(self):
        print(f"{self.name} purrs...")


dog = Dog("Snowball")
cat = Cat("Mimi")

dog.speak()   # Snowball enthusiastically says: Woof!!
cat.speak()   # Mimi lazily says: Meow~
dog.fetch()   # Snowball goes to fetch the ball!
cat.purr()    # Mimi purrs...

print(dog)    # Dog (Snowball)
print(cat)    # Cat (Mimi)

Using super()#

super() calls a method from the parent class. It is most commonly used in __init__ to ensure the parent’s initialization logic also runs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Vehicle:
    def __init__(self, brand, speed):
        self.brand = brand
        self.speed = speed

    def info(self):
        print(f"{self.brand}, top speed {self.speed} km/h")


class Car(Vehicle):
    def __init__(self, brand, speed, doors):
        super().__init__(brand, speed)   # run parent initialization first
        self.doors = doors               # then set subclass-specific attributes

    def info(self):
        super().info()                   # call parent info()
        print(f"Number of doors: {self.doors}")


class ElectricCar(Car):
    def __init__(self, brand, speed, doors, battery):
        super().__init__(brand, speed, doors)
        self.battery = battery

    def info(self):
        super().info()
        print(f"Battery capacity: {self.battery} kWh")


ev = ElectricCar("Tesla", 250, 4, 100)
ev.info()
# Tesla, top speed 250 km/h
# Number of doors: 4
# Battery capacity: 100 kWh

Type checking#

1
2
3
4
5
6
print(isinstance(dog, Dog))     # True
print(isinstance(dog, Animal))  # True (Dog inherits from Animal)
print(isinstance(dog, Cat))     # False

print(issubclass(Dog, Animal))  # True
print(issubclass(Cat, Animal))  # True

Polymorphism#

Polymorphism means: objects of different classes can be called in the same way, and each runs its own version.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import math

class Shape:
    def area(self):
        raise NotImplementedError("Subclasses must implement area()")

    def describe(self):
        print(f"{self.__class__.__name__}, area = {self.area():.2f}")


class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return math.pi * self.radius ** 2


class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height


class Triangle(Shape):
    def __init__(self, base, height):
        self.base = base
        self.height = height

    def area(self):
        return 0.5 * self.base * self.height


# Objects of different classes, operated through the same interface
shapes = [Circle(5), Rectangle(4, 6), Triangle(3, 8)]

for shape in shapes:
    shape.describe()   # each object calls its own area()

# Circle, area = 78.54
# Rectangle, area = 24.00
# Triangle, area = 12.00

# Calculate total area
total = sum(s.area() for s in shapes)
print(f"Total area: {total:.2f}")   # Total area: 114.54

Magic methods (dunder methods)#

Python’s magic methods (also called dunder methods, for the double underscores on each side) let your classes support Python’s built-in syntax:

MethodTriggered whenExample
__init__Creating an objectDog("Snowball", 3)
__str__print(obj) or str(obj)print(dog)
__repr__Interactive mode display, repr(obj)dog
__len__len(obj)len(bag)
__eq__obj1 == obj2v1 == v2
__lt__obj1 < obj2v1 < v2
__add__obj1 + obj2v1 + v2
__contains__x in objitem in bag
__getitem__obj[key]bag[0]
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __str__(self):
        return f"({self.x}, {self.y})"

    def __repr__(self):
        return f"Vector({self.x}, {self.y})"

    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)

    def __sub__(self, other):
        return Vector(self.x - other.x, self.y - other.y)

    def __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)

    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

    def __len__(self):
        # Return the vector's magnitude (as integer)
        return int((self.x ** 2 + self.y ** 2) ** 0.5)


v1 = Vector(1, 2)
v2 = Vector(3, 4)

print(v1 + v2)        # (4, 6)
print(v2 - v1)        # (2, 2)
print(v1 * 3)         # (3, 6)
print(v1 == v2)       # False
print(v1 == Vector(1, 2))  # True
print(len(v2))        # 5 (3² + 4² = 25, √25 = 5)

Practical example: TradingAccount#

A comprehensive example combining classes, encapsulation, inheritance, and @property:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
class TradingAccount:
    """Basic trading account"""

    def __init__(self, owner, cash):
        self.owner = owner
        self._cash = cash
        self._holdings = {}   # {ticker: number of shares held}
        self._trades = []     # trade history

    @property
    def cash(self):
        return self._cash

    @property
    def holdings(self):
        return dict(self._holdings)   # return a copy to prevent direct external modification

    def buy(self, symbol, shares, price):
        cost = shares * price
        if cost > self._cash:
            raise ValueError(f"Insufficient cash: need {cost:,}, have {self._cash:,}")
        self._cash -= cost
        self._holdings[symbol] = self._holdings.get(symbol, 0) + shares
        self._trades.append(("Buy", symbol, shares, price))
        print(f"Bought {symbol} {shares} shares @ {price}, cash remaining: {self._cash:,.0f}")

    def sell(self, symbol, shares, price):
        if self._holdings.get(symbol, 0) < shares:
            raise ValueError(f"Insufficient {symbol} shares")
        self._cash += shares * price
        self._holdings[symbol] -= shares
        if self._holdings[symbol] == 0:
            del self._holdings[symbol]
        self._trades.append(("Sell", symbol, shares, price))
        print(f"Sold {symbol} {shares} shares @ {price}, cash remaining: {self._cash:,.0f}")

    def portfolio(self, current_prices):
        total = self._cash
        print(f"\n===== {self.owner}'s Portfolio =====")
        print(f"Cash: {self._cash:>12,.0f}")
        for symbol, shares in self._holdings.items():
            price = current_prices.get(symbol, 0)
            value = shares * price
            total += value
            print(f"{symbol}: {shares} shares × {price:,} = {value:>10,.0f}")
        print(f"{'─' * 30}")
        print(f"Total assets: {total:>10,.0f}")

    def __str__(self):
        return f"TradingAccount({self.owner}, cash={self._cash:,.0f})"


class MarginAccount(TradingAccount):
    """Margin account (inherits from TradingAccount, supports leverage)"""

    def __init__(self, owner, cash, leverage=2):
        super().__init__(owner, cash)
        self.leverage = leverage   # leverage multiplier

    @property
    def buying_power(self):
        """Buying power = cash × leverage"""
        return self._cash * self.leverage

    def buy(self, symbol, shares, price):
        cost = shares * price
        if cost > self.buying_power:
            raise ValueError(f"Exceeds buying power limit: {self.buying_power:,}")
        # Accessing parent's private attribute must go through methods
        # Simplified here: deduct only the actual cash proportion
        actual_cost = cost / self.leverage
        self._cash -= actual_cost
        self._holdings[symbol] = self._holdings.get(symbol, 0) + shares
        self._trades.append(("Buy (Margin)", symbol, shares, price))
        print(f"Margin buy {symbol} {shares} shares @ {price}, buying power remaining: {self.buying_power:,.0f}")


# Regular account
acc = TradingAccount("Alice", 100_000)
acc.buy("2330", 10, 950)
acc.buy("0050", 20, 150)
acc.sell("2330", 5, 1_000)

current_prices = {"2330": 1_000, "0050": 155}
acc.portfolio(current_prices)

print()

# Margin account
margin = MarginAccount("Bob", 50_000, leverage=2)
print(f"Buying power: {margin.buying_power:,}")   # 100,000
margin.buy("2330", 5, 950)

Output:

Bought 2330 10 shares @ 950, cash remaining: 90,500
Bought 0050 20 shares @ 150, cash remaining: 87,500
Sold 2330 5 shares @ 1000, cash remaining: 92,500

===== Alice's Portfolio =====
Cash:         92,500
2330: 5 shares × 1,000 =      5,000
0050: 20 shares × 155 =      3,100
──────────────────────────────
Total assets:    100,600

Buying power: 100,000
Margin buy 2330 5 shares @ 950, buying power remaining: 95,250