Ohjelmoinnin jatkokurssi

Arto Vihavainen ja Matti Luukkainen

Huomautus lukijalle

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

Tämä materiaali on tarkoitettu Helsingin Yliopiston Tietojenkäsittelytieteen laitoksen syksyn 2012 Ohjelmoinnin perusteet- ja jatkokurssille. Materiaali pohjautuu keväiden 2012, 2011 ja 2010 kurssimateriaaleihin, joiden sisältöön ovat vaikuttaneet Matti Paksula, Antti Laaksonen, Pekka Mikkola, Juhana Laurinharju, Martin Pärtel, Joel Kaasinen ja Mikael Nousiainen

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.

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

Viikko1

Ohjelmoinnin perusteiden kertausta

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

Ruostunutta ohjelmointitaitoa voi myös verestää täältä löytyvän tehtäväsarjan avulla.

Aiempien viikkojen kertausta

Tässä luvussa kerrataan pikaisesti muutamia aiempina viikkoina tutuksi tulleita asioita.

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ä viitetyyppiset muuttujatyypit String (merkkijono), ArrayList (taulukko) ja kaikki luokat. Palaamme alkeistyyppisiin ja viitetyyppisiin 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-else if-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. Alla oleva 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.

Ohjelmoija määrittelee metodeja ohjelman sisältävien toiminnallisuuksien abstrahoimiseksi. Ohjelmoidessa tulee tavoitella tilannetta, jossa ohjelmaa voidaan katsoa korkeammalta tasolta, jolloin pääohjelma koostuu joukosta itse määriteltyjen, hyvin nimettyjen metodien kutsuja. Metodit taas tarkentavat ohjelman toiminnallisuutta ja ehkä toteuttavat oman toiminnallisuutensa kutsumalla joitain muita metodeja.

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. Seuraavassa 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 toimii muuten samoin kuin 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ä luvussa huomasimme että koodi sisälsi tähtien tulostamisen osalta ikävästi "copypastea". Metodien avulla voimme siirtää tähtien tulostamisen erillisen metodiin. Luodaan metodi public static void tulostaTahtia(int kertaa), joka tulostaa parametrina annetun määrän tähtiä. Metodi käyttää for-toistolausetta while:n 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 vielä pienempiin ja loogisempiin kokonaisuuksiin. Luokkien avulla voimme määritellä ohjelmaan korkeamman tason 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 siis 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. Tätä seuraa oliomuuttujien määrittely. Oliomuuttujat ovat muuttujia, joista jokaisella luokan oliolla on oma toisista riippumaton kopionsa. On yleensä tarkoituksenmukaista piilottaa oliomuuttujat luokan käyttäjiltä, eli määritellä niille näkyvyysmääre private. Jos näkyvyysmääreeksi asetetaan public pääsee olion käyttäjä suoraan käsiksi oliomuuttujiin.

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.

Ylläolevalle luokalle on määritelty myös 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ä viitetyyppisiä muuttujia. Jos olioon liittyy viitetyyppisiä 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 viitetyyppisten muuttujien eroon tarkemmin myöhemmin.

Ohjelmien rakenteesta

Ohjelmista tulee olla selkeitä niin ohjelmoijalle kuin muille ohjelmaa tarkasteleville. Selkeys saadaan aikaan sopivalla luokkarakenteella ja hyvien nimeämiskäytänteiden seuraamisella. Jokaisella luokalla tulee olla selkeä vastuu, johon liittyviä tehtäviä luokka hoitaa. Metodeja käytetään toiston vähentämiseen ja luokkien sisäisten toimintojen jäsentämiseen. Myös metodilla tulee olla selkeä vastuu eli metodien ei tule olla liian pitkiä ja liian montaa asiaa tekeviä. Liian montaa asiaa tekevät monimutkaiset metodit tuleekin pilkkoa useiksi pienemmiksi apumetodeiksi joita alkuperäinen metodi kutsuu. 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 nimeäminen kuin ohjelmakoodin ilmavuus ja johdonmukainen sisentäminen. 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 myytävien tavaroiden varasto konstruktorin parametrina.

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 rakenteeltaan riittävän selkeä, metodit ovat kuvaavasti nimettyjä ja jokaisella metodilla on oma pieni tehtävä, josta metodi huolehtii. 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 metodit 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, tulee ylimääräinen välilyönti lisätä 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:

  • konstruktori public Muunnos(char muunnettava, char muunnettu) luo olion joka tekee muunnoksia merkiltä muunnettava merkille muunnettu
  • metodi public String muunna(String merkkijono) palauttaa muunnetun version annetusta merkkijonosta

Luokkaa käytetään seuraavalla tavalla:

  String sana = "porkkana";
  Muunnos muunnos1 = new Muunnos('a', 'b');
  sana = muunnos1.muunna(sana);

  System.out.println(sana);

  Muunnos muunnos2 = new Muunnos('k', 'x');
  sana = muunnos2.muunna(sana);

  System.out.println(sana);

Yllä oleva esimerkki tulostaisi:

  porkkbnb
  porxxbnb

Vihje: voit tehdä merkkien korvaamisen kahdella tavalla, joko suoraan hyödyntäen luokan String metodia (etsi metodi itse!) tai käymällä merkkijonon läpi merkki merkiltä ja samalla muodostaen muunnetun merkkijonon.

Jos et käytä Stringin valmista metodia, niin kannattaa muistaa, että vaikka merkkijonoja vertaillaankin komennolla equals yksittäisiä merkkejä vertaillaan operaattorin == avulla:

  String sana = "porkkana";

  String korvattuAat = ""; 
  for ( int i=0; i < sana.length(); i++) {
     char merkki = sana.charAt(i);
     if ( merkki == 'a' ) {  
        korvattuAat += '*'
     }  else {
        korvattuAat += merkki;
     } 
  }

  System.out.println(korvattuAat);  // tulostu porkk*n*

Muuntaja-luokka

Luo luokka Muuntaja, jolla on seuraavat toiminnot:

  • konstruktori public Muuntaja() luo uuden muuntajan
  • metodi public void lisaaMuunnos(Muunnos muunnos) lisää uuden muunnoksen muuntajaan
  • metodi public String muunna(String merkkijono) suorittaa merkkijonolle kaikki lisätyt muunnokset niiden lisäysjärjestyksessä ja palauttaa muunnetun merkkijonon

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

Vihje: Muunnokset kannattaa tallettaa Muuntajan oliomuuttujana olevalle listalle (samaan tyyliin kuin peruskurssilla talletettiin esim. pelaajat joukkueeseen, puhelinnumerot puhelinluetteloon tai kirjat kirjastoon). Muunnos suoritetaan siten, että muunnettavalle merkkijonolle suoritetaan muunnokset yksi kerrallaan seuraavan esimerkin tapaan:

    ArrayList<Muunnos> muunnokset = new ArrayList<Muunnos>();

    muunnokset.add( new Muunnos('a', 'b') );
    muunnokset.add( new Muunnos('k', 'x') );
    muunnokset.add( new Muunnos('o', 'å') );

    String sana = "porkkana";

    for (Muunnos muunnos : muunnokset) {
        sana = muunnos.muunna(sana);
    }

    System.out.println(sana);  // tulostuu pårxxbnb

MUISTUTUS kun lisäät ohjelmaasi ArrayList:in, Scanner:in tai Random:in ei Java tunnista luokkaa ellet "importoi" sitä lisäämällä ohjelmatiedoston alkuun:

import java.util.ArrayList;    // importoi ArrayListin
import java.util.*;            // importoi kaikki java.util:sissa olevat työkalut, mm. ArrayListin, Scannerin ja Randomin

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

  • public String lueMerkkijono()
  • public int lueKokonaisluku()

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 sovelluslogiikasta 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ä lukujen kysyminen tapahtuu metodiensumma, erotus ja tulo sisällä! Metodit käyttävät lukujen kysymiseen lukija-olioa. Metodien runko on siis seuraavanlainen:

    private void summa() {
       System.out.print("luku1: ");   
       int luku1 = // luetaan luku käyttäen lukija-olioa
       System.out.print("luku2: "); 
       int luku2 = // luetaan luku käyttäen lukija-olioa
       // tulostetaan tulos ylläolevan esimerkin tapaan
    }

Statistiikka

Metodissa kaynnista olevan while-silmukan jälkeen kutsutaan metodia statistiikka. Metodin on tarkoitus tulostaa kuinka montaa kertaa laskin-oliolla 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

Bonustehtävä (ei testata): Syötteen lukeminen toistuu samanlaisena kaikissa kolmessa laskuoperaation suorittavassa metodissa. Posta koodistasi toisteisuus sopivan apumetodin avulla. Metodi voi palauttaa käyttäjältä luetut 2 lukua esim. taulukossa.

Alkeis- ja viitetyyppiset muuttujat

Java on vahvasti tyypitetty kieli, eli kaikilla sen muuttujilla on tyyppi. Muuttujien tyypit voidaan jakaa kahteen kategoriaan: alkeis- ja viitetyyppisiin muuttujiin. Kummankin kategorian tyypisillä muuttujilla on oma "lokero", joka sisältää niihin liittyvän tiedon. Alkeistyyppisillä muuttujilla lokeroon talletetaan muuttujien konkreettinen arvo, kun taas viitetyyppisten muuttujien lokero sisältää viitteen muuttujaan liittyvään konkreettiseen olioon.

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 parametrille kopio annetun muuttujan lokerossa olevasta. Käytännössä myös metodin parametreillä 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.04 ei tuota toivottua tulosta pyöristysvirheen takia.

double eka = 0.39;
double toka = 0.35;

if((eka - toka) == 0.04) {
    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.04 - (eka - toka);

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

Viitetyyppi

Viitetyyppiset muuttujat tallentavat niihin liittyvän tiedon viitteen taakse eli "langan päähän". Viitetyyppisten muuttujien lokerossa on viite tiedon sisältävään paikkaan. Toisin kuin alkeistyyppisillä muuttujilla, viitetyyppisillä muuttujilla ei ole rajoitettua arvoaluetta, koska niiden oikea arvo tai tieto on viitteen takana. Oleellinen ero alkeistyyppisiin muuttujiin on se, että usea viitetyyppinen muuttuja voi viitata samaan olioon.

Tutkitaan kahden viitetyyppisen 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 viitetyyppinen muuttuja bonusLaskuri. Komentoa new kutsuessa varataan tila muuttujan tietoa varten, suoritetaan new-kutsua seuraavan konstruktorin koodi, ja palautetaan viite juuri luotuun olioon. . Palautettu viite asetetaan sijoitusoperaattorilla = muuttujaan bonusLaskuri. Sama tapahtuu muuttujalle nimeltä axeLaskuri. Kuvana viitetyyppi 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 viitetyyppisen 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

Viitetyyppistä 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 viitetyyppisten muuttujien viitteitä, kun taas alkeistyyppisiin muuttujiin liittyvässä esimerkissä kopioimme alkeistyyppien arvoja. Kummassakin tapauksessa siis lokeron sisältö kopioidaan, alkeistyyppisten muuttujien lokero sisältää arvon, viitetyyppisten 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.

Seuraavassa esimerkissä on kolme viitetyyppistä 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: viitetyyppisiä 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:

Viitetyyppinen muuttuja metodin parametrina

Kun viitetyyppinen muuttuja annetaan parametrina metodille, luodaan metodin parametrille kopio annetusta muuttujan viitteestä. Parametrilla 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, viitetyyppinen muuttuja ja alkeistyyppinen muuttuja. Kumpaankin muuttujaan liittyvän lokeron sisältö kopioidaan metodin parametrien omiin lokeroihin. Viitetyyppiselle parametrille laskuri kopioituu viite ja alkeistyyppiselle parametrille 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 parametriin laskuri ja paljonko kopioituvat siis viitetyyppisen muuttujan bonus viite, ja alkeistyyppisen muuttujan kertoja arvo 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!

Viitetyyppisestä 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.

Viitetyyppinen muuttuja metodin paluuarvona

Kun metodi palauttaa viitetyyppisen muuttujan, palauttaa se viitteen muualla sijaitsevaan olioon. Metodin palauttaman viitetyyppisen muuttujan voi asettaa muuttujalle samalla tavalla kuin normaalikin asetus tapahtuu, eli yhtäsuuruusmerkin avulla. Katsotaan metodia public Laskuri luoLaskuri(int alkuarvo), joka luo uuden viitetyyppisen 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 viitetyyppiseen 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. Staattisuudella ja ei-staattisisuudella 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 suurimman kokonaisluvun kertova luokkamuuttuja Integer.MAX_VALUE on vakiotyyppinen luokkamuuttuja.

Jos käytössämme on yllä esitelty luokka HslHinnasto, voivat kaikki ohjelmat, jotka tarvitsevat kerta- tai raitiovaunulipun hintaa, päästä niihin käsiksi 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 vakiot eli final-määreellä varustetut 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 metodia annaKertalipunHinta() sen sijaan että kutsuttaisiin muuttujaa suoraan.

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
}

Vaikka Java periaatteessa mahdollistaa staattisten muuttujien käytön, ei niille useinkaan ole tarvetta. Usein staattisten muuttujien käyttö aiheuttaa ongelmia ohjelman rakenteelle, sillä staattiset muuttujat toimivat pahamaineisten globaalien muuttujien tapaan. Tällä kurssilla käytämme ainoastaan vakioarvoisia eli final määreen omaavia staattisia muuttujia!

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 viitetyyppistä 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. Metodi getNimi() palauttaa viitteen nimi-olioon, ja getRahat()-metodi palauttaa alkeistyyppisen muuttujan rahat. Metodi lisaaRahaa(double summa) lisää 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); 

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:

  • Konstruktori, jolle annetaan parametrina tavaran nimi ja paino
  • Metodi public String getNimi(), joka palauttaa tavaran nimen
  • Metodi public int getPaino(), joka palauttaa tavaran painon
  • Metodi public String toString(), joka palauttaa merkkijonon muotoa "nimi (paino kg)"

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:

  • Konstruktori, jolle annetaan maksimipaino
  • Metodi public void lisaaTavara(Tavara tavara), joka lisää parametrina annettavan tavaran matkalaukkuun. Metodi ei palauta mitään arvoa.
  • Metodi public String toString(), joka palauttaa merkkijonon muotoa "x tavaraa (y kg)"

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:

  • metodi tulostaTavarat, joka tulostaa kaikki matkalaukussa olevat tavarat
  • metodi yhteispaino, joka palauttaa tavaroiden yhteispainon

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:

  • konstruktori, jolle annetaan maksimipaino
  • metodi public void lisaaMatkalaukku(Matkalaukku laukku), joka lisää parametrina annetun matkalaukun lastiruumaan
  • metodi public String toString(), joka palauttaa merkkijonon muotoa "x matkalaukkua (y kg)"

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

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.

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ä avainta vastaa aina yksi arvo. Jos jo olemassaolevalla avaimella tallennetaan uusi arvo, katoaa vanha arvo 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.

  • matin lempinimi on mage
  • mikaelin lempinimi on mixu
  • arton lempinimi on arppa

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

Testit edellyttävät että kirjoitat nimet pienellä alkukirjaimella.

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(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() {
        return "Nimi: " + this.nimi + " (" + this.julkaisuvuosi + ")\n"
                + "Sisältö: " + this.sisalto;
    }
}

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 viitetyyppisiä. Jos haluat käyttää alkeistyyppisiä muuttujia avaimena tai tallennettavana arvona, on niille olemassa myös viitetyyppiset vastineet. Alla on esitelty muutama.

AlkeistyyppiViitetyyppinen vastine
intInteger
doubleDouble
charCharacter

Java oikeastaan kapseloi alkeistyyppiset muuttujat automaattisesti viitetyyppisiksi 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 viitetyyppisiksi 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 Rekisteribongaus() {
        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 viitetyyppiset 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:

  • konstruktori public Velkakirja() luo uuden velkakirjan
  • metodi public void asetaLaina(String kenelle, double maara) tallettaa velkakirjaan merkinnän lainasta tietylle henkilölle.
  • metodi public double paljonkoVelkaa(String kuka) palauttaa velan määrän annetun henkilön nimen perusteella

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

Ole tarkkana tilanteessa, jossa kysytään velattoman ihmisen velkaa. Kertaa luvun 4.3 lopun esimerkki!

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:

  • public String kaanna(String sana) metodi palauttaa parametrinsa käännöksen. Jos sanaa ei tunneta, palautetaan null.
  • public void lisaa(String sana, String kaannos) metodi lisää sanakirjaan uuden käännöksen

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

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

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

Vihje: saat käytyä kaikki HashMapissa olevat avaimet läpi metodin keySet avulla seuraavasti:

    HashMap<String, String> sanaparit = new HashMap<String, String>();

    sanaparit.put("apina", "eläin");
    sanaparit.put("etelä", "ilmansuunta");
    sanaparit.put("sauerkraut", "ruoka");

    for ( String avain : sanaparit.keySet() ) {
        System.out.print( avain + " " );
    }
 
    // tulostuu apina etelä sauerkraut

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

  • konstruktori public Tekstikayttoliittyma(Scanner lukija, Sanakirja sanakirja)
  • metodi public void kaynnista(), joka käynnistää tekstikäyttöliittymän.

Tekstikäyttöliittymä tallettaa konstruktorin parametrina saamansa lukijan ja sanakirjan oliomuuttujiin. Muita oliomuuttujia ei tarvita. Käyttäjän syötteen lukeminen tulee hoitaa konstruktorin parametrina saatua lukija-olioa käyttäen! Myös kaikki käännökset on talletettava konstruktorin parametrina saatuun sanakirja-olioon. Tekstikäyttöliittymä ei saa luoda olioita itse!

HUOM: vielä uudelleen edellinen, eli Tekstikäyttöliittymä ei saa luoda itse skanneria vaan sen on käytettävä parametrina saamaansa skanneria syötteiden lukemiseen!

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 syntaksi ja semantiikka: muuttujien määrittelytapa, kontrollirakenteiden muoto, ja muuttujien ja luokkien rakenne ja niiden toimintatapa. 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.

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

Huom: jotta testit toimisivat, ohjelmasi saa luoda vain yhden Scanner-olion. Älä myöskään käytä staattisia muuttujia, testit suorittavat ohjelman useita kertoja joten staattisiin muuttujiin edellisillä suorituskerroilla jääneet arvot todennäköisesti häiritsevät testien toimintaa!

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

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

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

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

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

Huom1: Testien kannalta on oleellista että käyttöliittymä toimii täsmälleen kuten yllä kuvattu. Ohjelman tulostamat menut kannattaneekin copypasteta tästä ohjelmakoodiin. Testit eivät oleta, että ohjelmasi on varautunut epäkelpoihin syötteisiin. Tämä tehtävä on kolmen yksittäisen tehtäväpisteen arvoinen.

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

Ohjelman tulee käynnistyä kun tehtäväpohjassa oleva main-metodi suoritetaan.

Vielä uudelleen varoitus: jotta testit toimisivat, ohjelmasi saa luoda vain yhden Scanner-olion. Älä myöskään käytä staattisia muuttujia, testit suorittavat ohjelman useita kertoja joten staattisiin muuttujiin edellisillä suorituskerroilla jääneet arvot todennäköisesti häiritsevät testien toimintaa!

Viikko2

Object

Kurssilla on jo useampaan otteeseen käytetty metodia public String toString() olion merkkijonoesityksen muodostamiseen esim. tulostuskomennon yhteydessä. 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())

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 antaa vihjeen että annotaatiota seuraava korvaa perityssä luokassa määritellyn metodin. Jos korvattavaan metodiin ei liitetä annotaatiota, antaa kääntäjä tilanteesa varoituksen, overriden kirjottamatta jättäminen ei kuitenkaan ole virhe.

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 yhteydessä.

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, eli toisinsanoen oletusarvoisesti vertaillaan onko kyse kahdesta samasta oliosta. 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");
}

// nyt luodaan saman sisältöinen olio joka kuitenkin on oma erillinen olionsa
toinenOlioKirja = new Kirja("Oliokirja", 2000);

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

Tulostuu:

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 eli vertaillaan olioa itseensä. Toisessa vertailussa kyse on kahdesta eri oliosta, vaikka muuttujilla onkin samat arvot.

Merkkijonojen eli Stringien yhteydessä equals toimii odotetulla tavalla, eli se ilmoittaa kaksi samansisältöistä merkkijonoa "equalseiksi" vaikka kyseessä olisikin kaksi erillistä olioa. String-luokassa onkin korvattu oletusarvoinen equals omalla toteutuksella.

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 viitteen 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 (this.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-tyyppisellä parametrilla viitattua olioa, tulee Object-viitteen tyyppiä muuttaa. Viitteen tyyppiä voidaan 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 alla olevaa 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 jos olioa käytetään HashMap:in avaimena. Olemme tähän mennessä käyttäneet HashMapin avaimina ainoastaan String- ja Integer-tyyppisiä olioita ja niillä on valmiit sopivasti toteutetut hashCode-metodit. Luodaan esimerkki jossa näin ei ole: jatketaan kirjojen parissa ja ruvetaan pitämään kirjaa lainassa olevista kirjoista. Päätetään ratkaista kirjanpito HashMapin avulla. Avaimena toimii kirja ja kirjaan liitetty arvo on merkkijono, joka keroo lainaajan nimen:

        HashMap<Kirja, String> lainaajat = new HashMap<Kirja, String>();
       
        Kirja oliokirja = new Kirja("Oliokirja", 2000);
        lainaajat.put( oliokirja, "Pekka" );
        lainaajat.put( new Kirja("Test Driven Development",1999), "Arto" );
        
        System.out.println( lainaajat.get( oliokirja ) );
        System.out.println( lainaajat.get( new Kirja("Oliokirja", 2000) );
        System.out.println( lainaajat.get( new Kirja("Test Driven Development", 1999) );

Tulostuu:

Pekka
null
null

Löydämme lainaajan hakiessamme samalla oliolla, joka annettiin hajautustaulun put-metodille avaimeksi. Täsmälleen samanlaisella kirjalla mutta eri oliolla haettaessa lainaajaa ei kuitenkaan löydy ja saamme null-viitteen. Syynä on taas Object-luokassa oleva hashCode-metodin oletustoteutus. Oletustoteutus luo indeksin viitteen perusteella eli samansisältöiset mutta eri oliot saavat eri tuloksen hashCode-metodista ja tämän takia olioa ei osata etsiä oikeasta paikasta HashMapia.

Jotta HashMap toimisi haluamallamme tavalla, eli palauttaisi lainaajan kun avaimeksi annetaan oikean sisältöinen olio (ei välttämässä siis sama olio kuin alkuperäinen avain), on avaimena toimivan luokan ylikirjoitettava metodin equals lisäksi metodi hashCode. Metodi on ylikirjoitettava siten, että se antaa saman numeerisen tuloksen kaikille samansisältöisille olioille. Myös jotkut erisisältöiset oliot saavat saada saman tuloksen hashCode-metodista, on kuitenkin HashMapin tehokkuuden kannalta oleellista, että erisisältöiset oliot saavat mahdollisimman harvoin saman tuloksen.

Olemme aiemmin käyttäneet String-olioita menestyksekkäästi HashMapin avaimena, joten voimme päätellä että String-luokassa on oma järkevästi toimiva 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 "satunnaisesti" valittu alkuluku, yhtä hyvin olisi voitu valita esimerkiksi arvo 13.

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

        return this.nimi.hashCode();
    }

Parantelemme vielä hashCode-metodia siten, että koodin laskennassa huomioidaan myös kirjan julkaisuvuosi:

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

        return this.julkaisuvuosi + this.nimi.hashCode();
    }

Sivuhuomautus: HashMapissa avaimina olevien olioiden hashCode-metodin tulos kertoo hajautusrakenteeseen talletettavien arvon paikan eli indeksin hajautustaulussa. 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 hajautustaulun avaimina toimivina olioilla olla metodi equals toteutettuna. Lisätietoa hajautustaulujen toteuttamisen periaatteista tulee 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;
    }

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

        return this.julkaisuvuosi + this.nimi.hashCode();
    }
}

Kerrataan vielä: jotta luokkaa voidaan käyttää HashMap:in avaimena, tulee sille määritellä

Luokalle Kirja määrittelemämme equals ja hashCode selvästi täyttävät nämä ehdot. Nyt myös aiemmin kohtaamamme ongelma ratkeaa ja kirjojen lainaajat löytyvät:

        HashMap<Kirja, String> lainaajat = new HashMap<Kirja, String>();
       
        Kirja oliokirja = new Kirja("Oliokirja", 2000);
        lainaajat.put( oliokirja, "Pekka" );
        lainaajat.put( new Kirja("Test Driven Development",1999), "Arto" );
        
        System.out.println( lainaajat.get( oliokirja ) );
        System.out.println( lainaajat.get( new Kirja("Oliokirja", 2000) );
        System.out.println( lainaajat.get( new Kirja("Test Driven Development", 1999) );

Tulostuu:

Pekka
Pekka
Arto

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.

Autorekisterikeskus

Rekisterinumeron equals ja hashCode

Eurooppalaiset rekisteritunnukset koostuvat kahdesta osasta: yksi tai kaksikirjaimisesta maatunnuksesta ja maakohtaisesti määrittyvästä rekisterinumerosta, joka taas koostuu numeroista ja merkeistä. Rekisterinumeroita esitetään seuraavanlaisen luokan avulla:

public class Rekisterinumero {
    // HUOM: oliomuuttujissa on määre final eli niiden arvoa ei voi muuttaa!
    private final String rekNro;
    private final String maa;

    public Rekisterinumero(String rekNro, String maa) {
       this.rekNro = rekNro;
       this.maa = maa;
    }

    public String toString(){
        return maa+ " "+rekNro;
    }
}

Rekisterinumeroja halutaan tallettaa esim. ArrayList:eille ja käyttää HashMap:in avaimina, eli kuten yllä mainittu, tulee niille toteuttaa metodit equals ja hashCode, muuten ne eivät toimi halutulla tavalla.

Vihje: ota equals- ja hashCode-metodeihin mallia yllä olevasta Kirja-esimerkistä. Rekisterinumeron hashCode voidaan muodostaa esim. maatunnuksen ja rekNro:n hashCodejen summana.

Esimerkkiohjelma:

    public static void main(String[] args) {
        Rekisterinumero rek1 = new Rekisterinumero("FI", "ABC-123");
        Rekisterinumero rek2 = new Rekisterinumero("FI", "UXE-465");
        Rekisterinumero rek3 = new Rekisterinumero("D", "B WQ-431");

        ArrayList<Rekisterinumero> suomalaiset = new ArrayList<Rekisterinumero>();
        suomalaiset.add(rek1);
        suomalaiset.add(rek2);

        Rekisterinumero uusi = new Rekisterinumero("FI", "ABC-123");
        if (!suomalaiset.contains(uusi)) {
            suomalaiset.add(uusi);
        }
        System.out.println("suomalaiset: " + suomalaiset);
        // jos equals-metodia ei ole ylikirjoitettu, menee sama rekisterinumero toistamiseen listalle

        HashMap<Rekisterinumero, String> omistajat = new HashMap<Rekisterinumero, String>();
        omistajat.put(rek1, "Arto");
        omistajat.put(rek3, "Jürgen");

        System.out.println("omistajat:");
        System.out.println(omistajat.get(new Rekisterinumero("FI", "ABC-123")));
        System.out.println(omistajat.get(new Rekisterinumero("D", "B WQ-431")));
        // jos hashCode ei ole ylikirjoitettu, eivät omistajat löydy
    }

Jos equals ja hashCode on toteutettu oikein, tulostus on seuraavanlainen

suomalaiset: [FI ABC-123, FI UXE-465]   
omistajat:
Arto
Jürgen

Omistaja rekisterinumeron perusteella

Toteuta luokka Ajoneuvorekisteri jolla on seuraavat metodit:

  • public boolean lisaa(Rekisterinumero rekkari, String omistaja) lisää parametrina olevaa rekisterinumeroa vastaavalle autolle parametrina olevan omistajan, metodi palauttaa true jos omistajaa ei ollut ennestään, jos rekisterinumeroa vastaavalla autolla oli jo omistaja, metodi palauttaa false ja ei tee mitään
  • public String hae(Rekisterinumero rekkari) palauttaa parametrina olevaa rekisterinumeroa vastaavan auton omistajan. Jos auto ei ole rekisterissä, palautetaan null
  • public boolean poista(Rekisterinumero rekkari) poistaa parametrina olevaa rekisterinumeroa vastaavat tiedot, metodi palauttaa true jos tiedot poistetiin, ja false jos parametria vastaavia tietoja ei ollut rekisterissä

Huom: Ajoneuvorekisterin täytyy tallettaa omistajatiedot HashMap<Rekisterinumero, String> omistajat -tyyppiseen oliomuuttujaan!

Ajoneuvorekisteri laajenee

Lisää Ajoneuvorekisteriin vielä seuraavat metodit:

  • public void tulostaRekisterinumerot() tulostaa rekisterissä olevat rekisterinumerot
  • public void tulostaOmistajat() tulostaa rekisterissä olevien autojen omistajat, yhden omistajan nimeä ei saa tulostaa kuin kertaalleen vaikka omistajalla olisikin useampi auto

Rajapinta

Rajapinta (engl. interface) on väline luokilta vaaditun käyttäytymisen määrittelyyn. Rajapinnat määritellään kuten normaalit Javan olevat luokat, mutta luokan alussa olevan määrittelyn "public class ..." sijaan käytetään määrittelyä "public interface ...". Rajapinnat 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 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:

  • metodi int getTJ() palauttaa jäljellä olevien palveluspäivien määrän
  • metodi void palvele() vähentää yhden palveluspäivän. Palveluspäivien määrä ei saa mennä negatiiviseksi.
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 viitetyyppiset muuttujat (kaikki oliot). Olemme tähän mennessä käyttäneet viitetyyppisten 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 merkkijonoon.

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() {
        String luettu = "";
        for(Luettava luettava: this.luettavat) {
            luettu += luettava.lue() + "\n";
        }

        this.luettavat.clear();
        return luettu;
    }
}
    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.7) );

        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ä 4.0 kiloa

Huom: koska painot esitetään doubleina, saattaa laskutoimituksissa tulla pieniä pyöristysvirheitä. Tehtävässä ei tarvitse välittää niistä.

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. Seuraavassa Tehdas, jota voi pyytää valmistamaan erilaisia Talletettava-rajapinnan toteuttavia oliota. Tehdas valmistaa aluksi satunnaisesti kirjoja ja levyjä.

   public class Tehdas {
      public Tehdas(){
          // HUOM: parametritonta tyhjää konstruktoria ei ole pakko kirjoittaa, jos luokalla ei ole muita konstruktoreja
          //       Java tekee automaattisesti tälläisissä tilanteissa luokalle oletuskonstruktorin eli parametrittoman tyhjän konstruktorin   
      }

       public Talletettava valmistaUusi(){
           Random arpa = new Random();
           int luku = arpa.nextInt(4);
           if ( luku==0 ) {
               return new CDLevy("Pink Floyd", "Dark Side of the Moon", 1973);
           } else if ( luku==1 ) {
               return new CDLevy("Wigwam", "Nuclear Nightclub", 1975);
           } else if ( luku==2 ) {
               return new Kirja("Robert Martin", "Clean Code", 1 );
           } else {
               return new Kirja("Kent Beck", "Test Driven Development", 0.7);
           }      
       }
   }

Tehdasta on mahdollista käyttää tuntematta tarkalleen mitä erityyppisiä Talletettava-rajapinnan luokkia on olemassa. Seuraavassa luokka Pakkaaja, jolta voi pyytää laatikollisen esineitä. Pakkaaja tuntee tehtaan, jota se pyytää luomaan esineet:

   public class Pakkaaja {
       private Tehdas tehdas; 

       public Pakkaaja(){
            tehdas = new Tehdas();
       }

       public Laatikko annaLaatikollinen() {
            Laatikko laatikko = new Laatikko(100);
             
            for ( int i=0; i < 10; i++ ) {
                Talletettava uusiTavara = tehdas.valmistaUusi();
                laatikko.lisaa(uusiTavara);
            }
  
            return laatikko;
       }
   }

Koska pakkaaja ei tunne rajapinnan Talletettava toteuttavia luokkia, on ohjelmaan mahdollisuus lisätä uusia luokkia jotka toteuttavat rajapinnan ilman tarvetta muuttaa pakkaajaa. Seuraavassa on luotu uusi Talletettava-rajapinnan toteuttava luokka, Suklaalevy. Tehdasta on muutettu siten, että se luo kirjojen ja cd-levyjen lisäksi suklaalevyjä. Luokka Pakkaaja toimii muuttamatta tehtaan laajennetun version kanssa.

   public class Suklaalevy implements Talletettava {
      // koska Javan generoima oletuskonstruktori riittää, emme tarvitse konstruktoria! 

      public double paino(){
         return 0.2;
      }
   }

   public class Tehdas {
       // koska Javan generoima oletuskonstruktori riittää, emme tarvitse konstruktoria!

       public Talletettava valmistaUusi(){
           Random arpa = new Random();
           int luku = arpa.nextInt(5);
           if ( luku==0 ) {
               return new CDLevy("Pink Floyd", "Dark Side of the Moon", 1973);
           } else if ( luku==1 ) {
               return new CDLevy("Wigwam", "Nuclear Nightclub", 1975);
           } else if ( luku==2 ) {
               return new Kirja("Robert Martin", "Clean Code", 1 );
           } else if ( luku==3 ) {
               return new Kirja("Kent Beck", "Test Driven Development", 0.7);
           } else {
               return new Suklaalevy();
           }     
       }
   }

Rajapintojen käyttö ohjelmoinnissa mahdollistaa luokkien välisten riippuvaisuuksien vähentämisen. Esimerkissämme Pakkaaja ei ole riippuvainen rajapinnan Talletettava-toteuttavista luokista vaan ainoastaan rajapinnasta. Tämä mahdollistaa rajapinnan toteuttavien luokkien lisäämisen ohjelmaan ilman tarvetta muuttaa luokkaa Pakkaaja. Myöskään pakkaajan käyttäjiin rajapinnan toteuttavien luokkien lisääminen ei vaikuta. Vähäisemmät riippuvuudet siis helpottavat ohjelman laajennettavuutta.

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 List toteutukset 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 sisäisestä toteutuksesta 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:

  • public void lisaaTuote(String tuote, int hinta, int saldo) lisää varastoon tuotteen jonka hinta ja varastosaldo ovat parametrina annetut luvut
  • public int hinta(String tuote) palauttaa parametrina olevan tuotteen hinnan, jos tuotetta ei ole varastossa, palauttaa metodi -99

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:

  • public int saldo(String tuote) palauttaa parametrina olevan tuotteen varastosaldon.
  • public boolean ota(String tuote) vähentää parametrina olevan tuotteen saldoa yhdellä ja palauuttaa true jos tuotetta oli varastossa. Jos tuotetta ei ole varastossa, palauttaa metodi false, tuotteen saldo ei saa laskea alle nollan.

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:

  • public Set<String> tuotteet() palauttaa joukkona varastossa olevien tuotteiden nimet

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:

  • public Ostos(String tuote, int kpl, int yksikkohinta) konstruktori joka luo ostoksen joka vastaa parametrina annettua tuotetta. Tuotteita ostoksessa on kpl kappaletta ja yhden tuotteen hinta on kolmantena parametrina annettu yksikkohinta
  • public int hinta() palauttaa ostoksen hinnan. Hinta saadaan kertomalla kappalemäärä yksikköhinnalla
  • public void kasvataMaaraa() kasvattaa ostoksen kappalemäärää yhdellä
  • public String toString() palauttaa ostoksen merkkijonomuodossa, joka on alla olevan esimerkin mukainen

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 oliomuuttuja 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:

  • public void lisaa(String tuote, int hinta) lisää ostoskoriin ostoksen joka vastaa parametrina olevaa tuotetta ja jolla on parametrina annettu hinta.
  • public int hinta() palauttaa ostoskorin kokonaishinnan

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 alla oleva 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 asioi(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 HashMap<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 ojennukseen

Tehtäväpohjan mukana on luokka, jonka oliot kuvaavat pelikortteja. Kortilla on arvo ja maa. Kortin arvo on 2, 3, ..., 10, J, Q, K tai A ja maa Risti, Ruutu, Hertta tai Pata. Arvo ja maa kuitenkin esitetään olioiden sisällä kokonaislukuina. Kortilla on myös metodi toString, jota käyttäen kortin arvo ja maa tulostuvat "ihmisystävällisesti".

Jotta korttien käyttäjän ei tarvitsisi käsitellä korttien maita numeroina, on luokkaan määritelty neljä vakioa eli public static final -muuttujaa:

public class Kortti {
    public static final int RISTI  = 0;
    public static final int RUUTU  = 1;
    public static final int HERTTA = 2;
    public static final int PATA   = 3;

    // ...
}

Nyt ohjelmassa voidaan luvun 1 sijaan käyttää vakioa Kortti.RUUTU. Seuraavassa esimerkissä luodaan kolme korttia ja tulostetaan ne:

  Kortti eka = new Kortti(2, Kortti.RUUTU);
  Kortti toka = new Kortti(14, Kortti.PATA);  
  Kortti kolmas = new Kortti(12, Kortti.HERTTA);

  System.out.println(eka);
  System.out.println(toka);
  System.out.println(kolmas);

Tulostuu:

Ruutu 2
Pata A
Hertta Q

HUOM: vaikioiden käyttö ylläolevaan tapaan ei ole tyylikkäin tapa asian hoitamiseen. Myöhemmin kurssilla opimme oikeaoppisen tyylin maan esittämiseen!

Kortti-luokasta Comparable

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.

Järjestyksessä pienin kortti siis olisi risti kakkonen ja suurin pataässä.

Käsi

Tehdään seuraavaksi luokka Kasi joka edustaa pelaajan kädessään pitämää korttien joukkoa. Tee kädelle seuraavat metodit:

  • public void lisaa(Kortti kortti) lisää käteen kortin
  • public void tulosta() tulostaa kädessä olevat kortit alla olevan esimerkin tyylillä
  Kasi kasi = new Kasi();

  kasi.lisaa( new Kortti(2, Kortti.RUUTU) );
  kasi.lisaa( new Kortti(14, Kortti.PATA) );
  kasi.lisaa( new Kortti(12, Kortti.HERTTA) );
  kasi.lisaa( new Kortti(2, Kortti.PATA) );

  kasi.tulosta();

Tulostuu:

Ruutu 2
Pata A
Hertta Q
Pata 2

Talleta käden sisällä olevat kortit ArrayListiin.

Käden järjestäminen

Tee kädelle metodi public void jarjesta() jota kutsumalla käden sisällä olevat kortit menevät suuruusjärjestykseen. Järjestämisen jälkeen kortit tulostuvat järjestyksessä:

  Kasi kasi = new Kasi();

  kasi.lisaa( new Kortti(2, Kortti.RUUTU) );
  kasi.lisaa( new Kortti(14, Kortti.PATA) );
  kasi.lisaa( new Kortti(12, Kortti.HERTTA) );
  kasi.lisaa( new Kortti(2, Kortti.PATA) );

  kasi.jarjesta(); 

  kasi.tulosta();

Tulostuu:

Ruutu 2
Pata 2
Hertta Q
Pata A

Käsien vertailu

Eräässä korttipelissä kahdesta korttikädestä arvokkaampi on se, jonka sisältämien korttien arvon summa on suurempi. Tee luokasta Kasi vertailtava tämän kriteerin mukaan, eli laita luokka toteuttamaan rajapinta Comparable<Kasi>.

Esimerkkiohjelma, jossa vertaillaan käsiä:

  Kasi kasi1 = new Kasi();

  kasi1.lisaa( new Kortti(2, Kortti.RUUTU) );
  kasi1.lisaa( new Kortti(14, Kortti.PATA) );
  kasi1.lisaa( new Kortti(12, Kortti.HERTTA) );
  kasi1.lisaa( new Kortti(2, Kortti.PATA) );

  Kasi kasi2 = new Kasi();

  kasi2.lisaa( new Kortti(11, Kortti.RUUTU) );
  kasi2.lisaa( new Kortti(11, Kortti.PATA) );
  kasi2.lisaa( new Kortti(11, Kortti.HERTTA) );

  int vertailu = kasi1.compareTo(kasi2);

  if ( vertailu < 0 ) {
    System.out.println("arvokkaampi käsi sisältää kortit");
    kasi2.tulosta();
  } else if ( vertailu > 0 ){
    System.out.println("arvokkaampi käsi sisältää kortit");
    kasi1.tulosta(); 
  } else {
    System.out.println("kädet yhtä arvokkaat");
  }

Tulostuu

arvokkaampi käsi sisältää kortit
Ruutu J
Pata J
Hertta J

Korttien järjestäminen eri kriteerein

Entä jos haluaisimme välillä järjestää kortit hieman eri tavalla, esim. kaikki saman maan kortit peräkkäin? Luokalla voi olla vain yksi compareTo-metodi, joten joudumme muunlaisia järjestyksiä saadaksemme turvautumaan muihin keinoihin.

Vaihtoehtoiset järjestämistavat toteutetaan erillisten vertailun suorittavien luokkien avulla. Korttien vaihtoehtoisten järjestyksen määräävän luokkien tulee toteuttaa Comparator<Kortti>-rajapinta. Järjestyksen määräävän luokan olio vertailee kahta parametrina saamaansa korttia. Metodeja on ainoastaan yksi compare(Kortti k1, Kortti k2), jonka tulee palauttaa negatiivinen arvo, jos kortti k1 on järjestyksessä ennen korttia k2, positiivinen arvo jos k2 on järjestyksessä ennen k1:stä ja 0 muuten.

Periaatteena on luoda jokaista järjestämistapaa varten oma vertailuluokka, esim. saman maan kortit vierekkäin vievän järjestyksen määrittelevä luokka:

import java.util.Comparator;

public class SamatMaatVierekkain implements Comparator<Kortti> {
    public int compare(Kortti k1, Kortti k2) {
        return k1.getMaa()-k2.getMaa();
    }
}

Maittainen järjestys on sama kuin kortin metodin compareTo maille määrittelemä järjestys eli ristit ensin, ruudut toiseksi, hertat kolmanneksi, padat viimeiseksi.

Järjestäminen tapahtuu edelleen luokan Collections metodin sort avulla. Metodi saa nyt toiseksi parametrikseen järjestyksen määräävän luokan olion:

  ArrayList<Kortti> kortit = new ArrayList<Kortti>(); 

  kortit.add( new Kortti(3, Kortti.PATA) );
  kortit.add( new Kortti(2, Kortti.RUUTU) );
  kortit.add( new Kortti(14, Kortti.PATA) );
  kortit.add( new Kortti(12, Kortti.HERTTA) );
  kortit.add( new Kortti(2, Kortti.PATA) );

  SamatMaatVierekkain samatMaatVierekkainJarjestaja = new SamatMaatVierekkain();
  Collections.sort(kortit, samatMaatVierekkainJarjestaja ); 
  
  for (Kortti k : kortit) {
    System.out.println( k );
  }

Tulostuu:

Ruutu 2
Hertta Q
Pata 3
Pata A
Pata 2

Järjestyksen määrittelevä olio voidaan myös luoda suoraan sort-kutsun yhteydessä:

  Collections.sort(kortit, new SamatMaatVierekkain() ); 

Tarkempia ohjeita vertailuluokkien tekemiseen täällä

Tee nyt luokka Comparator-rajapinnan toteuttava luokka SamatMaatVierekkainArvojarjestykseen jonka avulla saat kortit muuten samanlaiseen järjestykseen kuin edellisessä esimerkissä paitsi, että saman maan kortit järjestyvät arvon mukaiseesti.

Käden järjestäminen maittain

Lisää luokalle Kasi metodi public void jarjestaMaittain() jota kutsumalla käden sisällä olevat kortit menevät edellisen tehtävän vertailijan määrittelemään järjestykseen. Järjestämisen jälkeen kortit tulostuvat järjestyksessä:

  Kasi kasi = new Kasi();

  kasi.lisaa( new Kortti(12, Kortti.HERTTA) );
  kasi.lisaa( new Kortti(4, Kortti.PATA) );
  kasi.lisaa( new Kortti(2, Kortti.RUUTU) );
  kasi.lisaa( new Kortti(14, Kortti.PATA) );
  kasi.lisaa( new Kortti(7, Kortti.HERTTA) );
  kasi.lisaa( new Kortti(2, Kortti.PATA) );

  kasi.jarjestaMaittain(); 

  kasi.tulosta();

Tulostuu:

Ruutu 2
Hertta 7
Hertta Q
Pata 2
Pata 4
Pata A

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

Mäkihyppy

Harjoitellaan taas ohjelman rakenteen omatoimista suunnittelua. Käyttöliittymän ulkomuoto ja vaadittu toiminnallisuus on määritelty ennalta, rakenteen saat toteuttaa vapaasti.

Huom: jotta testit toimisivat, ohjelmasi saa luoda vain yhden Scanner-olion. Älä myöskään käytä staattisia muuttujia, testit suorittavat ohjelman useita kertoja joten staattisiin muuttujiin edellisillä suorituskerroilla jääneet arvot todennäköisesti häiritsevät testien toimintaa!

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

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

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

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

Screencast jossa tehdään viikon 1 ja 2 ydinasioita hyödyntävä hieman isompi sovellus:

Viikko3

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ä puuttuvat 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 tulee 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 nimeäminen helpottaa toiminnallisuuksien muistamista. Järkevän nimennän lisäksi lähdekooditiedostot kannattaa jakaa toiminnallisuutta, käyttötarkoitusta tai jotain muuta loogista kokonaisuutta 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ä-rajapinta

Luo projektipohjaan pakkaus mooc. 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 mooc.ui), ja lisää sinne rajapinta Kayttoliittyma.

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

Tekstikäyttöliittymä

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

Sovelluslogiikka

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

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

Voit testata sovelluksen toimintaa seuraavalla pääohjelmaluokalla.

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

public class Main {

    public static void main(String[] args) {
        Kayttoliittyma kayttoliittyma = new Tekstikayttoliittyma();
        new Sovelluslogiikka(kayttoliittyma).suorita(3);
    }
}

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ähdekoodihakemiston 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 ja rajapintojen tarjoamasta joustavuudesta

Viime viikolla tutustuimme rajapintoihin. Rajapinta siis määrittelee 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();
    }

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

Rajapintojen vahvuus on se, että rajapinta on myös tyyppi. Kaikki rajapinnan toteuttavista luokista tehdyt oliot ovat myös rajapinnan tyyppisiä. Tämä helpottaa sovellusten rakentamista huomattavasti.

Tehdään luokka Rekisteri, josta voimme hakea henkilöitä tunnisteen perusteella. Yksittäisten henkilöiden hakemisen lisäksi Rekisteri tarjoaa metodin kaikkien henkilöiden hakemiseen listana.

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

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

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

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

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

Rekisterin käyttö on helppoa

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

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

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

Koska henkilöt on talletettu rekisteriin Tunnistettava-tyyppisinä, joudumme muuntanaan ne takaisin oikeaan tyyppiin jos haluamme käsitellä henkilöitä sellaisten metodien kautta, joita ei rajapinnassa ole määritelty. Näin tapahtuu yllä olevan esimerkin kahdella viimeisellä rivillä.

Entä jos haluaisimme operaation, joka palauttaa rekisteriin talletetut henkilöt tunnisteen mukaan järjestettynä?

Yksi luokka voi toteuttaa useamman rajapinnan, eli voimme toteuttaa Henkilo-luokalla rajapinnan Tunnistettava lisäksi viime viikolta tutun rajapinnan Comparable. 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.getTunniste().compareTo(toinen.getTunniste());
    }
}

Nyt voimme lisätä rekisterille metodin haeKaikkiJarjestyksessa:

    public List<Tunnistettava> haeKaikkiJarjestyksessa() {
        ArrayList<Tunnistettava> kaikki = new ArrayList<Tunnistettava>(rekisteroidyt.values());;
        Collections.sort(kaikki);
        return kaikki;
    }

Huomaamme kuitenkin, että ratkaisumme ei toimi. Koska henkilöt on talletettu rekisteriin Tunnitettava-tyyppisinä, onkin Henkilön toteutettava rajapinta Comparable<Tunnistettava> jotta rekisteri osaisi järjestää henkilöt metodin Collections.sort() avulla. Eli muutamme henkilön toteuttamaa rajapintaa:

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

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

Nyt ratkaisu toimii!

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

public class Tuote implements Tunnistettava {
    private String nimi;
    private String viivakoodi;
    private int varastosaldo;
    private int hinta;

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

    public String getTunniste() {
        return viivakoodi;
    }

    // ...
}

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

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

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

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

Muutama NetBeans-vihje
  • Kaikki NetBeans-vihjeet löytyvät täältä
  • Implement all abstract methods

    Oletetaan että ohjelmassasi on rajapinta Rajapinta, ja olet tekemässä rajapinnan toteuttavaa luokkaa Luokka. Joudut näkemään hieman vaivaa kirjoittaessasi toteuttavaan luokkaan rajapinnan määrittelemien metodien esittelyrivit.

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

    public class Luokka implements Rajapinta {
    }
    

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

  • Clean and Build

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

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.

Tavara ja Esine

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

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

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

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

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

Esine vertailtavaksi

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

    List<Esine> esineet = new ArrayList<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)]

Muuttolaatikko

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

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

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

Esineiden pakkaaminen

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

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

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

Seuraavassa pakkaajan toimintaa demonstroiva esimerkki:

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

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

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

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

    for (Muuttolaatikko laatikko : laatikot) {
        System.out.println("  laatikossa tavaraa: "+laatikko.getTilavuus()+" dm^3");
    }

Tulostuu:

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

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

Tulostuu:

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

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

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.

Ohjelmoinnin perusteiden tehtävän kellosta olio bonusversiossa kerrottiin, että ohjelma saadaan viivyttämään itseään sekunnin verran kutsumalla komentoa Thread.sleep(1000). Komento saattaa heittää poikkeuksen, joka on pakko käsitellä. Poikkeuksen käsittely siis tapahtuu 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
    }

Metodeissa on myös mahdollista jättää poikkeus itse käsittelemättä ja siirtää 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 try-catchia ei tarvita!
    }

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. Joskus poikkeuksen käsittelyä pakoillaan viimeiseen asti, ja main-metodikin heittää poikkeuksen käsiteltäväksi eteenpäin:

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

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

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

Sensorit ja lämpötilan mittausta

Kaikki sovelluksessa oleva koodi tulee sijoittaa pakkaukseen sovellus.

Käytössämme on seuraava rajapinta:

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

Vakiosensori

Tee luokka Vakiosensori joka toteuttaa rajapinnan Sensori.

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

Esimerkki:

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

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

  System.out.println( kymppi.onPaalla() );
  kymppi.poisPaalta();
  System.out.println( kymppi.onPaalla() );
}

Tulostuu:

10
-5
true
true

Lampomittari

Tee luokka Lampomittari joka toteuttaa rajapinnan Sensori.

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

Keskiarvosensori

Tee luokka Keskiarvosensori joka toteuttaa rajapinnan Sensori.

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

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

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

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

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

tulostuu (tulostetut lukuarvot riippuvat tietenkin arvotuista lämpötiloista):

lämpötila Kumpulassa -7 astetta
lämpötila Pääkaupunkiseudulla -10 astetta

Huom: kannatata käyttää Vakiosensori-oliota keskiarvosensorin testaamiseen!

Kaikki mittaukset

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

public static void main(String[] args) {
    Sensori kumpula = new Lampomittari();         
    Sensori kaisaniemi = new Lampomittari();        
    Sensori helsinkiVantaa = new Lampomittari();
        
    Keskiarvosensori paakaupunki = new Keskiarvosensori();
    paakaupunki.lisaaSensori(kumpula);
    paakaupunki.lisaaSensori(kaisaniemi);
    paakaupunki.lisaaSensori(helsinkiVantaa);
        
    paakaupunki.paalle();
    System.out.println("lämpötila Pääkaupunkiseudulla "+paakaupunki.mittaa() + " astetta");     
    System.out.println("lämpötila Pääkaupunkiseudulla "+paakaupunki.mittaa() + " astetta");    
    System.out.println("lämpötila Pääkaupunkiseudulla "+paakaupunki.mittaa() + " astetta");    

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

tulostuu (tulostetut lukuarvot riippuvat jälleen arvotuista lämpötiloista):

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

mittaukset: [-10, -4, 5]

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 alla olevasta 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 printStackTrace() 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ä.

Tiedoston lukeminen

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");

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 Lopuksi tiedosto suljetaan komennolla close.

        // 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 lisätä jokaisen rivin jälkeen rivinvaihtomerkki:

    public String lueTiedostoMerkkijonoon(File tiedosto) throws Exception {
        // tiedosto mistä luetaan
        Scanner lukija = new Scanner(tiedosto);

        String merkkijono = "";

        while (lukija.hasNextLine()) {
            String rivi = lukija.nextLine();
            merkkijono += rivi;
            merkkijono += "\n";
        }

        lukija.close();
        return merkkijono;
    }

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öä.

Rivit joilla sana

Tee luokka Tulostaja ja sille konstruktori public Tulostaja(String tiedostonNimi), joka saa parametrinaan tiedoston nimeä vastaavan merkkijonon sekä metodi public void tulostaRivitJoilla(String sana) tulostaa tiedostosta ne rivit, joilla esiintyy parametrina oleva sana (pienet ja isot kirjaimet erotellaan tehtävässä, eli esim. "koe" ja "Koe" eivät ole sama sana), rivit tulostetaan samassa järjestyksessä missä ne ovat tiedostossa.

Jos parametri on tyhjä merkkijono, tulostuu koko tiedosto.

Jos tiedostoa ei ole olemassa, heittää konstruktori aiheutuvan poikkeuksen eteenpäin, eli try-catch-komentoa ei tarvita, riittää määritellä konstruktori seuraavasti:

public Tulostaja {

   public Tulostaja(String tiedostonNimi) throws Exception {
      // ...
   }

   // ...
}

Projektisi default-pakkauksessa on testausta varten tiedosto testitiedosto.txt. Ohjelmasta avatessa tiedoston nimeksi tulee antaa src/testitiedosto.txt. Tiedoston sisältö on seuraava:

Siinä vanha Väinämöinen
katseleikse käänteleikse 
Niin tuli kevätkäkönen
näki koivun kasvavaksi 
Miksipä on tuo jätetty
koivahainen kaatamatta  
Sanoi vanha Väinämöinen 

Seuraavassa esimerkki ohjelman toiminnasta testitiedostolla:

    Tulostaja tulostaja = new Tulostaja("src/testitiedosto.txt");

    tulostaja.tulostaRivitJoilla("Väinämöinen");
    System.out.println("-----");
    tulostaja.tulostaRivitJoilla("Frank Zappa");
    System.out.println("-----");
    tulostaja.tulostaRivitJoilla("");
    System.out.println("-----");

Tulostuu:

Siinä vanha Väinämöinen
Sanoi vanha Väinämöinen 
-----
-----
Siinä vanha Väinämöinen
katseleikse käänteleikse 
Niin tuli kevätkäkönen
näki koivun kasvavaksi 
Miksipä on tuo jätetty
koivahainen kaatamatta  
Sanoi vanha Väinämöinen 

Projektipohjasta löytyy myös koko Kalevala, tiedoston nimi on src/kalevala.txt

Tiedoston analyysi

Tässä tehtävässä tehdään sovellus tiedoston rivi- ja merkkimäärän laskemiseen.

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.

Metodi ei saa olla "kertakäyttöinen", eli sen pitää tuottaa oikea tulos myös usealla peräkkäisellä kutsulla. Huomaa, että kun teet tiedostoa vastaavan Scanner-olion, ja luet tiedoston koko sisällön nextLine-komennoilla, et voi käyttää enää samaa skanneria tiedoston uudelleenlukemiseen!

Huom: jos testit sanovat timeout, et todennäköisesti muista lukea tiedostoa ollenkaan, eli nextLine-kutsut puuttuvat!

Merkkien laskeminen

Toteuta luokkaan Analyysi metodi public int merkkeja(), joka palauttaa luokan konstruktorille annetun tiedoston merkkien määrän.

Metodi ei saa olla "kertakäyttöinen", eli sen pitää tuottaa oikea tulos myös usealla peräkkäisellä kutsulla.

Voit itse päättää miten reagoidaan jos konstruktorin parametrina saatua tiedostoa ei ole olemassa.

Projektisi testipakkauksessa on testausta varten tiedosto testitiedosto.txt. Ohjelmasta avatessa tiedoston nimeksi tulee antaa test/testitiedosto.txt. Tiedoston sisältö on seuraava:

rivejä tässä on 3 ja merkkejä 
koska rivinvaihdotkin ovat
merkkejä

Ohjelman toiminta testaustiedostolla:

    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". Koska sanalista on varsin pitkä, on projektissa testausta varten myös pienilista.txt joka löytyy polusta "src/pienilista.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 tässä tehtävässä, 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. Viikolle 612 on tulossa oikeaoppinen tapa copypasten eliminointiin.

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.

Hajautustauluista ja joukoista

Monta arvoa yhtä avainta kohti

Kuten muistamme, voi HashMapiin tallettaa tiettyä avainta kohti vaan yhden arvon. Seuraavassa esimerkissä tallennamme henkilöiden puhelinnumeroita HashMap:iin.

  Map<String, String> puhelinnumerot = new HashMap<String, String>();

  puhelinnumerot.put("Pekka", "040-12348765");

  System.out.println( "Pekan numero: "+ puhelinnumerot.get("Pekka") );

  puhelinnumerot.put("Pekka", "09-111333");

  System.out.println( "Pekan numero: "+ puhelinnumerot.get("Pekka") );

Kuten odotettua, tulostus kertoo, että

Pekan numero: 040-12348765
Pekan numero: 09-111333

Entä jos haluaisimmekin tallettaa yhtä avainta kohti useita arvoja, eli esim yhtä henkilöä kohti monta puhelinnumeroa? Onnistuuko se HashMap:in avulla? Kyllä, esim. tallettamalla HashMap:iin Stringien sijaan esim. ArrayList:eja arvoiksi, voidaan yhteen avaimeen "liittää" useampia oliota. Muutetaan puhelinnumeroiden talletustapaa seuraavasti:

  Map<String, ArrayList<String>> puhelinnumerot = new HashMap<String, ArrayList<String>>();

Nyt siis HashMapissa jokaiseen avaimeen littyy lista. Vaikka new-komento luokin HashMapin, on mapin sisälle talletettavat listat luotava erikseen. Seuraavassa lisätään HashMapiin Pekalle kaksi numeroa ja tulostetaan ne:

  Map<String, ArrayList<String>> puhelinnumerot = new HashMap<String, ArrayList<String>>();

  // liitetään Pekka-nimeen ensin tyhjä ArrayList
  puhelinnumerot.put( "Pekka", new  ArrayList<String>() );
  
  // ja lisätään Pekkaa vastaavalle listalle puhelinnumero
  puhelinnumerot.get("Pekka").add("040-12348765");
 
  // ja lisätään toinenkin puhelinnumero
  puhelinnumerot.get("Pekka").add("09-111333");

  System.out.println( "Pekan numerot: "+ puhelinnumerot.get("Pekka") );

Tulostuu

Pekan numero: [040-12348765, 09-111333]

Määrittelimme muuttujan puhelinnumero tyypiksi Map<String, ArrayList<String>> eli Map jonka avaimena on merkkijono ja arvona merkkijonoja sisältävä lista. Konkreettinen toteutus, eli luotu olio oli HasMap. Olisimme voineet määritellä muuttujan myös seuraavasti:

Map<String, List<String>> puhelinnumero = new HashMap<String, List<String>>();

Eli nyt muuttujan tyyppi on Map, jonka avaimena on merkkijono ja arvona merkkijonoja sisältävä List, joka siis on rajapinta joka määrittelee listatoiminnallisuuden, esim. ArrayList toteuttaa tämän rajapinnan. Konkreettinen olio on HashMap.

HashMap:iin talletettavat arvot siis ovat List<String>-rajapinnan toteuttavia konkreettisia olioita, esim. ArrayListeja. Eli lisäys HashMapiin tapahtuu edelleen seuraavasti:

  // liitetään Pekka-nimeen ensin tyhjä ArrayList
  puhelinnumerot.put( "Pekka", new  ArrayList<String>() );

  // ...

Jatkossa pyrkimyksemme on käyttää tyyppimäärittelyissä konkreettisten luokkien, esim. HashMap ja ArrayList sijaan niitä vastaavia rajapintoja Map ja List.

Joukoista

Rajapinta Set kuvaa joukon toiminnallisuutta. Toisin kuin listalla, on joukossa kutakin alkioita korkeintaan yksi kappale, eli yhtään samanlaista oliota ei ole kahdesti. Olioiden samankaltaisuuden tarkistaminen toteutetaan equals ja hashCode -metodeja käyttämällä.

Yksi rajapinnan Set toteuttava luokka on HashSet. 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) {
        // huomaa miten uudelle käyttäjälle on lisättävä HashMapiin ensin tyhjä tehtäväjoukko
        if (!this.tehdytTehtavat.containsKey(kayttaja)) {
            this.tehdytTehtavat.put(kayttaja, new HashSet<Integer>());
        }

        // haetaan ensin käyttäjän tehtävät sisältävä joukko ja tehdään siihen lisäys
        Set<Integer> tehdyt = this.tehdytTehtavat.get(kayttaja);
        tehdyt.add(tehtava);

        // edellinen olisi onnitunut myös ilman apumuuttujaa seuraavasti
        //  this.tehdytTehtavat.get(kayttaja).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 esimerkissä 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.

Sanakirja usealle käännökselle

Tehdään hieman laajennettu versio viikolla 17 tehdystä sanakirjasta. Tehtävänäsi on toteuttaa pakkaukseen sanakirja luokka OmaUseanKaannoksenSanakirja, joka voi tallettaa yhden tai useamman käännöksen jokaiselle sanalle. Luokan tulee toteuttaa tehtäväpohjassa annettu rajapinta UseanKaannoksenSanakirja, jossa on seuraavat toiminnot:

  • public void lisaa(String sana, String kaannos)
  • lisää käännöksen sanalle säilyttäen vanhat käännökset
  • public Set<String> kaanna(String sana)
  • palauttaa Set-olion, jossa on kaikki käännökset sanalle, tai null-viitteen, jos sanaa ei ole sanakirjassa
  • public void poista(String sana)
  • poistaa sanan ja sen kaikki käännökset sanakirjasta

Käännökset kannattanee tallentaa yllä olevan esimerkin Tehtavakirjanpito tapaan Map<String, Set<String>>-tyyppiseen oliomuuttujaan.

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);
}

Esimerkkiohjelma:

    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"));

Tulostuu:

[six, spruce]
null

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:

  • public void lisaa(String merkkijono)
  • tallettaa merkkijonon, jos se ei ole duplikaatti
  • public int getHavaittujenDuplikaattienMaara()
  • palauttaa tähän mennessä havaittujen duplikaattien määrän
  • public Set<String> getUniikitMerkkijonot()
  • palauttaa Set<String>-rajapinnan toteuttavan olion, jossa on kaikki uniikit lisätyt merkkijonot (ei siis duplikaatteja!). Jos merkkijonoja ei ole, palautetaan tyhjä joukko-olio.
  • public void tyhjenna()
  • poistaa talletetut merkkijonot ja nollaa havaittujen duplikaattien määrän

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: []

Yksi olio useammassa eri listassa, Map-rakenteessa tai joukossa

Kuten muistamme, oliomuuttujat ovat viitetyyppisiä, eli muuttuja ei tallenna olioa itseään vaan viitteen olioon. Vastaavasti jos olio laitetaan esim. ArrayListiin, ei listalle talleteta olioa itseään vaan viite olioon. Mikään ei estäkään tallentamasta samaan olioon viitettä esim. useaan listaan tai HashMapiin.

Tarkastellaan esimerkkinä kirjastoa joka tallettaa kirjat hashMapeihin sekä kirjailijan että kirjan isbn-numeron perusteella. Tämän lisäksi kirjasto pitää kaikkia lainassa olevia sekä hyllyssä olevia kirjoja omalla listallaan.

public class Kirja {
    private String ISBN;
    private String kirjailija;
    private String nimi;
    private int vuosi;
    // ...
}

public class Kirjasto {
    private Map< String, Kirja> kirjaIsbnNumeronPerusteella;
    private Map< String, List<String>> kirjatKirjailijanPerusteella;
    private List<Kirja> lainassaOlevatKirjat;
    private List<Kirja> hyllyssaOlevatKirjat;

    public void lisaaKirjaKokoelmaan(Kirja uusiKirja){
        kirjaIsbnNumeronPerusteella.put(uusiKirja.getIsbn(), uusiKirja);
        kirjatKirjailijanPerusteella.get(uusikirja.getKirjailija()).add(uusiKirja);
        hyllyssaOlevatKirjat.add(uusiKirja);
    }

    public Kirja haeKirjaIsbnNumeronPerusteella(String isbn){
        return kirjaIsbnNumeronPerusteella.get(isbn);
    }

    // ...
}

Jos olio on yhtäaikaa useassa kokoelmassa (listalla, joukossa tai map-rakenteessa), on kiinnitettävä erityistä huomiota, että kokoelmien tila on konsistentti. Jos esim. kirja päätetään poistaa, on se poistettava molemmista mapeista sekä lainassa/hyllyssä olevia kuvaavalta listalta.

Huomaathan että yliopistoon hakevien tulee tehdä koko tehtävä.

Numerotiedustelu

Huom: jotta testit toimisivat, ohjelmasi saa luoda vain yhden Scanner-olion. Älä myöskään käytä staattisia muuttujia, testit suorittavat ohjelman useita kertoja joten staattisiin muuttujiin edellisillä suorituskerroilla jääneet arvot todennäköisesti häiritsevät testien toimintaa!

Tehdään sovellus jonka avulla on mahdollista hallinnoida ihmisten puhelinnumeroita ja osoitteita.

Tehtävän voi suorittaa 1-5 pisteen laajuisena. Yhden pisteen laajuuteen on toteutettava seuraavat toiminnot:

  • 1 puhelinnumeron lisäys henkilölle
  • 2 henkilön puhelinnumeroiden haku

kahteen pisteeseen vaaditaan edellisten lisäksi

  • 3 numeroa vastaavan henkilön nimen haku

kolmeen pisteeseen vaaditaan edellisten lisäksi

  • 4 osoitteen lisäys henkilölle
  • 5 henkilön tietojen (osoite ja puhelinnumero) haku

neljään pisteeseen vaaditaan toiminto

  • 6 henkilön tietojen poisto

ja täysiin pisteeseen vaaditaan vielä

  • 7 hakusanalla filtteröity listaus (nimen mukaan aakkostettuna), hakusana voi esiintyä henkilön nimessä tai osoitteessa

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:

  • Testien kannalta on oleellista että käyttöliittymä toimii kuten yllä olevassa esimerkissä. Sovellus voi itse päättää kuinka epäkelvot syötteet käsitellään. Testit sisältävät vaan kelvollisia syötteitä.
  • Ohjelman tulee käynnistyä kun tehtäväpohjassa oleva main-metodi suoritetaan, tehtävässä saa luoda vain yhden Scanner-olion.
  • Älä käytä staattisia muuttujia, testit suorittavat ohjelman useita kertoja joten staattisiin muuttujiin edellisillä suorituskerroilla jääneet arvot todennäköisesti häiritsevät testien toimintaa!
  • Älä käytä luokkein nimissä skandeja, ne saattavat aiheuttaa ongelmia testeihin!
  • Yksinkertaisuuden vuoksi oletetaan että nimi on yksittäinen merkkijono, eli jos halutaan sukunimen mukaan järjestetyn tulostus viimeiseen toimintoon, nimi on annettava muodossa mikkola pekka.
  • Henkilöllä voi olla useita puhelinnumeroja sekä osoite. Henkilöllä ei kuitenkaan ole välttämättä yhtään puhelinnumeroa tai osoite ei ole tiedossa.
  • Jos henkilö poistetaan, ei mikään haku saa enää palauttaa henkilön tietoja.
Viikko4

Rengastustoimisto

Ohjelmoinnin perusteiden kuudennellaKuudennella viikolla teimme lintubongaria varten havaintotietokannan. Jatkamme hieman samasta teemasta, tällä kertaa teemme rengastustoimistolle ohjelman, jonka avulla pidetään kirjaa tiettynä vuotena rengastettujen lintujen havaintopaikoista.

HUOM: saatat törmätä tehtävässä kummalliseen virheilmoitukseen NoSuchMethodError: Lintu.equals(LLintu;)Z, jos näin käy suorita clean and build eli paina harja ja vasara -kuvakkeesta.

Linnun equals ja toString

Rengastustoimisto tallettaa tiettynä vuotena rengastettettujen lintujen tiedot Lintu-olioihin:

public class Lintu {

    private String nimi;
    private String latinankielinenNimi;
    private int rengastusvuosi;

    public Lintu(String nimi, String latinankielinenNimi, int rengastusvuosi) {
        this.nimi = nimi;
        this.latinankielinenNimi = latinankielinenNimi;
        this.rengastusvuosi = rengastusvuosi;
    }

    @Override
    public String toString() {
        return this.latinankielinenNimi + "(" + this.rengastusvuosi + ")";
    }
}

Ideana on toteuttaa rengastustoimistoon toiminnallisuus jonka avulla voidaan pitää kirjaa tiettynä vuotena rengastettujen lintujen havaintojen lukumääristä ja havaintopaikoista. Havaintomääriä ja -paikkoja ei kuitenkaan talleteta Lintu-olioihin, vaan erilliseen HashMap:iin jonka avaimena käytetään Lintu-olioita. Kuten muistamme viikolta 28, on tälläisessä tapauksessa toteutettava luokalle Lintu metodit equals(Object t) ja hashCode().

Joillain linnuilla on useita suomenkielisä nimiä (esim. punakottaraisesta käytetään edelleen joskus sen vanhaa nimitystä rusokottarainen), latinankielinen nimi on kuitenkin aina yksikäsitteinen. Tee luokalle Lintu equals- ja hashCode-metodit jotka toimivat siten, että lintu-oliot tulkitaan samoiksi jos niiden latinankielinen nimi ja rengastusvuosi ovat samat.

Esimerkki:

    Lintu lintu1 = new Lintu("punakottarainen", "Sturnus roseus", 2012);
    Lintu lintu2 = new Lintu("rusokottarainen", "Sturnus roseus", 2012);
    Lintu lintu3 = new Lintu("varis", "Corvus corone cornix", 2012);
    Lintu lintu4 = new Lintu("punakottarainen", "Sturnus roseus", 2000);

    System.out.println( lintu1.equals(lintu2));   // ovat sama koska sama latinankielinen nimi ja rengastusvuosi
    System.out.println( lintu1.equals(lintu3));   // eivät ole sama koska latinankielinen nimi eri
    System.out.println( lintu1.equals(lintu4));   // eivät ole sama koska eri rengastusvuosi
    System.out.println( lintu1.hashCode()==lintu2.hashCode() );

tulostuu:

true
false
false
true

Rengastustoimisto

Rengastustoimistolla on kaksi metodia: public void havaitse(Lintu lintu, String paikka) lisää linnulle havainnon ja havaintopaikan ja metodi public void havainnot(Lintu lintu) tulostaa alla olevan esimerkin mukaisesti parametrina olevan linnun havaintojen määrän ja havaintopaikat. Havaintopaikkojen tulostusjärjestyksellä ei ole testien läpimenon kannalta merkitystä.

Rengastustoimisto tallettaa havaintopaikat Map<Lintu, List<String>>-tyyppiseen oliomuuttujaan. Havaintojen lukumäärä selviää havaintopaikkojen listan pituudesta. Tarvittaessa voit ottaa tehtävään mallia luvusta 16.

Esimerkki rengastustoimiston käytöstä:

    Rengastustoimisto kumpulanRengas = new Rengastustoimisto();
  
    kumpulanRengas.havaitse( new Lintu("punakottarainen", "Sturnus roseus", 2012), "Arabia" );
    kumpulanRengas.havaitse( new Lintu("rusokottarainen", "Sturnus roseus", 2012), "Vallila" );
    kumpulanRengas.havaitse( new Lintu("harmaalokki", "Larus argentatus", 2008), "Kumpulanmäki" );
    kumpulanRengas.havaitse( new Lintu("punakottarainen", "Sturnus roseus", 2008), "Mannerheimintie" );

    kumpulanRengas.havainnot( new Lintu("rusokottarainen", "Sturnus roseus", 2012 ) );
    System.out.println("--");
    kumpulanRengas.havainnot( new Lintu("harmaalokki", "Larus argentatus", 2008 ) );
    System.out.println("--");
    kumpulanRengas.havainnot( new Lintu("harmaalokki", "Larus argentatus", 1980 ) );

tulostuu:

Sturnus roseus (2012) havaintoja: 2
Arabia
Vallila
--
Larus argentatus (2008) havaintoja: 1
Kumpulanmäki
--
Larus argentatus (1980) havaintoja: 0

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ä, 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]

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 merkkijonojen rakentamisessa usein Stringiä tehokkaampi StringBuilder. Metodi tulostaMerkit tulostaa annetun olion jokaisen merkin omalle rivilleen.

    Tulostin tulostin = new Tulostin();

    String mjono = "toimii";

    tulostin.tulostaMerkit(mjono);
t
o
i
m
i
i

Joukkoja

Tässä tehtävässä teemme eliöita ja eliöistä koostuvia laumoja jotka liikkuvat ympäriinsä. Eliöiden sijaintien ilmoittamiseen käytetään kaksiulotteista koordinaatistoa. Jokaiseen sijaintiin liittyy kaksi lukua, x- ja y-koordinaatti. Koordinaatti x kertoo, kuinka pitkällä "nollapisteestä" mitattuna sijainti on vaakasuunnassa, ja koordinaatti y vastaavasti kuinka pitkällä sijainti on pystysuunnassa. Jos koordinaatiston käsite ei ole tuttu, voit lukea siitä lisää esimerkiksi wikipediasta.

Tehtävän mukana tulee rajapinta Siirrettava, joka kuvaa asiaa jota voidaan siirtää paikasta toiseen. Rajapinta sisältää metodin void siirra(int dx, int dy). Parametri dx kertoo, paljonko asia siirtyy x-akselilla ja dy y-akselilla.

Tehtävässä toteutat luokat Elio ja Lauma, jotka molemmat ovat siirrettäviä. Toteuta kaikki toiminnallisuus pakkaukseen siirrettava.

Elio-luokan toteuttaminen

Luo pakkaukseen siirrettava luokka Elio, joka toteuttaa rajapinnan Siirrettava. Eliön tulee tietää oma sijaintinsa (x, y -koordinaatteina). Luokan Elio APIn tulee olla seuraava:

  • public Elio(int x, int y)
    Luokan konstruktori, joka saa olion aloitussijainnin x- ja y-koordinaatit parametrina
  • public String toString()
    Luo ja palauttaa oliosta merkkijonoesityksen. Eliön 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 Elio toimintaa seuraavalla esimerkkikoodilla.

     Elio elio = new Elio(20, 30);
     System.out.println(elio);
     elio.siirra(-10, 5);
     System.out.println(elio);
     elio.siirra(50, 20);
     System.out.println(elio);
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 rivin vaihdolla erotettuna.
  • public void lisaaLaumaan(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.lisaaLaumaan(new Elio(73, 56));
    lauma.lisaaLaumaan(new Elio(57, 66));
    lauma.lisaaLaumaan(new Elio(46, 52));
    lauma.lisaaLaumaan(new Elio(19, 107));
    System.out.println(lauma);
x: 73; y: 56
x: 57; y: 66
x: 46; y: 52
x: 19; y: 107

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

        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.

Private, protected ja public

Jos metodilla tai muuttujalla on näkyvyysmääre private, ei se näy aliluokille eikä aliluokalla ole mitään suoraa tapaa päästä käsiksi yliluokan siihen. Edellisessä esimerkissä Moottori ei siis pääse suoraan käsiksi yliluokassa Osa määriteltyihin ominaisuuksiinsa (tunnus, valmistaja, kuvaus). Aliluokka näkee luonnollisesti kaiken yliluokan julkisen eli public-määreellä varustetun kaluston. Jos halutaan määritellä yliluokkaan joitain muuttujia tai metodeja joiden näkeminen halutaan sallia aliluokille, mutta estää muilta voidaan käyttää näkyvyysmäärettä protected.

Yliluokka super

Yliluokan konstruktoria kutsutaan avainsanalla super. Kutsu super on käytännössä samanlainen kuin this-konstruktorikutsu. Kutsulle annetaan parametrina yliluokan konstruktorin vaatiman tyyppiset arvot.

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ä!

Yliluokan metodin kutsuminen

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ää sen korvaavassa metodissa 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

Olion tyyppi määrää kutsutun metodin: Polymorfismi

Olion kutsuttavissa olevat metodit määrittyvät muuttujan tyypin kautta. Esimerkiksi jos Opiskelija-tyyppisen olion viite on talletettu Henkilo-tyyppiseen muuttujaan, on oliosta käytössä vain Henkilo-luokassa määritellyt metodit:

   Henkilo olli = new Opiskelija("Olli", "Ida Albergintie 1 00400 Helsinki");
   olli.opintopisteita();        // EI TOIMI!
   olli.opiskele();              // EI TOIMI!
   String.out.println( olli );   // olli.toString() TOIMII

Jos oliolla on monta eri tyyppiä, on sillä käytössä jokaisen tyypin määrittelemät metodit. Esimerkiksi Opiskelija-tyyppisellä oliolla on käytössä Henkilo-luokassa määritellyt metodit sekä Object-luokassa määritellyt metodit.

Edellisessä tehtävässä korvasimme Opiskelijan luokalta Henkilö perimän toString uudella versiolla. Myös luokka Henkilö oli jo korvannut Object-luokalta perimänsä toStringin. Jos käsittelemme olioa jonkin muun kuin sen todellisen tyypin omaavan muuttujan kautta, mitä versiota olion metodista kutsutaan? Esim. seuraavassa on kaksi opiskelijaa joiden viitteet on talletettu Henkilo- ja Object-tyyppisiin muuttujiin. Molemmille kutsutaan metodia toString. Mikä versio metodista suoritetaan, luokassa Object, Henkilo vai Opiskelija määritelty?

   Henkilo olli = new Opiskelija("Olli", "Ida Albergintie 1 00400 Helsinki");
   String.out.println( olli );   

   Object liisa = new Opiskelija("Liisa", "Väinö Auerin katu 20 00500 Helsinki");
   String.out.println( liisa );

Tulostuu:

Olli
  Ida Albergintie 1 00400 Helsinki
  opintopisteitä 0
Liisa
  Väinö Auerin katu 20 00500 Helsinki
  opintopisteitä 0

Eli suoritettava metodi valitaan olion todellisen tyypin perusteella, ei viitteen tallettavan muuttujan tyypin perusteella!

Hieman yleisemmin: Suoritettava metodi valitaan aina olion todellisen tyypin 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.

Toinen esimerkki: pisteitä

Kaksiulotteisessa koordinaatiostossa sijaitsevaa pistettä voisi kuvata seuraavan luokan avulla:

public class Piste {

    private int x;
    private int y;

    public Piste(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int manhattanEtaisyysOrigosta(){
        return Math.abs(x)+Math.abs(y);
    }
    
    protected String sijainti(){
        return x+", "+y;
    }

    @Override
    public String toString() {
        return "("+this.sijainti()+") etäisyys "+this.manhattanEtaisyysOrigosta();
    }    
}

Metodi sijainti ei ole tarkoitettu ulkoiseen käyttöön, joten se on näkyvyysmääreeltään protected, eli aliluokat pääsevät siihen käsiksi. Esim. reitinhakualgoritmien hyödyntämässä Manhattan-etäisyydellä tarkoitetaan pisteiden etäisyyttä, jos niiden välin voi kulkea ainoastaan koordinaattiakselien suuntaisesti.

Värillinen piste on muuten samanlainen kuin piste, mutta se sisältää merkkijonona ilmaistavan värin. Luokka voidaan siis tehdä perimällä Piste:

public class VariPiste extends Piste {

    private String vari;

    public VariPiste(int x, int y, String vari) {
        super(x, y);
        this.vari = vari;
    }

    @Override
    public String toString() {
        return super.toString()+" väri: "+vari; 
    }
}

Luokka määrittelee oliomuuttujan värin talletusta varten. Koordinaatit talletetaan yliluokkaan. Merkkijonoesityksestä halutaan muuten samanlainen kuin pisteellä, mutta väri tulee myös ilmaista. Ylikirjoitettu metodi toString kutsuukin yliluokan toStringiä ja lisää sen tulokseen pisteen värin.

Seuraavassa esimerkki, jossa listalle laitetaan muutama piste, osa normaaleja ja osa väripisteitä ja tulostetaan listalla olevat pisteet. Polymorfismin ansioista kaikille tulee kutsutuksi olion todellisen tyypin toString-metodi vaikka lista tuntee kaikki pisteet Piste-tyyppisinä:

public class Main {
    public static void main(String[] args) {
        List pisteet = new ArrayList(); 
        pisteet.add(new Piste(4, 8));
        pisteet.add(new VariPiste(1, 1, "vihreä"));
        pisteet.add(new VariPiste(2, 5, "sininen"));
        pisteet.add(new Piste(0, 0));
        
        for (Piste piste : pisteet) {
            System.out.println(piste);
        }
    }
}

Tulostuu:

(4, 8) etäisyys 12
(1, 1) etäisyys 2 väri: vihreä
(2, 5) etäisyys 7 väri: sininen
(0, 0) etäisyys 0

Haluamme ohjelmaamme myös kolmiulotteisen pisteen. Koska kyseessä ei ole värillinen versio, periytetään se pisteestä:

public class Piste3D extends Piste {

    private int z;

    public Piste3D(int x, int y, int z) {
        super(x, y);
        this.z = z;
    }

    @Override
    protected String sijainti() {
        return super.sijainti()+", "+z;    // tulos merkkijono muotoa "x, y, z"
    }    

    @Override
    public int manhattanEtaisyysOrigosta() {
        // kysytään ensin yliluokalta x:n ja y:n perusteella laskettua etäisyyttä 
        // ja lisätään tulokseen z-koordinaatin vaikutus
        return super.manhattanEtaisyysOrigosta()+Math.abs(z);   
    }    
    
    @Override
    public String toString() {
        return "("+this.sijainti()+") etäisyys "+this.manhattanEtaisyysOrigosta();
    }    
}

Kolmiulotteinen piste siis määrittelee kolmatta koordinaattia vastaavan oliomuuttujan ja ylikirjoittaa metodit sijainti, manhattanEtaisyysOrigosta ja toString siten, että ne huomioivat kolmannen ulottuvuuden. Voimme nyt laajentaa edellistä esimerkkiä ja lisätä listalle myös kolmiulotteisia pisteitä:

public class Main {

    public static void main(String[] args) {
        List pisteet = new ArrayList(); 
        pisteet.add(new Piste(4, 8));
        pisteet.add(new VariPiste(1, 1, "vihreä"));
        pisteet.add(new VariPiste(2, 5, "sininen"));
        pisteet.add(new Piste3D(5, 2, 8));
        pisteet.add(new Piste(0, 0));
        
        for (Piste piste : pisteet) {
            System.out.println(piste);
        }
    }
}

Tulostus on odotusten mukainen

(4, 8) etäisyys 12
(1, 1) etäisyys 2 väri: vihreä
(2, 5) etäisyys 7 väri: sininen
(5, 2, 8) etäisyys 15
(0, 0) etäisyys 0

Huomamme, että kolmiulotteisen pisteen metodi toString on täsmälleen sama kuin pisteen toString. Voisimmeko jättää toStringin ylikirjoittamatta? Vastaus on kyllä! Kolmiulotteinen piste pelkistyy seuraavanlaiseksi:

public class Piste3D extends Piste {

    private int z;

    public Piste3D(int x, int y, int z) {
        super(x, y);
        this.z = z;
    }

    @Override
    protected String sijainti() {
        return super.sijainti()+", "+z;
    }    

    @Override
    public int manhattanEtaisyysOrigosta() {
        return super.manhattanEtaisyysOrigosta()+Math.abs(z);
    }      
}

Mitä tarkalleenottaen tapahtuu kuin kolmiulotteiselle pisteelle kutsutaan toString-metodia? Suoritus etenee seuraavasti:

  1. etsitään toString:in määrittelyä luokasta Piste3D, sitä ei löydy joten mennään yliluokkaan
  2. etsitään toString:in määrittelyä yliluokasta Piste, metodi löytyy, joten suoritetaan sen koodi
    • suoritettava koodi siis on return "("+this.sijainti()+") etäisyys "+this.manhattanEtaisyysOrigosta();
    • esimmäisenä suoritetaan metodi sijainti
    • etsitään metodin sijainti määrittelyä luokasta Piste3D, metodi löytyy ja suoritetaan sen koodi
    • metodin sijainti laskee oman tuloksensa kutsumalla yliluokassa olevaa metodia sijainti
    • seuraavaksi etsitään metodin manhattanEtaisyysOrigosta määrittelyä luokasta Piste3D, metodi löytyy ja suoritetaan sen koodi
    • jälleen metodi laskee tuloksensa kutsuen ensin yliluokassa olevaa samannimistä metodia

Metodikutsun aikaansaama toimintoketju siis on varsin monivaiheinen. Periaate on kuitenkin selkeä: suoritettavan metodin määrittelyä etsitään ensin olion todellisen tyypin määrittelystä ja jos sitä ei löydy edetään yliluokkaan. Ja jos yliluokastakaan ei löydy metodin toteutusta siirrytään etsimään yliluokan yliluokasta jne...

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 pitää matalana, sillä hierarkian ylläpito ja jatkokehitys vaikeutuu perintöhierarkian kasvaessa. Yleisesti ottaen, jos perintähierarkian korkeus on yli 2 tai 3, ohjelman rakenteessa on todennäköisesti parannettavaa.

Perinnä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, eli varastossa olevan tilavuuden.
  • public double getTilavuus()
    Palauttaa arvonaan varaston tilavuuden (eli sen, joka annettiin konstruktorille).
  • 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:

  • public Tuotevarasto(String tuotenimi, double tilavuus)
    Luo tyhjän tuotevaraston. Tuotenimi ja vetoisuus annetaan parametrina.
  • public String getNimi()
    Palauttaa arvonaan tuotteen nimen.

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:

  • public void setNimi(String uusiNimi) asettaa tuotteelle uuden nimen.
  • public String toString() palauttaa olion tilan merkkijonoesityksenä tyyliin Juice: saldo = 64.5, tilaa 123.5

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 kiinnostavaa 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:

  • public Muutoshistoria() luo tyhjän Muutoshistoria-olion.
  • public void lisaa(double tilanne) lisää muutoshistorian viimeisimmäksi muistettavaksi määräksi parametrina annetun tilanteen.
  • public void nollaa() tyhjää muistin.
  • public String toString() palauttaa muutoshistorian merkkijonoesityksen. ArrayList-luokan antama merkkijonoesitys kelpaa sellaisenaan.

Muutoshistoria.java, vaihe 2

Täydennä Muutoshistoria-luokkaa analyysimetodein:

  • public double maxArvo() palauttaa muutoshistorian suurimman arvon. Jos historia on tyhjä, metodi palauttaa nollan.
  • public double minArvo() palauttaa muutoshistorian pienimmän arvon. Jos historia on tyhjä, metodi palauttaa nollan.
  • public double keskiarvo() palauttaa muutoshistorian arvojen keskiarvon. Jos historia on tyhjä, metodi palauttaa nollan.

Muutoshistoria.java, vaihe 3

Täydennä Muutoshistoria-luokkaa analyysimetodein:

  • public double suurinMuutos() palauttaa muutoshistorian isoimman (huom: -5:n kokoinen muutos on isompi kuin 4:n kokoinen muutos) yksittäisen muutoksen itseisarvon. Jos historia on tyhjä tai yhden arvon mittainen, metodi palauttaa nollan. Itseisarvo on luvun etäisyys nollasta. Esimerkiksi luvun -5.5 itseisarvo on 5.5, luvun 3.2 itseisarvo on 3.2.
  • public double varianssi() palauttaa muutoshistorian arvojen varianssin (käytetään otosvarianssin kaavaa). Jos historia on tyhjä tai yhden arvon mittainen, metodi palauttaa nollan.

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

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:

  • public MuistavaTuotevarasto(String tuotenimi, double tilavuus, double alkuSaldo) luo tuotevaraston. Tuotenimi, vetoisuus ja alkusaldo annetaan parametrina. Aseta alkusaldo sekä varaston alkusaldoksi että muutoshistorian ensimmäiseksi arvoksi.
  • public String historia() palauttaa tuotehistorian tyyliin [0.0, 119.2, 21.2]. Käytä Muutoshistoria-olion merkkiesitystä sellaisenaan.

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

  • public void lisaaVarastoon(double maara) toimii kuin Varasto-luokan metodi, mutta muuttunut tilanne kirjataan historiaan. Huom: historiaan tulee kirjata lisäyksen jälkeinen varastosaldo, ei lisättävää määrää!
  • public double otaVarastosta(double maara) toimii kuin Varasto-luokan metodi, mutta muuttunut tilanne kirjataan historiaan. Huom: historiaan tulee kirjata poiston jälkeinen varastosaldo, ei poistettavaa määrää!

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

  • public void tulostaAnalyysi(), joka tulostaa tuotteeseen liittyviä historiatietoja esimerkin esittämään tapaan.

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.

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. 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ä
  • public String getNimi() palauttaa lehmän nimen
  • public double getTilavuus() palauttaa utareiden tilavuuden
  • public double getMaara() palauttaa utareissa olevan maidon määrän
  • public 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 final 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/35.0
Ammu 9.0/35.0
Ammu 0.0/35.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
  • public Maitosailio getMaitosailio() palauttaa kiinnitetyn maitosäiliö tai null-viitteen, jos säiliötä ei ole vielä kiinnitetty
  • public void setMaitosailio(Maitosailio maitosailio) kiinnittää annetun säiliön lypsyrobottiin
  • public 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();

        System.out.println(maatila);

Odotettu tulostus:

Maatilan omistaja: Esko
Navetan maitosäiliö: 0.0/2000.0
Lehmät:
        Heluna 2.0/17.0
        Rima 3.0/32.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/35.0
        Hento 0.0/34.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 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

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 pääset hyödyntämään erilaisten 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 equalsin ja hashCoden toteuttamiseen.

Maksimipainollinen laatikko

Toteuta 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

Huomio olioiden poistamisesta ArrayListista

Seuraavassa tehtävässä saatat joutua tilanteeseen, jossa haluat poistaa ArrayListiä läpikäydessäsi osan listan olioista:

   // joissain määritelty seuraavasti:
   // ArrayList<Olio> lista = new ... 

   for ( Olio olio : lista ) {
      if ( tarvitseePoistaa(olio) ) {
         lista.remove(olio);
      }
   }

Ratkaisu ei toimi ja aiheuttaa poikkeuksen ConcurrentModificationException, sillä listaa foreach-tyylillä läpikäydessä listaa ei saa muokata. Palaamme aiheeseen hieman tarkemmin viikolla 612. Jos törmäät tilanteeseen, voit hoitaa sen esim. seuraavasti:

   // joissain määritelty seuraavasti:
   // ArrayList<Olio> lista = new ... 

   ArrayList<Olio> poistettavat = new ArrayList<Olio>();

   for ( Olio olio : lista ) {
      if ( tarvitseePoistaa(olio) ) {
         poistattavat.add(olio);
      }
   }

   lista.removeAll(poistettavat);

Eli poistettavat oliot kerätään listan läpikäyntinä erilliselle listalle ja poisto-operaatio suoritetaan vasta listan läpikäynnin jälkeen.

Luola

Tämä tehtävä on neljän tehtäväpisteen arvoinen. Huom! Toteuta kaikki toiminnallisuus pakkaukseen luola.

Huom: jotta testit toimisivat, ohjelmasi saa luoda vain yhden Scanner-olion. Älä käytä luokkien nimissä skandeja. Älä myöskään käytä staattisia muuttujia, testit suorittavat ohjelman useita kertoja joten staattisiin muuttujiin edellisillä suorituskerroilla jääneet arvot todennäköisesti häiritsevät testien toimintaa!

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. Alla olevassa 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. Alla olevassa 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:

  • w liiku ylöspäin
  • s liiku alaspäin
  • a liiku vasemmalle
  • d liiku oikealle

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ä pelilaudalta ulos tai ruutuun jossa on jo hirviö, jätetään siirto suorittamatta. Kun kaikki hirviöt on tuhottu, peli loppuu ja tulostetaan VOITIT.

Testaamisen helpottamiseksi tee peliisi luokka Luola, jolla on :

  • konstruktori public Luola(int leveys, int korkeus, int hirvioita, int siirtoja, boolean hirviotLiikkuvat)

    Luvut leveys ja korkeus antavat luolan koon (se on aina neliskulmainen), hirvioita antaa hirviöiden lukumäärän alussa (hirviöiden sijainnin voi arpoa), siirtoja antaa siirtojen lukumäärän alussa ja jos hirviotLiikkuvat on false, hirviöt eivät liiku.

  • metodi public void run() joka käynnistää pelin

Huom! pelaajan tulee aloittaa sijainnista 0,0!

Huom! jos pelaaja tai hirviö koittaa liikkua ulos luolasta tai kaksi hirviötä koittaa liikkua samaan ruutuun, 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
Viikko5

Tiedostoon kirjoittaminen

Luvussa 1546 opimme, että tekstitiedostojen lukeminen onnistuu Scanner- ja File-luokkien avulla. 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äsittelyvastuuta 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();
    }
}

Useimmiten tiedoston perään metodilla append kirjoittamisen sijasta on helpompi kirjoittaa koko tiedosto uudelleen.

Tiedostonkäsittelijä

Saat tehtävärungon mukana luokan Tiedostonkasittelija joka sisältää metodirungot tiedoston lukemista ja tiedostoon kirjoittamista varten.

Tiedoston lukeminen

Täydennä metodi public ArrayList<String> lue(String tiedosto) sellaiseksi, että se palauttaa parametrina annetun tekstitiedoston sisältämät rivit ArrayList:ina siten, että tiedoston jokainen rivi on omana merkkijononaan listalla.

Projektissa on testaamista varten kaksi tekstitiedostoa src/koesyote1.txt ja src/koesyote2.txt. Metodia on tarkoitus käyttää seuraavalla tavalla:

    public static void main(String[] args) throws FileNotFoundException, IOException {
        TiedostonKasittelija t = new TiedostonKasittelija();

        for (String rivi : t.lue("src/koesyote1.txt")) {
            System.out.println(rivi);
        }
    }

Tulostuksen pitäisi olla

eka
toka

Rivin kirjoittaminen tiedostoon

Täydennä projektipohjan mukana tuleva metodi public void tallenna(String tiedosto, String teksti) sellaiseksi, että se tallentaa ensimmäisen parametrin määrittelemään tiedostoon toisena parametrina annetun merkkijonon. Jos tiedosto on jo olemassa, kirjoitetaan vanhan version päälle.

Rivin kirjoittaminen tiedostoon

Täydennä projektipohjan mukana tuleva metodi public void tallenna(String tiedosto, ArrayList<String> tekstit) sellaiseksi, että se tallentaa ensimmäisen parametrin määrittelemään tiedostoon toisena parametrina annetun listan siten, että jokainen merkkijono tulee omalle rivilleen. Jos tiedosto on jo olemassa, kirjoitetaan vanhan version päälle.

Muistava kahteen suuntaan kääntävä sanakirja

Tässä tehtävässä laajennetaan aiemmin toteutettua sanakirjaa siten, että sanat voidaan lukea tiedostosta ja kirjoittaa tiedostoon. Sanakirjan tulee myös osata kääntää molempiin suuntiin, suomesta vieraaseen kieleen sekä toiseen suuntaan (tehtävässä oletetaan hieman epärealistisesti, että suomen kielessä ja vieraassa kielessä ei ole yhtään samalla tavalla kirjoitettavaa sanaa). Tehtävänäsi on luoda sanakirja luokkaan MuistavaSanakirja. Toteuta luokka pakkaukseen sanakirja.

Muistiton perustoiminnallisuus

Tee sanakirjalle parametriton konstruktori sekä metodit:

  • public void lisaa(String sana, String kaannos)
  • lisää sanan sanakirjaan. Jokaisella sanalla on vain yksi käännös ja jos sama sana lisätään uudelleen, ei tapahdu mitään.
  • public String kaanna(String sana)
  • palauttaa käännöksen annetulle sanalle. Jos sanaa ei tunneta, palautetaan null.

Sanakirjan tulee tässä vaiheessa toimia seuraavasti:

MuistavaSanakirja sanakirja = new MuistavaSanakirja();
sanakirja.lisaa("apina", "monkey");
sanakirja.lisaa("banaani", "banana");
sanakirja.lisaa("apina", "apfe");

System.out.println( sanakirja.kaanna("apina") );
System.out.println( sanakirja.kaanna("monkey") );
System.out.println( sanakirja.kaanna("ohjelmointi") );
System.out.println( sanakirja.kaanna("banana") );

Tulostuu

monkey
apina
null
banaani

Kuten tulostuksesta ilmenee, käännöksen lisäämisen jälkeen sanakirja osaa tehdä käännöksen molempiin suuntiin.

Huom: metodit lisaa ja kaanna eivät lue tiedostoa tai kirjoita tiedostoon! Myöskään konstruktori ei koske tiedostoon.

Sanojen poistaminen

Lisää sanakirjalle metodi public void poista(String sana) joka poistaa annetun sanan ja sen käännöksen sanakirjasta.

HUOM: kannattaa ehkä kerrata luku 49.9Huomio olioiden poistamisesta ArrayListista.

HUOM2: metodi poista ei kirjoita tiedostoon.

Sanakirjan tulee tässä vaiheessa toimia seuraavasti:

MuistavaSanakirja sanakirja = new MuistavaSanakirja();
sanakirja.lisaa("apina", "monkey");
sanakirja.lisaa("banaani", "banana");
sanakirja.lisaa("ohjelmointi", "programming");
sanakirja.poista("apina");
sanakirja.poista("banana");

System.out.println( sanakirja.kaanna("apina") );
System.out.println( sanakirja.kaanna("monkey") );
System.out.println( sanakirja.kaanna("banana") );
System.out.println( sanakirja.kaanna("banaani") );
System.out.println( sanakirja.kaanna("ohjelmointi") );

Tulostuu

null
null
null
null
programming

Poisto siis toimii myös molemmin puolin, alkuperäisen sanan tai sen käännöksen poistamalla, poistuu sanakirjasta tieto molempien suuntien käännöksestä.

Lataaminen tiedostosta

Tee sanakirjalle konstruktori public MuistavaSanakirja(String tiedosto) ja metodi public boolean lataa(), joka lataa sanakirjan konstruktorin parametrina annetun nimisestä tiedostosta. Jos tiedoston avaaminen tai lukeminen ei onnistu, palauttaa metodi false ja muuten true.

Huom: parameterillinen konstruktori ainoastaan kertoo sanakirjalle käytetävän tiedoston nimen. Konstruktori ei lue tiedostoa, tiedoston lukeminen tapahtuu ainoastaan metodissa lataa.

Sanakirjatiedostossa yksi rivi sisältää sanan ja sen käännöksen merkillä ":" erotettuna. Tehtäväpohjan mukana tuleva testaamiseen tarkoitettu sanakirjatiedosto src/sanat.txt on sisällöltään seuraava:

apina:monkey
alla oleva:below
olut:beer

Lue sanakirjatiedosto rivi riviltä lukijan metodilla nextLine. Voit pilkkoa rivin String metodilla split seuraavasti:

Scanner tiedostonLukija = new ...
while ( tiedostonLukija.hasNextLine() ){
    String rivi = tiedostonLukija.nextLine();
    String[] osat = rivi.split(":");   // pilkotaan rivi :-merkkien kohdalta
    
    System.out.println( osat[0] );     // ennen :-merkkiä ollut osa rivistä
    System.out.println( osat[1] );     // :-merkin jälkeen ollut osa rivistä
}

Sanakirjaa käytetään seuraavasti:

MuistavaSanakirja sanakirja = new MuistavaSanakirja("src/sanat.txt");
boolean onnistui = sanakirja.lataa();

if ( onnistui ) {
  System.out.println("sanakirjan lataaminen onnistui");
}

System.out.println( sanakirja.kaanna("apina") );
System.out.println( sanakirja.kaanna("ohjelmointi") );
System.out.println( sanakirja.kaanna("alla oleva") );

Tulostuu

sanakirjan lataaminen onnistui
monkey
null
below

Tallennus tiedostoon

Tee sanakirjalle metodi public boolean tallenna(), jota kutsuttaessa sanakirjan sisältö kirjoitetaan konstruktorin parametrina annetun nimiseen tiedostoon. Jos tallennus ei onnistu, palauttaa metodi false ja muuten true. Sanakirjatiedostot tulee tallentaa ylläesitellyssä muodossa, eli ohjelman on osattava lukea itse kirjoittamiaan tiedostoja.

Huom1: mikään muu metodi kuin tallenna ei kirjoita tiedostoon. Jos teit edelliset kohdat oikein, sinun ei tulisi tarvita muuttaa mitään olemassaolevaa koodia.

Huom2: vaikka sanakirja osaa käännökset molempiin suuntiin, ei sanakirjatiedostoon tule kirjoittaa kuin toinen suunta. Eli jos sanakirja tietää esim. käännöksen tietokone = computer, tulee rivi:

tietokone:computer

tai rivi

computer:tietokone

mutta ei molempia!

Talletus kannattanee hoitaa siten, että koko käännöslista kirjoitetaan uudelleen vanhan tiedoston päälle, eli materiaalissa esiteltyä append-metodia ei kannata käyttää.

Sanakirjan lopullista versiota on tarkoitus käyttää seuraavasti:

MuistavaSanakirja sanakirja = new MuistavaSanakirja("src/sanat.txt");
sanakirja.lataa();

// käytä sanakirjaa

sanakirja.tallenna();

Eli käytön aluksi ladataan sanakirja tiedostosta ja lopussa tallennetaan se takaisin tiedostoon jotta sanakirjaan tehdyt muutokset pysyvät voimassa seuraavallekin käynnistyskerralle.

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. Oikeaoppisesti luodut käyttöliittymät toteuttavat rajapinnan Runnable, ja ne käynnistetään pääohjelmasta. Käytämme kurssilla seuraavanlaista käyttöliittymärunkoa:

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 toisen vuoden kurssilla Käyttöjärjestelmät.

    private JFrame frame;

Käyttöliittymä sisältää oliomuuttujana JFrame-olion, 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 eli ikkunan leveydeksi tulee 200 pikseliä, korkeudeksi 100 pikseliä. Komento frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE); kertoo JFrame-oliolle, että käyttöliittymän käynnistäneen ohjelman suoritus tulee lopettaa, 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 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 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!". Käyttöliittymän (eli JFrame-olion) leveyden tulee olla vähintään 400 ja korkeuden vähintään 200 ja otsikkona teksti "Swing on". 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).

HUOM: Käyttöliittymien oliomuuttujia saa alustaa vain metodeissa tai konstruktorissa! Älä alusta oliomuuttujia suoraan määrittelyn yhteydessä.

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ä ovat 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) {
        // seuraava rivi siis ei tässä tilanteessa pakollinen, sillä BorderLayout on JFramessa joka tapauksessa oletuksena
        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 "Koska 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 JTextArean sisällön 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. Tapahtumankuuntelija liitetään nappiin ja sen tulee kopioida käyttöliittymässä olevan JTextField-kentän sisältö JLabel-kenttään napin painalluksen yhteydessä ja samalla tyhjentää JTextField asettamalla sen sisällöksi "".

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 rajapinta HenkiloVarasto, ja haluamme toteuttaa käyttöliittymän henkilöiden tallentamiseen.

public interface HenkiloVarasto {
    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äyttöliittymä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 HenkiloVarasto-rajapinnasta sekä kentistä, joita se käyttää. Luodaan ActionListener-rajapinnan toteuttava luokka HenkilonLisaysKuuntelija. Luokka saa konstruktorissaan parametrina HenkiloVarasto-rajapinnan toteuttavan olion sekä kaksi JTextField-oliota, jotka ovat kentät nimelle ja henkilötunnukselle. Metodissa actionPerformed luodaan uusi Henkilo-olio ja tallennetaan se HenkiloVarasto-olion tarjoamalla talleta-metodilla.

public class HenkilonLisaysKuuntelija implements ActionListener {

    private HenkiloVarasto henkiloVarasto;
    private JTextField nimiKentta;
    private JTextField hetuKentta;

    public HenkilonLisaysKuuntelija(HenkiloVarasto henkiloVarasto, JTextField nimiKentta, JTextField hetuKentta) {
        this.henkiloVarasto = henkiloVarasto;
        this.nimiKentta = nimiKentta;
        this.hetuKentta = hetuKentta;
    }

    @Override
    public void actionPerformed(ActionEvent ae) {
        Henkilo henkilo = new Henkilo(nimiKentta.getText(), hetuKentta.getText());
        this.henkiloVarasto.talleta(henkilo);
    }
}

Jotta saamme HenkiloVarasto-viitteen HenkilonLisaysKuuntelija-oliolle, tulee sen olla käyttöliittymän tiedossa. Lisätään käyttöliittymälle oliomuuttuja private HenkiloVarasto henkiloVarasto, joka asetetaan konstruktorissa. Luokan Kayttoliittyma konstruktoria muokataan siten, että sille annetaan HenkiloVarasto-rajapinnan toteuttava luokka.

public class Kayttoliittyma implements Runnable {

    private JFrame frame;
    private HenkiloVarasto henkiloVarasto;

    public Kayttoliittyma(HenkiloVarasto henkiloVarasto) {
        this.henkiloVarasto = henkiloVarasto;
    }
    // ...


Voimme nyt luoda tapahtumankuuntelijan HenkilonLisaysKuuntelija, jolle annetaan sekä HenkiloVarasto-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(henkiloVarasto, nimiKentta, hetuKentta);
        lisaaNappi.addActionListener(kuuntelija);

        container.add(nimiTeksti);
        container.add(nimiKentta);
        container.add(hetuTeksti);
        container.add(hetuKentta);
        container.add(new JLabel(""));
        container.add(lisaaNappi);
    }

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 tätä varten 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.

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 (kuten BoxLayout), 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.

Laskin

Tehtävässä on tarkoitus toteuttaa yksinkertainen laskin. Laskimen käyttöliittymän tulee olla seuraavanlainen:

Tehtäväpohjan mukana tulee käynnistyksen suorittava pääohjelma sekä graafisen käyttöliittymän sisältävä luokka GraafinenLaskin. Käyttöliittymän on oltava täsmälleen seuraavassa osassa kuvaillulla tavalla tehty, muuten saat vapaasti suunnitella ohjelman rakenteen.

Layout kuntoon

Käyttöliittymän pohjana olevassa JFramessa tulee käyttää asettelijana GridLayoutia jossa on kolme riviä ja yksi sarake. Ylimpänä on tuloskenttänä toimiva JTextField, joka täytyy asettaa "pois päältä" metodikutsulla setEnabled(false). Toisena on syötekenttänä toimiva JTextField. Tuloskentässä on aluksi teksti 0 ja syötekenttä on tyhjä.

Alimpana komponenttina sijaitsee JPanel, jonka asettelijana on GridLayout, jossa on yksi rivi ja kolme saraketta. JPanelissa on kolme JButtonia, joissa tekstit "+", "-" ja "Z".

Laskimen käyttöliittymän koon on oltava vähintään 300*150.

Perustoiminnallisuus

Laskimen toimintalogiikka on seuraava. Käyttäjän kirjoittaessa syötekenttään luvun n ja painaessa +, lisätään tuloskentässä olevaan arvoon n ja päivitetään tuloskenttä uuteen arvoon. Vastaavasti käyttäjän kirjoittaessa syötekenttään luvun n ja painaessa -, vähennetään tuloskentässä olevasta arvosta n ja päivitetään tuloskenttä uuteen arvoon. Jos käyttäjä painaa Z, nollautuu tuloskenttä.

Vihje: joskus on tarkoituksenmukaista hoitaa yhdellä tapahtumankuuntelijalla usean napin painallusten käsittely. Tämä onnistuu kysymällä actionPerformed-metodin parametrilta kuka oli tapahtuman aiheuttaja. Seuraava koodi olettaa, että tapahtumankäsittelijällä on oliomuuttujat JButton plus ja JButton miinus jotka viittaavat plus- ja miinus-nappeihin:

    @Override
    public void actionPerformed(ActionEvent ae) {
        if (ae.getSource() == plus) {
           // käsittele plus-painike
        } else if (ae.getSource() == miinus) {
           // käsittele miinus-painike
        } else ...

Hienosäätö

Laajennetaan vielä ohjelmaa seuraavilla ominaisuuksilla:

  • Jos tuloskentässä on 0, ei Z-nappia voi painaa, eli se tulee olla asetettu "pois päältä" metodikutsulla setEnabled(false). Muissa tilanteissa napin tulee olla päällä.
  • Kun käyttäjä painaa jotain napeista +, -, Z syötekenttä tyhjenee.
  • Jos syötekentässä oleva syöte ei ole kokonaisluku ja käyttäjä painaa jotain napeista+, -, Z syötekenttä tyhjenee ja tuloskentän tila ei muutu (paitsi napin ollessa Z).

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

Huom: metodin paintComponent alussa tulee olla kutsu korvattuun metodiin, eli ensimmäisen rivin tulee olla super.paintComponent(g); muuten piirtäminen ei toimi halutulla tavalla.

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 51. 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ä erillisen hahmo-olion. 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, joilla kuvioita hallitaan. Pääsemme myöhemmin piirtämään kuvioita ruudulle. Tee kaikki ohjelman luokat pakkaukseen liikkuvakuvio.

Tehtävässä käytetään perintää ja abstrakteja luokkia. Kertaa siis tarvittaessa luvut 18.1, 18.2 ja 18.5

Abstrakti luokka Kuvio

Tee abstrakti luokka Kuvio. Kuviolla on oliomuuttujat 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 arvon konstruktori public Ympyra(int x, int y, int halkaisija) asettaa. Sijainti tallennetaan yläluokassa määriteltyihin oliomuuttujiin.

Ympyra määrittelee metodin piirra siten, että oikean kokoinen ympyrä piirretään koordinaattien osoittamaan paikkaan parametrina olevan Graphics-olion fillOval-metodia käyttäen, ympyrän sijaintia tulee käyttää metodin kahtena ensimmäisenä parametrina. Ota mallia Hahmo-esimerkin vastaavasta metodista. Graphics-olion metodien toimintaa kannattaa tutkia Java API:sta.

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 ja ei ole merkitystä mitä koostekuvio asettaa perimiensä x- ja y-koordinaatin arvoiksi. Koostekuvio piirtää itsensä pyytämällä osiaan piirtämään itsensä, koostekuvion siirtyminen tapahtuu samoin. Kuviolta peritty metodi siirra on siis ylikirjoitettava!

Koostekuvion konstruktorilla ei ole parametreja. Koostekuvion konstruktorista on kuitenkin pakko kutsua yliluokan konstruktoria jolla taas on parametrit. Koska koostekuviolla ei ole omaa sijaintia, voit käyttää yliluokan konstruktorikutsussa mitä tahansa parametrin arvoja.

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 kuvion 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 ja käyttöliittymä.

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

HUOM: tehtävä ei ole erityisen vaikea, mutta tehtävänanto saattaa aluksi vaikuttaa sekavalta. Lue ohje tarkasti uudelleen tai kysy apua jos et pääse alkuun. Tehtävä kannattaa ehdottomasti tehdä, sillä lopputulos on hieno!

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:

  • Jokainen elossa oleva solu, jolla on alle kaksi elossa olevaa naapuria kuolee.
  • Jokainen elossa oleva solu, jolla on kaksi tai kolme elossa olevaa naapuria elää seuraavaan iteraatioon eli kierrokseen.
  • Jokainen elossa oleva solu, jolla on yli kolme naapuria kuolee.
  • Jokainen kuollut solu, jolla on tasan kolme elossa olevaa naapuria muuttuu eläväksi.

Abstrakti luokka GameOfLifeAlusta tarjoaa seuraavat toiminnot

  • public GameOfLifeAlusta(int leveys, int korkeus) luo määritellyn kokoisen pelialustan
  • public boolean[][] getAlusta() tarjoaa pääsyn pelialustaan, joka on totuusarvoista koostuva kaksiulotteinen taulukko – kuten metodin paluuarvosta voi havaita! Palaamme kaksiulotteiseen taulukkoon tarkemmin sitä tarvitessamme.
  • public int getLeveys() palauttaa alustan leveyden
  • public int getKorkeus() palauttaa alustan korkeuden
  • public void pelaaKierros() simuloi pelikierroksen

Luokassa GameOfLifeAlusta on lisäksi määritelty seuraavat abstraktit metodit, jotka sinun tulee toteuttaa.

  • public abstract void muutaElavaksi(int x, int y) muuttaa alkion koordinaatissa (x, y) eläväksi eli asettaa sille arvon true. Jos koordinaatit ovat alustan ulkopuolella ei tapahdu mitään.
  • public abstract void muutaKuolleeksi(int x, int y) muuttaa alkion koordinaatissa (x, y) kuolleeksi eli asettaa sille arvon false. Jos koordinaatit ovat alustan ulkopuolella ei tapahdu mitään.
  • public abstract boolean onElossa(int x, int y) kertoo onko koordinaatissa (x, y) oleva alkio elossa. Jos koordinaatit ovat alustan ulkopuolella, palautetaan false.
  • public abstract void alustaSatunnaisetPisteet(double todennakoisyysPisteelle) alustaa kaikki alustan alkiot siten, että kukin alkio on elävä todennäköisyydellä todennakoisyysPisteelle. Todennäköisyys annetaan double-arvona suljetulla välillä [0, 1]. Jos metodia kutsutaan arvolla 1, tulee jokaisen alkion olla elävä. Jos taas todennäköisyys on 0, tulee jokaisen alkion olla kuollut.
  • public abstract int getElossaOlevienNaapurienLukumaara(int x, int y) kertoo elossa olevien naapureiden lukumäärän solulle pisteessä (x, y).
  • public abstract void hoidaSolu(int x, int y, int elossaOleviaNaapureita) hoitaa solun (x, y) Game of Life -sääntöjen mukaan.

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

  • public void muutaElavaksi(int x, int y) muuttaa alkion koordinaatissa (x, y) eläväksi eli asettaa sille arvon true
  • public void muutaKuolleeksi(int x, int y) muuttaa alkion koordinaatissa (x, y) kuolleeksi eli asettaa sille arvon false
  • public boolean onElossa(int x, int y) kertoo onko koordinaatissa (x, y) oleva alkio elossa. Jos koordinaatit ovat alustan ulkopuolella, palautetaan false.

Vihje: 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]);

Ja indeksiin x,y voidaan asettaa esim. arvo true seuraavasti:

boolean[][] alusta = getAlusta();
alusta[x][y] = true;

Tai suoraan käyttämättä apumuuttujaa:

getAlusta()[x][y] = true;    

Testaa toteutustasi seuraavalla testiohjelmalla. Huom: jos projektissasi ei ole mukana luokkaa GameOfLifeTestaaja löydät sen täältä.

package game;

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);

        GameOfLifeTestaaja gom = new GameOfLifeTestaaja(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);

        GameOfLifeTestaaja gom = new GameOfLifeTestaaja(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:

  • Jokainen elossa oleva solu, jolla on alle kaksi elossa olevaa naapuria kuolee.
  • Jokainen elossa oleva solu, jolla on kaksi tai kolme elossa olevaa naapuria elää seuraavaan iteraatioon eli kierrokseen.
  • Jokainen elossa oleva solu, jolla on yli kolme naapuria kuolee.
  • Jokainen kuollut solu, jolla on tasan kolme elossa olevaa naapuria muuttuu eläväksi.

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();
    }
}
Viikko6

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 on 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

Säännölliset lausekkeet sopivat tietynlaisiin tilanteisiin. Joissain tapaukseesa lausekkeista tulee liian monimutkaisia, ja merkkijonon "sopivuus" kannattaa tarkastaa muulla tyylillä tai voi olla tarkoituksenmukaista käyttää säännöllisiä lausekkeita vain osaan tarkastuksesta.

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). Tässä metodissa saat käyttää säännöllisten lausekkeiden lisäksi mitä tahansa muutakin tekniikkaa.

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.

Nykyään lähes kaikista ohjelmointikielistä löytyy tuki säännöllisille lausekkeille. Säännöllisten lausekkeiden teoriaa tarkastellaan toisen vuoden kurssilla Laskennan mallit. Lisää säännöllisistä lausekkeista löydät esim. googlaamalla hakusanalla regular expressions java.

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 huono ratkaisu, 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ä luokkaa 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 (tohtori), FM (maisteri), LuK (kandidaatti), FilYO (ylioppilas).

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ä alla olevan 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:

  • public void lisaa(Henkilo lisattava) lisää parametrina olevan henkilön työntekijäksi
  • public void lisaa(List<Henkilo> lisattavat) lisää parametrina olevan listan henkilöitä työntekijöiksi
  • public void tulosta() tulostaa kaikki työntekijät
  • public void tulosta(Koulutus koulutus) tulostaa työntekijät joiden koulutus on sama kuin parametrissa määritelty koulutus

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

Enumien oliomuuttujat

Luetellut tyypit voivat sisältää oliomuuttujia. Oliomuuttujien arvot tulee asettaa luetellun tyypin määrittelevän luokan sisäisessä eli näkyvyysmääreen private omaavassa konstruktorissa. Enum-tyyppisillä luokilla ei saa olla public-konstruktoria.

Seuraavassa lueteltu tyyppi Vari, joka sisältää vakioarvot PUNAINEN, VIHREA ja SININEN. Vakioille on määritelty värikoodin kertova oliomuuttuja:

public enum Vari {
    PUNAINEN("#FF0000"),        // konstruktorin parametrit määritellään vakioarvoja lueteltaessa
    VIHREA("#00FF00"),
    SININEN("#0000FF");

    private String koodi;        // oliomuuttuja

    private Vari(String koodi) { // konstruktori
        this.koodi = koodi;
    }

    public String getKoodi() {
        return this.koodi;
    }
}  

Lueteltua tyyppiä Vari voidaan käyttää esimerkiksi seuraavasti:

    System.out.println(Vari.VIHREA.getKoodi());
#00FF00

Elokuvien suosittelija

Hiljattain Suomeen rantautunut Netflix 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/).

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 getArvo(), joka palauttaa arvioon liittyvän arvon. Arviotunnusten ja niihin liittyvien arvosanojen tulee olla seuraavat:

TunnusArvo
HUONO-5
VALTTAVA-3
EI_NAHNYT0
NEUTRAALI1
OK3
HYVA5

Luokkaa voi käyttää seuraavasti:

    Arvio annettu = Arvio.HYVA;
    System.out.println("Arvio " + annettu + ", arvo " + annettu.getArvo());
    annettu = Arvio.NEUTRAALI;
    System.out.println("Arvio " + annettu + ", arvo " + annettu.getArvo());
Arvio HYVA, arvo 5
Arvio NEUTRAALI, arvo 1

ArvioRekisteri, osa 1

Aloitetaan arvioiden varastointiin liittyvän palvelun toteutus.

Luo pakkaukseen suosittelija luokka ArvioRekisteri, jolla on konstruktori public ArvioRekisteri() sekä seuraavat metodit:

  • public void lisaaArvio(Elokuva elokuva, Arvio arvio) lisää arviorekisteriin parametrina annetulle elokuvalle uuden arvion. Samalla elokuvalla voi olla useita samanlaisiakin arvioita.
  • public List<Arvio> annaArviot(Elokuva elokuva) palauttaa elokuvalle lisätyt arviot listana.
  • public Map<Elokuva, List<Arvio>> elokuvienArviot() palauttaa mapin, joka sisältää arvioidut elokuvat avaimina. Jokaiseen elokuvaan liittyy lista, joka sisältää elokuvaan lisatyt arviot.

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äämiseen.

Lisää luokkaan ArvioRekisteri seuraavat metodit:

  • public void lisaaArvio(Henkilo henkilo, Elokuva elokuva, Arvio arvio) lisää parametrina annetulle elokuvalle tietyn henkilön tekemän arvion. Sama henkilö voi arvioida tietyn elokuvan vain kertaalleen. Henkilön tekemä arvio tulee myös lisätä kaikkiin elokuviin liittyviin arvioihin.
  • public Arvio haeArvio(Henkilo henkilo, Elokuva elokuva) palauttaa parametrina annetun henkilön tekemän arvion parametrina annetulle elokuvalle. Jos henkilö ei ole arvioinut kyseistä elokuvaa, palauta arvio Arvio.EI_NAHNYT.
  • public Map<Elokuva, Arvio> annaHenkilonArviot(Henkilo henkilo) palauttaa hajautustaulun, joka sisältää henkilön tekemät arviot. Hajautustaulun avaimena on arvioidut elokuvat, arvoina arvioituihin elokuviin liittyvät arviot. Jos henkilö ei ole arvioinut yhtään elokuvaa, palautetaan tyhjä hajautustaulu.
  • public List<Henkilo> arvioijat() palauttaa listan henkilöistä jotka ovat arvioineet elokuvia.

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

Nyt tekemämme ensimmäinen vaihe toimii oikein ainoastaan henkilöille, jotka eivät ole vielä arvostelleet yhtään elokuvaa. Heidän elokuvamaustaanhan on mahdoton sanoa mitään ja paras arvaus on suositella heille keskimäärin parhaan arvosanan saanutta elokuvaa.

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.

Valitettavasti tämän osan virhediagnostiikkakaan ei ole samaa luokkaa kuin edellisissä kohdissa.

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.

Vaihteleva määrä parametreja metodille

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ä taulukkona.

    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));  // luvut = {3, 5, 7, 9}
    System.out.println(summa(1, 2));        // luvut = {1, 2}
24
3

Huomaa yllä miten parametrimäärittely int... luvut johtaa siihen, että metodin sisällä näkyy taulukkotyyppinen muuttuja luvut.

Metodille voi määritellä vain yhden parametrin joka saa rajattoman määrän arvoja, ja sen tulee olla metodimäärittelyn viimeinen parametri. Esimerkiksi:

    public void tulosta(String... merkkijonot, int kertaa) // ei sallittu!
    public void tulosta(int kertaa, String... merkkijonot) // sallittu!

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.

JoustavatHakuehdot

Muutamassa tehtävässä (mm. ohpen kirjasto ja ohjan sanatutkimus) törmäsimme tilanteeseen, jossa jouduimme filtteröimään listalta jotain hakuehtoa vastaavat oliot. Esim. sanatutkimuksessa metodit zSisaltava, lLoppuiset, palindromit, kaikkiVoksSis tekivät oleellisesti saman asian: ne kävivät läpi tiedoston sisällön sana kerrallaan ja tarkastivat jokaisen sanan kohdalla päteekö sille tietty ehto, ja jos pätee, ottivat sanan talteen. Koska kaikkien metodien ehto oli erilainen, ei toisteisuutta tehtävissä osattu poistaa vaan kaikkien koodi oli ehtoa vaille "copypastea".

Tässä tehtävässä teemme ohjelman, jonka avulla on mahdollista filtteröidä rivejä Project Gutenbergin sivuilta löytyvistä kirjoista. Seuraavassa esimerkkinä Dostojevskin Rikos ja rangaistus. Haluamme, että erilaisia filtteröintiehtoja on monelaisia ja että filtteröinti voi tapahtua myös eri ehtojen kombinaationa. Ohjelman rakenteen pitää myös mahdollistaa uusien ehtojen lisääminen myöhemmin.

Sopiva ratkaisu tilanteeseen on jokaisen filtteröintiehdon määritteleminen omana rajapinnan Ehto toteuttavana oliona. Seuraavassa rajapinnan määritelmä:

public interface Ehto {
    boolean toteutuu(String rivi);
}

Seuraavassa eräs rajapinnan toteuttava filtteriluokka:

public class SisaltaaSanan implements Ehto {

    String sana;

    public SisaltaaSanan(String sana) {
        this.sana = sana;
    }

    @Override
    public boolean toteutuu(String rivi) {
        return rivi.contains(sana);
    }    
}

Luokan oliot ovat siis hyvin yksinkertaisia, ne muistavat konstruktorin parametrina annetun sanan. Olion ainoalta metodilta voi kysyä toteutuuko ehto parametrina olevalle merkkijonolle, ja ehdon toteutuminen tarkoittaa olion tapauksessa sisältääkö merkkijono olion muistaman sanan.

Tehtäväpohjan mukana saat valmiina luokan GutenbergLukija, jonka avulla voit tutkia kirjojen rivejä filtteröitynä parametrina annetun hakuehdon perusteella:

public class GutenbergLukija {

    private List<String> sanat;

    public GutenbergLukija(String osoite) throws IllegalArgumentException {
        // kirjan verkosta hakeva koodi
    }
    
    public List≶String> rivitJoilleVoimassa(Ehto ehto){
        List≶String> ehdonTayttavat = new ArrayList<String>();
        
        for (String rivi : sanat) {
            if ( ehto.toteutuu(rivi)) {
                ehdonTayttavat.add(rivi);
            }
        }
        
        return ehdonTayttavat;
    }
}

Seuraavassa tulostetaan Rikoksesta ja rangaistuksesta kaikki rivit, joilla esiintyy sana "beer":

public static void main(String[] args) {
    String osoite = "http://www.gutenberg.myebook.bg/2/5/5/2554/2554-8.txt";
    GutenbergLukija kirja = new GutenbergLukija(osoite);

    Ehto ehto = new SisaltaaSanan("beer");
        
    for (String rivi : kirja.rivitJoilleVoimassa(ehto)) {
        System.out.println(rivi);
    }
}

Huom: Project Gutenbergin sivuilla olevat tekstit ovat tarkoitettu vain ihmisten luettaviksi. Jos tekstejä haluaa lukea koneellisesti eli esim. GutenbergLukijalla, on kirjan sivulta, esim. http://www.gutenberg.org/ebooks/2554 mentävä alakulmassa olevaan linkin mirror sites takaa löytyvälle sivulle, esim. bulgarialaiselle e-book Ltd:n sivulle ja luettava kirja sieltä löytyvästä osoitteesta.

Kaikki sanat

Tee rajapinnan ehto toteuttava luokka KaikkiRivit, joka kelpuuttaa jokaisen rivin. Tämä ja muutkin tämän tehtävän luokat tulee toteuttaa pakkaukseen lukija.ehdot.

public static void main(String[] args) {
    String osoite = "http://www.gutenberg.myebook.bg/2/5/5/2554/2554-8.txt";
    GutenbergLukija kirja = new GutenbergLukija(osoite);

    Ehto ehto = new KaikkiRivit();
        
    for (String rivi : kirja.rivitJoilleVoimassa(ehto)) {
        System.out.println(rivi);
    }
}

Loppuu huuto- tai kysymysmerkkiin

Tee rajapinnan ehto toteuttava luokka LoppuuHuutoTaiKysymysmerkkiin, joka kelpuuttaa ne rivit, joiden viimeinen merkki on huuto- tai kysymysmerkki.

public static void main(String[] args) {
    String osoite = "http://www.gutenberg.myebook.bg/2/5/5/2554/2554-8.txt";
    GutenbergLukija kirja = new GutenbergLukija(osoite);

    Ehto ehto = new LoppuuHuutoTaiKysymysmerkkiin();
        
    for (String rivi : kirja.rivitJoilleVoimassa(ehto)) {
        System.out.println(rivi);
    }
}

Muistutus: merkkien vertailu Javassa tapahtuu == operaattorilla:

String nimi = "pekka";

// HUOM: 'p' on merkki eli char p, "p" taas merkkojono, jonka ainoa merkki on p
if ( nimi.charAt(0) == 'p' ) {
    System.out.println("alussa p");
} else {
    System.out.println("alussa jokin muu kuin p");
}

Pituus vähintään

Tee rajapinnan ehto toteuttava luokka PituusVahintaan, jonka oliot kelpuuttavat ne rivit, joiden pituus on vähintään olion konstruktorin parametrina annettu luku.

public static void main(String[] args) {
    String osoite = "http://www.gutenberg.myebook.bg/2/5/5/2554/2554-8.txt";
    GutenbergLukija kirja = new GutenbergLukija(osoite);

    Ehto ehto = new PituusVahintaan(40);
        
    for (String rivi : kirja.rivitJoilleVoimassa(ehto)) {
        System.out.println(rivi);
    }
}

molemmat

Tee rajapinnan ehto toteuttava luokka Molemmat. Luokan oliot saavat konstruktorin parametrina kaksi rajapinnan Ehto toteuttavaa olioa. Molemmat-olio kelpuuttavaa ne rivit, jotka sen kummatkin konstruktorissa saamansa ehdot kelpuuttavat. Seuraavassa tulostetaan kaikki huuto- tai kysymysmerkkiin loppuvat rivit, jotka sisältävät sanan "beer".

public static void main(String[] args) {
    String osoite = "http://www.gutenberg.myebook.bg/2/5/5/2554/2554-8.txt";
    GutenbergLukija kirja = new GutenbergLukija(osoite);

    Ehto ehto = new Molemmat(
                    new LoppuuHuutoTaiKysymysmerkkiin(),
                    new SisaltaaSanan("beer")
                );

    for (String rivi : kirja.rivitJoilleVoimassa(ehto)) {
        System.out.println(rivi);
    }
}

negaatio

Tee rajapinnan ehto toteuttava luokka Ei. Luokan oliot saavat parametrina rajapinnan Ehto toteuttavaavan olion. Ei-olio kelpuuttaa ne rivit, joita sen parametrina saama ehto ei kelpuuta. Seuraavassa tulostetaan rivit, joiden pituus vähemmän kuin 10.

public static void main(String[] args) {
    String osoite = "http://www.gutenberg.myebook.bg/2/5/5/2554/2554-8.txt";
    GutenbergLukija kirja = new GutenbergLukija(osoite);

    Ehto ehto = new Ei( new PituusVahintaan(10) );
      
    for (String rivi : kirja.rivitJoilleVoimassa(ehto)) {
        System.out.println(rivi);
    }
}

vähintään yksi

Tee rajapinnan ehto toteuttava luokka VahintaanYksi. Luokan oliot saavat konstruktorin parametrina mielivaltaisen määrän rajapinnan Ehto toteuttavia olioita, konstruktorissa siis käytettävä vaihtuvanmittaista parametrilistaa. VahintaanYksi-oliot kelpuuttavat ne rivit, jotka ainakin yksi sen konstruktoriparametrina saamista ehdoista kelpuuttaa. Seuraavassa tulostetaan rivit, jotka sisältävät jonkun sanoista "beer", "milk" tai "oil".

public static void main(String[] args) {
    String osoite = "http://www.gutenberg.myebook.bg/2/5/5/2554/2554-8.txt";
    GutenbergLukija kirja = new GutenbergLukija(osoite);

    Ehto ehto = new VahintaanYksi(
                    new SisaltaaSanan("beer"),
                    new SisaltaaSanan("milk"),
                    new SisaltaaSanan("oil")
                );

    for (String rivi : kirja.rivitJoilleVoimassa(ehto)) {
        System.out.println(rivi);
    }
}

Huomaa, että ehtoja voi kombinoida mielivaltaisesti. Seuraavassa ehto, joka hyväksyy rivit, joilla on vähintään yksi sanoista "beer", "milk" tai "oil" ja jotka ovat pituudeltaan 20-30 merkkiä.

    Ehto sanat = new VahintaanYksi(
                    new SisaltaaSanan("beer"),
                    new SisaltaaSanan("milk"),
                    new SisaltaaSanan("oil")
                );

    Ehto oikeaPituus = new Molemmat(
                         new PituusVahintaan(20),
                         new Ei( new PituusVahintaan(31))
                       );

    Ehto halutut = new Molemmat(sanat, oikeaPituus);

StringBuilder

Olemme tottuneet rakentamaan merkkijonoja seuraavaan tapaan:

    public static void main(String[] args) {
        int[] t = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
        System.out.println(muotoile(t));
    }

    public static String muotoile(int[] t) {
        String mj = "{";

        for (int i = 0; i < t.length; i++) {
            mj += t[i];
            if (i != t.length - 1) {
                mj += ", ";
        
            }
        }

        return mj + "}";
    }

Tulostus:

{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}

Tapa on toimiva mutta ei kovin tehokas. Kuten muistamme, merkkijonot ovat immutaabeleita eli olioita joita ei voi muuttaa. Merkkijono-operaatioiden tuloksena onkin aina uusi merkkijono-olio. Eli edellisessäkin esimerkissä syntyi välivaiheena 10 merkkijono-olioa. Jos syötteen koko olisi isompi, alkaisi välivaiheena olevien olioiden luominen vaikuttaa ohjelman suoritusaikaan ikävällä tavalla.

String builder

Edellisen kaltaisissa tilanteissa onkin parempi käyttää merkkijonon muodostamisessa StringBuilder-olioita. Toisin kuin Stringit, StringBuilderit eivät ole immutaabeleita, ja yhtä StringBuilderolioa voi muokata. Tutustu StringBuilderin API-kuvaukseen (löydät sen esim googlaamalla stringbuilder java api 6) ja muuta tehtäväpohjassa oleva metodi public static String muotoile(int[] t) toimimaan StringBuilderia käyttäen seuraavaan tapaan:

{
 1, 2, 3, 4, 
 5, 6, 7, 8, 
 9, 10
}

Eli aaltosulkeet tulevat omalle rivilleen. Taulukon alkioita tulostetaan 4 per rivi ja rivin ensimmäistä edeltää välilyönti. Pilkun jälkeen ennen seuraavaa numeroa tulee olla tasan yksi välilyönti.

Loppuhuipennus

Kurssi alkaa olla ohi ja on loppuhuipennuksen aika!

Matopeli

Tässä tehtävässä luodaan rakenteet ja osa toiminnallisuudesta seuraavannäköiseen matopeliin. Tehtävän palautettavassa versiossa tosin pelin väritys on erilainen, mato on musta, omena punainen ja pohja harmaa.

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.

  • public int getX() palauttaa Palan konstruktorissa saadun x-koordinaatin.
  • public int getY() palauttaa Palan konstruktorissa saadun y-koordinaatin.
  • public boolean osuu(Pala pala) palauttaa true jos oliolla on sama x- ja y-koordinaatti kuin parametrina saadulla Pala-luokan ilmentymällä.
  • public String toString() palauttaa palan sijainnin muodossa (x,y). Esim. (5,2) kun x-koordinaatin arvo on 5 ja y-koordinaatin arvo on 2.

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ä. Huom: enum Suunta löytyy valmiina pakkauksesta Matopeli.

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

  • public Suunta getSuunta() palauttaa madon suunnan.
  • public void setSuunta(Suunta suunta) asettaa madolle uuden suunnan. Mato liikkuu uuteen suuntaan kun metodia liiku kutsutaan seuraavan kerran.
  • public int getPituus() palauttaa madon pituuden. Madon pituuden tulee olla sama kuin getPalat()-metodikutsun palauttaman listan alkioiden määrä.
  • public List<Pala> getPalat() palauttaa listan pala-olioita, joista mato koostuu. Palat ovat listalla järjestyksessä, siten että pää sijaitsee listan lopussa.
  • public void liiku() liikuttaa matoa yhden palan verran eteenpäin.
  • public void kasva() kasvattaa madon kokoa yhdellä. Madon kasvaminen tapahtuu seuraavan liiku-metodikutsun yhteydessä. Sitä seuraaviin liiku-kutsuihin kasvaminen ei enää vaikuta. Jos madon pituus on 1 tai 2 kun metodia kutsutaan, ei kutsulla saa olla mitään vaikutusta matoon.
  • public boolean osuu(Pala pala) tarkistaa osuuko mato parametrina annettuun palaan. Jos mato osuu palaan, eli joku madon pala osuu metodille parametrina annettuun palaan, tulee metodin palauttaa arvo true. Muuten metodi palauttaa arvon false.
  • public boolean osuuItseensa() tarkistaa osuuko mato itseensä. Jos mato osuu itseensä, eli joku sen pala osuu johonkin toiseen sen palaan, metodi palauttaa arvon true. Muuten metodi palauttaa arvon false.

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 listan loppuun, ja poistetaan listan alussa oleva alkio. Uudesta palasta siis tulee madon "uusi pää" ja jokaisen palan koordinaatteja ei tarvitse päivittää erikseen. Toteuta kasvaminen siten, että listan alussa olevaa palaa, eli "madon häntää" 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)]
[(5,5), (6,5)]
[(5,5), (6,5), (7,5)]
[(6,5), (7,5), (8,5)]
[(6,5), (7,5), (8,5)]
[(6,5), (7,5), (8,5), (9,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

  • public Mato getMato() palauttaa matopelin madon.
  • public void setMato(Mato mato) asettaa matopeliin metodin parametrina olevan madon. Jos metodia getMato kutsutaan madon asetuksen jälkeen, tulee metodin getMato palauttaa viite samaan matoon.
  • public Omena getOmena palauttaa matopelin omenan.
  • public void setOmena(Omena omena) asettaa matopeliin metodin parametrina olevan omenan. Jos metodia getOmena kutsutaan omenan asetuksen jälkeen, tulee metodin getOmena palauttaa viite samaan omenaan.

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. Jos mato törmää seinään, aseta muuttujan jatkuu arvoksi false
  5. Kutsu rajapinnan Paivitettava toteuttavan muuttujan paivitettava metodia paivita.
  6. 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(Graphics g) siten, että piirrät metodissa paintComponent madon ja omenan. Käytä madon piirtämiseen Graphics-olion tarjoamaa fill3DRect-metodia. Madon värin tulee olla musta (Color.BLACK). Omenan piirtämisessä tulee käyttää Graphics-olion tarjoamaa fillOval-metodia. Omenan värin tulee olla punainen (Color.RED).

Huom: metodin paintComponent alussa tulee olla kutsu korvattuun metodiin, eli ensimmäisen rivin tulee olla super.paintComponent(g);

Toteuta luokalla Piirtoalusta myös rajapinta Paivitettava. Paivitettava-rajapinnan määrittelemän metodin paivita tulee kutsua JPanel-luokalta perittyä 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();

Peliin jää vielä paljon paranneltavaa. Palauta nyt tehtävä ja voit sen jälkeen laajentaa ja parannella peliä haluamallasi tavalla!

Kurssipalaute

Kurssipalaute

Olemme saaneet paljon arvokasta palautetta TMC:n kautta. Näin kurssin viimeisenä kysymyksenä haluaisimme koko kurssin sisältöä koskevan palautteen. Anna palaute täyttämällä täältä löytyvä lomake. Palaute on anonyymi.

Jotta saat merkatuksi tämän tehtävän, aja tehtävän TMC-testit ja lähetä tehtävä palvelimelle.

Vaikka olemme saaneet paljon arvokasta palautetta TMC:n kautta yksittäisistä tehtävistä, palauttaessasi tätä, kirjoitathan kommenttikenttään palautteen koko MOOC-kurssista. Mainitsethan siinä, mikäli et halua palautettasi julkaistavan anonyymisti sivustoillamme. Vain osa palautteesta tullaan julkaisemaan.

Materiaali