Lectures

Object Oriented Programming (OOP) in Python

Classes and Objects

What is OOP?

So far you've written procedural code — a list of instructions that run one by one, with functions to organize them.

OOP is a completely different way of thinking about code. Instead of writing instructions, you model real world things as objects in your code.


Real World Analogy — The Blueprint

Think of a class as a blueprint and an object as the actual thing built from that blueprint.

Blueprint of a Car (Class)
- Has: color, brand, speed, fuel
- Can: start, stop, accelerate, brake

Actual Car 1 (Object): Red Toyota, speed=0
Actual Car 2 (Object): Blue Honda, speed=60
Actual Car 3 (Object): Black BMW, speed=120

One blueprint — many cars. Each car has the same properties and abilities but different values.

Same in Python:

Student Class (Blueprint)
- Has: name, age, marks
- Can: introduce(), get_grade(), study()

student1 = Student("Rahul", 20, 85)
student2 = Student("Priya", 21, 92)
student3 = Student("Gagan", 22, 78)

Your First Class


    class Dog:
        def __init__(self, name, breed, age):
            self.name = name
            self.breed = breed
            self.age = age

        def bark(self):
            print(f"{self.name} says: Woof!")

        def introduce(self):
            print(f"I am {self.name}, a {self.breed}, {self.age} years old")

Let's break this down piece by piece.


class keyword

class Dog:

class tells Python you're defining a class. Dog is the class name. Convention — class names start with Capital Letter.


__init__ — The Constructor


    def __init__(self, name, breed, age):
        self.name = name
        self.breed = breed
        self.age = age

__init__ is a special method that runs automatically when you create an object. It's called the constructor — it initializes (sets up) the object.

self — refers to the object being created. When you create dog1, self means dog1. When you create dog2, self means dog2.

self.name = name — stores the name parameter as an attribute of the object. Attributes are variables that belong to the object.


Creating Objects


    dog1 = Dog("Bruno", "Labrador", 3)
    dog2 = Dog("Max", "German Shepherd", 5)
    dog3 = Dog("Charlie", "Poodle", 2)

This is called instantiation — creating an instance (object) of a class.

Each object is completely independent with its own data.


Accessing Attributes and Calling Methods


    dog1 = Dog("Bruno", "Labrador", 3)

    # Accessing attributes
    print(dog1.name)       # Bruno
    print(dog1.breed)      # Labrador
    print(dog1.age)        # 3

    # Calling methods
    dog1.bark()            # Bruno says: Woof!
    dog1.introduce()       # I am Bruno, a Labrador, 3 years old


Full Example — All Three Dogs


    class Dog:
        def __init__(self, name, breed, age):
            self.name = name
            self.breed = breed
            self.age = age

        def bark(self):
            print(f"{self.name} says: Woof!")

        def introduce(self):
            print(f"I am {self.name}, a {self.breed}, {self.age} years old")

        def birthday(self):
            self.age += 1
            print(f"Happy Birthday {self.name}! Now {self.age} years old")


    dog1 = Dog("Bruno", "Labrador", 3)
    dog2 = Dog("Max", "German Shepherd", 5)
    dog3 = Dog("Charlie", "Poodle", 2)

    dog1.introduce()
    dog2.introduce()
    dog3.introduce()

    print()

    dog1.bark()
    dog2.bark()

    print()

    dog1.birthday()
    print(f"Bruno's age is now: {dog1.age}")    # 4
    print(f"Max's age is still: {dog2.age}")    # 5 — unchanged

Output:

I am Bruno, a Labrador, 3 years old
I am Max, a German Shepherd, 5 years old
I am Charlie, a Poodle, 2 years old

Bruno says: Woof!
Max says: Woof!

Happy Birthday Bruno! Now 4 years old
Bruno's age is now: 4
Max's age is still: 5

Methods vs Functions

A function is defined outside a class with def:


    def greet(name):
        print(f"Hello {name}")

A method is a function defined inside a class. It always has self as first parameter:


    class Person:
        def greet(self):          # method — belongs to class
            print(f"Hello {self.name}")

They work the same way but methods are tied to a specific object.


self Explained Simply

self is just a reference to the current object. When you call:

dog1.bark()

Python internally does:

Dog.bark(dog1)    # passes dog1 as self

So inside bark(), when you write self.name — it means dog1.name. Simple.

You could technically name it anything but self is the universal convention. Always use self.


Real World Class — Bank Account


    class BankAccount:
        def __init__(self, owner, balance=0):
            self.owner = owner
            self.balance = balance
            self.transactions = []

        def deposit(self, amount):
            if amount <= 0:
                print("Deposit amount must be positive")
                return
            self.balance += amount
            self.transactions.append(f"Deposited: Rs.{amount}")
            print(f"Rs.{amount} deposited. New balance: Rs.{self.balance}")

        def withdraw(self, amount):
            if amount <= 0:
                print("Withdrawal amount must be positive")
                return
            if amount > self.balance:
                print(f"Insufficient balance. Available: Rs.{self.balance}")
                return
            self.balance -= amount
            self.transactions.append(f"Withdrawn: Rs.{amount}")
            print(f"Rs.{amount} withdrawn. New balance: Rs.{self.balance}")

        def get_balance(self):
            print(f"\nAccount: {self.owner}")
            print(f"Balance: Rs.{self.balance}")

        def get_statement(self):
            print(f"\n=== Statement for {self.owner} ===")
            if not self.transactions:
                print("No transactions yet")
            else:
                for t in self.transactions:
                    print(f"  {t}")
            print(f"Current Balance: Rs.{self.balance}")


    # Create two accounts
    acc1 = BankAccount("Gagan", 1000)
    acc2 = BankAccount("Rahul")          # starts with 0

    acc1.deposit(5000)
    acc1.withdraw(2000)
    acc1.withdraw(10000)                  # should fail — insufficient balance
    acc1.get_statement()

    print()

    acc2.deposit(3000)
    acc2.get_balance()

Output:

Rs.5000 deposited. New balance: Rs.6000
Rs.2000 withdrawn. New balance: Rs.4000
Insufficient balance. Available: Rs.4000

=== Statement for Gagan ===
  Deposited: Rs.5000
  Withdrawn: Rs.2000
Current Balance: Rs.4000

Rs.3000 deposited. New balance: Rs.3000

Account: Rahul
Balance: Rs.3000

This is real OOP — data (balance, transactions) and behavior (deposit, withdraw) bundled together in one object.


Class Attributes vs Instance Attributes

Instance attributes — unique to each object (what we've been using):

self.name = name     # each dog has its own name

Class attributes — shared by ALL objects of the class:


    class Dog:
        species = "Canis familiaris"    # class attribute — same for all dogs

        def __init__(self, name):
            self.name = name            # instance attribute — different per dog

    dog1 = Dog("Bruno")
    dog2 = Dog("Max")

    print(dog1.species)     # Canis familiaris
    print(dog2.species)     # Canis familiaris
    print(dog1.name)        # Bruno
    print(dog2.name)        # Max

    print(Dog.species)      # can also access via class name


__str__ — String Representation

When you print an object directly you get something ugly:


    dog1 = Dog("Bruno", "Labrador", 3)
    print(dog1)    # <__main__.Dog object at 0x7f...> — ugly!

Define __str__ to control what prints:


    class Dog:
        def __init__(self, name, breed, age):
            self.name = name
            self.breed = breed
            self.age = age

        def __str__(self):
            return f"Dog({self.name}, {self.breed}, {self.age} years)"

    dog1 = Dog("Bruno", "Labrador", 3)
    print(dog1)    # Dog(Bruno, Labrador, 3 years)

__str__ is called automatically when you use print() or str() on your object. Always define it — makes debugging much easier.


Inheritance — One Class Extending Another

Inheritance lets you create a new class based on an existing one — it inherits all attributes and methods of the parent class.


    # Parent class
    class Animal:
        def __init__(self, name, age):
            self.name = name
            self.age = age

        def eat(self):
            print(f"{self.name} is eating")

        def sleep(self):
            print(f"{self.name} is sleeping")

        def __str__(self):
            return f"{self.name} (age {self.age})"


    # Child class — inherits from Animal
    class Dog(Animal):
        def __init__(self, name, age, breed):
            super().__init__(name, age)    # call parent's __init__
            self.breed = breed             # add extra attribute

        def bark(self):
            print(f"{self.name} says: Woof!")

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


    class Cat(Animal):
        def __init__(self, name, age, indoor):
            super().__init__(name, age)
            self.indoor = indoor

        def meow(self):
            print(f"{self.name} says: Meow!")

        def purr(self):
            print(f"{self.name} is purring...")


    dog = Dog("Bruno", 3, "Labrador")
    cat = Cat("Whiskers", 2, True)

    # Dog has Animal methods AND its own methods
    dog.eat()          # from Animal — Bruno is eating
    dog.sleep()        # from Animal — Bruno is sleeping
    dog.bark()         # from Dog — Bruno says: Woof!
    dog.fetch()        # from Dog — Bruno fetches the ball!

    print()

    cat.eat()          # from Animal
    cat.meow()         # from Cat
    cat.purr()         # from Cat

    print(dog)         # Bruno (age 3)
    print(cat)         # Whiskers (age 2)

Output:

Bruno is eating
Bruno is sleeping
Bruno says: Woof!
Bruno fetches the ball!

Whiskers is eating
Whiskers says: Meow!
Whiskers is purring...
Bruno (age 3)
Whiskers (age 2)

super() Explained


    class Dog(Animal):
        def __init__(self, name, age, breed):
            super().__init__(name, age)    # runs Animal's __init__
            self.breed = breed

super() calls the parent class. Without super().__init__(), the name and age attributes from Animal would never get set. Always call super().__init__() in child class constructor.


Overriding Methods

Child class can override (replace) parent's method:


    class Animal:
        def speak(self):
            print("Some sound")

    class Dog(Animal):
        def speak(self):                    # overrides Animal's speak
            print(f"{self.name} says: Woof!")

    class Cat(Animal):
        def speak(self):                    # overrides Animal's speak
            print(f"{self.name} says: Meow!")

    class Duck(Animal):
        def speak(self):                    # overrides Animal's speak
            print(f"{self.name} says: Quack!")

    animals = [Dog("Bruno", 3, "Lab"), Cat("Whiskers", 2, True), Duck("Donald", 4)]

    # Same method call — different behavior based on object type
    # This is called Polymorphism
    for animal in animals:
        animal.speak()

Output:

Bruno says: Woof!
Whiskers says: Meow!
Donald says: Quack!

This is Polymorphism — same method name, different behavior depending on the object. One of the core OOP concepts.


Encapsulation — Protecting Data

Encapsulation means hiding internal data and only exposing what's necessary.

In Python, use _ (single underscore) to indicate "private" — don't access directly:


    class BankAccount:
        def __init__(self, owner, balance):
            self.owner = owner
            self._balance = balance        # _ means "private, don't touch directly"

        def get_balance(self):             # controlled access
            return self._balance

        def deposit(self, amount):
            if amount > 0:
                self._balance += amount    # controlled modification

    acc = BankAccount("Gagan", 1000)

    # Bad practice — accessing private attribute directly
    print(acc._balance)    # works but you shouldn't do this

    # Good practice — use the method
    print(acc.get_balance())    # 1000
    acc.deposit(500)
    print(acc.get_balance())    # 1500

Python doesn't strictly enforce private attributes like some other languages but the _ convention signals to other developers "don't access this directly."


Real World Example — Student Management System with OOP


    class Student:
        student_count = 0    # class attribute — tracks total students

        def __init__(self, name, age, email):
            self.name = name
            self.age = age
            self.email = email
            self.marks = []
            self.student_id = Student.student_count + 1
            Student.student_count += 1

        def add_marks(self, subject, mark):
            self.marks.append({"subject": subject, "mark": mark})

        def get_average(self):
            if not self.marks:
                return 0
            total = sum(m["mark"] for m in self.marks)
            return total / len(self.marks)

        def get_grade(self):
            avg = self.get_average()
            if avg >= 90: return "A"
            elif avg >= 80: return "B"
            elif avg >= 70: return "C"
            elif avg >= 60: return "D"
            else: return "F"

        def print_report(self):
            print(f"\n{'='*35}")
            print(f"  STUDENT REPORT CARD")
            print(f"{'='*35}")
            print(f"  ID     : {self.student_id}")
            print(f"  Name   : {self.name}")
            print(f"  Age    : {self.age}")
            print(f"  Email  : {self.email}")
            print(f"{'='*35}")
            if self.marks:
                for m in self.marks:
                    print(f"  {m['subject']:<15}: {m['mark']}")
                print(f"{'='*35}")
                print(f"  Average : {self.get_average():.2f}")
                print(f"  Grade   : {self.get_grade()}")
            else:
                print("  No marks added yet")
            print(f"{'='*35}")

        def __str__(self):
            return f"Student({self.student_id}: {self.name}, Grade: {self.get_grade()})"


    class Classroom:
        def __init__(self, class_name):
            self.class_name = class_name
            self.students = []

        def add_student(self, student):
            self.students.append(student)
            print(f"{student.name} added to {self.class_name}")

        def find_student(self, name):
            for student in self.students:
                if student.name.lower() == name.lower():
                    return student
            return None

        def get_top_student(self):
            if not self.students:
                return None
            return max(self.students, key=lambda s: s.get_average())

        def print_summary(self):
            print(f"\n=== {self.class_name} Summary ===")
            print(f"Total students: {len(self.students)}")
            for student in self.students:
                print(f"  {student}")


    # Using the system
    classroom = Classroom("Python Batch 2025")

    s1 = Student("Rahul", 20, "rahul@email.com")
    s2 = Student("Priya", 21, "priya@email.com")
    s3 = Student("Gagan", 22, "gagan@email.com")

    classroom.add_student(s1)
    classroom.add_student(s2)
    classroom.add_student(s3)

    s1.add_marks("Python", 85)
    s1.add_marks("Math", 78)
    s1.add_marks("English", 82)

    s2.add_marks("Python", 92)
    s2.add_marks("Math", 95)
    s2.add_marks("English", 88)

    s3.add_marks("Python", 78)
    s3.add_marks("Math", 72)
    s3.add_marks("English", 80)

    s1.print_report()
    s2.print_report()

    classroom.print_summary()

    top = classroom.get_top_student()
    print(f"\nTop student: {top.name} with average {top.get_average():.2f}")
    print(f"Total students enrolled: {Student.student_count}")

Output:

Rahul added to Python Batch 2025
Priya added to Python Batch 2025
Gagan added to Python Batch 2025

===================================
  STUDENT REPORT CARD
===================================
  ID     : 1
  Name   : Rahul
  Age    : 20
  Email  : rahul@email.com
===================================
  Python         : 85
  Math           : 78
  English        : 82
===================================
  Average : 81.67
  Grade   : B
===================================

...

=== Python Batch 2025 Summary ===
Total students: 3
  Student(1: Rahul, Grade: B)
  Student(2: Priya, Grade: A)
  Student(3: Gagan, Grade: C)

Top student: Priya with average 91.67
Total students enrolled: 3

Four Pillars of OOP — Summary

Pillar

Meaning

Example

Encapsulation

Bundle data and methods together, hide internals

_balance in BankAccount

Inheritance

Child class extends parent class

Dog extends Animal

Polymorphism

Same method, different behavior

speak() in Dog, Cat, Duck

Abstraction

Hide complexity, show only what's needed

deposit() hides balance logic


Exercise 🏋️

Build a Library Management System using OOP:

Create these classes:

Book class:

  • Attributes: title, author, isbn, is_available (default True)
  • Methods: __str__, borrow(), return_book()

Member class:

  • Attributes: name, member_id, borrowed_books (list)
  • Methods: borrow_book(book), return_book(book), print_profile()

Library class:

  • Attributes: name, books (list), members (list)
  • Methods: add_book(book), register_member(member), search_book(title), print_catalog()

Requirements:

  • Member cannot borrow a book that's already borrowed
  • Member cannot borrow more than 3 books at once
  • When book is returned, it becomes available again
  • print_catalog() shows all books with availability status

This is a real system — using everything from all 9 stages together!


No comments:

Post a Comment