Ohjelmoinnin jatkokurssi

Matti Paksula, Arto Vihavainen, Matti Luukkainen

Toiston kontrollointi

Toistorakenteet (for, while) ovat hyvin hyödyllisiä listojen ja taulukoiden läpikäyntiin. Tutustutaan tässä kappaleessa kahteen toiston kontrollimahdollisuuteen, break ja continue, eli lopeta ja jatka. Tutustutaan myös niinkutsuttuun while-true-toistotapaan, jossa toistoa jatketaan niin pitkään kunnes tietty ehto toteutuu. Avainsanat break ja continue toimivat kaikissa toistorakenteissa.

Break

Avainsanalla break poistutaan suoritettavasta toistorakenteesta. Toistosta poistuminen on hyvin hyödyllistä esimerkiksi tilanteissa, joissa toivottu tulos on jo saavutettu, eikä haluta jatkaa enää toistoa. Katsotaan muutamaa esimerkkiä merkkijonon hakemiseen merkkijonolistasta. Oletetaan että merkkijonot-olio on luotu seuraavasti, eli se sisältää 50000 erilaista merkkijonoa.

ArrayList<String> merkkijonot = new ArrayList();
for(int i = 0; i < 50000; i++) {
  merkkijonot.add("Hei " + i);
}

Ensimmäinen vaihtoehto sanan hakemiseen listasta on kaikkien sanojen läpikäynti. Fiksua olisi tietenkin käyttää binäärihakua tai sopivampaa tietorakennetta, kuten HashMap, mutta pidetään asiat yksinkertaisina esimerkin vuoksi. Merkkijonon "Hei 2" etsiminen merkkijonot listasta ilman lopetusehtoa käy kaikki 50000 alkiota läpi. Alla oleva esimerkki ei ole hyvää ohjelmointityyliä!

boolean loytyi = false;
String haettava = "Hei 2";

for(String merkkijono: merkkijonot) {
  System.out.println("Tutkitaan merkkijono: " + merkkijono);

  if(haettava.equals(merkkijono)) {
    loytyi = true;  
  }
}

if(loytyi) {
  System.out.println();
  System.out.println("Haettu löytyi!");
}
Tutkitaan merkkijono: Hei 0
Tutkitaan merkkijono: Hei 1
Tutkitaan merkkijono: Hei 2
...
Tutkitaan merkkijono: Hei 49998
Tutkitaan merkkijono: Hei 49999

Haettu löytyi!

Esimerkki tulostaa jokaiselle alkiolle tulostuksen "Tutkitaan merkkijono: Hei numero", koska jokainen alkio käydään läpi riippumatta merkkijonon löytymisestä.

Avainsanaa break käytetään toiston lopettamiseen halutussa tilanteessa. Muokataan ylläolevaa esimerkkiä siten, että toistosta poistutaan heti kun haettava merkkijono löytyy.

boolean loytyi = false;
String haettava = "Hei 2";

for(String merkkijono: merkkijonot) {
  System.out.println("Tutkitaan merkkijono: " + merkkijono);

  if(haettava.equals(merkkijono)) {
    loytyi = true;
    break;
  }
}

if(loytyi) {
  System.out.println();
  System.out.println("Haettu löytyi!");
}
Tutkitaan merkkijono: Hei 0
Tutkitaan merkkijono: Hei 1
Tutkitaan merkkijono: Hei 2

Haettu löytyi!

Esimerkissä toisto lopetetaan siis avainsanaan break, jolloin kaikkia merkkijonoja ei tarvitse tutkia.

Avainsanalla break poistutaan vain tällä hetkellä suoritettavasta toistosta. Jos toistoja on useampia sisäkkäin, jatkuu ulompi toisto kuten ennenkin. Katsotaan seuraavaa esimerkkiä, jossa rivin alkioita tulostetaan kunnes saavutaan lukuun jonka arvo on yli 100.

int[][] numerot = {
    {17, 4, 2009},
    {12, 53},
};

for(int[] rivi: numerot) {
  for(int luku: rivi) {
    if(luku > 100) {
      break;
    }
    
    System.out.print(luku + " ");
  }
  System.out.println();
}

Yllä olevan hieman teennäisen esimerkin sisempi toisto lopetetaan jos luku on suurempi kuin 100. Koska break-ehto poistuu vain tällä hetkellä suoritettavasta toistosta, jatkuu ulomman toiston suoritus normaalisti. Esimerkin tulostus on seuraavanlainen.

17 4 
12 53

Toinen esimerkki break-avainsanan käytöstä ja hyödyllisyydestä sisäkkäisissä toistorakenteissa on seuraava (ei kovin tehokas!) alkulukuja etsivä algoritmi. Alkuluvut ovat lukuja, jotka ovat jaollisia vain yhdellä ja itsellään. Ulompi toistorakenne määrittelee luvut, joita tutkitaan, ja sisemmässä toistorakenteessa jaetaan lukua mahdollisilla luvuilla. Jos jakojäännös (%) on 0, on tutkittava luku jaollinen jakajalla, eikä se voi olla alkuluku.

int tutkiLukuun = 500;

for (int alkulukuEhdokas = 2; alkulukuEhdokas < tutkiLukuun; alkulukuEhdokas++) {
  boolean onAlkuluku = true;
  for (int jakaja = 2; jakaja < alkulukuEhdokas; jakaja++) {
    if (alkulukuEhdokas % jakaja == 0) {
      onAlkuluku = false;
      break;
    }
  }

  if (onAlkuluku) {
    System.out.println(alkulukuEhdokas);
  }
}
2
3
5
7
11
13
17
19
...

While-true

Eräs hyödyllisistä toistorakenteista on while-true rakenne. While-true - rakenteen idea on jatkaa toistoa loputtomiin, tai niin pitkään kunnes poistutaan toistosta. Yksi esimerkki while-true-toistorakenteen käytöstä on salasanan kysyminen. Salasanaa kysytään niin pitkään, kunnes käyttäjä syöttää oikean salasanan.

while (true) {
  System.out.print("Kirjoita salasana: ");
  
  if("salasana".equals(lukija.nextLine())) {
    break;
  } else {
    System.out.println("Väärin!");
  }
}

System.out.println("Kiitos!");

Yllä olevassa esimerkissä toistoa jatketaan niin pitkään kunnes käyttäjä antaa syötteeksi merkkijonon salasana.

Kirjoita salasana: kala
Väärin!
Kirjoita salasana: salakana
Väärin!
Kirjoita salasana: salasana
Kiitos!

Continue

Avainsana continue määrittelee tilanteen, jossa kyseinen toistokierros lopetetaan ja jatketaan seuraavasta. Käytetään aiemmin luotua merkkijonot-oliota continue avainsanan esittämiseen.

for(String merkkijono: merkkijonot) {
  if("Hei 2".equals(merkkijono)) {
    continue;
  }
  
  System.out.println("Tutkitaan merkkijono: " + merkkijono);
}

Kun toisto kohtaa merkkijonon "Hei 2" päätyy se ehtoon, jossa on avainsana continue. Tällöin se lopettaa kyseisen toistokierroksen ja jatkaa seuraavasta. Ylläolevan esimerkin tulostus olisi seuraavanlainen.

Tutkitaan merkkijono: Hei 0
Tutkitaan merkkijono: Hei 1
Tutkitaan merkkijono: Hei 3
Tutkitaan merkkijono: Hei 4
...

Avainsana continue on hyödyllinen esimerkiksi tilanteissa, missä kaikki listan tai taulukon alkiot täytyy käydä läpi, mutta tiedetään että tietynlaisia alkioita ei haluta ottaa huomioon. Seuraava esimerkki laskee lukujen 1..100 summan siten, että kolmella ja viidellä jaollisia lukuja ei oteta huomioon.

int summa = 0;

for(int luku = 1; luku <= 100; luku++) {
  if(luku % 3 == 0 || luku % 5 == 0) {
    continue;
  }
  
  summa += luku;
}

System.out.println(summa);

Yllä oleva esimerkki tulostaa luvun 2632.

Toistorakenteiden nimeäminen

Toistorakenteille on myös mahdollista antaa nimet. Toistorakenteiden nimeäminen tapahtuu määrittelemällä nimi ennen toistorakenteen tyyppiä, esimerkiksi Nimi: for.... Tätä ohjelmointityyliä näkee harvemmin, sillä se saattaa johtaa tilanteisiin joissa ohjelman seuraaminen vaikeutuu. Seuraavassa esimerkissä toistorakenteen nimeksi on määritelty LueSalasana.

LueSalasana: while (true) {
  System.out.print("Kirjoita salasana: ");
  
  if("salasana".equals(lukija.nextLine())) {
    break;
  } else {
    System.out.println("Väärin!");
  }
}

System.out.println("Kiitos!");

Toistorakenteiden nimet mahdollistavat break ja continue avainsanojen käytön siten, että voidaan jatkaa tai poistua tietyn nimisestä toistorakenteesta vaikka se olisi ulompi toistorakenne. Seuraavassa esimerkissä luetaan käyttäjätunnus ja salasana. Jos salasana menee oikein, poistutaan LueKayttaja-nimisestä toistorakenteesta, eli ulommasta toistosta.

String kayttaja;
String salasana;

LueKayttaja: while(true) {
  System.out.print("Anna käyttäjätunnus: ");
  kayttaja = lukija.nextLine();
  
  for(int yritykset = 0; yritykset < 3; yritykset++) {
    System.out.print("Anna salasana: ");
    salasana = lukija.nextLine();
    
    if("salasana".equals(salasana)) {
      break LueKayttaja; // poistutaan toistosta jonka nimi on LueKayttaja
    } else {
      System.out.println("Väärä salasana!");
    }
  }
  
  System.out.println("Liian monta yritystä!");
  System.out.println();
}

System.out.println();
System.out.println("Tervetuloa " + kayttaja);
System.out.println("Annoit salasanaksi " + salasana);
Anna käyttäjätunnus: Matti
Anna salasana: kala
Väärä salasana!
Anna salasana: sala
Väärä salasana!
Anna salasana: mikäsenytoli
Väärä salasana!
Liian monta yritystä!

Anna käyttäjätunnus: Matti
Anna salasana: salasana

Tervetuloa Matti
Annoit salasanaksi salasana

Voimme käyttää toistorakenteiden nimeämistä myös alkulukulaskuriimme. Sen sijaan, että pitäisimme yllä muuttujaa onAlkuluku, voimme jatkaa ulommasta toistosta jos huomaamme että tarkastelemamme luku ei ole alkuluku. Alkuluvun tarkistava toisto on nimetty AL:ksi, eli Alkuluvuksi. Jos huomaamme että jakaja pystyy jakamaan alkulukuehdokkaan, siirrymme tarkastelemaan seuraavaa lukua.

int tutkiLukuun = 500;

AL: for (int alkulukuEhdokas = 2; alkulukuEhdokas < tutkiLukuun; alkulukuEhdokas++) {
  for (int jakaja = 2; jakaja < alkulukuEhdokas; jakaja++) {
    if (alkulukuEhdokas % jakaja == 0) {
      continue AL;
    }
  }

  System.out.println(alkulukuEhdokas);
}
2
3
5
7
11
13
17
19
23
29
31
37
...

Toistorakenteet ja niiden kontrollointi avainsanojen break ja continue on hyvin hyödyllistä. Ne kuitenkin mahdollistavat tilanteita, joissa ohjelmakoodin kulku ei ole kovin loogista. Ohjelmakoodin dokumentointi onkin tärkeää niiltä kohdin, joissa ohjelman kulku ei ole selkeää!

Pakkaukset

Huomaamme suurempia ohjelmia suunniteltaessa ja toteuttaessa luokkamäärän kasvavan suureksi. Pakkauksilla (package) voidaan jakaa luokat eri sijainteihin niiden toiminnallisuuden perusteella, ja parantaa ohjelmiston ylläpidettävyyttä ja hallittavuutta. Pakkausten käyttö tarkoittaa käytännössä sitä, että luokat sijaitsevat kansioissa projektin sisällä.

Luokka määrittelee oman pakkauksensa määreellä package. Esimerkiksi seuraava luokka Kirja sijaitsee pakkauksessa kirjasto.

package kirjasto;

public class Kirja {
  private String nimi;
  private String sisalto;
  
  public Kirja() {
  }
  
  ...
}

Huomaa että pakkausten nimet kirjoitetaan aina pienellä!

Jos luokan pakkauksena on kirjasto, sijaitsee se ohjelman lähdekoodikansion sisällä kansiossa kirjasto. Vastaavasti voimme luoda pakkauksia siten, että luokat on jaettu useammalle tasolle. Esimerkiksi seuraava luokka KirjanLainaaja sijaitsisi kansion kirjasto sisällä olevassa kansiossa lainaus.

package kirjasto.lainaaja;

public class KirjanLainaaja {
  private String sijainti;
  
  public KirjanLainaaja() {
  }
  
  ...
}

Kaikki hyvät ohjelmointiympäristöt, kuten NetBeans ja Eclipse, tarjoavat valmiit pakkausten hallintaan, jolloin ohjelmoijan ei tarvitse huolehtia kansioista. Uuden pakkauksen voi luoda NetBeansissa projektin Source Packages-osiossa oikeaa hiirennappia painamalla ja valitsemalla New -> Java Package.... Luodun pakkauksen sisälle voidaan luoda luokkia kuten oletuspakkaukseenkin (default package).

Pakkausten käyttö mahdollistaa myös lähdekoodin jakamisen siten, että eri projekteissa toteutetut lähdekoodit eivät sekoitu toisiinsa. Yksi nimeämiskäytäntö paketeille on maa.organisaation-webosoite.projekti...., esimerkiksi fi.visiojapojat.supertuote, jotka alla on supertuote-projektiin liittyvät lähdekoodit (ja pakkaukset!).

Javan API:n pakkaukset ovat yleensä pakkauksissa java.alue.. Esimerkiksi Javan luokka ArrayList sijaitsee pakkauksessa java.util, eli javan työkalut. Ottaessamme luokan ArrayList käyttöömme komennolla import java.util.ArrayList kerromme käytännössä halutun luokan nimen, sekä pakkauksen jonka sisällä luokka sijaitsee.

Jos pakkauksessa kirjasto.lainaaja sijaitseva luokka KirjanLainaaja haluaa käyttää pakkauksessa kirjasto sijaitsevaa luokkaa Kirja, täytyy sen myös tuoda kirja käyttöön komennolla import kirjasto.Kirja

package kirjasto.lainaaja;

import kirjasto.Kirja;

public class KirjanLainaaja {
  private String sijainti;
  ...
  
  public KirjanLainaaja() {
  }
  
  public void lainaa(Kirja kirja) {..}
  
  ...
}

Samassa pakkauksessa sijaitsevia luokkia ei tarvitse erikseen tuoda käyttöön, eli saman pakkauksen sisällä olevat luokat ovat oletuksena käytössä pakkauksessa oleville luokille.

Pakkausnäkyvyys

Pakkausnäkyvyydellä tarkoitetaan sitä, että metodeille ja luokille ei määritellä erikseen näkyvyyttä. Näkyvyysmääreitähän ovat public, protected ja private. Jos näkyvyysmääreet jätetään pois metodeilta, voi metodeja kutsua pakkauksen sisällä olevista luokista. Vastaavasti konstruktorilla ja muuttujilla.

Jos luokka Kirja olisi määritelty ilman näkyvyysmäärettä public, voi sitä käyttää siis vain saman pakkauksen sisällä.

package kirjasto;

class Kirja {
  private String nimi;
  private String sisalto;
  
  ...
}

Nyt luokka KirjanLainaaja ei voi käyttää luokkaa Kirja, koska ne sijaitsevat eri pakkauksissa. Näkyvyysmääre protected määrittelee näkyvyyden siten, että muuttujat ja metodit ovat näkyvissä vain luokan periville luokille sekä samassa pakkauksessa oleville luokille. Määrettä protected ei kuitenkaan voi käyttää määrittelemään luokan näkyvyyttä.

Poikkeukset

Poikkeustilanteet ovat tilanteita joissa ohjelman suoritus ei ole edennyt toivotusti. Olemme tähän mennessä jättäneet poikkeustilanteet käsittelemättä määrittelemällä metodit ja pääohjelman sellaisiksi, että ne heittävät poikkeuksen (throws Exception). Katsotaan esimerkiksi metodia lueTiedosto(), joka lukee kokonaisen tiedoston sisällön merkkijonoon.

public static String lueTiedosto(String tiedostonNimi) throws Exception {
  File tiedosto = new File(tiedostonNimi);
  Scanner lukija = new Scanner(tiedosto);
  String teksti = "";
  while(lukija.hasNextLine()) {
    teksti += lukija.nextLine() + "\n";
  }
  return teksti;
}

Metodissa lueTiedosto() luokan Scanner tiedoston parametrina ottava konstruktori saattaa heittää poikkeuksen tilanteessa, jossa tiedoston lukeminen ei onnistu. Javan APIsta näkee poikkeuksen heittävät metodit ja konstruktorit.

Voimme myös itse hoitaa poikkeustenkäsittelyn, jolloin poikkeusta ei siirretä eteenpäin. Tällöin poikkeuksen käsittelyvastuu pysyy metodilla, jossa poikkeus tapahtuu.

Poikkeukset käsitellään try { } catch () { } - lohkorakenteella, jossa try { } - lohkon sisällä on ohjelmakoodi joka halutaan suorittaa. Osio catch () { } kertoo poikkeuksen mihin varaudutaan, ja siihen liittyvässä lohkossa on ohjelmakoodi, mikä suoritetaan poikkeustilanteessa.

try {
  // poikkeuksen heittävä ohjelmakoodi
} catch (Exception e) { // poikkeus johon varaudutaan
  // ohjelmakoodi, joka suoritetaan poikkeustilanteessa
}

Muutetaan metodia lueTiedosto() siten, että se varautuu itse mahdollisen poikkeustilanteen. Jos Scanner-konstruktori heittää poikkeuksen, palautetaan null-viite luettuna merkkijonona ja tulostetaan virheilmoitus. System-luokan staattinen muuttuja err tarjoaa samat tulostusmetodit kuin out, mutta niitä käytetään virhetapahtumien tulostamiseen.

public static String lueTiedosto(String tiedostonNimi) { // ei heitetä poikkeusta
  File tiedosto = new File(tiedostonNimi);
  Scanner lukija = null;
  
  try {
    lukija = new Scanner(tiedosto);
  } catch (Exception e) {
    System.err.println("Tiedoston avaaminen ei onnistunut!");
    return null;
  }
  
  String teksti = "";
  while(lukija.hasNextLine()) {
    teksti += lukija.nextLine() + "\n";
  }
  return teksti;
}

Metodi siis varautuu Scanner-luokan konstruktorin mahdolliseen poikkeukseen. Jos poikkeus tapahtuu, tulostetaan virheviesti ja palautetaan null-viite.

Lisätietoa: Katenointi, eli kahden merkkijonon yhdistäminen, luo aina uuden String-olion, jolloin tarvitaan myös roskienkeruuta vanhoille merkkijonoille. Luokkaa StringBuilder käyttämällä voimme yhdistää merkkijonoja ilman uusien String-olioiden luomista. Yllä oleva metodi on paljon tehokkaampi seuraavanlaisena.

public static String lueTiedosto(String tiedostonNimi) { // ei heitetä poikkeusta
  File tiedosto = new File(tiedostonNimi);
  Scanner lukija = null;
  
  try {
    lukija = new Scanner(tiedosto);
  } catch (Exception e) {
    System.err.println("Tiedoston avaaminen ei onnistunut!");
    return null;
  }
  
  StringBuilder teksti = new StringBuilder();
  while(lukija.hasNextLine()) {
    teksti.append(lukija.nextLine());
    teksti.append("\n");
  }
  return teksti.toString();
}

Yläluokka Exception

Luokka Exception on poikkeusten yläluokka, eli kaikki poikkeukset perivät luokan Exception. Polymorfismin ansiosta voimme varautua kaikkiin poikkeuksiin varautumalla poikkeustyyppiin Exception, eli kaikkien poikkeusten yläluokkaan. Tämä ei kuitenkaan aina ole tarpeellista tai toivottua, koska erilaiset poikkeustilanteet saattavat tarvita erilaista käsittelyä.

Yksi eniten nähdyistä poikkeuksista on NullPointerException, eli poikkeustilanne, joka johtuu null-viitteen käyttämisestä esimerkiksi metodikutsussa. Voimme nähdä NullPointerException poikkeuksen esimerkiksi seuraavan lähdekoodin avulla.

String merkkijono = null;
System.out.println("Merkkijonon pituus on " + merkkijono.length());

Koska viite merkkijono on null, ei String-olioon liittyvän length() metodin suoritus onnistu ja ohjelma heittää poikkeuksen NullPointerException.

Aiemmin luomamme metodi lueTiedosto() varautuu poikkeustyyppiin Exception, vaikka Scanner-luokan konstruktori oikeasti heittää FileNotFoundException-tyyppisen poikkeuksen. Poikkeukset ovat yleensä hyvin nimettyjä, esimerkiksi FileNotFoundException kertoo sen, että haettua tiedostoa ei löytynyt tai sen avaamisessa oli ongelmia. Voimme muuttaa lueTiedosto()-metodia varautumaan FileNotFound-tyyppiseen poikkeukseen seuraavasti.

public static String lueTiedosto(String tiedostonNimi) { // ei heitetä poikkeusta
  File tiedosto = new File(tiedostonNimi);
  Scanner lukija = null;
  
  try {
    lukija = new Scanner(tiedosto);
  } catch (FileNotFoundException e) {
    System.err.println("Tiedoston avaaminen ei onnistunut!");
    return null;
  }
  
  String teksti = "";
  while(lukija.hasNextLine()) {
    teksti += lukija.nextLine() + "\n";
  }
  return teksti;
}

Poikkeuksen tiedot

Lohko catch() {} määrittelee varauduttavan poikkeuksen lisäksi muuttujan, mihin poikkeuksen tiedot tallennetaan. Esimerkiksi seuraava rakenne määrittelee poikkeuksen FileNotFoundException tallentumisen muuttujaan e. Koska poikkeukset perivät yläluokan Exception, voimme käyttää sen metodeja poikkeuksen tietojen tulostamiseen.

try {
  // ohjelmakoodi, joka saattaa heittää poikkeuksen
} catch (FileNotFoundException e) {
  // poikkeuksen tiedot ovat tallessa muuttujassa e
}

Luokka Exception tarjoaa muutamia hyödyllisiä metodeja. Esimerkiksi metodi printStackTrace() tulostaa polun, joka johti poikkeukseen. Tutkitaan seuraavaa metodin printStactTrace() tulostamaa virhettä.

Exception in thread "main" java.lang.NullPointerException
  at pakkaus.Luokka.tulosta(Luokka.java:43)
  at pakkaus.Luokka.main(Luokka.java:29)

Poikkeuspolun lukeminen tapahtuu alhaalta ylöspäin. Alimpana on ensimmäinen kutsu, eli ohjelman suoritus on alkanut luokan Luokka metodista main(). Rivillä 29 on kutsuttu metodia tulosta(). Metodissa tulosta() on tapahtunut rivillä 43 poikkeus, NullPointerException. Poikkeuksen tiedot ovatkin hyvin hyödyllisiä virhekohdan selvittämisessä.

Finally

Poikkeuksiin varauduttaessa on mahdollista määritellä myös lohko, joka suoritetaan riippumatta siitä, tapahtuiko poikkeus vai ei. Lohko finally { } tulee poikkeuslohkon jälkeen, ja se sisältää joka tapauksessa suoritettavan koodin. Finally lohkon lähdekoodi suoritetaan sen jälkeen, kun try {} ja catch {} -lohkot on suoritettu. Katsotaan viime viikolla nähtyä esimerkkiä tiedostoon kirjoittamisesta.

public static void kirjoitaTiedostoon(String tiedostonNimi, String teksti) throws Exception {
  FileWriter kirjoittaja = new FileWriter(tiedostonNimi);
  kirjoittaja.write(teksti);
  kirjoittaja.close();
}

Luokan FileWriter konstruktori siis avaa parametrina annetun tiedoston kirjoittamista varten, ja yrittää kirjoittaa siihen. Yllä olevassa esimerkissä on kaksi erillistä poikkeuskohtaa. Luokka FileWriter voi heittää poikkeuksen IOException jos tiedostoa ei jostain syystä saada avattua kirjoittamista varten. Metodi write() voi myös heittää poikkeuksen jos tiedostoon kirjoittaminen ei onnistu, vaikka tiedoston avaaminen olisikin jo onnistunut. Muutetaan metodia siten, että poikkeustenkäsittely tapahtuu metodin sisällä.

public static void kirjoitaTiedostoon(String tiedostonNimi, String teksti) throws Exception {
  FileWriter kirjoittaja = null;
  try {
    kirjoittaja = new FileWriter(tiedostonNimi);
    kirjoittaja.write(teksti);
    kirjoittaja.close();
  } catch (Exception e) {
    System.err.println("Tiedostoon kirjoittaminen epäonnistui.");
    e.printStackTrace();
  }
}

Poikkeusten käsittelyyn siirrytään heti poikkeuksen tapahtuessa, esimerkiksi jos ylläolevassa esimerkissä konstruktorikutsu new FileWriter() heittää poikkeuksen, ei sitä seuraavaa kahta kutsua suoriteta ollenkaan. Jos poikkeus tapahtuu metodissa write(), jää metodikutsu close() suorittamatta, ja tiedosto jää auki.

Lohko finally {} on erinomainen tapauksiin, joissa täytyy sulkea resursseja lopuksi, riippumatta suorituksen kulusta. Siirretään metodikutsu close lohkoon finally {}, jotta sen suoritus tapahtuu aina.

public static void kirjoitaTiedostoon(String tiedostonNimi, String teksti) throws Exception {
  FileWriter kirjoittaja = null;
  try {
    kirjoittaja = new FileWriter(tiedostonNimi);
    kirjoittaja.write(teksti);
  } catch (Exception e) {
    System.err.println("Tiedostoon kirjoittaminen epäonnistui.");
    e.printStackTrace();
  } finally {
    if(kirjoittaja != null) {
      kirjoittaja.close();
    } else {
      System.err.println("Kirjoittajaa ei koskaan luotu.");
    }
  }
}

Jos metodi palauttaa arvon try {} tai catch -lohkossa, lohkon finally sisältö suoritetaan silti. Tutkitaan metodia vitonen(), joka palauttaa arvon 5 try-lohkossa, ja tulostaa merkkijonon Moi finally lohkossa.

public static int vitonen() {
  try {
    return 5;
  } catch (Exception e) {
  } finally {
    System.out.println("Moi!");
  }
  
  return 0;
}
System.out.println(vitonen());
Moi!
5

Esimerkin tulostuksesta huomataan että lohko finally suoritetaan ennen arvon palauttamista, jolloin myös merkkijono Moi! tulostuu ennen lukua 5.

Käyttöliittymät

Olemme tähän mennessä rakentaneet ohjelmamme kahdella eri tavalla. Ohjelmat ovat joko suorittaneet laskutoimituksia, jonka jälkeen ne ovat lopettaneet toimintansa, tai niitä on ohjattu tekstipohjaisen käyttöliittymän kautta. Tutustutaan tässä kappaleessa graafisen käyttöliittymän luomiseen Javalla.

Käyttöliittymät koostuvat pohjakerroksesta, niinkutsutusta containerista, ja siihen asetetuista komponenteista. Komponentteja ovat napit, tekstit, ym. Container voi sisältää myös toisen containerin. Seuraava esimerkki luo dialogityylisen ikkunan, ja asettaa siihen napin (luokka JButton) jossa on teksti "Ok!".

JDialog dlg = new JDialog();
JButton ok = new JButton("Ok!");
// lisätään dialogin containeriin nappi "ok"
Container container = dlg.getContentPane();
container.add(ok);

// asetetaan koko 240*120 pikseliä
dlg.setSize(240, 120);
// sulje ikkuna kun käyttäjä painaa X
dlg.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
// näytä ikkuna
dlg.setVisible(true);

Napin painaminen ei vielä tee yhtään mitään. Jos haluamme lisätä dialogiin uuden komponentin, esimerkiksi tekstielementin, joudumme päättämään sille sijainnin. Containerille on määritelty myös ulkoasu, eli jokaisella sen sisältämällä komponentilla on myös sijainti. Luokka BorderLayout on eräs ulkoasun määrittelyyn käytetty luokka. Sen avulla voidaan määritellä komponenttien sijainti ilmansuuntia käyttäen (NORTH, EAST, SOUTH, WEST). Muutetaan dialogia siten, että ikkunassa on pohjoisessa nappi "Ok!", ja etelässä teksti (luokka JLabel) "Eipäs!".

// luodaan dialogi-tyyppinen ikkuna
JDialog dlg = new JDialog();

// luodaan komponentit
JButton ok = new JButton("Ok!");
JLabel eipas = new JLabel("Eipäs!");

// lisätään komponentit containeriin
Container container = dlg.getContentPane();
container.add(ok, BorderLayout.NORTH);
container.add(eipas, BorderLayout.SOUTH);

// ikkunan ulkoasun ja käyttäytymisen asettelua
dlg.setSize(240, 120);
dlg.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
dlg.setVisible(true);

Tässäkään esimerkissä napin painaminen ei vielä tee mitään.

Tapahtumapohjainen ohjelmointi

Käyttöliittymiä rakennettaessa jokainen komponentti on oma osansa käyttöliittymää. Tällöin jokaiselle komponentille täytyy myös tehdä oma tapahtumanhallinta. Java tarjoaa joukon erilaisia tapahtumankuuntelijoita, jotka voidaan liittää komponentteihin. Jos komponentilla (esimerkiksi nappi) on tapahtumankuuntelija, ja komponentissa tapahtuu toiminto (esimerkiksi nappia painetaan), kuulee tapahtumankuuntelija tapahtuman, ja suorittaa tapahtumankäsittelyyn ohjelmoidun ohjelman.

Tapahtumien kuuntelijat on määritelty rajapintoina. Esimerkiksi rajapinta ActionListener määrittelee metodin actionPerformed(), joka saa käyttöliittymästä parametrina ActionEvent-olion. Olio ActionEvent sisältää tapahtumaan liittyvät tiedot. Luodaan luokka, joka toteuttaa tapahtumankuuntelijan ActionListener.

public class NapinKuuntelija implements ActionListener {
  public void actionPerformed(ActionEvent e) {
    System.out.println("Nappia painettu!");
  }
}

Tapahtumankuuntelijan voi rekisteröidä, eli liittää komponenttiin, komponenttiin liittyvillä metodeilla. Esimerkiksi luokalla JButton on metodi addActionListener(), jolle voidaan antaa parametrina ActionListener-rajapinnan toteuttava luokka. Esimerkiksi ylläolevaan ohjelmaan voidaan liittää luokka Napinkuuntelija seuraavasti.

// luodaan dialogi-tyyppinen ikkuna
JDialog dlg = new JDialog();

// luodaan komponentit
JButton ok = new JButton("Ok!");
JLabel eipas = new JLabel("Eipäs!");

// lisätään napille tapahtumankuuntelija
ok.addActionListener(new NapinKuuntelija());

// lisätään komponentit containeriin
Container container = dlg.getContentPane();
container.add(ok, BorderLayout.NORTH);
container.add(eipas, BorderLayout.SOUTH);

// ikkunan ulkoasun ja käyttäytymisen asettelua
dlg.setSize(240, 120);
dlg.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
dlg.setVisible(true);

Nyt jos nappia painetaan kolmesti saadaan konsoliin seuraavanlainen tuloste:

Nappia painettu!
Nappia painettu!
Nappia painettu!

Muutetaan luokkaa NapinKuuntelija siten, että se saa parametrina JLabel-tyyppisen tekstielementin. Kun nappia painetaan, tekstielementin tekstiksi asetetaan teksti "Nappia painettu!"

public class NapinKuuntelija implements ActionListener {
  private JLabel tekstielementti;
  
  public NapinKuuntelija(JLabel teksti) {
    this.tekstielementti = teksti;
  }

  public void actionPerformed(ActionEvent e) {
    teksti.setText("Nappia painettu!");
  }
}

Muutetaan dialoginluontia vielä siten, että NapinKuuntelija saa parametrikseen JLabel-olion

// lisätään napille tapahtumankuuntelija
ok.addActionListener(new NapinKuuntelija(eipas));

Napin painalluksen jälkeen ikkuna näyttää seuraavalta.

JDialog luokan periminen

Voimme myös periä JDialog luokan ja rakentaa oman dialogimme sen avulla. Seuraava esimerkki NapinPainallus vastaa yllä olevaa esimerkkiä, mutta olemme käyttäneet perittyä luokkaa suoraan. Myös tapahtumankäsittely hoidetaan luokassa NapinPainallus.

public class NapinPainallus extends JDialog implements ActionListener {

  // käyttöliittymän komponentit
  private JButton okButton;
  private JLabel eipasLabel;

  public NapinPainallus() {
    super();

    okButton = new JButton("Ok!");
    eipasLabel = new JLabel("Eipäs!");
    okButton.addActionListener(this);

    Container container = getContentPane();
    container.add(okButton, BorderLayout.NORTH);
    container.add(eipasLabel, BorderLayout.SOUTH);

    setSize(240, 120);
    setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
    setVisible(true);
  }

  public void actionPerformed(ActionEvent e) {
    eipasLabel.setText("Nappia painettu!");
  }
}

Nyt voimme luoda uuden ikkunan main()-metodissa kutsumalla new NapinPainallus().

public class Main {
    public static void main(String[] args) {
        new NapinPainallus();
    }
}

JPanel

Luokkaa JPanel käytetään muunmuassa piirtoalustana. Luokalla JPanel on metodi paint(), jota kutsutaan aina kun paneeli halutaan piirtää. Metodi paint() saa käyttöliittymältä Graphics-tyyppisen olion, jolla voidaan piirtää paneelille. Laajennetaan JPanel luokkaa luokalla Piirturi, ja muutetaan sen piirtotoiminnallisuutta.

public class Piirturi extends JPanel {

    public void paint(Graphics g) {
        super.paint(g);
    }
}

Luokkamme Piirturi ei vielä tee mitään muuta, kuin kutsuu yläluokkansa (eli JPanel-luokan) paint()-metodia. Luodaan myös pääohjelma, jossa luodaan JDialog-tyyppinen ikkuna, ja asetetaan sen containeriin luokan Piirturi ilmentymä.

// luodaan ikkuna
JDialog dlg = new JDialog();

// luodaan piirturi
Piirturi piirturi = new Piirturi();

// otetaan viite ikkunan containeriin ja asetetaan piirturi siihen
Container container = dlg.getContentPane();
container.add(piirturi);

// ikkunan koko, sulkemistoiminnallisuus ja näkyväksi asetus
dlg.setSize(480, 360);
dlg.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
dlg.setVisible(true);

Ohjelmaa ajettaessa ikkuna näyttää seuraavalta.

Piirretään piirturiin ympyrä. Graphics-oliolla piirrettäessä iso osa metodeista määrittelee piirrettävän objektin siten, että ensiksi annetaan sen vasemman yläkulman koordinaatit, jonka jälkeen tulee piirrettävän kuvion leveys ja korkeus. Esimerkiksi seuraava kutsu luo kohdasta (30, 30) alkavan ympyrän, jonka halkaisija on 100. Huomaa että kohta 30, 30 lasketaan paneelin vasemmasta ylälaidasta!

g.fillOval(30, 30, 100, 100);

Ikkuna näyttää nyt seuraavanlaiselta.

Lisätään vielä toinen ja kolmas ympyrä. Kolmas ympyrä ei ole täytetty.

g.fillOval(250, 40, 80, 80);
g.drawOval(40, 200, 300, 100);

Ikkuna näyttää nyt kokonaisuudessaan seuraavanlaiselta.

Piirturi luokan sisältämä lähdekoodi on seuraavanlainen.

public class Piirturi extends JPanel {
  public void paint(Graphics g) {
    super.paint(g);

    g.fillOval(30, 30, 100, 100);
    g.fillOval(250, 40, 80, 80);
    g.drawOval(40, 200, 300, 100);
  }
}

Näppäimistön kuunteleminen

Tutustutaan näppäimistön kuunteluun tarkoitettuun rajapintaan, eli rajapintaan KeyListener. Rajapinta KeyListener tarjoaa metodit näppäimistön tapahtumien kuunteluun. Rajapinta määrittelee kolme erilaista näppäimistöltä tulevaa tapahtumaa, kirjaimen kirjoitus (keyTyped()), paino (keyPressed()) ja nosto (keyReleased(). Toteutetaan rajapinta KeyListener luokassa Piirturi.

public class Piirturi extends JPanel implements KeyListener {

  public void paint(Graphics g) {
    super.paint(g);

    g.fillOval(30, 30, 100, 100);
    g.fillOval(250, 40, 80, 80);
    g.drawOval(40, 200, 300, 100);
  }

  public void keyPressed(KeyEvent e) {
  }
  
  public void keyTyped(KeyEvent e) {
  }

  public void keyReleased(KeyEvent e) {
  }
}

KeyListener-rajapinnan tarjoavat metodit saavat parametrina KeyEvent-tyyppisen olion käyttöliittymältä. KeyEvent-oliolta voimme esimerkiksi kysyä, mitä nappia on painettu (metodi getKeyCode()). Muutetaan piirturi-luokkaa siten, että nuoli ylös pienentää hahmon suuta, nuoli alas suurentaa sitä (eli sen korkeus muuttuu). Käytetään tähän metodia keyPressed(), sillä jos näppäimistön nappia pidetään pohjassa metodia keyPressed() kutsutaan jatkuvasti.

public class Piirturi extends JPanel implements KeyListener {
    int y = 0;

  public void paint(Graphics g) {
    super.paint(g);

    g.fillOval(30, 30, 100, 100);
    g.fillOval(250, 40, 80, 80);
    g.drawOval(40, 200, 300, 100 + y);
  }

  public void keyPressed(KeyEvent e) {
    if (e.getKeyCode() == KeyEvent.VK_UP) {
      this.y--;
      repaint();
    }

    if (e.getKeyCode() == KeyEvent.VK_DOWN) {
      this.y++;
      repaint();
    }
  }

  public void keyTyped(KeyEvent e) {
  }

  public void keyReleased(KeyEvent e) {
  }
}

Tapahtumankäsittelijä pitää rekisteröidä ikkunaan, eli käytettävään dialogiin, sillä se on uloin käyttöliittymän kerros. Voimme rekisteröidä näppäimistönkuuntelijan pääohjelmalle seuraavasti.

// luodaan ikkuna
JDialog dlg = new JDialog();

// luodaan piirturi
Piirturi piirturi = new Piirturi();

// otetaan viite ikkunan containeriin ja asetetaan piirturi siihen
Container container = dlg.getContentPane();
container.add(piirturi);

// lisätään näppäimistönkuuntelija
dlg.addKeyListener(piirturi);

// ikkunan koko, sulkemistoiminnallisuus ja näkyväksi asetus
dlg.setSize(480, 360);
dlg.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
dlg.setVisible(true);

Alla oleva kuva on kuva hahmosta kun ylöspäin nappia on pidetty muutama hetki pohjassa.

Säikeistä

Ei kuulu koealueeseen!

Ohjelmissa halutaan usein suorittaa asioita samaan aikaan esimerkiksi siten, että osa ohjelmasta tekee laskentaa, ja toinen osa tulostaa tietoa. Useimmat hyvin suunnitellut käyttöliittymät toimivat siten, että ne vain näyttävät tiedon, jolloin itse tiedon laskenta tapahtuu taustalla. Tutustutaan Javassa oleviin säikeisiin, eli suorituksen jakamiseen, rajapinnan Runnable avulla.

Rajapinta Runnable määrittelee metodin run(), joka mahdollistaa olion suorituksen erillisessä säikeessä. Toteutetaan luokka Hiekkalaatikko, jossa on yksinäinen liikkuva muurahainen. Esitetään muurahainen pisteenä. Toteutetaan metodi run() seuraavasti.

public void run() {
  while(true) {
    liikutaMuurahaista();
    System.out.println(getMuurahainen());

    try {
      Thread.sleep(30);
    } catch (InterruptedException ex) {}            
  }
}

Kutsu Thread.sleep(30) komentaa kyseisen säikeen nukkumaan 30 millisekunniksi. Jos säiettä ei pyydettäisi nukkumaan, pyrkisi se käyttämään niin paljon tietokoneen tehoa kuin mahdollista. Käytännössä muurahainen kulkisi niin lujaa ettei sen liikkeestä saisi kunnolla selvää. Vanhojen pelien ystäville: Tämä on syynä siihen, että osa hyvin vanhoista peleistä toimivat järjettömillä nopeuksilla jos niitä yrittää pelata nykyajan tietokoneilla. Ennen muinoin pelejä suunniteltaessa ei otettu huomioon sitä, että eri tietokoneet ovat eri tehoisia, ja kaikki pelit pyrkivät ottamaan kaiken tehon irti koneesta.

Luokka Hiekkalaatikko on kokonaisuudessaan seuraavanlainen.

public class Hiekkalaatikko implements Runnable {
  private Point muurahainen;

  private int leveys;
  private boolean oikealle = true;

  public Hiekkalaatikko(int leveys) {
    this.leveys = leveys;

    // käytetään luokkaa Random satunnaisen pisteen saamiseen
    Random random = new Random();
    this.muurahainen = new Point(random.nextInt(leveys), 5);
  }

  private void liikutaMuurahaista() {
    if(oikealle) {
      muurahainen.x++;
    } else {
      muurahainen.x--;
    }

    if(muurahainen.x < 0) {
      oikealle = true;
    }

    if(muurahainen.x > leveys) {
      oikealle = false;
    }
  }

  public Point getMuurahainen() {
    return muurahainen;
  }

  public void run() {
    while(true) {
      liikutaMuurahaista();
      System.out.println(getMuurahainen());

      try {
        Thread.sleep(30);
      } catch (InterruptedException ex) {}            
    }
  }
}

Jos ylläoleva luokka ajetaan seuraavalla main()-metodilla, ei ohjelman suoritus pääty kun main()-metodi päättyy. Kutsu new Thread().start(), jolle annetaan parametrina Runnable-rajapinnan toteuttava olio, aloittaa uuden säikeen parametrina annetulle oliolle.

Hiekkalaatikko hiekkis = new Hiekkalaatikko(250);
new Thread(hiekkis).start();

Koska muurahainen on esitetty Javan Point-luokan avulla, on ohjelman tulostus seuraavanlainen (ja päättyy vasta kun ohjelma tapetaan).

java.awt.Point[x=126,y=5]
java.awt.Point[x=127,y=5]
java.awt.Point[x=128,y=5]
java.awt.Point[x=129,y=5]
java.awt.Point[x=130,y=5]
java.awt.Point[x=131,y=5]
java.awt.Point[x=132,y=5]
java.awt.Point[x=133,y=5]
...

Luodaan hiekkalaatikolle vielä oma pieni graafinen käyttöliittymä. Hiekkalaatikko ottaa parametrina graafisen käyttöliittymän, ja kutsuu omassa run()-metodissaan käyttöliittymän päivittämistä. Toteutetaan piirtäminen JPanel-luokkaa laajentaen.

public class Paneeli extends JPanel {
  private Point piste;
  
  public void paint(Graphics g) {
    super.paint(g);
    
    if(piste != null) {
      g.drawRect(piste.x, piste.y, 2, 2);
    }
  }

  public void piirra(Point p) {
    this.piste = p;
    repaint();
  }
}

Luodaan dialogi, jonka sisään paneeli asetetaan pääohjelmassa. Koska ohjelmassa on useampia säikeitä, joudumme muokkaamaan ikkunan sulkemismetodia siten, että se sulkee koko ohjelman.

Paneeli paneeli = new Paneeli();

JDialog dialog = new JDialog();
dialog.getContentPane().add(paneeli);
// ikkunan koko, sulkemistoiminnallisuus ja näkyväksi asetus
dialog.setSize(300, 100);

// suljetaan koko ohjelma kun ikkuna suljetaan, muuten säie jäisi
// pyörimään
dialog.addWindowListener(new WindowAdapter() {
  public void windowClosing(WindowEvent e) {
    System.exit(0);
  }
});

dialog.setVisible(true);

Hiekkalaatikko hiekkis = new Hiekkalaatikko(paneeli, 250);
new Thread(hiekkis).start();

Hiekkalaatikko vielä siten, että se saa parametrina Paneeli-olion.

public class Hiekkalaatikko implements Runnable {
  private Point muurahainen;
  private Paneeli paneeli;
  
  private int leveys;
  private boolean oikealle = true;

  public Hiekkalaatikko(Paneeli paneeli, int leveys) {
    this.leveys = leveys;
    this.paneeli = paneeli;

    // käytetään luokkaa Random satunnaisen pisteen saamiseen
    Random random = new Random();
    this.muurahainen = new Point(random.nextInt(leveys), 5);
  }

  private void liikutaMuurahaista() {
    if(oikealle) {
      muurahainen.x++;
    } else {
      muurahainen.x--;
    }

    if(muurahainen.x < 0) {
      oikealle = true;
    }

    if(muurahainen.x > leveys) {
      oikealle = false;
    }
  }

  public Point getMuurahainen() {
    return muurahainen;
  }

  public void run() {
    while(true) {
      liikutaMuurahaista();

      if(paneeli != null) {
        paneeli.piirra(muurahainen);
      }

      try {
        Thread.sleep(30);
      } catch (InterruptedException ex) {}            
    }
  }
}

Nyt ohjelma on seuraavanlainen (muurahainen toki liikkuu!).

Samanaikaisuudesta

Jos useampi säie käyttää samaa resurssia, esimerkiksi ArrayList-luokan ilmentymää, voivat ne päätyä tilanteeseen, missä toinen yrittää poistaa alkiota mitä toinen lukee. Tällöin puhumme rinnakkaisuusongelmista. Kurssilla rinnakkaisohjelmistot pääsemme tutustumaan rinnakkaisuuteen liittyviin ongelmiin (ja rinnakkaisuuden mahdollistamaan tehokkuuteen! :)).