Saltar a contenido

Encapsulación

La Encapsulación es el principio de la POO que consiste en:

  • Agrupar datos (atributos) y comportamiento (métodos) dentro de una clase.
  • Controlar el acceso a esos datos para proteger el estado interno del objeto.
  • Exponer una interfaz (métodos) para usar el objeto , es decir, un conjunto de métodos y/o propiedades bien definidos que permiten usar el objeto desde fuera sin conocer ni modificar su funcionamiento interno.

Tipos de acceso

En la POO se distinguen tres niveles de acceso:

  • Público
    • Los datos y los métodos de una clase son accesibles desde cualquier parte del programa.
    • Es el nivel más bajo de protección.
    • Asignaremos esta protección a la parte de mis objetos que puede ser accedida de forma externa.
  • Protegido
    • Los datos y métodos con este nivel de protección no son accesibles externamente.
    • Solo pueden ser accedidos desde la propia clase o desde subclases (más adelante veremos las subclases)
  • Privado
    • Es el nivel más alto de protección
    • Los datos y métodos con este nivel de protección solo son accesibles desde la propia clase.

En muchos lenguajes de programación se usan modificadores (palabras reservadas) que se ponen al principio de la definición del atributo o método para asignarle un nivel de protección. Por ejemplo, en Java se usan los modificadores public, protected y private

class EstudianteUniversitario {
    private int id;
    public String nombre;
    ...
}
En Python se siguen otros principios para establecer el nivel de protección de los miembros de una clase

Cuando hablamos de miembros de una clase estamos hablando de los atributos y métodos que incluye

Miembros públicos en Python

En Python los atributos y métodos son públicos por defecto. Cualquier miembro puede ser accedido desde el exterior con la notación punteada como hemos visto hasta ahora.

1
2
3
4
5
6
7
8
9
class Estudiante:
    nombre_colegio = 'Colegio XYZ'  # atributo de clase público

    def __init__(self, nombre, edad):
        self.nombre = nombre  # atributo de instancia público
        self.edad = edad      # atributo de instancia público

    def mostrar_nombre(self): # método de instancia público
        return self.nombre

Podemos acceder a los atributos y métodos de la clase e incluso modificar su valor:

>>> sebastian = Estudiante("Sebastián", 15)
>>> print(sebastian.edad)
15
>>> sebastian.nombre = "Antonio"
>>> print(sebastian.mostrar_nombre())
'Antonio'
>>> print(sebastian.nombre_colegio)
'Colegio XYZ'

Miembros privados

Como hemos visto es el nivel más alto de protección y los métodos y atributos así definidos solo pueden ser accedidos internamente.

En Python no existe un modificador que nos permita definir con dicha protección un miembro, para conseguirlo se sigue el convenio de que el nombre del mismo debe tener como prefijo un doble guión bajo (__)

1
2
3
4
5
6
7
8
9
class Estudiante:
    __nombre_colegio = 'Colegio XYZ'  # atributo de clase privado

    def __init__(self, nombre, edad):
        self.__nombre = nombre  # atributo de instancia privado
        self.__edad = edad      # atributo de instancia privado

    def __obtener_edad(self):  # método privado
        return self.__edad

Para comprobarlo:

>>> est = Estudiante("Bill", 25)
>>> est.__nombre_colegio
AttributeError: 'Estudiante' object has no attribute '__nombre_colegio'
>>> est.__nombre
AttributeError: 'Estudiante' object has no attribute '__nombre'
>>> est.__obtener_edad()
AttributeError: 'Estudiante' object has no attribute '__obtener_edad'
Vemos que obtenemos error al intentar acceder a dichos atributos y métodos desde fuera del objeto.

Cuando Python ve que un miembro empieza con __ lo que hace es enmascararlos utilizando un mecanismo que se llama name mangling. Lo que hace es renombrarlos internamente añadiendo al nombre el prefijo _NombreClase.

Para la clase anterior:

>>> est = Estudiante("Bill", 25)
>>> est.__nombre
AttributeError: 'Estudiante' object has no attribute '__nombre'

# Con el prefijo _Estudiante sí podemos acceder a los miembros 

>>> est._Estudiante__nombre  
'Bill'
>>> est._Estudiante__nombre = 'Steve'
>>> est._Estudiante__nombre
'Steve'
>>> est._Estudiante__obtener_edad()
25

Fíjate que al atributo __nombre y al método __obtener_edad() se les ha añadido el prefijo _Estudiante y son accesibles externamente mediante _Estudiante__nombre y _Estudiante__obtener_edad().

El objetivo no es impedir el acceso, sino que el programador conozca el comportamiento y no intente acceder a miembros si su definición especifica que no se debería acceder.

Miembros protegidos

Cómo vimos, son los atributos que queremos que sean accedidos solo internamente o desde las clases heredadas (subclases)

En breve veremos la herencia en POO

Para definir un miembro como protegido seguimos el convenio de poner en su nombre como prefijo el carácter subrayado (_)

1
2
3
4
5
6
7
8
9
class Estudiante:
    _nombre_colegio = 'Colegio XYZ'  # atributo de clase protegido

    def __init__(self, nombre, edad):
        self._nombre = nombre  # atributo de instancia protegido
        self._edad = edad      # atributo de instancia protegido

    def _obtener_edad(self):  # método protegido
        return self._edad

En este caso no se realiza ninguna traducción interna por parte de Python, por tanto aunque formalmente el miembro es protegido, realmente si es accesible externamente.

>>> est = Estudiante("Swati")
>>> est._nombre
'Swati'
>>> est._nombre = 'Dipa'
>>> est._nombre
'Dipa'

Los creadores de Python siguieron este esquema por conveniencia y siguieron la regla de que debe ser el programador el que siga dicho convenio y no intente acceder desde donde no debe a los miembros así definidos si quiere que si aplicación sea coherente en su comportamiento.

Extraido de comentario en stackoverflow.com: "Python does not support access protection as C++/Java/C# does. Everything is public. The motto is, "We're all adults here." Document your classes, and insist that your collaborators read and follow the documentation.

The culture in Python is that names starting with underscores mean, "don't use these unless you really know you should." You might choose to begin your "protected" methods with underscores. But keep in mind, this is just a convention, it doesn't change how the method can be accessed.

Names beginning with double underscores (__name) are mangled, so that inheritance hierarchies can be built without fear of name collisions. Some people use these for "private" methods, but again, it doesn't change how the method can be accessed."

Ventajas

La encapsulación es importante porque permite diseñar clases seguras, fáciles de usar y de mantener. Sus principales ventajas son:

  • Protección de los datos: evita que los atributos se modifiquen directamente desde fuera de la clase, lo que podría dejar al objeto en un estado incorrecto. Ejemplo: en una clase CuentaBancaria, si el saldo fuera público, podría hacerse cuenta.saldo = -100. Al ser protegido/privado, solo puede modificarse mediante métodos controlados.
  • Prevención de estados inválidos: al obligar a usar métodos o propiedades para modificar el estado del objeto, se pueden añadir validaciones (por ejemplo, impedir valores negativos o fuera de rango) que permiten asegurarnos que los datos no tomen valores no deseados. Ejemplo: una propiedad edad podemos antes de asignarle un nuevo valor comprobar que este no es negativos o mayor de 120, con lo que nos aseguramos que el objeto siempre tenga datos coherentes.
  • Facilidad de mantenimiento: si la lógica interna cambia, el código que usa la clase no se ve afectado mientras la interfaz se mantenga igual. Ejemplo: si una cuenta pasa de guardar el saldo en euros a guardarlo en céntimos, el usuario seguirá usando depositar() y retirar() de la misma forma. Los cambios los haremos en la lógica interna de la clase.
  • Código más claro y ordenado: se separa qué partes son internas de la clase y cuáles forman parte de su interfaz pública.Ejemplo: métodos como calcular_total() forman parte de la interfaz, mientras que atributos como _descuento quedan ocultos al exterior.

En resumen, encapsular ayuda a escribir programas más robustos, seguros y fáciles de modificar.

Uso de miembros protegidos vs privados

En caso de que queramos restringir el acceso externo a los miembros (atributos y métodos) de un objeto se recomienda de forma predeterminada utilizar miembros protegidos y reservar los miembros privados solo para cuando el atributo o el método: - No queremos que sea modificado ni siquiera de forma accidental - No debe ser accedido por subclases.

Acceso externo

A la hora de acceder a los miembros protegidos o privados de un objeto hay dos enfoques o maneras de hacerlo

Acceso a miembros privados o protegidos usando métodos

Cuando diseñamos nuestros objetos definimos como privados los miembros que no queremos que sean accedidos directamente desde fuera del objeto.

Si queremos que externamente se pueda acceder a determinada información lo que hacemos es crear metodos públicos que accedan a los miembros privados de la clase.

Si por ejemplo queremos crear un método que permita externamente mostrar la información de un estudiante:

class Estudiante:
    _nombre_colegio = 'Colegio XYZ'  # atributo de clase protegido

    def __init__(self, nombre, edad, informe_medico):
        self._nombre = nombre  # atributo de instancia protegido
        self._edad = edad      # atributo de instancia protegido
        self.__informe_medico = informe_medico # atributo privado

    def _obtener_edad(self):  # método protegido
        return self._edad

    def __obtener_informe_medico(self)   # método privado
        return self.__informe_medico

    def mostrar_estudiante(self):  # método público
        edad_info = self._obtener_edad()            # accede a método protegido
        informe = self.__obtener_informe_medico()   # accede a método privado
        return f"{self._nombre} tiene {edad_info} años. Sus datos médicos son: {informe} y pertenece a {self._nombre_colegio}"  # accede a atributos protegidos
Para comprobarlo:
>>> est = Estudiante("Bill", 25, "Alergia ácaros")
>>> est.mostrar_estudiante()
Bill tiene 25 años. Sus datos médicos son: Alergia ácaros y pertenece a Colegio XYZ

Fíjate que para que un método pueda acceder a los atributos y métodos de la clase debe utilizar la notación punteada poniendo en primer lugar self para indicar que los atributos y métodos a los que queremos acceder son del propio objeto

Acceso con propiedades @property

Antes de explicar las propiedades, es importante entender qué es un decorador, ya que @property es uno de ellos.


¿Qué es un decorador?

Un decorador es una forma especial de escribir código en Python que permite modificar el comportamiento de una función o método sin cambiar su nombre ni la forma en que se usa.

Se escribe con el símbolo @ justo encima de la definición de la función o método al que afecta.

Desde un punto de vista teórico (sin entrar aún en detalles técnicos), puede entenderse así:

  • Un decorador añade funcionalidad extra a una función o método.
  • El código que usa esa función no nota el cambio.
  • Sirve para mantener el código más claro y organizado.

*una función o método que recibe otra función o método y devuelve una función modificada.

La idea es que permite añadir funcionalidades nuevas a una función sin cambiar su código por dentro.

Veámoslo con un ejemplo:

def decorador(funcion):
    def nueva_funcion():
        print("Antes de llamar a la función")
        funcion()
        print("Después de llamar a la función")
    return nueva_funcion

@decorador
def saludar():
    print("Hola!")

saludar()

Lo que ocurre es que @decorador hace que saludar() pase por dentro del decorador. Por lo que el resultado del código anterior sería:

Antes de llamar a la función
Hola!
Después de llamar a la función

Los decoradores sirven para:

  • añadir comportamiento sin tocar la función original
  • reutilizar lógica común
  • “envolver” funciones para controlar cosas como:
  • permisos
  • validaciones
  • tiempos de ejecución
  • logs
  • escribir código más limpio y ordenado

Propiedades en objetos con @property y @setter

Se les llama propiedades porque, desde el punto de vista del código que usa la clase, se comportan como atributos, no como métodos.

Aunque internamente las propiedades están implementadas mediante métodos, para el usuario del objeto son una propiedad del mismo (por ejemplo, precio, edad, saldo), no una acción.

Esta idea es importante a nivel teórico:

  • Un atributo representa una característica o cualidad del objeto.
  • Un método representa una acción.
  • Una propiedad representa una característica, pero con lógica y control internos.

Por eso, cuando un dato necesita validación o cálculo, pero conceptualmente sigue siendo una característica del objeto, se modela como propiedad y no como método.

Permiten implementar lo que en otros lenguajes de programación se conoce como getters y setters.

Para entenderlo mejor veamos un ejemplo:

class Alumno:
    def __init__(self, nombre, nota):
        self.nombre = nombre
        self._nota = nota

    @property
    def nota(self):
        # Getter: devuelve el valor de __nota
        return self._nota

    @nota.setter
    def nota(self, valor):
        # Setter: controla qué valores son válidos y la almacena o muestra error
        if 0 <= valor <= 10:
            self._nota = valor
        else:
            print("Error: la nota debe estar entre 0 y 10")

ana = Alumno("Ana", 8)
print(ana.nota)      # Se llama al getter → 8
ana.nota = 9         # Se llama al setter → cambia el valor
print(ana.nota)      # 9
ana.nota = 15        # Error: la nota debe estar entre 0 y 10

getter con @property:

En el ejemplo anterior el decorador @property hace de getter del atributo. Lo que conseguimos es convertir el método nota() en un getter, es decir, en el código que se ejecuta cuando se lee un atributo:

print(ana.nota)

Parece que estamos leyendo un atributo, pero en realidad se está llamando al método nota(). Estamos accediendo a un atributo protegido, pero de forma segura a través de un getter.

setter con @atributo.setter

@nota.setter linenums="1" hl_lines="1 4"
def nota(self, valor):
    if 0 <= valor <= 10:
        self._nota = valor
    else:
        print("Error: la nota debe estar entre 0 y 10")

Este decorador indica que este método debe ejecutarse cuando queremos modificar un atributo:

ana.nota = algo

Es un setter: controla cómo se escribe el valor. En este caso solo se modifica el atributo si cumple con la validación de que el el valor está entre 0 y 10. Si no → se muestra un error y no se cambia la nota.

Modificando la nota correctamente

ana.nota = 9

NO modifica directamente el atributo. Llama al setter nota(self, valor). Como 9 es válido, _nota cambia a 9.

Intento con un valor inválido

ana.nota = 15

Actúa el setter. No cumple 0 <= valor <= 10.

Muestra:

Error: la nota debe estar entre 0 y 10

Y la nota no cambia.

Aunque en los ejemplos estamos usando print para modificar los mensajes de error de validación en la propia clase ya veremos más adelante que esta no es la forma más óptima de hacerlo.

Ejemplo final

A continuación, un ejemplo completo en una clase Coche que demuestra todos los conceptos de encapsulación.

class Coche:
    def __init__(self, marca, modelo, precio):
        self.marca = marca      # Atributo público
        self._modelo = modelo   # Atributo protegido
        self.__precio = precio  # Atributo privado

    # Método público
    def describir(self):
        return f"{self.marca} {self._modelo} cuesta {self.__precio} euros."

    # Método protegido
    def _calcular_impuesto(self):
        return self.__precio * 0.21  # Accede al atributo privado

    # Método privado
    def __actualizar_precio(self, nuevo_precio):
        if nuevo_precio > 0:
            self.__precio = nuevo_precio
        else:
            raise ValueError("El precio debe ser positivo.")

    # Getter para atributo privado usando @property
    @property
    def precio(self):
        return self.__precio

    # Setter para atributo privado usando @property
    @precio.setter
    def precio(self, nuevo_precio):
        self.__actualizar_precio(nuevo_precio)  # Llama al método privado para validar

    # Método público
    def precio_total(self):
        return self.__precio + self._calcular_impuesto()  

# Ejemplo de uso
mi_coche = Coche("Toyota", "Corolla", 20000)

# Acceso a atributo público (permitido)
print(mi_coche.marca)  # Salida: Toyota

# Acceso a atributo protegido (posible, pero no recomendado por convención)
print(mi_coche._modelo)  # Salida: Corolla

# Intento de acceso a atributo privado (fallará directamente)
# print(mi_coche.__precio)  # Error: AttributeError

# Acceso indirecto al privado usando getter
print(mi_coche.precio)  # Salida: 20000

# Modificación usando setter (valida internamente)
mi_coche.precio = 22000
print(mi_coche.precio)  # Salida: 22000

# Modificación inválida (el setter llama al método privado y lanza excepción)
try:
    mi_coche.precio = -1000
except ValueError as e:
    print(e)  # Salida: El precio debe ser positivo.
print(mi_coche.precio)  # Sigue siendo 22000 (no se modificó)

# Llamada a método público
print(mi_coche.describir())  # Salida: Toyota Corolla cuesta 22000 euros.

# Llamada a método protegido (posible, pero no recomendado)
print(mi_coche._calcular_impuesto())  # Salida: 4620.0

# Intento de llamada a método privado (fallará directamente)
# mi_coche.__actualizar_precio(25000)  # Error: AttributeError

# Acceso "forzado" al privado (posible pero no recomendado, rompe encapsulación)
print(mi_coche._Coche__precio)  # Salida: 22000 (usando name mangling)

# Uso del método precio_total
print(mi_coche.precio_total())  # Salida: 26620.0 (22000 + 4620.0)
  • Atributos públicos (marca): Se accede y modifica libremente. Útil para datos que no necesitan control.
  • Atributos protegidos (_modelo): Pensados para uso interno o en subclases. Python permite acceso, pero la convención dice "no lo hagas" para mantener la encapsulación.
  • Atributos privados (__precio): Ocultos mediante name mangling (Python cambia el nombre internamente a _Clase__atributo). Esto previene accesos accidentales, promoviendo el uso de getters/setters.
  • Métodos públicos (describir(), precio_total()): Exponen funcionalidad segura.
  • Métodos protegidos (_calcular_impuesto): Para lógica interna, accesibles pero no recomendados desde fuera.
  • Métodos privados (__actualizar_precio): Solo para uso dentro de la clase.
  • @property y setter: Proporcionan encapsulación "pythonica". Permiten tratar atributos como públicos pero con validaciones internas, ocultando la implementación.

Aunque en el ejemplo anterior se han usado atributos y métodos privados para ilustrar su funcionamiento, en un caso real es recomendable usar solo miembros protegidos y reservar los privados para los casos especiales que se indicaron anteriormente.