FIGYELEM! Ez a dokumentum kizárólag az ELTE IK hallgatók számára oktatási célra készült! Félkész munka, dolgozunk rajta! Terjeszteni, felhasználni máshol a szerzők engedélye nélkül tilos!

Kivételek, dobás és elkapás


Eddig Kényelemországban írtuk a programunkat - egy csodálatos helyen, ahol mindig minden jól megy. Minden könyvtárhívásunk sikeres volt, a felhasználó nem adott meg helytelen adatot, valamint sok és olcsó erőforrás állt rendelkezésünkre. Nos, ez változni fog. Üdvözöllek a való világban!

A való világban történnek hibák. Jó programok (és programozók) számolnak velük, és a kényelmesen kezelhetőségük érdekében csoportokba osztják őket. Ez azonban nem olyan egyszerű, mint amilyennek lennie kéne. Gyakran a hibát felismerő kód nem rendelkezik megfelelő információval az illetően, hogy mit is tegyen vele. Például, egy nem létező file megnyitása elfogadható bizonyos körülmények között, míg más esetekben végzetes hiba is lehet. Mit tegyen hát a file-ok kezelését végző modulunk?

A hagyományos megközelítés a visszatérési kódok használata. Az open metódus egy különleges értéket ad vissza, ha hibát akar jelezni. Ez az érték ezután visszagörgetődik a meghívási rutinok különböző logikai rétegein keresztül mindaddig, amíg valaki felelősséget nem vállal érte.

Ennek a megközelítésnek legnagyobb problémája, hogy a hibakódok karbantartása igen keserves lehet. Ha egy függvény egymás után meghívja az open, a read, és végül a close metódusokat, és mindhárom visszaadhat egy hibakódot, akkor a függvényünk hogyan jelzi ezt a saját hívójának?

Ezt a problémát a kivételek egész magas szinten megoldhatják. A kivételek lehetővé teszik az információ objektumba csomagolását. Ez a kivételobjektum adódik vissza a hívási vermen keresztül autómatikusan mindaddig, amíg a futási rendszer meg nem találja azt a kódrészletet, amely pontosan felismeri, és lekezelni az adott kivételtípust.

Az Exception (kivétel) osztály

Az a csomag, amely egy kivétellel kapcsolatos információkat tartalmaz, vagy az Exception osztály, vagy annak leszármazottjának egy objektuma. A Ruby új, rendezett hierarchiát alakított ki a kivételek számára. Mint azt később látni fogjuk, ez a hierarchia a kivételek kényelmesebb kezelését teszi lehetővé.

Ha egy kivételt ki kell váltanunk, akkor használhatjuk a beépített Exception osztályokat, vagy megalkothatjuk a sajátunkat is. Ha az utóbbi mellett döntünk, akkor valószínüleg a SandardError osztály, vagy annak valamely leszármazottjából fogjuk származtatni a sajátunkat. Ha nem, akkor a kivételünket alapból nem fogjuk tudni elkapni.

Minden Exception rendelkezik egy hozzá kapcsolódó üzenet szöveggel, és egy visszautalással a verem megfelelő helyére. A saját kivételünk definiálásánel ezeket újabb információkkal bővíthetjük.

Kivételek kezelése

A zenegépünk egy TCP socket használatával az Interneten keresztül tölti le a számokat. Az alap kód egyszerű:

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

Mi történik, ha egy végzetes hibát kapunk a letöltés felénél? Természetesen nem akarunk egy befejezetlen dalt a listában tárolni.

Adjunk hát hozzá némi kivételkezelő kódot, és nézzük meg, hogyan is segít nekünk. A kivételt kiváltható kódot egy begin/end blokkba zárjuk, és rescue esetekkel adjuk meg a Rubynak, hogy milyen típusú kivételekkel akarunk foglalkozni. Ez esetben a SystemCallError típusú kivételek (és így annak leszármazottai) érdekelnek minket, tehát ez jelenik meg a rescue sorában. A hiba kezelő blokkjában jelentjük a hibát, lezárjuk és töröljük a kimeneti file-t, majd kivételt váltunk ki.

opFile = File.open(opName, "w")
begin
  # Ezen kód által kiváltott kivételeket
  # az ezt követő "rescue" blokk kezeli le
  while data = socket.read(512)
    opFile.write(data)
  end
rescue SystemCallError
  $stderr.print "IO failed: " + $!
  opFile.close
  File.delete(opName)
  raise
end

Amikor egy, minden következő kivételkezeléstől független kivétel váltódik ki, a Ruby az adott kivétel Exception objektumára mutató referenciát helyez el $! globális változóban. Az előző példánkban is használtuk ezt a változót a hibaüzenet megformázásához.

A file zárása és törlése után meghívjuk a raise metódust paraméter nélkül, amely egy $! kivétel kiváltását jelenti. Ez a hasznos technika lehetővé teszi számunkra, hogy magasabb szintre továbbítsuk azon kivételeket, amelyeket nem tudtunk lekezelni. Ez majdnem olyan, mint egy öröklődési hierarchia implementálása hibafeldolgozás céljára.

Egy begin blokkban több rescue kifejezésünk is lehet, és minden egyes rescue több kivételtípust is elkaphat. A rescue kifejezés végén megadhatjuk azt a lokális változót, amelyben az elkapott kivételt tárolni szeretnénk. Sokan ezt olvashatóbbnak tartják a $! folyamatos használatánnál.

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

Hogyan dönti el a Ruby, hogy melyik rescue klózt hajtsa végre? Ez nagyon hasonlít a case utasítás feldolgozására. A Ruby rescue kifejezések közül az első illeszkedésnél végrehajtja az ott meghatározott utasításokat, és leállítja a keresést. Az illeszkedés felderítéséhez a $!.kind_of?(parameter) metódust használja, amely sikeres, ha a paraméter ugyanolyan típusú, mint a kivétel, vagy a kivétel egy őse. Ha egy paraméter nélküli rescue kifejezést adunk meg, akkor a paraméter alapértelmezésként a StandardError.

Ha kiváltott kivétel egyik rescue kifejezéssel sem egyezik, vagy a begin/end blokkon kívül váltódott ki, akkor a Ruby a hivóban keres kivételkezelőt, majd a hívó hívójában, és így tovább.

Habár általában a rescue kifejezések paraméterei az Exception osztályok nevei, lehetnek olyan kifejezések metódushívások is, amelyek egy Exception osztályt adnak vissza.

Rendrakás

Néha garantlálnunk kell azt, hogy egy bizonyos számítás befejeződik egy blokknyi kód végére, függetlenül attól, hogy kiváltódott-e kivétel. Például, ha van egy file megnyitásunk a blokk elején, bizosak akarunk lenni benne, hogy a blokk végére azt le is zárjuk.

Az ensure (biztosítani) kifejezés pontosan ezt teszi. Az ensure a rescue kifejezést követi, és olyan kódrészletet tartalmaz, amely a blokk terminálásakor mindenképp végrehajtódik, függetlenül attól, hogy normális kilépés volt-e, vagy esetleg egy lekezelt, vagy ismeretlen kivétel megjelenése okozta a blokk terminálását.

f = File.open("testfile")
begin
  # .. számítás
rescue
  # .. hibakezelés
ensure
  f.close unless f.nil?
end

Az else kifejezés hasonló, bár kevésbé hasznos konstrukció. Ha van, akkor a rescue és az ensure között a helye. Az else törzse csak akkor kerül végrehajtásra, ha a fő blokk törzse nem váltott ki semmilyen kivételt.

f = File.open("testfile")
begin
  # .. számítás
rescue
  # .. hibakezelés
else
  puts "Gratulálok-- nincs hiba!"
ensure
  f.close unless f.nil?
end

Játsszuk újra

Néha képesnek kell lennünk egy kivétel okának kijavítására. Ezekben az esetekben használhatjuk a retry utasítást egy rescue blokkon belül, és ekkor az egész begin/end blokk újra végrehajtásra kerül. Láthatjuk, hogy itt igen nagy esélyünk van arra, hogy az egész programot belekergessük egy végtelen ciklusba. Szóval ezt a funkciót különös körültekintéssel kezeljük (és egy ujjunkat tartsuk a megszakító gombon...)!

Egy kivétel esetén újra próbálkozó blokk példájaként vegyünk Minero Aoki net/smtp.rb könyvtárából egy részletet.

@esmtp = true
begin
  # Először egy kiterjesztett login-nal próbálkozunk. Ha sikertelen,
  # mert a szerver nem támogatja, akkor normál login-nal
  # próbálkozunk.
  if @esmtp then
    @command.ehlo(helodom)
  else
    @command.helo(helodom)
  end
rescue ProtocolError
  if @esmtp then
    @esmtp = false
    retry
  else
    raise
  end
end

Ez a kód először az EHLO paranccsal próbál az SMTP szerverhez csatlakozni, ami általában nem támogatott. Ha a kapcsolódás hibát jelez, a kód a @esmtp változót false-ra állítja, és újra megpróbál kapcsolódni. Ha ismét sikertelen, akkor kivételt jelez a hívónak.

Kivételek kiváltása

Ezidáig védekező játékot játszottunk, azaz a mások által kiváltott kivételeket próbáltuk lekezelni. Ideje kipróbálni a másik oldalt is.

Kivételeket a Kernel::raise metódus használatával jelezhetünk.

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

Az első forma egyszerűen újra kiváltja az aktuális kivételt (vagy egy RuntimeError-t, ha nincs aktuális kivétel). Ezt kivételkezelőkben használjuk, ha egy kivételt fel kell deríteni, mielőtt továbbhaladunk.

A második forma egy új RuntimeError kivételt hoz létre, és az üzenetét az adott szövegre állítja be. Ezt a kivételt ezután eggyel magasabb továbbítja a hívási veremben.

A harmadik forma első argumentumát egy kivétel létrehozására használja, majd annak üzenetét beállítja a második argumentumban megadott szövegre. Általában az első argumentum vagy Exception hierarchiabeli osztály neve, vagy ezen osztályok egy objektumára mutató referencia. A hívási veremben keresés általában a Kernel::caller metódus használatával történik.

Vegyünk néhány tipikus példát a raise használatára:

raise
raise "Missing name" if name.nil?
if i >= myNames.size
  raise IndexError, "#{i} >= size (#{myNames.size})"
end
raise ArgumentError, "Name too big", caller

Az utolsó példában eltávolítjuk az aktuális rutint a veremből, amely általában könyvtár moduloknál hasznos. De ezt tovább is vihetjük: a következő kód 2 rutint távolít el:

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

Információ hozzáadása kivételekhez

Definiálhatunk saját kivételeket, amelyek tartalmazzák a számunkra fontos információkat egy hiba fellépéséről. Például: bizonyos típusú hálózati hibák - a körülményektől függően - csak pillanatokig tartanak. Ha egy ilyen hiba lép fel, és a körülmények jók, beállíthatjuk a kivételben, hogy a kivételkezelő tudomására hozzuk, a művelet végrehajtását megérné újra megpróbálni.

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

Valahol a kód mélyebb részein egy ilyen pillanatnyi hiba lép fel.

def readData(socket)
  data = socket.read(512)
  if data.nil?
    raise RetryException.new(true), "transient read error"
  end
  # .. normális folyamat
end

A hívási verem magasabb szintjén pedig lekezeljük a kivételt.

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

Dobás és elkapás

Míg a kivételek raise és rescue mechanizmusa megfelelő, ha hiba esetén szeretnénk megszakítani a futtatást, néha azért szebb, ha képesek vagyunk kiugrani egy mélyen beágyazott szerkezetből normális működés közben. Itt lép be a képbe a catch (elkapni) és a throw (dobni).

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

A catch egy adott névvel cimkézett blokkot definiált, amely mindaddig normálisan került futtatásra, amíg egy throw hirtelen meg nem jelenik. A blokk neve lehet String, vagy Symbol (szimbólum).

Amikor a Ruby egy throw-ba ütközik, végigzongorázza a hívási vermet egy megfelelő szimbólummal ellátott catch blokkot keresve. Ha megtalálja, a Ruby visszatörli a vermet az adott pontig, és terminálja az adott blokkot. Ha a throw egy opcionális második paraméterrel kerül meghívásra, akkor annak értéke a catch visszatérési értékeként kerül átadásra. Tehát előző példánkban, ha a bemenet nem tartalmaz megfelelően formázott sorokat, a throw a hozzá tartozó catch végére ugrik, nem csak terminálva ezzel a while ciklust, de kihagyva a dalok listájának lejátszását is.

A következő példában a throw használatával megszakítjuk a felhasználóval való kommunikációt, ha egy !-et ad bármely kérdésre.

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:  ")
  # ..
  # információ feldolgozása
end

Mint az ezen a példán is látszik, a catch-nek nem kell a throw statikus láthatóságán belül lennie.

< Előző oldalKövetkező oldal >