Contactez-nous
-

Feature match : Java 15 vs les autres langages de la JVM

Est-ce que Java tient encore la route ? Face aux langages plus jeunes de la JVM, Java fait souvent pâle figure en ce qui concerne les fonctionnalités. Pourtant, ses améliorations sont parfois nécessaires pour supporter les fonctionnalités des autres langages, comme par exemple le fameux invokedynamic ! Java rattrape-t-il ses concurrents ? Est-ce même son objectif ?

Feature match

Sommaire

Voyons quelles sont les nouveautés de Java et comparons-les aux fonctionnalités similaires d’autres langages comme Scala 2.13 ou Kotlin 1.4.

Seront examinées aussi bien les “language core features” que les intégrations avec le reste du monde (cycle de développement, CI/CD, cloud, etc.).

Dans ce premier article seront examinées les fonctionnalités du langage après un tour sur le support dans le cloud. Les suivants parleront APIs, performances, sécurité…

Dans le Cloud

Java intéresse forcément le Cloud Native Developer, au moins parce qu’il était déjà populaire depuis longtemps sur les data centers maison.

Quoi de neuf Docker ?

Pour les projets agnostiques du fournisseur de cloud, on pense immédiatement aux conteneurs. Or, qui n’a jamais vu une JVM conteneurisée exploser en vol avec une magnifique OOM (OutOfMemoryError) ? Pour éviter la perte de temps de l’alignement de tous les descripteurs de déploiements et autre configs de lancement, Java 8 a vu, au cours d’une mise à jour “mineure”, arriver le paramètre -XX:+UseCGroupMemoryLimitForHeap. Ce dernier a sauvé les nerfs de nombreux devs et ops en alignant automatiquement le fameux “Xmx” (taille maximum du tas) de la JVM sur la limite de mémoire définie sur le conteneur.

Serverless

Fer de lance du cloud native, cette manière d’exécuter du code nécessitait au départ un support spécifique par le fournisseur infonuagique. Aujourd’hui, on peut exécuter à peu près n’importe quoi dans une lambda sur AWS grâce aux layers : on peut personnaliser ce qui est embarqué dans la lambda comme on le ferait en personnalisant une image Docker existante avec le Dockerfile.

Néanmoins, il reste intéressant d’avoir un support spécifique pour le serverless car afin d’obtenir le niveau de productivité et d’intégration maximal promis par cette technologie, il faut utiliser le SDK du fournisseur. Sur AWS par exemple, le SDK est distribué pour Java, C#, Ruby, Python, JavaScript, PHP, Objective C et bientôt Go. Il sera donc intéressant de constater que via le SDK Java, tous les langages de la JVM peuvent accéder aux intégrations AWS. Rien ne vous empêche d’écrire une lambda en Scala ou en Kotlin par exemple.

On notera tout de même que pour l’instant, opter pour un langage JVM alternatif vous oblige à utiliser la ligne de commande pour les tests locaux et le déploiement, les intégrations IDE étant prévues pour les langages officiellement supportés. Par exemple, sous IntelliJ le clic droit → run sur le classe principale ne permettra pas de lancer votre lambda localement, il faudra passer par la ligne de commande AWS sam.

Java obtient ici un avantage de confort, mais il n’y a pas de réel inconvénient à utiliser un autre langage.

Crusadeless

Et si on accélérait un peu les lambdas ? On sait qu’une JVM, même sur la technologie serverless de votre fournisseur de cloud, est longue à démarrer. Si vous ajoutez les bibliothèques standards de Kotlin ou Scala, ça ne va pas aider. Et si en plus votre lambda est appelée peu fréquemment, même avec le pré-provisionning permis par AWS (pour avoir toujours un minimum de lambdas actives), le compilateur JIT de la JVM ne fera pas de miracle et le code risque de rester longtemps en mode interprété, retardant d’autant le moment ou votre code servira en un temps optimal.

Alors pourquoi ne pas utiliser GraalVM ? En compilant à l’avance, vous aurez une lambda légère (suppression des bibliothèques de la JVM non-utilisées) et rapide immédiatement, même sur un langage alternatif.

Java aura ici encore un avantage de confort, notamment avec des frameworks exploitant GraalVM comme Quarkus ou Micronaut. Ce dernier supporte Kotlin, ce qui fait de Scala le moins outillé dans l’optique du downsizing épaulé par un framework applicatif. Cependant, pour avoir beaucoup développé du microservice en Scala, je sais que le langage nu et son écosystème actuel permettent déjà beaucoup de choses tout en restant sur un outillage frugal. Il n’en reste pas moins que j’aimerais voir venir un support confortable de GraalVM ou autre approche “light&fast runtime” arriver sur Scala.

Fonctionnalités du langage

Switch & pattern matching

La grande nouveauté pour Java 12, c’est que le switch peut désormais être une expression. Cela permet de l’inclure dans une manière de programmer plus fonctionnelle et plus sûre en permettant la transparence référentielle plus fréquemment dans le code. Cette notion, comme l’immuabilité est une pratique issue de la programmation fonctionnelle (où c’est juste un prérequis !) qui transpire dans la POO pour la robustesse qu’elle apporte. Ce nouveau switch est donc accueilli chaleureusement.

Si le pattern matching est encore absent de Java, on commence à en voir un bout : désormais, instanceof permet de capturer la référence testée dans une variable du type testé en cas de succès. Cela évite le classique et répétitif cast après instanceof.

Malgré ces deux évolutions, Java est encore un cran en-dessous de Kotlin et loin derrière Scala, qui dispose de la version la plus puissante ; donc aucun bénéfice n’est attendu de cette évolution pour les autres langages de la JVM dans le sens où elle concerne uniquement la syntaxe Java.

Switch & pattern matching
Java switch expr. + instanceof Kotlin when Scala match
matching par valeur ; pas d'objet sauf enum par valeur structuré
scope switch entier limité au cas limité au cas
guards
capture uniquement sur instanceof uniquement le sujet structurée
syntaxe switch / case et instanceof cohérente : when() / case cohérente match / case
default case obligatoire sauf si le compilateur peut prouver la complétude obligatoire sauf si le compilateur peut prouver la complétude facultatif (MatchError), mais avertissement du compilateur sur les cas précis manquants. Possibilité de rendre l’avertissement fatal.

On notera que Kotlin dispose du sucre syntaxique in permettant de vérifier l’appartenance de la valeur à un range, mais la vérification de structure quelle que soit sa profondeur sans instancier d’objet temporaire comme en Scala ne serait même pas à l’ordre du jour. Quant à Java, il est possible qu’il bénéficie d’un pattern matching structuré dans un certain avenir, au vu de réflexions en cours.

Important également à savoir, Scala permet des cas non-exhaustifs par défaut car l’absence de cas par défaut permet la construction de fonctions partielles, inexistantes dans les autres langages de la JVM.

Inférence de type

Variables locales

En Java, les types des variables sont toujours explicitement déclarés. La raison invoquée : la lisibilité du code. En cas d’abus, il est vrai qu’on peut rapidement se retrouver avec un code difficile à lire. La nouveauté, c’est une concession faite aux variables locales. Une méthode devant être courte, retrouver le type d’une variable resterait alors simple pour le (re)lecteur du code.

Encore une fois, rien de neuf pour Scala/Kotlin. Kotlin infère même les membres de classe, ainsi que Scala qui peut inférer le type de retour des méthodes/fonctions. Sans rentrer dans le débat, cela peut tout à fait sembler légitime pour des raisons de lisibilité des codes cours (classes très simples ou fonctions très courtes) :


def square(a: Int, b: Int) = a * b //En quoi rajouter un 3ème “Int” ajoute-il de la lisibilité ?

Bref, Java est là dans un combat d’arrière-garde, les langages modernes préférant donner un peu plus de liberté. Aux dernières nouvelles, les communautés Kotlin/Scala sont très averties sur le sujet de la lisibilité du code, et des règles communément admises existent pour éviter les abus. En entreprise, les outils de style checking seront utilisés. Par exemple, la règle communément admise est de toujours mettre le type sur les membres publics afin d’avoir un code facilement utilisable et auto-documenté.

Types génériques

Introduit en Java 7, le diamond operator permettait d’éviter la répétition des types génériques dans la définition des variables. Depuis la version 9, il est possible d’omettre les génériques sur la définition de classes anonymes.

Kotlin fait mieux en omettant même les chevrons encadrant les types génériques. Il est vrai que l’utilité du “diamond operator” est discutable…

Java inférerait mieux que Kotlin en cas de bounded type parameter, mais ce dernier s’améliore sur l’inférence et pourrait donc rapidement corriger le tir.

Scala est là encore au-delà des deux autres. Le but de ce langage ayant toujours été de permettre d’aller plus loin (type-level programming, higher-kinded types, type classes…), et la syntaxe des génériques allongerait beaucoup trop le code si le compilateur n’inférait pas lorsque c’est évident pour le développeur. Certaines bibliothèques demandent même l’activation de l’unification partielle, une option du compilateur qui renforce encore l’inférence de type.

On notera que dès lors qu’on veut exploiter les types génériques Java de manière aussi ordinaire qu’en Scala, on finit avec une syntaxe infernale ; ce qui n’incite pas vraiment à explorer plus avant cet aspect du langage...

En bref, Java est à mille lieues de ce qui se fait de mieux. Une avancée majeure qui pourrait concerner tous les langages de la JVM -- pour peu que ça soit réellement utile -- serait la compensation du phénomène de type erasure, afin d’améliorer encore le pattern matching.

Records

Déjà en preview dans Java 14, les Records semblent adresser une problématique bien connue des développeurs Java, et ce depuis trop longtemps.

Les objets simples, dès lors qu’il fallait une certaine standardisation et une robustesse d’implémentation, étaient une souffrance en Java. Au point que l’on voit apparaître des générateurs (comme Lombok) pour retirer cette tâche fastidieuse au développeur.

Kotlin et Scala ont une syntaxe plus brève pour la création de classes depuis longtemps, et disposent respectivement des data class et case class.

Autant dire que les Records étaient attendus de pied ferme.

Qu’en est-il actuellement ?

  Java Kotlin Scala
Serializable + serialVersionUID
constructeur
constructeur personnalisable
equals/hashCode
toString
constructeur par copie
accesseurs de tuple component<n>() _<n> ou productElement(n: Int)
déconstruction en variables
attributs en lecture seule ✅forcément ✅à préciser ✅par défaut
getters ✅méthode ✅accès direct ✅accès direct
setters Si attribut déclaré var, accès direct Si attribut déclaré var, accès direct
déconstructeur ❗syntaxique ; pas de pattern matching ✅ fonction unapply de l’objet compagnon ; utilisée automatiquement pour le pattern matching
méthode usine ✅fonction apply de l'objet compagnon
objet compagnon ❗méthodes statiques sur le record

On notera que la déconstruction pourrait arriver dans une future version de Java.

Là encore, Java est loin de proposer la flexibilité des autres langages. Une des raisons à cela est la tare historique qu’il traîne à propos des accesseurs.

Les accesseurs

Les accesseurs accusent un lourd passif dans Java. Afin de permettre aux classes d’évoluer, les développeurs ont appris à rendre les attributs privés tout en mettant des accesseurs/mutateurs publics. On s’assure que le code client passe toujours par une méthode, au cas où on aurait besoin de faire évoluer la classe en ajoutant de la logique sur les getters/setters. Dans les faits, ces méthodes ne font rien 90% du temps, obligeant à produire du “code au mètre” sans intérêt et noyant les informations essentielles dans un océan de lignes.

C’est ainsi que des générateurs de code (comme Lombok) ont émergé.

Une autre solution radicale connue est de profiter de l’immuabilité de certains objets pour rendre leurs attributs public final, ce qui permet de se passer d’accesseurs en faisant un pari sur la stabilité du code.

Les solutions permettant à la fois de profiter d’un accès direct aux attributs dans la syntaxe, sans violer l’encapsulation et tout en gardant une classe évolutive, existent :

  • Javascript utilise les getters/setters s’ils existent. Pour l’utilisateur de l’objet, la syntaxe reste celle d’un accès direct aux attributs.
  • C# distingue les attributs qui fonctionnent comme en Java, des propriétés auxquelles on accède directement (avec un contrôle possible toutefois)
  • Kotlin et Scala choisissent de permettre la syntaxe objet.membre que membre soit une valeur ou une méthode. On notera la disparition du terme attribut. Le changement méthode ←→ valeur dans le code d’une classe ne casse pas le code appelant. Dans ces langages, le fait qu’un membre soit une valeur ou une méthode est considéré comme un détail d’implémentation. Cet accès uniforme aux membres d’un objet permet de supporter la transparence référentielle même dans la syntaxe.

Malheureusement, le passif de Java a peut-être obligé les concepteurs des Records à rester prudents et à ne pas aller trop loin. D’où des getters sans préfixe pour une syntaxe plus confortable, mais qui restent des méthodes... dont on se demande bien l’utilité, car un Record étant forcément immuable en Java, l’accès aux attributs publics pourrait être permis. Sauf si les concepteurs veulent se laisser la possibilité de faire des Records muables plus tard ? Auquel cas il faudra absolument des méthodes en Java pour les raisons évoquées plus haut.

Sérialisation

L’absence d’implémentation de la sérialisation sur les records par défaut est par contre complètement un choix plus qu’un “retard” ; voire même serait plus opportun. Java permet de décider en dernier ressort si on alourdit l’objet pour la sérialisation. Mais en même temps, un “Record” comme une “data” class n’est-elle pas supposée, par l’usage suggéré, passer promptement par une entrée/sortie ? Un grand nombre d’articles illustre déjà l’usage des Records avec des cas de DTO ou autre objets de protocoles, autant de cas qui supposent une sérialisation par défaut. Sauf que j’ai le choix de l’implémentation de la sérialisation. Si Java le laisse libre, le Record reste léger dans le cas où je choisis un mécanisme externe.

Constructeurs & tuples

Un vrai retard conceptuel en revanche se fait ressentir dans l’absence de méthode de copie ainsi que l’absence de vue “tuple”. En Kotlin/Scala, les data/case classes sont vues comme des “types produits”, ou autrement dit des “tuples nommés”. La cohérence avec les tuples permet plus de flexibilité, et rend naturelle la déconstruction.

Quant à la méthode de recopie, c’est un confort indispensable aujourd’hui. Multiplier les copies d’objets en ne redéfinissant que les attributs dont les valeurs changent est devenu monnaie courante en Kotlin et Scala.

Illustration avec cette classe :


case class EnterpriseClass(
                      id: Long,
                      businessId: String,
                      organization : String,
                      name: String,
                      value: BigDecimal,
                      valueDate: LocalDateTime
                   //imaginez 10 autres attributs
                   )

val first = EnterpriseClass(
    9000000000L, 
    "9MM", 
    "Big Zinzin", 
    "RAROC", 
    0.95012, 
    LocalDateTime.now().minusDays(7)
    //imaginez 10 autres attributs
)
val second = first.copy(
    value = 0.887766, valueDate = LocalDateTime.now()
); // 10 bébés phoques et 2 ours blancs sauvés

Ci-dessus, en précisant juste 2 valeurs j’ai une copie de ma première instance, mais avec value et valueDate mis à jour. Pour ceux qui se demanderaient si la copie est superficielle ou profonde, sachez qu’elle est superficielle, ce qui particulièrement adapté puisque les attributs d’une case class sont immuables par défaut (d’où l’omission de val dans les arguments du constructeurs)

Là encore, l’amélioration nécessite peut-être une fonctionnalité qui fait défaut à Java : les signatures de méthode avec valeur par défaut. Si on comprend pourquoi les simplifications de Java par rapport à C++ ont aidé à son succès, l’abandon des valeurs par défaut est pour moi incompréhensible ! On comprend mieux leur retour en force dans les langages récents.

Méthodes privées dans les interfaces

Java 8 avait ajouté les implémentations par défaut dans les interfaces, permettant de réduire le nombre de classes d’implémentations et d’améliorer la productivité sur les nouvelles fonctionnalités (ou celles qui sont simples) en permettant d’ajouter un comportement par défaut à tous les implanteurs d’une interface.

Avec les méthodes privées, les interfaces de Java 9 peuvent mettre en commun du code utilisé dans plusieurs méthodes par défaut. Cela évite ou une répétition, ou la création de méthodes statiques dans une autre classe.

Si cela permet d’obtenir désormais une entité cohérente, nous ne sommes pas encore au niveau des traits permis par Kotlin et Scala.

  interfaces Java interfaces Kotlin traits Scala
constantes (valeur statique)
variables/valeurs abstraites
variables/valeurs concrètes
méthodes abstraites
méthodes concrètes
implémentation multiple par une classe
Règles d’implémentation multiple avec collision de méthode Force la classe à redéfinir pour expliciter Force la classe à redéfinir pour expliciter ; syntaxe de délégation Règle de mixage standard ; possibilité de redéfinir

Il n’y a pas de variables statiques en Kotlin/Scala. Les membres statiques sont remplacés par des membres sur l’objet compagnon de la classe, de l’interface ou du trait. L’absence de constante sur l’interface Kotlin ou le trait Scala n’est donc pas un réel désavantage, c’est assumé.

Par contre, Java n’a toujours pas la possibilité de définir des variables/valeurs dans une interface. Kotlin le permet si elles sont abstraites (à initialiser par l’implémentation) et Scala permet même des variables/valeurs concrètes. Le retard de de Java s’explique par les simplifications drastiques faites en POO, que le langage peine à rattraper avec des interfaces améliorées. En Scala, le plus avancé sur ce sujet parmi les trois, une interface n’est qu’un trait totalement abstrait. Mais de manière générale, un trait est un pan complet de comportement, une “caractéristique” mixable sur un objet (avec ou sans contrainte sur les cibles possibles) ou spécialisable. Les règles de mixage permettent une résolution simple sans ambiguïté. L’héritage en losange, cas problématique de l’héritage multiple, n’existe plus mais l’intention qui était derrière est toujours possible ; les traits sont en quelque sorte en POO une redéfinition propre de l’héritage multiple. D’autres langages hors-JVM utilisent cette notion.

On notera qu’en Scala, qui est autant orienté PF (programmation fonctionnelle) que POO, les traits sont également la base des Type Classes (c’est ainsi qu’Haskell implémente les traits).

En cas de conflit de méthodes lors de l’implémentation de plusieurs interfaces, deux cas se présentent :

  • le cas banal : une classe implémente 2 interfaces avec la même méthode. Celle-ci l’implémente une fois pour les 2 interfaces.
  • Le cas trait/default interface : une classe implémente 2 interfaces avec la même méthode, mais avec 2 implémentations par défaut.

Le premier cas ne pose aucun problème.

Dans le second, Java et Kotlin le permettent, à condition que l’implémenteur réalise la méthode. Ce dernier peut appeler explicitement les super-méthodes des 2 interfaces. Mais seul Kotlin permet de remplacer l’héritage par une délégation particulière pour chaque interface avec une syntaxe brève.

Scala va quant à lui demander de désigner un trait principal (derrière le mot-clé extends) et un ou plusieurs autres traits (derrière le mot-clé with). Puis une règle appelée linéarisation permet de revenir à un héritage simple et déterministe. Ainsi, super est déterminé et ne nécessite pas de précision sur le type parent entre chevrons comme en Kotlin. Le compilateur Scala ne demande à redéfinir la méthode explicitement que si les traits implémentés n’ont pas de super-trait commun.

Voici l’illustration du cas conflictuel en Java :


public interface Flying  extends Moving {

  default Boolean moveTo(Integer x, Integer y, Integer z) {
     if(y <0) {
        System.out.println("I cannot fly in the water or under the ground !");
        return false;
     }
     System.out.format("I'm flying to (%s; %s) at altitude %s.", x, z, y).println();
     return true;
  }
}

public interface Moving {
  public Boolean moveTo(Integer x, Integer y, Integer z);
}

public interface Swimming extends Moving{

  default Boolean moveTo(Integer x, Integer y, Integer z) {
     if(y > 0) {
        System.out.println("I cannot swim in the air !");
        return false;
     }
     System.out.format("I'm swimming to (%s; %s) at %s depth.", x, z, y).println();
     return true;
  }
}

public class FlyingFish implements Swimming, Flying{
  @Override
  public Boolean moveTo(Integer x, Integer y, Integer z) {
     //1) implementation of moveTo is mandatory for conflict resolution
     //2) Any reference to super must be prefixed to reach the right "moveTo"
     return Swimming.super.moveTo(x, y, z) || Flying.super.moveTo(x, y, z) ;
  }
}

En Kotlin :


interface Moving {
   fun moveTo(x: Int, y: Int, z: Int): Boolean
}

interface Flying: Moving {
   override fun moveTo(x: Int, y: Int, z: Int): Boolean {
       if(y < 0) {
           println("I cannot fly in the water or under the ground !");
           return false
       }
       println("I'm flying to ($x; $z) at altitude $y.")
       return true;
   }
}

interface Swimming: Moving {
   override fun moveTo(x: Int, y: Int, z: Int): Boolean {
       if(y > 0) {
           println("I cannot swim in the air !");
           return false
       }
       println("I'm swimming to ($x; $z) at $y depth.")
       return true;
   }
}

class FlyingFish: Swimming, Flying {
   override fun moveTo(x: Int, y: Int, z: Int): Boolean {
   //1) implementation of moveTo is mandatory for conflict resolution
   //2) super is typed using a generic-like syntax
       return super
  
   .moveTo(x, y, z) || super
   
    .moveTo(x, y, z) } } 
   
  

Kotlin dispose également de la solution de délégation, qui consiste à déléguer l’implémentation d’une interface à un membre spécifique. Cela permet d’économiser l’écriture des méthode redéfinie qui est triviale dans ce cas.

En Scala, il existe plusieurs manières de faire. Soit on fait comme en Kotlin et en Java :


trait Moving {
  def moveTo(x: Int, y: Int, z: Int): Boolean
}

trait Flying extends Moving {
  override def moveTo(x: Int, y: Int, z: Int): Boolean = {
     if(y < 0) {
        println("I cannot fly in the water or under the ground !")
        false
     } else {
        println(s"I'm flying to ($x; $z) at altitude $y.")
        true
     }
  }
}

trait Swimming extends Moving {
  override def moveTo(x: Int, y: Int, z: Int): Boolean = {
     if(y > 0) {
        println("I cannot swim in the air !")
        false
     } else {
        println(s"I'm swimming to ($x; $z) at $y depth.")
        true
     }
  }
}

class FlyingFish extends Swimming with Flying {
  //1) If no implementation, linearization will call Flying's super.
  //2) If implementing, I can do like in Kotlin
  override def moveTo(x: Int, y: Int, z: Int): Boolean = {
     super.moveTo(x, y, z) || super[Swimming].moveTo(x, y, z)
  }
}

Mais dans notre cas, comme nous avons une interface Moving qui définit un contrat commun, il y a une autre manière de faire : la linéarisation.


trait Moving {
  def moveTo(x: Int, y: Int, z: Int): Boolean
}

trait MovingFailure extends Moving {
  override def moveTo(x: Int, y: Int, z: Int): Boolean = {
     println("No other way to move. I will stay here.")
     false
  }
}

trait Flying extends Moving {
  abstract override def moveTo(x: Int, y: Int, z: Int): Boolean = {
     if(y < 0) {
        println("I cannot fly in the water or under the ground !")
        super.moveTo(x, y, z)
     } else {
        println(s"I'm flying to ($x; $z) at altitude $y.")
        true
     }
  }
}

trait Swimming extends Moving {
  abstract override def moveTo(x: Int, y: Int, z: Int): Boolean = {
     if(y > 0) {
        println("I cannot swim in the air !")
        super.moveTo(x, y, z)
     } else {
        println(s"I'm swimming to ($x; $z) at $y depth.")
        true
     }
  }
}

class FlyingFish extends MovingFailure with Swimming with Flying {
  //Thanks to linearization, our hierarchy looks like FlyingFish --> Flying --> Swimming --> MovingFailure --> Moving
  override def moveTo(x: Int, y: Int, z: Int): Boolean = {
     super.moveTo(x, y, z)
  }
}

Dans la classe ci-dessus, notre poisson volant va tenter d’abord de voler. Si cela échoue, le trait Flying dit de tenter d’appeler la méthode de la super-classe. Comme Swimming a été mixée juste avant sur FlyingFish, c’est elle la super-classe, notre poisson tente donc ensuite de nager. Si cela échoue encore, il tentera d’appeler la méthode de la classe parente. Ici, c’est MovingFailure qui a été mixée en premier, son implémentation sera appelée. Si via un appel à super on arrive à une méthode non implémentée, le compilateur lancera une erreur. Vous trouverez les détails sur le mixage de trait dans la doc officielle.

Vous remarquerez que le mixage “insère” les traits dans la hiérarchie, permettant de créer une nouvelle classe avec plusieurs décorateurs sur une classe de base.

Sur ce point, on conclura que Java a pris du retard : ne permettant pas le mixage, il force la délégation sans en avoir les sucres syntaxiques. Verbosité au rendez-vous...

Blocs de texte

Avec cette fonctionnalité, Java permet enfin les chaînes de caractères multi-lignes ! Malheureusement, les règles d’indentation et de suppression des espaces non significatifs nécessitent un apprentissage ou quelques tâtonnements.

Pour éviter une complexité d’apprentissage inappropriée pour une syntaxe censée simplifier la vie, les jeunes langages de la JVM vont venir avec des règles simples : tout espace/tabulation est significatif, mais avec possibilité de “dessiner” la marge avec un caractère personnalisable pour le retirer ensuite (trimIndent() et trimMargin() en Kotlin, stripMarging() en Scala).


//this scala code:
val text =
  """
    |How many tabs ?
    |    One ?
    |        Two ?
    |""".stripMargin
println(text)

//...will display:
How many tabs ?
    One ?
          Two ?

Si on peut s’habituer aux règles d’indentation des blocs de texte Java, demeure l’absence d’interpolation de chaîne intégrée au langage. Aujourd’hui, on doit encore faire comme en C avec une fonction à arguments multiples. Vous avez dit verbeux ?

Identificateurs devenus invalides

Underscore

Selon les antédiluviennes spécifications de Java, un identificateur (nom de variable, méthode, classe, package) doit commencer par une lettre (au sens Unicode) ou un underscore (“_”), et peut être suivi de lettres, chiffres et underscores.

Cela permettait de nommer une variable “_”.

Depuis Java 9, il est interdit de faire commencer un identificateur par un underscore pour une bonne raison. Java s’offre la possibilité de l’utiliser comme un joker contextuel comme c’est le cas en Scala.

Cette interdiction fait suite à une dépréciation de son utilisation depuis les spécifications de Java 8.

var

On notera également que depuis l’introduction du mot-clé var en Java 10, ce mot-clé ne peut plus désigner un nom de classe ou de variable.

Si en Kotlin ou Java ce mot désigne une variable qu’on peut ré-assigner, en Java il s’agit juste d’un raccourci pour omettre la déclaration du type, et n’est autorisé que pour les variables locales ; en Kotlin et Scala, on peut omettre partout le type, même si ce n’est pas conseillé pour les attributs publics (pour un code auto-documenté).

On verra donc des final var là où Kotlin et Scala ont un val. C’est déjà de la verbosité en trop pour une fonctionnalité arrivée après les jeunes langages, et pourtant elle souffre d’un autre défaut qui va encore augmenter vos lignes de code : elle perd parfois le compilateur. Si en Scala le compilateur a un moteur d’inférence extrêmement fort qui vous permet dans la plupart des cas d’omettre la déclaration de type, le compilateur Java s’appuie trop souvent sur le type déclaré et la moindre incursion dans du code un peu plus fonctionnel que d’habitude (en utilisant les lambdas de Java 8), avec des types génériques (fonctionnalité de Java 5 !) va vous obliger à préciser les types paramétrés de manière précise. La lisibilité disparaît alors dans un brouillard de chevrons.

Vous l’aurez compris, on a eu droit ici à une demi-mesure là où beaucoup réclamaient du var et du val pour encourager la pratique immutability first.

L’annotation @SafeVarargs

Elle fait partie des annotations qui suppriment des avertissements du compilateur. C’est malheureusement le développeur qui porte toute la responsabilité… Rien n’est garanti.

Kotlin détecte les cas d’utilisation non sûrs des varargs. Le tableau de type générique T sera transformé à la compilation en tableau du type T réel déterminé à la compilation. Dans les cas où le type générique ne serait pas sûr, comme par exemple lorsqu’on passe plusieurs collections de T à l’endroit où un vararg T est attendu, le spread operator permet de dire qu’on veut transformer les collections de T en un seul vararg du type des éléments. Le compilateur contrôle que c’est possible, et réifie le vararg avec le bon type (au lieu de Object comme en Java).

En Scala, ce problème n’existe pas non plus : vous êtes protégés par la manière dont le langage est construit et l’absence de types natifs avant la compilation. Les varargs sont typés comme des Seq[T]T est contrôlé à la compilation. Il est impossible d’assigner un vararg de List[String] à un tableau d’objet comme c’est le cas en Java. La faute originelle amenant à une pollution du tas par un Object[] est donc impossible. Autre protection : les tableaux natifs n’existent pas en Scala. Avant compilation, Array[T] est une collection. Il ne devient un tableau natif qu’après la compilation.

Illustration en Java :


import java.util.Arrays;
import java.util.List;

class Scratch {

  @SafeVarargs // Not actually safe!
  static void m(List
  
   ... stringLists) { Object[] array = stringLists; List
   
     tmpList = Arrays.asList(42); array[0] = tmpList; // Semantically invalid, but compiles without warnings String s = stringLists[0].get(0); // Oh no, ClassCastException at runtime! } public static void main(String[] args) { m(List.of("a", "b", "c"), List.of("A","B", "C")); } } 
   
  

En Scala :


object Scratch {

  def m(stringLists: List[String]*): Unit = {
     val array: Array[AnyRef] = stringLists //Not compiling: "Type mismatch. Required: Array[AnyRef] ; Actual: Seq[List[String]]"

     val tmpList: List[Int] = List(42)
     array(0) = tmpList;
     val s: String = stringLists(0)(0)

  }
 
  def main(args: Array[String]): Unit = {
     Scratch.m(List("a", "b", "c"), List("A", "B", "C"))
  }
}

Notez que remplacer AnyRef par Object ne change rien, et remplacer Array[AnyRef] par Seq[AnyRef] provoque une erreur de compilation  à la ligne array(0) = tempList car une Seq n’est pas modifiable. Déclarer array comme mutable.Seq afin de pouvoir rendre possible l’assignation array(0) = tempList rend à nouveau impossible l’assignation du début ; on ne peut pas assigner une Seq (immuable) à une mutable.Seq, ce n’est pas la même hiérarchie de classe.

Conclusion : n’attendez rien de cette annotation, à part faire taire un compilateur impuissant… Si toutefois vous êtes sûr de vous !

Améliorations du try with resources

L’Automatic Resource Management (ARM), cette amélioration de Java 7, a bénéficié d’un coup de jeune en Java 9.

Jusqu’à Java 8, seules les variables déclarées final pouvaient être dans le bloc de déclaration de ressources du try. À partir de Java 9, il est possible d’intégrer avec les ressources déclarées dans les ressources du try, d’autres ressources dont la variable n’est pas déclarée final mais l’est factuellement (“effectively final”).

Par exemple, si vous déclarez l’utilisation d’un ResultSet dans try, vous pouvez ajouter la variable qui contient la connection à la base.

Kotlin propose lui un ARM en étendant l’interface Closable de Java (donc ça fonctionne avec une JVM 1.6) avec une fonction using qui prend un bloc de code qui n’est qu’une lambda. Le bloc de code est donc enveloppé par une méthode qui libérera la ressource utilisée ; vous pouvez imbriquer la structure en cas d’utilisation de plusieurs ressources.

Mais… ce n’est pas la meilleure solution, il faudra écrire votre propre fonction using si vous souhaitez gérer ça de manière plus lisible.

En Scala, la situation est pire puisque rien n’est prévu ! Il faudra réinventer la roue, même si c’est finalement assez simple d’écrire soi-même une implémentation correcte d’ARM.

Sur ce point, Java est donc toujours en avance sur ses concurrents ; le try-with-resource simplifie sa syntaxe. Même si l’apport n’est pas aussi important pour Kotlin et Scala, c’est un lieu commun du code et un irritant récurrent qui devrait faire partie de la trousse à outil standard de n’importe quel langage. C’est d’autant plus important en POO impérative, car rappelons que ni Kotlin ni Scala n’imposent la programmation fonctionnelle (PF).

NullPointerException détaillées

La fameuse “NPE”, mal idiomatique rampant des applications Java, est désormais plus précise.

Jusqu’à l'implémentation de la JEP358 en Java 14, Java ne rapportait que la ligne ayant généré la NPE. Cela pose problème lorsque plusieurs appels s’enchaînent sur la même ligne : lequel a retourné le null qui a fait écrouler les  appels comme des dominos ?

Désormais, en ajoutant -XX:+ShowCodeDetailsInExceptionMessages comme paramètre sur la ligne de commande java, l’expression ayant retourné null est dénoncée, et l’expression victime qui a provoqué le lancement de l’exception est également indiquée.

Exemple de code :


List
  
    listOfStr = new LinkedList<>(); listOfStr.add(null); listOfStr.get(0).trim(); 
  

Sans -XX:+ShowCodeDetailsInExceptionMessages :

Exception in thread "main" java.lang.NullPointerException
    at Main.main(Main.java:18)

Avec -XX:+ShowCodeDetailsInExceptionMessages :

Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.trim()" because the return value of "java.util.List.get(int)" is null
    at Main.main(Main.java:18)

Cette amélioration bénéficie à tous les langages de la JVM. Si elle semblait indispensable, il n’en reste pas moins que le besoin d’une telle fonctionnalité serait aujourd’hui admis comme un mauvais signal. Une sorte de “code smell”, mais dans le langage lui-même, indiquant qu’on est passé à côté de quelque chose.

On ne sera donc pas étonnés de voir que les jeunes langages de la JVM se sont, eux, d’abord attaqués à la source du problème. Pour Kotlin, la valeur null est par défaut interdite ! Si on regrette que Scala n’ait pas adopté la même politique, on constatera que les outils fournis par ce langage pour se protéger du null sont similaires à ceux de Kotlin : avec la monade Option, le⋅a développeur⋅euse se protège ; avec cette monade plus d’autres, plus l’outillage de programmation fonctionnelle, il/elle reste dans un paradigme où le null ne peut par construction pas exister. Et il n’y a pas besoin d’atteindre des niveaux stratosphériques “Haskelliens” ! Les API standards respectives de Kotlin/Scala permettent déjà de transformer le spectre menaçant de la NPE en vieux souvenir.

Pour celles et ceux qui souhaiteraient sécuriser leur code Java, il y a un prix à payer au niveau syntaxe, mais qui peut valoir le coup : le checkerframework, processeur d’annotations JSR-308 et notamment celles liées à la (non-)nullité de variables. Vous remarquerez que je n’ai pas parlé d’Optional, cette classe Java qui ne s’initialise correctement que lorsque vous appelez la méthode usine ofNullable. Parce que les concepteurs de Java 8, n’ayant pas bien compris la “billion dollar mistake”, ont choisi de vous laisser obtenir des Some(null).

Les Modules

Killer feature de Java 9, les modules permettent à la fois de renforcer l’encapsulation, et de réduire l’empreinte du livrable applicatif en n’embarquant que les bibliothèques réellement utilisées. Ce second effet étant rendu possible par les contraintes imposées par le premier.

Si la partie “encapsulation” est déjà intéressante en elle-même pour les bibliothèques en limitant ce qui est exposé malgré la granularité trop grande du mécanisme d’encapsulation Java par défaut (public /protected/package private/private), c’est la réduction des temps de build et de la taille de livrable qui a rendu cette fonctionnalité populaire. Elle a permis au passage de tacler un reproche fait à Java, à savoir que pour le moindre “Hello World”, l’image Docker faisait des centaines de Mo…

L’utilisation des modules est possible en Kotlin depuis qu’il supporte la runtime Java 9. La version 1.4 du langage a même amélioré le découpage modulaire de sa “std lib”.

En Scala, les modules ne sont pas supportés et l’utilisation depuis un programme Scala d’une bibliothèque Java ne respectera pas l’encapsulation des modules. Si Scala dispose d’un système d’encapsulation beaucoup plus précis, en revanche on regrette l’absence de compatibilité avec les modules Java, bien utile lorsque notre programme Scala appelle du code Java.

Conclusion

Nous avons vu que les fonctionnalités du langage Java sont presque toujours en retrait par rapport aux autres langages de la JVM. Java accuse un historique long (25 ans), qui a trop souvent tenté de maintenir une compatibilité handicapante et pas toujours très utile.

Si certains choix sont intéressants (modules, sérialisabilité optionnelle des Records), et que Java commence à se débarrasser de certains fantômes du passé comme nous le verrons dans la partie API, il reste moins productif avec une verbosité accrue et n’encourage pas aux bonne pratiques : avez-vous déjà essayé de coder en mettant final @NonNull partout ? Un enfer…