Python Protocol Oriented Programming

Python Objects 🐍

In Python everything is an object.

An object is formed by:

  • A memory location identified internally by an id.
  • A type (class) that determines the protocols of the object.
  • A value (or a set of values) that the object holds.

Example:

# Given an object identified by the variable name `a`
>>> a = 1

# We can access its memory location `id`
>>> id(a)
1407854654624

# We can access its type
>>> type(a)
'<class 'int'>'

# We can access its value
>>> a
1

# We can use the behavior defined on its protocol
>>> a + 1
2
>>> a.hex()
'0x1'

The above will be similar for every object in Python.

Types 🔠

Every object is based on a type, a type is a class definition.

  • print <- is function type
  • "Hello" <- is str type
  • 0 <- is int type
  • [0:5] <- is a slice type
  • str.upper <- is a method_descriptor type
Function String Integer       Slice
    __⬆️__  __⬆️__ _⬆️___         __⬆️__
    print("Hello"[0] + "World!"[0:5].upper())
                  ___⬆️___            __⬆️___
                   Symbol            Method Descriptor

Protocols 📝

Python is a POP Language (Protocol Oriented Programming)

The type of the object determines its implementation, which exposes the behavior, the behavior are the things that the object can do or things that can be done with the object.

There are languages that calls it Traits of an object.

Each set of those abilities is what we call a Protocol, protocols are useful for setting contracts 🤝 between objects.

Identifying protocols on the Hello World program:

Callable  Subscriptable   Sliceable
    __⬆️__  ______⬆️__   __________⬆️__
    print("Hello"[0] + "World!"[0:5].upper())
             ________⬆️________      __⬆️__
                  Summable         Callable
  • Callable 🗣️ Can be invoked using ()
    A type is also callable when its protocol includes the __call__ method.

  • Subscriptable ✍🏻 its elements can be accessed through a subscription.
    The subscription can be numeric ordinal [0] or named key ['name']. A type is Subscriptable when its protocol includes the __getitem__ method.

  • Sliceable 🔪 its collection of elements can be sliced in parts.
    A type is Sliceable when it is Subscriptable and its __getitem__ method can accept a slice in place of the index or name. A slice is the composition of [start:stop:step] elements.

  • Summable ➕ Can be combined with other objects via + operation.
    The product of this combination is always new object. On numeric types this is the ability to sum two or more numbers in a set. On sequences it is the concatenation of its fragments in to one. A type is Summable when its protocol includes the __add__ or __radd__ method.

  • Printable 🖨️ Can be printed using print
    All the Python objects are printable, print will look either for a __repr__ or a __str__ method for printing the object.

ℹ️ There are many more and in fact, you can define custom protocols, Protocols are very generic and there is no official list of protocols although there are some pre-defined protocols in the typing module.

from typing import Iterator, Iterable, Optional, Sequence, Awaitable, Generic

Complete list of protocols and sub typing is available on https://mypy.readthedocs.io/en/stable/protocols.html

🦆 Protocols empowers an approach called Duck Typing which is the fact that in Python if an object looks like, behaves like, and has the behavior of a Duck, it is said to be a Duck, regardless if this is the case of a Dog that learned to say quack immitating a Duck 🐶.

Typing and Protocol Checking

Some protocols can be checked using built-in functions

callable(print) is True
callable("Hello") is False

Some protocols must be checked against its type class

isinstance("Hello", str) is True
isinstance(0, slice) is False

There are cases where the only way to verify protocols
is checking for its attributes.

hasattr("Hello", "__add__") is True  # Summable, we can use `+` operator.

Others where we need to use the EAFP pattern.

try: 
    "Hello" + 1 
except TypeError: # Strong type checking
    # we cannot `__add__` an `str` to an `int`

Typing Protocols

Python3 offers a way to define custom protocols

from typing import Protocol, runtime_checkable

@runtime_checkable
class CallableSummableSubscriptable(Protocol):
    def __call__(self) -> T: 
        ...
    def __add__(self, other: T) -> T: 
        ...
    def __getitem__(self, item: T) -> T: 
        ...

ℹ️ Protocol methods are just signatures with empty bodies, stated by the .... T is usually a type alias indicating a generic type.

Protocols are useful to define contracts, bounds on function signatures for example defining a function that accepts an argument only if the type has the specified protocol.

def awesome_function(thing: CallableSummableSubscriptable):
    # accepts only objects that implements that ⬆️ protocol.
    # Protocols can be checked at runtime @runtime_checkable
    # Or checked using static analysers e.g: mypy

Is there the Traditional OOP in Python?

OOP Python is actually POP (Protocol Oriented Programming)

  • More about Protocols and Behavior.
  • Less about tradicional OOP concepts.

All the traditional concepts and patterns are also available, but some are intrinsic to objects and protocols that in the end of the day the programmers doesn't have to take care of it at all in the same way.

Inheritance

The ability to inherit from another base type and take all its behavior and the ability to override with custom implementation.

class MyString(str)
    def __str__(self):
        return super().__str__().upper()

>>> x = MyString("Bruno")
>>> print(x)
"BRUNO"

Encapsulation

The ability to hide object attributes and methods and expose only a selected set of them or to expose them in a more controlled way.

# Descriptor is a protocol for getter/setter like approach.
class Field:
    def __get__(...):
    def __set__(...):

class Thing:
    # Double underline means that the field is private
    # but actually it is only a naming mangling convention.
    __protected_attr = Field()

    # Properties can also be used to define getter/setter
    @property
    def foo(self): 
        return self.__protected_attr
    @foo.setter
    def set_foo(self, value): 
        self.__protected_attr = value

Polymorfism

The ability for objects to behave differently regardless of its base type and for procedures to take different types of objects as arguments.

len("Bruno")
len([1, 2, 3])

dict.get("key")
dict.get("key", default="other")

print("Hello")
print(123)
print(*["Hello", "World"])

def function(*args, **kwargs):
    ...

ℹ️ In traditional OOP literature polymorphism is often used to define only the ability to have methods reusing the same name but different implementation, but in fact it goes deeper than this.

Conclusion

🧑‍🍳 A Chef when selecting ingredients for a recipe, will look for the protocols defined on each ingredient, the Chef can go to the supermarket and see a set of different types of onions 🧅, even if the recipe only says onion the Chef knows based on the desired behavior that wants a specific type of onion, white onions are swetter, better for cooked recipes, the purple onions are more acid so better for salads and raw recipes. (but that also depends a bit on taste)

🧑‍💻 The Software Engineer when choosing the data structures to use in an algorithm 🤖 must look to the protocols defined and how the objects behaves even if the requirements says it is a collection of texts for example, the engineer must analyse the program to choose wisely between a tuple, a list or even a set.

The protocols are often more important than the types.

NOTE: Credits to @mathsppblog to have inspired the first paragraph of this post https://twitter.com/mathsppblog/status/1445148609977126914

17