Interfaces en Python

Como he comentado en otros posts, actualmente estoy trabajando en un proyecto en Python aplicando Ports & Adapters donde las interfaces son un elemento muy importante. En este post voy a explorar las diferentes opciones que tenemos en Python para definir interfaces.

Interfaces trabajando con Ports & Adapters

La importancia de las interfaces cuando se trabaja en un proyecto aplicando el patrón Ports & Adapters es muy importante. Estas interfaces suelen ser denominadas puertos y cada una de sus implementaciones adapters.

Estas interfaces nos permiten definir a grandes rasgos el comportamiento que esperamos de determinados componentes de nuestro sistema.

Un ejemplo típico, como veremos después, suele ser la creación y borrado de usuarios. Esta creación puede ser implementada de múltiples maneras (en memoria, en una BBDD, etc.). Si todas y cada una de estas implementaciones “respetan” una interfaz común, cambiar unas por otras sería transparente para el resto de la aplicación.

Definir estas interfaces nos permite, por tanto que nuestro código dependa de ellas en lugar de implementaciones concretas, haciéndolo mucho más flexible, fácil de cambiar y sobre todo muy testeable.

Python no tiene interfaces

Así es, dicho todo esto, resulta que Python no tiene interfaces como tal!!! Esto es algo que me choco muchísimo en su día cuando lo descubrí. Por suerte existen maneras de conseguir un comportamiento muy similar a una interfaz, aunque sea haciendo uso de clases (concretamente clases abstractas y herencia).

Usando clases abstractas

Me gustaría empezar por la forma en la que yo personalmente he estado trabajando hasta ahora, que es haciendo uso de clases abstractas y herencia. Acerca de esta forma de crear interfaces se puede leer en la propia documentación de Python:

The “drawback” of this approach is the necessity to either subclass the abstract class or register an implementation explicitly.

Yo ,personalmente, no lo considero un “drawback”, ya que este comportamiento nos obliga a ser explícitos en cuanto a qué interfaces estamos implementando, lo que hace nuestro código mucho más entendible.

La forma de definir una interfaz en Python sería la siguiente:

  • Definir una clase abstracta que herede de ABC.
  • Definir los métodos que queremos que implementen las clases que hereden de la interfaz.
    • Para ello usamos el decorador @abstractmethod.
    • Los métodos no tienen implementación, solo la firma.
    • Los métodos deben levantar una excepción NotImplementedError.

Como podemos ver en el siguiente ejemplo, la interfaz UsersRepository define las firmas de dos métodos: save y delete.

from abc import ABC, abstractmethod


class UsersRepository(ABC):
    @abstractmethod
    def save(self, user: User) -> None:
        raise NotImplementedError()

    @abstractmethod
    def delete(self, user: User) -> None:
        raise NotImplementedError()

Una vez definida la interfaz de forma explícita, si queremos crear una implementación de la misma, bastaría con heredar de la clase abstracta y definir los métodos con sus implementaciones concretas.

class InMemoryUsersRepository(UsersRepository):
    def save(self, user: User) -> None:
        ...

    def delete(self, user: User) -> None:
        ...


class MongoUsersRepository(UsersRepository):
    def save(self, user: User) -> None:
        ...

    def delete(self, user: User) -> None:
        ...

Si no están todos los métodos implementados, el IDE nos avisará con un error. De igual forma nos ayudará a crear los métodos que faltan de una forma muy sencilla, que es algo superagradable.

Por ver un poco como sería el uso de estas interfaces en una aplicación real, vamos a ver un ejemplo de como podríamos inyectar nuestro UsersRepository en un CommandHandler que se encargue de crear usuarios. De esta forma, el caso de uso se abstrae de la implementación concreta que se esté usando, lo que nos permitirá cambiar el repositorio que inyectamos de forma completamente transparente.

class CreateUserCommandHandler:
    def __init__(self, users_repository: UsersRepository) -> None:
        self.users_repository = users_repository

    def execute(self, command: CreateUserCommand) -> None:
        self.users_repository.save(command.user)

Desde el punto de vista del caso de uso, no importa realmente qué implementación del UsersRepository se esté usando, podríamos pasar tanto InMemoryUsersRepository como MongoUsersRepository y el comportamiento sería el mismo.

    user_repository = InMemoryUsersRepository()
    create_user_command_handler = CreateUserCommandHandler(user_repository)
    user_repository = MongoUsersRepository()
    create_user_command_handler = CreateUserCommandHandler(user_repository)

Usando protocolos

Introducidos hace relativamente poco, los protocolos son otra alternativa para definir interfaces en Python. Como se puede leer en la propia documentación:

The intention of this PEP is to solve all these problems by allowing users to write the above code without explicit base classes in the class definition.

Estos protocolos nacen con la idea de ser una alternativa a las clases abstractas, permitiendo definir las interfaces de una manera más implícita. Personalmente, y como decía antes, me parece que esto puede hacer que nuestro código sea menos entendible y por eso no las he llegado a usar nunca.

Si quisiéramos definir un protocolo, únicamente necesitamos definir una clase que herede de Protocol.

from typing import Protocol

class InMemoryUsersRepository(Protocol):
    def save(self, user: User) -> None:
        ...
    def delete(self, user: User) -> None:
        ...

Una vez definido el protocolo, bastaría con crear una clase que implemente los mismos métodos para que de forma implícita esta nueva clase pueda ser considerada dentro de nuestra aplicación como si fuera la interfaz.

class MongoUsersRepository:
    def save(self, user: User) -> None:
        ...
    def delete(self, user: User) -> None:
        ...

ABCs vs Protocolos

ABCs:

  • No requiere implementación en la interfaz.
  • Las implementaciones de la interfaz son explícitas.
  • El IDE muestra un error cuando los métodos no están implementados.
  • El IDE no ayuda a implementar los métodos que faltan.

Protocolos:

  • Requiere implementar en la interfaz.
  • No queda realmente claro cuál es la interfaz original que define el comportamiento.
  • Las implementaciones del Protocolo no son explícitas.
  • El IDE no muestra ningún error, ya que la interfaz se conforma implícitamente.