Reprenons l’histoire de Pythonia, notre pizzeria ! 🍕 🤖
Chez Pythonia, les serveurs et serveuses robotisés sont très compétents. Ils appliquent le principe de responsabilité unique en ne faisant qu’un type de chose (servir les clients).
Petit souci : ils ne parlent que français. Imaginons que nous souhaitions les former à communiquer en italien, pour le confort de tous les clients italophones. 🇮🇹
Cette nouvelle compétence ne doit pas modifier leurs compétences existantes – ils apporteront toujours les pizzas de la même façon, et accueilleront les clients francophones de la même façon. Mais, après leur formation à l’italien, ils auront étendu leur éventail de compétences.
Nous pourrions du coup dire que les serveurs et serveuses sont ouverts à l’extension, mais fermés à la modification.
Pourrait-on appliquer la même idée aux classes en Python ?
Bien vu ! Ceci nous amène au principe ouvert/fermé, qui déclare que :
Une classe doit être ouverte à l’extension, mais fermée à la modification.
Pourquoi utiliser le principe ouvert/fermé ?
Si vous ne modifiez pas le code existant, vous ne risquez pas de casser ce qui marche. L’ensemble de la nouvelle fonctionnalité est contenu dans la ou les fonction(s) et classe(s) nouvellement ajoutée(s).
Le plus difficile avec ce principe, c’est de reconnaître quand il peut être appliqué avant de commencer à coder. Voici deux lignes directrices pour vous aider à reconnaître quand vous pouvez envisager d’appliquer le principe ouvert/fermé.
Lorsque vous avez des algorithmes qui accomplissent des calculs (coût, taxe, score dans un jeu, etc.) : il est probable que l’algorithme change au fil du temps.
Lorsque vous avez des données qui entrent ou sortent du système : la destination finale (fichier, base de données, autre système) changera probablement, de même que le format concret des données.
Comment appliquer le principe ouvert/fermé à notre code ?
L’évaluation du vainqueur est susceptible d’être modifiée. Au chapitre précédent, nous avons créé une fonction evaluate_game
dans la classe Controller
. Et si nous changions le jeu pour que ce soit la plus petite carte qui gagne ? Alors, nous devrons modifier Controller
– peut-être en ajoutant un paramètre booléen pour dire « trouver le vainqueur avec la plus haute carte » vs « trouver le vainqueur avec la plus petite carte ».
class GameController:
def __init__(self, deck, view, high_card_wins=True):
# Model
self.players = []
self.deck = deck
# View
self.view = view
# Controller
self.high_card_wins = high_card_wins
def evaluate_game(self):
best_rank = None
best_rank_suit = None
best_candidate = None
for player in self.players:
this_rank = RANKS[player.hand.card_by_index(0).rank]
this_suit = SUITS[player.hand.card_by_index(0).suit]
if (best_rank is None
or (self.high_card_wins
and ((this_rank > best_rank)
or (this_rank == best_rank
and this_suit > best_rank_suit)))
or (not self.high_card_wins
and ((this_rank < best_rank)
or (this_rank == best_rank
and this_suit < best_rank_suit)))
):
best_candidate = player.name
best_rank = this_rank
best_rank_suit = this_suit
return best_candidate
Oh la la, c’est déjà bien plus compliqué ! Mais que se passerait-il si nous voulions ajouter encore plus de règles ? 😨 Cette classe deviendrait difficile à comprendre, à tester, et à maintenir.
De plus, tous ceux qui utilisent notre classe Controller
doivent désormais implémenter ce paramètre booléen. Mauvaise idée.
Alors, que faire ?
En réalité, la méthode d’évaluation du vainqueur doit être définie hors de la classe Controller
, dans une classe ou fonction séparée.
Nous pouvons créer une classe pour faire l’évaluation, et en passer une instance dans Controller
. Puis, evaluate_game
appellera simplement la méthode pertinente depuis cet objet.
Ensuite, lorsque nous ajouterons des règles alternatives, comme la victoire de la plus petite carte, nous pourrons simplement créer un LowCardGameChecker
(ÉvaluateurDuJeuDePetiteCarte), et l’utiliser à la place.
Essayons ensemble !
Voici le lien vers le code.
Alors, est-ce que nous suivons bien les principes SOLID à ce stade ?
Controller
a toujours une responsabilité unique.CheckerRankAndSuitIndex
est une nouvelle classe, qui possède aussi une responsabilité unique très claire.Controller
reste ouvert à l’extension des règles d’évaluation du vainqueur, mais fermé à la modification. Des règles alternatives d’évaluation du vainqueur peuvent être ajoutées sans impacter les implémentations existantes.
Attendez une seconde – on pourrait jouer à un jeu ayant des règles plus complexes, par exemple si la victoire dépendait d’autre chose, comme des cartes restant dans la pile… Nous devrions alors quand même modifier la classe Controller
!
Oui, vous avez raison. En fait, il n’est pas réaliste d’écrire du code qui n’aura jamais besoin d’être modifié, et parfois des modifications entraînant une rupture sont réellement nécessaires. Elles nécessitent de modifier d’autres parties du code pour qu’elles s’adaptent à vos changements.
En fait ce qu'il faut retenir, c'est que plus vous suivez de près le principe ouvert/fermé, moins ces changements seront fréquents et pénibles.
En résumé
Le principe ouvert/fermé déclare que les classes doivent être ouvertes à l’extension, mais fermées à la modification.
Dans les systèmes existants, vous pourrez avoir à retravailler le code pour lui permettre de bénéficier du principe ouvert/fermé.
Au chapitre suivant, nous nous intéresserons aux moyens d’éviter de mauvaises utilisations de l’héritage, avec la substitution de Liskov.