Compléments¶
Principe d'encapsulation¶
L'encapsulation consiste à ne fournir aux utilisateurs d'une classe qu'un accès limité à la classe. En général, on masque tout ce qui est attribut pour ne laisser la possibilité d'utiliser que quelques méthodes.
- En masquant les attributs à l'utilisateur, on interdit à ce dernier d'effectuer directement des modifications de valeurs ; on peut ainsi contrôler l'intégrité et la validité des valeurs.
- On offre à l'utilisateur une interface : la signature et le rôle d'un ensemble de méthodes ; cette interface détermine les services possibles d'une classe en rendant ces services indépendants de leur implémentation.
En reprenant notre classe Personnage
, illustrons le principe d'encapsulation :
- Les attributs devraient ne pas être accessibles en modification :
- le nom est choisi une fois pour toute, aucune raison de pouvoir le changer ;
- les points de vie max pourraient évoluer par exemple si le personnage change de niveau mais là encore, l'utilisateur ne devrait pas pouvoir les modifier à sa guise ;
- les points de vie à l'instant t eux sont susceptibles de changer mais là encore pas n'importe comment (par exemple ils ne devraient jamais sortir de l'intervalle 0..
pv_max
).
- Les méthodes doivent permettre une certaine intégrité des données.
Malheureusement de base Python n'offre pas vraiment les moyens de mettre en oeuvre l'encapsulation contrairement à des langages comme Java ou C++ où le concept d'attributs et méthodes privés existent.
En Python, on peut utiliser des conventions de nommage pour inciter l'utilisateur à respecter l'encapsulation. Voici une nouvelle définition de notre classe Personnage
. Tous les noms d'attributs commencent par un souligné ce qui signifie à l'utilisateur qu'il ne devrait pas les utiliser directement. Noter que l'implémentation de la méthode soigner
a été modifiée pour contrôler la validité (les points de vie doivent rester dans l'intervalle 0... point de vie max)
class Personnage:
"""Modélise un personnage du jeu Combat Pour de Faux"""
def __init__(self, nom, pv_max):
self._nom = nom
self._pv_max = pv_max
self._pv = pv_max
def ko(self):
return self._pv == 0
def soigner(self, qte_soin):
self._pv = min(self._pv_max, self._pv + qte_soin)
Ainsi, l'utilisateur peut faire :
>>> merlin = Personnage('Merlin', 80)
>>> merlin._nom
'Merlin'
>>> merlin.ko()
False
>>> merlin.soigne(10)
Mais ne devrait pas faire :
>>> merlin._pv = 20
>>> merlin._pv += 10
>>> merlin._pv_max = 1000
Même si en l'état actuel de la définition de notre classe rien ne l'en empêche.
Assesseurs et Mutateurs¶
Ce sont des méthodes qui vont permettre à l'utilisateur d'obtenir la valeur des attributs et de les modifier (lorsque c'est pertinent), sans pour autant accéder directement aux variables d'instance, qui, on le répète, doivent autant que possible rester cachées.
Ainsi pour chaque attribut _attr
on devrait avoir :
-
un assesseur :
qui permet d'obtenir la valeur de l'attribut ;def get_attr(self): return self._attr
-
éventuellement un mutateur pour les modifications :
python def set_attr(self, new_value): self._attr = new_value
Mais nous avons plusieurs inconvénients et cette façon de faire n'est que partiellement satisfaisante :
- le simple souligné devant le nom d'une variable d'instance n'est qu'une convention : l'utilisateur peut passer outre et accéder à l'attribut ;
- devoir passer par
merlin.get_nom()
juste pour obtenir le nom de Merlin est assez pénible et on aimerait continuer à écrire tout simplementmerlin.nom
.
Ceci est possible grâce aux noms spéciaux commençant par un double souligné et aux décorateurs, un mécanisme de Python permettant d'ajouter aux fonctions certaines fonctionnalités.
Ainsi, une instance de Personnage
aura un attribut caché : __nom
mais une interface nom
laissant accéder en lecture à cet attribut. De même pour les points de vie maximum. Concernant les points de vie __pv
la classe expose également une méthode permettant de les modifier. Voici la définition plus réaliste d'une classe Personnage
mettant en place cette interface par variables d'instances cachées et décorateurs :
class Personnage:
def __init__(self, nom, pv_max):
self.__nom = nom
self.__pv_max = pv_max
self.__pv = pv_max
@property
def nom(self):
return self.__nom
@property
def pv_max(self):
return self.__pv_max
@property
def pv(self):
return self.__pv
@pv.setter
def pv(self, pv):
if 0 <= pv <= self.__pv_max:
self.__pv = pv
def soigner(self, qte):
self.pv = min(self.__pv_max, self.pv + qte)
L'utilisateur retrouve un comportement simple et sain :
>>> merlin = Personnage('Merlin', 80)
>>> merlin.nom
'Merlin'
>>> merlin.nom = 'Arthur'
--------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
<ipython-input-37-240e232355b3> in <module>
----> 1 merlin.nom = 'Arthur'
AttributeError: can t set attribute
>>> merlin.pv -= 30
>>> merlin.pv
50
>>> merlin.pv += 100 # valeur excessive
>>> merlin.pv # l'interface a empêché la modification
50
>>> merlin.soigne(20)
>>> merlin.pv
70
Attention, même avec la syntaxe du double souligné, la privatisation des variables d'instance en Python est une façade. On parle souvent de sucre syntaxique. Un appel à __dict__
met en lumière les vrais noms des attributs de l'instance, qui restent donc accessibles :
>>> merlin.__dict__
{'_Personnage__nom': 'Merlin',
'_Personnage__pv_max': 80,
'_Personnage__pv': 80}
Principes d'héritage et de polymorphisme¶
Ces deux concepts permettent d'organiser nos objets de façon hiérarchique et de définir des méthodes partageant les mêmes noms mais ne faisant pas tout à fait la même chose.
L'héritage est le principe qui autorise la définition d'une classe à partir d'une autre. La nouvelle classe appelée classe dérivée ou classe fille possède les attributs et les méthodes de la classe dont elle dérive (classe mère).
Par exemple un Magicien
est un Personnage
particulier. Il possède donc tous les attributs d'un Personnage
ainsi que de la mana, pour lancer des sorts :
class Magicien(Personnage):
def __init__(self, nom, pv_max, mana_max):
Personnage.__init__(self, nom, pv_max)
self.__mana_max = mana_max
self.__mana = mana_max
Notons la deuxième ligne du constructeur qui appelle le constructeur de la classe mère avec comme premier argument l'instance de Magicien
en train de se créer, c'est-à-dire self
.
Dès lors :
>>> merlin = Magicien('Merlin', pv_max=80, mana_max=120)
>>> merlin.pv
80
>>> merlin.mana
120
Python autorise même l'héritage multiple, c'est-à-dire qu'une classe peut dériver de plusieurs classes.
Le polymorphisme n'est pas propre à la programmation orientée objet. Ainsi, le polymorphisme ad hoc désigne la possibilité d'avoir le même nom de fonction ou d'opérateur pour des classes qui n'ont rien à voir l'une avec l'autre. C'est par exemple le cas de l'opérateur +
qui définit l'addition mathématique sur les entiers ou la concaténation sur les chaînes de caractères.
Nous ne parlerons pas du polymorphisme paramétrique car non disponible en Python.
Le polymorphisme d'héritage consiste à redéfinir au niveau de la classe fille une méthode présente dans la classe mère afin de la spécialiser.
Par exemple, redéfinition de la méthode spéciale __str__
:
class Magicien:
def __str__(self):
return Personnage.__str__(self) + f', {self.mana}/{self.mana_max} mana'
Méthodes spéciales¶
Leurs noms commencent et se terminent par un double souligné. Nous en avons déjà vu une : l'initialiseur __init__
__repr__
est la chaîne de caractères que retourne l'interprète Python lorsqu'il évalue l'objet. Lorsque l'on définit cette méthode, on essaie de retourner une chaîne de caractères se rapprochant de ce qu'on écrirait pour créer l'objet.
>>> L = [1, 2]
>>> repr(L)
'[1, 2]'
>>> f = open('test', 'w')
>>> repr(f)
"<_io.TextIOWrapper name='test' mode='w' encoding='cp1252'>"
Concernant notre classe Personnage
:
def __repr__(self):
return f'Personnage(nom={self.nom}, pv_max={self.pv_max})'
>>> repr(merlin)
'Personnage(nom=Merlin, pv_max=80)'
__str__
est aussi une chaîne de caractères, mais servant principalement à la fonction print
pour afficher notre objet. Sur les objets prédéfinis, souvent les deux méthodes coïncident. Mais pour notre Personnage
l'affichage peut être plus sympa :
def __str__(self):
return f'{self.nom} : {self.pv}/{self.pv_max} pv'
>>> print(merlin)
Merlin : 80/80 pv
__iter__
pour parcourir... supposons que notre Personnage
évolue dans un Monde
:
class Monde:
def __init__(self, ...):
# tout plein d'attributs
# ...
self.__protagonistes = [] # la liste des Personnages du Monde
Le Monde
a donc une liste de protagonistes qui sont des Personnages
. On voudrait alors pouvoir parcourir l'ensemble des Personnages
simplement. Supposons donc que terre
soit une instance de Monde
avec plusieurs Personnages
s'y cotoyant. On voudrait pouvoir écrire ceci :
for personne in terre:
# traitement
Ce qui est possible en définissant une méthode __iter__
de la classe Monde
:
def __iter__(self):
return self.__protagonistes.__iter__()
Les opérateurs : à chaque opérateur correspond une méthode spéciale. Voici le tableau des principales :
opérateur | méthode spéciale |
---|---|
+ |
__add__ |
- |
__sub__ |
* |
__mul__ |
/ |
__truediv__ |
// |
__floordiv__ |
< |
__lt__ |
> |
__gt__ |
<= |
__le__ |
>= |
__ge__ |
== |
__eq__ |
[ ] |
__getitem__ |
Variables et Méthodes de classe¶
Nous avons vu comment définir et utiliser les variables et les méthodes d'instances. Il est possible de créer des variables et des méthodes de classe, ie définies dans la classe et communes à toutes les instances. Un exemple classique est la variable de classe qui comptabilise le nombre d'instances créées :
class A:
nb = 0
def __init__(self):
A.nb += 1
print(f'{A.nb} objet(s) {A.__name__} créé(s)')
>>> a = A()
1 objet(s) A créé(s)
>>> b = A()
2 objet(s) A créé(s)
>>> A.nb
2
>>> a.nb
2
>>> b.nb
2
On utilisera aussi les variables de classe pour définir les constantes associées à une classe.
Une méthode de classe se définit par la syntaxe suivante :
class A:
@classmethod
def methode_de_classe(cls):
# traitement
On utilise le décorateur @classmethod
ainsi que le mot-clé cls
à la place de self
en premier paramètre. Une des utilisations des méthodes de classe concerne la possibilité d'offrir une méthode alternative de la construction d'un objet :
import Time
Class Date:
# Constructeur par défaut
#
def __init__(self, year, month, day):
self.year = year
self.month = month
self.day = day
# Constructeur alternatif sous forme de methode de class
#
@classmethod
def today(cls):
t = time.localtime()
return cls(t.tm_year, t.tm_mon, t.tm_mday)
Ce qui permet :
>>> date_1 = Date(2021, 12, 24) # constructeur par défaut
>>> date_2 = Date.today() # Méthode de classe