Le modèle de stratégie expliqué en utilisant Java

Dans cet article, je parlerai de l'un des modèles de conception les plus populaires - le modèle de stratégie. Si vous n'êtes pas déjà au courant, les modèles de conception sont un ensemble de principes de programmation orientés objet créés par des noms notables de l'industrie du logiciel, souvent appelés le Gang of Four (GoF). Ces modèles de conception ont eu un impact énorme sur l'écosystème logiciel et sont utilisés à ce jour pour résoudre les problèmes courants rencontrés dans la programmation orientée objet.

Définissons formellement le modèle de stratégie:

Le modèle Stratégie définit une famille d'algorithmes, encapsule chacun d'eux et les rend interchangeables. La stratégie permet à l'algorithme de varier indépendamment des clients qui l'utilisent

Très bien avec cela, plongons dans un code pour comprendre ce que ces mots signifient VRAIMENT. Nous prendrons un exemple avec un piège potentiel, puis appliquerons le modèle de stratégie pour voir comment il surmonte le problème.

Je vais vous montrer comment créer un programme de simulation de chien dopé pour apprendre le modèle de stratégie. Voici à quoi ressembleront nos classes: Une superclasse «Dog» avec des comportements communs puis des classes concrètes de Dog créées en sous-classant la classe Dog.

Voici à quoi ressemble le code

public abstract class Dog { public abstract void display(); //different dogs have different looks! public void eat(){} public void bark(){} // Other dog-like methods ... }

La méthode display () est rendue abstraite car différents chiens ont des regards différents. Toutes les autres sous-classes hériteront des comportements manger et aboyer ou les remplaceront par leur propre implémentation. Jusqu'ici tout va bien!

Maintenant, que faire si vous vouliez ajouter un nouveau comportement? Disons que vous avez besoin d'un chien robot cool qui peut faire toutes sortes de tours. Pas de problème, nous avons juste besoin d'ajouter une méthode performTricks () dans notre superclasse Dog et nous sommes prêts à partir.

Mais attendez une minute… Un chien robot ne devrait pas pouvoir manger correctement? Les objets inanimés ne peuvent pas manger, bien sûr. D'accord, comment pouvons-nous résoudre ce problème alors? Eh bien, nous pouvons remplacer la méthode eat () pour ne rien faire et cela fonctionne très bien!

public class RobotDog extends Dog { @override public void eat(){} // Do nothing }

Bien fait! Désormais, les chiens robots ne peuvent pas manger, ils ne peuvent qu'aboyer ou exécuter des tours. Mais qu'en est-il des chiens en caoutchouc? Ils ne peuvent pas manger ni exécuter des tours. Et les chiens en bois ne peuvent pas manger, aboyer ou exécuter des tours. Nous ne pouvons pas toujours remplacer les méthodes pour ne rien faire, ce n'est pas propre et cela semble juste piraté. Imaginez faire cela sur un projet dont les spécifications de conception changent tous les quelques mois. Le nôtre n'est qu'un exemple naïf mais vous voyez l'idée. Nous devons donc trouver un moyen plus propre de résoudre ce problème.

L'interface peut-elle résoudre notre problème?

Et les interfaces? Voyons s'ils peuvent résoudre notre problème. D'accord, nous créons donc une interface CanEat et CanBark:

interface CanEat { public void eat(); } interface CanBark { public void bark(); }

Nous avons maintenant supprimé les méthodes bark () et eat () de la superclasse Dog et les avons ajoutées aux interfaces respectives. Ainsi, seuls les chiens qui peuvent aboyer implémenteront l'interface CanBark et les chiens qui peuvent manger implémenteront l'interface CanEat. Maintenant, plus de soucis pour les chiens héritant d'un comportement qu'ils ne devraient pas, notre problème est résolu… ou est-ce?

Que se passe-t-il lorsque nous devons modifier le comportement alimentaire des chiens? Disons qu'à partir de maintenant, chaque chien doit inclure une certaine quantité de protéines avec son repas. Vous devez maintenant modifier la méthode eat () de toutes les sous-classes de Dog. Et s'il y avait 50 classes de ce genre, oh l'horreur!

Ainsi, les interfaces ne résolvent que partiellement notre problème de chiens ne faisant que ce qu'ils sont capables de faire - mais elles créent un autre problème. Les interfaces n'ont pas de code d'implémentation, il n'y a donc aucune réutilisabilité du code et le potentiel de nombreux codes dupliqués. Comment pouvons-nous résoudre ce problème, demandez-vous? Le modèle de stratégie vient à la rescousse!

Le modèle de stratégie

Nous allons donc faire cela étape par étape. Avant de continuer, permettez-moi de vous présenter un principe de conception:

Identifiez les parties de votre programme qui varient et séparez-les de ce qui reste le même.

C'est en fait très simple - le principe stipule qu'il faut séparer et «encapsuler» tout ce qui change fréquemment afin que tout le code qui change se trouve au même endroit. De cette façon, le code qui change n'aura aucun effet sur le reste du programme et notre application est plus flexible et robuste.

Dans notre cas, le comportement «aboyer» et «manger» peut être retiré de la classe Chien et peut être encapsulé ailleurs. Nous savons que ces comportements varient selon les chiens et qu'ils doivent avoir leur propre classe distincte.

Nous allons créer deux ensembles de classes en dehors de la classe Dog, une pour définir le comportement alimentaire et une pour le comportement d'aboiement. Nous utiliserons des interfaces pour représenter le comportement telles que 'EatBehavior' et 'BarkBehavior' et la classe de comportement concrète implémentera ces interfaces. Ainsi, la classe Dog n'implémente plus l'interface. Nous créons des classes séparées dont le seul travail est de représenter le comportement spécifique!

Voici à quoi ressemble l'interface EatBehavior

interface EatBehavior { public void eat(); }

Et BarkBehavior

interface BarkBehavior { public void bark(); }

Toutes les classes qui représentent ces comportements implémenteront l'interface respective.

Classes de béton pour BarkBehavior

public class PlayfulBark implements BarkBehavior { @override public void bark(){ System.out.println("Bark! Bark!"); } } public class Growl implements BarkBehavior { @override public void bark(){ System.out.println("This is a growl"); } public class MuteBark implements BarkBehavior { @override public void bark(){ System.out.println("This is a mute bark"); }

Classes concrètes pour EatBehavior

public class NormalDiet implements EatBehavior { @override public void eat(){ System.out.println("This is a normal diet"); } } public class ProteinDiet implements EatBehavior { @override public void eat(){ System.out.println("This is a protein diet"); } }

Maintenant que nous faisons des implémentations concrètes en sous-classant la superclasse 'Dog', nous voulons naturellement pouvoir attribuer dynamiquement les comportements aux instances des chiens. Après tout, c'était la rigidité du code précédent qui causait le problème. Nous pouvons définir des méthodes de définition sur la sous-classe Dog qui nous permettront de définir différents comportements lors de l'exécution.

Cela nous amène à un autre principe de conception:

Programmez sur une interface et non sur une implémentation.

Cela signifie qu'au lieu d'utiliser les classes concrètes, nous utilisons des variables qui sont des supertypes de ces classes. En d'autres termes, nous utilisons des variables de type EatBehavior et BarkBehavior et attribuons à ces variables des objets de classes qui implémentent ces comportements. De cette façon, les classes Dog n'ont pas besoin d'avoir d'informations sur les types d'objets réels de ces variables!

Pour clarifier le concept, voici un exemple qui différencie les deux manières - Prenons une classe animale abstraite qui a deux implémentations concrètes, Dog et Cat.

La programmation d'une implémentation serait:

Dog d = new Dog(); d.bark();

Voici à quoi ressemble la programmation d'une interface:

Animal animal = new Dog(); animal.animalSound();

Ici, nous savons que l'animal contient une instance d'un 'Chien' mais nous pouvons utiliser cette référence polymorphiquement partout ailleurs dans notre code. Tout ce qui nous importe, c'est que l'instance animale soit capable de répondre à la méthode animalSound () et que la méthode appropriée, en fonction de l'objet assigné, soit appelée.

C'était beaucoup à assimiler. Sans plus d'explications, voyons à quoi ressemble notre superclasse «Chien» maintenant:

public abstract class Dog { EatBehavior eatBehavior; BarkBehaviour barkBehavior; public Dog(){} public void doBark() { barkBehavior.bark(); } public void doEat() { eatBehavior.eat(); } }

Faites très attention aux méthodes de cette classe. La classe Dog est maintenant en train de «déléguer» la tâche de manger et d'aboyer au lieu de l'implémenter seule ou d'en hériter (sous-classe). Dans la méthode doBark (), nous appelons simplement la méthode bark () sur l'objet référencé par barkBehavior. Maintenant, nous ne nous soucions pas du type réel de l'objet, nous nous soucions seulement de savoir s'il sait aboyer!

Maintenant, le moment de vérité, créons un chien en béton!

public class Labrador extends Dog { public Labrador(){ barkBehavior = new PlayfulBark(); eatBehavior = new NormalDiet(); } public void display(){ System.out.println("I'm a playful Labrador"); } ... }

What’s happening in the constructor of the Labrador class? we are assigning the concrete instances to the supertype (remember the interface types are inherited from the Dog superclass). Now, when we call doEat() on the Labrador instance, the responsibility is handed over to the ProteinDiet class and it executes the eat() method.

The Strategy Pattern in Action

Alright, let’s see this in action. The time has come to run our dope Dog simulator program!

public class DogSimulatorApp { public static void main(String[] args) { Dog lab = new Labrador(); lab.doEat(); // Prints "This is a normal diet" lab.doBark(); // "Bark! Bark!" } }

How can we make this program better? By adding flexibility! Let’s add setter methods on the Dog class to be able to swap behaviors at runtime. Let’s add two more methods to the Dog superclass:

public void setEatBehavior(EatBehavior eb){ eatBehavior = eb; } public void setBarkBehavior(BarkBehavior bb){ barkBehavior = bb; }

Now we can modify our program and choose whatever behavior we like at runtime!

public class DogSimulatorApp { public static void main(String[] args){ Dog lab = new Labrador(); lab.doEat(); // This is a normal diet lab.setEatBehavior(new ProteinDiet()); lab.doEat(); // This is a protein diet lab.doBark(); // Bark! Bark! } }

Let’s look at the big picture:

We have the Dog superclass and the ‘Labrador’ class which is a subclass of Dog. Then we have the family of algorithms (Behaviors) “encapsulated” with their respective behavior types.

Take a look at the formal definition that I gave at the beginning: the algorithms are nothing but the behavior interfaces. Now they can be used not only in this program but other programs can also make use of it. Notice the relationships between the classes in the diagram. The IS-A and HAS-A relationships can be inferred from the diagram.

That’s it! I hope you have gotten a big picture overview of the Strategy pattern. The Strategy pattern is extremely useful when you have certain behaviors in your app that change constantly.

This brings us to the end of the Java implementation. Thank you so much for sticking with me so far! If you are interested to learn about the Kotlin version, stay tuned for the next post. I talk about interesting language features and how we can reduce all of the above code in a single Kotlin file :)

P.S

I have read the Head First Design Patterns book and most of this post is inspired by its content. I would highly recommend this book to anyone who is looking for a gentle introduction to Design Patterns.