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
BearbeitenSie 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
BearbeitenMetaprogrammierung 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
undModule.new
. Diese Methoden definieren tatsächlich neue Konstruke, je nach Methode eben Methoden, Klassen oder Module.eval
,instance_eval
,class_eval
undmodule_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
undconst_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
BearbeitenAls 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
BearbeitenEin 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
BearbeitenBlö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
BearbeitenAngenommen 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
BearbeitenXML 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