clases en Python práctica encapsulamiento property programas

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.

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:

Primerosaldo tiene getter y setter con @property. El getter devuelve el valor, el setter valida que no sea negativo.

Segundomovimientos devuelve una copia de la lista interna con list(self.__movimientos). Si devolvieras la lista original cualquiera podría modificarla directamente desde fuera.

Tercerodepositar 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.

Publicaciones Similares

Deja una respuesta

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