Encapsulación en Python
Hasta ahora, cualquier atributo de un objeto se lee y se modifica libremente desde afuera: objeto.atributo = lo-que-sea, sin que nada lo impida. La encapsulación es el principio de proteger el estado interno de un objeto, exponiendo solo lo necesario y controlando cómo se lo modifica.
Bienvenido
En Clases y objetos ya viste que un objeto guarda sus propios atributos y que un método puede leerlos y modificarlos. Lo que no viste todavía es que, en Python, nada impide por defecto que alguien de afuera de la clase asigne cualquier valor a esos atributos, tenga sentido o no. Esta guía es sobre cómo prevenir eso.
Al finalizar esta guía vas a saber usar las convenciones _ y __ para señalar y dificultar el acceso externo a un atributo, entender qué es el name mangling, escribir getters y setters clásicos, y usar @property —la forma pythonica de lograr lo mismo sin cambiar cómo se accede al atributo desde afuera— para validar un valor antes de aceptarlo.
Todos los bloques con el encabezado Python son de solo lectura (para que los escribas a mano en el editor de abajo). El panel editor-panel que sigue a cada uno es donde escribes y ejecutas tu propio código —usa Pyodide, un Python real compilado a WebAssembly. La primera ejecución tarda unos segundos porque descarga el intérprete.
Además del código, esta guía tiene 3 preguntas conceptuales cortas para chequear que entendiste bien el porqué de cada herramienta, no solo su sintaxis.
📖 Aprende
Por qué el acceso directo a un atributo es riesgoso, y las convenciones _/__ que usa Python para señalarlo.
💻 Practica
Ejemplos editables y ejecutables en cada sección, construyendo la misma clase paso a paso.
🎯 Domina
Getters/setters clásicos, @property, y validación de datos con raise.
El problema: sin encapsulación
Cualquier atributo creado con self.atributo = valor queda accesible y modificable desde afuera de la clase, sin ninguna restricción. Eso significa que un objeto puede terminar en un estado que no tiene ningún sentido.
class CuentaBancaria:
def __init__(self, titular, saldo):
self.titular = titular
self.saldo = saldo
cuenta = CuentaBancaria("Ana", 100)
cuenta.saldo = -500
print(cuenta.saldo)
Salida:
-500
A diferencia de otros lenguajes con una palabra clave private que el compilador hace cumplir, Python confía en quien usa la clase. Un saldo bancario negativo por una simple asignación no tiene sentido en el mundo real —pero cuenta.saldo = -500 corre sin ningún error. El resto de esta guía muestra las herramientas que Python sí ofrece para prevenir esto: convenciones, y validación de verdad con @property.
Convención de un guion bajo (_protegido)
Anteponer un guion bajo al nombre de un atributo (self._atributo) es la señal que usan los programadores de Python para decir "esto es un detalle interno de la clase, no lo toques desde afuera aunque puedas". Sigue siendo perfectamente accesible.
class CuentaBancaria:
def __init__(self, titular, saldo):
self.titular = titular
self._saldo = saldo # convención: "no tocar desde afuera"
cuenta = CuentaBancaria("Ana", 100)
print(cuenta._saldo)
cuenta._saldo = -500
print(cuenta._saldo)
Salida:
100 -500
Un solo guion bajo no cambia absolutamente nada a nivel técnico: cuenta._saldo se lee y se escribe exactamente igual que cuenta.saldo. Es pura comunicación entre programadores —muchos editores e IDEs incluso marcan en gris o con una advertencia el acceso a un atributo _protegido desde afuera de su clase, pero Python mismo no hace nada para impedirlo.
Convención de doble guion bajo (__privado) y name mangling
Anteponer dos guiones bajos (self.__atributo) activa un mecanismo real del lenguaje llamado name mangling: Python renombra el atributo por dentro a _NombreDeLaClase__atributo, así que acceder con el nombre original desde afuera ya no funciona.
class CuentaBancaria:
def __init__(self, titular, saldo):
self.titular = titular
self.__saldo = saldo
cuenta = CuentaBancaria("Ana", 100)
print(cuenta.__saldo)
Salida:
AttributeError: 'CuentaBancaria' object has no attribute '__saldo'
El atributo sigue existiendo: solo que su nombre real, guardado adentro del objeto, es _CuentaBancaria__saldo. Se puede acceder así, aunque nadie debería hacerlo en código real:
print(cuenta._CuentaBancaria__saldo)
100
La misma convención sirve para métodos: un método def __validar(self): definido dentro de la clase también sufre name mangling, y está pensado como un método auxiliar interno, pensado para ser llamado únicamente desde otros métodos de la misma clase (self.__validar()), no desde afuera.
El name mangling no existe por seguridad: existe principalmente para evitar que un atributo __algo definido en una clase choque por accidente con otro __algo de una subclase que la hereda (algo que vas a ver con más detalle en la guía de Herencia). Como mecanismo para "ocultar" datos es fácil de sortear, como recién viste.
Si una clase guarda self.__saldo y, desde afuera de la clase, escribís cuenta.__saldo, ¿qué pasa?
Getters y setters clásicos
Ya que __saldo es difícil de tocar directamente desde afuera, hace falta una forma controlada de leerlo y modificarlo. El estilo clásico —el mismo que usan lenguajes como Java— es escribir un método para leer (getter) y otro para escribir (setter), cada uno con su propio nombre.
class CuentaBancaria:
def __init__(self, titular, saldo):
self.titular = titular
self.__saldo = saldo
def obtener_saldo(self):
return self.__saldo
def establecer_saldo(self, nuevo_saldo):
if nuevo_saldo < 0:
print("Error: el saldo no puede ser negativo")
return
self.__saldo = nuevo_saldo
cuenta = CuentaBancaria("Ana", 100)
print(cuenta.obtener_saldo())
cuenta.establecer_saldo(-50)
cuenta.establecer_saldo(300)
print(cuenta.obtener_saldo())
Salida:
100 Error: el saldo no puede ser negativo 300
Este código ya protege el saldo de valores inválidos, pero pagó un precio: en vez de leer y escribir con cuenta.saldo, ahora hay que llamar métodos con paréntesis, cuenta.obtener_saldo(). Eso rompe la simetría con el resto de tus clases, donde los atributos se acceden directo. La siguiente sección resuelve exactamente esto.
La forma pythonica: @property
@property es un decorador que convierte un método en algo que se lee como si fuera un atributo, sin paréntesis. Por dentro sigue siendo un método —con toda su lógica—, pero por fuera se usa exactamente igual que un atributo normal.
class CuentaBancaria:
def __init__(self, titular, saldo):
self.titular = titular
self.__saldo = saldo
@property
def saldo(self):
return self.__saldo
cuenta = CuentaBancaria("Ana", 100)
print(cuenta.saldo)
Salida:
100
Tal como está, @property solo definió la parte de lectura. Si en este momento intentaras cuenta.saldo = 500, Python lanzaría AttributeError: property 'saldo' of 'CuentaBancaria' object has no setter —todavía falta la parte que permite escribir, con validación incluida. Eso es lo que sigue.
¿Cuál es la ventaja principal de @property frente a un getter clásico como obtener_saldo()?
Validar en el setter
Con @saldo.setter se agrega la contraparte de escritura: un método con el mismo nombre que se ejecuta automáticamente cada vez que alguien hace cuenta.saldo = valor. Ahí adentro se puede validar antes de aceptar el cambio.
class CuentaBancaria:
def __init__(self, titular, saldo):
self.titular = titular
self.saldo = saldo # ya pasa por el setter de abajo
@property
def saldo(self):
return self.__saldo
@saldo.setter
def saldo(self, nuevo_saldo):
if nuevo_saldo < 0:
raise ValueError("El saldo no puede ser negativo")
self.__saldo = nuevo_saldo
cuenta = CuentaBancaria("Ana", 100)
cuenta.saldo = 300
print(cuenta.saldo)
cuenta.saldo = -50
Salida:
300 ValueError: El saldo no puede ser negativo
Fíjate que __init__ asigna con self.saldo = saldo (el nombre de la property), no self.__saldo = saldo directo. Así, la validación del setter corre también al crear el objeto —si alguien intentara CuentaBancaria("Ana", -100), fallaría ahí mismo, en vez de crear una cuenta ya inválida desde el arranque.
El setter de arriba solo acepta valores mayores o iguales a 0. Si alguien intentara asignar cuenta.saldo = , el setter lo acepta, porque 300 no es menor que 0.
Fíjate que el setter usa raise ValueError(...), no print(...) seguido de return como en los getters/setters clásicos de más arriba. La diferencia importa: raise detiene el programa en el acto y obliga a quien llamó al código a enterarse del problema, en vez de dejar pasar un dato inválido en silencio con solo un mensaje impreso.
¿Por qué conviene usar raise ValueError(...) en vez de imprimir un mensaje y hacer return dentro de un setter, cuando el valor recibido es inválido?
Errores comunes
❌ Confundir _ con __
Un guion bajo (_atributo) es solo una convención visual: sigue siendo tan accesible como cualquier otro atributo. Dos guiones bajos (__atributo) sí activan el name mangling —más difícil de acceder por accidente, pero tampoco imposible.
❌ print() + return en vez de raise
Rechazar un valor inválido con solo un mensaje impreso deja el programa corriendo como si nada hubiera pasado. Quien llamó al setter nunca se entera de que su dato fue ignorado, a menos que revise la salida a mano.
❌ Definir @property sin su @nombre.setter
Una property sin setter es de solo lectura. Intentar objeto.atributo = valor sobre ella lanza AttributeError: property ... has no setter, incluso si el getter existe y funciona bien.
❌ Pensar que __atributo es invisible de verdad
El name mangling no borra el atributo: solo le cambia el nombre a _Clase__atributo. Alguien que conozca el truco igual puede leerlo o modificarlo desde afuera —no es un mecanismo de seguridad.
Ejercicios Propuestos
Pon en práctica lo aprendido resolviendo los siguientes ejercicios directamente en el bloque editable.
- Escribe una clase
Personacon un atributo protegido_nombre(asignado en__init__) y un métodomostrar(self)que lo imprima. Después, desde afuera, reasígnale_nombrea otro valor y volvé a llamarmostrar()para comprobar que la convención no impidió nada. - Escribe una clase
Mascotacon un atributo privado__edad, más los métodos clásicosobtener_edad(self)yestablecer_edad(self, nueva_edad)—este último debe rechazar con unprint()cualquier edad negativa. - Reescribe la clase
Mascotadel ejercicio anterior usando@propertyy@edad.setteren vez de los dos métodos, lanzandoraise ValueError(...)si la edad es negativa. - Escribe una clase
Estudiantecon una propertynota(de 0 a 100) que valide el rango completo con@nota.setter(raise ValueErrorfuera de rango), y un métodoaprobo(self)que devuelvaTruesi la nota es mayor o igual a 51. - Escribe una clase
Termometrocon un atributo privado__celsiusy una property de solo lecturafahrenheitque calculecelsius * 9 / 5 + 32—sin ningún setter: una property no siempre protege un valor guardado, a veces solo calcula uno nuevo.
Reto Final
Completa una clase CuentaBancaria más realista que la de los ejemplos, combinando todo lo visto:
__init__(self, titular, saldo_inicial)debe guardartitulary asignarsaldo_iniciala través de la property (self.saldo = saldo_inicial), para que la validación corra desde el arranque.- Una property
saldo(getter) y su@saldo.setter, que rechace conraise ValueError(...)cualquier valor negativo. - Un método
depositar(self, monto): simontono es positivo,raise ValueError(...); si lo es, súmalo al saldo asignandoself.saldo = self.saldo + monto(para que también pase por el setter). - Un método
retirar(self, monto): simontono es positivo o es mayor al saldo disponible,raise ValueError("Fondos insuficientes"); si no, réstalo del saldo de la misma forma.
Pruébala con este código:
cuenta = CuentaBancaria("Beto", 200)
cuenta.depositar(100)
print(cuenta.saldo)
cuenta.retirar(50)
print(cuenta.saldo)
cuenta.retirar(1000)
300 250 ValueError: Fondos insuficientes
La última línea del ejemplo (cuenta.retirar(1000)) debe detener el programa con ese error —no imprimir nada y seguir de largo.
Cheat Sheet
| Uso | Ejemplo |
|---|---|
| Atributo "protegido" (convención) | self._atributo = valor |
| Atributo "privado" (name mangling) | self.__atributo = valor |
| Acceder al nombre real desde afuera | objeto._Clase__atributo (no recomendado) |
| Getter clásico | def obtener_x(self): return self.__x |
| Setter clásico | def establecer_x(self, valor): ... |
| Property (getter) | @propertydef x(self): return self.__x |
| Setter de una property | @x.setterdef x(self, valor): ... |
| Rechazar un valor inválido | raise ValueError("mensaje") |
| Property de solo lectura | @property sin @x.setter |
¿Sabías que...?
🔓 "private" no existe en Python
A diferencia de Java, C++ o C#, que tienen una palabra clave private que el compilador hace cumplir de verdad, Python no tiene nada equivalente. La filosofía de la comunidad se resume en una frase famosa: "we're all consenting adults here" (acá todos somos adultos que dieron su consentimiento) —se confía en que el programador respete las convenciones.
🧩 El name mangling piensa en la herencia
Su propósito principal no es ocultar datos: es evitar que un atributo __algo de una clase choque por accidente con otro __algo del mismo nombre definido en una subclase que la hereda —un problema que vas a entender mejor en la guía de Herencia.
🗑️ property también tiene un deleter
Además de @property (leer) y @x.setter (escribir), existe @x.deleter, que se ejecuta cuando alguien hace del objeto.x. Es poco común usarlo, pero existe para completar el trío.