Manejo de atributos mediante propiedades.

En Python, cuando creamos una clase, definimos una serie de atributos (que indicarán su estado) y de métodos (que marcarán su comportamiento). El acceso a ambos se realiza de manera sencilla mediante el operador punto. Un ejemplo de ello (como nota decir que todo el código mostrado en esta entrada está escrito en Python 3 sobre el IDE PyScripter)  es el siguiente:

class MiClase:
    def __init__(self, midato):
        self.dato = midato
    def cuadrado(self):
        return self.dato ** 2
a = MiClase(12.72)
print("El cuadrado es:", a.cuadrado())
a.dato = 7.72
print("El cuadrado es:", a.cuadrado())

La salida sería:

>>> 
El cuadrado es: 161.79840000000002
El cuadrado es: 59.5984
>>>

Para ejemplos sencillos podría ser suficiente actuar de esta manera, pero en otros momentos necesitaremos más versatilidad. Imaginemos que queremos validar los atributos con una determinada condición. En nuestro caso podría ser que los valores de dato estuviesen entre 0 y 10. A los valores menores que 0 se les asignaría el 0 y a los mayores de 10, el 10. Todo ello se lograría fácilmente creando un método que manejase esa condición y ejecutándolo también en __init__():

class MiClase:
   def __init__(self, midato):
       self.set_dato(midato)
   def set_dato(self, midato):
       if isinstance(midato, (int, float)):
           if midato < 0:
               self.dato = 0
           elif midato > 10:
               self.dato = 10
           else:
               self.dato = midato
       else:
           raise ValueError("Dato no válido")
   def cuadrado(self):
       return self.dato ** 2
a = MiClase(12.72)
print("El cuadrado es:", a.cuadrado())
a.set_dato(7.72)
print("El cuadrado es:", a.cuadrado())

Con salida:

>>> 
El cuadrado es: 100
El cuadrado es: 59.5984
>>>

En el método set_dato() hacemos la comprobación que el dato es entero o real. De lo contrario lanzamos una excepción. Con ello nos aseguraríamos que el dato cumple las condiciones que queremos.

De la misma forma que hemos creado un método para manejar la escritura del atributo dato podríamos hacerlo para su lectura. Son los denominados, de forma genérica, métodos getter y setter, que nos permiten, respectivamente, obtener o modificar los atributos. El concepto de encapsulación de datos incluye, además del uso de estos métodos, el que los atributos sean privados (no accesibles desde fuera de la propia clase), algo que en Python se logra añadiendo un doble guión bajo al nombre del atributo. Tendríamos lo siguiente:

class MiClase:
    def __init__(self, midato):
        self.set_dato(midato)
    def get_dato(self):
        return self.__dato
    def set_dato(self, midato):
        if isinstance(midato, (int, float)):
            if midato < 0:
                self.__dato = 0
            elif midato > 10:
                self.__dato = 10
            else:
                self.__dato = midato
        else:
            raise ValueError("Dato no válido")
        return None
    def cuadrado(self):
        return self.__dato ** 2
a = MiClase(12.72)
print("El cuadrado es:", a.cuadrado())
a.set_dato(7.72)
print("El cuadrado de", a.get_dato(), "es", a.cuadrado())

La salida es:

>>> 
El cuadrado es: 100
El cuadrado de 7.72 es 59.5984
>>>

En Python es habitual que los atributos que en un principio son simples datos estáticos con el tiempo se conviertan en expresiones a calcular dinámicamente o, como ya hemos visto, haya que validarlos de alguna manera. Si actuamos con métodos getter y setter desde el principio podremos hacer esa nueva implementación sin cambiar la forma en la que accedemos a los datos, pero si lo hemos hecho simplemente mediante el operador punto, estaríamos en una situación delicada. Es para superar este escollo, o conseguir una forma mas pythónica que los métodos getter y setter, para lo que se usan las propiedades.

¿Qué son exactamente las propiedades?

Las propiedades son atributos que manejamos mediante métodos getter, setter y deleter, por lo que podríamos llamarlos "atributos manejados". Podemos considerarlos unos atributos "especiales". En realidad en Python los datos, métodos y propiedades de una clase son todos atributos. Los métodos serían atributos "llamables" y las propiedades atributos "personalizables". La única diferencia entre una propiedad y un atributo estándar es que las primeras pueden invocar código personalizado al ser obtenidas, modificadas o eliminadas. Las propiedades se asocian a los atributos de la clase y, como cualquiera de ellos, son heredados en subclases e instancias. Una propiedad maneja un solo y específico atributo, y siempre harán referencia a los métodos de la clase en la que estén definidas.
Por lo tanto, las propiedades parecen atributos estándar, pero al acceder a ellos se lanzan los métodos getter, setter o deleter correspondientes. Como éstos métodos reciben el argumento self, podremos acceder a todos los elementos de la clase o la instancia (sus atributos y métodos) y hacer uso de ellos.

¿Cuándo usar entonces las propiedades?

Respecto al uso desde el principio de simples métodos getter , setter y deleter, las propiedades nos aportan un código mas pythónico, siendo mas fácil la lectura y escritura de los atributos. La interfaz es mas homogénea ya que a todo se accede mediante el operador punto.

Respecto al uso de atributos estándar, las propiedades nos dan la posibilidad de incorporar código que se ejecute de forma dinámica cuando intentamos acceder a ellos (para obtenerlos, modificarlos o borrarlos). Imaginemos el caso comentado con anterioridad: tenemos hecho un programa donde accedemos a atributos estándar públicos. Posteriormente necesitamos que esos atributos estándar sean validados con unas determinadas condiciones. Mediante las propiedades lograremos nuestro objetivo sin modificar el formato del código ya escrito, algo que no lograríamos con los métodos.

¿Cómo crear las propiedades?

La función property(), integrada en el intérprete, nos permite canalizar la lectura o escritura de los atributos (además de interceptar el momento en el que son borrados o proporcionar documentación sobre ellos) mediante funciones o métodos. Su formato es el siguiente:

atributo_de_clase = property(fget=None, fset=None,fdel=None, doc)

Ninguno de los parámetros es obligatorio. Si no los pasamos su valor por defecto es None. La función fget() se encargará de interceptar la lectura del atributo, la fset() de hacerlo cuando se escriba, la fdel() a la hora de borrarlo y el argumento doc recibirá una cadena para documentar el atributo (si no lo recibe, se copia el docstring de fget(),que por defecto tiene valor None). Si alguna operación no está permitida(por ejemplo si intentamos borrar un atributo, algo no demasiado habitual, y no tenemos indicada fdel()) se lanzará una excepción. La función fget() devolverá el valor procesado del atributo y tanto fset() como fdel() devolverán None. La función property() devuelve un objeto de tipo propiedad que se asigna al nombre del atributo. Un primer ejemplo del uso de las propiedades podría ser el siguiente:

class Persona:
    def __init__(self, nombre):
        self.set_nombre(nombre)
    def get_nombre(self):
        try:
            print("Pedimos atributo:")
            return self.__nombre
        except AttributeError:
            print("Error. No existe el atributo indicado")
        except:
            print("Error al acceder al atributo")
    def set_nombre(self, nuevo_nombre):
        print("Asignamos el valor", nuevo_nombre,"al atributo 'nombre'")
        self.__nombre = nuevo_nombre
        return None
    def del_nombre(self):
        try:
            print("Borramos atributo", self.__nombre)
            del self.__nombre
        except AttributeError:
            print("Error. No existe el atributo que desea borrar")
        except:
            print("Error al intentar borrar el atributo")
        return None
    nombre = property(get_nombre, set_nombre, del_nombre, "Mi información")
def main():
    a = Persona("Pepe")
    a.nombre = "Juan"
    print(a.nombre)
    del a.nombre
    del a.nombre
    print(a.nombre)
    a.nombre = "Elena"
    print(a.nombre)
    print(help(Persona.nombre))
main()

Obtenemos la siguiente salida:

>>> 
Asignamos el valor Pepe al atributo 'nombre'
Asignamos el valor Juan al atributo 'nombre'
Pedimos atributo:
Juan
Borramos atributo Juan
Error. No existe el atributo que desea borrar
Pedimos atributo:
Error. No existe el atributo indicado
None
Asignamos el valor Elena al atributo 'nombre'
Pedimos atributo:
Elena
Help on property:
    Mi información
None
>>>

En él se ha creado una clase Persona con un atributo privado nombre y tres métodos de tipo getter, setter y deleter. Posteriormente se crea, mediante la función property(), la propiedad nombre vinculada a ellos, que reemplaza al atributo del mismo nombre. Se añade además una información de ayuda. Más adelante creamos una instancia de Persona a la que posteriormente cambiamos el atributo nombre, tras lo cual lo borramos. Al intentar  borrarlo de nuevo, generamos un error que manejamos. También se lanza un error manejado al intentar leer el atributo borrado. Es interesante ejecutar el código paso a paso para ver su funcionamiento exacto. En este ejemplo, por simplicidad, no hemos manejado posibles excepciones en el método setter, pero sería conveniente hacerlo en un código completo.

Si no quisiésemos contravenir uno de los principios del Zen de Python ("Debería haber una, y preferiblemente solo una, forma obvia de hacer las cosas") los métodos get_nombre(), set_nombre() y del_nombre() deberían ser privados (algo que nuevamente logramos colocando un doble guión bajo antes de su nombre).

Pero tampoco es ésta la forma mas pythónica para tratar con las propiedades, algo que se consigue mediante el uso de decoradores. Recordemos que un decorador es básicamente una función que "envuelve" a otra dotándola (al añadir código) de alguna funcionalidad extra. El formato es el siguiente:

def mifunción(argumentos):
    ...
mifunción = decorador(mifunción)

También podríamos ponerlo así:

@decorador
def mifunción(argumentos)
    ...

Podemos ahora usar la función property() como decorador para que se ejecute cuando queramos acceder a uno de los atributos.

class Miclase:
    def mi_atributo(self):
        ...
    mi_atributo = property(mi_atributo)

O también colocarlo de la siguiente manera:

class Miclase:
     @property
     def mi_atributo(self):
         ...

De esta manera lograríamos pasar mi_atributo como el primer argumento de la función property(), que es el que se usa cuando intentamos leer. El objeto propiedad tiene métodos getter, setter y deleter que asignan los métodos de acceso de la propiedad y que devuelven una copia de la propia propiedad. Los podemos, a su vez, usar para decorar métodos con el mismo nombre mi_atributo que usaremos en el momento en que intentamos modificar o borrar el atributo. Es un poco lioso, por lo que el código nos puede aclarar un poco las cosas:

class Persona:
    def __init__(self, nombre):
        self.nombre = nombre
    @property
    def nombre(self):
        "Documentación del atributo 'nombre' "
        try:
            print("Pedimos atributo:")
            return self.__nombre
        except AttributeError:
            print("Error. No existe el atributo indicado")
        except:
            print("Error al leer el atributo")
    @nombre.setter
    def nombre(self, nuevo_nombre):
        print("Asignamos el valor",nuevo_nombre," al atributo 'nombre'")
        self.__nombre = nuevo_nombre
        return None
    @nombre.deleter
    def nombre(self):
        try:
            print("Borramos atributo", self.__nombre)
            del self.__nombre
        except AttributeError:
             print("Error. No existe el atributo que desea borrar")
        except:
             print("Error al borrar atributo")
        return None
def main():
    a = Persona("Pepe")
    a.nombre = "Juan"
    print(a.nombre)
    del a.nombre
    del a.nombre
    print(a.nombre)
    a.nombre = "Jaime"
    print(a.nombre)
    print(help(Persona.nombre))
main()

Salida:

>>> 
Asignamos el valor Pepe al atributo 'nombre'
Valor del atributo 'nombre':
Pepe
Asignamos el valor Juan al atributo 'nombre'
Valor del atributo 'nombre':
Juan
Borramos atributo Juan
Error. No existe el atributo que desea borrar
Valor del atributo 'nombre':
Error. No existe el atributo indicado
None
Asignamos el valor Jaime al atributo 'nombre'
Valor del atributo 'nombre':
Jaime
Help on property:
    Documentación del atributo 'nombre'
None
>>>

En el código definimos en  __init__() el atributo nombre como público. Eso nos permitirá que se ejecute, al crear la instancia de Persona, el método setter asociado a su propiedad y por tanto añadir el código que creamos conveniente. Nuevamente, por simplicidad, no se ha añadido manejo de excepciones ni condiciones de validación en él. Mediante la función property() decoramos el método getter que tiene el mismo nombre que nuestro atributo, es decir, nombre. Una vez hecho ésto, podemos usar los métodos setter y deleter del objeto propiedad para decorar los métodos correspondientes, de nombre nombre. Esta si es la forma mas pythónica de tratar las propiedades.

Los métodos getter de las propiedades pueden ser muy útiles si necesitamos calcular atributos sobre la marcha teniendo como base otros atributos. O también si queremos almacenar el resultado de un cálculo complejo para que sucesivas peticiones de ese cálculo no lo realicen de nuevo, sino que recuperen el resultado previo. Un ejemplo heterodoxo del uso de todo ello  es el siguiente: imaginemos que queremos crear una clase que almacene uno o una serie de valores. Podemos tener  o un número(entero o real) o una lista de números (reales y/o enteros). Como condición de validación tendremos que el número o números esté/n entre 0 y 10 (inclusive). De no ser así asignaremos al número el valor 0. Hay en la clase una propiedad llamada que simula (mediante el uso del módulo time) una operación pesada computacionalmente aplicada a los datos. Simulamos que nos cuesta tres segundos calcularla y el resultado es 123.79382.  Lo almacenamos en transformada  por si hay una posterior petición de su cálculo. Otra propiedad llamada media calcula el promedio de los datos ya filtrados mediante la validación. Se comprueba que los datos de entrada son correctos y si no se lanza una excepción de tipo ValueError.

El código y su correspondiente salida serían algo así, aconsejando una ejecución paso a paso para observar su funcionamiento detalladamente:

import time
class MiClase:
   def __init__(self, valor=None):
       self.valor = valor
       self.__transformada = None
   @property
   def valor(self):
       return self.__valor
   @valor.setter
   def valor(self, mivalor):
       if isinstance(mivalor, (int, float)):
           if 0 <= mivalor <=10:
               self.__valor = mivalor
           else:
               self.__valor = 0
       elif isinstance(mivalor, list):
           res = []
           for midato in mivalor:
               if isinstance(midato, (int, float)):
                   if 0 <= midato <=10:
                       res.append(midato)
                   else:
                       res.append(0)
               else:
                   raise ValueError ("El dato introducido no es válido")
           self.__valor = res
       else:
           raise ValueError("El dato introducido no es válido")
       return None
    @property
    def transformada(self):
        if not self.__transformada:
            print("Calculando transformada...")
            time.sleep(3)
            self.__transformada = 123.79382
        else:
            print("Transformada en caché. Valor:")
        return self.__transformada
    @property
    def media(self):
        if isinstance(self.valor, list):
            return sum(self.__valor) / len(self.__valor)
        else:
            return "\nNo se puede calcular la media al no ser una lista"
def main():
    datos = [12.21, 8.68, -2, 7.77]
    print("Los datos introducidos son: ", datos)
    a = MiClase(datos)
    print("Los datos filtrados son: ", a.valor)
    print("La media de los datos filtrados es:", a.media)
    print(a.transformada)
    print(a.transformada)
main()
>>> 
Los datos introducidos son: [12.21, 8.68, -2, 7.77]
Los datos filtrados son: [0, 8.68, 0, 7.77]
La media de los datos filtrados es: 4.1125
Calculando transformada...
123.79382
Transformada en caché. Valor:
123.79382
>>> 

8 thoughts on “Manejo de atributos mediante propiedades.

  1. Un artículo bastante compresible. Una pequeñísima corrección: las propiedades, al igual que los setters/getters, tienen sentido si son la única forma de acceder a los valores de los atributos que protegen. Al calcular la media, debes emplear la propiedad self.valor y no el atributo self.__valor .

  2. Muchas gracias por esta entrada. Precisamente estaba estudiando este tema de la POO. Pero siempre soy reacio a usar los metodos privados (aunque no lo sean del todo en Python). A veces, creo, que hacen la clase que creamos con excesivo codigo. Y si no son extrictamente necesarios, mejor crear los atributos y metodos publicos. De todas formas me leere con mucho gusto toda la explicacion que has expuesto. Tampoco podemos olvidar el name mangling. Ya que por este metodo se accede con facilidad a los valores que hemos encapsulado. Supongo, que el que programa por mero hobby o entretenimiento, pocas veces usara la ocultacion de datos.

    Por cierto, muy buen blog sobre Python. Alguna entrada sobre estructuras de datos tampoco estaria mal. Sobre arboles y grafos. Saludos!

    1. Muchas gracias por tu comentario Juan. Hace que tenga la sensación de que la entrada es útil para alguien, y eso es muy motivante.
      El uso de atributos privados es mas para evitar que un uso erróneo (no intencionado) pueda sobrescribirlos que para protegerlos completamente de un acceso externo,ya que en Python no es posible crear atributos privados en sentido estricto, cosa que por ejemplo sí es posible en Java. Por ello, aunque tengamos un atributo privado podemos acceder a él desde fuera de la clase mediante “name mangling”, como bien comentas. Muchos programadores de Python no están cómodos con ello y simplemente “marcan” como convención con un solo guión bajo los atributos que desean que sean privados (ya que no tiene un significado especial para el intérprete cuando lo usamos en nombres de atributos), convención que debe ser posteriormente respetada, claro está.

    2. Sobre Pybonacci decir que yo solo soy un autor invitado. Como lector llevo disfrutando muchos años de él y me parece una referencia extraordinaria dento del mundo Python en castellano, al margen de la gran labor divulgativa que han hecho en todos estos años. Así que te animo a leer los artículos ya que son todos de una enorme calidad.
      ¡Saludos!

Leave a Reply

Your email address will not be published. Required fields are marked *