Saltar a contenido

Encapsulación

La Encapsulación es la propiedad que permite asegurar que la información y los métodos de un objeto están ocultos del mundo exterior.

Cuando mediante el proceso de abstracción diseñamos una clase puede darse el caso de que incluya atributos o métodos que, por seguridad o para evitar inconsistencias, solo queremos que sean accedidos internamente en el objeto, pero no de forma externa.

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.
    • Asignaré 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'

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.

Acceso externo a miembros protegidos

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

    def mostrar_estudiante(self):  # método público
        edad_info = self.__obtener_edad()  # accede a método privado
        return f"{self.__nombre} tiene {edad_info} años y pertenece a {self.__nombre_colegio}"  # accede a atributos privados
Para comprobarlo:
>>> est = Estudiante("Bill", 25)
>>> est.mostrar_estudiante()
Bill tiene 25 años 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

Lo que conseguimos dando protección a ciertos miembros de una clase es encapsular su información y comportamiento y restringir el acceso externo a determinados miembros de la clase que acceden de forma controlada.

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

Decoradores

Para poder entender el siguiente apartado debemos antes entender qué son los decoradores.

Un decorador en Python es una función que recibe otra función 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 una metáfora, imagina que tienes una taza. Si le pones una funda o decoración:

  • La taza sigue siendo la misma.
  • Pero ahora tiene algo extra: está más caliente, tiene un dibujo, etc.

Un decorador es esa funda que envuelve una función, añadiéndole algo más.

La función original no se toca, pero ahora:

  • se ejecuta con más comportamiento,
  • o con verificaciones,
  • o con mensajes extra,
  • o con controles.

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

Python ofrece una forma más elegante de controlar el acceso a los atributos protegidos o privados: usando el decorador @property convertimos el atributo protegido o privado en una propiedad que aunque accedemos al mismo como su fuera un atributo normal, realmente es un método el que ejecuta código para leerlo o modificarlo.

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:

Es un decorador que viene incluido con Python. Lo que hace es convertir el método nota() en un getter, es decir, en el código que se ejecuta cuando hacemos:

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
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 alguien hace:

ana.nota = algo

Es un setter: controla cómo se escribe el valor. En este caso solo se modifica el atributo si cumple con el filtro 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.

Esto es encapsulación: impedir que el objeto adopte valores incorrectos o incoherentes.