What is Object-Oriented Programming?

Object-Oriented Programming (OOP) organizes code around objects — bundles of data and behavior that model real-world things. A class is the blueprint; an object is an instance built from that blueprint.

Think of it like this: "Dog" is a class (the concept), while "Rex the golden retriever" is an object (a specific dog).

Creating Your First Class

class Dog:
    def __init__(self, name, breed):
        self.name = name        # instance attribute
        self.breed = breed      # instance attribute

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

    def info(self):             # method
        print(f"{self.name} is a {self.breed}")

# Create objects (instances)
rex = Dog("Rex", "Golden Retriever")
luna = Dog("Luna", "German Shepherd")

rex.bark()       # Rex says: Woof!
luna.info()      # Luna is a German Shepherd
💡
What is self?

self refers to the specific object calling the method. When you write rex.bark(), Python passes rex as self automatically. Every method must have self as its first parameter.

The __init__ Constructor

__init__ runs automatically when you create a new object. Use it to set up the object's initial state:

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

    def deposit(self, amount):
        if amount > 0:
            self.balance += amount
            self.transactions.append(f"+${amount}")
            print(f"Deposited ${amount}. Balance: ${self.balance}")

    def withdraw(self, amount):
        if amount > self.balance:
            print("Insufficient funds!")
            return
        self.balance -= amount
        self.transactions.append(f"-${amount}")
        print(f"Withdrew ${amount}. Balance: ${self.balance}")

    def statement(self):
        print(f"\nAccount: {self.owner}")
        print(f"Balance: ${self.balance}")
        print(f"Transactions: {', '.join(self.transactions)}")

# Usage
account = BankAccount("Alice", 1000)
account.deposit(500)         # Deposited $500. Balance: $1500
account.withdraw(200)        # Withdrew $200. Balance: $1300
account.statement()

Instance vs Class Attributes

class Car:
    # Class attribute — shared by ALL instances
    wheels = 4

    def __init__(self, make, model, year):
        # Instance attributes — unique to each object
        self.make = make
        self.model = model
        self.year = year
        self.mileage = 0

    def drive(self, km):
        self.mileage += km

car1 = Car("Toyota", "Camry", 2024)
car2 = Car("Honda", "Civic", 2023)

print(car1.wheels)      # 4 (from class)
print(car2.wheels)      # 4 (from class)
print(Car.wheels)       # 4 (access via class)

car1.drive(100)
print(car1.mileage)     # 100
print(car2.mileage)     # 0 (each has its own)

Special Methods (Dunder Methods)

Python uses double-underscore methods (dunder methods) to define how objects behave with built-in operations:

class Product:
    def __init__(self, name, price):
        self.name = name
        self.price = price

    def __str__(self):
        """Called by print() and str()"""
        return f"{self.name}: ${self.price:.2f}"

    def __repr__(self):
        """Called in debugger and interactive shell"""
        return f"Product('{self.name}', {self.price})"

    def __eq__(self, other):
        """Called by == operator"""
        return self.name == other.name and self.price == other.price

    def __lt__(self, other):
        """Called by < operator (enables sorting)"""
        return self.price < other.price

items = [
    Product("Laptop", 999.99),
    Product("Mouse", 29.99),
    Product("Keyboard", 79.99)
]

print(items[0])              # Laptop: $999.99
items.sort()                 # Sorts by price (uses __lt__)
for item in items:
    print(item)              # Mouse, Keyboard, Laptop

Encapsulation

Encapsulation means hiding internal details and controlling access to data:

class User:
    def __init__(self, username, password):
        self.username = username
        self._password = password    # Convention: "private" (one underscore)

    def check_password(self, attempt):
        return attempt == self._password

    def change_password(self, old, new):
        if self.check_password(old):
            self._password = new
            print("Password changed!")
        else:
            print("Wrong password!")

user = User("alice", "secret123")
user.check_password("secret123")     # True
user.change_password("secret123", "newpass456")
💡
Python's privacy convention

A single underscore (_name) signals "don't access this directly." A double underscore (__name) triggers name mangling for stronger protection. Neither truly prevents access — Python trusts developers.

Practical Example: Task Manager

class Task:
    def __init__(self, title, priority="medium"):
        self.title = title
        self.priority = priority
        self.completed = False

    def complete(self):
        self.completed = True

    def __str__(self):
        status = "done" if self.completed else "pending"
        return f"[{status}] {self.title} ({self.priority})"


class TaskManager:
    def __init__(self):
        self.tasks = []

    def add(self, title, priority="medium"):
        task = Task(title, priority)
        self.tasks.append(task)
        print(f"Added: {title}")

    def complete(self, title):
        for task in self.tasks:
            if task.title == title:
                task.complete()
                print(f"Completed: {title}")
                return
        print(f"Task not found: {title}")

    def show(self, show_completed=True):
        for task in self.tasks:
            if show_completed or not task.completed:
                print(f"  {task}")

    def pending_count(self):
        return sum(1 for t in self.tasks if not t.completed)

# Usage
mgr = TaskManager()
mgr.add("Write report", "high")
mgr.add("Buy groceries", "low")
mgr.add("Fix bug #42", "high")
mgr.complete("Write report")

print(f"\nAll tasks:")
mgr.show()
print(f"\nPending: {mgr.pending_count()}")

Summary

  • Classes are blueprints; objects are instances created from classes
  • __init__ initializes new objects; self refers to the current instance
  • Instance attributes belong to each object; class attributes are shared
  • Dunder methods (__str__, __eq__, etc.) customize built-in behavior
  • Use underscore prefix (_name) to signal private attributes
  • Classes bundle related data and behavior together, making code organized and reusable
🎉
OOP fundamentals unlocked!

You can now create your own classes and objects. Next up: inheritance and polymorphism — building class hierarchies and sharing code between related classes.