Helsingin yliopisto / Tietojenkäsittelytieteen laitos / 581258-1 Johdatus ohjelmointiin
Copyright © 1997 Arto Wikla. Tämän oppimateriaalin käyttö on sallittu vain yksityishenkilöille opiskelutarkoituksissa. Materiaalin käyttö muihin tarkoituksiin, kuten kaupallisilla tai muilla kursseilla, on kielletty.

5.1 Poikkeuksista ja lukemisesta

(korjattu 30.11.2004, lisätty kohta "Virheiden käsittelyä" 25.11.2004, luokka PieniSuuri korjattu 6.3.2003, edellinen muutos 3.12.1997)

Tässä luvussa tutustutaan syöttötietojen lukemisen yhteen tekniikkaan. Samalla nähdään pieni esimerkki kielen valmiiden välineiden käyttötapojen etsimisestä: Lukemisen toteuttaminen esitetään yksityiskohtaisena tarinana etsimisestä ja löytämisestä.

Poikkeusten käsittelyyn tutustutaan hyvin pintapuolisesti ja lähinnä vain lukuoperaatioiden toteuttamisessa.

Poikkeuksista

Poikkeukset (exceptions) ovat "poikkeuksellisia tilanteita" kesken normaalin ohjelmansuorituksen: tiedosto loppuu, merkkijono ei kelpaa kokonaisluvuksi, odotetun olion tilalla onkin null-arvo, taulukon indeksi menee ohjelmointivirheen takia sopimattomaksi, ...

Yksi tapa suhtautua tuollaisiin virheisiin on antaa käyttöjärjestelmän keskeyttää ohjelman suoritus (Pascal), toinen tapa on antaa ohjelman jatkaa virheen jälkeen kohti mahdollista katastrofia (C). Kolmas tapa on varustaa ohjelmoija välinein, joilla hän voi halutessaan itse ohjelmoida poikkeustilanteiden käsittelyn.

Javassa on monipuoliset välineet poikkeusten käsittelemiseen. Kieleen on määritely paljon valmiita poikkeuksia, esimerkiksi ArithmeticException, ArrayIndexOutOfBoundsException, ClassNotFoundException, NegativeArraySizeException, NullPointerException, NumberFormatException, FileNotFoundException, ...

Ohjelmoija voi myös laatia omia poikkeuksia erityisiin tarpeisiinsa. Poikkeukset ovat tietenkin luokkia nekin, mikäpä Javassa ei olisi ... Poikkeusten yliluokka on Exception.

Joihinkin poikkeuksiin ohjelmoijan on pakko varautua (checked exceptions), joidenkin poikkeusten tapahtumisen annetaan oletusarvoisesti kaataa ohjelman suoritus (unchecked exceptions).

Tiedostojen lukemiseen liittyvät poikkeukset kuuluvat edelliseen ryhmään. Ja esimerkiksi taulukon virheellinen indeksointi jälkimmäiseen.

Unchecked-poikkeukset ovat RuntimeException- tai Error-luokan tai jonkin niiden aliluokan ilmentymiä. Näihin kuuluu mm. juuri tuo hyvin tutuksi tullut ArrayIndexOutOfBoundsException.

Kun käytetään metodia, joka voi aiheuttaa "checked exception" -poikkeuksen, ohjelmoijan on joko ilmoitettava metodinsa otsikossa, että poikkeus voi tulla ("throws clause") tai itse käsiteltävä mahdollinen virhe try-catch-lauseella.

Poikkeustilanteen syntymistä kutsutaan poikkeuksen aiheuttamiseksi (throw), sen käsittelemistä poikkeuksen sieppaamiseksi (catch).

Poikkeus siepataan try-catch -lauseella:

   try {
      ... Yritetään jotakin, joka voi aiheuttaa 
          poikkeuksen Poikkeuksen_tyyppi.
          Jos poikkeusta ei aiheuteta, try-osa
          suoritetaan loppuun ja jätetään 
          catch-osa väliin.
   }
   catch (Poikkeuksen_tyyppi poikkeuksen_nimi) {
     ... Käsitellään siepattu poikkeus: 
         try-osa siis aiheutti poikkeuksen
         Poikkeuksen_tyyppi.
   }

Try-catch -lauseessa voi olla useita catch-osia erilaisten poikkeusten sieppaamiseen. Lauseessa voi olla myös finally-osa, joka suoritetaan joka tapauksessa try-catch -lauseen lopuksi (jopa silloin, kun on lopetettu break- tai return-lauseeseen!).

Tarkastellaan esimerkkiä, jossa taulukon virheellinen indeksointi voi aiheuttaa poikkeuksen ArrayIndexOutOfBoundsException (TaulEx.java):

public class TaulEx {

  public static void main(String[] args) {
    int[] t = {9,8,7,6,5,4,3,2,1,0};

    boolean ok;
    do {
      try {
        System.out.println("Anna luku 0-9 taulukon indeksiksi");
        int i = Lue.kluku();
        System.out.println(t[i]);
                          // ^ tässä voi tulla virhe!
        ok = true;
      }
      catch (ArrayIndexOutOfBoundsException e) {
        System.out.println("Kelvoton indeksi, pitää olla 0-9");
        ok = false;
      }
    } while (!ok);
  }
} 

Tämä olisi toki järkevämpää ohjelmoida tavanomaiseen tapaan tarkistamalla indeksi if-lauseella!

Tarina: "Miten rivejä oikein luetaan?"

Olemme tulostaneet standarditulostusvirtaan monentyyppisiä tietoja luokan System (pakkauksessa java.lang) kentän out avulla. Tuo kenttä on määritelty:
public static final PrintStream out;
Luokassa PrintStream on määritelty kuormitettuja ja helppokäyttöisiä tulostusmetodeita print ja println.

Luokassa System on määritelty myös standardisyöttövirta kenttänä in:

public static final InputStream in;
InputStream-luokka on puolestaan melko köyhä lukemismetodeista: välineitä löytyy vain tavu kerrallaan lukemiseen.

Mitä tehdä, jos haluaisikin lukea rivin kerrallaan?

Java-henkinen ratkaisu on etsiä luokkaa, jossa on metodi rivin lukemiseen. Sellainen löytyykin: luokassa BufferedReader on metodi readLine().

Ongelma tulee siitä, että BufferedReader-luokalla ei ole konstruktoria, jolle voisi antaa kentän in parametriksi! Konstruktorin muodollisen parametrin tyyppi on abstrakti luokka Reader, eikä todelliseksi parametriksi voi antaa InputStream-oliota, koska InputStream on Object-luokan välitön aliluokka.

Ratkaisu löytyy luokkana InputStreamReader:

  1. Se on luokan Reader aliluokka, joten se kelpaa parametriksi luotaessa BufferedReader-oliota.
  2. Sillä itsellään on konstruktori, jonka parametri on tyyppiä InputStream

Ja näin BufferedReader-tyyppinen syöttövirtaolio voidaan asettaa muuttujan arvoksi lauseella:

  BufferedReader stdin = 
      new BufferedReader(new InputStreamReader(System.in));
Ja nyt rivejä voi lukea String-muuttujaan:
  String jono = stdin.readLine();

Lukeminen ja poikkeukset

Tietojen lukemiseen liittyvät poikkeukset ovat siis niitä, joiden käsittelyyn ohjelmoija joutuu ottamaan kantaa ("checked exceptions") joko ilmoittamalla, että poikkeus voi tulla (throws ...) tai sieppaamalla tuon poikkeuksen (try-catch).

Seuraavissa esimerkeissä huomattavaa:

Esimerkki 1: annetaan virheen tapahtua

Laaditaan sovellus, joka lukee ja tulostaa rivin, kokonaisluvun ja liukuluvun. Pääohjelmametodin alussa ilmaistaan throws-ilmauksella, että metodi voi aiheuttaa jonkin poikkeuksen Exception.

Tässä siis tavallaan siis annetaan ohjelmalle lupa kaatua!

Huom: Jos kyseessä on kutsuttava metodi, kutsuvan metodin täytyy puolestaan käsitellä poikkeus: joko omalla throws-ilmauksella tai try-cath -lauseella!

Ohjelma lukee merkkijonon, kokonaisluvun ja liukuluvun (SyottoKokeita.java):

import java.io.*;

public class SyottoKokeita {

  public static void main(String[] args)
                          throws Exception  {

    BufferedReader stdin
        = new BufferedReader(
                 new InputStreamReader(System.in)
              );

    String jono;
    System.out.print("Anna rivi: ");
    System.out.flush();
    jono = stdin.readLine();
    System.out.println("Se oli: "+jono);

    System.out.print("Anna kokonaisluku: ");
    System.out.flush();
    jono = stdin.readLine();
    int luku = Integer.parseInt(jono);
    System.out.println("Se oli: "+luku);

    System.out.print("Anna desimaaliluku: ");
    System.out.flush();
    jono = stdin.readLine();
    double dluku = new Double(jono).doubleValue();
    System.out.println("Se oli: "+dluku);

  }
}

Kun virheitä ei tule, ohjelman suoritus näyttää seuraavalta:
Anna rivi: Kissa kävelee ...
Se oli: Kissa kävelee ...
Anna kokonaisluku: 321
Se oli: 321
Anna desimaaliluku: 12.34
Se oli: 12.34

Jos ohjelmalle syötetään virheellinen kokonaisluku 1.2, saadaan tulostus:
Anna rivi: Kissa kävelee ...
Se oli: Kissa kävelee ...
Anna kokonaisluku: 1.2
java.lang.NumberFormatException: 1.2
        at java.lang.Integer.parseInt(Integer.java)
        at java.lang.Integer.parseInt(Integer.java)
        at SyottoKokeita.main(SyottoKokeita.java:22)

Virheellinen desimaaliluku "kissa" aiheuttaa tulostuksen:
Anna rivi: Kissa kävelee ...
Se oli: Kissa kävelee ...
Anna kokonaisluku: 321
Se oli: 321
Anna desimaaliluku: kissa
java.lang.NumberFormatException: kissa
        at java.lang.Double.(Double.java)
        at SyottoKokeita.main(SyottoKokeita.java:28)

Kokeillaan sitten antaa tiedostonloppu (ctrl-d) heti aluksi. Saadaan:
Anna rivi: Se oli: null
Anna kokonaisluku: java.lang.NumberFormatException: null
        at java.lang.Integer.parseInt(Integer.java)
        at java.lang.Integer.parseInt(Integer.java)
        at SyottoKokeita.main(SyottoKokeita.java:22)

Tästä opitaan, että tiedoston loputtua stdin.readLine()-metodi palauttaa arvon null, tilanne ei ole poikkeus! Mutta null ei kelpaa Integer.parseInt(String)-metodille parametriksi.

Esimerkki 2: siepataan poikkeukset

Laaditaan ohjelma, joka lukee kokonaisluvun ja liukuluvun. Mikä tahansa poikkeus (Exception e) siepataan ja kerrotaan, mikä siepattiin (e). Luovutetaan, jos ei saada kunnon kokonaislukua. Liukuluvun kanssa ollaan sitkeitä.

Huom: Poikkeuksilla on omat toString()-metodinsa, joten niiden tulostaminen onnistuu vaivatta!

Huom: Varaudutaan myös tiedoston päättymiseen: kts. toistoehto. Miten käy ilman testiä jono!=null?

(PoikkeusKokeita.java):

import java.io.*;

public class PoikkeusKokeita {

  public static void main(String[] args) {

    
    BufferedReader stdin
       = new BufferedReader(new InputStreamReader(System.in));

    String jono=null;

    try {
        System.out.print("Anna kokonaisluku: ");
        System.out.flush();
        jono = stdin.readLine();
        int uluku = Integer.parseInt(jono);
        System.out.println("Se oli: "+uluku);
    } catch (Exception e) {
        System.out.println("Virhe: "+e);
    }


    boolean ok;
    do {
      try {
          System.out.print("Anna desimaaliluku: ");
          System.out.flush();
          jono = stdin.readLine();
          double udluku = new Double(jono).doubleValue();
          System.out.println("Se oli: "+udluku);
          ok = true;
      } catch (Exception e) {
        System.out.println("Virhe: "+e);
        ok = false;
      }
    } while (!ok && jono!=null);
  }
} 

Kun annetaan virheellinen kokonaisluku 12.3, ohjelma tulostaa virheilmoituksen ja jatkaa kyselemällä desimaalilukua:
Anna kokonaisluku: 12.3
Virhe: java.lang.NumberFormatException: 12.3
Anna desimaaliluku:
Annetaan nyt desimaaliluvuksi ensin "kissa" ja sitten desimaaliluku 12.3:
Anna desimaaliluku: kissa
Virhe: java.lang.NumberFormatException: kissa
Anna desimaaliluku: 12.3
Se oli: 12.3

Kokeillaan kokonaisluvun sijaan tiedoston loppumista (ctrl-d):
Anna kokonaisluku: Virhe: java.lang.NumberFormatException: null
Anna desimaaliluku: Virhe: java.lang.NullPointerException

Ja lopuksi vielä annetaan kelvollinen kokonaisluku ja tiedoston loppumerkki:
Anna kokonaisluku: 123
Se oli: 123
Anna desimaaliluku: Virhe: java.lang.NullPointerException


Esimerkki 3: varaudutaan erilaisiin virheisiin

Laaditaan sovellus liukuluvun lukemiseen. Varaudutaan erilaisiin virheisiin. Jatketaan luvun pyytelyä vain silloin, kun lukutarjokas on virheellinen. Tiedoston loppumiseen ja "yleiseen IO-virheeseen" lopetetaan. (UPoiKo.java):
import java.io.*;

public class UPoiKo {

  public static void main(String[] args) {

    BufferedReader stdin
      = new BufferedReader(new InputStreamReader(System.in));

    boolean loppu=false;

    do {
      try {
          System.out.print("Anna desimaaliluku: ");
          System.out.flush();
          String jono = stdin.readLine();
          double udluku = new Double(jono).doubleValue();
          System.out.println("Se oli: "+udluku);
          loppu = true;
      } catch (NumberFormatException e) {
        System.out.println("Virhe: ei kelpaa numeroksi");
      } catch (NullPointerException e) {
        System.out.println("Virhe: tiedosto loppui");
        loppu = true;
      } catch (IOException e) {
        System.out.println("Virhe: jotain mätää tiedostossa");
        loppu = true;
      }
    } while (!loppu);
  }
}

Jos ohjelmalle syötetään kissa, koira ja tiedostonloppu (ctrl-d), saadaan:
Anna desimaaliluku: kissa
Virhe: ei kelpaa numeroksi
Anna desimaaliluku: koira
Virhe: ei kelpaa numeroksi
Anna desimaaliluku: Virhe: tiedosto loppui


Esimerkki 4: merkkimuunnoksia

Laaditaan ohjelma, joka lukee merkkijonoja ja tulostaa ne siten, että isot kirjaimet korvataan pienillä ja pienet isoilla. Käytetään merkkitaulukkoa ja luokan Character metodeita isLowerCase(char), isUpperCase(char), toUpperCase(char) ja toLowerCase(char). (PieniSuuri.java):
import java.io.*;

public class PieniSuuri {

  public static void main(String[] args) {

  BufferedReader stdin
    = new BufferedReader(new InputStreamReader(System.in));

    System.out.println(
       "Pienet suuriksi ja suuret pieniksi (ctrl-d lopettaa)");

    for (boolean loppu=false; !loppu; ) {
      try {
          System.out.print("Anna muutettava: ");
          System.out.flush();
          String jono = stdin.readLine();
          char[] taulu = jono.toCharArray();
          for (int i=0; i<taulu.length; ++i) {
            if (Character.isLowerCase(taulu[i]))
              taulu[i] = Character.toUpperCase(taulu[i]);
            else if (Character.isUpperCase(taulu[i]))
              taulu[i] = Character.toLowerCase(taulu[i]);
          }
          System.out.println("Muutettuna:      " +
                              new String(taulu));
      } catch (Exception e) {
        System.out.println("\n\nSelvä, työt on tehty\n");
        loppu = true;
      }
    }
  }
}


Esimerkkisuoritus:
Pienet suuriksi ja suuret pieniksi (ctrl-d lopettaa)
Anna muutettava: Agricolan ABC-kiria
Muutettuna:      aGRICOLAN abc-KIRIA
Anna muutettava: Hyvää Päivää Rouva Presidentti!
Muutettuna:      hYVÄÄ pÄIVÄÄ rOUVA pRESIDENTTI!
Anna muutettava:

Selvä, työt on tehty


Esimerkki 5: luokka Lue

Lukemisen helpottamiseksi laadittu luokka Lue on nyt helppo ymmärtää (Lue.java):

Huom: Luokka on hieman virheellinen! Luokan kehittely-yrityksistä tullee harjoitustehtävä


import java.io.*;
public class Lue {
/***************************************************************************
Lukurutiinit Johdatus ohjelmointiin -kurssille syksyllä 1997   Arto Wikla
         TÄMÄ ON JAVAN VERSIOLLE 1.1.3

Operaatiot:   Lue.rivi()    antaa seuraavan syöttörivin    (String)
              Lue.kluku()     "      "      kokonaisluvun  (int)
              Lue.dluku()     "      "      desimaaliluvun (double)
              Lue.merkki()  antaa seuraavan syöttörivin ensimmäisen merkin

Operaatiot ovat sitkeitä, ne VAATIVAT kelvollisen syötteen!
***************************************************************************/
  static BufferedReader stdin =
     new BufferedReader(new InputStreamReader(System.in));

  public static String rivi() {
    String arvo=null;
    boolean ok;
    do {
      try {
        arvo = stdin.readLine();
        ok = true;
      } catch (Exception e) {
        System.out.println("Virhe rivin lukemisessa. Anna uusi!");
        ok = false;
      }
    }
    while (!ok);
    return arvo;
  }
/**************************************************************************/
  public static int kluku() {
    int arvo=-1;
    boolean ok;
    do {
      try {
        arvo = Integer.parseInt(stdin.readLine());
        ok = true;
      } catch (Exception e) {
        System.out.println("Kelvoton kokonaisluku. Anna uusi!");
        ok = false;
      }
    }
    while (!ok);
    return arvo;
  }
/**************************************************************************/
  public static double dluku() {
    double arvo=-1;
    boolean ok;
    do {
      try {
        arvo = new Double(stdin.readLine()).doubleValue();
        ok = true;
      } catch (Exception e) {
        System.out.println("Kelvoton desimaaliluku. Anna uusi!");
        ok = false;
      }
    }
    while (!ok);
    return arvo;
  }
/**************************************************************************/
  public static char merkki() {
    String rivi = rivi();
    try {
      return rivi.charAt(0);
    } catch (Exception e) {
      return ' ';
    }
  }
}


Virheiden käsittelyä

(Lisäys 25.11.2004)

Virhetilanteita, esimerkiksi virheellisiä parametrien arvoja on kurssin esimerkkiohjelmissa ja harjoitustehtävissä käsitelty monin eri tavoin: Metodi on voinut palauttaa tiedon virheestä totuusarvona tai jonkin muun tyypin erikoisarvona. Liian suuri tai pieni arvo on voitu tulkita suurimmaksi tai pienimmäksi kelvolliseksi arvoksi asiasta sen kummemmin virheellisen arvon antajalle tiedottamatta. Virheellisen indeksin on voitu sallia johtaa ohjelman kaatumiseen tavallisen indeksoinnin tapaan. Useimmiten olioparametrin arvoon null ei ole lainkaan varauduttu, vaan on luotettu ja edellytetty kutsujan pitävän huolta todellisen parametrin olemassaolosta. Jne., jne.

Mahdollisuuksia virheiden käsittelyyn on paljon, erilaisia virhetilanteita varmaan vieläkin enemmän...

Erityisen ongelmallisia Javalla ohjelmoitaessa ovat olioiden luonnissa tapahtuvat virheet, esimerkiksi konstruktorin parametrien virheelliset arvot tai jonkin tarvittavan resurssin (esim. tiedoston) hankinnassa tulevat virheet, koska konstruktorin kutsu (melkein) vääjäämättömästi johtaa olion luontiin. Näissä tilanteissa ongelmana on luotavan olion "laillisen" tilan varmistaminen. Asiaa on joissakin harjoitustehtävissä yritetty hoidella siten, että korvataan virheelliset arvot "oletusarvoilla". Tästäkin voi seurata murheita: olion luoja ei välttämättä edes tiedä, että jotakin meni pieleen, ja luulee saaneensa tilaamansa olion. Joskus luonteva ratkaisu voi olla erillisen onkoLuotuOlioKunnossa-metodin laatiminen. Olion luojan vastuulle jää luodun olion kunnon tarkistus.

Esimerkki oman onkoLuotuOlioKunnossa-metodin käytöstä:

  public class OmaOlio {

     private boolean kunnossa;

     // muut kentät 
     ...
     public OmaOlio(parametreja) {
       pyri asettamaan luotava olio lailliseen alkutilaan:
       ... 
       if (kunnon olio syntyi)
         kunnossa = true;
       else
         kunnossa = false;
     }

     public boolean onkoLuotuOlioKunnossa() {
       return kunnossa;
     }
     ...
  }

  // ja sitten käyttöä:
  ...
  OmaOlio x = new OmaOlio(parametreja);

  if (!x.onkoLuotuOlioKunnossa())
    käsittele virhetilanne;
  else 
    // luonti onnistui
    ...
Tätä menetettelyä on helppo laajentaa tilanteeseen, jossa olio voi särkyä olemassaolonsa aikana (esim. tiedosto särkyy).

Toinen tapa hoitaa konstruontivirheet on laatia erillinen luokkametodi olioiden luomiseen, ns. staattinen luontimetodi. Tuo metodi voi palauttaa arvonaan null, ellei kunnon oliota saatu luotua (tähän ei konstruktori pysty, koska this on vakio ja toisaalta vaikkapa return null ei ole mahdollinen konstruktorissa).

Esimerkki staattisesta luontimetodista:

  public class OmaOlio {
     // kenttiä 
     ...
     private OmaOlio(parametreja) {
       ...
     }

     public static OmaOlio luoOmaOlio(parametreja) {
       if (parametreissa vikaa tai jotain resurssia ei saada varattua)
         return null;
       else
         return new OmaOlio(parametreja);
     }
     ...
  }

  // ja sitten käyttöä:
  ...
  OmaOlio x = luoOmaOlio(parametreja);
  if (x == null)
    käsittele virhetilanne;
  else 
    // luonti onnistui
    ...

Kolmas tapa hoitaa tällaiset virheet on poikkeusten käyttö. Tällöin on ainakin kaksi mahdollisuutta: käytetään checked- tai unchecked-poikkeuksia. Jälkimmäisiä ei tarvitse siepata ja niiden voidaan antaa kaataa ohjelman suoritus. Ensinmainitut on käsiteltävä joko try-catch-lauseella tai kutsuvan metodin throws-ilmauksella. (Unchecked-poikkeukset ovat RuntimeException- Errorluokan tai jonkin niiden aliluokan ilmentymiä)

Unchecked-esimerkki:

  public class OmaOlio {
     // kenttiä 
     ...
     public OmaOlio(parametreja) {
       if (parametreissa vikaa tai jotain resurssia ei saada varattua)
         throw new RuntimeException("Olion luonti ei onnistu. Kaikki loppuu!"); 
       // parametrit kunnossa:
       ...
     }
     ...
  }

  // ja sitten käyttöä:
  ...
  OmaOlio x = new OmaOlio(parametreja);

  // jatkamaan päästään vain, jos poikkeusta ei tullut
  ...
Huom: Jos vikaa on nimenomaan parametreissa, voi olla perusteltua käyttää RuntimeExceptionin sijasta IllegalArgumentExceptionia!
Huom: Myös unchecked-poikkeuksia voi siepata try-catch-lauseella!

Checked-esimerkki:

  public class OmaOlio {
     // kenttiä 
     ...
     public OmaOlio(parametreja) throws Exception {
       if (parametreissa vikaa tai jotain resurssia ei saada varattua)
         throw new Exception("Olion luonti ei onnistu!"); 
       // parametrit kunnossa:
       ...
     }
     ...
  }

  // ja sitten käyttöä:
  ...
  OmaOlio x;
  try {
    x = new OmaOlio(parametreja);
  }
  catch (Exception e) {
    tee mitä luonnin epäonnistuessa pitää tehdä

  }
  // tänne kun tullaan, olio on joko luotu tai virhe käsitelty
  ...

Huom: Poikkeusten käyttäminen "tavallisten" tilanteiden hoitamiseen - ei siis "poikkeuksellisiin" tilanteisiin - voi olla epäviisasta, koska poikkeuksiin varautuminen (try-catch) saattaa hidastaa ohjelman suorittamista merkittävästi. Kokeile esimerkkiä: MittaaPoiAikaa.java!


Takaisin luvun 5 sisällysluetteloon.