Alternativas a class en Python

Además de class, es posible crear nuevos tipos en Python usando distintas soluciones como Simplenamespace, namedtuple o @dataclass.

Cuando queremos crear un nuevo tipo, lo más habitual es que usemos class, nada que objetar.

class User:
    def __init__(self, id: int, name: str, email: str):
        self._id = id
        self._name = name
        self._email = email


me = User(1, "Sergio", "panicoenlaxbox@gmail.com")

print(me)  # <__main__.User object at 0x000001548AA8CE80>

Muchas veces, con esto es suficiente. Un método __init__ (no confundir con un constructor que es __new__) y a correr.

Sin embargo, algo muy típico es implementar el protocolo __str__ e incluso __repr__ para que si volcamos el objeto por consola tengamos una representación mejor que la que viene por defecto. La diferencia entre __str__ y __repr__ es que la primera es una representación friendly, para humanos, mientras que la segunda es una representación de cómo recrear el objeto. Si sólo quieres implementar una, usa __repr__ porque __str__ la llamará automáticamente… aunque por otro lado es más sencillo implementar __str__. En caso de que estén ambas, str llamará a __str__ (y es el caso de print) y repr llamará a __repr__.

class User:
    def __init__(self, id: int, name: str, email: str):
        self.id = id
        self.name = name
        self.email = email

    def __repr__(self):
        return f'{self.__class__.__name__}({self.id}, "{self.name}", "{self.email}")'

    def __str__(self):
        return f'id = {self.id}, name = {self.name}, email = {self.email}'


me = User(1, "Sergio", "panicoenlaxbox@gmail.com")
print(str(me))  # id = 1, name = Sergio, email = panicoenlaxbox@gmail.com
print(repr(me))  # User(1, "Sergio", "panicoenlaxbox@gmail.com")
print(me)  # id = 1, name = Sergio, email = panicoenlaxbox@gmail.com

Lógicamente, hacer un buen __repr__ no siempre es tan sencillo, a medida que el objeto es más complejo y tiene datos anidados se vuelve más difícil. Sinceramente, tengo mis dudas si merece o no la pena, realmente muy pocas veces he usado esta representación para usarla en un REPL.

class SocialNetwork:
    def __init__(self, name: str, url: str):
        self.name = name
        self.url = url

    def __repr__(self) -> str:
        return f'{self.__class__.__name__}("{self.name}", "{self.url}")'


class User:
    def __init__(self, id: int, name: str, email: str, social_networks: list[SocialNetwork]):
        self.id = id
        self.name = name
        self.email = email
        self.social_networks = social_networks

    def __repr__(self) -> str:
        return (
            f'{self.__class__.__name__}({self.id}, "{self.name}", "{self.email}"'
            f", {[social_network for social_network in self.social_networks]})"
        )


me = User(
    1,
    "Sergio",
    "panicoenlaxbox@gmail.com",
    [
        SocialNetwork("twitter", "https://twitter.com/panicoenlaxbox"),
        SocialNetwork("linkedin", "https://www.linkedin.com/in/sergio-le%C3%B3n-gonz%C3%A1lez-49412642/"),
    ],
)
print(me)
# User(1, "Sergio", "panicoenlaxbox@gmail.com", [SocialNetwork("twitter", "https://twitter.com/panicoenlaxbox"), SocialNetwork("linkedin", "https://www.linkedin.com/in/sergio-le%C3%B3n-gonz%C3%A1lez-49412642/")])

Hasta aquí lo conocido de class y suficiente para sobrevivir al día a día.

Sin embargo, muchas veces necesitamos crear tipos que son puramente contenedores de datos (sin lógica) o simplemente tipos “anónimos” con un uso muy reducido y no queremos (o nos da pereza) usar class.

Aunque vamos a ver SimpleNamespace y namedtuple, te adelanto que mi apuesta es usar @dataclass para aquellas veces donde no quiero usar class.

La primera alternativa sería usar SimpleNamespace que permite crear al vuelo un objeto con los atributos que queremos. Podemos cambiar el valor de un atributo, añadir nuevos o incluso borrarlos con del.

from types import SimpleNamespace

me = SimpleNamespace(id=1, name="Sergio", email="panicoenlaxbox@gmail.com")
print(type(me))  # <class 'types.SimpleNamespace'>
print(me)  # namespace(id=1, name='Sergio', email='panicoenlaxbox@gmail.com')
print(me.email)  # panicoenlaxbox@gmail.com
me.name = "Sergio León"
print(me.name)  # Sergio León
del me.email
print(me)  # namespace(id=1, name='Sergio León')
me.nick = "panicoenlaxbox"
print(me)  # namespace(id=1, name='Sergio León', nick='panicoenlaxbox')

La parte negativa es que mypy se lavará las manos y, al menos en mi caso, no mypy no glory.

def print_user_id(user: SimpleNamespace) -> None:
    # mypy no fallará, pero no tenemos autocompletado en me.
    print(user.id)


print_user_id(me)

Una segunda opción sería namedtuple, que se presenta como una alternativa a tuple donde podemos acceder a los atributos por nombre (además de por índice). namedtuple crea al vuelo un nuevo tipo.

from collections import namedtuple

user = namedtuple("User", ["id", "name", "email"])
print(type(user))  # <class 'type'>
print(user)  # <class '__main__.User'>
# Podemos usar argumentos posicionales o por nombre
me = user(1, "Sergio", email="panicoenlaxbox@gmail.com")
print(type(me))  # <class '__main__.User'>
print(me)  # User(id=1, name='Sergio', email='panicoenlaxbox@gmail.com')
print(me.email)  # panicoenlaxbox@gmail.com
print(me[2])  # panicoenlaxbox@gmail.com, también se pueda indexar
# me.name = "Sergio León"  # AttributeError: can't set attribute
print(me._asdict())  # {'id': 1, 'name': 'Sergio', 'email': 'panicoenlaxbox@gmail.com'}
id, name, email = me  # unpack
print(id, name, email, sep=", ")  # 1, Sergio, panicoenlaxbox@gmail.com
you = me._replace(id=2)  # Crear nuevo objeto copiando de... y sustituyendo...
print(you)  # User(id=2, name='Sergio', email='panicoenlaxbox@gmail.com')

Hay una segunda versión con NamedTuple donde podemos crear el tipo de forma declarativa y así poder especificar también el tipo de sus atributos. Además, mypy será feliz con esta nueva forma y podrá ayudarnos a no meter la pata.

from typing import NamedTuple


class User(NamedTuple):
    id: int
    name: str
    email: str


me = User(1, "Sergio", "panicoenlaxbox@gmail.com")
print(me)  # User(id=1, name='Sergio', email='panicoenlaxbox@gmail.com')
print(me.name)  # Sergio
print(me[1])  # Sergio
# me.name = "Sergio León"  # AttributeError: can't set attribute
print(me._asdict())  # {'id': 1, 'name': 'Sergio', 'email': 'panicoenlaxbox@gmail.com'}
id, name, email = me
print(id, name, email, sep=", ")  # 1, Sergio, panicoenlaxbox@gmail.com
# and so on...

Y llegamos por fin a @dataclass. Que es fácil, directo y te ahorra bastante líneas de código.

@dataclass es bien.

from dataclasses import dataclass


@dataclass
class User:
    id: int
    name: str
    email: str


me = User(1, "Sergio", "panicoenlaxbox@gmail.com")
print(me)  # User(id=1, name='Sergio', email='panicoenlaxbox@gmail.com')

@dataclass nos ha suministrado un constructor por defecto listo para usar y un repr, pero si tenemos la necesidad de hacer algo post-inicialización podemos declarar el método __post__init__.

@dataclass
class User:
    id: int
    name: str
    email: str
    
    def __post_init__(self):
        self.name = self.name.upper()

Podemos hacer que nuestro @dataclass sea inmutable con frozen = True (aunque como efecto colateral, olvídate de usar __post_init__).

from dataclasses import dataclass


@dataclass(frozen=True)
class User:
    id: int
    name: str
    email: str


me = User(1, "Sergio", "panicoenlaxbox@gmail.com")
print(me)  # User(id=1, name='Sergio', email='panicoenlaxbox@gmail.com')
# me.name = "Sergio León" # dataclasses.FrozenInstanceError: cannot assign to field 'name'

Si algún valor por defecto es mutable, `@dataclass`` se protege y nos obliga a usar default_factory.

from dataclasses import dataclass, field


def fn_bad(items=[]):  # default value is mutable, it's not a good idea
    return items


items_1 = fn_bad()
items_1.append(1)
items_2 = fn_bad()
print(items_2)  # [1], probably, you didn't want this


def fn_good(items=None):
    if items is None:
        items = []
    return items


items_1 = fn_good()
items_1.append(1)
items_2 = fn_good()
print(items_2)  # [], ok, each function has its own copy


@dataclass
class User:
    # items: list = []  # mutable default <class 'list'> for field items is not allowed: use default_factory
    items: list = field(default_factory=list)


user_1 = User()
user_1.items.append(1)
user_2 = User()
print(user_2)  # User(items=[]), ok

Al igual que class, mypy se llevará de maravilla con @dataclass y no habrá ningún problema.

Finalmente, mi recomendación es usar class y @dataclass, con lo que creo están cubiertas el 99.9% de las necesidades típicas de un programa.

Es verdad que hay más opciones que no se han cubierto en este post, como las metaclases, pero no las uso y con franqueza tampoco sabría explicarlas hoy por hoy.

Un saludo!


Ver también