Tehtävät
Kuudennen osan tavoitteet

Tuntee käsitteen tiedosto. Osaa lukea tietoa erilaisista tietolähteistä (mm. tiedosto, verkko). Ymmärtää hajautustaulun periatteen. Osaa kirjoittaa hajautustaulua käyttäviä ohjelmia ja osaa luoda satunnaislukuja Javan avulla. Tuntee staattisten ja ei staattisten metodien erot ja tutustuu sovelluksen jakamiseen useampaan vastuualueeseen (tekstikäyttöliittymä, sovelluslogiikka).

Tiedostot ja tiedon lukeminen

Merkittävä osa ohjelmistoista perustuu tavalla tai toisella tiedon käsittelyyn. Musiikin toistoon tarkoitetut ohjelmistot käsittelevät musiikkitiedostoja, kuvankäsittelyohjelmat käsittelevät kuvatiedostoja. Verkossa ja mobiililaitteissa toimivat sovellukset kuten Facebook ja WhatsApp taas käsittelevät muunmuassa tietokantoihin tallennettuja henkilötietoja. Kaikissa näistä sovelluksista on yhteistä tiedon lukeminen, tiedon käsitteleminen tavalla tai toisella sekä se, että käsiteltävä tieto on loppujenlopulta tallennettu jonkinlaisessa muodossa yhteen tai useampaan tiedostoon.

Lukeminen näppäimistöltä

Olemme käyttäneet Scanner-luokkaa näppäimistöllä kirjoitetun syötteen lukemiseen kurssin alusta lähtien. Tiedon lukemiseen käytetty runko on while-true -toistolause, missä lukeminen lopetetaan tietynmuotoiseen syötteeseen.

// luodaan Scanner-olio, joka lukee näppäimistösyötettä
Scanner lukija = new Scanner(System.in);

// jatketaan syötteen lukemista kunnes käyttäjä syöttää
// rivin "loppu"
while (true) {
    String rivi = lukija.nextLine();

    if (rivi.equals("loppu")) {
        break;
    }

    // lisää luettu rivi myöhempää käsittelyä varten
    // tai käsittele rivi
}

// käsittele myöhempää käsittelyä varten lisätyt rivit

Yllä Scanner-luokan konstruktorille annetaan parametrina järjestelmän syöte (System.in). Tekstikäyttöliittymissä käyttäjän kirjoittama tieto ohjataan syötevirtaan rivi kerrallaan, eli tieto lähetetään käsiteltäväksi aina kun käyttäjä painaa rivinvaihtoa.

Tässä tehtävässä toteutetaan ohjelma kurssipistetilastojen tulostamiseen. Ohjelmalle syötetään pisteitä (kokonaislukuja nollasta sataan), ja ohjelma tulostaa niiden perusteella arvosanoihin liittyviä tilastoja. Syötteiden lukeminen lopetetaan kun käyttäjä syöttää luvun -1. Lukuja, jotka eivät ole välillä [0-100] ei tule ottaa huomioon tilastojen laskemisessa.

Muistathan, että käyttäjältä luetun merkkijonon saa muunnettua kokonaisluvuksi Integer-luokan metodilla parseInt. Tämä toimii seuraavasti:

String lukuMerkkijonona = "3";
int luku = Integer.parseInt(lukuMerkkijonona);

System.out.println(lukuMerkkijonona + 7);
System.out.println(luku + 7);
37
10

Pisteiden keskiarvot

Kirjoita ohjelma, joka lukee käyttäjältä kurssin yhteispisteitä kuvaavia kokonaislukuja. Luvut väliltä [0-100] ovat hyväksyttäviä ja luku -1 lopettaa syötteen. Muut luvut ovat virhesyötteitä, jotka tulee jättää huomiotta. Kun käyttäjä syöttää luvun -1, tulostetaan syötettyjen yhteispisteiden keskiarvo.

Syötä yhteispisteet, -1 lopettaa:
-42
24
42
72
80
52
-1
Pisteiden keskiarvo (kaikki): 54.0
Syötä yhteispisteet, -1 lopettaa:
50
51
52
-1
Pisteiden keskiarvo (kaikki): 51.0

Hyväksyttyyn arvosanaan liittyvien pisteiden keskiarvot

Täydennä ohjelmaa siten, että se laskee kaikkien pisteiden keskiarvon lisäksi myös hyväksyttyyn arvosanaan liittyvien pisteiden keskiarvot.

Hyväksytyn arvosanan saa vähintään 70 kurssipisteellä. Voit olettaa, että käyttäjä kirjoittaa aina vähintään yhden välillä [0-100] olevan kokonaisluvun. Jos hyväksyttyyn arvosanaan osuvia lukuja ei ole lainkaan, tulostetaan viiva hyväksyttyjen keskiarvon kohdalle "-".

Syötä yhteispisteet, -1 lopettaa:
-42
24
42
72
80
52
-1
Pisteiden keskiarvo (kaikki): 54.0
Pisteiden keskiarvo (hyväksytyt): 76.0
Syötä yhteispisteet, -1 lopettaa:
50
51
52
-1
Pisteiden keskiarvo (kaikki): 51.0
Pisteiden keskiarvo (hyväksytyt): -

Hyväksyttyjen prosenttiosuus

Täydennä edellisessä osassa toteuttamaasi ohjelmaa siten, että ohjelma tulostaa myös hyväksymisprosentin. Hyväksymisprosentti lasketaan kaavalla 100 * hyväksytyt / osallistujat.

Syötä yhteispisteet, -1 lopettaa:
50
51
52
-1
Pisteiden keskiarvo (kaikki): 51.0
Pisteiden keskiarvo (hyväksytyt): -
Hyväksymisprosentti: 0.0
Syötä yhteispisteet, -1 lopettaa:
102
-4
33
77
99
1
-1
Pisteiden keskiarvo (kaikki): 52.5
Pisteiden keskiarvo (hyväksytyt): 88.0
Hyväksymisprosentti: 50.0

Arvosanajakauma

Täydennä ohjelmaa siten, että ohjelma tulostaa myös arvosanajakauman. Arvosananajakauma muodostetaan seuraavasti.

pistemäärä arvosana
< 70 hylätty eli 0
< 76 1
< 81 2
< 86 3
< 91 4
>= 91 5

Jokainen koepistemäärä muutetaan arvosanaksi yllä olevan taulukon perusteella. Jos syötetty pistemäärä ei ole välillä [0-100], jätetään se huomiotta.

Arvosanajakauma tulostetaan tähtinä. Esim jos arvosanaan 5 oikeuttavia koepistemääriä on 1 kappale, tulostuu rivi 5: *. Jos johonkin arvosanaan oikeuttavia pistemääriä ei ole, ei yhtään tähteä tulostu, alla olevassa esimerkissä näin on mm. nelosten kohdalla.

Syötä yhteispisteet, -1 lopettaa:
102
-2
1
33
77
99
-1
Pisteiden keskiarvo (kaikki): 52.5
Pisteiden keskiarvo (hyväksytyt): 88.0
Hyväksymisprosentti: 50.0
Arvosanajakauma:
5: *
4:
3:
2: *
1:
0: **

Lukeminen tiedostosta

Tiedostot ovat tietokoneella sijaitsevia tietokokoelmia, jotka voivat sisältää vaikkapa tekstiä, kuvia, musiikkia tai niiden yhdistelmiä. Tiedoston tallennusmuoto määrittelee tiedoston sisällön sekä tavan tiedon lukemiseen. Esimerkiksi PDF-tiedostoja luetaan PDF-tiedostojen lukemiseen soveltuvalla ohjelmalla ja musiikkitiedostoja luetaan musiikkitiedostojen lukemiseen soveltuvalla ohjelmalla. Jokainen näistä ohjelmista on ihmisen luoma, ja ohjelman luoja tai luojat -- eli ohjelmoijat -- ovat osana työtään myös määritelleet tiedoston tallennusmuodon.

Tiedoston sijainti

Voit käydä tarkastelemassa NetBeansissa kaikkia projektiin liittyviä tiedostoja valitsemalla Files-välilehden. Jos tiedosto on projektin juuressa, saa sen auki File-olion avulla vain nimen perusteella. Jos taas tiedosto on jossain muualla, tulee myös sen polku kertoa.

Tässä tehtävässä ei ohjelmoida, vaan tutustutaan tiedoston luomiseen.

Luo tehtäväpohjan juurikansioon (samassa kansiossa mm. kansiot src ja test) tiedosto nimeltä tiedosto.txt. Muokkaa tiedostoa, ja kirjoita tiedoston ensimmäisen rivin alkuun viesti Hei maailma.

Tiedoston lukeminen tapahtuu tutun Scanner-luokan avulla. Kun Scanner-luokan avulla halutaan lukea tiedosto, annetaan luokan konstruktorille parametrina luettavaa tiedostoa kuvaava tiedosto-olio. Tämän jälkeen tiedostoa voi lukea kuten näppäimistöltä luettavaa syötettä. Lukeminen tapahtuu while-toistolauseella, jota jatketaan kunnes kaikki tiedoston rivit on luettu.

Alla olevassa esimerkissä luetaan tiedoston "tiedosto.txt" kaikki rivit, jotka lisätään ArrayList-listaan. Tiedostoja lukiessa voidaan kohdata virhetilanne, joten tiedoston lukeminen vaatii erillisen "yrittämisen" (try) sekä mahdollisen virheen kiinnioton (catch). Palaamme virhetilanteiden käsittelyyn ohjelmoinnin jatkokurssilla.

ArrayList<String> rivit = new ArrayList<>();

// luodaan lukija tiedoston lukemista varten
try (Scanner lukija = new Scanner(new File("tiedosto.txt"))) {

    // luetaan kaikki tiedoston rivit
    while (lukija.hasNextLine()) {
        rivit.add(lukija.nextLine());
    }
} catch (Exception e) {
    System.out.println("Virhe: " + e.getMessage());
}

// tee jotain luetuilla riveillä

Tehtäväpohjassa on valmiina toiminnallisuus vieraslistaohjelmaan, missä käyttäjän syöttämien nimien olemassaolo tarkistetaan vieraslistalta.

Ohjelmasta puuttuu kuitenkin toiminnallisuus vieraslistan lukemiseen. Muokkaa ohjelmaa siten, että vieraslistan nimet luetaan tiedostosta.

Minkä niminen tiedosto luetaan?
vieraslista.txt

Syötä nimiä, tyhjä rivi lopettaa.
Chuck Norris
Nimi ei ole listalla.
Jack Baluer
Nimi ei ole listalla.
Jack Bauer
Nimi on listalla.
Jack Bower
Nimi on listalla.

Kiitos!

Huom! Tehtäväpohjassa on mukana kaksi tiedostoa, nimet.txt ja toiset-nimet.txt, joiden sisällöt ovat seuravat. Älä muuta näiden tiedostojen sisältöä!

nimet.txt:

ada
arto
leena
testi

toiset-nimet.txt:

leo
jarmo
alicia

Tehtäväpohjassa tulee kaksi tekstitiedostoa: nimet.txt ja toiset-nimet.txt. Kirjoita ohjelma, joka kysyy ensin käyttäjältä luettavan tiedoston nimeä, jonka jälkeen käyttäjältä kysytään etsittävää merkkijonoa. Tämän jälkeen ohjelma lukee tiedoston ja etsii tiedostosta haluttua merkkijonoa.

Jos merkkijono löytyy, ohjelman tulee tulostaa "Löytyi!". Jos merkkijonoa ei löydy, ohjelman tulee tulostaa "Ei löytynyt.". Jos tiedoston lukeminen epäonnistuu (lukeminen päätyy virhetilanteeseen), ohjelman tulee tulostaa viesti "Tiedoston lukeminen epäonnistui.".

Minkä niminen tiedosto luetaan?
nimet.txt
Mitä etsitään?
Antti
Ei löytynyt.
Minkä niminen tiedosto luetaan?
nimet.txt
Mitä etsitään?
ada
Löytyi!
Minkä niminen tiedosto luetaan?
olematon.txt
Mitä etsitään?
testi
Tiedoston olematon.txt lukeminen epäonnistui.

Toteuta ohjelma, joka lukee käyttäjältä tiedoston nimen sekä hyväksyttävien lukujen ala- ja ylärajan. Tämän jälkeen ohjelma lukee tiedoston sisältämät luvut (jokainen luku on omalla rivillään) ja ottaa huomioon vain ne luvut, jotka ovat annetulla lukuvälillä. Lopulta ohjelma tulostaa annetulla lukuvälillä olleiden lukujen lukumäärän.

Tiedosto? mittaukset-1.txt
Alaraja? 15
Yläraja? 20
Lukuja: 2
Tiedosto? mittaukset-1.txt
Alaraja? 0
Yläraja? 300
Lukuja: 4

Huom! Tehtäväpohjassa on mukana kaksi tiedostoa, mittaukset-1.txt ja mittaukset-2.txt, joiden sisällöt ovat seuravat. Älä muuta näiden tiedostojen sisältöä.

mittaukset-1.txt:

300
9
20
15

mittaukset-2.txt:

123
-5
12
67
-300
1902

Monimutkaisemman tiedon lukeminen

Edellisessä esimerkissä sekä sitä seuranneissa tehtävissä tiedoston sisältö käsiteltiin riveittäin lukuina tai merkkijonoina. Mikäli tiedosto noudattaa jonkinlaista ennalta määrättyä rakennetta ja sen sisältämä tieto liittyy konkreettiseen käsitteeseen, voidaan luetut rivit muuttaa myös olioiksi.

Oletetaan, että käytössämme on seuraavaa muotoa noudattava reseptejä sisältävä tiedosto. Tiedosto sisältää aina ensin reseptin nimen, jota seuraa reseptiin liittyvät raaka-aineet. Raaka-aineita seuraa tyhjä rivi, jonka jälkeen alkaa uusi resepti. Tiedoston muoto on siis seuraava

Reseptin 1 nimi
reseptin 1 raaka-aine 1
reseptin 1 raaka-aine 2

Reseptin 2 nimi
reseptin 2 raaka-aine 1
reseptin 2 raaka-aine 2
reseptin 2 raaka-aine 3
reseptin 2 raaka-aine 4

Reseptin 3 nimi
reseptin 3 raaka-aine 1
reseptin 3 raaka-aine 2
reseptin 3 raaka-aine 3
reseptin 3 raaka-aine 4

Käsitettä Resepti voidaan luoda kuvaamaan seuraavanlainen luokka, joka sisältää sekä nimen että listan raaka-aineita.

import java.util.ArrayList;

public class Resepti {
    private String nimi;
    private ArrayList<String> raakaAineet;

    public Resepti(String nimi) {
        this.nimi = nimi;
        this.raakaAineet = new ArrayList<>();
    }

    public void lisaaRaakaAine(String raakaAine) {
        this.raakaAineet.add(raakaAine);
    }

    public String toString() {
        String palautettava = this.nimi;
        for (String raakaAine: this.raakaAineet) {
            palautettava += "\n  " + raakaAine;
        }
        return palautettava;
    }
}
Resepti resepti = new Resepti("Lettutaikina");
resepti.lisaaRaakaAine("0.5 litraa maitoa");
resepti.lisaaRaakaAine("2 munaa");
resepti.lisaaRaakaAine("sopivasti jauhoa");
resepti.lisaaRaakaAine("0.5 tl suolaa");
resepti.lisaaRaakaAine("2 rkl sokeria");
resepti.lisaaRaakaAine("voita paistamiseen");

System.out.println(resepti);
Lettutaikina
  0.5 litraa maitoa
  2 munaa
  sopivasti jauhoa
  0.5 tl suolaa
  2 rkl sokeria
  voita paistamiseen

Nyt tiedoston lukeminen onnistuu seuraavasti.

ArrayList<Resepti> reseptit = new ArrayList<>();
Scanner lukija = new Scanner(System.in);
System.out.println("Mistä tiedostosta luetaan?");
String tiedosto = lukija.nextLine();

try (Scanner tiedostonLukija = new Scanner(new File(tiedosto))) {

    // luetaan reseptit ja raaka-aineet
    while (tiedostonLukija.hasNextLine()) {
        // luetaan resepti ja luodaan sitä vastaava olio
        String reseptinNimi = tiedostonLukija.nextLine();
        Resepti resepti = new Resepti(reseptinNimi);

        // lisätään resepti listalle
        reseptit.add(resepti);

        // lisätään reseptiin raaka-aineet
        while (tiedostonLukija.hasNextLine()) {
            String raakaAine = tiedostonLukija.nextLine();

            // reseptin raaka-aineet lopetetaan tyhjällä rivillä
            if(raakaAine.isEmpty()) {
                // poistutaan tästä while-toistolauseesta
                // (ulompi jatkaa)
                break;
            }

            resepti.lisaaRaakaAine(raakaAine);
        }

    }
} catch (Exception e) {
    System.out.println("Virhe: " + e.getMessage());
}

// tee jotain luetuilla resepteillä

Tässä tehtävässä tehdään ohjelma, joka tarjoaa käyttäjälle mahdollisuuden reseptien hakuun reseptin nimen, keittoajan tai raaka-aineen nimen perusteella. Ohjelman tulee lukea reseptit käyttäjän antamasta tiedostosta.

Jokainen resepti koostuu kolmesta tai useammasta rivistä reseptitiedostossa. Ensimmäisellä rivillä on reseptin nimi, toisella rivillä reseptin keittoaika (kokonaisluku), ja kolmas ja sitä seuraavat rivit kertovat reseptin raaka-aineet. Reseptin raaka-aineiden kuvaus päättyy tyhjään riviin. Tiedostossa voi olla useampia reseptejä. Alla kuvattuna esimerkkitiedosto.

Lettutaikina
60
maito
muna
jauho
sokeri
suola
voi

Lihapullat
20
jauheliha
muna
korppujauho

Tofurullat
30
tofu
riisi
vesi
porkkana
kurkku
avokado
wasabi

Ohjelma toteutetaan osissa. Ensin ohjelmaan luodaan mahdollisuus reseptien lukemiseen sekä listaamiseen. Tämän jälkeen ohjelmaan lisätään mahdollisuus reseptien hakemiseen nimen perusteella, keittoajan perusteella ja lopulta raaka-aineen perusteella.

Tehtäväpohjassa on mukana tiedosto reseptit.txt, jota voi käyttää sovelluksen testaamiseen. Huomaa, että ohjelman ei tule listata reseptien raaka-aineita, mutta niitä käytetään hakutoiminnallisuudessa.

Reseptien lukeminen ja listaaminen

Luo ohjelmaan ensin mahdollisuus reseptien lukemiseen sekä listaamiseen. Ohjelman käyttöliittymän tulee olla seuraavanlainen. Voit olettaa, että käyttäjä syöttää aina tiedoston, joka on olemassa. Alla oletetaan, että tehtävänannossa annetut esimerkkireseptit ovat tiedostossa reseptit.txt.

Mistä luetaan? reseptit.txt

Komennot:
listaa - listaa reseptit
lopeta - lopettaa ohjelman

Syötä komento: listaa

Reseptit:
Lettutaikina, keittoaika: 60
Lihapullat, keittoaika: 20
Tofurullat, keittoaika: 30

Syötä komento:  lopeta

Reseptien hakeminen nimen perusteella

Lisää ohjelmaan mahdollisuus reseptien hakemiseen nimen perusteella. Nimen perusteella hakeminen tapahtuu komennolla hae nimi, jonka jälkeen käyttäjältä kysytään merkkijonoa, jota etsitään reseptin nimistä. Hakutoiminnallisuuden tulee toimia siten, että se tulostaa kaikki ne reseptit, joiden nimessä esiintyy käyttäjän kirjoittama merkkijono.

Mistä luetaan? reseptit.txt

Komennot:
listaa - listaa reseptit
lopeta - lopettaa ohjelman
hae nimi - hakee reseptiä nimen perusteella

Syötä komento: listaa

Reseptit:
Lettutaikina, keittoaika: 60
Lihapullat, keittoaika: 20
Tofurullat, keittoaika: 30

Syötä komento: hae nimi
Mitä haetaan: rulla

Reseptit:
Tofurullat, keittoaika: 30

Syötä komento:  lopeta

Reseptien hakeminen keittoajan perusteella

Lisää seuraavaksi ohjelmaan mahdollisuus reseptien hakemiseen keittoajan perusteella. Keittoajan perusteella hakeminen tapahtuu komennolla hae keittoaika, jonka jälkeen käyttäjältä kysytään suurinta hyväksyttävää keittoaikaa. Hakutoiminnallisuuden tulee toimia siten, että se tulostaa kaikki ne reseptit, joiden keittoaika on pienempi tai yhtä suuri kuin käyttäjän syöttämä keittoaika.

Mistä luetaan? reseptit.txt

Komennot:
listaa - listaa reseptit
lopeta - lopettaa ohjelman
hae nimi - hakee reseptiä nimen perusteella
hae keittoaika - hakee reseptiä keittoajan perusteella

Syötä komento: hae keittoaika
Keittoaika korkeintaan: 30

Reseptit:
Lihapullat, keittoaika: 20
Tofurullat, keittoaika: 30

Syötä komento: hae keittoaika
Keittoaika korkeintaan: 15

Reseptit:

Syötä komento: hae nimi
Mitä haetaan: rulla

Reseptit:
Tofurullat, keittoaika: 30

Syötä komento:  lopeta

Reseptien hakeminen raaka-aineen perusteella

Lisää lopulta ohjelmaan mahdollisuus reseptien hakemiseen raaka-aineen perusteella. Raaka-aineen perusteella hakeminen tapahtuu komennolla hae aine, jonka jälkeen käyttäjältä kysytään merkkijonoa. Hakutoiminnallisuuden tulee toimia siten, että se tulostaa kaikki ne reseptit, joiden raaka-aineissa esiintyy käyttäjän antama merkkijono. Huomaa, että tässä annetun merkkijonon täytyy vastata täysin haettua raaka-ainetta (esim. "okeri" ei käy ole sama kuin "sokeri").

Mistä luetaan? reseptit.txt

Komennot:
listaa - listaa reseptit
lopeta - lopettaa ohjelman
hae nimi - hakee reseptiä nimen perusteella
hae keittoaika - hakee reseptiä keittoajan perusteella
hae aine - hakee reseptiä raaka-aineen perusteella

Syötä komento: hae keittoaika
Keittoaika korkeintaan: 30

Reseptit:
Lihapullat, keittoaika: 20
Tofurullat, keittoaika: 30

Syötä komento: hae aine
Mitä raaka-ainetta haetaan: sokeri

Reseptit:
Lettutaikina, keittoaika: 60

Syötä komento: hae aine
Mitä raaka-ainetta haetaan: muna

Reseptit:
Lettutaikina, keittoaika: 60
Lihapullat, keittoaika: 20

Syötä komento: hae aine
Mitä raaka-ainetta haetaan: una

Reseptit:

Syötä komento:  lopeta

Lukeminen verkkoyhteyden yli

Lähes kaikki verkkosivut, kuten tämäkin oppimateriaali, voidaan lukea tekstimuodossa ohjelmallista käsittelyä varten. Scanner-oliolle voi antaa konstruktorin parametrina lähes minkälaisen syötevirran tahansa. Alla olevassa esimerkissä luodaan URL-olio annetusta web-osoitteesta, pyydetään siihen liittyvää tietovirtaa, ja annetaan se uudelle Scanner-oliolle luettavaksi.

ArrayList<String> rivit = new ArrayList<>();

// luodaan lukija web-osoitteen lukemista varten
try (Scanner lukija = new Scanner(new URL("http://www.cs.helsinki.fi/home/").openStream())) {

    // luetaan osoitteesta http://www.cs.helsinki.fi/home/
    // saatava vastaus
    while (lukija.hasNextLine()) {
        rivit.add(lukija.nextLine());
    }
} catch (Exception e) {
    System.out.println("Virhe: " + e.getMessage());
}

// tehdään jotain vastauksella

Web-selain on oikeastaan ohjelma siinä missä muutkin ohjelmat. Toisin kuin yllä toteutettu sivun sisällön lataaja, web-selaimeen on toteutettu toiminnallisuus vastauksena tulevan HTML-muotoisen lähdekoodin tulkisemiseen ja graafisessa käyttöliittymässä näyttämiseen.

Osoitteessa http://www.icndb.com/api/ sijaitsee web-sovellus, joka tarjoaa Chuck Norris -vitsejä kaikkien vapaaseen käyttöön.

Sovellus tarjoaa muunmuassa mahdollisuuden satunnaisten vitsien hakemiseen (osoite http://api.icndb.com/jokes/random) sekä vitsien hakemiseen niihin liittyvillä numeerisilla tunnuksilla (osoite http://api.icndb.com/jokes/tunnus, missä tunnus on kokonaisluku).

Toteuta sovellus, joka tarjoaa kolme toimintoa. Jos käyttäjä kirjoittaa "lopeta", ohjelman suoritus lopetetaan. Jos käyttäjä kirjoittaa "satunnainen", ohjelma tulostaa icndb-palvelusta noudetun satunnaisen chuck norris vitsin. Jos käyttäjä kirjoittaa "vitsi numero", missä numero on kokonaisluku, ohjelma tulostaa icndb-palvelusta noudetun tietyn vitsin.

Huom! Tässä tehtävässä riittää tulostaa palvelun palauttama merkkijono kokonaisuudessaan. Merkkijono voi olla esimerkiksi muotoa { "type": "success", "value": { "id": 341, "joke": "Chuck Norris sleeps with a pillow under his gun.", "categories": [] } }.

Ohjelmassa ei ole testejä, eli testit eivät ota kantaa sovelluksen rakenteeseen tai tulostuksen ulkoasuun. Palauta sovellus kun se toimii koneellasi toivotulla tavalla.

Hajautustaulu (HashMap)

Hajautustaulu on eräs ohjelmoinnissa paljon käytetyistä tietorakenteista. Hajautustaulua käytetään kun halutaan käsitellä tietoa avain-arvo -pareina, missä avaimen perusteella voidaan lisätä, hakea ja poistaa avaimeen liittyvä arvo.

Alla olevassa esimerkissä on luotu HashMap-olio kaupunkien hakemiseen postinumeron perusteella, jonka jälkeen HashMap-olioon on lisätty neljä postinumero-kaupunki -paria. Sekä postinumero että kaupunki on esitetty merkkijonona.

HashMap<String, String> postinumerot = new HashMap<>();
postinumerot.put("00710", "Helsinki");
postinumerot.put("90014", "Oulu");
postinumerot.put("33720", "Tampere");
postinumerot.put("33014", "Tampere");
Hashmapissa avaimen perusteella saadaan selville arvo.

Hajautustaulua luodessa tarvitaan kaksi tyyppiparametria, avainmuuttujan tyyppi ja lisättävän arvon tyyppi. Kuten yllä, myös seuraavassa esimerkissä sekä avainmuuttujan että lisättävän arvon tyyppi on String.

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

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

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

Yllä olevassa esimerkissä luodaan hajatustaulu, jonka avaimena ja tallennettavana oliona on merkkijono. Hajautustauluun lisätään tietoa kaksiparametrisella metodilla put, jolle annetaan parametrina sekä avain- että arvomuuttuja.

Yksiparametrinen metodi get palauttaa parametrina annettuun avaimeen liittyvän viitteen tai null-viitteen jos avaimella ei löydy viitettä.

Hajautustaulussa on jokaista avainta kohden korkeintaan yksi arvo. Jos hajautustauluun lisätään uusi avain-arvo -pari, missä avain on jo aiemmin liittynyt toiseen hajautustauluun tallennettuun arvoon, vanha arvo katoaa hajautustaulusta.

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

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

System.out.println(numerot.get("Dos"));
System.out.println(numerot.get("Tres"));
System.out.println(numerot.get("Uno"));
Ein
Zwei
null
Ein

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

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

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

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

Viittaustyyppinen muuttuja hajautustaulussa

Tutkitaan hajautustaulun toimintaa kirjastoesimerkin avulla. Kirjastosta voi hakea kirjoja kirjan nimen perusteella. Jos haetulla nimellä löytyy kirja, palauttaa kirjasto kirjan viitteen. Luodaan ensin esimerkkiluokka Kirja, jolla on oliomuuttujina nimi, kirjaan liittyvä sisältö sekä kirjan julkaisuvuosi.

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

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

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

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

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

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

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

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

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

Luodaan seuraavaksi hajautustaulu, joka käyttää avaimena kirjan nimeä eli String-tyyppistä oliota, ja arvona edellä luomaamme kirjaa.

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

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

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

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

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

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

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

Hajautustauluun lisättäessä avain-arvo -parin arvo voi olla käytännössä mitä tahansa. Arvo voi olla kokonaisluku, lista, tai vaikkapa toinen hajautustaulu.

Hajautustaulu oliomuuttujana

Edellä kuvatun esimerkin ongelma on se, että kirjan kirjoitusmuoto tulee muistaa täsmälleen oikein. Joku saattaa etsiä kirjaa pienellä alkukirjaimella ja joku toinen saattaa vaikkapa painaa välilyöntiä nimen kirjoituksen aluksi. Tarkastellaan seuraavaksi erästä tapaa hieman sallivampaan kirjan nimen perusteella tapahtuvaan hakemiseen.

Hyödynnämme hakemisessa String-luokan tarjoamia välineitä merkkijonojen käsittelyyn. Metodi toLowerCase() luo merkkijonosta uuden merkkijonon, jonka kaikki kirjaimet on muunnettu pieniksi. Metodi trim() taas luo merkkijonosta uuden merkkijonon, jonka alusta ja lopusta on poistettu tyhjät merkit kuten välilyönnit.

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

Jos mietit "kuka kirjoittaisi välilyöntejä ja miksi?", etsi ja lue kirja Ender's Game (suom. Ender).

Luodaan luokka Kirjasto, joka kapseloi kirjat sisältävän hajautustaulun ja mahdollistaa kirjoitusasusta riippumattoman kirjojen haun. Lisätään luokalle Kirjasto metodit lisäämiseen, hakemiseen ja poistamiseen. Jokainen näistä tapahtuu siistityn nimen perusteella -- siistiminen sisältää nimen muuntamisen pienellä kirjoitetuksi sekä ylimääräisten alussa ja lopussa olevien välilyöntien poistamisen.

Huomaamme jo nyt että merkkijonon siistimiseen liittyvää koodia tarvitsisi jokaisessa kirjaa käsittelevässä metodissa, joten siitä on hyvä tehdä erillinen metodi.

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

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

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

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

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

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

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

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

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

Yllä käytetään hajautustaulun tarjoamaa metodia containsKey avaimen olemassaolon tarkastamiseen. Metodi palauttaa arvon true, jos hajautustauluun on lisätty haetulla avaimella mikä tahansa arvo, muulloin metodi palauttaa arvon false.

Edeltävässä esimerkissä noudatimme ns. DRY-periaatetta (Don't Repeat Yourself), jonka tarkoituksena on saman koodin toistumisen välttäminen. Merkkijonon siistiminen eli pienellä kirjoitetuksi muuttaminen sekä trimmaus, eli tyhjien merkkien poisto alusta ja lopusta, olisi toistunut useasti kirjastoluokassamme ilman metodia siistiMerkkijono. Toistuvaa koodia ei usein huomaa ennen kuin sitä on jo kirjoittanut, jolloin sitä päätyy koodiin lähes pakosti. Tässä ei ole mitään pahaa -- tärkeintä on että koodia siistitään sitä mukaa siistimistä vaativia tilanteita huomataan.

Hajautustaulun avainten läpikäynti

Haluamme joskus etsiä kirjaa nimen osan perusteella. Hajautustaulun metodi get ei tähän sovellu, sillä sitä käytetään tietyllä avaimella etsimiseen. Kirjan nimen osan perusteella etsiminen ei sillä onnistu.

Hajautustaulun arvojen läpikäynti hajautustaulun metodin keySet() palauttaman joukon avulla. Metodi keySet() palauttaa hajautustaulussa olevat avaimet tietokokoelmana, jonka voi käsitellä yksitellen for-each -lauseella.

Tarkastellaan tätä kirjastoesimerkin kautta.

Alla haetaan kaikki ne kirjat, joiden nimessä esiintyy annettu merkkijono.

public ArrayList<Kirja> haeKirjaNimenOsalla(String nimenOsa) {
    nimenOsa = siistiMerkkijono(nimenOsa);

    ArrayList<Kirja> kirjat = new ArrayList<>();

    for(String kirjanNimi : this.hakemisto.keySet()) {
        if(!kirjanNimi.contains(nimenOsa)) {
            continue;
        }

        // mikäli avain sisältää haetun merkkijonon, haetaan avaimeen
        // liittyvä arvo ja lisätään se palautettavien kirjojen joukkoon
        kirjat.add(this.hakemisto.get(kirjanNimi));
    }

    return kirjat;
}

Tällä tavalla etsiessä menetämme kuitenkin hajautustauluun liittyvän nopeusedun. Hajautustaulu on toteutettu siten, että yksittäisen avaimen perusteella hakeminen on erittäin nopeaa. Yllä olevassa esimerkissä käydään kaikkien kirjojen nimet läpi, kun tietyllä avaimella etsittäessä tarkasteltaisiin tasan yhden kirjan olemassaoloa.

Hajautustaulun arvojen läpikäynti

Edellä kuvatun toiminnallisuuden voisi toteuttaa myös hajautustaulun arvojen läpikäynnillä. Hajautustaulu arvojoukon saa hajautustaulun metodilla values(). Myös tämän arvojoukon voi käydä läpi for-each -lauseella.

public ArrayList<Kirja> haeKirjaNimenOsalla(String nimenOsa) {
    nimenOsa = siistiMerkkijono(nimenOsa);

    ArrayList<Kirja> kirjat = new ArrayList<>();

    for(Kirja kirja : this.hakemisto.values()) {
        if(!kirja.getNimi().contains(nimenOsa)) {
            continue;
        }

        kirjat.add(kirja);
    }

    return kirjat;
}

Kuten edellisessä esimerkissä, myös tällä tavalla etsiessä menetetään hajautustauluun liittyvä nopeusedun.

Alkeistyyppiset muuttujat hajautustaulussa

Hajautustaulu olettaa, että siihen lisätään viittaustyyppisiä muuttujia (samoin kuin ArrayList). Java muuntaa alkeistyyppiset muuttujat viittaustyyppisiksi käytännössä kaikkia Javan valmiita tietorakenteita (kuten ArrayList ja HashMap) käytettäessä. Vaikka luku 1 voidaan esittää alkeistyyppisen muuttujan int arvona, tulee sen tyypiksi määritellä Integer ArrayListissä ja HashMapissa.

HashMap<Integer, String> taulu = new HashMap<>(); // toimii
taulu.put(1, "Ole!");
HashMap<int, String> taulu2 = new HashMap<>(); // ei toimi

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

Alkeistyyppi Viittaustyyppinen vastine
int Integer
double Double
char Character

Java muuntaa alkeistyyppiset muuttujat automaattisesti viittaustyyppisiksi kun niitä lisätään HashMapiin tai ArrayListiin. Tätä automaattista muunnosta viittaustyyppisiksi kutsutaan Javassa auto-boxingiksi, eli automaattiseksi "laatikkoon" asettamiseksi. Automaattinen muunnos onnistuu myös toiseen suuntaan.

int avain = 2;
HashMap<Integer, Integer> taulu = new HashMap<>();
taulu.put(avain, 10);
int arvo = taulu.get(avain);
System.out.println(arvo);
10

Seuraava esimerkki kuvaa rekisterinumeroiden bongausten laskemiseen käytettävää luokkaa. Metodeissa metodeissa lisaaBongaus ja montakoKertaaBongattu tapahtuu automaattinen tyyppimuunnos.

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

    public Rekisteribongauslaskuri() {
        this.bongatut = new HashMap<>();
    }

    public void lisaaBongaus(String bongattu) {
        if (!this.bongatut.containsKey(bongattu)) {
            this.bongatut.put(bongattu, 0);
        }

        int montakobongausta = this.bongatut.get(bongattu);
        montakobongausta++;
        this.bongatut.put(bongattu, montakobongausta);
    }

    public int montakoKertaaBongattu(String bongattu) {
        this.bongatut.get(bongattu);
    }
}

Tyyppimuunnoksissa piilee kuitenkin vaara. Jos yritämme muuntaa null-viitettä -- eli esimerkiksi bongausta, jota ei ole HashMapissa -- kokonaisluvuksi, näemme virheen java.lang.reflect.InvocationTargetException. Kun teemme automaattista muunnosta, tulee varmistaa että muunnettava arvo ei ole null. Yllä olevassa ohjelmassa oleva montakoKertaaBongattu-metodi tulee korjata esimerkiksi seuraavasti.

public int montakoKertaaBongattu(String bongattu) {
    return this.bongatut.getOrDefault(bongattu, 0);
}

HashMapin metodi getOrDefault hakee sille ensimmäisenä parametrina annettua avainta HashMapista. Jos avainta ei löydy, palauttaa se toisena parametrina annetun arvon. Metodin toiminta vastaa seuraavaa metodia.

public int montakoKertaaBongattu(String bongattu) {
    if (this.bongatut.containsKey(bongattu) {
        return this.bongatut.get(bongattu);
    }

    return 0;
}

Siistitään vielä lisaaBongaus-metodia hieman. Alkuperäisessä versiossa metodin alussa lisätään hajautustauluun bongausten lukumääräksi arvo 0, jos bongattua ei löydy. Tämän jälkeen bongausten määrä haetaan, sitä kasvatetaan yhdellä, ja vanha bongausten lukumäärä korvataan lisäämällä arvo uudestaan hajautustauluun. Osan tästäkin toiminnallisuudesta voi korvata metodilla getOrDefault.

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

    public Rekisteribongauslaskuri() {
        this.bongatut = new HashMap<>();
    }

    public void lisaaBongaus(String bongattu) {
        int montakobongausta = this.bongatut.getOrDefault(bongattu, 0);
        montakobongausta++;
        this.bongatut.put(bongattu, montakobongausta);
    }

    public int montakoKertaaBongattu(String bongattu) {
        return this.bongatut.getOrDefault(bongattu, 0);
    }
}

Luo luokka Velkakirja, jolla on seuraavat toiminnot:

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

Luokkaa käytetään seuraavalla tavalla:

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

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

Yllä oleva esimerkki tulostaisi:

51.5
0.0

Ole tarkkana tilanteessa, jossa kysytään velattoman ihmisen velkaa.

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

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

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

CrowdSorcerer ja HashMap

Tässä kohtaa kertaat hajautustauluja ja pääset taas pohtimaan tehtävää tulevia sukupolvia varten. Jos et ole CrowdSorcereria aiemmin, käy katsomassa CrowdSorcererin opasvideo toisen osan materiaalista.

Suunnittele oma tehtävä: HashMap

Keksi tehtävä, jossa käytetään HashMappia. Tehtäväpohjassa on valmiina komennon kysyminen ja toistolause, joka jatkuu kunnes ohjelman käyttäjä kirjoittaa komennon "lopeta".

Huom! Tässä sinun täytyy syöttää jokaiselle testitapaukselle useampi syöte. Useamman syötteen saat annettua, kun laitat rivinvaihdon \n jokaisen syötteen väliin. Lisäksi lopeta jokainen testisyöte tekstillä lopeta, jotta testissä silmukan suoritus lakkaa.

Esimerkiksi jos haluat antaa testisyötteeksi "kissa", "koira", "lopeta", syötä input-kenttään teksti kissa\nkoira\nlopeta.

Muista merkitä malliratkaisurivit ohjelmaan -- näin ratkaisu ei tule suoraan käyttäjälle näkyvään.

Satunnaisuus ohjelmissa

Satunnaisuutta tarvitaan esimerkiksi salausalgoritmeissa, koneoppimisessa sekä tietokonepelien ennustettavuuden vähentämisessä. Satunnaisuutta mallinnetaan käytännössä satunnaislukujen avulla, joiden luomiseen Java tarjoaa valmiin Random-luokan. Random-luokasta voi tehdä olion jota voi käyttää seuraavalla tavalla.

import java.util.Random;

public class Arvontaa {
    public static void main(String[] args) {
        Random arpoja = new Random(); // luodaan apuväline arpoja
        int i = 0;

        while (i < 10) {
            // Arvotaan ja tulostetaan jokaisella kierroksella satunnainen luku
            int luku = arpoja.nextInt(10);
            System.out.println(luku);
            i++;
        }
    }
}

Yllä olevassa koodissa luodaan ensin Random-luokasta olio käyttäen avainsanaa new -- samoin kuin muitakin olioita luodessa. Random-olio tarjoaa metodin nextInt, jolle annetaan parametrina kokonaisluku. Metodi palauttaa satunnaisen kokonaisluvun väliltä 0..(annettu kokonaisluku - 1).

Ohjelman tulostus voisi olla vaikka seuraavanlainen:

2
2
4
3
4
5
6
0
7
8

Satunnaisia kokonaislukuja voidaan käyttää esimerkiksi nopanheittojen mallintamiseen.

Tehtäväpohjassa on luokka Noppa, jonka runko on seuraava:

import java.util.Random;

public class Noppa {
    private Random random;
    private int tahkojenMaara;

    public Noppa(int tahkojenMaara) {
        this.random = new Random();
        // Alusta oliomuuttuja tahkojenMaara tässä
    }

    public int heita() {
        // arvo täällä luku jonka tulee olla yhdestä tahkojen määrään
        // ja palauta se
    }
}

Muokkaa luokkaa siten, että sen konstruktoriNoppa(int tahkojenMaara) luo uuden noppa-olion annetulla nopan tahkojen (eri oman numeronsa sisältämien "puolien") määrällä. Muokkaa myös metodia heita siten, että se antaa satunnaisen nopanheiton tuloksen, jonka arvon tulee olla väliltä 1...tahkojen määrä (vinkki: plus!).

Seuraavassa noppaa testaava pääohjelma:

public class Ohjelma {
    public static void main(String[] args) {
        Noppa noppa = new Noppa(6);

        int i = 0;
        while (i < 10) {
            System.out.println(noppa.heita());
            i++;
        }
    }
}

Tulostus voisi olla esimerkiksi seuraava:

1
6
3
5
3
3
2
2
6
1

Random-luokasta tehdyn olion kautta päästään käsiksi myös satunnaisiin liukulukuihin, joita käytetään muunmuassa todennäköisyyslaskennan yhteydessä; tietokoneilla todennäköisyyksiä simuloidaan yleensä väliltä [0..1] olevilla luvuilla.

Random-oliolta satunnaisia liukulukuja saa metodilla nextDouble. Tarkastellaan seuraavia säämahdollisuuksia:

  • Sataa räntää todennäköisyydellä 0.1 (10%)
  • Sataa lunta todennäköisyydellä 0.3 (30%)
  • Aurinko paistaa todennäköisyydellä 0.6 (60%)

Luodaan edellä olevista arvioista sääennustaja.

import java.util.ArrayList;
import java.util.Random;

public class SaaEnnustaja {
    private Random random;

    public SaaEnnustaja() {
        this.random = new Random();
    }

    public String ennustaSaa() {
        double todennakoisyys = this.random.nextDouble();

        if (todennakoisyys <= 0.1) {
            return "Sataa räntää";
        } else if (todennakoisyys <= 0.4) { // 0.1 + 0.3
            return "Sataa lunta";
        } else { // loput, 1.0 - 0.4 = 0.6
            return "Aurinko paistaa";
        }
    }

    public int ennustaLampotila() {
        return (int) (4 * this.random.nextGaussian() - 3);
    }
}

Metodi ennustaLampotila on monella tapaa mielenkiintoinen. Metodin sisällä tehtävä kutsu this.random.nextGaussian() on tavallinen metodikutsu, jonka kaltaisia olemme nähneet aikaisemminkin. Kiinnostavaa tässä Random-luokan ilmentymän tarjoamassa metodissa on se, että metodin palauttama luku on normaalijakautunut (jos et koe mielenkiintoa satunnaisuuden eri lajeihin se ei haittaa!).

public int ennustaLampotila() {
    return (int) (4 * this.random.nextGaussian() - 3);
}

Edellisessä esimerkissä käytetään eksplisiittistä tyyppimuunnosta liukulukujen muuntamiseen kokonaisluvuiksi (int). Vastaavalla menetelmällä voidaan muuttaa myös kokonaislukuja liukuluvuiksi kirjoittamalla (double) kokonaisluku

Luodaan vielä pääohjelma josta luokkaa SaaEnnustaja käytetään.

public class Ohjelma {

    public static void main(String[] args) {
        SaaEnnustaja ennustaja = new SaaEnnustaja();

        // tallennetaan päivät listalle
        ArrayList<String> paivat = new ArrayList<>();
        paivat.add("Ma");
        paivat.add("Ti");
        paivat.add("Ke");
        paivat.add("To");
        paivat.add("Pe");
        paivat.add("La");
        paivat.add("Su");

        System.out.println("Seuraavan viikon sääennuste:");

        for (String paiva: paivat) {
            String saaEnnuste = ennustaja.ennustaSaa();
            int lampotilaEnnuste = ennustaja.ennustaLampotila();

            System.out.println(paiva + ": " + saaEnnuste + " " + lampotilaEnnuste + " astetta.");
        }
    }
}

Ohjelman tulostus voisi olla esimerkiksi seuraavanlainen:

Seuraavan viikon sääennuste:
Ma: Sataa lunta 1 astetta.
Ti: Sataa lunta 1 astetta.
Ke: Aurinko paistaa -2 astetta.
To: Aurinko paistaa 0 astetta.
Pe: Sataa lunta -3 astetta.
La: Sataa lunta -3 astetta.
Su: Aurinko paistaa -5 astetta

Tehtävänäsi on täydentää luokkaa Lottorivi, joka arpoo viikon lottonumerot. Lottonumerot ovat väliltä 1–40 ja niitä arvotaan 7. Lottorivi koostuu siis seitsemästä eri numerosta väliltä 1–40. Luokassa on seuraavat toiminnot:

  • konstruktori Lottorivi luo uuden Lottorivi-olion joka sisältää uudet, arvotut numerot
  • metodi numerot palauttaa tämän lottorivin lottonumerot
  • metodi sisaltaaNumeron kertoo onko arvotuissa numeroissa annettu numero
  • metodi arvoNumerot arpoo riville uudet numerot

Luokan runko on seuraava:

import java.util.ArrayList;
import java.util.Random;

 public class LottoRivi {
    private ArrayList<Integer> numerot;

    public LottoRivi() {
        // Arvo numerot heti LottoRivin luomisen yhteydessä
        this.arvoNumerot();
    }

    public ArrayList<Integer> numerot() {
        return this.numerot;
    }

    public boolean sisaltaaNumeron(int numero) {
        // Testaa tässä onko numero jo arvottujen numeroiden joukossa
    }

    public void arvoNumerot() {
        // Alustetaan lista numeroille
        this.numerot = new ArrayList<>();
        // Kirjoita numeroiden arvonta tänne käyttämällä metodia sisaltaaNumeron()
    }
}

Tehtäväpohjan mukana tulee seuraava pääohjelma:

import java.util.ArrayList;

public class Ohjelma {
    public static void main(String[] args) {
        Lottorivi rivi = new Lottorivi();
        ArrayList<Integer> lottonumerot = rivi.numerot();

        System.out.println("Lottonumerot:");
        for (int numero: lottonumerot) {
            System.out.print(numero + " ");
        }

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

Ohjelman mahdollisia tulostuksia ovat seuraavat:

Lottonumerot:
3 5 10 14 15 27 37
Lottonumerot:
2 9 11 18 23 32 34

Huom! Sama numero saa esiintyä lottorivissä vain kerran. Lottorivin numeroiden ei tarvitse olla järjestyksessä.

Lukujen satunnaisuudesta

Tietokoneiden toiminta on ennustettavissa sillä ne suorittavat niille annettuja komentoja orjallisesti. Ovatko siis tietokoneen luomat luvut todellisuudessa satunnaisia?

Tietokoneohjelmissa käytetyt satunnaisluvut ovat tyypillisesti pseudosatunnaislukuja, eli ne vaikuttavat satunnaisluvuilta, mutta seuraavat todellisuudessa jonkinlaista algoritmisesti luotua toistuvaa lukusarjaa. Suurimmalle osalle tietokoneohjelmista pseudosatunnaisluvut ovat riittävän hyviä -- esimerkiksi youtube-videoiden satunnaisessa toistossa normaali käyttäjä tuskin huomaa eroa. Toisaalta, jos satunnaislukuja käytetään tieteelliseen laskentaan, heikosti toimivat pseudosatunnaislukuja luovat algoritmit saattavat jopa johtaa tulosten kyseenalaistamiseen. Eräs esimerkki tällaisesta on hetken 1960-luvulla käytössä ollut IBM:n RANDU.

Kaikki tietokoneohjelmien satunnaisuus ei kuitenkaan ole pseudosatunnaisuutta. Vahvempaan satunnaisuuteen pyrkivät ohjelmat hyödyntävät muunmuassa jonkinlaisia tosielämän satunnaiseksi ajateltuja ilmiöitä satunnaislukujen luomiseen. Tällaisia ilmiöitä ovat esimerkiksi avaruussäteily tai vaikkapa laavalamppujen toiminta.

Lisää aiheesta osoitteessa https://www.random.org/randomness/.

Oletetaan, että henkilö aloittaa kävelynsä pisteestä (0, 0). Tietämättä maailmasta enempää, onko mahdollista, että henkilö voi kulkea pisteeseen (20, 20)?

Kursseilla tietorakenteet ja algoritmit sekä johdatus tekoälyyn tarkastellaan muunmuassa reitinhakualgoritmeja, joiden avulla voidaan selvittää nopeimpia reittejä annettujen pisteiden välillä. Tässä tarkastelemme ongelmaa hieman erilaisen menetelmän avulla ja tutustumme satunnaiskävelyyn (satunnaiskulku, random walk).

Satunnaiskävelyssä todennäköisyydet liikkeelle ovat näennäisesti satunnaisia. Toteutettavassa tapauksessamme henkilö liikkuu seuraavasti: 20% todennäköisyydellä hän pysyy paikallaan, 20% todennäköisyydellä hän liikkuu oikealle, 20% todennäköisyydellä hän liikkuu vasemmalle, 20% todennäköisyydellä hän liikkuu ylös, ja 20% todennäköisyydellä hän liikkuu alas.

Jos algoritmi ei ole saavuttanut pistettä (20, 20) hyvin ison askelmäärän jälkeen, voimme ehdottaa, että pisteeseen (20, 20) ei ole pääsyä.

Tehtäväpohjassa on annettu satunnaiskävelyn piirtämistä varten luotu ohjelma, missä kaksiulotteista maailmaa maillinnetaan kahden sisäkkäisen HashMap-tietorakenteen avulla seuraavastiHashMap<Integer, HashMap<Integer, Double>> taulukko. Koordinaatissa 0, 0 olevaan arvoon pääsee käsiksi kutsumalla taulukko.get(0).get(0).

Tehtäväpohjassa on lisäksi mallinnettu tilannetta, missä jokainen askel jättää hiljalleen haihtuvan jäljen.

Tehtävänäsi on tutustua ohjelmaan ja lisätä siihen toiminnallisuus, missä jokainen askel on satunnainen. Henkilön askelten tulee tapahtua seuraavasti: 20% todennäköisyydellä henkilö pysyy paikallaan, 20% todennäköisyydellä henkilö liikkuu oikealle, 20% todennäköisyydellä henkilö liikkuu vasemmalle, 20% todennäköisyydellä henkilö liikkuu ylös, ja 20% todennäköisyydellä henkilö liikkuu alas.

Alla on kuvakaappaus eräästä satunnaiskävelystä.

Esimerkki (lyhyestä) satunnaiskävelystä)

Vaikka Random walk -menetelmä tuntuu hyvin yksinkertaiselta, sillä on monia sovelluksia. Sitä käytetään muunmuassa molekyylien satunnaisen liikkeen mallintamisessa, osakkeiden hintojen muutosten mallintamisessa, ja geenien satunnaisissa muutoksissa.

Tehtävään ei ole testejä -- palauta se kun se toimii halutusti.

Ohjelmien rakenteesta

Kuudennen osan lopuksi muutama sana lähdekoodin kommentoinnista sekä ymmärrettävyydestä.

Lähdekoodin kommentointi

Lähdekoodiin voidaan lisätä kommentteja joko yhdelle riville kahden vinoviivan jälkeen // kommentti tai useammalle riville vinoviivan ja tähden sekä tähden ja vinoviivan rajaamalle alueelle /* kommentti */.

/*
Tulostaa luvut kymmenestä yhteen. Jokainen
luku tulostetaan omalle rivilleen.
*/
int luku = 10;
while (luku > 0) {
    System.out.println(luku);
    luku--; // sama kuin luku = luku - 1;
}

Kommenteilla on useita käyttötarkoituksia. Ohjelmointikurssilla ohjelmointia opettelevan kannattaa käyttää kommentteja ohjelman toiminnallisuuden itselleen selittämiseen. Kun yllä oleva lähdekoodi on selitetty kommenteissa rivi riviltä auki, näyttää se esimerkiksi seuraavalta.

/*
Tulostaa luvut kymmenestä yhteen. Jokainen
luku tulostetaan omalle rivilleen.
*/

// Luodaan kokonaislukutyyppinen muuttuja nimeltä
// luku, johon asetetaan arvo 10.
int luku = 10;

// Toistolauseen lohkon suoritusta jatketaan kunnes
// muuttujan luku arvo on nolla tai pienempi kuin nolla.
// Suoritus ei lopu _heti_ kun muuttujaan luku asetetaan
// arvo nolla, vaan vasta kun toistolauseen ehtolauseke
// evaluoidaan seuraavan kerran. Tämä tapahtuu aina lohkon
// suorituksen jälkeen.
while (luku > 0) {
    // tulostetaan muuttujassa luku oleva arvo sekä rivinvaihto
    System.out.println(luku);
    // vähennetään yksi luku-muuttujan arvosta
    luku--; // sama kuin luku = luku - 1;
}

Kommentit eivät vaikuta ohjelman suoritukseen, eli ohjelma toimii kommenttien kanssa täysin samalla tavalla kuin ilman kommentteja.

Edellä käytetty ohjelmoinnin opetteluun tarkoitettu kommentointityyli on kuitenkin ohjelmistokehitykseen kelpaamaton. Ohjelmistoja rakennettaessa lähdekoodin tulee kommentoida itse itsensä. Tällöin ohjelman toiminnallisuus tulee ilmi luokkien, metodien ja muuttujien nimistä.

Edelliset esimerkit voidaan yhtä hyvin kommentoida kapseloimalla ohjelmakoodi sopivasti nimettyn metodin sisään. Alla on kaksi esimerkkiä yllä olevan koodin kapseloivista metodeista -- toinen metodeista on hieman yleiskäyttöisempi kuin toinen. Toisaalta, jälkimmäisessä metodissa oletetaan, että käyttäjä tietää kumpaan parametreista asetetaan isompi ja kumpaan pienempi luku.

public void tulostaLuvutKymmenestaYhteen() {
    int luku = 10;
    while (luku > 0) {
        System.out.println(luku);
        luku--;
    }
}
public void tulostaLuvutIsoimmastaPienimpaan(int mista, int mihin) {
    while (mista >= mihin) {
        System.out.println(mista);
        mista--;
    }
}

Kommenteista ja ymmärrettävyydestä

Alla on hieman kryptisempi ohjelma.

Tutustu ohjelmaan ja yritä selvittää mitä ohjelma tekee ennen materiaalissa etenemistä. Alla olevan ohjelman suorituksen selvittämisessä kannattaa käyttää esimerkiksi kynää ja paperia. Kun käytössäsi on kynä ja paperi, aloita ohjelmakoodin läpi käyminen rivi riviltä kuin olisit tietokone. Kirjaa jokaisen rivin jälkeen ylös ohjelman käyttämissä muuttujissa tapahtuneet muutokset.

ArrayList<Integer> l = new ArrayList<>();
l.add(12);
l.add(14);
l.add(18);
l.add(40);
l.add(41);
l.add(42);
l.add(47);
l.add(52);
l.add(59);
int x = 42;

int a = 0;
int b = l.size() - 1;
while (a <= b) {
    int c = a + (b - a) / 2;
    if (x < l.get(c)) {
        b = c - 1;
    } else if (x > l.get(c)) {
        a = c + 1;
    } else {
        System.out.println(c);
    }
}

System.out.println("-1");

Kun olet kokeillut ohjelman toiminnan seuraamista yllä olevalla ohjelmalla, toista harjoitus alla olevalla ohjelmalla. Alla olevassa ohjelmassa muuttujien nimet on muutettu kuvaavammiksi.

ArrayList<Integer> luvut = new ArrayList<>();
luvut.add(12);
luvut.add(14);
luvut.add(18);
luvut.add(40);
luvut.add(41);
luvut.add(42);
luvut.add(47);
luvut.add(52);
luvut.add(59);

int haettava = 42;

int alaraja = 0;
int ylaraja = luvut.size() - 1;
while (alaraja <= ylaraja) {
    int keskikohta = alaraja + (ylaraja - alaraja) / 2;
    if (haettava < luvut.get(keskikohta)) {
        ylaraja = keskikohta - 1;
    } else if (haettava > luvut.get(keskikohta)) {
        alaraja = keskikohta + 1;
    } else {
        System.out.println(keskikohta);
    }
}

System.out.println("-1");

Lähdekoodi, missä muuttujien nimet on selkeitä, on helpommin ymmärrettävää kuin lähdekoodi, missä muuttujien nimet eivät kuvaa niiden tarkoitusta. Haluamme ohjelmasta version, joka on nopeasti ymmärrettävissä. Luodaan siitä metodi ja nimetään metodi sopivasti.

public int static binaariHaku(ArrayList<Integer> luvut, int haettava) {

    int alaraja = 0;
    int ylaraja = luvut.size() - 1;
    while (alaraja <= ylaraja) {
        int keskikohta = alaraja + (ylaraja - alaraja) / 2;
        if (haettava < luvut.get(keskikohta)) {
            ylaraja = keskikohta - 1;
        } else if (haettava > luvut.get(keskikohta)) {
            alaraja = keskikohta + 1;
        } else {
            return keskikohta;
        }
    }

    return -1;
}

Lähdekoodi on nyt ymmärrettävissä suoraan metodin määrittelystä: public void binaariHaku(ArrayList<Integer> luvut, int haettava). Kyseessä on binäärihakualgoritmi, joka etsii listasta annettua lukua. Metodimäärittely ei kuitenkaan kerro binäärihakuun liittyvistä oletuksista tai sen palautusarvoista.

Korjataan tilanne kommentilla. Yllä esitetyn binäärihakualgoritmin toiminnan ehtona on se, että lista on järjestyksessä pienimmästä suurimpaan. Jos etsittävä luku löytyy, algoritmi palauttaa luvun indeksin. Jos lukua taas ei löydy, algoritmi palauttaa luvun -1.

Käytämme alla ohjelman dokumentointiin liittyvää kommentointitapaa, missä kommentti alkaa vinoviivalla ja kahdella tähdellä sekä päättyy yhteen tähteen ja vinoviivaan /** kommentti */. Ohjelmointiympäristöt näyttävät metodeihin liittyvät dokumenttikommentit muunmuassa lähdekoodin automaattisen täydennyksen yhteydessä.

/**
Binäärihaku etsii parametrina annetusta listasta parametrina annettua lukua.
Jos etsittävä luku löytyy, metodi palauttaa luvun indeksin listassa. Jos
etsittävää lukua ei löydy, metodi palauttaa arvon -1. Metodi olettaa, että
lista on järjestetty pienimmästä arvosta suurimpaan.
*/

public static int binaariHaku(ArrayList<Integer> luvut, int haettava) {

    int alaraja = 0;
    int ylaraja = luvut.size() - 1;
    while (alaraja <= ylaraja) {
        int keskikohta = alaraja + (ylaraja - alaraja) / 2;
        if (haettava < luvut.get(keskikohta)) {
            ylaraja = keskikohta - 1;
        } else if (haettava > luvut.get(keskikohta)) {
            alaraja = keskikohta + 1;
        } else {
            return keskikohta;
        }
    }

    return -1;
}

Alla olevassa kuvassa näytetään miten ohjelmointiympäristö näyttää metodiin liittyvän kommentin. Oletuksena on, että hakualgoritmi on luokassa Hakualgoritmit. Kun luokasta on tehty olio, ja ohjelmoija alkaa kirjoittamaan metodin nimeä, näyttää ohjelmointiympäristö metodiin aiemmin liitetyn dokumentaation. Kuvassa metodin parametrien määrittely poikkeaa hieman edellisestä esimerkistä.

Ohjelmointiympäristö näyttää metodiin liitetyn kommentin.

 

Kommentteja käytetään siis ensisijaisesti luokkien sekä metodien yleisen toiminnallisuuden kuvaamisessa sen sijaan, että kerrottaisiin yksityiskohtaisesti mitä ohjelma tekee. Yksityiskohtainen ohjelman toiminnan avaaminen on kuitenkin hyvä tapa selittää ohjelmakoodia itselleen. Yleisesti ottaen voidaan ajatella niin, että vaikeasti ymmärrettävät ohjelmat kannattaa pilkkoa luokkiin ja metodeihin, jotka kuvaavat ohjelman rakennetta. Dokumentointi ja kommentointi niiltä osin, mitkä eivät ole luokkien tai metodien nimistä selviä, on tärkeää -- esimerkiksi metodien paluuarvot sekä niiden toimintaan liittyvät oletukset on hyvä dokumentoida.

Sovellus ja sen osat

Edellä puhuimme kommenteista sekä ohjelman pilkkomisesta luokkiin ja metodeihin, jotka kuvaavat ohjelman rakennetta. Seuraava katkelma on Edsger W. Dijkstran artikkelista On the role of scientific thought.

Let me try to explain to you, what to my taste is characteristic for all intelligent thinking. It is, that one is willing to study in depth an aspect of one's subject matter in isolation for the sake of its own consistency, all the time knowing that one is occupying oneself only with one of the aspects. We know that a program must be correct and we can study it from that viewpoint only; we also know that it should be efficient and we can study its efficiency on another day, so to speak. In another mood we may ask ourselves whether, and if so: why, the program is desirable. But nothing is gained - on the contrary! - by tackling these various aspects simultaneously. It is what I sometimes have called "the separation of concerns", which, even if not perfectly possible, is yet the only available technique for effective ordering of one's thoughts, that I know of. This is what I mean by "focusing one's attention upon some aspect": it does not mean ignoring the other aspects, it is just doing justice to the fact that from this aspect's point of view, the other is irrelevant. It is being one- and multiple-track minded simultaneously.

Ohjelmoijan tulee pystyä tarkastelemaan ohjelmaansa eri näkökulmista ilman, että muut ohjelman osa-alueet vievät keskittymistä. Käyttöliittymään tulee voida keskittyä ilman, että ohjelmoijan tulee keskittyä sovelluksen ydinlogiikkaan. Vastaavasti ohjelmassa ja ongelma-alueessa esiintyviin käsitteisiin tulee voida keskittyä ilman, että ohjelmoijan tarvitsee välittää käyttöliittymästä. Vastaavasti ohjelmassa käytettävien algoritmien tehokkuus on oma "huolenaihe", johon ohjelmoijan tulee voida keskittyä ilman huolta muista osa-alueista.

Samaa ajatusta voidaan jatkaa vastuiden näkökulmasta. Robert "Uncle Bob" C. Martin kuvaa blogissaan termiä "single responsibility principle" seuraavasti.

When you write a software module, you want to make sure that when changes are requested, those changes can only originate from a single person, or rather, a single tightly coupled group of people representing a single narrowly defined business function. You want to isolate your modules from the complexities of the organization as a whole, and design your systems such that each module is responsible (responds to) the needs of just that one business function.

[..in other words..] Gather together the things that change for the same reasons. Separate those things that change for different reasons.

Selkeys saadaan aikaan sopivalla luokkarakenteella sekä nimeämiskäytänteiden seuraamisella. Jokaisella luokalla tulee olla vastuu, johon liittyviä tehtäviä luokka hoitaa. Metodeja käytetään toiston vähentämiseen ja luokkien sisäisten toimintojen jäsentämiseen. Myös metodeilla tulee olla selkeä vastuu eli metodien ei tule olla liian pitkiä ja liian montaa asiaa tekeviä. Liian montaa asiaa tekevät monimutkaiset metodit tuleekin pilkkoa useiksi pienemmiksi apumetodeiksi joita alkuperäinen metodi kutsuu.

Hyvin harva ohjelma kirjoitetaan vain kerran

Ohjelmistoja kehittäessä keskitytään tyypillisesti niihin ohjelmiston ominaisuuksiin, jotka tuovat eniten arvoa ohjelmiston käyttäjälle. Nämä ominaisuudet sovitaan yhdessä ohjelmiston kehittäjän sekä loppukäyttäjän kanssa, mikä mahdollistaa ominaisuuksien järjestämisen tärkeysjärjestykseen.

Ohjelmistoille on tyypillistä se, että ohjelmistoon liittyvät toiveet sekä ominaisuuksien tärkeysjärjestys muuttuu ohjelmiston elinkaaren aikana. Tämä johtaa siihen, että osia ohjelmistosta kirjoitetaan uudestaan, osia siirrellään paikasta toiseen ja osia poistetaan kokonaan.

Ohjelmoijan näkökulmasta tämä tarkoittaa ensisijaisesti sitä, että ohjelmisto kehittyy jatkuvasti. Uudelleenkirjoitettavat osat tulevat tyypillisesti paremmiksi, sillä ohjelmoija oppii ongelma-alueesta siihen liittyviä ratkaisuja kehittäessään. Samalla tämä tarkoittaa sitä, että ohjelmoijan tulee myös säilyttää kokonaiskuva ohjelman rakenteesta, sillä joitain osia saatetaan myös uudelleenkäyttää muissa osissa ohjelmistoa.

Yleisesti ottaen voidaan todeta, että hyvin harva ohjelma kirjoitetaan vain kerran. Tätä ajatusta jatkaen on hyvä pyrkiä tilanteeseen, missä ohjelman käyttäjä pääsee kokeilemaan sitä mahdollisimman nopeasti -- tällöin muutostoiveiden kerääminen myös alkaa nopeasti. Ohjelmistoja tehdessä onkin hyvä usein luoda ensin Proof of Concept-sovellus, jolla voidaan kokeilla idean toimivuutta. Jos idea on hyvä, sitä jatkokehitetään -- samalla myös ohjelma ja kehittyy.

Staattiset metodit

Kurssin alussa kaikissa metodeissa esiintyi määre static, mutta aloittaessamme olioiden käytön, tuon määreen käyttö jopa kiellettiin. Mistä on kysymys?

Seuraavassa esimerkissä on metodi nollaaLista joka toimii nimensä mukaisesti eli asettaa nollan parametrina saamansa listan kaikkien lukujen arvoksi.

import java.util.ArrayList;

public class Ohjelma {

    public static void nollaaLista(ArrayList<Integer> lista) {
        for (int i = 0; i < lista.size(); i++) {
            lista.set(i, 0);
        }
    }

    public static void main(String[] args) {
        ArrayList<Integer> luvut = new ArrayList<>();
        luvut.add(1);
        luvut.add(2);
        luvut.add(3);
        luvut.add(4);
        luvut.add(5);

        for (int luku : luvut) {
            System.out.print(luku + " ");  // tulostuu 1 2 3 4 5
        }

        System.out.println();

        nollaaLista(luvut);

        for (int luku : luvut) {
            System.out.print(luku + " ");  // tulostuu 0 0 0 0 0
        }
    }
}

Yllä olevassa esimerkissä metodilla nollaaLista on määre static ja sen kutsuminen tapahtuu ilman alussa olevaa olioviitettä.

Staattiset metodit eivät liity olioon vaan luokkaan. Staattisia metodeja kutsutaan usein myös luokkametodeiksi. Toisin kuin olioiden metodit (joilla ei ole määrettä static), staattiseen metodiin ei liity olioa, eikä niillä voi muokata oliomuuttujia.

Staattiselle metodille voi toki antaa olion parametrina. Staattinen metodi ei kuitenkaan voi käsitellä mitään muita lukuja, merkkijonoja, tai olioita kuin niitä, jotka annetaan sille parametrina, tai jotka se luo itse.

Toisin sanoen, staattista metodia käyttävän koodin tulee antaa staattiselle metodille ne arvot ja oliot, joita staattisessa metodissa käsitellään.

Koska staattinen metodi ei liity mihinkään olioon, ei sitä kutsuta oliometodien tapaan olionNimi.metodinNimi(), vaan ylläolevan esimerkin tapaan käytetään pelkkää staattisen metodin nimeä.

Jos staattisen metodin koodi on eri luokan sisällä kuin sitä kutsuva metodi, voi staattista metodia kutsua muodossa LuokanNimi.staattisenMetodinNimi(). Edellinen esimerkki alla muutettuna siten, että pääohjelma ja metodi ovat omissa luokissaan (eli eri tiedostoissa):

import java.util.ArrayList;

public class Ohjelma {
    public static void main(String[] args) {
        ArrayList<Integer> luvut = new ArrayList<>();
        luvut.add(1);
        luvut.add(2);
        luvut.add(3);
        luvut.add(4);
        luvut.add(5);

        for (int luku : luvut) {
            System.out.print(luku + " ");  // tulostuu 1 2 3 4 5
        }

        System.out.println();

        ListaApurit.nollaaLista(luvut);

        for (int luku : luvut) {
            System.out.print(luku + " ");  // tulostuu 0 0 0 0 0
        }
    }
}
import java.util.ArrayList;

public class ListaApurit {

    public static void nollaaLista(ArrayList<Integer> lista) {
        for (int i = 0; i < lista.size(); i++) {
            lista.set(i, 0);
        }
    }
}

Toisen luokan sisällä -- tässä tämän toisen luokan nimi on ListaApurit -- määriteltyä staattista metodia kutsutaan yllä muodossa ListaApurit.nollaaLista(parametri);.

Milloin staattisia metodeja tulisi käyttää

Kaikki olion tilaa käsittelevät metodit tulee määritellä oliometodeina, joilla ei ole static-määrettä. Esimerkiksi edellisissä osissa määrittelemiemme luokkien kuten Henkilo, Paivays, Soittolista, ... kaikki metodit tulee määritellä ilman static-määrettä.

Palataan vielä luokkaan Henkilo. Seuraavassa on osa luokan määritelmästä. Kaikkiin oliomuuttujiin viitataan this-määreen avulla sillä korostamme, että metodeissa käsitellään olion "sisällä" olevia oliomuuttujia.

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

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

    public boolean taysiIkainen() {
        if (this.ika < 18) {
            return false;
        }

        return true;
    }

    public void vanhene() {
        this.ika++;
    }

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

Koska metodit käsittelevät oliota, ei niitä voi määrittää static:eiksi eli "olioista riippumattomiksi". Jos näin yritetään tehdä, ei metodi toimi. Esimerkiksi allaoleva Henkilo-olion iän muokkausta yrittävä metodi vanhene ei toimi:

public class Henkilo {
    //...

    public static void vanhene() {
        this.ika++;
    }
}

Seurauksena on virheilmoitus non static variable ika can not be referenced from static context, joka tarkoittaa että oliomuuttujaan ei voida viitata luokkametodista; staattinen metodi ei siis pysty käsittelemään oliomuuttujaa.

Eli milloin staattista metodia sitten kannattaa käyttää? Tarkastellaan aiemmin materiaalissa nähtyä henkilöolioita käsittelevää esimerkkiä:

public class Main {
    public static void main(String[] args) {
        Henkilo ada = new Henkilo("Ada");
        Henkilo antti = new Henkilo("Antti");
        Henkilo juhana = new Henkilo("Juhana");

        for (int i = 0; i < 30; i++) {
            ada.vanhene();
            juhana.vanhene();
        }

        antti.vanhene();

        if (ada.taysiIkainen()) {
            System.out.println(ada.getNimi() + " on täysi-ikäinen");
        } else {
            System.out.println(ada.getNimi() + " on alaikäinen ");
        }

        if (antti.taysiIkainen()) {
            System.out.println(antti.getNimi() + " on täysi-ikäinen");
        } else {
            System.out.println(antti.getNimi() + " on alaikäinen");
        }

        if (juhana.taysiIkainen()) {
            System.out.println(juhana.getNimi() + " on täysi-ikäinen");
        } else {
            System.out.println(juhana.getNimi() + " on alaikäinen ");
        }
    }
}

Huomaamme, että henkilöiden täysi-ikäisyyden ilmottamiseen liittyvä koodinpätkä on copy-pastettu kolme kertaa peräkkäin. Todella rumaa!

Henkilön täysi-ikäisyyden ilmoittaminen on mainio kohde staattiselle metodille. Kirjoitetaan ohjelma uudelleen metodia hyödyntäen:

public class Main {

    public static void main(String[] args) {
        Henkilo ada = new Henkilo("Ada");
        Henkilo antti = new Henkilo("Antti");
        Henkilo juhana = new Henkilo("Juhana");

        for (int i = 0; i < 30; i++) {
            ada.vanhene();
            juhana.vanhene();
        }

        antti.vanhene();

        ilmoitaTaysiIkaisyys(ada);

        ilmoitaTaysiIkaisyys(antti);

        ilmoitaTaysiIkaisyys(juhana);
    }

    private static void ilmoitaTaysiIkaisyys(Henkilo henkilo) {
        if (henkilo.taysiIkainen()) {
            System.out.println(henkilo.getNimi() + " on täysi-ikäinen");
        } else {
            System.out.println(henkilo.getNimi() + " on alaikäinen");
        }
    }
}

Metodi ilmoitaTaysiIkaisyys on määritelty staattiseksi, eli se ei liity mihinkään olioon, mutta metodi saa parametrikseen henkilöolion. Metodia ei ole määritelty Henkilö-luokan sisälle sillä vaikka se käsittelee parametrinaan saamaan henkilöolioa, se on juuri kirjoitetun pääohjelman apumetodi, jonka avulla pääohjelma on saatu kirjoitettua selkeämmin.

Tässä tehtävässä kerrataan olio-ohjelmoinnin perusteita sekä listojen käsittelyä virran avulla.

Kumpulan tiedekirjasto tarvitsee uuden järjestelmän kirjojen hallintaan. Tässä tehtävässä hahmotellaan ongelma-alueen tietosisältöä ja toteutetaan prototyyppi, joka mahdollistaa kirjan haun nimen, julkaisijan tai julkaisuvuoden perusteella.

Rakennetaan järjestelmä osista, ensin toteutetaan oleelliset luokat eli Kirja ja Kirjasto. Luokka Kirja sisältää kirjaan liittyvät tiedot, luokka Kirjasto tarjoaa erilaisia hakutoiminnallisuuksia kirjoihin liittyen.

Kirja

Luodaan ensiksi luokka Kirja. Kirjalla on oliomuuttujina nimeke, eli kirjan nimi, julkaisija, eli kirjan julkaisija, ja julkaisuvuosi eli vuosi jolloin kirja on julkaistu. Kaksi ensimmäistä muuttujaa on merkkijonotyyppisiä, viimeisin on kokonaisluku. Oletamme tässä että kirjalla on aina vain yksi kirjoittaja.

Toteuta luokka Kirja. Kirjalla tulee olla myös konstruktori public Kirja(String niemeke, String julkaisija, int julkaisuvuosi) sekä metodit public String nimeke(), public String julkaisija(), public int julkaisuvuosi() ja public String toString(). Arvannet mitä metodien tulee tehdä, alla esimerkki.

Testaa luokan toimintaa:

Kirja cheese = new Kirja("Cheese Problems Solved", "Woodhead Publishing", 2007);
System.out.println(cheese.nimeke());
System.out.println(cheese.julkaisija());
System.out.println(cheese.julkaisuvuosi());

System.out.println(cheese);
Cheese Problems Solved
Woodhead Publishing
2007
Cheese Problems Solved, Woodhead Publishing, 2007

Kirjasto

Kirjaston tehtävä on antaa käyttäjälle mahdollisuus kirjojen lisäämiseen ja niiden hakemiseen. Luo luokka Kirjasto, jolla on konstruktori public Kirjasto() ja metodit public void lisaaKirja(Kirja uusiKirja) ja public void tulostaKirjat()

Kirjasto kirjasto = new Kirjasto();

Kirja cheese = new Kirja("Cheese Problems Solved", "Woodhead Publishing", 2007);
kirjasto.lisaaKirja(cheese);

Kirja nhl = new Kirja("NHL Hockey", "Stanley Kupp", 1952);
kirjasto.lisaaKirja(nhl);

kirjasto.lisaaKirja(new Kirja("Battle Axes", "Tom A. Hawk", 1851));

kirjasto.tulostaKirjat();
Cheese Problems Solved, Woodhead Publishing, 2007
NHL Hockey, Stanley Kupp, 1952
Battle Axes, Tom A. Hawk, 1851

Hakutoiminnallisuus

Kirjastosta tulee pystyä etsimään kirjoja nimekkeiden ja julkaisijoiden perusteella. Lisää kirjastolle metodit public ArrayList<Kirja> haeKirjaNimekkeella(String nimeke), public ArrayList<Kirja> haeKirjaJulkaisijalla(String julkaisija) ja public ArrayList<Kirja> haeKirjaJulkaisuvuodella(int julkaisuvuosi). Metodit palauttavat listan kirjoista, joissa on haluttu nimeke, julkaisija tai julkaisuvuosi.

Voit halutessasi hyödyntää seuraavaa runkoa metodin tekemiseen.

public class Kirjasto {
    // ...

    public ArrayList<Kirja> haeKirjaNimekkeella(String nimeke) {
        ArrayList<Kirja> loydetyt = new ArrayList<>();

        // käy läpi kaikki kirjat ja lisää ne joilla haetun kaltainen nimeke listalle loydetyt

        return loydetyt;
    }
}

Huom! Kun haet teet hakua merkkijonon avulla, älä tee tarkkaa hakua (metodi equals) vaan käytä String-luokan metodia contains. Huomaat todennäköisesti myös että sinulla on ns. copy-paste -koodia Kirjasto-luokan koodissa. Keksitkö tavan päästä siitä eroon?

Kirjasto kirjasto = new Kirjasto();

kirjasto.lisaaKirja(new Kirja("Cheese Problems Solved", "Woodhead Publishing", 2007));
kirjasto.lisaaKirja(new Kirja("The Stinky Cheese Man and Other Fairly Stupid Tales", "Penguin Group", 1992));
kirjasto.lisaaKirja(new Kirja("NHL Hockey", "Stanley Kupp", 1952));
kirjasto.lisaaKirja(new Kirja("Battle Axes", "Tom A. Hawk", 1851));

for (Kirja kirja: kirjasto.haeKirjaNimekkeella("Cheese")) {
    System.out.println(kirja);
}

System.out.println("---");

for (Kirja kirja: kirjasto.haeKirjaJulkaisijalla("Pong Group")) {
    System.out.println(kirja);
}

System.out.println("---");

for (Kirja kirja: kirjasto.haeKirjaJulkaisuvuodella(1851)) {
    System.out.println(kirja);
}
Cheese Problems Solved, Woodhead Publishing, 2007
The Stinky Cheese Man and Other Fairly Stupid Tales, Penguin Group, 1992
---
---
Battle Axes, Tom A. Hawk, 1851

Paranneltu hakutoiminnallisuus

Hakutoiminnallisuutemme on jo hyvä, mutta se ei ymmärrä isojen ja pienten kirjainten eroa. Yllä olleessa esimerkissä haku nimekkeellä "cheese" ei olisi tuottanut yhtäkään tulosta. Myös toinen esimerkki, jossa oli ylimääräisiä välilyöntejä, ei näyttänyt haluttua tulosta. Haluamme että nimekkeiden ja julkaisijoiden nimillä haettaessa ei välitetä merkkien koosta, ja että käyttäjä voi syöttää ylimääräisiä välilyöntejä kirjan nimen alkuun tai loppuun (meidän ei tarvitse välittää sanojen välillä olevista tyhjistä!). Toteutetaan pieni apukirjasto StringUtils merkkijonojen vertailuun.

Luo luokka StringUtils, ja lisää sille staattinen metodi public static boolean sisaltaa(String sana, String haettava), joka tarkistaa sisältääkö merkkijono sana merkkijonon haettava. Jos jommankumman merkkijonon arvo on null, metodin tulee palauttaa arvo false. Metodin tarjoaman vertailun tulee olla välittämättä merkin koosta.

Lisää metodille sisaltaa myös toiminnallisuus, joka poistaa merkkijonojen sana ja haettava alusta ja lopusta ylimääräiset välilyönnit. Käytä tähän String-luokan metodia trim, esim. trimmattu = trimmattava.trim().

Vinkki! String-luokan metodista toUpperCase() on hyötyä kun haluat verrata ovatko kaksi merkkijonoa samat -- riippumatta niiden alkuperäisestä merkkikoosta.

Kun olet saanut metodin valmiiksi, käytä sitä Kirjasto-luokassa. Alla esimerkki:

if (StringUtils.sisaltaa(kirja.nimeke(), nimeke)) {
    // kirja löytyi!
}
Kirjasto kirjasto = new Kirjasto();

kirjasto.lisaaKirja(new Kirja("Cheese Problems Solved", "Woodhead Publishing", 2007));
kirjasto.lisaaKirja(new Kirja("The Stinky Cheese Man and Other Fairly Stupid Tales", "Penguin Group", 1992));
kirjasto.lisaaKirja(new Kirja("NHL Hockey", "Stanley Kupp", 1952));
kirjasto.lisaaKirja(new Kirja("Battle Axes", "Tom A. Hawk", 1851));

for (Kirja kirja: kirjasto.haeKirjaNimekkeella("CHEESE")) {
    System.out.println(kirja);
}

System.out.println("---");
for (Kirja kirja: kirjasto.haeKirjaJulkaisijalla("PENGUIN  ")) {
    System.out.println(kirja);
}
Cheese Problems Solved, Woodhead Publishing, 2007
The Stinky Cheese Man and Other Fairly Stupid Tales, Penguin Group, 1992
---
The Stinky Cheese Man and Other Fairly Stupid Tales, Penguin Group, 1992

Ongelmasta kokonaisuuteen ja takaisin osiin

Tarkastellaan erään ohjelman rakennusprosessia sekä tutustutaan sovelluksen vastuualueiden erottamiseen toisistaan. Ohjelma kysyy käyttäjältä sanoja kunnes käyttäjä syöttää saman sanan uudestaan. Ohjelma käyttää listaa sanojen tallentamiseen.

Anna sana: porkkana
Anna sana: selleri
Anna sana: nauris
Anna sana: lanttu
Anna sana: selleri
Annoit saman sanan uudestaan!

Rakennetaan ohjelma osissa. Eräs haasteista on se, että on vaikea päättää miten lähestyä tehtävää, eli miten ongelma tulisi jäsentää osaongelmiksi, ja mistä osaongelmasta kannattaisi aloittaa. Yhtä oikeaa vastausta ei ole -- joskus on hyvä lähteä pohtimaan ongelmaan liittyviä käsitteitä ja niiden yhteyksiä, joskus taas ohjelman tarjoamaa käyttöliittymää.

Käyttöliittymän hahmottelu voisi lähteä liikenteeseen luokasta Kayttoliittyma. Käyttöliittymä käyttää Scanner-oliota, jonka sille voi antaa. Tämän lisäksi käyttöliittymällä on käynnistämiseen tarkoitettu metodi.

public class Kayttoliittyma {
    private Scanner lukija;

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

    public void kaynnista() {
        // tehdään jotain
    }
}

Käyttöliittymän luominen ja käynnistäminen onnistuu seuraavasti.

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

Toisto ja lopetus

Ohjelmassa on (ainakin) kaksi "aliongelmaa". Ensimmäinen on sanojen toistuva lukeminen käyttäjältä kunnes tietty ehto toteutuu. Tämä voitaisiin hahmotella seuraavaan tapaan.

public class Kayttoliittyma {
    private Scanner lukija;

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

    public void kaynnista() {

        while (true) {
            System.out.print("Anna sana: ");
            String sana = lukija.nextLine();

            if (pitää lopettaa) {
                break;
            }

        }

        System.out.println("Annoit saman sanan uudestaan!");
    }
}

Sanojen kysely jatkuu kunnes käyttäjä syöttää jo aiemmin syötetyn sanan. Täydennetään ohjelmaa siten, että se tarkastaa onko sana jo syötetty. Vielä ei tiedetä miten toiminnallisuus kannattaisi tehdä, joten tehdään siitä vasta runko.

public class Kayttoliittyma {
    private Scanner lukija;

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

    public void kaynnista() {

        while (true) {
            System.out.print("Anna sana: ");
            String sana = lukija.nextLine();

            if (onJoSyotetty(sana)) {
                break;
            }

        }

        System.out.println("Annoit saman sanan uudestaan!");
    }

    public boolean onJoSyotetty(String sana) {
        // tänne jotain

        return false;
    }
}

Ohjelmaa on hyvä testata koko ajan, joten tehdään metodista kokeiluversio:

public boolean onJoSyotetty(String sana) {
    if (sana.equals("loppu")) {
        return true;
    }

    return false;
}

Nyt toisto jatkuu niin kauan kunnes syötteenä on sana loppu:

Anna sana: porkkana
Anna sana: selleri
Anna sana: nauris
Anna sana: lanttu
Anna sana: loppu
Annoit saman sanan uudestaan!

Ohjelma ei toimi vielä kokonaisuudessaan, mutta ensimmäinen osaongelma eli ohjelman pysäyttäminen kunnes tietty ehto toteutuu on saatu toimimaan.

Oleellisten tietojen tallentaminen

Toinen osaongelma on aiemmin syötettyjen sanojen muistaminen. Lista sopii mainiosti tähän tarkoitukseen.

public class Kayttoliittyma {
    private Scanner lukija;
    private ArrayList<String> aiemmatSanat;

    public Kayttoliittyma(Scanner lukija) {
        this.lukija = lukija;
        this.aiemmatSanat = new ArrayList<String>();
    }

    //...

Kun uusi sana syötetään, on se lisättävä syötettyjen sanojen joukkoon. Tämä tapahtuu lisäämällä while-silmukkaan listan sisältöä päivittävä rivi:

while (true) {
    System.out.print("Anna sana: ");
    String sana = lukija.nextLine();

    if (onJoSyotetty(sana)) {
        break;
    }

    // lisätään uusi sana aiempien sanojen listaan
    this.aiemmatSanat.add(sana);
}

Kayttoliittyma näyttää kokonaisuudessaan seuraavalta.

public class Kayttoliittyma {
    private Scanner lukija;
    private ArrayList<String> aiemmatSanat;

    public Kayttoliittyma(Scanner lukija) {
        this.lukija = lukija;
        this.aiemmatSanat = new ArrayList<String>();
    }

    public void kaynnista() {

        while (true) {
            System.out.print("Anna sana: ");
            String sana = lukija.nextLine();

            if (onJoSyotetty(sana)) {
                break;
            }

            // lisätään uusi sana aiempien sanojen listaan
            this.aiemmatSanat.add(sana);
        }

        System.out.println("Annoit saman sanan uudestaan!");
    }

    public boolean onJoSyotetty(String sana) {
        if (sana.equals("loppu")) {
            return true;
        }

        return false;
    }
}

Jälleen kannattaa testata, että ohjelma toimii edelleen. Voi olla hyödyksi esimerkiksi lisätä kaynnista-metodin loppuun testitulostus, joka varmistaa että syötetyt sanat todella menivät listaan.

// testitulostus joka varmistaa että kaikki toimii edelleen
for(String sana: this.aiemmatSanat) {
    System.out.println(sana);
}

Osaongelmien ratkaisujen yhdistäminen

Muokataan vielä äsken tekemämme metodi onJoSyotetty tutkimaan onko kysytty sana jo syötettyjen joukossa, eli listassa.

public boolean onJoSyotetty(String sana) {
    return this.aiemmatSanat.contains(sana);
}

Nyt sovellus toimii kutakuinkin halutusti.

Oliot luonnollisena osana ongelmanratkaisua

Rakensimme äsken ratkaisun ongelmaan, missä luetaan käyttäjältä sanoja, kunnes käyttäjä antaa saman sanan uudestaan. Syöte ohjelmalle oli esimerkiksi seuraavanlainen.

Anna sana: porkkana
Anna sana: selleri
Anna sana: nauris
Anna sana: lanttu
Anna sana: selleri
Annoit saman sanan uudestaan!

Päädyimme ratkaisuun

public class Kayttoliittyma {
    private Scanner lukija;
    private ArrayList<String> aiemmatSanat;

    public Kayttoliittyma(Scanner lukija) {
        this.lukija = lukija;
        this.aiemmatSanat = new ArrayList<String>();
    }

    public void kaynnista() {

        while (true) {
            System.out.print("Anna sana: ");
            String sana = lukija.nextLine();

            if (onJoSyotetty(sana)) {
                break;
            }

            // lisätään uusi sana aiempien sanojen listaan
            aiemmatSanat.add(sana);
        }

        System.out.println("Annoit saman sanan uudestaan!");
    }

    public boolean onJoSyotetty(String sana) {
        return this.aiemmatSanat.contains(sana);
    }
}

Ohjelman käyttämä apumuuttuja lista aiemmatSanat on yksityiskohta käyttöliittymän kannalta. Käyttöliittymän kannaltahan on oleellista, että muistetaan niiden sanojen joukko jotka on nähty jo aiemmin. Sanojen joukko on selkeä erillinen "käsite", tai abstraktio. Tälläiset selkeät käsitteet ovat potentiaalisia olioita; kun koodissa huomataan "käsite" voi sen eristämistä erilliseksi luokaksi harkita.

Sanajoukko

Tehdään luokka Sanajoukko, jonka käyttöönoton jälkeen käyttöliittymän metodi kaynnista on seuraavanlainen:

while (true) {
    String sana = lukija.nextLine();

    if (aiemmatSanat.sisaltaa(sana)) {
        break;
    }

    aiemmatSanat.lisaa(sana);
}

System.out.println("Annoit saman sanan uudestaan!");

Käyttöliittymän kannalta Sanajoukolla kannattaisi siis olla metodit boolean sisaltaa(String sana) jolla tarkastetaan sisältyykö annettu sana jo sanajoukkoon ja void lisaa(String sana) jolla annettu sana lisätään joukkoon.

Huomaamme, että näin kirjoitettuna käyttöliittymän luettavuus on huomattavasti parempi.

Luokan Sanajoukko runko näyttää seuraavanlaiselta:

public class Sanajoukko {
    // oliomuuttuja(t)

    public Sanajoukko() {
        // konstruktori
    }

    public boolean sisaltaa(String sana) {
        // sisältää-metodin toteutus
        return false;
    }

    public void lisaa(String sana) {
        // lisaa-metodin toteutus
    }
}

Toteutus aiemmasta ratkaisusta

Voimme toteuttaa sanajoukon siirtämällä aiemman ratkaisumme listan sanajoukon oliomuuttujaksi:

import java.util.ArrayList;

public class Sanajoukko {
    private ArrayList<String> sanat;

    public Sanajoukko() {
        this.sanat = new ArrayList<>();
    }

    public void lisaa(String sana) {
        this.sanat.add(sana);
    }

    public boolean sisaltaa(String sana) {
        return this.sanat.contains(sana);
    }
}

Ratkaisu on nyt melko elegantti. Erillinen käsite on saatu erotettua ja käyttöliittymä näyttää siistiltä. Kaikki "likaiset yksityiskohdat" on saatu siivottua eli kapseloitua olion sisälle.

Muokataan käyttöliittymää niin, että se käyttää Sanajoukkoa. Sanajoukko annetaan käyttöliittymälle samalla tavalla parametrina kuin Scanner.

public class Kayttoliittyma {
    private Scanner lukija;
    private Sanajoukko sanajoukko;

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

    public void kaynnista() {

        while (true) {
            System.out.print("Anna sana: ");
            String sana = lukija.nextLine();

            if (this.sanajoukko.sisaltaa(sana)) {
                break;
            }

            this.sanajoukko.lisaa(sana);
        }

        System.out.println("Annoit saman sanan uudestaan!");
    }
}

Ohjelman käynnistäminen tapahtuu nyt seuraavasti:

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

    Kayttoliittyma kayttoliittyma = new Kayttoliittyma(lukija, joukko);
    kayttoliittyma.kaynnista();
}

Luokan sisäisen toteutuksen muuttaminen

Olemme päätyneet tilanteeseen missä Sanajoukko ainoastaan "kapseloi" ArrayList:in. Onko tässä järkeä? Kenties. Voimme nimittäin halutessamme tehdä Sanajoukolle muitakin muutoksia. Ennen pitkään saatamme esim. huomata, että sanajoukko pitää tallentaa tiedostoon. Jos tekisimme nämä muutokset Sanajoukkoon muuttamatta käyttöliittymän käyttävien metodien nimiä, ei käyttöliittymää tarvitsisi muuttaa mitenkään.

Oleellista on tässä se, että Sanajoukko-luokkaan tehdyt sisäiset muutokset eivät vaikuta luokkaan Käyttöliittymä. Tämä johtuu siitä, että käyttöliittymä käyttää sanajoukkoa sen tarjoamien metodien -- eli julkisten rajapintojen -- kautta.

Uusien toiminnallisuuksien toteuttaminen: palindromit

Voi olla, että jatkossa ohjelmaa halutaan laajentaa siten, että Sanajoukko-luokan olisi osattava uusia asiota. Jos ohjelmassa haluttaisiin esimerkiksi tietää kuinka moni syötetyistä sanoista oli palindromi, voidaan sanajoukkoa laajentaa metodilla palindromeja.

public void kaynnista() {

    while (true) {
        System.out.print("Anna sana: ");
        String sana = lukija.nextLine();

        if (this.sanajoukko.sisaltaa(sana)) {
            break;
        }

        this.sanajoukko.lisaa(sana);
    }

    System.out.println("Annoit saman sanan uudestaan!");
    System.out.println("Sanoistasi " + this.sanajoukko.palindromeja() + " oli palindromeja");
}

Käyttöliittymä säilyy siistinä ja palindromien laskeminen jää Sanajoukko-olion huoleksi. Metodin toteutus voisi olla esimerkiksi seuraavanlainen.

import java.util.ArrayList;

public class Sanajoukko {
    private ArrayList<String> sanat;

    public Sanajoukko() {
        this.sanat = new ArrayList<>();
    }

    public boolean sisaltaa(String sana) {
        return this.sanat.contains(sana);
    }

    public void lisaa(String sana) {
        this.sanat.add(sana);
    }

    public int palindromeja() {
        int lukumaara = 0;

        for (String sana: this.sanat) {
            if (onPalindromi(sana)) {
                lukumaara++;
            }
        }

        return lukumaara;
    }

    public boolean onPalindromi(String sana) {
        int loppu = sana.length() - 1;

        int i = 0;
        while (i < sana.length() / 2) {
            // metodi charAt palauttaa annetussa indeksissä olevan merkin
            // alkeistyyppisenä char-muuttujana
            if(sana.charAt(i) != sana.charAt(loppu - i)) {
                return false;
            }

            i++;
        }

        return true;
    }
}

Metodissa palindromeja käytetään sekä apumetodia onPalindromi että virran filter-metodia. Virran count-metodi palauttaa long-tyyppisen kokonaisluvun, joka tulee muuntaa int-tyyppiseksi ennen sen palautusta metodista.

Uusiokäyttö

Kun ohjelmakoodin käsitteet on eriytetty omiksi luokikseen, voi niitä uusiokäyttää helposti muissa projekteissa. Esimerkiksi luokkaa Sanajoukko voisi käyttää yhtä hyvin graafisesta käyttöliittymästä, ja se voisi myös olla osa kännykässä olevaa sovellusta. Tämän lisäksi ohjelman toiminnan testaaminen on huomattavasti helpompaa silloin kun ohjelma on jaettu erillisiin käsitteisiin, joita kutakin voi käyttää myös omana itsenäisenä yksikkönään.

Neuvoja ohjelmointiin

Yllä kuvatussa laajemmassa esimerkissä noudatettiin seuraavia neuvoja.

  • Etene pieni askel kerrallaan
    • Yritä pilkkoa ongelma osaongelmiin ja ratkaise vain yksi osaongelma kerrallaan
    • Testaa aina että ohjelma on etenemässä oikeaan suuntaan eli että osaongelman ratkaisu meni oikein
    • Tunnista ehdot, minkä tapauksessa ohjelman tulee toimia eri tavalla. Esimerkiksi yllä tarkistus, jolla katsotaan onko sana jo syötetty, johtaa erilaiseen toiminnallisuuden.
  • Kirjoita mahdollisimman "siistiä" koodia
    • sisennä koodi
    • käytä kuvaavia muuttujien ja metodien nimiä
    • älä tee liian pitkiä metodeja, edes mainia
    • tee yhdessä metodissa vaan yksi asia
    • poista koodistasi kaikki copy-paste
    • korvaa koodisi "huonot" ja epäsiistit osat siistillä koodilla
  • Astu tarvittaessa askel taaksepäin ja mieti kokonaisuutta. Jos ohjelma ei toimi, voi olla hyvä idea palata aiemmin toimineeseen tilaan. Käänteisesti voidaan sanoa, että rikkinäinen ohjelma korjaantuu harvemmin lisäämällä siihen lisää koodia.

Ohjelmoijat noudattavat näitä käytänteitä sen takia että ohjelmointi olisi helpompaa. Käytänteiden noudattaminen tekee myös ohjelmien lukemisesta, ylläpitämisestä ja muokkaamisesta helpompaa muille.

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

Luokka Sanakirja

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

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

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

Testaa sanakirjasi toimintaa:

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

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

Sanojen lukumäärä

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

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

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

Tässä osassa kannattaa tutkiskella HashMapin valmiiksi tarjoamia metodeja... Vaihtoehtoisesti long-tyyppisen muuttujan saa muunnettua int-tyyppiseksi seuraavalla tavalla.

long lukuLongina = 1L;
int lukuInttina = (int) lukuLongina;

Kaikkien sanojen listaaminen

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

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

ArrayList<String> kaannokset = sanakirja.kaannoksetListana();

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

Tekstikäyttöliittymän alku

Harjoitellaan erillisen tekstikäyttöliittymän tekemistä. Luo luokka Tekstikayttoliittyma, jolla on seuraavat metodit:

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

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

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

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

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

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

Komento: apua
Tuntematon komento.

Komento: lopeta
Hei hei!

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

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

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

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

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

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

Komento: lopeta
Hei hei!

Yhteenveto

Tässä osassa tarkasteltiin tiedon lukemista erilaisista lähteistä, mukaanlukien tiedostoista ja verkosta. Tutustuimme myös hajautustauluihin, joita käytetään avain-arvo -parien tallentamiseen sekä tiedon etsimiseen avaimen perusteella. Opimme käyttämään Javan menetelmiä satunnaislukujen luomiseen, ja tarkastelimme myös static- ja ei-static -tyyppisten metodien eroja. Lopuksi keskityimme ohjelman pilkkomiseen useampaan osaan.

Kerrataan tässä vielä lyhyesti hajautustaulun toimintaa ja tarkastellaan sen hyödyntämistä kolmannessa osassa toteutetun "ajatustenlukijan" tekoälynä. Kolmannessa osassa tutuksi tullut ajatustenlukija oli seuraava -- se yrittää arvata valitsemasi numeron.

Alla on kurssin kolmannesta osasta tuttu "ajatustenlukija", joka yrittää arvata valitsemasi numeron.

Koneen voitot: 0
Koneen tappiot: 0

 

Kuten ehkä muistamme, ajatustenlukija ei ole oikeasti älykäs, vaan se pitää kirjaa kaikista käyttäjän aiemmista valinnoista, ja yrittää arvata käyttäjän seuraavan siirron näiden perusteella. Listan avulla toteutettu arvaustoiminnallisuus toimi hyvin, mutta lista tuli käydä kokonaan läpi uutta siirtoa pohdittaessa.

Voimme toteuttaa saman toiminnallisuuden hajautustaulujen avulla hieman fiksummin siten, että hyödynnämme hajautustaulua erilaisten merkkijonosarjojen esiintymisten laskurina. Jokainen hajautustauluun tallennettava merkkijonosarja vastaa (esimerkiksi) kolmea käyttäjän perättäistä siirtoa, ja siirtosarjaan liittyvä arvo niiden esiintymislukumäärää. Sen sijaan, että laskemme seuraavan siirron listan perusteella listan alkioita vertaillen, voimme hakea mahdollisten siirtojen esiintymiä suoraan hajautustaulusta.

Tarkastellaan tätä esimerkin kautta, ja syvennytään sen jälkeen mahdolliseen ohjelmalliseen toteutukseen. Oletetaan, että hajautustauluun tallennetaan kolmen syötteen mittaiset jaksot.

Tekoäly toimii, siirtoja vielä niin vähän, että valitaan siirto satunnaisesti.

Pelaajan ensimmäinen siirto, hajautustaulussa ei arvoja.

Siirrot:
0

Tekoäly toimii, siirtoja vielä niin vähän, että valitaan siirto satunnaisesti.

Pelaajan toinen siirto, hajautustaulussa ei arvoja.

Siirrot:
0 1

Tekoäly toimii, siirtoja vielä niin vähän, että valitaan siirto satunnaisesti.

Pelaajan kolmas siirto, hajautustaulussa yksi arvo.

Siirrot:
0 1 0

Hajautustaulu:
0 1 0=1

Tekoäly toimii. Etsitään hajautustaulusta pelaajan kahta viimeistä siirtoa sekä mahdollista seuraavaa siirtoa (joko 0 tai 1) kuvaavat avaimet, eli "1 0 0" ja "1 0 1". Mikäli toisen arvo on suurempi kuin toisen, oletetaan, että pelaaja tekee kyseisen valinnan. Tässä kyseistä hahmoa ei ole vielä näkynyt, joten arvataan satunnaisesti jompi kumpi luku.

Pelaajan neljäs siirto:

Siirrot:
0 1 0 1

Hajautustaulu:
0 1 0=1
1 0 1=1

Tekoäly toimii. Etsitään hajautustaulusta pelaajan kahta viimeistä siirtoa sekä mahdollista seuraavaa siirtoa (joko 0 tai 1) kuvaavat avaimet, eli "0 1 0" ja "0 1 1". Mikäli toisen arvo on suurempi kuin toisen, oletetaan, että pelaaja tekee kyseisen valinnan. Tässä hahmolla "0 1 0" on arvo 1, mikä on suurempi kuin hahmoa "0 1 1" vastaava arvo (0 tai olematon). Oletetaan, että pelaaja valitsee luvun "0" ja arvataan se.

Pelaajan viides siirto:

Siirrot:
0 1 0 1 1

Hajautustaulu:
0 1 0=1
1 0 1=1
0 1 1=1

Tekoäly toimii. Etsitään hajautustaulusta pelaajan kahta viimeistä siirtoa sekä mahdollista seuraavaa siirtoa (joko 0 tai 1) kuvaavat avaimet, eli "1 1 0" ja "1 1 1". Mikäli toisen arvo on suurempi kuin toisen, oletetaan, että pelaaja tekee kyseisen valinnan. Tässä kyseistä hahmoa ei ole vielä näkynyt, joten arvataan satunnaisesti jompi kumpi luku.

Pelaajan kuudes siirto:

Siirrot:
0 1 0 1 1 0

Hajautustaulu:
0 1 0=1
1 0 1=1
0 1 1=1
1 1 0=1

Tekoäly toimii. Etsitään hajautustaulusta pelaajan kahta viimeistä siirtoa sekä mahdollista seuraavaa siirtoa (joko 0 tai 1) kuvaavat avaimet, eli "1 0 0" ja "1 0 1". Mikäli toisen arvo on suurempi kuin toisen, oletetaan, että pelaaja tekee kyseisen valinnan. Tässä hahmolla "1 0 1" on arvo 1, mikä on suurempi kuin hahmoa "1 0 0" vastaava arvo (0 tai olematon). Oletetaan, että pelaaja valitsee luvun "1" ja arvataan se.

Pelaajan seitsemäs siirto:

Siirrot:
0 1 0 1 1 0 1

Hajautustaulu:
0 1 0=1
1 0 1=2
0 1 1=1
1 1 0=1

Ja niin edelleen. Tässä ajatustenlukijan algoritmi poikkeaa listaan perustuvasta algoritmista siten, että jokaisen tekoälyn tekemän arvauksen yhteydessä koko listaa ei tarvitse käydä läpi. Ajatustenlukijalla on siis nopeammin saatavilla pelaajan mahdolliset valinnat.

Ohjelmallisesti toiminta on seuraavanlainen.

System.out.println("Syötä nolla tai ykkönen, ihan sama, tiedän sen.");
Scanner lukija = new Scanner(System.in);

String viimeisimmat = "";
HashMap<String, Integer> muisti  = new HashMap<>();
int voitot = 0;
int tappiot = 0;

while (true) {
    // peli päättyy kun jommalla kummalla on yli 25 pistettä
    if (voitot >= 25 || tappiot >= 25) {
        break;
    }

    int arvaus = 0;
    if (viimeisimmat.length() == 2) {
        int arvaaNollan = muisti.getOrDefault(viimeisimmat + "0", 0);
        int arvaaYkkosen = muisti.getOrDefault(viimeisimmat + "1", 0);

        if(arvaaYkkosen > arvaaNollan) {
            arvaus = 1;
        }
    }

    System.out.print("Syötä 0 tai 1: ");
    int luku = Integer.parseInt(lukija.nextLine());
    if (luku != 0 && luku != 1) {
        System.out.println("höpönlöpön..");
        continue;
    }

    if (luku == arvaus) {
        voitot++;
    }

    // pidetään pelaajan kaksi viimeisintä arvausta tallessa
    viimeisimmat += luku;
    muisti.put(viimeisimmat, muisti.getOrDefault(viimeisimmat, 0) + 1);
    if (viimeisimmat.length() > 2) {
        viimeisimmat = viimeisimmat.substring(1);
    }


    System.out.println("Syötit " + luku + ", arvasin " + arvaus + ".");
    System.out.println("Tietokoneen voitot: " + voitot);
    System.out.println("Pelaajan voitot: " + tappiot);

    System.out.println();
}

System.out.println("Peli päättyi.");

Edellä kuvattu tekoäly toimii myös muutamissa muissa tapauksissa. Tässä tehtävässä sovellat sitä kivi-paperi-sakset -peliin. Mikäli peli ei ole sinulle tuttu, tutustu sen sääntöihin osoitteessa https://fi.wikipedia.org/wiki/Kivi,_paperi_ja_sakset.

Tehtäväpohjassa on annettuna valmiiksi pohja pelille, sekä muutamia apumetodeja. Tehtävänäsi on toteuttaa kivi paperi sakset -peliin tekoäly, joka hyödyntää pelaajan aiempia siirtoja omissa siirroissaan.

Noudata tekoälyssäsi seuraavaa strategiaa. Tässä strategia vielä lyhyesti:

  • Mikäli käyttäjä on syöttänyt alle 3 syötettä, tietokoneen tulee pelata "k" eli kivi.
  • Muulloin, tietokoneen tulee tarkastella pelaajan aiempia valintoja ja etsiä sopiva valinta. Sopivan valinnan etsiminen tehdään tarkastelemalla käyttäjän kahta viimeistä syötettä ja vertailemalla niitä koko historiaan. Mikäli historian mukaan kahta viimeistä syötettä seuraa useimmin kivi, tekoälyn tulee pelata paperi. Mikäli kahta viimeistä syötettä seuraa useimmin paperi, tekoälyn tulee pelata sakset. Mikäli taas kahta viimeistä syötettä seuraa useimmin sakset, tekoälyn tulee pelata kivi. Muissa tapauksissa pelataan kivi.

Tarkastele ohjelmaa ennen toteutusta. Varmista mm. että pelaajan aiemmat siirrot jäävät talteen tekoälyäsi varten.

Esimerkki:

Syötä k, p tai s: k
Pelaaja: k, tekoäly: k.
Tietokoneen voitot: 0
Pelaajan voitot: 0
Tasapelit: 1

Syötä k, p tai s: k
Pelaaja: k, tekoäly: k.
Tietokoneen voitot: 0
Pelaajan voitot: 0
Tasapelit: 2

Syötä k, p tai s: k
Pelaaja: k, tekoäly: k.
Tietokoneen voitot: 0
Pelaajan voitot: 0
Tasapelit: 3

Syötä k, p tai s: k
Pelaaja: k, tekoäly: p.
Tietokoneen voitot: 1
Pelaajan voitot: 0
Tasapelit: 3

Syötä k, p tai s: k
Pelaaja: k, tekoäly: p.
Tietokoneen voitot: 2
Pelaajan voitot: 0
Tasapelit: 3

Syötä k, p tai s: k
Pelaaja: k, tekoäly: p.
Tietokoneen voitot: 3
Pelaajan voitot: 0
Tasapelit: 3

Syötä k, p tai s: k
Pelaaja: k, tekoäly: p.
Tietokoneen voitot: 4
Pelaajan voitot: 0
Tasapelit: 3

Syötä k, p tai s: s
Pelaaja: s, tekoäly: p.
Tietokoneen voitot: 4
Pelaajan voitot: 1
Tasapelit: 3

Syötä k, p tai s: s
Pelaaja: s, tekoäly: k.
Tietokoneen voitot: 5
Pelaajan voitot: 1
Tasapelit: 3

Syötä k, p tai s: s
Pelaaja: s, tekoäly: k.
Tietokoneen voitot: 6
Pelaajan voitot: 1
Tasapelit: 3

Syötä k, p tai s: k
Pelaaja: k, tekoäly: k.
Tietokoneen voitot: 6
Pelaajan voitot: 1
Tasapelit: 4

Syötä k, p tai s: p
Pelaaja: p, tekoäly: k.
Tietokoneen voitot: 6
Pelaajan voitot: 2
Tasapelit: 4

...

Sisällysluettelo