Principe du parapluie
Il est possible de penser juste avec des choses qui n’existent pas. À vrai dire, c’est même le propre de l’informatique que de penser des choses qui n’existent pas. C’est-à-dire des choses abstraites.
Je paraphrase ici un extrait du livre « Le théorème du parapluie, ou l’art d’observer le monde dans le bon sens » de Mickaël Launay. Alors bien évidemment, lui nous parle de mathématiques, mais c’est tout à fait transposable à l’informatique et plus précisément au développement logiciel.
Je vous invite aujourd’hui à observer sous un angle différent des situations qui nous sont familières. Nous avons pu constater, dans un précédent article, que Rails n’est pas simple. Ainsi, plutôt que de cacher la complexité sous le tapis du framework, voyons comment s’en sortir élégamment avec un pas de côté.
Principe du parapluie
Ce principe porte le nom d’automorphisme intérieur pour les férus de maths, mais j’ai préféré ici conserver le nom que lui a donné Mickaël Launay dans son livre de vulgarisation. D’autant plus que nous ne rentrerons pas dans les méandres de la théorie des groupes. Loin de moi l’envie de vous perdre dès le quatrième paragraphe ! Et en toute honnêteté, ça dépasse de très loin mes compétences.
Mais ce principe, énoncé de manière concise et en des termes compréhensibles par tous, nous sera très utile pour la suite. Il consiste en trois étapes :
- Inventer un monde dans lequel modéliser notre question ;
- Résoudre le problème dans ce monde ;
- Transférer le résultat dans le monde réel.
Autrement dit, inventons-nous une petite bulle dans laquelle les situations les plus alambiquées sont faciles à résoudre. Pour reprendre la métaphore du parapluie, imaginons que vous souhaitiez traverser la rue sous une pluie battante sans vous mouiller. Voici la procédure :
- Ouvrez votre parapluie ;
- Traversez la rue ;
- Refermez votre parapluie.
Simple, non ? Encore fallait-il avoir eu l’idée du parapluie !
Et si on faisait un essai ?
Prenons un exemple assez commun. Mettons que nous voulions connaitre le nom de la province dans laquelle réside un citoyen.
# @param id [Integer] a citizen identifier
# @return [String] the found state name
def citizen_state(id)
citizen = Citizen.find_by(id: id)
citizen.address.city.state.name
end
Malheureusement, c’est rarement si simple ! Il se peut en effet qu’aucune
province n’ait été renseignée, voire qu’aucune adresse n’ait été renseignée pour
ce citoyen. Autrement dit, n’importe laquelle des méthodes que l’on chaine ici
peut, pour une raison ou une autre, retourner nil
.
Tony Hoare a inventé le
nil
(null pointer) en 1965 ; il l’appelle maintenant son « erreur d’un milliard de dollars », qui a « probablement causé un milliard de dollars de douleur et de dégâts ».
C’est peut-être une erreur, mais Ruby a cette notion en son sein et nous devons
faire avec. Ce genre de situation, où l’on doit tolérer l’éventuelle présence
d’un nil
en retour de méthode, est tellement fréquent que le framework Rails,
à travers ActiveSupport, a très tôt proposé un palliatif sous la forme d’une
méthode nommée try
.
# @param id [Integer] a citizen identifier
# @return [String] the found state name
def citizen_state(id)
citizen = Citizen.find_by(id: id)
citizen.try(:address).try(:city).try(:state).try(:name)
end
Cette approche ne semble pas trop mal, mais nous pouvons cependant lui reprocher
une chose : try
n’est autre qu’un monkey patch assez grossier
sur les classes Object
, NilClass
et Delegator
.
Mais depuis Ruby 2.3, nous avons un nouvel opérateur à disposition, j’ai nommé
le safe navigation operator (&.
). Utiliser un élément du langage, c’est déjà
beaucoup mieux ! Voyons ce que ça donne.
# @param id [Integer] a citizen identifier
# @return [String] the found state name
def citizen_state(id)
citizen = Citizen.find_by(id: id)
citizen&.address&.city&.state&.name
end
Pas mal. Mais nous avons toujours un petit problème. En effet, l’usage de cet
opérateur ne nous garantit pas que la valeur de retour de notre méthode sera
bien une chaine de caractères. On peut se retrouver avec un nil
. Il ne faut
alors pas oublier de fournir une valeur par défaut en solution de repli.
# @param id [Integer] a citizen identifier
# @return [String] the found state name
def citizen_state(id)
citizen = Citizen.find_by(id: id)
citizen&.address&.city&.state&.name || "No state"
end
Bon, maintenant prenons un peu de recul. Il y a quelque chose d’inélégant dans
cette approche. Elle donne l’impression de constamment ouvrir et fermer son
parapluie, à chaque petit pas, pour éviter qu’un nil
ne nous tombe dessus. Ne
pourrions-nous pas créer un petit monde où la présence éventuelle d’un nil
est
gérée élégamment, et ne récupérer le résultat — c’est-à-dire refermer notre
parapluie — qu’en toute fin ?
Peut-être…
Pour nous élever vers ce monde merveilleux où l’absence de valeur significative
nous glisse dessus sans même nous émouvoir, nous aurons besoin d’encapsuler
notre objet citizen
dans une instance de classe que nous nommerons Maybe
.
# @param id [Integer] a citizen identifier
# @return [String] the found state name
def citizen_state(id)
citizen = Citizen.find_by(id: id)
state_name = Maybe(citizen).
maybe(&:address).
maybe(&:city).
maybe(&:state).
maybe(&:name)
state_name.value_or("No state")
end
Ici, Maybe
retournera soit une instance de Some(citizen)
, soit None
.
Toutes deux sont des classes qui héritent de Maybe
. Elles partagent donc le
même comportement. L’appel à la méthode maybe
retournera lui aussi soit une
instance de Some
, soit None
, c’est-à-dire un autre Maybe
. Ainsi, nos
méthodes retournent toutes à présent une structure de données bien maîtrisée.
Bien évidemment, l’implémentation de cette méthode maybe
diffère selon qu’on
l’appelle sur l’une ou l’autre classe. Dans le cas de None
, nous avons affaire
à un singleton qui se retourne lui-même, tout bêtement. Pas de valeur, pas de
traitement.
# Represents an absence of a value, i.e. the value nil.
#
class None < Maybe
@instance = new.freeze
singleton_class.send(:attr_reader, :instance)
# Ignores the input parameter and returns self.
# It exists to keep the interface identical to that of {Some}.
#
def maybe(*)
self
end
end
En ce qui concerne Some
, là c’est un peu différent. Ce que l’on va vouloir
faire c’est appliquer un traitement sur la valeur contenue dans notre instance
de Some
, puis encapsuler le résultat dans une nouvelle instance de Maybe
.
Ainsi, nous nous assurons de toujours retourner une instance de Maybe
, ce qui
nous permettra de chainer les appels.
# Represents a value that is present, i.e. not nil.
#
class Some < Maybe
# Does the same thing as #bind except it also wraps the value
# in an instance of Maybe::Some monad. This allows for easier
# chaining of calls.
#
# @param args [Array<Object>] arguments will be passed through to #bind
# @return [Maybe::Some, Maybe::None] Wrapped result,
# i.e. nil will be mapped to None,
# other values will be wrapped with Some
#
def maybe(*args, &block)
Maybe.coerce(bind(*args, &block))
end
end
Comme on peut le constater, l’appel à bind
s’occupe de faire le traitement,
puis on encapsule le tout à l’aide de Maybe.coerce
. Jetons un œil à ce dernier
pour commencer, vous allez voir, rien de sorcier ici.
# Represents a value which can exist or not, i.e. it could be nil.
#
class Maybe
class << self
# Wraps the given value with into a Maybe object.
#
# @param value [Object] value to be stored in
# @return [Maybe::Some, Maybe::None]
def coerce(value)
if value.nil?
None.instance
else
Some.new(value)
end
end
end
end
OK. Si la valeur qu’on lui passe est nil
, on retourne l’instance de notre
singleton None
, sinon on crée une nouvelle instance de Some
avec ladite
valeur en argument.
Et bind
alors ? Là ça peut paraitre un peu complexe à la lecture, mais tout ce
que ça fait c’est appliquer un block ayant notre valeur en argument, ou appeler
la méthode call
sur l’objet qu’on lui aurait donné en paramètre. Voici
quelques exemples pour clarifier le propos :
Some(5).bind(&:succ) # === 5.succ
=> 6
Some(5).bind { |i| i.succ } # === 5.succ
=> 6
Some(5).bind(->(i) { i.succ }) # === proc { |i| i.succ }.call(5)
=> 6
plusplus = 1.method("+")
=> #<Method: Integer#+(_)>
Some(5).bind(plusplus) # === plusplus.call(5)
=> 6
Some(5).bind
# NoMethodError: undefined method `call' for nil:NilClass
Refermer le parapluie
Nous avons ouvert notre parapluie en nous installant dans le monde réconfortant
de Maybe
où nous pouvons chainer nos appels sans être constamment sur la
défensive au cas où un nil
nous tomberait sur le coin du nez. Seulement
maintenant, il est temps de redescendre, de fermer notre parapluie et de
retourner le tant attendu nom de la province de notre citoyen. Pour cela, nous
allons faire appel à la méthode value_or
qui, comme vous vous y attendez, aura
une implémentation différente selon qu’on l’appelle sur une instance de Some
ou sur None
.
class Some < Maybe
# Returns value. It exists to keep the interface identical to that of {None}
#
# @return [Object]
def value_or(_val = nil)
@value
end
end
class None < Maybe
# Returns the passed value
#
# @return [Object]
def value_or(val = nil)
if block_given?
yield
else
val
end
end
end
Dans le cas de Some
on retournera la valeur que contient celui-ci, alors que
pour None
on retournera le résultat d’exécution du bloc, ou la valeur passée
en paramètre à la méthode value_or
.
Résumons-nous. Qu’avons-nous fait ? Nous avons enveloppé (ou décoré) notre objet
initial avec Maybe
. Et ce faisant, nous sommes en capacité de chainer de
multiples appels, et certains de récupérer une valeur, quelle qu’elle soit,
enveloppée à son tour dans un Maybe
. Quand on a finalement besoin de la
valeur, on la découvre en ouvrant l’enveloppe.
Maybe(citizen).maybe(&:address).maybe(&:city).maybe(&:state).maybe(&:name).value_or("No state")
Il est évident que, quoique l’approche soit élégante — fini le monkey patch
sur le moindre objet instancié —, la syntaxe résultante dans notre exemple n’est
aucunement comparable à la lisibilité du safe navigation operator (&.
).
citizen&.address&.city&.state&.name || "No state"
Alors quoi ? C’est intéressant, mais inutile ? Eh bien non, pas si inutile que
ça ! Si l’on y regarde de plus près, nous n’avons utilisé ici qu’un sucre
syntaxique de notre fameuse structure Maybe
. En effet, la méthode maybe
fait
deux choses : elle applique (ou non) un bloc sur la valeur que contient notre
instance de Maybe
et retourne une nouvelle instance de cette même classe dont
la valeur est le résultat de cette opération. Ce qui se cache derrière maybe
,
c’est la méthode bind
que nous avons survolée un peu plus tôt. Là où le safe
navigation operator nous cantonne à l’appel d’une simple méthode de l’objet qui
le précède, bind
nous offre toute la puissance des blocs Ruby !
Imaginons que nous ayons besoin d’associer une nouvelle adresse à notre citoyen
car celui-ci vient de déménager. Et supposons que nous ayons deux méthodes
find_citizen
et find_address
qui nous retournent des instances de Maybe
.
Nous pourrions alors écrire ceci, sans nous soucier du fait que l’on ait ou non
trouvé un citoyen et une adresse correspondant aux références passées.
def move(params)
find_citizen(params[:citizen_id]).bind do |citizen|
find_address(params[:address_id]).bind do |address|
Some(citizen.update(address_id: address.id))
end
end
end
private
def find_citizen(ref)
Maybe(Citizen.find(ref))
end
def find_address(ref)
Maybe(Address.find(ref))
end
Et pour remettre une couche de sucre syntaxique, et éviter la pyramid of doom que l’on voit poindre, on peut utiliser la do notation :
def move(params)
citizen = yield find_citizen(params[:citizen_id])
address = yield find_address(params[:address_id])
Some(citizen.update(address_id: address.id))
end
À présent, l’intérêt d’une telle approche parait plus évident. Le simple fait
d’être certain de ce qu’on manipule (ici des instances de Maybe
), nous permet
de faire abstraction des cas d’erreur (la présence d’un nil
) à l’écriture
du code métier et d’en déléguer leur gestion à une structure étudiée pour
absorber ce type d’effet de bord. Notre code n’est pas parasité par du code
défensif, et notre intention s’exprime clairement et sans entrave.
Un petit mot sur la do notation. Elle n’a rien de magique et ne sort pas de
mon chapeau. Cette notation est inspirée du langage Haskell
et, en Ruby, est notamment implémentée par la gem dry-monads
dont est fortement inspiré le code présenté dans cet article. Notons également
que le mot clé do
du langage Haskell a laissé place dans cette implémentation
à yield
, mot clé du langage Ruby.
Un zeste de principes
Pour assurer le comportement qu’on lui a observé, Maybe
se doit de respecter
quelques principes :
- être capable de construire une nouvelle instance de
Maybe
à partir d’une valeur quelconque ; - toujours retourner une instance de
Maybe
à l’appel de sa méthodemaybe
, c’est ce que l’on nomme l’identité et qui nous permet de chainer les appels ; - enfin, retourner le même résultat, peu importe la façon dont est emboîté le chaînage des méthodes.
Ce dernier point correspond à ce qu’on nomme l’associativité. Ça veut tout
simplement dire que (a + b) + c
est strictement équivalent à a + (b + c)
.
Pour le dire avec un exemple, les deux appels suivants retourneront le même
résultat.
f = ->(x) { x + 5 }
=> #<Proc:0x00007f03db2530d8: (lambda)>
g = ->(x) { x * 2 }
=> #<Proc:0x00007f03db26a170: (lambda)>
Maybe(10).maybe(f).maybe(g)
=> Some(30)
h = ->(x) { g.(f.(x)) }
=> #<Proc:0x00007f03db2262b8: (lambda)>
Maybe(10).maybe(h)
=> Some(30)
Dans le premier cas, nous appliquons deux méthodes en deux étapes. Dans le second cas, nous composons d’abord les méthodes, puis nous appliquons le résultat. Les retours de ces appels doivent être identiques.
Une myriade de déclinaisons
En respectant ces mêmes principes d’identité et d’associativité, nous sommes en mesure de répondre à d’autres problématiques communes comme la gestion des exceptions ou le traitement des messages asynchrones.
À l’instar du couple Some/None
qui hérite de Maybe
, on peut implémenter une
gestion d’exception via une structure Try
qui se déclinerait en deux variantes
Value/Error
. Son usage ressemblerait à ceci :
Try { 10 / 2 }.fmap { |x| x * 3 }
# => Try::Value(15)
Try { 10 / 0 }.fmap { |x| x * 3 }
# => Try::Error(ZeroDivisionError: divided by 0)
Ici fmap
est tout simplement l’équivalent de la méthode maybe
vue plus haut,
et que nous aurions pu nommer and_then
dans les deux cas si nous avions voulu
être plus générique.
Autre exemple, que l’on a déjà rencontré dans notre précédant article « Rails
n’est pas simple ! », sans pour autant l’avoir expliqué, c’est la structure
de données Result
et ses deux déclinaisons Success/Failure
. Il s’avère en
effet qu’on se repose ici encore sur les mêmes principes qui régissent Maybe
et Try
. Ces structures portent un nom : on les appelle des monades.
Alors quel est le point commun dans les problématiques que tentent d’assigner ces structures de données ? Il s’agit en réalité de conserver la maîtrise sur d’éventuels effets de bord. Ainsi protégé, bien à l’abri sous notre parapluie, il nous est dès lors possible de gérer les imprévus au moment où nous avons décidé de nous en préoccuper, plutôt qu’à l’instant où ils nous tombent dessus.
Ressources
- Refactoring Ruby with monads par Tom Stuart
- DRY Monads, une gem de la collection dry-rb