URL: https://linuxfr.org/users/yazgoo/journaux/port-des-for_comprehension-de-scala-en-ruby Title: port des for_comprehension de scala en ruby Authors: yazgoo Date: 2025年01月04日T01:48:33+01:00 License: CC By-SA Tags: scala, ruby et programmation_fonctionnelle Score: 20 ## contexte: `map` et `flatMap` Scala est un langage fonctionnel, et donc les operateurs `map` et `flatMap` sont très utilisés. Pour rappel: - `map` perment d'appliquer une fonction à chaque élément d'une structure de données. - `flatMap` permet d'appliquer une fonction qui retourne une structure de données à chaque élément d'une structure de données, et de "déplier" le résultat. Cela s'applique à des liste, mais pas seulement. ### exemples de `map` Avec une liste: ```scala val list = List(1, 2, 3) val result = list.map(x => x * 2) // result: List(2, 4, 6) ``` Avec un `Option`: ```scala val option = Some(1) val result = option.map(x => x * 2) // result: Some(2) val option = None val result = option.map(x => x * 2) // result: None ``` ### exemples de `flatMap` Avec une liste: ```scala val list = List(1, 2, 3) val result = list.flatMap(x => List(x, x * 2)) // result: List(1, 2, 2, 4, 3, 6) ``` Avec un `Either` (un type qui peut contenir soit une valeur (auquel cas, c'est un Right) ou une erreur (Left)): ```scala val either = Right(1) val result = either.flatMap(x => Right(x * 2)) // result: Right(2) val either = Left("error") val result = either.flatMap(x => Right(x * 2)) // result: Left("error") ``` ### contexte: programmes fonctionnels En scala, on chaine souvent `map`, `flatMap` et `filter` pour écrire des programmes fonctionnels. Par exemple, supposons que l'on veuille calculer tous les tuples de 0 à 9 dont la somme est égale à 10: ```scala val result = (0 to 9).flatMap { x => (0 to 9).map { y => (x, y) } }.filter { case (x, y) => x + y == 10 } // result: Vector((1,9), (2,8), (3,7), (4,6), (5,5), (6,4), (7,3), (8,2), (9,1)) ``` ### for comprehensions On voit que cela devient rapidement difficile à lire. Pour cela, Scala propose les `for comprehensions`, qui permettent d'écrire du code plus lisible. Avec une for comprehension, le code précédent devient: ```scala val result = for { x <- 0 to 9 y <- 0 to 9 if x + y == 10 } yield (x, y) // result: Vector((1,9), (2,8), (3,7), (4,6), (5,5), (6,4), (7,3), (8,2), (9,1)) ``` - `<-` dénote un générateur (c'est à dire une structure de données sur laquelle on va itérer) - `if` (une guarde) permet de filtrer les éléments. - `yield` permet de retourner le résultat (ici, un tuple) (en pratique, un `map`). En pratique `<-` est un raccourci pour `flatMap`. Il est aussi possible de faire des `map` avec l'opérateur `=`. Par exemple si on veut multiplier chaque élément d'une liste par 2, et associer ces éléments à leur double, on peut écrire: ```scala val result = for { x <- List(1, 2, 3) y = x * 2 } yield (x, y) // result: List((1,2), (2,4), (3,6)) ``` ## port en ruby Voulant voir si il est possible d'avoir une syntaxe ruby, j'ai cherché à reproduire les `for comprehensions`: Voilà la syntaxe que je propose, en reprenant les exemples précédents, le but étant de se rapprocher le plus possible de la syntaxe scala tout en évitant de trop dénaturer le ruby: ```ruby result = for_c( gen(:x) { (0..9) }, gen(:y, if_c { x + y == 10 } ) { (0..9) }, ) { [x, y] } # result: [[1, 9], [2, 8], [3, 7], [4, 6], [5, 5], [6, 4], [7, 3], [8, 2], [9, 1]] ``` ```ruby result = for_c( gen(:x) { [1, 2, 3] }, let(:y) { x * 2 } ) { [x, y] } # result: [[1, 2], [2, 4], [3, 6]] ``` En réimplémentant `Either` de scala, on peut aussi faire de la gestion d'erreur tout en gardant le programme principal très lisible et en évitant d'utiliser des exceptions: ```ruby def validate_username(username) if username.length < 8 Either.left("Username is too short (8 characters minimum)") else Either.right(username.downcase) end end def validate_date_of_birth(dob, today) if dob.next_year(18)> today Either.left("User must be 18 years old or older") else Either.right(dob) end end def validate_user(username, dob) for_c( gen(:username) { validate_username(username) }, gen(:dob) { validate_date_of_birth(dob, Date.today) }, ) { User.new(username, dob) } end puts validate_user("Bob", Date.new(2000, 12, 1)) # Left("Username is too short (8 characters minimum)") puts validate_user("Bob_12345", Date.new(1960, 3, 5)) # Right(Bob_12345 (1960年03月05日)) ``` ## implémentation de `for_c` Le gros de l'implémentation ci-dessous : ```ruby # permet de rendre disponible les variables déclarées à chaque étape dans les blocs de code def with_binding_from_hash(variables, &block) obj = Object.new variables.each do |key, value| obj.instance_variable_set("@#{key}", value) obj.define_singleton_method(key) { instance_variable_get("@#{key}") } end obj.instance_eval(&block) end # interprétèe les blocs de code en prenant en compte les guards def for_comprehension_with_guard(head, env) with_binding_from_hash(env, &head).map do |value| new_env = env.clone.merge({ head.name => value }) [ new_env, value ] end.select do |new_env, value| head.guard.nil? || with_binding_from_hash(new_env, &head.guard) end end # méthode principale def for_comprehension(ranges, env = {}, &block) if ranges.empty? with_binding_from_hash(env, &block) else head, *tail = ranges # si on est à la fin de la liste ou que le prochain élément est un let, on fait un map, sinon un flat_map mapping = tail.empty? || tail[0].is_a?(Let) ? :map : :flat_map if head.is_a? Gen # si c'est un générateur, on itère sur les valeurs for_comprehension_with_guard(head, env).send(mapping) do |new_env, value| for_comprehension(tail, new_env, &block) end elsif head.is_a? Let # si c'est un let, on associe la valeur à la variable value = with_binding_from_hash(env, &head) new_env = env.clone.merge({ head.name => value }) for_comprehension(tail, new_env, &block) end end end ``` Pour les curieux, l'implémentation complète de `for_c` est disponible sur [gist.github.com](https://gist.github.com/yazgoo/e897b5d31b0fae7bb0b299c6a1225ecd), ### conclusion Le lecteur jugera de la pertinence d'utiliser cette syntaxe ainsi que de sa lisibilité. Le code n'est pas du tout optimisé, cela reste une expérimentation. Je suis en train de m'amuser à implémenter des effets (IOs) à la Haskell/cats effects/ZIO en ruby, et cette syntaxe simplifiera grandement le code.