Object-Oriented Programming (OOP) is one of the most powerful paradigms in Python. When I started working with Python over a decade ago, mastering OOP concepts completely transformed how I approached software development.
In this article, I’ll share my hands-on experience with Python’s OOP features and show you practical examples that go beyond the typical “Hello World” demonstrations.
Python is indeed an object-oriented language that implements all the major OOP concepts: encapsulation, inheritance, polymorphism, and abstraction. Let’s dive in!
What is Object-Oriented Programming?
Object-Oriented Programming is a programming paradigm that organizes code around the concept of “objects” rather than functions and logic. These objects are instances of classes, which serve as blueprints for creating them.
When I’m developing applications for clients in sectors like healthcare or finance, OOP helps me write more maintainable and reusable code. This is particularly important for large-scale projects where multiple developers collaborate.
Check out the page Python Dictionaries and read all tutorials
Creating Classes and Objects in Python
Basic Class Structure
In Python, creating a class is as simple as using the class keyword:
class Car:
def __init__(self, make, model, year):
self.make = make
self.model = model
self.year = year
def get_description(self):
return f"{self.year} {self.make} {self.model}"
The __init__ method is a special method called a constructor. It initializes a new object with the values we provide. Python uses constructors with parameters to set up initial states for objects.
Creating an Instance
Creating an object (instance) from a class is straightforward:
my_car = Car("Tesla", "Model 3", 2023)
print(my_car.get_description()) # Output: 2023 Tesla Model 3
The Four Pillars of OOP in Python
1. Encapsulation
Encapsulation is about bundling data and methods that work on that data within a single unit (the class) and restricting access to some of the object’s components.
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.__balance = balance # Private attribute
def deposit(self, amount):
if amount > 0:
self.__balance += amount
return True
return False
def withdraw(self, amount):
if 0 < amount <= self.__balance:
self.__balance -= amount
return True
return False
def get_balance(self):
return self.__balance
In this example, __balance is a private attribute that can’t be directly accessed from outside the class. This is a form of data hiding, which is a key aspect of encapsulation.
Python uses naming conventions for access modifiers rather than strict access control. Attributes with double underscores (__) are name-mangled to make them harder to access directly.
2. Inheritance
Inheritance allows a class to inherit attributes and methods from another class.
class Vehicle:
def __init__(self, make, model, year):
self.make = make
self.model = model
self.year = year
def get_info(self):
return f"{self.year} {self.make} {self.model}"
class ElectricVehicle(Vehicle):
def __init__(self, make, model, year, battery_capacity):
super().__init__(make, model, year)
self.battery_capacity = battery_capacity
def get_info(self):
return f"{super().get_info()} with {self.battery_capacity} kWh battery"
Notice how we use super().__init__() to call the base class constructor with arguments. This is a common pattern when extending classes.
3. Polymorphism
Polymorphism allows objects of different classes to be treated as objects of a common superclass. Python’s dynamic typing makes polymorphism particularly flexible.
def vehicle_description(vehicle):
return vehicle.get_info()
car = Vehicle("Toyota", "Camry", 2022)
ev = ElectricVehicle("Tesla", "Model Y", 2023, 75)
print(vehicle_description(car)) # Output: 2022 Toyota Camry
print(vehicle_description(ev)) # Output: 2023 Tesla Model Y with 75 kWh battery
Both objects respond to the same method call but provide different implementations – that’s polymorphism in action.
4. Abstraction
Abstraction focuses on hiding complex implementation details and showing only the necessary features of an object.
from abc import ABC, abstractmethod
class PaymentProcessor(ABC):
@abstractmethod
def process_payment(self, amount):
pass
class CreditCardProcessor(PaymentProcessor):
def process_payment(self, amount):
print(f"Processing ${amount} via credit card")
# Complex credit card processing logic here
class PayPalProcessor(PaymentProcessor):
def process_payment(self, amount):
print(f"Processing ${amount} via PayPal")
# Complex PayPal processing logic here
The abstract base class PaymentProcessor defines a common interface that all concrete payment processors must implement.
Learn about all Python Data Types on this page
Advanced OOP Concepts in Python
Magic Methods
Python’s “magic methods” (also called dunder methods) allow you to define how your objects behave with built-in functions and operators:
class ShoppingCart:
def __init__(self):
self.items = []
def add_item(self, product, price, quantity=1):
self.items.append({"product": product, "price": price, "quantity": quantity})
def __len__(self):
return sum(item["quantity"] for item in self.items)
def __str__(self):
return f"Shopping Cart with {len(self)} items"
def __iter__(self):
return iter(self.items)
# Usage
cart = ShoppingCart()
cart.add_item("Laptop", 999.99)
cart.add_item("Headphones", 149.99, 2)
print(len(cart)) # Output: 3
print(cart) # Output: Shopping Cart with 3 items
for item in cart:
print(f"{item['product']} - ${item['price']} x {item['quantity']}")
Python has many built-in functions that can be customized through magic methods.
Method Resolution Order (MRO)
When dealing with multiple inheritance, Python’s Method Resolution Order determines the order in which base classes are searched when looking for a method:
class A:
def method(self):
return "A's method"
class B(A):
def method(self):
return "B's method"
class C(A):
def method(self):
return "C's method"
class D(B, C):
pass
d = D()
print(d.method()) # Output: B's method
print(D.__mro__) # Shows the method resolution order
Creating Object Instances: __new__ vs __init__
Python uses two special methods when creating objects: __new__ and __init__. The __new__ method is responsible for creating and returning a new instance, while __init__ initializes the newly created instance.
class Singleton:
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, name):
self.name = name
# Both variables reference the same object
s1 = Singleton("First")
s2 = Singleton("Second")
print(s1.name) # Output: Second
print(s2.name) # Output: Second
print(s1 is s2) # Output: True
Property Decorators for Cleaner Code
Property decorators help create getter, setter, and deleter methods in a more Pythonic way:
class Circle:
def __init__(self, radius=1.0):
self._radius = radius
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value <= 0:
raise ValueError("Radius must be positive")
self._radius = value
@property
def area(self):
import math
return math.pi * self._radius ** 2
# Usage
circle = Circle(5)
print(circle.radius) # Output: 5
print(circle.area) # Output: 78.53981633974483
circle.radius = 10 # Using the setter
print(circle.area) # Output: 314.1592653589793
try:
circle.radius = -1 # This will raise an exception
except ValueError as e:
print(e) # Output: Radius must be positive
Real-World Application Example
Let’s look at a more comprehensive example of how OOP can be used in a real-world scenario. Here’s a simplified e-commerce system:
class Product:
def __init__(self, product_id, name, price):
self.product_id = product_id
self.name = name
self.price = price
def get_display_price(self):
return f"${self.price:.2f}"
class Customer:
def __init__(self, customer_id, name, email):
self.customer_id = customer_id
self.name = name
self.email = email
self.shopping_cart = ShoppingCart()
class ShoppingCart:
def __init__(self):
self.items = {} # product_id -> quantity
def add_item(self, product, quantity=1):
if product.product_id in self.items:
self.items[product.product_id] += quantity
else:
self.items[product.product_id] = quantity
def remove_item(self, product, quantity=1):
if product.product_id in self.items:
self.items[product.product_id] -= quantity
if self.items[product.product_id] <= 0:
del self.items[product.product_id]
def calculate_total(self, product_catalog):
total = 0
for product_id, quantity in self.items.items():
product = product_catalog.get_product(product_id)
total += product.price * quantity
return total
class Order:
def __init__(self, order_id, customer, product_catalog):
self.order_id = order_id
self.customer = customer
self.order_items = dict(customer.shopping_cart.items)
self.total = customer.shopping_cart.calculate_total(product_catalog)
customer.shopping_cart = ShoppingCart() # Empty the cart after order
def generate_receipt(self, product_catalog):
receipt = f"Receipt for Order #{self.order_id}\n"
receipt += f"Customer: {self.customer.name}\n"
receipt += "-" * 40 + "\n"
for product_id, quantity in self.order_items.items():
product = product_catalog.get_product(product_id)
receipt += f"{product.name} x{quantity}: {product.get_display_price()} each\n"
receipt += "-" * 40 + "\n"
receipt += f"Total: ${self.total:.2f}"
return receipt
class ProductCatalog:
def __init__(self):
self.products = {}
def add_product(self, product):
self.products[product.product_id] = product
def get_product(self, product_id):
return self.products.get(product_id)
# Sample usage
catalog = ProductCatalog()
catalog.add_product(Product("p1", "MacBook Pro", 1299.99))
catalog.add_product(Product("p2", "AirPods Pro", 249.99))
catalog.add_product(Product("p3", "iPhone 14", 999.99))
customer = Customer("c1", "John Smith", "john@example.com")
customer.shopping_cart.add_item(catalog.get_product("p1"))
customer.shopping_cart.add_item(catalog.get_product("p2"), 2)
order = Order("ord123", customer, catalog)
print(order.generate_receipt(catalog))This example demonstrates how different classes work together to create a functional e-commerce system. Each class has a specific responsibility:
Producthandles product informationCustomermaintains customer details and their shopping cartShoppingCartmanages the items a customer wants to purchaseOrderprocesses the customer’s order and creates receiptsProductCatalogserves as a repository for all available products
Check out this page to learn about Python Arrays
Common OOP Design Patterns in Python
Design patterns are reusable solutions to common programming problems. Here are a few that I frequently use in my Python projects:
1. Singleton Pattern
I’ve already shown a basic singleton implementation. Here’s a more decorator-based approach:
def singleton(cls):
instances = {}
def get_instance(*args, **kwargs):
if cls not in instances:
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return get_instance
@singleton
class DatabaseConnection:
def __init__(self, host, username, password):
# In a real app, this would establish a database connection
self.host = host
self.username = username
self.password = password
print(f"Connecting to database on {host}")
# Creates only one connection regardless of how many times it's instantiated
conn1 = DatabaseConnection("localhost", "admin", "password123")
conn2 = DatabaseConnection("127.0.0.1", "root", "different_password")
print(conn1.host) # Output: localhost (not 127.0.0.1)
print(conn1 is conn2) # Output: True
2. Factory Pattern
The Factory pattern creates objects without specifying the exact class of object to be created:
class PaymentMethod:
def process_payment(self, amount):
pass
class CreditCardPayment(PaymentMethod):
def process_payment(self, amount):
return f"Processing ${amount} via Credit Card"
class PayPalPayment(PaymentMethod):
def process_payment(self, amount):
return f"Processing ${amount} via PayPal"
class ApplePayPayment(PaymentMethod):
def process_payment(self, amount):
return f"Processing ${amount} via Apple Pay"
class PaymentFactory:
@staticmethod
def create_payment_method(method_type):
if method_type == "credit_card":
return CreditCardPayment()
elif method_type == "paypal":
return PayPalPayment()
elif method_type == "apple_pay":
return ApplePayPayment()
else:
raise ValueError(f"Unknown payment method: {method_type}")
# Client code
payment_method = PaymentFactory.create_payment_method("credit_card")
print(payment_method.process_payment(100)) # Output: Processing $100 via Credit Card
3. Observer Pattern
The Observer pattern is useful when you need a one-to-many dependency between objects:
class NewsPublisher:
def __init__(self):
self._subscribers = set()
self._latest_news = None
def attach(self, subscriber):
self._subscribers.add(subscriber)
def detach(self, subscriber):
self._subscribers.discard(subscriber)
def notify(self):
for subscriber in self._subscribers:
subscriber.update(self._latest_news)
def add_news(self, news):
self._latest_news = news
self.notify()
class NewsSubscriber:
def __init__(self, name):
self.name = name
def update(self, news):
print(f"{self.name} received news: {news}")
# Usage
publisher = NewsPublisher()
subscriber1 = NewsSubscriber("New York Times")
subscriber2 = NewsSubscriber("Washington Post")
subscriber3 = NewsSubscriber("Wall Street Journal")
publisher.attach(subscriber1)
publisher.attach(subscriber2)
publisher.attach(subscriber3)
publisher.add_news("NASA successfully launches new Mars rover!")
# All three subscribers will receive the news
publisher.detach(subscriber2)
publisher.add_news("Stock market reaches all-time high!")
# Only subscribers 1 and 3 will receive this news
Common Pitfalls and Best Practices
Over my years of Python development, I’ve encountered several common pitfalls in OOP. Here’s how to avoid them:
Mutable Default Arguments
Never use mutable objects as default function arguments:
# BAD
class User:
def __init__(self, name, roles=[]): # This creates a shared list!
self.name = name
self.roles = roles
# GOOD
class User:
def __init__(self, name, roles=None):
self.name = name
self.roles = roles if roles is not None else []
Overusing Private Variables
While encapsulation is important, Python’s culture emphasizes “we’re all adults here.” Use private variables judiciously:
# Probably excessive privacy
class Person:
def __init__(self, name, age):
self.__name = name # Double underscore for "private"
self.__age = age
def __get_name(self):
return self.__name
def __get_age(self):
return self.__age
# More Pythonic
class Person:
def __init__(self, name, age):
self.name = name # Public is often fine
self._age = age # Single underscore as a convention for "protected"
@property
def age(self):
return self._age
You can refer to this page to learn about the topic of File Handling in Python
Inheriting from Multiple Concrete Classes
Multiple inheritance can be tricky. Prefer composition over deep inheritance hierarchies:
# Potentially problematic multiple inheritance
class Flyer:
def fly(self):
print("Flying")
class Swimmer:
def swim(self):
print("Swimming")
class Duck(Flyer, Swimmer): # Multiple inheritance
pass
# Often better to use composition
class Duck:
def __init__(self):
self.flying_behavior = Flyer()
self.swimming_behavior = Swimmer()
def fly(self):
self.flying_behavior.fly()
def swim(self):
self.swimming_behavior.swim()
Performance Considerations
Object-oriented programming in Python is convenient but can have performance implications. Here are some tips from my experience:
Use slots for Memory Optimization
If you’re creating many instances of a class, consider using __slots__ to reduce memory usage:
class Point:
__slots__ = ['x', 'y'] # Restricts attributes to these two
def __init__(self, x, y):
self.x = x
self.y = y
# This class can only have x and y attributes
# and will use less memory than a regular class
Be Aware of Method Resolution Costs
Method lookups in a complex inheritance hierarchy can be expensive. Keep your inheritance hierarchies relatively flat.
Object-oriented programming- tutorials
- Python Class Method vs Static Method
- __new__ vs __init__ in Python
- Difference Between Class and Instance Variables in Python
- Create and Use an Empty Constructor in Python
- How to Call Super Constructor in Python
- Implement Constructor Overloading in Python
- Use Constructors in Python
- Naming Conventions in Python
- Call Super Constructors with Arguments in Python
- Use Python Class Constructors with Parameters
- Call a Base Class Constructor with Arguments in Python
- Check if an Object is Iterable in Python
- How to Use Private Variables in Python for Data Hiding
- How to Use Python slots to Optimize Memory Performance
- Data Encapsulation in Python
- Python @property vs Normal Methods
- How to Use @property Decorator in Python
- How to Use Abstract Base Classes (ABC) in Python
- Python Abstract Class vs Concrete Class
- Python Composition vs Inheritance
- Python Aggregation vs Composition
- How to Build a Simple OOP Project in Python
- How to Use @dataclass in Python
- Python Dataclass vs. Normal Class
Conclusion
Object-oriented programming in Python gives you powerful tools to structure your code in a reusable, maintainable way. As you can see from the examples, Python’s implementation of OOP is flexible and intuitive, with less rigid syntax than languages like Java or C++.
I’ve been using these techniques for over a decade to build everything from web applications to data analysis tools. The key is to understand when OOP is the right approach and when a simpler functional style might be more appropriate.
Start with simple classes, focus on clear responsibilities for each class, and gradually incorporate more advanced features as you become comfortable. Before you know it, you’ll be designing elegant object-oriented solutions to complex problems!
Remember, in Python, readability counts. Write your classes to be intuitive for other developers (and your future self). This often means following the principle of least surprise: methods and attributes should do what their names suggest.