jeudi 2 juin 2011

The case of the mysterious extractors

En Scala, les case classes sont formidables, en particulier parce qu'elles facilitent le pattern matching. Mais comment faire avec des classes qui ne sont pas "case"?

Les case classes


Prenez celle-ci par exemple:

case class Person(firstname:String,lastname:String)

On peut difficilement faire plus court et pourtant, ce simple morceau de code cache toute une série de bonus:
  • une factory (objet compagnon avec méthode apply basée sur le constructeur primaire)
    val p = Person("John","Doe")
  • des propriétés immutables (val) publiques
    println(p.firstname)
    p.firstname = "Toto" //Ne compile pas
  • une méthode toString de base
    println(p)
    qui écrit
    Person(John,Doe)
  • une méthode equals (et la méthode hashCode correspondante) basée sur l'égalité de tous les paramètres du constructeur primaire
    val p1 = Person("Toto","Tata")
    val p2 = Person("John","Doe")
    p == p1 //FAUX
    p == p2 //VRAI
  • la possibilité de faire du pattern matching: nous y reviendrons dans un instant car c'est le sujet de cet article

Toute cela ne nous empêche pas de modifier certains comportements générés par défaut:

  • on peut ajouter des propriétés
    case class Person(firstname:String, lastname:String){
            var age:Int = _
    }
  • les paramètres du constructeurs peuvent être transformés en var (donc mutables même s'il n'est jamais conseillé de modifier une propriété qui conditionne le résultat du equals et du hashCode)
    case class Person(var firstname:String...
  • les paramètres du constructeur peuvent être rendus private (et éventuellement mutables, voir ci-dessus)
    case class Person(private val firstname...
  • des méthodes peuvent être ajoutées:
    case class Person(firstname:String, lastname:String){
            def speak(text:String) = this + " dit: "+text
    }
  • les méthodes toString, equals et hashCode peuvent être personnalisées
  • d'autres constructeurs peuvent être ajoutés (mais, étant auxiliaires, ne génèrent pas de méthode factory - apply):
    def this(f:String) = this(f,"Doe")
  • d'autres méthodes "apply" peuvent être ajoutées à l'"object" Person (mais sans redéfinir celle qui a été autogénérée par le constructeur de base):
    object Person {
            def apply(f:String) = new Person(f,"Doe")
    }


Le pattern matching


Le but premier d'une case class est de permettre le pattern matching. Voici quelques exemples qui sont purement informatifs. Ils n'ont pas vraiment d'intérêt et auraient pu être écrits autrement.

/**
* Méthode qui renvoie "prenom nom"
*/
def toFullName(p:Person):String = p match {
    case Person(f,s) => f+" "+s
}

/**
* Méthode qui renvoie true si le nom de famille de la personne est "Doe"
* false dans les autres cas
*/
def inconnu(p:Person):Boolean = p match {
    case Person(_,"Doe") => true
    case _ => false
}

/**
* Méthode qui écrit dans la console "Hello prenom"
*/
def hello(p:Person):Unit = p match {
    case Person(f,_) => println("Hello "+f)
}

/**
* Méthode qui écrit dans la console "Bonjour nom"
*/
def bonjour(p:Person):Unit = p match {
    case Person(_,l) => println("Bonjour "+l)
}

Classes pas case

Si vous n'avez jamais vu ça auparavant, vous vous dites, la langue pendante: "Whouaaa, génial... Mieux que Java....". Ok, ok... Je suis d'accord. Mais ce n'était pas mon objectif.

Le pattern matching, c'est génial. Mais comment faire avec une classe qui n'est pas case? Par exemple, parce que vous utilisez une classe que vous n'avez pas écrite vous-même.

Prenons une autre classe (honnêtement... elle ressemble fort à la première):

class Personne(val firstname:String,val lastname:String)

et son objet compagnon avec une méthode factory:

object Personne{
 def apply(f:String,l:String) = new Personne(f,l)
}

La classe Personne n'est pas équivalente à la classe Person du début. Il y manque beaucoup de fonctionnalités que l'on peut bien sûr implémenter "manuellement". Je pense à equals, hashCode, toString.

La classe Personne ne permet cependant pas de faire du pattern matching. Bien sûr, on peut l'utiliser dans des match/case. Par exemple:

def isPersonne(o:Any) = o match {
    case Personne => true
    case _ => false
}

Mais ceci ne fonctionnera pas:

def toFullName(p:Personne):String = p match {
    case Personne(f,s) => f+" "+s
}

Les extractors

Ce qu'il lui faut, à cette classe, c'est un extractor dont l'objectif est de transformer un objet Personne en un tuple (firstname,lastname).

C'est assez simple. Il suffit de définir une méthode unapply dans l'objet compagnon Personne, méthode qui prend une référence de type Personne en paramètre et renvoie une Option[(String,String)]. La voici ajoutée à notre objet compagnon:

object Personne{
 def apply(f:String,l:String) = new Personne(f,l)
 def unapply(p:Personne):Option[(String,String)] = Some(p.firstname,p.lastname)
}

Et maintenant, le pattern matching fonctionne.

Toujours plus loin


Les extractors peuvent aussi être utilisés pour valider un format ou pour extraire des informations sans pour autant construire un objet.

Considérez la deuxième méthode unapply ci-dessous:

object Personne{
 def apply(f:String,l:String) = new Personne(f,l)

 def unapply(p:Personne):Option[(String,String)] = Some(p.firstname,p.lastname)

 def unapply(fullname:String):Option[(String,String)] = {
    val parts = fullname.split(" ")
    if(parts.length == 2) Some(parts(0),parts(1)) else None
 }
}

Elle permet de faire ceci:

def testName(name:String) = name match {
       case Personne(p,n) => println("Prénom: "+p+", nom: "+n)
       case _ => println("Pas complet")
}

testName("Fabrice Claes")
testName("Toto")

Ce qui affiche:

Prénom: Fabrice, nom: Claes
Pas complet

Dans ce cas-ci, on extrait de la String le nom et le prénom (du moins si on l'écrit sous la forme prénom+nom). Aucune instance de Personne n'a été créée.

Le retour d'une Option n'est pas nécessaire si on ne souhaite pas extraire des informations. Ceci aurait fonctionné (différemment):

def unapply(fullname:String):Boolean = {
    val parts = fullname.split(" ")
    parts.length == 2
}

et testName serait devenue

def testName(name:String) = name match {
       case Personne() => println("Potentiellement complet")
       case _ => println("Pas complet")
}

Avec la première version (celle qui retourne un Option), on pourrait également concevoir une factory basée sur une String complexe.

def apply(fullName:String) = unapply(fullName) match {
    case Some((f,l)) => new Personne(f,l)
    case _ => null
}

Ce permettrait d'écrire:

val p1 = Personne("Toto Lehéros")

Par contre, avec

val p2 = Personne("Raoul")

p2 serait null.

Pas mal, hein? J'aurai certainement l'occasion d'y revenir.

Aucun commentaire:

Enregistrer un commentaire