5 Classes and Inhertiance

Last edited

37 Compose classes instead of nesting builtin-types

This is a walk through of how you go from using simple dicts to a full class for managing data.

  1. start with a dict for key/values

    When to move on:
    - once you have a dict in a dict
  2. collections.namedtuple

    • you get to name attributes thus stablizing the API for a future class
    • no types
    • no default attributes
    • no mutables
    • cant control init or repr
    When to move on:
    - once you need types or default attributes
  3. typing.NamedTuple

    • default attributes
    • types
    • some of the same issues as above
    When to move on: 
    - once you need the missing parts of tuples
    resource: https://peps.python.org/pep-0557/#why-not-just-use-namedtuple
  4. @dataclass

    When to move on: 
    - you want real control __init__ at construction time (__post_init__ only patches the tail end).
    - args that don't map 1:1 to storage.
    - or the class is more behavior than data so dataclasses' default __eq__ is wrong/irrelevant.
  5. class

38 Accept functions instead of classes for simple interfaces

Question

What does def do?

Answer

def creates a function object, storing the compiled body in code. The object is callable because its type (types.FunctionType) defines call, which inturn calls __code__

So if want to make something that isn’t a function ‘callable’ we just need to meet the interface of __call__

We all know you can pass a generic function into a function for it to be run.
The point of this is you can pass in a class AND STORE STATE.

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

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

counter = BetterCountMissing()
assert counter() == 0
assert callable(counter)
counter = BetterCountMissing()
result = defaultdict(counter, current) # Relies on __call__
for key, amount in increments:
    result[key] += amount
assert counter.added == 2

So we emulated a function to meet the interface and snuck in our state tracking.

40 The purpose of super

The main takeaway: always use super() when calling a parent class, rather than the name directly.

The problem

The old way to initialize a parent class from a child:

Note

This example outlines the issue of not using super() and how fragile calling parent’s __init__ is.

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)

foo = ThisWay(5)
print('Should be (5 * 7) + 9 = 44 but is', foo.value)  # 14

PlusNine.__init__ calls MyBaseClass.__init__ a second time, resetting value back to 5. So TimesSeven’s *7 is wiped out and you get 5 + 9 = 14.

diamond inhertiance

The super function

You should instead use super(), as it allows python to MRO (method resolution order) to ensure methods are called in the correct order

class MyBaseClass:
    def __init__(self, value):
        self.value = value
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
class GoodWay(TimesSevenCorrect, PlusNineCorrect):
    def __init__(self, value):
        super().__init__(value)

foo = GoodWay(5)
print('Should be 7 * (5 + 9) = 98 and is', foo.value)  # 98

Calling specific parents

You can still refer to specific parents, but make sure to use super(Parent)

Note

This is rare and I have no idea a use case, but basically always use super, and pass in a specific parent if you need to for MRO safety.

class MyBaseClass:
    def __init__(self, value):
        self.value = value
    def describe(self):
        return f"value={self.value}"

# ... [snip same as above]

class GoodWay(TimesSevenCorrect, PlusNineCorrect):
    def __init__(self, value):
        super().__init__(value)          # normal: MRO drives init

    def describe(self):
        base = super(PlusNineCorrect, self).describe() # call a specific implementation
        return f"GoodWay({base})"

foo = GoodWay(5)
print(foo.value)         # 98  (normal cooperative init, untouched)
print(foo.describe())    # GoodWay(value=98)

41 Mix-in Classes instead of Multiple inhertance

40 showed how to use multiple inhertiance safely, although it’s better to avoid multiple inhertiance all together.

Definition

A mix-in is a class that defines only a small set of additional methods for its child classes to provide. Mix-in classes don’t define their own instance attributes nor require their __init__ constructor to be called.

Example

class ToDictMixin:
    def to_dict(self):
        return self._traverse_dict(self.__dict__)

    def _traverse_dict(self, instance_dict) 
        ... # implementation
    
class User(ToDictMixin):
    def __init__(self, username: str):
        self.username = username

user = User("mj")
user.to_dict()

This could also be used to implement a sort of interface, where it gives you some methods while requiring the client provide some.

class CRUDMixin:
    def _store(self):
        raise NotImplementedError   # child provides

    def create(self, **data):
        # ideally this would do some heavy lifting to justify it
        self._store().update(data)
        return data

class Book(CRUDMixin):
    _books = {}
    def _store(self):
        return self._books

Book().create(id=1, title="Dune")

42 Prefer public attributes over private

Lean towards using protected attributes when you don’t want an attribute to be accessed not private, one day someone may have a reason to access that attribute, don’t make them use jank to access it.

The only time you should use private is to avoid naming conflicts with child classes.

  • __foo private - name mangled to _ClassName__foo discourages access and prvents subclass clashes but not enforced, reachable by the mangled name
  • _foo protected - you can use these but proceed with caution
  • foo public - you can use these

Brett Slatkin p. 172

We are all consenting adults here. We don’t need the language to prevent us from doing what we want to do. It’s our individual choice to extend functionality as we wish and to take responsibility for the consequences of such a risk

43 Inherit from collections.abc for Custom Container Types

Brett Slatkin p. 175

Much of programming in Python is defining classes that contain data and describing how such objects relate to each other. Every Python class is a container of some kind, encapsulating attributes and functionality together.

Use collections.abc’s base classes to help you implement interfaces, they’ll do the heavy lifting and tell what you must provide to meet their requirements.

Example, I want to make my own sequence, I could do guess work and try to implement all the dunder methods, or I could have a friendly abstract base class from collections tell what I need.

Definition

Interfaces define methods that are typically abstract, which means that the interface declares them, but doesn’t implement them.

from collections.abc import Sequence

class BadType(Sequence):
    pass

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

#################################################
# Now that I know what I need I can implement it.

class MySequence(Sequence):
    def __init__(self, members):
        self._members = members

    def __getitem__(self, index):
        return self._members[index]

    def __len__(self):
        return len(self._members)

foo = MySequence([10, 5, 6, 7, 15, 11])

print('Index of 7 is', foo.index(7))   # free from Sequence
print('Count of 10 is', foo.count(10)) # free from Sequence
print('11 in seq', 11 in foo)         # free (__contains__)
print('As list:', list(foo))           # free (__iter__)
>>>
# Index of 7 is 3
# Count of 10 is 1
# 11 in seq True
# As list: [10, 5, 6, 7, 15, 11]