Olen törmännyt nyt pariinkin otteeseen ohjelmoinnin tyyliohjeisiin tms., jossa on poikkeusten käytöstä suunnilleen tällainen suositus:
"Poikkeukset ovat poikkeuksellisia tilanteita varten. Poikkeuksia ei pidä heittää tilanteissa, jotka ovat odotettavissa: ne ovat sellaisia tilanteita varten, jotka eivät todennäköisesti koskaan toteudu."
Näin jälkeenpäin ajateltuna tämä on tosi outo kanta, koska yleensä poikkeuksen heittäjä ei voi tietää, onko tilanne odotettu vai ei. Kutsuja sen sijaan yleensä tietää, jos kutsun "pitäisikin" aiheuttaa poikkeus. Niinpä poikkeusten heittämiseen pätee omat sääntönsä (alla), mutta niitä ei välttämättä pitäisi aiheuttaa tahallisesti.
Näin tämän mielipiteen viimeksi Brian Kerninghanin kirjassa Käytännön ohjelmointi, sitä ennen mm. wikissä ja muuten arvostamassani kirjassa The Pragmatic Programmer. (Tätä jälkemmäistä kirjaa muuten suosittelen lämpimästi kaikille, jotka haluavat kehittyä ohjelmoijina.)
Poikkeuksilla on kaksi ominaisuutta, jotka asettavat ne erilleen muista ohjelmarakenteista:
Poikkeusten käyttöä yritetään rajoittaa siksi, että jälkemmäinen ominaisuus mahdollistaa toimintalogiikan, josta ei ole jälkeäkään välittävässä funktiossa. Tällöin poikkeuksen saa jättää ottamatta kiinni vain, jos välittävän funktion ei oikeasti pitäisikään tietää siitä mitään. Jos tätä vielä vähän tiukennetaan, poikkeuksia saa käyttää vain asioihin, joiden käsittely on yleisen "voi ei, maailma on rikki" -käsittelijän hommaa.
Tässä ei ole mielestäni mitään järkeä. Poikkeusten ominaisuudet tekevät niistä hyödyllisiä ja käyttökelpoisia moneen asiaan, eivät vain katastrofitilanteisiin. Sitä paitsi kaikenlaisista nyrkkisäännöistä huolimatta on kyseenalaista, mikä on todella poikkeuksellista. Välittävän funktion ongelmaa voi helpottaa kertomalla (kielen konstruktioilla tai kommenteilla), mitä ohjelman normaalitoimintaan liittyviä poikkeuksia mikin funktio heittää. Yhden funktion heittämien erilaisten poikkeusten määrä pitää kyllä pitää aisoissa, ja huolehtia siitä, että näiden poikkeusten nimet vastaavat niiden merkitystä kunkin funktion kontekstissa. (Esimerkiksi jonoluokka ei saa antaa minkään taulukon indekseihin viittaavan perkoloitua jonon ollessa tyhjä; sen pitää ottaa kyseinen poikkeus kiinni ja nostaa oma, joka kertoo, että jono on tyhjä.)
Ylipäänsä poikkeuksissa usein ylikorostetaan niiden kaskadiominaisuuksia (eli toimintavuota, kohta 2 yllä) ja alikorostetaan niiden tietomallia (kohta 1 yllä). Semanttisestihan poikkeusten käyttö oikeastaan vastaa sitä, että funktion palautustyyppi on unioni sen "normaalista" palautustyypistä sekä muista mahdollisista palautuksista, jotka hoidetaan poikkeuksin. Poikkeukset kehitettiin alun perin, jotta vältyttäisiin käyttämästä erityisiä unionipalautustyyppejä, joissa yksi erityinen palautusarvo (tai useampi) tarkoittaa virhetilannetta.
Minun kantani on, että poikkeuksia voi ja pitää käyttää kaikissa tilanteissa, joissa on tällainen asymmetrinen palautusarvojen liitto - varsinkin kielissä, joissa unionityyppien käsittely aiheuttaa ylimääräistä vaivaa. Esimerkiksi funktio, joka etsii taulukosta tietyn alkion indeksin, voisi joko palauttaa jonkin dummy-arvon, esim. -1, jos alkiota ei löydy taulukosta ollenkaan, tai heittää poikkeuksen. Dummy-arvojen käyttö on hyväksyttävää vain, jos niiden käyttö on standardisoitu ja dokumentoitu koko ohjelman laajuisesti: esimerkiksi, jos indeksi -1 tarkoittaa jokaisessa taulukon indeksejä hyväksyvässä tilanteessa "ei mikään indeksi" - mutta mitä tällöin on tuloksena, jos taulukosta haetaan alkio -1? Poikkeus, vihdoinkin?
Toinen klassinen esimerkki on tiedoston avaaminen, jonka epäonnistuminen voi olla "poikkeuksellista" tai olla olematta riippuen siitä, mikä tiedosto on kyseessä. Pitäisikö tiedoston avaavan funktion heittää poikkeus? Vastaukseni on: kyllä vain, mikäli vaihtoehto on palauttaa jotain roskaa, esim. null, ellei tiedoston avaaminen onnistu. Ei ole mitään keinoa keksiä null:lle järkevää semantiikkaa jokaisessa tiedostoihin liittyvässä tilanteessa.
Mitä merkkivirran lukemiseen tarkoitetun funktion pitäisi tehdä merkkivirran loppuessa? Merkkivirran loppuminen ei todellakaan ole poikkeuksellista siinä mielessä, että jokainen merkkivirta loppuu aikanaan. Kuitenkin jälleen, jos vaihtoehtona on vaikkapa unionityyppi, jokin epämääräinen merkki-dummy-arvo (˙?) virran ehtymisen kertomiseksi tai funktio "onko-loppu?", jota pitää kutsua joka kerta ennen virrasta lukemista, poikkeus on oikea ratkaisu. onko-loppu?-funktion voi toki kirjoittaa, vaikka poikkeuksia käytettäisiinkin.
Jopa, vaikka dummy-arvoilla kuten nulleilla ja -1:llä olisi ohjelman laajuinen vakiinnutettu merkitys, funktion tulee heittää poikkeus eikä palauttaa dummy-arvoa, mikäli ei ole kuviteltavissa tilannetta, jossa kutsuva funktio ei haluaisi tarkistaa, onko saatu dummy-arvo vai ei. Ainoa oikeutettu syy käyttää dummy-arvoja on se mahdollisuus, että ohjelma yksinkertaistuu jossain kohtaa siksi, että palautusarvosta ei tarvitse tietää, onko se aito arvo vai dummy-arvo. (Joskus dummy-arvojen käyttö voi yksinkertaistaa ohjelmaa huomattavasti.)
Mainitsin asymmetrian poikkeusten käyttämisen edellytyksenä, sillä kaikissa tilanteissa ei ole selvää, mikä on funktion "normaali" tulos ja mikä "poikkeuksellinen". Jos funktiolla on monia järkeviä, mutta eri tyyppisiä palautusarvoja (harvinainen tilanne), kannattaa käyttää aitoa unionityyppiä. Mikäli asiakaskoodi joutuu palautusarvosta johtuen tekemään erilaisia asioita, oliopohjaisten ihanteiden mukaista olisi siirtää koko toiminnallisuus tietotyyppien ominaisuudeksi, yhdeksi virtuaalimetodiksi.
Poikkeusten pitkiin kaskadeihin liittyy ongelma, joka vaikeuttaa ohjelmointivirheiden paikantamista. Jos nimittäin teet kaksi kuprua, esimerkiksi Pythonissa ensiksi:
try: nprocs = int( getOption( "ProcessCount" )) except ValueError: warn( "number of processes option has non-numeric value" ) warn( "(defaulting to one process)" ) nprocs = 1
ja toiseksi olet unohtanut napata kiinni ValueErrorin jossain osassa getOption:a tai sen alifunktioita, alitason ValueErrorin nappaa "väärä käsittelijä". Huomaa, että tässä rikotaan yhtä yllä mainittua suunnittelusääntöä: getOption päästää maailmalle poikkeuksen, jolla ei ole (oikeaa) semanttista merkitystä sen kontekstissa. Joka tapauksessa tällaisten ongelmien välttämiseksi on monille kielille olemassa poikkeusvuotyökaluja (ja poikkeusdeklaraatioita), joiden käyttämättömyys tai puuttuminen on vakava ongelma poikkeusohjelmoinnissa.
Loppujen lopuksi poikkeukset ovat poikkeuksellisia vain muutamalla tavalla. Ensinnäkin siinä mielessä, että ne edustavat prosessoinnin "pääsuunnasta" poikkeavia vaihtoehtoja. Poikkeusten alkuperäinen tarkoitus oli nostaa rutiinin päätehtävän eteneminen esiin kaikenlaisten harvinaisempien tapausten keskeltä, jotta saisi selkeän kuvan siitä, mitä rutiinin on oikeasti tarkoitus tehdä. Olkoot ne siinä tehtävässä edelleenkin.
Toisekseen poikkeukset liittyvät nimeämiseen (josta lisää: http://sange.fi/~atehwa/scheme-kurssi/algoritmisuunnittelu.html): koska funktion nimen tulee kuvata sen palautusarvoa (ja sen suhdetta argumentteihin), poikkeuksia tulee käyttää aina, kun funktio ei pysty palauttamaan nimensä mukaista arvoa. Sopimusohjelmointiin tykästyneet ihmiset saattaisivat sanoa, että poikkeuksia käytetään, kun funktio ei pysty toteuttamaan sopimustaan, mutta itse asiassa mahdolliset poikkeukset ovat osa funktion sopimusta.
Katso myös:
Poikkeuksia tapaa olla kahta tyyppiä: niitä, jotka otetaan kiinni heti (tai enintään pari tasoa ylempänä), ja niitä, joita ei oteta kiinni ollenkaan tai jotka käsitellään ylimmän tason poikkeuskäsittelijässä, joka on tyypillisesti jonkinlainen yleiskäsittelijä. Nämä ovat molemmat ongelmattomia poikkeustyyppejä. Poikkeukset-ovat-vain-odottamattomiin-tilanteisiin-filosofia tunnustaa niistä vain jälkemmäisen. Lisäksi on jonkin verran ongelmallinen välimuoto, jossa poikkeus heitetään jonkin ohjelman osan alatasoilta ylätasolle -- esimerkiksi parseri virhetilanteessa tai säie lopettaessaan toimintansa. Tällaiset käytötkin ovat järkeviä, mutta syytä dokumentoida tarkoin.
Jari: Jos funktiokutsussa vallitsee "asymmetrinen palautusarvojen liitto", niin eikö silloin periaatteessa ainoa lopullinen ratkaisu olisi unohtaa kutsut (call), ja siirtyä viestien välittämiseen (message passing), joka on jostain syystä vielä harvinainen tapa ohjelmien viestinnässä.
atehwa: eikös message passing ole ainakin Smalltalkissa täsmälleen samaa funktiokutsujen kanssa? Mutta funktiolle voi toki antaa "jatkofunktioita", joista funktio kutsuu jotakin sen mukaan, millaista haluaa palauttaa:
def f(teksti, kuva, jatko1, jatko2): if palautetaanko_teksti(teksti, kuva): return jatko1(teksti) else: return jatko2(kuva)
Tämä on kuitenkin mielestäni hyvä ajatus nimenomaan, jos palautusarvo on symmetrinen tyyppien liitto, eli eri tyypit eivät ole olennaisesti erilaisessa asemassa funktion palautuksen kannalta.