Curso
Los descriptores de Python, o descriptores en general, te ofrecen una técnica potente para escribir código reutilizable que se pueda compartir entre clases. Pueden parecerse a la herencia, pero técnicamente no lo son. Son un mecanismo de uso general para interceptar el acceso a atributos. De hecho, los descriptores están detrás de propiedades, métodos estáticos, métodos de clase, super(), etc.
Los descriptores se incorporaron en Python 2.2 y, desde entonces, se consideran como una característica “mágica” que dio un nuevo estilo a las clases tradicionales. Son clases que te permiten gestionar propiedades en otra clase. En concreto, implementan la interfaz de los métodos __get__(), __set__() y __delete__(), lo que los hace interesantes por muchas razones. Por ejemplo, son la base de decoradores de clase y de propiedad que ya habrás visto en Python.
En pocas palabras, una clase que implementa los métodos __get__(), __set()__ o __delete()__ para un objeto se conoce como "descriptor". Citando la documentación oficial de Python, un descriptor es un atributo de objeto con comportamiento de enlace (binding), cuyo acceso de atributo ha sido redefinido por los métodos del protocolo de descriptores. Esos métodos son __get__(), __set__() y __delete__() (Fuente).
El comportamiento de enlace en el contexto de los descriptores significa que se controla cómo se puede establecer, consultar (get) o eliminar el valor de una variable, objeto o conjunto de datos. Toda esta interacción queda vinculada a ese dato concreto y solo se aplica al dato sobre el que lo definas.
Los descriptores pueden clasificarse en data descriptors y non-data descriptors. Si el descriptor que escribes solo tiene el método __get__(), es un non-data descriptor; en cambio, si implementa __set__() y __delete__(), se llama data descriptor. Los non-data descriptors son solo de lectura, mientras que los data descriptors permiten lectura y escritura.
Es importante tener en cuenta que los descriptores se asignan a una clase, no a una instancia. Al modificar la clase, sobrescribes o eliminas el propio descriptor, en lugar de ejecutar su código (IBM Developer).
Por último, la clase descriptor no se limita a estos tres métodos; puede contener cualquier otro atributo además de los métodos get, set y delete.
Veamos con más detalle los métodos get, set y delete, inspirándonos en esta página de IBM Developer:
selfes la instancia del descriptor que creas (Real Python).objectes la instancia del objeto al que está enlazado tu descriptor (Real Python).typees el tipo del objeto al que está enlazado el descriptor (Real Python).valuees el valor que se asigna al atributo del descriptor. get(self, object, type) set(self, object, value) delete(self, object)-
__get__()accede al atributo cuando quieres extraer información. Devuelve el valor del atributo o lanza la excepción AttributeError si el atributo solicitado no existe. -
__set__()se llama al asignar un valor a un atributo. No devuelve nada, pero puede lanzar AttributeError. -
__delete__()controla una operación de borrado, es decir, cuando quieres eliminar el atributo de un objeto. No devuelve nada.
¡Ahora veamos el propósito de los descriptores con algunos ejemplos!
Propósito de los descriptores
Definamos una clase car con tres atributos: make, model y fuel_cap. Usarás el método __init__() para inicializarlos. Después, utilizarás la función mágica __str__(), que simplemente devolverá un texto con los tres atributos que pases al crear el objeto.
Ten en cuenta que el método __str__() devuelve la representación en cadena del objeto. Se llama cuando ejecutas print() o la función str() sobre un objeto de la clase.
class Car:
def __init__(self,make,model,fuel_cap):
self.make = make
self.model = model
self.fuel_cap = fuel_cap
def __str__(self):
return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car1 = Car("BMW","X7",40)
print(car1)
BMW model X7 with a fuel capacity of 40 ltr.
Como ves en la salida, todo parece correcto.
Ahora cambiemos la capacidad del depósito a menos 40.
car2 = Car("BMW","X7",-40)
print(car2)
BMW model X7 with a fuel capacity of -40 ltr.
Un momento, algo no cuadra, ¿verdad? La capacidad del depósito no puede ser negativa. Sin embargo, Python lo acepta sin error. Esto se debe a que Python es un lenguaje dinámico que no aplica comprobación de tipos de forma explícita.
Para evitarlo, añadamos una condición if en __init__() que verifique si la capacidad es válida. Si no lo es, lanzamos una excepción ValueError.
class Car:
def __init__(self,make,model,fuel_cap):
self.make = make
self.model = model
self.fuel_cap = fuel_cap
if self.fuel_cap < 0:
raise ValueError("Fuel Capacity can never be less than zero")
def __str__(self):
return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car1 = Car("BMW","X7",40)
print(car1)
BMW model X7 with a fuel capacity of 40 ltr.
car2 = Car("BMW","X7",-40)
print(car2)
----------------------------------------
ValueErrorTraceback (most recent call last)
<ipython-input-22-1c3d23be72f7> in <module>
----> 1 car2 = Car("BMW","X7",-40)
2 print(car2)
<ipython-input-20-1e154783588d> in __init__(self, make, model, fuel_cap)
5 self.fuel_cap = fuel_cap
6 if self.fuel_cap < 0:
----> 7 raise ValueError("Fuel Capacity can never be less than zero")
8
9 def __str__(self):
ValueError: Fuel Capacity can never be less than zero
Como puedes ver, ahora el programa lanza ValueError si la capacidad es menor que cero.
Sin embargo, puede surgir otro problema: ¿y si fuel_cap es un float o una cadena? Y no solo fuel_cap; también make y model podrían ser enteros. En todos estos casos, el programa no lanzaría excepción.
class Car:
def __init__(self,make,model,fuel_cap):
self.make = make
self.model = model
self.fuel_cap = fuel_cap
if self.fuel_cap < 0:
raise ValueError("Fuel Capacity can never be less than zero")
def __str__(self):
return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car(-40,"X7",40)
print(car2)
-40 model X7 with a fuel capacity of 40 ltr.
Para gestionar esto, podrías pensar en añadir otra condición if o usar isinstance para la comprobación de tipos.
Usemos ahora la función integrada isinstance para controlar el error.
class Car:
def __init__(self,make,model,fuel_cap):
self.make = make
self.model = model
self.fuel_cap = fuel_cap
if isinstance(self.make, str):
print(self.make)
else:
raise ValueError("Make of the car can never be an integer")
if self.fuel_cap < 0:
raise ValueError("Fuel Capacity can never be less than zero")
def __str__(self):
return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car("BMW","X7",40)
print(car2)
BMW
BMW model X7 with a fuel capacity of 40 ltr.
car2 = Car(-40,"X7",40)
print(car2)
----------------------------------------
ValueErrorTraceback (most recent call last)
<ipython-input-34-75b08cba454f> in <module>
----> 1 car2 = Car(-40,"X7",40)
2 print(car2)
<ipython-input-31-175690bf3b98> in __init__(self, make, model, fuel_cap)
7 print(self.make)
8 else:
----> 9 raise ValueError("Make of the car can never be an integer")
10
11 if self.fuel_cap < 0:
ValueError: Make of the car can never be an integer
¡Genial! También has resuelto este caso.
Pero ¿y si más tarde cambias explícitamente el atributo fuel_cap a -40? No funcionará, porque la comprobación de tipos solo se hace una vez en __init__(). Como sabes, __init__() es el constructor y solo se ejecuta al crear el objeto. Por tanto, tu comprobación personalizada fallará después.
Veámoslo con un ejemplo.
class Car:
def __init__(self,make,model,fuel_cap):
self.make = make
self.model = model
self.fuel_cap = fuel_cap
if isinstance(self.make, str):
print(self.make)
else:
raise ValueError("Make of the car can never be an integer")
if self.fuel_cap < 0:
raise ValueError("Fuel Capacity can never be less than zero")
def __str__(self):
return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car("BMW","X7",40)
print(car2)
BMW
BMW model X7 with a fuel capacity of 40 ltr.
car2.make = -40
print(car2)
-40 model X7 with a fuel capacity of 40 ltr.
¡Ahí lo tienes! Has podido saltarte la comprobación de tipos.
Ahora piensa que, además, tienes más atributos del coche como consumo, precio, accesorios, etc., que también requieren validación, y quizá algunos deberían ser solo de lectura. ¿No sería un lío?
Para resolver todo esto, Python tiene los descriptores.
Como ya viste, cualquier clase que implemente los métodos mágicos __get__(), __set()__ o __delete()__ del protocolo de descriptores se considera un descriptor. También te dan control adicional sobre cómo debe funcionar un atributo, por ejemplo, si debe ser de solo lectura o permitir escritura.
Extendamos ahora el ejemplo anterior añadiendo los métodos de descriptor de Python.
class Descriptor:
def __init__(self):
self.__fuel_cap = 0
def __get__(self, instance, owner):
return self.__fuel_cap
def __set__(self, instance, value):
if isinstance(value, int):
print(value)
else:
raise TypeError("Fuel Capacity can only be an integer")
if value < 0:
raise ValueError("Fuel Capacity can never be less than zero")
self.__fuel_cap = value
def __delete__(self, instance):
del self.__fuel_cap
class Car:
fuel_cap = Descriptor()
def __init__(self,make,model,fuel_cap):
self.make = make
self.model = model
self.fuel_cap = fuel_cap
def __str__(self):
return "{0} model {1} with a fuel capacity of {2} ltr.".format(self.make,self.model,self.fuel_cap)
car2 = Car("BMW","X7",40)
print(car2)
40
BMW model X7 with a fuel capacity of 40 ltr.
Si la clase Descriptor te resulta críptica, no te preocupes. Desgranémosla:
- El método
__init__()de Descriptor inicializa la variable local__fuel_capa cero. Eldunder(doble guion bajo) al principio indica que la variable es privada. Usamos dunder para distinguir el atributo de capacidad de combustible de la clase Descriptor del de la clase Car.
- Como ya sabes,
__get__()recupera el atributo y devuelve la capacidad de combustible. Recibe tres argumentos: el objeto descriptor, la instancia de la clase que contiene el descriptor (por ejemplo,car2) y el owner, que es la clase a la que pertenece la instancia, es decir,Car. En este método simplemente devuelves el valor del atributofuel_cap, que se establece en__set__().
- El método
__set__()se invoca cuando se asigna un valor al atributo y, a diferencia de__get__(), no devuelve nada. Recibe la instancia (igual que en__get__()) y el valor que asignas. Aquí compruebas que el valor parafuel_capsea un entero; si no, lanzasTypeError. Luego verificas que no sea menor que cero; si lo es, lanzasValueError. Tras las comprobaciones, actualizasfuel_capcon el valor.
- Por último,
__delete__()se llama al borrar el atributo del objeto y, como__set__(), no devuelve nada.
La clase Car permanece igual salvo por añadir la instancia de fuel_cap de la clase Descriptor(). Recuerda: la instancia del descriptor debe añadirse a la clase como atributo de clase, no como atributo de instancia.
En cuanto asignas la variable local fuel_cap en __init__(), se invoca el método __set__() del descriptor.
Cambiemos ahora la capacidad a un valor negativo para comprobar si se lanza ValueError.
car2 = Car("BMW","X7",-40)
print(car2)
-40
----------------------------------------
ValueErrorTraceback (most recent call last)
<ipython-input-115-1c3d23be72f7> in <module>
----> 1 car2 = Car("BMW","X7",-40)
2 print(car2)
<ipython-input-107-3e1f3e97d3c7> in __init__(self, make, model, fuel_cap)
4 self.make = make
5 self.model = model
----> 6 self.fuel_cap = fuel_cap
7
8 def __str__(self):
<ipython-input-106-0b252695aeed> in __set__(self, instance, value)
11
12 if value < 0:
---> 13 raise ValueError("Fuel Capacity can never be less than zero")
14
15 self.__fuel_cap = value
ValueError: Fuel Capacity can never be less than zero
Si recuerdas aquí, la comprobación de tipos fallaba cuando cambiabas el valor después, ya que solo se hacía en __init__(). Probemos ahora a actualizar fuel_cap con un valor de cadena y veamos si da error.
car2.fuel_cap = -1
-1
----------------------------------------
ValueErrorTraceback (most recent call last)
<ipython-input-120-dea9dbe96ebe> in <module>
----> 1 car2.fuel_cap = -1
<ipython-input-106-0b252695aeed> in __set__(self, instance, value)
11
12 if value < 0:
---> 13 raise ValueError("Fuel Capacity can never be less than zero")
14
15 self.__fuel_cap = value
ValueError: Fuel Capacity can never be less than zero
car2.fuel_cap = "BMW"
----------------------------------------
TypeErrorTraceback (most recent call last)
<ipython-input-121-0b316a9872c6> in <module>
----> 1 car2.fuel_cap = "BMW"
<ipython-input-106-0b252695aeed> in __set__(self, instance, value)
8 print(value)
9 else:
---> 10 raise TypeError("Fuel Capacity can only be an integer")
11
12 if value < 0:
TypeError: Fuel Capacity can only be an integer
¡Perfecto! Como ves, también funciona cuando actualizas el atributo más tarde.
Hay, eso sí, un pequeño inconveniente: cuando creas una nueva instancia de la clase, el valor anterior puede verse sobrescrito. La razón es que los descriptores están asociados a la clase y no a la instancia.
Veámoslo con este ejemplo.
car3 = Car("BMW","X7",48) #creada una nueva instancia 'car3' con valores distintos
48
Si imprimes la instancia car2, verás que sus valores se han sobrescrito con los de car3.
print(car2)
BMW model X7 with a fuel capacity of 48 ltr.
Conclusión
Enhorabuena por completar el tutorial.
Este tutorial está pensado para quienes ya se sienten cómodos con Python y quieren dominar conceptos avanzados. Como buen ejercicio, puedes investigar cómo resolver el problema de sobrescritura entre instancias que hemos visto hoy.
No dudes en dejar tus preguntas sobre este tutorial en los comentarios.
Si quieres seguir aprendiendo Python, echa un vistazo al itinerario de aprendizaje Python Programming de DataCamp.
Consulta también nuestro tutorial de funciones en Python.