Programmierung in Ruby

Der Leitfaden der Pragmatischen Programmierer

Container, Blöcke und Iteratoren



Eine Jukebox mit nur einem Song wird wahrscheinlich nicht sehr populär werden (außer vielleicht in einigen sehr, sehr kargen Bars), also müssen wir schon bald darüber nachdenken, einen Katalog von verfügbaren Songs zu erstellen und eine Liste von Songs, die darauf warten gespielt zu werden. Das sind beides Container: Objekte, die Referenzen auf ein oder mehrere andere Objekte haben.

Beides, der Katalog und die Liste, benötigen einen ähnlichen Satz von Methoden: Einen Song hinzufügen, einen Song entfernen, eine Liste von Songs zurückgeben und so weiter. Die Liste könnte noch andere Aufgaben erfüllen, wie ab und zu Reklame-Einblendungen oder das Verfolgen der Gesamtspielzeit, aber darum kümmern wir uns später. Jetzt scheint es erstmal eine gute Idee zu sein, eine SongList-Klasse als Gattung zu entwickeln, die wir aufteilen können in Kataloge und Listen.

Container

Bevor wir das durchführen, müssen wir uns überlegen, wie wir die Songliste innerhalb eines SongList-Objektes abspeichern sollen. Wir haben drei offensichtliche Möglichkeiten. Wir benutzen den Ruby-Array-Typ, den Ruby-Hash-Typ oder wir bauen uns eine eigene Liste zusammen. Weil wir faul sind sehen wir uns erstmal die Arrys und Hashes an und nehmen eines davon für unsere Klasse.

Arrays

Die Klasse Array enthält eine Liste mit Objekt-Referenzen. Jede Objekt-Referenz besetzt eine Position in dem Array und wird durch eine nicht-negative ganze Zahl identifiziert.

Man kann Arrays erzeugen, indem man Literale benutzt oder indem man explizit ein Array-Objekt erzeugt. Ein literales Array ist einfach eine Liste von Objekten zwischen eckigen Klammern.

a = [ 3.14159, "pie", 99 ]
a.type » Array
a.length » 3
a[0] » 3.14159
a[1] » "pie"
a[2] » 99
a[3] » nil
b = Array.new
b.type » Array
b.length » 0
b[0] = "second"
b[1] = "array"
b » ["second", "array"]

Auf Arrays greift man zu mit dem []-Operator. Wie bei den meisten Ruby-Operatoren ist dies tatsächlich eine Methode (in der Klasse Array) und kann daher auch in Unterklassen überschrieben werden. Wie das Beispiel zeigt, fangen Array-Indizes bei 0 an. Wenn man das Array über eine einzelne Nummer indiziert, gibt es das Objekt an dieser Position zurück oder nil falls da nichts ist. Wenn man ein Array mit einer negativen Nummer indiziert, zählt es von hinten. Dies wird in Figur 4.1 auf Seite 37 gezeigt.

Indizieren von Arrays

Figur 4.1 Indizieren von Arrays

a = [ 1, 3, 5, 7, 9 ]
a[-1] » 9
a[-2] » 7
a[-99] » nil

Man kann Arrays auch mit einem Zahlenpaar indizieren, [start, count]. Dann bekommt man ein neues Array zurück, das aus den count Objekt-Referenzen, angefangen mit Position start, besteht.

a = [ 1, 3, 5, 7, 9 ]
a[1, 3] » [3, 5, 7]
a[3, 1] » [7]
a[-3, 2] » [5, 7]

Schließlich kann man Arrays auch über Bereiche indizieren, in dem Start- und End-Position durch zwei oder drei Punkte getrennt sind. Bei der Zwei-Punkte-Version ist die Endposition dabei, bei der Drei-Punkte-Version nicht.

a = [ 1, 3, 5, 7, 9 ]
a[1..3] » [3, 5, 7]
a[1...3] » [3, 5]
a[3..3] » [7]
a[-3..-1] » [5, 7, 9]

Der []-Operator besitzt einen korrespondierenden []=-Operator, der es einem ermöglicht, Elemente in einem Array zu setzen. Wenn man diesen mit einem Einzel-Zahl-Index benutzt, wird das Element an dieser Stelle mit dem überschrieben, was auf der rechten Seite der Zweisung steht. Alle Lücken, die entstehen könnten, werden mit nil gefüllt.

a = [ 1, 3, 5, 7, 9 ] » [1, 3, 5, 7, 9]
a[1] = 'bat' » [1, "bat", 5, 7, 9]
a[-3] = 'cat' » [1, "bat", "cat", 7, 9]
a[3] = [ 9, 8 ] » [1, "bat", "cat", [9, 8], 9]
a[6] = 99 » [1, "bat", "cat", [9, 8], 9, nil, 99]

Wenn der Index für []= aus zwei Zahlen besteht (Start und Länge) oder aus einem Bereich, dann werden alle diese ausgewählten Elemente aus dem ursprünglichen Array ersetzt durch was auch immer auf der rechten Seite der Zuweisung steht. Wenn die Länge Null ist, wird die rechte Seite vor die Startposition eingefügt; keine Elemente werden entfernt. Wenn die rechte Seite selber wieder ein Array ist, werden dessen Elemente zum Ersetzen benutzt. Die Array-Größe wird automatisch angepasst, wenn der Index eine andere Zahl von Elementen bestimmt, als auf der rechten Seite der Zuweisung verfügbar sind.

a = [ 1, 3, 5, 7, 9 ] » [1, 3, 5, 7, 9]
a[2, 2] = 'cat' » [1, 3, "cat", 9]
a[2, 0] = 'dog' » [1, 3, "dog", "cat", 9]
a[1, 1] = [ 9, 8, 7 ] » [1, 9, 8, 7, "dog", "cat", 9]
a[0..3] = [] » ["dog", "cat", 9]
a[5] = 99 » ["dog", "cat", 9, nil, nil, 99]

Arrays besitzen eine große Anzahl von nützlichen Methoden. Wenn man die benutzt, kann man Arrays wie Stacks behandeln, oder wie Mengen, queues, dequeues, and fifos. Eine komplette Liste aller Array-Methoden fängt auf Seite 282 an.

Hashes

Hashes (auch bekannt als Associative Arays oder Dictionaries) sind insofern ähnlich wie Arrays, dass sie indizierte Ansammlungen von Objekt-Referenzen sind.

Allerdings kann man, wo man Arrays nur mit Zahlen indizieren kann, Hashes mit allem Möglichen indizieren: Strings, reguläre Ausdrücke und so weiter. Wenn man einen Wert in einem Hash speichert, fügt man tatsächlich zwei Objekte hinzu --- den Schlüssel und den Wert. Man kann hinterher den Wert wieder herausbekommen, indem man das Hash mit dem selben Schlüssel indiziert. Die Werte in dem Hash können alle möglichen Objekte mit egal welchem Typ sein. Das folgende Beispiel benutzt literale Hashs: eine Liste aus Schlüssel => Werte-Paaren zwischen Klammern.

h = { 'dog' => 'canine', 'cat' => 'feline', 'donkey' => 'asinine' }
h.length » 3
h['dog'] » "canine"
h['cow'] = 'bovine'
h[12]    = 'dodecine'
h['cat'] = 99
h » {"cow"=>"bovine", 12=>"dodecine", "dog"=>"canine", "donkey"=>"asinine", "cat"=>99}

Verglichen mit Arrays haben Hashes einen signifikanten Vorteil: man kann Objekte als Indexe benutzen. Allerdings hat das auch einen signfikanten Nachteil: die Elemente sind nicht geordnet, man kann also Hashes nicht als Stack oder Reihe benutzen.

Du wirst merken, dass Hashes eine der am meisten benutzten Daten-Strukturen in Ruby ist. Eine Liste mit allen Methoden dieser Hash-Klasse beginnt auf Seite 321.

Einen SongList-Container implementieren

Nach dieser kurzen Abschweifung sind wir nun bereit, die SongList der Jukebox zu implementieren. Wir werden erstmal eine Liste von Methoden aufstellen, die wir in unserer SongList brauchen. Später werden wir noch Sachen hinzufügen, aber fürs Erste wird dies genügen.

append( aSong ) » list
Hängt einen gegebenen Song an die Liste an.
deleteFirst() » aSong
Entfern den ersten Song von der Liste und gibt ihn aus.
deleteLast() » aSong
Entfernt den letzten Song von der Liste und gibt ihn aus.
[ anIndex } » aSong
Gibt den Song aus, der mit anIndex identifiziert wird, das kann ein Zahl-Index sein oder ein Song-Titel.

Diese Liste gibt uns schon einen Hinweis, wie wir implementieren müssen. Die Möglichkeit, einen Song ans Ende anzuhängen, und einen sowohl vom Ende als auch vom Anfang entfernen zu können, weist auf eine Dequeue hin --- eine an beiden Seiten offene Reihe --- und wir wissen, die können wir mit einem Array realisieren. Außerdem wird die Fähigkeit, einen Song nach seiner als Zahl angegebenen Position zurückzuliefern, von einem Array unterstützt.

Andererseits gibt es da die Forderung, einen Song über den Titel zurückzuliefern, was auf einen Hash hindeutet, mit dem Titel als Schlüssel und dem Song als Wert. Könnten wir einen Hash benutzen? Nun ja, schon, aber es gibt da Probleme. Als Erstes ist ein Hash ungeordnet, wir brauchen also noch einen zusätzlichen Array, um uns die Reihenfolge der Songs zu merken. Ein größeres Problem ist, dass ein Hash nicht mehrere Schlüssel für einen Wert unterstützt. Das wäre ein Problem für unsere Playlist, in der ein Song mehrfach an unterschiedlichen Stellen auftauchen kann. Also nehmen wir erstmal einen Array für die Songs und durchsuchen ihn nach den Titeln, wenn wir das brauchen. Wenn das nachher ein Problem mit der Performance ergibt, können wir immer noch eine hash-basierte Zusatztabelle einführen.

Wir beginnen unsere Klasse mit einer grundlegegenden initialize-Methode, die das Array erzeugt, in dem wir die Songs sichern, und speichern eine Referenz darauf in der Instanz-Variablen @songs.

class SongList
  def initialize
    @songs = Array.new
  end
end

Die SongList#append-Methode fügt einen gegebenen Song am Ende des @songs Arrays hinzu. Sie gibt self zurück, eine Referenz auf das aktuelle SongList-Objekt. Dies ist eine nützliche Vorgehensweise, denn damit kann man mehrere Aufrufe von append hintereinanderhängen. Wir werden ein Beispiel dafür später noch sehen.

class SongList
  def append(aSong)
    @songs.push(aSong)
    self
  end
end

Dnaach fügen wir die deleteFirst- und deleteLast-Methoden hinzu, ganz einfach indem wir Array#shift bzw. Array#pop nutzen.

class SongList
  def deleteFirst
    @songs.shift
  end
  def deleteLast
    @songs.pop
  end
end

An dieser Stelle wäre ein scheller Test angebracht. Erstmal hängen wir vier Songs an unsere Liste. Nur um ein bißchen anzugeben benutzen wir die Tatsache, dass append das SongList-Objekt zurückgibt, dazu, die Methodenaufrufe aneinanderzuhängen.

list = SongList.new
list.
  append(Song.new('title1', 'artist1', 1)).
  append(Song.new('title2', 'artist2', 2)).
  append(Song.new('title3', 'artist3', 3)).
  append(Song.new('title4', 'artist4', 4))

Dann untersuchen wir, ob die Songs korrekt vom Anfang und Ende der Liste entnommen werden, und ob tatsächlich nil zurückgegeben wird, wenn die Liste leer geworden ist.

list.deleteFirst » Song: title1--artist1 (1)
list.deleteFirst » Song: title2--artist2 (2)
list.deleteLast » Song: title4--artist4 (4)
list.deleteLast » Song: title3--artist3 (3)
list.deleteLast » nil

So weit so gut. Unsere nächste Methode ist [], mit der man auf Elemente über einen Index zugreifen kann. Der Index ist eine Zahl (das prüfen wir mit Object#kind_of?), wir geben nur das Element an dieser Position zurück.

class SongList
  def [](key)
    if key.kind_of?(Integer)
      @songs[key]
    else
      # ...
    end
  end
end

Es ist wieder recht einfach, das zu testen.

list[0] » Song: title1--artist1 (1)
list[2] » Song: title3--artist3 (3)
list[9] » nil

Nun müssen wir noch das Teil hinzufügen, mit dem wir den Song über den Titel ansprechen können. Dazu müssen wir duch alle Songs in der Liste scannen und jeden Titel prüfen. Um das zu machen müssen wir uns erstmal ein paar Seiten lang mit einem der außerordentlichsten Merkmale von Ruby beschäftigen: Iteratoren.

Blocks and Iterators

Also, unsser nächstes Problem für SongList ist, in der Methode [] Code zu implementieren, der einen String entgegennimmt und nach einem Song mit diesem Titel sucht. Der Weg scheint klar: wir haben ein Array mit Songs, also gehen wir das einfach Element für Element durch und suchen nach einem Treffer.

class SongList
  def [](key)
    if key.kind_of?(Integer)
      return @songs[key]
    else
      for i in 0...@songs.length
        return @songs[i] if key == @songs[i].name
      end
    end
    return nil
  end
end

Das klappt und es sieht angenehm vertraut aus: eine for-Schleife die über ein Array läuft. Was könnte natürlicher sein?

Tatsächlich gibt es etwas, das natürlicher ist. In gewisser Weise ist die for-Schleife ein wenig zu intim mit dem Array; Sie fragt nach der Länge, dann holt sie Werte bis sie einen Treffer findet. Warum sollte man nicht das Array selber darum bitten, einen Test an jedem seiner Mitglieder auszuführen? Genau das ist es, was die find-Methode in Array macht.

class SongList
  def [](key)
    if key.kind_of?(Integer)
      result = @songs[key]
    else
      result = @songs.find { |aSong| key == aSong.name }
    end
    return result
  end
end

Wir können if als Anweisung benutzen, um den Code noch kürzer zu kriegen.

class SongList
  def [](key)
    return @songs[key] if key.kind_of?(Integer)
    return @songs.find { |aSong| aSong.name == key }
  end
end

Die Methode find ist ein Iterator, der einen Code-Block wiederholt aufruft. Iteratoren und Code-Blöcke gehören zu den interessanteren Merkmalen von Ruby, also werden wir etwas Zeit aufbringen, sie näher zu untersuchen (und dabei werden wir auch genau herausfinden, was diese Zeile Code in unserer []-Methode denn nun wirklich macht).

Iteratoren implementieren

In Ruby ist ein Iterator einfach eine Methode, die einen Code-Block aufrufen kann. Auf den ersten Blick sieht ein Code-Block in Ruby genau so aus wie in C, Java oder Perl. Unglücklicherweise täuscht dieser Eindruck --- ein Block in Ruby ist eine Art Anweisungs-Gruppierung, aber nicht im üblichen Sinn.

Zum Einen darf ein Block im Quellcode nur verbunden mit einem Methoden-Aufruf auftauchen; der Block beginnt auf der selben Zeile wie der letzte Parameter der Methode. Zum Anderen wird der Block nicht dann ausgeführt, wenn das Programm auf ihn stößt. Stattdessen merkt sich Ruby den Kontext, in dem der Block auftaucht (die lokalen Variablen, das aktuelle Objekt und so weiter) und führt dann die Methode aus. Zu diesem Zeitpunkt wirds dann interessant.

Innerhalb der Methode kann der Block aufgerufen werden, fast so als wäre er selber eine Methode, und zwar mit der yield Anweisung. Immer wenn ein yield ausgeführt wird, wird der Code aus dem Block aufgerufen. Wenn der Block wieder verlassen wird, gehts direkt nach dem yield weiter.[Programmiersprachen-Kenner werden erfreut sein zu hören, dass das Schlüselwort yield gewählt wurde, um die yield-Funktion in Liskovs Sprache CLU wiederzuspiegeln, eine Sprache die über 20 Jahre alt ist und immer noch Sachen enthält, auf die die CLU-lose Welt noch wartet.] Fangen wir mit einem einfachen Beispiel an.

def threeTimes
  yield
  yield
  yield
end
threeTimes { puts "Hello" }
erjeugt:
Hello
Hello
Hello

Der Block (der Code zwischen den Klammern) ist verbunden mit dem Aufruf der Methode threeTimes. Innerhalb dieser Methode wird yield drei Mal nacheinander aufgerufen. Jedesmal ruft das den Code im Block auf und eine heitere Begrüßung wird ausgegeben. Was so einen Block allerdings interessant macht ist, dass man Parameter an ihn übergeben und Werte zurückerhalten kann. Als Beispiel schreiben wir eine einfache Funktion, die Mitglieder der Fibonacci-Reihe bis zu einem bestimmten Wert zurückgibt.[Die allgemeine Fibonacci-Reihe ist eine Folge von Zahlen die mit zwei Einsen beginnt. Jede folgende Zahl ist dann die Summe der vorhergehenden beiden Zahlen. Diese Reihe wird manchmal in Sortier-Algorhythmen benutzt oder beim Analysieren von natürlichen Phänomenen.]

def fibUpTo(max)
  i1, i2 = 1, 1        # parallele Zuweisung
  while i1 <= max
    yield i1
    i1, i2 = i2, i1+i2
  end
end
fibUpTo(1000) { |f| print f, " " }
erzeugt:
1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987

In diesem Beispiel besitzt die yield-Anweisung einen Parameter. Dieser Wert wird an den zugehörigen Block weitergegeben. In der Definition des Blockes taucht die Argument-Liste zwischen senkrechten Strichen auf. In dieser Instanz erhält die Variable f den an yield übergebenen Wert, so dass der Block mehrere Mitglieder der Reihe ausgibt. (Diese Beispiel zeigt auch parallele Zuweisungen. Wir kommen darauf auf Seite 77 zurück.) Obwohl es üblich ist, nur einen Wert an einen Block zu übergeben, ist dies nicht notwendig, ein Block kann jede beliebige Anzahl von Argumenten bekommen. Was passiert, wenn der Block eine andere Anzahl von Parametern erwartet, als vom Yield übergeben werden? Durch einen seltsamen Zufall gelten hier die selben Regeln wie bei der parallelen Zuweisung (mit einem kleinen Unterschied: werden mehrere Parameter an einen yield übergeben, so gelten diese als ein Array, falls der Block nur ein Argument erwartet).

Parameter für einen Block können existierende lokale Variablen sein; in diesem Falle bleibt der neue Wert der Variable erhalten, wenn der Block fertig ist. Das kann zu ganz unerwartetem Verhalten führen, aber dafür gibt es einen Performance-Gewinn, wenn man bereits existierende Variablen benutzt.[Mehr Informationen darüber und über andere Fallstricke gibts ab Seite 129, mehr Informationen über die Performance ab Seite 130.]

Ein Block kann auch einen Wert an die Methode zurückgeben. Der Wert des letzten ausgewerteten Ausdrucks dieses Blockes wird an die Methode als Wert von yield zurückgegeben. Genauso funktioniert die find-Methode der Klasse Array. [Die find-Methode wird tatsächlich im Modul Enumerable definiert, die in die Klasse Array eingebunden ist.] Ihre Implementierung würde ungefähr so aussehen.

class Array
  def find
    for i in 0...size
      value = self[i]
      return value if yield(value)
    end
    return nil
  end
end
[1, 3, 5, 7, 9].find {|v| v*v > 30 } » 7

Damit werden ein Element nach dem anderen an den assoziierten Block zurückgegeben. Wenn der Block true liefert, gibt die Methode das dazugehörende Element zurück. Wenn kein Element passt, gibt die Methode nil zurück. Das Beispiel zeigt den Nutzen dieses Verfahrens bei Iteratoren. Die Klasse Array macht, was sie am besten kann, sie greift auf Array-Elemente zu und überlässt es dem Code der Applikation, sich um die speziellen Anforderungen zu kümmern (in diesem Fall das Finden eines Eintrags, der irgendwelchen mathematischen Kriterien genügt).

Einige Iteratoren gibt es bei vielen Aufzählungstypen von Ruby. Wir haben uns schon find angesehen. Zwei andere sind each und collect. each ist wahrscheinlich der einfachste Iterator --- das einzige was er macht, ist yield mit nacheinander allen Elementen seiner Aufzählung auszuführen.

[ 1, 3, 5 ].each { |i| puts i }
erzeugt:
1
3
5

Der each-Iterator hat in Ruby einen besonderen Platz; auf Seite 87 werden wir beschreiben, wie er als Basis für die for-Schleife dient, und ab Seite 104 werden wir sehen, wie man durch die Definition einer each-Methode einen Haufen Funktionalität zu einer eigenen Klasse für lau hinzufügen kann.

Ein anderer gebräuchlicher Iterator ist collect, der jedes Element der Aufzählung nimmt und es an den Block weitergibt. Die vom Block zurückgegebenen Ergebnisse werden zum Erzeugen eines neuen Arrays benutzt. Als Beispiel:

["H", "A", "L"].collect { |x| x.succ } » ["I", "B", "M"]

Ruby verglichen mit C++ und Java

Der Vergleich der Verfahren der Iteratoren von Ruby einerseits und C++ und Java andererseits ist einen eigenen Abschnitt wert. Im Verfahren von Ruby ist der Iterator einfach eine Methode, genau wie jede andere, die jedesmal wenn sie einen Wert generiert einen yield aufruft. Das Teil, das diesen Iterator aufruft ist einfach nur ein mit dieser Methode verbundener Code-Block. Man braucht keine Hilfs-Klasse, die den Iterator-Status enthält, wie in Java oder C++. In dieser wie in vielen anderen Beziehungen ist Ruby eine transparente Sprache. Wenn man ein Ruby-Programm schreibt, konzentriert man sich auf die anstehende Aufgabe, nicht darauf, Hilfsgerüste zur Unterstützung der Sprache selber zu bauen.

Iteratoren sind nicht auf existierende Daten in Arrays und Hashes begrenzt. Wie wir beim Fibonacci-Beispiel sahen, kann ein Iterator erzeugte Werte zurückgeben. Diese Fähigkeit wird bei den Input/Output-Klassen von Ruby benutzt, die ein Iterator-Interface besitzen, das nacheinander Zeilen (oder Bytes) in einem I/O-Stream zurückliefert.

f = File.open("testfile")
f.each do |line|
  print line
end
f.close
produces:
This is line one
This is line two
This is line three
And so on...

Lasst uns noch einen weiteren Iterator ansehen, Die Sprache Smalltalk unterstützt noch einen weiteren Iterator für Aufzählungen. Wenn man Smalltalk-Programmierer bittet, die Elemente in einem Array aufzusummieren, werden sie normalerweise die inject-Funktion benutzen.

sumOfValues              "Smalltalk Methode"
    ^self values
          inject: 0
          into: [ :sum :element | sum + element value]

inject arbeitet folgendermaßen: Beim ersten Mal wenn der Block aufgerufen wird, wird sum auf den inject-Parameter gesetzt (in diesem Fall Null) und element wird auf das erste Element des Arrays gesetzt. Beim zweiten und allen weiteren Malen wenn der Block aufgerufen wird, wird sum auf den Rückgabewert des Blocks vom vorhergehenden Mal gesetzt. Auf diese Weise wird sum benutzt, um eine fortlaufende Gesamtsumme zu bilden. Der endgültige Wert von inject ist derjenige, der beim letzen Aufruf des Blocks zurükgegeben wurde.

Ruby hat keine inject-Methode, aber man kann einfach eine schreiben. In diesem Fall fügen wir sie zur Array-Klasse hinzu, während wir auf Seite 102 sehen werden, wie man sie auch allgemeiner verfügbar machen kann.

class Array
  def inject(n)
     each { |value| n = yield(n, value) }
     n
  end
  def sum
    inject(0) { |n, value| n + value }
  end
  def product
    inject(1) { |n, value| n * value }
  end
end
[ 1, 2, 3, 4, 5 ].sum » 15
[ 1, 2, 3, 4, 5 ].product » 120

Obwohl Blöcke oft das Ziel eines Iterators sind, kann man sie aber auch auf andere Arten benutzen. Wir werden uns einige davon ansehen.

Blöcke für Transaktionen

Mit Blöcken kann man ein Stück Code definieren, das unter einer Art von Transaktionskontrolle laufen soll. Zum Beispiel öffnet man oft eine Datei, macht etwas mit deren Inhalt und möchte dann sicher sein, dass die Datei auch geschlossen wird, wenn man fertig ist. Obwohl man das auch mit konventionellem Code machen kann, gibt es doch gute Gründe dafür, die Datei sich selber schließen zu lassen. Man kann das mit Blöcken erreichen. Eine naive Implementation (ohne irgendwelches Fehlerhandling) könnte etwa so aussehen.

class File
  def File.openAndProcess(*args)
    f = File.open(*args)
    yield f
    f.close()
  end
end

File.openAndProcess("testfile", "r") do |aFile|   print while aFile.gets end
erzeugt:
This is line one
This is line two
This is line three
And so on...

Dieses kurze Beispiel zeigt eine ganze Reihe von Techniken. Die openAndProcess-Methode ist eine Klassen-Methode --- sie kann unabhängig von einem speziellen File-Objekt aufgerufen werden. Wir wollen, dass sie die selben Argumente entgegennimmt wie die konventionelle File.open-Methode, aber wir kümmern uns nicht wirklich darum, was das für welche sind. Stattdessen spezifizieren wir diese Argumente als *args, was bedeutet ``sammel die aktuellen an diese Methode übergebenen Parameter in einem Array ein''. Wir rufen dann File.open auf und übergeben *args als Parameter. Dabei wird das Array zurück in einzelne Parameter aufgefächert. Insgesamt werden also bei diesem openAndProcess alle reinkommenden Parameter einfach an File.open weitergegeben.

Wenn die Datei geöffnet wurde, wird von openAndProcess aus yield aufgerufen und das Offene-Datei-Objekt an den Block übergeben. Wenn der Block beendet wird, wird die Datei geschlossen. Auf diese Weise wurde die Verantwortung für das Schließen einer offenen Datei vom Benutzer der Datei-Objekte an diese Objekte selber verschoben.

Schließlich wird in diesem Beispiel noch do...end benutzt, um einen Block zu definieren. Der einzige Unterschied zur Benutzung von Klammern ist die Priorität: do...end bindet schwächer als ``{...}''. Wir werden die Wirkung davon ab Seite 236 noch diskuttieren.

Diese Technik, Dateien ihren eigenen Lebensfaden verwalten zu lassen, ist so nützlich, dass die mit Ruby mitgelieferte Klasse File das direkt unterstützt. Falls File.open einen assoziierten Block dazubekommt, dann wird dieser Block zusammen mit der Datei aufgerufen und die Datei wird geschlossen, sowie der Block beendet wird. Das ist interessant, bedeutet es doch, dass File.open gleich zwei unterschiedliche Verhaltensweisen hat: Falls es mit einem Block zusammen aufgerufen wird, dann führt es den Block aus und schließt die danach Datei. Falls es ohne Block aufgerufen wird, gibt es das Datei-Objekt zurück. Dies wird ermöglicht durch die Methode Kernel::block_given?, die true zurückgibt, falls ein Block mit der aktuellen Methode assoziiert wird. Damit könnte man File.open (wieder ohne irgendwelches Fehlerhandling) ungefähr so implementieren.

class File
  def File.myOpen(*args)
    aFile = File.new(*args)
    # Falls es einen Block gibt, die Datei übergeben und die    # Datei schließen beim Zurückkehren
    if block_given?
      yield aFile
      aFile.close
      aFile = nil
    end
    return aFile
  end
end

Blöcke können Closures sein

Kehren wir kurz zu unserer Jukebox zurück (erinnerst du dich noch an die Jukebox ?). Irgendwann einmal werden wir Code schreiben, der die Benutzerschnittstelle darstellt --- die Buttons (Knöpfe) die die Leute drücken, um Songs auszuwählen und die Jukebox zu kontrollieren. Wir werden Aktionen mit diesen Buttons verbinden müssen: Drücke STOP und die Musik hört auf. Es ergibt sich sehr schön, dass diese Ruby-Blöcke ein bequemer Weg dazu sind. Nehmen wir für den Anfang mal an, dass die Leute, die die Hardware hergestellt haben auch eine Ruby-Extension implementiert haben, die uns eine Button-Basis-Klasse zur Verfügung stellt. (Wir sprechen über Ruby-Extensionen ab Seite 171.)

bStart = Button.new("Start")
bPause = Button.new("Pause")
# ...

Was passiert, wenn der Benutzer einen unserer Buttons drückt? In der Button-Klasse haben es die Hardware-Leute so gedeichselt, dass eine Callback-Methode, buttonPressed, aufgerufen wird. Der offensichtliche Weg zum Hinzufügen von Funktionalität zu diesen Buttons ist, Unterklassen von Button zu erzeugen und jeder dieser Unterklassen ihre eigene buttonPressed-Methode mitzugeben.

class StartButton < Button
  def initialize
    super("Start")       # Initialisierung der Buttons aufrufen  end
  def buttonPressed
    # do start actions...
  end
end

bStart = StartButton.new

Es gibt dabei zwei Probleme. Zum einen führt das zu einer sehr großen Anzahl von Unterklassen. Falls die Schnittstelle von Button sich ändert, müssen wir eine Menge an Anpassungen machen. Zum anderen werden die Aktionen, die beim Drücken eines Buttons ausgelöst werden, auf einem ganz falschen Level beschrieben; sie sind nicht das Merkmal eines Buttons, sondern das Merkmal der Jukebox, die diese Buttons benutzt. Wir können dieses Problem lösen durch die Benutzung von Blöcken.

class JukeboxButton < Button
  def initialize(label, &action)
    super(label)
    @action = action
  end
  def buttonPressed
    @action.call(self)
  end
end

bStart = JukeboxButton.new("Start") { songList.start } bPause = JukeboxButton.new("Pause") { songList.pause }

Der Schlüssel zu diesem allen ist der zweite Parameter von JukeboxButton#initialize. Wenn der letzte Parameter in einer Methoden-Definition mit einem Ampersand (wie in &action) anfängt, sucht Ruby jedesmal wenn diese Methode aufgerufen wird nach einem Code-Block. Dieser Code-Block wird zu einem Objekt der Klasse Proc konvertiert und an den Parameter gebunden. Man kann dann diesen Parameter wie jede andere Variable auch benutzen. In unserem Beispiel haben wir ihn an die Instanzvariable @action gebunden. Wenn die Callback-Methode buttonPressed aufgerufen wird, benutzen wir die Proc#call-Methode für dieses Objekt, um den Block aufzurufen.

Also was genau haben wir nun, wenn wir ein Proc-Objekt erzeugen? Das interessante daran ist, dass es mehr ist als nur eine Stückchen Code. Mit dem Block verbunden (deswegen ein Proc-Object) bleibt der ganze Kontext, in dem der Block definiert wurde: der Wert von self und die Methoden, Variablen und Konstanten der Umgebung. Teil des Zaubers von Ruby ist, das dieser Block immer noch all diese originalen Bereichs-Informationsn nutzen kann, selbst wenn die Umgebung, in der er definiert war ansonsten schon verschwunden ist. In anderen Sprachen wird diese Möglichkeit Closure genannt.

Schauen wir uns ein konstruiertes Beispiel an, Diese Beispiel nutzt die Methode proc, die einen Block zu einem Proc-Objekt konvertiert.

def nTimes(aThing)
  return proc { |n| aThing * n }
end
p1 = nTimes(23)
p1.call(3) » 69
p1.call(4) » 92
p2 = nTimes("Hello ")
p2.call(3) » "Hello Hello Hello "

Die Methode nTimes gibt ein Proc-Objekt zurück, das den Parameter der Methode, nämlich aThing referenziert. Selbst wenn dieser Parameter eigentlich nicht mehr im zugehörigen Bereich ist: wenn der Block aufgerufen wird, bleibt der Parameter für den Block erreichbar.


Extracted from the book "Programming Ruby - The Pragmatic Programmer's Guide"
Übersetzung: Jürgen Katins
Für das englische Original:
© 2000 Addison Wesley Longman, Inc. Released under the terms of the Open Publication License V1.0. That reference is available for download.
Diese Lizenz sowie das Original vom Herbst 2001 bilden die Grundlage der Übersetzung
Es wird darauf hingewiesen, dass sich die Lizenz des englischen Originals inzwischen geändert hat.
Für die deutsche Übersetzung:
© 2002 Jürgen Katins
Der Copyright-Eigner stellt folgende Lizenzen zur Verfügung:
Nicht-freie Lizenz:
This material may be distributed only subject to the terms and conditions set forth in the Open Publication License, v1.0 or later (the latest version is presently available at http://www.opencontent.org/openpub/). Distribution of substantively modified versions of this document is prohibited without the explicit permission of the copyright holder. Distribution of the work or derivative of the work in any standard (paper) book form is prohibited unless prior permission is obtained from the copyright holder.
Freie Lizenz:
Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.1 or any later version published by the Free Software Foundation; with no Invariant Sections, with no Front-Cover Texts, and with no Back-Cover Texts. A copy of the license is included in the section entitled "GNU Free Documentation License".