Journal port des for_comprehension de scala en ruby

Posté par  (site web personnel) . Licence CC By‑SA.
20
4
jan.
2025

Sommaire

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:

val list = List(1, 2, 3)
val result = list.map(x => x * 2)
// result: List(2, 4, 6)

Avec un Option:

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:

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)):

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:

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:

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:

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:

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]] 
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:

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 :

# 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,

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.

Envoyer un commentaire

Suivre le flux des commentaires

Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.