Un petit exemple de code Scala pour vous montrer un exemple tiré du monde réel. Imaginons que vous deviez afficher une page Web avec les détails d’un hôtel, les détails d’un vol d’avion et enfin des informations de réservation pour une voiture. Nous utilisons différents services pour récupérer les données.
Pour débuter, nous allons imaginer que nous récupérons chaque information l’une après l’autre. D’abord les détails d’un trajet en avion, puis un hôtel et enfin la description d’une location de voiture.
Voyons d’abord une implémentation classique en Java. J’ai volontairement simplifié le code, nous allons partir de cet exemple pour aller vers Scala. Notez simplement que nous utilisons le résultat de l’appel du service Airfare pour chercher la voiture. En effet, notre spécification dit qu’il faut effectuer une demande de réservation de voiture de location en utilisant l’aéroport d’arrivé. Logique : si j’arrive à Orly, je n’ai pas envie de courir jusqu’à Charles-de-Gaulle.
// Action Play 1.x Java pour afficher les détails d'un vol public static void showTravel(Date outbound, Date inbound, String origin, String destination) { Airfare airfare = Airfare.findFor(origin, destination, outbound, inbound); Hotel hotel = Hotel.findHotelFor(origin, destination, outbound, inbound); Car car = Car.findCarFor(Date outbound, airfare.arrivalAirport, airfare.arrivalHour); render(airfare, hotel, car) }
La spécification dit ensuite que les services Airfare et Hotel retournent null lorsqu’il n’y a pas de résultats pour les dates demandées.
Ajoutons donc un peu de vérifications, en restant simple :
// Action Play 1.x Java pour afficher les détails d'un vol public static void showTravel(Date outbound, Date inbound, String origin, String destination) { Airfare airfare = Airfare.findFor(origin, destination, outbound, inbound); if(airfare != null) { Hotel hotel = Hotel.findHotelFor(origin, destination, outbound, inbound); if(hotel != null) { Car car = Car.findCarFor(Date outbound, airfare.arrivalAirport, airfare.arrivalHour); // car peut être null, ce sera à la vue de le gérer render(airfare, hotel, car) } else { renderTemplate("Application/noHotel.html"); } } else { renderTemplate("Application/noAirfare.html"); } }
Une autre règle de gestion à intégrer s’ajoute à votre cahier des charges : si jamais le voyageur arrive après 23h00, alors ne pas lui proposer de voiture, mais afficher cependant le voyage. En effet, certaines agences de location seront fermées si vous arrivez après une certaine heure. Cela change selon les aéroports.
// Action Play 1.x Java pour afficher les détails d'un vol public static void showTravel(Date outbound, Date inbound, String origin, String destination) { Airfare airfare = Airfare.findFor(origin, destination, outbound, inbound); if(airfare != null) { Hotel hotel = Hotel.findHotelFor(origin, destination, outbound, inbound); if(hotel != null) { if(airfare.arrivalHour!= null && airfare.arrivalHour > 23) { Car car = Car.findCarFor(Date outbound, airfare.arrivalAirport, airfare.arrivalHour); render(airfare, hotel, car); } else { render(airfare, hotel, null); // la vue se débrouillera avec le null... } } else { renderTemplate("Application/noHotel.html"); } } else { renderTemplate("Application/noAirfare.html"); } }
Pour gérer le cas où une valeur est null, Play 1.x permet d’utiliser notFoundIfNull, ce qui nous permettrait de retirer des paquets de if. Mais restons pour l’instant sur notre exemple.
Bref arrivé à ce point, et je n’ai que 3 petits services, le code commence à refléter la complexité de notre domaine fonctionnel. Ce code commence à sentir l’odeur d’un vestiaire de rugby, il est temps d’aller voir Scala.
Passage du côté Scala
En passant vers Scala, nous allons d’abord reprendre notre spécification à la lettre. Il est dit que nos fonctions doivent retourner null lorsqu’il n’y a pas de résultats pour des critères données. Or null… c’est nul. En Scala nous disposons d’une classe bien pratique, la classe Option. C’est une monade, soit quelque chose qui empaquete notre fonction et qui va nous permettre d’utiliser des fonctions avancées dans quelques instants.
En Scala, la class Option[T] permet d’encapsuler une valeur et de retourner soit un Some(T), soit None lorsque la valeur est nulle. Ainsi voyez le code Scala suivant pour mieux comprendre :
val nicolas:String = "Nicolas" val bob:String = null val maybeNicolas = Option(nicolas) // retourne un Option[String] val maybeBob = Option(bob) // retourne un Option[String] println(maybeNicolas.isDefined) // affiche true println(maybeBob.isDefined) // affiche false println(maybeNicolas) // affiche Some(nicolas) println(maybeBob) // affiche None maybeNicolas.map( s => s.toUpperCase).getOrElse("?") // affiche NICOLAS maybeBob.map( s => s.toUpperCase).getOrElse("?") // affiche ? // Version plus simple maybeNicolas.map(_.toUpperCase).getOrElse("?") // affiche NICOLAS maybeBob.map(_.toUpperCase).getOrElse("?") // affiche ?
Au lieu de gérer le cas où une valeur est nulle, nous l’empaquetons dans une Option typée, ce qui va nous permettre ensuite de chaîner et d’appliquer d’autres fonctions. En étant simpliste, la monade c’est la mort du if/then/else. Bon, ne déclenchez pas votre énergie dans les commentaires, je cherche une approche simple pour vous parler d’un concept fonctionnel assez compliqué.
C’est très pratique, et nous allons revenir à notre exemple pour voir cela en action.
Notre code va se simplifier et devenir de plus en plus puissant. Nous pourrions tout d’abord écrire naïvement le code ci-dessous, afin d’empaqueter notre service :
// Action Play 2 et Scala pour afficher les détails d'un vol def showTravel(outbound:Date, inbound:Date, origin:String, destination:String) = Action { implicit request=> val maybeAirfare = Option(Airfare.findFor(origin, destination, outbound, inbound)) }
En général, si le service lui-même sait s’il doit retourner une valeur ou non, nous adapterons alors la signature de la méthode findFor afin de retourner un Option[Airfare].
object Airfare { def findFor(origin:String, destination:String, outbound:Date, inbound:Date):Option[Airfare] = { // appel de notre service } } // Code de notre application object Application extends Controller { def showTravel(outbound:Date, inbound:Date, origin:String, destination:String) = Action { implicit request=> val maybeAirfare = Airfare.findFor(origin, destination, outbound, inbound) // retourne un Option[Airfare] ... val maybeHotel = Hotel.findHotelFor(origin, destination, outbound, inbound) .. val maybeCar = ??? } }
Nous pourrions ré-écrire le même code très orienté impératif, avec des if/then/else, mais dans ce cas, quel intérêt y aurait-il à faire du Scala ? Alors poussons plus loin.
L’intérêt d’une monade est de permettre d’enchaîner du code si une valeur est définie. Or c’est exactement ce dont nous avons besoin. Nous voulons enchainer nos appels, et aussi utiliser au passage notre objet airfare si celui-ci est défini, en filtrant selon l’heure d’arrivée.
object Application extends Controller { def showTravel(outbound:Date, inbound:Date, origin:String, destination:String) = Action { implicit request=> val maybeAirfare = Airfare.findFor(origin, destination, outbound, inbound) // retourne un Option[Airfare] val maybeHotel = Hotel.findHotelFor(origin, destination, outbound, inbound) // retourne un Option[Hotel] val maybeCar = maybeAirfare.filter(_.arrivalHour < 23) .flatMap{ airfare => Car.findCarFor(outbound, airfare.arrivalAirport, airfare.arrivalHour) // retourne Option[Car] } Ok(views.html.Application.showTravel(maybeAirfare, maybeHotel, maybeCar) ) } }
La fonction filter ne s’appliquera que si maybeAirfare est défini, qu’il y a donc un vol pour notre plage d’horaire. Sinon, maybeCar vaudra None (= pas de voiture). La fonction map permet de mapper un objet Car si airfare est défini. Le type de maybeCar est Option[Car].
La clé pour suivre ici c’est de lire la ligne 9, fonction par fonction. Je reprends en détaillant :
1) maybeAirfare est « peut-être » un prix d’avion ou pas. C’est une Option de Airfaire, noté Option[Airfare] (ou Option<Airfare> en Java)
2) la fonction filter retourne cette Option si celle-ci est définie, et applique le prédicat spécifié
3) le prédicat est noté « _.arrivalHour < 23 », c’est un raccourci pour « yaUnAvion => yaUnAvion.arrivalHour < 23 ».
4) la fonction flatMap permet de transformer notre Option[Airfare] vers un Option[Card] si et seulement si l’Option est définie (il y a bien un avion qui arrive avant 23 heures). La fonction flatMap retourne le résultat de l’exécution de la fonction f, qui prend un Airfare en argument et qui retourne un Option[Car].
5) le code de la ligne 11 n’est donc exécuté que lorsqu’il y a un avion qui arrive avant 23h
Le résultat de cette enchainement est un Option[Car], c’est à dire : peut-être une voiture, sinon rien.
For-Comprehension
Pour aller jusqu’au bout, prenons le temps de parler des for-comprehensions en Scala. Si nous ne pouvions utiliser que des fonctions d’ordre supérieur comme map, flatMap ou filter, cela resterait parfois assez verbeux. C’est pour cette raison qu’il est possible de combiner plus simplement nos services grâce à ce que l’on appelle les « for-comprehension ». Tiens je pourrais placer un jeu de mot comme « effort de compréhension » mais je ne le fais pas…
Nous allons voir Either, une autre monade intéressante. Si Option ne peut retourner que l’un des deux états, à savoir « Some(qqchose) » ou « None », Either est intéressant car vous pouvez explicitement décrire le cas nominal et vos cas d’erreur. Either vous permet de décrire un choix, d’expliquer que votre fonction peut retourner un résultat d’un type ou d’un autre. Ou que votre fonction peut retourner une erreur, ou un cas nominal.
Dans notre exemple, il serait plus simple de grouper les erreurs fonctionnelles.
Nous pouvons :
– ne pas trouver de vol pour les paramètres
– ne pas trouver d’hôtel
Ce qui serait bien, c’est une petite structure sympa pour stocker nos erreurs, sans casser notre traitement. Cela tombe bien, ça existe. En combinant cela avec une for-comprehension, nous allons voir que le code va être plus simple.
Je vais définir une valeur result qui sera un Either[String, (Airfare, Hotel, Option[Car])].
Un Either représente un choix : notre panier ne peut pas être rempli à la fois avec une String et avec un tuple (Airfare, Hotel,Option[Car]). Vous pouvez avoir un panier avec une case à gauche, un panier avec une case à droite, mais pas les deux. Either représente cependant bien les 2 états que nous attendons. Soit nous aurons un message d’erreur car l’avion ou l’hotel n’existent pas, soit nous aurons notre résultat.
Cependant nous savons que la voiture est optionnelle, car en arrivant après 23h, il ne sera pas possible de récupérer une voiture.
Voyons d’abord le code.
def showTravel(outbound:Date, inbound:Date, origin:String, destination:String) = Action { implicit request => val result = ( for ( airfare < - Airfare.findFor(origin, destination, outbound, inbound).toRight("No flight found").right; zhotel <- Hotel.findHotelFor(origin, destination, outbound, inbound).toRight("No hotel found").right ) yield (airfare, zhotel, Some(airfare).filter(_.arrivalHour < 23).flatMap(air2=>Car.findCarFor(outbound, air2.arrivalAirport, air2.arrivalHour)) ) )
Cela se lit de la façon suivante :
1) Essaye de charger un airfare, mais si tu n’en trouves pas, alors retourne un message « No flight found » et arrête-toi.
2) Essaye ensuite de charger un Hotel, mais encore une fois, si findHotelFor retourne un None, alors empaquete un message et termine.
3) Si nous avons un airfare et un hotel, alors filtre selon l’heure d’arriver, puis va demander à findCarFor de me trouver une voiture. Encore une fois, cet appel retourne un Option[Car], nous ne sommes pas certain d’avoir une voiture
Ce qui est « beau » dans ce code, c’est qu’il exprime exactement les cas métiers que l’on me demande d’implémenter.
Nous nous retrouvons avec un Either[String, (Airfare, Hotel, Option[Car])].
Nous pouvons nous amuser à tester s’il s’agit d’un Left ou d’un Right. Attention, le for-comprehension en Scala est loin d’être simple à comprendre, j’en conviens. C’est en fait une autre écriture de plusieurs flatMap et map, mais nous resterons sur ce cas aujourd’hui.
Notre Either donc, qu’allons-nous en faire ?
Nous allons utiliser la fonction fold(), qui permet d’exécuter une fonction ou une autre, selon que le Either spécifié est un Left ou un Right.
Dans le cas où nous avons eu une erreur, alors nous afficherons un message d’erreur et nous retournerons avec Play2 une page HTML avec un code 404 NotFound. Dans le cas où nous avons notre tuple, et bien il ne reste plus qu’à afficher notre page. En général ici nous ajoutons un match/case, mais j’ai fait le choix de rester sur l’utilisation du tuple. Un concept à la fois 🙂
Le code final sera :
def showTravel(outbound:Date, inbound:Date, origin:String, destination:String) = Action { implicit request => val result = ( for ( airfare < - Airfare.findFor(origin, destination, outbound, inbound).toRight("No flight found").right; zhotel <- Hotel.findHotelFor(origin, destination, outbound, inbound).toRight("No hotel found").right ) yield (airfare, zhotel, Some(airfare).filter(_.arrivalHour < 23).flatMap(air2=>Car.findCarFor(outbound, air2.arrivalAirport, air2.arrivalHour)) ) ) result.fold( errorMsg:String=> { // une belle page d'erreur avec un code 404 NotFound(views.html.Application.notFound(errorMsg)) }, successTuple=>{ // successTuple est notre tuple (Airfare,Hotel,Option[Car]) Ok(views.html.Application.showTravel(successTuple._1, successTuple._2, successTuple._3)) } ) }
Nous venons de voir comment utiliser ce que l’on appelle les monades, à savoir des super-pouvoirs qui permettent de rendre le code plus fonctionnel. Cela vous semblera plus compliqué, mais c’est juste une histoire d’habitude. On prend goût à cette approche, surtout lorsque l’on fait vraiment du code métier. C’est moins verbeux car Scala est plus puissant.
Après je vous sors quelque chose que j’ai compris 2 mois après avoir débuté en Scala, donc restez zen, mais gardez cette histoire de for-comprehension et d’Either.
Je m’explique auprès des développeurs plus à l’aise avec le paradigme fonctionnel : je sais qu’il y a des imprécisions, mais mon objectif ici était d’aller jusqu’au bout de l’exemple, en restant accessible pour quelqu’un qui découvre Scala.
Conclusion
Dans cet article, je vous ai montré une autre façon d’exprimer un problème fonctionnel simple. En passant du paradigme impératif vers du fonctionnel, cela donne aussi de nouvelles possibilités, particulièrement avec Play2.
Notez par exemple que l’appel au service Airfare est indépendant de l’appel au service Hotel. D’un point de vue fonctionnel, l’ordre d’exécution de ces 2 appels n’a pas d’importance. Or lorsque l’on dépend de services métiers dont les temps de réponses sont assez lents, vous voulez exécuter ces 2 appels en parallele. Un fork/join si vous connaissez. Avec Scala, cela devient facile et trivial.
Encore plus intéressant : je souhaite arrêter ma recherche d’hôtel si je n’ai pas de vols disponibles. Ou inversement. Et bien grâce à Scala, et plus précisément grâce au moteur Asynchrone de Play2, cela devient possible. Vous pouvez facilement exprimer ce type de problèmes et ensuite les résoudre avec du « beau » code.
C’est une chose que Java ne peut pas faire en restant simple. Entendez bien : oui vous pouvez aussi le faire en Java, mais nous touchons aux limites actuelles du langage. Il manque les lambdas expressions, ainsi que le principe de base de la programmation fonctionnelle. C’est là que Scala (qui veut dire Scalable Language) devient intéressant et répond à de vrais cas, de la vraie vie, de vrais projets.
Et c’est l’une des raisons qui fait que je trouve ce langage intéressant à connaître.
Quelques explications sur les monades
L’une des meilleurs explications sur les monades sur StackOverflow
La définition de Wikipedia
http://james-iry.blogspot.fr/2007/09/monads-are-elephants-part-1.html
Un très bon
http://blog.demotera.com/published/2010-10-28-Les-Monades-Dans-La-Programmation-Fonctionnelle.html
http://web.cecs.pdx.edu/~antoy/Courses/TPFLP/lectures/MONADS/Noel/research/monads.html
Merci pour cet article clair et concis qui constitue pour moi une très bonne entrée en matière !
Hello!
Merci pour cette reflexion.
Pour completer tes dires sur les monades et Option: il est possible d’utiliser un mecanisme proche avec Guava et du Java classique.
http://www.javabeat.net/2012/06/handlingavoiding-nulls-in-java-using-guava-versus-scala/
@Eric en effet, le type Optional de Guava permet d’aller plus loin, mais tu ne peux pas enchaîner ensuite l’appel de fonction avec map ou flatMap, alors que Scala le fait. La version Guava/Java n’est donc pas une Monade. Une Monade est l’association de 3 caractéristiques
– un constructeur de type pour pouvoir par exemple construire un Option[Airfare]
– un moyen de retourner un Option[Airfaire] ce qui est implicite en Scala
– enfin la possibilité d’appliquer une autre fonction, le « bind » du monde haskell, flatMap en Scala. Scala supporte aussi map, qui peut être vu comme une fonction dérivée de flatMap
🙂
Bonjour et merci Nicolas pour ce récapitulatif sur les monades scala.
Il m’a permit de comprendre une monade que je n’utilisais pas encore car je n’avais pas encore trouvé de cas d’utilisation, ce que fait ton article.
Pour info : la scaladoc sur Either est assez intéressante à lire : https://github.com/scala/scala/blob/v2.9.2/src/library/scala/Either.scala
Utilises tu d’autres monades en scala, et as tu des priorité || préférences quand à leurs utilisation ?
Ce que tu veux dire, c’est que
[code]
val maybeAirfare = Airfare.findFor(…
[/code]
Comme tu ne précises pas de type, du coup, tu peux bien retourner un nomade alors qu’avec Guava tu aurais écris un truc du genre
[code]
Optional airfaireOptional = …
[/code]
Et en fait avec scala, tu peux utiliser direct le Option alors qu’avec Guava, tu dois faire un isPresent suivi d’un get (ou un or)
@Nicolas: map est fondamental dans la notion de monade. Une monade est avant tout un foncteur, donc implémente map 🙂
Les options monadiques et Java est une question à laquelle je me suis attaqué il y a quelques temps. Clément a raison, le foncteur est fondamental à la monade. Aussi, avec les Optional de Guava (et bientôt de Java 8), il est possible de retrouver des options monadiques. Comment ? Avec un Optional<Optional>. Dans ce cas, oui, la double application de map (ou plutôt de transform dans le cas de Guava) est équivalent à flatMap (ou transformAndConcat dans le cas de Guava). Par contre, c’est moche 🙁
J’ai bien essayé à plusieurs reprises soit de rendre les Optional de Guava monadiques (http://kerflyn.wordpress.com/2011/12/05/from-optional-to-monad-with-guava/), soit de créer mes propres Option monadiques (http://blog.xebia.fr/2012/04/04/monades-java-monstre-cosmique/), mais c’est toujours aussi verbeux. On s’empêtre dans les generics. Bref, de quoi donner mal à la tête.
Une autre solution consiste à utiliser FluentInterface de Guava et d’émuler les Options avec… des collections : None équivaut à une liste vide et Some équivaut à un singleton. C’est un peu perturbant et ça reste assez verbeux sans les lambda expressions de Java 8… Et même avec !
Le meilleur compromis à mon sens serait d’avoir un type Option implémentant Iterable, ce qui permettrait d’avoir un avant goût des for-comprehension à la Scala dans Java.
À noter que pour Java 8, il y a quelques voix qui s’élèvent pour avoir un type Option digne de ce nom (http://mail.openjdk.java.net/pipermail/lambda-dev/2012-September/006050.html , http://mail.openjdk.java.net/pipermail/lambda-dev/2012-October/006130.html). Bref, on est encore loin de Either :/
@François merci pour ton retour, et les articles sont vraiment intéressants. J’encourage les développeurs Java à aussi aller voir ce qui se prépare du côté de Java 8.
Merci pour tout tes exemples d’implémentations en scala qui sont très utiles pour améliorer la qualité de mon code ;-).
Guava permet de transformer aussi l’option en une autre option, c’est juste assez lourd de créer la classe anonyme.
On a aussi l’équivalent de getOrElse avec optional.or(value)
Attention cependant, c’est pas tout a fait la même chose car si votre value est calculée dans une fonction, du genre optional.or( computeDefaultValue ), alors la defaultValue est calculée même si on a pas besoin d’elle!
Pour pallier à ce problème on peut utiliser une classe anonyme Supplier (encore lourd).
De son coté Scala utilise le parameter by-name au lieu de by-value comme ca l’expression est évaluée que si on a besoin d’elle.