Muchos tutoriales presentan la programación orientada a objetos en Python como una escalera de términos: clase, objeto, __init__, self, atributo, método, herencia. El orden parece razonable, pero deja una pregunta importante sin responder: ¿qué problema aparece en tu código para que una clase sea una mejor decisión que unas cuantas funciones y diccionarios?
La POO no empieza cuando escribes class. Empieza cuando ciertos datos deben conservar una relación entre sí, cambiar siguiendo reglas comunes y no mezclarse con los datos de otra cosa parecida. En Python, una clase agrupa datos y funcionalidad; sus instancias pueden mantener estado propio y sus métodos pueden modificarlo. Pero esa descripción solo se vuelve útil cuando has visto el fallo que evita. La tesis de este artículo es que la POO en Python empieza cuando el estado necesita un responsable, no cuando aparece self.
El día en que una lista dejó de pertenecer a todos
Supón que estás haciendo un programa para registrar tareas. Al principio, una lista y tres funciones bastan:
tareas = []
def agregar(texto):
tareas.append({"texto": texto, "hecha": False})
def completar(indice):
tareas[indice]["hecha"] = True
def pendientes():
return [tarea for tarea in tareas if not tarea["hecha"]]
No hay nada malo en este diseño. Si el programa maneja una sola lista, mantener funciones simples puede ser más claro que crear una clase solo para demostrar que sabes usar una. El cambio llega cuando aparece una segunda lista: un proyecto personal y uno de estudio. Ahora cada conjunto de tareas necesita sus propios elementos, pero también necesita las mismas operaciones: agregar, completar, ver pendientes y quizá contar las terminadas.
Una salida apresurada sería duplicar funciones: agregar_personal(), agregar_estudio(), pendientes_personal(). Otra sería conservar una variable global y pasarle un nombre de proyecto. Ambas opciones pueden funcionar durante un rato. La pregunta es quién garantiza que las tareas de un proyecto no se mezclen con las del otro, y dónde vive esa garantía.
Ahí aparece una clase con una responsabilidad concreta:
class Proyecto:
def __init__(self, nombre):
self.nombre = nombre
self.tareas = []
def agregar(self, texto):
self.tareas.append({"texto": texto, "hecha": False})
def completar(self, indice):
self.tareas[indice]["hecha"] = True
def pendientes(self):
return [tarea for tarea in self.tareas if not tarea["hecha"]]
estudio = Proyecto("Python")
personal = Proyecto("Casa")
estudio.agregar("Leer sobre funciones")
personal.agregar("Comprar café")
La ganancia no es que el programa tenga una sintaxis más sofisticada. La ganancia es que estudio y personal tienen listas diferentes, pero obedecen las mismas reglas. Cada objeto conserva su propio estado. Si quieres saber por qué Python trata esto de ese modo, la documentación oficial sobre clases lo expresa con precisión: las instancias pueden tener atributos para mantener estado y métodos definidos por la clase para modificarlo.
self no crea magia: nombra a qué objeto pertenece una decisión
Es fácil aprender por repetición que los métodos llevan self como primer parámetro. Más útil es leerlo como una pregunta: “¿qué proyecto está recibiendo esta operación?”. Cuando escribes self.tareas.append(...), no estás invocando una palabra reservada especial. Estás diciendo que el cambio debe ocurrir en las tareas del objeto que llamó al método.
La documentación explica que self es una convención de nombre y que, al llamar estudio.agregar("..."), Python inserta la instancia como primer argumento. El comportamiento importa más que memorizar la fórmula: Proyecto.agregar(estudio, "...") describe la misma relación. Un método tiene contexto porque una instancia concreta lo recibió.
Esto también aclara cuándo una función suelta puede ser preferible. Si una operación no necesita recordar nada entre una llamada y otra, una función puede comunicar mejor su intención:
def normalizar_titulo(texto):
return texto.strip().title()
normalizar_titulo() toma una entrada y devuelve una salida. No mantiene una colección propia, no protege una regla de estado ni representa una entidad. Convertirla en método de una clase de “Utilidades” no la vuelve más orientada a objetos; solo le añade un contenedor que no tiene responsabilidad real. La elección no es “funciones contra clases”, sino “¿esta operación necesita pertenecer a un estado que debe conservarse?”.
El error de estado compartido que revela la diferencia entre clase e instancia
Ahora observa una versión que parece equivalente, pero no lo es:
class Proyecto:
tareas = []
def __init__(self, nombre):
self.nombre = nombre
def agregar(self, texto):
self.tareas.append(texto)
Si creas dos proyectos y agregas una tarea a uno, ambos verán la misma lista. La razón es que tareas se creó una sola vez en la clase, no una vez por instancia. La referencia oficial usa un ejemplo parecido con una lista de trucos para perros: una lista mutable declarada como atributo de clase se comparte entre todas las instancias, mientras que una lista creada dentro de __init__ pertenece a cada una.
| Ubicación del dato | Qué representa | Cuándo es una buena idea |
|---|---|---|
Proyecto.tareas | Un valor compartido por todos los proyectos. | Solo si compartirlo es parte explícita de la regla del dominio. |
self.tareas | El estado de un proyecto concreto. | Cuando cada instancia debe conservar sus propios datos. |
| Una variable local dentro de una función | Un valor temporal de una operación. | Cuando no debe sobrevivir después de terminar la llamada. |
Este problema no es exclusivo de POO. Se conecta con algo que aparece al trabajar con listas, diccionarios y archivos: los nombres no siempre crean copias de los objetos. El modelo de datos de Python distingue identidad, tipo y valor, y explica que objetos mutables como listas y diccionarios pueden cambiar aunque existan varias referencias al mismo objeto. Antes de culpar a una clase por “comportarse raro”, revisa si dos nombres están apuntando a la misma colección.
La práctica de elegir una estructura de datos por la operación que debe preservar ayuda aquí. Una lista dentro de una instancia no es solo “un atributo”: es una decisión de que cada proyecto mantenga un orden de tareas que puede cambiar independientemente.
Responsabilidad antes de herencia
Una vez que un principiante entiende clases e instancias, suele escuchar que el siguiente paso es herencia. Entonces aparecen ejemplos de Animal, Perro y Gato, aunque el programa real no necesita ninguno. La herencia puede ser útil cuando una clase especializada es genuinamente una versión de otra y puede cumplir sus mismas expectativas. Pero no es un requisito para que una clase tenga sentido.
En el ejemplo de proyectos, quizá quieras añadir recordatorios. Podrías heredar:
class ProyectoConRecordatorios(Proyecto):
...
Antes de hacerlo, pregunta si un recordatorio es un tipo de proyecto o algo que un proyecto puede tener. Si es una capacidad adicional que podría reutilizarse por separado, una composición suele describir mejor la relación:
class Recordatorio:
def __init__(self, fecha, mensaje):
self.fecha = fecha
self.mensaje = mensaje
class Proyecto:
def __init__(self, nombre):
self.nombre = nombre
self.tareas = []
self.recordatorios = []
Ahora el proyecto contiene recordatorios, igual que contiene tareas. No estás obligado a extender una jerarquía para incorporar cada comportamiento nuevo. La herencia no debería ser una medalla por haber llegado a “conceptos avanzados”; es una herramienta para relaciones específicas que debes poder explicar.
Encapsular no significa esconder cada atributo
En Python, “encapsulamiento” suele explicarse como usar nombres con guion bajo. Pero el lenguaje no convierte automáticamente una variable en privada por escribir _saldo. La documentación de clases es directa: Python no fuerza un ocultamiento de datos puro; muchas protecciones dependen de convenciones y de diseñar métodos que mantengan invariantes.
Una invariante es una condición que quieres conservar. Por ejemplo, una tarea completada no debería volver a quedar pendiente por accidente, o un presupuesto no debería poder ser negativo. Si cualquiera modifica el atributo en cualquier lugar, esa regla se dispersa. Un método puede concentrarla:
class Presupuesto:
def __init__(self, limite):
self.limite = limite
self.gastado = 0
def registrar_gasto(self, monto):
if monto <= 0:
raise ValueError("El monto debe ser positivo")
if self.gastado + monto > self.limite:
raise ValueError("El gasto supera el límite")
self.gastado += monto
La clase no existe para esconder gastado por deporte. Existe para que la regla “no superar el límite” tenga una casa. Cuando un objeto se vuelve útil, normalmente puedes señalar una regla o una relación que quedaría frágil si estuviera repartida por el resto del programa.
Este criterio también evita convertir todo dato en objeto. Un diccionario leído desde un archivo puede ser suficiente durante mucho tiempo. Cuando empieces a leer y escribir archivos, pregunta primero si solo necesitas almacenar valores o si necesitas preservar reglas cuando esos valores cambian. Crear una clase tiene coste: debes nombrarla, definir sus operaciones y decidir cómo se relaciona con las demás. Vale la pena cuando reduce una ambigüedad real.
Una prueba de diseño antes de escribir class
Prueba estas tres preguntas sobre tu código actual. Si respondes “sí” a varias, una clase puede ayudarte:
- ¿Hay datos que siempre deberían viajar juntos, como una colección y las reglas que la cambian?
- ¿Necesitas dos o más copias independientes de esa misma idea?
- ¿Existe una condición que quieres proteger cada vez que el estado cambia?
Si respondes “no”, una función o una estructura de datos básica puede seguir siendo la solución más legible. La POO no es una etapa obligatoria que reemplaza todo lo anterior. Es una forma de dar identidad y responsabilidad a una parte del programa cuando esa parte ya tiene estado propio que cuidar.
Antes de crear una clase, comprueba también qué estructura contendrá ese estado y qué operaciones pueden modificarla. Las reglas de listas, diccionarios y colecciones no desaparecen dentro de un objeto; siguen determinando qué puede cambiar y cómo se comparte. La referencia de estructuras de datos de Python ayuda a elegir esa colección con criterio, mientras que las prácticas de Python limpio ayudan a que los métodos comuniquen la regla que protegen.
Tres dudas que aparecen al modelar estado
¿self es una palabra reservada de Python?
No. Es el nombre convencional para la instancia que recibe el método. Python pasa esa instancia automáticamente al llamar un método, pero podrías usar otro nombre. Mantener self facilita que otras personas reconozcan la intención del código.
¿Todo atributo debe definirse dentro de __init__?
No todo atributo imaginable, pero el estado inicial que debe ser distinto por instancia suele definirse allí. Un valor compartido puede vivir en la clase, siempre que compartirlo sea deliberado. Para listas, diccionarios y otros objetos mutables, declarar el valor dentro de __init__ evita que distintas instancias usen la misma colección por accidente.
¿Herencia es la forma correcta de reutilizar código?
A veces, pero no siempre. Si una cosa simplemente contiene o utiliza otra capacidad, composición suele comunicar mejor la relación. Usa herencia cuando la clase especializada pueda comportarse de forma válida como la clase base y esa relación sea estable, no solo porque ambas comparten algunas líneas.

Martin Rojas escribe sobre tecnología, programación y desarrollo web. En Skydutz Academy comparte explicaciones prácticas sobre herramientas digitales, conceptos de programación y recursos para aprender de forma progresiva. Su objetivo es hacer que los temas técnicos sean más claros, útiles y accesibles para lectores de distintos niveles.
