La alternativa a la falta de interfaces en Python

La alternativa a la falta de interfaces en Python

Una de las cosas con las que tarde o temprano nos vamos a topar cuando recién comenzamos a aprender temas avanzados relacionados con arquitectura y diseño de software es el concepto de interfaz; y no, no interfaz gráfica. Por eso, en esta entrada quiero explicar cómo podemos aprovecharnos de los beneficios que nos ofrecen las interfaces, incluso en un lenguaje que no tiene una implementación nativa de ellas.

¿Que es una interfaz en programación?

Primero que nada, tenemos que comenzar con una definición. Una interfaz es, básicamente, una declaración que describe las características y funcionalidades que una estructura de datos, capaz de implementar o extender interfaces, debe tener. En muchos lugares te vas a topar con la comparación de las interfaces con un contrato, y esto es correcto; podemos ver las interfaces como un contrato, donde se deben respetar ciertas cláusulas.

A falta de interfaces en Python, para presentarlas te voy a mostrar algunas líneas de código en TypeScript, ya que este lenguaje (un superset de JavaScript si eres purista) sí cuenta con interfaces.

Vamos a poner el ejemplo con algunas líneas de código súper avanzadas. Supongamos que vamos a implementar un módulo en una aplicación para gestionar los datos de los hermosos pacientes de una clínica veterinaria, y queremos hacer que nuestro software siga cierto caminito y respete ciertas reglas. En este caso, vamos a hacer una interfaz que se asegure de que todas las clases que representen a un animalito tengan unos requisitos mínimos para ser implementadas.

Definimos una interfaz Animal, que explícitamente establece que cualquier cosa que la implemente debe tener un nombre, una especie y un nombre del sonido que produce (los tres siendo strings), y una función llamada makeSound que no recibe ni devuelve nada.

interface Animal {
    name: string;
    species: string;
    soundName: string;
    makeSound: () => void;
}

Ahora hagamos nuestra primera clase para representar a un animalito:

class Dog implements Animal {

}

La clase Dog al implementar nuestra interfaz Animal, sí o sí debe cumplir con el contrato de la interfaz; es decir, debe tener como mínimo las propiedades y métodos que establece la interfaz Animal (a menos que establezcamos alguna como opcional). Si no cumplimos con el contrato, para empezar, el linter se va a quejar y nos va a decir que necesitamos cumplir, y luego, si el lenguaje es compilado, seguramente nos arrojará un error. Por lo que nuestra clase tendría que verse así:

class Dog implements Animal {
    name = 'Dog';
    soundName = 'Bark';
    species = 'Canine';
    makeSound() {
        console.log(this.soundName);
    }
}

La clase Dog ya cumple con todo lo que establece la interfaz Animal, y obviamente podemos añadir cosas extras únicas de un perrito, pero ya cumplimos con los requisitos.

Las interfaces también pueden ser usadas como tipos, tipos que establecen reglas de cómo puede lucir un objeto. Por ejemplo:

interface User {
    name: string;
    email: string;
    age: number;
    role: 'admin' | 'client';
}

Tenemos la interfaz User, que establece que un objeto debe tener un nombre, email, edad y un rol (fíjate cómo explícitamente debe ser 'admin' o 'client').

Ahora, supongamos que tenemos una función que realiza algún proceso que involucre a un objeto de tipo User. Pues con las interfaces podemos tipar al parámetro de nuestra función para dejar explícitamente establecido que lo que le mandemos tiene que ser algo que respete la interfaz User.

const superFunction = (user: User) => {
    // Algun proceso que involucre al usuario
}

De esta forma, solo vamos a poder enviar objetos parecidos a este:

const user: User = {
    name: 'Paquito Perez',
    email: '[email protected]',
    age: 30,
    role: 'admin'
}

Las interfaces pueden ser tan detalladas y complejas como sea requerido y son una gran herramienta para crear código mantenible y fácil de escalar. Desde luego, la sintaxis en otros lenguajes puede variar, pero el concepto es el mismo; es básicamente un contrato.

¿Qué pasa con las interfaces en Python?

Si tienes conocimientos en este lenguaje, te darás cuenta de que no hay tal cosa como las interfaces. Esto se debe a que Python tiene una forma un poco distinta de manejar la creación de clases y objetos.

Una de las principales características de la programación orientada a objetos (POO) es la herencia. En varios lenguajes de programación como C#, PHP o Java, no está soportado que una clase pueda heredar de más de una clase. Aquí es donde entran las interfaces a salvar las cosas, porque sí se puede extender de varias interfaces que establezcan reglas diferentes, y esto hace que se pueda hacer código más modular.

En el caso de Python, este tiene muy bien implementada la herencia múltiple, por lo que, en principio, sería redundante crear otra cosa que establezca propiedades y métodos a manera de contrato como una interfaz. En vez de eso, puedes armar tus clases de la forma en que sea requerido y, si otra clase necesita heredar de más de una, lo puedes hacer sin problema.

Pero esto no puede ser exactamente lo que buscamos. Veamos un ejemplo:

class Vehicle:
    """
    Clase vehiculo
    """
    def __init__(self, brand: str, model: str):
        self.brand = brand
        self.model = model
        self.speed = 0

    def accelerate(self):
        """
        Acelerar
        """
        pass

    def brake(self):
        """
        Frenar
        """
        pass

Tenemos esa clase Vehicle, que pretendemos que sea una base para clases más específicas. Ahora hagamos una clase Car, la cual hereda de Vehicle.

class Car(Vehicle):
    """
    Clase carro
    """
    def __init__(self, brand: str, model: str, color: str):
        super().__init__(brand, model)
        self.color = color

Esto funciona, sí, pero en ningún lado me va a salir un warning o error que me diga que explícitamente tengo que cumplir con el contrato que pretendo establecer con la clase Vehicle. Como puedes observar, no implementé los dos métodos accelerate y brake.

Si queremos tener un código mantenible como el que podemos lograr con las interfaces, esto no es muy útil porque es muy volátil y podemos no cumplir con lo que se necesita que se cumpla.

Solución: Las fabulosas clases abstractas

Esta es la alternativa que más me gusta y la más parecida a lo que hace una interfaz en otros lenguajes.

Primero que nada, una clase abstracta no es lo mismo que una interfaz; podemos verlas como primos hermanos, ya que pueden cumplir con los mismos propósitos. En otros lenguajes puedes hacer ambas cosas, tanto interfaces como clases abstractas.

La principal diferencia entre estas dos cosas es que en una interfaz sólo estableces propiedades con sus tipos y métodos con sus tipos de retorno y tipos de parámetros. No puedes hacer nada más; no es posible escribir la implementación de un método. La implementación de lo que hacen esos métodos es tarea de las clases que la implementen.

Por otro lado, una clase abstracta es una clase que establece propiedades y métodos que otra clase tiene que tener al heredar de la clase abstracta. Estas son para herencia de otras clases, no para generar instancias de objetos. La diferencia es que en la clase abstracta sí puedes implementar funcionalidad. Más allá de solo definir métodos y propiedades, puedes establecer funciones completas en una clase abstracta, y cualquier otra clase que herede de ella podrá usar esas funcionalidades sin problema.

Veamos cómo se hace una clase abstracta en Python. Para hacer una clase abstracta necesitamos importar el módulo nativo abc.

from abc import ABC, abstractmethod


class Figura(ABC):
    """
    Clase abstracta figura
    """
    @abstractmethod
    def area(self):
        """
        Metodo abstracto area
        """
        raise NotImplementedError

    @abstractmethod
    def perimetro(self):
        """
        Metodo abstracto perimetro
        """
        raise NotImplementedError

Para que una clase sea abstracta en Python, basta con heredar de la clase ABC del módulo abc. Para definir métodos de la clase abstracta, solo agregamos el decorador abstractmethod, y con esto el intérprete tomará la función como un método abstracto, es decir, uno que la clase que herede, sí o sí, tiene que implementar.

Ahora usemos nuestra clase abstracta:

class Cuadrado(Figura):
    """
    Clase cuadrado
    """
    def __init__(self, lado: float):
        self.lado = lado

    def area(self) -> float:
        return self.lado ** 2

    def perimetro(self) -> float:
        return self.lado * 4

Como puedes ver, hicimos la clase Cuadrado, la cual define los métodos que requiere nuestra clase abstracta Figura, cumpliendo con el contrato.

Aquí ya tenemos los beneficios que una interfaz nos da. Si ejecutamos el código, no va a fallar:

cuadrado = Cuadrado(5)
print(f'{cuadrado.area()}')
# >>> 25

Genial, pero ¿qué pasa si no implemento el método area() porque resulta que no lo necesito? Me va a arrojar un error en tiempo de ejecución, diciendo que no he implementado todos los métodos que la clase abstracta me pide, en este caso area():

TypeError: Can't instantiate abstract class Cuadrado with abstract method area

Con esto podemos poner más restricciones cuando escribimos código. Además, si tienes un buen linter, este puede avisarte antes de ejecutar si estás o no cumpliendo con las reglas de la clase abstracta.

Conclusiones

Las clases abstractas son, en mi opinión, la alternativa definitiva a la falta de interfaces en Python. Es crucial conocerlas porque son la puerta a poder escribir código más escalable y mantenible. Ahora no tendrás excusa para aprender patrones de diseño y arquitectura; simplemente sustituye la parte de las interfaces por clases abstractas ¡y sé feliz!

¡Hasta luego!