Clases en Python — 3 programas reales con encapsulamiento y @property
Las clases en Python práctica real es lo que toca ahora. En el artículo anterior vimos la teoría. Ahora construimos tres clases desde cero, empezando sin encapsulamiento y añadiéndolo progresivamente para que veas exactamente por qué mejora el código.
Tabla de Contenidos
Clases en Python práctica — Programa 1: Clase CuentaBancaria
Empezamos con la versión básica sin encapsulamiento:
class CuentaBancaria:
def __init__(self, titular, saldo_inicial=0):
self.titular = titular
self.saldo = saldo_inicial
def depositar(self, cantidad):
self.saldo += cantidad
def retirar(self, cantidad):
self.saldo -= cantidad # permite saldo negativo — problema
def __str__(self):
return f'Cuenta de {self.titular}: {self.saldo:.2f}€'
cuenta = CuentaBancaria('Sergio', 1000)
cuenta.saldo = -999999 # cualquiera puede manipular el saldo directamente
cuenta.retirar(5000) # permite quedarse en negativo
print(cuenta)
El problema es claro, el saldo es accesible y modificable directamente desde fuera. Ahora la versión con encapsulamiento completo:
class CuentaBancaria:
_total_cuentas = 0
def __init__(self, titular, saldo_inicial=0):
if not titular or not isinstance(titular, str):
raise ValueError('El titular debe ser una cadena no vacía')
self.__titular = titular
self.__saldo = 0
self.__movimientos = []
self.saldo = saldo_inicial # usa el setter
CuentaBancaria._total_cuentas += 1
@property
def titular(self):
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 numérico')
if valor < 0:
raise ValueError('El saldo inicial no puede ser negativo')
self.__saldo = valor
@property
def movimientos(self):
return list(self.__movimientos) # copia — no el original
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}", {self.__saldo})'
# Uso
c1 = CuentaBancaria('Sergio', 1000)
c2 = CuentaBancaria('María', 500)
c1.depositar(250)
c1.retirar(100)
print(c1) # → Cuenta de Sergio: 1150.00€
print(c1.movimientos) # → ['+250.00€', '-100.00€']
print(CuentaBancaria.total_cuentas()) # → 2
try:
c1.retirar(5000)
except ValueError as err:
print(err) # → Saldo insuficiente: tienes 1150.00€
try:
c1.saldo = -100 # intento de manipulación directa
except ValueError as err:
print(err) # → El saldo inicial no puede ser negativo
Fíjate en los tres puntos clave de la evolución:
Primero — saldo tiene getter y setter con @property. El getter devuelve el valor, el setter valida que no sea negativo.
Segundo — movimientos devuelve una copia de la lista interna con list(self.__movimientos). Si devolvieras la lista original cualquiera podría modificarla directamente desde fuera.
Tercero — depositar y retirar usan self.__saldo directamente, no el setter — porque ya validan ellos mismos las condiciones de negocio.
Clases en Python práctica — Programa 2: Clase Libro para una biblioteca
class Libro:
_total_libros = 0
def __init__(self, titulo, autor, anio, isbn):
self.titulo = titulo # usa setter
self.autor = autor # usa setter
self.anio = anio # usa setter
self.__isbn = isbn
self.__disponible = True
Libro._total_libros += 1
@property
def titulo(self):
return self.__titulo
@titulo.setter
def titulo(self, valor):
if not valor or not isinstance(valor, str):
raise ValueError('El título debe ser una cadena no vacía')
self.__titulo = valor.strip()
@property
def autor(self):
return self.__autor
@autor.setter
def autor(self, valor):
if not valor or not isinstance(valor, str):
raise ValueError('El autor debe ser una cadena no vacía')
self.__autor = valor.strip()
@property
def anio(self):
return self.__anio
@anio.setter
def anio(self, valor):
if not isinstance(valor, int) or valor < 1000 or valor > 2025:
raise ValueError(f'Año no válido: {valor}')
self.__anio = valor
@property
def isbn(self):
return self.__isbn # solo lectura — sin setter
@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 está 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}" de {self.__autor} ({self.__anio}) — {estado}'
def __repr__(self):
return f'Libro("{self.__titulo}", "{self.__autor}", {self.__anio}, "{self.__isbn}")'
# Biblioteca
class Biblioteca:
def __init__(self, nombre):
self.__nombre = nombre
self.__libros = []
def añadir_libro(self, libro):
if not isinstance(libro, Libro):
raise TypeError('Solo se pueden añadir objetos de tipo Libro')
self.__libros.append(libro)
def buscar_titulo(self, titulo):
resultados = [l for l in self.__libros
if titulo.lower() in l.titulo.lower()]
return resultados
def libros_disponibles(self):
return [l for l in self.__libros if l.disponible]
def __str__(self):
return f'Biblioteca "{self.__nombre}": {len(self.__libros)} libros'
# Uso
b = Biblioteca('ULPGC')
l1 = Libro('Fundamentos de Python', 'Hernández', 2023, '978-1-1234')
l2 = Libro('Programación en C', 'Santos', 2022, '978-2-5678')
l3 = Libro('Estadística para GCID', 'Rodríguez', 2021, '978-3-9012')
b.añadir_libro(l1)
b.añadir_libro(l2)
b.añadir_libro(l3)
print(b) # → Biblioteca "ULPGC": 3 libros
print(l1) # → "Fundamentos de Python" de Hernández (2023) — disponible
l1.prestar()
print(l1) # → "Fundamentos de Python" de Hernández (2023) — prestado
try:
l1.prestar()
except ValueError as err:
print(err) # → "Fundamentos de Python" ya está prestado
disponibles = b.libros_disponibles()
for libro in disponibles:
print(libro)
print(Libro.total_libros()) # → 3
Clases en Python práctica — Programa 3: Clase Producto para una tienda
class Producto:
_iva = 0.21 # atributo de clase — mismo para todos
def __init__(self, nombre, precio, stock=0):
self.nombre = nombre # usa setter
self.precio = precio # usa setter
self.__stock = 0
self.añadir_stock(stock)
@property
def nombre(self):
return self.__nombre
@nombre.setter
def nombre(self, valor):
if not valor or not isinstance(valor, str):
raise ValueError('El nombre debe ser una cadena no vacía')
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):
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} unidades')
self.__stock -= cantidad
return self.__precio * cantidad
def aplicar_descuento(self, porcentaje):
if not (0 < porcentaje < 100):
raise ValueError('El descuento debe estar entre 0 y 100')
self.__precio = round(self.__precio * (1 - porcentaje / 100), 2)
@classmethod
def cambiar_iva(cls, nuevo_iva):
if not (0 <= nuevo_iva <= 1):
raise ValueError('El IVA debe estar entre 0 y 1')
cls._iva = nuevo_iva
def __str__(self):
return (f'{self.__nombre}: {self.__precio:.2f}€ '
f'(+IVA: {self.precio_con_iva:.2f}€) — '
f'stock: {self.__stock}')
def __repr__(self):
return f'Producto("{self.__nombre}", {self.__precio}, {self.__stock})'
# Tienda
print('=== TIENDA ===\n')
p1 = Producto('Teclado', 45.99, 10)
p2 = Producto('Ratón', 29.99, 5)
p3 = Producto('Monitor', 299.99, 3)
print(p1) # → Teclado: 45.99€ (+IVA: 55.65€) — stock: 10
print(p2) # → Ratón: 29.99€ (+IVA: 36.29€) — stock: 5
# Venta
total = p1.vender(2)
print(f'Venta: {total:.2f}€') # → Venta: 91.98€
print(p1) # → Teclado: 45.99€ (+IVA: 55.65€) — stock: 8
# Descuento
p2.aplicar_descuento(10)
print(p2) # → Ratón: 26.99€ (+IVA: 32.66€) — stock: 5
# Cambio de IVA para todos
Producto.cambiar_iva(0.10)
print(p1.precio_con_iva) # → 50.59 (IVA al 10%)
print(p3.precio_con_iva) # → 329.99 (IVA al 10%)
# Errores
try:
p2.vender(10)
except ValueError as err:
print(err) # → Stock insuficiente: quedan 5 unidades
try:
p3.precio = -50
except ValueError as err:
print(err) # → El precio debe ser un número no negativo
Fíjate en el atributo de clase _iva — cuando lo cambias con Producto.cambiar_iva(0.10) afecta a todos los productos a la vez. Eso es exactamente para lo que sirven los atributos de clase, datos que son comunes a todos los objetos.
Visualízalo con Python Tutor
Copia este código en pythontutor.com:
class CuentaBancaria:
def __init__(self, titular, saldo):
self.__titular = titular
self.__saldo = saldo
@property
def saldo(self):
return self.__saldo
def depositar(self, cantidad):
self.__saldo += cantidad
c = CuentaBancaria('Sergio', 1000)
c.depositar(250)
print(c.saldo)
Observa cómo __saldo aparece como _CuentaBancaria__saldo en el diagrama, eso es el name mangling de Python en acción.
Resumen y siguiente paso
En este artículo construiste tres clases reales con encapsulamiento completo. La evolución de CuentaBancaria muestra claramente por qué el encapsulamiento protege la integridad de los datos. Libro demuestra el patrón de propiedad de solo lectura con isbn. Producto muestra cómo los atributos de clase afectan a todos los objetos a la vez.
En el siguiente artículo encontrarás ejercicios propuestos con solución.
Python classes — 3 real programs with encapsulation and @property
Program 1 — BankAccount class
Basic version first, no encapsulation:
class BankAccount:
def __init__(self, holder, initial_balance=0):
self.holder = holder
self.balance = initial_balance
def deposit(self, amount):
self.balance += amount
def withdraw(self, amount):
self.balance -= amount # allows negative balance — problem
def __str__(self):
return f'{self.holder}\'s account: {self.balance:.2f}€'
account = BankAccount('Sergio', 1000)
account.balance = -999999 # anyone can manipulate balance directly
Now with full encapsulation:
class BankAccount:
_total_accounts = 0
def __init__(self, holder, initial_balance=0):
if not holder or not isinstance(holder, str):
raise ValueError('Holder must be a non-empty string')
self.__holder = holder
self.__balance = 0
self.__transactions = []
self.balance = initial_balance
BankAccount._total_accounts += 1
@property
def holder(self):
return self.__holder
@property
def balance(self):
return self.__balance
@balance.setter
def balance(self, value):
if not isinstance(value, (int, float)):
raise TypeError('Balance must be numeric')
if value < 0:
raise ValueError('Initial balance cannot be negative')
self.__balance = value
@property
def transactions(self):
return list(self.__transactions)
def deposit(self, amount):
if amount <= 0:
raise ValueError('Deposit amount must be positive')
self.__balance += amount
self.__transactions.append(f'+{amount:.2f}€')
def withdraw(self, amount):
if amount <= 0:
raise ValueError('Withdrawal amount must be positive')
if amount > self.__balance:
raise ValueError(f'Insufficient funds: you have {self.__balance:.2f}€')
self.__balance -= amount
self.__transactions.append(f'-{amount:.2f}€')
@classmethod
def total_accounts(cls):
return cls._total_accounts
def __str__(self):
return f'{self.__holder}\'s account: {self.__balance:.2f}€'
def __repr__(self):
return f'BankAccount("{self.__holder}", {self.__balance})'
a1 = BankAccount('Sergio', 1000)
a1.deposit(250)
a1.withdraw(100)
print(a1) # → Sergio's account: 1150.00€
print(a1.transactions) # → ['+250.00€', '-100.00€']
print(BankAccount.total_accounts()) # → 1
Three key points: balance has @property getter and setter. transactions returns a copy. deposit and withdraw use self.__balance directly, they validate their own business rules.
Program 2 — Book class for a library
class Book:
_total_books = 0
def __init__(self, title, author, year, isbn):
self.title = title
self.author = author
self.year = year
self.__isbn = isbn
self.__available = True
Book._total_books += 1
@property
def title(self):
return self.__title
@title.setter
def title(self, value):
if not value or not isinstance(value, str):
raise ValueError('Title must be a non-empty string')
self.__title = value.strip()
@property
def author(self):
return self.__author
@author.setter
def author(self, value):
if not value or not isinstance(value, str):
raise ValueError('Author must be a non-empty string')
self.__author = value.strip()
@property
def year(self):
return self.__year
@year.setter
def year(self, value):
if not isinstance(value, int) or value < 1000 or value > 2025:
raise ValueError(f'Invalid year: {value}')
self.__year = value
@property
def isbn(self):
return self.__isbn # read-only — no setter
@property
def available(self):
return self.__available
def lend(self):
if not self.__available:
raise ValueError(f'"{self.__title}" is already on loan')
self.__available = False
def return_book(self):
if self.__available:
raise ValueError(f'"{self.__title}" is not on loan')
self.__available = True
@classmethod
def total_books(cls):
return cls._total_books
def __str__(self):
status = 'available' if self.__available else 'on loan'
return f'"{self.__title}" by {self.__author} ({self.__year}) — {status}'
def __repr__(self):
return f'Book("{self.__title}", "{self.__author}", {self.__year}, "{self.__isbn}")'
Program 3 — Product class for a store
class Product:
_vat = 0.21 # class attribute — same for all products
def __init__(self, name, price, stock=0):
self.name = name
self.price = price
self.__stock = 0
self.add_stock(stock)
@property
def name(self):
return self.__name
@name.setter
def name(self, value):
if not value or not isinstance(value, str):
raise ValueError('Name must be a non-empty string')
self.__name = value.strip()
@property
def price(self):
return self.__price
@price.setter
def price(self, value):
if not isinstance(value, (int, float)) or value < 0:
raise ValueError('Price must be a non-negative number')
self.__price = round(value, 2)
@property
def price_with_vat(self):
return round(self.__price * (1 + Product._vat), 2)
@property
def stock(self):
return self.__stock
def add_stock(self, quantity):
if not isinstance(quantity, int) or quantity < 0:
raise ValueError('Quantity must be a non-negative integer')
self.__stock += quantity
def sell(self, quantity=1):
if not isinstance(quantity, int) or quantity <= 0:
raise ValueError('Quantity must be a positive integer')
if quantity > self.__stock:
raise ValueError(f'Insufficient stock: {self.__stock} left')
self.__stock -= quantity
return self.__price * quantity
def apply_discount(self, percentage):
if not (0 < percentage < 100):
raise ValueError('Discount must be between 0 and 100')
self.__price = round(self.__price * (1 - percentage / 100), 2)
@classmethod
def change_vat(cls, new_vat):
if not (0 <= new_vat <= 1):
raise ValueError('VAT must be between 0 and 1')
cls._vat = new_vat
def __str__(self):
return (f'{self.__name}: {self.__price:.2f}€ '
f'(+VAT: {self.price_with_vat:.2f}€) — '
f'stock: {self.__stock}')
def __repr__(self):
return f'Product("{self.__name}", {self.__price}, {self.__stock})'
_vat is a class attribute, changing it with Product.change_vat(0.10) affects all products at once. That’s exactly what class attributes are for.
