Oppimateriaali perustuu osittain Arto Wiklan ohjelmointisivustoon. Materiaalin copyright © Arto Wikla. Materiaalia saa vapaasti käyttää itseopiskeluun. Muu käyttö vaatii luvan.

IV Ohjelmointitekniikkaa: parametrien ja syöttötietojen tarkistamista

(Muutettu viimeksi 11.10.2010, sivu perustettu 27.9.2010. Arto Wikla)

Virheisiin varautuminen on vaikeaa

Ohjelmointi on vaikeaa. Ei siksi, että algoritmien ja olioiden rakenteiden yksityiskohdat sinänsä olisivat vaikeita. Vaikeus tulee ainakin kahdesta muusta suunnasta:

Tässä luvussa tarkastellaan esimerkkejä kummastakin tilanteesta. Kuten todettu, virheiden käsittely on vaikeaa. Siksi asiaan palataan uudelleen myös jatkokurssilla.

Virheelliset parametrit metodeissa, konstruktoreissa ja aksessoreissa

Ohjelmoinnissa – erityisesti Java-maailmassa – ohjelman sisäiset virheet usein luokitellaan kahteen eri ryhmään:

Tähän palataan jatkokurssin puolella, kun opiskellaan ns. poikkeusten heittämistä ja niiden ns. sieppaamista.

Jo tässä vaiheessa on kuitenkin hyvä vähän miettiä asiaa.

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 taulukkoindeksoinnin tapaan. Useimmiten olioparametrin arvoon null ei ole lainkaan varauduttu, vaan on luotettu ja edellytetty kutsujan pitävän huolta todellisen parametrin olemassaolosta. Jne., jne.

Kuten sanottu, näihin asioihin palataan jatkokurssilla.

Pari tekniikkaa on kuitenkin hyvä oppia jo nyt:

Karsitaan parametrivirheet heti metodin alussa

Ohjelman logiikasta voi saada selkeämmän, jos heti alussa karsii virhetilanteet pois.

Esimerkki: Olkoon (vähän keinotekoisena) tehtävänä laatia pääohjelman avuksi metodi, joka hyväksyy parametrikseen vain positiivisen ja parillisen luvun, mutta ei kuitenkaan lukua 666. Metodi palauttaa kelvollisen parametrinsa arvon kolminkertaisena. Jos parametri on kelvoton, metodi palauttaa luvun -1:

private static int kolmenna(int: param) {
  if (param < 0)  // ... kuvittele tänne monimutkaisemmat
    return -1;       //     virhetarkistukset ...
  if (param % 2 != 0)
    return -1;
  if (param == 666)
    return -1; 
  // nyt sitten voidaan jatkaa puhtaalta pöydältä:
  // ... kuvittele tänne monimutkaisempi algoritmi ...
  return 3 * param;
}

Olioiden laiton tai arvaamaton tila ja olioiden särkyminen

Erityisen ongelmallisia Javalla ohjelmoitaessa ovat olioiden luonnissa ja aksessoinnissa tapahtuvat virheet, esimerkiksi konstruktorin parametrien virheelliset arvot, 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.

Edellä oli puhe siitä, miten taulukon virheellinen indeksointi johtaa ohjelman suorituksen päättymiseen. Ohjelmoijan siis pitää tuntea kielen dokumantaatiota tältä osin ja itse pitää huoli, ettei taulukkoa indeksoida väärin.

Vastaava logiikka on sovellettavissa myös omiin välineisiin. Esimerkisi edellisen luvun Varasto-luokan kaksiparametrinen konstruktori varoittamatta luo "nollavaraston" ja varoittamatta heittää mahtumattoman tuotteen menemään:

  public Varasto(double tilavuus, double alkuSaldo) { // kuormitetaan
   if (tilavuus > 0.0)
      this.tilavuus = tilavuus;
    else                    // virheellinen, nollataan
      this.tilavuus = 0.0;  // => käyttökelvoton varasto

    if (alkuSaldo < 0.0)
       this.saldo = 0.0;
    else if (alkuSaldo <= tilavuus)  // mahtuu
      this.saldo = alkuSaldo;
    else
      this.saldo = tilavuus;  // täyteen ja ylimäärä hukkaan!
  }

Samoin lisäävä aksessori heittää mahdollisen ylijäämän hukkaan:

  public void lisaaVarastoon(double maara) {
    if (maara < 0)   // virhetilanteessa voidaan tehdä 
       return;       // tällainen pikapoistuminenkin!

    if ( maara <= paljonkoMahtuu() )  // omia aksessoreita voi kutsua
      saldo = saldo + maara;          // ihan suoraan sellaisinaan
    else
      saldo = tilavuus;  // täyteen ja ylimäärä hukkaan!
  }

Tällaisissa tilanteissa voisi olla hyvin perusteltua toimia taulukon virheellisen indeksoinnin tapaan: vaatia ohjelmoijaa itse tarkistamaan parametrit ennen virheen vaaraa.

Vaikka poikkeuksiin tutustutaan varsinaisesti vasta jatkokurssilla, jo nyt saa "heittää poikkeuksen" IllegalArgumentExceptioni(). Eli karsitaan virheet heti alussa ja lopetetaan ohjelman suoritus kohtuullisen selkeään virheilmoitukseen:

  public Varasto(double tilavuus, double alkuSaldo) {
    if (tilavuus <= 0.0)
      throw new IllegalArgumentException("Virheellinen tilavuus!");
    if (alkuSaldo < 0.0)
      throw new IllegalArgumentException("Negatiivinen alkusaldo!");
    if (alkuSaldo > tilavuus)
      throw new IllegalArgumentException("Liian suuri alkusaldo!");

    // kaikki kunnossa:
    this.tilavuus = tilavuus;
    this.saldo = alkuSaldo;
  }

  public void lisaaVarastoon(double maara) {
    if (maara < 0)
      throw new IllegalArgumentException("Negatiivinen lisäys!");
    if ( maara > paljonkoMahtuu() )
      throw new IllegalArgumentException("Liiallinen lisäys!");

    // kaikki kunnossa:
    saldo = saldo + maara;
  }

Saadaan kohtuullisen selviä virheilmoituksia tyyliin:

Exception in thread "main" java.lang.IllegalArgumentException: Virheellinen tilavuus!
	at Varasto.(Varasto.java:22)
	at VarastoEsittely.main(VarastoEsittely.java:51
...
Exception in thread "main" java.lang.IllegalArgumentException: Negatiivinen alkusaldo!
	at Varasto.(Varasto.java:24)
	at VarastoEsittely.main(VarastoEsittely.java:51)
...

Syöttötietojen virheisiin varautumista: tekstitiedon syöttö

Javassa näppäimistöltä syötettävä teksti (samoin kuin tekstitiedostojen sisältö) muodostuu riveistä. Tietokoneen muistia ei tietenkään ole painetun tai kirjoitetun tekstin tavoin organisoitu riveittäin. Koneessa rivinvaihto muiden merkkien tapaan esitetään bittijonoksi koodattuna. Linuxissa/Unixissa rivin loppu ilmaistaan yhdellä merkillä, Windowsissa kahdella. Java-ohjelmoijan ei tarvitse tästä kantaa huolta – Java tietää, millaisessa ympäristössä sitä käytetään.

Kun tietoa kirjoitetaan näppäimistöltä, ohjelma saa kirjoitetun tekstin ns. syöttöpuskurissa, jonne viedään kaikki teksti seuraavaan rivinvaihtoon saakka (eli enter-näppäimen painallukseen saakka).

Jos kirjoitetaan vaikkapa kaksi välilyöntiä, merkit "2", "1", "3" ja vielä kolme välilyöntiä, syöttöpuskurissa on:

  213   @ (loppumerkki)
^ (seuraavaksi luettava merkki)

Merkitään näissä erimerkeissä loppumerkkiä näin: @. Syöttöpuskurissa on myös osoitin, joka osoittaa ensimmäistä lukemantonta merkkiä. Ilmaistaan se tässä merkillä ^.

Kun tässä tilanteessa luetaan

int i = nextInt();

Muuttuja i saa arvokseen kokonaisluvun 213 ja syöttöpuskurin tilanne on seuraava:

  213   @
     ^

Jos samassa lähtötilanteessa

  213   @
^

luetaan merkkijo tyyliin

String jono = nextLine();
puskuriin syntyy tilanne:

Eli puskuri on tyhjä ja muuttuja jono on arvoltaan "__123___" (välilyönnit on tässä merkattu alaviivalla).

Scanner-aksessori nextLine() siis lukee myös loppumerkin, vaikkei sisällytäkään sitä palauttamaansa merkkijonoon.

Syöttötietojen virheisiin varautumista: lukuoperaatiot

Tutun lukuoperaatiot:

Näistä operaatioista kolme ensin lueteltua toimivat eri logiikalla kuin viimeksi mainittu:

Operaatio nextLine() sen sijaan ei white space -merkkejä tunnista. Se palauttaa arvonaan merkkijonona kaiken "seuraavaksi luettavasta merkistä" aina seuraavaan rivinvaihtoon saakka (ilman rivinvaihtomerkkiä, jonka se kuitenkin "syö"):

Esimerkki: Olkoon lähtötilanne:

213@
^

Operaation

int i = nextInt();

jälkeen puskurissa on

213@
   ^

Eli seuraavaksi tarjolla on loppumerkki. Jos nyt luetaan

String jono = nextLine();

muuttuja jono saa arvokseen tyhjän merkkijonon "" ja "ensimmäisen lukemattoman merkin" osoitin siirtyy seuraavan rivin alkuun, joka vuorovaikutteisen ohjelman tapauksessa syntyy vasta, kun käyttäjä päättää syöttää seuraavan rivin.

Tämä selittänee joidenkin varmasti havaitseman "sykronointiongelman" tietojen syötössä?

Syöttötietojen virheisiin varautumista: kurkistus tulevaisuuteen

Kuten moni varmaan on katkerasti saanut kokea, virheellisen tyyppinen syöte johtaa ohjelman suorituksen keskeytymiseen. Jos esimerkiksi nextDouble()-operaatiolle syötetään desimaaliluvuksi kelpaamaton arvo, kaikki päättyy ikävällä tavalla:

Exception in thread "main" java.util.InputMismatchException
        at java.util.Scanner.throwFor(Scanner.java:819)
        at java.util.Scanner.next(Scanner.java:1431)
        at java.util.Scanner.nextDouble(Scanner.java:2335)
        at KolmeKarvo.main(KolmeKarvo.java:14)

Tällainen ei tietenkään ole oikeissa ohjelmissa hyväksyttävää! Niissä syöttötietojen oikeellisuus pitää siis tarkistaa. Tämän luvun oppien jälkeen se onkin mahdollista: loogisista lausekkeista, ehdollisista lauseista ja toistolauseista on jälleen iloa.

Scanner-luokassa on joukko totuusarvoisia eli boolean-tyyppisiä aksessoreita, joilla voi kysyä: "Jos lukisin, saisinko sen ja sen tyyppisen arvon". Metodit siis tavallaan "kurkistavat tulevaisuuteen". Oikeasti ne vain käyvät kurkkaamassa syöttöpuskurin sisältöä:

Näillä välineillä voidaan tarkistaa esimerkiksi, onko seuraava syöttöalkio kokonaisluku:

import java.util.Scanner;
public class Tarkista1 {
  private static Scanner lukija = new Scanner(System.in);
  public static void main(String[] args) {

    int luku;
    System.out.println("Anna kokonaisluku.");

    if (lukija.hasNextInt()) {
      luku = lukija.nextInt();
      System.out.println("Annoit luvun " + luku);
    } 
    else {
      System.out.println("Et syöttänyt kokonaislukua!");
    }
  }
}

Toki voidaan myös vaatia kunnon kokonaisluku:

import java.util.Scanner; 
public class Tarkista2 {
  private static Scanner lukija = new Scanner(System.in);
  public static void main(String[] args) {
 
    int luku = 0;   // kääntäjän iloksi!
    boolean kunnonLuku;

    do {
      System.out.println("Anna kokonaisluku.");

      kunnonLuku = lukija.hasNextInt();
      if (kunnonLuku)
         luku = lukija.nextInt();
      else {
        String virheellinen = lukija.next();  // ohitetaan kelvoton alkio
        System.out.print  (virheellinen + " ei ole kokonaisluku! ");
        System.out.println("Yritä uudelleen!");
      }
    } while (!kunnonLuku);

    System.out.println("Annoit luvun " + luku);
  }
}

Koska tällä kurssilla harjoitellaan ohjelmoinnin perusvälineistöä, useinkaan ei ole syytä käyttää voimia ja ohjelmarivejä syöttötietojen tarkastamiseen, jottei opeteltava uusi asia hukkuisi muun ohjelmatekstin sisään. Jos ja kun harjoitus- ja koetehtävissä syötteiden tarkistamista vaaditaan, se sanotaan erikseen.

On kuitenkin syytä pitää mielessä se jo useampaan kertaan todettu sääntö, että "oikeissa" käyttäjille tarkoitetuissa ohjelmissa syöttötiedot tarkistetaan aina!

Syöttötietojen virheisiin varautumista: Scanner ja String

Scanner-luokka on monipuolinen väline. Sen lisäksi, että sillä voidaan kehittynein välinein lukea syöttövirtaa, aivan samoin tutuin välinein on mahdollista tutkia yksittäisen merkkijonon sisältöä!

Olio-ohjelmointiterminologialla ilmaisten: Scanner-luokassa on myös konstruktori, jolla voidaan luoda yksittäistä String-arvoa "skannaava" Scanner-olio. Samat tutut metodit ovat tällöinkin käytettävissä.

Tämä tarjoaa yhden mahdollisuuden virheitä sietävän tietojen lukemisen toteuttamiseen: Idea on, että luetaan syöttövirtaa rivi kerrallaan operaatiolla nextLine(). Yksi kerrallaan jokaisesta rivistä (String) luodaan Scanner-olio, jonka avulla rivin sisältöä sitten tutkitaan ja kaivetaan tiedot esiin.

Esimerkki:

import java.util.Scanner;
public class RiviLuku {
  private static Scanner lukija = new Scanner(System.in);
  public static void main(String[] args) {

    System.out.println("Anna kokonaislukuja. Tyhjä rivi päättää.");
    String rivi = lukija.nextLine();

    while (rivi.length() > 0) {
      Scanner rivinSisalto = new Scanner(rivi); // joka rivistä oma Scanner!
      if (rivinSisalto.hasNextInt()) {
        int luku = rivinSisalto.nextInt();
        System.out.println("Luku on: " + luku);
      }
      else
        System.out.println("Virheellinen rivi: " + rivi);

      rivi = lukija.nextLine();
    }
  }
}