IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Tutoriel sur Java 8 : du neuf dans les interfaces

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 15 commentaires Donner une note à l´article (5).

Article lu   fois.

L'auteur

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

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).

 
Sélectionnez
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.

 
Sélectionnez
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]");
    }
 
}
 
Sélectionnez
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 :

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

Résultat :

 
Sélectionnez
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.
Image non disponible

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.

 
Sélectionnez
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.

 
Sélectionnez
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() !

 
Sélectionnez
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));
    }
}
 
Sélectionnez
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

Image non disponible

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).

 
Sélectionnez
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

Image non disponible

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

 
Sélectionnez
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 ?

 
Sélectionnez
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.

 
Sélectionnez
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 :

 
Sélectionnez
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 !

 
Sélectionnez
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 ?

 
Sélectionnez
((InterfaceA) proxy).foo(); 
((InterfaceB) proxy).foo();

Le résultat est encore plus étrange :

 
Sélectionnez
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.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

Copyright © 2014 Olivier Croisier. Aucune reproduction, même partielle, ne peut être faite de ce site ni 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.