Clases en Python — práctica con tres programas: una cuenta que no se deja manipular, la biblioteca de la facultad y una copistería con IVA para todos (y el saldo de −4000 € que nadie frenó)
La primera clase CuentaBancaria que escribí para una práctica funcionaba de maravilla en mis pruebas: depositar, retirar, imprimir. Se la di a un compañero para que la probara y en un minuto tenía la cuenta en −4000 €: le bastó con retirar más de lo que había. Al segundo minuto había escrito cuenta.saldo = 'mucho' y el programa reventó en el siguiente depósito. Mi clase guardaba datos, pero no los protegía. Hoy arreglamos eso.
En la teoría de clases en Python viste __init__, self, los atributos de clase, el encapsulamiento con @property y los métodos mágicos. Ahora los usamos en tres programas completos de nivel medio: una cuenta bancaria que evoluciona de «cualquiera la toca» a «nadie la rompe», la biblioteca de la facultad con libros que se prestan y se devuelven, y la copistería de la facultad, donde un solo cambio de IVA afecta a todos los productos a la vez. Los datos tienen trampas a propósito (retiradas imposibles, años del futuro, títulos vacíos), porque una clase bien hecha se nota justo cuando alguien intenta usarla mal.
Tabla de Contenidos
Antes de empezar
Conviene tener claro:
- Que
self.x = xen__init__es lo que guarda el dato en el objeto. - Cómo se escribe una propiedad:
@propertypara leer y@nombre.setterpara cambiar con validación. - La diferencia entre un atributo de clase (uno para todos) y uno de instancia (uno por objeto).
- Que
__str__es lo que enseñaprinty__repr__, lo que se ve dentro de una lista.
Si alguno te suena dudoso, repasa la teoría antes de seguir.
En los tres programas los errores se provocan a propósito al final, dentro de un bucle con try, para que veas el mensaje real de cada uno. Se usa exec(orden) para ejecutar cada línea escrita como texto: es solo una forma cómoda de enseñar varias excepciones seguidas, no algo que vayas a necesitar en tus programas.
Clases en Python práctica — Programa 1: Clase CuentaBancaria
Una cuenta con titular y saldo que permita depositar y retirar. Lo que la complica es que el saldo nunca puede quedar en un estado absurdo: ni negativo, ni con un texto dentro, ni con movimientos falsos en el historial. Vamos a verlo en dos versiones: la ingenua (la mía) y la encapsulada.
La idea
En la primera versión todo es público y nadie comprueba nada. En la segunda, el saldo vive en self.__saldo y solo se puede tocar por tres puertas: el setter (que valida tipo y signo), depositar y retirar (que validan la cantidad y que haya fondos). El titular es de solo lectura y el historial se entrega como copia.
Antes de programar, apunta todas las formas de dejar la cuenta en un estado imposible. Cada una de ellas tiene que acabar en un raise.
El código
Primero, la versión ingenua y lo que le pasó:
class CuentaBancaria:
def __init__(self, titular, saldo_inicial=0):
self.titular = titular
self.saldo = saldo_inicial # atributo público: cualquiera lo cambia
def depositar(self, cantidad):
self.saldo += cantidad
def retirar(self, cantidad):
self.saldo -= cantidad # no mira si hay dinero
def __str__(self):
return f'Cuenta de {self.titular}: {self.saldo:.2f} €'
cuenta = CuentaBancaria('Laura', 1000)
cuenta.retirar(5000) # se queda en negativo sin protestar
print(cuenta)
cuenta.saldo = 'mucho' # y aquí ya ni es un número
print(cuenta.saldo)
cuenta.depositar(50)
Salida:
Cuenta de Laura: -4000.00 €
mucho
Traceback (most recent call last):
File "cuenta_v1.py", line 21, in <module>
cuenta.depositar(50)
~~~~~~~~~~~~~~~~^^^^
File "cuenta_v1.py", line 7, in depositar
self.saldo += cantidad
TypeError: can only concatenate str (not "int") to str
1000 − 5000 = −4000: la cuenta se queda en negativo sin protestar. Después cuenta.saldo = 'mucho' mete un texto donde iba un número, y el error no salta ahí, sino en el siguiente depositar, con un TypeError que no tiene nada que ver con el problema real. Es lo peor que puede pasar: el fallo aparece lejos de donde se produjo.
Y ahora la versión encapsulada:
class CuentaBancaria:
_total_cuentas = 0 # atributo de clase: cuántas cuentas hay
def __init__(self, titular, saldo_inicial=0):
if not isinstance(titular, str) or not titular.strip():
raise ValueError('el titular debe ser un texto no vacío')
self.__titular = titular.strip()
self.__movimientos = []
self.saldo = saldo_inicial # pasa por el setter: valida también al crear
CuentaBancaria._total_cuentas += 1
@property
def titular(self): # solo lectura: sin setter
return self.__titular
@property
def saldo(self):
return self.__saldo
@saldo.setter
def saldo(self, valor):
if not isinstance(valor, (int, float)):
raise TypeError('el saldo debe ser un número')
if valor < 0:
raise ValueError('el saldo no puede ser negativo')
self.__saldo = valor
@property
def movimientos(self):
return list(self.__movimientos) # copia: la lista de verdad no sale de aquí
def depositar(self, cantidad):
if cantidad <= 0:
raise ValueError('la cantidad a depositar debe ser positiva')
self.__saldo += cantidad
self.__movimientos.append(f'+{cantidad:.2f}')
def retirar(self, cantidad):
if cantidad <= 0:
raise ValueError('la cantidad a retirar debe ser positiva')
if cantidad > self.__saldo:
raise ValueError(f'saldo insuficiente: tienes {self.__saldo:.2f} €')
self.__saldo -= cantidad
self.__movimientos.append(f'-{cantidad:.2f}')
@classmethod
def total_cuentas(cls):
return cls._total_cuentas
def __str__(self):
return f'Cuenta de {self.__titular}: {self.__saldo:.2f} €'
def __repr__(self):
return f'CuentaBancaria({self.__titular!r}, {self.__saldo})'
c1 = CuentaBancaria('Laura', 1000)
c2 = CuentaBancaria('Pablo', 500)
c1.depositar(250)
c1.retirar(100)
print(c1)
print(c1.movimientos)
historial = c1.movimientos
historial.append('+1000000.00') # intento de colar un movimiento
print(c1.movimientos) # no ha entrado: era una copia
print(repr(c2), '| cuentas:', CuentaBancaria.total_cuentas())
for orden in ['c1.retirar(5000)', 'c1.saldo = -100', "c1.saldo = 'mucho'", "c1.titular = 'Pablo'"]:
try:
exec(orden)
except (ValueError, TypeError, AttributeError) as err:
print(f'{orden:<20} {type(err).__name__}: {err}')
Zoom al corazón de la clase, la propiedad saldo:
@property
def saldo(self):
return self.__saldo
@saldo.setter
def saldo(self, valor):
if not isinstance(valor, (int, float)):
raise TypeError('el saldo debe ser un número')
if valor < 0:
raise ValueError('el saldo no puede ser negativo')
self.__saldo = valor
El getter solo devuelve self.__saldo. El setter comprueba dos cosas, cada una con su excepción: el tipo (TypeError si no es un número) y el signo (ValueError si es negativo). En __init__, self.saldo = saldo_inicial pasa por el setter, así que tampoco se puede crear una cuenta con −50 € de partida.
Salida
Cuenta de Laura: 1150.00 €
['+250.00', '-100.00']
['+250.00', '-100.00']
CuentaBancaria('Pablo', 500) | cuentas: 2
c1.retirar(5000) ValueError: saldo insuficiente: tienes 1150.00 €
c1.saldo = -100 ValueError: el saldo no puede ser negativo
c1.saldo = 'mucho' TypeError: el saldo debe ser un número
c1.titular = 'Pablo' AttributeError: property 'titular' of 'CuentaBancaria' object has no setter
Comprobación: 1000 + 250 − 100 = 1150. El historial guarda +250.00 y -100.00. El intento de colar un movimiento de un millón no entra: historial es una copia. Y los cuatro ataques del final acaban cada uno en su excepción: no hay fondos, el saldo no puede ser negativo, ni un texto, y el titular no tiene setter.
El dibujo enseña por qué el millón no entró: c1.movimientos devuelve list(self.__movimientos), una lista nueva con los mismos datos. El append cae en esa copia y la de dentro no se entera.
Preguntas que te puedes hacer
¿Por qué depositar y retirar usan self.__saldo y no el setter? Porque ya validan ellas mismas lo que les importa (cantidad positiva, fondos suficientes) y el resultado nunca puede ser negativo. Pasar por el setter haría la misma comprobación otra vez. El setter es la puerta para quien asigna el saldo directamente.
¿Por qué el titular no tiene setter? Porque en una cuenta real el titular no cambia con una asignación. Sin setter, c1.titular = 'Pablo' da AttributeError y el mensaje de Python lo explica: property 'titular' … has no setter.
¿Por qué TypeError para el texto y ValueError para el negativo? Por lo que vimos en excepciones: un texto es un tipo equivocado; −100 es del tipo correcto con un valor no permitido. Usar la excepción precisa permite a quien use la clase tratarlas por separado.
¿Qué pasa si alguien escribe c1._CuentaBancaria__saldo = -999? Que funciona: el name mangling no es una cerradura. Pero tiene que hacerlo a propósito, escribiendo un nombre raro que grita «no toques esto». El encapsulamiento en Python protege contra errores, no contra quien quiere saltárselo.
Escribir return self.__movimientos en la propiedad. Funciona en todas tus pruebas… hasta que alguien hace c1.movimientos.append(...) y modifica el historial real sin pasar por depositar.
Pruébalo tú
- Añade un método
transferir(self, otra, cantidad)que retire de esta cuenta y deposite enotra, y lanzaTypeErrorsiotrano es unaCuentaBancaria. - Añade un límite de descubierto como atributo de clase (
DESCUBIERTO = 100) y deja queretirarllegue hasta −100 €, pero no más.
Clases en Python práctica — Programa 2: Clase Libro para una biblioteca
El catálogo de la biblioteca de la facultad: una clase Libro con título, autor, año e ISBN, que se presta y se devuelve, y una clase Biblioteca que guarda libros y permite buscarlos. Lo que lo complica es que hay dos clases que colaboran y que los datos tienen que validarse siempre: un título vacío o un libro publicado en 2030 no pueden entrar.
La idea
Libro valida en sus setters (título y autor no vacíos, año entre 1450, más o menos la imprenta, y el año actual), tiene el ISBN de solo lectura y controla su propio estado con prestar y devolver, que lanzan ValueError si la operación no tiene sentido. Biblioteca no copia libros: guarda referencias a los objetos Libro en una lista privada, y comprueba con isinstance que solo entren libros.
Antes de programar, decide quién es responsable de cada cosa. ¿Quién sabe si un libro está prestado? El propio libro. ¿Quién sabe qué libros hay? La biblioteca.
El código
AÑO_ACTUAL = 2026
class Libro:
_total_libros = 0
def __init__(self, titulo, autor, anio, isbn):
self.titulo = titulo # setters: validan desde el principio
self.autor = autor
self.anio = anio
self.__isbn = isbn # sin setter: el ISBN no cambia
self.__disponible = True
Libro._total_libros += 1
@property
def titulo(self):
return self.__titulo
@titulo.setter
def titulo(self, valor):
if not isinstance(valor, str) or not valor.strip():
raise ValueError('el título debe ser un texto no vacío')
self.__titulo = valor.strip()
@property
def autor(self):
return self.__autor
@autor.setter
def autor(self, valor):
if not isinstance(valor, str) or not valor.strip():
raise ValueError('el autor debe ser un texto no vacío')
self.__autor = valor.strip()
@property
def anio(self):
return self.__anio
@anio.setter
def anio(self, valor):
if not isinstance(valor, int) or not 1450 <= valor <= AÑO_ACTUAL:
raise ValueError(f'año no válido: {valor}')
self.__anio = valor
@property
def isbn(self):
return self.__isbn
@property
def disponible(self):
return self.__disponible
def prestar(self):
if not self.__disponible:
raise ValueError(f'«{self.__titulo}» ya está prestado')
self.__disponible = False
def devolver(self):
if self.__disponible:
raise ValueError(f'«{self.__titulo}» no estaba prestado')
self.__disponible = True
@classmethod
def total_libros(cls):
return cls._total_libros
def __str__(self):
estado = 'disponible' if self.__disponible else 'prestado'
return f'«{self.__titulo}», {self.__autor} ({self.__anio}): {estado}'
def __repr__(self):
return f'Libro({self.__titulo!r}, {self.__autor!r}, {self.__anio}, {self.__isbn!r})'
class Biblioteca:
def __init__(self, nombre):
self.__nombre = nombre
self.__libros = [] # guarda REFERENCIAS a objetos Libro
def añadir(self, libro):
if not isinstance(libro, Libro):
raise TypeError('solo se pueden añadir objetos Libro')
self.__libros.append(libro)
def buscar(self, texto):
return [l for l in self.__libros if texto.lower() in l.titulo.lower()]
def disponibles(self):
return [l for l in self.__libros if l.disponible]
def __str__(self):
return f'Biblioteca {self.__nombre}: {len(self.__libros)} libros'
bib = Biblioteca('de la EII')
l1 = Libro('Python para todos', 'Severance', 2016, '978-1-5303-3762-6')
l2 = Libro('Estructuras de datos en C', 'Joyanes', 2005, '978-84-481-4227-6')
l3 = Libro('Cálculo', 'Stewart', 2018, '978-607-526-549-8')
for l in [l1, l2, l3]:
bib.añadir(l)
print(bib)
l1.prestar()
print(l1)
for accion in [l1.prestar, l3.devolver]:
try:
accion()
except ValueError as err:
print('Error:', err)
print('Buscar «c»:', bib.buscar('c'))
print('Disponibles:', [l.titulo for l in bib.disponibles()])
for mal in [("''", 'Nadie', 2020), ('Futuro', 'Alguien', 2030)]:
try:
Libro(eval(mal[0]) if mal[0] == "''" else mal[0], mal[1], mal[2], '000')
except ValueError as err:
print('No se crea:', err)
print('Libros creados:', Libro.total_libros())
Los dos métodos que cambian el estado del libro:
def prestar(self):
if not self.__disponible:
raise ValueError(f'«{self.__titulo}» ya está prestado')
self.__disponible = False
def devolver(self):
if self.__disponible:
raise ValueError(f'«{self.__titulo}» no estaba prestado')
self.__disponible = True
Cada uno comprueba primero si la operación es posible y, si no, lanza ValueError con el título en el mensaje. Así, el bucle de errores del final puede tratar l1.prestar y l3.devolver de la misma forma.
Salida
Biblioteca de la EII: 3 libros
«Python para todos», Severance (2016): prestado
Error: «Python para todos» ya está prestado
Error: «Cálculo» no estaba prestado
Buscar «c»: [Libro('Estructuras de datos en C', 'Joyanes', 2005, '978-84-481-4227-6'), Libro('Cálculo', 'Stewart', 2018, '978-607-526-549-8')]
Disponibles: ['Estructuras de datos en C', 'Cálculo']
No se crea: el título debe ser un texto no vacío
No se crea: año no válido: 2030
Libros creados: 3
Repaso de lo que sale:
| Línea de la salida | Qué la produce |
|---|---|
Biblioteca de la EII: 3 libros | __str__ de Biblioteca con len(self.__libros) |
«Python para todos»…: prestado | l1.prestar() y el __str__ de Libro |
ya está prestado | El segundo l1.prestar() del bucle |
no estaba prestado | l3.devolver(): Cálculo nunca se prestó |
Buscar «c»: [Libro(...), Libro(...)] | Una lista: se ve el __repr__ de cada libro |
Disponibles: [...] | Solo los títulos: el __repr__ ya no hace falta |
No se crea: … (dos) | Los setters de título y año al crear |
Fíjate en la búsqueda: «c» aparece en «Estructuras de datos en C» y en «Cálculo» (gracias al lower() de los dos lados), pero no en «Python para todos». Y en que el contador dice 3: los dos libros mal hechos lanzaron la excepción antes de llegar a Libro._total_libros += 1, así que no cuentan.
El dibujo explica por qué «Python para todos» ya no sale en disponibles: l1 y la primera casilla de la lista de la biblioteca apuntan al mismo objeto. Lo que hagas por un camino se ve por el otro.
Preguntas que te puedes hacer
¿Por qué AÑO_ACTUAL = 2026 y no la fecha del sistema? Para que el programa dé siempre la misma salida. En un programa real usarías datetime.date.today().year.
¿Por qué buscar devuelve libros y no títulos? Porque quien busca normalmente quiere hacer algo con el libro (prestarlo). Si devolviera títulos, tendría que volver a buscar el objeto.
¿Qué pasa si añado el mismo libro dos veces? Que la lista tendría dos referencias al mismo objeto. Un buen «Pruébalo tú» es impedirlo comprobando if libro in self.__libros.
¿Por qué el ISBN tiene getter pero no setter? Porque identifica la edición: si cambia, es otro libro. La propiedad sin setter lo hace visible pero inmutable.
Comprobar en Biblioteca si un libro está prestado mirando un atributo suyo (libro._Libro__disponible). Pregúntale al libro por su puerta pública (libro.disponible): si un día cambia cómo guarda el dato, la biblioteca no se entera.
Pruébalo tú
- Añade a
Bibliotecaun métodoprestar(titulo)que busque el libro por título exacto y lo preste, o lanceValueErrorsi no existe. - Haz que
añadirlanceValueErrorsi ya hay un libro con el mismo ISBN.
Clases en Python práctica — Programa 3: Clase Producto para una tienda
La copistería de la facultad vende apuntes, cuadernos y memorias USB. Cada producto tiene nombre, precio sin impuestos y stock, y su precio final incluye el IVA. Lo que lo complica es que el impuesto es el mismo para todos y puede cambiar (en Canarias no se paga IVA, sino IGIC, del 7 %): cambiarlo producto a producto sería un error esperando a ocurrir.
La idea
El impuesto es un atributo de clase, _iva, y se cambia con un @classmethod que lo valida. El precio con IVA no se guarda: es una propiedad calculada que se obtiene cada vez a partir del precio y del _iva actual. Así, cuando cambia el impuesto, todos los precios finales cambian solos. aplicar_descuento reutiliza el setter del precio para redondear y validar.
Antes de programar, piensa qué datos son de cada producto (nombre, precio, stock) y cuáles son de todos (el impuesto).
El código
class Producto:
_iva = 0.21 # atributo de clase: el mismo IVA para todos
def __init__(self, nombre, precio, stock=0):
self.nombre = nombre
self.precio = precio
self.__stock = 0
self.añadir_stock(stock) # reutiliza la validación del método
@property
def nombre(self):
return self.__nombre
@nombre.setter
def nombre(self, valor):
if not isinstance(valor, str) or not valor.strip():
raise ValueError('el nombre debe ser un texto no vacío')
self.__nombre = valor.strip()
@property
def precio(self):
return self.__precio
@precio.setter
def precio(self, valor):
if not isinstance(valor, (int, float)) or valor < 0:
raise ValueError('el precio debe ser un número no negativo')
self.__precio = round(valor, 2)
@property
def precio_con_iva(self): # propiedad calculada: no se guarda, se calcula
return round(self.__precio * (1 + Producto._iva), 2)
@property
def stock(self):
return self.__stock
def añadir_stock(self, cantidad):
if not isinstance(cantidad, int) or cantidad < 0:
raise ValueError('la cantidad debe ser un entero no negativo')
self.__stock += cantidad
def vender(self, cantidad=1):
if not isinstance(cantidad, int) or cantidad <= 0:
raise ValueError('la cantidad debe ser un entero positivo')
if cantidad > self.__stock:
raise ValueError(f'stock insuficiente: quedan {self.__stock}')
self.__stock -= cantidad
return round(self.precio_con_iva * cantidad, 2)
def aplicar_descuento(self, porcentaje):
if not 0 < porcentaje < 100:
raise ValueError('el descuento debe estar entre 0 y 100')
self.precio = self.__precio * (1 - porcentaje / 100) # pasa por el setter
@classmethod
def cambiar_iva(cls, nuevo):
if not 0 <= nuevo <= 1:
raise ValueError('el IVA debe estar entre 0 y 1')
cls._iva = nuevo
def __str__(self):
return f'{self.__nombre}: {self.__precio:.2f} € (con IVA {self.precio_con_iva:.2f} €), stock {self.__stock}'
print('=== COPISTERÍA DE LA FACULTAD ===')
apuntes = Producto('Apuntes de FP2', 12.40, 30)
cuaderno = Producto('Cuaderno A4', 3.50, 50)
usb = Producto('Memoria USB 64 GB', 9.90, 4)
for p in [apuntes, cuaderno, usb]:
print(p)
print('Venta de 3 cuadernos:', cuaderno.vender(3), '€')
usb.aplicar_descuento(20)
print(usb)
Producto.cambiar_iva(0.07) # el IGIC canario, para todos a la vez
print([p.precio_con_iva for p in [apuntes, cuaderno, usb]])
for orden in ['usb.vender(10)', 'apuntes.precio = -5', 'cuaderno.vender(1.5)', 'Producto.cambiar_iva(7)']:
try:
exec(orden)
except ValueError as err:
print(f'{orden:<24} {err}')
La pareja que hace funcionar el IVA compartido:
@property
def precio_con_iva(self): # propiedad calculada: no se guarda, se calcula
return round(self.__precio * (1 + Producto._iva), 2)
Y el método de clase que lo cambia:
@classmethod
def cambiar_iva(cls, nuevo):
if not 0 <= nuevo <= 1:
raise ValueError('el IVA debe estar entre 0 y 1')
cls._iva = nuevo
precio_con_iva no tiene setter ni atributo propio: lee Producto._iva cada vez que alguien la consulta. Y cambiar_iva usa cls._iva = nuevo, que cambia el atributo de la clase. Si escribieras self._iva = nuevo en un método normal, crearías un atributo nuevo en un solo producto (la trampa de la teoría).
Salida
=== COPISTERÍA DE LA FACULTAD === Apuntes de FP2: 12.40 € (con IVA 15.00 €), stock 30 Cuaderno A4: 3.50 € (con IVA 4.23 €), stock 50 Memoria USB 64 GB: 9.90 € (con IVA 11.98 €), stock 4 Venta de 3 cuadernos: 12.69 € Memoria USB 64 GB: 7.92 € (con IVA 9.58 €), stock 4 [13.27, 3.75, 8.47] usb.vender(10) stock insuficiente: quedan 4 apuntes.precio = -5 el precio debe ser un número no negativo cuaderno.vender(1.5) la cantidad debe ser un entero positivo Producto.cambiar_iva(7) el IVA debe estar entre 0 y 1
Comprobemos las cuentas:
| Producto | Precio | Con IVA 21 % | Con IGIC 7 % |
|---|---|---|---|
| Apuntes de FP2 | 12,40 | 12,40 × 1,21 = 15,004 → 15,00 | 12,40 × 1,07 = 13,268 → 13,27 |
| Cuaderno A4 | 3,50 | 3,50 × 1,21 = 4,235 → 4,23 | 3,50 × 1,07 = 3,745 → 3,75 |
| Memoria USB (con −20 %) | 9,90 × 0,8 = 7,92 | 7,92 × 1,21 = 9,5832 → 9,58 | 7,92 × 1,07 = 8,4744 → 8,47 |
La venta de 3 cuadernos cobra 3 × 4,23 = 12,69 € con IVA. Fíjate en los dos redondeos «raros»: 4,235 da 4,23 y 3,745 da 3,75. No es un error de la clase: los decimales como 4,235 no se pueden guardar exactos en binario (en realidad es 4,23499999…), y round trabaja con el número guardado.
Es la regla de búsqueda de la teoría: _iva vive en la clase y cada producto lo encuentra al buscarlo; por eso un solo Producto.cambiar_iva(0.07) cambia los tres precios finales.
Preguntas que te puedes hacer
¿Por qué vender devuelve el importe con IVA y no precio * cantidad? Porque lo que cobra la copistería es el precio final. El artículo antiguo devolvía el precio sin impuestos, que no es lo que pagas.
¿Por qué aplicar_descuento escribe self.precio = … en lugar de self.__precio = …? Para pasar por el setter, que redondea a dos decimales y comprueba que no sea negativo. Reutilizar la validación es más seguro que repetirla.
¿Por qué cuaderno.vender(1.5) falla si 1,5 es positivo? Porque no se venden cuadernos y medio: isinstance(cantidad, int) lo rechaza.
¿Por qué _iva lleva un guion bajo? Es «privado por convención»: se puede leer, pero para cambiarlo está cambiar_iva, que valida. Si cualquiera hiciera Producto._iva = 7, todos los precios se multiplicarían por 8.
Guardar el precio con IVA como atributo en __init__ (self.__precio_iva = precio * 1.21). Funciona hasta que cambia el impuesto: los productos ya creados se quedan con el precio viejo.
Pruébalo tú
- Añade un atributo de clase
_productos = [](aquí sí tiene sentido que sea compartido) donde cada producto se apunte al crearse, y un@classmethodque devuelva el valor total del stock. - Añade
__repr__y comprueba la diferencia al imprimir[apuntes, cuaderno].
Errores típicos en esta práctica
- Atributos públicos para datos que hay que proteger: cualquiera los deja en un estado imposible y el error aparece lejos.
- Devolver la lista interna en una propiedad: quien la recibe puede modificar el objeto sin pasar por sus métodos.
- Validar solo en los setters y olvidar
__init__: asigna en__init__conself.x = x(no conself.__x) para pasar por la validación. - Cambiar un atributo de clase con
self.: crea un atributo nuevo en un objeto en lugar de cambiar el de todos; usacls.o el nombre de la clase. - Guardar valores calculados que dependen de otros: mejor una propiedad calculada, que nunca se queda desfasada.
- Excepciones genéricas:
TypeErrorpara el tipo,ValueErrorpara el valor, y un mensaje que diga cuál.
Míralo paso a paso en Python Tutor
En Python Tutor (elige Python 3) se ve muy bien el truco de la copia del programa 1, y además cómo aparecen los atributos con name mangling:
class Cuenta:
def __init__(self, titular, saldo):
self.__titular = titular
self.__movimientos = []
self.__saldo = saldo
@property
def movimientos(self):
return list(self.__movimientos)
def depositar(self, c):
self.__saldo += c
self.__movimientos.append(c)
c = Cuenta('Laura', 100)
c.depositar(50)
copia = c.movimientos
copia.append(999)
print(c.movimientos, copia)
Salida:
[50] [50, 999]
Qué observar:
- Al crear
c, el objeto muestra los atributos_Cuenta__titular,_Cuenta__movimientosy_Cuenta__saldo: es el name mangling en directo. - Tras
c.depositar(50), la lista de movimientos del objeto contiene50. - En
copia = c.movimientos, mira las flechas:copiaapunta a una lista nueva, distinta de la del objeto. - Tras
copia.append(999), solo cambia la lista decopia. La salida lo confirma:[50]frente a[50, 999].
Resumen
- Un atributo público sin validación permite estados imposibles; una propiedad con setter los corta en el momento en que se intentan.
- Asigna en
__init__a través de las propiedades para que un objeto no pueda nacer mal. - Devuelve copias (
list(...)) de lo que sea mutable: la lista de dentro solo se toca desde los métodos. - Las clases colaboran guardando referencias: la biblioteca y
l1ven el mismo libro. - Lo que es igual para todos (el impuesto) va en un atributo de clase, y se cambia con un
@classmethod. - Lo que se puede calcular (el precio con IVA) mejor como propiedad calculada que como dato guardado.
Checklist: ¿lo has practicado?
- ¿Sabes explicar por qué la versión 1 de la cuenta da el
TypeErrorendepositary no en la asignación? - ¿Entiendes por qué el millón no entró en el historial?
- ¿Sabrías decir por qué el contador de libros vale 3 aunque se intentó crear 5?
- ¿Puedes explicar qué pasaría si
cambiar_ivausaraselfen vez decls? - ¿Has ejecutado los tres programas y comprobado las salidas?
- ¿Has intentado los «Pruébalo tú» sin mirar la teoría?
Si has respondido que sí a casi todo, estás listo para los ejercicios de clases en Python, donde ya no te doy el código hecho. ¡Nos vemos allí!
Siguiente paso: ejerciciosClases en Python — ejercicios con un estudiante y sus notas, un vehículo que pierde valor y una pila con sus propias excepciones (y el boletín de Laura con el suspenso de Pablo)¿Te ha servido este artículo? Así puedes apoyar SergioLearns →




Qué es un sistema operativo — teoría con tu portátil en una tarde de estudio, una llamada a write() y la CPU que nunca descansa (y los tres programas que no se ejecutaban a la vez)