Tutoriel sur Java 8 : du neuf dans les interfaces

Olivier Croisier

Ce tutoriel s'intéresse aux nouveautés introduites par Java 8 concernant les interfaces.

Cet article a été rédigé par Olivier Croisier. L'article original (Java 8 : du neuf dans les interfaces !) peut être vu sur le blog/site de Olivier Croisier.

Pour réagir au contenu de cet article, un espace de dialogue vous est proposé sur le forum Commentez .

- Titre : Tutoriel sur Java 8 : du neuf dans les interfaces

- Auteur : Olivier Croisier

- Parution : 31 janvier 2014

- Licence : Copyright ® 2014 Olivier Croisier. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- II. En Java 8

- II-A. Syntaxe

- III. Traits

- III-A. Concept

- III-B. Exemple : Comparable et Orderable

- III-C. Chez la concurrence

- III-C-1. Scala

- III-C-2. Haskell

- IV. Les diamants sont éternels

- IV-A. Proxy, mon ami

- V. Conclusion

I - Introduction

Parmi 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é.

II - 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 connus des développeurs C++. On verra (plus loin) que ce n'est finalement pas un problème en Java.

II-A - 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()).

III - Traits

III-A - 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.

III-B - 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 listes 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

III-C - Chez la concurrence

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

III-C-1 - Scala

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

III-C-2 - Haskell

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"}

IV - Les diamants sont éternels

Évidemment, 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.

IV-A - 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.

V - 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 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 ?

Cet article a été publié avec l'aimable autorisation de Olivier Croisier. L'article original (Java 8 : du neuf dans les interfaces) peut être vu sur le blog http://thecodersbreakfast.net/.

Nous tenons à remercier Cédric Duprez pour sa relecture attentive de cet article et Mickaël Baron pour la mise au gabarit.