Hola! Este capítulo es corto a propósito
Te lo digo de una: para analizar datos necesitas entender objetos, no diseñarlos 🌸
He visto cursos que meten tres semanas de herencia y polimorfismo antes de tocar un CSV, y la gente sale creyendo que Python es dificilísimo. No lo es.
Lo que sí necesitas es entender por qué escribes df.head() con
punto y con paréntesis, y por qué df.shape lleva punto pero no
paréntesis. Vamos a eso.
Así que la pregunta de este capítulo es chiquita: ¿por qué unas cosas llevan paréntesis y otras no? Contéstala y ya sabes lo único que hace falta saber de objetos para trabajar con datos 🌸
Todo lo que has usado ya era un objeto
texto = 'Minimarket El Sol' print(type(texto)) print(texto.upper()) print(texto.count('l'))
<class 'str'> MINIMARKET EL SOL 2
Ese texto no es solo una cadena de letras: es un objeto de tipo
str que viene con funciones pegadas. Esas funciones pegadas se
llaman métodos, y se llaman con el punto.
La lista también:
ventas = [480.37, 154.83] ventas.append(2480.84) print(ventas) print(ventas.count(154.83))
[480.37, 154.83, 2480.84] 1
Atributo o método, que es la duda de todos
Esta es la pregunta que quería resolverte con este capítulo:
- 📊 Atributo: un dato que el objeto guarda. Se lee sin paréntesis.
- ⚙️ Método: una acción que el objeto sabe hacer. Se llama con paréntesis.
from datetime import date hoy = date(2026, 8, 15) print(hoy.year) # atributo: un dato guardado print(hoy.month) print(hoy.weekday()) # metodo: calcula algo y lo devuelve print(hoy.isoformat())
2026 8 5 2026-08-15
Y esto es lo que pasa si te equivocas, que te va a pasar:
print(hoy.year())
TypeError: 'int' object is not callable
Léelo: hoy.year es un int, o sea el número 2026, y
le pusiste paréntesis como si fuera una función. "No es llamable" significa
justo eso 🙃
Al revés también avisa:
print(hoy.weekday)
<built-in method weekday of datetime.date object at ...
No revienta, pero mira lo que imprimió: te describió el método en vez de ejecutarlo. Si tu salida dice "method" en alguna parte, te faltaron los paréntesis. Guárdate ese truco, que en pandas lo vas a usar 💜
Cuando dudes, dir() te lista todo lo que tiene un objeto:
print([n for n in dir(hoy) if not n.startswith('_')])
['ctime', 'day', 'fromisocalendar', 'fromisoformat', 'fromordinal', 'fromtimestamp', 'isocalendar', 'isoformat', 'isoweekday', 'max', 'min', 'month', 'replace', 'resolution', 'strftime', 'timetuple', 'today', 'toordinal', 'weekday', 'year']
Ese filtro del guion bajo saca los internos, que no te interesan por ahora.
Crear tu propia clase
Una clase es el molde y un objeto es lo que sale del molde. Vamos con un ejemplo que sí tiene sentido.
class Venta: def __init__(self, cliente, ciudad, monto, unidades): self.cliente = cliente self.ciudad = ciudad self.monto = monto self.unidades = unidades def precio_unitario(self): return round(self.monto / self.unidades, 2) def con_igv(self, igv=0.18): return round(self.monto * (1 + igv), 2) v = Venta('C0045', 'Trujillo', 480.37, 10) print(v.cliente) print(v.precio_unitario()) print(v.con_igv())
C0045 48.04 566.84
Tres cosas y ya sabes leer cualquier clase:
- 🏗️
__init__es lo que corre al crear el objeto. Ahí se guardan los datos. - 👉
selfes "este objeto en particular". Va como primer argumento de todos los métodos y nunca se pasa al llamarlos: Python lo pone solo. - 💾
self.cliente = clienteguarda el dato dentro del objeto para que siga ahí después.
Si te olvidas del self, pasa esto:
class Mal: def __init__(self, monto): self.monto = monto def doble(): return 0 Mal(100).doble()
TypeError: Mal.doble() takes 0 positional arguments but 1 was given
Python le pasó el objeto como primer argumento y el método no lo esperaba. Ese
"1 was given" cuando tú no diste ninguno es la firma del self
olvidado 😄
Que se imprima bonito
print(v)
<__main__.Venta object at ...
Feo, ¿no? Se arregla con __repr__:
class Venta: def __init__(self, cliente, ciudad, monto): self.cliente = cliente self.ciudad = ciudad self.monto = monto def __repr__(self): return f'Venta({self.cliente}, {self.ciudad}, S/{self.monto})' print(Venta('C0045', 'Trujillo', 480.37)) print([Venta('C0325', 'Lima', 524.9), Venta('C0260', 'Cusco', 154.83)])
Venta(C0045, Trujillo, S/480.37) [Venta(C0325, Lima, S/524.9), Venta(C0260, Cusco, S/154.83)]
Fíjate en la segunda línea: al imprimir una lista de objetos, cada uno usa su
__repr__. Eso hace que depurar sea infinitamente más cómodo 🌟
dataclass: lo mismo con mucho menos
Si la clase es solo para guardar datos, hay un atajo que te ahorra escribir el
__init__ y el __repr__:
from dataclasses import dataclass @dataclass class Venta: cliente: str ciudad: str monto: float unidades: int = 1 def precio_unitario(self): return round(self.monto / self.unidades, 2) v = Venta('C0045', 'Trujillo', 480.37, 10) print(v) print(v.precio_unitario()) print(Venta('C0325', 'Lima', 524.9))
Venta(cliente='C0045', ciudad='Trujillo', monto=480.37, unidades=10) 48.04 Venta(cliente='C0325', ciudad='Lima', monto=524.9, unidades=1)
Ese @dataclass de arriba es un decorador, o sea algo que le
agrega comportamiento a la clase. Escribió el __init__, el
__repr__ y la comparación por ti.
Y esos : str y : float son anotaciones de tipo. No
obligan nada en tiempo de ejecución, pero tu editor las lee y te avisa si metes
un texto donde iba un número 💜
Y ahora la pregunta importante: ¿te hace falta una clase?
Casi siempre no. De verdad.
| Situación | Qué usar |
|---|---|
| Una fila de datos suelta | Un diccionario |
| Muchas filas con las mismas columnas | Un DataFrame de pandas |
| Una transformación | Una función |
| Datos y comportamiento que van SIEMPRE juntos | Ahí sí, una clase |
Mira estas dos formas de hacer lo mismo:
venta_dict = {'cliente': 'C0045', 'monto': 480.37, 'unidades': 10}
print(round(venta_dict['monto'] / venta_dict['unidades'], 2))
venta_obj = Venta('C0045', 'Trujillo', 480.37, 10)
print(venta_obj.precio_unitario())
48.04 48.04
Para una fila, el diccionario gana por simple. Para tres mil filas, ninguna de las dos: ahí va pandas, que es el capítulo 12.
Mi regla después de varios proyectos: escribe funciones hasta que te duela, y recién ahí piensa en una clase. La clase se justifica cuando llevas cinco funciones que reciben siempre los mismos tres argumentos. Ahí esos tres argumentos querían ser un objeto 🌸
Que dos objetos iguales se reconozcan
Esta es la que más rabia da la primera vez 😤
class Simple: def __init__(self, nombre): self.nombre = nombre a = Simple('Rosita') b = Simple('Rosita') print('con clase normal, a == b ->', a == b)
con clase normal, a == b -> False
Dos objetos con exactamente el mismo contenido y Python dice que no son iguales. No está roto: por defecto, comparar dos objetos pregunta si son el mismo objeto, no si tienen lo mismo dentro.
Y eso rompe cosas que ni te imaginas: buscar en una lista, quitar duplicados con un conjunto, comparar el antes y el después de un cálculo.
from dataclasses import dataclass @dataclass class ConDataclass: nombre: str c = ConDataclass('Rosita') d = ConDataclass('Rosita') print('con dataclass, c == d ->', c == d) print('y buscando en una lista ->', ConDataclass('Rosita') in [c])
con dataclass, c == d -> True y buscando en una lista -> True
El @dataclass escribe ese == por ti, y es la
segunda razón para usarlo aunque la primera ya fuera suficiente 🎁
El error que hasta las dataclass te marcan
Si le pones una lista vacía como valor por defecto a un atributo, Python te para en seco.
@dataclass class Malo: compras: list = []
ValueError: mutable default <class 'list'> for field compras is not allowed: use default_factory
Ese error es un regalo, y es el mismo problema del capítulo 6: el valor por defecto se crea una sola vez, al definir la clase. Sin ese aviso, todos los clientes que crearas compartirían la misma lista de compras.
La forma correcta lo dice el propio mensaje:
from dataclasses import field @dataclass class Bueno: compras: list = field(default_factory=list) x = Bueno() y = Bueno() x.compras.append('venta 1') print('y.compras ->', y.compras)
y.compras -> []
default_factory=list significa "llama a list() cada
vez que crees uno". Por eso y nace con su lista vacía y no con la
de x 🧺
Fíjate en la diferencia con el capítulo 6: allí el mismo error se cuela en silencio y aquí Python lo caza. Cuando una librería te obliga a hacerlo bien, agradécelo.
Y sin dataclass, sin aviso ninguno
Arriba te dije que sin ese error todos los clientes compartirían la misma lista. Ahora te lo enseño pasando de verdad, porque en una clase normal nadie te avisa 😖
class ClienteMal: compras = [] def __init__(self, codigo): self.codigo = codigo def compra(self, monto): self.compras.append(monto) a = ClienteMal('C0045') b = ClienteMal('C0325') a.compra(480.37) b.compra(154.83) print('compras de a:', a.compras) print('compras de b:', b.compras) print('es la misma lista:', a.compras is b.compras)
compras de a: [480.37, 154.83] compras de b: [480.37, 154.83] es la misma lista: True
Dos clientes distintos, cada uno con una compra, y los dos ven las dos 🫠
Lo que pasó es que compras = [] está escrito
en la clase, no dentro de __init__. Se creó una sola
vez, cuando Python leyó el archivo, y todos los objetos que nazcan de esa clase
apuntan a esa misma lista.
La diferencia es exactamente esta:
| Dónde lo escribes | Qué es | Quién lo tiene |
|---|---|---|
Fuera de __init__ | Atributo de clase | Uno solo, compartido por todos |
Dentro, con self. | Atributo de instancia | Uno por objeto |
Se arregla moviendo la línea adentro: self.compras = [] dentro de
__init__. Ahí se crea una lista nueva en cada objeto, que es lo que
querías 🩹
Y por eso el @dataclass te para en seco en vez de dejarte hacer
esto: te está ahorrando una tarde entera de mirar por qué a un cliente le aparecen
las compras de otro.
Ojo, que un atributo de clase no es malo: es perfecto para cosas que de verdad
son iguales para todos, tipo IGV = 0.18 o una lista de nombres de
columnas que nadie va a modificar. Lo peligroso es cuando es
mutable y alguien lo toca.
Otra forma de crear el objeto
Casi siempre los datos no te llegan como argumentos bonitos: te llegan como
una fila de un CSV. Y para eso está @classmethod 🏗️
from dataclasses import dataclass, field @dataclass class ClienteBien: codigo: str compras: list = field(default_factory=list) @classmethod def desde_fila(cls, fila): codigo, monto = fila.split(';') return cls(codigo.strip(), [float(monto)]) @property def total(self): return round(sum(self.compras), 2) c = ClienteBien.desde_fila('C0045; 480.37') d = ClienteBien('C0325') c.compras.append(120.0) print(c) print(d) print('total de c :', c.total) print('comparten lista :', c.compras is d.compras)
ClienteBien(codigo='C0045', compras=[480.37, 120.0]) ClienteBien(codigo='C0325', compras=[]) total de c : 600.37 comparten lista : False
Tres cosas ahí que vale la pena separar 🧩
El @classmethod es un constructor alternativo.
Recibe cls, que es la clase misma, en vez de self, y
devuelve un objeto nuevo. Lo que consigues es que la lógica de "cómo se lee una
fila" viva junto a la clase y no repartida por diez sitios.
El @property es un método que se usa como si
fuera un atributo: fíjate en que escribí c.total sin paréntesis. Para
lo que se calcula a partir de lo que ya tienes, queda mucho más natural.
Y el False del final confirma que
default_factory funcionó: cada cliente tiene su propia lista, al
revés que en la clase de arriba 💜
Un patrón que uso mucho con esto: desde_fila para leer,
@property para lo calculado, y una función suelta que recorre el CSV
y devuelve la lista de objetos. Con eso, el resto del programa deja de saber cómo
era el archivo 🗂️
Herencia, que aquí se usa menos de lo que parece
Heredar es decir "esto es como aquello, pero con algo distinto".
@dataclass class Cliente: nombre: str segmento: str def descuento(self): return 0.0 def ficha(self): return f'{self.nombre} ({self.segmento}) descuento {self.descuento():.0%}' @dataclass class Mayorista(Cliente): segmento: str = 'Mayorista' def descuento(self): return 0.15 for c in [Cliente('Bodega Rosita', 'Bodega'), Mayorista('Distribuidora Lima')]: print(c.ficha()) print('Mayorista es un Cliente?', isinstance(Mayorista('X'), Cliente))
Bodega Rosita (Bodega) descuento 0% Distribuidora Lima (Mayorista) descuento 15% Mayorista es un Cliente? True
Mira lo que pasó: ficha se escribió una sola vez, en
Cliente, y cuando la llama un Mayorista usa
su descuento. Eso se llama polimorfismo y suena mucho peor de
lo que es 🎭
Ahora la parte honesta, que casi ningún tutorial dice: en análisis de datos vas a heredar poquísimo. La herencia sirve cuando tienes varias cosas que se parecen mucho y se comportan distinto en un detalle. Un análisis normal tiene funciones y DataFrames, no jerarquías de clases.
Dónde sí la vas a ver: heredando de algo que ya existe, como cuando escribes un transformador propio para scikit-learn. Ahí heredas porque la librería te lo pide, no porque tú lo diseñaras así 🔧
Ejercicios
1. Atributo o método
De un texto, saca su largo, su versión en mayúsculas y cuántas veces aparece una letra. Di cuál de las tres es un atributo.
t = 'Minimarket El Sol' print(len(t)) print(t.upper()) print(t.count('l'))
17 MINIMARKET EL SOL 2
Ninguna de las tres es un atributo: len() es una función suelta y
las otras dos son métodos. Los textos casi no tienen atributos públicos, y por
eso todo lleva paréntesis.
2. Provoca el error de los paréntesis
Con una fecha, llama a un atributo como si fuera método.
from datetime import date d = date(2026, 8, 15) print(d.day) print(d.day())
TypeError: 'int' object is not callable
3. El método sin llamar
Imprime un método sin los paréntesis y aprende a reconocer la salida.
t = 'hola' print(t.upper)
<built-in method upper of str object at ...
Si en tu salida aparece la palabra "method", te faltaron los paréntesis. Es de los errores más rápidos de detectar una vez que sabes mirarlo.
4. Tu primera clase
Crea una clase Cliente con nombre y ciudad, y
un método que devuelva una etiqueta tipo "Nombre (Ciudad)".
class Cliente: def __init__(self, nombre, ciudad): self.nombre = nombre self.ciudad = ciudad def etiqueta(self): return f'{self.nombre} ({self.ciudad})' print(Cliente('Bodega Rosa', 'Piura').etiqueta())
Bodega Rosa (Piura)
5. Que se imprima bien
Agrégale __repr__ a esa clase e imprime una
lista de dos clientes.
class Cliente: def __init__(self, nombre, ciudad): self.nombre = nombre self.ciudad = ciudad def __repr__(self): return f'Cliente({self.nombre!r}, {self.ciudad!r})' print([Cliente('Bodega Rosa', 'Piura'), Cliente('Sol', 'Lima')])
[Cliente('Bodega Rosa', 'Piura'), Cliente('Sol', 'Lima')]
Ese !r dentro del f-string aplica repr() al valor, y
por eso salen las comillas. Es el detalle que hace que el repr se pueda copiar y
pegar para recrear el objeto.
6. Con dataclass
Reescribe la clase anterior como dataclass y
compara cuánto código te ahorraste.
from dataclasses import dataclass @dataclass class Cliente: nombre: str ciudad: str activo: bool = True print(Cliente('Bodega Rosa', 'Piura')) print(Cliente('Sol', 'Lima', activo=False))
Cliente(nombre='Bodega Rosa', ciudad='Piura', activo=True) Cliente(nombre='Sol', ciudad='Lima', activo=False)
7. El self olvidado
Escribe un método sin self a propósito y lee el
mensaje de error con calma.
class Pedido: def total(): return 0 Pedido().total()
TypeError: Pedido.total() takes 0 positional arguments but 1 was given
Ese "1 was given" cuando tú no diste ninguno es el objeto que Python metió
solo. Cuando veas ese mensaje, mira el self.
8. Diccionario o clase
Guarda tres ventas de las dos formas y calcula el total. Después decide cuál te gusta más para este caso.
from dataclasses import dataclass @dataclass class V: ciudad: str monto: float como_dict = [{'ciudad': 'Lima', 'monto': 480.37}, {'ciudad': 'Cusco', 'monto': 154.83}] como_obj = [V('Lima', 480.37), V('Cusco', 154.83)] print(round(sum(d['monto'] for d in como_dict), 2)) print(round(sum(o.monto for o in como_obj), 2))
635.2 635.2
Mismo resultado. Para dos ventas el diccionario sobra; la clase empieza a ganar cuando además necesitas métodos, y pandas les gana a las dos en cuanto son más de cien filas.
9. Dos clientes que deberían ser el mismo
Quita los duplicados de una lista de clientes. Con una clase normal no se puede; con dataclass sí, y hace falta una línea más.
@dataclass(frozen=True) class ClienteUnico: nombre: str segmento: str lista = [ClienteUnico('Rosita', 'Bodega'), ClienteUnico('Distribuidora Lima', 'Mayorista'), ClienteUnico('Rosita', 'Bodega')] print('en la lista:', len(lista)) print('sin repetir :', len(set(lista))) for c in sorted(set(lista), key=lambda c: c.nombre): print(' ', c.nombre)
en la lista: 3 sin repetir : 2 Distribuidora Lima Rosita
Ese frozen=True es lo que hace que se pueda meter en un
conjunto. Congela el objeto: una vez creado no se le cambia nada 🧊
Y tiene lógica: un conjunto necesita poder calcular una huella de cada elemento, y esa huella no puede cambiar debajo. Por eso las listas no se pueden meter en un conjunto y las tuplas sí.
10. Una clase que se defiende sola
Haz que la clase compruebe sus propios datos al crearse, en vez de confiar en quien la llama.
@dataclass class Venta: cliente: str monto: float unidades: int def __post_init__(self): if self.monto < 0: raise ValueError(f'monto negativo: {self.monto}') if self.unidades <= 0: raise ValueError(f'unidades tiene que ser mayor que cero: {self.unidades}') @property def precio_unitario(self): return self.monto / self.unidades v = Venta('Rosita', 340.0, 4) print('precio por unidad:', round(v.precio_unitario, 2)) try: Venta('Rosita', -50.0, 4) except ValueError as e: print('rechazada:', e)
precio por unidad: 85.0 rechazada: monto negativo: -50.0
Dos cosas nuevas aquí, y las dos se usan muchísimo 💛
__post_init__ corre justo después de crear el objeto, y es donde
va la comprobación. Un dato imposible se rechaza al entrar, no
tres pantallas más abajo cuando ya nadie sabe de dónde salió.
@property hace que precio_unitario se lea como un
atributo (sin paréntesis) aunque por dentro sea un cálculo. Así nunca puede
quedar desactualizado, porque no se guarda: se calcula cada vez que lo pides 🔄
La lista que todos tus clientes comparten sin saberlo
Antes de cerrar, la trampa de clases por excelencia. Funciona perfecto mientras pruebas con un objeto, y por eso llega tan lejos antes de que alguien la vea 😳
La trampa
Creas una clase para tus clientes con una lista de compras dentro, que es lo natural. Registras una compra de Ana y miras a Beto.
class Cliente: compras = [] def __init__(self, nombre): self.nombre = nombre ana = Cliente('Ana') beto = Cliente('Beto') ana.compras.append('arroz') print(beto.compras) # ['arroz']
Qué está mal
Beto compró arroz sin enterarse 😳 Esa lista está escrita en el cuerpo de la clase, o sea que pertenece a la clase y no a cada cliente. Hay una sola lista y todos los clientes la comparten.
El error tarda en aparecer porque con un solo cliente funciona perfecto. Sale cuando hay dos, y para entonces ya hay código encima que lo da por bueno.
Lo que es de cada objeto se crea dentro de __init__: self.compras = []. En el cuerpo de la clase solo van las cosas que de verdad son iguales para todos, como una constante. Y con dataclass pasa lo mismo, por eso existe field(default_factory=list).
Comprueba que lo tienes
¿Cuándo te conviene escribir una clase en un proyecto de datos?
- Cuando tienes datos y funciones que van siempre juntos
- Siempre, porque es la forma profesional de programar
- Cuando el archivo pasa de doscientas líneas
- Nunca, en análisis de datos no hacen falta
Lo que te llevas
- 🔵 En Python casi todo es un objeto, y por eso todo se usa con puntos.
- 📊 Atributo sin paréntesis, método con paréntesis.
- 🔎 Si tu salida dice "method", te faltaron los paréntesis.
- 👉
selfes este objeto, va primero en cada método y no se pasa al llamar. - ⚡
@dataclasste escribe el__init__y el__repr__. - 🤏 Antes de crear una clase, pregúntate si un diccionario o una función bastan. Casi siempre bastan.
Y si de todo el capítulo te llevas una sola frase, que sea esta:
Para datos hace falta entender objetos, no diseñarlos.
Eso de rechazar un dato imposible al entrar, en vez de descubrirlo tres pantallas más abajo, en una base de datos tiene nombre y se llama restricción. Está en el libro de SQL, y es la misma idea escrita en otro sitio 🛡️
En el capítulo 10 abrimos archivos de verdad: CSV, Excel, JSON, y la codificación, que es la razón por la que a veces ves "á" donde debería haber una á.
Que tengas lindo día! 🌸
Practica este capítulo 📓
Todo el código de arriba en un cuaderno que corre de principio a fin, y los ejercicios con una celda vacía para que los hagas tú. Se abre en Google Colab de un clic y no hay que instalar nada. Donde veas %%revisa, escribe tu respuesta y el cuaderno te dice si te salió.
¿Prefieres trabajar en tu máquina? Bájate el cuaderno de práctica o el de soluciones. Todos están también en github.com/soymissyera/MissYeraEjercicios.