Ruby-Programmierung: Metaprogrammierung

Zurück zum Inhaltsverzeichnis.

Metaprogrammierung beschreibt den Vorgang, anstelle direkt den entsprechenden Code zu schreiben, Code zu schreiben der diesen Code generiert. Dieser Vorgang scheint zunächst eine unötige Indirektion darzustellen, ist jedoch nützlich, wenn es sich um oft wiederholten, strukturell gleichen Code handelt. Dieses Buch verwendet den Term Makro für die Benutzung des metaprogrammierten Codes in Anlehnung an die gleiche Terminologie in Lisp-ähnlichen Programmiersprachen.

Symbole überall

Bearbeiten

Sie haben bereits ein Makro verwendet, als es darum ging mehrere Attribute eines Objekts öffentlich zugänglich zu machen. Schauen wir uns zunächst noch einmal die direkte Methode an:

class User
  def name=(name)
    @name = name
  end

  def name
    @name
  end

  def email_address=(email_address)
    @email_address = email_address
  end

  def email_address
    @email_address
  end
end

Die Struktur des Codes zwischen den beiden Instanzvariablen ist sehr ähnlich und bei ein, zwei Instanzvariablen vielleicht noch akzeptabel, darüber hinaus wird es sehr unübersichtlich. Insbesondere wenn dann doch vielleicht eine Settermethode eine Überprüfung durchführt bevor die Speicherung erfolgt, wird dies schnell übersehen.

Im Kapitel über Klassen haben wir stattdessen attr_accessor :user, :email_address verwendet. Dadurch wird der Code deutlich kürzer und wenn jetzt eine Settermethode dasteht, ist offensichtlich, dass etwas an ihr anders ist als an den anderen, da sie sich von der Art der Implementierung offensichtlich unterscheiden. attr_acessor ist ein Makro, das von Ruby bereit gestellt wird. Darüber hinaus ist daran nicht besonderes und Sie können diesen Makro durchaus auch selber implementieren.

Sie fragen sich vielleicht, warum im Kapitel über Strings Symbole vorgestellt wurden und bisher sehr wenig Verwendung fanden. Bei der Metaprogrammierung kommt man um Symbole nicht herrum. Stellen Sie sich einmal die Frage, warum attr_accessor name, email_address in Ruby nicht funktionieren kann. Das hat damit zu tun, dass Ruby die Argumente einer Methode sofort ausgewertet werden. Im Gegensatz zu lazy evaluation versucht Ruby also bei der Zeile attr_accessor name eine Variable oder Methode mit dem Namen name zu finden und den Inhalt oder das Ergebnis als Argument an attr_accessor zu übergeben. Falls die Variable/Methode im Scope vorliegt, handelt es sich dabei wahrscheinlich nicht um das erwünschte Verhalten, da wir die Zeichenkette name übergeben wollen. Für Zeichenketten haben wir zwei Möglichkeiten in unserem Ruby-Reporoire: Strings und Symbole. Da Strings bei jeder Konstante neu erschaffen werden müssen sind diese langsamer als Symbole und wir verwenden Symbole in unserem Code.

Going Meta

Bearbeiten

Metaprogrammierung lässt sich in Ruby auf verschiedene Arten durchführen, die bestimmte Bereiche abdecken und manche Vor- und Nachteile gegenüber einander haben. Dabei betrachen wir die vier Gruppen:

  • define_method, Class.new und Module.new. Diese Methoden definieren tatsächlich neue Konstruke, je nach Methode eben Methoden, Klassen oder Module.
  • eval, instance_eval, class_eval und module_eval führen Rubycode aus, der sich in einem String befindet. Dadurch ist es Möglich durch Manipulation des Strings Änderungen am Rubycode zu erreichen. Die Unterschiede beziehen sich auf den Kontext der jeweiligen Ausführung.
  • instance_exec erlaubt die Änderung des Kontextes eines Blockes. Während normalerweise Variablen und Methoden innerhalb des Blockes dort interpretiert werden, wo der Block geschrieben ist, wird es so möglich Methoden aufzurufen, die an anderer Stelle implementiert sind.
  • method_missing und const_missing. Diese häufig in Verbindung mit den anderen Gruppen verwendeten Methoden erlauben es auf einen Fehlen von Code zu reagieren, dadurch ist es möglich Code ony-the-fly zu definieren oder nachzuladen.

Meta-Definieren

Bearbeiten

Als Beispiel soll hier eine Onlineapplikation herhalten. In ihr gibt es User, die sich angemeldet haben, Member, die bestätigt wurden und Admins, die besondere Rechte haben. Die Modellierung dieser drei unterschiedlichen Klassen von Personen kann durch drei Klassen erfolgen, die sich darin unterscheiden, dass zum Beispiel User#admin? falsch ist, während Admin#admin? wahr ist. Da Admins auch auf die normalen Funktionen der Webseite zurückgreifen können ist Admin#member? auch wahr.

class Class
  def define_right_predicates(right)
    rights = [:user, :member, :admin]
    predicates = rights.map { |right| "#{ right }?" }

    predicates.each do |pred|
      define_method(pred) do
        rights.index(right) >= predicates.index(pred)
      end
    end
  end
end

class User
  define_right_predicates :user
end

class Member
  define_right_predicates :member
end

class Admin
  define_right_predicates :admin
end

Sie sehen, dass das Interface sehr schick ist und sogar unabhängig von den tatsächlichen Klassen, wenn Sie also eine andere Klasse einführen und die die gleichen Rechte haben soll, wie ein User, dann können sie das tun mit define_right_predicates :user in ihrer Klasse.

Dagegen steht, dass die Implementierung mitunter schwierig zu verstehen, weil man sich dauerhaft damit konfrontiert sieht, dass man die Variablen im Kopf selber einsetzen muss, um zu erfahren welcher Code jetzt schließlich dasteht.

Meta-Ausführen

Bearbeiten

Ein einfaches Beispiel für das Ausführen eines Strings ist:

eval %Q(
  def hello
    puts "Hello"
  end
)

hello

Das heißt eval nimmt einen String und führt ihn im Kontext des aktuellen Programms aus, so lassen sich Methoden und Klassen definieren und manipulieren. Die anderen eval-methoden aus der Gruppe funktionieren ähnlich und ändern lediglich den Kontext indem der Code ausgeführt wird.

Ein einfaches Beispiel mit Manipulation des Codes mag so aussehen:

def define_outputer(phrase)
  eval %Q(
    def #{ phrase.downcase }
      puts "#{ phrase }"
    end
  )
end

define_outputer("hi")
hi

Das Problem wird hierbei schon relativ offensichtlich, da es sich bei dem String nicht automatisch um Rubycode handelt, es möglicherweise schwierig Fehler bei falscher Interpolation zu finden. Die erste Version des Codes ergab den Fehler: (eval):3: stack level too deep (SystemStackError). Es wird also eine Methode rekursiv unendlich oft aufgerufen. Das Problem ist, dass normalerweise uns Ruby darüber informieren würde in welcher Methode und Zeile das Problem auftritt. Das tut es diesmal auch, denn das Problem tritt in eval auf, darüber hinaus kann es uns keine Informationen liefern. Der Fehler war übrigens die fehlenden Anführungszeichen in puts "#{ phrase }" dadurch wurde die Methode tatsächlich rekursiv definiert und aufgerufen ohne Abbruch.

Zum Schluss noch folgende Anmerkung: Es gibt die Eigenart eval als evil zu bezeichnen, daran ist auch vieles richtig. eval erlaubt einen unglaublich viel und man muss sich stark beschränken um sich nicht mit unglaublich viel Macht in den Fuß zu schießen. Insbesondere zwei Richtlinien sind wichtig bei der Verwendung von eval: Niemals unsichere Usereingaben ausführen. Das ist eine typische Sicherheitslücke, die in Variationen und auch versteckt immer mal wieder auftritt. Im einfachsten Fall sieht ihr Code Beispielweise so aus:

input = gets
eval input

Wenn Sie das einem vertrauenswürdigen User übergeben kann das nützlich sein, aber auch jeder Angreifer hat damit die Möglichkeit auf ihr System voll zuzugreifen.

Zweitens sollte man darauf verzichten zur Ausführungzeit eval zu benutzen. Ruby weiß nicht was in einem String drin steckt, bis es ihn zusammengebaut hat und kann damit Optimierungen nicht durchführen, das Programm ist dadurch insgesamt langsamer. Im Falle wie oben, dass innerhalb von eval Methoden oder Klassen definiert werden handelt es sich dann um eine längere Startzeit, die möglicherweise nicht ins Gewicht fällt (je nach Art der Applikation). Sollten sie jedoch eval zur Ausführungszeit des Programms benutzen, so tritt das Problem bei jedem Aufruf von eval auf.

Meta-Blöcke

Bearbeiten

Blöcke haben Sie kennengelernt als eine einfache Variante sogenannte Closures an Methoden zu übergeben. Closures sind Codeteile, die sich ihre Umgebung merken und darauf zurückgreifen können. Dadurch können sie zum Beispiel auf eine lokale Variable in einem Block zugreifen, obwohl die in der Methode, die den Block ausführt nicht unbedingt existiert.

def exec(&block)
  block.call()
end

x = 2
block = proc { x * x }

puts exec(&block)

x = 3
puts exec(&block)

In diesem Fall bekommt der Block also die Information, dass er eine lokale Variable mit dem Namen x benutzen soll, um sie zu quadrieren. Wenn sich der Wert dieser Variable ändert, dann ändert sich auch das Ergebnis bei der Ausführung des Blockes. Falls zur Zeit des Erstellen des Blockes anstatt einer lokalen Variable eine Methode mit dem gleichen Namen vorgelegen hat, so wird stattdessen diese Methode benutzt, selbst wenn später eine lokale Variable definiert wird.

Mit instance_exec ist es nun Möglich den Kontext eines Blockes zu Ändern. Im wesentlichen ändert es das Ergebnis von self, das heißt das Ziel von Methodenaufrufen. Das führt insbesondere zu Problemen, falls der Block lokale Variablen benutzt, dann ändert sich nicht das Verhalten des Blockes trotz der verwendung von instance_exec.

def exec(&block)
  block.call()
end

class Executor
  def x
    3
  end

  def exec(&block)
    instance_exec(&block)
  end

  def call(&block)
    block.call()
  end
end

def x
  2
end

block = proc { x * x }

puts exec(&block)
puts Executor.new.exec(&block)
puts Executor.new.call(&block)

Hier ändert sich also das Objekt, wo die Methode x aufgerufen wird. Zunächst wird der Block erstellt, da sich in diesem Kontext keine lokale Variable mit dem Namen x findet, wird eine Methode angenommen.

Dann wird der Block mit diesem Kontext aufgerufen, die Methode x liefert als Ergbnis 2 und das Quadrat ist 4. Wenn wir jedoch den Kontext ändern, dann wird die Methode des Executor-Objekts aufrufen, die als Ergebnis 3 liefert und das Quadrat ist 9. Ohne eine Änderung des Ziels bleibt alles normal und die Methode call ändert den Kontext nicht, sodass wieder 4 ausgeben wird.

Meta-Fehlen

Bearbeiten

Angenommen Sie wollen ein Objekt schreiben, dass sehr flexibles Interface erlaubt. Sie könnten sämtliche Methoden implementieren, die das Objekt haben soll, aber eventuell sind viele davon ähnlich in ihrem Verhalten. Im folgenden soll als beispiel ein Begrüßungsobjekt dienen. Es erlaubt mit Greeter.new.hello_Wikibooks eine entsprechende Grußmeldung auszugeben. Es ist aber nicht nur auf Wikibooks beschränkt, denn vielleicht wollen Sie ja auch ihre Tante grüßen.

class Greeter
  def method_missing(meth, *args, &block)
    match = meth.to_s.match(/(hello)_(.+)/)
    if match[1] == "hello"
      puts "Hello #{ match[2] }!"
    else
      super
    end
  end
end

greeter = Greeter.new
greeter.hello_World
greeter.hello_Wikibooks
greeter.hello

Nett. Dieses Beispiel ist natürlich etwas weit hergeholt, man sollte natürlich in solch einem Fall lieber ein Interface bereitstellen wie greeter.hello("World"), aber method_missing hat seine Anwendungen.

Man sollte zwei Dinge beachten, wenn man method_missing benutzt. Erstens muss man jeden Fall, den man nicht selber behandelt möchte mit super weiterleiten, um nicht die Fehlerbehandlung von Ruby zu unterbrechen. Zweitens lügt das oben gezeigte Objekt über sich selbst. Probieren Sie greeter.respond_to? :hello_World aus und das Objekt sagt, dass es keine solche Methode kenne. Das kann zu Problemen führen, daher sollte man parallel zu method_missing die Methode respond_to? überschreiben.

class Greeter
  def respond_to?(meth)
    match = meth.to_s.match(/(hello)_(.+)/)
    if match[1] == "hello"
      true
    else
      super
    end
  end
end

Eine XML-DSL

Bearbeiten

XML ist ein Format, das erlaubt eine Baumstruktur abzubilden. Sie könnten das mit einem Hash implementieren, der als Keys die XML-Tags besitzt und als Elemente Arrays von weiteren Hashes. Das Beispiel hier beschreibt eine DSL (Domain Specific Language) also eine Sprache, die speziell für das schreiben von XML entwickelt ist. Dabei handelt es sich natürlich nur um Ruby, das durch Metaprogrammierung elegant so gestalltet wird, dass es sich anfühlt wie eine andere Sprache.

xml {
  users {
    user {
      name "Hans"
      age 18
    }
    user {
      name "Charles"
      age 21
    }
  }
}

Auch wenn der folgende Code noch einige Fehler aufweist, die ihn für eine generelle Anwendung unnützlich machen, so erfüllt er doch den Zweck, den obigen Rubycode in XML umzuwandeln. Ein fehlendes Feature ist beispielsweise die Möglichkeit Attribute an die XML-Tags zu übergeben, so dass zum Beispiel user class: "Admin" {...} umgesetzt wird in <user class="Admin">...</user>.

class XML
  class Node
    def initialize(name, content = nil, &block)
      @name = name
      @content = content
      @nodes = []
      instance_exec(&block) if block
    end

    def method_missing(method, *args, &block)
      @nodes << Node.new(method, args.first, &block)
    end

    def to_str
      to_s
    end

    def to_s
      if @content
        "<#{ @name }>#{ @content }</#{ @name }>"
      else
        "<#{ @name }>#{ @nodes.join }</#{ @name }>"
      end
    end
  end

  def initialize(&block)
    @nodes = []
    instance_exec(&block)
  end

  def method_missing(method, *args, &block)
    @nodes << Node.new(method, &block)
  end

  def to_s
    @nodes.join()
  end
end

def xml(&block)
  XML.new(&block).to_s
end