Les méthodes virtuelles d’extension dans Java 8

Dans un précédant article, nous avons vu les lambda expressions et comment elles allaient apparaître dans Java 8 — l’idée étant d’orienter Java vers un style plus fonctionnel. Mais pour parfaire l’intégration de ce style de programmation dans Java, Brian Goetz indique qu’il faudra modifier l’API Collection pour y ajouter des fonctions telles que filter, map ou fold. Afin de faciliter la mise en place de telles fonctions au sein de l’API Collection, Brian Goetz propose d’intégrer les méthodes virtuelles d’extension (virtual extension method) dans le langage.

Dans cet article, nous allons voir ce que sont les méthodes virtuelles d’extension, comment est-ce que Java résout le lien entre ces méthodes et leur implémentation, et comment est traité le cas du diamant (un problème classique en programmation objet). Puis nous comparerons les méthodes virtuelles d’extension avec une autre approche plus ou moins similaire utilisée dans d’autres langages : les mixins.

Avertissement : Les informations présentes dans cet article sont celles qui ont été récupérées en date de publication de l’article. Tant que Java 8 n’est pas officiellement sorti, ces informations peuvent changer à tout instant.

Méthode virtuelle d’extension (VEM)

L’arrivée des lambda expressions implique un rapprochement entre Java et la programmation fonctionnelle. Mais pour que ce rapprochement soit plus présent, il faut adapter l’API Java Collections en conséquence. En effet, les langages de programmation fonctionnelle tels que Haskell ou Common Lisp définissent une grande variété de fonctions d’ordre supérieur agissant sur des collections. C’est le cas par exemple de la fonction filter() qui filtre une collection selon un prédicat (ie. une fonction qui retourne un booléen). Mais aussi la fonction map() qui modifie chaque élément de la collection selon une transformation (ie. une fonction qui prend un élément et retourne un autre élément). Ou encore la fonction reduce() qui reduit une liste selon un opérateur (ie. une fonction prenant deux paramètres).

Dans la version Java telle que nous la connaissons aujourd’hui, l’implementation de ces fonctions nécessite par exemple de modifier la classe utilitaire java.util.Collections pour lui ajouter ces opérations. Nous pouvons aussi créer des classes utilitaires complémentaires telles que Sets, Lists, Maps, etc., comme le fait déjà Guava. Enfin, il est possible d’ajouter des méthodes dans chacune des interfaces telles que Set, List, Map, etc., puis de fournir leur implémentation dans chacune des classes telles que AbstractList ou ArrayList, AbstractSet ou HashSet, etc.

Plutôt que de partir sur ces solutions, Brian Goetz propose d’étendre le langage Java avec la notion de méthode virtuelle d’extension (anciennement connues sous le nom de defender method). Cette extension du langage est motivée par la problématique de compatibilité ascendante ; problème récurrent du JDK. En effet les interfaces collections ont été implémentées bon nombre de fois par des développeurs Java pour construire des collections optimisées dans le cadre de leurs projets et l’ajout de méthodes dans ces interfaces aurait rendu l’ensemble des ces collections personnalisées non compilables.

Dans la suite de ce document, nous utiliserons l’acronyme VEM pour parler des méthodes virtuelles d’extension — l’abréviation provenant de la version anglophone : virtual extension method. Le principe des VEM est de fournir une implémentation par défaut lors de la déclaration des méthodes dans une interface. Par exemple :

public interface Collection<T> extends Iterable<T> {
    /* ... */
    <R> Collection<R> filter(Predicate<T> p)
        default Collections.<T>filter;
}

Le mot-clé default dans les interfaces au niveau des méthodes est ce qui permet de définir une implémentation par défaut d’une méthode, même si cette méthode n’est pas définie au niveau de la classe sous-jacente. Nous étendons ainsi l’API Java Collection sans pour autant ajouter du code dans le contenu des classes telles que ArrayList, HashSet ou AbstractMap.

Résolution des méthodes

Voyons comment savoir, avec la présence du mot-clé default, quelle méthode sera utilisée.

Déclarons une interface A avec une implémentation par défaut et la classe DefaultA comme suit :

public interface A { void m() default U.m; }
public class DefaultA implements A {}

La déclaration ci-dessus est donc sensée compiler même si nous n’avons pas déclaré la méthode m() dans la classe DefaultA. Ici, U.m est une méthode statique appartenant à la classe U. Elle prend en entrée un argument qui est l’instance de A appelant la méthode. Sa déclaration ressemble à :

public class U {
    public static void m(A a) { /* do something with 'a'... */ }
}

Si nous écrivons :

A a = new DefaultA();
a.m();

l’appel a.m() sera transformé en U.m(a).

Maintenant, si nous déclarons OtherA comme suit :

public class OtherA implements A {
    public void m() { System.out.println("hello"); }
}

Et si nous écrivons :

A a = new OtherA();
a.m();

Dans ce cas, l’appel à a.m() ne sera pas transformé, malgré la présence du mot-clé default dans l’interface A. En effet, l’implémentation de la méthode m() dans OtherA prime sur l’implémentation par défaut fournit par A.

Écrivons maintenant :

public interface A { void m() default U1.m; }
public interface B extends A { void m() default U2.m; }
public class DefaultB implements B {}

B b = new DefaultB();
b.m();

Dans ce cas, b.m() sera transformé en U2.m(b). On applique la méthode par défaut de B car B est plus spécifique que A. Cette particularité est pratique dans le cas suivant :

public interface Collection<T> {
    Collection<T> filter(Predicate<T> p)
        default Collections.<T>filter;
}

public interface Set<T> extends Collection<T> {
    Set<T> filter(Predicate<T> p)
        default Sets.<T>filter;
}

Si on a une variable de type Set, l’appel à la méthode filter() sera tranformé en appel vers Sets.filter(), qui est effectivement plus spécifique que Collections.filter().

Résolution du diamant

Le problème du diamant est un problème classique en programmation objet. Il apparaît dans le cadre de l’héritage multiple lorsqu’un élément D (classe ou interface) hérite à la fois des éléments B et C qui tous deux héritent d’un élément A : comment sont gérées dans ce cas les collisions dans les déclarations de variables et de méthodes ? Pour rappel, en Java, nous avons de l’héritage multiple avec les interfaces et de l’héritage simple avec les classes.

Écrivons maintenant :

public interface A { void m() default AUtils.m; }
public interface B extends A { void m() default BUtils.m; }
public interface C extends A { void m() default CUtils.m; }
public class D implements B, C {}

D d = new D();
d.m();

Quelle appel sera exécuté ? Est-ce que ce sera AUtils.m(d), BUtils.m(d) ou CUtils.m(d) ? A priori, ça ne peut pas être AUtils.m(d) car B et C sont plus spécifique que A. Il reste donc BUtils.m(d) et CUtils.m(d). En fait, d’après ce que spécifie un brouillon écrit en 2010 par Brian Goetz sur les defender method, Java doit lever une exception à la liaison de la méthode m() due à un conflit entre les implémentations par défaut.

Par contre, écrire ce qui suit ne pose pas de problème :

public interface A { void m() default AUtils.m; }
public interface B extends A {}
public interface C extends A {}
public class D implements B, C {}

D d = new D();
d.m();

Ici, d.m() sera transformé en AUtils.m(d).

Vers des mixins dans Java ?

Jusque là avec Java, on avait (et on a toujours pour l’instant) de l’héritage simple au niveau des classes et de l’héritage multiple au niveau des interfaces. Donc, Java permet l’héritage multiple de types, mais pas l’héritage multiple de l’état ou du comportement. Avec l’arrivée des VEM, Java proposera l’héritage multiple du comportement. Ce qui n’est pas sans rappeler les traits de Scala (voir à ce propos l’article Traits Scala de Nicolas Jozwiak) et autres mixins que nous pouvons trouver dans Groovy.

La compréhension de la notion de mixin ici est la suivante : un mixin est une classe abstraite que l’on peut composer dans le cadre d’un héritage multiple et qui permet d’étendre les fonctionnalités d’une classe. Hériter d’un mixin ne correspond pas à proprement parler à de la spécialisation. Il s’agit plutôt d’un moyen de collecter des fonctionnalités pour les ajouter à des classes. L’utilisation des mixins est un moyen de faire de la factorisation structurelle (ie. variables membres et méthodes) de code en POO et d’améliorer en conséquence la lisibilité des classes.

Par exemple, en se basant sur une classe Maison, nous pouvons imaginer créer sa propre (classe) maison avec un ensemble de services. En Scala, on écrirait :

class MaMaison extends Maison with Garage with Jardin with Veranda

Garage, Jardin et Veranda représentent des mixins. Ils ne spécialisent pas précisément la classe MaMaison. Ils lui ajoutent des fonctionnalités. Le mot-clé with est utilisé pour composer les mixins.

Nous allons voir qu’avec les VEM, Java se rapproche de la notion de mixin sans véritablement l’atteindre.

Héritage multiple de comportement

Partons de la grappe de classes ci-dessous, permettant de représenter différents types de documents.

/**
 * Represent any kind of documents (articles, presentations, animations,
 * etc.).
 */
public interface Document {}

// Abstract classes
public abstract AbstractOfficeDocument implements Document {
    // ...
}
public abstract AbstractWebDocument implements Document {
    // ...
}

// Implementations
public class WordDocument extends AbstractOfficeDocument {
    // ...
}
public class PdfDocument extends AbstractWebDocument {
    // ...
}
public class HtmlDocument extends AbstractWebDocument {
    // ...
}
public class FlashDocument extends AbstractWebDocument {
    // ...
}

Nous pouvons vouloir faire en sorte que certains types de documents soient imprimables. Si c’est le cas, une méthode printOn() doit lui être fournit pour imprimer le document sur un périphérique donné. Seuls les documents de type Word, PDF et HTML sont imprimables et pas les animations Flash.

Pour cela, nous créons une interface Printable contenant la méthode printOn() et ayant une implémentation par défaut.

public interface Printable extends Document {
    void printOn(Device device) default Printables.print;
}

public class Printables {
    private Printables() {
        throw new UnsupportedOperationException();
    }

    public static void print(Printable printable, Device device) {
        // ...
    }
}

Puis nous ajoutons l’interface Printable aux classes représentant des documents imprimables.

// Implementations
public class WordDocument extends AbstractOfficeDocument implements Printable {
    // ...
}
public class PdfDocument extends AbstractWebDocument implements Printable {
    // ...
}
public class HtmlDocument extends AbstractWebDocument implements Printable {
    // ...
}
public class FlashDocument extends AbstractWebDocument  {
    // ...
}

Nous pourrons alors appeler la méthode printOn() sur les instances de WordDocument, PdfDocument et HtmlDocument, mais pas sur celle de FlashDocument.

HtmlDocument htmlDoc = new HtmlDocument();
htmlDoc.printOn(device); // OK

FlashDocument flashDoc = new FlashDocument();
flashDoc.printOn(device); // --> Erreur de compilation !!!

Nous avons ici ajouté une méthode dans certaines classes sans ajouter de lignes de code, en dehors de modifier l’en-tête de ces classes. Nous avons donc résolu un problème qu’il aurait été plus difficile de résoudre avec de l’héritage simple tel qu’il est pratiqué avec les versions actuelles de Java.

Émuler des mixins avec les VEM

Il existe une possibilité avec les VEM d’émuler les mixins intégrant l’héritage multiple d’états. Il faut dans ce cas passer par une Map située dans la classe utilitaire dont les clés sont les instances et les “valeurs” associées sont les parties de l’état lié au mixin.

Prenons le cas d’un appareil quelconque possédant un interrupteur. Un tel appareil peut être modélisé par une classe implémentant l’interface SwitchableDevice contenant deux méthodes isActivated() et setActivated(). En fait, SwitchableDevice représente notre mixin. Une telle classe dépend forcément d’un état contenu dans une variable booléenne. Si nous ne voulons pas redéclarer la variable booléenne et réimplémenter les méthodes isActivated() et setActivated() à différents endroits du code, on peut se baser sur l’implémentation suivante :

/**
 * Interface représentant le mixin
 */
public interface SwitchableDevice {
    boolean isActivated()
        default SwitchableDevices.isActivated;

    void setActivated(boolean activated)
        default SwitchableDevices.setActivated;
}


/**
 * Implémentation du mixin
 */
public final class SwitchableDevices {
    // conteneur d'état
    private static final Map<SwitchableDevice, Boolean> SWITCH_STATES
            = new HashMap<SwitchableDevice, Boolean>();

    private SwitchableDevices() {
        throw new UnsupportedOperationException();
    }

    public static boolean isActivated(SwitchableDevice device) {
        return !SWITCH_STATES.containsKey(device) || SWITCH_STATES.get(device);
    }

    public static void setActivated(SwitchableDevice device, boolean activated) {
        SWITCH_STATES.put(device, activated);
    }
}

Note : Faites attention à l’implémentation ci-dessus, car elle possède un certain nombre de caractéristiques qui peuvent s’avérer dangereuses : le code n’est pas thread safe, il peut produire des fuites mémoires et son comportement dépend complètement de la manière dont sont définies les méthodes hashCode() et equals() au niveau des instances de SwitchableDevice. Cette implémentation permet uniquement de faciliter la compréhension.

public abstract class AbstractDevice {}


public class MyDummyDevice1 extends AbstractDevice implements SwitchableDevice {}
public class MyDummyDevice2 extends AbstractDevice implements SwitchableDevice {}

MyDummyDevice1 device1 = new MyDummyDevice1();
MyDummyDevice2 device2 = new MyDummyDevice2();

device1.setActivated(true);

System.out.println("Device 1 activated: " + device1.isActivated()); // doit afficher true
System.out.println("Device 2 activated: " + device2.isActivated()); // doit afficher false

On peut donc émuler les mixins avec état en utilisant les VEM. Cependant, l’écriture se révèle assez fastidieuse et peut l’être plus encore dès lors que nous avons besoin de protéger le code contre les bugs.

Conclusion

Nous avons vu ici ce que seront les méthodes virtuelles d’extension (VEM). Nous avons découvert comment elles apparaîtront au sein du code Java et comment s’effectuera leur résolution. Puis nous avons comparé les VEM et les mixins en essayant d’émuler un mixin en utilisant les VEM.

L’arrivée des lambda expressions et des VEM représente en soit une petite révolution dans le monde Java. Ces éléments peuvent participer à la simplification du code écrit en Java, mais aussi à la mise en place de moyens d’expression très puissants.

Pour aller au delà de la notion de VEM, il peut être intéressant que le JCP se penche sur une syntaxe permettant de faciliter l’écriture de mixins. Mais en attendant, les autres questions que nous pouvons nous poser concerne l’intégration des VEM et des lambdas dans les IDE. De plus, le débogage se révèlera-t-il aisé ?

Références

Billets sur le même thème :

7 commentaires

  • Dans un premier temps, les VEM permettront surtout d’ajouter plein de méthodes dans les interfaces existantes, et donc de faire évoluer proprement les APIs existante !

    Et le truc fort c’est que cela ne sera pas « simplement » convertie en un appel de méthode static, puisqu’à les classes seront libre d’implémenter (ou non) ces méthodes.

    Cela peut permettre de proposer une implémentation par défaut de ces méthodes, tout en permettant aux différentes classes de proposer une version plus spécifique si besoin ! Chose qu’il est difficile de faire avec une seule méthode static…

    L’API de Collections devraient énormément y gagner ;)

    a++

  • Les VEM permettent de résoudre de nombreux problèmes de compatibilité mais, à moins que des règles supplémentaires ne soient posées, le conflit de signature reste à résoudre.

    Si des méthodes existaient déjà avec la même signature (nom + liste de paramètres typés), on peut se retrouver avec des incompatibilités. Par exemple :

    public class OtherA implements A {
    public void m() throws MyCheckedException { System.out.println(« hello »); }
    }

    ou

    public class OtherA implements A {
    public String m() { return « hello »; }
    }

    ou encore

    public class OtherA implements A {
    protected void m() { System.out.println(« hello »); }
    }

  • Là dessus, je ne suis que le messager et je ne peux te répondre. Il est vrai que si un développeur a été amené à écrire dans son application sous Java 7 (ou antérieur) :

    public class ArrayListCustom extends … implements List {
    protected Collection
    filter(Predicate predicate) throws MyCheckException { … }
    }

    et qu’en passant à Java 8, le JDK définit :

    public interface List extends … {
    List
    filter(Predicate predicate) default Lists.filter;
    }

    La mise à jour de Java sur le projet risque d’être assez douloureuse… Le développeur se verra contraint de renommer sa méthode filter() dans la classe ArrayListCustom. Et s’il ne peut pas le faire, il devra changer la visibilité de la méthode et aussi changer son type de retour.

    Merci pour ta remarque.

  • Ce genre de problème existe déjà lorsqu’on rajoute une méthode dans une classe : il est tout à fait possible qu’une des classes filles possède déjà une méthode du même nom, avec un type de retour ou des checked-exceptions différentes.

    Et comme le bytecode permet cela, il est possible de maintenir une compatibilité « binaire » qui fait coexister les deux versions.

    Du coup cela devient uniquement une incompatibilité au niveau des sources, ce qui est déjà un peu plus acceptable…

    a++

  • Merci pour la précision. Je me disais qu’il y avait un risque de non compatibilité et cela m’étonnait puisque historiquement chaque version assure une compatibilité ascendante.

    Effectivement, le même problème se pose avec la mise à jour de toute classe potentiellement parente (ici, virtuelle). Et la compatibilité « binaire » est l’outil utilisé pour assurer une montée de version ‘sans douleur’. La compatibilité dans le cas des VEMs pourrait donc ne pas être totale mais il n’y aurait pas d’impact à mettre à jour son jre sur les serveurs.

  • Remarque : bien qu’il y ait une volonté forte de compatibilité, chaque nouvelle version de Java apporte son lot de nouvelle incompatibilité, même si généralement cela s’applique à des cas bien spécifique…

    Pour Java 7 les principales incompatibilités sont listées ici : http://www.oracle.com/technetwork/java/javase/compatibility-417013.html

    a++

  • Je trouve dommage qu’il n’y ait que la possibilité de définir une VEM par une méthode statique.

    J’aurais bien aimé voir la possibilité de pouvoir créer une VEM aussi en tant qu’objet, comme un Visiteur (tel qu’il est décrit dans le livre du GoF), et de l’attacher à l’instance. Ça aurait le mérite de résoudre le problème de l’état.

Laisser un commentaire