Tehtävät
Yhdeksännen osion tavoitteet

Yhdeksännessä osiossa käsitellään ohjelman luokkien jakamista pakkauksiin. Tämän lisäksi tutustumme ohjelmissa tapahtuviin poikkeuksiin ja opimme käsittelemään sekä tarkastamaan käyttäjän antamia virheellisiä syötteitä. Opimme käyttämään useampia rajapintoja, sekä tutustumme Javan valmiiseen järjestämisessä käytettävään rajapintaan Comparable.

Osassa on muutamia pienempiä harjoituksia sekä kaksi isompaa tehtävää, joissa pääset suunnittelemaan sovelluksen rakenteen. Tulet huomaamaan, että isompien tehtävien rakenne voi olla hyvinkin samankaltainen -- erillinen käyttöliittymäluokka, muutamia käsitteitä kuvaavia luokkia, sekä listoja ja mahdollisesti hajautustauluja sisältävää sovelluslogiikkaa olioiden "kirjanpitoon". Isommat tehtävät vievät todennäköisesti useamman tunnin -- varaa tekemiseen sekä kertaamiseen aikaa.

Tämän osan jälkeen osaat suunnitella ja toteuttaa käyttöliittymäkuvauksen pohjalta laajemman sovelluksen. Osaat käyttää pakkauksia luokkien jakamiseen erilaisiin vastuualueisiin, sekä tarkastaa käyttäjän antamia syötteitä. Osaat käsitellä ohjelmassa tapahtuvia poikkeuksia. Lisäksi rajapinnat ja hajautustaulut tulevat yhä tutummiksi.

Ohjelman rakenne ja pakkaukset

Ohjelmaa varten toteutettujen luokkien määrän kasvaessa niiden toiminnallisuuksien ja metodien muistaminen vaikeutuu. Muistamista helpottaa luokkien järkevä nimentä sekä luokkien suunnittelu siten, että jokaisella luokalla on yksi selkeä vastuu. Tämän lisäksi luokat kannattaa jakaa toiminnallisuutta, käyttötarkoitusta tai jotain muuta loogista kokonaisuutta kuvaaviin pakkauksiin.

Pakkaukset (package) ovat käytännössä hakemistoja (directory, puhekielessä myös kansio), joihin lähdekooditiedostot organisoidaan.

Ohjelmointiympäristöt tarjoavat valmiit työkalut pakkausten hallintaan. Olemme tähän mennessä luoneet luokkia ja rajapintoja vain projektiin liittyvän lähdekoodipakkaukset-osion (Source Packages) oletuspakkaukseen (default package). Uuden pakkauksen voi luoda NetBeansissa projektin pakkauksiin liittyvässä Source Packages -osiossa oikeaa hiirennappia painamalla ja valitsemalla New -> Java Package....

Pakkauksen sisälle voidaan luoda luokkia aivan kuten oletuspakkaukseenkin (default package). Alla luodaan juuri luotuun pakkaukseen kirjasto luokka Sovellus.

Luokan pakkaus -- eli pakkaus, jossa luokka sijaitsee -- ilmaistaan lähdekooditiedoston alussa lauseella package pakkaus;. Alla oleva luokka Sovellus sijaitsee pakkauksessa kirjasto.

package kirjasto;

public class Sovellus {

    public static void main(String[] args) {
        System.out.println("Hello packageworld!");
    }
}

Jokainen pakkaus -- myös oletuspakkaus eli default package -- voi sisältää useampia pakkauksia. Esimerkiksi pakkausmäärittelyssä package kirjasto.domain pakkaus domain on pakkauksen kirjasto sisällä. Edellä käytettyä nimeä domain käytetään usein kuvaamaan sovellusalueen käsitteisiin liittyvien luokkien säilytyspaikkaa. Esimerkiksi luokka Kirja voisi hyvin olla pakkauksen kirjasto.domain sisällä, sillä se kuvaa kirjastosovellukseen liittyvää käsitettä.

package kirjasto.domain;

public class Kirja {
    private String nimi;

    public Kirja(String nimi) {
        this.nimi = nimi;
    }

    public String getNimi() {
        return this.nimi;
    }
}

Pakkauksissa olevia luokkia tuodaan luokan käyttöön import-lauseen avulla. Pakkauksessa kirjasto.domain oleva luokka Kirja tuodaan käyttöön puolipisteeseen päättyvällä lauseella import kirjasto.domain.Kirja. Luokkien tuomiseen käytetyt import-lauseet asetetaan lähdekooditiedostoon pakkausmäärittelyn jälkeen.

package kirjasto;

import kirjasto.domain.Kirja;

public class Sovellus {

    public static void main(String[] args) {
        Kirja kirja = new Kirja("pakkausten ABC!");
        System.out.println("Hello packageworld: " + kirja.getNimi());
    }
}
Hello packageworld: pakkausten ABC!

Jatkossa kaikissa tehtävissämme käytetään pakkauksia. Luodaan seuraavaksi ensimmäiset pakkaukset itse.

Käyttöliittymä-rajapinta

Luo projektipohjaan pakkaus mooc. Rakennetaan tämän pakkauksen sisälle sovelluksen toiminta. Lisää pakkaukseen mooc pakkaus ui (tämän jälkeen käytössä pitäisi olla pakkaus mooc.ui), ja lisää sinne rajapinta Kayttoliittyma.

Rajapinnan Kayttoliittyma tulee määritellä metodi void paivita().

Tekstikäyttöliittymä

Luo samaan pakkaukseen luokka Tekstikayttoliittyma, joka toteuttaa rajapinnan Kayttoliittyma. Toteuta luokassa Tekstikayttoliittyma rajapinnan Kayttoliittyma vaatima metodi public void paivita() siten, että sen ainut tehtävä on merkkijonon "Päivitetään käyttöliittymää"-tulostaminen System.out.println-metodikutsulla.

Sovelluslogiikka

Luo tämän jälkeen pakkaus mooc.logiikka, ja lisää sinne luokka Sovelluslogiikka. Sovelluslogiikan tarjoaman toiminnallisuuden tulee olla seuraavanlainen.

  • public Sovelluslogiikka(Kayttoliittyma kayttoliittyma)
  • Sovelluslogiikka-luokan konstruktori. Saa parametrina Kayttoliittyma-rajapinnan toteuttavan luokan. Huom: jotta sovelluslogiikka näkisi rajapinnan, on sen "importoitava" se, eli tarvitset tiedoston alkuun rivin import mooc.ui.Kayttoliittyma;
  • public void suorita(int montaKertaa)
  • Tulostaa montaKertaa-muuttujan määrittelemän määrän merkkijonoa "Sovelluslogiikka toimii". Jokaisen "Sovelluslogiikka toimii"-tulostuksen jälkeen tulee kutsua konstruktorin parametrina saadun rajapinnan Kayttoliittyma-toteuttaman olion määrittelemää paivita()-metodia.

Voit testata sovelluksen toimintaa seuraavalla pääohjelmaluokalla.

import mooc.logiikka.Sovelluslogiikka;
import mooc.ui.Kayttoliittyma;
import mooc.ui.Tekstikayttoliittyma;

public class Main {

    public static void main(String[] args) {
        Kayttoliittyma kayttoliittyma = new Tekstikayttoliittyma();
        new Sovelluslogiikka(kayttoliittyma).suorita(3);
    }
}
Sovelluslogiikka toimii
Päivitetään käyttöliittymää
Sovelluslogiikka toimii
Päivitetään käyttöliittymää
Sovelluslogiikka toimii
Päivitetään käyttöliittymää

Hakemistorakenne tiedostojärjestelmässä

Kaikki NetBeansissa näkyvät projektit ovat tietokoneesi tiedostojärjestelmässä tai jollain keskitetyllä levypalvelimella. Jokaiselle projektille on olemassa oma hakemisto, jonka sisällä on projektiin liittyvät tiedostot ja hakemistot.

Projektin hakemistossa src on ohjelmaan liittyvät lähdekoodit. Jos luokan pakkauksena on kirjasto, sijaitsee se projektin lähdekoodihakemiston src sisällä olevassa hakemistossa kirjasto. NetBeansissa voi käydä katsomassa projektien konkreettista rakennetta Files-välilehdeltä joka on normaalisti Projects-välilehden vieressä. Jos et näe välilehteä Files, saa sen näkyville valitsemalla vaihtoehdon Files valikosta Window.

Sovelluskehitystä tehdään normaalisti Projects-välilehdeltä, jossa NetBeans on piilottanut projektiin liittyviä tiedostoja joista ohjelmoijan ei tarvitse välittää.

Pakkaukset ja näkyvyysmääreet

Olemme tähän mennessä käyttäneet kahta näkyvyysmäärettä. Näkyvyysmääreellä private määritellään muuttujia (ja metodeja), jotka ovat näkyvissä vain sen luokan sisällä joka määrittelee ne. Niitä ei voi käyttää luokan ulkopuolelta. Näkyvyysmääreellä public varustetut metodit ja muuttujat ovat taas kaikkien käytettävissä.

package kirjasto.ui;

public class Kayttoliittyma {
    private Scanner lukija;

    public Kayttoliittyma(Scanner lukija) {
        this.lukija = lukija;
    }

    public void kaynnista() {
        tulostaOtsikko();

        // muu toiminnallisuus
    }

    private void tulostaOtsikko() {
        System.out.println("************");
        System.out.println("* KIRJASTO *");
        System.out.println("************");
    }
}

Yllä olevasta Kayttoliittyma-luokasta tehdyn olion konstruktori ja kaynnista-metodi on kutsuttavissa mistä tahansa ohjelmasta. Metodi tulostaOtsikko ja lukija-muuttuja on käytössä vain luokan sisällä.

Jos näkyvyysmäärettä ei määritellä, metodit ja muuttujat ovat näkyvillä saman pakkauksen sisällä. Tätä kutsutaan oletus- tai pakkausnäkyvyydeksi. Muutetaan yllä olevaa esimerkkiä siten, että metodilla tulostaOtsikko on pakkausnäkyvyys.

package kirjasto.ui;

public class Kayttoliittyma {
    private Scanner lukija;

    public Kayttoliittyma(Scanner lukija) {
        this.lukija = lukija;
    }

    public void kaynnista() {
        tulostaOtsikko();

        // muu toiminnallisuus
    }

    void tulostaOtsikko() {
        System.out.println("************");
        System.out.println("* KIRJASTO *");
        System.out.println("************");
    }
}

Nyt saman pakkauksen sisällä olevat luokat -- eli luokat, jotka sijaitsevat pakkauksessa kirjasto.ui voivat käyttää metodia tulostaOtsikko.

package kirjasto.ui;

import java.util.Scanner;

public class Main {

    public static void main(String[] args) {
        Scanner lukija = new Scanner(System.in);
        Kayttoliittyma kayttoliittyma = new Kayttoliittyma(lukija);

        kayttoliittyma.tulostaOtsikko(); // onnistuu!
    }
}

Jos luokka on eri pakkauksessa, ei metodia tulostaOtsikko pysty käyttämään. Alla olevassa esimerkissä luokka Main on pakkauksessa kirjasto, jolloin pakkauksessa kirjasto.ui pakkausnäkyvyydellä määriteltyyn metodiin tulostaOtsikko ei pääse käsiksi.

package kirjasto;

import java.util.Scanner;
import kirjasto.ui.Kayttoliittyma;

public class Main {

    public static void main(String[] args) {
        Scanner lukija = new Scanner(System.in);
        Kayttoliittyma kayttoliittyma = new Kayttoliittyma(lukija);

        kayttoliittyma.tulostaOtsikko(); // ei onnistu!
    }
}

Tässä tehtävässä pääset suunnittelemaan vapaasti ohjelman rakenteen. Käyttöliittymän ulkomuoto sekä vaaditut komennot on määritelty ennalta. Tehtävä on kolmen yksittäisen tehtäväpisteen arvoinen.

Huom: jotta testit toimisivat, saat luoda ohjelmassasi vain yhden Scanner-olion käyttäjän syötteen lukemiseen.

Lentokenttä-tehtävässä toteutetaan lentokentän hallintasovellus. Lentokentän hallintasovelluksessa hallinnoidaan lentokoneita ja lentoja. Lentokoneista tiedetään aina tunnus ja kapasiteetti. Lennoista tiedetään lennon lentokone, lähtöpaikan tunnus (esim. HEL) ja kohdepaikan tunnus (esim. BAL).

Sekä lentokoneita että lentoja voi olla useita. Samalla lentokoneella voidaan myös lentää useita eri lentoja.

Sovelluksen tulee toimia kahdessa vaiheessa: ensin syötetään lentokoneiden ja lentojen tietoja hallintakäyttöliittymässä, jonka jälkeen siirrytään lentopalvelun käyttöön. Lentopalvelussa on kolme toimintoa; lentokoneiden tulostaminen, lentojen tulostaminen, ja lentokoneen tietojen tulostaminen. Tämän lisäksi käyttäjä voi poistua ohjelmasta valitsemalla vaihtoehdon x. Jos käyttäjä syöttää epäkelvon komennon, kysytään komentoa uudestaan.

Lentokentän hallinta
--------------------

Valitse toiminto:
[1] Lisää lentokone
[2] Lisää lento
[x] Poistu hallintamoodista
> 1
Anna lentokoneen tunnus: HA-LOL
Anna lentokoneen kapasiteetti: 42
Valitse toiminto:
[1] Lisää lentokone
[2] Lisää lento
[x] Poistu hallintamoodista
> 1
Anna lentokoneen tunnus: G-OWAC
Anna lentokoneen kapasiteetti: 101
Valitse toiminto:
[1] Lisää lentokone
[2] Lisää lento
[x] Poistu hallintamoodista
> 2
Anna lentokoneen tunnus: HA-LOL
Anna lähtöpaikan tunnus: HEL
Anna kohdepaikan tunnus: BAL
Valitse toiminto:
[1] Lisää lentokone
[2] Lisää lento
[x] Poistu hallintamoodista
> 2
Anna lentokoneen tunnus: G-OWAC
Anna lähtöpaikan tunnus: JFK
Anna kohdepaikan tunnus: BAL
Valitse toiminto:
[1] Lisää lentokone
[2] Lisää lento
[x] Poistu hallintamoodista
> 2
Anna lentokoneen tunnus: HA-LOL
Anna lähtöpaikan tunnus: BAL
Anna kohdepaikan tunnus: HEL
Valitse toiminto:
[1] Lisää lentokone
[2] Lisää lento
[x] Poistu hallintamoodista
> x

Lentopalvelu
------------

Valitse toiminto:
[1] Tulosta lentokoneet
[2] Tulosta lennot
[3] Tulosta lentokoneen tiedot
[x] Lopeta
> 1
G-OWAC (101 henkilöä)
HA-LOL (42 henkilöä)
Valitse toiminto:
[1] Tulosta lentokoneet
[2] Tulosta lennot
[3] Tulosta lentokoneen tiedot
[x] Lopeta
> 2
HA-LOL (42 henkilöä) (HEL-BAL)
HA-LOL (42 henkilöä) (BAL-HEL)
G-OWAC (101 henkilöä) (JFK-BAL)

Valitse toiminto:
[1] Tulosta lentokoneet
[2] Tulosta lennot
[3] Tulosta lentokoneen tiedot
[x] Lopeta
> 3
Mikä kone: G-OWAC
G-OWAC (101 henkilöä)

Valitse toiminto:
[1] Tulosta lentokoneet
[2] Tulosta lennot
[3] Tulosta lentokoneen tiedot
[x] Lopeta
> x

Huom1: Testien kannalta on oleellista että käyttöliittymä toimii täsmälleen kuten yllä kuvattu. Ohjelman tulostamat vaihtoehdot kannattanee copypasteta tästä ohjelmakoodiin. Testit eivät oleta, että ohjelmasi on varautunut epäkelpoihin syötteisiin.

Huom2: älä käytä luokkein nimissä skandeja, ne saattavat aiheuttaa ongelmia testeihin!

Ohjelman tulee käynnistyä kun pakkauksessa lentokentta olevan luokan Main metodi main suoritetaan.

Poikkeukset

Poikkeukset ovat tilanteita, joissa ohjelman suoritus päättyy virheeseen. Ohjelmassa on esimerkiksi kutsuttu null-viitteeseen liittyvää metodia, jolloin ohjelmassa tapahtuu poikkeus NullPointerException. Vastaavasti taulukon ulkopuolella olevan indeksin hakeminen johtaa poikkeukseen IndexOutOfBoundsException ym.

Osa Javassa esiintyvistä poikkeuksista on sellaisia, että niihin tulee aina varautua. Näitä ovat esimerkiksi tiedoston lukemisessa tapahtuvaan virheeseen tai verkkoyhteyden katkeamiseen liittyvät poikkeukset. Osa poikkeuksista taas on ajonaikaisia poikkeuksia -- kuten vaikkapa NullPointerException --, joihin ei erikseen tarvitse varautua. Java ilmoittaa aina jos ohjelmassa on lause tai lauseke, jossa mahdollisesti tapahtuvaan poikkeukseen tulee varautua.

Poikkeusten käsittely

Poikkeukset käsitellään try { } catch (Exception e) { } -lohkorakenteella. Avainsanan try aloittaman lohkon sisällä on lähdekoodi, jonka suorituksessa tapahtuu mahdollisesti poikkeus. Avainsanan catch aloittaman lohkon sisällä taas määritellään poikkeustilanteessa tapahtuva käsittely, eli mitä tehdään kun try-lohkossa tapahtuu poikkeus. Avainsanaa catch seuraa myös käsiteltävän poikkeuksen tyyppi, esimerkiksi "kaikki poikkeukset" eli Exception (catch (Exception e)).

try {
    // poikkeuksen mahdollisesti heittävä ohjelmakoodi
} catch (Exception e) {
    // lohko johon päädytään poikkeustilanteessa
}

Avainsana catch eli ota kiinni tulee siitä, että poikkeukset heitetään (throw).

Kuten edellä todettiin, ajonaikaisiin poikkeuksiin kuten NullPointerException ei tarvitse erikseen varautua. Tällaiset poikkeukset voidaan jättää käsittelemättä, jolloin ohjelman suoritus päättyy virheeseen poikkeustilanteen tapahtuessa. Tarkastellaan erästä poikkeustilannetta nyt jo tutun merkkijonon kokonaisluvuksi muuntamisen kautta.

Olemme käyttäneet luokan Integer metodia parseInt merkkijonon kokonaisluvuksi muuntamiseen. Metodi heittää poikkeuksen NumberFormatException, jos sille parametrina annettu merkkijono ei ole muunnettavissa kokonaisluvuksi.

Scanner lukija = new Scanner(System.in);
System.out.print("Syötä numero: ");

int numero = Integer.parseInt(lukija.nextLine());
Syötä numero: tatti
  Exception in thread "..." java.lang.NumberFormatException: For input string: "tatti"

Yllä ohjelma heittää poikkeuksen, kun käyttäjä syöttää virheellisen numeron. Ohjelman suoritus päättyy tällöin virhetilanteeseen.

Lisätään esimerkkiin poikkeuksen käsittely. Kutsu, joka saattaa heittää poikkeuksen asetetaan try-lohkon sisään, ja virhetilanteessa tapahtuva toiminta catch-lohkon sisään.

Scanner lukija = new Scanner(System.in);

System.out.print("Syötä numero: ");
int numero = -1;

try {
    numero = Integer.parseInt(lukija.nextLine());
} catch (Exception e) {
    System.out.println("Et syöttänyt kunnollista numeroa.");
}
Syötä numero: 5
Syötä numero: enpäs!
Et syöttänyt kunnollista numeroa.

Avainsanan try määrittelemän lohkon sisältä siirrytään catch-lohkoon heti poikkeuksen tapahtuessa. Havainnollistetaan tätä lisäämällä tulostuslause try-lohkossa metodia Integer.parseInt kutsuvan rivin jälkeen.

Scanner lukija = new Scanner(System.in);

System.out.print("Syötä numero: ");
int numero = -1;

try {
    numero = Integer.parseInt(lukija.nextLine());
    System.out.println("Hienosti syötetty!");
} catch (Exception e) {
    System.out.println("Et syöttänyt kunnollista numeroa.");
}
Syötä numero: 5
Hienosti syötetty!
Syötä numero: enpäs!
Et syöttänyt kunnollista numeroa.

Ohjelmalle syötetty merkkijono enpäs! annetaan parametrina Integer.parseInt-metodille, joka heittää poikkeuksen, jos parametrina saadun merkkijonon muuntaminen luvuksi epäonnistuu. Huomaa, että catch-lohkossa oleva koodi suoritetaan vain poikkeustapauksissa.

Tehdään yllä olevasta luvun muuntajasta hieman hyödyllisempi. Tehdään siitä metodi, joka kysyy numeroa yhä uudestaan, kunnes käyttäjä syöttää oikean numeron. Metodin suoritus loppuu vasta silloin, kun käyttäjä syöttää kokonaisluvun.

public int lueLuku(Scanner lukija) {
    while (true) {
        System.out.print("Syötä numero: ");

        try {
            int numero = Integer.parseInt(lukija.nextLine());
            return numero;
        } catch (Exception e) {
            System.out.println("Et syöttänyt kunnollista numeroa.");
        }
    }
}
Syötä numero: enpäs!
Et syöttänyt kunnollista numeroa.
Syötä numero: Matilla on ovessa tatti.
Et syöttänyt kunnollista numeroa.
Syötä numero: 43

Käsittelyvastuun siirtäminen

Metodit ja konstruktorit voivat heittää poikkeuksia. Heitettäviä poikkeuksia on karkeasti ottaen kahdenlaisia. On poikkeuksia jotka on pakko käsitellä, ja on poikkeuksia joita ei ole pakko käsitellä. Poikkeukset käsitellään joko try-catch -lohkossa, tai heittämällä ne ulos metodista.

Alla olevassa esimerkissä luetaan parametrina annetun tiedoston rivit yksitellen. Tiedoston lukeminen saattaa heittää poikkeuksen -- voi olla, ettei tiedostoa esimerkiksi löydy, tai voi olla ettei siihen ole lukuoikeuksia. Tällainen poikkeus tulee käsitellä. Poikkeuksen käsittely tapahtuu try-catch -lauseella. Seuraavassa esimerkissä emme juurikaan välitä poikkeustilanteesta, mutta tulostamme kuitenkin poikkeukseen liittyvän viestin.

public List<String> lue(String tiedosto) {
    List<String> rivit = new ArrayList<>();

    try {
        Files.lines(Paths.get("tiedosto.txt")).forEach(rivi -> rivit.add(rivi));
    } catch (Exception e) {
        System.out.println("Virhe: " + e.getMessage());
    }

    return rivit;
}

Ohjelmoija voi myös jättää poikkeuksen käsittelemättä ja siirtää vastuun poikkeuksen käsittelystä metodin kutsujalle. Vastuun siirto tapahtuu heittämällä poikkeus metodista eteenpäin lisäämällä tästä tieto metodin määrittelyyn. Tieto poikkeuksen heitosta -- throws PoikkeusTyyppi, missä poikkeustyyppi esimerkiksi Exception -- lisätään ennen metodirungon avaavaa aaltosulkua.

public List<String> lue(String tiedosto) throws Exception {
    ArrayList<String> rivit = new ArrayList<>();
    Files.lines(Paths.get(tiedosto)).forEach(rivi -> rivit.add(rivi));
    return rivit;
}

Nyt metodia lue kutsuvan metodin tulee joko käsitellä poikkeus try-catch -lohkossa tai siirtää poikkeuksen käsittelyn vastuuta eteenpäin. Joskus poikkeuksen käsittelyä vältetään viimeiseen asti, ja main-metodikin heittää poikkeuksen käsiteltäväksi eteenpäin:

public class Paaohjelma {
   public static void main(String[] args) throws Exception {
       // ...
   }
}

Tällöin mahdollinen poikkeus päätyy ohjelman suorittajalle eli Javan virtuaalikoneelle, joka keskeyttää ohjelman suorituksen poikkeukseen johtavan virheen tapahtuessa.

Poikkeusten heittäminen

Voimme heittää poikkeuksen throw-komennolla. Esimerkiksi NumberFormatException-luokasta luodun poikkeuksen heittäminen tapahtuisi komennolla throw new NumberFormatException(). Seuraava ohjelma päätyy aina poikkeustilaan.

public class Ohjelma {

    public static void main(String[] args) throws Exception {
        throw new NumberFormatException(); // Ohjelmassa heitetään poikkeus
    }
}

Eräs poikkeus, johon käyttäjän ei ole pakko varautua on IllegalArgumentException. Poikkeuksella IllegalArgumentException kerrotaan että metodille tai konstruktorille annettujen parametrien arvot ovat vääränlaiset. IllegalArgumentException-poikkeusta käytetään esimerkiksi silloin, kun halutaan varmistaa, että parametreilla on tietyt arvot.

Luodaan luokka Arvosana, joka saa konstruktorin parametrina kokonaislukutyyppisen arvosanan.

public class Arvosana {
    private int arvosana;

    public Arvosana(int arvosana) {
        this.arvosana = arvosana;
    }

    public int getArvosana() {
        return this.arvosana;
    }
}

Haluamme seuraavaksi varmistaa, että Arvosana-luokan konstruktorin parametrina saatu arvo täyttää tietyt kriteerit. Arvosanan tulee olla aina välillä 0-5. Jos arvosana on jotain muuta, haluamme heittää poikkeuksen. Lisätään Arvosana-luokan konstruktoriin ehtolause, joka tarkistaa onko arvosana arvovälin 0-5 ulkopuolella. Jos on, heitetään poikkeus IllegalArgumentException sanomalla throw new IllegalArgumentException("Arvosanan tulee olla välillä 0-5");.

public class Arvosana {
    private int arvosana;

    public Arvosana(int arvosana) {
        if (arvosana < 0 || arvosana > 5) {
            throw new IllegalArgumentException("Arvosanan tulee olla välillä 0-5");
        }

        this.arvosana = arvosana;
    }

    public int getArvosana() {
        return this.arvosana;
    }
}
Arvosana arvosana = new Arvosana(3);
System.out.println(arvosana.getArvosana());

Arvosana virheellinenArvo = new Arvosana(22);
// tapahtuu poikkeus, tästä ei jatketa eteenpäin
3
Exception in thread "..." java.lang.IllegalArgumentException: Arvosanan tulee olla välillä 0-5

Jos poikkeus on esimerkiksi tyyppiä IllegalArgumentException, tai yleisemmin ajonaikainen poikkeus, ei sen heittämisestä tarvitse kirjoittaa erikseen metodin määrittelyyn.

Harjoitellaan hieman parametrien validointia IllegalArgumentException-poikkeuksen avulla. Tehtäväpohjassa tulee kaksi luokkaa, Henkilo ja Laskin. Muuta luokkia seuraavasti:

Henkilön validointi

Luokan Henkilo konstruktorin tulee varmistaa että parametrina annettu nimi ei ole null, tyhjä tai yli 40 merkkiä pitkä. Myös iän tulee olla väliltä 0-120. Jos joku edelläolevista ehdoista ei päde, tulee konstruktorin heittää IllegalArgumentException-poikkeus.

Laskimen validointi

Luokan Laskin metodeja tulee muuttaa seuraavasti: Metodin kertoma tulee toimia vain jos parametrina annetaan ei-negatiivinen luku (0 tai suurempi). Metodin binomikerroin tulee toimia vain jos parametrit ovat ei-negatiivisia ja osajoukon koko on pienempi kuin joukon koko. Jos jompikumpi metodeista saa epäkelpoja arvoja metodikutsujen yhteydessä, tulee metodien heittää poikkeus IllegalArgumentException.

Poikkeukset ja rajapinnat

Rajapintaluokilla ei ole metodirunkoa, mutta metodimäärittely on vapaasti rajapinnan suunnittelijan toteutettavissa. Rajapintaluokissa voidaan määritellä metodeja, jotka saattavat heittää poikkeuksen. Esimerkiksi seuraavan rajapinnan Tiedostopalvelin toteuttavat luokat heittävät mahdollisesti poikkeuksen lataa- ja tallenna-metodissa.

public interface Tiedostopalvelin {
    String lataa(String tiedosto) throws Exception;
    void tallenna(String tiedosto, String merkkijono) throws Exception;
}

Jos rajapinta määrittelee metodeille throws Exception-määreet, eli että metodit heittävät mahdollisesti poikkeuksen, tulee samat määreet olla myös rajapinnan toteuttavassa luokassa. Luokan ei kuitenkaan ole pakko heittää poikkeusta kuten alla olevasta esimerkistä näkee.

public class Tekstipalvelin implements Tiedostopalvelin {

    private Map<String, String> data;

    public Tekstipalvelin() {
        this.data = new HashMap<>();
    }

    @Override
    public String lataa(String tiedosto) throws Exception {
        return this.data.get(tiedosto);
    }

    @Override
    public void tallenna(String tiedosto, String merkkijono) throws Exception {
        this.data.put(tiedosto, merkkijono);
    }
}

Poikkeuksen tiedot

Poikkeusten käsittelytoiminnallisuuden sisältämä catch-lohko määrittelee catch-osion sisällä poikkeuksen johon varaudutaan catch (Exception e). Poikkeuksen tiedot tallennetaan e-muuttujaan.

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

Luokka Exception tarjoaa hyödyllisiä metodeja. Esimerkiksi metodi printStackTrace() tulostaa stack tracen, joka kertoo miten poikkeukseen päädyttiin. Tutkitaan seuraavaa metodin printStackTrace() tulostamaa virhettä.

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

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

Kaikki luotavat luokat tulee sijoittaa pakkaukseen sovellus.

Käytössämme on seuraava rajapinta:

public interface Sensori {
    boolean onPaalla();  // palauttaa true jos sensori on päällä
    void paalle();       // käynnistä sensorin
    void poisPaalta();   // sulkee sensorin
    int mittaa();        // palauttaa sensorin lukeman jos sensori on päällä
                         // jos sensori ei ole päällä heittää poikkeuksen
                         // IllegalStateException
}

Vakiosensori

Tee luokka Vakiosensori joka toteuttaa rajapinnan Sensori.

Vakiosensori on koko ajan päällä. Metodien paalle ja poisPaalta kutsuminen ei tee mitään. Vakiosensorilla tulee olla konstruktori, jonka parametrina on kokonaisluku. Metodikutsu mittaa palauttaa aina konstruktorille parametrina annetun luvun.

Esimerkki:

public static void main(String[] args) {
    Vakiosensori kymppi = new Vakiosensori(10);
    Vakiosensori miinusViis = new Vakiosensori(-5);

    System.out.println(kymppi.mittaa());
    System.out.println(miinusViis.mittaa());

    System.out.println(kymppi.onPaalla());
    kymppi.poisPaalta();
    System.out.println(kymppi.onPaalla());
}
10
-5
true
true

Lampomittari

Tee luokka Lampomittari, joka toteuttaa rajapinnan Sensori.

Aluksi lämpömittari on poissa päältä. Kutsuttaessa metodia mittaa kun mittari on päällä mittari arpoo luvun väliltä -30...30 ja palauttaa sen kutsujalle. Jos mittari ei ole päällä, heitetään poikkeus IllegalStateException.

Käytä Javan valmista luokkaa Random satunnaisen luvun arpomiseen. Saat luvun väliltä 0...60 kutsulla new Random().nextInt(61); -- väliltä -30...30 arvotun luvun saa vähentämällä väliltä 0...60 olevasta luvusta sopiva luku.

Keskiarvosensori

Tee luokka Keskiarvosensori, joka toteuttaa rajapinnan Sensori.

Keskiarvosensori sisältää useita sensoreita. Rajapinnan Sensori määrittelemien metodien lisäksi keskiarvosensorilla on metodi public void lisaaSensori(Sensori lisattava) jonka avulla keskiarvosensorin hallintaan lisätään uusi sensori.

Keskiarvosensori on päällä silloin kuin kaikki sen sisältävät sensorit ovat päällä. Kun keskiarvosensori käynnistetään, täytyy kaikkien sen sisältävien sensorien käynnistyä jos ne eivät ole käynnissä. Kun keskiarvosensori suljetaan, täytyy ainakin yhden sen sisältävän sensorin mennä pois päältä. Saa myös käydä niin että kaikki sen sisältävät sensorit menevät pois päältä.

Keskiarvosensorin metodi mittaa palauttaa sen sisältämien sensoreiden lukemien keskiarvon (koska paluuarvo on int, pyöristyy lukema alaspäin kuten kokonaisluvuilla tehdyissä jakolaskuissa). Jos keskiarvosensorin metodia mittaa kutsutaan sensorin ollessa poissa päältä, tai jos keskiarvosensorille ei vielä ole lisätty yhtään sensoria heitetään poikkeus IllegalStateException.

Seuraavassa sensoreja käyttävä esimerkkiohjelma (huomaa, että sekä Lämpömittarin että Keskiarvosensorin konstruktorit ovat parametrittomia):

public static void main(String[] args) {
    Sensori kumpula = new Lampomittari();
    kumpula.paalle();
    System.out.println("lämpötila Kumpulassa " + kumpula.mittaa() + " astetta");

    Sensori kaisaniemi = new Lampomittari();
    Sensori helsinkiVantaa = new Lampomittari();

    Keskiarvosensori paakaupunki = new Keskiarvosensori();
    paakaupunki.lisaaSensori(kumpula);
    paakaupunki.lisaaSensori(kaisaniemi);
    paakaupunki.lisaaSensori(helsinkiVantaa);

    paakaupunki.paalle();
    System.out.println("lämpötila Pääkaupunkiseudulla "+paakaupunki.mittaa() + " astetta");
}

Alla olevan esimerkin tulostukset riippuvat arvotuista lämpötiloista:

lämpötila Kumpulassa 11 astetta
lämpötila Pääkaupunkiseudulla 8 astetta

Kaikki mittaukset

Lisää luokalle Keskiarvosensori metodi public List<Integer> mittaukset(), joka palauttaa listana kaikkien keskiarvosensorin avulla suoritettujen mittausten tulokset. Seuraavassa esimerkki metodin toiminnasta:

public static void main(String[] args) {
    Sensori kumpula = new Lampomittari();
    Sensori kaisaniemi = new Lampomittari();
    Sensori helsinkiVantaa = new Lampomittari();

    Keskiarvosensori paakaupunki = new Keskiarvosensori();
    paakaupunki.lisaaSensori(kumpula);
    paakaupunki.lisaaSensori(kaisaniemi);
    paakaupunki.lisaaSensori(helsinkiVantaa);

    paakaupunki.paalle();
    System.out.println("lämpötila Pääkaupunkiseudulla "+paakaupunki.mittaa() + " astetta");
    System.out.println("lämpötila Pääkaupunkiseudulla "+paakaupunki.mittaa() + " astetta");
    System.out.println("lämpötila Pääkaupunkiseudulla "+paakaupunki.mittaa() + " astetta");

    System.out.println("mittaukset: "+paakaupunki.mittaukset());
}

Alla olevan esimerkin tulostukset riippuvat jälleen arvotuista lämpötiloista:

lämpötila Pääkaupunkiseudulla -10 astetta
lämpötila Pääkaupunkiseudulla -4 astetta
lämpötila Pääkaupunkiseudulla 5 astetta

mittaukset: [-10, -4, 5]

Lisää rajapinnoista

Rajapinta määrittelee yhden tai useamman metodin, jotka rajapinnan toteuttavan luokan on pakko toteuttaa. Rajapintoja, kuten kaikkia luokkia voi asettaa pakkauksiin. Esimerkiksi seuraava Tunnistettava-rajapinta sijaitsee pakkauksessa sovellus.domain. Rajapinta määrää, että Tunnistettava-rajapinnan toteuttavien luokkien tulee toteuttaa metodi public String getTunnus().

package sovellus.domain;

public interface Tunnistettava {
    String getTunnus();
}

Luokka toteuttaa rajapinnan implements-avainsanalla. Alla on esimerkkinä luokka Henkilo, joka toteuttaa rajapinnan tunnistettava. Rajapinnan Tunnistettava vaatima metodi getTunnus palauttaa aina henkilön henkilötunnuksen.

package sovellus.domain;

public class Henkilo implements Tunnistettava {
    private String nimi;
    private String henkilotunnus;

    public Henkilo(String nimi, String henkilotunnus) {
        this.nimi = nimi;
        this.henkilotunnus = henkilotunnus;
    }

    public String getNimi() {
        return this.nimi;
    }

    public String getHenkilotunnus() {
        return this.henkilotunnus;
    }

    @Override
    public String getTunnus() {
        return getHenkilotunnus();
    }

    @Override
    public String toString() {
        return this.nimi + " hetu: " + this.henkilotunnus;
    }
}

Rajapintojen vahvuus on se, että rajapintaa voidaan käyttää muuttujan tyyppinä. Tämä mahdollistaa yleiskäyttöisempien luokkien tekemisen.

Tehdään luokka Rekisteri, jota käytetään Tunnistettava-tyyppisten olioiden säilömiseen. Rekisteriin voidaan lisätä sekä henkilöitä että mitä tahansa muita olioita, jotka toteuttavat rajapinnan Tunnistettava. Yksittäisten henkilöiden hakemisen lisäksi Rekisteri tarjoaa metodin kaikkien henkilöiden hakemiseen listana.

public class Rekisteri {
    private Map<String, Tunnistettava> rekisteroidyt;

    public Rekisteri() {
        this.rekisteroidyt = new HashMap<>();
    }

    public void lisaa(Tunnistettava lisattava) {
        this.rekisteroidyt.put(lisattava.getTunnus(), lisattava);
    }

    public Tunnistettava hae(String tunnus) {
        return this.rekisteroidyt.get(tunnus);
    }

    public List<Tunnistettava> haeKaikki() {
        return new ArrayList<Tunnistettava>(rekisteroidyt.values());
    }
}

Rekisterin käyttö onnistuu seuraavasti.

Rekisteri henkilokunta = new Rekisteri();
henkilokunta.lisaa(new Henkilo("Pekka", "221078-123X"));
henkilokunta.lisaa(new Henkilo("Jukka", "110956-326B"));

System.out.println(henkilokunta.hae("280283-111A"));

Henkilo loydetty = (Henkilo) henkilokunta.hae("110956-326B");
System.out.println(loydetty.getNimi());

Koska henkilöt on lisätty rekisteriin Tunnistettava-tyyppisinä, ne löytyvät sieltä myös Tunnistettava-tyyppisinä. Jos haluamme käsitellä henkilöitä sellaisten metodien kautta, joita rajapinnassa ei ole määritelty, joudumme muuntamaan ne takaisin Henkilo-olioiksi. Tämä tapahtuu eksplisiittisella tyyppimuunnoksella, jota demonstroidaan edellisen esimerkin kahdella viimeisellä rivillä.

Entä jos haluaisimme rekisteriin lisäksi metodin, joka palauttaa rekisteriin talletetut henkilöt tunnisteen mukaan järjestettynä? Yksi vaihtoehto olisi käyttää aiemmin tutuksi tullutta virran järjestämistä. Tutustutaan kuitenkin myös Javan valmiiseen järjestämisessä käytettävään rajapintaan.

Järjestämisessä käytettävä rajapinta Comparable

Javan valmis rajapinta Comparable määrittelee metodin compareTo, jota käytetään olioiden vertailuun. Jos olio on vertailujärjestyksessä ennen parametrina saatavaa olioa, tulee metodin palauttaa negatiivinen luku. Jos taas olio on järjestyksessä parametrina saatavan olion jälkeen, tulee metodin palauttaa positiivinen luku. Muulloin palautetaan luku 0. Tätä compareTo-metodin avulla johdettua järjestystä kutsutaan luonnolliseksi järjestykseksi (natural ordering).

Tarkastellaan tätä ensin kerhossa käyvää lasta tai nuorta kuvaavan luokan Kerholainen avulla. Jokaisella kerholaisella on nimi ja pituus. Kerholaisten tulee mennä syömään pituusjärjestyksessä, joten toteutetaan kerholaisille rajapinta Comparable. Comparable-rajapinta ottaa tyyppiparametrinaan luokan, johon vertaus tehdään. Käytetään tyyppiparametrina samaa luokkaa Kerholainen.

public class Kerholainen implements Comparable<Kerholainen> {
    private String nimi;
    private int pituus;
    
    public Kerholainen(String nimi, int pituus) {
	this.nimi = nimi;
	this.pituus = pituus;
    }
    
    public String getNimi() {
	return this.nimi;
    }
    
    public int getPituus() {
	return this.pituus;
    }
    
    @Override
    public String toString() {
	return this.getNimi() + " (" + this.getPituus() + ")";
    }
    
    @Override
    public int compareTo(Kerholainen kerholainen) {
	if (this.pituus == kerholainen.getPituus()) {
	    return 0;
	} else if (this.pituus > kerholainen.getPituus()) {
	    return 1;
	} else {
	    return -1;
	}
    }
}

Rajapinnan vaatima metodi compareTo palauttaa kokonaisluvun, joka kertoo vertausjärjestyksestä. Koska compareTo()-metodista riittää palauttaa negatiivinen luku, jos this-olio on pienempi kuin parametrina annettu olio ja nolla, kun pituudet ovat samat, voidaan edellä esitelty metodi compareTo toteuttaa myös seuraavasti.

@Override
public int compareTo(Kerholainen kerholainen) {
    return this.pituus - kerholainen.getPituus();
}

Kerholaisten järjestäminen on nyt suoraviivaista.

List<Kerholainen> kerholaiset = new ArrayList<>();
kerholaiset.add(new Kerholainen("mikael", 182));
kerholaiset.add(new Kerholainen("matti", 187));
kerholaiset.add(new Kerholainen("ada", 184));

kerholaiset.stream().forEach(k -> System.out.println(k);
System.out.println();
kerholaiset.stream().sorted().forEach(k -> System.out.println(k);
mikael (182)
matti (187)
ada (184)
  
mikael (182)
ada (184)
matti (187)

Koska Kerholainen toteuttaa rajapinnan Comparable, ei virran sorted-metodille tarvitse enää antaa parametrina olioiden vertailuun liittyvää järjestystä. Toisin sanoen, minkä tahansa Comparable-rajapinnan toteuttavan luokan oliot voi järjestää virran sorted-metodilla. Huomaa kuitenkin, että virta ei järjestä alkuperäistä listaa, vaan vain virrassa olevat alkiot ovat järjestyksessä -- jos alkuperäisen listan haluaa järjestykseen, tulee lista korvata järjestetystä virrasta kerätyllä listalla.

Saat valmiin luokan Ihminen. Ihmisellä on nimi- ja palkkatiedot. Muokkaa Ihminen-luokasta Comparable-rajapinnan toteuttava niin, että compareTo-metodi lajittelee ihmiset palkan mukaan järjestykseen isoimmasta palkasta pienimpään.

Saat valmiin luokan Opiskelija. Opiskelijalla on nimi. Muokkaa Opiskelija-luokasta Comparable-rajapinnan toteuttava niin, että compareTo-metodi lajittelee opiskelijat nimen mukaan aakkosjärjestykseen.

Vinkki: Opiskelijan nimi on String, ja String-luokka on itsessään Comparable. Voit hyödyntää String-luokan compareTo-metodia Opiskelija-luokan metodia toteuttaessasi. String.compareTo kohtelee kirjaimia eriarvoisesti kirjainkoon mukaan, ja tätä varten String-luokalla on myös metodi compareToIgnoreCase joka nimensä mukaisesti jättää kirjainkoon huomioimatta. Voit käyttää opiskelijoiden järjestämiseen kumpaa näistä haluat.

Useamman rajapinnan toteuttaminen

Luokka voi toteuttaa useamman rajapinnan. Useamman rajapinnan toteuttaminen tapahtuu erottamalla toteutettavat rajapinnat toisistaan pilkuilla (public class ... implements RajapintaEka, RajapintaToka ...). Toteuttaessamme useampaa rajapintaa, tulee meidän toteuttaa kaikki rajapintojen vaatimat metodit. Toteutetaan seuraavaksi luokalle Henkilo rajapinta Comparable.

package sovellus.domain;

public class Henkilo implements Tunnistettava, Comparable<Henkilo> {
    private String nimi;
    private String henkilotunnus;

    public Henkilo(String nimi, String henkilotunnus) {
        this.nimi = nimi;
        this.henkilotunnus = henkilotunnus;
    }

    public String getNimi() {
        return this.nimi;
    }

    public String getHenkilotunnus() {
        return this.henkilotunnus;
    }

    @Override
    public String getTunnus() {
        return getHenkilotunnus();
    }

    @Override
    public int compareTo(Henkilo toinen) {
        return this.getTunnus().compareTo(toinen.getTunnus());
    }
}

Kokeillaan lisätä aiemmin luomallemme Rekisteri-luokalle metodi haeKaikkiJarjestyksessa.

public List<Tunnistettava> haeKaikkiJarjestyksessa() {
    // ei toimi!
    return rekisteroidyt.values()
        .stream().sorted().collect(Collectors.toCollection(ArrayList::new));
}

Metodi ei kuitenkaan toimi. Koska henkilöt on talletettu rekisteriin Tunnistettava-tyyppisinä, on Henkilön toteutettava rajapinta Comparable<Tunnistettava>, jotta rekisteri osaisi järjestää henkilöt tunnistettavina. Joudumme joko muuttamaan henkilön toteuttamaa rajapintaa, tai lisäämään sorted-metodille järjestämiseen käytettävät tiedot. Muutetaan tässä henkilön toteuttamaa rajapintaa:

public class Henkilo implements Tunnistettava, Comparable<Tunnistettava> {
    // ...

    @Override
    public int compareTo(Tunnistettava toinen) {
        return this.getTunnus().compareTo(toinen.getTunnus());
    }
}

Nyt ratkaisu toimii!

Rekisteri on täysin tietämätön sinne lisättyjen olioiden todellisesta tyypistä. Voimme käyttää luokkaa rekisteri myös muuntyyppisten olioiden kuin henkilöiden rekisteröintiin, kunhan olioiden luokka vaan toteuttaa rajapinnan Tunnistettava. Esim. seuraavassa käytetään rekisteriä kaupassa myytävien tuotteiden hallintaan:

public class Tuote implements Tunnistettava {

    private String nimi;
    private String viivakoodi;
    private int varastosaldo;
    private int hinta;

    public Tuote(String nimi, String viivakoodi) {
        this.nimi = nimi;
        this.viivakoodi = viivakoodi;
    }

    public String getTunnus() {
        return viivakoodi;
    }

    // ...
}
Rekisteri tuotteet = new Rekisteri();
tuotteet.lisaa(new Tuote("maito", "11111111"));
tuotteet.lisaa(new Tuote("piimä", "11111112"));
tuotteet.lisaa(new Tuote("juusto", "11111113"));

System.out.println(tuotteet.hae("99999999"));

Tuote tuote = (Tuote) tuotteet.hae("11111112");
tuote.kasvataSaldoa(100);
tuote.muutaHinta(23);

Teimme luokasta Rekisteri melko yleiskäyttöisen pitämällä sen riippumattomana konkreettisista luokista. Mikä tähänsa luokka, joka toteuttaa rajapinnan Tunnistettava, on rekisterin kanssa käyttökelpoinen. Metodin haeKaikkiJarjestyksessä toimiminen tosin edellyttää luokalta myös vertailtavuuden eli Comparable<Tunnistettava>-rajapinnan toteuttamisen.

Muutama NetBeans-vihje
  • Implement all abstract methods

    Voit pyytää NetBeansia täydentämään metodirungot automaattisesti rajapinnan toteuttavalle luokalle. Kun olet määritellyt luokan toteuttavan rajapinnan, eli kirjoittanut

    public class Luokka implements Rajapinta {
    }
    

    NetBeans värjää luokan nimen punaisella. Mene rivin vasemmassa reunassa olevan lamppusymbolin kohdalle, klikkaa ja valitse Implement all abstract methods ja metodirungot ilmestyvät koodiin!

  • Clean and Build

    Tietyissä tilanteissa NetBeans saattaa mennä sekaisin ja yrittää ajaa koodista versiota johon ei ole huomioitu kaikkia koodiin kirjoitettuja muutoksia. Yleensä huomaat tilanteen siten, että jotain "outoa" vaikuttaa tapahtuvan. Ongelman korjaa usein Clean and build -operaation suorittaminen. Operaatio löytyy Run-valikosta ja sen voi suorittaa myös painamalla harja ja vasara -symbolia. Clean and build poistaa koodista olemassa olevat käännetyt versiot ja tekee uuden käännöksen.

Oletusmetodit rajapinnoissa

Rajapintoihin voi määritellä oletusmetodeja, joiden mukana annetaan myös toteutus. Oletusmetodien määrittely alkaa avainsanalla default, jota seuraa metodin määrittely. Kuten rajapintojen metodeissa yleensä, myös tässäkään näkyvyyttä ei tarvitse määritellä erikseen. Rajapinnoissa määriteltyjen metodien näkyvyys on aina public.

Alla olevassa esimerkissä rajapintaan Luettava on lisätty oletusmetodi lueTulostaen, joka tulostaa lue-metodin palauttaman arvon.

public interface Luettava {
    String lue();

    default void lueTulostaen() {
        System.out.println(lue());
    }
}

Yksi oletusmetodien suurimmista hyödyistä ilmenee tilanteissa, missä rajapinta on määritelty aiemmin, ja useampi luokka toteuttaa sen jo valmiiksi. Jos rajapintaan lisätään uusi metodi, tulee sille ohjelmoida toteutus kaikkiin rajapinnan toteuttamiin luokkiin, jos uusi metodi ei tarjoa oletustoteutusta.

Toisaalta, jos oletustoteutus lisätään uuden metodin lisäämisen yhteydessä, ei aiemmin rajapinnan toteuttaneille luokille tarvitse tehdä minkäänlaisia muutoksia. Edellisestä osasta tutut luokat Tekstiviesti ja Sahkoposti toimisivat nyt myös seuraavasti.

Tekstiviesti viesti = new Tekstiviesti("G. Hopper", "COBOL kicks ass");
viesti.lueTulostaen();

Sahkoposti posti = new Sahkoposti("D. Knuth", "If you optimize everything, you will always be unhappy.");
posti.lueTulostaen();
COBOL kicks ass
If you optimize everything, you will always be unhappy.

Järjestäminen ja hakeminen

Tähän mennessä käyttämämme järjestäminen stream-metodin avulla ei muuta alkuperäisen listan järjestystä, vaan se luo aina uuden järjestetyn listan. Tutustutaan seuraavaksi luokkakirjastoon Collections, joka tarjoaa tähän liittyviä yleishyödyllisiä metodeja.

Järjestäminen

Collections tarjoaa metodin sort listan järjestämiseen. Metodi olettaa, että listalla olevat oliot toteuttavat rajapinnan Comparable. Järjestäminen on suoraviivaista.

List<Kerholainen> kerholaiset = new ArrayList<>();
kerholaiset.add(new Kerholainen("mikael", 182));
kerholaiset.add(new Kerholainen("matti", 187));
kerholaiset.add(new Kerholainen("ada", 184));

kerholaiset.stream().forEach(k -> System.out.println(k));
Collections.sort(kerholaiset);

System.out.println();

kerholaiset.stream().forEach(k -> System.out.println(k));
mikael (182)
matti (187)
ada (184)

mikael (182)
ada (184)
matti (187)

Järjestämisen lisäksi luokkakirjaston avulla voi etsiä esimerkiksi minimi- (min-metodi) tai maksimialkioita (max-metodi), vaikkapa kääntää listan (reverse-metodi).

List<Kerholainen> kerholaiset = new ArrayList<>();
kerholaiset.add(new Kerholainen("mikael", 182));
kerholaiset.add(new Kerholainen("matti", 187));
kerholaiset.add(new Kerholainen("ada", 184));

kerholaiset.stream().forEach(k -> System.out.println(k));
Collections.sort(kerholaiset);
Collections.reverse(kerholaiset);  

System.out.println();

kerholaiset.stream().forEach(k -> System.out.println(k));

System.out.println();
System.out.println(Collections.max(kerholaiset));
mikael (182)
matti (187)
ada (184)

matti (187)
ada (184)
mikael (182)

matti (187)

Hakeminen

Collections-luokkakirjasto tarjoaa myös valmiiksi toteutetun binäärihaun. Metodi binarySearch() palauttaa haetun alkion indeksin listasta jos se löytyy. Jos alkiota ei löydy, metodi palauttaa negatiivisen arvon. Metodi binarySearch() käyttää Comparable-rajapintaa haetun olion löytämiseen.

Kerholainen-luokkamme vertaa pituuksia compareTo()-metodissaan, eli listasta etsiessä etsisimme samanpituista kerholaista.

List<Kerholainen> kerholaiset = new ArrayList<>();
kerholaiset.add(new Kerholainen("mikael", 182));
kerholaiset.add(new Kerholainen("matti", 187));
kerholaiset.add(new Kerholainen("joel", 184));

Collections.sort(kerholaiset);

Kerholainen haettava = new Kerholainen("Nimi", 180);
int indeksi = Collections.binarySearch(kerholaiset, haettava);

if (indeksi >= 0) {
    System.out.println("180 senttiä pitkä löytyi indeksistä " + indeksi);
    System.out.println("nimi: " + kerholaiset.get(indeksi).getNimi());
}

haettava = new Kerholainen("Nimi", 187);
int indeksi = Collections.binarySearch(kerholaiset, haettava);

if (indeksi >= 0) {
    System.out.println("187 senttiä pitkä löytyi indeksistä " + indeksi);
    System.out.println("nimi: " + kerholaiset.get(indeksi).getNimi());
}
187 senttiä pitkä löytyi indeksistä 2
nimi: matti

Esimerkissä kutsuttiin myös metodia Collections.sort() sillä binäärihakualgoritmi ei toimi jos käsiteltävä lista ei ole valmiiksi järjestyksessä. Huom! Älä kuitenkaan toteuta hakutoiminnallisuutta siten, että lista järjestetään jokaisen haun yhteydessä -- järjestäminen itsessään on hitaampaa kuin peräkkäishaku eli listan läpikäynti alkio kerrallaan. Binäärihaun hyödyt tulevatkin esille vasta useamman haun jälkeen.

Muuttokuormaa pakattaessa esineitä lisätään muuttolaatikoihin siten, että tarvittujen laatikoiden määrä on mahdollisimman pieni. Tässä tehtävässä simuloidaan esineiden pakkaamista muuttolaatikoihin. Jokaisella esineellä on tilavuus, ja muuttolaatikoilla on maksimitilavuus.

Tavara ja Esine

Muuttomiehet siirtävät tavarat myöhemmin rekka-autoon (ei toteuteta tässä), joten toteutetaan ensin kaikkia esineitä ja laatikoita kuvaava Tavara-rajapinta.

Tavara-rajapinnan tulee määritellä metodi int getTilavuus(), jonka avulla tavaroita käsittelevät saavat selville kyseisen tavaran tilavuuden. Toteuta rajapinta Tavara pakkaukseen muuttaminen.domain.

Toteuta seuraavaksi pakkaukseen muuttaminen.domain luokka Esine, joka saa konstruktorin parametrina esineen nimen (String) ja esineen tilavuuden (int). Luokan tulee toteuttaa rajapinta Tavara.

Lisää luokalle Esine myös metodit public String getNimi() ja korvaa metodi public String toString() siten että se tuotta merkkijonoja muotoa "nimi (tilavuus dm^3)". Esineen tulee toimia seuraavasti:

Tavara esine = new Esine("hammasharja", 2);
System.out.println(esine);
hammasharja (2 dm^3)

Esine vertailtavaksi

Pakatessamme esineitä muuttolaatikkoon haluamme aloittaa pakkaamisen järjestyksessä olevista esineistä. Toteuta Esine-luokalla rajapinta Comparable siten, että esineiden luonnollinen järjestys on tilavuuden mukaan nouseva. Kun olet toteuttanut esineellä rajapinnan Comparable, tulee niiden toimia Collections-luokan sort-metodin kanssa seuraavasti.

List<Esine> esineet = new ArrayList<>();
esineet.add(new Esine("passi", 2));
esineet.add(new Esine("hammasharja", 1));
esineet.add(new Esine("sirkkeli", 100));

Collections.sort(esineet);
System.out.println(esineet);
[hammasharja (1 dm^3), passi (2 dm^3), sirkkeli (100 dm^3)]

Muuttolaatikko

Toteuta tämän jälkeen pakkaukseen muuttaminen.domain luokka Muuttolaatikko. Tee aluksi muuttolaatikolle seuraavat:

  • public Muuttolaatikko(int maksimitilavuus)
  • Muuttolaatikko-luokan konstruktori. Saa parametrina muuttolaatikon maksimitilavuuden.
  • public boolean lisaaTavara(Tavara tavara)
  • Lisää muuttolaatikkoon Tavara-rajapinnan toteuttaman esineen. Jos laatikkoon ei mahdu, metodi palauttaa arvon false. Jos tavara mahtuu laatikkoon, metodi palauttaa arvon true. Muuttolaatikon tulee tallettaa tavarat listaan.

Laita vielä Muuttolaatikko toteuttamaan rajapinta Tavara. Metodilla getTilavuus tulee saada selville muuttolaatikossa olevien tavaroiden tämänhetkinen yhteistilavuus.

Esineiden pakkaaminen

Toteuta luokka Pakkaaja pakkaukseen muuttaminen.logiikka. Luokan Pakkaaja konstruktorille annetaan parametrina int laatikoidenTilavuus, joka määrittelee minkä kokoisia muuttolaatikoita pakkaaja käyttää.

Toteuta tämän jälkeen luokalle metodi public List<Muuttolaatikko> pakkaaTavarat(List<Tavara> tavarat), joka pakkaa tavarat muuttolaatikoihin.

Tee metodista sellainen, että kaikki parametrina annetussa listassa olevat tavarat päätyvät muuttolaatikoihin. Muuttolaatikot tulee luoda metodissa. Sinun ei tarvitse varautua tilanteisiin, joissa tavarat ovat suurempia kuin pakkaajan käyttämä muuttolaatikon koko. Testit eivät välitä siitä kuinka täyteen pakkaaja täyttää muuttolaatikot.

// tavarat jotka haluamme pakata
List<Tavara> tavarat = new ArrayList<>();
tavarat.add(new Esine("passi", 2));
tavarat.add(new Esine("hammasharja", 1));
tavarat.add(new Esine("kirja", 4));
tavarat.add(new Esine("sirkkeli", 8));

// luodaan pakkaaja, joka käyttää tilavuudeltaan 10:n kokoisia muuttolaatikoita
Pakkaaja pakkaaja = new Pakkaaja(10);

// pyydetään pakkaajaa pakkaamaan tavarat laatikoihin
List<Muuttolaatikko> laatikot = pakkaaja.pakkaaTavarat(tavarat);

System.out.println("laatikoita: " + laatikot.size());

laatikot.stream().forEach(laatikko -> {
    System.out.println("  laatikossa tavaraa: " + laatikko.getTilavuus() + " dm^3");
});
laatikoita: 2
laatikossa tavaraa: 7 dm^3
laatikossa tavaraa: 8 dm^3

Pakkaaja on siis pakannut tavarat kahteen laatikkoon, ensimmäiseen laatikkoon on mennyt 3 ensimmäistä tavaraa, yhteistilavuudeltaan 7, ja listan viimeinen tavara eli sirkkeli jonka tilavuus on 8 on mennyt toiseen laatikkoon. Testit eivät aseta rajoitusta pakkaajan käyttävien muuttolaatioiden määrälle, tavarat olisi siis voitu pakata vaikka jokainen eri laatikkoon, eli tuloste olisi ollut:

laatikoita: 4
laatikossa tavaraa: 2 dm^3
laatikossa tavaraa: 1 dm^3
laatikossa tavaraa: 7 dm^3
laatikossa tavaraa: 8 dm^3

Huom: tehtävän testaamista helpottamaan kannatanee tehdä luokalle Muuttolaatikko esim. toString-metodi, jonka avulla voi printata laatikon sisällön.

Harjoitellaan taas ohjelman rakenteen omatoimista suunnittelua. Käyttöliittymän ulkomuoto ja vaadittu toiminnallisuus on määritelty ennalta, rakenteen saat toteuttaa vapaasti. Tehtävä on neljän yksittäisen tehtäväpisteen arvoinen.

Huom: jotta testit toimisivat, ohjelmasi saa luoda vain yhden käyttäjän syötteen lukemiseen käytettävän Scanner-olion.

Mäkihyppy on suomalaisille erittäin rakas laji, jossa pyritään hyppäämään hyppyrimäestä mahdollisimman pitkälle mahdollisimman tyylikkäästi. Tässä tehtävässä toteutetaan simulaattori mäkihyppykilpailulle.

Simulaattori kysyy ensin käyttäjältä hyppääjien nimiä. Kun käyttäjä antaa tyhjän merkkijonon (eli painaa enteriä) hyppääjän nimeksi siirrytään hyppyvaiheeseen. Hyppyvaiheessa hyppääjät hyppäävät yksitellen käänteisessä pistejärjestyksessä. Hyppääjä, jolla on vähiten pisteitä kerättynä hyppää aina kierroksen ensimmäisenä, toiseksi vähiten pisteitä omaava toisena jne, ..., eniten pisteitä kerännyt viimeisenä.

Hyppääjän yhteispisteet lasketaan yksittäisten hyppyjen pisteiden summana. Yksittäisen hypyn pisteytys lasketaan hypyn pituudesta (käytä satunnaista kokonaisluku väliltä 60-120) ja tuomariäänistä. Jokaista hyppyä kohden annetaan 5 tuomariääntä (satunnainen luku väliltä 10-20). Tuomariääniä laskettaessa otetaan huomioon vain kolme keskimmäistä ääntä: pienintä ja suurinta ääntä ei oteta huomioon. Esimerkiksi jos Mikael hyppää 61 metriä ja saa tuomariäänet 11, 12, 13, 14 ja 15, on hänen hyppynsä yhteispisteet 100.

Satunnaisen luvun luomiseen voi käyttää Javan valmista luokkaa Random. Sen parametrillinen metodi nextInt antaa satunnaisen luvun väliltä 0...luku-1. Satunnaisen luvun väliltä 10-20 saa arvottua seuraavasti:

Random arpoja = new Random();
int luku = arpoja.nextInt(11) + 10;

Kierroksia hypätään niin monta kuin ohjelman käyttäjä haluaa. Kun käyttäjä haluaa lopettaa tulostetaan lopuksi kilpailun lopputulokset. Lopputuloksissa tulostetaan hyppääjät, hyppääjien yhteispisteet ja hyppääjien hyppäämien hyppyjen pituudet. Lopputulokset on järjestetty hyppääjien yhteispisteiden mukaan siten, että eniten pisteitä kerännyt on ensimmäinen.

Tehtävän tekemisessä on hyötyä muun muassa metodeista Collections.sort ja Collections.reverse. Kannattaa aluksi hahmotella minkälaisia luokkia ja olioita ohjelmassa voisi olla. On myös hyvä pyrkiä tilanteeseen, jossa käyttöliittymäluokka on ainut luokka joka kutsuu tulostuskomentoa.

Kumpulan mäkiviikot

Syötä kilpailun osallistujat yksi kerrallaan, tyhjällä merkkijonolla siirtyy hyppyvaiheeseen.
  Osallistujan nimi: Mikael
  Osallistujan nimi: Mika
  Osallistujan nimi:

Kilpailu alkaa!

Kirjoita "hyppaa" niin hypätään, muuten lopetetaan: hyppaa

1. kierros

Hyppyjärjestys:
  1. Mikael (0 pistettä)
  2. Mika (0 pistettä)

Kierroksen 1 tulokset
  Mikael
    pituus: 95
    tuomaripisteet: [15, 11, 10, 14, 14]
  Mika
    pituus: 112
    tuomaripisteet: [14, 12, 18, 18, 17]

Kirjoita "hyppaa" niin hypätään, muuten lopetetaan: hyppaa

2. kierros

Hyppyjärjestys:
  1. Mikael (134 pistettä)
  2. Mika (161 pistettä)

Kierroksen 2 tulokset
  Mikael
    pituus: 96
    tuomaripisteet: [20, 19, 15, 13, 18]
  Mika
    pituus: 61
    tuomaripisteet: [12, 11, 15, 17, 11]

Kirjoita "hyppaa" niin hypätään, muuten lopetetaan: hyppaa

3. kierros

Hyppyjärjestys:
  1. Mika (260 pistettä)
  2. Mikael (282 pistettä)

Kierroksen 3 tulokset
  Mika
    pituus: 88
    tuomaripisteet: [11, 19, 13, 10, 15]
  Mikael
    pituus: 63
    tuomaripisteet: [12, 19, 19, 12, 12]

Kirjoita "hyppaa" niin hypätään, muuten lopetetaan: lopeta

Kiitos!

Kilpailun lopputulokset:
Sija    Nimi
1       Mikael (388 pistettä)
          hyppyjen pituudet: 95 m, 96 m, 63 m
2       Mika (387 pistettä)
          hyppyjen pituudet: 112 m, 61 m, 88 m

Huom1: Testien kannalta on oleellista että käyttöliittymä toimii kuten yllä kuvattu, esim. rivien alussa olevien välilyöntien määrän on oltava oikea. Rivien alussa oleva tyhjä pitää tehdä välilyönneillä, testit eivät toimi jos tyhjä on tehty tabulaattoreilla. Ohjelman tulostamat tekstit kannattaneekin copypasteta ohjelmakoodiin joko tehtävänannosta tai testien virheilmoituksista.

Huom2: älä käytä luokkein nimissä skandeja, ne saattavat aiheuttaa ongelmia testeihin!

Ohjelman tulee käynnistyä kun tehtäväpohjassa oleva main-metodi suoritetaan, muistutuksena vieltä, että tehtävässä saa luoda vain yhden Scanner-olion.

Sisällysluettelo