Scala 3 : un nouveau langage ?

TL;DR : Oui.

Scala 3 vient tout juste de sortir.

Il succède à Scala 2.13, dont nous avons parlé il y a deux ans. De cette nouvelle version, qui jusque-là apparaissait comme le projet Dotty, que devons-nous attendre ? Qu'apportent les améliorations ? Quel sera le coût de la migration depuis la version 2 ?

Nous allons voir parmi ces nouveautés, celles qui sont les plus impactantes pour le développeur Scala usuel et discuter de l'aspect migration.

Syntaxe

Scala 3 propose une nouvelle syntaxe pour écrire le code. Elle me fait penser à un mix entre l'ancienne syntaxe Scala, celle de Python et celle de Pascal et Ruby. Cette nouvelle syntaxe est optionnelle et il est toujours possible d'utiliser l'ancienne syntaxe (avec quelques restrictions), voire de mixer des deux.

Structure de contrôle

Au niveau structure de contrôle, voici le if.

// 1 - old Scala syntax (available), braces are optional here
if (age >= 18) {
  "adult"
} else {
  "kid"
}

// 2 - new Scala syntax (end if), semi-indent sensitive
if age >= 18 then
  "adult"
else
  "kid"

// 3 - new Scala syntax (Pascal/Ruby-style)
if age >= 18 then
  "adult"
else
  "kid"
end if

S'ajoute ainsi le then et la perte des parenthèses pour la condition. Un marqueur de fin peut être ajouté en fin de structure. On retrouve une syntaxe équivalente avec while...do, avec for...do/yield et avec try...catch et le pattern matching.

Structure de données

Pour les structures de données, voici la nouvelle syntaxe côté classe.

// 1 - old syntax (available), braces are optional here
class Message1(message: String) {
  def display: Unit = {
    println(message)
  }
}

// 2 - new syntax (Python-style), indent sensitive
class Message2(message: String):
  def display: Unit =
    println(message)

// 3 - new syntax, indent sensitive
class Message3(message: String):
  def display: Unit =
    println(message)
  end display
end Message3

Sur la deuxième version de l'exemple ci-dessus, l'inspiration de Python est très présente. Il y avait déjà le def, le rappel du nom des paramètres à l'appel et les valeurs par défaut pour les paramètres. Maintenant, les accolades ont fait place aux deux-points en fin de déclaration. Et c'est l'indentation qui détermine si les déclarations qui suivent font partie de la classe ou si elles sont en dehors de son scope. Si vous souhaitez délimiter les blocs sans accolades, utilisez alors la troisième version dans l'exemple.

Toplevel function

En aparté, vous pouvez déclarer des fonctions qui ne sont rattachés à aucune classe ou structure équivalente directement dans un fichier. Ce qui rappelle l'une des possibilités offertes par Kotlin. Cette approche remplace la syntaxe package object de la version 2 de Scala.

Main

Du côté du main :

// 1 - old syntax
object Main {
  def main(args: Array[String]): Unit =
    println("You're the best!!!")
}

// 2 - new, no parameter
@main def mySuperProgram: Unit:
  println("You're the best!!!")

// 3 - new, with parameter
@main def hello(message: String): Unit:
  println(s"hello $message")

L'entrée de votre application n'est pas nécessairement une méthode appelée main, mais elle peut être une fonction ayant n'importe quel nom. Il faut qu'elle soit précédée de l'annotation @main. Et plutôt que de passer en paramètre un conteneur récupérant les paramètres, vous devez ici préciser les paramètres dont vous avez besoin exactement en précisant leur type (String, Int, Boolean...). Si vous ne connaissez pas par avance le type et la quantité de paramètres, il est possible d'indiquer String* ou de revenir à l'ancienne syntaxe.

Lorsque vous utilisez la nouvelle syntaxe pour déclarer l'entrée de votre application, Scala crée une classe JVM dont le nom correspond au nom de la fonction annotée @main, dans laquelle il ajoute la méthode statique main. Cette méthode main fait alors appel à un analyseur de paramètre avant de passer la main au code de la fonction. Vous pouvez ainsi avoir plusieurs fonctions @main dans le même fichier.

Package

Enfin, pour les packages :

def f: String = p1.p2.hello

package p1:
  package p2:
    def hello: String = "hello world"

Enum

Ça fait bien longtemps que les développeurs Scala l'attendait : une déclaration d'enum. Il y a déjà une telle déclaration du côté Java depuis la version 5, qui permet de déclarer des constantes. Mais il manquait un équivalent aussi pratique du côté Scala pour pouvoir déclarer des ADT et des GADT. Une tentative a été mise au point, mais l'implémentation bien qu'encore présente est loin de valoir le confort offert par les enums Java. La meilleure façon de mettre en place un ADT en Scala en version 2 est d'utiliser des sealed trait et des case class. Par exemple, voici un ADT permettant de représenter des expressions arithmétiques :

sealed trait Expression
case class Const(value: Double) extends Expression
case class Add(left: Expression, right: Expression) extends Expression
case class Mul(left: Expression, right: Expression) extends Expression

Scala 3 introduit une forme de déclaration d'enum qui est plus similaire aux enum Java, en ayant la possibilité en plus de déclarer des ADT et des GADT.

// series of constant
enum MusicalMode:
  case Ionian, Dorian, Phrygian, Lydian, Mixolydian, Aeolian, Locrian

// ADT: parameterized symbols
enum Expression:
  case Const(value: Double)
  case Add(Left: Expression, right: Expression)
  case Mul(Left: Expression, right: Expression)
  case Variable

// GADT to represent computations
enum Computation[+A]:
  def map[B](f: A => B): Computation[B] =
    Computation.Map(this, f)

  def filter(p: A => Boolean): Computation[A] =
    Computation.Filter(this, p)

  case Effect(a: () => A) extends Computation[A]
  case Map[A, B](computation: Computation[A], f: A => B) extends Computation[B]
  case Filter[A](computation: Computation[A], p: A => Boolean) extends Computation[A]

object Computation:
  def apply[A](a: =>A): Computation[A] = Computation.Effect(() => a)

L'instanciation d'un élément de l'enum sera typé par rapport à l'enum. Ainsi, que le type de Expression.Const(2.0) est Expression.

Sur des enums de type liste de constantes, Scala fournit des fonctions d'exploration, comme valueOf, name ou ordinal, que nous avons déjà côté Java.

D'ailleurs, pour avoir des enum compatibles avec Java, il suffit d'étendre java.lang.Enum.

enum MusicalMode1 extends Enum[MusicalMode1]:
  case Ionian, Dorian, Phrygian, Lydian, Mixolydian, Aeolian, Locrian

enum MusicalMode2(degree: Int) extends Enum[MusicalMode2]:
  case Ionian     extends MusicalMode2(1)
  case Dorian     extends MusicalMode2(2)
  case Phrygian   extends MusicalMode2(3)
  case Lydian     extends MusicalMode2(4)
  case Mixolydian extends MusicalMode2(5)
  case Aeolian    extends MusicalMode2(6)
  case Locrian    extends MusicalMode2(7)

Les méthodes d'extension

Les méthodes d'extension ne sont pas exactement une nouveauté en Scala. Mais il faut en Scala version 2 passer par une syntaxe verbeuse et peu explicite. En Kotlin et en C#, cette fonctionnalité existe avec une syntaxe plus explicite.

Par exemple, on reçoit en entrée une série d'événements sur des stocks de produits indiquant leur quantité constatée et l'heure à laquelle le constat a été fait. Le problème est que notre classe représentant ces événements a été générée automatiquement (par exemple, depuis une représentation en Avro, Thrift ou Protobuf). Il n'est pas envisageable de modifier cette classe pour ajouter une méthode permettant de composer deux événements de stock.

// AUTO-GENERATED
case class StockEvent(product: String, quantity: Double, timestamp: Instant)

Pour pallier ce problème en Scala 2, il faut créer une classe implicite qui pourra être utilisée dès que la méthode de composition sera appelée sur des événements de stock. Et pas que...

implicit class RichStockEvent(event: StockEvent) {
  def combineWith(other: StockEvent): StockEvent = ???
  def isEmpty: Boolean = event.quantity == 0
}

En Scala 3, la syntaxe est bien plus explicite.

extension (event: StockEvent)
  def combineWith(other: StockEvent): StockEvent = ???
  def isEmpty: Boolean = event.quantity == 0

Dans ce cas, la syntaxe est un peu plus légère et est comprise comme le fait d'ajouter une nouvelle méthode à une classe existante. De plus, il n'est pas nécessaire de trouver à cette structure un nom qui ne sera pas utilisé.

Import

La syntaxe des imports change quelque peu sur des aspects spécifiques. Pour importer le contenu d'un module, on utilisera désormais le symbole * à la place de _. Par exemple : import java.time.*.

Si vous souhaitez renommer un import, il faudra utiliser le mot-clé as à la place du symbole =>. Par exemple import java.lang.{Double as JDouble}.

Ce qui disparaît

La syntaxe procédurale (le fait d'écrire def f() {...}) n'est plus permise en Scala 3. Il vous faudra écrire plus explicitement def f() = {...} ou def f(): Unit = {...}.

La structure do...while disparaît notamment parce qu'elle est rarement utilisée, qu'il y a des alternatives et que le do est utilisable par d'autres structures, comme le for...do et le while...do. Ceci évite la confusion.

Il n'y a plus de limite à 22 paramètres pour les fonctions et les tuples. Dans les faits, il y a une représentation directe jusqu'à 22 paramètres — eg. pour les fonctions, on a les types scala.Function1, .scala.function2, ..., scala.Function22. Au-delà, on a les types génériques scala.runtime.FunctionXXL pour les fonctions et scala.runtime.TupleXXL pour les tuples.

Disparaissent aussi

  • Les package object (les déclarations de cet ordre se font directement dans le fichier).
  • Les littéraux de type symbole (eg. `mySymbol), qu'il faudra remplacer par des appels à Symbol.
  • L'initialisation par joker (eg. var v: Int = _). Il faudra utiliser scala.compiletime.uninitialized.

Tooling

Le compilateur fournit des options (exclusives) permettant de choisir entre 4 styles de syntaxe :

  • -old-syntax vérifie que les structures de contrôle suivent l'ancienne syntaxe (eg. if (cond) ... else ...).
  • -new-syntax vérifie que les structures de contrôle suivent la nouvelle syntaxe (eg. if cond then ... else ...).
  • -indent permet les indentations significatives (eg. class MyClass: ...).
  • -noindent ne permet que les accolades délimiter les blocs (eg. class MyClass { ... }).

Avec l'option -rewrite, le compilateur Scala réécrit le code par rapport au style de syntaxe sélectionné. Ce qui permet d'avoir une homogénéité de style de syntaxe sur l'ensemble du code d'un projet.

Une discussion est en cours pour l'ajout d'une option qui permettrait de gérer les marqueurs de fin de bloc basé sur la syntaxe end <name>.

En dehors des options du compilateur, SBT et Scalafmt sont compatibles avec Scala 3. De leur côté, le plugin Scala pour Maven et Scalafix sont en cours de développement pour cette nouvelle version.

Migration de Scala 2 vers Scala 3

C'est certainement le sujet le plus sensible ici. Bien évidemment, si vous avez la possibilité de commencer un nouveau projet, ça peut valoir le coup de passer à Scala 3 ou de l'intégrer de manière sporadique sur un petit projet ou un module plus ou moins séparé. Encore faut-il que les bibliothèques, les frameworks et le reste de l'écosystème est suivi...

Concernant le code, le fait d'ajouter l'option de compilation -source:3.0-migration, fait passer le compilateur en mode migration. Il est alors plus laxiste sur certains aspects syntaxiques et fait alors apparaître des warnings sur les fonctionnalités retirées dans Scala 3. Cette option peut être utilisée conjointement avec l'option -rewrite pour que le compilateur réécrive le code dans un format plus en adéquation avec Scala 3. Néanmoins, il est vivement conseillé de procéder par étape afin d'éviter de se trouver dans une impasse résultant par l'apparition de bug dans vos applications, car la réécriture n'est que techniquement sûre : 1/ compiler sans l'option -rewrite 2/ commiter le projet pour conserver une version avant réécriture 3/ utiliser l'option -rewrite. Pour faciliter, la compréhension des warnings, les options de compilation -explain et -explain-types devrait pouvoir vous aider.

Sur le plan des dépendances, il y a un certain nombre de bibliothèques et de frameworks qui sont déjà disponibles pour Scala 3 parmi les plus populaires et les plus actifs. Mais ce n'est pas le cas de tous (par exemple, nous attendons avec impatience une version de Spark fonctionnant avec Scala 3). Néanmoins, les contributeurs Scala avaient annoncé une interopérabilité entre Scala 3 et Scala 2, et plus spécifiquement avec la version 2.13. Ainsi, la bibliothèque officielle de Scala 3 est la bibliothèque de Scala 2.13.

Du côté du développement Scala quotidien, si vous travaillez avec Scala 3 et que vous avez besoin d'une dépendance en 2.13, il vous suffira d'ajout dans vos fichiers SBT l'option CrossVersion.for3Use2_13 sur vos dépendances (eg. libraryDependencies += ("org.bar" %% "bar" % "1.0.0").cross(CrossVersion.for3Use2_13)), s'il s'agit d'une dépendance externe. Pour une dépendance locale, il n'y a rien à faire à part de l'indiquer comme d'habitude (eg. .dependsOn(my_scala3_module)). À l'inverse, si vous travaillez en Scala 2.13 et que vous avez besoin d'une dépendance en version 3, vous avez l'option -Ytasty-reader à ajouter au niveau des options de compilation. Dans ce cas, si vous avez une dépendance externe, vous devez ajouter l'option CrossVersion.for2_13Use3 à votre dépendance. Il n'y a rien de plus à faire pour une dépendance interne.

Le projet Scala fournit une documentation assez complète sur la migration vers Scala 3. Une matrice des incompatibilités et des modifications d'option de compilation sont aussi consultables.

Conclusion

Scala 3 arrive avec une syntaxe assez différente de Scala 2. Ce qui fait de Scala 3 un langage différent, mais avec des compatibilités vis-à-vis de l'ancienne version. Du côté écosystème, les contributeurs font émerger des mises à jour de leur projet basé sur Scala au fur et à mesure.

Néanmoins, le spectre douloureux de la migration Python 2 vers Python 3 qui s'est étalé sur une décennie est relativement présent autour de l'adoption de Scala 3. La difficulté de cette migration fait qu'il est apparu un mur de la honte indiquant les bibliothèques n'étant pas encore passées à Python 3 et qu'il a été imposé une date de fin de maintenance de la version 2.

De son côté, la communauté Scala (en dehors de ses dramas) semble vouloir avancer dans le même sens pour l'adoption de Scala 3. Elle est tout à fait consciente des difficultés de la migration Python 2 vers Python 3. Néanmoins, Scala n'est pas Python. Il n'a ni son organisation ni le même périmètre d'utilisation. Le projet sur lequel repose Scala 3 existe depuis 8 ans. Des annonces ont régulièrement été faites pour montrer les nouveautés à venir et donner une roadmap de plus en plus précise. Les fonctionnalités de Scala 3 pouvaient être testées bien avant ça sortie. Les principales bibliothèques ont régulièrement sorti des releases sur les pré-versions de Scala 3. Des workshops ont été organisés sur Scala 3. Les pré-versions de Scala 3 ont été automatiquement testées pour tenter de compiler un ensemble de projets Scala OSS. On peut s'attendre ainsi à une migration relativement simple entre Scala 2 et Scala 3, même si en réalité ce n'est jamais simple.

Photographie par Jack Hunter sur Unsplash.