Licence CC BY-NC-ND, Thierry Parmentelat & Arnaud Legout

from IPython.display import HTML
HTML(filename="_static/style.html")

attr.. (2/3) - descripteurs#

accès aux attributs - second notebook

pour résumer les chapitres précédents, on a vu jusqu’ici:

  • la mécanique “usuelle”

  • les properties

  • l’attrape-tout avec __getattr__

à présent on va voir encore un autre mécanisme, qui s’appelle les descriptors
en fait, il s’agit d’un mécanisme de très bas niveau, et il se trouve que c’est grâce à ce mécanisme que l’on peut proposer les properties

avertissement: très avancé !

on s’est efforcé d’aborder jusqu’ici des notions qui ont une application dans la vie quotidienne d’un développeur
à partir de maintenant par contre, on entre de plain-pied dans les tréfonds du langage, et il est vraiment rare qu’on ait besoin de modifier le langage au point de devoir descendre aussi profond dans les soutes…
on s’adresse donc à partir d’ici à un public curieux et très avancé; you will have been warned ;-)

pourquoi c’est intéressant ?#

la recherche des attributs est totalement centrale dans le langage
on va le voir, lorsqu’on écrit un code aussi banal que person = Person("jean"), il y a déjà plusieurs recherches d’attributs qui entrent en jeu !

de plus pour que le modèle fonctionne, on a dû implémenter deux mécanismes séparés pour les instances et les classes, qui sont voisines mais subtilement différentes; si on veut maitriser à fond le langage, on doit en passer par l’étude de ces mécanismes

mais bon à nouveau, si on s’en tient à une utilisation usuelle du langage, tout ceci est complètement optionnel !

descripteurs#

un descripteur est une classe qui détermine le comportement lors de l’accès, l’affectation et l’effacement d’un attribut

une classe avec au moins une des méthodes suivantes est un descripteur

  • __get__()

  • __set__()

  • __delete__()

caractéristique troublante#

la caractéristique assez troublante des descripteurs est la suivante:
si pendant la recherche “habituelle”

  • on trouve un attribut

  • et que celui-ci est une instance de descripteur

  • alors on l’appelle pour obtenir la valeur finale de l’attribut ou pour écrire/détruire selon le contexte

ça nous rappelle un peu les propriétés (et de fait les propriétés sont implémentées à base de descripteurs…)

exemple: un attribut d’instance#

# a helper tool to turn verbosity on or off
VERBOSE = True

def verbose(*args, **kwds):
    if VERBOSE:
        print(*args, **kwds)

v0: un peu poussif#

# ici on implémente un attribut usuel (d'instance)
# il faut être attentif à bien ranger la donnée dans l'instance
# et pas dans le descripteur !!!

# une classe de descripteur pour implémenter l'attribut 'age'
class AgeDescriptor:
 
    def __init__(self):
        # ici self est l'instance du descripteur
        # cet espace va être partagé par toutes les instances de la classe
        # ce n'est **pas une bonne idée** d'y ranger
        # le nom des personnes
        pass
 
    def __get__(self, instance, owner):
        # self: l'instance du descripteur (de type Descriptor donc)
        # instance: l'instance de type Person
        # owner: ici ça va être None (pourrait recevoir 
        #        la classe Person dans d'autres use cases)

        # du coup bien faire attention à ranger dans `instance` et pas dans `self` !
        current_age = instance._age
        verbose(f"getter returning {current_age=}\n  -- {self=} {instance=}")
        return current_age
 
    def __set__(self, instance, new_age):
        # attention à ne pas utiliser instance.age
        # car on cacherait le descripteur !
        verbose(f"setting: age={new_age}\n  -- with {self=} {instance=}")
        instance._age = new_age
 
    def __delete__(self, instance):
        verbose(f"deleting: {instance._age}\n  -- from {instance=}")
        del instance._age


class Person:
    # on range une instance de Descriptor dans Person.__dict__['name']
    age = AgeDescriptor()

    # fait une lecture et une écriture
    def birthday(self):
        self.age += 1     
# avec ce code, c'est comme si on avait ajouté 
# un attribut 'name' à toutes les instances de Person

p1, p2 = Person(), Person()
p1.age = 12
p2.age = 20
setting: age=12
  -- with self=<__main__.AgeDescriptor object at 0x7a1da36d3860> instance=<__main__.Person object at 0x7a1da36d2630>
setting: age=20
  -- with self=<__main__.AgeDescriptor object at 0x7a1da36d3860> instance=<__main__.Person object at 0x7a1da36d2840>
# sont bien différents

p1.age, p2.age
getter returning current_age=12
  -- self=<__main__.AgeDescriptor object at 0x7a1da36d3860> instance=<__main__.Person object at 0x7a1da36d2630>
getter returning current_age=20
  -- self=<__main__.AgeDescriptor object at 0x7a1da36d3860> instance=<__main__.Person object at 0x7a1da36d2840>
(12, 20)
p1.birthday()
getter returning current_age=12
  -- self=<__main__.AgeDescriptor object at 0x7a1da36d3860> instance=<__main__.Person object at 0x7a1da36d2630>
setting: age=13
  -- with self=<__main__.AgeDescriptor object at 0x7a1da36d3860> instance=<__main__.Person object at 0x7a1da36d2630>

v1: mieux avec __set_name__#

notre descripteur est spécialisé pour l’attribut name, c’est franchement sous-optimal, on ne va pas récrire le même code à chaque fois
voici comment améliorer, en tirant profit de la dunder __set_name__ pour capturer le nom de l’attribut

# de nouveau un ici on implémente un attribut usuel (d'instance)
# mais qui peut marcher pour n'importe quel nom

class InstanceAttributeDescriptor:
 
    def __set_name__(self, owner, name):
        # par exemple 'age'
        self.public_name = name
        self.private_name = '_' + name
    
    def __get__(self, instance, owner):
        current_value = getattr(instance, self.private_name)
        verbose(f"getter: {self.public_name} -> {current_value}")
        return current_value
 
    def __set__(self, instance, new_value):
        verbose(f"setter: {self.public_name} -> {new_value}")
        setattr(instance, self.private_name, new_value)        
 

class Person:
    age = InstanceAttributeDescriptor()
    name = InstanceAttributeDescriptor()

    def __init__(self, name, age):
        self.name = name
        self.age = age

    def birthday(self):
        self.age += 1         
# avec ce code, c'est comme si on avait ajouté 
# un attribut 'name' à toutes les instances de Person

p1, p2 = Person("john", 12), Person("bill", 20)
setter: name -> john
setter: age -> 12
setter: name -> bill
setter: age -> 20
# sont bien différents

p1.age, p2.age
getter: age -> 12
getter: age -> 20
(12, 20)
p1.birthday()
getter: age -> 12
setter: age -> 13
VERBOSE
True

un peu d’introspection#

# l'instance de descripteur est ici

vars(Person)['age']
<__main__.InstanceAttributeDescriptor at 0x7a1da36d33e0>
# et ses deux attributs sont

vars(vars(Person)['age'])
{'public_name': 'age', 'private_name': '_age'}
# et dans un objet Person on trouve ceci

vars(p1)
{'_name': 'john', '_age': 13}

stockage des attributs#

le protocole nous expose à la fois les deux instances:

  • le descripteur

  • l’instance sujet de la recherche d’attribut

et comme il y a une instance de descripteur par attribut de la classe, on peut choisir de ranger les données

  • au niveau de l’instance - comme on vient de le faire;

  • où au niveau de la classe si on écrit dans le descripteur

voyons cette deuxième alternative, pour implémenter un attribut de classe

exemple: un attribut de classe#

et ceci avec un tout petit changement:

# ici on implémente un attribut de classe
# pour ça on va ranger la valeur .. directement dans le (l'instance du) descripteur 

# c-a-d dans self plutot que dans instance

class ClassAttributeDescriptor:

    def __set_name__(self, owner, name):
        self.public_name = name
        # on pourrait aussi ranger un _private_name
        # mais ce n'est pas nécessaire, car ici on veut un attribut
        # de classe, donc on peut ranger la donnée dans le descripteur
        # et du coup le nom n'a aucun importance, on va mettre en dur '_value'
 
    def __get__(self, instance, owner):
        # pas besoin de s'embeter à prendre un nom compliqué
        # il n'y a pas de risque de conflit de nom cette fois
        # le _ c'est juste par sécurité mais ça n'a pas d'importance ici
        return self._value
 
    def __set__(self, instance, newvalue):
        self._value = newvalue


class Person:
    # un attribut 'normal' 
    name = InstanceAttributeDescriptor()
    # un attribut de classe
    # ici du coup toutes les instances partagent l'attribut
    shared = ClassAttributeDescriptor()
VERBOSE = False

p1, p2 = Person(), Person()

# chacun son nom
p1.name, p2.name = "john", "doe"

# la première affectation est écrasée par la seconde
p1.shared, p2.shared = "useless", "because overwritten"

# la preuve
print("========== name:", p1.name, "<->", p2.name)
print("========== shared:", p1.shared, "<->", p2.shared)
========== name: john <-> doe
========== shared: because overwritten <-> because overwritten

attribut en lecture seule#

read-only

on a vu que pour rendre une propriété read-only, il suffisait de ne pas fournir de setter
ici c’est un peu différent, pour obtenir ce comportement il faut définir __set__ en lui faisant lever AttributeError

    def __set__(self, instance, name):
        raise AttributeError('read-only')

attention aux noms#

comme avec les propriétés, il faut soigneusement éviter d’utiliser le même nom pour le descripteur et pour ranger la donnée dans l’instance (dans le descripteur on s’en moque)

la tradition est d’utiliser

  • name pour le descripteur

  • _name pour la donnée

exemples pratiques#

voyez quelques exemples utiles de validateurs ici:
https://docs.python.org/fr/3/howto/descriptor.html#validator-class

data descriptors#

maintenant, il se trouve que la logique complète de recherche des attributs, qui est implémentée dans __getattribute__ et qu’on verra pour conclure dans le 3ème et dernier notebook de cette série, va avoir besoin de traiter un peu différemment les attributs de donnée et les attributs de fonction

pour cette raison, on a choisi à ce stade la définition suivante:

data descriptor

un descripteur qui implémente __set__ ou __delete__ est considéré comme un data descriptor

les autres sont donc .. des non-data descriptors

un data descriptor est prioritaire sur un attribut présent dans __dict__
ce qui n’est pas le cas pour les fonctions

xxx à finir

trouver un exemple un peu parlant

class DataDescriptor:

    def __get__(self, instance, owner):
        value = instance._name
        print(f"getting: {value}")
        return value
 
    # en fournissant __set__
    # on devient un *data* descriptor
    def __set__(self, instance, name):
        value = name.title()
        print(f"setting: {value}")
        instance._name = value

class PersonData:
    def __init__(self):
        self._name = None
    name = DataDescriptor()
pd = PersonData()
pd.name
getting: None
pd.name = 'john'
pd.name
setting: John
getting: John
'John'
class DescriptorNonData:

    # sans __set__ on parle 
    # de *non-data* descriptor
    def __get__(self, instance, owner):
        value = instance._name
        print("Getting: {}".format(value))
        return value
 
class PersonNonData:
    def __init__(self):
        self._name = 'John Doe'
    name = DescriptorNonData()
pnd = PersonNonData()
pnd.name = 'bill'
pnd.name
'bill'
del pnd.name
pnd.name
Getting: John Doe
'John Doe'

pour en savoir plus#