Tehtävät
Yhdennentoista osion tavoitteet

Yhdennessätoista osassa tutustutaan olioiden luomiseen rajapinnoista sekä virran metodeissa käytettyyn lyhenteeseen a -> b. Tämän lisäksi käsitellään joukko apuvälineitä, joita ohjelmoija tarvitsee silloin tällöin. Suurin paino osassa on tehtävissä.

Tämän osan jälkeen ymmärrät mitä virtojen metodeille annetut "loitsut" ovat ja mitä ne tekevät. Osaat luoda säännöllisiä lausekkeita ja käyttää iteraattoria. Ymmärrät, että jokainen merkkijonon yhdistysoperaatio luo uuden merkkijonon, ja osaat käyttää StringBuilder-apuluokkaa tämän välttämiseen. Tunnet käsitteen geneerisyys ja tiedät miten kokoelmille kuten ArrayList määritellään kokoelman sisältämien olioiden tyyppi. Kehityt myös ohjelmoijana ohjelmointia harjoittelemalla.

Virta ja rajapinnat

Olemme käyttäneet virran luomiseen kokoelmiin liittyvää stream-metodia. Kuten merkittävä osa muistakin Javassa käytetyistä apuvälineistä, virta on olio. Tarkemmin tarkastellen, virta on Stream-rajapinnan toteuttava olio.

Virta tarjoaa käyttöömme erinäisiä kokoelmien käsittelyyn tarkoitettuja metodeja. Alla olevassa esimerkissä lukuja sisältävästä listasta valitaan ensin välillä [1...5] olevat luvut. Tämän jälkeen valitut luvut järjestetään, jonka jälkeen ne vielä kerrotaan kahdella. Lopulta luvut tulostetaan.

List<Integer> luvut = new ArrayList<>();
luvut.add(5);
luvut.add(7);
luvut.add(3);
luvut.add(1);
luvut.add(9);

luvut.stream()
    .filter(luku -> luku >= 1 && luku <= 5)
    .sorted()
    .map(luku -> luku * 2)
    .forEach(luku -> System.out.println(luku));
2
6
10

Olemme käyttäneet listoja useasti, mutta emme oikeastaan ole tarkemmin pohtineet miksi listan metodit toimivat kuten toimivat. Tutustutaan tähän seuraavaksi.

Olion luominen rajapinnasta

Mitä ihmettä komento alkio -> koodi oikeasti tekee?

Ohjelmoija voi halutessaan luoda rajapinnasta olion ilman erillistä rajapinnan toteuttavaa luokkaa. Tarkastellaan seuraavaa rajapintaa Luettava.

public interface Luettava {
    String lue();
}

Teimme kahdeksannessa osassa useita Luettava-rajapinnan toteuttavia luokkia. Uuden luokan toteuttaminen ei kuitenkaan ole aina pakollista. Voimme tehdä rajapinnan toteuttavan olion ilman erillistä rajapinnan toteuttavaa luokkaa. Tällöin vastuullemme tulee metodin toiminnallisuuden määritteleminen olion luonnin yhteydessä.

Luettava luettava = new Luettava() {
    @Override
    public String lue() {
        return "Ok! -- Luettu!";
    };
};

System.out.println(luettava.lue());
Ok! -- Luettu!

Yllä luodaan Luettava-rajapinnan toteuttava olio. Olion metodi lue palauttaa merkkijonon "Ok! -- Luettu!".

Edellisessä esimerkissä nähty Luettava-olion luominen voidaan toteuttaa myös lyhyemmin. Rajapinnalla on vain yksi toteutettava metodi, joka ei saa parametreja ja palauttaa merkkijonon.

Luettava luettava = () -> "Ok! -- Luettu!";
System.out.println(luettava.lue());
Ok! -- Luettu!

Yllä lause () -> "Ok! -- Luettu!" luo uuden Luettava-rajapinnan toteuttavan olion ja määrittelee metodin lue toiminnan. Koska rajapinnalla on vain yksi toteutettava metodi, Java osaa päätellä minkä metodin toiminnallisuutta yllä määritellään. Lauseessa olevat sulut kertovat metodin parametreista, ja nuolen oikealle puolelle tulee tässä tapauksessa metodin palauttama arvo.

Entä jos käytössämme olisi rajapinta, joka ei palauttaisi arvoa ja jolle annettaisiin parametrina merkkijono? Tarkastellaan seuraavaa rajapintaa Tulostaja.

public interface Tulostaja {
    void tulosta(String merkkijono);
}

Olion luominen rajapinnasta onnistuu kuten edellä. Tehdään rajapinnasta olio, joka kutsuu parametrina annetulle merkkijonolle tulostusmetodia System.out.println.

Tulostaja tulostaja = new Tulostaja() {
    @Override
    public void tulosta(String merkkijono) {
        System.out.println(merkkijono);
    }
};

tulostaja.tulosta("Hei maailma!");
Hei maailma!

Kutsun voi kirjoittaa myös lyhyemmässä muodossa.

Tulostaja tulostaja = (String merkkijono) -> {
    System.out.println(merkkijono);
};

tulostaja.tulosta("Hei maailma!");

Voimme myös jättää muuttujan tyypin sekä aaltosulut pois metodimäärittelystä. Aaltosulkujen poisjättäminen onnistuu, sillä metodissa tehdään vain yksi komento.

Tulostaja tulostaja = merkkijono -> System.out.println(merkkijono);

tulostaja.tulosta("Hei maailma!");

Yllä oleva lause näyttää tutulta.

Virran metodit ja niiden parametrit

Virran metodit saavat parametrina rajapintoja toteuttavia olioita. Metodille filter annetaan parametrina Predicate-rajapinnan toteuttava olio, ja metodille map annetaan parametrina rajapinnan Function toteuttava olio. Metodi forEach taas saa parametrinaan Consumer-rajapinnan toteuttavan olion.

Alussa näkemämme lukujen rajaamiseen, järjestämiseen, muuntamiseen ja tulostamiseen liittyvän esimerkin voi kirjoittaa useammalla eri tavalla. Alla ensimmäinen tapa on "normaali", eli tapa, jota olemme tähän asti käyttäneet. Esimerkkiä seuraa ohjelman toimintaa hieman avaava esimerkki, missä jokainen rajapintaolio on luotu erikseen.

List<Integer> luvut = new ArrayList<>();
luvut.add(5);
luvut.add(7);
luvut.add(3);
luvut.add(1);
luvut.add(9);

luvut.stream()
    .filter(luku -> luku >= 1 && luku <= 5)
    .sorted()
    .map(luku -> luku * 2)
    .forEach(luku -> System.out.println(luku));
List<Integer> luvut = new ArrayList<>();
luvut.add(5);
luvut.add(7);
luvut.add(3);
luvut.add(1);
luvut.add(9);

Predicate<Integer> rajaus = new Predicate<Integer>() {
    @Override
    public boolean test(Integer luku) {
        return luku >= 1 && luku <= 5;
    }
};

Function muunnos = new Function<Integer, Integer>() {
    public Integer apply(Integer luku) {
        return luku * 2;
    }
};

Consumer tulostus = new Consumer<Integer>() {
    @Override
    public void accept(Integer luku) {
        System.out.println(luku);
    }
};

luvut.stream()
    .filter(rajaus)
    .sorted()
    .map(muunnos)
    .forEach(tulostus);

Kun esimerkkejä tarkastelee, lienee selvää, että ensimmäinen esimerkeistä on ymmärrettävämpi.

Tässä tehtävässä teemme useita edellä nähdyn Predicate-rajapinnan toteuttavia luokkia, joiden avulla on mahdollista filtteröidä rivejä Project Gutenbergin sivuilta löytyvistä kirjoista. Seuraavassa esimerkkinä Dostojevskin Kahden sydämen tarinat: Eriskummallinen kertomus. Haluamme, että erilaisia filtteröintiehtoja on monelaisia ja että filtteröinti voi tapahtua myös eri ehtojen kombinaationa. Ohjelman rakenteen tulee myös mahdollistaa uusien ehtojen lisääminen myöhemmin.

Seuraavassa eräs rajapinnan Predicate toteuttava filtteriluokka. Luokka rajaa String-tyyppisiä olioita.

public class SisaltaaSanan implements Predicate<String> {

    String sana;

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

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

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

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

public class GutenbergLukija {

    private List<String> sanat;

    public GutenbergLukija(String osoite) throws IllegalArgumentException {
    // kirjan verkosta hakeva koodi
    }

    public List<String> rivitJoilleVoimassa(Predicate<String> ehto){
        return sanat.stream()
            .filter(ehto)
            .collect(Collectors.toList());
            // metodi Collectors.toList() luo listan, johon tulokset lisätään
    }
}

Seuraavassa tulostetaan kaikki rivit, joilla esiintyy sana "silloin":

String osoite = "http://www.gutenberg.org/cache/epub/52537/pg52537.txt";
GutenbergLukija kirja = new GutenbergLukija(osoite);

Predicate<String> ehto = new SisaltaaSanan("silloin");

kirja.rivitJoilleVoimassa(ehto).stream().forEach(rivi -> {
    System.out.println(rivi);
});

Kaikki sanat

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

String osoite = "http://www.gutenberg.org/cache/epub/52537/pg52537.txt";
GutenbergLukija kirja = new GutenbergLukija(osoite);

Predicate<String> ehto = new KaikkiRivit();

kirja.rivitJoilleVoimassa(ehto).stream().forEach(rivi -> {
    System.out.println(rivi);
});

Loppuu huuto- tai kysymysmerkkiin

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

String osoite = "http://www.gutenberg.org/cache/epub/52537/pg52537.txt";
GutenbergLukija kirja = new GutenbergLukija(osoite);

Predicate<String> ehto = new LoppuuHuutoTaiKysymysmerkkiin();

kirja.rivitJoilleVoimassa(ehto).stream().forEach(rivi -> {
    System.out.println(rivi);
});

Muistutus: yksittäisten merkkien vertailu Javassa tapahtuu == operaattorilla:

String nimi = "pekka";

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

Pituus vähintään

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

String osoite = "http://www.gutenberg.org/cache/epub/52537/pg52537.txt";
GutenbergLukija kirja = new GutenbergLukija(osoite);


kirja.rivitJoilleVoimassa(new PituusVahintaan(40)).stream().forEach(rivi -> {
    System.out.println(rivi);
});

Molemmat

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

String osoite = "http://www.gutenberg.org/cache/epub/52537/pg52537.txt";
GutenbergLukija kirja = new GutenbergLukija(osoite);

Predicate<String> ehto = new Molemmat(
    new LoppuuHuutoTaiKysymysmerkkiin(),
    new SisaltaaSanan("beer")
);

kirja.rivitJoilleVoimassa(ehto).stream().forEach(rivi -> {
    System.out.println(rivi);
});

Negaatio

Tee rajapinnan Predicate toteuttava luokka Ei. Luokan oliot saavat parametrina rajapinnan Predicate toteuttavan olion. Ei-olio kelpuuttaa ne rivit, joita sen parametrina saama ehto ei kelpuuta.

Seuraavassa tulostetaan rivit, joiden pituus vähemmän kuin 10.

String osoite = "http://www.gutenberg.org/cache/epub/52537/pg52537.txt";
GutenbergLukija kirja = new GutenbergLukija(osoite);

Predicate<String> ehto = new Ei(new PituusVahintaan(10));

kirja.rivitJoilleVoimassa(ehto).stream().forEach(rivi -> {
    System.out.println(rivi);
});

Vähintään yksi

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

String osoite = "http://www.gutenberg.org/cache/epub/52537/pg52537.txt";
GutenbergLukija kirja = new GutenbergLukija(osoite);

Predicate ehto = new VahintaanYksi(
    new SisaltaaSanan("jalokivi"),
    new SisaltaaSanan("kulta"),
    new SisaltaaSanan("hopea")
);

kirja.rivitJoilleVoimassa(ehto).stream().forEach(rivi -> {
    System.out.println(rivi);
});

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

Predicate<String> sanat = new VahintaanYksi(
    new SisaltaaSanan("jalokivi"),
    new SisaltaaSanan("kulta"),
    new SisaltaaSanan("hopea")
);

Predicate<String> oikeaPituus = new Molemmat(
    new PituusVahintaan(20),
    new Ei(new PituusVahintaan(31))
);

Predicate halutut = new Molemmat(sanat, oikeaPituus);
Muutamia kertaus- ja pähkinätehtäviä

Seuraavassa on kertaus- ja pähkinätehtäviä, joissa tavoitteena on astua eräästä ohjelmointiin liittyvästä ajatusmallista ulos. Aloittelevat ohjelmoijat -- miksei hieman kokeneemmatkin -- kokevat usein, että ongelmaan on olemassa vain yksi ratkaisu. Seuraavissa tehtävissä jokaiseen tehtävään tulee kirjoittaa kaksi ratkaisua, missä ratkaiset ongelman eri tavoilla. Ratkaisutapa lasketaan erilaiseksi jos ainakin osa ohjelmakoodista on toteutettu eri tavalla. Muuttujien nimien muuttamista ei lasketa eri tavoiksi, mutta ratkaisun pilkkominen useammaksi metodiksi tai täysin erilainen ongelmanratkaisulogiikka (esim. virrat vs. toistolauseet) taas kelpaa.

Tehtävissä ei ole ohjelmointiprosessia tukevia automaattisia testejä. Mieti siis myös miten tarkistat, että ratkaisusi toimivat oikein.

Huomaathan, että juuri kerrattu virta ei ole ratkaisu kaikkiin seuraavassa esiteltyihin ongelmiin..

Painoindeksi on mitta-arvo, jonka avulla voidaan arvioida ihmisen painon ja pituuden suhdetta. Painoindeksi lasketaan kaavalla:

Painoindeksi = paino / (pituus * pituus)

Painoindeksiä käytetään muunmuassa ali- ja ylipainon tunnistamisessa. Jos henkilön painoindeksi on alle 18.5 luokitellaan hänet alipainoiseksi ("alipaino"). Jos painoindeksi on vähintään 18.5 mutta alle 25, on luokittelu "normaali". Jos taas painoindeksi on vähintään 25 mutta alle 30, on luokittelu "ylipainoinen". Jos taas painoindeksi on vähintään 30, on luokittelu "merkittävästi ylipainoinen".

Luokissa Raportinluoja1 ja Raportinluoja2 on metodi public PainoindeksiRaportti painoindeksiRaportti(List<Henkilo> henkilotiedot), joka saa parametrina listan henkilöitä ja palauttaa painoindeksiraportin -- kannattaa tutustua luokkiin Painoindeksiraportti ja Henkilo.

Luo luokkiin Raportinluoja1 ja Raportinluoja2 erilaiset toteutukset metodille painoindeksiRaportti painoindeksiraporttien luomiseen.

Tuotettavan painoindeksiraportin tulee sisältää lista nimistä (huom! ei henkilöistä) siten, että henkilöt ovat kategorisoitu heihin sopiviin painoindeksiluokkiin.

Testaa toteutustasi ennen sen palautusta. Koska tehtävässä ei ole automaattisia testejä, mieti myös miten ja minkälaisilla syötteillä testaat sen toimintaa.

Data-analytiikassa mittausten tasoittamisella tarkoitetaan liiallisen kohinan tai muiden häiriöiden poistamiseen datasta, jonka jälkeen oleellisten hahmojen tunnistamista datasta tulee mahdollisesti helpommaksi. Eräs suoraviivainen tekniikka mittausten tasoitukseen on muuttaa jokainen mittausarvo sen, sitä edeltävän mittausarvon ja sitä seuraavan mittausarvon mittausten keskiarvoksi. Jos oletamme, että poikkeukselliset arvot ovat häiriö mittadatassa, tämä keskiarvomenetelmä tasaa arvot potentiaalisesti luotettavimmiksi arvoiksi.

Tutkitaan esimerkiksi seuraavia sykemittauksia, jotka on kerätty henkilötietodatasta.

95 102 98 88 105

Jos ylläolevan mittausdatan tasaa keskiarvomenetelmällä, on tasauksen tuottama data seuraavanlainen:

95 98.33 96 97 105

Tässä:

  • Arvo 102 muutettiin arvoon 98.33: (95 + 102 + 98) / 3
  • Arvo 98 muutettiin arvoon 96: (102 + 98 + 88) / 3
  • Arvo 88 muutettiin arvoon 97: (98 + 88 + 105) / 3

Luokissa MittaustenTasoittaja1 ja MittaustenTasoittaja2 on metodi public List<Double> tasoita(List<Henkilo> henkilotiedot), joka saa parametrina listan henkilö-olioita (henkilöiden nimillä ei ole väliä, oleellista on sykemittausdata -- muuttuja syke) ja palauttaa listan tasattuja sykemittauksia -- luokka Henkilo on tässä sama kuin edellisessä tehtävässä.

Luo luokkiin MittaustenTasoittaja1 ja MittaustenTasoittaja2 erilaiset toteutukset metodille tasoita listana annettujen henkilo-olioihin tallennettujen sykemittausten tasoittamiseen. Toteutusten tulee siis käsitellä lista henkilötietueita, joista jokaisessa on sykemittaus, ja palauttaa lista double-arvoja, jotka ovat tasoitettuja sykemittauksia.

Luokissa YleisimmatSanat1 ja YleisimmatSanat2 on metodi public List<String> yleisetSanat(List<String> sanat), joka saa parametrina listan merkkijonoja ja palauttaa listan merkkijonoja.

Luo luokkiin YleisimmatSanat1 ja YleisimmatSanat2 erilaiset toteutukset kolmen yleisimmän merkkijonon tunnistamiseen. Yleisimmät merkkijonot tulee tunnistaa metodille yleisetSanat syötteeksi annetusta listasta, ja metodin tulee palauttaa yleisimmät merkkijonot listassa. Palauttava lista tulee olla järjestettynä siten, että listan ensimmäisenä alkiona on yleisin merkkijono, toisena alkiona on toiseksi yleisin merkkijono, ja kolmantena alkiona on kolmanneksi yleisin merkkijono.

Jos merkkijonot ovat yhtä yleisiä, aseta lyhin sana (vähiten merkkejä) ennen pidempää sanaa. Voit olettaa, että syötteen kolme yleisintä sanaa ovat eri pituisia. Voit lisäksi olettaa, että syötteessä on vähintään kolme eri sanaa.

Testaa toteutustasi ennen sen palautusta. Koska tehtävässä ei ole automaattisia testejä, mieti myös miten ja minkälaisilla syötteillä testaat sen toimintaa.

Geologit haluavat tarkastella paikallisen vuoren mahdollista maanjäristystoimintaa. He ovat asentaneet mittarin seismisen toiminnan (maan tärinän) mittaamiseen. Mittari lukee seismistä toimintaa tietyin aikavälein ja lähettää mitattua dataa mittausarvo kerrallaan tutkimuslaboratorion tietokoneelle.

Mittari lisää lisäksi mittausdataan päivämäärätietoja näyttämään seismisen toiminnan mittauspäivää. Mittarin lähettämä data on seuraavassa muodossa:

20151004 200 150 175 20151005 0.002 0.03 20151007 ...

Kahdeksanlukuiset arvot ovat päivämääriä (vuosi-kuukausi-päivä -muodossa) ja numerot nollan ja viidensadan välillä ovat värähtelyjen taajuuksia (hertzeinä). Ylläoleva esimerkki näyttää mittaukset 200, 150, ja 175 lokakuun neljäntenä päivänä vuonna 2015 ja mittaukset 0.002 ja 0.03 lokakuun viidentenä päivänä vuonna 2015. Lokakuun kuudennelta päivältä ei ole lainkaan mittausdataa (välillä verkkoyhteydessä on ongelmia, jolloin mittausdataa saattaa kadota).

Oleta, että mittausdata on järjestetty päivämäärien mukaan (myöhempi päivämäärä ei ikinä ilmesty datassa ennen aiempaa päivämäärää) ja että kaikki data on samalta vuodelta. Voit myös olettaa, että jokaiselta datassa olevalta päivältä on vähintään yksi mittausarvo.

Luokissa MittausRaportoija1 ja MittausRaportoija2 on tyhjä metodi List<SuurinTaajuusRaportti> paivittaisetMaksimit(List<Double> mittausData, int kuukausi), joka saa parametrina listan mittausdataa sekä kuukauden (oleta, että yksi (01) vastaa tammikuuta ja kaksitoista (12) vastaa joulukuuta). Metodin tulee tuottaa lista raportteja, joista jokainen sisältää suurimman mittaustuloksen kuukauden yksittäiselle päivälle, josta löytyy mittausdataa.

Suunnittele ja toteuta kaksi erilaista toteutusta metodille paivittaisetMaksimit ja toteuta ne luokkiin MittausRaportoija1 ja MittausRaportoija2. Metodin tulee siis käsitellä lista Double-muotoisia syötteitä, joista löytyy sekä päivämääriä että mittausarvoja. Metodin tulee käsitellä vain parametrina annettuun kuukauteen liittyviä arvoja, ja syötteiden perusteella tulee tunnistaa jokaiselle parametrina annetulle kuukauden päivälle suurin päiväkohtainen arvo. Suurimmat päiväkohtaiset arvot asetetaan palautettavaan listaan SuurinTaajuusRaportti-muotoisina olioina, ja metodi palauttaa lopulta listan tulevaa käsittelyä varten.

Kuten edellisissä tehtävissä, testaa tässäkin toteutustasi ennen sen palautusta. Koska tehtävässä ei ole automaattisia testejä, mieti myös miten ja minkälaisilla syötteillä testaat sen toimintaa.

Kun olet tehnyt neljä edellistä tehtävää (tai ainakin yrittänyt tehdä kaikkia neljää tehtävää), vastaa vielä osoitteessa http://goo.gl/forms/VZ2yyRNUVB olevaan kyselyyn.

Muutamia yleishyödyllisiä tekniikoita

Tutustutaan seuraavaksi muutamaan ohjelmoinnissa varsin näppärään tekniikaan sekä luokkaan.

Säännölliset lausekkeet

Säännöllinen lauseke määrittelee joukon merkkijonoja tiiviissä muodossa. Säännöllisiä lausekkeita käytetään muunmuassa merkkijonojen oikeellisuuden tarkistamiseen. Merkkijonojen oikeellisuuden tarkastaminen tapahtuu luomalla säännöllinen lauseke, joka määrittelee merkkijonot, jotka ovat oikein.

Tarkastellaan ongelmaa, jossa täytyy tarkistaa, onko käyttäjän antama opiskelijanumero oikeanmuotoinen. Opiskelijanumero alkaa merkkijonolla "01", jota seuraa 7 numeroa väliltä 0–9.

Opiskelijanumeron oikeellisuuden voisi tarkistaa esimerkiksi käymällä opiskelijanumeroa esittävän merkkijonon läpi merkki merkiltä charAt-metodin avulla. Toinen tapa olisi tarkistaa että ensimmäinen merkki on "0", ja käyttää Integer.parseInt metodikutsua merkkijonon muuntamiseen numeroksi. Tämän jälkeen voisi tarkistaa että Integer.parseInt-metodin palauttama luku on pienempi kuin 20000000.

Oikeellisuuden tarkistus säännöllisten lausekkeiden avulla tapahtuu ensin sopivan säännöllisen lausekkeen määrittelyn. Tämän jälkeen käytetään String-luokan metodia matches, joka tarkistaa vastaako merkkijono parametrina annettua säännöllistä lauseketta. Opiskelijanumeron tapauksessa sopiva säännöllinen lauseke on "01[0-9]{7}", ja käyttäjän syöttämän opiskelijanumeron tarkistaminen käy seuraavasti:

System.out.print("Anna opiskelijanumero: ");
String numero = lukija.nextLine();

if (numero.matches("01[0-9]{7}")) {
    System.out.println("Muoto on oikea.");
} else {
    System.out.println("Muoto ei ole oikea.");
}

Käydään seuraavaksi läpi eniten käytettyjä säännöllisten lausekkeiden merkintöjä.

Vaihtoehtoisuus (pystyviiva)

Pystyviiva tarkoittaa, että säännöllisen lausekkeen osat ovat vaihtoehtoisia. Esimerkiksi lauseke 00|111|0000 määrittelee merkkijonot 00, 111 ja 0000. Metodi matches palauttaa arvon true jos merkkijono vastaa jotain määritellyistä vaihtoehdoista.

String merkkijono = "00";

if (merkkijono.matches("00|111|0000")) {
    System.out.println("Merkkijonosta löytyi joku kolmesta vaihtoehdosta");
} else {
    System.out.println("Merkkijonosta ei löytynyt yhtäkään vaihtoehdoista");
}
Merkkijonosta löytyi joku kolmesta vaihtoehdosta

Säännöllinen lauseke 00|111|0000 vaatii että merkkijono on täsmälleen määritellyn muotoinen: se ei määrittele "contains"-toiminnallisuutta.

String merkkijono = "1111";

if (merkkijono.matches("00|111|0000")) {
    System.out.println("Merkkijonosta löytyi joku kolmesta vaihtoehdosta");
} else {
    System.out.println("Merkkijonosta ei löytynyt yhtäkään vaihtoehdoista");
}
Merkkijonosta ei löytynyt yhtäkään vaihtoehdoista

Merkkijonon osaan rajattu vaikutus (sulut)

Sulkujen avulla voi määrittää, mihin säännöllisen lausekkeen osaan sulkujen sisällä olevat merkinnät vaikuttavat. Jos haluamme sallia merkkijonot 00000 ja 00001, voimme määritellä ne pystyviivan avulla muodossa 00000|00001. Sulkujen avulla voimme rajoittaa vaihtoehtoisuuden vain osaan merkkijonoa. Lauseke 0000(0|1) määrittelee merkkijonot 00000 ja 00001.

Vastaavasti säännöllinen lauseke auto(|n|a) määrittelee sanan auto yksikön nominatiivin (auto), genetiivin (auton), partitiivin (autoa) ja akkusatiivin (auto tai auton).

System.out.print("Kirjoita joku sanan auto yksikön taivutusmuoto: ");
String sana = lukija.nextLine();

if (sana.matches("auto(|n|a|ssa|sta|on|lla|lta|lle|na|ksi|tta)")) {
    System.out.println("Oikein meni! RRrakastan tätä kieltä!");
} else {
    System.out.println("Taivutusmuoto ei ole oikea.");
}

Toistomerkinnät

Usein halutaan, että merkkijonossa toistuu jokin tietty alimerkkijono. Säännöllisissä lausekkeissa on käytössä seuraavat toistomerkinnät:

  • Merkintä * toisto 0... kertaa, esim
    String merkkijono = "trolololololo";
    
    if (merkkijono.matches("trolo(lo)*")) {
        System.out.println("Muoto on oikea.");
    } else {
        System.out.println("Muoto ei ole oikea.");
    }
    
    Muoto on oikea.
    
  • Merkintä + toisto 1... kertaa, esim
    String merkkijono = "trolololololo";
    
    if (merkkijono.matches("tro(lo)+")) {
        System.out.println("Muoto on oikea.");
    } else {
        System.out.println("Muoto ei ole oikea.");
    }
    
    Muoto on oikea.
    
    String merkkijono = "nänänänänänänänä Bätmään!";
    
    if (merkkijono.matches("(nä)+ Bätmään!")) {
        System.out.println("Muoto on oikea.");
    } else {
        System.out.println("Muoto ei ole oikea.");
    }
    
    Muoto on oikea.
    
  • Merkintä ? toisto 0 tai 1 kertaa, esim
    String merkkijono = "You have to accidentally the whole meme";
    
    if (merkkijono.matches("You have to accidentally (delete )?the whole meme")) {
        System.out.println("Muoto on oikea.");
    } else {
        System.out.println("Muoto ei ole oikea.");
    }
    
    Muoto on oikea.
    
  • Merkintä {a} toisto a kertaa, esim
    String merkkijono = "1010";
    
    if (merkkijono.matches("(10){2}")) {
        System.out.println("Muoto on oikea.");
    } else {
        System.out.println("Muoto ei ole oikea.");
    }
    
    Muoto on oikea.
    
  • Merkintä {a,b} toisto a ... b kertaa, esim
    String merkkijono = "1";
    
    if (merkkijono.matches("1{2,4}")) {
        System.out.println("Muoto on oikea.");
    } else {
        System.out.println("Muoto ei ole oikea.");
    }
    
    Muoto ei ole oikea.
    
  • Merkintä {a,} toisto a ... kertaa, esim
    String merkkijono = "11111";
    
    if (merkkijono.matches("1{2,}")) {
        System.out.println("Muoto on oikea.");
    } else {
        System.out.println("Muoto ei ole oikea.");
    }
    
    Muoto on oikea.
    

Samassa säännöllisessä lausekkeessa voi käyttää myös useampia toistomerkintöjä. Esimerkiksi säännöllinen lauseke 5{3}(1|0)*5{3} määrittelee merkkijonot, jotka alkavat ja loppuvat kolmella vitosella. Välissä saa tulla rajaton määrä ykkösiä ja nollia.

Merkkiryhmät (hakasulut)

Merkkiryhmän avulla voi määritellä lyhyesti joukon merkkejä. Merkit kirjoitetaan hakasulkujen sisään, ja merkkivälin voi määrittää viivan avulla. Esimerkiksi merkintä [145] tarkoittaa samaa kuin (1|4|5) ja merkintä [2-36-9] tarkoittaa samaa kuin (2|3|6|7|8|9). Vastaavasti merkintä [a-c]* määrittelee säännöllisen lausekkeen, joka vaatii että merkkijono sisältää vain merkkejä a, b ja c.

Harjoitellaan hieman säännöllisten lausekkeiden käyttöä. Tehtävissä haetut metodit tehdään luokkaan Tarkistin.

Viikonpäivä

Tee säännöllisen lausekkeen avulla metodi public boolean onViikonpaiva(String merkkijono), joka palauttaa true jos sen parametrina saama merkkijono on viikonpäivän lyhenne (ma, ti, ke, to, pe, la tai su).

Esimerkkitulostuksia metodia käyttävästä ohjelmasta:

Anna merkkijono: ti
Muoto on oikea.
Anna merkkijono: abc
Muoto ei ole oikea.

Vokaalitarkistus

Tee metodi public boolean kaikkiVokaaleja(String merkkijono) joka tarkistaa säännöllisen lausekkeen avulla ovatko parametrina olevan merkkijonon kaikki merkit vokaaleja.

Esimerkkitulostuksia metodia käyttävästä ohjelmasta:

Anna merkkijono: aie
Muoto on oikea.
Anna merkkijono: ane
Muoto ei ole oikea.

Kellonaika

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

Tee metodi public boolean kellonaika(String merkkijono) ohjelma, joka tarkistaa säännöllisen lausekkeen avulla onko parametrina oleva merkkijono muotoa tt:mm:ss oleva kellonaika (tunnit, minuutit ja sekunnit kaksinumeroisina).

Esimerkkitulostuksia metodia käyttävästä ohjelmasta:

Anna merkkijono: 17:23:05
Muoto on oikea.
Anna merkkijono: abc
Muoto ei ole oikea.
Anna merkkijono: 33:33:33
Muoto ei ole oikea.

Nykyään lähes kaikista ohjelmointikielistä löytyy tuki säännöllisille lausekkeille. Säännöllisten lausekkeiden teoriaa tarkastellaan muunmuassa kurssilla Laskennan mallit. Lisää säännöllisistä lausekkeista löydät esim. googlaamalla hakusanalla regular expressions java -- kannattaa myös lukea Codinghorror-blogin lyhyt artikkeli Regex use vs. Regex abuse.

Lueteltu tyyppi eli Enum

Jos tiedämme muuttujien mahdolliset arvot ennalta, voimme käyttää niiden esittämiseen enum-tyyppistä luokkaa eli lueteltua tyyppiä. Luetellut tyypit ovat oma luokkatyyppinsä rajapinnan ja normaalin luokan lisäksi. Lueteltu tyyppi määritellään avainsanalla enum. Esimerkiksi seuraava Maa-enumluokka määrittelee neljä vakioarvoa: RUUTU, PATA, RISTI ja HERTTA.

public enum Maa {
    RUUTU, PATA, RISTI, HERTTA
}

Yksinkertaisimmassa muodossaan enum luettelee pilkulla erotettuina määrittelemänsä vakioarvot. Lueteltujen tyyppien arvot eli vakiot on yleensä tapana kirjoittaa kokonaan isoin kirjaimin.

Enum luodaan (yleensä) omaan tiedostoon, samaan tapaan kuin luokka tai rajapinta. NetBeansissa Enumin saa luotua valitsemalla projektin kohdalla new/other/java/java enum.

Seuraavassa luokka Kortti jossa maa esitetään enumin avulla:

public class Kortti {

    private int arvo;
    private Maa maa;

    public Kortti(int arvo, Maa maa) {
        this.arvo = arvo;
        this.maa = maa;
    }

    @Override
    public String toString() {
        return maa + " " + arvo;
    }

    public Maa getMaa() {
        return maa;
    }

    public int getArvo() {
        return arvo;
    }
}

Korttia käytetään seuraavasti:

Kortti eka = new Kortti(10, Maa.HERTTA);

System.out.println(eka);

if (eka.getMaa() == Maa.PATA) {
    System.out.println("on pata");
} else {
    System.out.println("ei ole pata");
}

Tulostuu:

HERTTA 10
ei ole pata

Huomaamme, että enumin tunnukset tulostuvat mukavasti! Koska kortin maat ovat nyt tyyppiä Maa ei ylemmän esimerkin "järjenvastaiset" kummallisuudet, esim. "maan korottaminen toiseen potenssiin" onnistu. Oraclella on enum-tyyppiin liittyvä sivusto osoitteessa http://docs.oracle.com/javase/tutorial/java/javaOO/enum.html.

Lueteltujen tyyppien oliomuuttujat

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

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

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

    private String koodi;        // oliomuuttuja

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

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

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

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

Iteraattori

Tarkastellaan seuraavaa luokkaa Kasi, joka mallintaa tietyssä korttipelissä pelaajan kädessä olevien korttien joukkoa:

public class Kasi {
    private List<Kortti> kortit;

    public Kasi() {
        this.kortit = new ArrayList<>();
    }

    public void lisaa(Kortti kortti) {
        this.kortit.add(kortti);
    }

    public void tulosta() {
        this.kortit.stream().forEach(kortti -> {
            System.out.println(kortti);
        });
    }
}

Luokan metodi tulosta tulostaa jokaisen kädessä olevan kortin.

ArrayList ja muut Collection-rajapinnan toteuttavat "oliosäiliöt" toteuttavat rajapinnan Iterable, ja ne voidaan käydä läpi myös käyttäen iteraattoria, eli olioa, joka on varta vasten tarkoitettu tietyn oliokokoelman läpikäyntiin. Seuraavassa on iteraattoria käyttävä versio korttien tulostamisesta:

public void tulosta() {
    Iterator<Kortti> iteraattori = kortit.iterator();

    while (iteraattori.hasNext()) {
        System.out.println(iteraattori.next());
    }
}

Iteraattori pyydetään kortteja sisältävältä listalta kortit. Iteraattori on ikäänkuin "sormi", joka osoittaa aina tiettyä listan sisällä olevaa olioa, ensin ensimmäistä ja sitten seuraavaa jne... kunnes "sormen" avulla on käyty jokainen olio läpi.

Iteraattori tarjoaa muutaman metodin. Metodilla hasNext() kysytään onko läpikäytäviä olioita vielä jäljellä. Jos on, voidaan iteraattorilta pyytää seuraavana vuorossa oleva olio metodilla next(). Metodi siis palauttaa seuraavana läpikäyntivuorossa olevan olion ja laittaa iteraattorin eli "sormen" osoittamaan seuraavana vuorossa olevaa läpikäytävää olioa.

Iteraattorin next-metodin palauttama olioviite voidaan ottaa toki talteen myös muuttujaan, eli metodi tulosta voitaisiin muotoilla myös seuraavasti.

public void tulosta(){
    Iterator<Kortti> iteraattori = kortit.iterator();

    while (iteraattori.hasNext()) {
        Kortti seuraavanaVuorossa = iteraattori.next();
        System.out.println(seuraavanaVuorossa);
    }
}

Tarkastellaan seuraavaksi yhtä iteraattorin käyttökohdetta. Motivoidaan käyttökohde ensin ongelmallisella lähestymistavalla. Yritämme tehdä virran avulla metodia, joka poistaa käsiteltävästä virrasta ne kortit, joiden arvo on annettua arvoa pienempi.

public class Kasi {
    // ...

    public void poistaHuonommat(int arvo) {
        this.kortit.stream().forEach(kortti -> {
            if (kortti.getArvo() < arvo) {
                kortit.remove(kortti);
            }
        });
    }
}

Metodin suoritus aiheuttaa ongelman.

Exception in thread "main" java.util.ConcurrentModificationException
        at ...
Java Result: 1

Virheen syynä on se, että listan läpikäynti forEach-metodilla olettaa, ettei listaa muokata läpikäynnin yhteydessä. Listan muokkaaminen (eli tässä tapauksessa alkion poistaminen) aiheuttaa virheen -- voimme ajatella, että komento forEach menee tästä "sekaisin".

Jos listalta halutaan poistaa osa olioista läpikäynnin aikana osa, tulee tämä tehdä iteraattoria käyttäen. Iteraattori-olion metodia remove kutsuttaessa listalta poistetaan siististi se alkio jonka iteraattori palautti edellisellä metodin next kutsulla. Toimiva versio metodista seuraavassa:

public class Kasi {
    // ...

    public void poistaHuonommat(int arvo) {
        Iterator<Kortti> iteraattori = kortit.iterator();

        while (iteraattori.hasNext()) {
            if (iteraattori.next().getArvo() < arvo) {
                // poistetaan listalta olio jonka edellinen next-metodin kutsu palautti
                iteraattori.remove();
            }
        }
    }
}

Tehdään ohjelma pienen yrityksen henkilöstön hallintaan.

Koulutus

Tee pakkaukseen henkilosto lueteltu tyyppi eli enum Koulutus jolla on tunnukset FT (tohtori), FM (maisteri), LuK (kandidaatti), FilYO (ylioppilas).

Henkilo

Tee pakkaukseen henkilosto luokka Luokka Henkilo. Henkilölle annetaan konstruktorin parametrina annettava nimi ja koulutus. Henkilöllä on myös koulutuksen kertova metodi public Koulutus getKoulutus() sekä alla olevan esimerkin mukaista jälkeä tekevä toString-metodi.

Henkilo arto = new Henkilo("Arto", Koulutus.FT);
System.out.println(arto);
Arto, FT

Tyontekijat

Tee pakkaukseen henkilosto luokka Luokka Tyontekijat. Työntekijät-olio sisältää listan Henkilo-olioita. Luokalla on parametriton konstruktori ja seuraavat metodit:

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

HUOM: Luokan Tyontekijat tulosta-metodit on toteutettava iteraattoria käyttäen!

Irtisanominen

Tee luokalle Tyontekijat metodi public void irtisano(Koulutus koulutus) joka poistaa Työntekijöiden joukosta kaikki henkilöt joiden koulutus on sama kuin metodin parametrina annettu.

HUOM: toteuta metodi iteraattoria käyttäen!

Seuraavassa esimerkki luokan käytöstä:

Tyontekijat yliopisto = new Tyontekijat();
yliopisto.lisaa(new Henkilo("Matti", Koulutus.FT));
yliopisto.lisaa(new Henkilo("Pekka", Koulutus.FilYO));
yliopisto.lisaa(new Henkilo("Arto", Koulutus.FT));

yliopisto.tulosta();

yliopisto.irtisano(Koulutus.FilYO);

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

yliopisto.tulosta();

Tulostuu:

Matti, FT
Pekka, FilYO
Arto, FT
==
Matti, FT
Arto, FT

Hiljattain Suomeen rantautunut Netflix lupasi lokakuussa 2006 miljoona dollaria henkilölle tai ryhmälle, joka kehittäisi ohjelman, joka on 10% parempi elokuvien suosittelussa kuin heidän oma ohjelmansa. Kilpailu ratkesi syyskuussa 2009 (http://www.netflixprize.com/).

Rakennetaan tässä tehtävässä ohjelma elokuvien suositteluun. Alla on sen toimintaesimerkki:

ArvioRekisteri arviot = new ArvioRekisteri();

Elokuva tuulenViemaa = new Elokuva("Tuulen viemää");
Elokuva hiljaisetSillat = new Elokuva("Hiljaiset sillat");
Elokuva eraserhead = new Elokuva("Eraserhead");

Henkilo matti = new Henkilo("Matti");
Henkilo pekka = new Henkilo("Pekka");
Henkilo mikke = new Henkilo("Mikke");
Henkilo thomas = new Henkilo("Thomas");

arviot.lisaaArvio(matti, tuulenViemaa, Arvio.HUONO);
arviot.lisaaArvio(matti, hiljaisetSillat, Arvio.HYVA);
arviot.lisaaArvio(matti, eraserhead, Arvio.OK);

arviot.lisaaArvio(pekka, tuulenViemaa, Arvio.OK);
arviot.lisaaArvio(pekka, hiljaisetSillat, Arvio.HUONO);
arviot.lisaaArvio(pekka, eraserhead, Arvio.VALTTAVA);

arviot.lisaaArvio(mikke, eraserhead, Arvio.HUONO);


Suosittelija suosittelija = new Suosittelija(arviot);
System.out.println(thomas + " suositus: " +
        suosittelija.suositteleElokuva(thomas));
System.out.println(mikke + " suositus: " +
        suosittelija.suositteleElokuva(mikke));
Thomas suositus: Hiljaiset sillat
Mikke suositus: Tuulen viemää

Ohjelma osaa suositella elokuvia niiden yleisen arvion perusteella, sekä henkilökohtaisten henkilön antaminen arvioiden perusteella. Lähdetään rakentamaan ohjelmaa.

Henkilo ja Elokuva

Luo pakkaus suosittelija.domain ja lisää sinne luokat Henkilo ja Elokuva. Kummallakin luokalla on julkinen konstruktori public Luokka(String nimi), sekä metodi public String getNimi(), joka palauttaa konstruktorissa saadun nimen.

Henkilo henkilo = new Henkilo("Pekka");
Elokuva elokuva = new Elokuva("Eraserhead");

System.out.println(henkilo.getNimi() + " ja " + elokuva.getNimi());
Pekka ja Eraserhead

Lisää luokille myös public String toString()-metodi, joka palauttaa konstruktorissa parametrina annetun nimen, sekä korvaa metodit equals ja hashCode.

Korvaa equals siten että samuusvertailu tapahtuu oliomuuttujan nimi perusteella. Katso mallia luvusta 45.1. Luvussa 45.2. on ohje metodin hashCode korvaamiselle. Ainakin HashCode kannattaa generoida automaattisesti luvun lopussa olevan ohjeen mukaan:

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

Arvio

Luo pakkaukseen suosittelija.domain lueteltu tyyppi Arvio. Enum-luokalla Arvio on julkinen metodi public int getArvo(), joka palauttaa arvioon liittyvän arvon. Arviotunnusten ja niihin liittyvien arvosanojen tulee olla seuraavat:

TunnusArvo
HUONO-5
VALTTAVA-3
EI_NAHNYT0
NEUTRAALI1
OK3
HYVA5

Luokkaa voi käyttää seuraavasti:

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

ArvioRekisteri, osa 1

Aloitetaan arvioiden varastointiin liittyvän palvelun toteutus.

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

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

Testaa metodien toimintaa seuraavalla lähdekoodilla:

Elokuva hiljaisetSillat = new Elokuva("Hiljaiset sillat");
Elokuva eraserhead = new Elokuva("Eraserhead");

ArvioRekisteri rekisteri = new ArvioRekisteri();
rekisteri.lisaaArvio(eraserhead, Arvio.HUONO);
rekisteri.lisaaArvio(eraserhead, Arvio.HUONO);
rekisteri.lisaaArvio(eraserhead, Arvio.HYVA);

rekisteri.lisaaArvio(hiljaisetSillat, Arvio.HYVA);
rekisteri.lisaaArvio(hiljaisetSillat, Arvio.OK);

System.out.println("Kaikki arviot: " + rekisteri.elokuvienArviot());
System.out.println("Arviot Eraserheadille: " + rekisteri.annaArviot(eraserhead));
Kaikki arviot: {Hiljaiset sillat=[HYVA, OK], Eraserhead=[HUONO, HUONO, HYVA]}
Arviot Eraserheadille: [HUONO, HUONO, HYVA]

ArvioRekisteri, osa 2

Lisätään seuraavaksi mahdollisuus henkilökohtaisten arvioiden lisäämiseen.

Lisää luokkaan ArvioRekisteri seuraavat metodit:

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

Henkilöiden tekemät arviot kannattanee tallentaa hajautustauluun, jossa avaimena on henkilö. Arvona hajautustaulussa on toinen hajautustaulu, jossa avaimena on elokuva ja arvona arvio.

Testaa paranneltua ArvioRekisteri-luokkaa seuraavalla lähdekoodipätkällä:

ArvioRekisteri arviot = new ArvioRekisteri();

Elokuva tuulenViemaa = new Elokuva("Tuulen viemää");
Elokuva eraserhead = new Elokuva("Eraserhead");

Henkilo matti = new Henkilo("Matti");
Henkilo pekka = new Henkilo("Pekka");

arviot.lisaaArvio(matti, tuulenViemaa, Arvio.HUONO);
arviot.lisaaArvio(matti, eraserhead, Arvio.OK);

arviot.lisaaArvio(pekka, tuulenViemaa, Arvio.OK);
arviot.lisaaArvio(pekka, eraserhead, Arvio.OK);

System.out.println("Arviot Eraserheadille: " + arviot.annaArviot(eraserhead));
System.out.println("Matin arviot: " + arviot.annaHenkilonArviot(matti));
System.out.println("Arvioijat: " + arviot.arvioijat());
Arviot Eraserheadille: [OK, OK]
Matin arviot: {Tuulen viemää=HUONO, Eraserhead=OK}
Arvioijat: [Pekka, Matti]

Luodaan seuraavaksi muutama apuluokka arviointien helpottamiseksi.

HenkiloComparator

Luo pakkaukseen suosittelija.comparator luokka HenkiloComparator. Luokan HenkiloComparator tulee toteuttaa rajapinta Comparator<Henkilo>, ja sillä pitää olla konstruktori public HenkiloComparator(Map<Henkilo, Integer> henkiloidenSamuudet). Luokkaa HenkiloComparator käytetään myöhemmin henkilöiden järjestämiseen henkilöön liittyvän luvun perusteella.

HenkiloComparator-luokan tulee mahdollistaa henkilöiden järjestäminen henkilöön liittyvän luvun perusteella.

Testaa luokan toimintaa seuraavalla lähdekoodilla:

Henkilo matti = new Henkilo("Matti");
Henkilo pekka = new Henkilo("Pekka");
Henkilo mikke = new Henkilo("Mikke");
Henkilo thomas = new Henkilo("Thomas");

Map<Henkilo, Integer> henkiloidenSamuudet = new HashMap<>();
henkiloidenSamuudet.put(matti, 42);
henkiloidenSamuudet.put(pekka, 134);
henkiloidenSamuudet.put(mikke, 8);
henkiloidenSamuudet.put(thomas, 82);

List<Henkilo> henkilot = Arrays.asList(matti, pekka, mikke, thomas);
System.out.println("Henkilöt ennen järjestämistä: " + henkilot);

Collections.sort(henkilot, new HenkiloComparator(henkiloidenSamuudet));
System.out.println("Henkilöt järjestämisen jälkeen: " + henkilot);
Henkilöt ennen järjestämistä: [Matti, Pekka, Mikke, Thomas]
Henkilöt järjestämisen jälkeen: [Pekka, Thomas, Matti, Mikke]

ElokuvaComparator

Luo pakkaukseen suosittelija.comparator luokka ElokuvaComparator. Luokan ElokuvaComparator tulee toteuttaa rajapinta Comparator<Elokuva>, ja sillä pitää olla konstruktori public ElokuvaComparator(Map<Elokuva, List<Arvio>> arviot). Luokkaa ElokuvaComparator käytetään myöhemmin elokuvien järjestämiseen niiden arvioiden perusteella.

ElokuvaComparator-luokan tulee tarjota mahdollisuus elokuvien järjestäminen niiden saamien arvosanojen keskiarvon perusteella. Korkeimman keskiarvon saanut elokuva tulee ensimmäisenä, matalimman keskiarvon saanut viimeisenä.

Testaa luokan toimintaa seuraavalla lähdekoodilla:

ArvioRekisteri arviot = new ArvioRekisteri();

Elokuva tuulenViemaa = new Elokuva("Tuulen viemää");
Elokuva hiljaisetSillat = new Elokuva("Hiljaiset sillat");
Elokuva eraserhead = new Elokuva("Eraserhead");

Henkilo matti = new Henkilo("Matti");
Henkilo pekka = new Henkilo("Pekka");
Henkilo mikke = new Henkilo("Mikke");

arviot.lisaaArvio(matti, tuulenViemaa, Arvio.HUONO);
arviot.lisaaArvio(matti, hiljaisetSillat, Arvio.HYVA);
arviot.lisaaArvio(matti, eraserhead, Arvio.OK);

arviot.lisaaArvio(pekka, tuulenViemaa, Arvio.OK);
arviot.lisaaArvio(pekka, hiljaisetSillat, Arvio.HUONO);
arviot.lisaaArvio(pekka, eraserhead, Arvio.VALTTAVA);

arviot.lisaaArvio(mikke, eraserhead, Arvio.HUONO);

Map<Elokuva, List<Arvio>> elokuvienArviot = arviot.elokuvienArviot();

List<Elokuva> elokuvat = Arrays.asList(tuulenViemaa, hiljaisetSillat, eraserhead);
System.out.println("Elokuvat ennen järjestämistä: " + elokuvat);

Collections.sort(elokuvat, new ElokuvaComparator(elokuvienArviot));
System.out.println("Elokuvat järjestämisen jälkeen: " + elokuvat);
Elokuvat ennen järjestämistä: [Tuulen viemää, Hiljaiset sillat, Eraserhead]
Elokuvat järjestämisen jälkeen: [Hiljaiset sillat, Tuulen viemää, Eraserhead]

Suosittelija, osa 1

Toteuta pakkaukseen suosittelija luokka Suosittelija. Luokan Suosittelija konstruktori saa parametrinaan ArvioRekisteri-tyyppisen olion. Suosittelija käyttää arviorekisterissä olevia arvioita suositusten tekemiseen.

Toteuta luokalle metodi public Elokuva suositteleElokuva(Henkilo henkilo), joka suosittelee henkilölle elokuvia.

Toteuta metodi ensin siten, että se suosittelee aina elokuvaa, jonka arvioiden arvosanojen keskiarvo on suurin. Vinkki: Tarvitset parhaan elokuvan selvittämiseen ainakin aiemmin luotua ElokuvaComparator-luokkaa, luokan ArvioRekisteri metodia public Map<Elokuva, List<Arvio>> elokuvienArviot(), sekä listaa olemassaolevista elokuvista.

Testaa ohjelman toimimista seuraavalla lähdekoodilla:

ArvioRekisteri arviot = new ArvioRekisteri();

Elokuva tuulenViemaa = new Elokuva("Tuulen viemää");
Elokuva hiljaisetSillat = new Elokuva("Hiljaiset sillat");
Elokuva eraserhead = new Elokuva("Eraserhead");

Henkilo matti = new Henkilo("Matti");
Henkilo pekka = new Henkilo("Pekka");
Henkilo mikke = new Henkilo("Mikael");

arviot.lisaaArvio(matti, tuulenViemaa, Arvio.HUONO);
arviot.lisaaArvio(matti, hiljaisetSillat, Arvio.HYVA);
arviot.lisaaArvio(matti, eraserhead, Arvio.OK);

arviot.lisaaArvio(pekka, tuulenViemaa, Arvio.OK);
arviot.lisaaArvio(pekka, hiljaisetSillat, Arvio.VALTTAVA);
arviot.lisaaArvio(pekka, eraserhead, Arvio.VALTTAVA);

Suosittelija suosittelija = new Suosittelija(arviot);
Elokuva suositeltu = suosittelija.suositteleElokuva(mikke);
System.out.println("Mikaelille suositeltu elokuva oli: " + suositeltu);
Mikaelille suositeltu elokuva oli: Hiljaiset sillat

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

Suosittelija, osa 2

Huom! Tehtävä on haastava. Kannattaa tehdä ensin muut tehtävät ja palata tähän myöhemmin. Voit palauttaa tehtäväsarjan TMC:hen vaikket saakaan tätä tehtävää tehdyksi, aivan kuten lähes kaikkien muidenkin tehtävien kohdalla.

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

Jos henkilöt ovat lisänneet omia suosituksia suosituspalveluun, tiedämme jotain heidän elokuvamaustaan. Laajennetaan suosittelijan toiminnallisuutta siten, että se luo henkilökohtaisen suosituksen jos henkilö on jo arvioinut elokuvia. Edellisessä osassa toteutettu toiminnallisuus tulee säilyttää: Jos henkilö ei ole arvioinut yhtäkään elokuvaa, hänelle suositellaan elokuva arvosanojen perusteella.

Henkilökohtaiset suositukset perustuvat henkilön tekemien arvioiden samuuteen muiden henkilöiden tekemien arvioiden kanssa. Pohditaan seuraavaa taulukkoa, missä ylärivillä on elokuvat, ja vasemmalla on arvioita tehneet henkilöt. Taulukon solut kuvaavat annettuja arvioita.

Henkilo \ ElokuvaTuulen viemääHiljaiset sillatEraserheadBlues Brothers
MattiHUONO (-5)HYVA (5)OK (3)-
PekkaOK (3)-HUONO (-5)VALTTAVA (-3)
Mikael--HUONO (-5)-
Thomas-HYVA (5)-HYVA (5)

Kun haluamme hakea Mikaelille sopivaa elokuvaa, tutkimme Mikaelin samuutta kaikkien muiden arvioijien kesken. Samuus lasketaan arvioiden perusteella: samuus on kummankin katsomien elokuvien arvioiden tulojen summa. Esimerkiksi Mikaelin ja Thomasin samuus on 0, koska Mikael ja Thomas eivät ole katsoneet yhtäkään samaa elokuvaa.

Mikaelin ja Pekan samuutta laskettaessa yhteisten elokuvien tulojen summa olisi 25. Mikael ja Pekka ovat katsoneet vain yhden yhteisen elokuvan, ja kumpikin antaneet sille arvosanan huono (-5).

-5 * -5 = 25

Mikaelin ja Matin samuus on -15. Mikael ja Matti ovat myös katsoneet vain yhden yhteisen elokuvan. Mikael antoi elokuvalle arvosanan huono (-5), Matti antoi sille arvosanan ok (3).

-5 * 3 = -15

Näiden perusteella Mikaelille suositellaan elokuvia Pekan elokuvamaun mukaan: suosituksena on elokuva Tuulen viemää.

Kun taas haluamme hakea Matille sopivaa elokuvaa, tutkimme Matin samuutta kaikkien muiden arvioijien kesken. Matti ja Pekka ovat katsoneet kaksi yhteistä elokuvaa. Matti antoi Tuulen viemälle arvosanan huono (-5), Pekka arvosanan OK (3). Elokuvalle Eraserhead Matti antoi arvosanan OK (3), Pekka arvosanan huono (-5). Matin ja Pekan samuus on siis -30.

-5 * 3 + 3 * -5 = -30

Matin ja Mikaelin samuus on edellisestä laskusta tiedetty -15. Samuudet ovat symmetrisia.

Matti ja Thomas ovat katsoneet Tuulen viemää, ja kumpikin antoi sille arvosanan hyvä (5). Matin ja Thomaksen samuus on siis 25.

5 * 5 = 25

Matille tulee siis suositella elokuvia Thomaksen elokuvamaun mukaan: suosituksena olisi Blues Brothers.

Toteuta yllä kuvattu suosittelumekanismi. Jos henkilölle ei löydy yhtään suositeltavaa elokuvaa, tai henkilö, kenen elokuvamaun mukaan elokuvia suositellaan on arvioinut elokuvat joita henkilö ei ole vielä katsonut huonoiksi, välttäviksi tai neutraaleiksi, palauta metodista suositteleElokuva arvo null. Edellisessä tehtävässä määritellyn lähestymistavan tulee toimia jos henkilö ei ole lisännyt yhtäkään arviota.

Älä suosittele elokuvia, jonka henkilö on jo nähnyt.

Voit testata ohjelmasi toimintaa seuraavalla lähdekoodilla:

ArvioRekisteri arviot = new ArvioRekisteri();

Elokuva tuulenViemaa = new Elokuva("Tuulen viemää");
Elokuva hiljaisetSillat = new Elokuva("Hiljaiset sillat");
Elokuva eraserhead = new Elokuva("Eraserhead");
Elokuva bluesBrothers = new Elokuva("Blues Brothers");

Henkilo matti = new Henkilo("Matti");
Henkilo pekka = new Henkilo("Pekka");
Henkilo mikke = new Henkilo("Mikael");
Henkilo thomas = new Henkilo("Thomas");
Henkilo arto = new Henkilo("Arto");

arviot.lisaaArvio(matti, tuulenViemaa, Arvio.HUONO);
arviot.lisaaArvio(matti, hiljaisetSillat, Arvio.HYVA);
arviot.lisaaArvio(matti, eraserhead, Arvio.OK);

arviot.lisaaArvio(pekka, tuulenViemaa, Arvio.OK);
arviot.lisaaArvio(pekka, eraserhead, Arvio.HUONO);
arviot.lisaaArvio(pekka, bluesBrothers, Arvio.VALTTAVA);

arviot.lisaaArvio(mikke, eraserhead, Arvio.HUONO);

arviot.lisaaArvio(thomas, bluesBrothers, Arvio.HYVA);
arviot.lisaaArvio(thomas, hiljaisetSillat, Arvio.HYVA);

Suosittelija suosittelija = new Suosittelija(arviot);
System.out.println(thomas + " suositus: " + suosittelija.suositteleElokuva(thomas));
System.out.println(mikke + " suositus: " + suosittelija.suositteleElokuva(mikke));
System.out.println(matti + " suositus: " + suosittelija.suositteleElokuva(matti));
System.out.println(arto + " suositus: " + suosittelija.suositteleElokuva(arto));
Thomas suositus: Eraserhead
Mikael suositus: Tuulen viemää
Matti suositus: Blues Brothers
Arto suositus: Hiljaiset sillat

Miljoona käsissä? Ei ehkä vielä. Kursseilla Johdatus tekoälyyn ja Johdatus koneoppimiseen opitaan lisää tekniikoita oppivien järjestelmien rakentamiseen.

Vaihteleva määrä parametreja metodille

Olemme tähän mennessä luoneet metodimme siten, että parametrien määrät ovat olleet selkeästi määritelty. Java tarjoaa tavan antaa metodille rajoittamattoman määrän määrätyntyyppisiä parametreja asettamalla metodimäärittelyssä parametrin tyypille kolme pistettä perään. Esimerkiksi metodille public int summa(int... luvut) voi antaa summattavaksi niin monta int-tyyppistä kokonaislukua kuin käyttäjä haluaa. Metodin sisällä parametrin arvoja voi käsitellä taulukkona.

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

    return summa;
}
System.out.println(summa(3, 5, 7, 9));  // luvut = {3, 5, 7, 9}
System.out.println(summa(1, 2));        // luvut = {1, 2}
24
3

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

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

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

Ennalta määrittelemätöntä parametrien arvojen määrää käytetään esimerkiksi silloin, kun halutaan tarjota rajapinta, joka ei rajoita sen käyttäjää tiettyyn parametrien määrään. Vaihtoehtoinen lähestymistapa on metodimäärittely, jolla on parametrina tietyn tyyppinen lista. Tällöin oliot voidaan asettaa listaan ennen metodikutsua, ja kutsua metodia antamalla lista sille parametrina.

StringBuilder

Olemme tottuneet rakentamaan merkkijonoja seuraavaan tapaan:

// luokassa Main
public static void main(String[] args) {
    int[] t = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    new Muotoilija().muotoile(t));
}


// Luokassa muotoilija luokassa
public class Muotoilija {
    public String muotoile(int[] t) {
        String mj = "{";

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

        return mj + "}";
    }
}

Tulostus:

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

Tapa on toimiva mutta ei kovin tehokas. Merkkijonot ovat immutaabeleita eli olioita, joita ei voi muuttaa. Merkkijono-operaatioiden tuloksena on aina uusi merkkijono-olio. Edellisessä esimerkissä syntyi välivaiheena ainakin kymmenen merkkijono-olioa. Jos syötteen koko olisi isompi, alkaisi välivaiheena olevien olioiden luominen vaikuttaa ohjelman suoritusaikaan ikävällä tavalla.

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

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

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

Geneerisyys

Olemme listoihin tutustumisesta lähtien kertoneet erilaisille tietorakenteille niiden sisältämän olion tyypin. Esimerkiksi String-tyyppisiä olioita sisältävä lista on esitelty muodossa ArrayList<String>. Tässä on kuitenkin ihmetyttänyt se, että miten ihmeessä listat ja muutkin tietorakenteet voivat sisältää erityyppisiä oliota.

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

public class Lokero<T> {
    private T alkio;

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

    public T haeArvo() {
        return alkio;
    }
}

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

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

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

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

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

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

Samalla tavalla ohjelmoija voisi toteuttaa esimerkiksi luokan Pari, mihin voi laittaa kaksi halutun tyyppistä oliota.

public class Pari<T, K> {
    private T eka;
    private K toka;

    public void asetaArvot(T eka, K toka) {
        this.eka = eka;
        this.toka = toka;
    }

    public T haeEka() {
        return this.eka;
    }

    public K haeToka() {
        return this.toka;
    }
}

Huomattava osa Javan tietorakenteista mahdollistaa eri tyyppisten muuttujien käytön. Esimerkiksi ArrayList saa yhden tyyppiparametrin, HashMap kaksi.

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

Jatkossa kun näet esimerkiksi tyypin ArrayList<String> tiedät että sen sisäisessä rakenteessa on käytetty geneeristä tyyppiparametria. Sama periaate löytyy esimerkiksi rajapinnassa Comparable.

Hieman isompia ohjelmia

Tehdään lopuksi vielä muutama hieman isompi ohjelma. Tehtävissä on sekä kertausta että hieman uutta -- esimerkiksi tehtävässä Tunteikkaat elokuva-arviot tutustutaan koneoppimisen perusaskeleisiin.

Hirsipuu on peli, jossa käyttäjä yrittää arvata piilossa olevan sanan. Normaalissa hirsipuussa tietokone valitsee sanan, ja pitää sitä piilossa kun käyttäjä yrittää arvata sanaan liittyviä kirjaimia. Arvauskertoja on rajattu määrä: jos pelaaja arvaa kaikki sanaan liittyvän kirjaimet, hän voittaa pelin. Jos taas pelaaja ei arvaa sanoja, tietokone voittaa pelin.

Toteutetaan tässä palasia hieman ärsyttävämpään versioon hirsipuusta, missä tietokone pyrkii voittamaan pelin huijaamalla.

Huijauksen ideana on se, että tietokone voi vaihtaa valitsemansa sanan tarvittaessa lennosta. Pelin lopullinen toiminnallisuus on seuraava:

...

Sinulla on 3 arvausta jäljellä.
Olet käyttänyt merkit: [a, b, c, d, e, f, g, h, i, j]
Sana: -a--a
Arvaus: r
Ei r-kirjaimia.

Sinulla on 2 arvausta jäljellä.
Olet käyttänyt merkit: [a, b, c, d, e, f, g, h, i, j, r]
Sana: -a--a
Arvaus: s
Löytyi ainakin yksi s-kirjain.

Sinulla on 2 arvausta jäljellä.
Olet käyttänyt merkit: [a, b, c, d, e, f, g, h, i, j, r, s]
Sana: -as-a
Arvaus: p
Ei p-kirjaimia.

Sinulla on 1 arvaus jäljellä.
Olet käyttänyt merkit: [a, b, c, d, e, f, g, h, i, j, p, r, s]
Sana: -as-a
Arvaus: t
Löytyi ainakin yksi t-kirjain.

Sinulla on 1 arvaus jäljellä.
Olet käyttänyt merkit: [a, b, c, d, e, f, g, h, i, j, p, r, s, t]
Sana: -asta
Arvaus: v
Ei v-kirjaimia.

Parempaa onnea ensi kerralla!
Sana oli: rasta

Ohjelman tekstikäyttöliittymä on toteutettu valmiiksi Main-luokkaan.

Sanalista

Tässä toteutettavaa luokkaa Sanalista käytetään käytettävissä olevien sanojen rajaamiseen. Luokkaan Sanalista on määritelty merkkijonolistan parametrina ottavan konstruktorin sekä seuraavat metodit.

  1. public List<String> sanat() - palauttaa sanalistalla olevat sanat.
  2. public Sanalista sanatJoidenPituusOn(int pituus) - palauttaa uuden sanalista-olion, jossa on vain ne sanat, joiden pituus on parametrina annetun muuttujan arvo.
  3. public Sanalista sanatJoissaEiEsiinnyKirjainta(char kirjain) - palauttaa uuden sanalista-olion, jossa on vain ne sanat, joissa ei esiinny parametrina annettua kirjainmerkkiä.
  4. public Sanalista sanatJoissaMerkit(String merkkijono) - palauttaa uuden sanalista-olion, jossa on vain ne sanat, joissa on merkit parametrina annetun merkkijonon määräämissä kohdissa. Annettu merkkijono on muotoa --d-, missä viivat kuvaavat mitä tahansa merkkiä ja kirjaimet merkkejä, joiden täytyy olla sanassa juuri annetulla paikalla.
  5. public int koko() - palauttaa sanalistan sisältämien sanojen määrän.

Toteuta edelliset metodit luokassa Sanalista oleviin metodirunkoihin.

Hirsipuu, osa 1

Luokka Hirsipuu pitää kirjaa hirsipuu-pelin tilanteesta. Hirsipuulla on konstruktori, joka saa parametrinaan sanalistan sekä arvausten määrän. Hirsipuu valitsee konstruktorissa myös arvattavan sanan annetulta sanalistalta.

Hirsipuu tarjoaa lisäksi ainakin seuraavat metodit.

  1. public boolean arvaa(Character merkki) - arvaa parametrina annettua merkkiä. Lisää arvauksen arvauslistalle. Jos merkki löytyy arvattavasta sanasta, palauttaa true. Jos merkkiä taas ei löydy, vähentää arvausten määrää yhdellä, ja palauttaa false.
  2. public List<Character> arvaukset() - palauttaa tehdyt arvaukset listaoliona.

  3. public int arvauksiaJaljella() - kertoo jäljellä olevien arvausten määrän.
  4. public String sana() - kertoo arvattavan sanan siten, että kirjaimet, joita ei ole vielä arvattu, peitetään merkillä -.
  5. public String oikeaSana() - kertoo arvattavan sanan ilman peittelyä.
  6. public boolean onLoppu() - kertoo onko peli loppu. Peli on loppu jos kaikki arvattavan sanan merkit on arvattu.

Toteuta edelliset metodit. Kun edelliset metodit on toteutettu, voit jo pelata hirsipuuta.

Tarkastele toteutuksen avuksi Test Packages -kansiossa sijaitsevaa luokkaa BHirsipuuTest. Voitko päätellä mitä luokassa olevat metodit tekevät?

Hirsipuu, osa 2

Jatka hirsipuun kehitystä siten, että hyödynnät sanalistaa ja pyrit tekemään hirsipuu-pelistä sellaisen, että se välttelee pelaajan arvauksia mahdollisimman hyvin. Kannattaa aloittaa arvaa-metodin parantamisesta.

Tähän osioon ei ole testejä -- palauta peli kun hirsipuu välttelee arvauksia mielestäsi tarpeeksi hyvin.

Kerro myös palautuksen yhteydessä hirsipuutekoälysi oleellisimmat tausta-ajatukset.

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

Muistiton perustoiminnallisuus

Tee sanakirjalle parametriton konstruktori sekä metodit:

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

Sanakirjan tulee tässä vaiheessa toimia seuraavasti:

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

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

Tulostuu

monkey
apina
null
banaani

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

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

Sanojen poistaminen

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

Kannattanee kerrata aiemmilta viikoilta materiaalia, mikä liittyy olioiden poistamiseen ArrayListista.

HUOM2: metodi poista ei kirjoita tiedostoon.

Sanakirjan tulee tässä vaiheessa toimia seuraavasti:

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

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

Tulostuu

null
null
null
null
programming

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

Lataaminen tiedostosta

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

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

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

apina:monkey
alla oleva:below
olut:beer

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

Scanner tiedostonLukija = new ...
while (tiedostonLukija.hasNextLine()) {
    String rivi = tiedostonLukija.nextLine();
    String[] osat = rivi.split(":");   // pilkotaan rivi :-merkkien kohdalta

    System.out.println(osat[0]);     // ennen :-merkkiä ollut osa rivistä
    System.out.println(osat[1]);     // :-merkin jälkeen ollut osa rivistä
}

Sanakirjaa käytetään seuraavasti:

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

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

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

Tulostuu

sanakirjan lataaminen onnistui
monkey
null
below

Tallennus tiedostoon

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

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

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

tietokone:computer

tai rivi

computer:tietokone

mutta ei molempia!

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

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

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

// käytä sanakirjaa

sanakirja.tallenna();

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

Tässä tehtävässä nivotaan yhteen aiemmin harjoiteltuja hajautustauluja, tiedoston lukemista sekä tehdään pieni askel koneoppimisen suuntaan. Koneoppiminen on tietojenkäsittelytieteen osa-alue, missä tutkitaan ja rakennetaan ohjelmia, jotka voivat oppia muunmuassa niille annetusta datasta.

Käytössämme oleva data (kansiossa src oleva tiedosto arviot.txt) sisältää yli 8000 englanninkielistä elokuva-arviota, joihin on valmiiksi lisätty tunnearvio. Tunnearviot on annettu skaalalla nollasta neljään, missä arvot ovat seuraavat:

  1. 0 - negatiivinen
  2. 1 - hieman negatiivinen
  3. 2 - neutraali
  4. 3 - hieman positiivinen
  5. 4 - positiivinen

Teemme seuraavaksi ohjelman, joka pyrkii arvioimaan liittyykö tekstimuotoiseen elokuva-arvioon negatiivinen, positiivinen vai neutraali tunne.

Sanojen lukumäärä

Toteuta tehtäväpohjassa annettuun luokkaan TunteikkaatArviot metodi public int sanojenLukumaara(String sana). Metodin tulee kertoa sille parametrina annetun merkkijonon sana esiintymislukumäärä luokan konstruktorille annetussa merkkijonolistassa.

Kannattanee tässä jo miettiä minkälainen tietorakenne olisi hyvä sanojen lukumäärän tallentamiseen. Saat merkkijonoon liittyvät yksittäiset sanat selville esimerkiksi String-luokan split-metodin avulla:

String merkkijono = "hei kaikki siellä";
String[] palat = merkkijono.split(" ");

Voit kokeilla ohjelmasi toimintaa esimerkiksi seuraavalla koodilla:

List<String> rivit = lueRivit("src/arviot.txt");
TunteikkaatArviot arviot = new TunteikkaatArviot(rivit);
System.out.println(arviot.sanojenLukumaara("what"));
System.out.println(arviot.sanojenLukumaara("is"));
System.out.println(arviot.sanojenLukumaara("love"));
System.out.println(arviot.sanojenLukumaara("chuck"));
System.out.println(arviot.sanojenLukumaara("norris"));
System.out.println(arviot.sanojenLukumaara("mikkihiiri"));

Ylläoleva esimerkki tuottaa seuraavanlaisen tulostuksen.

338
2538
172
2
1
0

Huom! Käsittele pienellä ja isolla kirjoitetut sanat samoina sanoina!

Yksittäisen sanan tunne

Tehtäväpohjassa annetut tiedostot arviot-lyhyt-1.txt, arviot-lyhyt-2.txt ja arviot.txt sisältävät elokuva-arvioita. Tiedostojen muoto on seuraavanlainen, missä jokaisen rivin ensimmäinen arvo on arvioon liitetty tunnearvo skaalalla nollasta neljään. Tätä seuraa konkreettinen tekstimuotoinen arvio. Esimerkiksi:

1 Simply put , there should have been a more compelling excuse to pair Susan Sarandon and Goldie Hawn .
3 Definitely in the guilty pleasure B-movie category , Reign of Fire is so incredibly inane that it is laughingly enjoyable .
3 It 's an experience in understanding a unique culture that is presented with universal appeal .
0 The French director has turned out nearly 21\/2 hours of unfocused , excruciatingly tedious cinema that , half an hour in , starts making water torture seem appealing .
  

Yllä on kuvattu neljän eri elokuvan saamat arviot, sekä niihin liitetyt tunnearvot. Ensimmäinen arvio on hieman negatiivinen, kaksi seuraavaa hieman positiivisia, ja viimeinen on negatiivinen.

Toteuta tässä osiossa metodiin public double sananTunne(String sana) toiminnallisuus, joka palauttaa parametrina annetulle sanalle keskimääräisen tunnearvon.

Keskimääräinen tunnearvo lasketaan niiden arvioiden keskiarvona, joissa sana esiintyy. Jos sana esiintyy useaan kertaan arviossa, tulee arvio ottaa useampaan kertaan huomioon. Jos sanaan ei esiinny kertaakaan, palauta neutraali arvo, eli 2.0.

Ylläolevassa esimerkissä sana "it" esiintyy kahdesti, kummassakin lauseessa arvio on 3. Sanan "it" keskimääräiseksi tunnearvoksi tulee siis (3+3) / 2 = 3. Vastaavasti sana "that" esiintyy kolmesti, ja tunnearvoksi tulee (3+3+0) / 3 = 2.

Voit kokeilla ohjelmasi toimintaa esimerkiksi seuraavalla koodilla:

List<String> rivit = lueRivit("src/arviot.txt");
TunteikkaatArviot arviot = new TunteikkaatArviot(rivit);
System.out.println(arviot.sananTunne("poor"));
System.out.println(arviot.sananTunne("is"));
System.out.println(arviot.sananTunne("love"));
System.out.println(arviot.sananTunne("damme"));
System.out.println(arviot.sananTunne("norris"));

Ylläoleva esimerkki tuottaa seuraavanlaisen tulostuksen.

0.8235294117647058
2.0260047281323876
2.645348837209302
2.5
2.0

Toteuta lisäksi myös metodi public String sananTunneMerkkijonona(String sana), joka tarkastelee sanaan liittyvää tunnearvoa ja palauttaa tunnearvoon liittyvän merkkijonon. Jos tunnearvo on pienempi tai yhtäsuuri kuin 1.9, tulee palauttaa merkkijono "negatiivinen". Jos taas tunnearvo on pienempi tai yhtäsuuri kuin 2.1, tulee palauttaa merkkijono "neutraali". Muulloin palautetaan merkkijono "positiivinen".

List<String> rivit = lueRivit("src/arviot.txt");
TunteikkaatArviot arviot = new TunteikkaatArviot(rivit);
System.out.println(arviot.sananTunneMerkkijonona("poor"));
System.out.println(arviot.sananTunneMerkkijonona("is"));
System.out.println(arviot.sananTunneMerkkijonona("love"));
System.out.println(arviot.sananTunneMerkkijonona("damme"));
System.out.println(arviot.sananTunneMerkkijonona("norris"));
negatiivinen
neutraali
positiivinen
positiivinen
neutraali

Huom! Käsittele pienellä ja isolla kirjoitetut sanat samoina sanoina! String-luokan metodeista toLowerCase ja toUpperCase on tässä hyötyä.

Lauseen tunne

Toteuta seuraavaksi metodi public double lauseenTunne(String lause), joka palauttaa lauseen tunteen. Laske lauseen tunnearvo lauseeseen liittyvien sanojen tunnearvojen keskiarvona.

List<String> rivit = lueRivit("src/arviot.txt");
TunteikkaatArviot arviot = new TunteikkaatArviot(rivit);

System.out.println(arviot.lauseenTunne("unicorn is a mythical creature"));
System.out.println(arviot.lauseenTunne("chuck norris made a happy meal cry"));
System.out.println(arviot.lauseenTunne("the movie was an utter and complete failure"));
2.181146685022733
2.104368086244505
1.73662040170538

Toteuta vielä lopuksi metodi public String lauseenTunneMerkkijonona(string lause), joka palauttaa lauseen tunteen merkkijonomuodossa. Käytä tässä samaa muunnosta kuin edellisessä osassa.

List<String> rivit = lueRivit("src/arviot.txt");
TunteikkaatArviot arviot = new TunteikkaatArviot(rivit);

System.out.println(arviot.lauseenTunneMerkkijonona("unicorn is a mythical creature"));
System.out.println(arviot.lauseenTunneMerkkijonona("chuck norris made a happy meal cry"));
System.out.println(arviot.lauseenTunneMerkkijonona("the movie was an utter and complete failure"));
positiivinen
positiivinen
negatiivinen

Huom! Kuten edellä, käsittele pienellä ja isolla kirjoitetut sanat samoina sanoina!

Sisällysluettelo