Tehtävät
Yhdeksännen osan tavoitteet

Tuntee luokkaakaavioiden merkintätavan ja luo luokkia luokkakaavioiden perusteella. Tuntee Javan erityyppiset poikkeukset ja luo ohjelmia, jotka varautuvat poikkeuksiin. Tuntee menetelmiä listojen järjestämiseen ja järjestetyistä listoista hakemiseen. Tuntee rajapinnan Comparable ja hyödyntää sitä olioiden järjestämisessä. Tuntee käsitteen perintähierarkia ja kertaa arvojen ryhmittelyä hajautustaulun avulla.

Konekokeesta

Lisätietoa ohjelmoinnin jatkokurssin ensimmäisestä konekokeesta löytyy kurssimateriaalin johdanto-osiosta.

Luokkakaaviot

Luokkakaavio on ohjelmistojen mallinnuksessa käytettävä kaaviotyyppi, jonka avulla kuvataan olio-ohjelmoinnin luokkia. Luokkaaviossa kuvattavat luokat vastaavat ohjelmakoodin luokkia. Kaavioissa kuvataan luokkien nimet, attribuutit, luokkien väliset yhteydet sekä mahdollisesti myös metodit.

Luokka ja attribuutit

Luodaan luokka nimeltä Henkilo, jolla on oliomuuttujat nimi ja ikä.

public class Henkilo {
    private String nimi;
    private int ika;
}

Yllä kuvattua luokkaa kuvaa seuraava luokkakaavio. Luokkakaavioissa luokka kuvataan suorakulmiona, jonka ylälaidassa on luokan nimi, ja keskellä on oliomuuttujien nimet ja tyypit.

Luokkaakaaviossa luokkaan liittyvät muuttujat määritellään muodossa "muuttujanNimi: muuttujanTyyppi". Miinusmerkki ennen muuttujan nimeä kertoo, että muuttujalla on avainsana private.

[Henkilo|-nimi:String;-ika:int]

Olemme nyt määritelleet rakennuspiirustukset -- luokan -- henkilöoliolle. Jokaisella uudella henkilöolioilla on muuttujat nimi ja ika, joissa voi olla oliokohtainen arvo. Henkilöiden "tila" koostuu niiden nimeen ja ikään asetetuista arvoista.

Määrittellään seuraavaksi luokalle konstruktori, joka saa parametrina nimen.

public class Henkilo {
    private String nimi;
    private int ika;

    public Henkilo(String nimi) {
        this.nimi = nimiAlussa;
        this.ika = 0;
    }
}

Luokkakaaviossa konstruktori (ja metodit) merkitään oliomuuttujien alapuolelle. Konstruktori saa näkyvyysmääreen public takia eteen plussan, jonka lisäksi siitä merkitään nimi sekä parametrien nimet ja niiden tyypit (tässä + Henkilo(nimi: String)).

[Henkilo|-nimi:String;-ika:int|+Henkilo(nimi:String)]

Lisätään luokalle metodi, jonka palautustyyppi on void.

public class Henkilo {
    private String nimi;
    private int ika;

    public Henkilo(String nimi) {
        this.nimi = nimi;
        this.ika = 0;
    }

    public void tulostaHenkilo() {
        System.out.println(this.nimi + ", ikä " + this.ika + " vuotta");
    }
}

Luokkakaaviossa metodi merkitään kuten konstruktori, mutta tämän lisäksi metodista kirjoitetaan myös sen palautustyyppi.

[Henkilo|-nimi:String;-ika:int|+Henkilo(nimi:String);+tulostaHenkilo():void]

Metodi tulostaHenkilo hyödyntää oliomuuttujia nimi ja ika, mutta luokkakaaviossa tätä ei kerrota. Tarkemmin katsoen huomaamme, että luokkakaavio ei kerro mitään konstruktorien ja metodien sisäisestä toteutuksesta. Luokkakaaviolla kerrotaan siis olioiden rakenteesta, mutta luokkakaaviot eivät itsessään määrittele toiminnallisuutta.

Lisätään luokalle vielä nimen palauttava metodi getNimi.

public class Henkilo {
    private String nimi;
    private int ika;

    public Henkilo(String nimi) {
        this.nimi = nimi;
        this.ika = 0;
    }

    public void tulostaHenkilo() {
        System.out.println(this.nimi + ", ikä " + this.ika + " vuotta");
    }

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

[Henkilo|-nimi:String;-ika:int|+Henkilo(nimi:String);+tulostaHenkilo():void;+getNimi():String]

Yhteyksien merkintä luokkakaavioon

Luokkakaavioissa yhteydet merkitään viivoilla, joissa nuolet kuvaavat yhteyden suuntaa. Oletetaan, että käytössämme luokka Kirja.

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

    // konstruktorit ja metodit
}

[Kirja|-nimi:String;-julkaisija:String]

Jos luokalle kirja merkitään kirjoittaja, joka on tyyppiä Henkilö, ohjelmakoodissa oliomuuttuja merkitänä muiden muuttujien seuraksi.

public class Kirja {
    private String nimi;
    private String kustantaja;
    private Henkilo kirjoittaja;

    // konstruktorit ja metodit
}

Luokkakaaviossa toisiin olioihin viittaavia muuttujia ei merkitä attribuutteihin, vaan ne merkitään yhteyksinä. Alla olevassa luokkakaaviossa on merkittynä sekä luokka Henkilo että luokka Kirja, sekä näiden välinen yhteys. Yhteys kertoo, että se luotu kirjan suunnasta (nuoli henkilöön) ja että yhteys kertoo kirjoittajasta (nuolen teksti "kirjoittaja").

[Henkilo|-nimi:String;-ika:int|+Henkilo(nimi:String);+tulostaHenkilo():void;+getNimi():String]
											 [Kirja|-nimi:String;-julkaisija:String]
											 [Kirja]-kirjoittaja->[Henkilo]

Jos kirjalla voi olla useita kirjoittajia, kirjoittajat merkitään luokkaan listana.

public class Kirja {
    private String nimi;
    private String kustantaja;
    private ArrayList<Henkilo> kirjoittajat;

    // konstruktorit ja metodit
}

Luokkakaaviossa tilanne merkitään yhteyden päätyyn asetettavalla tähdellä. Tähti kertoo, että kirjalla voi olla nollasta äärettömään kirjoittajaa. Alla olevassa esimerkissä yhteyteen ei ole merkitty yhteyttä kuvaavaa tekstiä "kirjoittajat", mutta se kannattaisi lisätä kaavioon.

[Henkilo|-nimi:String;-ika:int|+Henkilo(nimi:String);+tulostaHenkilo():void;+getNimi():String]
											  [Kirja|-nimi:String;-julkaisija:String]
											  [Kirja]-*>[Henkilo]

Metodit merkitään luokkakaavioon normaalisti. Alla luokkaan Kirja on lisätty metodit getKirjoittajat ja lisaaKirjoittaja.

public class Kirja {
    private String nimi;
    private String kustantaja;
    private ArrayList<Henkilo> kirjoittajat;

    // konstruktori

    public ArrayList<Henkilo> getKirjoittajat() {
        return this.kirjoittajat;
    }

    public void lisaaKirjoittaja(Henkilo kirjoittaja) {
        this.kirjoittajat.add(kirjoittaja);
    }
}

[Henkilo|-nimi:String;-ika:int|+Henkilo(nimi:String);+tulostaHenkilo():void;+getNimi():String]
												     [Kirja|-nimi:String;-julkaisija:String|+getKirjoittajat():ArrayList;+lisaaKirjoittaja(kirjoittaja:Henkilo)]
												     [Kirja]-*>[Henkilo]

Ylläolevaan kaavioon voisi lisätä vielä ArrayListin sisältämien arvojen tyypin ArrayList<Henkilo> sekä yhteyttä tarkentavan määreen "kirjoittajat".

Rajapinnat luokkakaaviossa

Rajapinnat merkitään luokkakaavioissa muodossa <<interface>> RajapintaLuokanNimi. Tarkastellaan esimerkkinä rajapintaa Luettava.

public interface Luettava {

}

[<<interface>> Luettava]

Metodit voidaan merkitä alle kuten luokkakaavioissa.

Rajapinnan toteuttaminen merkitään katkoviivalla ja kolmiolla. Alla on kuvattu tilanne, missä luokka Kirja toteuttaa rajapinnan Luettava.

[<<interface>> Luettava]
									   [Kirja]-.-^[<<interface>> Luettava]

Laajempi esimerkki: Maatilasimulaattori

Eräs edellisen osan ohjelmointitehtävä oli maatilasimulaattori. Maatilalla on lehmiä, jotka ovat eleleviä ja lypsäviä. Navetassa on lypsyrobotti ja maitosäiliö. Maatilalla on navetta ja lehmiä. Tämän lisäksi myös Maatila on elelevä. Eräs mahdollinen tehtävän loppuratkaisua kuvaava luokkakaavio on seuraavanlainen.

[Maitosailio|-tilavuus:double;-saldo:double]
	    [Lehma]
	    [<<interface>> Eleleva]
            [<<interface>> Lypsava]
	    [Lypsyrobotti]
	    [Maatila|-omistaja:String]
	    [Navetta]
	    [Navetta]->[Maitosailio]
	    [Navetta]->[Lypsyrobotti]
	    [Maatila]->[Navetta]
	    [Maatila]->*[Lehma]
	    [Maatila]-.-^[<<interface>> Eleleva]
	    [Lehma]-.-^[<<interface>> Eleleva]
	    [Lehma]-.-^[<<interface>> Lypsava]

Luokkakaavioiden käytöstä

Luokkakaavioita kannattaa käyttää laajempien tehtävien (ja ongelmien) käsitteiden sekä niiden yhteyksien hahmottamisessa. Kurssin tehtäviä tehdessä hyvä lähtökohta on piirtää luokat ja niiden yhteydet ilman oliomuuttujia tai metodeja.

Poikkeukset

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

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

Poikkeusten käsittely

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

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

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

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

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

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

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

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

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

Scanner lukija = new Scanner(System.in);

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

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

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

Scanner lukija = new Scanner(System.in);

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

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

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

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

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

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

Käsittelyvastuun siirtäminen

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

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

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

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

    return rivit;
}

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

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

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

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

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

Poikkeusten heittäminen

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

public class Ohjelma {

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

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

Luodaan luokka Arvosana, joka saa konstruktorin parametrina kokonaislukutyyppisen arvosanan.

public class Arvosana {
    private int arvosana;

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

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

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

public class Arvosana {
    private int arvosana;

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

        this.arvosana = arvosana;
    }

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

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

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

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

Henkilön validointi

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

Laskimen validointi

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

Poikkeukset ja rajapinnat

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

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

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

public class Tekstipalvelin implements Tiedostopalvelin {

    private Map<String, String> data;

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

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

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

Poikkeuksen tiedot

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

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

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

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

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

Kaikki luotavat luokat tulee sijoittaa pakkaukseen sovellus.

Käytössämme on seuraava rajapinta:

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

Vakiosensori

Tee luokka Vakiosensori joka toteuttaa rajapinnan Sensori.

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

Esimerkki:

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

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

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

Lampomittari

Tee luokka Lampomittari, joka toteuttaa rajapinnan Sensori.

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

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

Keskiarvosensori

Tee luokka Keskiarvosensori, joka toteuttaa rajapinnan Sensori.

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

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

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

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

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

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

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

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

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

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

Kaikki mittaukset

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

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

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

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

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

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

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

mittaukset: [-10, -4, 5]

Lisää rajapinnoista

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

package sovellus.domain;

public interface Tunnistettava {
    String getTunnus();
}

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

package sovellus.domain;

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

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

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

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

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

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

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

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

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

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

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

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

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

Rekisterin käyttö onnistuu seuraavasti.

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

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

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

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

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

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

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

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

public class Kerholainen implements Comparable<Kerholainen> {
    private String nimi;
    private int pituus;

    public Kerholainen(String nimi, int pituus) {
	this.nimi = nimi;
	this.pituus = pituus;
    }

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

    public int getPituus() {
	return this.pituus;
    }

    @Override
    public String toString() {
	return this.getNimi() + " (" + this.getPituus() + ")";
    }

    @Override
    public int compareTo(Kerholainen kerholainen) {
	if (this.pituus == kerholainen.getPituus()) {
	    return 0;
	} else if (this.pituus > kerholainen.getPituus()) {
	    return 1;
	} else {
	    return -1;
	}
    }
}

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

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

Kerholaisten järjestäminen on nyt suoraviivaista.

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

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

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

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

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

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

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

Useamman rajapinnan toteuttaminen

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

package sovellus.domain;

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

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

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

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

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

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

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

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

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

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

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

Nyt ratkaisu toimii!

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

public class Tuote implements Tunnistettava {

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

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

    public String getTunnus() {
        return viivakoodi;
    }

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

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

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

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

Muutama NetBeans-vihje
  • Implement all abstract methods

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

    public class Luokka implements Rajapinta {
    }
    

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

  • Clean and Build

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

Oletusmetodit rajapinnoissa

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

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

public interface Luettava {
    String lue();

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

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

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

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

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

Järjestäminen ja hakeminen

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

Järjestäminen

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

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

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

System.out.println();

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

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

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

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

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

System.out.println();

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

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

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

matti (187)

Tee ohjelma, joka lukee käyttäjältä kirjoja ja niiden minimikohdeikiä. Minimikohdeiällä tarkoitetaan pienintä ikää vuosina, jolle kyseistä kirjaa suositellaan.

Ohjelma kysyy uusia kirjoja kunnes käyttäjä syöttää tyhjän merkkijonon kirjan nimen kohdalla (eli painaa rivinvaihtoa). Täämän jälkeen ohjelma tulostaa syötettyjen kirjojen lukumäärän sekä kirjat.

Kirjojen lukeminen ja tulostaminen

Toteuta ensin kirjojen lukeminen ja niiden listaaminen. Tässä vaiheessa kirjojen järjestyksellä ei ole vielä väliä.

Syötä kirjan nimi, tyhjä lopettaa: Soiva tuutulaulukirja
Syötä kirjan pienin kohdeikä: 0

Syötä kirjan nimi, tyhjä lopettaa: Kurkkaa kulkuneuvot
Syötä kirjan pienin kohdeikä: 0
    
Syötä kirjan nimi, tyhjä lopettaa: Lunta tupaan
Syötä kirjan pienin kohdeikä: 12
    
Syötä kirjan nimi, tyhjä lopettaa: Litmanen 10
Syötä kirjan pienin kohdeikä: 10
    
Syötä kirjan nimi, tyhjä lopettaa:
        
Yhteensä 4 kirjaa.
    
Kirjat:
Soiva tuutulaulukirja (0 vuotiaille ja vanhemmille)
Kurkkaa kulkuneuvot (0 vuotiaille ja vanhemmille)
Lunta tupaan (12 vuotiaille ja vanhemmille)
Litmanen 10 (10 vuotiaille ja vanhemmille)

Kirjojen järjestäminen kohdeiän perusteella

Täydennä toteuttamaasi ohjelmaa siten, että kirjat järjestetään tulostuksen yhteydessä kohdeiän perusteella. Jos kahdella kirjalla on sama kohdeikä, näiden kahden kirjan keskinäinen järjestys saa olla mielivaltainen.

Syötä kirjan nimi, tyhjä lopettaa: Soiva tuutulaulukirja
Syötä kirjan pienin kohdeikä: 0

Syötä kirjan nimi, tyhjä lopettaa: Kurkkaa kulkuneuvot
Syötä kirjan pienin kohdeikä: 0
    
Syötä kirjan nimi, tyhjä lopettaa: Lunta tupaan
Syötä kirjan pienin kohdeikä: 12
    
Syötä kirjan nimi, tyhjä lopettaa: Litmanen 10
Syötä kirjan pienin kohdeikä: 10
    
Syötä kirjan nimi, tyhjä lopettaa:
    
Yhteensä 4 kirjaa.
    
Kirjat:
Soiva tuutulaulukirja (0 vuotiaille ja vanhemmille)
Kurkkaa kulkuneuvot (0 vuotiaille ja vanhemmille)
Litmanen 10 (10 vuotiaille ja vanhemmille)
Lunta tupaan (12 vuotiaille ja vanhemmille)

Kirjojen järjestäminen kohdeiän ja nimen perusteella

Täydennä edellistä ohjelmaasi siten, että saman kohdeiän kirjat tulostetaan aakkosjärjestyksessä.

Syötä kirjan nimi, tyhjä lopettaa: Soiva tuutulaulukirja
Syötä kirjan pienin kohdeikä: 0

Syötä kirjan nimi, tyhjä lopettaa: Kurkkaa kulkuneuvot
Syötä kirjan pienin kohdeikä: 0
    
Syötä kirjan nimi, tyhjä lopettaa: Lunta tupaan
Syötä kirjan pienin kohdeikä: 12
    
Syötä kirjan nimi, tyhjä lopettaa: Litmanen 10
Syötä kirjan pienin kohdeikä: 10
    
Syötä kirjan nimi, tyhjä lopettaa:
    
Yhteensä 4 kirjaa.
    
Kirjat:
Kurkkaa kulkuneuvot (0 vuotiaille ja vanhemmille)
Soiva tuutulaulukirja (0 vuotiaille ja vanhemmille)
Litmanen 10 (10 vuotiaille ja vanhemmille)
Lunta tupaan (12 vuotiaille ja vanhemmille)

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

Tavara ja Esine

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

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

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

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

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

Esine vertailtavaksi

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

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

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

Muuttolaatikko

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

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

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

Esineiden pakkaaminen

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

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

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

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

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

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

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

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

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

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

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

Binäärihaku ja hakeminen

Binäärihaku (tunnetaan myös nimellä puolitushaku) etsii annettua arvoa järjestyksessä olevasta listasta. Tutustutaan algoritmin ideaa seuraavan järjestyksessä olevan listan avulla.

// indeksit   0   1   2   3    4   5    6   7   8   9  10
// luvut     -7  -3   3   7   11  15   17  21  24  28  30

Oletetaan että haluamme löytää luvun 17 indeksin. Hyödynnetään tietoa siitä että arvot ovat järjestyksessä. Sen sijaan, että kävisimme lukuja läpi alusta lähtien, tarkastelemme arvoa listan puolivälissä. Listan puolivälissä olevan alkion indeksi on isoin indeksi 10 jaettuna kahdella eli 5. Keskimmäinen alkio on merkattu seuraavaan tähdellä:

                                   *
// indeksit   0   1   2   3    4   5    6   7   8   9  10
// luvut     -7  -3   3   7   11  15   17  21  24  28  30

Puolessa välissä on luku 15, joka ei ollut hakemamme luku (eli luku 17). Koska taulukko on järjestyksessä (tässä suuruusjärjestyksessä), ei etsitty luku voi missään tapauksessa olla luvun 15 vasemmalla puolella. Voimme siis päätellä että kaikki indeksit, jotka ovat pienempiä tai yhtäsuuria kuin 5, eivät missään nimessä sisällä hakemaamme arvoa.

Alue, jolta etsimme haettavaa lukua voidaan nyt rajata lukuihin, jotka sijaitsevat indeksin 5 oikealla puolella, eli indekseihin välillä [6, 10] (6, 7, 8, 9, 10). Seuraavassa on merkitty harmaalla se osa taulukkoa jossa etsitty ei voi olla:

// indeksit    0   1   2   3   4    5    6   7   8   9  10
// luvut      -7  -3   3   7  11   15   17  21  24  28  30

Tutkitaan seuraavaksi jäljellä olevan etsintäalueen, eli indeksien 6-10 keskimmäistä indeksiä. Keskimmäinen indeksi löytyy laskemalla etsintäalueen pienimmän ja suurimman indeksin summan ja jakamalla se kahdella, eli (6+10)/2 = 16/2 = 8. Indeksi 8 on merkitty alle tähdellä.

                                                 *
// indeksit    0   1   2   3   4    5    6   7   8   9  10
// luvut      -7  -3   3   7  11   15   17  21  24  28  30

Indeksissä 8 oleva luku on 24, joka ei ollut hakemamme luku. Koska luvut taulukossa ovat suuruusjärjestyksessä, ei etsittävä luku voi missään nimessä olla luvun 24 oikealla puolella. Voimme siis päätellä että kaikki indeksit, jotka ovat suurempia tai yhtäsuuria kuin 8, eivät missään nimessä sisällä hakemaamme arvoa. Etsintäalue rajautuu taas:

// indeksit    0   1   2   3   4    5    6   7   8   9  10
// luvut      -7  -3   3   7  11   15   17  21  24  28  30

Etsintä jatkuu. Tutkitaan jäljellä olevan etsintäalueen, eli indeksien 6-7, keskimmäistä indeksiä. Keskimmäinen indeksi löytyy taas ottamalla etsintäalueen pienimmän ja suurimman indeksin summa ja jakamalla se kahdella, eli (6+7)/2 = 6,5, joka pyöristyy alaspäin luvuksi 6. Kohta on merkitty alle tähdellä.

                                         *
// indeksit    0   1   2   3   4    5    6   7   8   9  10
// luvut      -7  -3   3   7  11   15   17  21  24  28  30

Indeksissä 6 on luku 17, joka on sama kuin hakemamme luku. Voimme lopettaa haun ja ilmoittaa että etsitty luku on taulukossa. Jos luku ei olisi ollut taulukossa -- esimerkiksi jos haettava luku olisi ollut 16, etsintäalue olisi jäänyt lopulta tyhjäksi.

                                         *
// indeksit    0   1   2   3   4    5    6   7   8   9  10
// luvut      -7  -3   3   7  11   15   17  21  24  28  30

Simuloi kynällä ja paperilla miten binäärihaku toimii kun taulukkona on alla oleva taulukko ja haet ensin lukua 33, sitten lukua 1.

// indeksit   0   1   2   3   4   5   6   7   8   9  10  11  12  13
// luvut     -5  -2   3   5   8  11  14  20  22  26  29  33  38  41
Binäärihaku vs. Peräkkäishaku

Peräkkäishaun pahimmassa tapauksessa käydään kaikki taulukon arvot läpi. Miljoona alkiota sisältävässä taulukossa tämä tarkoittaa miljoonan alkion tarkastelua.

Binäärihaun pahimmassa tapauksessa tutkittava alue jaetaan kahteen osaan kunnes osan koko on yksi. Alkioita tarkastellaan huomattavasti vähemmän kuin peräkkäishaussa. Tarkastellaan tätä hieman tarkemmin.

Lista, jossa on 16 alkiota, voidaan jakaa kahteen osaan korkeintaan 4 kertaa, eli 16 -> 8 -> 4 -> 2 -> 1.

Toisaalta, lista, jossa on miljoona alkiota voidaan jakaa kahteen osaan korkeintaa 20 kertaa, eli 1000000 -> 500000 -> 250000 -> 125000 -> 62500 -> 31250 -> 15625 -> ~7813 -> ~3907 -> 1954 -> ~977 -> ~489 -> ~245 -> ~123 -> ~62 -> ~31 -> ~16 -> ~8 -> ~4 -> ~2 -> ~1.

Mitä tämä tarkoittaa? Binäärihakua käyttäen miljoona alkiota sisältävästä listasta tulee pahimmassa tapauksessa tarkastella noin kahtakymmentä alkiota, kun peräkkäishaussa tarkasteltavia alkioita on miljoona.

Koska haettavien alkioiden määrä puolittuu binäärihaussa jokaisen tarkastelun yhteydessä, voi binäärihaun tehokkuutta tarkastella kaksikantaisen logaritmin avulla. Kaksikantainen logaritmi (log2) annetusta luvusta kertoo kuinka monta kertaa luku voidaan puolittaa. Esimerkiksi kaksikantainen logaritmi luvusta 16777216 (log2 16777216) on 24, ja luvun 4294967296 kaksikantainen logaritmi, (log2 4294967296) on 32. Tämä tarkoittaa että 4294967296 eri arvoa sisältävästä järjestyksessä olevasta listasta hakeminen vaatisi binäärihaulta korkeintaan 32 eri alkion tarkastamista.

Collections-luokkakirjasto tarjoaa valmiiksi toteutetun binäärihakualgoritmin. Kerholainen-luokkamme vertaa pituuksia compareTo()-metodissaan, eli listasta etsiessä etsisimme samanpituista kerholaista.

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

Collections.sort(kerholaiset);

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

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

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

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

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

Olioiden monimuotoisuus

Olemme aiemmissa osissa törmänneet tilanteisiin, joissa viittaustyyppisillä muuttujilla on oman tyyppinsä lisäksi muita tyyppejä. Esimerkiksi kaikki oliot ovat tyyppiä Object, eli mikä tahansa olio voidaan oman tyyppinsä lisäksi esittää Object-tyyppisenä muuttujana.

String merkkijono = "merkkijono";
Object merkkijonoString = "toinen merkkijono";
String merkkijono = "merkkijono";
Object merkkijonoString = merkkijono;

Yllä olevissa esimerkeissä merkkijonomuuttuja esitetään sekä String-tyyppisenä että Object-tyyppisenä, jonka lisäksi String-tyyppinen muuttuja asetetaan Object-tyyppiseen muuttujaan. Asetus toiseen suuntaan, eli Object-tyyppisen muuttujan asettaminen String-tyyppiseksi ei kuitenkaan onnistu. Tämä johtuu siitä, että Object-tyyppiset muuttujat eivät ole tyyppiä String

Object merkkijonoString = "toinen merkkijono";
String merkkijono = merkkijonoString; // EI ONNISTU!

Mistä tässä oikein on kyse?

Jokainen muuttuja voidaan esittää muuttujan alkuperäisen tyypin lisäksi myös muuttujan toteuttamien rajapintojen sekä perimien luokkien tyyppisenä. Luokka String perii luokan Object, 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.

Kuvakaappaus String-luokan API-dokumentaatiosta. Kuvakaappauksessa näkyy, että String-luokka perii luokan Object.

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. Toisaalta, perittävä luokka on voinut periä toisen luokan, joten välillisesti luokka voi periä useampia luokkia.

Perintähierarkiaa voi ajatella myös listana tyypeistä, joita olio toteuttaa.

Tieto siitä, että oliot voivat olla montaa eri tyyppiä -- esimerkiksi tyyppiä Object -- suoraviivaistaa ohjelmointia. Jos tarvitsemme metodissa vain Object-luokassa määriteltyjä metodeja kuten toString, equals ja hashCode, voimme käyttää metodin parametrina tyyppiä Object. Tällöin metodille voi antaa parametrina minkä tahansa olion. Tarkastellaan tätä metodin tulostaMonesti avulla. Metodi 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());
            // tai System.out.println(object);
        }
    }
...
}

Metodille voi antaa parametrina minkä tahansa olion. Metodin tulostaMonesti sisällä oliolla on käytössään vain Object-luokassa määritellyt metodit, koska olio tunnetaan metodissa Object-tyyppisenä. Todellisuudessa olio voi olla myös toisen tyyppinen.

Tulostin tulostin = new Tulostin();

String merkkijono = " o ";
List<String> sanat = new ArrayList<>();
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.

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

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

Ryhmittely hajautustaulun avulla

Kerrataan osan lopuksi vielä hieman hajautustaulun toimintaa sekä ryhmittelyä. Hajautustaulu sisältää korkeintaan yhden arvon yhtä avainta kohti. Alla luodaan henkilöiden puhelinnumeroita hajautustauluun.

Map<String, String> puhelinnumerot = new HashMap<>();
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"));
Pekan numero: 040-12348765
Pekan numero: 09-111333

Useampi arvo yhdelle avaimelle

Entä jos haluaisimme liittää yhteen avaimeen useita arvoja, eli esimerkiksi useampia puhelinnumeroita yhdelle henkilölle?

Koska hajautustaulun avaimet ja arvot voivat olla mitä tahansa muuttujia, listojen käyttäminen hajautustaulun arvona onnistuu. Useamman arvon lisääminen yhdelle arvolle onnistuu liittämällä avaimeen lista. Muutetaan puhelinnumeroiden talletustapaa seuraavasti:

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

Nyt hajautustaulussa on jokaiseen avaimeen liitettynä lista. Vaikka new-komento luo hajautustaulun, ei hajautustaulu sisällä alussa yhtäkään listaa. Ne on luotava tarvittaessa erikseen.

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

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

// 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"));
Pekan numero: [040-12348765, 09-111333]

Määrittelimme muuttujan puhelinnumero tyypiksi Map<String, List<String>>. Tämä tarkoittaa hajautustaulua, joka käyttää avaimena merkkijonoa ja arvona merkkijonoja sisältävää listaa. Hajautustauluun lisättävät arvot ovat siis List<String>-rajapinnan toteuttavia konkreettisia olioita, eli esimerkiksi ArrayListejä.

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

// ...

Joukoista

Rajapinta Set kuvaa joukon toiminnallisuutta. Toisin kuin listalla, joukossa kutakin alkioita on 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<>();
    }

    public void lisaa(int tehtava) {
        this.tehdytTehtavat.add(tehtava);
    }

    public void tulosta() {
        this.tehdytTehtavat.stream().forEach(t -> System.out.println(t));
    }
}
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 eri käyttäjien tekemistä tehtävistä. Muutetaan sovelluksen toiminnallisuutta 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<>();
    }

    public void lisaa(String kayttaja, int tehtava) {
        // uudelle käyttäjälle on lisättävä HashMapiin tyhjä tehtäväjoukko jos sitä
        // ei ole jo lisätty
        this.tehdytTehtavat.putIfAbsent(kayttaja, new HashSet<>());

        // 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() {
        this.tehdytTehtavat.entrySet().stream(entry -> {
            System.out.println(entry.getKey() + ": " + entry.getValue());
        });
    }
}
Tehtavakirjanpito kirjanpito = new Tehtavakirjanpito();
kirjanpito.lisaa("Ada", 3);
kirjanpito.lisaa("Ada", 4);
kirjanpito.lisaa("Ada", 3);
kirjanpito.lisaa("Ada", 3);

kirjanpito.lisaa("Pekka", 4);
kirjanpito.lisaa("Pekka", 4);

kirjanpito.lisaa("Matti", 1);
kirjanpito.lisaa("Matti", 2);

kirjanpito.tulosta();
Matti: [1, 2]
Pekka: [4]
Ada: [3, 4]

Huomaamme että käyttäjien nimet eivät tulostu järjestyksessä. Tämä selittyy sillä että HashMap-tyyppisessä hajautustaulussa alkioiden tallennus tapahtuu hashCode-metodin palauttaman hajautusarvon perusteella. HashMap-hajautustaulu ei ota kantaa alkioiden järjestykseen.

Tehtävänäsi on toteuttaa pakkaukseen sanakirja luokka OmaUseanKaannoksenSanakirja, johon voidaan lisätä yksi tai useampi käännös jokaiselle sanalle. Luokan tulee toteuttaa tehtäväpohjassa annettu rajapinta UseanKaannoksenSanakirja, joka määrittelee seuraavat metodit:

  • 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-rajapinnan toteuttavan olion, jossa on kaikki käännökset sanalle. Jos sanalle ei ole yhtäkään käännöstä, metodin tulee palauttaa Set-olio, jossa ei ole yhtäkään alkiota
  • public void poista(String sana) poistaa sanan ja sen kaikki käännökset sanakirjasta.

Käännökset kannattanee lisätä Map<String, Set<String>>-tyyppiseen oliomuuttujaan.

Rajapinta UseanKaannoksenSanakirja:

package sanakirja;

import java.util.Set;

public interface UseanKaannoksenSanakirja {
    void lisaa(String sana, String kaannos);
    Set<String> kaanna(String sana);
    void poista(String sana);
}

Esimerkki:

UseanKaannoksenSanakirja sanakirja = new OmaUseanKaannoksenSanakirja();
sanakirja.lisaa("kuusi", "six");
sanakirja.lisaa("kuusi", "spruce");

sanakirja.lisaa("pii", "silicon");
sanakirja.lisaa("pii", "pi");

System.out.println(sanakirja.kaanna("kuusi"));
sanakirja.poista("pii");
System.out.println(sanakirja.kaanna("pii"));
[six, spruce]
[]

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

Rajapinta:

package tyokalut;

import java.util.Set;

public interface DuplikaattienPoistaja {
    void lisaa(String merkkijono);
    int getHavaittujenDuplikaattienMaara();
    Set<String> getUniikitMerkkijonot();
    void tyhjenna();
}

Käyttöesimerkki:

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 esimerkiksi seuraavaa: (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: []

Sama olio useammassa tietorakenteessa

Oliot ovat viittaustyyppisiä, eli muuttuja ei tallenna olioa itseään vaan viitteen. Tämä tarkoittaa myös sitä, että jos olio lisätään esimerkiksi listaan, listalle lisätään viite olioon. Mikään ei estä lisäämästä saman olion viitettä useampaan paikkaan.

Tarkastellaan esimerkkinä kirjastoa joka tallettaa kirjat hajautustauluihin sekä kirjailijan että kirjan isbn-numeron perusteella. Tämän lisäksi kirjasto pitää kirjaa lainassa olevista sekä hyllyssä olevista kirjoista erillisillä listoilla.

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<Kirja>> kirjatKirjailijanPerusteella;
    private List<Kirja> lainassaOlevatKirjat;
    private List<Kirja> hyllyssaOlevatKirjat;

    public Kirjasto() {
        this.kirjaIsbnNumeronPerusteella = new HashMap<>();
        this.kirjatKirjailijanPerusteella = new HashMap<>();
        this.lainassaOlevatKirjat = new ArrayList<>();
        this.hyllyssaOlevatKirjat = new ArrayList<>();
    }

    public void lisaaKirjaKokoelmaan(Kirja uusiKirja) {
        this.kirjaIsbnNumeronPerusteella.put(uusiKirja.getIsbn(), uusiKirja);

        this.kirjatKirjailijanPerusteella.putIfAbsent(uusiKirja.getKirjailija(), new ArrayList<>());
        this.kirjatKirjailijanPerusteella.get(uusikirja.getKirjailija()).add(uusiKirja);

        this.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 esimerkiksi kirja päätetään poistaa, on se poistettava kaikista paikoista, missä kirjaan on viite.

Huom: jotta testit toimisivat, ohjelmasi saa luoda vain yhden syötteen lukemiseen tarkoitetun Scanner-olion.

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

Sisällysluettelo