Ruby is Magic – Das Blog

Jetzt mit 20% mehr Inhalt

Episode #7: Closures

| Comments

Herzlich willkommen zu der ersten siebten Ausgabe von ‘Ruby is Magic’. Für alle die am 18.01. bei der colognerb nicht live mit dabei sein konnten, oder einfach noch mal lesen wollen was passiert ist, kommt hier nun die schriftliche Zusammenfassung mit Codestücken und Ponies.

Inspiriert von einem hervorragenden Beitrag von Paul Cantrell, haben wir uns in dieser Folge einem Ruby-Thema gewidmet, dem jeder Ruby-Entwickler regelmäßig begegnet: Blöcke und Closures. Wie bei vielem, dass wir regelmäßig verwenden lohnt sich aber auch hier ein Blick hinter das Offensichtliche. Und vielleicht entdeckt man etwas, dass einem bisher so nicht klar war. Wir hoffen also euch neue Erkenntnisse über Closures in Ruby nahe zu bringen – wir hatten jedenfalls welche bei den Vorbereitungen der Show.

Wie so oft lassen sich Eigenschaften von Programmiersprachen am besten in der Sprache selbst verdeutlichen. Bevor wir euch aber mit einem Code-Fragment nach dem anderen bewerfen, wollen wir kurz eine Definition von Closures liefern. Sie erhebt dabei keinerlei Anspruch auf Vollständigkeit, wir brauchen nur eine gemeinsame Grundlage.

Wir verstehen Closures als Codeblöcke, die …

  • zugewiesen und herumgereicht werden können.
  • jederzeit und von jedem aufgerufen werden können.
  • Zugriff auf Variablen im ursprünglich definierenden Kontext haben.

Diese Definition ist nicht exklusiv gültig für Ruby, sondern lässt sich auch auf andere Sprachen anwenden. In Ruby zeichnen sich Closures darüber hinaus dadurch aus, dass sie auf die Methode call() antworten. Gleichzeitig ist natürlich nicht jedes Objekt, dass auf call() antworten kann auch ein Closure.

Die Sieben ist unsere Zahl

Wirklich bemerkenswert – und durchaus auch verwirrend – ist die Fülle an Konstrukten, die in Ruby Closures oder Closure-ähnliches beschreiben: Es gibt derer sieben an der Zahl. Auf den ersten Blick sieht das nach einer ganzen Menge aus, es stellt sich aber heraus, dass es am Ende dann doch wieder einfacher ist als gedacht.

Jetzt tun wir aber mal endlich Butter bei die Fische und schauen uns ein wenig Ruby-Code an. Den Anfang machen einfach Blöcke. Solche wie sie wohl jeder von uns täglich im Zusammenhang mit Iteratoren verwendet:

Einfache Blöcke
1
2
3
4
5
@my_bag = Bag.new

%w(MacBook Headphones iPhone Camera).each do |item|
  @my_bag.add item
end

Der an each übergebene Codeblock wird für jedes Element in dem Array aufgerufen und hat dabei Zugriff auf den umgebenen Kontext. Dadurch können wir mit @my_bag interagieren. Damit ist eines unserer definierenden Kriterien schon mal erfüllt.

Angenommen wir benötigen nun eine Methode each_item auf der Klasse Bag die es uns ermöglicht über alles zu iterieren, was jemand dort hineingesteckt hat. Eine mögliche Implementierung könnte dann wie folgt aussehen:

‘each_item’-Implementierung
1
2
3
4
5
6
7
8
9
class Bag
  def each_item
    @items.each do |item|
      yield item
    end
  end
end

@my_bag.each_item { |item| puts item }

Bemerkenswert an dieser Stelle ist, dass wir in der Methodensignatur keine Parameter definiert haben. Blöcke können also einfach implizit an Methoden weitergereicht werden. Gleichzeitig lässt sich der Block dadurch auch nur implizit über das Schlüsselwort yield referenzieren. Objekte, die man yield übergibt werden als Parameter an den Block weiter gereicht. Falls man yield verwendet und keinen Block übergeben hat, beschwert sich Ruby mit der Meldung LocalJumpError: no block given (yield). Ob ein Block übergeben wurde oder nicht, lässt sich mit der Methode block_given? prüfen.

Blöcke fangen den definierenden Kontext zwar ein, können ihn jedoch (zum Glück) nicht erweitern. So erhält man dann auch bei der Ausführung des nachfolgenden Codes einen Fehler.

Blöcke erweitern den definierenden Kontext nicht
1
2
3
4
5
6
7
8
9
%w(MacBook Headphones iPhone Camera).each do |item|
  item_count ||= 0
  @my_bag.add item
  item_count += 1
end

puts "#{item_count} item(s) have been added to my bag."

# => NameError: undefined local variable or method ‘item_count’

Insgesamt sind Blöcke also schon mal ein guter Schritt in die richtige Richtung. Allerdings fehlen noch zwei Eigenschaften:

  • Wir können den Block nicht beliebig herumreichen (nur einmal beim Methodenaufruf) und
  • Wir können den Block nicht jederzeit aufrufen.

Beiden Problemen lässt sich damit begegnen, in dem man den Block explizit macht, also in die Methodensignatur aufnimmt:

Expliziter Block
1
2
3
4
5
6
7
8
9
10
11
12
class Bag
  def initialize(items)
    @items = items
  end

  def each_item(&block)
    @items.each(&block)
  end
end

bag = Bag.new %w(MacBook Headphones Keys)
bag.each_item { |item| puts item }

Als Nutzer der Methode ändert sich für mich dadurch übrigens überhaupt nichts. Ich erhalte nicht einmal einen ArgumentError wenn ich den Block nicht an die Methode übergebe – selbst nicht bei dem Aufruf von @items.each(&block), denn each liefert einfach eine Enumerator-Instanz zurück, wenn kein Block mitgegeben wird.

Als letzten Schritt müssen wir nun nur noch dafür sorgen, dass der Block gespeichert werden kann und somit jederzeit aufrufbar wird. Wir erreichen das, indem wir uns einfach das &-Prefix des Blockparameters sparen, was ein Synonym für Proc.new(&block) ist.

Speichern des Blocks
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Bag
  def initialize(items)
    @items = items
  end

  def define_iterator(&block)
    @iterator = block # Proc.new &block
  end

  def iterate!
    @items.each(&@iterator)
  end
end

bag = Bag.new(%w(MacBook Headphones Keys))
bag.define_iterator { |item| puts item }
bag.iterate!

Damit haben wir also endlich unser Closure. Wir hoffen es hat euch mal wieder gefallen und bis zum nächsten Mal … Moment! … Das kann es doch noch nicht gewesen sein. Richtig – da kommt noch was!

“Echte” Closures?!

Wie Eingangs bereits erwähnt, hält Ruby für uns mehrere Möglichkeiten bereit ein Closure zu konstruieren:

  • &block ohne & ist wie Proc.new(&block)
  • proc {}
  • lambda {}

Die Variante mit proc ist übrigens noch keinem von uns bewusst in der freien Wildbahn aufgefallen und es handelt sich dabei auch lediglich um ein Alias auf lambda. Was irgendwie nicht besonders intuitiv ist. Das hat sich dann wohl auch das Ruby-Core-Team gedacht und ab Ruby 1.9 ist proc sinniger Weise ein Alias auf Proc.new.

Dennoch stellt sich die Frage, ob Unterschiede zwischen den lambda und Proc.new Varianten existieren? Und wenn ja, welche? Die Antwort ist zu erwarten gewesen: Ja, es gibt Unterschiede. Schauen wir uns einmal den Kontrollfluss und die Prüfung der Arität an.

Kontrollfluss

Wenn man innerhalb eines Closures oder Code-Blocks return verwenden möchte führt dies unter Umständen nicht immer zu dem gewünschten Resultat:

Unterschiedliches Verhalten bei ‘return’
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def call_closure(closure)
  puts "Calling a closure"
  result = closure.call
  puts "The result of the call was: #{result}"
end

call_closure(Proc.new { return "All hell breaks loose!" })

# => Calling a closure
# => LocalJumpError: unexpected return

call_closure(lambda { return "Everypony calm down. All is good." })

# => Calling a closure
# => The result of the call was: ‘Everypony calm down. All is good.’

Während sich also ein return in einem durch Proc.new erzeugten Closure immer auf den ursprünglich definierenden Kontext bezieht (in diesem Beispiel ist das main), springt es in einem lambda-Closure einfach aus dem lambda zurück. Bei der Verwendung der lambda-Methode erhält man also eine “true closure”, welches sich in puncto Kontrollfluss wie eine Methode verhält. In beiden Fällen erhält man übrigens eine Instanz der Proc-Klasse.

Aritätsprüfung

Closures antworten nicht nur auf call(), sondern auch auf die Nachricht arity():

Returns the number of arguments that would not be ignored. If the block
is declared to take no arguments, returns 0. If the block is known to
take exactly n arguments, returns n. If the block has optional
arguments, return -n-1, where n is the number of mandatory arguments. A
proc with no argument declarations is the same a block declaring || as
its arguments.

Es liegt nahe, dass die Arität beim Aufruf des Closures überprüft wird. Falls die Anzahl der Parameter dann nicht mit der erwarteten übereinstimmt wird ein ArgumentError geworfen. Dieses Verhalten tritt allerdings nur auf, wenn die lambda-Methode verwendet wurde. “Closures” durch Proc.new überprüfen die Arität nicht:

Aritätsprüfung: Proc.new
1
2
3
4
5
6
7
proc_closure = Proc.new do |arg1, arg2|
  puts "arg1: #{arg1}; arg2: #{arg2}"
end

proc_closure.call(1,2,3,4) # arg1: 1; arg2: 2
proc_closure.call(1,2) # arg1: 1; arg2: 2
proc_closure.call(1) # arg1: 1; arg2: nil
Aritätsprüfung: lambda
1
2
3
4
5
6
7
lambda_closure = lambda do |arg1, arg2|
  puts "arg1: #{arg1}; arg2: #{arg2}"
end

lambda_closure.call(1,2,3,4) # ArgumentError
lambda_closure.call(1,2) # arg1: 1; arg2: 2
lambda_closure.call(1) # ArgumentError

Aber genauso magisch wie Ruby ist, gibt ist auch immer mal wieder Momente der Verwirrung. So auch in diesem Fall: Closures aus lambda prüfen die Arität nur in Ruby 1.9 so wie erwartet. Und damit kommen wir dann auch zum Fun Fact dieser Ausgabe:

In Ruby 1.8 gilt für Closures durch lambda:

  • lambda {||}.arity != lambda {}.arity
  • lambda {}.arity == -1
  • Die Anzahl der Argumente wird nicht geprüft wenn die Arität 1 ist

In Ruby 1.9 ist die Welt aber wie gesagt in Ordnung, zumindest in dieser Hinsicht: lambda {}.arity == lambda {||}.arity == 0

Dirk hat ein nettes Beispiel für Closures in seinem Blog vor einiger Zeit geschrieben: Roll your own lazy loading collection beschreibt, wie man eine lazy collection bauen kann.

One More Thing

Fassen wir einmal zusammen, welche Möglichkeiten von Closures wir bisher besprochen haben:

  • block (implizit übergeben)
  • block (explizit übergeben)
  • block (explizit übergeben und zu Proc)
  • Proc.new
  • proc (Alias auf lambda / Proc.new)
  • lambda

Aber fehlt da nicht noch etwas? JA!

Methoden sind ebenfalls Closures! Das sie den umgebenen Kontext einfangen ist offensichtlich. Sie überprüfen weiterhin die Arität und ein return springt nur aus der Methode heraus.

Um eine Methode referenzierbar zu machen, benötigt man die method()-Methode. Mit method() erhält man eine Method-Instanz, welches die Methode repräsentiert.

Methoden als Closures
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Bag
  def each_item(closure)
    @items.each { |item| closure.call(item) }
  end
end

class Iterator
  def self.print_element(element)
    puts "Element: #{element}"
  end
end

my_bag = Bag.new(%w(MacBook Headphones iPad Gloves))

my_bag.each_item lambda { |item| puts "Element: #{item}" }

my_bag.each_item Iterator.method(:print_element)

Als Beispiel für Methoden als Closures haben wird eine Implementierung von Python-style Decorators gezeigt. Den Code dazu findet ihr hier und ein paar weitere Details wird es in einem separatem Post geben.

Präsentation

Und hier noch die Präsentation. Für das volle audio-visuelle Erlebnis müsst ihr allerdings zur @colognerb kommen ;-)

Comments