jeudi 17 mars 2011

Traits, philosophie et stratégie en Scala

Les traits en Scala sont vraiment intéressants. Cette fois, j'ai envie de m'en servir pour ajouter des comportement à des classes.

Le comportement, c'est "Philosophe" et je veux l'ajouter à... à des grenouilles. Ce comportement se traduira par une méthode “philosophe”, sans arguments et qui renverra la String "Je pense donc je suis".

Commençons avec une Grenouille:
class Grenouille(nom:String) {
   def croasse() = "Croua, croua! dit "+nom
}

val g = new Grenouille(“Reinette”)
println(g croasse)
Croua, croua! dit Reinette

(Quand je disais que c’était bête...)

Et maintenant, voici le comportement “Philosophe”, défini dans un trait:
trait Philosophe {
   def philosophe() = "Je pense donc je suis"
}

Si je veux une Grenouille philosophe, il me suffit d’écrire:
class GrenouillePhilosophe(nom:String) extends Grenouille(nom) with Philosophe
Ce qui me donne les résultats suivants:
val g = new GrenouillePhilosophe(“Reinette”)
println(g croasse)
Croua, croua! dit Reinette
println(g philosophe)
Je pense donc je suis

L’intérêt du comportement, c’est que je peux l’ajouter à n’importe quoi.

A une vache par exemple:
class Vache(nom:String) {
   def meugle() = "Meuhh! dit "+nom
}

class VachePhilosophe(nom:String) extends Vache(nom) with Philosophe

Ou encore à un rocher:
class Rocher(nom:String) {
   def roule() = nom + " n'amasse pas mousse"
}

class RocherPhilosophe(nom:String) extends Rocher(nom) with Philosophe

Je vous passe les arbres, les astres... (mais si un jour quelqu’un doit écrire une application dans laquelle des grenouilles et des rochers deviennent philosophes, qu’il me contacte !)

L'équivalent en Java

Le code Scala se compile en bytecode exécutable par une JVM. La question est alors "comment obtenir le même effet en Java?"
Soit trois classes de base à transformer en philosophes. Les voici, toutes à la suite les unes des autres:
public class Grenouille {
   private final String nom;
   
   public Grenouille(String nom){
       this.nom = nom;
   }
   
   public String croasse(){
       return "Croua, croua! dit "+nom;
   }
}

public class Vache {
   private final String nom;
   
   public Vache(String nom){
       this.nom = nom;
   }
   
   public String meugle(){
       return "Meuhh! dit "+nom;
   }
}

public class Rocher {
   private final String nom;
   
   public Rocher(String nom){
       this.nom = nom;
   }
   
   public String roule(){
       return nom + " n'amasse pas mousse";
   }
}
A noter au passage la longueur du code Java par rapport à celui en Scala...

A présent, imaginons les différentes façons d'ajouter un comportement "philosophe" à nos classes. La bonne solution devrait sauter aux yeux de tout le monde, mais l’expérience m’a appris que ce n’était pas toujours le cas.

L’héritage

Par exemple:
public class GrenouillePhilosophe extends Grenouille {
   public GrenouillePhilosophe(String nom){
       super(nom);
   }
   
   public String philosophe(){
       return "Je pense donc je suis";
   }
}

Si je pose la question autour de moi, je suis sûr que c’est la réponse qui va revenir le plus souvent. Les développeurs orientés objet aiment l’héritage, qu’ils considèrent comme le fond même de l’orienté objet. Il faut reconnaître que cette méthode semble fonctionner.

Je peux bien sûr l’appliquer à Vache et à Rocher (mais je vous passe le code).

Néanmoins, voici quelques objections:
  1. Java ne permet pas l’héritage multiple. Dans un exemple aussi simple, ça ne pose pas de problème, mais dans une structure complexe, composée de différentes hiérarchies de classes, cette méthode ne sera pas possible.
  2. Chaque classe implémente sa méthode “philosophe”. D’où un coût de maintenance élevé s’il faut modifier le comportement, avec des risques d’erreur.
  3. Les classes “philosophes” ne sont pas de type Philosophe. Elles n’ont rien en commun. En Scala, je peux écrire le code suivant:
    val phi = Set(new VachePhilosophe("Rita"), new GrenouillePhilosophe("Reinette"), new RocherPhilosophe("Pierre"))
    phi.foreach((p) =>println(p philosophe))
    Je pense donc je suis
    Je pense donc je suis
    Je pense donc je suis
    Dans la solution proposée ci-dessus en Java, c’est impossible (à moins de passer par la réflexion).

Bref, cette solution n’est pas équivalente à Scala.

Implémentation d’une interface

Etre Philosophe, c’est implémenter l’interface suivante:
public interface Philosophe {
   String philosophe();
}
Voici son utilisation:
public class VachePhilosophe extends Vache implements Philosophe {
   public VachePhilosophe(String nom) {
       super(nom);
   }
   
   public String philosophe() {
       return "Je pense donc je suis";
   }
}

Cette solution a le mérite de résoudre deux des précédentes objections: le problème de l’héritage multiple est résolu et toutes les classes “philosophes” sont bien des Philosophe(s).

En fait, on très proche des traits Scala qui sont souvent proposés en remplacement des interfaces (lesquelles n’existent pas en Scala).

Il reste cependant la dernière objection: chaque classe devra implémenter sa méthode philosophe, avec duplication de code et problèmes de maintenance.

C’est mieux, mais pas encore ça.

La composition "simple"

A la base, la composition permet d’encapsuler un comportement dans une classe et d’utiliser cette classe pour "hériter" du comportement.

Par exemple, le comportement du philosophe est le suivant:
public class Philosophe {
   public String philosophe(){
       return "Je pense donc je suis";
   }
}
La création d’un Rocher philosophe, se ferait ainsi:
public class RocherPhilosophe extends Rocher {
   private Philosophe philosopheComportement = new Philosophe();
   
   public RocherPhilosophe(String nom) {
       super(nom);
   }
   
   public String philosophe(){
       return philosopheComportement .philosophe();
   }
}

Est-ce mieux? Pas vraiment. Certes, le comportement est encapsulé et s’il est modifié, les modifications seront répercutées automatiquement. On peut éventuellement objecter qu’une partie de code sera dupliquée (l’utilisation de la composition), mais c'est finalement peu de chose.

Par contre, notre RocherPhilosophe n’est pas un Philosophe. Petit problème simple à résoudre...

La composition améliorée



C’est beaucoup mieux à présent:
  • le comportement "philosophe" est isolé dans une classe, comme avec la composition simple ci-dessus
  • notre RocherPhilosophe est un Philosophe
  • cette relation “isA”, parce qu’elle vient de l’implémentation d’une interface, n’interfère pas avec l’héritage

Il reste un point noir: dans chaque classe “Philosophe” (comme RocherPhilosophe), il faudra implémenter la logique de composition. Je ne vois pas de solution en Java.

A ce niveau, le trait de Scala est bien sûr supérieur.

Une petite remarque en passant: il n’est pas vraiment nécessaire que PhilosopheImpl et RocherPhilosophe implémentent la même interface (Philosophe). Le but de l'exercice était de trouver l'équivalent d'une utilisation des traits Scala en Java, ce à quoi répond le schéma ci-dessus.

Néanmoins, il y a mieux à faire, car ce schéma a des airs de pattern Strategy. Alors, poussons la logique plus loin, même si cela nous éloigne momentanément de code Scala initial.

Pattern strategy

Les philosophes que sont nos vaches, grenouilles ou rochers peuvent appartenir à différentes écoles de pensée. Tous sont philosophes, mais philosophent différemment. Mieux, ce comportement (leur école) peut être modifié au runtime.

Voici l’implémentation Java (toutes les classes à la suite des unes des autres, à l'exception des classes de base déjà écrites plus haut):
public abstract class EcolePhilosophique {
   public abstract String penser();
}

public class EcoleCartesienne extends EcolePhilosophique {
   public String penser() {
       return "Je pense donc je suis";
   }
}

public class EcoleExistentialiste extends EcolePhilosophique {
   public String penser() {
       return "Le faire est révélateur de l'être";
   }
}

public interface Philosophe {
   String philosophe();
}

public class VachePhilosophe extends Vache implements Philosophe {
   private EcolePhilosophique ecolePhilosophique = new EcoleExistentialiste();

   public VachePhilosophe(String nom) {
       super(nom);
   }
   
   public String philosophe() {
       return ecolePhilosophique.penser();
   }
}

public class GrenouillePhilosophe extends Grenouille implements Philosophe {
   private EcolePhilosophique ecolePhilosophique = new EcoleCartesienne();

   public GrenouillePhilosophe(String nom) {
       super(nom);
   }
   
   public String philosophe() {
       return ecolePhilosophique.penser();
   }
}

L’implémentation est simple. Ici, la séparation entre la stratégie (l’école philosophique) et le contexte (les objets qui l’utilisent) est claire. Le comportement est défini de manière statique, mais il est possible d'apporter quelques modifications au code pour pouvoir le changer au runtime.

Le seul point noir est que la composition doit encore une fois être explicitement écrite.

Est-ce que Scala apporte une solution?

Implémentation du pattern Strategy en Scala

Le voilà donc, tel qu’il est décrit dans le schéma. Les classes Vache, Grenouille et Rocher sont celles données tout au début de cet article.

abstract class EcolePhilosophique {
   def penser() : String
}

class EcoleCartesienne extends EcolePhilosophique {
   override def penser () = "Je pense donc je suis"
}

class EcoleExistentialiste extends EcolePhilosophique {
   override def penser () = "Le faire est révélateur de l'être"
}

trait Philosophe {
   def ecolePhilosophie: EcolePhilosophique
   def philosophe() = ecolePhilosophie.penser
}

class GrenouillePhilosophe(nom:String) extends Grenouille(nom) with Philosophe{
   def ecolePhilosophie = new EcoleCartesienne
}

class VachePhilosophe(nom:String) extends Vache(nom) with Philosophe{
   def ecolePhilosophie = new EcoleExistentialiste
}

Quelques remarques:
  • la stratégie (l’école) est choisie lors de la construction de l’objet et est donc statique. Néanmoins, le code peut être facilement modifié pour que le comportement soit modifiable dynamiquement.
  • EcolePhilosophique est une classe abstraite. Elle pourrait être un trait, mais je n’en ai pas vu l’intérêt. De plus, dans le schéma UML, elle n’est pas définie comme une interface.

Grâce à l’utilisation du trait “Philosophe”, les classes “philosophes” ne doivent plus implémenter la logique de la composition (une référence vers l’école et l’invocation de la méthode “penser” dans l’implémentation de la méthode “philosophe”). A cause de cela, l’implémentation en Scala, bien que ni plus ni moins souple qu’en Java, est meilleure.

Et puis, franchement, c'est beaucoup plus court en Scala !