Effective Python #5 | Classes and Interfaces


Introduction

  • As an OOP language, Python supports a full range of features, such as inheritance, polymorphism, and encapsulation

  • Python’s classes and inheritance make it easy to express a program’s intended behaviors with objects.

    • They allow you to improve and expand functionality over time.

    • They provide flexibility in an environment of changing requirements.



Item 37. Compose Classes Instead of Nesting Many Levels of Build-in Types

  1. Avoid making dictionaries with values that are dictionaries, long tuples, or complex nestings of other build-in types.

  2. Use namedtuple for lightweight, immutable data containers before you need the flexibility of a full class.

  3. Move your bookkeeping code to using multiple classes when your internal state dictionaries get complicated

# Before : nested build-in types in dictionary
class BySubjectGradeBook:
    def __init__(self):
        self._grades = {}

    def add_student(self, name):
        self._grades[name] = defaultdict(list)

    def report_grade(self, name, subject, grade):
        by_subject = self._grades[name]
        grade_list = by_subject[subject]
        grade_list.append(grade)

    def average_grade(self, name):
        by_subject = self._grades[name]
        total, count = 0, 0    
        for grades in by_subject.values():
            total += sum(grades)
            coutn += len(grades)
        return total/count
# After : Use multiple classes
class Subject:
    def __init__(self):
        self._grades = []

    def report_grades(self, score, weight):
        self._grades.append(Grade(score, weight))

    def average_grade(self):
        return total / total_weight # omitted details

class Student:
    def __init__(self):
        self._subjects = defaultdict(Subject)

    def get_subject(self, name):
        return self._subjects[name]

    def average_grade(self):
        return total / count # omitted details 

class Gradebook:
    def __init__(self):
        self._students = defaultdict(Student)

    def get_student(self, name):
        return self._students[name]


Item 38. Accecpt Functions Instead of Classes for Simple Interfaces

  1.  Instead of defining and instantiating classes, you can often simply use functions for simple interfaces between components in Python.

  2. References to functions and methods in Python are first class, meaning they can be used in expressions (like any other type).

  3. The __call__ special method enables instances of a class to be called like plain Python functions.

  4. When you need a function to maintain state, consider defining a class that provides the __call__ method instead of defining a state-

    ful closure.

class BetterCountMissing:
    def __init__(self):
        self.added = 0

    def __call__(self):
        self.added += 1
        return 0


Item 39. Use @classmethod Polymorphism to Construct Object Generically

  1. Python only supports a single constructor per class: the __init__ method.

  2. Use @classmethod to define alternative constructors for your classes.

  3. Use class method polymorphism to provide generic ways to build and connect many concrete subclasses.

    • Constructor Polymorphism (overloading) : A concept in OOP where a class can have multiple constructors with different number or types of parameters ( in other languages, python only provides __init__)

    • You can achieve similar functionality by using @classmethodin python to create alternative constructors

# example not in the book
class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    @classmethod
    def from_birth_year(cls, name, birth_year, current_year):
        age = current_year - birth_year
        return cls(name, age) # Calls the __init__ method

    @classmethod
    def from_string(cls, data_string):
        name, age_str = data_string.split(',')
        age = int(age_str.strip())
        return cls(name.strip(), age) # Calls the __init__ method

# Using the primary constructor
person1 = Person("Alice", 30)
print(f"Person 1: {person1.name}, {person1.age}")

# Using an alternative constructor (from_birth_year)
person2 = Person.from_birth_year("Bob", 1990, 2025)
print(f"Person 2: {person2.name}, {person2.age}")

# Using another alternative constructor (from_string)
person3 = Person.from_string("Charlie, 25")
print(f"Person 3: {person3.name}, {person3.age}")<br>

Item 40. Initialize Parent Classes with super

  1. Python’s standard method resolution order solves the problems of superclass initialization order and diamond inheritance

  2. Use the super built-in function with zero arguments to initialize parent classes

# Diamond inheritance
class MyBaseClass:
    def __init__(self, value):
        self.value = value

class TimesSeven(MyBaseClass):
    def __init__(self, value):
        MyBaseClass.__init__(self, value)
        self.value *= 7

class PlusNine(MyBaseClass):
    def __init__(self, value):
        MyBaseClass.__init__(self, value)
        self.value += 9

class ThisWay(TimesSeven, PlusNine):
    def __init__(self, value):
        TimesSeven.__init__(self, value)
        PlusNine.__init__(self, value) # This line resets the value to 5


ㅆ# >> Should be (5 * 7) + 9 = 44 but is 14
# The call to the second parent class’s constructor, PlusNine.__init__, causes self.value to be reset back to 5 when MyBaseClass
# To solve these problems, Python has the super built-in function and standard method resolution order (MRO). 
# super ensures that common superclasses in diamond hierarchies are run only once
class TimesSevenCorrect(MyBaseClass):
    def __init__(self, value):
        super().__init__(value)
        self.value *= 7

class PlusNineCorrect(MyBaseClass):
    def __init__(self, value):
        super().__init__(value)
        self.value += 9

# Now, the top part of the diamond, MyBaseClass.__init__, is run only a single time. The other parent classes are run in the order specified in the class statement
class GoodWay(TimesSevenCorrect, PlusNineCorrect):
    def __init__(self, value):
        super().__init__(value)


Item41. Consider Composing Functionality with Mix-in Classes

  1. Avoid using multiple inheritance with instance attributes and __init__ if mix-in classes can achieve the same outcome.

    • A mix-in is a class that defines only a small set of additional methods for its child classes to provide.
  2. Use pluggable behaviors at the instance level to provide per-class customization when mix-in classes may require it.

  3. Mix-ins can include instance methods or class methods, depending on your needs.

  4. Compose mix-ins to create complex functionality from simple behaviors.

# Define an example mix-in that accomplishes 
class ToDictMixin:
    def to_dict(self):
        return self._traverse_dict(self.__dict__)

    def _traverse_dict(self, instance_dict):
        output = {}
        for k,v in instance_dict.items()
            output[key] = self._traverse(key, value)
        return output

    def _traverse(self, key, value):
        if isinstance(value, ToDictMixin):
            return value.to_dict()
        elif isinstance(value, dict):
            return self._traverse_dict(value)
        elif isinstance(value, list):
            return [self.traverse(key, i) for i in value]
        else:
            return value

# Define an example class that uses the mix-in to make a dictionary representation of a binary tree
class BinaryTree(ToDictMixin):
    def __init__(self, value, left=None, right=None):
        self.value = value
        self.left = left
        self.right = right

# Translating a large number of related Python objects into a dict becomes easy:
tree = BinaryTree(10,
    left = BinaryTree(7, right=BinaryTree(9)),
    right = BinaryTree(13, left=BinaryTree(11)))


Item42. Prefer Public Attributes Over Private Ones

  1. Private attributes aren’t regorously enforced by the Python complier

  2. Plan from the beginning to allow subclasses to do more with your internal APIs and attributes instead of choosing to lock them out.

  3. Use documentation of protected fields to guide subclasses instead of trying to force access control with private attributes

  4. Only consider using private attributes to avoid naming conflicts with subclasses that are out of your control

# In Python, there are only two types of visibility for a class’s attributes: public and private
Class MyObject:
    def __init__(self):
        self.public_field = 5
        self.__private_field = 10

    def get_private_field(self):
        return self.__privage_field

    # class methdos also have access to private attributes    
    @classmethod
    def get_private_field_of_instance(cls, instance):
        return instance.__private_field

Class MyChildObject(MyObject):
    def get_private_field(self):
        return self.__private_field


Item43. Inherit from collections.abc for Custom Container Types

  1. Inherit directly from Python’s container types (like list or dict) for simple use cases

  2. Beware of the large number of methods required to implement custom container types correctly

  3. Have your custom container types inherit from the interfaces defined in collections.abc to ensure that your classes match required interfaces and behaviors

# When you access a sequence item by index:
bar = [1,2,3]
bar[0] 
# above line will be interpreted as : 
bar.__getitem__(0)
# So, to make your own class act like a sequence, you can provide a custom implementation of __getitem__
class MyClass():
    def __init__(self, values):
        self.values = values

    def __getitem__(self, index):
        for i, item in enumerate(values):
            if i == index:
                return item
        raies IndexError(f'Inted {index} is out of range')
# But it's not enough to provide all of the sequence semantics you'd expect from a list instance
test = MyClass(values=[1,2,3])
len(test) # >> MyClass has no len()
# You can directly inherit list like :
class Myclass(list):
...
# The built-in collections.abc module defines a set of abstract base classes that provide all of the typical methods for each container type
from collections.abc import Sequence
class BadType(Sequence):
    pass

foo = BadType()
>>>
Traceback ...
TypeError: Can't instantiate abstract class BadType with  ̄abstract methods __getitem__, __len__

# When you do implement all the methods required by an abstract base class from collections.abc, it provides all of the additional methods, like index, count, ... for free.