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.

Antes de empezar

Conviene tener claro:

  • Que self.x = x en __init__ es lo que guarda el dato en el objeto.
  • Cómo se escribe una propiedad: @property para leer y @nombre.setter para 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ña print y __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.

Devolver una copiaEl objeto cuenta guarda su lista de movimientos; la propiedad devuelve una copia a la que apunta historial, y el append del millón cae en la copia.historial = c1.movimientos → una copiaobjeto CuentaBancaria (c1)__saldo1150__movimientos['+250.00', '-100.00'][…, '+1000000.00']historialel append cae en la copialist(…) hace la copia. Si la propiedad devolviera self.__movimientos, el movimiento falso se colaría en la lista de dentro.
Dos listas distintas: la de dentro no la puede tocar nadie desde fuera.

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 en otra, y lanza TypeError si otra no es una CuentaBancaria.
  • Añade un límite de descubierto como atributo de clase (DESCUBIERTO = 100) y deja que retirar llegue 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 salidaQué la produce
Biblioteca de la EII: 3 libros__str__ de Biblioteca con len(self.__libros)
«Python para todos»…: prestadol1.prestar() y el __str__ de Libro
ya está prestadoEl segundo l1.prestar() del bucle
no estaba prestadol3.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.

La biblioteca y sus librosEl objeto Biblioteca tiene una lista con tres casillas que apuntan a tres objetos Libro; la variable l1 apunta también al primero, que está prestado.bib guarda referencias a los libros, no copiasBiblioteca__libros012bib«Python para todos»prestado«Estructuras de datos en C»disponible«Cálculo»disponiblel1l1.prestar() y bib.disponibles() hablan del MISMO objeto: por eso «Python para todos» ya no sale en disponibles.
Prestar con l1 se ve desde la biblioteca porque los dos caminos llegan al mismo libro.

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 Biblioteca un método prestar(titulo) que busque el libro por título exacto y lo preste, o lance ValueError si no existe.
  • Haz que añadir lance ValueError si 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:

ProductoPrecioCon IVA 21 %Con IGIC 7 %
Apuntes de FP212,4012,40 × 1,21 = 15,004 → 15,0012,40 × 1,07 = 13,268 → 13,27
Cuaderno A43,503,50 × 1,21 = 4,235 → 4,233,50 × 1,07 = 3,745 → 3,75
Memoria USB (con −20 %)9,90 × 0,8 = 7,927,92 × 1,21 = 9,5832 → 9,587,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 @classmethod que 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__ con self.x = x (no con self.__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; usa cls. 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: TypeError para el tipo, ValueError para 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:

  1. Al crear c, el objeto muestra los atributos _Cuenta__titular, _Cuenta__movimientos y _Cuenta__saldo: es el name mangling en directo.
  2. Tras c.depositar(50), la lista de movimientos del objeto contiene 50.
  3. En copia = c.movimientos, mira las flechas: copia apunta a una lista nueva, distinta de la del objeto.
  4. Tras copia.append(999), solo cambia la lista de copia. 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 l1 ven 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 TypeError en depositar y 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_iva usara self en vez de cls?
  • ¿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 →

Sigue aprendiendo

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *