Java 8 : du neuf dans les interfaces !

java8.pngParmi les nouveautés apportées par Java 8, on en trouve deux qui concernent les interfaces : les méthodes statiques et les méthodes par défaut.

Les méthodes statiques définies sur les interfaces fonctionnent exactement de la même façon que celles portées par les classes, il n'y a donc pas grand-chose à en dire. En revanche, les méthodes par défaut risquent de modifier assez profondément notre façon de concevoir nos API.

En Java 7 et antérieur, une méthode déclarée dans une interface ne fournit pas d'implémentation. Ce n'est qu'une signature, un contrat auquel chaque classe dérivée doit se conformer en fournissant une implémentation propre.

Mais il arrive que plusieurs classes similaires souhaitent partager une même implémentation de l'interface. Dans ce cas, deux stratégies sont possibles (celui qui a dit "copier/coller" viendra me voir à la fin du billet pour une retenue) :

  • Factoriser le code commun dans une classe abstraite, mais il n'est pas toujours possible de modifier la hiérarchie des classes
  • Extraire le code commun dans une classe utilitaire, sous forme de méthode statique (ex: Collections.sort()).

On conviendra qu'aucune des deux n'est réellement satisfaisante. Heureusement, Java 8 nous offre maintenant une troisième possibilité.

En Java 8

Java 8 propose en effet une solution plus propre : permettre aux méthodes déclarées dans les interfaces d'avoir une implémentation !
Là, tout le monde se frappe le front en disant, bon sang mais c'est bien sûr, pourquoi n'y a-t-on pas pensé avant ? Tout simplement parce que les concepteurs du langage voulaient absolument éviter les problèmes d'héritage en diamant, bien connu des développeurs C++. On verra (plus loin) que ce n'est finalement pas un problème en Java.

Syntaxe

La syntaxe est simple et sans surprises : il suffit de fournir un corps à la méthode, et de la qualifier avec le mot-clé default (mot-clé déjà utilisé pour les annotations, si vous vous rappelez).

public interface Foo {
    public default void foo() {
        System.out.println("Default implementation of foo()");
    }
}

Les classes filles sont alors libérées de l'obligation de fournir elles-mêmes une implémentation à cette méthode - en cas d'absence d'implémentation spécifique, c'est celle par défaut qui est utilisée.

public interface Itf {
 
    /** Pas d'implémentation - comme en Java 7 et antérieur */
    public void foo();
 
    /** Implémentation par défaut, qu'on surchargera dans la classe fille */
    public default void bar() {
        System.out.println("Itf -> bar() [default]");
    }
 
    /** Implémentation par défaut, non surchargée dans la classe fille */
    public default void baz() {
        System.out.println("Itf -> baz() [default]");
    }
 
}
public class Cls implements Itf {
 
    @Override
    public void foo() {
        System.out.println("Cls -> foo()");
    }
 
    @Override
    public void bar() {
        System.out.println("Cls -> bar()");
    }
 
    /* NON SURCHARGE
    @Override
    public void baz() {
        System.out.println("Cls -> baz()");
    }*/
 
}

Et le test :

public class Test {
    public static void main(String[] args) {
        Cls cls = new Cls();
        cls.foo();
        cls.bar();
        cls.baz();
    }
}

Résultat :

Cls -> foo()
Cls -> bar()
Itf -> baz() [default]

Comme prévu, l'implémentation de la classe est préférée à celle de l'interface (méthode bar()), et si la classe ne fournit pas d'implémentation, c'est celle de l'interface qui est utilisée (méthode baz()).


Traits

Concept

Avec l'apparition des Default Methods vient la possibilité d'implémenter des traits en Java.

Un "trait", ou "extension", c'est plus ou moins de l'AOP appliquée aux classes : il encapsule un ensemble cohérent de méthodes à caractère transverse et réutilisable.
En général, un trait est composé de :

  • une méthode abstraite qui fait le lien avec la classe sur laquelle il est appliqué
  • un certain nombre de méthodes additionnelles, dont l'implémentation est fournie par le trait lui-même car elles sont directement dérivables du comportement de la méthode abstraite.

trait.jpg

Exemple : Comparable et Orderable

Prenons l'exemple de l'interface Comparable en Java. Cette interface déclare une unique méthode, compareTo(), qui permet au développeur de spécifier la position relative de deux objets. Cette interface est largement utilisée dans l'API Collections, afin de trier les liste par exemple. L'algorithme utilisé dépend évidemment de chaque classe : on ne trie pas des Personnes comme des Strings ou des Long...

La méthode compareTo() est très utile, mais elle renvoie un int, ce qui n'est pas très... sémantique. Des méthodes comme greaterThan() / lessThan() ou isBefore() / isAfter(), renvoyant des booléens, seraient plus parlantes.
Et comme elles sont directement dérivées de compareTo(), c'est un cas d'application rêvé pour les Default Methods !

Comme l'interface Comparable appartient au JDK, nous ne pouvons pas la modifier, mais il est toujours possible de l'étendre.
Notre interface s'appellera Orderable et ne contiendra que des méthodes par défaut s'appuyant sur la méthode compareTo() héritée de Comparable.

public interface Orderable<T> extends Comparable<T> {
 
    // La méthode compareTo() est définie
    // dans la super-interface Comparable
 
    public default boolean isAfter(T other) {
        return compareTo(other) > 0;
    }
 
    public default boolean isBefore(T other) {
        return compareTo(other) < 0;
    }
 
    public default boolean isSameAs(T other) {
        return compareTo(other) == 0;
    }
 
}

On peut l'appliquer à une classe...

public class Person implements Orderable<Person> {
 
    private final String name;
 
    public Person(String name) {
        this.name = name;
    }
 
    @Override
    public int compareTo(Person other) {
        return name.compareTo(other.name);
    }
 
}

... qui bénéficie aussitôt des nouvelles méthodes isBefore() et isAfter() !

public class Test {
    public static void main(String[] args) {
        Person laurel = new Person("Laurel");
        Person hardy = new Person("Hardy");
        System.out.println("Laurel compareto Hardy : " + laurel.compareTo(hardy));
        System.out.println("Laurel >  Hardy : " + laurel.isAfter(hardy));
        System.out.println("Laurel <  Hardy : " + laurel.isBefore(hardy));
        System.out.println("Laurel == Hardy : " + laurel.isSameAs(hardy));
    }
}
Laurel compareto Hardy : 4
Laurel >  Hardy : true
Laurel <  Hardy : false
Laurel == Hardy : false


Chez la concurrence

D'autres langages proposent ce concept depuis longtemps, en particulier Scala et Haskell.

Scala

scala-logo.png En Scala, le trait Ordered se comporte exactement comme notre interface Orderable.
En implémentant la méthode compare (abstract def compare(that: A): Int), on bénéficie gratuitement des méthodes >, >=, < et <= (en Scala, les symboles sont des méthodes comme les autres).

case class Person(name: String) extends Ordered[Person] {
  def compare(that: Person) = this.name compare that.name
}
 
val laurel = Person("Laurel")
val hardy = Person("Hardy")
 
println "Laurel > Hardy ? " + (laurel > hardy)  // true
Haskell

haskell-logo.png En Haskell, la "classe" (au sens de famille de types) Data.Ord remplit le même office. Il suffit là encore d'implémenter la méthode compare (compare :: a -> a -> Ordering) pour bénéficier gratuitement des méthodes >, >=, <, <=, min(), et max().

data Person = Person {
  name :: String }
  deriving (Eq, Show)
 
instance Ord Person where 
  compare p1 p2 = (name p1) `compare` (name p2)
 
main = do 
  let laurel = Person { name = "Laurel" }
  let hardy  = Person { name = "Hardy" }
  print (laurel > hardy)    -- True
  print (max laurel hardy)  -- Person {name="Laurel"}


Les diamants sont éternels

Evidemment, avec les Default Methods dans les interfaces, le spectre de l'héritage en diamant rôde.
Si deux interfaces déclarent la même méthode mais proposent des implémentations incompatibles, que se passe-t-il ?

public interface InterfaceA {
    public default void foo() {
        System.out.println("A -> foo()");
    }
}
 
public interface InterfaceB {
    public default void foo() {
        System.out.println("B -> foo()");
    }
}
 
private class Test implements InterfaceA, InterfaceB {
    // Erreur de compilation : "class Test inherits unrelated defaults for foo() from types InterfaceA and InterfaceB"
}

Une erreur de compilation nous indique que les deux interfaces A et B fournissent chacune une implémentation, qui se télescopent lorsqu'elles sont tirées par la classe Test.
Pour résoudre le conflit, une seule solution : implémenter la méthode au niveau de la classe elle-même, car l'implémentation de la classe est toujours prioritaire.

public class Test implements InterfaceA, InterfaceB {
     public void foo() {
        System.out.println("Test -> foo()");
    }
}

Maintenant, ça compile, mais le code des méthodes par défaut n'est plus appelable directement. Une nouvelle syntaxe a donc été proposée : <Interface>.super.<méthode>

Par exemple, si la méthode foo() de la classe souhaite appeler la méthode foo() par défaut fournie par l'interface B :

public class Test implements InterfaceA, InterfaceB {
     public void foo() {
        InterfaceB.super.foo();
    }
}

Le problème de l'héritage en diamant est donc résolu par une vérification de compatibilité au niveau du compilateur, plus une syntaxe pour accéder sélectivement aux implémentations par défaut des interfaces.

Proxy, mon ami

Comme d'habitude, je me suis demandé comment une clause vérifiée par le compilateur se comportait au runtime.

Et comment créer dynamiquement une classe qui implémenterait les deux interfaces InterfaceA et InterfaceB ?
Grâce à un proxy dynamique évidemment !

Object proxy = Proxy.newProxyInstance(
    Test.class.getClassLoader(),
    new Class[]{InterfaceA.class, InterfaceB.class},
    (tagetProxy, targetMethod, targetMethodArgs) -> {
        System.out.println("Calling " + targetMethod.toGenericString());
        return null;
    });

Apparemment, au runtime, il n'y a aucune vérification de la compatibilité des interfaces implémentées.

Essayons maintenant d'appeler foo() sur le proxy.
Saurez-vous deviner le résultat des appels ci-dessous ?

((InterfaceA) proxy).foo(); 
((InterfaceB) proxy).foo();

Le résultat est encore plus étrange :

Calling public default void InterfaceA.foo()
Calling public default void InterfaceA.foo()

Si la première ligne paraît tout à fait normale, la seconde est troublante : pourquoi la méthode de l'InterfaceA est-elle appelée, alors même que le type de la référence est InterfaceB ?
Serait-ce un bug ?

En réalité, un proxy n'a aucun moyen de connaître le type de référence à travers lequel il est appelé. Il ne peut donc pas choisir finement entre les implémentations fournies par les interfaces InterfaceA et InterfaceB, et choisit donc de se reposer sur leur ordre de déclaration. Pour preuve, si l'on intervertit les interfaces (new Class{InterfaceB.class, InterfaceA.class}), c'est alors la méthode foo() de l'InterfaceB qui est appelée dans tous les cas !

Ce comportement est d'ailleurs parfaitement documenté dans la Javadoc de la classe java.lang.reflect.Proxy :

When two or more interfaces of a proxy class contain a method with the same name and parameter signature, the order of the proxy class's interfaces becomes significant. When such a duplicate method is invoked on a proxy instance, the Method object passed to the invocation handler will not necessarily be the one whose declaring class is assignable from the reference type of the interface that the proxy's method was invoked through. This limitation exists because the corresponding method implementation in the generated proxy class cannot determine which interface it was invoked through. Therefore, when a duplicate method is invoked on a proxy instance, the Method object for the method in the foremost interface that contains the method (either directly or inherited through a superinterface) in the proxy class's list of interfaces is passed to the invocation handler's invoke method, regardless of the reference type through which the method invocation occurred.

Conclusion

Les Default Methods vont avoir un certain impact sur notre façon de concevoir nos classes et frameworks. Le JDK 8 lui-même en tire pleinement parti, notamment dans au niveau de l'API Collections.

Il est probable qu'un grand nombre de classes utilitaires disparaîtront, remplacées par des Default Methods judicieusement ajoutées dans des interfaces génériques. Les Guava, Apache Commons et autres classes *Utils de nos projets risquent de subir une sérieuse cure d'amaigrissement...

Enfin, la possibilité d'implémenter des traits va probablement apporter - de manière gratuite et transparente - une plus grande richesse sémantique dans les API.

Au fait, Java 8 sort dans deux mois. Spring, Hibernate et les autres frameworks industriels sont déjà prêts. Et vous ?


Commentaires

1. Le jeudi 23 janvier 2014, 07:43 par adiGuba

Quelques remarques supplémentaires :

L'héritage en losange pose moins de problème car il ne concerne que les interfaces, et qu'il est logique dans ce cas de demander une implémentation.
C'est surtout l'héritage multiple de classe qui pose le plus de problème : en C++ le problème d'héritage en losange se pose entre différente classes parentes...

Les méthodes par défaut ne sont pas compté comme des méthodes purement abstraite. Ainsi une interface peut tout à fait contenir plusieurs méthodes par défaut tout en restant compatible avec les expressions lambda, qui requiert une seule méthode abstraite...

La grande force de ce système, c'est que les classes restent toujours libre de proposer une implémentation spécifique ou plus optimisé (contrairement aux "Extension Methods" de C# par exemple).

Enfin la résolution de la méthode à appelée est bien effectuée à l'exécution lors du premier appel de la méthode (via invokedynamic il me semble). Bref ce n'est pas un simple sucre syntaxique à la compilation.
Cela implique qu'une modif dans le code d'une méthode par défaut se reportera à l'exécution, sans avoir à recompiler toutes les classes implémentant cette interface.

A noter également la possibilité de définir des méthodes static au sein des interfaces (pratique pour déclarer une méthode de fabrique par exemple).
En plus ils ont forcé un appel explicite via le nom de l'interface (et non via le nom d'une instance, comme c'est possible avec une méthode static déclarée dans une classe, même si c'est incorrect et potentiellement trompeur).

a++

2. Le jeudi 23 janvier 2014, 09:41 par Jean

Je n'ai pas de jdk8 sous la main : est-ce que ça ça fonctionne ?

interface Base { public void foo() { System.out.println("Base -> foo()"); } }
interface A { public void foo() { super.foo();System.out.println("A -> foo()"); } }
interface B { public void foo() { super.foo();System.out.println("B -> foo()"); } }
class Impl implements A, B{

   public void foo() {
       Base.super.foo(); 
   }

}

3. Le jeudi 23 janvier 2014, 09:59 par Olivier Croisier

Jean, ton code ne fonctionne par car :
- les méthodes foo() des interfaces doivent être déclarées "default" car elles possèdent un corps
- la notation "super.foo()" ne fonctionne pas. Dans A et B, il faut utiliser "Base.super.foo()"
- enfin, dans Impl, étrangement, on peut tout à fait appeler "A.super.foo()" ou "B.super.foo()", mais pas directement "Base.super.foo()".

Une fois ces points corrigés, si on appelle "A.super.foo()" dans Impl, on voit bien l'affichage "Base -> foo()" suivi de "A -> foo()"

4. Le vendredi 24 janvier 2014, 10:50 par Jean

Ok, comme je disais je n'ai pas de jdk 8 sous la main et je n'ai pas étudié la syntaxe en détail, mais tu as bien compris la structure que je voulais donner :)
Ce qui m'intéressait c'était de savoir si il était possible de récupérer la méthode foo de Base.Tta réponse semble indiquer que non.

5. Le lundi 3 février 2014, 15:44 par Fabinout

Quel article bien étoffé! Tellement qu'il ne serait pas sage de le lire au travail, j'ai hâte de mettre les mains dessus plus tard.
Fab

6. Le lundi 3 février 2014, 23:01 par Jabberwock

Bonjour,

Vous indiquez que la seule solution en cas d'implémentation de 2 interfaces définissant la même méthode est de mettre en place une implémentation spécifique directement dans la classe.

Une autre solution si l'on a la main sur les interfaces est d'indiquer l'interface la plus spécifique via l'héritage d'interface. C'est peut être par contre un peu plus traitre à l'usage.

Cela pourrait par exemple revenir à ça :

public interface InterfaceA extends InterfaceB{

   public default void foo() {
       System.out.println("A -> foo()");
   }

}



public interface InterfaceB {

   public default void foo() {
       System.out.println("B -> foo()");
   }

}



private class Test implements InterfaceA, InterfaceB {

   // Pas besoin d'implémentation spécifique InterfaceA est plus spécifique qu'InterfaceB. C'est donc

}

7. Le jeudi 6 février 2014, 11:02 par Olivier

A l'epoque, j'avais demande a Joe Darcy comment ils comptaient gerer le probleme de l'heritage en diamant pour le code legacy. Et ils n'avaient pas encore trouver de solution.

Example:

  • J'utilise une lib, compilee en JDK 7 (ou inferieur).
  • Cette lib definit une class Foo qui herite de deux interfaces Bar & Baz, issue d'un autre projet.
  • Le projet qui contient Bar & Baz passe a JDK 8 et rajoute une methode default "kaboom()" dans les deux interfaces.
  • Dans mon projet en JDK 8, je veux faire appel a Foo.kaboom(). Quelle est la methode qui va etre appelee?

Je n'ai pas de JDK8 sous la main, mais je pense que cela va creer quelques bugs "interessants".

Ajouter un commentaire

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