Skip to article frontmatterSkip to article content

accès aux attributs - second notebook

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

à 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

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

caractéristique troublante

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

ç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 0x7fc31720fb90> instance=<__main__.Person object at 0x7fc30ccc55e0>
setting: age=20
  -- with self=<__main__.AgeDescriptor object at 0x7fc31720fb90> instance=<__main__.Person object at 0x7fc30cce1670>
# sont bien différents

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

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 0x7fc30ccc5d00>
# 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:

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

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

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

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:

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

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