Licence CC BY-NC-ND Thierry Parmentelat

properties

en guise de complément, ce notebook introduit la notion de property

propos

on a vu qu’en général, une classe expose

  • des attributs, pour accéder directement aux différents ‘morceaux’ qui constituent une instance

  • et des méthodes, qui sont des fonctions

il arrive qu’on se trouve dans une situation un peu mixte, où on voudrait

  • pouvoir accéder aux morceaux de données,

  • mais au travers d’une fonction; qui puisse, par exemple, faire des contrôles sur la validité des valeurs; ou simplement parce que l’accès en question se fait au travers d’une indirection

exemple 1 : indirection

imaginez par exemple que vous avez lu une dataframe, qui contient la liste des stations de métro

import pandas as pd

stations = pd.read_csv("data/stations.txt")
stations.head(2)
station_id latitude longitude
0 2122 48.866856 2.290038
1 2177 48.829863 2.350623

et maintenant, on veut définir une classe Station pour manipuler ce contenu

class Station:
    def __init__(self, indice):
        self.row = stations.iloc[indice]

on a donc une classe qui ‘emballe’ un objet de type pandas.Series
et on aimerait bien d’exposer un attribut latitude, pour pouvoir écrire par exemple

# ce code ne marche pas

class Station:
    def __init__(self, indice):
        self.row = stations.iloc[indice]
    def __repr__(self):
        # ici self.latitude ne veut rien dire
        return f"[Station {self.latitude:.2f}]"

mais bien entendu, ça ne fonctionne pas dans l’état, puisque l’attibut latitude n’est pas présent dans l’objet station

# ça ne marche pas !
# Station(0)

ça oblige à écrire plutôt station.row.latitude (ou, encore pire, à copier les colonnes de la dataframe sous forme d’attributs - une trè mauvaise idée); mais ça n’est pas du tout pratique, il va falloir se souvenir de cette particularité à chaque fois qu’on aura besoin d’accéder à latitude

dans ce premier exemple, on peut s’en sortir simplement avec une property :

# maintenant ça fonctionne

class Station:
    def __init__(self, indice):
        self.row = stations.iloc[indice]
    def __repr__(self):
        # plus de problème
        return f"[Station {self.latitude:.2f}]"
    
    # grâce à cett property, on peut accéder à l'attribut self.latitude
    @property
    def latitude(self):
        return self.row.latitude
station0 = Station(0)
station0
[Station 48.87]

exemple 2 : une jauge

on veut une classe qui manipule une valeur, dont on veut être sûr qu’elle appartient à un intervalle; disons entre 0 et 100

sans les properties, on est obligé de définir une méthode set_value; comme c’est une fonction, elle va pouvoir faire des contrôles

# en définissant un setter
# ça marche mais c'est vraiment moche comme approche

class Gauge:
    def __repr__(self):
        return f"[Gauge {self._value}]"
    def __init__(self, value):
        self.set_value(value)
    def set_value(self, newvalue):
        # on force la nouvelle valeur à être dans l'intervalle
        self._value = max(0, min(newvalue, 100))
Gauge(1000)
[Gauge 100]

mais à nouveau ce n’est pas du tout pratique :

  • d’abord il faut “cacher” l’attribut pour éviter que l’on fasse accidentellement gauge.value = 1000

  • ensuite du coup il faut aussi exposer une autr méthode self.get_value() pour lire la valeur

  • et une fois qu’on a fait tout ça, on se retrouve à devoir écrire un code bavard et pas très lisible, bref c’est super moche

  • enfin, ça change l’API, et s’il y a déjà du code qui utilise l’attibut .value il faut tout changer

pour information, cette technique est celle employée dans les langages comme C++ et Java, on appelle ces méthodes des getters et setters; pas du tout pythonique comme pratique !

à nouveau dans cette situtation les properties viennent à la rescousse; voici comment ça se présenterait

# version avec une property

class Gauge:
    
    @property
    def value(self):
        return self._value
    
    # la syntaxe pour définir le 'setter' correspondant 
    # à la property 'value'
    # et c'est pour ça bien sûr qu'on écrit '@value'
    @value.setter
    def value(self, newvalue):
        self._value = max(0, min(newvalue, 100))
        
    def __init__(self, value):
        self.value = value
        
    def __repr__(self):
        return f"[Gauge {self._value}]"

avec ce code, on peut manipuler les objets de la classe “normalement”, et pourtant on contrôle bien la validité de la valeur

# à la création
g = Gauge(1000); g
[Gauge 100]
# ou à la modification
g.value = -10
g
[Gauge 0]

l’autre syntaxe

en fait il y a deux syntaxes pour définir une property, choisir entre les deux est une question de goût

voici la deuxième syntaxe utilisée dans la classe Gauge

# version avec une property - deuxième syntaxe

class Gauge:
    
    # je choisis de cacher cette méthode car elle n'est 
    # pas supposée être appelée directement
    def _get_value(self):
        return self._value

    # pareil
    def _set_value(self, newvalue):
        self._value = max(0, min(newvalue, 100))
        
    # la syntaxe pour définir la property
    value = property(_get_value, _set_value)

    # le reste est inchangé        
    def __init__(self, value):
        self.value = value
        
    def __repr__(self):
        return f"[Gauge {self._value}]"
# à la création
g = Gauge(1000); g
[Gauge 100]
# ou à la modification
g.value = -10
g
[Gauge 0]

conclusion

  • en Python, on aime bien accéder aux attributs d’un objet directement, et ne pas s’encombrer de getters et setters qui obscurcissent le code pour rien

  • on a parfois besoin que l’accès à un attribut passe par une couche de logique

    • soit pour implémenter une indirection

    • soit pour contrôler la validité des arguments

  • et dans ces cas-là on définit une property

  • qui permet de conserver le code existant (pas de changement de l’API)