Il y a 11 ans -
Temps de lecture 10 minutes
Scala – jouer avec le pattern matching
Combien de fois vous êtes vous senti engoncé dans votre frustration parce que vous étiez incapable d’utiliser des chaînes de caractères dans vos switch-case ? À défaut de pouvoir utiliser Java 7, une telle possibilité serait très utile pour par exemple traiter les arguments de votre application, pour analyser un fichier ou le contenu d’une chaîne. En fait, pour y arriver, vous devez écrire une série de if-else-if. Mais vous pourriez aussi utiliser une table de hachage, où les clés sont les chaînes de caractères et les valeurs sont les traitements réifiés par des Runnable, des Callable ou des Function de Guava.
Si le switch-case acceptant des chaînes de caractères est pour vous une chouette invention, le pattern matching de Scala nous indique que ce n’est pas suffisant ! En effet, il y a d’autres cas où une série de if-else_if-else_if… serait sympathiquement transformée en une sorte de structure plus ou moins équivalente au switch-case. Par exemple, ce serait bien de pouvoir simplifier une série de compositions entre des instanceof et des class cast enfermés dans cette série de if-else_if-… en vue de réaliser des traitements spécifiques selon le type d’un paramètre (en attendant le multi-méthode).
Dans cet article, nous allons voir ce que peut apporter le pattern matching de Scala dans différents cas.
Note : le pattern matching tel qu’il apparaît dans le langage Scala n’est pas nouveau. Cette caractéristique existe déjà dans des langages comme OCaml et Haskell. On le retrouve aussi dans des dialectes de Lisp. Prolog propose aussi une sorte de pattern matching, mais celui-ci utilise en fait un mécanisme assez différent (on parle d’unification dans ce cas).
Approche classique
Le pattern matching de Scala possède un cas d’utilisation qui est similaire aux switch-case de Java et de C : chaque entrée se base sur un entier ou tout autre scalaire. Voici un exemple :
def toYesOrNo(choice: Int): String = choice match { case 1 => "yes" case 0 => "no" case _ => "error" }
Si vous appelez toYesOrNo(1), Scala répond "yes"
. Et si vous entrez toYesOrNo(2), Scala répond "error"
. Ici, le symbole _ est utilisé pour indiquer le cas par défaut. On peut remarquer que nous n’utilisons pas l’instruction break. Cette instruction est d’une certaine manière implicite.
Maintenant, si vous souhaitez que la fonction réponde "yes"
non seulement pour la valeur 1, mais aussi pour les valeurs 2 et 3, on écrira :
def toYesOrNo(choice: Int): String = choice match { case 1 | 2 | 3 => "yes" case 0 => "no" case _ => "error" }
Jusque là, rien de vraiment nouveau.
Utilisons des chaînes de caractères en entrée, comme le permet Java 7. Utiliser des chaînes est intéressant par exemple lorsque vous devez analyser les arguments passés à votre programme :
def parseArgument(arg: String) = arg match { case "-h" | "--help" => displayHelp case "-v" | "--version" => displayVerion case whatever => unknownArgument(whatever) }
Si vous entrez parseArgument("-h")
ou parseArgument("--help")
, Scala appellera la fonction displayHelp
. Et si vous entrez parseArgument("hein?")
, Scala appellera la fonction unknownArgument("hein?")
. Dans la fonction parseArgument
, nous n’avons pas utilisé ‘_’ pour le cas par défaut. En fait, lorsque le cas par défaut est utilisé, contrairement aux fonctions précédentes, on a la possibilité de lui donner un nom.
Pattern typé
Parfois en Java, vous devez faire face à une variable déclarée en tant que Object ou toute autre interface ou classe de haut niveau. Malheureusement, après avoir vérifié que la variable n’est pas nulle, vous devez en plus utiliser une série de if-else incluant des tests avec instanceof et cast. Tout ça dans le but de vérifier la classe ou l’interface d’une instance, avant de l’utiliser et de la traiter comme il se doit. C’est le cas, par exemple, lorsque vous devez redéfinir la méthode equals() en Java.
Voici comment Scala voit la chose :
def f(x: Any): String = x match { case i:Int => "integer: " + i case _:Double => "a double" case s:String => "I want to say " + s }
On obtient alors les résultats suivants :
f(1) → "integer: 1" f(1.0) → "a double" f("hello") → "I want to say hello"
N’est-ce pas mieux qu’une succession de if+instanceof+cast ?
Ce type de pattern matching est utile pour traverser une structure utilisant le design pattern composite. Par exemple, vous pouvez l’utiliser pour explorer plus aisément le DOM d’un document XML ou le modèle objet d’un message JSON.
Approche fonctionnelle du pattern matching
Une particularité d’une telle structure, c’est qu’elle offre une approche alternative pour écrire des fonctions. Par exemple, considérons la fonction factorielle. Si nous choisissons la version récursive, habituellement nous la définirions ainsi :
def fact(n: Int): Int = if (n == 0) 1 else n * fact(n - 1)
Mais, on peut aussi utiliser le pattern matching de Scala dans ce cas :
def fact(n: Int): Int = n match { case 0 => 1 case n => n * fact(n - 1) }
Notez l’utilisation de la variable n ci-dessus. Elle est mise en correspondance avec toutes les valeurs qui n’apparaissent pas dans les cas précédents. Il faut comprendre que le n du dernier cas n’est pas le même que celui utilisé dans la signature de la fonction. Si vous voulez utiliser directement le paramètre n et non pas une autre variable du même nom, vous devez délimiter le nom de la variable par des guillemets inversés dans la déclaration du case, comme ceci : n
Sinon, voici un moyen simple de définir la fonction factorielle en Scala :
def fact(n) = (1 to n).foldLeft(1) { _ * _ }
Où foldLeft est une opération visant à « réduire » une collection par rapport à une fonction et { _ * _ }
est une fonction anonyme qui équivaut à { case (x: Int, y: Int) => x * y }
, autrement dit une fonction qui prend deux entiers et effectue la multiplication de ces deux entiers.
Pattern matching et collection : l’approche par « similarité »
Le pattern matching peut être appliqué aux collections. Ci-dessous, vous avez une fonction qui calcule la longueur d’une liste sans pattern matching :
def length[A](list: List[A]): Int = { if (list.isEmpty) 0 else 1 + length(list.tail) }
Maintenant, voici la même fonction avec le pattern matching :
def length[A](list: List[A]): Int = list match { case Nil => 0 case _ :: tail => 1 + length(tail) }
Dans cette fonction, il y a deux cas (ou plutôt deux case). Le premier vérifie si la liste est vide grâce à la valeur Nil
. Le second vérifie si il y a au moins un élément dans la liste. La notation _::tail
doit être interprétée comme « une liste ayant au moins un élément, sachant qu’on ne s’intéresse qu’au reste de la liste qui est représenté par la variable tail ». Ici, le reste peut être Nil (ie. la liste vide) ou n’importe quelle liste non vide.
On peut utiliser cette approche par similarité avec des tuples afin d’améliorer la méthode parserArgument vue auparavant :
def parseArgument(arg : String, value: Any) = (arg, value) match { case ("-l", lang) => setLanguageTo(lang) case ("-o" | "--optim", n : Int) if ((0 < n) && (n <= 5)) => setOptimizationLevelTo(n) case ("-o" | "--optim", badLevel) => badOptimizationLevel(badLevel) case ("-h" | "--help", _) => displayHelp() case bad => badArgument(bad) }
Notez en premier lieu l’utilisation de l’opérateur | qui permet de mettre en correspondance des formes alternatives de arg au sein d’un tuple. Notez aussi l’utilisation de deux patterns pour les options -o
et --optim
. Ces patterns se distinguent par l’utilisation d’un prédicat qu’on appelle une garde. Une garde vous permet d’affiner vos case lorsque le pattern ne suffit pas.
Pattern Matching avancé : case class
Les case classes sont des classes dont une partie du comportement est prédéfinie afin de faciliter leur construction et leur utilisation dans des patterns. Les cases classes vous permettent de manipuler des symboles paramétrés, provenant par exemple de l’analyse d’un compilateur ou de votre DSL interne.
L’exemple ci-dessous montre comment utiliser les case classes et le pattern matching afin de représenter simplement des expressions mathématiques, de les évaluer et d’en calculer la dérivée. Commençons par définir les symboles que nous utiliserons : la variable X, la constante, l’addition, la multiplication et la négation (pour le fun !). Ici, sealed signifie qu’aucune classe fille de Expression n’apparaîtra en dehors de l’espace de nom courant.
sealed abstract class Expression case object X extends Expression case class Const(value: Int) extends Expression case class Add(left: Expression, right: Expression) extends Expression case class Mult(left: Expression, right: Expression) extends Expression case class Neg(expr: Expression) extends Expression
Maintenant, définissons une fonction qui évalue une expression avec une valeur donnée pour la variable X, en utilisant le pattern matching.
def eval(expression: Expression, xValue: Int): Int = expression match { case X => xValue case Const(cst) => cst case Add(left, right) => eval(left, xValue) + eval(right, xValue) case Mult(left, right) => eval(left, xValue) * eval(right, xValue) case Neg(expr) => - eval(expr, xValue) }
Essayons la fonction eval :
// 1 + 2 * X*X val expr = Add(Const(1), Mult(Const(2), Mult(X, X))) assert(eval(expr, 3) == 19)
Maintenant, définissons une fonction qui calcule la dérivée (non réduite) par rapport à X d’une expression :
def deriv(expression: Expression): Expression = expression match { case X => Const(1) case Const(_) => Const(0) case Add(left, right) => Add(deriv(left), deriv(right)) case Mult(left, right) => Add(Mult(deriv(left), right), Mult(left, deriv(right))) case Neg(expr) => Neg(deriv(expr)) }
Essayons notre fonction deriv :
val df = deriv(expr)
Voici à quoi ressemble df :
Add(Const(0), Add(Mult(Const(0),Mult(X,X)), Mult(Const(2), Add(Mult(Const(1),X), Mult(X,Const(1)))))) // = 0 + (0 * X*X + 2 * (1*X + X*1)) = 4 * X
Et en utilisant les deux fonctions conjointement :
assert(eval(df, 3), 12)
Autres utilisations avancées du pattern matching
Il y a d’autres notations particulières utilisables dans le pattern matching de Scala. Scala permet ainsi de déclarer des alias sur tout ou partie d’un pattern qui pourront être exploités par la suite. L’alias doit apparaître avant le pattern séparé d’un @. Par exemple, dans l’expression address @ Address(_, _, "Paris", "France")
, nous acceptons dans address toutes les adresses en France à Paris, mais peu importe la rue et le numéro.
Il y a une notation spécifique pour faire correspondre des séquences avec _*. Cette notation prend en compte zéro élément, un élément ou plus dans une séquence.
En Scala, le pattern matching n’apparaît pas seulement après la fonction match. Vous pouvez utiliser le pattern matching pour les paramètres de closures et dans les blocs catch.
Il est possible aussi de personnaliser le comportement du pattern matching au moyen des extractor. Pour cela, vous devez définir la méthode unapply (et/ou unapplySeq pour une séquence) au niveau des objets que vous utiliserez dans les pattern matching.
Conclusion
Dans cet article, nous avons vu différentes manières d’utiliser le pattern matching de Scala. Le principal avantage de cet outil est de fournir une manière simple de construire des structures alternatives basées sur la mise en correspondance de valeurs scalaires, de chaînes de caractères, de collections, mais aussi de symboles paramétrés et de types. Pour moi, le pattern matching est l’une des alternatives les plus sexy à la structure if ! Une bonne utilisation du pattern matching peut rendre votre programme plus lisible et vous aider à mettre en oeuvre un DSL interne.
Cet article est une traduction libre, faite avec l’autorisation de l’auteur, de « Playing with Scala’s pattern matching » publié par François Sarradin le 14/02/2011 sur http://kerflyn.wordpress.com/.
Commentaire
3 réponses pour " Scala – jouer avec le pattern matching "
Published by Francois Scheurer , Il y a 10 ans
Merci Francois pour cet article très didactique! ;)
(je débute en scala)
Published by Francois Scheurer , Il y a 10 ans
Merci Francois pour cet article très didactique! ;)
(je débute en scala)
PS: petite correction
def fact(n:Int) = (1 to n).foldLeft(1) { _ * _ }
^
Published by iuva , Il y a 5 ans
Dans le chapitre sur l’approche fonctionnelle tu dis qu’il faut mettre des guillemets inversés pour utiliser le paramètre directement (histoire de pas confondre le paramètre et la variable déclarée dans le case).
Je propose un petit exemple pour comprendre où tu veux en venir (corrige moi si je me trompe car je débute en Scala) :
//fonction qui permet de multiplier le calcul factoriel par 2
def fact(n: Int): Int = n match {
case 1 => 1+`n` //si n = 1, renvoyer 1+1, donc 2
case m => m * fact(m – 1) //ici, la variable peut s’appeler m ou n ou toto, c’est égal
}
Je sais, ce code n’a pas trop d’intérêt parce qu’on peut faire plus simple mais au moins, on voit qu’on peut utiliser d’une part le paramètre n et d’autre part une autre variable qu’on peut appeler comme on veut.
merci pour ton article, il est top