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 => U où U 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...