Programmation orienté objet
Directory
serie
- partie 1: Introduction
- partie 2: En pratique
- partie 3: Attributs
- partie 4: Héritage
- partie 5: Héritage multiple et composition$ \Leftarrow $
- partie 6: Méthodes magiques
sources
Héritage multiple
Dans un jeux vidéo, nous avons des protections et des armes pour les personnages:
class Arme(object):
def __init__(self, nom, degat):
self.nom = nom
self.degat = degat
def attaque(self, cible):
cible.vie -= self.degat
class Protection(object):
def __init__(self, nom, armure):
self.nom = nom
self.armure = armure
def defend(self, degat): # mitige dégats
degat -= self.armure
if degat < 0:
return 0
return degat
epee = Arme('Epée de ouf', degat = 999)
casque = Protection('Casque stylé', armure=1)
La concurence est rude et on est à la traine. Il faut implémenter un barbar dans le jeu qui puisse taper avec son bouclier.
Une solution serait de créer une nouvelle classe qui hérite des deux classes en même temps:
class ProtectionOffensive(Arme, Protection):
def __init__(self, nom, degat, armure):
Arme.__init__(self, nom, degat) # appel __init__ Arme
Protection.__init__(self, nom, armure) # appel __init__ Protection
Comme on a appelé les deux __init__
, on va avoir les attributs settés dans les deux __init__
attachés à cette classe.
Nous avons donc une classe qui possède les méthodes des deux classes parentes:
>>>bouclier = ProtectionOffensive('Bouclier en taule', degat=10, armure=100)
>>>print("degats bouclier:",bouclier.degat)
degats bouclier: 10
>>>print("armure bouclier:",bouclier.armure)
armure bouclier: 100
>>>print("bouclier.defend(214), degats restants:",bouclier.defend(214))
bouclier.defend(214), degats restants: 114
Les deux classes parentes ont une méthode __init__
mais Python ne peut en “copier” qu’une seule dans l’enfant. Il copie donc la première qu’il trouve. Il va prendre la liste des parents (class C(A, B):) et la lire de gauche à droite. Il va regarder chaque parent, et si la méthode __init__
existe la “copier“ dans l’enfant. Si il retrouve une méthode de même nom dans un des parents suivant, il l’ignore.
Dans notre exemple, si on fait:
class ProtectionOffensive(Arme, Protection):
pass
ProtectionOffensive
n’aura que la méthode __init__
de Arme
et ce n’est pas ce qu’on veut. On va donc override la méthode __init__
de ProtectionOffensive
, et dedans appeler la méthode __init__
de Arme
et de Protection
.
Cette syntaxe: Classe.methode(self, args...)
que l’on retrouve dans Arme.__init__(self, nom, degat)
est juste un moyen d’appeler spécifiquement la méthode du parent.
Dans la partie précédente nous avons vu qu’on pouvait faire cela avec super()
. Mais super()
nous retournera la première méthode du premier parent qu’elle trouve. C’est le but de super()
, de faire ça automatiquement sans se soucier de savoir qui est le premier parent à avoir une méthode du bon nom.
rappel syntaxe super()
:
class Enfant(Parent):
def calcule_complique(self, liste, ristourne):
prix = super().calcule_complique(liste)
return prix - (prix * ristourne / 100)
C’est utile car parfois c’est le parent du parent du parent qui a la méthode qu’on veut appeler, on ne connaît pas forcément son nom, ou alors on ne veut pas l’hard coder. Dans notre cas, on veut spécifiquement une méthode d’un parent en particulier, il faut donc l’écrire à la main.
De façon générale:
- On utilise
super()
quand on fait de l’héritage simple où qu’on veut juste appeler la méthode du premier parent venu sans se soucier de son nom (car il peut être très haut dans la chaîne d’héritage). - On utilise
Classe.methode(self, args...)
quand on veut spécifiquement appeler la méthode d’un parent en particulier.
ATTENTION
Le self
n’est pas au même endroit dans les cas suivants:
super().methode(args...)
$ \Rightarrow $ On passe la classe courante (quesuper()
va analyser pour trouver les parents automatiquement)ClasseParente.methode(self, args...)
$ \Rightarrow $ On hard code le nom de la classe parente.
Exemple récapitulatif
class A(object):
def __init__(self, name, val_a):
self.name = name
self.val_a = val_a
@property
def methodA(self):
return ("name: {}\tval_a: {}"
.format(self.name, self.val_a))
def methodForE(self):
return ("\nE don't know that A exist...")
class B(object):
def __init__(self, name, val_b):
self.name = name
self.val_b = val_b
@property
def methodB(self):
return ("name: {}\tval_b: {}"
.format(self.name, self.val_b))
class C(A,B):
def __init__(self, nom, val_a, val_b):
A.__init__(self, nom, val_a)
B.__init__(self, nom, val_b)
@property
def methodC(self):
return ("name: {}\tval_a: {}\tval_b: {}"
.format(self.name, self.val_a, self.val_b))
class D(C):
pass
class E(D):
@property
def methodForE(self):
first = super().methodForE()
return ("{}\n...but still can override a method from it."
.format(first))
objectA = A("objectA", "a")
objectB = B("objectB", "b")
objectC = C("objectC", "a(C)", "b(C)")
objectD = D("objectD", "a(D)", "b(D)")
objectE = E("objectE", "a(E)", "b(E)")
print(objectA.methodA)
print(objectB.methodB)
print(objectC.methodC)
print("\nobjectC also have acces to both methodA and methodB:")
print(objectC.methodA)
print(objectC.methodB)
output:
name: objectA val_a: a
name: objectB val_b: b
name: objectC val_a: a(C) val_b: b(C)
objectC also have acces to both methodA and methodB:
name: objectC val_a: a(C)
name: objectC val_b: b(C)
print("\nobjectD have acces to methodC:")
print(objectD.methodC)
print("\nobjectD also have acces to both methodA and methodB:")
print(objectD.methodA)
print(objectD.methodB)
output:
objectD have acces to methodC:
name: objectD val_a: a(D) val_b: b(D)
objectD also have acces to both methodA and methodB:
name: objectD val_a: a(D)
name: objectD val_b: b(D)
print("\nobjectE have acces to methodC:")
print(objectE.methodC)
print("\nobjectE also have acces to both methodA and methodB:")
print(objectE.methodA)
print(objectE.methodB)
print(objectE.methodForE)
output:
objectE have acces to methodC:
name: objectE val_a: a(E) val_b: b(E)
objectE also have acces to both methodA and methodB:
name: objectE val_a: a(E)
name: objectE val_b: b(E)
E don't know that A exist...
...but still can override a method from it.
La composition ou…
… comment faire interagire plusieurs objets entre eux.
Revenons à notre exemple de jeu vidéo et ajoutons une classe:
class Heros(object):
def __init__(self, nom, vie, arme=None, protection=None):
self.nom = nom
self.vie = vie
self.arme = arme
self.protection = protection
def combattre(self, ennemi):
print("{} attaque {}".format(self.nom, ennemi.nom))
while True:
if self.arme:
self.arme.attaque(ennemi)
if ennemi.vie <= 0:
break
if enemi.arme:
ennemi.arme.attaque(self)
if self.vie <= 0:
break
if self.vie > 0:
print("Victoire de {}".format(self.nom))
else:
print("{} est mort".format(self.nom))
Notons que nous n’avons pas de méthode attaque()
sur notre héros. Nous utilisons la méthode attaque d’un objet Arme
qui est un attribut de la classe Heros
.
La composition c’est ça, un objet qui en fait est composé de plusieurs sous-objets. Dans notre cas, notre objet de type Hero
est aussi composé d’une arme et d’une protection, qui sont ses attributs. Il peut ainsi utiliser le comportement de ces onjets pour faire le boulot à sa place: **c’est ce qu’on appelle la délégation.
Reprenons notre code des armes un peu adapté:
# On change le code de l'arme, si la cible a une protection
# cela diminue les dégâts pris
class Arme(object):
def __init__(self, nom, degat):
self.nom = nom
self.degat = degat
def attaque(self, cible):
if cible.protection:
degat = cible.protection.defend(self.degat)
print("{} - {} = {}"
.format(cible.vie, degat, cible.vie - degat))
cible.vie -= degat
else:
print("{} - {} = {}"
.format(cible.vie, self.degat, cible.vie - self.degat))
cible.vie -= self.degat
# Le code de l'armure ne bouge pas
class Protection(object):
def __init__(self, nom, armure):
self.nom = nom
self.armure = armure
def defend(self, degat):
degat = degat - self.armure
if degat < 0:
return 0
return degat
Et maintenant créons deux héros, armons les et faisons-les combattre:
gnark = Hero("Gnark", 2000)
gnark.arme = Arme("Lame T-Rex", 10)
gnark.protection = Protection("Plastron dur", 10)
kaco = Hero("Kaco", 50)
kaco.arme = Arme("fourchette", 1)
kaco.protection = Protection("Slip", 1)
kaco.combattre(gnark)
output:
Kaco attaque Gnark!
2000 - 0 = 2000
50 - 9 = 41
2000 - 0 = 2000
41 - 9 = 32
2000 - 0 = 2000
32 - 9 = 23
2000 - 0 = 2000
23 - 9 = 14
2000 - 0 = 2000
14 - 9 = 5
2000 - 0 = 2000
5 - 9 = -4
Kaco est mort comme un noob.
Dans le détail
Analysons la méthode combattre:
# ...
# Elle attend un ennemi en paramètrem donc un objet Hero
# self est l'objet en cours, donc aussi un objet Hero
def combattre(self, ennemi):
print("{} attaque {}".format(self.nom, ennemi.nom))
# Une boucle infinie. Cette boucle loop pour
# toujours si il n'y a pas d'attribut arme !
while True:
# On donne le premier coup à la personne qui attaque
# (l'objet en cours). On vérifie qu'il a une arme.
# Si c'est le cas, on appelle la méthode de l'arme
# "attaque()", et on lui passe en paramètre l'ennemi.
if self.arme:
self.arme.attaque(ennemi)
# Condition de sortie de la boucle sur
# la vie du héro qui pris le coup.
if ennemi.vie <= 0:
break
# Ensuite on fait pareil à l'envers pour donner une
# change à l'adversaire de répliquer: on vérifie que
# l'ennemi a une arme, et si c'est le cas, on applique
# la méthode "attaque()" de l'arme à l'objet en cours
if ennemi.arme:
ennemi.arme.attaque(self)
# Condition de sortie de la boucle sur
# la vie du héro qui a pris le coup.
if self.vie <= 0:
break
# Une fois sorti de la boucle, on vérifie le
# niveau de vie pour désigner le vainqueur.
if self.vie > 0:
print("Victoire de {}!".format(self.nom))
else:
print("{} est mort!".format(self.nom))
# ...
Donc combattre()
utilise un objet Arme
, et appelle la méthode attaque()
d’Arme
sur un objet Heros
:
# ...
# self est l'objet en cours, donc l'arme.
# cible est un objet de la classe Hero qu'on passe en paramètre.
def attaque(self, cible):
# Si la cible (objet Hero) a un attribut protection, les dégâts
# retirés sont diminués (ce calcul est fait par la protection).
if cible.protection:
cible.vie -= cible.protection.defend(self.degat)
# Sinon, on retire les dégâts à la vie de la cible (Hero) directement.
else:
cible.vie -= self.degat
# ...
La méthode attaquer utilise elle-même la méthode defend()
de l’objet de type Protection
:
# ...
# self est l'objet en cours, donc protection.
# degat est un simple int.
def defend(self, degat):
# On retourne les degâts infligés, moins la protection
return degat - self.armure
# ...
Il y a deux choses a bien comprendre ici:
-
Il y a 6 objets. Deux de type
Hero
qui possèdent chacun un objet de typeArme
et un objet de typeProtection
en attributs. -
On se sert des méthodes des objets de type
Armes
pour attaquer. On passe les objets de typeHero
en paramètre de ces méthodes. Les objets de typeArme
se servent des objets de typeProtection
que porte les objets de typeHero
pour calculer les dégats.
Ce dernier point est le plus important et la clé de voute de la compréhension de la POO. On le reprend:
- Les objets de type
Hero
ont une référence aux objets de typeArme
- On passe une référence des objets de type
Hero
aux objets de typeArme
- Les objets de type
Arme
retirent de la vie à ces objets de typeHero
mais calculent avant les dégats en fonction des objets de typeProtection
que les objets de typeHero
portent.
Les objets ont tous des références les uns vers les autres. Ils se manipulent tous les uns les autres.
Cela fait bizarre car dans la vie une épée ne manipule pas un héros. On comprend facilement qu’un héros ait un attribut épée mais il est difficile de comprendre qu’une épée ait une méthode, et que le paramètre de cette méthode soit un héros.
C’est un concepte purement informatique: La logique des dégâts est codée dans l’arme, pas dans le héros. L’avantage de cette architecture, c’est que si on change d’arme, on peut changer la logique des dégâts. Par exemple, on peut rajouter une arme empoisonnée:
class ArmeEmpoisonee(Arme):
def __init__(self, nom, degat, poison=10000):
super().__init__(nom, degat)
self.poison = poison
def attaque(self, cible):
# degats de l'arme normale
super().attaque(cible)
# defats ajoutés du poison
cible.vie -= self.poison
Le mécanisme pour faire des dégâts de cette arme est différent. Il suffit d’équiper un héros avec une instance de cette arme (en changeant l’attribut) pour que ce nouveau calcul de dégats soit pris en compte:
kaco = Hero("Kaco", 50)
kaco.arme = ArmeEmpoisonee("Cheat", 1)
kaco.protection = Protection("Slip", 1)
kaco.combattre(gnark) # Vengeance !
output:
Kaco attaque Gnark!
2000 - 0 = 2000
Victoire de Kaco!
Ce qu’il faut retenir: On peut mettre des objets en tant qu’attributs d’autres objets. Il n’y a pas de limite dans le nombres d’objets, leur mélange, les niveau d’imbrication, etc… On peut mettre des objets, dans des objets, dans des objets,… C’est la composition.
Les objets peuvent utiliser les méthodes des autres objets. Et on peut passer des objets comme paramètres à des méthodes. C’est la délégation.
On peut biensur mettre des objets dans des sets, des dicos, des listes, … ps juste dans des attributs. Il y a un tas de chose à faire avec eux…
Choisir entre l’héritage et la composition
Les deux techniques permettent de réutiliser du code, mis pas de la même façon. Aucune règle générale ne tient la route dans tous les cas, mais un bon poinr de départ est de se dire que:
-
Si nous avons deux objets de même nature, et qu’un est une spécialisation de lautre (Garçon est une spécialisation de Personne, Voiture une spécialisation de Véhicule, Clio une spécialisation de voiture,…) alors on préfèrera l’héritage.
-
Si nous avons deux objets qui échangent des données, qui sont associés ou qui dans la vie réelle sont des “parts de” (Un article est une partie d’un blog, Un coeur est une partie d’un corp,…) alors on préfèrera la composition.
Ne pas oublier la différence entre aggrégation et composition vue en C++. Mais on vivra très bien sans pour le moment.
Deign pattern “stratégie”
Le motif de conception (design pattern) “stratégie”, et une mise en application abstraite de la composition.
Normalement, la composition s’utilise avec des “part de” concrètes. Une voiture est composée d’objets pneus, d’un objet moteur, etc …
Le defign pattern stratégie est l’extraction d’un part du comportement d’un objet pour le mettre dans un autre objet. Mais la nature de l’objet importe peu. Ceci est fait purement pour découpler le comportement de l’objet.
On a vu plus haut que changer l’arme permet de changer le calcul des dégâts. C’est ce type de résultat qu’on vise avec le design pattern strategy.
import os
class ParseurXml(object):
# ...
pass
class ParseurJson(object):
# ...
pass
class ParseurDeFichier(object):
_strategy = { # les stratégies par défaut
'json': ParseurXml,
'xml' : ParseurJson
}
def __init__(self, fichier, strategy=None):
self.fichier = fichier
# On récupère l'extension du fichier
path, ext = os.path.splitext(fichier)
# Strategy est une classe de parseur
# on la récupère depuis les paraètres
# ou selon l'extension.
Strategy = strategy or self._strategy[ext.lstrip('.')]
# On instancie notre classe de strategie
self.strategy = Strategy(fichier)
def parse(self):
# On délègue le boulot à la stratégie
self.strategy.parse()
La ligne la plus importante est:
Strategy = strategy or self._strategy[ext]
Ici on dit récupère la stratégie de parsing en paramètre, ou si non, la bonne en fonction de l’extention du fichier. On charge donc une classe dynamiquement, on va créer un objet à partir de cette classe. Et c’est cet objet à qui on va déléguer le comportement du parseur:
def parse(self):
self.strategy.parse()
On utilise l’objet dynamiquement pour gérer tout le parsing. On peut ainsi choisir un parseur à la volée.
La pattern strategy mélange donc composition (l’objet strategy est une part de l’objet général), délégation (l’objet général utilise le comportement de l’objet strategy) et d’injection de dépendance (on peut changer l’algorithme à la volée, il suffit de changer de stratégie).