Object Oriented Programming in Python

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:

  • Product handles product information
  • Customer maintains customer details and their shopping cart
  • ShoppingCart manages the items a customer wants to purchase
  • Order processes the customer’s order and creates receipts
  • ProductCatalog serves 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

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.

51 Python Programs

51 PYTHON PROGRAMS PDF FREE

Download a FREE PDF (112 Pages) Containing 51 Useful Python Programs.

Let’s be friends

Be the first to know about sales and special discounts.