Object-Oriented Programming

Introduction

Object-Oriented Programming (OOP) is a paradigm that organizes code around objects - entities that combine data (attributes) and behavior (methods). It's one of the most influential programming paradigms, used in languages from Python to Java to C++.

Learning Objectives

By the end of this reading, you will be able to:

  • Create classes and instantiate objects
  • Define attributes and methods
  • Understand encapsulation, inheritance, and polymorphism
  • Use special methods (dunder methods)
  • Apply OOP design principles

1. Classes and Objects

What is a Class?

A class is a blueprint for creating objects. It defines:

  • Attributes: Data the object holds
  • Methods: Functions the object can perform

A class is like a cookie cutter; objects are the cookies.

What is an Object?

An object is an instance of a class - a concrete realization with actual data.

# Class definition
class Dog:
    pass

# Creating objects (instances)
my_dog = Dog()
your_dog = Dog()

# Each object is independent
print(my_dog)       # <__main__.Dog object at 0x...>
print(your_dog)     # <__main__.Dog object at 0x...> (different address)

2. Defining Classes

Basic Class Structure

class Dog:
    # Class attribute (shared by all instances)
    species = "Canis familiaris"

    # Initializer (constructor)
    def __init__(self, name, age):
        # Instance attributes (unique to each instance)
        self.name = name
        self.age = age

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

    # Another instance method
    def description(self):
        return f"{self.name} is {self.age} years old"

The __init__ Method

Called automatically when creating a new object:

# Creating instances
buddy = Dog("Buddy", 3)
max_dog = Dog("Max", 5)

print(buddy.name)   # "Buddy"
print(max_dog.age)  # 5

The self Parameter

self refers to the instance calling the method:

buddy.bark()    # "Buddy says woof!"
max_dog.bark()  # "Max says woof!"

# Internally, Python translates this to:
# Dog.bark(buddy)
# Dog.bark(max_dog)

Class vs Instance Attributes

class Dog:
    species = "Canis familiaris"  # Class attribute

    def __init__(self, name):
        self.name = name          # Instance attribute

buddy = Dog("Buddy")
max_dog = Dog("Max")

# Instance attributes are unique
print(buddy.name)      # "Buddy"
print(max_dog.name)    # "Max"

# Class attributes are shared
print(buddy.species)   # "Canis familiaris"
print(max_dog.species) # "Canis familiaris"

# Changing class attribute affects all instances
Dog.species = "Canis lupus"
print(buddy.species)   # "Canis lupus"

3. Encapsulation

Encapsulation bundles data and methods, controlling access to internal state.

Public, Protected, Private (Convention)

Python doesn't enforce access control, but uses naming conventions:

class BankAccount:
    def __init__(self, owner, balance):
        self.owner = owner        # Public
        self._balance = balance   # Protected (convention: internal use)
        self.__pin = "1234"       # Private (name mangling)

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

    def get_balance(self):
        return self._balance

account = BankAccount("Alice", 100)

# Public - accessible
print(account.owner)      # "Alice"

# Protected - accessible but discouraged
print(account._balance)   # 100 (works, but not recommended)

# Private - name mangled
# print(account.__pin)    # AttributeError
print(account._BankAccount__pin)  # "1234" (still accessible, but ugly)

Properties

Control attribute access with getters and setters:

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

    @property
    def radius(self):
        """Getter for radius"""
        return self._radius

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

    @property
    def area(self):
        """Computed property"""
        return 3.14159 * self._radius ** 2

circle = Circle(5)
print(circle.radius)    # 5 (uses getter)
print(circle.area)      # 78.54 (computed)

circle.radius = 10      # Uses setter
# circle.radius = -1    # Raises ValueError

4. Inheritance

Inheritance allows a class to inherit attributes and methods from another class.

Basic Inheritance

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

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

# Child class (derived class)
class Dog(Animal):
    def speak(self):
        return f"{self.name} says Woof!"

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

buddy = Dog("Buddy")
whiskers = Cat("Whiskers")

print(buddy.speak())     # "Buddy says Woof!"
print(whiskers.speak())  # "Whiskers says Meow!"

The super() Function

Call parent class methods:

class Animal:
    def __init__(self, name, age):
        self.name = name
        self.age = age

class Dog(Animal):
    def __init__(self, name, age, breed):
        super().__init__(name, age)  # Call parent __init__
        self.breed = breed

buddy = Dog("Buddy", 3, "Golden Retriever")
print(buddy.name)   # "Buddy"
print(buddy.breed)  # "Golden Retriever"

Method Overriding

Child classes can override parent methods:

class Animal:
    def make_sound(self):
        return "Some generic sound"

class Dog(Animal):
    def make_sound(self):
        return "Woof!"

animal = Animal()
dog = Dog()

print(animal.make_sound())  # "Some generic sound"
print(dog.make_sound())     # "Woof!"

Multiple Inheritance

Python supports inheriting from multiple classes:

class Flyable:
    def fly(self):
        return "Flying!"

class Swimmable:
    def swim(self):
        return "Swimming!"

class Duck(Flyable, Swimmable):
    def quack(self):
        return "Quack!"

duck = Duck()
print(duck.fly())   # "Flying!"
print(duck.swim())  # "Swimming!"
print(duck.quack()) # "Quack!"

Method Resolution Order (MRO)

Python uses MRO to determine which method to call:

class A:
    def method(self):
        return "A"

class B(A):
    def method(self):
        return "B"

class C(A):
    def method(self):
        return "C"

class D(B, C):
    pass

d = D()
print(d.method())  # "B" (B comes before C in D's inheritance)
print(D.__mro__)   # Shows: D -> B -> C -> A -> object

5. Polymorphism

Polymorphism allows different classes to be treated through a common interface.

Duck Typing

"If it walks like a duck and quacks like a duck, it's a duck."

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

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

class Duck:
    def speak(self):
        return "Quack!"

def animal_sound(animal):
    # Works with any object that has a speak() method
    print(animal.speak())

animal_sound(Dog())   # "Woof!"
animal_sound(Cat())   # "Meow!"
animal_sound(Duck())  # "Quack!"

Abstract Base Classes

Enforce that subclasses implement certain methods:

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        pass

    @abstractmethod
    def perimeter(self):
        pass

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)

# shape = Shape()  # TypeError: Can't instantiate abstract class
rect = Rectangle(5, 3)
print(rect.area())       # 15
print(rect.perimeter())  # 16

6. Special Methods (Dunder Methods)

Special methods customize object behavior. They're surrounded by double underscores.

Object Representation

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

    def __str__(self):
        """Human-readable string (for print)"""
        return f"Point({self.x}, {self.y})"

    def __repr__(self):
        """Developer-friendly string (for debugging)"""
        return f"Point(x={self.x}, y={self.y})"

p = Point(3, 4)
print(p)        # Point(3, 4) - uses __str__
print(repr(p))  # Point(x=3, y=4) - uses __repr__

Comparison Methods

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def __eq__(self, other):
        return self.age == other.age

    def __lt__(self, other):
        return self.age < other.age

    def __le__(self, other):
        return self.age <= other.age

alice = Person("Alice", 25)
bob = Person("Bob", 30)

print(alice == bob)  # False
print(alice < bob)   # True
print(alice <= bob)  # True

Arithmetic Methods

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = 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 __str__(self):
        return f"Vector({self.x}, {self.y})"

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

print(v1 + v2)  # Vector(4, 6)
print(v2 - v1)  # Vector(2, 2)
print(v1 * 3)   # Vector(3, 6)

Container Methods

class Playlist:
    def __init__(self):
        self.songs = []

    def __len__(self):
        return len(self.songs)

    def __getitem__(self, index):
        return self.songs[index]

    def __setitem__(self, index, value):
        self.songs[index] = value

    def __contains__(self, song):
        return song in self.songs

    def __iter__(self):
        return iter(self.songs)

    def add(self, song):
        self.songs.append(song)

playlist = Playlist()
playlist.add("Song A")
playlist.add("Song B")
playlist.add("Song C")

print(len(playlist))        # 3
print(playlist[1])          # "Song B"
print("Song A" in playlist) # True

for song in playlist:
    print(song)

Context Managers

class FileHandler:
    def __init__(self, filename, mode):
        self.filename = filename
        self.mode = mode
        self.file = None

    def __enter__(self):
        self.file = open(self.filename, self.mode)
        return self.file

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.file.close()
        return False  # Don't suppress exceptions

# Usage
with FileHandler("test.txt", "w") as f:
    f.write("Hello!")
# File is automatically closed after the block

7. Class Methods and Static Methods

Instance Methods

Regular methods - operate on instances:

class Dog:
    def bark(self):  # self = instance
        print("Woof!")

Class Methods

Operate on the class itself:

class Dog:
    count = 0

    def __init__(self, name):
        self.name = name
        Dog.count += 1

    @classmethod
    def get_count(cls):  # cls = class
        return cls.count

    @classmethod
    def create_puppy(cls, name):
        """Alternative constructor"""
        return cls(f"Baby {name}")

print(Dog.get_count())  # 0

dog1 = Dog("Buddy")
dog2 = Dog("Max")

print(Dog.get_count())  # 2

puppy = Dog.create_puppy("Rex")
print(puppy.name)  # "Baby Rex"

Static Methods

Don't access instance or class - utility functions:

class MathUtils:
    @staticmethod
    def is_even(n):
        return n % 2 == 0

    @staticmethod
    def factorial(n):
        if n <= 1:
            return 1
        return n * MathUtils.factorial(n - 1)

print(MathUtils.is_even(4))    # True
print(MathUtils.factorial(5))  # 120

8. Composition vs Inheritance

Inheritance: "is-a" Relationship

class Animal:
    pass

class Dog(Animal):  # Dog IS-A Animal
    pass

Composition: "has-a" Relationship

class Engine:
    def start(self):
        return "Engine started"

class Car:
    def __init__(self):
        self.engine = Engine()  # Car HAS-A Engine

    def start(self):
        return self.engine.start()

car = Car()
print(car.start())  # "Engine started"

Prefer composition over inheritance when possible - it's more flexible.


9. Design Principles

Single Responsibility Principle

A class should have one reason to change.

# Bad - multiple responsibilities
class User:
    def __init__(self, name):
        self.name = name

    def save_to_database(self):
        pass  # Database logic

    def send_email(self):
        pass  # Email logic

# Good - separated responsibilities
class User:
    def __init__(self, name):
        self.name = name

class UserRepository:
    def save(self, user):
        pass

class EmailService:
    def send(self, user, message):
        pass

Open/Closed Principle

Open for extension, closed for modification.

# Good - extend through inheritance, not modification
class Shape(ABC):
    @abstractmethod
    def area(self):
        pass

class Rectangle(Shape):
    def area(self):
        return self.width * self.height

class Circle(Shape):
    def area(self):
        return 3.14 * self.radius ** 2

# Adding new shapes doesn't modify existing code

Exercises

Basic

  1. Create a Rectangle class with width and height attributes, and methods area() and perimeter().

  2. Create a BankAccount class with methods deposit(), withdraw(), and get_balance(). Ensure balance can't go negative.

  3. Create a Student class with name and grades (list). Add method average_grade().

Intermediate

  1. Create a class hierarchy:

    • Vehicle (base) with make, model, year
    • Car (inherits Vehicle) adds num_doors
    • Motorcycle (inherits Vehicle) adds has_sidecar
  2. Implement a Fraction class with:

    • __init__, __str__
    • __add__, __sub__, __mul__
    • __eq__, __lt__
  3. Create a Deck class that:

    • Contains 52 cards
    • Has shuffle() method
    • Has deal() method that removes and returns a card
    • Supports len(deck)

Advanced

  1. Implement a LinkedList class with:

    • Node class (internal)
    • append(), prepend(), delete()
    • __len__, __iter__, __contains__
  2. Create a plugin system using abstract base classes where plugins must implement execute() and get_name().

  3. Implement the Observer pattern: subjects notify observers of state changes.


Summary

  • Classes are blueprints; objects are instances
  • __init__ initializes objects; self refers to the instance
  • Encapsulation bundles data and methods, controls access
  • Inheritance creates "is-a" relationships
  • Polymorphism allows treating different types uniformly
  • Special methods customize object behavior
  • Composition often preferable to inheritance
  • Follow design principles for maintainable code

Next Reading

Error Handling and Debugging →