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
ynamedtuple
, te adelanto que mi apuesta es usar@dataclass
para aquellas veces donde no quiero usarclass
.
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!