Today we'll explore the fundamentals of Object-Oriented Programming (OOP) in Python:
By the end of this lecture, you'll be able to create your own classes and understand how OOP makes programming more organized and powerful.
After this lecture, you will be able to:
__init__ method and self parameterA programming paradigm is a fundamental style or approach to programming. Python supports multiple paradigms:
# Procedural approach
def calculate_area(length, width):
return length * width
def calculate_perimeter(length, width):
return 2 * (length + width)
# Usage
area = calculate_area(10, 5)
perimeter = calculate_perimeter(10, 5)
print(f"Area: {area}, Perimeter: {perimeter}")
Imagine you need to store information about several dogs. Without objects, you might use separate lists for each piece of information.
# Data for multiple dogs is spread out
dog_names = ["Fido", "Rex"]
dog_ages = [4, 7]
# How do you get all info for just Fido?
# It's clumsy.
fido_index = dog_names.index("Fido")
fido_age = dog_ages[fido_index]
print(f"{dog_names[fido_index]} is {fido_age} years old.")
# Output: Fido is 4 years old.
As your program grows, parallel lists become increasingly problematic:
# What happens when lists get out of sync?
student_names = ["Alice", "Bob", "Charlie"]
student_grades = [85, 92] # Oops! Missing Charlie's grade
student_ages = [20, 19, 21]
# This will cause an IndexError!
for i in range(len(student_names)):
print(f"{student_names[i]}: Grade {student_grades[i]}, Age {student_ages[i]}")
Object-Oriented Programming (OOP) lets you bundle related data and functions into a single "object".
We create a class which acts as a blueprint. Then we can create individual objects (or instances) from that blueprint.
# The "Dog" class is a blueprint
class Dog:
def __init__(self, name, age):
self.name = name
self.age = age
# Create objects from the blueprint
fido = Dog("Fido", 4)
rex = Dog("Rex", 7)
# Accessing data is clean and intuitive
print(f"{fido.name} is {fido.age} years old.")
# Output: Fido is 4 years old.
OOP makes your code better in several key ways:
OOP is built on four fundamental concepts. We will explore these throughout the lecture.
Think of a cookie cutter and the cookies it makes.
You can make many unique cookies (objects) from a single cutter (class).
You've been using objects all along! Even basic data types are objects in Python.
The type() function reveals an item's underlying class.
# A string is an object of the 'str' class
print(type("hello"))
# Output:
# An integer is an object of the 'int' class
x = 5
print(type(x))
# Output:
# A list is an object of the 'list' class
my_list = [1, 2, 3]
print(type(my_list))
# Output:
The object's class (or type) determines which operations are valid.
Trying to perform an undefined operation between objects of different types will result in a TypeError.
The operation must be defined for the specific classes involved.
x = 1 # An object of class 'int'
y = "hello" # An object of class 'str'
# This will fail!
# The '+' operation is not defined
# for an int and a str together.
print(x + y)
# TypeError: unsupported operand type(s)
# for +: 'int' and 'str'
A method is a function that "belongs" to a class and can be performed on its objects.
You call a method on an object using the dot . operator.
The methods available depend on the object's class.
my_string = "hello world"
# The .upper() method is valid for strings
print(my_string.upper())
# Output: HELLO WORLD
my_int = 10
# This will cause an error!
print(my_int.upper())
# AttributeError: 'int' object
# has no attribute 'upper'
Let's explore some methods available on common Python objects:
# String methods
text = "Python Programming"
print(text.lower()) # python programming
print(text.split()) # ['Python', 'Programming']
print(text.replace("Python", "Java")) # Java Programming
# List methods
numbers = [3, 1, 4, 1, 5]
numbers.append(9) # Add element
numbers.sort() # Sort in place
print(numbers) # [1, 1, 3, 4, 5, 9]
# Dictionary methods
person = {"name": "Alice", "age": 25}
print(person.keys()) # dict_keys(['name', 'age'])
print(person.get("name")) # Alice
You can create your own custom blueprints using the class keyword.
Naming Convention: Class names should start with a capital letter and use CamelCase (e.g., `MyFirstClass`).
Methods defined inside the class are the actions objects of this class can perform.
# Define a new blueprint called 'Dog'
class Dog:
# Define a method for the Dog class
# 'self' refers to the instance of the dog
def bark(self):
print("Woof!")
The `self` parameter is a reference to the specific object instance that a method is being called on.
Instantiation is the process of creating an object (an instance) from a class.
You do this by calling the class name as if it were a function.
Once you have an instance, you can call its methods.
class Dog:
def bark(self):
print("Woof!")
# Create an instance of the Dog class
d = Dog()
# d is an object of our custom Dog class
print(type(d))
# Output:
# Call the bark method on our dog instance
d.bark()
# Output: Woof!
The __init__ method (short for "initialize") is a special method, often called a "constructor".
Dog("Fido", 5)) are passed to the __init__ method.class Dog:
# name and age are passed when creating a Dog
def __init__(self, name, age):
# Create attributes for this specific dog
self.name = name
self.age = age
# Create two different Dog instances
d1 = Dog("Fido", 10)
d2 = Dog("Rex", 4)
# Each instance has its own attributes
print(f"{d1.name} is {d1.age} years old.")
# Output: Fido is 10 years old.
Attributes are variables that belong to a specific object instance.
We create attributes inside the __init__ method using `self`.
self allows us to store data unique to that object (e.g., `self.name`).
class Dog:
def __init__(self, name, age, breed):
# These are instance attributes
self.name = name
self.age = age
self.breed = breed
def get_info(self):
return f"{self.name} is a {self.breed}"
# Create two different Dog instances
d1 = Dog("Fido", 10, "Golden Retriever")
d2 = Dog("Rex", 4, "German Shepherd")
# Each instance has its own attributes
print(d1.get_info())
# Output: Fido is a Golden Retriever
print(d2.get_info())
# Output: Rex is a German Shepherd
Let's create a practical example with a Rectangle class that can calculate its area and perimeter:
class Rectangle:
def __init__(self, length, width):
self.length = length
self.width = width
def calculate_area(self):
return self.length * self.width
def calculate_perimeter(self):
return 2 * (self.length + self.width)
def is_square(self):
return self.length == self.width
# Create rectangle objects
rect1 = Rectangle(10, 5)
rect2 = Rectangle(7, 7)
print(f"Rectangle 1 - Area: {rect1.calculate_area()}, Perimeter: {rect1.calculate_perimeter()}")
print(f"Rectangle 2 is square: {rect2.is_square()}")
# Output: Rectangle 1 - Area: 50, Perimeter: 30
# Output: Rectangle 2 is square: True
Encapsulation is the bundling of data (attributes) and methods that operate on the data into a single object.
Analogy: A Car
Our `Dog` class encapsulates the `name` and `age` with methods like `get_info()`.
Abstraction means hiding the complexity and showing only the essential features of the object.
Analogy: A TV Remote
In our code, when we call `my_list.sort()`, we don't need to know which sorting algorithm it uses. We just know it will sort the list. That's abstraction!
Let's model something different. A car has attributes (make, model) and methods (start, stop).
Notice how methods can change the object's internal state (e.g., `is_running`).
class Car:
def __init__(self, make, model, year):
self.make = make
self.model = model
self.year = year
self.is_running = False # Default state
def start_engine(self):
self.is_running = True
print(f"The {self.model}'s engine is now running.")
def stop_engine(self):
self.is_running = False
print(f"The {self.model}'s engine has been stopped.")
my_car = Car("Toyota", "Camry", 2021)
print(f"Is the car running? {my_car.is_running}") # False
my_car.start_engine()
print(f"Is the car running? {my_car.is_running}") # True
Here's a more complex example showing how methods can modify object state:
class BankAccount:
def __init__(self, account_holder, initial_balance=0):
self.account_holder = account_holder
self.balance = initial_balance
self.transaction_history = []
def deposit(self, amount):
if amount > 0:
self.balance += amount
self.transaction_history.append(f"Deposited ${amount}")
print(f"${amount} deposited. New balance: ${self.balance}")
else:
print("Deposit amount must be positive")
def withdraw(self, amount):
if amount > self.balance:
print("Insufficient funds")
elif amount > 0:
self.balance -= amount
self.transaction_history.append(f"Withdrew ${amount}")
print(f"${amount} withdrawn. New balance: ${self.balance}")
account = BankAccount("Alice", 100)
account.deposit(50) # $50 deposited. New balance: $150
account.withdraw(30) # $30 withdrawn. New balance: $120
The key=lambda parameter tells Python how to compare elements when finding max/min or sorting.
Instead of comparing objects directly, it compares the return values of the lambda function.
Basic syntax:
max(iterable, key=lambda item: comparison_value)
sorted(iterable, key=lambda item: comparison_value)
# Simple examples
words = ["apple", "banana", "cherry", "date"]
# Find longest word
longest = max(words, key=lambda word: len(word))
print(longest) # "banana"
# Sort by length
sorted_by_length = sorted(words, key=lambda word: len(word))
print(sorted_by_length) # ['date', 'apple', 'cherry', 'banana']
You can use lambda functions for complex comparisons:
The lambda function can perform any operation that returns a comparable value.
# Working with dictionaries
people = [
{"name": "Alice", "age": 25, "salary": 75000},
{"name": "Bob", "age": 30, "salary": 65000},
{"name": "Charlie", "age": 20, "salary": 85000}
]
# Find highest earner
highest_earner = max(people, key=lambda p: p["salary"])
print(highest_earner["name"]) # "Charlie"
# Sort by multiple criteria: salary desc, then age asc
sorted_people = sorted(people,
key=lambda p: (-p["salary"], p["age"]))
# Complex transformations
numbers = [-5, 2, -10, 3, -1]
by_absolute = sorted(numbers, key=lambda x: abs(x))
print(by_absolute) # [-1, 2, 3, -5, -10]
# Case-insensitive sorting
words = ["Apple", "banana", "Cherry"]
sorted_words = sorted(words, key=lambda w: w.lower())
print(sorted_words) # ['Apple', 'banana', 'Cherry']
Try to solve these problems using key=lambda:
Hint: For multiple criteria, use tuples like (-rating, title)
# Given data
movies = [
{"title": "The Shawshank Redemption", "year": 1994, "rating": 9.3},
{"title": "The Godfather", "year": 1972, "rating": 9.2},
{"title": "Pulp Fiction", "year": 1994, "rating": 8.9},
{"title": "The Dark Knight", "year": 2008, "rating": 9.0},
{"title": "Forrest Gump", "year": 1994, "rating": 8.8}
]
# Your solutions here:
# 1. Shortest title
shortest = min(movies, key=lambda m: ?)
print(f"Shortest: {shortest['title']}")
# 2. Sort by year
by_year = sorted(movies, key=lambda m: ?)
print("By year:", [m['title'] for m in by_year])
# 3. Highest rating
highest_rated = max(movies, key=lambda m: ?)
print(f"Highest rated: {highest_rated['title']}")
# 4. Sort by rating desc, then title asc
by_rating_title = sorted(movies, key=lambda m: ?)
print("By rating/title:", [m['title'] for m in by_rating_title])
# 5. Oldest movie
oldest = min(movies, key=lambda m: ?)
print(f"Oldest: {oldest['title']} ({oldest['year']})")
Let's model a more complex system to see the power of classes interacting.
We will create:
Student class to hold information about an individual student.Course class to manage a collection of `Student` objects.This shows how objects of one class can contain and interact with objects of another class.
A simple blueprint for a student.
Each student will have a name, age, and grade.
We'll add a "getter" method to retrieve the student's grade.
class Student:
def __init__(self, name, age, grade):
self.name = name
self.age = age
self.grade = grade # A number 0-100
def get_grade(self):
return self.grade
def get_letter_grade(self):
if self.grade >= 90:
return 'A'
elif self.grade >= 80:
return 'B'
elif self.grade >= 70:
return 'C'
elif self.grade >= 60:
return 'D'
else:
return 'F'
The `Course` class will manage `Student` objects.
Its `students` attribute is a list that will hold `Student` objects.
The `add_student` method takes a `Student` object as an argument.
class Course:
def __init__(self, name, max_students):
self.name = name
self.max_students = max_students
self.students = [] # Starts as an empty list
def add_student(self, student):
if len(self.students) < self.max_students:
self.students.append(student)
return True
return False
def get_student_count(self):
return len(self.students)
This method demonstrates how a `Course` object can interact with the `Student` objects it contains.
It iterates through its list of students and calls the `get_grade()` method on each one.
class Course:
# ... (init and add_student methods here) ...
def get_average_grade(self):
if not self.students:
return 0
value = 0
for student in self.students:
# Calling a method on a Student object
value += student.get_grade()
return value / len(self.students)
def get_top_student(self):
if not self.students:
return None
return max(self.students, key=lambda s: s.get_grade())
Let's see how our classes work together:
# Create student objects
alice = Student("Alice", 20, 95)
bob = Student("Bob", 19, 87)
charlie = Student("Charlie", 21, 92)
# Create a course and add students
python_course = Course("Python Programming", 30)
python_course.add_student(alice)
python_course.add_student(bob)
python_course.add_student(charlie)
# Use course methods
print(f"Course: {python_course.name}")
print(f"Students enrolled: {python_course.get_student_count()}")
print(f"Average grade: {python_course.get_average_grade():.1f}")
top_student = python_course.get_top_student()
print(f"Top student: {top_student.name} with grade {top_student.get_grade()}")
# Output:
# Course: Python Programming
# Students enrolled: 3
# Average grade: 91.3
# Top student: Alice with grade 95
Inheritance allows a class to inherit attributes and methods from another class.
The new class (child/subclass) can extend or modify the behavior of the parent class (superclass).
class Animal:
def __init__(self, name, species):
self.name = name
self.species = species
def speak(self):
print(f"{self.name} makes a sound")
class Dog(Animal): # Dog inherits from Animal
def __init__(self, name, breed):
super().__init__(name, "Canine") # Call parent constructor
self.breed = breed
def speak(self): # Override parent method
print(f"{self.name} barks: Woof!")
class Cat(Animal): # Cat also inherits from Animal
def speak(self): # Override parent method
print(f"{self.name} meows: Meow!")
# Usage
my_dog = Dog("Buddy", "Golden Retriever")
my_cat = Cat("Whiskers", "Persian")
my_dog.speak() # Buddy barks: Woof!
my_cat.speak() # Whiskers meows: Meow!
Here's a practical example of inheritance with an employee management system:
class Employee:
def __init__(self, name, employee_id, salary):
self.name = name
self.employee_id = employee_id
self.salary = salary
def get_info(self):
return f"Employee: {self.name} (ID: {self.employee_id})"
def calculate_bonus(self):
return self.salary * 0.05 # 5% bonus
class Manager(Employee):
def __init__(self, name, employee_id, salary, team_size):
super().__init__(name, employee_id, salary)
self.team_size = team_size
def calculate_bonus(self): # Override for managers
base_bonus = super().calculate_bonus()
return base_bonus + (self.team_size * 1000) # Extra for team management
class Developer(Employee):
def __init__(self, name, employee_id, salary, programming_language):
super().__init__(name, employee_id, salary)
self.programming_language = programming_language
# Usage
dev = Developer("Alice", "D001", 80000, "Python")
mgr = Manager("Bob", "M001", 100000, 5)
print(f"Developer bonus: ${dev.calculate_bonus()}") # $4000
print(f"Manager bonus: ${mgr.calculate_bonus()}") # $10000
Polymorphism allows objects of different classes to be treated uniformly through a common interface.
Different objects can respond to the same method call in their own way.
class Shape:
def area(self):
pass
def perimeter(self):
pass
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
class Rectangle(Shape):
def __init__(self, length, width):
self.length = length
self.width = width
def area(self):
return self.length * self.width
def perimeter(self):
return 2 * (self.length + self.width)
# Polymorphism in action
shapes = [Circle(5), Rectangle(4, 6), Circle(3)]
for shape in shapes:
print(f"Area: {shape.area():.2f}") # Same method call, different behavior
Understanding OOP is fundamental to modern programming. You will see these concepts in:
Next Steps: Practice creating your own classes, experiment with inheritance, and start thinking about the "things" in your programs as objects!