Python Lecture 11: Introduction to Object-Oriented Programming
Welcome to a transformative lecture that will completely change how you think about programming! Until now, you've been writing procedural code - a series of functions that manipulate data. Today, we're entering the world of Object-Oriented Programming (OOP) - a paradigm that organizes code around objects that combine data and behavior. OOP is not just a different way to write code; it's a fundamentally different way to model and solve problems.
Object-Oriented Programming is the dominant programming paradigm in modern software development. Languages like Python, Java, C++, and JavaScript are all object-oriented. Understanding OOP is essential for: reading and writing professional code, using frameworks and libraries (which are all built with OOP), designing large systems, collaborating with other developers, and advancing your programming career. This is one of the most important concepts you'll learn in programming.
By the end of this comprehensive lecture, you'll understand what objects and classes are, why OOP matters, how to create and use your own objects, and how OOP makes code more organized, reusable, and maintainable. We'll cover the fundamental concepts with detailed explanations and real-world examples. Let's begin this crucial journey into object-oriented thinking!
Understanding Objects - The Foundation of OOP
Before we write any code, we need to understand the conceptual foundation of OOP. An object is a bundle of related data (attributes) and functions (methods) that work with that data. Objects model real-world entities or concepts, making code more intuitive and organized.
The Real-World Analogy: Think about a car. A car has properties (color, model, speed, fuel level) and behaviors (accelerate, brake, turn, refuel). In procedural programming, you might have separate variables for color, speed, and fuel, plus separate functions for accelerate() and brake(). In OOP, you create a Car object that bundles all these properties and behaviors together. This mirrors how we think about real-world entities.
Why Objects Matter: Objects provide organization, encapsulation, and abstraction. Instead of having hundreds of scattered variables and functions, you group related data and functionality together. A BankAccount object contains balance, account_number, and methods like deposit() and withdraw(). This organization makes code easier to understand, maintain, and extend. When you need to add a new feature, you know exactly where it belongs.
Attributes vs Methods: Attributes (also called properties or instance variables) are the data associated with an object - the car's color, a person's age, an account's balance. Methods are functions that belong to an object and typically operate on its attributes - the car's accelerate() method changes its speed attribute. Understanding this distinction is crucial for OOP thinking.
Classes - The Blueprint for Objects
A class is a blueprint or template for creating objects. The class defines what attributes and methods objects of that type will have, but doesn't create actual objects. Think of a class as a cookie cutter and objects as the cookies - one cookie cutter (class) can create many cookies (objects), all with the same shape but potentially different decorations.
Class vs Object - The Critical Distinction: The class is the definition; objects are instances. The class "Dog" defines that all dogs have names, ages, and can bark(). When you create specific dogs (Buddy, Max, Bella), those are objects - instances of the Dog class. Each object has its own specific values for name and age, but they all share the same structure defined by the class.
The __init__ Method - The Constructor: The __init__ method (called a constructor) is special - it runs automatically when you create a new object. This is where you initialize the object's attributes with specific values. Every object needs to be initialized, so understanding __init__ is essential for creating classes.
# Defining a simple class
class Dog:
"""A simple class representing a dog"""
def __init__(self, name, age):
"""Initialize dog with name and age"""
self.name = name
self.age = age
def bark(self):
"""Make the dog bark"""
return f"{self.name} says Woof!"
def get_age_in_dog_years(self):
"""Calculate dog's age in dog years"""
return self.age * 7
# Creating objects (instances) of the Dog class
buddy = Dog("Buddy", 3)
max_dog = Dog("Max", 5)
# Accessing attributes
print(f"Dog 1: {buddy.name}, Age: {buddy.age}")
print(f"Dog 2: {max_dog.name}, Age: {max_dog.age}")
# Calling methods
print(buddy.bark())
print(f"{max_dog.name} is {max_dog.get_age_in_dog_years()} in dog years")
Understanding 'self': The 'self' parameter appears in every method but might seem mysterious. 'self' refers to the specific object the method is called on. When you write buddy.bark(), Python automatically passes buddy as the first argument (self) to the bark method. This lets methods access the object's own attributes. Without self, methods couldn't access or modify the object's data.
Naming Convention: Class names use PascalCase (capitalize each word: BankAccount, ShoppingCart, UserProfile). This distinguishes classes from functions and variables which use snake_case. Following this convention makes your code professional and readable.
Instance Attributes vs Class Attributes
There are two types of attributes in Python classes: instance attributes (unique to each object) and class attributes (shared by all objects of that class). Understanding when to use each is important for proper class design.
Instance Attributes: These are defined in __init__ using self. Each object gets its own copy. If you change buddy.name, it doesn't affect max_dog.name. Instance attributes store data specific to individual objects - each bank account has its own balance, each user has their own email.
Class Attributes: These are defined at the class level (outside any method) and shared by all instances. They're useful for data that's the same for all objects - all Circle objects might share the value of pi, all BankAccount objects might share the bank's name or interest rate.
# Demonstrating instance and class attributes
class BankAccount:
"""Represents a bank account"""
# Class attribute - shared by all accounts
bank_name = "Python Bank"
interest_rate = 0.02
def __init__(self, owner, balance):
# Instance attributes - unique to each account
self.owner = owner
self.balance = balance
def deposit(self, amount):
"""Deposit money into account"""
self.balance += amount
return f"Deposited ${amount}. New balance: ${self.balance}"
def get_interest(self):
"""Calculate interest on current balance"""
return self.balance * BankAccount.interest_rate
# Creating accounts
account1 = BankAccount("Alice", 1000)
account2 = BankAccount("Bob", 2000)
# Instance attributes are different
print(f"{account1.owner} has ${account1.balance}")
print(f"{account2.owner} has ${account2.balance}")
# Class attributes are the same
print(f"Both accounts at {BankAccount.bank_name}")
print(f"Interest rate: {BankAccount.interest_rate * 100}%")
Encapsulation - Protecting Data
Encapsulation is one of OOP's core principles - bundling data and methods together while controlling access to that data. In Python, we use naming conventions to indicate which attributes/methods are "private" (internal use) vs "public" (external use).
Private Variables (Convention): Variables starting with underscore (_variable) are conventionally private - signals to other programmers "don't access this directly." Variables with double underscore (__variable) are "name-mangled" - Python changes the name to make them harder to access from outside the class. This isn't true privacy (Python doesn't enforce it strictly), but it's a strong signal about intended use.
Why Encapsulation Matters: Encapsulation protects object integrity. If you let anyone change a BankAccount's balance directly, they could set it to any value - even negative! By making balance private and only allowing changes through deposit() and withdraw() methods, you can enforce rules: withdrawals can't exceed balance, amounts must be positive, transactions get logged. This prevents bugs and ensures objects stay in valid states.
# Demonstrating encapsulation
class SecureBankAccount:
"""Bank account with protected balance"""
def __init__(self, owner, initial_balance):
self.owner = owner
self.__balance = initial_balance # Private attribute
def deposit(self, amount):
"""Deposit money - validates amount"""
if amount > 0:
self.__balance += amount
return f"Deposited ${amount}"
else:
return "Invalid deposit amount"
def withdraw(self, amount):
"""Withdraw money - checks sufficient funds"""
if amount > 0:
if amount <= self.__balance:
self.__balance -= amount
return f"Withdrew ${amount}"
else:
return "Insufficient funds"
else:
return "Invalid withdrawal amount"
def get_balance(self):
"""Safe way to check balance"""
return self.__balance
# Using the secure account
account = SecureBankAccount("Charlie", 500)
# Can't directly access private balance
# print(account.__balance) # AttributeError!
# Must use methods
print(account.deposit(100))
print(account.withdraw(50))
print(f"Balance: ${account.get_balance()}")
# Validation prevents invalid operations
print(account.withdraw(1000)) # Insufficient funds
print(account.deposit(-50)) # Invalid amount
Real-World Application - User Authentication: A User class might have a private __password attribute. You don't want code directly accessing or changing passwords. Instead, provide a check_password() method that compares hashed values and a change_password() method that validates the old password before changing. This encapsulation ensures security rules are always enforced.
Methods - Adding Behavior to Objects
Methods are functions defined inside a class. They define what objects can do. Well-designed methods make objects useful and your code intuitive. Understanding different types of methods helps you build better classes.
Instance Methods: These are the most common methods. They take self as the first parameter and can access/modify instance attributes. deposit(), withdraw(), bark() are all instance methods - they work with a specific object's data.
Method Design Principles: Methods should be cohesive (do one thing well) and encapsulate operations on the object's data. A Student class might have calculate_gpa(), is_passing(), and enroll_in_course() methods. Each has a clear, focused purpose. Don't create giant methods that do everything - split functionality into focused methods.
# Complete class with multiple methods
class Student:
"""Represents a student with courses and grades"""
def __init__(self, name, student_id):
self.name = name
self.student_id = student_id
self.courses = {} # Dictionary: course_name -> grade
def enroll(self, course_name):
"""Enroll in a course"""
if course_name not in self.courses:
self.courses[course_name] = None
return f"{self.name} enrolled in {course_name}"
else:
return f"Already enrolled in {course_name}"
def add_grade(self, course_name, grade):
"""Add grade for a course"""
if course_name in self.courses:
if 0 <= grade <= 100:
self.courses[course_name] = grade
return f"Grade {grade} added for {course_name}"
else:
return "Grade must be between 0 and 100"
else:
return f"Not enrolled in {course_name}"
def calculate_gpa(self):
"""Calculate GPA from all grades"""
grades = [g for g in self.courses.values() if g is not None]
if grades:
average = sum(grades) / len(grades)
# Convert to 4.0 scale
if average >= 90: return 4.0
elif average >= 80: return 3.0
elif average >= 70: return 2.0
elif average >= 60: return 1.0
else: return 0.0
return 0.0
def get_transcript(self):
"""Get student transcript"""
transcript = f"\n=== Transcript for {self.name} (ID: {self.student_id}) ===\n"
for course, grade in self.courses.items():
grade_str = f"{grade}%" if grade else "In Progress"
transcript += f"{course}: {grade_str}\n"
transcript += f"GPA: {self.calculate_gpa():.2f}\n"
return transcript
# Using the Student class
student = Student("Emma Wilson", "S12345")
# Enroll in courses
print(student.enroll("Mathematics"))
print(student.enroll("Physics"))
print(student.enroll("Chemistry"))
# Add grades
print(student.add_grade("Mathematics", 95))
print(student.add_grade("Physics", 88))
print(student.add_grade("Chemistry", 92))
# View transcript
print(student.get_transcript())
The __str__ and __repr__ Methods
These special methods (called "dunder" methods - double underscore) control how objects are converted to strings. They're incredibly useful for debugging and user-friendly output.
__str__ Method: Returns a human-readable string representation. Called by str() and print(). Design this for end users - make it informative and readable.
__repr__ Method: Returns an "official" string representation that ideally could recreate the object. Called by repr() and when displaying objects in interactive shells. Design this for developers - include key information for debugging.
# Implementing __str__ and __repr__
class Book:
"""Represents a book"""
def __init__(self, title, author, year):
self.title = title
self.author = author
self.year = year
def __str__(self):
"""User-friendly representation"""
return f'"{self.title}" by {self.author} ({self.year})'
def __repr__(self):
"""Developer-friendly representation"""
return f"Book('{self.title}', '{self.author}', {self.year})"
# Creating books
book1 = Book("1984", "George Orwell", 1949)
book2 = Book("To Kill a Mockingbird", "Harper Lee", 1960)
# __str__ is used by print()
print(book1) # "1984" by George Orwell (1949)
# __repr__ is used in interactive mode
print(repr(book1)) # Book('1984', 'George Orwell', 1949)
# Without __str__, Python uses __repr__
books = [book1, book2]
print(books) # Shows __repr__ for each book
📚 Related Python Tutorials:
Summary and OOP Mastery
Object-Oriented Programming represents a fundamental shift in how we organize code. You've learned:
✓ What objects and classes are conceptually
✓ How to define classes with __init__
✓ Creating and using objects
✓ Instance attributes vs class attributes
✓ Encapsulation and data protection
✓ Designing useful methods
✓ String representation with __str__ and __repr__
Think in Objects: Start thinking about programs as collections of interacting objects rather than sequences of functions. When designing a program, ask: "What are the entities in this problem?" "What data does each entity have?" "What can each entity do?" This object-oriented thinking will make you a better programmer and help you design cleaner, more maintainable systems.
Practice Challenge: Create a Library system with Book and Member classes. Books have title, author, ISBN, and availability status. Members have name, ID, and borrowed books. Implement methods for checking out books, returning books, viewing member's borrowed books, and searching available books. This exercise combines everything you've learned about OOP!

