Photo by Chris Ried on Unsplash

Mastering Object-Oriented Programming: Best Practices and Design Patterns

Exploring the Core Principles of OOP Through Python Examples

Aurora Poppyseed

--

Introduction

Object-oriented programming (OOP) is a paradigm that uses “objects” — data structures that consist of data fields and methods together with their interactions — to design applications and computer programs. Mastering OOP involves not just understanding the theory behind it but also adopting the best practices and design patterns that make your code efficient, reusable, and easy to maintain. This article outlines the best practices and design patterns for mastering object-oriented programming.

Best Practices

Some best practice examples would include: encapsulation, inheritance, polymorphism, proper class design, and composition over inheritance. Here are more details about each one of them:

  • Encapsulation: This practice involves hiding the implementation details of an object. This can be done in Python using private members with double underscore prefixes.
class BankAccount:
def __init__(self):
self.__account_number = "123456789"
self.__balance = 0.0

def deposit(self, amount):
self.__balance += amount

def withdraw(self, amount):
if amount > self.__balance:
print("Insufficient balance!")
return
self.__balance -= amount

def get_balance(self):
return self.__balance

In this example, the bank account number and balance are private to the BankAccount class and can't be accessed directly from outside the class.

  • Inheritance: Inheritance is used to create a new class that has all the properties and behaviours of another class, with the potential to add or override them.
class Vehicle:
def __init__(self, color, wheels):
self.color = color
self.wheels = wheels

class Car(Vehicle):
def __init__(self, color, wheels, brand):
super().__init__(color, wheels)
self.brand = brand

Here, Car is a subclass of Vehicle, inheriting the color and wheels properties and adding a brand property.

  • Polymorphism: This principle allows methods to act differently based on the object type they are acting on.
class Dog:
def make_sound(self):
return "Woof!"

class Cat:
def make_sound(self):
return "Meow!"

def animal_sound(animal):
print(animal.make_sound())

dog = Dog()
cat = Cat()

animal_sound(dog) # prints "Woof!"
animal_sound(cat) # prints "Meow!"

In this example, the animal_sound function uses the make_sound method, which behaves differently depending on whether it's called on a Dog or a Cat object.

  • Proper Class Design: Each class should have a single responsibility. The following is an example of a class that only handles operations related to a bank account:
class BankAccount:
def __init__(self):
self.__balance = 0.0

def deposit(self, amount):
self.__balance += amount

def withdraw(self, amount):
if amount > self.__balance:
print("Insufficient balance!")
return
self.__balance -= amount

def get_balance(self):
return self.__balance

In this example, BankAccount is only responsible for handling operations related to the bank account.

  • Composition over Inheritance: While inheritance can be useful, it can lead to overly complex hierarchies. Composition, on the other hand, promotes flexibility.
class Engine:
def start(self):
print("Engine starts")

class Car:
def __init__(self):
self.engine = Engine()

def start(self):
self.engine.start()
print("Car starts")

In this example, instead of inheriting from Engine, the Car class is composed with an Engine object, demonstrating the principle of composition over inheritance.

Design Patterns

Design patterns provide general solutions or flexible ways to solve common design problems. These are some of the most important ones in OOP:

  • Factory Pattern: The factory pattern involves creating an object factory to abstract the object creation process.
class Dog:
def __init__(self, name):
self._name = name

def speak(self):
return "Woof!"

class Cat:
def __init__(self, name):
self._name = name

def speak(self):
return "Meow!"

def get_pet(pet="dog"):
pets = dict(dog=Dog("Hope"), cat=Cat("Peace"))
return pets[pet]

d = get_pet("dog")
print(d.speak())

c = get_pet("cat")
print(c.speak())

In this example, the get_pet function is a factory function that creates and returns an instance of Dog or Cat depending on the input.

  • Singleton Pattern: The Singleton pattern ensures that a class has only one instance and provides a global point of access to it.
class Singleton:
_instance = None

@staticmethod
def getInstance():
if Singleton._instance == None:
Singleton()
return Singleton._instance

def __init__(self):
if Singleton._instance != None:
raise Exception("This class is a singleton!")
else:
Singleton._instance = self

s = Singleton()
print(s)

s = Singleton.getInstance()
print(s)

s = Singleton.getInstance()
print(s)

In this example, Singleton is a singleton class that always returns the same instance.

  • Strategy Pattern: The Strategy pattern enables an algorithm’s behaviour to be selected at runtime.
class StrategyExample:
def __init__(self, func=None):
if func:
self.execute = func

def execute(self):
print("Original execution")

def executpue_replacement1():
print("Strategy 1")

def execute_replacement2():
print("Strategy 2")

strategy_example = StrategyExample()
strategy_example.execute()

strategy_example1 = StrategyExample(execute_replacement1)
strategy_example1.execute()

strategy_example2 = StrategyExample(execute_replacement2)
strategy_example2.execute()

In this example, the StrategyExample class's execute method can be replaced with different functions at runtime.

  • Observer Pattern: The Observer pattern defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified.
class Subject:
def __init__(self):
self._observers = []

def attach(self, observer):
if observer not in self._observers:
self._observers.append(observer)

def detach(self, observer):
try:
self._observers.remove(observer)
except ValueError:
pass

def notify(self):
for observer in self._observers:
observer.update(self)

class Observer:
def update(self, subject):
pass

class AmericanStockMarket(Observer):
def update(self, subject):
print("American stock market received: {0}".format(subject))

class EuropeanStockMarket(Observer):
def update(self, subject):
print("European stock market received: {0}".format(subject))

if __name__ == '__main__':
ibm = Subject()
asm = AmericanStockMarket()

ibm.attach(asm)
esm = EuropeanStockMarket()

ibm.attach(esm)
ibm.notify()

In this example, the Subject class notifies all registered observers when its notify method is called.

  • Decorator Pattern: The Decorator pattern allows behaviour to be added to an individual object, either statically or dynamically, without affecting the behaviour of other objects from the same class.
class Component:
def operation(self):
pass

class ConcreteComponent(Component):
def operation(self):
return "ConcreteComponent"

class Decorator(Component):
def __init__(self, component):
self._component = component

@property
def component(self):
return self._component

def operation(self):
return self._component.operation()

class ConcreteDecoratorA(Decorator):
def operation(self):
return f"ConcreteDecoratorA({self.component.operation()})"

class ConcreteDecoratorB(Decorator):
def operation(self):
return f"ConcreteDecoratorB({self.component.operation()})"

In this example, ConcreteDecoratorA and ConcreteDecoratorB are decorators that add responsibilities to ConcreteComponent without changing its interface. Decorators are a flexible alternative to subclassing for extending functionality.

In conclusion, design patterns are proven solutions to common software design problems. They are not classes or libraries that can be plugged into an application and automatically solve problems. Instead, they are templates that provide guidance on how to solve various design problems in object-oriented software.

Conclusion

Mastering object-oriented programming requires understanding the fundamental principles and best practices, as well as how and when to use common design patterns. Through careful application of encapsulation, inheritance, polymorphism, and proper class design, along with the judicious use of design patterns, you can write code that is efficient, reusable, and easy to maintain. Remember, the ultimate goal of mastering OOP is to write code that not only works, but is also clean and manageable. This makes your software easier to read, understand, and modify, leading to more robust and maintainable applications.

Thanks for reading this article!

In our collective journey into the future, your thoughts and contributions are invaluable. For more engaging talks about blockchain, AI, technology, and biohacking, let’s stay connected across these platforms:

  • Read more of my articles on Medium 📚
  • Follow me on Twitter 🐦 for thoughts and discussions on AI, biohacking, and blockchain.
  • Check out my projects and contribute on GitHub 👩‍💻.
  • And join me on YouTube 🎥 for more in-depth conversations.

Let’s continue shaping the future together, one creation at a time. 🚀

Aurora Poppyseed

--

--