Skip to content

Latest commit

 

History

History
885 lines (613 loc) · 27.9 KB

25.Advanced-OOP.md

File metadata and controls

885 lines (613 loc) · 27.9 KB

Lesson 25: Advanced OOP

"The place where code transcends functionality and becomes an art form"

Content

  1. Mixins
  2. Metaclasses
  3. Type checking
  4. Duck Typing
  5. Quiz
  6. Homework

1. Mixins

Mixins are a sort of class designed to offer optional methods or functionality to other classes.

They are a form of multiple inheritance, allowing developers to add the same functionality to multiple classes without repeating code.

IMPORTANT: Unlike traditional base classes, mixins are specifically designed to be combined with other classes, not to stand on their own.

1.1 The Purpose of Mixins

They are used to modularize functionality, making it easy to add or remove features from objects without afecting the inheritance hierarchy of the classes. They can:

  • Provide a set of methods that can be used in different classes.
  • Compose behaviors in classes.
  • Add functionality to classes without modifying them directly.

Generally, the main idea is to make mixins generic as possible, defining functionality to use them in different classes which serve different purpose.

1.2 Implementation

A mixin is typically implemented as a class that does not work by itself. As I mentioned before, it must be combined with another class to make sense.

Example

class JsonMixin:
    import json

    def to_json(self):
        return self.json.dumps(self.__dict__)

class Vehicle:
    def __init__(self, brand, model):
        self.brand = brand
        self.model = model

class Car(JsonMixin, Vehicle):  # Mixin
    def __init__(self, brand, model, engine_type):
        super().__init__(brand, model)
        self.engine_type = engine_type

my_car = Car("Tesla", "Model S", "Electric")
print(my_car.to_json()) # Call external functionality frim ``JsonMixin`` class

Output

{"brand": "Tesla", "model": "Model S", "engine_type": "Electric"}

Explanation

In this example, JsonMixin provides a to_json method, which is then available to the Car class through multiple inheritance. This is a simple yet powerful way to add functionality to classes without affecting their logic.

Let's create another mixin that adds logging functionality to any class.

Example

import logging

class LoggingMixin:
    @property
    def logger(self):
        name = '.'.join([self.__class__.__module__, self.__class__.__name__])
        return logging.getLogger(name)

    def log_info(self, message):
        self.logger.info(message)

    def log_error(self, message):
        self.logger.error(message)

logging.basicConfig(level=logging.INFO)

class DataProcessor(LoggingMixin):
    def process_data(self, data):
        self.log_info(f"Processing data: {data}")
        if data == "error":
            self.log_error("An error occurred while processing data.")

processor = DataProcessor()
processor.process_data("some data")
processor.process_data("error")

Output

INFO:__main__.DataProcessor:Processing data: some data
INFO:__main__.DataProcessor:Processing data: error
ERROR:__main__.DataProcessor:An error occurred while processing data.

Explanation

LoggingMixin uses the @property decorator for logger to ensure that the logger is specific to the class it's mixed into, using the class's fully qualified name.

This makes the logs clearer and more informative because they're automatically tagged with the class name. As well it remains generic ass possible and can be injected into any class being an extremely powerful tool.

1.3 Best Practices

1.Single Responsibility: Each mixin should be focused on a single, clear purpose, adhering to the Single Responsibility Principle.

import json

class JsonMixin:
    def to_json(self):
        """Serialize data to a JSON format."""
        return json.dumps(self.__dict__)

class Person(JsonMixin):
    def __init__(self, name, age):
        self.name = name
        self.age = age

# Usage
person = Person("John Doe", 30)
print(person.to_json())  # {"name": "John Doe", "age": 30}

IMPORTANT: JsonMixin can be integrated in ANY class, I am intentionally including the same example as above to show that we can use it not only with a class Car but with a Person as well.

Output

{"name": "John Doe", "age": 30}

2.Avoid State in Mixins: Ideally, mixins should not store state. If they must, be cautious of conflicts with the classes they are mixed into.

class DebuggingMixin:
    def debug_method_call(self, method_name):
        print(f"Method called: {method_name}")

class Calculation(DebuggingMixin):
    def add(self, a, b):
        self.debug_method_call('add')
        return a + b

    def subtract(self, a, b):
        self.debug_method_call('subtract')
        return a - b

# Usage
calc = Calculation()
print(calc.add(10, 5))
print(calc.subtract(10, 5))

Output

Method called: add
15
Method called: subtract
5

3.Use Descriptive Names: Since mixins can be combined in various ways, their names should be as descriptive as possible to clarify their purpose and functionality, as everything in Python.

import base64

class EncryptionDecryptionMixin:
    def encrypt_data(self, data):
        """Encrypts data using base64 encoding."""
        return base64.b64encode(data.encode('utf-8'))

    def decrypt_data(self, data):
        """Decrypts data using base64 decoding."""
        return base64.b64decode(data).decode('utf-8')

class SecureCommunicator(EncryptionDecryptionMixin):
    def send_secure_message(self, message):
        encrypted_message = self.encrypt_data(message)
        print(f"Sending encrypted message: {encrypted_message}")

    def receive_secure_message(self, encrypted_message):
        message = self.decrypt_data(encrypted_message)
        print(f"Received message: {message}")

comm = SecureCommunicator()
encrypted = comm.encrypt_data("Hello World")
print(f"Encrypted: {encrypted}")
print("Decrypted:", comm.decrypt_data(encrypted))

Output

Encrypted: b'SGVsbG8gV29ybGQ='
Decrypted: Hello World

4.Be Mindful of the Method Resolution Order (MRO): Python's method resolution order means that the order of base classes affects the methods used.

class BaseFeature:
    def feature(self):
        print("Base feature")

class EnhancementMixin:
    def feature(self):
        print("Enhanced feature")

class AdditionalFeatureMixin:
    def feature(self):
        super().feature()
        print("Additional feature")

class Product(EnhancementMixin, AdditionalFeatureMixin, BaseFeature):
    pass

product = Product()
product.feature()  # Will call EnhancementMixin's feature due to MRO

Again, based on my experience mixins are very underestimated, but hopefully you will be able to find where they could be applied potentially.

2. Metaclasses

Metaclasses define how a class behaves, they define the rules for class objects. A class is an instance of a metaclass, just as an object is an instance of a class.

2.1 Syntax

In Python, the type type is the built-in metaclass that is used by default, but custom metaclasses can be created by inheriting from type. In order to create a metaclass you need to be inheriting from type and defining the __new__ or __init__ method.

Example

class Meta(type):
    def __new__(cls, name, bases, dct):
        # custom logic here
        return super().__new__(cls, name, bases, dct)

To use a metaclass, you specify it in the class definition using the metaclass keyword.

class MyClass(metaclass=Meta):
      pass

The __new__ method in Python is a special method used for creating and returning a new instance of a class.

It is important to understand, unlike __init__, which initializes an already existing instance, __new__ is responsible for actually creating the instance.

Example

class MyClass:
    def __new__(cls):
        print("Creating instance")
        instance = super().__new__(cls)
        return instance

    def __init__(self):
        print("Initializing instance")

# When you create a new instance of MyClass
obj = MyClass()

Output

# Creating instance
# Initializing instance

In some cases, you might override __new__ when you need to control the creation of a new instance, such as enforcing a Singleton Pattern (ensuring a class only ever has one instance) or modifying the instantiation process of a subclass.

2.2 Real world examples

Suppose you want to add a debugging method to a range of classes to print their attributes in a formatted manner for easier debugging.

Instead of adding the method to each class manually, you can create a metaclass that automatically injects this method into any class that uses it, that's how we ensure that we have a controll on the stage of creation of a new instance.

2.2.1 Debugging Metaclass

class DebugMeta(type):
    def __new__(cls, name, bases, dct):
        # Define a new method
        def debug(self):
            attrs = "\n".join(f"{key}={value}" for key, value in self.__dict__.items())
            print(f"Debugging {self.__class__.__name__}:\n{attrs}")
        
        # Add the method to the class
        dct["debug"] = debug
        
        return super().__new__(cls, name, bases, dct)


class Product(metaclass=DebugMeta):
    def __init__(self, name, price):
        self.name = name
        self.price = price

class Customer(metaclass=DebugMeta):
    def __init__(self, id, name):
        self.id = id
        self.name = name

# While creating instances 
# (before ``__init__`` ``__new__`` will be called and assign attributes of classes which use ``DebugMeta``
product = Product("Laptop", 1200)
customer = Customer("001", "John Doe")

# Using the new method
product.debug()
customer.debug()

Output

Debugging Product:
name=Laptop
price=1200
Debugging Customer:
id=001
name=John Doe

Explanation

  • The DebugMeta metaclass defines a debug method inside the __new__ method and adds it to every class that specifies DebugMeta as its metaclass.
  • By using this metaclass, any class can automatically gain the debug method without explicitly defining it. This is powerful for adding functionality like logging, debugging, serialization, and more across multiple classes.

Now, developers have flexibility in extending class functionality and this can be particularly useful in large applications or libraries.

2.2.2. Django's ORM (Object-Relational Mapping)

Django, Python web framework, uses metaclasses to define models that represent database tables. The ModelBase metaclass in Django's ORM system allows developers to define models using simple class syntax, which the metaclass then translates into database fields and tables.

This abstraction enables developers to work with databases in a more Pythonic way, without having to write SQL queries for basic operations.

Example Use Case:

from django.db import models

class Person(models.Model):
    name = models.CharField(max_length=100)
    age = models.IntegerField()

    class Meta:
        db_table = 'person_table'

Explanataion

models.Model uses a metaclass to process the class attributes (name and age) and convert them into database columns.

The metaclass also handles inheritance, database schema generation, and integrates with Django's migrations system to apply changes to the database schema over time.

Unfortunately, Django framework is beyond the scope of this book, as it is enormously big framework which will require a separate book to be written in order to describe its functionality, but you can refere to the official documentation and try building web apps by yourself, as your knowledge should be enough already.

2.2.3 SQLAlchemy's Declarative Base

SQLAlchemy, a popular SQL toolkit and Object-Relational Mapping (ORM) library for Python, utilizes metaclasses to define a declarative base class.

Example

from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, Integer, String

Base = declarative_base()

class User(Base):
    __tablename__ = 'users'
    id = Column(Integer, primary_key=True)
    name = Column(String)
    age = Column(Integer)

Explanation

The declarative_base() function in SQLAlchemy uses a metaclass to create a base class, which then automatically maps class properties to database table columns. Due to metaclasses the ORM simplifies the creation of models and their associated database operations.

IMPORTANT: Be careful using metaclasses, they can introduce complexity and should only be used when simpler solutions like class inheritance or decorators are insufficient.

Metaclasses can automatically validate or modify member attributes. This can be useful for type checking or automatically adding getter/setter methods to attributes.

3. Type checking

You can annotate variables, function parameters, and return types using custom classes just as you would with built-in types. This tells the reader of the code, as well as static type checkers, exactly what kind of object is expected.

Example:

class Car:
    def __init__(self, make: str, model: str):
        self.make = make
        self.model = model

def display_car_info(car: Car) -> str:
    return f"{car.make} {car.model}"

# Correct usage
my_car: Car = Car("Tesla", "Model S")
info: str = display_car_info(my_car)

wrong_car: int = 123
display_car_info(wrong_car)  # Mypy will flag this as an error

3.1 Conditional Imports

When working with type hints that refer to classes defined in external modules, you might encounter a situation where you want to avoid importing those modules directly at runtime.

Python's typing.TYPE_CHECKING constant can be used in these cases to conditionally import modules for type annotations without affecting runtime performance.

Example

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from some_external_module import ExternalClass

def func(arg: 'ExternalClass') -> None:
    pass

Note the use of a string literal 'ExternalClass' in the function signature. This is a forward declaration, which is necessary because the actual ExternalClass is not imported at runtime.

Having this practices can reduce circular imports, which is the worst error to be tackled, as well, improve readability and the code will be less prune to errors.

3.2 Runtime Type Checking

Python's dynamic nature allows for flexibility in handling different types, but there are scenarios where enforcing type safety at runtime is necessary, especially when interfacing with external systems or libraries.

3.2.1 isinstance()

The isinstance() function checks if an object is an instance of a specific class or a tuple of classes. It's a straightforward way to validate types at runtime, ensuring that the data conforms to the expected type before proceeding.

Example

def process_data(data):
    if not isinstance(data, dict):
        raise ValueError("Expected a dictionary")
    # process data here

In this case we want to work only with a dict class and its subclasses, inheritance is taken into consideration as well.

Example

Suppose we have a class hierarchy where Animal is a base class, and Dog and Cat are subclasses. We might want to write a function that behaves differently based on whether it's given a Dog or a Cat.

class Animal:
    pass

class Dog(Animal):
    def bark(self):
        return "Woof!"

class Cat(Animal):
    def meow(self):
        return "Meow!"

def make_sound(animal):
    if isinstance(animal, Dog):
        print(animal.bark())
    elif isinstance(animal, Cat):
        print(animal.meow())
    else:
        raise TypeError("make_sound only accepts Dog or Cat instances")

# Usage
make_sound(Dog())
make_sound(Cat())

Output

Woof!
Meow!

In this way, we handle different scenarios based on the object type, calling isinstance() function.

3.2.2 type()

While isinstance() checks an object's type against a class or a tuple of classes considering inheritance, type() is used to get the exact type of an object without considering subclassing. This can be useful for type checking that needs to ignore the inheritance hierarchy.

Example

class MyBaseClass:
    pass

class MyDerivedClass(MyBaseClass):
    pass

obj = MyDerivedClass()

if type(obj) is MyDerivedClass:
    print("obj is exactly MyDerivedClass")
else:
    print("obj is not exactly MyDerivedClass")

Output

obj is exactly MyDerivedClass

Or we can implement something similar to the example avoiding subclass interference.

if type(obj) is MyBaseClass:
    print("obj is exactly MyBaseClass and not a subclass")
else:
    print("obj is a subclass of MyBaseClass or an unrelated class")

Output

obj is a subclass of MyBaseClass or an unrelated class

Based on the table we could see when and which function should be used.

Personally, I don't like using type() in production code, as it can be dangerous if we want to work within inheritance paradigm and can lead to unexpected bugs or changing behaivours. But again, you will not find out what is best for your application unless trying.

3.2.3 isinstance() vs type()

Feature isinstance() type()
Checks inheritance Yes, considers an object an instance if it's derived from the class. No, checks for the object's immediate type only.
Use case Ideal for polymorphic behavior where subclass instances should be treated as instances of the base class. When you need to distinguish an object's exact type, especially to differentiate between a class and its subclass.
Syntax isinstance(object, class_or_tuple) type(object) is SomeClass

3.2.4 Type Guards

Type guards are constructs that explicitly check and narrow down the type of variables within a certain scope, making it safer to perform operations that are type-specific. You would want to use them when dealing with union types or the Any type, where the specific type might not be clear

Example

from typing import Union

def double(value: Union[int, str]):
    if isinstance(value, str):
        # The type of value is narrowed down to str here
        return value * 2
    elif isinstance(value, int):
        # The type of value is narrowed down to int here
        return value + value

print(double(11))
print(double('11'))

Output

22
1111

Explanation

In this code, value can be either an int or a str. The isinstance() checks act as type guards, narrowing down the type of value within each block and allowing for type-specific operations. That can useful in case we want to handle different operations with different approaches.

4. Duck Typing

The main concept of duck typing is that, a function can accept any object that has the required attributes or methods, regardless of the object's class.

"If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck"

Example

def quack_and_fly(thing):
    thing.quack()
    thing.fly()
    print("This thing looks like a duck and quacks like a duck.")

class Duck:
    def quack(self):
        print("Quack, quack!")

    def fly(self):
        print("Flap, flap!")

class Airplane:
    def fly(self):
        print("Whoosh!")

class Person:
    def quack(self):
        print("I'm quacking like a duck!")

    def fly(self):
        print("I'm flapping my arms!")

duck = Duck()
airplane = Airplane()
person = Person()

quack_and_fly(duck)      # This works fine
quack_and_fly(person)    # This works fine

# This will raise an AttributeError, because `airplane` has no `quack` method.
quack_and_fly(airplane)  

Output

Quack, quack!
Flap, flap!
This thing looks like a duck and quacks like a duck.
I'm quacking like a duck!
I'm flapping my arms!
This thing looks like a duck and quacks like a duck.

Traceback (most recent call last):
  File "<string>", line 32, in <module>
  File "<string>", line 2, in quack_and_fly
AttributeError: 'Airplane' object has no attribute 'quack'

Explanation

  • Despite their different types, all these objects can be used in the quack_and_fly() function
  • In case with airplane, we can use Type Guards to check the types passed or call hasattr() function to check if an object has a certain attribute or method before calling it.
  • Due to the fact that Python is a dynamical language, we can't really strictly enforce Python to define the type explicitly.

Duck typing is more than just a theoretical concept. It has practical applications in real-world scenarios, especially in web development.

Example

Consider a web application framework that needs to handle different types of HTTP requests. Instead of checking the type of request, you can rely on the presence of a method to handle it.

class GetRequestHandler:
    def handle(self, request):
        # process the GET request
        return "Handling GET request"

class PostRequestHandler:
    def handle(self, request):
        # process the POST request
        return "Handling POST request"

def process_request(handler, request):
    return handler.handle(request)

# Both GetRequestHandler and PostRequestHandler can be passed to process_request
get_handler = GetRequestHandler()
post_handler = PostRequestHandler()

print(process_request(get_handler, 'test'))  # As long as it has a handle method, it works
print(process_request(post_handler, 'test'))

Output

Handling GET request
Handling POST request

Example

In data analysis, you might encounter different data sources. Here's how duck typing allows you to write generic data loading functions:

class CSVDataLoader:
    def load_data(self, source):
        print(f"Loading data from a CSV file: {source}")

class ExcelDataLoader:
    def load_data(self, source):
        print(f"Loading data from an Excel file: {source}")

class SQLDataLoader:
    def load_data(self, source):
        print(f"Loading data from a SQL database: {source}")

def load_data_from_any_source(loader, source):
    loader.load_data(source)

csv_loader = CSVDataLoader()
excel_loader = ExcelDataLoader()
sql_loader = SQLDataLoader()

load_data_from_any_source(csv_loader, "data.csv")
load_data_from_any_source(excel_loader, "data.xlsx")
load_data_from_any_source(sql_loader, "database.sqldb")

Output

Loading data from a CSV file: data.csv
Loading data from an Excel file: data.xlsx
Loading data from a SQL database: database.sqldb

IMPORTANT: Duck typing requires careful handling, though it has a high flexebility, it can lead to potential pitfalls inside the app and situations where it's a disadvantage, rather than an advantage.

During designing your application, don't hesitate to look at this table, it can help you to decide the best approach to be used:

4.1 Duck Typing

OOP Concept Advantages Potential Drawbacks Use-Case Scenarios
Duck Typing - Flexibility
- Less boilerplate
- Natural polymorphism
- Possible runtime errors
- Less explicit type safety
- Small scripts
- When behavior is a priority over type
class Duck:
    def quack(self):
        print("Quack")

def make_sound(animal):
    animal.quack()

daffy = Duck()
make_sound(daffy)

4.2 Explicit Type Checking

OOP Concept Advantages Potential Drawbacks Use-Case Scenarios
Explicit Type Checking - Clear type contracts
- Compile-time error detection
- More boilerplate
- Less flexibility
- Large systems
- Safety-critical applications
def process_data(data):
    if not isinstance(data, dict):
        raise ValueError("Expected a dictionary")
    # process data here

4.3 Abstract Base Classes (ABC)

OOP Concept Advantages Potential Drawbacks Use-Case Scenarios
Abstract Base Classes - Enforces an interface
- Explicit contracts between code parts
- Requires more upfront design
- Can be overkill for simple cases
- Plugin systems
- Framework development
from abc import ABC, abstractmethod

class AbstractAnimal(ABC):
    @abstractmethod
    def sound(self):
        pass

class Dog(AbstractAnimal):
    def sound(self):
        print("Woof!")

fido = Dog()
fido.sound()

4.4 Static Typing (Type Hints, mypy)

OOP Concept Advantages Potential Drawbacks Use-Case Scenarios
Static Typing - Early error detection
- Improved IDE support and code completion
- Additional complexity in annotations
- Steeper learning curve for new Python users
- Large codebases
- Applications with a long lifecycle
def greet(name: str) -> str:
    return f'Hello, {name}'

greeting = greet("Alice")
print(greeting)

Ultimately, the choice of when and how to use these concepts depends on the specific requirements of your project, your team's preferences, and the need to balance development speed with code safety and maintainability.

Personally, I prefer more explicit type checking, static typing for large projects, and Abstract Base Classes. Not a big fun of duck typing, as all the time you have a feeling that something will break :)

Enough theory for today, try it yourself!

5. Quiz

Question 1:

What is the primary purpose of using mixins in object-oriented programming?

A) To serve as the primary base class for all classes in an application.
B) To provide a set of methods that can be used in various unrelated classes to enhance functionality without using inheritance.
C) To enforce strict data typing in Python.
D) To replace the functionality of interfaces in Python.

Question 2:

In the context of duck typing, which of the following statements is true?

A) Duck typing refers to the type system used by Python to manage memory more efficiently.
B) Duck typing allows a function to accept any object that meets the required interface, regardless of its specific type.
C) Duck typing is a programming style where the type of the variable is known only at runtime.
D) Duck typing requires classes to inherit from a common base to be interchangeable in a function.

Question 3:

What does this sippet illustrate?

class EncryptionMixin:
    def encrypt(self, message):
        # Simulated encryption logic
        return message[::-1]

class DecryptionMixin:
    def decrypt(self, message):
        # Simulated decryption logic
        return message[::-1]

class SecureCommunicator(EncryptionMixin, DecryptionMixin):
    pass

comm = SecureCommunicator()
encrypted = comm.encrypt("hello")
print(comm.decrypt(encrypted))

A) Singleton pattern
B) Factory method pattern
C) Use of mixins for adding encryption and decryption methods to a class
D) The use of the decorator pattern to enhance method functionality

Question 4:

What is a metaclass in Python primarily used for?

A) To prevent the modification of a class once it has been created.
B) To define a class for an ORM framework like SQLAlchemy or Django.
C) To create classes and control the behavior of class creation.
D) To implement mandatory methods in derived classes like an abstract base class.

Question 5:

Which Python feature allows the creation of a class whose instances can only have one instantiation no matter how many times the class is instantiated?

A) Encapsulation
B) Inheritance
C) Metaclass with Singleton Pattern
D) Duck typing with type hints

6. Homework

I want you to be creative and sumbit H/W tasks for review in the related MR. Find your own usage of patterns and techniques described above!