1

Object-Oriented Programming in Python

A Beginner's Guide to Classes and Objects

2

Course Overview

Today we'll explore the fundamentals of Object-Oriented Programming (OOP) in Python:

  • Why OOP? Understanding the problems it solves
  • Core Concepts: Classes, Objects, Attributes, and Methods
  • The Four Pillars: Encapsulation, Abstraction, Inheritance, and Polymorphism
  • Practical Examples: Building real-world applications
  • Best Practices: Writing clean, maintainable code

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.

3

Learning Objectives

After this lecture, you will be able to:

  • Explain the difference between procedural and object-oriented programming
  • Define classes and create objects (instances) from them
  • Understand the purpose of the __init__ method and self parameter
  • Create and use instance attributes and methods
  • Apply the four pillars of OOP in Python code
  • Design simple class hierarchies using inheritance
  • Write more organized and maintainable code using OOP principles
4

Programming Paradigms

A programming paradigm is a fundamental style or approach to programming. Python supports multiple paradigms:

  • Procedural Programming: Code organized as a sequence of functions
  • Functional Programming: Code organized around mathematical functions
  • Object-Oriented Programming: Code organized around objects and classes
# 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}")
5

The Challenge: Managing Data

Imagine you need to store information about several dogs. Without objects, you might use separate lists for each piece of information.

  • This is called using "parallel lists".
  • The data is scattered and disconnected.
  • What if you want to add a third attribute, like `dog_breeds`? You have to add another list.
  • Deleting "Rex" would be a nightmare—you have to find his index and remove him from every single list.
# 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.
6

More Problems with Parallel Lists

As your program grows, parallel lists become increasingly problematic:

  • Error-prone: Easy to accidentally misalign data
  • Hard to maintain: Adding new attributes requires modifying multiple places
  • No data validation: Nothing prevents inconsistent data
# 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]}")
7

A Better Way: Objects

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 data for "Fido" is now neatly contained in a single `Dog` object.
  • Everything is in one place!
# 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.
8

Why Use Object-Oriented Programming?

OOP makes your code better in several key ways:

  • Organization & Scalability: It bundles related data and behaviors into logical units. Creating thousands of dogs is as easy as creating one.

  • Data Consistency: All data for an object is managed together, preventing inconsistencies when items are modified or deleted.

  • Reusability: Once a class is defined, it can be reused multiple times to create many objects without rewriting code.

  • Intuitive Modeling: It allows you to model real-world things (like a Dog, a Car, a Student) in your code, making it easier to understand.
9

The Four Pillars of OOP

OOP is built on four fundamental concepts. We will explore these throughout the lecture.

  • Encapsulation: Bundling data and the methods that operate on that data into a single unit or "capsule".

  • Abstraction: Hiding complex implementation details and exposing only the necessary functionalities.

  • Inheritance: Allowing a new class to adopt the properties and methods of an existing class.

  • Polymorphism: Allowing objects of different classes to be treated as objects of a common super class. (e.g., a `Cat` and a `Dog` can both `speak()`).
10

The Class is a Blueprint

  • A Class is a blueprint that defines the properties (attributes) and behaviors (methods) of a certain type of thing.
  • An Object is an actual instance created from that blueprint.
  • The class defines what you can do with its objects. For example, the `str` class defines that you can call `.upper()` on string objects.
11

Real-World Analogy: Class vs. Object

Think of a cookie cutter and the cookies it makes.

  • The Class is the Cookie Cutter.
    • It defines the shape and properties (e.g., a "star-shaped cutter").
    • It doesn't have any actual dough or flavor. It's just the plan.

  • An Object is the Cookie.
    • It's a real instance made from the cutter.
    • Each cookie is a distinct object, with its own specific attributes (e.g., this one is burnt, that one has sprinkles).

You can make many unique cookies (objects) from a single cutter (class).

12

Objects are Everywhere

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: 
13

Class Defines Behavior

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'
14

What are Methods?

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'
15

Example: Built-in Object Methods

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
16

Creating Your Own Class

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!")
17

The `self` Parameter Explained

The `self` parameter is a reference to the specific object instance that a method is being called on.

  • It's how a method knows which dog is barking or which student's grade to get.
  • Python passes it automatically (invisibly) as the first argument when you call a method like `my_dog.bark()`.
  • You must include it as the first parameter in your method definitions.
  • Forgetting `self` causes the error: `takes 0 positional arguments but 1 was given` because Python tried to pass the instance, but your method wasn't set up to receive it.
18

Instantiating an Object

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!
19

The `__init__` Method (The Constructor)

The __init__ method (short for "initialize") is a special method, often called a "constructor".

  • It is automatically called whenever you create a new instance of the class.
  • It's used to set up the object's initial state and define its attributes.
  • The values you pass when creating the object (e.g., 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.
20

Attributes: Storing an Object's Data

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
21

Example: Rectangle Class

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
22

Core Concept: Encapsulation

Encapsulation is the bundling of data (attributes) and methods that operate on the data into a single object.

Analogy: A Car

  • A car encapsulates a huge amount of complexity (engine, electronics, transmission).
  • You don't interact with the engine pistons directly. You use the public interface: the steering wheel, pedals, and gear stick.
  • The class (`Car`) protects its internal data and allows access only through methods (`turn_wheel()`, `press_accelerator()`).

Our `Dog` class encapsulates the `name` and `age` with methods like `get_info()`.

23

Core Concept: Abstraction

Abstraction means hiding the complexity and showing only the essential features of the object.

Analogy: A TV Remote

  • When you press the "Volume Up" button, you don't need to know about the circuits, infrared signals, or how the speaker's amplifier works.
  • The complex details are abstracted away. You are given a simple interface to work with.

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!

24

Another Example: The `Car` Class

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
25

Example: Bank Account Class

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
1

Understanding key=lambda Pattern

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']
2

Advanced key=lambda Examples

You can use lambda functions for complex comparisons:

  • Multiple criteria using tuples
  • Nested data access
  • Mathematical operations
  • String transformations

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']
3

Practice: key=lambda Challenges

Try to solve these problems using key=lambda:

  1. Find the shortest movie title
  2. Sort movies by year (ascending)
  3. Find the movie with the highest rating
  4. Sort by rating descending, then by title alphabetically
  5. Find the oldest movie (lowest year)

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']})")
26

Example: Interacting Classes

Let's model a more complex system to see the power of classes interacting.

We will create:

  1. A Student class to hold information about an individual student.
  2. A 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.

27

The `Student` 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'
28

The `Course` Class (Part 1)

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)
29

The `Course` Class (Part 2)

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())
30

Using the Student-Course System

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
31

Core Concept: Inheritance

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!
32

Example: Employee Hierarchy

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
33

Core Concept: Polymorphism

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
34

Key Terminology Recap

  • Class: A blueprint for creating objects.
  • Object/Instance: A specific entity created from a class.
  • Attribute: A variable stored on an object (`self.name`).
  • Method: A function defined within a class that acts on an object.
  • Encapsulation: Bundling data and methods together in a class.
  • Abstraction: Hiding complex details behind a simple interface.
  • Inheritance: Creating a new class from an existing one ("is-a" relationship).
  • Polymorphism: Different objects responding to the same method call in their own way.
35

OOP Best Practices

  • Use CamelCase for class names: `StudentRecord`, not `student_record`
  • Use descriptive names: `BankAccount` is better than `Account`
  • Keep classes focused: Each class should have a single, clear responsibility
  • Use inheritance wisely: Only when there's a true "is-a" relationship
  • Document your classes: Use docstrings to explain what each class does
  • Initialize all attributes in `__init__`: Don't create attributes in other methods
  • Use private attributes when needed: Prefix with underscore (`_private_attr`)
36

OOP is Everywhere in Software

Understanding OOP is fundamental to modern programming. You will see these concepts in:

  • Web Frameworks: Django and Flask use classes to represent web pages, database models, and forms.
  • Game Development: Pygame uses classes for characters (`Player`, `Enemy`), items, and game levels.
  • Data Science: Libraries like Scikit-Learn use classes to represent machine learning models (`LinearRegression`, `RandomForestClassifier`).
  • Graphical User Interfaces (GUIs): Tkinter and PyQt use classes for windows, buttons, and other widgets.
  • APIs and Microservices: FastAPI and Flask use classes to organize endpoints and business logic.
37

Final Review & Takeaways

  1. Problem Solving: We started with a problem (parallel lists) and saw how objects provide a clean, organized solution.
  2. Core Concepts: We learned to create our own classes (blueprints) with attributes (data) and methods (behaviors).
  3. Four Pillars: We explored Encapsulation, Abstraction, Inheritance, and Polymorphism.
  4. Practical Applications: We built real-world examples from simple dogs to complex employee hierarchies.
  5. Best Practices: We learned how to write clean, maintainable object-oriented code.

Next Steps: Practice creating your own classes, experiment with inheritance, and start thinking about the "things" in your programs as objects!