Protocols: Default Methods, Inheritance, and More

Explore the advanced use of Protocols in Python with our latest article. Dive into practical examples showcasing unions, runtime checkable Protocols, and combining behaviors for enhanced type safety and flexibility in Python programming. Learn about Python's evolving type system with us.

Protocols: Default Methods, Inheritance, and More
The roots of Python protocols run deep, branching into infinite possibilities.

Overview

In our previous exploration, "Static Duck Typing with Python's Protocols," we delved into how Protocols in Python enhance type safety and readability through structural typing. In this article, we'll uncover some of the advanced features of Protocols that make them a powerful tool in Python's type system.

Protocols in Python are not just about defining interfaces; they offer a range of sophisticated functionalities that can significantly improve the flexibility and robustness of your code. This article will explore these advanced aspects, including default methods, inheritance, and generics.

By the end of this article, We'll have a deeper understanding of how to leverage these advanced features of Protocols to write more efficient, readable, and maintainable Python code.


Default Method Implementations

A default implementation in a Protocol allows the definition of methods with a concrete implementation directly within the Protocol. This is a significant departure from the traditional understanding of interfaces, which typically dictate only the method signatures without implementation.

Including default implementations means that any class implementing a Protocol can use these out-of-the-box methods without providing its version. This functionality is beneficial for delivering common or generic implementations that can serve as a fallback or standard behavior.

This aspect of Protocols resembles Scala or Rust's traits. Traits allow the bundling of method definitions with an interface. While the Protocol's primary benefit is develop-time-checked structural typing, adding default implementations brings a flavor of trait-like functionality to the language.

It's important to note that classes in Python do not need to be directly inherited from a Protocol to be considered as implementing that Protocol. This is in line with Python's dynamic and flexible nature. However, direct inheritance becomes necessary if a class wishes to utilize the default implementations provided by a Protocol. This allows the class to inherit these implementations, reducing redundancy and simplifying the codebase:

Python ≥ 3.8

from typing import Protocol

class Greetable(Protocol):
    def greet(self) -> str:
        return "Hello, I'm a default greeting!"

class Person(Greetable):
    pass

class Robot:
    def greet(self) -> str:
        return "Hello, I'm a robot!"

# Usage
person = Person()
robot = Robot()

print(person.greet())  # Uses default implementation from Greetable
print(robot.greet())   # Uses its own implementation
        


In this example:

  • Greetable is a Protocol with a default greet method.
  • Person inherits from Greetable and uses the default greet method.
  • Robot does not inherit from Greetable but provides its greet method, demonstrating that direct inheritance from the Protocol is optional to conform to it.

Through default implementations in Protocols, Python extends its type system's capabilities, allowing developers to write more expressive and flexible code that resonates with patterns found in other modern programming languages like Scala and Rust.


Understanding Protocol Inheritance

First and foremost, Protocols follow the conventional inheritance rules found in Python. This means a class that inherits from another will inherit its attributes and methods. This typical behavior also applies to Protocols, allowing them to form hierarchies and extend the functionality of parent Protocols.

Special Rules for Protocol Inheritance

The uniqueness of Protocol inheritance comes into play with two fundamental rules:

  1. Requirement of typing.Protocol as a Direct Parent: For a class to be considered a Protocol, it must have typing.Protocol as a direct parent. This explicit requirement ensures clarity in the class’s role as a Protocol. It distinguishes regular classes, which may have similar structures but do not serve as Protocols, from actual Protocol classes.
  2. Protocol Hierarchies Must Consist Solely of Protocols: In a hierarchy involving a Protocol, all classes in the inheritance chain must be Protocols. This rule ensures that the structural typing principles that Protocols are based on are consistently maintained throughout the inheritance chain.
  3. Restriction Against Extending Non-Protocol Classes: A Protocol cannot extend a non-Protocol class. This restriction is in place to maintain the integrity and purpose of Protocols. Since Protocols are meant to define behaviors (interfaces) rather than concrete implementations, inheriting from a regular class could introduce specific implementations and states, which goes against the fundamental concept of Protocols.

Practical Implications and an Example

These rules have profound implications on how Protocols are used and defined in Python. They ensure that Protocols remain pure as behavior definers without getting muddled with concrete class implementations.

Suppose we are working on a system where we need to handle both serialization and logging functionalities. We start by defining two basic Protocols, each representing one functionality:

Python ≥ 3.8

from typing import Protocol

# Define the basic Serializable Protocol
class Serializable(Protocol):
    def serialize(self) -> str:
        ...

# Define the basic Loggable Protocol
class Loggable(Protocol):
    def log(self, message: str) -> None:
        ...

# Combine Serializable and Loggable into a new Protocol
class SerializableAndLoggable(Serializable, Loggable, Protocol):
    pass

# Implement the combined Protocol
class DataProcessor(SerializableAndLoggable):
    def serialize(self) -> str:
        return "Serialized Data"

    def log(self, message: str) -> None:
        print(f"Log: {message}")

# Usage
processor = DataProcessor()
# Outputs: Serialized Data
print(processor.serialize())  
          
# Outputs: Log: Processing completed.
processor.log("Processing completed.")  
        

Here, Serializable defines a behavior for serialization, and Loggable defines a behavior for logging. Neither Protocol extends a non-Protocol class, adhering to the rules of Protocol inheritance.

Next, we create a new protocol that combines the functionalities of both serializable and logical. This new Protocol will be used for objects needing serialization and logging capabilities.

In this example, SerializableAndLoggable is a valid Protocol that inherits from both Serializable and Loggable. This new Protocol does not introduce any new methods but combines the requirements of its parent Protocols.

In the DataProcessor class, we implement the serialize and log methods, as the SerializableAndLoggable Protocol requires. This class is a practical example of how a Protocol can be used to enforce the implementation of multiple behaviors.


Using Generics With Protocols in Python

Generic Protocols are similar to regular Protocols but include one or more type variables. These type variables make the Protocol adaptable to different types, allowing you to define a standard interface with varying data types.

The use of implicit type variables in Generic Protocols is also reflected in Python's standard library, as seen with Protocols like Iterable and Iterator. These Protocols use generic types to define the structure of the elements they handle, providing a clear and concise way to express these types.

We can pass the type variables to typing.Protocol to define a Generic Protocol. Here's an example using a simple Generic Protocol for a container that can hold items of any type:

Python ≥ 3.8

from typing import Protocol

class BidirectionalMapper(Protocol[T, U]):
    def to(self, item: T) -> U:
        ...

    def from_(self, item: U) -> T:
        ...

class IntStringMapper(BidirectionalMapper[int, str]):
    def to(self, item: int) -> str:
        return str(item)

    def from_(self, item: str) -> int:
        return int(item)

# Usage
mapper = IntStringMapper()
print(mapper.to(42))       # Outputs: '42'
print(mapper.from_('42'))  # Outputs: 42      
        

An Example of Python’s Protocols

A Python entwined in digital code orbits, showcasing the dynamic capabilities of Python protocols.
Coding with precision. Python protocols weave through the fabric of programming.

In this section, we'll explore a practical application of Protocols in Python, focusing on a scenario involving file handling and data processing. This example will demonstrate the use of Protocols with unions, runtime checkable Protocols, and the combined functionality of different Protocols.

In this example, different classes handle files and data processing. We'll define Protocols for each of these responsibilities and show how they can be combined and utilized in various functions:

Python ≥ 3.8

from typing import Union, Protocol, runtime_checkable

class FileHandler(Protocol):
    def read(self) -> str:
        ...

    def write(self, data: str) -> None:
        ...

class DataProcessor(Protocol):
    def process(self, data: str) -> str:
        ...

class FileDataHandler(FileHandler, DataProcessor, Protocol):
    pass

def read_process_write(handler: Union[FileHandler, DataProcessor, FileDataHandler], data: str):
    if isinstance(handler, DataProcessor):
        data = handler.process(data)
    if isinstance(handler, FileHandler):
        handler.write(data)

@runtime_checkable
class RuntimeChecker(Protocol):
    def check(self) -> bool:
        ...

class TextFileHandler:
    def read(self) -> str:
        return "Sample text from file"

    def write(self, data: str) -> None:
        print(f"Writing to file: {data}")

class SimpleDataProcessor:
    def process(self, data: str) -> str:
        return data.upper()

file_handler = TextFileHandler()
data_processor = SimpleDataProcessor()
combined_handler = TextFileHandler()  # Assume it also implements DataProcessor

read_process_write(file_handler, "Some data")
read_process_write(data_processor, "Some data")
read_process_write(combined_handler, "Some data")

if isinstance(combined_handler, RuntimeChecker):
    print("Runtime check passed")
        

We start by defining two basic Protocols, FileHandler and DataProcessor, each specifying methods relevant to their functionalities (lines 4-13). These Protocols are then combined into FileDataHandler, creating a new Protocol that encapsulates the responsibilities of both file handling and data processing (lines 14-16).

The function read_process_write (lines 18-25) demonstrates the versatility of Protocols. It accepts an argument that can be of type FileHandler, DataProcessor, or the combined FileDataHandler. Line 18th shows that unions work with Protocols (line 18) as with standard classes.

The RuntimeChecker Protocol (lines 27-30) is marked with @runtime_checkable, enabling runtime type checking with isinstance. This feature is demonstrated later in the code (lines 47-48), where a runtime check confirms whether an object conforms to this Protocol.

Concrete classes TextFileHandler and SimpleDataProcessor (lines 32-41) provide specific implementations for the methods defined in the FileHandler and DataProcessor Protocols. Their instances are created and used in the read_process_write function (lines 43-45), illustrating how objects adhering to different Protocols can be handled within the same function.


Conclusion

In this article, we explored the power of Python's Protocols. Through a practical example of file handling and data processing, we've seen how Protocols can be adeptly used to manage diverse functionalities, from combining different behaviors to allowing flexible function parameters with unions.

Protocols extend Python’s type hinting capabilities, offering a robust framework for building more readable, maintainable, and reliable code. The use of unions, runtime checkable Protocols, and the seamless combination of different Protocols, as demonstrated, underlines the dynamic nature of Python, catering to a wide range of programming needs.

Looking ahead, there's more to uncover about Python's type system, such as recursive Protocols and self-types, which we'll explore in upcoming articles. Stay tuned and continue to delve deeper into Python's ever-evolving landscape, where each new feature brings a fresh perspective and toolkit for tackling the challenges of modern software development.

Remember to subscribe to The Turing Taco Tales for more in-depth analysis and Python tips.


Addendum: A Special Note for Our Readers

I decided to delay the introduction of subscriptions, you can read the full story here.

In the meantime, I decided to accept donations.

If you can afford it, please consider donating:

Every donation helps me offset the running costs of the site and an unexpected tax bill. Any amount is greatly appreciated.

Also, if you are looking to buy some Swag, please visit I invite you to visit the TuringTacoTales Store on Redbubble.

Take a look, maybe you can find something you like: