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:
self es la instancia del descriptor que creas (Real Python).
object es la instancia del objeto al que está enlazado tu descriptor (Real Python).
type es el tipo del objeto al que está enlazado el descriptor (Real Python).
value es 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.
classCar: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)Con la tecnología de
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.
classCar: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)Con la tecnología de
----------------------------------------ValueErrorTraceback(most recent call last)<ipython-input-22-1c3d23be72f7>in<module>---->1 car2 =Car("BMW","X7",-40)2print(car2)<ipython-input-20-1e154783588d>in__init__(self, make, model, fuel_cap)5 self.fuel_cap = fuel_cap
6if self.fuel_cap <0:---->7 raise ValueError("Fuel Capacity can never be less than zero")89 def __str__(self):ValueError: Fuel Capacity can never be less than zero
Con la tecnología de
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.
classCar: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)Con la tecnología de
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.
classCar:def__init__(self,make,model,fuel_cap):
self.make = make
self.model = model
self.fuel_cap = fuel_cap
ifisinstance(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)Con la tecnología de
----------------------------------------ValueErrorTraceback(most recent call last)<ipython-input-34-75b08cba454f>in<module>---->1 car2 =Car(-40,"X7",40)2print(car2)<ipython-input-31-175690bf3b98>in__init__(self, make, model, fuel_cap)7print(self.make)8else:---->9 raise ValueError("Make of the car can never be an integer")1011if self.fuel_cap <0:ValueError: Make of the car can never be an integer
Con la tecnología de
¡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.
classCar:def__init__(self,make,model,fuel_cap):
self.make = make
self.model = model
self.fuel_cap = fuel_cap
ifisinstance(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)Con la tecnología de
¡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.
classDescriptor:def__init__(self):
self.__fuel_cap =0def__get__(self, instance, owner):return self.__fuel_cap
def__set__(self, instance, value):ifisinstance(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
Con la tecnología de
classCar:
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)Con la tecnología de
Si la clase Descriptor te resulta críptica, no te preocupes. Desgranémosla:
El método __init__() de Descriptor inicializa la variable local __fuel_cap a cero. El dunder (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 atributo fuel_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 para fuel_cap sea un entero; si no, lanzas TypeError. Luego verificas que no sea menor que cero; si lo es, lanzas ValueError. Tras las comprobaciones, actualizas fuel_cap con 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.
-40----------------------------------------ValueErrorTraceback(most recent call last)<ipython-input-115-1c3d23be72f7>in<module>---->1 car2 =Car("BMW","X7",-40)2print(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
78 def __str__(self):<ipython-input-106-0b252695aeed>in__set__(self, instance, value)1112if value <0:--->13 raise ValueError("Fuel Capacity can never be less than zero")1415 self.__fuel_cap = value
ValueError: Fuel Capacity can never be less than zero
Con la tecnología de
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.
-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)1112if value <0:--->13 raise ValueError("Fuel Capacity can never be less than zero")1415 self.__fuel_cap = value
ValueError: Fuel Capacity can never be less than zero
Con la tecnología de
----------------------------------------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)8print(value)9else:--->10 raise TypeError("Fuel Capacity can only be an integer")1112if value <0:TypeError: Fuel Capacity can only be an integer
Con la tecnología de
¡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 distintosCon la tecnología de
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.
Descubre cómo escribir funciones en Python reutilizables y eficientes. Domina los parámetros, las sentencias return y temas avanzados como las funciones lambda. Organiza mejor tu código con main() y otras buenas prácticas.
Aprende sobre los conjuntos en Python: qué son, cómo crearlos, cuándo usarlos, funciones incorporadas y su relación con las operaciones de la teoría de conjuntos.
Aprende a crear e importar módulos de Python. ¡Descubre las mejores prácticas, ejemplos y consejos para escribir código Python reutilizable, organizado y eficiente!
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 concomportamiento 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 enlaceen 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 quedavinculadaa 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,setydelete.Veamos con más detalle los métodos
get,setydelete, 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
carcon tres atributos:make,modelyfuel_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 ejecutasprint()o la funciónstr()sobre un objeto de la clase.Como ves en la salida, todo parece correcto.
Ahora cambiemos la capacidad del depósito a menos 40.
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
ifen__init__()que verifique si la capacidad es válida. Si no lo es, lanzamos una excepción ValueError.Como puedes ver, ahora el programa lanza
ValueErrorsi la capacidad es menor que cero.Sin embargo, puede surgir otro problema: ¿y si
fuel_capes un float o una cadena? Y no solofuel_cap; tambiénmakeymodelpodrían ser enteros. En todos estos casos, el programa no lanzaría excepción.Para gestionar esto, podrías pensar en añadir otra condición
ifo usarisinstancepara la comprobación de tipos.Usemos ahora la función integrada
isinstancepara controlar el error.¡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.
¡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.
Si la clase Descriptor te resulta críptica, no te preocupes. Desgranémosla:
__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.__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__().__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.__delete__()se llama al borrar el atributo del objeto y, como__set__(), no devuelve nada.La clase
Carpermanece igual salvo por añadir la instancia defuel_capde la claseDescriptor(). 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_capen__init__(), se invoca el método__set__()del descriptor.Cambiemos ahora la capacidad a un valor negativo para comprobar si se lanza ValueError.
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 actualizarfuel_capcon un valor de cadena y veamos si da error.¡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.
Si imprimes la instancia
car2, verás que sus valores se han sobrescrito con los decar3.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.