Ohjelmoinnin peruskurssi Y2, kurssimateriaali

2.2. Pythonin poikkeukset

«  2.1. Pythonin perustyyppien tehokasta käyttöä   ::   Etusivulle   ::   2.3. Tehtävä: Intervallit (190 p)  »

2.2. Pythonin poikkeukset

Tästä sivusta:

Pääkysymyksiä: Miten käsitellä poikkeuksia?

Mitä käsitellään?

Suuntaa antava työläysarvio:? 1 tunti.

Esitietokurssilla CSE-A1111 Ohjelmoinnin peruskurssi Y1 käsiteltiin jonkin verran poikkeuksia. Jos perusteet ovat päässeet unohtumaan, voi käydä läpi Y1-kurssin kurssimonisteen luvun 6.1: "Poikkeukset".

Tässä luvussa perehdymme hieman tarkemmin poikkeuksien käsittelyyn. Ulkoisena lähdemateriaalina on

Nämä lähtee kannattaa pitää mielessä ohjelmointitehtäviä ja omaa projektia tehtäessä.

Poikkeukset

Ohjelmassa on lähes aina lukuisia kohtia, joissa yritetty toimenpide voi tavalla tai toisella epäonnistua. Hyvin suunnitellun ohjelman tulisi käyttäytyä jollain tapaa mielekkäästi silloinkin, kun jokin menee pieleen. Ohjelmamme ei saisi “kaatua” kuin äärimmäisessä hädässä.

Poikkeus (engl. exception) on erikoistilanne, joita ohjelmaa suoritettaessa syntyy ja joka vaarantaa ohjelman toiminnan. Suuressa osassa ohjelmointikieliä on jonkinlainen poikkeuksen käsite. Esimerkkejä poikkeukseen johtavista toimista:

  • Yritetään jakaa nollalla, esim. 1 / 0
  • Yritetään käsitellä tyhjän listan tietyssä indeksissä olevaa alkiota, esim list()[0]
  • Yritetään käsitellä olion attribuuttia, jota ei ole määritelty, esim. object().name.
  • Yritetään muuntaa merkkijono luvuksi, mutta merkkijono ei sisälläkään laillista numeroa, esim. int("apina")
  • Yritetään lukea tiedostoa, jota ei ole olemassa tai johon ohjelmallamme ei ole lukuoikeuksia

Mihin poikkeuksia käytetään?

Usein poikkeustilanteeseen ei voida reagoida mielekkäästi juuri siellä (siinä metodissa), missä poikkeustilanne syntyy. Esimerkiksi mitä int() -metodin pitäisi tehdä, jos sen parametri on kelvoton? Tällaisen metodin yleishyödyllisyys laskisi ratkaisevasti, jos se "itse päättäisi". Metodin palautusarvoa voi joskus käyttää (ja käytetäänkin) erikoistilanteiden kuvaamiseen, mutta sillä on rajoituksensa.

Allaoleva metodi palauttaa kokonaislukukokoelman pienimmän arvon suhteessa taulukon suurimpaan arvoon.

def getRatio(l):
  max(l) / min(l)

Mitä tehdään, jos taulukko onkin tyhjä? Entä jos pienin alkio onkin nolla?

Poikkeusten käsittely perustuu siihen, että on mahdollista heittää poikkeus (eli nostaa poikkeus; engl. throw/raise an exception) eli välittää poikkeustilanneilmoitus eteenpäin sellaisen ohjelmakohdan huoleksi, joka määrittelee, miten poikkeustilanteesta selvitään. Ellei metodi voisi heittää poikkeusta, jouduttaisiin erikoistilanteisiin aina joko reagoimaan sen metodin sisällä, jossa ne syntyvät, tai välittämään tieto tilanteesta eteenpäin jollain tarkoitukseen vähemmän luonnollisella tavalla (yleensä metodin palautusarvona).

Poikkeustilanteet kuvataan Pythonissa olioina, mikä ei liene kenellekään yllätys. Se mikä poikkeuksen tarkka tyyppi on riippuu siitä, millaista ongelmaa tai erikoistapausta poikkeus yrittää kuvata. Vaikka "yleisluontoisia" poikkeusolioita olisikin mahdollista luoda suoraan kaikkien poikkeusten yläluokasta BaseException, hukattaisiin silloin tilaisuus kertoa tilanteesta tarkemmin. Tavallisesti onkin mielekkäämpää luoda erikoistuneempi olio jostakin tämän "emäluokan" aliluokasta, jolloin voidaan käyttää poikkeuksen tyyppiä ja sen sisältöä tarkempaan tiedonvälitykseen.

if hommaEiToiminut:
    # Poikkeuksessa on käytännössä aina tekstimuotoinen kuvausviesti siitä mikä meni pieleen
    # Exception-luokasta suoraan luodulla oliolla ei muita mahdollisuuksia siirtää informaatiota olekaan

    juttuXPieleen = BaseException("Juttu X meni pieleen.")

    # Tehdään poikkeusoliolla jotain...

if yhaPielessa:
    # Oma, tai valmis mutta tarkoitukseen sopivampi poikkeustyyppi mahdollistaa tarkemman
    # tiedonvälityksen sekä poikkeuksen tyypissä, että poikkeusolion sisällä.

    selkeammin = ElevatorStuckException("Hissin ovi jumissa", elevatorNum, floor)

Käytännössä poikkeusolioita luodaan lähes aina juuri silloin, kun halutaan ilmoittaa suoritettavan metodin kutsujalle, että metodin suoritus jostain syystä epäonnistui, eli halutaan heittää poikkeus. Tämä kutsuja(metodi) voi sitten päättää, miten suhtautuu asiaan. Poikkeukset muodostavat kommunikointikanavan kutsutulta metodilta kutsuvalle.

Poikkeusten heittäminen

"Nothing travels faster than the speed of light, with the possible exception of bad news, which obeys its own special laws.""

—Douglas Adams, Mostly Harmless

Poikkeuksen heittämiseen on Pythonissa erikseen määritelty heittolause raise. Heittolauseelle annetaan heitettäväksi yksi poikkeusolio. Heittolause keskeyttää metodin normaalin suorituksen välittömästi. Suoritusta jatketaan ensimmäisestä löydetystä ohjelmakohdasta, johon on kirjattu menettely kyseisenlaisten poikkeusten käsittelyyn. Usein tällaista poikkeuksenkäsittelijärakennetta ei löydy samasta metodista heittolauseen kanssa. Tällöin poikkeus heitetään "ulos metodista", eli metodin suoritus keskeytyy kokonaan ja poikkeukselle lähdetään etsimään käsittelijää sitä kutsuneesta metodista. Siihen miten tällainen poikkeuksenkäsittelijärakenne merkitään ohjelmaan, palataan tuossa tuokiossa.

if :hommaEiToiminut
    # Joku antoi meille mahdottoman tehtävän!!
    # Heitänpä poikkeuksen, jotta saavat itse päättää mitä nyt pitäisi tehdä.

    raise Exception("Juttu X meni pieleen.");

    # Yllä luodaan poikkeusolio ja heitetään se saman tien.
    # (Nämä kaksi asiaa tehdäänkin hyvin usein yhdessä.)

    print("Tätä viestiä ei näe kukaan")

    # Näille riveille ei mennä, jos poikkeus heitettiin.
    # Sen sijaan hypätään sellaiseen ohjelmakohtaan,
    # johon on merkitty erityisiä poikkeuksenkäsittelykäskyjä.

Poikkeusten eri lajit

Kuten aiemmin mainittiin, kaikkien poikkeusluokkien yläluokka on BaseException. Sillä puolestaan on välittöminä alaluokkinaan luokat SystemExit, KeyboardInterrupt, GeneratorExit ja Exception. Kolme ensimmäistä näistä eivät kuvaa varsinaisia virheitä, vaan muunlaisia poikkeustilanteinta.

  • Luokan SystemExit olio heitetään, kun järjestelmässä suoritetaan kutsu sys.exit(), jolla on tarkoitus lopettaa ohjelman suoritus (ks. tarkemmin exception SystemExit).
  • Luokan KeyboardInterrupt olio heitetään, kun käyttäjä painaa näppäimistön keskeytysnäppäintä (Control-C tai Delete).
  • Luokan GeneratorExit olio heitetään, kun generaattorin close-metodia kutsutaan.

Luokka Exception ja kaikki sen alaluokat kuvaavat jonkinlaisia virhetilanteita. Käyttäjän luomien poikkeusluokkien tulee periä joko tämä luokka tai joku sen alaluokka. Erilaisten poikkeusluokkien käyttö mahdollistaa sen, että saman toiminnon tuottamat erilaiset poikkeustilanteet voidaan helposti erottaa toisistaan. Esimerkiksi yllä oleva funktio getRatio voi tehdä nollalla jaon, jos pienin alkio on nolla tai vihreellisen viittauksen listan indeksiin, jos lista on tyhjä. On hyvä voida heittää eri poikkeus näistä tilanteista.

Pythonin valmiiksi tarjoamat poikkeukset löytyvät dokumentaatiosta The Python Standard Library: 5. Built-in Exceptions. Poikkeusluokkien väliset suhteet löytyvät kohdasta 5.4. Exception hierarchy.

Kirjastojen valmiit poikkeukset eivät kuitenkaan ole suunniteltuja juuri siihen aihealueeseen tai toteutukseen jota olemme kasaamassa. Kun tarkoitukseen räätälöityä poikkeusluokkaa ei ole valmiina, on sellainen helppo itse periyttää Exception -luokasta (tai jostakin sen aliluokasta). Sellainenkin Exception -luokan aliluokka, joka ei määrittele mitään uusia toimintoja voi hyvin olla perusteltu. Pelkästään tieto siitä, että kyseessä on luokan Exception alityyppi, jota on tarkoitus käyttää tietynlaisissa poikkeustilanteissa, on hyödynnettävissä. Lisäinformaatio siirtyy silloin vain poikkeusluokan tyypissä.

Oma poikkeustyyppi

Pyritään laatimaan luokalle Opiskelija konstruktori, jolle voi antaa parametrina merkkijonon muotoa "Teemu Teekkari 12345" ja joka alustaa opiskelijaolion kentät tästä merkkijonosta saamiensa tietojen perusteella.

Tämä esimerkki on hieman keinotekoinen, mutta voisi kenties olla perusteltavissa esim. jos tiedettäisiin opiskelijaolioita haluttavan luoda sellaisten tekstitiedostojen perusteella, joissa opiskelijoiden tietoja olisi tallennettu riveittäin em. tavalla esitettyinä.

äh... olisi se sittenkin huonoa tyyliä, sillä tällaisia asioita ei tulisi koskaan tehdä konstruktorissa, mutta katsotaan nyt kumminkin

opiskelija = Opiskelija("Teemu Teekkari 12345")
print(opiskelija.opiskelijanumero)

Mitä jos alla esitetty konstruktori saakin parametrinaan vaikkapa "kissa" tai vain "Teemu Teekkari"? Konstruktorillahan ei edes ole palautusarvoa, jota käyttää viestintään. Olisikin varsin erikoista, jos meidän olisi pakko kasata väkisin Opiskelija-olio. Poikkeus kuitenkin keskeyttää konstruktorin toiminnan ja oliota ei synny.

class Opiskelija:
    def __init__(self, tiedot):
        osat = tiedot.split(" ")
        # String-luokassa on split-metodi, joka jakaa merkkijonon
        # osiin käyttäen annettua parametria (tässä välilyöntiä) erottimena
        self.etunimi  = osat[0]
        self.sukunimi = osat[1]
        self.opiskelijanumero = osat[2]

Monisijoitus

class Opiskelija:
     def __init__(self, tiedot):
         # String-luokassa on split-metodi, joka jakaa merkkijonon
         # osiin käyttäen annettua parametria (tässä välilyöntiä) erottimena
         self.etunimi, self.sukunimi, self.opiskelijanumero = tiedot.split(" ")

Metodi split tuottaa paluuarvonaan listan. Sijoitus self.etunimi, self.sukunimi, self.opiskelijanumero = tiedot.split(" ") sijoittaa listan ensimmäisen alkion muuttujaan self.etunimi, toisen alkion muuttujaan self.sukunimi ja kolmannen muuttujaan self.opiskelijanumero. Näppärää, eikö?

Jos Opiskelija-luokan konstruktoria kutsuttaisiin parametrilla "Teemu Teekkari", syntyisi opiskelijanumeroa tallennettaessa poikkeus ValueError("need more than 2 values to unpack"), joka johtuu siitä että taulukon alkiota yritettiin ottaa paikasta, joka on taulukon ulkopuolella. Ei kovin hyödyllistä informaatiota konstruktoria kutsuvalle luokalle, sillä poikkeusta tulkitakseen pitäisi tietää miten konstruktori on toteutettu.

Kehitetään mieluummin tarkoitukseen sopiva poikkeustyyppi, jonka avulla voidaan kuljettaa juuri tämäntyyppiseen poikkeukseen liittyvää tietoa.

class VirheellinenOpiskelijaData(Exception):
     def __init__(self, kuvaus, virheData):
         self.kuvaus = kuvaus       # Virheen kuvaus tekstinä.
         self.virheData = virheData # Tallennetaan kaikkiin tämäntyyppisiin poikkeuksiin
                                    # (varsinaisen kuvausviestin lisäksi) tieto siitä, mikä
                                    # virheen aiheuttanut data-merkkijono oli.

Kuinka Opiskelija-luokkaa sitten pitäisi muuttaa, jotta saamme uuden poikkeuksemme käyttöön?

class Opiskelija:
        def __init__(self, tiedot):
            osat = tiedot.split(" ")
            # Katsotaan aluksi, vaikuttaako data oikealta. Ellei, heitetään poikkeus.
            if len(osat) != 3:
               # Koska samaan metodiin ei ole merkitty rakennetta, joka käsittelisi poikkeuksen, tulee
               # poikkeus heitetyksi ulos kutsujametodille ja konstruktorin suoritus päättyy
               # raise-lauseeseen. (Uutta Opiskelija-oliota ei synny.)
               raise VirheellinenOpiskelijaData("Opiskelijadatassa ei ollut tasan kolmea 'sanaa'.", tiedot )
            # osat sisältää nyt varmasti ne kolme osaa, joita sijoitus etunimeen, sukunimeen ja
            # opiskelijanumeroon kaipaavat. Nämä sijoitukset voidaankin nyt suorittaa
            # turvallisesti tietäen, että dataa riittää. (Mikään ei tietystikään tässä takaa sitä, että
            # annettu data olisi mitenkään semanttisesti järkevää.).
            self.etunimi, self.sukunimi, self.opiskelijanumero = tiedot.split(" ")

Poikkeusten sieppaaminen

Metodi, joka kutsuu poikkeuksen mahdollisesti heittävää metodia, voi ilmoittaa itse hoitelevansa eli sieppaavansa (engl. catch) syntyvät poikkeukset (tai ainakin tietyntyyppiset poikkeukset).

Tämä toteutetaan try-except-lauseella: "yritän tehdä nämä hommat, mutta jos niitä suorittaessa jokin menee pieleen, niin sieppaan poikkeusolion ja katson mitä sillä teen". Jos jostain try-lohkossa kutsutusta metodista heitetään poikkeus, hypätään loppuosa lohkosta ohi ja siirrytään johonkin määritellyistä except-lohkoista (ks. alla oleva koodiesimerkki). Jos try-lohkon suoritus onnistuu mutkitta, ei mitään except- lohkoa suoriteta.

Except-lohkoja voi olla usealle eri poikkeustyypeille. Usein yksikin riittää. Kun jonkin lohkoista loppu saavutetaan, jatkuu ohjelman suoritus tavalliseen tapaan try-except-lauseen jälkeisiin lauseisiin. Vain yksi lohko siis suoritetaan.

Mutta mikä except-lohko valitaan, kun try-lohko tuottaa poikkeuksen? Python käy läpi except-lohkoja alkaen ensimmäisestä ja suorittaa ensimmäisen lohkon, jonka luokka on heitetyn poikkeuksen luokka tai yläluokka. Jos heitetty poikkeus ei ole minkään except-lohkon luokan (tai sen alaluokan) ilmentymä ja on määritetty except-lohko ilman poikkeusluokkaa, suoritetaan tämä. Tällainen kaiken nappaava except-lohko on vaarallinen, koska se voi peittää käytännössä minkä tahansa virheen.

Pari lisäviritystä on vielä tarjolla. Except-lohkojen jälkeen voidaan haluttaessa määrittää else -lohko, joka suoritetaan vain, jos try-lohkossa ei synny poikkeusta. Lopuksi voidaan määrittää finally -lohko, joka suoritetaan aina, tuotti try-lohko poikkeuksen tai ei. Tämä on näppärä tapa vapauttaa resursseja, jotka on varattu try-lohkossa ennen mahdollista poikkeuksen heittämistä.

try:
    # Koodia, jonka ainakin yhdestä kohdasta kutsutaan poikkeuksen mahdollisesti heittävää metodia.
except SomeException as e:
    # Koodia, joka suorittamalla reagoidaan luokan SomeException (tai sen alaluokan) poikkeukseen.
    # Muuttujaan e tulee tarjolle poikkeusolio, jota tässä koodissa voidaan käyttää.
except SomeOtherException:
    # Koodia, joka suorittamalla reagoidaan tähän toiseen poikkeustyyppiin.
    # Tässä esimerkissä ei sidota poikkeusoliota muuttujaan.
except:
    # Sieppaa kaikki poikkeukset, joita edeltävät except-lohkot eivät sieppaa.
    # Poikkeusten sieppaaminen ilman luokkaa on TODELLA huono idea, sillä
    # se vain piilottaa vielä tuntemattomat virheet. Jos ohjelmasi vaikka jakaa nollalla
    # et huomaisi sitä
else:
    # Jos halutaan koodin tekevän jotain vain siinä tapauksessa, että try-lohko ei heitä poikkeusta,
    # voidaan käyttää else-lohkoa.
finally:
    # Jos Finally-lohko on määritetty, suoritetaan se joka tapauksessa, tuli poikkeuksia tai ei.
    # Finally-lohko on kätevä tapa "siivota jäljet", esim. vapauttaa resursseja, joita try-lohko on varannut.

Mitä jos except-lohko tuottaa poikkeuksen?

Except-lohkon tuottama poikkeus käsitellään koko try-except -lauseen ulkopuolella. Huomaa kuitenkin, että mahdollinen finally-lohko suoritetaan ennen tätä.

Mitä jos try-except-lauseen sisällä on return?

Jos finally-lohkoa ei ole määritetty, on toiminta suoraviivaista. Jos try-lohkossa suoritetaan return, poistutaan (lähimmästä) ympäröivästä funktiosta sinne, missä funktiota kutsuttiin. Samoin käy, jos return suoritetaan except-lohkossa. Jos finally-lohko on määritetty suoritetaan se kuitenkin ennen poistumista. Finally-lohkon mahdollisesti sisältämä return muuttaa paluuarvoa.

Esimerkki

def strange(n):
    try:
        a = x/n
        return "Try"
    except:
        return "Except"
    finally:
        return "Finally"

Kutsuttiin funktiota strange millä parametrilla tahansa, mukaanlukien nolla, palauttaa se aina arvon "Finally".

Poikkeuksen heittäminen eteenpäin

Jos poikkeusta ei oteta jossakin metodissa kiinni, se jatkaa matkaansa kutsupinossa seuraavaan metodiin. Kaikkien metodien jotka eivät ota poikkeusta kiinni, suoritus päättyy. Jos poikkeus heitetään koko ohjelmasta ulos koko ohjelman suoritus lakkaa ja Python tulostaa kuvauksen poikkeuksesta. Tähän ei tulisi kuitenkaan päätyä, vaan ohjelman pitäisi käsitellä syntyvät poikkeukset.

Poikkeusten ketjutus

Ensimmäisessä Opiskelija-luokkamme versiossa joka käytti apunaan Array-luokan extractoria merkkijonon osien määrää ei tarkistettu. Tällöin Array-luokan extractor olisi heittänyt MatchErrorin. Nimestään huolimatta kyse ei ole vakavasta virheestä, vaan olisimme voineet kaapata tämän poikkeuksen ja heittää uuden poikkeuksen kaappaamamme perusteella.

Haluttaessa voidaan kertoa että jonkin poikkeuksen aiheutti toinen poikkeus.

class Opiskelija:
     def __init__(self, tiedot):
         try:
            # String-luokassa on split-metodi, joka jakaa merkkijonon
            # osiin käyttäen annettua parametria (tässä välilyöntiä) erottimena
            self.etunimi, self.sukunimi, self.opiskelijanumero = tiedot.split(" ")
         except ValueError e:
            uusi = VirheellinenOpiskelijaData("Opiskelijadatassa ei ollut tasan kolmea 'sanaa'.", tiedot )
            raise uusi from e

Yhteenvetoa

  • Tutustuttiin poikkeuksiin: poikkeusolioihin, poikkeusten heittämiseen ja kaappaamiseen

Palaute

«  2.1. Pythonin perustyyppien tehokasta käyttöä   ::   Etusivulle   ::   2.3. Tehtävä: Intervallit (190 p)  »