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, unmap
).
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.