IDS Guía de estudio

Semana 8 · Módulo 8

Programación orientada a objetos: clases y encapsulamiento

Los diccionarios guardaban datos, pero cualquiera podía romper sus reglas. Esta semana los datos y las reglas viajan juntos en objetos: una cuenta que no se deja sobregirar, un producto que no vende lo que no tiene.

Pregunta orientadora¿Cómo modelo las entidades de un negocio para que sus reglas se cumplan siempre, sin importar qué parte del programa las use?

Lecturas
PCC 9 (1.ª parte) · TRO 5
Proyecto
Preparar Fase 5
Evaluación
Control 3 (esta semana)

Cómo usar esta guía

Antes de clasePCC 9 (primera parte) y el inicio de TRO 5. Luego el checkpoint.
Durante el laboratorioModelas un sistema bancario o académico: usa el caso guiado como plantilla.
Después de claseLa auditoría de código IA (E4) es exactamente el ejercicio de clase.
Control 3Esta semana. Repasa la lista de “puedo…”. Sin IA.
  • Pregunta
  • →
  • Exploración
  • →
  • Implementación
  • →
  • Resultado
  • →
  • Interpretación
  • →
  • Verificación

Qué puedes usar esta semana

Sí, desde ahora

  • Todo lo de las semanas 1 a 7
  • class, __init__, self, atributos y métodos
  • Crear objetos (instancias) y llamar a sus métodos
  • Convención de atributo interno con guion bajo: self._saldo
  • @property para leer un atributo sin permitir modificarlo
  • raise ValueError para proteger las reglas (invariantes)
  • __str__ para mostrar un objeto de forma legible

Todavía no / fuera del curso

  • Herencia y super() → semana 9
  • Objetos que contienen otros objetos (composición) → semana 9
  • Clases en C# → semana 11

En PCC 9 la herencia viene después de la primera parte: léela, pero se practica la semana próxima.

Lecturas de preparación

Reading checkpoint

¿Cuál es la relación entre una clase y un objeto?

Una clase CuentaBancaria describe qué datos y acciones tiene toda cuenta; cuenta_ana es una cuenta en particular con su propio saldo.

¿Qué es self dentro de un método?

En cuenta_ana.depositar(50), dentro de depositar, self es cuenta_ana. Python lo pasa solo; quien llama no lo escribe.

¿Cuándo se ejecuta __init__?

__init__ prepara el objeto nuevo: asigna sus atributos iniciales.

Con class Producto: y un método vender(self, n), ¿cómo se llama correctamente?

Se llama sobre el objeto y solo con los argumentos después de self.

¿Qué indica el guion bajo en self._saldo?

Python no lo impide, es una convención. El acceso correcto es por los métodos, que validan las reglas.

Un método retirar recibe un monto mayor que el saldo. ¿Qué es lo más adecuado?

El objeto protege su regla (“el saldo nunca es negativo”). A eso se le llama invariante.

Dos objetos creados con la misma clase…

Cada instancia guarda sus propios datos. Depositar en la cuenta de Ana no cambia la de Luis.

¿Para qué sirve @property en def saldo(self): return self._saldo?

Con la propiedad, cuenta.saldo funciona para leer, pero cuenta.saldo = 1000 da error: solo los métodos cambian el saldo.

Clases y objetos

En palabras simples

Una clase es un formulario en blanco (qué casillas tiene y qué se puede hacer con él). Cada objeto es un formulario lleno: el de Ana, el de Luis. Los métodos son los trámites que se hacen con cada formulario.

Una clase CuentaBancaria funciona como molde con atributos y métodos; de ella se crean tres objetos, cada uno con su propio titular y saldo.
Un molde, muchas cuentas. Cada objeto tiene su propio saldo.

Caso: una cuenta bancaria que no se deja romper

Pregunta

Con diccionarios, cualquier parte del programa podía escribir cuenta["saldo"] = -500. ¿Cómo hago que el saldo solo cambie por depósitos y retiros válidos?

Exploración
  • Datos (atributos): titular, saldo, movimientos.
  • Acciones (métodos): depositar, retirar, estado de cuenta.
  • Regla (invariante): el saldo nunca es negativo; montos siempre positivos.

Una clase junta datos, acciones y reglas en un solo lugar.

Implementación
class CuentaBancaria:
    """Cuenta con saldo que nunca puede ser negativo."""

    def __init__(self, titular, saldo_inicial=0):
        if saldo_inicial < 0:
            raise ValueError("el saldo inicial no puede ser negativo")
        self.titular = titular
        self._saldo = saldo_inicial
        self._movimientos = []

    @property
    def saldo(self):
        return self._saldo

    def depositar(self, monto):
        if monto <= 0:
            raise ValueError("el depósito debe ser positivo")
        self._saldo += monto
        self._movimientos.append(("depósito", monto))

    def retirar(self, monto):
        if monto <= 0:
            raise ValueError("el retiro debe ser positivo")
        if monto > self._saldo:
            raise ValueError(f"saldo insuficiente (${self._saldo:.2f})")
        self._saldo -= monto
        self._movimientos.append(("retiro", monto))

    def estado_de_cuenta(self):
        lineas = [f"Cuenta de {self.titular}"]
        for tipo, monto in self._movimientos:
            lineas.append(f"  {tipo:<9} ${monto:>8.2f}")
        lineas.append(f"  saldo     ${self._saldo:>8.2f}")
        return "\n".join(lineas)


ana = CuentaBancaria("Ana", 100)
luis = CuentaBancaria("Luis")
ana.depositar(50)
ana.retirar(30)
try:
    luis.retirar(10)
except ValueError as e:
    print("Luis:", e)
print(ana.estado_de_cuenta())
print("Saldo de Luis:", luis.saldo)
Luis: saldo insuficiente ($0.00)
Cuenta de Ana
  depósito  $   50.00
  retiro    $   30.00
  saldo     $  120.00
Saldo de Luis: 0
Interpretación

Cada método revisa las reglas antes de cambiar el estado: si algo no cuadra, lanza ValueError y el saldo queda intacto. saldo es una propiedad: se lee como atributo, pero no se puede asignar. ana y luis son independientes.

Verificación

100 + 50 − 30 = 120 ✔. Prueba en el editor: ana.saldo = 1000 (da AttributeError), ana.depositar(-5) y CuentaBancaria("X", -1). Cada regla debe tener una prueba que la intente romper.

Anatomía de una clase
  • class Nombre: en PascalCase (PEP 8).
  • __init__(self, ...): se ejecuta al crear el objeto; asigna self.atributo = valor.
  • Todo método recibe self primero; quien llama no lo escribe.
  • Los atributos se leen con objeto.atributo; los métodos se llaman con objeto.metodo(args).

Encapsulamiento e invariantes

En palabras simples

Encapsular es poner el dinero en una caja fuerte con ventanilla: nadie mete la mano directo; todo pasa por la ventanilla (los métodos), donde alguien revisa que la operación sea válida.

Encapsulamiento: el saldo interno está protegido y el código externo lo modifica solo a través de los métodos depositar y retirar, que validan las reglas; asignar directamente el atributo interno se salta esas reglas.
El estado interno solo se toca a través de los métodos, que hacen cumplir las reglas.

__str__: cómo se ve un objeto al imprimirlo

class Producto:
    def __init__(self, codigo, nombre, precio, stock=0):
        self.codigo = codigo
        self.nombre = nombre
        self.precio = precio
        self.stock = stock

    def __str__(self):
        return f"{self.codigo} {self.nombre} (${self.precio:.2f}, stock {self.stock})"

    def valor_en_bodega(self):
        return self.precio * self.stock


aceite = Producto("A01", "Aceite 1 L", 12.50, 40)
print(aceite)                      # usa __str__
print(aceite.valor_en_bodega())
A01 Aceite 1 L ($12.50, stock 40)
500.0

Sin __str__, print(aceite) mostraría algo como <__main__.Producto object at 0x7f…>. Con él, el objeto se presenta en términos del negocio.

Por qué importa a un negocio

Un invariante es una regla que siempre se cumple: saldo ≥ 0, stock ≥ 0, precio > 0. Si solo se valida “en la pantalla de ventas”, otra parte del sistema (un reporte, una importación de CSV) puede romperla. Si la valida el objeto, se cumple venga de donde venga el cambio.

Tablas de traza

Traza 1 · Dos objetos y un alias

class Contador:
    def __init__(self, inicio=0):
        self.valor = inicio

    def sumar(self, n=1):
        self.valor += n
        return self.valor


a = Contador()
b = Contador(10)
a.sumar()
b.sumar(5)
a.sumar(3)
c = a
c.sumar()
Después dea.valorb.valor
b = Contador(10)
a.sumar()
b.sumar(5)
a.sumar(3)
c = a; c.sumar()
¿Por qué c.sumar() cambió a?

Igual que con las listas (semana 5): c = a no copia el objeto, crea otro nombre para el mismo. Cambiar por c es cambiar a.

Traza 2 · ¿Qué pasa con la cuenta?

Usa cuenta.py empezando cada fila con c = CuentaBancaria("X", 100). Escribe el saldo final o, si hay error, ValueError.

OperaciónResultado
c.depositar(20)
c.retirar(100)
c.retirar(100.01)
c.depositar(0)
c.depositar(5); c.retirar(105)

Ejercicios

E1 · Producto que protege su stock

clase + invariantes · ★★

Escribe la clase Producto con código, nombre, precio y stock. Métodos: vender(cantidad) (devuelve el importe; no permite dejar stock negativo), reponer(cantidad) y necesita_reorden(minimo=10). El precio debe ser positivo desde la creación.

Solución
class Producto:
    def __init__(self, codigo, nombre, precio, stock=0):
        if precio <= 0:
            raise ValueError("el precio debe ser positivo")
        self.codigo = codigo
        self.nombre = nombre
        self.precio = precio
        self._stock = stock

    @property
    def stock(self):
        return self._stock

    def vender(self, cantidad):
        if cantidad <= 0:
            raise ValueError("cantidad inválida")
        if cantidad > self._stock:
            raise ValueError(f"solo hay {self._stock} unidades de {self.nombre}")
        self._stock -= cantidad
        return cantidad * self.precio

    def reponer(self, cantidad):
        if cantidad <= 0:
            raise ValueError("cantidad inválida")
        self._stock += cantidad

    def necesita_reorden(self, minimo=10):
        return self._stock < minimo


arroz = Producto("B07", "Arroz 5 lb", 4.25, 12)
print("Venta:", arroz.vender(5))
print("¿Reordenar?", arroz.necesita_reorden())
arroz.reponer(20)
print("Stock:", arroz.stock)
assert arroz.stock == 27
try:
    arroz.vender(100)
except ValueError as e:
    print("Rechazado:", e)
Venta: 21.25
¿Reordenar? True
Stock: 27
Rechazado: solo hay 27 unidades de Arroz 5 lb

E2 · Estudiante con notas

clase + lista interna · ★★

Clase Estudiante con nombre, carnet y una lista interna de notas. Métodos: agregar_nota (solo de 0 a 10), promedio (0 si no hay notas) y aprobado(minimo=6). Agrega __str__.

Solución
class Estudiante:
    def __init__(self, nombre, carnet):
        self.nombre = nombre
        self.carnet = carnet
        self._notas = []

    def agregar_nota(self, nota):
        if not 0 <= nota <= 10:
            raise ValueError(f"nota fuera de rango: {nota}")
        self._notas.append(nota)

    def promedio(self):
        if not self._notas:
            return 0
        return round(sum(self._notas) / len(self._notas), 2)

    def aprobado(self, minimo=6):
        return self.promedio() >= minimo

    def __str__(self):
        estado = "aprobado" if self.aprobado() else "en riesgo"
        return f"{self.carnet} {self.nombre}: {self.promedio()} ({estado})"


e = Estudiante("Carla", "2026-0412")
for n in [7.5, 5.0, 8.0]:
    e.agregar_nota(n)
print(e)
try:
    e.agregar_nota(11)
except ValueError as err:
    print("Error:", err)
2026-0412 Carla: 6.83 (aprobado)
Error: nota fuera de rango: 11

"aprobado" if condicion else "en riesgo" es una expresión condicional: un if/else en una sola línea que produce un valor.

E3 · Auditoría: una clase generada por IA

auditoría · ★★★

Una IA propuso esta clase. Tiene cuatro errores conceptuales marcados con números. Explica cada uno sin ejecutar y luego corrígela.

class Cuenta:
    saldo = 0                         # (1)

    def __init__(self, titular):
        titular = titular             # (2)

    def depositar(monto):             # (3)
        saldo = saldo + monto

    def retirar(self, monto):
        self.saldo = self.saldo - monto   # (4)
        print("Retiro exitoso")
Solución
  1. Atributo de clase: saldo = 0 fuera de __init__ lo comparten todas las cuentas; el estado de cada cuenta debe ir en self.
  2. titular = titular asigna a una variable local que desaparece; debe ser self.titular.
  3. depositar(monto) no recibe self: al llamar c.depositar(50), Python pasa dos argumentos y da TypeError. Además saldo dentro es local.
  4. retirar no valida: permite saldo negativo e imprime “exitoso” siempre.
class Cuenta:
    def __init__(self, titular):
        self.titular = titular          # (2) guardar en el objeto
        self._saldo = 0                 # (1) estado por instancia

    @property
    def saldo(self):
        return self._saldo

    def depositar(self, monto):         # (3) self como primer parámetro
        if monto <= 0:
            raise ValueError("monto inválido")
        self._saldo += monto

    def retirar(self, monto):
        if monto <= 0 or monto > self._saldo:    # (4) validar antes de cambiar
            raise ValueError("retiro no permitido")
        self._saldo -= monto


c = Cuenta("Ana")
c.depositar(100)
c.retirar(40)
print(c.titular, c.saldo)
Ana 60

Práctica intensiva

Ejercicios rápidos que se corrigen solos y problemas tipo examen. Todo se puede ejecutar y modificar aquí mismo.

Ejercicios rápidos · ¿Qué imprime?

Con la clase C de cada fila. Si da error, escribe el nombre del error.

CódigoTu respuesta
class C:
    def __init__(self, x):
        self.x = x
c = C(3)
print(c.x)
class C:
    def __init__(self, x):
        self.x = x
a = C(1)
b = C(2)
a.x = 9
print(b.x)
class C:
    def __init__(self):
        self.n = 0
    def mas(self):
        self.n += 1
c = C()
c.mas()
c.mas()
print(c.n)
class C:
    def f():
        return 1
print(C().f())
class C:
    def __init__(self, x):
        x = x
print(C(5).x)
class C:
    def __str__(self):
        return "hola"
print(C())
class C:
    def __init__(self, x):
        self.x = x
a = C(1)
b = a
b.x = 7
print(a.x)

Problemas tipo examen

P1 · La factura de la semana 1, ahora como objeto

clase con lista interna · ★★★

Modela una Factura con número, cliente y líneas (descripción, cantidad, precio). Métodos: agregar, subtotal, descuento (10 % desde $50), total (con IVA 13 %) y __str__. No se permiten cantidades ni precios no positivos.

Tu programa debe cumplir estos casos de prueba:

SituaciónEsperado
3 aceites + 2 arrocessubtotal $46 → sin descuento → $51.98
+ 1 café ($6.75)subtotal $52.75 → descuento $5.28 → $53.65
agregar("x", 0, 5)ValueError
Pista

Guarda las líneas como tuplas en una lista interna. total() se apoya en subtotal() y descuento(): un método puede llamar a otros con self..

Solución
class Factura:
    TASA_IVA = 0.13

    def __init__(self, numero, cliente):
        self.numero = numero
        self.cliente = cliente
        self._lineas = []            # (descripcion, cantidad, precio_unitario)

    def agregar(self, descripcion, cantidad, precio):
        if cantidad <= 0 or precio <= 0:
            raise ValueError("cantidad y precio deben ser positivos")
        self._lineas.append((descripcion, cantidad, precio))

    def subtotal(self):
        return sum(c * p for _, c, p in self._lineas)

    def descuento(self):
        return self.subtotal() * 0.10 if self.subtotal() >= 50 else 0

    def total(self):
        base = self.subtotal() - self.descuento()
        return round(base * (1 + self.TASA_IVA), 2)

    def __str__(self):
        filas = [f"Factura {self.numero} · {self.cliente}"]
        for d, c, p in self._lineas:
            filas.append(f"  {c:>3} x {d:<12} ${c * p:>7.2f}")
        filas.append(f"  Total con IVA: ${self.total():.2f}")
        return "\n".join(filas)


f = Factura("F-0001", "Tienda Don Beto")
f.agregar("Aceite 1 L", 3, 12.50)
f.agregar("Arroz 5 lb", 2, 4.25)
print(f)
assert f.total() == round((37.5 + 8.5) * 1.13, 2)       # sin descuento: 46 < 50
f.agregar("Café 400 g", 1, 6.75)
assert abs(f.descuento() - 5.275) < 1e-9
print("Total tras agregar café:", f.total())
Factura F-0001 · Tienda Don Beto
    3 x Aceite 1 L   $  37.50
    2 x Arroz 5 lb   $   8.50
  Total con IVA: $51.98
Total tras agregar café: 53.65

Observa la evolución: es la misma regla de negocio de la semana 1 (pseudocódigo), la 2 (variables), la 4 (funciones) y ahora la 8 (un objeto que la protege).

P2 · Préstamo con su tabla de amortización

clase con cálculo · ★★★★

Clase Prestamo(monto, tasa_anual, meses) con cuota(), tabla() (lista de tuplas mes, interés, capital, saldo; el último mes deja saldo 0) e intereses_totales(). Tasa 0 debe funcionar.

Tu programa debe cumplir estos casos de prueba:

LlamadaEsperado
Prestamo(1200, 0.18, 6).cuota()210.63
Saldo del último mes0.0
Prestamo(1200, 0, 12).cuota()100.0
Prestamo(-5, 0.1, 12)ValueError
Pista

Reutiliza la fórmula de la semana 4 y el bucle de la semana 3, pero ahora los datos viven en self.

Solución
class Prestamo:
    def __init__(self, monto, tasa_anual, meses):
        if monto <= 0 or meses <= 0 or tasa_anual < 0:
            raise ValueError("datos del préstamo inválidos")
        self.monto = monto
        self.tasa_anual = tasa_anual
        self.meses = meses

    def cuota(self):
        if self.tasa_anual == 0:
            return round(self.monto / self.meses, 2)
        i = self.tasa_anual / 12
        return round(self.monto * i / (1 - (1 + i) ** -self.meses), 2)

    def tabla(self):
        """Lista de (mes, interes, capital, saldo); el último mes ajusta centavos."""
        i = self.tasa_anual / 12
        saldo = self.monto
        filas = []
        for mes in range(1, self.meses + 1):
            interes = round(saldo * i, 2)
            capital = saldo if mes == self.meses else round(self.cuota() - interes, 2)
            saldo = round(saldo - capital, 2)
            filas.append((mes, interes, capital, saldo))
        return filas

    def intereses_totales(self):
        return round(sum(f[1] for f in self.tabla()), 2)


p = Prestamo(1200, 0.18, 6)
print("Cuota:", p.cuota())
for mes, interes, capital, saldo in p.tabla():
    print(f"{mes:>2} {interes:>7.2f} {capital:>8.2f} {saldo:>9.2f}")
print("Intereses:", p.intereses_totales())
assert p.tabla()[-1][3] == 0
assert Prestamo(1200, 0, 12).cuota() == 100
Cuota: 210.63
 1   18.00   192.63   1007.37
 2   15.11   195.52    811.85
 3   12.18   198.45    613.40
 4    9.20   201.43    411.97
 5    6.18   204.45    207.52
 6    3.11   207.52      0.00
Intereses: 63.78

Proyecto integrador · Preparar la Fase 5

La Fase 5 (semana 9) pide refactorizar el sistema a clases (mínimo 3, con composición o herencia) y 5 pruebas. Esta semana diseñen las clases.

Tareas sugeridas

Preguntas de defensa

¿Por qué esto es una clase y no solo un diccionario?

Porque además de datos tiene reglas y acciones: la clase garantiza que el stock nunca sea negativo, algo que un diccionario no puede impedir.

¿Qué es self en su método tal?

El objeto sobre el que se llamó el método; señala en el código una llamada y di qué objeto es self en ese momento.

¿Qué invariante protege esta clase y dónde?

Nombra la regla y muestra la validación dentro del método (y la prueba que intenta romperla).

¿Cómo guardan un objeto en JSON?

Con un método que lo convierte a diccionario (a_dict) y una función que lo reconstruye (desde_dict). JSON no guarda objetos directamente.

Control 3

El programa ubica el Control 3 en esta semana (individual, sin IA). De esta semana y la anterior:

Fuentes

  • [PCC] Matthes, E. (2023). Python Crash Course, 3.ª ed. Cap. 9.
  • [TRO] Troelsen, A. y Japikse, P. (2022). Pro C# 10 with .NET 6. Cap. 5 (sección conceptual).
  • Python Software Foundation. Tutorial — Clases.