Herramientas para Validación de Atributos con Python

Cuando estás desarrollado una aplicación con un diseño orientado a objetos, es recomendable hacer una adecuada y consistente validación de atributos en todas tus clases y objetos.
La validación de atributos es el proceso de inspeccionar y chequear los datos que pretendes almacenar en tus atributos, para asegurarte de que son válidos antes de hacer cualquier cálculo o trabajo con ellos y así garantizar el funcionamiento confiable, seguro y estable de tus aplicaciones.
Si los datos con que trabajas son incorrectos y no te ocupas de chequearlos antes de usarlos, es muy probable que tu aplicación no funcione correctamente y que cause serios daños a tu sistema o tus datos.
Con la lectura de este artículo aprenderás cuales son las herramientas que Python pone a tu disposición para que puedas realizar una correcta validación de atributos de manera sencilla y pythónica. 
Estas herramientas son:
  • Las propiedades (property)
  • Los descriptores
  • Los métodos especiales __getattr__() y __setattr__()

Estas herramientas te darán la posibilidad de validar los atributos de tus clases y objetos a la manera de Python.


Tabla de Contenidos


Qué es la Validación de Atributos?

De forma general la validación es el proceso de chequear si un valor determinado es realmente lo que tu aplicación o programa necesita o espera que sea. Este chequeo puede consistir en verificar si el dato es del tipo de dato correcto, si contiene la cantidad de caracteres requeridos, si está dentro de un determinado intervalo de valores válidos o permisibles, si cumple determinados requisitos de precisión, entre otros.

La validación es usualmente desarrollada cuando los datos son introducidos o suministrados por primera vez.

La regla de oro a observar en la validación es que ninguna variable o atributo debe aceptar un valor incorrecto o estar/quedar en un estado incorrecto en algún momento de su vida. La integridad y fiabilidad de tus aplicaciones depende en gran medida de esto.

El concepto de validación aplicado a la Programación Orientada a Objetos se centra en la validación de atributos de clases/objetos y se resume en que al asignar un valor a un atributo determinado, la clase/objeto debe inspeccionarlo antes de procesarlo o utilizarlo. Si el valor no es válido, la clase/objeto debe ser capas de descartarlo y de emitir el correspondiente mensaje de error o solicitar un nuevo valor.

Por Qué Necesitas Validar tus Atributos

La validación de atributos es un problema común en programación orientada a objetos, sobre todo cuando se trata de procesar datos provenientes del usuario.

Una incorrecta validación de atributos puede generar serias dificultades y afectar significativamente el funcionamiento de tus aplicaciones y programas.

Imagina, por ejemplo, que estás desarrollando una clase llamada Circle que almacena un atributo .radius y que calcula el area del círculo a partir del valor de .radius.

Una primera implementación de esta clase podría ser:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import math


##################################################
# Clase Circle: versión 0.1 - Sin Validación de Atributos
class Circle:
    """Clase Circle."""

    def __init__(self, radius):
        """Inicializador."""
        self.radius = radius

    def area(self):
        """Calcula el área del círculo."""
        return round(math.pi * self.radius ** 2.0, 2)

Este es un ejemplo sencillo que puede servirte para entender mejor los problemas que puede causar una incorrecta o nula validación de atributos. Por ejemplo:

  • Si el valor asignado a .radius es correcto, obtendrás resultados correctos y serás plenamente feliz. Por ejemplo:
>>> from circle import Circle
>>> circle = Circle(10)
>>> circle.radius
10
>>> circle.area()
314.16
>>> circle.radius = 15.6
>>> circle.area()
764.54
  • Si el valor para .radius es incorrecto, obtendrás resultados incorrectos, o peor, obtendrás resultados aparentemente correctos y aquí es donde se complica y compromete el funcionamiento de tu aplicación. Por ejemplo:
>>> circle.radius = -15.0  # El radio no puede ser negativo
>>> circle.area()  # Aún así, se obtiene un valor de área
706.86

Este tipo de comportamiento errático puede acarrear serios problemas en tu código y aunque este puede parecer un ejemplo inofensivo, otras clases más complejas y críticas pueden propiciar problemas como: inyección de comandos SQL, inyección de código HTML o XML, desbordamiento del buffer, entre otros.

Volviendo al ejemplo anterior, para evitar problemas relacionados con la validación de atributos, deberás modificar un poco el diseño de tu código y asegurarte de que .radius siempre almacene un valor correcto.

Ahora bien, ¿cuándo el valor de .radius es correcto? Para responder a esta interrogante, es preciso que definas lo que podrías llamar reglas de validación, que no son más que el conjunto de requisitos que debe cumplir un valor para que pueda ser considerado un .radius válido.

La reglas de validación, en este caso, podrían ser:

  • Valor en el dominio de los números reales (float) o de los enteros (int)
  • Valor mayor que cero o positivo (> 0)
  • Valor no infinito (not float('inf'))

Con las reglas de validación definidas y bien claras, solo te resta escribir el código que las implemente.

Las reglas de validación necesarias pueden variar en función múltiples factores, como por ejemplo, el tipo de atributo y el concepto que encierra, la lógica de negocios de la aplicación, el contexto de trabajo, e incluso tus propios conocimientos sobre los anteriores y sobre lenguaje en sí mismo, entre otros.

Herramientas para la Validación de Atributos

Python ofrece varias herramientas que facilitan la validación de atributos. En este epígrafe podrás ver cómo se acostumbra a hacer la validación de atributos en otros lenguajes como Java o C++ y cómo se hace en Python.

Los Métodos Getters y Setters (Recomendados en Otros Lenguajes)

Partiendo del ejemplo que ya has visto, puedes modificar la case Circle e incluir métodos setter y getter para .radius. Esto te permitirá gestionar y validar .radius antes de utilizarlo.

El código modificado podría lucir así:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import math


##################################################
# Clase Circle: versión 0.2 - Validación de Atributos con métodos setter y getter
class Circle:
    """Clase Circle."""

    def __init__(self, radius):
        """Inicializador."""
        self.set_radius(radius)

    def get_radius(self):
        """Método getter para radius."""
        return self._radius

    def set_radius(self, radius):
        """Método setter para radius."""
        # Validación del atributo radius...
        if isinstance(radius, (float, int)):
            self._radius = radius
        else:
            raise ValueError('Float or Integer number expected')

    def area(self):
        """Calcula el área del círculo."""
        return round(math.pi * self._radius ** 2.0, 2)

Ahora tienes dos métodos nuevos, .get_radius() y .set_radius() (líneas 13 a 23) y la validación del atributo .radius se realiza en las líneas de la 20 a la 23, esto es, dentro del método setter.

Fíjate que en este caso solo quedó implementada la primera regla de validación de .radius, es decir, que el valor asignado sea float o int.

Para usar esta clase, puedes escribir código como el siguiente:

>>> from circle import Circle
>>> circle = Circle(18.5)  # .radius recibe un valor float
>>> circle.get_radius()
18.5
>>> circle.area()
1075.21
>>> circle.set_radius(10)  # .radius recibe un valor int
>>> circle.get_radius()
10
>>> circle.area()
314.16
>>> circle.set_radius('5.4')  # .radius recibe un valor str que no es válido
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/username/circle.py", line 23, in set_radius
    raise ValueError('Float or Integer number expected')
ValueError: Float or Integer number expected

Este código realmente funciona y garantiza que si el valor asignado a .radius no es del tipo correcto, se emita el error correspondiente.

Ahora bien, estos cambios realizados a Circle modifican radicalmente la interfaz pública de tu código y te obligan a pasar de circle.radius a circle.get_radius() y de circle.radius = 15.6 a circle.set_radius(15.6), lo cual puede convertirse en un verdadero dolor de cabeza si se trata una amplia base de código, donde siempre accedes a .radius directamente de la forma circle.radius.

En este caso, si implementas los cambios propuestos, estarías rompiendo toda tu base de código y para solucionar el problema tendrías que actualizar todo el código en correspondencia con la nueva interfaz.

La alternativa que has visto en este epígrafe funciona correctamente y de hecho, en lenguajes como Java o C++, es la forma correcta de programar. Sin embargo, en Python las cosas son muy diferentes y definitivamente esta no es la forma de hacer las cosas.

La Función property

Una manera simple de hacer validación de atributos en Python es a través del empleo de propiedades (property).

property() es una función integrada (una clase en realidad) que puede emplearse para retornar un atributo propiedad que te permitirá agregar procesamiento extra en el momento de asignar y acceder al atributo.

Si retomas el ejemplo anterior, definir una propiedad para .radius es cuestión de agregar una única línea de código. Por ejemplo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import math


# ##################################################
# Clase Circle: versión 0.4 - Validación de Atributos con property
class Circle:
    """Clase Circle."""

    def __init__(self, radius):
        """Inicializador."""
        self.radius = radius

    def get_radius(self):
        """Método getter para radius."""
        return self._radius

    def set_radius(self, radius):
        """Método setter para radius."""
        # Validación del atributo radius...
        if isinstance(radius, (float, int)):
            self._radius = radius
        else:
            raise ValueError('Float or Integer number expected')

    def area(self):
        """Calcula el área del círculo."""
        return round(math.pi * self._radius ** 2.0, 2)

    radius = property(get_radius, set_radius)

Fíjate que solo has añadido la línea 29 y todo está hecho. Ahora puedes acceder a .radius directamente!

Adicionalmente, podrás preguntarte por qué __init__() inicializa self.radius en lugar de self._radius (línea 11). En este ejemplo, toda la lógica está enfocada en usar una propiedad para validación de atributos. Por tanto, existe la posibilidad de que desees que esa validación se lleve a cabo desde la propia inicialización del atributo. De hecho, esto es lo que deberías hacer, pues los atributos nunca deben estar en un estado no válido.

Cuando tratas de asignar un valor a self.radius, lo que realmente ocurre es que Python llama al método setter (set_radius()) en lugar de acceder directamente a self._radius.

Para usar esta nueva clase, podrás escribir algo así:

>>> from circle import Circle
>>> circle = Circle(18.5)
>>> circle.radius
18.5
>>> circle.area()
1075.21
>>> circle.radius = 10.2
>>> circle.radius
10.2
>>> circle.area()
326.85
>>>

Con estos pequeños cambios y adiciones, tienes la posibilidad de validar tus datos, sin tener que modificar toda tu base de código, es decir, puedes continuar accediendo directamente a .radius.

Una propiedad (property) es en definitiva, una colección de métodos empaquetados juntos. Si inspeccionas detenidamente una clase que contiene un atributo propiedad, podrás encontrar los métodos fget(), fset() y fdel() (si están definidos). Por ejemplo:

>>> from circle import Circle
>>> Circle.radius.fget
<function Circle.get_radius at 0x7f70ab339d90>
>>> Circle.radius.fset
<function Circle.set_radius at 0x7f70ab339e18>

Debes emplear las propiedades solo cuando necesites realizar algún procesamiento extra de los atributos, tal como en el caso de la validación.

No es recomendable definir propiedades que realmente no realicen procesamiento de atributos, debido a que estas:

  • Hacen que tu código sea más verboso y confuso para otros
  • Provocan que tus programas sean más lentos
  • No ofrecen beneficios de diseño reales

El Decorador @property

property también brinda la posibilidad de ser empleado como un decorador. De esta forma el diseño de la clase te resultará más pythónico y simple. Por ejemplo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import math


##################################################
# Clase Circle: versión 0.3 - Validación de atributos con el decorador property
class Circle:
    """Clase Circle."""

    def __init__(self, radius):
        """Inicializador."""
        self.radius = radius

    @property
    def radius(self):
        """Propiedad (getter) para radius."""
        return self._radius

    @radius.setter
    def radius(self, radius):
        """Propiedad (setter) para radius."""
        # Validación del atributo radius...
        if isinstance(radius, (float, int)):
            self._radius = radius
        else:
            raise ValueError('Float or Integer number expected')

    def area(self):
        """Calcula el área del círculo."""
        return round(math.pi * self._radius ** 2.0, 2)

Aquí las líneas 13 y 18 hacen posible que el código funcione. Debes notar que ambos métodos (getter y setter) deben tener el mismo nombre (radius en este caso). La diferencia entre ellos es el decorador a emplear, es decir, @property para el getter y @radius.setter para el setter.

Para usar el código anterior, puedes escribir:

>>> from circle import Circle
>>> circle = Circle(18.5)
>>> circle.radius
18.5
>>> circle.area()
1075.21

Como ves, todo funciona de la misma manera que en el epígrafe anterior, la única diferencia es que ahora tu código es un poco más pythónico y conciso.

Los Descriptores

Un descriptor no es más que una clase especial que implementa los métodos especiales __set__(), __get__() y __del__(). Gracias a estas funcionalidades, los descriptores te dan la posibilidad de gestionar fácil y eficientemente todo el acceso a atributos.

Los descriptores son muy similares a las propiedades en términos de funcionalidades y roles, de hecho, las propiedades son básicamente una forma restringida de descriptor.

Para rediseñar la clase Circle empleando un descriptor, puedes escribir:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import math


##################################################
# Clase Circle: versión 0.4 - Validación de atributos con descriptor
class Circle:
    """Clase Circle."""

    def __init__(self, radius):
        """Inicializador."""
        self.radius = radius

    class Radius:
        """Descriptor para radius."""

        def __get__(self, instance, cls):
            """Método getter para radius."""
            return self._radius

        def __set__(self, instance, radius):
            """Método setter para radius."""
            # Validación del atributo radius...
            if isinstance(radius, (float, int)):
                self._radius = radius
            else:
                raise ValueError('Float or Integer number expected')

    def area(self):
        """Calcula el área del círculo."""
        return round(math.pi * self.radius ** 2.0, 2)

    radius = Radius()

La clase embebida Radius es en realidad un descriptor que, una vez codificado, te servirá para definir el atributo .radius (línea 32) como un objeto de tipo Radius. Luego, cuando asignas self.radius = radius dentro de __init__(), Python automáticamente llama al método Circle.radius.__set__() con los argumentos apropiados.

El código en este ejemplo funciona de la forma siguiente:

>>> from circle import Circle
>>> circle = Circle(18.5)
>>> circle.radius
18.5
>>> circle.area()
1075.21

Finalmente, debes notar que aunque en este ejemplo Radius es una clase embebida, esto no tiene que ser necesariamente así, es decir, eres libre implementar la clase Radius en el lugar que estimes conveniente, incluso en un módulo o paquete aparte.

Los Métodos __getattr__() y __setattr__()

El método especial __getattr__() es capas de interceptar el acceso a los atributos de una clase, aún cuando estos no han sido definidos previamente.

En la práctica, si defines una clase con un método __getattr__(), siempre que accedas a un atributo de la forma obj.attr, lo que realmente sucede es que Python llama automáticamente a __getattr__() con el nombre del atributo en forma de string. Por lo tanto, obj.attr se convierte en obj.__getattr__('attr').

Por otra parte, el método __setattr__() se ejecuta automáticamente en las operaciones de asignación.

Con el empleo de __getattr__() y de su contraparte __setattr__(), puedes implementar la validación de atributos de forma más genérica que si empleas propiedades o descriptores.

La clase Circle con estos métodos podría quedarte así:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import math


##################################################
# Clase Circle: versión 0.5 - Validación con __getattr__() y __setattr__()
class Circle:
    """Clase Circle."""

    def __init__(self, radius):
        """Inicializador."""
        self.radius = radius

    def __getattr__(self, attr):
        """Método getter para radius."""
        if attr == 'radius':
            return getattr(self, '_radius', 0)  # return  self.__dict__['_radius']

    def __setattr__(self, attr, value):
        """Método setter para radius."""
        if attr == 'radius':
            # Attribute validation here...
            if isinstance(value, (float, int)):
                self.__dict__['_radius'] = value
            else:
                raise ValueError('Float or Integer number expected')

    def area(self):
        """Calcula el área del círculo."""
        return round(math.pi * self.radius ** 2.0, 2)

De modo similar a los ejemplos de los epígrafes anteriores, en este caso la asignación de self.radius dentro de __init__() lanza al método __setattr__() en lugar de realizar la asignación directamente.

Debes notar además, que con esta variante puedes introducir bloques if...else y ejecutar acciones en dependencia del atributo que te interese procesar.

Por otro lado, en la línea 16 se retorna el valor del atributo ._radius con el empleo de la función integrada getattr(). Puedes obtener un resultado similar si empleas en el diccionario __dict__, donde se almacenan los atributos de un objeto determinado.

En la línea 23, realizas la asignación de ._radius con la ayuda de __dict__ debido a que si empleas la forma self._radius = value, entras nuevamente en __setattr__() (recursión) y como _radius no está incluido en ningún bloque if, entonces retornarán con valor None.

Finalmente, para usar este código puedes escribir algo así:

>>> from circle import Circle
>>> circle = Circle(18.5)
>>> circle.radius
18.5
>>> circle.area()
1075.21

Conclusión

Después de esta breve lectura, estás al tanto del potente kit de herramientas que Python proporciona y que puedes emplear para la validación de atributos de forma elegante, eficiente y sobre todo pythónica.

Estas herramientas son:

  • Las propiedades (property)
  • Los descriptores
  • Los métodos especiales __getattr__() y __setattr__().

Recuerda, los atributos que usas en tus clases y objetos no deben estar/quedar en un estado no válido. Es por esto que siempre deberás realizar una correcta validación de atributos antes de hacer cualquier cálculo u operación con ellos.

Con las herramientas propuestas en esta entrada, podrás llegar a una solución consistente, elegante y pythónica a tus problemas de validación de atributos en Python.

Y bien, esto es todo por ahora, si este artículo te resultó interesante y/o útil, compártelo para que otros también puedan acceder a él. Deja tus comentarios para poder mejorar nuestros contenidos.

Gracias por la lectura,

lpozo