Lecture 13 — Object-Oriented Programming#
🎯 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
📑 Table of Contents
- ● 🏗️ Lecture 13 — Object-Oriented Programming
- 1. ▸ 💡 1. Why Object-Oriented Programming?
- 2. ▸ 🏗️ 2. Classes and Objects
- 3. ▸ 2.1 Defining a Class
- 4. ▸ 2.2 The `self` Parameter
- 5. ▸ 📋 3. Instance vs. Class Attributes
- 6. ▸ ⚙️ 4. Types of Methods
- 7. ▸ 📦 5. Inheritance
- 8. ▸ 5.1 Method Overriding
- 9. ▸ 🔒 6. Encapsulation
- 10. ▸ ✨ 7. Special (Dunder) Methods
- 11. ▸ 🔀 8. Polymorphism
- 12. ▸ 📝 9. Practice Exercises
- 13. ▸ 🎯 Key Takeaways
💡 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.
🏗️ 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) |
|
Instance + class data |
Class method |
|
|
Class data only |
Static method |
|
(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 |
|---|---|---|
|
Public — accessible from anywhere |
|
|
Protected — “internal use, please don’t touch” |
|
|
Name-mangled — harder to access from outside |
|
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 |
|---|---|---|
|
Object creation |
|
|
|
Human-readable string |
|
|
Unambiguous string |
|
|
Custom addition |
|
|
Custom equality |
|
|
Custom length |
|
|
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__
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 returnsTrueif the book has more than 300 pagesA
__str__method for nice printingA
__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 libraryfind_by_author(author)— return all books by a given author__len__()— return the number of books__getitem__(index)— support indexing likelibrary[0]
Exercise 3: Create a class hierarchy for geometric shapes:
Base class
Shapewith an abstractarea()andperimeter()methodSubclasses:
Square,Triangle,CircleA 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;selfrefers 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.
—
—