Py: OOP 5 Héritage multiple, composition, délégation
Py: OOP 5 Héritage multiple, composition, délégation

Programmation orienté objet

Directory

serie

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 (que super() 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 type Arme et un objet de type Protection en attributs.

  • On se sert des méthodes des objets de type Armes pour attaquer. On passe les objets de type Hero en paramètre de ces méthodes. Les objets de type Arme se servent des objets de type Protection que porte les objets de type Hero 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:

  1. Les objets de type Hero ont une référence aux objets de type Arme
  2. On passe une référence des objets de type Hero aux objets de type Arme
  3. Les objets de type Arme retirent de la vie à ces objets de type Hero mais calculent avant les dégats en fonction des objets de type Protection que les objets de type Hero 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).

Suite