Object-Oriented Programming in Python: Complete Crash Course



This content originally appeared on DEV Community and was authored by Arslan Yousaf

Table of Contents

  1. What is Object-Oriented Programming?
  2. Classes and Objects
  3. Attributes and Methods
  4. The Four Pillars of OOP
    • Encapsulation
    • Inheritance
    • Polymorphism
    • Abstraction
  5. Special Methods (Magic Methods)
  6. Class vs Instance Variables
  7. Property Decorators
  8. Multiple Inheritance
  9. Composition vs Inheritance
  10. Real-World Examples
  11. Best Practices
  12. Common Mistakes to Avoid

What is Object-Oriented Programming?

Object-Oriented Programming (OOP) is a way of writing code that organizes your program around objects instead of functions. Think of it like building with LEGO blocks – each block (object) has its own properties and can do specific things.

In the real world, everything is an object. Your phone, car, and even you are objects. Each object has:

  • Properties (what it has): A car has color, model, year
  • Methods (what it can do): A car can start, stop, accelerate

OOP helps us write code that mirrors this real-world thinking, making our programs easier to understand, maintain, and expand.

Why Use OOP?

  1. Organization: Keep related code together
  2. Reusability: Write once, use many times
  3. Maintainability: Easier to fix and update
  4. Scalability: Easy to add new features
  5. Real-world modeling: Code matches how we think

Classes and Objects

What is a Class?

A class is like a blueprint or template. It defines what properties and methods objects of that type will have. Think of it as a cookie cutter – it shapes cookies but isn’t a cookie itself.

What is an Object?

An object is an instance of a class. It’s the actual “thing” created from the blueprint. Using our cookie cutter analogy, the object is the actual cookie.

Creating Your First Class

# Define a class
class Dog:
    # This is a method that runs when we create a new dog
    def __init__(self, name, breed, age):
        self.name = name      # Instance variable
        self.breed = breed    # Instance variable
        self.age = age        # Instance variable

    # This is a method that makes the dog bark
    def bark(self):
        return f"{self.name} says Woof!"

    # This is a method that returns dog info
    def get_info(self):
        return f"{self.name} is a {self.age} year old {self.breed}"

Creating Objects (Instances)

# Create objects from the Dog class
dog1 = Dog("Buddy", "Golden Retriever", 3)
dog2 = Dog("Max", "German Shepherd", 5)
dog3 = Dog("Bella", "Poodle", 2)

# Use the objects
print(dog1.bark())        # Output: Buddy says Woof!
print(dog2.get_info())    # Output: Max is a 5 year old German Shepherd
print(dog3.name)          # Output: Bella

The __init__ Method

The __init__ method is special – it’s called automatically when you create a new object. It’s like a constructor that sets up the initial state of your object.

class Car:
    def __init__(self, make, model, year, color="white"):
        self.make = make
        self.model = model
        self.year = year
        self.color = color
        self.is_running = False  # Default value
        self.fuel_level = 100    # Default value

    def start_engine(self):
        if not self.is_running:
            self.is_running = True
            return f"The {self.make} {self.model} is now running!"
        return f"The {self.make} {self.model} is already running!"

    def stop_engine(self):
        if self.is_running:
            self.is_running = False
            return f"The {self.make} {self.model} has been turned off."
        return f"The {self.make} {self.model} is already off."

# Create car objects
my_car = Car("Toyota", "Camry", 2022, "blue")
friend_car = Car("Honda", "Civic", 2021)  # Uses default color

print(my_car.start_engine())    # Output: The Toyota Camry is now running!
print(friend_car.color)         # Output: white (default value)

Attributes and Methods

Instance Attributes vs Class Attributes

Instance attributes belong to specific objects. Each object has its own copy.
Class attributes belong to the class itself and are shared by all objects.

class Student:
    # Class attribute - shared by all students
    school_name = "Python High School"
    total_students = 0

    def __init__(self, name, grade):
        # Instance attributes - unique to each student
        self.name = name
        self.grade = grade
        Student.total_students += 1  # Update class attribute

    def study(self, subject):
        return f"{self.name} is studying {subject}"

    def get_grade(self):
        return f"{self.name} is in grade {self.grade}"

    @classmethod
    def get_school_info(cls):
        return f"Welcome to {cls.school_name}! We have {cls.total_students} students."

# Create student objects
alice = Student("Alice", 10)
bob = Student("Bob", 11)
charlie = Student("Charlie", 9)

# Access instance attributes
print(alice.name)           # Output: Alice
print(bob.grade)            # Output: 11

# Access class attributes
print(Student.school_name)  # Output: Python High School
print(Student.total_students)  # Output: 3

# All instances share class attributes
print(alice.school_name)    # Output: Python High School
print(charlie.school_name)  # Output: Python High School

# Use methods
print(alice.study("Math"))  # Output: Alice is studying Math
print(Student.get_school_info())  # Output: Welcome to Python High School! We have 3 students.

Types of Methods

  1. Instance Methods: Work with instance data
  2. Class Methods: Work with class data
  3. Static Methods: Independent utility functions
class Calculator:
    # Class attribute
    calculation_count = 0

    def __init__(self, name):
        self.name = name
        self.history = []

    # Instance method
    def add(self, a, b):
        result = a + b
        self.history.append(f"{a} + {b} = {result}")
        Calculator.calculation_count += 1
        return result

    # Class method
    @classmethod
    def get_calculation_count(cls):
        return f"Total calculations performed: {cls.calculation_count}"

    # Static method - doesn't need self or cls
    @staticmethod
    def is_even(number):
        return number % 2 == 0

# Usage
calc1 = Calculator("Basic Calculator")
calc2 = Calculator("Scientific Calculator")

print(calc1.add(5, 3))                    # Output: 8
print(calc2.add(10, 20))                  # Output: 30

print(Calculator.get_calculation_count()) # Output: Total calculations performed: 2
print(Calculator.is_even(4))              # Output: True
print(calc1.history)                      # Output: ['5 + 3 = 8']

The Four Pillars of OOP

Encapsulation

Encapsulation means bundling data and methods together and controlling access to them. It’s like having a capsule that protects the inside from the outside.

In Python, we use naming conventions:

  • Public: Normal names (name)
  • Protected: Single underscore (_name) – for internal use
  • Private: Double underscore (__name) – hidden from outside
class BankAccount:
    def __init__(self, account_holder, initial_balance=0):
        self.account_holder = account_holder    # Public
        self._account_number = "ACC123456"      # Protected
        self.__balance = initial_balance        # Private
        self.__pin = "1234"                     # Private

    # Public method to check balance
    def check_balance(self, pin):
        if self.__verify_pin(pin):
            return f"Current balance: ${self.__balance}"
        return "Invalid PIN"

    # Public method to deposit money
    def deposit(self, amount, pin):
        if self.__verify_pin(pin):
            if amount > 0:
                self.__balance += amount
                return f"Deposited ${amount}. New balance: ${self.__balance}"
            return "Deposit amount must be positive"
        return "Invalid PIN"

    # Public method to withdraw money
    def withdraw(self, amount, pin):
        if self.__verify_pin(pin):
            if amount > 0 and amount <= self.__balance:
                self.__balance -= amount
                return f"Withdrew ${amount}. New balance: ${self.__balance}"
            return "Insufficient funds or invalid amount"
        return "Invalid PIN"

    # Private method - only used internally
    def __verify_pin(self, pin):
        return pin == self.__pin

    # Protected method - for internal use
    def _get_account_info(self):
        return f"Account: {self._account_number}, Holder: {self.account_holder}"

# Usage
account = BankAccount("John Doe", 1000)

print(account.check_balance("1234"))     # Output: Current balance: $1000
print(account.deposit(500, "1234"))      # Output: Deposited $500. New balance: $1500
print(account.withdraw(200, "1234"))     # Output: Withdrew $200. New balance: $1300

# These will work but are not recommended
print(account.account_holder)            # Output: John Doe (public)
print(account._account_number)           # Output: ACC123456 (protected)

# This won't work - private attribute
# print(account.__balance)               # AttributeError

Inheritance

Inheritance allows a class to inherit properties and methods from another class. The parent class is called the superclass or base class, and the child class is called the subclass or derived class.

Think of it like genetics – children inherit traits from their parents but can also have their own unique features.

# Parent class (Base class)
class Animal:
    def __init__(self, name, species):
        self.name = name
        self.species = species
        self.is_alive = True

    def eat(self, food):
        return f"{self.name} is eating {food}"

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

    def make_sound(self):
        return f"{self.name} makes a sound"

# Child class inherits from Animal
class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name, "Canine")  # Call parent constructor
        self.breed = breed
        self.loyalty = "High"

    # Override parent method
    def make_sound(self):
        return f"{self.name} barks: Woof!"

    # New method specific to Dog
    def fetch(self, item):
        return f"{self.name} fetches the {item}"

# Another child class
class Cat(Animal):
    def __init__(self, name, indoor=True):
        super().__init__(name, "Feline")
        self.indoor = indoor
        self.independence = "High"

    # Override parent method
    def make_sound(self):
        return f"{self.name} meows: Meow!"

    # New method specific to Cat
    def climb(self, object_name):
        return f"{self.name} climbs the {object_name}"

# Usage
dog = Dog("Buddy", "Golden Retriever")
cat = Cat("Whiskers", True)

# Inherited methods
print(dog.eat("kibble"))        # Output: Buddy is eating kibble
print(cat.sleep())              # Output: Whiskers is sleeping

# Overridden methods
print(dog.make_sound())         # Output: Buddy barks: Woof!
print(cat.make_sound())         # Output: Whiskers meows: Meow!

# Specific methods
print(dog.fetch("ball"))        # Output: Buddy fetches the ball
print(cat.climb("tree"))        # Output: Whiskers climbs the tree

# Inherited attributes
print(dog.species)              # Output: Canine
print(cat.is_alive)             # Output: True

More Complex Inheritance Example

# Base class
class Vehicle:
    def __init__(self, make, model, year):
        self.make = make
        self.model = model
        self.year = year
        self.is_running = False

    def start(self):
        if not self.is_running:
            self.is_running = True
            return f"{self.make} {self.model} started"
        return f"{self.make} {self.model} is already running"

    def stop(self):
        if self.is_running:
            self.is_running = False
            return f"{self.make} {self.model} stopped"
        return f"{self.make} {self.model} is already stopped"

    def get_info(self):
        return f"{self.year} {self.make} {self.model}"

# Intermediate class
class LandVehicle(Vehicle):
    def __init__(self, make, model, year, wheels):
        super().__init__(make, model, year)
        self.wheels = wheels

    def drive(self, distance):
        if self.is_running:
            return f"Driving {distance} miles"
        return "Vehicle must be started first"

# Specific vehicle classes
class Car(LandVehicle):
    def __init__(self, make, model, year, doors=4):
        super().__init__(make, model, year, 4)
        self.doors = doors
        self.trunk_open = False

    def open_trunk(self):
        self.trunk_open = True
        return "Trunk opened"

    def close_trunk(self):
        self.trunk_open = False
        return "Trunk closed"

class Motorcycle(LandVehicle):
    def __init__(self, make, model, year, engine_size):
        super().__init__(make, model, year, 2)
        self.engine_size = engine_size

    def wheelie(self):
        if self.is_running:
            return f"{self.make} {self.model} does a wheelie!"
        return "Start the motorcycle first"

# Usage
car = Car("Toyota", "Camry", 2022)
bike = Motorcycle("Harley", "Sportster", 2023, "883cc")

print(car.get_info())           # Output: 2022 Toyota Camry
print(bike.start())             # Output: Harley Sportster started
print(bike.wheelie())           # Output: Harley Sportster does a wheelie!
print(car.drive("50"))          # Output: Vehicle must be started first

Polymorphism

Polymorphism means “many forms.” It allows objects of different classes to be treated the same way if they have similar methods. Think of it like different animals all being able to “make sound” but each making their own unique sound.

class Shape:
    def __init__(self, name):
        self.name = name

    def area(self):
        pass  # This will be overridden by child classes

    def perimeter(self):
        pass  # This will be overridden by child classes

class Rectangle(Shape):
    def __init__(self, width, height):
        super().__init__("Rectangle")
        self.width = width
        self.height = height

    def area(self):
        return self.width * self.height

    def perimeter(self):
        return 2 * (self.width + self.height)

class Circle(Shape):
    def __init__(self, radius):
        super().__init__("Circle")
        self.radius = radius

    def area(self):
        return 3.14159 * self.radius ** 2

    def perimeter(self):
        return 2 * 3.14159 * self.radius

class Triangle(Shape):
    def __init__(self, base, height, side1, side2):
        super().__init__("Triangle")
        self.base = base
        self.height = height
        self.side1 = side1
        self.side2 = side2

    def area(self):
        return 0.5 * self.base * self.height

    def perimeter(self):
        return self.base + self.side1 + self.side2

# Polymorphism in action
def print_shape_info(shape):
    """This function works with any shape object"""
    print(f"Shape: {shape.name}")
    print(f"Area: {shape.area():.2f}")
    print(f"Perimeter: {shape.perimeter():.2f}")
    print("-" * 20)

# Create different shape objects
rectangle = Rectangle(5, 3)
circle = Circle(4)
triangle = Triangle(6, 4, 5, 7)

# List of different shapes
shapes = [rectangle, circle, triangle]

# Use polymorphism - same function works for all shapes
for shape in shapes:
    print_shape_info(shape)

# Output:
# Shape: Rectangle
# Area: 15.00
# Perimeter: 16.00
# --------------------
# Shape: Circle
# Area: 50.27
# Perimeter: 25.13
# --------------------
# Shape: Triangle
# Area: 12.00
# Perimeter: 18.00
# --------------------

Duck Typing (Python’s Approach to Polymorphism)

In Python, we often use “duck typing” – if it walks like a duck and quacks like a duck, it’s a duck. This means if objects have the same methods, they can be used interchangeably.

class Duck:
    def make_sound(self):
        return "Quack!"

    def swim(self):
        return "Duck swims in water"

class Robot:
    def make_sound(self):
        return "Beep boop!"

    def swim(self):
        return "Robot swims with propellers"

class Dog:
    def make_sound(self):
        return "Woof!"

    def swim(self):
        return "Dog does doggy paddle"

# Function that works with any object that has make_sound and swim methods
def animal_actions(creature):
    print(f"Sound: {creature.make_sound()}")
    print(f"Swimming: {creature.swim()}")
    print()

# All these work even though they're different types
duck = Duck()
robot = Robot()
dog = Dog()

creatures = [duck, robot, dog]

for creature in creatures:
    animal_actions(creature)

Abstraction

Abstraction means hiding complex implementation details and showing only the essential features. It’s like using your TV remote – you don’t need to know how the electronics work inside, you just press buttons.

from abc import ABC, abstractmethod

# Abstract base class
class PaymentProcessor(ABC):
    def __init__(self, merchant_name):
        self.merchant_name = merchant_name

    # Abstract method - must be implemented by child classes
    @abstractmethod
    def process_payment(self, amount):
        pass

    # Abstract method
    @abstractmethod
    def refund_payment(self, transaction_id, amount):
        pass

    # Concrete method - can be used by all child classes
    def send_receipt(self, email, amount):
        return f"Receipt sent to {email} for ${amount}"

# Concrete implementations
class CreditCardProcessor(PaymentProcessor):
    def __init__(self, merchant_name):
        super().__init__(merchant_name)
        self.fee_rate = 0.03  # 3% fee

    def process_payment(self, amount):
        fee = amount * self.fee_rate
        net_amount = amount - fee
        return {
            "status": "success",
            "amount": amount,
            "fee": fee,
            "net_amount": net_amount,
            "method": "Credit Card"
        }

    def refund_payment(self, transaction_id, amount):
        return f"Credit card refund of ${amount} processed for transaction {transaction_id}"

class PayPalProcessor(PaymentProcessor):
    def __init__(self, merchant_name):
        super().__init__(merchant_name)
        self.fee_rate = 0.025  # 2.5% fee

    def process_payment(self, amount):
        fee = amount * self.fee_rate
        net_amount = amount - fee
        return {
            "status": "success",
            "amount": amount,
            "fee": fee,
            "net_amount": net_amount,
            "method": "PayPal"
        }

    def refund_payment(self, transaction_id, amount):
        return f"PayPal refund of ${amount} processed for transaction {transaction_id}"

class BankTransferProcessor(PaymentProcessor):
    def __init__(self, merchant_name):
        super().__init__(merchant_name)
        self.fee_rate = 0.01  # 1% fee

    def process_payment(self, amount):
        fee = amount * self.fee_rate
        net_amount = amount - fee
        return {
            "status": "success",
            "amount": amount,
            "fee": fee,
            "net_amount": net_amount,
            "method": "Bank Transfer"
        }

    def refund_payment(self, transaction_id, amount):
        return f"Bank transfer refund of ${amount} processed for transaction {transaction_id}"

# Usage - client doesn't need to know implementation details
def handle_payment(processor, amount):
    result = processor.process_payment(amount)
    print(f"Payment Method: {result['method']}")
    print(f"Amount: ${result['amount']}")
    print(f"Fee: ${result['fee']:.2f}")
    print(f"Net Amount: ${result['net_amount']:.2f}")
    print()

# Create different processors
credit_processor = CreditCardProcessor("My Store")
paypal_processor = PayPalProcessor("My Store")
bank_processor = BankTransferProcessor("My Store")

processors = [credit_processor, paypal_processor, bank_processor]

# Same interface, different implementations
for processor in processors:
    handle_payment(processor, 100)

Special Methods (Magic Methods)

Special methods (also called magic methods or dunder methods) start and end with double underscores. They allow your objects to work with built-in Python functions and operators.

class Book:
    def __init__(self, title, author, pages):
        self.title = title
        self.author = author
        self.pages = pages

    # String representation for users
    def __str__(self):
        return f"{self.title} by {self.author}"

    # String representation for developers
    def __repr__(self):
        return f"Book('{self.title}', '{self.author}', {self.pages})"

    # Length of the book (number of pages)
    def __len__(self):
        return self.pages

    # Comparison methods
    def __eq__(self, other):
        if isinstance(other, Book):
            return self.title == other.title and self.author == other.author
        return False

    def __lt__(self, other):
        if isinstance(other, Book):
            return self.pages < other.pages
        return False

    def __le__(self, other):
        if isinstance(other, Book):
            return self.pages <= other.pages
        return False

    # Addition (combining books)
    def __add__(self, other):
        if isinstance(other, Book):
            combined_title = f"{self.title} & {other.title}"
            combined_author = f"{self.author} & {other.author}"
            combined_pages = self.pages + other.pages
            return Book(combined_title, combined_author, combined_pages)
        return NotImplemented

    # Indexing support
    def __getitem__(self, page_number):
        if 1 <= page_number <= self.pages:
            return f"Content of page {page_number} of {self.title}"
        raise IndexError("Page number out of range")

# Usage
book1 = Book("1984", "George Orwell", 328)
book2 = Book("Animal Farm", "George Orwell", 112)
book3 = Book("1984", "George Orwell", 328)

# String representations
print(str(book1))          # Output: 1984 by George Orwell
print(repr(book1))         # Output: Book('1984', 'George Orwell', 328)

# Length
print(len(book1))          # Output: 328

# Comparisons
print(book1 == book3)      # Output: True
print(book1 == book2)      # Output: False
print(book2 < book1)       # Output: True

# Addition
combined_book = book1 + book2
print(combined_book.title)  # Output: 1984 & Animal Farm
print(len(combined_book))   # Output: 440

# Indexing
print(book1[1])            # Output: Content of page 1 of 1984

More Magic Methods

class ShoppingCart:
    def __init__(self):
        self.items = {}
        self.discount = 0

    def add_item(self, item, price, quantity=1):
        if item in self.items:
            self.items[item]['quantity'] += quantity
        else:
            self.items[item] = {'price': price, 'quantity': quantity}

    def remove_item(self, item):
        if item in self.items:
            del self.items[item]

    # Make the cart iterable
    def __iter__(self):
        return iter(self.items.items())

    # Boolean evaluation (True if cart has items)
    def __bool__(self):
        return len(self.items) > 0

    # Length (number of different items)
    def __len__(self):
        return len(self.items)

    # Check if item exists in cart
    def __contains__(self, item):
        return item in self.items

    # Get total value
    def __call__(self):
        total = sum(details['price'] * details['quantity'] 
                   for details in self.items.values())
        return total * (1 - self.discount)

    # String representation
    def __str__(self):
        if not self.items:
            return "Empty cart"

        cart_str = "Shopping Cart:\n"
        for item, details in self.items.items():
            cart_str += f"- {item}: ${details['price']} x {details['quantity']}\n"
        cart_str += f"Total: ${self():.2f}"
        return cart_str

# Usage
cart = ShoppingCart()

# Add items
cart.add_item("Apple", 1.50, 5)
cart.add_item("Banana", 0.75, 3)
cart.add_item("Orange", 2.00, 2)

# Boolean evaluation
print(bool(cart))              # Output: True

# Check if item exists
print("Apple" in cart)         # Output: True
print("Grape" in cart)         # Output: False

# Length
print(len(cart))               # Output: 3

# Iteration
for item, details in cart:
    print(f"{item}: {details}")

# Call the cart to get total
cart.discount = 0.1  # 10% discount
print(f"Total: ${cart():.2f}") # Output: Total: $12.15

# String representation
print(cart)

Class vs Instance Variables

Understanding the difference between class and instance variables is crucial for proper OOP design.

class Employee:
    # Class variables - shared by all instances
    company_name = "Tech Corp"
    total_employees = 0
    bonus_rate = 0.05

    def __init__(self, name, salary, department):
        # Instance variables - unique to each instance
        self.name = name
        self.salary = salary
        self.department = department
        self.employee_id = Employee.total_employees + 1

        # Update class variable
        Employee.total_employees += 1

    def get_annual_bonus(self):
        return self.salary * Employee.bonus_rate

    def get_info(self):
        return f"ID: {self.employee_id}, Name: {self.name}, Dept: {self.department}"

    @classmethod
    def set_bonus_rate(cls, new_rate):
        cls.bonus_rate = new_rate

    @classmethod
    def get_company_info(cls):
        return f"{cls.company_name} has {cls.total_employees} employees"

# Create employees
emp1 = Employee("Alice", 50000, "Engineering")
emp2 = Employee("Bob", 45000, "Marketing")
emp3 = Employee("Charlie", 55000, "Engineering")

# Instance variables are unique
print(emp1.name)              # Output: Alice
print(emp2.salary)            # Output: 45000
print(emp3.employee_id)       # Output: 3

# Class variables are shared
print(Employee.total_employees)  # Output: 3
print(emp1.company_name)      # Output: Tech Corp
print(emp2.company_name)      # Output: Tech Corp

# Changing class variable affects all instances
Employee.set_bonus_rate(0.08)
print(emp1.get_annual_bonus())   # Output: 4000.0
print(emp2.get_annual_bonus())   # Output: 3600.0

# Company info
print(Employee.get_company_info())  # Output: Tech Corp has 3 employees

Careful with Mutable Class Variables

class Student:
    # WRONG - mutable class variable
    grades_wrong = []  # This will be shared by ALL students!

    def __init__(self, name):
        self.name = name
        # CORRECT - mutable instance variable
        self.grades = []  # Each student gets their own list

    def add_grade_wrong(self, grade):
        # This modifies the shared class variable
        Student.grades_wrong.append(grade)

    def add_grade(self, grade):
        # This modifies the instance variable
        self.grades.append(grade)

# Demonstrating the problem
student1 = Student("Alice")
student2 = Student("Bob")

# Wrong way - affects all students
student1.add_grade_wrong(95)
student2.add_grade_wrong(87)
print(Student.grades_wrong)    # Output: [95, 87] - Both grades!

# Right way - each student has their own grades
student1.add_grade(95)
student2.add_grade(87)
print(student1.grades)         # Output: [95]
print(student2.grades)         # Output: [87]

Property Decorators

Properties allow you to use methods like attributes. They’re great for validation, computed values, and controlling access to data.

class Temperature:
    def __init__(self, celsius=0):
        self._celsius = celsius

    # Getter property
    @property
    def celsius(self):
        return self._celsius

    # Setter property with validation
    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Temperature cannot be below absolute zero (-273.15°C)")
        self._celsius = value

    # Read-only property (computed value)
    @property
    def fahrenheit(self):
        return (self._celsius * 9/5) + 32

    # Another read-only property
    @property
    def kelvin(self):
        return self._celsius + 273.15

    # Property with getter and setter
    @property
    def fahrenheit_rw(self):
        return (self._celsius * 9/5) + 32

    @fahrenheit_rw.setter
    def fahrenheit_rw(self, value):
        if value < -459.67:  # Absolute zero in Fahrenheit
            raise ValueError("Temperature cannot be below absolute zero (-459.67°F)")
        self._celsius = (value - 32) * 5/9

# Usage
temp = Temperature(25)

# Access like attributes
print(temp.celsius)           # Output: 25
print(temp.fahrenheit)        # Output: 77.0
print(temp.kelvin)           # Output: 298.15

# Set temperature in Celsius
temp.celsius = 100
print(temp.fahrenheit)        # Output: 212.0

# Set temperature in Fahrenheit
temp.fahrenheit_rw = 68
print(temp.celsius)           # Output: 20.0

# Validation works
try:
    temp.celsius = -300       # This will raise an error
except ValueError as e:
    print(f"Error: {e}")      # Output: Error: Temperature cannot be below absolute zero

More Complex Property Example

class Rectangle:
    def __init__(self, width, height):
        self._width = width
        self._height = height

    @property
    def width(self):
        return self._width

    @width.setter
    def width(self, value):
        if value <= 0:
            raise ValueError("Width must be positive")
        self._width = value

    @property
    def height(self):
        return self._height

    @height.setter
    def height(self, value):
        if value <= 0:
            raise ValueError("Height must be positive")
        self._height = value

    @property
    def area(self):
        return self._width * self._height

    @property
    def perimeter(self):
        return 2 * (self._width + self._height)

    @property
    def diagonal(self):
        return (self._width ** 2 + self._height ** 2) ** 0.5

    @property
    def is_square(self):
        return self._width == self._height

    def __str__(self):
        return f"Rectangle({self._width}x{self._height})"

# Usage
rect = Rectangle(4, 6)

print(rect.area)             # Output: 24
print(rect.perimeter)        # Output: 20
print(rect.diagonal)         # Output: 7.211102550927978
print(rect.is_square)        # Output: False

# Change dimensions
rect.width = 5
rect.height = 5
print(rect.is_square)        # Output: True
print(rect.area)             # Output: 25

Multiple Inheritance

Python supports multiple inheritance, where a class can inherit from multiple parent classes. However, use it carefully as it can become complex.

# First parent class
class Flyable:
    def __init__(self):
        self.can_fly = True
        self.altitude = 0

    def take_off(self):
        self.altitude = 100
        return f"Taking off! Altitude: {self.altitude} feet"

    def land(self):
        self.altitude = 0
        return "Landing complete"

    def fly_to_altitude(self, new_altitude):
        self.altitude = new_altitude
        return f"Flying at {self.altitude} feet"

# Second parent class
class Swimmable:
    def __init__(self):
        self.can_swim = True
        self.depth = 0

    def dive(self, depth):
        self.depth = depth
        return f"Diving to {self.depth} feet underwater"

    def surface(self):
        self.depth = 0
        return "Surfacing to water level"

    def swim(self, distance):
        return f"Swimming {distance} meters"

# Child class with multiple inheritance
class Duck(Flyable, Swimmable):
    def __init__(self, name):
        # Call both parent constructors
        Flyable.__init__(self)
        Swimmable.__init__(self)
        self.name = name
        self.species = "Duck"

    def quack(self):
        return f"{self.name} says: Quack!"

    def migrate(self, destination):
        actions = []
        actions.append(self.take_off())
        actions.append(self.fly_to_altitude(5000))
        actions.append(f"Flying to {destination}")
        actions.append(self.land())
        return actions

# Another example of multiple inheritance
class AmphibiousVehicle(Flyable, Swimmable):
    def __init__(self, model, max_speed):
        Flyable.__init__(self)
        Swimmable.__init__(self)
        self.model = model
        self.max_speed = max_speed
        self.engine_on = False

    def start_engine(self):
        self.engine_on = True
        return f"{self.model} engine started"

    def stop_engine(self):
        self.engine_on = False
        return f"{self.model} engine stopped"

# Usage
duck = Duck("Donald")
vehicle = AmphibiousVehicle("Seaplane X1", 200)

# Duck can fly and swim
print(duck.quack())                    # Output: Donald says: Quack!
print(duck.take_off())                 # Output: Taking off! Altitude: 100 feet
print(duck.swim(50))                   # Output: Swimming 50 meters
print(duck.dive(10))                   # Output: Diving to 10 feet underwater

# Vehicle can also fly and swim
print(vehicle.start_engine())          # Output: Seaplane X1 engine started
print(vehicle.take_off())              # Output: Taking off! Altitude: 100 feet
print(vehicle.surface())               # Output: Surfacing to water level

# Migration example
migration_steps = duck.migrate("South for winter")
for step in migration_steps:
    print(step)

Method Resolution Order (MRO)

When using multiple inheritance, Python uses Method Resolution Order to determine which method to call.

class A:
    def method(self):
        return "Method from A"

class B:
    def method(self):
        return "Method from B"

class C(A, B):  # Inherits from both A and B
    pass

class D(B, A):  # Different order
    pass

# Check Method Resolution Order
print(C.__mro__)  # Shows the order Python will search for methods
print(D.__mro__)

# Create instances
c = C()
d = D()

print(c.method())  # Output: Method from A (A comes first in C(A, B))
print(d.method())  # Output: Method from B (B comes first in D(B, A))

Composition vs Inheritance

Sometimes composition (having objects as attributes) is better than inheritance. The rule of thumb: use inheritance for “is-a” relationships and composition for “has-a” relationships.

Inheritance Example (is-a relationship)

# A car IS-A vehicle
class Vehicle:
    def __init__(self, make, model):
        self.make = make
        self.model = model

    def start(self):
        return f"{self.make} {self.model} started"

class Car(Vehicle):  # Car IS-A Vehicle
    def __init__(self, make, model, doors):
        super().__init__(make, model)
        self.doors = doors

Composition Example (has-a relationship)

# A car HAS-A engine, HAS wheels, etc.
class Engine:
    def __init__(self, horsepower, engine_type):
        self.horsepower = horsepower
        self.engine_type = engine_type
        self.is_running = False

    def start(self):
        self.is_running = True
        return f"{self.horsepower}HP {self.engine_type} engine started"

    def stop(self):
        self.is_running = False
        return f"{self.engine_type} engine stopped"

class Wheel:
    def __init__(self, size, tire_type):
        self.size = size
        self.tire_type = tire_type
        self.pressure = 32  # PSI

    def check_pressure(self):
        return f"{self.size}\" {self.tire_type} tire: {self.pressure} PSI"

class GPS:
    def __init__(self):
        self.current_location = "Unknown"
        self.destination = None

    def set_destination(self, location):
        self.destination = location
        return f"Destination set to {location}"

    def navigate(self):
        if self.destination:
            return f"Navigating from {self.current_location} to {self.destination}"
        return "No destination set"

class MusicSystem:
    def __init__(self):
        self.volume = 50
        self.current_song = None
        self.is_playing = False

    def play_song(self, song):
        self.current_song = song
        self.is_playing = True
        return f"Now playing: {song} (Volume: {self.volume})"

    def set_volume(self, volume):
        self.volume = max(0, min(100, volume))
        return f"Volume set to {self.volume}"

# Car class using composition
class Car:
    def __init__(self, make, model, year):
        self.make = make
        self.model = model
        self.year = year

        # Composition - Car HAS these components
        self.engine = Engine(200, "V6")
        self.wheels = [
            Wheel(18, "All-Season"),
            Wheel(18, "All-Season"),
            Wheel(18, "All-Season"),
            Wheel(18, "All-Season")
        ]
        self.gps = GPS()
        self.music_system = MusicSystem()

        self.is_locked = True
        self.fuel_level = 100

    def start_car(self):
        if self.fuel_level > 0:
            result = self.engine.start()
            return f"{self.make} {self.model} ready to drive. {result}"
        return "Cannot start - no fuel"

    def drive_to(self, destination):
        if self.engine.is_running:
            self.gps.set_destination(destination)
            return self.gps.navigate()
        return "Start the car first"

    def play_music(self, song):
        return self.music_system.play_song(song)

    def check_tires(self):
        tire_status = []
        for i, wheel in enumerate(self.wheels, 1):
            tire_status.append(f"Tire {i}: {wheel.check_pressure()}")
        return tire_status

    def get_car_info(self):
        return {
            "car": f"{self.year} {self.make} {self.model}",
            "engine": f"{self.engine.horsepower}HP {self.engine.engine_type}",
            "fuel": f"{self.fuel_level}%",
            "engine_running": self.engine.is_running
        }

# Usage
my_car = Car("Toyota", "Camry", 2023)

# Start the car
print(my_car.start_car())
# Output: Toyota Camry ready to drive. 200HP V6 engine started

# Use GPS
print(my_car.drive_to("Downtown"))
# Output: Navigating from Unknown to Downtown

# Play music
print(my_car.play_music("Bohemian Rhapsody"))
# Output: Now playing: Bohemian Rhapsody (Volume: 50)

# Check tires
tire_status = my_car.check_tires()
for status in tire_status:
    print(status)

# Car info
info = my_car.get_car_info()
for key, value in info.items():
    print(f"{key}: {value}")

Benefits of Composition

  1. Flexibility: Easy to change components
  2. Reusability: Components can be used in other classes
  3. Testability: Each component can be tested separately
  4. Maintainability: Changes to one component don’t affect others

Real-World Examples

Let’s build a complete library management system to see OOP in action.

from datetime import datetime, timedelta
from abc import ABC, abstractmethod

# Base class for all library items
class LibraryItem(ABC):
    def __init__(self, title, item_id, author_or_creator):
        self.title = title
        self.item_id = item_id
        self.author_or_creator = author_or_creator
        self.is_available = True
        self.borrowed_by = None
        self.due_date = None

    @abstractmethod
    def get_item_type(self):
        pass

    @abstractmethod
    def get_borrowing_period(self):
        pass

    def borrow(self, member):
        if self.is_available:
            self.is_available = False
            self.borrowed_by = member
            self.due_date = datetime.now() + timedelta(days=self.get_borrowing_period())
            return True
        return False

    def return_item(self):
        self.is_available = True
        self.borrowed_by = None
        self.due_date = None

    def is_overdue(self):
        if self.due_date:
            return datetime.now() > self.due_date
        return False

    def __str__(self):
        status = "Available" if self.is_available else f"Borrowed by {self.borrowed_by.name}"
        return f"{self.get_item_type()}: {self.title} by {self.author_or_creator} - {status}"

# Specific item types
class Book(LibraryItem):
    def __init__(self, title, item_id, author, pages, genre):
        super().__init__(title, item_id, author)
        self.pages = pages
        self.genre = genre

    def get_item_type(self):
        return "Book"

    def get_borrowing_period(self):
        return 14  # 2 weeks

class DVD(LibraryItem):
    def __init__(self, title, item_id, director, duration, rating):
        super().__init__(title, item_id, director)
        self.duration = duration
        self.rating = rating

    def get_item_type(self):
        return "DVD"

    def get_borrowing_period(self):
        return 7  # 1 week

class Magazine(LibraryItem):
    def __init__(self, title, item_id, publisher, issue_number, publication_date):
        super().__init__(title, item_id, publisher)
        self.issue_number = issue_number
        self.publication_date = publication_date

    def get_item_type(self):
        return "Magazine"

    def get_borrowing_period(self):
        return 3  # 3 days

# Member class
class Member:
    def __init__(self, name, member_id, email, phone):
        self.name = name
        self.member_id = member_id
        self.email = email
        self.phone = phone
        self.borrowed_items = []
        self.membership_date = datetime.now()
        self.fine_amount = 0.0

    def borrow_item(self, item):
        if len(self.borrowed_items) < 5:  # Max 5 items
            if item.borrow(self):
                self.borrowed_items.append(item)
                return f"Successfully borrowed: {item.title}"
            return f"Item not available: {item.title}"
        return "Cannot borrow more than 5 items"

    def return_item(self, item):
        if item in self.borrowed_items:
            # Check for overdue fine
            if item.is_overdue():
                days_overdue = (datetime.now() - item.due_date).days
                fine = days_overdue * 1.0  # $1 per day
                self.fine_amount += fine

            item.return_item()
            self.borrowed_items.remove(item)
            return f"Successfully returned: {item.title}"
        return f"You haven't borrowed this item: {item.title}"

    def get_borrowed_items(self):
        return [item.title for item in self.borrowed_items]

    def pay_fine(self, amount):
        if amount <= self.fine_amount:
            self.fine_amount -= amount
            return f"Paid ${amount}. Remaining fine: ${self.fine_amount}"
        return f"Amount exceeds fine. Current fine: ${self.fine_amount}"

    def __str__(self):
        return f"Member: {self.name} (ID: {self.member_id}), Items: {len(self.borrowed_items)}, Fine: ${self.fine_amount}"

# Library class
class Library:
    def __init__(self, name):
        self.name = name
        self.items = {}  # item_id -> LibraryItem
        self.members = {}  # member_id -> Member
        self.next_item_id = 1
        self.next_member_id = 1

    def add_item(self, item):
        self.items[item.item_id] = item
        return f"Added {item.get_item_type()}: {item.title}"

    def add_member(self, name, email, phone):
        member_id = f"M{self.next_member_id:04d}"
        member = Member(name, member_id, email, phone)
        self.members[member_id] = member
        self.next_member_id += 1
        return member

    def find_items_by_title(self, title):
        found_items = []
        for item in self.items.values():
            if title.lower() in item.title.lower():
                found_items.append(item)
        return found_items

    def find_items_by_author(self, author):
        found_items = []
        for item in self.items.values():
            if author.lower() in item.author_or_creator.lower():
                found_items.append(item)
        return found_items

    def get_available_items(self):
        return [item for item in self.items.values() if item.is_available]

    def get_overdue_items(self):
        return [item for item in self.items.values() if not item.is_available and item.is_overdue()]

    def get_member_info(self, member_id):
        if member_id in self.members:
            return self.members[member_id]
        return None

    def generate_report(self):
        total_items = len(self.items)
        available_items = len(self.get_available_items())
        borrowed_items = total_items - available_items
        overdue_items = len(self.get_overdue_items())
        total_members = len(self.members)

        report = f"""
        {self.name} - Library Report
        ============================
        Total Items: {total_items}
        Available Items: {available_items}
        Borrowed Items: {borrowed_items}
        Overdue Items: {overdue_items}
        Total Members: {total_members}
        """
        return report

# Usage Example
def main():
    # Create library
    library = Library("City Central Library")

    # Add items to library
    book1 = Book("The Python Programming Language", "B001", "Guido van Rossum", 300, "Technology")
    book2 = Book("Clean Code", "B002", "Robert Martin", 464, "Technology")
    dvd1 = DVD("The Matrix", "D001", "Wachowski Sisters", 136, "R")
    magazine1 = Magazine("National Geographic", "M001", "National Geographic Society", "January 2024", datetime(2024, 1, 1))

    print(library.add_item(book1))
    print(library.add_item(book2))
    print(library.add_item(dvd1))
    print(library.add_item(magazine1))

    # Add members
    alice = library.add_member("Alice Johnson", "alice@email.com", "555-1234")
    bob = library.add_member("Bob Smith", "bob@email.com", "555-5678")

    print(f"Added member: {alice}")
    print(f"Added member: {bob}")

    # Borrow items
    print(alice.borrow_item(book1))
    print(alice.borrow_item(dvd1))
    print(bob.borrow_item(book2))

    # Check borrowed items
    print(f"Alice's borrowed items: {alice.get_borrowed_items()}")
    print(f"Bob's borrowed items: {bob.get_borrowed_items()}")

    # Search functionality
    python_books = library.find_items_by_title("Python")
    print(f"Books with 'Python' in title: {[book.title for book in python_books]}")

    # Generate report
    print(library.generate_report())

    # Return items
    print(alice.return_item(book1))
    print(alice.return_item(dvd1))

    # Final report
    print(library.generate_report())

if __name__ == "__main__":
    main()

Best Practices

1. Use Clear and Descriptive Names

# Bad
class C:
    def __init__(self, n, a):
        self.n = n
        self.a = a

# Good
class Customer:
    def __init__(self, name, age):
        self.name = name
        self.age = age

2. Keep Classes Focused (Single Responsibility Principle)

# Bad - doing too many things
class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email

    def save_to_database(self):
        # Database logic
        pass

    def send_email(self):
        # Email logic
        pass

    def validate_input(self):
        # Validation logic
        pass

# Good - separate responsibilities
class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email

class UserRepository:
    def save(self, user):
        # Database logic
        pass

class EmailService:
    def send_email(self, user, message):
        # Email logic
        pass

class UserValidator:
    def validate(self, user):
        # Validation logic
        pass

3. Use Properties for Validation

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age  # This will use the setter

    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        if not isinstance(value, int) or value < 0:
            raise ValueError("Age must be a non-negative integer")
        if value > 150:
            raise ValueError("Age seems unrealistic")
        self._age = value

4. Use Type Hints

from typing import List, Optional

class ShoppingCart:
    def __init__(self) -> None:
        self.items: List[str] = []
        self.total: float = 0.0

    def add_item(self, item: str, price: float) -> None:
        self.items.append(item)
        self.total += price

    def get_total(self) -> float:
        return self.total

    def find_item(self, item_name: str) -> Optional[str]:
        for item in self.items:
            if item == item_name:
                return item
        return None

5. Use docstrings

class BankAccount:
    """
    A simple bank account class that supports deposits and withdrawals.

    Attributes:
        account_number (str): The unique account identifier
        balance (float): Current account balance
        account_holder (str): Name of the account holder
    """

    def __init__(self, account_number: str, account_holder: str, initial_balance: float = 0):
        """
        Initialize a new bank account.

        Args:
            account_number: Unique identifier for the account
            account_holder: Name of the person who owns the account
            initial_balance: Starting balance (default is 0)

        Raises:
            ValueError: If initial_balance is negative
        """
        if initial_balance < 0:
            raise ValueError("Initial balance cannot be negative")

        self.account_number = account_number
        self.account_holder = account_holder
        self.balance = initial_balance

    def deposit(self, amount: float) -> float:
        """
        Deposit money into the account.

        Args:
            amount: The amount to deposit

        Returns:
            The new account balance

        Raises:
            ValueError: If amount is not positive
        """
        if amount <= 0:
            raise ValueError("Deposit amount must be positive")

        self.balance += amount
        return self.balance

Common Mistakes to Avoid

1. Overusing Inheritance

# Bad - too much inheritance
class Animal:
    pass

class Mammal(Animal):
    pass

class Dog(Mammal):
    pass

class SmallDog(Dog):
    pass

class Chihuahua(SmallDog):  # Too deep!
    pass

# Better - use composition or keep inheritance shallow
class Dog:
    def __init__(self, breed, size):
        self.breed = breed
        self.size = size  # "small", "medium", "large"

2. Not Using super() Properly

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

class Child(Parent):
    def __init__(self, name, age):
        Parent.__init__(self, name)  # Hard-coded parent class
        self.age = age

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

class Child(Parent):
    def __init__(self, name, age):
        super().__init__(name)  # Uses super()
        self.age = age

3. Modifying Mutable Default Arguments

# Bad - dangerous!
class ShoppingList:
    def __init__(self, items=[]):  # Mutable default argument
        self.items = items

# All instances will share the same list!
list1 = ShoppingList()
list2 = ShoppingList()
list1.items.append("milk")
print(list2.items)  # Output: ["milk"] - unexpected!

# Good
class ShoppingList:
    def __init__(self, items=None):
        if items is None:
            items = []
        self.items = items

4. Not Handling Exceptions Properly

# Bad
class Calculator:
    def divide(self, a, b):
        return a / b  # Will crash on division by zero

# Good
class Calculator:
    def divide(self, a, b):
        if b == 0:
            raise ValueError("Cannot divide by zero")
        return a / b

    def safe_divide(self, a, b):
        try:
            return self.divide(a, b)
        except ValueError as e:
            print(f"Error: {e}")
            return None

5. Creating God Objects

# Bad - one class doing everything
class GameManager:
    def __init__(self):
        self.players = []
        self.score = {}
        self.level = 1
        self.graphics = None
        self.sound = None

    def add_player(self, player):
        pass

    def update_score(self, player, points):
        pass

    def render_graphics(self):
        pass

    def play_sound(self, sound_file):
        pass

    def save_game(self):
        pass

    def load_game(self):
        pass

    # ... 50 more methods

# Better - separate concerns
class Player:
    def __init__(self, name):
        self.name = name
        self.score = 0

class ScoreManager:
    def __init__(self):
        self.scores = {}

    def update_score(self, player, points):
        pass

class GraphicsEngine:
    def render(self, objects):
        pass

class SoundManager:
    def play(self, sound_file):
        pass

class GameState:
    def save(self):
        pass

    def load(self):
        pass

Conclusion

Object-Oriented Programming in Python is a powerful way to organize and structure your code. The key concepts we’ve covered are:

  1. Classes and Objects: Templates and instances
  2. Encapsulation: Bundling data and methods, controlling access
  3. Inheritance: Creating new classes based on existing ones
  4. Polymorphism: Using the same interface for different types
  5. Abstraction: Hiding complex implementation details

Remember these guidelines:

  • Start simple and add complexity gradually
  • Use inheritance for “is-a” relationships
  • Use composition for “has-a” relationships
  • Keep classes focused on a single responsibility
  • Use properties for validation and computed values
  • Write clear, descriptive names
  • Document your code with docstrings

OOP takes practice to master, but once you understand these concepts, you’ll be able to write more organized, maintainable, and scalable code. Start with simple examples and gradually work your way up to more complex systems.

The library management system we built shows how all these concepts work together in a real application. Practice by building your own projects – maybe a school management system, a simple game, or an e-commerce platform. The more you practice, the more natural OOP thinking will become!

Happy coding! 🐍


This content originally appeared on DEV Community and was authored by Arslan Yousaf