Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

TIEP111 Ohjelmointi 2

Tämä on Jyväskylän yliopiston järjestämän TIEP111 Ohjelmointi 2 -opintojakson oppimateriaali.

Voit palauttaa tehtäviä vain, jos olet ilmoittautunut opintojaksolle Sisu- tai Ilpo-järjestelmässä. Oman etenemisesi tilanteen (harjoitustehtävien pisteet, harjoitustyön hyväksyminen, tenttitulokset) näet TIM-järjestelmästä.

Tietoja opintojaksosta

Opintojaksolla opit

  • oliopohjaisen ohjelmoinnin perusteita ja periaatteita,
  • tuottamaan pieniä ja keskisuuria oliopohjaisia ohjelmia,
  • graafisen käyttöliittymän suunnittelua ja kehittämistä,
  • ohjelman testaamista,
  • erilaisia ohjelmoijan työkaluja ja tekniikoita.

Tarkemmat tiedot löydät opintojakson Sisu-esitteestä.

Uutiset

1. tammikuuta 2026: Kurssimateriaalia uudistetaan keväällä 2026

Teemme kokonaisvaltaisen uudistuksen oppimateriaaliin sekä tehtäviin kevään 2026 aikana. Osa materiaalista julkaistaan kurssin edetessä. Uudistamisesta johtuen sisällössä voi olla myös keskeneräisyyksiä ja virheitä. Pahoittelemme tästä mahdollisesti aiheutuvaa haittaa. Pyydämme, että ilmoitat virheistä tai parannusehdotuksista GitHubin kautta (katso tämän sivun alareuna) tai suoraan opettajien sähköpostiin ohj2-opet@jyu.onmicrosoft.com.

Ohjaukset ja tuki

Kevään 2026 on 12. tammikuuta – 24. huhtikuuta välisenä aikana tarjolla lähiohjausta Agoralla, etäohjausta Teamsin kautta, sekä sähköpostitukea.

Pääsiäistauon aikana (30.3.-6.4.) ei kuitenkaan ole ohjausta tarjolla.

Sisu vaatii ilmoittautumisen yhteydessä valitsemaan ohjausryhmän. Voit kuitenkin täysin vapaasti käyttää kaikkia ohjausaikoja ja -kanavia riippumatta siitä, mihin ohjausryhmään olet ilmoittautunut.

Ohjausajat 7.4. alkaen:

TukikanavaAikaPaikka/Linkki
Lähiohjauske 10-18, to 10-18, pe 8-14Agoralla luokat Ag B212.1 Finland ja Ag B211.1 Sovjet
Etäohjauske 10-18, to 10-18, pe 8-14Ohjelmointi 2 Teams-kanava
Vastuuopettajien ja tuntiopettajien sähköpostiosoiteJatkuvaohj2-opet@jyu.onmicrosoft.com

(Ke klo 8-10 ja to 8-10 pudotettu pois 30.3. alkaen.)

Ohjaukset ovat yhteisiä TIEP111 Ohjelmointi 2, ITKP102 Ohjelmointi 1- ja ITKA2004 Tietokannat ja tiedonhallinta -opintojaksojen kanssa. Ohjaajat auttavat kaikkien kolmen kurssin opiskelijoita.

Ohjausaikoja saatetaan lisätä tai poistaa kysynnän mukaan; kerro aikatoiveistasi opettajille sähköpostitse.

24.4. jälkeen ohjausta on saatavilla ajanvarauksella. Linkki ajanvaraukseen tulee myöhemmin saataville.

Haluatko ohjausaikoja näkyviin Sisun opintokalenteriin? (Avaa ohje klikkaamalla)
  1. Kirjaudu Sisuun

  2. Jos olet jo ilmoittautunut kurssille, klikkaa ylhäällä välilehteä Opintokalenteri tai klikkaa sitä hampurilaisvalikosta

  3. Selaa oikealla oikea kurssi näkyville, eli tässä tapauksessa Ohjelmointi 2

  4. Klikkaa oikealla olevaa oikealle osoittavaa väkästä Ohjelmointi 2 -kurssin kohdalla

  5. Skrollaa alaspäin, kunnes tulee alaotsikko Pääteohjaus

  6. Jos ei vielä näy, niin skrollaa alaspäin, kunnes näkyy Muiden ryhmien tiedot ja klikkaa sitä

  7. Nyt voit skrollaamalla alaspäin haluamiesi pääteohjauksien kohdalta klikata nappulaa Näytä tapahtumat kalenterissa.

    Näytä Sisu-tapahtumat kalenterissa

  8. Nyt kyseisen ryhmän ohjausajat näkyvät sinulla automaattisesti. Tarvittaessa voit poistaa ryhmän tapahtumia viikkokohtaisesti Tapahtumakalenterista.

Ohjeet etäohjaukseen liittymiseksi

Teams-kanavalle liittyminen: Jyväskylän yliopiston tutkinto-opiskelijat ## Ohjeet etäohjaukseen liittymiseksi {#teams}
Teams-kanavalle liittyminen: Jyväskylän yliopiston tutkinto-opiskelijat
  1. Kirjaudu yliopiston tunnuksellasi Microsoft Teamsiin osoitteessa https://teams.microsoft.com. Käyttäjätunnus on muotoa käyttäjätunnus@jyu.fi (esim. mameikal@jyu.fi). Tunnuksen muoto student.jyu.fi ei käy. Tunnuksen toimiminen vaatii, että olet hyväksynyt Office 365 -palvelut OMA-palvelussa (https://sso.jyu.fi).

  2. Lataa Teams-sovellus (suositus) tai käytä nettiversiota. Saatavilla on myös mobiilisovellus. Jos selaimella liittymisessä on ongelmia, tarkista ensin tukeeko Microsoft sitä täältä.

  3. Teams-sovelluksessa klikkaa Teams Join or create team Join a team with a code

  4. Syötä koodi nnobn49

  5. Testaa kaverin kanssa, että puhelu ja ruudun jakaminen toimii. Sinun tulee tarvittaessa sallia oikeudet käyttöjärjestelmäsi asetuksista.

Teams-kanavalle liittyminen: Jyväskylän yliopiston Avoin yliopisto sekä erilliset opinto-oikeudet
Teams-kanavalle liittyminen: Jyväskylän yliopiston Avoin yliopisto sekä erilliset opinto-oikeudet

Lähetä sähköpostilla alla oleva pyyntö osoitteeseen ohj2-opet@jyu.onmicrosoft.com.

Hei,

opiskelen Ohjelmointi 2 -kurssilla ei-tutkintoon johtavassa koulutuksessa.
Pyydän liittämään minut opintojakson Teams-ryhmään vieraana. 
Teamsissa käyttämäni sähköpostiosoite on: [oma sähköposti tähän].

Terveisin, [oma nimi]

Liitämme sinut viimeistään seuraavana arkipäivänä.

Etäohjauksiin osallistuminen ilman Teamsia
Etäohjauksiin osallistuminen ilman Teamsia

Jos et millään onnistu kirjautumaan Teamsiin tai et halua olla Teams-kanavalla, voit pyytää etäohjausta Zoomin kautta seuraavasti:

  1. Asenna Zoom sovellus koneellesi osoitteesta https://zoom.us/download (muut kuin tutkinto-opiskelijat) tai https://jyufi.zoom.us (tutkinto-opiskelijat; Valitse Download Client ihan alhaalta)
  2. Kirjaudu Zoomiin valitsemallasi tilillä, esim. Google-kirjautumista käyttäen (muut kuin tutkinto-opiskelijat) tai Single Sign-on / SSO -toiminnolla (tutkinto-opiskelijat; käytä company domainia jyufi)
  3. Aloita kokous New meeting toiminnolla
  4. Testaa Audio Test speaker & mikrofone toiminnolla että äänet pelittää
  5. Ota kokouslinkki talteen Participants Copy invite link
  6. Avaa ohjauspyyntölomake: https://forms.gle/5QULUPBHjjqS4ndf6
  7. Täytä omat tietosi ja HUOM Pasteta lisätietokenttään kohdassa 5 kopioimasi linkki
  8. Odota, että ohjaaja tulee huoneeseesi. Saatat joutua hyväksymään hänen sisäänpääsyn (riippuu kokoushuoneesi asetuksista)

Tässä muutama pikavinkki tässä materiaalissa navigoimiseen.

  • Sisällysluettelon saat auki ja kiinni sivupalkki-kuvakkeesta .
  • Voit selata materiaalia eteen- ja taaksepäin nuolikuvakkeista sivun vasemmassa ja oikeassa laidassa (tai ihan sivun alalaidassa, jos käytät mobiililaitetta) .
  • Hakutoiminnon saat auki suurennuslasista oikeasta yläreunasta tai painamalla S-kirjainta näppäimistöltä .

Palaute ja kehittäminen

Olemme erittäin kiitollisia kaikesta palautteesta, joka auttaa meitä kehittämään opintojaksoa edelleen! Voit antaa palautetta ja kehitysehdotuksia opintojaksosta kolmella tavalla:

  1. Jyväskylän yliopiston tutkinto-opiskelijat voivat antaa jatkuvaa palautetta opintojakson aikana Norppa-järjestelmässä. Nyt, kun olemme kehittämässä opintojakson sisältöjä ja toteutusta, tämä jatkuva palaute on erityisen tärkeää.

  2. Kaikki opiskelijat voivat ilmoittaa havaitsemistaan virheistä, epäselvyyksistä, tai muista ongelmista tässä oppimateriaalissa. Raportoi havaintosi GitHubissa klikkaamalla kunkin sivun alareunassa olevia linkkejä. Voit myös ilmoittaa puutteista suoraan opettajille sähköpostitse osoitteeseen ohj2-opet@jyu.onmicrosoft.com.

  3. Opintojakson lopuksi kaikki Sisussa (tai Ilpo-portaalissa) ilmoittautuneet (tutkinto, avoin, erilliset opinto-oikeudet, lukiolinjat) saavat henkilökohtaisen linkin kurssipalautekyselyyn, jossa voit antaa anonyymisti palautetta koko opintojaksosta.

Tekijät ja lisenssi

Ohjelmointi 2 oppimateriaali © 2025 by Denis Zhidkikh, Sami Sarsa, Antti-Jussi Lakanen, Rauli Ruokokoski, Karri Sormunen.

Kiitos Jonne Itkoselle palautteesta ja parannusehdotuksista.

Materiaali on julkaistu CC-BY-SA-4.0-lisenssillä. Tarkemmat tiedot löydät materiaalin GitHub-sivulta.

Suorittaminen

Voit valita kolmesta suoritustavasta itsellesi sopivimman. Kaikki suoritustavat sisältävät harjoitustyön tekemisen.

Suoritustapojen yksityiskohdat eroavat aikaisemmista toteutuksista jonkin verran, joten jos olet aiemmin yrittänyt suorittaa kurssia, lue tämä osio huolellisesti läpi.

Suoritustapa 1

Harjoitustehtävät, harjoitustyö aikataulussa sekä tentti

Suoritus koostuu seuraavista osasuorituksista (kaikista on saatava hyväksytty):

  1. Keräät jokaisesta osasta vähintään 50% siitä pistemäärästä mitä harjoitustehtävien perustehtävistä voi saada1
  2. Teet harjoitustyön aikataulussa
  3. Teet tentin hyväksytysti

1 Harjoitustehtävät muodostuvat perustehtävistä ja lisätehtävistä. "100%" tarkoittaa tässä yhteydessä sitä pistemäärää, mitä perustehtävistä voi saada. Voit kuitenkin tehdä myös lisätehtäviä kartuttaaksesi prosenttiosuuttasi ja siten tehdä jopa >= 100% pisteistä.

Arvosana muodostuu harjoitustehtävien ja tentin painotettuna keskiarvona (40/60). Tarkempi kuvaus on alla.

Harjoitustehtävien pisterajat:

Kerättyjä pisteitä enintäänHarjoitustehtävien arvosana
<50%0
50%1
60%2
70%3
80%4
>=90%5

Ensimmäisen kuuden osan kohdalla on mahdollista saada myös ns. DL-BONUS-pisteitä: Jos teet osasta vähintään 50%2 osan takarajaan mennessä, lisätään kyseisen osan harjoitustehtävien pistemäärään 0,5 pistettä. Niinpä DL-BONUS-pisteitä voi saada maksimissaan 6 * 0,5 = 3 pistettä. Karkeasti ottaen, kun teet yhden osan takarajaan mennessä, se vaikuttaa noin 1%-yksikön verran kokonaisprosenttiisi.

2 Edelleen, "50%" tarkoittaa tässä yhteydessä puolet siitä pistemäärästä, mitä kyseisen osan perustehtävistä voi saada.

Osien takarajat DL-BONUS-pisteiden saamiseksi ovat seuraavat:

OsaTakaraja DL-BONUS-pisteille
1ma 19.1.2026 klo 11:59 (keskipäivä)
2ma 26.1.2026 klo 11:59 (keskipäivä)
3ma 2.2.2026 klo 11:59 (keskipäivä)
4ma 9.2.2026 klo 11:59 (keskipäivä)
5ma 16.2.2026 klo 11:59 (keskipäivä)
6ma 23.2.2026 klo 11:59 (keskipäivä)

Näet etenemissivulla kerättyjen tehtäväpisteidän määrän ja prosenttiosuuden sekä DL-BONUS-pisteet erikseen.

Lopullinen arvosana muodostuu harjoitustehtävien arvosanan ja tentin arvosanan painotettuna keskiarvona, pyöristäen lähimpään kokonaislukuun. Harjoitustehtävistä saatua arvosanaa painotetaan 40% ja tentistä saatua arvosanaa painotetaan 60%. Sekä harjoitustehtävistä että tentistä täytyy saada vähintään arvosana 1, jotta kurssista voi saada hyväksytyn arvosanan.

Esim1Esim2Esim3
Osuus harjoitustehtävistä (sis DL-BONUS)70%90%70%
Harjoitustehtävien arvosana353
Tentin arvosana145
Painotettu keskiarvo1.84.44.6
Pyöristetty arvosana245

Harjoitustehtävistä saatu arvosana otetaan lukuun kolmeen ensimmäiseen tenttiin, jotka opiskelija suorittaa, ja enintään yhden vuoden sisällä opintojakson viimeisestä suorituspäivästä.

Suoritustapa 2

105% harjoitustehtävistä, suullinen kuulustelu harjoitustyöstä, harjoitustyö aikataulussa

  1. Keräät vähintään 100% pistettä harjoitustehtävistä (kaikista osista yhteensä) JA keräät kaikki DL-BONUS-pisteet (6 * 0.5 = 3 pistettä)
  2. Teet harjoitustyön aikataulussa
  3. Osallistut suulliseen kuulusteluun, jossa ohjaaja arvioi harjoitustyösi

Arvosanasi on tällöin 1, jota voit vapaaehtoisesti korottaa tentillä.

Suoritustapa 3

Harjoitustyö ja loppukoe.

  1. Teet harjoitustyön
  2. Teet tasokokeen tapaisen loppukokeen (tämä on eri asia kuin tentti)

Arvosana muodostuu loppukokeen arvosanasta.

Eettiset ohjeet

Olet vastuussa kaikista palauttamistasi töistä. Kopioiminen tai toisen henkilön työn esittäminen omanaan on kiellettyä. Ryhmätyö on sallittua, mutta jokaisen ryhmän jäsenen tulee antaa panoksensa työhön, ymmärtää tekemänsä asiat ja osata selittää ne tarvittaessa. Ryhmätyönä tehty osa tulee aina merkitä selvästi palautettuun työhön, esimerkiksi koodin kommenttien avulla.

Noudatamme Jyväskylän yliopiston ohjeita ja linjauksia tekoälypohjaisten sovellusten käytössä opiskelussa. Lue nämä ohjeet ja linjaukset huolellisesti, jos aiot käyttää tekoälytyökaluja opiskelussasi. Alla olevat ohjeet täydentävät näitä linjauksia.

Generatiivisten tekoälytyökalujen käyttö valmiiden vastausten luomiseksi on kiellettyä.

Generatiivista tekoälyä voi käyttää apuvälineenä esimerkiksi käsitteiden selittämiseen, tehtävänantojen ymmärtämiseen, virheilmoitusten tulkintaan, materiaalissa annettujen esimerkkien selittämiseen tai uusien esimerkkien luomiseen. Tekoälytyökalulle annettavassa kehotteessa tulee huomioida, että työkalut ovat hyvin herkkiä tuottamaan suoria vastauksia tehtäviin. Tästä syystä kehotteessa tulee tyypillisesti ilmaista selkeästi, että haluat ymmärtää asian etkä halua suoraa ratkaisua.

Jos käytät tekoälyä, Microsoft 365 Copilot lienee tässä suositeltavin työkalu, koska JY:llä on sopimus sen käyttämiseksi. Toinen mahdollinen työkalu on GitHub Copilot, joka kuuluu GitHub Education -pakettiin, jota opiskelijat voivat anoa ilmaiseksi.

Esimerkkejä sopivista kehotteista
  • Mitä tarkoitetaan kapseloinnilla ja miten se liittyy olio-ohjelmointiin?
  • Saan koodissani virheilmoituksen 'X'. Mitä se tarkoittaa? Selitä mitä virheilmoitus tarkoittaa ja mistä se voisi johtua.
  • Kurssillani on tällainen tehtävänanto: 'Kirjoita funktio, joka laskee Fibonacci-lukujonon n:nteen termiin asti.' Miten voisin lähestyä sen ratkaisemista?
Esimerkkejä kielletyistä kehotteista
  • Kirjoita funktio, joka laskee Fibonacci-lukujonon n:nteen termiin asti.
  • Mikä on oikea koodi tehtävään X?
  • Tee minulle JavaFX-sovellus, jonka avulla voin laskea menoja ja tuloja.
  • Tee JavaFX-sovellukseeni uusi ominaisuus, joka tekee X.
Esimerkki M365 Copilotin käyttämisestä harjoitustyön tekemisessä

Alla on kuvattu ongelma, joka liittyy harjoitustyön tekemiseen, ja esimerkki siitä, miten M365 Copilotia voisi käyttää apuna ongelman ratkaisemisessa.

Annan oheisen kehotteen M365 Copilotille.

Haluaisin, että ostostapahtumien TableView täyttäisi tilan alas saakka, kun ostostapahtuman yksityiskohtaisen tarkastelun näkymä ei ole auki. Anna vinkki, miten voisin jatkaa tästä eteenpäin.

Liitän oheen myös kontrollerin koodin, joka ei näy tässä. Erittäin oleellista kuitenkin on, että olen määritellyt TableView-komponentit, niihin liittyvät tapahtumankäsittelijät, ja ymmärrän lähtökohtaisesti, miten sovellukseni toimii. Kysymykseni koskee siis yksityiskohtaa, joka liittyy siihen, miten TableView-komponentin piilotus kannattaa tehdä JavaFX:ssä.

Vastaus on varsin pitkä, ja etenee seuraavasti. Copilot...

  • kuvailee ongelman ja sen syyn,
  • ehdottaa ratkaisuksi managedProperty()-ominaisuuden käyttämistä selittäen ensin, mitä se on ja miten se toimii,
  • antaa esimerkkikoodia, jossa yksittäisen ostostapahtuman tietojen näyttämisen managedProperty()-ominaisuuden arvo kytketään (bind) siihen, onko yksityiskohtaisen tarkastelun näkymä auki vai ei,
  • selittää, miten esimerkkikoodi toimii ja miten sitä voisi soveltaa omaan koodiini,
  • antaa vinkkejä, mitä muuta parannettavaa kontrollerini koodissa olisi, ja miten voisin jatkaa siitä eteenpäin. Esimerkiksi taulukoiden skaalauksessa oli ongelma (VBox.setVgrow()-metodin käyttö), ja Copilot ehdotti siihen ratkaisua.

Tässä tapauksessa palaute on erittäin hyödyllistä, ja auttaa konkreettisesti eteenpäin ongelman ratkaisemisessa. Joskus palaute on vähemmän hyödyllistä, ja joskus se voi jopa ohjata aivan väärään suuntaan. Tällaisissa tilanteissa onkin ensiarvoisen tärkeää, että osaan arvioida saamaani palautetta kriittisesti, ja että ymmärrän, miksi ehdotettu ratkaisu toimii tai miksi se ei toimi.

Tentissä, näyttökokeessa, suullisessa kuulustelussa ja vastaavissa näyttötilanteissa kaikenlaisten tekoälytyökalujen käyttö on ehdottomasti kiellettyä.

Menettely vilppiepäilytilanteessa on kuvattu Jyväskylän yliopiston opintoja ohjaavissa säädöksissä ja määräyksissä.

Työkaluohjeet

Ohjelmointi 2 -opintojaksolla käytämme seuraavia työkaluja:

  • Java Development Kit (JDK) - ohjelmistokehityspaketti, joka sisältää muun muassa Java-kääntäjän sekä virtuaalikoneen Java-ohjelmien ajamista varten.

  • Git - versiohallintaohjelma (engl. Version Control Software, VCS), joka mahdollistaa koodin versioinnin ja yhteistyön koodaajien välillä.

  • IntelliJ IDEA - integroitu kehitysympäristö (engl. Integrated Development Environment, IDE), jolla voi kehittää ja debugata muun muassa Java-ohjelmia. Käytämme IntelliJ IDEAn ilmaista Community Edition -versiota.

  • SceneBuilder - aputyökalu JavaFX-käyttöliittymien luomiseksi.

  • ComTest - työkalu dokumentaatiotestien kirjoittamiselle ja ajamiselle.

Yllä olevat ohjelmat löytyvät valmiiksi asennettuna Agoran mikroluokissa (Alban puoleinen pääty, 1. ja

  1. kerros). Jos sinulla on oma tietokone, suosittelemme vahvasti, että asennat ohjelmat myös siihen. Erityisesti harjoitustyön tekeminen on helpompaa, kun kaikki tarvittavat ohjelmat on myös omalla tietokoneella.

tärkeää

Tämän sivun ohjeet vaativat komentorivin käyttöä. Voit tarvittaessa kerrata komentorivin perusteita seuraavista linkeistä:

Kurssilla virallisesti tuettuja käyttöjärjestelmiä ovat Windows, macOS ja Linux. Työkalujen asentaminen ChromeOS:ään saattaa olla mahdollista, mutta emme valitettavasti voi tarjota tukea kyseiseen käyttöjärjestelmään. Tästä syystä emme suosittele ChromeOS:n käyttöä.

Valitse käyttöjärjestelmäsi alta:

Valitse käyttöjärjestelmäsi yllä olevista vaihtoehdoista.

Esivalmistelut

Valitse käyttöjärjestelmäsi yllä olevista vaihtoehdoista.

Git

Valitse käyttöjärjestelmäsi yllä olevista vaihtoehdoista.

IntelliJ IDEA

Valitse käyttöjärjestelmäsi yllä olevista vaihtoehdoista.

Java Development Kit (JDK)

  1. Avaa IntelliJ IDEA ja odota, kunnes pääset Welcome to IntelliJ IDEA -näkymään.

  2. Klikkaa ikkunan keskellä tai ylädassa olevaa New Project -painiketta:

  3. Avautuneesta ikkunasta klikkaa JDK-alasvetolaatikkoa ja valitse Download JDK... -painike:

  4. Aseta avautuneessa ikkunassa asetukset seuraavasti:

    • Version: 25
    • Vendor: Oracle OpenJDK Älä muuta Location-kohdassa olevaa polkua! Paina lopuksi Select-painiketta.
  5. Jätä muut projektin asetukset sellaiseksi kuin ne ovat. Paina oikeassa alalaidassa olevaa Create-painiketta ja anna projektin latautua.

    Tämä avaa IntelliJ IDEA -kehitysympäristön käyttöliittymän.

    JDK:n lataamisessa voi mennä aikaa. Odota rauhassa, kunnes kaikki virheet ja punaiset tekstit häviää.

  6. Kun projekti on latautunut eikä virheitä näy, kokeile ajaa projekti painamalla oikeassa ylälaidassa olevaa Play-painiketta:

  7. Odota, kunnes ohjelma kääntyy. Jos kaikki toimii, ikkunan alapuolelle pitäisi ilmestyä konsoli-ikkuna, jossa näet seuraavan tekstin:

    Hello and welcome!
    i = 1
    i = 2
    i = 3
    i = 4
    i = 5
    
  8. Voit nyt sulkea IntelliJ IDEA:n.

SceneBuilder

Valitse käyttöjärjestelmäsi yllä olevista vaihtoehdoista.

ComTest

  1. Avaa IntelliJ IDEA ja odota, kunnes pääset Welcome to IntelliJ IDEA -näkymään.

    Jos sinulle avautui jokin vanha projekti, klikkaa yläpalkista tai hampurilaisvalikosta File Close project. Tämä vie sinut takaisin Welcome to IntelliJ IDEA -näkymään.

  2. Klikkaa ikkunan vasemmassa alalaidassa oleva Configure (Ratas-ikoni) Settings.

  3. Valitse vasemmalla puolella olevista asetusnäkymistä Plugins

  4. Valitse Marketplace-välilehti ja hae hakusanalla ComTest

  5. Valitse Comtest Runner -pluginin kohdalta Install

  6. Paina Save

  7. Sulje IntelliJ IDEA

Mitä seuraavaksi?

Onneksi olkoon! Sinulla on seuraavaksi kaikki tarvittavat kurssityökalut. Voit jatkaa tästä varsinaisiin materiaaleihin.

Yleiset ongelmat ja ratkaisut

Saan IDEAssa Java-projektia ajaessa virheen error: illegal character: '\ufeff'

Virhe voi mahdollisesti johtua siitä, että toit Rider-työkalun asetukset IDEAan. Riderin asetukset eivät ole täysin yhteensopivia Javan kehityksen kanssa eikä IDEA osaa korjata ongelmaa.

Tee seuraavasti:

  1. Avaa IntelliJ IDEA ja odota, kunnes pääset Welcome to IntelliJ IDEA -näkymään.

    Jos sinulle avautui jokin vanha projekti, klikkaa yläpalkista tai hampurilaisvalikosta File Close project. Tämä vie sinut takaisin Welcome to IntelliJ IDEA -näkymään.

  2. Klikkaa ikkunan vasemmassa alalaidassa oleva Configure (Ratas-ikoni) Settings.

  3. Valitse vasemmalla puolella olevista asetusnäkymistä Editor File Encodings

  4. Aseta Create UTF-8 files -asetuksen arvoksi with no BOM.

  5. Paina Save.

  6. Tee uusi projekti ja kokeile ajaa yksinkertainen ohjelma.

Internal error: com.intellij.platform.ide.bootstrap... Process "C:\...idea64.exe" is still running and does not respond

Tämä virhe voi johtua siitä, että Rider on jostain syystä jumittunut taustaprosessina käyttöjärjestelmässä.

  1. Sulje IntelliJ IDEA kokonaan.
  2. Avaa Rider.
  3. Sulje Rider.
  4. Käynnistä IntelliJ IDEA uudelleen.

Harjoitustyö

Opintojaksoon kuuluu harjoitustyö, jossa toteutat graafisen Java-käyttöliittymäsovelluksen käyttäen JavaFX-kirjastoa. Löydät alta harjoitustyön tarkat arvioitavat vaatimukset.

Voit tehdä harjoitustyön joko valmiin vaatimusmäärittelyn perusteella tai keksiä oman aiheen. Löydät kaikki aiheiden kuvaukset ja vaatimukset alla.

Harjoitustyö toteutetaan vaiheittain osissa 9–12. Jokainen osa sisältää ohjeita, joiden tarkoituksena on auttaa sinua etenemään, mutta voit toteuttaa vaatimukset myös muulla tavoin, kunhan ne täyttyvät.

Suosittelemme, että ennen harjoitustyön tekemistä tutustut ja teet osissa 7-8 tehdyn Todo-sovelluksen. Saat siitä merkittävästi apua harjoitustyön toteutukseen.

Näytä lopullinen, kaikkia vaatimuksia täyttävä harjoitustyösi kurssin tuntiopettajalle ennen osan 12 palautusta etä- tai lähiohjauksessa. Kun tuntiopettaja on hyväksynyt työsi, hän tekee siitä merkinnän TIMissä.

Parityöt

Harjoitustyön voi tehdä yksin tai parin kanssa. Sovelluksessa tulee olla vähintään kolme mallinnettavaa kohdetta (yksilötöissä kaksi; vaatimus 1.1), sekä kolme näkymää (yksilötöissä kaksi; vaatimus 4.1). Muut vaatimukset ovat samat kuin yksilötyössäkin.

Parityössä kummankin tulee osallistua mahdollisimman tasapuolisesti työn edistämiseen. Harjoitustyön palauttamisen yhteydessä kummankin tekijän tulee pystyä osoittamaan ohjaajalle, että pystyy itse toteuttamaan harjoitustyön vaatimukset. Ei voi siis olla esimerkiksi niin, että toinen tekijä keskittyy yhteen osa-alueeseen, esimerkiksi käyttöliittymään, ja toinen tekijä hoitaa koodin ja tietomallin toteutuksen.

Yli kahden hengen ryhmiä ei sallita.

Aihe

Voit valita valmiin aiheen alla olevista vaihtoehdoista, tai keksiä oman aiheen, joka täyttää vaatimukset. Näet kunkin aiheen tarkemmat vaatimukset klikkaamalla.

Bonus-merkinnällä () olevia vaatimuksia ei ole pakko toteuttaa.

Kulujen seuranta

Tässä sovelluksessa käyttäjä voi seurata omia kulujaan ja menojaan.

Toiminnalliset vaatimukset

  • Käyttäjä voi syöttää kuluja ja menoja ("tapahtuma").
  • Käyttäjä näkee kaikki tapahtumat taulukossa, jossa on ainakin tapahtuman nimi, summa ja päivämäärä.
  • Käyttäjä voi määrittää kulukategorioita.
  • Käyttäjä voi määrittää, mihin kategoriaan kulu kuuluu. Tuloilla ei tarvitse välttämättä olla kategoriaa.
  • Käyttäjä näkee tietyn kategorian tapahtumat
  • Käyttäjä voi katsoa tapahtumat tietyltä aikaväliltä (kaksi päivämäärää).
  • Käyttäjä voi muokata tapahtumia ja kategorioita.
  • Kategorian nimen vaihtaminen siirtää kaikki kyseiseen kategoriaan liittyvät tapahtumat uuteen kategoriaan.
  • Kulukategoria voi olla pakollinen, mikä tarkoittaa välttämätöntä menoa, kuten vuokra tai sähkölasku.
  • Käyttäjä voi valita useita kategorioita filtteriin. Käytä esimerkiksi ControlsFX:n CheckComboBox-komponenttia (https://controlsfx.github.io/features/checkcombobox/).
  • Käyttäjä näkee kuvaajan, jossa esitetään kaikki tapahtumat kuukausittain.
  • Käyttäjä näkee kategorioittain aikasarjan kuluista.

Voit hyötyä ainakin seuraavista komponenteista:

  • DatePicker päivämäärän valitsemiseen.
  • CheckBox ja ComboBox kategorian valitsemiseen ja suodattamiseen.
  • TableView-komponenttiin FilteredList, joka tuottaa suodatetun näkymän. Lue ohjeet FilteredListin käyttöön.

Sovelluksen tietomalli

Tietomalli voi näyttää esimerkiksi seuraavalta.

Tällaisessa mallissa tieto siitä onko tapahtuma kulu vai meno voidaan ilmaista summan positiivisuudella tai negatiivisuudella. Toinen vaihtoehto olisi tehdä TuloVaiMeno-enum, ja lisätä Tapahtuma-luokkaan TuloVaiMeno tuloVaiMeno -attribuutti. Kannattaa ehkä aloittaa ilman enumia, ja lisätä se myöhemmin, jos tuntuu, että siitä on hyötyä.

Valmiit kulukategoriat

Voit antaa sovelluksessa valmiiksi vaikkapa nämä Marttojen budjettioppaan mukaiset kulukategoriat. Toki voit keksiä myös omasi tai antaa käyttäjälle vapauden luoda omat kategoriansa.

  • Ruoka kotona
  • Ravintolat
  • Vuokra ja vastike
  • Vesi
  • Sähkö
  • Muu asuminen
  • Vaatteet
  • Terveys
  • Auto
  • Julkinen liikenne
  • Muu matkustus
  • Suoratoistot
  • Päivähoito
  • Vakuutukset
  • Kodin hankinnat
  • Vapaa-aika
  • Lahjat, lahjoitukset
  • Säästäminen
  • Lainanhoito ja korot
  • Muut menot

Valmis sovellus voisi näyttää vaikkapa tältä. Ei haittaa, vaikka oma käyttöliittymäsi ei näyttäisi samanlaiselta -- tärkeintä on, että sovelluksesi täyttää vaatimukset, ei se, miltä se näyttää.

alt text

Kassa

Tässä sovelluksessa käyttäjä voi hallita tuotteita ja tehdä ostostapahtumia.

  • Käyttäjä voi syöttää tuotteita. Tuotteella on tunniste, nimi, hinta.
  • Käyttäjä näkee kaikki tuotteet taulukossa
  • Käyttäjä voi tehdä ostostapahtumia, joissa hän syöttää ostettavan tuotteen ja ostettavan määrän.
  • Ostostapahtuman yhteydessä kunkin rivin kohdalla näytetään yksikköhinta ja rivin loppuhinta (yksikköhinta * määrä) ostostapahtuman yhteydessä. Tarvitset sarakkeen, joka laskee kertolaskun tuotteiden hinnasta ja ostettavasta määrästä.
  • Näytetään ostosten loppuhinta.
  • Sovelluksessa voi olla eri näkymät tuotteille (yksi TableView) tai sekä tuotelista että ostostapahtumalista samassa näkymässä (kaksi TableViewia samassa Scenessä).
  • Tuotteita ja ostorivejä pitää pystyä muokkaamaan, mutta ostostapahtumia ei tarvitse muuttaa ostotapahtuman jälkeen. Tässä kannattanee tehdä niin, että ostotapahtuman yhteydessä tuotteen hinta kiinnitetään ostotapahtuman tietoihin, jolloin tuotteen muokkaaminen ei vaikuta vanhoihin ostotapahtumiin.
  • Rivialennus tai ostostapahtumakohtainen alennus

Kun käyttöliittymässä tehdään ostotapahtuma

  • Käyttäjä valitsee ostettavan tuotteen alasvetovalikosta, ja syöttää ostettavan määrän.
  • käyttäjä voi hakupalkin avulla hakea tuotteita nimellä. Tarvitset mahdollisesti ControlFX:ää tähän.
  • Tarvitset taulukon, joka näyttää ostotapahtuman tämän hetkisen tilanteen. Taulukossa pitää näkyä tuotteen tunniste, nimi, tuotteen hinta, määrä ja rivihinta.
  • Koska Ostorivi-luokassa ei ole viittausta Tuote-olioon, pitää Tuotteen tiedot hakea tuotteen tunnisteen avulla apumetodia käyttäen. Tämä onnistuu Bindings-luokan avulla.

Kirjasto

Sovellus kirjaston kirjojen ja lainojen hallintaan. Kirjaston hoitaja voi hallita kirjojen tietoja ja lainatilannetta, sekä tutkia kirjojen lainahistoriaa.

Toiminnalliset vaatimukset

  • Käyttäjä voi syöttää kirjoja ja niiden tietoja.
  • Käyttäjä näkee kaikki kirjat taulukossa, jossa on ainakin kirjan nimi, tekijä, ISBN-tunniste ja lainatilanne. Käyttäjä voi myös helposti nähdä, kuinka monta kertaa jokaista kirjaa on lainattu.
  • Käyttäjä voi lainata kirjoja henkilölle ja merkata lainaukset palautetuksi.
  • Käyttäjä voi katsoa kirjojen lainaushistoriaa.
  • Jokainen kirja voi olla samaan aikaan lainassa vain yhdellä lainaajalla. Samaa kirjaa voi lainata, jos se on parhaillaan lainassa.
  • Käyttäjä voi helposti nähdä, mitkä kirjat ovat lainassa. Käyttäjä voi lisäksi helposti nähdä, minkä kirjojen lainausten palautuspäivämäärä on mennyt jo ohi (eli ns. myöhästyneet palautukset).

Sovelluksen tietomalli

Sovellus sisältää kaksi oleellista tietomallin kohdetta: Kirja ja Lainaus. Lisäksi tietomallissa on kaikkia asuntoja hallinnoiva Kirjasto-luokka. Tietomalli näyttää seuraavalta:

Esimerkki siitä, miltä JSON voisi näyttää.

Taloyhtiön hallinta

Taloyhtiön hallintasovelluksessa taloyhtiön isännöitsijä voi hallita taloyhtiön tietoja, kuten asuntoja, asukkaita ja taloyhtiön tapahtumia.

Toiminnalliset vaatimukset

  • Käyttäjä voi syöttää asuntoja ja niiden tietoja.
  • Käyttäjä näkee kaikki asunnot taulukossa, jossa on asunnon numero ja asukkaiden lukumäärä
  • Käyttäjä voi lisätä ja poistaa asukkaita asuntoon.
  • Käyttäjä näkee asunnon asukkaat taulukossa, jossa on ainakin asukkaiden nimet, sähköpostiosoitteet ja syntymävuodet.

Sovelluksen tietomalli

Sovellus sisältää kaksi oleellista tietomallin kohdetta: Asunto ja Asukas. Lisäksi tietomallissa on kaikkia asuntoja hallinnoiva Yhtio-luokka. Tietomalli näyttää seuraavalta:

Valmis sovellus voisi näyttää vaikkapa tältä.

taloyhtiö

Bonus: Lisää ominaisuuksia

Voit halutessasi lisätä sovellukseen myös alla olevia ominaisuuksia. Lisäominaisuudet eivät vaikuta harjoitustyön hyväksyntään, ja voit toteuttaa ne haluamallasi tavalla. Mikäli kuitenkin lisäät ylimääräisiä ominaisuuksia, tulee ne toteuttaa harjoitustyön vaatimuksia noudattaen.

Isännöitsijän ja asukkaan näkymät

  • Sovelluksessa on kaksi tilaa: Isännöitsijän näkymä ja asukkaan näkymä.
  • Sovelluksen aloitusnäytössä käyttäjä valitsee, haluaako hän käyttää isännöitsijän vai asukkaan näkymää. Jos valitaan asukkaan näkymä, käyttäjän tulee valita asukas, jona hän "kirjautuu" näkymään.
  • Isännöitsijä voi hallita asuntojen tietoja, asukkaita ja taloyhtiön tapahtumia (kuten perusversiossakin)
  • Asukkaan näkymässä käyttäjä näkee sen asunnon tiedot, johon hän kuuluu. Asukas ei voi muokata asunnon perustietoja.
  • Asukkaan näkymässä käyttäjä voi antaa palautetta isännöitsijälle.
  • Isännöitsijänäkymässä käyttäjä näkee palautteet taulukossa, jossa on ainakin palautteen tekijän nimi, päivämäärä ja palautteen sisältö.

Vesimittarilukemien kirjaus

  • Asukasnäkymässä asukas voi syöttää asunnolle vesimittarilukemia. Vesimittarilukema sisältää lukeman, päivämäärän ja sen, onko lukema otettu kylmästä vai lämpimästä vedestä.
  • Asukas näkee asuntonsa vesimittarilukemat taulukossa

Vesilaskun luominen

  • Isännöitsijä voi syöttää taloyhtiölle lämpimän ja kylmän veden hinnat ja niiden alkamispäivät.
  • Käyttäjä voi luoda asunnolle vesilaskun. Vesilasku sisältää kahden viimeisimmän kylmän ja lämpimän veden lukeman erotuksen. Riittää, että vesilasku näyttää laskukauden (alku- ja loppupvm), kulutetun kylmän ja lämpimän veden määrän, sekä vesilaskun loppusumman (kylmä ja lämmin vesi eroteltuina).

Voit hyötyä seuraavista tietomalleista.

Muistikorttisovellus

Sovellus, jolla voi luoda ja hallita muistikortteja (vrt. Anki). Käyttäjä voi luoda muistikortteja, jotka sisältävät termin ja siihen liittyvän selityksen. Samaan aiheeseen kuuluvia muistikortteja kerätään korttipakkoihin, joita voi harjoitella sovelluksessa.

Toiminnalliset vaatimukset

  • Käyttäjä voi luoda korttipakkoja, jotka sisältävät kortteja. Korttipakalla on nimi ja valinnainen kuvaus.
  • Käyttäjä voi lisätä kortteja korttipakkaan. Kortilla on termi ja termin selitys.
  • Käyttäjä voi selata ja muokata lisättyjä korttipakkoja.
  • Käyttäjä voi harjoitella korttipakan kortteja ns. harjoitustilassa. Harjoitustilassa käyttäjälle näytetään yhden korttipakan kortin termi. Käyttäjä voi katsoa kortin selityksen (eli ns. "kääntää kortin"). Käyttäjä voi sen jälkeen siirtyä seuraavaan korttiin tai edelliseen korttiin.
  • Harjoitustilassa kortit näytetään aina satunnaisessa järjestyksessä.
  • Käyttäjä voi muokata ja poistaa korttipakkoja tai sen kortteja.

Sovelluksen tietomalli

Sovellus sisältää kaksi oleellista tietomallin kohdetta: Kortti ja Korttipakka. Lisäksi tietomallissa on kaikkia korttipakkoja hallinnoiva Korttipakkakokoelma-luokka. Tietomalli näyttää siten seuraavalta:

Bonus: Lisää ominaisuuksia

Voit halutessasi lisätä sovellukseen myös alla olevia ominaisuuksia. Lisäominaisuudet eivät vaikuta harjoitustyön hyväksyntään, ja voit toteuttaa ne haluamallasi tavalla. Mikäli kuitenkin lisäät ylimääräisiä ominaisuuksia, tulee ne toteuttaa harjoitustyön vaatimuksia noudattaen.

Pelitilastot

  • Lisää kortteille katselukertojen lukumäärän. Aina, kun käyttäjä paljastaa kortin selityksen harjoitustilassa, kortin katselukerta kasvaa yhdellä.
  • Korttien katselukerrat näytetään korttipakan muokkausnäkymässä korttitaulukossa.
  • Lisää korttipakalle harjoituskertojen lukumäärän. Aina, kun käyttäjä avaa harjoitustilan ja harjoittelee jokaisen kortin kerran, kasvatetaan harjoituskertojen lukumäärää.
  • Korttipakan harjoituskerrat näytetään päänäkymässä omana sarakkeena.

Tenttitila

  • Lisää korttipakoille tenttitila. Tenttitilassa käyttäjälle näytetään yhden kortin termi ja kolme mahdollista selitystä monivalintakysymyksenä. Käyttäjän tulee valita oikea termiä vastaava selitys. Käyttäjä saa palautteena oikean vastauksen, minkä jälkeen näytetään seuraava monivalintakysymys.
  • Tenttitilan tulee toimia yhtä hyvin niin kolmen kortin kuin usean sadan kortin pakalla.
  • Tenttitilaan pääsee vain, jos pakassa on vähintään kolme korttia.
  • Voit hyötyä mm. RadioButton ja ToggleGroup -komponenteista.

Oma idea

Oma vapaavalinnainen JavaFX-käyttöliittymäsovellus, joka täyttää opintojakson harjoitustyön vaatimukset. Halutessasi voit myös laajentaa osissa 7 ja 8 työstettyä Todo-sovellusta.

Jos valitset oman aiheen, sinun on kirjoitettava alustava harjoitustyösuunnitelma, jossa ilmenevät sovelluksen oleelliset toiminnalliset vaatimukset sekä sovelluksessa käytettävä tietomalli. Voit ottaa mallia suunnitelman laajuudesta yllä olevista harjoitustyöaiheista.

Suunnitelma tulee hyväksyttää tuntiopettajalla ennen kuin aloitat toteutuksen.

Suunnitelmaa kirjoittaessasi pohdi myös, millä tavoin täyttää harjoitustyön yleiset vaatimukset. Tuntiopettaja voi pyytää täydennyksiä suunnitelmaan, jos työn laajuus ei vastaa harjoitustyön vaatimuksia.

Jos päätät laajentaa osan 7 ja 8 Todo-sovellusta, harjoitustyön vaatimukset koskevat sinun tekemää laajennosta. Esimerkiksi vaatimus 1.1 (Sovelluksessa on vähintään kaksi kohdealueen mallinnettavaa asiaa) tarkoittaisi, että sinun tulee määrittää kaksi uutta mallinnettavaa asiaa nykyisen Tehtava-mallin lisäksi. Puolestaan vaatimus 4.1 tarkoittaa, että käyttöliittymään on lisättävä kaksi uutta lisänäkymää tai laajentaa nykyiset näkymät merkittävästi siten, että laajennos voisi tulkita omaksi näkymäksi.

Tekniset vaatimukset ja arviointi

Alla olevia vaatimuksia käytetään harjoitustyön arvioinnissa. Harjoitustyö arvioidaan asteikolla hylätty/hyväksytty. Hylätyn harjoitustyön voi täydentää tuntiopettajan antaman palautteen perusteella.

Lähtökohtaisesti työn on täytettävä kaikki alla olevat vaatimukset. Yksittäisten vaatimusten kohdalla voidaan joustaa, mikäli työ on muilta osin tavanomaista laajempi tai ansiokkaampi, tai jos työn aihe sitä vaatii. Tuntiopettaja tekee lopullisen arvion työn hyväksymisestä tapauskohtaisesti.

Vaatimus 1: Tietomalli

  1. Sovelluksessa on vähintään kaksi kohdealueen mallinnettavaa asiaa.

    Se voi olla esimerkiksi tehtävä, tapahtuma, kirja, asiakas, treeni, peli, resepti tai vastaava. Jokaisella mallinnettavalla oliolla on omia kohdealueen kannalta merkittäviä attribuutteja tai ominaisuuksia. Osien 7–8 mallisovelluksessa oli yksi mallinnettava asia: Tehtava. Puolestaan muistikorttisovelluksessa ne voisivat olla Kortti ja Korttipakka. Kulujen hallintasovelluksessa taas sopivat mallinnettavat asiat olisivat Tapahtuma ja Kategoria.

  2. Jokaisella kohdealuetta mallinnettavalla asialla on oltava vähintään yksi kohdealueen kannalta oleellinen ja asialle ominainen attribuutti.

    Osien 7–8 mallisovelluksessa Tehtava-luokka sisälsi attribuutit tehty, otsikko, kuvaus ja prioriteetti.

    Huomaa, että attribuutti, jonka ainoa tarkoitus on viitata toiseen malliin tai jonka arvo on johdettavissa jonkun toisen attribuutin arvosta ei lasketa tähän vaatimukseen mukaan. Esimerkiksi osan 7–8 mallisovelluksen Tehtavakokoelma-luokka sisältää ainoana attribuuttina tehtavat-kokoelman, joka on vain kokoelma viitteitä tehtäviin, ja siten sitä ei laskettaisi tähän vaatimukseen mukaan. Sen sijaan muistikorttisovelluksessa Korttipakka sisältää korttikokoelman lisäksi korttipakan otsikon ja kuvauksen, jotka lasketaan sovelluksen kannalta oleellisiksi ja korttipakalle ominaisiksi.

  3. Sovelluksen dataa ei mallinneta käyttöliittymäkomponenteilla, vaan omilla malliluokilla.

  4. Sovelluksessa käytetään JavaFX:n havaittavia (observable) rakenteita silloin, kun ne liittyvät käyttöliittymän ja datan kytkemiseen.

    Vähintään keskeisen tietokokoelman tulee olla ObservableList tai vastaava.

  5. Datan esittämiseen käyttöliittymässä käytetään tarkoituksenmukaista komponenttia.

    Jos työssä on useita samantyyppisiä olioita, TableView on yleensä luonteva ratkaisu.

Vaatimus 2: Perustoiminnallisuus

  1. Kullekin mallinnetulle oliolle on toteutettava CRUD-toiminnallisuus käyttöliittymässä.

    Käyttäjä voi luoda (Create), lukea (Read), päivittää (Update) ja poistaa (Delete) olioita käyttöliittymän kautta. Esimerkiksi osan 7–8 mallisovelluksessa käyttäjä voi luoda tehtäviä painikkeella, lukea ne TableView-komponentista, muokata tehtäviä erillisessä näkymässä ja poistaa ne poistopainikkeella.

  2. Käyttäjän ei saa antaa lisätä ilmeisen virheellistä tietoa.

    Esimerkiksi tyhjää nimeä tai pakollisen kentän puuttumista ei tule sallia. Validointi on toteutettava joko mallissa tai käyttöliittymässä.

  3. Käyttöliittymän tila vastaa aina mallin tilaa ja päinvastoin.

    Tietoa päivitettäessä tai poistettaessa malliin tai käyttöliittymään ei saa jäädä väärää tietoa. Olion poistaminen mallista poistaa sen välittömästi myös näkyvistä.

Vaatimus 3: Tallennus

  1. Sovelluksen tiedot tallennetaan tiedostoon.

    Tiedot säilyvät ohjelman sulkemisen jälkeen. Tallennus voi tapahtua automaattisesti tai erillisenä "Tallenna"-toimintona.

  2. Tallennetut tiedot ladataan takaisin ohjelman käynnistyessä

Vaatimus 4: Käyttöliittymä

  1. Sovelluksessa on graafinen käyttöliittymä, jossa on vähintään kaksi näkymää.

    Näkymät voivat olla esimerkiksi päänäkymä (listaus) ja muokkausnäkymä (dialogi).

  2. Käyttöliittymä on jäsennelty ja käyttökelpoinen.

    Syöttökentät, painikkeet, nimiöt ja listaukset on aseteltu loogisesti, eivätkä ne ole sattumanvaraisia.

Vaatimus 5: Arkkitehtuuri ja vastuunjako

  1. Sovelluksen rakenne noudattaa MVC-mallia (Model-View-Controller).

    Pääasia on, että tietomalli, käyttöliittymä ja niitä yhteen kytkevä ohjainlogiikka on erotettu toisistaan.

  2. Sovelluksen data ja tallennuslogiikka on erotettu ohjainluokasta.

    Käyttöliittymän ohjain ei saa sisältää kaikkea sovelluksen dataa ja tallennuslogiikkaa.

  3. Tiedon lataus ja tallennus on erotettu omaksi vastuualueekseen.

    Käytännössä talletukselle ja lataukselle on vähintään oma metodi, joka on sijoitettu ohjainlogiikan ulkopuolelle.

Vaatimus 6: Testaus

  1. Sovelluksen keskeiselle mallille tai sovelluslogiikalle on kirjoitettu yksikkötestejä.

    Testeissä on varmistettava, että keskeiset metodit (kuten lisääminen ja poistaminen) muokkaavat tietomallin tilaa odotetusti.

Vaatimus 7: Versiohallinta ja projektinhallinta

  1. Työlle on luotu julkinen Git-etävarasto (esim. GitLab tai GitHub).

  2. Projektissa on .gitignore-tiedosto ja README.md.

    README-tiedostossa kerrotaan lyhyesti, mikä sovellus on kyseessä ja miten se toimii.

  3. Git-commitit on nimetty kuvaavasti.

    Commiteista käy ilmi, mitä muutoksia kukin niistä sisältää.

  4. Työtä on edistetty iteratiivisesti tallentaen iteraatioiden tulokset omina committeina.

    Etävarastossa ei saa olla vain yhtä "valmis sovellus" -committia, vaan kehityshistorian tulee näkyä.

Vaatimus 8: Koodin laatu

  1. IntelliJ IDEAssa ei saa näkyä mitään virheitä tai varoituksia Java-lähdekoodissa.

    Käännösvirheet ja varoitukset tarkistetaan käyttäen IDEAn Java-kielen oletusasetuksia. IDEA merkitsee varoitukset keltaisella ja virheet punaisella. Jos on perusteltu syy sallia jokin varoitus, siitä on mainittava koodissa SuppressWarnings-kommentilla ja lyhyellä perustelulla, miksi kyseinen varoitus on sallittu.

    Kielen tarkistukseen liittyvät varoitukset (vihreällä) sallitaan. Vastaavasti .fxml-tiedostossa olevia virhemerkintöjä sallitaan.

    Voit ajaa virheentarkistuksen kaikille tiedostoille kerralla käyttäen Run all inspections -toimintoa.

    Huomaa, että monille varoituksille ja virheille IntelliJ IDEA tarjoaa valmiita korjauksia, jotka saa näkyviin klikkaamalla varoituksen yhteydessä näkyvästä toimintopainikkeesta ().

  2. Kaikkien .java-lähdekooditiedostojen tulee olla muotoiltu yhtenäisellä tyylillä.

    Käytä IDEA:n Reformat code -ominaisuutta käyttäen sen kaikkia korjauksia (Optimize imports, Rearrange entries, Cleanup code).

  3. Julkisuusmääreet ovat eksplisiittisesti määritelty jokaiselle luokalle, attribuutille ja metodeille hyviä kapselointiperiaatteita noudattaen.

    Attribuutit ovat lähtökohtaisesti merkitty private-määreellä. Metodien kohdalla julkisuusmääreen tulee sopia metodin tarkoitukseen: muiden olioiden käytettäväksi tarkoitetut metodit ovat public, kun taas luokan omat apumetodit ovat private.

Tentti

Lukuvuonna 2025-2026 tenttejä järjestetään seuraavasti

TenttiPäivämääräAikaPaikkaIlmoittautumislinkki
Kevät 1ke 22.4.2026klo 10-14Agora Auditorio 1 / ZoomIlmoittaudu
Kevät 2ke 6.5.2026klo 12-16Agora Auditorio 2 / ZoomIlmoittaudu
Kevät 3ke 27.5.2026klo 12-16Agora Auditorio 2 / ZoomIlmoittaudu
Kesä 118.6.2026klo 12-16Agora / ZoomIlmoittaudu
Kesä 25.8.2026klo 12-16Agora / ZoomIlmoittaudu
Syksy 1pp.kk.2026klo xx-xxAgora / ZoomIlmoittaudu
Syksy 2pp.kk.2026klo xx-xxAgora / ZoomIlmoittaudu
Syksy 3pp.kk.2026klo xx-xxAgora / ZoomIlmoittaudu

Voit osallistua tenttiin joko Agoran luentosalissa tai etänä Zoomin kautta.

Tenttiin tulee ilmoittautua viimeistään 72 tuntia ennen tentin alkuhetkeä. Ilmoittautumisen yhteydessä opiskelija valitsee tentin suoritustavan (salitentti tai etätentti) ja hyväksyy tentin säännöt (kuvattu alla).

Ilmoita ilmoittautumisen yhteydessä, mikäli sinulle on myönnetty lisäaikaa tentteihin yksilöllisenä järjestelynä.

Ennen kuin ilmoittaudut tenttiin, lue huolellisesti (i) Jyväskylän yliopiston ohjeet verkkotenttien suorittamiseen ja (ii) Tarkentavat ohjeet opintojakson Ohjelmointi 2 (TIEP111) -tenttiin ennen tenttiin ilmoittautumista. Jos yliopiston ohjeiden ja opintojakson tarkentavien ohjeiden välillä on ristiriita, opintojakson ohjeet pätevät.

Ongelmat tentin aikana

Jos tentin aikana ilmenee ongelmia, käytä jotakin seuraavista yhteydenottotavoista:

  • Salissa: Nosta kätesi ylös ja odota, että valvoja tulee luoksesi.
  • Etänä: Jätä avunpyyntö lomakkeella: Avunpyyntö etätentissä (linkki tulee tähän hyvissä ajoin ennen tenttiä)
    • Vaihtoehtoisesti laita sähköpostia osoitteeseen ohj2-opet@jyu.onmicrosoft.com.
    • Vaihtoehtoisesti soita numeroon 040 805 3276 (Antti-Jussi Lakanen). Ei tekstiviestejä.
    • Muista, että ongelman sattuessa ei ole kiirettä tai syytä paniikkiin. Vastuuopettaja voi tarvittaessa myöntää lisäaikaa tenttiin.

Tyyliopas

Tämä dokumentti on kesken.

Usein kysyttyä

Olen aloittanut Ohjelmointi 2 -kurssin aiemmin. Voinko jatkaa siitä, mihin jäin?

Jos sinulta puuttuu aiemmalta toteutukselta vain harjoitustyö, ja muut osasuoritukset ovat enintään 3 vuotta vanhoja, voit viimeistellä harjoitustyön.

Jos sinulta puuttuu jotain tai joitain muita osasuorituksia, sinun tulee suorittaa opintojakso uudelleen. Tämä johtuu siitä, että opintojakson materiaaleja päivitettiin merkittävästi keväällä 2026.

Voinko korottaa aiempaa suoritustani?

Hyväksyttyä arvosanaa voi korottaa enintään kolmen vuoden kuluessa hyväksytyn arvosanan saamisesta. Korottaminen tapahtuu tekemällä kuluvan toteutuksen harjoitustehtävät ja tentti. Arvosana lasketaan Suoritustavan 1 mukaisesti. Aiemmin (enintään kolme vuotta sitten hyväksytty) tehty harjoitustyö merkitään hyväksytyksi kuluvalle toteutukselle, eikä sitä tarvitse tehdä uudestaan.

Tenttiohjeet

Tämä sivu sisältää Jyväskylän yliopiston ohjeet verkkotenttien suorittamiseen sekä tarkentavat ohjeet opintojakson Ohjelmointi 2 (TIEP111) -tenttiin.

1. Jyväskylän yliopiston ohjeet verkkotenttien suorittamiseen

Verkkotentin suorittaminen

  • Verkkotenttiin ilmoittaudutaan tentin järjestäjän ohjeiden mukaisesti.
  • Verkkotentti on yksilökoe, ellei tentin järjestäjä ole muuta ohjeistanut. Se suoritetaan ilman ulkopuolisten henkilöiden suoraa tai välillistä apua.
  • Järjestelmään, jossa verkkotentti järjestetään, kirjaudutaan Jyväskylän yliopiston käyttäjätunnuksella ja salasanalla. Käyttäjätunnus on henkilökohtainen, eikä omaa tunnusta saa luovuttaa eteenpäin muille henkilöille.
  • Verkkotentti suoritetaan sille varattuna aikana.
  • Verkkotentin saa suorittaa tietokoneella, tabletilla tai puhelimella. Tentti suositellaan suoritettavaksi tietokoneella.
  • Verkkotentin suorittamisen aikainen yhteydenpito ja viestintä ulkopuolisten henkilöiden kanssa on kielletty.
  • Tentin järjestäjä antaa ennen tenttiä tarkentavat ohjeet mahdollisista aineistoista, materiaaleista ja välineistä, joita tentin suorittamiseen saa käyttää. (Katso seuraavassa alaluvussa olevat kurssin tenttisäännöt.)
  • Verkkotentin suorittamisen aikana saa tarvittaessa käyttää Jyväskylän yliopiston VPN-yhteyttä, mutta ei muita VPN-yhteyksiä.
  • Jos yhteytesi verkkotenttijärjestelmään katkeaa tentin suorittamisen aikana, ilmoita siitä tentin jälkeen tentin järjestäjälle.

Verkkotentin valvonta ja henkilötietojen käsittely

  • Yliopisto valvoo verkkotenttien suorittamista käytettävissä olevin keinoin. Yliopisto voi käyttää valvonnassa ensisijaisesti tietojärjestelmien keräämiä lokitietoja tai live-kuvaa ja toissijaisesti video- ja äänitallenteita.
  • Yliopisto voi tarkistaa opiskelijan henkilöllisyyden tentin aikana.
  • Arkaluonteisia henkilötietoja ei käsitellä verkkotenttien valvonnan yhteydessä.
  • Henkilötietojen käsittelyssä noudatetaan yliopiston tietosuoja- ja tietoturvaohjeita.

Verkkotenttiohjeiden noudattaminen ja vilppi verkkotentissä

  • Opiskelijalla on ehdoton velvollisuus perehtyä ennen tenttiä annettuihin ohjeisiin ja noudattaa niitä yksityiskohtaisesti.
  • Tenttiin osallistuva opiskelija tiedostaa, että Jyväskylän yliopisto valvoo verkkotentin suorittamista ja verkkotenttiohjeiden noudattamista käytettävissä olevin keinoin.
  • Tenttivastaukset voidaan opiskelijan suostumuksella käsitellä plagiaatintunnistusohjelmalla.
  • Vilppi ja vilpin yritys tentissä on kielletty. Vilppiepäilytapauksissa noudatetaan rehtorin päätöstä opiskelun eettisistä ohjeista ja vilppitapausten käsittelystä Jyväskylän yliopistossa. Vilppi johtaa aina opintosuorituksen hylkäämiseen ja voi johtaa kirjalliseen huomautukseen, kirjalliseen varoitukseen tai määräaikaiseen erottamiseen.

2. Tarkentavat ohjeet opintojakson Ohjelmointi 2 (TIEP111) -tenttiin

Lisäohjeet salitenttiin

  • Tentti suoritetaan paikan päällä valvotussa luentosalissa Jyväskylän yliopiston Agora-rakennuksessa.
  • Tentti suoritetaan ensisijaisesti omalla henkilökohtaisella laitteella. Ilmoita ilmoittautumisen yhteydessä, mikäli sinulla ei ole omaa tietokonetta, niin järjestämme sinulle lainakoneen tenttipäiväksi.
  • Tentti alkaa salissa, kun valvoja antaa luvan aloittaa tentin.
  • Tenttisuorituksen päätteeksi opiskelijan on todistettava henkilöllisyytensä ennen salista poistumista.

Lisäohjeet etätenttiin

  • Tentti suoritetaan etävalvotusti Zoom-ohjelman välityksellä.
  • Tentin aikana opiskelijan tulee jakaa kaikkien näyttöjen ruutukuvat sekä videokuvan itsestään. Tarvittavan laitteiston hankinta on opiskelijan vastuulla. Videokamerana on sallittua käyttää oman puhelimen kameraa.
  • Tentti alkaa, kun valvoja antaa luvan aloittaa tentin.
  • Valvojalla on oikeus pyytää näyttämään videokuvaa ympäristöstä tai pyytää laittamaan mikrofonin päälle.
  • Valvojalla on oikeus pyytää suorittamaan toimintoja tietokoneella, kuten avaamaan tehtävienhallintaa.
  • Valvojalla on oikeus pyytää opiskelijaa todistamaan henkilöllisyytensä tentin aikana.
  • Zoom-puhelusta saa poistua vain valvojan luvalla.

Tentissä sallitut aineistot ja materiaalit

  • Tenttivastausten antamiseen saa käyttää vain tietokonetta. Mobiililaitteiden käyttö on kielletty.
  • Tentin aikana saa käyttää tekstiin ja videoon perustuvia verkkosivuja, mukaan lukien opintojakson materiaaleja, harjoitustehtäviä, ohje- ja dokumentaatiosivuja.
  • Tentin aikana ainoa sallittu hakukone on DuckDuckGo, jonka tekoälyominaisuudet tulee kytkeä pois päältä.
  • Tentin aikana saa käyttää Rideria, Visual Studiota, tai vastaavaa kehitysympäristöä.
  • Tentin aikana saa käyttää Visual Studio Codea, Sublime Textia, tai vastaavaa tekstieditoria.
  • Tentin aikana saa käyttää itse tehtyjä sähköisiä tai paperille kirjoitettuja muistiinpanoja.

Tentissä kielletyt välineet ja menetelmät

  • ChatGPT:n, Geminin, Copilotin tai vastaavien generatiivisten tekoälyteknologioiden käyttö on kielletty. Kielto koskee myös kehitysympäristöjen tekoälylisäkkeitä/-avustimia jne.
  • Tentin aikana ainoa sallittu hakukone on DuckDuckGo, jonka tekoälyominaisuudet tulee kytkeä pois päältä. Muiden hakukoneiden (kuten Google, Bing tai Yahoo) käyttö on kielletty, koska niiden tekoälyominaisuuksia ei voi tällä hetkellä kytkeä pois päältä.
  • Verkkotentti on yksilökoe. Kommunikointi muiden kanssa verkkotentin suorittamisen aikana on ehdottomasti kielletty.

Henkilötietojen käsittely tentissä

  • TIM-järjestelmän keräämiä toimintalokeja voidaan käyttää vilppiepäilytapausten tutkimiseen. Järjestelmän tiedot kerätään ja käsitellään järjestelmän oman tietosuojaselosteen mukaisesti.
  • Live-kuvaa, video- tai äänitallenteita ei tallenneta.

Muita ohjeita

  • Tallenna vastauksesi ennen palautusajan päättymistä, mielellään muutamia minuutteja ennakkoon. Mikäli et ole tallentanut vastaustasi ennen palautusajan päättymistä, menetät viimeisimmät muutoksesi.
  • Vastausten lukumäärää ei ole tentin aikana rajoitettu. Viimeisin tallennettu vastaus arvioidaan. Voit vielä tarkistaa tentin lopussa olevalla painikkeella, että vastasit kaikkiin kysymyksiin.
  • Tentin arvioinnista, arvosanoista ja hyvityspisteistä on kerrottu tarkemmin kurssin suoritusohjeissa.

Luennot

Keväällä 2026 luennot järjestetään paikan päällä Agoralla. Luennot samanaikaisesti striimataan YouTube-palveluun. Luentonauhoitteet julkaistaan YouTubessa ja Moniviestimessä.

LuentoPäivämääräSijaintiStriimi ja nauhoiteMateriaalit
Luento 1: Java-kielen perusteetma 12.1.2026 klo 10.15Ag Auditorio 3YouTube, MoniviestinKalvot
Luento 2: Olio-ohjelmoinnin perusteetma 19.1.2026 klo 14.15Ag Auditorio 3YouTube, MoniviestinKalvot, Koodit
Luento 3: Perintä, polymorfismima 26.1.2026 klo 14.15Ag Auditorio 3YouTube, MoniviestinKalvot, Koodit
Luento 4: Rajapinta, geneeriset luokatma 2.2.2026 klo 14.15Ag Auditorio 3YouTube, MoniviestinKalvot, Koodit
Luento 5: Tietorakenteita ja algoritmejavain nauhoiteYouTube, MoniviestinKalvot, Koodit
Luento 6: Hyödyllisiä menetelmiä Javassama 16.2.2026 klo 14.15Ag Auditorio 3YouTube (osa 1), Moniviestin (osa 1), YouTube (osa 2), Moniviestin (osa 2)Koodit
Luento 7: JavaFX, osa 1ma 23.2.2026 klo 14.15Ag Auditorio 3YouTube (osa 1), Moniviestin (osa 1), YouTube (osa 2), Moniviestin (osa 2)
Luento 8: JavaFX, osa 2ma 2.3.2026 klo 14.15Ag Auditorio 3YouTube (T8.1-T8.2), Moniviestin (T8.1-T8.2), YouTube (T8.2-T8.8), Moniviestin (T8.2-T8.8)
Luento 9: Harjoitustyö, vaihe 1ma 9.3.2026 klo 14.15Ag Auditorio 3YouTube, Moniviestin

Kyselytunnit

Kyselytunti järjestetään joka viikko luentojen jälkeen klo 16.00–17.00 alkaen maanantaista 19.1.2026. Kyselytunteja pidetään viimeiseen luentoon asti.

Kyselytunnilla vastuuopettajat päivystävät luentosalissa ja opintojakson Teams-kanavalla ja vastaavat opiskelijoiden kysymyksiin. Tilaisuus on luonteeltaan vapaamuotoinen, eli ennalta määrättyä ohjelmaa ei ole, eikä tilaisuutta nauhoiteta. Voit tulla paikalle kysyäksesi mieltäsi askarruttavista asioista liittyen luentoihin, harjoitustehtäviin tai harjoitustyöhön. Kyselytunti ei ole kuitenkaan tarkoitettu harjoitustyön hyväksymiseen tai harjoitustehtävien pistekorjauksiin. Näissä kysymyksissä ota yhteyttä opettajiin sähköpostitse.

Java-kielen perusteet

osaamistavoitteet

  • Tutustut Java-kielen perussyntaksiin, muuttujiin, ja ohjausrakenteisiin.
  • Opit kirjoittamaan yksinkertaisia Java-ohjelmia ja ymmärrät miten koodia käännetään ja suoritetaan.

Materiaalin käytöstä

Materiaali on jaettu 12 osaan, jotka sisältävät tekstiä, esimerkkejä ja tehtäviä.

Yläpalkista löydät seuraavat toiminnot:

  • Sisällysluettelo: Avaa ja sulkee vasemman laidan sisällysluettelon.
  • Haku (tai paina s-painiketta): Etsi sisältöä koko materiaalista hakusanalla.
  • Teema: Vaihda ulkoasua (vaalea, tumma, automaattinen).
  • Tulosta: Tulosta koko materiaali.

Koodiesimerkit

Ajettavissa koodiesimerkeissä on käytössä seuraavat painikkeet:

  • Aja koodi: Suorittaa koodiesimerkin ja näyttää tulosteen.
  • Näytä koko koodi: Paljastaa piilotetut rivit, jotka eivät ole esimerkin ymmärtämisen kannalta keskeisiä, mutta vaaditaan koodin suorittamiseen.

Jotkin koodiesimerkit saattavat olla muokattavia. Voit tarvittaessa palauttaa esimerkin alkuperäiseen tilaan Peruuta muutokset-painikkeella ().

Tehtävät

Jokainen osa sisältää joukon harjoitustehtäviä. Löydät kuhunkin alalukuun liittyvät tehtävät alaluvun lopusta. Löydät kaikki osan tehtävät koostettuna myös "Osan kaikki tehtävät" -alaluvusta.

Tehtävät palautetaan TIM-järjestelmään. Linkki kunkin tehtävän palautuslaatikkoon löytyy aina tehtävänannon yhteydessä. Voit seurata edistymistäsi kurssin TIM-kotisivulla. Kirjaudu sisään TIM-järjestelmään Haka-kirjautumisella käyttäen yliopistotunnuksesi. Ohjeet Haka-kirjautumiselle löytyvät TIM-ohjeista.

Hei, Java!

osaamistavoitteet

  • Tutustut Java-kielen perusteisiin
  • Tiedät, miten Java-ohjelma käännetään ja ajetaan
  • Tiedät, mikä on (J)VM ja miten kääntäminen eroaa tulkkauksesta
  • Tunnet Java-kielen vastineita yleisimmille I/O-operaatioille

Ohjelmointi 2 -kurssilla käytämme Java-ohjelmointikieltä. Java on yleiskäyttöinen olio-ohjelmointia tukeva kieli, joka on tarkoitettu alustasta riippumattomien ohjelmien kirjoittamiseen. Java on suosittujen ohjelmointikielten kärkilistoilla (ks. esim. TIOBE index, StackOverflow 2025 developer survey, suosituimmat kielet GitHub-palvelussa). Javan syntaksi muistuttaa paljon Ohjelmointi 1 -kurssilla käytettyä C#-kieltä.

Java-kielen perusteet

Lähdetään liikkeelle perinteisellä 'Hei, maailma' -esimerkillä Javalla:

/* 1 */ void main() {
/* 2 */     IO.println("Hei, maailma!");
/* 3 */ }

Käydään läpi ohjelma rivi riviltä:

  1. Java-ohjelman suoritus alkaa main-nimisestä aliohjelmasta. void tarkoittaa, että aliohjelma ei palauta mitään arvoja. Koska pääohjelma ei ota parametreja, main-sanan perässä olevat kaarisulkeet voidaan jättää tyhjäksi. Aliohjelman runko alkaa avaavalla aaltosululla {.

  2. Tekstin tulostaminen komentorivi-ikkunaan onnistuu IO.println-metodilla. Javassa lause loppuu yleensä puolipisteeseen ;, kuten tässäkin.

  3. Aliohjelman runko lopetetaan aaltosululla }. Ohjelman suoritus päättyy automaattisesti, kun main-aliohjelma on suoritettu loppuun.

Vaikka ohjelma on hyvin yksinkertainen, se on silti aivan toimiva Java-ohjelma. Opintojakson aikana teemme paljon juuri komentorivi-ikkunaan kirjoittavia ja sieltä lukevia ohjelmia. Opintojakson loppupuoliskolla painopiste siirtyy graafisten käyttöliittymien parissa työskentelyyn.

Javan koodauskäytänteistä

Monesti ohjelmointikielessä on joukko kyseiseen kieleen vakiintuneita koodauskäytänteitä. Näin on myös Javassa. Tutustumme erilaisiin käytänteisiin tämän materiaalin edetessä. Mainittakoon, että Javan koodauskäytänteet poikkeavat hieman C#-kielen käytänteistä muun muassa nimeämisessä ja koodin muotoilussa.

Tässä olennaisimmat Javan koodauskäytänteet, joita on tässä vaiheessa hyvä pitää mielessä:

  • Aliohjelman runkoa aloittava aaltosulku { laitetaan yleensä samalle riville kuin aliohjelman määrittely. Sama pätee muihin rakenteisiin, joissa käytetään aaltosulkuja, kuten if-, for-, while- ja do-while-rakenteille.

  • Muuttujien ja aliohjelmien nimeämisessä käytetään camelCasing-tyyliä, eli ensimmäinen kirjain on pienaakkonen ja seuraavat sanat aloitetaan suuraakkosella. Esimerkiksi tamaOnFunktionNimi.

  • Tiedostojen ja luokkien nimeämisessä käytetään PascalCasing-tyyliä, eli ensimmäinen kirjain on suuraakkonen ja seuraavat sanat aloitetaan isolla kirjaimella: HeiMaailma.java, public class Opiskelija, jne. Samoin monissa muissa myöhemmin opittavissa rakenteissa, kuten rajapinnoissa ja enumeraatioissa käytetään PascalCasing-tyyliä.

Opas: Java-ohjelmien kääntäminen ja ajaminen

tärkeää

Tässä osiossa tarvitset opintojakson työkaluja. Käy ensin asentamassa kaikki työkalut Työkaluohjeesta.

Vaikka monet yllä olevan tapaiset pienet ohjelmat voidaan periaatteessa tehdä nettiselaimessa, on ohjelmia kehittäessä varsin käytännöllistä käyttää erillistä kehitysympäristöä. Tässä materiaalissa käytämme IntelliJ IDEA -kehitysympäristöä Java-ohjelmien luomiseen, ajamiseen ja virheenjäljitykseen.

Luo uusi projekti

Luodaan seuraavaksi yksinkertainen projekti IDEAssa. Projekti on IDEA-kehitysympäristön tapa koostaa lähdekooditiedostoja, testejä, kirjastoja ja muita lisätiedostoja yhteen kokonaisuuteen.

Tee seuraavasti:

  1. Avaa IntelliJ IDEA ja avaa uuden projektin dialogi.

    Jos sinulle avautui Welcome to IntelliJ IDEA, valitse New Project.

    Jos sinulle avautui jokin valmis Java-projekti, valitse yläpalkissa File New Project.

    Yläpalkin valinnat saattavat olla hampurilaisvalikkopainikkeen () takana.

  2. Uuden projektin dialogissa aseta seuraavat tiedot:

    • Valitse vasemmalla puolella olevasta listasta projektityypiksi Empty Project.

    • Aseta projektin nimeksi Name-kenttään HelloWorld. Projektien nimet kirjoitetaan yleensä ilman välilyöntejä.

    • Aseta projektin sijainti Location-kenttään. Klikkaa kentän oikealla puolella olevaa kansiokuvaketta () ja valitse projektille sopiva kansio. Valitse sellainen kansio, jonka löydät tulevaisuudessakin helposti omalta tietokoneelta.

    • Laita ruksi Create Git repository pois päältä. Emme tarvitse versiohallintaa vielä tässä vaiheessa.

    Yllä olevien valintojen jälkeen tuloksen pitäisi näyttää seuraavalta (Location-kenttä voi olla erilainen riippuen käyttöjärjestelmästäsi):

  3. Paina Create. Tämän jälkeen IDEA luo uuden projektin valitsemaasi kansioon.

    Käydään pikaisesti läpi IDEAn olennaisimmat osat:

    1. Koodialue: projektissa olevien tiedostojen sisällöt näkyvät tässä, kun ne avataan. Kukin avattu tiedosto avautuu omaan välilehteen.
    2. Projektiselain: projektissa olevat kansiot ja tiedostot näkyvät tässä. Selaimen kautta voidaan lisätä, poistaa, siirtää tai uudelleennimetä tiedostoja ja kansioita.
    3. Projektin ajaminen ja debuggaus: tässä näkyy ajettavan Java-ohjelman nimi, ohjelman ajopainike () ja debuggauspainike ().
    4. Valikot ja näkymät: IDEAssa on erilaisia näkymiä, jotka ovat oletuksella piilossa. Sivupalkin avulla voidaan avata ja piilottaa näkymät tarpeen mukaan. Esimerkiksi projektiselaimen voi piilottaa painamalla sivupalkissa olevasta kansiokuvakkeesta ().

Luo Java-moduuli

Oletuksella uusi projekti on tyhjä. IDEA:ssa ohjelmakoodi kirjoitetaan moduuleihin (engl. module). Samaan projektiin voi lisätä useita moduuleita, joita voi kehittää ja ajaa erikseen. Esimerkiksi, voit näin tehdä yhden projektin tietyn viikon tehtäville (esim. Viikko1 tai Viikon1Tehtavat) ja lisätä kunkin tehtävän omana moduulina.

Luodaan seuraavaksi alimoduuli nimeltään HelloProgram, lisätään siihen uusi ohjelma ja kokeillaan ajaa se.

Tee seuraavasti:

  1. Klikkaa hiiren toissijaisella painikkeella projektin nimeä projektinäkymässä (HelloWorld) ja valitse New Module.

  2. Avautuvassa New Module -dialogissa aseta seuraavat tiedot:

    • Valitse vasemmalla puolella olevasta listasta projektityypiksi Java.

    • Aseta projektin nimeksi Name-kenttään HelloProgram. Projektien nimet kirjoitetaan yleensä ilman välilyöntejä.

    • Jätä Location-kenttä sellaisenaan. IDEA automaattisesti valitsee moduulille oikean sijainnin.

    • Valitse Build system-rivillä IntelliJ. Tutustumme muihin projektien rakennusjärjestelmiin myöhemmissä osissa.

    • Varmista, että JDK-kentässä on sama JDK-versio kuin minkä olet asentanut Työkaluohjeissa.

    • Laita ruksi Add sample code pois päältä. Lisäämme kooditiedoston itse.

    Yllä olevien valintojen jälkeen tuloksen pitäisi näyttää seuraavalta (Location-kenttä voi olla erilainen riippuen käyttöjärjestelmästäsi):

    Paina lopuksi Create. Tämä luo uuden HelloProgram-kansion, joka sisältää src-kansion.

Luo lähdekooditiedosto

IntelliJ-projektissa moduuliin kuuluva koodi laitetaan src-kansioon. Jos projektissa on useampi moduuli, jokaisella moduulilla on oma src-kansio.

Luodaan seuraavaksi yksinkertainen Java-lähdekooditiedosto, johon voidaan kirjoittaa koodi.

  1. Klikkaa projektiselaimessa olevaa HelloProgram-moduulin alasvetopainiketta. Sen jälkeen klikkaa toissijaisella hiiren painikkeella src-kansiosta ja valitse New Java Compact File.

  2. Anna lähdekooditiedoston nimeksi Ohjelma ja paina Enter.

IDEA luo uuden Ohjelma.java-nimisen tiedoston src-kansioon. IDEA myös lisää automaattisesti main-aliohjelman määrittelyn lähdekooditiedostoon. Samalla IDEA avaa lähdekooditiedoston koodialueelle. Voit jatkossa avata tiedoston myös tuplaklikkaamalla sitä.

Kirjoita ohjelma

Kirjoitetaan seuraavaksi yksinkertainen "Hei, maailma"-ohjelma alusta alkaen juuri luotuun Ohjelma.java-tiedostoon.

Tee seuraavasti:

  1. Poista kaikki koodi Ohjelma.java-tiedostosta.

    IDEA lisää yleensä valmista pohjakoodia uusiin lähdekooditiedostoihin. Tätä harjoitusta varten kirjoitamme kuitenkin koodia itse.

  2. Kirjoita seuraava koodi Ohjelma.java -tiedostoon:

    void main() {
        IO.println("Hei, maailma!");
    }
    

    Vältä kopioimasta koodia, vaan kirjoita se itse. Kirjoittaminen itse usein auttaa muistamaan, mistä eri ohjelmoinnissa käytettävät merkit, kuten aaltosulut, kaarisulut ja puolipiste löytyvät.

Bonus: IDEAn täydennysominaisuuksien käyttäminen

IDEA tarjoaa erilaisia aikaa säästäviä täydennysominaisuuksia, joiden käyttöä on hyvä harjoitella.

Kokeile ainakin seuraavia ominaisuuksia.

  • main-pääohjelman automaattinen lisääminen: Aloita kirjoittamalla main. Paina sen jälkeen Ctrl+Space (macOS: +Space). Valitse nuolinäppäimillä main-pohja ja paina Enter:

    IDEA sisältää erilaisia valmiita pohjia, jotka nopeuttavat koodin kirjoittamista ja helpottavat yleisempien rakenteiden muistamista. Näet kaikki koodipohjat painamalla Ctrl+J (macOS: +J).

  • println-aliohjelman automaattinen täydentäminen: Siirrä kursori main-pääohjelmaan tyhjälle riville.

Kirjoita alkuun kirjain I, minkä jälkeen IDEA automaattisesti näyttää kaikki kursorin kohdalle sopivat rakenteet, jotka alkavat kirjaimella I. Valitse nuolipainikkeilla IO ja paina Enter. Tämä täydentää IO kursorin kohdalle.

Kirjoita sen jälkeen . (piste), minkä jälkeen IDEA automaattisesti näyttää kaikki IO-luokassa olevat aliohjelmat. Siirry listassa nuolipainikkeilla println-aliohjelman kohdalle ja paina Enter. Tämä täydentää println-tekstin kursorin kohdalle.

Kirjoita sen jälkeen kaarisulku (. IDEA automaattisesti täydentää lopettavan kaarisulun ). Siirry nuolipainikkeilla kaarisulkujen väliin ja kirjoita "Hei, maailma!". Lopuksi siirry rivin loppuun painamalla End tai nuolinäppäimiä käyttäen ja lisää loppuun puolipiste ;.

IDEA osaa automaattisesti siis ehdottaa luokkien ja aliohjelmien nimiä kontekstin perusteella. Voit myös aina erikseen avata automaattisen täydennyksen painamalla

Ohjelman ajaminen

Kooditiedostoja, jotka sisältävät main-aliohjelman, voidaan suorittaa. Suorittaminen onnistuu ajopainikkeella (), joka sijaitsee main-aliohjelman vieressä sekä IDEA:n yläpalkissa.

Tee seuraavasti:

  1. Klikkaa main-aliohjelman vasemmalla puolella olevaa ajopainiketta ().

    IDEA ensin kääntää ohjelmasi. Kun ohjelma on käännetty, IDEA ajaa ohjelmasi, ja editorin alapuolelle avautuu Run-ikkuna, jossa näkyy tekstiä. Ensimmäinen rivi on se komento, jota IDEA käytti käännetyn tiedoston ajamiseksi. Seuraavalla rivillä on oman ohjelmamme tuottama tuloste Hei, maailma!. Viimeinen rivi kertoo, että ohjelman suoritus päättyi ilman virheitä.

  2. Kokeile vielä ohjelman ajamista luodulla ajokonfiguraatiolla.

    Kun ajat kooditiedoston ensimmäistä kertaa, IDEA luo ajokonfiguraation. Ajokonfiguraatio on pieni tiedosto, johon tallentuu koodin suorittamiseen liittyviä asetuksia, kuten käytettävä JDK-versio, mahdolliset komentoriviparametrit ja työhakemisto. Oletusarvoisesti tämä tiedosto syntyy projektin juurikansioon .idea workspace.xml.

    Kun ajokonfiguraatio on luotu ensimmäisen ajon jälkeen, voit jatkossa ajaa koodin aina IDEA:n yläpalkissa olevalla ajopainikkeella. Tällä tavoin voit helposti ajaa ohjelmia ilman, että kooditiedostoa tarvitsee erikseen avata.

    IDEAn yläpalkissa pitäisi nyt näkyä Ohjelma-ajokonfiguraation nimi, jonka vieressä on ajopainike (). Kokeile sulkea Ohjelma.java ja suorittaa ohjelma yläpalkin kautta.

    Ajokonfiguraatioiden avulla voit ajaa eri moduuleissa kirjoitettuja ohjelmia. Myöhemmin materiaalissa tutustumme lisäksi Gradle-hallintatyökaluun, jonka avulla teemme muun muassa erillisiä ajokonfiguraatioita projektin ajamiselle, testaamiselle ja kääntämiselle.

vinkki

Tutustu yleisimpiin pikanäppäinkomentoihin

Näppäinkomennot nopeuttavat kehitysympäristön käyttöä, ja pienellä harjoittelulla ohjelmointi voi sujua kokonaan hiirtä käyttämättä. Näppäinkomennot riippuvat käyttöjärjestelmästä ja valituista näppäinasetuksista. IDEA kuitenkin näyttää näppäinkomennot valikoissa sekä vihjeteksteissä, mikä helpottaa komentojen oppimista.

Voit myös muokata näppäinkomentoja asetuksista kohdassa File Settings Keymap. Voit myös ladata muiden kehitysympäristöjen, kuten Visual Studio Coden, näppäinasetuksia laajennoskaupasta kohdassa File Plugins.

Kääntäminen ja ajaminen komentoriviltä

Ennen kuin IDEA varsinaisesti ajaa ohjelman, se käännetään ajettavaan muotoon. IDEAssa tämä tapahtuu taustalla automaattisesti klikkaamalla ajopainiketta, debuggauspainiketta tai Build-valikon kautta. Java-lähdekoodin voi kuitenkin tarvittaessa myös kääntää ja ajaa itse komentoriviltä. On hyvä tietää, miten tämä tapahtuu, jotta ymmärrät paremmin, mitä IDEA taustalla tekee.

Tutkitaan nyt, miten ohjelma käännetään ja ajetaan komentoriviltä.

Miten voin seurata mukana?

Tee alkuun yksinkertainen ohjelma yllä olevan oppaan mukaisesti.

Sen jälkeen avaa IDEA:n vasemmasta näkymäpalkista komentorivi painamalla komentorivipainikkeesta (). Tämä avaa käyttöjärjestelmän komentorivin (zsh macOS:lla, PowerShell Windowsilla, oletuskomentorivi Linuxilla).

Jos latasit Java-kehitysympäristön seuraamalla työkaluohjeita, komentorivi ei löydä mitään Javan kääntämiseen tarkoitettuja työkaluja. Ota työkalut käyttöön kopioimalla ja liittämällä alla oleva komento:

Valitse käyttöjärjestelmäsi yllä olevista vaihtoehdoista.

Yllä oleva komento mahdollistaa JDK:n työkalujen käyttöä komentoriviltä väliaikaisesti. Komentorivi palautuu ennalleen, kun suljet ja avaat komentorivin uudelleen.

Avataan nyt komentorivi ja siirrytään alkuun projektikansioon. Tarkastellaan vielä, mitä tiedostoja projektista löytyy:

Koska käytössämme on IntelliJ-projekti, sieltä löytyy vain muutama olennainen tiedosto ja kansio:

  • HelloWorld.iml on projektin asetustiedosto, jolla IDEA tunnistaa kansion olevan Java-projekti
  • HelloProgram on lähdekoodikansio, jossa kaikki lähdekooditiedostot sijaitsevat
  • out on kansio, joka sisältää käännetyt ohjelmat

Siirrytään nyt kansioon HelloProgram ja tarkastellaan sen sisältö:

Yksittäisestä moduulista löytyvät vastaavasti seuraavat tiedostot ja kansiot:

  • HelloProgram.iml on moduulin asetustiedosto, jolla IDEA tunnistaa kansion olevan Java-moduuli
  • src on lähdekoodikansio, joka sisältää ohjelman lähdekoodin

Siirrytään vastaavasti kansioon src ja tarkastellaan sen sisältö:

.java-tiedostopäätettä käytettävät tiedostot ovat Javan lähdekooditiedostoja. Ne sisältävät ohjelman lähdekoodia tekstinä eivätkä ne ole vielä suoraan ajettavissa.

Jotta ohjelma voidaan ajaa, se pitää kääntää. Kuten mainitsimme, IDEA tekee tämän automaattisesti kun käynnistämme tekemämme ohjelman, mutta lähdekoodin kääntäminen onnistuu myös komentoriviltä käyttäen Java-kehitysympäristön mukana tullutta javac-kääntäjäohjelmaa. Kokeillaan kääntää Ohjelma.java:

Jos kääntäminen onnistui, javac-komento ei tulosta oletuksena mitään. Tutkitaan vielä kansion rakenne ls-komennolla:

Kääntämisen seurauksena syntyy .class-päätteinen tiedosto. Tämä tiedosto sisältää niin sanottua tavukoodia (engl. bytecode), joka on tiedoston käännetty muoto. Tavukoodi ei ole suoraan prosessorilla ajettava ohjelma, vaan eräänlainen välivaihe. Tavukoodia voidaan kuitenkin suorittaa Javan virtuaalikoneella (JVM, Java Virtual Machine), joka on erillinen ohjelma, joka osaa tulkita ja suorittaa tavukoodia. Vaikka tämä voi kuulostaa turhan monimutkaiselta, hyöty on siinä, että ohjelma joka on käännetty Java-tavukoodiksi voidaan nyt ajaa eri alustoilla (Windows, macOS, Linux, jne.), kunhan JVM on toteutettu kyseisellä alustalla. JVM voi puolestaan optimoida tavukoodia juuri alustalle ja prosessorille sopivaan muotoon. Javalla onkin iskulause: "Write Once, Run Anywhere", jolla viitataan tähän periaatteeseen.

JDK:n kanssa tulee myös valmiiksi Java-virtuaalikone sekä Javan ajoympäristö (JRE, Java Runtime Environment), joka sisältää yleisempiä toimintoja, joita Java-ohjelma saattaa käyttää. Tavukooditiedosto voidaan ajaa JVM:llä käyttäen java-komentoa:

Huomaa, että java-komentoa antaessa kirjoitetaan tavukooditiedoston nimi ilman .class-päätettä. Myöhemmin materiaalissa tutustumme Gradle-projektinhallintaohjelmaan, jolla pystyy kääntämään useita lähdekooditiedostoja yhteen .jar-tiedostoon, johon voidaan pakata kaikki ohjelman ajamiseen tarvittavat tiedostot. Myös .jar-tiedostot voidaan suorittaa java-komennolla.

vinkki

Alkaen Javan versiosta 11 java-komento osaa myös automaattisesti kääntää ja suorittaa .java-lähdekooditiedostot ilman erillistä javac-kääntäjän ajamista.

Lisäksi tässä materiaalissa käytämme pääosin IDEA-kehitysympäristöä, joka hoitaa lähdekooditiedostojen kääntämisen automaattisesti ja tehokkaasti.

Bonus: jshell-tulkkiohjelma

Vaikka Java lasketaan käännettäväksi kieleksi, toisinaan voi olla hyödyllistä kokeilla Java-ohjelmien kirjoittamista interaktiivisesti ilman jatkuvaa kääntämistä. Interaktiivisuus tässä tarkoittaa, että voit kokeilla eri komentoja rivi/lohko kerrallaan ilman erillistä kääntämistä ja ajamista, luokkia tai main-pääohjelmaa.

Tätä varten JDK sisältää jshell-ohjelman, joka on Java-ohjelman komentorivitulkki eli ns. REPL-tulkki (read-evaluate-print-loop).

jshell tarjoaa useita hyödyllisiä toimintoja, kuten:

  • Luokkien ja aliohjelmien nimien täydennys ja haku Tab-painikkeella
  • Lausekkeiden suorittaminen ilman tarvetta main-aliohjelmalle

jshell-ohjelmasta voi poistua /exit-komennolla.

Tekstin tulostaminen ja syötteen lukeminen komentorivi-ikkunassa

Jatkossa voi olla hyödyllistä tulostaa erilaisia asioita komentorivin avulla ja toisaalta lukea tietoa sieltä. Javan IO-luokka tarjoaa kolme perustoimintoa tekstin tulostamiseen ja lukemiseen komentorivillä:

AliohjelmaEsimerkkiSelitys
printlnIO.println("Moi!"); Tulostaa parametrina annetun arvon ja lisää loppuun rivinvaihdon
IO.println(); Tulostaa rivin rivinvaihdolla
printIO.print("Samalla rivillä!"); Tulostaa parametrina annetun arvon ilman rivinvaihtoa
readlnIO.readln(); Lukee syöterivin käyttäjältä (ts. Enterin painallukseen saakka). Jos käyttäjä lopettaa ohjelman antamatta syötettä, palauttaa ns. null-viitteen
IO.readln("Anna sana > "); Sama kuin readln, mutta tulostaa ensin annetun tekstin ennen syötteen lukemista.

Katsotaan vielä näiden yhteistoimintaa. Voit muokata alla olevaa esimerkkiä vapaasti ja kokeilla, miten erilainen tulostus toimii.

void main() {
    String nimi = IO.readln("Anna nimesi: > ");
    IO.println();
    IO.println("Moi, " + nimi + "!");

    IO.print("Tämä teksti");
    IO.print(" menee samalle");
    IO.print(" riville");
    
    IO.println(); // Kokeile ottaa tämä pois ja katso, mitä tapahtuu

    IO.println("Tervetuloa Ohjelmointi 2 -kurssille!");
}

Muuttujat ja tietotyypit

osaamistavoitteet

  • Muistat, mitä ovat muuttujat ja vakiot
  • Muistat, mitä ovat merkkijonot ja listat
  • Tunnet Javan alkeistietotyypit
  • Tunnet, miten merkkijonoja, taulukoita ja listoja käytetään Javassa

Ohjelmat käsittelevät muistiin tallennettua tietoa. Korkean tason kielissä, kuten Javassa käytetään selkokielisiä nimiä viitattaessa muistiin tallennettuun tietoon. Tällaista nimeä, joka viittaa muistissa olevaan tietoon, kutsutaan muuttujaksi (engl. variable). Ohjelmoijan tarvitsee muistaa vain nimi; käyttöjärjestelmä ja tietokoneen sisäinen logiikka huolehtivat tiedon todellisesta sijainnista muistissa.

Ennen käyttämistä muuttuja tulee määritellä.

tyyppi muuttujanNimi;

Muuttujan tyyppi määritellään muuttujan nimen edessä, ja se kertoo, millaista tietoa muuttuja voi sisältää, esimerkiksi kokonaisluvun, desimaaliluvun tai totuusarvon. Muuttujan nimi on ohjelmoijan valitsema tunniste, jonka avulla muuttujaan viitataan. Nimi voi sisältää kirjaimia ja alaviivoja. Muuttujan nimi ei kuitenkaan voi olla Java-kielessä varattu avainsana eikä muuttujan nimi saa alkaa numerolla.

Muuttujan määrittelyn jälkeen muuttujaan voi sijoittaa lausekkeiden arvoja:

muuttujanNimi = lauseke;

Yhtäsuuruusmerkin oikealla puolella olevan lausekkeen (engl. expression) arvo tallennetaan sen vasemmalla puolella nimettyyn muuttujaan. Jos muuttujissa oli aiemmin jotain muita arvoja, ne korvataan uusilla.

void main() {
double korkokerroin; // Muuttujan määrittely, double = desimaaliluku
double paaoma; // Muuttujan määrittely

korkokerroin = 0.05; // Arvon sijoitus muuttujaan
paaoma = 150.0; // Arvon sijoitus muuttujaan
 IO.println("korkokerroin = " + korkokerroin);
 IO.println("paaoma = " + korkokerroin);
 }

Muuttujaa ei voi käyttää ennen kuin siihen sijoittaa arvon ainakin kerran. Tätä varten voi käyttää yhdistettyä määrittely- ja sijoituslausetta, joka yhdistää muuttujan määrittelyn ja alkuarvon sijoittamisen samalle riville:

double paaoma = 0.05; 
// Yllä oleva on sama kuin:
double paaoma;
paaoma = 0.05;

Muuttuja voi olla myös lausekkeen osana, ja siten sen arvoa voidaan käyttää osana sijoitettavaa lauseketta.

void main() {
double korkokerroin = 0.05;
double paaoma = 150.0;

double paaomaKorolla = (1 + korkokerroin) * paaoma;
 IO.println("korkokerroin = " + korkokerroin);
 IO.println("paaoma = " + korkokerroin);
 IO.println("paaomaKorolla = " + paaomaKorolla);
}

Ohjelmoinnissa sijoitus on lause, eli yksittäinen suoritettava käsky. Esimerkiksi lausetta double paaoma = 150.0; voi ajatella tarkoittavan: "tallenna luku 150.0 muistiin paikkaan, jota kutsutaan tästä eteenpäin nimellä paaoma". Muuttujan arvo pysyy samana kunnes jokin toinen lause muokkaa muuttujan arvoa.

Javan tietotyypit voidaan jakaa kahteen pääryhmään: alkeistietotyyppeihin (engl. primitive data types) ja viitetietotyyppeihin (engl. reference data types). Kaikki tieto tallennetaan tietokoneen muistiin binäärilukuina (nollien ja ykkösten sarjana), ja tietotyypit eroavat toisistaan siinä, kuinka paljon muistia ne varaavat, millaista dataa ne esittävät ja millä säännöillä dataa voi käsitellä. Alkeistietotyypit sisältävät yksinkertaisia arvoja, kuten kokonaislukuja ja totuusarvoja, kun taas viitetietotyypit sisältävät monimutkaisempia rakenteita, kuten olioita, taulukoita ja merkkijonoja.

Alkeistietotyypit

Javassa on kahdeksan sisäänrakennettua alkeistietotyyppiä, joita voi karkeasti jakaa neljään kategoriaan: kokonaisluvut, liukuluvut, merkit ja totuusarvot.

Kokonaisluvut

Kokonaisluvuille on olemassa neljä tyyppiä, jotka eroavat toisistaan lukualueen ja muistinkulutuksen perusteella. Yleisimmin käytetty kokonaislukutyyppi on int.

TyyppiKoko (tavua /bittiä)Lukualue (suuntaa antava)
byte1 tavu (8 bittiä)-128 ... 127
short2 tavua (16 bittiä)-32 768 ... 32 767
int4 tavua (32 bittiä)n. -2 miljardia ... 2 miljardia
long8 tavua (64 bittiä)n. +/- 9 * 10^18
Huomautus: Muuttujan tyyppi ei vaihdu lennosta

Useissa dynaamisissa ohjelmointikielissä, kuten Pythonissa tai JavaScriptissa, kokonaisluvuille ei välttämättä ole suurinta arvoa: suurille luvuille varataan joko lisää tilaa tai vähemmän merkitseviä numeroita pyöristetään. Tämä ei päde Javassa. Jos laskutoimituksen tuloksena kokonaisluku ylittää muuttujan tyypin lukualueen, luku vuotaa yli (engl. overflow) ja "pyörähtää lukualueen ympäri":

void main() {
int suuriLuku = 2000000000;
IO.println("suuriLuku = " + suuriLuku);

suuriLuku += 1000000000;
IO.println("suuriLuku = " + suuriLuku);
}

Siispä mahdollinen lukualue on otettava huomioon ohjelmaa kirjoittaessa. Jos on mahdollisuus, että laskutoimitus ylittää tyypin lukualueen, on syytä vaihtaa toiseen tietotyyppiin.

Javasta löytyy myös suuria lukuja käsittelevä BigInteger -tyyppi, jota ei tällä opintojaksolla käsitellä.

Liukuluvut

Desimaaliluvuille käytetään liukulukutyyppejä. Yleisin näistä on double.

TyyppiKoko (tavua)Tarkkuus
float4 tavua (32 bittiä)n. 7 merkitsevää numeroa
double8 tavua (64 bittiä)n. 15 merkitsevää numeroa
Huomautus: Liukuluvut ovat epätarkkoja!

Java käyttää liukulukuja desimaalilukujen double ja float esittämiseen. Liukulukuja voidaan ajatella esittävän desimaalilukujen likiarvoja. Liukulukujen tarkka toiminta on standardoitu (IEEE 754 -standardi); vaikka ne on tarkoitettu desimaalilukujen esittämiseen, niillä on silti joitain mielenkiintoisia ja kenties yllättäviä eroja tavallisiin desimaalilukuihin:

void main() {
    // Laskutoimitukset voivat erota desimaaliluvuista pyöristysvirheiden takia
    double pyoristysVirhe = 0.1 + 0.2;
    IO.println("pyoristysVirhe = " + pyoristysVirhe);

    // Jakaminen nollalla on määritelty eikä aiheuta virhettä
    double negatiivinenAarettomyys = -1.0 / 0.0;
    IO.println("negatiivinenAarettomyys = " + negatiivinenAarettomyys);
    double positiivinenAarettomyys = 1.0 / 0.0;
    IO.println("positiivinenAarettomyys = " + positiivinenAarettomyys);

    // Jakolasku 0/0 ei aiheuta virhettä
    double nan = 0.0 / 0.0;
    IO.println("nan = " + nan);
}

Liukuluvuille on siten erikseen määritelty ja NaN = Not A Number. Lisäksi liukulukujen väliset laskutoimitukset voivat sisältää pieniä virheitä johtuen liukulukujen esitystavasta ja pyöristysvirheistä.

Hyvin tarkkoja laskuja vaativille ohjelmille löytyy myös BigDecimal -tyyppi, jota ei tällä opintojaksolla käsitellä.

Merkit

Yksi merkki tallennetaan char-tyyppiseen muuttujaan, joka käyttää 2 tavua muistia.

Totuusarvot

Totuusarvoja varten on boolean-tyyppi, jolla on kaksi mahdollista arvoa: true (tosi) tai false (epätosi).

Viitetietotyypit

Toisin kuin alkeistietotyypit, viitetietotyyppinen muuttuja sisältää varsinaisen tiedon sijaan vain pienen, kiinteän kokoisen arvon eli viitteen (engl. reference). Viitteen avulla ohjelma pääsee käsiksi varsinaiseen dataan,
jonka kokoa tai sisältöä ei välttämättä tiedetä ennen kuin ohjelma ajetaan.

Javassa käytännössä kaikki muut tietotyypit kuin alkeistietotyypit ovat viitetietotyyppejä. Esimerkiksi String on viitetietotyyppi, kuten myös kaikki taulukot ja listat. Alkaen luvusta 2 tutustumme olio-ohjelmointiin; Javassa kaikki oliot ovat viitetietotyypit.

Kaikkiin viitetyyppimuuttujiin on sallittua sijoittaa erikoisarvo null. Tämä niin sanottu null-viite merkitsee, että muuttuja ei sisällä viitettä mihinkään tietoon. Yritys muokata tai lukea muuttujaa, jonka arvo on null, tuottaa yleensä virheen ohjelman ajon aikana:

String teksti = null;
String tekstiIsolla = teksti.toUpperCase();
IO.println(tekstiIsolla);
java.lang.NullPointerException: Cannot invoke "String.toUpperCase()" because "<local1>" is null
	at main.main(main.java:3)

Koska virhe tapahtuu vasta ajon aikana, Javassa on yleensä ohjelmoijan vastuulla varmistaa, että muuttujan tai funktion parametrin arvona ei ole null-viite. Varmistus voidaan tehdä esimerkiksi ehtorakenteella, jotka esitetään seuraavassa alaluvussa.

Valinnaista lisätietoa: Miksi viitetietotyyppejä on olemassa?

On useita syitä sille, miksi nämä kaksi eri kategoriaa tietotyypeille on olemassa.

Ensimmäinen liittyy suorituskykyyn ja muistin hallintaan. Jos kaikki muuttujat olisivat arvopohjaisia (kuten alkeistietotyypit), se aiheuttaisi valtavasti turhaa muistin kulutusta ja hidastaisi ohjelman suorituskykyä, erityisesti suurten tietorakenteiden kohdalla. Jos meillä olisi vaikkapa kirja, joka sisältäisi 1000 sivua tekstiä, niin joka ikinen kerta kun haluamme käsitellä kirja-muuttujaa, meidän pitäisi kopioida kaikki 1000 sivua muistissa. Tämä olisi erittäin tehotonta. Sen sijaan viitetietotyypit mahdollistavat sen, että vain viittaamme kirja-olioon, joka sijaitsee jossakin muualla muistissa, ilman että tarvitsee kopioida koko kirjaa joka kerta.

Toinen syy liittyy jaettuun tilaan. Usein haluamme, että useampi ohjelman osa muokkaa samaa tietoa. Esimerkiksi on järkevää, että pankkitili-olio on jaettu useiden eri toimintojen kesken, kuten talletus, nosto ja tilin saldo. Arvopohjaisessa maailmassa joutuisimme kopioimaan pankkitili-olion joka kerta, kun tililtä halutaan nostaa rahaa, tehdä tilisiirto tai vaikkapa tarkistaa saldo. Tämä johtaisi helposti siihen, että eri kopiot olisivat eri tilassa, mikä saattaisi aiheuttaa virheitä.

Kolmas syy on dynaaminen koko. Viitetietotyypit mahdollistavat dynaamisesti kasvavien ja kutistuvien tietorakenteiden, kuten linkitettyjen listojen, pinojen ja jonojen, luomisen. Näitä rakenteita ei voida helposti toteuttaa arvopohjaisina, koska arvopohjaisten muuttujien koko on kiinteä käännösaikana.

Neljäs syy liittyy erityisesti olio-ohjelmointiin, ja liittyy osittain myös kolmanteen kohtaan. Javassa viitteet mahdollistavat polymorfismin. Koska muuttuja on vain viite, se voi osoittaa mihin tahansa, joka "näyttää" oikealta tyypiltä.

Elain lemmikki = new Koira();
lemmikki = new Kissa();

Jos nämä olisivat puhtaita arvotyyppejä, Elain-tyyppiselle muuttujalle pitäisi varata kiinteä määrä muistia. Jos Kissa sitten tarvitsisikin enemmän muistia kuin Elain on varannut, koodi hajoaisi. Viitteiden avulla muuttujan koko on aina sama (viitteen koko), riippumatta siitä kuinka valtava olio viitteen päässä on.

Literaalit

Literaali (engl. literal) tarkoittaa ohjelmakoodiin kirjoitettua kiinteää arvoa. Eri tietotyypeillä on omat kirjoitussääntönsä literaaleille.

  • Merkit (char): Kirjoitetaan yksittäisen lainausmerkin sisään, esimerkiksi 'A', '*' ja 'x'. Erikoismerkit alkavat kenoviivalla: '\n' (rivinvaihto), '\u03A9' (kreikkalainen iso omega) ja '\t' (tabulaattori).
  • Kokonaisluvut (byte, short, int, long): Kirjoitetaan suoraan numerona, esimerkiksi 42, -7 ja 0. long-luvun literaali päättyy isoon tai pieneen kirjaimeen L tai l, esimerkiksi 12345678901L.
  • Liukuluvut (float, double): Kirjoitetaan desimaalipisteellä erotettuna, esimerkiksi 3.14, -0.001 ja 2.0. Voidaan käyttää myös tieteellistä muotoa: 1.5e3 (eli 1.5 × 10³ = 1500) ja 2.0E-4 (eli 2.0 × 10⁻⁴ = 0.0002). Oletuksena desimaaliluvut ovat double-tyyppiä. Jos haluat luoda float-luvun, literaalin tulee päättyä isoon tai pieneen kirjaimeen F tai f, esimerkiksi 3.14f.
  • Totuusarvot (boolean): Kirjoitetaan avainsanoina true ja false.
void main() {
char merkki = 'A';
IO.println("merkki = " + merkki);

int luku = 123;
IO.println("luku = " + luku);
long isoLuku = 12345678901L;
IO.println("isoLuku = " + isoLuku);

double liukuluku = -2.0;
IO.println("liukuluku = " + liukuluku);
float pieniLiukuluku = 2.0f;
IO.println("pieniLiukuluku = " + pieniLiukuluku);
double tieteellinenMuoto = 1.5e-2;
IO.println("tieteellinenMuoto = " + tieteellinenMuoto);

boolean totuusarvo = true;
IO.println("totuusarvo = " + totuusarvo);
}

Käärijäluokat

Javassa kullekin alkeistietotyypille on olemassa niin sanottu käärijäluokka (engl. wrapper class). Käärijäluokka "käärii" alkeistietotyypin arvon olion sisään, jolloin alkeistietotyypin arvoa voidaan käsitellä oliona. Esimerkiksi int-tyypin käärijäluokka on Integer. Käärijäluokista löytyy hyödyllisiä metodeja, kuten toString() sekä vakioita, kuten MAX_VALUE. Alkeistietotyypit ja niitä vastaavat käärijäluokat on esitetty alla olevassa taulukossa.

AlkeistietotyyppiKäärijäluokka
byteByte
shortShort
intInteger
longLong
floatFloat
doubleDouble
charCharacter
booleanBoolean

Alla olevassa esimerkissä käytetään käärijäluokkien MAX_VALUE-vakioita, tulostetaan primitiivityyppien käärittyjä arvoja, sekä havainnollistetaan kokonaisluvun lukualueen ylitystä.

void main() {
    byte tavu = Byte.MAX_VALUE;
    short kaksiTavua = Short.MAX_VALUE;
    IO.println(tavu);
    IO.println(kaksiTavua);
    IO.println(Short.toString(kaksiTavua).charAt(0));

    int maksimi = Integer.MAX_VALUE;
    IO.println(maksimi + " on suurin luku, jonka voi tallettaa int-tyyppiseen muuttujaan" );
    int ylivuoto = Integer.MAX_VALUE + 1;
    IO.println("Ylitetään lukualue:");
    IO.println(ylivuoto);
}

Merkkijonot

Javassa merkkijonoja ei lasketa alkeistietotyypiksi. Java-kieli tarjoaa kuitenkin merkkijonoille oman syntaksin: uuden merkkijonon voi luoda kirjoittamalla merkkejä lainausmerkkien " väliin:

void main() {
String jono = "Opiskelen ohjelmointia!";
IO.println("jono = " + jono);
}

Javassa merkkijono on muuttumaton. Jos yrität suorittaa jonkin operaation merkkijonolle, saat tulokseksi uuden merkkijonon, eikä alkuperäinen merkkijono muutu. Katsotaan tästä esimerkki:

void main() {
String muuttumaton = "Tämä on muuttumaton.";
IO.println("muuttumaton = " + muuttumaton);

muuttumaton.concat("Vai onko sittenkään?");
IO.println("muuttumaton = " + muuttumaton);
}

Metodin concat() palauttamaa uutta merkkijonoa ei nyt tallenneta mihinkään, ja alkuperäinen merkkijono pysyy ennallaan. Toisin sanoen, merkkijonomuuttujien arvoa voi muuttaa vain sijoittamalla:

void main() {
String muuttumaton = "Tämä on muuttumaton.";
IO.println("muuttumaton = " + muuttumaton);

// HIGHLIGHT_GREEN_BEGIN
muuttumaton = muuttumaton.concat("Vai onko sittenkään?");
// HIGHLIGHT_GREEN_END
IO.println("muuttumaton = " + muuttumaton);
}

String-tyyppi sisältää lukuisia hyödyllisiä metodeja. Alla on lueteltu joitain niistä.

MetodiSelitys
jono.charAt(paikka)Palauttaa jonossa olevan yksittäisen merkin indeksistä paikka.
jono.length()Palauttaa jonossa olevien merkkien määrän eli jonon pituuden.
jono.trim()Palauttaa kopion jonosta ilman alussa ja lopussa olevia ylimääräisiä tyhjiä merkkejä.
jono.replace(mitä, millä)Palauttaa kopion jonosta, jossa jonot/merkit mitä on korvattu jonolla/merkillä millä.
jono.split(haku)"Pilkkoo" jonon osiin haku-jonon esiintymien kohdalla ja palauttaa taulukon pilkotuista osajonoista. Huomaa, että haku on ns. säännöllinen lauseke.
jono.contains(etsittävä)Palauttaa true, jos etsittävä löytyy jonosta.
jono.indexOf(etsittävä)Palauttaa indeksin, jossa etsittävä esiintyy ensimmäistä kertaa.
jono.substring(mistä, mihin)Palauttaa osan jonosta alkaen indeksistä mistä päättyen indeksiin mihin.
String.join(merkki, jonot)Palauttaa jonon, jossa taulukossa jonot olevat jonot ovat peräkkäin yhdistettynä merkillä merkki).

Kaikki metodit ja niiden tarkat selitykset löytyvät JavaDocs-sivulta (ks. Class String). Katsotaan vielä, miten yllä olevia esimerkkejä voi käyttää:

void main() {
    String mjono = "Opiskelen ohjelmointia java-kielellä.";
    IO.println("mjono = " + mjono);
    IO.println("Ensimmäinen merkki: " + mjono.charAt(0));
    IO.println("Jonon pituus: " + mjono.length());

    IO.println(); // Lisää ylimääräisen rivivaihdon

    // Merkkijonojen yhdistäminen onnistuu + operaattorilla
    mjono = mjono + " Hei maailma!";
    IO.println("mjono (lisäyksen jälkeen) = " + mjono);

    // Tekstin korvaaminen merkkijonossa
    mjono = mjono.replace("java", "Java");
    IO.println("mjono (korvattu java -> Java) = " + mjono);

    IO.println();

    // "Pilkkoo" jonon kahteen osajonoon viivan kohdalla
    // HUOM: split-metodissa merkit \^$.|?*+()[]{}
    // vaativat, että niiden eteen laitetaan kaksi kenoviivaa \\
    // Eli jos haluttaisiin pilkkoa pisteen kohdalla, tulisi kirjoittaa
    // mjono.split("\\.") eikä mjono.split(".");
    // jälkimmäinen versio on ns. säännöllinen lauseke (regular expression),
    // joka pilkkoo jonon jokaisen merkin kohdalla
    String[] lauseet = mjono.split("\\.");
    IO.println("lauseet = " + Arrays.toString(lauseet));

    IO.println();

    // indexOf etsii indeksin, jossa annettu jono löytyy
    int ohjelmointiaPaikka = mjono.indexOf("ohjelmointia");
    IO.println("ohjelmointiaPaikka = " + ohjelmointiaPaikka);

    // substring palauttaa osajonon annetusta jonosta indeksin perusteella
    String osajono = mjono.substring(ohjelmointiaPaikka, ohjelmointiaPaikka + 12);
    IO.println("osajono = " + osajono);

    IO.println();

    // Operaatio "String + lauseke" muuntaa lausekkeen arvon merkkijonoksi
    String toinenJono = "1/2 = " + (1.0 / 2);
    IO.println("toinenJono = \"" + toinenJono + "\"");
}

Luvun parsiminen merkkijonosta

Merkkijono voidaan muuntaa luvuksi käyttämällä käärijäluokkien parse-alkuisia metodeja. Esimerkiksi Integer.parseInt muuntaa merkkijonon kokonaisluvuksi ja Double.parseDouble muuntaa merkkijonon desimaaliluvuksi.

void main() {
    String kokonaislukuJono = "42";
    int kokonaisluku = Integer.parseInt(kokonaislukuJono);
    IO.println("kokonaisluku = " + kokonaisluku);

    String desimaalilukuJono = "3.14";
    double desimaaliluku = Double.parseDouble(desimaalilukuJono);
    IO.println("desimaaliluku = " + desimaaliluku);
}

StringBuilder

Käytä StringBuilder-luokkaa, kun tarvitset muunneltavan merkkijonon. Se tarjoaa menetelmiä merkkijonon muokkaamiseen ilman uusien merkkijono-olioiden luomista, mikä tehostaa muistin käyttöä.

Alla on lueteltu joitain StringBuilder-luokan hyödyllisiä metodeja.

MetodiSelitys
sb.charAt(paikka)Palauttaa jonossa olevan yksittäisen merkin indeksistä paikka.
sb.length()Palauttaa jonossa olevien merkkien määrän eli jonon pituuden.
sb.append(arvo)Lisää arvon nykyisen jonon loppuun.
sb.toString()Palauttaa kopion tästä jonosta String-merkkijonona.

Kaikki toiminnot ja niiden tarkat selitykset löytyvät JavaDocs-sivulta (ks. Class StringBuilder). Alla on esimerkkejä metodien käytöstä.

void main() {
    StringBuilder muuttuva = new StringBuilder("Tämä on muuttuva");
    IO.println("muuttuva = " + muuttuva);
    IO.println("muuttuva.length() = " + muuttuva.length());

    IO.println();

    muuttuva.append(" merkkijono.");
    IO.println("muuttuva = " + muuttuva);
    IO.println("muuttuva.length() = " + muuttuva.length());

    IO.println();

    String muuttumatonKopio = muuttuva.toString();
    IO.println("muuttumatonKopio = " + muuttumatonKopio);
}

Taulukot

Taulukkoja (engl. array) käytetään tallentamaan joukkoa samantyyppisiä alkioita muuttujaan. Tämä helpottaa datan organisointia.

Uuden taulukon määrittely ja luominen Javassa onnistuu seuraavasti:

Tyyppi[] nimi = new Tyyppi[koko];

Tässä new Tyyppi[koko] luo taulukon, joka sisältää koko kappaletta alkioita, joiden tyyppi on Tyyppi. Taulukon luomisen jälkeen alkioiden arvoja voi asettaa käyttäen sijoituslausetta seuraavasti:

void main() {
int[] arvosanat = new int[4];
arvosanat[0] = 4;
arvosanat[1] = 2;
arvosanat[2] = 2;
arvosanat[3] = 5;
IO.println("arvosanat = " + Arrays.toString(arvosanat));
}

Sijoituslauseessa [numero] tarkoittaa alkion paikkaa eli indeksiä taulukossa. Javassa indeksointi alkaa nollasta, eli ensimmäinen alkio on indeksissä 0, toinen indeksissä 1, ja niin edelleen. Taulukon viimeisen alkion indeksi on aina taulukko.length - 1.

Jos alkioiden arvot tunnetaan etukäteen, taulukon voi myös luoda seuraavasti.

void main () {
int[] arvosanat = new int[] {4, 2, 2, 5};
IO.println("arvosanat = " + Arrays.toString(arvosanat));
}

Yhdistetyssä muuttujan määrittely- ja sijoituslauseessa new Tyyppi[]-osa on myös sallittua pudottaa pois, jolloin yllä olevaa voi vielä hieman tiivistää.

void main () {
int[] arvosanat = {4, 2, 2, 5};
IO.println("arvosanat = " + Arrays.toString(arvosanat));
}

Javassa taulukkojen kokoa ei voi muuttaa taulukon luomisen jälkeen. Arvon sijoittaminen indeksiin, jota ei ole taulukossa, aiheuttaa ajonaikaisen virheen.

int[] arvosanat = new int[] {4, 2, 2, 5};
arvosanat[5] = 3;
java.lang.ArrayIndexOutOfBoundsException: Index 5 out of bounds for length 4
	at main.main(main.java:3)

Javassa taulukon pituuden voi aina tarkistaa length-attribuutilla. Lisäksi taulukon voi tulostaa käyttämällä Arrays.toString-metodia (ks. JavaDocs)

void main() {
int[] arvosanat = new int[] {4, 2, 2, 5};
IO.println("Taulukon pituus: " + arvosanat.length);
IO.println("Taulukon sisältö: " + Arrays.toString(arvosanat));
}

Moniulotteiset taulukot

Toisin kuin esimerkiksi C#-kielessä, Javassa ei ole erillisiä moniulotteisia taulukkoja. Sen sijaan Javassa voi luoda taulukon, jonka alkioina ovat taulukot, eli Tyyppi[][]:

void main() {
int[][] taulukko2D = new int[][] {
    new int[] {1, 2, 3},
    new int[] {4, 5, 6, 7},
    new int[] {8, 9, 0},
};

// Taulukon pituus on tässä siten taulukkojen lukumäärä, eli "rivien" määrä.
IO.println("Taulukossa on " + taulukko2D.length + " taulukkoa.");

// Indeksointi toimii normaalisti; alkiona on nyt kokonainen taulukko
IO.println("taulukko2D[0] on taulukko: " + Arrays.toString(taulukko2D[0]));
IO.println("taulukko2D[1] on taulukko: " + Arrays.toString(taulukko2D[1]));
IO.println("taulukko2D[2] on taulukko: " + Arrays.toString(taulukko2D[2]));

// Myös yksittäisen taulukon indeksointi toimii normaalisti,
// mutta huomaa syntaksi!
int ensimmainenAlkio = taulukko2D[0][0];
IO.println("Ensimmäisen rivin ensimmäinen alkio: " + ensimmainenAlkio);

IO.println("Rivillä 2 (indeksi 1) kolmas alkio (indeksi 2) on: " + taulukko2D[1][2]);
}

Huomaa, että yllä olevassa esimerkissä taulukko2D[0][0] viittaa ensimmäisen taulukon (taulukko2D[0]) ensimmäiseen alkioon (taulukko2D[0])[0]. Yllä oleva taulukko ja siinä olevat alkiot voisi kuvata siis seuraavasti:

[0][1][2]taulukko2D[0][0]taulukko2D[0][1]taulukko2D[0][2]123taulukko2D[0]taulukko2D[1][0]taulukko2D[1][1]taulukko2D[1][2]taulukko2D[1][3]4567taulukko2D[1]taulukko2D[2][0]taulukko2D[2][1]taulukko2D[2][2]890taulukko2D[2]

Huomaa erityisesti, että "rivitaulukkojen" ei tarvitse olla välttämättä samanpituuisia.

Vakiot

Muuttuja, jolle voidaan sijoittaa arvo vain alustuksen yhteydessä, määritellään käyttämällä final-avainsanaa. Javan koodauskäytänteisiin kuuluu, että final-muuttujat kirjoitetaan suuraakkosin ja sanat erotellaan toisistaan alaviivalla.

Javassa final-avainsanaa voi käyttää sekä alkeistietotyyppien että viitetietotyyppien kanssa. On kuitenkin huomattava, että viitetietotyyppisen muuttujan tapauksessa final-sana tarkoittaa, että viitettä ei voi muuttaa osoittamaan uuteen dataan. Viitteen päässä olevaa dataa voi silti muuttaa, jos tietotyyppi sallii sen.

final int PAIVIA_VIIKOSSA = 7;
final int[] PAIVIA_KUUKAUDESSA_KARKAUSVUOSI = new int[] {31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};

// PAIVIA_VIIKOSSA = 8; // Tämä aiheuttaa käännösvirheen
PAIVIA_KUUKAUDESSA_KARKAUSVUOSI[0] = 30; // Tämä on sallittu

Vakioita tarvitaan mm. koodin lukemisen helpottamiseksi, toisteisen koodin vähentämiseksi, luotettavuuden parantamiseksi ja suorituskyvyn parantamiseksi.

Listat

Lista on tietorakenne, joka voi kasvaa ja kutistua tarpeen mukaan. Kuten taulukko, lista voi sisältää vain yhden tyypin mukaisia alkioita. Listan koko ei ole kiinteä, mikä tekee siitä joustavamman tilanteisiin, joissa alkioiden määrä ei ole etukäteen tiedossa. Javan listat vastaavat siten JavaScriptin taulukkoja.

Javan yleisin listan tyyppi on ArrayList<T>, jossa T on listassa olevien alkioiden tyyppi. Jotta listoja voi käyttää koodissa, tulee ne ensin tuoda kääntäjän näkyville lisäämällä tiedoston ensimmäiselle riville import-määre:

import java.util.*;

// Varsinainen ohjelma
void main() ...

import-määre kertoo kääntäjälle, että ohjelmassa käytetään tyyppejä, jotka löytyvät java.util-pakkauksesta. Lyhyesti, pakkaus on tapa järjestellä luokkia yhteisen nimittäjän alle. Jos olet käyttänyt C#:a aiemmin, likimain vastaava käsite siellä on nimiavaruus (namespace). Pakkauksiin palataan tarkemmin myöhemmissä osissa; tässä vaiheessa riittää tiedostaa, että kääntäjä ei välttämättä tiedä tyyppien olemassaolosta ellei ne tuo näkyviin import-määreellä.

Kun java.util-pakkauksen sisältö on tuotu ohjelmaan, voidaan listoja alustaa seuraavasti:

import java.util.*;

void main() {
    // Tapa 1: Tyhjä lista ilman alkioita
    List<Integer> arvosanat = new ArrayList<Integer>();
    // Lisätään alkiot yksi kerrallaan
    arvosanat.add(4);
    arvosanat.add(2);
    arvosanat.add(2);
    arvosanat.add(5);
    IO.println("arvosanat = " + arvosanat);

    // Tapa 2: Lista, jossa alkiot annettu valmiiksi
    List<Integer> arvosanatValmis = new ArrayList<Integer>(List.of(4, 2, 2, 5));
    IO.println("arvosanatValmis = " + arvosanatValmis);
}

Javan rajoituksista johtuen listan alkioiden tyypin on aina oltava viitetyyppi. Niinpä esimerkiksi int-alkioita sisältävää listaa ei voi kirjoittaa muodossa ArrayList<int>:

List<int> lista = new ArrayList<int>();
error: unexpected type
List<int> lista = new ArrayList<int>();
     ^
  required: reference
  found:    int

Jos tarvitset listoja, jonka alkioina ovat alkeistietotyypit, käytä alkioiden tyyppinä alkeistietotyyppien käärijäluokat, jotka ovat viitetyyppejä, mutta toimivat kuten niitä vastaavat alkeistietotyypit. Toisin sanoen, ArrayList<Integer> on sallittu, kun taas ArrayList<int> ei ole. Puolestaan ArrayList<String> on sallittu, koska merkkijono on viitetietotyyppi.

huomautus

Javan koodauskäytänteisiin kuuluu, että listamuuttujien tyyppinä käytetään List<T>, kun taas muuttujien arvojen alustuksessa käytetään tarkempaa tyyppiä, kuten ArrayList<T>.

Toisin sanoen, vaikka alla oleva on sallittu



void main() {
ArrayList<String> nimet = new ArrayList<String>(List.of("Matti", "Teppo"));
IO.println("nimet = " + nimet);
}

koodauskäytänteiden mukaisesti on yleisempää esittää muuttuja seuraavasti:



void main() {
List<String> nimet = new ArrayList<String>(List.of("Matti", "Teppo"));
IO.println("nimet = " + nimet);
}

Lisäksi, jos alkioiden tyyppi on muuttujan määrittelyrivin perusteella selvä, alkioiden tyyppi saatetaan usein jättää pois listan alustuksesta:

void main() {
// Kääntäjä päättelee, että ArrayList<> = ArrayList<String> 
// muuttujan tyypin perusteella
List<String> nimet = new ArrayList<>(List.of("Matti", "Teppo"));
IO.println("nimet = " + nimet);
}

Palaamme tarkemmin List<T> ja ArrayList<T> -tyyppien välisiin eroihin osassa 5. Voit tässä vaiheessa kuitenkin miettiä, että List<T> on yleinen tyyppi listoille, kun taas ArrayList<T> on (eräs) Javan tarjoama tapa esittää lista.

Katsotaan vielä listojen eräitä hyödyllisiä metodeja.

MetodiSelitys
size()Palauttaa listassa olevien alkioiden lukumäärän.
add(lisättävä)Lisää alkion listan loppuun.
add(indeksi, lisättävä)Lisää alkion listan indeksiin indeksi siirtäen loput alkiot yhden paikan eteenpäin.
get(indeksi)Palauttaa indeksissä indeksi olevan alkion.
remove(poistettava)Poistaa listasta poistettava:n ensimmäisen esiintymän siirtäen loput alkiot yhden paikan taaksepäin.
remove(indeksi)Poistaa listasta paikassa indeksi olevan alkion.

Löydät lisää metodeja JavaDocs-sivustolla (ks. Class ArrayList<E>).

import java.util.*;

void main () {
    // Luodaan tyhjä merkkijonolista
    List<String> nimet = new ArrayList<>();
    // Lisätään alkioita listaan
    nimet.add("Matti");
    nimet.add("Teppo");
    nimet.add("Liisa");

    // Tulostetaan listan koko
    IO.println("Listan koko: " + nimet.size());
    IO.println("------");
    // Haetaan alkio indeksistä 1 (toinen alkio)
    String toinen = nimet.get(1);
    IO.println("Toinen alkio: " + toinen);
    IO.println("------");

    // Poistetaan alkio indeksistä 0 (ensimmäinen alkio)
    nimet.remove(0);
    IO.println("Poistettiin ensimmäinen alkio.");
    IO.println("------");
    // Tulostetaan kaikki alkiot
    IO.println("nimet = " + nimet);
    IO.println("------");

    // Tulostetaan listan koko
    IO.println("Listan koko: " + nimet.size());


    // Kaksi esimerkkiä siitä, kuinka luoda listaan heti sisältöä
    List<String> elaimet = new ArrayList<>(List.of("koira", "kissa", "kala"));
    List<String> varit = Arrays.asList("punainen", "sininen", "keltainen");
    IO.println("elaimet = " + elaimet);
    IO.println("varit = " + elaimet);
}

Huomaa ainakin nämä erot Javan, C# ja Pythonin välillä listoja käytettäessä. Muuttuja i viittaa listan indeksiin.

ToimintoJavaC#Python
Lukeminen tietystä paikastalist.get(i)list[i]list[i]
Listan kokolist.size()list.Countlen(list)
Poistaminenlist.remove(i)list.RemoveAt(i)list.pop(i)
Onko lista tyhjä?list.isEmpty() tai list.size() == 0list.Count == 0if not list: tai len(list) == 0

Javan tyyppijärjestelmä

Java on staattisesti tyypitetty kieli, mikä tarkoittaa, että muuttujien tyypit määräytyvät käännösaikana, ei ohjelman ajon aikana. Jos yrität sijoittaa muuttujaan väärän tyyppistä tietoa, ohjelma ei käänny, ja kääntäjä antaa virheilmoituksen.

Käytännössä Javassa eri tietotyyppejä ei voi käyttää toistensa sijaan, ellei kieli nimenomaisesti salli sitä. Esimerkiksi totuusarvoa (boolean) ei voi käyttää lukuarvona, eikä viitetyyppistä arvoa voi käsitellä kokonaislukuna. Jos ohjelmoija yrittää rikkoa näitä sääntöjä, seurauksena on käännösvirhe.

void main() {
    boolean totuusarvo = false;
    totuusarvo = 1;
}
error: incompatible types: int cannot be converted to boolean
    totuusarvo = 1;
                 ^
1 error
error: compilation failed

Yllä oleva käännösvirhe kertoo, että kokonaislukua (int) ei voida muuntaa totuusarvoksi (boolean). Tämä on selkeä ero dynaamisesti tyypitettyihin kieliin, kuten Pythoniin tai JavaScriptiin, jossa tyyppi määräytyy ohjelman ajon aikana, mahdollistaen erityyppisten arvojen sijoittamisen samaan muuttujaan:

// Tämä on sallittu koodi JavaScriptissa
let totuusarvo = true;
console.log(`totuusarvo = ${totuusarvo}`);
totuusarvo = 1;
console.log(`totuusarvo = ${totuusarvo}`);

On kuitenkin väistämätöntä, että ohjelmassa tulee käsitellä useita erityyppisiä arvoja. Tätä varten Javassa on valmiiksi määritelty joitain automaattisia sääntöjä, joiden perusteella kääntäjä osaa tehdä implisiittisen tyyppimuunnoksen sijoituksissa ja lausekkeissa. Esimerkiksi

  • kokonaislukuja (int) voidaan automaattisesti muuntaa desimaaliluvuksi (double),
  • pienempiä kokonaislukuja (esim. 8-bittinen kokonaisluku byte) voidaan "laajentaa" suurempiin kokonaislukuihin (esim. 32-bittinen kokonaisluku int).

Tyyppimuunnossääntöjä on paljon lisää; yleisperiaate on, että jos muunnos ei aiheuta tiedon menetystä, sille on todennäköisesti olemassa implisiittinen muunnos.

void main() {
    int kokonaisluku = 23;
    IO.println("kokonaisluku = " + kokonaisluku);
    double desimaaliluku = kokonaisluku; // OK: int -> double muunnos on implisiittinen
    IO.println("desimaaliluku = " + desimaaliluku);

    // HUOM: jakolasku on int / int => desimaalit häviävät
    double puoletVirhe = 1 / 2; 
    IO.println("puoletVirhe = " + puoletVirhe);

    // OIKEIN: jakolasku on int / double -> double / double
    double puoletOikein = 1 / 2.0;
    IO.println("puoletOikein = " + puoletOikein);
}

Lisäksi ohjelmoija voi erikseen pakottaa ns. eksplisiittisen tyyppimuunnoksen käyttämällä syntaksia (uusiTyyppi)muuttujanNimi. Tämä soveltuu tilanteisiin, jossa muunnos ei olisi mahdollista implisiittisesti:

void main() {
  long sairaanIsoLuku = 40000000000L; // long = 64-bittinen luku
  IO.println("Iso, long-tyyppinen luku: " + sairaanIsoLuku);
  // long -> int ei ole implisiittinen, mutta se onnistuu eksplisiittisesti
  int katkaistu = (int)sairaanIsoLuku; // int = 32-bittinen luku
  IO.println("int-luku eksplisiittisen tyyppimuunnoksen jälkeen: " + katkaistu);
}

Staattinen tyypitys tarkoittaa Javassa käytännössä sitä, että tyyppeihin liittyvät virheet pyritään estämään jo ennen ohjelman suorittamista. Kääntäjä toimii eräänlaisena turvaverkkona, joka varmistaa, että arvot, muuttujat ja operaatiot ovat keskenään yhteensopivia.

Ohjausrakenteet ja perustietorakenteet

osaamistavoitteet

  • Ehtolauseet (if, switch)
  • Toistolauseet (for, while, do-while), ja listatyyppiset tietorakenteet
  • Tiedostat, että Javassa merkkijonoja verrataan equals-aliohjelmalla eikä ==-operaattorilla

Ohjelmointi on harvoin pelkkää koodirivien suorittamista peräkkäin. Jotta ohjelmista saadaan hyödyllisiä, niiden täytyy pystyä tekemään päätöksiä, toistamaan asioita ja hallinnoimaan tietoa järkevästi. Tässä osassa käymme läpi Javan logiikan, toistorakenteet sekä kaksi tapaa säilöä tietoa: perinteiset taulukot ja joustavat listat.

Vertailuoperaattorit

Ennen kuin voimme opettaa ohjelmaa tekemään valintoja ("jos tämä, niin tuo"), meidän täytyy ymmärtää, miten tietokone näkee maailman. Tietokoneen logiikka on binääristä: väittämät ovat joko totta (true) tai epätotta (false). Vertailuoperaattorit ovat kuin kysymyksiä, jotka palauttavat vastaukseksi totuusarvon. Tässä ovat yleisimmät vertailuoperaattorit Javassa.

OperaattoriMerkitysEsimerkki (kun x=5, y=3)Tulos
==Yhtä suuri kuinx == yfalse
!=Eri suuri kuinx != ytrue
>Suurempi kuinx > ytrue
<Pienempi kuinx < 4false
>=Suurempi tai yhtä suurix >= 5true
<=Pienempi tai yhtä suuriy <= 3true

Usein päätökset riippuvat useammasta kuin yhdestä asiasta. Esimerkiksi: "Menen ulos, JOS ei sada JA minulla on vapaa-aikaa". Tätä varten tarvitsemme loogisia operaattoreita yhdistämään ehtoja.

  • && (JA / AND): Lauseke on tosi vain, jos molemmat ehdot ovat tosi.

  • || (TAI / OR): Lauseke on tosi, jos edes toinen ehdoista on tosi.

  • ! (EI / NOT): Kääntää totuusarvon päinvastaiseksi (tosi muuttuu epätodeksi).

varoitus

Älä sekoita toisiinsa sijoitusoperaattoria = ja vertailuoperaattoria ==.

  • if (x = 5) yrittää asettaa x:n arvoksi 5 (virhe)
  • if (x == 5) kysyy, onko x:n arvo 5 (oikein)

Viitetietotyyppisten muuttujien vertailu

Toisin kuin primitiivityypeillä (int, double, jne.), Javassa ==-operaattori vertaa viitetyyppien kohdalla viitteitä, eikä sisältöä. Tästä syystä merkkijonojen ja muiden viitetyyppimuuttujien sisällön vertailuun tulee käyttää equals()-metodia.

void main() {
    String mjono1 = "Slush";
    String mjono2 = new String("Slush"); // Luodaan pakolla uusi merkkijono

    // VÄÄRIN: Vertaa viitteitä -> tulostaa false
    IO.println(mjono1 == mjono2); 

    // OIKEIN: Vertaa sisältöjä -> tulostaa true
    IO.println(mjono1.equals(mjono2));
}

Vertailu null-viitteeseen

Kuitenkin null-viitteen tarkistus voidaan tehdä ==-operaattorilla, koska kyseessä on juuri viitteiden vertailu:

void main() {
    String mjono = null;
    IO.println(mjono == null);
}

Aliohjelmat usein käyttävät null-viitettä esittämään arvon puuttumista. Esimerkiksi IO.readln() voi JavaDoc-dokumentaation perusteella palauttaa null, jos syötettä ei voitu lukea. Näin voi käydä, jos esimerkiksi käyttäjä lopettaa ohjelman suorituksen kesken. Silloin voi olla järkevää tarkistaa, että muuttujan arvo ei ole null ennen muuttujan käyttöä:

void main() {
    String syote = IO.readln("Anna syöte");

    if (syote == null) {
        // Tehdään jotain, jos syötettä ei olekaan annettu
        // Esimerkiksi: poistutaan ohjelmasta
        return;
    }

    // Tässä ollaan varmoja, että syote-muuttujassa on edes jokin merkkijono
    IO.println("Teksti huudettuna: " + syote.toUpperCase());
}

Ehtolauseet

Ehtolauseilla ohjataan ohjelman kulkua. Ehtolauseiden muodostamiseen tarvitaan aina yksi tai useampi totuusarvoinen ehtolauseke.

If-rakenne

Perusmuotoinen ehtolause on if. Sen sisällä oleva koodilohko suoritetaan vain, jos sulkeissa oleva totuuslauseke on tosi (true). Usein tarvitsemme myös vaihtoehtoisia reittejä, jolloin käytämme else if ("muuten jos") ja else ("muuten") -rakenteita.

If-lauseiden syntaksi Javassa on seuraavanlainen:

if (pisteet >= 90) {
    IO.println("Arvosana: 5");
} else if (pisteet >= 50) {
    IO.println("Arvosana: Läpi");
} else {
    // Suoritetaan, jos mikään yllä olevista ei toteutunut
    IO.println("Arvosana: Hylätty");
}

Switch-rakenne

Kun halutaan verrata yhden muuttujan sisältämää arvoa useisiin yksittäisiin arvoihin (esimerkiksi valikon valinta), switch-rakenne voi olla selkeämpi kuin pitkä if-else-ketju.

int valinta = 2;

switch (valinta) {
    case 1:
        IO.println("Valitsit vaihtoehdon 1");
        break; // Tärkeä: lopettaa suorituksen tässä lohkossa
    case 2:
        IO.println("Valitsit vaihtoehdon 2");
        break;
    default:
        IO.println("Tuntematon valinta");
}

Kolmiarvoinen operaattori

Yksinkertaisissa "joko-tai"-tilanteissa, joissa halutaan sijoittaa arvo muuttujaan ehdon perusteella, voidaan käyttää kolmiarvoista operaattoria (ternary operator) ?:.

Syntaksi: (ehto) ? arvo_jos_tosi : arvo_jos_epätosi;

Koodiesimerkki:

void main() {
    int luku1 = 5;
    int luku2 = 8;
    
    // Luetaan: Jos luku1 on suurempi kuin luku2, 
    // sijoita suurempi-muuttujaan luku1, muuten luku2
    int suurempi = (luku1 > luku2) ? luku1 : luku2;

    IO.println("Suurempi luvuista on: " + suurempi);
}

Silmukat

Silmukoita tarvitaan, kun halutaan suorittaa asioita toistuvasti. Javassa on neljä päätapaa toteuttaa toistorakenteita: for, for-each, while ja do-while.

For

Käytä for-silmukkaa, kun tiedät etukäteen toistojen määrän tai tarvitset indeksiä (järjestysnumeroa) toiston aikana. Rakenne on seuraava.

for (alustus; toistoehto; päivitys) {
    // silmukan runko
}

Alla on esimerkki summan laskemisesta for-silmukassa.

void main () {
int[] luvut = {1, 2, 3, 4};
int summa = 0;

// Käydään taulukko läpi indeksien 0, 1, 2, 3 avulla
for (int i = 0; i < luvut.length; i++) {
    summa += luvut[i];
}
IO.println("Summa on: " + summa);
}

Alustus, toistoehto ja päivitys voidaan periaatteessa jättää jopa tyhjiksi, mutta puolipisteiden on pakko olla paikallaan. For-silmukalla voidaan tehdä ikuinen silmukka jättämällä toistoehto tyhjäksi, joskin tämä on harvoin tarkoituksenmukaista.

For-Each

For-each-silmukka on usein luettavin ja myös turvallisin tapa käydä läpi koko tietorakenne. Kun et tarvitse indeksiä etkä aio muokata rakenteen kokoa, käytä for-each-silmukkaa.

For-each-silmukassa on joitain rajoituksia: Et tiedä monennessako alkiossa olet menossa, etkä voi tehdä muutoksia tietorakenteeseen, kuten poistaa tai lisätä alkioita.

void main () {
    int[] luvut = {1, 2, 3, 4};
    int summa = 0;

    // "Jokaiselle luvulle taulukossa luvut..."
    for (int luku : luvut) {
        summa += luku;
    }
    IO.println(summa);
}

While

While-silmukka on hyvä valinta silloin, kun et tiedä etukäteen, kuinka monta kertaa toisto pitää suorittaa. Se jatkuu niin kauan kuin ehto on tosi. Tyypillinen esimerkki on tietojen lukeminen tiedostosta rivi riviltä tai pelisilmukka.

void main() {
    String syote = "";

    IO.println("Tervetuloa peliin! (Kirjoita 'lopeta' poistuaksesi)");

    // Huomaa "!" (EI-operaattori) ja .equals() merkkijonolle
    // Silmukka jatkuu niin kauan kuin 
    // syöte EI puutu (eli ei ole null-viite) JA syöte EI ole "lopeta" 
    while (syote != null && !syote.equals("lopeta")) {
        syote = IO.readln("> "); // Pysähtyy odottamaan käyttäjän kirjoitusta

        IO.println("Kaiku: " + syote);
    }
    
    IO.println("Peli päättyi.");
}

Do-While

Tämä toimii kuten while, mutta yhdellä merkittävällä erolla: silmukan runko suoritetaan aina vähintään kerran, koska ehto tarkistetaan vasta lopussa.

Do-while on ainoa silmukka, jonka lopettavaan sulkeeseen tulee puolipiste.

Alla pseudokoodina esimerkki, jossa omenan sijainti arvotaan uudestaan, jos se on liian lähellä pelaajaa.

void main () {
    Vector2D pelaajanSijainti = new Vector2D(0, 0);
    Vector2D omenanSijainti = new Vector2D(0, 0);
    do {
        // Arvo omenalle uusi sijainti
        omenanSijainti.x = Math.random() * 10;
        omenanSijainti.y = Math.random() * 10;        
        // Jos omena on liian lähellä pelaajaa, arvotaan uudestaan
    } while (omenanSijainti.distanceTo(pelaajanSijainti) < 2.0);
}

Silmukoiden suorituksen ohjaaminen

Tarvittaessa silmukan suoritusta voi ohjata seuraavilla lauseilla:

  • break: lopettaa silmukan suorittamisen ja siirtyy suorittamaan silmukan jälkeistä koodia
  • continue: päättää tämänhetkisen silmukan toiston ja siirtyy silmukan päivityslauseeseen ja toistoehtoon

Esimerkiksi alla olevassa silmukassa tulostetaan luvut 1, 2, 3, 4, 6, 7. Luku 5 jätetään välistä ja luvun 8 kohdalla silmukka lopetetaan.

 void main() {
for (int i = 1; i <= 10; i++) {
    if (i == 5) {
        continue; // Siirrytään seuraavaan kierrokseen
    }
    if (i == 8) {
        break; // Lopetetaan silmukan suoritus
    }
    IO.println(i);
}
}

Ohjausrakenteiden yhdistäminen

Silmukkarakenne voi sisältää muita rakenteita, kuten ehto- ja silmukkarakenteita. Sisäkkäisiä rakenteita käytettäessä on huomioitavaa muuttujien näkyvyys: sisemmässä rakenteessa määritelty muuttuja ei näy ulommassa rakenteessa, kun taas ulommassa rakenteessa määritelty muuttuja näkyy sisemmässä rakenteessa.

Esimerkiksi, ehtorakenteen sisällä määritelty muuttuja ei ole käytettävissä ehtorakenteen ulkopuolella:

void main() {
    int luku = 10;

    if (luku > 5) {
        int luku2 = luku + 1; // OK, luku määritelty ulommassa rakenteessa
        luku2 *= 5;
    }

    IO.println(luku2); // VIRHE: luku2 on määritelty sisemmässä rakenteessa
}
error: cannot find symbol
    IO.println(luku2);
               ^
  symbol:   variable luku2

Tällaisissa tapauksissa eräs korjaus on siirtää muuttujan määrittely ulompaan rakenteeseen:

void main() {
    int luku = 10;

    // Lisätään muuttujan määrittely ja alustetaan väliaikaisella arvolla
    // HIGHLIGHT_GREEN_BEGIN
    int luku2 = 0;
    // HIGHLIGHT_GREEN_END
    if (luku > 5) {
    // OK: Nyt luku ja luku2 määritelty ulommassa rakenteessa
    // HIGHLIGHT_YELLOW_BEGIN
        luku2 = luku + 1;
    // HIGHLIGHT_YELLOW_END
        luku2 *= 5;
    }

    IO.println(luku2); // OK: luku2 on määritelty samassa rakenteessa kuin tämä lause
}

Sisäkkäiset silmukat

Sisäkkäiset silmukat tarkoittaa yhden tai useamman silmukkarakenteen kirjoittamista toisen silmukkarakenteen sisään. Tällöin jokaista ulomman silmukan toistokertaa kohden suoritetaan tietty määrä lisää toistoja.

Yleinen käyttötapaus sisäkkäisille silmukoille on moniulotteisten taulukoiden T[][] käsittely.

void main() {
int[][] taulu2D = {
    {1, 2, 3},
    {4, 5, 6},
    {7, 8, 9}
};

for (int riviNro = 0; riviNro < taulu2D.length; riviNro++) {
    IO.println("Rivi " + riviNro + ":");
    for (int sarakeNro = 0; sarakeNro < taulu2D[riviNro].length; sarakeNro++) {
        int alkio = taulu2D[riviNro][sarakeNro];
        IO.println("  Sarake " + sarakeNro + ": " + alkio);
    }
}
}

Muista, että int[][] tarkoittaa, että kyseessä on taulukko, jonka alkioina ovat kokonaislukutaulukot. Siispä yllä olevassa esimerkissä:

  • taulu2D.length antaa taulukossa olevien taulukoiden lukumäärän, eli ns. "rivien" lukumäärän;
  • taulu2D[riviNro] antaa tietyssä indeksissä olevan int[]-taulukon, joka sisältää kaikki rivillä olevat alkiot;
  • taulu2D[riviNro][sarakeNro] antaa taulu2D[riviNro]-taulukossa olevan alkion indeksistä sarakeNro.

Aliohjelmat

osaamistavoitteet

  • Osaat määritellä aliohjelman
  • Osaat käsitellä tietoa aliohjelmien avulla
  • Ymmärrät Javan perustietotyyppien ja viitetyyppien eron aliohjelmaa kutsuttaessa
  • Osaat dokumentoida aliohjelman

Aliohjelma on ohjelman osa, joka suorittaa tietyn tehtävän. Aliohjelmat helpottavat ohjelman jäsentämistä, sillä niiden avulla ohjelma voidaan jakaa pienempiin, hallittavampiin osiin. Aliohjelmat helpottavat myös uudelleenkäyttöä, sillä samaa aliohjelmaa voidaan kutsua useita kertoja eri kohdissa ohjelmaa ilman, että koodia tarvitsee kirjoittaa uudelleen.

Aliohjelmia kutsutaan joskus myös funktioiksi, ja olio-ohjelmoinnin yhteydessä myös metodeiksi. Nimeäminen riippuu kontekstista, mutta tässä yhteydessä käytämme termiä aliohjelma.

Aliohjelma voi ottaa vastaan syötteitä, joita sanotaan parametreiksi. Tehtävän suoritettuaan aliohjelma voi palauttaa tuloksen. Kutsutaan alla keskiarvo-aliohjelmaa, joka laskee kokonaislukujen joukon keskiarvon. Tässä siis parametrina annetaan yksi kokonaislukutaulukko, ja aliohjelma palauttaa keskiarvon double-tyyppisenä arvona.

void main () {
    int[] luvut = {4, 8, 15, 16, 23, 42};
    double keskiarvo = keskiarvo(luvut);
    IO.println("Lukujen keskiarvo on: " + keskiarvo);
}

double keskiarvo(int[] luvut) {
    if (luvut.length == 0) {
        return 0; 
    }
    double summa = 0;
    for (int luku : luvut) {
        summa += luku;
    }
    return summa / luvut.length;
}

Aliohjelman määrittely

Yllä oleva keskiarvo-aliohjelma koostuu monesta pienestä palasesta (1) paluuarvo, (2) nimi, (3) parametrit ja (4) runko-osa.

  • (1) Paluuarvon tyyppi (tässä double): Kertoo, minkä tyyppistä tietoa aliohjelma palauttaa. Jos aliohjelma ei palauta mitään, tyyppi on void.
  • (2) Aliohjelman nimi (tässä keskiarvo): Kertoo millä nimellä sitä kutsutaan.
  • (3) Parametrit (tässä int[] luvut): Sulkeiden sisään määritellään muuttujat, jotka ottavat vastaan aliohjelmalle annettavat syötteet. Parametreja voi olla nolla tai useampia, ja ne erotetaan toisistaan pilkulla. Jokaisella parametrilla on oma tyyppi ja nimi.

Kutsutaan näitä kolmea palasta yhdessä aliohjelman esittelyriviksi. Osassa 2 tutustutaan myös olio-ohjelmointiin liittyviin määreisiin (engl. modifier), joita esittelyriville voi lisätä.

Esittelyrivin jälkeen kirjoitetaan aaltosulkeiden sisään aliohjelman runko-osa. Se sisältää varsinaisen koodin, joka suoritetaan, kun aliohjelmaa kutsutaan.

Kuten kaikessa lähdekoodissa muutenkin, myös aliohjelmien ja parametrien nimien tulee olla kuvaavia ja noudattaa Javan nimeämiskäytäntöjä ja olla tämän opintojakson tyylioppaan mukaisia.

Paluuarvot ja datan käsittely

Aliohjelmaa voi ajatella mustana laatikkona: sinne syötetään raaka-ainetta (parametrit), laatikon sisällä tapahtuu prosessointia, ja lopuksi ulos tulee valmis tuote (paluuarvo). Avainsana return lopettaa aliohjelman suorituksen välittömästi ja palauttaa arvon kutsujalle. Arvon tyypin on vastattava aliohjelman määrittelyssä annettua tyyppiä.

Toisen tekemää aliohjelmaa käytettäessä emme välttämättä tiedä, mitä laatikon sisällä tapahtuu, vaan luotamme siihen, että se toimii määritellyllä tavalla. Tämä on tyypillistä ohjelmoinnissa, jossa käytämme valmiita kirjastoja ja aliohjelmia.

Void-aliohjelma

Joskus aliohjelmaa tarvitaan vain tekemään jokin toimenpide, kuten tulostamaan tekstiä ruudulle, tai aiheuttamaan muu sivuvaikutus. Tällaisessa tapauksessa aliohjelman ei tarvitse palauttaa arvoa. Tällöin paluuarvon tyypiksi merkitään void.

Parametrin välitys: alkeistietotyypit ja viitetyypit

On tärkeää ymmärtää, mitä itse asiassa annamme aliohjelmalle parametrina kun kutsumme sitä. Javassa kaikki parametrit välitetään arvona (ns. pass-by-value), mutta välitettävän arvon luonne riippuu parametrin tyypistä.

  • Jos parametrin tyyppi on alkeistietotyyppi (kuten int, double, char, boolean), aliohjelmalle annetaan kopio alkuperäisestä arvosta.
  • Jos parametrin tyyppi on viitetyyppi (kuten taulukko), aliohjelmalle annetaan kopio viitteestä alkuperäiseen dataan.

Kun alkeistietotyyppi (ks. Luku 1.2) annetaan parametrina, kyseisen muuttujan arvo kopioidaan ja välitetään kutsuttavalle aliohjelmalle. Jos aliohjelma muuttaa tätä kopiota, alkuperäinen muuttuja ei muutu. Alla esimerkki, jossa annamme int-tyyppisen muuttujan parametrina.

void yritaMuuttaa(int luku) {
    luku = 99; // Muutetaan vain kopiota
    IO.println("Metodissa: " + luku);
}

void main() {
    int x = 10;
    yritaMuuttaa(x);
    IO.println("Mainissa: " + x); // Tulostaa edelleen 10
}

Kun viitetyyppi (ks. Luku 1.2) annetaan parametrina aliohjelmalle, kopioidaan viite eikä alkuperäistä dataa. Aliohjelma saa siis käyttöönsä kopion viitteestä, joka osoittaa samaan dataan kuin pääohjelman muuttuja. Jos aliohjelma sitten muokkaa muuttujan osoittamaa dataa (esim. taulukon alkioita), muutos näkyy myös pääohjelmassa.

Voit ajatella viitteen ikään kuin "kaukosäätimenä", jolla ohjataan "televisiota" eli dataa. Vaikka aliohjelmalle viedään kopio "kaukosäätimestä", voi sillä ohjata samaa "televisiota".

Alla esimerkki tällaisesta tilanteesta, jossa annamme int[]-tyyppisen taulukon parametrina, ja aliohjelma muokkaa taulukon alkioita.

void nollaaTaulukko(int[] taulukko) {
    // Tämä muutos tapahtuu alkuperäiselle taulukolle!
    // Koska "taulukko"-muuttuja viittaa samaan taulukkoon.
    for (int i = 0; i < taulukko.length; i++) {
        taulukko[i] = 0;
    }
}

void main() {
    int[] luvut = {1, 2, 3};
    
    nollaaTaulukko(luvut);
    
    // Alkuperäinen taulukko on muuttunut
    IO.println(luvut[0]); // Tulostaa 0
}

Oleellista on kuitenkin ymmärtää, että aliohjelman kutsussa annoimme nimenomaisesti kopion viitteestä, emme alkuperäistä viitettä. Jos aliohjelma yrittäisi muuttaa viitettä osoittamaan muuhun dataan (esim. toiseen taulukkoon), tämä muutos ei vaikuttaisi alkuperäiseen viitteeseen pääohjelmassa. Alla esimerkki tästä tilanteesta:

void main() {
    int[] luvut = {1, 2, 3};
    
    muutaViite(luvut);
    
    // Alkuperäinen taulukko ei ole muuttunut
    IO.println(luvut[0]); // Tulostaa edelleen 1
}

void muutaViite(int[] taulukko) {
    // Tämä muutos ei vaikuta alkuperäiseen viitteeseen!
    taulukko = new int[] {9, 9, 9};
}

Joissain kielissä, kuten C++:ssa, on mahdollista välittää parametrina alkuperäinen muuttuja viitteenä (ns. pass-by-reference). Javassa tällaista mekanismia ei kuitenkaan ole, vaan kaikki parametrit välitetään arvona, kuten yllä on selitetty.

Aliohjelma ja sivuvaikutukset

Aliohjelmaa, joka muokkaa parametrina annettua dataa, sanotaan usein aiheuttavan sivuvaikutuksia. Esimerkiksi, jos alla oleva aliohjelma kaanna kääntää parametrina annetun taulukon alkiot käänteiseen järjestykseen:

void kaanna(int[] taulu) {
    // Toteutus piilotettu tilan säästämiseksi
    for (int i = 0; i < taulu.length / 2; i++) {
        int tmp = taulu[i];
        taulu[i] = taulu[taulu.length - 1 - i];
        taulu[taulu.length - 1 - i] = tmp;
    }
}

void main() {
    int[] taulukko = {1, 2, 3, 4, 5};
    IO.println("taulukko = " + Arrays.toString(taulukko));
    kaanna(taulukko);
    IO.println("taulukko = " + Arrays.toString(taulukko));
}

Huomaa, että kaanna ei palauta mitään, mutta sen sivuvaikutuksena on, että parametrina annettu taulukko kääntyy. Yllä oleva aliohjelma voitaisiin toteuttaa myös ilman sivuvaikutuksia muuttamalla palautusarvoa:

int[] kaanna(int[] taulu) {
    // Toteutus piilotettu tilan säästämiseksi
    int[] tulos = new int[taulu.length];
    for (int i = 0; i < taulu.length; i++) {
        tulos[i] = taulu[taulu.length - 1 - i];
    }
    return tulos;
}

void main() {
    int[] taulukko = {1, 2, 3, 4, 5};
    IO.println("taulukko = " + Arrays.toString(taulukko));
    int[] kaannetty = kaanna(taulukko);
    IO.println("kaannetty = " + Arrays.toString(kaannetty));
}

Sivuvaikutukset voivat olla hyödyllisiä muun muassa muistin käytön optimoinnin kannalta, mutta ne voivat myös tehdä ohjelmasta vaikeammin ymmärrettävän. Yllä olevassa esimerkissä aliohjelman void kaanna(int[] taulu) sivuvaikutus ei ole ilmiselvää ellei katso aliohjelman lähdekoodia. Sivuvaikutuksilla voikin esimerkiksi tiedostamatta tuhota tai muokata dataa. Siksi on erittäin tärkeää olla tietoinen siitä, miten aliohjelmat käsittelevät parametreja.

Kommentointi ja dokumentointi

Lähdekoodiin voi kirjoittaa tekstiä, joka ei ole varsinaista koodia, vaan selittää sitä. Tällaista selitystekstiä on kahdentyyppisiä: (1) koodin sekaan kirjoitettavia kommentteja (nimitetään näitä lyhyesti kommenteiksi) sekä (2) dokumentaatiokommentteja.

Kommenttien tarkoitus on palvella kehityksen aikaista tekemistä. Ne näkyvät sisäisesti, eli ohjelmoijalle itselleen. Dokumentaatiokommenttien tarkoitus on palvella kaikkia, jotka käyttävät koodia. Ne näkyvät paitsi ohjelmoijalle itselleen, myös niille, jotka hyödyntävät koodia esimerkiksi API:n (application programming interface) kautta.

Yhden rivin kommentointi

Yhden rivin kommentteja, jonka syntaksi on // voidaan käyttää esimerkiksi merkitsemään TODO-kohtia koodissa:

void main() {
    // TODO: Tarkista millaisia ongelmia tästä ratkaisusta voi tulla
    String syote = IO.readln();
    IO.println("Kirjoitit: " + syote);
}

Yleisesti hyvä periaate on, että ohjelmoija pyrkii kirjoittamaan sellaista koodia, joka selittää itse itseään. Muuttujat, luokat, aliohjelmat ja muut nimet, johon ohjelmoija pystyy vaikuttamaan, pyritään nimeämään mahdollisimman kuvaavasti, jolloin yksittäisten rivien kommentointi ei välttämättä ole tarpeen. Asiaa on kuvattu myös opintojakson tyylioppaassa. TODO: Linkki.

Joskus yhden rivin kommenteilta ei voi välttyä, jos jotakin operaatiota ei voida olettaa itsestäänselväksi tai muuttujan nimestä tulisi kohtuuttoman pitkä:

void main() {
    int n = 9;
    // Pyöristää alaspäin lähimpään neljällä jaolliseen lukuun
    int pyoristetty = n & ~3; 
    IO.println(pyoristetty);
}

Nyt muuttujan pyoristetty tilalla voisi olla pyoristaaAlaspainLahimpaanNeljallaJaolliseenLukuun, joka ei sekään ole oikein järkevä vaihtoehto.

Monirivinen kommentti

Javassa monirivinen kommentti tulee /* ja */ väliin. Tällaista suositellaan käytettäväksi, kun jokin monimutkaisempi logiikka vaatii tarkempaa avaamista ja/tai on järkevää selittää miksi juuri kyseinen ratkaisu on valittu. Tätä kommenteissa olevaa tarkempaa avaamista ei kuitenkaan ole tarkoitus näyttää koodin käyttäjille.

if (kayttaja.kayttaaVanhaa) {
    /* 
     * Vanhat käyttäjät (rekisteröityneet ennen vuotta 2022) käyttävät 
     * toistaiseksi vanhaa käyttöoikeusmallia.
     * Älä poista tarkistusta, ennen kuin kaikki tilit on siirretty.
     */
    return kaytaVanhojaKayttooikeuksia(kayttaja);
}

Dokumentaatiokommentti

Dokumentaatiokommentti tarkoittaa sellaista kommenttia, josta voidaan automaattisesti luoda erilaisia koodin käyttäjille tarkoitettuja dokumentaatiomuotoja. Tällaisia dokumentaatiomuotoja voivat olla esimerkiksi HTML-muotoiset API-dokumentaatiot, jotka kertovat miten koodia käytetään, tai IDE:ssä näkyvät työkaluvihjeet aliohjelmien käytöstä.

Dokumentaatiokommenttien sijainti on ennen dokumentoitavaa koodia, kuten aliohjelmaa tai luokkaa.

Javassa dokumentaatiokommentit kirjoitetaan erityisellä syntaksilla, joka eroaa tavallisista kommenteista. Dokumentaatiokommentit alkavat /** ja päättyvät */, eli ovat syntaksiltaan hyvin lähellä monirivistä kommenttia.

 void main() {
   IO.println("summa(1, 2) ==> " + summa(1, 2));
 }

/**
 * Laskee kahden kokonaisluvun summan.
 * 
 * @param a Ensimmäinen luku
 * @param b Toinen luku
 * @return Lukujen summa
 */
int summa(int a , int b) {
    return a + b;
}

Aliohjelman dokumentaatiokommentin runko syntyy automaattisesti IDEA-kehitysympäristössä, kun aliohjelman esittelyrivin yläpuolelle kirjoittaa merkit /** ja painaa Enter.

Bonus: miltä Javan dokumentaatio näyttää?

Oletetaan nyt, että tallennat yllä olevan tiedostoon Summa.java ja ajat sen jälkeen komennon javadoc Summa.java. Nyt voit avata luodun index.html -tiedoston selaimessa, klikata selaimessa luokkaa Summa ja pääset seuraavanlaiseen näkymään:

Juuri tehdystä dokumentaatiosta kuva, joka voi näyttää tutulta jos on käynyt tutkimassa Javan omaa dokumentaatiota

Näyttääkö tutulta? Vertaa esimerkiksi Javan dokumentaatioon IO-luokasta

vinkki

Kannattaa vilkaista materiaalissa linkattuihin Javan virallisiin dokumentaatioihin! Linkin tunnistaa tekstissä olevasta "JavaDoc"-tekstiä sisältävästä linkistä.

Varaudu kuitenkin siihen, että dokumentaation lukeminen on haastavaa ennen kuin siihen tottuu! Virallinen dokumentaatio myös sisältää usein paljon termejä ja syntaksia, joita ei ole vielä käsitelty ja on vaikea ymmärtää. Tästä ei tarvitse olla huolissaan ja kaikki mitä kurssilla tarvitsee osata käydään läpi näissä kurssimateriaaleissa ellei erikseen toisin mainita.

Dokumentaation lukutaito on kuitenkin tärkeä taito ohjelmoijalle, joten kannattaa pyrkiä totuttelemaan siihen pikkuhiljaa — myös tekoälyn aikana sillä ne eivät välttämättä anna viimeisintä tai edes onnistu toistamaan dokumentaation kertomaa totuudenmukaisesti. Jos oppii luottamaan pelkästään tekoälyyn, siinä vaiheessa kun asiat menee vaikeiksi tulee ongelmia. Tekoäly (erityisesti generatiivinen), toimii nimittäin parhaiten kun ongelmat ovat yleisiä ja tunnettuja eivätkä vaadi suurta asiantuntemusta tai tarkkaa silmää.

Osan kaikki tehtävät

huomautus

Jos palautat tehtävät ennen osan takarajaa (ma 19.1.2026 klo 11:59 (keskipäivä)), voit saada DL-BONUS-pisteitä harjoitustehtäviin. Lue lisää suorittaminen-sivulta.

Ennen kuin aloitat tehtävien tekemisen, on syytä tutustua opintojakson eettisiin ohjeisiin.

Tehtävä 1.1: Oma ohjelma Javalla 1 p.

Tee uusi IDEA-projekti nimeltään Viikko1 (ks. Luo uusi projekti ohje). Lisää projektiin moduuli Tehtava11 (ks. Luo Java-moduuli -ohje).

Lisää projektiin Java Compact File -tiedosto nimeltään OmatTiedot.java. Kirjoita tiedostoon ohjelma, joka tulostaa kullekin eri riville

  • nimesi,
  • puhelimesi merkin
  • puhelimesi mallin
Tee tehtävä TIMissä
Tehtävä 1.2: Sanojen määrä 1 p.

Tee ohjelma, joka tulostaa konsolisyötteenä annetun merkkijonon sisältämien sanojen määrän. Voit olettaa, että sanat on eroteltu yhdellä välilyönnillä. Muita välimerkkejä ei tarvitse huomioida.

Ohjelman pitää tulostaa "Ei sanoja", jos syöte on tyhjä merkkijono.

Ohjelma lopettaa voi toimintansa tulosteen näyttämisen jälkeen. Ohjelman ei tarvitse kysyä syötettä jatkuvasti.

Testaa ohjelmaasi lukemalla käyttäjältä merkkijono ja tulostamalla sanojen määrä, esimerkiksi:

Sanojen määrä: 5
Tee tehtävä TIMissä
Tehtävä 1.3: Kokonaislukujen lukeminen käyttäjältä 1 p.

Tee ohjelma, joka lukee käyttäjältä ei-negatiivisia kokonaislukuja yksi rivi kerrallaan silmukassa käyttäen IO.readln()-metodia, kunnes käyttäjä antaa tyhjän merkkijonon. Tallenna nämä luvut listaan.

Tee sitten aliohjelmat summa, keskiarvo, pienin ja suurin, jotka laskevat listan pienimmän luvun, suurimman luvun, lukujen summan ja lukujen keskiarvon. Tulosta aliohjelmien palauttamat arvot käyttäjälle. Lisää aliohjelmiin sopiva käsittely tyhjille listoille ja dokumentoi ne.

Voit olettaa, että käyttäjä kirjoittaa vain ei-negatiivisia kokonaislukuja syötteenä. Jos aliohjelma saa syötteenä tyhjän listan, sen tulee palauttaa arvo -1.

Valmiiden Collections-luokan metodien, kuten Collections.min() ja Collections.max(), käyttö on kielletty.

Vinkki

Voit muuntaa merkkijonon numeroksi käyttäen Integer.parseInt(luku)-metodia.

Tee tehtävä TIMissä
Tehtävä 1.4: Merkkijonon pakkaaminen 1 p.

Tee aliohjelma pakkaaMerkkijono, joka ottaa parametrina String-merkkijonon ja palauttaa uuden String-merkkijonon tiivistettynä siten, että peräkkäiset samat merkit ilmoitetaan merkillä ja niiden lukumäärällä.

Esimerkiksi:

  • pakkaaMerkkijono("aaaabbbccd") palauttaisi merkkijonon "a4b3c2d1",
  • pakkaaMerkkijono("00666663332222222") palauttaisi merkkijonon "02653327",
  • pakkaaMerkkijono("Niiiiiin") palauttaisi merkkijonon "N1i6n1",
  • pakkaaMerkkijono("nnNNnnnN") palauttaisi merkkijonon "n2N2n3N1",
  • pakkaaMerkkijono("ohjelmointi") palauttaisi merkkijonon "o1h1j1e1l1m1o1i1n1t1i1".

Kirjoita aliohjelmalle myös sopiva dokumentaatiorivi ja lisää ainakin yksi toimintaesimerkki main()-pääohjelmaan.

Vinkki 1

Käytä StringBuilder-merkkijonoa ja sen metodeja merkkijonon rakentamiseen.

Vinkki 2

Kokeile ratkaista ongelma ensin paperilla käsin lyhyelle jonolle (esim. "aabb"). Mistä asioista on pidettävä kirjaa (muuttujat)?

Vinkki 3

Voi olla helpompaa aloittaa toisesta merkistä ja verrata se aina edeltävään:

0123aabbijono.lengthi11
Tee tehtävä TIMissä
Tehtävä 1.5: Salasanan vahvuuden tarkistaminen 1 p.

Tee funktio onkoSalasanaVahva, joka tarkastaa parametrina saadun salasanan (String) vahvuuden. Salasanan tulee olla vähintään 8 merkkiä pitkä, sisältää yhden luvun, sekä vähintään yhden suuren ja pienen kirjaimen.

Funktio palauttaa true, jos merkkijonona annettu salasana täyttää yllä olevat vaatimukset, muuten se palauttaa false.

Kirjoita aliohjelmalle myös sopiva dokumentaatiorivi ja lisää ainakin yksi toimintaesimerkki main()-pääohjelmaan.

Vinkki

Katso Character-luokan metodi isDigit.

Esimerkiksi kutsumalla Character.isDigit(merkki) saat paluuarvona true, jos merkki on numero, ja vastaavasti false, jos merkki ei ole numero.

Vastaavalla periaatteella toimivat myös metodit isUpperCase ja isLowerCase, jotka löytyvät myös samasta luokasta.

Tee tehtävä TIMissä
Tehtävä 1.6: Matriisin tulostus 1 p.

Tee aliohjelma tulostaMatriisi, joka tulostaa kaikki parametrina annetun kokonaislukumatriisin luvut samalle riville. Aliohjelma ei palauta mitään.

Esimerkki aliohjelman toiminnasta

int[][] matriisi = {
    {42, 2, 3},
    {4, 67, 6},
    {7, 8, 9}
};

tulostaMatriisi(matriisi);

Tulostuksen pitäisi silloin olla:

42 2 3 4 67 6 7 8 9 
Tee tehtävä TIMissä
Bonus: Tehtävä 1.7: Numerolaskuri 1 p.

Tee ohjelma, joka kysyy käyttäjältä syötteen ja tulostaa, kuinka monta kertaa kukin numero (0–9) esiintyy syötteessä.

Esimerkiksi, jos käyttäjä antaa syötteenä 12223, ohjelma tulostaa:

1: 1 kpl
2: 3 kpl
3: 1 kpl

Vastaavasti, jos syötteenä annetaan 10002244412, ohjelma tulostaa.

0: 3 kpl
1: 2 kpl
2: 3 kpl
4: 3 kpl

Tulostamisen jälkeen ohjelma kysyy käyttäjältä uuden syötteen. Jos käyttäjä antaa tyhjän syötteen, ohjelman suoritus päättyy.

Vinkki

Yksittäisen merkin saa muunnettua kokonaisluvuksi yhdistämällä Character.toString ja Integer.parse:

int numeroLukuna = Integer.parseInt(Character.toString(a));

Huomaa, että Integer.parseInt olettaa, että annettu merkkijono on todellakin numero; jos se sisältää jotain muuta kuin numeroa, funktio heittää virheen. Voit tarkistaa, että onko yksittäinen merkki numero käyttämällä Character.isDigit-funktiota.

Tee tehtävä TIMissä
Bonus: Tehtävä 1.8: Puuttuva luku 1 p.

Tee funktio int puuttuvaLuku(int[] taulukko). Funktiolle annetaan parametriksi taulukko, joka sisältää luvut 1-N satunnaisessa järjestyksessä, mutta yksi luvuista puuttuu. Funktion on palautettava luku, joka puuttuu. Funktio ei saa aiheuttaa sivuvaikutuksia.

Esimerkiksi:

  • puuttuvaLuku(new int[] { 1, 6, 3, 4, 5 }) palauttaa 2
  • puuttuvaLuku(new int[] { 8, 2, 4, 1, 3, 5, 6 }) palauttaa 7
  • puuttuvaLuku(new int[] { }) palauttaa 1
  • puuttuvaLuku(new int[] { 2 }) palauttaa 1

Voit käyttää alla olevaa apufunktiota satunnaisen taulukon luomiseksi:

int[] annaSyote(int maxLuku) {
    Random r = new Random();
    List<Integer> luvut = new ArrayList<>(IntStream.range(1, r.nextInt(2, maxLuku + 1)).boxed().toList());
    Collections.shuffle(luvut);
    luvut.remove(r.nextInt(luvut.size()));
    return luvut.stream().mapToInt(Integer::intValue).toArray();
}

Voit käyttää aliohjelmaa seuraavasti:

// Tekee taulukon luvuista 1-10, mutta yksi luku puuttuu
int[] syote = annaSyote(10); 
// Selvitetään, mikä luvuista puuttuu
int puuttuva = puuttuvaLuku(syote);
Tee tehtävä TIMissä
Bonus: Tehtävä 1.9: Alkuluvut 1 p.

Tulosta kaikki alkuluvut väliltä 1-100.

Alkuluku on lukua 1 suurempi kokonaisluku, joka on jaollinen vain itsellään ja luvulla 1.

Vinkki

Tee ensin funktio onAlkuluku, joka saa parametrinaan kokonaisluvun ja palauttaa totuusarvon, joka kertoo onko luku alkuluku vai ei. Käytä tätä funktiota apuna pääohjelmassa, jossa tulostat silmukkaa apuna käyttäen kaikki alkuluvut väliltä 1-100.

Tee tehtävä TIMissä

Olio-ohjelmoinnin perusteet

osaamistavoitteet

  • Ymmärrät olio-ohjelmoinnin perusidean ja kuinka se eroaa proseduraalisesta ohjelmoinnista.
  • Osaat hahmottaa olion kokonaisuutena, jossa tieto ja toiminta yhdistyvät.
  • Ymmärrät luokan ja olion suhteen, ja miten luokka toimii mallina olioiden luomisessa.
  • Osaat määritellä oman luokan ja sen jäsenet, eli attribuutit, metodit ja konstruktorit.
  • Ymmärrät olioiden elinkaaren ja osaat käyttää olioita ohjelman osina.
  • Tiedät, mitä static-määrite merkitsee luokan jäsenten osalta.
  • Ymmärrät kapseloinnin periaatteet ja hyödyt, ja osaat erottaa olion sisäisen toteutuksen sen ulkoisesta käytöstä.

Kohti olio-ohjelmointia

osaamistavoitteet

  • Otat askeleen proseduraalisen ohjelmoinnin data ja funktio -ajattelumallista kohti olio-ohjelmoinnin tila ja metodi -ajattelumallia.
  • Ymmärrät olion käsitteen; tieto ja toiminnallisuus yhdessä paketissa.
  • Ymmärrät, miten käsitteiden mallintaminen olioina voi helpottaa ohjelman rakentamista.

Tähän mennessä olemme tehneet enimmäkseen ohjelmia, joissa dataa tallennetaan ohjelman muuttujiin ja käsitellään funktioiden avulla. Tällaista ohjelmointityyliä kutsutaan proseduraaliseksi ohjelmoinniksi. Tutustumme tällä kurssilla toiseen ohjelmointityyliin: olio-ohjelmointiin.

Olio-ohjelmointi on hyvin laaja aihe, ja käymme tällä kurssilla läpi olio-ohjelmoinnin teoriaa valikoidusti erityisesti tämän opintojakson tarpeita ajatellen. Syvemmin teoriaan perehdytään esimerkiksi opintojaksolla TIEA1130 Oliosuuntautunut suunnittelu ja ohjelmointi.

Perusidea

Olio-ohjelmoinnissa ideana on luoda olioiksi kutsuttuja rakenteita, jotka sisältävät datan sekä toiminnallisuudet sen muokkaamiseen. Oliot voivat olla samanlaisia, mutta jokaisella oliolla on oma tila, joka voi muuttua ohjelman suorituksen aikana. Olion tila tallentuu sen omiin muuttujiin eli attribuutteihin.

Oliolla voi olla myös omia aliohjelmia, joita kutsutaan metodeiksi. Metodi on oliolle kuuluva aliohjelma, joka voi tarkastella ja muuttaa sen omistavan olion tilaa. Ennen olion luontia täytyy ensin määrittää luokka eli class, jossa kuvaillaan olion rakenne.

Olioiden tehtävä on olla vastuussa oman vastuualueensa toiminnallisuuksista. Olioiden välinen yhteistoiminta ja kommunikointi metodikutsujen kautta on olio-ohjelmoinnissa keskeisessä osassa. Joissain ohjelmointikielissä tätä saatetaan kutsutaan viestinvälitykseksi.

Minimaalinen, olioita hyödyntävä ohjelma voisi näyttää esimeriksi tältä:

class Kissa {
    private String nimi;

    public String getNimi() {
        return nimi;
    }

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

void main() {
    Kissa kissa1 = new Kissa();
    kissa1.setNimi("Miuku");

    Kissa kissa2 = new Kissa();
    kissa2.setNimi("Katti");

    IO.println(kissa1.getNimi());
    IO.println(kissa2.getNimi());
}

Esimerkissä määritellään ensin Kissa-luokka ja luodaan sitten pääohjelmassa sen pohjalta olioita, jotka sisältävät attribuuttina merkkijonon nimi sekä kaksi metodia. Olion nimeä voidaan muuttaa kutsumalla sen setNimi-metodia ja se voidaan pyytää vastaavasti getNimi-metodilla. Molemmilla olioilla on oma tilansa - eli oma nimi. Yhden olion tila ei vaikuta toisen olion tilaan.

Tästä yksinkertaisesta esimerkistä näemme, kuinka data voidaan ryhmitellä olioiden sisälle. Tässä tapauksessa olion ainoa attribuutti nimi on suoraan käsiteltävissä metodien kautta. Käytännössä tällainen suora manipulointi on harvinaista tuotantokoodissa, mutta palaamme tähän myöhemmin.

Yhtä muuttujaa varten tuskin kannattaa tehdä eri olioita, mutta olio-ohjelmoinnin hyödyt tulevat nopeasti esille, kun tallennettavan tiedon määrä lisääntyy. Mietitään esimerkiksi tilannetta, jossa haluaisimme tallentaa tietoa kilpailussa mukana olevista kilpailijoista. Kilpailijoita voi olla useita ja jokaisesta pitäisi tallentaa ainakin nimi, kilpailijanumero ja pisteet. Olioiden avulla voimme pitää yhden kilpailijan tiedot ja niiden muokkaamiseen liittyvät toiminnallisuudet saman rakenteen sisällä, mikä helpottaa näiden tietojen käsittelyä.

class Kilpailija {
    private String nimi;
    private int numero;
    private int pisteet;

    public Kilpailija(String nimi, int numero, int pisteet) {
        this.nimi = nimi;
        this.numero = numero;
        this.pisteet = pisteet;
    }

    // ...

    public void tulostaTiedot() {
        IO.println(String.format("%d: %s, %d pistettä", numero, nimi, pisteet));
    }
}

void main() {
    Kilpailija[] kilpailijat = {
        new Kilpailija("A", 2, 20),
        new Kilpailija("B", 4, 15),
        new Kilpailija("C", 6, 10)
    };

    for (Kilpailija kilpailija : kilpailijat) {
        kilpailija.tulostaTiedot();
    }
}

Vastaavan ohjelman tekeminen ilman olio-ohjelmointia vaatii vähän aivojumppaa. Yksi tapa olisi käyttää kolmea erillistä taulukkoa: Yksi sisältäisi nimet, toinen numerot ja kolmas pisteet. Tällöin täytyy kuitenkin huolehtia, että kilpailijan tiedot löytyvät aina samoista indekseistä kaikissa taulukoissa, mikä on käytännössä hankalaa ja altistaa virheille. Olioiden avulla tiedot ja toiminnallisuudet voidaan paketoida selkeämmin yhteen.

Yhteen kuuluvan tiedon ja toiminnallisuuden järjestely saman rakenteen sisälle tekee ohjelman koodista helpommin ymmärrettävää ja laajennettavaa, mutta se ei toki ole olio-ohjelmoinnin ainoa etu; voimme myös piilottaa osan luokan toteutusyksityiskohdista vain luokan sisäiseen käyttöön, jolloin luokkaa käyttävä ohjelmoija saa käyttöönsä vain tarkkaan määritetyn julkisen rajapinnan eikä hänen tarvitse miettiä luokan sisäistä toimintaa. Tutustumme 3. osassa myös polymorfismiin, perintään ja rajapintoihin, jolloin olio-ohjelmoinnin hyödyt tulevat todella esille.

Pääohjelma Javassa

Java on erityisesti olio-ohjelmointiin suuntautunut ohjelmointikieli, jossa myös pääohjelman on historiallisesti täytynyt olla luokan sisällä. Javan uudemmissa versioissa tähän on tullut muutoksia; yksinkertaisen ohjelman kirjoittamista on pyritty helpottamaan niin, että pääohjelma ei tarvitsisi ympärilleen luokkaa. Näistä muutoksista voi lukea lisää Java 21:n dokumentaatiosta sekä vuoden 2025 syksyllä julkaistun Java 25:n dokumentaatiosta.

Nykyään riittää näissä materiaaleissa käytetty suoraviivaisempi pääohjelma:

void main() {
    IO.println("Hei maailma!");
}

Aiemmin minimaalinen Java-ohjelma saattoi näyttää tältä:

public class HeiMaailma {
    public static void main(String[] args) {
        IO.println("Hei maailma!");
    }
}

Vanhemman tyylin esimerkissä on muutama uusi käsite, joihin tutustumme tässä osassa tarkemmin. Ohjelmassa määritetään ensiksi class-avainsanaa käyttäen luokka, jonka nimeksi on esimerkissä annettu HeiMaailma. Pääohjelma main sijoitetaan tämän luokan sisälle. Vanhassa tyylissä pääohjelmassa täytyy olla static-määrite, jota katsomme tarkemmin pian.

Koska suoraviivaisempi pääohjelma on verrattain uusi ominaisuus, valtaosa verkosta ja kirjoista löytyvistä esimerkeistä käyttää yhä alkuperäistä tyyliä, eli luokan sisään upotettua pääohjelmaa. Tämä on hyvä tiedostaa tietoa etsiessä.

Luokka ja olio

osaamistavoitteet

  • Ymmärrät luokan ja olion suhteen, ja miten luokka toimii mallina olioiden luomisessa.
  • Osaat määritellä oman luokan ja sen jäsenet, eli attribuutit, metodit ja konstruktorit.
  • Ymmärrät olioiden elinkaaren ja osaat käyttää olioita ohjelman osina.
  • Ymmärrät, miten this-viite toimii olion metodeissa.
  • Tiedät, mitä static-määrite merkitsee luokan jäsenten osalta.

Luokka

Ensimmäinen askel olio-ohjelmointiin on luokan määritteleminen class-avainsanaa käyttäen. Luokkaa voi ajatella kaavana tai muottina, jonka pohjalta olioita luodaan. Luokka kertoo, mitä tietoja olio sisältää (attribuutit) ja mitä se voi tehdä (metodit). Luokassa määriteltyjä attribuutteja ja metodeja kutsutaan myös luokan jäseniksi (engl. class member).

Tehdään pieni ajatusharjoitus: mietitään hetki talonrakennusta. Arkkitehdin piirtämän yhden rakennuspiirustuksen pohjalta voidaan rakentaa monta rakennusta. Ne olisivat rakenteeltaan samanlaisia, sillä ne ovat saman kaavan mukaan tehty, mutta jokaisella rakennuksella olisi kuitenkin oma tila; eri omistaja, väri, sisustus, ja niin edelleen. Rakennuspiirustusta voi (ainakin etäisesti) ajatella olio-ohjelmoinnin luokkana, kun taas rakennukset ovat sen pohjalta tehtyjä olioita. Luokan nimi kertoo, mikä olio on, joten jos tekisimme luokan rakennuksille, sen nimeksi sopisi Rakennus.

Huomaa, että Javassa on tapana aloittaa luokkien nimet aina isolla kirjaimella.

Määritellään aluksi tyhjä luokka Rakennus, jota lähdemme täydentämään.

class Rakennus {
    // Luokan sisällä määritellään rakenne, jota luokasta 
    // tehdyt oliot vastaavat.
}

Luokasta luodaan ilmentymiä eli olioita käyttämällä avainsanaa new. Tämä varaa muistista oliolle sopivan tilan, valitsee ja suorittaa sopivan muodostajan, ja palauttaa viitteen juuri luotuun olioon. Sijoitamme tämän viitteen muuttujaan, jotta pääsemme olioon sitä kautta käsiksi. Luokan nimi on muuttujan tyyppi ja siten kertoo kääntäjälle, millainen olio muistisijainnissa täytyy olla.

void main() {
    // Lauseke 'new Rakennus()' luo olion ja palauttaa viitteen siihen. 
    // Sijoitamme tämän viitteen muuttujaan 'rakennus'.
    Rakennus rakennus = new Rakennus();
}

huomautus

Viitemuuttuja ja olio ovat kaksi eri asiaa. Viitemuuttuja on kuin nuoli, joka voi osoittaa olioon. Viitemuuttujan ei kuitenkaan ole pakko viitata mihinkään, jolloin sen arvo on null. Olio on vastaavasti mahdollista luoda ilman siihen viittaavaa muuttujaa, mutta jos olioon osoittavia viitteitä ei ole, siihen ei päästä käsiksi ja se merkitään automaattisesti roskaksi. Useampi viitemuuttuja voi viitata samaan olioon, mutta viitemuuttuja voi osoittaa vain yhteen olioon kerrallaan.

Attribuutit

Attribuutti on luokan sisällä, aliohjelmien ulkopuolella määritelty muuttuja, joka edustaa olion ominaisuutta, piirrettä tai tilaa. Luokasta tehdyt oliot sisältävät aina luokassa määritellyt attribuutit. Siinä missä luokka määrittelee, mitä tietoja olioilla voi olla, attribuutit tallentavat kunkin yksittäisen olion konkreettiset arvot. Kuten muuttujat yleensä, attribuutit voivat olla alkeistietotyyppejä tai viitteitä. Niiden nimeämisessä käytetään myös samoja käytänteitä. Huomaa, että metodien sisällä esitellyt muuttujat eivät ole attribuutteja; ne ovat metodin paikallisia eli lokaaleja muuttujia. Lokaalit muuttujat eivät ole osa olion tilaa.

Lisätään nyt muutama attribuutti Rakennus-luokkaamme.

public class Rakennus {
    // Nämä muuttujat ovat olion attribuutteja. Jokaisella rakennuksella
    // on omistaja ja väri, mutta ne eivät välttämättä ole kaikilla 
    // olioilla arvoiltaan samat.
    private String omistaja;

    // Attribuutille voidaan asettaa oletusarvo. Tässä värin oletusarvo on 
    // sininen, jolloin kaikilla tämän luokan pohjalta tehdyillä olioilla
    // on aluksi tämä arvo, ellei sitä muuteta.
    private String väri = "sininen";
}

Attribuutit poikkeavat paikallisista muuttujista siten, että niiden näkyvyyttä voidaan hallita näkyvyysmääreiden avulla. Paikalliset muuttujat ovat olemassa ja nähtävillä vain aliohjelman sisällä sen suorituksen ajan, mutta attribuutit ovat olemassa koko olion eliniän ajan ja näkyvät kaikille muille jäsenille luokan sisällä. Ne voidaan asettaa näkyväksi myös olion ulkopuolelta, joskin tämä on yleisesti ottaen huono idea. Palaamme näkyvyysmääreisiin myöhemmin tässä osassa.

huomautus

Paikallisella muuttujalla voi olla sama nimi kuin attribuutilla, jolloin se peittää attribuutin. Tätä kutsutaan varjostamiseksi (engl. shadowing). Jos attribuutilla ja paikallisella muuttujalla on sama nimi, käytetään lausekkeissa ensisijaisesti lokaalia muuttujaa. Tässäkin tilanteessa olion metodeista voi yhä päästä käsiksi sen attribuuttiin käyttämällä this-viitettä, johon palaamme hyvin pian.

Attribuutille voidaan luokassa antaa oletusarvo, jolloin luokasta luodut oliot saavat sen myös oman attribuuttinsa alkuarvoksi. Jos attribuutilla ei ole oletusarvoa, sen arvo voidaan määrittää olion luomisen yhteydessä, myöhemmin metodien avulla, tai jopa jättää määrittämättä.

public class Rakennus {
    // Olion attribuutti, jolla on oletusarvo.
    private String väri = "sininen";

    public void tulosta() {
        // Tämä lokaali muuttuja peittää saman nimen omaavan attribuutin 
        // aliohjelman sisällä.
        String väri = "punainen"; 

        // Tulostaa "punainen". Tunniste 'väri' viittaa ensisijaisesti 
        // lokaaliin muuttujaan, jos sellainen on näkyvissä.
        IO.println(väri); 

        // Tulostaa "sininen". Voimme viitata olion metodin sisältä sen 
        // attribuuttiin 'this'-viitteen avulla.
        IO.println(this.väri); 
    }
}

Metodit

Luokassa määriteltyjä aliohjelmia kutsutaan metodeiksi. Siinä missä attribuuttia voisi kuvailla niin, että se muodostaa olion sisäisen tilan, metodia voisi kuvailla olion kyvyksi tehdä jotain. Javassa erikoista on se, että kaikki aliohjelmat ovat itse asiassa aina jonkin luokan sisällä. Ehkä tästä syystä Java-kielessä tupataan kutsumaan kaikkia aliohjelmia metodeiksi.

Metodien määrittely ei syntaksiltaan eroa muista aliohjelmista, ja niiden nimeämisessä käytetään myös samanlaisia käytänteitä. Metodeja voidaan myös kuormittaa. Metodien näkyvyyttä luokan ulkopuolelle voidaan hallita attribuuttien tapaan näkyvyysmääreiden avulla.

Kuten yleensä aliohjelmia tehdessä, metodin tulisi suorittaa tehtävä, jota sen nimi kuvastaa. Liian suuret tehtävät on hyvä jakaa pienempiin osiin eli useammaksi metodiksi.

Voidaksemme kutsua olion metodia, meillä täytyy olla olio ja siihen viite. Lisätään nyt Rakennus-luokkaamme pari yksinkertaista metodia olion tilan käsittelyyn ja kutsutaan näitä. Tällaisia metodeja kutsutaan usein saantimetodeiksi.

Rakennus.java
public class Rakennus {
    private String omistaja;
    private String väri;

    // Olion metodi, joka ottaa vastaan merkkijonon ja sijoittaa 
    // sen 'väri'-attribuuttiin.
    public void setVäri(String väri) {
        // Parametrilla ja attribuutilla on sama nimi, joten käytämme 
        // this-viitettä.
        this.väri = väri;
    }

    // Olion metodi, joka palauttaa 'väri'-attribuutin arvon kutsujalle.
    public String getVäri() {
        return this.väri;
    }
}

Voisimme lisätä myös vastaavat metodit luokan toista attribuuttia varten. Keskustelemme myöhemmin tässä osassa siitä, miten olion tilaa voidaan käsitellä hieman järkevämmin, mutta tässä vaiheessa tällaiset yksinkertaiset get- ja set-metodit riittävät.

This-viite

Avainsana this viittaa olioon itseensä. Se toimii viitteenä "tähän olioon", jonka kontekstissa koodia suoritetaan.

Käytimme tämän osan alun esimerkeissä this-viitettä lukeaksemme olion attribuutteja näin:

public class Rakennus {
    private String väri;

    // ...

    public String getVäri() {
        return this.väri;
    }
}

Tämä viite on automaattisesti käytettävissä aina, kun kutsutaan jonkin olion metodia. Metodikutsun yhteydessä this asetetaan osoittamaan metodin suorituksen sisällä siihen olioon, jonka metodia kutsuttiin, eikä sitä voi muuttaa. Viitteen kautta metodi pääsee käsiksi oikean olion tilaan sekä metodeihin. Olion metodien sisällä this-viite on pakko kirjoittaa vain, jos näkyvyysalueella on samanniminen jäsen, kuten lokaali muuttuja. Meidän ei siis tarvitse kirjoittaa aina attribuutin tai metodikutsun eteen this, sillä kääntäjä osaa päätellä sen itse, jos konfliktia ei ole. Joissain ohjelmointikielissä tällaista viitettä kutsutaan myös nimellä self.

Katsotaan muutama esimerkki this-viitteen käytöstä.

Rakennus.java
public class Rakennus {
    private String omistaja;
    private String väri;

    public void setVäri(String väri) {
        // Käytämme tässä this-viitettä, sillä attribuutilla ja parametrilla 
        // on sama nimi. Lokaalia muuttujaa käytettäisiin muuten ensisijaisesti.
        this.väri = väri; 
    }

    public String getVäri() {
        // Tässä this on vapaaehtoinen. Samalla näkyvyysalueella ei ole muita 
        // "väri" nimisiä muuttujia, joten sekaannusta ei tapahdu.
        // this-viitteen käyttö on kuitenkin täysin sallittua.
        return this.väri;
    }
}

Metodien sisällä voimme käyttää this-viitettä kuin muitakin viitemuuttujia. Voimme esimerkiksi välittää sen toiselle aliohjelmalle parametrina. Tämä ei ole tarpeen olion omia metodeja kutsuessa, mutta voimme näin välittää viitteen olioon myös luokan ulkopuolisille aliohjelmille.

Rakennus.java
public class Rakennus {
    private String omistaja;
    private String väri;

    public String getOmistaja() {
        return omistaja;
    }

    public void setOmistaja(String omistaja) {
        this.omistaja = omistaja;
    }

    public String getVäri() {
        return väri;
    }

    public void setVäri(String väri) {
        this.väri = väri;
    }

    // Olion metodi.
    public String kaunista() {
        // Välitetään omistava olio toisen luokan metodille.
        return Kaunistaja.MuotoileKauniisti(this);
    }
}

Muodostaja eli konstruktori

Muodostaja eli konstruktori on luokan erikoismetodi, jota käytetään uuden olion luomisen yhteydessä sen tilan alustamiseen. Muodostajan nimi on aina sama kuin luokan nimi ja se kirjoitetaan isolla alkukirjaimella, mikä poikkeaa muiden metodien nimeämistyylistä. Muodostajalle ei määritetä paluuarvon tyyppiä, vaan muodostaja palauttaa aina viitteen muodostettuun olioon.

Luokassa täytyy olla ainakin yksi muodostaja. Olemme kuitenkin tähän asti luoneet olioita määrittelemättä luokkaan muodostajaa. Tämä onnistuu Javassa, sillä jos luokkaan ei ole tehty yhtään muodostajaa, kääntäjä luo automaattisesti parametrittoman muodostajan. Automaattisesti luotu parametriton muodostaja on toteutukseltaan tyhjä siinä mielessä, että se ei sisällä yhtään lausetta. Parametritonta muodostajaa ei luoda automaattisesti, jos määrittelemme luokkaan yhdenkin muodostajan itse.

Muodostajan nimi on aina sama kuin luokan, joten teemme siis muodostajia lisätessämme metodin kuormitusta erilaisilla parametreilla. Kääntäjä valitsee oikean muodostajan automaattisesti olion luomisen yhteydessä annettujen argumenttien perusteella.

Käytimme aikaisemmassa esimerkissä Rakennus-luokkaa määrittelemättä muodostajaa. Otetaan metodit hetkeksi pois selkeyden vuoksi ja katsotaan, mitä olioita luodessa tapahtuu.

Rakennus.java
public class Rakennus {
    private String omistaja;
    private String väri;
}

Tässä tapauksessa olio muodostetaan automaattista oletusmuodostajaa käyttäen, sillä yhtään muodostajaa ei ole määritelty. Voimme tehdä vastaavan muodostajan itsekin seuraavasti.

Rakennus.java
public class Rakennus {
    private String omistaja;
    private String väri;

    // Parametriton muodostaja, joka vastaa oletusmuodostajaa.
    public Rakennus() {
        // Voisimme täällä alustaa olion tilan jollain tavalla, 
        // asettamalla attribuuteille alkuarvot.
    }
}

Olemme tähän asti muodostaneet olioita ilman kunnollista alustusta. Tämä ei ole hyvä käytäntö, joten korjataan tilanne seuraavaksi.

Rakennukset ovat heti luomisen jälkeen tilassa, jossa niillä ei ole omistajaa tai väriä. Tällä hetkellä vasta luodun olion attribuuttien arvot ovat null. Oliosta ja sen tarkoituksesta riippuen tällaiset arvot voivat olla sallittuja, mutta jos rakennus on olemassa, sillä täytynee olla jokin omistaja ja väri.

Olisi parempi, että olio olisi heti luonnin jälkeen käyttökelpoinen. Voisimme luoda olion suoraan oikeaan tilaan määrittelemällä muodostajan, joka ottaa olion tilan alustamiseen tarvittavat tiedot vastaan parametreina ja alustaa attribuutit oikein.

Lisätään nyt Rakennus-luokalle muodostaja, joka ottaa omistajan ja värin vastaan parametreina ja alustaa näiden avulla olion attribuutit. Näin meidän ei tarvitse asettaa attribuutteja erikseen olion luomisen jälkeen pääohjelmassa. Voimme nyt myös poistaa parametrittoman muodostajan, jotta sitä ei voi enää käyttää luomaan olioita, joilla on virheellinen alkutila.

Rakennus.java
public class Rakennus {
    private String omistaja;
    private String väri;

    public Rakennus(String omistaja, String väri) {
        // Alustetaan olion tila parametreilla. Huomaa tässä this-viitteen 
        // käyttö, sillä parametrien ja attribuuttien nimet ovat samat.
        this.omistaja = omistaja;
        this.väri = väri;
    }
}

Pari huomiota: Emme voi luoda rakennusta yhdellä parametrilla, esimerkiksi Rakennus rakennus2 = new Rakennus("JYU");, sillä määrittelemämme muodostaja tarvitsee kaksi parametria. Emme voi myöskään luoda rakennusta ilman parametreja, esimerkiksi Rakennus rakennus3 = new Rakennus();, koska oletusmuodostajaa ei enää ole.

Lisätään vielä lopuksi hieman erikoisempi muodostaja, joka ottaa vastaan toisen saman luokan olion ja kopioi sen tilan muodostettavalle oliolle. Tällaisesta muodostajasta puhuttaessa käytetään usein nimitystä copy constructor.

Rakennus.java
public class Rakennus {
    private String omistaja;
    private String väri;

    public Rakennus(String omistaja, String väri) {
        this.omistaja = omistaja;
        this.väri = väri;
    }

    // Muodostaja, joka ottaa vastaan toisen saman luokan olion ja 
    // kopioi sen arvot muodostettavalle oliolle.
    public Rakennus(Rakennus kopioitava) {
        this.omistaja = kopioitava.omistaja;
        this.väri = kopioitava.väri;
    }
}

Voimme lisäksi käyttää muodostajassa this-avainsanaa kuin metodia, jos haluamme siirtää muodostamisen toiselle saman luokan muodostajalle. Voimme usein välttää näin turhaa toistoa.

Muutetaan luokkaa nyt niin, että parametriton muodostaja käyttää parametrillista muodostajaa antamaan attribuuteille alkuarvot.

Rakennus.java
public class Rakennus {
    private String omistaja;
    private String väri;

    public Rakennus(String omistaja, String väri) {
        this.omistaja = omistaja;
        this.väri = väri;
    }

    public Rakennus(Rakennus kopioitava) {
        // Siirrämme muodostamisen yllä olevalle muodostajalle sitä 
        // vastaavilla parametreilla
        this(kopioitava.omistaja, kopioitava.väri);
    }
}
Tehtävä 2.1: Kello1 p.

Tee luokka Kello, jolla on attribuutit minuutit ja tunnit kokonaislukuina.

Lisää luokkaan metodit setMinuutit ja setTunnit, jotka ottavat parametrina kelloon minuutit ja tunnit. Minuuttien täytyy olla välillä 0-59, muuten tulostetaan virheilmoitus, ja aika pysyy entisellään. Samoin tuntien täytyy olla välillä 0-23, muuten tulostetaan virheilmoitus.

Tee myös metodi naytaAika, joka tulostaa ajan muodossa "HH:MM".

Voit testata luokan toimintaa valmiin pääohjelman avulla.

Tee tehtävä TIMissä
Tehtävä 2.2: Ajastin1 p.

Tee luokka Ajastin, jolla on attribuutit minuutit ja sekunnit kokonaislukuina.

Lisää luokkaan metodit lisaaMinuutteja ja lisaaSekunteja, jotka ottavat parametrina ajastimeen lisättävät minuutit ja sekunnit. Lisää myös metodi annaMerkkijono, joka antaa ajastimen minuutit ja sekunnit merkkijonona.

Minuutteja voi olla kuinka monta tahansa, mutta sekuntien täytyy olla välillä 0-59. Jos sekunnit ylittävät rajan, muutetaan ne minuuteiksi. Sekunnit voi muuttaa minuuteiksi + sekunneiksi esimerkiksi näin:

int sekunteja = 75; // Esimerkki parametrina tulevasta arvosta

// Tämä antaa this.minuutteja-attribuuttiin _lisättävät_ minuutit
int lisattaviaMinuutteja = (this.sekunteja + sekunteja) / 60; 

// Tämä antaa this.sekunteja-attribuuttiin _sijoitettavat_ sekunnit
int jaljelleJaavatSekunnit = (this.sekunteja + sekunteja) % 60;  

Voit testata luokan toimintaa valmiin pääohjelman avulla.

Tee tehtävä TIMissä
Tehtävä 2.3: Oma luokka1 p.

Valitse jokin tosimaailman esine tai käsite ja tee siitä oma yksinkertainen luokka.

Lisää luokkaan seuraavat:

  • Vähintään kaksi sille sopivaa attribuuttia.
  • Vähintään kaksi metodia, jotka muuttavat tai tarkastelevat olion tilaa jollain tavalla.
  • Vähintään kaksi erilaista muodostajaa, jotka alustavat olion järkevään tilaan. Se mikä on järkevä tila riippuu omasta oliostasi.

Voit käyttää materiaalin esimerkkejä tarvittaessa inspiraationa attribuutteja ja metodeja keksiessä.

Luo olio ja kokeile sen tilan muuttamista pääohjelmassa. Tulosta olion tietoja ennen tilan muuttamista ja sen jälkeen.

Tee tehtävä TIMissä

Static

Luokan jäsenet voidaan määritellä kuuluvaksi olion sijaan luokalle static-määritettä käyttämällä. Tällaisia attribuutteja ja metodeja kutsutaan luokan attribuuteiksi (engl. class attribute) ja luokan metodeiksi (engl. class method).

Sana static voi olla hieman harhaanjohtava; staattisuus ei tässä tarkoita, että nämä luokan jäsenet ovat pysyviä tai muuttumattomia. Käytämme kuitenkin tässä materiaalissa sanaa staattinen kuvaamaan luokan jäseniä.

Siinä missä attribuutit ja metodit liittyvät olioon -- olion attribuutit ja metodit pääsevät olion tilaan käsiksi -- luokan attribuutti ei ole osa minkään olion tilaa ja sillä on vain yksi arvo, joka on jaettu kaikkien luokan olioiden kesken. Jos yksi olio muuttaa oman luokkansa attribuutin arvoa, muutos näkyy kaikissa saman luokan olioissa.

Samalla tavalla voimme ajatella myös luokan metodien olevan jaettu kaikkien luokan olioiden kesken; ne eivät liity mihinkään olioon, eivätkä siten pääse minkään olion tilaan suoraan käsiksi. Oliot voivat kutsua oman luokkansa metodia, siis staattista metodia, mutta tämä kutsu ei mahdollista olion tilan käsittelyä. Koska staattiset metodit eivät liity mihinkään olioon, niiden sisällä ei myöskään voi käyttää this-viitettä.

Voidaksemme kutsua olion metodia, meillä täytyy olla olio ja siihen viite. Staattista luokan metodia sen sijaan voimme kutsua suoraan luokan kautta ilman olion luomista. Staattista metodia on mahdollista kutsua myös olioviitteen kautta, mutta tämäkään ei mahdollista olion tilan tarkastelua metodin sisältä.

Tehdään aluksi luokka, jossa ei ole staattisia jäseniä.

Henkilo.java
public class Henkilo {
    private String etunimi;
    private String sukunimi;

    public Henkilo(String etunimi, String sukunimi) {
        this.etunimi = etunimi;
        this.sukunimi = sukunimi;
    }

    public String annaNimi() {
        return etunimi + " " + sukunimi;
    }
}

Koska annaNimi on olion metodi, meidän täytyy ensin luoda olio. Emme voi kutsua tätä metodia staattisesti luokan kautta, esimerkiksi Henkilo.annaNimi().

Lisätään nyt luokan attribuutti taysiIkaisyydenRaja, joka kuvastaa kaikkien henkilöiden täysi-ikäisyyden rajaa. Lisätään luokkaan myös metodi onkoTaysiIkainen, joka tarkistaa, onko annettu ikä täysi-ikäinen. Huomaa, että kyseinen metodi ei liity mihinkään olioon, vaan se tarkistaa vain annetun iän.

Henkilo.java
public class Henkilo {
    private String etunimi;
    private String sukunimi;
    private static int taysiIkaisyydenRaja = 18;

    public Henkilo(String etunimi, String sukunimi) {
        this.etunimi = etunimi;
        this.sukunimi = sukunimi;
    }

    public String annaNimi() {
        return etunimi + " " + sukunimi;
    }

    public static boolean onkoTaysiIkainen(int ika) {
        return ika >= taysiIkaisyydenRaja;
    }
}

Nyt meillä on luokan metodi onkoTaysiIkainen, jota voimme kutsua ilman oliota. Voisimme kutsua tätä luokan metodia myös olion kautta, mutta se ei ole tarpeen ja kääntäjä varoittaakin siitä.

Staattinen jäsen on hyödyllinen, kun haluamme määritellä jonkin toiminnallisuuden, joka ei liity mihinkään yksittäiseen olioon, mutta liittyy luokkaan yleisesti.

Staattiseen jäseneen pääsee käsiksi myös olion metodin sisältä. Voimme esimerkiksi tarkistaa, onko henkilö täysi-ikäinen.

// Olion metodi voi myös käyttää staattista muuttujaa
public boolean onkoHenkiloItseTaysiIkainen() {
    return this.ika >= taysiIkaisyydenRaja;
}

Selkeyden vuoksi on hyvä käyttää luokan nimeä, kun viitataan staattiseen jäseneen luokan sisälläkin, esimerkiksi Henkilo.taysiIkaisyydenRaja. Tämä auttaa erottamaan staattiset jäsenet olion jäsenistä.

Yllä oleva esimerkki on sikäli varoittava, että täysi-ikäisyyden rajaa pystyy muuttamaan mistä tahansa koodista, joka pääsee käsiksi Henkilo-luokkaan. Tällaisen "globaalin" tilan käyttäminen koodissa on yleensä erittäin huono idea.

Yksi esimerkki staattisesta metodista, jota käytämme usein, on IO.println(). Tämä on IO-luokan staattinen metodi. Sen käyttäminen on helpompaa, kun meidän ei tarvitse joka kerta luoda IO-oliota ja kutsua sen println-metodia.

Tehtävä 2.4: Static1 p.

Muuta esimerkin Henkilo-luokkaa niin, että jokainen luotu henkilö saa automaattisesti oman järjestysnumeron, joka on kokonaisluku. Ensimmäiseksi luodun henkilön numero on 1, seuraava saa numeroksi 2, jne.

Numeroa ei saa antaa henkilölle sen ulkopuolelta esimerkiksi kutsumalla sen metodia.

Toteuta luokkaan myös metodi annaNumero, joka palauttaa tietyn olion numeron kokonaislukuna. Muita metodeja ei tarvitse lisätä.

Vinkki

Tarvitset tässä tehtävässä staattisia luokan jäseniä.

Mieti aluksi seuraavia kysymyksiä:

  • Milloin olio saa numeron?
  • Mistä olio tietää, mikä sen numeron pitäisi olla?
  • Mikä tieto on jaettua olioiden kesken ja mikä on oliokohtaista?
Tee tehtävä TIMissä

Olion elinkaari

Ohjelman ajon aikana luokasta luodaan ilmentymä eli olio. Jotta olioon voidaan päästä käsiksi, luodaan sitä varten viitemuuttuja. Javan kääntäjä tarkistaa käännöksen yhteydessä, että muuttuja on yhteensopiva luodun olion kanssa, ja asettaa viitteen osoittamaan luotuun olioon.

Olion luonnin yhteydessä Java varaa sille sopivan tilan virtuaalikoneensa kekomuistista. Kun olioon ei enää ole yhtään viitettä olemassa, se tuhoutuu. Javassa ohjelmoijan ei tarvitse itse pitää huolta muistin varaamisesta tai vapauttamisesta. Tuhoutuneiden olioiden varaama muisti vapautetaan lopulta Javan automaattisen roskienkeräyksen toimesta.

Käydään vielä olion koko elinkaari läpi esimerkkien avulla. Tarvitsemme olioiden luomista varten ensimmäiseksi luokan. Käytetään esimerkkinä taas Henkilo-luokkaa, mutta lisätään nyt muutama hyvin yksinkertainen metodi olion tilan muuttamiseen.

Henkilo.java
class Henkilo {
    private String etunimi;
    private String sukunimi;

    public Henkilo(String etunimi, String sukunimi) {
        this.etunimi = etunimi;
        this.sukunimi = sukunimi;
    }

    public String annaNimi() {
        return etunimi + " " + sukunimi;
    }

    public void asetaEtunimi(String etunimi) {
        this.etunimi = etunimi;
    }

    public void asetaSukunimi(String sukunimi) {
        this.sukunimi = sukunimi;
    }
}

Pääohjelmassa luodaan nyt olio eri muodostajia käyttäen ja katsotaan samalla viitteiden toimintaa.

Kun olemme luoneet olioita, voimme tarkastella ja muokata niiden tilaa ohjelman suorituksen aikana.

main.java
void main() {
    Henkilo h1 = new Henkilo("Joni", "Mäkinen");
    IO.println(h1.annaNimi());
    h1.asetaSukunimi("Korhonen");
    IO.println(h1.annaNimi());
}

Tarkastellaan lopuksi olioiden elinkaaren loppua, eli niiden tuhoutumista. Kun olioon ei enää ole yhtään viitettä, se merkitään "roskaksi", jonka Javan automaattinen roskienkeräys (engl. garbage collection) voi aikanaan poistaa muistista vapauttaen sitä varten varten varatun tilan.

main.java
void main() {
    // Luomme taas kaksi oliota.
    Henkilo h1 = new Henkilo("Joni", "Mäkinen");
    Henkilo h2 = new Henkilo("Anna", "Korhonen");

    // Muuttuja h1 on tällä hetkellä ainoa viite ensimmäiseen olioon.
    // Jos sijoitamme h1-muuttujaan jonkin muun viitten tai asetamme sen arvoksi
    // null, olio merkitään tuhottavaksi, sillä siihen ei ole enää viitteitä.
    h1 = null;

    // Aliohjelman päättyessä kaikki sen sisällä luodut lokaalit 
    // muuttujat (h1 ja h2) tuhoutuvat. Tässä tapauksessa olioihin ei ole 
    // enää muita viitteitä, joten nekin tuhoutuvat.
}

Emme tällä kurssilla perehdy kovin syvällisesti Javan automaattiseen roskienkeräykseen tai muistin hallintaan. Tämän kurssin kannalta riittää, että tiedämme milloin olio muuttuu roskaksi ja tuhoutuu. Jos haluat tutustua aiheeseen hieman tarkemmin, voit aloittaa lukemalla täältä täältä lisää kekomuistista ja sen varaamisesta sekä täältä Javan roskienkeräyksestä suhteellisen helposti lähestyttävässä muodossa.

Tehtävät

Tehtävä 2.5: Puhelin1 p.

Tee luokka Puhelin, jolla on attribuutit merkki (merkkijono) ja akunVaraus (kokonaisluku, joka kuvaa akun varausta prosentteina väliltä 0-100). Lisää luokkaan seuraavat metodit:

  • lahetaViesti(String henkilo, String viesti): tulostaa viestin muodossa "Lähetetään viesti henkilölle <henkilo>: <viesti>". Viestin lähettäminen vähentää akkua 5 prosenttiyksikköä.
  • soita(String henkilo, int minuutit): tulostaa viestin muodossa "Soitetaan puhelu henkilölle <henkilo>, kesto: <minuutit> minuuttia". Soittaminen vähentää akkua 1 prosenttiyksikköä per minuutti.
  • lataa(int prosentteja): lisää akun varausta annetun määrän, mutta akun varaus ei voi ylittää 100 prosenttia.
  • tulostaTiedot(): tulostaa puhelimen merkin ja akun varauksen muodossa "Puhelimen <merkki> akun varaus on <akku>%".

Korvaa kulmasulkeissa olevat kohdat sopivilla attribuuttien / parametrien arvoilla.

Akun varaus ei voi mennä alle 0%.

Jos akun varaus on 0%, viestiä ei voida lähettää eikä voi soittaa "Akku tyhjä. Viestiä ei voida lähettää / ei voi soittaa.".

Testaa sovellustasi luomalla Puhelin-olio, lähettämällä viesti, soittamalla puhelu, lataamalla akkua ja tulostamalla puhelimen tiedot.

Valinnainen lisätehtävä: Jos akku loppuu kesken

Muokkaa lahetaViesti- ja soita-metodeja siten, että jos akun varaus ei riitä koko viestin lähettämiseen tai puhelun soittamiseen, niin lähetetään/soitetaan niin kauan kuin akku riittää. Jos akku loppuu kesken puhelun / viestin, tulosta kauanko puhelu onnistui / kuinka paljon viestistä saatiin lähetettyä ennen akun loppumista. Esimerkiksi "Akku tyhjä. Sait lähetettyä 60% viestistä." tai "Akku loppui 3 minuutin jälkeen.

Tee tehtävä TIMissä
Tehtävä 2.6: Kirjasto1 p.

Toteuta luokka Kirja, joka pitää kirjaa yksittäisistä kirjoista, mutta myös seuraa kirjaston tilastoja globaalisti. Luo luokalle seuraavat muuttujat:

Oliomuuttujat:

  • String nimi: Kirjan nimi.
  • String kirjoittaja: Kirjan kirjoittaja.
  • boolean onLainassa: Kertoo, onko kyseinen kirja tällä hetkellä lainassa.

Luokkamuuttujat (static):

  • static int kirjojenMaara: Kuinka monta kirjaa on luotu yhteensä.
  • static int lainassaOlevat: Kuinka monta kirjaa on tällä hetkellä lainassa.

Muodostajan tulee ottaa vastaan nimi ja kirjoittaja. Aina kun uusi kirja luodaan, kirjojenMaara-muuttuja kasvaa yhdellä.

Tee oliometodit lainaa() ja palauta: Nämä muuttavat kirjan onLainassa-tilaa ja päivittävät staattisen lainassaOlevat-laskurin.

Tee staattinen metodi tulostaTilastot(), joka tulostaa ruudulle kirjaston tilastot: "Kirjasto sisältää X kirjaa, joista Y on lainassa."

Saat valmiina pääohjelman, jota voit käyttää ohjelmasi testaamiseen

Tee tehtävä TIMissä

Kapselointi

osaamistavoitteet

  • Tiedät, mitä näkyvyysmääreet kuten public ja private tarkoittavat.
  • Ymmärrät, että metodit ovat olioiden pääasiallinen tapa viestiä.
  • Ymmärrät kapseloinnin periaateet ja hyödyt, ja miten olion sisäinen toteutus eroaa sen ulkoisesta käytöstä.
  • Osaat toteuttaa ohjelman, jossa oliot toimivat yhdessä niin, että ne eivät ole riippuvaisia toistensa sisäisestä toteutuksesta.

Autoa ajetaan, vaikka emme tiedä miten moottori toimii

Näkyvyysmääreet

Java tarjoaa kolme pääasiallista näkyvyysmäärettä: public, protected ja private. Näkyvyysmääreet määrittelevät, mistä luokan jäseniin voidaan päästä käsiksi.

Javassa oletuksena luokan jäsenet ovat ns. package-private-näkyvyydellä, mikä tarkoittaa, että ne ovat näkyvissä vain samassa pakkauksessa oleville luokille. Alla olevassa taulukossa on yhteenveto eri näkyvyysmääreiden vaikutuksista; Oletus-rivi viittaa package-private-näkyvyyteen.

LuokkaPakkausAliluokkaMuu maailma
publicKylläKylläKylläKyllä
protectedKylläKylläKylläEi
oletusKylläKylläEiEi
privateKylläEiEiEi

Ensimmäinen sarake ("luokka") ilmaisee, onko luokan oliolla itsellään pääsy määritellyn näkyvyystason jäseneen. Kuten näet, oliolla on aina pääsy omiin jäseniinsä. Toinen sarake ("pakkaus") ilmaisee, onko muilla samassa pakkauksessa olevilla oliolla pääsy jäseneen. Kolmas sarake ("aliluokka") ilmaisee, onko luokasta perityillä aliluokan olioilla, jotka sijaitsevat pakkauksen ulkopuolella, pääsy jäseneen. Neljäs sarake ilmaisee, onko millä tahansa oliolla pääsy jäseneen.

Kun muut ohjelmoijat tai sinä itse käyttävät tekemääsi luokkaa, näkyvyysmääreet auttavat varmistamaan, että luokkaasi käytetään sillä tavalla, jolla olet suunnitellut sen käytettävän. Pääsääntö on, että ohjelmoijan tulisi käyttää mahdollisimman rajoittavaa näkyvyysmäärettä, ellei ole erityistä syytä käyttää jotain muuta. Tämä auttaa suojaamaan luokan sisäistä tilaa ja estämään tahalliset tai tahattomat väärinkäytökset luokan jäseniin. Erityisesti julkisten attribuuttien kohdalla tulisi pohtia tarkkaan, onko niille todella tarvetta, sillä ne altistavat luokan sisäisen tilan suoraan ulkopuolisille. Tällä opintojaksolla pyrimme suunnittelemaan ohjelmat niin, ettei julkisia attribuutteja -- poislukien vakiot -- tarvita.

huomautus

Tässä materiaalissa saatetaan hetkittäin käyttää esimerkinomaisesti julkisia attribuutteja. Tämä voi auttaa havainnollistamaan joitakin kohtia tiiviisti, mutta sitä ei suositella tuotantokoodissa.

Attribuutille tai metodille voi antaa näkyvyysmääreen lisäämällä sen esittelyriville. Luokalle voidaan myös asettaa näkyvyysmääre.

Henkilo.java
class Henkilo {
    // Näkyvyysmääre 'private' piilottaa attribuutin niin, että sitä ei voi 
    // tarkastella luokan ulkopuolelta.
    private String etunimi;
    private String sukunimi;

    // Näkyvyysmääre 'public' mahdollistaa metodin kutsumisen luokan ulkopuolelta.
    public String annaMerkkijono() {
        return etunimi + " " + sukunimi;
    }
}

Kapselointi ja koheesio

Kapselointi (engl. encapsulation) on yksi keskeisimmistä käsitteistä olio-ohjelmoinnissa. Se tarkoittaa luokkien suunnittelua mahdollisimman itsenäiseksi ja modulaariseksi. Jokaisella luokalla on oma tehtävänsä, jota varten tarvittavat tiedot ja toiminnallisuudet kapseloidaan olion sisälle. Osa näistä tiedoista ja toiminnallisuuksista voidaan piilottaa vain olion sisäistä käyttöä varten. Olioiden tilan käsittelyä varten luokka tarjoaa käyttäjälleen tavallisesti julkisista metodeista koostuvan rajapinnan, mikä parantaa ohjelman muokattavuutta ja laajennettavuutta vähentämällä luokan sisäisistä muutoksista johtuvia sivuvaikutuksia.

Yhteen kuuluvan tiedon ja toiminnallisuuden sijoittaminen saman rakenteen sisälle on kapseloinnin ensimmäinen periaate. Olemmekin jo tehneet näin määritellessämme luokkia ja niille sopivia attribuutteja ja metodeja. Luokan koheesio (engl. cohesion) kuvastaa sitä, kuinka hyvin luokan attribuutit ja metodit sopivat yhteen luokan tarkoituksen kanssa. Luokkien suunnittelun tavoitteena on mahdollisimman korkea koheesio; luokan jäsenten tulisi olla sopivia sen tarkoitukseen.

Jos tekisimme esimerkiksi luokan kuvaamaan autoa, ei ehkä olisi kovin järkevää lisätä tähän luokkaan jäseneksi auton omistajan nimeä, osoitetta, puhelinnumeroa, jne. Omistajan tiedot ja niihin liittyvät toiminnallisuudet voi olla parempi laittaa omaan luokkaan.

Tehdään nyt luokka Auto, johon voimme soveltaa kapseloinnin periaatteita. Lisätään aluksi vain attribuutit ja yksinkertaiset muodostajat.

Auto.java
class Auto {
    String malli;
    String valmistenumero;
    double ajetutKilometrit;

    public Auto(String malli, String valmistenumero, double ajetutKilometrit) {
        this.malli = malli;
        this.valmistenumero = valmistenumero;
        this.ajetutKilometrit = ajetutKilometrit;
    }
}

Haluaisimme tallentaa myös tietoja auton eri osista. Moottorista voisimme tallentaa esimerkiksi mallin ja nykyisen kierrosluvun. Renkaista olisi hyvä tietää ainakin malli, rengaspaine ja ehkä myös tyyppi, jolla voimme erottaa kesä- ja talvirenkaat. Nämä voisi lisätä suoraan Auto-luokkaan attribuuttina, mutta attribuuttien määrä kasvaisi aika suureksi, sillä jokaisella renkaalla on omat tietonsa. Jos käyttäisimme taulukoita tai listoja, tarvitsisimme niitäkin useita. Voi myös olla, että autossa ei välttämättä aina ole moottoria kiinni. Renkaatkin on mahdollista ottaa irti tai niiden lukumäärä voisi vaihdella automallien välillä.

Auton ei oikeastaan tarvitse olla tietoinen sen moottorin tai renkaiden sisäisestä toiminnasta, joten meidän on parempi tehdä useampi luokka, joista jokainen on vastuussa omista tiedoistaan ja toiminnoistaan.

Lisätään nyt Moottori ja Rengas -luokat ja määritellään näille sopivia attribuutteja sekä muodostajat.

Moottori.java
class Moottori {
    String malli;
    double kierrosluku;

    public Moottori(String malli, double kierrosluku) {
        this.malli = malli;
        this.kierrosluku = kierrosluku;
    }
}

Lisätään lopuksi moottori ja lista renkaista Auto-luokan attribuuteiksi. Nämä sisältävät viitteet moottori- ja rengasolioihin. Muistetaan, että viitteet eivät oletuksena osoita mihinkään, vaan meidän täytyy luoda myös oliot ja asettaa viitteet osoittamaan niihin. Auto sisältää nyt muiden luokkien olioita, jotka hoitavat omat vastuualueensa auton kokonaisuudessa. Tällaista rakennetta kutsutaan kompositioksi (engl. composition). Yksi etu tässä on se, että auton moottorin tai renkaat voisi vaihtaa helposti uusin asettamalla viiteattribuutit osoittamaan uuteen olioon.

Lisätään myös pieni pääohjelma, jossa voimme luoda yhden auton oletusarvoilla ja tulostaa sen tietoja. Käytämme tässä vielä olion attribuuttien arvoja suoraan, mikä ei ole hyvän tavan mukaista. Teemme pian tämän paremmin.

Moottori.java
class Moottori {
    String malli;
    double kierrosluku;

    public Moottori(String malli, double kierrosluku) {
        this.malli = malli;
        this.kierrosluku = kierrosluku;
    }
}

Kapseloinnin toinen periaate on luokan sisäisen tiedon piilottaminen ja sen käsittelyn rajoittaminen niin, että siihen päästään käsiksi vain tarkkaan määritetyn julkisen rajapinnan kautta. Oliolla voi olla sen tilan tallentamista varten paljon sisäistä tietoa, jonka ei ole tarkoitus olla tarkasteltavissa tai muokattavissa olion ulkopuolelta. Itse asiassa kaikki olion attribuutit tavallisesti piilotetaan, jotta oliota ei olisi mahdollista vahingossa saattaa attribuutteja suoraan muuttamalla virheelliseen tilaan. Oliolla voi myös olla sisäiseen käyttöön apumetodeja, joita ei ole tarkoitus voida kutsua ulkopuolelta.

Olion sisäisen tilan muokkaamista varten luokkaan määritellään julkisia metodeja, joita voidaan kutsua muualta ohjelmasta. Nämä metodit muodostavat edellä mainitun julkisen rajapinnan ja kaikki muutokset olion tilaan tapahtuvat niiden kautta, jolloin virheellisiin muutoksiin voidaan reagoida metodin sisällä sopivalla tavalla.

Se, mitä attribuutteja luokalla on tai miten niitä käsitellään luokan sisällä ovat yleensä toteutusyksityiskohtia, joita luokkaa käyttävän ohjelmoijan ei tarvitse tietää. Tällaisten toteutusyksityiskohtien piilottamisen tavoite on helpottaa ohjelmoijan työtä; kun luokan toteutusyksityiskohdat ovat piilotettuja ja tilaa käsitellään vain julkisen rajapinnan kautta, luokan sisäiseen toimintaan voidaan helpommin tehdä muutoksia niin, että luokan käyttäjä ei edes huomaa niiden tapahtuneen. Tämä on yksi kapseloinnin suurimmista höydyistä.

Lisätään Auto-luokalle muutama metodi yksinkertaista julkista rajapintaa varten. Piilotetaan myös attribuutit, että emme voi muuttaa auton tilaa enää suoraan. Luokan jäsenten näkyvyyttä luokan ja sen olioiden ulkopuolelle voidaan muuttaa käyttämällä niiden esittelyn yhteydessä näkyvyysmääreitä kuten public ja private. Moottori ja Rengas pysyvät vielä samana.

Auto.java
import java.util.ArrayList;

public class Auto {
    private String malli;
    private String valmistenumero;
    private double ajetutKilometrit;
    private Moottori moottori;
    private ArrayList<Rengas> renkaat = new ArrayList<>();

    public Auto(String malli, String valmistenumero, double ajetutKilometrit) {
        this.malli = malli;
        this.valmistenumero = valmistenumero;
        this.ajetutKilometrit = ajetutKilometrit;

        // Lisätään autolle moottori luomalla uusi moottori-olio.
        moottori = new Moottori("M100", 0);

        // Lisätään autolle neljä rengasta luomalla näille oliot ja lisäämällä 
        // viitteet renkaat-listaan.
        renkaat.add(new Rengas("RR", "nastarengas", 100.0));
        renkaat.add(new Rengas("RR", "nastarengas", 100.0));
        renkaat.add(new Rengas("RR", "nastarengas", 100.0));
        renkaat.add(new Rengas("RR", "nastarengas", 100.0));
    }

    public void aja(double kilometrit) {
        if (kilometrit < 0) return;
        this.ajetutKilometrit += kilometrit;
    }

    public void lisaaMoottori(Moottori moottori) {
        this.moottori = moottori;
    }

    public void lisaaRengas(Rengas rengas) {
        this.renkaat.add(rengas);
    }

    public void tulostaTiedot() {
        IO.println("Malli: " + malli);
        IO.println("Valmistenumero: " + valmistenumero);
        IO.println("Ajetut kilometrit: " + ajetutKilometrit);
        
        // Lisätään moottorin ja renkaiden tulostus seuraavaksi.
    }
}

Piilotimme Auto-luokan attribuutit private-näkyvyymääritteellä ja auton tilaa käsitellään nyt yksinkertaisten saantimetodien avulla. Siirsimme myös tulostamisen luokan vastuulle. Meidän tulisi tehdä vielä samanlaiset muutokset Moottori ja Rengas -luokille, jotta voimme käyttää niiden julkisia rajapintoja Auto-luokan sisällä.

Auto.java
import java.util.ArrayList;

public class Auto {
    private String malli;
    private String valmistenumero;
    private double ajetutKilometrit;
    private Moottori moottori;
    private final int maxRenkaat = 4;
    private ArrayList<Rengas> renkaat = new ArrayList<>();

    public Auto(String malli, String valmistenumero, double ajetutKilometrit) {
        this.malli = malli;
        this.valmistenumero = valmistenumero;
        this.ajetutKilometrit = ajetutKilometrit;
    }

    public void aja(double kilometrit) {
        if (kilometrit < 0) return;
        this.ajetutKilometrit += kilometrit;
    }

    public void lisaaMoottori(Moottori moottori) {
        this.moottori = moottori;
    }

    public void lisaaRengas(Rengas rengas) {
        if (renkaat.size() < maxRenkaat) {
            this.renkaat.add(rengas);
        }
    }

    public void tulostaTiedot() {
        IO.println("Auton malli: " + malli);
        IO.println("Auton valmistenumero: " + valmistenumero);
        IO.println("Ajetut kilometrit: " + ajetutKilometrit);
        
        // Välitetään tulostuskomento moottorille, joka tulostaa itse omat tietonsa.
        IO.println();
        IO.println("Moottori:");
        IO.println();
        if (moottori != null) {
            moottori.tulostaTiedot();
        }

        // Välitetään tulostuskomento myös jokaiselle renkaalle.
        IO.println();
        IO.println("Renkaat:");
        for (Rengas rengas : renkaat) {
            IO.println();
            rengas.tulostaTiedot();
        }
    }
}

Nyt Auto-luokkamme ei enää ole riippuvainen Moottori tai Rengas -luokkien sisäisistä toteutusyksityiskohdista.

Vastuu olion tilasta kuuluu oliolle itselleen

Joissain tähän mennessä nähdyissä esimerkeissä attribuutteja piilotettiin, mutta niitä voitiin edelleen muokata lähes suoraan metodin kautta. Tämä ei ole täysin olio-ohjelmoinnin tavoitteiden mukaista; julkisen rajapinnan ei ole tarkoitus olla vain väliaskel attribuutin muuttamisessa. Julkisen rajapinnan idea on välittää oliolle käsky tehdä jotain, jolloin olio suorittaa käskyn itse parhaaksi näkemällään tavalla.

Hyvä esimerkki tästä voisi olla esimerkiksi pelihahmo, jolla on sijaintia varten attribuutteina koordinaatit X ja Y. Sen sijaan, että muuttaisimme pelihahmon sijaintia suoraan yksinkertaisilla setX tai setY -metodeilla, voisimme määritellä hahmolle liiku-metodin, joka ottaa tavoitekoordinaatit vastaan ja sallii pelihahmon itse suorittaa tavoitekoordinaatteihin liikkumisen oman sisäisen toteutuksensa ja rajoitteidensa mukaisesti. Tällöin vastuu liikkumisesta kuuluu pelihahmolle itselleen. Jos pelihahmo ei esimerkiksi kykenisi sillä hetkellä liikkumaan, metodi voi käsitellä tilanteen itse, jolloin metodin kutsujan ei tarvitse ottaa tällaisia erikoistilanteita huomioon.

Todellisuudessa yksinkertaisia saantimetodeja käytetään usein myös tuotantokoodissa, sillä olioiden hyvä suunnittelu vie paljon aikaa ja vaivaa. Joskus voi olla myös ihan perusteltua muuttaa yksinkertaisen attribuutin arvoa suoraan metodin kautta. Metodi tuntuu tässä tapauksessa aika turhalta, mutta sen olemassaolo kuitenkin mahdollistaa sen, että luokan sisäistä toteutusta voidaan muuttaa ilman, että luokkaa käyttävä ohjelmakoodi hajoaa.

Tällä kurssilla emme valitettavasti ehdi käydä oliosuunnittelun teoriaa perusteellisesti läpi. Suosittelemme olio-ohjelmoinnin teorian oppimiseen tämän osan alussa mainittua kurssia.

Olioiden yhteistoiminta

Kerrataan vielä esimerkin avulla olioiden ja niiden yhteistyön suunnittelua tässä osassa opittuja käsitteitä käyttäen. Olioiden hyödyt tulevat paremmin esille, kun alamme rakentamaan ohjelmaan useampia luokkia, jotka tekevät yhteistyötä. Nyt kun olemme myös oppineet kapseloinnin periaatteista, voimme käyttää niitä organisoimaan ohjelmakoodia fiksummin.

Oliot voivat toimia yhdessä eri tavoin. Oliot voivat esimerkiksi sisältää toisia olioita - tai tarkemmin ilmaistuna viitteitä toisiin olioihin. Kun olio koostuu olioista, joista jokainen tuo oman toiminnallisuutensa, tätä kutsutaan usein kompositioksi. Oliot voivat kutsua toistensa metodeja ja näin delegoida tehtäviä toiselle oliolle, jolle tehtävän vastuu kuuluu, tai kommunikoida esimerkiksi tapahtumien yhteydessä. Oliot voivat myös sisältää kokoelmia olioista; esimerkiksi Javan sisäänrakennetut tietorakenteet ovat olioita, jotka sisältävät kokoelman olioista.

Katsotaan esimerkkiä, jossa haluamme mallintaa ohjelmallamme rakennuksia, niissä sijaitsevia tiloja sekä tiloihin tehtäviä varauksia. Jokaisessa rakennuksessa voi olla monta tilaa ja jokaisessa tilassa voi olla monta varausta. Emme välitä vielä tässä esimerkissä siitä, voiko varauksia olla samassa tilassa päällekkäin. Pidetään myös luokat vielä suhteellisen yksinkertaisina.

Aloitetaan määrittelemällä tarvittavat luokat Rakennus, Tila ja Varaus. Tehdään näille myös muodostajat, jotka alustavat olion heti käyttökelpoiseen tilaan.

Rakennus.java
import java.util.*;

public class Rakennus {
    private String nimi;
    private List<Tila> tilat = new ArrayList<>();
    
    public Rakennus(String nimi) { 
        this.nimi = nimi;
    }
}

Lisätään nyt rakennukselle sopivat metodit tilojen lisäämiseen. Haluaisimme löytää oikean tilan myöhemmin sen nimen perusteella, joten rakennuksessa ei saisi olla saman nimisiä tiloja. Tarvitsemme siis myös keinon hakea tila sen nimen perusteella. Tilan lisäämiseen tarvitsemme vain tilan nimen.

Rakennus.java
import java.util.*;

public class Rakennus {
    private String nimi;
    private List<Tila> tilat = new ArrayList<>();
    
    public Rakennus(String nimi) { 
        this.nimi = nimi;
    }

    public Tila haeTila(String tilanNimi) {
        for (Tila tila : tilat) { 
            if (tila.getNimi().equals(tilanNimi)) {
                return tila;
            }
        }
        return null;
    }

    public void lisaaTila(String tilanNimi) { 
        Tila tila = haeTila(tilanNimi); 
        if (tila != null) {
            IO.println("Rakennus " + nimi + " sisältää jo tilan " + tilanNimi);
            return;
        } 
        tilat.add(new Tila(tilanNimi)); 
    }
}

Tilaan pitäisi voida tehdä varauksia. Lisätään tämä ominaisuus, mutta pidetään esimerkki yksinkertaisena tekemällä varaukset tasatunneille. Ohjelman tila voisi kuvastaa esimerkiksi seuraavan päivän tilavarauksia.

Tarvitsemme varauksen tekemiseen varaajan nimen sekä varauksen alkamistunnin ja keston. Lisätään oheinen metodi Tila-luokkaan.

public void lisaaVaraus(String varaaja, int ajankohta, int kesto) { 
    varaukset.add(new Varaus(varaaja, ajankohta, kesto)); 
}

Lisätään vielä mahdollisuus lisätä varauksia rakennuksen kautta niin, että käyttäjän ei tarvitse edes tietää, että Tila-luokka on olemassa. Se miten Rakennus tallentaa tiedot tiloista jää sen toteutusyksityiskohdaksi.

Tarvitsemme varauksen lisäämiseen tilan ja varaajan nimet sekä varauksen alkamistunnin ja keston.

Lisätään myös yksinkertaiset tulosta-metodit kaikkiin luokkiin, jotta voimme nähdä kaikki rakennuksen tilat ja varaukset helposti.

Rakennus.java
import java.util.*;

public class Rakennus {
    private String nimi;
    private List<Tila> tilat = new ArrayList<>();
    
    public Rakennus(String nimi) { 
        this.nimi = nimi;
    }

    public Tila haeTila(String tilanNimi) {
        for (Tila tila : tilat) { 
            if (tila.getNimi().equals(tilanNimi)) {
                return tila;
            }
        }
        return null;
    }

    public void lisaaTila(String tilanNimi) { 
        Tila tila = haeTila(tilanNimi); 
        if (tila != null) {
            IO.println("Rakennus '" + nimi + "' sisältää jo tilan '" + tilanNimi + "'");
            return;
        } 
        tilat.add(new Tila(tilanNimi)); 
    }

    public void lisaaVaraus(String tilanNimi, String varaaja, int ajankohta, int kesto) { 
        Tila tila = haeTila(tilanNimi); 
        if (tila == null) {
            IO.println("Rakennuksessa '" + nimi + "' ei ole tilaa '" + tilanNimi + "'");
            return;
        } 
        tila.lisaaVaraus(varaaja, ajankohta, kesto);
    }

    public void tulosta() {
        IO.println(nimi);
        for (Tila tila : tilat) {
            tila.tulosta();
        }
    }
}

Voimme nyt käyttää Rakennus-luokkaa niin, että meidän ei tarvitse olla tietoisia tilojen tai varausten toiminnasta. Vastaavasti Rakennus ei riipu suoraan siitä, miten Tila tallentaa varausten tietoja.

Tässä vaiheessa meiltä puuttuu vielä osa olio-ohjelmoinnin tärkeimmistä työkaluista; perintä, polymorfismi ja rajapinnat. Tutustumme näihin seuraavassa osassa.

Tehtävät

Tehtävä 2.7: Ovi1 p.

Toteuta luokka Ovi, joka mallintaa ovea, joka voi olla joko lukossa tai auki.

Attribuutit:

  • private boolean lukossa
  • private String avainkoodi

Muodostaja saa oven avainkoodin parametrina: Ovi(String avainkoodi)

Muodostajan pitää asettaa avainkoodi ja asettaa ovi aluksi lukkoon.

Metodit:

  • boolean avaa(String koodi): avaa oven vain, jos koodi on oikein ja ovi on lukossa. Palauttaa true, jos ovi avattiin, muuten false.
  • boolean lukitse(): lukitsee oven vain, jos se on auki. Palauttaa true, jos lukitseminen onnistui, muuten false.
  • boolean vaihdaKoodi(String vanha, String uusi): vaihtaa avainkoodin uuteen, jos vanha koodi on oikein ja ovi on auki. Uusi koodi ei voi olla tyhjä merkkijono. Palauttaa true, jos vaihto onnistui, muuten false.
  • String tila(): palauttaa merkkijonon "Ovi on lukossa" tai "Ovi on auki"

Vain tila() saa tulostaa jotain. Muut metodit eivät.

Kirjoita pääohjelma, jossa

  • luot oven
  • lukitset oven
  • yrität avata ovea väärällä koodilla
  • avaat oven oikealla koodilla
  • yrität avata jo avointa ovea
  • yrität lukita jo lukittua ovea
  • yrität vaihtaa koodia kun ovi on lukossa
  • vaihdat koodin väärällä vanhalla koodilla
  • vaihdat koodin oikealla vanhalla koodilla
  • tulostat oven tilan

Tee tehtävä TIMissä
Tehtävä 2.8: Säästölipas1 p.

Toteuta luokka Saastolipas, jonka tarkoituksena on säilyttää rahaa.

Attribuutit:

  • private double saldo: Säästölippaan nykyinen rahamäärä.
  • private String omistaja: Lippaan omistajan nimi.
  • private final String SALASANA: Salasana, joka tarvitaan rahojen nostamiseen.

Konstruktori: Ottaa vastaan omistaja ja SALASANA -arvot. Asettaa alkusaldoksi 0.0.

Metodit:

  • public void talleta(double maara): Lisää rahaa vain, jos maara on positiivinen.
  • public double nosta(double maara, String annettuSalasana): Tarkistaa, onko annettuSalasana oikein. Tarkistaa, onko lippaassa tarpeeksi rahaa. Jos molemmat täyttyvät, vähentää saldon ja palauttaa nostetun määrän. Muuten palauttaa 0.0 ja tulostaa virheilmoituksen.
  • public void tulostaSaldo(): Tulostaa "Hei <omistaja>, lippaasi saldo on <saldo> euroa.".

Korvaa kulmasulkeissa olevat kohdat sopivilla attribuuttien / parametrien arvoilla.

Tee pääohjelma, jossa luot Saastolipas-olion ja testaat sen toiminnallisuutta eri tilanteissa, kuten tallettaminen, onnistunut nosto ja epäonnistuneet nostot (väärä salasana, liian suuri nostettava määrä).

Tee tehtävä TIMissä
Bonus: Tehtävä 2.9: Sähköverkko1 p.

Tässä tehtävässä rakennat järjestelmän, joka valvoo rakennusten sähkönkulutusta ja estää sulakkeiden palamisen. Tehtävä koostuu vaiheista.

Vaihe 1: Sähkölaite

Tee luokka Sahkolaite. Laitteilla on kaksi muuttumatonta ominaisuutta: nimi ja virrankulutus ampeereina.

Lisää attribuutit: private final String NIMI ja private final double VIRTA. Oletetaan, että virrankulutus on aina positiivinen luku.

Tee konstruktori, joka asettaa nämä arvot.

Lisää private boolean kytketty, joka kertoo, onko laite päällä.

Tee metodit kytke() ja irrota(), jotka muuttavat kytketty-muuttujan tilaa. Tee myös getVirta()-metodi, joka palauttaa laitteen virrankulutuksen, vastaavasti getNimi().

Kokeile luokkaasi pääohjelmassa luomalla muutama laite ja kytkemällä niitä päälle ja pois.

Vaihe 2: Keskus ja oliolista

Luo Sahkokeskus-luokka. Tämän luokan tarkoituksena on hallita sähkölaitteita.

Lisää luokkaan attribuutti final double SULAKKEEN_KOKO. Sulakkeen koko voi olla esimerkiksi 16 ampeeria tai 35 ampeeria. Lisää myös private boolean sulakePaalla, joka kertoo, onko sulake ehjä (true) vai palanut (false). Aluksi sulake on päällä.

Luo List<Sahkolaite> paallaOlevatLaitteet (käytä ArrayListia).

Tee metodi double laskeNykyinenVirta(), joka käy listan läpi ja laskee laitteiden VIRTA-arvojen summan.

Vaihe 3: Valvova logiikka ja tilan hallinta

Keskuksen on päätettävä, saako laitteen kytkeä päälle.

Tee metodi boolean kytke(Sahkolaite laite). Metodin tulee tarkistaa, onko nykyinen virta + uuden laitteen virta <= sulakekoko. Jos on, laite lisätään listaan ja sille kutsutaan laite.kytke(). Muussa tapauksessa sulake palaa: aseta sulakePaalla = false, sammuta kaikki listan laitteet (irrota()) ja tyhjennä päälläolevien laitteiden lista.

Tee myös metodi void irrota(Sahkolaite laite), joka poistaa laitteen listasta ja kutsuu laite.irrota().

Vaihe 4: Globaali seuranta

Sähköyhtiö haluaa seurata kaikkien keskusten tilannetta.

Lisää Sahkokeskus-luokkaan static double kokonaisKulutusValtakunnassa.

Päivitä tätä muuttujaa aina, kun jokin laite missä tahansa keskuksessa kytketään päälle, irrotetaan sähkökeskuksesta tai kun sulake palaa.

Lisää static-metodi tulostaValtakunnanTilanne(), joka tulostaa kokonaiskulutuksen.

Voit testata ohjelmaasi TIMissä olevalla valmiilla pääohjelmalla, tai voit kirjoittaa oman testiohjelmasi.

Tee tehtävä TIMissä
Bonus: Tehtävä 2.10: Varaukset1 p.

Muokkaa esimerkin Rakennus, Tila ja Varaus -luokat sisältävää ohjelmaa niin, että ohjelma ei anna lisätä samaan tilaan päällekkäisiä varauksia. Jos tilassa on jo varaus, joka olisi päällekkäin uuden varauksen kanssa, uutta varausta ei luoda.

Lisää myös tarkistukset, jotka estävät virheellisten varausten luomisen. Varauksen keston täytyy olla vähintään 1 tunti ja alkuajankohdan täytyy olla välillä 0-23.

Virhetilanteet voi tässä tehtävässä käsitellä tulostamalla virheilmoituksen.

Ennen tehtävän aloittamista kannattaa miettiä hetki, mitkä vastuut kuuluvat millekin oliolle.

Voit testata ohjelman toimintaa valmiiksi annetulla pääohjelmalla.

Tee tehtävä TIMissä

Osan kaikki tehtävät

huomautus

Jos palautat tehtävät ennen osan takarajaa (ma 26.1.2026 klo 11:59 (keskipäivä)), voit saada DL-BONUS-pisteitä harjoitustehtäviin. Lue lisää suorittaminen-sivulta.

Tehtävä 2.1: Kello1 p.

Tee luokka Kello, jolla on attribuutit minuutit ja tunnit kokonaislukuina.

Lisää luokkaan metodit setMinuutit ja setTunnit, jotka ottavat parametrina kelloon minuutit ja tunnit. Minuuttien täytyy olla välillä 0-59, muuten tulostetaan virheilmoitus, ja aika pysyy entisellään. Samoin tuntien täytyy olla välillä 0-23, muuten tulostetaan virheilmoitus.

Tee myös metodi naytaAika, joka tulostaa ajan muodossa "HH:MM".

Voit testata luokan toimintaa valmiin pääohjelman avulla.

Tee tehtävä TIMissä
Tehtävä 2.2: Ajastin1 p.

Tee luokka Ajastin, jolla on attribuutit minuutit ja sekunnit kokonaislukuina.

Lisää luokkaan metodit lisaaMinuutteja ja lisaaSekunteja, jotka ottavat parametrina ajastimeen lisättävät minuutit ja sekunnit. Lisää myös metodi annaMerkkijono, joka antaa ajastimen minuutit ja sekunnit merkkijonona.

Minuutteja voi olla kuinka monta tahansa, mutta sekuntien täytyy olla välillä 0-59. Jos sekunnit ylittävät rajan, muutetaan ne minuuteiksi. Sekunnit voi muuttaa minuuteiksi + sekunneiksi esimerkiksi näin:

int sekunteja = 75; // Esimerkki parametrina tulevasta arvosta

// Tämä antaa this.minuutteja-attribuuttiin _lisättävät_ minuutit
int lisattaviaMinuutteja = (this.sekunteja + sekunteja) / 60; 

// Tämä antaa this.sekunteja-attribuuttiin _sijoitettavat_ sekunnit
int jaljelleJaavatSekunnit = (this.sekunteja + sekunteja) % 60;  

Voit testata luokan toimintaa valmiin pääohjelman avulla.

Tee tehtävä TIMissä
Tehtävä 2.3: Oma luokka1 p.

Valitse jokin tosimaailman esine tai käsite ja tee siitä oma yksinkertainen luokka.

Lisää luokkaan seuraavat:

  • Vähintään kaksi sille sopivaa attribuuttia.
  • Vähintään kaksi metodia, jotka muuttavat tai tarkastelevat olion tilaa jollain tavalla.
  • Vähintään kaksi erilaista muodostajaa, jotka alustavat olion järkevään tilaan. Se mikä on järkevä tila riippuu omasta oliostasi.

Voit käyttää materiaalin esimerkkejä tarvittaessa inspiraationa attribuutteja ja metodeja keksiessä.

Luo olio ja kokeile sen tilan muuttamista pääohjelmassa. Tulosta olion tietoja ennen tilan muuttamista ja sen jälkeen.

Tee tehtävä TIMissä
Tehtävä 2.4: Static1 p.

Muuta esimerkin Henkilo-luokkaa niin, että jokainen luotu henkilö saa automaattisesti oman järjestysnumeron, joka on kokonaisluku. Ensimmäiseksi luodun henkilön numero on 1, seuraava saa numeroksi 2, jne.

Numeroa ei saa antaa henkilölle sen ulkopuolelta esimerkiksi kutsumalla sen metodia.

Toteuta luokkaan myös metodi annaNumero, joka palauttaa tietyn olion numeron kokonaislukuna. Muita metodeja ei tarvitse lisätä.

Vinkki

Tarvitset tässä tehtävässä staattisia luokan jäseniä.

Mieti aluksi seuraavia kysymyksiä:

  • Milloin olio saa numeron?
  • Mistä olio tietää, mikä sen numeron pitäisi olla?
  • Mikä tieto on jaettua olioiden kesken ja mikä on oliokohtaista?
Tee tehtävä TIMissä
Tehtävä 2.5: Puhelin1 p.

Tee luokka Puhelin, jolla on attribuutit merkki (merkkijono) ja akunVaraus (kokonaisluku, joka kuvaa akun varausta prosentteina väliltä 0-100). Lisää luokkaan seuraavat metodit:

  • lahetaViesti(String henkilo, String viesti): tulostaa viestin muodossa "Lähetetään viesti henkilölle <henkilo>: <viesti>". Viestin lähettäminen vähentää akkua 5 prosenttiyksikköä.
  • soita(String henkilo, int minuutit): tulostaa viestin muodossa "Soitetaan puhelu henkilölle <henkilo>, kesto: <minuutit> minuuttia". Soittaminen vähentää akkua 1 prosenttiyksikköä per minuutti.
  • lataa(int prosentteja): lisää akun varausta annetun määrän, mutta akun varaus ei voi ylittää 100 prosenttia.
  • tulostaTiedot(): tulostaa puhelimen merkin ja akun varauksen muodossa "Puhelimen <merkki> akun varaus on <akku>%".

Korvaa kulmasulkeissa olevat kohdat sopivilla attribuuttien / parametrien arvoilla.

Akun varaus ei voi mennä alle 0%.

Jos akun varaus on 0%, viestiä ei voida lähettää eikä voi soittaa "Akku tyhjä. Viestiä ei voida lähettää / ei voi soittaa.".

Testaa sovellustasi luomalla Puhelin-olio, lähettämällä viesti, soittamalla puhelu, lataamalla akkua ja tulostamalla puhelimen tiedot.

Valinnainen lisätehtävä: Jos akku loppuu kesken

Muokkaa lahetaViesti- ja soita-metodeja siten, että jos akun varaus ei riitä koko viestin lähettämiseen tai puhelun soittamiseen, niin lähetetään/soitetaan niin kauan kuin akku riittää. Jos akku loppuu kesken puhelun / viestin, tulosta kauanko puhelu onnistui / kuinka paljon viestistä saatiin lähetettyä ennen akun loppumista. Esimerkiksi "Akku tyhjä. Sait lähetettyä 60% viestistä." tai "Akku loppui 3 minuutin jälkeen.

Tee tehtävä TIMissä
Tehtävä 2.6: Kirjasto1 p.

Toteuta luokka Kirja, joka pitää kirjaa yksittäisistä kirjoista, mutta myös seuraa kirjaston tilastoja globaalisti. Luo luokalle seuraavat muuttujat:

Oliomuuttujat:

  • String nimi: Kirjan nimi.
  • String kirjoittaja: Kirjan kirjoittaja.
  • boolean onLainassa: Kertoo, onko kyseinen kirja tällä hetkellä lainassa.

Luokkamuuttujat (static):

  • static int kirjojenMaara: Kuinka monta kirjaa on luotu yhteensä.
  • static int lainassaOlevat: Kuinka monta kirjaa on tällä hetkellä lainassa.

Muodostajan tulee ottaa vastaan nimi ja kirjoittaja. Aina kun uusi kirja luodaan, kirjojenMaara-muuttuja kasvaa yhdellä.

Tee oliometodit lainaa() ja palauta: Nämä muuttavat kirjan onLainassa-tilaa ja päivittävät staattisen lainassaOlevat-laskurin.

Tee staattinen metodi tulostaTilastot(), joka tulostaa ruudulle kirjaston tilastot: "Kirjasto sisältää X kirjaa, joista Y on lainassa."

Saat valmiina pääohjelman, jota voit käyttää ohjelmasi testaamiseen

Tee tehtävä TIMissä
Tehtävä 2.7: Ovi1 p.

Toteuta luokka Ovi, joka mallintaa ovea, joka voi olla joko lukossa tai auki.

Attribuutit:

  • private boolean lukossa
  • private String avainkoodi

Muodostaja saa oven avainkoodin parametrina: Ovi(String avainkoodi)

Muodostajan pitää asettaa avainkoodi ja asettaa ovi aluksi lukkoon.

Metodit:

  • boolean avaa(String koodi): avaa oven vain, jos koodi on oikein ja ovi on lukossa. Palauttaa true, jos ovi avattiin, muuten false.
  • boolean lukitse(): lukitsee oven vain, jos se on auki. Palauttaa true, jos lukitseminen onnistui, muuten false.
  • boolean vaihdaKoodi(String vanha, String uusi): vaihtaa avainkoodin uuteen, jos vanha koodi on oikein ja ovi on auki. Uusi koodi ei voi olla tyhjä merkkijono. Palauttaa true, jos vaihto onnistui, muuten false.
  • String tila(): palauttaa merkkijonon "Ovi on lukossa" tai "Ovi on auki"

Vain tila() saa tulostaa jotain. Muut metodit eivät.

Kirjoita pääohjelma, jossa

  • luot oven
  • lukitset oven
  • yrität avata ovea väärällä koodilla
  • avaat oven oikealla koodilla
  • yrität avata jo avointa ovea
  • yrität lukita jo lukittua ovea
  • yrität vaihtaa koodia kun ovi on lukossa
  • vaihdat koodin väärällä vanhalla koodilla
  • vaihdat koodin oikealla vanhalla koodilla
  • tulostat oven tilan

Tee tehtävä TIMissä
Tehtävä 2.8: Säästölipas1 p.

Toteuta luokka Saastolipas, jonka tarkoituksena on säilyttää rahaa.

Attribuutit:

  • private double saldo: Säästölippaan nykyinen rahamäärä.
  • private String omistaja: Lippaan omistajan nimi.
  • private final String SALASANA: Salasana, joka tarvitaan rahojen nostamiseen.

Konstruktori: Ottaa vastaan omistaja ja SALASANA -arvot. Asettaa alkusaldoksi 0.0.

Metodit:

  • public void talleta(double maara): Lisää rahaa vain, jos maara on positiivinen.
  • public double nosta(double maara, String annettuSalasana): Tarkistaa, onko annettuSalasana oikein. Tarkistaa, onko lippaassa tarpeeksi rahaa. Jos molemmat täyttyvät, vähentää saldon ja palauttaa nostetun määrän. Muuten palauttaa 0.0 ja tulostaa virheilmoituksen.
  • public void tulostaSaldo(): Tulostaa "Hei <omistaja>, lippaasi saldo on <saldo> euroa.".

Korvaa kulmasulkeissa olevat kohdat sopivilla attribuuttien / parametrien arvoilla.

Tee pääohjelma, jossa luot Saastolipas-olion ja testaat sen toiminnallisuutta eri tilanteissa, kuten tallettaminen, onnistunut nosto ja epäonnistuneet nostot (väärä salasana, liian suuri nostettava määrä).

Tee tehtävä TIMissä
Tehtävä 2.9: Sähköverkko1 p.

Tässä tehtävässä rakennat järjestelmän, joka valvoo rakennusten sähkönkulutusta ja estää sulakkeiden palamisen. Tehtävä koostuu vaiheista.

Vaihe 1: Sähkölaite

Tee luokka Sahkolaite. Laitteilla on kaksi muuttumatonta ominaisuutta: nimi ja virrankulutus ampeereina.

Lisää attribuutit: private final String NIMI ja private final double VIRTA. Oletetaan, että virrankulutus on aina positiivinen luku.

Tee konstruktori, joka asettaa nämä arvot.

Lisää private boolean kytketty, joka kertoo, onko laite päällä.

Tee metodit kytke() ja irrota(), jotka muuttavat kytketty-muuttujan tilaa. Tee myös getVirta()-metodi, joka palauttaa laitteen virrankulutuksen, vastaavasti getNimi().

Kokeile luokkaasi pääohjelmassa luomalla muutama laite ja kytkemällä niitä päälle ja pois.

Vaihe 2: Keskus ja oliolista

Luo Sahkokeskus-luokka. Tämän luokan tarkoituksena on hallita sähkölaitteita.

Lisää luokkaan attribuutti final double SULAKKEEN_KOKO. Sulakkeen koko voi olla esimerkiksi 16 ampeeria tai 35 ampeeria. Lisää myös private boolean sulakePaalla, joka kertoo, onko sulake ehjä (true) vai palanut (false). Aluksi sulake on päällä.

Luo List<Sahkolaite> paallaOlevatLaitteet (käytä ArrayListia).

Tee metodi double laskeNykyinenVirta(), joka käy listan läpi ja laskee laitteiden VIRTA-arvojen summan.

Vaihe 3: Valvova logiikka ja tilan hallinta

Keskuksen on päätettävä, saako laitteen kytkeä päälle.

Tee metodi boolean kytke(Sahkolaite laite). Metodin tulee tarkistaa, onko nykyinen virta + uuden laitteen virta <= sulakekoko. Jos on, laite lisätään listaan ja sille kutsutaan laite.kytke(). Muussa tapauksessa sulake palaa: aseta sulakePaalla = false, sammuta kaikki listan laitteet (irrota()) ja tyhjennä päälläolevien laitteiden lista.

Tee myös metodi void irrota(Sahkolaite laite), joka poistaa laitteen listasta ja kutsuu laite.irrota().

Vaihe 4: Globaali seuranta

Sähköyhtiö haluaa seurata kaikkien keskusten tilannetta.

Lisää Sahkokeskus-luokkaan static double kokonaisKulutusValtakunnassa.

Päivitä tätä muuttujaa aina, kun jokin laite missä tahansa keskuksessa kytketään päälle, irrotetaan sähkökeskuksesta tai kun sulake palaa.

Lisää static-metodi tulostaValtakunnanTilanne(), joka tulostaa kokonaiskulutuksen.

Voit testata ohjelmaasi TIMissä olevalla valmiilla pääohjelmalla, tai voit kirjoittaa oman testiohjelmasi.

Tee tehtävä TIMissä
Bonus: Tehtävä 2.10: Varaukset1 p.

Muokkaa esimerkin Rakennus, Tila ja Varaus -luokat sisältävää ohjelmaa niin, että ohjelma ei anna lisätä samaan tilaan päällekkäisiä varauksia. Jos tilassa on jo varaus, joka olisi päällekkäin uuden varauksen kanssa, uutta varausta ei luoda.

Lisää myös tarkistukset, jotka estävät virheellisten varausten luomisen. Varauksen keston täytyy olla vähintään 1 tunti ja alkuajankohdan täytyy olla välillä 0-23.

Virhetilanteet voi tässä tehtävässä käsitellä tulostamalla virheilmoituksen.

Ennen tehtävän aloittamista kannattaa miettiä hetki, mitkä vastuut kuuluvat millekin oliolle.

Voit testata ohjelman toimintaa valmiiksi annetulla pääohjelmalla.

Tee tehtävä TIMissä

Perintä ja polymorfismi

osaamistavoitteet

  • Osaat tehdä luokkahierarkian käyttämällä perintää
  • Osaat korvata kantaluokan toiminnallisuuden
  • Ymmärrät konkreettisten ja abstraktien luokkien eron
  • Ymmärrät, miten polymorfismi mahdollistaa erilaisten luokkien käsittelyn yhtenäisesti

Perintä

osaamistavoitteet

  • Ymmärrät perinnän käsitteen olio-ohjelmoinnissa ja osaat periä luokkia Javassa
  • Osaat luoda yksinkertaisen luokkahierarkian, jossa luokka perii toisen luokan
  • Ymmärrät, että kaikki Javan luokat perivät Object-luokan

Perintä tarkoittaa mekanismia, jossa luokka sisällyttää itseensä toisen luokan ominaisuudet (attribuutit) ja toiminnallisuudet (metodit). Tämä mahdollistaa koodin uudelleenkäytön ja luokkien välisen hierarkian luomisen.

Käytännössä olioilla on usein yhteisiä piirteitä. Yksi tapa näiden yhteisten piirteiden käsittelemiseen siten, että koodia ei tarvitse toistaa, on perintä.

Opintotietojärjestelmä

Otetaan keksitty esimerkki henkilötietojärjestelmästä: Olli Opiskelija, Maija Opettaja ja Satu Sihteeri voisivat kaikki olla olioita kuvitteellisessa Kisu-opintotietojärjestelmässä. Kaikilla näillä on kaikille käyttäjille tyypillisiä ominaisuuksia, kuten nimi ja käyttäjätunnus. Jokaisen pitäisi myös päästä kirjautumaan sisään järjestelmään ja sieltä ulos.

Kullakin käyttäjällä on kuitenkin myös omia erityispiirteitään: Opiskelijalla voisi olla lista kursseista, joille hän on ilmoittautunut, sekä hänen suorittamansa opintopisteet. Opettajalla on kurssit, joita hän opettaa sekä tehtävänimike, mutta hänellä ei ole opintopisteitä. Sihteeri on vastuussa opintosuoritusten kirjaamisesta ja tutkinnon antamisesta, mutta hänellä ei ole opiskelijanumeroa tai opetettavia kursseja.

Lähdetään kuitenkin aluksi liikkeelle pienesti. Alla on Opiskelija- ja Opettaja-luokat, joihin olemme tehneet pari attribuuttia ja metodia. Tutki näitä luokkia.

varoitus

Useista alla olevista esimerkeistä puuttuu dokumentaatiokommentit, tai kommentit voivat olla osittain puutteellisia. Tämä on tietoinen valinta, sillä niiden lisääminen pidentäisi esimerkkikoodia, ja siten hankaloittaisi lukemista. Tässä materiaalissa koodia "dokumentoidaan" ja sitä selitetään ympäröivällä tekstillä, joten tämän materiaalin esitystavassa dokumentaatio ei ole välttämättä tarpeen.

varoitus

Alla oleva esimerkki on tarkoitettu havainnollistamaan perinnän syntaksia, eikä siitä syystä noudata (vielä) parhaita käytäntöjä. Erityisesti nimen asettaminen sellaisenaan attribuutin arvoksi käyttämällä julkista setNimi-metodia rikkoo tiedon piilottamisen periaatetta (ks. Luku 2.3). Korjaamme tämän asian kuitenkin esimerkin edetessä.

Opiskelija.java
import java.util.ArrayList;

class Opiskelija {
    String nimi;
    List<String> kaynnissaOlevatKurssit;

    public Opiskelija() {
        this.kaynnissaOlevatKurssit = new ArrayList<>();
    }

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

    String getNimi() {
        return this.nimi;
    }

    void naytaOpintoOhjelma() {
        String kurssit = String.join(", ", kaynnissaOlevatKurssit);
        IO.println(nimi + " opiskelee kursseilla: " + kurssit);
    }

    void ilmoittauduKurssille(String kurssi) {
        IO.println(this.nimi + " ilmoittautui kurssille: " + kurssi);
        kaynnissaOlevatKurssit.add(kurssi);
    }
}

Huomaat, että kummassakin luokassa on samat attribuutti nimi sekä metodit getNimi ja setNimi. Näiden luokkien välillä on toki myös eroja, mutta nimen omaan toisto on ongelmallista, koska:

  • jokaisessa luokassa on määriteltävä samat ominaisuudet ja toiminnot uudelleen,
  • jos haluamme muuttaa jotain yhteistä ominaisuutta tai toimintoa, meidän täytyy tehdä se kolmessa eri paikassa,
  • uuden luokan lisääminen, jolla on samat ominaisuudet, vaatii saman koodin kopioimisen uudelleen taas uuteen paikkaan.

Jos nyt haluaisimme muuttaa esimerkiksi nimi-attribuuttia niin, että etunimi ja sukunimi tallennetaan erikseen kahteen attribuuttiin, meidän pitäisi tehdä tämä muutos kaikissa näissä luokissa. Tämä lisää virheiden mahdollisuutta ja tekee koodin ylläpidosta hyvin hankalaa. Yksi ohjelmistokehityksen periaatteista onkin älä toista itseäsi (Don't Repeat Yourself, lyh. DRY; ks. Wikipedia).

Luokkahierarkia

Toistamisen välttämiseksi voimme luoda yliluokan (engl. superclass) nimeltä Henkilo, joka sisältää kaikki yhteiset ominaisuudet ja toiminnot. Sitten alaluokat (engl. subclass) Opiskelija ja Opettaja voivat periä Henkilo-luokan, jolloin ne saavat automaattisesti kaikki sen määrittelemät ominaisuudet ja metodit. Näin voimme lisätä vain erityispiirteet kuhunkin aliluokkaan ilman koodin toistamista.

Toteutetaan nyt uusi Henkilo-luokka, ja muutetaan Opiskelija- ja Opettaja-luokkia niin, että ne perivät Henkilo-luokan. Javassa perintä toteutetaan käyttämällä extends-avainsanaa. Esimerkiksi class Opiskelija extends Henkilo tarkoittaa, että Opiskelija-luokka perii Henkilo-luokan. Tehdään tämä muutos koodissamme.

Henkilo.java
public class Henkilo {
    String nimi;

    String getNimi() {
        return this.nimi;
    }

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

Huomaa, että Opiskelija- ja Opettaja-luokat eivät enää määrittele nimi-attribuuttia tai getNimi- ja setNimi-metodeja, koska ne perivät nämä Henkilo-luokasta, eikä sitä koodia enää tarvitse uudelleen kirjoittaa. Tämä tekee koodista huomattavasti siistimpää ja helpommin ylläpidettävää. Perinnällä siis määritetään yksi yliluokka (tässä Henkilo) ja aliluokka tai aliluokat (tässä Opiskelija ja Opettaja), jotka laajentavat (engl. extend) Henkilo-luokan lisätiedoilla ja -toiminnallisuuksilla opiskelijasta ja opettajasta.

Toisin sanoen, Opiskelija ja Opettaja saavat itselleen samat (ei-yksityiset) attribuutit ja (ei-yksityiset) metodit kuin Henkilo-luokka ilman sitä, että ne pitää erikseen määritellä aliluokissa.

Kirjallisuudessa käytetään joskus yliluokka- ja aliluokka-termeistä myös nimityksiä kantaluokka (engl. base class) ja johdettu luokka (engl. derived class). Myös muita nimityksiä on käytössä. Näillä termeillä tarkoitetaan samaa asiaa kuin yliluokka- ja aliluokka-termeillä. Käytämme tässä materiaalissa kuitenkin pääasiassa yliluokka- ja aliluokka-termejä.

Periytymistä voidaan kuvata alla olevan tapaisella kuviolla. Tässä Henkilo on yliluokka (superclass) ja Opiskelija ja Opettaja ovat aliluokkia (subclasses), jotka perivät Henkilo-luokan ominaisuudet ja metodit.

Iso C-kirjain tarkoittaa, että kyseessä on luokka. Nuoli ylöspäin tarkoittaa perintää, eli aliluokka (nuolen tyvessä) perii yliluokan (nuolen kärjessä). Yllä oleva kuvio on tehty mukaillen niin sanottua UML-kuvauskieltä (engl. Unified Modelling Language).

Muodostajat ja super-avainsana

Yliluokan muodostajia voidaan kutsua aliluokista käyttäen super-avainsanaa. Katsotaan esimerkin kautta, missä tällainen kutsu on tarpeen.

Yllä olevassa esimerkissämme on pari ongelmaa. Henkilo-luokassa ei ole muodostajaa, jolloin nimen alustaminen tapahtuu setNimi-metodin kautta. Tästä seuraa, että Henkilo-olion muodostamisen jälkeen nimi-attribuutti on aina null, ennen kuin se erikseen asetetaan. Tämä ei ole hyvä käytäntö kahdestakaan syystä: Ensinnäkin, on parempi, että olio on käyttökelpoinen heti luomisen jälkeen ilman, että erillisiä asettamisia tarvitsee tehdä. Toiseksi, nimen asettaminen julkisen setNimi-metodin kautta ei ole hyvä idea, sillä se rikkoo tiedon kapseloinnin periaatetta.

Vaikka nimen muuttaminen toki pitäisikin tietyissä tilanteissa olla opintotietojärjestelmässä mahdollista, sen asettaminen julkisen metodin kautta, eli niin, että mikä tahansa olio voisi kutsua minkä tahansa Henkilo-olion metodia nimen muuttamiseksi, ei pitäisi olla sallittua, vaan pitäisi tapahtua huomattavasti hallitumman prosessin kautta.

Korjataan tilanne. Asetetaan aluksi nimi-attribuutti yksityiseksi Henkilo-luokassa. Lisätään sitten muodostaja, joka ottaa nimi-parametrin, ja alustaa attribuutin arvon vastaavasti. Tämän jälkeen voimme poistaa setNimi-metodin kokonaan, jolloin nimen asettaminen onnistuu vain muodostajan kautta. Niinpä nimen muuttaminen ei enää onnistu, mutta tämä sopii meille tässä vaiheessa.

Muutetaan olioiden rakentelu pääohjelmassa vastaamaan tätä uutta muodostajaa.

Henkilo.java
class Henkilo {

    // HIGHLIGHT_GREEN_BEGIN
    private String nimi;

    public Henkilo(String nimi) {
        this.nimi = nimi;
    }
    // HIGHLIGHT_GREEN_END

    // HIGHLIGHT_RED_BEGIN
    void setNimi(String nimi) {
        this.nimi = nimi;
    }
    // HIGHLIGHT_RED_END

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

Nyt koska Henkilo-luokassa on määritelty muodostaja, joka ottaa parametreja, Java ei enää luo oletusmuodostajaa—siis sellaista, jossa ei ole parametreja—automaattisesti. Tämä aiheuttaa käännösvirheen—valitettavasti hieman kryptisen sellaisen.

java: constructor Opiskelija in class Opiskelija cannot be applied to given types;
  required: no arguments
  found:    java.lang.String
  reason: actual and formal argument lists differ in length

Virheilmoituksen pointti on, että Opiskelija-olion muodostaja ei vastaa sitä, miten yritämme luoda olion pääohjelmassa.

Tässä tuleekin tärkeä huomio: Luokat eivät peri muodostajia yliluokiltaan. Esimerkiksi Opiskelija-luokka ei peri Henkilo-luokan muodostajia, vaan ne täytyy määritellä erikseen jokaisessa aliluokassa. Tehdään Opiskelija ja Opettaja-luokkiin muodostajat vastaamaan tätä vaatimusta. Esimerkiksi Opiskelija-luokassa muodostajan alku näyttäisi tältä.

class Opiskelija extends Henkilo {
    public Opiskelija(String nimi) {
        // Muodostajan runko tulee tähän
    }
}

Toisaalta nyt kun määrittelimme nimi-attribuutin yksityiseksi, emme voi myöskään asettaa niitä perivästä luokasta käsin, esimerkiksi seuraavasti.

class Opiskelija extends Henkilo {
    public Opiskelija(String nimi) {
        // HIGHLIGHT_YELLOW_BEGIN
        this.nimi = nimi;
        // HIGHLIGHT_YELLOW_END
    }
}
Opiskelija.java:6:5
java: constructor Henkilo in class Henkilo cannot be applied to given types;
  required: java.lang.String
  found:    no arguments
  reason: actual and formal argument lists differ in length

Opiskelija.java:8:13
java: nimi has private access in Henkilo

Ensimmäinen virhe liittyy siihen, että Henkilo-luokassa ei ole ei-parametrista muodostajaa. Korjaamme tämän hieman myöhemmin. Jälkimmäinen virhe on tämän hetkinen ongelmamme: nimi-attribuutti on yksityinen, joten emme voi asettaa sitä suoraan perivästä luokasta käsin.

Koska poistimme setNimi-metodin, niin ainoa tapa asettaa nimen arvo on tehdä se kutsumalla aliluokasta muodostajasta käsin yliluokan muodostajaa ja välittämällä tuossa kutsussa tarvittavat parametrit. Tämä kutsuminen toteutetaan super-avainsanaa. Tehdään tämä muutos kumpaankin aliluokkaan. Muutetaan samalla myös loputkin attribuutit yksityisiksi.

import java.util.ArrayList;
class Opiskelija extends Henkilo {
    // HIGHLIGHT_GREEN_BEGIN
    private ArrayList<String> kaynnissaOlevatKurssit;
    // HIGHLIGHT_GREEN_END

    // HIGHLIGHT_GREEN_BEGIN
    public Opiskelija(String nimi) {
        super(nimi);
        kaynnissaOlevatKurssit = new ArrayList<>();
    }
    // HIGHLIGHT_GREEN_END
    // ...
}

Huomaa, että järjestys on oltava nimen omaan tämä: super-kutsu tulee ensimmäisenä muodostajan rungossa. Vasta sen jälkeen voidaan tehdä muita alustuksia.

Tee vastaava muutos myös Opettaja-luokkaan.

Tämän jälkeen ohjelma ei kuitenkaan vielä käänny, koska perivissä luokissa emme edelleenkään pääse käsiksi yliluokan yksityiseen nimi-attribuuttiin.

class Opiskelija extends Henkilo {
    void naytaOpintosuunnitelma() {
        String kurssit = String.join(", ", kaynnissaOlevatKurssit);
        // HIGHLIGHT_YELLOW_BEGIN
        IO.println(this.nimi + " opiskelee kursseilla: " + kurssit);
        // HIGHLIGHT_YELLOW_END
        // Käännösvirhe: nimi on yksityinen muuttuja
    }
}

Ainoa tapa päästä käsiksi nimi-attribuuttiin on kutsua yliluokan getNimi()-metodia, sillä kyseinen metodi on julkinen. Tehdään tämä muutos kaikkiin kohtiin, joissa nimi-attribuuttiin viitataan suoraan perivissä luokissa.

Henkilo.java
class Henkilo {
    private String nimi;

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

    public String getNimi() {
        return nimi;
    }
}

Ei-parametrista muodostajaa emme tarvitse enää, joten jätämme sen toteuttamatta.

UML-kaavioihin on tapana lisätä perintäsuhteiden lisäksi tietoja luokkien attribuuteista ja metodeista sekä niiden näkyvyydestä. Attribuutit tyyppeineen merkitään luokan nimen alle, ja metodit, myös muodostajat, vastaavasti ihan alimmaiseksi. Perittyjä attribuutteja metodeja, kuten tässä attribuutti nimi ja metodi getNimi(), ei yleensä merkitä kaavioon, paitsi jos ne ylikirjoitetaan aliluokassa—tästä lisää Osassa 3.2. Vihreä pallo tarkoittaa, että kyseessä on julkinen (public) attribuutti/metodi, ja punainen neliö, että kyseessä on yksityinen attribuutti/metodi. Tietojen merkitseminen kaavioon mahdollistaa rakenteiden kuvailemisen ilman, että tarvitsee sanallisesti kuvailla kaikkia yksityiskohtia.

Luokkahierarkia voi olla enemmänkin kuin kaksi tasoa syvä. Meillä voisi olla myös Sihteeri, joka voi kirjata opintosuorituksia. Sihteeri peritään Henkilo-luokasta. Voisimme tehdä myös kahdenlaisia erilaisia opiskelijoita: Tutkinto-opiskelijoita sekä Avoimen yliopiston opiskelijoita. Tutkinto-opiskelijalla voisi olla oma tutkinto-ohjelma, kun taas Avoimen opiskelijalla ei ole tutkinto-ohjelmaa. Toisaalta Avoimen opiskelijan täytyisi suorittaa maksu ennen kuin hän voi saada opintopisteitä.

Luokkahierarkia näyttäisi nyt seuraavalta. Merkitään tähänkin kuvioon attribuutit ja metodit mukaan. Tekstit menevät jo aika pieneksi, joten saat halutessasi kuvan auki uuteen välilehteen klikkaamalla sitä oikealla (tai Ctrl+klikkaamalla macOS:ssa) ja avaamalla kuvan uuteen välilehteen.

Koska nuoli TutkintoOpiskelija-luokasta osoittaa Opiskelija-luokkaan ja sieltä edelleen Henkilo-luokkaan, niin TutkintoOpiskelija-luokasta muodostettu olio perii sekä Opiskelija-luokan ominaisuudet ja metodit, että Henkilo-luokan ominaisuudet ja metodit. Vastaavasti AvoinOpiskelija-luokasta tehty olio perii myös molemmat luokat.

Jätämme esimerkin tässä toteuttamatta, mutta voit halutessasi tutkia valmista koodia täällä.

Huomautetaan vielä, että yliluokan muodostajan kutsuminen super-avainsanalla kutsuu nimen omaan luokan välittömän yliluokan muodostajaa. Luokkarakenteessa "yli hyppiminen", tyyliin super().super() ei ole mahdollista. Niinpä TutkintoOpiskelija-luokan muodostajassa voi kutsua vain Opiskelija-luokan muodostajaa, ei Henkilo-luokan muodostajaa.

Metodit ja super-avainsana

super-avainsanaa voidaan käyttää kutsuttaessa yliluokasta perittyä metodia, kun halutaan eksplisiittisesti viitata yliluokan metodiin.

class Opiskelija extends Henkilo {

    // ...    
    public void naytaKurssit(){
        String kaikkiKurssit = String.join(", ", kaynnissaOlevatKurssit);
        // HIGHLIGHT_RED_BEGIN
        IO.println(this.getNimi() + " opiskelee kursseilla: " + kaikkiKurssit);
        // HIGHLIGHT_RED_END
        // HIGHLIGHT_GREEN_BEGIN
        IO.println(super.getNimi() + " opiskelee kursseilla: " + kaikkiKurssit);
        // HIGHLIGHT_GREEN_END
    }
}

Tässä esimerkissä tällä ei ole mitään vaikutusta, koska getNimi() on sama sekä yliluokassa että aliluokassa. Seuraavassa osassa 3.2 Polymorfismi käsitellään tilannetta, jossa aliluokassa on määritelty saman niminen metodi kuin yliluokassa. Tällöin super-avainsanalla voidaan viitata nimenomaisesti yliluokan metodiin.

Huomautus moniperinnän puuttumisesta

Javassa luokka voi periä vain yhden luokan. Joissain muissa ohjelmointikielissä, kuten C++:ssa, on mahdollista käyttää moniperintää (engl. multiple inheritance), jossa luokka voi periä useamman kuin yhden luokan. Emme tässä mene syvemmälle moniperinnän käsitteeseen, mutta mainittakoon, moniperinnän käyttö voi joissain tilanteissa olla ongelmallista (esim. Timanttiongelma).

Usein kirjallisuudessa mainitaan, että Javassa moniperintää muistuttaa hieman rajapinnan käsite (engl. interface). Kysymys on kuitenkin monin tavoin eri asiasta. Rajapintoja käsitellään osassa 4 (TODO: linkki kuntoon)

Tehtävät

Tehtävä 3.1: Luokkahierarkia, osa 1. 1 p.

Tee luokka Tuote. Tee sille attribuutit nimi (merkkijono) ja hinta (desimaaliluku). Aseta attribuuttien arvot konstruktorissa. Tee myös metodi void tulostaTiedot(), joka tulostaa tuotteen nimen ja hinnan muodossa "Nimi: Hinta €".

Peri Tuote-luokasta luokat Vaate ja Ruoka. Perivien luokkien konstruktoreissa ei tarvitse tehdä muuta kuin kutsua yläluokan konstruktoria oikeilla arvoilla.

Tee nyt ohjelmassasi kaksi erilaista vaatetta ja kaksi erilaista ruokatuotetta ja tulosta niiden tiedot kutsumalla void tulostaTiedot()-metodia.

Tee tehtävä TIMissä
Tehtävä 3.2: Luokkahierarkia, osa 2. 1 p.

Jatketaan edellistä tehtävää. Peri Tuote-luokasta myös luokka Elektroniikka.

Lisää erityispiirteitä kuhunkin aliluokkaan:

  • Vaate: attribuutti String koko (esim. "M", "L", jne.), metodi void sovita(String sovittajanKoko), joka tulostaa, onko vaate sopiva sovittajalle.
  • Elektroniikka: attribuutti int takuuKuukausina (esim. 24), metodi int takuutaJaljella(int kuukausiaKulunut) palauttaa montako kuukautta takuuta on jäljellä (tai 0, jos takuu on umpeutunut).
  • Ruoka: attribuutti String parastaEnnen (esim. "31.01.2026"), ja metodi void syo(), joka tulostaa "Nautit ruoan, jonka viimeinen käyttöpäivä on DD.MM.YYYY." (korvaa DD.MM.YYYY parastaEnnen-arvolla).

Huomaa, että perivien luokkien konstruktoreissa tulee nyt kutsua yläluokan konstruktoria oikeilla arvoilla, sekä asettaa omat attribuutit.

Tehtäväsivulla on valmiiksi annettuna pääohjelma. Käytä sitä luokkiesi testaamiseen. Se ei saa tuottaa käännös- tai ajonaikaisia virheitä. Voit kuitenkin halutessasi lisätä pääohjelmaan omaa koodiasi.

Tee tehtävä TIMissä
Tehtävä 3.3: Luokkahierarkia, osa 3. 1 p.

EDIT 30.1.2026: UML-kaavio korjattu vastaamaan tehtävänantoa

EDIT 29.1.2026: UML päivitetty vastaamaan tehtävänantoa

Laajenna aiemmin tekemääsi verkkokaupan luokkahierarkiaa alla olevan UML-kaavion mukaisesti. Saat kuvan suuremmaksi oikeaklikkaamalla (Windows) tai Control-klikkaamalla (macOS) sitä ja valitsemalla "Avaa kuva uudessa välilehdessä".

Tehtäväsivulla on valmiiksi annettuna pääohjelma, jota voit käyttää luokkiesi testaamiseen.

Avaa tästä ohjelman antama esimerkkituloste.
Kutsutaan perittyjä metodeja:
Talvitakki Dulce & Käppänä: 120.0 €
Ruisleipä Reissurähjä: 2.5 €
Tietokone HighPower: 899.0 €

----------------------------

Kutsutaan omia metodeja:
Testi 1: Sovitetaan M-kokoista käyttäjää:
Sovitetaan vaatetta Talvitakki Dulce & Käppänä...
Ei välttämättä sopivin koko. Sinä olet kokoa M, mutta tämä vaate on L.

Testi 2: Sovitetaan L-kokoista käyttäjää:
Sovitetaan vaatetta Talvitakki Dulce & Käppänä...
Mahtavaa! Koko L istuu sinulle täydellisesti!

Syödään Ruisleipä Reissurähjä.
Parasta ennen oli 20.12.2024, toivottavasti on hyvää.

Takuuta jäljellä: 19 kk.

pHone: 999.99 €
Takuuta puhelimessa jäljellä: 19 kk
Käyttöjärjestelmä: Orange
Yhteystyyppi: 4G
Soitetaan käyttöjärjestelmästä Orange(4G) numeroon 0401122330

Hernepussi: 0.99 €
Sulatit pakastetta Hernepussi 10 minuuttia. Säilytyssuositus on -18 astetta C.
Syödään Hernepussi.
Parasta ennen oli 31.5.2026, toivottavasti on hyvää.

Tehtävän kuvaus sanallisessa muodossa

Tässä on kuvaus uusista luokista ja niiden vaadituista ominaisuuksista. Löydät vastaavat tiedot UML-kaaviosta.

  1. Puhelin (perii Elektroniikka)

    • Lisää attribuutit:
      • private String kayttojarjestelma (esim. "Droid" tai "AiOS")
      • private boolean onko5G
    • Lisää metodit:
      • public void soita(String numero). Metodi tulostaa esimerkiksi: Soitetaan käyttöjärjestelmästä Orange(4G) numeroon 0401122330
      • public void tulostaPuhelimenTiedot(int kuukausiaKulunut). Metodin tulee kutsua ensin perittyä metodia (tulostaTiedot()), ja sitten tulostaa puhelimeen liittyvät lisätiedot (jäljellä olevan takuuajan, käyttöjärjestelmän ja 5G-tuki).
  2. Pakaste (perii Ruoka)

    • Lisää attribuutti:
      • private int lampotilaSuositus (esim. -18)
    • Lisää metodi:
      • private void sulata(int minuutit) (huomaa private-määre)
      • Kun metodia kutsutaan, se tulostaa esimerkiksi: Sulatat pakastetta 10 minuuttia. Säilytyssuositus: -18 C.
    • Lisää metodi:
      • public void sulataJaNauti(int minuutit)
      • Metodi kutsuu ensin sulata(minuutit)-metodia ja sitten perittyä syo()-metodia.

Tee tehtävä TIMissä

Polymorfismi

osaamistavoitteet

  • Ymmärrät polymorfismin perusajatuksen
  • Osaat korvata yliluokan metodin aliluokassa sekä estää korvaamisen final-avainsanalla
  • Osaat kirjoittaa pienen ohjelman, jossa hyödynnetään polymorfismia
  • Tunnistat Object-luokan korvattavia metodeja, kuten toString()

Bändi

Polymorfismi viittaa olio-ohjelmoinnissa kykyyn käsitellä erilaisia olioita yhtenäisellä tavalla. Kun metodia kutsutaan, päätös siitä, mikä metodi tosiasiallisesti suoritetaan, tehdään ajon aikana olion todellisen tyypin perusteella. Polymorfismi mahdollistaa joustavan koodin kirjoittamisen, jossa uusia olioita voidaan lisätä ilman, että olemassa olevaa koodia tarvitsee muuttaa.

Polymorfismi jaetaan yleensä kahteen päätyyppiin: (1) käännösaikaiseen polymorfismiin, jota kutsutaan myös staattiseksi sidonnaksi (engl. static binding) ja (2) ajon aikaiseen polymorfismiin. Käännösaikaisella polymorfismilla tarkoitetaan Javassa aliohjelman kuormitusta (engl. method overloading). Asiaa on käsitelty Ohjelmointi 1 -kurssilla, emmekä sitä tässä käsittele tarkemmin, mutta lyhyesti: aliohjelman kuormitus tarkoittaa sitä, että aliohjelmalla voi olla useita samannimisiä toteutuksia, jotka eroavat toisistaan parametrien lukumäärän, parametrien tyyppien tai aliohjelman paluuarvon perusteella. Lue lisää Ohjelmointi 1 -kurssin materiaalista. (TODO: Linkki)

Tämä kaikki saattaa kuulostaa hitusen abstraktilta, joten otetaanpa konkreettinen esimerkki!

Metodin korvaaminen ja dynaaminen sidonta

Kuvitellaan tilanne, jossa ohjelmassa on erilaisia soittimia: Kitara, Piano ja Rumpusetti. Haluamme, että soittimia voi soittaa. Yksi mahdollisuus olisi kirjoittaa jokaiselle soittimelle oma metodi soittamista varten, kuten:

Kitara kitara = new Kitara();
kitara.soitaKitaraa();
Piano piano = new Piano();
piano.soitaPianoa();
Rumpusetti rumpusetti = new Rumpusetti();
rumpusetti.soitaRumpuja();

Tämä lähestymistapa ei ole laajennettavissa. Jos yrittäisimme käsitellä soittimia yhtenäisenä joukkona, esimerkiksi listana, joutuisimme tekemään hankalia ja virheherkkiä tyyppitarkastuksia vain saadaksemme selville, mitä soittometodia kutsua. Ratkaisu tähän on löytää yhteinen nimittäjä kaikille soittimille. Sekä kitara että piano ovat loppujen lopuksi Soittimia. Luodaan yliluokka Soitin, joka sisältää toiminnon, jonka jokaisen soittimen pitäisi pystyä tekemään: soita().

huomautus

Soitin-luokka määritellään tässä tavallisena luokkana, mutta se voisi olla myös abstrakti luokka, ja se olisikin tässä tapauksessa luontevaa. Koska abstrakti luokka käsitellään vasta osassa 3.3 Abstraktit luokat, määrittelemme Soittimen tässä tavallisena luokkana.

public class Soitin {
    // Kaikilla soittimilla on soita()-metodi
    public void soita() {
        IO.println("Tuntematon soitin soi."); // Oletusarvoinen toteutus
    }
}

Nyt voimme määritellä Kitara- ja Piano-luokat perimään Soitin-luokan.

public class Kitara extends Soitin {
    // ...
}
public class Piano extends Soitin {
    // ...
}

Nyt meillä on kyllä yhtenäinen tapa kutsumista varten, mutta jos kutsuisimme nyt Kitara- tai Piano-olion soita()-metodia, ne molemmat suorittaisivat yliluokan (Soitin) oletustoteutuksen: "Tuntematon soitin soi." Tämä ei riitä! Haluamme, että kukin soitin soi itselleen ominaisella tavalla. Tätä varten aliluokassa voidaan korvata (engl. override) yliluokan soita()-metodi omalla, spesifillä toteutuksellaan.

public class Kitara extends Soitin {
    // Korvataan yliluokan Soitin.soita()
    @Override
    public void soita() {
        IO.println("Kitara soi ja kieliä näppäillään.");
    }
}

public class Piano extends Soitin {
    // Korvataan yliluokan Soitin.soita()
    @Override
    public void soita() {
        IO.println("Piano soi ja koskettimia painellaan.");
    }
}

Perintä antoi meille yhteisen tyypin (Soitin). Metodin korvaaminen antoi meille mahdollisuuden toteuttaa toiminto olioittain. Nyt nämä kaksi mekanismia yhdessä mahdollistavat polymorfismin (nk. monimuotoisuuden). Kun kutsumme metodia yliluokan tyyppiä käyttäen, ohjelma valitsee automaattisesti oikean, korvatun metodin sen perusteella, mikä on olion todellinen tyyppi suoritusajankohdalla.

Tämä mahdollistaa yhtenäisen käsittelyn, jota lähdimme hakemaan:

main.java
void main() {
    ArrayList<Soitin> orkesteri = new ArrayList<>();
    orkesteri.add(new Kitara());
    orkesteri.add(new Piano());
    // Rumpusetti voitaisiin toteuttaa samoin
    // orkesteri.add(new Rumpusetti()); 

    // Kutsumme kaikille samaa soita()-metodia...
    for (Soitin soitin : orkesteri) {
        soitin.soita(); 
    }
}

TODO: Lisää tähän väliin UML-kaavio.

is-a-suhde

Perintäsuhteesta käytetään myös englanninkielistä termiä is-a-suhde. Voimmekin sanoa, Piano on Soitin ja Kitara on Soitin -- nimen omaan näin päin.

Palataan vielä hetkeksi edelliseen opintotietojärjestelmä-esimerkkiimme, siinäkin voimme sanoa että Opiskelija on Henkilo, Opettaja on Henkilo ja Sihteeri on Henkilo. Edelleen, myös TutkintoOpiskelija on Henkilo, koska se perii Opiskelija-luokan, joka puolestaan perii Henkilo-luokan.

Kuten edellä opimme, polymorfismin ansiosta voimme käsitellä Opiskelija, Opettaja ja Sihteeri-olioita koodissamme Henkilo-luokan olioina. Lisätään kaikki tekemämme oliot Henkilo-taulukkoon:

Opiskelija opiskelija = new Opiskelija();
Opettaja opettaja = new Opettaja();
Sihteeri sihteeri = new Sihteeri();

Henkilo[] henkilot = {opiskelija, opettaja, sihteeri};

Jotta esimerkkimme olisi hieman mielekkäämpi, lisätään Henkilo-luokkaan vielä metodit kirjaudu() ja kirjauduUlos(). Kaikki Henkilo-luokan perivät luokat perivät myös nämä metodit.

class Henkilo {
    // HIGHLIGHT_GREEN_BEGIN
    private boolean kirjautunut;
    // HIGHLIGHT_GREEN_END

    public Henkilo(String nimi) {
        // ...
        // HIGHLIGHT_GREEN_BEGIN
        this.kirjautunut = false;
        // HIGHLIGHT_GREEN_END
        // ..
    }

    // HIGHLIGHT_GREEN_BEGIN
    void kirjaudu() {
        this.kirjautunut = true;
        IO.println(this.getNimi() + " kirjautui sisään.");
    }
    void kirjauduUlos() {
        this.kirjautunut = false;
        IO.println(this.getNimi() + " kirjautui ulos.");
    }
    // HIGHLIGHT_GREEN_END
}

Voimme nyt kutsua vaikkapa kirjauduUlos()-metodia kaikille henkilot-taulukon olioille ilman, että meidän tarvitsee tietää tarkasti, minkä tyyppisiä olioita taulukossa on:

for (Henkilo henkilo : henkilot) {
    henkilo.kirjauduUlos();
}

Huomionarvoista on is-a-suhteen suunta; Opettaja ei ole Sihteeri, vaikkakin molemmat perivät Henkilo-luokan.

Lisätään yllä olevaan Opiskelija-esimerkkimme attribuutti boolean opintoOikeusVoimassa, joka ilmaisee, onko opiskelijalla voimassa oleva opinto-oikeus. Jos opinto-oikeus ei ole voimassa, opiskelija ei voi kirjautua järjestelmään. Korvataan kirjaudu()-metodi Opiskelija-luokassa tarkistamaan tämä ehto ennen kirjautumista.

class Opiskelija extends Henkilo {

    // ...

    boolean opintoOikeusVoimassa;

    @Override
    void kirjaudu() {
        if (opintoOikeusVoimassa) {
            super.kirjaudu(); // Kutsutaan yliluokan kirjaudu-metodia
        } else {
            IO.println("Opinto-oikeus ei ole voimassa. Et voi kirjautua.");
        }
    }
}

Muissa Henkilo-luokan aliluokissa, kuten Opettaja ja Sihteeri, kirjaudu()-metodi toimii edelleen alkuperäisellä tavalla, koska niitä ei ole korvattu.

Metodin korvaamiseen liittyy pari sääntöä:

  • Korvaaminen koskee aina hierarkiassa lähintä yliluokan metodia.
  • Kun aliluokan olion metodia kutsutaan, kutsu viittaa aina hierarkiassa lähimpään korvattuun versioon.

Alla oleva koodi havainnollistaa korvaamista ja kutsujen välittymistä luokkahierarkiassa.

main.java
public class KokeillaanKorvaamista {  
  public static void main(String args[]) {  
    C c = new C();  
    c.hei();    // Kutsuu A-luokan hei()-metodia
    c.moikka(); // Kutsuu B-luokan moikka()-metodia
    c.huhhuh(); // Kutsuu C-luokan huhhuh()-metodia
  }  
}  

Tämän esimerkin UML-kaavio näyttäisi seuraavalta.

Esimerkki: Muoto-luokka

Otetaan vielä yksi esimerkki. Tarkastellaan Muoto-luokkaa, jolla on metodi laskeAla().

public class Muoto {
    public double laskeAla() {
        return 0.0;
    }
}

Huomaamme, että laskeAla()-metodin toteutus on vähän hassu. Tämä johtuu siitä, että ei ole oikeastaan mitään ns. yleistä muotoa, vaan Muoto-luokan edustajan tulee aina olla jokin konkreettinen muoto, kuten suorakulmio tai ympyrä, joilla on omat tavat laskea pinta-ala. Kuten jo Soitin-esimerkissä mainitsimme, palaamme tähän dilemmaan osassa 3.3 Abstraktit luokat.

Tehdään nyt aliluokat Suorakulmio ja Ympyra. Koska näiden muotojen pinta-alat ovat luonnollisesti keskenään erilaisia, tulee kummallakin olla oma toteutus laskeAla()-metodille.

Suorakulmio.java
public class Suorakulmio extends Muoto {
    private double leveys;
    private double korkeus;

    public Suorakulmio(double leveys, double korkeus) {
        this.leveys = leveys;
        this.korkeus = korkeus;
    }

    @Override
    public double laskeAla() {
        return leveys * korkeus;
    }
}

Nyt voimme kirjoittaa koodia, joka käsittelee Muoto-olioita ilman, että tarvitsee tietää, onko kyseessä Suorakulmio vai Ympyra.

main.java
public class Main {
    public static void main()
    {
        Muoto muoto1 = new Ympyra(5);
        Muoto muoto2 = new Suorakulmio(5, 7);

        IO.println(muoto1.laskeAla());
        IO.println(muoto2.laskeAla());
    }
}

Miksi polymorfismia tarvitaan?

Polymorfismi mahdollistaa monin tavoin joustavan ja laajennettavan koodin kirjoittamisen. Olio-ohjelmoinnissa polymorfismia tarvitaan erityisesti siksi, että sen avulla voimme tarjota yhtenäisen tavan käsitellä keskenään hyvinkin erilaisia olioita.

Kun useat luokat perivät saman yliluokan (tai toteuttavat saman rajapinnan; paneudumme rajapintoihin osassa 4.1 Rajapinta), ne voidaan käsitellä yhden yhteisen tyypin kautta. Tämä mahdollistaa sen, että ohjelma voi käsitellä joukkoa erilaisia olioita kuten:

  • kaikkia soittimia (Soitin), kuten kitarat, pianot ja rummut
  • kaikkia ajoneuvoja (Ajoneuvo), vaikka ne olisivatkin erilaisia, kuten autoja, polkupyöriä ja lentokoneita
  • kaikkia eläimiä (Elain), kuten koiria, kissoja ja lintuja
  • kaikkia graafiseen käyttöliittymään piirrettäviä komponentteja (Piirrettava), kuten painikkeita, tekstikenttiä ja kuvia
  • kaikkia maksutapoja (Maksutapa), kuten luottokortti, PayPal ja käteinen

Javassa on mahdollista kiertää yhtenäistä käsittelyä tutkimalla instanceof-operaattorin avulla, onko olio tietyn luokan ilmentymä. Esimerkiksi:

if (soitin instanceof Kitara) {
    ((Kitara) soitin).soitaKitaraa();
} else if (soitin instanceof Piano) {
    ((Piano) soitin).soitaPianoa();
}

Tällä kurssilla vältämme instanceof-operaattoria, ellei siihen erikseen ohjeisteta. On nimittäin niin, että instanceof-operaattorin käyttö tarkoittaa varsin usein sitä, ettei perintää ja polymorfismia ole hyödynnetty optimaalisella tavalla, josta seuraa yllä olevan esimerkin mukainen ehtolause-hässäkkä. Tällöin menetetään olio-ohjelmoinnin keskeinen etu, eli se, että olioiden erilaiset toteutukset voidaan piilottaa niiden käyttäjiltä.

instanceof-operaattorin käyttö voi olla oikeutettua joissain erityistilanteissa, kuten

  • kun emme hallitse olemassa olevaa luokkahierarkiaa,
  • kun koodi toimii rajalla, kuten parsittaessa tietoa ulkoisesta lähteestä, integroiduttaessa toiseen järjestelmään tai työskenneltäessä reflektiolla, tai
  • jos vaihtoehto olisi huonompi, kuten monimutkaisen luokkahierarkian tai toisteisen koodin kirjoittaminen.
Esimerkki instanceof-operaattorin käytöstä

Tarkastellaan tilannetta, jossa ohjelma vastaanottaa viestejä (tekstiviesti, kuvaviesti) ulkoisesta järjestelmästä (esim. JSON-rajapinta, verkko, kolmannen osapuolen kirjasto). Viestien luokkia ei voi muuttaa, ja niillä on vain yhteinen ylityyppi.

interface Viesti { }

// Konkreettiset viestityypit (ulkoisesta kirjastosta)
class TekstiViesti implements Viesti {
    String teksti;
}

class KuvaViesti implements Viesti {
    byte[] data;
}

Ohjelman täytyy käsitellä viestit eri tavoin niiden todellisen ajonaikaisen tyypin perusteella.

void kasittele(Viesti v) {
    if (v instanceof TekstiViesti t) {
        IO.println("Teksti: " + t.teksti);
    } else if (v instanceof KuvaViesti k) {
        IO.println("Kuvan koko: " + k.data.length);
    } else {
        throw new IllegalArgumentException("Tuntematon viestityyppi");
    }
}

Tämä on harvoja tilanteita, joissa instanceof on aidosti oikea ratkaisu:

  • Luokkahierarkiaa ei voi muuttaa: Viestiluokat tulevat ulkoisesta kirjastosta → niihin ei voi lisätä metodeja.
  • Polymorfia ei ole käytettävissä: Ei voida määritellä esimerkiksi metodia kasittele() rajapintaan Viesti.
  • Käsittely riippuu konkreettisesta tyypistä: Tekstiviesti ja kuvaviesti vaativat luonteeltaan eri logiikan.
  • Kyseessä on järjestelmän rajapinta: Tällainen koodi kuuluu tyypillisesti I/O-, integraatio- tai adapterikerrokseen.

Object-luokan metodien korvaaminen

Javassa kaikilla luokilla on yhteinen yliluokka nimeltä Object. Tämä tarkoittaa, että kaikki luokat perivät automaattisesti Object-luokan ominaisuudet ja metodit, ellei toisin määritellä. Object-luokassa on useita hyödyllisiä metodeja, joita voidaan korvata aliluokissa.

Object-luokasta löytyy esimerkiksi toString()-metodi, joka tarjoaa olion merkkijonoesityksen. Oletusarvoisesti metodi palauttaa olion luokan nimen ja sen hajautusarvon, mikä ei välttämättä ole kovin informatiivista. Voimme korvata tämän metodin omassa luokassamme, jotta se palauttaa juuri meidän tarpeisiimme sopivan merkkijonoesityksen.

Tehdään vaikkapa Vektori3D-luokka, joka edustaa kolmiulotteista vektoria. Tehdään pääohjelmassa muutama Vektori3D-olio ja tulostetaan niiden arvot.

main.java
public class Main {
    public static void main(String[] args) {
        Vektori3D v1 = new Vektori3D(1.0, 2.0, 3.0);
        Vektori3D v2 = new Vektori3D(4.0, 5.0, 6.0);
        IO.println("Vektori 1: (" + v1.getX() + ", " + v1.getY() + ", " + v1.getZ() + ")");
        IO.println("Vektori 2: (" + v2.getX() + ", " + v2.getY() + ", " + v2.getZ() + ")");
    }
}

Vaikka tulostaminen kyllä toimii, olisi varsin mukavaa, jos voisimme yksinkertaisesti kirjoittaa IO.println("Vektori 1: " + v1); ilman, että meidän tarvitsee erikseen hakea koordinaatteja ja yhdistellä String-olioita toisiinsa. Tätä varten voimme korvata toString()-metodin Vektori3D-luokassa.

main.java
public class Main {
    public static void main(String[] args) {
        Vektori3D v1 = new Vektori3D(1.0, 2.0, 3.0);
        Vektori3D v2 = new Vektori3D(4.0, 5.0, 6.0);
        IO.println("Vektori 1: " + v1);
        IO.println("Vektori 2: " + v2);
    }
}

Pääohjelma näyttää nyt huomattavasti siistimmältä.

Tutki omatoimisesti muita Object-luokan metodeja Javan dokumentaatiosta.

Perimisen tai korvaamisen estäminen (final-avainsana)

Luokan periminen tai metodin korvaaminen voidaan estää käyttämällä final-avainsanaa. Kun luokka on merkitty final-avainsanalla, sitä ei voi periä. Vastaavasti, kun metodi on merkitty final-avainsanalla, sitä ei voi korvata aliluokassa.

Ehkä hieman hämäävästi final-avainsanaa voidaan käyttää myös muuttujien yhteydessä, jolloin se tarkoittaa, että muuttujan arvoa ei voi muuttaa sen alustamisen jälkeen. Tällä ei ole kuitenkaan tekemistä perinnän kanssa.

Tehtävät

Bonus: Tehtävä 3.4: Luokkahierarkia, osa 4. 1 p.

EDIT 29.1.2026: Kiitos palautteestanne. Poistin selityksen puhelimen tietojen tulostamisesta. Tämän tehtävän tavoitteena on harjoitella toString()-metodin ylikirjoittamista. Jos vielä on epäselvyyksiä, niin älkää epäröikö laittaa palauteboksiin kommenttia tai sähköpostia.

EDIT 29.1.2026: Luokan SahkoAuto nimi muutettu Sahkoauto-muotoon, kuten TIMissäkin oli.

Luokissa Tuote, Elektroniikka ja Puhelin ylikirjoita metodi toString(), jossa kutsut ensimmäisenä yliluokan toString()-metodia, ja sen jälkeen yhdistä merkkijonoon luokan omista attribuuteista tietoja.

Tehtäväsivulla on valmiiksi annettuna pääohjelma, jota voit käyttää luokkiesi testaamiseen.

Avaa tästä, mitä ohjelma voisi esimerkiksi tulostaa.
Tietokone HighPower: 899.0 €
Takuuta laitteessa alunperin: 24 kk

Aifoun42: 888.0 €
Takuuta laitteessa alunperin: 37 kk
Käyttöjärjestelmä: AiOS
Yhteystyyppi: 5G

----------------------------

--- UUSI KAUPAN TUOTE ---
Light Bulb: 67000.0 €
Takuuta laitteessa alunperin: 73 kk
Akun kunto: 100.00
Toimintasäde: 404.00 km

--- KÄYTTÖÖNOTTO JA LATAUS ---
Ladataan autoa Light Bulb...
Ladataan autoa Light Bulb...
Ladataan autoa Light Bulb...
Ladataan autoa Light Bulb...
Ladataan autoa Light Bulb...

--- TILANNE LATAUSTEN JÄLKEEN ---
Light Bulb: 67000.0 €
Takuuta laitteessa alunperin: 73 kk
Akun kunto: 99.50
Toimintasäde: 401.98 km

Laajenna luokkahierarkiaa edelleen. Lisää Sahkoauto-luokka, joka perii Elektroniikka-luokan.

Lisää luokkaan

  • attribuutit
    • vakio TOIMINTASADE_MAX, joka ilmaisee maksimietäisyyden kilometreinä, jonka sähköauto voi kulkea yhdellä latauksella.
    • private double akunKunto (prosentteina; väliltä 0-100)
  • metodit
    • lataa(), joka heikentää akun kuntoa 0.1%:lla jokaisella latauskerralla.
    • toString(), joka kutsuu ensin yliluokan metodia toSTring(), jonka jälkeen tulostaa akun kunnon prosentteina ja sitten toimintasäteen kilometreinä, jonka laskemiseen hyödynnetään kaavaa: (akunkunto / 100 * TOIMINTASADE_MAX).

Tee tehtävä TIMissä
Tehtävä 3.5: Korvaaminen, osa 1. 1 p.

Tee luokka Ajoneuvo, jolla on attribuutti String merkki ja konstruktori joka asettaa tämän arvon. Lisää myös metodi liiku(), joka ei tee mitään.

Peri Ajoneuvo-luokasta luokat Auto ja Lentokone. Tee Auto- ja Lentokone-luokkiin liiku()-metodi, joka ylikirjoittaa Ajoneuvo-luokan liiku()-metodin. Auto-olio tulostaaa "Auto <merkki> ajaa maantiellä renkaat vinkuen.", ja Lentokone-olio "Lentokone <merkki> nousee kiitotieltä ja lentää pilvien päällä.".

Tee pääohjelma, jossa luot kaksi Ajoneuvo-muuttujaa (ei siis Auto- tai Lentokone-tyyppisiä), ja sijoitat niihin Auto-olion ja Lentokone-olion. Kutsu kummankin olion liiku()-metodia.

Tee tehtävä TIMissä
Tehtävä 3.6: Korvaaminen, osa 2. 1 p.

Lisää Auto-luokkaan attribuutti int ajokilometrit. Lisää Lentokone-luokkaan attribuutti int lentotunnit. Lisää kummallekin luokalle uusi konstruktori, jossa nämä attribuutit asetetaan. Muokkaa aiempaa konstruktoria niin, että nämä attribuutit saavat arvon 0.

Muuta liiku()-metodeja siten, että ne kasvattavat näitä arvoja. Auto-luokan liiku()-metodi kasvattaa ajokilometrit-attribuuttia 10:llä ja Lentokone-luokan liiku()-metodi kasvattaa lentotunnit-attribuuttia 1:llä.

Ylikirjoita vielä Ajoneuvo-luokassa metodi toString(), joka palauttaa tekstin "Ajoneuvon <merkki> tiedot: ". Ylikirjoita tämä metodi edelleen Auto- ja Lentokone-luokissa siten, että ne palauttavat lisäksi ajokilometrit tai lentotunnit.

Tee tehtävä TIMissä

Abstrakti luokka

osaamistavoitteet

  • Osaat tehdä abstraktin luokan ja abstrakteja metodeja Javassa.
  • Ymmärrät abstraktin luokan ja abstraktin metodin käsitteet ja niiden hyödyt olio-ohjelmoinnissa.

Abstraktin luokan ajatusta voidaan havainnollistaa tuoleilla: Puutuoli, keinutuoli ja työtuoli ovat kaikki tuoleja, jotka erikoistavat "istuin"-käsitettä.

Suunnitellessamme ali- ja yliluokkasuhteita voi tulla tilanne, että olisi hyödyllistä tehdä yhteistä toiminnallisuutta määrittävä yliluokka, josta itsestään ei kuitenkaan ole mielekästä luoda ilmentymiä eli olioita.

Ajatellaan vaikkapa tuolia. Vaikka sana tuoli varmasti herättää meissä mielikuvan jostain tietynlaisesta tuolista, niin todellisuudessa tuoleja on monenlaisia: on puutuoleja, keinutuoleja, työtuoleja ja niin edelleen. Jokainen näistä tuolityypeistä on omanlainen ja hieman erilainen. Voidaan argumentoida, että tuoli-käsite itsessään on abstraktio. Tuolihan on oikeastaan vain asia, joka mahdollistaa istumisen. Tarvitaan aina jokin erikoistava käsite, kuten työtuoli, joka todella kuvaa millaisesta konkreettisesta tuolista on kysymys, ja jollaisia lopulta voidaan valmistaa tuotantolinjalla.

Otetaan toinen esimerkki, joka on ehkä jo hieman lähempänä oikeaa koodia. Jatketaan edellisessä osassa esitettyä Muoto-esimerkkiä. Voisimme periaatteessa luoda Muoto-luokan ilmentymän ja kutsua sen laskeAla()-metodia.

Muoto muoto = new Muoto();
double ala = muoto.laskeAla();
IO.println("Muodon ala on " + ala); // 0.0

Tässä ei kuitenkaan ole mitään järkeä. Muoto-olio ei edusta mitään konkreettista muotoa, vaan se on vain yleinen käsite, josta konkreettiset muodot, kuten Ympyrä ja Suorakulmio, periytyvät. Tämä on ilmeistä viimeistään siinä vaiheessa, kun yritämme tulostaa tämän yleisen muodon pinta-alaa. Näin ollen Muoto-luokka on tarkoitettu vain perittäväksi, eikä siitä ole mielekästä luoda ilmentymiä. Muutetaan Muoto-luokka abstraktiksi luokaksi, ja muutetaan myös laskeAla()-metodi abstraktiksi metodiksi.

public abstract class Muoto {
    public abstract double laskeAla();
}

Tämän jälkeen Muoto-luokasta ei voi enää luoda ilmentymiä.

Muoto muoto = new Muoto(); 
java: Muoto is abstract; cannot be instantiated

Abstrakti luokka (engl. abstract class) on luokka, jonka avulla tällainen käsitteen piirre voidaan tehdä selväksi koodin tasolla luokkahierarkiassa. Abstraktista luokasta ei voi luoda suoria ilmentymiä, vaan se toimii ainoastaan pohjana muille luokille, jotka perivät sen. Abstrakti luokka voi sisältää sekä abstrakteja metodeja (ts. joilla ei ole toteutusta), että konkreettisia metodeja (ts. joilla on toteutus). Perivän luokan tulee sitten toteuttaa nuo abstraktit metodit, ellei perivä luokka ole myös abstrakti.

huomautus

Tässä kohtaa voi pysähtyä hetkeksi miettimään tarvitaanko edellisen osan henkilötietojärjestelmässä laisinkaan henkilö-olioita, vai ovatko kaikki henkilöt jotain muutakin kuin henkilöitä, kuten opiskelijoita tai opettajia.

Esimerkki: Älykoti

Älykodissa voisi olla monenlaisia laitteita, kuten valoja, turvakamera sekä tietysti älykahvinkeitin. Sovitaan, että kaikilla laitteilla olisi toiminto vaihdaTilaa(), joka suorittaa laitteen päätoiminnon (esim. valot syttyvät tai sammuvat, kamera aloittaa tai päättää videon tallennuksen, kahvinkeitin aloittaa tai lopettaa kahvin keittämisen). Kukin laite voisi myös raportoida oman tilansa raportoiTila()-metodilla.

Lähdemme aluksi liikkeelle yksinkertaisesta esimerkistä, jossa voi vain vaihtaa laitteen tilaa kahden mahdollisen tilan välillä. Palaamme monimutkaisempiin säätömahdollisuuksiin myöhemmin.

Luokkakaaviomme voisi näyttää seuraavanlaiselta.

main.java
public class Main {
    public static void main() {
        Laite[] laitteet = {
            new Valo(),
            new Turvakamera(),
            new Kahvinkeitin()
        };

        for (Laite laite : laitteet) {
            laite.vaihdaTilaa();
            laite.raportoiTila();
        }
    }
}

Jos katsotaan Laite-luokkaa, huomataan, että sen metodit vaihdaTilaa() ja raportoiTila() eivät tee mitään. Teoriassa voisimme luoda myös Laite-luokasta ilmentymän ja kutsua sen metodeja:

Laite laite = new Laite();
laite.vaihdaTilaa(); // Ei tee mitään
laite.raportoiTila(); // Ei tee mitään

Kuten nähdään, mitään ei tapahdu näitä metodeja kutsuttaessa, ja sikäli Laite-luokasta tehdyt oliot ovat tavallaan hyödyttömiä. Ei ole oikeastaan järkevää, että olisi olemassa jokin "yleinen laite", ilman, että tiedetään tarkemmin, minkä tyyppisestä laitteesta on kyse. Näin ollen Laite-luokka on oikeastaan tarkoitettu vain perittäväksi.

Muutetaan Laite-luokka abstraktiksi luokaksi. Koska myös metodit on tarkoitettu toteutettavaksi perivissä luokissa, määritellään myös metodit abstrakteiksi. Kaikkien perivien luokkein on toteutettava nämä metodit, kuten ne esimerkissämme jo tekevätkin.

main.java
public class Main {
    public static void main() {
        Laite[] laitteet = {
            new Valo(),
            new Turvakamera(),
            new Kahvinkeitin()
        };

        for (Laite laite : laitteet) {
            laite.vaihdaTilaa();
            laite.raportoiTila();
        }
    }
}

Vastaavasti kuin aiemmassa Muoto-esimerkissä, nyt Laite-luokasta ei voi enää luoda ilmentymiä.

Laite laite = new Laite(); 
java: Laite is abstract; cannot be instantiated

Luokkakaaviona kuvio näyttää samalta kuin ennen, mutta nyt Laite-luokka on merkitty abstraktiksi luokaksi A-kirjaimella, ja sen metodit on merkitty abstrakteiksi metodeiksi. UML-notaatiossa abstrakti luokka ja abstraktit metodit merkitään kursiivilla.

Abstraktin luokan "vastakohtana" voidaan pitää konkreettista luokkaa, josta voi luoda ilmentymiä. Esimerkiksi Valo, Turvakamera ja Kahvinkeitin ovat konkreettisia luokkia, koska niistä voi luoda ilmentymiä.

Miksi abstrakti luokka on hyödyllinen?

Abstrakti luokka ei ole vain kielto tehdä luokasta ilmentymiä. Sen ensisijainen tarkoitus on

  • määritellä yhteinen sopimus siitä, mitä metodeja kaikkien aliluokkien pitää tarjota, ja
  • tarjota yhteisiä ominaisuuksia ja tarvittaessa myös toteutuksia, jotta aliluokat keskittyvät vain olennaiseen.

Kun Laite on abstrakti, voimme lisätä sille attribuutteja ja metodien valmiita toteutuksia, joita kaikki aliluokat käyttävät.

Lisätään Laite-luokkaan attribuutti nimi, joka kertoo laitteen nimen, sekä attribuutti kytketty, joka kertoo, onko laite päällä vai pois päältä. Sellainen attribuutti on hyödyllinen kaikille laitteille, joten se sopii hyvin abstraktiin luokkaan.

public abstract class Laite {

    // HIGHLIGHT_GREEN_BEGIN
    private String nimi;
    private boolean kytketty;
    // HIGHLIGHT_GREEN_END

    public abstract void vaihdaTilaa();
    public abstract void raportoiTila();
}

Jos kyse olisi verkkolaitteesta, hyödyllisiä tai jopa pakollisia attribuutteja voisivat olla muun muassa MAC-osoite ja IP-osoite. Pidämme kuitenkin tämän esimerkin yksinkertaisena, joten tyydymme tässä vain nimeen ja kytketty-tilan seuraamiseen.

Lisätään myös metodit kytkePaalle() ja kytkePois(), jotka sisältävät yleisen logiikan laitteen käynnistämiseen ja sammuttamiseen, jota kaikki laitteet voivat noudattavat.

public abstract class Laite {
    private String nimi;
    private boolean kytketty;


    // HIGHLIGHT_GREEN_BEGIN
    protected Laite(String nimi) {
        this.nimi = nimi;
        this.kytketty = false; // oletus
    }

    public void kytkePaalle() {
        if (!kytketty) {
            kytketty = true;
            IO.println(nimi + " käynnistyy.");
        }
    }

    public void kytkePois() {
        if (kytketty) {
            kytketty = false;
            IO.println(nimi + " sammuu.");
        }
    }
    // HIGHLIGHT_GREEN_END

    public abstract void vaihdaTilaa();
    public abstract void raportoiTila();
}

Huomaa, että koska päätimme, että joka laitteella on oltava nimi, siitä seuraa, että nimi on asetettava muodostajan parametrin kautta. Tämän seurauksena emme voi enää luoda ilmentymiä ei-parametrisen muodostajan avulla.

Valo valo = new Valo();
Valo.java
java: constructor Laite in class Laite cannot be applied to given types;
  required: java.lang.String
  found:    no arguments
  reason: actual and formal argument lists differ in length

Muodostajan kutsuminen vaatii nyt nimen välittämisen, esimerkiksi new Valo("PhilipsHue"). Niinpä kussakin aliluokan muodostajassa on kutsuttava yliluokan muodostajaa. Tehdään tämä muutos kaikkiin aliluokkiin.

main.java
public class Main {
    public static void main() {
        Laite[] laitteet = {
                new Valo("PhilipsHue"),
                new Kahvinkeitin("Moccamaster"),
                new Turvakamera("Reolink")
        };

        for (Laite laite : laitteet) {
            laite.kytkePaalle();
            laite.vaihdaTilaa();
            laite.raportoiTila();
            laite.kytkePois();
        }
    }
}

Aliluokat perivät nyt päälle- ja pois-kytkemislogiikan sellaisenaan, mutta niiden on pakko toteuttaa laitteen omat, oliokohtaiset toiminnallisuudet. Tämä luo tasapainoa joustavuuden ja pakollisen rakenteen välille: Tilan vaihtaminen ja tilan raportointi ovat pakollisia, mutta niiden toteutus on vapaa. Toisaalta laitteen käynnistys- ja sammutuslogiikka on yhteinen kaikille laitteille.

Abstraktin luokan metodien näkyvyys

Abstraktin luokan metodien näkyvyys määritellään samojen periaatteiden mukaan kuin muidenkin metodien. Abstraktit metodit määritellään joko public- tai protected-metodeina, jotta aliluokat voivat toteuttaa ne. Jos metodia kutsuu koodi, joka luo olion, metodin tulee olla public. Jos metodia kutsutaan vain perivästä luokasta, riittää että metodi on protected. On kuitenkin huomattava, että aliluokan toteuttaman metodin näkyvyys ei voi olla rajoittavampi kuin abstraktin metodin näkyvyys. Esimerkiksi public-abstraktia metodia ei voi toteuttaa protected-metodina aliluokassa.

Konkreettiset metodit voivat olla myös private: tällöin kyseessä on vain abstraktin luokan sisäinen apumetodi, jota aliluokat eivät näe.

Abstraktia metodia ei voi määritellä private-määreellä.

Operaatiorunko-malli

Abstraktissa luokassa voi olla myös konkreettinen metodi, jonka toteutuksessa kutsutaan abstraktia metodia. Tällaista toteutusta kutsutaan ohjelmistosuunnittelussa operaatiorunko-suunnittelumalliksi. Abstrakti luokka määrittelee toimenpiteelle "kaavan", mutta delegoi osan vaiheista aliluokkien toteutettavaksi.

Jo aiemmin toteutetut osat on piilotettu koodista. Saat ne esiin klikkaamalla silmä-kuvaketta koodialueen oikeasta yläreunasta.

Laite.java
public abstract class Laite {
    private String nimi;
    private boolean kytketty;

    protected Laite(String nimi) {
        this.nimi = nimi;
    }

    public final void suoritaPaivitys() {
        kytkePaalle();
        valmistelePaivitys(); // Abstrakti askel, jonka aliluokka toteuttaa
        paivitys();
        kytkePois();
    }

    protected abstract void valmistelePaivitys();

    private void paivitys() {
        IO.println("Haetaan uusin päivitys verkosta...");
        IO.println("Laite päivitetään...");
    }

    public void kytkePaalle() {
        if (!kytketty) {
            kytketty = true;
            IO.println(nimi + " käynnistyy.");
        }
    }

    public void kytkePois() {
        if (kytketty) {
            kytketty = false;
            IO.println(nimi + " sammuu.");
        }
    }

    public abstract void vaihdaTilaa();
    public abstract void raportoiTila();
}

suoritaPaivitys() on nyt ikään kuin valmis resep­ti, jota aliluokat eivät voi muuttaa (final). Sen sijaan ne täydentävät reseptin tarvitsemansa tavoilla toteuttamalla abstraktit metodit.

🤔 Pohdittavaksi: Missä tilanteissa haluaisit estää aliluokkaa ylikirjoittamasta tiettyä metodia?

Tehtävät

Tehtävä 3.7: Viestit. 1 p.

Tee abstrakti luokka Viesti, jolla on attribuutti String viesti, joka asetetaan konstruktorissa. Aseta viesti-attribuutin näkyvyys mahdollisimman rajoitetuksi. Luokalla on myös abstrakti metodi void laheta().

Peri Viesti-luokasta luokat Sahkoposti ja Tekstiviesti. Molemmissa luokissa on konstruktori, joka kutsuu yliluokan konstruktoria. Toteuta molempiin luokkiin laheta()-metodit. Sahkoposti-luokan laheta()-metodi tulostaa muodossa "Lähetetään sähköposti: <viesti>" ja Tekstiviesti-luokan laheta()-metodi tulostaa muodossa `"Lähetetään tekstiviesti: <viesti>"

Tee tehtävä TIMissä
Tehtävä 3.8: Abstrakti ajoneuvo. 1 p.

Muuta Tehtävän 3.6 Ajoneuvo-luokka ja sen liiku()-metodi abstrakteiksi. Jätä toString()-metodi edelleen tavalliseksi (ei-abstraktiksi) metodiksi.

Tee tehtävä TIMissä
Tehtävä 3.9: Viestikanavat. 1 p.
  1. Tee abstrakti luokka Viestikanava. Sillä on attribuutti String vastaanottaja, joka asetetaan konstruktorissa. Lisää abstrakti metodi lahetaSisaisesti(String viesti), joka ei palauta mitään.

  2. Tee myös metodi String getVastaanottaja(), joka palauttaa vastaanottajan.

  3. Tee konkreettinen metodi laheta(String viesti), joka aluksi lopettaa metodin (return), jos viesti on tyhjä tai null. Muuten metodi kutsuu abstraktia metodia lahetaSisaisesti(String viesti).

  4. Peri Viestikanava-luokasta Sahkoposti ja Tekstiviesti. Molemmissa luokissa ylikirjoita abstrakti metodi lahetaSisaisesti(String viesti), joka tulostaa konsoliin viestin muodossa "Lähetetään <kanava> <osoite/numero>: <viesti>", esim. "Lähetetään sähköposti osoitteeseen antti-jussi@lakanen.com: Hei, mikä on homma?" tai "Lähetetään tekstiviesti numeroon 0401234567: Tervetuloa kurssille!".

Tee tehtävä TIMissä
Tehtävä 3.10: Viestipalvelu. 1 p.

Tee Viestipalvelu-luokka, jolle voi lisätä erilaisia Viestikanava-olioita lisaaKanava(Viestikanava kanava)-metodilla. Lisää myös metodi lahetaKaikille(String viesti), joka lähettää viestejä kaikilla kanavilla kerralla.

Pari valinnaista lisähaastetta (ei pisteitä; nämä ovat kuitenkin mallivastauksessa mukana):

  1. Muuta Viestikanava-luokkaa siten, että se ottaa listan vastaanottajia, ei vain yhtä. Tämän seurauksena pitää muuttaa myös lahetaSisaisesti-metodeja.
  2. Laita Tekstiviesti-luokkaan merkkiraja (esim. 80 merkkiä). Jos viesti on tätä pidempi, niin viesti tulee pilkkoa merkkirajan mukaisiin pätkiin.
Tee tehtävä TIMissä

Osan kaikki tehtävät

huomautus

Jos palautat tehtävät ennen osan takarajaa (ma 2.2.2026 klo 11:59 (keskipäivä)), voit saada DL-BONUS-pisteitä harjoitustehtäviin. Lue lisää suorittaminen-sivulta.

Tehtävä 3.1: Luokkahierarkia, osa 1. 1 p.

Tee luokka Tuote. Tee sille attribuutit nimi (merkkijono) ja hinta (desimaaliluku). Aseta attribuuttien arvot konstruktorissa. Tee myös metodi void tulostaTiedot(), joka tulostaa tuotteen nimen ja hinnan muodossa "Nimi: Hinta €".

Peri Tuote-luokasta luokat Vaate ja Ruoka. Perivien luokkien konstruktoreissa ei tarvitse tehdä muuta kuin kutsua yläluokan konstruktoria oikeilla arvoilla.

Tee nyt ohjelmassasi kaksi erilaista vaatetta ja kaksi erilaista ruokatuotetta ja tulosta niiden tiedot kutsumalla void tulostaTiedot()-metodia.

Tee tehtävä TIMissä
Tehtävä 3.2: Luokkahierarkia, osa 2. 1 p.

Jatketaan edellistä tehtävää. Peri Tuote-luokasta myös luokka Elektroniikka.

Lisää erityispiirteitä kuhunkin aliluokkaan:

  • Vaate: attribuutti String koko (esim. "M", "L", jne.), metodi void sovita(String sovittajanKoko), joka tulostaa, onko vaate sopiva sovittajalle.
  • Elektroniikka: attribuutti int takuuKuukausina (esim. 24), metodi int takuutaJaljella(int kuukausiaKulunut) palauttaa montako kuukautta takuuta on jäljellä (tai 0, jos takuu on umpeutunut).
  • Ruoka: attribuutti String parastaEnnen (esim. "31.01.2026"), ja metodi void syo(), joka tulostaa "Nautit ruoan, jonka viimeinen käyttöpäivä on DD.MM.YYYY." (korvaa DD.MM.YYYY parastaEnnen-arvolla).

Huomaa, että perivien luokkien konstruktoreissa tulee nyt kutsua yläluokan konstruktoria oikeilla arvoilla, sekä asettaa omat attribuutit.

Tehtäväsivulla on valmiiksi annettuna pääohjelma. Käytä sitä luokkiesi testaamiseen. Se ei saa tuottaa käännös- tai ajonaikaisia virheitä. Voit kuitenkin halutessasi lisätä pääohjelmaan omaa koodiasi.

Tee tehtävä TIMissä
Tehtävä 3.3: Luokkahierarkia, osa 3. 1 p.

EDIT 30.1.2026: UML-kaavio korjattu vastaamaan tehtävänantoa

EDIT 29.1.2026: UML päivitetty vastaamaan tehtävänantoa

Laajenna aiemmin tekemääsi verkkokaupan luokkahierarkiaa alla olevan UML-kaavion mukaisesti. Saat kuvan suuremmaksi oikeaklikkaamalla (Windows) tai Control-klikkaamalla (macOS) sitä ja valitsemalla "Avaa kuva uudessa välilehdessä".

Tehtäväsivulla on valmiiksi annettuna pääohjelma, jota voit käyttää luokkiesi testaamiseen.

Avaa tästä ohjelman antama esimerkkituloste.
Kutsutaan perittyjä metodeja:
Talvitakki Dulce & Käppänä: 120.0 €
Ruisleipä Reissurähjä: 2.5 €
Tietokone HighPower: 899.0 €

----------------------------

Kutsutaan omia metodeja:
Testi 1: Sovitetaan M-kokoista käyttäjää:
Sovitetaan vaatetta Talvitakki Dulce & Käppänä...
Ei välttämättä sopivin koko. Sinä olet kokoa M, mutta tämä vaate on L.

Testi 2: Sovitetaan L-kokoista käyttäjää:
Sovitetaan vaatetta Talvitakki Dulce & Käppänä...
Mahtavaa! Koko L istuu sinulle täydellisesti!

Syödään Ruisleipä Reissurähjä.
Parasta ennen oli 20.12.2024, toivottavasti on hyvää.

Takuuta jäljellä: 19 kk.

pHone: 999.99 €
Takuuta puhelimessa jäljellä: 19 kk
Käyttöjärjestelmä: Orange
Yhteystyyppi: 4G
Soitetaan käyttöjärjestelmästä Orange(4G) numeroon 0401122330

Hernepussi: 0.99 €
Sulatit pakastetta Hernepussi 10 minuuttia. Säilytyssuositus on -18 astetta C.
Syödään Hernepussi.
Parasta ennen oli 31.5.2026, toivottavasti on hyvää.

Tehtävän kuvaus sanallisessa muodossa

Tässä on kuvaus uusista luokista ja niiden vaadituista ominaisuuksista. Löydät vastaavat tiedot UML-kaaviosta.

  1. Puhelin (perii Elektroniikka)

    • Lisää attribuutit:
      • private String kayttojarjestelma (esim. "Droid" tai "AiOS")
      • private boolean onko5G
    • Lisää metodit:
      • public void soita(String numero). Metodi tulostaa esimerkiksi: Soitetaan käyttöjärjestelmästä Orange(4G) numeroon 0401122330
      • public void tulostaPuhelimenTiedot(int kuukausiaKulunut). Metodin tulee kutsua ensin perittyä metodia (tulostaTiedot()), ja sitten tulostaa puhelimeen liittyvät lisätiedot (jäljellä olevan takuuajan, käyttöjärjestelmän ja 5G-tuki).
  2. Pakaste (perii Ruoka)

    • Lisää attribuutti:
      • private int lampotilaSuositus (esim. -18)
    • Lisää metodi:
      • private void sulata(int minuutit) (huomaa private-määre)
      • Kun metodia kutsutaan, se tulostaa esimerkiksi: Sulatat pakastetta 10 minuuttia. Säilytyssuositus: -18 C.
    • Lisää metodi:
      • public void sulataJaNauti(int minuutit)
      • Metodi kutsuu ensin sulata(minuutit)-metodia ja sitten perittyä syo()-metodia.

Tee tehtävä TIMissä
Bonus: Tehtävä 3.4: Luokkahierarkia, osa 4. 1 p.

EDIT 29.1.2026: Kiitos palautteestanne. Poistin selityksen puhelimen tietojen tulostamisesta. Tämän tehtävän tavoitteena on harjoitella toString()-metodin ylikirjoittamista. Jos vielä on epäselvyyksiä, niin älkää epäröikö laittaa palauteboksiin kommenttia tai sähköpostia.

EDIT 29.1.2026: Luokan SahkoAuto nimi muutettu Sahkoauto-muotoon, kuten TIMissäkin oli.

Luokissa Tuote, Elektroniikka ja Puhelin ylikirjoita metodi toString(), jossa kutsut ensimmäisenä yliluokan toString()-metodia, ja sen jälkeen yhdistä merkkijonoon luokan omista attribuuteista tietoja.

Tehtäväsivulla on valmiiksi annettuna pääohjelma, jota voit käyttää luokkiesi testaamiseen.

Avaa tästä, mitä ohjelma voisi esimerkiksi tulostaa.
Tietokone HighPower: 899.0 €
Takuuta laitteessa alunperin: 24 kk

Aifoun42: 888.0 €
Takuuta laitteessa alunperin: 37 kk
Käyttöjärjestelmä: AiOS
Yhteystyyppi: 5G

----------------------------

--- UUSI KAUPAN TUOTE ---
Light Bulb: 67000.0 €
Takuuta laitteessa alunperin: 73 kk
Akun kunto: 100.00
Toimintasäde: 404.00 km

--- KÄYTTÖÖNOTTO JA LATAUS ---
Ladataan autoa Light Bulb...
Ladataan autoa Light Bulb...
Ladataan autoa Light Bulb...
Ladataan autoa Light Bulb...
Ladataan autoa Light Bulb...

--- TILANNE LATAUSTEN JÄLKEEN ---
Light Bulb: 67000.0 €
Takuuta laitteessa alunperin: 73 kk
Akun kunto: 99.50
Toimintasäde: 401.98 km

Laajenna luokkahierarkiaa edelleen. Lisää Sahkoauto-luokka, joka perii Elektroniikka-luokan.

Lisää luokkaan

  • attribuutit
    • vakio TOIMINTASADE_MAX, joka ilmaisee maksimietäisyyden kilometreinä, jonka sähköauto voi kulkea yhdellä latauksella.
    • private double akunKunto (prosentteina; väliltä 0-100)
  • metodit
    • lataa(), joka heikentää akun kuntoa 0.1%:lla jokaisella latauskerralla.
    • toString(), joka kutsuu ensin yliluokan metodia toSTring(), jonka jälkeen tulostaa akun kunnon prosentteina ja sitten toimintasäteen kilometreinä, jonka laskemiseen hyödynnetään kaavaa: (akunkunto / 100 * TOIMINTASADE_MAX).

Tee tehtävä TIMissä
Tehtävä 3.5: Korvaaminen, osa 1. 1 p.

Tee luokka Ajoneuvo, jolla on attribuutti String merkki ja konstruktori joka asettaa tämän arvon. Lisää myös metodi liiku(), joka ei tee mitään.

Peri Ajoneuvo-luokasta luokat Auto ja Lentokone. Tee Auto- ja Lentokone-luokkiin liiku()-metodi, joka ylikirjoittaa Ajoneuvo-luokan liiku()-metodin. Auto-olio tulostaaa "Auto <merkki> ajaa maantiellä renkaat vinkuen.", ja Lentokone-olio "Lentokone <merkki> nousee kiitotieltä ja lentää pilvien päällä.".

Tee pääohjelma, jossa luot kaksi Ajoneuvo-muuttujaa (ei siis Auto- tai Lentokone-tyyppisiä), ja sijoitat niihin Auto-olion ja Lentokone-olion. Kutsu kummankin olion liiku()-metodia.

Tee tehtävä TIMissä
Tehtävä 3.6: Korvaaminen, osa 2. 1 p.

Lisää Auto-luokkaan attribuutti int ajokilometrit. Lisää Lentokone-luokkaan attribuutti int lentotunnit. Lisää kummallekin luokalle uusi konstruktori, jossa nämä attribuutit asetetaan. Muokkaa aiempaa konstruktoria niin, että nämä attribuutit saavat arvon 0.

Muuta liiku()-metodeja siten, että ne kasvattavat näitä arvoja. Auto-luokan liiku()-metodi kasvattaa ajokilometrit-attribuuttia 10:llä ja Lentokone-luokan liiku()-metodi kasvattaa lentotunnit-attribuuttia 1:llä.

Ylikirjoita vielä Ajoneuvo-luokassa metodi toString(), joka palauttaa tekstin "Ajoneuvon <merkki> tiedot: ". Ylikirjoita tämä metodi edelleen Auto- ja Lentokone-luokissa siten, että ne palauttavat lisäksi ajokilometrit tai lentotunnit.

Tee tehtävä TIMissä
Tehtävä 3.7: Viestit. 1 p.

Tee abstrakti luokka Viesti, jolla on attribuutti String viesti, joka asetetaan konstruktorissa. Aseta viesti-attribuutin näkyvyys mahdollisimman rajoitetuksi. Luokalla on myös abstrakti metodi void laheta().

Peri Viesti-luokasta luokat Sahkoposti ja Tekstiviesti. Molemmissa luokissa on konstruktori, joka kutsuu yliluokan konstruktoria. Toteuta molempiin luokkiin laheta()-metodit. Sahkoposti-luokan laheta()-metodi tulostaa muodossa "Lähetetään sähköposti: <viesti>" ja Tekstiviesti-luokan laheta()-metodi tulostaa muodossa `"Lähetetään tekstiviesti: <viesti>"

Tee tehtävä TIMissä
Tehtävä 3.8: Abstrakti ajoneuvo. 1 p.

Muuta Tehtävän 3.6 Ajoneuvo-luokka ja sen liiku()-metodi abstrakteiksi. Jätä toString()-metodi edelleen tavalliseksi (ei-abstraktiksi) metodiksi.

Tee tehtävä TIMissä
Tehtävä 3.9: Viestikanavat. 1 p.
  1. Tee abstrakti luokka Viestikanava. Sillä on attribuutti String vastaanottaja, joka asetetaan konstruktorissa. Lisää abstrakti metodi lahetaSisaisesti(String viesti), joka ei palauta mitään.

  2. Tee myös metodi String getVastaanottaja(), joka palauttaa vastaanottajan.

  3. Tee konkreettinen metodi laheta(String viesti), joka aluksi lopettaa metodin (return), jos viesti on tyhjä tai null. Muuten metodi kutsuu abstraktia metodia lahetaSisaisesti(String viesti).

  4. Peri Viestikanava-luokasta Sahkoposti ja Tekstiviesti. Molemmissa luokissa ylikirjoita abstrakti metodi lahetaSisaisesti(String viesti), joka tulostaa konsoliin viestin muodossa "Lähetetään <kanava> <osoite/numero>: <viesti>", esim. "Lähetetään sähköposti osoitteeseen antti-jussi@lakanen.com: Hei, mikä on homma?" tai "Lähetetään tekstiviesti numeroon 0401234567: Tervetuloa kurssille!".

Tee tehtävä TIMissä
Tehtävä 3.10: Viestipalvelu. 1 p.

Tee Viestipalvelu-luokka, jolle voi lisätä erilaisia Viestikanava-olioita lisaaKanava(Viestikanava kanava)-metodilla. Lisää myös metodi lahetaKaikille(String viesti), joka lähettää viestejä kaikilla kanavilla kerralla.

Pari valinnaista lisähaastetta (ei pisteitä; nämä ovat kuitenkin mallivastauksessa mukana):

  1. Muuta Viestikanava-luokkaa siten, että se ottaa listan vastaanottajia, ei vain yhtä. Tämän seurauksena pitää muuttaa myös lahetaSisaisesti-metodeja.
  2. Laita Tekstiviesti-luokkaan merkkiraja (esim. 80 merkkiä). Jos viesti on tätä pidempi, niin viesti tulee pilkkoa merkkirajan mukaisiin pätkiin.
Tee tehtävä TIMissä

Rajapintojen ja geneeristen tyyppien perusteet

osaamistavoitteet

  • Osaat käyttää rajapintoja määrittääksesi luokan toimintaa määrittävän sopimuksen
  • Osaat toteuttaa Comparable-rajapintaan, joka mahdollistaa olioiden järjestämisen,
  • Ymmärrät geneeristen luokkien ja tyyppiparametrien käsitteet
  • Osaat toteuttaa yleiskäyttöisiä luokkia ja metodeja geneeristen tyyppien avulla

Rajapinta

osaamistavoitteet

  • Ymmärrät, mitä rajapinta (interface) tarkoittaa olio-ohjelmoinnissa.
  • Osaat määritellä ja käyttää rajapintoja Javassa.
  • Osaat käyttää rajapintaa aliohjelman parametrina ja muuttujan tyyppinä.
  • Ymmärrät, milloin kannattaa käyttää rajapintaa perinnän sijaan.
  • Ymmärrät, että luokka voi toteuttaa monta rajapintaa, mutta periä vain yhden luokan

Rajapinta toimii sitovana sopimuksena: Se määrittelee, mitä metodeja luokan on tarjottava, ottamatta kantaa siihen, miten ne on teknisesti toteutettu. Toisin kuin abstrakti luokka, joka luo pohjan luokan metodeille ja attribuuteille, rajapinta keskittyy kuvailemaan olion kyvykkyyksiä. Rajapinta mahdollistaa yhtenevän kyvykkyyksien määrittelyn, vaikka luokat olisivat täysin erilaisia tai periytyisivät eri paikoista luokkahierarkiassa. Kun ohjelmoija sitten käsittelee oliota rajapinnan kautta, hän voi luottaa siihen, että olio tarjoaa sovitun kyvykkyyden riippumatta siitä, mitä luokkaa olio edustaa.

Älykoti: säädettävät laitteet

Jatketaan Osassa 3 aloittamaamme älykoti-esimerkkiä. Jotkin älykotimme laitteet voisivat olla säädettäviä, eli niihin voisi asettaa suoraan arvon, kuten kirkkauden, lämpötilan tai äänenvoimakkuuden. Näinhän periaatteessa toimimmekin jo esimerkkimme Valo-luokassa, jossa kirkkaus vaihtelee kolmen arvon välillä. Olion käyttäjän kannalta olisi kuitenkin kätevämpää, jos voisi asettaa kirkkauden suoraan haluttuun arvoon (esim. 33%), sen sijaan, että pitäisi kutsua vaihdaTilaa()-metodia useita kertoja ja toivoa, että arvo osuu kohdalleen. Loppukäyttäjän kannalta tätä voisi verrata tilanteeseen, jossa käyttäjä voisi asettaa vaikkapa mobiilisovelluksesta suoraan haluamansa kirkkauden sen sijaan, että pitäisi klikkailla Lisää kirkkautta- tai Vähennä kirkkautta -painikkeita useita kertoja.

Määritellään rajapinta Saadettava, jossa on metodi asetaArvo(int arvo). Tiedosto tallennetaan nimellä Saadettava.java, eli samaan tapaan kuin luokat.

/**
 * Laite, jonka voi säätää suoraan haluttuun arvoon.
 */
public interface Saadettava {
    void asetaArvo(int arvo);
}

Tämän voi lukea seuraavasti: Jokaisella Saadettava-rajapinnan toteuttavalla luokalla tulee olla asetaArvo-metodi.

Nyt voimme muokata Valo-luokkaa toteuttamaan Saadettava-rajapinnan:

Lisätään Valo-luokkaan rajapinnan toteutus (klikkaa Valo.java-tiedostoa). Jätämme Kahvinkeitin- ja Turvakamera-luokat tässä vaiheessa esimerkistä pois, koska päätämme yksinkertaisuuden vuoksi, että ne eivät ole säädettäviä laitteita.

main.java
public class Main {
    public static void main() {
        Valo valo = new Valo("PhilipsHue");
        valo.asetaArvo(33);
        valo.raportoiTila();

        valo.vaihdaTilaa();
        valo.raportoiTila();
    }
}

Luokkakaaviona esimerkkimme näyttäisi tältä. I-kirjain ilmaisee, että kyseessä on rajapinta. Abstraktin luokan tapaan rajapinta on merkitty kursiivilla. Rajapinnan toteuttaminen esitetään katkoviivalla, jossa on avoin nuoli kohti rajapintaa.

Usean rajapinnan toteuttaminen

Luokka voi toteuttaa useita rajapintoja. Esimerkiksi Javan sisäänrakennettu ArrayList-luokka toteuttaa rajapintoja: List, RandomAccess, Cloneable ja Serializable (ks. ArrayList-luokan dokumentaatio).

  • List-rajapinta määrittelee listan perustoiminnot, kuten elementtien lisäämisen, poistamisen ja hakemisen.
  • RandomAccess-rajapinta määrittelee, että listan alkioihin tulee päästä käsiksi nopeasti indeksien avulla.
  • Cloneable-rajapinta sallii olion kloonauksen eli kopioinnin.
  • Serializable-rajapinta sallii olion tallentamisen tiedostoon tai lähettämiseen verkon yli.

Toisaalta myös Javan Date-luokka toteuttaa muun muassa Cloneable-rajapinnan, joka mahdollistaa päivämääräolion kloonaamisen. Huomaa, että Date-luokka ei liity mitenkään ArrayList-luokkaan, mutta molemmat toteuttavat saman rajapinnan.

Luodaan nyt itse kaksi rajapintaa ja luokkia, jotka toteuttavat molemmat rajapinnat.

Otetaan esimerkki käyttöliittymäkomponenteista, joita voi piirtää näytölle ja joita voi klikata hiirellä. Määritellään kaksi rajapintaa: Piirrettava ja Klikattava. Näiden rajapintojen avulla voitaisiin määritellä, millaisia komponentteja käyttöliittymässä on. Sovitaan niin, että piirrettävä komponentti osaa piirtää itsensä, ja klikattava komponentti osaa käsitellä klikkauksia ja korostaa itsensä, kun hiiri on sen päällä.

Piirrettava.java
/**
 * Käyttöliittymään piirrettävä komponentti.
 */
public interface Piirrettava {
    public void piirra();
}

Huomaa, että emme tiedä emmekä välitä siitä, miten nämä metodit aikanaan toteutetaan. Piirto voi tapahtua graafisella käyttöliittymällä, tekstipohjaisella käyttöliittymällä tai vaikkapa tulostamalla tiedostoon. Meille riittää, että tiedämme, että jokaisella Pirrettava-rajapinnan toteuttavalla luokalla on piirra()-metodi, ja jokaisella Klikattava-rajapinnan toteuttavalla luokalla on klikattu()- ja asetaKorostus(boolean korostus)-metodit.

Mennään eteenpäin. Toteutetaan Teksti, joka on pelkkää tekstiä näyttävä käyttöliittymäkomponentti.

/**
 * Pelkkää tekstiä esittävä piirrettävä komponentti.
 */
public class Teksti implements Piirrettava {
    private String sisalto;
    public Teksti(String sisalto)
    {
        this.sisalto = sisalto;
    }

    @Override
    public void piirra() {
        // Piirretään vain pelkkä tekstisisältö ilman kehyksiä
        IO.println(sisalto);
    }
}

Rajapintojen hyöty ei vielä kokonaisuudessaan välity, osittain siksi, että piirra()-metodi on ainoa metodi, jota Piirrettava-rajapinta tarjoaa. Nyt kuitenkin voimme luoda toisen komponentin, Painike, joka on laatikon näköinen klikkattava painike, jossa on tekstiä. Painike-luokka toteuttaa molemmat rajapinnat: Pirrettava ja Klikattava.

/**
 * Laatikon näköinen klikkattava painike,
 * jossa on tekstiä.
 */
public class Painike implements Piirrettava, Klikattava {

    private String sisalto;
    private boolean korostettu;

    public Painike(String sisalto)
    {
        this.sisalto = sisalto;
        this.korostettu = false;
    }

    @Override
    public void piirra() {
        // Piirretään suorakulmio ja teksti
        if (!korostettu) {
            IO.println("[ " + sisalto + " ]");
        } else {
            IO.println("[*" + sisalto + "*]");
        }
    }

    /**
     * Käsitellään klikkaustapahtuma
     */
    @Override
    public void klikattu() {
        IO.println("(Klikattiin painiketta, jossa lukee \"" + sisalto + "\")");
    }

    /**
     * Asetetaan korostustila. Jos tila muuttuu, piirretään komponentti uudestaan.
     */
    @Override
    public void asetaKorostus(boolean korostus) {
        if (this.korostettu == korostus) {
            return;
        }
        this.korostettu = korostus;
        this.piirra();
    }
}

Nyt meillä on kaksi erilaista käyttöliittymäkomponenttia, jotka molemmat voidaan piirtää näytölle. Painike-komponentti on lisäksi klikattava. Käytetään näitä komponentteja pääohjelmassa.

Piirrettava.java
/**
 * Käyttöliittymään piirrettävä komponentti.
 */
public interface Piirrettava {
    public void piirra();
}

Jos haluat testata tätä koodia omalla koneellasi, voit ladata tämänkin esimerkin GitHubista.

Valinnaista lisätietoa: Piirtämisvastuun siirtäminen pois komponenteista

Yllä oleva esimerkkimme on siinä mielessä aavistuksen epätodellinen, että käyttöliittymäkomponentit eivät yleensä huolehdi itse itsensä piirtämisestä, vaan piirtämisvastuu on usein erotettu muuhun osaan järjestelmää. Tällöin komponentit vain tarjoavat tiedot, jotka tarvitaan piirtämiseen, ja joku muu osa järjestelmää huolehtii siitä, että komponentit piirretään oikein näytölle (tai muuhun esitystapaan).

Muokataan esimerkkiämme tämän ajatuksen mukaisesti. Tehdään Naytto-luokka, joka pitää kirjaa kaikista näytöllä näkyvistä käyttöliittymäkomponenteista.

/**
 * Naytto-luokka hallinnoi piirrettäviä komponentteja.
 */
public class Naytto {
    private ArrayList<Piirrettava> komponentit = new ArrayList<>();

    public void lisaaKomponentti(Piirrettava p) {
        komponentit.add(p);
    }

    public void poistaKomponentti(Piirrettava p) {
        komponentit.remove(p);
    }
}

Tehdään myös Piirturi-luokka, joka toimii välikerroksena Naytto-luokan ja käyttöliittymäkomponenttien välillä. Piirturi-luokka huolehtii siitä, että komponentit piirretään oikein näytölle. Tässä esimerkissä ne tulostetaan konsolille, mutta oikeassa käyttöliittymässä ne piirrettäisiin graafiselle näytölle.

/**
 * Piirturi-luokka vastaa piirtoalueen piirtämisestä.
 */
public class Piirturi {
    public void piirraPainike(String teksti, boolean korostettu) {
        if (!korostettu) {
            IO.println("[ " + teksti + " ]");
        } else {
            IO.println("[*" + teksti + "*]");
        }
    }

    public void piirraTeksti(String teksti) {
            IO.println(teksti);
    }

    public void tyhjaa() {
        IO.println("Tyhjennetään piirtoalue");
        // Jätetään tässä toteuttamatta        
    }
}

Nyt Naytto-luokka voi käyttää Piirturi-luokkaa piirtämään ne tarvittaessa. Lisätään Naytto-luokkaan metodi paivita(), joka käy läpi kaikki näytöllä olevat komponentit ja pyytää niitä piirtämään itsensä Piirturi-olion avulla.

import java.util.ArrayList;

/**
 * Naytto-luokka hallinnoi piirrettäviä komponentteja.
 */
public class Naytto {
    private ArrayList<Piirrettava> komponentit = new ArrayList<>();
    // HIGHLIGHT_GREEN_BEGIN
    private Piirturi piirturi = new Piirturi();
    // HIGHLIGHT_GREEN_END

    public void lisaaKomponentti(Piirrettava p) {
        komponentit.add(p);
    }

    public void poistaKomponentti(Piirrettava p) {
        komponentit.remove(p);
    }

    // HIGHLIGHT_GREEN_BEGIN
    public void paivita() {
        piirturi.tyhjaa();
        for (Piirrettava p : komponentit) {
            p.piirra(piirturi);
        }
    }
    // HIGHLIGHT_GREEN_END
}

Huomaa, että Piirrettava-rajapinnan piirra()-metodin tulee nyt ottaa parametrina Piirturi-olio. Tämän avulla komponentit voivat käyttää Piirturi-oliota piirtämiseen.

public interface Piirrettava {
    // HIGHLIGHT_GREEN_BEGIN
    public void piirra(Piirturi piirturi);
    // HIGHLIGHT_GREEN_END
}

Ja nyt se oleellinen kohta: Tämän seurauksena Teksti- ja Painike-luokkien piirra()-metodit eivät enää itse tulosta mitään, vaan ne kutsuvat Piirturi-olion metodeja.

/**
 * Pelkkää tekstiä esittävä piirrettävä komponentti.
 */
public class Teksti implements Piirrettava {
    private String sisalto;
    public Teksti(String sisalto)
    {
        this.sisalto = sisalto;
    }

    /**
     * Piirrä komponentti
     * @param piirturi Piirturi
     */
    @Override
    // HIGHLIGHT_GREEN_BEGIN
    public void piirra(Piirturi piirturi) {
        piirturi.piirraTeksti(sisalto);
    }
    // HIGHLIGHT_GREEN_END
}

Vastaava muutos tulee tehdä Painike-luokkaan.

Tässä meidän yksinkertaisessa esimerkissämme kaikki tietysti tapahtuu konsolille tulostamalla, mutta oikeassa graafisessa käyttöliittymässä Piirturi-luokka voisi käyttää jotain graafista kirjastoa, kuten JavaFX:ää tai Swingiä.

Esimerkki on pitkähkö, ja jos haluat ajaa sen omalla tietokoneellasi, lataa se GitHubista.

Rajapinnan periminen

Rajapinta voi myös laajentaa (periä) toista rajapintaa. Syntaktisesti tämä tapahtuu käyttämällä extends-avainsanaa, kuten luokkien perinnässä. Luokkien perinnästä poiketen rajapinta voi periä useita rajapintoja. Alirajapinta saa kaikki ylirajapinnan metodit. Alla synteettinen esimerkki.

A.java
public interface A {
    void metodiA();
}

Esimerkit

Löydät kaikki tällä sivulla esitellyt esimerkit GitHubista (E34-alkuiset kansiot).

Huomautuksia

Valinnaista lisätietoa: Javan versiosta 8 alkaen rajapinnat voivat sisältää myös metodien oletustoteutuksia. Ominaisuus saattaa olla hyödyllinen esimerkiksi tilanteissa, jossa halutaan lisätä uusi metodi olemassa olevaan rajapintaan rikkomatta vanhoja toteutuksia. Lue aiheesta lisää Javan dokumentaatiosta.

Tehtävät

Tehtävä 4.1: Muunnin. 1 p.
  1. Luo rajapinta nimeltään Muunnin. Määrittele rajapintaan yksi metodi: String muunna(String syote). Muista, että rajapinnassa metodilla ei ole runkoa (ei aaltosulkeita {}).

  2. Tee luokat PienetKirjaimet, IsotKirjaimet ja IsoAlkukirjain, jotka toteuttavat Muunnin-rajapinnan.

  • PienetKirjaimet-luokan muunna-metodi muuntaa annetun merkkijonon pieniksi kirjaimiksi. muunna("Hei Maa") --> "hei maa".
  • IsotKirjaimet-luokan muunna-metodi muuntaa annetun merkkijonon suuraakkosiksi. muunna("Hei Maa") --> "HEI MAA".
  • IsoAlkukirjain-luokan muunna-metodi muuntaa annetun merkkijonon siten, että vain ensimmäinen kirjain on suuraakkonen ja muut pieniä. muunna("HEI MAA") --> "Hei maa".
  1. Testaa ohjelmaasi valmiiksi annetulla pääohjelmalla.
Tee tehtävä TIMissä
Tehtävä 4.2: Vakoojien viestijärjestelmä.1 p.

Vakoojat lähettävät viestejä toisilleen, mutta salausmenetelmä vaihtuu päivittäin, jotta vihollinen ei pääse perille logiikasta. Tarvitsemme rajapinnan, jonka avulla voimme vaihtaa salausalgoritmia lennosta.

  1. Luo rajapinta Salaaja. Määrittele rajapintaan kaksi metodia
String salaa(String viesti);
String pura(String salattuViesti);
  1. Toteuta kolme erilaista luokkaa: Kaantaja, Hakkeri ja SeuraavaKirjain, jotka toteuttavat Salaaja-rajapinnan seuraavilla logiikoilla:
  • Kaantaja (Peilikuvakirjoitus). Kääntää sanan väärinpäin. Esimerkki: "Agentti" → "ittnegA". Vihje: Voit käyttää StringBuilder-luokan reverse()-komentoa tai silmukkaa, joka käy sanan läpi lopusta alkuun.

  • Hakkeri ("Leet-speak"). Korvaa tietyt kirjaimet numeroilla tai merkeillä. Esimerkki: "Agentti" -> "@g3ntt!"

Korvaa 'a' -> '@'
Korvaa 'e' -> '3'
Korvaa 'i' -> '!'
Korvaa 'o' -> '0'
  • SeuraavaKirjain (Caesar-siirros). Jokaista kirjainta siirretään aakkosissa yksi eteenpäin. Esimerkki: abc -> bcd. Vihje: Javassa char on luku. Voit tehdä merkki + 1.
'a' -> 'b'
'b' -> 'c'
'k' -> 'l'
jne. 

Tässä harjoituksessa ei tarvitse huolehtia ö-kirjaimen pyörähtämisestä ympäri, ellei halua. Tehtävässä ei myöskään tarvitse huolehtia siitä, että salauksen ja purkamisen jälkeen saatu viesti ei välttämättä ole samanlainen kuin alkuperäinen viesti. Esimerkiksi jos Hakkeri-muuntajaa käytettäessä alkuperäisessä viestissä on oikeasti merkki @, pura-metodi antaa tulokseksi tuohon paikalle merkin a. Tämä ei haittaa tässä, mutta tietenkin oikeassa salauksessa pitäisi varmistaa, ettei tietoa katoa tai muutu vahingossa.

Saat TIMissä valmiina pääohjelman, jonka avulla voit testata luokkarakennettasi.

Tee tehtävä TIMissä

Comparable-rajapinta ja luonnollinen järjestys

Rajapinta Comparable määrittelee metodin compareTo, jonka avulla luokan olioille voi määrittää luonnollisen järjestykse suhteessa toiseen saman tyyppiseen olioon.

Rajapinnassa on yksi metodi, compareTo, joka palauttaa kokonaisluvun, joka ilmaisee olion järjestyksen suhteessa toiseen olioon. Palautusarvo tulkitaan seuraavasti:

TapausMerkitysTulkinta
olioA.compareTo(olioB) < 0olioA < olioBolioA on pienempi kuin olioB
olioA.compareTo(olioB) == 0olioA == olioBolioA on yhtä suuri kuin olioB
olioA.compareTo(olioB) > 0olioA > olioBolioA on suurempi kuin olioB

Esimerkiksi Integer-tyyppi toteuttaa Comparable-rajapinnan Integer-olioille. Niinpä kahta kokonaislukuoliota voidaan vertailla keskenään compareTo-metodilla.

void main() {
  Integer luku1 = 5;
  Integer luku2 = 18;
  int tulos = luku1.compareTo(luku2);

  // Tulostaa negatiivisen arvon (< 0), koska 5 < 18
  IO.println("luku1.compareTo(luku2): " + tulos);
}

Merkkijonoille on valmiiksi määriteltynä luonnollinen järjestys aakkosjärjestyksenä:

// Apufunktio, joka tulostaa kahden merkkijonon välisen järjestyksen
void kerroJarjestys(String sana1, String sana2) {
  int tulos = sana1.compareTo(sana2);
  if (tulos < 0) {
      IO.println("Merkkijono '" + sana1 + "' on järjestyksessä ennen merkkijonoa '" + sana2 + "'");
  } else if (tulos > 0) {
      IO.println("Merkkijono '" + sana1 + "' on järjestyksessä merkkijonon '" + sana2 + "' jälkeen");
  } else {
      IO.println("'" + sana1 + " on yhtä suuri kuin '" + sana2 + "'");
  }
}

void main() {
  String sana1 = "omena";
  String sana2 = "appelsiini";
  String sana3 = "banaani";
  kerroJarjestys(sana1, sana2);
  kerroJarjestys(sana1, sana3);
  kerroJarjestys(sana2, sana3);
}

Vastaavasti Integer-luokalla kokonaislukujen luonnollinen järjestys on nouseva suuruusjärjestys.

int kerroJarjestys(Integer luku1, Integer luku2) {
  int tulos = luku1.compareTo(luku2);
  if (tulos < 0) {
      IO.println(luku1 + " on pienempi kuin " + luku2);
  } else if (tulos > 0) {
      IO.println(luku1 + " on suurempi kuin " + luku2);
  } else {
      IO.println(luku1 + " on yhtä suuri kuin " + luku2);
  }
  return tulos;
}

void main() {
  Integer luku1 = 5;
  Integer luku2 = 18;
  Integer luku3 = 5;
  kerroJarjestys(luku1, luku2);
  kerroJarjestys(luku1, luku3);
  kerroJarjestys(luku2, luku3);
}

Olennainen Comparable-rajapinnan hyöty on, että voimme kirjoittaa ohjelmia ja käyttää vertailuja vaativia algoritmeja, jotka toimivat yleisesti kaikille olioille, jotka toteuttavat Comparable-rajapinnan. Kyseisen rajapinnan toteuttavat luokat voidaan myös järjestää käyttämällä Javan valmiita kokoelmien järjestämistoteutuksia, kuten esimerkiksi Collections.sort-metodia. Näin meidän ei tarvitse itse kirjoittaa järjestämisalgoritmeja.

void main() {
    List<Integer> numerot = Arrays.asList(18, 5, 42);
    List<String> hedelmat = Arrays.asList("omena",  "päärynä", "appelsiini");
    IO.println(numerot); // [18, 5, 42]
    IO.println(hedelmat); // [omena, päärynä, appelsiini]

    // Järjestetään listat alkioiden luontaisen järjestyksen mukaan
    Collections.sort(hedelmat);
    Collections.sort(numerot);

    IO.println(numerot); // [5, 18, 42]
    IO.println(hedelmat); // [appelsiini, omena, päärynä]
}
Ekstra: Collections-luokka

Collections tarjoaa yllämainitun sort-metodin lisäksi monia yleishyödyllisiä metodeja Javan kokoelmille. Kokoelma on Javassa käytetty yleistys alkioita sisältäville tietorakenteille, kuten taulukoille, listoille ja sanakirjoille.

Käsittelemme kokoelmia tarkemmin osassa 5. Voit kuitenkin halutessasi tutkia jo Collections-luokkaa, joka sisältää metodeja kokoelmien käsittelyyn. Mikäli katsot linkin, varaudu, että se sisältää paljon vasta myöhemmin käsiteltävää syntaksia.

Monet Collections-luokan metodit perustuvat siihen, että kokoelman alkiot toteuttavat erilaisia rajapintoja, kuten edellä mainittu Comparable. Yhdistämällä Javan kokoelmille ja alkioille tarkoitettuja rajapintoja onkin mahdollista kirjoittaa hyvin yleisiä algoritmeja, jotka toimivat riippumatta siitä, onko parametrina lista numeroita tai vaikkapa taulukko opiskelijoita.

Tehtävä 4.5: Miksi Comparable. 1 p.

Tutki Javan dokumentaatiota. Vastaa kysymyksiin Comparable-rajapinnasta.

Tee tehtävä TIMissä

Oma toteutus Comparable-rajapinnalle

Kokeillaan Comparable-rajapinnan toteuttamista omassa luokassamme.

Otetaan esimerkiksi luokka Kerailykortti, joka mallintaa eräässä keräilypelissä käytettäviä kortteja. Meidän keräilykortti sisältää alkuun vain keräilykortin nimen ja yksilöivän, ykkösestä alkavan tunnistenumeron:

Kerailykortti.java
class Kerailykortti {
    private String nimi;
    private int tunnistenumero;

    public Kerailykortti(String nimi, int tunnistenumero) {
        this.nimi = nimi;
        this.tunnistenumero = tunnistenumero;
    }

    @Override
    public String toString() {
        return "Kortti: " + nimi + " (#" + tunnistenumero + ")";
    }
}

Mikäli nyt yritämme järjestää Kerailykortti-olioita Collections.sort()-metodilla, saamme käännöksenaikaisen virheen, koska se ei toteuta Comparable-rajapintaa:

main.java
void main() {
    List<Kerailykortti> kortit = Arrays.asList(
        new Kerailykortti("Loistava Lohikäärme", 3),
        new Kerailykortti("Aloittelijan Ameeba", 1),
        new Kerailykortti("Mieletön Merihevonen", 2)
    );

    IO.println("Ennen järjestämistä:");
    for (Kerailykortti kortti : kortit) {
        IO.println(kortti);
    }

    Collections.sort(kortit);

    IO.println();

    IO.println("Jälkeen järjestämisen:");
    for (Kerailykortti kortti : kortit) {
        IO.println(kortti);
    }
}
main.java:11: error: no suitable method found for sort(List<Kerailykortti>)
    Collections.sort(kortit);

Virheilmoitus on vähintäänkin kryptinen. Yksinkertaistetusti virhe johtuu perimmäisesti siitä, että Collections.sort() ei voi meidän puolestamme arvata, mikä on Kerailykortti-olioiden luonnollinen järjestys. Onko se kenties kortin nimen aakkosjärjestys vai kenties numerotunnisteen mukainen nouseva järjestys? Vastataksemme tähän kysymykseen meidän täytyy toteuttaa Comparable-rajapinta Kerailykortti-luokalle.

Kun lähdemme toteuttamaan Comparable-rajapintaa keräilykortille, joudumme heti pohtimaan, mikä on luonnollinen järjestys keräilykorteillemme. Esimerkiksi aakkosjärjestys nimen mukaan voi olla hyödyllinen. Toisaalta koska korteilla on numeeriset ykkösestä alkavat numerotunnisteet, numerojärjestys tunnisteen mukaan voidaan myös mieltää luonnollisemman tuntuiseksi ja yhtälailla tarpeelliseksi. Luonnollista järjestystä valittaessa on lisäksi syytä pohtia kohdealueen ja sovelluksen tarpeen — mitä luokkaa käyttäjät muut ohjelmoijat tai sovelluksen lopulliset käyttäjät kaipaavat tai olettavat keräilykorttien oletusjärjestyksestä?

Päättäkäämme tämän esimerkin puiteissa, että järjestys yksilöllisen tunnisteen mukaan on tässä tapauksessa järkevin luonnollinen järjestys. Toteutetaan tällä pohjustuksella Comparable-rajapinta siten, että kortit järjestetään numerotunnisteen mukaan. Tätä varten tarvitsemme rajapinnan toteutuksen luokan määrittelyyn sekä toteutuksen edellä mainitulle compareTo-metodille.

Käytämme toteutuksessa osan alussa olevaa palautustaulukkoa:

Kerailykortti.java
// HIGHLIGHT_GREEN_BEGIN
class Kerailykortti implements Comparable<Kerailykortti> {
// HIGHLIGHT_GREEN_END
    private String nimi;
    private int tunnistenumero;

    public Kerailykortti(String nimi, int tunnistenumero) {
        this.nimi = nimi;
        this.tunnistenumero = tunnistenumero;
    }

    // HIGHLIGHT_GREEN_BEGIN
    @Override
    public int compareTo(Kerailykortti other) {
        if (tunnistenumero > other.tunnistenumero) {
            return 1;
        }
        if (tunnistenumero < other.tunnistenumero) {
            return -1;
        }
        return 0;
    }
    // HIGHLIGHT_GREEN_END

    @Override
    public String toString() {
        return "Kortti: " + nimi + " (#" + tunnistenumero + ")";
    }
}

Comparable on niin sanottu geneerinen rajapinta, eli se ei itsessään kerro minkä tyyppisiin olioihin vertailu kohdistuu. Käsittelemme geneeristä ohjelmointia tarkemmin osassa 4.4. Tästä syystä Comparable-rajapinnan toteuttamisessa meidän täytyy kertoa minkä tyypin olioille luonnollinen järjestys määritellään. Tässä tapauksessa toteutamme järjestyksen keräilykorteille, joten määrittelemme implements Comparable<Kerailykortti>.

Valmiiden vertailumetodien käyttö

Yllä olevassa tapauksessa toteutimme compareTo-metodin käyttäen suoraan Comparable-rajapinnan määritelmää. Kuitenkin Javan valmiit tyypit useimmiten tarjoavat jo valmiita vertailumetodeja, joita voi hyödyntää Comparable-rajapinnan toteuttamiseksi.

Esimerkiksi int-kokonaisluvuille Java tarjoaa valmiin Integer.compare-metodin, jolla Kerailykortti-luokan compareTo-metodin toteutus voidaan yksinkertaistaa yhden rivin funktioksi:

class Kerailykortti implements Comparable<Kerailykortti> {
    private String nimi;
    private int tunnistenumero;

    public Kerailykortti(String nimi, int tunnistenumero) {
        this.nimi = nimi;
        this.tunnistenumero = tunnistenumero;
    }

    @Override
    public int compareTo(Kerailykortti other) {
        // HIGHLIGHT_GREEN_BEGIN
        return Integer.compare(tunnistenumero, other.tunnistenumero);
        // HIGHLIGHT_GREEN_END
    }
 
    @Override
    public String toString() {
        return "Kortti: " + nimi + " (#" + tunnistenumero + ")";
    }
}

void main() {
    List<Kerailykortti> kortit = Arrays.asList(
        new Kerailykortti("Loistava Lohikäärme", 3),
        new Kerailykortti("Aloittelijan Ameeba", 1),
        new Kerailykortti("Mieletön Merihevonen", 2)
    );

    IO.println("Ennen järjestämistä:");
    IO.println(kortit);

    Collections.sort(kortit);

    IO.println("Jälkeen järjestämisen:");
    IO.println(kortit);
}

Toteuttaessa Comparable-rajapintaa itse tehdyille luokille onkin syytä suosia valmiita vertailumetodeja ja niiden yhdistämistä. Esimerkiksi Integer.compare osaa käsitellä kaikkia erikoistapauksia, kuten lukualueen ylivuotoja. Vastaavasti Double.compare osaa käsitellä kaikkia liukulukutyyppien erikoisarvoja, kuten äärettömyyttä tai "Not a Number" -arvoja.

Useamman attribuutin vertailu

Luonnollinen järjestys voi määräytyä useamman attribuutin mukaan. Esimerkiksi kortit voitaisiin järjestetää ensin aakkosjärjestyksen ja vasta sitten numerotunnisteen mukaan. Tätä varten meidän täytyy muuttaa compareTo-metodia siten, että ensin verrataan nimi-arvoja aakkosjärjestyksen mukaan käyttäen String-luokan omaa compareTo-metodia. Jos merkkijonot ovat samat (eli compareTo palauttaa 0), tehdään vertailu tunnistenumero-attribuutille.

Kerailykortti.java
class Kerailykortti implements Comparable<Kerailykortti> {
    private String nimi;
    private int tunnistenumero;

    public Kerailykortti(String nimi, int tunnistenumero) {
        this.nimi = nimi;
        this.tunnistenumero = tunnistenumero;
    }

    @Override
    public int compareTo(Kerailykortti other) {
        // HIGHLIGHT_GREEN_BEGIN
        int nimiVertailu = this.nimi.compareTo(other.nimi);
        if (nimiVertailu != 0) {
            return nimiVertailu;
        }
        return Integer.compare(this.tunnistenumero, other.tunnistenumero);
        // HIGHLIGHT_GREEN_END
    }

    @Override
    public String toString() {
        return "Kortti: " + nimi + " (#" + tunnistenumero + ")";
    }
}

Tehtävät

Tehtävä 4.6: Henkilöt järjestykseen, osa 1. 1 p.

Tehtävässä on pohjana Henkilo-luokka omassa tiedostossaan sekä jarjestaHenkilot-metodi main.java-tiedostossa. Kyseinen metodi ei kuitenkaan toimi, sillä se käyttää Javan valmista Collections.sort-metodia ja Henkilo-luokasta puuttuu sille tuki.

Muokkaa Henkilo-luokkaa niin, että List<Henkilo>-tyyppiset listat voidaan järjestää Collections.sort-metodilla henkilön nimen mukaan aakkosjärjestykseen.

Esimerkiksi listan

List<Henkilo> henkilot = Arrays.asList(
    new Henkilo("Joukahainen"),
    new Henkilo("Ilmatar"),
    new Henkilo("Kyllikki"),
    new Henkilo("Kokko")
);

pitäisi olla Collections.sort(henkilot);-kutsun jälkeen järjestyksessä:

  1. Ilmatar
  2. Joukahainen
  3. Kokko
  4. Kyllikki
Tee tehtävä TIMissä
Tehtävä 4.7: Henkilöt järjestykseen, osa 2. 1 p.

Jatkoa edelliselle tehtävälle. Nyt Henkilo-luokassa henkilöiden nimet on jaettu erikseen sukunimeen ja etunimiin.

Muokkaa uudistettua Henkilo-luokkaa niin, että List<Henkilo>-tyyppiset listat voidaan järjestää Collections.sort-metodilla henkilön sukunimen ja etunimien mukaan aakkosjärjestykseen, niin että järjestys tapahtuu ensin sukunimen mukaan.

Esimerkiksi listan

List<Henkilo> henkilot = Arrays.asList(
        new Henkilo("Pacius", "Fredrik"),
        new Henkilo("Mozart", "Wolfgang Amadeus"),
        new Henkilo("Mozart", "Leopold"),
        new Henkilo("Chopin", "Frédéric")
);

pitäisi olla Collections.sort(henkilot);-kutsun jälkeen järjestyksessä:

  1. Chopin Frédéric
  2. Mozart Leopold
  3. Mozart Wolfgang Amadeus
  4. Pacius Fredrik
Tee tehtävä TIMissä

Perintä ja rajapinnat olioiden yhteistyössä

osaamistavoitteet

  • Osaat hyödyntää rajapintoja ja abstrakteja luokkia luokkien välisen riippuvuuden välttämiseksi
  • Tunnistaa milloin perintää kannattaa käyttää, ja milloin koostaminen on parempi vaihtoehto. ("Composition over inheritance")

alt text

Perintä ja rajapinnat voivat toimia, ja usein toimivatkin yhdessä. Perintä määrittelee luokkien välisen hierarkian ja jakaa yhteistä toiminnallisuutta, kun taas rajapinnat määrittelevät kyvykkyyksiä, joita eri luokat voivat toteuttaa riippumatta niiden sijainnista luokkahierarkiassa.

Itse asiassa käytimme jo Älykoti-esimerkissämme sekä perintää (Laite abstraktina luokkana) että rajapintaa (Saadettava-rajapinta). Laajennetaan kuitenkin perinnän ja rajapintojen yhteistyötä hieman eteenpäin. Tarkastellaan tilannetta, jossa meillä on ohjelmassamme luokkia, jotka eivät jaa yhteistä yliluokkaa, mutta kuitenkin jakavat yhteisen kyvykkyyden.

Pistorasia ja sähkölaitteet

Tehdään pieni ajatusharjoitus. Kuvittele kotisi seinässä olevaa pistorasiaa. Pistorasia tarjoaa sähkövirtaa, mutta se ei anna sitä mihin tahansa. Se vaatii, että laitteessa on sopiva pistotulppa, joka sopii pistorasiaan.

Tässä analogiassa rajapinta on se standardi eli sopimus, jonka laitteen täytyy täyttää, jotta se voi käyttää pistorasiaa. Asiaa voidaan tarkastella myös niin päin, että jos laitteessa on pistorasiaan sopiva pistotulppa, niin sillä täytyy olla kyky toimia siinä tilanteessa, että se kytketään pistorasiaan.

Pistorasiaa ei kiinnosta, kytketkö siihen leivänpaahtimen vai sirkkelin. Laitteet ovat itse asiassa täysin erilaisia. Toisella voi tehdä ruokaa, toinen on työkalu. Niillä ei ole yhteistä "esi-isää" laitehierarkiassa samalla tavalla, kuin vaikkapa Auto ja Moottoripyora voisivat periä Ajoneuvo-luokan. Ainoa leivänpaahdinta ja sirkkeliä yhdistävä tekijä on kyky kytkeytyä verkkovirtaan.

Jos yrittäisimme mallintaa tämän perinnällä, joutuisimme ongelmiin heti, kun haluaisimme käyttää leivänpaahdinta. Onko leivänpaahdin Sahkolaite, Keittiolaite, vai kenties molempia? Javassa luokka ei kuitenkaan voi periä kahta yliluokkaa.

Rajapinta ratkaisee tämän ongelman tyylikkäästi:

  • Leivanpaahdin on Keittiolaite (perintä), mutta se myös toteuttaa Verkkovirtalaite-rajapinnan.
  • Samoin Sirkkeli voisi olla vaikkapa Tyokalu (perintä), joka myöskin toteuttaa saman Verkkovirtalaite-rajapinnan.

Näin pistorasia voi hyväksyä kumman tahansa laitteen, koska molemmat täyttävät sopimuksen eli toteuttavat rajapinnan vaatiman kytkennän.

Yksinkertaisimmillaan Verkkovirtalaite-rajapinnan sisältö olisi määritelmä siitä, että laitteen on pystyttävä reagoimaan siihen, kun se kytketään pistorasiaan ja virta alkaa kulkea johdossa.

public interface Verkkovirtalaite {
    // Tämä metodi on "pistotulppa". 
    // Kun pistorasia aktivoi tämän, laite saa sähköä.
    void kytkeVirta();
}

Nyt Leivanpaahdin ja Sirkkeli voivat toteuttaa tämän rajapinnan.

public class Leivanpaahdin implements Verkkovirtalaite {
    
    @Override
    public void kytkeVirta() {
        // Leivänpaahtimen oma tapa reagoida virtaan:
        IO.println("Leivänpaahdin: Vastukset alkavat hehkua punaisena.");
    }
}

public class Sirkkeli implements Verkkovirtalaite {
    
    @Override
    public void kytkeVirta() {
        // Sirkkelin oma tapa reagoida virtaan:
        IO.println("Sirkkeli: Moottori alkaa pyörittää terää 4000 rpm.");
    }
}

Nämä luokat voivat olla aivan eri puolella luokkahierarkiaa. Toinen on keittiölaite, toinen työkalu. Molemmat kuitenkin reagoivat sähkövirran kytkemiseen -- joskin omalla tavallaan. Tehdään vielä abstraktit Keittiolaite- ja Tyokalu-yliluokat, joista Leivanpaahdin ja Sirkkeli periytyvät. Jotta esimerkki olisi hieman mielekkäämpi, lisätään näihin yliluokkiin joitain ominaisuuksia ja metodeja.

public abstract class Keittiolaite {
    /**
     * Sisältääkö laite lämmitysvastuksia.
     */
    boolean lammittava;

    /**
     * Kaikki keittiölaitteet pitää voida pestä.
     */
    public abstract void puhdista();
}

public abstract class Tyokalu {
    /**
     * Laitteen käyttötunnit
     */
    private int kayttotunnit = 0;

    /**
     * Käytä laitetta
     * @param tunnit Montako tuntia laitetta käytetään.
     */
    public void kayta(int tunnit)
    {
        this.kayttotunnit = tunnit;
    }

    /**
     * Huolla laitetta
     * @return Onnistuiko huolto
     */
    public abstract boolean huolla();
}

Toteutetaan nyt nuo ominaisuudet ja metodit perivissä luokissa.

// Sirkkeli on Työkalu, joka toimii verkkovirralla
public class Sirkkeli extends Tyokalu implements Verkkovirtalaite {

    @Override
    public void kytkeVirta() {
        // Sirkkelin oma tapa reagoida virtaan:
        IO.println("Sirkkeli: Moottori alkaa pyörittää terää 4000 rpm.");

        // Kutsutaan tässä myös yliluokan kayta()-metodia, jolloin
        // käyttötunnit lisääntyvät.
        super.kayta(1);
    }

    /**
     * Huolletaan sirkkeli.
     * @return Onnistuiko huolto.
     */
    @Override
    public boolean huolla() {
        IO.println("Huolletaan sirkkeliä..."
         + "Teroitetaan terää ja säädetään kierrosnopeutta.");
        return true;
    }
}

// Leivänpaahdin on Keittiölaite, joka toimii verkkovirralla
public class Leivanpaahdin extends Keittiolaite 
implements Verkkovirtalaite {

    @Override
    public void kytkeVirta() {
        // Leivänpaahtimen oma tapa reagoida virtaan:
        IO.println("Leivänpaahdin: "
        + "Vastukset alkavat hehkua punaisena.");
    }

    @Override
    public void puhdista() {
        IO.println("Leivänpaahdin: Poistetaan murut "
        + "ja pyyhitään kevyesti kostealla rätillä.");
    }
}

Luokkahierarkia näyttäisi seuraavanlaiselta.

Tämä on tärkein kohta ymmärryksen kannalta: Pistorasia on luokka, joka käyttää rajapintaa.

public class Pistorasia {
    
    // Pistorasiaan voi kytkeä MINKÄ TAHANSA verkkovirtalaitteen.
    // Pistorasiaa ei kiinnosta, onko se sirkkeli vai paahdin.
    public void kytkeLaite(Verkkovirtalaite laite) {
        IO.println("--- Pistorasia antaa sähköä ---");
        
        // Pistorasia kutsuu sopimuksen mukaista metodia.
        // Tässä toteutuu polymorfismi: 
        // laite reagoi oikealla, sille ominaisella tavalla.
        laite.kytkeVirta();
    }
}

Huomaamme, että aliohjelman parametrin tyyppinä on Verkkovirtalaite-rajapinta! Parametrin ei tarvitse olla Leivanpaahdin, Sirkkeli tai mikään muukaan konkreettinen luokka. Riittää, että se toteuttaa Verkkovirtalaite-rajapinnan.

Tässä kytkeLaite()-metodi ottaa parametrinaan Verkkovirtalaite-rajapinnan mukaisen tyypin. Tämä tarkoittaa, että metodi voi hyväksyä minkä tahansa olion, joka toteuttaa tämän rajapinnan, riippumatta siitä, mihin luokkahierarkiaan kyseinen olio kuuluu.

Rajapinta muuttujan tyyppinä

Jotta Pistorasia-luokka pääsisi tositoimiin, tarvitsemme vielä pääohjelman, jossa luomme Pistorasia-olion ja kytkemme siihen erilaisia laitteita. Luodaan nyt pääohjelma, jossa kytketään ensin Leivanpaahdin pistorasiaan.

Esimerkki sisältää jo aika monta tiedostoa, joten lue esimerkki huolellisesti läpi. Voit vaihtoehtoisesti selata esimerkin tiedostoja GitHubissa.

main.java
public class KodinSahkot {

    public static void main(String[] args) {

        // 1. Luodaan infrastruktuuri: Pistorasia
        // Tässä kohtaa Pistorasia-olio syntyy tietokoneen muistiin.
        Pistorasia keittionPistoke = new Pistorasia();

        // 2. Luodaan laitteet
        Leivanpaahdin paahdin = new Leivanpaahdin();
        Sirkkeli sirkkeli = new Sirkkeli();

        // 3. Käytetään laitteita pistorasian kautta
        IO.println("--- Aamu keittiössä ---");

        // Kytketään paahdin seinään
        keittionPistoke.kytkeLaite(paahdin);
        IO.println("\n--- Remontti alkaa ---");

        // Kytketään sirkkeli SAMAAN pistorasiaan
        // Koska yhdessä pistorasiassa voi olla yksi laite kerrallaan,
        // paahdin irrotetaan, vaikka sitä ei erikseen
        // tässä esitetäkään.
        keittionPistoke.kytkeLaite(sirkkeli);
    }
}

Kuten Osassa 3.2 Polymorfismi opimme, meidän ei olisi pääohjelmassa pakko määritellä paahdin- ja sirkkeli-muuttujia konkreettisten tyyppien (Leivanpaahdin ja Sirkkeli) avulla, vaan voisimme määritellä molemmat Verkkovirtalaite-tyyppisiksi. Tässähän nimittäin meitä kiinnostaa vain se, että laitteet pystytään kytkemään pistorasiaan.

public class KodinSahkot {

    public static void main(String[] args) {

        // 1. Luodaan infrastruktuuri: Pistorasia
        // Tässä kohtaa Pistorasia-olio syntyy tietokoneen muistiin.
        Pistorasia keittionPistoke = new Pistorasia();

        // 2. Luodaan laitteet
        // HIGHLIGHT_GREEN_BEGIN
        Verkkovirtalaite paahdin = new Leivanpaahdin();
        Verkkovirtalaite sirkkeli = new Sirkkeli();
        // HIGHLIGHT_GREEN_END

        // 3. Käytetään laitteita pistorasian kautta
        IO.println("--- Aamu keittiössä ---");

        // Kytketään paahdin seinään
        keittionPistoke.kytkeLaite(paahdin);
        IO.println("\n--- Remontti alkaa ---");

        // Kytketään sirkkeli SAMAAN pistorasiaan
        // Koska yhdessä pistorasiassa voi olla yksi laite kerrallaan,
        // paahdin irrotetaan, vaikka sitä ei erikseen
        // tässä esitetäkään.
        keittionPistoke.kytkeLaite(sirkkeli);
    }
}

Ylätyyppiä vasten ohjelmointi

Yllä kuvattu tapa, jossa aliluokan oliota käsitellään ylätyypin, eli yliluokan tai rajapinnan tyyppisenä, on olio-ohjelmoinnissa hyvin yleinen ja suositeltava käytäntö. Tätä tapaa kutsutaan usein nimellä rajapintaa vasten ohjelmointi (program to an interface) tai ylätyyppiä vasten ohjelmointi (program to a supertype). Näin ohjelman eri osat kytkeytyvät toisiinsa löyhemmin, ja yksittäisiä toteutuksia voidaan vaihtaa ilman, että muuta koodia tarvitsee muuttaa.

Miksi ylätyyppiä vasten ohjelmointi on hyödyllistä? Yksi syy on se, että voimme nyt käsitellä hyvin eri tyyppisiä olioita yhtenäisenä joukkona; näinhän tehtiin jo Tehtävässä 3.4. Otetaan vaikkapa Verkkovirtalaite-esimerkkimme: voimme esimerkiksi luoda listan erilaisista verkkovirtalaitteista ja kytkeä ne kaikki pistorasiaan silmukassa.

List<Verkkovirtalaite> laitteet = List.of(
    new Leivanpaahdin(),
    new Sirkkeli(),
    new Imuri()
);

Pistorasia pistorasia = new Pistorasia();

for (Verkkovirtalaite v : laitteet) {
    pistorasia.kytkeLaite(v);
}

Jos jokainen laite olisi määritelty omaksi tyypikseen, meidän täytyisi kirjoittaa seuraavasti (oletetaan jälleen, että Keittiolaite ja Tyokalu ovat olemassa olevia yliluokkia).

List<Keittiolaite> keittionLaitteet = ...;
List<Tyokalu> tyokalut = ...;

Pistorasia pistorasia = new Pistorasia();

for (Keittiolaite k : keittionLaitteet) {
    pistorasia.kytkeLaite(k);
}

for (Tyokalu t : tyokalut) {
    pistorasia.kytkeLaite(t);
}

Toinen syy on helppo vaihdettavuus, josta käytetään englanninkielistä termiä loose coupling. Kun koodi käyttää rajapintaa muuttujan tyyppinä, se ei ole sidottu tiettyyn toteutukseen. Tämä tarkoittaa, että voimme helposti vaihtaa yhden toteutuksen toiseen ilman, että meidän tarvitsee muuttaa koodia, joka käyttää kyseistä rajapintaa.

Kuvitellaan, että teemme ohjelmaa, joka testaa sähkölaitteita.

Leivanpaahdin testattavaLaite = new Leivanpaahdin();

// .. suoritetaan laitteen testaus ..

// Vaihdetaan testattava laite toiseen toteutukseen
testattavaLaite = new Sirkkeli(); // Ei onnistu, koska tyypit eivät täsmää

Kun muuttuja määritellään rajapintana, voimme helposti luoda erilaisia testilaitteita, jotka toteuttavat saman rajapinnan, ja voimme vaihtaa konkreettisen toteutuksen vapaasti.

Verkkovirtalaite testattavaLaite = new Leivanpaahdin();
// .. suoritetaan laitteen testaus ..

// Vaihdetaan testattava laite toiseen toteutukseen
testattavaLaite = new Sirkkeli(); 

// Tämä onnistuu, koska molemmat toteuttavat 
// Verkkovirtalaite-rajapinnan

Kolmas syy liittyy ohjelmiston suunnitteluun ja käytännön kirjoittamiseen. Kun määrittelet muuttujan tyypiksi Verkkovirtalaite, kääntäjä estää sinua kutsumasta metodeja, jotka ovat spesifejä vain leivänpaahtimille (kuten saadaKuumuus()) tai sirkkelille (kuten asetaTeranKorkeus()). Vaikka tällainen itsensä rajoittaminen saattaa tuntua oudolta, se auttaa pitämään koodin selkeänä ja estää virheitä, joissa yritetään käyttää laitetta tavalla, joka ei ole yhteensopiva sen rajapinnan kanssa.

Liskovin korvausperiaate

Yllä mainittuun loose coupling-periaatteeseen liittyy läheisesti myös Liskovin korvausperiaate (engl. Liskov Substitution Principle, LSP). LSP on olio-ohjelmoinnin periaate, jonka mukaan, että olion tulee olla korvattavissa sellaisella oliolla, joka toteuttaa saman rajapinnan tai sovitun sopimuksen ilman, että ohjelman käyttäytyminen muuttuu. Niinpä esimerkiksi aliluokan tulee noudattaa yliluokan määrittelemiä sopimuksia ja käyttäytymismalleja, tai vastaavasti rajapinnan toteuttavan luokan tulee noudattaa rajapinnan määrittelemiä sopimuksia.

Palataan hetkeksi Osassa 3.2 alustettuun soitin-esimerkkiin. Oletetaan, että meillä on Soitin-rajapinta, joka määrittelee yleisölle musiikkia metodin soita().

public interface Soitin {
    /**
     * Esittää kappaleen yleisölle.
     */
    void soita();
}

Kaikki soittimet, kuten Kitara, Piano ja Rumpusetti, toteuttavat tämän rajapinnan. Konsertin järjestäjä haluaa varmistaa, että kaikki soittimet voivat soittaa huolimatta siitä, minkä tyyppisiä soittimia ne ovat. Tehdään Konsertti-luokka, jonka soitaKaikkiaSoittimia()-metodi laittaa kaikki soittimet soimaan.

public class Konsertti {
    public void soitaKaikkiaSoittimia(Soitin[] soittimet) {
        for (Soitin soitin : soittimet) {
            soitin.soita();
        }
    }
}

Tehdään nyt uusi soitin, HarjoitusPiano, jolla voi soittaa vain kuulokkeilla, jolloin yleisö ei kuule mitään. Tämäkin soitin toteuttaa Soitin-rajapinnan, mutta sen käyttäytyminen poikkeaa muista soittimista.

class HarjoitusPiano implements Soitin {
    @Override
    public void soita() {
        // Toteutus, joka tekee sinänsä jotain "järkevää", mutta rikkoo sopimuksen.
        IO.println("Harjoitellaan kuulokkeilla. Yleisö ei kuule mitään.");
    }
}

Kootaan nyt "konsertti" pääohjelmaan.

void main() {
    Soitin[] soittimet = {
        new Kitara(),
        new Piano(),
        new HarjoitusPiano()
    };

    Konsertti konsertti = new Konsertti();
    konsertti.soitaKaikkiaSoittimia(soittimet);
}

Koodi kyllä sinänsä toimii teknisesti. Silti HarjoitusPiano rikkoo Soitin-rajapinnan sopimusta: sen soita() ei "esitä kappaletta yleisölle", vaan vain soittajalle itselleen. Aliluokan toiminta voisi olla järkevää jossain toisessa kontekstissa (tässä, harjoittelutilanteessa). Hyvin suunnitellussa luokkahierarkiassa kuitenkin jokainen olio noudattaa yliluokan tai rajapinnan lupaamaa sopimusta. Tällöin polymorfismia voidaan käyttää luotettavasti ilman, että ohjelman käyttäjän tarvitsee tuntea kaikkia konkreettisia aliluokkia erikseen.

Myös kodin sähkölaitteet -esimerkkimme liittyy samaan aiheeseen. Muistetaan, että koska Esimerkissä 3.8 paahdin ja sirkkeli määriteltiin Verkkovirtalaite-tyyppisiksi, emme voi kutsua niille metodeja, jotka eivät ole määritelty kyseisessä rajapinnassa. Esimerkiksi emme voi kutsua paahdin.puhdista() tai sirkkeli.huolla(), koska nämä metodit eivät kuulu Verkkovirtalaite-rajapintaan. Jos todella tarvitsisimme pääsyn näihin metodeihin, meidän tulisi pysähtyä miettimään, miksi käsittelemme oliota ylipäätään pelkkänä verkkovirtalaitteena. Ohjelmistosuunnittelun näkökulmasta tilanne vihjaa siihen, että yritämme ehkä ratkaista kahta eri ongelmaa samassa paikassa.

Hyvässä suunnittelussa koodi, joka käsittelee Verkkovirtalaite-tyyppisiä olioita (kuten sähkömittari tai sulakekaappi), on kiinnostunut vain ja ainoastaan sähköön liittyvistä asioista. Sitä ei pitäisikään kiinnostaa, onko laite leivänpaahdin vai sirkkeli, eikä sen kuulu yrittää puhdistaa tai huoltaa niitä.

Jos huomaamme tarvitsevamme puhdista()-metodia, olemme todennäköisesti "väärässä huoneessa":

  • Väärä abstraktiotaso: Jos olemme rakentamassa sovelluslogiikkaa keittiön siivousta varten, meidän ei pitäisi säilyttää laitteita List<Verkkovirtalaite>-listassa, vaan List<Keittiolaite>-listassa. Tällöin kaikilla listan olioilla on luonnostaan puhdista()-metodi käytettävissä ilman kikkailua.

  • Vastuun jako (Single Responsibility): Jos samassa aliohjelmassa yritetään sekä mitata sähkönkulutusta (rajapinta) että pestä laite (abstrakti luokka), aliohjelma tekee liikaa asioita. Parempi ratkaisu on jakaa ohjelma osiin: yksi osa hallinnoi sähköverkkoa (Verkkovirtalaite-rajapinnan kautta) ja toinen osa huolehtii ylläpidosta (Keittiolaite- tai Tyokalu-tyyppien kautta).

Tiivistetysti: Sen sijaan, että yrittäisimme pakottaa yleisen rajapinnan kautta esiin erityisominaisuuksia, meidän tulisi valita muuttujan tyyppi sen mukaan, mitä olemme sillä hetkellä tekemässä. Sähkömies näkee sirkkelin verkkovirtalaitteena, puuseppä näkee sen työkaluna – ja koodin tulisi heijastaa tätä roolijakoa."

Abstrakti luokka vai rajapinta?

Alla on lyhyt yhteenvetotaulukko, joka tiivistää abstraktin luokan ja rajapinnan keskeiset erot syntaktin ja käyttötarkoituksen osalta.

KysymysAbstrakti luokkaRajapinta
Voiko sisältää attribuutteja?KylläEi
Voiko sisältää metodien toteutuksia?KylläEi (Java v8 alkaen mahdollisuus ns. default-metodeihin)
Kuinka monta voi periä/toteuttaa?Luokka voi periä vain yhden abstraktin luokanLuokka voi toteuttaa useita rajapintoja
KäyttötarkoitusYhteinen runko ja osittainen toteutusYhteinen sopimus käyttäytymisestä

Tehtävät

Tehtävä 4.3: Seikkailupeli. 1 p.

Toteutetaan yksinkertainen tekstiseikkailupeli (tai oikeammin pieni palanen pelistä), jossa pelaaja voi yrittää poimia esineitä maasta, sekä syödä saadakseen energiaa. Saat valmiina kaksi rajapintaa: Syotava ja Poimittava. Lisäksi saat osittain toteutetut luokat: Omena ja IsoKivi, jotka toteuttavat nämä rajapinnat. Edelleen, saat osittain toteutetun pääohjelman, jossa pelaajan energiaa ja repun tilaa seurataan.

Täydennä kaikki TODO-sanalla merkityt osat, jotta ohjelma toimii ohjeiden mukaisesti.

Tee tehtävä TIMissä
Bonus: Tehtävä 4.4: Kotityörobotti. 1 p.

Tee Robotti, joka osaa suorittaa erilaisia kotitöitä, kuten imurointia ja kukkien kastelua.

Toteuta tehtävä oheisen UML-kaavion mukaisesti. Katkoviiva, jossa on musta nuoli, tarkoittaa, että Robotti-luokka käyttää Kayttoesine-rajapintaa: Robotti-luokka sisältää attribuutin, joka on tyyppiä Kayttoesine.

Kuvaus sanallisessa muodossa

Tässä on kuvaus luokista ja niiden vaadituista ominaisuuksista (vastaavat kuin UML-kaaviossa):

Robotilla on seuraavat metodit:

  • void vaihdaKayttoesine(Kayttoesine esine): Vaihtaa robotin käyttämän esineen (esim. imuri tai kastelukannu).
  • void teeTyota(String kohde): Suorittaa kotityön. Jos kohde on sillä listalla, jotka kyseiseltä käyttöesineeltä on kielletty (esim. Kastelukannu-oliolla ei saa kastella "Tietokone"-kohdetta), robotin tulee tulostaa virheilmoitus. Kielletyt käyttökohteet määritellään käyttöesineen attribuuttina merkkijonolistana.
  • Kastelukannu-olio ei kastele jos vettä ei ole riittävästi. Sen voi täyttää taytaVesi()-metodilla. Kastelukannun vesimäärä on aluksi 50 yksikköä. Voit halutessasi tehdä uuden muodostajan, joka asettaa vesimäärän alkutilan toiseksi.
  • Imuri-olio ei imuroi jos roskasäiliö on täynnä. Sen voi tyhjentää tyhjennaSailio()-metodilla. Roskasäiliön kapasiteetti on 100 yksikköä. Voit halutessasi tehdä uuden muodostajan, joka asettaa roskasäiliön alkutilan toiseksi.
  • Molemmat käyttöesineet palauttavat kayta(String kohde)-metodin avulla totuusarvon, joka kertoo onnistuiko työ.
Tee tehtävä TIMissä

Tyyppiparametrit ja geneerisyys

osaamistavoitteet

  • Osaat hyödyntää tyyppiparametreja toteuttaaksesi yleiskäyttöisiä eli geneerisiä luokkia ja metodeja

Olet oppinut aiemmissa ohjelmointiopinnoissasi, että parametrit mahdollistavat toiston vähentämisen yleistämällä ohjelman toimintaa erilaisille arvoille. Parametrien idea on erottaa laskennan logiikka itse arvoista. Kun kirjoitamme metodin, laskenta määritellään vain kerran. Esimerkiksi, jos haluaisimme ilman parametreja selvittää, missä indeksissä etsimämme luku ilmaantuu ensimmäistä kertaa, voisimme kirjoittaa koodin näin.

void main() {
int[] taulukko1 = {2, 3, 4};
// Etsitään luku 3
for (int i = 0; i < taulukko1.length; i++) {
    if (taulukko1[i] == 3) {
        IO.println("Luku 3 on ekan kerran indeksissä " + i);
        break;
    }
}

int[] taulukko2 = {-20, 10, 2, 1};
// Etsitään luku 2
for (int i = 0; i < taulukko2.length; i++) {
    if (taulukko2[i] == 2) {
        IO.println("Luku 2 on ekan kerran indeksissä " + i);
        break;
    }
}
}

Tämä toimii, mutta koodia on kopioitu turhaan. Tehdään nyt funktio, joka ottaa taulukon parametrina.

int etsiIndeksi(int[] taulukko, int etsittava) {
    for (int i = 0; i < taulukko.length; i++)
        if (taulukko[i] == etsittava) return i;
    return -1;
}

void main() {
    IO.println("Luku 3 on indeksissä " + etsiIndeksi(new int[] {2, 3, 4}, 3));
    IO.println("Luku 2 on indeksissä " + etsiIndeksi(new int[] {-20, 10, 2, 1}, 2));
}

Tätä voi ajatella parametrien käyttönä arvojen tasolla: metodi on yleinen, mutta sille annettavat arvot vaihtelevat.

Huomaamme kuitenkin nopeasti, että sama etsiIndeksi-funktio ei kuitenkaan toimi muille lukutyypeille, kuten long tai float, eikä myöskään kokonaan muunlaisille tyypeille, kuten String. Näitä varten täytyisi tehdä erilliset funktiot.

int etsiIndeksiLong(long[] taulukko, long etsittava) {
    for (int i = 0; i < taulukko.length; i++)
        if (taulukko[i] == etsittava) return i;
    return -1;
}

int etsiIndeksiFloat(float[] taulukko, float etsittava) {
    for (int i = 0; i < taulukko.length; i++)
        if (taulukko[i] == etsittava) return i;
    return -1;
}

int etsiIndeksiString(String[] taulukko, String etsittava) {
    for (int i = 0; i < taulukko.length; i++)
        if (taulukko[i].equals(etsittava)) return i;
    return -1;
}

void main() {
    long[] longTaulukko = {-10L, 5L, 1L};
    float[] floatTaulukko = {-10.0f, 2.0f};
    String[] stringTaulukko = {"Koira", "Kissa", "Lintu"};
    IO.println("Luku -10 on indeksissä " + etsiIndeksiLong(longTaulukko, -10L));
    IO.println("Luku 2.0 on indeksissä " + etsiIndeksiFloat(floatTaulukko, 2.0f));
    IO.println("Merkkijono \"Kissa\" on indeksissä " + etsiIndeksiString(stringTaulukko, "Kissa"));
}

Koska Java on staattisesti tyypitetty kieli, emme voi kirjoittaa yhtä ja samaa metodia, joka toimisi automaattisesti kaikille näille. Ilman tällaista ratkaisua päätyisimme helposti tilanteeseen, jossa meillä on joukko lähes identtisiä metodeja: etsiIndeksiInt, etsiIndeksiDouble, etsiIndeksiString ja niin edelleen. Koodi on käytännössä sama, vain tyypit vaihtuvat.

Oikeastaan etsiIndeksi-funktion perusajatus on aina sama:

int etsiIndeksi(TYYPPI[] taulukko, TYYPPI etsittava) {
    for (int i = 0; i < taulukko.length; i++)
        if (taulukko[i] == etsittava) return i;
    return -1;
}

Javassa tämän tapaisen koodin kirjoittaminen on mahdollista tyyppiparametrien avulla.

Tyyppiparametrit

Tyyppiparametri on parametri, jonka arvona on tietotyyppi. Tyyppiparametrin tarkoitus on vähentää toistoa tapauksissa, jossa sama koodi toimii eri tyyppisille arvoille luopumatta staattisen tyypityksen antamista hyödyistä. Lisäksi tyyppiparametrit mahdollistavat ylimääräisten tyyppimuunnosten välttämistä jossain tapauksissa.

Tyypiparametri tai -parametrit voidaan määrittää metodille tavallisten parametrien lisäksi. Erikoisuutena on, että tyyppiparametreja voidaan myös määrittää luokille. Yhdessä metodien ja luokkien tyyppiparametrit mahdollistavat geneeristä ohjelmointia, eli tyypistä riippumattomien algoritmien ja tietorakenteiden ohjelmointia.

Geneerinen metodi

Metodia, joka määrittelee tyyppiparametrin, kutsutaan geneeriseksi metodiksi. Geneerisessä metodissa tietotyyppiä ei ole lukittu metodia määriteltäessä tiettyyn tyyppiin etukäteen, vaan tyyppi ilmaistaan symbolilla, joka täsmennetään vasta metodia kutsuttaessa. Metodin tyyppiparametri laitetaan kulmasulkeiden väliin ennen metodin palautustyyppiä. Yleinen käytäntö on käyttää yksikirjaimisia, isoja kirjaimia. Tavallisin näistä on T, joka tulee sanasta Type.

// aliohjelma "tulosta", jolla on yksi tyyppiparametri T 
// ja yksi tavallinen parametri "arvo"
<T> void tulosta(T arvo) {
    IO.println("Moikka, olen '" + arvo + "' ja olen luokan '" + arvo.getClass() + "' olio!");
}

void main() {
    tulosta(1.0);
    tulosta(1);
    tulosta("kissa");
}

Geneerinen metodi voi olla staattinen, ei-staattinen tai konstruktori. Tyyppiparametri voi esiintyä metodin palautustyypissä, parametreissa tai molemmissa.

Tyyppiparametreja voi olla yksi tai useampia. Ne määritellään pilkulla eroteltuina kulmasulkeiden sisällä, ja jokainen niistä voi edustaa toisistaan riippumatonta tyyppiä. Esimerkiksi alla olevassa metodissa on kaksi tyyppiparametria, T1 ja T2, jotka voivat edustaa mitä tahansa kahta erilaista tietotyyppiä.

<T1, T2> String yhdista(T1 arvo1, T2 arvo2) {
    return arvo1.toString() + ", " + arvo2.toString();
}

void main() {
    IO.println(yhdista(1, 2)); // T1 = Integer, T2 = Integer
    IO.println(yhdista(true, 1.0)); // T1 = Boolean, T2 = Double
}

Tyyppiparametrien nimeämisen osalta yleistynyt käytäntö tällä hetkellä lienee, että nimi on yleensä yksi suuraakkonen, joka on johdettu tyyppiparametrin merkityksestä, kuten T (Type), E (Element), K (Key), N (Number), V (Value). Jossain tapauksissa tyyppiparametrien nimeen lisätään myös numero, kuten T1, T2, T3, jne.

Huomaa, että yllä olevissa esimerkeissä tyyppiparametri määritellään, mutta tyyppiparametreille ei anneta arvoa kutsuttaessa. Tämä voitaisiin kyllä tehdä; esimerkiksi yllä oleva tulosta-aliohjelman kutsulle voidaan määrittää tarkasti tyyppiparametrin tyyppi.

<T> void tulosta(T arvo) {
    IO.println("Moikka, olen '" + arvo + "' ja olen luokan '" + arvo.getClass() + "' olio!");
}

void main() {
    this.<Double>tulosta(1.0); // sama kuin tulosta(1.0)
    this.<String>tulosta("kissa"); // tulosta("String")
}

Tyyppiparametrin arvoa ei yleensä määritetä kutsussa, sillä kääntäjä osaa yleensä päätellä tyyppiparametrin arvon automaattisesti. Esimerkiksi tulosta(1.0)-kutsussa lausekkeen 1.0 tyyppi on double, joten kääntäjä päättelee tyyppiparametrin T olevan (Double). On kuitenkin hyvä pitää mielessä, että tyypiparametrille kyllä annetaan taustalla arvo, vaikka sitä ei itse kirjoittaisikaan näkyville.

Katsotaan, miten aiempi etsimisongelma ratkeaa geneerisen metodin avulla.

<T> int etsiIndeksi(T[] taulukko, T etsittava) {
    for (int i = 0; i < taulukko.length; i++)
        if (taulukko[i].equals(etsittava)) return i;
    return -1;
}

void main() {
    Integer[] kokonaisluvut = {2, 3, 4};
    Double[] liukuluvut = {-10.0, 2.0, 0.0, 5.5};
    String[] elaimet = {"koira", "kissa", "gepardi", "kissa", "gepardi"};

    IO.println(etsiIndeksi(kokonaisluvut, 3));
    IO.println(etsiIndeksi(liukuluvut, 5.4));
    IO.println(etsiIndeksi(elaimet, "gepardi"));
}

Huomaa, että jouduimme tekemään erityisesti pari muutosta:

Ensinnäkin, vertailu tapahtuu nyt kirjoittamalla taulukko[i].equals(etsittava). Tämä johtuu siitä, että tyyppiparametri T voi edustaa mitä tahansa viitetietotyyppiä, ja viitetietotyyppisten arvojen vertailua ei voi tehdä ==-operaattorilla.

Toiseksi, main-pääohjelmassa tulee käyttää perustietotyyppien int, double ja long sijaan käärijäluokkia Integer, Double ja Long. Tämä johtuu siitä, että Javassa vain viitetietotyyppejä voidaan käyttää tyyppiparametreina. Rajoite puolestaan johtuu Javan tavasta toteuttaa viitetietotyyppejä. Mainittakoon, että Java-kieltä kehitetään jatkuvasti, ja on hyvin mahdollista, että lähitulevaisuudessa tämä rajoite jää pois.

Valinnaista lisätietoa: Miksi tyyppiparametrit eivät voi olla perustietotyyppejä?

Java käyttää mekanismia nimeltä type erasure, jonka voisi vapaasti suomentaa "tyyppien poistamiseksi". Tämä tarkoittaa, että käännettäessä Java-koodi tavukoodiksi tyyppiparametrit poistetaan ja korvataan niiden ylärajalla. Ylärajalla tarkoitetaan sitä tyyppiä, jota geneerinen parametri varmasti edustaa. Jos tyyppiparametrille on asetettu rajoitus, kuten <T extends Number>, yläraja on tällöin Number. Käännöksen jälkeen kaikki T:hen viittaava koodi käsitellään ikään kuin tyyppi olisi Number. Jos taas tyyppiparametrille ei ole asetettu rajoitusta, sen yläraja on automaattisesti Object. Esimerkiksi tyyppiparametri T käsitellään käännöksen jälkeen ikään kuin se olisi Object.

Käytännössä tämä tarkoittaa, että geneerisyys ei ole Javan ajonaikainen ominaisuus, vaan käännösaikainen tarkistusmekanismi. Tyyppitiedot poistetaan, jotta geneerinen koodi olisi yhteensopivaa vanhemman, ei-geneerisen Java-koodin kanssa.

Koska primitiivityypit eivät peri Object-luokkaa, ne eivät voi toimia tyyppiparametreina. Siksi geneerisissä rakenteissa on aina käytettävä käärijäluokkia (Integer, Double, Boolean).

Sama rajoitus näkyy myös taulukoiden kanssa: Java ei salli geneeristen taulukoiden luomista. Esimerkiksi lause new T[10] ei ole sallittu, koska tyyppiparametri ei ole ajonaikana tiedossa type erasure -mekanismin vuoksi. Käytännössä tämä tarkoittaa, että geneerisen koodin yhteydessä käytetään lähes aina kokoelmia (kuten ArrayList) taulukoiden sijaan.

Geneerinen luokka ja geneerinen rajapinta

Geneerisyys ei rajoitu vain metodeihin. Tyyppiparametrien todellinen hyöty tapana tuottaa yleistyvää koodia tulee esiin erityisesti silloin, kun tyyppiparametreja määritellään luokille tai rajapinnoille. Olemmekin jo käyttäneet kurssilla tyyppiparametreja valmiissa luokissa, kuten ArrayList<T>. lista itsessään on yleinen, mutta sen sisältämä tyyppi täsmennetään.

todo

DZ: Joku yksinkertainen esimerkki? Vaikkapa Osassa 1 oleva salasanatehtävä, mutta se palauttaisi Tulos(boolean oikein, String virhe). Se refaktoroidaan luokaksi Pari<T, U>.

Geneerinen luokka on erityisen perusteltu silloin, kun luokka säilyttää jonkin tyyppisiä arvoja ja useat metodit liittyvät samaan tyyppiparametriin. Esimerkiksi Pari<T, U> voisi olla tällainen: luokan tarkoitus on säilyttää kahta arvoa, ja on olennaista, että niiden tyypit säilyvät koko elinkaaren ajan.

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

    public Pari(T eka, U toka) {
      this.eka = eka;
      this.toka = toka;
    }

    public T getEka() {
      return eka;
    }

    public U getToka() {
      return toka;
    }

    public void setEka(T eka) {
      this.eka = eka;
    }

    public void setToka(U toka) {
      this.toka = toka;
    }
}

Tämän luokan avulla voimme luoda ilmentymiä, joiden arvot voivat olla mitä tahansa tyyppejä, ilman, että meidän tarvitsee kirjoittaa erillisiä luokkia jokaista käyttötarkoitusta varten. Alla esimerkki

 public class Pari<T, U> {
     private T eka;
     private U toka;
 
     public Pari(T eka, U toka) {
       this.eka = eka;
       this.toka = toka;
     }
 
     public T getEka() {
       return eka;
     }
 
     public U getToka() {
       return toka;
     }
 
     public void setEka(T eka) {
       this.eka = eka;
     }
 
     public void setToka(U toka) {
       this.toka = toka;
     }
 }
void main() {
    Pari<String, Integer> nimiJaIka = new Pari<>("Matti", 30);
    IO.println("Nimi: " + nimiJaIka.getEka() + ", Ikä: " + nimiJaIka.getToka());

    Pari<Double, Double> koordinaatit = new Pari<>(60.192059, 24.945831);
    IO.println("Leveysaste: " + koordinaatit.getEka() + ", Pituusaste: " + koordinaatit.getToka());
}

Jos saman toteuttaisi Object-tyyppisillä attribuuteilla ja yrittäisi "paikata" sen geneerisillä metodeilla, tyyppiturvallisuus katoaa helposti ja mukaan tulee pakollisia tyyppimuunnoksia, mistä taas seuraa mahdollisia ajonaikaisia virheitä.

public class Pari {
    private final Object eka;
    private final Object toka;

    public Pari(Object eka, Object toka) {
        this.eka = eka;
        this.toka = toka;
    }

    public <T> T getEka() {
        return (T) eka; // tyyppimuunnos, ei käännösaikaista varmistusta
    }
}

Yllä olevassa esimerkissä mukamas geneerinen metodi ei oikeasti tee luokasta tyyppiturvallista, koska luokan tila on edelleen Object-tasolla ja tyyppimuunnos tapahtuu vasta ajon aikana. Geneerisen luokan idea on nimenomaan se, että tyyppi kiinnittyy luokan kenttiin ja niiden käyttöön käännösaikaisesti.

On tärkeää huomata, että geneerisen metodin ja geneerisen luokan valinta ei riipu siitä, onko metodi staattinen, vaan siitä, kuuluuko tyyppi luokan pysyvään rakenteeseen vai vain yksittäiseen toimintaan. Metodi luokan sisällä voi edelleen olla geneerinen, kunhan se käyttää omaa, eri nimistä tyyppiparametria eikä sekoitu luokan tyyppiparametriin.

Valinnaista lisätietoa: Java ei voi kaikissa tilanteissa päätellä tyyppiä yksikäsitteisesti

Edellä mainittiin, että Java pystyy usein päättelemään geneerisen metodin tyyppiparametrin automaattisesti. Tätä ominaisuutta kutsutaan nimellä type inference. Käytännössä kääntäjä tarkastelee metodikutsun argumentteja ja niiden tyyppejä ja päättelee niiden perusteella, mikä tyyppiparametri täyttää metodin määrittelyn vaatimukset.

Esimerkiksi kutsussa etsiIndeksi(kokonaisluvut, 3) kääntäjä näkee, että taulukon tyyppi on Integer[] ja etsittävä arvo on Integer. Näiden perusteella se päättelee, että tyyppiparametrin T on oltava Integer, eikä kutsussa tarvitse kirjoittaa sitä erikseen.

Java sallii myös eksplisiittisen geneerisen metodikutsun, jossa tyyppiparametri annetaan itse:

Etsija.<Integer>etsiIndeksi(kokonaisluvut, 3);

Vaikka useimmissa käytännön tilanteissa kääntäjän automaattinen päättely on kuitenkin riittävä, voi olla tilanteita, joissa kääntäjä ei pysty päättelemään tyyppiä yksiselitteisesti tai kun halutaan tehdä tyyppi eksplisiittiseksi luettavuuden tai virheiden paikantamisen vuoksi.

Yksi tällainen tapaus syntyy, kun argumenteilla on eri, mutta yhteensopivia tyyppejä, eikä ole selvää, mikä niistä pitäisi valita tyyppiparametriksi.

static <T> T valitse(T a, T b) {
    return a;
}

// valitse(1, 1.0);        // KÄÄNNÖSVIRHE: tyyppiä T ei voida päätellä
Number n = <Number>valitse(1, 1.0); // OK: tyyppi annetaan eksplisiittisesti

Tässä tapauksessa argumentit ovat eri tyyppiä (Integer ja Double). Molemmat perivät Number-luokan, mutta kääntäjä ei voi itse päättää, mikä näistä (tai niiden yhteinen yläluokka) olisi oikea valinta tyyppiparametrille. Antamalla tyyppiparametrin eksplisiittisesti kerromme kääntäjälle, että haluamme käyttää metodia Number-tyyppisenä.

Geneerisyys ja polymorfismi

Geneerisyys ja polymorfismi (tarkemmin alityyppipolymorfismi) ovat kaksi eri mekanismia, jotka täydentävät toisiaan. Vaikka molemmat lisäävät koodin joustavuutta, ne ratkaisevat eri ongelmia ja toimivat eri vaiheissa ohjelman suoritusta.

  1. Polymorfismi (alityypitys): Ajonaikainen mekanismi, johon tutustuimme osassa 3. Sen tehtävä on mahdollistaa olioiden käsittely niiden yliluokan tai rajapinnan kautta, jolloin oikea toiminnallisuus (metodin toteutus) valitaan vasta ohjelman ajon aikana.
  2. Geneerisyys (parametrinen polymorfismi): Käännösaikainen mekanismi. Sen tehtävä on varmistaa tyyppiturvallisuus ja vähentää toistoa sallimalla saman koodin toimia eri tyypeillä ilman että tyyppitieto katoaa.
  3. Pelkkä polymorfismi (ei tyyppiturvaa) Ennen geneerisyyttä (Java 1.4 ja aiemmat) kokoelmat perustuivat pelkkään polymorfismiin ja Object-luokkaan.
// "Raaka" lista (raw type) - ei suositella enää
List lista = new ArrayList();
lista.add("teksti");
lista.add(123); // Sallittu, koska Integer on Object

for (Object o : lista) {
    // toString() kutsuu kunkin olion omaa toteutusta
    IO.println(o.toString());
}

Tässä polymorfismi sinänsä toimii, mutta koodi ei ole tyyppiturvallista. Kääntäjä ei voi estää meitä lisäämästä listaan vääriä tyyppejä, mikä johtaa virheisiin usein vasta, kun yritämme muuntaa (cast) oliota takaisin alkuperäiseen tyyppiinsä.

Geneerisyys tuo koodiin rajoitteet, jotka kääntäjä tarkistaa.

List<String> sanat = new ArrayList<>();
sanat.add("kissa");
sanat.add("koira");
// sanat.add(123); // KÄÄNNÖSVIRHE!

Tässä geneerisyys estää virheellisen käytön jo ennen kuin ohjelmaa edes ajetaan. Tässä esimerkissä emme varsinaisesti hyödynnä polymorfismia omien luokkien suhteen, vaan luotamme kääntäjän tiukkaan valvontaan siitä, että lista sisältää vain merkkijonoja.

Tehokkainta on yhdistää molemmat: geneerisyys rajaa sallitut tyypit tiettyyn perheeseen (esim. Number), ja polymorfismi hoitaa kyseisen perheen jäsenten yksilöllisen toiminnan.

// Listalle kelpaa mikä tahansa luku (Integer, Double, Long...)
List<Number> luvut = new ArrayList<>();
luvut.add(1);   // Integer on Number
luvut.add(2.5); // Double on Number

for (Number n : luvut) {
    // Geneerisyys takaa, että 'n' on vähintään Number.
    // Polymorfismi (Number-luokan toteutus) hoitaa arvot.
    IO.println(n.doubleValue());
}

Tyyppirajoitukset

Tyyppiparametreille voidaan asettaa rajoituksia, jotka määrittelevät, millainen tyyppi parametrina voidaan antaa. Tämä tehdään käyttämällä extends-avainsanaa tyyppiparametrin määrittelyn yhteydessä. Rajoitukset voivat olla luokkia tai rajapintoja, ja ne määrittelevät ylärajan tyypille, jota tyyppiparametri voi edustaa. Huomaa, että extends-avainsanaa käytetään tässä yhteydessä sekä luokista että rajapinnoista, vaikka rajapinnat eivät perikään luokkia.

// Tyyppiparametri T voi olla vain Number-luokan alityyppi
<T extends Number> void tulostaLuku(T luku) {
    IO.println("Numero: " + luku.doubleValue());
}

void main() {
    tulostaLuku(10);      // OK: Integer on Number
    tulostaLuku(3.14);    // OK: Double on Number
    // tulostaLuku("kissa"); // KÄÄNNÖSVIRHE: String ei ole Number
}

Rajoituksia voidaan asettaa useita käyttämällä &-operaattoria, jolloin tyyppiparametrin on täytettävä useita ehtoja. Alla on esimerkki metodista, jossa tyyppiparametrin tulee olla sekä Number-luokan että Comparable-rajapinnan alityyyppi.

// Tyyppiparametri T voi olla vain luokka, joka on sekä Number että Comparable
<T extends Number & Comparable<T>> void vertaile(T a, T b) {
    if (a.compareTo(b) < 0) {
        IO.println(a + " on pienempi kuin " + b);
    } else if (a.compareTo(b) > 0) {
        IO.println(a + " on suurempi kuin " + b);
    } else {
        IO.println(a + " on yhtä suuri kuin " + b);
    }
}

void main() {
    vertaile(10, 20);      // OK: Integer on Number ja Comparable
    vertaile(3.14, 2.71);  // OK: Double on Number ja Comparable
    // vertaile("kissa", "koira"); // KÄÄNNÖSVIRHE: String ei ole Number
}

Tyyppirajoituksia voidaan tehdä myös käyttäen niin sanottuja jokerimerkkiä (?), joka edustaa tuntematonta tyyppiä. Jokerimerkin avulla on mahdollista muun muassa määritellä niin sanottuja ala- ja ylärajoituksia geneerisille tyypeille. Jokerimerkkiä käytetään usein geneerisissä kokoelmissa, kun halutaan ilmaista, että kokoelmasta voi lukea tai siihen voi kirjoittaa tietyn tyyppisiä alkioita, mutta tarkkaa tyyppiä ei tiedetä etukäteen. Alla esimerkkejä.

// Metodi, joka ottaa listan, joka voi sisältää mitä tahansa Number-luokan alityyppejä.
// Listasta voi lukea Number-tyyppisiä arvoja, mutta ei voi lisätä mitään, koska
// emme tiedä tarkkaa tyyppiä.
void tulostaLuvut(List<? extends Number> luvut) {
    for (Number n : luvut) {
        IO.println(n); // Huomaa, että emme tiedä tarkkaa tyyppiä, mutta tiedämme että se on Number
    }
    // luvut.add(10); // KÄÄNNÖSVIRHE: emme voi lisätä, koska emme tiedä tarkkaa tyyppiä
}

/* Ottaa listan, jonka alkioiden tyyppi on Number tai jokin sen ylityyppi 
 * (esim. Object), joten listaan on turvallista lisätä Number-arvoja, ja siten myös Integer, Double jne. 
 */
void lisaaLukuja(List<? super Number> lista) {
    lista.add(10);      // OK: Integer on Number
    lista.add(3.14);    // OK: Double on Number
    // lista.add("kissa"); // KÄÄNNÖSVIRHE: String ei ole Number    
    // Integer eka = lista.getFirst(); // KÄÄNNÖSVIRHE: emme tiedä tarkkaa tyyppiä, joten emme voi olettaa että se on Integer
}

Emme käsittele jokerimerkkiä tässä osiossa tarkemmin, mutta voit tutustua niihin omatoimisesti Javan dokumentaatiosta.

Geneeristen tyyppien invarianssi

Vaikka Integer on Number-luokan alityyppi, List<Integer> ei ole List<Number>-luokan alityyppi. Ne ovat täysin erillisiä tyyppejä, eikä niillä ole perintäsuhdetta. Tätä kutsutaan invarianssiksi, eli muuttumattomuudeksi tyyppisuhteissa. Geneeriset tyypit ovat oletuksena invariantteja turvallisuussyistä. Alla on lyhyt esimerkki, joka havainnollistaa tätä periaatetta.

List<Integer> kokonaisluvut = new ArrayList<>();
kokonaisluvut.add(1);

// Jos geneerisyys EI olisi invarianttia, voisimme tehdä näin:
List<Number> luvut = kokonaisluvut; // (Tämä on se kohta, minkä Java estää)

// Nyt luvut ja kokonaisluvut viittaavat samaan listaan muistissa.
// Koska luvut on tyyppiä List<Number>, voimme lisätä sinne liukuluvun:
luvut.add(3.14); 

// MUTTA 'kokonaisluvut' luulee edelleen sisältävänsä vain Integer-lukuja!
Integer i = kokonaisluvut.get(1); // PAM! Ajonaikainen virhe (ClassCastException)

Jos voisimme kohdella kokonaislukulistaa yleisenä numerolistana, voisimme vahingossa ujuttaa sinne desimaalilukuja. Sitten kun alkuperäinen koodi yrittää lukea listaa kokonaislukuina, ohjelma kaatuisi. Tämä on erityisen hämmentävää siksi, että Javan taulukot toimivat eri tavalla. Taulukot ovat kovariantteja, eli Integer[]-taulukkoa voidaan käsitellä Number[]-taulukkona, mutta tällöin tyyppiturvallisuus tarkistetaan vasta ajonaikaisesti: jos taulukkoon yritetään tallentaa väärän tyyppinen alkio (esim. Double), Java heittää ArrayStoreException-poikkeuksen.

// Tämä on sallittua Javassa:
Integer[] kokonaisluvut = {1, 2};
Number[] luvut = kokonaisluvut; // OK taulukoilla!

// Mutta tämä aiheuttaa virheen vasta ohjelmaa ajettaessa:
luvut[0] = 3.14; // ArrayStoreException!

Taulukoiden kanssa Java hyväksyy riskin ja heittää virheen vasta, kun ohjelma on käynnissä. Geneerisyyden (listat yms.) yksi tärkeimmistä tavoitteista oli korjata tämä ongelma ja siirtää virhe käännösaikaan.

Jos haluamme hyödyntää polymorfismia geneeristen kokoelmien välillä, meidän on käytettävä jokerimerkkejä. Esimerkiksi, jos haluamme käsitelläList<Integer>-listaa kuten List<Number>-listaa, voimme käyttää List<? extends Number>-tyyppiä.

// Nyt tämä on sallittua, mutta lista on "read-only" turvallisuussyistä
List<? extends Number> luvut = kokonaisluvut;

for (Number n : luvut) {
    IO.println(n); // Toimii
}
Tehtävä 4.8: Etsi suurin. 1 p.

Tee geneerinen metodi etsiSuurin, joka etsii listan suurimman alkion. Parametrina tulevan listan tulee toteuttaa Comparable-rajapinta, muutoin lista voi olla minkä tyyppinen tahansa. Älä käytä valmiita Collections-luokan metodeja.

Tee tehtävä TIMissä
Tehtävä 4.9: Kontti. 1 p.

Tee luokka Kontti, joka hyödyntää geneerisyyttä ja toimii yksinkertaisena säiliönä yhdelle minkä tahansa tyypin oliolle.

Lisää luokkaan attribuutti sisalto, joka voi sisältää minkä tahansa tyyppisen olion. Lisää myös merkkijono omistaja. Tee luokkaan muodostaja, joka ottaa nämä arvot vastaan parametreina.

Lisää lisäksi saantimetodit getOmistaja, getSisalto ja getTyyppi, joista viimeinen palauttaa kontin sisällön tyypin merkkijonona. Tee myös override toString-metodille, joka palauttaa nämä tiedot yhdessä merkkijonossa.

Tehtävässä on valmiiksi pääohjelma, jolla voit kokeilla luokan toimintaa.

Vinkki

Olion tyypin saa merkkijonona metodilla olio.getClass().getSimpleName().

Tee tehtävä TIMissä
Bonus: Tehtävä 4.10: Iso kontti. 1 p.

Luo luokka IsoKontti, joka toimii säiliönä usealle minkä tahansa tyypin oliolle. Konttiin pakataan esineitä niin, että viimeisimpänä lisätty otetaan aina ensimmäiseksi pois.

Lisää luokkaan attribuutiksi lista, johon oliot tallennetaan.

Lisää myös seuraavat metodit:

  • lisaa lisää parametrina annetun olion listan loppuun.

  • ota palauttaa viimeisimmän olion ja ottaa sen pois listasta.

  • katso palauttaa viimeisimmän olion, mutta ei ota sitä pois listasta.

  • sisaltaa ottaa parametrina olion ja palauttaa true, jos olio löytyy kontista. Muussa tapauksessa se palauttaa false.

  • tulosta tulostaa kontin sisällön. Voit itse päättää, missä muodossa sisältö tulostetaan.

Tehtävässä on valmiiksi pääohjelma, jolla voit kokeilla luokan toimintaa.

Tee tehtävä TIMissä
Bonus: Tehtävä 4.11: Tyyppirajoitukset, osa 1. 1 p.

Lisää edellisen tehtävän IsoKontti-luokkaan kaksi metodia.

  1. Luokan metodi (static) summaaNumerot ottaa parametrina IsoKontti-olion, joka sisältää numeroita eli Number-luokan tai sen alityyppien olioita. Metodi palauttaa kontin numeroiden summan.

  2. Olion metodi siirraKaikki ottaa parametrina toisen IsoKontti-olion ja siirtää metodia suorittavan kontin sisällön sinne. Toisen kontin täytyy olla tyypiltään sellainen, että se voi sisältää tämän kontin tyypin olioita.

Tehtävässä on valmiiksi pääohjelma, jolla voit kokeilla luokan toimintaa.

Vinkki

Tarvitset tässä tehtävässä tyyppirajoituksia.

  1. Kaikilla Number-luokan olioilla on doubleValue()-metodi, joka palauttaa sen arvon double-muodossa.

  2. Huomaa, että konttien tyyppien ei tarvitse olla täysin samat; Number-kontti voi sisältää Integer-olioita, sillä Integer on sen alityyppi.

Tee tehtävä TIMissä
Bonus: Tehtävä 4.12: Tyyppirajoitukset, osa 2. 1 p.

Tee geneerinen funktio, joka kopioi yhdestä listasta kaikki tyyppiä T vastaavat alkiot toiseen listaan, jonka tyyppi voi olla T tai sen ylityyppi.

Tee tehtävä TIMissä

Osan kaikki tehtävät

huomautus

Jos palautat tehtävät ennen osan takarajaa (ma 9.2.2026 klo 11:59 (keskipäivä)), voit saada DL-BONUS-pisteitä harjoitustehtäviin. Lue lisää suorittaminen-sivulta.

Tehtävä 4.1: Muunnin. 1 p.
  1. Luo rajapinta nimeltään Muunnin. Määrittele rajapintaan yksi metodi: String muunna(String syote). Muista, että rajapinnassa metodilla ei ole runkoa (ei aaltosulkeita {}).

  2. Tee luokat PienetKirjaimet, IsotKirjaimet ja IsoAlkukirjain, jotka toteuttavat Muunnin-rajapinnan.

  • PienetKirjaimet-luokan muunna-metodi muuntaa annetun merkkijonon pieniksi kirjaimiksi. muunna("Hei Maa") --> "hei maa".
  • IsotKirjaimet-luokan muunna-metodi muuntaa annetun merkkijonon suuraakkosiksi. muunna("Hei Maa") --> "HEI MAA".
  • IsoAlkukirjain-luokan muunna-metodi muuntaa annetun merkkijonon siten, että vain ensimmäinen kirjain on suuraakkonen ja muut pieniä. muunna("HEI MAA") --> "Hei maa".
  1. Testaa ohjelmaasi valmiiksi annetulla pääohjelmalla.
Tee tehtävä TIMissä
Tehtävä 4.2: Vakoojien viestijärjestelmä.1 p.

Vakoojat lähettävät viestejä toisilleen, mutta salausmenetelmä vaihtuu päivittäin, jotta vihollinen ei pääse perille logiikasta. Tarvitsemme rajapinnan, jonka avulla voimme vaihtaa salausalgoritmia lennosta.

  1. Luo rajapinta Salaaja. Määrittele rajapintaan kaksi metodia
String salaa(String viesti);
String pura(String salattuViesti);
  1. Toteuta kolme erilaista luokkaa: Kaantaja, Hakkeri ja SeuraavaKirjain, jotka toteuttavat Salaaja-rajapinnan seuraavilla logiikoilla:
  • Kaantaja (Peilikuvakirjoitus). Kääntää sanan väärinpäin. Esimerkki: "Agentti" → "ittnegA". Vihje: Voit käyttää StringBuilder-luokan reverse()-komentoa tai silmukkaa, joka käy sanan läpi lopusta alkuun.

  • Hakkeri ("Leet-speak"). Korvaa tietyt kirjaimet numeroilla tai merkeillä. Esimerkki: "Agentti" -> "@g3ntt!"

Korvaa 'a' -> '@'
Korvaa 'e' -> '3'
Korvaa 'i' -> '!'
Korvaa 'o' -> '0'
  • SeuraavaKirjain (Caesar-siirros). Jokaista kirjainta siirretään aakkosissa yksi eteenpäin. Esimerkki: abc -> bcd. Vihje: Javassa char on luku. Voit tehdä merkki + 1.
'a' -> 'b'
'b' -> 'c'
'k' -> 'l'
jne. 

Tässä harjoituksessa ei tarvitse huolehtia ö-kirjaimen pyörähtämisestä ympäri, ellei halua. Tehtävässä ei myöskään tarvitse huolehtia siitä, että salauksen ja purkamisen jälkeen saatu viesti ei välttämättä ole samanlainen kuin alkuperäinen viesti. Esimerkiksi jos Hakkeri-muuntajaa käytettäessä alkuperäisessä viestissä on oikeasti merkki @, pura-metodi antaa tulokseksi tuohon paikalle merkin a. Tämä ei haittaa tässä, mutta tietenkin oikeassa salauksessa pitäisi varmistaa, ettei tietoa katoa tai muutu vahingossa.

Saat TIMissä valmiina pääohjelman, jonka avulla voit testata luokkarakennettasi.

Tee tehtävä TIMissä
Tehtävä 4.3: Seikkailupeli. 1 p.

Toteutetaan yksinkertainen tekstiseikkailupeli (tai oikeammin pieni palanen pelistä), jossa pelaaja voi yrittää poimia esineitä maasta, sekä syödä saadakseen energiaa. Saat valmiina kaksi rajapintaa: Syotava ja Poimittava. Lisäksi saat osittain toteutetut luokat: Omena ja IsoKivi, jotka toteuttavat nämä rajapinnat. Edelleen, saat osittain toteutetun pääohjelman, jossa pelaajan energiaa ja repun tilaa seurataan.

Täydennä kaikki TODO-sanalla merkityt osat, jotta ohjelma toimii ohjeiden mukaisesti.

Tee tehtävä TIMissä
Bonus: Tehtävä 4.4: Kotityörobotti. 1 p.

Tee Robotti, joka osaa suorittaa erilaisia kotitöitä, kuten imurointia ja kukkien kastelua.

Toteuta tehtävä oheisen UML-kaavion mukaisesti. Katkoviiva, jossa on musta nuoli, tarkoittaa, että Robotti-luokka käyttää Kayttoesine-rajapintaa: Robotti-luokka sisältää attribuutin, joka on tyyppiä Kayttoesine.

Kuvaus sanallisessa muodossa

Tässä on kuvaus luokista ja niiden vaadituista ominaisuuksista (vastaavat kuin UML-kaaviossa):

Robotilla on seuraavat metodit:

  • void vaihdaKayttoesine(Kayttoesine esine): Vaihtaa robotin käyttämän esineen (esim. imuri tai kastelukannu).
  • void teeTyota(String kohde): Suorittaa kotityön. Jos kohde on sillä listalla, jotka kyseiseltä käyttöesineeltä on kielletty (esim. Kastelukannu-oliolla ei saa kastella "Tietokone"-kohdetta), robotin tulee tulostaa virheilmoitus. Kielletyt käyttökohteet määritellään käyttöesineen attribuuttina merkkijonolistana.
  • Kastelukannu-olio ei kastele jos vettä ei ole riittävästi. Sen voi täyttää taytaVesi()-metodilla. Kastelukannun vesimäärä on aluksi 50 yksikköä. Voit halutessasi tehdä uuden muodostajan, joka asettaa vesimäärän alkutilan toiseksi.
  • Imuri-olio ei imuroi jos roskasäiliö on täynnä. Sen voi tyhjentää tyhjennaSailio()-metodilla. Roskasäiliön kapasiteetti on 100 yksikköä. Voit halutessasi tehdä uuden muodostajan, joka asettaa roskasäiliön alkutilan toiseksi.
  • Molemmat käyttöesineet palauttavat kayta(String kohde)-metodin avulla totuusarvon, joka kertoo onnistuiko työ.
Tee tehtävä TIMissä
Tehtävä 4.5: Miksi Comparable. 1 p.

Tutki Javan dokumentaatiota. Vastaa kysymyksiin Comparable-rajapinnasta.

Tee tehtävä TIMissä
Tehtävä 4.6: Henkilöt järjestykseen, osa 1. 1 p.

Tehtävässä on pohjana Henkilo-luokka omassa tiedostossaan sekä jarjestaHenkilot-metodi main.java-tiedostossa. Kyseinen metodi ei kuitenkaan toimi, sillä se käyttää Javan valmista Collections.sort-metodia ja Henkilo-luokasta puuttuu sille tuki.

Muokkaa Henkilo-luokkaa niin, että List<Henkilo>-tyyppiset listat voidaan järjestää Collections.sort-metodilla henkilön nimen mukaan aakkosjärjestykseen.

Esimerkiksi listan

List<Henkilo> henkilot = Arrays.asList(
    new Henkilo("Joukahainen"),
    new Henkilo("Ilmatar"),
    new Henkilo("Kyllikki"),
    new Henkilo("Kokko")
);

pitäisi olla Collections.sort(henkilot);-kutsun jälkeen järjestyksessä:

  1. Ilmatar
  2. Joukahainen
  3. Kokko
  4. Kyllikki
Tee tehtävä TIMissä
Tehtävä 4.7: Henkilöt järjestykseen, osa 2. 1 p.

Jatkoa edelliselle tehtävälle. Nyt Henkilo-luokassa henkilöiden nimet on jaettu erikseen sukunimeen ja etunimiin.

Muokkaa uudistettua Henkilo-luokkaa niin, että List<Henkilo>-tyyppiset listat voidaan järjestää Collections.sort-metodilla henkilön sukunimen ja etunimien mukaan aakkosjärjestykseen, niin että järjestys tapahtuu ensin sukunimen mukaan.

Esimerkiksi listan

List<Henkilo> henkilot = Arrays.asList(
        new Henkilo("Pacius", "Fredrik"),
        new Henkilo("Mozart", "Wolfgang Amadeus"),
        new Henkilo("Mozart", "Leopold"),
        new Henkilo("Chopin", "Frédéric")
);

pitäisi olla Collections.sort(henkilot);-kutsun jälkeen järjestyksessä:

  1. Chopin Frédéric
  2. Mozart Leopold
  3. Mozart Wolfgang Amadeus
  4. Pacius Fredrik
Tee tehtävä TIMissä
Tehtävä 4.8: Etsi suurin. 1 p.

Tee geneerinen metodi etsiSuurin, joka etsii listan suurimman alkion. Parametrina tulevan listan tulee toteuttaa Comparable-rajapinta, muutoin lista voi olla minkä tyyppinen tahansa. Älä käytä valmiita Collections-luokan metodeja.

Tee tehtävä TIMissä
Tehtävä 4.9: Kontti. 1 p.

Tee luokka Kontti, joka hyödyntää geneerisyyttä ja toimii yksinkertaisena säiliönä yhdelle minkä tahansa tyypin oliolle.

Lisää luokkaan attribuutti sisalto, joka voi sisältää minkä tahansa tyyppisen olion. Lisää myös merkkijono omistaja. Tee luokkaan muodostaja, joka ottaa nämä arvot vastaan parametreina.

Lisää lisäksi saantimetodit getOmistaja, getSisalto ja getTyyppi, joista viimeinen palauttaa kontin sisällön tyypin merkkijonona. Tee myös override toString-metodille, joka palauttaa nämä tiedot yhdessä merkkijonossa.

Tehtävässä on valmiiksi pääohjelma, jolla voit kokeilla luokan toimintaa.

Vinkki

Olion tyypin saa merkkijonona metodilla olio.getClass().getSimpleName().

Tee tehtävä TIMissä
Bonus: Tehtävä 4.10: Iso kontti. 1 p.

Luo luokka IsoKontti, joka toimii säiliönä usealle minkä tahansa tyypin oliolle. Konttiin pakataan esineitä niin, että viimeisimpänä lisätty otetaan aina ensimmäiseksi pois.

Lisää luokkaan attribuutiksi lista, johon oliot tallennetaan.

Lisää myös seuraavat metodit:

  • lisaa lisää parametrina annetun olion listan loppuun.

  • ota palauttaa viimeisimmän olion ja ottaa sen pois listasta.

  • katso palauttaa viimeisimmän olion, mutta ei ota sitä pois listasta.

  • sisaltaa ottaa parametrina olion ja palauttaa true, jos olio löytyy kontista. Muussa tapauksessa se palauttaa false.

  • tulosta tulostaa kontin sisällön. Voit itse päättää, missä muodossa sisältö tulostetaan.

Tehtävässä on valmiiksi pääohjelma, jolla voit kokeilla luokan toimintaa.

Tee tehtävä TIMissä
Bonus: Tehtävä 4.11: Tyyppirajoitukset, osa 1. 1 p.

Lisää edellisen tehtävän IsoKontti-luokkaan kaksi metodia.

  1. Luokan metodi (static) summaaNumerot ottaa parametrina IsoKontti-olion, joka sisältää numeroita eli Number-luokan tai sen alityyppien olioita. Metodi palauttaa kontin numeroiden summan.

  2. Olion metodi siirraKaikki ottaa parametrina toisen IsoKontti-olion ja siirtää metodia suorittavan kontin sisällön sinne. Toisen kontin täytyy olla tyypiltään sellainen, että se voi sisältää tämän kontin tyypin olioita.

Tehtävässä on valmiiksi pääohjelma, jolla voit kokeilla luokan toimintaa.

Vinkki

Tarvitset tässä tehtävässä tyyppirajoituksia.

  1. Kaikilla Number-luokan olioilla on doubleValue()-metodi, joka palauttaa sen arvon double-muodossa.

  2. Huomaa, että konttien tyyppien ei tarvitse olla täysin samat; Number-kontti voi sisältää Integer-olioita, sillä Integer on sen alityyppi.

Tee tehtävä TIMissä
Bonus: Tehtävä 4.12: Tyyppirajoitukset, osa 2. 1 p.

Tee geneerinen funktio, joka kopioi yhdestä listasta kaikki tyyppiä T vastaavat alkiot toiseen listaan, jonka tyyppi voi olla T tai sen ylityyppi.

Tee tehtävä TIMissä

Tietorakenteita ja algoritmeja

osaamistavoitteet

  • Ymmärrät, mitä kokoelma tarkoittaa ja miksi niitä käytetään ohjelmoinnissa.
  • Ymmärrät Javan kokoelmakehyksen (Collections Framework) perusrakenteen.
  • Tunnet Javan yleisimmät tietorakenteet ja niiden keskeisimmät metodit.
  • Ymmärrät, miksi olion equals ja hashCode -metodit ovat erityisen tärkeitä.
  • Osaat arvioida tietorakenteiden vaikutuksen suorituskykyyn eri tilanteissa.
  • Ymmärrät, miten rekursio toimii.

Kokoelmat

osaamistavoitteet

  • Ymmärrät, mitä kokoelma tarkoittaa ja miksi niitä käytetään ohjelmoinnissa.
  • Ymmärrät Javan kokoelmakehyksen (Collections Framework) perusrakenteen.
  • Tunnet Javan Collection-rajapinnan metodit ja osaat käyttää niitä ohjelmassa.

Ohjelmoinnin ytimessä on usein tiedon koostaminen järkeviksi kokonaisuuksiksi. Olemme aiemmin käyttäneet taulukoita ja listoja samantyyppisten arvojen, kuten lämpötilojen tai opiskelijoiden, tallentamiseen. Olemme myös oppineet olio-ohjelmointia, jonka avulla voimme niputtaa yhteen dataa ja siihen liittyvää toiminnallisuutta.

Pelkkä listaus ei kuitenkaan aina riitä. Vaikka taulukot ja listat ovat hyödyllisiä, todellisen maailman ongelmat vaativat usein tarkempia sääntöjä sille, miten tietoa lisätään, poistetaan tai haetaan.

Javassa termi kokoelma (engl. collection) viittaa olioon, jonka tehtävänä on hallita joukkoa muita arvoja tai olioita. Kokoelma ei ole vain säiliö; se on tietorakenne, joka määrittelee pelisäännöt tiedon käsittelylle. Oikean tietorakenteen valinta on ohjelmoijalle tärkeä taito, sillä se vaikuttaa sekä ohjelman tehokkuuteen että koodin luettavuuteen.

Alla on esimerkkejä tilanteista ja niihin soveltuvista kokoelmatyypeistä, kun taulukko tai lista ei enää ole optimaalinen ratkaisu:

  • Jono (Queue): Kun soitat asiakaspalveluun, puhelut ohjataan jonoon. Uudet soittajat tulevat jonon hännille, ja asiakaspalvelija poimii palveltavan aina jonon kärjestä. Tätä "ensimmäisenä sisään, ensimmäisenä ulos" -rakennetta kutsutaan jonoksi. Jono on optimoitu tällä tavalla tapahtuviin lisäyksiin ja poistoihin, toisin kuin tavallinen lista. Vastaavasti pino (engl. stack) toimii päinvastoin: viimeisenä lisätty alkio poistetaan ensimmäisenä (kuten pinossa lautasia).

  • Joukko (Set): Discord-palvelussa tai puhelinmuistiossa samaa henkilöä ei ole järkevää lisätä ystäväksi kahta kertaa. Rakennetta, joka huolehtii siitä, että jokainen alkio esiintyy siellä vain kerran (uniikit arvot), kutsutaan joukoksi.

  • Hakurakenne (Map): Opintotietojärjestelmässä jokaista opiskelijanumeroa vastaa tietty arvosana. Tässä ei ole kyse vain listasta, vaan avaimista (opiskelijanumero) ja niihin liittyvistä arvoista (arvosana). Rakennetta, jossa tietoa haetaan yksilöllisen avaimen perusteella, kutsutaan hakurakenteeksi tai assosiaatiotaulukoksi.

Voisimme periaatteessa toteuttaa nämä kaikki logiikat käyttämällä tavallisia listoja ja kirjoittamalla paljon ylimääräistä koodia (if-lauseita tarkistamaan duplikaatteja tai silmukoita etsimään tietoa). Javan kokoelmat tarjoavat kuitenkin valmiit, optimoidut ja selkeät työkalut näihin tarpeisiin.

Kokoelmakehys (Java Collections Framework)

Java tarjoaa valtavan joukon valmiita kokoelmia sekä rajapintoja uusien kokoelmien toteuttamiseksi osana Javan kokoelmakehystä.

Javan kokoelmaviitekehys perustuu kahteen pääosaan:

  • kokoelmarajapintoihin, jotka määrittävät, mitä toimintoja kokoelmalla voi tehdä (esim. List, Set), sekä
  • konkreettisiin toteutusluokkiin, jotka toteuttavat rajapinnan tai rajapinnat jollakin tavalla (esim. ArrayList, HashSet, HashMap).

Esimerkiksi List on kokoelmarajapinta, joka määrittää listalle kuuluvia metodeja, mutta ei sitä, miten ne varsinaisesti toteutetaan. Puolestaan ArrayList on eräs luokka, joka toteuttaa List-rajapinnan käyttämällä taulukkoja. Muita valmiita listan toteutuksia käsitellään osassa 5.2.

Javan kokoelmakehyksen oleellisin rajapinta on Collection, joka on yleinen, korkean tason rajapinta. List-rajapinta periytyy Collection-rajapinnasta, joten esimerkiksi edellä mainittu ArrayList voidaan sijoittaa Collection-tyyppiseen muuttujaan:

void main() {
Collection<String> marjat = new ArrayList<>(
  List.of("mansikka", "mustikka", "puolukka", "lakka")
);

IO.println(marjat);
}

Collection ei tee oletuksia sen sisältämien alkioiden järjestyksestä tai sisällöstä. Varsin lukuisa joukko Javan valmiita kokoelmia toteuttavat Collection-rajapinnan. Tutustumme seuraavaksi mitä Collection-rajapinnan toteuttavalla kokoelmalla voi tehdä.

Lisääminen ja poistaminen

Alkioita lisätään add-metodilla ja poistetaan remove-metodilla. Molemmat palauttavat true, jos kokoelma muuttui.

void main() {
Collection<String> marjat = new ArrayList<>(
  List.of("mansikka", "mustikka", "puolukka", "lakka")
);

marjat.add("kirsikka");
IO.println(marjat);

marjat.remove("mansikka");
IO.println(marjat);
}

Kaikissa kokoelmissa lisäys ei aina onnistu. Esimerkiksi Set-toteutuksessa samaa alkiota ei voi olla kahta kertaa, joten add voi palauttaa false. remove puolestaan poistaa yhden equals-metodin perusteella löytyvän alkion ja palauttaa false, jos alkiota ei löytynyt.

Collection-rajapinta ei tunne indeksien käsitettä. Siksi remove poistaa alkion arvon perusteella, eikä esimerkiksi "kolmatta alkiota". Jos haluat lisätä tai poistaa indeksin perusteella, tarvitset List-rajapinnan.

Yksittäisen alkion poistamisen lisäksi koko kokoelman voi tyhjentää clear-metodilla.

void main() {
Collection<String> marjat = new ArrayList<>(
  List.of("mansikka", "mustikka", "puolukka", "lakka")
);

marjat.clear();
IO.println(marjat);
}

Tietyn alkion löytyminen kokoelmasta

Collection-rajapinta määrittelee myös contains-metodin, jolla voi tarkistaa, löytyykö kokoelmasta jokin alkio. Tarkistus perustuu equals-metodiin, joten omien olioiden kanssa equals tulee toteuttaa järkevästi – palaamme tähän osassa 5.2.

void main() {
Collection<String> marjat = new ArrayList<>(
  List.of("mustikka", "puolukka", "lakka", "kirsikka")
);

IO.println("Löytyykö mustikka: " + marjat.contains("mustikka"));

// Mansikka poistettiin yllä, eli ei löydy
IO.println("Löytyykö mansikka: " + marjat.contains("mansikka"));
}

Alkioiden määrä ja tyhjyys

Collection-rajapinta määrittelee myös size- ja isEmpty-metodit, joilla voi selvittää, kuinka monta alkiota kokoelmassa on ja onko kokoelma tyhjä. isEmpty on usein selkeämpi tapa ilmaista, että kiinnostaa vain tyhjyys.

void main() {
Collection<String> marjat = new ArrayList<>(
  List.of("mustikka", "puolukka", "lakka", "kirsikka")
);

IO.println("Marjoja on: " + marjat.size());
IO.println("Onko marjakokoelma tyhjä: " + marjat.isEmpty());  
}

Alkioiden läpikäynti

Collection-rajapinta perii Iterable-rajapinnan, joten kokoelman alkioita voi käydä läpi for each-silmukalla.

void main() {
Collection<String> marjat = new ArrayList<>(
  List.of("mustikka", "puolukka", "lakka", "kirsikka")
);

for (String marja : marjat) {
    IO.println("Marja: " + marja);
}
}

Collection-rajapinta ei tee oletuksia alkioiden järjestyksestä. Tämän vuoksi läpikäynti ei perustu indekseihin, ja järjestys riippuu aina konkreettisesta kokoelmasta. ArrayList säilyttää lisäysjärjestyksen, kun taas HashSet ei takaa mitään järjestystä.

Sivuhuomautuksena mainittakoon, että Collection todellakin perii Iterable-rajapinnan. Emme käsitelleet rajapinnan perintää aiemmin, mutta idea toimii rajapinnoissa samalla tavalla kuin luokissa: kaikki Collection-toteutukset tarjoavat myös Iterable-rajapinnan vaatimukset.

Listarakenteet

List-rajapinta kuvaa kokoelmaa, jossa alkiot ovat tietyssä järjestyksessä ja niihin voidaan viitata indeksin avulla. Tämä vastaa monella tapaa taulukkoa, mutta tarjoaa huomattavasti joustavamman rajapinnan. Sivuhuomautus: Oikeampaa olisi sanoa, että kyseessä on List<E>-rajapinta, jossa E on listan alkioiden (element) tyyppi, mutta tässä yhteydessä jätämme geneerisyyden mainitsematta, jotta kirjoitusasu pysyy yksinkertaisena.

Kuvitellaan, että ohjelma tallentaa opiskelijoiden nimet siinä järjestyksessä kuin he ovat ilmoittautuneet kurssille. Tässä tilanteessa järjestyksellä on merkitystä, ja sama nimi voi esiintyä useammin kuin kerran, koska kahdella eri henkilöllä voi olla sama nimi.

void main() {
List<String> opiskelijat = new ArrayList<>();
opiskelijat.add("Aino");
opiskelijat.add("Ville");
opiskelijat.add("Aino");

IO.println(opiskelijat.get(1)); // Ville
}

Listan käyttäminen tuntuu luontevalta, koska ajattelet tietoa nimenomaan jonona: ensimmäinen, toinen, kolmas. ArrayList on tässä yleisin valinta, koska se mahdollistaa nopean pääsyn alkioihin indeksin avulla. Tässä vaiheessa kurssia voit ajatella, että List tarkoittaa käytännössä ArrayList‑luokkaa, ellei ole erityistä syytä käyttää muuta toteutusta.

Listan alkioiden suhteellinen järjestys säilyy, vaikka välistä poistetaan alkioita. Kun yksi alkio poistetaan, sitä seuraavat alkiot siirtyvät automaattisesti yhden askeleen eteenpäin.

void main() {
List<String> opiskelijat = new ArrayList<>();
opiskelijat.add("Aino");
opiskelijat.add("Ville");
opiskelijat.add("Aino");
opiskelijat.remove("Ville");
IO.println(opiskelijat);
}

Jokaisella alkiolla on listassa aina tietty paikka eli indeksi, ja tämän vuoksi lista käydään läpi aina samassa järjestyksessä. Tämä tekee listasta sopivan tilanteisiin, joissa järjestyksellä on merkitystä, järjestystä halutaan muokata tai säilyttää, tai sama alkio saa esiintyä useita kertoja.

Oma listarakenne

Seuraavaksi rakennetaan itse yksinkertainen dynaaminen listarakenne taulukon päälle. Tämän tarkoituksena on havainnollistaa, miten ArrayList toimii sisäisesti perusperiaatteiden tasolla. Toteutus ei ole täydellinen eikä kata koko List‑rajapintaa, mutta riittää ymmärtämään keskeiset ideat.

Aloitetaan luomalla oma luokka Lista<T>, joka sisältää taulukon alkioiden tallentamista varten. Alla oleva koodi on annettu valmiiksi.

public class Lista<T> {
  private T[] alkiot;
  private int koko;

  @SuppressWarnings("unchecked")
    public Lista(int kapasiteetti) {
      this.alkiot = (T[]) new Object[kapasiteetti];
    this.koko = 0;
  }
}

Tämän seurauksena alkiot-taulukko alustuu niin, että se sisältää kapasiteetti-parametrin verran null-arvoja.

Tässä välissä on tarpeen selittää, miksi @SuppressWarnings("unchecked") tarvitaan. Javassa ei voi suoraan luoda geneeristä T-tyyppistä taulukkoa. Tyyppiparametri T on olemassa vain käännösaikana. Ajonaikaisesti Java ei tiedä, mikä T oikeasti on. Tätä kutsutaan tyyppien häviämiseksi (type erasure). Taulukot sen sijaan ovat ajonaikaisesti tyyppitietoisia; kun luot taulukon, JVM tietää tarkasti, minkä tyyppisiä alkioita siihen saa tallentaa. Tästä syntyy ristiriita: Java ei pysty luomaan taulukkoa tyypistä T, koska T:n todellinen tyyppi ei ole ajonaikaisesti tiedossa. Siksi tämä ei ole sallittua:

new T[10]; // käännösvirhe 

Ainoa tapa kiertää rajoitus on luoda taulukko yleisimmästä mahdollisesta viitetyypistä eli Object:sta ja pakottaa se tyyppimuunnoksella geneeriseksi.

(T[]) new Object[kapasiteetti];

Kääntäjä tietää, että tämä muunnos ei ole täysin turvallinen. Se ei pysty varmistamaan, että taulukkoon ei koskaan päädy väärän tyyppisiä alkioita. Varoituksen ohittava annotaatio @SuppressWarnings("unchecked") ei tee koodista turvallisempaa eikä vaarallisempaa. Se ainoastaan kertoo kääntäjälle, että tiedostat tämän rajoituksen ja hyväksyt sen. Ilman annotaatiota ohjelma toimii täsmälleen samalla tavalla, mutta kääntäjä tulostaa varoituksen.

Valinnaista lisätietoa: Missä tilanteessa tyyppimuunnos voisi aiheuttaa ongelmia?

Oletetaan, että luot Lista<String>-olion. Tällöin T on String. Sisäisesti taustalla luotu taulukko alkioiden säilyttämistä varten on kuitenkin Object[]. Niin kauan kuin listaa käytetään oikein, ongelmaa ei synny. Mutta Java sallii tämän:

Lista<String> nimet = new Lista<>(10); // Lista, jossa T on String, pituus 10
Object o = nimet;
Lista<Integer> luvut = (Lista<Integer>) o; // unchecked cast

Kääntäjä varoittaa, mutta sallii koodin. Nyt molemmat viitteet osoittavat samaan listaan.

Seuraavaksi lisätään listaan alkioita käyttäen luvut-viitettä:

luvut.add(42); // oletetaan että add-metodi on toteutettu

Tämä onnistuu ajonaikaisesti, koska taulukko on oikeasti Object[] ja Integer on Object. JVM ei havaitse mitään virhettä tässä vaiheessa. Ongelmia syntyy, kun yrität hakea alkiota listasta:

String s = nimet.get(0); // ClassCastException

Tässä vaiheessa JVM yrittää muuntaa alkion String-tyyppiseksi, koska lista on Lista<String>. Koska alkio on kuitenkin Integer, syntyy ClassCastException. Tämä on se tarkka syy, miksi kääntäjä varoittaa unchecked-muunnoksesta. Se ei pysty todistamaan, että listan sisäinen tyyppisopimus säilyy ehjänä kaikissa tilanteissa. On tärkeää huomata, että ongelma ei johdu listan käytöstä sinänsä, vaan siitä, että geneerisen luokan tyyppitietoa kierretään eksplisiittisellä tyyppimuunnoksella. Valmiit Javan kokoelmat ovat samassa tilanteessa, mutta niiden rajapinnat ja toteutukset on suunniteltu niin, että tällaisia rikkomuksia ei käytännössä tapahdu normaalissa käytössä.

Listan perustoiminnot

Ensimmäisenä toteutetaan metodi add(element), joka lisää alkion listan loppuun. Emme vielä murehdi sitä, mitä tapahtuu, jos taulukko on täynnä. Jos taulukossa on tilaa, lisääminen on yksinkertaista: asetetaan alkio taulukon seuraavaan vapaaseen kohtaan ja kasvatetaan kokoa yhdellä. Jos taulukko on täynnä, palataan vain return-lauseella tekemättä mitään -- korjataan tämä myöhemmin.

Koska emme voi vielä lukea alkioita listasta, emme voi ohjelmallisesti tarkistaa, että lisäys onnistui. Voimme kuitenkin käyttää debuggeria. Kutsutaan add-metodia pääohjelmasta ja asetetaan sen jälkeen keskeytyskohta.

void main() {
  Lista<String> lista = new Lista<>(10);
  lista.add("Aino");
  lista.add("Ville");
  lista.add("Matti");
  // Aseta keskeytyskohta loppusulun kohdalle
}

Käynnistä IDEAn debuggeri. Avaa alhaalla aukeavassa näkymässä Threads and variables -välilehti. Jos tätä ei näy, valitse ylävalikosta View Tool Windows Debug. Nyt sinun pitäisi nähdä lista-olion tiedot. Avaa lista-olion alkiot-taulukko. Sen pitäisi sisältää lisätyt nimet alusta alkaen. IDEA ei näytä null-arvoja taulukon lopussa oletuksena. Jos haluat, saat ne esille klikkaamalla alkiot-taulukon kohdalla hiiren oikeaa painiketta, valitse "Customize data view", ja poista valinta kohdasta "Hide null elements".

Tehtävä 5.1: Listaan lisääminen. 1 p.

Luo luokka Lista. Määrittele sen sisälle taulukko (array), jossa säilytät listan alkiota, sekä kokonaislukumuuttuja, joka pitää kirjaa listan nykyisestä alkiomäärästä. Toteuta metodit

  • add(T element): lisää alkion listan loppuun.

Tietorakenteen kokoa ei tarvitse tässä vaiheessa kasvattaa, eli taulukko voi tulla täyteen. Tutki debuggerin avulla, että alkio on lisätty oikein. Älä toteuta vielä get- tai toString-metodeja; tarkastele listan tilaa nimen omaan debuggerin avulla.

Tee tehtävä TIMissä

Tehdään seuraavaksi metodi get(int index), joka palauttaa listan tietyn indeksin alkion, sekä metodi set(int index, T element), joka asettaa tietyn alkion tiettyyn indeksiin.

public T get(int index) {
  if (index < 0 || index >= koko) {
    throw new IndexOutOfBoundsException("Indeksi " + index + " ei ole välillä 0.." + (koko - 1));
  }
  return alkiot[index];
}

public void set(int index, T element) {
  if (index < 0 || index >= koko) {
    throw new IndexOutOfBoundsException("Indeksi " + index + " ei ole välillä 0.." + (koko - 1));
  }
  alkiot[index] = element;
}

Käsittelemme poikkeuksia tarkemmin vasta myöhemmin, mutta tässä yhteydessä on tarpeen mainita, mitä throw new...-rivi tarkoittaa. Listan indeksit alkavat nollasta ja jatkuvat aina koko - 1:een asti. Jos yrität hakea alkiota indeksillä, joka on pienempi kuin nolla tai suurempi tai yhtä suuri kuin koko, kyseinen indeksi on listan ulkopuolella. Tämä on yleinen käytäntö Javan kokoelmissa, ja varmasti sinulle myös tuttu aivan ohjelmoinnin alkeista asti. Nyt pääsemme itse heittämään kyseisen poikkeuksen!

Dynaamisuus

Vaikka Javassa List-rajapinta ei periaatteessa vaadi dynaamisuutta, eli alkioiden lisäämistä ja poistamista, niin käytännössä listojen odotetaan tukevan sitä. Tämä tarkoittaa, että listan koko voi muuttua ajon aikana. Tämä on merkittävä ero taulukkoon verrattuna, jossa koko on kiinteä.

Listan dynaamisuuden toteuttaminen taulukon päälle vaatii hieman lisälogiikkaa. Jos taulukossa on tilaa, ts. koko on pienempi kuin taulukon pituus, niin uuden alkion lisääminen on helppoa: asetetaan alkio taulukon seuraavaan vapaaseen kohtaan ja kasvatetaan kokoa yhdellä. Jos taulukko on täynnä, yleinen käytäntö on, että luodaan uusi, yleensä kaksinkertainen, taulukko, kopioidaan vanhan taulukon alkiot uuteen taulukkoon, ja sitten lisätään uusi alkio uuteen taulukkoon. Tämä prosessi varmistaa, että lista voi kasvaa tarpeen mukaan.

Tehtävä 5.2: Dynaaminen lista, osa 1. 1 p.

Muokkaa add-metodia siten, että se lisää alkion listan loppuun, ja tarvittaessa luo uuden isomman taulukon (1,5x tai 2x kokoisen) ja kopioi vanhan taulukon alkiot uuteen taulukkoon. Muista huolehtia myös listan koosta asianmukaisesti.

Toteuta myös size()-metodi, joka palauttaa listan nykyisen alkiomäärän.

Käytä get- ja size-metodeja apuna testataksesi, että add-metodi toimii oikein myös kun taulukko on täynnä ja uusi taulukko on luotu.

Tee tehtävä TIMissä

Alkioiden poistaminen listasta on hieman monimutkaisempaa, koska poistettu alkio jätetään tyhjäksi paikaksi taulukkoon. Yksi vaihtoehto olisi jättää kohta null-arvoksi. Tämä aiheuttaisi kuitenkin ongelmia, alkioiden järjestys ja indeksi eivät enää täsmäisi.

Toinen vaihtoehto olisi kopioida kaikki alkiot paitsi poistettu uuteen taulukkoon. Tämä olisi melko tehotonta, koska jokainen poisto vaatisi koko taulukon kopioimisen.

Yleensä poistaminen toteutetaan siten, että poistettua alkiota seuraavat alkiot siirretään yhden askeleen taaksepäin. Näin lista pysyy ehyenä, eikä uutta taulukkoa tarvita.

Tehtävä 5.3: Dynaaminen lista, osa 2. 1 p.

Toteuta remove(int index)-metodi, joka poistaa listasta alkion halutusta indeksistä. Metodin tulee palauttaa poistettu alkio.

  • Poistettua alkiota seuraavat alkiot siirtyvät vasemmalle, jotta taulukkoon ei jää "aukkoja".
  • Taulukkoon ei saa jäädä ylimääräisiä sisältöjä poistamisen jälkeen.
  • Muista päivittää listan koko asianmukaisesti.

Tarkista debuggerissa ja/tai get- ja size-metodeilla, että poisto on onnistunut oikein.

Tee tehtävä TIMissä

Poistaminen voi tapahtua myös alkion yhtäsuuruuden perusteella. Tällöin etsitään ensimmäinen alkio, joka on yhtä suuri kuin annettu alkio, ja poistetaan se.

Jotta remove(T element)-metodi toimisi oikein, on tärkeää käyttää equals-metodia yhtäsuuruuden tarkistamiseen. Tämä varmistaa, että vertailu toimii oikein myös silloin, kun listassa on geneerisiä tyyppejä. Toteutetaan tämä metodi seuraavaksi. Olemme tähän asti laittaneet listan alkioiksi lähinnä lukuja ja merkkijonoja, mutta käyttäessämme geneeristä T-tyyppiä, metodi toimii kaikilla olioilla, jotka toteuttavat equals-metodin.

Equals-metodi

Otetaan tässä yhteydessä pieni tangentti equals- ja hashCode-metodeista, koska niiden oikea käyttö on olennaista kokoelmien kanssa työskennellessä. Aloitetaan equals-metodista.

Javassa ==-operaattori toimii luotettavana sisällön vertailuna vain primitiivityyppien kanssa. Tämä on tietysti aivan hyvä tapa vertailla vaikkapa lukuja tai String-merkkijonoja. Olioviitteiden osalta ==-operaattori vertailee viitteitä. Esimerkiksi Integer a = new Integer(42); ja Integer b = new Integer(42); eivät ole a == b, vaikka niiden sisällöt ovatkin samat.

equals-metodin tarkoitus on vertailla olioiden sisältöä eli sitä, ovatko ne samat sovelluksen näkökulmasta.

Kaikki luokat perivät equals-metodin luokasta Object. Oletustoteutus Object.equals käyttäytyy kuten ==, eli se vertailee viitteitä. Siksi sisältövertailu vaatii usein oman equals-toteutuksen.

equals-metodilla on selkeä sopimus, jota pitää noudattaa:

  • Refleksiivisyys: x.equals(x) on aina true.
  • Symmetrisyys: jos x.equals(y) on true, niin y.equals(x) on true.
  • Transitiivisuus: jos x.equals(y) ja y.equals(z), niin x.equals(z).
  • Johdonmukaisuus: useat kutsut samalla datalla antavat saman tuloksen.
  • x.equals(null) on aina false.

Tyypillinen equals-toteutus etenee seuraavasti:

  1. Jos viitteet ovat samat, palauta true.
  2. Jos toinen on null tai tyyppi ei täsmää, palauta false.
  3. Muunna tyyppi ja vertaile niitä kenttiä, jotka määrittelevät "saman".

Esimerkki luokasta, joka vertailee henkilön nimeä ja opiskelijanumeroa. Vain siinä tilanteessa että molemmat kentät ovat yhtä suuret, olioiden katsotaan olevan yhtä suuret.

public class Opiskelija {
  private String nimi;
  private String opiskelijanumero;

  public Opiskelija(String nimi, String opiskelijanumero) {
    this.nimi = nimi;
    this.opiskelijanumero = opiskelijanumero;
  }

  @Override
  public boolean equals(Object toinen) {
    if (this == toinen) {
      return true;
    }
    if (toinen == null || getClass() != toinen.getClass()) {
      return false;
    }
    // Tämä tyyppimuunnos on nyt turvallinen
    Opiskelija toinenOpiskelija = (Opiskelija) toinen;
    return this.opiskelijanumero.equals(toinenOpiskelija.opiskelijanumero)
        && this.nimi.equals(toinenOpiskelija.nimi);
  }
}

Jos opiskelijanumero tai nimi voi olla null, käytä Objects.equals(a, b), joka toimii null-turvallisesti.

import java.util.Objects;

@Override
public boolean equals(Object toinen) {
  
  // ...
  
  Opiskelija toinenOpiskelija = (Opiskelija) toinen;
  return Objects.equals(this.opiskelijanumero, toinenOpiskelija.opiskelijanumero)
      && Objects.equals(this.nimi, toinenOpiskelija.nimi);
}

HashCode-metodi

Kun luokka toteuttaa equals-metodin, on ehdottoman tärkeää toteuttaa myös hashCode-metodi. Nämä kaksi kulkevat käsi kädessä, ja toisen unohtaminen johtaa vaikeasti jäljitettäviin virheisiin. Jotta ymmärrämme miksi, on sukellettava hetkeksi hajautustaulujen (kuten HashSet ja HashMap) toimintaperiaatteeseen.

Miten hajautus toimii? Kuvittele kirjasto, jossa on tuhansia kirjoja. Jos haluat löytää tietyn kirjan, et käy jokaista kirjaa läpi yksi kerrallaan alusta loppuun (kuten lista tekisi). Sen sijaan katsot kirjastojärjestelmästä hyllyluokan (esim. 800–899). Javassa hashCode toimii kuten tämä hyllyluokka:

  1. Lokerointi: hashCode-metodi tiivistää olion tiedot yhdeksi kokonaisluvuksi, jota kutsutaan hajautusarvoksi. Kokoelma käyttää tätä lukua päättääkseen, mihin lokeroon olio tallennetaan.
  2. Nopeus: Kun etsit oliota kokoelmasta, Java laskee etsittävän olion hajautusarvon ja hyppää suoraan oikeaan lokeroon.
  3. Tarkistus: Lopuksi Java käy läpi vain kyseisessä lokerossa olevat oliot käyttäen equals-metodia varmistaakseen, onko etsitty olio todella siellä.

Javan kokoelmat luottavat sokeasti siihen, että jos equals sanoo, että kaksi oliota ovat samat, niiden hajautusarvojen on oltava samat.

  • Pakollinen suunta: x.equals(y) == true x.hashCode() == y.hashCode().

  • Ei-pakollinen suunta: Vaikka hajautusarvot olisivat samat, oliot voivat olla eri sisältöisiä, jolloin syntyy niin sanottu törmäys. Tämä tarkoittaa, että ne vain päätyvät samaan lokeroon, mutta equals erottelee ne kuitenkin toisistaan.

Toteutus: Helpoin ja turvallisin tapa toteuttaa hashCode on käyttää Javan valmista apumetodia Objects.hash. On erittäin tärkeää, että hashCode-arvon laskennassa käytetään tismalleen samoja kenttiä kuin equals-metodissa. Jos equals vertailee nimeä ja opiskelijanumeroa, hashCode ei voi jättää toista pois.

@Override
public int hashCode() {
    // Luodaan tunnusluku (hash) samoista kentistä kuin equals-vertailussa
    return Objects.hash(nimi, opiskelijanumero);
}
Valinnaista lisätietoa: Älä muuta avaimia!

Hajauttavien rakenteiden kanssa on yksi vaaranpaikka: olioiden muuttaminen.

Jos lisäät olion HashSet-kokoelmaan ja sen jälkeen muutat olion kenttiä (esim. opiskelijanumeroa), olion laskennallinen hashCode muuttuu. Olio on kuitenkin edelleen alkuperäisen hajautusarvon mukaisessa lokerossa. Kun yrität etsiä sitä uudella arvolla, Java katsoo uuteen lokeroon eikä löydä mitään.

Nyrkkisääntö: Jos käytät oliota HashMap-avaimena tai HashSet-jäsenenä, pyri pitämään kyseinen olio muuttumattomana.

Vinkki: Jos käytät Javan uudempia record-tietueita, sinun ei tarvitse huolehtia tästä. Java generoi automaattisesti oikeaoppiset equals- ja hashCode-metodit, jotka ottavat huomioon kaikki tietueen kentät.

Vaikka juuri oman dynaamisen listan rakentelussa sinun ei tarvitsekaan itse toteuttaa equals- tai hashCode-metodeja, on jatkon kannalta tärkeää ymmärtää näiden metodien merkitys kokoelmien, kuten HashSet ja HashMap, taustalla.

Poistaminen alkion perusteella

Kuten edellä opimme, poistaminen alkion perusteella edellyttää, että listan alkioiden tyypillä on toimiva equals-metodi. Tämän jälkeen poistaminen voidaan tehdä luotettavasti käymällä lista läpi ja vertaamalla jokaista alkiota haluttuun alkioon equals-metodilla.

Jos listassa voi olla useita samanlaisia alkioita, tämä toteutus poistaa vain ensimmäisen; muiden poistaminen vaatii uuden läpikäynnin.

Tehtävä 5.4: Dynaaminen lista, osa 3. 1 p.

Jatka Lista-luokkaa toteuttamalla remove(T element)-metodi, joka poistaa ensimmäisen esiintymän annetusta alkiosta.

  • Jos alkio löytyy ja poistetaan, palauta true.
  • Jos alkiota ei löydy, lista pysyy ennallaan ja palautetaan false.

Vinkki: Voit käyttää jo toteuttamaasi remove(int index)-metodia apuna; älä kopioi siirto-logiikkaa tähän metodiin.

Tee tehtävä TIMissä

Hakurakenteet

osaamistavoitteet

  • Tunnet Java-kielen yleisimmät valmiit tietorakenteet: Map ja sen toteutukset HashMap, LinkedHashMap ja TreeMap.
  • Osaat käyttää em. tietorakenteita.
  • Ymmärrät em. tietorakenteiden keskeisimmät operaatiot ja tunnistat niiden aikavaativuudet.
  • Ymmärrät, miksi oliot tarvitsevat hashCode-metodin.

Kuvitellaan tilanne, jossa ylläpidämme laajaa opiskelijarekisteriä. Haluamme tallentaa kunkin opiskelijan numeron ja nimen siten, että voimme opiskelijanumeron perusteella löytää helposti opiskelijan nimen.

Jos käyttäisimme tähän listaa, joutuisimme tallentamaan tiedot joko eri listoihin tai luomaan erillisen olion, joka sisältää kaikki tiedot. Kun haluaisimme hakea tietyn henkilön tiedot, joutuisimme käymään listan alkioita läpi, kunnes oikea henkilö löytyy. Tämä ei ole hirveän tehokasta, jos opiskelijoita on hyvin suuri määrä.

Hakurakenteet tarjoavat tähän ratkaisun mahdollistamalla suoran haun jonkin tunnuksen perusteella parhaimmassa tapauksessa ilman koko listan läpikäymistä. Tunnuksena voisi toimia esimerkiksi opiskelijan numero. Hakurakenne toimii kuin sanakirja, josta voimme katsoa suoraan oikean kohdan sen sijaan, että lukisimme koko kirjan kannesta kanteen löytääksemme tietyn sanan. Hakurakenteita kutsutaankin useissa ohjelmointikielissä nimellä sanakirja (engl. dictionary). Javassa ne tunnetaan nimellä map.

void main() {
// Luodaan avain-arvo-pareja.
Map<String, String> opiskelijat = Map.of(
  "123", "Joni",
  "555", "Maija",
  "789", "Mikko"
);

// Voimme pyytää avaimen avulla opiskelijan tietoja suoraan käymättä
// koko tietorakennetta läpi.
IO.println("Opiskelija, jonka numero on 123: " + opiskelijat.get("123"));
}

Hakurakenteet ovat tietorakenteita, joihin tallennetaan tietoa avain-arvo-pareina (engl. key-value pair), joista käytetään myös nimitystä Entry. Siinä missä listassa yksilöllinen, kokonaislukutyyppinen indeksi toimii "reittinä" sisältöön, hakurakenteessa reittinä toimii yksilöllinen, minkä tahansa tyyppinen olio. Avaimet ovat aina uniikkeja; yksi avain voi esiintyä hakurakenteessa vain kerran ja se osoittaa vain yhteen arvoon kerrallaan. Arvot sen sijaan eivät ole uniikkeja.

huomautus

Javassa sekä avaimet että arvot ovat aina olioita. Ne eivät voi olla primitiivityyppejä, kuten int tai double, vaan tähän tarkoitukseen on käytettävä vastaavia kääreluokkia, kuten Integer tai Double.

Map

Map on Javan kokoelmakehyksen toinen keskeinen rajapinta Collection-rajapinnan rinnalla. Map määrittelee yleiset säännöt kaikille hakurakenteille tarjoamalla tärkeimmät metodit avain-arvo-parien tallentamiseen ja käsittelyyn. Collection-rajapinnan tapaan Map-rajapinta ei ota kantaa alkioiden järjestykseen tai sisältöön eikä edellytä sen toteuttavien luokkien käyttävän mitään tiettyä tietorakennetta tiedon tallentamiseen.

Toisin kuin Collection, Map ei toteuta Iterable-rajapintaa, minkä vuoksi hakurakenteita ei voida iteroida esimerkiksi for each -silmukan avulla, vaan alkioiden läpikäynti on tehtävä hieman hankalammin avainten tai arvojen kautta.

Tutustutaan nyt Map-rajapinnan tärkeimpiin metodeihin. Käytämme esimerkeissä konkreettista HashMap-luokkaa, mutta kaikki esimerkeissä käytetyt metodit ovat määritelty Map-rajapinnassa, joten ne toimivat kaikissa Map-toteutuksissa.

Alkion lisääminen ja poistaminen

Alkioiden lisääminen onnistuu put- ja putIfAbsent-metodeilla. Metodit ottavat parametreina avaimen ja sitä vastaavan arvon. Jos avain on jo valmiiksi tietorakenteessa, metodit palauttavat sitä vastaavan alkuperäisen arvon ennen ylikirjoittamista. Jos avainta ei ole valmiiksi tietorakenteessa, metodit palauttavat null.

void main() {
Map<String, Integer> arvosanat = new HashMap<>();

// Lisää tai korvaa arvon.
arvosanat.put("Maija", 3);
arvosanat.put("Maija", 5); // Palauttaa 3 ja korvaa alkuperäisen.

// Lisää arvon vain, jos avainta ei ole vielä tietorakenteessa.
arvosanat.putIfAbsent("Joni", 1);
arvosanat.putIfAbsent("Joni", 0); // Palauttaa 1, mutta ei korvaa alkuperäistä.

IO.println(arvosanat);
}

Poistaminen onnistuu kokoelmien tapaan remove-metodilla, jolle annetaan parametrina poistettava avain. Metodi palauttaa lisäämisen tapaan poistettavaa avainta vastaavan arvon, jos sellainen tietorakenteessa on. Muussa tapauksessa se palauttaa null.

void main() {
Map<String, Integer> arvosanat = new HashMap<>();
arvosanat.put("Maija", 5);
arvosanat.put("Joni", 5);

// Poistaa avain-arvo-parin, jonka avain on "Joni".
arvosanat.remove("Joni");

IO.println(arvosanat);
}

Arvon hakeminen avaimen avulla

Avainta vastaavan arvon hakemiseen voidaan käyttää get ja getOrDefault-metodeja. Jos avainta ei löydy, get palauttaa null. getOrDefault-metodille voi itse määrittää palautettavan oletusarvon.

void main() {
Map<String, Integer> arvosanat = new HashMap<>();
arvosanat.put("Maija", 5);

// Arvo on 5.
IO.println("Maijan arvosana: " + arvosanat.get("Maija")); 

// Avainta ei ole, joten arvo on null.
IO.println("Jonin arvosana: " + arvosanat.get("Joni")); 

// Käytetään oletusarvoa 0.
IO.println("Jonin arvosana: " + arvosanat.getOrDefault("Joni", 0)); 
}

Voimme myös tarkistaa, sisältääkö tietorakenne tietyn avaimen tai arvon containsKey ja containsValue -metodeilla, jotka palauttavat totuusarvon true tai false.

void main() {
Map<String, Integer> arvosanat = new HashMap<>();
arvosanat.put("Maija", 5);

IO.println(arvosanat.containsKey("Maija")); // true
IO.println(arvosanat.containsKey("Matti")); // false

IO.println(arvosanat.containsValue(5)); // true
IO.println(arvosanat.containsKey(0)); // false
}

Alkioiden määrä

Map-rajapinta määrittelee Collection-rajapinnan tapaan myös size ja isEmpty -metodit, joilla voimme tarkastella alkioiden lukumäärää tai sitä, onko tietorakenne tyhjä.

void main() {
Map<String, Integer> arvosanat = new HashMap<>();
arvosanat.put("Maija", 5);

IO.println("Alkioita: " + arvosanat.size()); // 1
IO.println("Tyhjä: " + arvosanat.isEmpty()); // false
}

Alkioiden läpikäynti

Map ei toteuta Iterable-rajapintaa eikä ole siten suoraan iteroitavissa for each -silmukalla, mutta se tarjoaa metodit keySet, values ja entrySet, jotka palauttavat avaimet, arvot tai näiden parit kokoelmina.

Huomaa, että entrySet palauttaa kokoelman Map.Entry<K,V>-tyypin olioista, jossa K ja V vastaavat tietorakenteen avaimen ja arvon tyyppejä. Map.Entry on avain-arvo-pari, joka sisältää metodit getKey ja getValue.

void main() {
Map<String, Integer> arvosanat = new HashMap<>();
arvosanat.put("Maija", 3);
arvosanat.put("Matti", 4);

// Käydään läpi kaikki avaimet. Voimme avaimen perusteella hakea 
// myös sitä vastaavan arvon tulostettavaksi.
for (String avain : arvosanat.keySet()) {
  IO.println(avain + " : " + arvosanat.get(avain));
}

// Käydään läpi kaikki arvot. Arvon avulla emme pääse käsiksi 
// avaimeen, joten emme voi sitä tulostaa.
for (Integer arvo : arvosanat.values()) { 
  IO.println(arvo);
}

// Käydään läpi kaikki avain-arvo-parit.
for (Map.Entry<String, Integer> pari : arvosanat.entrySet()) { 
  IO.println(pari.getKey() + " : " + pari.getValue());
}
}

Hajautustaulu

Ennen kuin tutustumme tarkemmin konkreettisiin Map-toteutuksiin, katsotaan ensin yleisellä tasolla hajautustaulun (engl. hash table) toimintaperiaatetta. Kyseessä on klassinen tietorakenteiden taustalla oleva logiikka, joka toimii perustana useille Map-toteutuksille, kuten Javan HashMap- ja LinkedHashMap-luokille.

Hajautustaulun toteutus perustuu taulukkoon, jota käytetään avain-arvo-parien tallentamiseen. Menetelmän keskeinen idea on siinä, että tietoa ei tarvitse etsiä selaamalla, vaan oikea sijainti lasketaan avaimen perusteella.

Kun tietorakenteeseen lisätään pari, sen paikka taulukossa määritetään seuraavasti:

  1. Hajautusarvo: Avaimelle (key) lasketaan kokonaisluku hashCode-metodin avulla.
  2. Indeksi: Koska hajautusarvo voi olla valtava tai negatiivinen, se täytyy "leikata" taulukon kokoon sopivaksi.

Tämä tehdään tyypillisesti laskemalla hajautusarvon jakojäännös hajautustaulun taustalla olevan taulukon koosta (kapasiteetti). Tästä luvusta otetaan itseisarvo, joka on aina välillä [0..kapasiteetti-1].

indeksi = itseisarvo(hajautusarvo % kapasiteetti)

Koska mahdollisia hajautusarvoja on valtavasti, mutta taulukossa on vain rajallinen määrä indeksejä, on mahdollista, että kaksi eri avainta päätyy samaan indeksiin. Tätä kutsutaan törmäykseksi. Törmäysten hallintaan on kaksi päästrategiaa:

  1. Ketjutus (engl. Chaining): Tämä on muun muassa Javan HashMap-luokan käyttämä tapa. Hajautustaulun jokainen indeksi on "ämpäri", joka sisältää listan. Kaikki samaan indeksiin osuvat alkiot lisätään tähän listaan jonoon.

  2. Avoin hajautus (engl. Open Addressing): Indeksissä on tilaa vain yhdelle alkiolle. Jos paikka on varattu, etsitään seuraava vapaa paikka esimerkiksi siirtymällä taulukossa eteenpäin, kunnes tyhjä kohta löytyy.

Alkiota hakiessa lasketaan ensin indeksi samalla kaavalla kuin lisäyksessä. Tämän jälkeiset vaiheet riippuvat strategiasta. Ketjutusmenetelmää käytettäessä alkiota lähdetään etsimään indeksistä löytyvästä listasta. Avointa hajautusta käytettäessä lähdetään käymään hajautustaulun taulukon indeksejä läpi, kunnes etsitty avain (tai null-arvo) löytyy.

Törmäysten määrä vaikuttaa suoraan tietorakenteen suorituskykyyn. Hajautustaulun lisäys-, poisto- ja hakuoperaatiot ovat hyvin nopeita. Niiden keskimääräinen aikavaativuus on , sillä oikea indeksi saadaan suoraan yksinkertaisella laskutoimituksella. Huonoimmassa tapauksessa kaikki alkiot päätyvät samaan indeksiin, jolloin oikean alkion löytämiseksi joudutaan käymään koko tietorakenne läpi, minkä vuoksi pahin tapaus on .

huomautus

Aikavaativuuksia käsitellään perusteellisemmin Algoritmit 1 -opintojaksolla; tässä vain sivuamme niitä. Tämän opintojakson osalta riittää ymmärtää, että tarkoittaa, että operaatio onnistuu vakioajassa, eli se ei riipu tietorakenteen koosta. tarkoittaa, että operaation vaatima aika kasvaa samassa suhteessa tietorakenteen kokoon. Jos esimerkiksi tietorakenteessa on 1000 alkiota, -operaatio vaatisi 1000 kertaa enemmän aikaa kuin -operaatio.

Jotta hajautustaulu pysyisi nopeana (), törmäykset on minimoitava. Tähän vaikuttaa täyttöaste (engl. load factor). Jos taulukon kapasiteetti on liian pieni suhteessa alkioiden määrään, taulukko tulee liian täyteen ja törmäykset lisääntyvät. Kun täyttöaste ylittää tietyn rajan, hajautustaulu kasvatetaan -- yleensä tuplataan -- ja kaikki alkiot sijoitetaan uudelleen uuteen, isompaan taulukkoon. Tämä operaatio on raskas, joten oikean alkukapasiteetin arviointi on tärkeää.

Hajautustaulu toimii kuin varasto, jossa on monta säilytyslaatikkoa, joihin voidaan laittaa kuinka monta esinettä tahansa. Kapasiteetti kuvastaa säilytyslaatikoiden lukumäärää ja laatikot ovat hajautustaulun indeksejä. Laatikot ovat tapa järjestellä esineitä niin, että löydämme haluamamme esineen varastosta helpommin. Yksi laatikko voi olla vaatteita varten, toinen työkaluja, ja kolmanteen laitetaan kaikki muut. Voimme yksinkertaistetusti ajatella, että esimerkiksi kaikki työkalut saisivat hajautusfunktion tuloksena saman indeksin ja päätyvät samaan laatikkoon. Etsiessämme vasaraa voimme heti katsoa työkalujen laatikosta, mutta jos se sisältää suuren määrän esineitä, oikean työkalun löytämiseen voi silti mennä aikaa.

HashMap

HashMap on yleisimmin käytetty Map-rajapinnan toteuttava luokka. HashMap-luokan toteutus perustuu edellä mainittuun hajautustauluun ja se tarjoaa parhaan keskimääräisen suorituskyvyn perusoperaatioille. Alkioiden hakeminen, lisääminen ja poistaminen avaimen perusteella onnistuu parhaimmillaan vakioajassa . Kaikkien alkioiden läpikäyminen on kuitenkin monimutkaisemman sisäisen tietorakenteen vuoksi hieman hitaampaa kuin listoissa. HashMap-luokan suorituskykyerot tulevat paremmin esille, kun alkioita on hyvin suuri määrä; jos alkioita on vähän, eroa esimerkiksi listaan ei juurikaan huomaa.

HashMap ei takaa alkioiden järjestystä; alkioita läpi käydessä ne voivat olla missä järjestyksessä tahansa, ja järjestys voi muuttua, kun rakenteeseen lisätään uusia alkioita.

LinkedHashMap

LinkedHashMap on HashMap-luokasta periytyvä luokka, joka ylläpitää sisäisesti hajautustaulun lisäksi linkitettyä listaa kaikista lisätyistä alkioista. Tämä mahdollistaa alkioiden lisäysjärjestyksen säilyttämisen, mutta lisätty tietorakenne vie hieman enemmän muistia.

void main() {
Map<String, Integer> hashmap = new HashMap<>();
hashmap.put("Joni Virtanen", 20);
hashmap.put("Maija Meikäläinen", 10);
hashmap.put("Matti Korhonen", 5);

// Lisäysjärjestys ei säily.
for (String key : hashmap.keySet()) {
    IO.println(key + " : " + hashmap.get(key));
}

IO.println();

Map<String, Integer> linked = new LinkedHashMap<>();
linked.put("Joni Virtanen", 20);
linked.put("Maija Meikäläinen", 10);
linked.put("Matti Korhonen", 5);

// Lisäysjärjestys säilyy.
for (String key : linked.keySet()) {
    IO.println(key + " : " + linked.get(key));
}
}

LinkedHashMap sopii hyvin esimerkiksi verkkokaupan viimeksi katsottujen tuotteiden tallentamiseen. Tuote voidaan hakea nopeasti avaimena toimivan tuotetunnuksen perusteella, mutta samalla tuotteet säilyvät siinä järjestyksessä kuin käyttäjä on niitä katsonut. Tämä tekee rakenteesta käytännöllisen silloin, kun tarvitaan sekä nopeaa hakua että käyttäjälle näytettävää, luonnollista järjestystä.

TreeMap

TreeMap eroaa edellisistä siten, että se käyttää hajautustaulun sijaan puurakennetta sisäisenä tietorakenteenaan. Se toteuttaa SortedMap- ja NavigableMap-rajapinnat. SortedMap takaa, että avaimet ovat aina luonnollisessa järjestyksessä, ja NavigableMap lisää tähän mahdollisuuden etsiä esimerkiksi lähintä avainta tietyn arvon ylä- tai alapuolelta. Muista tässä osassa mainituista hakurakenteista poiketen TreeMap ei salli null-arvoa avaimena, sillä sitä ei voitaisi vertailla muihin avaimiin niiden järjestyksen selvittämiseksi.

TreeMap on puurakenteen vuoksi operaatioiltaan hitaampi kuin HashMap, mutta se mahdollistaa alkioiden järjestämisen avaimen perusteella. TreeMap-luokan operaatioiden aikavaativuus on .

void main() {
Map<String, Integer> tree = new TreeMap<>();
tree.put("Olli", 100);
tree.put("Heikki", 200);
tree.put("Anna", 300);

// Tulostaa avaimen mukaisesti suuruusjärjestyksessä.
for (String key : tree.keySet()) {
    IO.println(key + " : " + tree.get(key));
}
}

NavigableMap-rajapinta tarjoaa myös useita metodeja, joilla voidaan hakea avaimia tai pareja eri tavoin avainten järjestykseen perustuen.

void main() {
NavigableMap<String, Integer> tree = new TreeMap<>();
tree.put("B", 2);
tree.put("H", 3);
tree.put("A", 1);
tree.put("Q", 1);

// Tulostetaan pienin ja suurin avain.
IO.println("Pienin avain: " + tree.firstKey());
IO.println("Suurin avain: " + tree.lastKey()); 

// Tulostetaan annettua avainta lähin pienempi ja suurempi avain.
IO.println(tree.lowerKey("B")); 
IO.println(tree.higherKey("B"));

// Palauttaa koko tietorakenteen käänteisessä järjestyksessä.
IO.println(tree.descendingMap()); 
}

Lisäksi TreeMap mahdollistaa alipuiden muodostamisen subMap-metodilla.

void main() {
NavigableMap<String, Integer> tree = new TreeMap<>();
tree.put("B", 2);
tree.put("H", 3);
tree.put("A", 1);

// Muodostetaan uusi hakurakenne alkioista, jotka ovat A-C välillä.
// Parametrien true-arvot kertovat, että myös A ja C otetaan mukaan.
Map<String, Integer> alipuu = tree.subMap("A", true, "C", true);
IO.println(alipuu);
}

Hyvä esimerkki TreeMap-rakenteen käyttökohteesta on aikaleimoihin perustuva tapahtumien tallennus. Oletetaan, että järjestelmä kerää lokimerkintöjä, joissa jokaisella tapahtumalla on tarkka aikaleima ja siihen liittyvä kuvaus. Tällöin TreeMap<LocalDateTime, Tapahtuma> soveltuu rakenteeksi hyvin. Sen keskeinen etu on avainten automaattinen järjestäminen: uudet tapahtumat sijoittuvat suoraan oikeaan kohtaan, jolloin dataa voidaan käsitellä kronologisessa järjestyksessä ilman erillistä lajittelua.

TreeMap mahdollistaa myös nopeat haut tietyltä aikaväliltä (subMap, headMap, tailMap) sekä lähimmän arvon etsimisen. Esimerkiksi floorKey löytää annettua hetkeä edeltävän (tai saman) aikaleiman, kun taas ceilingKey palauttaa seuraavan (tai saman) aikaleiman. Kaikkien näiden hakumetodien aikavaativuus on , mikä on huomattavasti tehokkaampaa kuin järjestämättömän tietorakenteen läpikäynti.

TreeMap mahdollistaa myös tehokkaat aikavälihaut, jotka ovat tällaisessa tilanteessa keskeisiä. Esimerkiksi voidaan hakea kaikki tapahtumat tietyn ajanhetken jälkeen tai kahden aikaleiman väliltä käyttämällä tailMap, headMap tai subMap -metodeja. Näiden operaatioiden aikavaativuus on , mikä tekee niistä selvästi tehokkaampia kuin koko tietorakenteen läpikäynnin vaativat ratkaisut. Lisäksi TreeMap tarjoaa metodeja, joilla voidaan löytää lähin tapahtuma ennen tai jälkeen tietyn ajanhetken, mikä on tyypillinen tarve lokien ja historiatietojen käsittelyssä.

Seuraavassa taulukossa verrataan TreeMap-rakennetta vaihtoehtoisiin tietorakenteisiin aikajärjestystä vaativissa tilanteissa:

RakenneEtuHaitta
HashMapYksittäisen avaimen haku on keskimäärin nopeampaa ().Järjestys katoaa. Aikavälihaut vaatisivat kaikkien alkioiden läpikäynnin tai erillisen lajittelun.
LinkedHashMapSäilyttää lisäysjärjestyksen.Ei takaa aikajärjestystä, jos dataa ei syötetä kronologisesti.
PriorityQueueNopea pääsy ääriarvoihin (min/max).Ei tue tehokasta hakua mielivaltaisella avaimella tai aikavälillä.

Rakenteiden valinta riippuu siis tarpeesta: HashMap on yleensä paras, kun tarvitaan mahdollisimman nopeaa avaimella hakua eikä järjestyksellä ole väliä. LinkedHashMap sopii tilanteisiin, joissa lisäysjärjestys halutaan säilyttää. TreeMap on näitä hitaampi, mutta oikea valinta silloin, kun tarvitaan avainten jatkuvaa järjestystä sekä järjestykseen perustuvia hakuja.


Tehtävä 5.5: Sanat1 p.

Tee funktio laskeSanat, joka ottaa parametrina vastaan merkkijonon ja tekee seuraavat asiat:

  • Tulostaa kaikki uniikit sanat ja sen, kuinka monta kertaa kyseinen sana esiintyy merkkijonossa.
  • Tulostaa vielä erikseen yleisimmän sanan ja sen, kuinka monta kertaa se esiintyy merkkijonossa.
  • Tulostaa uniikkien sanojen lukumäärän.

Voit testata ohjelmasi toimintaa valmiilla pääohjelmalla, jossa on myös esimerkkituloste.

Vinkki

Merkkijonosta voi ottaa välimerkit pois sen replaceAll-metodilla.

Tee tehtävä TIMissä
Bonus: Tehtävä 5.6: Varaukset1 p.

Tee luokka Varaukset, joka tallentaa varauksia. Yhdelle päivämäärälle voi olla vain yksi varaus ja varauksen tekijän nimi täytyy myös tallentaa.

Päivämääränä voit tässä tehtävässä käyttää merkkijonoa, jonka muoto on YYYY-MM-DD eli vuodet, kuukaudet ja päivät. Voit myös olettaa, että päivämäärät ovat aina oikeassa muodossa.

Toteuta luokkaan seuraavat metodit:

  • lisaaVaraus ottaa parametrina päivämäärän ja varaajan nimen merkkijonona ja lisää varauksen tietorakenteeseen. Jos päivämäärälle on jo varaus, uusi varaus ei saa korvata sitä. Metodi palauttaa true, jos uusi varaus lisätään tietorakenteeseen , muuten false.

  • poistaVaraus ottaa parametrina päivämäärän ja poistaa sille päivälle sijoittuvan varauksen. Metodi palauttaa true, jos varaus poistetaan tietorakenteesta, muuten false.

  • tulostaVaraukset ottaa parametrina alku- ja loppupäivämäärän ja tulostaa kaikki näiden väliin sijoittuvat varaukset varauksen päivämäärän mukaan järjestettynä.

Voit testata luokan toimintaa valmiin pääohjelman avulla.

Vinkki

Tietorakennetta ei tässä tapauksessa kannata järjestää itse. Yksi Map-rajapinnan toteuttavista luokista pitää alkiot aina järjestyksessä.

Tee tehtävä TIMissä
Bonus: Tehtävä 5.7: Hajautustaulu1 p.

Toteuta oma yksinkertainen hajautustaulu.

Käytä hajautustaulun päätietorakenteena taulukkoa. Voit käyttää törmäysten käsittelyyn esimerkiksi listaa, eli samaan indeksiin osuvat alkiot laitetaan siinä indeksissä sijaitsevaan listaan alkioista. Alkioita ei saa kadota törmäysten yhteydessä.

Hajautustaulun kapasiteetilla voi olla oletusarvona 10 tai se voi ottaa arvon parametrina muodostajassa. Kapasiteetin ei tarvitse muuttua missään vaiheessa ohjelman suorituksen aikana, eli taulun käyttöastetta ei tarvitse huomioida tai toteuttaa.

Javan hashCode voi palauttaa negatiivisen arvon, joten kannattaa käyttää itseisarvoa negatiivisen indeksin välttämiseksi.

Lisää metodi hae, joka hakee alkion hajautustaulusta sen avaimen perusteella. Lisää myös metodit lisaa ja poista alkioiden lisäämistä ja poistamista varten.

Tee tehtävä TIMissä

Joukko- ja jonorakenteet

osaamistavoitteet

  • Tunnet Java-kielen yleisimmät valmiit tietorakenteet: Set, Queue, Deque ja niiden toteutukset HashSet ja ArrayDeque.
  • Osaat käyttää ym. tietorakenteita.
  • Ymmärrät ym. tietorakenteiden keskeisimmät operaatiot ja niiden aikakompleksisuudet.
  • Ymmärrät, miksi oliot tarvitsevat hashCode-metodin.

Joukkorakenteet

Joukot ovat kokoelmia, joiden kaikki alkiot ovat uniikkeja. Jos joukkoon yritetään lisätä jonkin alkion duplikaatti, sen tila ei muutu millään tavalla. Tämä ominaisuus tekee joukoista hyödyllisiä tietorakenteita, kun haluamme varmistaa, että samaa tietoa ei löydy tietorakenteesta montaa kertaa.

Voisimme käyttää joukkoa esimerkiksi sähköpostilistan vastaanottajien sähköpostiosoitteiden tallentamiseen. Tämä takaa, että yksi sähköpostiosoite voi esiintyä vastaanottajien listassa korkeintaan yhden kerran, jolloin yhteen sähköpostiosoitteeseen ei lähetetä koskaan saman viestin kopioita. Tietorakennetta käytetään usein myös matemaattisten joukkojen ja niiden operaatioiden mallintamiseen.

Set

Javan kokoelmakehys sisältää rajapinnan Set, joka määrittelee perussäännöt kaikille joukkorakenteille. Set periytyy Collection-rajapinnasta, joten kaikki joukot toteuttavat myös sen lupaamat toiminnallisuudet. Voimme tämän vuoksi esimerkiksi iteroida joukkoja helposti listojen tavoin.

Javassa yleisimmin käytetty joukon toteutus on HashSet, joka perustuu nimensä mukaisesti hajautustauluun. Sisäisesti HashSet-käyttää alkioiden tallentamiseen HashMap-tietorakennetta, johon se tallentaa joukon alkiot avaimina. Hajautustaulutoteutuksen vuoksi erityisesti alkion lisääminen, poistaminen ja hakeminen tietorakenteesta onnistuvat nopeasti vakioajassa. Hajautustaulu on kuitenkin tavallista taulukkoa monimutkaisempi tietorakenne, joten tietorakenteen läpikäyminen on taulukkoon perustuvaa ArrayList-luokkaa hieman hitaampaa, mikä voi olla huomattavissa alkioiden määrän kasvaessa hyvin suureksi.

Collection ja Map -rajapintojen tapaan Set-rajapinta tai sen toteuttava luokka HashSet eivät takaa alkioiden olevan missään tietyssä järjestyksessä, mutta tätä tarkoitusta varten on olemassa myös järjestyksen säilyttäviä joukkorakenteita.

Katsotaan nyt, kuinka HashSet-tietorakenteen käyttö onnistuu Set-rajapinnan kautta.

Alkion lisääminen ja poistaminen

Alkioiden lisääminen ja poistaminen HashSet-rakenteeseen onnistuu kokoelmista tuttuun tapaan. Lisääminen tapahtuu add-metodilla ja poistaminen taas tehdään remove-metodilla. Molemmat metodit palauttavat totuusarvon true, jos joukko muuttui operaation seurauksena, eli jos operaatio oikeasti lisää tai poistaa alkion.

Alkion lisääminen ja poistaminen tapahtuvat HashSet-tietorakenteessa hajautustaulun vuoksi keskimäärin vakioajassa O(1).

void main() {
Set<String> nimet = new HashSet<>();

// Alkioiden lisääminen.
nimet.add("Matti");
nimet.add("Maija");

// Lisääminen palauttaa false, koska alkio on jo joukossa.
IO.println(nimet.add("Matti"));

// Alkion poistaminen.
IO.println(nimet.remove("Maija"));
IO.println(nimet.remove("Maija")); // Palauttaa false, koska alkiota ei ole.
}

Alkion etsiminen

Tietyn alkion löytäminen joukosta onnistuu helposti contains-metodilla, joka palauttaa totuusarvon sen perusteella löytyykö alkio joukosta.

HashSet-tietorakenteen ei hajautustaulun vuoksi tarvitse käydä koko tietorakennetta läpi, joten etsiminen on myös keskimäärin vakioaikainen operaatio.

void main() {
Set<String> nimet = new HashSet<>();
nimet.add("Matti");
nimet.add("Maija");

if (nimet.contains("Matti")) {
    IO.println("Matti löytyi joukosta!");
}
}

Alkioiden määrä ja läpikäyminen

Set periytyy Collection-rajapinnasta, joten voimme käyttää kokoelmista tuttuja metodeja size ja isEmpty alkioiden lukumäärän tarkastelemiseen. Iterable-rajapinnan ansiosta voimme myös iteroida joukkoja helpommin for each -silmukan avulla.

Kaikkien alkioiden läpikäynnin aikavaativuus on myös joukoilla O(n)

void main() {
Set<String> nimet = new HashSet<>();
nimet.add("Matti");
nimet.add("Maija");

for (String nimi : nimet) {
    IO.println(nimi);
}

IO.println();
IO.println("Joukossa on " + nimet.size() + " alkiota.");
IO.println("Onko tyhjä: " + nimet.isEmpty());
}

Alkioiden järjestys

HashSet ei säilytä alkioiden järjestystä millään tavalla, mutta hakurakenteiden tapaan myös joukoille on olemassa tietorakenteita, jotka säilyttävät joko alkioiden lisäysjärjestyksen tai niiden luonnollisen järjestyksen.

LinkedHashSet periytyy HashSet-luokasta ja ylläpitää LinkedHashMap-luokan tapaan sisäistä linkitettyä listaa, joka säilyttää alkiot niiden lisäysjärjestyksessä. LinkedHashSet-rakenteen perusoperaatiot ovat edelleen keskimääräiseltä aikavaativuudeltaan O(1), mutta tietorakenne vaatii hieman enemmän muistia linkitetyn listan ylläpitämisen vuoksi.

void main() {
LinkedHashSet<String> linked = new LinkedHashSet<>();
linked.add("Joni Virtanen");
linked.add("Maija Meikäläinen");
linked.add("Matti Korhonen");

// LinkedHashSet säilyttää alkioiden lisäysjärjestyksen.
IO.println(linked);
}

TreeSet toteuttaa SortedSet- ja NavigableSet-rajapinnat ja säilyttää alkiot vastaavasti TreeMap-luokan tapaan niiden luonnollisessa järjestyksessä. Se ei periydy HashSet-tietorakenteesta, sillä se käyttää alkioiden tallentamiseen hajautustaulun sijaan tasapainotettua puuta, jonka perusoperaatiota ovat hieman hitaampia. Alkioiden lisääminen, poistaminen ja hakeminen ovat aikavaativuudeltaan keskimäärin O(log n).

void main() {
TreeSet<String> tree = new TreeSet<>();
tree.add("Joni Virtanen");
tree.add("Maija Meikäläinen");
tree.add("Matti Korhonen");

// TreeSet lajittelee alkiot automaattisesti luonnolliseen järjestykseen.
IO.println(tree);
}

SortedSet ja NavigableSet -rajapinnat tarjoavat omat hyödylliset metodinsa järjestetyn joukon hallinnoimiseen. Toiminnot ovat hyvin samankaltaisia hakurakenteiden SortedMap ja NavigableMap -rajapintojen kanssa.

void main() {
NavigableSet<Integer> tree = new TreeSet<>();
tree.add(1);
tree.add(2);
tree.add(5);
tree.add(4);
tree.add(3);

// Alkiot ovat suuruusjärjestyksessä.
IO.println(tree);

// Tulostetaan pienin ja suurin alkio.
IO.println("Pienin alkio: " + tree.first()); // 1
IO.println("Suurin alkio: " + tree.last());  // 5

// Tulostetaan annettua lukua lähin pienemi ja suurempi alkio.
IO.println(tree.lower(3)); // 2
IO.println(tree.higher(3)); // 4

// Palauttaa koko tietorakenteen käänteisessä järjestyksessä.
IO.println(tree.descendingSet()); 
}

NavigableSet mahdollistaa myös alijoukkojen muodostamisen subSet-metodilla.

void main() {
NavigableSet<Integer> tree = new TreeSet<>();
tree.add(1);
tree.add(2);
tree.add(5);
tree.add(4);
tree.add(3);

// Muodostetaan uusi joukko alkioista, jotka ovat 3-5 välillä.
// Parametrien true-arvot kertovat, että myös 3 ja 5 otetaan mukaan joukkoon.
Set<Integer> alijoukko = tree.subSet(3, true, 5, true);
IO.println(alijoukko);
}

Jonot ja pinot

Jonot ja pinot ovat listojen kaltaisia lineaarisia tietorakenteita. Nämä tietorakenteet ovat hyödyllisiä tilanteissa, joissa haluamme tarkastella erityisesti tietorakenteen alussa ja lopussa sijaitsevia alkioita. Jonot ja pinot ovat käytännössä listoja, joissa alkioiden lisäys-, poisto- ja hakuoperaatiot rajoittuvat tietorakenteen päätyihin, mutta näiden tietorakenteiden käsitteet ovat erittäin hyödyllisiä tietojenkäsittelyssä ja niitä käytetäänkin tietojärjestelmissä hyvin usein.

Jonon lisäksi on myös olemassa kaksipäinen jono, joka yhdistää jonon ja pinon toiminnallisuudet ja sallii siten lisäys- ja poisto-operaatiot tietorakenteen molempiin päätyihin. Javassa kaksipäinen jono sisältää myös monia muita hyödyllisiä toiminnallisuuksia ja sitä itse asiassa käytetään Javassa sekä jonon että pinon toteuttamiseen.

Pino

Pino eli stack on tietorakenne, joka toimii viimeisenä sisään, ensimmäisenä ulos (engl. last in, first out) -periaatteen mukaisesti. Alkioita voidaan lisätä vain pinon alkuun ja poistaminen kohdistuu aina ensimmäiseen alkioon.

Esimerkiksi tekstinkäsittelyohjelman kumoa-toiminto toimii kuin pino. Tekstiin tehdyt muutokset lisätään aina pinon päällimmäiseksi ja kumoamistoiminnon seurauksena ohjelma ottaa viimeisimmän muutoksen pinon päältä ja kumoaa sen.

huomautus

Poikkeuksellisesti Java ei tarjoa Stack-rajapintaa pinon toteuttamista varten. Javan historiasta johtuen Stack on luokka, jonka käyttöä ei enää suositella. Pinon toteuttamiseen käytetään nykyään kaksipäistä jonoa, eli Deque-rajapinnan toteuttavaa ArrayDeque-luokkaa.

Pinon perusoperaatiot ovat push, pop ja peek. Javan Deque sisältää nämä metodit, joten käytämme niitä.

void main() {
Deque<String> pino = new ArrayDeque<>();

// Lisää alkioita pinoon. Ei palauta mitään.
pino.push("A");
pino.push("B"); 
pino.push("C");

// Huomaa, että viimeisimpänä lisätty alkio tulostuu ensimmäisenä.
IO.println(pino);

// Palauttaa alkion "C", mutta ei poista sitä pinosta.
// Palauttaa null, jos pinossa ei ole yhtään alkiota.
IO.println(pino.peek());

// Palauttaa alkion "C" ja poistaa sen pinosta.
// Palauttaa null, jos pinossa ei ole yhtään alkiota.
IO.println(pino.pop());

IO.println(pino);
}

Jono

Jono eli queue toimii ensimmäisenä sisään, ensimmäisenä ulos (engl. first in, first out) -periaatteen mukaisesti. Ensimmäisenä lisätty alkio otetaan myös ensimmäiseksi pois.

Jonot toimivat kuten nimen perusteella voisi odottaa. Verkkoreitittimet ovat yksi hyvä esimerkki jonorakenteen käytöstä; ne tallentavat lähetettävät paketit jonoon, josta ne lähetetään eteenpäin samassa järjestyksessä, kuin ne lisättiin.

Javan Queue-rajapinta määrittelee jonorakenteiden yleiset toiminnallisuudet. Se laajentaa Collection-rajapintaa ja sisältää metodit, joilla alkioita voidaan lisätä jonon loppuun ja poistaa tai tarkastella sen alusta.

Queue-rajapinta sisältää näistä kolmesta perustoiminnosta kahdet eri versiot, jotka käsittelevät virheet eri tavalla. Metodit add, remove ja element aiheuttavat virhetilanteissa ohjelman pysäyttävän poikkeuksen, kun taas offer, poll ja peek palauttavat näissä tilanteissa eri arvon. Käsittelemme poikkeuksia myöhemmin tällä kurssilla, joten tässä vaiheessa voimme keskittyä käyttämään offer, poll ja peek -metodeja, jotka toimivat ihan yhtä hyvin.

ArrayDeque-luokka toteuttaa Queue-rajapinnan, joten se sisältää kaikki sen tarvitsemat metodit. Käytämme siis sitä toteuttamaan jonon.

void main() {
Queue<String> jono = new ArrayDeque<>();

// Lisätään alkioita jonon loppuun. 
// 'offer' palauttaa true, jos alkion lisääminen onnistuu ja false, jos ei.
jono.offer("A");
jono.offer("B");

IO.println(jono); // [A, B]

// Katsotaan ensimmäistä alkiota poistamatta sitä jonosta.
// 'peek' palauttaa null, jos jonossa ei ole alkioita.
IO.println(jono.peek()); // "A"

// Poistetaan alkioita jonon alusta. Palauttaa alkion.
// 'poll' palauttaa null, jos jonossa ei ole alkioita.
IO.println(jono.poll()); // "A"
IO.println(jono.poll()); // "B"
IO.println(jono.poll()); // null
}

Kaksipäinen jono

Kaksipäinen jono tunnetaan nimellä deque, joka on lyhenne tietorakenteen englanninkielisestä nimestä double ended queue. Voisimme käyttää tätä tietorakennetta mallintamaan esimerkiksi jonoa, jonka alkuun voisi joissain tapauksissa lisätä alkion esimerkiksi sen tärkeyden mukaan.

Deque-rajapinta määrittelee kaikki kaksipäisten jonojen tarvitsemat toiminnallisuudet. Käytännössä tämä tarkoittaa sitä, että se lupaa kaikki jonon ja pinon tarvitsemat metodit sekä muutamia muita hyödyllisiä toiminnallisuuksia, kuten mahdollisuuden alkioiden läpikäymiseen käänteisessä järjestyksessä. Huomaa, että Deque sisältää tässä kohdassa mainittujen metodien lisäksi myös pinon ja jonon yhteydessä mainitut metodit.

Deque-rajapinnan yleisimmin käytetty toteutusluokka on ArrayDeque, joka nimensä mukaisesti käyttää taulukkoa sisäisenä tietorakenteenaan. Deque-toteuttaa Collection-rajapinnan, joten kaikki kokoelmista tutut metodit, kuten size, isEmpty ja contains ovat myös käytettävissä.

Alkion lisääminen ja poistaminen

Myös Deque määrittelee kaksi eri tapaa lisätä ja poistaa alkioita: metodit, jotka heittävät poikkeuksen epäonnistuessaan, ja metodit, jotka palauttavat näissä tilanteissa null tai false.

Alkioita voidaan lisätä alkuun metodeilla addFirst ja offerFirst tai loppuun metodeilla addLast ja offerLast. Käytetään toistaiseksi offerFirst ja offerLast-metodeja, sillä ne eivät aiheuta poikkeuksia epäonnistuessaan.

void main() {
Deque<Integer> luvut = new ArrayDeque<>();

// 'offerFirst' lisää alkion jonon alkuun, 'offerLast' lisää alkion jonon loppuun.
// Molemmat palauttavat false, jos lisääminen epäonnistuu.
luvut.offerFirst(2);
IO.println(luvut); // [2]
luvut.offerLast(3);
IO.println(luvut); // [2, 3]
luvut.offerFirst(1);
IO.println(luvut); // [1, 2, 3]
}

Poistaminen onnistuu vastaavasti kummastakin päästä. Metodit removeFirst ja removeLast poistavat alkion tai heittävät poikkeuksen, jos jono on tyhjä. Sen sijaan pollFirst ja pollLast palauttavat null-arvon, jos poistettavaa ei löydy, joten voimme toistaiseksi käyttää ensisijaisesti näitä.

Lisäksi voimme kurkata alkioita poistamatta niitä käyttämällä peekFirst tai peekLast -metodeja.

void main() {
Deque<Integer> luvut = new ArrayDeque<>();
luvut.offerFirst(3);
luvut.offerFirst(2);
luvut.offerFirst(1);

IO.println(luvut); // Tulostaa [1, 2, 3]

// Kurkistetaan ensin ensimmäistä ja viimeistä arvoa.
IO.println(luvut.peekFirst()); // Tulostaa 1
IO.println(luvut.peekLast()); // Tulostaa 3

// Poistetaan ensimmäinen ja viimeinen arvo. Poistaminen palauttaa alkion, 
// jos poistaminen onnistuu. Muussa tapauksessa null.
luvut.pollFirst();
luvut.pollLast();

IO.println(luvut); // Tulostaa [2]
}

Sekä lisääminen että poistaminen molemmista päistä tapahtuu vakioajassa O(1), sillä ArrayDeque on toteutettu kehämäisenä taulukkona, jossa pään ja hännän sijaintia seurataan indekseillä. Taulukon alkioita ei siis tarvitse lisäyksen tai poiston yhteydessä käydä läpi tai siirrellä.

ArrayDeque tarjoaa myös Collection-rajapinnan metodin remove, jolla voidaan poistaa parametrina annettu alkio mistä tahansa kohdasta. Se toimii samalla tavalla kuin listasta poistaminen; käymällä koko listan läpi alkiota etsiessään.

Alkioiden läpikäyminen

ArrayDeque toteuttaa Iterable-rajapinnan, joten voimme iteroida sen for each -silmukalla. Lisäksi sen metodi descendingIterator palauttaa iteraattorin, jonka avulla tietorakenne voidaan käydä läpi käänteisessä järjestyksessä. Alkioiden läpikäynnin aikavaativuus on O(n), mikä on sama kuin tavallisella listalla.

void main() {
Deque<Integer> luvut = new ArrayDeque<>();
luvut.offerFirst(3);
luvut.offerFirst(2);
luvut.offerFirst(1);

// Tavallinen läpikäynti.
for (Integer luku : luvut) {
    IO.println(luku);
}

// Käänteinen läpikäynti.
Iterator<Integer> it = luvut.descendingIterator();
while (it.hasNext()) {
    IO.println(it.next());
}
}

Prioriteettijono

Prioriteettijono eli priority queue on jonon erikoistapaus, jonka toteutus on Javassa PriorityQueue-luokka. Tavallinen jono säilyttää alkiot niiden lisäysjärjestyksessä, mutta prioriteettijono pitää huolen, että luonnollisessa järjestyksessä pienin alkio tulee aina ensimmäisenä, kun pyydämme siltä ensimmäistä alkiota.

void main() {
PriorityQueue<Integer> luvut = new PriorityQueue<>();
luvut.offer(2);
luvut.offer(3);
luvut.offer(1);
luvut.offer(5);
luvut.offer(4);

// Huomaa, että tulostaessa tietorakenteen kaikki alkiot ne
// eivät ole luonnollisessa järjestyksessä.
IO.println(luvut);

// Prioriteettijono antaa aina "tärkeimmän" eli pienimmän alkion, kun
// piidämme siltä seuraavaa alkiota.
IO.println(luvut.poll()); // 1
IO.println(luvut.poll()); // 2
IO.println(luvut.poll()); // 3
IO.println(luvut.poll()); // 4
IO.println(luvut.poll()); // 5
}

PriorityQueue-luokan operaatioiden aikavaativuus poikkeaa perusjonoista, koska rakenne perustuu sisäisesti kekoon (engl. heap). Alkion lisäämisen ja poistamisen aikavaativuus on O(log n). Pienimmän alkion pyytäminen on kuitenkin erittäin nopeaa ja tapahtuu vakioajassa O(1).

Tehtävät

Tehtävä 5.8: Joukot1 p.

Saat tehtävässä kaksi duplikaatteja sisältävää listaa luvuista.

Muodosta näistä luvuista seuraavat joukot:

  • Yhdiste: Sisältää kaikki luvut, jotka kuuluvat joko ensimmäiseen joukkoon, toiseen joukkoon tai molempiin.

  • Leikkaus: Sisältää vain ne luvut, jotka löytyvät molemmista joukoista samanaikaisesti.

  • Erotus: Sisältää ne luvut, jotka kuuluvat ensimmäiseen joukkoon, mutta eivät kuulu toiseen joukkoon.

  • Symmetrinen erotus: Sisältää ne luvut, jotka kuuluvat vain jompaankumpaan joukkoon, mutta eivät molempiin.

Joukot eivät saa sisältää duplikaatteja.

Joukkojen muodostaminen onnistuu Set-rajapinnan määrittelemien metodien avulla niin, että tietorakenteita ei tarvitse selata läpi silmukoiden avulla.

Valmis pääohjelma sisältää esimerkit, mitä näiden joukkojen pitäisi sisältää.

Tee tehtävä TIMissä
Tehtävä 5.9: Tehtävälista1 p.

Tee luokka Tehtavalista, joka toimii todo-listana. Tehtävät voivat olla yksinkertaisia merkkijonoja.

Tehtävälista pitää kirjaa sekä tekemättömistä että tehdyistä tehtävistä. Tehtävät suoritetaan siinä järjestyksessä, missä ne ovat tehtävälistaan lisätty. Poikkeustapauksia varten tulee olla mahdollista lisätä kiireellisiä tehtäviä heti tehtävälistan alkuun.

Ohjelmassa pitää olla myös mahdollisuus kumota tehtävän merkitseminen suoritetuksi siltä varalta, että tehtävän merkitsee vahingossa tehdyksi liian aikaisin. Kumoaminen palauttaa suoritetuksi merkityn tehtävän takaisin tehtävälistan alkuun.

Lisää luokkaan seuraavat metodit:

  • lisaaTehtava, joka lisää tehtävän tehtävälistaan. Uusi tehtävä menee tehtävälistan viimeiseksi.

  • lisaaTarkeaTehtava, joka lisää kiireellisen tehtävän tehtävälistaan. Kiireellinen tehtävä menee aina tehtävälistan ensimmäiseksi.

  • merkitseTehdyksi, joka merkitsee seuraavana tehtävälistalla olevan tehtävän suoritetuksi.

  • kumoaTehty, joka palauttaa viimeksi tehdyn tehtävän suoritettujen tehtävienlistalta takaisin tehtävälistan alkuun.

  • tulosta, joka tulostaa tekemättömät ja tehdyt tehtävät omina listoinaan. Tulostusmuoto ei ole hirveän tärkeä, kunhan tulosteesta näkee selvästi eri listat.

Voit testata luokan toimintaa valmiin pääohjelman avulla.

Tee tehtävä TIMissä
Bonus: Tehtävä 5.10: Sulut1 p.

Kirjoita aliohjelma, joka tarkistaa merkkijonon sisältämien sulkujen oikeellisuuden. Aliohjelman tulee tunnistaa, sulkeutuvatko kaikki sulut oikeassa järjestyksessä ja onko jokaisella alkavalla sululla vastaava lopettava pari.

Tuetut sulkutyypit ovat kaarisulut ( ), hakasulut [ ] ja aaltosulut { }.

Toimintalogiikka ja säännöt:

  • Sisäkkäisyys: Sulut voivat olla sisäkkäin (esim. ([])), mutta ne eivät saa mennä ristiin. Esimerkiksi ([)] on virheellinen, koska sulut menevät ristiin.
  • Järjestys: Sulun on aina alettava ennen kuin se sulkeutuu.
  • Muut merkit kuten kirjaimet tai numerot tulee jättää huomiotta.
  • Tyhjä merkkijono katsotaan oikeelliseksi, ja siinä on 0 paria.

Paluuarvo:

  • Jos sulutus on kunnossa, palauta löydettyjen sulkuparien lukumäärä (kokonaisluku).
  • Jos sulutus on virheellinen (yksikin pari puuttuu tai järjestys on väärä), palauta luku -1.

Esimerkit:

MerkkijonoTulosSelite
""0Tyhjä syöte on validi, 0 paria.
"()"1Yksi ehjä pari.
"(())"2Kaksi sisäkkäistä paria.
"([{}])"3Kolme sisäkkäistä paria.
"()[]{}"3Kolme vierekkäistä paria.
"a(b)c"1Kirjaimet sivuutetaan, yksi pari.
"("-1Sulkeva pari puuttuu.
"(()"-1Yksi sulkeva pari puuttuu.
"()}"-1Ylimääräinen sulkeva sulku.
")("-1Väärä järjestys (alkava sulku puuttuu alussa).
"([)]"-1Sulut menevät ristiin (virheellinen sisäkkäisyys).

Aliohjelma tulee toteuttaa niin, että jos sulkuihin lisättäisin uusia sulkutyyppejä, niin varsinaisessa logiikassa ei tarvitsisi tehdä muutoksia. Esimerkiksi merkkijonon a<(b)>c käsittelemiseen tulisi vain lisätä tuki kulmasuluille < >, mutta muuten logiikka pysyisi samana (lue: ei ylimääräisiä if-lauseita).

Tee tehtävä TIMissä

Rekursio

osaamistavoitteet

  • Ymmärrät miten rekursio toimii
  • Ymmärrät, miten rekursiota voidaan mallintaa pinon avulla

Rekursio tarkoittaa ongelman määrittelemistä itsensä avulla niin, että ongelma muodostuu pienemmistä osista, jotka ovat rakenteeltaan samanlaisia kuin alkuperäinen ongelma. Rekursiivinen ratkaisu on perusteltu, kun ongelmalla on selkeä perustapaus ja kun rekursiivinen askel pienentää ongelmaa siten, että perustapaukseen päädytään varmasti. Rekursio on luonteva tapa toteuttaa niin kutsuttua hajota ja hallitse -periaatetta: ongelma jaetaan pienempiin osiin, ratkaistaan pienet ongelmat ja yhdistetään tulokset.

Rekursiivinen tietorakenne on rakenne, jonka määritelmä viittaa itseensä. Eräs esimerkki rekursiivisesta tietorakenteesta on linkitetty lista, jossa jokainen solmu sisältää viitteen seuraavaan solmuun tai null-arvon, joka merkitsee listan loppua. Linkitettyihin listoihin törmää käytännössä esimerkiksi soittolistan toistossa (seuraava kappale), sovellusten "takaisin/eteenpäin"- historiassa sekä käyttöjärjestelmien ja ohjelmistokirjastojen (eli valmiiden ohjelmakokoelmien) sisäisissä tietorakenteissa.

Rekursiivisten algoritmien soveltaminen on erityisen luontevaa, kun käsitellään rekursiivisia tietorakenteita. Seuraava Java-esimerkki näyttää kokonaislukuja sisältävän linkitetyn listan rakenteen ja listan pituuden laskemisen rekursiivisesti.

class Solmu {
    int arvo;
    Solmu seuraava;

    Solmu(int arvo) {
        this.arvo = arvo;
    }
}

Tällä tavalla määritellyn listan pituuden laskemiseksi voidaan käyttää rekursiota:

int pituus(Solmu solmu) {
    if (solmu == null) return 0;           // perustapaus
    return 1 + pituus(solmu.seuraava);     // rekursiivinen tapaus
}

Listan rakentelu "käsin" näyttäisi seuraavalta.

class Solmu {
    int arvo;
    Solmu seuraava;

    Solmu(int arvo) {
        this.arvo = arvo;
    }
}
 
 int pituus(Solmu solmu) {
     if (solmu == null) return 0;           // perustapaus
     return 1 + pituus(solmu.seuraava);     // rekursiivinen tapaus
 }
void main() {
Solmu eka = new Solmu(10);
eka.seuraava = new Solmu(20);
eka.seuraava.seuraava = new Solmu(30);

int n = pituus(eka); // n == 3
IO.println(n);
}

Tämä on kuitenkin hieman kömpelöä. Tyypillisessä käytössä listalla olisi oma luokka, joka kapseloi alku-viitteen ja lisäämisen:

class Lista {
    Solmu alku;

    void lisaaLoppuun(int arvo) {
        if (alku == null) {
            alku = new Solmu(arvo);
            return;
        }

        Solmu nykyinen = alku;
        while (nykyinen.seuraava != null) {
            nykyinen = nykyinen.seuraava;
        }
        nykyinen.seuraava = new Solmu(arvo);
    }

    int pituus() {
        return pituus(alku);
    }
}

Nyt listan käyttö näyttäisi seuraavalta:

Lista lista = new Lista();
lista.lisaaLoppuun(10);
lista.lisaaLoppuun(20);
lista.lisaaLoppuun(30);

int n = lista.pituus(); // n == 3

Rekursio käytännössä

Listat ovat lineaarisia: jokaisella solmulla on korkeintaan yksi seuraava solmu. Monissa ongelmissa rakenne kuitenkin haarautuu. Puu on tällainen haarautuva tietorakenne: se koostuu solmuista ja niiden lapsisolmuista, ja sillä on yksi juurisolmu. Puu ei sisällä syklejä, mikä tarkoittaa, että kun etenet puussa alaspäin lapsisolmuihin, et voi koskaan palata samaan solmuun pelkästään lapsiviitteitä seuraamalla.

Yleinen erikoistapaus on binääripuu, jossa jokaisella solmulla on korkeintaan kaksi lasta: vasen ja oikea. Rekursio sopii puiden käsittelyyn, koska puu koostuu alipuista: jokainen lapsi on itsekin puu.

Esimerkki binääripuusta:

graph TD
A((5))
A --> B((8))
A --> C((3))
B --> D((7))
B --> E((1))
C --> F((7))
C --> G((9))

Seuraava esimerkki laskee binääripuun korkeuden rekursion avulla. Ajatus seuraa suoraan korkeuden määritelmästä: tyhjän puun korkeus on 0, ja ei-tyhjän puun korkeus on 1 + suurimman alipuun korkeus. Jokainen polku juuresta lehteen kulkee ensin vasempaan tai oikeaan alipuuhun, joten pisin polku saadaan valitsemalla näistä kahdesta suurempi. Rekursio pysähtyy, kun alipuuta ei ole (juuri == null), jolloin perustapaus palauttaa 0.

public class Solmu {
    // Solmun tallettama arvo.
    int arvo;
    // Viite vasempaan lapseen (null jos ei ole).
    Solmu vasen;
    // Viite oikeaan lapseen (null jos ei ole).
    Solmu oikea;

    Solmu(int arvo) {
        this.arvo = arvo;
    }
}

public static int korkeus(Solmu juuri) {
    if (juuri == null) {
        return 0;
    }

    return 1 + Math.max(korkeus(juuri.vasen), korkeus(juuri.oikea));
}

void main() {
    // Muodostetaan binääripuu
    Solmu juuri = new Solmu(1);
    juuri.vasen = new Solmu(2);
    juuri.oikea = new Solmu(3);
    juuri.vasen.vasen = new Solmu(4);

    IO.println(korkeus(juuri));
}

Sivuhuomautuksena mainittakoon, että koodissamme mikään ei nyt estä meitä luomasta syklisiä rakenteita, joissa solmut viittaavat toisiinsa muodostaen silmukan. Tällöin rekursio ei pysähtyisi koskaan, vaan aiheuttaisi ohjelman kaatumisen (pinon ylivuodon). Vaikka emme tässä harjoituksessa toteuta syklisyyden tarkistusta, todellisessa ohjelmassa on syytä varmistaa, että syklisiä rakenteita ei pääse syntymään, jos algoritmi olettaa puun kaltaista rakennetta.

Kun korkeus-metodia kutsutaan juurisolmulle, laskenta etenee luonnollisesti alaspäin puussa. Metodi kutsuu itseään vasemmalle ja oikealle alipuulle ja jatkaa näin, kunnes vastaan tulee null, eli tyhjä puu. Tämä on rekursion perustapaus: tyhjän puun korkeudeksi määritellään 0.

Kun perustapaukseen on päästy, laskenta alkaa palautua takaisin päin kutsupinoa pitkin. Jokainen solmu saa alipuittensa korkeudet ja määrittää oman korkeutensa niiden perusteella arvona 1 + suuremman alipuun korkeus. Näin puun korkeus rakentuu askel askeleelta lehdistä kohti juurta, pelkkien palautusarvojen avulla.

Rekursion mallintaminen pinon avulla

Rekursiivinen ratkaisu näyttää usein hämmentävän yksinkertaiselta, jopa "maagiselta". Miten tietokone tietää, mihin kohtaan suoritusta sen pitää palata, kun funktio on kutsunut itseään kymmeniä kertoja sisäkkäin? Miten edellisen kutsun muuttujat (kuten solmu, vasen tai oikea) pysyvät tallessa?

Tietokone ei tee taikatemppuja, vaan se käyttää kulissien takana pinoa. Joka kerta kun funktio kutsuu itseään, tietokone:

  • Keskeyttää nykyisen suorituksen.
  • Luo uuden pinokehyksen (stack frame), johon se tallentaa ainakin parametrit, paikalliset muuttujat ja tiedon siitä, mihin kohtaan suoritusta palataan, kun kutsuttu funktio palauttaa arvon.
  • Laittaa tämän kehyksen muistissa olevan kutsupinon päällimmäiseksi.

Kun rekursiivinen kutsu valmistuu, eli perustapaus saavutetaan, päällimmäinen kehys "popataan" pois pinosta, ja suoritus jatkuu siitä, mihin edellisessä kehyksessä jäätiin. Rekursio on siis pohjimmiltaan vain pinon täyttämistä ja tyhjentämistä.

Tämä on mekanismi, joka tekee rekursiosta mahdollisen: jokainen rekursiivinen kutsu keskeyttää nykyisen laskennan, tallentaa sen tilan pinoon ja siirtää ohjauksen seuraavalle, pienemmälle aliongelmalle.

Seuraava pinokuvio havainnollistaa pinoa rekursiivisessa summassa, joka laskee lukujen 1 + 2 + ... + n summan:

int summa(int n) {
    if (n == 0) return 0;   // perustapaus
    return n + summa(n - 1);
}

Oletetaan, että kutsutaan summa(3). Jokainen kehys sisältää parametrin n ja keskeneräisen laskun "n + summa(n - 1)". Toisin sanoen kehys odottaa alikutsun tulosta, jotta se voi lisätä oman lukunsa.

VaihePino (alhaalta → ylös)Mitä tapahtuu
1summa(3)Odottaa summa(2)
2summa(3), summa(2)Odottaa summa(1)
3summa(3), summa(2), summa(1)Odottaa summa(0)
4summa(3), summa(2), summa(1), summa(0)Perustapaus: palauttaa 0
5summa(3), summa(2), summa(1)Paluu: summa(1) = 1 + 0 = 1
6summa(3), summa(2)Paluu: summa(2) = 2 + 1 = 3
7summa(3)Paluu: summa(3) = 3 + 3 = 6
8(tyhjä)Laskenta valmis, tulos 6

Kutsuvaiheessa pino kasvaa, koska jokainen kehys odottaa alikutsun tulosta (esimerkiksi n + summa(n - 1)). Perustapauksen jälkeen laskenta etenee takaisin päin, ja jokainen kehys täydentää omaa laskuaan alikutsun tuloksella. Tämä on se "muisti", jonka ansiosta rekursio toimii.

Voisimmeko tehdä saman asian itse ilman rekursiota? Kyllä voimme. Voimme "leikkiä tietokonetta" ja hallita pinoa manuaalisesti. Tämä ei ole vain akateeminen harjoitus, vaan varsin hyödyllinen taito, sillä se auttaa ymmärtämään syvällisesti, mitä koodissa tapahtuu. Lisäksi on tilanteita, kuten erittäin syvät puut, joissa automaattinen kutsupino saattaa täyttyä (pinon ylivuoto), mutta oma manuaalinen pinomme toimii yhä.

Lähdetään liikkelle klassisesta yksinkertaisesta esimerkistä, eli kertomasta. Alla muistutus rekursiivisesta versiosta.

int kertoma(int n) {
    if (n <= 1) return 1;
    return n * kertoma(n - 1);
}

Iteratiivinen versio voidaan kirjoittaa itse ylläpitämämme pinon avulla niin, että ensin talletetaan "odottavat kertolaskut" ja sitten puretaan ne. Nyky-Javassa ArrayDeque on suositeltu pino-tietorakenne.

int kertomaIter(int n) {
    Deque<Integer> pino = new ArrayDeque<>(); // eksplisiittinen pino odottaville kertoimille
    while (n > 1) { // kerätään kaikki kertoimet n, n-1, ..., 2 pinoon
        // Kutsuvaihe: talletetaan odottava kerroin.
        pino.push(n);
        n--;
    }

    int tulos = 1;
    while (!pino.isEmpty()) { // puretaan pino ja muodostetaan lopputulos
        // Paluuvaihe: puretaan kertoimet ja kerrotaan tulokseen.
        tulos *= pino.pop();
    }

    return tulos;
}

Tässä siis "pinokehys" on itse rakentamamme rakenne, joka kertoo mitä on vielä tekemättä. Yksinkertaisissa tapauksissa, kuten kertoma-funktiossa, pinokehys voi olla pelkkä arvo, joka on vain tieto siitä, mitä lukuja on vielä kerrottavana.

Ratkaistaan myös puun korkeusongelma iteratiivisesti. Jotta onnistumme tässä, tarvitsemme kaksi asiaa:

  • while-silmukan, joka pyörii niin kauan kuin töitä on jäljellä.
  • pino-tietorakenteen, johon talletamme solmut, joita emme ole vielä käsitelleet – aivan kuten rekursio teki automaattisesti.

Korkeuden laskeminen tapahtuu niin, että käymme puun läpi tason kerrallaan (ns. leveys ensin, engl. breadth-first) ja laskemme, kuinka monta tasoa puussa on. Tason läpikäynti onnistuu hyvin jonotietorakenteella, joka pitää kirjaa siitä, mitä solmuja on vielä käsittelemättä kullakin tasolla. Jokaisella tasolla käydään läpi kaikki solmut, ja niiden lapset lisätään jonoon seuraavaa tasoa varten.

public class Solmu {
    int arvo;
    Solmu vasen;
    Solmu oikea;

    Solmu(int arvo) {
        this.arvo = arvo;
    }
}

public static int puunKorkeusIter(Solmu juuri) {
    if (juuri == null) return 0;

    // Käytetään jonoa tason läpikäyntiin. 
    // Jokaisella tasolla käydään läpi kaikki solmut,
    // ja niiden lapset lisätään jonoon seuraavaa tasoa varten.
    Queue<Solmu> odottavatSolmut = new ArrayDeque<>();
    odottavatSolmut.add(juuri);
    int korkeus = 0;

    while (!odottavatSolmut.isEmpty()) {
        int tasonSolmujenLkm = odottavatSolmut.size();
        korkeus++;

        for (int i = 0; i < tasonSolmujenLkm; i++) {
            // otetaan jonon ensimmäinen solmu käsittelyyn
            Solmu nykyinen = odottavatSolmut.poll(); 
            // Jos solmulla on vasen lapsi, lisätään se jonoon seuraavalle tasolle
            if (nykyinen.vasen != null) odottavatSolmut.add(nykyinen.vasen); 
            // Vastaavasti oikea lapsi, jos sellainen on
            if (nykyinen.oikea != null) odottavatSolmut.add(nykyinen.oikea); 
        }
    }

    return korkeus;
}

void main() {
    //Muodostetaan binääripuu
    Solmu juuri = new Solmu(1);
    juuri.vasen = new Solmu(2);
    juuri.oikea = new Solmu(3);
    juuri.vasen.vasen = new Solmu(4);

    IO.println(puunKorkeusIter(juuri));
}
Tehtävä 5.11: Summa pinolla. 1 p.

Laske lukujen 1 + 2 + ... + n summa ilman rekursiota käyttäen omaa pinoa.

Lähtökohtana on rekursiivinen määritelmä:

int summa(int n) {
    if (n == 0) return 0;
    return n + summa(n - 1);
}

Kirjoita metodi summaIteratiivisesti(int n), joka palauttaa saman tuloksen. Mallinna rekursiota pinon avulla: talleta pinoon luvut, jotka "odottavat" paluuvaiheessa. Käytä pinon toteutukseen ArrayDeque-toteutusta. Et tarvitse tässä vielä Kehys-olion kaltaista rakennetta.

Tee tehtävä TIMissä

Kertoman ja puun korkeuden laskemisessa ei tarvittu erillistä tilatietoa. Kertomassa pinoon tallennettiin vain luvut ja puun korkeutta laskettaessa käsiteltiin pelkkiä solmuja, joita kohdeltiin aina samalla tavalla. Rekursion etenemis- ja paluuvaiheita ei tarvinnut erottaa, koska perustapauksen jälkeen tulos rakentui automaattisesti palautusarvojen kautta. Siksi pelkkä pino tai jono riitti käsittelyjärjestyksen ja lopputuloksen muodostamiseen.

Tilanne muuttuu, kun algoritmi tekee työtä sekä ennen että jälkeen rekursiivisen kutsun, kuten puun läpikäynnissä. Tällöin pelkkä data pinossa ei riitä, vaan mukaan on tallennettava myös tieto siitä, ollaanko kutsuvaiheessa vai paluuvaiheessa. Tätä varten tarvitaan pinokehys, joka yhdistää solmuun liittyvän datan ja rekursion vaiheen.

Seuraavassa esimerkissä rekursion etenemistä mallinnetaan omalla pinokehys-oliolla. Kehys toimii muistilappuna, jonka avulla tiedetään, mihin solmuun ollaan palaamassa ja missä vaiheessa laskentaa ollaan. Näin rekursion kutsupinoon kätkeytyvä tieto tehdään eksplisiittiseksi omassa tietorakenteessa.

static class Kehys {
    Solmu solmu;
    boolean kayty;

    Kehys(Solmu solmu, boolean kayty) {
        this.solmu = solmu;
        this.kayty = kayty;
    }
}

Tavoitteena on tulostaa binääripuun solmut jälkijärjestyksessä. Jälkijärjestys (postorder) tarkoittaa, että ensin käsitellään vasen alipuu, sitten oikea alipuu ja vasta lopuksi itse solmu. Olennaista on, että varsinainen työ tehdään vasta alikutsujen jälkeen, ei silloin kun solmu kohdataan ensimmäisen kerran.

Koska emme käytä rekursiota, meidän täytyy itse muistaa, onko solmu jo käyty läpi kutsuvaiheessa ja jätetty odottamaan vai ollaanko palaamassa siihen alipuista. Tätä varten pinossa säilytetään Kehys-olioita. Kun solmu kohdataan ensimmäistä kertaa, se merkitään käydyksi ja työnnetään takaisin pinoon odottamaan. Samalla sen lapset lisätään pinoon niin, että ne käsitellään ennen solmua. Kun sama kehys myöhemmin nousee pinosta uudelleen, tiedetään olevamme paluuvaiheessa ja solmun arvo voidaan tulostaa.

void tulostaJalkijarjestyksessa(Solmu juuri) {
    if (juuri == null) return;

    Deque<Kehys> pino = new ArrayDeque<>();
    pino.push(new Kehys(juuri, false));

    while (!pino.isEmpty()) {
        Kehys f = pino.pop();
        if (f.kayty) {
            IO.println(f.solmu.arvo); // paluuvaihe
            continue;
        }

        // Kutsuvaihe: laitetaan solmu odottamaan ja työnnetään lapset.
        f.kayty = true;
        pino.push(f);
        if (f.solmu.oikea != null) pino.push(new Kehys(f.solmu.oikea, false));
        if (f.solmu.vasen != null) pino.push(new Kehys(f.solmu.vasen, false));
    }
}

Tällä tavoin silmukka ja pino jäljittelevät täsmällisesti rekursion toimintaa: ensin rakennetaan työtä alipuille, ja vasta paluuvaiheessa suoritetaan solmuun liittyvä käsittely. Koodi seuraa suoraan jälkijärjestyksen määritelmää, mutta ilman rekursiivisia metodikutsuja.

Bonus: Tehtävä 5.12: Puun summa pinolla. 1 p.

Toteuta kokonaislukuja sisältävän binääripuun solmujen summan laskenta ilman rekursiota käyttäen omaa pinoa. Käytä oheista Solmu-luokkaa.

public class Solmu {
    int arvo;
    Solmu vasen;
    Solmu oikea;

    Solmu(int arvo) {
        this.arvo = arvo;
    }
}

Lähtökohtana on rekursiivinen määritelmä:

int summa(Solmu juuri) {
    if (juuri == null) return 0;
    return juuri.arvo + summa(juuri.vasen) + summa(juuri.oikea);
}

Mallinna rekursiota pinon avulla: jokainen pinon alkio vastaa rekursiivisen kutsun tilaa. Tätä varten tarvitset Kehys-luokan (esim. Solmu ja kayty), jolla ylläpidetään tilatietoa. Pinoa ei tarvitse toteuttaa, vaan voit käyttää ArrayDeque-toteutusta, kuten materiaalissakin.

Esimerkkipääohjelma on mukana TIM-tehtävässä.

Tee tehtävä TIMissä

Osan kaikki tehtävät

huomautus

Jos palautat tehtävät ennen osan takarajaa (ma 16.2.2026 klo 11:59 (keskipäivä)), voit saada DL-BONUS-pisteitä harjoitustehtäviin. Lue lisää suorittaminen-sivulta.

Tehtävä 5.1: Listaan lisääminen. 1 p.

Luo luokka Lista. Määrittele sen sisälle taulukko (array), jossa säilytät listan alkiota, sekä kokonaislukumuuttuja, joka pitää kirjaa listan nykyisestä alkiomäärästä. Toteuta metodit

  • add(T element): lisää alkion listan loppuun.

Tietorakenteen kokoa ei tarvitse tässä vaiheessa kasvattaa, eli taulukko voi tulla täyteen. Tutki debuggerin avulla, että alkio on lisätty oikein. Älä toteuta vielä get- tai toString-metodeja; tarkastele listan tilaa nimen omaan debuggerin avulla.

Tee tehtävä TIMissä
Tehtävä 5.2: Dynaaminen lista, osa 1. 1 p.

Muokkaa add-metodia siten, että se lisää alkion listan loppuun, ja tarvittaessa luo uuden isomman taulukon (1,5x tai 2x kokoisen) ja kopioi vanhan taulukon alkiot uuteen taulukkoon. Muista huolehtia myös listan koosta asianmukaisesti.

Toteuta myös size()-metodi, joka palauttaa listan nykyisen alkiomäärän.

Käytä get- ja size-metodeja apuna testataksesi, että add-metodi toimii oikein myös kun taulukko on täynnä ja uusi taulukko on luotu.

Tee tehtävä TIMissä
Tehtävä 5.3: Dynaaminen lista, osa 2. 1 p.

Toteuta remove(int index)-metodi, joka poistaa listasta alkion halutusta indeksistä. Metodin tulee palauttaa poistettu alkio.

  • Poistettua alkiota seuraavat alkiot siirtyvät vasemmalle, jotta taulukkoon ei jää "aukkoja".
  • Taulukkoon ei saa jäädä ylimääräisiä sisältöjä poistamisen jälkeen.
  • Muista päivittää listan koko asianmukaisesti.

Tarkista debuggerissa ja/tai get- ja size-metodeilla, että poisto on onnistunut oikein.

Tee tehtävä TIMissä
Tehtävä 5.4: Dynaaminen lista, osa 3. 1 p.

Jatka Lista-luokkaa toteuttamalla remove(T element)-metodi, joka poistaa ensimmäisen esiintymän annetusta alkiosta.

  • Jos alkio löytyy ja poistetaan, palauta true.
  • Jos alkiota ei löydy, lista pysyy ennallaan ja palautetaan false.

Vinkki: Voit käyttää jo toteuttamaasi remove(int index)-metodia apuna; älä kopioi siirto-logiikkaa tähän metodiin.

Tee tehtävä TIMissä
Tehtävä 5.5: Sanat1 p.

Tee funktio laskeSanat, joka ottaa parametrina vastaan merkkijonon ja tekee seuraavat asiat:

  • Tulostaa kaikki uniikit sanat ja sen, kuinka monta kertaa kyseinen sana esiintyy merkkijonossa.
  • Tulostaa vielä erikseen yleisimmän sanan ja sen, kuinka monta kertaa se esiintyy merkkijonossa.
  • Tulostaa uniikkien sanojen lukumäärän.

Voit testata ohjelmasi toimintaa valmiilla pääohjelmalla, jossa on myös esimerkkituloste.

Vinkki

Merkkijonosta voi ottaa välimerkit pois sen replaceAll-metodilla.

Tee tehtävä TIMissä
Bonus: Tehtävä 5.6: Varaukset1 p.

Tee luokka Varaukset, joka tallentaa varauksia. Yhdelle päivämäärälle voi olla vain yksi varaus ja varauksen tekijän nimi täytyy myös tallentaa.

Päivämääränä voit tässä tehtävässä käyttää merkkijonoa, jonka muoto on YYYY-MM-DD eli vuodet, kuukaudet ja päivät. Voit myös olettaa, että päivämäärät ovat aina oikeassa muodossa.

Toteuta luokkaan seuraavat metodit:

  • lisaaVaraus ottaa parametrina päivämäärän ja varaajan nimen merkkijonona ja lisää varauksen tietorakenteeseen. Jos päivämäärälle on jo varaus, uusi varaus ei saa korvata sitä. Metodi palauttaa true, jos uusi varaus lisätään tietorakenteeseen , muuten false.

  • poistaVaraus ottaa parametrina päivämäärän ja poistaa sille päivälle sijoittuvan varauksen. Metodi palauttaa true, jos varaus poistetaan tietorakenteesta, muuten false.

  • tulostaVaraukset ottaa parametrina alku- ja loppupäivämäärän ja tulostaa kaikki näiden väliin sijoittuvat varaukset varauksen päivämäärän mukaan järjestettynä.

Voit testata luokan toimintaa valmiin pääohjelman avulla.

Vinkki

Tietorakennetta ei tässä tapauksessa kannata järjestää itse. Yksi Map-rajapinnan toteuttavista luokista pitää alkiot aina järjestyksessä.

Tee tehtävä TIMissä
Bonus: Tehtävä 5.7: Hajautustaulu1 p.

Toteuta oma yksinkertainen hajautustaulu.

Käytä hajautustaulun päätietorakenteena taulukkoa. Voit käyttää törmäysten käsittelyyn esimerkiksi listaa, eli samaan indeksiin osuvat alkiot laitetaan siinä indeksissä sijaitsevaan listaan alkioista. Alkioita ei saa kadota törmäysten yhteydessä.

Hajautustaulun kapasiteetilla voi olla oletusarvona 10 tai se voi ottaa arvon parametrina muodostajassa. Kapasiteetin ei tarvitse muuttua missään vaiheessa ohjelman suorituksen aikana, eli taulun käyttöastetta ei tarvitse huomioida tai toteuttaa.

Javan hashCode voi palauttaa negatiivisen arvon, joten kannattaa käyttää itseisarvoa negatiivisen indeksin välttämiseksi.

Lisää metodi hae, joka hakee alkion hajautustaulusta sen avaimen perusteella. Lisää myös metodit lisaa ja poista alkioiden lisäämistä ja poistamista varten.

Tee tehtävä TIMissä
Tehtävä 5.8: Joukot1 p.

Saat tehtävässä kaksi duplikaatteja sisältävää listaa luvuista.

Muodosta näistä luvuista seuraavat joukot:

  • Yhdiste: Sisältää kaikki luvut, jotka kuuluvat joko ensimmäiseen joukkoon, toiseen joukkoon tai molempiin.

  • Leikkaus: Sisältää vain ne luvut, jotka löytyvät molemmista joukoista samanaikaisesti.

  • Erotus: Sisältää ne luvut, jotka kuuluvat ensimmäiseen joukkoon, mutta eivät kuulu toiseen joukkoon.

  • Symmetrinen erotus: Sisältää ne luvut, jotka kuuluvat vain jompaankumpaan joukkoon, mutta eivät molempiin.

Joukot eivät saa sisältää duplikaatteja.

Joukkojen muodostaminen onnistuu Set-rajapinnan määrittelemien metodien avulla niin, että tietorakenteita ei tarvitse selata läpi silmukoiden avulla.

Valmis pääohjelma sisältää esimerkit, mitä näiden joukkojen pitäisi sisältää.

Tee tehtävä TIMissä
Tehtävä 5.9: Tehtävälista1 p.

Tee luokka Tehtavalista, joka toimii todo-listana. Tehtävät voivat olla yksinkertaisia merkkijonoja.

Tehtävälista pitää kirjaa sekä tekemättömistä että tehdyistä tehtävistä. Tehtävät suoritetaan siinä järjestyksessä, missä ne ovat tehtävälistaan lisätty. Poikkeustapauksia varten tulee olla mahdollista lisätä kiireellisiä tehtäviä heti tehtävälistan alkuun.

Ohjelmassa pitää olla myös mahdollisuus kumota tehtävän merkitseminen suoritetuksi siltä varalta, että tehtävän merkitsee vahingossa tehdyksi liian aikaisin. Kumoaminen palauttaa suoritetuksi merkityn tehtävän takaisin tehtävälistan alkuun.

Lisää luokkaan seuraavat metodit:

  • lisaaTehtava, joka lisää tehtävän tehtävälistaan. Uusi tehtävä menee tehtävälistan viimeiseksi.

  • lisaaTarkeaTehtava, joka lisää kiireellisen tehtävän tehtävälistaan. Kiireellinen tehtävä menee aina tehtävälistan ensimmäiseksi.

  • merkitseTehdyksi, joka merkitsee seuraavana tehtävälistalla olevan tehtävän suoritetuksi.

  • kumoaTehty, joka palauttaa viimeksi tehdyn tehtävän suoritettujen tehtävienlistalta takaisin tehtävälistan alkuun.

  • tulosta, joka tulostaa tekemättömät ja tehdyt tehtävät omina listoinaan. Tulostusmuoto ei ole hirveän tärkeä, kunhan tulosteesta näkee selvästi eri listat.

Voit testata luokan toimintaa valmiin pääohjelman avulla.

Tee tehtävä TIMissä
Bonus: Tehtävä 5.10: Sulut1 p.

Kirjoita aliohjelma, joka tarkistaa merkkijonon sisältämien sulkujen oikeellisuuden. Aliohjelman tulee tunnistaa, sulkeutuvatko kaikki sulut oikeassa järjestyksessä ja onko jokaisella alkavalla sululla vastaava lopettava pari.

Tuetut sulkutyypit ovat kaarisulut ( ), hakasulut [ ] ja aaltosulut { }.

Toimintalogiikka ja säännöt:

  • Sisäkkäisyys: Sulut voivat olla sisäkkäin (esim. ([])), mutta ne eivät saa mennä ristiin. Esimerkiksi ([)] on virheellinen, koska sulut menevät ristiin.
  • Järjestys: Sulun on aina alettava ennen kuin se sulkeutuu.
  • Muut merkit kuten kirjaimet tai numerot tulee jättää huomiotta.
  • Tyhjä merkkijono katsotaan oikeelliseksi, ja siinä on 0 paria.

Paluuarvo:

  • Jos sulutus on kunnossa, palauta löydettyjen sulkuparien lukumäärä (kokonaisluku).
  • Jos sulutus on virheellinen (yksikin pari puuttuu tai järjestys on väärä), palauta luku -1.

Esimerkit:

MerkkijonoTulosSelite
""0Tyhjä syöte on validi, 0 paria.
"()"1Yksi ehjä pari.
"(())"2Kaksi sisäkkäistä paria.
"([{}])"3Kolme sisäkkäistä paria.
"()[]{}"3Kolme vierekkäistä paria.
"a(b)c"1Kirjaimet sivuutetaan, yksi pari.
"("-1Sulkeva pari puuttuu.
"(()"-1Yksi sulkeva pari puuttuu.
"()}"-1Ylimääräinen sulkeva sulku.
")("-1Väärä järjestys (alkava sulku puuttuu alussa).
"([)]"-1Sulut menevät ristiin (virheellinen sisäkkäisyys).

Aliohjelma tulee toteuttaa niin, että jos sulkuihin lisättäisin uusia sulkutyyppejä, niin varsinaisessa logiikassa ei tarvitsisi tehdä muutoksia. Esimerkiksi merkkijonon a<(b)>c käsittelemiseen tulisi vain lisätä tuki kulmasuluille < >, mutta muuten logiikka pysyisi samana (lue: ei ylimääräisiä if-lauseita).

Tee tehtävä TIMissä
Tehtävä 5.11: Summa pinolla. 1 p.

Laske lukujen 1 + 2 + ... + n summa ilman rekursiota käyttäen omaa pinoa.

Lähtökohtana on rekursiivinen määritelmä:

int summa(int n) {
    if (n == 0) return 0;
    return n + summa(n - 1);
}

Kirjoita metodi summaIteratiivisesti(int n), joka palauttaa saman tuloksen. Mallinna rekursiota pinon avulla: talleta pinoon luvut, jotka "odottavat" paluuvaiheessa. Käytä pinon toteutukseen ArrayDeque-toteutusta. Et tarvitse tässä vielä Kehys-olion kaltaista rakennetta.

Tee tehtävä TIMissä
Bonus: Tehtävä 5.12: Puun summa pinolla. 1 p.

Toteuta kokonaislukuja sisältävän binääripuun solmujen summan laskenta ilman rekursiota käyttäen omaa pinoa. Käytä oheista Solmu-luokkaa.

public class Solmu {
    int arvo;
    Solmu vasen;
    Solmu oikea;

    Solmu(int arvo) {
        this.arvo = arvo;
    }
}

Lähtökohtana on rekursiivinen määritelmä:

int summa(Solmu juuri) {
    if (juuri == null) return 0;
    return juuri.arvo + summa(juuri.vasen) + summa(juuri.oikea);
}

Mallinna rekursiota pinon avulla: jokainen pinon alkio vastaa rekursiivisen kutsun tilaa. Tätä varten tarvitset Kehys-luokan (esim. Solmu ja kayty), jolla ylläpidetään tilatietoa. Pinoa ei tarvitse toteuttaa, vaan voit käyttää ArrayDeque-toteutusta, kuten materiaalissakin.

Esimerkkipääohjelma on mukana TIM-tehtävässä.

Tee tehtävä TIMissä

Hyödyllisiä menetelmiä Javassa

osaamistavoitteet

  • Hyödynnät lambdalausekkeita ja funktioviitteitä koodin tiivistämisessä.
  • Käsittelet kokoelmia deklaratiivisesti Stream API:n avulla.
  • Hallitset virhetilanteita poikkeusten (Exception) avulla.
  • Käytät Maven-projektinhallintatyökalua ja ulkoisia kirjastoja.
  • Luet ja kirjoitat tietoa tiedostoihin.

Olemme tähän mennessä oppineet Javan perussyntaksin, olio-ohjelmoinnin ja tavallisimmat tietorakenteet. Tässä osassa opimme moderneista Javan ominaisuuksista sekä tutustumme projektihallinan perustyökaluihin.

Aloitamme tutustumalla Javan funktionaalisiin piirteisiin ja Stream API:iin, joka tarjoaa tehokkaan tavan käsitellä dataa ilman silmukkarakenteita.

Ohjelmistojen luotettavuuden parantamiseksi opimme hallitsemaan virhetilanteita poikkeusten hallinnan avulla. Lisäksi katsomme, kuinka Maven auttaa meitä hallitsemaan ulkoisia kirjastoja ja miten voimme käsitellä tiedostoja ohjelmallisesti.

Funktiorajapinnat ja lambdalausekkeet

osaamistavoitteet

  • Ymmärrät funktionaalisen rajapinnan käsitteen
  • Osaat käyttää lambdalausekkeita ja funktioviitteitä funktiorajapintojen toteuttamiseen
  • Tunnet Javan yleisimmät valmiit funktiorajapinnat (esim. Function, Consumer)
  • Osaat määrittää olioille vaihtoehtoisia järjestyksiä Comparator-rajapinnan ja lambdalausekkeiden avulla

Funktionaalinen rajapinta (engl. functional interface) on rajapinta, joka sisältää vain yhden pakollisen metodin. Sen tarkoituksena on edustaa yksittäistä toimintoa tai kyvykkyyttä.

Esimerkiksi seuraava rajapinta on funktionaalinen:

/**
 * Rajapinta, joka edustaa funktiota. Se ottaa parametrina luvun
 * ja palauttaa toisen luvun.
 */
public interface NumeroFunktio {
    int laske(int luku);
}

Myös osassa 4.1 esimerkkinä tehty Saadettava-rajapinta on funktionaalinen, sillä se sisältää vain yhden metodin: asetaArvo.

Java tarjoaa yksinkertaistetun tavan luoda olioita, jotka toteuttavat funktionaalisia rajapintoja. Tämä mahdollistaa koodin, jossa voimme käsitellä funktioita lähes samalla tavalla kuin käsittelemme dataa.

Olion alustaminen funktiorajapinnasta

Jos haluamme luoda olion, joka toteuttaa NumeroFunktio-rajapinnan, voimme määritellä luokan, joka toteuttaa rajapinnan ja sitten ylikirjoittaa metodin laske.

main.java
public class KerroKahdella implements NumeroFunktio {
    @Override
    public int laske(int luku) {
        return luku * 2;
    }
}

void main() {
    NumeroFunktio kerroKahdella = new KerroKahdella();
    IO.println(kerroKahdella.laske(3));
}

Tässä jouduimme kirjoittamaan melko paljon koodia (uusi luokka, metodin ylikirjoitus) vain yhtä pientä oliota varten. Nyt on kuitenkin niin, että NumeroFunktio on funktionaalinen rajapinta: sillä on vain yksi pakollinen metodi. Javassa on mahdollista käyttää olemassa olevaa metodia ilman luokan rakentelua ikään kuin tämä metodi olisi kyseisen rajapinnan toteuttava olio. Tätä kutsutaan funktioviitteeksi (engl. method reference).

main.java
int kerroKahdella(int luku) {
    return luku * 2;
}

void main() {
    // Käytetään olemassa olevaa metodia rajapinnan toteutuksena
    // HIGHLIGHT_GREEN_BEGIN
    NumeroFunktio funktio = this::kerroKahdella;
    // HIGHLIGHT_GREEN_END
    
    IO.println(funktio.laske(3));
}

Huomaa erityisesti syntaksi this::kerroKahdella. Se ei kutsu metodia, vaan se ikään kuin luo viitteen metodiin. Java osaa automaattisesti luoda rajapinnan toteuttavan olion, koska kerroKahdella-metodin parametrit ja palautusarvo täsmäävät rajapinnan ainoan metodin kanssa. Jos yritämme tulostaa funktio-muuttujan arvon, kokonaisluvun sijaan tulostuukin olion tiedot:

public interface NumeroFunktio {
    int laske(int luku);
}

int kerroKahdella(int luku) {
    return luku * 2;
}

void main() {
NumeroFunktio funktio = this::kerroKahdella;
IO.println(funktio);
}

Toiseksi, funktioviitteen yhteydessä käytetään ::-merkintää viittaamaan joko olion tai luokan metodiin. Toisin sanoen, this::kerroKahdella tarkoittaa, että funktioviite koskee nykyisen olion kerroKahdella-metodia. this-viitteen sijaan voidaan käyttää olioviitettä tai luokkametodien tapauksessa luokkaa:

main.java
class Ohjelma {
    public static int kerroKahdellaStatic(int luku) {
        IO.println("Olen luokkametodi!");
        return luku * 2;
    }

    public int kerroKahdellaEiStatic(int luku) {
        IO.println("Olen oliometodi!");
        return luku * 2;
    }

    void main() {
        // Nyt kerroKahdella on luokkametodi, joten käytetään luokan nimeä
        // olioviitteen sijaan.
        NumeroFunktio funktio = Ohjelma::kerroKahdellaStatic;
        // Tavallisen metodin viite saadaan olioviitteen kautta
        NumeroFunktio funktio2 = this::kerroKahdellaEiStatic;
        IO.println(funktio.laske(2));
        IO.println(funktio2.laske(2));
    }
}
Bonus: Miten funktioviite toimii?

Saatat miettiä, miten funktio voi yhtäkkiä "muuttua" olioksi. Javassa kyseessä on oikeastaan tekninen temppu. Ennen funktioviitteitä sama asia Javassa tehtiin anonyymeillä luokilla.

Kääntäjä muuttaa funktioviitteen this::kerroKahdella suunnilleen tällaiseksi rakenteeksi:

int kerroKahdella(int luku) {
    return luku * 2;
}

NumeroFunktio funktio = new NumeroFunktio() {
    @Override
    public int laske(int luku) {
        return kerroKahdella(luku);
    }
};

Lambdalauseke on siis todellisuudessa olio, joka toteuttaa halutun rajapinnan. Tästä syystä niitä voidaan käyttää vain sellaisten rajapintojen kanssa, joissa on tasan yksi metodi (funktionaaliset rajapinnat).

Mainittakoon, että vaikka anonyymejä luokkia käytetään nykyään vähemmän, voivat olla silti hyödyllisiä tapauksissa, jossa toteutettava rajapinta ei ole funktionaalinen.

Lambdalausekkeet

Javassa on mahdollista kirjoittaa funktion toteutus ilman erillistä metodia, suoraan siinä kohdassa, missä funktio tarvitaan. Tällaista lausekemuodossa kirjoitettua funktiota kutsutaan lambdalausekkeeksi (engl. lambda expression).

Lambdalausekkeen perusrakenne on seuraava:

(tyyppi parametri) -> {
    // funktion runko
    return tulos;
}

Jos parametreja on useampi, ne erotetaan pilkulla:

(tyyppi1 parametri1, tyyppi2 parametri2) -> {
    // funktion runko
    return tulos;
}

Lambdalausekkeelle ei tarvitse antaa erikseen nimeä. Tästä syystä niitä kutsutaan myös anonyymeiksi funktioiksi (engl. anonymous function). Alla esimerkki, tulostetaan listan alkioita lambdalausekkeella.

main.java
void main() {
    // Välitetään anonyymi funktio suoraan forEach-metodille
    List<String> marjoja = List.of("mansikka", "mustikka", "puolukka", "mansikka");
    // tulosta vain mansikat
    marjoja.forEach(marja -> {
        if (marja.equals("mansikka")) {
            IO.println(marja);
        }
    });
    
}

Tämähän olisi voitu kirjoittaa myös perinteisellä aliohjelmakutsulla:

void main() {
    List<String> marjoja = List.of("mansikka", "mustikka", "puolukka");
    tulostaMansikat(marjoja);
}

void tulostaMansikat(List<String> marjoja) {
    for (String marja : marjoja) {
        if (marja.equals("mansikka")) {
            IO.println(marja);
        }
    }
}

Oleellinen ajatus on tämä: lambdalauseke ei ole irrallinen koodinpätkä, vaan se on olio, joka toteuttaa funktionaalisen rajapinnan. Kullekin parametrille tulee määritellä tyyppi, joka vastaa funktionaalisen rajapinnan metodin parametreja. Palataan esimerkkiimme NumeroFunktio-rajapinnasta. Toteutetaan nyt lambdalausekkeena funktio, joka kertoo syötetyn luvun kahdella. Tällaisen lausekkeen tulee ottaa parametrina yksi kokonaisluku ja palauttaa kokonaisluku. Muoto on seuraava:

(int luku) -> {
    return luku * 2;
}

Koska tällainen lambdalauseke toteuttaa NumeroFunktio-rajapinnan, voimme sijoittaa sen NumeroFunktio-tyyppiseen muuttujaan:

NumeroFunktio funktio = (int luku) -> {
    return luku * 2;
};

Nyt voimme kutsua funktio-muuttujan laske-metodia, koska olemme toteuttaneet NumeroFunktio-rajapinnan.

main.java
void main() {
    NumeroFunktio funktio = (int luku) -> {
        return luku * 2;
    };
    IO.println(funktio.laske(1));
    IO.println(funktio.laske(2));
    IO.println(funktio.laske(3));
    IO.println(funktio.laske(4));
}

Emme siis tarvinneet erillistä luokkaa, emmekä edes erillistä metodia!

Lambdalausekkeiden suurin etu on juuri niiden tiiviys. Java osaa päätellä monta asiaa automaattisesti, jolloin koodia vielä tästäkin voidaan lyhentää. Ensinnäkin parametrien tyypit voidaan päätellä funktiorajapinnan parametrien tyypeistä, joten tyypit voidaan usein jättää pois. Yllä olevassa esimerkissämme voimme jättää pois int-tyypin, koska NumeroFunktio.laske-metodi ottaa parametrina kokonaisluvun, eikä tätä tarvitse erikseen mainita lambdalausekkeessa.

public interface NumeroFunktio {
    int laske(int luku);
}

void main() {
NumeroFunktio funktio = (luku) -> {
    return luku * 2;
};
IO.println(funktio.laske(1));
IO.println(funktio.laske(2));
IO.println(funktio.laske(3));
IO.println(funktio.laske(4));
}

Toiseksi, jos lambdalausekkeen runko sisältää vain yhden lauseen, aaltosulut ja return-sanan voi jättää pois.

public interface NumeroFunktio {
    int laske(int luku);
}

void main() {
NumeroFunktio funktio = (luku) -> luku * 2;
IO.println(funktio.laske(1));
IO.println(funktio.laske(2));
IO.println(funktio.laske(3));
IO.println(funktio.laske(4));
}

Lopuksi, jos lambdalausekkeessa on tasan yksi parametri, myös kaarisulut voi jättää pois.

public interface NumeroFunktio {
    int laske(int luku);
}

void main() {
NumeroFunktio funktio = luku -> luku * 2;
IO.println(funktio.laske(1));
IO.println(funktio.laske(2));
IO.println(funktio.laske(3));
IO.println(funktio.laske(4));
}

Lambdalausekkeissa on lisäksi tapana käyttää tavallista lyhyempiä parametrien nimiä, sillä parametrien merkitys dokumentoidaan funktionaalisessa rajapinnassa.

public interface NumeroFunktio {
    int laske(int luku);
}

void main() {
NumeroFunktio kerroKahdella = x -> x * 2;
IO.println(kerroKahdella.laske(1));
IO.println(kerroKahdella.laske(2));
IO.println(kerroKahdella.laske(3));
IO.println(kerroKahdella.laske(4));
}

Funktiot parametreina

Koska lambdalauseke on olio, voimme välittää sen aliohjelmalle parametrina. Tämä mahdollistaa korkeamman abstraktiotason funktioiden kirjoittamisen. Voimme tehdä vaikkapa aliohjelman, joka osaa laskea kahden eri funktion summan:

public interface NumeroFunktio {
    int laske(int luku);
}

/**
 * Laskee kahden funktion summan tietylle arvolle.
 */
int summaaFunktiot(NumeroFunktio f1, NumeroFunktio f2, int x) { 
    return f1.laske(x) + f2.laske(x);
}

void main() {
    // Välitetään kaksi eri funktiota (x*2 ja x*x) summattavaksi
    int tulos = summaaFunktiot(x -> x * 2, x -> x * x, 3);
    IO.println("Tulos: " + tulos); // (3*2) + (3*3) = 6 + 9 = 15
}

Valmiita funktiorajapintoja

Javassa on joukko valmiita yleisiä funktiorajapintoja, jotka löytyvät java.util.function-paketista (ks. JavaDoc).

Function<T, R> (JavaDoc) esittää funktiota, joka ottaa yhden parametrin tyyppiä T ja palauttaa parametrin tyyppiä R. Esimerkiksi yllä oleva esimerkki voidaan yksinkertaistaa käyttämällä valmista Function-rajapintaa NumeroFunktio-rajapinnan sijaan:

void main() {
Function<Integer, Integer> kerroKahdella = x -> x * 2;
Function<Integer, Integer> potenssiinKaksi = x -> x * x;

IO.println(kerroKahdella.apply(1));
IO.println(potenssiinKaksi.apply(2));
}

Function-rajapinta sisältää myös apumetodit andThen ja compose, joiden avulla funktioita voidaan ketjuttaa:

void main() {
Function<Integer, Integer> kerroKahdella = x -> x * 2;
Function<Integer, Integer> potenssiinKaksi = x -> x * x;

// Laskee: (x^2) * 2
Function<Integer, Integer> potenssiinJaKerro = kerroKahdella.compose(potenssiinKaksi);
// Laskee: (x * 2)^2
Function<Integer, Integer> kerroJaPotenssiin = kerroKahdella.andThen(potenssiinKaksi);

IO.println(potenssiinJaKerro.apply(2));
IO.println(kerroJaPotenssiin.apply(2));
}

Vastaavasti BiFunction<T, U, R> (JavaDoc) edustaa funktiota, joka ottaa kaksi parametria ja palauttaa yhden arvon.

Consumer<T> (JavaDoc) ja BiConsumer<T, U> (JavaDoc) puolestaan vastaavat funktioita, jotka ottavat parametreja mutta eivät palauta mitään (palautustyyppi on void). Esimerkiksi useat kokoelmat sisältävät forEach-metodin, jolle välitetään Consumer-olio:

void main() {
List<String> marjoja = List.of("mansikka", "mustikka", "puolukka");

// IO.println sopii Consumer<T>:hen, sillä 
// se ottaa yhden parametrin eikä palauta mitään
marjoja.forEach(IO::println);

Map<String, Integer> arvosanat = new HashMap<>(
            Map.of( "Denis",        1,
                    "Antti-Jussi",  3,
                    "Sami",         5,
                    "Karri",        5)
    );
// BiConsumer ottaa kaksi parametria (avain ja arvo)
arvosanat.forEach((nimi, arvosana) -> IO.println(nimi + " => " + arvosana));
}
Tehtävä 6.1: Laskukone 1 p.

Tee ohjelma, joka kysyy käyttäjältä kaksi desimaalilukua sekä laskutoimituksen ja tulostaa lopputuloksen seuraavasti:

Luku 1 > 12.0
Luku 2 > 3.0
Laskutoimitus (+, -, *, /) > +
12.0 + 3.0 = 15.0

Tässä vaiheessa sinun ei tarvitse käsitellä virheellisiä syötteitä, vaan voit olettaa, että luvut annetaan aina lukuina. Sallitut laskutoimitukset ovat summa (+), erotus (-), tulo (*) ja osamäärä (/). Voit olettaa, että vain näitä laskutoimituksia käytetään syötteenä.

Älä käytä silmukoita tai ehtorakenteita. Sen sijaan toteuta laskutoimitukset lambdalausekkeina ja tallenna ne hakurakenteeseen käyttäen laskutoimituksen merkkiä avaimena.

Ohjelman suoritus päättyy tuloksen näyttämisen jälkeen.

Vinkki 1

Voit käyttää lambdalausekkeiden tyyppinä BiFunction<Double, Double, Double> (JavaDoc) tai DoubleBinaryOperator (JavaDoc).

Vinkki 2

Voit käyttää hakurakenteen tyyppinä Map<String, BiFunction<Double, Double, Double>> tai Map<String, DoubleBinaryOperator>.

Voit joko valita hakurakenteelle tietyn toteutuksen tai alustaa muuttumattoman hakurakenteen Map.of-metodilla:

Map<String, BiFunction<Double, Double, Double>> laskutoimitukset = Map.of(
    "+", ...,
    "-", ...,
    "*", ...,
    "/", ...
);

...-kohdan tilalle riittää asettaa sopiva lambdalauseke.

Tee tehtävä TIMissa

Comparator-rajapinta

Palataan luvussa 4.2 esiteltyyn Comparable<T>-rajapintaan. Sen avulla määritimme olioille luonnollisen järjestyksen. Toisinaan haluamme kuitenkin järjestää samoja olioita eri tilanteissa eri tavoin (esim. henkilöt nimen mukaan TAI iän mukaan).

Javan Comparator-rajapinta (JavaDoc) tarjoaa tavan määrittää vaihtoehtoisia järjestystapoja. Rajapinta sisältää vain yhden pakollisen metodin (compare), eli se on funktionaalinen rajapinta. Rajapinnan compare toimii samalla periaatteella kuin Comparable.compareTo:

TapausMerkitysTulkinta
cmp.compare(a, b) < 0a < ba on järjestyksessä ennen b:tä
cmp.compare(a, b) == 0a == ba ja b ovat samanarvoisia
cmp.compare(a, b) > 0a > ba on järjestyksessä b:n jälkeen

Koska Comparator on funktionaalinen, voimme määrittää vertailun erittäin tiiviisti lambdalausekkeena:

void main() {
List<String> nimet = Arrays.asList("Ville", "Aino", "Matti");

// Järjestetään nimet pituuden mukaan (lyhyin ensin)
nimet.sort((s1, s2) -> Integer.compare(s1.length(), s2.length()));

IO.println(nimet); // [Aino, Ville, Matti]
}

Palataan vielä osassa 4.2 olevaan keräilykorttiesimerkkiin. Laajennetaan hieman Kerailykortti-luokkaa lisäämällä attribuutti sarja, joka kuvaa korttisarjaa (esim. eläimet, ajoneuvot, jne.):

class Kerailykortti implements Comparable<Kerailykortti> {
    private String nimi;
    // HIGHLIGHT_GREEN_BEGIN
    private String sarja;
    // HIGHLIGHT_GREEN_END
    private int tunnistenumero;
    
    // HIGHLIGHT_GREEN_BEGIN
    public Kerailykortti(String nimi, String sarja, int tunnistenumero) {
    // HIGHLIGHT_GREEN_END
        this.nimi = nimi;
    // HIGHLIGHT_GREEN_BEGIN
        this.sarja = sarja;
    // HIGHLIGHT_GREEN_END
        this.tunnistenumero = tunnistenumero;
    }

    @Override
    public int compareTo(Kerailykortti other) {
        int sarjaVertailu = this.sarja.compareTo(other.sarja);
        if (sarjaVertailu != 0) {
            return sarjaVertailu;
        }
        return Integer.compare(this.tunnistenumero, other.tunnistenumero);
    }
    
    public String getNimi() {
        return nimi;
    }

    public String getSarja() {
        return sarja;
    }
    

    @Override
    public String toString() {
        return "Kortti: " + nimi + " (Sarja: " + sarja + ", #" + tunnistenumero + ")";
    }
}

void main() {
    List<Kerailykortti> kortit = Arrays.asList(
        new Kerailykortti("Loistava Lohikäärme", "Eläimet", 3),
        new Kerailykortti("Vauhdikas Vespajetti", "Ajoneuvot", 1),
        new Kerailykortti("Aloittelijan Ameeba", "Eläimet", 1),
        new Kerailykortti("Mieletön Merihevonen", "Eläimet", 2),
        new Kerailykortti("Nopea Nopsa", "Ajoneuvot", 2)
    );

    IO.println("Ennen järjestämistä:");
    for (Kerailykortti kortti : kortit) {
        IO.println(kortti);
    }

    Collections.sort(kortit);

    IO.println();

    IO.println("Jälkeen järjestämisen:");
    for (Kerailykortti kortti : kortit) {
        IO.println(kortti);
    }
}

Tällä hetkellä keräilykorteille on määritelty luonnollinen järjestys siten, että ensin keräilykortit järjestetään nimen ja sitten tunnisteen mukaan. Haluaisimme kuitenkin tarjota vaihtoehtoisen tavan järjestää keräilykortteja sarjan nimen mukaan. Tätä varten voimme käyttää Javan valmista sort-metodin versiota, joka ottaa parametrina Comparator-vertailijan:

main.java
void main() {
    List<Kerailykortti> kortit = Arrays.asList(
        new Kerailykortti("Loistava Lohikäärme", "Eläimet", 3),
        new Kerailykortti("Vauhdikas Vespajetti", "Ajoneuvot", 1),
        new Kerailykortti("Aloittelijan Ameeba", "Eläimet", 1),
        new Kerailykortti("Mieletön Merihevonen", "Eläimet", 2),
        new Kerailykortti("Nopea Nopsa", "Ajoneuvot", 2)
    );

    IO.println("Ennen järjestämistä:");
    kortit.forEach(IO::println);

    // Collections.sort tarjoaa version, jossa toiseksi parametrina voi antaa
    // järjestyksen, jonka mukaan alkioita järjestetään.
    Comparator<Kerailykortti> sarjanMukaan = 
        (kortti1, kortti2) -> kortti1.getSarja().compareTo(kortti2.getSarja());
    Collections.sort(kortit, sarjanMukaan);

    IO.println();

    IO.println("Jälkeen järjestämisen:");
    kortit.forEach(IO::println);
}

Huomaa erityisesti, että nyt järjestäminen tehdään sarjanMukaan-vertailijan mukaan, joka on määritelty lambdalausekkeena. Koska vertailija on määritelty Kerailykortti-luokan ulkopuolella, lisäsimme myös saantimetodin getSarja().

Javan Comparator-luokka tarjoaa myös valmiita apumetodeja vertailijoiden yhdistämiseksi.

Comparator.comparing()-metodi ottaa parametrina lambdalausekkeen, joka palauttaa oliosta lasketun arvon, jonka perusteella vertailu tehdään. Metodi soveltuu tilanteisiin, kun olio halutaan vertailla olion metodin palauttaman arvon luonnollisen järjestyksen mukaan. Esimerkiksi keräilykorttien sarjanMukaan-vertailija voidaan toteuttaa suoraviivaisemmin:

void main() {
    List<Kerailykortti> kortit = Arrays.asList(
        new Kerailykortti("Loistava Lohikäärme", "Eläimet", 3),
        new Kerailykortti("Vauhdikas Vespajetti", "Ajoneuvot", 1),
        new Kerailykortti("Aloittelijan Ameeba", "Eläimet", 1),
        new Kerailykortti("Mieletön Merihevonen", "Eläimet", 2),
        new Kerailykortti("Nopea Nopsa", "Ajoneuvot", 2)
    );

    IO.println("Ennen järjestämistä:");
    kortit.forEach(IO::println);

Comparator<Kerailykortti> sarjanMukaan = 
    Comparator.comparing(Kerailykortti::getSarja);
    Collections.sort(kortit, sarjanMukaan);

    IO.println();

    IO.println("Jälkeen järjestämisen:");
    kortit.forEach(IO::println);
}

class Kerailykortti implements Comparable<Kerailykortti> {
    private String nimi;
    private String sarja;
    private int tunnistenumero;
    
    public Kerailykortti(String nimi, String sarja, int tunnistenumero) {
        this.nimi = nimi;
        this.sarja = sarja;
        this.tunnistenumero = tunnistenumero;
    }

    public String getSarja() {
        return sarja;
    }

    @Override
    public int compareTo(Kerailykortti other) {
        int sarjaVertailu = this.sarja.compareTo(other.sarja);
        if (sarjaVertailu != 0) {
            return sarjaVertailu;
        }
        return Integer.compare(this.tunnistenumero, other.tunnistenumero);
    }

    @Override
    public String toString() {
        return "Kortti: " + nimi + " (Sarja: " + sarja + ", #" + tunnistenumero + ")";
    }
}

Puolestaan Comparator.thenComparing()-metodi palauttaa vertailijan, joka yhdistää kaksi vertailijaa yhteen: ensin verrataan olioita ensimmäisen vertailijan mukaan ja jos ensimmäinen vertailija palauttaa 0, vertaillaan toisen vertailijan mukaan. Esimerkiksi keräilykorttien omaa compareTo-metodia voidaan toteuttaa oliomaisemmin seuraavasti:

@Override
public int compareTo(Kerailykortti other) {
    // Vertailija, joka vertaa kortteja sarjan mukaan
    Comparator<Kerailykortti> sarjanMukaan = Comparator.comparing(k -> k.sarja);
    // Vertailija, joka vertaa kortteja tunnistenumeron mukaan
    Comparator<Kerailykortti> tunnistenumeronMukaan = Comparator.comparing(k -> k.tunnistenumero);
    
    // vertaillaan ensin sarjan mukaan ja sitten tunnistenumeron mukaan
    Comparator<Kerailykortti> vertailu = sarjanMukaan.thenComparing(tunnistenumeronMukaan);
    return vertailu.compare(this, other);
}

Comparator.naturalOrder() palauttaa Comparator-vertailijan, joka järjestää oliot niiden luonnollisen järjestyksen mukaan. Toisin sanoen, tämä mahdollistaa ns. eristää Comparable-rajapintaa toteuttavan olion compareTo-metodin toteutuksen vertailuolioksi. Esimerkiksi merkkijonojen aakkosjärjestystä vastaavan vertailijan saa tällä tavoin:

void main() {
    List<String> jonoja = new ArrayList<>(List.of("Denis", "Antti-Jussi", "Karri", "Rauli", "Sami"));
    Comparator<String> aakkosjarjestys = Comparator.naturalOrder();
    Collections.sort(jonoja, aakkosjarjestys);
    IO.println(jonoja);
}

Comparator.reversed() luo uuden vertailijan, joka kääntää vertailujärjestyksen. Tämän avulla esimerkiksi pystyy helposti järjestämään merkkijonot käänteiseen aakkosjärjestykseen:

void main() {
    List<String> jonoja = new ArrayList<>(List.of("Denis", "Antti-Jussi", "Karri", "Rauli", "Sami"));
    Comparator<String> aakkosjarjestys = Comparator.naturalOrder();
    // HIGHLIGHT_GREEN_BEGIN
    Comparator<String> kaanteinenAakkosjarjestys = aakkosjarjestys.reversed();
    // HIGHLIGHT_GREEN_END
    Collections.sort(jonoja, kaanteinenAakkosjarjestys);

    IO.println(jonoja);
}

Oletuksena monet vertailijat eivät käsittele null-viitteitä, mikä voi johtaa erilaisiin virheisiin ja odottamattomiin tilanteisiin. Esimerkiksi jopa Javassa määritelty String-merkkijonojen luonnollinen järjestys ei käsittele tapausta, jos jompikumpi verrattavista merkkijonoista on null:

void main() {
String[] jono = {"Ohjelmointi 1", null,  "Ohjelmointi 2"};
Arrays.sort(jono);
IO.println(Arrays.toString(jono));
}
java.lang.NullPointerException: Cannot invoke "java.lang.Comparable.compareTo(Object)" because "a[runHi]" is null

Comparator.nullsFirst() ja Comparator.nullsLast() -metodit auttavat tällaisissa tilanteissa: ne ottavat parametriksi vertailuolion ja palauttavat uuden vertailijan, joka osaa käsitellä null-viitteitä. Nimensä mukaan nullsFirst() asettaa null-viitteet pienemmäksi kuin muut arvot (ja siten järjestyksessä ensimmäiseksi), kun taas nullsLast asettaa null-viitteet suuremmaksi kuin muut arvot (eli järjestyksessä viimeiseksi):

void main() {
String[] jono = {"Ohjelmointi 1", null,  "Ohjelmointi 2"};
Comparator<String> aakkosjarjestys = Comparator.naturalOrder();

Comparator<String> nullitEnsimmaiseksi = Comparator.nullsFirst(aakkosjarjestys);
Arrays.sort(jono, nullitEnsimmaiseksi);
IO.println(Arrays.toString(jono));

Comparator<String> nullitViimeiseksi = Comparator.nullsLast(aakkosjarjestys);
Arrays.sort(jono, nullitViimeiseksi);
IO.println(Arrays.toString(jono));
}

Yhdistämällä eri Comparator-vertailijoiden metodeja voidaan toteuttaa hyvin monipuolisia vertailijoita ilman ehtorakenteita:

void main() {
List<String> nimet = Arrays.asList("Ville", "Aino", "Matti", null);

// Rakennetaan monimutkaisempi vertailija:
// 1. Huomioi null-arvot (laita ne loppuun)
// 2. Järjestä pituuden mukaan
// 3. Jos pituus on sama, käytä aakkosjärjestystä (naturalOrder)
Comparator<String> vertailija = Comparator.nullsLast(
    Comparator.comparingInt(String::length)
              .thenComparing(Comparator.naturalOrder())
);

nimet.sort(vertailija);
IO.println(nimet);
}
Tehtävä 6.2: Vertailu harvinaisuuden mukaan 1 p.

Laajenna Kerailykortti-luokkaa lisäämällä sille attribuutti String harvinaisuus. Keräilykortin harvinaisuus voi olla yksi seuraavista arvoista (vähiten harvinaisesta harvinaisimpaan): C, U, R, RR, RRR, SR, AR, SAR, UR.

Kirjoita vertailija, joka järjestää listassa olevat kortit niiden harvinaisuuden mukaan. Voit käyttää seuraavaa valmista korttikokoelmaa koodisi testaamiseen:

Mallilista erilaisista korteista
List<Kerailykortti> kortit = new ArrayList<>(List.of(
    new Kerailykortti("Kadonnut Puolipiste", "Koodiviidakko", 101, "C"),
    new Kerailykortti("Loputon Silmukka", "Koodiviidakko", 102, "U"),
    new Kerailykortti("Bugimetsästäjä", "Koodiviidakko", 103, "R"),
    new Kerailykortti("Spagettikoodi-Hirviö", "Koodiviidakko", 104, "RR"),
    new Kerailykortti("Ylikellotettu Prosessori", "Koodiviidakko", 105, "SR"),
    new Kerailykortti("Pyhä Stack Overflow", "Koodiviidakko", 106, "RRR"),
    new Kerailykortti("Null Pointer -Ninja", "Koodiviidakko", 107, "U"),
    new Kerailykortti("Sininen Kuolemanruutu", "Koodiviidakko", 108, "AR"),

    new Kerailykortti("Opiskelijakortti", "Kampus-Saaga", 201, "C"),
    new Kerailykortti("Unelias Luennoitsija", "Kampus-Saaga", 202, "C"),
    new Kerailykortti("Haalaribileet", "Kampus-Saaga", 203, "U"),
    new Kerailykortti("Ylisuorittaja", "Kampus-Saaga", 204, "R"),
    new Kerailykortti("Ilmainen Ämpäri", "Kampus-Saaga", 205, "SAR"),
    new Kerailykortti("Myöhästynyt Palautus", "Kampus-Saaga", 206, "RR"),
    new Kerailykortti("Akateeminen Vartti", "Kampus-Saaga", 207, "SR"),
    new Kerailykortti("Gradu-Ahdistus", "Kampus-Saaga", 208, "AR"),
    new Kerailykortti("Semman Pannukakku", "Kampus-Saaga", 209, "UR"),

    new Kerailykortti("Vihainen Hirvi", "Suomi-Myytit", 301, "C"),
    new Kerailykortti("Ikuinen Marraskuu", "Suomi-Myytit", 302, "RR"),
    new Kerailykortti("Saunaklonkku", "Suomi-Myytit", 303, "SR"),
    new Kerailykortti("Salmiakkisade", "Suomi-Myytit", 304, "U"),
    new Kerailykortti("Väinämöisen Kantele", "Suomi-Myytit", 305, "SAR"),
    new Kerailykortti("Sisu", "Suomi-Myytit", 306, "RRR"),
    new Kerailykortti("Laser-Löylykauha", "Suomi-Myytit", 307, "UR"),
    new Kerailykortti("Toripoliisi", "Suomi-Myytit", 308, "R")
));

Kirjoita main()-ohjelma, joka järjestää ja tulostaa keräilykortit harvinaisuuden mukaan (yleisimmät kortit ensin, harvinaisimmat viimeiseksi). Kortit, joiden harvinaisuus on jokin muu kuin yllä mainitut tai null, tulee sijoittaa listan alkuun.

Tee tehtävä TIMissa

Parametri, jota ei käytetä

Käyttöliittymäkoodissa tulee usein vastaan tilanteita, joissa aliohjelman määrittely vaatii parametrin, mutta toteutus ei tarvitse sitä. Tyypillinen esimerkki on tapahtumankuuntelija: rajapinta edellyttää tapahtumaolion vastaanottamista, vaikka itse tapahtumatietoa ei käytettäisi.

button.setOnAction(event -> {
    IO.println("Nappia painettu!");
});

Tästä voi tulla ohjelmointiympäristössä varoitus käyttämättömästä event-parametrista. Varoitus on yleensä hyödyllinen, mutta tässä tapauksessa kyse ei ole virheestä.

Koska parametria ei voi jättää kokonaan pois, Javassa sille voidaan antaa nimeämätön muoto kirjoittamalla nimen paikalle _. Java 22:sta alkaen tämä on virallinen kielen ominaisuus, jota kutsutaan nimeämättömäksi muuttujaksi (unnamed variable).

button.setOnAction(_ -> {
    // Tämä tapahtumankuuntelija ei tarvitse tapahtumatietoja, joten
    // parametri voidaan hylätä nimellä "_"
    IO.println("Nappia painettu!");
});

Tällöin ilmaistaan suoraan, että parametrin arvoa ei tarvita. Alaviivalla määriteltyyn parametriin ei voi viitata myöhemmin koodissa, ja mahdollinen "parameter is never used" -varoitus jää pois.

Vastaavanlainen käytäntö on käytössä myös joissakin muissa kielissä, kuten C#:ssa. Javassa kyse ei kuitenkaan ole enää pelkästä konventiosta, vaan virallisesta kieliominaisuudesta.

Kokoelmien käsittely: Stream API

osaamistavoitteet

  • Ymmärrät deklaratiivisen ja imperatiivisen ohjelmoinnin eron kokoelmien käsittelyssä
  • Tunnet Stream API:n keskeiset käsitteet (väli- ja lopetusoperaatiot)
  • Osaat käyttää striimejä kokoelmien suodattamiseen, muuntamiseen ja lajitteluun
  • Osaat hyödyntää Optional-tyyppiä mahdollisesti puuttuvien arvojen käsittelyssä
  • Tunnet perustietotyypeille tarkoitetut striimit, kuten IntStream ja DoubleStream

Olemme toistaiseksi käyttäneet silmukoita kokoelmien käsittelyyn. Jos haluamme esimerkiksi laskea listasta jokaisen parillisen alkion summan, kirjoitamme ratkaisun tavallisesti näin:

void main() {
List<Integer> numeroita = List.of(508, 18, 17, -148, 67, 42, -41);
int summa = 0;
for (int numero : numeroita) {
    if (numero % 2 == 0) {
      summa += numero;
    }
}
IO.println("Summa: " + summa);
}

Tätä ohjelmointitapaa kutsutaan imperatiiviseksi. Siinä kirjoitamme vaihe vaiheelta, mitä tietokoneen pitää tehdä, jonka seurauksena pääsemme lopputulokseen, jonka tiedämme haluavamme.

Datan prosessoinnissa on kuitenkin usein selkeämpää kuvata, millaisen lopputuloksen haluamme, sen sijaan että kertoisimme tarkat suoritusvaiheet. Tätä kutsutaan deklaratiiviseksi ohjelmoinniksi. Javan Stream API tarjoaa tähän työkalut hyödyntämällä lambdalausekkeita. Sen avulla voimme korvata yllä olevan silmukan yhdellä rivillä:

void main() {
List<Integer> numeroita = List.of(508, 18, 17, -148, 67, 42, -41);
int summa = numeroita.stream().filter(i -> i % 2 == 0).mapToInt(Integer::intValue).sum();
IO.println("Summa: " + summa);
}

Striimien perustoiminta

Tarkastellaan yllä olevaa esimerkkiä tarkemmin. Huomaamme, että rivi koostuu neljästä eri osasta:

numeroita                       //    Käsiteltävä kokoelma
  .stream()                     // 1. Muunto striimiksi
  .filter(i -> i % 2 == 0)      // 2. Suodatus
  .mapToInt(Integer::intValue)  // 3. Muunnos perustietotyypiksi
  .sum();                       // 4. Arvon laskeminen

Käydään jokainen vaihe läpi.

1. Kokoelman muuntaminen striimiksi

Kaikilla Javan kokoelmilla on stream()-metodi, joka palauttaa Stream<T>-tyyppisen olion eli striimin (JavaDoc). Striimiä voi ajatella liukuhihnana tai koneena, joka ottaa kokoelman ja tuottaa siitä yhden alkion kerrallaan tietovirtana:

42671481718508tietovirtaStream

2. Suodatinfunktio filter

Striimin filter() on metodi, joka suorittaa parametrina annetun lambdalausekkeen jokaiselle alkiolle. Jos lauseke palauttaa alkiolle true, alkio jatkaa eteenpäin tietovirrassa. Jos taas lauseke palauttaa false, alkio poistetaan tietovirrasta.

Toisin sanoen, filter() on eräänlainen suodatin, joka lambdalausekkeen perusteella joko antaa alkion mennä läpi tai suodattaa sen pois:

426714814818508falsepois17tietovirtaStreamfiltertruei%2==0eteenpäin

3. Muunnosfunktio map

Striimien tärkeimpiä työkaluja ovat erilaiset muunnokset eli kuvaukset. Nämä metodit alkavat yleensä sanalla map. Ne ottavat yhden alkion kerrallaan ja muuttavat sen joksikin muuksi.

Esimerkiksi mapToInt() on muunnos, joka ottaa alkion ja muuttaa sen int-tyyppiseksi luvuksi annetun funktion avulla. Tässä käytämme Integer::intValue -funktioviitettä, joka muuttaa Integer-olion tavalliseksi kokonaisluvuksi.

4267148int14818508tietovirtaStreamfiltermapToInti%2==0Integer::intValue

Tarvitsemme tämän vaiheen siksi, että yleinen Stream<T> on geneerinen, ja Javassa geneeristen tyyppien sisällä ei voi olla perustietotyyppejä (kuten int). Kutsumalla mapToInt() striimi muuttuu IntStream-tyyppiseksi (JavaDoc). IntStream on optimoitu kokonaislukujen käsittelyyn ja se tarjoaa valmiita tilastollisia metodeja, kuten sum().

4. Arvon laskeminen

Striimin päätteeksi kutsumme aina jotain lopetusfunktiota. Se ottaa vastaan tietovirran lopussa olevat alkiot ja palauttaa ne ohjelmalle halutussa muodossa (esim. summana tai listana).

Tässä esimerkissä käytimme sum()-metodia, joka laskee luvut yhteen ja palauttaa lopputuloksen yhtenä lukuna:

4267148tietovirta5081842StreamfiltermapToIntsum420i%2==0Integer::intValue-148

Striimien käyttäminen

Kaikki, mitä on mahdollista tehdä striimeillä, voitaisiin kirjoittaa myös tavallisina silmukoina. Kuitenkin yhdistämällä eri Stream API -funktioita saamme usein hyvin ytimekkäitä ratkaisuja ongelmiin, jotka muuten vaatisivat useita rivejä imperatiivista koodia.

Striimien luominen

Yleisin tapa on luoda striimi suoraan kokoelmasta. Kaikilla Javan Collection-rajapinnan toteuttavilla luokilla on stream()-metodi:

void main() {
List<String> hedelmia = List.of("omena", "päärynä", "appelsiini");
Map<String, Integer> asukaslukuja = Map.of(
  "Helsinki", 695526,
  "Tampere", 263526,
  "Jyväskylä", 149967
);
Set<String> automerkkeja = Set.of("BMW", "Audi", "Hyundai", "Volvo");

Stream<String> hedelmiaStream = hedelmia.stream();
Stream<Map.Entry<String, Integer>> asukaslukujaStream = asukaslukuja.entrySet().stream();
Stream<String> automerkkejaStream = automerkkeja.stream();

IO.println("Hedelmiä, jossa ei ole p-kirjainta: " + hedelmiaStream.filter(h -> !h.contains("p")).toList());
IO.println("Kaupunki, jossa on eniten asukkaita: " + asukaslukujaStream.max(Comparator.comparing(Map.Entry::getValue)).get().getKey());
IO.println("Automerkkien nimet yhdistettynä: " + automerkkejaStream.reduce("", (p, n) -> p + n));
}

Myös taulukoista voidaan luoda striimi Arrays.stream-metodilla:

void main() {
int[] arvosanoja = {5, 1, 2, 3, 4, 5, 2, 5, 5, 4};
String[] opettajat = {"Denis", "Antti-Jussi", "Sami", "Karri"};

IntStream arvosanojaStream = Arrays.stream(arvosanoja);
Stream<String> opettajatStream = Arrays.stream(opettajat);

IO.println("Arvosanojen keskiarvo: " + arvosanojaStream.average().getAsDouble());
IO.println("Opettaja, jolla on pisin etunimi: " + opettajatStream.max(Comparator.comparing(String::length)).get());
}

Mainittakoon tässä vaiheessa, että perustietotyypeille on olemassa omat striimiluokat IntStream, DoubleStream, LongStream, jne. Nämä erikoisluokat tarjoavat muun muassa erilaisia tilastofunktioita, kuten max, min, average ja sum. Kokoelmien tapauksessa perustietotyypit kääritään kuitenkin aina käärijäluokkaan, jolloin striimit ovat muotoa Stream<Integer>, Stream<Double>, Stream<Long>. Stream-luokka tarjoaa aiemmin mainitut mapToInt, mapToDouble ja vastaavia metodeja, jolla striimin voi muuttaa perustietotyyppiversioon.

Voimme myös luoda striimejä, jotka tuottavat äärettömästi arvoja. Esimerkiksi Stream.generate kutsuu annettua funktiota toistuvasti. Tällöin on käytettävä alkioita rajoittavia metodeja, kuten limit, joka pysäyttää tietovirran halutun määrän jälkeen:

void main() {
Stream<String> risuaitoja = Stream.generate(() -> "#");
List<String> kymmenenRisuaitaa = risuaitoja.limit(10).toList();
IO.println(kymmenenRisuaitaa);
}

Striimin välioperaatiot

Kaikki striimin metodit, jotka palauttavat uuden Stream-olion, ovat ns. välioperaatioita (engl. intermediate operations). Niitä käytetään tietovirrassa liikkuvien alkioiden muokkaamiseen ja suodattamiseen.

Kuvitellaan, että ylläpidämme kaupan ostostietoja. Haluamme selvittää syyskuun ostosten keskihinnan.

public class Ostotapahtuma {
  private double hinta;
  private LocalDate pvm;
}

Sen sijaan, että kirjoittaisimme silmukan ja if-ehtoja, rakennetaan haluttua tulosta antavan striimin vaihe vaiheelta. Aloitetaan ensin ottamalla mukaan vain syyskuun mukaan. Voimme käyttää filter()-metodia, joka suodattaa striimistä alkioita annetun boolean-funktion perusteella:

main.java
void main() {
  List<Ostotapahtuma> ostotapahtumat = List.of(
    new Ostotapahtuma(100.0, LocalDate.of(2025, Month.JANUARY, 2)),
    new Ostotapahtuma(21.5, LocalDate.of(2025, Month.JULY, 3)),
    new Ostotapahtuma(12.0, LocalDate.of(2025, Month.SEPTEMBER, 1)),
    new Ostotapahtuma(5.25, LocalDate.of(2025, Month.SEPTEMBER, 12)),
    new Ostotapahtuma(245.0, LocalDate.of(2025, Month.SEPTEMBER, 21)),
    new Ostotapahtuma(342.0, LocalDate.of(2025, Month.OCTOBER, 2))
  );

  Stream<Ostotapahtuma> vainSyyskuu = 
          ostotapahtumat.stream()
                        .filter(o -> o.getPvm().getMonth() == Month.SEPTEMBER);

  vainSyyskuu.forEach(IO::println);
}

Nyt kun meillä on vain syyskuun ostokset suodatettu mukaan, haluamme laskea niiden keskiarvohinnan. Keskiarvo voidaan laskea vain luvuista, kun taas ostotapahtuma on Ostotapahtuma-tyyppinen. Voimme käyttää striimin map-metodia, joka muuntaa jokaisen alkion arvon toiseksi annetun muunnosfunktion perusteella. Meidän muunnosfunktiossa riittää hakea Ostotapahtuma-olion hinta-attribuutti, jolloin näin saadaan striimin luvuista:

main.java
void main() {
  List<Ostotapahtuma> ostotapahtumat = List.of(
    new Ostotapahtuma(100.0, LocalDate.of(2025, Month.JANUARY, 2)),
    new Ostotapahtuma(21.5, LocalDate.of(2025, Month.JULY, 3)),
    new Ostotapahtuma(12.0, LocalDate.of(2025, Month.SEPTEMBER, 1)),
    new Ostotapahtuma(5.25, LocalDate.of(2025, Month.SEPTEMBER, 12)),
    new Ostotapahtuma(245.0, LocalDate.of(2025, Month.SEPTEMBER, 21)),
    new Ostotapahtuma(342.0, LocalDate.of(2025, Month.OCTOBER, 2))
  );

  Stream<Double> syyskuunHinnat = 
          ostotapahtumat.stream()
                        .filter(o -> o.getPvm().getMonth() == Month.SEPTEMBER)
// HIGHLIGHT_GREEN_BEGIN
                        .map(o -> o.getHinta());
// HIGHLIGHT_GREEN_END

  syyskuunHinnat.forEach(IO::println);
}

Huomaa, että tuloksena on Stream<Double>, eli käärijäluokkaan tallennettu liukuluku. Jotta keskiarvon laskenta olisi helpompaa, muunnetaan Double-alkiot perustyyppiinsä mapToDouble()-metodilla:

main.java
void main() {
  List<Ostotapahtuma> ostotapahtumat = List.of(
    new Ostotapahtuma(100.0, LocalDate.of(2025, Month.JANUARY, 2)),
    new Ostotapahtuma(21.5, LocalDate.of(2025, Month.JULY, 3)),
    new Ostotapahtuma(12.0, LocalDate.of(2025, Month.SEPTEMBER, 1)),
    new Ostotapahtuma(5.25, LocalDate.of(2025, Month.SEPTEMBER, 12)),
    new Ostotapahtuma(245.0, LocalDate.of(2025, Month.SEPTEMBER, 21)),
    new Ostotapahtuma(342.0, LocalDate.of(2025, Month.OCTOBER, 2))
  );

// HIGHLIGHT_YELLOW_BEGIN
  DoubleStream syyskuunHinnat = 
// HIGHLIGHT_YELLOW_END
          ostotapahtumat.stream()
                        .filter(o -> o.getPvm().getMonth() == Month.SEPTEMBER)
                        .map(o -> o.getHinta())
// HIGHLIGHT_GREEN_BEGIN
                        .mapToDouble(d -> d.doubleValue());
// HIGHLIGHT_GREEN_END

  syyskuunHinnat.forEach(IO::println);
}

DoubleStream sisältää valmiiksi average()-metodin, joka kerää ja palauttaa striimissä olevien alkioiden keskiarvon:

main.java
void main() {
  List<Ostotapahtuma> ostotapahtumat = List.of(
    new Ostotapahtuma(100.0, LocalDate.of(2025, Month.JANUARY, 2)),
    new Ostotapahtuma(21.5, LocalDate.of(2025, Month.JULY, 3)),
    new Ostotapahtuma(12.0, LocalDate.of(2025, Month.SEPTEMBER, 1)),
    new Ostotapahtuma(5.25, LocalDate.of(2025, Month.SEPTEMBER, 12)),
    new Ostotapahtuma(245.0, LocalDate.of(2025, Month.SEPTEMBER, 21)),
    new Ostotapahtuma(342.0, LocalDate.of(2025, Month.OCTOBER, 2))
  );

  OptionalDouble syyskuunKeskiarvo = 
          ostotapahtumat.stream()
                        .filter(o -> o.getPvm().getMonth() == Month.SEPTEMBER)
                        .map(o -> o.getHinta())
                        .mapToDouble(d -> d.doubleValue())
// HIGHLIGHT_GREEN_BEGIN
                        .average();
// HIGHLIGHT_GREEN_END

  IO.println(syyskuunKeskiarvo);
}

Huomaa, että average() ei palauta suoraan double-arvoa, vaan OptionalDouble-olion. Tämä johtuu siitä, että jos striimi on tyhjä (esimerkiksi yhtään syyskuun ostosta ei löytyisi), keskiarvoa ei voida laskea. Palaamme tähän hieman alempana.

Striimien lopetusoperaatiot

Kaikki striimin metodit, jotka palauttavat jotain muuta kuin uuden striimin, ovat lopetusoperaatioita (engl. terminal operations). Lopetusoperaatiot yleensä käyvät läpi striimissä kaikki alkiot ja tuottavat arvon tai sivuvaikutuksen.

Eräs tavallinen lopetusoperaatio on striimin alkioiden kerääminen kokoelmaksi. Esimerkiksi toList() kerää striimin alkiot listaksi ja toArray() taulukoksi:

void main() {
List<Integer> arvosanoja = List.of(1, 4, 5, -1, 0, 15, 2, 4, 5);

List<Integer> oikeitaArvosanoja = arvosanoja.stream()
                                    .filter(i -> 1 <= i && i <= 5)
                                    .sorted()
                                    .toList();

int[] oikeitaArvosanojaTaulu = arvosanoja.stream()
                                    .filter(i -> 1 <= i && i <= 5)
                                    .mapToInt(i -> i.intValue())
                                    .sorted()
                                    .toArray();

IO.println(oikeitaArvosanoja);
IO.println(Arrays.toString(oikeitaArvosanojaTaulu));
}

Huomaa, että lopetusoperaation jälkeen striimi yleensä lasketaan käytetyksi, eikä jo käytettyä striimiä voi enää yleensä käyttää sen jälkeen, vaan tarvitaan uusi striimi. Jo käytetyn striimin uudelleenkäyttäminen aiheuttaa yleensä virheen:

void main() {
List<Integer> arvosanoja = List.of(1, 4, 5, -1, 0, 15, 2, 4, 5);

Stream<Integer> arvosanojaStream = arvosanoja.stream()
                                       .filter(i -> 1 <= i && i <= 5)
                                       .sorted();
// toList() lopettaa striimin
List<Integer> arvosanojaLista = arvosanojaStream.toList();

// VIRHE: yritetään käyttää jo käytettyä striimiä
long arvosanatLkm = arvosanojaStream.count();
}
java.lang.IllegalStateException: stream has already been operated upon or closed

Kuten kokoelmissa, myös striimeissä on forEach()-metodi, jonka avulla voi suorittaa mielivaltaista koodia jokaiselle alkiolle:

void main() {
IntStream.range(0, 10)      // Striimi kokonaisluvuista 0-9
  .filter(i -> i % 2 == 1)  // Otetaan vain parittomat kokonaisluvut
  .forEach(IO::println);    // Suoritetaan IO.println jokaiselle luvulle
}

Striimit sisältävät myös muutaman apufunktion yleisempiin ongelmiin. min() ja max() -metodit keräävät striimin alkiot ja palauttavat alkioista suurimman. Kummatkin metodit ottavat parametrina Comparator-vertailijafunktion.

void main() {
List<String> opet = List.of("Denis", "Antti-Jussi", "Sami", "Karri");

Optional<String> pisinNimi = opet.stream().max(Comparator.comparing(String::length));
Optional<String> lyhinNimi = opet.stream().min(Comparator.comparing(String::length));

IO.println("Pisin: " + pisinNimi);
IO.println("Lyhin: " + lyhinNimi);
}

Huomaa, että max(), min() ja monet muut striimin lopetusfunktiot eivät palauta arvoja suoraan, vaan Optional<T>-olion (JavaDoc). Nimensä mukaan tällainen olio kuvastaa arvon mahdollista puuttumista. Esimerkiksi, jos striimissä ei ole yhtään alkiota tai jos lopetusfunktio ei voi muuten laskea arvoa, se palauttaa Optional.empty-arvon kuvastamaan laskennan epäonnistumista:

void main() {
List<String> opet = List.of("Denis", "Antti-Jussi", "Sami", "Karri");

Optional<String> pisinNimi = opet.stream()
                    .filter(s -> s.startsWith("V"))
                    .max(Comparator.comparing(String::length));

IO.println("Pisin: " + pisinNimi);
}

Ennen kuin palautettua arvoa voi käyttää, tulee ensin tarkistaa, sisältääkö Optional<T>-olio tuloksen. Tämä onnistuu esimerkiksi isPresent()-metodilla. Kun tiedetään, että arvo on olemassa, se voidaan hakea get()-metodilla:

void main() {
List<String> opet = List.of("Denis", "Antti-Jussi", "Sami", "Karri");

Optional<String> pisinNimi = opet.stream().max(Comparator.comparing(String::length));

if (pisinNimi.isPresent()) {
  String nimi = pisinNimi.get();

  IO.println("Pisin: " + nimi);
} else {
  IO.println("Annetuilla ehdoilla ei löytynyt yhtään nimeä");
}
}

Mainittakoon, että Optional<T>-tyyppi sisältää joukon muita apufunktioita, joilla voi välttyä ylimääräisiltä if-rakenteilta.

Palataan vielä hetkeksi striimeihin. Striimit soveltuvat kätevästi arvojen etsimiseen kokoelmista; findFirst()-metodi palauttaa ensimmäisen alkion, joka pääsee "tietovirran loppuun" asti. Esimerkiksi, jos haluaisimme löytää varastosovelluksesta ostotapahtuman, joka oli tehty syyskuussa ja ylittänyt hinnaltaan 100 €:

main.java
void main() {
List<Ostotapahtuma> ostotapahtumat = List.of(
  new Ostotapahtuma(100.0, LocalDate.of(2025, Month.JANUARY, 2)),
  new Ostotapahtuma(21.5, LocalDate.of(2025, Month.JULY, 3)),
  new Ostotapahtuma(12.0, LocalDate.of(2025, Month.SEPTEMBER, 1)),
  new Ostotapahtuma(5.25, LocalDate.of(2025, Month.SEPTEMBER, 12)),
  new Ostotapahtuma(245.0, LocalDate.of(2025, Month.SEPTEMBER, 21)),
  new Ostotapahtuma(342.0, LocalDate.of(2025, Month.OCTOBER, 2))
);
 
Optional<Ostotapahtuma> tapahtuma = 
                  ostotapahtumat.stream()
                      .filter(o -> o.getPvm().getMonth() == Month.SEPTEMBER)
                      .filter(o -> o.getHinta() > 100.0)
                      .findFirst();

if (tapahtuma.isPresent()) {
  IO.println(tapahtuma.get());
} else {
  IO.println("Tapahtumaa ei löytynyt");
}

}

Lopuksi, striimeillä on myös joitain tilastoihin liittyviä operaatioita. Esimerkiksi aiemmin koodissa mainittu count()-metodi palauttaa kokonaislukuna, kuinka monta alkiota striimissä on. Lisäksi perustietotyypeille tarkoitetuissa striimeissä IntStream, DoubleStream ja LongStream löytyy muun muassa seuraavia tilastometodeja:

  • sum() - summaa luvut yhteen
  • min()/max() - etsii pienimmän/suurimman luvun
  • average() - laskee lukujen keskiarvon
  • summaryStatistics() - laskee kerrallaan summan, suurimman, pienimmän luvut ja keskiarvon
void main() {
IntStream lukuja = new Random().ints(20, 0, 100);
IO.println(lukuja.summaryStatistics());
}
Tehtävä 6.3: Musiikkilista 1 p.

Olkoon käytössä luokka Kappale, joka edustaa yksittäistä musiikkikappaletta. Kappaleella on nimi, genre ja kesto sekunteina:

class Kappale {
    String nimi;
    String genre;
    int kestoSekunteina;
}

Lisää luokalle tarvittavat näkyvyysmääreet, muodostaja, tarpeelliset saantimetodit (getterit) sekä sopiva toString()-metodin toteutus.

Tee funktio teeSoittolista(kappaleet, genre, kappaleita), joka palauttaa korkeintaan kappaleita-muuttujan ilmoittaman määrän kappaleita, joiden genre vastaa annettua genre-parametria. Kappaleiden tulee olla järjestettynä keston mukaan lyhyemmästä pisimpään.

Voit käyttää seuraavaa mallilistaa koodisi testaamiseen:

Lista mallikappaleista
List<Kappale> biisilista = List.of(
    new Kappale("Bohemian Rhapsody", "Rock", 354),
    new Kappale("Levitating", "Pop", 203),
    new Kappale("Sandstorm", "Electronic", 225),
    new Kappale("Paranoid", "Metal", 168),
    new Kappale("Toxic", "Pop", 198),
    new Kappale("Master of Puppets", "Metal", 515),
    new Kappale("Cha Cha Cha", "Pop", 175),
    new Kappale("Hotel California", "Rock", 390),
    new Kappale("Stay", "Pop", 141),
    new Kappale("Enter Sandman", "Metal", 331),
    new Kappale("Bad Romance", "Pop", 295),
    new Kappale("Midnight City", "Electronic", 243),
    new Kappale("Billie Jean", "Pop", 294),
    new Kappale("Hard Rock Hallelujah", "Metal", 247),
    new Kappale("Thriller", "Pop", 357),
    new Kappale("As It Was", "Pop", 167),
    new Kappale("Paint It, Black", "Rock", 202),
    new Kappale("Hollywood Hills", "Rock", 210),
    new Kappale("Get Lucky", "Electronic", 369),
    new Kappale("Shake It Off", "Pop", 219),
    new Kappale("Ace of Spades", "Metal", 169),
    new Kappale("Rolling in the Deep", "Pop", 228),
    new Kappale("Sweet Child O' Mine", "Rock", 356),
    new Kappale("Borderline", "Pop", 210),
    new Kappale("Back in Black", "Rock", 255),
    new Kappale("Shape of You", "Pop", 233),
    new Kappale("Fear of the Dark", "Metal", 438),
    new Kappale("Blinding Lights", "Pop", 200),
    new Kappale("Stairway to Heaven", "Rock", 482),
    new Kappale("Uptown Funk", "Pop", 269),
    new Kappale("Smells Like Teen Spirit", "Rock", 301),
    new Kappale("Short Pop Song", "Pop", 120)
);

Älä käytä silmukoita, vaan toteuta teeSoittolista käyttäen striimejä.

Vinkki

Saatat tarvita ainakin seuraavia Stream-metodeja:

  • filter(): alkioiden suodatus
  • sorted(): alkioiden järjestäminen
  • limit(): alkioiden lukumäärän rajaaminen
  • toList(): alkioiden kerääminen listaksi
Tee tehtävä TIMissa
Tehtävä 6.4: Keskiarvo raja-arvoilla 1 p.

Tee funktio double keskiarvo(int[] luvut, int minimi, int maksimi). Funktio laskee taulukkona annettujen lukujen keskiarvon seuraavilla ehdoilla:

  • Jos alkio on pienempi tai yhtä suuri kuin minimi, alkio hylätään eikä sitä lasketa keskiarvoon mukaan.
  • Jos alkio on suurempi tai yhtä suuri kuin maksimi, kyseinen alkio ja kaikki sen jälkeen tulevat alkiot hylätään.

Esimerkki:

IO.println(keskiarvo(new int[] { -5, 1, -4, 0, 98 }, -7, 99));
IO.println(keskiarvo(new int[] { 11, 4, 2, 6, 99, 12, 0, -3 }, 3, 99));
IO.println(keskiarvo(new int[] { 99, 1, 2, 3 }, 0, 99));
18.0
7.0
0.0

Ensimmäinen kutsu palauttaa 18.0, sillä aineisto on kokonaisuudessaan minimin ja maksimin välissä. Toinen kutsu palauttaa taas 7.0, sillä vain luvut 11, 4 ja 6 otetaan keskiarvoon mukaan: luku 2 on pienempi kuin minimi ja kaikki luvusta 99 alkaen hylätään.

Jos keskiarvoa ei voida laskea, funktio palauttaa minimi-parametrin arvon.

Älä käytä silmukoita, vaan toteuta funktio käyttäen striimejä.

Vinkki

Tutustu IntStream-tyyppiin (JavaDoc) ja sen metodeihin. Voit hyötyä ainakin seuraavista:

  • filter(): alkioiden suodattaminen pois striimistä
  • takeWhile(): ottaa alkioita striimistä niin kauan kuin ehto on tosi; heti kun ehto on epätosi, striimin käsittely loppuu siihen (kuin "hana", joka suljetaan).
  • average(): laskee keskiarvon

Huomaa, että average() palauttaa OptionalDouble-olion (JavaDoc). Olio sisältää orElse()-metodin, jonka avulla voit palauttaa joko lasketun arvon tai vaihtoehtoisen oletusarvon.

Bonustieto

Samankaltainen tehtävä tehdään Ohjelmointi 1 -kurssilla käyttäen silmukoita (ks. Ohjelmointi 1: demo 6, tehtävä B1). Jos olet suorittanut kyseisen kurssin, voit verrata striimeillä tehtyä ratkaisuasi aiemmin tekemääsi silmukkaratkaisuun.

Tee tehtävä TIMissa

Poikkeusten hallinta

osaamistavoitteet

  • Ymmärrät, mikä poikkeus on ja miten se eroaa ohjelman normaalista käskynkulusta.
  • Tiedät eron tarkastettujen (checked) ja tarkastamattomien (unchecked) poikkeusten välillä.
  • Osaat käsitellä poikkeuksia try-catch-finally -rakenteella.
  • Osaat heittää poikkeuksia (throw) ja ilmoittaa niistä metodin määrittelyssä (throws).
  • Ymmärrät poikkeusten hyödyt koodin luettavuuden ja virhehallinnan kannalta.
  • Osaat luoda omia poikkeusluokkia.

Poikkeus (engl. exception; lyhennetty muoto ilmauksesta exceptional event), on tilanne, joka syntyy ohjelman suorituksen aikana ja keskeyttää ohjelman normaalin käskynkulun. Poikkeus voi syntyä esimerkiksi seuraavissa tilanteissa:

  • Käyttäjä antaa virheellisen syötteen (esimerkiksi tekstin, kun odotetaan numeroa).
  • Yritetään lukea tiedostoa, jota ei ole olemassa.
  • Verkko- tai tietokantayhteys katkeaa kesken suorituksen.
  • Jaetaan nollalla laskutoimituksessa.
  • Viitataan listan tai taulukon indeksiin, jota ei ole olemassa.
  • Kutsutaan metodia olion viittauksella, joka on null.

Javassa poikkeusten hallintaan on sisäänrakennettu mekanismi, joka mahdollistaa virhetilanteiden hallitun käsittelyn ohjelmakoodissa. Tämän mekanismin avulla ohjelmoija voi määritellä, miten ohjelman tulee reagoida erilaisiin virhetilanteisiin ilman, että koko ohjelma kaatuu.

Poikkeusten heittäminen ja käsittely

Poikkeusten hallinta Javassa perustuu kolmeen pääkomponenttiin:

  1. Poikkeusten heittäminen (engl. throwing exceptions): Kun virhe tapahtuu, poikkeusolio luodaan joko ohjelmakoodissa tai JVM:n toimesta. Tämän jälkeen JVM keskeyttää normaalin suoritusvirran ja alkaa etsiä sopivaa käsittelijää kutsupinosta.
  2. Poikkeusten käsittely (engl. catching exceptions): Ohjelmoija voi määritellä koodilohkoja, jotka käsittelevät tiettyjä poikkeuksia try-catch-rakenteella.
  3. Lopullinen varmistus (engl. finally): finally-lohko, joka suoritetaan aina riippumatta siitä, tapahtuiko poikkeus vai ei, esimerkiksi resurssien vapauttamiseksi tai tilan palauttamiseksi.

Palaamme esimerkkeihin kohta, mutta pohditaan ensin järjestelmän toimintaa korkealla tasolla.

Kun metodin suorituksen aikana tapahtuu virhe, metodi luo virhettä kuvaavan poikkeusolion ja luovuttaa sen ajonaikaiselle järjestelmälle. Poikkeusolio sisältää muun muassa poikkeuksen tyypin sekä ohjelman tilan sillä hetkellä, kun virhe tapahtui. Tätä poikkeusolion luomista ja luovuttamista ajonaikaiselle järjestelmälle kutsutaan poikkeuksen heittämiseksi.

Kun poikkeus on heitetty, ajonaikainen järjestelmä alkaa etsiä poikkeukselle sopivaa käsittelijää (engl. exception handler) kutsupinosta siinä järjestyksessä, jossa metodeja on kutsuttu. Käsittelijä on sopiva, jos se pystyy käsittelemään heitetyn poikkeusolion tyypin mukaisen poikkeusolion. Jos sopiva käsittelijä löytyy, poikkeus välitetään sille. Tällöin sanotaan, että poikkeus "otetaan kiinni" (engl. catch).

Jos järjestelmä käy läpi koko kutsupinon löytämättä sopivaa käsittelijää, se säie (engl. thread), jossa virhe tapahtui, pysähtyy. Jos kyseessä on ohjelman pääsäie (engl. main thread), koko ohjelma kaatuu.

Alla oleva kaavio havainnollistaa karkeasti poikkeuksen heittämisen ja käsittelyn prosessia. Oletetaan, että main()-metodi kutsuu metodia a(), joka puolestaan kutsuu metodia b(), ja b() edelleen c()-metodia.

main()kutsuukutsuukutsuu.Heittää poikkeuksenc()Metodi, jossa virhetapahtuiEtsitäänsopivaakäsittelijääHeittää poikkeuksenb()Metodi, jossa ei oleeteenpäinpoikkeuskäsittelijääEtsitäänsopivaakäsittelijääOttaa poikkeuksena()Metodi, jossa onkiinnipoikkeuskäsittelijä

Esimerkissä tapahtuu seuraavaa:

  1. Metodi c() heittää poikkeuksen, esimerkiksi käsitellessään verkkoyhteyttä, mutta metodissa c() ei ole sopivaa käsittelijää.
  2. Ajonaikainen järjestelmä katsoo kutsupinossa seuraavana olevaa metodia, joka on b().
  3. Metodi b():llä ei myöskään ole sopivaa käsittelijää, joten tutkitaan edelleen seuraavaa metodia, joka on a().
  4. a()-metodilla on sopiva käsittelijä, joka ottaa poikkeuksen kiinni.
  5. Ohjelma jatkaa suoritustaan, kun a()-metodissa oleva käsittelijä on suoritettu.

Tarkastetut ja tarkastamattomat poikkeukset

Javassa poikkeukset jaetaan kahteen kategoriaan: tarkastettuihin (engl. checked) ja tarkastamattomiin (engl. unchecked). Nimitys tulee siitä, että tarkastettujen poikkeusten kohdalla käsittelyn olemassaolo tarkastetaan käännösaikana, kun taas tarkastamattomien poikkeusten käsittelyä ei tarkasteta.

Tarkastetut poikkeukset kuvaavat ympäristöstä tai syötteestä johtuvia ongelmia joihin ohjelma voi usein reagoida hallitusti. Tyypillisiä esimerkkejä ovat tiedostojen käsittely, verkkoyhteydet ja tietokantatoiminnot. Esimerkiksi tiedoston avaaminen voi epäonnistua, koska tiedostoa ei ole olemassa tai siihen ei ole lukuoikeuksia, vaikka ohjelmakoodi itsessään olisi täysin oikein. Tarkastettuja poikkeuksia ovat esimerkiksi

  • IOException, joka kuvaa syötteeseen tai tulosteeseen liittyviä ongelmia, kuten tiedoston lukemisen epäonnistumista, ja
  • SQLException, joka liittyy tietokantatoimintoihin.

Tarkastetut poikkeukset periytyvät Exception-luokasta.

Kun kääntäjä kohtaa koodin, joka voi heittää tarkastetun poikkeuksen, se ikään kuin ilmoittaa ohjelmoijalle: "Näen, että olet tekemässä jotain riskialtista (kuten lukemassa tiedostoa). En käännä ohjelmaasi, ennen kuin olet osoittanut, että olet ottanut huomioon mahdolliset ongelmat."

Tällöin on tehtävä jompikumpi seuraavista:

  1. käsiteltävä poikkeus try–catch-rakenteella (vrt. ylemmän kuvion a()-metodi), tai
  2. ilmoitettava metodin määrittelyssä throws-määreellä, että poikkeus voi siirtyä kutsujalle (vrt. ylemmän kuvion b()- ja c()-metodit).

Jos kumpaakaan ei tehdä, koodi ei käänny. Tätä vaatimusta kutsutaan catch or specify -vaatimukseksi.

Tarkastamaton poikkeus on poikkeus, jota ei tarkasteta käännösaikana, eikä sitä siten tarvitse käsitellä tai ilmoittaa etukäteen. Tällainen poikkeus voi kuitenkin laueta ohjelman ohjelman suorituksen aikana. Tyypillisiä tarkastamattomia poikkeuksia ovat

  • NullPointerException, joka tapahtuu, kun yritetään käyttää olion viitettä, joka on null,
  • IllegalArgumentException, joka tapahtuu, kun metodille annetaan sopimaton argumentti,
  • ArrayIndexOutOfBoundsException, joka tapahtuu, kun yritetään käyttää taulukon indeksiä, joka on taulukon raja-arvojen ulkopuolella, ja
  • ArithmeticException, joka tapahtuu, kun tapahtuu laskuvirhe, kuten jakaminen nollalla.

Tarkastamattomat poikkeukset periytyvät RuntimeException-luokasta.

Tarkastamattomat poikkeukset kuvaavat yleensä ohjelmointivirheitä, ja näiden käsittelemistä try-catch-rakenteella ei vaadita lähdekoodissa, eikä se ole myöskään suositeltavaa. Esimerkiksi NullPointerException-poikkeus on usein selvä merkki ohjelmassa olevasta virheestä. Vaikka try-catch-rakenteella voikin kyllä ottaa NullPointerException-poikkeuksen kiinni, se ei yleensä ole järkevää, koska se vain peittää ohjelman virheen sen sijaan, että korjaisi sen.

Syntaksi

try-catch-rakenne näyttää seuraavalta:

try {
    // Koodilohko, jossa poikkeus voi tapahtua
} catch (Poikkeustyyppi poikkeus) {
    // Koodilohko, joka käsittelee poikkeuksen
}

throws-määre puolestaan määritellään metodin allekirjoituksessa seuraavasti:

void metodi() throws Poikkeustyyppi {
    // Metodin toteutus, joka voi heittää poikkeuksen
}

Poikkeuksia voi olla useita, ja ne voidaan käsitellä erikseen:

try {
    // Koodilohko, jossa poikkeus voi tapahtua
} catch (Poikkeustyyppi1 e1) {
    // Koodilohko, joka käsittelee Poikkeustyyppi1 -poikkeuksen
} catch (Poikkeustyyppi2 e2) {
    // Koodilohko, joka käsittelee Poikkeustyyppi2 -poikkeuksen
}

Jos metodi voi heittää useita tarkastettuja poikkeuksia, ne voidaan ilmoittaa throws-määreessä pilkulla erotettuna:

void metodi() throws Poikkeustyyppi1, Poikkeustyyppi2 {
    // Metodin toteutus, joka voi heittää useita poikkeuksia
}

finally

finally-lohkoa käytetään tilanteissa, joissa try-lohkon aikana avatut resurssit täytyy vapauttaa tai suoritusympäristön tila palauttaa varmasti riippumatta siitä, onnistuiko try-lohkon suoritus tai tapahtuiko poikkeus. Tyypillisiä esimerkkejä ovat tiedoston, verkkoyhteyden tai muun resurssin sulkeminen.

Kun käytössä on try-catch-finally, suoritus etenee näin:

  1. try suoritetaan.
  2. Jos poikkeus tapahtuu, sopiva catch suoritetaan.
  3. Lopuksi finally suoritetaan aina.

Alla on esimerkki tiedoston lukemisesta Scanner-luokan avulla. Paneudumme Scanner-luokkaan tarkemmin osassa 6.5, mutta lyhyesti: Scanner-olion avulla voidaan lukea tekstiä tiedostosta esimerkiksi merkki tai rivi kerrallaan. Käytettäessä try-catch-rakennetta Scanner-olio ei sulje itseään automaattisesti esimerkiksi poikkeuksen sattuessa, joten se pitää sulkea finally-lohkossa.

import java.io.File;
import java.io.FileNotFoundException;
import java.util.Scanner;

void lueTiedosto(String polku) {
    Scanner lukija = null;

    try {
        lukija = new Scanner(new File(polku));
        while (lukija.hasNextLine()) {
            IO.println(lukija.nextLine());
        }
    } catch (FileNotFoundException e) {
        IO.println("Tiedostoa ei löytynyt: " + polku);
    } finally {
        if (lukija != null) {
            lukija.close();
        }
        IO.println("Lukuoperaatio päättyi.");
    }
}

Yllä olevassa esimerkissä finally sulkee lukijan myös silloin, kun tiedoston lukeminen keskeytyy poikkeukseen. Jos oliota ei suljeta, "tiedostokahva", eli resurssi, joka on varattu tiedoston lukemiseen, jää auki. Käyttöjärjestelmillä on tiukat rajat sille, kuinka monta tiedostoa yhdellä prosessilla tai koko järjestelmällä voi olla auki samanaikaisesti. Jos ohjelmasi pyörii silmukassa tai palvelimella ja avaa tiedostoja sulkematta niitä, ns. file descriptor -taulukko täyttyy. Kun raja tulee vastaan, ohjelma kaatuu virheeseen, usein IOException: Too many open files, eikä se pysty enää avaamaan uusia tiedostoja. finally-lohko varmistaa, että tiedoston lukija suljetaan, vaikka lukeminen epäonnistuisi.

Nyky-Javassa resurssien hallintaan käytetään usein try-catch-rakenteen sijaan try-with-resources-rakennetta (JavaDoc), jolloin esimerkiksi Scanner osaa sulkea itsensä automaattisesti. Otetaan tästä esimerkki myöhemmissä osissa, kun olemme tutustuneet Closeable-rajapintaan, jonka avulla resurssit voidaan määritellä suljettaviksi.

Esimerkki tarkastetusta poikkeuksesta

Oletetaan, että haluamme lukea tiedoston sisältöä. Tehdään se käyttäen modernista Javasta löytyvää Files.readString()-metodia. Huomaa, että tätä metodia käytettäessä ei tarvita finally-lohkoa, koska kyseinen metodi lukee tiedoston kerralla ja huolehtii tiedoston sulkemisesta automaattisesti. Jatkon kannalta on kuitenkin tärkeä muista, että näin ei ole kaikkien Files-luokan metodien, esim Files.lines() (JavaDoc) kanssa.

Files.readString()-metodi vaatii Path-olion argumentikseen, joten käytämme tässä myös Path.of()-metodia, mikä on kätevä tapa luoda Path-olio merkkijonon avulla.

import java.nio.file.Files;
import java.nio.file.Path;

void main() {
    String sisalto = lueTiedosto("data.txt");
    IO.println(sisalto);
}

String lueTiedosto(String polku) {
    return Files.readString(Path.of(polku));
}

Ohjelmamme kaatuu, koska Files.readString()-metodi voi heittää IOException-poikkeuksen. Tämä on tarkastettu poikkeus. Määritellään lueTiedosto()-metodille throws IOException -määre.

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

void main() {
    String sisalto = lueTiedosto("data.txt");
    IO.println(sisalto);
}

String lueTiedosto(String polku) throws IOException {
    return Files.readString(Path.of(polku));
}

Ohjelmamme kaatuu edelleen, koska throws-määre heittää poikkeuksen edelleen kutsujalle (vrt. aiemman kuvion b()- ja c()-metodit). Koska main()-metodissa ei ole sopivaa käsittelijää, ohjelma kaatuu. Käsitellään poikkeus main()-metodissa try–catch-rakenteella.

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

void main() {
    try {
        String sisalto = lueTiedosto("data.txt");
        IO.println(sisalto);
    } catch (IOException e) {
        IO.println("Tiedoston lukeminen epäonnistui: " + e.getMessage());
    }
}

String lueTiedosto(String polku) throws IOException {
    return Files.readString(Path.of(polku));
}

Nyt ohjelmamme toimii ja tulostaa virheilmoituksen, vaikka data.txt-tiedostoa ei olisikaan olemassa.

Esimerkki tarkastamattomasta poikkeuksesta

Oletetaan, että meillä on luokka Henkilo, jolla on getNimi()-metodi, joka palauttaa henkilön nimen. Luodaan nyt lista henkilöistä.

import java.util.List;

public class Henkilo {
    private String nimi;

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

    public String getNimi() {
        return nimi;
    }
}

void main() {
    List<Henkilo> henkilot = new ArrayList<>();
    henkilot.add(new Henkilo("Antti-Jussi"));
    henkilot.add(new Henkilo("Denis"));

Katsotaan seuraavaksi miten null-viittaukset voivat aiheuttaa NullPointerException-poikkeuksia. Oletetaan, että jostain kumman syystä listalle päätyisi null-arvo. Tällainen tilanne voisi syntyä esimerkiksi, jos henkilöiden tietoja luettaisiin ulkoisesta lähteestä, ja jokin tietue olisi puutteellinen, mutta se siitä huolimatta lisättäisiin listalle.

 public class Henkilo {
     private String nimi;
 
     public Henkilo(String nimi) {
         this.nimi = nimi;
     }
 
     public String getNimi() {
         return nimi;
     }
 }
void main() {
    List<Henkilo> henkilot = new ArrayList<>();
    henkilot.add(new Henkilo("Antti-Jussi"));
    henkilot.add(new Henkilo("Denis"));
    kasitteleListaa(henkilot);

    for (Henkilo h : henkilot) {
        IO.println(h.getNimi());
    }
}

void kasitteleListaa(List<Henkilo> henkilot) {
    // Jostain syystä listalle päätyy null-viittaus
    henkilot.add(null);
}

Tämähän ei ole main()-metodin näkökulmasta ollenkaan ilmeistä – se hoitaa vain omaa hommaansa. Ongelma onkin siinä, että kasitteleListaa()-metodi aiheuttaa sivuvaikutuksen (muuttaa listan sisältöä) nimenomaan sillä tavalla, joka rikkoo main()-metodin odotuksen siitä, että listalla on vain Henkilo-olioita. Ongelma ilmenee, kun main()-metodi yrittää kutsua getNimi()-metodia null-viittaukselle.

Toki tämä tilanne voitaisiin käsitellä try-catch-rakenteella, tai toisaalta tarkastamalla if-lauseella tulostettaessa, onko h-olio null-viittaus. Tämä on kuitenkin melkoisen tympeää, eikä ratkaise ongelman juurisyytä.

Useat poikkeusoliot

Sama try-lohko voi aiheuttaa useita eri poikkeuksia. Tällöin jokaiselle poikkeustyypille voidaan tehdä oma catch-lohko.

Alla Integer.parseInt() voi heittää NumberFormatException-poikkeuksen, jos syöte ei ole numero, ja jakolasku voi heittää ArithmeticException-poikkeuksen, jos jakaja on nolla.

void main() {
    laske("42", 2);   // onnistuu
    laske("abc", 2);  // NumberFormatException
    laske("42", 0);   // ArithmeticException
}

void laske(String syote, int jakaja) {
    try {
        int luku = Integer.parseInt(syote);
        int tulos = luku / jakaja;
        IO.println("Tulos: " + tulos);
    } catch (NumberFormatException e) {
        IO.println("Virheellinen luku: " + syote);
    } catch (ArithmeticException e) {
        IO.println("Nollalla ei voi jakaa.");
    }
}

Kun poikkeukset ovat eri tyyppisiä olioita, ne kannattaa käsitellä eri tavalla tilanteen mukaan.

Omien poikkeusolioiden tekeminen

Joskus valmiit poikkeusluokat eivät kuvaa ongelmaa riittävän tarkasti. Tällöin voit määritellä oman poikkeusluokan.

Yleensä valinta tehdään näin:

  1. Peri luokka Exception-luokasta, jos haluat tarkastetun poikkeuksen.
  2. Peri luokka RuntimeException-luokasta, jos haluat tarkastamattoman poikkeuksen.

Alla on esimerkki tarkastetusta omasta poikkeuksesta:

class EpakelpoSalasanaException extends Exception {
    public EpakelpoSalasanaException(String viesti) {
        super(viesti);
    }
}

void main() {
    try {
        rekisteroiKayttaja("abc");
        IO.println("Käyttäjä rekisteröity.");
    } catch (EpakelpoSalasanaException e) {
        IO.println("Rekisteröinti epäonnistui: " + e.getMessage());
    }
}

void rekisteroiKayttaja(String salasana) throws EpakelpoSalasanaException {
    if (salasana.length() < 8) {
        throw new EpakelpoSalasanaException("Salasanan pitää olla vähintään 8 merkkiä.");
    }
    if (!salasana.matches(".*[A-Z].*")) { // säännöllinen lauseke, joka tarkistaa, onko salasanassa iso kirjain
        throw new EpakelpoSalasanaException("Salasanassa pitää olla vähintään yksi iso kirjain.");
    }
    // ... muita tarkistuksia ...
}

Omien poikkeusten etu on se, että virheestä tulee semanttisesti tarkempi: poikkeuksen nimestä näkee heti, mitä sääntöä rikottiin. Toki salasanan tarkistamisessa olisi voinut käyttää myös if-lausetta ilman poikkeuksia, mutta poikkeuksella on se etu, että se pakottaa käsittelemään virhetilanteen, eikä sitä voi unohtaa.

Poikkeusten käsittelyn hyödyllisyys

Poikkeukset tuovat merkittäviä etua koodin luettavuuteen ja ylläpidettävyyteen. Ennen Javaa poikkeusten hallinta oli usein toteutettu erilaisten virhekoodien palauttamisella. Esimerkiksi C-kielessä funktiot saattoivat palauttaa virheen merkiksi erikoisarvoja, kuten -1 tai NULL. Tämä lähestymistapa oli altis virheille ja johti helposti siihen, että ohjelmoijat saattoivat unohtaa tarkistaa näitä virhekoodiarvoja. Virheentarkistus ja varsinainen toiminta sekoittuvat ns. "spagettikoodiksi". Alla esimerkki pseudokoodina.

avaaTiedosto;
JOS (onnistui) {
    lueKoko;
    JOS (kokoSelvisi) {
        varaaMuisti;
        JOS (muistiRiitti) {
            lueData;
             // ... jne ...
        } MUUTEN palautaVirhe -2;
    } MUUTEN palautaVirhe -3;
} MUUTEN palautaVirhe -5;

Poikkeusten avulla koodin "onnellinen polku" (ns. happy path) on selkeästi luettavissa, ja virheet on siivottu omiin lohkoihinsa.

try {
    avaaTiedosto();
    selvitaKoko();
    varaaMuisti();
    lueData();
} catch (TiedostoVirhe e) {
    käsitteleVirhe();
} catch (MuistiVirhe e) {
    käsitteleVirhe();
}

Joskus virhe tapahtuu syvällä metodikutsujen ketjussa (esim. metodi1 metodi2 metodi3 lueTiedosto). Ilman poikkeuksia jokaisen välissä olevan metodin (metodi2, metodi3) pitää tarkistaa paluuarvo ja välittää virhe eteenpäin, vaikka ne eivät itse osaisi tehdä virheelle mitään. Poikkeusten kanssa välissä olevat metodit voivat "väistää" (duck) poikkeuksen. Virhe "kuplii" automaattisesti ylöspäin kutsupinossa, kunnes se saavuttaa metodin, joka on kiinnostunut käsittelemään sen.

Koska poikkeukset ovat olioita, ne muodostavat hierarkioita. Tämä mahdollistaa joustavan virheenkäsittelyn. Voit napata juuri tietyn virheen (esim. FileNotFoundException), jos tiedät miten se korjataan. Voit myös napata yliluokan (esim. IOException), jolloin käsittelet yhdellä kertaa kaikki tiedonsiirtoon liittyvät ongelmat. Vaarallisena houkutuksena on napata yleinen yliluokka (esim. Exception), jolloin käsittelet kaikki mahdolliset poikkeukset, mutta et oikeastaan tiedä, miten niitä käsitellään. Tällaista catch (Exception e) -rakennetta tulee välttää, ellei todella aio käsitellä kaikkia mahdollisia virheitä -- myös odottamattomia bugeja. Käsittely liian yleisen olion tasolla voi kuitenkin jälleen kerran piilottaa ohjelmointivirheitä ja vaikeuttaa vianetsintää.

Eri kielissä on erilaisia lähestymistapoja poikkeusten hallintaan. Esimerkiksi Pythonissa kaikki poikkeukset ovat tarkastamattomia, ja ohjelmoijan ei tarvitse ilmoittaa etukäteen, että metodi voi heittää poikkeuksia. C++:ssa on sekä tarkastettuja että tarkastamattomia poikkeuksia, mutta niiden käyttö on vähemmän yleistä kuin Javassa. Rustissa poikkeuksia ei ole lainkaan, vaan virhetilanteet käsitellään Result-tyypin avulla, mikä pakottaa ohjelmoijan käsittelemään kaikki mahdolliset virheet eksplisiittisesti.

Osa tämä osan tekstistä pohjautuu Java-dokumentaatioon poikkeuksista.

Tehtävä 6.5 : Poikkeukset, osa 1. 1 p. Tee monivalintatehtävä TIMissä. Tee tehtävä TIMissä Tehtävä 6.6: Poikkeukset, osa 2. 1 p.

Tee main, joka lukee käyttäjältä silmukassa kokonaislukuja niin kauan, kuin käyttäjä antaa muun kuin tyhjän syötteen. Jos käyttäjä antaa muun kuin kokonaisluvun, tulosta virheilmoitus ja jatka lukemista.

Tallenna luvut taulukkoon. Tulosta lopuksi kaikki taulukkoon tallennetut luvut.

Tee tehtävä TIMissä
Tehtävä 6.7: Poikkeukset, osa 3. 1 p.

Tee ohjelma, joka tarkistaa, onko käyttäjän syöttämä ikä riittävä tiettyyn toimintaan, esimerkiksi ajokortin hankkimiseen.

Tee aliohjelma onkoIkaa, joka ottaa parametrina iän (int) ja palauttaa true, jos ikä on riittävä. Jos ikä on alle 18, heitä poikkeus IkaException, joka on oma tarkastettu poikkeusluokka. Anna sopiva poikkeusviesti, esimerkiksi "Ikä ei riitä.". Ohessa vinkiksi metodin esittelyrivi.

static boolean onkoIkaa(int ika) throws IkaException

Jos ikä on negatiivinen, heitä poikkeus IkaException viestillä "Ikä ei voi olla negatiivinen.".

Poista if-rakenne ja muokkaa main-metodia niin, että se kääntyy ja tulostaa oikeat asiat.

Tee tehtävä TIMissä

Ulkoiset kirjastot ja Java-projektien hallintatyökalut

osaamistavoitteet

  • Ymmärrät, miksi rakennustyökaluja (kuten Maven tai Gradle) tarvitaan modernissa ohjelmistokehityksessä.
  • Tunnet Maven-projektin perusrakenteen ja pom.xml-tiedoston merkityksen.
  • Osaat etsiä ja lisätä ulkoisia riippuvuuksia projektiisi.
  • Ymmärrät Javan pakkausrakenteen (package) merkityksen koodin organisoinnissa ja nimikonfliktien estämisessä.
  • Osaat hyödyntää import-lauseita eri pakkauksissa sijaitsevien luokkien käyttämiseen.

Oletetaan, että haluat tehdä Java-ohjelman, joka hakee tietoa verkosta HTTP-kutsulla. Löydät netistä seuraavan esimerkkikoodin.

import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;

public class Main {
    public static void main(String[] args) throws Exception {
        OkHttpClient client = new OkHttpClient();
        Request request = new Request.Builder()
                .url("https://api.github.com/zen")
                .header("User-Agent", "Demo")
                .build();

        Response response = client.newCall(request).execute();
        IO.println(response.body().string());
        response.close();
    }
}

Yrität kääntää ohjelman, mutta saat virheilmoituksen, että okhttp3-pakettia ei löydy. Kysymys kuuluu: mistä tämä kirjasto pitäisi saada? Jos löydätkin sen jostain, ja lataat sen .jar-tiedostona, niin mihin tuo tiedosto pitäisi laittaa? Entä jos kirjasto tarvitsee itse tuekseen muita kirjastoja?

Tässä kohtaa törmätään modernin ohjelmistokehityksen ytimeen: oma koodi ei aina riitä, vaan tarvitsemme usein myös muiden kirjoittamaa koodia osana omaa ohjelmaamme. Toisen tekemää koodia, joka on julkaistu muiden käytettäväksi, kutsutaan kirjastoksi. Kun käytät toisen kehittäjän tekemää kirjastoa, projektisi riippuu siitä; Tätä kutsutaan riippuvuudeksi (dependency). Äskeisessä esimerkissä riippuvuus on OkHttp-kirjasto.

Riippuvuuksien hallinta tarkoittaa:

  • kirjaston oikean version hakemista,
  • sen lisäämistä projektin classpathiin,
  • kirjaston omien riippuvuuksien huomioimista, ja
  • versioristiriitojen estämistä.

Kirjastojen hallinta käsin on kovin työlästä, ja siksi on kehitetty työkaluja, jotka hoitavat tämän puolestasi. Näitä työkaluja kutsutaan build-työkaluiksi. Vapaa suomennos voisi olla rakennustyökalu, mutta käytämme tässä yhteydessä vakiintunutta englanninkielistä termiä build-työkalu. Niistä tunnetuimpia Java-maailmassa ovat Maven ja Gradle. Build-työkalu automatisoi yllä mainitun riippuvuuksien hallintatyön. Build-työkalu voi tehdä muutakin: se voi ajaa mahdolliset testit ja myös pakata valmiin ohjelman jakelukuntoon.

Esittelemme seuraavaksi Maven-työkalun käyttöä IDEAssa. Aivan hyvin saman asian voisi tehdä myös Gradlella tai Antilla. Maven on alkuvaiheessa ehkä aavistuksen helppokäyttöisempi, joten valitsemme sen tähän esimerkkiin.

Analogia: Huonekalusuunnittelija

Ajattele rooliasi ohjelmoijana ikään kuin IKEAn huonekalusuunnittelijana. Et itse rakenna jokaista huonekalua asiakkaalle, vaan luot tarkan rakennesuunnitelman, kokoamisohjeet (koodin) ja laadit osaluettelon siitä, mitä huonekalun kokoamiseen tarvitaan. Henkilöä, joka lopulta ostaa huonekalun ja alkaa koota sitä, voidaan puolestaan verrata Java-virtuaalikoneeseen tai muuhun ajoympäristöön: hän avaa myyntipakkauksen, lukee ohjeesi, suorittaa vaiheet järjestyksessä ja herättää huonekalun henkiin.

Mutta miten suunnittelijan työpöydällä olevista piirustuksista ja ohjeista tulee asiakkaan ostama valmis myyntipakkaus? Et voi vain postittaa pelkkiä ohjeita asiakkaalle ja toivoa, että hän käy itse etsimässä rautakaupasta juuri oikeanlaiset lastulevyt, mutterit, pultit ja saranat.

Suunnittelijana toimitat ohjeesi ja osaluettelosi tehtaalle ja pakkaamoon. Siellä kerätään automaattisesti yhteen kaikki vaaditut osat ja pakataan ne yhdessä ohjeidesi kanssa siistiin, litteään pahvilaatikkoon, jotta paketti voidaan helposti kuljettaa ja myydä eteenpäin.

Tässä astuvat kuvaan rakennustyökalut.

Rakennustyökalut, kuten Maven, Gradle ja vanhempi Apache Ant, toimivat ohjelmistoprojektisi automaattisena tehtaana ja pakkaamona. Niiden päätehtävät jaetaan kolmeen kategoriaan:

1. Riippuvuuksien hallinta (Mutterien ja pulttien tilaaminen)

Ohjelmoijana et kirjoita kaikkea alusta asti itse (esimerkiksi tietokantayhteyksiä tai salasanan salausta), vaan käytät muiden tekemiä "valmiita osia", eli koodikirjastoja. Näitä kutsutaan riippuvuuksiksi (dependencies). Rakennustyökalu lukee kirjoittamasi osaluettelon (esim. pom.xml tai build.gradle), etsii tarvittavat standardikirjastot automaattisesti internetin varastoista ja lataa ne projektiisi. Riippuvuuksien hallinta on keskeinen osa modernia Java-kehitystä, ja se auttaa varmistamaan, että projekti käyttää oikeita versioita kirjastoista ja että kaikki tarvittavat osat ovat saatavilla.

2. Kääntäminen ja testaaminen (Laadunvalvonta)

Ennen kuin paketti laitetaan kiinni, työkalu varmistaa, että kaikki toimii. Se kääntää ihmiskielisen koodisi koneen ymmärtämään muotoon ja ajaa mahdolliset automaattiset testit. Se siis tarkistaa laadunvalvontalinjastolla, ettei laatikosta puutu tärkeitä osia, osat sopivat toisiinsa ja että ohjeissa on järkeä.

3. Pakkaaminen ja jakelu (Litteä pahvilaatikko)

Kun kaikki osat on kerätty ja ohjeet todettu toimiviksi, rakennustyökalu pakkaa koko komeuden yhdeksi helposti käsiteltäväksi tiedostoksi, kuten JAR- tai WAR-tiedostoksi (Java Archive).

Lopuksi rakennustyökalu voi auttaa paketin julkaisemisessa (deployment) eli sen toimittamisessa sinne, missä ohjelmaa tullaan lopulta käyttämään. Tämä voi tarkoittaa esimerkiksi pilvipalvelinta, kuten AWS tai Azure, sovelluskauppaa, kuten Google Play tai Apple App store, taikka yrityksen sisäistä palvelinta. IKEA-vertauksessa tämä on se vaihe, kun tehdas laittaa valmiit litteät laatikot rekkaan ja ne kuljetetaan paikallisen tavaratalon noutovaraston hyllylle asiakkaiden haettavaksi.

Ensimmäinen projekti Mavenilla

Kokeillaan tehdä itse ensimmäinen Java-projekti Mavenilla.

  1. Aloita luomalla uusi projekti.
  2. Anna projektin nimeksi "EkaMavenProjekti".
  3. Valitse IDEAssa Build System -kohdassa Maven.
  4. Klikkaa Create.

Jos jostain syystä Mavenia ei ole valittavissa, asenna se IDEAn pluginien hallinnan kautta: File Settings Plugins Marketplace Etsi "Maven" Install Käynnistä IDEA uudestaan.

Pienen miettimisen jälkeen sinulle pitäisi syntyä projekti, jossa on läjä tiedostoja ja kansioita. Katsotaan näitä nyt lähemmin. Projektisi kansiorakenne näyttää suunnilleen tältä:

srcmaintestpom.xmljavajava
  • src-kansio: sisältää varsinaisen Java-koodin (main/.../java) ja testikoodin (test/java).
  • pom.xml-tiedosto: Mavenin konfiguraatiotiedosto, jossa määritellään projektin riippuvuudet, rakennusasetukset ja muut tärkeät tiedot.
  • Lisäksi projektiin syntyy automaattisesti .gitignore-tiedosto, sekä .mvn-kansio, johon tutustumme myöhemmin.

Vilkaistaan pom.xml-tiedostoa, joka on Maven-projektin sydän. Avatessasi tiedoston näet XML-muotoista tekstiä. Tämä tiedosto määrittelee projektisi rakenteen, riippuvuudet ja muut asetukset. "Vanilla"-Java-projektin (ts. projekti, joka ei käytä ulkoisia kirjastoja) pom.xml-tiedosto näyttää suunnilleen tältä:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.example</groupId>
    <artifactId>EkaMaven</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>25</maven.compiler.source>
        <maven.compiler.target>25</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

</project>

Tiedoston alussa määritellään tyypillistä XML-rakennetta, jonka jälkeen tulee <groupId>, <artifactId> ja <version>-elementit. Nämä ovat ikään kuin projektisi tunniste, joka yksilöi juuri sinun projektisi muiden Maven-projektien joukossa.

  • groupId toimii projektin "organisaatiotunnisteena". Se on usein käänteinen verkkotunnus, kuten fi.jyu.ohjelmointi.
  • artifactId on projektin nimi, ja
  • version kertoo projektin version.

Näiden kolmen yhdistelmä muodostaa projektin yksilöllisen tunnisteen. Tässä vaiheessa näillä tunnisteilla ei ole hirveästi merkitystä, mutta jos julkaiset projektisi esimerkiksi Maven Central -varastoon, nämä tunnisteet ovat tärkeitä.

Loput rivit määrittelevät projektin Java-version sekä koodin merkistökoodauksen.

Avaa nyt Main.java-tiedostoa. Lisää sinne sivun alussa esitetty HTTP-kutsun esimerkkikoodi ja yritä kääntää se. Projekti ei kuitenkaan käänny vielä, koska OkHttp-kirjasto ei ole vielä projektin riippuvuuksissa. Riippuvuuksien lisääminen Maven-projektiin tapahtuu muokkaamalla pom.xml-tiedostoa. Etsi tiedostosta <dependencies>-elementti. Lisää se (ja sen vastinpari </dependencies>), mikäli kyseistä elementtiä ei vielä ole.

<dependency>
    <groupId>com.squareup.okhttp3</groupId>
    <artifactId>okhttp-jvm</artifactId>
    <version>5.3.2</version>
</dependency>

IDEA valittaa vielä, että okhttp-jvm-pakettia ei löydy. Riippuvuuksien lisäämisen jälkeen Maven-projekti täytyy virkistää klikkaamalla projektinäkymässä projektin nimen päältä hiiren oikealla, valitsemalla Maven ja Sync project. Tämän jälkeen IDEA lataa tarvittavat okhttp-riippuvuuden, ja myös kyseisen kirjaston itsensä vaatimat muut riippuvuudet.

Lisää myös aivan koodin alkuun package org.example;, jotta koodi on oikeassa pakkauksessa. Palaamme pakkauksen tarkempaan merkitykseen hieman alempana.

Tallenna tiedosto ja käännä projekti. Nyt Maven hakee OkHttp-kirjaston Maven Central -varastosta ja linkittää sen projektiisi. Tämän jälkeen käännös onnistuu, ja näet HTTP-kutsun tulokset konsolissa.

Maven Central

Riippuvuus-XML:iä ei tarvitse itse keksiä. Yksi suosituimmista Java-kirjastojen varastoista on Sonatype-yrityksen ylläpitämä Maven Central, joka on julkinen varasto, josta voit hakea ja ladata Java-kirjastoja sekä liittää niitä projektiisi.

Voit etsiä tarvitsemasi kirjastot ja niiden riippuvuudet helposti Maven Centralista, ja kopioida sieltä suoraan XML-koodin pom.xml-tiedostoosi. Kokeillaan etsiä äsken mainittu okHttp-kirjasto Maven Centralista.

  1. Mene osoitteeseen https://central.sonatype.com/
  2. Kirjoita hakukenttään "okhttp" ja paina Enter.
  3. Ensimmäinen hakutulos vie vanhempaan okHttp-kirjastoon, joka on nimeltään "okhttp". Valitse sen sijaan toinen hakutulos, joka on uudempi.
  4. Näet Snippets-kohdassa valmiin XML-koodin, jonka yleensä voit kopioida suoraan pom.xml-tiedostoosi.
  5. Kopioi XML-koodi ja liitä se pom.xml-tiedostoon <dependencies>-elementin sisälle.

Aivan kaikkien kirjastojen kohdalla XML:ää ei voi välttämättä suoraan kopioida, vaan sinun täytyy tarkistaa kirjaston dokumentaatiosta, onko XML Maven-yhteensopiva. Juurikin okHttp-kirjaston kohdalla on niin, että XML:ää tarvitsee hivenen muuttaa, koska tarvitsemme nimen omaan okhttp-jvm-version, joka on Maven-yhteensopiva.

Tässä tapauksessa riittää, että vaihdetaan artifactId-elementti okhttp-jvm:ksi, ja XML on valmis.

Riippuvuuden sisältämien luokkien käyttäminen edellyttää vielä, että lisäät luokan alkuun import-lauseen, joka tuo tarvittavat luokat näkyviin. Esimerkiksi OkHttp-kirjaston OkHttpClient-luokan käyttämiseksi sinun täytyy lisätä import okhttp3.OkHttpClient;-lause luokan alkuun. Joskus voi olla tarvetta tuoda useita luokkia samasta paketista, jolloin voit käyttää jokerimerkkiä, kuten import okhttp3.*;, joka tuo kaikki okhttp3-paketin luokat näkyviin.

Kolmannen osapuolen riippuvuudet

Java-projekteissa on usein tarpeen käyttää kolmannen osapuolen kirjastoja, jotka tarjoavat valmiita toiminnallisuuksia ja säästävät kehitysaikaa. Maven-projekteissa on oletuksena käytettävissä Maven Central -varasto sekä käyttäjän paikallinen varasto. Jos haluamme käyttää jotain riippuvuutta, joka ei sijaitse Maven Central -varastossa, voimme lisätä muitakin varastoja projektin käyttöön määrittelemällä ne pom.xml-tiedostossa seuraavasti.

<repositories>
    <repository>
        <id>varaston-tunnus</id>
        <url>varaston-url</url>
        <!-- Muut asetukset -->
    </repository>

    <!-- Muut varastot -->
</repositories>

Projektin käytössä olevien varastojen lista löytyy Intellij IDEA:n asetuksista:

File > Settings > Build, Execution, Deployment > Build Tools > Maven > Repositories

Bonus: Mihin Maven tallentaa kirjastot?

Maven asentaa kaikki lataamansa riippuvuudet paikalliseen kansioon, minkä jälkeen ne ovat kaikkien projektien käytettävissä. Tämä kansio löytyy käyttäjähakemiston alta polusta .m2/repository. Tähän paikalliseen varastoon on myös mahdollista lisätä itse paketteja, jolloin niihin voi viitata tavalliseen tapaan pom.xml-tiedostosta.

Minkä tahansa jar-tiedoston voi lisätä paikalliseen repositorioon esimerkiksi seuraavalla komennolla. Tämä kuitenkin vaatii Maven-komentorivityökalujen asentamisen, joten emme tällä kurssilla tule sitä käyttämään.

mvn install:install-file \
   -Dfile=<tiedostopolku> \
   -DgroupId=<organisaatiotunniste> \
   -DartifactId=<projektitunniste> \
   -Dversion=<versionumero> \
   -Dpackaging=jar \
   -DgeneratePom=true

Kannattaa kuitenkin pitää mielessä, että nämä riippuvuudet ovat tällöin käytettävissä vain laitteella, jossa tämä manuaalinen lisääminen paikalliseen varastoon on suoritettu.

Voimme myös lisätä paikallisen riippuvuuden projektiin ilman, että se lisätään paikalliseen varastoon. Tämä mahdollistaa esimerkiksi riippuvuuden sijoittamisen projektin kansioon, jolloin se on helpompi lisätä myös projektin versionhallintaan.

Riippuvuus lisätään pom.xml-tiedostoon tavalliseen tapaan, mutta lisäämme scope-asetuksen ja annamme sen arvoksi system, jotta voimme käyttää systemPath-asetusta määrittämään paikallisen riippuvuuden tiedostopolun.

Muuttuja ${project.basedir} viittaa projektin juureen, joten tässä esimerkissä riippuvuuden tiedostopolku on projektin hakemistossa sijaitseva lib/tiedosto.jar.

<dependencies>
    <dependency>
        <groupId>organisaatiotunniste</groupId>
        <artifactId>projektitunniste</artifactId>
        <version>1.0</version>
        <scope>system</scope>
        <systemPath>${project.basedir}/lib/tiedosto.jar</systemPath>
    </dependency>
</dependencies>

Pakkaukset Javassa

Kun Java-ohjelma kasvaa useista luokista koostuvaksi kokonaisuudeksi, luokkien järjestäminen satunnaisesti samaan kansioon ei enää riitä. Tarvitsemme tavan ryhmitellä toisiinsa liittyvät luokat loogisiksi kokonaisuuksiksi. Tätä varten Java tarjoaa pakkaukset (engl. packages). Pakkaus on nimetty luokkien kokoelma. Se toimii samalla sekä loogisena ryhmittelykeinona että teknisenä nimialueena (namespace), joka estää nimikonfliktit.

Luokan alussa voidaan määrittää package-lause, joka määrittelee pakkauksen, johon luokka kuuluu.

package fi.jyu.ohjelmointi; 

class User
{
    // ...
}

Nyt User-luokka kuuluu pakkaukseen fi.jyu.ohjelmointi. Luokan täydellinen nimi on fi.jyu.ohjelmointi.User, mutta samassa pakkauksessa olevat luokat voivat käyttää toistensa jäseniä suoraan ilman täydellisiä nimiä. Samaan pakkaukseen kuuluvat luokat voivat käyttää toistensa jäseniä ilman erillisiä import-lauseita, ja ilman tarvetta käyttää luokkien täydellisiä nimiä.

package fi.jyu.ohjelmointi;

class Main
{
    static void main() {
        User user = new User();
        // ...
        // Voidaan myös tehdä näin, mutta se on turhaa, koska Main ja User kuuluvat samaan pakkaukseen:
        fi.jyu.ohjelmointi.User user2 = new User();
    }
}

Pakkaus liittyy suoraan myös projektin kansiorakenteeseen. Jokainen pakkauksen osa vastaa yhtä kansiota. Esimerkiksi pakkaus org.example vastaa kansiorakennetta src/main/java/org/example/Main.java. Tämä ei ole pelkkä suositus, vaan Java-kääntäjä edellyttää, että tiedoston sijainti vastaa sen pakkausmäärittelyä.

Pakkauksia käytetään myös muiden kirjastojen luokkien hyödyntämiseen. Kun kirjasto lisätään projektiin, sen luokat sijaitsevat omissa pakkauksissaan. Näiden luokkien käyttäminen edellyttää import-lausetta. Esimerkiksi OkHttp-kirjaston OkHttpClient-luokka kuuluu pakkaukseen okhttp3, ja sen käyttämiseksi kirjoitetaan import okhttp3.OkHttpClient;. Import-lause ei kopioi luokkaa omaan projektiisi, vaan kertoo kääntäjälle, mistä paketista luokka löytyy. Ilman import-lausetta luokkaa voisi käyttää vain sen täydellisellä nimellä:

okhttp3.OkHttpClient client = new okhttp3.OkHttpClient();

Palataan vielä User-luokan esimerkkiin. Samassa projektissa voisi olla toinenkin pakkaus, nimieltään com.example.library, joka sisältää User-luokan.

package com.example.library;

class User
{
    // ...
}

Vaikka projektissa on nyt kaksi User-luokkaa, niiden täydelliset nimet eroavat, eikä ristiriitaa synny. Joskus voidaan haluta käyttää molempia User-luokkia samassa kooditiedostossa. Tällöin luokat tuodaan projektiin import-lauseilla, ja niitä käytetään täydellisillä nimillä, jotta voidaan erottaa, kumpaa User-luokkaa tarkoitetaan.

import fi.jyu.ohjelmointi.User;
import com.example.library.User;

void main() {
    fi.jyu.ohjelmointi.User user1 = new fi.jyu.ohjelmointi.User();
    com.example.library.User user2 = new com.example.library.User();
}

Tällainen tilanne on ehkä käytännössä harvinainen, mutta se korostaa pakkauksen merkitystä nimialueena.

Pakkausten nimissä käytetään vakiintunutta käytäntöä, joka perustuu käänteiseen verkkotunnukseen. Esimerkiksi Jyväskylän yliopiston projektissa pakkauksen nimi voisi olla

fi.jyu.ohj2.munekamavenprojekti

Tämä käytäntö auttaa varmistamaan, että pakkausten nimet ovat maailmanlaajuisesti yksilöllisiä, mikä on erityisen tärkeää, jos kirjasto julkaistaan muiden käytettäväksi.

Aivan pienissä ohjelmissa pakkauksia ei käytännössä tarvita, ja compact Java -tyylisen ohjelman kaikki luokat voidaan sijoittaa samaan kansioon ilman pakkauksia. Pakkaukset ovat kuitenkin keskeinen osa suurten Java-ohjelmien rakennetta. Ne auttavat pitämään koodin järjestyksessä ja estävät nimikonfliktit. Ne muodostavat myös perustan Java-kirjastojen ja build-työkalujen, kuten Mavenin, käyttämälle standardoidulle kansiorakenteelle. Tämä rakenne varmistaa, että sekä kehitystyökalut että ajoympäristö löytävät luokat oikeista paikoista ja voivat käyttää niitä oikein.

IDEAssa pakkauksen saa näppärästi määritettyä Maven-projektia luotaessa. Tehdessäsi uutta projektia, valitse Advanced settings, ja kirjoita GroupID-kenttään haluamasi pakkauksen nimi. IDEA tekee näin automaattisesti oikean kansiorakenteen ja lisää Main.java-tiedoston määrittelemääsi pakkaukseen.

Tehtävät

Tehtävä 6.8: Riippuvuudet. 1 p.

Tee uusi Maven-projekti. Aseta pakkauksen nimeksi fi.jyu.omatunnus (laita omatunnus-kohdalle JY-käyttäjätunnus tai jokin muu keksimäsi käyttäjänimi). Anna pääluokan nimeksi Riippuvuudet. Lisää siihen tämä koodi.

void main() {
    JSONObject json = new JSONObject();
    json.put("nimi", "Maija");
    json.put("ika", 25);
    IO.println(json.getString("nimi"));
    IO.println(json.getInt("ika"));
}

Lisää nyt pom.xml-tiedostoon riippuvuus json-nimiseen artefaktiin. Etsi tämä kirjasto Maven Centralista, ja kopioi sieltä XML-koodi pom.xml-tiedostoosi. Lisää myös riippuvuuden vaatima import-lause luokan alkuun.

Käännä ja aja ohjelma, ja varmista, että se tulostaa odotetut tiedot.

Tee tehtävä TIMissä

Tiedostojen käsittely

osaamistavoitteet

  • Osaat lukea ja kirjoittaa tekstitiedostoja Javan Files-luokan avulla.
  • Osaat käyttää Scanner-luokkaa tiedon lukemiseen ja parsimiseen tiedostosta.
  • Osaat käsitellä tiedostoja riveittäin hyödyntäen Stream-rajapintaa.
  • Tunnet JSON-tiedostomuodon perusteet.
  • Osaat käyttää Jackson-kirjastoa JSON-datan lukemiseen ja kirjoittamiseen.
  • Ymmärrät, miten Javan record-tietueet soveltuvat datan mallintamiseen.

Tiedoston käsittelyssä on aina sama peruskaari: avaat resurssin, luet tai kirjoitat dataa tietyssä muodossa, ja suljet resurssin. Java tarjoaa tähän useita valmiita vaihtoehtoja. Valinta riippuu siitä, luetko dataa vain riveittäin vai tarvitsetko rivien pilkkomista ja parsimista arvoiksi (esim. luvut), haluatko käsitellä suurta tiedostoa suorituskykyisesti, ja missä muodossa data on.

Oman tiedoston lisääminen projektiin

Oletetaan, että meillä on oheisen kaltainen tekstitiedosto.

nimi,ika
Maija,25
Matti,30

Tiedosto sisältää henkilöiden tietoja. Ensimmäisellä rivillä on sarakkeiden nimet, ja seuraavilla riveillä on tietoja henkilöistä. Tiedot on erotettu toisistaan pilkuilla. Tällaista tiedostomuotoa kutsutaan CSV-tiedostoksi (engl. comma-separated values), ja se on varsin yleinen tapa tallentaa taulukkomuotoista dataa tekstitiedostoon.

Tallennetaan tällainen tiedosto nimellä data.csv projektin juurikansioon. Jotta IDEA osaa ohjelman ajon aikana käyttää tätä tiedostoa, määritellään, että ohjelman työskentelykansio on projektin juurikansio. Tämän voi tehdä Run Edit Configurations. Valitse vasemmalta luokka, johon main-metodi on kirjoitettu. Oikealla "Working directory" -kohdassa varmista, että kansioksi on määritetty projektin lähdekoodin juurihakemisto, joka päättyy yleensä src tai src/main/java.

Nyt voimme käyttää data.csv-tiedostoa ohjelmassamme.

Tiedoston käsittely Files API:lla

Files-luokka (tai oikeammin sanottuna java.nio.file-paketin API) tarjoaa suoraviivaisen tavan lukea koko tiedosto kerralla sellaisissa tilanteissa, joissa tiedoston koko on kohtuullinen. Voit esimerkiksi lukea koko tiedoston muistiin rivilistana Files.readAllLines()-metodilla tai merkkijonona Files.readString()-metodilla. Jos datan sisältää vaikkapa lukuja, päivämääriä tai muuta erikoisempaa, tulee ne käsitellä erikseen.

Tehdään yllä oleva esimerkki käyttäen Files-luokan readAllLines()-metodia. Tämä metodi lukee koko tiedoston muistiin listana merkkijonoja, joissa jokainen merkkijono vastaa yhtä riviä tiedostossa. Tämän jälkeen voimme käydä listan läpi ja pilkkoa jokaisen rivin sarakkeiksi.

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.List;

public class TiedostonLukija {

    public static void main(String[] args) {
        try {
            List<String> lines = Files.readAllLines(Paths.get("data.csv"));
            for (int i = 1; i < lines.size(); i++) {
                String line = lines.get(i); 
                String[] parts = line.split(","); 
                String nimi = parts[0]; 
                int ika = Integer.parseInt(parts[1]); 
                IO.println("Nimi: " + nimi + ", Ikä: " + ika);
            }
        } catch (IOException e) {
            IO.println("Tiedostoa ei löydy tai sitä ei voi lukea: " + e.getMessage());
        } finally {
            // Ei tarvitse erikseen sulkea mitään, koska Files API hoitaa sen puolestamme
        }
    }
}

Samalla idealla – eli kokonainen tiedosto kerrallaan – voit myös kirjoittaa tiedostoon. Kun koko sisältö on yhtenä merkkijonona, voit käyttää Files.writeString()-metodia. Ennen kirjoittamista tulee varmistaa, että kansio, johon tiedosto kirjoitetaan, pitää olla olemassa. Tämä voidaan tehdä seuraavasti:

Path polku = Path.of("data", "tulos.txt"); // Polku-olio, joka sisältää tiedon kansiosta ja tiedostosta
Files.createDirectories(polku.getParent()); // Varmistetaan, että data-kansio on olemassa
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

public class KirjoitaTiedostoWriteString {
    public static void main(String[] args) {
        Path polku = Path.of("data", "tulos.txt");

        try {
            Files.createDirectories(polku.getParent()); // varmistetaan, että data-kansio on olemassa

            String sisalto = "Hei!\nTämä on uusi tiedosto.\n";
            Files.writeString(polku, sisalto, StandardCharsets.UTF_8);

            IO.println("Kirjoitettiin: " + polku.toAbsolutePath());
        } catch (IOException e) {
            IO.println("Kirjoittaminen epäonnistui: " + e.getMessage());
        }
    }
}

Kun data on riveinä, esimerkiksi listana, on usein luontevaa kirjoittaa se riveittäin käyttäen Files.write()-metodia.

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;

public class KirjoitaTiedostoRiveina {
    public static void main(String[] args) {
        Path polku = Path.of("data", "data.csv");

        List<String> rivit = List.of(
                "nimi,ika",
                "Maija,25",
                "Matti,30"
        );

        try {
            Files.createDirectories(polku.getParent());
            Files.write(polku, rivit, StandardCharsets.UTF_8);

            IO.println("Kirjoitettiin: " + polku.toAbsolutePath());
        } catch (IOException e) {
            IO.println("Kirjoittaminen epäonnistui: " + e.getMessage());
        }
    }
}

On myös mahdollista lisätä tekstiä olemassa olevan tiedoston loppuun. Se vaatii parin lisäargumentin antamista. Alla lyhyt esimerkki Files.write()-metodin käytöstä, jossa teksti lisätään olemassa olevan tiedoston loppuun.

// ...
Path polku = Path.of("data", "tulos.txt");
String rivi = "Uusi rivi, joka lisätään tiedoston loppuun.\n";
Files.writeString(
        polku,
        rivi,
        StandardCharsets.UTF_8, // Käytetään UTF-8-koodausta
        StandardOpenOption.CREATE, // Luo tiedosto, jos sitä ei ole
        StandardOpenOption.APPEND // Lisää tekstiä olemassa olevan tiedoston loppuun
);
// ...

Lukeminen Scanner-oliolla

Scanner sopii tilanteisiin, joissa haluat lukea tekstiä ikään kuin palasissa: esimerkiksi kokonainen rivi kerrallaan, seuraavaan välilyöntiin asti tai jopa seuraavan luvun. Voit ajatella, että Scanner-olio on kuin lukupää, "kursori", joka etenee sitä mukaa kun kutsut kursoria eteenpäin liikuttavia metodeja, kuten nextLine() tai next(). Kun tiedostossa ei ole enää luettavaa, saat hasNext()-metodilta paluuarvon false.

Scanner osaa myös lukea lukuja ja muita primitiivityyppejä suoraan (nextInt(), nextDouble(), jne.), mikä vähentää käsin parsimista.

Alla olevassa mallikoodissa luetaan tiedosto Scanner-oliolla. Ensin luodaan Tiedoston lukeminen tapahtuu siis rivi kerrallaan, ja jokainen rivi pilkotaan sarakkeiksi.

import java.io.File;
import java.io.FileNotFoundException;
import java.util.Scanner;


public class TiedostonLukija {

    public static void main(String[] args) {
        Scanner scanner = null;
        try {
            scanner = new Scanner(new File("data.csv"));
            // Ohitetaan otsikkorivi
            if (scanner.hasNextLine()) {
                scanner.nextLine();
            }
            // Luetaan rivejä, kunnes tiedoston loppu saavutetaan
            while (scanner.hasNextLine()) {
                String line = scanner.nextLine(); // Esim. "Maija,25"
                String[] parts = line.split(","); // Pilkotaan rivi sarakkeiksi
                String nimi = parts[0]; // Ensimmäinen sarake on nimi
                int ika = Integer.parseInt(parts[1]); // Toinen sarake on ikä, parsitaan intiksi
                IO.println("Nimi: " + nimi + ", Ikä: " + ika);
            }
        } catch (FileNotFoundException e) {
            IO.println("Tiedostoa ei löydy: " + e.getMessage());
        } finally {
            // Suljetaan scanner
            if (scanner != null) {
                scanner.close();
            }
        }
    }
}

Aina käsiteltävä aineisto ei ole näin "nättiä". Tiedostossa voi olla lukuja, joiden erottimina voi olla vaikkapa välilyönti, pilkku, puolipiste tai rivinvaihto.

Oletetaan tiedosto mittaukset.txt:

12  8,  5
-3; 10  7
virhe  2  1.5  3

Haluat lukea kaikki numerot riippumatta siitä, millä tavalla ne on eroteltu. Tämä on vaikeampi tehdä siististi rivi kerrallaan Files-luokan avulla, mutta Scanner-olion avulla asia hoituu kätevämmin. Scanner-olion useDelimiter()-metodilla voit määritellä, mitkä merkit toimivat erottimina. Esimerkiksi useDelimiter("[\\s,;]+") määrittelee, että välilyönti, pilkku ja puolipiste ovat erottimia. Kaikki ennen erotinmerkkiä olevat merkit muodostavat niin sanotun tokenin, joka voidaan lukea next()-metodilla.

import java.io.File;
import java.io.IOException;
import java.util.Locale;
import java.util.Scanner;

public class LueNumerotScannerilla {
    public static void main(String[] args) throws IOException {

        double summa = 0.0;
        int maara = 0;

        Scanner sc = new Scanner(new File("mittaukset.txt"));
        try {
            sc.useLocale(Locale.US); // desimaalierottimena piste "."
            sc.useDelimiter("[\\s,;]+"); // erottimina välilyönti, rivinvaihto, pilkku tai puolipiste

            while (sc.hasNext()) { // onko vielä luettavia tokeneja
                if (sc.hasNextDouble()) { // onko seuraava palanen kelvollinen luku
                    summa += sc.nextDouble(); // lue luku ja lisää summaan
                    maara++;
                } else {
                    sc.next(); // ohita token, joka ei ole kelvollinen luku
                }
            }
        } finally {
            sc.close();
        }

        IO.println("Lukuja: " + maara);
        IO.println("Summa: " + summa);
        IO.println("Keskiarvo: " + (maara == 0 ? 0 : summa / maara));
    }
}

Scanner-oliolla ei voi kirjoittaa tiedostoon, se on vain lukutyökalu.

Tietovirrat (Stream)

Kokoelmien ohella (ks. osa 6.2) myös tiedostoja (ja muitakin ulkoisia resursseja) voidaan käsitellä Stream-rajapinnan avulla. Streamit ovat hyödyllisiä silloin, kun dataan halutaan tehdä useita peräkkäisiä operaatioita, kuten muunnoksia (map), suodatuksia (filter) ja keräilyä (esim. toList, collect). Tällöin käsittely kuvataan ketjuna, joka kertoo selkeästi mitä datalle tehdään vaihe vaiheelta.

Luettaessa tiedostoa virtana tyypillinen aloitus on Files.lines(polku). Se tuottaa rivit laiskasti: rivejä ei lueta etukäteen kokonaan muistiin, vaan niitä luetaan sitä mukaa kuin streamiä kulutetaan. Tämä on keskeinen ero readAllLines-metodiin: Files.lines sopii myös suurille tiedostoille, koska se ei vaadi koko tiedoston lataamista muistiin. Koska tiedostoa luetaan taustalla, stream täytyy sulkea.

Tehdään aluksi yksinkertainen esimerkki, jossa toistetaan aiempi kuvio, mutta nyt käytetään Files.lines-metodia ja Stream-käsittelyä. Käytämme aiemmin opittua map-operaatiota muuntamaan jokaisen rivin taulukkomuotoon. Käytämme kerääjäfunktiona forEach-metodia, joka suorittaa annetun lambda-lausekkeen jokaiselle riville. Tässä parsimme rivit samalla tavalla kuin aiemmissa esimerkeissä, ja lopuksi tulostamme nimet ja iät.

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;

public class TiedostonLukijaStream {
    static void main() {
        try {
            Files.lines(Paths.get("data.csv"))
                    .skip(1) // Ohitetaan otsikkorivi
                    .map(line -> line.split(",")) // Pilkotaan rivi sarakkeiksi
                    .forEach(parts -> {
                        String nimi = parts[0]; // Ensimmäinen sarake on nimi
                        int ika = Integer.parseInt(parts[1]); // Toinen sarake on ikä, parsitaan intiksi
                        IO.println("Nimi: " + nimi + ", Ikä: " + ika);
                    });
        } catch (IOException e) {
            IO.println("Tiedostoa ei löydy tai sitä ei voi lukea: " + e.getMessage());
        }
    }
}

Jatketaan esimerkkiä hieman. Suodatetaan sellaiset henkilöt pois, joiden ikä on alle 18 vuotta, ja lopuksi tulostetaan nimet aakkosjärjestyksessä.

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.List;

public class TiedostonLukijaStream {
    static void main() {
        try {
List<String> nimet = Files.lines(Paths.get("data.csv"))
        .skip(1) // Ohitetaan otsikkorivi
        .map(line -> line.split(",")) // Pilkotaan rivi sarakkeiksi
// HIGHLIGHT_GREEN_BEGIN
        .filter(parts -> Integer.parseInt(parts[1]) >= 18) // Suodatetaan alle 18-vuotiaat
        .map(parts -> parts[0]) // Otetaan vain nimi
        .sorted() // Järjestetään nimet aakkosjärjestykseen
        .toList(); // Kerätään tulokset listaksi
nimet.forEach(IO::println); // Tulostetaan nimet
// HIGHLIGHT_GREEN_END
        } catch (IOException e) {
            IO.println("Tiedostoa ei löydy tai sitä ei voi lukea: " + e.getMessage());
        }
    }
}

Virtapohjaisessa käsittely on varsin näppärää, kun käsittely on suhteellisen yksinkertaista ja lineaarisesti etenevää. Virtapohjainen käsittely voi kuitenkin merkittävästi hankaloittaa esimerkiksi debuggaamista, joka on hyvä tiedostaa.

Valinnaista lisätietoa: Stream-käsittelyn haasteista tarkemmin
  • kertakäyttöisyys: Stream-olion voi käyttää vain kerran, minkä jälkeen se on suljettava. Jos haluat käsitellä samaa dataa uudestaan, sinun täytyy luoda uusi Stream-olio.
  • debuggaaminen: Ketjutus piilottaa välitulokset. Jos jokin map/filter-vaihe heittää poikkeuksen, pinoloki kertoo kyllä missä lambdassa oltiin, mutta "mikä rivi" ja "millä välituloksella" ei näy ilman erillisiä tulostuksia tai erillisen peek()-metodin kutsumista. Lambda-lausekkeita ei voi askeltaa yhtä suoraviivaisesti kuin perinteistä for-silmukkaa.
  • virheiden käsittely: lambda-lausekkeiden sisällä tapahtuvat tarkistamattomat poikkeukset (esim. NumberFormatException Integer.parseInt()-kutsussa) on käsiteltävä erikseen, koska lambda-lausekkeet eivät salli tarkistamattomien poikkeusten heittämistä suoraan. Tämä voi tehdä virheiden käsittelystä hieman monimutkaisempaa verrattuna perinteiseen silmukkaan.
  • laiskuus voi yllättää: Stream ei tee mitään ennen keräysoperaatiota (forEach, toList, collect, count, …). Tämä voi aiheuttaa yllätyksiä, kuten että koodi näyttää lukevan tiedoston, mutta mitään ei tapahdu, jos keräysvaihe puuttuu. Myöskään poikkeukset eivät synny siinä kohdassa, missä tiedosto avataan, vaan vasta keräysvaiheessa.

BufferedReader ja BufferedWriter

BufferedReader ja BufferedWriter ovat "perinteisiä" työkalut tekstitiedostojen käsittelyyn silloin, kun haluat lukea ja kirjoittaa rivi kerrallaan ja hallita käsittelysilmukkaa tarkasti. Ne puskuroivat I/O:ta, eli eivät tee järjestelmäkutsua jokaisesta yksittäisestä merkistä, vaan lukevat ja kirjoittavat suuremmissa paloissa. Tämä parantaa suorituskykyä erityisesti suurilla tiedostoilla ja tekee käsittelystä ennustettavaa. Jätämme näiden opiskelun omatoimiseksi, valinnaiseksi harjoitukseksi.

JSON-muotoinen tiedosto

JSON (JavaScript Object Notation) on suosittu tiedonvaihtomuoto, joka on paljon käytetty erityisesti web-kehityksessä. JSON-tiedostot ovat avain-arvo-pareja sisältäviä tekstitiedostoja, jotka voivat sisältää monimutkaisia tietorakenteita, kuten taulukkoja ja olioita.

JSON voi sisältää seuraavan tyyppisiä arvoja:

  • merkkijono ("Maija")
  • luku (25)
  • totuusarvo (true / false)
  • tyhjä arvo (null)
  • taulukko ([...])
  • olio ({...})

Esimerkiksi tiedosto henkilot.json voi näyttää tältä:

[
  {
    "nimi": "Maija",
    "ika": 25,
    "kaupunki": "Jyväskylä"
  },
  {
    "nimi": "Matti",
    "ika": 30,
    "kaupunki": "Tampere"
  }
]

CSV:hen verrattuna JSONin etu on se, että kentät voivat olla sisäkkäisiä, eli vaikkapa "kaupunki" voisi olla olio, jossa on "aikaisemmat_kaupungit" ja "nykyinen_kaupunki". Näin ollen rivit eivät ole sidottuja yhteen taulukkomalliin. Haittapuolena rakenne on usein hieman raskaampi lukea silmämääräisesti verrattuna CSV:hen. Lisäksi niissä on hieman enemmän syntaksia, mikä tekee niistä hieman monimutkaisempia käsitellä "käsin" ilman erillistä kirjastoa.

JSON-tiedostojen käsittely Jackson-kirjastolla

JSONin käsittely onnistuu toki käsin merkkijonoja pilkkomalla, mutta käytännössä tämä on virhealtista. Siksi JSONia kannattaa käsitellä siihen tarkoitetulla kirjastolla. Yksi yleisimmistä Java-kirjastoista on Jackson.

Lisää pom.xml-tiedostoon Jackson-riippuvuus:

<dependency>
    <groupId>tools.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>3.0.4</version>
</dependency>

Riippuvuuden lisäämisen jälkeen virkistä Maven-projektisi. Alla oleva esimerkki lukee tiedoston henkilot.json listaksi Henkilo-olioita. Selitämme koodin tarkemmin seuraavaksi.

import tools.jackson.core.JacksonException;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
import java.nio.file.Path;
import java.util.List;


public class LueJson {
    public static void main(String[] args) {
        ObjectMapper mapper = new ObjectMapper();
        Path polku = Path.of("data", "henkilot.json");

        try {
            List<Henkilo> henkilot = mapper.readValue(
                    polku.toFile(),
                    new TypeReference<List<Henkilo>>() {}
            );

            henkilot.forEach(h ->
                    IO.println(h.nimi() + " (" + h.ika() + "), " + h.kaupunki())
            );
        } catch (JacksonException je) {
            IO.println("JSONin lukeminen epäonnistui: " + e.getMessage());
        }
    }
}

JSONin lukeminen tiedostosta: Lukeminen aloitetaan luomalla ObjectMapper-olio, joka on Jackson-kirjaston keskeinen työkalu JSONin muuntamiseen Java-olioiksi ja päinvastoin. Tämän jälkeen käytetään readValue()-metodia, joka ottaa JSON-tiedoston ja kertoo, minkä tyyppiseksi JSONin pitäisi muuntaa.

Jotta muunnos olisi mahdollinen, meidän täytyy mallintaa JSON-tieto Java-olioiksi. Tehdään Java-luokka Henkilo, jossa on kentät nimi, ika ja kaupunki, eli saman nimiset kentät kuin JSON-tiedostossa. Tehdään myös niitä vastaavat getterit ja setterit, sekä tyhjä konstruktori – tämän kaltainen luokka on Jackson-kirjaston vaatimus, jotta se osaa luoda olioita JSONista. Alla esimerkki.

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

    public Henkilo() {
    }

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

    public String getNimi() {
        return nimi;
    }

    public int getIka() {
        return ika;
    }

    public void setIka(int ika) {
        this.ika = ika;
    }

    public void setKaupunki(String kaupunki) {
        this.kaupunki = kaupunki;
    }

    public String getKaupunki() {
        return kaupunki;
    }
}

Tiedoston lukeminen voi epäonnistua, joten readValue()-metodi on syytä kääriä try-catch-rakenteeseen. Jackson-kirjasto heittää JacksonException-poikkeuksen, mikä on IOException-poikkeuksen aliluokka.

JSONin kirjoittaminen tiedostoon on aavistuksen lukemista helpompaa. Kirjoittaminen tapahtuu writeValue()-metodilla, joka ottaa tiedoston ja tallennettavan olion, ja muuntaa sen JSON-muotoon. Tässä on mahdollista, että

  1. kansion luominen epäonnistuu; createDirectories heittää IOException-poikkeuksen, tai
  2. JSON-tiedoston lukeminen epäonnistuu, jos tiedosto ei löydy, JSON on virheellistä tai tyyppimuunnos epäonnistuu; writeValue heittää JacksonException-poikkeuksen.

Kumpikin näistä poikkeuksista tulee käsitellä erikseen.

Seuraava esimerkki kirjoittaa listan henkilöitä tiedostoon output/henkilot-uusi.json:

import tools.jackson.databind.ObjectMapper;
import tools.jackson.core.JacksonException;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;

public class KirjoitaJson {
    static void main() {
        ObjectMapper mapper = new ObjectMapper();

        List<Henkilo> henkilot = List.of(
                new Henkilo("Aino", 22, "Turku"),
                new Henkilo("Pekka", 41, "Oulu")
        );

        Path polku = Path.of("data", "henkilot-uusi.json");

        try {
            Files.createDirectories(polku.getParent());
            mapper.writeValue(polku.toFile(), henkilot);
            IO.println("Kirjoitettiin JSON: " + polku.toAbsolutePath());
        } catch (IOException e) {
            IO.println("Kansion luominen epäonnistui: " + e.getMessage());
        } catch (JacksonException je) {
            IO.println("JSON-prosessointi epäonnistui: " + je.getMessage());
        }
    }
}

Record-luokka JSONin mallintamiseen

Esitellään tässä kohtaa lyhyesti Javan record-käsite. Javan record on erityinen luokka, joka on suunniteltu pienten, suoraviivaisten datarakenteiden kuten JSONin kaltaisen rakenteisen datan mallintamiseen. Kun määrittelet recordin, Java muodostaa automaattisesti joitain rutiineja sinulle valmiiksi taustalla. Saat valmiina:

  • automaattisen konstruktorin, joka ottaa kaikki kentät argumentteina,
  • kenttiä vastaavat "getterit", joiden nimet ovat suoraan kenttien nimet, esim. nimi() eikä getNimi(),
  • equals()- ja hashCode()-toteutukset,
  • selkeän toString()-tulostuksen.

Record-olion komponentit (eli attribuutit) ovat käytännössä final-kenttiä, mikä tarkoittaa, että recordit ovat pääosin muuttumattomia olioita.

Näin recordit ovat luonnollinen pari JSON-kirjastoille: JSON-olio vastaa usein suoraan yhtä "datakimppua", jonka voi mallintaa recordilla ilman ylimääräistä koodia. Siinä missä perinteinen luokka kirjoitetaan usein niin, että määritellään kentät, konstruktorit ja getterit erikseen, recordissa sama asia ilmaistaan tiiviisti yhdellä rivillä.

On hyvä huomata, että recordiin saa kyllä kirjoittaa metodeja ja tarkistuksia, mutta perusajatus on pitää se pienenä ja keskittyä datan esittämiseen. Jos luokkaan alkaa kertyä paljon muuttuvaa tilaa tai monimutkaista toiminnallisuutta, tavallinen luokka on yleensä parempi valinta.

Määritellään datalle nyt tietotyyppi käyttäen record-rakennetta:

public record Henkilo(String nimi, int ika, String kaupunki) {}

Tällä tavalla pitkähkö Henkilo-luokka saadaan korvattua yhdellä rivillä. Tässä tapauksessa record-luokka on toiminnallisuudeltaan täysin samanlainen kuin aiemmin määritetty perinteinen luokka.

Tehtävät

Tehtävä 6.9: Sanat. 1 p.

Lataa aineisto: sanat.txt

Tallenna tiedosto projektisi työskentelyhakemistoon nimellä sanat.txt.

Tiedostossa on yksi sana per rivi. Mukana on tarkoituksella tyhjiä rivejä, välilyöntejä sanojen alussa/lopussa, samoja sanoja useaan kertaan, eri kirjainkokoja (esim. Java, java, JAVA).

Esimerkki aineiston alusta:

  Java
python

JAVA
CSharp
  java  

Tee ohjelma, joka:

  • Lukee kaikki rivit.
  • Poistaa sanoista ylimääräiset välilyönnit ja muuttaa sanat pieniksi kirjaimiksi. Vinkki: String.trim() ja String.toLowerCase().
  • Poistaa tyhjät rivit.
  • Poistaa duplikaatit. (Vinkki: distinct()-metodi Stream API:lla, tai Set-kokoelma.)
  • Järjestää sanat aakkosjärjestykseen. (Vinkki: sorted()-metodi Stream API:lla, tai Collections.sort()-metodi Listalla.)
  • Kirjoittaa uuden tiedoston output/sanat-siisti.txt, jossa on siistitty sanalista (yksi sana per rivi).
  • Kirjoittaa lisäksi tiedoston output/raportti.txt, jossa on:
    • alkuperäisten rivien määrä
    • uniikkien sanojen määrä sen jälkeen, kun tyhjät rivit on poistettu ja sanat on siistitty
    • pisin sana (jos useita, mikä tahansa kelpaa). Vinkki: Jos ratkaiset tehtävän Stream API:lla, voit käyttää Stream.max(Comparator)-metodia pisimmän sanan löytämiseen.

Vinkki: tee ensin List<String> siistit = ..., ja kirjoita lopuksi Files.write(...) kahteen eri tiedostoon.

raportti.txt:n pitäisi näyttää tältä:

Alkuperäisiä rivejä: 1074
Siistittyjä sanoja: 59
Pisin sana: binarytree
Tee tehtävä TIMissä
Tehtävä 6.10: Lue henkilöt JSON-tiedostosta. 1 p.

EDIT 23.2.2026: Jackson-kirjaston riippuvuuksia ja esimerkkejä päivitetty materiaalissa. Pahoittelut virheistä.

  1. Tee uusi Maven-projekti, joka käyttää Jackson-kirjastoa JSON-tiedostojen käsittelyyn.
  2. Lisää pom.xml-tiedostoosi tarvittava riippuvuus.
  3. Lataa henkilot.json ja tallenna se projektiisi samaan kansioon kuin missä koodisi on.
  4. Henkilo-luokka tai vastaava record, jolla on kentät String nimi, int ika, String kaupunki.
  5. Lue tiedosto henkilot.json ja muuta se listaksi Henkilo-olioita.
  6. Suodata mukaan vain vähintään 18-vuotiaat.
  7. Tulosta heidän nimensä, ikänsä ja kaupunkinsa.

Muista varmistaa, että tallennat henkilot.json-tiedoston samaan kansioon kuin missä koodisi sijaitsee. Tarkista sitten ajokonfiguraatiostasi, että työskentelyhakemisto on sama kuin koodisi kansio, jotta tiedosto löytyy.

Saat henkilöiden tiedot tarvittaessa auki tästä:

henkilot.json

[ { "nimi": "Maija Laine", "ika": 25, "kaupunki": "Jyväskylä" }, { "nimi": "Matti Virtanen", "ika": 30, "kaupunki": "Tampere" }, { "nimi": "Liisa Niemi", "ika": 17, "kaupunki": "Helsinki" }, { "nimi": "Pekka Korhonen", "ika": 41, "kaupunki": "Oulu" }, { "nimi": "Aino Salmi", "ika": 22, "kaupunki": "Turku" }, { "nimi": "Jari Heikkinen", "ika": 19, "kaupunki": "Kuopio" }, { "nimi": "Sari Lehto", "ika": 16, "kaupunki": "Lahti" }, { "nimi": "Oskari Mäkinen", "ika": 28, "kaupunki": "Espoo" }, { "nimi": "Emilia Ranta", "ika": 33, "kaupunki": "Vantaa" }, { "nimi": "Teemu Koski", "ika": 45, "kaupunki": "Pori" }, { "nimi": "Noora Aalto", "ika": 18, "kaupunki": "Joensuu" }, { "nimi": "Kalle Hämäläinen", "ika": 52, "kaupunki": "Rovaniemi" } ]

Tee tehtävä TIMissä
Bonus: Tehtävä 6.11: CSV -> JSON. 1 p.

EDIT 23.2.2026: Jackson-kirjaston riippuvuuksia ja esimerkkejä päivitetty materiaalissa. Pahoittelut virheistä.

Tee ohjelma, joka lukee tiedoston henkilot.csv (muoto nimi,ika,kaupunki) ja kirjoittaa siitä saman tapainen JSON-tiedoston henkilot.json kuin edellisessä tehtävässä oli annettu valmiiksi. Jos rivi on virheellinen (esim. ikä ei ole numero), ohita rivi ja jatka käsittelyä.

Tulostiedoston pitäisi näyttää tältä. Ei haittaa, jos sisennykset tai rivinvaihdot eivät ole täsmälleen samanlaisia.

[
  {
    "nimi": "Maija Laine",
    "ika": 25,
    "kaupunki": "Jyväskylä"
  },
  {
    "nimi": "Matti Virtanen",
    "ika": 30,
    "kaupunki": "Tampere"
  },
  ...
]
Tee tehtävä TIMissä
Bonus: Tehtävä 6.12: Parempi laskukone 1 p.

Tee yksinkertainen laskinohjelma, joka kysyy käyttäjältä toistuvasti kaksi lukua sekä laskutoimituksen ja tulostaa laskutoimituksen tuloksen.

Ohjelman tulisi toimia suunnilleen seuraavasti:

Anna laskutoimitus muodossa <luku> <operaattori> <luku>.
Kirjoita "sulje" sulkeaksesi ohjelman.

> 1 + 1
2.0
> 10 - 1
9.0
> 0.5 * 100
50.0
> 10 / 2
5.0
> 10
Anna laskutoimitus muodossa <luku> <operaattori> <luku>.
> kissa
Anna laskutoimitus muodossa <luku> <operaattori> <luku>.
> sulje
Ohjelma sulkeutuu.

Ohjelman on käsiteltävä käyttäjän syötteessä olevat virheet siten, ettei ohjelma kaadu virheellisen syötteen vuoksi.

Toteuta peruslaskutoimituksista summa (+), erotus (-), tulo (*) ja osamäärä (/). Keksi lisäksi vähintään kaksi omaa vapaavalintaista laskutoimitusta ja toteuta ne.

Älä käytä ehtorakenteita varsinaisten laskutoimitusten valitsemiseen. Voit kuitenkin käyttää ehtorakenteita sekä try/catch-rakenteita syötteen oikeellisuuden tarkistamiseen.

Vinkki 1

Voit toteuttaa operaatiot lambdalausekkeina. Käytä lambdalausekkeiden tyyppinä BiFunction<Double, Double, Double> (JavaDoc) tai DoubleBinaryOperator (JavaDoc).

Vinkki 2

Voit käyttää Scanner-luokkaa käyttäjän syötteen lukemiseen:

Scanner lukija = new Scanner(kayttajanSyote);

double luku1 = lukija.nextDouble();
String laskutoimitus = lukija.next();
double luku2 = lukija.nextDouble();

Saatat joutua lisäämään tarvittavat poikkeustenkäsittelyt.

Tee tehtävä TIMissä

Osan kaikki tehtävät

huomautus

Jos palautat tehtävät ennen osan takarajaa (ma 23.2.2026 klo 11:59 (keskipäivä)), voit saada DL-BONUS-pisteitä harjoitustehtäviin. Lue lisää suorittaminen-sivulta.

Tehtävä 6.1: Laskukone 1 p.

Tee ohjelma, joka kysyy käyttäjältä kaksi desimaalilukua sekä laskutoimituksen ja tulostaa lopputuloksen seuraavasti:

Luku 1 > 12.0
Luku 2 > 3.0
Laskutoimitus (+, -, *, /) > +
12.0 + 3.0 = 15.0

Tässä vaiheessa sinun ei tarvitse käsitellä virheellisiä syötteitä, vaan voit olettaa, että luvut annetaan aina lukuina. Sallitut laskutoimitukset ovat summa (+), erotus (-), tulo (*) ja osamäärä (/). Voit olettaa, että vain näitä laskutoimituksia käytetään syötteenä.

Älä käytä silmukoita tai ehtorakenteita. Sen sijaan toteuta laskutoimitukset lambdalausekkeina ja tallenna ne hakurakenteeseen käyttäen laskutoimituksen merkkiä avaimena.

Ohjelman suoritus päättyy tuloksen näyttämisen jälkeen.

Vinkki 1

Voit käyttää lambdalausekkeiden tyyppinä BiFunction<Double, Double, Double> (JavaDoc) tai DoubleBinaryOperator (JavaDoc).

Vinkki 2

Voit käyttää hakurakenteen tyyppinä Map<String, BiFunction<Double, Double, Double>> tai Map<String, DoubleBinaryOperator>.

Voit joko valita hakurakenteelle tietyn toteutuksen tai alustaa muuttumattoman hakurakenteen Map.of-metodilla:

Map<String, BiFunction<Double, Double, Double>> laskutoimitukset = Map.of(
    "+", ...,
    "-", ...,
    "*", ...,
    "/", ...
);

...-kohdan tilalle riittää asettaa sopiva lambdalauseke.

Tee tehtävä TIMissä
Tehtävä 6.2: Vertailu harvinaisuuden mukaan 1 p.

Laajenna Kerailykortti-luokkaa lisäämällä sille attribuutti String harvinaisuus. Keräilykortin harvinaisuus voi olla yksi seuraavista arvoista (vähiten harvinaisesta harvinaisimpaan): C, U, R, RR, RRR, SR, AR, SAR, UR.

Kirjoita vertailija, joka järjestää listassa olevat kortit niiden harvinaisuuden mukaan. Voit käyttää seuraavaa valmista korttikokoelmaa koodisi testaamiseen:

Mallilista erilaisista korteista
List<Kerailykortti> kortit = new ArrayList<>(List.of(
    new Kerailykortti("Kadonnut Puolipiste", "Koodiviidakko", 101, "C"),
    new Kerailykortti("Loputon Silmukka", "Koodiviidakko", 102, "U"),
    new Kerailykortti("Bugimetsästäjä", "Koodiviidakko", 103, "R"),
    new Kerailykortti("Spagettikoodi-Hirviö", "Koodiviidakko", 104, "RR"),
    new Kerailykortti("Ylikellotettu Prosessori", "Koodiviidakko", 105, "SR"),
    new Kerailykortti("Pyhä Stack Overflow", "Koodiviidakko", 106, "RRR"),
    new Kerailykortti("Null Pointer -Ninja", "Koodiviidakko", 107, "U"),
    new Kerailykortti("Sininen Kuolemanruutu", "Koodiviidakko", 108, "AR"),

    new Kerailykortti("Opiskelijakortti", "Kampus-Saaga", 201, "C"),
    new Kerailykortti("Unelias Luennoitsija", "Kampus-Saaga", 202, "C"),
    new Kerailykortti("Haalaribileet", "Kampus-Saaga", 203, "U"),
    new Kerailykortti("Ylisuorittaja", "Kampus-Saaga", 204, "R"),
    new Kerailykortti("Ilmainen Ämpäri", "Kampus-Saaga", 205, "SAR"),
    new Kerailykortti("Myöhästynyt Palautus", "Kampus-Saaga", 206, "RR"),
    new Kerailykortti("Akateeminen Vartti", "Kampus-Saaga", 207, "SR"),
    new Kerailykortti("Gradu-Ahdistus", "Kampus-Saaga", 208, "AR"),
    new Kerailykortti("Semman Pannukakku", "Kampus-Saaga", 209, "UR"),

    new Kerailykortti("Vihainen Hirvi", "Suomi-Myytit", 301, "C"),
    new Kerailykortti("Ikuinen Marraskuu", "Suomi-Myytit", 302, "RR"),
    new Kerailykortti("Saunaklonkku", "Suomi-Myytit", 303, "SR"),
    new Kerailykortti("Salmiakkisade", "Suomi-Myytit", 304, "U"),
    new Kerailykortti("Väinämöisen Kantele", "Suomi-Myytit", 305, "SAR"),
    new Kerailykortti("Sisu", "Suomi-Myytit", 306, "RRR"),
    new Kerailykortti("Laser-Löylykauha", "Suomi-Myytit", 307, "UR"),
    new Kerailykortti("Toripoliisi", "Suomi-Myytit", 308, "R")
));

Kirjoita main()-ohjelma, joka järjestää ja tulostaa keräilykortit harvinaisuuden mukaan (yleisimmät kortit ensin, harvinaisimmat viimeiseksi). Kortit, joiden harvinaisuus on jokin muu kuin yllä mainitut tai null, tulee sijoittaa listan alkuun.

Tee tehtävä TIMissä
Tehtävä 6.3: Musiikkilista 1 p.

Olkoon käytössä luokka Kappale, joka edustaa yksittäistä musiikkikappaletta. Kappaleella on nimi, genre ja kesto sekunteina:

class Kappale {
    String nimi;
    String genre;
    int kestoSekunteina;
}

Lisää luokalle tarvittavat näkyvyysmääreet, muodostaja, tarpeelliset saantimetodit (getterit) sekä sopiva toString()-metodin toteutus.

Tee funktio teeSoittolista(kappaleet, genre, kappaleita), joka palauttaa korkeintaan kappaleita-muuttujan ilmoittaman määrän kappaleita, joiden genre vastaa annettua genre-parametria. Kappaleiden tulee olla järjestettynä keston mukaan lyhyemmästä pisimpään.

Voit käyttää seuraavaa mallilistaa koodisi testaamiseen:

Lista mallikappaleista
List<Kappale> biisilista = List.of(
    new Kappale("Bohemian Rhapsody", "Rock", 354),
    new Kappale("Levitating", "Pop", 203),
    new Kappale("Sandstorm", "Electronic", 225),
    new Kappale("Paranoid", "Metal", 168),
    new Kappale("Toxic", "Pop", 198),
    new Kappale("Master of Puppets", "Metal", 515),
    new Kappale("Cha Cha Cha", "Pop", 175),
    new Kappale("Hotel California", "Rock", 390),
    new Kappale("Stay", "Pop", 141),
    new Kappale("Enter Sandman", "Metal", 331),
    new Kappale("Bad Romance", "Pop", 295),
    new Kappale("Midnight City", "Electronic", 243),
    new Kappale("Billie Jean", "Pop", 294),
    new Kappale("Hard Rock Hallelujah", "Metal", 247),
    new Kappale("Thriller", "Pop", 357),
    new Kappale("As It Was", "Pop", 167),
    new Kappale("Paint It, Black", "Rock", 202),
    new Kappale("Hollywood Hills", "Rock", 210),
    new Kappale("Get Lucky", "Electronic", 369),
    new Kappale("Shake It Off", "Pop", 219),
    new Kappale("Ace of Spades", "Metal", 169),
    new Kappale("Rolling in the Deep", "Pop", 228),
    new Kappale("Sweet Child O' Mine", "Rock", 356),
    new Kappale("Borderline", "Pop", 210),
    new Kappale("Back in Black", "Rock", 255),
    new Kappale("Shape of You", "Pop", 233),
    new Kappale("Fear of the Dark", "Metal", 438),
    new Kappale("Blinding Lights", "Pop", 200),
    new Kappale("Stairway to Heaven", "Rock", 482),
    new Kappale("Uptown Funk", "Pop", 269),
    new Kappale("Smells Like Teen Spirit", "Rock", 301),
    new Kappale("Short Pop Song", "Pop", 120)
);

Älä käytä silmukoita, vaan toteuta teeSoittolista käyttäen striimejä.

Vinkki

Saatat tarvita ainakin seuraavia Stream-metodeja:

  • filter(): alkioiden suodatus
  • sorted(): alkioiden järjestäminen
  • limit(): alkioiden lukumäärän rajaaminen
  • toList(): alkioiden kerääminen listaksi
Tee tehtävä TIMissä
Tehtävä 6.4: Keskiarvo raja-arvoilla 1 p.

Tee funktio double keskiarvo(int[] luvut, int minimi, int maksimi). Funktio laskee taulukkona annettujen lukujen keskiarvon seuraavilla ehdoilla:

  • Jos alkio on pienempi tai yhtä suuri kuin minimi, alkio hylätään eikä sitä lasketa keskiarvoon mukaan.
  • Jos alkio on suurempi tai yhtä suuri kuin maksimi, kyseinen alkio ja kaikki sen jälkeen tulevat alkiot hylätään.

Esimerkki:

IO.println(keskiarvo(new int[] { -5, 1, -4, 0, 98 }, -7, 99));
IO.println(keskiarvo(new int[] { 11, 4, 2, 6, 99, 12, 0, -3 }, 3, 99));
IO.println(keskiarvo(new int[] { 99, 1, 2, 3 }, 0, 99));
18.0
7.0
0.0

Ensimmäinen kutsu palauttaa 18.0, sillä aineisto on kokonaisuudessaan minimin ja maksimin välissä. Toinen kutsu palauttaa taas 7.0, sillä vain luvut 11, 4 ja 6 otetaan keskiarvoon mukaan: luku 2 on pienempi kuin minimi ja kaikki luvusta 99 alkaen hylätään.

Jos keskiarvoa ei voida laskea, funktio palauttaa minimi-parametrin arvon.

Älä käytä silmukoita, vaan toteuta funktio käyttäen striimejä.

Vinkki

Tutustu IntStream-tyyppiin (JavaDoc) ja sen metodeihin. Voit hyötyä ainakin seuraavista:

  • filter(): alkioiden suodattaminen pois striimistä
  • takeWhile(): ottaa alkioita striimistä niin kauan kuin ehto on tosi; heti kun ehto on epätosi, striimin käsittely loppuu siihen (kuin "hana", joka suljetaan).
  • average(): laskee keskiarvon

Huomaa, että average() palauttaa OptionalDouble-olion (JavaDoc). Olio sisältää orElse()-metodin, jonka avulla voit palauttaa joko lasketun arvon tai vaihtoehtoisen oletusarvon.

Bonustieto

Samankaltainen tehtävä tehdään Ohjelmointi 1 -kurssilla käyttäen silmukoita (ks. Ohjelmointi 1: demo 6, tehtävä B1). Jos olet suorittanut kyseisen kurssin, voit verrata striimeillä tehtyä ratkaisuasi aiemmin tekemääsi silmukkaratkaisuun.

Tee tehtävä TIMissä
Tehtävä 6.5 : Poikkeukset, osa 1. 1 p.

Tee monivalintatehtävä TIMissä.

Tee tehtävä TIMissä
Tehtävä 6.6: Poikkeukset, osa 2. 1 p.

Tee main, joka lukee käyttäjältä silmukassa kokonaislukuja niin kauan, kuin käyttäjä antaa muun kuin tyhjän syötteen. Jos käyttäjä antaa muun kuin kokonaisluvun, tulosta virheilmoitus ja jatka lukemista.

Tallenna luvut taulukkoon. Tulosta lopuksi kaikki taulukkoon tallennetut luvut.

Tee tehtävä TIMissä
Tehtävä 6.7: Poikkeukset, osa 3. 1 p.

Tee ohjelma, joka tarkistaa, onko käyttäjän syöttämä ikä riittävä tiettyyn toimintaan, esimerkiksi ajokortin hankkimiseen.

Tee aliohjelma onkoIkaa, joka ottaa parametrina iän (int) ja palauttaa true, jos ikä on riittävä. Jos ikä on alle 18, heitä poikkeus IkaException, joka on oma tarkastettu poikkeusluokka. Anna sopiva poikkeusviesti, esimerkiksi "Ikä ei riitä.". Ohessa vinkiksi metodin esittelyrivi.

static boolean onkoIkaa(int ika) throws IkaException

Jos ikä on negatiivinen, heitä poikkeus IkaException viestillä "Ikä ei voi olla negatiivinen.".

Poista if-rakenne ja muokkaa main-metodia niin, että se kääntyy ja tulostaa oikeat asiat.

Tee tehtävä TIMissä
Tehtävä 6.8: Riippuvuudet. 1 p.

Tee uusi Maven-projekti. Aseta pakkauksen nimeksi fi.jyu.omatunnus (laita omatunnus-kohdalle JY-käyttäjätunnus tai jokin muu keksimäsi käyttäjänimi). Anna pääluokan nimeksi Riippuvuudet. Lisää siihen tämä koodi.

void main() {
    JSONObject json = new JSONObject();
    json.put("nimi", "Maija");
    json.put("ika", 25);
    IO.println(json.getString("nimi"));
    IO.println(json.getInt("ika"));
}

Lisää nyt pom.xml-tiedostoon riippuvuus json-nimiseen artefaktiin. Etsi tämä kirjasto Maven Centralista, ja kopioi sieltä XML-koodi pom.xml-tiedostoosi. Lisää myös riippuvuuden vaatima import-lause luokan alkuun.

Käännä ja aja ohjelma, ja varmista, että se tulostaa odotetut tiedot.

Tee tehtävä TIMissä
Tehtävä 6.9: Sanat. 1 p.

Lataa aineisto: sanat.txt

Tallenna tiedosto projektisi työskentelyhakemistoon nimellä sanat.txt.

Tiedostossa on yksi sana per rivi. Mukana on tarkoituksella tyhjiä rivejä, välilyöntejä sanojen alussa/lopussa, samoja sanoja useaan kertaan, eri kirjainkokoja (esim. Java, java, JAVA).

Esimerkki aineiston alusta:

  Java
python

JAVA
CSharp
  java  

Tee ohjelma, joka:

  • Lukee kaikki rivit.
  • Poistaa sanoista ylimääräiset välilyönnit ja muuttaa sanat pieniksi kirjaimiksi. Vinkki: String.trim() ja String.toLowerCase().
  • Poistaa tyhjät rivit.
  • Poistaa duplikaatit. (Vinkki: distinct()-metodi Stream API:lla, tai Set-kokoelma.)
  • Järjestää sanat aakkosjärjestykseen. (Vinkki: sorted()-metodi Stream API:lla, tai Collections.sort()-metodi Listalla.)
  • Kirjoittaa uuden tiedoston output/sanat-siisti.txt, jossa on siistitty sanalista (yksi sana per rivi).
  • Kirjoittaa lisäksi tiedoston output/raportti.txt, jossa on:
    • alkuperäisten rivien määrä
    • uniikkien sanojen määrä sen jälkeen, kun tyhjät rivit on poistettu ja sanat on siistitty
    • pisin sana (jos useita, mikä tahansa kelpaa). Vinkki: Jos ratkaiset tehtävän Stream API:lla, voit käyttää Stream.max(Comparator)-metodia pisimmän sanan löytämiseen.

Vinkki: tee ensin List<String> siistit = ..., ja kirjoita lopuksi Files.write(...) kahteen eri tiedostoon.

raportti.txt:n pitäisi näyttää tältä:

Alkuperäisiä rivejä: 1074
Siistittyjä sanoja: 59
Pisin sana: binarytree
Tee tehtävä TIMissä
Tehtävä 6.10: Lue henkilöt JSON-tiedostosta. 1 p.

EDIT 23.2.2026: Jackson-kirjaston riippuvuuksia ja esimerkkejä päivitetty materiaalissa. Pahoittelut virheistä.

  1. Tee uusi Maven-projekti, joka käyttää Jackson-kirjastoa JSON-tiedostojen käsittelyyn.
  2. Lisää pom.xml-tiedostoosi tarvittava riippuvuus.
  3. Lataa henkilot.json ja tallenna se projektiisi samaan kansioon kuin missä koodisi on.
  4. Henkilo-luokka tai vastaava record, jolla on kentät String nimi, int ika, String kaupunki.
  5. Lue tiedosto henkilot.json ja muuta se listaksi Henkilo-olioita.
  6. Suodata mukaan vain vähintään 18-vuotiaat.
  7. Tulosta heidän nimensä, ikänsä ja kaupunkinsa.

Muista varmistaa, että tallennat henkilot.json-tiedoston samaan kansioon kuin missä koodisi sijaitsee. Tarkista sitten ajokonfiguraatiostasi, että työskentelyhakemisto on sama kuin koodisi kansio, jotta tiedosto löytyy.

Saat henkilöiden tiedot tarvittaessa auki tästä:

henkilot.json

[ { "nimi": "Maija Laine", "ika": 25, "kaupunki": "Jyväskylä" }, { "nimi": "Matti Virtanen", "ika": 30, "kaupunki": "Tampere" }, { "nimi": "Liisa Niemi", "ika": 17, "kaupunki": "Helsinki" }, { "nimi": "Pekka Korhonen", "ika": 41, "kaupunki": "Oulu" }, { "nimi": "Aino Salmi", "ika": 22, "kaupunki": "Turku" }, { "nimi": "Jari Heikkinen", "ika": 19, "kaupunki": "Kuopio" }, { "nimi": "Sari Lehto", "ika": 16, "kaupunki": "Lahti" }, { "nimi": "Oskari Mäkinen", "ika": 28, "kaupunki": "Espoo" }, { "nimi": "Emilia Ranta", "ika": 33, "kaupunki": "Vantaa" }, { "nimi": "Teemu Koski", "ika": 45, "kaupunki": "Pori" }, { "nimi": "Noora Aalto", "ika": 18, "kaupunki": "Joensuu" }, { "nimi": "Kalle Hämäläinen", "ika": 52, "kaupunki": "Rovaniemi" } ]

Tee tehtävä TIMissä
Bonus: Tehtävä 6.11: CSV -> JSON. 1 p.

EDIT 23.2.2026: Jackson-kirjaston riippuvuuksia ja esimerkkejä päivitetty materiaalissa. Pahoittelut virheistä.

Tee ohjelma, joka lukee tiedoston henkilot.csv (muoto nimi,ika,kaupunki) ja kirjoittaa siitä saman tapainen JSON-tiedoston henkilot.json kuin edellisessä tehtävässä oli annettu valmiiksi. Jos rivi on virheellinen (esim. ikä ei ole numero), ohita rivi ja jatka käsittelyä.

Tulostiedoston pitäisi näyttää tältä. Ei haittaa, jos sisennykset tai rivinvaihdot eivät ole täsmälleen samanlaisia.

[
  {
    "nimi": "Maija Laine",
    "ika": 25,
    "kaupunki": "Jyväskylä"
  },
  {
    "nimi": "Matti Virtanen",
    "ika": 30,
    "kaupunki": "Tampere"
  },
  ...
]
Tee tehtävä TIMissä
Bonus: Tehtävä 6.12: Parempi laskukone 1 p.

Tee yksinkertainen laskinohjelma, joka kysyy käyttäjältä toistuvasti kaksi lukua sekä laskutoimituksen ja tulostaa laskutoimituksen tuloksen.

Ohjelman tulisi toimia suunnilleen seuraavasti:

Anna laskutoimitus muodossa <luku> <operaattori> <luku>.
Kirjoita "sulje" sulkeaksesi ohjelman.

> 1 + 1
2.0
> 10 - 1
9.0
> 0.5 * 100
50.0
> 10 / 2
5.0
> 10
Anna laskutoimitus muodossa <luku> <operaattori> <luku>.
> kissa
Anna laskutoimitus muodossa <luku> <operaattori> <luku>.
> sulje
Ohjelma sulkeutuu.

Ohjelman on käsiteltävä käyttäjän syötteessä olevat virheet siten, ettei ohjelma kaadu virheellisen syötteen vuoksi.

Toteuta peruslaskutoimituksista summa (+), erotus (-), tulo (*) ja osamäärä (/). Keksi lisäksi vähintään kaksi omaa vapaavalintaista laskutoimitusta ja toteuta ne.

Älä käytä ehtorakenteita varsinaisten laskutoimitusten valitsemiseen. Voit kuitenkin käyttää ehtorakenteita sekä try/catch-rakenteita syötteen oikeellisuuden tarkistamiseen.

Vinkki 1

Voit toteuttaa operaatiot lambdalausekkeina. Käytä lambdalausekkeiden tyyppinä BiFunction<Double, Double, Double> (JavaDoc) tai DoubleBinaryOperator (JavaDoc).

Vinkki 2

Voit käyttää Scanner-luokkaa käyttäjän syötteen lukemiseen:

Scanner lukija = new Scanner(kayttajanSyote);

double luku1 = lukija.nextDouble();
String laskutoimitus = lukija.next();
double luku2 = lukija.nextDouble();

Saatat joutua lisäämään tarvittavat poikkeustenkäsittelyt.

Tee tehtävä TIMissä

JavaFX osa 1, SceneBuilder

osaamistavoitteet

  • Osaat tehdä JavaFX-projektin
  • Ymmärrät käyttöliittymän luomisen periaatteet SceneBuilderia käyttäen
  • Osaat tehdä lomakkeen, jossa kysytään käyttäjältä tietoa, tallennetaan tieto (in-memory), ja esitetään syötetty tieto käyttäjälle käyttöliittymässä.
  • Osaat luoda projektillesi paikallisen Git-varaston sopivilla asetuksilla, ja tehdä varastoon committeja

Olemme tähän mennessä toteuttaneet ohjelmia, jotka toimivat tekstipohjaisessa komentoriviympäristössä. Tässä osassa otamme askeleen kohti visuaalisempia sovelluksia ja tutustumme JavaFX-kirjastoon, jonka avulla rakennamme graafisia käyttöliittymiä.

Käytämme SceneBuilder-työkalua käyttöliittymän visuaaliseen suunnitteluun. Samalla otamme käyttöön versionhallinnan (Git), joka auttaa meitä hallitsemaan projektin koodia ja valmistautumaan harjoitustyön ensimmäiseen vaiheeseen.

Osien 7 ja 8 aikana rakennamme yksinkertaisen Todo-sovelluksen. Tähän osioon kuuluu tehtäviä, joissa opit tekemään saman sovelluksen omatoimisesti. Nämä osat antavat sinulle tarvittavan ymmärryksen JavaFX:stä, jotta voit luoda oman harjoitustyön osien 9-11 aikana.

Osassa 7 teemme sovellukseen seuraavat ominaisuudet:

  • Käyttäjä voi lisätä uuden tehtävän
  • Käyttäjä näkee listan kaikista tehtävistä
  • Käyttäjä voi merkitä tehtävän tehdyksi
  • Käyttäjä voi poistaa tehtävän
  • Käyttäjä voi palauttaa tehdyn tehtävän takaisin tekemättömäksi
  • Tehtävät tallennetaan tiedostoon, jotta ne säilyvät sovelluksen sulkemisen jälkeen
  • Tehtävät haetaan tiedostosta sovelluksen käynnistyessä

Tämän osan lopuksi sovelluksemme toimii seuraavasti:

Kuten aiemminkin, tämänkin osan tehtävistä täytyy tehdä vähintään 50%. Erityisesti osissa 7 ja 8 kuitenkin suosittelemme tekemään kaikki tehtävät jotta harjoitustyön tekeminen olisi helpompaa. Bonustehtävät jäävät kuitenkin edelleen vapaavalintaisiksi.

JavaFX perusteet

osaamistavoitteet

  • Ymmärrät JavaFX-sovelluksen rakenteen

Olemme tähän saakka tehneet komentorivisovelluksia ja lähinnä tulostaneet tekstiä ruudulle. Graafinen käyttöliittymä (GUI) on kuitenkin monille ohjelmille olennainen osa. Graafisen käyttöliittymän avulla käyttäjä näkee painikkeita, valikoita ja kuvia sen sijaan, että hänen pitäisi opetella kirjoittamaan komentoja oikeassa muodossa.

Java-kielelle on useita kirjastoja graafisten käyttöliittymien toteuttamiseen, mutta JavaFX on niistä ehkäpä nykyaikaisin ja monipuolisin. JavaFX käsittelee käyttöliittymää puumaisena rakenteena. Jokainen ikkunan osa, kuten painike, teksti tai ryhmittelyelementti on "solmu" (Node), joka kuuluu johonkin suurempaan kokonaisuuteen. Tämä tekee monimutkaistenkin näkymien hallinnasta loogista.

Ulkoasu ja logiikka erotetaan JavaFX:ssä toisistaan. Ulkoasun määritellään käyttämällä FXML-kieltä, mikä on XML-pohjainen tiedostomuoto. Toiminnallinen logiikka kirjoitetaan tavallisena Java-koodina. Tämä muistuttaa tapaa, jolla web-kehityksessä erotetaan HTML (rakenne) ja JavaScript (toiminta).

JavaFX:ssä on oma toteutus CSS:stä (Cascading Style Sheets), joka tukee osaa CSS 2.1:n ominaisuuksista, ja joitain CSS 3:n ominaisuuksia. Tämän avulla käyttöliittymäelementtien tyylittelyjä voidaan tietyssä määrin toteuttaa web-kehityksestä tutulla tavalla. CSS-tuki on kuitenkin kohtalaisen rajallista, eikä esimerkiksi float-, position- tai flexbox-ominaisuuksia tueta. Joihinkin ominaisuuksiin löytyy joko JavaFX:n omat vastineensa, kuten flexboxin tapauksessa VBox/HBox. Myös kehittäjäyhteisö tuottaa jatkuvasti avoimen lähdekoodin kirjastoja, jotka tuovat joitain CSS:stä tuttuja ominaisuuksia JavaFX:ään.

Tutoriaali: Todo-sovellus

Osien 7 ja 8 aikana rakennamme yksinkertaisen Todo-sovelluksen. Tähän osioon kuuluu tehtäviä, joissa opit tekemään saman sovelluksen omatoimisesti. Nämä osat antavat sinulle tarvittavan ymmärryksen JavaFX:stä, jotta voit luoda oman harjoitustyön osien 9-11 aikana.

Osassa 7 teemme sovellukseen seuraavat ominaisuudet:

  • Käyttäjä voi lisätä uuden tehtävän
  • Käyttäjä näkee listan kaikista tehtävistä
  • Käyttäjä voi merkitä tehtävän tehdyksi
  • Käyttäjä voi poistaa tehtävän
  • Käyttäjä voi palauttaa tehdyn tehtävän takaisin tekemättömäksi
  • Tehtävät tallennetaan tiedostoon, jotta ne säilyvät sovelluksen sulkemisen jälkeen
  • Tehtävät haetaan tiedostosta sovelluksen käynnistyessä

Tämän osan lopuksi sovelluksemme toimii seuraavasti:

Kuten aiemminkin, tämänkin osan tehtävistä täytyy tehdä vähintään 50%. Erityisesti osissa 7 ja 8 kuitenkin suosittelemme tekemään kaikki tehtävät jotta harjoitustyön tekeminen olisi helpompaa. Bonustehtävät jäävät kuitenkin edelleen vapaavalintaisiksi.

Ensimmäinen JavaFX-sovellus

Tehdään nyt IDEAssa uusi JavaFX-projekti. Avaa IDEA ja valitse File New Project. Avautuu tuttu New Project -näkymä:

Aiemmissa osissa olemme luoneet tyhjiä projekteja, johon lisäsimme koodia ja riippuvuuksia. JavaFX vaatii kuitenkin useita riippuvuuksia, asetuksia ja alustuskoodia, joita olisi vaivallollista lisätä käsin aina, kun halutaan tehdä uusi sovellus.

Maven-projektille voidaan määritellä valmis pohja, eli ns. Maven-arkkityyppi (engl. Maven Archetype). Pohja sisältää esimerkiksi valmiin rakenteen projektille, koodia ja valmiiksi määritellyt riippuvuudet. Maven-arkkityyppejä on hyvin monenlaisia erilaisiin käyttötarkoituksiin. Olemme tehneet tälle opintojaksolle oman pohjan ja käytämme sitä jatkossa.

Valitse vasemmasta laidasta Maven Archetype jolloin saat seuraavat asetukset näkyviin:

Täytetään lomake meidän projektitiedoilla:

  • Name: TodoFx
  • Location: Valitse jokin kansio, johon haluat luoda projektin. Voit kirjoittaa kansion polun käsin tai käyttää kansionvalitsinta -ikonista
  • Create Git repository: Jätä tyhjäksi. Luomme uuden Git-varaston itse myöhemmin.
  • JDK: Valitse jokin Java 25 -vaihtoehto. Oletusarvo on yleensä hyvä. Tarvittaessa voit ladata JDK:n seuraamalla työkalujen asennusohjeita.
  • Catalog: Maven Central
  • Archetype: io.github.ohj-perus-jy:javafx-fxml-template
  • Version: Valitse uusin versio, jos se näkyy. Jos ei, kirjoita kenttään käsin 1.0.1.
  • Additional properties: Jätä muokkaamatta. Projektipohjan oletusarvot riittävät tähän tarkoitukseen.
  • Additional settings (Klikkaa otsikosta jos sen alla olevia asetuksia ei näy):
    • GroupId: Julkinen, yksilöllinen tunniste sovellukselle. Javassa yleinen käytäntö on kirjoittaa tunniste muodossa <oma verkkosivun osoite käänteisesti>.<sovelluksen tunniste>. Tässä materiaalissa voit käyttää tunnisteena fi.jyu.ohj2.nimesi.todo, missä nimesi on etunimesi tai käyttäjätunnuksesi ilman erikoismerkkejä.
    • ArtifactId: Tämä täsmää projektin Name-kentän kanssa
    • Version: 0.1

Tietojen täyttämisen jälkeen lomakkeen tulisi siten näyttää seuraavalta:

Paina sen jälkeen Create. Tämä luo projektin ja lataa Maven-arkkityypin riippuvuudet. Tämä saattaa kestää hetken, joten odota rauhassa. Lopuksi Run-paneelissa pitäisi lukea BUILD SUCCESS-teksti onnistumisen merkiksi:

Kokeillaan vielä käynnistää sovellus. Avaa projektiselaimessa src/main/java/<pakkauksen nimi>-kansiossa oleva Main-luokka ja klikkaa main-pääohjelman vieressä olevaa ajopainiketta () ja valitse Run:

Tämä käynnistää sovelluksen, jossa sinun pitäisi nyt nähdä yksi klikattava painike:

Lisäksi tämä luo ajokonfiguraation, jolloin voit käynnistää projektin yläpalkin ajopainikkeella.

JavaFX-sovelluksen rakenne

Projektia ajaessa saatoit jo huomata, että JavaFX-projekti sisältää valmiiksi muutaman tiedoston:

🗎🖿pom.xmlsrc🖿🖿🗎main.fxmlfi/jyu/ohj2/nimesi/todofi/jyu/ohj2/nimesi/todo🖿main🖿java🗎App.java🗎MainController.java🗎Main.java🖿resources
  • pom.xml on Maven-projektin konfiguraatiotiedosto.
  • fi/jyu/ohj2/nimesi/todo vastaa äsken asettamaasi GroupId-arvoa ja on projektin pääpakkaus. Java-kooditiedostot sijaitsevat tässä kansiossa.
  • App.java, MainController.java ja Main.java ovat JavaFX-sovellukseen liittyviä Java-luokkia.
  • main.fxml on JavaFX-sovelluksen käyttöliittymään liittyvä tiedosto.

JavaFX-sovellus koostuu yleensä kolmesta pääkomponentista: pääluokka, ulkoasu ja kontrolleriluokka.

Pääluokka on Java-luokka, joka toimii JavaFX-sovelluksen käynnistyspisteenä. Esimerkissämme se on App.java, jota kutsutaan Main.java-tiedostossa olevasta perinteisestä main()-pääohjelmasta. Pääluokka perii Application-luokan ja määrittelee, miten sovellus luo ja näyttää ikkunan. Pääluokka on vastuussa sovelluksen elinkaaren hallinnasta.

Käyttöliittymän näkymä määritellään niin kutsutussa FXML-tiedostossa, joka on XML-pohjainen kuvaus käyttöliittymästä. Esimerkissämme se löytyy resources-kansiosta nimeltä main.fxml. FXML-tiedosto määrittelee tekstimuodossa, millaisia komponentteja eli käyttöliittymäosia ikkunassa on ja miten ne on järjestetty. Pienessä projektissa FXML-tiedostoja on yleensä nolla tai yksi, mutta suuremmissa projekteissa niitä voi olla useita. Jos esimerkiksi sovelluksella on useita eri näkymiä, kuten pääikkuna, asetukset ja itse pääkäyttöliittymä, ja jokaiselle näkymälle voidaan luoda oma FXML-tiedosto.

Kontrolleriluokka on Java-luokka, joka sisältää logiikan käyttöliittymän komponenttien käsittelyyn. Kontrolleriluokka ja sitä vastaava FXML-näkymätiedosto ovat kytköksessä toisiinsa. Esimerkiksi projektissamme oleva MainController.java on kontrolleri näkymälle main.fxml. Kontrolleriluokassa määritellään, miten sovellus reagoi käyttöliittymän syötteisiin ja tapahtumiin. Toisin sanoen, sillä aikaan kun FXML-tiedosto määrittelee käyttöliittymän, kontrolleriluokka tekee käyttöliittymästä vuorovaikutteisen.

JavaFX-sovelluksen käynnistys ja ydinluokat

Tutustutaan hieman App.java-pääluokan rakenteeseen tarkemmin:

public class App extends Application {
    @Override
    public void start(Stage stage) throws IOException {
    /* 1 */ FXMLLoader loader = new FXMLLoader(getClass().getResource("main.fxml"));
    /* 1 */ Scene scene = new Scene(loader.load());

    /* 2 */ stage.setScene(scene);
    /* 3 */ stage.setTitle("MyApp");
    /* 4 */ stage.show();
    }
}

JavaFX-sovelluksen pääluokka perii Application-luokan, joka vastaa sovelluksen alustamisesta ja ikkunan luomisesta käyttöjärjestelmätasolla. JavaFX-kirjasto kutsuu start()-metodin, kun käyttöliittymälle on luotu ikkuna.

start()-metodin parametrina välittyy Stage-olio, joka vastaa sovellukselle luotua ikkunaa. start()-metodin vastuulla on yleensä suorittaa seuraavat neljä päävaihetta (ks. numerot kommenteissa):

  1. Näkymän alustaminen: aivan alkuun käyttöliittymän ensisijainen näkymä alustetaan luomalla Scene-olio. Scene on kokoelma käyttöliittymässä olevia komponentteja, eli ns. näkymäolio. Tässä projektipohjassa komponentit ladataan main.fxml-tiedostosta käyttäen FXMLLoader-apuluokkaa, joka alustaa näkymässä olevia komponentteja. Komponentteja voitaisiin luoda myös manuaalisesti alustamalla komponenttiolioita.

  2. Näkymän asettaminen ikkunaan: stage.setScene()-metodilla voidaan asettaa luotu Scene-olio ja siinä olevat komponentit näkyviin ikkunaan. Huomaa, että samassa ikkunassa (Stage) voi olla vain yksi näkymä (Scene) kerrallaan, mutta näkymä voidaan vaihtaa milloin tahansa. Tällä tavoin voidaan esimerkiksi toteuttaa erilaisia näkymiä samaan sovellukseen (esim. sisäänkirjautumisnäkymä, sovellusnäkymä, jne.)

  3. Ikkunan asetusten muuttaminen: Stage-olio sisältää lukuisia metodeja, jolla sovelluksen ikkunan toimintaa voidaan muuttaa. Yleinen toiminto on esimerkiksi setTitle()-metodi, jolla voi muuttaa ikkunan otsikon.

  4. Ikkunan näyttäminen: Aivan alussa sovelluksen ikkuna on piilossa käyttäjältä, jotta vältytään käyttöliittymän "välkkymiseltä". Stage-olion show()-metodi asettaa ikkunan näkyväksi, jolloin käyttäjä voi alkaa vuorovaikuttaa käyttöliittymän kanssa. Ikkuna laitetaan näkyväksi yleensä aivan viimeisenä, kun sovellus on täysin alustettu.

JavaFX:ssä kaikki käyttöliittymäosat perivät Node-luokan. Node eli ns. solmu vastaa käyttöliittymän yksittäistä komponenttia, kuten painiketta tai tekstiä. Node-oliot ovat rekursiivisia, eli solmu voi sisältää muita solmuja. JavaFX-käyttöliittymä muodostaakin puurakenteen, jossa komponentit sisältävät muita komponentteja. Esimerkiksi projektipohjan esimerkkisovelluksen rakenne voitaisiin mallintaa seuraavasti:

flowchart TD
    Stage["
        Stage
        (Pääikkuna)
    "]
    Scene["
        Scene
        (Näkymä)
    "]
    VBox["
        VBox
        (Node)
    "]
    Label["
        Label
        text: Hello JavaFX
    "]
    Button["
        Button
        text: Click Me!
    "]

    Stage --- Scene
    Scene --- VBox
    subgraph "UI-komponentit (Node)"
    VBox --- Label
    VBox --- Button
    end
Tehtävä 7.1: Todo-sovellus, vaihe 1 1 p.

Palauta tässä osan 7.1 perusteella luotu projekti.

Kertaus tämän osan vaiheista:

  • Tee io.github.ohj-perus-jy:javafx-fxml-template-archetypen pohjalta JavaFX-projekti.
  • Käynnistä ohjelma, ja varmista, että saat JavaFX-sovelluksen ikkunan näkyviin.

Palauta projektisi tiedostot.

Tee tehtävä TIMissä

SceneBuilder

osaamistavoitteet

  • Osaat käyttää SceneBuilder-työkalua JavaFX-käyttöliittymien luomiseen
  • Osaat yhdistää FXML-tiedoston ja kontrolleriluokan

tärkeää

Tästä luvusta alkaen tarvitset SceneBuilder-työkalun.

Asenna työkalu seuraamalla kurssin työkaluohjeita.

SceneBuilder on visuaalinen työkalu, joka helpottaa JavaFX-käyttöliittymien luomista. Se tarjoaa drag-and-drop-käyttöliittymän, jonka avulla voit luoda ja muokata FXML-tiedostossa määriteltyä käyttöliittymää ilman FXML:n kirjoittamista käsin. SceneBuilderin avulla voit helposti lisätä komponentteja, määrittää niiden ominaisuuksia ja järjestää ne haluamallasi tavalla. Se on erityisen hyödyllinen, jos et ole vielä tottunut kirjoittamaan FXML:ää suoraan tai haluat nopeuttaa käyttöliittymän suunnitteluprosessia.

SceneBuilderin päänäkymässä on kolme pääaluetta.

Ensimmäinen komponentti

Avataan nyt alkuun projektimme main.fxml-näkymätiedosto SceneBuilderissä.

Avaa SceneBuilder ja valitse vasemmasta alalaidasta Open Project. Hae ja avaa src/resources/pakkaus-kansiosta main.fxml (tässä pakkaus viittaa edellisessä vaiheessa luotun projektin pääpakkauksen alikansioita). Nyt sama käyttöliittymä, jonka näimme IDEAssa, pitäisi näkyä SceneBuilderissä:

Tutustutaan samalla SceneBuilderin käyttöliittymään:

  1. Suunnittelunäkymä. Tässä näet FXML-tiedoston määrittelemän käyttöliittymän visuaalisena esityksenä. Voit tässä raahata komponentteja ja järjestää niitä haluamallasi tavalla.

  2. Inspector-näkymä. Löydät tästä muun muassa Properties-, Layout- ja Code-paneeleja. Näissä paneeleissa voi muuttaa valitun komponentin ominaisuuksia, kuten tekstiä, fonttia, väriä sekä asettelua ja määrittää komponentin ja kontrollerin liittämiseen liittyvät asetukset.

  3. Library-näkymä. Löydät tästä kaikki käytettävissä olevat komponentit, kuten painikkeet, tekstikentät, layout-komponentit ja niin edelleen. Saat lisättyä komponentit käyttöliittymään raahaamalla ne suunnittelunäkymään.

  4. Document-näkymä. Näet tässä sovelluksesi kaikki komponentit puurakenteessa. Voit käyttää tämän näkymän komponenttien tarkkaan valintaan, siirtämiseen ja poistamiseen.

  • Vasemmalla on kaksi paneelia: Library ja Document. Library-paneelista löydät kaikki käytettävissä olevat komponentit, kuten painikkeet, tekstikentät, layout-komponentit ja niin edelleen. Document-paneelissa näet hierarkkisen esityksen oman sovelluksesi käyttöliittymän rakenteesta.

Suunnittelunäkymässä on valmiina painike, eli Button-komponentti sekä nimiö eli Label-komponentti. Jos napsautat Button-komponenttia, näet oikealla Properties-paneelissa kyseisen komponentin ominaisuuksia. Voit muuttaa esimerkiksi tekstiä, fonttia, väriä ja monia muita ominaisuuksia.

Kokeile alkuun muokata painikkeen tekstiä. Klikkaa suunnittelunäkymässä olevasta painikkeesta, jolloin painikkeen perusominaisuudet ilmestyvät Inspector-näkymän Properties-paneeliin:

Muuta painikkeen Text-ominaisuus arvoon Lisää tehtävä ja paina Enter. Huomaa, että painikkeen teksti päivittyy samalla suunnittelunäkymässä.

Tallenna muutokset (File Save). Kokeile nyt ajaa sovellus taas IDEA:n kautta. Huomaat, että painikkeen teksti muuttui.

Käyttöliittymän hierarkkinen rakenne

Vasemmalla olevassa Document-paneelissa näet käyttöliittymän rakenteen, joka muistuttaa hierarkiaa: Button ja Label-komponentit ovat VBox-komponentin lapsia. VBox (Vertical Box, eli "pystysuuntainen laatikko") on layout-komponentti, joka järjestää lapsikomponentit automaattisesti pystysuoraan.

JavaFX:ssä on paljon vastaavia valmiita Pane-luokasta periytyviä luokkia, jotka auttavat järjestelemään käyttöliittymää kokonaisuuksiin sen sijaan, että kaikki komponentit olisivat yhdessä läjässä suoraan ikkunan alaisuudessa.

  • HBox-komponentti järjestää lapsensa vaakasuoraan,
  • GridPane-komponentti järjestää lapsensa ruudukkomaisesti,
  • BorderPane-komponentti järjestää lapsensa reunoille ja keskelle, ja niin edelleen.

Näiden komponenttien avulla voidaan luoda monimutkaisiakin käyttöliittymiä, jotka skaalautuvat hyvin eri kokoisiksi ikkunoiksi.

Syöttökenttä

Lisätään nyt käyttöliittymään syöttökenttä, johon käyttäjä voi kirjoittaa. Valitse vasemmalta Library-näkymän paneeleista Controls TextField ja raahaa se VBox-komponentin sisään, aiemman tekstikentän ja painikkeen väliin:

(Mikäli pudotit tekstikentän väärään paikkaan, voit peruuttaa muutokset painamalla Ctrl+Z tai +Z)

Tallenna muutokset (File Save) ja kokeile vielä käynnistää sovellus IDEA:ssa. Huomaat, että sovellukseen ilmestyi syöttökenttä, johon voi kirjoittaa tekstiä.

Bonus: Missä käyttöliittymä on määritelty?

Avaa IDEA:ssa resources-kansiossa oleva main.fxml. Tiedoston pitäisi näyttää nyt suunnilleen seuraavalta:

<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.VBox?>

<VBox alignment="CENTER" spacing="20.0" xmlns="http://javafx.com/javafx/25" xmlns:fx="http://javafx.com/fxml/1" fx:controller="fi.jyu.ohj2.dezhidki.todo.MainController">
    <padding>
        <Insets bottom="20.0" left="20.0" right="20.0" top="20.0" />
    </padding>

    <Label text="Hello, JavaFX!" />
   <TextField />
    <Button text="Lisää tehtävä" />
</VBox>

FXML-tiedosto sisältää käyttöliittymän näkymän määrittelyn käyttäen tekstuaalista esittelymuotoa. Vertaa tekstitiedostoa SceneBuilderissa olevaan hierarkiarakenteeseen:

SceneBuilder on siten yksinkertaisuudessaan sovellus, joka osaa lukea ja tuottaa FXML-käyttöliittymätiedostoja.

FXML:n ja kontrolleriluokan yhdistäminen

Painikkeesta ei vielä tapahdu mitään. Jotta voimme käsitellä käyttöliittymän tapahtumia, kuten painikkeen klikkaus, meidän on luotava yhteys FXML-tiedoston ja kontrolleriluokan välille. Tämä tapahtuu kahdessa vaiheessa: antamalla komponenteille tunnisteet SceneBuilderissä ja määrittelemällä vastaavat muuttujat Java-koodissa.

Tunnisteiden määrittäminen komponenteille

FXML:ssä jokaisella komponentilla, jota haluamme hallita ohjelmallisesti, täytyy olla yksilöllinen tunniste. Lisätään alkuun tunnisteet painikkeelle ja syöttökentälle ja kokeillaan tulostaa kenttään kirjoitettu teksti konsoliin.

Valitse SceneBuilderissa syöttökenttä ja valitse Inspector-näkymän alapuolella oleva Code-paneeli:

Aseta tunnisteen eli fx:id-kentän arvoksi jokin uniikki komponenttia kuvaava nimi käyttäen camelCase-kirjoitustyyliä, esimerkiksi uusiTehtavaNimi. Vahvista muutos painamalla Enter. Toista sama painikkeelle ja anna sen tunnisteeksi lisaaUusiTehtavaPainike.

Tallenna lopuksi FXML-tiedosto.

Komponenttien määrittäminen kontrollerissa

Saatoit huomata, että heti tunnisteen määrittämisen jälkeen SceneBuilder näyttää varoituksen "No injectable field found":

Korjataan varoitus lisäämällä tunnistetta vastaavat attribuutit kontrolleriluokkaan. Voit toistaiseksi tyhjentää varoituksen painamalla Clear-painiketta, sillä SceneBuilder ei tyhjennä varoituksia automaattisesti.

Palaa IDEAan ja avaa MainController.java. Lisää luokkaan kaksi attribuuttia luokan alkuun:

@FXML
private Button lisaaUusiTehtavaPainike;

@FXML
private TextField uusiTehtavaNimi;

Korjaa mahdolliset import-määreiden puute lisäämällä import-määreet javafx.scene.control-pakkauksessa oleviin Button ja TextField-luokkiin:

tärkeää

Muuttujan nimen on oltava täsmälleen sama kuin SceneBuilderissä määritellyn fx:id-arvon. JavaFX yhdistää komponentit ja attribuutit toisiinsa käyttäen tunnistetta. Jos tunnisteet eivät täsmää, JavaFX ei osaa yhdistää niitä.

Kokeile kääntää ja ajaa ohjelma uudestaan. Ohjelman pitäisi toimia kuten ennenkin.

Mitä tämä käytännössä teki? Kun ohjelma käynnistyy, App-luokassa määritelty FXMLLoader-olio lukee main.fxml-tiedoston ja huomaa siellä määritellyt komponentit sekä niiden fx:id-tunnisteet. Tämän jälkeen se luo ilmentymän MainController-luokasta ja etsii @FXML-annotaatiolla merkityt attribuutit. Lopuksi lataaja asettaa annotoitujen attribuuttien arvoksi komponenttien oliot attribuutin nimen ja fx:id-tunnisteen perusteella. Siispä FXMLLoader tekee puolestamme jotakuinkin seuraavaa:

// Älä kopioi.
// FXMLLoader tekee tämän meidän puolestamme käyttäen attribuutin nimeä ja
// fx:id-asetuksen arvoa.
this.uusiTehtavaNimi = (TextField)findComponentById("uusiTehtavaNimi");
// Tämän jälkeen uusiTehtavaNimi sisältää näkymässä olevan TextField-komponentin olion.

Kontrollerin elinkaari ja initialize-metodi

Jos kontrolleri toteuttaa Initializable-rajapinnan, JavaFX kutsuu metodin automaattisesti, kun FXML-tiedosto on täysin ladattu ja kontrolleriluokassa olevat attribuutit on alustettu. Tämä on oikea paikka määritellä komponenttien käyttäytymiseen liittyviä toimintoja.

Lisätään alkuun initialize()-metodiin seuraava rivi testataksemme, että syöttökentän olio on todellakin käytettävissä koodista.

uusiTehtavaNimi.requestFocus();

Aja ohjelma uudestaan. Nyt heti ohjelman käynnistyessä syöttökenttä aktivoituu, eli se saa ns. fokuksen, ikään kuin kenttää olisi klikattu:

Tapahtumien käsittely

Pelkän fokuksen lisääminen on vielä hieman tylsää. Lisätään sovellukselle toiminnallisuus, että painiketta klikattaessa konsoliin tulostetaan tekstikentässä oleva teksti.

JavaFX-sovelluksissa kaikenlaiset vuorovaikutukset komponenttien kanssa muodostavat tapahtumia. Esimerkiksi painikkeen painaminen, näppäimen painaminen syöttökentässä, tekstin kopioiminen tai liittäminen, kaikki muodostavat erilaisia tapahtumia. Käyttöliittymän ohjelmointi onkin yleisesti sitä, että ensin määritellään käyttöliittymässä näkyvät osat ja sitten reagoidaan osien tapahtumiin.

Reagointi tapahtumiin JavaFX:ssä tapahtuu lisäämällä komponenteille tapahtumakäsittelijät käyttäen muun muassa komponenttien setOn-alkavia metodeja. setOn-metodit ottavat parametrina funktioviitteen tai lambdalausekkeen, joka kutsutaan, kun tapahtuma laukeaa.

Lisätään painikkeelle nyt yleinen toimintatapahtuma setOnAction-metodia. Tämä action-tapahtuma laukeaa, kun käyttäjä vuorovaikuttaa komponentin kanssa komponentille ominaisella tavalla. Painikkeiden tapauksessa action-tapahtuma laukeaa painiketta klikkaamalla tai painamalla Enter-painiketta, kun fokus on painikkeessa. Huomaa, että muissa komponenteissa action-tapahtuma voi merkitä jotain toista komponentille ominaista vuorovaikutusta.

Lisää initialize()-metodiin tapahtumakäsittelijä lisaaUusiTehtavaPainike-painikkeelle käyttäen setOnAction-metodia. Tässä vaiheessa painikkeen painaminen käsitellään hakemalla tekstikentän sisältö ja tulostamalla se näytölle:

lisaaUusiTehtavaPainike.setOnAction(event -> {
    String teksti = uusiTehtavaNimi.getText(); // Haetaan tekstikentän sisältö
    IO.println("Tekstikentän sisältö: " + teksti); // Tulostetaan se konsoliin
});
Lisätietoa: Miksi emme lisää tapahtumankäsittelijää suoraan konstruktorissa?

Syy liittyy JavaFX:n alustuksen järjestykseen ja siihen, milloin FXML-komponentit ovat saatavilla.

Kun käynnistät JavaFX-sovelluksen, tapahtuu seuraavaa:

  1. JavaFX luo kontrollerin kutsumalla konstruktoria new MainController().
  2. @FXML-kentät ovat tässä vaiheessa vielä null.
  3. FXMLLoader lukee FXML-tiedoston ja injektoi kenttiin oikeat komponentit fx:id-arvojen perusteella.
  4. Lopuksi kutsutaan initialize()-metodi.

Jos siis kirjoitat konstruktoriin koodia, joka käyttää FXML-komponentteja, saat NullPointerExceptionin. Voit kokeilla tätä itse kirjoittamalla tapahtumankäsittelijän konstruktorin sisään:

public MainController() {
    uusiTehtavaNimi.requestFocus();
    lisaaUusiTehtavaPainike.setOnAction(event -> {
        String teksti = uusiTehtavaNimi.getText();
        IO.println(teksti);
    });
}

Yllä olevassa esimerkissä sekä lisaaUusiTehtavaPainike että uusiTehtavaNimi ovat konstruktorin aikana vielä null.

Siksi tapahtumankäsittelijät ja muut FXML-komponentteihin nojaavat alustukset tehdään initialize()-metodissa.

Tallenna ja käynnistä sovellus. Nyt kun painiketta klikataan, konsoliin tulostuu tekstikentän sisältö:

Meillä on nyt alustava graafinen "Hei maailma!" -sovellus! 🥳

Tekstikentän sisällön näyttäminen ikkunassa

Tekstin tulostuminen konsoliin on kylläkin mukavaa, mutta todellisessa sovelluksessa on harvoin konsoli samaan aikaan auki. Muutetaankin sovellusta vielä niin, että syöttökenttään kirjoitettu teksti lisätään yläpuolella olevaan nimiökomponenttiin.

Mene SceneBuilderiin ja valitse Label-nimiökomponentti, jossa lukee nyt "Hello, JavaFX!". Ensin, valitse Inspector-näkymästä Properties-paneeli ja poista Text-asetuksesta kaikki teksti. Nimiö jää silloin tyhjäksi:

Lisää sen jälkeen nimiölle fx:id-asetukseen tunniste Code-paneelista. Anna tunnisteeksi esimerkiksi tekemattomat. Tallenna FXML-tiedosto.

Lisää kontrolleriluokkaan nimiötä vastaava attribuutti:

@FXML
private Label tekemattomat;

Lisää tarpeelliset import-määreet. Mikäli IntelliJ tarjoaa useita vaihtoehtoja Label-luokalle, valitse javafx.scene.control-pakkauksessa oleva vaihtoehto.

Muokkaa aiemmin tekemääsi tapahtumankäsittelijää niin, että teksti lisätäänkin nimiöön:

lisaaUusiTehtavaPainike.setOnAction(event -> {
    String teksti = uusiTehtavaNimi.getText();
    // HIGHLIGHT_RED_BEGIN
    IO.println("Tekstikentän sisältö: " + teksti);
    // HIGHLIGHT_RED_END
    //HIGHLIGHT_GREEN_BEGIN
    tekemattomat.setText(tekemattomat.getText() + teksti + "\n");
    //HIGHLIGHT_GREEN_END
});

Tallenna ja aja. Nyt painikkeen painaminen lisää tekstin syötekentän yläpuolelle olevaan nimiöön aina omalle rivilleen:

Saatoit huomata, että toisen rivin lisääminen ei näy ellei ikkunan kokoa suurenna. Korjaamme ikkunankokoon liittyvät ongelmat tämän tutoriaalin osan lopussa.

Tehtävä 7.2: Todo-sovellus, vaihe 2 1 p.

Palauta tässä osan 7.2 perusteella edistetty projekti.

Kertaus tämän osan vaiheista:

  • Lisää SceneBuilderissa FXML-tiedostoon oma TextField-komponentti VBoxin sisään.
  • Lisää painikkeelle onAction-tapahtumankäsittelijä, joka lisää tekstikenttään kirjoitetun tekstin Label-komponenttiin.
  • Näytä Label-komponentissa kaikki tehtävät erottamalla ne toisistaan rivinvaihdolla.

Palauta projektisi tiedostot.

Tee tehtävä TIMissä

Versionhallinta

tärkeää

Tämä luku olettaa, että olet käyttänyt Git-versiohallintaa aikaisemman. Jos et ole aiemmin käyttänyt Gitiä tai kaipaat kertausta, lue aluksi Ohjelmointi 1 -kurssin materiaalin Git-osio. Emme tässä vaiheessa tarvitse vielä etävarastoa, joten voit ohittaa GitLab-etävarastoa käsittelevän kohdan.

Vastaavasti Git-komentorivityökalun käyttö olettaa, että sinulla on kokemusta komentorivityökalujen käytöstä. Mikäli kaipaat kertausta, tutustu Ohjelmointi 1 -kurssin komentorivimateriaaliin:

Tässä vaiheessa on hyvä hetki aloittaa versionhallinta. Käytämme Git-versionhallintaa, joka on laajasti käytetty työkalu ohjelmistokehityksessä. Tämän osan jälkeen teet jokaisesta tutoriaalin tehtävästä oman Git-commitin, joka kuvaa tehtävän aikana tehtyjä muutoksia.

Gitin käyttämiseen on monenlaisia käyttöliittymiä – myös IDEAssa on omansa. Käytämme tässä kuitenkin komentoriviä, koska se on suhteellisen yleinen tapa käyttää Gitiä kaikissa ympäristöissä samalla tavalla.

Aloitetaan versionhallinta luomalla Git-varasto projektille. Avaa komentorivi ja siirry projektisi juurikansioon. Juurikansio on se kansio, jossa on src-kansio ja pom.xml-tiedosto. Alusta sen jälkeen Git-varasto komennolla git init:

Saat ilmoituksen, että tyhjä Git-varasto on luotu. Projektin polku polku/omaan/todo/projektiin on tietenkin erilainen omalla koneellasi.

Ennen kuin teemme ensimmäisen commitin, meidän on kerrottava Gitille, mitä tiedostoja tulisi lisätä mukaan. Aivan alkuun riittää, että lisätään kaikki kansiossa olevat tiedostot mukaan käyttäen git add . -komentoa:

Tämä lisää kaikki nykyisessä kansiossa ja sen alikansioissa olevat tiedostot tulevaan commitiin. Huomaa, että komento ei itsessään tulosta mitään.

Varmistetaan vielä, mitä tiedostoja lähtee committiin mukaan käyttäen git status -komentoa:

Saat listan tiedostoista, joita Git seuraa ja tulee lisäämään seuraavaan commitiin. Tarkastellaan lyhyesti sisältö. Alkuun on aiemmista vaiheista tutut pom.xml, .java-lähdekoodit ja .fxml-näkymätiedosto. Puolestaan .idea-kansio sisältää IntelliJ IDEA:n kannalta oleellisia asetuksia.

Lisäksi mukana on .gitignore-tiedosto. Tämä tiedosto tuli valmiiksi projektipohjan mukana. Tiedosto kertoo Git-työkalulle, mitä tiedostoja ei haluta koskaan lisätä mukaan commitiin. Näin varmistetaan, että esimerkiksi käännetyt .class-tiedostot tai IDEAn omat asetustiedostot eivät päädy versionhallintaan. .gitignore-tiedostoa voi ja kannattaa muokata tarpeen mukaan, jos halutaan jättää pois muita tiedostoja versionhallinnasta.

Nyt voimme tehdä ensimmäisen commitin, joka tallentaa kansiossa olevan koodin tilan Gitiin ikään kuin "ruutukaappauksena". Commitin yhteydessä kirjoitetaan kuvaava viesti, joka kertoo, mitä muutoksia on tehty. Yleensä ensimmäiselle commitille kirjoitetaan viesti, kuten "Initial commit" tai "Projektin aloitus". Luo uusi commit komennolla git commit:

Komento listaa onnistumisen merkiksi kaikki tiedostot, joista tallennettiin senhetkinen tila Gitiin.

Tästä eteenpäin jokaisen tehtävän yhteydessä tee uusi commit, jossa kuvaat tehtävän aikana tekemiäsi muutoksia. Voit halutessasi tehdä useammankin commitin, jos haluat.

Tehtävä 7.3: Todo-sovellus, vaihe 3. 1 p.

Tee projektillesi Git-varasto, ja tee siihen ensimmäinen commit.

Aja sen jälkeen komentorivillä git status -komento ja palauta sen tuloste tämän tehtävän palautuslaatikkoon.

Tee tehtävä TIMissä

Sovelluslogiikan ja käyttöliittymän yhdistäminen

Sovelluksemme voisi jo nyt toimia eräänlaisena Todo-listana. Palautetaan kuitenkin vielä mieleen, mitä ominaisuuksia suunnittelimme tämän osan alussa:

  • Käyttäjä voi lisätä uuden tehtävän
  • Käyttäjä näkee listan kaikista tehtävistä
  • Käyttäjä voi merkitä tehtävän tehdyksi
  • Käyttäjä voi poistaa tehtävän
  • Käyttäjä voi palauttaa tehdyn tehtävän takaisin tekemättömäksi
  • Tehtävät tallennetaan tiedostoon, jotta ne säilyvät sovelluksen sulkemisen jälkeen
  • Tehtävät haetaan tiedostosta sovelluksen käynnistyessä

Näitä huomioon ottaen sovelluksemme ei ole vielä kovin käytettävä: tehtäviä voidaan lisätä, ja pystymme näkemään kaikki tehtävät, mutta tehtäviä ei voi poistaa eikä merkitä tehdyksi.

Muutetaan käyttöliittymä siten, että tehtävät näytetään valintaruutuina, jolloin ne voi merkitä tehdyksi tai tekemättömäksi. Lisäksi listataan tehdyt ja tekemättömät tehtävät omaan listaan. Piirretään alustava wireframe-suunnitelmakuva:

Yllä oleva kuva on piirretty käyttäen wireframe.cc -palvelua, mutta vastaavia suunnitelmakuvia voidaan piirtää millä tahansa piirtotyökalulla. Yleisesti ottaen käyttöliittymiä on hyvä suunnitella hieman etukäteen, jotta sen toteuttaminen olisi suoraviivaisempaa.

Komponenttien luominen dynaamisesti

Aloitetaan ensin muuttamalla painikkeen toimintaa niin, että uusi tehtävä lisätään käyttöliittymään valintaruutuna, eli ns. CheckBox-komponenttina. Koska käyttäjä voi lisätä uusia tehtäviä rajattomasti, emme voi lisätä valintaruutuja SceneBuilderin kautta. Sen sijaan lisäämme komponentteja suoraan kontrollerin koodissa.

Valmistellaan ensiksi käyttöliittymä. Mene SceneBuilderiin ja poista siellä oleva Label-nimiökomponentti. Koska nimiössä ei ole oletuksena tekstiä, sitä ei pysty klikkaamaan suunnittelunäkymässä. Sen sijaan valitse nimiö käyttäen vasemmalla puolella olevan Document-näkymän Hierarchy-paneelia:

Poista nimiö painamalla Delete (macOS: ⌫ delete). Saat varoituksen "This component has an fx:id. Do you really want to delete it?" sen merkiksi, että nimiökomponenttia käytetään kontrollerin koodissa. Valitse varoitusdialogissa Delete.

Tämän jälkeen etsi VBox-komponentti Library-näkymästä (Library Containers VBox ) ja raahaa se tekstikentän yläpuolelle:

VBox (Vertical Box) on ns. sisältökomponentti, joka on tarkoitettu muiden komponenttien ryhmittelyyn ja asetteluun. Sisältökomponentteihin voi lisätä muita elementteja, joita sisältökomponentti asettelee sille ominaisella tavalla. Esimerkiksi VBox asettelee kaikki sen sisällä olevat komponentit pystysuorasti ylhäältä alas.

Anna uudelle VBox-komponentille fx:id-tunnisteeksi tekemattomat, eli sama kuin poistetun nimiökomponentin. Tallenna FXML-tiedosto ja muokkaa sitten MainController-luokka niin, että tekemattomat-attribuutin tyyppi on jatkossa VBox:

// HIGHLIGHT_RED_BEGIN
@FXML
private Label tekemattomat;
// HIGHLIGHT_RED_END
// HIGHLIGHT_GREEN_BEGIN
@FXML
private VBox tekemattomat;
// HIGHLIGHT_GREEN_END

Nyt tapahtumankäsittelijä ei enää toimi, koska VBox-komponentti ei sisällä getText/setText-metodia. Sen sijaan VBox-komponentin oleellinen metodi on getChildren(), joka palauttaa listan kaikista sen sisältämistä komponenteista. Muokataankin painikkeen tapahtumakäsittelijä niin, että painikkeen painalluksesta alustetaan uusi CheckBox-olio ja lisätään se VBox-komponenttiin. Tällöin tapahtumakäsittelijästä tulee seuraavanlainen:

lisaaUusiTehtavaPainike.setOnAction(event -> {
    String teksti = uusiTehtavaNimi.getText();
    CheckBox tehtava = new CheckBox(teksti);
    tekemattomat.getChildren().add(tehtava);
});

Kokeile ajaa sovellus tässä vaiheessa. Huomaat, että "Lisää tehtävä" -painike luo uuden valintaruutukomponentin ja lisää sen syöttökentän yläpuolelle. Valintaruudut ovat klikattavissa ikään kuin merkiksi siitä, onko tehtävä tehty:

Parannetaan sovelluksen käytettävyyttä hieman tässä vaiheessa. Ensiksi, jos "Lisää tehtävä" -painiketta painaa ilman, että syöttökenttään kirjoittaa mitään, sovellukseen ilmestyy tyhjä valintaruutu. Lisäämme järkevyystarkistuksen: jos tekstikentästä haettu teksti on null-viite, ei sisällä mitään tekstiä tai sisältää vain välilyöntejä, lopetetaan tapahtumankäsittely kesken. Tämä onnistuu String-tyypin isBlank()-metodilla. Lisäksi, poistetaan tehtävän alusta ja lopusta turhia välilyöntejä, jos käyttäjä saattaa kirjoittaa ne vahingossa käyttäen trim()-metodia:

lisaaUusiTehtavaPainike.setOnAction(event -> {
    String teksti = uusiTehtavaNimi.getText();
    // HIGHLIGHT_GREEN_BEGIN
    if (teksti == null || teksti.isBlank()) {        
        return; 
    }
    teksti = teksti.trim();
    // HIGHLIGHT_GREEN_END
    CheckBox tehtava = new CheckBox(teksti);
    tekemattomat.getChildren().add(tehtava);
});

Toiseksi, tehtävän lisääminen jättää tehtävätekstin syöttökenttään, jolloin uuden tehtävän lisäämistä varten joudumme kumittamaan pois vanhan tekstin. Käytetään sitä varten TextField-komponentin clear()-metodia, jolla me tyhjennämme tekstikentän sisällön aina tehtävän lisäämisen lopuksi:

lisaaUusiTehtavaPainike.setOnAction(event -> {
    String teksti = uusiTehtavaNimi.getText();
    if (teksti == null || teksti.isBlank()) {        
        return; 
    }
    teksti = teksti.trim();
    CheckBox tehtava = new CheckBox(teksti);
    tekemattomat.getChildren().add(tehtava);
    // HIGHLIGHT_GREEN_BEGIN
    uusiTehtavaNimi.clear();
    // HIGHLIGHT_GREEN_END
});

Kolmanneksi, tehtävän lisäämisen jälkeen joudumme klikkaamaan syöttökentästä ennen kuin seuraavan tehtävän kirjoittamista. Tehdään tämä klikkaus ohjelmallisesti käyttäen requestFocus()-metodia, joka siirtää fokuksen eli ikään kuin simuloi komponentin valintaa. Huomaa, että metodi on lisättävä kaikkiin kohtiin, jossa tapahtumankäsittely päättyy:

lisaaUusiTehtavaPainike.setOnAction(event -> {
    String teksti = uusiTehtavaNimi.getText();
    if (teksti == null || teksti.isBlank()) {        
        // HIGHLIGHT_GREEN_BEGIN
        uusiTehtavaNimi.requestFocus(); 
        // HIGHLIGHT_GREEN_END
        return; 
    }
    teksti = teksti.trim();
    CheckBox tehtava = new CheckBox(teksti);
    tekemattomat.getChildren().add(tehtava);
    uusiTehtavaNimi.clear();
    // HIGHLIGHT_GREEN_BEGIN
    uusiTehtavaNimi.requestFocus(); 
    // HIGHLIGHT_GREEN_END
});
Bonus: Fokuksen automaattinen asettaminen tapahtuman lopuksi

Nyt hieman ärsyttävästi joudumme asettamaan fokuksen kahteen kohtaan.

Eräs JavaFX-tyylinen tapa ratkaista ongelma on käyttää Platform.runLater()-metodia (JavaDoc), joka ajaa sille annettavan koodin myöhemmin sovelluksen aikana (mutta aikaisintaan sen tapahtuman jälkeen, jona metodia kutsuttiin). Metodi ottaa parametrina Runnable-rajapintaa toteuttavan olion. Koska Runnable on funktionaalinen (ks. Luku 6.1), voimme antaa parametrina lambdalausekkeen tai funktioviitteen metodiin, joka ei ota mitään parametreja eikä palauta mitään. Koska requestFocus()-metodi täsmää parametrien ja palautusarvon kannalta Runnable-rajapinnan kanssa, voimme käyttää funktioviitettä suoraan. Tällöin tapahtumankäsittely yksinkertaistuu muotoon:

lisaaUusiTehtavaPainike.setOnAction(event -> {
    Platform.runLater(uusiTehtavaNimi::requestFocus);
    String teksti = uusiTehtavaNimi.getText();
    if (teksti == null || teksti.isBlank()) {        
        return; 
    }
    teksti = teksti.trim();
    CheckBox tehtava = new CheckBox(teksti);
    tekemattomat.getChildren().add(tehtava);
    uusiTehtavaNimi.clear();
});

Toinen tapa on soveltaa geneerisia metodeja (ks. luku 4.4) sekä funktionaalisia rajapintoja (ks. luku 6.1). Koska lambdalausekkeita voidaan ottaa parametrina ja toisaalta palauttaa arvona, voimme tehdä apumetodin ajaJaFokusoi, joka ottaa parametrina tapahtumakäsittelijän ja palauttaa uuden tapahtumakäsittelijän, joka kutsuu requestFocus aina lopuksi:



static <T extends Event> EventHandler<T> ajaJaFokusoi(EventHandler<T> kasittelija, Node komponentti) {
    return e -> {
        kasittelija.handle(e);
        komponentti.requestFocus();
    };
}

Tällaista metodia, joka palauttaa parametrina annetun funktion pienellä muutoksella, kutsutaan yleensä ns. käärijämetodiksi tai wrapper-metodiksi. Nimensä mukaan metodi siis "käärii" alkuperäisen funktion toisen sisään.

Apumetodin avulla voimme yksinkertaistaa tapahtumankäsittelijän muotoon:

lisaaUusiTehtavaPainike.setOnAction(ajaJaFokusoi(event -> {
    String teksti = uusiTehtavaNimi.getText();
    if (teksti == null || teksti.isBlank()) {
        return;
    }
    teksti = teksti.trim();
    CheckBox tehtava = new CheckBox(teksti);
    tekemattomat.getChildren().add(tehtava);
    uusiTehtavaNimi.clear();
}, uusiTehtavaNimi));

Huomaa, että kaikki JavaFX-komponentin perivät Node-luokasta.

Lopuksi, tehtävän lisääminen vaatii aina "Lisää tehtävä" -painikkeen painamista. Tehokäyttäjälle voisi olla kätevämpi lisätä tehtäviä myös Enter-painiketta käyttäen, jolloin uusia tehtäviä voi lisätä paljon nopeammin. Huomaamme, että TextField-komponentin onAction-tapahtuma laukeaa, kun käyttäjä painaa Enter-painiketta kentässä (ks. JavaDoc). Lisätään siis myös uusiTehtavaNimi-syöttökentälle tapahtumankäsittelijä käyttäen setOnAction-metodia. Koska haluamme käsitellä näppäimen painallusta ja tekstikentän Enter-painiketta samalla tavalla, refaktoroidaan samalla tapahtumankäsittely omaksi metodiksi:

public void initialize(URL url, ResourceBundle resourceBundle) {
    lisaaUusiTehtavaPainike.setOnAction(event -> lisaaTehtava());
    uusiTehtavaNimi.setOnAction(event -> lisaaTehtava());
}

private void lisaaTehtava() {
    String teksti = uusiTehtavaNimi.getText();
    if (teksti == null || teksti.isBlank()) {
        uusiTehtavaNimi.requestFocus();
        return;
    }
    teksti = teksti.trim();
    CheckBox tehtava = new CheckBox(teksti);
    tekemattomat.getChildren().add(tehtava);
    uusiTehtavaNimi.clear();
    uusiTehtavaNimi.requestFocus();
}

Kokeillaan nyt vielä ajaa sovellus ja testataan, että kaikki toimii. Muutosten myötä sovellus on nyt hieman käytettävämpi:

  • Tehtävien lisääminen ei onnistu jos tekstikenttä on tyhjä
  • Tehtävän lisääminen tyhjentää tekstikentän ja palauttaa fokuksen siihen nopeampaa kirjoittamista varten
  • Tehtäviä voidaan lisätä myös painamalla Enter-painiketta

Käsitellyt tehtävät

Jatketaan sovelluksen toimintojen edistämistä. Muutetaan sovellusta niin, että käsitellyt tehtävät ovat aina erillään tekemättömistä, jolloin tehtävien tekemistä on helpompaa seurata.

Palaa SceneBuilderiin ja lisää uusi VBox-komponentti tekemättömien tehtävien VBox-komponentin alle. Anna samalla uudelle VBox-komponentin fx:id-tunnisteeksi tehdyt:

Tallenna FXML-tiedosto, ja määrittele kontrolleriluokkaan vastaava VBox tehdyt -attribuutti:

@FXML
private VBox tehdyt;

Tehdään niin, että aina, kun valintaruutua klikataan, tehtävä siirtyy tekemättömästä tehdyksi ja samalla siirtyy ylemmästä alempaan VBox-komponenttiin. Huomaamme, että CheckBox-komponentti perii ButtonBase-luokan, jolloin onAction-tapahtuma laukeaa aina, kun valintaruutupainiketta klikataan (ks. JavaDoc). Muutetaan lisaaTehtava()-metodia niin, että valintaruudun onAction-tapahtumalle asetetaan käsittelijä. Tapahtumankäsittelijässä ensiksi poistamme valintaruudun tekemattomat-säiliökomponentista ja lisätään se tehdyt-säiliökomponenttiin:

private void lisaaTehtava() {
    // metodin alku piilotettu...
    String teksti = uusiTehtavaNimi.getText();
    if (teksti == null || teksti.isBlank()) {
        uusiTehtavaNimi.requestFocus();
        return;
    }
    teksti = teksti.trim();
    CheckBox tehtava = new CheckBox(teksti);
    // HIGHLIGHT_GREEN_BEGIN
    tehtava.setOnAction(event -> {
        tekemattomat.getChildren().remove(tehtava);
        tehdyt.getChildren().add(tehtava);
    });
    // HIGHLIGHT_GREEN_END
    tekemattomat.getChildren().add(tehtava);
    // metodin loppu piilotettu...
    uusiTehtavaNimi.clear();
    uusiTehtavaNimi.requestFocus();
}

Kokeile ajaa sovellusta. Nyt tehtävän klikkaaminen siirtää sen alempaan VBox-säiliöön. Klikkaamalla jo tehtyä tehtävää se ei kuitenkaan siirry takaisin tekemättömiin. Lisäksi, jos katsot IDEAssa konsoliin, näet poikkeuksen:

java.lang.IllegalArgumentException: Children: duplicate children added: parent = VBox[id=tehdyt]

Poikkeus kertoo, että yritämme lisätä samaa CheckBox-komponenttia uudestaan tehdyt-säiliöön, vaikka se on jo siellä. Muokataan logiikkaa niin, että jos tehtävä merkittiin tehdyksi, siirretään se tehtyihin ja jos tehtävä merkittiin tekemättömäksi, siirretään se takaisin tekemättömiin. Voimme käyttää tässä CheckBox-komponentin isSelected()-metodia, joka kertoo, onko valintaruutu valittu tai ei (ks. JavaDoc). Tällöin tapahtumakäsittely muuttuu seuraavaan muotoon:

tehtava.setOnAction(event -> {
    if (tehtava.isSelected()) { // Tehtävä valittu --> Siirretään tehtyjen joukkoon
        tekemattomat.getChildren().remove(tehtava);
        tehdyt.getChildren().add(tehtava);
    } else { // Tehtävä ei-valittu--> Siirretään takaisin tekemättömien joukkoon
        tehdyt.getChildren().remove(tehtava);
        tekemattomat.getChildren().add(tehtava);
    }
});

Kokeile nyt ajaa sovellus. Tehtävien merkkaaminen tehdyksi pitäisi nyt siirtää ne alempaan säiliöön. Vastaavasti tehtävien merkkaaminen tekemättömäksi siirtää ne takaisin ylös:

Huomaa, että isSelected()-metodilla on jo tiedossaan "uusi" arvo, onko komponentti valittuna vai ei. Tilan päivitys tapahtuu ennen setOnAction-tapahtuman laukeamista. Kun käyttäjä klikkaa valintaruutua, tapahtumaketju on karkeasti seuraava:

  • Hiiren painallus rekisteröityy käyttöjärjestelmään.
  • JavaFX päivittää sisäisen selected-ominaisuuden (esim. false -> true).
  • ActionEvent luodaan ja setOnAction-käsittelijä suoritetaan.
  • Käsittelijän suoritus: Kun kutsut tässä vaiheessa isSelected(), saat jo uuden, päivitetyn tilan.

Nimiöt säiliöille

Tehdään vielä pieni muutos parantaakseen käyttäjäystävällisyyttä. Lisää yksi nimiökomponentti (Label) tekemättömien tehtävien säiliön yläpuolelle ja aseta sen Text-attribuutiksi TODO. Sen jälkeen lisää vielä yksi nimiökomponentti tehtyjen tehtävien säiliön yläpuolelle ja aseta sen Text-attribuutiksi TEHTY:

Tallenna FXML-tiedosto ja kokeile vielä ajaa sovellus IDEA:sta varmistaksesi, että kaikki vieläkin toimii.

Tehtävä 7.4: Todo-sovellus, vaihe 4. 1 p.

Palauta tässä osan 7.4 perusteella edistetty projekti.

Kertaus tämän osan vaiheista:

  • Tee kaksi VBox-komponenttia tekemättömille ja tehdyille tehtäville.
  • Kun käyttäjä syöttää tehtävän, lisää se tekemättömien tehtävien VBox-säiliöön CheckBox-komponenttina.
  • Kun käyttäjä merkitsee tehtävän tehdyksi klikkaamalla valintaruudusta, siirrä se tekemättömien VBox-säiliöstä tehtyjen säiliöön.
  • Kun käyttäjä merkitsee tehdyn tehtävän tekemättömäksi klikkaamalla valintaruudusta, siirrä se tehtyjen säiliöstä tekemättömien säiliöön.
  • Kun käyttäjä lisää tehtävän, fokuksen tulee palautua syöttökenttään.
  • Käyttäjän ei pidä pystyä lisäämään tehtävää ilman tekstiä tai tehtävää, jonka tekstinä on pelkästään välilyöntejä.
  • Käyttäjän tulee pystyä lisäämän tehtävän myös painamalla Enter-painiketta, kun fokus on syöttökentässä.

Kun vaihe on valmis, muista tehdä git add muuttuneille tiedostoille ja git commit. Palauta projektisi tiedostot. Ei haittaa jos TIMissä tulee jokin varoitus tai jopa käännösvirhe. TIMissä ei välttämättä ole kaikkia tehtävissä vaadittavia riippuvuuksia, eikä siten JavaFX-projekti välttämättä edes käänny. Pääasia on, että olet saanut projektin toimimaan paikallisessa ympäristössäsi.

Tee tehtävä TIMissä

Tehtävien lukeminen ja kirjoittaminen tiedostoon

Sovelluksemme alkaa olla lähellä valmis toiminnallisesti. Toteutetaan vielä kaksi viimeistä toiminnallisuutta:

  • Tehtävät tallennetaan tiedostoon, jotta ne säilyvät sovelluksen sulkemisen jälkeen
  • Tehtävät haetaan tiedostosta sovelluksen käynnistyessä

Osassa 6.5 opimme, miten olioita voi tallentaa tiedostoihin käyttäen JSON-tiedostomuotoa. Suunnitellaan hieman tiedostomuotoa. Tällä hetkellä yksittäinen tehtävä voitaisiin mallintaa kahdella attribuutilla: tehtävän teksti merkkijonona sekä tieto, onko tehtävä tehty boolean-arvona. Toisin sanoen, yksittäistä tehtävää voidaan mallintaa JSON-oliona:

{
    "teksti": "Opiskele ohjelmointia",
    "tehty": true
}

Koska tehtäviä on monta, tallennetaan kaikki tehtävät yhteen listaan, jolloin JSON-tiedoston muoto olisi seuraavanlainen:

[
    {
        "teksti": "Opiskele ohjelmointia",
        "tehty": true
    },
    {
        "teksti": "Mene kouluun",
        "tehty": false
    }
]

Tallentamisen valmistelu

Lisää alkuun riippuvuus Jackson-kirjastoon osan 6.5 ohjeen mukaisesti.

Teemme seuraavaksi luokan Tehtava, joka mallintaa yllä olevaa yksittäistä tehtävää. Lisäämme luokkaan attribuutit teksti ja tehty sekä tarvittavat saantimetodit. Teemme lisäksi rakentajan, joka asettaa attribuuttien arvot:

public class Tehtava {
    @SuppressWarnings("FieldMayBeFinal")
    private String teksti;
    @SuppressWarnings("FieldMayBeFinal")
    private boolean tehty;

    @SuppressWarnings("unused")
    public Tehtava() { /* Jätä tyhjäksi tai tee oletustoteutus */ }

    public Tehtava(String teksti, boolean tehty) { /* Lisää toteutus */ }

    public boolean getTehty() { /* Lisää toteutus */ }

    public String getTeksti() { /* Lisää toteutus */ }
}

Lisää järkevä toteutus itse. Jätämme set-asetusmetodit toistaiseksi lisäämättä, koska käytämme luokkaa toistaiseksi vain tehtävien lataamiseen ja tallentamiseen. Emme kuitenkaan merkitse attribuutteja final-määreellä, jotta Jackson-kirjasto osaa asettaa arvoja attribuutteihin. Lisäämme vielä oletusmuodostajan, jota Jackson käyttää olioiden alustamiseen.

Valinnaista lisätietoa: Tehtävä on tietue

Jos luokan attribuutteja ei ole tarkoitettu muokattavaksi (eli kaikki attribuutit ovat final), luokka voidaan kirjoittaa tiiviimmässä muodossa käyttäen Javan tietuesyntaksia:

record Tehtava(String teksti, boolean tehty) {}

Tietuesyntaksia käydään tarkemmin läpi luvussa 6.5.

Materiaalin seuraavassa osassa tehtäväolioita käytetään suoraan käyttöliittymässä, jolloin lisäämme tarvittavat set-asetusmetodit. Tästä syystä toteutamme tehtävät tavallisena luokkana.

Tehtävien tallentaminen

Aloitetaan tehtävien tallentamisella. Aivan aluksi meidän pitäisi saada tuotettua lista tehtävistä Tehtava-olioina. Tällä hetkellä mallinnamme tehtävät suoraan CheckBox-komponentilla. Niinpä meidän täytyy muuntaa VBox-säiliössä olevat valintaruudut listaksi tehtävistä. Listojen muuntaminen onnistuu helposti muun muassa striimeillä (ks. luku 6.2). Lisätään lisaaTehtava-metodin loppuun koodi, joka hakee alkuun kaikki tekemättömät tehtävät:

void lisaaTehtava() {
    //...metodin alku piilotettu
    String teksti = uusiTehtavaNimi.getText();
    if (teksti == null || teksti.isBlank()) {
        uusiTehtavaNimi.requestFocus();
        return;
    }
    teksti = teksti.trim();
    CheckBox tehtava = new CheckBox(teksti);
    tehtava.setOnAction(event -> {
        if (tehtava.isSelected()) {
            tekemattomat.getChildren().remove(tehtava);
            tehdyt.getChildren().add(tehtava);
        } else {
            tehdyt.getChildren().remove(tehtava);
            tekemattomat.getChildren().add(tehtava);
        }
    });
    tekemattomat.getChildren().add(tehtava);
    uusiTehtavaNimi.clear();
    uusiTehtavaNimi.requestFocus();

    List<Tehtava> tekemattomatList = tekemattomat.getChildren().stream()
            .map(n -> (CheckBox) n)
            .map(cb -> new Tehtava(cb.getText(), cb.isSelected()))
            .toList();
}

Käytämme tässä striimiä, joka koostuu kahdesta map-muunnoksesta ja toList()-kerääjästä. Huomaa, että VBox-säiliön lapsikomponentit ovat tyyppiä Node – JavaFX:ssä kaikki visuaaliset komponentit, myös CheckBox, periytyvät Node-luokasta. Tässä tapauksessa tiedämme varmasti, että tehtyjen ja tekemättömien tehtävien lista sisältää ainoastaan CheckBox-komponentteja. Tästä syystä ensimmäinen map-muunnos muuntaa kaikki säiliössä olevat oliot CheckBox-tyypiksi; muunnos ei tässä tapauksessa tuota virheitä.

Toinen map-muunnos muuntaa CheckBox-oliot Tehtava-olioiksi. CheckBox-luokan getText()-metodi palauttaa valintaruudussa näkyvän tekstin (ks. JavaDoc) eli tehtävän tekstin ja isSelected() palauttaa valintaruudun tilan (ks. JavaDoc), eli onko tehtävä tehty tai ei. Lopuksi toList() kerää kaikki tehtävät listaan.

Tekemättömien lista perinteisellä silmukalla

Tekemättömien listan voisi toki muodostaa myös perinteisellä silmukkarakenteella. Alla vertailun vuoksi sama koodi for-each-silmukkana.

List<Tehtava> tekemattomatList = new ArrayList<>();
for (Node node : tekemattomat.getChildren()) {
    CheckBox c = (CheckBox) node;
    String tekstiC = c.getText();
    boolean tehtyC = c.isSelected();
    Tehtava tehtava = new Tehtava(tekstiC, tehtyC);
    tekemattomatList.add(tehtava);
}
Bonus: Mitä jos säiliössä on muitakin kuin CheckBox-komponentteja?

Tässä tapauksessa jätimme tyyppitarkistuksen pois, koska tiesimme, että VBox-säiliöt sisältävät vain valintaruutuja. Jos sen sijaan VBox sisältäisi erilaisia komponentteja, ja haluaisimme löytää vain tietyntyyppiset komponentit, voisimme tehdä tyyppitarkistuksen. Tämä tehdään instanceof-operaattorilla, jonka perään voidaan kirjoittaa muuttujan nimi (tässä c), johon tarkistettu objekti sijoitetaan uuden tyypin kera:

List<Tehtava> tekemattomatList = new ArrayList<>();

for (Node node : tekemattomat.getChildren()) {
    // Periaatteessa kaikki lapsikomponentit pitäisi 
    // olla CheckBox-komponentteja, mutta varmuuden vuoksi tarkistetaan 
    // tämä kuitenkin. 
    if (!(node instanceof CheckBox c)) {
        continue;
    }
    // Jos pääsemme tänne, niin node on CheckBox, 
    // ja voimme turvallisesti käyttää c-muuttujaa
    // CheckBox-tyyppisenä.

    String tekstiC = c.getText();
    boolean tehtyC = c.isSelected();
    Tehtava tehtava = new Tehtava(tekstiC, tehtyC);
    tekemattomatList.add(tehtava);
}

Mainittakoon, että vastaavat tyyppitarkistukset on myös mahdollista toteuttaa striimeillä käyttäen mapMulti-metodia (JavaDoc), jonka avulla voi valikoivasti poistaa tai lisätä alkioita striimiin:

tekemattomat.getChildren().stream()
            .<CheckBox>mapMulti((n, consumer) -> {
                // Tarkistetaan, onko komponentti tyyppiä CheckBox
                if (n instanceof CheckBox c) {
                    // Jos on, c-muuttuja sisältää tyyppimuunnetun komponentin
                    // consumer.accept lisää komponentin striimiin
                    consumer.accept(c);
                }
            })
            .map(cb -> new Tehtava(cb.getText(), cb.isSelected()))
            .toList();

Kokeillaan nyt tallentaa ainakin tekemättömiä tehtäviä tiedostoon käyttäen Jackson-kirjastoa. Lisäämme lisaaTehtava()-metodin loppuun Jackson-kirjaston ObjectMapper-olion ja käytämme sen writeValue()-metodia:

private void lisaaTehtava() {
    //...metodin alku piilotettu
    String teksti = uusiTehtavaNimi.getText();
    if (teksti == null || teksti.isBlank()) {
        uusiTehtavaNimi.requestFocus();
        return;
    }
    teksti = teksti.trim();
    CheckBox tehtava = new CheckBox(teksti);
    tehtava.setOnAction(event -> {
        if (tehtava.isSelected()) {
            tekemattomat.getChildren().remove(tehtava);
            tehdyt.getChildren().add(tehtava);
        } else {
            tehdyt.getChildren().remove(tehtava);
            tekemattomat.getChildren().add(tehtava);
        }
    });
    tekemattomat.getChildren().add(tehtava);
    uusiTehtavaNimi.clear();
    uusiTehtavaNimi.requestFocus();

    List<Tehtava> tekemattomatList = tekemattomat.getChildren().stream()
            .map(n -> (CheckBox) n)
            .map(cb -> new Tehtava(cb.getText(), cb.isSelected()))
            .toList();
    // HIGHLIGHT_GREEN_BEGIN
    ObjectMapper mapper = new ObjectMapper();
    mapper.writeValue(Path.of("tehtavat.json"), tekemattomatList);
    // HIGHLIGHT_GREEN_END
}

Kokeile nyt ajaa sovellus ja lisätä pari uutta tehtävää. Joka kerta, kun lisäät uuden tehtävän, tehtävät tallentuvat tehtavat.json-tiedostoon. Näet tiedoston IDEAssa sen jälkeen, kun suljet sovelluksen.

huomautus

Tässä välissä on hyvä lisätä .gitignore-tiedostoon rivi tehtavat.json, koska tuota tiedostoa ei haluta versionhallintaan. Tallennuksen jälkeen lisää .gitignore-komento Gitiin ja tee uusi commit:

git add .gitignore
git commit -m "Lisätty tehtavat.json .gitignoreen"

Jos lisäsit jo tehtavat.json-tiedoston versionhallintaan, poista se ensin komennolla git rm --cached tehtavat.json, tee commit, ja muuta vasta sen jälkeen .gitignore-tiedostoa.

Luonnollisesti myös tehdyt tehtävät tulee tallentaa. Jotta koodia ei tarvitse toistaa, tehdään yllä olevasta koodista uusi metodi, haeTehtavat(VBox vbox), joka palauttaa VBox-parametrin lapsikomponenttien perusteella listan Tehtava-olioita. Sen jälkeen kerätään sekä tehdyt että tekemättömät tehtävät samaan listaan:

private List<Tehtava> haeTehtavat(VBox sailio) {
    return sailio.getChildren().stream()
            .map(n -> (CheckBox) n)
            .map(cb -> new Tehtava(cb.getText(), cb.isSelected()))
            .toList();
}

private void lisaaTehtava() {
    //...metodin alku piilotettu
    String teksti = uusiTehtavaNimi.getText();
    if (teksti == null || teksti.isBlank()) {
        uusiTehtavaNimi.requestFocus();
        return;
    }
    teksti = teksti.trim();
    CheckBox tehtava = new CheckBox(teksti);
    tehtava.setOnAction(event -> {
        if (tehtava.isSelected()) {
            tekemattomat.getChildren().remove(tehtava);
            tehdyt.getChildren().add(tehtava);
        } else {
            tehdyt.getChildren().remove(tehtava);
            tekemattomat.getChildren().add(tehtava);
        }
    });
    tekemattomat.getChildren().add(tehtava);
    uusiTehtavaNimi.clear();
    uusiTehtavaNimi.requestFocus();

    // HIGHLIGHT_RED_BEGIN
    List<Tehtava> tekemattomatList = tekemattomat.getChildren().stream()
            .map(n -> (CheckBox) n)
            .map(cb -> new Tehtava(cb.getText(), cb.isSelected()))
            .toList();
    // HIGHLIGHT_RED_END
    // HIGHLIGHT_GREEN_BEGIN
    List<Tehtava> tehtavat = new ArrayList<>();
    tehtavat.addAll(haeTehtavat(tekemattomat));
    tehtavat.addAll(haeTehtavat(tehdyt));
    // HIGHLIGHT_GREEN_END
    ObjectMapper mapper = new ObjectMapper();
    mapper.writeValue(Path.of("tehtavat.json"), tehtavat);
}

Kokeile ajaa sovellus ja tutki, miten tehtavat.json-tiedosto muuttuu. Nyt sekä tekemättömät että tehdyt tehtävät tallentuvat aina, kun lisäät uuden tehtävän.

Huomaamme kuitenkin, että tehtävien tila ei päivity tiedostoon, kun tehtävä merkitään tehdyksi tai tekemättömäksi. Tämä kyllä onnistuu, jos kirjoitetaan sama tallennuskoodi uudestaan CheckBox-komponentin setOnAction-tapahtumankäsittelijään, mutta koodin toistaminen ei ole hyvä ratkaisu.

Mietitäänpä siis hetki. Tallentaminen on selkeästi oma kokonaisuutensa, joka ei liity suoraan siihen, miten tehtävät luodaan, näytetään tai siirretään. Refaktoroidaan tallennus siis omaksi metodiksi:

private void tallenna() {
    List<Tehtava> kaikkiTehtavat = new ArrayList<>();
    kaikkiTehtavat.addAll(haeTehtavat(tekemattomat));
    kaikkiTehtavat.addAll(haeTehtavat(tehdyt));
    ObjectMapper mapper = new ObjectMapper();
    mapper.writeValue(Path.of("tehtavat.json"), kaikkiTehtavat);
}

Nyt voimme korvata lisaaTehtava()-metodin lopussa olevan koodin pelkällä tallenna()-metodin kutsulla. Lisäksi voimme kutsua tallenna()-metodia valintaruudun omassa onAction-tapahtumakäsittelijässä, jolloin tallennus tehdään myös aina, kun jonkin valintaruudun tila muuttuu:

private void lisaaTehtava() {
    // metodin alku piilotettu...
    String teksti = uusiTehtavaNimi.getText();
    if (teksti == null || teksti.isBlank()) {
        uusiTehtavaNimi.requestFocus();
        return;
    }
    teksti = teksti.trim();
    CheckBox tehtava = new CheckBox(teksti);
    tehtava.setOnAction(event -> {
        // tapahtumankäsittelijän alku piilotettu...
        if (tehtava.isSelected()) {
            tekemattomat.getChildren().remove(tehtava);
            tehdyt.getChildren().add(tehtava);
        } else {
            tehdyt.getChildren().remove(tehtava);
            tekemattomat.getChildren().add(tehtava);
        }
        // HIGHLIGHT_GREEN_BEGIN
        tallenna();
        // HIGHLIGHT_GREEN_END
    });
    tekemattomat.getChildren().add(tehtava);
    uusiTehtavaNimi.clear();
    uusiTehtavaNimi.requestFocus();
    // HIGHLIGHT_RED_BEGIN
    List<Tehtava> tehtavat = new ArrayList<>();
    tehtavat.addAll(haeTehtavat(tekemattomat));
    tehtavat.addAll(haeTehtavat(tehdyt));
    ObjectMapper mapper = new ObjectMapper();
    mapper.writeValue(Path.of("tehtavat.json"), tehtavat);
    // HIGHLIGHT_RED_END
    // HIGHLIGHT_GREEN_BEGIN
    tallenna();
    // HIGHLIGHT_GREEN_END
}

Kokeile sovellusta uudestaan. Nyt tehtävien lisääminen sekä tehtävän tilan muuttaminen tallentaa tehtävät tehtavat.json-tiedostoon.

Huomaamme tässä vaiheessa, että lisaaTehtava()-metodi on myös paisunut hieman liian isoksi. Uuden CheckBox-valintaruudun luominen on selkeästi oma kokonaisuutensa, joten se on järkevää erottaa omaksi metodiksi. Tehdään uusi luoCheckBox()-metodi, joka ottaa parametrina valintaruutuun lisättävä teksti ja joka palauttaa luodun valintaruutuolion. Siirretään metodiin nykyinen CheckBox-oliota luova koodi sinne:

private CheckBox luoCheckBox(String teksti) {
    CheckBox tehtava = new CheckBox(teksti);
    // metodin runko piilotettu...
    tehtava.setOnAction(event -> {
        if (tehtava.isSelected()) {
            tekemattomat.getChildren().remove(tehtava);
            tehdyt.getChildren().add(tehtava);
        } else {
            tehdyt.getChildren().remove(tehtava);
            tekemattomat.getChildren().add(tehtava);
        }
        tallenna();
    });
    return tehtava;
}

private void lisaaTehtava() {
    // metodin alku piilotettu...
    String teksti = uusiTehtavaNimi.getText();
    if (teksti == null || teksti.isBlank()) {
        uusiTehtavaNimi.requestFocus();
        return;
    }
    teksti = teksti.trim();
    // HIGHLIGHT_RED_BEGIN
    CheckBox tehtava = new CheckBox(teksti);
    tehtava.setOnAction(event -> {
        if (tehtava.isSelected()) {
            tekemattomat.getChildren().remove(tehtava);
            tehdyt.getChildren().add(tehtava);
        } else {
            tehdyt.getChildren().remove(tehtava);
            tekemattomat.getChildren().add(tehtava);
        }
        tallenna();
    });
    // HIGHLIGHT_RED_END
    // HIGHLIGHT_YELLOW_BEGIN
    tekemattomat.getChildren().add(luoCheckBox(teksti));
    // HIGHLIGHT_YELLOW_END
    // metodin loppu piilotettu...
    uusiTehtavaNimi.clear();
    uusiTehtavaNimi.requestFocus();
    tallenna();
}

Tehtävien lataaminen

Nyt olemme saaneet tehtävät tallennettua, mutta ne pitäisi myös lukea ohjelman käynnistyessä. Tehdään sitä varten saman tien metodi lataa(). Käytetään tiedoston lukemiseen tapaa, jonka opimme luvussa 6.5, eli käytetään ObjectMapper-luokan readValue()-metodia. Kääritään koko komeus try-catch-lohkon sisään, jotta mahdolliset poikkeukset saadaan kiinni:

private void lataa() {
    Path path = Path.of("tehtavat.json");
    if (Files.notExists(path)) {
        return;
    }
    try {
        ObjectMapper mapper = new ObjectMapper();
        List<Tehtava> kaikkiTehtavat = mapper.readValue(path.toFile(), new TypeReference<>() {});
        kaikkiTehtavat.forEach(tehtava -> {
            CheckBox checkbox = luoCheckBox(tehtava.getTeksti());
            if (tehtava.getTehty()) {
                tehdyt.getChildren().add(checkbox);
            } else {
                tekemattomat.getChildren().add(checkbox);
            }
        });
    } catch (JacksonException je) {
        IO.println("JSONin lukeminen epäonnistui: " + je.getMessage());
    }
}

Toistaiseksi virheen sattuessa tulostamme vain virheen konsoliin. Käsittelemme myöhemmässä osassa, miten virhetilanne voitaisiin ilmoittaa käyttäjälle tarkemmin. Lisätään metodin kutsu initialize()-metodin alkuun:

public void initialize(URL url, ResourceBundle resourceBundle) {
    lataa();
    // metodin loppu piilotettu...
    uusiTehtavaNimi.setOnAction(event -> lisaaTehtava());
    lisaaUusiTehtavaPainike.setOnAction(event -> lisaaTehtava());
}

Kokeile ajaa sovellus. Nyt tehtävät pysyvät tallessa, vaikka ohjelma suljettaisiin ja käynnistettäisiin uudelleen:

Huomaamme, että lukemisessa, tai oikeastaan valintaruutujen luomisessa, on pieni ongelma. Kun ohjelma käynnistetään uudelleen, tehtyjen tehtävien listassa valintaruudut eivät ole enää valittuna. Tämä johtuu siitä, että luoCheckBox()-metodi ei ota huomioon sitä, onko tehtävä tehty vai ei. Korjataan tämä lisäämällä metodille parametri, joka kertoo, onko valintaruutu luomisen yhteydessä valittu tai ei:

private CheckBox luoCheckBox(String teksti, boolean valittu) {
    CheckBox tehtava = new CheckBox(teksti);
    // HIGHLIGHT_GREEN_BEGIN
    tehtava.setSelected(valittu);
    // HIGHLIGHT_GREEN_END
    // metodin loppuosa piilotettu...
    tehtava.setOnAction(event -> {
        if (tehtava.isSelected()) {
            tekemattomat.getChildren().remove(tehtava);
            tehdyt.getChildren().add(tehtava);
        } else {
            tehdyt.getChildren().remove(tehtava);
            tekemattomat.getChildren().add(tehtava);
        }
        tallenna();
    });
    return tehtava;
}

Nyt voimme asettaa valintaruudun oikean "tehty/ei-tehty" -tilan latauksen yhteydessä:

private void lataa() {
    // metodin alkuosa piilotettu...
    Path path = Path.of("tehtavat.json");
    if (Files.notExists(path)) {
        return;
    }
    try {
        ObjectMapper mapper = new ObjectMapper();
        List<Tehtava> kaikkiTehtavat = mapper.readValue(path.toFile(), new TypeReference<>() {});
        kaikkiTehtavat.forEach(tehtava -> {
            // HIGHLIGHT_YELLOW_BEGIN
            CheckBox checkbox = luoCheckBox(tehtava.getTeksti(), tehtava.getTehty());
            // HIGHLIGHT_YELLOW_END
    // metodin loppuosa piilotettu...
            if (tehtava.getTehty()) {
                tehdyt.getChildren().add(checkbox);
            } else {
                tekemattomat.getChildren().add(checkbox);
            }
        });
    } catch (JacksonException je) {
        IO.println("JSONin lukeminen epäonnistui: " + je.getMessage());
    }
}

Samalla korjaamme lisaaTehtava()-metodissa oleva luoCheckBox()-kutsu. Koska uusi tehtävä lisätään aina tekemättömäksi, annetaan parametrin arvoksi false:

private void lisaaTehtava() {
    // metodin alkuosa piilotettu...
    String teksti = uusiTehtavaNimi.getText();
    if (teksti == null || teksti.isBlank()) {
        uusiTehtavaNimi.requestFocus();
        return;
    }
    teksti = teksti.trim();
    // HIGHLIGHT_YELLOW_BEGIN
    tekemattomat.getChildren().add(luoCheckBox(teksti, false));
    // HIGHLIGHT_YELLOW_END
    // metodin loppuosa piilotettu...
    uusiTehtavaNimi.clear();
    uusiTehtavaNimi.requestFocus();
    tallenna();
}

Kokeile tallentaa ja ajaa sovellus uudelleen. Nyt sovelluksen käynnistyessä tehtyjen tehtävien valintaruudut merkataan "tehty"-tilaan oikein:

Tehtävä 7.5: Todo-sovellus, vaihe 5. 1 p.

Palauta tässä osan 7.5 perusteella edistetty projekti.

Kertaus tämän osan vaiheista:

  • Tallenna tehtävät JSON-tiedostoon aina, kun käyttäjä lisää tehtävän tai muuttaa tehtävän tilaa.

  • Lue tehtävät JSON-tiedostosta ohjelman käynnistyessä (jos tiedosto on olemassa). JSON-tiedoston tulisi näyttää suunnilleen seuraavalta (pois lukien luettavuutta varten lisätyt rivinvaihdot ja sisennykset):

    [
      {
        "tehtava": "Osta maitoa",
        "tehty": false
      },
      {
        "tehtava": "Vie roskat",
        "tehty": true
      }
    ]
    

Kun vaihe on valmis, muista tehdä git add muuttuneille tiedostoille ja git commit. Palauta projektisi tiedostot.

Tee tehtävä TIMissä

Komponenttien asettelu

Sovelluksemme on toiminnallisesti valmis, mutta sen ulkoasussa on vielä muutama puute:

  • Ikkunan oletuskoko on liian suuri.
  • Ikkunan koon kasvattaminen jättää tyhjää tilaa väärään paikkaan.
  • Komponenttien asettelu kaipaa hienosäätöä: valintaruudut ovat liian lähekkäin, nimiöt on keskitetty oudosti ja painikkeet ovat kaukana syöttökentistä.

Parannetaan sovelluksen ulkoasua ja tutustutaan VBox-säiliön lähisukulaiseen HBox.

Avaa sovelluksen main.fxml-tiedosto SceneBuilderissa. Valitse heti yläpalkista View Show Outlines tai paina Ctrl+E (macOS: Cmd+E). Toiminto muuttaa komponentit näyttämään laatikoilta, mikä helpottaa koon ja paikan hahmottamista:

Tämän osan jälkeen voit palata takaisin perusnäkymään valitsemalla View Hide Outlines.

Ikkunan ja komponenttien koko

Korjataan aluksi ikkunan koko. Valitse vasemman puolen Hierarchy-paneelista koko sovelluksen sisältävä VBox-elementti ja avaa oikealta Layout-paneeli:

Layout-paneeli sisältää komponentin kokoon liittyvät asetukset. JavaFX:ssä jokaisella komponentilla on kolmenlaista korkeutta ja leveyttä:

  • Oletuskoko (Pref Width ja Pref Height): komponentin oletuskoko, kun sovellusnäkymä ladataan. Koko voi kuitenkin muuttua riippuen komponentin luonteesta ja sitä ympäröivistä tai sisältämistä komponenteista.
  • Pienin koko (Min Width ja Min Height): Pienin sallittu koko, johon komponentti voi kutistua.
  • Suurin koko (Max Width ja Max Height): Suurin sallittu koko, johon komponentti voi kasvaa.

Voit antaa ominaisuuksille arvoksi desimaaliluvun tai käyttää seuraavia erikoisarvoja:

  • USE_COMPUTED_SIZE: JavaFX laskee komponentille parhaan koon sen sisällön perusteella.
  • USE_PREF_SIZE: JavaFX käyttää oletuskokoa (Pref). Hyödyllinen koon rajoittamiseen.

Näitä huomioon ottaen asetetaan koko näkymän VBox:lle seuraavat arvot:

  • Min Width: USE_PREF_SIZE
  • Min Height: USE_PREF_SIZE
  • Pref Width: 400
  • Pref Height: 400
  • Max Width: USE_COMPUTED_SIZE
  • Max Height: USE_COMPUTED_SIZE

Toisin sanoen: alusta näkymä kokoon 400x400, äläkä salli sen pienentämistä tästä, mutta anna sen kasvaa vapaasti. Huomaat muutoksen heti SceneBuilderissa:

Tallenna FXML-tiedosto ja käynnistä sovellus. Ikkunan oletuskoko on nyt 400x400:

Ikkunaa voi kuitenkin edelleen pienentää alle 400x400, koska muokkasimme vain näkymän rajoja, emme koko ikkunan (Stage). Korjataan tämä asettamalla ikkunan minimikoko App-luokan start()-metodissa. Samalla muokataan sovelluksen otsikko siistimmäksi:

public void start(Stage stage) throws IOException {
    FXMLLoader loader = new FXMLLoader(getClass().getResource("main.fxml"));
    Scene scene = new Scene(loader.load());

    stage.setScene(scene);
    // HIGHLIGHT_GREEN_BEGIN
    stage.setMinHeight(400);
    stage.setMinWidth(400);
    // HIGHLIGHT_GREEN_END
    // HIGHLIGHT_YELLOW_BEGIN
    stage.setTitle("Todo-sovellus");
    // HIGHLIGHT_YELLOW_END
    stage.show();
}

Nyt kun käynnistät sovelluksen, huomaat, että ikkunan kokoa ei voi pienentää alle 400x400:

VBox-säiliön komponenttien väli

VBox-komponentti sisältää Spacing-asetuksen, joka määrittää komponentin sisällä olevien komponenttien välisen tyhjän tilan.

Huomaamme, että näkymän VBox-säiliössä välistys on 20, joka on hiukan liian suuri. Korjataan tämä vaihtamalla asetuksen arvoksi 10:

Korjataan samalla se, että tehtävien VBox-säiliöissä CheckBox-valintaruutujen välissä ei ole yhtään tyhjää tilaa. Aseta tehtyjen ja tekemättömien tehtävien VBox-komponenteille Spacing-arvoksi 5. Tallenna FXML-tiedosto ja aja sovellus. Huomaat, että nyt valintaruutujen välissä on hieman tyhjää tilaa:

Komponenttien kasvaminen VBox-säiliön kasvaessa

Ikkunan koon muuttaminen paljastaa uuden ongelman: tehtyjen ja tekemättömien listat eivät kasva ikkunan mukana. Alla oleva video havainnollistaa tilanteen:

Oletuksena VBox pitää sisällään olevien komponenttien korkeuden muuttumattomana. Voimme kuitenkin määrittää erikseen, miten kunkin komponentin tulisi käyttäytyä, kun säiliöön syntyy tyhjää tilaa. Tässä tapauksessa haluamme, että listat täyttävät vapaan tilan, mutta muut komponentit (nimiöt, kentät, painikkeet) pysyvät samankokoisina.

Valitse SceneBuilderissa tekemättömien tehtävien VBox ja avaa Layout-paneeli:

Kun komponentti on jonkin VBox-säiliön sisällä, komponentille on mahdollista asettaa ns. Vgrow-asetus. Asetus määrittää, miten komponentin korkeuden tulee käyttäytyä, jos komponenttia sisältävän VBox korkeus kasvaa. Mahdolliset asetukset ovat:

  • NEVER: Korkeus pysyy samana.
  • ALWAYS: Täyttää aina tyhjän tilan. Jos usealla komponentilla on tämä asetus, ne jakavat tilan keskenään.
  • SOMETIMES: Kasvaa vain, jos mitään muuta komponenttia ei voi kasvattaa.

Aseta tekemättömien tehtävien säiliölle Vgrow-asetuksen arvoksi ALWAYS. Tee sama myös tehtyjen tehtävien säiliölle, jolloin kummatkin säiliöt kasvavat samassa suhteessa.

Tallenna ja kokeile: nyt listat täyttävät ikkunan pystysuunnassa, ja muut komponentit säilyttävät kokonsa.

Painikkeen asettaminen syöttökentän tasolle

Tällä hetkellä syöttökenttä näyttää olevan hieman irrallinen painikkeesta. Sovelluksissa on yleisempää, että suoraan kenttään liittyvät painikkeet laitetaan syöttökentän kanssa samalle riville.

Koska VBox asettaa komponentit aina allekkain, tarvitsemme sen vaakasuoraa vastinetta: HBox (Horizontal Box). Nimensä mukaisesti HBox on säiliökomponentti, jonka sisällä olevat alkiot sijoitetaan vaakasuorassa suunnassa vasemmalta oikealle.

Lisää HBox-komponentti tehtyjen tehtävien alle ja raahaa syöttökenttä ja painike sen sisään:

Aseta HBox-komponentille seuraavat asetukset:

  • Spacing: 10 (syöttökentän ja painikkeen välille lisätään tyhjää tilaa)
  • Pref Width ja Pref Height: USE_COMPUTED_SIZE (säiliön koko mukautuu syöttökentään ja painikkeeseen)
  • Vgrow: NEVER (säiliön korkeus ei ikinä muutu vaikka sitä sisältävä VBox kasvaisi)

Valitse lopuksi TextField-syöttökenttä ja aseta sen Hgrow-asetukseksi ALWAYS. Tämä vastaa VBox:n Vgrow-asetusta, mutta toimii vaakasuunnassa: syöttökenttä täyttää nyt kaiken vapaan tilan leveyssuunnassa.

Nimiöiden tasaaminen vasemmalle

Tehdään aivan viimeinen loppusilaus: sovelluksissa on yleistä, että nimiöt ovat tasattu vasempaan reunaan. Korjataan vielä tasaus, jotta sovelluksen ulkoasu on käyttäjälle "tutumpi".

Valitse koko näkymän päällimmäinen VBox ja aseta Properties-paneelissa Alignment-asetukseksi CENTER_LEFT:

Tallenna ja käynnistä sovellus. Varmista, että kaikki toimii ja komponentit mukautuvat hyvin ikkunan kokoon.

Tehtävä 7.6: Todo-sovellus, vaihe 6. 1 p.

Palauta tässä osan 7.6 perusteella edistetty projekti.

Kertaus tämän osan vaiheista:

  • Aseta näkymän ja ikkunan minimikooksi 400x400 pikseliä.
  • Aseta valintaruutujen väliin hieman tyhjää tilaa spacing-asetuksella.
  • Aseta tehtyjen ja tekemättömien tehtävien listat kasvamaan, kun tehtäviä tulee paljon.
  • Laita painike ja tekstikenttä samalle riville HBox-säiliön avulla.
  • Tasaa nimiöt vasemmalle.

Kun vaihe on valmis, tee git add muuttuneille tiedostoille ja git commit. Palauta projektisi tiedostot.

Tee tehtävä TIMissä

Osan kaikki tehtävät

Tehtävä 7.1: Todo-sovellus, vaihe 1 1 p.

Palauta tässä osan 7.1 perusteella luotu projekti.

Kertaus tämän osan vaiheista:

  • Tee io.github.ohj-perus-jy:javafx-fxml-template-archetypen pohjalta JavaFX-projekti.
  • Käynnistä ohjelma, ja varmista, että saat JavaFX-sovelluksen ikkunan näkyviin.

Palauta projektisi tiedostot.

Tee tehtävä TIMissä
Tehtävä 7.2: Todo-sovellus, vaihe 2 1 p.

Palauta tässä osan 7.2 perusteella edistetty projekti.

Kertaus tämän osan vaiheista:

  • Lisää SceneBuilderissa FXML-tiedostoon oma TextField-komponentti VBoxin sisään.
  • Lisää painikkeelle onAction-tapahtumankäsittelijä, joka lisää tekstikenttään kirjoitetun tekstin Label-komponenttiin.
  • Näytä Label-komponentissa kaikki tehtävät erottamalla ne toisistaan rivinvaihdolla.

Palauta projektisi tiedostot.

Tee tehtävä TIMissä
Tehtävä 7.3: Todo-sovellus, vaihe 3. 1 p.

Tee projektillesi Git-varasto, ja tee siihen ensimmäinen commit.

Aja sen jälkeen komentorivillä git status -komento ja palauta sen tuloste tämän tehtävän palautuslaatikkoon.

Tee tehtävä TIMissä
Tehtävä 7.4: Todo-sovellus, vaihe 4. 1 p.

Palauta tässä osan 7.4 perusteella edistetty projekti.

Kertaus tämän osan vaiheista:

  • Tee kaksi VBox-komponenttia tekemättömille ja tehdyille tehtäville.
  • Kun käyttäjä syöttää tehtävän, lisää se tekemättömien tehtävien VBox-säiliöön CheckBox-komponenttina.
  • Kun käyttäjä merkitsee tehtävän tehdyksi klikkaamalla valintaruudusta, siirrä se tekemättömien VBox-säiliöstä tehtyjen säiliöön.
  • Kun käyttäjä merkitsee tehdyn tehtävän tekemättömäksi klikkaamalla valintaruudusta, siirrä se tehtyjen säiliöstä tekemättömien säiliöön.
  • Kun käyttäjä lisää tehtävän, fokuksen tulee palautua syöttökenttään.
  • Käyttäjän ei pidä pystyä lisäämään tehtävää ilman tekstiä tai tehtävää, jonka tekstinä on pelkästään välilyöntejä.
  • Käyttäjän tulee pystyä lisäämän tehtävän myös painamalla Enter-painiketta, kun fokus on syöttökentässä.

Kun vaihe on valmis, muista tehdä git add muuttuneille tiedostoille ja git commit. Palauta projektisi tiedostot. Ei haittaa jos TIMissä tulee jokin varoitus tai jopa käännösvirhe. TIMissä ei välttämättä ole kaikkia tehtävissä vaadittavia riippuvuuksia, eikä siten JavaFX-projekti välttämättä edes käänny. Pääasia on, että olet saanut projektin toimimaan paikallisessa ympäristössäsi.

Tee tehtävä TIMissä
Tehtävä 7.5: Todo-sovellus, vaihe 5. 1 p.

Palauta tässä osan 7.5 perusteella edistetty projekti.

Kertaus tämän osan vaiheista:

  • Tallenna tehtävät JSON-tiedostoon aina, kun käyttäjä lisää tehtävän tai muuttaa tehtävän tilaa.

  • Lue tehtävät JSON-tiedostosta ohjelman käynnistyessä (jos tiedosto on olemassa). JSON-tiedoston tulisi näyttää suunnilleen seuraavalta (pois lukien luettavuutta varten lisätyt rivinvaihdot ja sisennykset):

    [
      {
        "tehtava": "Osta maitoa",
        "tehty": false
      },
      {
        "tehtava": "Vie roskat",
        "tehty": true
      }
    ]
    

Kun vaihe on valmis, muista tehdä git add muuttuneille tiedostoille ja git commit. Palauta projektisi tiedostot.

Tee tehtävä TIMissä
Tehtävä 7.6: Todo-sovellus, vaihe 6. 1 p.

Palauta tässä osan 7.6 perusteella edistetty projekti.

Kertaus tämän osan vaiheista:

  • Aseta näkymän ja ikkunan minimikooksi 400x400 pikseliä.
  • Aseta valintaruutujen väliin hieman tyhjää tilaa spacing-asetuksella.
  • Aseta tehtyjen ja tekemättömien tehtävien listat kasvamaan, kun tehtäviä tulee paljon.
  • Laita painike ja tekstikenttä samalle riville HBox-säiliön avulla.
  • Tasaa nimiöt vasemmalle.

Kun vaihe on valmis, tee git add muuttuneille tiedostoille ja git commit. Palauta projektisi tiedostot.

Tee tehtävä TIMissä

JavaFX osa 2, MVC

osaamistavoitteet

  • Osaat erottaa datan, sovelluslogiikan ja käyttöliittymän toisistaan JavaFX-projektissa.
  • Osaat käyttää JavaFX:n Observable-rajapintaan perustuvia tyyppejä niin, että käyttöliittymä päivittyy automaattisesti datan mukana.
  • Osaat esittää tehtävädatan TableView-komponentissa ja hyödyntää databindingia.
  • Osaat tehdä tehtävien muokkausnäkymän, jossa on validointi ja priorisointitieto.
  • Osaat kirjoittaa yksikkötestejä Todo-sovelluksen mallille ja sovelluslogiikalle.
  • Tutoriaalin toisen vaiheen palautus TIMiin.

Osassa 7 teimme toimivan Todo-sovelluksen, jossa tehtävät mallinnettiin pitkälti käyttöliittymäkomponenteilla (CheckBox) ja tallennettiin JSON-tiedostoon.

Ratkaisu oli sinänsä hyvä aloitus, mutta pidemmällä aikavälillä se tekee sovelluksesta jäykän. Esimerkiksi jos haluamme lisätä tehtäville uusia ominaisuuksia, kuten pidemmän kuvauksen tai vaikkapa prioriteetin, meidän pitäisi lisätä uusia komponentteja, kuten TehtavaCheckBox-komponentti. Edelleen jos haluaisimme muokata tehtävän dataa lomakkeena, joutuisimme tekemään oman TehtavaForm-komponentin. Silloin taas on epäselvää, kumpi datan esitysmuodoista on "oikea": pitääkö TehtavaCheckBox-tehtävän tiedot siirtää aina TehtavaForm-tehtäviin vai toisinpäin?

Oliopohjaista suunnittelua mukaillen kohdealueen ydintoiminta ja sen esittäminen käyttäjälle ovat kaksi erillistä vastuuta. Todo-sovelluksen tehtävien tiedot ja niiden hallinta olisi parasta mallintaa omana kokonaisuutena. Puolestaan käyttöliittymän ainoa vastuu tulisi olla esittää kohdealueen data. Valintaruudut eivät itsessään ole tehtävä, vaan vain tapa esittää niitä.

Tässä osassa jatkamme osan 7 Todo-sovelluksen työstämistä. Keskitymme nyt sovelluksen käyttöliittymän ja ydintoiminnan erottamiseen. Lopuksi katsomme, miten tämä erottaminen mahdollistaa ydintoiminnan oikeellisuuden varmistamista automaattisilla testeilla.

JavaFX tarjoaa aputyökaluja, jolla käyttöliittymä ja ydinlogiikka voidaan pitää ns. "löyhästi sidottuna". Tässä osassa:

  • Siirrämme kaikki tehtävien tiedot Tehtava-luokkaan ja mallinnamme tehtävät ObservableList-kokoelmalla.
  • Käytämme TableView-näkymää tehtävien datan esittämiseen.
  • Jäsennämme sovelluksen luokat MVC-arkkitehtuurin mukaisesti kolmeen vastuualueeseen.
  • Lisäämme tehtävän muokkausikkunan, jossa voi muokata kuvausta, prioriteettia ja määräpäivää.
  • Varmistamme ratkaisun toimivuutta yksikkötesteillä.
  • Julkaisemme valmiin projektimme GitLab- tai GitHub-etävarastopalvelussa.

Malli ja Observable-rajapinta

Osassa 7.5 teimme Tehtava-luokan, jonka tarkoituksena oli mallintaa JSON-tiedostoon tallennettavan tehtävän tiedon. Käyttöliittymässä tehtävät käsiteltiin CheckBox-olioina. Tehtävien tallentamisen ja lataamisen yhteydessä muunsimme tehtäväoliot muodosta toiseen (CheckBox <-> Tehtava). Kuten tämän osan johdannossa mainittiin, tämä muuntaminen ei pidemmällä aikavälillä ole hyvä ratkaisu.

Ensimmäinen päävaihe datan ja käyttöliittymän vastuiden erottamisessa olisikin vähentää datan toistoa. Eräs selkeä tapa on tehdä Tehtava-luokasta ainoa tapa mallintaa yksittäisen tehtävän toiminnot ja tila. Käyttöliittymäkielessä Tehtava-luokasta tulee niin kutsuttu malliluokka (engl. model class). Malli-termillä tarkoitetaan sovelluksen datan rakennetta ja siihen liittyvää tilaa ilman käyttöliittymäriippuvuuksia. Malli vastaa siis kysymykseen siitä, mitä tietoa sovelluksessa on, ei siihen, miltä tieto näyttää ruudulla.

Heti ensimmäiseksi ongelmaksi nousee, miten Tehtava-olion tiedot saadaan käyttöliittymälle. Lisäksi jos tehtävädata muuttuu ohjelman ajon aikana, näkymän pitäisi reagoida tähän automaattisesti ilman, että jokaisen muutoksen jälkeen kirjoitetaan erikseen päivityskoodia kaikkiin käyttöliittymäkomponentteihin.

Tässä kohtaan meitä auttavat JavaFX:n Observable-rakenteet, joiden avulla data ja käyttöliittymä voidaan kytkeä toisiinsa hallitusti.

Observable-rakenteet

Sana observable (havaittava) tarkoittaa, että olio osaa ilmoittaa muutoksistaan muille sovelluksen olioille. Oliot, jotka kuuntelevat havaittavan olion muutoksia kutsutaan puolestaan havaitsijoiksi (observer), tilaajiksi (subscriber) tai kuuntelijoiksi (listener). Näillä termeillä tarkoitetaan käytännössä samaa asiaa, vaikka eri konteksteissa saatetaan käyttää painotuksista riippuen eri termejä.

Haivaitsijat ja havaittavat oliot liittyvät syvemmin ohjelmistosuunnittelun observer-suunnittelumalliin, jota käsitellään tarkemmin myöhemmissä osissa. Tässä vaiheessa oleellista on ymmärtää, että JavaFX:ssä observable-rakenteet toimivat perustana sille, miten käyttöliittymä saadaan päivittymään heti, kun data muuttuu.

JavaFX:ssä käytämme pääosin seuraavia Observable-rakenteita:

  • ObservableList<T>, joka ilmoittaa, kun listaan lisätään tai siitä poistetaan alkioita,
  • ObservableValue<T>, joka ilmoittaa, kun sen sisältämä yksittäinen arvo muuttuu,
  • Property-tyyppejä, jotka ilmoittavat aina, kun sen sisältämä arvo muuttuu. Property-tyypit ovat havaittavia versioita niitä vastaavista primitiivityypeistä. Esimerkiksi StringProperty on havaittava versio String-tyypistä, BooleanProperty vastaavasti Boolean-tyypistä ja niin edelleen.

Johdatteleva esimerkki

Unohdetaan ihan hetkeksi Todo-sovellus ja yritetään saada kiinni Observable-tyyppien toiminnasta johdattelevan esimerkin avulla.

Tehdään uusi JavaFX projekti seuraamalla osan 7.1 ohjeita. Anna sovellukselle jokin toinen nimi ja groupId-arvo, vaikkapa ObservableEsimerkki ja fi.jyu.ohj2.esimerkit.observable.

Kommentoi pois Main-luokan main()-pääohjelmasta Application.launch()-kutsu:

public static void main(String[] args) {
    // HIGHLIGHT_GREEN_BEGIN
    // Application.launch(App.class, args);
    // HIGHLIGHT_GREEN_END
}

Sovelluksemme käyttäytyy nyt kuin tavallinen komentoriviohjelma. Kokeile alkuun lisätä ja ajaa alla oleva esimerkki. Lisää tarvittaessa import-määre: import javafx.collections.*;.

// ==========================================
// ÄLÄ KOPIOI TÄTÄ PIILOSSA OLEVAA KOODIA.
// Tämä koodi on olemassa, jotta mdbookissa voidaan matkia ObservableList-luokan 
// toimintaa. JavaFX-sovelluksessa ObservableList on toteutettuna valmiiksi.
// ==========================================
static interface ListChangeListener<E> {
    void onChanged(Change<E> c);
    class Change<E> {
        boolean next = true, added;
        List<E> items;
        Change(boolean a, E item) { 
            added = a; 
            items = a ? Collections.singletonList(item) : Collections.emptyList(); 
        }
        boolean next() { boolean r = next; next = false; return r; }
        boolean wasAdded() { return added; }
        List<E> getAddedSubList() { return items; }
    }
}

static class ObservableList<E> extends ArrayList<E> {
    List<ListChangeListener<E>> listeners = new ArrayList<>();
    void addListener(ListChangeListener<E> l) { listeners.add(l); }
    public boolean add(E e) {
        super.add(e);
        listeners.forEach(l -> l.onChanged(new ListChangeListener.Change<>(true, e)));
        return true;
    }
    public boolean remove(Object o) {
        if (super.remove(o)) {
            listeners.forEach(l -> l.onChanged(new ListChangeListener.Change<>(false, null)));
        }
        return true;
    }
}

static class FXCollections {
    public static <E> ObservableList<E> observableArrayList() { return new ObservableList<>(); }
}

public static void main(String[] args) {
    // 1. Luodaan havaittavia lista tavallisen ArrayListin sijaan
    ObservableList<String> nimet = FXCollections.observableArrayList();

    // 2. Rekisteröidään "kuuntelija", joka reagoi heti kun listan sisältö muuttuu
    nimet.addListener((ListChangeListener<String>) change -> {
        int koko = nimet.size();
        IO.println("Listalla on nyt " + koko + " nimeä.");
    });

    // 3. Muutetaan dataa
    nimet.add("Denis");
    nimet.add("Antti-Jussi");

    // Application.launch(App.class, args);
}

Kun ajat koodin, näet, että aina kun nimet.add() suoritetaan, konsoliin tulostuu tieto siitä, että nimi on lisätty. Teksti tulostuu aina kun nimet-listaan tehdään muutos mistä päin ohjelmaa tahansa.

ObservableList-olion oleellinen ero tavalliseen listaan on, että sillä on addListener-metodi, jonka avulla voidaan rekisteröidä muuta koodia havaitsemaan, kun listaan tehdään muutoksia. Jos lisäät alkioita tavalliseen ArrayList-listaan, mikään toinen olio ei automaattisesti tiedä siitä, ellei se erikseen käy tarkistamassa listan kokoa. ObservableList taas on aktiivinen. Kun listalle vaikkapa lisätään tai sieltä poistetaan alkio, lista lähettää ilmoituksen kaikille muutoksista kiinnostuneille, jotka ovat rekisteröityneet havaitsijoiksi addListener()-metodin avulla.

Yllä olevassa esimerkissä havaitsija on lambdalauseke, joka tulostaa konsoliin listan koon muutoksen jälkeen.

Lambdan change-parametri sisältää kuvauksen juuri tapahtuneesta muutoksesta tai muutoksista, jos niitä tapahtui useita: mitä indeksejä muutos koski, lisättiinkö vai poistettiinko alkioita, ja mitä alkioita lisättiin tai poistettiin. Kyseisellä oliolla on käytettävissään metodeja, joiden avulla voidaan selvittää muutokseen liittyviä yksityiskohtia, kuten wasAdded(), wasRemoved(), getAddedSubList() ja getRemoved() (ks. JavaDoc).

Kokeillaan change-parametrin käyttöä. Lisätään kuuntelijaan ehto, jonka perusteella listaan lisättäessä tulostetaan jotakin, mutta poistettaessa ei.

// ==========================================
// ÄLÄ KOPIOI TÄTÄ PIILOSSA OLEVAA KOODIA.
// Tämä koodi on olemassa, jotta mdbookissa voidaan matkia ObservableList-luokan 
// toimintaa. JavaFX-sovelluksessa ObservableList on toteutettuna valmiiksi.
// ==========================================
static interface ListChangeListener<E> {
    void onChanged(Change<E> c);
    class Change<E> {
        boolean next = true, added;
        List<E> items;
        Change(boolean a, E item) { 
            added = a; 
            items = a ? Collections.singletonList(item) : Collections.emptyList(); 
        }
        boolean next() { boolean r = next; next = false; return r; }
        boolean wasAdded() { return added; }
        List<E> getAddedSubList() { return items; }
    }
}

static class ObservableList<E> extends ArrayList<E> {
    List<ListChangeListener<E>> listeners = new ArrayList<>();
    void addListener(ListChangeListener<E> l) { listeners.add(l); }
    public boolean add(E e) {
        super.add(e);
        listeners.forEach(l -> l.onChanged(new ListChangeListener.Change<>(true, e)));
        return true;
    }
    public boolean remove(Object o) {
        if (super.remove(o)) {
            listeners.forEach(l -> l.onChanged(new ListChangeListener.Change<>(false, null)));
        }
        return true;
    }
}

static class FXCollections {
    public static <E> ObservableList<E> observableArrayList() { return new ObservableList<>(); }
}

public static void main(String[] args) {
    ObservableList<String> nimet = FXCollections.observableArrayList();

    nimet.addListener((ListChangeListener<String>) change -> {
        // HIGHLIGHT_GREEN_BEGIN
        while (change.next()) { // Käydään läpi kaikki tapahtuneet muutokset
            if (change.wasAdded()) { // Tarkistetaan, oliko muutos lisäys
                IO.println("Listalle lisättiin: " + change.getAddedSubList());
            }
        }
        // HIGHLIGHT_GREEN_END
        // Tämä osa koodista suoritetaan edelleen aina kuuntelijaa kutsuttaessa, 
        // riippumatta siitä, oliko muutos lisäys vai poisto
        int koko = nimet.size();
        IO.println("Listalla on nyt " + koko + " nimeä.");
    });

    nimet.add("Denis");
    nimet.add("Antti-Jussi");
    // HIGHLIGHT_GREEN_BEGIN
    nimet.add("Sami");
    nimet.remove("Denis");
    // HIGHLIGHT_GREEN_END
    
    // Application.launch(App.class, args);
}

Yllä olevan esimerkin while (change.next()) on JavaFX:n tapa käsitellä listalla tapahtuneita muutoksia. Yhdellä kertaa listaan saattaa tulla useita muutoksia (esim. alkioiden lisäys, alkoiden poisto, alkioiden siirtäminen). Silmukka varmistaa, että jokainen niistä käsitellään.

Kuuntelijoita voi olla useita. Jokainen addListener(...) rekisteröi uuden kuuntelijan. Kun listassa tapahtuu muutos, JavaFX ilmoittaa siitä kaikille kuuntelijoille yksi kerrallaan. Yksi havaitsija voi esimerkiksi päivittää käyttöliittymää, toinen voi kirjoittaa lokia ja kolmas voi tehdä validointia.

Huomionarvoista on, että ObservableList-olion kuuntelija reagoi oletusarvoisesti vain listan rakenteen muutoksiin (lisäys, poisto, jne.), ei alkioiden sisällön muutoksiin. Niinpä ObservableList-olion kuuntelijaa ei kutsuta silloin, kun listan yksittäinen alkio muuttuu, esimerkiksi jos nimet-listan ensimmäinen alkio muuttuu "Denis" -> "Antti-Jussi". Palaamme tähän seikkaan osassa 8.2, kun käsittelemme property-tyyppejä.

Valinnaista lisätietoa: Miksi lambdalausekkeessa tarvitaan tyyppimuunnos?

Vielä sananen change-parametrista, joka näyttää hieman monimutkaiselta. Change on geneerinen olio, joka sisältää tietoa listassa kulloinkin tapahtuneesta muutoksesta. Change-oliota käytetään ListChangeListener-rajapinnan onChanged-metodissa; tuon metodin esittelyrivi on void onChanged(Change<? extends E> c);. Nyt meillä E on String, joten täydellinen tyyppi on ListChangeListener.Change<? extends String>. Syy tälle syntaksille on siinä, että listat ovat geneerisiä, ja tällä tavalla erilaisia listamuutoksia (lisäys, poisto, korvaus, jne.) voidaan käsitellä samalla Change-oliolla.

Kytkentä käyttöliittymään

JavaFX:ssä havaittavia olioita käytetään siten, että muutosten havaitsija on jokin käyttöliittymässä oleva komponentti. Katsotaan vielä, miten tämä toimii käytännössä.

Palauta Main-luokka alkuperäiseen tilaan, jossa main()-metodissa on vain Application.launch()-kutsu. Ota sen jälkeen pohjaksi alla oleva valmis kontrolleriluokka ja FXML-näkymä. Import-lauseita on poistettu tilan säästämiseksi.

MainController.java
package fi.jyu.ohj2.esimerkit.observable;

public class MainController implements Initializable {
    @FXML
    private TextField nimikentta;

    @FXML
    private Button nimipainike;

    @FXML
    private ListView<String> nimitulosteet;

    private ObservableList<String> nimet = FXCollections.observableArrayList();

    @Override
    public void initialize(URL url, ResourceBundle resourceBundle) {
    }
}

Kokeile ajaa sovellus, jonka pitäisi näyttää suunnilleen täältä:

Kontrollerissa olevat attribuutit nimikentta ja nimipainike vastaavat käyttöliittymässä olevaa kenttää ja painiketta. Puolestaan nimet on lista nimistä käyttäen ObservableList-listaa; siis sama lista kuin ylempänä olevissa esimerkeissä. Lopuksi nimitulosteet on ListView-komponentti (JavaDoc), joka osaa näyttää ObservableList-listan sisällön käyttöliittymässä. View-sanalla varustettuja komponentteja, kuten ListView, kutsutaan usein näkymäkomponenteiksi (view component).

Alkuun ListView ei tiedä, minkä listan sisältöä näytetään. Kytketään nyt nimilista ja näkymäkomponentti toisiinsa kontrolleriluokassa:

public void initialize(URL url, ResourceBundle resourceBundle) {
    // HIGHLIGHT_GREEN_BEGIN
    nimitulosteet.setItems(nimet);

    nimet.add("Denis");
    nimet.add("Antti-Jussi");
    nimet.add("Sami");
    // HIGHLIGHT_GREEN_END
}

Näin asetamme nimitulosteet-komponentin nimet-listan havaitsijaksi. Varsinainen addListener()-kutsu tapahtuu setItems()-metodissa aivan samalla tavalla kuin teimme itse aiemmin. Tämän kytkennän jälkeen meidän ei tarvitse koskaan kutsua mitään "päivitä lista" -metodia. Kun koodissa tehdään nimet.add("Uusi nimi"), nimi ilmestyy ruudulle automaattisesti.

Tätä on tietysti vielä pikkuisen hankala nähdä, koska initialize()-metodissa nimien lisäys kovakoodattuna. Lisätään vielä painikkeelle tapahtumankäsittelijä, joka lisää listaan uuden nimen.

public void initialize(URL url, ResourceBundle resourceBundle) {
    nimitulosteet.setItems(nimet);

    // HIGHLIGHT_RED_BEGIN
    nimet.add("Denis");
    nimet.add("Antti-Jussi");
    nimet.add("Sami");
    // HIGHLIGHT_RED_END

    // HIGHLIGHT_GREEN_BEGIN
    nimipainike.setOnAction(event -> {
        String teksti = nimikentta.getText();
        nimet.add(teksti);
        nimikentta.clear();
        nimikentta.requestFocus();
    });
    // HIGHLIGHT_GREEN_END
}

Kokeile nyt ajaa sovellus ja lisätä nimiä käyttöliittymän kautta.

Tehtävä malliluokaksi

Palataan nyt takaisin Todo-sovellukseemme. Tavoitteenamme olisi mallintaa kaikki tehtävän tila Tehtava-luokalla. VBox- ja CheckBox-komponenttien tehtäväksi jäisi datan esittäminen.

Aloitetaan lisäämällä MainController-luokkaan ObservableList-attribuutti, joka toimii kaikkien tehtävien säiliönä.

private ObservableList<Tehtava> tehtavat = FXCollections.observableArrayList();

Muuta sitten lisaaTehtava()-metodi niin, että se ei enää lisää CheckBox-komponenttia, vaan lisää uuden Tehtava-olion listaamme.

private void lisaaTehtava() {
    String teksti = uusiTehtavaNimi.getText();
    if (teksti == null || teksti.isBlank()) {
        uusiTehtavaNimi.requestFocus();
        return;
    }
    teksti = teksti.trim();
    // HIGHLIGHT_RED_BEGIN
    tekemattomat.getChildren().add(luoCheckBox(teksti, false));
    // HIGHLIGHT_RED_END
    // HIGHLIGHT_GREEN_BEGIN
    tehtavat.add(new Tehtava(teksti, false));
    // HIGHLIGHT_GREEN_END
    uusiTehtavaNimi.clear();
    uusiTehtavaNimi.requestFocus();
    tallenna();
}

Vastaavasti voimme nyt muuttaa tallenna()-metodin toimintaa niin, että tallennamme suoraan tehtavat-listan, koska se on jatkossa "totuuden lähde" kaikkien tehtävien tilasta.

private void tallenna() {
    // HIGHLIGHT_RED_BEGIN
    List<Tehtava> kaikkiTehtavat = new ArrayList<>();
    kaikkiTehtavat.addAll(haeTehtavat(tekemattomat));
    kaikkiTehtavat.addAll(haeTehtavat(tehdyt));
    // HIGHLIGHT_RED_END
    ObjectMapper mapper = new ObjectMapper();
    // HIGHLIGHT_YELLOW_BEGIN
    mapper.writeValue(Path.of("tehtavat.json"), tehtavat);
    // HIGHLIGHT_YELLOW_END
}

Myös lataa()-metodi yksinkertaistuu, kun voimme käyttää addAll()-metodia, joka lisää kaikki alkiot kerralla:

private void lataa() {
    Path path = Path.of("tehtavat.json");
    if (Files.notExists(path)) {
        return;
    }
    try {
        ObjectMapper mapper = new ObjectMapper();
        List<Tehtava> kaikkiTehtavat = mapper.readValue(path.toFile(), new TypeReference<>() {});
        // HIGHLIGHT_RED_BEGIN
        kaikkiTehtavat.forEach(tehtava -> {
            CheckBox checkbox = luoCheckBox(tehtava.getTeksti(), tehtava.getTehty());
            if (tehtava.getTehty()) {
                tehdyt.getChildren().add(checkbox);
            } else {
                tekemattomat.getChildren().add(checkbox);
            }
        });
        // HIGHLIGHT_RED_END
        // HIGHLIGHT_GREEN_BEGIN
        tehtavat.addAll(kaikkiTehtavat);
        // HIGHLIGHT_GREEN_END
    } catch (JacksonException je) {
        IO.println("JSONin lukeminen epäonnistui: " + je.getMessage());
    }
}

Nyt tehtävien luominen, tallentaminen ja lataaminen on toiminnallisesti erotettu käyttöliittymän komponenteista. Toisin sanoen, Tehtava-luokka ja tehtavat-lista muodostavat yhdessä sovelluksen mallin.

Luonnollisesti muutoksen seurauksena nyt tehtävien lataaminen ja niiden lisääminen ei näy käyttöliittymässä, koska mallin ja käyttöliittymän välillä ei ole mitään kytkentää. Toteutetaan nyt käyttöliittymän päivittäminen tehtävien perusteella.

Tässä kohtaa on myös luontevaa muuttaa myös luoCheckBox-metodin esittelyrivi. Aiemmin annoimme sille parametreina tekstin ja tehty/ei-tehty-tiedon erikseen, mutta nyt kun meillä on Tehtava-olio käytettävissä, annetaan se parametrina.

// HIGHLIGHT_YELLOW_BEGIN
private CheckBox luoCheckBox(Tehtava t) {
    CheckBox tehtava = new CheckBox(t.getTeksti());
    tehtava.setSelected(t.getTehty());
// HIGHLIGHT_YELLOW_END
    // metodin loppuosa piilotettu...
    tehtava.setOnAction(event -> {
        if (tehtava.isSelected()) {
            tekemattomat.getChildren().remove(tehtava);
            tehdyt.getChildren().add(tehtava);
        } else {
            tehdyt.getChildren().remove(tehtava);
            tekemattomat.getChildren().add(tehtava);
        }
        tallenna();
    });
    return tehtava;
}

Tehdään nyt käyttöliittymän päivittämiseen apumetodi paivitaNakyma.

private void paivitaNakyma() {
    // Tyhjennetään nykyiset listat
    tekemattomat.getChildren().clear();
    tehdyt.getChildren().clear();

    // Rakennetaan näkymä uudestaan mallin perusteella.
    // Metodi luoCheckBox(tehtava) saa nyt koko olion parametrina.
    for (Tehtava tehtava : tehtavat) {
        CheckBox cb = luoCheckBox(tehtava);
        if (tehtava.getTehty()) {
            tehdyt.getChildren().add(cb);
        } else {
            tekemattomat.getChildren().add(cb);
        }
    }
}

Nyt voimme kytkeä kaiken yhteen. Kuitenkin nyt sen sijaan, että paivitaNakyma() kutsuttaisiin tehtävien lisäämisen tai lataamisen yhteydessä, kytkemme malli ja näkymä löyhästi ObservableList-listan avulla. Lisätään listalle havaitsija, joka pävittää näkymän ja tallentaa tehtävät aina, kun tehtävälista muuttuu:

public void initialize(URL url, ResourceBundle resourceBundle) {
    // HIGHLIGHT_GREEN_BEGIN
    tehtavat.addListener((ListChangeListener<Tehtava>) change -> {
        paivitaNakyma();
        tallenna();
    });
    // HIGHLIGHT_GREEN_END

    // metodin loppuosa piilotettu...
    lataa();
    uusiTehtavaNimi.setOnAction(event -> lisaaTehtava());
    lisaaUusiTehtavaPainike.setOnAction(event -> lisaaTehtava());
}

Koska nyt tallennuskin tapahtuu aina, kun lista muuttuu, voimme myös poistaa erillisen tallenna()-metodikutsun lisaaTehtava()-metodista:

private void lisaaTehtava() {
    // metodin alkuosa piilotettu...
    String teksti = uusiTehtavaNimi.getText();
    if (teksti == null || teksti.isBlank()) {
        uusiTehtavaNimi.requestFocus();
        return;
    }
    teksti = teksti.trim();
    tehtavat.add(new Tehtava(teksti, false));
    uusiTehtavaNimi.clear();
    uusiTehtavaNimi.requestFocus();
    // ...
    // HIGHLIGHT_RED_BEGIN
    tallenna();
    // HIGHLIGHT_RED_END
}

Jos kokeilet sovellusta nyt, tiedostosta ladatut tiedostot näkyvät käyttöliittymässä, ja uusien tehtävien lisääminen toimii.

CheckBox-tapahtuma muuttamaan mallia

Tällä hetkellä luoCheckBox-metodi sisältää edelleen logiikkaa, joka siirtelee checkboxia käsin VBox-säiliöiden välillä. Tässä on nyt hieman turhaa toistoa näkymän päivittämiseen verrattuna, joten otetaan jo tässä vaiheessa vanha koodi pois:

private CheckBox luoCheckBox(Tehtava t) {
    CheckBox tehtava = new CheckBox(t.getTeksti());
    tehtava.setSelected(t.getTehty());
    tehtava.setOnAction(event -> {
        // HIGHLIGHT_RED_BEGIN
        if (tehtava.isSelected()) {
            tekemattomat.getChildren().remove(tehtava);
            tehdyt.getChildren().add(tehtava);
        } else {
            tehdyt.getChildren().remove(tehtava);
            tekemattomat.getChildren().add(tehtava);
        }
        tallenna();
        // HIGHLIGHT_RED_END
    });
    return tehtava;
}

Nyt meidän on muutettava ajattelutapaa. Valintaruudun klikkaamisen ei tulisi siirtää itse itseään, vaan ainoastaan muuttaa mallia. Puolestaan kun malli muuttuu, tehtavat-listan havaitsija päivittää näkymää paivitaNakyma()-metodia käyttäen.

Tässä vaiheessa Tehtava-olio ei vielä osaa ilmoittaa sisäisen tilansa muuttumisesta. Jos kutsuisimme vain tehtava.setTehty(true), ObservableList ei huomaisi tällä hetkellä mitään, koska itse listaan ei tullut uutta oliota.

Juuri nyt kierrämme ongelmaa mallintamalla tilan muuttumista poistamalla vanha tehtävä ja lisäämällä uusi tehtävä, jonka tehty tila on päinvastainen. Samalla uudelleennimetään vielä CheckBox-olion muuttuja kuvaavammin, koska se ei enää mallinna tehtävää, vaan on pelkästään valintaruutu:

private CheckBox luoCheckBox(Tehtava t) {
    CheckBox cb = new CheckBox(t.getTeksti());
    cb.setSelected(t.getTehty());
    cb.setOnAction(event -> {
        // MUUTOS: Emme enää siirrä komponenttia käsin VBoxista toiseen.
        // Sen sijaan päivitämme mallilistaa, mikä laukaisee näkymän päivityksen.
        // HIGHLIGHT_GREEN_BEGIN
        tehtavat.remove(t);
        tehtavat.add(new Tehtava(t.getTeksti(), !t.getTehty()));
        // HIGHLIGHT_GREEN_END
    });
    return cb;
}

Nyt valintaruudun toiminta on suoraviivaisempi:

  1. Käyttäjä klikkaa valintaruutua.
  2. luoCheckBox-metodin setOnAction-tapahtumakäsittelijä muuttaa tehtavat-listaa (remove ja add). Tässä vaiheessa VBox-komponentteihin ei vielä kosketa.
  3. tehtavat-listan kuuntelija (addListener) huomaa, että listan sisältö muuttui.
  4. Kuuntelija kutsuu paivitaNakyma()- ja tallenna()-metodeja.
  5. Vasta nyt paivitaNakyma() tyhjentää VBoxit ja rakentaa ne uudestaan mallin uuden tilan mukaiseksi.

Mainittakoon, että tämä ratkaisu on hieman tehoton. Nyt koko käyttöliittymä rakennetaan uudestaan yhden klikkauksen takia. Toisaalta checkbox-olion tilan muuttaminen aiheuttaa kaksi erillistä muutosta tehtavat-listaan: vanhan Tehtava-olion poiston ja uuden olion lisäyksen. Toisin sanoen, paivitaNakyma() tulee kutsutuksi kahdesti aina, kun valintaruutua klikataan. Tämä on tietysti vähän turhaa, mutta toimii, koska ObservableList huomaa molemmat muutokset ja päivittää näkymän automaattisesti.

Saavutimme kuitenkin päätavoitteemme: sovelluksen tilan ja sen muutoksen mallintaminen on siirtynyt tehtavat-listan ja Tehtava-olioiden vastuulle.

Tehtävä 8.1: Todo-sovellus, vaihe 7. 1 p.

Palauta osan 8.1 perusteella refaktoroitu projekti. Kertaus tämän osan vaiheista:

  • Luo tehtävälle oma malliolio (Tehtava) käyttöliittymäkomponenttien sijaan.
  • Lisää malliin vähintään tehtävän otsikko ja tehty/ei-tehty -tila.
  • Ota käyttöön ObservableList<Tehtava> tehtävien pääasiallisena tietorakenteena.
  • Päivitä tehtävän lisäyslogiikka käyttämään mallioliota.

Kun vaihe on valmis, tee git add muuttuneille tiedostoille ja git commit. Palauta projektisi tiedostot.

Tee tehtävä TIMissä

TableView ja databinding

Osassa 7 tehtävät näytettiin VBox-säiliöissä CheckBox-komponentteja. Tämä on oikein hyvä tapa opetella käyttöliittymän perusidea: luodaan komponentteja ja lisätään ne näkymään. Huomasimme edellisessä osassa, että mallin ja käyttöliittymän erottaminen toisistaan vaatii paivitaNakyma()-metodia, joka kutsutaan aina, kun malli muuttuu. Näkymän päivittäminen mallin muuttuessa voi kuitenkin osoittautua pullonkaulaksi sovelluksen koon kasvaessa, kun näkymän päivittämisen optimointi on itsessään hankala ongelma, johon ei tämän kurssin puitteissa pureuduta.

Tässä kohtaa onkin parempi nojautua JavaFX:n valmiin näkymäkomponentteihin, jotka osaavat tehokkaasti esittää olioita ja reagoida niiden muutoksiin. Otamme tässä osassa käyttöön TableView-komponentin. TableView on JavaFX:n valmis komponentti, jolla olioita voidaan esittää riveinä ja olioiden ominaisuuksia sarakkeina. TableView-komponenttia voi ajatella taulukkona, jossa jokainen rivi on yksi tehtävä ja sarakkeet ovat ominaisuuksia, joita tehtävästä halutaan näyttää, kuten otsikko, prioriteetti ja se, onko tehtävä tehty vai ei.

Esivalmistelu: Tehtävä-luokka havaittavaksi

JavaFX:n TableView osaa reagoida olioiden määrän muutosten lisäksi olioiden sisällä tapahtuneeseen muutokseen. Esimerkiksi jos jonkun tehtävän otsikkoa muutetaan, TableView osaa havaita muutoksen automaattisesti. Tätä varten kuitenkaan pelkästään ObservableList-listan käyttö ei riitä, koska se osaa ilmoittaa kuuntelijoille vain tehtävien lisäämistä ja poistamista. Sen sijaan meidän tulee tehdä itse tehtävä-olio ja myös sen ominaisuudet havaittaviksi.

Olion yksittäisiä ominaisuuksia voidaan muuttaa havaittaviksi käyttäen JavaFX:n niin kutsuttuja property-tyyppejä. Property-tyypit "käärivät" tavalliset arvot, kuten Boolean tai String, ja tarjoavat mekanismin ilmoittaa, kun niiden arvo muuttuu. Esimerkiksi StringProperty on havaittava versio String-tyypistä, BooleanProperty vastaavasti Boolean-tyypistä ja niin edelleen.

Tarvitsemme Tehtava-luokkaamme siis merkkijono- ja totuusarvotyyppien havaittavat versiot. Kun aiemmin tehtävällä oli tavallinen boolean tehty -muuttuja, joka oli piilotettu ohjelman uumeniin, muutamme sen nyt observable-tyyppiseksi. Vastaava muutos tehdään myös tehtävän tekstille. Näille löytyy JavaFX:stä valmiit toteutukset: SimpleStringProperty ja SimpleBooleanProperty.

import javafx.beans.property.*;

public class Tehtava {
    // Alkuperäiset attribuutit on korvattu Property-kääreillä
    private final StringProperty teksti = new SimpleStringProperty("");
    private final BooleanProperty tehty = new SimpleBooleanProperty(false);

    @SuppressWarnings("unused")
    public Tehtava() {}

    public Tehtava(String teksti, boolean tehty) {
        setTeksti(teksti);
        setTehty(tehty);
    }

    // --- Property-setterit ja getterit ---
    // Huomaa, että JavaFX-tyylissä on tapana tarjota kolme metodia per property:
    // 1. Tavallinen get-metodi (palauttaa esim. boolean)
    // 2. Tavallinen set-metodi (ottaa esim. boolean)
    // 3. property-metodi (palauttaa itse Property-olion, esim. BooleanProperty)

    public boolean getTehty() { return this.tehty.get(); }
    public void setTehty(boolean tehty) { this.tehty.set(tehty); }
    public BooleanProperty tehtyProperty() { return this.tehty; }

    public String getTeksti() { return this.teksti.get(); }
    public void setTeksti(String teksti) { this.teksti.set(teksti); }
    public StringProperty tekstiProperty() { return this.teksti; }

    @Override
    public String toString() {
        return getTeksti() + ": " + (getTehty() ? "TEHTY" : "EI TEHTY");
    }
}

Nyt yksittäisen tehtävän ominaisuudet ovat observable eli havaittavia. Tämä mahdollistaa käyttöliittymän näkymän sitomisen (engl. binding) dataan, ja näkymä päivittyy kun arvo muuttuu datassa. Toisaalta kun käyttäjä muuttaa arvoa käyttöliitymässä, muutos päivittyy samaan propertyyn. Palaamme tähän ajatukseen seuraavaksi tarkemmin.

TableView-komponentin lisääminen ja käyttöliittymän siistiminen

Aloitetaan näkymästä: avaa main.fxml SceneBuilderissa. Poista käyttöliittymästä tehdyt ja tekemattomat VBox-komponentit sekä niihin liittyvät nimiöt. Poistamisen jälkeen hierarkiaan jää vain HBox-komponentti, jossa on syötekenttä ja painike:

Sen jälkeen etsi Library-näkymästä TableView-komponentti ja lisää se syötekentän yläpuolelle. Ole tarkkana: valitse nimenomaan TableView, ei TreeTableView. Valitse lisätty TableView ja aseta sen Vgrow-asetukseksi ALWAYS, jotta se täyttää kaiken vapaan tilan. Anna vielä TableView-komponentille fx:id-arvoksi tehtavaTaulu.

Lopuksi, valitse ja poista Hierarchy-paneelista kummatkin TableColumn-komponentit. Lopullinen hierarkia näyttää siis täältä:

Tallenna FXML-tiedosto.

Vielä muutama sana ennen kuin jatketaan. TableView on itse taulukko. Se näyttää rivejä, mutta ei vielä tiedä, millaisia olioita rivit ovat. Se tieto annetaan kontrollerin puolella Java-koodissa – teemme tämän kohta. TableView sisältää useita TableColumn-komponentteja, jotka esittävät olion ominaisuuksia sarakkeina. Tieto siitä, mitä sarakkeessa näytetään, annetaan myös Java-koodissa. Tämänkin teemme ihan pian.

Siistitään nyt MainController-luokka. Ensiksi, poista vanhat VBox tekemattomat- ja VBox tehdyt-attribuutit ja lisää niiden tilalle TableView<Tehtava> tehtavaTaulu-attribuutti:

// HIGHLIGHT_RED_BEGIN
@FXML
private VBox tekemattomat;

@FXML
private VBox tehdyt;
// HIGHLIGHT_RED_END
// HIGHLIGHT_GREEN_BEGIN
@FXML
private TableView<Tehtava> tehtavaTaulu;
// HIGHLIGHT_GREEN_END

Huomaa, että tehtavaTaulu-attribuutin tyyppi on TableView<Tehtava>. TableView on geneerinen luokka, joka ottaa tyyppiparametrina sen olion tyypin, jota taulukossa on tarkoitus näyttää. Meidän tapauksessamme taulukossa on Tehtava-olioiden tietoja.

Muutoksen myötä näkymän päivittämisen tarkoitettu paivitaNakyma()-metodi muuttuu turhaksi, sillä jatkossa TableView hoitaa näkymän päivittämisen automaattisesti itse. Poistetaan siis paivitaNakyma()-metodi:

// Poista koko metodi; TableView hoitaa näkymän päivittämisen itse
// HIGHLIGHT_RED_BEGIN
private void paivitaNakyma() {
    // ...
    // Tyhjennetään nykyiset listat
    tekemattomat.getChildren().clear();
    tehdyt.getChildren().clear();

    // Rakennetaan näkymä uudestaan tehtävien perusteella.
    // Metodi luoCheckBox(tehtava) saa nyt koko olion parametrina.
    for (Tehtava tehtava : tehtavat) {
        CheckBox cb = luoCheckBox(tehtava);
        if (tehtava.getTehty()) {
            tehdyt.getChildren().add(cb);
        } else {
            tekemattomat.getChildren().add(cb);
        }
    }
}
// HIGHLIGHT_RED_END

Vastaavasti initialize()-metodissa oleva tehtavat-listan kuuntelijasta voidaan poistaa paivitaNakyma()-kutsu:

public void initialize(URL url, ResourceBundle resourceBundle) {
    tehtavat.addListener((ListChangeListener<Tehtava>) change -> {
        // HIGHLIGHT_RED_BEGIN
        paivitaNakyma();
        // HIGHLIGHT_RED_END
        tallenna();
    });
    // metodin loppuosa piilotettu...
    lataa();
    uusiTehtavaNimi.setOnAction(event -> lisaaTehtava());
    lisaaUusiTehtavaPainike.setOnAction(event -> lisaaTehtava());
}

Tehtävien ja ominaisuuksien sitominen taulukkoon

Kun FXML-rakenne on määritelty, sidomme taulukon näkymän ja mallin yhteen. Ensiksi sidomme tehtavat-listan taulukkoon käyttäen setItems()-metodia:

public void initialize(URL url, ResourceBundle resourceBundle) {
    // HIGHLIGHT_GREEN_BEGIN
    tehtavaTaulu.setItems(tehtavat);
    // HIGHLIGHT_GREEN_END

    // metodin loppuosa piilotettu...
    tehtavat.addListener((ListChangeListener<Tehtava>) change -> {
        tallenna();
    });

    lataa();
    uusiTehtavaNimi.setOnAction(event -> lisaaTehtava());
    lisaaUusiTehtavaPainike.setOnAction(event -> lisaaTehtava());
}

Kuten edellisessä osassa ListView-komponentin esimerkissä, tässä sidomme tehtävälistan datan taulukkonäkymään. JavaFX lisää setItems()-kutsun myötä listalle havaitsijan ja päivittää taulukon rivit aina, kun tehtävälistassa olevia tehtäviä poistetaan tai lisätään.

Seuraavaksi määrittelemme taulukon sarakkeet ja sidomme tehtävän yksittäiset attribuutit sarakkeisiin. Luodaan aluksi tehty-attribuutille sarake:

public void initialize(URL url, ResourceBundle resourceBundle) {
    tehtavaTaulu.setItems(tehtavat);

    // HIGHLIGHT_GREEN_BEGIN
    /* 1 */ TableColumn<Tehtava, Boolean> tehtySarake = new TableColumn<>("Tehty"); 
    /* 2 */ tehtySarake.setCellValueFactory(cd -> cd.getValue().tehtyProperty());   
    /* 3 */ tehtavaTaulu.getColumns().add(tehtySarake);                             
    // HIGHLIGHT_GREEN_END

    // metodin loppuosa piilotettu...
    tehtavat.addListener((ListChangeListener<Tehtava>) change -> {
        tallenna();
    });

    lataa();
    uusiTehtavaNimi.setOnAction(event -> lisaaTehtava());
    lisaaUusiTehtavaPainike.setOnAction(event -> lisaaTehtava());
}

Tämä näyttää hieman hurjalta, joten palastellaan vihreällä merkitty koodi rivi riviltä.

  1. Luodaan uusi sarake alustamalla TableColumn<Tehtava, Boolean>-olio. Ensimmäinen tyyppiparametri Tehtava kertoo, minkä typpisiä olioita taulukon riveillä on. Toinen tyyppiparametri Boolean kertoo, minkä tyyppisiä arvoja sarakkeessa näytetään. Merkkijono "Tehty" on sarakkeen otsikko, joka näkyy taulukossa.
  2. Sidotaan tehtySarake-sarake tehtyProperty-ominaisuuden arvoon. setCellValueFactory() määrittää, mistä oliosta tai ominaisuudesta sarakkeen näytettävä arvo otetaan. JavaFX kutsuu tässä annettua lambdalauseketta aina, kun se tarvitsee juuri tässä sarakkeessa näytettävän arvon. Lambdan parametri cd ("cell data") sisältää tiedon, minkä rivin tietoja ollaan käsittelemässä, ja cd.getValue() palauttaa kyseisen rivin Tehtava-olion. Edelleen Tehtava-olion tehtyProperty() sisältää ObservableValue<Boolean>-olion, eli juurikin sellaisen, jota TableView pystyy seuraamaan.
  3. Lisätään sarake näkyviin tauluun.

Tehdään sarake myös tehtävän tekstille.

public void initialize(URL url, ResourceBundle resourceBundle) {
    tehtavaTaulu.setItems(tehtavat);

    TableColumn<Tehtava, Boolean> tehtySarake = new TableColumn<>("Tehty");
    tehtySarake.setCellValueFactory(cd -> cd.getValue().tehtyProperty());
    tehtavaTaulu.getColumns().add(tehtySarake);

    // HIGHLIGHT_GREEN_BEGIN
    TableColumn<Tehtava, String> tekstiSarake = new TableColumn<>("Tehtävä");
    tekstiSarake.setCellValueFactory(cd -> cd.getValue().tekstiProperty());
    tehtavaTaulu.getColumns().add(tekstiSarake);
    // HIGHLIGHT_GREEN_END

    // metodin loppuosa piilotettu...
    tehtavat.addListener((ListChangeListener<Tehtava>) change -> {
        tallenna();
    });

    lataa();
    uusiTehtavaNimi.setOnAction(event -> lisaaTehtava());
    lisaaUusiTehtavaPainike.setOnAction(event -> lisaaTehtava());
}
Valinnaista lisätietoa: (1) Tehdasmetodi eli factory method -malli. (2) "Luo ensin, konfiguroi sitten".

Sana factory (esim. setVellValueFactory) viittaa tehdasmetodi-malliin, joka on olio-ohjelmoinnin suunnittelumalli. Tehdasmetodissa olioiden luominen on eriytetty erilliseen metodiin, joka toimii ikään kuin tehtaan tapaan. Tehdasmetodi tarjoaa tavan luoda olioita ilman, että kutsujan tarvitsee tietää tarkalleen, miten olio luodaan tai mitä parametreja tarvitaan.

Yksinkertainen esimerkki:

public class Car {
    private static int carCount = 0;

    private Car() {
        // Yksityinen konstruktori estää suoran olioiden luomisen
    }

    public static Car createCar() {
        carCount++;
        return new Car();
    }

    public static int getCarCount() {
        return carCount;
    }
}

Tällöin olioita luodaan näin:

public class Main {
    public static void main(String[] args) {
        Car car1 = Car.createCar();
        Car car2 = Car.createCar();
        System.out.println("Total cars created: " + Car.getCarCount()); // Tulostaa: Total cars created: 2
    }
}

Tässä createCar() on tehdasmetodi: se luo auton ja voi samalla tehdä muuta hyödyllistä työtä, kuten kasvattaa laskuria.

JavaFX:n TableColumn-tapauksessa kiinnostavampi ajatus ei kuitenkaan ole aivan tehdasmetodi, vaan API-suunnittelun malli, jossa olio luodaan ensin ja sitä konfiguroidaan vasta sen jälkeen erillisillä metodeilla.

Tällainen malli ei ole aina hyvä tavallisille sovellusolioille, kuten autoille tai pankkitileille, koska niiden olisi usein hyvä olla heti konstruktorin jälkeen käyttökelpoisessa tilassa. Käyttöliittymäkirjastojen komponenteissa tilanne on toinen: ne ovat usein tarkoituksella vaiheittain konfiguroitavia olioita.

Juuri tämä muistuttaa sitä, miten JavaFX:n TableColumn-API on suunniteltu. Ensin luodaan sarake:

TableColumn<Tehtava, Boolean> tehtySarake = new TableColumn<>("Tehty");

Sen jälkeen sarakkeelle asetetaan erikseen sen toimintaan liittyviä asioita, kuten:

  • mistä solujen arvot haetaan (setCellValueFactory())
  • miten solut piirretään (setCellFactory())
  • voiko saraketta muokata
  • miten leveä sarake on
  • voiko saraketta lajitella
  • millaista tyyliä sarake käyttää

Tämä on Java-kirjastoissa hyvin tavallinen tapa suunnitella rajapintoja: olio luodaan ensin, ja sen jälkeen sitä konfiguroidaan askel askeleelta. Ratkaisu tekee API:sta joustavan, koska kaikkia asetuksia ei tarvitse tunkea yhteen pitkään konstruktoriin. Jos TableColumn-konstruktorissa olisi jo otsikko, arvonhakija, solutehdas, leveys, lajittelu, muokattavuus ja muita asetuksia, siitä tulisi nopeasti raskas ja hankala käyttää.

Juuri tästä syystä JavaFX erottaa nämä asiat toisistaan:

  • konstruktori luo sarakeolion
  • setCellValueFactory() kertoo, miten datamallista haetaan sarakkeen arvo
  • setCellFactory() kertoo, millainen käyttöliittymäsolu tuon arvon näyttää

Siksi saraketta ei tässä API:ssa luoda muodossa new TableColumn<>("Tehty", cd -> ...). Konstruktori ei ota vastuulleen arvonhakua, vaan se tehdään erikseen setCellValueFactory()-metodilla.

On myös tärkeää erottaa setCellValueFactory() ja setCellFactory() toisistaan. setCellValueFactory() ei luo soluja, vaan määrittää, mistä kunkin rivin arvo haetaan. setCellFactory() puolestaan määrittää, millainen solu (esimerkiksi teksti, valintaruutu, kuva) sarakkeeseen luodaan ja miten se esittää arvonsa. Esimerkiksi Boolean-arvo voidaan näyttää tavallisena tekstinä (true tai false) tai kätevämpänä valintaruutuna. Vastaavasti String-arvo voidaan näyttää tavallisena tekstinä tai jossain villissä tapauksessa vaikkapa kuvana, joka kuvaa sanan merkitystä.

Mainittakoon, että sarakkeet voidaan määritellä myös SceneBuilderissa FXML:ään. Monimutkaisemmissa taulukoissa voi olla kätevää määritellä sarakkeita etukäteen SceneBuilderissa ja ainoastaan käyttää kontrolleria sitoakseen data ja tietty sarake toisiinsa.

Kokeile käynnistää sovellus. Nyt tehtävät näkyvät taulukkonäkymässä, ja tehtävien lisääminen lisää ne taulukkonäkymään. Lisäksi taulukkonäkymä tarjoaa tavan lajitella tehtäviä ja siirtää sarakkeita haluamallaan tavalla:

Huomaa, että meidän ei tarvinnut enää muokata yhtään tehtävän lisäämiseen, lataamiseen tai tallentamiseen liittyvää toimintoa, sillä mallin hallinta on erotettu näkymän esittämisestä.

Datan sitominen eli ns. databinding on taulukkonäkymän toiminnan ytimessä. Jos tekisimme taulukon ilman propertyjä ja TableView:ta, joutuisimme usein itse luomaan jokaiselle riville komponentit, täyttämään ne arvoilla sekä päivittämään näkymän erikseen, kun data muuttuu. TableView yhdessä propertyjen kanssa vähentää tätä käsityötä merkittävästi. Koodi kertoo enemmän siitä, mitä halutaan näyttää ja JavaFX:n harteille jätetään itse käyttöliittymän näyttäminen.

Tehty-sarake valintaruuduksi

Oletuksena taulukossa näytetään arvojen merkkijonoesityksiä, minkä takia tehty-tila näytetään false ja true -teksteinä. On kuitenkin aika oleellista, että tehtävän tila olisi edelleen valintaruutu, joka voisi merkata tehdyksi.

Tätä varten taulukkojen TableColumn-sarakekomponenteissa on olemassa setCellFactory()-metodi, jonka avulla sarakkeessa näytetävien solujen ulkoasua voi muuttaa. Lisäksi JavaFX tarjoaa valmiita rakentajia yleisimmille saraketyypeille. Esimerkiksi valintaruutuja voi luoda käyttäen CheckBoxTableCell-luokkaa (JavaDoc). Luokassa oleva forTableColumn-luokkametodi palauttaa oikeanmuotoisen rakentajan, jonka voi antaa setCellFactory()-metodille.

Lisäksi meidän tulee sallia taulukon arvojen muokkaamisen setEditable()-metodilla, jotta valintaruudut olisivat klikattavissa.

public void initialize(URL url, ResourceBundle resourceBundle) {
    tehtavaTaulu.setItems(tehtavat);
    // HIGHLIGHT_GREEN_BEGIN
    tehtavaTaulu.setEditable(true);
    // HIGHLIGHT_GREEN_END

    TableColumn<Tehtava, Boolean> tehtySarake = new TableColumn<>("Tehty");
    tehtySarake.setCellValueFactory(cd -> cd.getValue().tehtyProperty());
    // HIGHLIGHT_GREEN_BEGIN
    tehtySarake.setCellFactory(CheckBoxTableCell.forTableColumn(tehtySarake));
    // HIGHLIGHT_GREEN_END
    tehtavaTaulu.getColumns().add(tehtySarake);

    // metodin loppuosa piilotettu...
    TableColumn<Tehtava, String> tekstiSarake = new TableColumn<>("Tehtävä");
    tekstiSarake.setCellValueFactory(cd -> cd.getValue().tekstiProperty());
    tehtavaTaulu.getColumns().add(tekstiSarake);

    tehtavat.addListener((ListChangeListener<Tehtava>) change -> {
        tallenna();
    });

    lataa();
    uusiTehtavaNimi.setOnAction(event -> lisaaTehtava());
    lisaaUusiTehtavaPainike.setOnAction(event -> lisaaTehtava());
}

Kun sovelluksen käynnistää, Tehty-sarake esittää tehtävien tilan valintaruutuina, joita voi klikata:

Erityisesti aiempi tehty datan sitominen takaa, että valintaruudun tila ja tehtäväolion tila pysyvät ajan tasalla: valintaruudun klikkaaminen muuttaa olion tilaa, ja olion tilan muutos vaikuttaa taulukon näkymään.

Nyt kun valintaruutujen luominen on TableView-komponentin vastuulla, voimme poistaa oman luoCheckBox()-metodin:

// Ei tarvita enää; TableView tekee valintaruudut itse
// HIGHLIGHT_RED_BEGIN
private CheckBox luoCheckBox(Tehtava t) {
    // metodin toteutus piilotettu...
    CheckBox cb = new CheckBox(t.getTeksti());
    cb.setSelected(t.getTehty());
    cb.setOnAction(event -> {
        tehtavat.remove(t);
        tehtavat.add(new Tehtava(t.getTeksti(), !t.getTehty()));
    });
    return cb;
}
// HIGHLIGHT_RED_END

Kuitenkin huomaamme nopeasti ongelman: valintaruudun klikkaaminen ei enää tallenna muuttunutta tilaa tiedostoon. Tämä on osin odotettua: vanhassa toteutuksessa valintaruudun klikkaaminen pakotti muutoksen tehtavat-listaan käyttäen remove() ja add()-metodeja, jotka puolestaan ilmoittivat muutoksesta initialize()-metodissa olevalla havaitsijalle. Valintaruutu ainoastaan muuttaa datan arvoa, jolloin tehtavat-lista ei muutu eikä listan havaitsijassa olevaa tallenna()-metodia ikinä suoriteta.

Tallennus tehtävän tilan muutoksesta

Yksi ratkaisu olisi sellainen, että kytkisimme Tehtava-olion tehtyProperty:n muutokseen havaitsijan, joka kutsuu tallennusta. Tämä onnistuisi vaikkapa lisaaTehtava()-metodissa. Alla on esimerkki, miten tämä voitaisiin tehdä – älä kuitenkaan tee tätä nyt.

private void lisaaTehtava() {
    // metodin alkuosa piilotettu...

    String teksti = uusiTehtavaNimi.getText();
    if (teksti == null || teksti.isBlank()) {
        uusiTehtavaNimi.requestFocus();
        return;
    }
    teksti = teksti.trim();
    // Näin **voitaisiin** tehdä, mutta älä tee tätä nyt!
    // HIGHLIGHT_GREEN_BEGIN
    Tehtava tehtava = new Tehtava(teksti, false);
    tehtava.tehtyProperty().addListener((obs, vanhaArvo, uusiArvo) -> tallenna());
    // HIGHLIGHT_GREEN_END
    tehtavat.add(tehtava);

    // metodin loppuosa piilotettu...
    uusiTehtavaNimi.clear();
    uusiTehtavaNimi.requestFocus();
}

Tämä tarkoittaisi, että aina kun tehtyProperty muuttuu (esimerkiksi valintaruutua klikataan tai muutetaan setTehty()-metodilla), tallennettaisiin kaikki tehtävät.

Tämä olisi sinänsä kätevää, mutta pieneksi ongelmaksi muodostuu, että Jackson-kirjaston kautta ladatut Tehtava-oliot eivät tätä kuuntelijaa saa. Jos nyt muutamme UI:ssa tehtavat.json-tiedostosta ladatun tehtävän tilaa, tallennus ei tapahdu, koska kuuntelijaa ei ole koskaan lisätty. Joutuisimme siis lisäämään saman havaitsijan myös lataa()-metodiin sekä muutenkin kaikkiin paikkoihin, jossa Tehtava-olioita saatetaan luoda.

Mietitään hetki tilannetta. Voisimme kyllä ratkaista yllä olevaa tekemällä apumetodin, joka alustaa Tehtava-olion ja tarvittavan havaitsijan. Tosaalta voisimme hyödyntää ObservableList-listan addListener()-metodia: lisätään initialize()-metodissa tehtavat-listalle uusi havaitsija, joka toimisi luvun 8.1 alun esimerkin tapaisesti. Havaitsija kävisi läpi kaikki lisättävät tehtävät ja lisäisi niiden tehtyProperty-ominaisuudelle havaitsijan automaattisesti. Alla on esimerkki, miten tämä voitaisiin tehdä – älä silti tee tätäkään nyt.

public void initialize(URL url, ResourceBundle resourceBundle) {
    // Parempi, mutta älä tee tätäkään pelkästään tallentamista varten!
    tehtavat.addListener((ListChangeListener<Tehtava>) change -> {
        while (change.next()) {
            if (change.wasAdded()) {
                for (Tehtava tehtava : change.getAddedSubList()) {
                    tehtava.tehtyProperty().addListener((obs, vanhaArvo, uusiArvo) -> tallenna());
                }
            }
        }
    });

    // metodin loppuosa piilotettu...
}

Nyt aina, kun tehtavat-listaan lisätään uusi tehtävä, seuraisimme tehtävän tehty-tilaa ja tallentaisimme tehtävät muutoksen jälkeen. Tämä pätisi sekä käyttöliittymän että tiedoston kautta lisätyille tehtäville.

Tässä ratkaisussa kuitenkin edelleen jokaisen tehtäväolion tehty-ominaisuus saa oman havaitsijan, mikä on hieman outoa. Yllä olevassa esimerkissä kaikkien tehtävien tallentaminen sidotaan suoraan yksittäisen tehtävän tehty-tilan muutokseen. Sen sijaan olisi loogisempaa, että kaikkien tehtävien tallennus olisi sidottu kaikkia tehtäviä sisältävään tehtavat-listaan.

Tähän ongelmaan on olemassa toinenkin, aavistuksen elegantimpi ratkaisu. ObservableList-oliolle voi sen luomisen yhteydessä määritellä niin sanottu ekstraktori (engl. extractor), joka kertoo listalle, mitä kunkin olion propertyjä tulee seurata. Muuta ObservableList-kokoelman luonti seuraavasti:

// Parempi tapa, käytä tätä!
private final ObservableList<Tehtava> tehtavat 
   = FXCollections.observableArrayList(tehtava -> new Observable[] {tehtava.tehtyProperty()});

Tässä tehtava -> new Observable[] { ... } on ekstraktori. Se palauttaa taulukon niistä Observable-olioista, joita listan halutaan seuraavan jokaisessa Tehtava-oliossa. Tällöin ObservableList ilmoittaa listan muutosten (olio lisätty/poistettu) lisäksi listan alkioiden ominaisuuksien muutoksista. Toisin sanoen, addListener()-metodilla olevat havaitsijat saavat nyt tiedon aina, kun listaan lisätään tehtävä, listasta poistetaan tehtävä (tätä tosin emme ole vielä toteuttaneet) ja kun listassa olevien tehtävien tehtyProperty-ominaisuuden arvo vaihtuu.

public void initialize(URL url, ResourceBundle resourceBundle) {
    // metodin alkuosa piilotettu...
    tehtavaTaulu.setItems(tehtavat);
    tehtavaTaulu.setEditable(true);

    TableColumn<Tehtava, Boolean> tehtySarake = new TableColumn<>("Tehty");
    tehtySarake.setCellValueFactory(cd -> cd.getValue().tehtyProperty());
    tehtySarake.setCellFactory(CheckBoxTableCell.forTableColumn(tehtySarake));
    tehtavaTaulu.getColumns().add(tehtySarake);

    TableColumn<Tehtava, String> tekstiSarake = new TableColumn<>("Tehtävä");
    tekstiSarake.setCellValueFactory(cd -> cd.getValue().tekstiProperty());
    tehtavaTaulu.getColumns().add(tekstiSarake);

    // Tämä on jo olemassa!
    // Nyt tätä suoritetaan aina, kun
    // - Tehtävä lisätään
    // - Tehtävä poistetaan
    // - Jonkun tehtävän tehty-ominaisuus muuttuu (esim. taulukon kautta)
    tehtavat.addListener((ListChangeListener<Tehtava>) change -> {
        tallenna();
    });

    // metodin loppuosa piilotettu...
    lataa();
    uusiTehtavaNimi.setOnAction(event -> lisaaTehtava());
    lisaaUusiTehtavaPainike.setOnAction(event -> lisaaTehtava());
}

Kokeile nyt ajaa sovellus. Huomaat, että tehtävien merkkaaminen tehdyksi tallentaa muutokset tiedostoon.

Tehdyt tehtävät taulukon loppuun

Toteutetaan nyt aiemmasta VBox-pohjaisesta ratkaisusta se ominaisuus, jossa tehdyt tehtävät asetetaan aina listan loppuun.

Tämä periaatteessa onnistuu jo nyt käyttöliittymästä, sillä TableView tukee lajittelua klikkaamalla sarakkeen otsikosta. Tehdään kuitenkin tämä kontrollerissa, jotta käyttäjän ei tarvitse kytkeä lajittelua päälle käsin.

JavaFX tarjoaa useamman tavan hoitaa lajittelu. Eräs tapa tehdä pysyvä lajittelu on käyttää ObservableList-listan sorted()-metodia, joka ottaa parametriksi Comparator-vertailijan ja palauttaa SortedList-listan (ks. JavaDoc). SortedList-listan voi puolestaan sitoa TableView-näkymään:

public void initialize(URL url, ResourceBundle resourceBundle) {
    // HIGHLIGHT_GREEN_BEGIN
    SortedList<Tehtava> tehtavatLajiteltu = tehtavat.sorted(Comparator.comparing(Tehtava::getTehty));
    // HIGHLIGHT_GREEN_END
    // HIGHLIGHT_YELLOW_BEGIN
    tehtavaTaulu.setItems(tehtavatLajiteltu);
    // HIGHLIGHT_YELLOW_END

    // metodin loppuosa piilotettu...
    tehtavaTaulu.setEditable(true);

    TableColumn<Tehtava, Boolean> tehtySarake = new TableColumn<>("Tehty");
    tehtySarake.setCellValueFactory(cd -> cd.getValue().tehtyProperty());
    tehtySarake.setCellFactory(CheckBoxTableCell.forTableColumn(tehtySarake));
    tehtavaTaulu.getColumns().add(tehtySarake);

    TableColumn<Tehtava, String> tekstiSarake = new TableColumn<>("Tehtävä");
    tekstiSarake.setCellValueFactory(cd -> cd.getValue().tekstiProperty());
    tehtavaTaulu.getColumns().add(tekstiSarake);

    tehtavat.addListener((ListChangeListener<Tehtava>) change -> {
        tallenna();
    });

    lataa();
    uusiTehtavaNimi.setOnAction(event -> lisaaTehtava());
    lisaaUusiTehtavaPainike.setOnAction(event -> lisaaTehtava());
}

SortedList-lista sisältää alkuperäisen listan alkiot järjestettynä annetun vertailijan mukaan. Kummatkin listat ovat sidottuja toisiinsa: alkion lisääminen järjestettyyn listaan lisää alkion alkuperäiseen listaan ja toisinpäin. Tässäkin korostuu, kuinka databinding-periaate suoraviivaistaa näkymän päivittämistä.

Kokeile ajaa sovellus. Nyt tekemättömät tehtävät näkyvät taulukossa ensin ja tehdyt lopussa, sillä boolean-tyypin oletusvertailija asettaa false-arvot ennen true-arvoja.

Tehtävän poistaminen

Nyt kun meillä on taulukko, voimme käyttää sitä tehtävän poistamisen toteuttamiseen.

Avaa main.fxml SceneBuilderssa ja lisää uusi Button-painikekomponentti HBox-komponentin alapuolelle. Aseta painikkeen tekstiksi "Poista tehtävä" ja anna painikkeelle fx:id-tunnisteeksi poistaValittuPainike:

Tallenna FXML-tiedosto. Lisää sitten MainController-luokkaan painiketta vastaava attribuutti:

@FXML
private Button poistaValittuPainike;

Lisätään sitten poistaValittu()-metodi, joka hoitaa poistamisen. Jotta poisto toimii oikein, käyttöliittymän on ensin tiedettävä, mikä rivi taulukosta on valittuna. TableView pitää kirjaa valituista riveistä erillisessä SelectionModel-oliossa, johon pääsee käsiksi getSelectionModel()-metodilla. Saamme valitun Tehtava-olion edelleen getSelectedItem()-metodilla. Tämän jälkeen voimme poistaa tehtävän tehtavat-listasta.

private void poistaValittu() {
    // 1. Hae valittu tehtävä taulukon valintamallista
    Tehtava valittuTehtava = tehtavaTaulu.getSelectionModel().getSelectedItem();
    // 2. Jos mitään ei ole valittu, ei tehdä mitään
    if (valittuTehtava == null) {
        return;
    }
    // 3. Poistetaan tehtävä mallilistasta
    tehtavat.remove(valittuTehtava);
}

Lopuksi lisätään poistaValittuPainike-painikkeelle tapahtumakäsittelijä, joka kutsuu tätä uutta metodia:

public void initialize(URL url, ResourceBundle resourceBundle) {
    SortedList<Tehtava> tehtavatLajiteltu = tehtavat.sorted(Comparator.comparing(Tehtava::getTehty));
    tehtavaTaulu.setItems(tehtavatLajiteltu);
    tehtavaTaulu.setEditable(true);

    TableColumn<Tehtava, Boolean> tehtySarake = new TableColumn<>("Tehty");
    tehtySarake.setCellValueFactory(cd -> cd.getValue().tehtyProperty());
    tehtySarake.setCellFactory(CheckBoxTableCell.forTableColumn(tehtySarake));
    tehtavaTaulu.getColumns().add(tehtySarake);

    TableColumn<Tehtava, String> tekstiSarake = new TableColumn<>("Tehtävä");
    tekstiSarake.setCellValueFactory(cd -> cd.getValue().tekstiProperty());
    tehtavaTaulu.getColumns().add(tekstiSarake);

    tehtavat.addListener((ListChangeListener<Tehtava>) change -> {
        tallenna();
    });

    lataa();
    uusiTehtavaNimi.setOnAction(event -> lisaaTehtava());
    lisaaUusiTehtavaPainike.setOnAction(event -> lisaaTehtava());
    // metodin alkuosa piilotettu...

    poistaValittuPainike.setOnAction(event -> poistaValittu());
}

Kokeile ajaa sovellus. Kun valitset tehtävän taulukosta ja painat "Poista tehtävä", tehtävän pitäisi poistua taulukosta. Tämäkin toimii datan sidonnan takia: painike poistaa tehtävän tehtavat-listasta, mikä automaattisesti muuttaa lajitellun tehtavatLajiteltu-listan. Puolestaan muutos tehtavatLajiteltu-listassa aiheuttaa TableView-näkymän päivittymisen ilman erillistä toimintaa. Tässäkin siis mallin muokkaus ja näkymän päivitys ovat vain löyhästi kytkettyjä toisiinsa observable-rakenteiden avustuksella.

Bonus: Painikkeen klikkaamisen estäminen jos tehtävää ei valittu

Nyt "Poista tehtävä" -painike on aika klikattavissa vaikka tehtävää ei ole valittu. Hieman käyttäjäystävällisemmin olisi, että painike olisi klikattavissa vain, jos taulukossa on ylipäätään valittuna jokin tehtävä.

Button-komponentissa on olemassa setDisable()-metodi sekä sitä vastaava havaittava disableProperty(), joiden avulla painike voidaan kytkeä pois päältä. Vastaavasti taulukon SelectionModel-oliolla on olemassa selectedItemProperty(), joka on havaittava ominaisuus tällä hetkellä valitusta tehtävästä.

Koska selectedItemProperty() on havaittava ominaisuus, voimme toteuttaa painikkeen kytkemisen päälle ja pois lisäämällä havaitsija:

    poistaValittuPainike.setDisable(true);
    tehtavaTaulu.getSelectionModel().selectedItemProperty().addListener((observable, oldValue, newValue) -> {
        if (newValue == null) {
            poistaValittuPainike.setDisable(true);
        } else {
            poistaValittuPainike.setDisable(false);
        }
    });

TODO: Bindings-luokka ja bind-metodi

Bonus: Painikkeen ilmestyminen vain, jos tehtävää on valittu

Toinen vaihtoehto olisi, että "Poista tehtävä" -painike näkyisi vain, jos taulukossa on valittuna jokin tehtävä. Tällöin painikkeen näkyvyyttä voisi ohjata setVisible()-metodilla, joka on myös havaittava ominaisuus visibleProperty(). Toteutus olisi muuten sama kuin edellisessä kohdassa, mutta setDisable()-kutsut korvattaisiin setVisible()-kutsuilla.

Tehtävä 8.2: Todo-sovellus, vaihe 8. 1 p.

Palauta osan 8.2 perusteella edistetty projekti. Kertaus tämän osan vaiheista:

  • Korvaa tehtävien VBox + CheckBox-listaus TableView-komponentilla.
  • Lisää taulukkoon vähintään sarakkeet: tehtävä (otsikko), tehty-tila.
  • Kytke taulukon data ObservableList<Tehtava>-listaan.
  • Mahdollista tehtävän valinta ja poisto valitulta riviltä.

Kun vaihe on valmis, tee git add muuttuneille tiedostoille ja git commit. Palauta projektisi tiedostot.

Tee tehtävä TIMissä

MVC-arkkitehtuuri

Nyt kun meillä on luotuna Tehtava-malli propertyineen ja pystymme näyttämään listan tehtäviä TableView-komponentissa, on aika pohtia koko sovelluksen arkkitehtuuria.

Sovelluksen arkkitehtuuri tarkoittaa ohjelman eri osien ja vastuualueet järjestämisestä muodostuvaa kokonaisuutta. Arkkitehtuuri muodostuu päätöksistä joiden kumoaminen, muuttaminen tai refaktorointi on erittäin vaikeaa. Hyvä arkkitehtuuri tekee koodista helpommin ymmärrettävää, laajennettavaa ja testattavaa.

Aivan minimaalisissa projekteissa arkkitehtuuria ei tietenkään tarvitse enemmälti pohtia. Tässä projektissa arkkitehtuurille on kuitenkin jo tarvetta erityisesti siksi, että tehtävien käsittely, tallennus ja käyttöliittymä eivät kasautuisi yhteen samaan luokkaan.

Arkkitehtuuria ei usein tarvitse miettiä nollasta, vaan on olemassa valmiita yleisesti hyväksi todettua artkkitehtuuriratkaisuja. Eräs ratkaisu sovelluksen arkkitehtuurin suunnitteluun on MVC (engl. Model-View-Controller).

MVC-arkkitehtuurissa sovellus jaetaan kolmeen osaan: malliin (model), näkymään (view) ja ohjaimeen (controller). Malli huolehtii datasta ja sen käsittelystä, näkymä näyttää käyttöliittymän ja ohjain välittää käyttäjän toiminnot mallille sekä päivittää näkymää. Tässä projektissa MVC auttaa selkeyttämään rakennetta niin, että MainController ei vastaa enää yksin kaikesta, vaan tehtävälistan logiikka ja tallennus voidaan siirtää omaan malliluokkaansa.

Käytännön sovelluksessa ei yleensä ole vain yhtä mallia, yhtä näkymää ja yhtä kontrolleria. Samassa ohjelmassa voi olla useita malleja eri tiedoille, useita näkymiä eri ruuduille tai käyttötilanteille sekä useita kontrollereita, jotka vastaavat omista käyttöliittymän osistaan. MVC kuvaa siis ennen kaikkea vastuiden jakamisen periaatetta, ei sitä, että koko sovellus pitäisi rakentaa vain yhdestä Model-, View- ja Controller-luokasta.

Tässä osassa tunnistamme sovelluksemme osien vastuualueet ja erotamme loput datan hallinnan ja tallennuksen toimintoja kontrollerista omaan malliluokkaansa.

MVC-arkkitehtuurin kerrokset ja vastuut

Katsotaan tarkemmin, mitkä ovat kunkin kerroksen eli osan vastuut MVC-arkkitehtuurissa ja miten kukin kerros toteutetaan tässä projektissa.

Näkymä (view)

  • Vastuu: Miltä sovellus näyttää.
  • Toteutus Todo-sovelluksessa: FXML-tiedostot, jotka kuvaavat käyttöliittymän rakenteen.
  • Rajoitukset: Ei sisällä lainkaan sovelluslogiikkaa (ei esim. tiedä miten tehtävät tallennetaan kovalevylle).

Malli (model)

  • Vastuu: Mitä dataa sovelluksessa on ja miten sitä käsitellään. Tästä käytetään usein termejä sovelluslogiikka, liiketoimintalogiikka tai bisneslogiikka.
  • Toteutus Todo-sovelluksessa: Olemme jo tehneet Tehtava-luokan mallintamaan yksittäistä tehtävää. Tässä osassa luomme lisäksi Tehtavakokoelma-luokan, joka pitää sisällään tehtävälistan tilan ja tarjoaa metodit tehtävien lisäämiseen, poistamiseen ja tallentamiseen.
  • Rajoitukset: Ei tiedä mitään JavaFX-näkymästä (TableView, TextField), vaan luottaa observable-rakenteisiin kertoakseen muutoksista kiinnostuneille osapuolille.

Ohjain (controller)

  • Vastuu: Toimia tulkkina näkymän ja mallin välillä.
  • Toteutus Todo-sovelluksessa: MainController reagoi käyttäjän tekemiin toimintoihin, kuten painikkeen painallukseen, kutsuu mallin (Tehtavakokoelma) metodeja, ja sitoo näkymän (TableView) kiinni malliin Observable-tietorakenteiden avulla.

MVC kannustaa noudattamaan yhden vastuun periaatetta

Yksi vastuu (engl. Single Responsibility) on yksi ohjelmistosuunnittelun periaate, jonka mukaan jokaisella luokalla tai ohjelman osalla pitäisi olla yksi selkeä vastuualue; yksi pääasiallinen syy muuttua. Ajatus on, että samaan luokkaan ei tule kasata asioita, jotka muuttuvat eri syistä. Yhden vastuun periaate on yksi viidestä SOLID-periaatteesta, johon palataan tarkemmin myöhemmässä osassa. "Syy muuttua" tarkoittaa tässä yhteydessä tarvetta tai vaatimusta, jonka vuoksi luokan toteutusta joudutaan muuttamaan.

Todo-sovelluksessamme esimerkiksi tehtävien tallentaminen tiedostoon voisi muuttua siksi, että haluamme vaihtaa JSON-tiedoston tietokantaan. Käyttöliittymä puolestaan voi muuttua siksi, että haluamme näyttää tehtävät eri tavalla, tai vaikkapa tarjota sama sovellus komentorivi- tai verkkoversiona. Jos sama luokka huolehtisi sekä tallennuksesta että käyttöliittymästä, nämä kaksi erilaista muutostarvetta sotkeutuisivat toisiinsa.

MVC-arkkitehtuuri tukee yhden vastuun periaatteen noudattamista, koska eri syistä muuttuvat asiat erotetaan lähtökohtaisesti eri kerroksiin. Tässä projektissa periaate näkyy esimerkiksi näin:

  • Tehtava (ja myöhemmin tässä osassa myös Tehtavakokoelma) kuuluvat malliin, koska niiden tehtävä on kuvata sovelluksen dataa ja siihen liittyviä sääntöjä.
  • MainController ei tallenna tiedostoja itse, vaan delegoi sen mallille.
  • FXML-näkymä sisältää käyttöliittymän rakenteen, ei sovelluslogiikkaa.

Jos MainController vastaisi samaan aikaan painikkeiden käsittelystä, syötteiden tarkistuksesta, tehtävien tallennuksesta ja tiedoston lukemisesta, luokalla olisi monta eri syytä muuttua. Tällöin sitä olisi vaikeampi testata, ylläpitää ja laajentaa turvallisesti.

Sovelluksen pakkausten refaktorointi

JavaFX-sovelluksessa MVC-arkkitehtuurin mukainen jako on helpointa nähdä projektin pakkauksista ja kansiorakenteesta. Tällä hetkellä sovelluksemme luokat jakautuvat seuraaviin pakkauksiin:

fi.jyu.ohj2.nimi.tododataTehtavaAppMainMainController

Refaktoroidaan nykyisten pakkausten nimet ja jaetaan luokat uusiin pakkauksiin niin, että MVC-arkkitehtuurin mukainen vastuunjako näkyy selkeämmin:

fi.jyu.ohj2.nimi.todo
├── model
│   └── Tehtava
├── controller
│   └── MainController
├── App
└── Main

Aloitetaan muuttamalla nykyinen data-alipakkaus model-alipakkaukseen. (Alipakkaus on siis pakkaus, joka sijaitsee toisen pakkauksen sisällä, kuten data-alipakkaus on fi.jyu.ohj2.nimi.todo-pakkauksen alipakkaus.) Avaa IDEAn projektiselain ja klikkaa hiiren toissijaisella painikkeella data-alipakkausta. Valitse sitten Rename avautuneesta valikosta. Tämän jälkeen muuta avautuneesta valikosta pakkauksen data-loppuosa model-loppuosaan ja paina Refactor:

Tee tämän jälkeen uusi alipakkaus nimeltään controller (ks. osa ) ja raahaa MainController-luokka uuteen pakkaukseen:

Huomaa, että IDEA osaa automaattisesti refaktoroida luokkien sisällä olevia package-määreitä sekä FXML-tiedostossa olevan luokkaviitteen.

Tehtäväkokoelma

Siirrämme nyt sovelluksen sydämen, eli tehtävälistan hallinnan ja tietojen luku- ja tallennusoperaatiot, pois kontrollerista omaan luokkaansa. Kyseinen toiminnallisuus liittyy selvästi sovelluksen dataan ja sen käsittelyyn, joten tehtävälista ja sen hallinta kuuluu MVC-arkkitehtuurissa mallikerrokseen.

Luodaan model-pakkaukseen luokka Tehtavakokoelma ja siirretään siihen tehtävien hallintaan kuuluvat toiminnot: tehtava-lista, listaan liittyvä alustus, lataa()-metodi ja tallenna()-metodi. Seuraamme myös kapselointiperiaatetta: teemme tehtavat-listasta private ja teemme apumetodit getTehtavat(), lisaaTehtava() sekä poistaTehtava(), joilla hoidetaan tehtävien lisääminen ja poistaminen sekä kytkentä käyttöliittymään. Samalla teemme pari pientä refaktorointia: siirrämme tallennustiedoston sijainnin sekä ObjectMapper-olion kokoelman attribuutteihin, sillä kumpaakin käytetään latauksen ja tallennuksen yhteydessä.

package fi.jyu.ohj2.nimi.todo.model;

import javafx.beans.Observable;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import tools.jackson.core.JacksonException;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
// import-määreet piilotettu tilan säästämiseksi

public class Tehtavakokoelma {
    private final ObservableList<Tehtava> tehtavat = FXCollections.observableArrayList(
            tehtava -> new Observable[]{tehtava.tehtyProperty()}
    );

    private final Path tiedostoPolku = Path.of("tehtavat.json");
    private final ObjectMapper mapper = new ObjectMapper();

    public Tehtavakokoelma() {
        tehtavat.addListener((ListChangeListener<Tehtava>) change -> {
            tallenna();
        });
    }

    public ObservableList<Tehtava> getTehtavat() {
            return tehtavat;
    }

    public void tallenna() {
        mapper.writeValue(tiedostoPolku, tehtavat);
    }

    public void lataa() {
        if (Files.notExists(tiedostoPolku)) {
            return;
        }
        try {
            List<Tehtava> kaikkiTehtavat = mapper.readValue(tiedostoPolku, new TypeReference<>() {});
            tehtavat.addAll(kaikkiTehtavat);
        } catch (JacksonException je) {
            IO.println("JSONin lukeminen epäonnistui: " + je.getMessage());
        }
    }

    public void lisaaTehtava(String teksti) {
        if (teksti == null || teksti.isBlank()) {
            return;
        }
        teksti = teksti.trim();
        tehtavat.add(new Tehtava(teksti, false));
    }

    public void poistaTehtava(Tehtava tehtava) {
        if (tehtava == null) {
            return;
        }
        tehtavat.remove(tehtava);
    }
}

Huomaa, miten kaikki säännöt ("otsikko ei saa olla tyhjä", "päivitä tiedosto kun lisätään tai ominaisuus muuttuu") asuvat nyt täällä malliluokassa!

Kontrollerin uusi rooli

Päivitetään lopuksi MainController. Kontrollerin rooli on nyt selkeä "virkailija" mallin ja näkymän välissä. Sillä ei ole enää kokoelmiin liittyvän logiikan taakkaa, vaan se vain viestii käyttöliittymän ja Tehtavakokoelman välillä. Tehtavakokoelma toimii tässä ylätason mallina, jota kontrolleri käyttää: se omistaa tehtävälistan, huolehtii sen lataamisesta ja tallentamisesta sekä tarjoaa metodit tehtävien lisäämiseen ja poistamiseen.

Vaikka tässä vaiheessa tämä refaktorointi saattaa vaikuttaa vain koodin siirtämisestä paikasta toiseen, kysymys on enemmänkin vastuiden erottamisesta eri luokkiin MVC-mallin mukaisesti. Kun tehtävälistan hallinta, tallennus ja syötteiden tarkistus ovat omassa malliluokassaan, niitä voidaan kehittää ja testata itsenäisesti ilman käyttöliittymää, ja kontrolleri pysyy yksinkertaisempana.

package fi.jyu.ohj2.nimi.todo.controller;

import fi.jyu.ohj2.nimi.todo.model.Tehtava;
import fi.jyu.ohj2.nimi.todo.model.Tehtavakokoelma;
import javafx.collections.transformation.SortedList;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.Button;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.scene.control.cell.CheckBoxTableCell;

import java.net.URL;
import java.util.Comparator;
import java.util.ResourceBundle;
// import-määreet piilotettu tilan säästämiseksi

public class MainController implements Initializable {
    @FXML
    private Button lisaaUusiTehtavaPainike;

    @FXML
    private TextField uusiTehtavaNimi;

    @FXML
    private TableView<Tehtava> tehtavaTaulu;

    @FXML
    private Button poistaValittuPainike;

    // HIGHLIGHT_YELLOW_BEGIN
    private Tehtavakokoelma tehtavakokoelma = new Tehtavakokoelma();
    // HIGHLIGHT_YELLOW_END

    @Override
    public void initialize(URL url, ResourceBundle resourceBundle) {
        // HIGHLIGHT_YELLOW_BEGIN
        SortedList<Tehtava> tehtavatLajiteltu = tehtavakokoelma.getTehtavat().sorted(Comparator.comparing(Tehtava::getTehty));
        // HIGHLIGHT_YELLOW_END
        tehtavaTaulu.setItems(tehtavatLajiteltu);
        tehtavaTaulu.setEditable(true);

        TableColumn<Tehtava, Boolean> tehtySarake = new TableColumn<>("Tehty");
        tehtySarake.setCellValueFactory(cd -> cd.getValue().tehtyProperty());
        tehtySarake.setCellFactory(CheckBoxTableCell.forTableColumn(tehtySarake));
        tehtavaTaulu.getColumns().add(tehtySarake);

        TableColumn<Tehtava, String> tekstiSarake = new TableColumn<>("Tehtävä");
        tekstiSarake.setCellValueFactory(cd -> cd.getValue().tekstiProperty());
        tehtavaTaulu.getColumns().add(tekstiSarake);

        // HIGHLIGHT_YELLOW_BEGIN
        tehtavakokoelma.lataa();
        // HIGHLIGHT_YELLOW_END
        uusiTehtavaNimi.setOnAction(event -> lisaaTehtava());
        lisaaUusiTehtavaPainike.setOnAction(event -> lisaaTehtava());
        poistaValittuPainike.setOnAction(event -> poistaValittu());
    }

    private void lisaaTehtava() {
        // HIGHLIGHT_YELLOW_BEGIN
        tehtavakokoelma.lisaaTehtava(uusiTehtavaNimi.getText());
        // HIGHLIGHT_YELLOW_END
        uusiTehtavaNimi.clear();
        uusiTehtavaNimi.requestFocus();
    }

    private void poistaValittu() {
        Tehtava valittuTehtava = tehtavaTaulu.getSelectionModel().getSelectedItem();
        // HIGHLIGHT_YELLOW_BEGIN
        tehtavakokoelma.poistaTehtava(valittuTehtava);
        // HIGHLIGHT_YELLOW_END
    }

    // HIGHLIGHT_RED_BEGIN
    private void tallenna() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.writeValue(Path.of("tehtavat.json"), tehtavat);
    }

    private void lataa() {
        Path path = Path.of("tehtavat.json");
        if (Files.notExists(path)) {
            return;
        }
        try {
            ObjectMapper mapper = new ObjectMapper();
            List<Tehtava> kaikkiTehtavat = mapper.readValue(path.toFile(), new TypeReference<>() {});
            tehtavat.addAll(kaikkiTehtavat);
        } catch (JacksonException je) {
            IO.println("JSONin lukeminen epäonnistui: " + je.getMessage());
        }
    }
    // HIGHLIGHT_RED_END
}

Tehtävät

Tehtävä 8.3: Todo-sovellus, vaihe 8. 1 p.

Palauta osan 8.3 perusteella edistetty projekti. Kertaus tämän osan vaiheista:

  • Jäsennä projekti kerroksiin (vähintään malli + käyttöliittymälogiikka).
  • Siirrä tiedoston luku- ja kirjoituslogiikka pois kontrollerista Tehtavakokoelma-luokkaan.
  • Muuta MainController-luokka delegoimaan tallennus- ja latausoperaatiot mallille.

Kun vaihe on valmis, tee git add muuttuneille tiedostoille ja git commit. Palauta projektisi tiedostot.

Tee tehtävä TIMissä

Useita näkymiä ja tehtävän muokkaus

On hyvin tavallista, että sovelluksessa on useampiakin näkymiä kuin vain pääikkuna. JavaFX sallii useiden näkymien lataamista ja näyttämistä kolmella tavalla. Ensiksi, pääikkunassa oleva näkymä voidaan korvata stage.setScene()-metodilla, kuten mainitsimme osassa 7.1. Toiseksi, näkymä voidaan ladata ja lisätä toisen näkymän sisään. Esimerkiksi voisimme lisätä pääikkunaan välilehtiä, jossa jokainen välilehti olisi jaettu omaan näkymään.

Kolmanneksi, voimme luoda uusia ikkunoita, joissa näytetään haluttu näkymä. Ikkunat voivat toimia pääikkunan rinnalla tai voivat olla myös modaalisia. Modaalinen komponentti vaatii käyttäjän huomion, eli sen ollessa auki pääikkunaa ei voi käyttää. Modaalisilla ikkunoille voimme esimerkiksi toteuttaa dialogeja, joilla pyydetään käyttäjältä syötettä, eikä käyttäjä voi jatkaa ennen kuin dialogi on suljettu.

Tässä osassa tutustumme kolmesta tavasta viimeiseen. Teemme dialogin, jossa käyttäjä voi muokata yksittäisen tehtävän yksityiskohtaiset tiedot. Dialogi avautuu, kun käyttäjä tuplaklikkaa tehtävää.

Esivalmistelu: lisää tietoja tehtäviin

Muokataan aluksi Tehtava-luokkaa lisäämällä siihen lisää tietoja. Haluaisimme, että jatkossa tehtävällä olisi

  • Otsikko, joka näytetään taulukossa
  • Tehty/ei-tehty tila, kuten nytkin
  • Tarkempi kuvaus
  • Prioriteetti, jossa on kolme sallittua arvoa: matala, keski, korkea

Ensimmäiset kaksi hoituvat nykyisillä tehtävän attribuuteilla. Tarkempi kuvaus voidaan mallintaa merkkijonona.

Prioriteetti voitaisiin mallintaa numerolla 0, 1 ja 2. Java kuitenkin tarjoaa tällaisille tapauksille erillisen luetelmatyypin (engl. enumeration, enum), jonka avulla voi mallintaa olion, jolla on jokin rajattu määrä mahdollisia arvovaihtoehtoja. Esimerkiksi prioriteetti voidaan mallintaa luetelmana seuraavasti:

// Luetelmatyyppi Prioriteetti
// Prioriteetti sallii vain kolme mahdollista arvoa: MATALA, KESKI, KORKEA
public enum Prioriteetti {
    MATALA, KESKI, KORKEA
}

// Esimerkki enum-tyypin käytöstä
void main() {
    Prioriteetti prio = Prioriteetti.MATALA;
    IO.println(prio);
}

Luetelmatyypin arvoja voidaan tallentaa attribuutteihin tai muuttujiin tavallisten olioiden tapaan, mutta luetelmatyypin arvona voi olla täsmälleen yksi luetelmassa mainituista arvoista.

Luo model-alipakkaukseen uusi tiedosto Prioriteetti.java ja määritä siihen luetelma:

package fi.jyu.ohj2.nimi.todo.model;

public enum Prioriteetti {
    MATALA, KESKI, KORKEA
}

Laajennetaan nyt Tehtava-luokkaa lisäämällä siihen uudet attribuutit kuvaukselle ja prioriteetille. Refaktoroidaan samalla teksti-attribuutti otsikko-nimiseksi:

public class Tehtava {
    // HIGHLIGHT_YELLOW_BEGIN
    private final StringProperty otsikko = new SimpleStringProperty("");
    // HIGHLIGHT_YELLOW_END
    // HIGHLIGHT_GREEN_BEGIN
    private final StringProperty kuvaus = new SimpleStringProperty("");
    // HIGHLIGHT_GREEN_END
    private final BooleanProperty tehty = new SimpleBooleanProperty(false);
    // HIGHLIGHT_GREEN_BEGIN
    private final ObjectProperty<Prioriteetti> prioriteetti = new SimpleObjectProperty<>(Prioriteetti.KESKI);
    // HIGHLIGHT_GREEN_END

    @SuppressWarnings("unused")
    public Tehtava() {}

    // HIGHLIGHT_YELLOW_BEGIN
    public Tehtava(String otsikko, boolean tehty) {
        setOtsikko(otsikko);
    // HIGHLIGHT_YELLOW_END
        setTehty(tehty);
    }

    public boolean getTehty() { return this.tehty.get(); }
    public void setTehty(boolean tehty) { this.tehty.set(tehty); }
    public BooleanProperty tehtyProperty() { return this.tehty; }

    // HIGHLIGHT_YELLOW_BEGIN
    public String getOtsikko() { return this.otsikko.get(); }
    public void setOtsikko(String otsikko) { this.otsikko.set(otsikko); }
    public StringProperty otsikkoProperty() { return this.otsikko; }
    // HIGHLIGHT_YELLOW_END

    // HIGHLIGHT_GREEN_BEGIN
    public String getKuvaus() { return kuvaus.get(); }
    public void setKuvaus(String kuvaus) { this.kuvaus.set(kuvaus); }
    public StringProperty kuvausProperty() { return this.kuvaus; }

    public Prioriteetti getPrioriteetti() { return this.prioriteetti.get(); }
    public void setPrioriteetti(Prioriteetti prioriteetti) { this.prioriteetti.set(prioriteetti); }
    public ObjectProperty<Prioriteetti> prioriteettiProperty() { return this.prioriteetti; }
    // HIGHLIGHT_GREEN_END

    @Override
    public String toString() {
        return getOtsikko() + ": " + (getTehty() ? "TEHTY" : "EI TEHTY");
    }
}

Huomaa, että teksti-attribuutin refaktorointi edellyttää, että myös get-, set- ja property-metodit sekä kontrollerissa olevat viitteet päivitetään vastaamaan uutta nimeä.

Voit helposti muuttaa nimen siirtämällä kursorin uudelleennimettävän kohteen kohdalle, klikkaamalla hiiren toissijaisella painikkeella ja valitsemalla Rename. Tämän jälkeen anna attribuutille uusi nimi ja paina Enter. IDEA tämän jälkeen kysyy, haluatko samalla uudelleennimetä metodit sekä kaikki muut mahdolliset paikat, joissa attribuuttia käytetään.

Vaihtoehtoisesti voit uudelleennimetä attribuutit ja metodit käsin. Muista, että MainControllerissa on myös viitteitä tekstiProperty()-metodiin, joita on nimettävä uudelleen.

Tallenna muutokset. Kun ajat ohjelman nyt, huomaat että vanhoista tehtävistä katosivat otsikot, sillä muutimme attribuutin nimen. Koska vain testaamme sovelluksen toimintaa, voit yksinkertaisesti poistaa tehtavat.json-tiedoston projektiselaimesta. Jos IDEA kysyy, haluatko poistamisen yhteydessä tehdä ns. turvallisen poiston ("Safe delete"), ota se pois päältä.

Uuden muokkausnäkymän luominen

Luodaan uusi näkymä tulevalle dialogille. Muistamme, että käyttöliittymää varten tarvitsemme näkymän eli uuden FXML-tiedoston sekä uuden kontrolleriluokan. Aloitamme ensin näkymästä.

Avaa SceneBuilder ja valitse etusivulta "New Project from Template" -kohdasta Empty -pohjan.

Tallenna uusi FXML-tiedosto saman tien. Valitse tallennuspaikaksi sama kansio, jossa projektisi main.fxml on, eli src/main/resources/fi/jyu/ohj2/nimi/todo. Anna uuden tiedoston nimeksi esimerkiksi tehtava-edit.fxml.

Tämän jälkeen luo SceneBuilderilla seuraava käyttöliittymä:

Aseta komponenttien asetukset seuraavasti:

  • VBox-säiliö
    • Spacing: 10
    • Padding: 10 kaikkiin reunoihin
    • Pref Width: 400
    • Pref Height: 300
  • Kaikki Label-nimiöt
    • Min Width: 100
    • Muut Width ja Height -arvot: USE_COMPUTED_SIZE
  • HBox-säiliöt otsikkokentälle, prioriteettikentälle sekä painikkeille
    • Vgrow: NEVER
  • HBox-säiliö kuvauskentälle
    • Vgrow: ALWAYS
  • HBox-säiliö painikkeille
    • Alignment: TOP_RIGHT
    • Spacing: 10
  • TextField, ComboBox ja TextArea -kentät:
    • Hgrow: ALWAYS
    • Kaikki Width ja Height -arvot: USE_COMPUTED_SIZE

Anna myös kentille ja painikkeille sopivat fx:id-tunnisteet:

  • Otsikon TextField: otsikkoKentta
  • Prioriteetin ComboBox: prioriteettiCombo
  • Kuvauksen TextArea: kuvausKentta
  • Tallennuspainikkeen Button: tallennaPainike
  • Peruutuspainikkeen Button: peruutaPainike

Tallenna FXML-tiedosto.

Voit myös kopioida valmiin FXML-tiedoston täältä
<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.ComboBox?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.TextArea?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.VBox?>

<VBox maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="300.0" prefWidth="400.0" spacing="10.0" xmlns="http://javafx.com/javafx/25" xmlns:fx="http://javafx.com/fxml/1">
   <children>
      <HBox VBox.vgrow="NEVER">
         <children>
            <Label minWidth="100.0" text="Otsikko" />
            <TextField fx:id="otsikkoKentta" HBox.hgrow="ALWAYS" />
         </children>
      </HBox>
      <HBox>
         <children>
            <Label minWidth="100.0" text="Prioriteetti" />
            <ComboBox fx:id="prioriteettiCombo" HBox.hgrow="ALWAYS" />
         </children>
      </HBox>
      <HBox VBox.vgrow="ALWAYS">
         <children>
            <Label minWidth="100.0" text="Kuvaus" />
            <TextArea fx:id="kuvausKentta" prefHeight="200.0" prefWidth="200.0" HBox.hgrow="ALWAYS" />
         </children>
      </HBox>
      <HBox alignment="TOP_RIGHT" spacing="10.0" VBox.vgrow="NEVER">
         <children>
            <Button fx:id="tallennaPainike" mnemonicParsing="false" text="Tallenna" />
            <Button fx:id="peruutaPainike" mnemonicParsing="false" text="Peruuta" />
         </children>
      </HBox>
   </children>
   <padding>
      <Insets bottom="10.0" left="10.0" right="10.0" top="10.0" />
   </padding>
</VBox>

Kontrolleriluokan luominen

Kun näkymä on valmis, tarvitaan myös kontrolleriluokka. Luodaan uusi TehtavaEditController-luokka controller-alipakkaukseen. Muistetaan, että luokan tulee toteuttaa Initializable-rajapinta. Lisätään myös attribuutit näkymän komponenteille, joille annettiin fx:id-tunniste.

package fi.jyu.ohj2.nimi.todo.controller;

import fi.jyu.ohj2.nimi.todo.model.Prioriteetti;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.Button;
import javafx.scene.control.ComboBox;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;

import java.net.URL;
import java.util.ResourceBundle;
// import-määreet piilotettu...

public class TehtavaEditController implements Initializable {
    @FXML
    private TextField otsikkoKentta;

    @FXML
    private ComboBox<Prioriteetti> prioriteettiCombo;

    @FXML
    private TextArea kuvausKentta;

    @FXML
    private Button tallennaPainike;

    @FXML
    private Button peruutaPainike;

    @Override
    public void initialize(URL location, ResourceBundle resources) {
    }
}

Muutama sana uusista komponenteista. TextArea-komponentti on monirivinen syötekenttä; se toimii pitkälti kuten TextField, mutta sallii rivinvaihtoja. ComboBox<T> on komponentti, joka esittää ObservableList<T>-listassa olevia alkioita alasvetovalikkona.

Vaikka kontrolleri on luotu, emme vielä kertoneet JavaFX:lle, että FXML-näkymä ja kontrolleriluokka liittyvät toisiinsa. Palataan takaisin SceneBuilderiin ja klikataan oikean puolen Document-näkymästä alhaalla oleva Controller-paneeli auki:

Paneelissa oleva Controller class -asetus määrittää, mikä kontrolleriluokka tulee ladata aina näkymän yhteydessä. Aseta asetuksen arvoksi fi.jyu.ohj2.nimi.todo.controller.TehtavaEditController, jossa fi.jyu.ohj2.nimi.todo on projektisi pääpaketti ja tunniste (korjaa oikeaksi!) ja controller.TehtavaEditController viittaa controller-alipaketissa olevaan TehtavaEditController-luokkaan.

Tallenna FXML-tiedosto. Nyt aina, kun muokkausnäkymä näytetään, JavaFX lataa näkymää vastaavan TehtavaEditController-kontrollerin.

Dialogin avaaminen päänäkymästä

Haluaisimme nyt avata dialogin aina, kun taulukossa oleva tehtävä klikataan kahdesti. Valitettavasti TableView ei tarjoa suoraan mitään tapahtumaa yksittäisen rivin klikkaamiselle. Sen sijaan rivin klikkaus aiheuttaa tapahtuman yksittäistä riviä mallintavalla TableRow-oliolle.

Rivien muokkaamista varten TableView tarjoaa setRowFactory()-metodin (JavaDoc). Metodille annetaan lambdalauseke, joka kutsutaan aina, kun tauluun lisätään uusi rivi. Lambdalausekkeen tulee luoda ja palauttaa TableRow-olio uudelle lisättävälle riville. Tämän kautta voimme samalla lisätä uusille riveille onMouseClicked-tapahtumakäsittelijän, joka laukeaa aina, kun riviä klikataan.

Lisäämme setRowFactory()-metodin MainController-luokan initialize()-metodiin samaan kohtaan, jossa taulukon sarakkeet alustetaan:

public void initialize(URL url, ResourceBundle resourceBundle) {
    // metodin alkuosa piilotettu...
    SortedList<Tehtava> tehtavatLajiteltu = tehtavakokoelma.getTehtavat().sorted(Comparator.comparing(Tehtava::getTehty));
    tehtavaTaulu.setItems(tehtavatLajiteltu);
    tehtavaTaulu.setEditable(true);

    TableColumn<Tehtava, Boolean> tehtySarake = new TableColumn<>("Tehty");
    tehtySarake.setCellValueFactory(cd -> cd.getValue().tehtyProperty());
    tehtySarake.setCellFactory(CheckBoxTableCell.forTableColumn(tehtySarake));
    tehtavaTaulu.getColumns().add(tehtySarake);

    TableColumn<Tehtava, String> tekstiSarake = new TableColumn<>("Tehtävä");
    tekstiSarake.setCellValueFactory(cd -> cd.getValue().otsikkoProperty());
    tehtavaTaulu.getColumns().add(tekstiSarake);

    // HIGHLIGHT_GREEN_BEGIN
    // Asetetaan taulukolle rakentaja, jolla uudet rivit luodaan
    tehtavaTaulu.setRowFactory(tv -> {
        // Luodaan TableRow-olio riville
        TableRow<Tehtava> row = new TableRow<>();

        // Lisätään uudelle riville tapahtumakäsittelijä klikkauksille
        row.setOnMouseClicked(event -> {
            // Jos oli hiiren ykkösnapin tuplaklikkaus, 
            // eikä tyhjän rivialueen klikkaus, niin käsitellään tapahtuma
            if (event.getButton().equals(MouseButton.PRIMARY) &&
                event.getClickCount() == 2 && !row.isEmpty()) {
                // Haetaan riviä vastaava Tehtava-olio
                Tehtava tehtava = row.getItem();
                // Avataan muokkausdialogi
                avaaTehtavanMuokkaus(tehtava);
            }
        });

        return row;
    });
    // HIGHLIGHT_GREEN_END

    tehtavakokoelma.lataa();
    // metodin loppuosa piilotettu...
    uusiTehtavaNimi.setOnAction(event -> lisaaTehtava());
    lisaaUusiTehtavaPainike.setOnAction(event -> lisaaTehtava());
    poistaValittuPainike.setOnAction(event -> poistaValittu());
}

Lisäämme vastaavasti avaaTehtavanMuokkaus()-metodin, joka avaa dialogin:

private void avaaTehtavanMuokkaus(Tehtava tehtava) {
    try {
    /* 1 */ FXMLLoader loader = new FXMLLoader(App.class.getResource("tehtava-edit.fxml"));
    /* 1 */ Parent root = loader.load();
    /* 1 */ Scene scene = new Scene(root);

    /* 2 */ Stage dialogi = new Stage();
    /* 2 */ dialogi.setScene(scene);

    /* 3 */ dialogi.setTitle("Tehtävän muokkaus: " + tehtava.getOtsikko());
    /* 3 */ dialogi.setMinWidth(400);
    /* 3 */ dialogi.setMinHeight(300);
    /* 3 */ dialogi.initModality(Modality.APPLICATION_MODAL);

    /* 4 */ dialogi.showAndWait();
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

Huomaa, että avaaTehtavanMuokkaus()-metodin logiikka on sama kuin osassa 7.1 käsitelty App-luokassa oleva sovelluksen käynnistyskoodi muutamalla erolla:

  1. Näkymän alustaminen: lataamme näkymän FXML-tiedostosta käyttäen FXMLLoader-apuluokkaa. Tämän jälkeen alustamme varsinaisen Scene-näkymäolion, jolle annamme parametrina ladatun näkymän pääkomponentin.

  2. Näkymän asettaminen ikkunaan: asetamme näkymän aktiiviseksi scene.setScene()-metodilla. Nyt Stage-ikkunaolio ei tule JavaFX:stä suoraan, vaan alustamme sen itse. Tämä käytännössä luo uuden ikkunan.

  3. Ikkunan asetusten muuttaminen: asetamme ikkunan minimikoon ja otsikon. Lisäksi teemme ikkunasta modaalisen initModality()-metodilla (ks. JavaDoc). Ikkunan muuttaminen modaaliseksi tarkoittaa, että pääikkunassa olevia komponentteja ei voi klikata kunnes modaalinen ikkuna on sulkeutunut. Tämä on hyvä ratkaisu dialogeille, jotka vaativat käyttäjän huomiota.

  4. Ikkunan näyttäminen: lopuksi näytämme ikkunan. Käytämme tässä showAndWait()-metodia. Metodi toimii kuten show(), mutta metodin suoritus päättyy vasta, kun avattu dialogi sulkeutuu.

Kokeile nyt ajaa sovellus. Nyt rivien klikkaaminen kahdesti avaa dialogin.

Valitun tehtävän välittäminen dialogin kontrolleriluokalle

Emme vielä pysty näyttämään tehtävän tietoja dialogissa, koska dialogilla ei ole mitään tietoa valitusta tehtävästä. Meidän tulee siis välittää valitun Tehtava-olion muokkausdialogin kontrollerille, jotta tehtävän tiedot voidaan näyttää.

Aivan alkuun, lisätään TehtavaEditController-luokkaan uusi attribuutti, johon tallennetaan dialogissa näytettävän tehtävän tiedot:

public class TehtavaEditController implements Initializable {
    // HIGHLIGHT_GREEN_BEGIN
    private Tehtava muokattavaTehtava;
    // HIGHLIGHT_GREEN_END

Kapseloinnin takia attribuutti on private. Jotta voimme välittää tehtäväolion pääikkunasta dialogille, tehdään julkinen setTehtava()-metodi TehtavaEditController-luokkaan. Metodi päivittää attribuutin ja samalla asettaa tehtävän attribuutit dialogin kenttiin:

public void setTehtava(Tehtava tehtava) {
    this.muokattavaTehtava = tehtava;
    otsikkoKentta.setText(tehtava.getOtsikko());
    prioriteettiCombo.setValue(tehtava.getPrioriteetti());
    kuvausKentta.setText(tehtava.getKuvaus());
}

huomautus

Tässäkin voisimme käyttää datan sidontaa ja sitoa kenttien arvojen ominaisuudet tehtäväolion ominaisuuksiin seuraavasti:

// Sitoo otsikkokentän tekstin tehtävän otsikko-attribuuttiin
otsikkoKentta.textProperty().bindBidirectional(tehtava.otsikkoProperty());

Jos tekisimme näin, jokainen näppäimen painallus muuttaisi tehtävän otsikon välittömästi taustalla. Sen sijaan haluamme tässä antaa käyttäjälle mahdollisuuden peruuttaa muutokset Peruuta-painikkeen painalluksella. Siksi datan sidonnan sijaan kopioimme arvot tehtävästä kenttiin ja kentistä tehtäviin käsin.

Palataan MainController-luokan avaaTehtavanMuokkaus()-metodiin. Nyt ennen ikkunan luomista voimme hakea TehtavaEditController-luokasta luotu ilmentymä ja välittää sille muokattava tehtävä. Näkymälle luotu kontrolleriluokan olio saamme FXMLLoader-olion getController()-metodilla (ks. JavaDoc).

private void avaaTehtavanMuokkaus(Tehtava tehtava) {
    // metodin alkuosa piilotettu...
    try {
        FXMLLoader loader = new FXMLLoader(App.class.getResource("tehtava-edit.fxml"));
        Parent root = loader.load();
        Scene scene = new Scene(root);

        // HIGHLIGHT_GREEN_BEGIN
        // Haetaan näkymälle luotu kontrolleriolio
        TehtavaEditController controller = loader.getController();
        // Välitetään kontrollerille muokattava tehtävä
        controller.setTehtava(tehtava);
        // HIGHLIGHT_GREEN_END

        Stage dialogi = new Stage();
    // metodin loppuosa piilotettu...
        dialogi.setScene(scene);
        dialogi.setTitle("Tehtävän muokkaus: " + tehtava.getOtsikko());
        dialogi.setMinWidth(400);
        dialogi.setMinHeight(300);
        dialogi.initModality(Modality.APPLICATION_MODAL);

        dialogi.showAndWait();
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

Kokeile ajaa sovellus. Nyt tehtävän klikkaaminen kahdesti avaa dialogin, ja dialogin kentät täyttyvät tehtävän tiedoista:

Huomaamme tässä vaiheessa pienen bugin: prioriteetti-alasvetolaatikossa ei vielä näy kaikkia prioriteettivaihtoehtoja. Korjaamme tämän asettamalla ComboBox-komponenttiin näytettävät Prioriteetti-arvot käyttäen setItems()-metodia samankaltaisesti kuin aiemmin osassa esitellyssä ListView-komponentissa. Lisää TehtavaEditController-luokan initialize()-metodiin seuraava rivi:

public void initialize(URL location, ResourceBundle resources) {
    prioriteettiCombo.setItems(FXCollections.observableArrayList(Prioriteetti.values()));
}

Tässä Prioriteetti.values() palauttaa taulukon kaikista mahdollisista Prioriteetti-luetelman arvoista (eli MATALA, KESKI ja KORKEA). Nämä kääritään ObservableList-olioon ja asetetaan näytettäväksi ComboBox-komponentissa. Nyt alasvetovalikossa kaikki mahdolliset vaihtoehdot ovat valittavissa:

Dialogin logiikan toteuttaminen

Toteutetaan lopuksi varsinainen logiikka dialogiin TehtavaEditController-luokassa. Muokkausdialogissa on kaksi painiketta:

  • Tallenna-painike tallentaa tehtävän muutokset Tehtava-olioon ja sulkee dialogin. Tässä meidän tulee varmistaa, että emme salli tehtävän tallentamista ilman otsikkoa. Tehtävän kuvaus kuitenkin on tässä tapauksessa valinnainen kenttä.
  • Peruuta-painike sulkee dialogin siirtämättä tietoja oliolle.

Lisätään alkuun TehtavaEditController-luokkaan apumetodi sulje(), joka sulkee dialogin sekä tapahtumakäsittelijät Tallenna- ja Peruuta-painikkeille:

public void initialize(URL location, ResourceBundle resources) {
    prioriteettiCombo.setItems(FXCollections.observableArrayList(Prioriteetti.values()));
    // metodin alkuosa piilotettu...

    tallennaPainike.setOnAction(event -> {
        muokattavaTehtava.setOtsikko(otsikkoKentta.getText());
        muokattavaTehtava.setPrioriteetti(prioriteettiCombo.getValue());
        muokattavaTehtava.setKuvaus(kuvausKentta.getText());
        sulje();
    });
    peruutaPainike.setOnAction(event -> sulje());
}

private void sulje() {
    // Kikka: haetaan Scene-olio jostain komponentista
    Scene scene = otsikkoKentta.getScene();
    // Scene-olion getWindow()-metodi palauttaa tämänhetkisen ikkunan
    // Tiedämme, että ikkuna on nyt tyyppiä Stage, joten tehdään tyyppimuunnos
    Stage ikkuna = (Stage) scene.getWindow();
    ikkuna.close();
}

Muistetaan, että tehtävää ei saa tallentaa, jos otsikkokenttä on tyhjä. Toteutetaan tämä tarkistus käyttäjäystävällisenä validointina: jos otsikkokenttä on tyhjä, muutamme kentän reunuksen väriä ja lisäämme kenttään selkeän varoitustekstin. Tehdään tätä varten apumetodi validoi(), joka tarkistaa otsikkokentän oikeellisuuden ja palauttaa boolean-arvona, onko kaikki kentät oikein (true) tai väärin (false). Lisäksi, jos otsikkokenttä ei sisällä mitään arvoa, väritetään kentän reunus punaisella ja lisätään syötekenttään virheteksti.

private boolean validoi() {
    // Nollataan mahdolliset aiemmat virhetyylit ja vihjetekstit
    otsikkoKentta.setStyle("");
    otsikkoKentta.setPromptText("");

    String otsikko = otsikkoKentta.getText();
    if (otsikko == null || otsikko.isBlank()) {
        // Vaihdetaan reunus punaiseksi virheen merkiksi
        otsikkoKentta.setStyle(
                            "-fx-border-color: red; " +
                            "-fx-background-color: #fdf2f2;");
        // Lisätään kenttään vihjeteksti, joka sisältää virheen
        otsikkoKentta.clear();
        otsikkoKentta.setPromptText("Otsikko puuttuu!");
        // Palautetaan false sen merkiksi, että validointi epäonnistui
        return false;
    }

    return true;
}

Tällöin voimme kutsua validoi()-metodin suoraan tallennuspainikkeen tapahtumakäsittelijässä:

public void initialize(URL location, ResourceBundle resources) {
    // metodin alkuosa piilotettu...
    prioriteettiCombo.setItems(FXCollections.observableArrayList(Prioriteetti.values()));

    tallennaPainike.setOnAction(event -> {
        // HIGHLIGHT_GREEN_BEGIN
        if (!validoi()) {
            return;
        }
        // HIGHLIGHT_GREEN_END

        muokattavaTehtava.setOtsikko(otsikkoKentta.getText());
        muokattavaTehtava.setPrioriteetti(prioriteettiCombo.getValue());
        muokattavaTehtava.setKuvaus(kuvausKentta.getText());
        sulje();
    });
    // metodin loppuosa piilotettu...
    peruutaPainike.setOnAction(event -> sulje());
}

Kokeile nyt sovellusta taas. Nyt Tallenna- ja Peruuta-painikkeet toimivat. Lisäksi otsikkokentän jättäminen tyhjäksi näyttää virheen käyttäjälle.

Tehtävien tallentaminen tietojen muutoksesta

Huomaamme, että tietojen muokkaaminen dialogista muokkaa taulukossa näkyvät tiedot, mutta ei vielä tallenna tietoja tiedostoon. Tämä johtuu siitä, että tallennus tapahtuu nyt ainoastaan, jos Tehtavakokoelma-luokan tehtavat-lista muuttuu. Puolestaan lista muuttuu nyt vain, jos listaan lisätään tehtävä, listasta poistetaan tehtäviä, tai jos tehty-ominaisuus muuttuu, kuten ekstraktorissa on mainittu:

private final ObservableList<Tehtava> tehtavat = 
    FXCollections.observableArrayList(
        tehtava -> new Observable[]{tehtava.tehtyProperty()});

Tallentaminen dialogin jälkeen voitaisiin hoitaa eri tavoin. Pidetään kuitenkin tässä vaiheessa ratkaisu suoraviivaisena ja lisäämme Tehtava-luokan kaikki ominaisuudet ekstraktoriin:

private final ObservableList<Tehtava> tehtavat = FXCollections.observableArrayList(
        tehtava -> new Observable[] {
                tehtava.tehtyProperty(),
                tehtava.otsikkoProperty(),
                tehtava.kuvausProperty(),
                tehtava.prioriteettiProperty()
        }
);

Tällöin tehtava-lista ilmoittaa kaikista tehtävien ominaisuuksiin tehdyistä muutoksista. Toisin sanoen aina, kun jokin tehtävän ominaisuus muuttuu, Tehtavakokoelma-luokassa määritelty havaitsija tallentaa kaikki tehtävät.

Valinnaista lisätietoa: Tallentaminen pienellä viiveellä

Yllä oleva ratkaisu ei ole ideaalinen: nyt jokainen tehtävän set-metodin kutsuminen aiheuttaa kaikkien tehtävien tallentumista.

Eräs tapa ratkaista tämä käyttäen JavaFXää on lisätä ns. rajoittaja- eli debounce-olio. Rajoittajaolio estää saman koodin suorittamista tietyn aikavälin sisällä. Esimerkiksi, voimme rajoittaa tallenna()-funktion kutsua niin, että funktio suoritetaan vain yhden kerran 500 millisekunnissa. JavaFX tarjoaa tätä varten PauseTransition-apuluokan, jota voidaan käyttää seuraavasti:

private final PauseTransition tallennaDebounce = new PauseTransition(Duration.millis(500));

public Tehtavakokoelma() {
    // Määritellään koodi, jonka suoritusta rajoitetaan
    tallennaDebounce.setOnFinished(event -> tallenna());
    
    tehtavat.addListener((ListChangeListener<Tehtava>) change -> {
        // Suoritetaan aikarajoitettu koodi 500 millisekunnin päästä
        // Jos koodia kutsutaan uudestaan 500 millisekunnin sisällä, aloitetaan uusi aikalaskenta
        tallennaDebounce.playFromStart();
    });
}

Tämän muutoksen myötä monta peräkkäistä set-metodin kutsua aiheuttaa vain yhden tallenna()-metodin kutsua.

Tehtävä 8.4: Todo-sovellus, vaihe 10. 1 p.

Palauta osan 8.4 perusteella edistetty projekti. Kertaus tämän osan vaiheista:

  • Avaa tehtävän muokkausnäkymä, kun käyttäjä tuplaklikkaa tehtävää.
  • Lisää tehtävälle vähintään kuvaus ja prioriteetti.
  • Lisää syötteille validointi (esim. tehtävän otsikko ei saa olla tyhjä).
  • Tallenna muokkaukset takaisin tehtävään ja päivitä näkymä.

Kun vaihe on valmis, tee git add muuttuneille tiedostoille ja git commit. Palauta projektisi tiedostot.

Tee tehtävä TIMissä

Yksikkötestaus

Yksikkötestauksen perusidea on yksinkertainen: testataan ohjelman pieniä osia, kuten yksittäisiä luokkia tai metodeja, erillään muusta järjestelmästä. Tavoite on varmistaa, että kukin osa toimii oikein omalla vastuullaan. Kun testin ajatus pidetään pienenä ja tarkkarajaisena, virheiden paikantaminen on paljon helpompaa kuin tilanteessa, jossa koko ohjelmaa yritetään testata kerralla.

Yksikkötestit ovat hyödyllisiä, koska ne paljastavat virheitä nopeasti jo kehitysvaiheessa. Toiseksi ne toimivat eräänlaisena turvaverkkona: jos muutamme koodia myöhemmin, ajamalla yksikkötestit näemme nopeasti rikkoutuiko jokin aiemmin toiminut ominaisuus. Kolmanneksi ne pakottavat ohjelmoijan miettimään, millainen luokan rajapinta on ja mitä sen oikeastaan pitäisi tehdä.

Ajatellaan esimerkiksi Tehtavakokoelma-luokkaa. Voimme kirjoittaa testin, joka lisää kokoelmaan yhden tehtävän ja tarkistaa, että listassa on nyt yksi alkio. Voimme kirjoittaa toisen testin, joka yrittää lisätä tyhjän otsikon ja tarkistaa, ettei tehtävää lisätä lainkaan. Kolmas testi voisi poistaa valitun tehtävän ja varmistaa, että listan koko pienenee oikein. Jokaisessa näistä testeistä tarkistetaan yksi selkeä käyttäytyminen.

JUnit

Java-maailmassa yksikkötestejä tehdään usein JUnit-kirjastolla. JUnit antaa valmiit työkalut testimetodien kirjoittamiseen sekä odotettujen tulosten tarkistamiseen.

Kirjoitushetkellä ajantasainen JUnit-versio on 6.0.3, joka tunnetaan myös nimellä JUnit Jupiter. Lisätään junit-jupiter-riippuvuus pom.xml-tiedostoon.

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>6.0.3</version>
    <scope>test</scope>
</dependency>

Kokeillaan tehdä yksinkertainen testi käyttäen JUnitia. Tehdään uusi Maven-projekti. Lisätään pääluokkaamme aliohjelma Keskiarvo, joka laskee keskiarvon kuitenkin niin, jos listassa on lopetusluku tai sitä suurempi luku, kaikki sen jälkeen olevat luvut jätetään huomioimatta.

public static double keskiarvo(List<Integer> luvut, int lopetusluku) {
    if (luvut.isEmpty()) {
        throw new IllegalArgumentException("Lista ei saa olla tyhjä");
    }
    int summa = 0;
    int lukujenMaara = 0;
    for (int luku : luvut) {
        if (luku >= lopetusluku) {
            break;
        }
        summa += luku;
        lukujenMaara++;
    }
    return (double) summa / lukujenMaara;
}

Tehdään sitten tälle metodille yksikkötesti. Testit kirjoitetaan tavallisesti hakemistoon src/test/java. Kun IDEAssa klikkaa src-kansion päältä New directory, Maven-projektihallinta osaa automaattisesti tarjota src/test/java-hakemiston luomista. Tee test/java-kansio ja sinne uusi luokka KeskiarvoTest. Kirjoita seuraavat kaksi testimetodia. Jos kopioit alla olevan koodin, muuta jälleen ensimmäinen import-lause vastaamaan oman pääluokkasi nimeä.

import fi.jyu.ohj2.Main;
import org.junit.jupiter.api.Test;

import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

public class KeskiarvoTest {

    @Test
    void keskiarvoLaskeeOikein() {
        List<Integer> luvut = List.of(1, 2, 3, 4, 5);
        double tulos = Main.keskiarvo(luvut, 10);
        assertEquals(3.0, tulos, "Keskiarvon pitäisi olla 3.0");
    }

    @Test
    void keskiarvoLopettaaOikein() {
        List<Integer> luvut = List.of(1, 2, 3, 10, 4, 5);
        double tulos = Main.keskiarvo(luvut, 10);
        assertEquals(2.0, tulos, "Keskiarvon pitäisi olla 2.0, koska 10 ja sen jälkeen olevat luvut jätetään huomioimatta");
    }   
}

Testit voi ajaa esimerkiksi public class KeskiarvoTest-luokan vasemmalla puolella olevasta vihreästä nuolesta. JUnit ajaa testit ja näyttää tulokset IDE:n testinäkymässä. Jos kaikki testit menevät läpi, näet vihreän merkin. Jos jokin testi epäonnistuu, näet punaisen merkin ja virheilmoituksen, joka kertoo mikä meni pieleen.

Meiltä puuttuu yksi tärkeä testi: mitä tapahtuu, jos yhtään validia lukua ei ole? Sovitaan tässä, että aliohjelmamme tulisi heittää IllegalArgumentException- poikkeus. Kirjoitetaan vielä testi, joka varmistaa, että tämä tapahtuu.

Poikkeuksen odottaminen JUnitissa onnistuu assertThrows-metodilla. Se ottaa parametrina odotetun poikkeusluokan ja lambda-lausekkeen, joka sisältää testattavan koodin.

@Test
void keskiarvoHeittaaPoikkeuksenTyhjallaListalla() {
    List<Integer> luvut = List.of(10, 20, 30, 40, 50, 60);
    IllegalArgumentException exception =
            assertThrows(IllegalArgumentException.class, () -> {
                Main.keskiarvo(luvut, 10);
            });
    assertEquals("Yhtään lukua ei tullut mukaan keskiarvoon", exception.getMessage());
}

Nyt testitulos näyttää punaista:

org.opentest4j.AssertionFailedError: Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.

Nollalla jakaminen ei double-luvuilla laskettaessa heitä poikkeusta, vaan palauttaa NaN-arvon. Korjataan tämä aliohjelmassamme.

public static double keskiarvo(List<Integer> luvut, int lopetusluku) {
    // ... aiempi koodi ennallaan ...
    // HIGHLIGHT_GREEN_BEGIN
    if (lukujenMaara == 0) {
        throw new IllegalArgumentException("Yhtään lukua ei tullut mukaan keskiarvoon");
    }
    // HIGHLIGHT_GREEN_END
    return (double) summa / lukujenMaara;
}

Nyt kaikki testit menevät läpi!

Tehtävä 8.5: Testaus. 1 p.

Ota tehtävän 5.10 vastauksesi (tai mallivastaus), ja kirjoita sille yksikkötestit. Testaa aliohjelman toimivuutta ainakin viidellä eri syötteellä. Käytä tehtävässä annettuja esimerkkejä tai keksi itse uusia testitapauksia.

Tee tehtävä TIMissä

Todo-ohjelman testaaminen

Kun haluamme testata todo-sovellusta, kaikkea ei tarvitse lähestyä käyttöliittymän kautta. Olennaista on testata sovelluksen bisneslogiikkaa eli sitä, miten Tehtavakokoelma käyttäytyy eri tilanteissa. Tällöin emme klikkaile nappeja tai avaa ikkunoita, vaan kutsumme suoraan malliluokan metodeja ja tarkistamme, että lopputulos vastaa odotuksia.

Tällaisia testattavia asioita ovat esimerkiksi seuraavat:

  • lisaaTehtava("Käy kaupassa") lisää tehtävän listaan
  • poistaTehtava(tehtava) poistaa annetun tehtävän listasta
  • lisaaTehtava(" ") ei lisää tyhjää tehtävää lainkaan

Tämän vuoksi voisimme kirjoittaa src/test/java-hakemistoon esimerkiksi seuraavan JUnit-testiluokan:

import fi.jyu.ohj2.nimi.todo.model.Tehtava;
import fi.jyu.ohj2.nimi.todo.model.Tehtavakokoelma;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class TehtavakokoelmaTest {

    @Test
    void lisaaTehtava_lisaaTehtavanListaan() {
        Tehtavakokoelma kokoelma = new Tehtavakokoelma("testitehtavat.json");
        kokoelma.lisaaTehtava("Käy kaupassa");
        assertEquals(1, kokoelma.getTehtavat().size());
        assertEquals("Käy kaupassa", kokoelma.getTehtavat().get(0).getOtsikko());
    }

    @Test
    void poistaTehtava_poistaaTehtavanListasta() {
        Tehtavakokoelma kokoelma = new Tehtavakokoelma("testitehtavat.json");
        kokoelma.lisaaTehtava("Käy kaupassa");
        Tehtava tehtava = kokoelma.getTehtavat().get(0);
        kokoelma.poistaTehtava(tehtava);
        assertEquals(0, kokoelma.getTehtavat().size());
    }

    @Test
    void lisaaTehtava_eiLisaaTyhjaaTehtavaa() {
        Tehtavakokoelma kokoelma = new Tehtavakokoelma("testitehtavat.json");
        kokoelma.lisaaTehtava("   ");
        assertEquals(0, kokoelma.getTehtavat().size());
    }
}

Lisäsimme tässä Tehtavakokoelma-luokalle myös uuden konstruktorin, joka ottaa parametrina tallennustiedoston nimen. Näin testit voivat käyttää erillistä testitiedostoa, eikä oikea data sekoitu testien kanssa. Lisää tämä konstruktori Tehtavakokoelma-luokkaan:

public Tehtavakokoelma(String polku)
{
    tiedostoPolku = Path.of(polku);
    tehtavat.addListener((ListChangeListener<Tehtava>) change -> {
        tallenna();
    });
}

Ajatus testeissä on hyvin suoraviivainen: ensin valmistellaan testin lähtötilanne, sitten kutsutaan testattavaa metodia ja lopuksi tarkistetaan, että kokoelman tila muuttui oikein. Tämä on juuri sellaista bisneslogiikan testausta, jota MVC:n mukainen rakenne meille mahdollistaa.

Lisää .gitignore-tiedostoon rivi testitehtavat.json, jotta testitiedosto ei päädy versiohallintaan. Jos ehdit jo lisäämään sen versiohallintaan, poista se sieltä komennolla git rm --cached testitehtavat.json ja tee uusi commit.

Tehtävä 8.6: bisneslogiikan testaaminen.1 p.

Kirjoita Tehtavakokoelma-luokalle yksikkötestejä. Testaa ainakin seuraavat asiat:

  • Kun tehtävä lisätään otsikolla, jonka alussa ja lopussa on välilyöntejä, tyhjät poistetaan ennen tallentamista listaan.
  • Kun kokoelmaan lisätään kaksi tehtävää, joilla on sama otsikko, molemmat oikeasti päätyvät listaan.
  • Edelliseen jatkoa: Kun toinen noista tehtävistä merkitään tehdyksi, vain kyseinen tehtävä merkitään tehdyksi, ei toista.
  • Kun kokoelmaan lisätään kaksi eri tehtävää peräkkäin, molemmat päätyvät listaan oikeassa järjestyksessä.

Palauta TehtavakokoelmaTest-luokka.

Tee tehtävä TIMissä

Yksikkötestaus ja MVC-arkkitehtuuri

Nyt kun olemme siirtyneet MVC-arkkitehtuuriin ja luoneet Tehtavakokoelma-luokan, olemme erottaneet käyttöliittymän kokonaan irti sovelluksen logiikasta ja datasta. Tällä on ratkaiseva ohjelmistotuotannollinen hyöty. Jos yrittäisimme testata koodia käyttöliittymän kautta esimerkiksi simuloimalla napin painalluksia, testaus olisi hidasta, altista satunnaisille virheille ja vaatisi raskaiden JavaFX-kirjastojen käynnistämisen. Koska kokoelmalla on nyt selkeä ohjelmointirajapinta (metodit lisaaTehtava, poistaTehtava jne.), voimme rakentaa yksikkötestejä, jotka kutsuvat suoraan kokoelmaa ja tarkistavat asioiden toimiutuvuuden millisekunneissa ilman ruudulle aukeavia ikkunoita.

Valitettavasti tähän liittyy vielä yksi käytännön ongelma: nykyinen Tehtavakokoelma tekee myös tiedosto-operaatioita. Siksi aivan näin suoraviivainen testaus ei vielä ole täysin ongelmatonta.

IO on ongelmallista testauksessa

Mietitäänpä tilannetta, jossa lähtisimme testaamaan uutta hienoa Tehtavakokoelma-luokkaamme. Mitä tapahtuu, jos testi tekee kokoelmaan kymmenen uutta tehtävää ja testaa, että lukumäärä täsmää? Koska laitoimme observerin varoittamaan muutoksista ja kutsumaan kokoelman tallenna()-metodia, ohjelma tallentaa nämä testitehtävät oikealle kovalevylle (esim. tehtavat.json-tiedostoon).

Oikealle kovalevylle kirjoittaminen on testeissä yleensä pahasta. Tyypillisessä tuotantosovelluksessa testejä saatetaan ajaa satoja peräjälkeen, ja levy-IO (input/output) tekee testeistä erittäin hitaita. Lisäksi, jos testit epäonnistuvat tai keskeytyvät kesken, ne voivat jättää levylle sotkuisen tilan, jossa on puoliksi kirjoitettuja tiedostoja tai vanhentunutta dataa.

Tallennuksen eriyttäminen abstraktion taakse: repository-suunnittelumalli

Ratkaisu on erottaa tallennus omaksi kokonaisuudekseen. Tällöin Tehtavakokoelma ei enää itse lue tai kirjoita tehtavat.json-tiedostoa, vaan delegoi tallennuksen erilliselle luokalle. Tämä helpottaa yksikkötestausta, koska testit eivät ole suoraan sidoksissa oikeaan tiedostoon tai tiedostojärjestelmään. Tällaista ratkaisua toteutetaan usein niin kutsutulla repository-suunnittelumallilla.

Repository-suunnittelumalli tarkoittaa sitä, että datan tallennus ja lataus piilotetaan oman rajapinnan tai luokan taakse. Tällöin muu ohjelma ei käsittele suoraan tiedostoja, tietokantoja tai muita tallennusmekanismeja, vaan käyttää repository-rajapinnan (esim. TehtavaRepository) tarjoamia metodeja.

Katsotaan Todo-sovelluksessamme, miten tämä toteutaan käytännössä.

1. Luodaan rajapinta TehtavaRepository. Tyypillisesti lataamiseen ja tallentamiseen liittyvät metodit määritellään pakkaukseen persistence. Tehdään mekin niin.

package fi.jyu.ohj2.nimi.todo.persistence;

import fi.jyu.ohj2.nimi.todo.model.Tehtava;
import java.util.List;

public interface TehtavaRepository {
    List<Tehtava> lataa() throws RepositoryException;
    void tallenna(List<Tehtava> tehtavat) throws RepositoryException;
}

Tehdään samalla erillinen luokka latausvirhettä kuvaavalle RepositoryException, sillä nyt tehtäviä voidaan ladata muullakin tavalla kuin tiedostosta. Sijoitetaan tämäkin persistence-pakkaukseen.

package fi.jyu.ohj2.nimi.todo.persistence;

public class RepositoryException extends Exception {
    public RepositoryException(String message) {
        super(message);
    }
}

2. Irrotetaan JSON-tallennuskoodi mallista omaan toteutukseensa JsonTehtavaRepository edelleen persistence-pakkaukseen. Kopioidaan lataamiseen ja tallentamiseen liittyvät koodit Tehtavakokoelmasta uuteen luokkaan, joka toteuttaa TehtavaRepository-rajapinnan.

package fi.jyu.ohj2.nimi.todo.persistence;

import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.core.JacksonException;
import fi.jyu.ohj2.nimi.todo.model.Tehtava;

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;

public class JsonTehtavaRepository implements TehtavaRepository {
    private final Path tallennustiedosto;
    private final ObjectMapper mapper = new ObjectMapper();

    public JsonTehtavaRepository(Path tallennustiedosto) {
        this.tallennustiedosto = tallennustiedosto;
    }

    @Override
    public List<Tehtava> lataa() throws JacksonException {
        if (Files.notExists(tallennustiedosto)) {
            return List.of();
        }
        return mapper.readValue(tallennustiedosto.toFile(), new TypeReference<>() {});
    }

    @Override
    public void tallenna(List<Tehtava> tehtavat) throws JacksonException {
        mapper.writeValue(tallennustiedosto.toFile(), tehtavat);
    }
}

3. Päivitetään Tehtavakokoelma huolimaan mikä tahansa tallentaja. Muokataan konstruktoria niin, että sille annetaan jokin rajapinnan toteuttaja tiedostojumpan sijaan. Tällaista toimintamallia dependency injection -periaatteeksi. Dependency injection tarkoittaa sitä, että luokka ei itse luo riippuvuuksiaan, vaan ne annetaan sille ulkopuolelta. Erityisesti tässä tehtävässä Tehtavakokoelma-luokka ei enää luo JsonTehtavaRepository-oliota, vaan saa sen konstruktorin parametrina. Tämän hyöty tulee myöhemmin vielä paremmin esiin kun kirjoitamme konkreettisia testitapauksia.

public class Tehtavakokoelma {   
    // Riippuvuus tallennusmekanismista on nyt rajapinnan takana
    private final TehtavaRepository repository;

    // Konstruktoriin annetaan haluttu tallennusväline 
    // sisältäpäin luomisen sijaan (Dependency Injection)
    public Tehtavakokoelma(TehtavaRepository repository) {
        this.repository = repository;
        
        this.tehtavat.addListener((ListChangeListener<Tehtava>) change -> {
            tallenna();
        });
    }

    public void lataa() {
        try {
            List<Tehtava> kaikkiTehtavat = repository.lataa();
            tehtavat.addAll(kaikkiTehtavat);
        } catch (RepositoryException e) {
            IO.println(e.getMessage());
        }
    }

    public void tallenna() {
        try {
            repository.tallenna(tehtavat);
        } catch (RepositoryException e) {
            IO.println(e.getMessage());
        }
    }
    
    // ... kaikki muut lisaaTehtava yms. samat kuin aiemmin!
}

Vaihda myös MainController-luokassa tehtäväkokoelman alustus muotoon

private Tehtavakokoelma tehtavakokoelma = new Tehtavakokoelma(new JsonTehtavaRepository(Path.of("tehtavat.json")));

Repository tukee Todo-sovelluksemme tapauksessa MVC-mallin mukaista toteutusta, koska sen avulla mallikerroksen sisällä vastuut pidetään erillään. Tehtavakokoelma kuuluu malliin, mutta sen ei tarvitse tietää tallennuksen teknisistä yksityiskohdista. Se voi pyytää repositorya lataamaan tai tallentamaan tehtävät ja keskittyä itse sovelluslogiikkaan. Näin myös kontrolleri ja näkymä pysyvät erossa tiedostokäsittelystä.

Rajapinnan tehokkuus piilee siinä, että Tehtavakokoelma-luokan ei sen jälkeen enää tarvitse tietää, miten tai minne data tallennetaan (onko se JSON-tiedosto, tietokanta vai vain keskusmuistilista testausta varten).

Mock- ja Fake-luokat

Testauksessa käytetään usein niin sanottuja mock- tai fake-olioita, kun testattava luokka tekee yhteistyötä jonkin toisen olion kanssa. Ajatus on, että oikean yhteistyöolion tilalle annetaan testissä kevyt korvike, jonka toimintaa on helpompi hallita. Näin testi voidaan kohdistaa juuri siihen luokkaan, jota halutaan testata, ilman että mukana on turhaan tiedostoja, tietokantoja, verkkoa tai muuta raskasta ympäristöä.

Ajatellaan yksinkertaista esimerkkiä, jossa luokka Pakkasvahti kysyy lämpötilan toiselta oliolta, esimerkiksi Lampoanturi-rajapinnan kautta. Jos haluamme testata, toimiiko Pakkasvahti oikein, emme välttämättä halua käyttää oikeaa anturia, koska sellaista ei ehkä testissä ole olemassa tai sen palauttama arvo vaihtelee koko ajan. Sen sijaan voimme tehdä valeanturin, joka palauttaa aina esimerkiksi arvon 21.5. Tällöin testi on ennustettava: tiedämme tarkalleen, mitä arvoa Pakkasvahti saa ja mitä sen pitäisi tehdä sillä.

Esimerkiksi:

public interface Lampoanturi {
    double mittaaLampotila();
}

public class Pakkasvahti {
    private final Lampoanturi anturi;

    public Pakkasvahti(Lampoanturi anturi) {
        this.anturi = anturi;
    }

    public boolean onkoPakkasta() {
        return anturi.mittaaLampotila() < 0;
    }
}

Oikeassa ohjelmassa Lampoanturi voisi lukea arvon fyysiseltä laitteelta, mutta testissä voimme käyttää yksinkertaista valeoliota:

public class ValeLampoanturi implements Lampoanturi {
    @Override
    public double mittaaLampotila() {
        return 21.5;
    }
}

Tällöin Pakkasvahti-luokkaa testatessa tiedämme varmasti, että anturi palauttaa aina saman arvon.

Tällaiset korvikeoliot ovat hyödyllisiä erityisesti silloin, kun oikea riippuvuus on hidas, vaikeasti hallittava tai aiheuttaa sivuvaikutuksia. Seuraavaksi hyödynnämme samaa ajatusta todo-sovelluksessa tekemällä vale-säiliön, joka teeskentelee tallentavansa dataa, mutta pitääkin sen vain muistissa testin ajan.

Testaaminen mock-säilöllä

Testiympäristössä (eli src/test/java...-kansiossa) voimme nyt luoda mock-luokan, joka teeskentelee tallentavansa tietoja tiedostoon, mutta todellisuudessa tallentaakin ne vain normaaliin Java-listaan laitteen välimuistiin.

public class MockTehtavaRepository implements TehtavaRepository {

    // Keskusmuistissa oleva data "tiedoston" sijaan testejä varten
    private List<Tehtava> tallennetutTehtavat = new ArrayList<>();

    @Override
    public List<Tehtava> lataa() {
        return tallennetutTehtavat; 
    }

    @Override
    public void tallenna(List<Tehtava> tehtavat) {
        tallennetutTehtavat.clear();
        // Teemme jokaisesta tehtävästä kopion
        // Näin voimme testata, että tallennettu data täsmää myös kokoelmassa olevan datan kanssa
        for (Tehtava tehtava : tehtavat) {
            Tehtava kopio = new Tehtava();
            kopio.setOtsikko(tehtava.getOtsikko());
            kopio.setPrioriteetti(tehtava.getPrioriteetti());
            kopio.setKuvaus(tehtava.getKuvaus());
            kopio.setTehty(tehtava.getTehty());
            tallennetutTehtavat.add(kopio);
        }
    }
    
    public List<Tehtava> getTallennetutTehtavat() {
        return this.tallennetutTehtavat;
    }
}

Nyt voimme turvallisin mielin testata mallia JUnit-testeillä:

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class TehtavakokoelmaTest {

    @Test
    void lisaaTehtava_lisaaTehtavanJaTallentaaSen() {
        // 1. Arrange: Valmistellaan testidata. SYÖTETÄÄN VALE-säiliö!
        MockTehtavaRepository mockRepo = new MockTehtavaRepository();
        Tehtavakokoelma malli = new Tehtavakokoelma(mockRepo);
        
        // 2. Act: Kutsutaan metodia
        malli.lisaaTehtava("Käy kaupassa");
        
        // 3. Assert: Tarkistetaan tulos oikeassa data-domainissa
        assertEquals(1, malli.getTehtavat().size(), "Listassa pitäisi olla 1 tehtävä.");
        assertEquals("Käy kaupassa", malli.getTehtavat().get(0).getOtsikko(), "Otsikon pitäisi täsmätä");
        
        // 4. Assert 2: Varmistetaan mock-luokan avulla, että kokoelma laukaisi tallennuksen tapahtuman yhteydessä
        assertEquals(1, mockRepo.getTallennetutTehtavat().size(), "Data olisi pitänyt tallentaa rajapinnan läpi!");
    }

    @Test
    void lisaaTehtava_eiLisaaTyhjaaOtsikkoa() {
        MockTehtavaRepository mockRepo = new MockTehtavaRepository();
        Tehtavakokoelma malli = new Tehtavakokoelma(mockRepo);
        
        malli.lisaaTehtava("   "); // Tyhjä syöte
        
        assertEquals(0, malli.getTehtavat().size(), "Tyhjiä tehtäviä ei saa lisätä listaan.");
    }
}

Voimme oikeastaan nyt mennä pidemmälle ja testata, että tehtäväkokoelma oikeasti tallentaa tehtävät aina, kun kokoelman tai yksittäisen tehtävien tila muuttuu. Voimme lisätä siis erilliset testit nimenomaan tallentamiselle:

@Test
void tehtavakokoelmaTallennusToimiiLisayksestaJaPoistosta() {
    // Act ja Assert -vaiheita voi toistaa samassa testausyksikössä
    MockTehtavaRepository repo = new MockTehtavaRepository();
    Tehtavakokoelma kokoelma = new Tehtavakokoelma(repo);
    // Act 1: Lisätään tehtävä
    kokoelma.lisaaTehtava("Käy kaupassa");
    // Assert 1: Tallennus onnistui
    assertEquals(1, repo.getTallennetutTehtavat().size(), "Tehtävät tallentuvat, kun uusi tehtävä lisätään");

    // Act 2: Poistetaan tehtävä
    Tehtava tehtava = kokoelma.getTehtavat().getFirst();
    kokoelma.poistaTehtava(tehtava);
    // Assert 2: Tallennus onnistui
    assertEquals(0, repo.getTallennetutTehtavat().size(), "Tehtävät tallentuvat, kun tehtävä poistetaan");
}

@Test
void tehtavakokoelmaTallennusToimiiAttribuuttienMuutoksesta() {
    MockTehtavaRepository repo = new MockTehtavaRepository();
    Tehtavakokoelma kokoelma = new Tehtavakokoelma(repo);

    kokoelma.lisaaTehtava("Käy kaupassa");
    Tehtava tehtava = kokoelma.getTehtavat().getFirst();
    tehtava.setTehty(true);

    Tehtava tallennettuTehtava = repo.getTallennetutTehtavat().getFirst();
    assertEquals(tehtava.getTehty(), tallennettuTehtava.getTehty(), "Tehtävän tehty-tila tallentuu, kun se muutetaan");

    tehtava.setOtsikko("Mene nukkumaan");
    tallennettuTehtava = repo.getTallennetutTehtavat().getFirst();
    assertEquals(tehtava.getOtsikko(), tallennettuTehtava.getOtsikko(), "Tehtävän otsikko tallentuu, kun se muutetaan");

    tehtava.setPrioriteetti(Prioriteetti.KORKEA);
    tallennettuTehtava = repo.getTallennetutTehtavat().getFirst();
    assertEquals(tehtava.getPrioriteetti(), tallennettuTehtava.getPrioriteetti(), "Tehtävän prioriteetti tallentuu, kun se muutetaan");

    tehtava.setKuvaus("Nukkuminen on kivaa");
    tallennettuTehtava = repo.getTallennetutTehtavat().getFirst();
    assertEquals(tehtava.getKuvaus(), tallennettuTehtava.getKuvaus(), "Tehtävän kuvaus tallentuu, kun se muutetaan");
}

Ilman MVC-arkkitehtuuriamme olisimme yrittäneet kutsua suoraan kontrollerin logiikkaa Main.java-luokasta ja taistelisimme saadaksemme VBoxissa olevien Checkboxien lukumäärän tarkistettua, samalla varoitellen sitä sekoittamasta aitoa tehtavat.json-originaalitietokantaamme! Nyt voimme keskittyä vain malliluokan testaamiseen, joka on tämän pienen vaivannäön jälkeen nopeaa, luotettavaa ja helppoa.

Yhteenveto I/O-abstraktioista

Oikean arkkitehtuurijärjestelyn suurin hyöty näkyy yleensä ensimmäisenä testauksen sujuvuudessa. Kuvion voi ajatella menevän näin: UI (Controller) -> Business Logic (Tehtavakokoelma) -> Data Provider (TehtavaRepository)

UI:n testaus automatisoidusti on vaikeaa. Data providerin (oikean tallentamisen levylle) automaattinen testaus on tyypillisesti melko hidasta ja haurasta. Mutta eristetty bisneslogiikka eli sovelluksen hermokeskus voidaan suorittaa puhtaana logiikkakoodina sekunnin murto-osiin käyttämällä rajapintojen mahdollistamia mock-luokkia ympärillä olevien vaikeiden järjestelmien korvaamisessa testiajonaikaisesti.

Bonus: Tehtävä 8.7: Todo-sovellus, vaihe 11. 1 p.

Palauta osan 8.5 perusteella edistetty projekti. Kertaus tämän osan vaiheista:

  • Lisää projektiin yksikkötestit.
  • Eriytä tehtävien tallennus ja lataus erilliseen luokkaan, joka toteuttaa TehtavaRepository-rajapinnan.
  • Tee testipakkaukseen mock-luokka, joka toteuttaa TehtavaRepository-rajapinnan, mutta tallentaa datan vain muistissa.
  • Testaa tiedoston tallennus/lataus.

Kun vaihe on valmis, tee git add muuttuneille tiedostoille ja git commit.

Palauta TehtavaRepository-rajapinta sekä JsonTehtavaRepository, MockTehtavaRepository ja TehtavakokoelmaTest-luokat. Muita luokkia tai FXML-tiedostoja ei tarvitse palauttaa.

Tee tehtävä TIMissä

Versionhallinnan etäkäyttö

Tähän asti olemme käyttäneet versiohallintaa vain omalla koneella. Jotta koodi on turvassa kiintolevyn rikkoutumiselta ja jotta sen voi jakaa muille, koodi pitää yleensä viedä etävarastoon (engl. remote repository). Etävarasto voi olla vaikkapa toinen verkossa oleva tietokone, mutta nykyään on yleisempää käyttää jotakin julkista etävarastopalvelua, kuten GitHub- tai GitLab-palveluita. Nämä, kuten monet muut vastaavat Git-etävarastopalvelut tarjoavat myös muita projektihallinnassa hyödyllisiä lisäominaisuuksia, kuten tehtävähallintaa, keskustelupalstoja ja muita yhteistyötä helpottavia työkaluja. Nämä lisätyökalut eivät sinänsä ole Git-työkaluja, mutta ne tekevät etävarastopalveluista monipuolisia yhteistyöalustoja.

Tässä osassa siirrämme paikallisen projektin GitLab- tai GitHub-palveluun. Jyväskylän yliopiston opiskelijoilla on käytössään JY:n oma GitLab-palvelin. Muut opiskelijat voivat ladata koodin esimerkiksi GitHub-palveluun.

Etävaraston luominen

Jotta Git-varasto voidaan ladata etävarastopalveluun, palvelussa tulee ensin alustaa etävarasto. Etävarastopalvelut kutsuvat etävarastoja usein myös projekteiksi tarjottujen lisäpalvelujen takia.

Valitse käytettävä etävarastopalvelu:

  • Jyväskylän yliopiston opiskelijat: valitse GitLab (JYU). Halutessaan voi vaihtoehtoisesti käyttää GitHubia.
  • Muussa tapauksessa, valitse GitHub.

Etävaraston yhdistäminen lokaaliin projektiin

Avaa komentorivi ja siirry projektin juurikansioon. Juurikansio on se kansio, jossa on src-kansio ja pom.xml-tiedosto. Oikean kansion voi varmistaa suorittamalla git status -komennon, jolloin pitäisi näkyä git-varaston tila samalla tavalla kuin osassa 7.3.

Lisäämme seuraavaksi etävaraston osoitteen paikalliseen varastoon. Tätä varten meidän ensin pitäisi tietää git-etävaraston osoite.

Valitse käytettävä etävarastopalvelu:

  • Jyväskylän yliopiston opiskelijat: valitse GitLab (JYU). Halutessaan voi vaihtoehtoisesti käyttää GitHubia.
  • Muussa tapauksessa, valitse GitHub.

Kopioi etävaraston osoite ja lisää se paikalliseen varastoon git remote add -komennolla:

git remote add -komento ottaa kaksi parametria: etävaraston nimen ja etävaraston osoitteen.

Sana origin on Git-maailmassa vakiintunut nimitys projektin pääasialliselle etävarastolle.

Koodin lähettäminen etävarastoon ensimmäistä kertaa

Voimme nyt lähettää koodin etävarastoon. Ennen koodin lähettämistä meidän tulee vielä selvittää etävaraston käyttäjätunnus ja salasana. Nämä riippuvat palvelusta.

Valitse käytettävä etävarastopalvelu:

  • Jyväskylän yliopiston opiskelijat: valitse GitLab (JY). Halutessasi voit vaihtoehtoisesti käyttää GitHubia.
  • Muussa tapauksessa, valitse GitHub.

Kun tunnus ja salasana on tiedossa, projektin voi lähettää ensimmäistä kertaa etävarastoon käyttäen git push -komentoa:

Tämä komento tekee kaksi asiaa:

  1. push lähettää paikalliset commitit etävarastoon.
  2. -u origin master linkittää paikallisen master-haaran varaston master-haaraan. Tämän avulla Git-työkalu jatkossa tietää, että git push -komento ilman parametreja lähettää koodia aina origin-etävarastoon.

Huomaa, että ensimmäisen koodin lähettämisen, eli ns. push-komennon yhteydessä, Git-työkalu voi kysyä tunnusta ja salasanaa. Tunnus- ja salasanadialogi eroaa käyttöjärjestelmästä toiseen, mutta periaate on sama: anna yllä olevien ohjeiden mukainen tunnus ja salasana.

Jatkossa

Kun etävarasto on kerran määritelty ja ensimmäinen push on tehty, jatkossa työnkulku on yksinkertainen:

  1. Tee muutoksia koodiin.
  2. git add ., joka lisää muutokset Git-työkalun "käsittelyjonoon".
  3. git commit -m "Lisätty muokkausikkuna", joka tekee jonossa olevista muutoksista commitin.
  4. git push, joka lähettää kaikki tähän mennessä tehdyt commitit etävarastoon talteen.

Tehtävät

Tehtävä 8.8: Git-etävarasto. 1 p.

Tee työllesi julkinen Git-etävarasto ja tallenna koodisi sinne.

Tee tehtävä TIMissä

Osan kaikki tehtävät

Tehtävä 8.1: Todo-sovellus, vaihe 7. 1 p.

Palauta osan 8.1 perusteella refaktoroitu projekti. Kertaus tämän osan vaiheista:

  • Luo tehtävälle oma malliolio (Tehtava) käyttöliittymäkomponenttien sijaan.
  • Lisää malliin vähintään tehtävän otsikko ja tehty/ei-tehty -tila.
  • Ota käyttöön ObservableList<Tehtava> tehtävien pääasiallisena tietorakenteena.
  • Päivitä tehtävän lisäyslogiikka käyttämään mallioliota.

Kun vaihe on valmis, tee git add muuttuneille tiedostoille ja git commit. Palauta projektisi tiedostot.

Tee tehtävä TIMissä
Tehtävä 8.2: Todo-sovellus, vaihe 8. 1 p.

Palauta osan 8.2 perusteella edistetty projekti. Kertaus tämän osan vaiheista:

  • Korvaa tehtävien VBox + CheckBox-listaus TableView-komponentilla.
  • Lisää taulukkoon vähintään sarakkeet: tehtävä (otsikko), tehty-tila.
  • Kytke taulukon data ObservableList<Tehtava>-listaan.
  • Mahdollista tehtävän valinta ja poisto valitulta riviltä.

Kun vaihe on valmis, tee git add muuttuneille tiedostoille ja git commit. Palauta projektisi tiedostot.

Tee tehtävä TIMissä
Tehtävä 8.3: Todo-sovellus, vaihe 9. 1 p.

Palauta osan 8.3 perusteella edistetty projekti. Kertaus tämän osan vaiheista:

  • Jäsennä projekti kerroksiin (vähintään malli + käyttöliittymälogiikka).
  • Siirrä tiedoston luku- ja kirjoituslogiikka pois kontrollerista Tehtavakokoelma-luokkaan.
  • Muuta MainController-luokka delegoimaan tallennus- ja latausoperaatiot mallille.

Kun vaihe on valmis, tee git add muuttuneille tiedostoille ja git commit. Palauta projektisi tiedostot.

Tee tehtävä TIMissä
Tehtävä 8.4: Todo-sovellus, vaihe 10. 1 p.

Palauta osan 8.4 perusteella edistetty projekti. Kertaus tämän osan vaiheista:

  • Avaa tehtävän muokkausnäkymä, kun käyttäjä tuplaklikkaa tehtävää.
  • Lisää tehtävälle vähintään kuvaus ja prioriteetti.
  • Lisää syötteille validointi (esim. tehtävän otsikko ei saa olla tyhjä).
  • Tallenna muokkaukset takaisin tehtävään ja päivitä näkymä.

Kun vaihe on valmis, tee git add muuttuneille tiedostoille ja git commit. Palauta projektisi tiedostot.

Tee tehtävä TIMissä
Tehtävä 8.5: Testaus. 1 p.

Ota tehtävän 5.10 vastauksesi (tai mallivastaus), ja kirjoita sille yksikkötestit. Testaa aliohjelman toimivuutta ainakin viidellä eri syötteellä. Käytä tehtävässä annettuja esimerkkejä tai keksi itse uusia testitapauksia.

Tee tehtävä TIMissä
Tehtävä 8.6: bisneslogiikan testaaminen.1 p.

Kirjoita Tehtavakokoelma-luokalle yksikkötestejä. Testaa ainakin seuraavat asiat:

  • Kun tehtävä lisätään otsikolla, jonka alussa ja lopussa on välilyöntejä, tyhjät poistetaan ennen tallentamista listaan.
  • Kun kokoelmaan lisätään kaksi tehtävää, joilla on sama otsikko, molemmat oikeasti päätyvät listaan.
  • Edelliseen jatkoa: Kun toinen noista tehtävistä merkitään tehdyksi, vain kyseinen tehtävä merkitään tehdyksi, ei toista.
  • Kun kokoelmaan lisätään kaksi eri tehtävää peräkkäin, molemmat päätyvät listaan oikeassa järjestyksessä.

Palauta TehtavakokoelmaTest-luokka.

Tee tehtävä TIMissä
Bonus: Tehtävä 8.7: Todo-sovellus, vaihe 11. 1 p.

Palauta osan 8.5 perusteella edistetty projekti. Kertaus tämän osan vaiheista:

  • Lisää projektiin yksikkötestit.
  • Eriytä tehtävien tallennus ja lataus erilliseen luokkaan, joka toteuttaa TehtavaRepository-rajapinnan.
  • Tee testipakkaukseen mock-luokka, joka toteuttaa TehtavaRepository-rajapinnan, mutta tallentaa datan vain muistissa.
  • Testaa tiedoston tallennus/lataus.

Kun vaihe on valmis, tee git add muuttuneille tiedostoille ja git commit.

Palauta TehtavaRepository-rajapinta sekä JsonTehtavaRepository, MockTehtavaRepository ja TehtavakokoelmaTest-luokat. Muita luokkia tai FXML-tiedostoja ei tarvitse palauttaa.

Tee tehtävä TIMissä
Tehtävä 8.8: Git-etävarasto. 1 p.

Tee työllesi julkinen Git-etävarasto ja tallenna koodisi sinne.

Tee tehtävä TIMissä

Harjoitustyö, vaihe 1

Tässä osassa aloitetaan oman harjoitustyön toteutus. Harjoitustyö toteutetaan vaiheittain osissa 9-12, ja viimeistään osan 12 loppuun mennessä harjoitustyö tulee palauttaa ja hyväksyttää tuntiopettajalla etä- tai lähiohjauksessa. Lue huolellisesti harjoitustyön vaatimukset ennen aloittamista.

Osissa 9-12 on annettu ohjeita, joiden tarkoituksena on auttaa sinua etenemään harjoitustyössä. Vastaavasti osat 9-12 sisältävät tehtäviä, joiden tarkoitus on auttaa projektin edistämistä vaiheittain. Kuten aiemminkin, tehtävistä on palautettava vähintään 50 %.

Suosittelemme toteuttamaan harjoitustyön näissä osissa kuvattua vaiheistusta hyödyntäen.

Harjoitustyön aihe

Aloita ensin valitsemalla harjoitustyön aihe ja tutustumalla harjoitustyön vaatimuksiin. Löydät valmiita aiheita harjoitustyön ohjesivulta.

Kun olet valinnut aiheen, ilmoita se alla olevan tehtävän kautta.

Tehtävä 9.1: Harjoitustyö, aiheen valinta. 1 p.

Valitse sinua kiinnostava harjoitustyön aihe. Voit valita valmiin aiheen harjoitustyön ohjesivulta tai tehdä harjoitustyön omasta aiheesta.

Jos valitset valmiin aiheen, palauta vastauksena valitsemasi aiheen nimi (Kulujen seuranta, Tuotteiden varastohallinta, Kirjasto, Taloyhtiön hallinta tai Muistikorttisovellus).

Jos teet harjoitustyön omasta aiheesta, kirjoita ja palauta harjoitustyösuunnitelma. Suunnitelmassa tulee ilmetä sovelluksen tarkoitus, sovelluksen kannalta oleelliset toiminnalliset vaatimukset ja kuvaus sovelluksen kohdealueen kannalta oleellisista mallinnettavista kohteista. Voit ottaa mallia valmiiden aiheiden kuvauksista. Palautuksen jälkeen näytä suunnitelma ohjaajalle lähi- tai etäohjauksessa ennen kuin aloitat muiden vaiheiden suorittamista.

Paritöissä sovelluksessa tulee olla vähintään kolme mallinnettavaa kohdetta (yksilötöissä kaksi; vaatimus 1.1), sekä kolme näkymää (yksilötöissä kaksi; vaatimus 4.1). Tästä syystä parityön suunnitelma tulee aina tarkastuttaa ohjaajalla ennen seuraaviin vaiheisiin etenemistä.

Tee tehtävä TIMissä

Projektin alustaminen

Kun olet valinnut aiheen, voit luoda valmiiksi uuden JavaFX-projektin. Suosittelemme, että käytät kurssin valmista JavaFX-pohjaa, jonka käyttöä on esitelty osassa 7.1.

Vinkkejä:

  • Tee aluksi harjoitustyölle oma erillinen tyhjä kansio paikasta, jonka pystyt helposti löytämään tietokoneeltasi.
  • Kun luot projektia IDEAssa, valitse projektin poluksi (Location-asetus) juuri tuo äsken tekemäsi kansio. tietokoneelta.
  • Aseta projektillesi yksilöllinen tunniste (GroupId). Voit käyttää muotoa fi.jyu.ohj2.nimesi.aihe, jossa nimesi on yliopiston tunnuksesi ja aihe on harjoitustyön aihe.

Kun saat projektin luotua, kokeile ajaa se ja varmista, että saat sovelluksen käynnistettyä.

Git-varaston alustaminen

Kun projekti on luotu, luo saman tien projektikansioon Git-varasto komentoriviltä komennolla git init. Älä kuitenkaan tee vielä heti ensimmäistä commitia, vaan valmistellaan ensin hieman kansion sisältöä.

Git-versiohallintaa käyttäviin projekteihin on tapana sisällyttää .gitignore- ja README.md-tiedostot. .gitignore-tiedoston merkitystä on esitelty hieman osassa 7.3: tähän tiedostoon listattuja tiedostoja ja kansioita ei sisällytetä commiteihin ilman erityistä pakotusta. Esimerkkejä tällaisista ovat esimerkiksi IDEAn luomat out- ja target-kansiot, joissa on käännettyä koodia. Erityisen tärkeää on muistaa lisätä .gitignore-tiedostoon mahdolliset salaisuuksia sisältävät tiedostot, kuten henkilötietoja, salasanoja tai API-avaimia sisältävät tiedostot, jotta ne eivät päädy vahingossa etävarastoon.

Varmista, että projektikansiossasi on .gitignore-tiedosto. Jos käytät kurssin valmista JavaFX-pohjaa, sellainen tiedosto on valmiiksi sisällytetty projektiin.

Huomaa kirjoitusasu: tiedoston nimi on .gitignore, alussa piste ja kaikki pienellä. Vastaavasti README.md-tiedoston nimi on kaikki isoilla kirjaimilla, eikä siinä ole alussa pistettä.

README.md, eli ns. "Lue minut"-tiedosto, on tarkoitettu projektin esittämiseen ja toisaalta kehittämisen kannalta oleellisiin ohjeisiin. Etävarastopalvelut yleensä näyttävät tämän tiedoston heti projektin etusivulla, joten tiedosto on myös hyvä paikka kertoa projektista yleisesti ei-tekniselle peruskäyttäjälle. Voit luoda README.md-tiedoston suoraan IDEAssa klikkaamalla projektiselaimessa projektista hiiren toissijaisella painikkeella, valitsemalla New File ja antamalla tiedoston nimeksi README.md:

README.md-tiedosto on tapana kirjoittaa käyttäen Markdown-merkintäkieltä.

Tässä vaiheessa README-tiedosto voi olla aika alkeellinen. Lisää tiedostoon ainakin projektin nimi ja lyhyt kuvaus parilla virkkeellä. Jos käytät valmista aihetta, voit kopioida projektin aiheen kuvauksen harjoitustyön ohjeesta.

Kun saat README ja .gitignore -tiedostot tehtyä, tee ensimmäinen commit. Luo lopuksi uusi etävarasto ja lataa nykyinen varastosi sinne osan 8.6 ohjeiden perusteella.

Tehtävä 9.2: Harjoitustyö, Git-etävarasto. 1 p.
  1. Tee IDEA-projekti. Voit käyttää opintojakson JavaFX-pohjaa.
  2. Alusta paikallinen Git-varasto.
  3. Lisää README.md- ja (tarvittaessa) .gitignore-tiedostot.
  4. Tee ensimmäinen commit.
  5. Luo projektillesi julkinen etävarasto GitLab- tai GitHub-palvelussa.
  6. Puske paikallinen varasto etävarastoon.

README.md voi tässä vaiheessa sisältää vain projektin nimen sekä 1-2 virkkeen kuvauksen projektin aiheesta.

Palauta tähän tehtävään linkki julkiseen Git-etävarastoosi. Löydät etävaraston URL-osoitteen helpoimmin palvelun projektinäkymästä:

Tee tehtävä TIMissä

Tietomallin toteuttaminen

Ennen kuin menet syvemmin käyttöliittymään, on syytä ensin pohtia sovelluksen tietomallia ja sen toimintaa. Aloita kehitystyö toteuttamalla tietomallin kannalta oleelliset luokat projektiin. Valmiissa aiheessa luokat, niiden attribuutit ja luokkien väliset suhteet on esitetty UML-kaaviona.

Vinkkejä:

  • Tee tietomallin attribuutit käyttäen JavaFX Property-tyyppejä valmiiksi. Tämä helpottaa näkymän ja tietomallin kytkemistä yhteen myöhemmin.

  • Laita tietomalliin liittyvät luokat valmiiksi model-alipakkaukseen erillään muista luokista.

  • Mieti jo hieman, mitä julkisia metodeja luokan on hyvää tarjota muille luokille. Vaikka get- ja set-metodeja tarvitaan tietomallin tallentamiseksi JSON-muotoon, voit jo alustavasti miettiä, mitä metodeja luokka voisi tarjota parantakseen kapselointia. Esimerkiksi Todo-mallisovelluksessa ohjainluokka ei ikinä lisää Tehtava-oliota tehtäväkokoelman tehtavat-listaan itse, vaan tehtävän lisäys on tehtäväkokoelman vastuulla lisaaTehtava-metodin kautta.

    Älä kuitenkaan jää miettimään luokkien toimintaa liian kauan; kaikkia tapauksia ei kannata eikä voi vielä ennustaa. Voit tehdä apumetodeja lisää myöhemmin, kun toteutat ohjainluokkia.

  • Suosittelemme vahvasti, että testaat tietomallin luokkien välistä yhteistoimintaa kokeilemalla käyttää niitä main-pääohjelmassa (tai tee aliohjelma jota kutsut mainista). Et tarvitse tähän vielä käyttöliittymää, vaan voit luoda ja käyttää olioita suoraan pääohjelmassa. Varmista, että pystyt tietomallisi avulla tekemään sovelluksen ja harjoitustyön vaatimusten kannalta olennaisimmat toiminnot, kuten tiedon lisäyksen, hakemisen, muokkauksen ja poiston. Debuggerin avulla voit varmistaa, että tietomallin tila on oikea.

    Halutessasi voit jopa kirjoittaa yksikkötestejä, jossa testaat tietomallin perustoiminnallisuuksia. Voit ottaa mallia osan 8.5 ohjeesta, jossa Todo-sovelluksen tietomallin metodeja ja niiden toimivuutta testattiin.

  • Tallentamista tai lataamista ei tarvitse vielä tässä vaiheessa miettiä.

Kun sinulla on alustava versio tietomallista toteutettuna Javassa eikä koodi sisällä virheitä, on hyvä hetki tallentaa muutokset Gitiin. Tee muutoksista uusi commit (git add + git commit) ja puske ne etävarastoon talteen (git push).

Käyttöliittymän alustava suunnitelma

Kun sinulla on käsitys sovelluksen tietomallista ja vaatimuksista, on hyvä hetki alkaa pohtia käyttöliittymän alustavaa asettelua ja toimintaa.

Tee omaan projektiin uusi kansio suunnitelma. IDEAssa tämä tapahtuu klikkaamalla projektiselaimessa projektin nimestä hiiren toissijaisella painikkeella ja valitse New Directory. Tee uuteen kansioon tiedosto nimeltä kayttoliittyma.md. Kirjoita tiedostoon ylös alustavia tietoja sovelluksen käyttöliittymän tarvittavista näkymistä.

Voit käyttää seuraavaa mallirunkoa käyttöliittymän suunnitelmatiedostolle
# Käyttöliittymän suunnitelma

## Näkymä 1

![Näkymän karkea ulkoasu kuvana (wireframe.cc, DrawIO, Paint tai paperilla piirretty)](nakyma1.jpg)

**Olennaiset toiminnot**

- Mitä käyttäjä näkee käyttöliittymässä
- Miten tähän näkymään pääsee 
  (sovelluksen avaus, painikkeen klikkaus, jne.)
- Mitä käyttäjä voi tehdä käyttöliittymässä: 
  mitä voi klikata, mitä jokainen painike tekee

**Olennaiset komponentit**

- Mitä JavaFX-komponentteja saatat tarvita käyttöliittymän toteuttamiseen
- Tämä on pääosin paikka, johon voit kirjata linkkejä JavaFX-luokkiin 
  ja kirjastoihin, jotta niitä on helpompaa löytää käyttösuunnitelmaa tehtäessä
- Tämä osa ei ole pakollinen, vaan tarkoitettu helpottamaan dokumentaation hakemista myöhemmin

## Näkymä 2

![Näkymän karkea ulkoasu kuvana (wireframe.cc, DrawIO, Paint tai paperilla piirretty)](nakyma2.jpg)

**Olennaiset toiminnot**

- Mitä käyttäjä näkee käyttöliittymässä
- Miten tähän näkymään pääsee 
  (sovelluksen avaus, painikkeen klikkaus, jne.)
- Mitä käyttäjä voi tehdä käyttöliittymässä: 
  mitä voi klikata, mitä jokainen painike tekee

**Olennaiset komponentit**

- Mitä JavaFX-komponentteja saatat tarvita käyttöliittymän toteuttamiseen
- Tämä on pääosin paikka, johon voit kirjata linkkejä JavaFX-luokkiin 
  ja kirjastoihin, jotta niitä on helpompaa löytää käyttösuunnitelmaa tehtäessä
- Tämä osa ei ole pakollinen, vaan tarkoitettu helpottamaan dokumentaation hakemista myöhemmin

Piirrä alustavat kuvat jokaisesta näkymästä. Tässä vaiheessa käyttöliittymän tarkan ulkoasun ei tarvitse olla mietitty loppuun, vaan tarkoitus on keskittyä siihen mitä käyttäjä näkee ja mitä hän voi tehdä. Voit piirtää näkymät käyttäen esimerkiksi verkossa olevia kaaviosovelluksia, kuten wireframe.cc, DrawIO tai Figma, tai mitä tahansa muuta piirtosovellusta. Voit myös piirtää paperille ja ottaa kuvan / skannata sen.

Käyttöliittymä tarkempaa ulkoasua tehdään osassa 10.

Toki voit halutessasi tehdä näkymät heti valmiiksi SceneBuilderilla. Siinä tapauksessa ota näkymistä kuvakaappaus. Älä kuitenkaan käytä liikaa aikaa näkymien tekemiseen tässä vaiheessa; suunnitelman tarkoituksena on saada karkea idea käyttöliittymän näkymistä.

Tallenna kuvat suunnitelma-kansioon ja mainitse ne kayttoliittyma.md-tiedostossa. Löydät ohjeita kuvien upottamiseen Markdown-tiedostoihin verkosta.

Ota suunnitelmassa kantaa, mitä näkymässä näytetään ja millä eri tavoin käyttäjä voi vuorovaikuttaa käyttöliittymän kanssa. Näin voit varmistaa jo tässä vaiheessa, että muistat ottaa huomioon kaikki vaaditut tietomallin lisäys-, luku-, muokkaus- ja poistotoiminnot.

Kun käyttöliittymän näkymien suunnitelma on valmis, tee muutoksista commit ja puske muutokset etävarastoon.

Tehtävä 9.3: Käyttöliittymäsuunnitelma. 1 p.

Suunnittele käyttöliittymäsi näkymät ja niiden toiminnallisuus.

Piirrä jokaiselle käyttölittymälle alustava ulkoasu. Voit käyttää kaaviosovellusta, piirtosovellusta tai SceneBuilderia. Voit myös piirtää ulkoasun paperille ja skannata se.

Tee projektiisi kansio suunnitelma ja lataa näkymien kuvat sinne. Lisää kansioon myös Markdown-tiedosto kayttoliittyma.md ja kuvaa siellä seuraavat asiat jokaisen näkymän kohdalla:

  • mitä käyttäjä näkee näkymissä (päänäkymä, muokkausnäkymä, tms.),
  • mitä käyttäjä voi tehdä näkymissä,
  • millä komponenteilla tärkeimmät toiminnot on tarkoitus toteuttaa.

Tee uusi commit, joka sisältää suunnitelmakansion ja puske muutokset Git-etävarastoon.

Palauta linkki etävarastossa olevaan suunnitelma-kansioon. Saat linkin avaamalla etävarasto selaimessa, klikkaamalla etävaraston sisällöstä suunnitelma-kansiosta ja kopioimalla selaimen osoitepalkissa olevaa osoitetta.

Tee tehtävä TIMissä
Bonus: Tehtävä 9.4: Näyttäminen ohjaajalle. 1 p.

Suosittelemme, että näytät käyttöliittymäsuunnitelmasi ohjaajalle, valitsitpa valmiin aiheen tai oman aiheen. Näin saat varmistettua, että olet oikeilla jäljillä ja saat hyödyllistä palautetta, joka auttaa sinua toteuttamaan harjoitustyön onnistuneesti.

Kun olet näyttänyt suunnitelmasi ohjaajalle etä- tai lähiohjauksessa, voit itse merkitä tämän tehtävän tehdyksi. Ohjaaja laittaa tarvittaessa kommentteja palautuslaatikon alle.

Voit merkitä tästä tehtävästä pisteen vain silloin kun näytät ohjaajalle työsi keskeneräisessä tilassa -- ei enää siinä vaiheessa kun olet palauttamassa valmista työtä.

Tee tehtävä TIMissä

Harjoitustyö, vaihe 2

Jatketaan oman harjoitustyön tekemistä luomalla harjoitustyön käyttöliittymälle interaktiivinen prototyyppi. Prototyypin ei vielä tarvitse sisältää varsinaista toiminnallisuutta, mutta se antaa kuvan siitä, miltä sovellus tulee näyttämään. Lisäksi se antaa käsityksen siitä, miten käyttäjä voi olla vuorovaikutuksessa sovelluksen kanssa.

Näkymien luominen

Luo jokainen harjoitustyösi näkymä SceneBuilderissa.

Lisää fx:id jokaiseen sellaiseen komponenttiin, johon todennäköisesti haluat myöhemmin viitata koodissa. Älä unohda mahdollisia ohje- tai varoitustekstejä. Aluksi ne voivat sisältää placeholder-tekstin.

Tapahtumankäsittelijät

Lisää tapahtumankäsittelijät jokaiseen komponenttiin, joihin haluat myöhemmin lisätä toiminnallisuutta, kuten siirtymisiä muihin näkymiin, olioiden lisäämistä, poistamista, jne. Tyypillisesti tällaiset komponentit ovat painikkeita, alasvetovalikoita tai vastaavia.

Tapahtumankäsittelijöille voi luoda pohjat myös SceneBuilderissa. Klikkaa Code-kohtaa ja anna "On Action"-kohtaan tapahtumankäsittelijän nimi, esimerkiksi handleLoginButton.

Komponenttien ja tapahtumankäsittelijöiden nimeäminen

Fx:id-tunnisteen loppuun on tapana on lisätä komponentin tyyppi. Tämä auttaa koodin kirjoittamisessa, kun voit kirjoittaa vain button ja IDE osaa ehdottaa oikeaa komponenttia.

KomponenttiLyhenne / PääteEsimerkki (fx:id)
Buttonbtn tai ButtontallennaBtn, peruutaButton
TextFieldtxt tai FieldemailField, statusTxt
Labellbl tai LabelilmoitusLabel, virheLbl
ComboBoxcombomaaCombo
TableViewtablekayttajaTable, tehtavaTable
CheckBoxcb tai checksuodatusCheck

Tapahtumankäsittelijöiden kohdalla on tapana käyttää handle- tai kasittele-etuliitettä. Esimerkiksi kasitteleUusiOstostapahtuma voisi olla tapahtumankäsittelijä, joka liittyy uusiOstostapahtumaButton-painikkeeseen.

Kontrolleriluokkien nimeäminen

Näkymille kannattaa jo SceneBuilderissa antaa kontrolleriluokka, vaikka niitä ei olisikaan vielä olemassa. Nimi syötetään kohtaan Controller Controller class. Nimi tulee valita niin, että se on sama kuin näkymän nimi, perään lisättynä "Controller"-sana. Esimerkiksi SyotaTehtava.fxml-näkymälle sopisi SyotaTehtavaController-kontrolleriluokka.

tärkeää

Kontrolleriluokan nimi tulee antaa nimi kokonaisuudessaan pakkauksen kanssa, esimerkiksi fi.jyu.ohj2.anlakane.todo.SyotaTehtavaController. Jos annat pelkän luokan nimen, kuten SyotaTehtavaController, IDE ei löydä luokkaa.

Tehtävä 10.1: Näkymät SceneBuilderissa. 1 p.

Tee jokainen näkymä mahdollisimman valmiiksi SceneBuilderissa.

Jos näkymä sisältää dynaamisesti lisättäviä komponentteja (vrt. Todo-sovelluksen tehtävät), ei näitä luonnollisesti lisätä SceneBuilderissa, mutta niille kannattaa varata tilaa ja fx:id-tunniste, jotta niihin on helppo viitata koodissa.

Lisää jokaiselle interaktiiviselle komponentille fx:id-tunniste.

Lisää jokaiselle interaktiiviselle komponentille tapahtumankäsittelijä, joka liittyy kyseiseen komponenttiin.

Tee tehtävä TIMissä

Kontrolleri-luokkien luominen

Luo jokaiselle näkymälle oma kontrolleri-luokka. Vinkki: Saat SceneBuilderista kontrolleriluokan pohjan, jonka voit copy-pasteta projektiisi. Klikkaa View Show Sample Controller Skeleton. Täydennä siihen tarvittavat tyypit ?-merkkien kohdalle.

Tehtävä 10.2: Kontrollerit. 1 p.

Tee jokaiselle näkymälle kontrolleri-luokat. Jokaisen käsittelijän pitää tehdä jotakin, esimerkiksi tulostaa teksti konsoliin. Tässä kohdassa ei vielä pysty kokeilemaan muiden kuin päänäkymän kontrollerin toimintaa. Käännösvirheitä ei saa kuitenkaan tulla.

Tee tehtävä TIMissä

Siirtyminen näkymästä toiseen

Näkymien välillä pitää pystyä siirtymään. Kirjoita tapahtumankäsittelijöihin tarvittava koodi, jotta voit siirtyä näkymästä toiseen. Myös kaikkien muiden vuorovaikutteisten elementtien, kuten painikkeiden, pitää tehdä jotain, esimerkiksi tulostaa konsoliin. Näin saat hyvän pohjan, johon voit myöhemmin lisätä toiminnallisuutta.

Tehtävä 10.3: Siirtyminen näkymästä toiseen. 1 p.

Toteuta siirtymät näkymien välillä. Esimerkiksi päänäkymästä pitää pystyä siirtymään syöttö- tai muokkausnäkymään ja takaisin. Takaisin päin pitää päästä siirtymään muutenkin kuin sulkemalla ikkuna.

Tee tehtävä TIMissä

Näyttäminen ohjaajalle

Kuten osassa 9, suosittelemme tässäkin vaiheessa näyttämään harjoitustyön vaiheen ohjaajalle.

Bonus: Tehtävä 10.4: Vaiheen näyttäminen ohjaajalle. 1 p.

Näytä vaihe ohjaajalle.

Voit merkitä tästä tehtävästä pisteen vain silloin kun näytät ohjaajalle työsi keskeneräisessä tilassa -- ei enää siinä vaiheessa kun olet palauttamassa valmista työtä.

Tee tehtävä TIMissä

Harjoitustyö, vaihe 3

Aikaisempien osien perusteella sinulla pitäisi olla sovelluksen runko valmiina. Tässä vaiheessa toteutetaan harjoitustyön toiminnallisuudet, eli kytketään tietomalli käyttöliittymään.

Tässä vaiheessa kunkin tehtävän kohdalle palautetaan kyseiseen tehtävään liittyvä URL-osoite, joka sisältää niin sanotun commit hashin. Tällä tavalla ohjaaja pääsee tarvittaessa tarkastelemaan juuri tiettyyn vaiheeseen liittyvää koodia.

Commit hash

Commit hash on Gitin muodostama yksilöllinen tunniste yksittäiselle commitille. Se näyttää yleensä pitkältä merkkijonolta, kuten a1b2c3d4..., ja sen avulla voidaan viitata täsmällisesti juuri tiettyyn projektin tilaan.

Commitin tarkoitus on tallentaa yksi versio projektista versionhallintaan. Commit hash taas kertoo, mikä näistä tallennetuista versioista on kyseessä. Kun tehtävän palautuksessa annetaan commit hash, ohjaaja voi avata juuri sen hetken koodin, jossa tehtävä on ollut valmiina.

Commit hash ei edusta vain yksittäistä tiedostoa tai muutosta, vaan commit-oliota, joka sisältää metatietoa yhdestä commitista: Näitä ovat esimerkiksi viesti ("message"), tekijä ("author"), viittaus projektin hakemistorakenteeseen ("tree") sekä viittaus edelliseen commitiin ("parent").

GitLab- ja GitHub-palveluissa on mahdollisuus tarkastella committeja siten, että hash-arvo on suoraan URL-osoitteessa.

Commit hash -osoitteen esimerkki GitLabissa

  1. Kirjaudu GitLabiin ja avaa projektisi.
  2. Klikkaa vasemmalta Code Commits.
  3. Näet listan commiteista. Valitse se commit, joka liittyy tehtävän palautukseen. Klikkaa sitä.
  4. Osoiterivillä näkyy URL-osoite, joka sisältää commit hash -arvon. Kopioi tämä URL-osoite ja liitä se tehtävän palautukseen.

Commit hash -osoitteen esimerkki GitHubissa

  1. Kirjaudu GitHubiin ja avaa projektisi.
  2. Klikkaa vihreän Code-kuvakkeen alta NNN Commits, jossa NNN on committien määrä.
  3. Näet listan commiteista. Valitse se commit, joka liittyy tehtävän palautukseen. Klikkaa sitä.
  4. Osoiterivillä näkyy URL-osoite, joka sisältää commit hash -arvon. Kopioi tämä URL-osoite ja liitä se tehtävän palautukseen.

Tehtävät

Kuhunkin tehtävään palautetaan URL-osoite, joka vie commitiin etävarastossasi. Jos olet tehnyt omaa työtäsi hieman eri rytmissä kuin tässä vaiheistuksessa on esitetty, palauta URL-osoite siihen commitiin joka parhaiten edustaa kyseisen tehtävän vaatimuksia.

Tehtävä 11.1: Tiedon lisääminen. 1 p.

Toteuta tiedon lisääminen. Lisäämisen jälkeen ohjelman tulee näyttää lisätty tieto sovelluksen käyttöliittymässä. Validointia ei tarvitse vielä tehdä. Tässä kohden riittää, että dataa syntyy sovellukseen.

Tee tehtävä TIMissä
Tehtävä 11.2: Poistaminen. 1 p.

Toteuta tiedon poistaminen. Poistamisessa tulee huomioida ja käsitellä myös muut mahdolliset oliot, jotka viittaavat poistettuun olioon. Esimerkiksi, jos poistat Kategoria-olion Kulujenseuranta-sovelluksessa, pitää poistaa (asettaa null-arvioon tai Optional.empty()-arvoon) kategoria kaikilta niiltä Tapahtuma-olioilta, jotka siihen viittaavat. Poistamisessa on myös hyvä olla varmistusdialogi esimerkiksi Alert-luokan avulla, jotta vahingossa tapahtuneet klikkaukset eivät tuhoa dataa.

Tee tehtävä TIMissä
Tehtävä 11.3: Tallentaminen ja lukeminen tiedostosta. 1 p.

Toteuta tiedon tallentaminen ja lukeminen tiedostosta.

Tee tehtävä TIMissä
Tehtävä 11.4: Tiedon muokkaaminen. 1 p.

Toteuta tiedon muokkaaminen. Muokkauksen tulee tallentua välittömästi sekä tietomalliin että tiedostoon. Kuten poistamisessa, myös muokkaamisessa tulee huomioida muokattuun olioon viittaavat oliot.

Tee tehtävä TIMissä
Tehtävä 11.5: Validointi. 1 p.

Toteuta tiedon validointi. Sovelluksessa ei saa olla mahdollista syöttää selkeästi virheellistä tietoa.

Tee tehtävä TIMissä
Tehtävä 11.6: Yksikkötestit. 1 p.

Toteuta tietomallille yksikkötestejä JUnitilla.

Tee tehtävä TIMissä
Tehtävä 11.7: README-tiedosto. 1 p.

Täydennä README-tiedosto. Lisää siihen kuvaus toteuttamistasi toiminnallisuuksista sekä ohjeet sovelluksen käyttämiseen. Ota kuvakaappaukset eri näkymistä ja selitä, miten eri toiminnot toimivat.

Tee tehtävä TIMissä
Tehtävä 11.8: Bonus: Näytä vaihe ohjaajalle. 1 p.

Näytä vaihe ohjaajalle.

Tee tehtävä TIMissä

Harjoitustyön palautus

Harjoitustyön tarkastus maanantai 13.4.2026 mennessä. Näytä harjoitustyösi ohjaajalle joko etä- tai lähiohjauksessa. Mikäli etusivulla olevat ohjausajankohdat eivät sovi sinulle, ota yhteyttä opettajien sähköpostilistalle (ohj2-opet@jyu.onmicrosoft.com) ja sovi erikseen sopiva aika.

Varmista ennen tarkastusta, että olet huomioinut kaikki harjoitustyön vaatimukset ja että harjoitustyön viimeisin versio on saatavilla Git-etävarastossa. Valmistaudu esittämään työsi ja vastaamaan ohjaajan mahdollisiin kysymyksiin.

JavaFX-ohjeita

Tämä osio sisältää erilaisia JavaFX-kirjastoon liittyvien ongelmien ratkaisuohjeita, eli ns. reseptejä.

Yleisiä JavaFX-materiaaleja

Tämä osa ei sisällä kaikkien komponenttien yksityiskohtaisempia ohjeita. Suosittelemme käyttämään seuraavia lähteitä JavaFX-kirjaston käyttöön:

Näkymät

Näkymän vaihtaminen samassa ikkunassa

Näkymän vaihtaminen samassa ikkunassa on tärkeä osa monen JavaFX-sovelluksen toimintaa. Näkymien välillä voi siirtyä ilman uuden ikkunan avaamista.

Alla yksinkertainen esimerkki, jossa on kaksi näkymää: MainView ja SecondaryView. Painikkeella pääsee toiselle näkymälle, ja toisella painikkeella takaisin.

Juju on tämä: Asetamme nykyisen Scene-olion juurisolmuksi uuden näkymän. Näin ikkunan koko ja muut ominaisuudet säilyvät, mutta sisältö vaihtuu.

main.fxml
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.layout.VBox?>

<VBox xmlns:fx="http://javafx.com/fxml" fx:controller="fi.jyu.ohj2.esimerkit.MainController">
    <Button text="Toiseen näkymään" onAction="#siirryToiseenNakymaan"/>
</VBox>

Näkymän vaihtaminen ikkunassa

Jos näkymien välillä on tarvetta välittää tietoa, se voidaan toteuttaa näkymän lataamisen yhteydessä seuraavasti. Tässä MainController lähettää viestin SecondaryController-oliolle, joka tulostaa sen konsoliin initialize()-metodissaan. Täysin vastaavasti voitaisiin välittää tieto myös takaisin päin.

MainController.java
public class MainController  {
    @FXML
    private void siirryToiseenNakymaan(ActionEvent event) throws Exception {
        FXMLLoader loader = new FXMLLoader(App.class.getResource("secondary.fxml"));
        loader.setControllerFactory(_ -> new SecondaryController("Moikka"));
        Parent secondaryRoot = loader.load();
        Scene currentScene = ((Node) event.getSource()).getScene();
        currentScene.setRoot(secondaryRoot);
    }
}

TableView

Tyhjän rivin klikkaaminen

Oletuksena TableView-komponentti ei poista valintaa, jos käyttäjä klikkaa tyhjää riviä. Tämä on usein epäintuitiivista.

Tyhjän rivin klikkaaminen saadaan koodissa kiinni esimerkiksi asettamalla riveille setOnMouseClicked-kuuntelija, joka tarkistaa, onko klikattu rivi null ja poistaa valinnan, jos näin on.

tableView.setRowFactory(tv -> {
    TableRow<MyData> rivi = new TableRow<>();
    rivi.setOnMouseClicked(tapahtuma -> {
        if (rivi.isEmpty()) {
            tableView.getSelectionModel().clearSelection();
        }
    });
    return rivi;
});

Rivien suodattaminen

Esimerkki on pitkähkö; löydät sen kokonaisuudessaan GitHubista.

TableView-komponentti ei tarjoa suoraan tukea rivien suodattamiseen.JavaFX-kirjastossa on FilteredList-luokka, joka mahdollistaa suodattamisen.

Oletetaan, että meillä on tehtäviä ja kategorioita. Kukin tehtävä kuuluu johonkin yhteen kategoriaan. Haluamme valita kategorian pudotusvalikosta ja nähdä vain kyseiseen kategoriaan kuuluvat tehtävät.

Tehtävä ja Kategoria voisivat näyttää seuraavilta:

Tehtava.java
package fi.jyu.ohj2.esimerkit.filteredlist;

 import javafx.beans.property.ObjectProperty;
 import javafx.beans.property.SimpleObjectProperty;
 import javafx.beans.property.SimpleStringProperty;
 import javafx.beans.property.StringProperty;
 
public class Tehtava {

    private final StringProperty otsikko = new SimpleStringProperty();
    private final ObjectProperty<Kategoria> kategoria = new SimpleObjectProperty<>();

    public Tehtava() {
        // Tarvitaan Jacksonille
    }

    public Tehtava(String otsikko, Kategoria kategoria) {
        this.otsikko.set(otsikko);
        this.kategoria.set(kategoria);
    }

    // Otsikko
    public void setOtsikko(String otsikko) {
        this.otsikko.set(otsikko);
    }

    public String getOtsikko() {
        return otsikko.get();
    }

    public StringProperty otsikkoProperty() {
        return otsikko;
    }

    // Kategoria
    public void setKategoria(Kategoria kategoria) {
        this.kategoria.set(kategoria);
    }

    public Kategoria getKategoria() {
        return kategoria.get();
    }

    public ObjectProperty<Kategoria> kategoriaProperty() {
        return kategoria;
    }
}

Käyttöliittymä voisi näyttää vaikkapa seuraavalta: Meillä on TableView tehtävien näyttämistä varten, CheckBox-komponentti, jolla aktivoidaan suoritus, ja ComboBox-komponentti, josta valitaan kategoria.

Käyttöliittymä suodatukselle

Tee kullekin elementille fx:id ja vastaavat kentät kontrolleriin.

Lisää attribuutti FilteredList<Tehtava> suodatetutTehtavat kontrolleriin.

Kun olet ladannut tehtävät ja kategoriat esimerkiksi JSON-tiedostosta, luo FilteredList-olio ja aseta se TableView-komponentin datalähteeksi:

// Oletetaan, että tehtavat on ladattu ObservableListiin nimeltä 'tehtavat'
suodatetutTehtavat = new FilteredList<>(tehtavat, t -> true);
tableView.setItems(suodatetutTehtavat);

Tässä t -> true on lambdalauseke joka määrittää mitkä tehtävät näytetään. Aluksi kaikki näytetään, koska ehto on aina tosi.

Lisätään ComboBox-komponentille kuuntelija, joka päivittää suodatuskriteeriä:

comboBox.setOnAction(event -> { 
    paivitaSuodatus();
});

private void paivitaSuodatus() {
    Kategoria valittuKategoria = comboBox.getSelectionModel().getSelectedItem();
    suodatetutTehtavat.setPredicate(t -> 
        t.getKategoria().getNimi().equals(valittuKategoria.getNimi())
    ); 
}

Tässä setPredicate-metodi määrittää suodatuskriteerin. Jos kategoria on valitsematta, näytetään kaikki tehtävät. Muuten näytetään vain ne tehtävät, joiden kategoria vastaa valittua kategoriaa.

Tässä on kuitenkin se ongelma, että kerran valittua filtteröintiä ei voida poistaa. Siksi lisäsimme CheckBox-komponentin, jolla filtteröinti voidaan aktivoida ja deaktivoida. Muutetaan paivitaSuodatus-metodia seuraavasti:

private void paivitaSuodatus() {
    Kategoria valittuKategoria = comboBox.getSelectionModel().getSelectedItem();
    if (checkBox.isSelected() && valittuKategoria != null) {
        suodatetutTehtavat.setPredicate(t -> 
        t.getKategoria().getNimi().equals(valittuKategoria.getNimi()) 
    ); 
    } else {
        suodatetutTehtavat.setPredicate(t -> true); // Näytä kaikki
    }
}

Suodatus kannattaa disabloida kokonaan, kun CheckBox-komponentti ei ole valittuna. Tämä onnistuu esimerkiksi näin.

comboBox.disableProperty().bind(checkBox.selectedProperty().not());

Tämä rivi vaatinee hieman selitystä. Tässä comboBox-komponentin sitominen disabloidaan checkBox-komponentin selected-ominaisuuden käänteisen arvon mukaisesti. Toisin sanoen, kun checkBox on valittuna, comboBox-komponenttia "ei disabloida". JavaFX:ssä ei ole enableProperty()-metodia, joten meidän on käytettävä disableProperty()-metodia ja käännettävä sen arvo.

Tämä selectedProperty on olemassa CheckBox-komponentissa valmiina, joten sitä ei tarvitse erikseen määritellä.

Lopputulos näyttää vaikkapa tältä:

Suodatettu näkymä

Solujen uudelleenmuotoilu

Tämäkin esimerkki löytyy kokonaisuudessaan GitHubista.

Joskus voi olla tarvetta muuttaa solun ulkoasua tietyissä olosuhteissa.

Jatkaen edellistä esimerkkiä, oletetaan, että kategorioita voisi poistaa. Poistettu kategoria halutaan näyttää punaisella tekstillä, jotta käyttäjä huomaa, että kategoria on poistettu. Käyttöliittymä voisi näyttää vaikkapa seuraavalta:

Poistettu kategoria punaisella

Tieto siitä, onko kategoria poistettu, voisi olla Kategoria-luokan ominaisuutena, ja luonnollisesti mukana myös JSON-tiedostossa. Katso esimerkit näistä luokista GitHubista: Kategoria.java, kategoriat.json.

Toki voisimme lisätä taulukkoon uuden sarakkeen, joka näyttää, onko kategoria poistettu ja tehdä suodatusta sitä kautta. Tämä ei kuitenkaan ole aina kovin elegantti ratkaisu. Parempi idea on näyttää tieto poistetuista kategorioista suoraan kategorian nimessä, esimerkiksi punaisella tekstillä.

Edellisessä esimerkissä lisäsimme kategoriasarakkeen seuraavasti.

kategoriaColumn.setCellValueFactory(cellData -> cellData.getValue().kategoriaProperty().asString());

Tällä määritellään, mistä tieto soluun haetaan. Sillä ei voi vaikuttaa solun ulkoasuun. Se täytyy tehdä setCellFactory-metodin avulla, kuten opimme osassa 8.2. Valitettavasti ei ole mitään valmista CellFactory-luokkaa, joka osaisi muuttaa tekstin värin. Niinpä meillä on kaksi vaihtoehtoa: (1) Voimme tehdä oman CellFactory-luokan perimällä TableCell-luokan tai (2) käyttää lambdalauseketta. Ensimmäinen vaihtoehto on työläämpi, mutta hyödyllinen, jos käytämme samaa logiikkaa useammassa sarakkeessa tai jopa useammassa taulukossa. Tässä tapauksessa meille kuitenkin riittää yhden sarakkeen muokkaus, joten käytämme lambdalauseketta.

kategoriaColumn.setCellFactory(column -> {
  return new TableCell<Tehtava, String>() {
    @Override
    protected void updateItem(String item, boolean empty) {
      super.updateItem(item, empty);
      if (item == null || empty) { // Tyhjä solu täytyy käsitellä erikseen
          setText(null);
          setStyle("");
      } else {
        setText(item); // Näytä soluun kuuluva teksti
        Tehtava tehtava = getTableRow().getItem(); // Hakee koko rivin datan
        if (tehtava != null && tehtava.getKategoria().isPoistettu()) { 
          setStyle("-fx-text-fill: red;"); // Asettaa poistetun tekstin punaiseksi
        } else {
          setStyle(""); // Palauttaa oletustyylin, jos kategoria ei ole poistettu
        }
      }
    }
  };
});

Perusideana on ylikirjoittaa TableCell-luokan updateItem-metodi. Tätä metodia kutsutaan aina, kun TableView-oliossa olevan solun sisältöä tai ulkoasua tarvitsee päivittää, esimerkiksi kun taulukko renderöidään tai kun solun arvo muuttuu. Ylikirjoittamalla updateItem-metodin voimme määritellä tarkasti, miten solun sisältö ja ulkoasu päivitetään.

Lisätään vielä valintapainike, jolla käyttäjä voi näyttää poistetut kategoriat tai piilottaa ne. Oletuksena tämän valintapainikkeen pitää olla pois päältä ja silloin näytetään vain ne tehtävät, jotka eivät kuulu poistettuihin kategorioihin.

@FXML
private CheckBox naytaMyosPoistetutCheckBox;

// ...

public void initialize(URL url, ResourceBundle resourceBundle) {
    // ...

    naytaMyosPoistetutCheckBox.setOnAction(event -> {
        if (naytaMyosPoistetutCheckBox.isSelected()) {
            suodatetutTehtavat.setPredicate(t -> true); // Näytä kaikki
        } else {
            paivitaSuodatus(); 
        }
    });

    // ...
}

@FXML
private void paivitaSuodatus() {
    Kategoria valittuKategoria = valitseKategoriaComboBox.getSelectionModel().getSelectedItem();
    if (suodataCheckBox.isSelected() && valittuKategoria != null) {
        suodatetutTehtavat.setPredicate(t -> t.getKategoria().getNimi().equals(valittuKategoria.getNimi()));
    } else {
        // Näytä kaikki tehtävät, jotka eivät kuulu poistettuihin kategorioihin
        suodatetutTehtavat.setPredicate(t -> !t.getKategoria().isPoistettu());
    }
}

Poistettujen kategorioiden valintapainike kannattaa disabloida, kun muu suodatus on käytössä.

naytaMyosPoistetutCheckBox.disableProperty().bind(suodataCheckBox.selectedProperty());

Tämän seurauksena pitää myös resetoida käyttöliittymästä poistettujen kategorioiden valintapainike suodatuksen yhteydessä.

suodataCheckBox.selectedProperty()
               .addListener((obs, vanha, uusi) -> 
                naytaMyosPoistetutCheckBox.setSelected(false));

UI-kirjastojen käyttäminen

tärkeää

Tällä kurssilla on sallittua käyttää ulkoisia kirjastoja omassa harjoitustyössä, mutta omalla vastuulla.

Ota huomioon, että ulkoisille kirjastoille on tarjolla vaihtelevasti ohjeita, ja pahimmillaan voit joutua selvittämään kirjaston toimintaa suoraan sen lähdekoodista. Lisäksi ulkoiset kirjastot voivat sisältää bugeja ja ongelmia, joiden selvittäminen voi viedä aikaa pois itse harjoitustyön tekemisestä.

Kurssin opettajat ja tuntiopettajat tarjoavat tukea vain JavaFX-kirjastossa valmiiksi oleviin komponentteihin ja toimintoihin.

JavaFX:lle on olemassa lukuisia lisäkirjastoja, jotka voivat helpottaa kehitystä. Saatat hyötyä esimerkiksi seuraavista kirjastoista:

JavaFX-kirjastojen käyttöönotto tapahtuu samoin kuin osan 6.4 ohjeissa: etsitään projektia vastaava pakkaus Maven Central -sivustolta, kopioidaan tarvittava <dependency>-määre ja lisätään se projektin pom.xml-tiedostoon <dependencies>-listaukseen.

Esimerkiksi ControlsFX saa käyttöön lisäämällä pom.xml-tiedoston <dependencies>-kohtaan:

<dependency>
    <groupId>org.controlsfx</groupId>
    <artifactId>controlsfx</artifactId>
    <version>11.2.3</version>
</dependency>

Tämä ei kuitenkaan vielä näytä kirjaston komponentteja SceneBuilderissa. Jotta kirjaston komponentteja saa myös SceneBuilderiin, tee näin:

  1. Avaa SceneBuilderissa muokattava .fxml-tiedosto.

  2. Klikkaa Library-näkymän hakupalkin vieressä olevaa asetuspainiketta () ja valitse sieltä JAR/FXML Manager:

  3. Valitse avautuneesta dialogista Manually add Library from repository.

  4. Syötä avautuneeseen dialogiin pakkauksen <dependency>-määreen tiedot:

    • Group ID: Sama arvo kuin <groupId>. ControlsFX-kirjastolle tämä on esimerkiksi org.controlsfx
    • Artifact ID: Sama arvo kuin artifactId. ControlsFX-kirjastolle tämä on esimerkiksi controlsfx Paina Enter sen jälkeen, kun syötit Group ID ja Artifact ID -arvot, jolloin SceneBuilder hakee kirjaston tiedot Maven Centralista. Valitse sen jälkeen Version-kenttään sama versio kuin <dependency>-määreen <version>-kentässä. Yllä olevassa ControlsFX-kirjaston esimerkille tämä on 11.2.3. Varmista, että SceneBuilderiin lisättävä versio on sama kuin projektin pom.xml:ään lisättävä versio.
  5. Paina Add JAR. Tämä pitäisi avata komponenttivalikon, jolla voit esikatsella kirjaston komponentteja ja valita, mitkä niistä ladataan SceneBuilderiin.

    Tässä yleensä riittää painaa Import Components, jolloin kirjaston kaikki komponentit ladataan.

  6. Lopuksi sulje dialogi Close-painikkeella.

Nyt SceneBuilderin Library-näkymässä pitäisi löytyä myös kirjaston omia komponentteja Custom-paneelista:

Voit nyt käyttää komponentteja normaalisti.

Johdetut Observable-arvot

Käyttöliittymässä haluamme usein näyttää varsinaisen tiedon lisäksi tiedoista laskettuja arvoja, kuten arvojen summaa, keskiarvoa, yhdistelmää tai vastaavaa.

Esimerkiksi henkilön koko nimi voidaan laskea etunimen ja sukunimen yhdistelmänä:

String kokonimi = henkilo.getEtunimi() + henkilo.getSukunimi();

Näin laskettu arvo ei kuitenkaan päivity automaattisesti käyttöliittymässä, jos etunimi tai sukunimi muuttuu.

Esimerkki

Katsotaan seuraavaa sovellusta henkilön tietojen syöttämiseksi:

MainController.java
public class MainController implements Initializable {
    @FXML
    private TableColumn<Pelaaja, String> nimiColumn;

    @FXML
    private TableColumn<Pelaaja, Number> syntymavuosiColumn;

    @FXML
    private TableColumn<Pelaaja, Number> ikaColumn;

    @FXML
    private TableView<Pelaaja> pelaajatTable;

    @FXML
    private Label pelaajiaLkmLabel;

    @FXML
    private Button lisaaPelaajaButton;

    private ObservableList<Pelaaja> pelaajat = FXCollections.observableArrayList();

    @Override
    public void initialize(URL url, ResourceBundle resourceBundle) {
        nimiColumn.setCellFactory(TextFieldTableCell.forTableColumn());
        nimiColumn.setCellValueFactory(cellData -> cellData.getValue().nimiProperty());

        syntymavuosiColumn.setCellFactory(TextFieldTableCell.forTableColumn(new NumberStringConverter("####")));
        syntymavuosiColumn.setCellValueFactory(cellData -> cellData.getValue().syntymavuosiProperty());

        // Tämä ei toimi!
        // setCellValueFactory vaatii ObservableValue-arvon, mutta ikä on int-kokonaisluku
        // ikaColumn.setCellValueFactory(cellData -> LocalDate.now().getYear() - cellData.getValue().getSyntymavuosi());

        lisaaPelaajaButton.setOnAction(event -> {
            Pelaaja uusiPelaaja = new Pelaaja();
            uusiPelaaja.setNimi("Uusi Pelaaja");
            uusiPelaaja.setSyntymavuosi(2000);
            pelaajat.add(uusiPelaaja);
        });

        pelaajatTable.setItems(pelaajat);
    }
}

Tässä esimerkissä näemme kaksi ongelmaa:

  • Uuden pelaajan lisääminen ei päivitä pelaajien lukumäärää
  • Pelaajan syntymävuoden muokkaaminen ei päivitä pelaajan ikää

Observable-arvon muuttaminen toiseksi arvoksi

Kaikki ObservableValue-arvot, kuten StringProperty, IntegerProperty, FloatProperty, jne. sisältävät map()-apumetodin (ks. JavaDoc), jonka avulla arvolle voi suorittaa laskutoimituksia. Esimerkiksi pelaajan ikä saadaan laskettua syntymävuodesta seuraavasti:

ObservableValue<Number> ika = pelaaja.syntymavuosiProperty().map(vuosi -> LocalDate.now().getYear() - vuosi.intValue());

Tällöin ika-muuttujan sisältämä arvo lasketaan syntymävuodesta kaavalla LocalDate.now().getYear() - vuosi.intValue() aina, kun pelaajan syntymävuosi muuttuu. Koska ObservableValue on havaittava arvo, voimme käyttää sitä setCellValueFactory-metodissa:

public void initialize(URL url, ResourceBundle resourceBundle) {
    nimiColumn.setCellFactory(TextFieldTableCell.forTableColumn());
    nimiColumn.setCellValueFactory(cellData -> cellData.getValue().nimiProperty());

    syntymavuosiColumn.setCellFactory(TextFieldTableCell.forTableColumn(new NumberStringConverter("####")));
    syntymavuosiColumn.setCellValueFactory(cellData -> cellData.getValue().syntymavuosiProperty());

    // HIGHLIGHT_GREEN_BEGIN
    ikaColumn.setCellValueFactory(cellData -> 
        cellData.getValue().syntymavuosiProperty().map(
            syntymavuosi -> LocalDate.now().getYear() - syntymavuosi.intValue()));
    // HIGHLIGHT_GREEN_END

    lisaaPelaajaButton.setOnAction(event -> {
        Pelaaja uusiPelaaja = new Pelaaja();
        uusiPelaaja.setNimi("Uusi Pelaaja");
        uusiPelaaja.setSyntymavuosi(2000);
        pelaajat.add(uusiPelaaja);
    });

    pelaajatTable.setItems(pelaajat);
}

Funktion muuttaminen Observable-arvoksi

Pelaajien lukumäärä saadaan kutsumalla pelaajat.size(). size()-metodi ei kuitenkaan palauta havaittavaa arvoa, eikä pelaajat-lista sisällä yllä mainittua map()-metodia. Voimme kuitenkin muuntaa minkä tahansa funktion havaittavaksi käyttämällä Bindings-luokan (ks. JavaDoc) createXBinding-apumetodeja. Tässä X tarkoittaa havaittavan arvon tyyppiä, eli esimerkiksi Integer, Long, String tai Object. Koska size() on kokonaisluku, käytämme Bindings.createIntegerBinding()-metodia (ks. JavaDoc).

IntegerBinding pelaajienLkm = Bindings.createIntegerBinding(() -> pelaajat.size(), pelaajat);

Bindings.createIntegerBinding() ottaa vähintään kaksi parametria: lambdalausekkeen, josta havaittava arvo lasketaan ja yhden tai useamman Observable-arvon, jonka muuttuessa havaittava arvo lasketaan uudestaan. Tässä tapauksessa ensimmäinen parametri kertoo, että pelaajienLkm-arvo lasketaan aina lausekkeella pelaajat.size(). Toinen parametri pelaajat kertoo, että arvo on päivitettävä aina, kun pelaajat-listan sisältö muuttuu.

Bindings.createXBinding-metodi palauttaa Binding-tyyppisen havaittavan arvon, jonka voi käyttää samalla tavalla kuin muut Observable-arvot. Tässä tapauksessa voimme sitoa pelaajiaLkmLabel-kentän tekstin textProperty()-arvoon:

public void initialize(URL url, ResourceBundle resourceBundle) {
    nimiColumn.setCellFactory(TextFieldTableCell.forTableColumn());
    nimiColumn.setCellValueFactory(cellData -> cellData.getValue().nimiProperty());

    syntymavuosiColumn.setCellFactory(TextFieldTableCell.forTableColumn(new NumberStringConverter("####")));
    syntymavuosiColumn.setCellValueFactory(cellData -> cellData.getValue().syntymavuosiProperty());

    ikaColumn.setCellValueFactory(cellData -> cellData.getValue().syntymavuosiProperty().map(syntymavuosi -> LocalDate.now().getYear() - syntymavuosi.intValue()));

    lisaaPelaajaButton.setOnAction(event -> {
        Pelaaja uusiPelaaja = new Pelaaja();
        uusiPelaaja.setNimi("Uusi Pelaaja");
        uusiPelaaja.setSyntymavuosi(2000);
        pelaajat.add(uusiPelaaja);
    });

    // HIGHLIGHT_GREEN_BEGIN
    IntegerBinding pelaajienLkm = Bindings.createIntegerBinding(() -> pelaajat.size(), pelaajat);
    pelaajiaLkmLabel.textProperty().bind(pelaajienLkm.asString());
    // HIGHLIGHT_GREEN_END

    pelaajatTable.setItems(pelaajat);
}

Property-tyypin bind()-metodilla voimme sitoa arvon toiseen havaittavaan arvoon. Tässä tapauksessa pelaajiaLkmLabel-kentän teksti sidotaan pelaajien lukumäärään. Tällöin, jos pelaajat-lista muuttuu, niin

  • pelaajienLkm-arvo havaitsee muutoksen ja laskee arvonsa uudestaan lausekkeella pelaajat.size()
  • pelaajienLkm.asString() havaitsee muutoksen pelaajienLkm-arvossa ja päivittää arvonsa kutsumalla pelaajienLkm.toString()
  • pelaajiaLkmLabel.textProperty() havaitsee muutoksen pelaajienLkm.asString()-arvossa ja päivittää oman sisältönsä vastaamaan uutta arvoa

Muutosten jälkeen pelaajien lukumäärä ja yksittäisen pelaajan ikä päivittyy automaattisesti:

Tietojen validointi

Tietojen validoinnilla tarkoitetaan käyttäjän antamien tietojen oikeellisuuden tarkistusta. Validoinnilla varmistetaan, että käyttäjän muutokset eivät tuota kohdealueen kannalta epäkelpoa tietomallia.

Esimerkki

Olkoon meillä seuraavanlainen yksinkertainen sovellus:

MainController.java
public class MainController implements Initializable {
    @FXML
    private ListView<Lemmikki> lemmikitList;

    @FXML
    private TextField nimiField;

    @FXML
    private TextField lajiField;

    @FXML
    private Button lisaaButton;

    ObservableList<Lemmikki> lemmikit = FXCollections.observableArrayList();

    @Override
    public void initialize(URL url, ResourceBundle resourceBundle) {
        lemmikitList.setItems(lemmikit);
        
        lisaaButton.setOnAction(_ -> lisaaLemmikki());
    }

    private void lisaaLemmikki() {
        Lemmikki lemmikki = new Lemmikki();
        lemmikki.setNimi(nimiField.getText());
        lemmikki.setLaji(lajiField.getText());
        lemmikit.add(lemmikki);

        nimiField.clear();
        lajiField.clear();
    }
}

Sovelluksessa on lista, johon voi lisätä lemmikkejä, joita mallinnetaan Lemmikki-luokalla:

Nyt listaan voi lisätä lemmikkejä ilman nimeä tai lajia, mikä ei ole hyvä idea. On syytä estää käyttäjää lisäämästä nimettömiä tai lajittomia lemmikkejä.

Yksinkertainen validointi

Yksinkertaisin validointi voidaan tehdä tarkistamalla kohdealueen vaatimukset ennen tietomallin kohteen luomista. Voisimme siis tarkistaa lisaaLemmikki()-metodissa, onko nimi- ja laji-kentissä tekstiä:

private void lisaaLemmikki() {
    
    // TODO: Suorita tässä kohdassa tarkistus, jonka perusteella 
    // lisätään lemmikki vain, jos tieto on oikein, ts. validia.

    Lemmikki lemmikki = new Lemmikki();
    lemmikki.setNimi(nimi);
    lemmikki.setLaji(laji);
    lemmikit.add(lemmikki);

    nimiField.clear();
    lajiField.clear();
}

Validointimetodi

Validointi on usein helppo tehdä kontrollerissa erillisessä metodissa:

/**
 * Tarkista, onko lemmikki-kenttien sisältö validi. 
 * Jos ei, korosta virheelliset kentät.
 * 
 * @return true, jos validointi onnistuu, muuten false
 */
private boolean validoiLemmikki() {
    // Tyhjennetään kenttien tyylit
    nimiField.setStyle("");
    lajiField.setStyle("");

    // Haetaan kenttien sisällöt
    String nimi = nimiField.getText();
    String laji = lajiField.getText();

    // Validointi: Nimi ei saa olla tyhjä
    if (nimi.isBlank()) {
        // Jos validointi epäonnistuu, korostetaan virheellinen kenttä
        nimiField.setStyle("-fx-border-color: red; -fx-background-color: #ffcccc;");
        return false;
    }

    // Validointi: Laji ei saa olla tyhjä
    if (laji.isBlank()) {
        lajiField.setStyle("-fx-border-color: red; -fx-background-color: #ffcccc;");
        return false;
    }

    // True = Validointi onnistuu
    return true;
}

private void lisaaLemmikki() {
    // Suorita ensin validointi ennen lisäämistä
    if (!validoiLemmikki()) {
        return;
    }

    Lemmikki lemmikki = new Lemmikki();
    lemmikki.setNimi(nimiField.getText());
    lemmikki.setLaji(lajiField.getText());
    lemmikit.add(lemmikki);

    nimiField.clear();
    lajiField.clear();
}

Validoinnin siirto tietomalliin

Testaamisen ja vastuunjaon kannalta voi olla selkeämpää, että validointi on osa tietomallia. Tällöin validointi voidaan toteuttaa esimerkiksi suoraan osana tietomalliluokkaa. Tässä esimerkissä tarkistaVirheet on osa Lemmikki-luokkaa. Toteutetaan samalla myös luetelmatyyppi Tarkistusvirhe, joka kuvaa mahdollisia virhetilanteita.

public enum Tarkistusvirhe  {
    NIMI_TYHJA, LAJI_TYHJA
}

public class Lemmikki {    
    // ...

    // Varsinainen tarkistinmetodi
    public Tarkistusvirhe tarkistaVirheet() {
        if (getNimi().isBlank()) {
            return Tarkistusvirhe.NIMI_TYHJA;
        }
        if (getLaji().isBlank()) {
            return Tarkistusvirhe.LAJI_TYHJA;
        }
        return null;
    }
}

Lemmikin lisääminen tapahtuu edelleen lisaaLemmikki()-metodissa, mutta nyt tarkistetaan tietomallin kohteen validointi suoraan tietomalliluokasta:

private void lisaaLemmikki() {
    nimiField.setStyle("");
    lajiField.setStyle("");

    // Luodaan tietomallin kohde ja asetetaan arvot
    Lemmikki lemmikki = new Lemmikki();
    lemmikki.setNimi(nimiField.getText());
    lemmikki.setLaji(lajiField.getText());

    // Tarkistetaan, onko tietomallin kohde kohdealueen kannalta oikeellinen
    Tarkistusvirhe virheTulos = lemmikki.tarkistaVirheet();
    // Jos virhe löytyy, näytetään käyttäjälle virheilmoitus virheen perusteella
    if (virheTulos != null) {
        if (virheTulos == Tarkistusvirhe.NIMI_TYHJA) {
            nimiField.setStyle("-fx-border-color: red; -fx-background-color: #ffcccc;");
        }
        if (virheTulos == Tarkistusvirhe.LAJI_TYHJA) {
            lajiField.setStyle("-fx-border-color: red; -fx-background-color: #ffcccc;");   
        }
        return;
    }

    // Lisätään lemmikki vain, jos lemmikki on kohdealueen kannalta oikeellinen
    lemmikit.add(lemmikki);

    nimiField.clear();
    lajiField.clear();
}

Null-tarkistusten sijaan on usein huomattavasti mielekkäämpää käyttää Optional-tyyppiä, joka kuvaa paremmin tilannetta, jossa virhe voi joko olla olemassa tai ei.

public Optional<Tarkistusvirhe> tarkistaVirheet() {
    if (getNimi().isBlank()) {
        return Optional.of(Tarkistusvirhe.NIMI_TYHJA);
    }
    if (getLaji().isBlank()) {
        return Optional.of(Tarkistusvirhe.LAJI_TYHJA);
    }
    return Optional.empty();
}

Siinä missä null on näkymätön sopimus ja vaatii erillisen dokumentoinnin (joka käytännössä jää valitettavasti usein tekemättä), Optional-tyyppi kertoo täsmälleen mistä on kysymys. Näin virheen tarkastaminen muuttuu siistimmäksi.

private void lisaaLemmikki() {
    // ...

    Optional<Tarkistusvirhe> virheTulos = lemmikki.tarkistaVirheet();
    // Jos virhe löytyy, näytetään käyttäjälle virheilmoitus virheen perusteella
    if (virheTulos.isPresent()) {
        Tarkistusvirhe virhe = virheTulos.get();
        if (virhe == Tarkistusvirhe.NIMI_TYHJA) {
            nimiField.setStyle("-fx-border-color: red; -fx-background-color: #ffcccc;");
        }
        if (virhe == Tarkistusvirhe.LAJI_TYHJA) {
            lajiField.setStyle("-fx-border-color: red; -fx-background-color: #ffcccc;");   
        }
        return;
    }

    // ...
}

Viitteiden hallinta ja sisäkkäisten property-olioiden kuunteleminen

Löydät tämän esimerkin koodit kokonaisuudessaan GitHubista.

Usein olion on tarpeen viitata toiseen olioon, jotta se voi käyttää toisen olion tietoja tai toimintoja.

Oletetaan, että meillä on seuraava tietomalli.

Sovelluksessa riippuvuus voisi näyttää esimerkiksi seuraavalta.

Tehdään Tehtava-luokkaan StringProperty otsikko ja ObjectProperty<Kategoria> kategoria -kentät, jotka vastaavat yllä esitettyjä JSON-kenttiä. Vastaavasti Kategoria-luokkaan tehdään StringProperty nimi -kenttä.

Tehtava.java
package fi.jyu.ohj2.esimerkit.viitteidenkorjaaminen;

import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;

public class Tehtava {

    private final StringProperty otsikko = new SimpleStringProperty();
    private final ObjectProperty<Kategoria> kategoria = new SimpleObjectProperty<>();

    public Tehtava() {
    }

    public Tehtava(String otsikko, Kategoria kategoria) {
        this.otsikko.set(otsikko);
        this.kategoria.set(kategoria);
    }

    // Otsikko
    public void setOtsikko(String otsikko) {
        this.otsikko.set(otsikko);
    }

    public String getOtsikko() {
        return otsikko.get();
    }

    public StringProperty otsikkoProperty() {
        return otsikko;
    }

    // Kategoria
    public void setKategoria(Kategoria kategoria) {
        this.kategoria.set(kategoria);
    }

    public Kategoria getKategoria() {
        return kategoria.get();
    }

    public ObjectProperty<Kategoria> kategoriaProperty() {
        return kategoria;
    }
}

Tehtävät näyttävät JSON-tiedostossa suurin piirtein tältä:

[
  {
     "otsikko": "Herää",
     "kategoria": "Tärkeä"
  },
  {
    "otsikko": "Mene kouluun",
    "kategoria": "Tärkeä"
  },
  {
    "otsikko": "Syö",
    "kategoria": "Tärkeä"
  },
  {
    "otsikko": "Pelaa Fortniteä",
    "kategoria": "Ei-tärkeä"
  },
  {
    "otsikko": "Mene nukkumaan",
    "kategoria": "Vähemmän tärkeä"
  }
]

Vastaavasti kategoriat näyttäisivät tältä:

[
  {"nimi": "Tärkeä" },
  {"nimi": "Vähemmän tärkeä" },
  {"nimi": "Ei-tärkeä" }
]

Jos kategorian nimeä muutetaan, olisi loogista, että kaikki tehtävät, jotka viittaavat tähän kategoriaan, saavat automaattisesti päivitetyn kategorian nimen, koska ne viittaavat samaan Kategoria-olioon. Alla on kuvattuna tavoitetila, miten haluaisimme, että sovelluksemme toimii:

JSON-tiedostossa tehtävän sisällä kategoria on kuitenkin vain merkkijono, joka toimii kategorian tunnisteena. Kun tuon merkkijonon perusteella aikanaan kontrolleriessa luodaan Tehtava-olio, luodaan uusi Kategoria-olio, joka sisältää saman kategorian nimen, kuin Kategoria-olio, joka luetaan kategoriat.json-tiedostosta. Nämä ovat kaksi eri oliota. Näin ollen mitä tahansa kategorialle tapahtuukaan, muutos ei näy tehtävissä ilman manuaalista päivitystä. Tämä on ongelmallista, koska se rikkoo olioiden välisen yhteyden ja tekee sovelluksesta vaikeammin ylläpidettävän.

Jotta kategorian nimi saadaan päivittymään tehtävälistauksessa ilman manuaalista päivitystä, on

  1. tehtävä-oliossa viitattava oikeaan kategoria-olioon, ja
  2. TableView-olion kategoria-sarakkeen on kuunneltava kategorian nimen muutoksia setCellValueFactory-metodissa käyttäen flatMap-metodia (ks. JavaDoc). Esimerkki tästä on:
kategoriaColumn.setCellValueFactory(cellData -> 
                 cellData
                .getValue()
                .kategoriaProperty()
                .flatMap(kategoria -> kategoria.nimiProperty()));

Katsotaan tätä tarkemmin vaihe vaiheelta.

Korjataan aluksi Tehtava-oliossa olevat Kategoria-olioiden viitteet oikeaksi sovelluksen käynnistämisen jälkeen.

Oletetaan, että meillä on MainController-luokassa attribuutit tehtäville ja kategorioille ja niitä vastaavat GUI-oliot.

public class MainController implements Initializable {
    // ...
    @FXML
    private TableView<Tehtava> tehtavatTable;

    @FXML
    private TableView<Kategoria> kategoriatTable;

    private ObservableList<Tehtava> tehtavat = FXCollections.observableArrayList();
    private ObservableList<Kategoria> kategoriat = FXCollections.observableArrayList();
    // ...
}

Luetaan JSON-tiedostosta kategoriat ja tehtävät.

public void initialize(URL url, ResourceBundle resourceBundle) {
    Path tehtavatPolku = Path.of("tehtavat.json");
    Path kategoriatPolku = Path.of("kategoriat.json");
    ObjectMapper mapper = new ObjectMapper();
    try {
        Kategoria[] k = mapper.readValue(kategoriatPolku.toFile(), Kategoria[].class);
        Tehtava[] t = mapper.readValue(tehtavatPolku.toFile(), Tehtava[].class);
        kategoriat.setAll(k);
        tehtavat.setAll(t);
    } catch (JacksonException e) {
        e.printStackTrace();
    }
}

Lisätään vielä tehtävät ja kategoriat UI-olioihin.

public void initialize(URL url, ResourceBundle resourceBundle) {
    // ...
    TableColumn<Tehtava, String> otsikkoColumn = new TableColumn<>("Otsikko");
    otsikkoColumn.setCellValueFactory(cellData -> cellData.getValue().otsikkoProperty());
    TableColumn<Tehtava, String> kategoriaColumn = new TableColumn<>("Kategoria");
    kategoriaColumn.setCellValueFactory(cellData -> cellData.getValue().kategoriaProperty().asString());
    tehtavatTable.getColumns().addAll(otsikkoColumn, kategoriaColumn);

    TableColumn<Kategoria, String> nimiColumn = new TableColumn<>("Nimi");
    nimiColumn.setCellValueFactory(cellData -> cellData.getValue().nimiProperty());
    kategoriatTable.getColumns().add(nimiColumn);
}

Nyt jos muokkaamme kategorian nimeä, se ei päivity tehtävään.

Korjataan viitteet oikeiksi tiedostojen lukemisen jälkeen.

ObjectMapper mapper = new ObjectMapper();
try {
    Kategoria[] k = mapper.readValue(kategoriatPolku.toFile(), Kategoria[].class);
    Tehtava[] t = mapper.readValue(tehtavatPolku.toFile(), Tehtava[].class);

    kategoriat.setAll(k);
    // HIGHLIGHT_GREEN_BEGIN
    for (Tehtava tehtava : t) {
        Kategoria jsonistaLuettuKategoria = tehtava.getKategoria();
        tehtava.setKategoria(asetaKategoriaViite(jsonistaLuettuKategoria.getNimi()));
    }
    // HIGHLIGHT_GREEN_END
    tehtavat.setAll(t);

} catch (JacksonException e) {
    e.printStackTrace();
}

// Jos tehtävällä ei ole kategoriaa, palautetaan uusi kategoria, 
// joka on vain tyhjä merkkijono.
// HIGHLIGHT_GREEN_BEGIN
private static final Kategoria TYHJA_KATEGORIA = new Kategoria("");

private Kategoria asetaKategoriaViite(String nimi) {
    for (Kategoria ehdokas : kategoriat) {
        if (ehdokas.getNimi().equals(nimi)) {
            return ehdokas;
        }
    }
    return TYHJA_KATEGORIA;
// HIGHLIGHT_GREEN_END
    /* Tai stream-tyyliin:
     * return kategoriat.stream()
     * .filter(ehdokas -> ehdokas.getNimi().equals(kategoria.getNimi()))
     * .findFirst()
     * .orElse(TYHJA_KATEGORIA); 
     */
// HIGHLIGHT_GREEN_BEGIN
}
// HIGHLIGHT_GREEN_END

Nyt kategoriatieto kyllä päivittyy tehtäviin, mutta muutos ei näy heti TableView-oliossa, koska kategoria-sarake kuuntelee vain kategorian viitteen muutoksia.

Ensimmäinen ajatus voisi olla, että lisätään tehtavat-listan ekstraktoriin nimiProperty()-kuuntelija (tehtava.kategoriaProperty().get().nimiProperty()). Valitettavasti tämä ei toimi, koska kategoriaProperty()-kuuntelee kategorian viitteen muutoksia, eikä nimen muuttaminen tuota uutta kategoria-oliota.

Ratkaisu on käyttää flatMap-metodia, joka mahdollistaa sisäkkäisten property-olioiden kuuntelemisen. Lisää oheinen rivi initialize()-metodiin.

TableColumn<Tehtava, String> otsikkoColumn = new TableColumn<>("Otsikko");
otsikkoColumn.setCellValueFactory(cellData -> cellData.getValue().otsikkoProperty());
TableColumn<Tehtava, String> kategoriaColumn = new TableColumn<>("Kategoria");
// HIGHLIGHT_GREEN_BEGIN
kategoriaColumn.setCellValueFactory(
            cellData -> cellData.getValue().kategoriaProperty().flatMap(kategoria -> kategoria.nimiProperty()));
// HIGHLIGHT_GREEN_END
// HIGHLIGHT_RED_BEGIN
kategoriaColumn.setCellValueFactory(cellData -> cellData.getValue().kategoriaProperty().asString());
// HIGHLIGHT_RED_END
tehtavatTableView.getColumns().addAll(otsikkoColumn, kategoriaColumn);

Ilman flatMap-metodia meillä on kaksi erillistä property-tasoa sisäkkäin:

kategoriaProperty()  →  ObjectProperty<Kategoria>
                              ↓
                        nimiProperty()  →  StringProperty

Kumpaakin näistä tasoista halutaan kuunnella (kategorian muutos, kategorian nimen muutos), koska haluamme näyttää koko ajan TableView-sarakkeessa ajantasaisen kategorian nimen.

flatMap yhdistää nämä kaksi tasoa yhdeksi ObservableValue<String>-olioksi, joka reagoi molemmilla tasoilla tapahtuviin muutoksiin. Tätä yhdistämistä kutsutaan "litistämiseksi" (flatten), koska kaksi sisäkkäistä kerrosta muuttuu yhdeksi tasaiseksi kerrokseksi.

Nimi flatMap koostuu kahdesta osasta:

  • Map — muuntaa ulomman propertyn arvon (Kategoria) sisemmäksi propertyksi annetulla funktiolla (kategoria -> kategoria.nimiProperty()).
  • Flat — litistää tuloksen niin, ettei synny "property propertyn sisällä" -rakennetta, vaan yksi tasainen ObservableValue.

Sama nimeämiskäytäntö esiintyy myös esimerkiksi Stream.flatMap- ja Optional.flatMap-metodeissa.

Käytännössä flatMap toimii tässä seuraavasti:

  1. Se kuuntelee ulompaa propertyä (kategoriaProperty()).
  2. Kun ulomman propertyn arvo on olemassa, se kutsuu annettua funktiota (kategoria -> kategoria.nimiProperty()) ja alkaa kuunnella palautettua sisempää propertyä.
  3. Jos ulompi arvo vaihtuu, flatMap lopettaa vanhan sisemmän propertyn kuuntelun ja alkaa kuunnella uuden arvon sisempää propertyä.
  4. Tuloksena on yksi ObservableValue<String>, joka päivittyy aina kun kategorian nimi muuttuu tai kun koko kategoriaviite vaihtuu.