tl;dr: Jouons avec un peu d’instrospection pour décorer de manière dynamique n’importe quel objet.

SimpleDelegator, c’est bien mais…

Pour rappel, la class SimpleDelegator de ruby est bien pratique pour modifier de manière non intrusive le comportement d’une classe:

#! ruby
class FooBar
  def bar() puts "Bar" end
end

require 'delegate'
class FooBarDelegator < SimpleDelegator
  def bar
    puts "Calling #{__getobj__.class}.bar"
    __getobj__.bar
  end
end

foo = FooBarDelegator.new(FooBar.new)

foo.bar # Should print: 'Calling Foo.bar' and then 'Bar'

Bien qu’assez pratique si l’on ne souhaite surcharger que quelques méthodes d’une classe, cette approche est assez vite contraignante si on veut décorer toutes les méthodes d’une class qui en a beaucoup (ou dont on ne connait pas le nombre).

Use case: Je souhaite tracer toute le chemin d’execution d’un appel de n’importe quelle méthode d’une classe.

method_missing est ton ami !

Les objets de type SimpleDelegator héritent directement de la class Delegator, qui utilise le méchanisme de méta-programmation inhérent à ruby pour forwarder tous les appels de fonctions non-surchargés à l’instance décorée.

Une des méthodes utilisées est method_missing:

    # prototype de method_missing:
    def method_missing(name, *args, &block)

Cette méthode d’instance est appellée à chaque fois qu’une instance reçoitun appel de méthode pour lequel il n’existe pas de définition.

Ses paramètres sont:

  • name : Le nom/symbole de la méthode appellée
  • args : Une liste d’arguments passés à l’appel de méthode
  • block : Un block à transmettre à la méthode

Par exemple, si j’appelle foo.bar(42), et que l’instance foo n’a pas de méthode bar, c’est sa méthode method_missing qui va être appellée avec les arguments suivants:

  • name : :bar
  • args : [42]
  • block : pas de block

On peut donc profiter de cette méthode pour, par exemple, afficher tous les appels de méthode d’instance:

#! ruby
  class Foo
    def foo() 'foo' end
  end

  class Bar
    def make_foo
      Foo.new
    end
    def foobar
      make_foo.foo.inspect + 'bar'
    end
    def foobarize(e)
      "foo:#{e.inspect}:bar"
    end
  end

  require 'delegate'

  class Spy < SimpleDelegator
    def method_missing(name, *args, &block)
      parameters = args.map(&:inspect).join ', '
      puts "Calling #{__getobj__.class}.#{name}(#{parameters})"
      super
    end
  end

  watched_bar = Spy.new(Bar.new)
  puts watched_bar.foobar
  puts watched_bar.foobarize(nil)

Ce bout de code va afficher:

Calling Bar.foobar()
foobar
Calling Bar.foobarize(nil)
foo:nil:bar

Les points à noter sont les suivants:

  • L’appel à Bar.make_foo, dans la méthode Bar.foobar n’est pas affiché, car il s’execute sur self (l’instance de Bar) et pas sur l’instance de Spy. En revanche, watched_bar.make_foo déclencherait l’affichage de ‘Calling Bar.make_foo()’.
  • Le mot clef super sans arguments appellent la méthode de la classe parente de Spy (SimpleDelegator) avec les arguments intacts de l’appel initial.

Un peu de propagation ?

On peut très bien imaginer remplacer super par Spy.new(super), pour propager la surveillance aux résultats des méthodes appellées. Mais, watched_bar.make_foo.foo afficherait:

Calling Bar.make_foo()
Calling Foo.foo()
Calling String.to_ary()
Calling String.to_s()
#<Spy:0x007f87790aa7f0>

On remarquera qu’à la place de l’attendu ‘foo’, on a un cryptique ‘#', qui s'explique par le fait que la méthode Object.to_s retourne une chaine composée du nom de la class de l'instance courante (Spy) suivi de son identifiant:

Returns a string representing obj. The default to_s prints the object’s class and an encoding of the object id. As a special case, the top-level object that is the initial execution context of Ruby programs returns “main.”

On peut aisément résoudre ce problème en ajoutant juste une fonction ‘passe-plat’ à Spy, qui va juste faire suivre l’appel de to_s à l’object sous-jacent:

  def to_s() __getobj__.to_s end

Mais pour quoi faire tout ça ?

On peut imaginer bon nombre d’applications pour ce type de pattern, mais comme on ne peut que partiellement maitriser la propagation de la décoration (les appels de méthodes effectués depuis un autre appel de méthode de l’instance ne sont pas décorés), j’ai tendance à privilégier les utilisations non fonctionnelles:

  • Reverse-engineering : La première fois que j’ai vu quelqu’un utiliser une forme de ce pattern, il s’agissait d’effectuer du reverse engineering pour comprendre comment était utilisée une librairie dynamique par une application dont nous n’avions pas le code source (ni la documentation, bien entendu, c’est pour les faibles, comme les tests unitaires). Le développeur en charge de cette étude ne s’est pas démonté, a récupéré toutes les signatures de méthode de la dll, et a créé une dll à l’interface identique, mais qui enregistrait chaque appel de fonction dans un fichier avant de le faire suivre à la dll d’origine. On peut facilement imaginer faire la même chose avec un Spy (comme je le montre un peu plus bas).
  • Monitoring & debugging : Avec la même technique, on peut aussi extraire des métriques d’une application ou la raison d’un crash.

La seule règle à respecter est de ne pas changer les valeurs de retour des méthodes d’instance inspectées, car, comme précisé plus haut (oui, j’insiste lourdement, mais c’est pour ton bien) on ne maitrise pas les sous appels de méthode d’instance.

Un dernier exemple concret pour la route ?

Imaginons qu’on ai le code suivant:

#! ruby
class Foo
  def foo() 'foo' end
  def self.init() @instance = Foo.new end
  def self.instance() @instance end
end

class Bar
  def fetch() Foo end
end

puts Bar.new.fetch.foo.foo

Toi, rubyiste à l’oeil aguerri, tu as déjà remarqué plusieurs erreurs de conception (‘un simplet-gleton ?’), de flow (‘nan, mais elle est où, l’initialization ?’) et de contrôle d’erreur (‘un raise si nil, ça t’arracherait le postrulum ?’). En ce qui me concerne, je suis plutôt flemmard, ces derniers temps, alors je vais juste l’executer:

ruby test.rb
test.rb:12:in `<main>': undefined method `foo' for nil:NilClass (NoMethodError)

Ah. (Notez l’incroyable talent d’acteur)

Créons une class Spy avec propagation !

require 'delegate'

class Spy < SimpleDelegator
  def initialize(obj, propagate:true, depth:2)
    @propagate = propagate
    @depth = depth
    super(obj)
  end
  def to_s() __getobj__.to_s end
  def method_missing(name, *args, &block)
    parameters = args.map(&:inspect).join ', '
    puts(('  '*@depth)+"=> Calling #{__getobj__.inspect}.#{name}(#{parameters})")
    result = super
    puts(('  '*@depth)+" = #{result.inspect} (#{result.class.to_s})")
    if @propagate
      result = Spy.new(result, propagate:true, depth:@depth+2) 
    end
    result
  end
end

def watch(obj, propagate = true)
  Spy.new(obj, propagate:true)
end

Et tant qu’on y est, une petite méthode pour faire plus joli:

def watch(obj, propagate = true)
  Spy.new(obj, propagate:true)
end

Et ensuite, je vais changer:

puts Bar.new.fetch.foo.foo

en:

puts watch(Bar.new).fetch.foo.foo

Et cette fois ci, la sortie de mon script est:

    => Calling #<Bar:0x007fb61b8af8f8>.fetch()
     = Foo (Class)
        => Calling Foo.foo()
         = nil (NilClass)
            => Calling nil.foo()
test.rb:24:in `method_missing'            => Calling nil.inspect()
             = "nil" (String)
: undefined method `foo' for nil:Spy (NoMethodError)
  from test.rb:37:in `<main>'

On en déduit que Foo.foo() a renvoyé nil au lieu d’une instance de la classe Foo. Nous avions donc oublié d’appeller Foo.init. On ajoute Foo.init devant notre appel à fetch:

Foo.init
puts watch(Bar.new).fetch.foo.foo

Et cette fois ci, la sortie du script est:

    => Calling #<Bar:0x007fe5a98ab808>.fetch()
     = Foo (Class)
        => Calling Foo.foo()
         = #<Foo:0x007fe5a98ab830> (Foo)
            => Calling #<Foo:0x007fe5a98ab830>.foo()
             = "foo" (String)
                => Calling "foo".to_ary()
foo

Et hop.