aurelien-havet-initial
Aurélien Havet
Marvelous Engineer

Les ghost methods de Ruby

24/05/2017

Les ghosts methods, l'une des pierres angulaires de la métaprogrammation en Ruby, expliquées en large et en travers.

Les afficionados de l'univers Ruby en auront sûrement déjà entendu parler, on peut y croiser parfois, au détour d'un chemin par une nuit noire sans Lune, des méthodes fantômes. Je vous propose ici d'explorer ce pan de la métaprogrammation dans cet écosystème. Après une rapide explication de leur fonctionnement et de leur manipulation, nous discuterons brièvement de leurs performances, avant d'aborder quelques-uns de leurs possibles usages.

Une méthode fantôme, kezako ?

Ruby, comme la plupart des langages dits orientés objet, permet de définir des classes avec leur lot de méthodes, c'est-à-dire de fonctions qui pourront être utilisées sur leurs instances lorsqu'on envoie à celles-ci le signal correspondant (l'appel de méthode). Une instance de classe peut donc recevoir un appel de méthode qui aura été définie dans la classe en question ou dans l'une de ses classes parentes (on parle alors d'héritage de classe). De manière générale, en programmation orientée objet, les méthodes que l'on peut appeler sur une instance se limitent à celles qui ont été explicitement définies.

Mais en Ruby, il est également possible d'appeler sur des instances de classes des méthodes qui n'ont pas été définies, et ça c'est cool. On appelle ces méthodes des méthodes fantômes.

La mécanique est somme toute assez simple à comprendre. Lorsqu'on appelle sur une instance une méthode, Ruby opère un lookup, une recherche de cette méthode, dans la classe de l'instance, puis dans ses classes parentes, jusqu'à trouver la méthode correspondante. Si cette méthode n'est pas trouvée, Ruby appelle alors sur cette instance la méthode :method_missing. Cette méthode est définie dans la classe BasicObject, dont hérite l'ensemble des objets définis en Ruby, en lui passant par paramètre des infos pertinentes : le nom de la méthode et ses paramètres. Par défaut, cette méthode lève une exception NoMethodError. Mais par la magie de l'héritage, libre à nous de réimplémenter cette méthode dans notre classe afin de réagir différemment lors de l'appel d'une méthode qui n'aura pas été définie. Une méthode dont le comportement aura été défini dans :method_missing est alors appelée une méthode fantôme.

Un exemple basique

Imaginons une classe Hello, une classe sympa dont les instances peuvent vous dire bonjour (ça ne sert à rien, mais c'est sympa), depuis sa méthode :greet :

class Hello

  def greet(someone)
    "Hello #{someone}!"
  end
end

Hello.new.greet("dude")    # => "Hello dude!"

Si on veut pouvoir saluer beaucoup de monde d'un coup et avec un peu moins de caractères, on peut lui ajouter une méthode :world :

class Hello

  def greet(someone)
    "Hello #{someone}!"
  end

  def world
    greet("world")
  end
end

Hello.new.world    # => "Hello world!"

Supposons maintenant qu'on veut pouvoir de la même manière saluer n'importe qui, mais de manière plus individuelle. Une idée ? Je vous le donne en mille, on utilise les méthodes fantômes :

class Hello

  def greet(someone)
    "Hello #{someone}!"
  end

  def world
    greet("world")
  end

  def method_missing(method_name, *args)
    greet(method_name)
  end
end

Hello.new.ghost      # => "Hello ghost!"

Comment savoir si ma méthode fantôme peut être appelée ?

En Ruby, pour s'assurer qu'il est possible d'appeler une méthode sur un objet, il est d'usage d'appeler sur ce même objet la méthode :respond_to?, héritée de la classe Object. Voyons si nos méthodes :world et :ghost de la classe Hello existent :

Hello.new.respond_to? :world      # => true
Hello.new.respond_to? :ghost      # => false

La méthode :world existe bien, jusqu'ici pas de surprise, mais :ghost semble n'est apparemment pas une méthode qu'il est possible d'appeler sur une instance de la classe Hello. Mais comme dans tout langage dit objet, il est possible de surcharger cette méthode :respond_to? dans la classe Hello :

class Hello

  ...

  def respond_to?(method_name, include_all=false)
    true
  end
end

Ici, l'implémentation de :method_missing permet d'appeler n'importe quelle méthode sur toute instance de la classe Hello. Donc :respond_to? renvoie logiquement true en toutes circonstances. Ainsi :

Hello.new.respond_to? :world      # => true
Hello.new.respond_to? :ghost      # => true

Pour autant, l'implémentation de la méthode :respond_to? n'est pas encore tout à fait la bonne manière de faire. En effet, si l'on souhaite récupérer sur une instance l'objet méthode correspondant à une méthode fantôme, en utilisant la méthode :method de la classe Object, nous avons un problème :

Hello.new.method :world    # => #<Method: Hello#world>
Hello.new.method :ghost    # NameError: undefined method `ghost' for class `Hello'

En effet, Ruby cherche alors dans les méthodes explicitement définies celle voulue. Comme l'explique très clairement Marc-André Lafortune dans son bref article sur le sujet, Ruby 1.9.2 a introduit une nouvelle méthode :respond_to_missing? sur la classe Object afin de répondre proprement à cette problématique. Cette méthode est également appelée par la méthode :respond_to? lorsque celle-ci ne trouve pas de méthode définie correspondante.

class Hello

  ...

  def respond_to_missing?(method_name, include_all)
    true
  end
end

Hello.new.respond_to? :ghost  # => true
Hello.new.method :ghost       # => #<Method: Hello#ghost>

Voilà qui est bien mieux !

Un exemple plus utile

Considérons une instance de Hash :

h = Hash[:a,1,:b,2]       # => {:a=>1, :b=>2}

Pour récupérer la valeur associée à la clé :a, on utilise la syntaxe h[:a]. Imaginons maintenant que nous aimerions pouvoir récupérer cette même valeur en appelant sur h la méthode :a, soit h.a, comme en Javascript :

h.a                 # NoMethodError: undefined method `a' for {:a=>1, :b=>2}:Hash
h.respond_to?(:a)   # => false

Sans surprise, ça ne marche pas. Mais grâce aux méthodes fantômes, il est très facile d'implémenter une sous-classe de Hash avec cette fonctionnalité :

class SuperHash < Hash
  def method_missing(method_name, *args)
    if self.key?(method_name)
      self[method_name]
    else
      super
    end
  end

  def respond_to_missing?(method_name, include_all)
    self.key?(method_name)
  end
end

h = SuperHash[:a,1,:b,2]   # => {:a=>1, :b=>2}
h.a                        # => 1
h.respond_to?(:a)          # => true
h.c                        # NoMethodError: undefined method `c' for {:a=>1, :b=>2}:SuperHash
h.respond_to?(:c)          # => false

On constate au passage que notre implémentation de :respond_to_missing? permet à :respond_to? de renvoyer un résultat parfaitement cohérent.

Discutons les performances

Bon, les méthodes fantômes, c'est sympa, sexy et intensément expressif, mais est-ce performant ? Pour le savoir, faisons un rapide benchmark pour comparer le coût d'un appel de méthode conventionnelle et de celui d'une méthode fantôme :

require "benchmark"

hello = Hello.new
n = 10000000

Benchmark.bm(7) do |x|
  x.report("world:") { n.times { hello.world } }
  x.report("ghost:") { n.times { hello.ghost } }
end

# user     system      total        real
# world:    2.380000   0.010000   2.390000 (  2.415793)
# ghost:    4.120000   0.020000   4.140000 (  4.175610)

Le résultat est sans appel : c'est long, beaucoup plus long qu'un appel de méthode qui a explicitement été définie. Moralité de l'histoire : si votre problématique est d'implémenter une infinité de méthodes possibles pour une classe, foncez sur les méthodes fantômes, mais si vous ne pouvez pas vous permettre de perdre en performances, pensez à une autre manière d'arriver à vos fins.

Les méthodes fantômes dans Ruby

On retrouve l'utilisation des méthodes fantômes dans certaines classes built-in de Ruby.

Peut-être connaissez-vous la classe Struct qui vous permet de générer dynamiquement des classes avec une liste d'attributs, comme le montre cet exemple tiré de la documentation officielle :

Customer = Struct.new(:name, :address) do
  def greeting
    "Hello #{name}!"
  end
end

Customer.new("Dave", "123 Main").greeting  # => "Hello Dave!"

Et bien sa petite soeur OpenStruct vous permet d'instancier des structures de données similaires à des Hash, et dont les attributs seront définis à la volée en appelant une méthode du même nom, comme le montre cet exemple, également tiré de la documentation officielle :

require 'ostruct'

person = OpenStruct.new
person.name    = "John Smith"
person.age     = 70
person.pension = 300

puts person.name     # => "John Smith"
puts person.age      # => 70
puts person.address  # => nil

Grâce à l'utilisation des méthodes fantômes, il est ainsi possible de définir des objets au comportement similaire de ceux de Javascript :

require 'ostruct'

class MyHash < OpenStruct; end

h = MyHash.new
h.mykey = 'myvalue'
h.mykey             # => "myvalue"

Un cas d'application du monde réel

Pour être franc avec vous, dans ma vie de développeur Ruby et Rails, je n'ai pas souvent eu l'occasion d'avoir recours aux méthodes fantômes. Comme nous l'avons vu, le choix de cette solution permet de gagner en expressivité, mais le tradeoff sur la performance n'est pas à négliger. Donc on évitera ce genre d'implémentation dans tout code qui sera souvent solliciter dans notre application.

Néanmoins, j'ai été amené un jour à implémenter, dans une application Ruby on Rails, des rôles utilisateurs paramétrables pour différentes organisations. Il fallait pouvoir leur associer des fonctionnalités de manière dynamique. Ces associations étaient définies par des relations en base de données, existantes ou non, entre les différents rôles et les fonctionnalités de l'application.

Lorsqu'il a fallu concevoir l'interface de configuration de ces rôles, je me suis dit : "Un tableau à 2 entrées, mes rôles d'un côté, mes features de l'autre, et des checkboxes pour définir l'association rôle/feature, ça me paraît pas mal en terme d'UI." J'ai donc voulu utiliser le helper check_box de manière à implémenter un formulaire vite fait bien fait dans ma vue :

= form_for(@organization, url: custom_roles_update_path(@organization), method: 'post') do |f|

  table
    tr
      th
        | Feature
      - @organization.roles.each do |role|
        th = role.label

    - Feature.all.each do |feature|
      tr
        td = feature.label
        - @organization.roles.each do |role|
          td = f.check_box "role_#{role.label}_has_feature_#{feature.label}"

Et bim ! Me voilà avec un magnifique tableau à 2 entrées qui me permet de très facilement configurer l'association de mes fonctionnalités aux rôles de mon organisation, le tout en une douzaine de lignes de codes, plutôt cool. Ma problématique devient alors de réussir à gérer les appels du helper check_box sur mon @organization, en commençant par comprendre comment opère celui-ci.

Que fais le helper check_box sous son capot ? Il va utiliser 2 méthodes basées sur le premier argument passé en paramètre (ici "role_#{role.label}_has_feature_#{feature.label}") :

  • un getter qui va récupérer la valeur courante (un booléen) de l'attribut concerné sur mon organisation afin de connaître le statut à afficher pour cette check box ;

  • un setter qui va permettre de mettre à jour la valeur de l'attribut concerné lors de la soumission du formulaire.

Tout ça est bien gentil, mais concrètement, mon organisation n'a pas les attributs concernés, et les appels relatifs à ces check boxes ne trouvent pas les méthodes voulues (mais jusqu'ici, tout va bien). Pour autant, il n'est pas possible de créer ces méthodes, car elles correspondent à des valeurs (les rôles) dynamiques, c'est-à-dire existantes en base de données. Même une génération de ces méthodes à la volée, en utilisant define_method, à partir de la liste de rôles existants en base de données pour mon organisation, ne me permet pas de m'en tirer, car il est possible de créer de nouveaux rôles à tout moment dans mon application.

Et c'est ici que les méthodes fantômes me sauvent la mise. Chaque helper check_box utilisant des méthodes qui n'existent pas sur mon organisation, c'est finalement la méthode method_missing de celle-ci qui est appelée. En la redéfinissant de manière à vérifier que le nom de la méthode appelée correspond au pattern attendu ("role_#{role.label}_has_feature_#{feature.label}"), et à en extraire les noms du rôle et de la fonctionnalité concernée, on arrive très facilement à arriver à nos fins :

def method_missing(method_name, *args)

  # getters
  matcher = /^role_(?<role>.*)_has_feature_(?<uf>[^=]*)$/.match(method_name)
  return getter(find_feature_and_role(matcher), method_name, args) if matcher

  # setters
  matcher = /^role_(?<role>.*)_has_feature_(?<uf>.*)=$/.match(method_name)
  return setter(find_feature_and_role(matcher), method_name, args) if matcher

  super(method_name, args)
end

On arrive donc à facilement identifier si l'un de nos getters ou setters possiblement utilisé par le helper check_box a été appelé. On extrait les valeurs qui vont bien depuis notre matcher via une méthode privée find_feature_and_role qui vérifie au passage que ces valeurs sont cohérentes avec nos données en base. On délègue ensuite tout ça aux méthodes privées getter ou setter, selon le cas, qui finissent le boulot. Et on n'oublie pas d'appeler super pour faire remonter l'appel à method_missing (et à priori lever une exception NoMethodError), si la méthode ne correspond pas à un possible getter ou setter.

On profite ainsi de la puissance du helper check_box dans notre vue, et à bidouiller la classe concernée pour pouvoir utiliser ce dernier comme voulu, le tout en quelques lignes. Alors, elle est pas belle la vie ?

On se sera évidemment posé ici la question de la performance. Mais aucun problème, car il s'agit d'une interface de configuration qui ne sera utilisée que tous les 36 du mois.

Alors, on met de la méthode fantôme de partout, ou pas ?

Évidemment, vous l'avez sûrement déjà compris, on se gardera bien d'utiliser ce procédé en toutes circonstances, car le tradeoff sur la performance n'est vraiment pas négligeable. De plus, un abus d'emploi de méthodes fantômes peut également conduire à un effet boîte noire, où il devient difficile de comprendre qui fait quoi dans le lookup des différentes méthodes missing_method induit par l'héritage. Mais dans certains cas, il peut se montrer réellement salvateur, et nous sortir de situations potentiellement inextricables.

Si vous souhaitez en découvrir davantage sur la métaprogrammation dans l'univers Ruby, je ne saurais que trop vous conseiller la lecture de Metaprogramming Ruby 2, à mon sens l'ouvrage de référence sur le sujet.