Introduction
Oracle a une idée très précise de ce qui devra être inclus lors la sortie du JDK 8 prévue pour 2013. Au cours de son intervention à la QCon de Londres cette année, Simon Ritter a présenté les nouvelles fonctionnalités qui seront intégrées dans le JDK 8, à savoir la modularité (avec le projet Jigsaw - NDT: cette fonctionnalité a finalement été repoussée à Java 9), la convergence JRockit/hotspot, les annotations de types et le projet Lambda.
Du point de vue du langage, l'évolution la plus importante est sans doute liée au projet Lambda; celui-ci inclut le support des expressions lambda, les méthodes d'extension virtuelles et un meilleur support pour les plateformes multicoeurs grâce aux collections parallèles.
La plupart de ces fonctionnalités sont d'ores et déjà disponibles dans de nombreux autres langages reposant sur la JVM, dont Scala. De plus, de nombreuses décisions techniques prises pour Java 8 sont étonnement proche de Scala. En conséquence, se familiariser avec Scala est une bonne manière de toucher du doigt ce à quoi la programmation avec Java 8 ressemblera.
Nous explorerons dans cet article les nouvelles fonctionnalités de Java 8 en utilisant à la fois la nouvelle syntaxe proposée par Java ainsi que la syntaxe Scala. Nous découvrirons les expressions lambda, les fonctions d'ordre supérieur, les collections parallèles et les méthodes d'extension virtuelles, autrement appelées 'traits'. Par ailleurs, nous donnerons un aperçu des nouveaux paradigmes intégrés dans Java 8, parmi lesquels la programmation fonctionnelle.
Le lecteur découvrira que ces nouveaux concepts incorporés à Java 8 - et déjà disponibles en Scala - pourraient ouvrir la voie à un changement fondamental de paradigme, apportant de nombreuses possibilités nouvelles et changeant profondément notre manière de coder.
Expressions lambda / Fonctions
Après tant d'attente, Java 8 va finalement introduire les expressions lambda dans le monde Java! Les expressions lambda sont intégrées au projet Lambda depuis 2009. À cette époque, on y faisait encore référence en parlant de closures. Mais avant de sauter tête la première dans du code, nous allons d'abord en présenter les avantages.
Des expressions lambda, pour quoi faire?
Les expressions lambda sont souvent rencontrées dans le développement d'interfaces graphiques (GUI). En général, les programmes GUI consistent à lier des comportements à des évènements. Par exemple, si un utilisateur appuie sur un bouton (évènement), le programme doit exécuter une action particulière (comportement). Cela peut être la sauvegarde de données en base par exemple. Avec Swing, on utilise les ActionListeners pour réaliser de telles actions:
class ButtonHandler implements ActionListener {
public void actionPerformed(ActionEvent e) {
//do something
}
}
class UIBuilder {
public UIBuilder() {
button.addActionListener(new ButtonHandler());
}
}
Cet exemple montre l'utilisation d'une classe ButtonHandler en tant que callback. Le but de cette classe est uniquement de présenter la méthode actionPerformed définie dans l'interface ActionListener. Nous pourrions donc simplifier ce code verbeux en utilisant une classe interne anonyme:
class UIBuilder {
public UIBuilder() {
button.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent event) {
//do something
}
}
}
}
Ce code est un peu plus propre. Mais en l'analysant plus attentivement, nous constatons que nous créons toujours une instance de classe pour appeler une seule méthode. C'est exactement ce genre de problème que les expressions lambda peuvent résoudre.
Expressions lambda en tant que fonctions
Une expression lambda est la définition même d'une fonction: elle définit des paramètres d'entrée ainsi qu'un corps de fonction. La syntaxe des expressions lambda avec Java 8 est encore en cours de discussion, mais il est probable qu'elle ressemblera à quelque chose comme ceci:
(type parameter) -> function_body
Un exemple concret:
(String s1, String s2) -> s1.length() - s2.length();
Cette expression calcule la différence entre deux longueurs de chaînes de caractères. Il y a quelques extensions à cette syntaxe permettant par exemple d'omettre le type des arguments d'entrée et de supporter les définitions de corps s'étendant sur plusieurs lignes à l'aide d'accolades.
La méthode Collection.sort() s'applique parfaitement à l'utilisation d'une expression lambda. Le code suivant nous permet de trier une collection de chaînes de caractères à partir de leurs tailles:
List <String> list = Arrays.asList("looooong", "short", "tiny" );
Collections.sort(list, (String s1, String s2) -> s1.length() - s2.length());
"tiny", "short", "looooong"
Ici, au lieu de transmettre à la méthode sort une implémentation de l'interface Comparator comme nous le ferions avec la version actuelle de Java, nous pouvons transmettre uniquement l'expression lambda nécessaire pour atteindre le résultat voulu.
Expressions lambda en tant que closures
Les expressions lambda possèdent des propriétés intéressantes. L'une d'entre elle se nomme closure. Une closure permet à une fonction d'accéder aux variables définies à l'extérieur de son scope.
String outer = "Java 8"
(String s1) -> s1.length() - outer.length()
Cet exemple montre que la lambda que nous avons défini peut accéder à la variable outer bien qu'elle soit définie en dehors de son scope. Les closures sont donc très pratiques dans le cas où du code est défini comme étant in-line.
Inférence de types avec les expressions lambda
L'inférence de type, introduite avec Java 7, s'applique aussi aux expressions lambda. Pour faire simple, l'inférence de type permet au programmeur de ne pas indiquer de type lorsque le compilateur peut l'inférer, le déduire, par lui même.
Si l'inférence de type était utilisée pour notre expression de trie, elle pourrait être écrite de la manière suivante:
List<String> list = Arrays.asList(...);
Collections.sort(list, (s1, s2) -> s1.length() - s2.length());
Comme vous pouvez le voir, les types d'entrée s1 et s2 ne sont pas indiqués. Dans la mesure où le compilateur sait que list est une collection de String, il sait également qu'une expression lambda utilisée en tant que comparateur doit aussi avoir deux paramètres d'entrée de type String. En conséquence, leurs types n'ont pas besoin d'être déclarés explicitement bien qu'il soit possible de le faire.
Le principal avantage de l'inférence de type est de réduire la verbosité du code. Si le compilateur est en mesure d'inférer les types de nos variables, pourquoi devrions nous les définir malgré tout?
Bonjour les expressions lambda, au revoir les classes internes anonymes
Voyons comment les expressions lambda et l'inférence de type nous aide à simplifier la fonction callback que nous avons défini plus haut:
class UIBuilder {
public UIBuilder() {
button.addActionListener(e -> //process ActionEvent e)
}
}
Au lieu de définir une classe contenant notre méthode de callback, nous pouvons maintenant passer une lambda directement en paramètre de la méthode addActionListener. En plus d'améliorer la lisibilité de notre code en en réduisant la verbosité, cela nous permet de coder les seules choses qui nous intéressent vraiment: la gestion de l'évènement.
Avant de détailler plus avant les avantages des lambdas, nous allons jeter un oeil à la manière dont elles sont définies en Scala.
Les expressions lambda en Scala
Les fonctions sont les briques élémentaires dans ce que l'on appelle la programmation fonctionnelle. Scala mêle orientation objet et programmation fonctionnelle. En Scala, une expression lambda est une brique appelée fonction ou fonction littérale; celles-ci sont des objets de première classe (*first class citizen*) et elles peuvent être assignées à des vals ou des vars (variables finales ou non), passées en argument d'autres fonctions et combinées de sorte à créer de nouvelles fonctions.
En Scala, une expression lambda s'écrit de la manière suivante:
(argument) => //function body
Par exemple, la lambda permettant de calculer la différence de taille entre deux chaînes de caractères:
(s1: String, s2: String) => s1.length - s2.length
Les lambda sont aussi des closures en Scala: elles peuvent accéder à des variables définies en dehors de leur scope:
val outer = 10
val myFuncLiteral = (y: Int) => y * outer
val result = myFuncLiteral(2)
20
Ce code retourne 20 comme résultat. Comme vous pouvez le voir, nous avons assigné une fonction à une variable nommée myFuncLiteral.
Les similarités sémantiques et syntaxiques entre les expressions lambda en Java 8 et en Scala sont remarquables. Sémantiquement elles sont identiques. Syntaxiquement la seule différence réside dans le symbole liant les arguments aux coeurs des fonctions (-> en Java et => en Scala).
Les fonctions d'ordre supérieur: des briques élémentaires recyclables
L'avantage des fonctions littérales est que l'on peut les passer à d'autres littérals, comme une instance de String ou de n'importe quelle autre classe. Cela ouvre la porte à de nombreuses possibilités et nous permet de construire du code compact et modulaire.
Notre première fonction d'ordre supérieur
Nous appelons une fonction d'ordre supérieure toute fonction acceptant en paramètre d'entrée une ou plusieurs autres fonctions. Ainsi la méthode addActionListener que nous avons vue dans notre précédent exemple sur Swing est une fonction d'ordre supérieur. Nous pouvons bien sûr définir nos propres fonctions d'ordre supérieur. Observons l'exemple en Scala suivant:
def measure[T](func: => T):T = {
val start = System.nanoTime()
val result = func
val elapsed = System.nanoTime() - start
println("The execution of this call took: %s ns".format(elapsed))
result
}
Ici, nous définissons une méthode measure dont le rôle est de mesurer le temps d'exécution de la fonction callback appelée func. La signature de func est la suivante: elle ne prend aucun paramètre d'entrée et retourne un résultat générique de type T. Comme vous pouvez le voir, les fonctions en Scala peuvent être sans paramètres.
Maintenant nous pouvons passer n'importe quelle fonction littérale à notre méthode measure:
def myCallback = {
Thread.sleep(1000)
"I just took a powernap"
}
val result = measure(myCallback);
The execution of this call took: 1002449000 ns
Ici, d'un point de vue conceptuel, nous venons de séparer l'action de mesure du temps d'exécution d'une méthode de l'implémentation même de cette méthode. Nous avons créé deux briques élémentaires indépendantes similaires à un intercepteur.
Réutiliser ses fonctions d'ordre supérieur
Analysons un autre exemple hypothétique dans lequel nous possédons deux composants réutilisables fortement couplés:
def doWithContact(fileName:String, handle:Contact => Unit):Unit = {
try{
val contactStr = io.Source.fromFile(fileName).mkString
val contact = AContactParser.parse(contactStr)
handle(contact)
} catch {
case e: IOException => println("couldn't load contact file: " + e)
case e: ParseException => println("couldn't parse contact file: " + e)
}
}
La méthode doWithContact fait trois choses: elle extrait d'un fichier une String définissant un contact; elle parse cette String pour obtenir une instance de Contact; elle appelle la fonction handle en plaçant en paramètre cette instance de Contact. doWithContact et handle retournent toutes deux un résultat de type Unit, l'équivalent de void en Java.
Nous pouvons définir différentes méthodes de callback pouvant être passées à la méthode doWithContact:
val storeCallback = (c:Contact) => ContactDao.save(c)
val sendCallback = (c:Contact) => {
val msgBody = AConverter.convert(c)
RestService.send(msgBody)
}
val combinedCallback = (c:Contact) => {
storeCallback(c)
sendCallback(c)
}
doWithContact("custerX.vcf", storeCallback)
doWithContact("custerY.vcf", sendCallback)
doWithContact("custerZ.vcf", combinedCallback)
Les callback peuvent aussi être définis inline:
doWithContact("custerW.vcf", (c:Contact) => ContactDao.save(c))
Les fonctions d'ordre supérieur en Java 8
L'équivalent en Java 8 de la méthode doWithContact est très similaire à ce que nous avons écrit plus haut:
public interface Block<T> {
void apply(T t);
}
public void doWithContact(String fileName, Block<Contact> block) {
try{
String contactStr = FileUtils.readFileToString(new File(fileName));
Contact contact = AContactParser.parse(contactStr);
block.apply(contact);
} catch(IOException e) {
System.out.println("couldn't load contact file: " + e.getMessage());
} catch(ParseException p) {
System.out.println("couldn't parse contact file: " + p.getMessage());
}
}
//usage
doWithContact("custerX.vcf", c -> ContactDao.save(c))
Tirer parti des fonctions d'ordre supérieur
Comme vous avez pu le voir, les fonctions nous aident à séparer proprement la création d'un objet de domaine de sa manipulation. Se faisant, de nouvelles manières d'utiliser ces objets peuvent être facilement ajoutées sans être couplées à leur logique de création.
En conséquence, le bénéfice que nous obtenons en utilisant des fonctions d'ordre supérieur est le suivant: notre code respecte le principe DRY (Don't Repeat Yourself) de sorte que le programmeur peut réutiliser du code de manière fine et optimale.
Collections et fonctions d'ordre supérieur
Les fonctions d'ordre supérieur nous permettent de manipuler des collections de manière efficace. Parce que la plupart des programmes font l'usage de collections, améliorer leur efficacité peut se révéler d'une grande utilité.
Filtrer les collections: avant et après
Voyons un exemple classique en Java mettant en scène des collections: nous possédons une liste d'instances de Photo et nous souhaitons filtrer les photos dont la taille est supérieure à une valeur donnée:
List<Photo> input = Arrays.asList(...);
List<Photo> output = new ArrayList();
for (Photo c : input){
if(c.getSizeInKb() < 10) {
output.add(c);
}
}
Ce code est particulièrement verbeux, notamment lors de la création et l'ajout des photos à l'intérieur de notre nouvelle collection. Une alternative consiste à utiliser une approche un peu plus fonctionnelle en définissant une interface Function:
interface Predicate<T> {
boolean apply(T obj);
}
Voici notre code réécrit en utilisant Guava:
final Collection<Photo> input = Arrays.asList(...);
final Collection<Photo> output =
Collections2.transform(input, new Predicate<Photo>(){
@Override
public boolean apply(final Photo input){
return input.getSizeInKb() > 10;
}
});
C'est un peu mieux mais le résultat reste lourd et verbeux. Si nous traduisons notre code en Scala ou Java 8, nous avons alors un aperçu de la puissance et de l'élégance des expressions lambda.
Voici le code en Scala:
val photos = List(...)
val output = photos.filter(p => p.sizeKb < 10)
Et voici le code en Java 8:
List<Photo> photos = Arrays.asList(...)
List<Photo> output = photos.filter(p -> p.getSizeInKb() < 10)
Ces implémentations sont toutes deux élégantes et succinctes; elles font également l'usage de l'inférence de type: le type du paramètre p de filter n'est pas explicitement défini. En Scala l'inférence de type est déjà une fonctionnalité standard.
Enchaînement de fonctions en Scala
Jusqu'à présent nous avons économisé jusqu'à 6 lignes de code tout en en améliorant la lisibilité. Le plus intéressant commence toutefois lorsque nous enchaînons plusieurs fonctions d'ordre supérieur. Pour l'illustrer, créons une classe Photo et ajoutons-lui quelques propriétés en Scala:
case class Photo(name:String, sizeKb:Int, rates:List[Int])
Nous venons de déclarer une classe Photo possédant trois paramètres immuables: name, sizeKb et rates (les notes des utilisateurs, entre 1 et 10). Nous pouvons maintenant créer une instance de Photo comme ci-dessous:
val p1 = Photo("matterhorn.png", 344, List(9,8,8,6,9))
val p2 = ...
val photos = List(p1, p2, p3, ...)
À partir de cette liste de photos, nous pouvons définir différentes requêtes en enchaînant des fonctions d'ordre supérieur les unes après les autres. Supposons que nous voulions extraire le nom de toutes les photos dont la taille est supérieure à 10MB. La première question que nous devons nous poser est comment transformer une liste de photos en une liste de noms? Nous pouvons atteindre ce résultat en utilisant la plus puissance des fonctions d'ordre supérieur appelée map:
val names = photos.map(p => p.name)
La méthode map transforme une collection de type A (ici Photo) en une autre collection de type B (*String*) en utilisant la fonction qui lui est transmise (p => p.name). Donc dans ce cas précis, nous transformons notre collection de photos en une collection de noms.
Nous pouvons maintenant résoudre notre problème en chaînant l'appel à map avec la méthode filter:
val fatPhotos = photos.filter(p => p.sizeKb < 10)
.map(p => p.name)
Nous n'avons pas à nous inquiéter d'éventuels NullPointerException parce que chaque méthode (map, filter etc) retourne toujours une collection, éventuellement vide, mais jamais null. Donc si notre collection de photos était vide dès le début, notre calcul retournerait également une collection vide.
L'enchaînement de fonctions est aussi appelé "composition de fonctions". Grâce à la composition nous pouvons piocher dans l'API collection existante les briques élémentaires avec lesquelles nous pouvons résoudre notre problème.
Considérons un exemple un peu plus compliqué. Notre tâche est la suivante:
"Retourner les noms de toutes les photos dont la note moyenne est supérieure à 6, triés par ordre croissant suivant le nombre total de notes données."
val avg = (l:List[Int]) => l.sum / l.size
val minAvgRating = 6
val result = photos.filter(p => avg(p.ratings) >= minAvgRating)
.sortBy(p => p.ratings.size)
.map(p => p.name)
Pour achever cette tâche nous nous appuyons sur la méthode sortBy; cette fonction attend en paramètre une fonction qui, à partir des entrées de notre collection, donc de nos photos, retourne des instances de type Ordered (le nombre de notes est de type Int, et Int étend Ordered). Puisque List ne propose pas de méthode permettant de calculer une moyenne, nous définissons notre propre méthode, avg, qui calcule la moyenne des éléments de nos listes rates, et nous la passons à la fonction filter.
Enchaînement de fonctions en Java 8
Il n'est pas encore exactement établi quelles fonctions d'ordre supérieur Java 8 mettra à notre disposition dans la classe Collection. filter et map seront sans doute supportées. Par conséquent, notre premier exemple d'enchaînement de fonctions pourra être codé de la manière suivante:
List<Photo> photos = Arrays.asList(...)
List<String> output = photos.filter(p -> p.getSizeInKb() > 10000)
.map(p -> p.name)
Une fois de plus, il est remarquable de constater qu'il n'y a pratiquement aucune différence syntaxique entre ce code et le code Scala que nous avons écrit plus haut.
Les fonctions d'ordre supérieur utilisées sur des collections sont extrêmement puissantes. Elles rendent non seulement notre code plus succinct et plus lisible, mais elles nous épargnent en plus beaucoup de code boilerplate. De la sorte nous avons à coder moins de tests parce que notre code est moins susceptible de contenir des bugs. Et ce n'est pas tout...
Collections parallèles
Nous n'avons pas encore évoqué l'avantage le plus important qu'apportent les fonctions d'ordre supérieur lorsqu'il s'agit de manipuler des collections. En plus d'améliorer la lisibilité et de réduire la longueur de notre code, les fonctions d'ordre supérieur ajoutent un angle d'abstraction supplémentaire. Dans tous les exemples précédents nous n'avons vu aucune boucle. Pas même pour itérer sur une collection afin d'en filtrer, trier ou transformer les éléments. L'itération est transparente pour l'utilisateur de la collection, ce qui l'a rend plus abstraite.
Ce cadre d'abstraction supplémentaire apporte un avantage aux plateformes multicoeurs: l'implémentation effective de l'itération peut décider par elle même de la manière dont elle manipulera une collection. En conséquence, itérer peut désormais être effectué de manière parallèle. Avec la prolifération des plateformes multicœurs, tirer profit des processus parallèles n'est plus une option et les langages modernes doivent être capables de satisfaire cette demande.
En théorie, nous pourrions écrire notre propre code parallèle. En pratique, ce n'est pas une bonne idée. D'abord, écrire du code parallèle est extrêmement difficile, notamment lorsqu’il s'agit de partager des états et des résultats intermédiaires en vue de les fusionner. Ensuite, devoir gérer différentes implémentations de cette fonctionnalité n'est pas une bonne chose. En dépit de l'API Fork/Join de Java 7, le problème de la décomposition et du regroupement des données est laissé au client et ce n'est pas le degré d'abstraction que nous recherchons. Enfin, pourquoi chercher une solution à cette question alors que la programmation fonctionnelle est elle même la réponse?
Aussi, confions aux personnes les plus capables le soin d'implémenter une fois pour toutes une itération parallèle et faisons abstraction de cet usage en utilisant des fonctions d'ordre supérieur.
Les collections parallèles avec Scala
Observons un exemple simple en Scala faisant l'usage d'un processus parallèle:
def heavyComputation = "abcdefghijk".permutations.size
(0 to 10).par.foreach(i => heavyComputation)
Nous définissons d'abord une méthode heavyComputation qui réalise un calcul lourd: avec un processeur à 4 coeurs, l'exécution de cette fonction met environ 4 secondes. Nous instancions ensuite une collection, une suite d'entiers allant de 0 à 10, et nous appelons la méthode par. Cette méthode retourne une implémentation parallèle de l'API collection; elle offre ainsi les mêmes interfaces que son équivalent séquentiel. La plupart des collections Scala ont une méthode par. Du point de vue de l'utilisateur, c'est tout ce qu'il y a à savoir.
Voyons maintenant quel gain de performance nous obtenons avec un processeur à quatre coeurs en utilisant la fonction measure que nous avons codé plus haut:
//single execution
measure(heavyComputation)
The execution of this call took: 4.6 s
//sequential execution
measure((1 to 10).foreach(i => heavyComputation))
The execution of this call took: 46 s
//parallel execution
measure((1 to 10).par.foreach(i => heavyComputation))
The execution of this call took: 19 s
Au premier abord, ce qui peut sembler surprenant est le fait que l'exécution parallèle ne soit que 2,5 fois plus rapide que l'exécution séquentielle alors que nous utilisons quatre coeurs. L'explication réside dans le fait que le parallélisme met en oeuvre des processus coûteux tels que la création de threads et la gestion de résultats intermédiaires. C'est pour cette raison que l'utilisation par défaut des collections parallèles n'est pas conseillée. Il est préférable de les utiliser lorsqu'elles apportent un bénéfice réel, c'est à dire dans le cas de processus vraiment lourds.
Les collections parallèles avec Java 8
L'interface proposée dans Java 8 pour gérer les collections parallèles est presque identique à ce que l'on trouve en Scala:
Array.asList(1,2,3,4,5,6,7,8,9.0).parallel().foreach(int i ->
heavyComputation())
La seule différence avec Scala est que le nom de la méthode permettant de créer une collection parallèle est parallel au lieu de par.
Un exemple récapitulatif
Pour récapituler ce que nous avons appris sur les concepts de fonctions d'ordre supérieur et d'expressions lambda appliqués aux collections parallèles, étudions maintenant un exemple plus large embrassant l'ensemble de ces nouvelles notions.
Pour cet exemple nous avons choisi un site au hasard offrant une grande variété de fonds d'écrans. Nous allons écrire un programme téléchargeant toutes les images figurant sur ce site. Pour se faire nous utilisons deux librairies tierces: Dispatch pour les communications http et FileUtils de la fondation Apache. Au cours de cet exercice nous évoquerons des concepts que nous n'avions pas encore rencontré mais dont l'objet devrait être facilement compréhensible.
import java.io.File
import java.net.URL
import org.apache.commons.io.FileUtils.copyURLToFile
import dispatch._
import dispatch.tagsoup.TagSoupHttp._
import Thread._
object PhotoScraper {
def main(args: Array[String]) {
val url = "http://www.boschfoto.nl/html/Wallpapers/wallpapers1.html"
scrapeWallpapers(url, "/tmp/")
}
def scrapeWallpapers(fromPage: String, toDir: String) = {
val imgURLs = fetchWallpaperImgURLsOfPage(fromPage)
imgURLs.par.foreach(url => copyToDir(url, toDir))
}
private def fetchWallpaperImgURLsOfPage(pageUrl: String): Seq[URL] = {
val xhtml = Http(url(pageUrl) as_tagsouped)
val imgHrefs = xhtml \\ "a" \\ "@href"
imgHrefs.map(node => node.text)
.filter(href => href.endsWith("1025.jpg"))
.map(href => new URL(href))
}
private def copyToDir(url: URL, dir: String) = {
println("%s copy %s to %s" format (currentThread.getName, url, dir))
copyURLToFile(url, new File(toDir, url.getFile.split("/").last))
}
Explication de code
La méthode scrapeWallpapers cible les URL des images présentes dans le code HTML du site au moyen de fetchWallpaperImgURLsOfPage et les télécharge.
L'objet Http est une classe de la librairie dispatch; celle-ci met à disposition un DSL qui agit comme une surcouche à HttpClient d'Apache. La méthode as_tagsouped convertit le code HTML en code XML, ce dernier pouvant être manipulé nativement par Scala.
À partir du contenu XHTML, nous extrayons les attributs href des images que nous voulons sauvegarder:
val imgHrefs = xhtml \\ "a" \\ "@href"
Parce que le XML est géré nativement en Scala nous pouvons utiliser l'expression x-path \ pour sélectionner les noeuds qui nous intéressent. Après avoir récupéré tous les href présents, nous devons filtrer les URLs des images et réalisons une conversion String -> URL. Nous enchaînons donc différents appels de fonctions d'ordre supérieur de l'API Collection Scala telles que map et filter. Le résultat est une liste d'URL:
imgHrefs.map(node => node.text)
.filter(href => href.endsWith("1025.jpg"))
.map(href => new URL(href))
La prochaine étape consiste à télécharger parallèlement chacune des images de notre liste. Nous transformons donc notre liste en une collection parallèle. En conséquence, la méthode foreach lance plusieurs threads pour itérer sur cette collection. Chaque thread appellera éventuellement la méthode copyToDir.
imgURLs.par.foreach(url => copyToDir(url, toDir))
copyToDir utilise la classe FileUtils de Apache Common. La méthode statique FileUtils.copyURLToFile est importée statiquement et peut donc être appelée directement. Pour plus de clarté nous affichons aussi le nom du thread effectuant cette tâche, nous aurons ainsi la preuve que plusieurs threads sont utilisés pour ce processus.
private def copyToDir(url: URL, dir: String) = {
println("%s copy %s to %s" format (currentThread.getName, url, dir))
copyURLToFile(url, new File(toDir, url.getFile.split("/").last))
}
Les méthodes d'extension virtuelles et les traits
Les méthodes d'extension virtuelles de Java sont l'équivalent de traits de Scala. Qu’est-ce qu’un trait? En Scala, un trait propose une interface et éventuellement son implémentation, complète ou partielle. Cette structure offre d'importantes possibilités comme nous allons le voir en composant des classes à l'aide de traits.
Tout comme Java 8, Scala ne supporte pas l'héritage multiple: une classe ne peut étendre qu'une seule super-classe. Avec les traits cependant, la règle d'héritage est différente. Une classe peut incorporer plusieurs traits; ceci inclus à la fois les méthodes et l'état des ces différents traits. Pour cette raison, les traits sont aussi appelés "mixins" dans la mesure où ils permettent de mixer de nouveaux comportements et de nouveaux états au sein d'une classe.
Reste une question: si les traits supportent une certaine forme d'héritage multiple, ne risquons-nous pas de nous heurter au fameux problème du diamant? La réponse, heureusement, est non. Scala définit un ensemble très clair de règles de précédences qui détermine ce qui doit être exécuté et à quel moment dans une hiérarchie d'héritage multiple, et ce indépendamment du nombre de traits concernés. Ces règles nous permettent de conserver les bénéfices de l'héritage multiple tout en contournant la plupart des problèmes qui lui sont d'ordinaire associés.
Si seulement je pouvais utiliser un trait
L'exemple suivant montre un morceau de code familier pour un développeur Java:
class Photo {
final static Logger LOG = LoggerFactory.getLogger(Photo.class);
public void save() {
if(LOG.isDebugEnabled()) {
LOG.debug("You saved me." );
}
//some more useful code here ...
}
}
La journalisation est une préoccupation transverse d'un point de vue conceptuel. Pourtant, dans la pratique quotidienne, il s'agit d'une tâche laborieuse: chaque classe doit déclarer son logger et vérifier sans cesse si le niveau de log concerné est activé, en utilisant par exemple isDebugEnabled. Il s'agit d'une violation claire de DRY: Don't Repeat Yourself.
En Java, il n'y a aucun moyen de s'assurer qu'une programmeur déclare le bon niveau de log ou utilise le logger approprié. Les développeurs Java ont l'habitude de cet état de fait, et ils considèrent cela comme un automatisme.
Les traits offrent une bonne alternative à ce problème: si nous encapsulons la fonctionnalité de journalisation à l'intérieur d'un trait nous pouvons ensuite l'incorporer dans n'importe quelles classes. Ces classes auront désormais accès à des méthodes de journalisation tout en pouvant hériter d'une autre classe.
Un trait de journalisation
En Scala, nous implémentons le trait Loggable de la manière suivante:
trait Loggable {
self =>
val logger = Slf4jLoggerFactory.getLogger(self.getClass())
def debug[T](msg: => T):Unit = {
if (logger.isDebugEnabled()) logger.debug(msg.toString)
}
}
Scala définit un trait à l'aide du mot clef trait. Le corps de ce trait peut contenir tout ce qu'une classe abstraite peut contenir, comme des attributs et des méthodes. Un autre fait intéressant est l'utilisation de self =>. Le logger doit journaliser le nom de la classe incorporant ce trait, pas le nom de Loggable. La syntaxe self =>, appelée self-type en Scala, permet au trait d'obtenir une référence vers la classe à laquelle il est incorporé.
Notez l'utilisation de la fonction msg: => T en tant que paramètre d'entrée de la méthode debug. La raison pour laquelle nous appelons isDebugEnabled() est que nous voulons être certain que le message que nous allons journaliser soit généré si le niveau de débogage nécessaire est activé. Si debug acceptait une String en paramètre d'entrée, ce message serait toujours généré quelque soit le niveau de débogage désiré. À l'inverse, en transmettant une fonction msg: => T, nous obtenons exactement le comportement voulu: la String à journaliser ne sera générée par la méthode msg que si isDebugEnabled retourne true. Dans le cas contraire msg ne sera pas exécuté et le message ne sera donc pas généré.
Si nous voulons utiliser notre trait dans la classe Photo, il nous suffit d'utiliser le mot clef extends:
class Photo extends Loggable {
def save():Unit = debug("You saved me");
}
Le mot clef extends donne l'impression que la classe Photo hérite de Loggable et ne peut donc pas hériter d'une autre classe. Ce n'est bien sûr pas le cas: la syntaxe Scala impose que le premier mot clef utilisé pour réaliser un héritage ou un mix de trait soit extends. Si nous voulons incorporer plusieurs traits, nous devons ensuite utiliser le mot clef with comme nous le verrons plus bas.
Pour montrer que notre code fonctionne, nous appelons la méthode save() d'une instance de Photo:
new Photo().save()
18:23:50.967 [main] DEBUG Photo - You saved me
Ajouter des comportements supplémentaires à nos classes
Comme nous l'avons vu, une classe peut incorporer plusieurs traits. En plus de la journalisation, nous pouvons donc ajouter d'autres comportements à notre classe Photo. Disons que nous voulions pouvoir ordonner des photos à partir de leur taille. Heureusement pour nous, Scala offre déjà un certain nombre de traits prêts à fonctionner. L'un de ces traits est Ordered[T]. Ordered est l'équivalent de l'interface Java Comparable. La grande différence est que la version Scala propose également une implémentation partielle:
class Photo(name:String, sizeKb:Int, rates:List[Int]) extends Loggable with
Ordered[Photo]{
def compare(other:Photo) = {
debug("comparing " + other + " with " + this)
this.sizeKb - other.sizeKb
}
override def toString = "%s: %dkb".format(name, sizeKb)
}
Deux traits sont incorporés dans l'exemple ci-dessus. En plus de celui que nous avons précédemment défini, Loggable, nous ajoutons Ordered[Photo]. Le trait Ordered[T] requiert que nous implémentions nous même la méthode compare(type: T) tout comme nous le ferions en Java.
Les instances de classes implémentant Ordered peuvent être triées par n'importe quelle collection Scala: une collection contenant des instances de Ordered permet l'appel de la méthode sorted qui effectue un trie à partir des résultats retournés par la méthode compare.
val p1 = new Photo("Matterhorn", 240)
val p2 = new Photo("K2", 500)
val sortedPhotos = List(p1, p2).sorted
List(K2: 500kb, Matterhorn: 240kb)
Les bénéfices des traits
Les exemples précédents montrent que nous sommes en mesure d'isoler des fonctionnalités génériques de manière modulaire à l'aide de traits. Nous pouvons ainsi activer ces fonctionnalités pour toutes les classes en ayant besoin, tout comme nous avons pu équiper notre classe Photo d'une fonctionnalité de journalisation en incorporant le trait Loggable ainsi que d'une capacité de comparaison avec Ordered.
Les traits sont un mécanisme puissant permettant de créer du code modulaire et DRY en utilisant les fonctionnalités du langage et donc sans complexité inutile, à l'inverse de ce que nous obtenons avec la programmation orientée aspect.
L'intérêt des méthodes d'extension virtuelles
La spécification Java 8 propose un document pour les méthodes d'extension virtuelles. Ces méthodes permettront d'ajouter des implémentations par défaut aux interfaces existantes et à venir. Quel en est intérêt?
Pour de très nombreuses interfaces actuelles, il serait grandement appréciable de pouvoir supporter les expressions lambda sous la forme de fonctions d'ordre supérieur. On pense notamment à l'interface java.util.Collection. Une méthode que nous pourrions ajouter à cette interface est la méthode foreach(exprLambda); mais, en l'absence d'une implémentation par défaut, toutes les classes implémentant Collection devraient en fournir une et il est évident que cela poserait des problèmes de compatibilité.
C'est la raison pour laquelle l'équipe JDK a proposé le concept de méthode d'extension virtuelle. Avec un tel outil nous pourrions définir une implémentation par défaut de notre méthode foreach afin que toutes les classes implémentant Collection en héritent automatiquement. Se faisant, leurs API seraient en mesure d'évoluer de manière non intrusive. Si l'implémentation fournie par Collection n'est pas satisfaisante, il sera toujours possible de réaliser un override.
Méthodes d'extension virtuelles vs Traits
En terme d'évolution de l'API Java, les méthodes d'extension virtuelles représentent un bénéfice important. Par ailleurs, l'effet de bord de ces méthodes est qu'elles permettent d'utiliser une forme d'héritage multiple limitée au comportement uniquement. Les traits en Scala permettent l'héritage multiple de comportements et d'états. Ils permettent également d'obtenir une référence vers la classe l'implémentant, comme nous l'avons vu avec Loggable.
Du point de vue de l'utilisateur, les traits sont plus puissants que les méthodes d'extension virtuelles. Cependant leur objectif n'est pas le même: en Scala les traits ont toujours été considérés comme des blocs modulaires permettant d'utiliser l'héritage multiple en contournant les problèmes traditionnellement liés à cette technique, tandis que les méthodes d'extension virtuelles doivent d'abord permettre à l'API Java d'évoluer tout en restant rétrocompatible.
Les traits Loggable et Ordered en Java 8
Afin de voir ce que nous pourrions obtenir avec les méthodes d'extension virtuelles, essayons de réimplémenter nos traits Loggable et Ordered avec Java 8.
Le trait Ordered peut être pleinement implémenté avec les méthodes d'extension virtuelles parce qu'il ne possède aucun état. Comme mentionné plus haut, l'équivalent de Ordered en Java est Comparable. Son implémentation ressemblerait donc à la suivante:
interface Comparable<T> {
public int compare(T that);
public boolean gt(T other) default {
return compare(other) > 0
}
public boolean gte(T other) default {
return compare(other) >= 0
}
public boolean lt(T other) default {
return compare(other) < 0
}
public boolean lte(T other) default {
return compare(other) <= 0
}
}
Nous avons ajouté de nouvelles méthodes à Comparable ('supérieur', 'supérieur ou égal', 'inférieur', 'inférieur ou égal') équivalent à celles trouvées dans le trait Ordered (>, >=, <, <=). Tous les appels vers ces méthodes sont redirigés vers leurs implémentations par défaut, désignées par le mot clef default. Ainsi une interface préexistante peut être enrichie de nouvelles méthodes sans que ses implémentations aient le besoin d'être modifiées. Le trait Ordered en Scala ressemble beaucoup à cette implémentation de Comparable.
Si la classe Photo implémentait Comparable, nous pourrions être en mesure d'effectuer des comparaisons en utilisant ses nouvelles méthodes:
Photo p1 = new Photo("Matterhorn", 240)
Photo p1 = new Photo("K2", 500)
p1.gt(p2)
false
p1.lte(p2)
true
En revanche le trait Loggable ne pourrait pas être complètement implémenté à l'aide des méthodes d'extension virtuelles:
interface Loggable {
final static Logger LOG = LoggerFactory.getLogger(Loggable.class);
void debug(String msg) default {
if(LOG.isDebugEnabled()) LOG.debug(msg)
}
void info(String msg) default {
if(LOG.isInfoEnabled()) LOG.info(msg)
}
//etc...
}
Nous avons pu ajouter les méthodes debug et info ainsi qu'un comportement par défaut visant à utiliser une instance statique de Logger, mais nous n'avons pu obtenir une référence vers la classe l'implémentant. Du fait de cette impossibilité, tous les logs seront enregistrés comme provenant de Loggable et non des classes qui implémentent cette interface. Les méthodes d'extension virtuelles sont donc moins appropriées pour ce genre de scénario.
Pour résumer, les traits et les méthodes d'extension virtuelles permettent tous deux de profiter de l'héritage multiple de comportements. Les traits apportent aussi le bénéfice de l'héritage de multiples états ainsi qu'un moyen d'obtenir une référence vers la classe implémentante.
Conclusion
Java 8 va apporter de nouvelles fonctionnalités et a le potentiel de fondamentalement changer la manière dont nous écrivons nos applications. En particulier, l'introduction des expressions lambda peut être considérée comme un changement de paradigme. Ce changement nous permettra d'écrire du code plus concis, compact et facile à comprendre.
En outre, les expressions lambda sont la clef qui nous permettra de tirer parti de la programmation parallèle.
Comme expliqué tout au long de cet article, ces nouvelles fonctionnalités sont d’ores et déjà disponibles en Scala. Les développeurs qui veulent les essayer peuvent le faire en téléchargeant les early builds de Java 8. Nous recommandons également de jeter un oeil à Scala afin de se préparer au changement de paradigme qui s'annonce.
Appendix
Les informations sur Java 8 proviennent principalement des présentations suivantes:
A propos des auteurs
Urs Peter est consultant senior chez Xebia. Avec plus de dix ans d'expérience en IT il a pu jouer différents rôles: développeur, architecte logiciel, responsable d'équipe technique et scrum master. Au cours de sa carrière, il a exploré un large éventail de langages, de techniques d'ingénierie logiciels et d'outils pour la plateforme JVM. Il est l'un des premiers formateurs certifiés Scala en Europe et est actuellement président de la Dutch Scala Enthusiast community (DUSE).
Sander van den Berg est actif dans le monde de l'IT depuis 1999 alors qu'il travaillait en tant que développeur pour différentes entreprises liées à la défense. Il a principalement travaillé sur les solutions MDA/MDD. Il a rejoint Xebia en tant que consultant senior en 2010 où il est responsable de la promotion de Scala ainsi que des architectures lean. Outre l'architecture, Sander s'intéresse aux designs de langages et il est actif dans différentes communautés de programmation fonctionnelle. Sander apprécie les solutions élégantes aux problèmes difficiles, avec une préférence pour Scala en tant que moyen d'exprimer ces solutions. Sander apprécie de nombreux langages parmi lesquels Clojure, Haskell et F#.