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

Viikko3

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

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

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

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

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

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

46.2 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!
    }
}
  

47 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
    public 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.getTunnus().compareTo(toinen.getTunnus());
    }
}
  

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.getTunnus().compareTo(toinen.getTunnus());
    }
}
  

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.

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.

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

125.2 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)]
      

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

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

48 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

48.1 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
  

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

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

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

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
}
      

127.1 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
      

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

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

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

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

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

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

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

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

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

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

129.2 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

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.

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

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

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

130.4 Palindromit

Tee luokkaan metodi public List<String> palindromit(), joka palauttaa tiedoston kaikki sanat, jotka ovat palindromeja. Tällaisia sanoja ovat esimerkiksi ala ja enne.

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

50 Hajautustauluista ja joukoista

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

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

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

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

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

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.