Vous êtes sur la page 1sur 36

Programmation fonctionnelle

Cours 7
Évaluation paresseuse
Programmation dirigée par type

1
Évaluation paresseuse

• L'évaluation paresseuse ou appel par besoin est une stratégie


d'évaluation où une expression n'est pas évaluée avant sa
première utilisation, c'est-à-dire pour reporter l'évaluation
jusqu'à ce qu'elle soit demandée.
• Sans paresseux:
val l = List(1, 2, 3, 4, 5)
val output = l.map(l=> l*2)
println(output)
• La valeur de la sortie est calculée dès que l'opération y est
appliquée.

2
Évaluation paresseuse

• Avec paresseux:
val l2 = List(1, 2, 3, 4, 5)
lazy val output2 = l2.map(l=> l*2)
println(output2)

• La valeur n'est pas calculée tant que nous n'utilisons pas


output2 qui est jusqu'à println(output2).

3
Pourquoi une évaluation paresseuse?

• Dans l'exemple, que se passe-t-il si nous n'utilisons jamais la


valeur de sortie?
• Nous avons gaspillé notre opération de carte (calculs CPU) qui
peut être très coûteuse lorsque nous écrivons du code plus
complexe et plus gros.
• Ici, l'évaluation paresseuse nous aide à optimiser le processus
en évaluant l'expression uniquement lorsque cela est nécessaire
et en évitant une surcharge inutile.

4
Pourquoi une évaluation paresseuse?
Avantages

• Optimise le processus de calcul. Spark, un moteur de calcul Big


Data, utilise cette technique à la base.
• L'évaluation paresseuse peut nous aider à résoudre les
dépendances circulaires.
• Donne accès à une structure de données infinie.
• Permet la modularité du code en parties.
• Le programmeur perd le contrôle de la séquence où son code
est exécuté car certaines expressions sont évaluées et d’autres
ne dépendent pas du besoin.

5
Pourquoi une évaluation paresseuse?
Les inconvénients

• Trouver des bogues peut être délicat car le programmeur n'a


aucun contrôle sur l'exécution du programme.
• Peut augmenter la complexité de l'espace car toutes les
instructions (opérations) doivent être stockées.
• Plus difficile à coder contrairement à l'approche
conventionnelle.

6
Programmation dirigée par type

• Nous avons vu précédemment que le compilateur est capable


d'inférer des types à partir de valeurs.
• Par exemple, lorsque nous écrivons la définition: val x = 12
le compilateur en déduit que le type de x est Int, car le type de 12
est Int.
• Cela fonctionne également avec des expressions plus
complexes, par exemple si nous écrivons: val y = x + 1
le compilateur est à nouveau capable de déduire que y a le type Int
car l'opération + entre deux valeurs Int renvoie une valeur Int.

7
Programmation dirigée par type

• Vous verrez que le compilateur est capable de faire le contraire,


à savoir inférer des valeurs à partir de types.
• Pourquoi est-ce utile? Lorsqu'il y a exactement une valeur
"évidente" pour un type, le compilateur peut trouver cette
valeur et vous la fournir.

8
Programmation dirigée par type
• Considérez une méthode sort qui prend comme paramètre un
List[Int] et renvoie un autre List[Int] contenant les mêmes
éléments, mais triés. L'implémentation de cette méthode
ressemblerait à ceci:
def sort(xs: List[Int]): List[Int] = {
...
... if (x < y) ...
...
}
• L'implémentation réelle de l'algorithme de tri n'a pas
d'importance dans cet exemple, la partie importante est qu'à un
moment donné, la méthode doit comparer deux éléments x et y
de la liste.
• La signature de la méthode sort, comme indiqué ci-dessus, ne
fonctionne qu'avec des collections de type List[Int]. Est-il
possible de généraliser la méthode pour qu'elle puisse trier 9des
collections de type List[Double] ou List[String]?
Programmation dirigée par type
• Une approche simple consisterait à utiliser un type polymorphe
A pour le type d'éléments:
def sort[A](xs: List[A]): List[A] = ...
• Mais cela ne suffit pas, car l'opération de comparaison <n'est
pas définie pour les types arbitraires A. L'opération sort doit
prendre comme paramètre l'opération de comparaison:
def sort[A](xs: List[A])(lessThan: (A, A) => Boolean)
: List[A] = {
...
... if (lessThan(x, y)) ...
...
}

10
Refactoring
• La fonction de comparaison présentée ci-dessus est un moyen
d'implémenter une relation de classement. En fait, il existe déjà un
type dans la bibliothèque standard qui représente les commandes:
package scala.math

trait Ordering[A] {
def compare(a1: A, a2: A): Int
def lt(a1: A, a2: A): Boolean = compare(a1, a2) <=
0
...
}
• Il a une seule méthode abstraite compare, qui prend deux valeurs et
renvoie un nombre positif si la première valeur est supérieure à la
seconde, un nombre négatif si la première valeur est inférieure à la
seconde ou 0 si les deux valeurs sont égales. Il fournit également des
opérations plus pratiques telles que lt, qui renvoie un booléen
indiquant si la première valeur est inférieure à la deuxième valeur.
11
Refactoring
• Ainsi, au lieu de paramétrer la méthode sort avec la fonction
lessThan, elle peut être refactorisée pour prendre un paramètre de
type Ordering:
def sort[A](xs: List[A])(ord: Ordering[A]): List[A] =
{
...
... if (ord.lt(x, y)) ...
...
}
• Avec ce changement, la méthode de tri peut être appelée comme ceci:
import scala.math.Ordering

sort(xs)(Ordering.Int)
sort(strings)(Ordering.String)
• Cela utilise les valeurs Int et String définies dans l'objet
scala.math.Ordering, qui produisent le bon ordre sur les entiers et les
12
chaînes.
Refactoring
• Notez que les symboles Int et String font référence à des valeurs ici,
pas à des types.
• Dans Scala, il est possible d'utiliser le même symbole pour les types
et les valeurs.
• Selon le contexte, le compilateur déduit si un symbole fait référence à
un type ou à une valeur. Voici comment la valeur Ordering.Int est
définie:
object Ordering {
val Int = new Ordering[Int] {
def compare(x: Int, y: Int) = if (x > y) 1 else if (
x < y) -1 else 0
}
}

13
Refactoring
• Notez que les symboles Int et String font référence à des valeurs ici,
pas à des types.
• Dans Scala, il est possible d'utiliser le même symbole pour les types
et les valeurs.
• Selon le contexte, le compilateur déduit si un symbole fait référence à
un type ou à une valeur. Voici comment la valeur Ordering.Int est
définie:
object Ordering {
val Int = new Ordering[Int] {
def compare(x: Int, y: Int) = if (x > y) 1 else if (
x < y) -1 else 0
}
}

14
Programmation dirigée par type
• La première étape consiste à indiquer que nous voulons que le
compilateur fournisse l'argument ord en le marquant comme
implicite:
def sort[A](xs: List[A])(implicit ord: Ordering[A]):
List[A] = ...
• Ensuite, les appels à sort peuvent omettre le paramètre ord et le
compilateur essaiera de l'inférer pour nous:
sort(xs)
sort(ys)
sort(strings)
• Le compilateur déduit la valeur de l'argument en fonction de son type
attendu.

15
Programmation dirigée par type
• Détaillons les étapes par lesquelles le compilateur passe, afin de
déduire le paramètre implicite. Considérez l'expression suivante:
• sort(xs)
• Puisque xs a le type List[Int], le compilateur corrige le paramètre de
type A de sort à Int:
• sort[Int](xs)
• En conséquence, cela fixe également le type attendu du paramètre ord
à Ordering[Int].
• Le compilateur recherche les définitions candidates qui correspondent
au type attendu Ordering[Int]. Dans notre cas, le seul candidat
correspondant est la définition Ordering.Int. Ainsi, le compilateur
passe la valeur Ordering.Int à la méthode sort:
• sort[Int](xs)(Ordering.Int)
16
Programmation dirigée par type
• Avant d'expliquer comment les valeurs candidates sont définies,
décrivons quelques faits sur les paramètres implicites.
• Une méthode ne peut avoir qu'une seule liste de paramètres implicites
et doit être la dernière liste de paramètres donnée.
• Au site d'appel, les arguments de la clause donnée sont généralement
laissés de côté, bien qu'il soit possible de les passer explicitement:
// Argument déduit par le compilateur
sort(xs)

// Argument explicite
sort(xs)(Ordering.Int.reverse)

17
Argument explicite
• Où le compilateur recherche-t-il les définitions candidates lorsqu'il
tente d'inférer un paramètre implicite de type T?
• Le compilateur recherche les définitions qui:
• avoir le type T,
• sont marqués implicites,
• sont visibles au point de l'appel de fonction, ou sont définis dans
un objet compagnon associé à T.
• S'il existe une seule définition (la plus spécifique), elle sera
considérée comme l'argument réel du paramètre implicite. Sinon, une
erreur est signalée.

18
Définitions implicites
• Une définition implicite est une définition qualifiée avec le mot clé
implicite:
object Ordering {
implicit val Int: Ordering[Int] = ...
}
• Le code ci-dessus définit une valeur implicite de type Ordering[Int],
nommée Int.
• Toute définition val, lazy val, def ou object peut être marquée comme
implicite.
• Enfin, les définitions implicites peuvent prendre des paramètres de
type et des paramètres implicites.
implicit def orderingPair[A, B](implicit
orderingA: Ordering[A],
orderingB: Ordering[B]
): Ordering[(A, B)] = ...
19
Portée de la recherche implicite
• La recherche d'une valeur implicite de type T examine d'abord toutes
les définitions implicites qui sont visibles (héritées, importées ou
définies dans une portée englobante).
• Si le compilateur ne trouve pas d'instance implicite correspondant au
type T interrogé dans la portée lexicale, il continue la recherche dans
les objets compagnons associés à T. Il y a deux concepts à expliquer
ici: les objets compagnons et les types associés à d'autres types.
• Un objet compagnon est un objet qui porte le même nom qu'un type.
Par exemple, l'objet scala.math.Ordering est le compagnon du type
scala.math.Ordering.
• Les types associés à un type T sont:
• si T a des types parents T₁ avec T₂ ... avec Tₙ, l'union des parties
de T₁, ... Tₙ ainsi que T lui-même,
• si T est un type paramétré S [T₁, T₂, ..., Tₙ], l'union des parties de
S et T₁, ..., Tₙ, 20

• sinon, juste T lui-même.


Portée de la recherche implicite
• À titre d'exemple, considérons la hiérarchie de types suivante:
trait Foo[A]
trait Bar[A] extends Foo[A]
trait Baz[A] extends Bar[A]
trait X
trait Y extends X
• Si une valeur implicite de type Bar[Y] est requise, le compilateur
recherchera des définitions implicites dans les objets compagnons
suivants:
• Bar, car il fait partie de Bar[Y],
• Y, car il fait partie de Bar[Y],
• Foo, car il s'agit d'un type de barre parent,
• et X, car il s'agit d'un type parent de Y.
• Cependant, l'objet compagnon Baz ne sera pas visité.
21
Processus de recherche implicite
• Le processus de recherche peut résulter en aucun candidat ou au
moins un candidat trouvé.
• S'il n'y a pas de définition implicite disponible correspondant au type
interrogé, une erreur est signalée:
• scala> def f(implicit n: Int) = ()
• scala> f
• ^
• error: could not find implicit value for parameter n: Int
• En revanche, si plusieurs définitions implicites sont éligibles, une
ambiguïté est signalée:
• match expected type Int

22
Processus de recherche implicite
• En fait, plusieurs définitions implicites correspondant au même type ne
génèrent pas d’ambiguïté si l’une est plus spécifique que l’autre.
• Une définition a: A est plus spécifique qu'une définition b: B si:
• le type A a plus de pièces «fixes»,
• ou, a est défini dans une classe ou un objet qui est une sous-classe de la
classe définissant b.
• Voyons quelques exemples de priorités au travail.
• Quelle définition implicite correspond au paramètre implicite Int lorsque la
méthode f suivante est appelée?
implicit def universal[A]: A = ???
implicit def int: Int = ???
def f(implicit n: Int) = ()
f
• Dans ce cas, comme universal prend un paramètre de type et que int n'en a
pas, int a plus de parties fixes et est considéré comme plus spécifique que
universal. Ainsi, il n'y a pas d'ambiguïté et le compilateur sélectionne int.23
Processus de recherche implicite
• Quelle définition implicite correspond au paramètre implicite
Int lorsque la méthode f suivante est appelée?
trait A {
implicit val x: Int = 0
}
trait B extends A {
implicit val y: Int = 1
def f(implicit n: Int) = ()
f
}
• Ici, parce que y est défini dans un trait qui étend A (où x est
défini), y est plus spécifique que x. Ainsi, il n'y a pas
d'ambiguïté et le compilateur sélectionne y.

24
Limites de contexte
• Le sucre syntaxique permet l'omission de la liste de paramètres implicites:
def printSorted[A: Ordering](as: List[A]): Unit = {
println(sort(as))
}
• Le paramètre de type A a un contexte lié: Ordering. Cela équivaut à écrire:
def printSorted[A](as: List[A])(implicit ev1: Ordering[A]):
Unit = {
println(sort(as))
}
• Plus généralement, une définition de méthode telle que:
• def f[A: U₁ ... : Uₙ](ps): R = ...
• Est étendu à:
• def f[A](ps)(implicit ev₁: U₁[A], ..., evₙ: Uₙ[A]): R = ..
.

25
Requête implicite
• À tout moment dans un programme, on peut interroger une valeur
implicite d'un type donné en appelant l'opération implicitement:
• scala> implicitly[Ordering[Int]]
• res0: Ordering[Int] = scala.math.Ordering$Int$@72154ac1
• Notez que ce n'est pas implicitement un mot-clé spécial, il est défini
comme une opération de bibliothèque:
• def implicitly[A](implicit value: A): A = value

26
Classes de type
• Nous avons défini un type paramétré Ordering[A], des instances
implicites de ce type pour les types concrets A et des paramètres
implicites de type Ordering[A]:
trait Ordering[A] {
def compare(a1: A, a2: A): Int
}

object Ordering {
implicit val Int: Ordering[Int] =
new Ordering[Int] {
def compare(x: Int, y: Int) = if (x < y) -
1 else if (x > y) 1 else 0
}
implicit val String: Ordering[String] =
new Ordering[String] {
def compare(s: String, t: String) = s.compareTo(t)
}
}
27
Classes de type
• Les classes de types fournissent encore une autre forme de
polymorphisme. La méthode sort peut être appelée avec des listes
contenant des éléments de tout type A pour lesquels il existe une valeur
implicite de type Ordering[A].

• Au moment de la compilation, le compilateur résout l'implémentation


Ordering spécifique qui correspond au type des éléments de la liste.

28
Extension rétroactive
• Les classes de types nous permettent d'ajouter de nouvelles
fonctionnalités aux types de données sans changer la définition d'origine
de ces types de données. Par exemple, considérons le type rationnel
suivant, modélisant un nombre rationnel:
case class Rational(num: Int, denom: Int)
• On peut ajouter la capacité "à comparer" au type Rational en définissant
une instance implicite de type Ordering[Rational]:
object RationalOrdering {
implicit val orderingRational: Ordering[Rational] =
new Ordering[Rational] {
def compare(q: Rational, r: Rational): Int =
q.num * r.denom - r.num * q.denom
}
}

29
Extension rétroactive - Règles
• Les instances de la classe de type Ordering[A] doivent satisfaire les
propriétés suivantes:
• inverse: le signe du résultat de la comparaison de x et y doit être
l'inverse du signe du résultat de la comparaison de y et x,
• transitive: si une valeur x est inférieure à y et que y est inférieure à z,
alors x doit aussi être inférieur à z,
• cohérent: si deux valeurs x et y sont égales, alors le signe du résultat
de la comparaison de x et z doit être le même que le signe du résultat
de la comparaison de y et z.

30
Classe de type: Anneau (Ring)
• En mathématiques, un anneau est l'une des structures algébriques
fondamentales utilisées en algèbre abstraite. Il consiste en un ensemble
équipé de deux opérations binaires qui généralisent les opérations
arithmétiques d'addition et de multiplication. Grâce à cette généralisation,
les théorèmes de l'arithmétique sont étendus aux objets non numériques
tels que les polynômes, les séries, les matrices et les fonctions.

• Cette structure est si commune que, en faisant abstraction de la structure


en anneau, les développeurs pourraient écrire des programmes qui
pourraient ensuite être appliqués à divers domaines (arithmétique,
polynômes, séries, matrices et fonctions).

• Un anneau est un ensemble équipé de deux opérations binaires, + et *,


satisfaisant les lois suivantes (appelées axiomes d'anneau):

31
Classe de type: Anneau (Ring)
• (a + b) + c = a + (b + c) + est associatif
• a + b = b + a + est commutatif
• a + 0 = a 0 est l'identité additive
• a + -a = 0 -a est l'inverse additif de a
• (a * b) * c = a * (b * c) * est associatif
• a * 1 = a 1 est l'identité multiplicative
• a * (b + c) = a * b + a * c distributivité gauche
• (b + c) * a = b * a + c * une distributivité droite

32
Classe de type: Anneau (Ring)
• Voici comment nous pouvons définir une classe de type d'anneau
dans Scala:
trait Ring[A] {
def plus(x: A, y: A): A
def mult(x: A, y: A): A
def inverse(x: A): A
def zero: A
def one: A
}
• Voici comment nous définissons une instance de Ring[Int]:
object Ring {
implicit val ringInt: Ring[Int] = new Ring[Int] {
def plus(x: Int, y: Int): Int = x + y
def mult(x: Int, y: Int): Int = x * y
def inverse(x: Int): Int = -x
def zero: Int = 0
def one: Int = 1
33
}
}
Classe de type: Anneau (Ring)
• Voici comment définir une fonction qui vérifie que la loi
d'associativité + est satisfaite par une instance Ring donnée:
def plusAssociativity[A](x: A, y: A, z: A)(implicit r
ing: Ring[A]): Boolean =
ring.plus(ring.plus(x, y), z) == ring.plus(x, ring.
plus(y, z))
• Les classes de types fournissent une forme de polymorphisme: elles
peuvent être utilisées pour implémenter des algorithmes qui peuvent
être appliqués à différents types. Le compilateur sélectionne
l'implémentation de la classe de type pour un type spécifique au
moment de la compilation.

• Une définition de classe de type est un trait qui prend des paramètres
de type et définit les opérations qui s'appliquent à ces types.
Généralement, une définition de classe de type est accompagnée de
lois, vérifiant que les implémentations de leurs opérations sont
correctes. 34
Conclusions
• Il doit y avoir une définition implicite unique (la plus spécifique)
correspondant au type interrogé pour qu'elle soit sélectionnée par le
compilateur.

• Les valeurs implicites sont recherchées dans la portée lexicale


englobante (importations, paramètres, membres hérités) ainsi que
dans la portée implicite du type interrogé.

• La portée implicite de type est constituée de valeurs implicites


définies dans des objets compagnons de types associés au type
interrogé.

35
Merci de votre attention!

PF 2021-2022 36

Vous aimerez peut-être aussi