Évaluons votre projet

Principe du parapluie

Publié le 15 janvier 2021 par François Vantomme | architecture - ruby

Cet article est publié sous licence CC BY-NC-SA

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 :

  1. Inventer un monde dans lequel modéliser notre question ;
  2. Résoudre le problème dans ce monde ;
  3. 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 :

  1. Ouvrez votre parapluie ;
  2. Traversez la rue ;
  3. 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-monad 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éthode maybe, 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


L’équipe Synbioz.
Libres d’être ensemble.