Blog ENI : Toute la veille numérique !
🐠 -25€ dès 75€ 
+ 7 jours d'accès à la Bibliothèque Numérique ENI. Cliquez ici
Accès illimité 24h/24 à tous nos livres & vidéos ! 
Découvrez la Bibliothèque Numérique ENI. Cliquez ici

Programmation fonctionnelle

Fonctions

1. Définition

Une fonction est une expression qui prend en entrée des arguments et renvoie un résultat. Elle est semblable à une méthode mais c’est un objet et elle peut donc être manipulée de façon différente.

Pour définir une fonction, on utilise la syntaxe (t: T) => ???. La partie gauche correspond aux arguments du type désiré et la partie droite aux opérations effectuées sur ces paramètres.

La fonction est alors un objet de type T => UU est le type de l’objet retourné dans la partie de droite. Une fonction peut prendre un, aucun ou plusieurs arguments.

Exemple de fonction à un argument

Prenons comme exemple une fonction qui multiplie un Int par 1.2.

val fonctionUnArg = (i: Int) => i * 1.2 
// fonctionUnArg: Int => Double = <function>785a95b 

Sans spécifier son type de retour, il est automatiquement inféré à Double par le compilateur.

Exemple de fonction à deux arguments

Prenons comme exemple une fonction qui multiplie un Int à un Double. Le résultat par défaut sera un Double.

val fonctionDeuxArg = (i: Int, j: Double) => i * j 
// fonctionDeuxArg: (Int, Double) => Double = <function>25521b7 

On peut forcer le type de retour à BigDecimal et compter sur la conversion entre le type Double et BigDecimal. Le type de retour spécifié prévaut sur le type inféré par le compilateur.

val fonctionDeuxArgDecimal: (Int, Double) => BigDecimal = (i: Int, 
j: Double) => i * j 
// fonctionDeuxArgDecimal: (Int, Double) => BigDecimal = 
<function>25521b7 

Exemple de fonction sans argument

Prenons comme exemple une fonction qui imprime dans la console le message “Bonjour”. Comme la méthode println est une méthode qui a pour type de retour Unit, la fonction elle aussi a pour type de retour Unit.

val fonctionSansArg...

Fonctions pures

Une fonction pure est une fonction qui ne provoque pas d’effet de bord, ce qui se définit par trois aspects :

  • Le retour de la fonction ne dépend que des éléments en entrée.

  • Elle ne modifie aucun état.

  • Elle ne reçoit ni n’écrit de données de l’extérieur, par exemple la console ou une base de données.

À chaque fois que vous appelez une fonction pure avec les mêmes paramètres, elle donne toujours le même résultat.

Exemple de fonctions pures

Prenons comme exemple la String "exemple". La méthode length renvoie un Int correspondant à sa taille.

val exemple = "exemple" 
// exemple: String = "exemple" 
 
exemple.length 
// res15: Int = 7 

La méthode substring renvoie une sous-chaîne de caractères à partir de la String en commençant à un indice donné. Comme elle n’altère pas la String de départ mais en crée une nouvelle, c’est une fonction pure.

exemple.substring(4) 
// res16: String = "ple" 

Exemples de fonctions impures

Prenons pour exemple une liste animaux mutable :

import scala.collection.mutable.ArrayBuffer 
var animaux: ArrayBuffer[String] = ArrayBuffer.empty[String] 
// animaux: ArrayBuffer[String] = ArrayBuffer("chat", "chien") 

On définit...

Trait

1. Définition

Un trait est une construction qui se rapproche à la fois de la notion d’interface et de la notion de classe abstraite en Java. On peut y définir des méthodes ou des valeurs accessibles uniquement aux classes qui l’étendent.

Comme dans une interface, on peut définir des fonctions sans les implémenter. Dans ce cas, toutes les implémentations qui étendent ce trait devront implémenter cette fonction.

Comme dans une classe abstraite, on peut définir des fonctions concrètes. Dans ce cas, tous les éléments qui étendent ce trait auront accès à cette fonction.

2. Étendre un trait

Prenons comme exemple un trait Animal qui définit les fonctions abstraites suivantes :

  • nombreDePattes qui renvoie le nombre de pattes de l’animal ;

  • son qui renvoie le son que produit l’animal.

La méthode concrète imprimeSon imprime le son que produit l’animal.

trait Animal { 
 val nombreDePattes: Int 
 def son(): String 
 def imprimeSon(): String = s"L'animal fait ${son()}" 
} 
// defined trait Animal 

On crée une classe Chien qui étend ce trait en utilisant le mot-clé extends et en implémentant la méthode son et la variable nombreDePattes.

class Chien extends Animal { 
 val nombreDePattes: Int = 4 
 def son(): String = "Ouaf !" ...

Case class

1. Définition

Une case class est une classe qui présente trois grands avantages supplémentaires.

Premièrement, une méthode apply est systématiquement présente dans son objet compagnon (cf. chapitre Un aperçu du langage - Objets singletons) ce qui permet de créer une instance d’une case class sans le préfixe new. Cela rend le code moins encombré et donc plus lisible.

Deuxièmement, tous ses paramètres sont des val, c’est-à-dire des variables immuables, comme décrit dans le chapitre Un aperçu du langage - Variables, ce qui permet d’y accéder plus facilement.

Troisièmement, les méthodes toString, hashCode et equals sont automatiquement implémentées et de façon plus naturelle : respectivement, elles affichent, hachent et comparent un arbre construit à partir des arguments de la case class. Cela permet de comparer et lire les case class plus efficacement.

Prenons comme exemple une classe abstraite ClasseCouleur et une case class CouleurPleine qui étend cette classe.

abstract class ClasseCouleur 
// defined class ClasseCouleur 
 
case class CouleurPleine(nom: String) extends ClasseCouleur 
// defined class CouleurPleine 

Avec une instance de la classe CouleurPleine, on peut accéder à son paramètre nom.

val couleur = CouleurPleine("rouge") ...

Case object

De la même façon qu’une case class est une classe améliorée, un case object est un objet amélioré. Les méthodes toString, hashCode et equals sont définies de la même façon, permettant de comparer et lire les case objects plus aisément.

Prenons comme exemple, une énumération de couleur. On définit le trait Couleur qui servira dans la suite du chapitre.

On définit également le trait CouleurBasique qui étend le trait Couleur en ajoutant le préfixe sealed au trait. Cela permet de décrire de façon exhaustive tous les éléments qui étendent le trait et qui doivent tous être écrits dans le même fichier. C’est ainsi qu’on définit une énumération en Scala. 

sealed trait Couleur 
// defined trait Couleur 
 
sealed trait CouleurBasique extends Couleur 
// defined trait CouleurBasique 

Ensuite, on définit cinq case objets pour définir les couleurs Bleu, Rouge, Jaune, Blanc et Noir.

case object Bleu extends CouleurBasique 
// defined object Bleu 
 
case object Rouge extends CouleurBasique 
// defined object Rouge 
 
case object Jaune extends CouleurBasique 
// defined object Jaune 
 
case object Blanc extends CouleurBasique 
// defined object Blanc 
 
case object...

Pattern matching

1. Définition

Le pattern matching (filtrage par motif) est utilisé pour effectuer différentes actions selon la valeur de l’objet observé. Il se construit avec la structure match et chaque ligne correspond au mot-clé case. On peut le rapprocher de la structure switch en Java. Il peut se faire sur beaucoup plus d’éléments et présente trois différences majeures.

Premièrement, un match est une expression en Scala et de ce fait renvoie toujours un résultat.

Deuxièmement, si une condition est rencontrée, le match est terminé et on ne passe pas à la condition suivante, il n’y a pas besoin du mot-clé break pour sortir de la structure.

Troisièmement, si aucune des conditions n’est remplie, cela résulte en une exception de type MatchError. Il faut donc bien faire attention à spécifier tous les cas possibles.

Chaque cas peut s’écrire sur une ou plusieurs lignes, auquel cas, comme dans une méthode ou une fonction, c’est la dernière ligne qui fait office de résultat. Dans le cas où le cas fait plusieurs lignes, on n’est pas obligé de l’entourer d’accolades ; cela reste à la discrétion du développeur.

2. Pattern constructeurs

Dans un pattern matching, une instance de case class peut être décomposée soit avec des variables, soit en spécifiant une valeur spécifique ou une constante pour un ou plusieurs des arguments.

On définit la case class Mixte qui prend en paramètres deux CouleurBasique.

case class Mixte(couleurA: CouleurBasique, couleurB: 
CouleurBasique) extends Couleur 
// defined class Mixte 

Dans notre exemple, on peut décomposer une instance de Mixte en Mixte (couleurA, couleurB) où couleurA et couleurB sont ses paramètres de type Couleur.

On définit la méthode extraireMixte qui prend en entrée une Couleur et imprime les deux couleurs mixées d’un objet Mixte. On ajoute un cas spécifique lorsqu’il s’agit d’un mixte de Blanc et Noir.

def extraireMixte(couleur: Couleur) = couleur match { 
 case Mixte(Blanc, Noir) => print("mixte spécial") 
 case Mixte(couleurA, couleurB) => print(s"mixte de $couleurA...

Option

1. Définition

Comme son nom l’indique, la classe typée Option est utilisée pour des valeurs optionnelles. Un objet de ce type peut avoir deux valeurs possibles :

  • Some(x), où x est la valeur existante.

  • None, dans le cas où la valeur est manquante.

Ce type est souvent utilisé dans les opérations classiques des collections Scala, comme par exemple pour accéder à un élément d’une Map avec la méthode get.

Prenons comme exemple une Map[String, Int] associant un nom d’animal à un nombre.

val animauxMap = Map("chat" -> 4) 
// animauxMap: Map[String, Int] = Map("chat" -> 4) 

Si on appelle la méthode get sur cette variable avec l’argument "chat”, on obtient une Option[Int] non vide.

animauxMap.get("chat") 
// res59: Option[Int] = Some(4) 

Si on appelle la méthode get sur cette variable avec l’argument "chien”, on obtient une Option[Int] vide, autrement dit, None.

animauxMap.get("chien") 
// res60: Option[Int] = None 

Pour créer une Option à partir d’un objet non nul, il suffit d’utiliser la case class Some qui étend la classe Option suivie de la valeur non nulle.

val optionDefinie = Some(12) 
// optionDefinie: Some[Int] = Some(12) 

Pour savoir si une Option est définie ou vide, on utilise les méthodes...

Expressions régulières

Les expressions régulières de Scala se basent sur celles de Java et proposent un certain nombre de formats dont voici une liste non-exhaustive :

  • . correspond à n’importe quel caractère.

  • \w correspond à un mot.

  • \d correspond à un chiffre.

  • \s correspond à un espace.

  • .* pour correspondre à 0 ou plusieurs éléments présents avant le *.

  • .+ pour correspondre à 1 ou plusieurs éléments présents avant le +.

  • .? pour correspondre à 0 ou 1 élément présent avant le ?.

  • a{5} pour correspondre à exactement 5 éléments présents avant les accolades.

  • [a|b] pour correspondre à un des éléments présents entre les crochets et séparés par |.

  • (a|b) pour correspondre à l’expression présentes entre les parenthèses et retenir son contenu.

Les caractères utilisés pour créer des expressions régulières, comme les parenthèses ou le point d’interrogation, doivent être échappés en les préfixant par \.

En Scala, la classe Regex se trouve dans le paquet scala.util.matching. Pour créer une expression régulière, on peut, soit utiliser le constructeur de la classe Regex, soit utiliser la méthode r sur une String pour la transformer en expression régulière.

Lorsqu’on définit une expression régulière dans une String classique, le caractère \ doit être échappé....

Fonctions communes aux collections

Les collections Scala étendent toutes un trait commun qui possède un certain nombre de méthodes utilitaires pour manipuler des collections tout en ne modifiant pas sa structure.

Tout au long de cette section on prendra comme exemples trois List[Int] :

  • nombres contenant plusieurs éléments.

  • singleton ne possédant qu’un seul élément.

  • listeVide vide.

val nombres = List(2, 3, 5, 8, 13, 21) 
// nombres: List[Int] = List(2, 3, 5, 8, 13, 21) 
 
val singleton = List(65) 
// singleton: List[Int] = List(65) 
 
val listeVide: List[Int] = Nil 
// listeVide: List[Int] = List() 

La plupart de ces méthodes prennent en argument des fonctions et on utilisera des fonctions anonymes pour simplifier la syntaxe.

1. Accès aux éléments d’une collection

a. head

La méthode head permet de récupérer le premier élément d’une collection.

nombres.head 
// res78: Int = 2 

Si la collection est vide, une exception est levée.

Nil.head 
// java.util.NoSuchElementException: head of empty list 

C’est pour cela qu’il faut lui préférer la méthode headOption qui, elle, renvoie une Option contenant le premier élément de la collection s’il existe.

Si elle est appliquée à nombres, elle renvoie une Option non vide mais si elle est appliquée à la listeVide, elle renvoie None.

nombres.headOption 
// res79: Option[Int] = Some(2) 
 
Nil.headOption 
// res80: Option[Any] = None 

b. tail

La méthode tail permet de récupérer tous les éléments de la collection sauf le premier. Une nouvelle collection est créée avec les nouveaux éléments.

nombres.tail 
// res81: List[Int] = List(3, 5, 8, 13, 21) 

Si la collection ne contient qu’un élément, la méthode renvoie une liste vide.

singleton.tail 
// res82: List[Int] = List() 

Si la collection est vide, une exception est levée.

Nil.tail 
java.lang.UnsupportedOperationException: tail of empty list 

Pour contrer ce problème, on peut avoir recours à un pattern matching. On va définir la méthode queue qui prend en entrée une collection et renvoie :

  • si la liste est non vide : le résultat...

Gestion des erreurs

Les valeurs null et les exceptions sont presque inexistantes en Scala : on évite de les utiliser en préférant des structures comme les Option, les Try ou les Either. Dans le cas où des exceptions peuvent survenir, on peut toujours utiliser un bloc try / catch / finally.

Contrairement au Java, on ne peut pas spécifier quelles Exception sont renvoyées par une fonction. C’est pour cela qu’il faut toujours protéger les appels des fonctions qui peuvent en renvoyer avec les blocs suivants.

1. try/catch/finally

Comme en Java, il est possible d’encadrer du code pour intercepter les Exception et les traiter. Dans la partie catch, on retrouve un pattern matching décrivant les différents types d’erreurs qu’on peut rencontrer. Dans la partie finally, on peut effectuer des opérations à la fin du bloc, qu’il finisse en erreur ou en succès.

Prenons comme exemple une méthode qui prend en entrée une URL de site Internet sous forme de String et qui imprime le contenu du site.

Dans le bloc try, on va créer une URL à partir de la String et assigner son contenu à une variable contenuDuSite.

Dans le bloc catch, on va intercepter les erreurs liées à une URL incorrecte et celles liées à un site inexistant.

Dans le bloc finally, on imprime le contenu récupéré s’il existe ou une chaîne vide sinon.

import java.io.{FileNotFoundException, IOException} 
import scala.io.Source 
 
def imprimeSite(url: String) = { 
 var contenuDuSite = "" 
 try { 
   contenuDuSite = 
Source.fromURL(URI.create(url).toURL).mkString 
 } catch { 
   case _: IllegalArgumentException => println("URL incorrecte") 
   case _: UnknownHostException => println("Site inconnu") 
 } finally { 
   println(s"Contenu du site $url : $contenuDuSite") 
 } 
} 
// imprimeSite: (nomDeFichier: String)Unit 

Si on appelle cette méthode avec un site existant, la méthode fonctionne correctement. 

imprimeSite("http://www.perdu.com/") 
// Contenu du site http://www.perdu.com/ : <html><head><title>Vous 
Etes Perdu...

Future

1. Définition

Lorsqu’on veut écrire des opérations asynchrones, on fait appel à un Future. Cela correspond à un bloc d’opérations qui sera disponible à un moment, ou, à défaut, renverra une Exception. Le résultat d’un Future est un Try (vu dans la partie Gestion des erreurs) et peut donc avoir deux valeurs possibles :

  • Success(x), où x est la valeur obtenue calculée dans le corps du Future.

  • Failure(e), où e est une Exception survenue dans le corps du Future ou si ce dernier ne s’est pas terminé.

Future fait partie du package scala.concurrent, il faut l’importer pour pouvoir l’utiliser. De plus, il faut spécifier un contexte d’exécution lors de la manipulation de Future ; pour cela, il suffit d’importer le contexte global présent dans le même paquet et le déclarer dans une variable implicite.

Prenons comme exemple la méthode tacheLongue qui attend 10 secondes puis donne un nombre aléatoire entre 1 et 10.

import scala.concurrent.Future 
import scala.concurrent.ExecutionContext 
import scala.util.Random 
 
implicit val executor: ExecutionContext = ExecutionContext.global 
// executor: scala.concurrent.ExecutionContext = 
scala.concurrent.impl.ExecutionContextImpl@d204ff8 
 
def tacheLongue(): Future[Int] = Future { 
 Thread.sleep(10000) 
 Random.nextInt(10) 
} 
// tacheLongue: ()scala.concurrent.Future[Int] 

Si on appelle cette méthode, l’élément obtenu sera un Future non complété.

val resultat = tacheLongue() 
// resultat: Future[Int] = Future(<not completed>) 

Si on attend 10 secondes, la valeur créée sera disponible. Pour cela, on peut interrompre le thread mais on peut également utiliser la méthode utilitaire result disponible dans l’objet Await. Elle prend en argument un Future...

Implicites

Un paramètre ou une méthode implicite est un élément défini avec le mot implicit qui est appelé automatiquement à certaines occasions sans devoir être spécifié explicitement.

1. Paramètres implicites

Une méthode ou une classe peuvent prendre en paramètre un argument implicite. 

Prenons comme exemple une liste sur laquelle on applique la méthode sorted

Une nouvelle liste ordonnée est créée avec l’Ordering implicite contenu dans la classe String qui trie les éléments par ordre alphabétique.

val liste = List("blanc", "azur", "pourpre", "violet") 
// liste: List[String] = List(blanc, azur, pourpre, violet) 
 
liste.sorted 
// res161: List[String] = List(azur, blanc, pourpre, violet) 

On définit un implicit de type Ordering qui classe des String par taille croissante. Pour cela, on utilise la méthode by propre à Ordering qui permet d’appliquer une fonction anonyme avec la méthode length de String (voir chapitre Un aperçu du langage - Méthodes).

implicit val ordre: Ordering[String] = Ordering.by(_.length) 
// ordre: Ordering[String] = scala.math.Ordering$$anon$7@22ffd61f 
 
liste.sorted 
// res162: List[String] = List(azur, blanc, violet, pourpre) 

On peut aussi appeler cette méthode en renseignant directement l’argument.

liste.sorted(Ordering.String) 
// res163: List[String] = List(azur, blanc, pourpre, violet) 

2. Conversions implicites

Une conversion implicite peut être effectuée dans l’un des deux cas :

  • Un type T est attendu mais une expression est de type U est fournie.

  • Un élément de type U appelle une méthode m non présente dans ce type.

a. Changer le type d’un élément

Dans un premier temps, le compilateur cherche un convertisseur du type U au type T.

Par exemple, certaines conversions implicites existent entre les types numériques : on peut passer d’un Int à un Long mais pas l’inverse.

val i: Long = 1 
// i: Long = 1 
 
val j: Int = 1L 
// Type mismatch 

On peut créer une conversion implicite en définissant une méthode...

Exemple complet

Dans cette partie, nous allons créer un service permettant de gérer une machine à café qui s’inspire de l’exercice disponible sur le site https://simcap.github.io/coffeemachine. Le code est disponible dans les paquets services et modeles.

1. Définition du service

Le service propose plusieurs méthodes pour gérer la machine :

  • total pour récupérer le montant total d’argent récupéré.

  • acheterProduit pour acheter un produit.

  • produitsRestants pour récupérer les produits de type code restants.

  • ajouterProduit pour ajouter des produits de type code.

  • supprimerProduit pour supprimer les produits de type code.

2. Modèles

Un produit est défini par le trait Produit et chaque type de produit est défini par un case object. Il possède les attributs suivants :

  • Un prix.

  • Un code.

  • Une quantité initiale.

On surcharge la méthode toString pour plus de praticité.

trait Produit { 
 override final def toString: String = code 
 def prix: Double 
 def code: String 
 def quantiteInitiale: Int 
} 

Le trait est étendu par trois traits scellés :

  • Bouteille

  • Canette

  • Friandise

a. Friandise

Le trait scellé Friandise propose un prix et une quantité initiale par défaut.

sealed trait Friandise extends Produit { 
 override val prix: Double = 1.0 
 override val quantiteInitiale: Int = 5 
} 

Ces valeurs peuvent être redéfinies dans un case object qui étend ce trait ou gardées par défaut. Le champ code doit lui être défini dans chaque case object qui étend ce trait.

Par exemple, le case object Chocolat définit uniquement le code alors que le case object BarreEnergetique redéfinit le code et le prix. De même, le case object Reglisse redéfinit le code et la quantité initiale.

case object Chocolat extends Friandise { 
 override val code: String = "CHO" 
} 
 
case object BarreEnergetique extends Friandise { 
 override val code: String = "BAR" 
 override val prix: Double = 1.5 
} 
 
case object Reglisse extends Friandise { 
 override val code: String = "REG" 
 override...