Tabla de Contenidos
Programación orientada a objetos
Modelar entidades con clases y objetos.
Este módulo forma parte del curso de programación desde cero con Python
4. Introducción motivadora: el programa que describe entidades con diccionarios
En el Módulo 8 modelaste un usuario con un diccionario:
usuario = {"nombre": "Ana", "edad": 25, "activo": True}
Funciona para datos simples, pero cuando necesitas validar que la edad no sea negativa, calcular algo a partir de los datos, o garantizar que todos los usuarios tengan los mismos campos, el diccionario se queda corto. Tendrías que repetir la validación en cada lugar y recordar qué claves existen. Una clase resuelve esto: define una vez qué atributos tiene un usuario, cómo se inicializan, qué métodos pueden operar sobre ellos y qué reglas deben cumplirse.
La POO no es una moda: es una forma de organizar la complejidad. Cuando el problema tiene entidades con identidad propia (una cuenta, un producto, un enemigo), las clases permiten que el código hable el mismo lenguaje que el dominio.
5. Explicación paso a paso
5.1. Clases e instancias
Una clase es una plantilla que define qué atributos (datos) y qué métodos (comportamiento) tendrán sus objetos. Una instancia (u objeto) es un ejemplar concreto creado a partir de esa plantilla.
class Perro: """Representa un perro con nombre y edad.""" def __init__(self, nombre, edad): self.nombre = nombre self.edad = edad def ladrar(self): return f"{self.nombre} dice: ¡Guau!" # Crear instancias mi_perro = Perro("Rex", 3) tu_perro = Perro("Luna", 5) print(mi_perro.nombre) # Rex print(tu_perro.ladrar()) # Luna dice: ¡Guau!
Anatomía:
- class Perro: define la clase. Por convención, los nombres de clase usan CapWords (PEP 8).
- __init__ es el método inicializador. Se llama automáticamente al crear una instancia.
- self es una referencia a la instancia que se está creando o usando. Siempre es el primer parámetro de los métodos de instancia.
- self.nombre = nombre crea un atributo de instancia. Cada objeto tiene su propio nombre y edad.
[PYTHON]selfno es una palabra clave del lenguaje: es una convención muy fuerte. Podrías llamarloestooyo, pero todo el código Python esperaself, y las herramientas (editores, linters) lo asumen. No lo cambies.
[ERROR HABITUAL] Olvidarselfen la definición de un método. Si escribesdef ladrar():en lugar dedef ladrar(self):, al llamarmi_perro.ladrar()obtendrásTypeError: ladrar() takes 0 positional arguments but 1 was given. Python pasa automáticamente la instancia como primer argumento, aunque tú no la escribas en la llamada.
5.2. Atributos de instancia y atributos de clase
Un atributo de instancia se define dentro de __init__ (o de cualquier método) con self.atributo. Cada instancia tiene su propia copia.
Un atributo de clase se define directamente en el cuerpo de la clase, fuera de cualquier método. Es compartido por todas las instancias.
class Perro: especie = "Canis lupus familiaris" # Atributo de clase def __init__(self, nombre, edad): self.nombre = nombre # Atributo de instancia self.edad = edad print(Perro.especie) # Canis lupus familiaris print(mi_perro.especie) # Canis lupus familiaris print(mi_perro.nombre) # Rex
[ERROR HABITUAL] Usar un atributo de clase mutable (lista, diccionario) para almacenar datos que deberían ser de instancia. Todas las instancias comparten la misma lista y los cambios se filtran entre ellas. Si necesitas una lista por instancia, créala en__init__(self.items = []). Este es el mismo problema que viste con los valores predeterminados mutables en funciones (Módulo 9).
5.3. Métodos y métodos especiales
Los métodos son funciones definidas dentro de una clase. Reciben self como primer parámetro y pueden acceder a los atributos de la instancia.
Los métodos especiales (o dunder methods, por double underscore) permiten que los objetos interactúen con la sintaxis del lenguaje. Los más importantes al empezar:
| Método | Se invoca cuando… | Ejemplo |
|---|---|---|
__init__(self, ...) | Se crea una instancia. | Perro("Rex", 3) |
__str__(self) | Se usa str(objeto) o print(objeto). | print(mi_perro) |
__repr__(self) | Se usa repr(objeto) o se inspecciona en el intérprete. | repr(mi_perro) |
__eq__(self, otro) | Se compara con ==. | mi_perro == tu_perro |
__len__(self) | Se llama a len(objeto). | len(mi_perro) |
class Perro: def __init__(self, nombre, edad): self.nombre = nombre self.edad = edad def __str__(self): return f"Perro llamado {self.nombre}, {self.edad} años" def __repr__(self): return f"Perro({self.nombre!r}, {self.edad!r})" def __eq__(self, otro): if not isinstance(otro, Perro): return NotImplemented return self.nombre == otro.nombre and self.edad == otro.edad print(mi_perro) # Perro llamado Rex, 3 años print(repr(mi_perro)) # Perro('Rex', 3) print(mi_perro == Perro("Rex", 3)) # True
[BUENA PRÁCTICA] Define__repr__para todas las clases, incluso las internas: facilita la depuración.__str__es para la representación legible por el usuario;__repr__es para la representación inequívoca para el desarrollador. Si solo defines uno, define__repr__.
[PYTHON]__eq__debe devolverNotImplemented(noFalse) cuando el otro objeto no es del tipo esperado. Esto permite que Python intente la comparación con el otro operando y evita comportamientos incorrectos en estructuras como listas o diccionarios. Es un detalle idiomático que se pasa por alto con frecuencia.
5.4. Encapsulación: `_`, `__` y `@property`
Python no tiene modificadores de acceso como private o protected de Java o C++. La encapsulación se logra por convención:
- _atributo: indica “uso interno”. No se impone, pero el código externo no debería tocarlo.
- __atributo: activa el name mangling. Python lo renombra internamente a _Clase__atributo, lo que dificulta (pero no impide) el acceso externo.
La forma idiomática de controlar el acceso es @property, que permite definir métodos que se comportan como atributos:
class Temperatura: def __init__(self, celsius): self._celsius = None self.celsius = celsius # Usa el setter @property def celsius(self): """Temperatura en grados Celsius.""" return self._celsius @celsius.setter def celsius(self, valor): if valor < -273.15: raise ValueError("La temperatura no puede ser menor que el cero absoluto.") self._celsius = valor @property def fahrenheit(self): """Temperatura en grados Fahrenheit (calculada).""" return self._celsius * 9/5 + 32
Uso:
t = Temperatura(25) print(t.celsius) # 25 (getter) print(t.fahrenheit) # 77.0 (propiedad calculada) t.celsius = 30 # setter, valida # t.celsius = -300 # ValueError # t.fahrenheit = 100 # AttributeError: no hay setter
[PYTHON]@propertyes una función incorporada que convierte un método en un atributo gestionado. El patrón es: se define el getter con@property, y el setter con@nombre.setter. Si solo se define el getter, la propiedad es de solo lectura. Es la forma recomendada de validar o calcular atributos sin romper la interfaz pública de la clase.
[BUENA PRÁCTICA] Empieza con atributos públicos simples. Si más adelante necesitas validación o cálculo, puedes convertirlos en propiedades sin cambiar la forma en que el código externo los usa (objeto.atributosigue funcionando igual). Esta es una ventaja de Python frente a lenguajes que exigen getters y setters desde el principio.
5.5. Herencia y composición
La herencia permite crear una clase nueva (subclase) que reutiliza y extiende el comportamiento de otra (superclase).
class Animal: def __init__(self, nombre): self.nombre = nombre def hablar(self): raise NotImplementedError("Las subclases deben implementar hablar()") class Perro(Animal): def hablar(self): return f"{self.nombre} dice: ¡Guau!" class Gato(Animal): def hablar(self): return f"{self.nombre} dice: ¡Miau!" animales = [Perro("Rex"), Gato("Luna")] for animal in animales: print(animal.hablar())
Composición: en lugar de heredar, una clase contiene otra clase como atributo. Es preferible cuando la relación no es “es un” sino “tiene un”.
class Motor: def encender(self): return "Motor encendido" class Coche: def __init__(self): self.motor = Motor() # Composición def arrancar(self): return self.motor.encender()
[BUENA PRÁCTICA] “Favorece la composición sobre la herencia” es un principio de diseño ampliamente aceptado. La herencia crea un acoplamiento fuerte entre clases; la composición es más flexible. Usa herencia cuando exista una relación clara “es un” y quieras reutilizar comportamiento; usa composición cuando una clase “tiene un” componente.
[PYTHON]super()permite llamar a un método de la superclase. Es esencial en__init__para inicializar la parte heredada:
class Perro(Animal): def __init__(self, nombre, raza): super().__init__(nombre) self.raza = raza
Sinsuper().__init__(nombre), el atributonombreno se inicializaría.
5.6. Polimorfismo y duck typing
El polimorfismo permite tratar objetos de distintas clases a través de una interfaz común. En Python, esto se logra de forma natural mediante el duck typing: “si camina como un pato y grazna como un pato, es un pato”. No importa la clase del objeto, sino que tenga los métodos que se esperan.
def hacer_hablar(animal): print(animal.hablar()) hacer_hablar(Perro("Rex")) hacer_hablar(Gato("Luna"))
hacer_hablar no comprueba el tipo; simplemente llama a .hablar(). Cualquier objeto que tenga ese método funciona.
[PYTHON] El duck typing es una característica central de Python y de otros lenguajes dinámicos. En Java o C#, el polimorfismo requiere herencia o interfaces explícitas. En Python, basta con que el objeto tenga los métodos adecuados. Esto aporta flexibilidad, pero exige documentar bien las interfaces esperadas.
5.7. `dataclasses`: clases para almacenar datos
Cuando el propósito principal de una clase es almacenar datos (con poca o ninguna lógica), escribir __init__, __repr__ y __eq__ a mano es repetitivo. El decorador @dataclass (PEP 557) genera automáticamente esos métodos a partir de las anotaciones de tipo.
from dataclasses import dataclass @dataclass class Punto: x: float y: float p = Punto(1.0, 2.0) print(p) # Punto(x=1.0, y=2.0) print(p.x) # 1.0 print(p == Punto(1.0, 2.0)) # True
Puedes añadir valores predeterminados, métodos y opciones como frozen=True (inmutable) o order=True (comparaciones):
@dataclass(frozen=True) class Config: host: str = "localhost" puerto: int = 8080
[PYTHON] En Python 3.15, las anotaciones del__init__generado por@dataclassya no incluyen nombres de tipos internos, y la docstring de la clase se genera mediante un descriptor la primera vez que se lee__doc__. Son cambios menores que no afectan al uso básico, pero conviene conocerlos si inspeccionas los metadatos de una dataclass.
[BUENA PRÁCTICA] Usa@dataclasspara clases que son principalmente contenedores de datos (DTOs, configuraciones, resultados de funciones). Para clases con lógica compleja o invariantes fuertes, una clase normal con__init__explícito puede ser más clara.
5.8. ¿Cuándo usar clases?
Las clases no son siempre la respuesta. Antes de crear una, pregúntate:
- ¿Hay estado y comportamiento que van juntos? Si solo necesitas una función, usa una función.
- ¿Habrá varias instancias con los mismos atributos? Si solo hay una instancia, un módulo con funciones y variables puede ser suficiente.
- ¿Necesitas heredar o componer? Si no, un diccionario o una dataclass simple puede bastar.
- ¿El problema se describe naturalmente con entidades? Cuentas, usuarios, productos, pedidos: sí. Cálculos matemáticos puros: probablemente no.
[BUENA PRÁCTICA] No conviertas todo en una clase por moda. Un programa de cálculo numérico puede ser perfectamente funcional. Una clase aporta valor cuando encapsula invariantes (reglas que siempre deben cumplirse) y comportamiento que depende del estado.
6. Ejemplo completo comentado: `CuentaBancaria`
class SaldoInsuficienteError(Exception): """Se lanza cuando se intenta retirar más de lo disponible.""" pass class CuentaBancaria: """Representa una cuenta con titular, saldo y historial de operaciones.""" entidad = "Banco Python" # Atributo de clase def __init__(self, titular, saldo_inicial=0): self.titular = titular self._saldo = 0 self.historial = [] self.saldo = saldo_inicial # Usa el setter @property def saldo(self): """Saldo actual de la cuenta.""" return self._saldo @saldo.setter def saldo(self, cantidad): if cantidad < 0: raise ValueError("El saldo no puede ser negativo.") self._saldo = cantidad def depositar(self, cantidad): if cantidad <= 0: raise ValueError("La cantidad debe ser positiva.") self._saldo += cantidad self.historial.append(("depósito", cantidad)) def retirar(self, cantidad): if cantidad <= 0: raise ValueError("La cantidad debe ser positiva.") if cantidad > self._saldo: raise SaldoInsuficienteError(f"Saldo {self._saldo} insuficiente para retirar {cantidad}.") self._saldo -= cantidad self.historial.append(("retirada", cantidad)) def __str__(self): return f"Cuenta de {self.titular}: {self._saldo:.2f} €" def __repr__(self): return f"CuentaBancaria({self.titular!r}, saldo={self._saldo!r})"
Uso:
cuenta = CuentaBancaria("Ana", 100) cuenta.depositar(50) cuenta.retirar(30) print(cuenta) # Cuenta de Ana: 120.00 € print(cuenta.historial) # [('depósito', 50), ('retirada', 30)] # cuenta.retirar(500) # SaldoInsuficienteError
Seguimiento: La propiedad saldo valida que nunca sea negativo. El método retirar comprueba el saldo antes de restar y lanza una excepción específica si no hay suficiente. El historial registra cada operación. __str__ ofrece una representación legible; __repr__, una representación para depuración.
7. Seguimiento de la ejecución
Cuando ejecutas CuentaBancaria("Ana", 100):
1. Python crea un nuevo objeto vacío de tipo CuentaBancaria.
2. Llama automáticamente a __init__(self, "Ana", 100).
3. self.titular se asigna a "Ana".
4. self._saldo se inicializa a 0.
5. self.historial se inicializa a una lista vacía.
6. self.saldo = 100 invoca el setter de la propiedad saldo, que valida y asigna self._saldo = 100.
7. La instancia se devuelve y se asigna a la variable cuenta.
Cuando llamas a cuenta.depositar(50), Python pasa cuenta como self y 50 como cantidad. El método modifica self._saldo y self.historial. Los cambios persisten en el objeto.
8. Errores habituales
| Error | Causa | Cómo diagnosticarlo |
|---|---|---|
TypeError: __init__() missing 1 required positional argument: 'self' | Se llama a la clase sin crear una instancia, o se olvida self en la definición. | Revisa que __init__ tenga self como primer parámetro y que llames a Clase(...), no a Clase.__init__(...). |
AttributeError: 'Perro' object has no attribute 'nombre' | El atributo no se inicializó en __init__. | Asegúrate de que todos los atributos se asignan en __init__ o en un método llamado antes. |
| Atributo de clase mutable compartido | items = [] en el cuerpo de la clase, no en __init__. | Mueve la inicialización a __init__: self.items = []. |
NameError: name 'self' is not defined | Se usa self en un método que no lo recibe como parámetro. | Añade self a la firma del método. |
RecursionError en __repr__ | __repr__ se llama a sí mismo indirectamente. | Revisa que __repr__ no use repr(self) ni se llame a sí mismo. |
| Propiedad sin setter | Se intenta asignar a una propiedad de solo lectura. | Añade @nombre.setter o elimina la asignación. |
[ERROR HABITUAL] ElAttributeErrorpor atributo no inicializado es frecuente cuando se añaden atributos en métodos distintos de__init__. Si un atributo es parte del estado del objeto, inicialízalo siempre en__init__, aunque sea conNone.
9. Particularidades de Python frente a otros lenguajes
- No hay private real: la encapsulación se basa en convenciones (_, __) y en @property. En Java o C++, private impide el acceso desde fuera; en Python, es una cuestión de disciplina y documentación.
- self explícito: en otros lenguajes, this es implícito. En Python, self es un parámetro explícito, lo que hace evidente qué métodos acceden al estado del objeto.
- Métodos especiales (dunder) : permiten integrar objetos con la sintaxis del lenguaje (len(), ==, +, print()…). En Java, esto requiere interfaces como Comparable o toString().
- Duck typing: el polimorfismo no requiere herencia. Cualquier objeto con los métodos adecuados funciona. En lenguajes estáticamente tipados, se necesitan interfaces o clases base.
- @dataclass: genera __init__, __repr__ y __eq__ automáticamente, algo que en Java requiere librerías como Lombok o escribir código repetitivo.
10. Buenas prácticas
Incorrecto (clase sin __repr__, atributo de clase mutable):
class Carrito: items = [] # Compartido por todas las instancias def __init__(self): pass
Mejor:
class Carrito: def __init__(self): self.items = [] # Propio de cada instancia def __repr__(self): return f"Carrito({self.items!r})" def agregar(self, item): self.items.append(item)
[BUENA PRÁCTICA] Define__repr__siempre; usa__str__para la representación al usuario. Inicializa los atributos mutables en__init__. Usa@propertypara validar o calcular atributos. Prefiere composición sobre herencia cuando la relación no sea claramente “es un”. Y no crees clases si una función o un diccionario resuelven el problema con menos complejidad.
11. Windows y Linux
La programación orientada a objetos es idéntica en Windows y Linux. La sintaxis de clases, self, @property, herencia y @dataclass no depende del sistema operativo. Las diferencias de rutas o codificación que viste en el Módulo 13 no afectan a la definición de clases.
[WINDOWS / LINUX] Si usas@dataclassconslots=True(Python 3.10+), el comportamiento es el mismo en ambos sistemas.slotsreduce el uso de memoria al impedir la creación de atributos no declarados.
12. Ejercicios guiados
Ejercicio guiado 1: Clase Rectangulo.
Crea una clase Rectangulo con atributos ancho y alto, y métodos area() y perimetro(). Define __str__ para mostrar las dimensiones y el área. Crea dos instancias y compáralas con == (implementa __eq__).
Ejercicio guiado 2: @property con validación.
Modifica Rectangulo para que ancho y alto sean propiedades que rechacen valores negativos con ValueError. Comprueba que rect.ancho = -5 lanza la excepción.
Ejercicio guiado 3: dataclass para un punto 3D.
Crea una @dataclass llamada Punto3D con x, y, z (float). Añade un método distancia_al_origen() que devuelva (x**2 + y**2 + z**2) ** 0.5. Crea dos puntos y compáralos.
13. Ejercicios autónomos
1. Predicción: ¿Qué muestra este código? Razona antes de ejecutar.
```python
class A:
def __init__(self):
self.x = 1
a = A()
b = a
b.x = 2
print(a.x)
```
2. Herencia: Crea una clase Vehiculo con marca y modelo, y un método descripcion(). Crea Coche y Moto que hereden de Vehiculo y sobrescriban descripcion().
3. Composición: Crea una clase Motor con caballos y una clase Coche que contenga un Motor. El método especificaciones() del coche debe usar los datos del motor.
4. Encapsulación: Crea una clase Cuenta con saldo privado (__saldo) y métodos ingresar y retirar que validen las cantidades. Intenta acceder a cuenta.__saldo desde fuera y explica qué ocurre.
5. Reto opcional: Investiga el parámetro frozen=True de @dataclass. Crea una dataclass Configuracion congelada e intenta modificar un atributo después de crearla. ¿Qué excepción se lanza? ¿Por qué es útil?
14. Soluciones razonadas
Solución al ejercicio guiado 1:
class Rectangulo: def __init__(self, ancho, alto): self.ancho = ancho self.alto = alto def area(self): return self.ancho * self.alto def perimetro(self): return 2 * (self.ancho + self.alto) def __str__(self): return f"Rectángulo {self.ancho}x{self.alto}, área {self.area()}" def __eq__(self, otro): if not isinstance(otro, Rectangulo): return NotImplemented return self.ancho == otro.ancho and self.alto == otro.alto
Solución al ejercicio guiado 2:
class Rectangulo: def __init__(self, ancho, alto): self.ancho = ancho self.alto = alto @property def ancho(self): return self._ancho @ancho.setter def ancho(self, valor): if valor <= 0: raise ValueError("El ancho debe ser positivo.") self._ancho = valor @property def alto(self): return self._alto @alto.setter def alto(self, valor): if valor <= 0: raise ValueError("El alto debe ser positivo.") self._alto = valor
Solución al ejercicio guiado 3:
from dataclasses import dataclass @dataclass class Punto3D: x: float y: float z: float def distancia_al_origen(self): return (self.x**2 + self.y**2 + self.z**2) ** 0.5
Solución al ejercicio 1 (predicción): a.x muestra 2. b = a no copia el objeto; ambas variables apuntan a la misma instancia. Modificar b.x modifica el mismo atributo que a.x. Es el mismo comportamiento de referencias que viste con las listas en el Módulo 8.
Solución al ejercicio 4 (encapsulación): cuenta.__saldo desde fuera lanza AttributeError porque Python aplica name mangling: el atributo se renombra internamente a _Cuenta__saldo. No es un mecanismo de seguridad, sino una convención para indicar “no tocar”. Se puede acceder con cuenta._Cuenta__saldo, pero hacerlo es una mala práctica.
15. Resumen
- Una clase es una plantilla; una instancia es un objeto concreto creado a partir de ella.
- __init__ inicializa los atributos de instancia; self referencia al objeto actual.
- Los atributos de instancia son propios de cada objeto; los atributos de clase se comparten.
- Los métodos especiales (__str__, __repr__, __eq__…) integran los objetos con la sintaxis del lenguaje.
- La encapsulación se logra con convenciones (_, __) y @property para validar o calcular atributos.
- La herencia reutiliza comportamiento; la composición es a menudo preferible.
- El polimorfismo (duck typing) permite tratar objetos de distintas clases a través de una interfaz común.
- @dataclass genera automáticamente __init__, __repr__ y __eq__ para clases de datos.
- Usa clases cuando haya estado y comportamiento que deban ir juntos; no conviertas todo en clases por obligación.
16. Autoevaluación
1. ¿Qué diferencia hay entre un atributo de instancia y un atributo de clase? Pon un ejemplo de cada uno.
2. ¿Para qué sirve self y qué ocurre si se omite en un método?
3. ¿Qué hace @property y en qué se diferencia de un atributo normal?
4. ¿Cuándo preferirías composición sobre herencia?
5. Reto: Sin ejecutar, predice qué muestra este código y explica el motivo:
```python
class Contador:
total = 0
def __init__(self):
Contador.total += 1
a = Contador() b = Contador() print(Contador.total) ```
17. Fuentes oficiales
- Tutorial oficial: Classes: https://docs.python.org/3.15/tutorial/classes.html
- Referencia del lenguaje: Data model (métodos especiales): https://docs.python.org/3.15/reference/datamodel.html
- Biblioteca estándar: dataclasses — Data Classes: https://docs.python.org/3.15/library/dataclasses.html
- Funciones incorporadas: property(): https://docs.python.org/3.15/library/functions.html#property
- PEP 557 – Data Classes: https://peps.python.org/pep-0557/
- PEP 8 – Style Guide for Python Code (nombres de clases, métodos): https://peps.python.org/pep-0008/
- Novedades de Python 3.15 (cambios menores en dataclasses): https://docs.python.org/3/whatsnew/3.15.html
