Si vous pensez que vos clients sont pénibles, allez donc faire un tour sur Not Always Right.

Passer de Java à Scala – Partie 9 – Case classes et pattern matching

Passer de Java à Scala – Partie 9 – Case classes et pattern matching

Dans ce 9è et dernier article de la série « Passer de Java à Scala », je vais vous présenter les cases classes et le pattern matching.

Ce sont des techniques issues de la programmation fonctionnelle. Elles sont particulièrement adaptées pour gérer des appels récursifs à des données représentées sous forme d’arbre.

Retrouvez le code source des exemples ici : https://github.com/alexandrelanglais/scala-tutorial-9

Retrouvez toutes les sources de la série ici  : https://github.com/alexandrelanglais/scala-tutorial-full

Retrouvez tous les articles de la série « Passer de Java à Scala »

Un premier exemple

Pour illustrer ce qu’est le pattern matching, je vais reprendre l’exemple de calculette dont je m’étais servi dans l’article d’introduction de cette série : elle devrait être capable de gérer des constantes, des additions et des variables. Par exemple, elle devrait pouvoir résoudre 1+2 et (x+x) + (7 +y) en ayant pris soin de définir auparavant les valeurs de x et y dans l’espace de nom du programme (nous verrons cela plus loin).

Commençons par créer des case classe qui puissent gérer :

  • des constantes (1, 2, 7 etc..)
  • des variables (x, y)
  • des opérations (en l’occurrence une addition)
abstract class Expression {
  case class Sum(left: Expression, right: Expression) extends Expression
  case class Var(n: String) extends Expression
  case class Const(v: Int) extends Expression
}

Ici on a une classe abstraite nommée Expression et 3 sous-classes qui étendent Expression et répondent chacune aux problématiques précédentes :

  • la case class Const va gérer les constantes : elle prend donc un entier constant en paramètre
  • la case class Var va gérer les variables : elle prend donc une String représentant le nom de la variable en paramètre
  • la case class Sum va gérer les additions de 2 Expression : elle prend donc 2 expressions en paramètres, celle de gauche et celle de droite

Les case class

Le modification case avant chaque classe permet au compilateur d’ajouter quelques simplicités syntaxiques :

  • on peut écrire Var(« x ») au lieu de new Var(« x »)
  • les paramètres des case classes sont automatiquement retranscrits en val, ce qui permet d’accéder à ces champs sans avoir à les définir explicitement
  • le compilateur va générer des méthodes toString, equals et hashCode adaptées à ce type de classe
  • le compilateur rajoute une méthode copy(), similaire au clone de Java, qui permet de générer une nouvelle instance de la case classe en se basant sur une case class existante.

Voyons une utilisation au plus simple du pattern matching : le pattern matching se comporte comme un match (ou switch en Java) sauf que le choix va s’effectuer sur une case classe et sur les valeurs des paramètres de celle-ci.

Commençons à écrire une fonction pour faire les calculs (on laissera de côté dans un premier temps la notion de variables) :

def calculate(expr: Expression): Int = expr match {
  case Const(n) => n
  case Sum(left, right) => calculate(left) + calculate(right)
}

Le pattern matching se fait via l’instruction expr match. La fonction calculate prend en paramètre une Expression et renvoie un entier.

Intéressons-nous au cas où l’on passe une case classe de type Const : le code renvoie simplement la valeur de la constante.

Si on écrit le programme suivant :

def main(args: Array[String]): Unit = {
  println(calculate(Const(4)))
}

On obtiendra la sortie :

4

Jusqu’ici tout va bien. Maintenant regardons la fonction Sum : elle prend en paramètres 2 Expression. On peut donc écrire le code suivant :

println(calculate(Sum(Const(4), Const(5))))

Chaque Const va nous renvoyer un entier (4 et 5) par récurrence via les appels calculate(left) et calculate(right) définis dans la fonction Sum.

L’appel à Sum va donc nous renvoyer le résultat suivant :

9

On peut même si on le souhaite faire 2 fois appel à Sum :

val left = Sum(Const(4), Const(5))
val right = Const(1000)
println(calculate(Sum(left, right)))

Ce qui produira la sortie 1009.

Gérer les variables

Implémentons maintenant la partie de calculate qui va s’occuper des variables (x,  y etc…)

On veut pouvoir à terme résoudre un problème de mathématiques de niveau 6ème comme :

Sachant que x vaut 5 et que y vaut 3

Combien font (x +3) + (x + y) ?

On veut donc pouvoir, avant d’effectuer nos calculer, déclarer qu’une String x vaut 5 et qu’une String y vaut 3.

Pour cela, on va utiliser un environnement d’exécution, que l’on va définir comme étant un « convertisseur » de String vers un entier. On définit cet environnement comme ceci :

type Environment = String => Int

On définit un type dans lequel les String sont convertis en Int :

scala> type Environment = String => Int
defined type alias Environment

Le mot-clé type permet de donner des alias à des concepts (classes, opérations, packages etc…). Ici Environment représente un switch entre une String et un Int. C’est pourquoi on peut assigner des valeurs à cet environnement de cette manière :

scala> val env:Environment = {case "x" => 5 case "y" => 3}
env: Environment = $$Lambda$1320/885722697@53ba7997

env est une variable contenant fonction qui effectue un match : si la valeur référencée est « x », alors renvoyer 5. Si c’est y, renvoyer 3.

scala> env("x")
res3: Int = 5

Sachant cela, on peut réécrire calculate ainsi :

def calculate(expr: Expression, env: Environment): Int = expr match {
  case Var(x) => env(x)
  case Const(n) => n
  case Sum(left, right) => calculate(left, env) + calculate(right, env)
}

Var(x) ira chercher dans l’environnement passé en paramètre la valeur de x. Pour appeler cette fonction, on lui passe un environnement où on définit nos variables x et y.

Pour résoudre notre problème d’écolier, on peut finalement écrire :

def main(args: Array[String]): Unit = {
  val env:Environment = {case "x" => 5 case "y" => 3} // Sachant que x vaut 5 et y vaut 3
  val probleme = Sum(Sum(Var("x"), Const(3)), Sum(Var("x"), Var("y"))) // Combien font (x + 3) + (x + y)
  println(calculate(probleme, env))
}

Nous donnera le résultat 16.

Ajout d’opérations

Imaginons que l’on veuille pouvoir diviser, multiplier et soustraire. A l’image de Sum, on pourrait implémenter des méthodes Mul(), Div() et Sub().

Cependant le pattern matching nous permet d’avoir un code plus concis en analysant l’intérieur des case class. On peut par exemple créer la méthode Operation() et passer un signe en premier argument pour connaitre l’opération à effectuer sur les opérandes droite et gauche :

def calculate(expr: Expression, env: Environment): Int = expr match {
  case Var(x) => env(x)
  case Const(n) => n
  case Sum(left, right) => calculate(left, env) + calculate(right, env)
  case Operation("-", left, right) => calculate(left, env) - calculate(right, env)
  case Operation("/", left, right) => calculate(left, env) / calculate(right, env)
  case Operation("*", left, right) => calculate(left, env) * calculate(right, env)
}

Pour calculer l’hypothénuse d’un triangle rectangle de côté a = 3 et b = 5, on peut écrire :

val pythagoreEnv:Environment  = {case "a" => 3 case "b" => 5 }
val hypothenuse = calculate(Sum(Operation("*", Var("a"), Var("a")), Operation("*", Var("b"), Var("b"))), pythagoreEnv)

println(hypothenuse)

On obtiendra la sortie :

34

Les types de pattern

Dans l’exemple précédent on a vu du pattern matching avec des case classe, mais l’on peut avoir d’autres types de patterns.

Patterns de constantes

Le code suivant ne fait un matching qu’avec des constantes :

def define(x: Any): String = x match {
  case true => "vrai"
  case 2 => "un deux"
  case Nil => "une liste vide"
  case _ => "autre chose"
}
println(define(true))
println(define(2))
println(define(Nil))
println(define(false)) // autre chose

La sortie sera la suivante :

vrai
un deux
une liste vide
autre chose

Pattern de variables

Un pattern de variables matchera n’importe quoi (tel un wildcard _) et stockera la valeur dans une variable pouvant être réutilisée par la suite :

// Pattern de variables
def varPattern(x: Any): String = x match {
  case 0 => "Zero"
  case anythingElse => "On a autre chose que 0 : " + anythingElse
}

println(varPattern(5))

Affichera la sortie suivante :

On a autre chose que 0 : 5

L’intérêt est de ne pas dropper ce qui a été passé mais le récupérer.

Pattern de constructeur

Un pattern de constructeur est un pattern où l’on va matcher avec les paramètres passés dans le constructeur. Dans la calculette auparavant, nous avons vu ce pattern avec par exemple :

case Operation("-", left, right) => calculate(left, env) - calculate(right, env)

Le match se fait sur le constructeur de Operation et regarde si le premier argument est un signe « + »

Pattern de séquence

Il est possible de faire un match sur des List ou des Map de la même manière que l’on matche sur les classes, mais en spécifiant le nombre d’argument de ces derniers.

Par exemple, pour matcher sur une liste qui commence par « Hello » et qui contient 3 éléments on peut écrire :

// Pattern de séquence
def seqPattern(list: Any): String = list match {
  case List("Hello", _, _) => "On a un match"
  case _ => "KO"
}

println(seqPattern(Nil)) // KO
println(seqPattern(List("Hello", "world"))) // KO
println(seqPattern(List("Hello", "world", "!"))) // Match

Pattern de types

On peut utiliser les patterns de types pour par exemple éviter les cast. On peut ainsi définir une méthode pour définir la taille d’une string ou la taille d’une liste comme ceci :

// Pattern de type
def typePattern(x: Any) = x match {
  case s:String => s.length
  case l:List[_] => l.size
  case m:Map[_, _] => m.size
  case _ => -1
}

On évite ainsi les checks isInstanceOf et asInstanceOf.

Conclusion

C’est la fin de cet article et de cette série « Passer de Java à Scala ».

J’espère qu’après cela, votre envie de faire du Scala se sera renforcée et que vous allez continuer à vous renseigner sur ce langage qui pour ma part me plait énormément.

Retrouvez toutes les sources de la série ici  : https://github.com/alexandrelanglais/scala-tutorial-full

Retrouvez tous les articles de la série « Passer de Java à Scala »

Laisser un commentaire