Menu
Comment coder plus efficacement grâce au langage Scala

Comment coder plus efficacement grâce au langage Scala

Par Loïc DESCOTTE

15.12.2022

Scala est un langage de programmation dont la première version est sortie il y a bientôt 20 ans.
Il a gagné en popularité au cours des 15 dernières années, notamment avec la montée en puissance de la programmation fonctionnelle et du Big Data.

Il est utilisé par de grandes entreprises du Web comme Twitter (qui a basé tout son backend sur ce langage), Linkedin, Apple, Spotify, Paypal, Criteo...

C'est également un langage populaire dans le domaine de la finance.

Si vous avez déjà utilisé les sites MVN Repository ou le site de jeu d'échec multijoueur Lichess, ils sont aussi développés en Scala avec Play Framework!

Scala est un langage fortement et statiquement typé et orienté programmation fonctionnelle, avec également des constructions qui viennent de la programmation orientée objet.


Avantages par rapport à Java

Comme le langage Java, Scala permet d'exécuter des programmes sur la JVM (machine virtuelle Java). En effet le code Scala est compilé vers du bytecode Java.

En Scala, le code est moins verbeux et plus expressif. La librairie de collections est très riche et propose un grand nombre de méthodes pour nous faciliter la tache.

Exemple 1 :

Je souhaite calculer les combinaisons possibles pour créer des paires dans une liste d'entiers.

    List(1,2,3,4).combinations(2).foreach(println)
/* résultat : 
List(1, 2)
List(1, 3)
List(1, 4)
List(2, 3)
List(2, 4)
List(3, 4)
*/

Exemple 2

Je souhaite additionner des éléments :

    List(1,2,3,4).sum // 10

Il est intéressant de comprendre que la fonction sum s'adapte au type d'éléments contenus dans la liste.
Cette résolution qui peut sembler dynamique est en réalité faite de manière statique à la compilation, à l'aide d'un système de "type classes" décrit plus précisément dans cet article : Les « type classes » en Scala

 

Exemple 3

Je souhaite concaténer des champ optionnels en utilisant un caractère de séparation. Le type `Option` permet de définir des valeurs optionnelles. Option[A] peut être sous typé en Some[A] ou None.

    val firstName: Option[String] = ...
val nickName: Option[String] = ...
val lastName: Option[String] = ...

Notre code doit gérer ces cas :

  •  Cas numéro 1 : seul le prénom "john" est défini, je veux afficher "john".
  •  Cas numéro 2 : deux champs ou plus sont présents, je veux les afficher avec le séparateur "-", par exemple "john-the best-doe"
     val definedFields = List(firstName, nickName, lastName).flatten
 val output: String = definedFields.mkString("-")

Explication : Option est un "iterable" tout comme List. flatten permet de récupérer une liste "à plat", ici une List[String], contenant uniquement les valeurs définies.

En Scala un maximum de choses est vérifié à la compilation et non au runtime. La réflexion sur les types (très courante en Java) est par exemple très peu utilisée en Scala.

 


La programmation fonctionnelle

 

Données immuables

Un avantage qui vient avec la programmation fonctionnelle est l'utilisation de données immuables. Les collections Scala sont immuables par défaut, le mot clé `val` permet d'assurer que la référence d'une valeur n'est pas réassignée.

Scala permet aussi définir des types de données dont les champs sont immuables. Par exemple :

    case class Address(streetNumber: Int, street: String, town: String)
case class Person(firstName: String, lastName: String, address: Address, nickName: Option[String] = None) // default value as None
val jane = Person("Jane", "Doe", Address(10, "rue de la paix", "Paris"))

Les instances de cases classes sont immuables mais il est possible de les copier facilement pour obtenir de nouvelles instances.
    val joe = jane.copy(firstName="Joe")
Avoir des données immuables simplifie beaucoup la parallélisation des calculs. En effet on évite les bugs liés à des modifications concurrentes par des threads. On pourra utiliser des fonctions récursives et les opérations proposées par les collections pour éviter de faire appel à des variables mutables pour stocker les résultats intermédiaires de nos calculs.

Par exemple, voici une autre manière d'écrire une somme sans déclarer de compteur muable :
    List(1,2,3,4).reduce((counter, i) => counter + i)

Fonctions pures

Scala incite aussi à utiliser des fonctions pures. Une fonction pure ne produit pas d'effet de bord et elle est déterministe (c'est à dire que pour les mêmes paramètres d'entrée elle renvoie toujours le même résultat).
Ceci permet d'avoir des programmes plus prédictibles avec des résultats reproductibles. Les fonctions pures sont aussi plus faciles à tester.

On peut aussi ajouter le fait qu'une fonction pure est totale : elle doit renvoyer un résultat pour toute valeur en entrée, et ne doit donc jamais renvoyer d'exceptions (ou null) lorsqu'on lui passe certaines valeurs. Pour un résultat de type A, on utilisera plutôt des types de retours basés sur Option[A] ou Either[E,A]. Comme le type Option, Either permet de spécifier une valeur optionnelle, la différence étant que l'on peut expliquer la cause de l'absence de valeur : Either[E,A] permet d'encapsuler un résultat de type A ou une erreur de type E. Ceci a l'avantage d'être explicite pour la personne qui appelle la fonction et évite la programmation défensive (try/catch "au cas où").

Selon la définition de Wikipédia, "la transparence référentielle est une propriété des expressions d'un langage de programmation qui fait qu'une expression peut être remplacée par sa valeur sans changer le comportement du programme". La pureté des fonctions permet d'obtenir cette propriété et d'écrire des programmes sur lesquels il est plus aisé de raisonner. Lorsque l'on observe une valeur, on a ainsi moins le besoin de réfléchir au contenu qu'elle peut avoir (elle ne peut pas avoir été modifiée, son contenu ne dépend pas du contexte...).

Le monde extérieur étant muable et impur par nature (on perd le déterminisme dès qu'on a un risque d'erreur réseau par exemple), il existe des librairies qui permettent de repousser le moment où notre programme interagit avec l'extérieur, afin de pouvoir raisonner le plus possible avec des fonctions pures en faisant abstraction des données externes (bases de données, fichiers...). Les plus utilisées en Scala sont Cats Effect et ZIO.


Le Big Data

Hadoop est la technologie Big Data historique sur la JVM, Spark qui est écrit en Scala a ammené un modèle plus simple et plus performant que map/reduce. Mais quand utiliser des frameworks "Big Data" ?

Scala permet de paralléliser très facilement des traitements sur des données, en utilisant simplement l'API de collections standard du langage et la méthode par. Par exemple pour calculer les N premiers nombres pairs en mode multi-threadé, on peut simplement écrire :

    Range(1, 1000).par.map(item => item *2) //2, 4, 6, 8, 10, 12, ...
Des librairies comme Cats Effect, FS2 et ZIO facilitent la parallélisation et les traitements concurrents, avec des modèles de programmation réactive.

Quand la scalabilité verticale (plus de ressources utilisées sur une seule machine) ne suffit plus, on souhaite alors pouvoir utiliser des technologies permettant d'utiliser de la scalabilité horizontale en distribuant les calculs sur un cluster de machines.

Spark est un framework de data engineering, data science et de machine learning adapté au calcul distribué sur un cluster.

Il permet de de travailler sur des données avec un DSL proche du SQL (nommé spark-sql).
Voici un petit exemple permettant de calculer la moyenne d'âge des personnes d'un certain pays dans un jeu de données :
    case class Person(name: String, age: Int, countryId: Int)
case class Country(id: Int, name: String)

 val countries = Seq(
    Country(1, "fr"),
    Country(2, "it"),
    Country(3, "usa")
  )

  val people = Seq(
    Person("jane", 28, 2),
    Person("bob", 31, 1),
    Person("bob", 35, 1),
    Person("john", 45, 1),
    Person("joe", 40, 2),
    Person("linda", 37, 1)
  )
  
  val peopleDS = people.toDS // création d'un dataset au format spark
  val countryDS = countries.toDS

  peopleDS
    .join(countryDS, peopleDS("countryId") === countryDS("id"))
    .filter(countryDS("name") === "fr")
    .agg(avg(peopleDS("age")))
    .show

Lorsque l'on a besoin d'accéder à des technologies faisant partie de la pile Hadoop (Hive, HDFS ou même Parquet), Spark facilite grandement les choses.

Attention néanmoins, le développement en Spark diffère beaucoup de ce que l'on peut connaitre dans le reste de l'écosystème Scala, Spark ayant ses propres contraintes, ses propres pièges et surtout sa propre logique (souvent plus proche du monde de la data science et du SQL).
A titre d'exemple n'espérez pas retrouver les mêmes vérifications sur les types que dans vos traitements Scala traditionnels.

Retour aux articles

C'est à lire...