ruby-mine

exploring the mine

Methoden in Ketten legen

von skade am 02.04.2011 (16 Uhr)

Method chaining - also die implementierung einer Methode durch verketten mehrerer anderer ist durch Ruby on Rails zu einer gewissen Popularität gelangt. Während sich Rails hier auf ein einfaches Namenschema und alias verlässt, bietet DataMapper hier eine Technik an, die sich vorzüglich für einen Ausflug in Rubys Objektmodell eignet. Diese Gelegenheit will ich nicht an mir vorüber gehen lassen und die Implementierung namens Chainable hier vorstellen.

Chain

Ein kleiner Usecase

Wozu brauchen wir das Ganze eigentlich? Ruby ist eine Sprache, die besonders viel Wert auf die Komponentenweise konstruktion von Klassen legt. Anders als in Sprachen wie Java ist es durchaus erwünscht, Klassen in einzelnen Modulen zu implementieren und dann zusammen zu "mixen". Was aber, wenn bestimmte Methoden einer Klasse Verhalten gewinnen, wenn bestimmte Module inkludiert sind?

Nehmen wir als kleines Beispiel eine Klasse, die gespeichert werden kann:

1
2
3
4
5
class Person
  def save
    #speicher-fu!
  end
end

Hierzu schreiben wir uns ein Validierungsmodul. Validierung sollen immer ausgeführt werden, bevor die Person gespeichert wird:

1
2
3
4
5
6
7
8
9
10
module Validations
  def save
    validate!
    super
  end
end

class Person
  include Validations
end

Das sieht zwar schön aus, funktioniert aber leider nicht. Schauen wir uns die Ableitungskette von Person an, fällt auch sofort auf, wieso:

1
2
> Person.ancestors
=> [Person, Validations, Object, Kernel, BasicObject] 

Wird ein Modul per include in eine Klasse eingebunden, landet es zwischen der Klasse und dem nächsten Objekt in der Ancestors-Kette. Ruft man also save auf, erwischt man stets die save-Methode von Person.

Wege heraus

Um kurz den Rails-Weg anzureissen, hier deren Variante, dieses Problem zu lösen:

1
2
3
4
5
6
7
8
9
class Person
  def save_with_validations
    validate!
    save_without_validations
  end

  alias :save_without_validations :save
  alias :save :save_with_validations
end

Wir benennen also die alte Methode um, implementieren eine neue, die den neuen Namen der alten Methode verwendet und geben der die neue dann den alten Namen. Diese Lösung hat ihren Charme und durchaus Vorteile (ich kann z.B. immernoch sehr bequem save_with_validations aufrufen), aber auch Nachteile: ich muss diesen Code zum Beispiel immer beim Einmixen eines Moduls ausführen, wenn das Feature an einem Modul hängt. Auch ist die Kette schwer nachzuverfolgen.

Chainable geht einen anderen Weg. Die Implementierung ist kurz:

1
2
3
4
5
6
7
module Chainable
  def chainable(&block)
    mod = Module.new(&block)
    include mod
    mod
  end
end # module Chainable

Die Anwendung ist auch einfach:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Person
  extend Chainable

  chainable do
    def save
      #speicher-fu!
    end
  end
end

module Validations
  def save
    validate!
    super
  end
end

class Person
  include Validations
end

Ich markiere also eine oder mehrere Methode als verkettbar, was mir im Folgenden erlaubt, diese Methode als Anfang einer Methodenkette zu verwenden. Ein Blick auf Hierarchie zeigt, wo die Magie liegt:

1
2
> Person.ancestors
=> [Person, Validations, #<Module:0x000001009bb6e8>, Object, Kernel, BasicObject]

Chainable erstellt also ein anonymes Modul und mixt dieses in die Klasse ein, auf die chainable aufgerufen wurde. Das führt dazu, dass die innerhalb des Block definierten Methoden innerhalb der Hierarchie eine Ebene nach oben rutschen. Damit ist sie sowohl für die Klasse als auch für alle danach eingemixtend Module als super verfügbar, worüber dann die Kette aufgebaut werden kann.

Der Ansatz hat den Vorteil, dass alle Methoden einer Kette denselben Namen tragen und es ein klassisches Modell der Methodenverkettung verwendet: super. Ersteres kann natürlich von Nachteil sein, wenn man Methoden ohne Features nach Namen aufrufen will (z.B. save_without_validations). Allerdings stellt sich hier die Frage, ob Verkettung für solche Fälle die richtige Lösung ist: Welche Methode rufe ich auf, wenn ich auf 2 Features verzichten will?

Ein weiterer Vorteil ist die bessere Inspizierbarkeit: folgende kleine Methode gibt mir die volle Methodenkette für einen Namen aus, zusammen mit dem Ort, wo die Methoden definiert wurden (Achtung, source_location gibt es nur unter 1.9):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def filter_chain(klass, meth)
  chain = klass.ancestors.map do |a| 
    begin
      m = a.instance_method(meth)
      next if m.owner != a
      [m, m.source_location]
    rescue NameError
      next
    end
  end
  chain.compact
end

filter_chain(Person, :save)

Viel Spaß beim in Ketten legen, der volle Text über DataMapper kommt dann auch bald.


Kommentare

  1. Tom Myer schrieb am 12.06.2011 (10 Uhr)

    super erklärung! suche meinen weg momentan durch den source von rails und bin um solche allgemeinen erläuterungen von ruby-techniken daher sehr dankbar!!