Lecture 13 — Object-Oriented Programming

Lecture 13 — Object-Oriented Programming#

Lecture 13 of 18 — Progress: 72%

PyPro-SCiDaS

An Initiation to Programming using Python (Init2Py)

🏗️ Lecture 13 — Object-Oriented Programming

Python Proficiency for Scientific Computing and Data Science

🧑‍🏫 Instructor: Yaé Gaba 📘 Course: Init2Py 📅 Date: October 2025 🎓 Semester: Semester 1, 2025–2026 ⏱️ Estimated: 60 min Intermediate
🏛️ AI Research and Innovation Nexus for Africa (AIRINA Labs), AI.Technipreneurs, Bénin
& African Center for Advanced Studies (ACAS), Cameroon
✉️ yaeulrich.gaba@gmail.com   |   🔗 LinkedIn | 🌐 Website

🎯 Learning Objectives

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

  • ✅ Understand the difference between procedural and object-oriented programming
  • ✅ Define classes with attributes and methods using class
  • ✅ Create and use objects (instances) from classes
  • ✅ Implement inheritance, method overriding, and super()
  • ✅ Apply encapsulation, polymorphism, and special (dunder) methods

🔗 Building on what you know: In Lecture 7, you learned to organize code into functions and modules. Object-Oriented Programming takes this further by bundling data and functions into reusable, self-contained objects. Everything you use in Python — strings, lists, DataFrames, even NumPy arrays — is an object. This lecture teaches you to create your own.


💡 1. Why Object-Oriented Programming?

Object-Oriented Programming (OOP) is a programming paradigm that organizes code around objects rather than functions and logic. Objects bundle data (attributes) and behavior (methods) together, making code modular, reusable, and easier to maintain.

Imagine you are building a simulation of a solar system. With procedural programming, you would have separate lists for planet names, masses, positions, and velocities — plus functions that take all of these as arguments. As the simulation grows, managing all these scattered variables becomes chaotic.

With OOP, each planet is a single object that knows its own name, mass, position, and velocity, and knows how to update its position, compute gravitational pull, and draw itself. The code becomes a natural model of the real-world system.

Let’s see a simple comparison. First, the procedural approach:

# Procedural approach — data and behavior are separate
def area_circle(radius):
    return 3.14159 * radius ** 2

def circumference_circle(radius):
    return 2 * 3.14159 * radius

r = 5
print(f"Area: {area_circle(r)}")
print(f"Circumference: {circumference_circle(r)}")

Now the object-oriented approach — data and behavior live together:

# Object-Oriented approach — data and behavior are bundled
import math

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

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

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

c = Circle(5)
print(f"Area: {c.area():.4f}")
print(f"Circumference: {c.circumference():.4f}")

Notice how the OOP version doesn’t need to pass radius to every function — the circle knows its own radius. This becomes increasingly powerful as your objects grow more complex.

💡 The four pillars of OOP are: Encapsulation (bundling data and methods), Abstraction (hiding complexity), Inheritance (reusing code via parent-child relationships), and Polymorphism (one interface, many implementations). We will cover each in this lecture.


🏗️ 2. Classes and Objects

A class is a blueprint or template. An object (or instance) is a concrete realization of that blueprint. Think of a class as a cookie cutter and objects as the cookies — each cookie has the same shape but can have different decorations (attributes).

2.1 Defining a Class#

Use the class keyword to define a class. The special method __init__ is called the constructor — Python calls it automatically whenever you create a new object. Inside __init__, you set up the object’s initial state by assigning attributes to self.

class Student:
    """A class representing a student."""

    def __init__(self, name, age, major):
        self.name = name      # instance attribute
        self.age = age         # instance attribute
        self.major = major     # instance attribute

    def introduce(self):
        """Print a self-introduction."""
        print(f"Hi, I'm {self.name}, {self.age} years old, studying {self.major}.")

# Creating objects (instances)
alice = Student("Alice", 22, "Physics")
bob = Student("Bob", 24, "Computer Science")

alice.introduce()
bob.introduce()

# Each object is independent — changing one doesn't affect the other
alice.age = 23
print(f"Alice is now {alice.age}, Bob is still {bob.age}")

2.2 The self Parameter#

Every instance method receives self as its first parameter. self is a reference to the specific object calling the method. Python passes it automatically — you never write alice.introduce(alice), just alice.introduce().

This is what makes each object independent: self.count in one Counter is different from self.count in another.

class Counter:
    def __init__(self):
        self.count = 0

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

    def reset(self):
        self.count = 0

    def get_count(self):
        return self.count

c1 = Counter()
c2 = Counter()

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

print(f"c1: {c1.get_count()}")  # 3
print(f"c2: {c2.get_count()}")  # 1 — each object has its own state


📋 3. Instance vs. Class Attributes

Instance attributes belong to a specific object and are defined in __init__. Class attributes are shared across all instances of the class — change one and they all see the change.

This is similar to the difference between a variable inside a function (local) and a variable at module level (global) — a concept you learned in Lecture 7.

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

    def __init__(self, name, breed):
        self.name = name    # instance attribute (unique per dog)
        self.breed = breed   # instance attribute (unique per dog)

rex = Dog("Rex", "German Shepherd")
bella = Dog("Bella", "Labrador")

print(f"{rex.name} is a {rex.species}")
print(f"{bella.name} is a {bella.species}")
print(f"Same species object? {rex.species is bella.species}")  # True — shared!


⚙️ 4. Types of Methods

Python supports three kinds of methods inside a class. Knowing when to use each is key to writing clean OOP code.

Method Type

Decorator

First Parameter

Can Access

Instance method

(none)

self

Instance + class data

Class method

@classmethod

cls

Class data only

Static method

@staticmethod

(none)

Neither — just a regular function living inside the class

class MathUtils:
    pi = 3.14159  # class attribute

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

    # Instance method — operates on a specific object
    def double(self):
        return self.value * 2

    # Class method — operates on the class itself
    @classmethod
    def circle_area(cls, radius):
        return cls.pi * radius ** 2

    # Static method — just a utility function, no access to self or cls
    @staticmethod
    def add(a, b):
        return a + b

m = MathUtils(7)
print(f"Double of 7: {m.double()}")
print(f"Circle area (r=5): {MathUtils.circle_area(5):.2f}")
print(f"3 + 4 = {MathUtils.add(3, 4)}")


📦 5. Inheritance

Inheritance lets you build new classes on top of existing ones. The child class inherits all attributes and methods from the parent, and can add or override them. This avoids duplicating code and creates a natural hierarchy.

In scientific computing, inheritance is everywhere. For example, in Matplotlib, Axes3D inherits from Axes — it has all the 2D plotting methods plus 3D-specific ones. In scikit-learn, all classifiers inherit from a common BaseEstimator class.

Here is a simple example:

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"Animal({self.name})"


class Cat(Animal):
    """Cat inherits from Animal and adds purr()."""
    def __init__(self, name, color):
        super().__init__(name, "Meow")  # Call parent's __init__
        self.color = color

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


class Dog(Animal):
    """Dog inherits from Animal and adds fetch()."""
    def __init__(self, name, breed):
        super().__init__(name, "Woof")
        self.breed = breed

    def fetch(self, item):
        print(f"{self.name} fetches the {item}!")


cat = Cat("Whiskers", "orange")
dog = Dog("Rex", "Labrador")

cat.speak()         # Inherited from Animal
cat.purr()          # Unique to Cat
dog.speak()         # Inherited from Animal
dog.fetch("ball")   # Unique to Dog

5.1 Method Overriding#

A child class can override (replace) a parent’s method to provide specialized behavior. The parent can even define “abstract” methods that require children to implement them:

class Shape:
    """Base class — defines the interface but not the implementation."""
    def area(self):
        raise NotImplementedError("Subclasses must implement area()")

    def describe(self):
        print(f"I am a {self.__class__.__name__} with area {self.area():.2f}")


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

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


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

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


shapes = [Rectangle(4, 6), Circle(5), Rectangle(10, 2)]
for s in shapes:
    s.describe()  # Each calls its own area() — polymorphism in action!


🔒 6. Encapsulation

Encapsulation means hiding an object's internal details and exposing only what's necessary. This protects data from accidental modification and makes your code safer to use and maintain.

In Python, encapsulation is achieved through naming conventions:

Convention

Meaning

Example

name

Public — accessible from anywhere

self.name

_name

Protected — “internal use, please don’t touch”

self._cache

__name

Name-mangled — harder to access from outside

self.__balance

Python trusts programmers (“we’re all adults here”), so nothing is truly private. But the conventions are widely respected.

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner          # public
        self.__balance = balance    # "private" (name-mangled)

    def deposit(self, amount):
        if amount > 0:
            self.__balance += amount
            print(f"Deposited {amount}. New balance: {self.__balance}")
        else:
            print("Deposit amount must be positive.")

    def withdraw(self, amount):
        if 0 < amount <= self.__balance:
            self.__balance -= amount
            print(f"Withdrew {amount}. New balance: {self.__balance}")
        else:
            print("Invalid withdrawal amount.")

    def get_balance(self):
        return self.__balance


account = BankAccount("Alice", 1000)
account.deposit(500)
account.withdraw(200)
print(f"Balance: {account.get_balance()}")

# Direct access fails:
# print(account.__balance)  # AttributeError!
# But name-mangling can be bypassed (not recommended):
# print(account._BankAccount__balance)  # Works, but don't do this!


✨ 7. Special (Dunder) Methods

Python lets you define special methods (named with double underscores, hence "dunder") to make your objects work with built-in Python operations like +, print(), len(), and ==.

This is what makes Python so elegant. When you write "hello" + " world", Python is actually calling "hello".__add__(" world") behind the scenes. You can define the same behavior for your own classes.

Method

Triggered by

Example

__init__

Object creation

obj = MyClass()

__str__

print(obj), str(obj)

Human-readable string

__repr__

repr(obj), REPL display

Unambiguous string

__add__

obj1 + obj2

Custom addition

__eq__

obj1 == obj2

Custom equality

__len__

len(obj)

Custom length

__getitem__

obj[key]

Custom indexing

class Vector:
    """A 2D vector that supports arithmetic operations."""
    def __init__(self, x, y):
        self.x = x
        self.y = y

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

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

    def __add__(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 __abs__(self):
        return (self.x ** 2 + self.y ** 2) ** 0.5


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

print(f"v1 = {v1}")            # __str__
print(f"repr: {repr(v1)}")     # __repr__
print(f"v1 + v2 = {v1 + v2}")  # __add__
print(f"v1 * 3 = {v1 * 3}")    # __mul__
print(f"|v1| = {abs(v1)}")     # __abs__
print(f"v1 == v2? {v1 == v2}") # __eq__
🧠 NumPy connection: This is exactly how NumPy arrays work! When you write arr1 + arr2, NumPy's __add__ method performs element-wise addition. The operator overloading you just learned is the foundation of NumPy's elegant syntax.


🔀 8. Polymorphism

Polymorphism ("many forms") means different objects can respond to the same method call in different ways. Python achieves this naturally through duck typing: "If it walks like a duck and quacks like a duck, it's a duck."

You’ve already seen polymorphism in action: len() works on strings, lists, dicts, and tuples — each type implements __len__ differently. The same principle applies to your own classes:

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

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

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

# Polymorphism: same interface, different behavior
animals = [Dog(), Cat(), Duck()]
for animal in animals:
    print(f"{animal.__class__.__name__}: {animal.speak()}")

Python doesn’t check that these classes share a common parent — it only checks that the object has a speak() method when you call it. This flexibility is one of Python’s greatest strengths.


📝 9. Practice Exercises

Exercise 1: Create a Book class with attributes title, author, and pages. Add:

  • A method is_long() that returns True if the book has more than 300 pages

  • A __str__ method for nice printing

  • A __lt__ method so books can be sorted by page count

Exercise 2: Create a Library class that holds a list of Book objects. Add methods:

  • add_book(book) — add a book to the library

  • find_by_author(author) — return all books by a given author

  • __len__() — return the number of books

  • __getitem__(index) — support indexing like library[0]

Exercise 3: Create a class hierarchy for geometric shapes:

  • Base class Shape with an abstract area() and perimeter() method

  • Subclasses: Square, Triangle, Circle

  • A function total_area(shapes) that takes a list of mixed shapes and returns the total area

# Exercise 1: Your code here
Click to show solution
class Book:
    def __init__(self, title, author, pages):
        self.title = title
        self.author = author
        self.pages = pages

    def is_long(self):
        return self.pages > 300

    def __str__(self):
        return f"\"{self.title}\" by {self.author} ({self.pages} pages)"

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


# Test
b1 = Book("Python Crash Course", "Eric Matthes", 544)
b2 = Book("The Little Prince", "Antoine de Saint-Exupery", 96)
b3 = Book("Clean Code", "Robert C. Martin", 464)

print(b1)
print(f"Is \"{b1.title}\" long? {b1.is_long()}")
print(f"Is \"{b2.title}\" long? {b2.is_long()}")

books = [b1, b2, b3]
print(f"Sorted: {[str(b) for b in sorted(books)]}")
# Exercise 2: Your code here
Click to show solution
class Library:
    def __init__(self):
        self.books = []

    def add_book(self, book):
        self.books.append(book)

    def find_by_author(self, author):
        return [b for b in self.books if b.author == author]

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

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


# Test (assumes Book class from Exercise 1)
lib = Library()
lib.add_book(Book("Python Crash Course", "Eric Matthes", 544))
lib.add_book(Book("Automate the Boring Stuff", "Al Sweigart", 592))
lib.add_book(Book("The Little Prince", "Antoine de Saint-Exupery", 96))

print(f"Library has {len(lib)} books")
print(f"First book: {lib[0]}")
print(f"Books by Eric Matthes: {[str(b) for b in lib.find_by_author('Eric Matthes')]}") 
# Exercise 3: Your code here
Click to show solution
import math

class Shape:
    def area(self):
        raise NotImplementedError

    def perimeter(self):
        raise NotImplementedError


class Square(Shape):
    def __init__(self, side):
        self.side = side

    def area(self):
        return self.side ** 2

    def perimeter(self):
        return 4 * self.side


class Triangle(Shape):
    def __init__(self, a, b, c):
        self.a, self.b, self.c = a, b, c

    def area(self):
        s = (self.a + self.b + self.c) / 2
        return math.sqrt(s * (s - self.a) * (s - self.b) * (s - self.c))

    def perimeter(self):
        return self.a + self.b + self.c


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

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

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


def total_area(shapes):
    return sum(s.area() for s in shapes)


# Test
shapes = [Square(5), Triangle(3, 4, 5), Circle(7)]
for s in shapes:
    print(f"{s.__class__.__name__}: area={s.area():.2f}, perimeter={s.perimeter():.2f}")
print(f"Total area: {total_area(shapes):.2f}")

🎯 Key Takeaways

  • A class is a blueprint; an object is an instance of that class.
  • __init__ initializes object attributes; self refers to the current instance.
  • Inheritance lets a child class reuse and extend a parent class's behavior.
  • Encapsulation protects internal state with naming conventions (_private, __mangled).
  • Special methods (__str__, __repr__, __add__) customize how objects behave with Python operators.

🏁 End of Lecture 13 — Object-Oriented Programming

PyPro-SCiDaS • Python Proficiency for Scientific Computing and Data Science

Course progress: 78% complete (14 of 18 lectures)

© 2025 Yaé Gaba — CC BY-NC 4.0