Object-Oriented Programming Basics

Object-Oriented Programming Basics

Note: This lesson is for prerequisite knowledge review. If you lack OOP fundamentals before starting advanced lessons (decorators, metaclasses, etc.), study this content first.

Learning Objectives

  • Understand the concepts of classes and objects (instances)
  • Utilize constructors, instance/class variables, and methods
  • Grasp the principles of inheritance, polymorphism, and encapsulation
  • Use basic special methods (magic methods)

1. Classes and Objects

1.1 Basic Concepts

┌─────────────────────────────────────────────────────────────────┐
                Object-Oriented Programming (OOP)                
├─────────────────────────────────────────────────────────────────┤
                                                                 
  Class:                                                         
  - Blueprint (design) for objects                               
  - Defines attributes (variables) and behaviors (methods)       
                                                                 
  Object / Instance:                                             
  - Concrete entity created from a class                         
  - Each object has independent state (data)                     
                                                                 
  Example:                                                       
  ┌─────────────┐         ┌─────────────┐                       
   class Dog    ──────▶  my_dog        # Object 1           │
    name                 name="Max"                         
    age                  age=3                              
    bark()              └─────────────┘                       
    eat()                                                      
  └─────────────┘ ──────▶ ┌─────────────┐                       
                           your_dog      # Object 2           │
                           name="Bella"                       
                           age=5                              
                          └─────────────┘                       
                                                                 
└─────────────────────────────────────────────────────────────────┘

1.2 Class Definition

class Dog:
    """Dog class"""

    # Class variable (shared by all instances)
    species = "Canis familiaris"

    # Constructor (initialization method)
    def __init__(self, name, age):
        """
        Args:
            name: Dog's name
            age: Dog's age
        """
        # Instance variables (independent for each object)
        self.name = name
        self.age = age

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

    def describe(self):
        """Description"""
        return f"{self.name} is {self.age} years old"

    def birthday(self):
        """Birthday"""
        self.age += 1
        print(f"Happy birthday, {self.name}! Now {self.age} years old.")


# Object creation
my_dog = Dog("Max", 3)
your_dog = Dog("Bella", 5)

# Attribute access
print(my_dog.name)     # Max
print(my_dog.species)  # Canis familiaris

# Method calls
my_dog.bark()           # Max says: Woof!
print(my_dog.describe()) # Max is 3 years old
my_dog.birthday()        # Happy birthday, Max! Now 4 years old.

# Class variable vs Instance variable
print(Dog.species)       # Access via class
print(my_dog.species)    # Access via instance (same value)

1.3 Meaning of self

class Circle:
    def __init__(self, radius):
        # self = refers to the current instance being created
        self.radius = radius

    def area(self):
        # self.radius = this instance's radius
        return 3.14159 * self.radius ** 2

    def circumference(self):
        return 2 * 3.14159 * self.radius


# When creating objects
c1 = Circle(5)   # self = c1
c2 = Circle(10)  # self = c2

# When calling methods
print(c1.area())  # self = c1, self.radius = 5
print(c2.area())  # self = c2, self.radius = 10

# Actually works like this:
# Circle.area(c1) → self = c1

2. Class/Instance Variables and Methods

2.1 Variable Types

class Counter:
    # Class variable: shared by all instances
    total_count = 0

    def __init__(self, name):
        # Instance variable: independent for each object
        self.name = name
        self.count = 0

        # Modify class variable
        Counter.total_count += 1

    def increment(self):
        self.count += 1

    def get_count(self):
        return self.count


c1 = Counter("Counter 1")
c2 = Counter("Counter 2")

c1.increment()
c1.increment()
c2.increment()

print(c1.count)  # 2 (instance variable)
print(c2.count)  # 1 (instance variable)
print(Counter.total_count)  # 2 (class variable)

# Warning: assigning to class variable via instance creates instance variable
c1.total_count = 100  # creates instance variable for c1 only
print(c1.total_count)       # 100 (instance)
print(Counter.total_count)  # 2 (class variable unchanged)

2.2 Method Types

class MyClass:
    class_var = 0

    def __init__(self, value):
        self.instance_var = value

    # Instance method: uses self, accesses instance data
    def instance_method(self):
        return f"Instance: {self.instance_var}"

    # Class method: uses cls, accesses class data
    @classmethod
    def class_method(cls):
        cls.class_var += 1
        return f"Class var: {cls.class_var}"

    # Static method: no self or cls, independent function
    @staticmethod
    def static_method(x, y):
        return x + y


obj = MyClass(42)

# Instance method
print(obj.instance_method())  # Instance: 42

# Class method (callable via class or instance)
print(MyClass.class_method())  # Class var: 1
print(obj.class_method())      # Class var: 2

# Static method (callable via class or instance)
print(MyClass.static_method(3, 4))  # 7
print(obj.static_method(3, 4))      # 7

2.3 Factory Method Pattern

class Date:
    def __init__(self, year, month, day):
        self.year = year
        self.month = month
        self.day = day

    def __repr__(self):
        return f"Date({self.year}, {self.month}, {self.day})"

    # Factory method: create objects from various formats
    @classmethod
    def from_string(cls, date_string):
        """Create from 'YYYY-MM-DD' format"""
        year, month, day = map(int, date_string.split("-"))
        return cls(year, month, day)

    @classmethod
    def today(cls):
        """Create from today's date"""
        import datetime
        t = datetime.date.today()
        return cls(t.year, t.month, t.day)


# Create objects in various ways
d1 = Date(2024, 1, 15)
d2 = Date.from_string("2024-06-20")
d3 = Date.today()

print(d1)  # Date(2024, 1, 15)
print(d2)  # Date(2024, 6, 20)
print(d3)  # Date(current date)

3. Inheritance

3.1 Basic Inheritance

# Parent class (superclass, base class)
class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        raise NotImplementedError("Subclass must implement")

    def describe(self):
        return f"I am {self.name}"


# Child class (subclass, derived class)
class Dog(Animal):
    def __init__(self, name, breed):
        # Initialize parent class
        super().__init__(name)
        self.breed = breed

    def speak(self):
        return f"{self.name} says Woof!"

    def fetch(self):
        return f"{self.name} is fetching the ball"


class Cat(Animal):
    def speak(self):
        return f"{self.name} says Meow!"

    def scratch(self):
        return f"{self.name} is scratching"


# Usage
dog = Dog("Max", "Golden Retriever")
cat = Cat("Whiskers")

print(dog.describe())  # I am Max (parent method)
print(dog.speak())     # Max says Woof! (overridden)
print(dog.fetch())     # Max is fetching the ball (child-specific)
print(dog.breed)       # Golden Retriever

print(cat.speak())     # Whiskers says Meow!

# Check inheritance relationships
print(isinstance(dog, Dog))     # True
print(isinstance(dog, Animal))  # True
print(issubclass(Dog, Animal))  # True

3.2 Method Overriding

class Vehicle:
    def __init__(self, brand):
        self.brand = brand

    def info(self):
        return f"Brand: {self.brand}"

    def move(self):
        return "Moving..."


class Car(Vehicle):
    def __init__(self, brand, model):
        super().__init__(brand)
        self.model = model

    # Method overriding (redefinition)
    def info(self):
        # Call parent method + extend
        parent_info = super().info()
        return f"{parent_info}, Model: {self.model}"

    def move(self):
        # Completely new implementation
        return "Driving on the road"

    def honk(self):
        return "Beep beep!"


class Motorcycle(Vehicle):
    def move(self):
        return "Riding on two wheels"


car = Car("Toyota", "Camry")
print(car.info())  # Brand: Toyota, Model: Camry
print(car.move())  # Driving on the road

moto = Motorcycle("Harley")
print(moto.move())  # Riding on two wheels

3.3 Multiple Inheritance

class Flyable:
    def fly(self):
        return "Flying in the sky"


class Swimmable:
    def swim(self):
        return "Swimming in the water"


class Duck(Animal, Flyable, Swimmable):
    def speak(self):
        return f"{self.name} says Quack!"


duck = Duck("Donald")
print(duck.describe())  # I am Donald (Animal)
print(duck.fly())       # Flying in the sky (Flyable)
print(duck.swim())      # Swimming in the water (Swimmable)
print(duck.speak())     # Donald says Quack!

# Check MRO (Method Resolution Order)
print(Duck.__mro__)
# (<class 'Duck'>, <class 'Animal'>, <class 'Flyable'>, <class 'Swimmable'>, <class 'object'>)

4. Encapsulation

4.1 Access Control

class BankAccount:
    def __init__(self, owner, balance):
        self.owner = owner          # public
        self._balance = balance     # protected (convention)
        self.__pin = "1234"         # private (name mangling)

    def deposit(self, amount):
        if amount > 0:
            self._balance += amount
            return True
        return False

    def withdraw(self, amount, pin):
        if pin != self.__pin:
            raise ValueError("Invalid PIN")
        if amount > self._balance:
            raise ValueError("Insufficient funds")
        self._balance -= amount
        return amount

    def get_balance(self):
        return self._balance


account = BankAccount("Alice", 1000)

# public access
print(account.owner)  # Alice

# protected access (possible but not recommended)
print(account._balance)  # 1000

# private access (not directly accessible)
# print(account.__pin)  # AttributeError

# Access via name mangling (not recommended)
print(account._BankAccount__pin)  # 1234

# Safe access through methods
account.deposit(500)
print(account.get_balance())  # 1500

4.2 Property

class Temperature:
    def __init__(self, celsius=0):
        self._celsius = celsius

    # getter
    @property
    def celsius(self):
        return self._celsius

    # setter
    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Temperature below absolute zero")
        self._celsius = value

    # Computed property
    @property
    def fahrenheit(self):
        return self._celsius * 9/5 + 32

    @fahrenheit.setter
    def fahrenheit(self, value):
        self._celsius = (value - 32) * 5/9


temp = Temperature()

# Use property (like an attribute)
temp.celsius = 25
print(temp.celsius)      # 25
print(temp.fahrenheit)   # 77.0

temp.fahrenheit = 100
print(temp.celsius)      # 37.777...

# Validation works
# temp.celsius = -300  # ValueError

5. Polymorphism

5.1 Method Polymorphism

class Shape:
    def area(self):
        raise NotImplementedError


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

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


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

    def area(self):
        return 3.14159 * self.radius ** 2


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

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


# Polymorphism: same method name, different behavior
def print_area(shape: Shape):
    print(f"Area: {shape.area()}")


shapes = [
    Rectangle(4, 5),
    Circle(3),
    Triangle(6, 8)
]

for shape in shapes:
    print_area(shape)

# Output:
# Area: 20
# Area: 28.27431
# Area: 24.0

5.2 Duck Typing

# "If it walks like a duck and quacks like a duck, it's a duck"
# Python focuses on what methods/attributes an object has rather than its type

class Dog:
    def speak(self):
        return "Woof!"

class Cat:
    def speak(self):
        return "Meow!"

class Robot:
    def speak(self):
        return "Beep boop!"

# No inheritance relationship needed - same method works the same
def animal_sound(animal):
    print(animal.speak())

# All have speak() method so they work
animal_sound(Dog())    # Woof!
animal_sound(Cat())    # Meow!
animal_sound(Robot())  # Beep boop!

6. Special Methods (Magic Methods)

6.1 Basic Special Methods

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    # String representation (for users)
    def __str__(self):
        return f"Point({self.x}, {self.y})"

    # String representation (for developers, debugging)
    def __repr__(self):
        return f"Point(x={self.x}, y={self.y})"

    # Equality comparison
    def __eq__(self, other):
        if isinstance(other, Point):
            return self.x == other.x and self.y == other.y
        return False

    # Hash (can be used as dict key, set element)
    def __hash__(self):
        return hash((self.x, self.y))

    # Length
    def __len__(self):
        return 2  # two coordinates: x, y

    # Boolean conversion
    def __bool__(self):
        return self.x != 0 or self.y != 0


p1 = Point(3, 4)
p2 = Point(3, 4)
p3 = Point(0, 0)

print(str(p1))   # Point(3, 4)
print(repr(p1))  # Point(x=3, y=4)
print(p1 == p2)  # True
print(len(p1))   # 2
print(bool(p1))  # True
print(bool(p3))  # False

6.2 Operator Overloading

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y

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

    # Addition: v1 + v2
    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)

    # Subtraction: v1 - v2
    def __sub__(self, other):
        return Vector(self.x - other.x, self.y - other.y)

    # Multiplication (scalar): v * 3
    def __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)

    # Reverse multiplication: 3 * v
    def __rmul__(self, scalar):
        return self.__mul__(scalar)

    # Dot product: v1 @ v2
    def __matmul__(self, other):
        return self.x * other.x + self.y * other.y

    # Negation: -v
    def __neg__(self):
        return Vector(-self.x, -self.y)

    # Absolute value (magnitude)
    def __abs__(self):
        return (self.x ** 2 + self.y ** 2) ** 0.5


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

print(v1 + v2)     # Vector(4, 6)
print(v1 - v2)     # Vector(2, 2)
print(v1 * 2)      # Vector(6, 8)
print(3 * v1)      # Vector(9, 12)
print(v1 @ v2)     # 11 (dot product)
print(-v1)         # Vector(-3, -4)
print(abs(v1))     # 5.0

6.3 Container Protocol

class Deck:
    """Card deck class"""

    def __init__(self):
        suits = ['Hearts', 'Diamonds', 'Clubs', 'Spades']
        ranks = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A']
        self.cards = [f"{rank} of {suit}" for suit in suits for rank in ranks]

    # Length
    def __len__(self):
        return len(self.cards)

    # Indexing: deck[0]
    def __getitem__(self, index):
        return self.cards[index]

    # Assignment: deck[0] = "Joker"
    def __setitem__(self, index, value):
        self.cards[index] = value

    # Deletion: del deck[0]
    def __delitem__(self, index):
        del self.cards[index]

    # Membership: "Ace of Spades" in deck
    def __contains__(self, item):
        return item in self.cards

    # Iteration
    def __iter__(self):
        return iter(self.cards)


deck = Deck()

print(len(deck))          # 52
print(deck[0])            # 2 of Hearts
print(deck[-1])           # A of Spades
print("A of Spades" in deck)  # True

# Slicing automatically supported
print(deck[0:3])  # ['2 of Hearts', '3 of Hearts', '4 of Hearts']

# Iteration
for card in deck[:5]:
    print(card)

7. Abstract Classes

from abc import ABC, abstractmethod

class Shape(ABC):
    """Abstract base class"""

    @abstractmethod
    def area(self):
        """Calculate area (must be implemented)"""
        pass

    @abstractmethod
    def perimeter(self):
        """Calculate perimeter (must be implemented)"""
        pass

    def describe(self):
        """Regular method (inherited)"""
        return f"Area: {self.area()}, Perimeter: {self.perimeter()}"


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

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

    def perimeter(self):
        return 2 * (self.width + self.height)


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

    def area(self):
        return 3.14159 * self.radius ** 2

    def perimeter(self):
        return 2 * 3.14159 * self.radius


# Abstract class cannot be instantiated
# shape = Shape()  # TypeError

rect = Rectangle(4, 5)
print(rect.describe())  # Area: 20, Perimeter: 18

circle = Circle(3)
print(circle.describe())  # Area: 28.27..., Perimeter: 18.84...

Summary

Core OOP Concepts

Concept Description
Class Blueprint for objects
Object/Instance Concrete entity created from a class
__init__ Constructor, initialization method
self Reference to current instance
Inheritance Inheriting attributes/methods from parent class
Overriding Redefining parent methods
Encapsulation Data protection, access control
Polymorphism Same interface, different behavior
Abstract Class Base class with incomplete methods

Access Control Conventions

Format Meaning Example
name public Freely accessible
_name protected Internal use recommended
__name private Name mangling applied

Next Steps

After completing OOP basics, proceed to: - 02_Decorators.md: Decorators - 06_Metaclasses.md: Metaclasses - 07_Descriptors.md: Descriptors


References

to navigate between lessons