Scala : Sandwich de versions et rétrocompatibilité

Introduction

Dans son article https://univalence.io/blog/articles/scala-3-un-nouveau-langage/ François nous présentait les nouveautés de Scala 3 et nous donnait des conseils vis-à-vis de la migration de projets en Scala 2 vers Scala 3.

Dans cet article, nous parlerons un peu plus en détail des problèmes liés aux migrations et cohabitations entre Scala 2 et Scala 3.

Prérequis

Avant de commencer, débutons par les mauvaises nouvelles.

Vous allez avoir des problèmes pour migrer votre code base si :

  • Vous dépendez d’une bibliothèque logicielle qui fait usage de macro Scala 2
    • Si c’est le cas, vous pouvez aller voir ici l’état de la migration des dépendances populaires.
  • Votre projet dépend d’un plugin sbt qui n’a pas d’équivalent pour Scala 3
  • Votre projet dépend de scala-reflect
    • Si c’est le cas… Je n’ai pas encore de solution pour vous.

Jar Hell

Toujours avant de commencer, il est important d’avoir une vue rapide sur ce que l'on appelle le Jar Hell. Le Jar Hell c’est ce qui qualifie les problèmes que l'on peut rencontrer avec les mécanismes de classloading de la JVM.

Voilà une liste non exhaustive de ces problèmes :

  • Unexpressed deps
    • Un jar n'indique pas de quels autres jars, il dépend. Dès lors, c'est au développeur de résoudre lui-même certaines dépendances.
  • Dépendances transitives
    • Certaines bibliothèques logicielles dépendent de certaines autres bibliothèques (qui dépendent elles-mêmes d’autres bibliothèques, etc.). C’est, là encore, au développeur de récupérer les sous-dépendances.
  • Shadowing
    • Un projet peut dépendre de deux versions différentes d’une même bibliothèque. Cela arrive, par exemple, lorsque deux des dépendances d’un projet dépendent d’une même bibliothèque, mais pour des versions différentes. Dans le meilleur des cas, il est possible de ne conserver que la version la plus récente. Mais il peut arriver que les deux versions soient incompatibles (par exemple, une fonction est disponible dans les deux versions, mais la signature a changé avec des paramètres différents et/ou un type de retour différent).
  • Conflit de versions
    • Deux bibliothèques dépendent de deux bibliothèques incompatibles.

TASTy ?

Si vous êtes intéressé par Scala 3, vous avez surement déjà dû entendre parler de TASTy.

TASTy (Typed Abstract Syntax Trees) est un format d’échange entre votre code et votre code compilé (.class file).

Concrètement, ce sont des fichiers qui contiennent toute l’information utile sur le code.

Le format TASTy permet de palier à certains désavantages de la compilation en bytecode, les fameux .class, dans la mesure où ces derniers contiennent une représentation incomplète du code.

Par exemple, une fois compilé en bytecode (.class), les types génériques subissent un effacement de type (type erasure).
Le code Scala suivant :

val xs: List[Int] = List(1, 2, 3)

sera converti en

public scala.collection.immutable.List<java.lang.Object> xs();

Cela assure une compatibilité du code source et du code binaire entre les versions de la JVM, pour des raisons historiques, puisque les premières versions de Java ne disposaient pas de types génériques (implémentés dans Java 1.5 par Martin Ordersky). Cela vous évite entre autres de recompiler vos applications lorsque vous migrez vers des versions plus récentes de la JVM.

Les avantages d’avoir un format tel que TASTy sont multiples :

  • Un outil de build peut s’en servir pour construire le projet pour plusieurs versions de la JVM.
  • Permet une inspection du code profonde par des outils de métaprogrammation et permet de générer du code dans des cas avancés.

Qu’est-ce que cela autorise à faire de base ?

  • La lib ne doit pas dépendre de scala.reflect (donc pas de macro).
  • La lib ne doit pas dépendre du nouveau type de métaprogrammation (inline statements…).
  • Inclure l’option de compilateur -Ytasty-reader.

Notez que si vous souhaitez utiliser une bibliothèque Scala 3 dans votre projet Scala 2, vous allez soit :

  • Devoir écrire manuellement que votre lib est en Scala 3.
    Par exemple :
    "org.legogroup" %% "woof-core" % "0.2.0"
    Deviendra dans un build en Scala 2
    "org.legogroup" % "woof-core_3" % "0.2.0"
    lazy val main = project.in(file("main"))
      .settings(
    		scalaVersion:= scala2,
    		scalacOptions+= "-Ytasty-reader",
    		libraryDependencies++=Seq(
          "org.legogroup" % "woof-core_3"   % "0.2.0"
         )
      )
      .dependsOn(middle)
  • Soit utiliser l’opérateur cross
    lazy val main = project.in(file("main"))
      .settings(
    		scalaVersion:= scala2,
    		scalacOptions+= "-Ytasty-reader",
    		libraryDependencies++=Seq(
          "org.legogroup" %% "woof-core" % "0.2.0" cross CrossVersion.for2_13Use3
         )
      )
      .dependsOn(middle)
    
    sbt Reference Manual — Cross-building

Étude de cas

Nous allons étudier la compatibilité de Magnolia au sein de projets composants Scala 2 et Scala 3.

Rappelons que les versions Scala 2 et 3 de Magnolia tirent parti respectivement des macros et des nouvelles fonctionnalités de métaprogrammation.

Nous allons tenter de répondre aux questions suivantes :

  • Peut-on utiliser Magnolia pour Scala 2 au sein d’un projet Scala 3 ?
  • Peut-on utiliser Magnolia 3 au sein d’un projet Scala 2 ?
  • Est-ce qu’un projet Scala 2 peut dépendre d’un projet Scala 3 qui utilise Magnolia 3 ?
  • Est-ce qu’un projet Scala 3 peut dépendre d’un projet Scala 2 qui utilise Magnolia 2 ?
  • Est-ce que les ADT et Typeclass de Scala 3 peuvent être dérivés par Magnolia 2 ?

Note sur IntelliJ

L’outillage de IntelliJ vis-à-vis de Scala 3 étant encore en développement, nous vous conseillons d’utiliser les commandes sbt build, sbt run depuis un terminal directement.

Schéma d’une application mélangeant Scala 2, Scala 3 et Magnolia pour Scala 2 et 3.

Une précision sur le schéma :

  • genX[TcY[ADTZ]] signifie que la classe expose une dérivation via magnolia pour Scala X, sur des typeclass écrites en Scala Y paramétrés par des ADT Scala Z avec X, Y et Z pouvant être égal à 2 ou 3.

Notez que la topologie suivante ne marche pas :

💩 cette topologie ne marche pas

✅ Cette topologie fonctionne si :

  • On garde qu’une seule version de Magnolia
  • Et l'on dérive les typeclasses avec le bon compilateur :
    • Dans le Scala 3 module si on utilise magniola pour Scala 3
    • Dans le Scala 2 project si on utilise magniola pour Scala 2

Répondons aux questions précédemment posées :

  • Peut-on utiliser Magnolia 2 au sein d’un projet Scala 3 ?
    • Non. En effet, générer une typeclass via Magnolia 2 fait usage de scala.reflect.macro, ce qui est impossible à faire avec le compilateur de Scala 3 (ces fonctionnalités ont été abandonnées).
  • Peut-on utiliser Magnolia 3 au sein d’un projet Scala 2 ?
    • Non plus, ici ce sont les nouvelles fonctionnalités de métaprogrammation qui ne sont pas supportées par le compilateur de Scala 2.
  • Est-ce qu’un projet Scala 2 peut dépendre d’un projet Scala 3 qui utilise Magnolia 3 ?
    • Oui, car on dépend du bon compilateur.
  • Est-ce qu’un projet Scala 3 peut dépendre d’un projet Scala 2 qui utilise Magnolia 2 ?
    • Oui aussi, pour les mêmes raisons que précédemment.
  • Est-ce que les ADT et Typeclass de Scala 3 peuvent être dérivés par Magnolia 2
    • Les enums de Scala 3 semblent poser un problème lorsqu’on tente de les dériver en Scala 2. En revanche, les ADT et typeclass de Scala 3, même avec leurs nouvelles features, paraissent ne pas poser problème, ni à l’utilisation, ni à la dérivation.

Si vous souhaitez expérimenter un peu avec les sandwiches, vous pouvez démarrer avec le code de cet article : https://github.com/univalence/scala-sandwich

Les outils pour aider à la migration

sbt est livré avec un outil de migration sbt migration plugin.

Il permet de :

  • Migrer les dépendances disponibles en Scala 3.
  • Migrer les options de compilation vers leur équivalent en Scala 3.
  • Aider à migrer la syntaxe.
  • Repérer les types qu’il sera impératif d’annoter manuellement du fait que le mécanisme d’inférence de type change en Scala 3.

Si vous avez des dépendances de versions de Scala différentes, vous pouvez envisager d’utiliser Mima pour prévenir les incompatibilités binaires.

Conseils

Un jour viendra où il faudra impérativement arrêter d’utiliser des libs en Scala 2 qui ne seront plus maintenues notamment pour des raisons de sécurité. En effet, être compatible avec Scala 3 ne signifie pas que vous n’aurez jamais à migrer.

Voici quelques conseils pour préparer l’échéance :

  • Effectuer la migration progressivement pour fixer les problèmes petit à petit.
  • Arrêter le support des applications en Scala 2.12 et antérieures pour faciliter le passage a Scala 3.
  • Faites des tests de non-régression avec une bonne couverture de test (un couverture autour de 80% semble être une bonne recommandation).
  • Lisez la doc de Scala à propos de la migration.
  • Regardez l’état de la migration des libs dont vous dépendez.
  • Arrêtez d’utiliser les technologies/features dont vous savez à l’avance qu’elles ne seront pas portées en Scala 3 (les macros, scala reflect).

Erreurs

signature has wrong version

[error] (class scala.tools.tasty.UnpickleException/TASTy signature has wrong version.
[error]  expected: {majorVersion: 28, minorVersion: 1}
[error]  found   : {majorVersion: 28, minorVersion: 2}

Cela arrive quand les versions de scala 2.13 et scala 3 ne sont pas compatible, il faut en général monter la version de scala 2.13 (la dernière c’est 2.13.9).

inline in scala 2

[error] scala.reflect.internal.Types$TypeError: Unsupported Scala 3 inline method deriveSubtype; found in trait magnolia1.Derivation.


[error] stack trace is suppressed; run last c / Compile / compileIncremental for the full output
[error] (c / Compile / compileIncremental) scala.reflect.internal.Types$TypeError: Unsupported Scala 3 inline method deriveSubtype; found in trait magnolia1.Derivation.

WeakTypeTag evidences

[error][...] scala:26:51: macro implementations cannot have implicit parameters other than WeakTypeTag evidences
[error]implicit def gen[T]: Show[T] = macro Magnolia.gen[T]

Cela arrive si scala-reflect est manquant dans un projet magnolia - Scala 2.