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.