Ohjelmoinnin jatkokurssi

Huomautus lukijalle

Tämä on suoraa jatkoa Ohjelmoinnin perusteet -kurssin materiaaliin.

Tämä materiaali on tarkoitettu Helsingin Yliopiston Tietojenkäsittelytieteen laitoksen kevään 2012 Ohjelmoinnin perusteet- ja jatkokurssille sekä koko maailmalle avoimelle MOOC-ohjelmointikurssille. Materiaali pohjautuu kevään 2011 ja 2010 kurssimateriaaleihin, joiden sisältöön ovat vaikuttaneet Matti Paksula, Antti Laaksonen, Pekka Mikkola, Juhana Laurinharju ja Martin Pärtel.

Lue materiaalia siten, että teet samalla itse kaikki lukemasi esimerkit. Esimerkkeihin kannattaa tehdä pieniä muutoksia ja tarkkailla, miten muutokset vaikuttavat ohjelman toimintaan. Äkkiseltään voisi luulla, että esimerkkien tekeminen myös itse ja niiden muokkaaminen hidastaa opiskelua. Tämä ei kuitenkaan pidä ollenkaan paikkansa. Ohjelmoimaan ei ole vielä tietääksemme kukaan ihminen oppinut lukemalla (tai esim. luentoa kuuntelemalla). Oppiminen perustuu oleellisesti aktiiviseen tekemiseen ja rutiinin kasvattamiseen. Esimerkkien ja erityisesti erilaisten omien kokeilujen tekeminen on parhaita tapoja "sisäistää" luettua tekstiä.

Pyri tekemään tai ainakin yrittämään tehtäviä sitä mukaa kuin luet tekstiä. Jos et osaa heti tehdä jotain tehtävää, älä masennu, sillä saat ohjausta tehtävän tekemiseen pajassa (tai MOOC-kurssilaisena verkossa).

Tekstiä ei ole tarkoitettu vain kertaalleen luettavaksi. Joudut varmasti myöhemmin palaamaan jo aiemmin lukemiisi kohtiin tai aiemmin tekemiisi tehtäviin. Tämä teksti ei sisällä kaikkea oleellista ohjelmointiin liittyvää. Itse asiassa ei ole olemassa mitään kirjaa josta löytyisi kaikki oleellinen. Eli joudut joka tapauksessa ohjelmoijan urallasi etsimään tietoa myös omatoimisesti. Kurssin harjoitukset sisältävät jo jonkun verran ohjeita, mistä suunnista ja miten hyödyllistä tietoa on mahdollista löytää.

Muutamiin kohtiin olemme myös liittäneet screencasteja joita katsomalla voi pelkän valmiin koodin lukemisen sijaan seurata miten ohjelma muodostuu.

Kurssi alkaa siitä mihin ohjelmoinnin perusteet loppui ja oikeastaan kaikki ohjelmoinnin perusteet -kurssilla opitut asiat oletetaan nyt osattavan. Kannattaa käydä aina silloin tällöin kertaamassa Ohjelmoinnin perusteet -kurssin materiaalia.

Huom! Käytämme tällä kurssilla NetBeans-nimistä ohjelmointiympäristöä. Ohjeet NetBeansin ja kurssilla käytettävän tehtäväautomaatin käyttöön läydät täältä.

Ohjelmoinnin perusteiden kertausta

Tässä kappaleessa kerrataan pikaisesti muutamia ohjelmoinnin perusteissa tutuksi tulleita asioita. Ohjelmoinnin perusteet -kurssin materiaaliin pääsee tutustumaan tarkemmin tästä.

Ohjelma, käskyt ja muuttujat

Tietokoneohjelma koostuu joukosta käskyjä, joita tietokone suorittaa yksi kerrallaan, ylhäältä alaspäin. Käskyillä on aina määrätty rakenne ja semantiikka. Javassa, eli kurssilla käyttämässämme ohjelmointikielessä, käskyjä luetaan ylhäältä alas vasemmalta oikealle. Ohjelmointikurssit aloitetaan usein esittelemällä ohjelma, joka tulostaa merkkijonon Hei maailma!. Alla on Java-kielellä kirjoitettu käsky Hei maailma! -merkkijonon tulostamiseksi.

        System.out.println("Hei maailma!");

Käskyssä kutsutaan System-luokkaan liittyvän muuttujan println-metodia, joka tulostaa ensin parametrina annetun merkkijonon ja sen jälkeen rivinvaihdon. Metodille annetaan parametrina merkkijono Hei maailma!, jolloin tulostus on Hei maailma! jota seuraa rivinvaihto.

Ohjelmaan voi liittyä muuttujia joita voi käyttää osana ohjelman toimintaa. Alla on ohjelma, joka ensin esittelee kokonaislukutyyppisen muuttujan pituus johon asetetaan seuraavalla rivillä arvo 179. Tämän jälkeen tulostetaan muuttujan pituus arvo eli 179.

        int pituus;
        pituus = 179;
        System.out.println(pituus);

Yllä ohjelman suoritus tapahtuisi rivi kerrallaan. Ensin suoritetaan rivi int pituus;, jossa esitellään muuttuja nimeltä pituus. Seuraavaksi suoritetaan rivi pituus = 179;, jossa asetetaan edellisellä rivillä esiteltyyn muuttujaan arvo 179. Tämän jälkeen suoritetaan rivi System.out.println(pituus);, jossa kutsutaan aiemmin näkemäämme tulostusmetodia, jolle annetaan parametrina muuttuja pituus. Metodi tulostaa muuttujan pituus sisällön eli arvon 179.

Yllä olevassa ohjelmassa ei oikeastaan ole tarvetta esitellä muuttujaa pituus ja asettaa siihen arvoa erillisillä riveillä. Muuttujan esittelyn ja siihen liittyvän arvon asetuksen voi tehdä myös samalla rivillä.

        int pituus = 179;

Yllä olevaa riviä suoritettaessa esitellään ensin muuttuja pituus, johon asetetaan esittelyn yhteydessä arvo 179.

Kaikki tieto esiintyy oikeasti tietokoneen sisällä jonona bittejä, eli lukuja nolla ja yksi. Muuttujat ovat ohjelmointikielten tarjoama abstraktio, jolla voidaan käsitellä erilaisia arvoja helpommin. Muuttujia käytetään arvojen säilyttämiseen ja ohjelman tilan ylläpitoon. Javassa käytössämme on muun muassa alkeisarvoiset muuttujatyypit int (kokonaisluku), double (liukuluku), boolean (totuusarvo), char (merkki) sekä viittaustyyppiset muuttujatyypit String (merkkijono), ArrayList (taulukko) ja kaikki luokat. Palaamme alkeistyyppisiin ja viittaustyyppisiin muuttujiin ja niiden eroihin tarkemmin myöhemmin.

Muuttujien vertaileminen ja syötteen lukeminen

Ohjelmien toiminnallisuus rakennetaan kontrollirakenteiden avulla. Kontrollirakenteet mahdollistavat erilaiset toiminnot ohjelman muuttujien arvoista riippuen. Alla esimerkki if-elseif-else -kontrollirakenteesta, jossa tehdään erilainen toiminto vertailun tuloksesta riippuen. Esimerkissä tulostetaan merkkijono Kaasua jos muuttujan nopeus arvo on pienempi kuin 110, merkkijono Jarrua jos muuttujan nopeus arvo on suurempi kuin 120 ja merkkijono Kruisaillaan muissa tapauksissa.

        int nopeus = 105;

        if(nopeus < 110) {
            System.out.println("Kaasua");
        } else if (nopeus > 120) {
            System.out.println("Jarrua");
        } else {
            System.out.println("Kruisaillaan");
        }

Koska yllä olevassa esimerkissä muuttujan nopeus arvo on 105, tulostaa ohjelma aina merkkijonon Kaasua. Muistathan että merkkijonojen yhtäsuuruusvertailu tapahtuu merkkijonoon liittyvällä equals-metodilla. Alla on esimerkki jossa käytetään Javan Scanner-luokasta luotua olioita käyttäjän kirjoittaman syötteen lukemiseen. Ohjelma tarkistaa ovatko käyttäjän syöttämät merkkijonot samat.

        Scanner lukija = new Scanner(System.in);

        System.out.print("Syötä ensimmäinen merkkijono: ");
        String ensimmainen = lukija.nextLine();

        System.out.print("Syötä toinen merkkijono: ");
        String toinen = lukija.nextLine();

        System.out.println();

        if (ensimmainen.equals(toinen)) {
            System.out.println("Kirjoittamasi merkkijonot ovat samat!");
        } else {
            System.out.println("Kirjoittamasi merkkijonot eivät olleet samat!");
        }

Ohjelman toiminta riippuu käyttäjän syötteestä. Alla esimerkki, punaisella värillä tarkoitetaan käyttäjän kirjoittamaa syötettä.

Syötä ensimmäinen merkkijono: porkkana
Syötä toinen merkkijono: salaatti

Kirjoittamasi merkkijonot eivät olleet samat!

Toistolausekkeet

Ohjelmissa tarvitaan usein toistoa. Tehdään ensin ns. while-true-break -toistolause, jota jatketaan niin pitkään kunnes käyttäjä syöttää merkkijonon salasana. Lause while(true) aloittaa toistolauseen, jota jatketaan kunnes kohdataan avainsana break.

        Scanner lukija = new Scanner(System.in);

        while(true) {
            System.out.print("Syötä salasana: ");
            String salasana = lukija.nextLine();

            if(salasana.equals("salasana")) {
                break;
            }
        }

        System.out.println("Kiitos!");
Syötä salasana: porkkana
Syötä salasana: salasana
Kiitos

While-toistolausekkeeseen voi asettaa totuusarvon true sijaan myös vertailun. Alla tulostetaan käyttäjän syöttämä merkkijono siten, että sillä on ylä- ja alapuolella tähtiä.

        Scanner lukija = new Scanner(System.in);

        System.out.print("Syötä merkkijono: ");
        String merkkijono = lukija.nextLine();
        int tahdenNumero = 0;

        while(tahdenNumero < merkkijono.length()) {
            System.out.print("*");
            tahdenNumero = tahdenNumero + 1;
        }
        System.out.println();

        System.out.println(merkkijono);
        tahdenNumero = 0;

        while(tahdenNumero < merkkijono.length()) {
            System.out.print("*");
            tahdenNumero = tahdenNumero + 1;
        }
        System.out.println();
Syötä merkkijono: porkkana
********
porkkana
********

Yllä olevan esimerkin pitäisi nostattaa kylmiä väreitä selkäpiissäsi. Kylmät väreet toivottavasti johtuvat siitä, että huomaat esimerkin rikkovan ohjelmoinnin perusteissa opittuja käytänteitä. Esimerkissä on turhaa toistoa joka tulee poistaa metodien avulla.

While-toistolauseen lisäksi käytössämme on myös for-toistolauseen kaksi versiota. Uudempaa for-lauseketta käytetään listojen läpikäyntiin.

        ArrayList<String> tervehdykset = new ArrayList<String>();
        tervehdykset.add("Hei");
        tervehdykset.add("Hallo");
        tervehdykset.add("Hi");

        for(String tervehdys: tervehdykset) {
            System.out.println(tervehdys);
        }
Hei
Hallo
Hi

Perinteisempää for-lausetta käytetään samankaltaisissa tilanteissa kuin while-toistolausetta. Sitä voidaan käyttää esimerkiksi taulukoiden läpikäyntiin. Seuraavassa esimerkissä kerrotaan jokaisen taulukon luvut alkion sisältö kahdella ja lopuksi tulostetaan luvut uudempaa for-lausetta käyttäen.

        int[] luvut = new int[] {1, 2, 3, 4, 5, 6};

        for (int i = 0; i < luvut.length; i++) {
            luvut[i] = luvut[i] * 2;
        }

        for (int luku : luvut) {
            System.out.println(luku);
        }
2
4
6
8
10
12

Perinteinen for-lauseke on erittäin hyödyllinen tapauksissa joissa käymme indeksejä yksitellen läpi. Allaoleva toistolauseke käy merkkijonon merkit yksitellen läpi, ja tulostaa merkkijono Hip! aina kun törmäämme merkkiin a.

        String merkkijono = "saippuakauppias";
        for (int i = 0; i < merkkijono.length(); i++) {
            if (merkkijono.charAt(i) == 'a') {
                System.out.println("Hip!");
            }
        }
Hip!
Hip!
Hip!
Hip!

Metodit

Metodit ovat tapa pilkkoa ohjelman toiminnallisuutta pienempiin osakokonaisuuksiin. Kaikkien Java-ohjelmien suoritus alkaa pääohjelmametodista, joka määritellään lauseella public static void main(String[] args). Lause määrittelee staattisen, eli luokkaan liittyvän metodin, joka saa parametrina merkkijonotaulukon. Java-ohjelmat käynnistetään pääohjelmametodista.

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

Ohjelmoija määrittelee metodeja toiminnallisuuksien abstrahoimiseksi. Ohjelmoidessa tulee tavoitella tilannetta, jossa ohjelmaa voidaan katsoa ns. korkeammalta tasolta, jolloin ohjelma sisältää joukosta itse määriteltyjä metodikutsuja, jotka sisältävät ohjelman toiminnallisuudet. Metodit joiden määrittelyssä on sana static, liittyvät metodin sisältävään luokkaan ja toimivat ns. apumetodeina. Metodit joiden määrittelyssä ei ole sanaa static liittyvät luokasta tehtyihin ilmentymiin, eli olioihin, ja voivat muokata yksittäisten olioiden sisäistä tilaa.

Metodilla on aina näkyvyysmääre (public, näkyy kaikille, private, näkyy vain luokan sisällä), paluutyyppi (void, ei palauta arvoa) sekä metodin nimi. Luodaan luokkaan liittyvä metodi public static void tulosta(String merkkijono, int kertaa), joka tulostaa merkkijonon halutun määrän kertoja. Käytetään tällä kertaa metodia System.out.print, joka on kuin sen kaveri System.out.println, mutta ei tulosta rivinvaihtoa.

    public static void tulosta(String merkkijono, int kertaa) {
        for (int i = 0; i < kertaa; i++) {
            System.out.print(merkkijono);
        }
    }

Yllä oleva metodi tulostaa parametrina annetun merkkijonon niin monta kertaa kuin parametrina annettu kokonaisluku kertoo.

Toistolausekkeisiin liittyvässä kappaleessa huomasimme tilanteen, joka nostatti kylmiä väreitä selkäpiissämme. Metodien avulla voimme siirtää tähtien tulostamisen erillisen metodiin. Luodaan metodi public static void tulostaTahtia(int kertaa), joka tulostaa parametrina annetun muuttujan määräämän määrän tähtiä. Metodi käyttää for-toistolausetta while-toistolauseen sijaan.

    public static void tulostaTahtia(int kertaa) {
        for(int i = 0; i < kertaa; i++) {
            System.out.print("*");
        }
        System.out.println();
    }

Metodia hyödyntäessä aiemmin kauhistusta aihettanut esimerkkimme näyttää seuraavalta.

        Scanner lukija = new Scanner(System.in);

        System.out.print("Syötä merkkijono: ");
        String merkkijono = lukija.nextLine();

        tulostaTahtia(merkkijono.length());
        System.out.println(merkkijono);
        tulostaTahtia(merkkijono.length());

Luokka

Metodit toimivat ohjelman abstrahoimisessa tiettyyn pisteeseen asti, mutta ohjelmien kasvaessa suuremmiksi ohjelmia halutaan pilkkoa pienempiin metodit sisältäviin kokonaisuuksiin. Luokkien avulla voimme määrittelemme käsitteitä ja käsitteisiin liittyviä toiminnallisuuksia. Jokainen Java-ohjelma vaatii toimiakseen luokan, eli yllä rakennettu Hei maailma! -esimerkki ei toimisi ilman luokkamäärittelyä. Luokka määritellään avainsanoilla public class LuokanNimi.

public class HeiMaailma {
    public static void main(String[] args) {
        System.out.println("Hei maailma!");
    }
}

Luokkia käytetään ohjelmassa esiintyvien käsitteiden ja niihin liittyvien toiminnallisuuksien määrittelyyn. Luokista voidaan luoda olioita, jotka ovat luokan ilmentymiä. Jokaisella tiettyyn luokkaan liittyvällä oliolla on sama rakenne, mutta oliohin liittyvien muuttujien arvot voivat olla erilaiset. Olioiden metodit käsittelevät olioiden tilaa, eli sisäisiä muuttujia.

Tutkitaan alla olevaa luokkaa Kirja, jolla on oliomuuttujat nimi (merkkijono) ja julkaisuvuosi (kokonaisluku).

public class Kirja {
    private String nimi;
    private int julkaisuvuosi;

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

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

    public int getJulkaisuvuosi() {
        return this.julkaisuvuosi;
    }
}

Alussa oleva määritelmä public class Kirja kertoo luokan nimen, jota seuraa oliomuuttujat. Oliomuuttujat ovat muuttujia jotka ovat omat jokaiselle luokasta luodulle oliolle. Luokasta luodaan olioita konstruktorilla. Konstruktori on metodi joka alustaa olion (eli luo oliolle siihen liittyvät muuttujat) ja suorittaa konstruktorin sisällä olevat käskyt. Konstruktori on aina samanniminen kuin konstruktorin sisältävä luokka. Konstruktorissa public Kirja(String nimi, int julkaisuvuosi) luodaan uusi olio luokasta Kirja ja asetetaan siihen liittyviin muuttujiin parametrina annetut arvot.

Tämän lisäksi luokalle on määritelty kaksi olioiden tietoja käsittelevää metodia. Metodi public String getNimi() palauttaa käsiteltävän olion nimen. Metodi public int getJulkaisuvuosi() palauttaa käsiteltävän olion julkaisuvuoden.

Olio

Olioita luodaan luokkaan määritellyn konstruktorin avulla. Ohjelmakoodissa konstruktoria kutsutaan new-käskyllä, joka palauttaa viitteen uuteen olioon. Oliot ovat luokista tehtyjä ilmentymiä. Tutkitaan ohjelmaa, joka luo kaksi eri kirjaa, jonka jälkeen tulostetaan olioihin liittyvien metodien getNimi palauttamat arvot.

        Kirja jarkiJaTunteet = new Kirja("Järki ja tunteet", 1811);
        Kirja ylpeysJaEnnakkoluulo = new Kirja("Ylpeys ja ennakkoluulo", 1813);

        System.out.println(jarkiJaTunteet.getNimi());
        System.out.println(ylpeysJaEnnakkoluulo.getNimi());
Järki ja tunteet
Ylpeys ja ennakkoluulo

Jokaisella oliolla on siis oma sisäinen tila. Tila muodostuu olioon liittyvistä oliomuuttujista. Oliomuuttujat voivat olla sekä alkeistyyppisiä muuttujia että viittaustyyppisiä muuttujia. Jos olioon liittyy viittaustyyppisiä muuttujia, voi olla että muutkin oliot viittaavat samoihin olioihin! Visualisoidaan tämä pankkiesimerkillä, jossa on tilejä ja henkilöitä.

public class Tili {
    private String tilitunnus;
    private int saldoSentteina;

    public Tili(String tilitunnus) {
        this.tilitunnus = tilitunnus;
        this.saldoSentteina = 0;
    }

    public void pane(int summa) {
        this.saldoSentteina += summa;
    }

    public int getSaldoSentteina() {
        return this.saldoSentteina;
    }

    // .. muita tiliin liittyviä metodeja
}
import java.util.ArrayList;

public class Henkilo {
    private String nimi;
    private ArrayList<Tili> tilit;

    public Henkilo(String nimi) {
        this.nimi = nimi;
        this.tilit = new ArrayList<Tili>();
    }

    public void lisaaTili(Tili tili) {
        this.tilit.add(tili);
    }

    public int rahaaYhteensa() {
        int yhteensa = 0;
        for (Tili tili: this.tilit) {
            yhteensa += tili.getSaldoSentteina();
        }

        return yhteensa;
    }

    // ... muita henkilöön liittyviä metodeja
}

Jokaisella Henkilo-luokasta tehdyllä oliolla on oma nimi sekä oma lista tileistä. Luodaan seuraavaksi kaksi henkilöä ja kaksi tiliä. Toinen tileistä on vain yhden henkilön oma, toinen yhteinen.

        Henkilo matti = new Henkilo("Matti");
        Henkilo maija = new Henkilo("Maija");

        Tili palkkatili = new Tili("NORD-LOL");
        Tili kotitaloustili = new Tili("SAM-LOL");

        matti.lisaaTili(palkkatili);
        matti.lisaaTili(kotitaloustili);
        maija.lisaaTili(kotitaloustili);

        System.out.println("Matin tileillä rahaa: " + matti.rahaaYhteensa());
        System.out.println("Maijan tileillä rahaa: " + maija.rahaaYhteensa());
        System.out.println();

        palkkatili.pane(150000);

        System.out.println("Matin tileillä rahaa: " + matti.rahaaYhteensa());
        System.out.println("Maijan tileillä rahaa: " + maija.rahaaYhteensa());
        System.out.println();

        kotitaloustili.pane(10000);

        System.out.println("Matin tileillä rahaa: " + matti.rahaaYhteensa());
        System.out.println("Maijan tileillä rahaa: " + maija.rahaaYhteensa());
        System.out.println();
Matin tileillä rahaa: 0
Maijan tileillä rahaa: 0

Matin tileillä rahaa: 150000
Maijan tileillä rahaa: 0

Matin tileillä rahaa: 160000
Maijan tileillä rahaa: 10000

Ensin kummankin henkilön tilit ovat tyhjiä. Kun palkkatilille, johon oliolla matti on viite, lisätään rahaa, kasvaa Matin tileillä oleva rahamäärä. Kun kotitaloustilille lisätään rahaa, kasvaa kummankin henkilön rahamäärä. Tämä johtuu siitä että sekä Matilla että Maijalla on "oikeus" kotitaloustilille, eli kummakin omassa oliomuuttujassa tilit on viite kotitaloustiliin. Palaamme alkeis- ja viittaustyyppisten muuttujien eroon tarkemmin myöhemmin.

Ohjelmien rakenteesta

Ohjelmien tulee olla selkeitä paitsi itselle, myös toisille. Käytämme luokkia ja metodeja selventääksemme ohjelmissa esiintyviä käsitteitä kaikille. Jokaisella luokalla on vastuu, johon liittyviä tehtäviä se hoitaa. Metodeja käytetään toiston vähentämiseen ja luokkien sisäisten toimintojen selventämiseksi. Metodien avulla voidaan myös selkeyttää ohjelmakoodia. Käytännössä metodi ei saa olla koskaan niin pitkä että sen luettavuus vaikeutuisi. Jos huomaat että metodisi on niin pitkä, ettet ymmärrä tai muista mitä se tekee, kannattaa pilkkoa se heti pienempiin metodeihin. Hyvät ohjelmoijat ohjelmoivat koodia, jota he ja heidän työkaverinsa ymmärtävät myös viikkoja koodin kirjoittamisen jälkeenkin.

Ymmärrettävään koodiin liittyy niin kuvaava muuttujien, metodien ja luokkien nimentä kuin ohjelmakoodin ilmavuus ja seurattavien sisennysten määrä. Tutkitaan seuraavaa esimerkkiä käyttöliittymästä, jossa käyttäjä voi ostaa ja myydä esineitä. Vaikka esimerkissä ainoat ostettavat ja myytävät asiat ovat porkkanoita, eikä niistä pidetä kirjaa, voisi käyttöliittymää laajentaa esimerkiksi siten että sille annettaisiin varasto konstruktorin parametrina -- tähänkin palataan myöhemmin kurssin aikana.

public class Kayttoliittyma {
    private Scanner lukija;

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

    public void kaynnista() {
        while (true) {
            String komento = lukija.nextLine();

            if (komento.equals("lopeta")) {
                break;
            } else if (komento.equals("osta")) {
                String luettu = null;
                while(true) {
                    System.out.print("Mitä ostetaan: ");
                    luettu = lukija.nextLine();
                    if(luettu.equals("porkkana") {
                        break;
                    } else {
                        System.out.println("Ei löydy!");
                    }
                }

                System.out.println("Ostettu!");
            } else if (komento.equals("myy")) {
                String luettu = null;
                while(true) {
                    System.out.print("Mitä myydään: ");
                    luettu = lukija.nextLine();
                    if(luettu.equals("porkkana") {
                        break;
                    } else {
                        System.out.println("Ei löydy!");
                    }
                }

                System.out.println("Myyty!");
            }
        }
    }
}

Huomaamme esimerkissä heti monta ongelmakohtaa. Ensimmäinen pulma liittyy kaynnista-metodin sisällön suuruuteen. Huomaamme että metodia voi pienentää siirtämällä muiden komentojen kuin lopeta-komennon käsittelyn erilliseen metodiin.

public class Kayttoliittyma {
    private Scanner lukija;

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

    public void kaynnista() {
        while (true) {
            String komento = lukija.nextLine();

            if (komento.equals("lopeta")) {
                break;
            } else {
                hoidaKomento(komento);
            }
        }
    }

    public void hoidaKomento(String komento) {
        if (komento.equals("osta")) {
            String luettu = null;
            while(true) {
                System.out.print("Mitä ostetaan: ");
                luettu = lukija.nextLine();
                if(luettu.equals("porkkana") {
                    break;
                } else {
                    System.out.println("Ei löydy!");
                }
            }

            System.out.println("Ostettu!");
        } else if (komento.equals("myy")) {
            String luettu = null;
            while(true) {
                System.out.print("Mitä myydään: ");
                luettu = lukija.nextLine();
                if(luettu.equals("porkkana") {
                    break;
                } else {
                    System.out.println("Ei löydy!");
                }
            }

            System.out.println("Myyty!");
        }
    }
}

Metodissa hoidaKomento on vielä toisteisuutta syötteen lukemiseen liittyen. Huomaamme että lukemisessa toistuu aina muutama merkkijono. Kun ostetaan kysytään "Mitä ostetaan: ", kun myydään kysytään "Mitä myydään: ". Molemmat lukemiskohdat odottavat merkkijonoa "porkkana" ja tulostavat "Ei löydy!" jos käyttäjä ei syötä merkkijonoa "Porkkana". Luodaan tätä varten erillinen metodi public String lueKayttajalta(String kysymys), joka kysyy käyttäjältä haluttua merkkijonoa. Huomaa että jos käyttöliittymässämme olisi käytössä jonkinlainen varastonhallintaolio, vertaisimme sen sisältämiä esineitä käyttäjän syötteeseen porkkanan sijasta.

public class Kayttoliittyma {
    private Scanner lukija;

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

    public void kaynnista() {
        while (true) {
            String komento = lukija.nextLine();

            if (komento.equals("lopeta")) {
                break;
            } else {
                hoidaKomento(komento);
            }
        }
    }

    public void hoidaKomento(String komento) {
        if (komento.equals("osta")) {
            String luettu = lueKayttajalta("Mitä ostetaan: ");
            System.out.println("Ostettu!");
        } else if (komento.equals("myy")) {
            String luettu = lueKayttajalta("Mitä myydään: ");
            System.out.println("Myyty!");
        }
    }

    public String lueKayttajalta(String kysymys) {
        while(true) {
            System.out.print(kysymys);
            String luettu = lukija.nextLine();

            if(luettu.equals("porkkana") {
                return luettu;
            } else {
                System.out.println("Ei löydy!");
            }
        }
    }
}

Ohjelma on nyt pilkottu sopiviksi osiksi. Huomaamme kuitenkin että kaynnista-metodin yhteydessä on turha else-haara. Jos ohjelman suoritus päätyy if-haaraan, suoritetaan komento break ja poistutaan toistolauseesta. Voimme siis poistaa else-haaran jolloin hoidaKomento-metodin suorittaminen on omalla rivillään. Samanlainen tilanne on myös metodissa lueKayttajalta. Siistitään se myös samalla.

public class Kayttoliittyma {
    private Scanner lukija;

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

    public void kaynnista() {
        while (true) {
            String komento = lukija.nextLine();

            if (komento.equals("lopeta")) {
                break;
            }

            hoidaKomento(komento);
        }
    }

    public void hoidaKomento(String komento) {
        if (komento.equals("osta")) {
            String luettu = lueKayttajalta("Mitä ostetaan: ");
            System.out.println("Ostettu!");
        } else if (komento.equals("myy")) {
            String luettu = lueKayttajalta("Mitä myydään: ");
            System.out.println("Myyty!");
        }
    }

    public String lueKayttajalta(String kysymys) {
        while(true) {
            System.out.print(kysymys);
            String luettu = lukija.nextLine();

            if(luettu.equals("porkkana") {
                return luettu;
            }

            System.out.println("Ei löydy!");
        }
    }
}

Yllä kuvaavaamme ohjelman pilkkomista pienempiin osiin kutsutaan refaktoroinniksi. Refaktoroinnissa ohjelman toiminta pysyy samana, mutta sisäinen rakenne muuttuu selkeämmäksi ja ylläpidettävämmäksi. Nykyinen versiomme on huomattavasti selkeämpi alkuperäiseen ohjelmaan verrattuna. Ohjelmassa on toki vieläkin parannettavaa. Esimerkiksi metodin hoidaKomento voi pilkkoa ostamis- ja myymistoiminnallisuutta hoitaviin metodeihin.

public class Kayttoliittyma {
    private Scanner lukija;

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

    public void kaynnista() {
        while (true) {
            String komento = lukija.nextLine();

            if (komento.equals("lopeta")) {
                break;
            }

            hoidaKomento(komento);
        }
    }

    public void hoidaKomento(String komento) {
        if (komento.equals("osta")) {
            komentoOsta();
        } else if (komento.equals("myy")) {
            komentoMyy();
        }
    }

    public void komentoOsta() {
        String luettu = lueKayttajalta("Mitä ostetaan: ");
        System.out.println("Ostettu!");
    }

    public void komentoMyy() {
        String luettu = lueKayttajalta("Mitä myydään: ");
        System.out.println("Myyty!");
    }

    public String lueKayttajalta(String kysymys) {
        while(true) {
            System.out.print(kysymys);
            String luettu = lukija.nextLine();

            if(luettu.equals("porkkana") {
                return luettu;
            }

            System.out.println("Ei löydy!");
        }
    }
}

Nyt ohjelma on sopivan pieni. Huomaa että jokaisella metodilla on oma pieni tehtävä jota se hoitaa. Huomaa että emme lisänneet refaktoroidessa ohjelmaan uutta toiminnallisuutta, muokkasimme vain rakennetta selkeämmäksi.

Ohjelmoinnista ja ennenkaikkea harjoittelun tärkeydestä

Tietääksemme kukaan ei ole oppinut ohjelmoimaan luentoja kuuntelemalla. Ohjelmointitaidon kehittymisen kannalta harjoittelu ja kertaaminen on tärkeää. Ohjelmointitaitoa on verrattu niin kielten puhumiseen kuin instrumentin soittamiseen -- kummassakin kehittyy vain harjoittelemalla. Oikeastaan, esimerkiksi hyvät viulistit eivät ole hyviä vain sen takia, että he ovat harjoitelleet paljon. Harjoittelua motivoi myös se, että se on hauskaa. Samaa voi sanoa ohjelmoijista.

Linus Torvaldsin sanoin "Most good programmers do programming not because they expect to get paid or get adulation by the public, but because it is fun to program.".

Tohtori Luukkainen on kirjoittanut listan jota kannattaa seurata ohjelmoidessa ja siinä kehittyessä. Seuraa listan neuvoja kunnes osaat ne unissasikin.

Näkyvyysmääreet

Olemme tähän mennessä käyttäneet kahta erilaista näkyvyyteen liittyvää avainsanaa metodien ja oliomuuttujien määrittelyssä. Avainsana public asettaa metodit ja muuttujat kaikille näkyviksi. Esimerkiksi luokan aksessorit ja konstruktorit merkitään usein määreellä public, jolloin niitä voi kutsua luokan ulkopuolelta.

Avainsana private taas piilottaa metodit ja muuttujat luokan sisälle. Metodia, jolla on määre private, ei pysty kutsumaan luokan ulkopuolelta.

public class Kirja {
    private String nimi;
    private String sisalto;

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

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

    public String getSisalto() {
        return this.sisalto;
    }

    // ...
}

Yllä olevasta Kirja-luokasta luotujen olioiden tietoihin pääsee käsiksi vain kirjan julkisten metodien kautta. Private-määreellä merkityt oliomuuttujat ovat näkyvillä ja käsiteltävissä vain luokan sisäisessä koodissa. Jos kirjalla olisi private-määreellinen metodi, ei sitäkään voisi käyttää muualta kuin Kirja-luokan sisältä.

Sitten itse asiaan, eli harjoitteluun!

Hymiöt

Laadi tehtavapohjan mukana tulevalle luokalle Hymiot apumetodi private static void tulostaHymioityna(String merkkijono). Metodin tulee tulostaa annettu merkkijono hymiöillä ympyröitynä. Käytä hymiönä merkkijonoa :).

tulostaHymioityna("\\:D/");
:):):):):)
:) \:D/ :)
:):):):):)

Huomaa, että merkkijonoon on kirjoitettava \\ jotta saadaan tulostumaan merkki \.

Huom! Jos merkkijonon pituus on pariton, kannattaa lisätä ylimääräinen välilyönti annetun merkkijonon oikealle puolelle.

tulostaHymioityna("\\:D/");
tulostaHymioityna("87.");
:):):):):)
:) \:D/ :)
:):):):):)
:):):):):)
:) 87.  :)
:):):):):)

Kannattaa ensin miettiä montako hymiötä minkäkin pituiselle merkkijonolle tulee tulostaa. Merkkijonon pituuden saa selville siihen liittyvällä length-metodilla. Ala- ja ylärivin hymiöiden tulostamiseen auttaa toistolause, keskimmäisellä rivillä selviät normaalilla tulostuskomennolla. Pituuden parittomuuden voit tarkistaa jakojäännöksen avulla merkkijono.length() % 2 == 1.

Merkkijonomuuntaja

Tässä tehtävässä luodaan merkkijonomuuntaja, joka koostuu kahdesta luokasta. Luokka Muunnos muuttaa yksittäiset merkit toiseksi, Muuntaja sisältää joukon Muunnoksia ja muuttaa merkkijonoja sisältämiensä Muunnos-olioiden avulla.

Muunnos-luokka

Luo luokka Muunnos, jolla on seuraavat toiminnot:

Luokkaa käytetään seuraavalla tavalla:

  Muunnos muunnos = new Muunnos('a', 'b');
  System.out.println(muunnos.muunna("porkkana"));

Yllä oleva esimerkki tulostaisi:

  porkkbnb

Muuntaja-luokka

Luo luokka Muuntaja, jolla on seuraavat toiminnot:

Luokkaa käytetään seuraavalla tavalla:

  Muuntaja skanditPois = new Muuntaja();
  skanditPois.lisaaMuunnos(new Muunnos('ä', 'a'));
  skanditPois.lisaaMuunnos(new Muunnos('ö', 'o'));
  System.out.println(skanditPois.muunna("ääliö älä lyö, ööliä läikkyy"));

Yllä oleva esimerkki tulostaisi:

  aalio ala lyo, oolia laikkyy

Laskin

Teemme tässä tehtävässä samantyylisen yksinkertaisen laskimen, joka oli jo ohjelmoinnin perusteiden viikon 1 materiaalissa. Tällä kertaa kiinnitämme kuitenkin huomiota ohjelman rakenteeseen. Erityisesti teemme main-metodista eli pääohjelmasta hyvin kevyen. Pääohjelmametodi ei tee oikeastaan mitään muuta kun käynistää ohjelman:

public class Paaohjelma {
    public static void main(String[] args) {
        Laskin laskin = new Laskin();
        laskin.kaynnista();
    }
}

Pääohjelma siis ainoastaan luo varsinaisen sovelluslogiikan toteuttavan olion ja käynnistää sen. Tämä on oikea tyyli tehdä ohjelmia ja tulemme jatkossa usein pyrkimään tähän rakenteeseen.

Lukija

Kommunikoidakseen käyttäjän kanssa laskin tarvitsee Scanner-olion. Kuten olemme huomanneet, on kokonaislukujen lukeminen scannerilla hieman työlästä. Teemme nyt erillisen luokan Lukija joka kapseloi sisälleen Scanner-olion.

Toteuta luokka Lukija ja lisää sille metodit

Lukijan sisällä tulee olla oliomuuttujana Scanner-olio jota metodit käyttävät ohjelmoinnin perusteista tuttuun tyyliin. Muistathan että kokonaislukujen lukemisessa kannattaa ensin lukea koko rivi, jonka jälkeen rivi tulee muuttaa kokonaisluvuksi. Tässä on hyödyksi Integer-luokan metodi parseInt.

Sovellusrunko

Laskin toimii seuraavan esimerkin mukaan:

komento: summa
luku1: 4
luku2: 6
lukujen summa 10

komento: tulo
luku1: 3
luku2: 2
lukujen tulo 6

komento: lopetus

Tee ohjelmaasi sovelluslogiigasta huolehtiva luokka Laskin ja sille metodi public void kaynnista() jonka sisältö on täsmälleen seuraava:

    public void kaynnista() {
        while (true) {
            System.out.print("komento: ");
            String komento = lukija.lueMerkkijono();
            if (komento.equals("lopetus")) {
                break;
            }

            if (komento.equals("summa")) {
                summa();
            } else if (komento.equals("erotus")) {
                erotus();
            } else if (komento.equals("tulo")) {
                tulo();
            }
        }

        statistiikka();
    }

Laskimellamme on operaatiot summa, erotus, tulo.

Tee valmiiksi rungot metodeille summa, erotus, tulo ja statistiikka. Kaikkien tulee olla tyyppiä private void, eli metodit ovat vain laskimen sisäisessä käytössä.

Lisää laskimelle oliomuuttuja jonka tyyppi on Lukija, ja luo lukija konstruktorissa. Laskimessa ei saa olla erikseen Scanner-tyyppistä muuttujaa!

Sovelluslogiikan toteutus

Toteuta nyt metodit summa, erotus ja tulo siten, että ne toimivat yllä olevan esimerkin mukaan. Esimerkissä kysytään aina ensin komento, jonka jälkeen kysytään 2 lukua käyttäjältä, suoritetaan haluttu operaatio, ja tulostetaan operaation arvo.

Huomaa, että koska metodit ovat luokan Laskin sisällä, on oliomuuttujana oleva Lukija käytettävissä metodien sisällä.

Statistiikka

Metodissa kaynnista olevan while-silmukan jälkeen kutsutaan metodia statistiikka. Metodin on tarkoitus tulostaa kuinka montaa kertaa laskinoliolla suoritettiin joku laskutoimenpide. Esim:

komento: summa
luku1: 4
luku2: 6
lukujen summa 10

komento: tulo
luku1: 3
luku2: 2
lukujen tulo 6

komento: lopetus
Laskuja laskettiin 2

Toteuta metodi private void statistiikka(), ja tee statistiikan keräämiseen tarvittavat muutokset muualle Laskin-luokan koodiin.

Huom: jos ohjelmalle annetaan virheellinen komento (eli joku muu kuin summa, erotus, tulo, tai lopetus), ei laskin reagoi komentoon millään tavalla vaan jatkaa kysymällä seuraavaa komentoa. Statistiikka ei saa laskea virheellistä komentoa laskutoimenpiteeksi.

komento: integraali
komento: erotus
luku1: 3
luku2: 2
lukujen erotus 1

komento: lopetus
Laskuja laskettiin 1

Alkeis- ja viittaustyyppiset muuttujat

Java on vahvasti tyypitetty kieli, eli kaikilla sen muuttujilla on tyyppi. Muuttujatyypit voidaan jakaa kahteen kategoriaan; alkeis- ja viittaustyyppisiin muuttujiin. Kummankin kategorian muuttujatyypeillä on oma "lokero", joka sisältää niihin liittyvän arvon. Alkeistyyppisillä muuttujilla muuttujien konkreettinen arvo tallennetaan lokeroon, kun taas viittaustyyppisten muuttujien lokero sisältää viitteen muuttujaan liittyvään konkreettiseen olioon.

Oliot eivät mahdu lokeroihin, vaan niihin viitataan aina.

Alkeistyyppiset muuttujat

Alkeistyyppisen muuttujan arvo tallennetaan muuttujaa varten luotuun lokeroon. Jokaisella alkeistyyppisellä muuttujalla on oma lokero ja oma arvo. Muuttujalle luodaan uusi lokero silloin kun se esitellään (esim. int numero;). Lokeroon asetetaan arvo sijoitusoperaatiolla =. Alla on esimerkki alkeistyyppisen int (kokonaisluku) -muuttujan esittelemisestä ja arvon asettamisesta samassa lausekkeessa.

int numero = 42;

Alkeistyyppisiä muuttujia ovat muun muassa int, double, char, boolean sekä harvemmin käyttämämme short, float, byte ja long. Myös void on alkeistyyppi, mutta sillä ei ole omaa lokeroa tai arvoa. Void-tyyppiä käytetään silloin kun halutaan ilmaista että metodi ei palauta mitään arvoa.

Esitellään seuraavaksi kaksi alkeistyyppistä muuttujaa ja asetetaan niihin arvot.

int vitonen = 5;
int kutonen = 6;

Yllä esiteltyjen alkeistyyppisten muuttujien nimet ovat vitonen ja kutonen. Muuttujaa vitonen luodessa sitä varten luotuun lokeroon asetetaan arvo 5 (int vitonen = 5;). Muuttujaa kutonen luodessa sitä varten luotuun lokeroon asetetaan arvo 6 (int kutonen = 6;). Muuttujat vitonen ja kutonen ovat kumpikin int-tyyppisiä, eli kokonaislukuja.

Alkeistyyppiset muuttujat voi visualisoida laatikkoina joiden sisälle kuhunkin muuttujaan liittyvä arvo on tallennettu:

Tarkastellaan seuraavaksi alkeistyyppisten muuttujien arvojen kopioitumista.

int vitonen = 5;
int kutonen = 6;

vitonen = kutonen; // muuttuja vitonen sisältää nyt arvon 6, eli arvon joka oli muuttujassa kutonen
kutonen = 64; // muuttuja kutonen sisältää nyt arvon 64

// muuttuja vitonen sisältää vieläkin arvon 6

Yllä esitellään muuttujat vitonen ja kutonen ja asetetaan niihin arvot. Tämän jälkeen muuttujan vitonen lokeroon kopioidaan muuttujan kutonen lokeron sisältämä arvo (vitonen = kutonen;). Tässä vaiheessa muuttujan vitonen lokeroon kopioituu muuttujan kutonen sisältämä arvo. Jos muuttujan kutonen arvoa muutetaan tämän jälkeen, ei muuttujan vitonen sisältämä arvo muutu: muuttujan vitonen arvo on sen omassa lokerossa eikä liity muuttujan kutonen lokerossa olevaan arvoon millään tavalla. Lopputilanne kuvana.

Alkeistyyppinen muuttuja metodin parametrina ja paluuarvona

Kun alkeistyyppinen muuttuja annetaan metodille parametrina, asetetaan metodin parametrimuuttujaan kopio annetun muuttujan lokerossa olevasta. Käytännössä myös metodin parametrimuuttujilla on omat lokerot, joihin arvo kopioidaan kuten asetuslauseessa. Katsotaan seuraavaa metodia lisaaLukuun(int luku, int paljonko).

public int lisaaLukuun(int luku, int paljonko) {
    return luku + paljonko;
}

Metodi lisaaLukuun saa kaksi parametria, kokonaisluvut luku ja paljonko. Metodi palauttaa uuden luvun, joka on annettujen parametrien summa. Tutkitaan vielä metodin kutsumista.

int omaLuku = 10;
omaLuku = lisaaLukuun(omaLuku, 15);
// muuttuja omaLuku sisältää nyt arvon 25

Esimerkissä kutsutaan lisaaLukuun-metodia muuttujalla omaLuku ja arvolla 15. Metodin muuttujiin luku ja paljonko kopioituvat arvot 10, eli muuttujan omaLuku sisältö, ja 15. Metodi palauttaa muuttujien luku ja paljonko summan, eli 10 + 15 = 25.

Huom! Edellisessä esimerkissä muuttujan omaLuku arvo muuttuu ainoastaan koska metodin lisaaLukuun palauttama arvo asetetaan siihen sijoituslausekkeella (omaLuku = lisaaLukuun(omaLuku, 15);). Jos metodin lisaaLukuun kutsu olisi seuraavanlainen, ei muuttujan omaLuku arvo muutu.

int omaLuku = 10;
lisaaLukuun(omaLuku, 15);
// muuttuja omaLuku sisältää vieläkin arvon 10

Minimi- ja maksimiarvot

Eri tietotyypeillä on omat minimi- ja maksimiarvonsa, eli arvot joita pienempiä tai suurempia ne eivät voi olla. Tämä johtuu Javan (ja useimpien ohjelmointikielten) sisäisestä tiedon esitysmuodosta, jossa tietotyyppien koot on ennalta määrätty.

Alla muutama Javan alkeistyyppi ja niiden minimi- ja maksimiarvot

MuuttujatyyppiSelitysMinimiarvoMaksimiarvo
intKokonaisluku-2 147 483 648 (Integer.MIN_VALUE)2 147 483 647 (Integer.MAX_VALUE)
longIso kokonaisluku-9 223 372 036 854 775 808 (Long.MIN_VALUE)9 223 372 036 854 775 807 (Long.MAX_VALUE)
booleanTotuusarvotrue tai false
doubleLiukulukuDouble.MIN_VALUEDouble.MAX_VALUE

Pyöristysvirheet

Liukulukuja käyttäessä kannattaa muistaa että liukuluvun arvo on aina arvio oikeasta arvosta. Koska liukuluvun, kuten kaikkien muidenkin alkeistyyppien sisältämä tietomäärä on rajoitettu, voidaan huomata yllättäviäkin pyöristysvirheitä. Esimerkiksi seuraava tilanne.

double eka = 0.39;
double toka = 0.35;
System.out.println(eka - toka);

Esimerkki tulostaa arvon 0.040000000000000036. Ohjelmointikielet tarjoavat usein työkalut liukulukujen tarkempaa käsittelyä varten. Esimerkiksi Javassa on luokka BigDecimal, johon voi asettaa äärettömän pitkiä liukulukuja.

Liukulukuja vertaillessa pyöristysvirheisiin varaudutaan usein vertaamalla arvojen etäisyyttä toisistaan. Esimerkiksi edellisen esimerkin muuttujia käytettäessä vertailu eka - toka == 0.4 ei tuota toivottua tulosta pyöristysvirheen takia.

double eka = 0.39;
double toka = 0.35;

if((eka - toka) == 0.4) {
    System.out.println("Vertailu onnistui!");
} else {
    System.out.println("Vertailu epäonnistui!");
}
Vertailu epäonnistui!

Arvon etäisyyttä jostain luvusta voi tarkastella esimerkiksi seuraavasti. Apufunktio Math.abs palauttaa sille annetun luvun itseisarvon.

double eka = 0.39;
double toka = 0.35;

double etaisyys = 0.4 - (eka - toka);

if(Math.abs(etaisyys) < 0.0001) ) {
    System.out.println("Vertailu onnistui!");
} else {
    System.out.println("Vertailu epäonnistui!");
}

Viittaustyyppi

Viittaustyyppiset muuttujat tallentavat niihin liittyvän tiedon viitteen taakse eli "langan päähän". Viittaustyyppisten muuttujien lokerossa on viite tiedon sisältävään paikkaan. Toisin kuin alkeistyyppisillä muuttujilla, viittaustyyppisillä muuttujilla ei ole rajoitettua arvoaluetta, koska niiden oikea arvo tai tieto on viitteen takana. Oleellinen ero alkeistyyppisiin muuttujiin on se, että eri viittaustyyppiset muuttujat voivat viitata samaan paikkaan.

Viittaustyyppisistä muuttujista puhutaan olioina, joita luodaan new-kutsulla. Muuttujan arvo asetetaan vieläkin sijoitusoperaattorilla =, mutta komento new luo olion ja palauttaa viitteen olioon. Viite asetetaan muuttujaan liittyvään lokeroon eli sen arvoksi. Tutkitaan kahden viittaustyyppisen muuttujan luontia. Esimerkeissä käytetään seuraavaa luokkaa Laskuri:

public class Laskuri {
    private int arvo;

    public Laskuri(int alkuarvo) {  // Konstruktori
        this.arvo = alkuarvo;
    }

    public void kasvataArvoa() {
        this.arvo = this.arvo + 1;
    }

    public int annaArvo() {
        return arvo;
   }
}

Pääohjelma:

Laskuri bonusLaskuri = new Laskuri(5);
Laskuri axeLaskuri = new Laskuri(6);

Esimerkissä luodaan ensin viittaustyyppinen muuttuja bonusLaskuri (Laskuri bonusLaskuri = new Laskuri(5);). Komentoa new kutsuessa varataan tila muuttujan tietoa varten, suoritetaan new-kutsua seuraavan konstruktorin koodi, ja palautetaan viite juuri luotuun olioon. Konstruktori suorittaa mahdolliset muutokset olioon liittyvään tilaan. Esimerkissä luodaan Laskuri-tyyppinen olio, ja palautetaan viite siihen. Palautettu viite asetetaan sijoitusoperaattorilla = muuttujaan bonusLaskuri. Sama tapahtuu muuttujalle nimeltä axeLaskuri. Kuvana viittaustyyppi kannattaa ajatella siten, että muuttuja sisältää "langan" tai "nuolen", jonka päässä on olio itse. Muuttuja ei siis sisällä oliota, vaan viitteen olioon liittyviin tietoihin.

Tutkitaan seuraavaksi viittaustyyppisen muuttujan kopioitumista.

Laskuri bonusLaskuri = new Laskuri(5);
Laskuri axeLaskuri = new Laskuri(6);

bonusLaskuri = axeLaskuri; // muuttujaan bonusLaskuri kopioidaan muuttujan axeLaskuri sisältämä viite,
                           // eli viite Laskuri-tyyppiseen olioon joka on saanut konstruktorissaan arvon 6

Viittaustyyppistä muuttujaa kopioitaessa (yllä bonusLaskuri = axeLaskuri;) muuttujan viite kopioituu. Yllä muuttujan bonusLaskuri lokeroon kopioituu muuttujan axeLaskuri lokerossa oleva viite. Nyt kummatkin oliot viittaavat samaan paikkaan!

Jatketaan yllä olevaa esimerkkiä ja asetetaan muuttujaan axeLaskuri uusi viite, joka osoittaa kutsulla new Laskuri(10) luotuun olioon.

Laskuri bonusLaskuri = new Laskuri(5);
Laskuri axeLaskuri = new Laskuri(6);

bonusLaskuri = axeLaskuri; // muuttujaan bonusLaskuri kopioidaan muuttujan axeLaskuri sisältämä viite,
                           // eli viite Laskuri-tyyppiseen olioon joka on saanut konstruktorissaan arvon 6

axeLaskuri = new Laskuri(10); // muuttujaan axeLaskuri asetetaan uusi viite, joka osoittaa
                              // new Laskuri(10) - kutsulla luotuun Laskuri-olioon

// muuttuja bonusLaskuri sisältää vieläkin viitteen Laskuri-olioon, joka sai konstruktorissaan arvon 6

Esimerkissä tehdään käytännössä samat operaatiot kuin alkeistyyppi-kappaleessa olevassa asetusesimerkissä. Äskeisessä esimerkissä kopioimme viittaustyyppisten muuttujien viitteitä, kun taas alkeistyyppisiin muuttujiin liittyvässä esimerkissä kopioimme alkeistyyppien arvoja. Kummassakin tapausessa siis lokeron sisältö kopioidaan, alkeistyyppisten muuttujien lokero sisältää arvon, viittaustyyppisten muuttujien lokero sisältää viitteen.

Edellisen esimerkin lopussa kukaan ei viittaa Laskuriolioon, joka sai arvokseen 5 sen konstruktorissa. Javassa oleva roskienkeruumekanismi käy ajallaan poistamassa tällaiset turhat oliot. Lopputilanne kuvana:

Tarkastellaan vielä kolmatta esimerkkiä, joka näyttää viite- ja alkeistyyppisten muuttujien oleellisen eron.

Laskuri bonusLaskuri = new Laskuri(5);
Laskuri axeLaskuri = new Laskuri(6);

bonusLaskuri = axeLaskuri; // muuttujaan bonusLaskuri kopioidaan muuttujan axeLaskuri sisältämä viite,
                           // eli viite Laskuri-tyyppiseen olioon joka on saanut konstruktorissaan arvon 6

axeLaskuri.kasvataArvoa(); // kasvatetaan axeLaskuri-viitteen takana olevan olion arvoa yhdellä

System.out.println(bonusLaskuri.annaArvo());
System.out.println(axeLaskuri.annaArvo());
7
7

Koska asetuksen bonusLaskuri = axeLaskuri; jälkeen bonusLaskuri-muuttuja viittaa samaan olioon kuin axeLaskuri-muuttuja, on kummankin laskurin arvo 7 vaikka kasvatuksia on tehty vain yksi. Tämä johtuu siitä että kummatkin laskurit viittaavat samaan olioon.

Kuvana tilanne on ehkä selkeämpi. Kutsu axeLaskuri.kasvataArvoa() kasvattaa muuttujan axeLaskuri viittaaman olion sisältämää muuttujan arvo arvoa yhdellä. Koska muuttuja bonusLaskuri viittaa samaan olioon, palauttaa kutsu bonusLaskuri.annaArvo() saman muuttujan arvon, jota aiempi kutsu axeLaskuri.kasvataArvoa() kasvatti.

Viittaustyyppiset muuttujat viittaavat aina toisaalla oleviin olioihin. Useat viittaustyyppiset muuttujat voivat sisältää saman viitteen, jolloin kaikki muuttujat osoittavat samaan olioon. Seuraavassa esimerkissä on kolme viittaustyyppistä muuttujaa, jotka kaikki osoittavat samaan Laskuri-olioon.

Laskuri bonus = new Laskuri(5);
Laskuri ihq = bonus;
Laskuri lennon = bonus;

Esimerkissä luodaan vain yksi Laskuri-olio, mutta kaikki kolme Laskuri-tyyppistä muuttujaa osoittavat lopussa siihen. Tällöin kaikki metodikutsut viitteille bonus, ihq ja lennon muokkaavat samaa oliota. Vielä kerran: viittaustyyppisiä muuttujia kopioitaessa viitteet kopioituvat. Kuvana:

Katsotaan kopioitumista vielä esimerkillä.

Laskuri bonus = new Laskuri(5);
Laskuri ihq = bonus;
Laskuri lennon = bonus;

lennon = new Laskuri(3);

Kun muuttujan lennon sisältö, eli viite muuttuu, se ei vaikuta muuttujien bonus tai ihq sisältämiin viitteisiin. Muuttujan arvoa asetettaessa muutetaan aina vain muuttujan oman lokeron sisältöä. Kuvana:

Viittaustyyppinen muuttuja metodin parametrina

Kun viittaustyyppinen muuttuja annetaan parametrina metodille, luodaan metodin parametrimuuttujalle kopio annetusta muuttujan viitteestä. Parametrimuuttujalla on siis oma lokero, johon viite kopioidaan. Alkeistyyppisistä muuttujista poiketen kopioimme viitteen, emmekä arvoa, eli voimme muokata viitteen takana olevaa oliota myös metodin sisällä. Oletetaan että metodimme on alla esitelty public void lisaaLaskuriin(Laskuri laskuri, int paljonko).

public void lisaaLaskuriin(Laskuri laskuri, int paljonko) {
    for (int i = 0; i < paljonko; i++) {
        laskuri.kasvataArvoa();
    }
}

Metodille lisaaLaskuriin annetaan kaksi parametria, viittaustyyppinen muuttuja ja alkeistyyppinen muuttuja. Kumpaankin muuttujaan liittyvän lokeron sisältö kopioidaan metodin parametrimuuttujien omiin lokeroihin. Viittaustyyppiselle parametrimuuttujalle laskuri kopioituu viite ja alkeistyyppiselle parametrimuuttujalle paljonko kopioituu arvo. Metodi kutsuu Laskuri-tyyppisen parametrin metodia kasvataArvoa() paljonko-muuttujan sisältämän arvon määrän. Tutkitaan vielä metodin kutsumista.

int kertoja = 10;

Laskuri bonus = new Laskuri(10);
lisaaLaskuriin(bonus, kertoja);
// muuttujan bonus sisäinen arvo on nyt 20

Esimerkissä kutsutaan lisaaLaskuriin()-metodia muuttujilla bonus ja kertoja. Metodin parametrimuuttujiin laskuri ja paljonko kopioituvat siis viittaustyyppisen muuttujan bonus viite, ja alkeistyyppisen muuttujan kertojaarvo 10. Metodi suorittaa metodissa olevalle muuttujalle laskuri paljonko muuttujan määrittelemän määrän kasvataArvoa()-metodikutsuja. Tämä kuvana:

Metodissa on siis pääohjelmasta täysin erilliset muuttujat!

Viittaustyyppisestä muuttujasta kopioituu metodin sisäiseen muuttujaan viite, eli metodin sisäinen muuttuja viittaa vieläkin samaan olioon. Alkeistyyppisestä muuttujasta kopioituu arvo, eli metodin sisäisellä muuttujalla on täysin oma arvonsa.

Metodi näkee saman laskurin johon muuttuja bonus viittaa, eli metodin tekemä muutos vaikuttaa suoraan olioon. Alkeistyyppien suhteen tilanne on toinen, eli metodille tulee ainoastaan kopio muuttujan kertoja arvosta. Metodista käsin ei siis voi muuttaa alkeistyyppisten muuttujien arvoja.

Viittaustyyppinen muuttuja metodin paluuarvona

Kun metodi palauttaa viittaustyyppisen muuttujan, palauttaa se viitteen muualla sijaitsevaan olioon. Metodin palauttaman viittaustyyppisen muuttujan voi asettaa muuttujalle samalla tavalla kuin normaalikin asetus tapahtuu, eli yhtäsuuruusmerkin avulla. Katsotaan metodia public Laskuri luoLaskuri(int alkuarvo), joka luo uuden viittaustyyppisen muuttujan.

public Laskuri luoLaskuri(int alkuarvo) {
    return new Laskuri(alkuarvo);
}

Metodi luoLaskuri palauttaa metodissa luotuun olioon viittaavan viitteen uusiLaskuri. Uusi olio luodaan aina metodia kutsuttaessa, seuraavassa esimerkissä luomme kaksi erillistä Laskuri-tyyppistä oliota.

Laskuri bonus  = luoLaskuri(10);
Laskuri lennon = luoLaskuri(10);

Metodi luoLaskuri luo aina uuden Laskuri-tyyppisen olion. Ensimmäisessä kutsussa, eli kutsussa Laskuri bonus = luoLaskuri(10); asetetaan metodin palauttama viite viittaustyyppiseen muuttujaan bonus. Toisessa metodikutsussa luodaan uusi viite, joka asetetaan muuttujaan lennon. Muuttujat bonus ja lennon eivät sisällä samaa viitettä, sillä metodi luo aina uuden olion ja palauttaa viitteen luotuun olioon.

Static ja ei-static

Kerrataan ja täsmennetään Ohjelmoinnin perusteiden luvussa 30 käsiteltyä asiaa. Staattisilla ja ei-staattisilla metodeilla erotetaan se, mihin muuttuja tai metodi liittyy. Staattiset metodit liittyvät aina luokkaan, kun taas ei-staattiset metodit voivat muokata olion omia muuttujia.

Static, luokkakirjastot ja final

Static-määreen saavat metodit eivät liity olioihin vaan luokkiin. On mahdollista määritellä myös luokkakohtaisia muuttujia lisäämällä muuttujan eteen määre static. Esimerkiksi Integer.MAX_VALUE, Long.MIN_VALUE ja Double.MAX_VALUE ovat kaikki staattisia muuttujia. Staattisia muuttujia ja metodeja käytetään luokan nimen kautta, esimerkiksi LuokanNimi.muuttuja tai LuokanNimi.metodi().

Luokkakirjastoksi kutsutaan luokkaa, jossa on yleiskäyttöisiä metodeja ja muuttujia. Esimerkiksi Javan Math-luokka on luokkakirjasto. Se tarjoaa muun muassa Math.PI-muuttujan. Omien luokkakirjastojen toteuttaminen on usein hyödyllistä. Esimerkiksi Helsingin Seudun Liikenne (HSL) voisi pitää lippujensa hintoja luokkakirjastossa, josta ne löytyisi tarvittaessa.

public class HslHinnasto {
    public static final double KERTALIPPU_AIKUINEN = 2.50;
    public static final double RAITIOVAUNULIPPU_AIKUINEN = 2.50;
}

Avainsana final muuttujan määrittelyssä kertoo ettei muuttujaan voi asettaa uutta arvoa kun se on kerran asetettu. Final-tyyppiset muuttujat ovat vakioita, ja niiden tulee sisältää aina arvo. Esimerkiksi luokkamuuttuja Integer.MAX_VALUE on vakiotyyppinen luokkamuuttuja. Sillä on final-määre, joten sitä ei voi muuttaa.

Jos käytössämme on yllä esitelty luokka HslHinnasto, voivat kaikki ohjelmat, jotka käyttävät kerta- tai raitiovaunulipun hintaa käyttää niitä HslHinnasto-luokan kautta. Seuraavassa esimerkissä esitellään luokka Ihminen, jolla on metodi onkoRahaaKertalippuun(), joka käyttää HslHinnasto-luokasta löytyvää lipun hintaa.

public class Ihminen {
    private String nimi;
    private double rahat;
    // muut oliomuuttujat

    // konstruktori

    public boolean onkoRahaaKertalippuun() {
        if(this.rahat >= HslHinnasto.KERTALIPPU_AIKUINEN) {
            return true;
        }

        return false;
    }

    // muut luokkaan Ihminen liittyvät metodit
}

Metodi public boolean onkoRahaaKertalippuun() vertaa luokan Ihminen oliomuuttujaa rahat HslHinnasto-luokan staattiseen muuttujaan KERTALIPPU_AIKUINEN. Metodia onkoRahaaKertalippuun() voi kutsua vain olioviitteen kautta. Esimerkiksi:

Ihminen matti = new Ihminen();

if (matti.onkoRahaaKertalippuun()) {
    System.out.println("Ostetaan kertalippu.");
} else {
    System.out.println("Mennään pummilla.");
}

Huomaa nimeämiskäytäntö! Kaikki staattiset alkeistyyppiset muuttujat kirjoitetaan ISOLLA_JA_ALAVIIVOILLA. Staattiset metodit toimivat vastaavasti. Esimerkiksi Luokka HslHinnasto saattaisi kapseloida muuttujat ja antaa vain aksessorit niihin. Aksessoriksi kutsutaan metodia, jolla voi joko lukea muuttujan arvon tai sijoittaa muuttujalle uuden arvon.

public class HslHinnasto {
  private static final double KERTALIPPU_AIKUINEN = 2.50;
  private static final double RAITIOVAUNULIPPU_AIKUINEN = 2.50;

  public static double annaKertalipunHinta() {   // Aksessori
    return KERTALIPPU_AIKUINEN;
  }

  public static double annaRaitiovaunulipunHinta() {   // Aksessori
    return RAITIOVAUNULIPPU_AIKUINEN;
  }
}

Tällöin Ihminen-luokan toteutuksessa tulee kutsua metodiaannaKertalipunHinta() sen sijaan että kutsuttaisiin muuttujaa suoraan. Palaamme näkyvyysmääreiden public ja private tarkempaan merkitykseen kohta.

public class Ihminen {
    private String nimi;
    private double rahat;
    // muut oliomuuttujat

    // konstruktori

    public boolean onkoRahaaKertalippuun() {
        if(this.rahat >= HslHinnasto.annaKertalipunHinta()) {
            return true;
        }

        return false;
    }

    // muut luokkaan Ihminen liittyvät metodit
}

Ei-static

Ei-staattiset metodit ja muuttujat liittyvät olioihin. Oliomuuttujat, eli attribuutit määritellään luokan alussa. Kun olio luodaan new-kutsulla, kaikille oliomuuttujille varataan tila olioon liittyvän viitteen päähän. Muuttujien arvot ovat oliokohtaisia, eli jokaisella oliolla on omat muuttujien arvot. Tutkitaan taas luokkaa Ihminen, jolla on oliomuuttujat nimi ja rahat.

public class Ihminen {
  private String nimi;
  private double rahat;

  // muut tiedot
}

Kun luokasta Ihminen luodaan uusi ilmentymä, alustetaan myös siihen liittyvät muuttujat. Jos viittaustyyppistä muuttujaa nimi ei alusteta, saa se arvokseen null-viitteen. Lisätään luokan Ihminen toteutukseen vielä konstruktori ja muutama metodi.

public class Ihminen {
    private String nimi;
    private double rahat;

    // konstruktori
    public Ihminen(String nimi, double rahat) {
        this.nimi = nimi;
        this.rahat = rahat;
    }

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

    public double getRahat() {
        return this.rahat;
    }

    public void lisaaRahaa(double summa) {
        if(summa > 0) {
          this.rahat += summa;
        }
    }

    public boolean onkoRahaaKertalippuun() {
        if(this.rahat >= HslHinnasto.annaKertalipunHinta()) {
            return true;
        }

        return false;
    }
}

Konstruktori Ihminen(String nimi, double rahat) luo uuden ihmisolion ja palauttaa viitteen siihen. Aksessori getNimi() palauttaa viitteen nimi-olioon, ja getRahat()-metodi palauttaa alkeistyyppisen muuttujan rahat. Metodi lisaaRahaa(double summa) lisaa oliomuuttujaan rahat parametrina annetun summan jos parametrin arvo on suurempi kuin 0.

Oliometodeja kutsutaan olion viitteen kautta. Seuraava koodiesimerkki luo uuden Ihmis-olion, lisää sille rahaa, ja lopuksi tulostaa sen nimen. Huomaa että metodikutsut ovat muotoa olionNimi.metodinNimi()

Ihminen matti = new Ihminen("Matti", 5.0);
matti.lisaaRahaa(5); // lottovoitto!

if (matti.onkoRahaaKertalippuun()) {
    System.out.println("Ostetaan kertalippu.");
} else {
    System.out.println("Mennään pummilla.");
}

Esimerkki tulostaa "Ostetaan kertalippu".

Metodit luokan sisällä

Luokan sisäisiä ei-staattisia metodeja voi kutsua myös ilman olio-etuliitettä metodiin liittyvissä luokissa. Esimerkiksi seuraava toString()-metodi Ihminen luokalle, joka kutsuu olioon liittyvää metodia getNimi().

public class Ihminen {
    // aiemmin toteutetun luokan sisältö

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

Metodi toString() kutsuu siis luokan sisäistä juuri käsiteltävään olioon liittyvää getNimi()-metodia. Etuliite this korostaa kutsun liittyvän juuri tähän olioon.

Ei-staattiset metodit voivat kutsua myös staattisia, eli luokkakohtaisia metodeja. Toisaalta, luokkakohtaiset metodit eivät voi kutsua oliokohtaisia metodeja ilman viitettä itse olioon, sillä ilman viitettä ei päästä käsiksi olioon liittyviin tietoihin.

Muuttujat metodien sisällä

Metodien sisällä määriteltävät muuttujat ovat metodien suorituksessa käytettäviä apumuuttujia, eikä niitä tule sekoittaa oliomuuttujiin. Alla esimerkki metodista, jossa luodaan metodiin paikallinen muuttuja. Muuttuja indeksi on olemassa ja käytössä vain metodin suorituksen ajan.

public class ... {
    ...

    public static void tulostaTaulukko(String[] taulukko) {
        int indeksi = 0;

        while(indeksi < taulukko.length) {
            System.out.println(taulukko[indeksi]);
            indeksi++;
        }
    }
}

Metodissa tulostaTaulukko() luodaan apumuuttuja indeksi jota käytetään taulukon läpikäynnissä. Muuttuja indeksi on olemassa vain metodin suorituksen ajan.

Tavara, Matkalaukku ja Lastiruuma

Tässä tehtäväsarjassa tehdään luokat Tavara, Matkalaukku ja Lastiruuma, joiden avulla harjoitellaan olioita, jotka sisältävät toisia olioita.

Tavara-luokka

Tee luokka Tavara, josta muodostetut oliot vastaavat erilaisia tavaroita. Tallennettavat tiedot ovat tavaran nimi ja paino (kg).

Lisää luokkaan seuraavat metodit:

Seuraavassa on luokan käyttöesimerkki:

public class Main {
    public static void main(String[] args) {
        Tavara kirja = new Tavara("Aapiskukko", 2);
        Tavara puhelin = new Tavara("Nokia 3210", 1);

        System.out.println("Kirjan nimi: " + kirja.getNimi());
        System.out.println("Kirjan paino: " + kirja.getPaino());

        System.out.println("Kirja: " + kirja);
        System.out.println("Puhelin: " + puhelin);
    }
}

Ohjelman tulostuksen tulisi olla seuraava:

Kirjan nimi: Aapiskukko
Kirjan paino: 2
Kirja: Aapiskukko (2 kg)
Puhelin: Nokia 3210 (1 kg)

Matkalaukku-luokka

Tee luokka Matkalaukku. Matkalaukkuun liittyy tavaroita ja maksimipaino, joka määrittelee tavaroiden suurimman mahdollisen yhteispainon.

Lisää luokkaan seuraavat metodit:

Tavarat kannattaa tallentaa ArrayList-olioon:

ArrayList<Tavara> tavarat = new ArrayList<Tavara>();

Luokan Matkalaukku tulee valvoa, että sen sisältämien tavaroiden yhteispaino ei ylitä maksimipainoa. Jos maksimipaino ylittyisi lisättävän tavaran vuoksi, metodi lisaaTavara ei saa lisätä uutta tavaraa laukkuun.

Seuraavassa on luokan käyttöesimerkki:

public class Main {
    public static void main(String[] args) {
        Tavara kirja = new Tavara("Aapiskukko", 2);
        Tavara puhelin = new Tavara("Nokia 3210", 1);
        Tavara tiiliskivi = new Tavara("tiiliskivi", 4);

        Matkalaukku matkalaukku = new Matkalaukku(5);
        System.out.println(matkalaukku);

        matkalaukku.lisaaTavara(kirja);
        System.out.println(matkalaukku);

        matkalaukku.lisaaTavara(puhelin);
        System.out.println(matkalaukku);

        matkalaukku.lisaaTavara(tiiliskivi);
        System.out.println(matkalaukku);
    }
}

Ohjelman tulostuksen tulisi olla seuraava:

0 tavaraa (0 kg)
1 tavaraa (2 kg)
2 tavaraa (3 kg)
2 tavaraa (3 kg)

Kielenhuoltoa

Ilmoitukset "0 tavaraa" ja "1 tavaraa" eivät ole kovin hyvää suomea – paremmat muodot olisivat "ei tavaroita" ja "1 tavara". Tee tämä muutos luokkaan Matkalaukku.

Nyt edellisen ohjelman tulostuksen tulisi olla seuraava:

ei tavaroita (0 kg)
1 tavara (2 kg)
2 tavaraa (3 kg)
2 tavaraa (3 kg)

Kaikki tavarat

Lisää luokkaan Matkalaukku seuraavat metodit:

Seuraavassa on luokan käyttöesimerkki:

public class Main {
    public static void main(String[] args) {
        Tavara kirja = new Tavara("Aapiskukko", 2);
        Tavara puhelin = new Tavara("Nokia 3210", 1);
        Tavara tiiliskivi = new Tavara("tiiliskivi", 4);

        Matkalaukku matkalaukku = new Matkalaukku(10);
        matkalaukku.lisaaTavara(kirja);
        matkalaukku.lisaaTavara(puhelin);
        matkalaukku.lisaaTavara(tiiliskivi);

        System.out.println("Matkalaukussa on seuraavat tavarat:");
        matkalaukku.tulostaTavarat();
        System.out.println("Yhteispaino: " + matkalaukku.yhteispaino() + " kg");
    }
}

Ohjelman tulostuksen tulisi olla seuraava:

Matkalaukussa on seuraavat tavarat:
Aapiskukko (2 kg)
Nokia 3210 (1 kg)
Tiiliskivi (4 kg)
Yhteispaino: 7 kg

Muokkaa myös luokkaasi siten, että käytät vain kahta oliomuuttujaa. Toinen sisältää maksimipainon, toinen on lista laukussa olevista tavaroista.

Raskain tavara

Lisää vielä luokkaan Matkalaukku metodi raskainTavara, joka palauttaa painoltaan suurimman tavaran. Jos yhtä raskaita tavaroita on useita, metodi voi palauttaa minkä tahansa niistä. Metodin tulee palauttaa olioviite. Jos laukku on tyhjä, palauta arvo null.

Seuraavassa on luokan käyttöesimerkki:

public class Main {
    public static void main(String[] args) {
        Tavara kirja = new Tavara("Aapiskukko", 2);
        Tavara puhelin = new Tavara("Nokia 3210", 1);
        Tavara tiiliskivi = new Tavara("Tiiliskivi", 4);

        Matkalaukku matkalaukku = new Matkalaukku(10);
        matkalaukku.lisaaTavara(kirja);
        matkalaukku.lisaaTavara(puhelin);
        matkalaukku.lisaaTavara(tiiliskivi);

        Tavara raskain = matkalaukku.raskainTavara();
        System.out.println("Raskain tavara: " + raskain);
    }
}

Ohjelman tulostuksen tulisi olla seuraava:

Raskain tavara: Tiiliskivi (4 kg)

Lastiruuma-luokka

Tee luokka Lastiruuma, johon liittyvät seuraavat metodit:

Tallenna matkalaukut sopivaan ArrayList-rakenteeseen.

Luokan Lastiruuma tulee valvoa, että sen sisältämien matkalaukkujen yhteispaino ei ylitä maksimipainoa. Jos maksimipaino ylittyisi uuden matkalaukun vuoksi, metodi lisaaMatkalaukku ei saa lisätä uutta matkalaukkua.

Seuraavassa on luokan käyttöesimerkki:

public class Main {
    public static void main(String[] args) {
        Tavara kirja = new Tavara("Aapiskukko", 2);
        Tavara puhelin = new Tavara("Nokia 3210", 1);
        Tavara tiiliskivi = new Tavara("tiiliskivi", 4);

        Matkalaukku matinLaukku = new Matkalaukku(10);
        matinLaukku.lisaaTavara(kirja);
        matinLaukku.lisaaTavara(puhelin);

        Matkalaukku pekanLaukku = new Matkalaukku(10);
        pekanLaukku.lisaaTavara(tiiliskivi);

        Lastiruuma lastiruuma = new Lastiruuma(1000);
        lastiruuma.lisaaMatkalaukku(matinLaukku);
        lastiruuma.lisaaMatkalaukku(pekanLaukku);

        System.out.println(lastiruuma);
    }
}

Ohjelman tulostuksen tulisi olla seuraava:

2 matkalaukkua (7 kg)

Lastiruuman sisältö

Lisää luokkaan Lastiruuma metodi public void tulostaTavarat(), joka tulostaa kaikki lastiruuman matkalaukuissa olevat tavarat.

Seuraavassa on luokan käyttöesimerkki:

public class Main {
    public static void main(String[] args) {
        Tavara kirja = new Tavara("Aapiskukko", 2);
        Tavara puhelin = new Tavara("Nokia 3210", 1);
        Tavara tiiliskivi = new Tavara("tiiliskivi", 4);

        Matkalaukku matinLaukku = new Matkalaukku(10);
        matinLaukku.lisaaTavara(kirja);
        matinLaukku.lisaaTavara(puhelin);

        Matkalaukku pekanLaukku = new Matkalaukku(10);
        pekanLaukku.lisaaTavara(tiiliskivi);

        Lastiruuma lastiruuma = new Lastiruuma(1000);
        lastiruuma.lisaaMatkalaukku(matinLaukku);
        lastiruuma.lisaaMatkalaukku(pekanLaukku);

        System.out.println("Ruuman matkalaukuissa on seuraavat tavarat:");
        lastiruuma.tulostaTavarat();
    }
}

Ohjelman tulostuksen tulisi olla seuraava:

Ruuman matkalaukuissa on seuraavat tavarat:
Aapiskukko (2 kg)
Nokia 3210 (1 kg)
tiiliskivi (4 kg)

Paljon tiiliskiviä

Testataan vielä, että lastiruuman toiminta on oikea eikä maksimipaino pääse ylittymään. Tee Main-luokkaan metodi public static void lisaaMatkalaukutTiiliskivilla(Lastiruuma lastiruuma), joka lisää parametrina annettuun lastiruumaan 100 matkalaukkua, joissa jokaisessa on yksi tiiliskivi. Tiiliskivien painot ovat 1, 2, 3, ..., 100 kg.

Ohjelman runko on seuraava:

public class Main {
    public static void main(String[] args) {
        Lastiruuma lastiruuma = new Lastiruuma(1000);
        lisaaMatkalaukutTiiliskivilla(lastiruuma);
        System.out.println(lastiruuma);
    }

    public static void lisaaMatkalaukutTiiliskivilla(Lastiruuma lastiruuma) {
        // 100 matkalaukun lisääminen, jokaiseen tulee tiiliskivi
    }
}

Ohjelman tulostus on seuraava:

44 matkalaukkua (990 kg)

Hajautustaulu (HashMap)

Hajautustaulu on yksi Javan yleishyödyllisistä tietorakenteista. Hajautustaulun ideana on laskea olioon liittyvälle avaimelle, eli yksilöivälle arvolle (esimerkiksi henkilötunnus, opiskelijanumero, puhelinnumero), indeksi hajautustaulun sisältämästä taulukosta. Indeksin avulla Avaimen muuttamista indeksiksi kutsutaan hajautukseksi, joka tarkoittaa indeksin laskemista. Hajautus tapahtuu aina tietyn hajautusfunktion avulla, joka takaa että tietyllä avaimella saadaan aina sama indeksi.

Avaimen perusteella lisääminen ja hakeminen mahdollistaa erittäin nopean hakemisen. Sen sijaan että tutkisimme taulukon alkiot järjestyksessä (pahimmassa tapauksessa joudumme käymään kaikki alkiot läpi), tai etsisimme arvoa binäärihaulla (pahimmassa tapauksessa käymme taulukon kokoon liittyvän logaritmisen määrän alkoita läpi), voimme periaatteessa katsoa tasan yhtä taulukon indeksiä ja tarkistaa onko indeksiin tallennettu arvoa vai ei.

Hajautustaulu käyttää avaimen arvon laskemiseen Object-luokassa määriteltyä hashCode()-metodia, jonka jokainen toteutettu luokka perii. Emme kuitenkaan tutustu hajautustaulun toteutukseen tarkemmin tällä kurssilla. Perintään palaamme noin viikolla 4.

Javan luokka HashMap kapseloi eli piilottaa hajautustaulun toteutuksen, ja tarjoaa valmiit metodit sen käyttöön.

Hajautustaulua luodessa tarvitaan kaksi tyyppiparametria, avainmuuttujan tyyppi ja tallennettavan olion tyyppi. Seuraava esimerkki käyttää avaimena String-tyyppistä oliota, ja tallennettavana oliona String-tyyppistä oliota.

Hajautustaulun toiminnasta ja käytöstä on lyhyt yhteenveto MOOC-kurssin Cheatsheetissä.

HashMap<String, String> numerot = new HashMap<String, String>();
numerot.put("Yksi", "Uno");
numerot.put("Kaksi", "Dos");

String kaannos = numerot.get("Yksi");
System.out.println(kaannos);

System.out.println(numerot.get("Kaksi"));
System.out.println(numerot.get("Kolme"));
System.out.println(numerot.get("Uno"));
Uno
Dos
null
null

Esimerkissä luodaan hajatustaulu, jonka avaimena ja tallennettavana oliona merkkijono. Hajautustauluun lisätään tietoa put()-metodilla, joka saa parametreikseen viitteet avaimeen ja tallennettavaan olioon. Metodi get()-palauttaa parametrina annettuun avaimeen liittyvän viitteen tai arvon null jos avaimella ei löydy viitettä.

Hajautustaulussa tietty avain osoittaa aina tiettyyn paikkaan. Sama avain ei voi osoittaa kahteen eri olioon. Jos jo olemassaolevalla avaimella tallennetaan uusi olio, katoaa vanhan olion viite hajautustaulusta.

HashMap<String, String> numerot = new HashMap<String, String>();
numerot.put("Yksi", "Uno");
numerot.put("Kaksi", "Dos");
numerot.put("Yksi", "Ein");

String kaannos = numerot.get("Yksi");
System.out.println(kaannos);

System.out.println(numerot.get("Kaksi"));
System.out.println(numerot.get("Kolme"));
System.out.println(numerot.get("Uno"));

Koska avain "Yksi" asetetaan uudestaan, on esimerkin tulostus nyt seuraavanlainen.

Ein
Dos
null
null

Lempinimet

Luo main-metodissa uusi HashMap<String,String>-olio. Tallenna tähän HashMappiin seuraavien henkilöiden nimet ja lempinimet niin, että nimi on avain ja lempinimi on arvo. Käytä pelkkiä pieniä kirjaimia.

Tämän jälkeen hae HashMapistä mikaelin lempinimi ja tulosta se.

Kirjojen haku hajautustaulun avulla

Tutkitaan hajautustaulun toimintaa seuraavaksi kirjastoesimerkin avulla. Kirjastosta voi hakea kirjoja kirjan nimen perusteella, nimi toimii siis kirjan avaimena. Jos annetulle nimelle löytyy kirja, saadaan siihen liittyvä viite ja samalla kirjan tiedot. Luodaan ensin esimerkkiluokka Kirja, jolla on oliomuuttujina nimi ja kirjaan liittyvä sisältö.

public class Kirja {
    private String nimi;
    private String sisalto;
    private int julkaisuvuosi;

    public Kirja() {
    }

    public Kirja(String nimi, int julkaisuvuosi, String sisalto) {
        this.nimi = nimi;
        this.julkaisuvuosi = julkaisuvuosi;
        this.sisalto = sisalto;
    }

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

    public void setNimi(String nimi) {
        this.nimi = nimi;
    }

    public int getJulkaisuvuosi() {
        return this.julkaisuvuosi;
    }

    public void setJulkaisuvuosi(int julkaisuvuosi) {
        this.julkaisuvuosi = julkaisuvuosi;
    }

    public String getSisalto() {
        return this.sisalto;
    }

    public void setSisalto(String sisalto) {
        this.sisalto = sisalto;
    }

    public String toString() {
        String palautus = "Nimi: " + this.nimi + " (" + this.julkaisuvuosi + ")\n"
                         + "Sisältö: " + this.sisalto;
        return palautus;
    }
}

Luodaan seuraavaksi hajautustaulu, joka käyttää avaimena kirjan nimeä eli String-tyyppistä oliota, ja tallentaa viitteitä Kirja-olioihin.

HashMap<String, Kirja> kirjahakemisto = new HashMap<String, Kirja>();

Yllä oleva hajautustaulu käyttää avaimena String-oliota. Laajennetaan esimerkkiä siten, että kirjahakemistoon lisätään kaksi kirjaa, "Järki ja tunteet" ja "Ylpeys ja ennakkoluulo".

Kirja jarkiJaTunteet = new Kirja("Järki ja tunteet", 1811, "...");
Kirja ylpeysJaEnnakkoluulo = new Kirja("Ylpeys ja ennakkoluulo", 1813, "....");

HashMap<String, Kirja> kirjahakemisto = new HashMap<String, Kirja>();
kirjahakemisto.put(jarkiJaTunteet.getNimi(), jarkiJaTunteet);
kirjahakemisto.put(ylpeysJaEnnakkoluulo.getNimi(), ylpeysJaEnnakkoluulo);

Kirjahakemistosta voi hakea kirjoja kirjan nimellä. Haku kirjalla "Viisasteleva sydän" ei tuota osumaa, jolloin hajautustaulu palauttaa null-viitteen. Kirja "Ylpeys ja ennakkoluulo" kuitenkin löytyy.

Kirja kirja = kirjahakemisto.get("Viisasteleva sydän");
System.out.println(kirja);
System.out.println();
kirja = kirjahakemisto.get("Ylpeys ja ennakkoluulo");
System.out.println(kirja);
null

Nimi: Ylpeys ja ennakkoluulo (1813)
Sisältö: ...

Hajautustaulu on hyödyllinen silloin kun tiedetään avain minkä perusteella halutaan hakea. Avaimet ovat aina yksilöllisiä, joten saman avaimen taakse ei voi tallettaa montaa eri oliota. Tallennettava olio voi toki olla lista tai toinen hajautustaulukko!

Kirjasto

Yllä olevan kirjahakemiston ongelmana on se, että kirjoja haettaessa täytyy muistaa kirjan nimi merkki merkiltä oikein. Javan valmis String-luokka tarjoaa meille välineet tähänkin. Metodi toLowerCase() muuttaa merkkijonon kirjaimet pieniksi, ja metodi trim() poistaa merkkijonon alusta ja lopusta tyhjät merkit (esimerkiksi välilyönnit). Tietokoneen käyttäjät usein kirjoittavat tekstin alkuun tai loppuun vahingossa välilyöntejä.

String teksti = "Ylpeys ja ennakkoluulo ";
teksti = teksti.toLowerCase(); // teksti nyt "ylpeys ja ennakkoluulo "
teksti = teksti.trim() // teksti nyt "ylpeys ja ennakkoluulo"

Luodaan luokka Kirjasto, joka kapseloi kirjat sisältävän hajautustaulun ja mahdollistaa kirjoitusasusta riippumattoman kirjojen haun. Lisätään Kirjasto-luokalle metodit lisaaKirja(Kirja kirja) ja poistaKirja(String kirjanNimi). Huomaamme jo nyt että merkkijonon siistimistä tarvitsisi useammassa metodissa, joten tehdään siitä erillinen metodi private String siistiMerkkijono(String merkkijono).

public class Kirjasto {
    private HashMap<String, Kirja> hakemisto;

    public Kirjasto() {
        this.hakemisto = new HashMap<String, Kirja>();
    }

    public void lisaaKirja(Kirja kirja) {
        String nimi = siistiMerkkijono(kirja.getNimi());

        if(this.hakemisto.containsKey(nimi)) {
            System.out.println("Kirja on jo kirjastossa!");
        } else {
            hakemisto.put(nimi, kirja);
        }
    }

    public void poistaKirja(String kirjanNimi) {
        kirjanNimi = siistiMerkkijono(kirjanNimi);

        if(this.hakemisto.containsKey(kirjanNimi)) {
            this.hakemisto.remove(kirjanNimi);
        } else {
            System.out.println("Kirjaa ei löydy, ei voida poistaa!");
        }
    }

    private String siistiMerkkijono(String merkkijono) {
        if (merkkijono == null) {
            return "";
        }

        merkkijono = merkkijono.toLowerCase();
        return merkkijono.trim();
    }
}

Toteutetaan kirjan hakutoiminnallisuus siten, että kirjaa haetaan hajautusrakenteesta sen nimellä.

    public Kirja haeKirja(String kirjanNimi) {
        kirjanNimi = siistiMerkkijono(kirjanNimi);
        return this.hakemisto.get(kirjanNimi);
    }

Yllä oleva metodi palauttaa haetun kirjan jos sellainen löytyy, muulloin null-arvon. Voimme myös käydä kaikki hakemiston avaimet läpi yksitellen, etsien esimerkiksi alkuosaa kirjan nimestä. Tällä tavalla etsiessä menetämme kuitenkin hajautustaulun nopeusedun, sillä huonoimmassa tapauksessa joudumme käymään kaikkien kirjojen nimet läpi. Hakeminen alkuosan perusteella onnistuisi hajautustaulun keySet()-metodin avulla. Metodi keySet() palauttaa avaimet joukossa, jonka voi käydä läpi for-each -toistolauseella.

    public Kirja haeKirjaNimenAlkuosalla(String kirjanAlkuosa) {
        kirjanAlkuosa = siistiMerkkijono(kirjanAlkuosa);

        for (String avain: this.hakemisto.keySet()) {
            if (avain.startsWith(kirjanAlkuosa)) {
                return this.hakemisto.get(avain);
            }
        }

        return null;
    }

Jätämme yllä olevan metodin kuitenkin pois kirjastostamme. Kirjastosta puuttuu oleellisista toiminnoista vielä kirjojen listaaminen. Luodaan metodi public ArrayList<Kirja> kirjalista(), joka palauttaa listan kirjaston kirjoista. Metodi kirjalista hyödyntää hajautustaulun tarjoamaa values()-metodia. Metodi values() palauttaa kokoelman kirjaston kirjoista, jonka voi antaa parametrina ArrayList-luokan konstruktorille.

public class Kirjasto {
    private HashMap<String, Kirja> hakemisto;

    public Kirjasto() {
        this.hakemisto = new HashMap<String, Kirja>();
    }

    public Kirja haeKirja(String kirjanNimi) {
        kirjanNimi = siistiMerkkijono(kirjanNimi);
        return this.hakemisto.get(kirjanNimi);
    }

    public void lisaaKirja(Kirja kirja) {
        String nimi = siistiMerkkijono(kirja.getNimi());

        if(this.hakemisto.containsKey(nimi)) {
            System.out.println("Kirja on jo kirjastossa!");
        } else {
            this.hakemisto.put(nimi, kirja);
        }
    }

    public void poistaKirja(String kirjanNimi) {
        kirjanNimi = siistiMerkkijono(kirjanNimi);

        if(this.hakemisto.containsKey(kirjanNimi)) {
            this.hakemisto.remove(kirjanNimi);
        } else {
            System.out.println("Kirjaa ei löydy, ei voida poistaa!");
        }
    }

    public ArrayList<Kirja> kirjalista() {
        return new ArrayList<Kirja>(this.hakemisto.values());
    }

    private String siistiMerkkijono(String merkkijono) {
        if (merkkijono == null) {
            return "";
        }

        merkkijono = merkkijono.toLowerCase();
        return merkkijono.trim();
    }
}

Yksi ohjelmoinnin periaatteista on ns. DRY-periaate (Don't Repeat Yourself), jolla pyritään välttämään saman koodin olemista useassa paikassa. Merkkijonon pieneksi muuttaminen ja trimmaus, eli tyhjien merkkien poisto alusta ja lopusta, olisi toistunut useasti kirjastoluokassamme ilman metodia siistiMerkkijono. Toistuvaa koodia ei usein huomaa ennen kuin sitä on jo kirjoittanut, jolloin sitä päätyy koodiin lähes pakosti. Tässä ei kuitenkaan ole mitään pahaa. Tärkeintä on että siistit koodiasi sitä mukaa kun huomaat siistimistä vaativia tilanteita.

Alkeistyyppiset muuttujat hajautustaulussa

Huomaa että hajautustaulun avain ja tallennettava olio ovat aina viittaustyyppisiä. Jos haluat käyttää alkeistyyppisiä muuttujia avaimena tai tallennettavana arvona, on niille olemassa myös viittaustyyppiset vastineet. Alla on esitelty muutama.

AlkeistyyppiViittaustyyppinen vastine
intInteger
doubleDouble
charCharacter

Java oikeastaan kapseloi alkeistyyppiset muuttujat automaattisesti viittaustyyppisiksi muuttujiksi tarvittaessa. Vaikka numero 1 on alkeistyyppinen muuttuja, voit käyttää sitä suoraan Integer-tyyppisenä avaimena seuraavasti.

HashMap<Integer, String> taulu = new HashMap<Integer, String>();
taulu.put(1, "Ole!");

Alkeistyyppisten muuttujien automaattista muunnosta viittaustyyppisiksi kutsutaan Javassa auto-boxingiksi, eli automaattiseksi "laatikkoon" asettamiseksi. Vastaava onnistuu myös toisinpäin. Voimme luoda metodin, joka palauttaa hajautustaulun sisältämän kokonaisluvun. Seuraavassa esimerkissä olevassa metodissa lisaaBongaus tapahtuu automaattinen tyyppimuunnos.

public class Rekisteribongaus {
    private HashMap<String, Integer> bongatut;

    public Numerokirjanpito() {
        this.bongatut = new HashMap<String, Integer>();
    }

    public void lisaaBongaus(String nimi, int numero) {
        this.bongatut.put(nimi, numero);
    }

    public int viimeisinBongaus(String nimi) {
        this.bongatut.get(nimi);
    }
}

Vaikka hajautustaulu sisältää Integer-tyyppisiä olioita, osaa Java myös muuntaa tietyt viittaustyyppiset muuttujat myös niiden alkeistyyppisiksi vastineiksi. Esimerkiksi Integer-oliot muuttuvat tarpeen vaatiessa int-tyyppisiksi muuttujiksi. Tässä piilee kuitenkin vaara! Jos yritämme muuttaa null-viitettä numeroksi, näemme virheen java.lang.reflect.InvocationTargetException. Kun teemme automaattista muunnosta, tulee varmistaa että muunnettava arvo ei ole null. Yllä olevassa ohjelmassa oleva viimeisinBongaus-metodi tulee korjata esimerkiksi seuraavasti.

    public int viimeisinBongaus(String nimi) {
        if(this.bongatut.containsKey(nimi) {
            return this.bongatut.get(nimi);
        }

        return 0;
    }

Velkakirja

Luo luokka Velkakirja, jolla on seuraavat toiminnot:

Luokkaa käytetään seuraavalla tavalla:

  Velkakirja matinVelkakirja = new Velkakirja();
  matinVelkakirja.asetaLaina("Arto", 51.5);
  matinVelkakirja.asetaLaina("Mikael", 30);

  System.out.println(matinVelkakirja.paljonkoVelkaa("Arto"));
  System.out.println(matinVelkakirja.paljonkoVelkaa("Joel"));

Yllä oleva esimerkki tulostaisi:

51.5
0

Huom! Velkakirjan ei tarvitse huomioida vanhoja lainoja. Kun asetat uuden velan henkilölle jolla on vanha velka, vanha velka unohtuu.

  Velkakirja matinVelkakirja = new Velkakirja();
  matinVelkakirja.asetaLaina("Arto", 51.5);
  matinVelkakirja.asetaLaina("Arto", 10.5);

  System.out.println(matinVelkakirja.paljonkoVelkaa("Arto"));
10.5

Sanakirja

Tässä tehtäväsarjassa toteutetaan sanakirja, josta voi hakea suomen kielen sanoille englanninkielisiä käännöksiä. Sanakirjan tekemisessä käytetään HashMap-tietorakennetta.

Luokka Sanakirja

Toteuta luokka nimeltä Sanakirja. Luokalla on aluksi seuraavat metodit:

Toteuta luokka Sanakirja siten, että sen ainoa oliomuuttuja on HashMap-tietorakenne.

Testaa sanakirjasi toimintaa:

    Sanakirja sanakirja = new Sanakirja();
    sanakirja.lisaa("apina", "monkey");
    sanakirja.lisaa("banaani", "banana");
    sanakirja.lisaa("cembalo", "harpsichord");

    System.out.println(sanakirja.kaanna("apina"));
    System.out.println(sanakirja.kaanna("porkkana"));
monkey
null

Kaikkien sanojen listaaminen

Lisää sanakirjaan metodi public ArrayList<String> kaannoksetListana() joka palauttaa sanakirjan sisällön listana avain = arvo muotoisia merkkijonoja.

    Sanakirja sanakirja = new Sanakirja();
    sanakirja.lisaa("apina", "monkey");
    sanakirja.lisaa("banaani", "banana");
    sanakirja.lisaa("cembalo", "harpsichord");

    ArrayList<String> kaannokset = sanakirja.kaannoksetListana();
    for(String kaannos: kaannokset) {
        System.out.println(kaannos);
    }
banaani = banana
apina = monkey
cembalo = harpsichord

Sanojen lukumäärä

Lisää sanakirjaan metodi public int sanojenLukumaara(), joka palauttaa sanakirjassa olevien sanojen lukumäärän.

    Sanakirja sanakirja = new Sanakirja();
    sanakirja.lisaa("apina", "monkey");
    sanakirja.lisaa("banaani", "banana");
    System.out.println(sanakirja.sanojenLukumaara());

    sanakirja.lisaa("cembalo", "harpsichord");
    System.out.println(sanakirja.sanojenLukumaara());
2
3

Tekstikäyttöliittymän alku

Harjoitellaan tässäkin tehtävässä erillisen tekstikäyttöliittymän tekemistä. Luo luokka Tekstikayttoliittyma, jolla on seuraavat metodit

Tekstikäyttöliittymässä tulee aluksi olla vain komento lopeta, joka poistuu tekstikäyttöliittymästä. Jos käyttäjä syöttää jotain muuta, käyttäjälle sanotaan "Tuntematon komento".

    Scanner lukija = new Scanner(System.in);
    Sanakirja sanakirja = new Sanakirja();

    Tekstikayttoliittyma kayttoliittyma = new Tekstikayttoliittyma(lukija, sanakirja);
    kayttoliittyma.kaynnista();
Komennot:
  lopeta - poistuu käyttöliittymästä

Komento: apua
Tuntematon komento.

Komento: lopeta
Hei hei!

Sanojen lisääminen ja kääntäminen

Lisää tekstikäyttöliittymälle komennot lisaa ja kaanna. Komento lisaa lisää kysyy käyttäjältä sanaparin ja lisää sen sanakirjaan. Komento kaanna kysyy käyttäjältä sanaa ja tulostaa sen käännöksen.

    Scanner lukija = new Scanner(System.in);
    Sanakirja sanakirja = new Sanakirja();

    Tekstikayttoliittyma kayttoliittyma = new Tekstikayttoliittyma(lukija, sanakirja);
    kayttoliittyma.kaynnista();
Komennot:
  lisaa - lisää sanaparin sanakirjaan
  kaanna - kysyy sanan ja tulostaa sen käännöksen
  lopeta - poistuu käyttöliittymästä

Komento: lisaa
Suomeksi: porkkana
Käännös: carrot

Komento: kaanna
Anna sana: porkkana
Käännös: carrot

Komento: lopeta
Hei hei!

Kohti testauksen automatisointia

Ohjelman testaaminen käsin on toivottoman työlästä. Syötteen antaminen on kuitenkin mahdollista automatisoida esimerkiksi syöttämällä Scanner-oliolle luettava merkkijono. Alla on annettu esimerkki siitä, miten yllä olevassa tehtävässä luotua ohjelmaa voi testata automaattisesti.

    String syote = "kaanna\n" + "apina\n"  +
                   "kaanna\n" + "juusto\n" +
                   "lisaa\n"  + "juusto\n" + "cheese\n" +
                   "kaanna\n" + "juusto\n" +
                   "lopeta\n";

    Scanner lukija = new Scanner(syote);
    Sanakirja sanakirja = new Sanakirja();

    Tekstikayttoliittyma kayttoliittyma = new Tekstikayttoliittyma(lukija, sanakirja);
    kayttoliittyma.kaynnista();

Ohjelma tulostus näyttää vain ohjelman antaman tulostuksen, ei käyttäjän tekemiä komentoja.

Komennot:
  lisaa - lisää sanaparin sanakirjaan
  kaanna - kysyy sanan ja tulostaa sen käännöksen
  lopeta - poistuu käyttöliittymästä

Komento: Anna sana: Tuntematon sana!

Komento: Anna sana: Tuntematon sana!

Komento: Suomeksi: Käännös:
Komento: Anna sana: Käännös: cheese

Komento: Hei hei!

Merkkijonon antaminen Scanner-luokalle korvaa näppäimistöltä luettavan syötteen merkkijonolla. Merkkijonomuuttujan syote sisältö siis "simuloi" käyttäjän antamaa syötettä. Rivinvaihto syötteeseen merkitään \n:llä. Jokainen yksittäinen rivinvaihtomerkkiin loppuva osa syote-merkkijonossa siis vastaa käyttäjän yhteen nextLine()-komentoon antamaa syötettä.

Testityötettä on helppo muuttaa, esim. seuraavassa syötetään lisää uusia sanoja sanakirjaan:

    String syote = "lisaa\n"  + "juusto\n" +     "cheese\n" +
                   "lisaa\n"  + "olut\n"   +     "beer\n" +
                   "lisaa\n"  + "kirja\n"  +     "book\n" +
                   "lisaa\n"  + "tietokone\n" +  "computer\n" +
                   "lisaa\n"  + "auto\n"   +     "car\n" +
                   "lopeta\n";

Kun haluat testata ohjelmasi toimintaa jälleen käsin, vaihda Scanner-olion konstruktorin parametriksi System.in, eli järjestelmän syötevirta.

Ohjelman toiminnan oikeellisuus pitää edelleen tarkastaa itse ruudulta. Tulostus voi olla aluksi hieman hämmentävää, sillä automatisoitu syöte ei näy ruudulla ollenkaan.

Lopullinen tavoite on automatisoida myös ohjelman tulostuksen oikeellisuden tarkastaminen niin hyvin, että ohjelman testaus ja testituloksen analysointi onnistuu "nappia painamalla". Palaamme aiheeseen myöhemmin kurssin aikana.

Java API

Kurssilla käyttämämme Java-ohjelmointikieli koostuu kolmesta osasta. Ensimmäinen osa on ohjelmointikielen semantiikka ja syntaksi: muuttujien määrittelytapa, kontrollirakenteiden muoto, ja muuttujien ja luokkien rakenne ja niin edelleen. Toinen osa on JVM, eli Java Virtual Machine, jota käytetään ohjelmien suorittamiseen. Java-ohjelmat käännetään tavukoodiksi, jota voidaan suorittaa missä tahansa koneessa olevan JVM:n avulla. Emme oikeastaan ole törmänneet ohjelmien kääntämiseen, sillä ohjelmointiympäristöt tekevät sen ohjelmoijien puolesta. Silloin tällöin ohjelmointiympäristö ei toimi odotetulla tavalla, ja saatamme joutua valitsemaan clean & build, joka poistaa vanhat lähdekoodit ja kääntää ohjelman uudestaan. Kolmantena osana on API (Application Programming Interface), eli ohjelmointirajapinta tai standardikirjasto.

API on ohjelmointikielen tarjoama joukko valmiita luokkia, joita ohjelmoija voi käyttää omissa projekteissaan. Esimerkiksi luokat ArrayList, Arrays, Collections, ja String ovat kaikki osa Javan valmista APIa. Javan version 7 API-kuvaus löytyy osoitteesta http://docs.oracle.com/javase/7/docs/api/. Osoitteessa olevan sivuston vasemmassa laidassa on Javan valmiille luokille luokkakuvaus. Etsiessäsi sivulta luokkaa ArrayList, löydät sivun http://docs.oracle.com/javase/7/docs/api/java/util/ArrayList.html, joka kuvaa ArrayList-luokan rakenteen, konstruktorit, ja metodit.

NetBeans osaa näyttää luokkaan liittyvän APIn tarvittaessa. Kun kirjoitat luokan nimen ja lisäät siihen liittyvän import-lauseen, voit klikata luokan nimeä oikealla hiirennapilla ja valita Show Javadoc. Tämä avaa luokkaan liittyvän API-kuvauksen selaimessa.

StringBuilder

Merkkijonojen rakentaminen +-operaattorilla on hieman vaivalloista ja lisäksi erittäin tehotonta. Tätä varten Javan standardikirjasto tarjoaa luokan StringBuilder.

Tutustu ensin StringBuilder-luokan apidokumentaatioon.

Tehtävänäsi on toteuttaa luokkametodi public static void pyramidi(int koko, StringBuilder rakentaja), joka toimii seuraavalla tavalla:

StringBuilder rakentaja = new StringBuilder();
pyramidi(3, rakentaja);
System.out.print(rakentaja.toString());
  
1
1 2
1 2 3
-----
StringBuilder rakentaja = new StringBuilder();
pyramidi(5, rakentaja);
System.out.print(rakentaja.toString());
1
1 2
1 2 3
1 2 3 4
1 2 3 4 5
---------

Huom! Käytä vain metodin pyramidi parametrina saamaa StringBuilder-oliota pyramidin rakentamiseen. Älä esimerkiksi käytä merkkijonojen katenaatiota tai luo omaa erillistä StringBuilder-oliota metodissa pyramidi.

Lentokenttä

Jokaisella viikolla on yksi laajempi tehtävä, jossa pääset vapaasti suunnittelemaan ohjelman rakenteen -- käyttöliittymän ulkomuoto ja vaaditut komennot on määritelty ennalta. Ohjelmoinnin jatkokurssin ensimmäinen vapaasti suunniteltava tehtävä on Lentokenttä.

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. Sama lentokone voi myös lentää useaa eri lentoa (useaa eri reittiä). Sovelluksen tulee toimia kahdessa vaiheessa. Ensin lentokentän työntekijä syöttää lentokoneiden ja lentojen tietoja hallintakäyttöliittymässä.

Kun käyttäjä poistuu hallintakäyttöliittymässä, avautuu käyttäjälle mahdollisuus 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
> porkkana
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
> 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)

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

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

Huom! Testien kannalta on oleellista että käyttöliittymä toimii kuten yllä kuvattu. Jos käyttäjä syöttää epäkelvon komennon, pyydetään komentoa uudestaan. Tämä tehtävä on kolmen yksittäisen tehtäväpisteen arvoinen.

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

Object

Kurssilla on jo useampaan otteeseen käytetty metodia public String toString() olion merkkijonoesityksen tulostamiseen. Emme ole saaneet selvyyttä miksi Java osaa käyttää kyseistä metodia. Olemattoman metodin kutsuminenhan tuottaa normaalisti virheen. Tutkitaan seuraavaa luokkaa Kirja, jolla ei ole metodia public String toString(), ja ohjelmaa joka yrittää tulostaa Kirja-luokasta luodun olion System.out.println()-komennolla.

public class Kirja {
    private String nimi;
    private int julkaisuvuosi;

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

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

    public int getJulkaisuvuosi() {
        return this.julkaisuvuosi;
    }
}
Kirja olioKirja = new Kirja("Oliokirja", 2000);
System.out.println(olioKirja);

Ohjelmamme ei tulosta virheilmoitusta tai kaadu kun annamme Kirja-luokasta tehdyn olion parametrina System.out.println-komennolle. Näemme virheilmoituksen tai kaatumisen sijaan mielenkiintoisen tulosteen. Tuloste sisältää luokan Kirja nimen ja epämääräisen @-merkkiä seuraavan merkkijonon. Huomaa että kutsussa System.out.println(olioKirja) Java tekee oikeasti kutsun System.out.println(olioKirja.toString()) -- emme kuitenkaan kohtaa virhettä.

Selitys liittyy Javan luokkien rakenteeseen. Jokainen Javan luokka perii automaattisesti luokan Object, joka sisältää joukon jokaiselle Javan luokalle hyödyllisiä perusmetodeja. Perintä tarkoittaa että oma luokkamme saa käyttöön perittävän luokan määrittelemiä toiminnallisuuksia ja ominaisuuksia. Luokka Object sisältää muun muassa metodin toString, joka periytyy luomiimme luokiin.

Object-luokassa määritelty toString-metodin tuottama merkkijono ei yleensä ole toivomamme. Tämän takia meidän tulee korvata, eli syrjäyttää metodi omalla toteutuksellamme. Lisätään luokkaan Kirja metodi public String toString(), joka korvaa perityssä Object luokassa olevan metodin toString.

public class Kirja {
    private String nimi;
    private int julkaisuvuosi;

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

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

    public int getJulkaisuvuosi() {
        return this.julkaisuvuosi;
    }

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

Nyt kun teemme oliosta ilmentymän ja annamme sen tulostusmetodille, näemme luokassa Kirja olevan toString-metodin tuottaman merkkijonon.

Kirja olioKirja = new Kirja("Oliokirja", 2000);
System.out.println(olioKirja);
Oliokirja (2000)

Luokassa Kirja olevan metodin toString yläpuolella on annotaatio @Override. Annotaatioilla annetaan vinkkejä sekä kääntäjälle että lukijalle siitä, miten metodeihin tulisi suhtautua. Annotaatio @Override tarkoittaa että annotaatiota seuraava korvaa perityssä luokassa määritellyn metodin. Annotaatio @Override auttaa ohjelmoijaa, sillä kääntäjä luo virheilmoituksen jos @Override on lisätty ei-perityn metodin yläpuolelle.

Luokasta Object peritään muitakin hyödyllisiä metodeja. Tutustutaan seuraavaksi metodeihin equals ja hashCode.

Metodi equals

Metodia equals käytetään kahden olion yhtäsuuruusvertailuun. Metodia on jo käytetty muun muassa String-olioiden kanssa.

Scanner lukija = new Scanner(System.in);

System.out.print("Kirjoita salasana: ");
String salasana = lukija.nextLine();

if(salasana.equals("salasana")) {
    System.out.println("Oikein meni!");
} else {
    System.out.println("Pieleen meni!");
}
Kirjoita salasana: mahtiporkkana
Pieleen meni!

Luokassa Object määritelty equals-metodi tarkistaa onko parametrina annetulla oliolla sama viite kuin oliolla johon verrataan. Jos viite on sama, palauttaa metodi arvon true, muuten false. Tämä selvenee seuraavalla esimerkillä. Luokassa Kirja ei ole omaa equals-metodin toteutusta, joten se käyttää Object-luokassa olevaa toteutusta.

Kirja olioKirja = new Kirja("Oliokirja", 2000);
Kirja toinenOlioKirja = olioKirja;

if (olioKirja.equals(toinenOlioKirja)) {
    System.out.println("Kirjat olivat samat");
} else {
    System.out.println("Kirjat eivät olleet samat");
}

toinenOlioKirja = new Kirja("Oliokirja", 2000);

if (olioKirja.equals(toinenOlioKirja)) {
    System.out.println("Kirjat olivat samat");
} else {
    System.out.println("Kirjat eivät olleet samat");
}
Kirjat olivat samat
Kirjat eivät olleet samat

Vaikka Kirja-olioiden sisäinen rakenne (eli oliomuuttujien arvot) ovat molemmissa tapauksissa täsmälleen samat, vain ensimmäinen vertailu tulostaa merkkijonon "Kirjat olivat samat". Tämä johtuu siitä että vain ensimmäisessä tapauksessa myös viitteet ovat samat. Toisessa vertailussa viitteet ovat eri vaikka olioiden sisäiset muuttujat ovat samat.

Haluamme että kirjojen vertailu onnistuu myös nimen ja vuoden perusteella. Korvataan Object-luokassa oleva metodi equals määrittelemällä sille toteutus luokkaan Kirja. Metodin equals tehtävänä on selvittää onko olio sama kuin metodin parametrina saatu olio. Metodi saa parametrina Object-tyyppisen olion. Määritellään ensin metodi, jonka mielestä kaikki oliot ovat samoja.

    public boolean equals(Object olio) {
        return true;
    }

Metodimme on varsin optimistinen, joten muutetaan sen toimintaa hieman. Määritellään että oliot eivät ole samoja jos parametrina saatu olio on null tai jos olioiden tyypit eivät ole samat. Olion tyypin saa (Object-luokassa määritellyllä) metodilla getClass(). Muussa tapauksessa oletetaan että oliot ovat samat.

    public boolean equals(Object olio) {
        if (olio == null) {
            return false;
        }

        if (getClass() != olio.getClass()) {
            return false;
        }

        return true;
    }

Metodi equals huomaa eron erityyppisten olioiden välillä, mutta ei vielä osaa erottaa samanlaisia olioita toisistaan. Jotta voisimme verrata nykyistä oliota ja parametrina saatua Object-oliota, tulee Object-olion tyyppiä muuttaa. Olion tyyppiä voi muuttaa tyyppimuunnoksella jos ja vain jos olion tyyppi on oikeasti sellainen, mihin sitä yritetään muuttaa. Tyyppimuunnos tapahtuu antamalla asetuslauseen oikealla puolella haluttu luokka suluissa, esimerkiksi:

    HaluttuTyyppi muuttuja = (HaluttuTyyppi) vanhaMuuttuja;

Voimme tehdä tyyppimuunnoksen koska tiedämme olioiden olevan samantyyppisiä -- jos ne ovat erityyppisiä yllä oleva metodi getClass palauttaa arvon false. Muunnetaan metodissa equals saatu Object-tyyppinen parametri Kirja-tyyppiseksi, ja todetaan kirjojen olevan eri jos niiden julkaisuvuodet ovat eri. Muuten kirjat ovat vielä samat.

    public boolean equals(Object olio) {
        if (olio == null) {
            return false;
        }

        if (getClass() != olio.getClass()) {
            return false;
        }

        Kirja verrattava = (Kirja) olio;

        if(this.julkaisuvuosi != verrattava.getJulkaisuvuosi()) {
            return false;
        }

        return true;
    }

Nyt vertailumetodimme osaa erottaa eri vuosina julkaistut kirjat. Lisätään vielä tarkistus että kirjojemme nimet ovat samat ja että oman kirjamme nimi ei ole null.

    public boolean equals(Object olio) {
        if (olio == null) {
            return false;
        }

        if (getClass() != olio.getClass()) {
            return false;
        }

        Kirja verrattava = (Kirja) olio;

        if (this.julkaisuvuosi != verrattava.getJulkaisuvuosi()) {
            return false;
        }

        if (this.nimi == null || !this.nimi.equals(verrattava.getNimi())) {
            return false;
        }

        return true;
    }

Mahtavaa, viimeinkin toimiva vertailumetodi! Alla vielä tämänhetkinen Kirja-luokkamme.

public class Kirja {
    private String nimi;
    private int julkaisuvuosi;

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

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

    public int getJulkaisuvuosi() {
        return this.julkaisuvuosi;
    }

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

    @Override
    public boolean equals(Object olio) {
        if (olio == null) {
            return false;
        }

        if (getClass() != olio.getClass()) {
            return false;
        }

        Kirja verrattava = (Kirja) olio;

        if (this.julkaisuvuosi != verrattava.getJulkaisuvuosi()) {
            return false;
        }

        if (this.nimi == null || !this.nimi.equals(verrattava.getNimi())) {
            return false;
        }

        return true;
    }
}

Nyt kirjojen vertailu palauttaa true jos kirjojen sisällöt ovat samat.

Kirja olioKirja = new Kirja("Oliokirja", 2000);
Kirja toinenOlioKirja = new Kirja("Oliokirja", 2000);

if (olioKirja.equals(toinenOlioKirja)) {
    System.out.println("Kirjat olivat samat");
} else {
    System.out.println("Kirjat eivät olleet samat");
}
Kirjat olivat samat

Equals ja ArrayList

Useat Javan valmiit tietorakenteet käyttävät equals-metodia osana sisäistä hakumekanismiaan. Esimerkiksi luokan ArrayList contains-metodi vertailee olioiden yhtäsuuruutta equals-metodin avulla. Jatketaan aiemmin määrittelemämme Kirja-luokan käyttöä seuraavassa esimerkissä. Jos emme toteuta omissa olioissamme equals-metodia, emme voi käyttää esimerkiksi contains-metodia. Kokeile allaolevaa koodia kahdella erilaisella Kirja-luokalla. Toisessa on equals-metodi, ja toisessa sitä ei ole.

ArrayList<Kirja> kirjat = new ArrayList<Kirja>();
Kirja olioKirja = new Kirja("Oliokirja", 2000);
kirjat.add(olioKirja);

if (kirjat.contains(olioKirja)) {
    System.out.println("Oliokirja löytyi.");
}

olioKirja = new Kirja("Oliokirja", 2000);

if (!kirjat.contains(olioKirja)) {
    System.out.println("Oliokirjaa ei löytynyt.");
}

Metodi hashCode

Metodi hashCode luo oliosta numeerisen arvon eli hajautusarvon. Numeerista arvoa tarvitaan esimerkiksi hajautustauluissa olion paikan päättelemistä varten. Oikeastaan kaikilla luokilla joita olemme tähän mennessä käyttäneet hajautustaulun avaimina on ollut oma hashCode-metodin toteutus. Luodaan esimerkki jossa näin ei ole: jatketaan kirjojen parissa ja mietitään kirjojen sijoittamista hyllyihin. Luodaan esimerkkiä varten luokka Hylly, joka kuvaa kirjojen sijoituspaikkaa.

public class Hylly {
    private String nimi;

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

    @Override
    public String toString() {
        return this.nimi;
    }
}

Esimerkissä luodaan hahmotelmaa järjestelmälle joka kertoo mihin hyllyyn mikäkin kirja kuuluu. Kirja-olioita käytetään hajautustaulun avaimena ja Hylly-olioita talletettavana arvona. Tallennamme hajautustauluun siis kirjojen säilytyshyllyjä. Alla olevassa esimerkissä luodaan tuttu "Oliokirja" ja asetetaan se hyllyyn "Ohjelmointikirjat". Tämän jälkeen hyllystä haetaan ensin samalla oliolla, jolla on sama viite. Tämän jälkeen luodaan uusi täsmälleen samanlainen "Oliokirja"oliokirja, ja yritetään etsiä sille sopivaa sijoituspaikkaa.

        HashMap<Kirja, Hylly> kirjojenHyllyt = new HashMap<Kirja, Hylly>();
        Kirja olioKirja = new Kirja("Oliokirja", 2000);
        Hylly hylly = new Hylly("Ohjelmointikirjat");

        kirjojenHyllyt.put(olioKirja, hylly);
        System.out.println(kirjojenHyllyt.get(olioKirja));

        Kirja toinenOlioKirja = new Kirja("Oliokirja", 2000);
        System.out.println(kirjojenHyllyt.get(toinenOlioKirja));
Ohjelmointikirjat
null

Löydämme halutun hyllyn hakiessamme viitteellä joka annettiin hajautustaulun put-metodille avaimeksi. Täsmälleen samanlaisella kirjalla mutta eri viitteellä haettaessa hyllyä ei löydy ja saamme null-viitteen. Syynä on taas Object-luokassa oleva hashCode-metodin oletustoteutus. Oletustoteutus luo indeksin viitteen perusteella.

Haluamme kirjoille sijoitushyllyn kirjan nimen perusteella, joten korvataan Object-luokassa oleva hashCode-metodin toteutus omalla hashCode-metodilla. Olemme aiemmin käyttäneet String-olioita menestyksekkäästi hajautustaulun avaimena, joten voimme päätellä että String-luokassa on oma hashCode-toteutus. Delegoidaan, eli siirretään laskemisvastuu String-oliolle.

    public int hashCode() {
        return this.nimi.hashCode();
    }

Yllä oleva ratkaisu on melko hyvä, mutta jos nimi on null, näemme NullPointerException-virheen. Korjataan tämä vielä määrittelemällä ehto: jos nimi-muuttujan arvo on null, palautetaan arvo 7. Arvo 7 on tätä luokkaa varten satunnaisesti valittu alkuluku. Jos toteutat toisen luokan, voit hyvin valita esimerkiksi arvon 13.

    public int hashCode() {
        if (this.nimi == null) {
            return 7;
        }

        return this.nimi.hashCode();
    }

Saatat tässä kohtaa miettiä "eikö tämä johda tilanteeseen jossa useampi olio päätyy samaan indeksiin hajautustaulussa?". Vastaus on kyllä ja ei. Vaikka metodi hashCode antaisi kahdelle eri oliolle saman arvon, on hajautustaulut toteutettu sisäisesti siten että useampi olio voi olla samassa indeksissä. Jotta samassa indeksissä olevat oliot voi erottaa toisistaan, tulee olioilla olla metodi equals toteutettuna. Hajautustaulujen syvemmästä sielunelämästä tulee lisätietoa muun muassa kurssilla tietorakenteet ja algoritmit.

Luokka Kirja nyt kokonaisuudessaan.

public class Kirja {

    private String nimi;
    private int julkaisuvuosi;

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

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

    public int getJulkaisuvuosi() {
        return this.julkaisuvuosi;
    }

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

    @Override
    public boolean equals(Object olio) {
        if (olio == null) {
            return false;
        }

        if (getClass() != olio.getClass()) {
            return false;
        }

        Kirja verrattava = (Kirja) olio;

        if (this.julkaisuvuosi != verrattava.getJulkaisuvuosi()) {
            return false;
        }

        if (this.nimi == null || !this.nimi.equals(verrattava.getNimi())) {
            return false;
        }

        return true;
    }

    @Override
    public int hashCode() {
        if (this.nimi == null) {
            return 7;
        }

        return this.nimi.hashCode();
    }
}

Nyt myös aiemmin kohtaamamme hyllytysongelma ratkeaa. Uudelle "Oliokirja"-kirjalle löytyy oikea hylly.

        HashMap<Kirja, Hylly> kirjojenHyllyt = new HashMap<Kirja, Hylly>();
        Kirja olioKirja = new Kirja("Oliokirja", 2000);
        Hylly hylly = new Hylly("Ohjelmointikirjat");

        kirjojenHyllyt.put(olioKirja, hylly);
        System.out.println(kirjojenHyllyt.get(olioKirja));

        Kirja toinenOlioKirja = new Kirja("Oliokirja", 2000);
        System.out.println(kirjojenHyllyt.get(toinenOlioKirja));
Ohjelmointikirjat
Ohjelmointikirjat

NetBeans tarjoaa metodien equals ja hashCode automaattisen luonnin. Voit valita valikosta Source -> Insert Code, ja valita aukeavasta listasta equals() and hashCode(). Tämän jälkeen NetBeans kysyy oliomuuttujat joita metodeissa käytetään.

Ravintolan asiakas

Kurt Koodari toteutti eräälle lounasravintolalle järjestelmän asiakkaiden ja myyntien hallintaan. Järjestelmä hallinnoi asiakkaiden tilejä sekä ravintolan vip-listaa. Vip-asiakkaat syövät ravintolassa edullisemmin kuin normaalit asiakkaat.

Kurt suunnitteli ohjelman rakenteen fiksusti siten, että ohjelmassa on eriytetty käyttöliittymälogiikka ja sovelluslogiikka. Sovelluslogiikassa Asiakas-olioita tallennetaan muun muassa ArrayList- ja HashMap-tietorakenteisiin. Koodausinnossaan Kurt kuitenkin unohti Object-luokan metodien ylikirjoittamisen, joten ohjelma ei toimi toivotusti. Tällä hetkellä esimerkiksi sisäänkirjautuminen ei onnistu.

Tervetuloa ravintolapalveluun.
Kirjoita nimesi: Arto
Et ole asiakkaamme.

Kirjoita nimesi: Bonus
Et ole asiakkaamme.

Kirjoita nimesi:

Hätä ei ole tämän näköinen! Tehtävänäsi on pelastaa Kurtin projekti: toteuta tehtäväpohjassa tulevaan luokkaan Asiakas metodit equals ja hashCode. Hyödynnä asiakkaan nimeä sekä equals-metodissa että hashCode-metodissa. Muutoksen jälkeen ohjelman pitäisi toimia seuraavasti.

Tervetuloa ravintolapalveluun.
Kirjoita nimesi: Arto
Et ole asiakkaamme.

Kirjoita nimesi: Lennon
Hei Lennon, tililläsi on 8 euroa.
Komennot:
[1] osta lounas
[2] rekisteröidy vip-käyttäjäksi
[x] lopeta
Valitse komento: 1
Hei Lennon, tililläsi on 4 euroa.
Komennot:
[1] osta lounas
[2] rekisteröidy vip-käyttäjäksi
[x] lopeta
Valitse komento: 2
Hei Lennon, tililläsi on 4 euroa.
Komennot:
[1] osta lounas
[x] lopeta
Valitse komento: 1
Hei Lennon, tililläsi on 1 euroa.
Komennot:
[1] osta lounas
[x] lopeta
Valitse komento: x
Hei hei.

Tervetuloa ravintolapalveluun.
Kirjoita nimesi: Bonus
Hei Bonus, tililläsi on 400000 euroa.
Komennot:
[1] osta lounas
[x] lopeta
Valitse komento: x
Hei hei.

Tervetuloa ravintolapalveluun.
Kirjoita nimesi:

Huom! Muuta vain Asiakas-luokan toteutusta. Neuvoja equals ja hashCode-metodien toteutukseen saat edellisestä kappaleesta.

Rajapinta

Rajapinta (engl. interface) on väline luokilta vaaditun käyttäytymisen määrittelyyn. Rajapinnat ovat luokkia kuten normaalit Javan olevat luokat, mutta luokan alussa olevan määrittelyn "public class ..." sijaan käytetään määrittelyä "public interface ...". Rajapintaluokat määrittelevät käyttäytymisen metodien niminä ja palautusarvoina, mutta ne eivät sisällä metodien toteutusta. Näkyvyysmäärettä ei merkitä erikseen, sillä se on aina public. Tutkitaan luettavuutta kuvaavaa rajapintaa Luettava.

public interface Luettava {
    String lue();
}

Rajapinta Luettava määrittelee metodin lue(), joka palauttaa String-tyyppisen olion. Rajapinnan toteuttavat luokat -- tai oikeastaan luokat toteuttavat ohjelmoijat -- päättävät miten rajapinnassa määritellyt metodit lopulta toteutetaan. Luokka toteuttaa rajapinnan lisäämällä luokan nimen jälkeen avainsanalla implements, jota seuraa rajapinnan nimi. Luodaan luokka Tekstiviesti, joka toteuttaa rajapinnan Luettava.

public class Tekstiviesti implements Luettava {
    private String lahettaja;
    private String sisalto;

    public Tekstiviesti(String lahettaja, String sisalto) {
        this.lahettaja = lahettaja;
        this.sisalto = sisalto;
    }

    public String getLahettaja() {
        return this.lahettaja;
    }

    public String lue() {
        return this.sisalto;
    }
}

Koska luokka Tekstiviesti toteuttaa rajapinnan Luettava (public class Tekstiviesti implements Luettava), on luokassa Tekstiviesti pakko olla metodin public String lue() toteutus. Rajapinnassa määriteltyjen metodien toteutuksilla tulee aina olla näkyvyysmääre public.

Rajapinta on sopimus käyttäytymisestä. Jotta käyttäytyminen toteutuu, tulee luokan toteuttaa rajapinnan määrittelemät metodit. Rajapinnan toteuttavan luokan ohjelmoijan vastuulla on määritellä millaista käyttäytyminen on. Rajapinnan toteuttaminen tarkoittaa sopimuksen tekemistä siitä, että luokka tarjoaa kaikki rajapinnan määrittelemät toiminnot eli rajapinnan määrittelemän käyttäytymisen. Luokkaa, joka toteuttaa rajapinnan, mutta ei toteuta rajapinnan metodeja, ei voi olla olemassa.

Toteutetaan luokan Tekstiviesti lisäksi toinen Luettava rajapinnan toteuttava luokka. Luokka Sahkokirja on sähköinen toteutus kirjasta, joka sisältää kirjan nimen ja sivut. Sähkökirjaa luetaan sivu kerrallaan, metodin public String lue() kutsuminen palauttaa aina seuraavan sivun merkkijonona.

public class Sahkokirja implements Luettava {
    private String nimi;
    private ArrayList<String> sivut;
    private int sivunumero;

    public Sahkokirja(String nimi, ArrayList<String> sivut) {
        this.nimi = nimi;
        this.sivut = sivut;
        this.sivunumero = 0;
    }

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

    public int sivuja() {
        return this.sivut.size();
    }

    public String lue() {
        String sivu = this.sivut.get(this.sivunumero);
        seuraavaSivu();
        return sivu;
    }

    private void seuraavaSivu() {
        this.sivunumero = this.sivunumero + 1;
        if(this.sivunumero % this.sivut.size() == 0) {
            this.sivunumero = 0;
        }
    }
}

Rajapinnan toteuttavasta luokasta voi tehdä olioita aivan kuten normaaleistakin luokista, ja niitä voidaan käyttää myös esimerkiksi ArrayList-listojen tyyppinä.

    Tekstiviesti viesti = new Tekstiviesti("ope", "Huikeaa menoa!");
    System.out.println(viesti.lue());

    ArrayList<Tekstiviesti> tekstiviestit = new ArrayList<Tekstiviesti>();
    tekstiviestit.add(new Tekstiviesti("tuntematon numero", "I hid the body.");
Huikeaa menoa!
    ArrayList<String> sivut = new ArrayList<String>();
    sivut.add("Pilko metodisi lyhyiksi luettaviksi kokonaisuuksiksi.");
    sivut.add("Erota käyttöliittymälogiikka sovelluksen logiikasta.");
    sivut.add("Ohjelmoi aina ensin pieni ohjelma joka ratkaisee vain osan ongelmasta.");
    sivut.add("Harjoittelu tekee mestarin. Keksi joku hauska oma projekti.");

    Sahkokirja kirja = new Sahkokirja("Vinkkejä ohjelmointiin.", sivut);
    for(int sivu = 0; sivu < kirja.sivuja(); sivu++) {
        System.out.println(kirja.lue());
    }
Pilko metodisi lyhyiksi luettaviksi kokonaisuuksiksi.
Erota käyttöliittymälogiikka sovelluksen logiikasta.
Ohjelmoi aina ensin pieni ohjelma joka ratkaisee vain osan ongelmasta.
Harjoittelu tekee mestarin. Keksi joku hauska oma projekti.

Palvelusvelvollinen

Tehtäväpohjassa on valmiina rajapinta Palvelusvelvollinen, jossa on seuraavat toiminnot:

public interface Palvelusvelvollinen {
    int getTJ();
    void palvele();
}

Sivari

Tee Palvelusvelvollinen-rajapinnan toteuttava luokka Sivari, jolla parametriton konstruktori. Luokalla on oliomuuttuja TJ, joka alustetaan konstruktorikutsun yhteydessä arvoon 362.

Asevelvollinen

Tee Palvelusvelvollinen-rajapinnan toteuttava luokka Asevelvollinen, jolla on parametrillinen konstruktori, jolla määritellään palvelusaika (int tj).

Rajapinta muuttujan tyyppinä

Uutta muuttujaa esitellessä esitellään aina muuttujan tyyppi. Muuttujatyyppejä on kahdenlaisia, alkeistyyppiset muuttujat (int, double, ...) ja viittaustyyppiset muuttujat (kaikki oliot). Olemme tähän mennessä käyttäneet viittaustyyppisten muuttujien tyyppinä olion luokkaa.

    String merkkijono = "merkkijono-olio";
    Tekstiviesti viesti = new Tekstiviesti("ope", "Kohta tapahtuu huikeita");

Olion tyyppi voi olla muutakin kuin sen luokka. Esimerkiksi rajapinnan Luettava toteuttavan luokan tyyppi on lisäksi Luettava. Esimerkiksi koska luokka Tekstiviesti toteuttaa rajapinnan Luettava, on sillä tyypin Tekstiviesti lisäksi myös tyyppi Luettava.

    Tekstiviesti viesti = new Tekstiviesti("ope", "Kohta tapahtuu huikeita");
    Luettava luettava = new Tekstiviesti("ope", "Tekstiviesti on Luettava!");
    ArrayList<String> sivut = new ArrayList<String>();
    sivut.add("Metodi voi kutsua itse itseään.");

    Luettava kirja = new Sahkokirja("Rekursion alkeet.", sivut);
    for(int sivu = 0; sivu < kirja.sivuja(); sivu++) {
        System.out.println(kirja.lue());
    }

Koska rajapintaa voidaan käyttää tyyppinä, on mahdollista luoda rajapintaluokan tyyppisiä olioita sisältävä lista.

    ArrayList<Luettava> lukulista = new ArrayList<Luettava>();

    lukulista.add(new Tekstiviesti("ope", "never been programming before..."));
    lukulista.add(new Tekstiviesti("ope", "gonna love it i think!"));
    lukulista.add(new Tekstiviesti("ope", "give me something more challenging! :)"));
    lukulista.add(new Tekstiviesti("ope", "you think i can do it?"));
    lukulista.add(new Tekstiviesti("ope", "up here we send several messages each day"));

    for (Luettava luettava: lukulista) {
        System.out.println(luettava.lue());
    }

Huomaa että vaikka rajapinnan Luettava toteuttava luokka Sahkokirja on aina rajapinnan tyyppinen, eivät kaikki Luettava-rajapinnan toteuttavat luokat ole tyyppiä Sahkokirja. Luokasta Sahkokirja tehdyn olion asettaminen Luettava-tyyppiseen muuttujaan onnistuu, mutta toiseen suuntaan asetus ei ole sallittua ilman erillistä tyyppimuunnosta.

    Luettava luettava = new Tekstiviesti("ope", "Tekstiviesti on Luettava!"); // toimii
    Tekstiviesti viesti = luettava; // ei toimi

    Tekstiviesti muunnettuViesti = (Tekstiviesti) luettava; // toimii

Tyyppimuunnos onnistuu jos ja vain jos muuttuja on oikeastikin sitä tyyppiä johon sitä yritetään muuntaa. Tyyppimuunnoksen käyttöä ei yleisesti suositella, ja lähes ainut sallittu paikka sen käyttöön on equals-metodin toteutuksessa.

Rajapinta metodin parametrina

Rajapintojen todelliset hyödyt tulevat esille kun niitä käytetään metodille annettavan parametrin tyyppinä. Koska rajapintaa voidaan käyttää muuttujan tyyppinä, voidaan sitä käyttää metodikutsuissa parametrin tyyppinä. Esimerkiksi seuraavan luokan Tulostin metodi tulosta saa parametrina Luettava-tyyppisen muuttujan.

public class Tulostin {
    public void tulosta(Luettava luettava) {
        System.out.println(luettava.lue());
    }
}

Luokan Tulostin tarjoaman metodin tulosta huikeus piilee siinä, että sille voi antaa parametrina minkä tahansa Luettava-rajapinnan toteuttavan luokan ilmentymän. Kutsummepa metodia millä tahansa Luettava-luokan toteuttaneen luokan oliolla, metodi osaa toimia oikein.

    Tekstiviesti viesti = new Tekstiviesti("ope", "Huhhuh, tää tulostinkin osaa tulostaa näitä!");
    ArrayList<String> sivut = new ArrayList<String>();
    sivut.add("Lukujen {1, 3, 5} ja {2, 3, 4, 5} yhteisiä lukuja ovat {3, 5}.");

    Sahkokirja kirja = new Sahkokirja("Yliopistomatematiikan perusteet.", sivut);

    Tulostin tulostin = new Tulostin();
    tulostin.tulosta(viesti);
    tulostin.tulosta(kirja);
Huhhuh, tää tulostinkin osaa tulostaa näitä!
Lukujen {1, 3, 5} ja {2, 3, 4, 5} yhteisiä lukuja ovat {3, 5}.

Toteutetaan toinen luokka Lukulista, johon voidaan lisätä mielenkiintoisia luettavia asioita. Luokalla on oliomuuttujana ArrayList-luokan ilmentymä, johon luettavia asioita tallennetaan. Lukulistaan lisääminen tapahtuu lisaa-metodilla, joka saa parametrikseen Luettava-tyyppisen olion.

public class Lukulista {
    private ArrayList<Luettava> luettavat;

    public Lukulista() {
        this.luettavat = new ArrayList<Luettava>();
    }

    public void lisaa(Luettava luettava) {
        this.luettavat.add(luettava);
    }

    public int luettavia() {
        return this.luettavat.size();
    }
}

Lukulistat ovat yleensä luettavia, joten toteutetaan luokalle Lukulista rajapinta Luettava. Lukulistan lue-metodi lukee kaikki luettavat-listalla olevat oliot läpi, ja lisää yksitellen niiden lue()-metodin palauttaman merkkijonon StringBuilder-olioon. Tämän jälkeen lukulista tyhjennetään ja palautetaan StringBuilder-olioon luettu data.

public class Lukulista implements Luettava {
    private ArrayList<Luettava> luettavat;

    public Lukulista() {
        this.luettavat = new ArrayList<Luettava>();
    }

    public void lisaa(Luettava luettava) {
        this.luettavat.add(luettava);
    }

    public int luettavia() {
        return this.luettavat.size();
    }

    public String lue() {
        StringBuilder luettu = new StringBuilder();
        for(Luettava luettava: this.luettavat) {
            luettu.append(luettava.lue()).append("\n");
        }

        this.luettavat.clear();
        return luettu.toString();
    }
}
    Lukulista joelinLista = new Lukulista();
    joelinLista.lisaa(new Tekstiviesti("matti", "teitkö jo testit?"));
    joelinLista.lisaa(new Tekstiviesti("matti", "katsoitko jo palautukset?"));

    System.out.println("Joelilla luettavia: " + joelinLista.luettavia());
Joelilla luettavia: 2

Koska Lukulista on tyyppiä Luettava, voi lukulistalle lisätä Lukulista-olioita. Alla olevassa esimerkissä Joelilla on paljon luettavaa. Onneksi Mikael tulee hätiin ja lukee viestit Joelin puolesta.

    Lukulista joelinLista = new Lukulista();
    for (int i = 0; i < 1000; i++) {
        joelinLista.lisaa(new Tekstiviesti("matti", "teitkö jo testit?"));
    }

    System.out.println("Joelilla luettavia: " + joelinLista.luettavia());
    System.out.println("Delegoidaan lukeminen Mikaelille");

    Lukulista mikaelinLista = new Lukulista();
    mikaelinLista.lisaa(joelinLista);
    mikaelinLista.lue();

    System.out.println();
    System.out.println("Joelilla luettavia: " + joelinLista.luettavia());
Joelilla luettavia: 1000
Delegoidaan lukeminen Mikaelille

Joelilla luettavia: 0

Ohjelmassa Mikaelin listalle kutsuttu lue-metodi käy kaikki sen sisältämät Luettava-oliot läpi, ja kutsuu niiden lue-metodia. Kutsuttaessa lue-metodia Mikaelin listalle käydään myös Mikaelin lukulistalla oleva Joelin lukulista läpi. Joelin lukulista käydään läpi kutsumalla sen lue-metodia. Jokaisen lue-metodin kutsun lopussa tyhjennetään juuri luettu lista. Eli Joelin lukulista tyhjenee kun Mikael lukee sen.

Tässä on jo hyvin paljon viitteitä, kannattaa piirtää oliot paperille ja hahmotella miten mikaelinLista-oliolle tapahtuva metodikutsu lue etenee!

Tavaroita ja laatikoita

Talletettavia

Muuton yhteydessa tarvitaan muuttolaatikoita. Laatikoihin talletetaan erilaisia esineitä. Kaikkien laatikoihin talletettavien esineiden on toteutettava seuraava rajapinta:

public interface Talletettava {
    double paino();
}

Lisää rajapinta ohjelmaasi. Rajapinta lisätään melkein samalla tavalla kuin luokka, new Java class sijaan valitaan new Java interface.

Tee rajapinnan toteuttavat luokat Kirja ja CDLevy. Kirja saa konstruktorin parametreina kirjan kirjoittajan (String), kirjan nimen (String), ja kirjan painon (double). CD-Levyn konstruktorin parametreina annetaan artisti (String), levyn nimi (String), ja julkaisuvuosi (int). Kaikkien CD-levyjen paino on 0.1 kg.

Muista toteuttaa luokilla myös rajapinta Talletettava. Luokkien tulee toimia seuraavasti:

    public static void main(String[] args) {
        Kirja kirja1 = new Kirja("Fedor Dostojevski", "Rikos ja Rangaistus", 2);
        Kirja kirja2 = new Kirja("Robert Martin", "Clean Code", 1);
        Kirja kirja3 = new Kirja("Kent Beck", "Test Driven Development", 0.5);

        CDLevy cd1 = new CDLevy("Pink Floyd", "Dark Side of the Moon", 1973);
        CDLevy cd2 = new CDLevy("Wigwam", "Nuclear Nightclub", 1975);
        CDLevy cd3 = new CDLevy("Rendezvous Park", "Closer to Being Here", 2012);

        System.out.println(kirja1);
        System.out.println(kirja2);
        System.out.println(kirja3);
        System.out.println(cd1);
        System.out.println(cd2);
        System.out.println(cd3);
    }

Tulostus:

Fedor Dostojevski: Rikos ja Rangaistus
Robert Martin: Clean Code
Kent Beck: Test Driven Development
Pink Floyd: Dark Side of the Moon (1973)
Wigwam: Nuclear Nightclub (1975)
Rendezvous Park: Closer to Being Here (2012)

Huom! Painoa ei ilmoiteta tulostuksessa.

Laatikko

Tee luokka laatikko, jonka sisälle voidaan tallettaa Talletettava-rajapinnan toteuttavia tavaroita. Laatikko saa konstruktorissaan parametrina laatikon maksimikapasiteetin kiloina. Laatikkoon ei saa lisätä enempää tavaraa kuin sen maksimikapasiteetti määrää. Laatikon sisältämien tavaroiden paino ei siis koskaan saa olla yli laatikon maksimikapasiteetin.

Seuraavassa esimerkki laatikon käytöstä:

    public static void main(String[] args) {
        Laatikko laatikko = new Laatikko(10);

        laatikko.lisaa( new Kirja("Fedor Dostojevski", "Rikos ja Rangaistus", 2) ) ;
        laatikko.lisaa( new Kirja("Robert Martin", "Clean Code", 1) );
        laatikko.lisaa( new Kirja("Kent Beck", "Test Driven Development", 0.5) );

        laatikko.lisaa( new CDLevy("Pink Floyd", "Dark Side of the Moon", 1973) );
        laatikko.lisaa( new CDLevy("Wigwam", "Nuclear Nightclub", 1975) );
        laatikko.lisaa( new CDLevy("Rendezvous Park", "Closer to Being Here", 2012) );

        System.out.println( laatikko );
    }

Tulostuu

Laatikko: 6 esinettä, paino yhteensä 3.8 kiloa

Laatikon paino

Jos teit laatikon sisälle oliomuuttujan double paino, joka muistaa laatikossa olevien esineiden painon, korvaa se metodilla, joka laskee painon:

public class Laatikko {
    //...

    public double paino() {
        double paino = 0;
        // laske laatikkoon talletettujen tavaroiden yhteispaino
        return paino;
    }
}

Kun tarvitset laatikon sisällä painoa esim. uuden tavaran lisäyksen yhteydessä, riittää siis kutsua laatikon painon laskevaa metodia.

Metodi toki voisi palauttaa myös oliomuuttujan arvon. Harjoittelemme tässä kuitenkin tilannetta, jossa oliomuuttujaa ei tarvitse eksplisiittisesti ylläpitää vaan se voidaan tarpeentullen laskea. Seuraavan tehtävän jälkeen laatikossa olevaan oliomuuttujaan talletettu painotieto ei kuitenkaan välttämättä enää toimisi. Miksi?

Laatikkokin on talletettava!

Rajapinnan Talletettava toteuttaminen siis edellyttää että luokalla on metodi double paino(). Laatikollehan lisättiin juuri tämä metodi. Laatikosta voidaan siis tehdä talletettava!

Laatikot ovat oliota joihin voidaan laittaa Talletettava-rajapinnan toteuttavia olioita. Laatikot toteuttavat itsekin rajapinnan. Eli laatikon sisällä voi olla myös laatikoita!

Kokeile että näin varmasti on, eli tee ohjelmassasi muutama laatikko, laita laatikoihin tavaroita ja laita pienempiä laatikoita isompien laatikoiden sisään. Kokeile myös mitä tapahtuu kun laitat laatikon itsensä sisälle. Miksi näin käy?

Rajapinta metodin paluuarvona

Kuten mitä tahansa muuttujan tyyppiä, myös rajapintaa voi käyttää metodin paluuarvona. Toteutetaan luokat Uutinen, joka kuvaa yksittäistä luettavaa uutista, ja Uutispalvelu, jonka tehtävänä on luoda luettavia uutisia. Uutispalvelua käyttäville sovelluksille ei ole tärkeää tai edes mielekästä tietää uutisten todellisesta toteutuksesta, oleellista on vain niiden lukeminen. Uutispalvelu voi siis hyvin tarjota uutisensa Luettava-rajapinnan kautta.

public class Uutinen implements Luettava {
    private String teksti = teksti;

    public Uutinen(String teksti) {
        this.teksti = teksti;
    }

    public String lue() {
        return this.teksti;
    }
}
public class Uutispalvelu {

    public Luettava haeViimeisinUutinen() {
        return new Uutinen("uusinta hottia!");
    }
}

Uutispalvelu tuottaa uutisensa aina Luettava-rajapinnan tyyppisenä. Tässä kohtaa nousee usein esille erinomainen kysymys "Miksi emme käyttäisi vain luokkaa Uutinen?". Vastaus on pitkähkö, mutta toivottavasti selvittää taustaidean.

Pohditaan tilannetta, jossa meillä on uutisia julkaiseva Julkaisupalvelu. Julkaisupalvelun tehtävänä on lukea uutisia tasaisin väliajoin uutispalvelulta ja tulostaa viestit näkyville (julkaisupalvelu voisi lähettää hyvin viestin esimerkiksi eri medioille, mutta pidättäydytään pienemmässä esimerkissä). Oletetaan että Uutispalvelu palauttaa Uutinen-olioita.

public class Uutispalvelu {

    public Uutinen haeViimeisinUutinen() {
        return new Uutinen("uusinta hottia!");
    }
}

Julkaisupalvelun oleellinen toiminnallisuus on toistolauseke, joka kutsuu tasaisin väliajoin uutispalvelun haeViimeisinUutinen-metodia.

public class Julkaisupalvelu {
    private Uutispalvelu uutispalvelu;

    public Julkaisupalvelu() {
        this.uutispalvelu = new Uutispalvelu();
    }

    public void kaynnista() {
        while (true) {
            Uutinen uutinen = uutispalvelu.haeViimeisinUutinen();
            System.out.println(uutinen.lue());

            try {
                Thread.sleep(10000);
            } catch (Exception e) {
            }
        }
    }
}

Tässä vaiheessa kaikki toimii hyvin. Oletetaan että Uutispalvelun toimitusjohtaja huomaa, että he tarvitsevat uuden formaatin kuvallisille uutisille. Kuvallisia uutisia varten toteutetaan erillinen luokka KuvaUutinen. KuvaUutinen on luettava, joten Uutispalvelun ohjelmoijat toteuttavat myös sille rajapinnan Luettava-rajapinta.

public class KuvaUutinen implements Luettava {
    private String kuvaOsoite;
    private String teksti;

    public KuvaUutinen(String teksti, String kuvaOsoite) {
        this.teksti = teksti;
        this.kuvaOsoite = kuvaOsoite;
    }

    public String lue() {
        return this.teksti + " (kuvan osoite: " + this.kuvaOsoite + ")";
    }
}

Samalla he joutuvat myös muuttamaan UutisPalvelu-luokan toteutusta, sillä se ei tue uutta uutisformaattia:

public class Uutispalvelu {

    public KuvaUutinen haeViimeisinUutinen() {
        return new KuvaUutinen("uusinta hottia!", "kuvan osoite");
    }
}

Nyt julkaisupalvelun toteutusta on pakko muuttaa, sillä se ei enää toimi koska Uutispalvelu palauttaa KuvaUutinen-luokan ilmentymän. Kuinka montaa luokkaa pitäisi muuttaa jos uutispalvelua olisi käyttänyt kymmenen palvelua, entä jos tuhat? Tässä vaiheessa jokaisen uutispalvelua käyttävän sovelluksen tulee muuttaa omaa toimintaansa.

Entä jos kaikissa uutisissa ei ole kuvia, ja haluaisimme silloin tällöin kuitenkin palauttaa Uutinen-luokan ilmentymän? Yllä oleva metodi haeViimeisinUutinen ei taida riittää..

Pohditaan seuraavaksi yllä tehtyä uutisformaatin muutosta tilanteessa, jossa Uutispalvelun haeViimeisinUutinen-metodin palautustyyppi on Luettava.

public class Uutispalvelu {

    public Luettava haeViimeisinUutinen() {
        return new Uutinen("uusinta hottia!");
    }
}

Kun Uutispalvelun toimitusjohtaja haluaa uuden kuvaformaatin, ei Uutispalvelun haeViimeisinUutinen-metodin palautustyypille tarvitse tehdä mitään.

public class Uutispalvelu {

    public Luettava haeViimeisinUutinen() {
        return new KuvaUutinen("uusinta hottia!", "kuvan osoite");
    }
}

Julkaisupalveluunkaan ei tarvitse tehdä muutoksia.

public class Julkaisupalvelu {
    private Uutispalvelu uutispalvelu;

    public Julkaisupalvelu() {
        this.uutispalvelu = new Uutispalvelu();
    }

    public void kaynnista() {
        while (true) {
            Luettava luettava = uutispalvelu.haeViimeisinUutinen();
            System.out.println(luettava.lue());

            try {
                Thread.sleep(10000);
            } catch (Exception e) {
            }
        }
    }
}

Kuinka montaa luokkaa olisi pitänyt muuttaa jos uutispalvelua olisi käyttänyt kymmenen palvelua, entä jos tuhat? Nollaa. Entä jos uutispalvelu haluaa välillä lähettää normaaleja uutisia, välillä kuvallisia uutisia? Helppo homma, muutos tarvitaan vain uutispalvelun puolelle.

public class Uutispalvelu {

    public Luettava haeViimeisinUutinen() {
        Random random = new Random();

        if(random.nextDouble() > 0.5) {
            return new Uutinen("uusinta hottia!");
        }

        return new KuvaUutinen("uusinta hottia!", "kuvan osoite");
    }
}

Emme joudu tässäkään tapauksessa muuttamaan Uutispalvelua käyttäviä sovelluksia.

Rajapintojen käyttö ohjelmoinnissa mahdollistaa riippuvaisuuksien vähentämisen. Jos kaikki uutispalvelua käyttävät palvelut käyttävät rajapintaa Luettava, eivät ne ole suoraan riippuvaisia jostain tietystä Luettava-rajapinnan toteuttavasta luokasta. Yllä olevassa esimerkissä uutispalvelun sisäistä toteutusta pystyi muuttamaan siten, että siinä tehdyt muutokset eivät vaikuttaneet uutispalvelua käyttäneisiin olioihin millään tavalla.

Valmiit rajapinnat

Javan API tarjoaa huomattavan määrän valmiita rajapintoja. Tutustutaan tässä neljään ehkä Javan eniten käytettyyn rajapintaan: List, Map, Set ja Collection.

List

Rajapinta List määrittelee listoihin liittyvän peruskäyttäytymisen. Koska ArrayList-luokka toteuttaa List-rajapinnan, voi sitä käyttää myös List-rajapinnan kautta.

List<String> merkkijonot = new ArrayList<String>();
merkkijonot.add("merkkijono-olio arraylist-oliossa!");

Kuten huomaamme List-rajapinnan Java API:sta, rajapinnan List toteuttavia luokkia on useita. Eräs tietojenkäsittelijöille tuttu listarakenne on linkitetty lista (linked list). Linkitettyä listaa voi käyttää rajapinnan List-kautta täysin samoin kuin ArrayLististä luotua oliota.

List<String> merkkijonot = new LinkedList<String>();
merkkijonot.add("merkkijono-olio linkedlist-oliossa!");

Molemmat rajapinnan Listtoteutukset toimivat käyttäjän näkökulmasta samoin. Rajapinta siis abstrahoi niiden sisäisen toiminnallisuuden. ArrayListin ja LinkedListin sisäinen rakenne on kuitenkin huomattavan erilainen. ArrayList tallentaa alkioita taulukkoon, josta tietyllä indeksillä hakeminen on nopeaa. LinkedList taas rakentaa listan, jossa jokaisessa listan alkiossa on viite seuraavan listan alkioon. Kun linkitetyssä listassa haetaan alkiota tietyllä indeksillä, tulee listaa käydä läpi alusta indeksiin asti.

Isoilla listoille voimme nähdä huomattaviakin suorituskykyeroja. Linkitetyn listan vahvuutena on se, että listaan lisääminen on aina nopeaa. ArrayListillä taas taustalla on taulukko, jota täytyy kasvattaa aina kun se täyttyy. Taulukon kasvattaminen vaatii uuden taulukon luonnin ja vanhan taulukon tietojen kopioinnin uuteen taulukkoon. Toisaalta, indeksin perusteella hakeminen on Arraylististä erittäin nopeaa, kun taas linkitetyssä listassa joudutaan käymään listan alkioita yksitellen läpi tiettyyn indeksiin pääsemiseksi. Tietorakenteiden kuten linkitetyn listan ja ArrayListin sielunelämästä tulee lisää tietoa kurssilla Tietorakenteet ja algoritmit.

Ohjelmointikurssilla eteen tulevissa tilanteissa kannattaa käytännössä aina valita ArrayList. Rajapintoihin ohjelmointi kuitenkin kannattaa: toteuta ohjelmasi siten, että käytät tietorakenteita rajapintojen kautta.

Map

Rajapinta Map määrittelee hajautustauluihin liittyvän peruskäyttäytymisen. Koska HashMap-luokka toteuttaa Map-rajapinnan, voi sitä käyttää myös Map-rajapinnan kautta.

Map<String, String> kaannokset = new HashMap<String, String>();
kaannokset.put("gambatte", "tsemppiä");
kaannokset.put("hai", "kyllä");

Hajautustaulun avaimet saa hajautustaulusta keySet-metodin avulla.

Map<String, String> kaannokset = new HashMap<String, String>();
kaannokset.put("gambatte", "tsemppiä");
kaannokset.put("hai", "kyllä");

for(String key: kaannokset.keySet()) {
    System.out.println(key + ": " + kaannokset.get(key));
}
gambatte: tsemppiä
hai: kyllä

Metodi keySet palauttaa Set-rajapinnan toteuttavan joukon alkioita. Set-rajapinnan toteuttavan joukon voi käydä läpi for-each -toistorakenteella. Hajautustaulusta saa talletetut arvot metodin values-avulla. Metodi values palauttaa Collection rajapinnan toteuttavan joukon alkioita. Tutustutaan vielä pikaisesti Set- ja Collection-rajapintoihin.

Set

Rajapinta Set kuvaa joukkoihin liittyvää toiminnallisuutta. Javassa joukot sisältävät aina joko 0 tai 1 kappaletta tiettyä oliota. Set-rajapinnan toteuttaa muun muassa HashSet. Joukon alkioita pystyy käymään läpi for-each -rakenteen avulla seuraavasti.

Set<String> joukko = new HashSet<String>();
joukko.add("yksi");
joukko.add("yksi");
joukko.add("kaksi");

for (String alkio: joukko) {
    System.out.println(alkio);
}
yksi
kaksi

Huomaa että HashSet ei ota millään tavalla kantaa joukon alkioiden järjestykseen.

Collection

Rajapinta Collection kuvaa kokoelmiin liittyvää toiminnallisuutta. Javassa muun muassa listat ja joukot ovat kokoelmia -- rajapinnat List ja Set toteuttavat rajapinnan Collection. Kokoelmarajapinta tarjoaa metodit muun muassa alkioiden olemassaolon tarkistamiseen (metodi contains) ja kokoelman koon tarkistamiseen (metodi size). Kaikkia kokoelmarajapinnan toteuttavia luokkia voi käydä läpi for-each -toistolausekkeella.

Luodaan vielä hajautustaulu ja käydään erikseen läpi siihen liittyvät avaimet ja arvot.

Map<String, String> kaannokset = new HashMap<String, String>();
kaannokset.put("gambatte", "tsemppiä");
kaannokset.put("hai", "kyllä");

Set<String> avaimet = kaannokset.keySet();
Collection<String> avainKokoelma = avaimet;

System.out.println("Avaimet:");
for(String avain: avainKokoelma) {
    System.out.println(avain);
}

System.out.println();
System.out.println("Arvot:");
Collection<String> arvot = kaannokset.values();
for(String arvo: arvot) {
    System.out.println(arvo);
}
Avaimet:
gambatte
hai

Arvot:
kyllä
tsemppiä

Myös seuraavanlainen hajautustaulun käyttö olisi luonut saman tulostuksen.

Map<String, String> kaannokset = new HashMap<String, String>();
kaannokset.put("gambatte", "tsemppiä");
kaannokset.put("hai", "kyllä");

System.out.println("Avaimet:");
for(String avain: kaannokset.keySet()) {
    System.out.println(avain);
}

System.out.println();
System.out.println("Arvot:");
for(String arvo: kaannokset.values()) {
    System.out.println(arvo);
}

Seuraavassa tehtävässä rakennetaan verkkokauppa ja harjoitellaan luokkien käyttämistä niiden tarjoamien rajapintojen kautta.

Verkkokauppa

Teemme tehtävässä muutamia verkkokaupan hallinnointiin soveltuvia ohjelmakomponentteja.

Varasto

Tee luokka Varasto jolla on seuraavat metodit:

Varaston sisällä tuotteiden hinnat (ja seuraavassa kohdassa saldot) tulee tallettaa Map<String, Integer>-tyyppiseksi määriteltyyn muuttujaan! Luotava olio voi olla tyypiltään HashMap, muuttujan tyyppinä on kuitenkin käytettävä Map-rajapintaa (ks. 46.4.2.)

Seuraavassa esimerkki varaston käytöstä:

        Varasto varasto = new Varasto();
        varasto.lisaaTuote("maito", 3, 10);
        varasto.lisaaTuote("kahvi", 5, 7);

        System.out.println("hinnat:");
        System.out.println("maito:  " + varasto.hinta("maito"));
        System.out.println("kahvi:  " + varasto.hinta("kahvi"));
        System.out.println("sokeri: " + varasto.hinta("sokeri"));

Tulostuu:

hinnat:
maito:  3
kahvi:  5
sokeri: -99

Tuotteen varastosaldo

Talleta tuotteiden varastosaldot samaan tapaan Map<String, Integer>-tyyppiseen muuttujaan kuin talletit hinnat. Täydennä varastoa seuraavilla metodeilla:

Esimerkki varaston käytöstä:

        Varasto varasto = new Varasto();
        varasto.lisaaTuote("kahvi", 5, 1);

        System.out.println("saldot:");
        System.out.println("kahvi:  " + varasto.saldo("kahvi"));
        System.out.println("sokeri: " + varasto.saldo("sokeri"));

        System.out.println("otetaan kahvi " + varasto.ota("kahvi"));
        System.out.println("otetaan kahvi " + varasto.ota("kahvi"));
        System.out.println("otetaan sokeri " + varasto.ota("sokeri"));

        System.out.println("saldot:");
        System.out.println("kahvi:  " + varasto.saldo("kahvi"));
        System.out.println("sokeri: " + varasto.saldo("sokeri"));

Tulostuu:

saldot:
kahvi:  1
sokeri: 0
otetaan kahvi true
otetaan kahvi false
otetaan sokeri false
saldot:
kahvi:  0
sokeri: 0

Tuotteiden listaus

Listätään varastolle vielä yksi metodi:

Metodi on helppo toteuttaa. Saat tietoon varastossa olevat tuotteet kysymällä ne joko hinnat tai saldot muistavalta Map:iltä metodin keySet avulla.

Esimerkki varaston käytöstä:

        Varasto varasto = new Varasto();
        varasto.lisaaTuote("maito", 3, 10);
        varasto.lisaaTuote("kahvi", 5, 6);
        varasto.lisaaTuote("piimä", 2, 20);
        varasto.lisaaTuote("jugurtti", 2, 20);

        System.out.println("tuotteet:");
        for (String tuote : varasto.tuotteet()) {
            System.out.println(tuote);
        }

Tulostuu:

tuotteet:
piimä
jugurtti
kahvi
maito

Ostos

Ostoskoriin lisätään ostoksia. Ostoksella tarkoitetaan tiettyä määrää tiettyjä tuotteita. Koriin voidaan laittaa esim. ostos joka vastaa yhtä leipää tai ostos joka vastaa 24:ää kahvia.

Tee luokka Ostos jolla on seuraavat toiminnot:

Esimerkki ostoksen käytöstä

        Ostos ostos = new Ostos("maito", 4, 2);
        System.out.println( "ostoksen joka sisältää 4 maitoa yhteishinta on " + ostos.hinta() );
        System.out.println( ostos );
        ostos.kasvataMaaraa();
        System.out.println( ostos );

Tulostuu:

ostoksen joka sisältää 4 maitoa yhteishinta on 8
maito: 4
maito: 5

Huom: toString on siis muotoa tuote: kpl hintaa ei merkkijonoesitykseen tule!

Ostoskori

Vihdoin pääsemme toteuttamaan luokan ostoskori!

Ostoskori tallettaa sisäisesti koriin lisätyt tuotteet Ostos-olioina. Ostoskorilla tulee olla oliomuuttja jonka tyyppi on joko Map<String, Ostos> tai List<Ostos>. Älä laita mitään muita oliomuuttujia ostoskorille kuin ostosten talletukseen tarvittava Map tai List.

Huom: jos talletat Ostos-oliot Map-tyyppiseen apumuuttujaan, on tässä ja seuraavassa tehtävässä hyötyä Map:in metodista values(), jonka avulla on helppo käydä läpi kaikki talletetut ostos-oliot.

Tehdään aluksi ostoskorille parametriton konstruktori ja metodit:

Esimerkki ostoksen käytöstä

        Ostoskori kori = new Ostoskori();
        kori.lisaa("maito", 3);
        kori.lisaa("piimä", 2);
        kori.lisaa("juusto", 5);
        System.out.println("korin hinta: " + kori.hinta());
        kori.lisaa("tietokone", 899);
        System.out.println("korin hinta: " + kori.hinta());

Tulostuu:

korin hinta: 10
korin hinta: 909

Ostoskorin tulostus

Tehdään ostoskorille metodi public void tulosta() joka tulostaa korin sisältämät Ostos-oliot. Tulostusjärjestyksessä ei ole merkitystä. Edellisen esimerkin ostoskori tulostetuna olisi:

piimä: 1
juusto: 1
tietokone: 1
maito: 1

Huomaa, että tulostuva numero on siis tuotteen korissa oleva kappalemäärä, ei hinta!

yhtä tuotetta kohti vain yksi Ostos-olio

Täydennetään Ostoskoria siten, että jos korissa on jo tuote joka sinne lisätään, ei koriin luoda uutta Ostos-olioa vaan päivitetään jo korissa olevaa tuotetta vastaavaa ostosolioa kutsumalla sen metodia kasvataMaaraa().

Esimerkki:

        Ostoskori kori = new Ostoskori();
        kori.lisaa("maito", 3);
        kori.tulosta();
        System.out.println("korin hinta: " + kori.hinta() +"\n");

        kori.lisaa("piimä", 2);
        kori.tulosta();
        System.out.println("korin hinta: " + kori.hinta() +"\n");

        kori.lisaa("maito", 3);
        kori.tulosta();
        System.out.println("korin hinta: " + kori.hinta() +"\n");

        kori.lisaa("maito", 3);
        kori.tulosta();
        System.out.println("korin hinta: " + kori.hinta() +"\n");

Tulostuu:

maito: 1
korin hinta: 3

piimä: 1
maito: 1
korin hinta: 5

piimä: 1
maito: 2
korin hinta: 8

piimä: 1
maito: 3
korin hinta: 11

Eli ensin koriin lisätään maito ja piimä ja niille omat ostos-oliot. Kun koriin lisätään lisää maitoa, ei luoda uusille maidoille omaa ostosolioa, vaan päivitetään jo korissa olevan maitoa kuvaavan ostosolion kappalemäärää.

Kauppa

Nyt meillä on valmiina kaikki osat "verkkokauppaa" varten. Verkkokaupassa on varasto joka sisältää kaikki tuotteet. Jokaista asiakkaan asiointia varten on oma ostoskori. Aina kun asiakas valitsee ostoksen, lisätään se asiakkaan ostoskoriin jos tuotetta on varastossa. Samalla varastosaldoa pienennetään yhdellä.

Seuraavassa on valmiina verkkokaupan koodin runko. Tee projektiin luokka Kauppa ja kopioi allaoleva koodi luokkaan.

import java.util.Scanner;

public class Kauppa {

    private Varasto varasto;
    private Scanner lukija;

    public Kauppa(Varasto varasto, Scanner lukija) {
        this.varasto = varasto;
        this.lukija = lukija;
    }

    // metodi jolla hoidetaan yhden asiakkaan asiointi kaupassa
    public void asioi(String asiakas) {
        Ostoskori kori = new Ostoskori();
        System.out.println("Tervetuloa kauppaan " + asiakas);
        System.out.println("valikoimamme:");

        for (String tuote : varasto.tuotteet()) {
            System.out.println( tuote );
        }

        while (true) {
            System.out.print("mitä laitetaan ostoskoriin (pelkkä enter vie kassalle):");
            String tuote = lukija.nextLine();
            if (tuote.isEmpty()) {
                break;
            }

            // tee tänne koodi joka lisää tuotteen ostoskoriin jos sitä on varastossa
            // ja vähentää varastosaldoa
            // älä koske muuhun koodiin!

        }

        System.out.println("ostoskorissasi on:");
        kori.tulosta();
        System.out.println("korin hinta: " + kori.hinta());
    }
}

Seuraavassa pääohjelma joka täyttää kaupan varaston ja laittaa Pekan asioimaan kaupassa:

    Varasto varasto = new Varasto();
    varasto.lisaaTuote("kahvi", 5, 10);
    varasto.lisaaTuote("maito", 3, 20);
    varasto.lisaaTuote("piimä", 2, 55);
    varasto.lisaaTuote("leipä", 7, 8);

    Kauppa kauppa = new Kauppa(varasto, new Scanner(System.in));
    kauppa.asioi("Pekka");

Kauppa on melkein valmiina. Yhden asiakkaan asioinnin hoitavan metodin public void asiointi(String asiakas) on kommenteilla merkitty kohta jonka joudut täydentämään. Lisää kohtaan koodi joka tarkastaa onko asiakkaan haluamaa tuotetta varastossa. Jos on, vähennä tuotteen varastosaldoa ja lisää tuote ostoskoriin.

Vapise, verkkokauppa.com!

Geneerisyys

Geneerisyys (generics) liittyy olioita säilövien luokkien tapaan säilöä vapaavalintaisen tyyppisiä olioita. Vapaavalintaisuus perustuu luokkien määrittelyssä käytettyyn geneeriseen tyyppiparametriin, jonka avulla voidaan määritellä olion luontivaiheessa valittavia tyyppejä. Luokan geneerisyys määritellään antamalla luokan nimen jälkeen haluttu määrä luokan tyyppiparametreja pienempi kuin ja suurempi kuin -merkkien väliin. Toteutetaan oma geneerinen luokka Lokero, johon voi asettaa yhden minkälaisen tahansa olion.

public class Lokero<T> {
    private T alkio;

    public void asetaArvo(T alkio) {
        this.alkio = alkio;
    }

    public T haeArvo() {
        return alkio;
    }
}

Määrittely public class Lokero<T> kertoo että luokalle Lokero tulee antaa konstruktorissa tyyppiparametri. Konstruktorikutsun jälkeen kaikki olion sisäiset muuttujat tulevat olemaan kutsun yhteydessä annettua tyyppiä. Luodaan merkkijonon tallentava lokero.

    Lokero<String> merkkijono = new Lokero<String>();
    merkkijono.asetaArvo(":)");

    System.out.println(merkkijono.haeArvo());
:)

Tyyppiparametria vaihtamalla voidaan luoda myös muuntyyppisiä olioita tallentavia Lokero-olioita. Esimerkiksi kokonaisluvun saa tallennettua seuraavasti

    Lokero<Integer> luku = new Lokero<Integer>();
    luku.asetaArvo(5);

    System.out.println(luku.haeArvo());
5

Huomattava osa Javan tietorakenteista on ohjelmoitu geneerisiksi. Esimerkiksi ArrayList saa yhden tyyppiparametrin, HashMap kaksi.

    List<String> merkkijonot = new ArrayList<String>();
    Map<String, String> avainArvoParit = new ArrayList<String, String>();

Jatkossa kun näet esimerkiksi tyypin ArrayList<String> tiedät että sen sisäisessä rakenteessa on käytetty geneeristä tyyppiparametria.

Geneerisyyttä hyödyntävä rajapinta: Comparable

Normaalien rajapintojen lisäksi Javassa on geneerisyyttä hyödyntäviä rajapintoja. Geneerisille rajapinnoille määritellään sisäisten arvojen tyypit samalla tavalla kuin geneerisille luokille. Tutkitaan Javan valmista rajapintaa Comparable. Rajapinta Comparable määrittelee metodin compareTo, jonka tulee palauttaa this-olion paikan vertailujärjestyksessä verrattuna parametrina annettuun olioon (negatiivinen luku, 0 tai positiivinen luku). Jos this-olio on vertailujärjestyksessä ennen parametrina saatavaa olioa, tulee metodin palauttaa negatiivinen luku, jos taas parametrina saatava olio on järjestyksessä ennen, tulee metodin palauttaa positiivinen luku. Jos oliot ovat vertailujärjestykseltään samat, palautetaan 0. Vertailujärjestyksellä tarkoitetaan tässä ohjelmoijan määrittelemää olioiden "suuruusjärjestystä", eli jos oliot järjestetään sort-metodilla, mikä on niiden järjestys.

Yksi Comparable-rajapinnan eduista on se, että se mahdollistaa Comparable-tyyppisistä alkioista koostuvan listan järjestämisen esimerkiksi standardikirjaston Collections.sort-metodin avulla. Collections.sort käyttää listan alkioiden compareTo-metodia selvittääkseen, missä järjestyksessä alkoiden kuuluisi olla. Tätä compareTo-metodin avulla johdettua järjestystä kutsutaan luonnolliseksi järjestykseksi (natural ordering).

Luodaan luokka Kerholainen, joka kuvaa kerhossa käyvää lasta tai nuorta. Jokaisella kerholaisella on nimi ja pituus. Kerholaisten tulee mennä aina syömään pituusjärjestyksessä, joten toteutetaan kerholaisille rajapinta Comparable. Comparable-rajapinta ottaa tyyppiparametrinaan myös luokan, johon sitä verrataan. Käytetään tyyppiparametrina luokkaa Kerholainen-luokkaa.

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 helppoa.

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

    System.out.println(kerholaiset);
    Collections.sort(kerholaiset);
    System.out.println(kerholaiset);
[mikael (182), matti (187), joel (184)]
[mikael (182), joel (184), matti (187)]

Jos kerholaiset haluaa järjestää käänteiseen järjestykseen, riittää vain compareTo-metodissa olevien muuttujien paikan vaihtaminen.

Köyhät kyykkyyn

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 - suuripalkkaiset ensin, köyhät kyykkyyn listan loppuun.

Opiskelijat nimijärjestykseen

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.

Kortit järjestykseen

Ohjelmoinnin perusteet-kurssilla toteutettiin Korttipakka-, Kasi- ja Kortti-luokat. Nyt kun osaamme tehdä Comparable-luokkia, voimme parannella tuolloin tekemiämme ratkaisuja huomattavasti.

Kortti-luokasta Comparable

Saat valmiiksi toteutettuina Korttipakka-, Kasi- ja Kortti-luokat. Tee Kortti-luokasta Comparable. Toteuta compareTo-metodi niin, että korttien järjestys on arvon mukaan nouseva. Jos verrattavien Korttien arvot ovat samat, verrataan niitä maan perusteella nousevassa järjestyksessä: risti ensin, ruutu toiseksi, hertta kolmanneksi, pata viimeiseksi.

Korttipakan järjestäminen

Kun Kortti-luokasta on tehty Comparable, toteuta Korttipakka-luokkaan jarjesta-metodi, joka nimensä mukaisesti järjestää Korttipakan.

Ojenna kätesi

Käsien käsittely voi olla helpompaa, jos Kasi-luokan sisältämä korttilista on järjestyksessä. Tällöin voisimme halutessamme etsiä Kortteja kädestä vaikka puolitushaulla. Tee siis Kasi-luokan konstruktorista sellainen, että jokainen luotu Kasi on järjestyksessä.

Collections

Luokkakirjasto Collections on Javan yleishyödyllinen kokoelmaluokkiin liittyvä kirjasto. Kuten tiedämme, Collections tarjoaa metodit olioiden järjestämiseen joko Comparable- tai Comparator -rajapinnan kautta. Järjestämisen lisäksi luokkakirjaston avulla voi etsiä esimerkiksi minimi- (min-metodi) tai maksimialkioita (max-metodi), hakea tiettyä arvoa (binarySearch-metodi), tai kääntää listan (reverse-metodi).

Hakeminen

Collections-luokkakirjasto tarjoaa valmiiksi toteutetun binäärihaun. Metodi binarySearch() palauttaa haetun alkion indeksin listasta jos se löytyy. Jos alkiota ei löydy, hakualgoritmi palauttaa negatiivisen arvon. Metodi binarySearch() käyttää Comparable-rajapintaa haetun olion löytämiseen. Jos olion compareTo()-metodi palauttaa arvon 0, eli olio on sama, ajatellaan arvon löytyneen.

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

    List<Kerholainen> kerholaiset = new ArrayList<Kerholainen>();
    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());
    }

Esimerkkimme tulostaa seuraavaa

187 senttiä pitkä löytyi indeksistä 2
nimi: matti

Huomaa että esimerkissä kutsuttiin myös metodia Collections.sort(). Tämä tehdään sen takia, että binäärihakua ei voida tehdä jos taulukko tai lista ei ole valmiiksi järjestyksessä.

Tilastot kuntoon

NHL:ssä pidetään pelaajista yllä monenlaisia tilastotietoja. Teemme nyt oman ohjelman NHL-pelaajien tilastojen hallintaan.

Pelaajalistan tulostus

Tee luokka Pelaaja, johon voidaan tallettaa pelaajan nimi, joukkue, pelatut ottelut, maalimäärä, ja syöttömäärä. Luokalla tulee olla konstruktori, joka saa edellämainitut tiedot edellä annetussa järjestyksessä.

Tee kaikille edelläminituille arvoille myös ns. getterimetodit, jotka palauttavat arvot:

Talleta seuraavat pelaajat ArrayList:iin ja tulosta listan sisältö:

    public static void main(String[] args) {
        ArrayList<Pelaaja> pelaajat = new ArrayList<Pelaaja>();
        pelaajat.add(new Pelaaja("Alex Ovechkin", "WSH", 71, 28, 46));
        pelaajat.add(new Pelaaja("Dustin Byfuglien", "ATL", 69, 19, 31));
        pelaajat.add(new Pelaaja("Phil Kessel", "TOR", 70, 28, 24));
        pelaajat.add(new Pelaaja("Brendan Mikkelson", "ANA, CGY", 23, 0, 2));
        pelaajat.add(new Pelaaja("Matti Luukkainen", "SaPKo", 1, 0, 0 ));

        for (Pelaaja pelaaja : pelaajat) {
            System.out.println(pelaaja);
        }
    }

Pelaajan toString()-metodin muodostaman tulostuksen tulee olla seuraavassa muodossa:

Alex Ovechkin WSH 71 28 + 46 = 74
Dustin Byfuglien ATL 69 19 + 31 = 50
Phil Kessel TOR 70 28 + 24 = 52
Brendan Mikkelson ANA, CGY 23 0 + 2 = 2
Matti Luukkainen SaPKo 1 0 + 0 = 0

Ensin siis nimi, sitten joukkue, jonka jälkeen ottelut, maalit, plusmerkki, syötöt, yhtäsuuruusmerkki ja kokonaispisteet eli maalien ja syöttöjen summa.

Tulostuksen siistiminen

Tee Pelaaja-luokkaan metodi toSiistiMerkkijono(), joka palauttaa samat tiedot siististi aseteltuna siten, että jokaiselle muuttujalle on varattu tietty määrä tilaa tulostuksessa.

Tulostuksen tulee näyttää seuraavalta:

Alex Ovechkin             WSH             71  28 + 46 = 74
Dustin Byfuglien          ATL             69  19 + 31 = 50
Phil Kessel               TOR             70  28 + 24 = 52
Brendan Mikkelson         ANA, CGY        23   0 +  2 =  2
Matti Luukkainen          SaPKo            1   0 +  0 =  0

Nimen jälkeen joukkueen nimien täytyy alkaa samasta kohdasta. Saat tämän aikaan esim. muotoilemalla nimen tulostuksen yhteydessä seuraavasti:

String nimiJaTyhjaa = String.format("%-25s", nimi);

Komento tekee merkkijonon nimiJaTyhjaa joka alkaa merkkijonon nimi sisällöllä ja se jälkeen tulee välilyöntejä niin paljon että merkkijonon pituudeksi tulee 25. Joukkueen nimi tulee vastaavalla tavalla tulostaa 14 merkin pituisena merkkijonona. Tämän jälkeen on otteluiden määrä (2 merkkiä), jota seuraa 2 välilyöntiä. Tämän jälkeen on maalien määrä (2 merkkiä), jota seuraa merkkijono " + ". Tätä seuraa syöttöjen määrä (2 merkkiä), merkkijono " = ", ja lopuksi yhteispisteet (2 merkkiä).

Lukuarvot eli ottelu-, maali-, syöttö- ja pistemäärä muotoillaan kahden merkin mittaisena, eli lukeman 0 sijaan tulee tulostua välilyönti ja nolla. Seuraava komento auttaa tässä:

String maalitMerkkeina = String.format("%2d", maalit);

Pistepörssin tulostus

Lisää luokalle Pelaaja rajapinta Comparable<Pelaaja>, jonka avulla pelaajat voidaan järjestää kokonaispistemäärän mukaiseen laskevaan järjestykseen. Järjestä pelaajat Collections-luokan avulla ja tulosta pistepörssi:

        Collections.sort(pelaajat);

        System.out.println("NHL pistepörssi:\n");
        for (Pelaaja pelaaja : pelaajat) {
            System.out.println(pelaaja);
        }

Tulostuu:

NHL pistepörssi:

Alex Ovechkin             WSH           71  28 + 46 = 74
Phil Kessel               TOR           70  28 + 24 = 52
Dustin Byfuglien          ATL           69  19 + 31 = 50

Ohjeita tähän tehtävään materiaalissa.

Kaikkien pelaajien tiedot

Tilastomme on vielä hieman vajavainen, siinä on vaan muutaman pelaajan tiedot (ja nekin vastaavat 16.3. tilannetta). Kaikkien tietojen syöttäminen käsin olisi kovin vaivalloista. Onneksemme internetistä osoitteesta http://nhlstatistics.herokuapp.com/players.txt löytyy päivittyvä, koneen luettavaksi tarkoitettu lista pelaajatiedoista.

Huom: kun menet osoitteeseen ensimmäistä kertaa, sivun latautuminen kestää muutaman sekunnin (sivu pyörii virtuaalipalvelimella joka sammutetaan jos sivua ei ole hetkeen käytetty). Sen jälkeen sivu toimii nopeasti.

Datan lukeminen internetistä on helppoa. Projektissasi on valmiina luokka Tilasto, joka lataa annetun verkkosivun.

import java.io.InputStream;
import java.net.URL;
import java.util.Scanner;

public class Tilasto {
    private static final String OSOITE =
            "http://nhlstatistics.herokuapp.com/players.txt";

    private Scanner lukija;

    public Tilasto() {
        this(OSOITE);
    }

    public Tilasto(String osoite) {
        try {
            URL url = new URL(osoite);
            lukija = new Scanner(url.openStream());
        } catch (Exception ex) {
        }
    }

    public Tilasto(InputStream in) {
        try {
            lukija = new Scanner(in);
        } catch (Exception ex) {
        }
    }

    public boolean onkoRivejaJaljella() {
        return lukija.hasNextLine();
    }

    public String annaSeuraavaRivi() {
        String rivi = lukija.nextLine();
        return rivi.trim();
    }
}

Tilasto-luokka lukee pelaajien tilastotiedot internetistä. Metodilla annaSeuraavaRivi() saadaan selville yhden pelaajan tiedot. Tietoja on tarkoitus lukea niin kauan kuin pelaajia riittää, tämä voidaan tarkastaa metodilla onkoRivejaJaljella()

Kokeile että ohjelmasi onnistuu tulostamaan Tilasto-luokan hakemat tiedot:

    public static void main(String[] args) {
        Tilasto tilasto = new Tilasto();

        while (tilasto.onkoRivejaJaljella()) {
            String pelaajaRivina = tilasto.annaSeuraavaRivi();
            System.out.println(pelaajaRivina);
        }
    }

Tulostus on seuraavan muodoinen:

Evgeni Malkin;PIT;62;39;46;54 
Steven Stamkos;TBL;70;50;34;64
Claude Giroux;PHI;66;26;56;27
Jason Spezza;OTT;72;29;46;30
// ... ja yli 800:n muun pelaajan tiedot

Huom: tulostuksen alussa ja lopussa ja jokaisen pelaajan välissä on html-tägejä, esim. <br/> joka aiheuttaa www-sivulle rivin vaihtumisen.

Tulostuksessa pelaajan tiedot on erotettu toisistaan puolipisteellä. Ensin nimi, sitten joukkue, ottelut, maalit, syötöt ja laukaukset.

Pelaajaa vastaava merkkijono on siis yksittäinen merkkijono. Saat pilkottua sen osiin split-komennolla seuraavasti:

        while (tilasto.onkoRivejaJaljella()) {
            String pelaajaRivina = tilasto.annaSeuraavaRivi();
            String[] pelaajaOsina = pelaajaRivina.split(";");
            for (int j = 0; j < pelaajaOsina.length; j++) {
                System.out.print(pelaajaOsina[j] + " ");
            }
            System.out.println("");
        }

Kokeile että tämä toimii. Saat tästä tehtävästä pisteet seuraavan tehtävän yhteydessä.

Kaikkien pelaajien pistepörssi

Tee kaikista Tilasto-luokan hakemien pelaajien tiedoista Pelaaja-olioita ja lisää ne ArrayListiin. Lisää tehtävään luokka PelaajatTilastosta. Käytä allaolevaa koodia luokan runkona.

import java.util.ArrayList;

public class PelaajatTilastosta {
    public ArrayList<Pelaaja> haePelaajat(Tilasto tilasto) {
        ArrayList<Pelaaja> pelaajat = new ArrayList<Pelaaja>();
        while (tilasto.onkoRivejaJaljella()) {
            String pelaajaRivina = tilasto.annaSeuraavaRivi();
            String[] pelaajaOsina = pelaajaRivina.split(";");

            // Lisätään uusi pelaaja vain, jos syötteessä on kenttiä riittävästi
            if (pelaajaOsina.length > 4) {
                int ottelut = Integer.parseInt(pelaajaOsina[2].trim());
                // Täydennä koodia lukemalla kaikki pelaajaOsina-taulukon kentät uuteen Pelaaja-olioon
                // ...
                // pelaajat.add(new Pelaaja( ... ));
            }
        }

        return pelaajat;
    }
}

Tehtävänäsi on täydentää runkoa siten, että jokaisesta luetusta rivistä luodaan pelaaja, joka lisätään pelaajat-listaan. Huom! Tilasto-luokka palauttaa merkkijonoja, joten joudut muuntamaan merkkijonoja myös numeroiksi. Esimerkiksi numeromuotoinen ottelut on muutettava int:iksi Integer.parseInt-metodilla.

Jos merkkijonon metodi split ei ole tuttu, se jakaa merkkijonon useampaan osaan annetun merkin kohdalta. Esimerkiksi komento merkkijono.split(";"); palauttaa merkkijonosta taulukon, jossa alkuperäisen merkkijonon puolipisteellä erotetut osat ovat kukin omassa taulukon indeksissä.

Voit käyttää testauksen apuna seuraavaa pääohjelmaa:

        Tilasto tilasto = new Tilasto();

        PelaajatTilastosta pelaajienHakija = new PelaajatTilastosta();
        ArrayList<Pelaaja> pelaajat = pelaajienHakija.haePelaajat(tilasto);

        for (Pelaaja pelaaja : pelaajat) {
            System.out.println( pelaaja );
        }

Maali ja syöttöpörssi

Haluamme tulostaa myös maalintekijäpörssin eli pelaajien tiedot maalimäärän mukaan järjestettynä sekä syöttöpörssin. NHL:n kotisivu tarjoaa tämänkaltaisen toiminnallisuuden, eli selaimessa näytettävä lista on mahdollista saada järjestettyä halutun kriteerin mukaan.

Edellinen tehtävä määritteli pelaajien suuruusjärjestyksen perustuvan kokonaispistemäärään. Luokalla voi olla vain yksi compareTo-metodi, joten joudumme muunlaisia järjestyksiä saadaksemme turvautumaan muihin keinoihin.

Vaihtoehtoiset järjestämistavat toteutetaan erillisten luokkien avulla. Pelaajien vaihtoehtoisten järjestyksen määräävän luokkien tulee toteuttaa Comparator<Pelaaja>-rajapinta. Järjestyksen määräävän luokan olio vertailee kahta parametrina saamaansa pelaajaa. Metodeja on ainoastaan yksi compare(Pelaaja p1, Pelaaja p2), jonka tulee palauttaa negatiivinen arvo, jos pelaaja p1 on järjestyksessä ennen pelaajaa p2, positiivinen arvo jos p2 on järjestyksessä ennen p1:stä ja 0 muuten.

Periaatteena on luoda jokaista järjestämistapaa varten oma vertailuluokka, esim. maalipörssin järjestyksen määrittelevä luokka:

import java.util.Comparator;

public class Maali implements Comparator<Pelaaja> {
    public int compare(Pelaaja p1, Pelaaja p2) {
        // maalien perusteella tapahtuvan vertailun koodi tänne
    }
}

Tee Comparator-rajapinnan toteuttavat luokat Maali ja Syotto, ja niille vastaavat maali- ja syöttöpörssien generoimiseen sopivat sopivat vertailufunktiot.

Järjestäminen tapahtuu edelleen luokan Collections metodin sort avulla. Metodi saa nyt toiseksi parametrikseen järjestyksen määräävän luokan olion:

Maali maalintekijat = new Maali();
Collections.sort(pelaajat, maalintekijat);
System.out.println("NHL parhaat maalintekijät\n");

// tulostetaan maalipörssi

Järjestyksen määrittelevä olio voidaan myös luoda suoraan sort-kutsun yhteydessä:

Collections.sort(pelaajat, new Maali());
System.out.println("NHL parhaat maalintekijät\n");

// tulostetaan maalipörssi

Kun sort-metodi saa järjestyksen määrittelevän olion parametrina, se käyttää olion compareTo()-metodia pelaajia järjestäessään.

Tarkempia ohjeita vertailuluokkien tekemiseen täällä

Mäkihyppy

Harjoitellaan taas ohjelman rakenteen omatoimista suunnittelua. Käyttöliittymän ulkomuoto ja vaadittu toiminnallisuus on määritelty ennalta -- rakenteen saat toteuttaa vapaasti.

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ä tulee toteuttaa 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.

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

Huom! Testien kannalta on oleellista että käyttöliittymä toimii kuten yllä kuvattu. Tehtävä on neljän yksittäisen tehtäväpisteen arvoinen.

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

Screencast jossa tehdään viikon 1 ja 2 ydinasioita hyödyntävä hieman isompi sovellus:

Single responsibility principle

Isompia ohjelmia suunniteltaessa pohditaan usein mille luokalle minkäkin asian toteuttaminen kuuluu. Jos kaikki ohjelmaan kuuluva toiminnallisuus asetetaan samaan luokkaan, on tuloksena väistämättä kaaos. Ohjelmistojen suunnittelun osa-alue oliosuunnittelu sisältää periaatteen Single responsibility principle, jota meidän kannattaa seurata.

Single responsibility principle sanoo että jokaisella luokalla tulee olla vain yksi vastuu ja selkeä tehtävä. Jos luokalla on yksi selkeä tehtävä, on tehtävässä tapahtuvien muutosten toteuttaminen helppoa -- muutos tulee tehdä vain yhteen luokkaan. Jokaisella luokalla tulisi olla vain yksi syy muuttua.

Tutkitaan seuraavaa luokkaa Tyontekija, jolla on metodit palkan laskemiseen ja tuntien raportointiin.

public class Tyontekija {
    // oliomuuttujat

    // työntekijään liittyvät konstruktorit ja metodit

    public double laskePalkka() {
        // palkan laskemiseen liittyvä logiikka
    }

    public String raportoiTunnit() {
        // työtuntien raportointiin liittyvä logiikka
    }
}

Vaikka yllä olevasta esimerkistä puuttuu konkreettiset toteutukset, tulisi hälytyskellojen soida. Luokalla Tyontekija on ainakin kolme eri vastuualuetta. Se kuvaa sovelluksessa työntekijää, se toteuttaa palkanlaskennan tehtävää palkan laskemisesta, ja tuntiraportointijärjestelmän tehtävää työtuntien raportoinnista. Yllä oleva luokka pilkkoa kolmeen osaan: yksi osa kuvaa työntekijää, toinen osa palkanlaskentaa ja kolmas osa tuntikirjanpitoa.

public class Tyontekija {
    // oliomuuttujat

    // työntekijään liittyvät konstruktorit ja metodit
}
public class Palkanlaskenta {
    // oliomuuttujat

    // palkanlaskentaan liittyvät metodit

    public double laskePalkka(Henkilo henkilo) {
        // palkan laskemiseen liittyvä logiikka
    }
}
public class Tuntikirjanpito {
    // oliomuuttujat

    // tuntikirjanpitoon liittyvät metodit

    public String luoTuntiraportti(Henkilo henkilo) {
        // työtuntien raportointiin liittyvä logiikka
    }
}

Jokainen muuttuja, jokainen koodirivi, jokainen metodi, jokainen luokka, ja jokainen ohjelma pitäisi olla vain yhtä tarkoitusta varten. Usein ohjelman "parempi" rakenne on ohjelmoijalle selkeää vasta kun ohjelma on toteutettu jo kertaalleen. Tämä on täysin hyväksyttävää: vielä tärkeämpää on se, että ohjelmaa pyritään muuttamaan aina selkeämpään suuntaan. Refaktoroi eli muokkaa ohjelmaasi aina tarpeen tullen!

Luokkien organisointi pakkauksiin

Suurempia ohjelmia suunniteltaessa ja toteutettaessa luokkamäärä kasvaa helposti suureksi. Luokkien määrän kasvaessa niiden tarjoamien toiminnallisuuksien ja metodien muistaminen vaikeutuu. Järkevä luokkien nimentä helpottaa toiminnallisuuksien muistamista. Järkevän nimennän lisäksi lähdekooditiedostot kannattaa jakaa toiminnallisuutta, käyttötarkoitusta ja/tai tahoa kuvaaviin pakkauksiin. Pakkaukset (package) ovat käytännössä hakemistoja, joihin lähdekooditiedostot organisoidaan. Windowsissa ja puhekielessä hakemistoja (engl. directory) kutsutaan usein kansioiksi. Me käytämme kuitenkin termiä hakemisto.

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.... Luodun pakkauksen sisälle voidaan luoda luokkia aivan kuten oletuspakkaukseenkin (default package).

Pakkaus, jossa luokka sijaitsee, näkyy lähdekooditiedoston alussa ennen muita komentoja olevasta lauseesta package pakkaus. Esimerkiksi alla oleva luokka Sovellus sijaitsee pakkauksessa kirjasto.

package kirjasto;

public class Sovellus {

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

Pakkaukset voivat sisältää pakkauksia. Esimerkiksi pakkausmäärittelyssä package kirjasto.domain pakkaus domain on pakkauksen kirjasto sisällä. Asettamalla pakkauksia pakkausten sisään rakennetaan sovelluksen luokille ja rajapinnoille hierarkiaa. Esimerkiksi kaikki Javan luokat sijaitsevat pakkauksen java alla olevissa pakkauksissa. Pakkausnimeä 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. Esimerkiksi kirjasto-pakkauksessa oleva luokka Sovellus saisi käyttöönsä pakkauksessa kirjasto.domain olevan luokan määrittelyllä import kirjasto.domain.Kirja.

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!

Import-lauseet asetetaan lähdekooditiedostossa pakkausmäärittelyn jälkeen mutta ennen luokkamäärittelyä. Niitä voi olla myös useita -- esimerkiksi kun haluamme käyttää useita luokkia. Javan valmiit luokat sijaitsevat yleensä ottaen pakkauksen java alipakkauksissa. Luokkiemme alussa usein esiintyvät lauseet import java.util.ArrayList ja import java.util.Scanner; alkavat nyt toivottavasti vaikuttaa merkityksellisimmiltä.

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

Ensimmäisiä pakkauksia

Käyttöliittymä

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

Rajapinnan Kayttoliittyma tulee määritellä metodi void paivita(). 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 fi.mooc.observer.logiikka, ja lisää sinne luokka Sovelluslogiikka. Sovelluslogiikan APIn tulee olla seuraavanlainen.

Voit testata sovelluksen toimintaa seuraavalla pääohjelmaluokalla.

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

public class Main {

    public static void main(String[] args) {
        Kayttoliittyma kayttoliittyma = new Tekstikayttoliittyma();
        new Sovelluslogiikka(kayttoliittyma).suorita(3);
    }
}

Ohjelman tulostuksen tulee olla seuraava:

Sovelluslogiikka toimii
Päivitetään käyttöliittymää
Sovelluslogiikka toimii
Päivitetään käyttöliittymää
Sovelluslogiikka toimii
Päivitetään käyttöliittymää

Konkreettinen hakemistorakenne

Kaikki NetBeansissa näkyvät projektit ovat tietokoneesi tiedostojärjestelmässä. Jokaiselle projektille on olemassa oma hakemisto (eli kansio), 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ähdekoodihakemistoon src sisällä olevassa hakemistossa kirjasto. Jos olet kiinnostunut, 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ää.

Näkyvyysmääreet ja pakkaukset

Olemme aiemmin tutustuneet kahteen näkyvyysmääreeseen. Näkyvyysmääreellä private varustetut metodit ja muuttujat 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ä.

Pakkausnäkyvyyttä käytettäessä muuttujille tai metodeille ei aseteta mitään näkyvyyteen liittyvää etuliitettä. 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 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.

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!
    }
}

Monta rajapintaa

Rajapintaluokan alussa on sanat public interface RajapinnanNimi, missä RajapinnanNimi on rajapintaluokan nimi. Rajapintaluokka sisältää yhden tai useamman metodin, jotka sen toteuttavan luokan on pakko toteuttaa. Rajapintoja, kuten kaikkia luokkia voi asettaa pakkauksiin. Esimerkiksi seuraava Tunnistettava-rajapinta sijaitsee pakkauksessa sovellus.domain, ja määrittelee että Tunnistettava-rajapinnan toteuttavien luokkien tulee toteuttaa metodi public String getTunnus().

package sovellus.domain;

public interface Tunnistettava {
    String getTunnus();
}

Luokka toteuttaa rajapinnan implements-avainsanalla. Toteutetaan luokka Henkilo, joka toteuttaa rajapinnan tunnistettava. Henkilo-luokan 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();
    }
}

Rajapintojen vahvuus on se, että rajapinta on myös muuttujatyyppi. Kaikki rajapinnan toteuttavista luokista tehdyt oliot ovat myös rajapinnan tyyppisiä. Tämä helpottaa sovellusten rakentamista huomattavasti.

Tehdään luokka Henkilorekisteri, josta voimme hakea henkilöitä Tunnistettava-rajapinnan avulla. Yksittäisten henkilöiden hakemisen lisäksi Henkilorekisteri tarjoaa metodin kaikkien henkilöiden hakemiseen listana.

public class Henkilorekisteri {
    private HashMap<String, Henkilo> henkilot;

    public Henkilorekisteri(HashMap<String, Henkilo> henkilot) {
        this.henkilot = henkilot;
    }

    public void lisaaHenkilo(Henkilo henkilo) {
        this.henkilot.put(henkilo.getTunnus(), henkilo);
    }

    public void haeHenkiloTunnuksella(Tunnistettava tunnistettava) {
        this.henkilot.get(tunnistettava.getTunnus());
    }

    public List<Henkilo> getHenkilot() {
        return new ArrayList<Henkilo>(henkilot.values());
    }
}

Entä jos haluaisimme järjestää henkilölistan aakkosjärjestykseen?

Yksi ratkaisu on Tilastot kuntoon -tehtävässä esitelty Comparator-rajapinnan käyttö. Comparator-rajapinnan avulla tapahtuvan nimen vertailun voi toteuttaa esimerkiksi seuraavasti. Toteutetaan ensiksi Comparator-rajapinnan toteuttava luokka NimiJarjestys.

import java.util.Comparator;

public class NimiJarjestys implements Comparator<Henkilo> {

    @Override
    public int compare(Henkilo h1, Henkilo h2) {
        return h1.getNimi().compareTo(h2.getNimi());
    }
}

Luokan NimiJarjestys metodi compare vertaa kahden parametrina annetun henkilön nimiä toisiinsa. Luokan avulla voimme toteuttaa henkilörekisterin metodia getHenkilot palauttaman listan järjestämisen seuraavasti.

    public List<Henkilo> getHenkilot() {
        ArrayList<Henkilo> lista = new ArrayList<Henkilo>(this.henkilot.values());
        Collections.sort(lista, new NimiJarjestys());
        return lista;
    }

Luomme ensin metodissa henkilot-listan, jonka annamme Collections-luokan sort-metodille. Metodi sort saa toisena parametrinaan NimiJarjestys-luokan ilmentymän, joka kertoo miten henkilot-listalla olevat oliot tulee järjestää.

Comparator-rajapinnan toteuttamaa luokkaa ei kuitenkaan tarvitse toteuttaa tässä tapauksessa. Yksi luokka voi toteuttaa useamman rajapinnan, eli voimme toteuttaa Henkilo-luokalla lisäksi Comparable-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 luokalla 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.nimi.compareTo(toinen.getNimi());
    }
}

Nyt henkilöstörekisterin listaa-metodi on seuraavanlainen ja pääsemme eroon turhasta NimiJarjesta-luokasta.

    public List<Henkilo> getHenkilot() {
        ArrayList<Henkilo> henkilot = new ArrayList<Henkilo>(henkilot.values());
        Collections.sort(henkilot);
        return henkilot;
    }

Kassaesimerkki

Pohditaan seuraavaksi kauppojen kassalaitteessa olevaan lukijalaitteeseen liittyvää tuotteen tunnistamistoimintoa. Oletetaan että tuotteet sisältävät niihin liittyvän viivakoodin, nimen ja hinnan. Tuotteita kuvastava Tuote-olio toteuttaa aiemmin määritellyn rajapinnan Tunnistettava.

package sovellus.domain;

public class Tuote implements Tunnistettava {
    private String viivakoodi;
    private String nimi;
    private int hinta;

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

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

    public int getHinta() {
        return this.hinta;
    }

    @Override
    public String getTunnus() {
        return this.viivakoodi;
    }
}

Toteutetaan seuraavaksi Lukijalaite, joka osaa muuntaa tunnistettavat oliot tuotteiksi. Lukijalaitteen tulee lukea ja tunnistaa tuotteet rajapinnan Tunnistettava-avulla. Toteutetaan luokka Lukijalaite siten, että se sisältää hajautustaulun, josta tuotteet löytyvät tunnisteen perusteella. Lukijalaite on osa sovelluksen logiikkaa, joten lisätään se pakkaukseen sovellus.logiikka.

package sovellus.logiikka;

public class Lukijalaite {
    private HashMap<String, Tuote> tuotteet;

    public Lukijalaite(HashMap<String, Tuote> tuotteet) {
        this.tuotteet = tuotteet;
    }

    public Tuote tunnista(Tunnistettava tunnistettava) {
        return this.tuotteet.get(tunnistettava.getTunnus());
    }
}

Lukijalaite-olio palauttaa Tuote-olion jos sen tunnus löytyy lukijalaitteen sisältämästä hajautustaulusta. Luodaan seuraavaksi kassa, joka käyttää lukijalaitetta tuotteiden lisäämiseen ostettujen tuotteiden listalle. Kassalla on metodi public void osta, jolle annetaan luokan Tunnistettava-ilmentymä parametrina. Ostaminen lisää lukijalaitteella tunnistetun tuotteen ostettujen listalle. Jos tuotetta ei tunnisteta, ei tehdä mitään. Metodi tulostaOstokset tulostaa ostettujen tuotteiden nimet.

package sovellus.domain;

public class Kassa {
    private Lukijalaite laite;
    private List<Tuote> tuotteet;

    public Kassa(Lukijalaite lukijalaite) {
        this.laite = lukijalaite;
        this.tuotteet = new ArrayList<Tuote>();
    }

    public void osta(Tunnistettava tunnistettava) {
        Tuote tunnistettu = this.laite.tunnista(tunnistettava);
        if (tunnistettu == null) {
            return;
        }

        this.tuotteet.add(tunnistettu);
    }

    public void tulostaOstokset() {
        for(Tuote tuote: this.tuotteet) {
            System.out.println(tuote.getNimi());
        }
    }
}

Huomaamme tässä vaiheessa että tuotteiden lukeminen listalta, joka on tulostettu tuotteiden lisäysjärjestyksessä, on hyvin kuormittavaa asiakkaalle. Muokataan tulostusta siten, että tuotteet listataan aakkosjärjestyksessä. Lisätään Tuote-luokalle rajapinta Comparable, jonka avulla tuotteet voidaan järjestää aakkosjärjestyksessä.

package sovellus.domain;

public class Tuote implements Tunnistettava, Comparable<Tuote> {
    private String viivakoodi;
    private String nimi;
    private int hinta;

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

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

    public int getHinta() {
        return this.hinta;
    }

    @Override
    public String getTunnus() {
        return this.viivakoodi;
    }

    @Override
    public int compareTo(Tuote tuote) {
        return this.nimi.compareTo(tuote.getNimi());
    }
}

Muokataan vielä kassaan liittyvää toiminnallisuutta siten, että tuotteet järjestetään tarvittaessa. Huomaa että tuotteita tarvitsee järjestää vain silloin kun tuotteita ostetaan. Ostaminen on ainut tilanne, jossa tuotteita sisältävän listan järjestys mahdollisesti muuttuu. Tällöin ostokset ovat aina järjestyksessä metodia tulostaOstokset kutsuttaessa.

package sovellus.domain;

public class Kassa {
    private Lukijalaite laite;
    private List<Tuote> tuotteet;

    public Kassa(Lukijalaite lukijalaite) {
        this.laite = lukijalaite;
        this.tuotteet = new ArrayList<Tuote>();
    }

    public void osta(Tunnistettava tunnistettava) {
        Tuote tunnistettu = this.laite.tunnista(tunnistettava);
        if (tunnistettu == null) {
            return;
        }

        this.tuotteet.add(tunnistettu);
        Collections.sort(this.tuotteet);
    }

    public void tulostaOstokset() {
        for(Tuote tuote: this.tuotteet) {
            System.out.println(tuote.getNimi());
        }
    }
}

Muuttaminen

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

Huom! Tässä ei tarvitse ottaa esineiden ulkomuotoa huomioon!

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). Muuta luokkaa Esine siten, että se toteuttaa rajapinnan Tavara.

Lisää luokalle Esine myös metodit public String getNimi() ja korvaa metodi public String toString() versiolla, joka tulostaa "nimi (tilavuus dm^3)". Esineen pitäisi toimia nyt jotakuinkin seuraavasti

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

Esine vertailtavaksi ja Muuttolaatikko

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<Esine>();
    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)]

Toteuta tämän jälkeen pakkaukseen muuttaminen.domain luokka Muuttolaatikko, jonka tulee toteuttaa rajapinta Tavara. Metodilla getTilavuus saa selville muuttolaatikon tämänhetkisen tilavuuden. Rajapinnan Tavara lisäksi Muuttolaatikolla on seuraavanlainen API

Toteuta vielä luokalle Muuttolaatikko rajapinta Tavara. Metodilla getTilavuus tulee saada selville muuttolaatikossa olevien tavaroiden tämänhetkisen yhteistilavuuden.

Esineiden pakkaaminen

Seuraavaksi toteutetaan esineiden pakkaustoiminnallisuus.

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

Toteuta tämän jälkeen luokalle metodi public List<Muuttolaatikko> pakkaaEsineet(List<Esine> esineet), joka pakkaa esineet muuttolaatikoihin.

Tee metodista sellainen että kaikki parametrina annetussa listassa olevat esineet päätyvät palautetussa listassa oleviin muuttolaatikoihin. Sinun ei tarvitse varautua tilanteisiin joissa esineet ovat suurempia kuin muuttolaatikko.

Tehokkaampi pakkaaminen

Alla on kuvattu eräs hieman tehokkaampi pakkaustapa pseudokoodina, eli ohjelmointikielen tapaisena koodina. Pseudokoodia käytetään muun muassa ohjelmointikieliriippumattomaan algoritmien eli ohjelmien kuvaamiseen.

pakkaaEsineet( esineet ):
    jarjesta( esineet )

    laatikot = [] // huom, kannattaa käyttää ArrayListiä

    while esineet is not empty:
        Muuttolaatikko pakattu = pakkaaLaatikko( esineet )
        laatikot.add( pakattu )

    return laatikot

pakkaaLaatikko( esineet ):
    Muuttolaatikko laatikko = new Muuttolaatikko

    lisaaSuuria( esineet, laatikko )
    lisaaPienia( esineet, laatikko )

    return laatikko

lisaaSuuria( esineet, laatikko ):
    while esineet is not empty:
        esine = suurin( esineet )

        if lisaa ( laatikko, esine ) == false:
            return

lisaaPienia(List esineet, Muuttolaatikko laatikko):
    while esineet is not empty:
        esine = pienin( esineet )

        if lisaa ( laatikko, esine ) == false:
            return

Muokkaa luokkaa Pakkaaja siten, että se toimii samoin tai paremmin kuin yllä kuvattu lähestymistapa.

Metodissa pakkaaEsineet pakataan muuttolaatikoita niin pitkään kun esineet-listalla on esineitä. Muuttolaatikkoa pakattaessa algoritmi lisää laukkuun ensiksi niin paljon suurimpia esineitä kuin laatikkoon mahtuu. Kun laatikkoon ei enää mahdu suurimpia esineitä, aletaan täyttämään sitä pienimmillä esineillä.

Toteutuksesta: Kun esineesi ovat järjestyksessä, suurin tavara löytyy indeksistä esinelistan koko - 1, pienin tavara löytyy indeksistä 0. Älä käytä tässä Collections.min ja Collections.max-metodeja, sillä ne eivät osaa arvata että ArrayList-lista on jo järjestyksessä.

Poista esineitä esineet-listalta sitä mukaa kun niitä on lisätty muuttolaatikoihin. Sinun ei tarvitse varautua tilanteisiin joissa esineet ovat suurempia kuin muuttolaatikko.

Huom! Saadaksesi pisteen tästä viimeisestä tehtävästä algoritmisi tulee toimia vähintään yhtä hyvin kuin yllä kuvattu algoritmi. Hyvyydellä tarkoitetaan sitä, että pakkaukseen kulunut aika tulee olla vähintään yhtä pieni kuin pseudokoodiratkaisun. Muuttolaatikkojen määrän tulee myös olla vähintään yhtä pieni.

Voit käyttää seuraavaa metodia satunnaisten esineiden luomiseen.

    public static List<Esine> luoEsineet(int kpl, int maxTilavuus) {
        Random rand = new Random();

        List<Esine> esineet = new ArrayList<Esine>();
        for (int i = 0; i < kpl; i++) {
            esineet.add(new Esine("hammasharja", 1 + rand.nextInt(maxTilavuus)));
        }

        return esineet;
    }

Yllä kuvatulla algoritmilla pakkaamisen pitäisi toimia nopeasti jopa 100000 esinettä sisältävillä listoilla. Voit testata pakkaajasi nopeutta esimerkiksi seuraavasti:

        List<Esine> esineet = luoEsineet(100000, 10);
        Pakkaaja pakkaaja = new Pakkaaja(50);

        long start = System.nanoTime();
        List<Muuttolaatikko> laatikot = pakkaaja.pakkaaEsineet(esineet);
        long kulunutAika = ((System.nanoTime() - start) / 1000000);

        System.out.println("Pakkaukseen kului " + kulunutAika + " ms.");
        System.out.println("Tarvittiin " + laatikot.size() + " laatikkoa.");

Tekstiseikkailu

Tehtäväsarjassa tehdään laajennettava tekstiseikkailupelin runko. Seikkailu koostuu kohdista, joissa jokaisessa ruudulle tulee tekstiä. Kohdat voivat olla joko välivaiheita, kysymyksiä, tai monivalintakohtia. Monivalinta-tyyppisen kohdan näyttämä teksti voi olla esimerkiksi seuraavanlainen:

Huoneessa on kaksi ovea. Kumman avaat?

1. Vasemmanpuoleisen.
2. Oikeanpuoleisen.
3. Juoksen pakoon.

Käyttäjä vastaa kohdassa esitettävään tekstiin. Yllä olevaan tekstiin voi vastata 1, 2 tai 3, ja vastauksesta riippuu, minne käyttäjä siirtyy seuraavaksi.

Peliin tullaan toteuttamaan kohtia kuvaava rajapinta ja tekstikäyttöliittymä, jonka kautta peliä pelataan.

Huom! Toteuta kaikki tehtävän vaiheet pakkaukseen "seikkailu"

Kohta ja Välivaihe

Pelissä voi olla hyvinkin erilaisia kohtia, ja edellä olleessa esimerkissä ollut monivalinta on vain eräs vaihtoehto.

Toteuta kohdan käyttäytymistä kuvaava rajapinta Kohta. Rajapinnalla Kohta tulee olla metodi String teksti(), joka palauttaa kohdassa tulostettavan tekstin. Metodin teksti lisäksi kohdalla tulee olla metodi Kohta seuraavaKohta(String vastaus), jonka toteuttavat luokat palauttavat seuraavan kohdan vastauksen perusteella.

Toteuta tämän jälkeen yksinkertaisin tekstiseikkailun kohta, eli ei-interaktiivinen tekstiruutu, josta pääsee etenemään millä tahansa syötteellä. Toteuta ei-interaktiivista tekstiruutua varten luokka Valivaihe, jolla on seuraavanlainen API.

Testaa ohjelmaasi seuraavalla esimerkillä:

    Scanner lukija = new Scanner(System.in);

    Valivaihe alkuteksti = new Valivaihe("Olipa kerran ohjelmoija.");
    Valivaihe johdanto = new Valivaihe("Joka alkoi ohjelmoimaan Javalla.");
    alkuteksti.asetaSeuraava(johdanto);

    Kohta nykyinen = alkuteksti;
    System.out.println(nykyinen.teksti());

    nykyinen = nykyinen.seuraavaKohta(lukija.nextLine());
    if (nykyinen == null) {
        System.out.println("Virhe ohjelmassa!");
    }

    System.out.println(nykyinen.teksti());

    nykyinen = nykyinen.seuraavaKohta(lukija.nextLine());
    if (nykyinen != null) {
        System.out.println("Virhe ohjelmassa!");
    }
Olipa kerran ohjelmoija.
(jatka painamalla enteriä)

Joka alkoi ohjelmoimaan Javalla.
(jatka painamalla enteriä)

Käyttöliittymä

Pelin käyttöliittymä (luokka Kayttoliittyma) saa konstruktorin parametrina Scanner-olion ja Kohta-rajapinnan toteuttavan pelin aloittavan olion. Luokka tarjoaa metodin public void kaynnista(), joka käynnistää pelin suorituksen.

Käyttöliittymä käsittelee kaikkia kohtia Kohta-rajapinnan kautta. Käyttöliittymän tulee jokaisessa kohdassa kysyä kohtaan liittyvältä metodilta teksti tekstiä, joka käyttäjälle näytetään. Tämän jälkeen käyttöliittymä kysyy käyttäjältä vastauksen, ja antaa sen parametrina kohta-olion metodille seuraavaKohta. Metodi seuraavaKohta palauttaa vastauksen perusteella seuraavan kohdan, johon pelin on määrä siirtyä. Peli loppuu, kun metodi seuraavaKohta palauttaa arvon null.

Koska pääohjelma tulee käyttämään kohtia vain Kohta-rajapinnan kautta, voidaan peliin lisätä vaikka minkälaisia kohtia pääohjelmaa muuttamatta. Riittää tehdä uusia Kohta-rajapinnan toteuttavia luokkia.

Toteuta luokka Kayttoliittyma, ja testaa sen toimintaa seuraavalla esimerkillä

        Scanner lukija = new Scanner(System.in);
        Valivaihe alku = new Valivaihe("Olipa kerran ohjelmoija.");
        Valivaihe johdanto = new Valivaihe("Joka alkoi ohjelmoimaan Javalla.");
        Valivaihe loppu = new Valivaihe("Ja päätti muuttaa Helsinkiin.");

        alku.asetaSeuraava(johdanto);
        johdanto.asetaSeuraava(loppu);

        new Kayttoliittyma(lukija, alku).kaynnista();
Olipa kerran ohjelmoija.
(jatka painamalla enteriä)
>

Joka alkoi ohjelmoimaan Javalla.
(jatka painamalla enteriä)
>

Ja päätti muuttaa Helsinkiin.
(jatka painamalla enteriä)
>

Käytä seuraavaa metodia käyttöliittymän kaynnista-metodina. Yritä piirtää paperille mitä käy kun käyttöliittymä käynnistetään.

    public void kaynnista() {
        Kohta nykyinen = alkukohta;

        while (nykyinen != null) {
            System.out.println(nykyinen.teksti());
            System.out.print("> ");
            String vastaus = lukija.nextLine();

            nykyinen = nykyinen.seuraavaKohta(vastaus);
            System.out.println("");
        }
    }

Käyttöliittymän kaynnista-metodi sisältää siis toistolauseen, jossa ensin tulostetaan käsiteltävän kohdan teksti. Tämän jälkeen kysytään käyttäjältä syötettä. Käyttäjän syöte annetaan vastauksena käsiteltävän kohdan seuraavaKohta-metodille. Metodi seuraavaKohta palauttaa kohdan, jota käsitellään seuraavalla toiston kierroksella. Jos palautettu kohta oli null, lopetetaan toisto.

Kysymyksiä

Tekstiseikkailussa voi olla kysymyksiä, joihin on annettava oikea vastaus ennen kuin pelaaja pääsee eteenpäin. Tee luokka Kysymys seuraavasti:

Luokkaa voi testata seuraavalla pääohjelmalla:

    Scanner lukija = new Scanner(System.in);

    Kysymys alku = new Kysymys("Minä vuonna Javan ensimmäinen versio julkaistiin?", "1995");
    Valivaihe hyva = new Valivaihe("Hyvä! Lisätietoa: Javan alkuperäinen ideoija on James Gosling.");

    alku.asetaSeuraava(hyva);

    new Kayttoliittyma(lukija, alku).kaynnista();
Minä vuonna Javan ensimmäinen versio julkaistiin?
> 2000

Minä vuonna Javan ensimmäinen versio julkaistiin?
> 1995

Hyvä! Lisätietoa: Javan alkuperäinen ideoija on James Gosling.
(jatka painamalla enteriä)
>

Monivalintakysymykset

Tällä hetkellä tekstiseikkailu tukee välivaiheita ja yksinkertaisia kysymyksiä. Tekstiseikkailu on siis lineaarinen, eli lopputulokseen ei voi käytännössä vaikuttaa. Lisätään seikkailuun monivalintakysymyksiä, joiden avulla pelin kehittäjä voi luoda vaihtoehtoista toimintaa.

Esimerkki vaihtoehtoisesta toiminnasta:

Kello on 13:37 ja päätät mennä syömään. Minne menet?
1. Exactumiin
2. Chemicumiin
> 1

Ruoka on loppu :(
(jatka painamalla enteriä)
>
Kello on 13:37 ja päätät mennä syömään. Minne menet?
1. Exactumiin
2. Chemicumiin
> 2

Mainio valinta!
(jatka painamalla enteriä)
>

Toteuta luokka Monivalinta, jonka API on seuraavanlainen

Testaa ohjelmasi toimintaa seuraavalla pääohjelmalla:

    Scanner lukija = new Scanner(System.in);

    Monivalinta lounas = new Monivalinta("Kello on 13:37 ja päätät mennä syömään. Minne menet?");
    Monivalinta chemicum = new Monivalinta("Lounasvaihtoehtosi ovat seuraavat:");

    Valivaihe exactum = new Valivaihe("Exactumista on kaikki loppu, joten menet Chemicumiin.");

    exactum.asetaSeuraava(chemicum);

    lounas.lisaaVaihtoehto("Exactumiin", exactum);
    lounas.lisaaVaihtoehto("Chemicumiin", chemicum);

    Valivaihe nom = new Valivaihe("Olipas hyvää");

    chemicum.lisaaVaihtoehto("Punajuurikroketteja, ruohosipuli-soijajogurttikastiketta", nom);
    chemicum.lisaaVaihtoehto("Jauhelihakebakot, paprikakastiketta", nom);
    chemicum.lisaaVaihtoehto("Mausteista kalapataa", nom);

    new Kayttoliittyma(lukija, lounas).kaynnista();
Kello on 13:37 ja päätät mennä syömään. Minne menet?
1. Exactumiin
2. Chemicumiin
> 1

Exactumista on kaikki loppu, joten menet Chemicumiin.
(jatka painamalla enteriä)
>

Lounasvaihtoehtosi ovat seuraavat:
1. Punajuurikroketteja, ruohosipuli-soijajogurttikastiketta
2. Jauhelihakebakot, paprikakastiketta
3. Mausteista kalapataa
> 2

Olipas hyvää
(jatka painamalla enteriä)
>

Luokan Monivalinta sisäinen toteutus saattaa olla haastava. Kannattaa esimerkiksi käyttää listaa vastausvaihtoehtojen (merkkijonojen) tallentamiseen, ja hajautustaulua kohtien tallentamiseen valintavaihtoehdon indeksillä.

Poikkeustilanteet

Poikkeustilanteet ovat tilanteita joissa ohjelman suoritus ei ole edennyt toivotusti. Ohjelma on saattanut esimerkiksi kutsua null-viitteeseen liittyvää metodia, jolloin käyttäjälle heitetään poikkeus NullPointerException. Jos yritämme hakea taulukon ulkopuolella olevaa indeksiä, käyttäjälle heitetään poikkeus IndexOutOfBoundsException. Kaikki poikkeukset ovat tyyppiä Exception.

Poikkeukset käsitellään try { } catch (Exception e) { } -lohkorakenteella. Avainsanan try aloittaman lohkon sisällä on mahdollisesti poikkeuksen heittävä ohjelmakoodi. Avainsanan catch aloittaman lohkon sisällä taas määritellään mitä tehdään jos try-lohkossa suoritettavassa koodissa tapahtuu poikkeus. Catch-lauseelle määritellään kiinniotettavan poikkeuksen tyyppi (catch (Exception e)).

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

Merkkijonon numeroksi muuttava Integer-luokan parseInt-metodi heittää poikkeuksen NumberFormatException jos sille parametrina annettu merkkijono ei ole muunnettavissa numeroksi. Toteutetaan ohjelma, joka yrittää muuntaa käyttäjän syöttämän merkkijonon numeroksi.

    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ä oleva ohjelma heittää poikkeuksen kun käyttäjä syöttää virheellisen numeron. Ohjelman suoritus päättyy virhetilanteeseen, eikä suoritusta voi enää jatkaa. Lisätään ohjelmaan poikkeuskä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: ");

    try {
        int 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. Visualisoidaan 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: ");

    try {
        int 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 -- muulloin ohjelma ei pääse sinne.

Tehdään luvun muuntajasta hieman hyödyllisempi: Tehdään siitä metodi, joka kysyy numeroa yhä uudestaan kunnes käyttäjä syöttää oikean numeron. Metodista pääsee pois vain jos käyttäjä syöttää oikean luvun.

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.");
        }
    }
}

Metodin lueLuku kutsuminen voisi toimia esimerkiksi seuraavasti

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

Poikkeusten heittä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ä. Pakosti käsiteltävät poikkeukset käsitellään joko try-catch -lohkossa, tai heittämällä ne ulos metodista. Rajapintoihin liittyneessä Julkaisupalvelu-esimerkissä on Javan metodi Thread.sleep, jonka mahdollisesti heittämä poikkeus on pakko käsitellä. Sen käsittely tapahtuu esimerkiksi try-catch -lauseella, seuraavassa esimerkissä olemme välittämättä mahdollisista poikkeustilanteista ja jätimme catch-lohkon tyhjäksi.

    try {
        // nukutaan 1000 millisekuntia
        Thread.sleep(1000);
    } catch (Exception e) {
        // ei tehdä mitään poikkeustilanteessa
    }

Jos poikkeusta ei käsitellä, tulee metodin antaa vastuu poikkeuksen käsittelystä metodin kutsujalle. Vastuun siirto tapahtuu heittämällä poikkeus metodista eteenpäin sanomalla throws Exception.

    public void nuku(int sekuntia) throws Exception {
        Thread.sleep(sekuntia * 1000);
    }

Nyt metodia nuku-kutsuvan metodin tulee joko käsitellä poikkeus try-catch -lohkossa, tai siirtää poikkeuksen käsittelyn vastuuta eteenpäin heittää poikkeus eteenpäin. Palaamme poikkeuksiin, jotka on pakko käsitellä tarkemmin tiedostojen käsittelyn yhteydessä.

Osa poikkeuksista, kuten Integer.parseInt-metodin heittämä NumberFormatException, on sellaisia joihin ohjelmoijan ei ole pakko varautua. Poikkeukset, joihin käyttäjän ei tarvitse varautua ovat aina myös tyyppiä tyyppiä RuntimeException -- palaamme siihen miksi muuttujilla voi olla useita eri tyyppejä tarkemmin ensi viikolla.

Voimme itse heittää poikkeuksen lähdekoodista throw-komennolla. Esimerkiksi NumberFormatException-luokasta luodun poikkeuksen heittäminen tapahtuisi komennolla throw new NumberFormatException().

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 validoida Arvosana-luokan konstruktorin parametrina saadun arvon. 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

Parametrien validointi

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. Rajapintaluokat voivat määritellä myös poikkeusten heiton. 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 allaolevasta esimerkistä näkee.

public class Tekstipalvelin implements Tiedostopalvelin {

    private Map<String, String> data;

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

    @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 polun, joka kertoo mistä päädyttiin 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(). 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ä.

Tiedostojen käsittely

Huomattava osa ohjelmista käsittelee jollain tavalla tallennettua tietoa. Otetaan ensiaskeleet tiedostojen käsittelyyn Javassa. Javan API tarjoaa luokan File, jonka sisältö voidaan lukea kurssilla jo tutuksi tulleen Scanner-luokan avulla.

Luokan File API-kuvausta lukiessamme huomaamme File-luokalla on konstruktori File(String pathname) (Creates a new File instance by converting the given pathname string into an abstract pathname). Voimme siis antaa avattavan tiedoston polun File-luokan konstruktorille.

NetBeans-ohjelmointiympäristössä tiedostoille on oma välilehti nimeltä Files. Files-välilehdellä on määritelty kaikki projektiin liittyvät tiedostot. Jos projektin juureen, eli ei yhdenkään hakemiston sisälle, lisätään tiedosto, voidaan siihen viitata projektin sisältä suoraan tiedoston nimellä. Tiedosto-olion luominen tapahtuu antamalla sille parametrina polku tiedostoon, esimerkiksi seuraavasti

    File tiedosto = new File("tiedoston-nimi.txt");

Tiedoston lukeminen

Scanner-luokan konstruktorille voi antaa myös muita lukemislähteitä kuin System.in-syöttövirran. Lukemislähteenä voi olla näppäimistön lisäksi muun muassa tiedosto. Scanner tarjoaa tiedoston lukemiseen samat metodit kuin näppäimistöltä syötetyn syötteen lukemiseen. Seuraavassa esimerkissä avataan tiedosto ja tulostetaan kaikki tiedoston sisältämän tekstit System.out.println-komennolla.

        // tiedosto mistä luetaan
        File tiedosto = new File("tiedosto.txt");

        Scanner lukija = new Scanner(tiedosto);
        while (lukija.hasNextLine()) {
            String rivi = lukija.nextLine();
            System.out.println(rivi);
        }

        lukija.close();

Scanner-luokan konstruktori public Scanner(File source) (Constructs a new Scanner that produces values scanned from the specified file.) heittää FileNotFoundException-poikkeuksen jos luettavaa tiedostoa ei löydy. Poikkeus FileNotFoundException ei ole tyyppiä RuntimeException, joten se tulee joko käsitellä tai heittää eteenpäin. Tässä vaiheessa riittää tietää että ohjelmointiympäristö kertoo jos sinun tulee käsitellä poikkeus erikseen. Luodaan ensin vaihtoehto, jossa poikkeus käsitellään tiedostoa avattaessa.

    public void lueTiedosto(File tiedosto) {
        // tiedosto mistä luetaan
        Scanner lukija = null;

        try {
            lukija = new Scanner(tiedosto);
        } catch (Exception e) {
            System.out.println("Tiedoston lukeminen epäonnistui. Virhe: " + e.getMessage());
            return; // poistutaan metodista
        }

        while (lukija.hasNextLine()) {
            String rivi = lukija.nextLine();
            System.out.println(rivi);
        }

        lukija.close();
    }

Toinen vaihtoehto poikkeuksen käsittelyyn on poikkeuksen käsittelyvastuun siirtäminen metodin kutsujalle. Poikkeuksen käsittelyvastuu siirretään metodin kutsujalle lisäämällä metodiin määre throws PoikkeuksenTyyppi, eli esimerkiksi throws Exception sillä kaikki poikkeukset ovat tyyppiä Exception. Kun metodilla on määre throws Exception, tietävät kaikki sitä kutsuvat että se saattaa heittää poikkeuksen johon tulee varautua.

    public void lueTiedosto(File tiedosto) throws Exception {
        // tiedosto mistä luetaan
        Scanner lukija = new Scanner(tiedosto);

        while (lukija.hasNextLine()) {
            String rivi = lukija.nextLine();
            System.out.println(rivi);
        }

        lukija.close();
    }

Esimerkki avaa tiedoston tiedosto.txt projektin juuripolusta ja tulostaa sen rivi riviltä käyttäjälle näkyville. Lopuksi lukija suljetaan, jolloin tiedosto myös suljetaan. Määre throws Exception kertoo että metodi saattaa heittää poikkeuksen. Samanlaisen määreen voi laittaa kaikkiin metodeihin jotka käsittelevät tiedostoja.

Huomaa että Scanner-olio ei liitä rivinvaihtomerkkejä osaksi nextLine-metodin palauttamaa merkkijonoa. Yksi vaihtoehto tiedoston lukemiseen siten, että rivinvaihdot säilyvät, on StringBuilder-olion käyttäminen lukemisessa siten, että jokaisen rivin jälkeen lisätään rivinvaihtomerkki.

    public String lueTiedostoMerkkijonoon(File tiedosto) throws Exception {
        // tiedosto mistä luetaan
        Scanner lukija = new Scanner(tiedosto);

        StringBuilder stringBuilder = new StringBuilder();

        while (lukija.hasNextLine()) {
            String rivi = lukija.nextLine();
            stringBuilder.append(rivi);
            stringBuilder.append("\n");
        }

        lukija.close();
        return stringBuilder.toString();
    }

Koska käytämme tiedoston lukemiseen Scanner-luokkaa, käytössämme on kaikki Scanner-luokan tarjoamat metodit. Esimerkiksi metodi hasNext() palauttaa totuusarvon true, jos luettavassa tiedostossa on vielä luettavaa jäljellä, ja metodi next() lukee seuraavan sanan metodin palauttamaan String-olioon.

Seuraava ohjelma luo Scanner-olion, joka avaa tiedoston tiedosto.txt. Sen jälkeen se tulostaa joka viidennen sanan tiedostosta.

        File tiedosto = new File("tiedosto.txt");
        Scanner lukija = new Scanner(tiedosto);

        int monesko = 0;
        while (lukija.hasNext()) {
            monesko++;
            String sana = lukija.next();

            if (monesko % 5 == 0) {
                System.out.println(sana);
            }
        }

Alla on ensin luetun tiedoston sisältämä teksti ja sitten ohjelman tulostus

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

Merkistöongelmista

Tekstiä tiedostosta luettaessa (tai tiedostoon tallennettaessa) Java joutuu päättelemään käyttöjärjestelmän käyttämän merkistön. Merkistön tuntemusta tarvitaan sekä tekstin tallentamiseen tietokoneen kovalevylle binäärimuotoiseksi että binäärimuotoisen datan tekstiksi kääntämiseksi.

Merkistöihin on kehitetty standardeja, joista "UTF-8" on nykyään yleisin. UTF-8 -merkistö sisältää sekä jokapäiväisessä käytössä olevien aakkosten että erikoisempien merkkien kuten Japanin kanji-merkistön tai shakkipelin nappuloiden tallentamiseen ja lukemiseen tarvittavat tiedot. Ohjelmointimielessä merkistöä voi hieman yksinkertaistaen ajatella hajautustauluna merkistä numeroon ja numerosta merkkiin. Merkistä numeroon oleva hajautustaulu kuvaa minkälaisena binäärilukuna kukin merkki tallennetaan tiedostoon. Numerosta merkkiin oleva hajautustaulu taas kuvaa miten tiedostoa luettaessa saadut luvut muunnetaan merkeiksi.

Lähes jokaisella käyttöjärjestelmävalmistajalla on myös omat standardinsa. Osa tukee ja haluaa osallistua avoimien standardien käyttöön, osa ei. Mikäli sinulla on ongelmia ääkkösellisten sanojen kanssa (eritoten mac ja windows käyttäjät) voit kertoa Scanner-oliota luodessa käytettävän merkistön. Tällä kurssilla käytämme aina merkistöä "UTF-8".

UTF-8 -merkistöä käyttävän tiedostoa lukevan Scanner-olion voi luoda seuraavasti:

    File tiedosto = new File("esimerkkitiedosto.txt");
    Scanner lukija = new Scanner(tiedosto, "UTF-8");

Toinen vaihtoehto merkistön asettamiseksi on ympäristömuuttujan käyttäminen. Macintosh ja Windows-käyttäjät voivat asettaa ympäristömuuttujan JAVA_TOOL_OPTIONS arvoksi merkkijonon -Dfile.encoding=UTF8. Tällöin Java käyttää oletuksena aina UTF-8 -merkistöä.

Tiedoston analyysi

Tässä tehtävässä tehdään tiedoston analysointityökalu, joka tarjoaa rivien ja merkkien laskemistoiminnallisuuden.

Rivien laskeminen

Tee pakkaukseen tiedosto luokka Analyysi, jolla on konstruktori public Analyysi(File tiedosto). Toteuta luokalle metodi public int rivimaara(), joka palauttaa konstruktorille annetun tiedoston rivimäärän.

Merkkien laskeminen

Toteuta luokkaan Analyysi metodi public int merkkeja(), joka palauttaa luokan konstruktorille annetun tiedoston merkkien määrän.

Voit itse päättää miten reagoidaan jos konstruktorin parametrina saatua tiedostoa ei ole olemassa.

    File tiedosto = new File("src/testitiedosto.txt");
    Analyysi analyysi = new Analyysi(tiedosto);
    System.out.println("Rivejä: " + analyysi.rivimaara());
    System.out.println("Merkkejä: " + analyysi.merkkeja());
Rivejä: 3
Merkkejä: 67

Sanatutkimus

Tee luokka Sanatutkimus, jolla voi tehdä erilaisia tutkimuksia tiedoston sisältämille sanoille. Toteuta luokka pakkaukseen sanatutkimus.

Kotimaisten kielten tutkimuskeskus (Kotus) on julkaissut netissä suomen kielen sanalistan. Tässä tehtävässä käytetään listan muokattua versiota, joka löytyy tehtäväpohjasta src-hakemistosta nimellä sanalista.txt, eli suhteellisesta polusta "src/sanalista.txt".

Tiedosto löytyy projektin päähakemistosta. Kun tiedosto on NetBeans-projektisi päähakemistossa, voit käyttää sen polkuna lyhyttä muotoa "sanalista.txt".

Mikäli sinulla on ongelmia ääkkösellisten sanojen kanssa (mac ja windows käyttäjät) luo Scanner -olio antaen sille parametrina merkistö "UTF-8" seuraavasti: Scanner lukija = new Scanner(tiedosto, "UTF-8"); Ongelmat liittyvät erityisesti testien suoritukseen.

Sanojen määrä

Luo Sanatutkimus-luokalle konstruktori public Sanatutkimus(File tiedosto) joka luo uuden Sanatutkimus-olion, joka tutkii parametrina annettavaa tiedostoa.

Tee luokkaan metodi public int sanojenMaara(), joka lukee tiedostossa olevat sanat ja tulostaa niiden määrän. Tässä vaiheessa sanoilla ei tarvitse tehdä mitään, riittää laskea niiden määrä. Voit olettaa, että tiedostossa on vain yksi sana riviä kohti.

z-kirjain

Tee luokkaan metodi public List<String> kirjaimenZSisaltavatSanat(), joka palauttaa tiedoston kaikki sanat, joissa on z-kirjain. Tällaisia sanoja ovat esimerkiksi jazz ja zombi.

l-pääte

Tee luokkaan metodi public List<String> kirjaimeenLPaattyvatSanat(), joka palauttaa tiedoston kaikki sanat, jotka päättyvät l-kirjaimeen. Tällaisia sanoja ovat esimerkiksi kannel ja sammal.

Huom! Jos luet tiedoston uudestaan ja uudestaan jokaisessa metodissa huomaat viimeistään tässä vaiheessa copy-paste koodia. Kannattaa miettiä olisiko tiedoston lukeminen helpompi tehdä osana konstruktoria tai metodina, jota konstruktori kutsuu. Metodeissa voitaisiin käyttää tällöin jo luettua listaa ja luoda siitä aina uusi, hakuehtoihin sopiva lista...

Palindromit

Tee luokkaan metodi public List<String> palindromit(), joka palauttaa tiedoston kaikki sanat, jotka ovat palindromeja. Tällaisia sanoja ovat esimerkiksi ala ja enne.

Kaikki vokaalit

Tee luokkaan metodi public List<String> kaikkiVokaalitSisaltavatSanat(), joka palauttaa tiedoston kaikki sanat, jotka sisältävät kaikki suomen kielen vokaalit (aeiouyäö). Tällaisia sanoja ovat esimerkiksi myöhäiselokuva ja ympäristönsuojelija.

Tiedostoon kirjoittaminen

Luokka FileWriter tarjoaa toiminnallisuuden tiedostoon kirjoittamiseen. Luokan FileWriter konstruktorille annetaan parametrina kohdetiedoston sijaintia kuvaava merkkijono.

        FileWriter kirjoittaja = new FileWriter("tiedosto.txt");
        kirjoittaja.write("Hei tiedosto!\n"); // rivinvaihto tulee myös kirjoittaa tiedostoon!
        kirjoittaja.write("Lisää tekstiä\n");
        kirjoittaja.write("Ja vielä lisää");
        kirjoittaja.close(); // sulkemiskutsu sulkee tiedoston ja varmistaa että kirjoitettu teksti menee tiedostoon

Esimerkissä kirjoitetaan tiedostoon "tiedosto.txt" merkkijono "Hei tiedosto!", jota seuraa rivinvaihto, ja vielä hieman lisää tekstiä. Huomaa että tiedostoon kirjoitettaessa metodi write ei lisää rivinvaihtoja, vaan ne tulee lisätä itse.

Sekä FileWriter-luokan konstruktori että write-metodi heittää mahdollisesti poikkeuksen, joka tulee joko käsitellä tai siirtää kutsuvan metodin vastuulle. Metodi, jolle annetaan parametrina kirjoitettavan tiedoston nimi ja kirjoitettava sisältö voisi näyttää seuraavalta.

public class Tallentaja {

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

Yllä olevassa kirjoitaTiedostoon-metodissa luodaan ensin FileWriter-olio, joka kirjoittaa parametrina annetussa sijainnissa sijaitsevaan tiedostoon tiedostonNimi. Tämän jälkeen kirjoitetaan tiedostoon write-metodilla. Konstruktorin ja write-metodin mahdollisesti heittämä poikkeus tulee käsitellä joko try-catch-lohkolla tai siirtämällä poikkeuksen käsittely vastuuta eteenpäin. Metodissa kirjoitaTiedostoon käsittelyvastuu on siirretty eteenpäin.

Luodaan main-metodi jossa kutsutaan Tallentaja-olion kirjoitaTiedostoon-metodia. Poikkeusta ei ole pakko käsitellä main-metodissakaan, vaan se voi ilmoittaa heittävänsä mahdollisesti poikkeuksen määrittelyllä throws Exception.

    public static void main(String[] args) throws Exception {
        Tallentaja tallentaja = new Tallentaja();
        tallentaja.kirjoitaTiedostoon("paivakirja.txt", "Rakas päiväkirja, tänään oli kiva päivä.");
    }

Yllä olevaa metodia kutsuttaessa luodaan tiedosto "paivakirja.txt" johon kirjoitetaan teksti "Rakas päiväkirja, tänään oli kiva päivä.". Jos tiedosto on jo olemassa, pyyhkiytyy vanhan tiedoston sisältö uutta kirjoittaessa. Metodilla append() voidaan lisätä olemassaolevan tiedoston perään tekstiä, jolloin olemassaolevaa tekstiä ei poisteta. Lisätään Tallentaja-luokalle metodi lisaaTiedostoon(), joka lisää parametrina annetun tekstin tiedoston loppuun.

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

    public void lisaaTiedostoon(String tiedostonNimi, String teksti) throws Exception {
        FileWriter kirjoittaja = new FileWriter(tiedostonNimi);
        kirjoittaja.append(teksti);
        kirjoittaja.close();
    }
}

Joskus tiedoston perään metodilla append kirjoittamisen sijasta on helpompi kirjoittaa koko tiedosto uudelleen.

Muistava sanakirja

Tässä tehtävässä laajennetaan aiemmin toteutettua sanakirjaa siten, että sanakirjaan voi lukea sanat tiedostosta sekä tallettaa sanat tiedostoon. Tehtävänäsi on luoda luokka OmaMuistavaSanakirja, joka toteuttaa tehtäväpohjassa annetun rajapinnan MuistavaSanakirja. Toteuta luokka pakkaukseen sanakirja, jossa rajapintakin sijaitsee.

Tehtäväpohjan rajapinnan koodi:

package sanakirja;

public interface MuistavaSanakirja {
    void lisaa(String sana, String kaannos);
    String kaanna(String sana);
    void poista(String sana);

    void lataa() throws IOException;
    void talleta() throws IOException;
}

Rajapinnan metodien kuvaukset:

Voit kopioida metodien lisaa ja kaanna toteutukset suoraan aiemmasta sanakirjatehtävästä. Toteuta näiden lisäksi metodi poista.

Sanojen luku tiedostosta

Toteuta luokalle OmaMuistavaSanakirja konstruktori, joka ottaa ainoaksi parametrikseen tiedoston nimen merkkijonona.

Toteuta sanakirjalle myös metodi lataa, joka lukee sanakirjan sisällön tiedostosta (jonka nimi on annettu konstruktorissa).

Tiedosto koostuu tekstiriveistä jossa on sana-käännös-pareja:

olut beer
apina monkey
ohjelmoija programmer
opiskelija student

Huom: jotta ohjelma löytää tiedoston, pitää se sijoittaa projektin juureen. Tämä onnistuu valitsemalla NetBeansista file -> new file -> other. Sanakirjaprojektin tulee olla pääprojektina kun luot tiedoston. Voit editoida tiedostoa joko NetBeansilla tai tekstieditorilla.

Huom2: voit lukea syötteen kahdella tavalla, joko Scannerin metodilla next yksittäinen sana kerrallaa tai nextLine:llä rivi kerrallaan. Jos luet rivi kerrallaan, pitää rivi hajoittaa kahdeksi merkkijonoksi (esim. "olut beer" -> "olut", "beer") jotta lisäys sanakirjaan onnistuu. Tämä voidaan tehdä esim. split-komennolla:

   String rivi = lukija.nextLine();
   String[] osat = rivi.split(" ");
   // nyt osat[0] on rivin ensimmäinen sana ja osat[1] toinen

Sanakirjaa käytetään siis esimerkiksi näin:

    public static void main(String[] args) {
        MuistavaSanakirja sanakirja = new OmaMuistavaSanakirja("suomi-englanti.txt");
        sanakirja.lataa();

        // voit testata tässä käännöksiä
    }

Sanojen talletus tiedostoon

Tee sanakirjalle metodi talleta, jota kutsuttaessa sanakirjan sisältö kirjoitetaan tiedostoon.

Talletus kannattanee hoitaa siten, että koko käännöslista kirjoitetaan uudelleen vanhan tiedoston päälle, eli materiaalissa esiteltyä append-komentoa ei kannata käyttää.

Varmista että ohjelmasi toimii, eli että uuden käynnistyksen jälkeen edellisessä suorituksessa talletetut sanat löytyvät sanakirjasta.

Talletusominaisuutta voi käyttää vaikkapa näin:

    public static void main(String[] args) {
        MuistavaSanakirja sanakirja = new OmaMuistavaSanakirja("suomi-englanti.txt");
        sanakirja.lataa();

        // lisää uusia sanoja, poista vanhoja

        sanakirja.talleta();
    }

Olematon tiedosto

Muokaa vielä ohjelmasi sellaiseksi, että metodi lataa toimii (eli ei tee mitään) jos sanakirjatiedostoa ei ole vielä olemassa. Voit poistaa sanakirjan esim. valitsemalla window -> files -> delete.

Joukoista ja hajautustauluista

Rajapinta Set kuvaa joukon toiminnallisuutta. Joukossa on kutakin alkioita korkeintaan yksi kappale, eli yhtäkään samanlaista oliota ei ole kahdesti. Olioiden samankaltaisuuden tarkistaminen toteutetaan equals ja hashCode -metodeja käyttämällä. Ehdimme jo aiemmin pikaisesti tutustua HashSet-luokkaan, joka on eräs Javan Set-rajapinnan toteutus. Toteutetaan sen avulla luokka Tehtavakirjanpito, joka tarjoaa mahdollisuuden tehtävien kirjanpitoon ja tehtyjen tehtävien tulostamiseen. Oletetaan että tehtävät ovat aina kokonaislukuja.

public class Tehtavakirjanpito {
    private Set<Integer> tehdytTehtavat;

    public Tehtavakirjanpito() {
        this.tehdytTehtavat = new HashSet<Integer>();
    }

    public void lisaa(int tehtava) {
        this.tehdytTehtavat.add(tehtava);
    }

    public void tulosta() {
        for (int tehtava: this.tehdytTehtavat) {
            System.out.println(tehtava);
        }
    }
}
        Tehtavakirjanpito kirjanpito = new Tehtavakirjanpito();
        kirjanpito.lisaa(1);
        kirjanpito.lisaa(1);
        kirjanpito.lisaa(2);
        kirjanpito.lisaa(3);

        kirjanpito.tulosta();
1
2
3

Yllä oleva ratkaisu toimii tilanteessa, jossa emme tarvitse tietoa käyttäjistä eri käyttäjien tekemistä tehtävistä. Muutetaan tehtävien tallennuslogiikkaa siten, että tehtävät tallennetaan käyttäjäkohtaisesti hajautustaulua hyödyntäen. Käyttäjät tunnistetaan käyttäjän yksilöivällä merkkijonolla (esimerkiksi opiskelijanumero), ja jokaiselle käyttäjälle on oma joukko tehdyistä tehtävistä.

public class Tehtavakirjanpito {
    private Map<String, Set<Integer>> tehdytTehtavat;

    public Tehtavakirjanpito() {
        this.tehdytTehtavat = new HashMap<String, Set<Integer>>();
    }

    public void lisaa(String kayttaja, int tehtava) {
        if (!this.tehdytTehtavat.containsKey(kayttaja)) {
            this.tehdytTehtavat.put(kayttaja, new HashSet<Integer>());
        }

        Set<Integer> tehdyt = this.tehdytTehtavat.get(kayttaja);
        tehdyt.add(tehtava);
    }

    public void tulosta() {
        for (String kayttaja: this.tehdytTehtavat.keySet()) {
            System.out.println(kayttaja + ": " + this.tehdytTehtavat.get(kayttaja));
        }
    }
}
        Tehtavakirjanpito kirjanpito = new Tehtavakirjanpito();
        kirjanpito.lisaa("Mikael", 3);
        kirjanpito.lisaa("Mikael", 4);
        kirjanpito.lisaa("Mikael", 3);
        kirjanpito.lisaa("Mikael", 3);

        kirjanpito.lisaa("Pekka", 4);
        kirjanpito.lisaa("Pekka", 4);

        kirjanpito.lisaa("Matti", 1);
        kirjanpito.lisaa("Matti", 2);

        kirjanpito.tulosta();
Matti: [1, 2]
Pekka: [4]
Mikael: [3, 4]

Huomaamme että käyttäjien nimet eivät tulostu esimerkiksi järjestyksessä. Tämä selittyy sillä että HashMap-tyyppisessä hajautustaulussa alkioiden tallennus tapahtuu hashCode-metodin palauttaman hajautusarvon perusteella, eikä se liity millään tavalla alkioiden järjestykseen. Tehdään vielä toinen testi, jossa tarkistetaan päteekö sama myös HashSet-joukolle. Luodaan uusi HashSet-joukko, ja lisätään siihen merkkijonoja.

        Set<String> nimet = new HashSet<String>();
        nimet.add("Matti");
        nimet.add("Mikael");
        nimet.add("Pekka");
        nimet.add("Arto");

        System.out.println(nimet);
[Arto, Matti, Pekka, Mikael]

Alkiot eivät ole järjestyksessä. Järjestyksessä olevat tulostukset ovat yleensä ottaen paljon mielekkäämpiä kuin ei-järjestyksessä olevat, ja olemmekin käyttäneet kurssilla jo aikaa Comparable- ja Comparator-rajapintojen kanssa.

TreeSet

Rajapinnan Set toteuttava luokka TreeSet pitää joukossa olevia alkioita järjestyksessä. Jos TreeSet-luokan konstruktorille ei anneta parametreja, järjestetään siihen lisättävän alkiot niiden luonnollisen järjestyksen mukaan, eli Comparable-rajapinnan määräämän järjestyksen mukaan. Muutetaan edellä ollutta esimerkkiä siten että käytämme TreeSet-oliota nimien tallentamiseen.

        Set<String> nimet = new TreeSet<String>();
        nimet.add("Matti");
        nimet.add("Mikael");
        nimet.add("Pekka");
        nimet.add("Arto");

        System.out.println(nimet);
[Arto, Matti, Mikael, Pekka]

Luokan TreeSet parametritonta konstruktoria käytettäessä tallennettavien olioiden tulee toteuttaa rajapinta Comparable. Jos oliot eivät toteuta rajapintaa Comparable ja haluaisimme silti ne järjestykseen, voimme luoda luokasta TreeSet ilmentymän joka ottaa Comparator-rajapinnan toteuttavan olion konstruktorin parametrina. Tällöin kaikki alkiot järjestetään Comparator-rajapinnan toteuttavan olion määräämään järjestykseen.

Luodaan oma Comparator-rajapinnan toteutus, joka järjestää kääntää merkkijonot ja järjestää merkkijonot niiden käänteisessä järjestyksessä. Luokka KaanteinenJarjestys käytännössä järjestää merkkijonot siis vertailemalla ensin merkkijonojen viimeistä kirjainta, sitten toiseksi viimeistä jne -- mutta on niin fiksu että osaa hyödyntää String-luokan valmiina tarjoamaa compareTo-metodia.

public class KaanteinenJarjestys implements Comparator<String> {

    @Override
    public int compare(String t, String t1) {
        t = kaanna(t);
        t1 = kaanna(t1);
        return t.compareTo(t1);
    }

    private String kaanna(String merkkijono) {
        if (merkkijono == null) {
            return "";
        }

        StringBuilder stringBuilder = new StringBuilder(merkkijono);
        stringBuilder.reverse();
        return stringBuilder.toString();
    }
}
        Set<String> nimet = new TreeSet<String>(new KaanteinenJarjestys());
        nimet.add("Matti");
        nimet.add("Mikael");
        nimet.add("Pekka");
        nimet.add("Arto");

        System.out.println(nimet);
[Pekka, Matti, Mikael, Arto]

Duplikaattien poistaja

Tehtävänäsi on toteuttaa pakkaukseen tyokalut luokka OmaDuplikaattienPoistaja, joka tallettaa annetut merkkijonot siten, että annetuista merkkijonoista poistetaan samanlaiset merkkijonot (eli duplikaatit). Lisäksi luokka pitää kirjaa duplikaattien määrästä. Luokan tulee toteuttaa tehtäväpohjassa annettu rajapinta DuplikaattienPoistaja, jossa on seuraavat toiminnot:

Rajapinnan koodi:

package tyokalut;

import java.util.Set;

public interface DuplikaattienPoistaja {
    void lisaa(String merkkijono);
    int getHavaittujenDuplikaattienMaara();
    Set<String> getUniikitMerkkijonot();
    void tyhjenna();
}

Rajapintaa voi käyttää esimerkiksi näin:

    public static void main(String[] args) {
        DuplikaattienPoistaja poistaja = new OmaDuplikaattienPoistaja();
        poistaja.lisaa("eka");
        poistaja.lisaa("toka");
        poistaja.lisaa("eka");

        System.out.println("Duplikaattien määrä nyt: " +
            poistaja.getHavaittujenDuplikaattienMaara());

        poistaja.lisaa("vika");
        poistaja.lisaa("vika");
        poistaja.lisaa("uusi");

        System.out.println("Duplikaattien määrä nyt: " +
            poistaja.getHavaittujenDuplikaattienMaara());

        System.out.println("Uniikit merkkijonot: " +
            poistaja.getUniikitMerkkijonot());

        poistaja.tyhjenna();

        System.out.println("Duplikaattien määrä nyt: " +
            poistaja.getHavaittujenDuplikaattienMaara());

        System.out.println("Uniikit merkkijonot: " +
            poistaja.getUniikitMerkkijonot());
    }

Yllä oleva ohjelma tulostaisi: (merkkijonojen järjestys saa vaihdella, sillä ei ole merkitystä)

Duplikaattien määrä nyt: 1
Duplikaattien määrä nyt: 2
Uniikit merkkijonot: [eka, toka, vika, uusi]
Duplikaattien määrä nyt: 0
Uniikit merkkijonot: []

TreeMap

Joukkojen järjestyksessä pitäminen onnistuu Set rajapinnan toteuttavan TreeSet-olion avulla. Aiemmassa Tehtavakirjanpito-esimerkissä henkilökohtaiset tehtäväpisteet tallennettiin Map-rajapinnan toteuttavaan HashMap-olioon. Kuten HashSet, HashMap ei pidä alkioita järjestyksessä. Rajapinnasta Map on olemassa toteutus TreeMap, jossa hajautustaulun avaimia pidetään järjestyksessä. Muutetaan Tehtavakirjanpito-luokkaa siten, että henkilökohtaiset pisteet tallennetaan TreeMap-tyyppiseen hajautustauluun.

public class Tehtavakirjanpito {
    private Map<String, Set<Integer>> tehdytTehtavat;

    public Tehtavakirjanpito() {
        this.tehdytTehtavat = new TreeMap<String, Set<Integer>>();
    }

    public void lisaa(String kayttaja, int tehtava) {
        if (!this.tehdytTehtavat.containsKey(kayttaja)) {
            this.tehdytTehtavat.put(kayttaja, new TreeSet<Integer>());
        }

        Set<Integer> tehdyt = this.tehdytTehtavat.get(kayttaja);
        tehdyt.add(tehtava);
    }

    public void tulosta() {
        for (String kayttaja: this.tehdytTehtavat.keySet()) {
            System.out.println(kayttaja + ": " + this.tehdytTehtavat.get(kayttaja));
        }
    }
}

Muunsimme samalla Set-rajapinnan toteutukseksi TreeSet-luokan. Huomaa että koska olimme käyttäneet rajapintoja, muutoksia tuli hyvin pieneen osaan koodista. Etsi kohdat jotka muuttuivat!

Käyttäjäkohtaiset tehtävät voidaan nyt tulostaa järjestyksessä.

        Tehtavakirjanpito kirjanpito = new Tehtavakirjanpito();
        kirjanpito.lisaa("Mikael", 3);
        kirjanpito.lisaa("Mikael", 4);
        kirjanpito.lisaa("Mikael", 3);
        kirjanpito.lisaa("Mikael", 3);

        kirjanpito.lisaa("Pekka", 4);
        kirjanpito.lisaa("Pekka", 4);

        kirjanpito.lisaa("Matti", 1);
        kirjanpito.lisaa("Matti", 2);

        kirjanpito.tulosta();
Matti: [1, 2]
Mikael: [3, 4]
Pekka: [4]

Luokka TreeMap vaatii että avaimena käytetyn luokan tulee toteuttaa Comparable-rajapinta. Jos luokka ei toteuta rajapintaa Comparable, voidaan luokalle TreeMap antaa konstruktorin parametrina Comparator-luokan toteuttama olio aivan kuten TreeSet-luokalle.

Sanakirja usealle käännökselle

Jatketaan vielä sanakirjan laajentamista. Tehtävänäsi on toteuttaa pakkaukseen sanakirja luokka OmaUseanKaannoksenSanakirja, joka voi tallettaa useamman käännöksen samalle sanalle. Luokan tulee toteuttaa tehtäväpohjassa annettu rajapinta UseanKaannoksenSanakirja, jossa on seuraavat toiminnot:

Rajapinnan koodi:

package sanakirja;

import java.util.Set;

public interface UseanKaannoksenSanakirja {
    void lisaa(String sana, String kaannos);
    Set<String> kaanna(String sana);
    void poista(String sana);
}
    UseanKaannoksenSanakirja sanakirja = new OmaUseanKaannoksenSanakirja();
    sanakirja.lisaa("kuusi", "six");
    sanakirja.lisaa("kuusi", "spruce");

    sanakirja.lisaa("pii", "silicon");
    sanakirja.lisaa("pii", "pi");

    System.out.println(sanakirja.kaanna("kuusi"));
    sanakirja.poista("pii");
    System.out.println(sanakirja.kaanna("pii"));
[six, spruce]
null

Sähköposteja

Tehtävänäsi on toteuttaa sähköpostiohjelmaan komponentti, joka säilöö viestejä. Tehtäväpohjan mukana tulee luokka Sahkoposti, joka esittää sähköpostiviestiä. Luokalla Sahkoposti on oliomuuttujat:

Toteutetaan tässä luokka Viestivarasto, joka tarjoaa sähköpostien hallintaan liittyviä toimintoja.

Viestivarasto, lisääminen ja hakeminen

Luo pakkaukseen posti luokka Viestivarasto, ja lisää sille seuraavat metodit:

Voit olettaa että millään kahdella viestillä ei ole samaa otsikkoa.

Ajan perusteella hakeminen

Lisää luokkaan Viestivarasto seuraavat metodit

Huom! Kannattaa käyttää kahta erillistä rakennetta viestien tallentamiseen. Otsikon perusteella tallentamiseen voit käyttää HashMappia, ja viestien tallentamiseen ajan mukaan TreeMappia. Näin saat toteutettua hae-operaatiot tehokkaasti. Tutustu myös TreeMapin metodeihin lastKey() ja floorKey().

Numerotiedustelu

Tehdään sovellus jonka avulla on mahdollista hallinnoida ihmisten puhelinnumeroita ja osoitteita.

Tehtävän voi suorittaa 1-4 pisteen laajuisena. Yhden pisteen laajuuteen on toteutettava seuraavat toiminnot:

kahteen pisteeseen vaadittaan edellisten lisäksi

kolmeen pisteeseen vaadittaan toiminto

ja täysiin pisteeseen vaaditaan vielä

Esimerkki ohjelman toiminnasta:

numerotiedustelu
käytettävissä olevat komennot:
 1 lisää numero
 2 hae numerot
 3 hae puhelinnumeroa vastaava henkilö
 4 lisää osoite
 5 hae henkilön tiedot
 6 poista henkilön tiedot
 7 filtteröity listaus
 x lopeta

komento: 1
kenelle: pekka
numero: 040-123456

komento: 2
kenen: jukka
  ei löytynyt

komento: 2
kenen: pekka
 040-123456

komento: 1
kenelle: pekka
numero: 09-222333

komento: 2
kenen: pekka
 040-123456
 09-222333

komento: 3
numero: 02-444123
 ei löytynyt

komento: 3
numero: 09-222333
 pekka

komento: 5
kenen: pekka
  osoite ei tiedossa
  puhelinnumerot:
   040-123456
   09-222333

komento: 4
kenelle: pekka
katu: ida ekmanintie
kaupunki: helsinki

komento: 5
kenen: pekka
  osoite: ida ekmanintie helsinki
  puhelinnumerot:
   040-123456
   09-222333

komento: 4
kenelle: jukka
katu: korsontie
kaupunki: vantaa

komento: 5
kenen: jukka
  osoite: korsontie vantaa
  ei puhelinta

komento: 7
hakusana (jos tyhjä, listataan kaikki): kk

 jukka
  osoite: korsontie vantaa
  ei puhelinta

 pekka
  osoite: ida ekmanintie helsinki
  puhelinnumerot:
   040-123456
   09-222333

komento: 7
hakusana (jos tyhjä, listataan kaikki): vantaa

 jukka
  osoite: korsontie vantaa
  ei puhelinta

komento: 7
hakusana (jos tyhjä, listataan kaikki): seppo
 ei löytynyt

komento: 6
kenet: jukka

komento: 5
kenen: jukka
  ei löytynyt

komento: x

Huomioita:

Olioiden monimuotoisuus

Olemme aiemmissa kappaleissa törmänneet tilanteisiin, joissa muuttujilla on oman tyyppinsä lisäksi muita tyyppejä. Esimerkiksi kappaleessa 45 huomasimme että kaikki oliot ovat tyyppiä Object. Jos olio on jotain tiettyä tyyppiä, esimerkiksi Object, voidaan se myös esittää Object-tyyppisenä muuttujana. Esimerkiksi String on myös tyyppiä Object, joten kaikki String-tyyppiset muuttujat voidaan esitellä Object tyypin avulla.

    String merkkijono = "merkkijono";
    Object merkkijonoString = "toinen merkkijono";

Merkkijono-olion asettaminen Object-tyyppiseen viitteeseen onnistuu.

    String merkkijono = "merkkijono";
    Object merkkijonoString = merkkijono;

Toiseen suuntaan asettaminen ei onnistu. Koska Object-tyyppiset muuttujat eivät ole tyyppiä String, ei object-tyyppistä muuttujaa voi asettaa String-tyyppiseen muuttujaan.

    Object merkkijonoString = "toinen merkkijono";
    String merkkijono = merkkijonoString; // EI ONNISTU!

Mistä tässä oikein on kyse?

Muuttujilla on oman tyyppinsä lisäksi aina perimiensä luokkien ja toteuttamiensa rajapintojen tyypit. Luokka String perii Object-luokan, joten String-oliot ovat aina myös tyyppiä Object. Luokka Object ei peri String-luokkaa, joten Object-tyyppiset muuttujat eivät ole automaattisesti tyyppiä String. Tutustutaan tarkemmin String-luokan API-dokumentaatioon, erityisesti HTML-sivun yläosaan.

String-luokan API-dokumentaatio alkaa yleisellä otsakkeella jota seuraa luokan pakkaus (java.lang). Pakkauksen jälkeen tulee luokan nimi (Class String), jota seuraa luokan perintähierarkia.

java.lang.Object
  java.lang.String

Perintähierarkia listaa luokat, jotka luokka on perinyt. Perityt luokat listataan perimisjärjestyksessä, tarkasteltava luokka aina alimpana. String-luokan perintähierarkiasta näemme, että String-luokka perii luokan Object. Javassa jokainen luokka voi periä korkeintaan yhden luokan, mutta välillisesti niitä voi periä useampia.

Perintähierarkiaa voi ajatella myös listana tyypeistä, joita olio toteuttaa.

Se, että kaikki oliot ovat tyyppiä Object helpottaa ohjelmointia. Jos tarvitsemme metodissa vain Object-luokassa määriteltyjä toimintoja, voimme käyttää metodin parametrina tyyppiä Object. Koska kaikki oliot ovat myös tyyppiä object, voi metodille antaa minkä tahansa olion parametrina. Luodaan metodi tulostaMonesti, joka saa parametrinaan Object-tyyppisen muuttujan ja tulostusten lukumäärän.

public class Tulostin {
    ...
    public void tulostaMonesti(Object object, int kertaa) {
        for (int i = 0; i < kertaa; i++) {
            System.out.println(object.toString());
        }
    }
    ...
}

Metodille tulostaMonesti voi antaa parametrina minkä tahansa olion. Metodin tulostaMonesti sisässä oliolla on käytössään vain Object-luokassa määritellyt metodit koska olio esitellään metodissa Object-tyyppisenä.

    Tulostin tulostin = new Tulostin();

    String merkkijono = " o ";
    List<String> sanat = new ArrayList<String>();
    sanat.add("polymorfismi");
    sanat.add("perintä");
    sanat.add("kapselointi");
    sanat.add("abstrahointi");

    tulostin.tulostaMonesti(merkkijono, 2);
    tulostin.tulostaMonesti(sanat, 3);
 o
 o
[polymorfismi, perintä, kapselointi, abstrahointi]
[polymorfismi, perintä, kapselointi, abstrahointi]
[polymorfismi, perintä, kapselointi, abstrahointi]

Olioiden samuus

Kaikki oliot ovat tyyppiä Object, joten minkä tahansa tyyppisen olion voi antaa parametrina Object-tyyppisiä parametreja vastaanottavalle metodille.

Tehtävän mukana tulee rajapinta Vertaaja. Toteuta pakkaukseen samuus luokka OlioidenVertaaja, joka toteuttaa rajapinnan Vertaaja. Metodien tulee toimia seuraavasti:

  • public boolean samaOlio(Object o1, Object o2) metodi palauttaa true, mikäli parametrina saatujen olioiden viitteet ovat samat, muutoin metodi palauttaa false. Olioiden viitteiden samuutta vertaillaan "=="-ilmaisulla.
  • public boolean vastaavat(Object o1, Object o2) metodi palauttaa true, mikäli parametrina saadut oliot ovat samanlaiset. Muutoin metodi palauttaa false. Käytä tässä vertailussa equals-metodia. Tarkemmin equals-metodin toiminnasta Javan Object luokan APIsta. Huomaa, että equals-metodin toiminta riippuu siitä, onko verrattavan olion luokka korvannut Object-luokassa määritellyn equals-metodin.
  • public boolean samaMerkkijonoEsitys(Object o1, Object o2) metodi palauttaa true, mikäli parametrina saatujen olioiden merkkijonoesitykset (metodin toString-palauttamat merkkijonot) ovat samat. Muutoin metodi palauttaa false.

Tehtävän mukana tulee luokka Henkilo, jossa equals- ja compareTo-metodit on korvattu. Kokeile toteuttamiesi metodien toimintaa seuraavalla esimerkkikoodilla.

   OlioidenVertaaja vertaaja = new OlioidenVertaaja();
   Henkilo henkilo1 = new Henkilo("221078-123X", "Pekka", "Helsinki");
   Henkilo henkilo2 = new Henkilo("221078-123X", "Pekka", "Helsinki");  // täysin samansisältöinen kuin eka
   Henkilo henkilo3 = new Henkilo("110934-123X", "Pekka", "Helsinki");  // eri pekka vaikka asuukin helsingissä

   System.out.println(vertaaja.samaOlio(henkilo1, henkilo1));
   System.out.println(vertaaja.samaOlio(henkilo1, henkilo2));
   System.out.println(vertaaja.vastaavat(henkilo1, henkilo2));
   System.out.println(vertaaja.vastaavat(henkilo1, henkilo3));
   System.out.println(vertaaja.samaMerkkijonoEsitys(henkilo1, henkilo2));

   Henkilo henkilo4 = new Henkilo("221078-123X", "Pekka", "Savonlinna"); // henkilo1:n pekka mutta asuinpaikka muuttuu

   System.out.println(vertaaja.samaOlio(henkilo1, henkilo4));
   System.out.println(vertaaja.vastaavat(henkilo1, henkilo4));
   System.out.println(vertaaja.samaMerkkijonoEsitys(henkilo1, henkilo4));

Ylläolevan koodin tulostuksen pitäisi olla seuraava:

true
false
true
false
true
false
true
false

Monimuotoisuus ja rajapinnat

Jatketaan String-luokan API-kuvauksen tarkastelua. Kuvauksessa olevaa perintähierarkiaa seuraa listaus luokan toteuttamista rajapinnoista.

All Implemented Interfaces:
  Serializable, CharSequence, Comparable<String>

Luokka String toteuttaa rajapinnat Serializable, CharSequence, ja Comparable<String>. Myös rajapinta on tyyppi. Luokan String API-kuvauksen mukaan String-olion tyypiksi voi asettaa seuraavat rajapinnat.

    Serializable serializableString = "merkkijono";
    CharSequence charSequenceString = "merkkijono";
    Comparable<String> comparableString = "merkkijono";

Koska metodeille voidaan määritellä metodin parametrin tyyppi, voimme määritellä metodeja jotka vastaanottavat tietyn rajapinnan toteuttavan olion. Kun metodille määritellään parametrina rajapinta, sille voidaan antaa parametrina mikä tahansa olio joka toteuttaa kyseisen rajapinnan -- metodi ei välitä olion oikeasta tyypistä.

Täydennetään Tulostin-luokkaa siten, että sillä on metodi CharSequence-rajapinnan toteuttavien olioiden merkkien tulostamiseen. Rajapinta CharSequence tarjoaa muunmuassa metodit int length(), jolla saa merkkijonon pituuden, ja char charAt(int index), jolla saa merkin tietyssä indeksissä.

public class Tulostin {
    ...
    public void tulostaMonesti(Object object, int kertaa) {
        for (int i = 0; i < kertaa; i++) {
            System.out.println(object.toString());
        }
    }

    public void tulostaMerkit(CharSequence charSequence) {
        for (int i = 0; i < charSequence.length(); i++) {
            System.out.println(charSequence.charAt(i);
        }
    }
    ...
}

Metodille tulostaMerkit voi antaa minkä tahansa CharSequence-rajapinnan toteuttavan olion. Näitä on muunmuassa String ja StringBuilder. Metodi tulostaMerkit tulostaa annetun olion jokaisen merkin omalle rivilleen.

    Tulostin tulostin = new Tulostin();

    StringBuilder sb = new StringBuilder();
    sb.append("toimii");

    tulostin.tulostaMerkit(sb);
t
o
i
m
i
i

Joukkoja

Tehtävän mukana tulee siirtymistoiminnallisuutta kuvaava rajapinta Siirrettava. Tehtävässä toteutat luokat Olio ja Lauma, jotka molemmat ovat siirrettäviä. Toteuta kaikki toiminnallisuus pakkaukseen siirrettava.

Olio -luokan toteuttaminen

Luo pakkaukseen siirrettava luokka Olio, joka toteuttaa rajapinnan Siirrettava. Olion tulee tietää oma sijaintinsa (ilmaistaan x, y -koordinaatteina. Luokan Olio APIn tulee olla seuraava:

  • public Olio(int x, int y)
    Luokan konstruktori, joka saa olion aloitussijainnin x- ja y-koordinaatit parametrina
  • public String toString()
    Luo ja palauttaa oliosta merkkijonoesityksen. Olion merkkijonoesityksen tulee olla seuraavanlainen "x: 3; y: 6". Huomaa että koordinaatit on erotettu puolipisteellä (;)
  • public void siirra(int dx, int dy)
    Siirtää oliota parametrina saatujen arvojen verran. Muuttuja dx sisältää muutoksen koordinaattiin x, muuttuja dy sisältää muutoksen koordinaattiin y. Esimerkiksi jos muuttujan dx arvo on 5, tulee oliomuuttujan x arvoa kasvattaa viidellä

Kokeile luokan Olio toimintaa seuraavalla esimerkkikoodilla.

     Olio olio = new Olio(20, 30);
     System.out.println(olio);
     olio.siirra(-10, 5);
     System.out.println(olio);
     olio.siirra(50, 20);
     System.out.println(olio);
x: 20; y: 30
x: 10; y: 35
x: 60; y: 55

Lauman toteutus

Luo seuraavaksi pakkaukseen siirrettava luokka Lauma, joka toteuttaa rajapinnan Siirrettava. Lauma koostuu useasta Siirrettava-rajapinnan toteutavasta oliosta, jotka tulee tallettaa esimerkiksi listarakenteeseen.

Luokalla Lauma tulee olla seuraavanlainen API.

  • public String toString()
    Palauttaa merkkijonoesityksen lauman jäsenten sijainnista riveillä erotettuna.
  • public void liitaLaumaan(Siirrettava siirrettava)
    Lisää laumaan uuden Siirrettava-rajapinnan toteuttavan olion
  • public void siirra(int dx, int dy)
    Siirtää laumaa parametrina saatujen arvojen verran. Huomaa että tässä sinun tulee siirtää jokaista lauman jäsentä.

Kokeile ohjelmasi toimintaa alla olevalla esimerkkikoodilla.

    Lauma lauma = new Lauma();
    lauma.liitaLaumaan(new Olio(73, 56));
    lauma.liitaLaumaan(new Olio(57, 66));
    lauma.liitaLaumaan(new Olio(46, 52));
    lauma.liitaLaumaan(new Olio(19, 107));
    System.out.println(lauma);
x: 73; y: 56
x: 57; y: 66
x: 46; y: 52
x: 19; y: 107

Olion tyyppi määrää kutsutun metodin: Polymorfismi

Olion käytössä olevat metodit määrittyvät muuttujan tyypin kautta. Esimerkiksi String-tyyppinen olio, joka on esitelty Object-tyyppisenä saa käyttöönsä vain Object-luokassa määritellyt metodit. Jos oliolla on monta eri tyyppiä, on sillä käytössä jokaisen tyypin määrittelemät metodit. Esimerkiksi String-tyyppisellä oliolla on käytössä String-luokassa määritellyt metodit, Object-luokassa määritellyt metodit, ja toteutettujen rajapintojen määrittelemät metodit.

Edellisessä tehtävässä toteutettiin metodi samaMerkkijonoEsitys kahden olion tulostuksen vertailuun. Metodille annetaan parametrina kaksi Object-tyyppistä muuttujaa, joiden toString-metodin tuottamia tulostuksia vertaillaan. Koska parametrit ovat tyyppiä Object, voi metodille antaa parametrina minkä tahansa olion. Laajennetaan Tulostin-luokkaa esimerkkiä varten.

public class Tulostin {
    private String merkki;

    public Tulostin(String merkki) {
        this.merkki = merkki;
    }

    public void tulostaMonesti(Object object, int kertaa) {
        for (int i = 0; i < kertaa; i++) {
            System.out.println(object.toString());
        }
    }

    public void tulostaMerkit(CharSequence charSequence) {
        for (int i = 0; i < charSequence.length(); i++) {
            System.out.println(charSequence.charAt(i);
        }
    }

    @Override
    public String toString() {
        return this.merkki;
    }
}

Luokan Tulostaja toString-metodi palauttaa siis aina konstruktorissa annetun parametrin. Edellisessä tehtävässä toteutetun samaMerkkijonoEsitys-metodin toteutus on kutakuinkin seuraava.

    public boolean samaMerkkijonoEsitys(Object eka, Object toka) {
        return eka.toString().equals(toka.toString());
    }
    OlioidenVertaaja vertaaja = new OlioidenVertaaja();

    String merkkijono = "tulostuksen vertailu";
    CharSequence merkkijonoSeq = "tulostuksen vertailu";

    if (vertaaja.samaMerkkijonoEsitys(merkkijono, merkkijonoSeq)) {
        System.out.println("Vertailu 1. Sama merkkijonoesitys");
    } else {
        System.out.println("Vertailu 1. Eri merkkijonoesitys");
    }

    Tulostin tulostin = new Tulostin("Cannon");
    if (vertaaja.samaMerkkijonoEsitys(merkkijono, tulostin)) {
        System.out.println("Vertailu 2. Sama merkkijonoesitys");
    } else {
        System.out.println("Vertailu 2. Eri merkkijonoesitys");
    }
Vertailu 1. Sama merkkijonoesitys
Vertailu 2. Eri merkkijonoesitys

Ensimmäisessä vertailussa metodille samaMerkkijonoEsitys annetaan parametrina String- ja CharSequence-tyyppiset muuttujat. Toisessa esimerkissä parametrina annetaan String- ja Tulostin-tyyppiset muuttujat. Metodin samaMerkkijonoEsitys parametrit ovat Object-tyyppisiä, ja metodissa voi käyttää vain Object-luokassa määriteltyjä metodeja.

Kysymykseksi nousee Eikö metodin samaMerkkijonoEsitys pitäisi käyttää Object-luokassa määriteltyä toString-metodia, jolloin metodin palauttaman merkkijonon pitäisi olla muotoa pakkaus.Luokannimi@...?

Vastaus on ei. Suoritettava metodi valitaan olion tyypin perusteella, ei muuttujan tyypin perusteella. Vaikka metodissa samaMerkkijonoEsitys käytetään Object-tyyppisiä muuttujia, voidaan metodille antaa parametrina minkä tahansa tyyppisiä olioita. Jos olion luokka (tai joku sen perimä luokka) korvaa metodin toString, valitaan käyttöön aina korvaava metodi Object-luokassa määritellyn sijaan.

Hieman yleisemmin: Suoritettava metodi valitaan aina olion perusteella riippumatta käytetyn muuttujan tyypistä. Oliot ovat monimuotoisia, eli olioita voi käyttää usean eri muuttujatyypin kautta. Suoritettava metodi liittyy aina olion todelliseen tyyppiin. Tätä monimuotoisuutta kutsutaan polymorfismiksi.

Tallennustoiminnallisuuden eriyttäminen

Ohjelmia rakennettaessa käytetään usein ohjelmarakennetta, jossa tiedon tallennusmekanismi kapseloidaan sovelluslogiikalta näkymättömäksi. Tallennustoiminnallisuuden piilottaminen sovelluslogiikalta perustellaan Single Responsibility Principle -periaatteella. Jokaisella luokalla tulee olla vain yksi syy muuttua: jos sovelluslogiikkaa tarjoava luokka hoitaisi sovelluslogiikan lisäksi tiedon tallentamiseen liittyvää toiminnallisuutta, olisi sillä jo ainakin kaksi vastuuta ja syytä muuttua: sovelluslogiikassa tapahtuva muutos ja tallennuslogiikassa tapahtuva muutos. Tämä rikkoo Single Responsibility Principle -periatteen. Haluamme että tallennukseen liittyvä toiminnallisuus toteutetaan sovelluslogiikasta erillisenä.

Yleisin tapa tallennuslogiikan eriyttämiseen on ns. DAO-suunnittelumalli. DAO-suunnittelumallissa tallennuslogiikan käyttäjälle tarjotaan rajapinta, jossa on määritelty toiminnallisuudet luomiseen, hakemiseen, päivittämiseen ja poistamiseen. Kunkin DAO-rajapinnan toteuttan vastuulla on yleensä aina tietyntyyppisten olioiden tallentaminen. Käytetään seuraavassa esimerkissä luokkaa Henkilo, jolla on oliomuuttuja nimi ja henkilön yksilöivä henkilötunnus.

public class Henkilo {

    private String nimi;
    private String henkilotunnus;

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

    public String getHenkilotunnus() {
        return henkilotunnus;
    }

    public String getNimi() {
        return nimi;
    }
}

Haluamme tallentaa henkilöitä. Määritellään Henkilo-luokan ilmentymien tallentamiseen liittyvä DAO-rajapinta, joka määrittelee toiminnallisuudet tallentamiseen, hakemiseen, ja poistamiseen.

import java.util.Collection;

public interface HenkiloDAO {
    void talleta(Henkilo henkilo);
    Henkilo hae(String henkilotunnus);

    void poista(Henkilo henkilo);
    void poista(String henkilotunnus);
    void poistaKaikki();

    Collection<Henkilo> haeKaikki();
}

Ohjelmoidessa lähdemme yleensä pienestä liikkelle. Luodaan rajapinnalle ensimmäinen toteutus, jossa tallennetaan vain yksi henkilö. Luokka YksinkertainenHenkiloDAO tarjoaa konkreettista toiminnallisuutta vain osaan rajapinnan HenkiloDAO määrittelemistä metodeista.

import java.util.ArrayList;
import java.util.Collection;

public class YksinkertainenHenkiloDAO implements HenkiloDAO {

    private Henkilo henkilo;

    @Override
    public void talleta(Henkilo henkilo) {
        this.henkilo = henkilo;
    }

    @Override
    public Henkilo hae(String henkilotunnus) {
        return this.henkilo;
    }

    @Override
    public void poista(Henkilo henkilo) {
        this.henkilo = null;
    }

    @Override
    public void poista(String henkilotunnus) {
        this.henkilo = null;
    }

    @Override
    public void poistaKaikki() {
        this.henkilo = null;
    }

    @Override
    public Collection<Henkilo> haeKaikki() {
        Collection<Henkilo> henkilot = new ArrayList<Henkilo>();
        if (this.henkilo != null) {
            henkilot.add(this.henkilo);
        }

        return henkilot;
    }
}

Yllä oleva HenkiloDAO-rajapinnan toteutus käsittelee vain yhtä Henkilo-olioa.

Mikä tässä on nyt niin hienoa?

Kuvittele seuraava ohjelmoinnissa usein eteen tuleva tilanne. Kehität useammasta luokasta koostuvaa ohjelmistoa ja haluat testata olioiden yhteistoiminnallisuutta ennen kuin koko ohjelma on valmis. Käyttäessäsi rajapintoja voit vaihtaa rajapinnan toteuttavaa luokkaa muuttamatta rajapintaa käyttävän luokan toiminnallisuutta. Tämä mahdollistaa ohjelman paloittain kehittämisen: voit aluksi toteuttaa vain pienen osan toiminnallisuudesta, ja testata ohjelman toimintaa sillä. Yllä olevaa tallentajaa voi käyttää esimerkiksi seuraavan käyttöliittymäluokan kautta:

import java.util.Scanner;

public class Kayttoliittyma {

    private Scanner lukija;
    private HenkiloDAO henkiloDao;

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

    public void kaynnista() {
        while (true) {
            System.out.println("Toiminnallisuudet: ");
            System.out.println("\t1. Lisää henkilö");
            System.out.println("\t2. Listaa henkilöt");
            System.out.println("\t3. Lopeta");

            System.out.println("");
            System.out.print("Syötä komento: ");
            int komento = Integer.parseInt(this.lukija.nextLine());
            if (komento == 3) {
                break;
            }
        }
    }

    private void hoidaKomento(int komento) {
        if (komento == 1) {
            lisaaHenkilo();
        } else if (komento == 2) {
            listaaHenkilot();
        }
    }

    private void lisaaHenkilo() {
        System.out.print("Anna nimi: ");
        String nimi = this.lukija.nextLine();
        System.out.print("Anna hetu: ");
        String hetu = this.lukija.nextLine();

        this.henkiloDao.talleta(new Henkilo(nimi, hetu));
    }

    private void listaaHenkilot() {
        System.out.println("Listataan: ");
        for (Henkilo henkilo : this.henkiloDao.haeKaikki()) {
            System.out.println("\t" + henkilo.getNimi());
        }
    }
}

Käyttöliittymässä tarjotaan toiminnallisuus vain lisäämiseen ja listaamiseen. Huomaa että missään päin käyttöliittymäluokkaa ei mainita luokkaa YksinkertainenHenkiloDAO. Henkilöiden lisääminen ja listaaminen tapahtuu HenkiloDAO-rajapinnan kautta. Käyttöliittymän käynnistävässä luokassa annetaan käyttöliittymälle HenkiloDAO-rajapinnan toteuttava luokka.

    Scanner lukija = new Scanner(System.in);
    HenkiloDAO henkiloDao = new YksinkertainenHenkiloDAO();

    Kayttoliittyma kayttoliittyma = new Kayttoliittyma(lukija, henkiloDao);
    kayttoliittyma.kaynnista();

Kun sovellukseen toteutettu pieni osa toimii hyvin, voidaan HenkiloDAO-rajapinnan toteutusta laajentaa tai sille voidaan luoda uusi toteutus. Luodaan seuraavaksi hajautustaulua käyttävä toteutus MuistiHenkiloDAO. Hajautustaulua käyttävässä toteutuksessa henkilöt tallennetaan hajautustaulun arvoina siten, että avaimena käytetään henkilön henkilötunnusta.

public class MuistiHenkiloDAO implements HenkiloDAO {
    private Map<String, Henkilo> henkilot;

    public MuistiHenkiloDAO() {
        this.henkilot = new HashMap<String, Henkilo>();
    }

    @Override
    public void talleta(Henkilo henkilo) {
        this.henkilot.put(henkilo.getHenkilotunnus(), henkilo);
    }

    @Override
    public Henkilo hae(String henkilotunnus) {
        return this.henkilot.get(henkilotunnus);
    }

    @Override
    public void poista(Henkilo henkilo) {
        poista(henkilo.getHenkilotunnus());
    }

    @Override
    public void poista(String henkilotunnus) {
        this.henkilot.remove(henkilotunnus);
    }

    @Override
    public void poistaKaikki() {
        this.henkilot.clear();
    }

    @Override
    public Collection<Henkilo> haeKaikki() {
        return this.henkilot.values();
    }
}

Huomaa että käyttöliittymäluokan koodiin ei tarvitse koskea. Käyttöliittymässä käytetyn HenkiloDAO-rajapinnan toteuttavan luokan vaihto tapahtuu helposti:

    Scanner lukija = new Scanner(System.in);
    HenkiloDAO henkiloDao = new MuistiHenkiloDAO();

    Kayttoliittyma kayttoliittyma = new Kayttoliittyma(lukija, henkiloDao);
    kayttoliittyma.kaynnista();

Rajapintojen käyttäminen ohjelmoinnissa, jonka erityistapaus juuri esitelty tallennuslogiikan lähestymistapa on, on mahtava tapa helpottaa ohjelman osatoiminnallisuuksien testaamista yhdessä. Lähestymistavan vahvuus tulee erityisesti esiin silloin, kun ohjelmaa on rakentamassa useampi ihminen samaan aikaan.

Data Access Object (DAO)

Harjoitellaan seuraavaksi Data Access Object-suunnittelumallia ja kerrataan tiedostojen käyttöä. Tässä tehtävässä toteutetaan valmiiksi annetusta AlbumiDAO-rajapinnasta kaksi erilaista toteutusta. Rajapinta määrittelee seuraavat toiminnot:

  • void talleta(Albumi albumi)
    Tallettaa annetun albumin rajapinnan toteutuksen edellyttämällä tavalla
  • Albumi hae(int id)
    Hakee annettua ID:tä vastaavan albumin ja palauttaa sen - jos vastaavaa albumia ei löydy, palautetaan null-viite
  • void poista(Albumi albumi)
    Poistaa annetun albumin ID:tä vastaavan albumin
  • void poista(int id)
    Poistaa annettua ID:tä vastaavan albumin
  • void poistaKaikki()
    Poistaa kaikki albumit
  • Collection<Albumi> haeKaikki()
    Palauttaa kaikki talletetut albumit

Rajapinnan koodi:

package dao;

import java.util.Collection;

public interface AlbumiDAO {
    void talleta(Albumi albumi);
    Albumi hae(int id);

    void poista(Albumi albumi);
    void poista(int id);
    void poistaKaikki();

    Collection<Albumi> haeKaikki();
}

Rajapinta käsittelee Albumi-olioita, joiden toteutus on seuraavanlainen:

package dao;

public class Albumi {
    private int id;
    private String artisti;
    private String nimi;
    private int julkaisuVuosi;

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getArtisti() {
        return artisti;
    }

    public void setArtisti(String artisti) {
        this.artisti = artisti;
    }

    public String getNimi() {
        return nimi;
    }

    public void setNimi(String nimi) {
        this.nimi = nimi;
    }

    public int getJulkaisuVuosi() {
        return julkaisuVuosi;
    }

    public void setJulkaisuVuosi(int julkaisuVuosi) {
        this.julkaisuVuosi = julkaisuVuosi;
    }

    @Override
    public String toString() {
        return id + ": " + artisti + " - " + nimi + " (" + julkaisuVuosi + ")";
    }
}

Muistiin tallettava DAO-toteutus

Tehtävänäsi on toteuttaa luokka MuistiAlbumiDAO, joka toteuttaa rajapinnan AlbumiDAO siten, että albumeita talletetaan muistiin.

Vinkki: Map-rajapinnan toteuttavasta tietorakenteesta on hyötyä tässä tehtävässä.

Toteutusta voi käyttää esimerkiksi näin:

        AlbumiDAO dao = new MuistiAlbumiDAO();

        Albumi albumi = new Albumi();
        albumi.setId(4);
        albumi.setArtisti("Porcupine Tree");
        albumi.setNimi("Deadwing");
        albumi.setJulkaisuVuosi(2005);

        dao.talleta(albumi);

        System.out.println("Albumi ID-numerolla 4: " + dao.hae(4));

        Albumi albumi2 = new Albumi();
        albumi2.setId(137);
        albumi2.setArtisti("Flying Colors");
        albumi2.setNimi("Flying Colors");
        albumi2.setJulkaisuVuosi(2012);

        dao.talleta(albumi2);

        System.out.println("Kaikki talletetut albumit: " + dao.haeKaikki());

        dao.poista(4);

        System.out.println("Kaikki talletetut albumit: " + dao.haeKaikki());

        dao.poista(albumi2);

        System.out.println("Kaikki talletetut albumit: " + dao.haeKaikki());

Ylläoleva esimerkkiohjelma tulostaa: (albumien järjestys saattaa vaihdella)

Albumi ID-numerolla 4: 4: Porcupine Tree - Deadwing (2005)
Kaikki talletetut albumit: [137: Flying Colors - Flying Colors (2012), 4: Porcupine Tree - Deadwing (2005)]
Kaikki talletetut albumit: [137: Flying Colors - Flying Colors (2012)]
Kaikki talletetut albumit: []

CSV-tiedostomuodon käsittelyä

Kertaa tässä vaiheessa kappale 53. Tiedostojen käsittely.

CSV (Comma Separated Values) on tiedostomuoto, jossa yhtä kokonaisuutta (esim. oliota) vastaa yksi rivi. Rivillä arvot (olioiden kentät) on eroteltu pilkuilla.

Tässä tehtävässä käytetystä Albumi-olioista on tarkoitus tuottaa seuraavan esimerkin kaltaisia CSV-tiedostoja:

4,Porcupine Tree,Deadwing,2005
137,Flying Colors,Flying Colors,2012

Pilkulla erotetut arvot ovat siis: ID, artistin nimi, albumin nimi ja julkaisuvuosi.

Huom: Tilannetta, jossa jokin arvo sisältäisi pilkun, ei tarvitse ottaa huomioon.

Tehtävänäsi on toteuttaa luokka OmaCSVKasittelija, joka toteuttaa rajapinnan CSVKasittelija. Rajapinnassa on seuraavat toiminnot:

  • void talleta(Collection<Albumi> albumit) throws IOException tallettaa annetut Albumi-oliot tiedostoon
  • Map<Integer, Albumi> lataa() throws IOException lukee tiedoston ja muodostaa siitä Albumi-olioita, joista muodostetaan Map<Integer, Albumi>-rajapinnan toteuttava olio siten, että avaimena on albumin ID kokonaislukuna. Metodin tulee palauttaa tyhjä Map-olio, jos tiedostoa ei löydy (OmaCSVKasittelija ei siis saa heittää poikkeusta tässä tilanteessa).

Luokalla OmaCSVKasittelija on lisäksi oltava konstruktori, jonka parametrina on käytettävän tiedoston nimi: public OmaCSVKasittelija(String tiedostonNimi).

Rajapinnan koodi:

package dao;

import java.io.IOException;
import java.util.Collection;
import java.util.Map;

public interface CSVKasittelija {
    void talleta(Collection<Albumi> albumit) throws IOException;
    Map<Integer, Albumi> lataa() throws IOException;
}

Luokkaa voi käyttää esimerkiksi näin:

    public static void main(String[] args) {
        Albumi albumi = new Albumi();
        albumi.setId(4);
        albumi.setArtisti("Porcupine Tree");
        albumi.setNimi("Deadwing");
        albumi.setJulkaisuVuosi(2005);

        Albumi albumi2 = new Albumi();
        albumi2.setId(137);
        albumi2.setArtisti("Flying Colors");
        albumi2.setNimi("Flying Colors");
        albumi2.setJulkaisuVuosi(2012);

        List<Albumi> albumit = new ArrayList<Albumi>();
        albumit.add(albumi);
        albumit.add(albumi2);

        CSVKasittelija csvKasittelija = new OmaCSVKasittelija("albumit.csv");

        try {
            csvKasittelija.talleta(albumit);
        } catch (IOException e) {
            System.out.println("Poikkeus tallettaessa: " + e);
            return;
        }

        Map<Integer, Albumi> ladatutAlbumit;
        try {
            ladatutAlbumit = csvKasittelija.lataa();
        } catch (IOException e) {
            System.out.println("Poikkeus ladatessa: " + e);
            return;
        }

        System.out.println("Tiedostosta ladattiin albumit: " + ladatutAlbumit);
    }

Ylläoleva esimerkkiohjelma tulostaa: (albumien järjestys saattaa vaihdella)

Tiedostosta ladattiin albumit: {137=137: Flying Colors - Flying Colors (2012), 4=4: Porcupine Tree - Deadwing (2005)}

Muistathan aina sulkea tiedoston tallennuksen lopuksi! Tällöin viimeisetkin muutokset päätyvät varmasti tiedostoon.

CSV-tiedostomuotoon tallettava DAO-toteutus (hitaampi versio)

Tee luokka HidasCSVAlbumiDAO, joka toteuttaa AlbumiDAO-rajapinnan. Luokan tulee toimia siten, että albumeita ei talleteta muistiin, vaan niitä luetaan tiedostosta ja kirjoitetaan tiedostoon (CSV-muodossa) aina tarvittaessa. Jokaisen talletus- ja hakuoperaation tulee ensin lukea albumit tiedostosta. Jos tiedostoa ei löydy, tai sitä ei saa luettua, tulee olettaa ettei albumeita ole vielä tallennettu. Hyödynnä edellisessä tehtävässä luotua OmaCSVKasittelija-luokkaa.

Luokalla HidasCSVAlbumiDAO täytyy olla seuraava konstruktori:

  • public HidasCSVAlbumiDAO(CSVKasittelija csvKasittelija) - luokassa käytetään talletukseen ja lukemiseen annettua CSVKasittelija-oliota.

Voit käyttää ensimmäisen kohdan esimerkkiä testatessa tätä toteutusta, koska kumpikin toteuttaa saman rajapinnan. Kannattaa avata luotu CSV-tiedosto NetBeansissa ja katsoa mitä ohjelma kirjoitti tiedostoon.

CSV-tiedostomuotoon tallettava DAO-toteutus (nopeampi versio)

Edellisen tehtävän toteutus on nimensä mukaisesti jokseenkin hidas, koska toteutus "unohtaa" aiemmin haetut tai tallennetut albumit. Tämän vuoksi albumit joudutaan lukemaan aina uudestaan tiedostosta, mikä on muistiin talletettujen olioiden käsittelyyn verrattuna moninkertaisesti hitaampaa.

Tee luokka NopeaCSVAlbumiDAO, joka toteuttaa AlbumiDAO-rajapinnan. Luokan tulee toimia siten, että annetun tiedoston sisältö luetaan heti konstruktorissa ja talletetaan muistiin (vastaavalla tavalla kuin MuistiAlbumiDAO). Tällöin albumeja haettaessa tiedostoa ei tarvitse (eikä pidä!) lukea ollenkaan. Talletettaessa tai poistettaessa albumeja muutokset pitää tehdä muistissa oleviin albumeihin sekä tallettaa tiedostoon.

Luokalla NopeaCSVAlbumiDAO täytyy olla seuraava konstruktori:

  • public NopeaCSVAlbumiDAO(CSVKasittelija csvKasittelija) - luokassa käytetään talletukseen ja lukemiseen annettua CSVKasittelija-oliota.

Voit käyttää ensimmäisen kohdan esimerkkiä testatessa tätä toteutusta, koska kumpikin toteuttaa saman rajapinnan. Kannattaa avata luotu CSV-tiedosto NetBeansissa ja katsoa mitä ohjelma kirjoitti tiedostoon.

Luokan ominaisuuksien periminen

Luokat ovat ohjelmoijan tapa ratkaistavan ongelma-alueen käsitteiden selkeyttämiseen. Lisäämme jokaisella luomallamme luokalla uutta toiminnallisuutta ohjelmointikieleen. Toiminnallisuutta tarvitaan kohtaamiemme ongelmien ratkomiseen -- ratkaisut syntyvät luokista luotujen olioiden välisen interaktion avulla. Olio-ohjelmoinnissa olio on itsenäinen kokonaisuus, jolla on olion tarjoamien metodien avulla muutettava tila. Olioita käytetään yhteistyössä; jokaisella oliolla on oma vastuualue. Esimerkiksi käyttöliittymäluokkamme ovat tähän mennessä hyödyntäneet Scanner-olioita.

Jokainen Javan luokka perii luokan Object, eli jokainen luomamme luokka saa käyttöönsä kaikki Object-luokassa määritellyt metodit. Jos haluamme muuttaa Object-luokassa määriteltyjen metodien toiminnallisuutta tulee ne korvata (Override) määrittelemällä niille uusi toteutus luodussa luokassa.

Luokan Object perimisen lisäksi myös muiden luokkien periminen on mahdollista. Javan ArrayList-luokan APIa tarkasteltaessa huomaamme että ArrayList perii luokan AbstractList. Luokka AbstractList perii luokan AbstractCollection, joka perii luokan Object.

java.lang.Object
  java.util.AbstractCollection<E>
      java.util.AbstractList<E>
          java.util.ArrayList<E>

Kukin luokka voi periä suoranaisesti yhden luokan. Välillisesti luokka kuitenkin perii kaikki perimänsä luokan ominaisuudet. Luokka ArrayList perii suoranaisesti luokan AbstractList, ja välillisesti luokat AbstractCollection ja Object. Luokalla ArrayList on siis käytössään luokkien AbstractList, AbstractCollection ja Object muuttujat, metodit ja rajapinnat.

Luokan ominaisuudet peritään avainsanalla extends. Luokan perivää luokkaa kutsutaan aliluokaksi (subclass), perittävää luokkaa yliluokaksi (superclass). Tutustutaan erään autonvalmistajan järjestelmään, joka hallinnoi auton osia. Osien hallinan peruskomponentti on luokka Osa, joka määrittelee tunnuksen, valmistajan ja kuvauksen.

public class Osa {

    private String tunnus;
    private String valmistaja;
    private String kuvaus;

    public Osa(String tunnus, String valmistaja, String kuvaus) {
        this.tunnus = tunnus;
        this.valmistaja = valmistaja;
        this.kuvaus = kuvaus;
    }

    public String getTunnus() {
        return tunnus;
    }

    public String getKuvaus() {
        return kuvaus;
    }

    public String getValmistaja() {
        return valmistaja;
    }
}

Yksi osa autoa on moottori. Kuten kaikilla osilla, myös moottorilla on valmistaja, tunnus ja kuvaus. Näiden lisäksi moottoriin liittyy moottorityyppi: esimerkiksi polttomoottori, sähkömoottori tai hybridi. Luodaan luokan Osa perivä luokka Moottori: moottori on osan erikoistapaus.

public class Moottori extends Osa {

    private String moottorityyppi;

    public Moottori(String moottorityyppi, String tunnus, String valmistaja, String kuvaus) {
        super(tunnus, valmistaja, kuvaus);
        this.moottorityyppi = moottorityyppi;
    }

    public String getMoottorityyppi() {
        return moottorityyppi;
    }
}

Luokkamäärittely public class Moottori extends Osa kertoo että luokka Moottori perii luokan Osa toiminnallisuuden. Luokassa Moottori määritellään oliomuuttuja moottorityyppi.

Moottori-luokan konstruktori on mielenkiintoinen. Konstruktorin ensimmäisellä rivillä on avainsana super, jolla kutsutaan yliluokan konstruktoria. Kutsu super(tunnus, valmistaja, kuaus) kutsuu luokassa Osa määriteltyä konstruktoria public Osa(String tunnus, String valmistaja, String kuvaus, jolloin yliluokassa määritellyt oliomuuttujat saavat arvonsa. Tämän jälkeen oliomuuttujalle moottorityyppi asetetaan siihen liittyvä arvo.

Kun luokka Moottori perii luokan Osa, saa se käyttöönsä kaikki luokan Osa tarjoamat metodit. Luokasta Moottori voi tehdä ilmentymän aivan kuten mistä tahansa muustakin luokata.

        Moottori moottori = new Moottori("polttomoottori", "hz", "volkswagen", "VW GOLF 1L 86-91");
        System.out.println(moottori.getMoottorityyppi());
        System.out.println(moottori.getValmistaja());
polttomoottori
volkswagen

Kuten huomaat, luokalla Moottori on käytössä luokassa Osa määritellyt metodit. Huom! Jos metodilla tai muuttujalla on näkyvyysmääre private, ei se näy aliluokille.

Yliluokka super

Yliluokan konstruktoria kutsutaan avainsanalla super. Kutsu super on käytännössä samanlainen kuin this-konstruktorikutsu. Kutsulle annetaan parametrina konstruktorin vaatimat oliot.

Konstruktoria kutsuttaessa yliluokassa määritellyt muuttujat alustetaan. Konstruktorikutsussa tapahtuu käytännössä täysin samat asiat kuin normaalissa konstruktorikutsussa. Jos yliluokassa ei ole määritelty parametritonta konstruktoria, tulee aliluokan konstruktorikutsuissa olla aina mukana yliluokan konstruktorikutsu.

Huom! Kutsun super tulee olla aina konstruktorin ensimmäisellä rivillä!

Yliluokassa määriteltyjä metodeja voi kutsua super-etuliitteen avulla, aivan kuten tässä luokassa määriteltyjä metodeja voi kutsua this-etuliitteellä. Esimerkiksi yliluokassa määriteltyä toString-metodia voi hyödyntää seuraavasti.

    @Override
    public String toString() {
        return super.toString() + "\n  Ja oma viestini vielä!";
    }

Henkilö ja sen perilliset

Henkilo

Tee pakkaus henkilot ja sinne luokka Henkilo, joka toimii seuraavan pääohjelman yhteydessä

    public static void main(String[] args) {
        Henkilo pekka = new Henkilo("Pekka Mikkola", "Korsontie 1 03100 Vantaa");
        Henkilo esko = new Henkilo("Esko Ukkonen", "Mannerheimintie 15 00100 Helsinki");
        System.out.println(pekka);
        System.out.println(esko);
    }

siten että tulostuu

Pekka Mikkola
  Korsontie 1 03100 Vantaa
Esko Ukkonen
  Mannerheimintie 15 00100 Helsinki

Opiskelija

Tee pakkaukseen luokka Opiskelija joka perii luokan Henkilo.

Opiskelijalla on aluksi 0 opintopistettä. Aina kun opiskelija opiskelee, kasvaa opintopistemäärä. Toteuta luokka siten, että seuraava pääohjelma:

    public static void main(String[] args) {
        Opiskelija olli = new Opiskelija("Olli", "Ida Albergintie 1 00400 Helsinki");
        System.out.println(olli );
        System.out.println("opintopisteitä " + olli.opintopisteita());
        olli.opiskele();
        System.out.println("opintopisteitä "+ olli.opintopisteita());
    }

tuottaa tulostuksen:

Olli
  Ida Albergintie 1 00400 Helsinki
opintopisteitä 0
opintopisteitä 1

Opiskelijalle toString

Edellisessä tehtävässä Opiskelija perii toString-metodin luokalta Henkilo. Perityn metodin voi myös ylikirjoittaa, eli korvata omalla versiolla. Tee nyt Opiskelija:lle oma versio toString:istä joka toimii seuraavan esimerkin mukaan:

    public static void main(String[] args) {
        Opiskelija olli = new Opiskelija("Olli", "Ida Albergintie 1 00400 Helsinki");
        System.out.println( olli );
        olli.opiskele();
        System.out.println( olli );
    }

Tulostuu:

Olli
  Ida Albergintie 1 00400 Helsinki
  opintopisteitä 0
Olli
  Ida Albergintie 1 00400 Helsinki
  opintopisteitä 1

Opettaja

Tee pakkaukseen luokka Henkilo:n perivä luokka Opettaja. Opettajalla on palkka joka tulostuu opettajan merkkijonoesityksessä.

Testaa, että seuraava pääohjelma

    public static void main(String[] args) {
        Opettaja pekka = new Opettaja("Pekka Mikkola", "Korsontie 1 03100 Vantaa", 1200);
        Opettaja esko = new Opettaja("Esko Ukkonen", "Mannerheimintie 15 00100 Helsinki", 5400);
        System.out.println( pekka );
        System.out.println( esko );

        Opiskelija olli = new Opiskelija("Olli", "Ida Albergintie 1 00400 Helsinki");
        for ( int i=0; i < 25; i++ )
            olli.opiskele();
        System.out.println( olli );
    }

Aikaansaa tulostuksen

Pekka Mikkola
  Korsontie 1 03100 Vantaa
  palkka 1200 euroa/kk
Esko Ukkonen
  Mannerheimintie 15 00100 Helsinki
  palkka 5400 euroa/kk
Olli
  Ida Albergintie 1 00400 Helsinki
  opintopisteitä 25

Kaikki Henkilot listalle

Toteuta oletuspakkauksessa olevaan Main-luokkaan luokkametodi public static void tulostaLaitoksenHenkilot(List<Henkilo> henkilot), joka tulostaa kaikki metodille parametrina annetussa listassa olevat henkilöt. Metodin tulee toimia seuraavasti main-metodista kutsuttaessa.

    public static void tulostaLaitoksenHenkilot(List<Henkilo> henkilot) {
       // tulostetaan kaikki listan henkilöt
    }

    public static void main(String[] args) {
        List<Henkilo> henkilot = new ArrayList<Henkilo>();
        henkilot.add( new Opettaja("Pekka Mikkola", "Korsontie 1 03100 Vantaa", 1200) );
        henkilot.add( new Opiskelija("Olli", "Ida Albergintie 1 00400 Helsinki") );

        tulostaLaitoksenHenkilot(henkilot);
    }
Pekka Mikkola
  Korsontie 1 03100 Vantaa
  palkka 1200 euroa/kk
Olli
  Ida Albergintie 1 00400 Helsinki
  opintopisteitä 0

Milloin perintää tulee käyttää?

Perintä on väline käsitehierarkioiden rakentamiseen ja erikoistamiseen; aliluokka on aina yliluokan erikoistapaus. Jos luotava luokka on olemassaolevan luokan erikoistapaus, voidaan uusi luokka luoda perimällä olemassaoleva luokka. Esimerkiksi auton osiin liittyvässä esimerkissä moottori on osa, mutta moottoriin liittyy lisätoiminnallisuutta mitä jokaisella osalla ei ole.

Perittäessä aliluokka saa käyttöönsä yliluokan toiminnallisuudet. Jos aliluokka ei tarvitse tai käytä perittyä toiminnallisuutta, ei perintä ole perusteltua. Perityt luokat perivät yliluokkiensa metodit ja rajapinnat, eli aliluokkia voidaan käyttää missä tahansa missä yliluokkaa on käytetty. Perintähierarkia kannattaa mitää matalana, sillä hierarkian ylläpito ja jatkokehitys vaikeutuu perintöhierarkian kasvaessa. Yleisesti ottaen, jos perintähierarkian korkeus on yli 5, ohjelman rakenteessa on todennäköisesti parannettavaa.

Perinän käyttöä tulee miettiä. Esimerkiksi luokan Auto periminen luokasta Osa (tai Moottori) on väärin. Auto sisältää moottorin ja osia, mutta auto ei ole moottori tai osa. Voimme yleisemmin ajatella että jos olio omistaa tai koostuu toisista olioista, ei perintää tule käyttää.

Perintää käytettäessä tulee varmistaa että Single Responsibility Principle pätee myös perittäessä. Jokaisella luokalla tulee olla vain yksi syy muuttua. Jos huomaat että perintä lisää luokan vastuita, tulee luokka pilkkoa useammaksi luokaksi.

Esimerkki: perinnän väärinkäyttö

Pohditaan postituspalveluun liittyviä luokkia Asiakas, joka sisältää asiakkaan tiedot, ja Tilaus, joka perii asiakkaan tiedot ja sisältää tilattavan tavaran tiedot. Luokassa Tilaus on myös metodi postitusOsoite, joka kertoo tilauksen postitusosoitteen.

public class Asiakas {

    private String nimi;
    private String osoite;

    public Asiakas(String nimi, String osoite) {
        this.nimi = nimi;
        this.osoite = osoite;
    }

    public String getNimi() {
        return nimi;
    }

    public String getOsoite() {
        return osoite;
    }

    public void setOsoite(String osoite) {
        this.osoite = osoite;
    }
}
public class Tilaus extends Asiakas {

    private String tuote;
    private String lukumaara;

    public Tilaus(String tuote, String lukumaara, String nimi, String osoite) {
        super(nimi, osoite);
        this.tuote = tuote;
        this.lukumaara = lukumaara;
    }

    public String getTuote() {
        return tuote;
    }

    public String getLukumaara() {
        return lukumaara;
    }

    public String postitusOsoite() {
        return this.getNimi() + "\n" + this.getOsoite();
    }
}

Yllä perintää on käytetty väärin. Luokkaa perittäessä aliluokan tulee olla yliluokan erikoistapaus; tilaus ei ole asiakkaan erikoistapaus. Väärinkäyttö ilmenee single responsibility principlen rikkomisena: luokalla Tilaus on vastuu sekä asiakkaan tietojen ylläpidosta, että tilauksen tietojen ylläpidosta.

Ratkaisussa piilevä ongelma tulee esiin kun mietimme mitä käy asiakkaan osoitteen muuttuessa.

Osoitteen muuttuessa joudumme muuttamaan jokaista kyseiseen asiakkaaseen liittyvää tilausoliota, mikä kertoo huonosta tilanteesta. Parempi ratkaisu olisi kapseloida Asiakas Tilaus-luokan oliomuuttujaksi. Jos ajattelemme tarkemmin tilauksen semantiikkaa, tämä on selvää. Tilauksella on asiakas. Muutetaan luokkaa Tilaus siten, että se sisältää Asiakas-viitteen.

public class Tilaus {

    private Asiakas asiakas;
    private String tuote;
    private String lukumaara;

    public Tilaus(Asiakas asiakas, String tuote, String lukumaara) {
        this.asiakas = asiakas;
        this.tuote = tuote;
        this.lukumaara = lukumaara;
    }

    public String getTuote() {
        return tuote;
    }

    public String getLukumaara() {
        return lukumaara;
    }

    public String postitusOsoite() {
        return this.asiakas.getNimi() + "\n" + this.asiakas.getOsoite();
    }
}

Yllä oleva luokka Tilaus on nyt parempi. Metodi postitusosoite käyttää asiakas-viitettä postitusosoitteen saamiseen sen sijaan että luokka perisi luokan Asiakas. Tämä helpottaa sekä ohjelman ylläpitoa, että sen konkreettista toiminnallisuutta.

Nyt asiakkaan muuttaessa tarvitsee muuttaa vain asiakkaan tietoja, tilauksiin ei tarvitse tehdä muutoksia.

Varastointia

Tehtäväpohjassa tulee mukana luokka Varasto, jonka tarjoamat konstruktorit ja metodit ovat seuraavat:

  • public Varasto(double tilavuus)
    Luo tyhjän varaston, jonka vetoisuus eli tilavuus annetaan parametrina; sopimaton tilavuus (<=0) luo käyttökelvottoman varaston, jonka tilavuus on 0.
  • public double getSaldo()
    Palauttaa arvonaan varaston saldon.
  • public double getTilavuus()
    Palauttaa arvonaan varaston kapasiteetin.
  • public double paljonkoMahtuu()
    Palauttaa arvonaan tiedon, paljonko varastoon vielä mahtuu.
  • public void lisaaVarastoon(double maara)
    Lisää varastoon pyydetyn määrän; jos määrä on negatiivinen, mikään ei muutu, jos kaikki pyydetty ei enää mahdu, varasto laitetaan täydeksi ja loput määräsätä "heitetään menemään", "vuotaa yli".
  • public double otaVarastosta(double maara)
    Otetaan varastosta pyydetty määrä; metodi palauttaa paljonko saadaan jos pyydetty määrä on negatiivinen, mikään ei muutu ja palautetaan nolla; jos pyydetään enemmän kuin varastossa on, annetaan mitä voidaan ja varasto tyhjenee.
  • public String toString()
    Palauttaa olion tilan merkkijonoesityksenä tyyliin saldo = 64.5, tilaa 123.5

Tehtävässä rakennetaan Varasto-luokasta useampia erilaisia varastoja. Huom! Toteuta kaikki luokat pakkaukseen varastot.

Tuotevarasto, vaihe 1

Luokka Varasto hallitsee tuotteen määrään liittyvät toiminnot. Nyt tuotteelle halutaan lisäksi tuotenimi ja nimen käsittelyvälineet. Ohjelmoidaan Tuotevarasto Varaston aliluokaksi! Toteutetaan ensin pelkkä yksityinen oliomuuttuja tuotenimelle, konstruktori ja getteri nimikentälle:

Muista millä tavoin konstruktori voi ensi toimenaan suorittaa yliluokan konstruktorin!

Käyttöesimerkki:

        Tuotevarasto mehu = new Tuotevarasto("Juice", 1000.0);
        mehu.lisaaVarastoon(1000.0);
        mehu.otaVarastosta(11.3);
        System.out.println(mehu.getNimi()); // Juice
        System.out.println(mehu);           // saldo = 988.7, tilaa 11.3
Juice
saldo = 988.7, vielä tilaa 11.3

Tuotevarasto, vaihe 2

Kuten edellisestä esimerkistä näkee, Tuotevarasto-olion perimä toString() ei tiedä (tietenkään!) mitään tuotteen nimestä. Asialle on tehtävä jotain! Lisätään samalla myös setteri tuotenimelle:

Uuden toString()-metodin voisi toki ohjelmoida käyttäen yliluokalta perittyjä gettereitä, joilla perittyjen, mutta piilossa pidettyjen kenttien arvoja saa käyttöönsä. Koska yliluokkaan on kuitenkin jo ohjelmoitu tarvittava taito varastotilanteen merkkiesityksen tuottamiseen, miksi nähdä vaivaa sen uudelleen ohjelmointiin. Käytä siis hyväksesi perittyä toStringiä.

Muista miten korvattua metodia voi kutsua aliluokassa!

Käyttöesimerkki:

        Tuotevarasto mehu = new Tuotevarasto("Juice", 1000.0);
        mehu.lisaaVarastoon(1000.0);
        mehu.otaVarastosta(11.3);
        System.out.println(mehu.getNimi()); // Juice
        mehu.lisaaVarastoon(1.0);
        System.out.println(mehu);           // Juice: saldo = 989.7, tilaa 10.299999999999955
Juice
Juice: saldo = 989.7, tilaa 10.299999999999955

Muutoshistoria

Toisinaan saattaa olla kiinostavaa tietää, millä tavoin jonkin tuotteen varastotilanne muuttuu: onko varasto usein hyvin vajaa, ollaanko usein ylärajalla, onko vaihelu suurta vai pientä, jne. Varustetaan siksi Tuotevarasto-luokka taidolla muistaa tuotteen määrän muutoshistoriaa.

Aloitetaan apuvälineen laadinnalla.

Muutoshistorian muistamisen voisi toki toteuttaa suoraankin ArrayList<Double>-oliona luokassa Tuotevarasto, mutta nyt laaditaan kuitenkin oma erikoistettu väline tähän tarkoitukseen. Väline toteutetaan kapseloimalla ArrayList<Double>-olio.

Muutoshistoria-luokan julkiset konstruktorit ja metodit:

Muutoshistoria.java, vaihe 2

Täydennä Muutoshistoria-luokkaa analyysimetodein:

Havainnollista uusien metodien käyttöä ja toimivuutta pienellä ohjelmalla.

Muutoshistoria.java, vaihe 3

Täydennä Muutoshistoria-luokkaa analyysimetodein:

Ohjeen varianssin laskemiseksi voit katsoa esimerkiksi Wikipediasta kohdasta populaatio- ja otosvarianssi. Esimerkiksi lukujen {3, 2, 7, 2} keskiarvo on 3.5, joten otosvarianssi on ((3 - 3.5)² + (2 - 3.5)² + (7 - 3.5)² + (2 - 3.5)²)/(4 - 1) ≈ 5,666667.)

Havainnollista uusien metodien käyttöä ja toimivuutta pienellä ohjelmalla.

MuistavaTuotevarasto, vaihe 1

Toteuta luokan Tuotevarasto aliluokkana MuistavaTuotevarasto. Uusi versio tarjoaa vanhojen lisäksi varastotilanteen muutoshistoriaan liittyviä palveluita. Historiaa hallitaan Muutoshistoria-oliolla.

Julkiset konstruktorit ja metodit:

Huomaa että tässä esiversiossa historia ei vielä toimi kunnolla; nyt vasta vain aloitussaldo muistetaan.

Käyttöesimerkki:

// tuttuun tapaan:
MuistavaTuotevarasto mehu = new MuistavaTuotevarasto("Juice", 1000.0, 1000.0);
mehu.otaVarastosta(11.3);
System.out.println(mehu.getNimi()); // Juice
mehu.lisaaVarastoon(1.0);
System.out.println(mehu);           // Juice: saldo = 989.7, vielä tilaa 10.3
...
// mutta vielä historia() ei toimi kunnolla:
System.out.println(mehu.historia()); // [1000.0]
   // saadaan siis vasta konstruktorin asettama historian alkupiste...
...

Tulostus siis:

Juice
Juice: saldo = 989.7, vielä tilaa 10.299999999999955
[1000.0]

MuistavaTuotevarasto, vaihe 2

On aika aloittaa historia! Ensimmäinen versio ei historiasta tiennyt kuin alkupisteen. Täydennä luokkaa metodein

Käyttöesimerkki:

// tuttuun tapaan:
MuistavaTuotevarasto mehu = new MuistavaTuotevarasto("Juice", 1000.0, 1000.0);
mehu.otaVarastosta(11.3);
System.out.println(mehu.getNimi()); // Juice
mehu.lisaaVarastoon(1.0);
System.out.println(mehu);           // Juice: saldo = 989.7, vielä tilaa 10.3
...
// mutta nyt on historiaakin:
System.out.println(mehu.historia()); // [1000.0, 988.7, 989.7]
...

Tulostus siis:

Juice
Juice: saldo = 989.7, vielä tilaa 10.299999999999955
[1000.0, 988.7, 989.7]

Muista miten korvaava metodi voi käyttää hyväkseen korvattua metodia!

MuistavaTuotevarasto, vaihe 3

Täydennä luokkaa metodilla

Käyttöesimerkki:

MuistavaTuotevarasto mehu = new MuistavaTuotevarasto("Juice", 1000.0, 1000.0);
mehu.otaVarastosta(11.3);
mehu.lisaaVarastoon(1.0);
//System.out.println(mehu.historia()); // [1000.0, 988.7, 989.7]

mehu.tulostaAnalyysi();

Metodi tulostaAnalyysi kirjoittaa ilmoituksen tyyliin:

Tuote: Juice
Historia: [1000.0, 988.7, 989.7]
Suurin tuotemäärä: 1000.0
Pienin tuotemäärä: 988.7
Keskiarvo: 992.8

MuistavaTuotevarasto, vaihe 4

Täydennä analyysin tulostus sellaiseksi, että mukana ovat myös muutoshistorian suurin muutos ja historian varianssi.

Havainnollista kehiteltyä analyysiraporttia pienellä esimerkkiohjelmalla.

Perintä, rajapinnat, kumpikin, vai eikö kumpaakaan?

Perintä ei sulje pois rajapintojen käyttöä, eikä rajapintojen käyttö sulje pois perinnän käyttöä. Rajapinnat toimivat sopimuksena luokan tarjoamasta toteutuksesta, ja mahdollistavat konkreettisen toteutuksen abstrahoinnin. Kuten DAO-esimerkissä huomasimme, rajapinnat mahdollistavat ns. plug-and-play toiminnallisuuden. Rajapinnan toteuttavan luokan vaihto on hyvin helppoa.

Aivan kuten rajapintaa toteuttaessa, sitoudumme perittäessä siihen, että aliluokkamme tarjoaa kaikki yliluokan metodit. Monimuotoisuuden ja polymorfismin takia perintäkin toimii kuin rajapinnat. Voimme antaa yliluokkaa käyttävälle metodille sen aliluokan ilmentymän.

Tehdään seuraavaksi maatilasimulaattori, jossa simuloidaan maatilan elämää. Huomaa että ohjelmassa ei käytetä perintää, ja rajapintojenkin käyttö on melko vähäistä. Usein ohjelmat tehdäänkin niin että ensin toteutetaan yksi versio, jota lähdetään parantamaan myöhemmin. Tyypillistä on että ensimmäistä versiota toteutettaessa ongelma-aluetta ei vielä ymmärretä kunnolla, jolloin rajapintojen ja käsitehierarkioiden suunnittelu ennalta on hyvin vaikeaa ja saattaa jopa hidastaa työskentelyä.

Maatilasimulaattori

Maatiloilla on lypsäviä eläimiä, jotka tuottavat maitoa. Maatilat eivät itse käsittele maitoa, vaan se kuljetetaan Maitoautoilla meijereille. Meijerit ovat yleisiä maitotuotteita tuottavia rakennuksia. Jokainen meijeri erikoistuu yhteen tuotetyyppiin, esimerkiksi Juustomeijeri tuottaa Juustoa, Voimeijeri tuottaa voita ja Maitomeijeri tuottaa maitoa.

Rakennetaan maidon elämää kuvaava simulaattori. Toteuta kaikki luokat pakkaukseen maatilasimulaattori.

Maitosäiliö

Jotta maito pysyisi tuoreena, täytyy se säilöä sille tarkoitettuun säiliöön. Säiliöitä valmistetaan sekä oletustilavuudella 2000 litraa, että asiakkaalle räätälöidyllä tilavuudella. Toteuta luokka Maitosailio jolla on seuraavat konstruktorit ja metodit.

  • public Maitosailio()
  • public Maitosailio(double tilavuus)
  • public double getTilavuus()
  • public double getSaldo()
  • public double paljonkoTilaaJaljella()
  • public void lisaaSailioon(double maara) lisää säiliöön vain niin paljon maitoa kuin sinne mahtuu, ylimääräiset jäävät lisäämättä -- maitosäiliön ei siis tarvitse huolehtia tilanteesta jossa maitoa valuu yli
  • public double otaSailiosta(double maara) ottaa säiliöstä pyydetyn määrän, tai niin paljon kuin siellä on jäljellä

Toteuta Maitosailio-luokalle myös toString()-metodi, jolla kuvaat sen tilaa. Ilmaistessasi säiliön tilaa toString()-metodissa, pyöristä litramäärät ylöspäin käyttäen Math-luokan tarjoamaa ceil()-metodia.

Testaa maitosailiötä seuraavalla ohjelmapätkällä:

        Maitosailio sailio = new Maitosailio();
        sailio.otaSailiosta(100);
        sailio.lisaaSailioon(25);
        sailio.otaSailiosta(5);
        System.out.println(sailio);

        sailio = new Maitosailio(50);
        sailio.lisaaSailioon(100);
        System.out.println(sailio);

Ohjelman tulostuksen tulee olla seuraavankaltainen:

20.0/2000.0
50.0/50.0

Huomaa että kutsuttaessa System-luokan out-olioon liittyvää println()-metodia, joka saa parametrikseen Object-tyyppisen muuttujan, tulostus käyttää Maitosailio-luokassa korvattua toString()-metodia! Tässä on kyse polymorfismista, eli ajonaikaisesta käytettävien metodien päättelystä.

Lehmä

Saadaksemme maitoa tarvitsemme myös lehmiä. Lehmällä on nimi ja utareet. Utareiden tilavuus on satunnainen luku väliltä 15 ja 40 -- luokkaa Random voi käyttäää satunnaislukujen arpomiseen, esimerkiksi int luku = 15 + new Random().nextInt(26);. Luokalla Lehma on seuraavat toiminnot:

  • public Lehma() luo uuden lehmän satunnaisesti valitulla nimellä
  • public Lehma(String nimi) luo uuden lehmän annetulla nimellä
  • String getNimi() palauttaa lehmän nimen
  • double getTilavuus() palauttaa utareiden tilavuuden
  • double getMaara() palauttaa utareissa olevan maidon määrän
  • String toString() palauttaa lehmää kuvaavan merkkijonon (ks. esimerkki alla)

Lehma toteuttaa myös rajapinnat: Lypsava, joka kuvaa lypsämiskäyttäytymistä, ja Eleleva, joka kuvaa elelemiskäyttäytymistä.

public interface Lypsava {
    public double lypsa();
}

public interface Eleleva {
    public void eleleTunti();
}

Lehmää lypsettäessä sen koko maitovarasto tyhjennetään jatkokäsittelyä varten. Lehmän elellessä sen maitovarasto täyttyy hiljalleen. Suomessa maidontuotannossa käytetyt lehmät tuottavat keskimäärin noin 25-30 litraa maitoa päivässä. Simuloidaan tätä tuotantoa tuottamalla noin 0.7 - 2 litraa tunnissa.

Jos lehmälle ei anneta nimeä, valitse sille nimi satunnaisesti seuraavasta listasta.

    private static String[] NIMIA = new String[]{
        "Anu", "Arpa", "Essi", "Heluna", "Hely",
        "Hento", "Hilke", "Hilsu", "Hymy", "Ihq", "Ilme", "Ilo",
        "Jaana", "Jami", "Jatta", "Laku", "Liekki",
        "Mainikki", "Mella", "Mimmi", "Naatti",
        "Nina", "Nyytti", "Papu", "Pullukka", "Pulu",
        "Rima", "Soma", "Sylkki", "Valpu", "Virpi"};

Toteuta luokka Lehma ja testaa sen toimintaa seuraavan ohjelmapätkän avulla.

        Lehma lehma = new Lehma();
        System.out.println(lehma);


        Eleleva elelevaLehma = lehma;
        elelevaLehma.eleleTunti();
        elelevaLehma.eleleTunti();
        elelevaLehma.eleleTunti();
        elelevaLehma.eleleTunti();

        System.out.println(lehma);

        Lypsava lypsavaLehma = lehma;
        lypsavaLehma.lypsa();

        System.out.println(lehma);
        System.out.println("");

        lehma = new Lehma("Ammu");
        System.out.println(lehma);
        lehma.eleleTunti();
        lehma.eleleTunti();
        System.out.println(lehma);
        lehma.lypsa();
        System.out.println(lehma);

Ohjelman tulostus on erimerkiksi seuraavanlainen.

Liekki 0.0/23.0
Liekki 7.0/23.0
Liekki 0.0/23.0
Ammu 0.0/53.0
Ammu 9.0/53.0
Ammu 0.0/53.0

Lypsyrobotti

Nykyaikaisilla maatiloilla lypsyrobotit hoitavat lypsämisen. Jotta lypsyrobotti voi lypsää lypsävää otusta, tulee lypsyrobotin olla kiinnitetty maitosäiliöön:

  • public Lypsyrobotti() luo uuden lypsyrobotin
  • Maitosailio getMaitosailio() palauttaa kiinnitetyn maitosäiliö tai null-viitteen, jos säiliötä ei ole vielä kiinnitetty
  • void setMaitosailio(Maitosailio maitosailio) kiinnittää annetun säiliön lypsyrobottiin
  • void lypsa(Lypsava lypsava) lypsää lehmän robottiin kiinnitettyyn maitosäiliöön -- metodi heittää poikkeuksen IllegalStateException, jos säiliötä ei ole kiinnitetty

Toteuta luokka Lypsyrobotti ja testaa sitä seuraavien ohjelmanpätkien avulla. Varmista että lypsyrobotti voi lypsää kaikkia Lypsava-rajapinnan toteuttavia olioita!

        Lypsyrobotti lypsyrobotti = new Lypsyrobotti();
        Lehma lehma = new Lehma();
        lypsyrobotti.lypsa(lehma);
Exception in thread "main" java.lang.IllegalStateException: Maitosäiliötä ei ole asennettu
        at maatilasimulaattori.Lypsyrobotti.lypsa(Lypsyrobotti.java:17)
        at maatilasimulaattori.Main.main(Main.java:9)
Java Result: 1
        Lypsyrobotti lypsyrobotti = new Lypsyrobotti();
        Lehma lehma = new Lehma();
        System.out.println("");

        Maitosailio sailio = new Maitosailio();
        lypsyrobotti.setMaitosailio(sailio);
        System.out.println("Säiliö: " + sailio);

        for(int i = 0; i < 2; i++) {
            System.out.println(lehma);
            System.out.println("Elellään..");
            for(int j = 0; j < 5; j++) {
                lehma.eleleTunti();
            }
            System.out.println(lehma);

            System.out.println("Lypsetään...");
            lypsyrobotti.lypsa(lehma);
            System.out.println("Säiliö: " + sailio);
            System.out.println("");
        }

Ohjelman tulostus on esimerkiksi seuraavanlainen.

Säiliö: 0.0/2000.0
Mella 0.0/23.0
Elellään..
Mella 6.2/23.0
Lypsetään...
Säiliö: 6.2/2000.0

Mella 0.0/23.0
Elellään..
Mella 7.8/23.0
Lypsetään...
Säiliö: 14.0/2000.0

Navetta

Lehmät hoidetaan (eli tässä tapauksessa lypsetään) navetassa. Alkukantaisissa navetoissa on maitosäiliö ja tilaa yhdelle lypsyrobotille. Huomaa että lypsyrobottia asennettaessa se kytketään juuri kyseisen navetan maitosäiliöön. Jos navetassa ei ole lypsyrobottia, ei siellä voida myöskään hoitaa lehmiä. Toteuta luokka Navetta jolla on seuraavat konstruktorit ja metodit:

  • public Navetta(Maitosailio maitosailio)
  • public Maitosailio getMaitosailio() palauttaa navetan maitosailion
  • public void asennaLypsyrobotti(Lypsyrobotti lypsyrobotti) asentaa lypsyrobotin ja kiinnittää sen navetan maitosäiliöön
  • public void hoida(Lehma lehma) lypsää parametrina annetun lehmän lypsyrobotin avulla -- metodi heittää poikkeuksen IllegalStateException, jos lypsyrobottia ei ole asennettu
  • public void hoida(Collection<Lehma> lehmat) lypsää parametrina annetut lehmät lypsyrobotin avulla -- metodi heittää poikkeuksen IllegalStateException, jos lypsyrobottia ei ole asennettu
  • public String toString() palauttaa navetan sisältämän maitosäiliön tilan

Collection on Javan oma rajapinta joka kuvaa kokoelmien käyttäytymistä. Esimerkiksi luokat ArrayList ja LinkedList toteuttavat rajapinnan Collection. Jokaista Collection-rajapinnan toteuttavaa ilmentymää voi myös iteroida for-each-tyyppisesti.

Testaa luokkaa Navetta seuraavan ohjelmapätkän avulla. Älä hermoile luokasta LinkedList, se toimii ulkoapäin katsottuna kuin ArrayList, mutta sen kapseloima toteutus on hieman erilainen. Tästä lisää tietorakenteet-kurssilla!

        Navetta navetta = new Navetta(new Maitosailio());
        System.out.println("Navetta: " + navetta);

        Lypsyrobotti robo = new Lypsyrobotti();
        navetta.asennaLypsyrobotti(robo);

        Lehma ammu = new Lehma();
        ammu.eleleTunti();
        ammu.eleleTunti();

        navetta.hoida(ammu);
        System.out.println("Navetta: " + navetta);

        LinkedList<Lehma> lehmaLista = new LinkedList();
        lehmaLista.add(ammu);
        lehmaLista.add(new Lehma());

        for(Lehma lehma: lehmaLista) {
            lehma.eleleTunti();
            lehma.eleleTunti();
        }

        navetta.hoida(lehmaLista);
        System.out.println("Navetta: " + navetta);

Tulostuksen tulee olla esimerkiksi seuraavanlainen:

Navetta: 0.0/2000.0
Navetta: 2.8/2000.0
Navetta: 9.6/2000.0

Maatila

Maatilalla on omistaja ja siihen kuuluu navetta sekä joukko lehmiä. Maatila toteuttaa myös aiemmin nähdyn rajapinnan Eleleva, jonka metodia eleleTunti()-kutsumalla kaikki maatilaan liittyvät lehmät elelevät tunnin. Toteuta luokka maatila siten, että se toimii seuraavien esimerkkiohjelmien mukaisesti.

        Maatila maatila = new Maatila("Esko", new Navetta(new Maitosailio()));
        System.out.println(maatila);

        System.out.println(maatila.getOmistaja() + " on ahkera mies!");

Odotettu tulostus:

Maatilan omistaja: Esko
Navetan maitosäiliö: 0.0/2000.0
Ei lehmiä.
Esko on ahkera mies!
        Maatila maatila = new Maatila("Esko", new Navetta(new Maitosailio()));
        maatila.lisaaLehma(new Lehma());
        maatila.lisaaLehma(new Lehma());
        maatila.lisaaLehma(new Lehma());
        System.out.println(maatila);

Odotettu tulostus:

Maatilan omistaja: Esko
Navetan maitosäiliö: 0.0/2000.0
Lehmät:
        Naatti 0.0/19.0
        Hilke 0.0/30.0
        Sylkki 0.0/29.0
        Maatila maatila = new Maatila("Esko", new Navetta(new Maitosailio()));

        maatila.lisaaLehma(new Lehma());
        maatila.lisaaLehma(new Lehma());
        maatila.lisaaLehma(new Lehma());

        maatila.eleleTunti();
        maatila.eleleTunti();

Odotettu tulostus:

Maatilan omistaja: Esko
Navetan maitosäiliö: 0.0/2000.0
Lehmät:
        Heluna 2.0/17.0
        Rima 3.0/42.0
        Ilo 3.0/25.0
        Maatila maatila = new Maatila("Esko", new Navetta(new Maitosailio()));
        Lypsyrobotti robo = new Lypsyrobotti();
        maatila.asennaNavettaanLypsyrobotti(robo);

        maatila.lisaaLehma(new Lehma());
        maatila.lisaaLehma(new Lehma());
        maatila.lisaaLehma(new Lehma());

        maatila.eleleTunti();
        maatila.eleleTunti();

        maatila.hoidaLehmat();

        System.out.println(maatila);

Odotettu tulostus:

Maatilan omistaja: Esko
Navetan maitosäiliö: 18.0/2000.0
Lehmät:
        Hilke 0.0/30.0
        Sylkki 0.0/45.0
        Hento 0.0/54.0

Abstrakti luokka

Abstrakti luokka yhdistää rajapintoja ja perintää. Niistä ei voi tehdä ilmentymiä, vaan ilmentymät tehdään tehdään abstraktin luokan aliluokista. Abstrakti luokka voi sisältää sekä normaaleja metodeja, joissa on metodirunko, että abstrakteja metodeja, jotka sisältävät ainoastaan metodimäärittelyn. Abstraktien metodien toteutus jätetään perivän luokan vastuulle. Yleisesti ajatellen abstrakteja luokkia käytetään esimerkiksi kun abstraktin luokan kuvaama käsite ei ole selkeä itsenäinen käsite. Tällöin siitä ei tule pystyä tekemään ilmentymiä.

Sekä abstraktin luokan että abstraktien metodien määrittelyssä käytetään avainsanaa abstract. Abstrakti luokka määritellään lauseella public abstract class LuokanNimi, abstrakti metodi taas lauseella public abstract palautustyyppi metodinNimi. Pohditaan seuraavaa abstraktia luokkaa Toiminto, joka tarjoaa rungon toiminnoille ja niiden suorittamiselle.

public abstract class Toiminto {

    private String nimi;

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

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

    public abstract void suorita(Scanner lukija);
}

Abstrakti luokka Toiminto toimii runkona erilaisten toimintojen toteuttamiseen. Esimerkiksi pluslaskun voi toteuttaa perimällä luokka Toiminto seuraavasti.

public class Pluslasku extends Toiminto {

    public Pluslasku() {
        super("Pluslasku");
    }

    @Override
    public void suorita(Scanner lukija) {
        System.out.print("Anna ensimmäinen luku: ");
        int eka = Integer.parseInt(lukija.nextLine());
        System.out.print("Anna toinen luku: ");
        int toka = Integer.parseInt(lukija.nextLine());

        System.out.println("Lukujen summa on " + (eka + toka));
    }
}

Koska kaikki Toiminto-luokan perivät luokat ovat myös tyyppiä toiminto, voimme rakentaa käyttöliittymän Toiminto-tyyppisten muuttujien varaan. Seuraava luokka Kayttoliittyma sisaltaa listan toimintoja ja lukijan. Toimintoja voi lisätä käyttöliittymään dynaamisesti.

public class Kayttoliittyma {

    private Scanner lukija;
    private List<Toiminto> toiminnot;

    public Kayttoliittyma(Scanner lukija) {
        this.lukija = lukija;
        this.toiminnot = new ArrayList<Toiminto>();
    }

    public void lisaaToiminto(Toiminto toiminto) {
        this.toiminnot.add(toiminto);
    }

    public void kaynnista() {
        while (true) {
            tulostaToiminnot();
            System.out.println("Valinta: ");

            String valinta = this.lukija.nextLine();
            if (valinta.equals("0")) {
                break;
            }

            suoritaToiminto(valinta);
            System.out.println();
        }
    }

    private void tulostaToiminnot() {
        System.out.println("\t0: Lopeta");
        for (int i = 0; i < this.toiminnot.size(); i++) {
            String toiminnonNimi = this.toiminnot.get(i).getNimi();
            System.out.println("\t" + (i + 1) + ": " + toiminnonNimi);
        }
    }

    private void suoritaToiminto(String valinta) {
        int toiminto = Integer.parseInt(valinta);

        Toiminto valittu = this.toiminnot.get(toiminto - 1);
        valittu.suorita(lukija);
    }
}

Käyttöliittymä toimii seuraavasti:

        Kayttoliittyma kayttolittyma = new Kayttoliittyma(new Scanner(System.in));
        kayttolittyma.lisaaToiminto(new Pluslasku());

        kayttolittyma.kaynnista();
Toiminnot:
        0: Lopeta
        1: Pluslasku
Valinta: 1
Anna ensimmäinen luku: 8
Anna toinen luku: 12
Lukujen summa on 20

Toiminnot:
        0: Lopeta
        1: Pluslasku
Valinta: 0

Rajapintojen ja abstraktien luokkien ero on siinä, että abstraktit luokat tarjoavat enemmän rakennetta ohjelmaan. Koska abstrakteihin luokkiin voidaan määritellä toiminnallisuutta, voidaan niitä käyttää esimerkiksi oletustoiminnallisuuden määrittelyyn. Yllä käyttöliittymä käytti abstraktissa luokassa määriteltyä toiminnan nimen tallentamista.

Erilaisia laatikoita

Tehtäväpohjan mukana tulee luokat Tavara ja Laatikko. Luokka Laatikko on abstrakti luokka, jossa useamman tavaran lisääminen lisääminen on toteutettu siten, että kutsutaan aina lisaa-metodia. Yhden tavaran lisäämiseen tarkoitettu metodi lisaa on abstrakti, joten jokaisen Laatikko-luokan perivän laatikon tulee toteuttaa se. Tehtävänäsi on muokata luokkaa Tavara ja toteuttaa muutamia erilaisia laatikoita luokan Laatikko pohjalta.

Lisää kaikki uudet luokat pakkaukseen laatikot.

package laatikot;

import java.util.Collection;

public abstract class Laatikko {

    public abstract void lisaa(Tavara tavara);

    public void lisaa(Collection<Tavara> tavarat) {
        for (Tavara tavara : tavarat) {
            lisaa(tavara);
        }
    }

    public abstract boolean onkoLaatikossa(Tavara tavara);
}

Tavaran muokkaus ja maksimipainollinen laatikko

Lisää Tavara-luokan konstruktoriin tarkistus, jossa tarkistetaan että tavaran paino ei ole koskaan negatiivinen (paino 0 hyväksytään). Jos paino on negatiivinen, tulee konstruktorin heittää IllegalArgumentException-poikkeus. Toteuta Tavara-luokalle myös metodit equals ja hashCode, joiden avulla erilaisten pääset hyödyntämään listojen ja kokoelmien contains-metodia. Toteuta metodit siten, että Tavara-luokan oliomuuttujan paino arvolla ei ole väliä. Voit hyvin hyödyntää NetBeansin tarjoamaa toiminnallisuutta..

Toteuta lisäksi pakkaukseen laatikot luokka MaksimipainollinenLaatikko, joka perii luokan Laatikko. Maksimipainollisella laatikolla on konstruktori public MaksimipainollinenLaatikko(int maksimipaino), joka määrittelee laatikon maksimipainon. Maksimipainolliseen laatikkoon voi lisätä tavaraa jos ja vain jos tavaran lisääminen ei ylitä laatikon maksimipainoa.

        MaksimipainollinenLaatikko kahviLaatikko = new MaksimipainollinenLaatikko(10);
        kahviLaatikko.lisaa(new Tavara("Saludo", 5));
        kahviLaatikko.lisaa(new Tavara("Pirkka", 5));
        kahviLaatikko.lisaa(new Tavara("Kopi Luwak", 5));

        System.out.println(kahviLaatikko.onkoLaatikossa(new Tavara("Saludo")));
        System.out.println(kahviLaatikko.onkoLaatikossa(new Tavara("Pirkka")));
        System.out.println(kahviLaatikko.onkoLaatikossa(new Tavara("Kopi Luwak")));
true
true
false

Yhden tavaran laatikko ja Hukkaava laatikko

Toteuta seuraavaksi pakkaukseen laatikot luokka YhdenTavaranLaatikko, joka perii luokan Laatikko. Yhden tavaran laatikolla on konstruktori public YhdenTavaranLaatikko(), ja siihen mahtuu tasan yksi tavara. Jos tavara on jo laatikossa sitä ei tule vaihtaa. Laatikkoon lisättävän tavaran painolla ei ole väliä.

        YhdenTavaranLaatikko laatikko = new YhdenTavaranLaatikko();
        laatikko.lisaa(new Tavara("Saludo", 5));
        laatikko.lisaa(new Tavara("Pirkka", 5));

        System.out.println(laatikko.onkoLaatikossa(new Tavara("Saludo")));
        System.out.println(laatikko.onkoLaatikossa(new Tavara("Pirkka")));
true
false

Toteuta seuraavaksi pakkaukseen laatikot luokka HukkaavaLaatikko, joka perii luokan Laatikko. Hukkaavalla laatikolla on konstruktori public HukkaavaLaatikko(). Hukkaavaan laatikkoon voi lisätä kaikki tavarat, mutta tavaroita ei löydy niitä etsittäessä. Laatikkoon lisäämisen tulee siis aina onnistua, mutta metodin onkoLaatikossa kutsumisen tulee aina palauttaa false.

        HukkaavaLaatikko laatikko = new HukkaavaLaatikko();
        laatikko.lisaa(new Tavara("Saludo", 5));
        laatikko.lisaa(new Tavara("Pirkka", 5));

        System.out.println(laatikko.onkoLaatikossa(new Tavara("Saludo")));
        System.out.println(laatikko.onkoLaatikossa(new Tavara("Pirkka")));
false
false

Kuviot

Tehtäväpohjan mukana tulee luokat Ympyra, Suorakulmio ja TasasivuinenKolmio. Luokat liittyvät samaan aihepiiriin, ja niillä on hyvin paljon yhteistä toiminnallisuutta. Tutustu luokkiin ennenkuin lähdet tekemään, jolloin hahmotat tarkemmin syyt muutoksille. Jos huomaat että luokissa on alustavasti sisennys hieman pielessä, kannattaa sisennys hoitaa kuntoon luettavuuden helpottamiseksi.

Kuvio

Toteuta pakkaukseen kuviot abstrakti luokka Kuvio, jossa on kuvioihin liittyvää toiminnallisuutta. Luokan kuvio tulee sisältää konstruktori public Kuvio(int x, int y), aksessorit public int getX(), public int getY(), sekä abstraktit metodit public abstract double pintaAla() ja public abstract double piiri().

Ympyra perii kuvion

Muuta luokan Ympyra toteutusta siten, että se perii luokan Kuvio. Luokan Ympyra ulkoinen toiminnallisuus ei saa muuttua, eli sen tulee tarjota samat metodit kuin aiemminkin -- joko luokan Kuvio avulla tai itse. Muistathan että konstruktorikutsun super avulla voit käyttää yliluokan konstruktoria. Kun metodi public int getX() on toteutettu jo yliluokassa se ei tarvitse erillistä toteutusta luokassa Ympyra.

        Kuvio kuvio = new Ympyra(10, 10, 15);
        System.out.println("X " + kuvio.getX());
        System.out.println("Y " + kuvio.getY());
        System.out.println("Pinta-ala " + kuvio.pintaAla());
        System.out.println("Piiri " + kuvio.piiri());
X 10
Y 10
Pinta-ala 706.85834...
Piiri 94.24777...

Suorakulmio ja Tasakylkinen kolmio perii kuvion

Muuta luokkien Suorakulmio ja TasakylkinenKolmio toteutusta siten, että ne perivät luokan Kuvio. Luokkien ulkoinen toiminnallisuus ei saa muuttua, eli niiden tulee tarjota samat metodit kuin aiemminkin -- joko luokan Kuvio avulla tai itse.

        Kuvio kuvio = new Suorakulmio(10, 10, 15, 15);
        System.out.println("X " + kuvio.getX());
        System.out.println("Y " + kuvio.getY());
        System.out.println("Pinta-ala " + kuvio.pintaAla());
        System.out.println("Piiri " + kuvio.piiri());
        System.out.println("");

        kuvio = new TasakylkinenKolmio(10, 10, 15);
        System.out.println("X " + kuvio.getX());
        System.out.println("Y " + kuvio.getY());
        System.out.println("Pinta-ala " + kuvio.pintaAla());
        System.out.println("Piiri " + kuvio.piiri());
X 10
Y 10
Pinta-ala 225.0
Piiri 60.0

X 10
Y 10
Pinta-ala 97.42785...
Piiri 45.0

Luola

Tämä tehtävä on neljän tehtäväpisteen arvoinen. Huom! Toteuta kaikki toiminnallisuus pakkaukseen luola.

Tässä tehtävässä pääset toteuttamaan luolapelin. Pelissä pelaaja on luolassa hirviöitten kanssa. Pelaajan tehtävänä on ehtiä tallata kaikki hirviöt ennen kuin hänen lampustaan loppuu virta ja hirviöt pääsevät pimeän turvin syömään hänet. Pelaaja voi nähdä hirviöiden sijainnit välkäyttämällä lamppua, jonka jälkeen hänen on liikuttava sokkona ennen seuraavaa välkäytystä. Pelaaja voi kulkea monta askelta yhden siirron aikana.

Pelitilanne eli luola, pelaaja ja hirviöt esitetään pelaajalle tekstimuotoisesti. Tulostuksen ensimmäinen rivi kertoo jäljellä olevien siirtojen (eli lampun jäljellä olevan virran) määrän. Virran määrää seuraa pelaajan ja hirviöitten sijainnit, joiden jälkeen on pelitilanteesta piirretty kartta. Allaolevassa esimerkissä näet pelaajan (@) ja kolme hirviötä (h). Alla olevassa esimerkissä pelaajalla on virtaa neljääntoista siirtoon.

14

@ 1 2
h 6 1
h 7 3
h 12 2

.................
......h..........
.@.........h.....
.......h.........

Yllä olevassa esimerkissä virtaa on 14 välkäytykseen. Pelaaja @ sijatsee koordinaatissa 1 2. Huomaa että koordinaatit lasketaan aina pelialueen vasemmasta ylälaidasta lähtien. Allaolevassa kartassa merkki X on koordinaatissa 0 0, Y koordinaatissa 2 0 ja Z koordinaatissa 0 2.

X.Y..............
.................
Z................
.................

Käyttäjä voi liikkua antamalla sarjan komentoja ja painamalla rivinvaihtoa. Komennot ovat:

Kun käyttäjän antamat komennot on suoritettu (niitä voi olla useampi), piirretään uusi pelitilanne. Lampun virta vähenee yhdellä aina kun uusi pelitilanne piirretään. Jos virta menee nollaan, peli loppuu ja ruudulle tulostetaan teksti HÄVISIT

Hirviöt liikkuvat pelissä satunnaisesti, yhden askeleen jokaista pelaajan askelta kohti. Jos pelaaja ja hirviö osuvat samaan ruutuun (vaikka vain tilapäisesti), hirviö tuhoutuu. Jos hirviö yrittää siirtyä ruutuun jossa on jo hirviö, arvotaan sille siirto uudestaan. Kun kaikki hirviöt on tuhottu, peli loppuu ja tulostetaan VOITIT.

Testaamisen helpottamiseksi tee peliisi luokka Luola, jolla on :

Huom! pelaajan tulee aloittaa sijainnista 0,0!

Huom! jos pelaaja tai hirviö koittaa liikkua ulos luolasta, ei liikettä tule tapahtua!

Alla vielä selkeyden vuoksi vielä esimerkkipeli:

14

@ 0 0
h 1 2
h 7 8
h 7 5
h 8 0
h 2 9

@.......h.
..........
.h........
..........
..........
.......h..
..........
..........
.......h..
..h.......

ssd
13

@ 1 2
h 8 8
h 7 4
h 8 3
h 1 8

..........
..........
.@........
........h.
.......h..
..........
..........
..........
.h......h.
..........

ssss
12

@ 1 6
h 6 9
h 6 5
h 8 3

..........
..........
..........
........h.
..........
......h...
.@........
..........
..........
......h...

dd
11

@ 3 6
h 5 9
h 6 7
h 8 1

..........
........h.
..........
..........
..........
..........
...@......
......h...
..........
.....h....

ddds
10

@ 6 7
h 6 6
h 5 0

.....h....
..........
..........
..........
..........
..........
......h...
......@...
..........
..........

w
9

@ 6 6
h 4 0

....h.....
..........
..........
..........
..........
..........
......@...
..........
..........
..........

www
8

@ 6 3
h 4 0

....h.....
..........
..........
......@...
..........
..........
..........
..........
..........
..........

aa
7

@ 4 3
h 4 2

..........
..........
....h.....
....@.....
..........
..........
..........
..........
..........
..........

w
VOITIT

Valmiit sovelluskehykset

Sovelluskehys on ohjelma, joka tarjoaa lähtökohdan ja joukon palveluita jonkin erityisen sovelluksen toteuttamiseen. Yksi tapa laatia sovelluskehys on laatia valmiita palveluita tarjoava luokka, jonka päälle luokan perivät luokat rakentavat erityisen sovelluksen. Sovelluskehykset ovat yleensä hyvin laajoja, ja tarkoitettu johonkin tiettyyn tarkoitukseen -- esimerkiksi pelien ohjelmointiin tai web-sovelluskehitykseen. Tutustutaan seuraavasti pikaisesti valmiin sovelluskirjaston käyttöön luomalla sovelluslogiikka Game of Life -pelille.

Game of Life

Tässä tehtäväsarjassa toteutetaan sovelluslogiikka Game of Life-pelille perimällä valmis sovellusrunko. Sovellusrunko on projektiin erikseen lisätyssä kirjastossa, joten sen lähdekoodit eivät ole nähtävissä.

Game of Life on matemaatikko John Conway'n kehittelemä yksinkertainen "populaatiosimulaattori", kts. http://en.wikipedia.org/wiki/Conway%27s_Game_of_Life.

Game of Lifen säännöt ovat seuraavat:

Abstrakti luokka GameOfLifeAlusta tarjoaa seuraavat toiminnot

Luokassa GameOfLifeAlusta> on lisäksi määritelty seuraavat abstraktit metodit, jotka sinun tulee toteuttaa.

GameOfLife-toteutus, vaihe 1

Luo pakkaukseen game luokka OmaAlusta, joka perii pakkauksessa gameoflife olevan luokan GameOfLifeAlusta. Huomaa että pakkausta gameoflife ei ole näkyvillä omassa projektissasi, vaan se tulee mukana luokkakirjastona. Toteuta luokalle OmaAlusta konstruktori public OmaAlusta(int leveys, int korkeus), joka kutsuu yläluokan konstruktoria annetuilla parametreilla.

import gameoflife.GameOfLifeAlusta;

public class OmaAlusta extends GameOfLifeAlusta {

    public OmaAlusta(int leveys, int korkeus) {
        super(leveys, korkeus);
    }

    // ..

Voit ensin korvata kaikki abstraktit metodit ei-abstrakteilla metodeilla, jotka eivät kuitenkaan vielä tee mitään järkevää. Mutta koska ne eivät ole abstrakteja, tästä luokasta voi luoda ilmentymiä – toisin kuin abstraktista luokasta GameOfLifeAlusta.

Toteuta seuraavat metodit

Pääset yläluokassa olevaan kaksiulotteiseen taulukkoon käsiksi yläluokan tarjoaman metodin getAlusta() avulla. Kaksiulotteisia taulukoita käytetään kuten yksiulotteisia taulukoita, mutta taulukoille annetaan kaksi indeksiä. Ensimmäinen indeksi kertoo leveyskohdan, toinen indeksi korkeuskohdan. Esimerkiksi seuraava ohjelmapätkä luo ensin 10 x 10 -kokoisen taulukon, ja tulostaa sitten taulukon indeksissä 3, 1 olevan arvon.

boolean[][] arvot = new boolean[10][10];
System.out.println(arvot[3][1]);

Vastaavasti OmaAlusta-luokassa voidaan tulostaa yläluokasta saadun taulukon arvo indeksissä x, y seuraavasti:

boolean[][] alusta = getAlusta();
System.out.println(alusta[x][y]);

Testaa toteutustasi seuraavalla testiohjelmalla.

package game;

import gameoflife.komentorivi.KomentoriviGameOfLife;

public class Main {
    public static void main(String[] args) {
        OmaAlusta alusta = new OmaAlusta(7, 5);

        alusta.muutaElavaksi(2, 0);
        alusta.muutaElavaksi(4, 0);

        alusta.muutaElavaksi(3, 3);
        alusta.muutaKuolleeksi(3, 3);

        alusta.muutaElavaksi(0, 2);
        alusta.muutaElavaksi(1, 3);
        alusta.muutaElavaksi(2, 3);
        alusta.muutaElavaksi(3, 3);
        alusta.muutaElavaksi(4, 3);
        alusta.muutaElavaksi(5, 3);
        alusta.muutaElavaksi(6, 2);

        KomentoriviGameOfLife gom = new KomentoriviGameOfLife(alusta);
        gom.pelaa();
    }
}

Tulostuksen pitäisi olla seuraavanlainen:

Paina enter jatkaaksesi, muut lopettaa: <enter>

  X X

X     X
 XXXXX

Paina enter jatkaaksesi, muut lopettaa: stop
Kiitos!

GameOfLife-toteutus, vaihe 2

Toteuta metodi alustaSatunnaisetPisteet(double todennakoisyysPisteelle), joka alustaa kaikki alkiot siten, että kukin alkio on elävä todennäköisyydellä todennakoisyysPisteelle. Todennäköisyys annetaan metodille suljetulla välillä [0, 1] olevana double-tyyppisenä parametrina.

Testaa metodia. Arvolla 0.0 ei pitäisi olla yhtään elossa olevaa solua, arvolla 1.0 kaikkien solujen tulisi olla elossa (eli näkyä X-merkkisinä). Arvolla 0.5 noin puolet soluista on eläviä.

        OmaAlusta alusta = new OmaAlusta(3, 3);
        alusta.alustaSatunnaisetPisteet(1.0);

        KomentoriviGameOfLife gom = new KomentoriviGameOfLife(alusta);
        gom.pelaa();
Paina enter jatkaaksesi, muut lopettaa: <enter>

XXX
XXX
XXX
Paina enter jatkaaksesi, muut lopettaa: stop
Kiitos!

GameOfLife-toteutus, vaihe 3

Toteuta metodi getElossaOlevienNaapurienLukumaara(int x, int y), joka laskee elossa olevien naapurien lukumäärän. Keskellä taulukkoa olevalla solulla on yhteensä kahdeksan naapuria, reunassa olevalla solulla 5, kulmassa olevalla 3.

Testaa metodia seuraavilla lauseilla (voit keksiä myös muita testitapauksia!):

OmaAlusta alusta = new OmaAlusta(7, 5);

alusta.muutaElavaksi(0, 1);
alusta.muutaElavaksi(1, 0);
alusta.muutaElavaksi(1, 2);
alusta.muutaElavaksi(2, 2);
alusta.muutaElavaksi(2, 1);

System.out.println("Elossa naapureita (0,0): " + alusta.getElossaOlevienNaapurienLukumaara(0, 0));
System.out.println("Elossa naapureita (1,1): " + alusta.getElossaOlevienNaapurienLukumaara(1, 1));

Tulostuksen pitäisi olla seuraavanlainen:

Elossa naapureita (0,0): 2
Elossa naapureita (1,1): 5

GameOfLife-toteutus, vaihe 4

Jäljellä on vielä metodin hoidaSolu(int x, int y, int elossaOleviaNaapureita) toteuttaminen. GameOfLife-pelin säännöthän olivat seuraavat:

Toteuta metodi hoidaSolu(int x, int y, int elossaOleviaNaapureita) ylläolevien sääntöjen mukaan. Kannattaa ohjelmoida ja testata yksi sääntö kerrallaan!

Kun olet saanut kaikki valmiiksi, voit testata ohjelman toimintaa seuraavalla graafisella simulaattorilla.

package game;

import gameoflife.Simulaattori;

public class Main {

    public static void main(String[] args) {
        OmaAlusta alusta = new OmaAlusta(100, 100);
        alusta.alustaSatunnaisetPisteet(0.7);

        Simulaattori simulaattori = new Simulaattori(alusta);
        simulaattori.simuloi();
    }
}

Ohjelmien automaattinen testaaminen

Errare humanum est

Ihminen on erehtyväinen ja paraskin ohjelmoija tekee virheitä. Ohjelman kehitysvaiheessa tapahtuvien virheiden lisäksi huomattava osa virheistä syntyy olemassa olevaa ohjelmaa muokattaessa. Ohjelman muokkauksen aikana tehdyt virheet eivät välttämättä näy muokattavassa osassa, vaan voivat ilmaantua välillisesti erillisessä osassa ohjelmaa: osassa, joka käyttää muutettua osaa.

Ohjelmien automaattinen testaaminen tarkoittaa toistettavien testien luomista. Testeillä varmistetaan että ohjelma toimii halutusti, ja että ohjelma säilyttää toiminnallisuutensa myös muutosten jälkeen. Sanalla automaattinen painotetaan sitä, että luodut testit ovat toistettavia ja että ne voidaan suorittaa aina haluttaessa -- ohjelmoijan ei tarvitse olla läsnä testejä suoritettaessa.

Otimme aiemmin askeleita kohti testauksen automatisointia antamalla Scanner-oliolle parametrina merkkijonon, jonka se tulkitsee käyttäjän näppäimistöltä antamaksi syötteeksi. Automaattisessa testaamisessa testaaminen viedään viedä pidemmälle: koneen tehtävänä on myös tarkistaa että ohjelman tuottama vastaus on odotettu.

Automaattisen testauksen tällä kurssilla painotettu osa-alue on yksikkötestaus, jossa testataan ohjelman pienten osakokonaisuuksien -- metodien ja luokkien -- toimintaa. Yksikkötestaamiseen käytetään Javalla yleensä JUnit-testauskirjastoa.

Pino ja automaattiset testit

Pino on kaikille ihmisille tuttu asia. Esimerkiksi ravintola Unicafessa lautaset ovat yleensä pinossa. Pinon päältä voi ottaa lautasen ja pinon päälle voi lisätä lautasia. On myös helppo selvittää onko pinossa vielä lautasia jäljellä.

Pino on myös ohjelmoinnissa usein käytetty aputietorakenne. Rajapintana lukuja sisältävä pino näyttää seuraavalta.

public interface Pino {
    boolean tyhja();
    boolean taynna();
    void pinoon(int luku);
    int pinosta();
    int huipulla();
    int lukuja();
}

Rajapinnan määrittelemien metodien on tarkoitus toimia seuraavasti:

Toteutetaan rajapinnan Pino toteuttava luokka OmaPino, johon talletetaan lukuja. Pinoon mahtuvien lukujen määrä annetaan pinon konstruktorissa. Toteutamme pinon hieman aiemmasta poikkeavasti -- emme testaa ohjelmaa pääohjelman avulla, vaan käytämme pääohjelman sijasta automatisoituja JUnit-testejä ohjelman testaamiseen.

Tutustuminen JUnitiin

NetBeansissa olevat ohjelmamme ovat tähän asti sijainneet aina Source Packagesissa tai sen sisällä olevissa pakkauksissa. Ohjelman lähdekoodit tulevat aina kansioon Source Packages. Automaattisia testejä luodessa testit luodaan valikon Test Packages alle. Uusia JUnit-testejä voi luoda valitsemalla projektin oikealla hiirennapilla ja valitsemalla avautuvasta valikosta New -> JUnit Test.... Jos vaihtoehto JUnit test ei näy listassa, löydät sen valitsemalla Other.

JUnit-testit sijaitsevat luokassa. Uutta testitiedostoa luodessa ohjelma pyytää testitiedoston nimen. Tyypillisesti nimeksi annetaan testattavan luokan tai toiminnallisuuden nimi. Luokan nimen tulee aina päättyä sanaan Test. Esimerkiksi alla luodaan testiluokka PinoTest, joka sijaitsee pakkauksessa pino. NetBeans haluaa luoda käyttöömme myös valmista runkoa testiluokalle -- joka käy hyvin.

Jos NetBeans kysyy minkä JUnit-version haluat käyttöösi, valitse JUnit 4.x.

Kun testiluokka PinoTest on luotu, näkyy se projektin valikon Test Packages alla.

Luokka PinoTest näyttää aluksi seuraavalta

package pino;

import org.junit.*;
import static org.junit.Assert.*;

public class PinoTest {

    public PinoTest() {
    }

    @BeforeClass
    public static void setUpClass() throws Exception {
    }

    @AfterClass
    public static void tearDownClass() throws Exception {
    }

    @Before
    public void setUp() {
    }

    @After
    public void tearDown() {
    }
    // TODO add test methods here.
    // The methods must be annotated with annotation @Test. For example:
    //
    // @Test
    // public void hello() {}
}

Meille oleellisia osia luokassa PinoTest ovat metodit public void setUp, jonka yläpuolella on merkintä @Before, ja kommentoitu metodipohja public void hello(), jonka yläpuolella on merkintä @Test. Metodit, joiden yläpuolella on merkintä @Test ovat ohjelman toiminnallisuutta testaavia testimetodeja. Metodi setUp taas suoritetaan ennen jokaista testiä.

Muokataan luokkaa PinoTest siten, että sillä testataan rajapinnan Pino toteuttamaa luokkaa OmaPino. Älä välitä vaikkei luokkaa OmaPino ole vielä luotu. Pino on testiluokan oliomuuttuja, joka alustetaan ennen jokaista testiä metodissa setUp.

package pino;

import org.junit.*;
import static org.junit.Assert.*;

public class PinoTest {

    Pino pino;

    @Before
    public void setUp() {
        pino = new OmaPino(3);
    }

    @Test
    public void alussaTyhja() {
        assertTrue(pino.tyhja());
    }

    @Test
    public void lisayksenJalkeenEiTyhja() {
        pino.pinoon(5);
        assertFalse(pino.tyhja());
    }

    @Test
    public void lisattyAlkioTuleePinosta() {
        pino.pinoon(3);
        assertEquals(3, pino.pinosta());
    }

    @Test
    public void lisayksenJaPoistonJalkeenPinoOnTaasTyhja() {
        pino.pinoon(3);
        pino.pinosta();
        assertTrue(pino.tyhja());
    }

    @Test
    public void lisatytAlkiotTulevatPinostaOikeassaJarjestyksessa() {
        pino.pinoon(1);
        pino.pinoon(2);
        pino.pinoon(3);

        assertEquals(3, pino.pinosta());
        assertEquals(2, pino.pinosta());
        assertEquals(1, pino.pinosta());
    }

    @Test
    public void tyhjennyksenJalkeenPinoonLaitettuAlkioTuleeUlosPinosta() {
        pino.pinoon(1);
        pino.pinosta();

        pino.pinoon(5);

        assertEquals(5, pino.pinosta());
    }

    // ...
}

Jokainen testi, eli merkinnällä @Test varustettu metodi, alkaa tilanteesta, jossa on luotu uusi tyhjä pino. Jokainen yksittäinen @Test-merkitty metodi on oma testinsä. Yksittäisellä testimetodilla testataan aina yhtä pientä osaa pinon toiminnallisuudesta. Testit suoritetaan toisistaan täysin riippumattomina, eli jokainen testi alkaa "puhtaaltä pöydältä", setUp-metodin alustamasta tilanteesta.

Yksittäiset testit noudattavat aina samaa kaavaa. Ensin luodaan tilanne jossa tapahtuvaa toimintoa halutaan testata, sitten tehdään testattava toimenpide, ja lopuksi tarkastetaan onko tilanne odotetun kaltainen. Esimerkiksi seuraava testi testaa että lisäyksen ja poiston jälkeen pino on taas tyhjä -- huomaa myös kuvaava testimetodin nimentä:

    @Test
    public void lisayksenJaPoistonJalkeenPinoOnTaasTyhja() {
        pino.pinoon(3);
        pino.pinosta();
        assertTrue(pino.tyhja());
    }

Ylläoleva testi testaa toimiiko metodi tyhja() jos pino on tyhjennetty. Ensin laitetaan pinoon luku metodilla pinoon, jonka jälkeen pino tyhjennetään kutsumalla metodia pinosta(). Tällöin on saatu aikaan tilanne jossa pinon pitäisi olla tyhjennetty. Viimeisellä rivillä testataan, että pinon metodi tyhja() palauttaa arvon true testausmetodilla assertTrue(). Jos metodi tyhja() ei palauta arvoa true näemme testejä suorittaessa virheen.

Jokainen testi päättyy jonkun assert-metodin kutsuun. Esimerkiksi metodilla assertEquals() voidaan varmistaa onko metodin palauttama luku tai merkkijono haluttu, ja metodilla assertTrue() varmistetaan että metodin palauttama arvo on true. Erilaiset assert-metodit saadaan käyttöön luokan alussa olevalla määrittelyllä import static org.junit.Assert.*;.

Testit suoritetaan joko painamalla alt ja F6 tai valitsemalla Run -> Test project. (Macintosh-koneissa tulee painaa ctrl ja F6). Punainen väri ilmaisee että testin suoritus epäonnistui -- testattava toiminnallisuus ei toiminut kuten toivottiin. Vihreä väri kertoo että testin testaama toiminnallisuus toimi kuten haluttiin.

Luokan OmaPino toteutus

Pinon toteuttaminen testien avulla tapahtuisi askel kerrallaan siten, että lopulta kaikki testit toimivat. Ohjelman rakentaminen aloitetaan yleensä hyvin varovasti. Rakennetaan ensin luokka OmaPino siten, että ensimmäinen testi alussaTyhja alkaa toimimaan. Älä tee mitään kovin monimutkaista, "quick and dirty"-ratkaisu kelpaa näin alkuun. Kun testi menee läpi (eli näyttää vihreää), siirry ratkaisemaan seuraavaa kohtaa.

Testi alussaTyhja menee läpi aina kun palautamme arvon true metodista tyhja.

package pino;

import java.util.ArrayList;
import java.util.List;

public class OmaPino implements Pino {

    public OmaPino(int maksimikoko) {
    }

    @Override
    public boolean tyhja() {
        return true;
    }

    // tyhjät metodirungot

Siirrytään ratkaisemaan kohtaa lisayksenJalkeenEiTyhja. Tarvitsemme toteutuksen metodille pinoon. Yksi lähestymistapa on muokata luokkaa OmaPino siten, että se sisältää taulukon. Taulukkoa käytetään, että pinottavat luvut talletetaan pinon taulukkoon yksi kerrallaan. Seuraava kuvasarja selkeyttää taulukossa olevien alkioiden pinoon laittamista ja pinosta ottamista.

pino = new OmaPino(4);

  0   1   2   3
-----------------
|   |   |   |   |
-----------------
alkioita: 0

pino.pinoon(5);

  0   1   2   3
-----------------
| 5 |   |   |   |
-----------------
alkiota: 1

pino.pinoon(3);

  0   1   2   3
-----------------
| 5 | 3 |   |   |
-----------------
alkiota: 2

pino.pinoon(7);

  0   1   2   3
-----------------
| 5 | 3 | 7 |   |
-----------------
alkiota: 3

pino.pinosta();

  0   1   2   3
-----------------
| 5 | 3 |   |   |
-----------------
alkiota: 2

Ohjelman tulee siis muistaa kuinka monta alkiota pinossa on. Uusi alkio laitetaan jo pinossa olevien perään. Alkion poisto aiheuttaa sen, että taulukon viimeinen käytössä ollut paikka vapautuu ja alkiomäärän muistavan muuttujan arvo pienenee.

Luokan OmaPino toteutusta jatketaan askel kerrallaan kunnes kaikki testit menevät läpi. Jossain vaiheessa ohjelmoija todennäköisesti huomaisi, että taulukko kannattaa vaihtaa ArrayList-rakenteeksi.

Huomaat todennäköisesti ylläolevan esimerkin luettuasi että olet jo tehnyt hyvin monta testejä käyttävää ohjelmaa. Osa TMC:n toiminnallisuudesta rakentuu JUnit-testien varaan, ongelmat ovat varsinkin kurssin alkupuolella pilkottu pieniin testeihin, joiden avulla ohjelmoijaa on ohjattu eteenpäin. TMC:n mukana tulevat testit ovat kuitenkin usein monimutkaisempia kuin ohjelmien normaalissa automaattisessa testauksessa niiden tarvitsee olla. TMC:ssä ja kurssilla käytettävien testien kirjoittajien tulee muunmuassa varmistaa luokkien olemassaolo, jota normaalissa automaattisessa testauksessa harvemmin tarvitsee tehdä.

Harjoitellaan seuraavaksi ensin testien lukemista, jonka jälkeen kirjoitetaan muutama testi.

Tehtävälista

Tehtäväpohjassa on rajapinnan Tehtavalista toteuttava luokka MuistiTehtavalist. Ohjelmaa varten on koodattu valmiiksi testit, joita ohjelma ei kuitenkaan läpäise. Tehtävänäsi on tutustua testiluokkaan TehtavalistaTest, ja korjata luokka MuistiTehtavalista siten, että ohjelman testit menevät läpi.

Huom! Tässä tehtävässä sinun ei tarvitse koskea testiluokkaan TehtavalistaTest.

Lukutilasto

Huom! Tässä tehtävässä on jo mukana testiluokka, johon sinun tulee kirjoittaa lisää testejä. Vastauksen oikeellisuus testataan vasta TMC-palvelimella: tehtävästä saa pisteet vasta kun molemmat tehtävät on suoritettu palvelimella hyväksytysti. Ole tarkka metodien nimennän ja lisättyjen lukujen kanssa.

Tehtävässä tulee pakkauksessa tilasto sijaitseva luokka Lukutilasto.

Testikansiossa olevassa pakkauksessa tilasto on luokka LukutilastoTest, johon sinun tulee lisätä uusia testimetodeja.

Lukujen määrän kasvamisen tarkistus

Lisää testiluokkaan testimetodi public void lukujenMaaraKasvaaKahdellaKunLisataanKaksiLukua(), jossa lukutilastoon lisätään luvut 3 ja 5. Tämän jälkeen metodissa tarkistetaan että lukutilastossa on kaksi lukua käyttäen lukutilaston metodia lukujenMaara. Käytä Assert-luokan assertEquals-metodia palautettujen arvojen tarkastamiseen.

Summan tarkistus yhdellä luvulla

Lisää testiluokkaan testimetodi public void summaOikeinYhdellaLuvulla(), jossa lukutilastoon lisätään luku 3. Tämän jälkeen metodissa tarkistetaan lukutilaston summa-metodin avulla että tilastossa olevien lukujen summa on 3. Käytä Assert-luokan assertEquals-metodia palautettujen arvojen tarkastamiseen.

Käyttöliittymät


Huom! Osa käyttöliittymätehtävien testeistä avaa käyttöliittymän ja käyttää hiirtäsi käyttöliittymäkomponenttien klikkailuun. Kun suoritat käyttöliittymätehtävien testejä, älä käytä hiirtäsi!


Ohjelmamme ovat tähän mennessä koostuneet lähinnä sovelluslogiikasta ja sovelluslogiikkaa käyttävästä tekstikäyttöliittymästä. Muutamissa tehtävissä on ollut myös graafinen käyttöliittymä, mutta ne on yleensä luotu puolestamme. Tutustutaan seuraavaksi graafisten käyttöliittymien luomiseen Javalla.

Käyttöliittymät ovat ikkunoita, jotka sisältävät erilaisia osia kuten nappeja, tekstikenttiä ja valikkoja. Käyttöliittymien ohjelmoinnissa käytetään Javan Swing-komponenttikirjastoa, joka tarjoaa luokkia käyttöliittymäkomponenttien luomiseen ja käsittelyyn.

Käyttöliittymien peruselementti on luokka JFrame, jonka sisältämään komponenttiosioon käyttöliittymäkomponentit luodaan. Käyttöliittymät ovat suoritettavia, ja ne tulee käynnistää erikseen. Käytämme kurssilla seuraavanlaista käyttöliittymärunkoa, jonka päälle rakennamme toiminnallisuutta.

import java.awt.Container;
import java.awt.Dimension;
import javax.swing.JFrame;
import javax.swing.WindowConstants;

public class Kayttoliittyma implements Runnable {

    private JFrame frame;

    public Kayttoliittyma() {
    }

    @Override
    public void run() {
        frame = new JFrame("Otsikko");
        frame.setPreferredSize(new Dimension(200, 100));

        frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);

        luoKomponentit(frame.getContentPane());

        frame.pack();
        frame.setVisible(true);
    }

    private void luoKomponentit(Container container) {
    }

    public JFrame getFrame() {
        return frame;
    }
}

Tarkastellaan ylläolevan käyttöliittymäluokan koodia hieman tarkemmin.

public class Kayttoliittyma implements Runnable {

Luokka Kayttoliittyma toteuttaa Javan rajapinnan Runnable, joka tarjoaa mahdollisuuden säikeistettyyn ohjelman suorittamiseen. Säikeistetyllä suorittamisella voidaan suorittaa useita ohjelman osia rinnakkain. Emme tutustu säikeisiin tarkemmin -- lisää tietoa säikeistä tulee muunmuassa kurssilla Rinnakkaisohjelmointi.

    private JFrame frame;

Käyttöliittymä sisältää oliomuuttujana JFrame-olion -- ikkunan --, joka on näkyvän käyttöliittymän pohjaelementti. Kaikki käyttöliittymäkomponentit lisätään JFrame-olion sisältämään komponenttialueeseen. Huomaa että oliomuuttujia ei saa alustaa metodien ulkopuolella. Esimerkiksi oliomuuttujan JFrame alustus luokkamäärittelyssä "private JFrame frame = new JFrame()" kiertää käyttöliittymäsäikeiden suoritusjärjestyksen, ja voi johtaa ydintuhoon. Tai ohjelmasi kaatumiseen.

    @Override
    public void run() {
        frame = new JFrame("Otsikko");
        frame.setPreferredSize(new Dimension(200, 100));

        frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);

        luoKomponentit(frame.getContentPane());

        frame.pack();
        frame.setVisible(true);
    }

Rajapinta Runnable määrittelee metodin public void run(), joka jokaisen Runnable-rajapinnan toteuttajan tulee toteuttaa. Metodissa public void run() luodaan ensin uusi JFrame-ikkuna, jonka otsikoksi asetetaan "Otsikko". Tämän jälkeen asetetaan ikkunan toivotuksi kooksi 200, 100 -- leveydeksi tulee 200 pikseliä, korkeudeksi 100 pikseliä. Komento frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE); kertoo JFrame-oliolle, että käyttöliittymä tulee sulkea kun käyttäjä painaa käyttöliittymässä olevaa ruksia.

Tämän jälkeen kutsutaan luokassa myöhemmin määriteltyä metodia luoKomponentit. Metodille annetaan parametrina JFrame-olion Container-olio, johon voi lisätä käyttöliittymäkomponentteja.

Lopuksi kutsutaan metodia frame.pack(), joka asettaa JFrame-olion aiemmin määritellyn kokoiseksi ja järjestää JFrame-olion sisältämän Container-olion sisällä olevat käyttöliittymäkomponentit. Lopuksi kutsutaan metodia frame.setVisible(true), joka näyttää käyttöliittymän käyttäjälle.

    private void luoKomponentit(Container container) {
    }

Metodissa luoKomponentit lisätään JFrame-olion sisältämään komponenttialueeseen käyttöliittymäkomponentteja. Esimerkissämme ei ole yhtäkään käyttöliittymäkomponenttia JFrame-ikkunan lisäksi. Luokalla Kayttoliittyma on myös sen käyttöä helpottava metodi getFrame, jolla päästään käsiksi luokan kapseloimaan JFrame-olioon.

Swing-käyttöliittymät käynnistetään SwingUtilities-luokan tarjoaman invokeLater-metodin avulla. Metodi invokeLater saa parametrinaan Runnable-rajapinnan toteuttavan olion. Metodi asettaa Runnable-olion suoritusjonoon, ja kutsuu sitä kun ehtii. Luokan SwingUtilities avulla voimme käynnistää uusia säikeitä tarvittaessa.

import javax.swing.SwingUtilities;

public class Main {

    public static void main(String[] args) {
        Kayttoliittyma kayttoliittyma = new Kayttoliittyma();
        SwingUtilities.invokeLater(kayttoliittyma);
    }
}

Kun ylläoleva pääohjelmametodi suoritetaan, näemme luokassa Kayttoliittyma määrittellyn käyttöliittymän.

Käyttöliittymäkomponentit

Käyttöliittymä koostuu taustaikkunan (JFrame) sisältämästä komponenttipohjasta (Container), ja siihen asetetuista käyttöliittymäkomponenteista. Käyttöliittymäkomponentteja ovat erilaiset painikkeet, tekstit ym. Jokaiselle komponentille on oma luokka. Kannattaa tutustua Oraclen visuaalinen kuvasarjaan erilaisista komponenteista osoitteessa http://docs.oracle.com/javase/tutorial/ui/features/components.html.

Teksti

Tekstin näyttäminen tapahtuu JLabel-luokan avulla. Luokka JLabel tarjoaa käyttöliittymäkomponentin, jolle voi asettaa tekstiä ja jonka sisältämää tekstiä voi muokata. Teksti asetetaan joko konstruktorissa tai erillisellä setText-metodilla.

Muokataan käyttöliittymäpohjaamme siten, että siinä näkyy tekstiä. Luodaan uusi JLabel-tekstikomponentti metodissa luoKomponentit. Tämän jälkeen lisätään se JFrame-oliolta saatuun Container-olioon Container-olion add-metodia käyttäen.

    private void luoKomponentit(Container container) {
        JLabel teksti = new JLabel("Tekstikenttä!");
        container.add(teksti);
    }

Kuten yllä olevasta lähdekoodista näemme, JLabel-käyttöliittymäkomponentti tulee näyttämään tekstin "Tekstikenttä!". Kun suoritamme käyttöliittymän, näemme seuraavanlaisen ikkunan.

Tervehtijä

Toteuta käyttöliittymä, joka näyttää tekstin "Moi!". Tehtävä tulee toteuttaa tehtäväpohjassa tulevaan käyttöliittymärunkoon. JFrame-olion luominen ja näkyväksi asettamisen tulee tapahtua metodissa run(), tekstikomponentti lisätään käyttöliittymälle metodissa luoKomponentit(Container container).

Painikkeet

Käyttöliittymään saa painikkeita JButton-luokan avulla. JButton-olion lisääminen käyttöliittymään tapahtuu aivan kuin JLabel-olion lisääminen.

    private void luoKomponentit(Container container) {
        JButton nappi = new JButton("Click!");
        container.add(nappi);
    }

Yritetään seuraavaksi lisätä käyttöliittymään sekä tekstiä, että nappi.

    private void luoKomponentit(Container container) {
        JButton nappi = new JButton("Click!");
        container.add(nappi);
        JLabel teksti = new JLabel("Tekstiä.");
        container.add(teksti);
    }

Ohjelmaa suorittaessa näemme seuraavanlaisen käyttöliittymän.

Vain viimeiseksi lisätty käyttöliittymäkomponentti on näkyvillä, eikä ohjelma toimi toivotusti. Mistä tässä oikein on kyse?

Käyttöliittymäkomponenttien asettelu

Jokaisella käyttöliittymäkomponentilla on oma sijainti käyttöliittymässä. Komponentin sijainnin määrää käytössä oleva käyttöliittymän asettelija (Layout Manager). Yrittäessämme aiemmin lisätä useampia käyttöliittymäkomponentteja Container-olioon käyttöliittymässä oli vain yksi komponentti näkyvillä. Jokaisessa Container-oliossa on oletuksena käyttöliittymäasettelija BorderLayout.

BorderLayout asettelee käyttöliittymäkomponentit viiteen alueeseen: käyttöliittymän keskikohdan lisäksi käytössä on ilmansuunnat. Voimme antaa Container-olion add-metodille ylimääräisenä parametrina lisätoiveen kohdasta, johon haluamme asettaa käyttöliittymäkomponentin. BorderLayout-luokassa on käytössä luokkamuuttujat BorderLayout.NORTH, BorderLayout.EAST, BorderLayout.SOUTH, BorderLayout.WEST, ja BorderLayout.CENTER.

Käytettävä käyttöliittymäasettelija asetetaan Container-oliolle metodin setLayout-parametrina. Metodille add voidaan antaa käyttöliittymäkomponentin lisäksi paikka, johon komponentti lisätään. Alla on esimerkki, jossa jokaiseen BorderLayoutin tarjoamaan paikkaan asetetaan käyttöliittymäkomponentti.

    private void luoKomponentit(Container container) {
        container.setLayout(new BorderLayout());

        container.add(new JButton("Pohjoinen (North)"), BorderLayout.NORTH);
        container.add(new JButton("Itä (East)"), BorderLayout.EAST);
        container.add(new JButton("Etelä (South)"), BorderLayout.SOUTH);
        container.add(new JButton("Länsi (West)"), BorderLayout.WEST);
        container.add(new JButton("Keski (Center)"), BorderLayout.CENTER);

        container.add(new JButton("Oletuspaikka (Center)"));
    }

Huomaa, että nappi "Keski (Center)" ei tule näkymään käyttöliittymässä sillä nappi "Oletuspaikka (Center)" asetetaan oletuksena sen paikalle. Käyttöliittymässäpohjassa yllä oleva koodi näyttää seuraavalta.

Kuten käyttöliittymäkomponentteja, myös käyttöliittymän asettelijoita on useita. Oraclella on käyttöliittymäasettelijoihin visuaalinen opas osoitteessa http://docs.oracle.com/javase/tutorial/uiswing/layout/visual.html. Tutustutaan seuraavaksi käyttöliittymäasettelijaan BoxLayout.

BoxLayout

BoxLayoutia käytettäessä käyttöliittymäkomponentit asetetaan käyttöliittymään joko vaakasuunnassa tai pystysuunnassa. BoxLayoutin konstruktorille annetaan parametrina Container-olio, johon käyttöliittymäkomponentteja ollaan asettamassa, ja käyttöliittymäkomponenttien asettelusuunta. Asettelusuunta on joko BoxLayout.X_AXIS, eli komponentit vaakasuunnassa, tai BoxLayout.Y_AXIS, eli komponentit pystysuunnassa. Toisin kuin BorderLayout-asettelijaa käytettäessä, BoxLayoutilla ei ole rajattua määrää paikkoja. Container-olioon voi siis lisätä niin monta käyttöliittymäkomponenttia kuin haluaa.

Käyttöliittymän asettelu BoxLayout-asettelijaa käyttäen toimii kuten BorderLayout-asettelijan käyttö. Luomme ensin asettelijan, jonka asetamme Container-oliolle sen metodilla setLayout. Tämän jälkeen voimme lisätä käyttöliittymäkomponentteja Container-olion add-metodilla. Emme tarvitse erillistä sijaintia ilmaisevaa parametria. Alla esimerkki vaakasuunnassa asetetuista käyttöliittymäkomponenteista.

    private void luoKomponentit(Container container) {
        BoxLayout layout = new BoxLayout(container, BoxLayout.X_AXIS);
        container.setLayout(layout);

        container.add(new JLabel("Eka!"));
        container.add(new JLabel("Toka!"));
        container.add(new JLabel("Kolmas!"));
    }

Käyttöliittymäkomponenttien asettelu pystysuunnassa ei vaadi suurta muutosta. Vaihdamme BoxLayout-olion konstruktorille annettavaksi suuntaparametriksi BoxLayout.Y_AXIS.

    private void luoKomponentit(Container container) {
        BoxLayout layout = new BoxLayout(container, BoxLayout.Y_AXIS);
        container.setLayout(layout);

        container.add(new JLabel("Eka!"));
        container.add(new JLabel("Toka!"));
        container.add(new JLabel("Kolmas!"));
    }

Käyttöliittymäasettelijoita käyttämällä voimme luoda käyttöliittymiä, joissa käyttöliittymäkomponentit ovat aseteltu sopivasti. Alla on esimerkkikäyttöliittymä, jossa komponentit asetetaan pystysuuntaan. Ensin teksti, ja sitten vaihtoehtoinen valinta. Vaihtoehtoisen valinnan, eli valinnan jossa vain yksi vaihtoehto on aina voimassa, voi tehdä käyttämällä ButtonGroup-ryhmittelijää ja JRadioButton-painikkeita.

    private void luoKomponentit(Container container) {
        BoxLayout layout = new BoxLayout(container, BoxLayout.Y_AXIS);
        container.setLayout(layout);

        container.add(new JLabel("Valitse ruokavalio:"));

        JRadioButton liha = new JRadioButton("Liha");
        JRadioButton kala = new JRadioButton("Kala");

        ButtonGroup buttonGroup = new ButtonGroup();
        buttonGroup.add(liha);
        buttonGroup.add(kala);

        container.add(liha);
        container.add(kala);
    }

Kysely

Toteuta tehtäväpohjaan käyttöliittymä, joka näyttää seuraavalta:

Käytä käyttöliittymän asettelijana luokkaa BoxLayout, komponentteina luokkia JLabel, JRadioButton, JCheckBox ja JButton.

Käytä ButtonGroup-luokkaa varmistamaan että vaihtoehdot "Siksi" ja "Se on kivaa" eivät voi olla valittuina samaan aikaan.

Varmista että käyttöliittymä on niin iso, että käyttäjä voi klikata nappeja muuttamatta sen kokoa. Voit käyttää esimerkiksi leveytenä 200 pikseliä, korkeutena 300 pikseliä.

Tapahtumien käsittely

Tähänastiset graafiset käyttöliittymämme ovat -- vaikkakin hienoja -- hieman tylsiä: ne eivät reagoi millään tavalla käyttöliittymässä tehtyihin tapahtumiin. Reagoimattomuus ei johdu käyttöliittymäkomponenteista, vaan siitä että emme ole lisänneet käyttöliittymäkomponentteihin tapahtumia käsitteleviä kuuntelijoita.

Tapahtumankuuntelijat kuuntelevat käyttöliittymäkomponentteja joihin ne on liitetty. Aina kun käyttöliittymäkomponentille tehdään joku toiminto, -- esimerkiksi napille napin painaminen --, käyttöliittymäkomponentti kutsuu jokaisen siihen liitetyn tapahtumakuuntelijan tiettyä metodia. Käytännössä tapahtumankuuntelijat ovat tietyn rajapinnan toteuttavia luokkia, joiden ilmentymiä käyttöliittymäkomponentille voi lisätä. Tapahtuman tapahtuessa käyttöliittymäkomponentti käy jokaisen siihen liitetyn tapahtumankuuntelijan läpi, ja kutsuu rajapinnassa määriteltyä metodia.

Swing-käyttöliittymissä eniten käytetty tapahtumankuuntelurajapinta on ActionListener. Rajapinta ActionListener määrittelee metodin void actionPerformed(ActionEvent e), joka saa parametrinaan tapahtumasta kertovan ActionEvent-olion.

Toteutetaan ensimmäinen oma tapahtumankuuntelija, jonka tarkoituksena on vain tulostaa viesti standarditulostusvirtaan nappia painettaessa. Luokka ViestiKuuntelija toteuttaa rajapinnan ActionListener ja tulostaa viestin "Viesti vastaanotettu!" kun metodia actionPerformed kutsutaan.

import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

public class ViestiKuuntelija implements ActionListener {

    @Override
    public void actionPerformed(ActionEvent ae) {
        System.out.println("Viesti vastaanotettu!");
    }
}

Luodaan seuraavaksi käyttöliittymään JButton-tyyppinen nappi, ja lisätään siihen ViestiKuuntelija-luokan ilmentymä. Luokalle JButton voi lisätä tapahtumankuuntelijan käyttämällä sen yläluokassa AbstractButton määriteltyä metodia public void addActionListener(ActionListener actionListener).

    private void luoKomponentit(Container container) {
        JButton nappi = new JButton("Viestitä!");
        nappi.addActionListener(new ViestiKuuntelija());

        container.add(nappi);
    }

Käyttöliittymässä olevaa nappia painettaessa näemme standarditulostusvirrassa seuraavan viestin.

Viesti vastaanotettu!

Olioiden käsittely tapahtumankuuntelijoissa

Haluamme usein että tapahtumankuuntelija muokkaa jonkun olion tilaa. Päästäksemme olioon käsiksi tapahtumankuuntelijassa, tulee meidän antaa viite käsiteltävään olioon tapahtumankuuntelijalle sen konstruktorissa. Tapahtumankuuntelijat ovat täysin samanlaisia luokkia kuin muutkin Javan luokat, eli pääsemme ohjelmoimaan kaiken haluamamme toiminnallisuuden.

Pohditaan seuraavaa käyttöliittymää jossa on kaksi JTextArea-tyyppistä tekstikenttää, eli tekstikenttää johon käyttäjä voi syöttää tekstiä, ja JButton-tyyppinen nappi. Käyttöliittymä käyttää GridLayout-asettelijaa, jonka avulla käyttöliittymän voi rakentaa taulukkomaiseksi. GridLayout-luokan konstruktorille määriteltiin yksi rivi ja kolme saraketta.

    private void luoKomponentit(Container container) {
        GridLayout layout = new GridLayout(1, 3);
        container.setLayout(layout);

        JTextArea textAreaVasen = new JTextArea("Le Kopioija");
        JTextArea textAreaOikea = new JTextArea();
        JButton kopioiNappi = new JButton("Kopioi!");

        container.add(textAreaVasen);
        container.add(kopioiNappi);
        container.add(textAreaOikea);
    }

Haluamme lisätä käyttöliittymään toiminnallisuuden, jossa JButton-nappia painettaessa vasemman tekstikentän sisältö kopioituu oikeaan tekstikenttään. Tämä onnistuu toteuttamalla tapahtumankuuntelija. Luodaan rajapinnan ActionListener toteuttava luokka KenttienKopioija, joka kopioi JTextArea kentästä toiseen.

import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import javax.swing.JTextArea;

public class KenttienKopioija implements ActionListener {

    private JTextArea lahde;
    private JTextArea kohde;

    public KenttienKopioija(JTextArea lahde, JTextArea kohde) {
        this.lahde = lahde;
        this.kohde = kohde;
    }

    @Override
    public void actionPerformed(ActionEvent ae) {
        this.kohde.setText(this.lahde.getText());
    }
}

Tapahtumankuuntelijan rekisteröinti JButton-oliolle onnistuu metodilla addActionListener.

    private void luoKomponentit(Container container) {
        GridLayout layout = new GridLayout(1, 3);
        container.setLayout(layout);

        JTextArea textAreaVasen = new JTextArea("Le Kopioija");
        JTextArea textAreaOikea = new JTextArea();
        JButton kopioiNappi = new JButton("Kopioi!");

        KenttienKopioija kopioija = new KenttienKopioija(textAreaVasen, textAreaOikea);
        kopioiNappi.addActionListener(kopioija);

        container.add(textAreaVasen);
        container.add(kopioiNappi);
        container.add(textAreaOikea);
    }

Nappia painettaessa vasemman tekstikentän sisältö kopioituu oikealla olevaan tekstikenttään.

Ilmoitin

Toteuta tehtäväpohjaan käyttöliittymä, joka näyttää seuraavalta.

Ohjelman tulee koostua seuraavista pakkauksessa ilmoitin olevista luokista. Luokka Ilmoitin on käyttöliittymäluokka, joka käynnistetään Main-luokasta. Ilmoittimessa on käyttöliittymäkomponentteina JTextField, JButton, ja JLabel. Voit asetella käyttöliittymäkomponentit GridLayout-asettelijan avulla: kutsu new GridLayout(3, 1) luo uuden asettelijan, joka asettelee kolme käyttöliittymäelementtiä pystysuunnassa.

Sovelluksessa tulee olla lisäksi luokka TapahtumanKuuntelija, joka toteuttaa rajapinnan ActionListener. Tapahtumankuuntelijan tulee kopioida käyttöliittymässä olevan JTextField-kentän sisältö JLabel-kenttään. Ohjelman oleellinen toiminnallisuus on siis JTextField-kentän sisällön kopioiminen JLabel-kenttään napin painalluksella.

Huom! Varmista että käyttöliittymä käynnistyy niin isona että jokaista nappulaa voi klikata.

Sovelluslogiikan ja käyttöliittymälogiikan eriyttäminen

Sovelluslogiikan (esimerkiksi tallennus- tai lukutoiminnallisuuden) ja käyttöliittymän sekoittaminen samoihin luokkiin on yleisesti ottaen huono asia. Se vaikeuttaa ohjelman testaamista ja muokkaamista huomattavasti, ja tekee koodista myös paljon vaikeammin luettavaa. Single responsibility principlen sanoin "Jokaisella luokalla pitäisi olla vain yksi selkeä vastuu". Sovelluslogiikan erottaminen käyttöliittymälogiikasta onnistuu sopivan rajapintasuunnittelun kautta. Oletetaan että käytössämme on kappaleessa 56. Tallennustoiminnallisuuden eriyttäminen määritelty rajapinta HenkiloDAO, ja haluamme toteuttaa käyttöliittymän henkilöiden tallentamiseen.

public interface HenkiloDAO {
    void talleta(Henkilo henkilo);
    Henkilo hae(String henkilotunnus);

    void poista(Henkilo henkilo);
    void poista(String henkilotunnus);
    void poistaKaikki();

    Collection<Henkilo> haeKaikki();
}

Käyttöliittymän toteutus

Käyttöliittymää toteutettaessa hyvä aloitustapa on sopivien käyttöliittymäkomponenttien lisääminen käyttöliittymään. Henkilöiden tallennuksessa tarvitsemme kentät nimelle ja henkilötunnukselle, sekä napin jolla henkilö voidaan lisätä. Käytetään Javan JTextField-luokkaa tekstin syöttämiseen, ja JButton-luokkaa napin toteuttamiseen. Luodaan käyttöliittymään lisäksi selventävät JLabel-tyyppiset selitystekstit.

Käytetään käyttöliittymän asetteluun GridLayout-asettelijaa. Rivejä käyttöliittymässä on 3, sarakkeita 2. Lisätään tapahtumankuuntelija myöhemmin. Käyttoliittymäluokan metodi luoKomponentit näyttää nyt seuraavalta.

    private void luoKomponentit(Container container) {
        GridLayout layout = new GridLayout(3, 2);
        container.setLayout(layout);

        JLabel nimiTeksti = new JLabel("Nimi: ");
        JTextField nimiKentta = new JTextField();
        JLabel hetuTeksti = new JLabel("Hetu: ");
        JTextField hetuKentta = new JTextField();

        JButton lisaaNappi = new JButton("Lisää henkilö!");
        // tapahtumankuuntelija

        container.add(nimiTeksti);
        container.add(nimiKentta);
        container.add(hetuTeksti);
        container.add(hetuKentta);
        container.add(new JLabel(""));
        container.add(lisaaNappi);
    }

Käyttöliittymä näyttää seuraavalta kun siihen on lisätty tietoa.

Tapahtumankuuntelijan tulee tietää tallennustoiminnallisuudesta -- eli HenkiloDAO-rajapinnasta sekä kentistä, joita se käyttää. Luodaan ActionListener-rajapinnan toteuttava luokka HenkilonLisaysKuuntelija, joka saa konstruktorissaan parametrina HenkiloDAO-rajapinnan toteuttavan olion sekä kaksi JTextField-oliota -- kentät nimelle ja hetulle. Metodissa actionPerformed luodaan uusi Henkilo-olio ja tallennetaan se HenkiloDAO-olion tarjoamalla talleta-metodilla.

public class HenkilonLisaysKuuntelija implements ActionListener {

    private HenkiloDAO henkiloDao;
    private JTextField nimiKentta;
    private JTextField hetuKentta;

    public HenkilonLisaysKuuntelija(HenkiloDAO henkiloDao, JTextField nimiKentta, JTextField hetuKentta) {
        this.henkiloDao = henkiloDao;
        this.nimiKentta = nimiKentta;
        this.hetuKentta = hetuKentta;
    }

    @Override
    public void actionPerformed(ActionEvent ae) {
        Henkilo henkilo = new Henkilo(nimiKentta.getText(), hetuKentta.getText());
        this.henkiloDao.talleta(henkilo);
    }
}

Jotta saamme HenkiloDAO-viitteen HenkilonLisaysKuuntelija-oliolle, tulee sen olla käyttöliittymän tiedossa. Lisätään käyttöliittymälle oliomuuttuja private HenkiloDAO henkiloDao, joka asetetaan konstruktorissa. Luokan Kayttoliittyma konstruktoria muokataan siten, että sille annetaan HenkiloDAO-rajapinnan toteuttama luokka.

public class Kayttoliittyma implements Runnable {

    private JFrame frame;
    private HenkiloDAO henkiloDao;

    public Kayttoliittyma(HenkiloDAO henkiloDao) {
        this.henkiloDao = henkiloDao;
    }
    // ...


Voimme nyt luoda tapahtumankuuntelijan HenkilonLisaysKuuntelija, jolle annetaan sekä HenkiloDAO-viite, että kentät.

    private void luoKomponentit(Container container) {
        GridLayout layout = new GridLayout(3, 2);
        container.setLayout(layout);

        JLabel nimiTeksti = new JLabel("Nimi: ");
        JTextField nimiKentta = new JTextField();
        JLabel hetuTeksti = new JLabel("Hetu: ");
        JTextField hetuKentta = new JTextField();

        JButton lisaaNappi = new JButton("Lisää henkilö!");
        HenkilonLisaysKuuntelija kuuntelija = new HenkilonLisaysKuuntelija(henkiloDao, nimiKentta, hetuKentta);
        lisaaNappi.addActionListener(kuuntelija);

        container.add(nimiTeksti);
        container.add(nimiKentta);
        container.add(hetuTeksti);
        container.add(hetuKentta);
        container.add(new JLabel(""));
        container.add(lisaaNappi);
    }

Muutetaan vielä pääohjelmaluokan Main sisältöä siten, että käyttöliittymälle annetaan HenkiloDAO-rajapinnan toteuttava luokka. Voimme käyttää suoraan kappaleessa 56. Tallennustoiminnallisuuden eriyttäminen määriteltyä luokkaa MuistiHenkiloDAO sillä se toteuttaa rajapinnan HenkiloDAO.

public class Main {
    public static void main(String[] args) {
        HenkiloDAO henkiloDao = new MuistiHenkiloDAO();
        SwingUtilities.invokeLater(new Kayttoliittyma(henkiloDao));
    }
}

Nyt käyttöliittymässä lisätyt henkilöt tallennetaan MuistiHenkiloDAO-luokan määrittelemällä tavalla ja sovellus toimii. Huomaa että emme joutuneet toteuttamaan tallennuslogiikkaa erikseen, koska se oli toteutettu jo kertaalleen. Käytännössä aloitimme tekstikäyttöliittymästä graafiseen käyttöliittymään siirtymisen.

Axe Click Effect

Tässä tehtävässä toteutetaan laskuri klikkausten laskemiseen. Tehtävässä sovelluslogiikka -- eli laskeminen -- ja käyttöliittymälogiikka on erotettu toisistaan. Lopullisen sovelluksen tulee näyttää kutakuinkin seuraavalta.

OmaLaskuri

Toteuta pakkaukseen clicker.sovelluslogiikka rajapinnan Laskuri toteuttava luokka OmaLaskuri. Luokan OmaLaskuri metodin annaArvo palauttama luku on aluksi 0. Kun metodia kasvata kutsutaan, kasvaa arvo aina yhdellä.

Voit halutessasi testata luokan toimintaa seuraavan ohjelman avulla.

        Laskuri laskuri = new OmaLaskuri();
        System.out.println("Arvo: " + laskuri.annaArvo());
        laskuri.kasvata();
        System.out.println("Arvo: " + laskuri.annaArvo());
        laskuri.kasvata();
        System.out.println("Arvo: " + laskuri.annaArvo());
Arvo: 0
Arvo: 1
Arvo: 2

KlikkaustenKuuntelija

Toteuta pakkaukseen clicker.kayttoliittyma rajapinnan ActionListener toteuttava luokka KlikkaustenKuuntelija. Luokka KlikkaustenKuuntelija saa konstruktorin parametrina Laskuri-rajapinnan toteuttavan olion ja JLabel-olion.

Toteuta actionPerformed-metodi siten, että Laskuri-oliota kasvatetaan aluksi yhdellä, jonka jälkeen laskurin arvo asetetaan JLabel-olion tekstiksi. JLabel-olion tekstiä voidaan muuttaa metodilla setText.

Käyttöliittymä

Muokkaa luokkaa Kayttoliittyma siten, että käyttöliittymä saa konstruktorin parametrina Laskuri-olion -- tarvitset uuden konstruktorin. Lisää käyttöliittymään tarvittavat käyttöliittymäkomponentit. Rekisteröi napille myös edellisessä osassa toteutettu tapahtumankuuntelija.

Käytä käyttöliittymäkomponenttien asetteluun BorderLayout-luokan tarjoamia toiminnallisuuksia. Muuta myös Main-luokkaa siten, että käyttöliittymälle annetaan OmaLaskuri-olio. Kun käyttöliittymässä olevaa "Click!"nappia on painettu kahdesti, sovellus näyttää kutakuinkin seuraavalta.

Jätkänshakin sovelluslogiikka

Tämä tehtävä on kolmen yksittäisen tehtäväpisteen arvoinen. Tehtävässä toteutetaan sovelluslogiikka jätkänshakille ja harjoitellaan ohjelmarakenteen osittaista omatoimista suunnittelua.

Tehtäväpohjassa tulee mukana käyttöliittymä jätkänshakille, jossa pelilaudan koko on aina 3x3 ruutua. Käyttöliittymä huolehtii ainoastaan pelilaudalla tehtyihin tapahtumiin reagoimisesta, sekä pelilaudan ja pelitilanteen tietojen päivittämisestä. Pelin logiikka on erotettu JatkanshakinSovelluslogiikka-rajapinnan avulla omaksi luokakseen.

package jatkanshakki.sovelluslogiikka;

public interface JatkanshakinSovelluslogiikka {
    char getNykyinenVuoro();
    int getMerkkienMaara();

    void asetaMerkki(int sarake, int rivi);
    char getMerkki(int sarake, int rivi);

    boolean isPeliLoppu();
    char getVoittaja();
}

Rajapinnan JatkanshakinSovelluslogiikka lisäksi tehtäväpohjassa on apuluokka, joka määrittelee pelilaudan ruutujen mahdolliset tilat char-tyyppisinä kirjaimina. Ruutu voi olla joko tyhjä, tai siinä voi olla risti tai nolla. Apuluokassa Jatkanshakki on näille määrittelyt:

package jatkanshakki.sovelluslogiikka;

public class Jatkanshakki {
    public static final char RISTI = 'X';
    public static final char NOLLA = 'O';
    public static final char TYHJA = ' ';
}

Tehtävänäsi on täydentää pakkauksessa jatkanshakki.sovelluslogiikka olevaa rajapinnan JatkanshakinSovelluslogiikka toteuttavaa luokkaa OmaJatkanshakinSovelluslogiikka. Luokka OmaJatkanshakinSovelluslogiikka mahdollistaa jätkänshakin pelaamisen.

Rajapinta JatkanshakinSovelluslogiikka määrittelee seuraavat toiminnot, jotka luokan OmaJatkanshakinSovelluslogiikka tulee toteuttaa:

  • char getNykyinenVuoro() palauttaa pelaajan merkkiä vastaavan arvon: RISTI, NOLLA tai pelin päätyttyä TYHJA
  • int getMerkkienMaara() palauttaa pelilaudalle tähän mennessä asetettujen merkkien määrän (välillä 0-9)
  • void asetaMerkki(int sarake, int rivi) asettaa pelaajan vuoron mukaisen merkin annettuun ruutuun sarakkeen (0-2) ja rivin (0-2) perusteella ja antaa vuoron toiselle pelaajalle. Metodi heittää poikkeuksen IllegalArgumentException, jos sarake tai rivi on pelilaudan ulkopuolella tai ruudussa on jo merkki, ja poikkeuksen IllegalStateException, jos peli on jo loppu.
  • char getMerkki(int sarake, int rivi) palauttaa sarakkeen ja rivin määrittelemän ruudun tilan, joka voi olla TYHJA, RISTI tai NOLLA. Metodi heittää poikkeuksen IllegalArgumentException, jos sarake tai rivi on pelilaudan ulkopuolella.
  • boolean isPeliLoppu() palauttaa arvon true, jos toinen pelaajista voitti pelin tai peli päättyi tasapeliin, muutoin metodi palauttaa false
  • char getVoittaja() palauttaa arvon TYHJA, jos peli on kesken tai peli päättyi tasapeliin, muutoin metodi palauttaa voittajan merkin: RISTI tai NOLLA

Ensimmäinen pelivuoro on aina merkillä RISTI. Pelin voittaa se pelaaja, joka saa ensimmäisenä kolme merkkiä vaakasuoraan, pystysuoraan tai vinottain. Tasapeli todetaan vasta, kun pelilauta on täynnä merkkejä eli tyhjiä ruutuja ei enää ole.

Vinkki: Pelilaudan tilanteen voi esittää esimerkiksi yhdeksän alkion char-taulukolla, jonne talletetaan peliruutujen tilat. Sarakkeen ja rivin perusteella voidaan laskea taulukon indeksi: rivi * 3 + sarake.

Tiedostojen valitseminen käyttöliittymästä

Silloin tällöin eteen tulee tilanne, jossa käyttäjän pitää pystyä valitsemaan tiedosto tiedostojärjestelmästä. Java tarjoaa tiedostojen valintaan valmiin käyttöliittymäkomponentin JFileChooser.

JFileChooser poikkeaa tähän mennessä käyttämistämme käyttöliittymäkomponenteista siinä, että se avaa uuden ikkunan. Avautuvan ikkunan ulkonäkö riippuu hieman käyttöjärjestelmästä, esimerkiksi hieman vanhemmassa Fedora-käyttöjärjestelmässä ikkuna on seuraavannäköinen.

JFileChooser-olio voidaan luoda missä tahansa. Olion metodille showOpenDialog annetaan parametrina käyttöliittymäkomponentti, johon se liittyy -- esimerkiksi JFrame-luokan ilmentymä. Metodi showOpenDialog avaa tiedostonvalintaikkunan, ja palauttaa int-tyyppisen statuskoodin riippuen käyttäjän valinnasta. Luokassa JFileChooser on määritelty int-tyyppiset luokkamuuttujat, jotka kuvaavat statuskoodeja. Esimerkiksi onnistuneella valinnalla on arvo JFileChooser.APPROVE_OPTION.

Valittuun tiedostoon pääsee JFileChooser-oliosta käsiksi metodilla getSelectedFile.

    JFileChooser chooser = new JFileChooser();

    int valinta = chooser.showOpenDialog(frame);

    if (valinta == JFileChooser.APPROVE_OPTION) {
        File valittu = chooser.getSelectedFile();
        System.out.println("Valitsit tiedoston: " + valittu.getName());
    } else if (valinta == JFileChooser.CANCEL_OPTION) {
        System.out.println("Et valinnut tiedostoa!");
    }

Yllä oleva esimerkki avaa valintaikkunan, ja tulostaa valitun tiedoston nimen jos valinta onnistuu. Jos valinta epäonnistuu, ohjelma tulostaa "Et valinnut tiedostoa!".

Tiedostojen filtteröinti

Tiedostojen filtteröinnillä tarkoitetaan vain tietynlaisten tiedostojen näyttämistä tiedostoikkunassa. JFileChooser-oliolle voi asettaa filtterin metodilla setFileFilter. Metodi setFileFilter saa parametrina abstraktin luokan FileFilter-ilmentymän -- esimerkiksi luokasta FileNameExtensionFilter tehdyn olion.

Luokka FileNameExtensionFilter mahdollistaa tiedostojen filtteröinnin niiden päätteiden perusteella. Esimerkiksi pelkät txt-päätteiset tekstitiedostot saa näkyviin seuraavasti.

    JFileChooser chooser = new JFileChooser();
    chooser.setFileFilter(new FileNameExtensionFilter("Tekstitiedostot", "txt"));

    int valinta = chooser.showOpenDialog(frame);

    if (valinta == JFileChooser.APPROVE_OPTION) {
        File valittu = chooser.getSelectedFile();
        System.out.println("Valitsit tiedoston: " + valittu.getName());
    } else if (valinta == JFileChooser.CANCEL_OPTION) {
        System.out.println("Et valinnut tiedostoa!");
    }

Tiedostonnäytin

Tässä tehtävässä toteutetaan ohjelma, joka lukee käyttäjän valitseman tiedoston ja näyttää sen sisällön käyttöliittymässä.

Ohjelmassa on eroteltu käyttöliittymään ja sovelluslogiikka. Tehtäväpohjassa on valmiina sovelluslogiikan rajapinta TiedostonLukija sekä käyttöliittymäluokan runko Kayttoliittyma.

Käyttöliittymän rakentaminen

Täydennä käyttöliittymäluokan metodi luoKomponentit. Ohjelma tarvitsee toimiakseen kolme käyttöliittymäkomponenttia:

Koska tässä tehtävässä on vain kolme aseteltavaa komponenttia, riittävät asetteluun BorderLayout-asettelijan vaihtoehdot: BorderLayout.NORTH, BorderLayout.CENTER ja BorderLayout.SOUTH. Käyttöliittymäkomponentti JTextArea kannattaa sijoittaa keskelle, jotta se saa mahdollisimman paljon tilaa tekstin näyttämiselle.

Käyttöliittymän pitäisi näyttää suunnilleen seuraavalta. Alla olevassa esimerkissä JLabel-oliossa ei ole mitään tekstiä.

Tiedoston lukeminen

Luo pakkaukseen tiedostonnaytin.sovelluslogiikka luokka OmaTiedostonLukija, joka toteuttaa rajapinnan TiedostonLukija. Rajapinnassa on yksi metodi, lueTiedosto, joka lukee sille annetun tiedoston kokonaisuudessaan merkkijonoon ja palauttaa tämän merkkijonon.

Rajapinnan koodi:

package tiedostonnaytin.sovelluslogiikka;

import java.io.File;

public interface TiedostonLukija {
    String lueTiedosto(File tiedosto);
}

Huom: Palautettavassa merkkijonossa tulee säilyttää myös rivinvaihdot "\n". Esimerkiksi Scanner-lukijan metodi nextLine() poistaa palauttamistaan merkkijonoista rivinvaihdot, joten joudut joko lisäämään ne takaisin tai lukemaan tiedostoa eri tavalla.

Käyttöliittymän kytkeminen sovelluslogiikkaan

Viimeisessä tehtävän osassa toteutetaan käyttöliittymän JButton-napille tapahtumankuuntelija. Saat itse päättää luokalle sopivan nimen.

Tapahtumankuuntelijan tehtävänä on näyttää JFileChooser-tiedostonvalintaikkuna kun JButton-nappia painetaan. Kun käyttäjä valitsee tiedoston, tulee tapahtumankuuntelijan lukea tiedoston sisältö ja näyttää se JTextArea-kentässä. Tämän jälkeen tapahtumankuuntelijan tulee vielä päivittää JLabel-kenttään näytetyn tiedoston nimi (ilman tiedostopolkua).

JFileChooser-olion metodille showOpenDialog tulee antaa parametrina Kayttoliittyma-luokassa oleva JFrame-ikkunaolio. Jos käyttäjä valitsee tiedoston, tulee tiedosto lukea tapahtumankuuntelijassa Kayttoliittyma-luokassa määriteltyä TiedostonLukija-oliota apuna käyttäen. Kannattaa luoda tapahtumankuuntelija siten, että sille annetaan konstruktorissa kaikki tarvitut oliot.

Huomaa, että valintaikkunan voi myös sulkea valitsematta tiedostoa!

Kun tiedosto on avattu, tulee käyttöliittymän näyttää esimerkiksi seuraavalta.

Sisäkkäiset Container-oliot

Törmäämme silloin tällöin tilanteeseen, jossa JFrame-luokan tarjoama Container-olio ei riitä käyttöliittymän asetteluun. Saatamme tarvita erilaisia käyttöliittymänäkymiä tai mahdollisuutta käyttöliittymäkomponenttien ryhmittelyyn niiden käyttötarkoituksen mukaan. Esimerkiksi alla olevan käyttöliittymän rakentaminen ei olisi kovin helppoa vain JFrame-luokan tarjoamalla Container-oliolla.

Voimme asettaa Container-tyyppisiä olioita toistensa sisään. Luokka JPanel (katso myös How to Use Panels) mahdollistaa sisäkkäiset Container-oliot. JPanel-luokan ilmentymään voi lisätä käyttöliittymäkomponentteja samalla tavalla kuin JFrame-luokasta saatuun Container-ilmentymään. Tämän lisäksi JPanel-luokan ilmentymän voi lisätä Container-olioon. Tämä mahdollistaa useamman Container-olion käyttämisen käyttöliittymän suunnittelussa.

Yllä olevan käyttöliittymän luominen on helpompaa JPanel-luokan avulla.. Luodaan käyttöliittymä, jossa on kolme nappia "Suorita", "Testaa", ja "Lähetä", sekä tekstialue joka sisältää tekstiä. Napit ovat oma joukkonsa, joten tehdään niille erillinen JPanel-olio joka asetetaan JFrame-luokasta saadun Container-olion eteläosaan. Tekstialue tulee keskelle.

    private void luoKomponentit(Container container) {
        container.add(new JTextArea());
        container.add(luoValikko(), BorderLayout.SOUTH);
    }

    private JPanel luoValikko() {
        JPanel panel = new JPanel(new GridLayout(1, 3));
        panel.add(new JButton("Suorita"));
        panel.add(new JButton("Testaa"));
        panel.add(new JButton("Lähetä"));
        return panel;
    }

JPanel-luokalle annetaan konstruktorin parametrina käytettävä asettelutyyli. Jos asettelutyyli tarvitsee konstruktorissaan viitteen käytettyyn Container-olioon, on JPanel-luokalla myös metodi setLayout.

Jos käyttöliittymässämme on selkeät erilliset kokonaisuudet, voimme myös periä JPanel luokan. Esimerkiksi ylläolevan valikon voisi toteuttaa myös seuraavasti.

import java.awt.GridLayout;
import javax.swing.JButton;
import javax.swing.JPanel;

public class ValikkoPanel extends JPanel {

    public ValikkoPanel() {
        super(new GridLayout(1, 3));
        luoKomponentit();
    }

    private void luoKomponentit() {
        add(new JButton("Suorita"));
        add(new JButton("Testaa"));
        add(new JButton("Lähetä"));
    }
}

Nyt käyttöliittymäluokassa voidaan luoda ValikkoPanel-luokan ilmentymä.

    private void luoKomponentit(Container container) {
        container.add(new JTextArea());
        container.add(new ValikkoPanel(), BorderLayout.SOUTH);
    }

Huomaa että tapahtumankäsittelyä tarvittaessa luokalle ValikkoPanel tulee antaa parametrina kaikki tarvittavat oliot.

Tilastoiva kysely

Tässä tehtävässä tehdään tilastoja keräävä versio tehtävästä 157. Tehtävänäsi on toteuttaa käyttöliittymäluokan Kysely komponenttien luominen ja vastausten lisääminen Tilasto-olioon. Käyttöliittymäluokka Kysely sisältää kokoelman Kysymys-olioita, jotka sen tulee näyttää käyttäjälle. Käyttäjä voi vastata kysymyksiin, ja lähettää lopuksi vastaukset nappia painamalla. Kun vastaukset lähetetään, ne tulee tilastoida Tilasto-olioon.

Kysymykset ja tilasto annetaan käyttöliittymäluokalle konstruktorin parametreina.

    List<Kysymys> kysymykset = new ArrayList<Kysymys>();

    Kysymys kysymys = new Kysymys("Kumpi vai kampi?");
    kysymys.lisaaVaihtoehto("Kumpi");
    kysymys.lisaaVaihtoehto("Kampi");
    kysymykset.add(kysymys);

    kysymys = new Kysymys("?");
    kysymys.lisaaVaihtoehto("A");
    kysymys.lisaaVaihtoehto("B");
    kysymys.lisaaVaihtoehto("C");
    kysymys.lisaaVaihtoehto("D");
    kysymykset.add(kysymys);

    kysymys = new Kysymys("Onko MOOC kiva?");
    kysymys.lisaaVaihtoehto("On!");
    kysymykset.add(kysymys);


    SwingUtilities.invokeLater(new Kysely(kysymykset, new Tilasto()));

Käyttöliittymän tulee olla seuraavanlainen yllä annetuilla kysymyksillä.

Käyttöliittymän ulkomuoto

Muokkaa käyttöliittymäluokan Kysely toiminnallisuutta siten, että sen ulkonäkö on kuten ylläolevassa kuvassa. Huomaa että jokaisen kysymyksen vastausvaihtoehdot tulee olla omassa ButtonGroup-ryhmässä, eli jokaiseen kysymykseen saa antaa vain yhden vastauksen.

Kannattanee myös mahdollisesti käyttää useampaa kuin yhtä Container-oliota. Luokista JPanel ja BoxLayout lienee tässä hyötyä.

Huom! Kysymykset ja vaihtoehdot tulee piirtää käyttöliittymään siinä järjestyksessä kun ne annetaan. Vaihtoehdot tulee JRadioButton-nappuloilla, joista vain yksi (per kysymys) voi olla valittuna.

Vastauksen tilastointi

Luo käyttöliittymän lopussa olevalle napille "Valmis" tapahtumankuuntelija, joka rekisteröi kaikki valitut vastaukset kutsumalla tilasto-olion tilastoiVastaus-metodia.

Piirtäminen

Luokkaa JPanel käytetään Container-toiminnallisuuden lisäksi usein piirtoalustana siten, että käyttäjä perii luokan JPanel ja korvaa metodin protected void paintComponent(Graphics graphics). Käyttöliittymä kutsuu metodia paintComponent aina kun käyttöliittymäkomponentin sisältö halutaan piirtää ruudulle. Metodi paintComponent saa käyttöliittymältä parametrina abstraktin luokan Graphics toteuttavan olion. Luodaan luokan JPanel perivä luokka Piirtoalusta, joka korvaa paintComponent-metodin.

public class Piirtoalusta extends JPanel {

    public Piirtoalusta() {
        super.setBackground(Color.WHITE);
    }

    @Override
    protected void paintComponent(Graphics graphics) {
        super.paintComponent(graphics);
    }
}

Yllä oleva piirtoalusta ei sisällä konkreettista piirtämistoiminnallisuutta. Asetamme konstruktorissa piirtoalustan taustan valkoiseksi kutsumalla yläluokan metodia setBackground. Metodin setBackGround saa parametrina Color-luokan ilmentymän. Luokka Color sisältää yleisimmät värit luokkamuuttujina -- esimerkiksi väri valkoinen löytyy luokkamuuttujasta Color.WHITE.

Korvattu paintComponent metodi kutsuu yläluokan paintComponent-metodia eikä tee muuta. Lisätään piirtoalusta seuraavaksi käyttöliittymäluokan luoKomponentit-metodiin. Käytämme kappaleen 59. Käyttöliittymät alussa määriteltyä käyttöliittymäpohjaa.

    private void luoKomponentit(Container container) {
        container.add(new Piirtoalusta());
    }

Käynnistäessämme käyttöliittymän näemme tyhjän ruudun, jonka taustaväri on valkoinen. Alla olevan käyttöliittymän toivotuksi kooksi on asetettu setPreferredSize-metodilla 300, 300, ja sen otsikko on "Piirtoalusta".

Piirtoalustalle piirtäminen tapahtuu Graphics-olion tarjoamien metodien avulla. Muokataan Piirtoalusta-luokan metodia paintComponent siten, että siinä piirretään kaksi suorakulmiota Graphics-olion tarjoaman metodin fillRect avulla.

    @Override
    protected void paintComponent(Graphics graphics) {
        super.paintComponent(graphics);

        graphics.fillRect(50, 80, 100, 50);
        graphics.fillRect(200, 20, 50, 200);
    }

Metodi fillRect saa parametrina suorakulmion x, ja y -koordinaatit, sekä suorakulmion leveyden ja korkeuden tässä järjestyksessä. Yllä siis piirretään ensin koordinaatista (50, 80) alkava 100 pikseliä leveä ja 50 pikseliä korkea suorakulmio. Tämän jälkeen piirretään koordinaatista (200, 20) alkava 50 pikseliä leveä ja 100 pikseliä korkea suorakulmio.

Kuten piirtotuloksesta huomaat, koordinaatisto ei toimi aivan kuten olemme tottuneet.

Javan Graphics-olio (ja useiden muiden ohjelmointikielten käyttöliittymäkirjastot) olettaa että y-akselin arvo kasvaa alaspäin mennessä. Koordinaatiston origo, eli piste (0, 0) on piirrettävän alueen vasemmassa yläkulmassa: Graphics-olio tietää aina käyttöliittymäkomponentin, johon piirretään, ja osaa sen perusteella päätellä piirtotapahtuman sijainnin. Käyttöliittymän origon sijainti selkeytyy seuraavalla ohjelmalla. Piirretään ensin pisteestä (0, 0) lähtevä 10 pikseliä leveä ja 200 pikseliä korkea vihreä suorakulmio. Tämän jälkeen piirretään pisteestä (0, 0) lähtevä 200 pikseliä leveä ja 10 pikseliä korkea musta. Seuraavana piirrettävän kuvion väri määritellään Graphics-oliolle metodilla setColor.

    @Override
    protected void paintComponent(Graphics graphics) {
        super.paintComponent(graphics);

        graphics.setColor(Color.GREEN);
        graphics.fillRect(0, 0, 10, 200);
        graphics.setColor(Color.BLACK);
        graphics.fillRect(0, 0, 200, 10);
    }

Tämä koordinaatiston käänteisyys johtuu siitä, miten käyttöliittymien kokoa muokataan. Käyttöliittymän kokoa muutettaessa sitä pienennetään tai suurennetaan "oikeasta alakulmasta vetäen", jolloin ruudulla näkyvä piirros siirtyy kokoa muuttaessa. Kun koordinaatisto alkaa vasemmasta yläkulmasta, on piirroksen sijainti aina sama, mutta näkyvä osa muuttuu.

Piirtoalusta ja Piirtäminen

Tehtäväpohjassa on valmiina käyttöliittymä, johon on kytketty JPanel-luokan perivä luokka Piirtoalusta. Muuta luokan Piirtoalusta metodin paintComponent toteutusta siten, että se piirtää seuraavanlaisen kuvion. Saat käyttää tehtävässä vain graphics-olion fillRect-metodia.

Huom! Älä käytä enempää kuin viittä fillRect-kutsua. Kuvion ei tarvitse olla täsmälleen samanlainen kuin ylläoleva -- testit kertovat kun piirtämäsi kuva on tarpeeksi lähellä haluttua kuvaa.

Laajennetaan edellistä esimerkkiä siten, että piirrämme käyttöliittymässä erillinen hahmo-olio. Luodaan hahmon edustamiseen luokka Hahmo. Hahmolla on koordinaatteina ilmaistu sijainti, ja se piirretään ympyränä jonka halkaisija on 10 pikseliä. Hahmon sijaintia voi muuttaa kutsumalla sen siirry-metodia.

import java.awt.Graphics;

public class Hahmo {

    private int x;
    private int y;

    public Hahmo(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int getX() {
        return x;
    }

    public int getY() {
        return y;
    }

    public void siirry(int xmuutos, int ymuutos) {
        this.x += xmuutos;
        this.y += ymuutos;
    }

    public void piirra(Graphics graphics) {
        graphics.fillOval(x, y, 10, 10);
    }
}

Muutetaan piirtoalustaa siten, että sille annetaan Hahmo-luokan ilmentymä konstruktorin parametrina. Luokan Piirtoalusta metodi paintComponent ei itse piirrä hahmoa, vaan delegoi piirtovastuun Hahmo-luokan ilmentymälle.

import java.awt.Color;
import java.awt.Graphics;
import javax.swing.JPanel;

public class Piirtoalusta extends JPanel {

    private Hahmo hahmo;

    public Piirtoalusta(Hahmo hahmo) {
        super.setBackground(Color.WHITE);
        this.hahmo = hahmo;
    }

    @Override
    protected void paintComponent(Graphics graphics) {
        super.paintComponent(graphics);
        hahmo.piirra(graphics);
    }
}

Annetaan hahmo myös käyttöliittymälle parametrina. Hahmo on siis käyttöliittymästä erillinen olio, joka vain halutaan piirtää käyttöliittymässä. Oleelliset muutokset käyttöliittymäluokassa ovat siis konstruktorin muuttaminen siten, että se saa parametrina Hahmo-olion. Tämän lisäksi metodissa luoKomponentit annetaan Hahmo-luokan ilmentymä parametrina luotavalle Piirtoalusta-oliolle.

public class Kayttoliittyma implements Runnable {

    private JFrame frame;
    private Hahmo hahmo;

    public Kayttoliittyma(Hahmo hahmo) {
        this.hahmo = hahmo;
    }

// ...

    private void luoKomponentit(Container container) {
        Piirtoalusta piirtoalusta = new Piirtoalusta(hahmo);
        container.add(piirtoalusta);
    }
// ...

Käyttöliittymän voi nyt käynnistää antamalla sen konstruktorille Hahmo-olion parametrina.

        Kayttoliittyma kayttoliittyma = new Kayttoliittyma(new Hahmo(30, 30));
        SwingUtilities.invokeLater(kayttoliittyma);

Yllä olevassa käyttöliittymässä näkyy huikea, pallonmuotoinen hahmo.

Lisätään seuraavaksi ohjelmaan hahmon siirtämistoiminnallisuus. Haluamme liikuttaa hahmoa näppäimistöllä. Kun käyttäjä painaa nuolta vasemmalle, hahmon pitäisi siirtyä vasemmalle. Oikealle osoittavaa nuolta painettaessa hahmon pitäisi siirtyä oikealle. Tarvitsemme siis tapahtumankuuntelijan, joka kuuntelee näppäimistöä. Rajapinta KeyListener määrittelee näppäimistönkuuntelijalta vaaditut toiminnallisuudet.

Rajapinta KeyListener vaatii metodien keyPressed, keyReleased, ja keyTyped toteuttamista. Olemme kiinnostuneita vain tapahtumasta, jossa näppäintä painetaan, joten jätämme metodit keyReleased ja keyTyped tyhjiksi. Luodaan luokka NappaimistonKuuntelija, joka toteuttaa rajapinnan KeyListener. Luokka saa parametrina Hahmo-olion, jota tapahtumankäsittelijän tulee liikuttaa.

import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;

public class NappaimistonKuuntelija implements KeyListener {

    private Hahmo hahmo;

    public NappaimistonKuuntelija(Hahmo hahmo) {
        this.hahmo = hahmo;
    }

    @Override
    public void keyPressed(KeyEvent e) {
        if (e.getKeyCode() == KeyEvent.VK_LEFT) {
            hahmo.siirry(-5, 0);
        } else if (e.getKeyCode() == KeyEvent.VK_RIGHT) {
            hahmo.siirry(5, 0);
        }
    }

    @Override
    public void keyReleased(KeyEvent e) {
    }

    @Override
    public void keyTyped(KeyEvent ke) {
    }
}

Metodi keyPressed saa käyttöliittymältä parametrina KeyEvent-luokan ilmentymän. KeyEvent-oliolta saa tietoon painettuun nappiin liittyvän numeron sen getKeyCode()-metodilla. Eri näppäimille on luokkamuuttujat KeyEvent-luokassa -- esimerkiksi nuoli vasemmalle on KeyEvent.VK_LEFT.

Haluamme kuunnella käyttöliittymään kohdistuvia näppäimen painalluksia (emme esimerkiksi ole kirjoittamassa tekstikenttään), joten lisätään näppäimistönkuuntelija JFrame-luokan ilmentymälle. Muokataan käyttöliittymäämme siten, että näppäimistönkuuntelija lisätään JFrame-oliolle.

    private void luoKomponentit(Container container) {
        Piirtoalusta piirtoalusta = new Piirtoalusta(hahmo);
        container.add(piirtoalusta);

        frame.addKeyListener(new NappaimistonKuuntelija(hahmo));
    }

Nyt sovelluksemme kuuntelee näppäimistöltä tulleita painalluksia, ja ohjaa ne luokan NappaimistonKuuntelija ilmentymälle.

Kokeillessamme käyttöliittymää se ei kuitenkaan toimi: hahmo ei siirry ruudulla. Mistä tässä oikein on kyse? Voimme tarkastaa että näppäimistön painallukset ohjautuvat NappaimistonKuuntelija-oliolle lisäämällä keyPressed-metodin alkuun testitulostuksen.

    @Override
    public void keyPressed(KeyEvent e) {
        System.out.println("Nappia " + e.getKeyCode() +  " painettu.");

        // ...

Käynnistäessämme ohjelman ja painaessamme näppäimiä näemme konsolissa tulostuksen.

Nappia 39 painettu.
Nappia 37 painettu.
Nappia 40 painettu.
Nappia 38 painettu.

Huomaamme että näppäimistön kuuntelija toimii, mutta piirtoalusta ei päivity.

Piirtoalustan uudelleenpiirtäminen

Käyttöliittymäkomponentit sisältävät yleensä toiminnallisuuden komponentin ulkoasun uudelleenpiirtämiseen tarvittaessa. Esimerkiksi nappia painettaessa JButton-luokan ilmentymä osaa piirtää napin "painettuna", jonka jälkeen nappi piirretään taas normaalina. Toteuttamassamme piirtoalustassa ei ole valmista päivitystoiminnallisuutta, vaan meidän tulee pyytää sitä piirtämään itsensä uudelleen tarvittaessa.

Jokaisella Component-luokan aliluokalla on metodi public void repaint(), jonka kutsuminen pakottaa komponentin uudelleenpiirtämisen. Haluamme että Piirtoalusta-olio piirretään uudestaan aina kun hahmoa siirretään. Hahmon siirtäminen tapahtuu luokassa NappaimistonKuuntelija, joten on loogista että uudelleenpiirtokutsu tapahtuu myös näppäimistönkuuntelijassa.

Uudelleenpiirtokutsua varten näppäimistönkuuntelija tarvitsee viitteen piirtoalustaan. Muutetaan luokkaa NappaimistonKuuntelija siten, että se saa parametrinaan Hahmo-olion lisäksi uudelleenpiirrettävän Component-olion. Kutsutaan Component-olion repaint-metodia jokaisen keyPressed tapahtuman lopussa.

import java.awt.Component;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;

public class NappaimistonKuuntelija implements KeyListener {

    private Component component;
    private Hahmo hahmo;

    public NappaimistonKuuntelija(Hahmo hahmo, Component component) {
        this.hahmo = hahmo;
        this.component = component;
    }

    @Override
    public void keyPressed(KeyEvent e) {
        if (e.getKeyCode() == KeyEvent.VK_LEFT) {
            hahmo.siirry(-5, 0);
        } else if (e.getKeyCode() == KeyEvent.VK_RIGHT) {
            hahmo.siirry(5, 0);
        }

        component.repaint();
    }

    @Override
    public void keyReleased(KeyEvent e) {
    }

    @Override
    public void keyTyped(KeyEvent ke) {
    }
}

Muutetaan myös Kayttoliittyma-luokan luoKomponentit-metodia siten, että Piirtoalusta-luokan ilmentymä annetaan parametrina näppäimistönkuuntelijalle.

    private void luoKomponentit(Container container) {
        Piirtoalusta piirtoalusta = new Piirtoalusta(hahmo);
        container.add(piirtoalusta);

        frame.addKeyListener(new NappaimistonKuuntelija(hahmo, piirtoalusta));
    }

Nyt hahmon liikuttaminen myös näkyy käyttöliittymässä. Aina kun käyttäjä painaa näppäimistöä, käyttöliittymään liitetty näppäimistönkuuntelija käsittelee kutsun. Jokaisen kutsun lopuksi kutsutaan piirtoalustan repaint-metodia, joka aiheuttaa piirtoalustan uudelleenpiirtämisen.

Liikkuva kuvio

Teemme ohjelman, jossa käyttäjä voi liikutella näppäimistön avulla ruudulle piirrettyjä kuvioita. Ohjelmassa tulee mukana käyttöliittymärunko, jota pääset muokkaamaan ohjelman edetessä.

Aluksi tehdään muutama luokka jolla kuvioita hallitaan. Pääsemme myöhemmin piirtämään kuvioita ruudulle. Tee kaikki ohjelman luokat pakkaukseen liikkuvakuvio.

Abstrakti luokka Kuvio

Tee abstrakti luokka Kuvio. Kuviolla on attribuutit x ja y, jotka kertovat kuvion sijainnin ruudulla sekä metodi public void siirra(int dx, int dy), jonka avulla kuvion sijainti siirtyy parametrina olevien koordinaattisiirtymien verran. Esim. jos sijainti aluksi on (100,100), niin kutsun siirra(10,-50) jälkeen sijainti on (110, 50). Luokan konstruktorin public Kuvio(int x, int y) tulee asettaa kuviolle alkusijainti. Lisää luokalle myös metodit public int getX() ja public int getY().

Luokalla tulee olla myös abstrakti metodi public abstract void piirra(Graphics graphics), jolla kuvio piirretään piirtoalustalle. Kuvion piirtämismetodi toteutetaan luokan Kuvio perivissä metodeissa.

Ympyra

Tee luokka Ympyra joka perii Kuvion. Ympyrällä on halkaisija jonka arvo asetetaan konstruktorissa. Konstruktorissa asetetaan myös alkuperäinen sijainti. Ympyra määrittelee metodin piirra asiaan kuuluvalla tavalla -- käytä parametrina saadun Graphics-olion fillOval-metodia.

Piirtoalusta

Luo luokka Piirtoalusta joka perii luokan JPanel, mallia voit ottaa esimerkiksi edellisen tehtävän mukana tulleesta piirtoalustasta. Piirtoalusta saa konstruktorin parametrina Kuvio-tyyppisen olion. Korvaa luokan JPanel metodi protected void paintComponent(Graphics g) siten, että siinä kutsutaan ensin yläluokan paintComponent-metodia ja sitten piirtoalustalle asetetun kuvion piirra-metodia.

Muokkaa luokkaa Kayttoliittyma siten, että se saa konstruktorin parametrina Kuvio-tyyppisen olion. Lisää käyttöliittymään Piirtoalusta luoKomponentit(Container container)-metodissa -- anna piirtoalustalle konstruktorin parametrina käyttöliittymälle annettu kuvio.

Testaa lopuksi että seuraavalla esimerkkikoodilla ruudulle piirtyy ympyrä.

        Kayttoliittyma kayttoliittyma = new Kayttoliittyma(new Ympyra(50, 50, 250));
        SwingUtilities.invokeLater(kayttoliittyma);

Näppäimistöohjaus

Laajennetaan piirtoalustaa siten, että kuviota voi liikutella nuolinäppäinten avulla. Luo rajapinnan KeyListener toteuttava luokka NappaimistonKuuntelija. Luokan NappaimistonKuuntelija konstruktorin parametrit ovat luokan Component ilmentymä ja luokan Kuvio ilmentymä.

Luokan Component ilmentymä annetaan näppäimistönkuuntelijalle, jotta voimme päivittää halutun komponentin jokaisen näppäimenpainalluksen jälkeen uudestaan. Komponentin päivittäminen tapahtuu kutsumalla Component luokasta perityvää metodia repaint. Luokka Piirtoalusta on tyyppiä Component koska Component on luokan JPanel perivän luokan yläluokka.

Toteuta rajapinnan KeyListener määrittelemä metodi keyPressed(KeyEvent e) siten, että käyttäjän painaessa nuolta vasemmalle kuvio siirtyy yhden pykälän vasemmalle. Oikealle painettaessa yksi oikealle. Ylös painettaessa yksi ylös, ja alas painettaessa yksi alas. Huomaa että y-akseli kasvaa ikkunan yläosasta alaspäin. Näppäinkoodit nuolinäppäimille ovat KeyEvent.VK_LEFT, KeyEvent.VK_RIGHT, KeyEvent.VK_UP, ja KeyEvent.VK_DOWN. Jätä muut rajapinnan KeyListener vaatimat metodit tyhjiksi.

Kutsu aina Component-luokan repaint-metodia näppäimistönkuuntelutapahtuman lopussa.

Lisää näppäimistönkuuntelija Kayttoliittyma-luokan lisaaKuuntelijat-metodissa. Näppäimistönkuuntelija tulee liittää JFrame-olioon.

Nelio ja Laatikko

Peri luokasta Kuvio luokat Nelio ja Laatikko. Neliöllä on konstruktori public Nelio(int x, int y, int sivunPituus), laatikon konstruktori on muotoa public Laatikko(int x, int y, int leveys, int korkeus). Käytä piirtämisessä graphics-olion fillRect-metodia.

Varmista, että neliöt ja laatikot piirtyvät ja liikkuvat oikein Piirtoalustalla.

        Kayttoliittyma kayttoliittyma = new Kayttoliittyma(new Nelio(50, 50, 250));
        SwingUtilities.invokeLater(kayttoliittyma);

        Kayttoliittyma kayttoliittyma = new Kayttoliittyma(new Laatikko(50, 50, 100, 300));
        SwingUtilities.invokeLater(kayttoliittyma);

Koostekuvio

Peri luokasta Kuvio luokka Koostekuvio. Koostekuvio sisältää joukon muita kuvioita jotka se tallettaa ArrayList:iin. Koostekuviolla on metodi public void liita(Kuvio k) jonka avulla koostekuvioon voi liittää kuvio-olion. Koostekuviolla ei ole omaa sijaintia. Koostekuvio piirtää itsensä pyytämällä osiaan piirtämään itsensä, koostekuvion siirtyminen tapahtuu samoin.

Testaa että koostekuviosi piirtyy ja siirtyy oikein, esim. seuraavan koostekuvion avulla:

        Koostekuvio rekka = new Koostekuvio();

        rekka.liita(new Laatikko(220, 110, 75, 100));
        rekka.liita(new Laatikko(80, 120, 200, 100));
        rekka.liita(new Ympyra(100, 200, 50));
        rekka.liita(new Ympyra(220, 200, 50));

        Kayttoliittyma kayttoliittyma = new Kayttoliittyma(rekka);
        SwingUtilities.invokeLater(kayttoliittyma);

Huomaa miten olioiden vastuut jakautuvat tehtävässä. Jokainen Kuvio on vastuussa itsensä piirtämisestä ja siirtämisestä. Yksinkertaiset kuviot siirtyvät kaikki samalla tavalla. Jokaisen yksinkertaisen kuvion on itse hoidettava piirtymisestään. Koostekuvio siirtää itsensä pyytämällä osiaan siirtymään, samoin hoituu koostekuvion piirtyminen. Piirtoalusta tuntee Kuvio-olion joka siis voi olla mikä tahansa yksinkertainen kuvio tai koostekuvio, kaikki piirretään ja siirretään samalla tavalla. Piirtoalusta siis toimii samalla tavalla kuvan oikeasta tyypistä huolimatta, piirtoalustan ei tarvitse tietää kuvion yksityiskohdista mitään. Kun piirtoalusta kutsuu kuvion metodia piirra tai siirra polymorfismin ansiosta kutsutuksi tulee kuvion todellista tyyppiä vastaava metodi.

Huomionarvoista tehtävässä on se, että Koostekuvio voi sisältää mitä tahansa Kuvio-olioita, siis myös koostekuvioita! Luokkarakenne mahdollistaakin mielivaltaisen monimutkaisen kuvion muodostamisen ja kuvion siirtely ja piirtäminen tapahtuu aina täsmälleen samalla tavalla.

Luokkarakennetta on myös helppo laajentaa, esim. perimällä Kuvio-luokasta uusia kuviotyyppejä: kolmio, piste, viiva, ym... Koostekuvio toimii ilman muutoksia myös uusien kuviotyyppien kanssa, samoin piirtoalusta.

Muutamia hyödyllisiä tekniikoita

Kurssin lähestyessä loppua katsomme vielä muutamaa hyödyllistä Javan ominaisuutta.

Säännölliset lausekkeet

Säännöllinen lauseke määrittelee tiiviissä muodossa joukon merkkijonoja. Säännöllisiä lausekkeita käytetään muunmuassa merkkijonojen oikeellisuuden tarkistamiseen. Tarkastellaan tehtävää, jossa täytyy tarkistaa, onko käyttäjän antama opiskelijanumero oikeanmuotoinen. Opiskelijanumero alkaa merkkijonolla "01", jota seuraa 7 numeroa väliltä 0–9.

Opiskelijanumeron oikeellisuuden voisi tarkistaa esimerkiksi käymällä opiskelijanumeroa esittävän merkkijonon läpi merkki merkiltä charAt-metodin avulla. Toinen tapa olisi tarkistaa että ensimmäinen merkki on "0", ja käyttää Integer.parseInt metodikutsua merkkijonon muuntamiseen numeroksi. Tämän jälkeen voisi tarkistaa että Integer.parseInt-metodin palauttama luku on pienempi kuin 20000000.

Oikeellisuuden tarkistus säännöllisten lausekkeiden avulla vaatii ensin sopivan säännöllisen lausekkeen määrittelyn. Tämän jälkeen voimme käyttää String-luokan metodia matches, joka tarkistaa vastaako merkkijono parametrina annettua säännöllistä lauseketta. Opiskelijanumeron tapauksessa sopiva säännöllinen lauseke on "01[0-9]{7}", ja käyttäjän syöttämän opiskelijanumeron tarkistaminen käy seuraavasti:

System.out.print("Anna opiskelijanumero: ");
String numero = lukija.nextLine();

if (numero.matches("01[0-9]{7}")) {
    System.out.println("Muoto on oikea.");
} else {
    System.out.println("Muoto ei ole oikea.");
}

Käydään seuraavaksi läpi eniten käytettyjä säännöllisten lausekkeiden merkintöjä.

Pystyviiva eli vaihtoehtoisuus

Pystyviiva tarkoittaa, että säännöllisen lausekkeen osat ovat vaihtoehtoisia. Esimerkiksi lauseke 00|111|0000 määrittelee merkkijonot 00, 111 ja 0000. Metodi matches palauttaa arvon true jos merkkijono vastaa jotain määritellyistä vaihtoehdoista.

    String merkkijono = "00";
    
    if(merkkijono.matches("00|111|0000")) {
        System.out.println("Merkkijonosta löytyi joku kolmesta vaihtoehdosta");
    } else {
        System.out.println("Merkkijonosta ei löytynyt yhtäkään vaihtoehdoista");
    }
Merkkijonosta löytyi joku kolmesta vaihtoehdosta

Säännöllinen lauseke 00|111|0000 vaatii että merkkijono on täsmälleen määritellyn muotoinen: se ei määrittele "contains"-toiminnallisuutta.

    String merkkijono = "1111";
    
    if(merkkijono.matches("00|111|0000")) {
        System.out.println("Merkkijonosta löytyi joku kolmesta vaihtoehdosta");
    } else {
        System.out.println("Merkkijonosta ei löytynyt yhtäkään vaihtoehdoista");
    }
Merkkijonosta ei löytynyt yhtäkään vaihtoehdoista

Sulut, eli merkkijonon osaan rajattu vaikutusalue

Sulkujen avulla voi määrittää, mihin säännöllisen lausekkeen osaan sulkujen sisällä olevat merkinnät vaikuttavat. Jos haluamme sallia merkkijonot 00000 ja 00001, voimme määritellä ne pystyviivan avulla muodossa 00000|00001. Sulkujen avulla voimme rajoittaa vaihtoehtoisuuden vain osaan merkkijonoa. Lauseke 0000(0|1) määrittelee merkkijonot 00000 ja 00001.

Vastaavasti säännöllinen lauseke auto(|n|a) määrittelee sanan auto yksikön nominatiivin (auto), genetiivin (auton), partitiivin (autoa) ja akkusatiivin (auto tai auton).

System.out.print("Kirjoita joku sanan auto yksikön taivutusmuoto: ");
String sana = lukija.nextLine();

if (sana.matches("auto(|n|a|ssa|sta|on|lla|lta|lle|na|ksi|tta)")) {
    System.out.println("Oikein meni!");
} else {
    System.out.println("Taivutusmuoto ei ole oikea.");
}

Toistomerkinnät

Usein halutaan, että merkkijonossa toistuu jokin tietty alimerkkijono. Säännöllisissä lausekkeissa on käytössä seuraavat toistomerkinnät:

Samassa säännöllisessä lausekkeessa voi käyttää myös useampia toistomerkintöjä. Esimerkiksi säännöllinen lauseke 5{3}(1|0)*5{3} määrittelee merkkijonot, jotka alkavat ja loppuvat kolmella vitosella. Välissä saa tulla rajaton määrä ykkösiä ja nollia.

Hakasulut, eli merkkiryhmät

Merkkiryhmän avulla voi määritellä lyhyesti joukon merkkejä. Merkit kirjoitetaan hakasulkujen sisään, ja merkkivälin voi määrittää viivan avulla. Esimerkiksi merkintä [145] tarkoittaa samaa kuin (1|4|5) ja merkintä [2-36-9] tarkoittaa samaa kuin (2|3|6|7|8|9). Vastaavasti merkintä [a-c]* määrittelee säännöllisen lausekkeen, joka vaatii että merkkijono sisältää vain merkkejä a, b ja c.

Säännölliset lausekkeet

Harjoitellaan hieman säännöllisten lausekkeiden käyttöä. Tehtävät tehdään oletuspakkauksessa olevaan luokkaan Paaohjelma.

Viikonpäivä

Tee säännöllisen lausekkeen avulla luokalle Paaohjelma metodi public static boolean onViikonpaiva(String merkkijono), joka palauttaa true jos sen parametrina saama merkkijono viikonpäivän lyhenne (ma, ti, ke, to, pe, la tai su).

Esimerkkitulostuksia metodia käyttävästä ohjelmasta:

Anna merkkijono: ti
Muoto on oikea.
Anna merkkijono: abc
Muoto ei ole oikea.

Vokaalitarkistus

Tee luokalle Paaohjelma metodi public static boolean kaikkiVokaaleja(String merkkijono) joka tarkistaa säännöllisen lausekkeen avulla ovatko parametrina olevan merkkijonon kaikki merkit vokaaleja.

Esimerkkitulostuksia metodia käyttävästä ohjelmasta:

Anna merkkijono: aie
Muoto on oikea.
Anna merkkijono: ane
Muoto ei ole oikea.

Kellonaika

Tee luokalle Paaohjelma metodi public static boolean kellonaika(String merkkijono) ohjelma, joka tarkistaa säännöllisen lausekkeen avulla onko parametrina oleva merkkijono muotoa tt:mm:ss oleva kellonaika (tunnit, minuutit ja sekunnit kaksinumeroisina).

Esimerkkitulostuksia metodia käyttävästä ohjelmasta:

Anna merkkijono: 17:23:05
Muoto on oikea.
Anna merkkijono: abc
Muoto ei ole oikea.
Anna merkkijono: 33:33:33
Muoto ei ole oikea.

Enum eli lueteltu tyyppi

Toteutimme aiemmin pelikorttia mallintavan luokan Kortti suunilleen seuraavasti:

public class Kortti {

    public static final int RUUTU = 0;
    public static final int PATA = 1;
    public static final int RISTI = 2;
    public static final int HERTTA = 3;

    private int arvo;
    private int maa;

    public Kortti(int arvo, int maa) {
        this.arvo = arvo;
        this.maa = maa;
    }

    @Override
    public String toString() {
        return maanNimi() + " "+arvo;
    }

    private String maanNimi() {
        if (maa == 0) {
            return "RUUTU";
        } else if (maa == 1) {
            return  "PATA";
        } else if (maa == 2) {
            return "RISTI";
        }
        return "HERTTA";
    }

    public int getMaa() {
        return maa;
    }
}

Kortin maa tallennetaan kortissa olevaan oliomuuttujaan kokonaislukuna. Maan ilmaisemiseen on määritelty luettavuutta helpottavat vakiot. Kortteja ja maita ilmaisevia vakioita käytetään seuraavasti:

public static void main(String[] args) {
        Kortti kortti = new Kortti(10, Kortti.HERTTA);

        System.out.println(kortti);

        if (kortti.getMaa() == Kortti.PATA) {
            System.out.println("on pata");
        } else {
            System.out.println("ei ole pata");
        }

}

Maan esittäminen numerona on hiukan ikävä, sillä esimerkiksi seuraavat "järjenvastaiset" tavat käyttää korttia ovat mahdollisia:

        Kortti jarjetonKortti = new Kortti(10, 55);

        System.out.println(jarjetonKortti);

        if (jarjetonKortti.getMaa() == 34) {
            System.out.println("kortin maa on 34");
        } else {
            System.out.println("kortin maa on jotain muuta kun 34");
        }

        int maaPotenssiinKaksi = jarjetonKortti.getMaa() * jarjetonKortti.getMaa();

        System.out.println("kortin maa potenssiin kaksi on " + maaPotenssiinKaksi);

Jos tiedämme muuttujien mahdolliset arvot ennalta, voimme käyttää niiden esittämiseen enum-tyyppistä muuttujaa eli "lueteltua tyyppiä". Luetellut tyypit ovat oma luokkatyyppinsä rajapinnan ja normaalin luokan lisäksi. Lueteltu tyyppi määritellään avainsanalla enum. Esimerkiksi seuraava Maa-enumluokka määrittelee neljä vakioarvoa: RUUTU, PATA, RISTI ja HERTTA.

public enum Maa {
    RUUTU, PATA, RISTI, HERTTA
}

Yksinkertaisimmassa muodossaan enum luettelee pilkulla erotettuina määrittelemänsä vakioarvot. Enumien vakiot on yleensä tapana kirjoittaa kokonaan isoin kirjaimin.

Enum luodaan (yleensä) omaan tiedostoon, samaan tapaan kuin luokka tai rajapinta. NetBeansissa Enumin saa luotua valitsemalla projektin kohdalla new/other/java/java enum.

Seuraavassa luokka Kortti jossa maa esitetään enumin avulla:

public class Kortti {

    private int arvo;
    private Maa maa;

    public Kortti(int arvo, Maa maa) {
        this.arvo = arvo;
        this.maa = maa;
    }

    @Override
    public String toString() {
        return maa + " "+arvo;
    }

    public Maa getMaa() {
        return maa;
    }

    public int getArvo() {
        return arvo;
    }
}

Kortin uutta versiota käytetään seuraavasti:

public class Paaohjelma {

    public static void main(String[] args) {
        Kortti eka = new Kortti(10, Maa.HERTTA);

        System.out.println(eka);

        if (eka.getMaa() == Maa.PATA) {
            System.out.println("on pata");
        } else {
            System.out.println("ei ole pata");
        }

    }
}

Tulostuu:

HERTTA 10
ei ole pata

Huomaamme, että enumin tunnukset tulostuvat mukavasti! Koska kortin maat ovat nyt tyyppiä Maa ei ylemmän esimerkin "järjenvastaiset" kummallisuudet, esim. "maan korottaminen toiseen potenssiin" onnistu. Oraclella on enum-tyyppiin liittyvä sivusto osoitteessa http://docs.oracle.com/javase/tutorial/java/javaOO/enum.html.

Iteraattori

Tarkastellaan seuraavaa luokkaa Kasi, joka mallintaa tietyssä korttipelissä pelaajan kädessä olevien korttien joukkoa:

public class Kasi {
    private ArrayList<Kortti> kortit;

    public Kasi() {
        kortit = new ArrayList<Kortti>();
    }

    public void lisaa(Kortti kortti){
        kortit.add(kortti);
    }

    public void tulosta(){
        for (Kortti kortti : kortit) {
            System.out.println( kortti );
        }
    }
}

Luokan metodi tulosta tulostaa jokaisen kädessä olevan kortin tutuksi tullutta "for each"-lausetta käyttämällä. ArrayList ja muut Collection-rajapinnan toteuttavat "oliosäiliöt" toteuttavat rajapinnan Iterable. Rajapinnan Iterable toteuttavat oliot on mahdollista käydä läpi eli "iteroida" esimerkiksi. for each -tyyppisellä komennolla.

Oliosäiliö voidaan käydä läpi myös käyttäen ns. iteraattoria, eli olioa, joka on varta vasten tarkoitettu tietyn oliokokoelman läpikäyntiin. Seuraavassa on iteraattoria käyttävä versio korttien tulostamisesta:

public void tulosta() {
    Iterator<Kortti> iteraattori = kortit.iterator();

    while ( iteraattori.hasNext() ){
        System.out.println( iteraattori.next() );
    }
}

Iteraattori pyydetään kortteja sisältävältä arraylistiltä kortit. Iteraattori on ikäänkuin "sormi", joka osoittaa aina tiettyä listan sisällä olevaa olioa, ensin ensimmäistä ja sitten seuraavaa jne... kunnes "sormen" avulla on käyty jokainen olio läpi.

Iteraattori tarjoaa muutaman metodin. Metodilla hasNext() kysytään onko läpikäytäviä olioita vielä jäljellä. Jos on, voidaan iteraattorilta pyytää seuraavana vuorossa oleva olio metodilla next(). Metodi siis palauttaa seuraavana läpikäyntivuorossa olevan olion ja laittaa iteraattorin eli "sormen" osoittamaan seuraavana vuorossa olevaa läpikäytävää olioa.

Iteraattorin next-metodin palauttama olioviite voidaan ottaa toki talteen myös muuttujaan, eli metodi tulosta voitaisiin muotoilla myös seuraavasti:

public void tulosta(){
    Iterator<Kortti> iteraattori = kortit.iterator();

    while ( iteraattori.hasNext() ){
        Kortti seuraavanaVuorossa = iteraattori.next();
        System.out.println( seuraavanaVuorossa );
    }
}

Teemme metodin jonka avulla kädestä voi poistaa tiettyä arvoa pienemmät kortit:

public class Kasi {
    // ...

    public void poistaHuonommat(int arvo) {
        for (Kortti kortti : kortit) {
            if ( kortti.getArvo() < arvo ) {
                kortit.remove(kortti);
            }
        }
    }
}

Huomaamme että metodin suoritus aiheuttaa kummallisen virheen:

Exception in thread "main" java.util.ConcurrentModificationException
        at java.util.AbstractList$Itr.checkForComodification(AbstractList.java:372)
        at java.util.AbstractList$Itr.next(AbstractList.java:343)
        at Kasi.poistaHuonommat(Kasi.java:26)
        at Paaohjelma.main(Paaohjelma.java:20)
Java Result: 1

Virheen syynä on se, että for-each:illa listaa läpikäydessä ei ole sallittua poistaa listalta olioita: komento for-each menee tästä "sekaisin".

Jos listalta halutaan poistaa osa olioista läpikäynnin aikana osa, tulee tämä tehdä iteraattoria käyttäen. Iteraattori-olion metodia remove kutsuttaessa listalta poistetaan siististi se alkio jonka iteraattori palautti edellisellä metodin next kutsulla. Toimiva versio metodista seuraavassa:

public class Kasi {
    // ...

    public void poistaHuonommat(int arvo) {
        Iterator<Kortti> iteraattori = kortit.iterator();

        while (iteraattori.hasNext()) {
            if (iteraattori.next().getArvo() < arvo) {
                // poistetaan listalta olio jonka edellinen next-metodin kutsu palautti
                iteraattori.remove();   
            }
        }
    }
}

Enum ja Iteraattori

Tehdään ohjelma pienen yrityksen henkilöstön hallintaan.

Koulutus

Tee pakkaukseen henkilosto lueteltu tyyppi eli enum Koulutus jolla on tunnukset FT, FM, LuK, FilYO

Henkilo

Tee pakkaukseen henkilosto luokka Luokka Henkilo. Henkilölle annetaan konstruktorin parametrina annettava nimi ja koulutus. Henkilöllä on myös koulutuksen kertova metodi public Koulutus getKoulutus() sekä allaolevan esimerkin mukaista jälkeä tekevä toString-metodi.

    Henkilo arto = new Henkilo("Arto", Koulutus.FT);
    System.out.println(arto);
Arto, FT

Tyontekijat

Tee pakkaukseen henkilosto luokka Luokka Tyontekijat. Työntekijät-olio sisältää listan Henkilo-olioita. Luokalla on parametriton konstruktori ja seuraavat metodit:

HUOM: Luokan Tyontekijat tulosta-metodit on toteutettava iteraattoria käyttäen!

Irtisanominen

Tee luokalle Tyontekijat metodi public void irtisano(Koulutus koulutus) joka poistaa Työntekijöiden joukosta kaikki henkilöt joiden koulutus on sama kuin metodin parametrina annettu.

HUOM: toteuta metodi iteraattoria käyttäen!

Seuraavassa esimerkki luokan käytöstä:

Public class Paaohjelma {

    public static void main(String[] args) {
        Tyontekijat yliopisto = new Tyontekijat();
        yliopisto.lisaa(new Henkilo("Matti", Koulutus.FT));
        yliopisto.lisaa(new Henkilo("Pekka", Koulutus.FilYO));
        yliopisto.lisaa(new Henkilo("Arto", Koulutus.FT));

        yliopisto.tulosta();

        yliopisto.irtisano(Koulutus.FilYO);

        System.out.println("==");

        yliopisto.tulosta();
}

Tulostuu:

Matti, FT
Pekka, FilYO
Arto, FT
==
Matti, FT
Arto, FT

Toistolauseet ja continue

Toistolauseissa on komennon break lisäksi käytössä komento continue, joka mahdollistaa seuraavaan toistokierrokseen hyppäämisen.

    List<String> nimet = Arrays.asList("Matti", "Pekka", "Arto");
    
    for(String nimi: nimet) {
        if (nimi.equals("Arto")) {
            continue;
        }

        System.out.println(nimi);
    }
Matti
Pekka

Komentoa continue käytetään esimerkiksi silloin, kun tiedetään että toistolauseessa iteroitavilla muuttujilla on arvoja, joita ei haluta käsitellä lainkaan. Klassinen lähestymistapa olisi if-lauseen käyttö, mutta komento continue mahdollistaa sisennyksiä välttävän, ja samalla ehkä luettavamman lähestymistavan käsiteltävien arvojen välttämiseen. Alla on kaksi esimerkkiä, jossa käydään listalla olevia lukuja läpi. Jos luku on alle 5, se on jaollinen sadalla, tai se on jaollinen neljälläkymmenellä, niin sitä ei tulosteta, muulloin se tulostetaan.

    List<Integer> luvut = Arrays.asList(1, 3, 11, 6, 120);
    
    for(int luku: luvut) {
        if (luku > 4 && luku % 100 != 0 && luku % 40 != 0) {
            System.out.println(luku);
        }
    }

    for(int luku: luvut) {
        if (luku < 5) {
            continue;
        }

        if (luku % 100 == 0) {
            continue;
        }

        if (luku % 40 == 0) {
            continue;
        }
        
        System.out.println(luku);
    }
11
6
11
6

Lisää enumeista

Luodaan seuraavaksi lueteltuja tyyppejä jotka sisältävät oliomuuttujia ja toteuttavat rajapinnan.

Luetellun tyypin konstruktorin parametrit

Luetellut tyypit voivat sisältää oliomuuttujia. Oliomuuttujien arvot tulee asettaa luetellun tyypin määrittelevän luokan sisäisessä konstruktorissa. Enum-tyyppisillä luokilla ei saa olla public-konstruktoria.

public enum Vari {
    PUNAINEN("punainen"), // konstruktorin parametrit määritellään vakioarvoja lueteltaessa
    VIHREA("vihreä"),
    SININEN("sininen");

    private String nimi; // oliomuuttuja

    private Vari(String nimi) { // konstruktori
        this.nimi = nimi;
    }

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

Lueteltua tyyppiä Vari voidaan käyttää esimerkiksi seuraavasti:

    System.out.println(Vari.VIHREA.getNimi());
vihreä

Lueteltu tyyppi ja rajapinnat

Luetellut tyypit voivat toteuttaa rajapintoja samalla tavoin kuin muutkin luokat toteuttavat rajapintoja. Luodaan rajapinta Nimetty

public interface Nimetty {
    String getNimi();
}

ja määritellään luokka Vari toteuttamaan rajapinta Nimetty. Rajapinnan toteutus tapahtuu implements-avainsanalla.

public enum Vari implements Nimetty {
    PUNAINEN("punainen"),
    VIHREA("vihreä"),
    SININEN("sininen");

    private String nimi; 

    private Vari(String nimi) {
        this.nimi = nimi;
    }

    @Override
    public String getNimi() {
        return nimi;
    }
}

Kun lueteltu tyyppi toteuttaa rajapinnan, on se myös rajapinnan määrittelemää tyyppiä.

    Vari vari = Vari.PUNAINEN;
    Nimetty nimetty = Vari.VIHREA;

    System.out.println("Vari: " + vari.getNimi());
    System.out.println("Nimetty: " + nimetty.getNimi());
Vari: punainen
Nimetty: vihreä

Elokuvien suosittelija

Netflix-niminen yritys lupasi lokakuussa 2006 miljoona dollaria henkilölle tai ryhmälle, joka kehittäisi ohjelman, joka on 10% parempi elokuvien suosittelussa kuin heidän oma ohjelmansa. Kilpailu ratkesi syyskuussa 2009 (http://www.netflixprize.com/) -- emme valitettavasti voittaneet.

Rakennetaan tässä tehtävässä ohjelma elokuvien suositteluun. Alla on sen toimintaesimerkki:

    ArvioRekisteri arviot = new ArvioRekisteri();

    Elokuva tuulenViemaa = new Elokuva("Tuulen viemää");
    Elokuva hiljaisetSillat = new Elokuva("Hiljaiset sillat");
    Elokuva eraserhead = new Elokuva("Eraserhead");

    Henkilo matti = new Henkilo("Matti");
    Henkilo pekka = new Henkilo("Pekka");
    Henkilo mikke = new Henkilo("Mikke");
    Henkilo thomas = new Henkilo("Thomas");

    arviot.lisaaArvio(matti, tuulenViemaa, Arvio.HUONO);
    arviot.lisaaArvio(matti, hiljaisetSillat, Arvio.HYVA);
    arviot.lisaaArvio(matti, eraserhead, Arvio.OK);

    arviot.lisaaArvio(pekka, tuulenViemaa, Arvio.OK);
    arviot.lisaaArvio(pekka, hiljaisetSillat, Arvio.HUONO);
    arviot.lisaaArvio(pekka, eraserhead, Arvio.VALTTAVA);

    arviot.lisaaArvio(mikke, eraserhead, Arvio.HUONO);
    

    Suosittelija suosittelija = new Suosittelija(arviot);
    System.out.println(thomas + " suositus: " + 
            suosittelija.suositteleElokuva(thomas));
    System.out.println(mikke + " suositus: " + 
            suosittelija.suositteleElokuva(mikke));
Thomas suositus: Hiljaiset sillat
Mikke suositus: Tuulen viemää

Ohjelma osaa suositella elokuvia niiden yleisen arvion perusteella, sekä henkilökohtaisten henkilön antaminen arvioiden perusteella. Lähdetään rakentamaan ohjelmaa.

Henkilo ja Elokuva

Luo pakkaus suosittelija.domain ja lisää sinne luokat Henkilo ja Elokuva. Kummallakin luokalla on julkinen konstruktori public Luokka(String nimi), sekä metodi public String getNimi(), joka palauttaa konstruktorissa saadun nimen.

    Henkilo henkilo = new Henkilo("Pekka");
    Elokuva elokuva = new Elokuva("Eraserhead");

    System.out.println(henkilo.getNimi() + " ja " + elokuva.getNimi());
Pekka ja Eraserhead

Lisää luokille myös public String toString()-metodi, joka palauttaa konstruktorissa parametrina annetun nimen, sekä korvaa metodit equals ja hashCode.

Korvaa equals siten että samuusvertailu tapahtuu oliomuuttujan nimi perusteella. Katso mallia luvusta 45.1. Luvussa 45.2. on ohje metodin hashCode korvaamiselle. Ainakin HashCode kannattaa generoida automaattisesti luvun lopussa olevan ohjeen mukaan:

NetBeans tarjoaa metodien equals ja hashCode automaattisen luonnin. Voit valita valikosta Source -> Insert Code, ja valita aukeavasta listasta equals() and hashCode(). Tämän jälkeen NetBeans kysyy oliomuuttujat joita metodeissa käytetään.

Arvio

Luo pakkaukseen suosittelija.domain lueteltu tyyppi Arvio. Enum-luokalla Arvio on julkinen metodi public int getArvosana(), joka palauttaa arvioon liittyvän arvosanan. Arviotunnusten ja niihin liittyvien arvosanojen tulee olla seuraavat:

TunnusArvosana
HUONO-5
VALTTAVA-3
EI_NAHNYT0
NEUTRAALI1
OK3
HYVA5

Luokkaa voi käyttää seuraavasti:

    Arvio annettu = Arvio.HYVA;
    System.out.println("Arvio " + annettu + ", arvosana " + annettu.getArvosana());
    annettu = Arvio.NEUTRAALI;
    System.out.println("Arvio " + annettu + ", arvosana " + annettu.getArvosana());
Arvio HYVA, arvosana 5
Arvio NEUTRAALI, arvosana 1

ArvioRekisteri, osa 1

Aloitetaan arvioiden varastointiin liittyvän palvelun toteutus.

Luo pakkaukseen suosittelija luokka ArvioRekisteri, jolla on konstruktori public ArvioRekisteri() sekä seuraavat metodit:

Testaa metodien toimintaa seuraavalla lähdekoodilla:

    Elokuva hiljaisetSillat = new Elokuva("Hiljaiset sillat");
    Elokuva eraserhead = new Elokuva("Eraserhead");

    ArvioRekisteri rekisteri = new ArvioRekisteri();
    rekisteri.lisaaArvio(eraserhead, Arvio.HUONO);
    rekisteri.lisaaArvio(eraserhead, Arvio.HUONO);
    rekisteri.lisaaArvio(eraserhead, Arvio.HYVA);

    rekisteri.lisaaArvio(hiljaisetSillat, Arvio.HYVA);
    rekisteri.lisaaArvio(hiljaisetSillat, Arvio.OK);

    System.out.println("Kaikki arviot: " + rekisteri.elokuvienArviot());
    System.out.println("Arviot Eraserheadille: " + rekisteri.annaArviot(eraserhead));
Kaikki arviot: {Hiljaiset sillat=[HYVA, OK], Eraserhead=[HUONO, HUONO, HYVA]}
Arviot Eraserheadille: [HUONO, HUONO, HYVA]

ArvioRekisteri, osa 2

Lisätään seuraavaksi mahdollisuus henkilökohtaisten arvioiden lisääiseen.

Lisää luokkaan ArvioRekisteri seuraavat metodit:

Henkilöiden tekemät arviot kannattanee tallentaa hajautustauluun, jossa avaimena on henkilö. Arvona hajautustaulussa on toinen hajautustaulu, jossa avaimena on elokuva ja arvona arvio.

Testaa paranneltua ArvioRekisteri-luokkaa seuraavalla lähdekoodipätkällä:

    ArvioRekisteri arviot = new ArvioRekisteri();

    Elokuva tuulenViemaa = new Elokuva("Tuulen viemää");
    Elokuva eraserhead = new Elokuva("Eraserhead");

    Henkilo matti = new Henkilo("Matti");
    Henkilo pekka = new Henkilo("Pekka");

    arviot.lisaaArvio(matti, tuulenViemaa, Arvio.HUONO);
    arviot.lisaaArvio(matti, eraserhead, Arvio.OK);

    arviot.lisaaArvio(pekka, tuulenViemaa, Arvio.OK);
    arviot.lisaaArvio(pekka, eraserhead, Arvio.OK);

    System.out.println("Arviot Eraserheadille: " + arviot.annaArviot(eraserhead));
    System.out.println("Matin arviot: " + arviot.annaHenkilonArviot(matti));
    System.out.println("Arvioijat: " + arviot.arvioijat());
Arviot Eraserheadille: [OK, OK]
Matin arviot: {Tuulen viemää=HUONO, Eraserhead=OK}
Arvioijat: [Pekka, Matti]

Luodaan seuraavaksi muutama apuluokka arviointien helpottamiseksi.

HenkiloComparator

Luo pakkaukseen suosittelija.comparator luokka HenkiloComparator. Luokan HenkiloComparator tulee toteuttaa rajapinta Comparator<Henkilo>, ja sillä pitää olla konstruktori public HenkiloComparator(Map<Henkilo, Integer> henkiloidenSamuudet). Luokkaa HenkiloComparator käytetään myöhemmin henkilöiden järjestämiseen henkilöön liittyvän luvun perusteella.

HenkiloComparator-luokan tulee mahdollistaa henkilöiden järjestäminen henkilöön liittyvän luvun perusteella.

Testaa luokan toimintaa seuraavalla lähdekoodilla:

    Henkilo matti = new Henkilo("Matti");
    Henkilo pekka = new Henkilo("Pekka");
    Henkilo mikke = new Henkilo("Mikke");
    Henkilo thomas = new Henkilo("Thomas");

    Map<Henkilo, Integer> henkiloidenSamuudet = new HashMap<Henkilo, Integer>();
    henkiloidenSamuudet.put(matti, 42);
    henkiloidenSamuudet.put(pekka, 134);
    henkiloidenSamuudet.put(mikke, 8);
    henkiloidenSamuudet.put(thomas, 82);
    
    List<Henkilo> henkilot = Arrays.asList(matti, pekka, mikke, thomas);
    System.out.println("Henkilöt ennen järjestämistä: " + henkilot);

    Collections.sort(henkilot, new HenkiloComparator(henkiloidenSamuudet));
    System.out.println("Henkilöt järjestämisen jälkeen: " + henkilot);
Henkilöt ennen järjestämistä: [Matti, Pekka, Mikke, Thomas]
Henkilöt järjestämisen jälkeen: [Pekka, Thomas, Matti, Mikke]

ElokuvaComparator

Luo pakkaukseen suosittelija.comparator luokka ElokuvaComparator. Luokan ElokuvaComparator tulee toteuttaa rajapinta Comparator<Elokuva>, ja sillä pitää olla konstruktori public ElokuvaComparator(Map<Elokuva, List<Arvio>> arviot). Luokkaa ElokuvaComparator käytetään myöhemmin elokuvien järjestämiseen niiden arvioiden perusteella.

ElokuvaComparator-luokan tulee tarjota mahdollisuus elokuvien järjestäminen niiden saamien arvosanojen keskiarvon perusteella. Korkeimman keskiarvon saanut elokuva tulee ensimmäisenä, matalimman keskiarvon saanut viimeisenä.

Testaa luokan toimintaa seuraavalla lähdekoodilla:

    ArvioRekisteri arviot = new ArvioRekisteri();

    Elokuva tuulenViemaa = new Elokuva("Tuulen viemää");
    Elokuva hiljaisetSillat = new Elokuva("Hiljaiset sillat");
    Elokuva eraserhead = new Elokuva("Eraserhead");

    Henkilo matti = new Henkilo("Matti");
    Henkilo pekka = new Henkilo("Pekka");
    Henkilo mikke = new Henkilo("Mikke");

    arviot.lisaaArvio(matti, tuulenViemaa, Arvio.HUONO);
    arviot.lisaaArvio(matti, hiljaisetSillat, Arvio.HYVA);
    arviot.lisaaArvio(matti, eraserhead, Arvio.OK);

    arviot.lisaaArvio(pekka, tuulenViemaa, Arvio.OK);
    arviot.lisaaArvio(pekka, hiljaisetSillat, Arvio.HUONO);
    arviot.lisaaArvio(pekka, eraserhead, Arvio.VALTTAVA);

    arviot.lisaaArvio(mikke, eraserhead, Arvio.HUONO);

    Map<Elokuva, List<Arvio>> elokuvienArviot = arviot.elokuvienArviot();

    List<Elokuva> elokuvat = Arrays.asList(tuulenViemaa, hiljaisetSillat, eraserhead);
    System.out.println("Elokuvat ennen järjestämistä: " + elokuvat);

    Collections.sort(elokuvat, new ElokuvaComparator(elokuvienArviot));
    System.out.println("Elokuvat järjestämisen jälkeen: " + elokuvat);
Elokuvat ennen järjestämistä: [Tuulen viemää, Hiljaiset sillat, Eraserhead]
Elokuvat järjestämisen jälkeen: [Hiljaiset sillat, Tuulen viemää, Eraserhead]

Suosittelija, osa 1

Toteuta pakkaukseen suosittelija luokka Suosittelija. Luokan Suosittelija konstruktori saa parametrinaan ArvioRekisteri-tyyppisen olion. Suosittelija käyttää arviorekisterissä olevia arvioita suositusten tekemiseen.

Toteuta luokalle ArvioRekisteri metodi public Elokuva suositteleElokuva(Henkilo henkilo), joka suosittelee henkilölle elokuvia. Toteuta metodi ensin siten, että se suosittelee aina elokuvaa, jonka arvioiden arvosanojen keskiarvo on suurin. Vinkki: Tarvitset parhaan elokuvan selvittämiseen ainakin aiemmin luotua ElokuvaComparator-luokkaa, luokan ArvioRekisteri metodia public Map<Elokuva, List<Arvio>> elokuvienArviot(), sekä listaa olemassaolevista elokuvista.

Testaa ohjelman toimimista seuraavalla lähdekoodilla:

    ArvioRekisteri arviot = new ArvioRekisteri();

    Elokuva tuulenViemaa = new Elokuva("Tuulen viemää");
    Elokuva hiljaisetSillat = new Elokuva("Hiljaiset sillat");
    Elokuva eraserhead = new Elokuva("Eraserhead");

    Henkilo matti = new Henkilo("Matti");
    Henkilo pekka = new Henkilo("Pekka");
    Henkilo mikke = new Henkilo("Mikael");

    arviot.lisaaArvio(matti, tuulenViemaa, Arvio.HUONO);
    arviot.lisaaArvio(matti, hiljaisetSillat, Arvio.HYVA);
    arviot.lisaaArvio(matti, eraserhead, Arvio.OK);

    arviot.lisaaArvio(pekka, tuulenViemaa, Arvio.OK);
    arviot.lisaaArvio(pekka, hiljaisetSillat, Arvio.VALTTAVA);
    arviot.lisaaArvio(pekka, eraserhead, Arvio.VALTTAVA);

    Suosittelija suosittelija = new Suosittelija(arviot);
    Elokuva suositeltu = suosittelija.suositteleElokuva(mikke);
    System.out.println("Mikaelille suositeltu elokuva oli: " + suositeltu); 
Mikaelille suositeltu elokuva oli: Hiljaiset sillat

Suosittelija, osa 2

Huom! Tehtävä on haastava. Kannattaa tehdä ensin muut tehtävät ja palata tähän myöhemmin. Voit palauttaa tehtäväsarjan TMC:hen vaikket saakaan tätä tehtävää tehdyksi -- aivan kuten muidenkin tehtävien kohdalla.

Jos henkilöt ovat lisänneet omia suosituksia suosituspalveluun, tiedämme jotain heidän elokuvamaustaan. Laajennetaan suosittelijan toiminnallisuutta siten, että se luo henkilökohtaisen suosituksen jos henkilö on jo arvioinut elokuvia. Edellisessä osassa toteutettu toiminnallisuus tulee säilyttää: Jos henkilö ei ole arvioinut yhtäkään elokuvaa, hänelle suositellaan elokuva arvosanojen perusteella.

Henkilökohtaiset suositukset perustuvat henkilön tekemien arvioiden samuuteen muiden henkilöiden tekemien arvioiden kanssa. Pohditaan seuraavaa taulukkoa, missä ylärivillä on elokuvat, ja vasemmalla on arvioita tehneet henkilöt. Taulukon solut kuvaavat annettuja arvioita.

Henkilo \ ElokuvaTuulen viemääHiljaiset sillatEraserheadBlues Brothers
MattiHUONO (-5)HYVA (5)OK (3)-
PekkaOK (3)-HUONO (-5)VALTTAVA (-3)
Mikael--HUONO (-5)-
Thomas-HYVA (5)-HYVA (5)

Kun haluamme hakea Mikaelille sopivaa elokuvaa, tutkimme Mikaelin samuutta kaikkien muiden arvioijien kesken. Samuus lasketaan arvioiden perusteella: samuus on kummankin katsomien elokuvien arvioiden tulojen summa. Esimerkiksi Mikaelin ja Thomasin samuus on 0, koska Mikael ja Thomas eivät ole katsoneet yhtäkään samaa elokuvaa.

Mikaelin ja Pekan samuutta laskettaessa yhteisten elokuvien tulojen summa olisi 25. Mikael ja Pekka ovat katsoneet vain yhden yhteisen elokuvan, ja kumpikin antaneet sille arvosanan huono (-5).

-5 * -5 = 25

Mikaelin ja Matin samuus on -15. Mikael ja Matti ovat myös katsoneet vain yhden yhteisen elokuvan. Mikael antoi elokuvalle arvosanan huono (-5), Matti antoi sille arvosanan ok (3).

-5 * 3 = -15

Näiden perusteella Mikaelille suositellaan elokuvia Pekan elokuvamaun mukaan: suosituksena on elokuva Tuulen viemää.

Kun taas haluamme hakea Matille sopivaa elokuvaa, tutkimme Matin samuutta kaikkien muiden arvioijien kesken. Matti ja Pekka ovat katsoneet kaksi yhteistä elokuvaa. Matti antoi Tuulen viemälle arvosanan huono (-5), Pekka arvosanan OK (3). Elokuvalle Eraserhead Matti antoi arvosanan OK (3), Pekka arvosanan huono (-5). Matin ja Pekan samuus on siis -30.

-5 * 3 + 3 * -5 = -30

Matin ja Mikaelin samuus on edellisestä laskusta tiedetty -15. Samuudet ovat symmetrisia.

Matti ja Thomas ovat katsoneet Tuulen viemää, ja kumpikin antoi sille arvosanan hyvä (5). Matin ja Thomaksen samuus on siis 25.

5 * 5 = 25

Matille tulee siis suositella elokuvia Thomaksen elokuvamaun mukaan: suosituksena olisi Blues Brothers.

Toteuta yllä kuvattu suosittelumekanismi. Jos henkilölle ei löydy yhtään suositeltavaa elokuvaa, tai henkilö, kenen elokuvamaun mukaan elokuvia suositellaan on arvioinut elokuvat joita henkilö ei ole vielä katsonut huonoiksi, välttäviksi tai neutraaleiksi, palauta metodista suositteleElokuva arvo null. Edellisessä tehtävässä määritellyn lähestymistavan tulee toimia jos henkilö ei ole lisännyt yhtäkään arviota.

Älä suosittele elokuvia, jonka henkilö on jo nähnyt.

Voit testata ohjelmasi toimintaa seuraavalla lähdekoodilla:

    ArvioRekisteri arviot = new ArvioRekisteri();

    Elokuva tuulenViemaa = new Elokuva("Tuulen viemää");
    Elokuva hiljaisetSillat = new Elokuva("Hiljaiset sillat");
    Elokuva eraserhead = new Elokuva("Eraserhead");
    Elokuva bluesBrothers = new Elokuva("Blues Brothers");

    Henkilo matti = new Henkilo("Matti");
    Henkilo pekka = new Henkilo("Pekka");
    Henkilo mikke = new Henkilo("Mikael");
    Henkilo thomas = new Henkilo("Thomas");
    Henkilo arto = new Henkilo("Arto");

    arviot.lisaaArvio(matti, tuulenViemaa, Arvio.HUONO);
    arviot.lisaaArvio(matti, hiljaisetSillat, Arvio.HYVA);
    arviot.lisaaArvio(matti, eraserhead, Arvio.OK);

    arviot.lisaaArvio(pekka, tuulenViemaa, Arvio.OK);
    arviot.lisaaArvio(pekka, eraserhead, Arvio.HUONO);
    arviot.lisaaArvio(pekka, bluesBrothers, Arvio.VALTTAVA);
    
    arviot.lisaaArvio(mikke, eraserhead, Arvio.HUONO);
    
    arviot.lisaaArvio(thomas, bluesBrothers, Arvio.HYVA);
    arviot.lisaaArvio(thomas, hiljaisetSillat, Arvio.HYVA);

    Suosittelija suosittelija = new Suosittelija(arviot);
    System.out.println(thomas + " suositus: " + suosittelija.suositteleElokuva(thomas));
    System.out.println(mikke + " suositus: " + suosittelija.suositteleElokuva(mikke));
    System.out.println(matti + " suositus: " + suosittelija.suositteleElokuva(matti));
    System.out.println(arto + " suositus: " + suosittelija.suositteleElokuva(arto));
Thomas suositus: Eraserhead
Mikael suositus: Tuulen viemää
Matti suositus: Blues Brothers
Arto suositus: Hiljaiset sillat

Miljoona käsissä? :) -- ei ehkä vielä. Kursseilla Johdatus tekoälyyn ja Johdatus koneoppimiseen opitaan lisää tekniikoita oppivien järjestelmien rakentamiseen.

Ennalta määrittelemätön määrä parametrin arvoja

Olemme tähän mennessä luoneet metodimme siten, että niiden parametrien määrät ovat olleet selkeästi määritelty. Java tarjoaa tavan antaa metodille rajoittamattoman määrän määrätyntyyppisiä parametreja asettamalla metodimäärittelyssä parametrin tyypille kolme pistettä perään. Esimerkiksi metodille public int summa(int... luvut) voi antaa summattavaksi niin monta int-tyyppistä kokonaislukua kuin käyttäjä haluaa. Metodin sisällä parametrin arvoja voi käsitellä taulukosta.

    public int summa(int... luvut) {
        int summa = 0;
        for (int i = 0; i < luvut.length; i++) {
            summa += luvut[i];
        }
        return summa;
    }
    System.out.println(summa(3, 5, 7, 9));
24

Metodille voi määritellä vain yhden parametrin joka saa rajattoman määrän arvoja, ja sen tulee olla metodimäärittelyn viimeinen parametri. Esimerkiksi seuraavanlainen metodimäärittely ei ole sallittu.

    public void tulosta(String... merkkijonot, int kertaa)

Seuraava metodimäärittely on sallittu.

    public void tulosta(int kertaa, String... merkkijonot)

Ennalta määrittelemätöntä parametrien arvojen määrää käytetään esimerkiksi silloin, kun halutaan tarjota rajapinta, joka ei rajoita sen käyttäjää tiettyyn parametrien määrään. Vaihtoehtoinen lähestymistapa on metodimäärittely, jolla on parametrina tietyn tyyppinen lista. Tällöin oliot voidaan asettaa listaan ennen metodikutsua, ja kutsua metodia antamalla lista sille parametrina.

Demografiapalvelu

Tässä tehtäväsarjassa toteutetaan henkilötietopalvelu, johon voi tehdä hakuja käyttäjän määrittelemien hakukriteerien avulla. Tehtäväpohjan mukana tulee graafinen käyttöliittymä, jonka avulla omaa toteutusta voi kokeilla helposti.

Huom: Graafinen käyttöliittymä käynnistyy vasta ensimmäisessä alitehtävässä käsiteltävien PersonAttribute- ja Gender-enumeraatioiden luomisen jälkeen.

Henkilöiden tiedot lueteltuina tyyppeinä

Lueteltu tyyppi PersonAttribute

Luo pakkaukseen demographics.logic Attribute-rajapinnan toteuttava lueteltu tyyppi PersonAttribute. PersonAttribute määrittelee tiedot, joita henkilöistä talletetaan henkilötietopalveluun. Attribute-rajapinnassa on seuraavat metodit:

Määrittele lueteltuun tyyppiin demographics.logic.PersonAttribute seuraavat vakiot: (suluissa getId()-metodin palauttama merkkijono):

    Attribute attr = PersonAttribute.AGE;
    System.out.println(attr.getId());
age

Lueteltu tyyppi Gender

Luo pakkaukseen demographics.logic sukupuolta kuvaava lueteltuna tyyppinä Gender. Tyypin Gender tulee toteuttaa rajapinta EnumeratedValue. Rajapinnalla EnumeratedValue on vastaavat metodit kuin Attribute-rajapinnalla.

Määrittele lueteltuun tyyppiin Gender tunnukset (ID suluissa):

    EnumeratedValue value = Gender.MALE;
    System.out.println(value.getId());
M

PersonImpl

Luo pakkaukseen demographics.logic luokka PersonImpl, joka toteuttaa tehtäväpohjasta löytyvän henkilöä kuvaavan rajapinnan Person. Henkilöluokka sisältää attribuutteja, joiden arvot kuvaavat henkilön tietoja.

Vinkki! Luokan PersonImpl sisäinen toteutus kannattaa tehdä esimerkiksi hajautustaulun avulla.

Person-rajapinnalla on seuraavat metodit:

PersonImpl-luokkaa voi käyttää esimerkiksi näin:

    Person person = new PersonImpl();
    person.set(PersonAttribute.SOCIAL_SECURITY_NUMBER, "190481-1871");
    person.set(PersonAttribute.GENDER, Gender.FEMALE);
    person.set(PersonAttribute.AGE, 28);
    person.set(PersonAttribute.YEARLY_INCOME, 27580);
    person.set(PersonAttribute.STUDENT, true);

    System.out.println(person.get(PersonAttribute.YEARLY_INCOME));
    System.out.println(person.get(PersonAttribute.STUDENT));

Ohjelma tulostaa:

27580
true

Yhtäsuuruuspredikaatin toteuttaminen

Jotta hakukriteereitä voisi käyttää järkevästi, täytyy niitä voida yhdistellä melko vapaasti. Tätä varten tehtäväpohjassa on runko luokalle CriteriaBuilderImpl, joka toteuttaa CriteriaBuilder-rajapinnan. Luokan avulla voi luoda predikaatteja. Predikaatti sisältää tiedot hakukriteeristä tai niiden yhdistelmästä ja sen avulla voidaan selvittää onko henkilö kriteerin mukainen vai ei.

Predikaatti on määritelty tehtäväpohjassa Predicate-rajapintana:

package demographics.logic;

public interface Predicate {
    boolean matches(Person person);
}

CriteriaBuilder-rajapinnan koodi:

package demographics.logic;

public interface CriteriaBuilder {
    Predicate allOf(Predicate... predicates);
    Predicate oneOf(Predicate... predicates);
    Predicate not(Predicate predicate);

    Predicate equalTo(Attribute attribute, Object value);

    Predicate greaterThan(Attribute attribute, int value);
    Predicate lessThan(Attribute attribute, int value);
    Predicate greaterThanOrEqualTo(Attribute attribute, int value);
    Predicate lessThanOrEqualTo(Attribute attribute, int value);
}

Toteutetaan CriteriaBuilderImpl-luokan runkoon metodi equalTo. Muut metodit voi jättää vielä toteuttamatta, koska niitä tarvitaan vasta myöhemmin.

Metodia equalTo varten tarvitset uuden Predicate-rajapinnan toteuttavan luokan. Toteuta uusi predikaattiluokka siten, että se saa konstruktorissa parametrina Attribute-rajapinnan toteuttavan attribuutin, sekä halutun arvon. Predikaatin metodin matches tulee palauttaa true jos sille parametrina annettu Person-luokan ilmentymän arvot sopivat predikaattiluokassa määriteltyyn attribuuttiin ja sen arvoon.

Palauta uusi predikaattiluokan ilmentymä CriteriaBuilderImpl-luokan metodissa equalTo metodin saamilla parametreilla.

Kun ensimmäinen predikaatti on toteutettu, sen toteuttamaa ehtoa voi kokeilla matches()-metodin avulla esimerkiksi seuraavalla tavalla:

    Person person1 = new PersonImpl();
    person1.set(PersonAttribute.SOCIAL_SECURITY_NUMBER, "213821871");
    person1.set(PersonAttribute.GENDER, Gender.FEMALE);
    person1.set(PersonAttribute.AGE, 28);
    person1.set(PersonAttribute.YEARLY_INCOME, 27580);
    person1.set(PersonAttribute.STUDENT, true);

    CriteriaBuilder is = new CriteriaBuilderImpl();

    System.out.println(is.equalTo(PersonAttribute.STUDENT, false).matches(person1));
    System.out.println(is.equalTo(PersonAttribute.GENDER, Gender.FEMALE).matches(person1));

Ohjelma tulostaa:

false
true

Henkilörekisteri

Henkilötietojen tutkimiseen tarvitaan tiedoille säilytyspaikka, jota varten tehtävässä luodaan henkilörekisteri. Täydennä luokkaa PersonRegistryImpl siten, että se toteuttaa rajapinnan PersonRegistry alla kuvatusti.

package demographics.logic;

import java.util.List;

public interface PersonRegistry {
    void add(Person person);
    void remove(Person person);
    void clear();

    List<Person> getPersons();

    List<Person> query(Predicate predicate);
    List<Person> remove(Predicate predicate);

    CriteriaBuilder getCriteriaBuilder();
}

Henkilötietokyselyjen tekeminen

Laajenna PersonRegistryImpl-henkilörekisteriluokan metodeita seuraavasti:

Henkilörekisteriin tulee myös pystyä tehdä hakuja yhtäsuuruuspredikaatin avulla esimerkiksi seuraavalla tavalla:

    Person person1 = new PersonImpl();
    person1.set(PersonAttribute.SOCIAL_SECURITY_NUMBER, "213821871");
    person1.set(PersonAttribute.GENDER, Gender.FEMALE);
    person1.set(PersonAttribute.AGE, 28);
    person1.set(PersonAttribute.YEARLY_INCOME, 27580);
    person1.set(PersonAttribute.STUDENT, true);

    Person person2 = new PersonImpl();
    person2.set(PersonAttribute.SOCIAL_SECURITY_NUMBER, "98423874");
    person2.set(PersonAttribute.GENDER, Gender.MALE);
    person2.set(PersonAttribute.AGE, 54);
    person2.set(PersonAttribute.YEARLY_INCOME, 45200);
    person2.set(PersonAttribute.STUDENT, false);

    PersonRegistry registry = new PersonRegistryImpl();
    registry.add(person1);
    registry.add(person2);

    CriteriaBuilder is = registry.getCriteriaBuilder();

    List<Person> results1 = registry.query(is.equalTo(PersonAttribute.STUDENT, false));

    List<Person> results2 = registry.query(is.equalTo(PersonAttribute.GENDER, Gender.FEMALE));

    List<Person> removed = registry.remove(is.equalTo(PersonAttribute.GENDER, Gender.MALE));

allOf-predikaatin toteuttaminen

Jotta hakuehtojen käyttö olisi mielekästä, täytyy niitä voida yhdistellä. allOf-predikaatti yhdistää kaikki sille parametreina annetut predikaatit siten, että kaikkien predikaattien ehtojen on toteuduttava, jotta yhdistelmäpredikaatin ehto toteutuisi.

Esimerkiksi seuraava predikaatti toteutuu vain, jos henkilö on mies ja hän ei ole opiskelija:

    CriteriaBuilder is = registry.getCriteriaBuilder();

    Predicate predicate = is.allOf(is.equalTo(PersonAttribute.STUDENT, false), is.equalTo(PersonAttribute.Gender, Gender.MALE));

Toteuta CriteriaBuilderImpl-luokalle allOf-predikaatti:

Huom: allOf-predikaatin toteuttamisen jälkeen käyttöliittymän Filter...- ja Remove...-ominaisuudet alkavat toimia. Tässä vaiheessa voit tosin käyttää vasta yhtäsuuruusehtoa (=) ja "matching"-vaihtoehtoa (eli "non-matching" ei toimi), koska nämä toiminnot toteutetaan vasta myöhemmin.

Vinkki! Tässä kohdassa kannattaa esimerkiksi muistella miten tehtävän 164 koostekuvio koostui...

Kokonaislukujen vertailuun tarkoitettujen predikaattien toteuttaminen

Toteuta CriteriaBuilderImpl-luokalle seuraavat metodit:

Henkilörekisteriin voi tehdä hakuja kokonaislukujen vertailupredikaateilla esimerkiksi näin:

    Person person1 = new PersonImpl();
    person1.set(PersonAttribute.SOCIAL_SECURITY_NUMBER, "94726823");
    person1.set(PersonAttribute.GENDER, Gender.FEMALE);
    person1.set(PersonAttribute.AGE, 37);
    person1.set(PersonAttribute.YEARLY_INCOME, 17500);
    person1.set(PersonAttribute.STUDENT, false);

    Person person2 = new PersonImpl();
    person2.set(PersonAttribute.SOCIAL_SECURITY_NUMBER, "38271623");
    person2.set(PersonAttribute.GENDER, Gender.MALE);
    person2.set(PersonAttribute.AGE, 60);
    person2.set(PersonAttribute.YEARLY_INCOME, 50000);
    person2.set(PersonAttribute.STUDENT, false);

    PersonRegistry registry = new PersonRegistryImpl();
    registry.add(person1);
    registry.add(person2);

    CriteriaBuilder is = registry.getCriteriaBuilder();

    List<Person> results1 = registry.query(is.greaterThan(PersonAttribute.AGE, 40));

    List<Person> results2 = registry.query(is.lessThanOrEqualTo(PersonAttribute.YEARLY_INCOME, 50000));

Huom: Nyt voit käyttää käyttöliittymässä vertailuoperaatioita: >, <, >= ja <=.

not-predikaatin toteuttaminen

Jatketaan predikaattien toteuttamista. not-predikaatti yksinkertaisesti vaihtaa sille annetun predikaatin totuusarvon päinvastaiseksi.

not-predikaatti toimii näin:

    CriteriaBuilder is = registry.getCriteriaBuilder();
 
    // Predikaatti toteutuu, jos henkilö ei ole 6-vuotias
    Predicate predicate1 = is.not(is.equalTo(PersonAttribute.AGE, 6));

    // Predikaatti toteutuu, jos henkilö ei ole alle 18-vuotias
    Predicate predicate2 = is.not(is.lessThan(PersonAttribute.AGE, 18));

    // Predikaatti toteutuu, jos henkilö ei ole mies
    Predicate predicate3 = is.not(is.equalTo(PersonAttribute.GENDER, Gender.MALE));

Toteuta CriteriaBuilderImpl-luokalle metodi:

Huom: not-predikaatin toteuttamisen jälkeen käyttöliittymän kaikki toiminnot ovat käytettävissä.

oneOf-predikaatin toteuttaminen

oneOf-predikaatin ehto toteutuu, jos vähintään yksi sille annetuista predikaateista toteutuu.

Esimerkiksi seuraava oneOf-predikaatti toteutuu, jos henkilö on joko nainen (opiskelijastatuksella ei ole väliä) tai opiskelija (jolloin sukupuolella ei ole väliä):

    CriteriaBuilder is = registry.getCriteriaBuilder();

    Predicate predicate = is.oneOf(is.equalTo(PersonAttribute.STUDENT, true), is.equalTo(PersonAttribute.Gender, Gender.FEMALE));

Predikaatteja voidaan myös yhdistellä lähes mielivaltaisesti. Edellinen ehto voidaan kääntää päinvastaiseksi not-predikaatilla, jolloin se toteutuu vain, jos henkilö ei ole nainen eikä opiskelija:

    CriteriaBuilder is = registry.getCriteriaBuilder();

    Predicate predicate = is.not(is.oneOf(is.equalTo(PersonAttribute.STUDENT, true), is.equalTo(PersonAttribute.Gender, Gender.FEMALE)));

Toteuta CriteriaBuilderImpl-luokalle metodi:

Huom: oneOf-predikaattia ei voi testata käyttöliittymän kautta.

Nopeustesti

Luodaan ohjelma, joka mittaa kliksutteluvauhtia. Käyttöliittymä tulee näyttämään esimerkiksi seuraavalta.

Oma luokka JButtonille

Toteuta pakkaukseen nopeustesti luokka Nappi, joka perii JButtonin. Luokalla Nappi tulee olla konstruktori public Nappi(String text, Color aktiivinen, Color passiivinen). Konstruktorin parametrina saama merkkijono text tulee antaa parametrina yläluokan konstruktorille (kutsu super(text)).

Korvaa luokasta JButton peritty metodi protected void paintComponent(Graphics g) siten, että piirrät metodissa napin kokoisen värillisen ympyrän. Saat napin leveyden ja korkeuden JButton-luokalta perityistä metodeista getWidth() ja getHeight(). Kutsu korvatun metodin alussa yläluokan paintComponent-metodia.

Ympyrän värin tulee riippua Napin tilasta: jos nappi on aktiivinen (metodi isEnabled palauttaa true tulee ympyrän väri olla konstruktorin parametrina saatu aktiivinenVari. Muulloin käytetään väriä passiivinenVari.

Perustoiminta

Toteuta luokkaan Nopeustesti käyttöliittymä, jossa on neljä nappulaa ja teksti. Käytä asettelussa napeille omaa JPanel-alustaa, joka asetetaan BorderLayout-asettelijan keskelle. Teksti tulee BorderLayout-asettelijan alaosaan.

Käytä edellisessä osassa luomaasi Nappi-luokkaa. Napeille tulee antaa konstruktorissa tekstit 1, 2, 3 ja 4.

Nappuloiden aktiivisuus

Vain yhden nappulan kerrallaan tulee olla painettavissa (eli aktiivisena). Voit tehdä nappulasta ei-aktiivisen metodikutsulla nappi.setEnabled(false). Vastaavasti nappi muutetaan aktiiviseksi kutsulla nappi.setEnabled(true).

Kun aktiivisena olevaa nappulaa painetaan, tulee käyttöliittymän arpoa uusi aktiivinen nappi.

Pisteytys

Tehdään peliin pisteytys: mitataan 20 painallukseen kuluva aika. Helpoin tapa ajan mittaamiseen on metodin System.currentTimeMillis() kutsuminen. Metodi palauttaa kokonaisluvunu, joka laskee millisekunteja (tuhannesosasekunteja) jostain tietysti ajanhetkestä lähtien. Siispä voit mitata kulunutta aikaa kutsumalla currentTimeMillis pelin alussa ja lopussa ja laskemalla erotuksen.

Toteuta siis seuraava: peli laskee napinpainallusten määrän, ja 20. painalluksen jälkeen asettaa kaikki nappulat epäaktiivisiksi ja näyttää JLabel-komponentissa viestin "Pisteesi: XXXX", jossa XXXX on painalluksiin kulunut aika (millisekunteina) jaettuna 20:lla. Pienempi pistemäärä on siis parempi.

Matopeli

Tässä tehtävässä luodaan rakenteet ja osa toiminnallisuudesta seuraavannäköiseen matopeliin.

Yleiskäyttöinen luokka Pala ja Omena

Luo pakkaukseen matopeli.domain luokka Pala. Luokalla Pala on konstruktori public Pala(int x, int y), joka saa palan sijainnin parametrina. Lisäksi luokalla Pala on seuraavat metodit.

Toteuta pakkaukseen matopeli.domain myös luokka Omena. Peri luokalla Omena luokka Pala.

Mato

Toteuta pakkaukseen matopeli.domain luokka Mato. Luokalla Mato on konstruktori public Mato(int alkuX, int alkuY, Suunta alkusuunta), joka luo uuden madon jonka suunta on parametrina annettu alkusuunta. Mato koostuu listasta Pala-luokan ilmentymiä.

Mato luodaan yhden palan pituisena, mutta madon "aikuispituus" on kolme. Madon tulee kasvaa yhdellä aina kun se liikkuu. Kun madon pituus on kolme, se kasvaa isommaksi vain syödessään.

Toteuta madolle seuraavat metodit

Metodien public void kasva() ja public void liiku() toiminnallisuus tulee toteuttaa siten, että mato kasvaa vasta seuraavalla liikkumiskerralla.

Liikkuminen kannattaa toteuttaa siten, että madolle luodaan liikkuessa aina uusi pala. Uuden palan sijainti riippuu madon kulkusuunnasta: vasemmalle mennessä uuden palan sijainti on edellisen pääpalan sijainnista yksi vasemmalle, eli sen x-koordinaatti on yhtä pienempi. Jos uuden palan sijainti on edellisen pääpalan alapuolella, eli madon suunta on alas, tulee uuden palan y-koordinaatin olla yhtä isompi kuin pääpalan y-koordinaatti (käytämme siis piirtämisestä tuttua koordinaattijärjestelmää, jossa y-akseli on kääntynyt).

Liikkuessa uusi pala lisätään listalle, ja viimeisin poistetaan listan lopusta. Tällöin jokaisen palan koordinaatteja ei tarvitse päivittää erikseen. Toteuta kasvaminen siten, että palaa viimeisintä palaa ei poisteta jos metodia kasva on juuri kutsuttu.

Huom! Kasvata matoa aina sen liikkuessa jos sen pituus on pienempi kuin 3.

        Mato mato = new Mato(5, 5, Suunta.OIKEA);
        System.out.println(mato.getPalat());
        mato.liiku();
        System.out.println(mato.getPalat());
        mato.liiku();
        System.out.println(mato.getPalat());
        mato.liiku();
        System.out.println(mato.getPalat());
        
        mato.kasva();
        System.out.println(mato.getPalat());
        mato.liiku();
        System.out.println(mato.getPalat());

        mato.setSuunta(Suunta.VASEN);
        System.out.println(mato.osuuItseensa());
        mato.liiku();
        System.out.println(mato.osuuItseensa());
[(5,5)]
[(6,5), (5,5)]
[(7,5), (6,5), (5,5)]
[(8,5), (7,5), (6,5)]
[(8,5), (7,5), (6,5)]
[(9,5), (8,5), (7,5), (6,5)]
false
true

Matopeli, osa 1

Muokataan seuraavaksi pakkauksessa matopeli.peli olevaa matopelin toiminnallisuutta kapseloivaa luokka Matopeli. Matopeli-luokka perii luokan Timer, joka tarjoaa ajastustoiminnallisuuden pelin päivittämiseen. Luokka Timer vaatii toimiakseen ActionListener-rajapinnan toteuttavan luokan. Olemme toteuttaneet luokalla Matopeli rajapinnan ActionListener.

Muokkaa matopelin konstruktorin toiminnallisuutta siten, että konstruktorissa luodaan peliin liittyvä Mato. Luo mato siten, että sijainti riippuu Matopeli-luokan konstruktorissa saaduista parametreista. Madon x-koordinaatin tulee olla leveys / 2, y-koordinaatin korkeus / 2 ja suunnan Suunta.ALAS.

Luo konstruktorissa myös omena. Konstruktorissa luotavan omenan sijainnin tulee olla satunnainen, kuitenkin niin että omenan x-koordinaatti on aina välillä [0, leveys[, ja y-koordinaatti välillä [0, korkeus[.

Lisää matopeliin lisäksi seuraavat metodit

Matopeli, osa 2

Muokkaa metodin actionPerformed-toiminnallisuutta siten, että metodissa toteutetaan seuraavat askeleet annetussa järjestyksessä.

  1. Liikuta matoa
  2. Jos mato osuu omenaan, syö omena ja kutsu madon kasva-metodia. Arvo peliin uusi omena.
  3. Jos mato törmää itseensä, aseta muuttujan jatkuu arvoksi false
  4. Kutsu rajapinnan Paivitettava toteuttavan muuttujan paivitettava metodia paivita.
  5. Kutsu Timer-luokalta perittyä setDelay-metodia siten, että pelin nopeus kasvaa suhteessa madon pituuteen. Kutsu setDelay(1000 / mato.getPituus()); käy hyvin: kutsussa oletetaan että olet määritellyt oliomuuttujan nimeltä mato.

Aletaan seuraavaksi rakentamaan käyttöliittymäkomponentteja.

Näppäimistön kuuntelija

Toteuta pakkaukseen matopeli.gui luokka Nappaimistonkuuntelija. Luokalla on konstruktori public Nappaimistonkuuntelija(Mato mato), ja se toteuttaa rajapinnan KeyListener. Korvaa metodi keyPressed siten, että nuolinäppäintä ylös painettaessa madolle asetetaan suunta ylös. Nuolinäppäintä alas painettaessa madolle asetetaan suunta alas, vasemmalle painettaessa suunta vasen, ja oikealle painettaessa suunta oikea.

Piirtoalusta

Toteuta pakkaukseen matopeli.gui luokka Piirtoalusta, joka perii luokan JPanel. Piirtoalusta saa konstruktorin parametrina luokan Matopeli ilmentymän sekä int-tyyppisen muuttujan palanSivunPituus. Muuttuja palanSivunPituus kertoo minkä levyinen ja korkuinen yksittäinen pala on.

Korvaa luokalta JPanel peritty metodi paintComponent siten, että piirrät metodissa paintComponent madon ja omenan. Käytä madon piirtämiseen Graphics-olion tarjoamaa fill3DRect-metodia. Madon värin tulee olla valkoinen (Color.WHITE). Omenan piirtämisessä tulee käyttää Graphics-olion tarjoamaa fillOval-metodia. Omenan värin tulee olla punainen (Color.RED).

Toteuta luokalla Piirtoalusta myös rajapinta Paivitettava. Paivitettava-rajapinnan määrittelemän metodin paivita tulee kutsua JPanel-luokan repaint-metodia.

Kayttoliittyma

Muuta luokkaa Kayttoliittyma siten, että käyttöliittymä sisältää piirtoalustan. Metodissa luoKomponentit tulee luoda piirtoalustan ilmentymä ja lisätä se container-olioon. Luo metodin luoKomponentit lopussa luokan Nappaimistokuuntelija ilmentymä, ja lisää se frame-olioon.

Lisää luokalle Kayttoliittyma myös metodi public Paivitettava getPaivitettava(), joka palauttaa metodissa luoKomponentit luotavan piirtoalustan.

Voit käynnistää käyttöliittymän Main-luokassa seuraavasti. Ennen pelin käynnistystä odotamme että käyttöliittymä luodaan. Kun käyttöliittymä on luotu, se kytketään matopeliin ja matopeli käynnistetään.

        Matopeli matopeli = new Matopeli(20, 20);

        Kayttoliittyma kali = new Kayttoliittyma(matopeli, 20);
        SwingUtilities.invokeLater(kali);

        while (kali.getPaivitettava() == null) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException ex) {
                System.out.println("Piirtoalustaa ei ole vielä luotu.");
            }
        }

        matopeli.setPaivitettava(kali.getPaivitettava());
        matopeli.start();