La fausse bonne idée des Virtual Extension Methods dans Java 8

Quand on s'intéresse aux évolutions de Java, il faut lire le Touilleur Express et plus particulièrement son compte rendu "Rémi Forax au Paris JUG". Apparemment, les lambda expressions feront leur apparition dans la version 8 du langage, ce dont tout le monde devrait se réjouir.
Je ne suis pas un habitué de Java. Mon premier langage fut le C++ et aujourd'hui celui que je maîtrise le mieux reste C# mais comment résister à l'envie de jeter un coup d'oeil aux évolutions de Java au moment où celui-ci se met à rattraper (un petit peu[1]) son retard sur C# ?
Je vais essayer d'expliquer ce que j'ai compris et pourquoi je pense qu'un des choix effectués ne me semble pas être des plus judicieux[2]

Un des principaux usages des lambdas expressions dans les langages orientés objet, c'est la manipulation de collections à travers quelques méthodes générales (filtrage, projection, aggregats, ...) paramétrées par des lambdas expressions.
C'est bien évidemment un des objectifs de l'introduction des lambdas dans Java comme l'explique Brian Goetz dans ces slides de novembre dernier : http://blogs.oracle.com/briangoetz/resource/devoxx-lang-lib-vm-co-evol.pdf

En pratique, le but est de pouvoir écrire le code suivant en Java :

List<Student> students = ...
double highestScore =
    students.filter(s -> s.getGradYear() == 2011)
                .map(s -> s.getScore())
                .reduce(0.0, Integer::max);

Cela n'est bien sûr que l'équivalent de ce que l'on peut écrire en C# depuis quelques années déjà :

List<Student> students = ...
double highestScore =
    students.Where(s => s.GradYear == 2011)
                .Select(s => s.Score)
                .Aggregate(0.0, Math.Max);

Une question annexe est alors : comment définir les méthodes filter/map/reduce sur une collection ?
C# a fait un choix simple. Where/Select/Aggregate et bon nombre d'autres opérations sont définies comme méthodes d'extension statiques. Ce sont tout simplement des méthodes statiques d'une classe statique qu'un artifice du compilateur permet de faire apparaitre comme des méthodes de toute énumération.
Dans la suite de ce billet je les appelerai SEM (pour Static Extension Method)

Pour le compilateur C#, le code précédent est strictement équivalent à celui-ci :

double highestScore =
  Enumerable.Aggregate (
    Enumerable.Select (
      Enumerable.Where (students, s => s.GradYear == 2011),
      s => s.Score
    ),
    0.0, Math.Max
  );

Visiblement, cette façon de faire ne plait pas aux concepteurs de Java. Brian Goetz détaille les objections suivantes :

Classes don’t know about their extension methods so cannot provide a “better” implementation
Not reflectively discoverable
No covariant overrides
Brittle – if default changes, clients have to be recompiled
Poor interaction with existing instance methods of same name
Not very object-oriented

Voyons un peu ce qu'il en est vraiment.

  • Les classes ne connaissent pas leurs méthodes d'extension. C'est en partie vrai (seule l'interface IQueryable permet de faire varier l'implémentation des SEM, c'est d'ailleurs le fondement de LINQ) mais dans la plupart des cas, on n'en a rien à faire. Les SEM ne sont pas là pour étendre une abstraction, elles sont là pour proposer un concept d'implémentation indépendant du type auquel elles se rapportent.[3]
  • Etant indépendantes des types, les SEM ne sont pas accessibles à travers la réflexion. C'est vrai mais à quoi cela pourrait servir ? Quand on se soucie de réflexion sur les méthodes d'un type, c'est que l'on aurait grand besoin, au choix, de revoir sa conception[4] ou d'aller vers un langage plus dynamique.
  • Je ne m'étendrai pas sur les redéfinitions covariantes qui n'existent pas en C# et auxquelles je préfère une conception à base de types paramétrés
  • La prétendue "fragilité" due au manque de compatibilité binaire est un leurre. La compatibilité source est la seule qui a de l'importance à mes yeux pour définir (il faudra souvent le rappeler) des concepts d'implémentation
  • L'existence de méthodes du même nom que les extensions est sans objet : qui aurait intérêt à définir ce genre de doublons ?
  • Le "not very object-oriented", c'est le "cherry on top". Est-ce que les lambdas expressions sont "very object-oriented" ???

Vous l'aurez compris : je ne partage pas vraiment le point de vue de ces javaistes sur les extension methods de C#.
Mais voyons un peu ce qu'ils proposent pour faire mieux. Ils appelent ça les "Virtual Extension Methods". Cela consiste à fournir une implémentation par défaut à une méthode d'une interface.

Exemple :

interface List<T> {
  // existing methods, plus
  void sort(Comparator<? super T> cmp) default { Collections.sort(this, cmp); };
}

A première vue, je me suis dit "pourquoi pas ?".
Cela fournit un mécanisme élégant pour faire des héritages de comportement et si ça permet de combler les défauts mineurs de l'extension par méthode statique, il n'y a pas de raison de s'en passer.
Mais j'ai quand même un peu réfléchi à la question et il y a quelques raisons qui me font penser que l'approche choisie par C# est bien meilleure.

Raison No 1 : l'extension de types existants

Imaginons que, dans notre exemple de code, nous ayons envie de simplifier l'écriture de la manière suivante :

List<Student> students = ...
double highestScore =
    students.Where(s => s.GradYear == 2011)
                .Select(s => s.Score)
                .Max();

En effet, le "Aggregate(0.0, Math.Max)" n'est pas un modèle de lisibilité et il gagnerait à être raccourci par le rajout d'une méthode qui renvoie le maximum d'une collection.

En C#, cette fonction "Max" existe nativement mais, si elle n'existait pas, elle serait très facile à rajouter avec une SEM car celles-ci sont entièrement indépendantes des types auxquelles elles se rapportent :

static class MyIEnumerableExtension {
  static double Max(this IEnumerable<double> self)
  {
     return self.Aggregate(0.0, Math.Max);
  }
}

Allez donc faire ça avec les VEM de Java sans toucher à l'interface...
C'est d'ailleurs dingue ce que l'on peut faire dès que l'on a la possibilité de rajouter des méthodes à une interface sans toucher à celle-ci.
La future version de java aura de nouvelles méthodes filter/map/reduce dans ses collections mais comment être exhaustif et ne pas oublier toutes les méthodes qui pourraient être utiles ?
J'espère que ceux qui amélioreront les interfaces auront une bonne boule de cristal... Ils ne faudrait pas qu'ils oublient de mettre des méthodes de groupement et de mise en dictionnaire. Elles sont parfois si utiles.

List<Student> students = ...
Dictionary<double,IEnumerable<Student>> studentsByScore =
  students.GroupBy (s => s.Score)
          .ToDictionary (g => g.Key, g => g.AsEnumerable());


Raison No 2 : la maintenabilité au delà de la réutilisabilité

L'aspect le plus élégant des VEM (mixer du code et des interfaces) est aussi le plus dangereux en termes de maintenabilité.
Commençons par rappeler quelques principes énoncés par Uncle Bob :

Common Closure Principle
The classes in a package should be closed together against the same kind of changes.
A change that affects a package affects all the classes in that package.

Stable Abstractions Principle
Packages that are maximally stable should be maximally abstract.
Instable packages should be concrete.
The abstraction of a package should be in proportion to its stability.

Si l'on admet que le respect de ces principes (et de quelques autres) maximise la maintenabilité d'une application, alors on a un petit problème avec les VEM.
Mélanger une abstraction relativement stable avec un détail d'implémentation qui va plus souvent évoluer, fut-il un comportement par défaut, ne va pas dans le sens de regroupements maintenables.

En clair, on est dans un des deux contextes suivants :

  • Soit les VEM ne sont utilisées qu'à la marge et on n'a pas de problème de maintenabilité. On peut alors se demander si les VEM sont une véritable stratégie du langage Java ou une simple réponse tactique au seul problème de la compatibilité des collections Java existantes avec le rajout filter/map/reduce...
  • Soit les VEM sont amplement utilisées et elles sont alors une improbable invitation à mélanger abstraction stables et détails d'implémentation anéantissant ainsi tout effort allant vers plus de maintenabilité d'une application Java

A l'inverse, les SEM de C# induisent une séparation claire entre les abstractions et les implémentations.

Raison No 3 : une fonctionnalité pour les développeurs d'applications

Cette troisième raison est un peu le corollaire des deux raisons précédentes.
Brian Goetz écrit :

The whole point of extension methods is being able to compatibly evolve APIs
The key operation we care about is adding new methods with defaults to existing interfaces without necessarily recompiling the implementation class

Les solutions qu'il apporte vont clairement dans le sens de la facilitation d'écriture de librairie tierces et d'API : les Virtual Extension Methods de Java ne sont qu'une fonctionnalité pour les développeurs de composants et de frameworks
A l'inverse, avec leurs possibilités d'extension de types externes et de séparation abstraction/détail, les Static Extension Methods de C# sont une fonctionnalité pour les développeurs d'applications.

On pourra argumenter que c'est important d'avoir des composant tiers extensibles qui ne cassent pas la compatibilité binaire et qu'à ce titre, les VEM sont un bon choix.
A cela, je suis tenté de répondre :

  • que l'extensibilité grace aux VEM est plus que relative (on ne peut pas rajouter une méthode à une interface sans casser la compatibilité binaire...)
  • que le mécanisme IQueryable de C#/.NET qui est un des fondements de LINQ offre des possibilités bien supérieures si on tient absolument à écrire des composants binairement indépendants avec des fonctionnalités d'un tout autre ordre (récupération dynamique de l'arbre d'expression sous-jacent aux appels extension methods + lambdas)[5]

Ma conclusion est simple : le choix VEM vs SEM est un peu le reflet de l'écosystème dans lequel chacun des langages évolue.
En tant que développeur d'applications, je suis bien content d'utiliser C# et ses extensions statiques qui facilitent ma vie de tous les jours. Je laisse volontiers les Virtual Extension Methods aux amoureux des frameworks en tout genre et aux adeptes des armées de librairies à tout faire et à assembler "comme on peut"...

Notes

[1] troll inside

[2] Et si je fais des erreurs dans ce billet, je ne doute pas qu'un javaiste de passage saura me le faire remarquer...

[3] J'ai écrit récemment une série de billets sur le sujet. Le premier est là.

[4] Il y aurait un billet entier à faire sur "comment se passer de la réflexion sur les méthodes"

[5] Il faudrait là aussi un billet complet pour montrer le polymorphisme introduit en C# par LINQ

Miguel

Je pense que les deux approches ne sont pas bonnes et ne sont que des "cuisines" techniques de ces langages.
AMHA, la meilleure approche, est celui des traits, qui est un apport conceptuel et non uniquement technique. Il aura l'avantage de définir des méthodes de même catégorie (même traits) que l'on pourra tisser avec les classes d'objets.

Miguel 20 mars 2012 - 15:28
Oaz

Bonjour Miguel,

En quoi les traits (dont je ne nie pas l'apport conceptuel) permettent d'étendre le comportement d'un type externe à une application ?

Oaz 20 mars 2012 - 16:36
Miguel

Par ce que dans un trait tu rassembles les propriétés transverses aux types et qu'ensuite tu étends les types de ton système (ou les objets d'un type donné) avec le trait, que ces types soient ou non externe à l'application. En fait, un trait n'est rien d'autre finalement que l'implémentation dans un langage orienté-objet des types abstraits de données communs aux langages fonctionnels typés.

Ceci peut se faire de plusieurs façons différentes, selon les langages qui supportent le concept de trait :
- tu étends ton type à sa définition (extension statique),
- tu étends ton type à son usage (extension dynamique: tous les objets profitent du comportement),
- tu étends des objets d'un type donné à leur instanciation ou dynamiquement (extension dynamique: seuls certains objets en profitent: idéal pour la notion de rôle)

Miguel 20 mars 2012 - 17:45
Oaz

Ok.
Donc si on se limite aux langages à typage statique, le seul cas qui reste en course, c'est l'extension du type à sa définition.
C'est, je crois, la seule utilisation des traits possible en Scala (seul langage à typage statique et proposant les traits que je sois capable de citer)

Et c'est là que les extension methods de C# apportent quelque chose en plus : le type est étendu à l'usage mais à travers des définitions statiques.

Je ne veux pas éluder le cas des typages dynamiques où l'utilisation facilitée des traits résout effectivement bien des problèmes mais c'est hors-contexte par rapport au sujet du billet. Les types dynamiques viennent avec leur lot de défauts qui seraient à prendre en considération pour une comparaison équitable.

Oaz 20 mars 2012 - 18:15
Miguel

Non, il faut distinguer typage statique d'avec extension statique. Les langages à typage statique peuvent supporter l'extension dynamique par trait. Scala, que tu cites, le supporte, dont voici un exemple de tissage dynamique :
val mylist = new List[String] with Filtering

Miguel 21 mars 2012 - 08:58
Oaz

Merci. C'est intéressant.
Je ne pense pas que cet exemple réponde complètement au besoin (le trait est rajouté à l'instanciation et non pas à l'usage au sens large) mais ça me donne envie de regarder Scala dans les détails.

Oaz 21 mars 2012 - 10:10
Oaz

J'ai trouvé ce qui correspond effectivement aux extension methods dans scala : les définitions implicites (implicit def) http://hestia.typepad.com/flatlander/2009/03/scala-for-c-programmers-part-5-implicits.html

Scala ayant eu besoin de 2 concepts distincts ("trait" et "implicit def"), je pense que l'on peut affirmer que l'on traite de 2 choses définitivement distinctes :

  1. L'extension de types lors de la définition des objets (définition de classe + instanciation) dont les "traits" représentent peut-être la forme la plus avancée et les VEM une version simplifiée
  2. L'extension de type lors de l'utilisation des objets pour laquelle les définition implicites offrent un mécanisme intéressant et les SEM une version simplifiée
Ceci étant dit, je reste convaincu que l'extension à l'utilisation est la bonne approche pour rajouter les filter/map/reduce car la puissance des lambdas se concrétise dans la possibilité pour un utilisateur de types externes de définir un DSL.
Par ailleurs, la conclusion du billet reste valable : l'extension de types lors de la définition est une fonctionnalité orientée développeur de composant/framework tandis que l'extension de type lors de l'utilisation est une fonctionnalité orientée développeur d'applications.
Oaz 22 mars 2012 - 10:42

Fil des commentaires de ce billet

Ajouter un commentaire

Le code HTML est affiché comme du texte et les adresses web sont automatiquement transformées.