Programmierung in Ruby

Der Leitfaden der Pragmatischen Programmierer

Exceptions, Catch und Throw



Bis jetzt haben wir Schönwetter-Code entwickelt, Die Sonne scheint, es ist warm und niemals, niemals geht irgend etwas schief. Jeder Aufruf einer Bibliothek hat geklappt, der User hat niemals falsche Daten eingegeben und der Resourcen sind es gar viele und sie sind billig. Nun gut, das wird sich jetzt ändern. Willkomen in der wirklichen Welt!

In der wirklichen Welt passieren Fehler. Gute Programmierer ahnen sie voraus und gehen gnädig mit ihnen um. Das ist nicht immer so einfach wie es aussieht. Oft kennt der Code, der den Fehler entdeckt, nicht nicht genug über den Kontext, in dem er passiert, um damit richtig umzugehen. So ist der Versuch, eine nicht vorhandene Datei zu öffnen, manchmal akzeptabel und manchmal ein fataler Fehler. Wie soll also unser File-Handling-Modul damit umgehen?

Die traditionelle Herangehensweise besteht aus der Benutzung von Rückgabewerten. Die Methode open gibt einen bestimmten Wert zurück, um zu sagen, dass sie nicht geklappt hat. Dieser Wert wird dann über die verschiedenen Schichten von aufrufenden Routinen zurückgegeben, bis irgend jemand sich um ihn kümmert.

Das Problem dabei ist, dass die Behandlung aller dieser Fehler ziemlich übel sein kann. Wenn eine Funktion open aufruft, dann read und schließlich close und alle können einen Fehler-Code zurückliefern, wie kann diese Funktion all diese Fehler-Codes unterscheiden und der zugehörigen aufgerufenen Methode zuordnen?

In großem Maße lösen Exceptions dieses Problem. Mit Exceptions kann man Informationen über einen Fehler in ein Objekt packen. Dieses Exception_Objekt wird dann automatisch über den Funktions-Stack zurückgeliefert, bis das Runtime-System Code findet, der explizit erklärt, dass er für diese Art von Exception zuständig ist.

Die Exception Klasse

Das Paket mit der Information über eine Exception ist ein Objekt der Klasse Exception, oder eines der Kinder der Klasse Exception. In Ruby wird eine strenge Hierarchie von Exceptions vordefiniert, wie in Figur 8.1 auf Seite 93 zu sehen. Wie wir später noch sehen werden, macht diese Hierarchie das Behandeln von Exceptions wesentlich einfacher.

Hierarchie der Ruby-Ausnahmen

Figur 4.1 Hierarchie der Ruby-Ausnahmen

Wenn man eine Exception hervorrufen muss, kann man eine der eingebauten Exception-Klassen benutzen oder man kann eine eigene erzeugen. Wenn man eine eigene erzeugt, sollte man sie als Unterklasse von StandardError oder einer ihrer Nachfahren machen. Falls nicht, dann wird diese Exception nicht automatisch aufgefangen.

Zu jeder Exception gibt es einen zugehörigen Meldungs-String und einen Rückverfolgungsstapel. Wenn man eine eigene Exception definiert, kann man noch zusätzliche Informationen hinzufügen.

Exceptions behandeln

Unsere Jukebox lädt sich Songs aus dem Internet über ein TCP-Socket. Der Basis-Code ist einfach:

opFile = File.open(opName, "w")
while data = socket.read(512)
  opFile.write(data)
end

Was passiert, wenn es mitten im Download einen fatalen Fehler gibt? Wir wollen sicherlich keinen unvollständigen Song in der Liste abspeichern. ``I Did It My *click*''.

Lasst uns ein wenig Exception-Handling-Code hinzufügen und sehen, wie das hilft. Wir packen den Code, der eine Exception auslösen könnte, in einen begin/end-Block und benutzen rescue, um Ruby den Typ der Exception zu sagen, den wir behandeln wollen. In diesem Fall sind wir an SystemCallError-Exceptions interessiert (und implizit an allen Exceptions, die Unterklassen von SystemCallError sind), also schreiben wir das in die rescue-Zeile. Im Fehlerbehandlungs-Block merken wir uns den Fehler, schließen und vernichten die Output-Datei und dann lösen wir die Exception neu aus.

opFile = File.open(opName, "w")
begin
  # Exceptions die von diesem Code ausgelöst werden,
  # werden von der folgenden Rescue-Klausel aufgefangen
  while data = socket.read(512)
    opFile.write(data)
  end

rescue SystemCallError   $stderr.print "IO failed: " + $!   opFile.close   File.delete(opName)   raise end

Wenn eine Exception ausgelöst wird, setzt Ruby, unabhängig von jedweder weiteren Exception-Behandlung, eine Referenz des zu dieser Exception gehörenden Exception-Objektes in die globale Variable $! (das Ausrufezeichen zeigt noch zusätzlich unsere Überraschung an, dass unser Code überhaupt Fehler erzeugen könnte). Im vorherigen Beispiel benutzten wir diese Variable, um unsere Fehlermeldung zu formatieren.

Nachdem wir die Datei geschlossen und entfernt haben, rufen wir raise ohne Parameter auf, das löst die Exception in $! erneut aus. Dies ist eine nützliche Technik, sie erlaubt einem, Code zu schreiben, der Exceptions filtert und die, die man nicht selber behandeln kann, an höhere Ebenen weiterzuleiten. Das ist als wenn man eine Vererbungshierarchie für Fehlerbehandlungen implementiert.

Man kann mehrere rescue-Klauseln in einem begin-Block haben und jede rescue-Klausel kann mehrere Exceptions angeben, die sie fangen will. Am Ende jeder Rescue-Klausel kann man für Ruby den Namen einer lokalen Variablen angeben, die die passende Exception aufnehmen soll. Viele Leute finden das lesbarer, als dauernd $! zu benutzen.

begin
  eval string
rescue SyntaxError, NameError => boom
  print "String doesn't compile: " + boom
rescue StandardError => bang
  print "Error running script: " + bang
end

Woher weiß Ruby, welche Rescue-Klausel es ausführen soll? Das ganze läuft ganz ähnlich ab, wie bei einer case-Anweisung. Bei jeder rescueKlausel im begin-Block vergleicht Ruby die ausgelöste Exception mit den Rückgabeparametern. Wenn einer davon auf die ausgelöste Exception passt, führt Ruby den Rumpf von rescue aus und hört auf zu suchen. Die Suche wird mit $!.kind_of?(parameter) ausgeführt und ist dann erfolgreich, wenn der Parameter die selbe Klasse wie die Exception besitzt oder einer ihrer Vorfahren ist. Wenn man eine rescue-Klausel ohne Parameter-Liste schreibt, so ist der Default-Parameter StandardError.

Wenn keine rescue-Klausel passend ist oder die Exception außerhalb eines begin/end-Blocks ausgelöst wird, fährt Ruby den Stack aufwärts und sucht nach einer Exception-Behandlung im Aufrufer, dann im Aufrufer des Aufrufers und so weiter.

Obwohl die Parameter einer rescue-Klausel üblicherweise die Namen von Exception-Klassen sind, können sie auch beliebige Ausdrücke (inklusive Methoden-Aufrufe) sein, die eine Exception-Klasse zurückgeben.

Aufräumen

Manchmal braucht man die Garantie, dass einige Aufgaben am Ende eines Code-Blocks ausgeführt werden, egal ob eine Exception ausgelöst wurde oder nicht. Zum Beispiel könnte man eine Datei zu Anfang des Blockes öffnen und man möchte sicher sein, dass sie geschlossen wird, wenn der Block beendet wird.

Die ensure-Klausel macht genau das. ensure kommt nach der letzten rescue-Klausel und enthält ein Stück Code, das immer ausgeführt wird, wenn der Block beendet wird. Dabei kommt es nicht darauf an, ob der Block normal verlassen wird, ob er eine Exception auslöst und ein Rescue durchläuft oder ob er durch eine nicht abgefangene Exception beendet wird --- der ensure-Block kommt zuletzt dran.

f = File.open("testfile")
begin
  # .. Process
rescue
  # .. Fehler- Behandlung
ensure
  f.close unless f.nil?
end

Die else-Klausel ist ein ähnliches aber weniger nützliches Konstrukt. Falls vorhanden kommt sie nach der rescue-Klausel und vor einem eventuellen ensure. Der Rumpf der else-Klausel wird nur dann ausgeführt. wenn keine Exceptions vom Hauptteil des Codes ausgelöst wurden.

f = File.open("testfile")
begin
  # .. Process
rescue
  # .. Fehler- Behandlung
else
  puts "Congratulations-- no errors!"
ensure
  f.close unless f.nil?
end

Nochmal auf Start

Manchmal ist man in der Lage, den Verursacher eine Exception zu beseitigen. In diesem Fall kann man die retry-Anweisung innerhalb einer rescue-Anweisung nutzen, um den kompletten begin/end Block zu wiederholen. Natürlich ist das eine hervorragende Möglichkeit, Endlos-Schleifen herzustellen, also sollte man das doch mit äußerster Vorsicht (und mit einem Finger auf dem Reset-Knopf) genießen.

Als Beispiel für Code, der nach einer Exception mit Retry wiederholt wird, sehen wir uns das Folgende aus Minero Aoki's net/smtp.rb-Bibliothek an.

@esmtp = true

begin   # Versucht zuerst ein ausführliches Login. Falls das schiefgeht, weil der Server das nicht kennt, versuch ein normales Login

  if @esmtp then     @command.ehlo(helodom)   else     @command.helo(helodom)   end

rescue ProtocolError   if @esmtp then     @esmtp = false     retry   else     raise   end end

Der Code versucht zuerst mit dem EHLO-Kommando, die Verbindung zu einem SMTP-Server herzustellen, was aber nicht überall unterstützt wird. Falls der Verbindungsversuch fehlschlägt, setzt der Code die @esmtp-Variable auf false und wiederholt den Verbindungsversuch. Wenn das wieder schief geht, wird die Exception an den Aufrufer weitergereicht.

Exceptions auslösen

Bis jetzt waren wir nur in der Defensive und behandelten Exceptions, die von anderen ausgelöst wurden. Nun ist es an der Zeit, die Seiten zu wechseln und in die Offensive zu gehen. (Es gibt da Leute, die behaupten, die Autoren dieser Zeilen seien immer in der Offensive, aber das ist ein anderes Buch.)

Man kann in seinem eigenen Code Exceptions mit der Kernel::raise-Methode auslösen.

raise
raise "bad mp3 encoding"
raise InterfaceException, "Keyboard failure", caller

Im ersten Fall wird einfach die aktuelle Exception noch mal ausgelöst (oder ein RuntimeError, falls es keine aktuelle Exception gibt). Das wird in Exception-Behandlungen benutzt, die eine Exception abfangen bevor sie sie weiterleiten.

Der zweite Fall erzeugt eine neue RuntimeError-Exception und setzt seine Meldung in den gegebenen String. Diese Exception wird dann an den Aufruf-Stack weitergeleitet.

Im dritten Fall wird das erste Argument benutzt, um eine Exception zu erzeugen, die damit verbundene Meldung bildet das zweite Argument und die Stack-Spur das dritte. Üblicherweise ist das erste Argument entweder der Name einer Klasse aus der Exception-Hierarchie oder eine Referenz auf eine Objekt-Instanz einer dieser Klassen.[Technisch gesehen kann dieses Argument jegliches Objekt sein, das auf die Exception-Meldung ein Objekt zurückgibt, das auf object.kind_of?(Exception) mit true antwortet.] Die Stack-Spur wird üblicherweise mit der Kernel::caller-Methode erzeugt.

Jetzt einige typische Beispiele für raise in Aktion.

raise

raise "Missing name" if name.nil?

if i >= myNames.size   raise IndexError, "#{i} >= size (#{myNames.size})" end

raise ArgumentError, "Name too big", caller

Im letzten Beispiel entfernen wir die aktuelle Routine vom Rückverfolgungs-Stack, das ist meistens in Bibliotheksroutinen hilfreich. Wir können das auch noch weiter treiben: der folgende Code entfernt gleich zwei Routinen vom Rückverfolgungs-Stack.

raise ArgumentError, "Name too big", caller[1..-1]

Informationen zu Exceptions hinzufügen

Man kann seine eigenen Exceptions definieren, die dann alle möglichen Informationen beinhalten, die man vom Entstehungsort des Fehlers aus weiterleiten möchte. Zum Beispiel sind verschiedene Typen von Netzwerkfehlern nur vorübergehend und stark von der Umgebung abhängig. Wenn ein solcher Fehler auftritt, könnte man in der Exception ein Flag setzen, um der Fehlerbehandlung zu erzählen, dass man die Operation mit einem Retry wiederholen könnte.

class RetryException < RuntimeError
  attr :okToRetry
  def initialize(okToRetry)
    @okToRetry = okToRetry
  end
end

Irgendwo in den Tiefen des Codes passiert ein vorübergehender Fehler.

def readData(socket)
  data = socket.read(512)
  if data.nil?
    raise RetryException.new(true), "transient read error"
  end
  # .. normal processing
end

Weiter oben im Aufrufer-Stack behandeln wir die Exception.

begin
  stuff = readData(socket)
  # .. process stuff
rescue RetryException => detail
  retry if detail.okToRetry
  raise
end

Catch und Throw

Während der Exception-Mechanismus von raise und rescue gut geeignet ist, die Ausführung abzubrechen, wenn ein Fehler aufgetreten ist, so ist es manchmal angebracht, aus einem tief verschachtelten Konstrukt ganz normal heraus zu springen. Dazu gibt es catch und throw.

catch (:done)  do
  while gets
    throw :done unless fields = split(/\t/)
    songList.add(Song.new(*fields))
  end
  songList.play
end

catch definiert einen Block, der mit dem gegebenen Namen angesprochen wird (das kann ein Symbol oder ein String) sein. Der Block wird ganz normal ausgeführt, bis ein throw auftaucht.

Wenn Ruby auf einen throw stößt, dann läuft es zurück über den Aufrufer-Stack und sucht nach einem catch-Block mit einem passenden Symbol. Wenn es einen gefunden hat, macht es den Stack bis dahin frei und beendet den Block. Wenn throw mit dem optionalen zweiten Parameter aufgerufen wird, so wird dieser als Wert von catch zurückgegeben. Falls also in den vorherigen Beispiel die Eingabe falsch formatierte Zeilen enthält, so springt throw an das Ende des dazugehörenden catch und beendet dabei nicht nur die while-Schleife sondern auch das Abspielen der Song-Liste.

Das folgende Beispiel benutzt einen throw, um die Benutzerabfrage zu beenden, wenn ein ``!'' eingegeben wurde.

def promptAndGet(prompt)
  print prompt
  res = readline.chomp
  throw :quitRequested if res == "!"
  return res
end

catch :quitRequested do   name = promptAndGet("Name: ")   age  = promptAndGet("Age:  ")   sex  = promptAndGet("Sex:  ")   # ..   # process information end

Wie dieses Beispiel zeigt, muss der throw nicht innerhalb des statischen Bereichs von catch auftauchen.


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".