This content originally appeared on DEV Community and was authored by Arslan Yousaf
Table of Contents
- What is Object-Oriented Programming?
- Classes and Objects
- Attributes and Methods
-
The Four Pillars of OOP
- Encapsulation
- Inheritance
- Polymorphism
- Abstraction
- Special Methods (Magic Methods)
- Class vs Instance Variables
- Property Decorators
- Multiple Inheritance
- Composition vs Inheritance
- Real-World Examples
- Best Practices
- 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?
- Organization: Keep related code together
- Reusability: Write once, use many times
- Maintainability: Easier to fix and update
- Scalability: Easy to add new features
- 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
- Instance Methods: Work with instance data
- Class Methods: Work with class data
- 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
- Flexibility: Easy to change components
- Reusability: Components can be used in other classes
- Testability: Each component can be tested separately
- 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:
- Classes and Objects: Templates and instances
- Encapsulation: Bundling data and methods, controlling access
- Inheritance: Creating new classes based on existing ones
- Polymorphism: Using the same interface for different types
- 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