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:
| Tukikanava | Aika | Paikka/Linkki |
|---|---|---|
| Lähiohjaus | ke 10-18, to 10-18, pe 8-14 | Agoralla luokat Ag B212.1 Finland ja Ag B211.1 Sovjet |
| Etäohjaus | ke 10-18, to 10-18, pe 8-14 | Ohjelmointi 2 Teams-kanava |
| Vastuuopettajien ja tuntiopettajien sähköpostiosoite | Jatkuva | ohj2-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)
-
Kirjaudu Sisuun
-
Jos olet jo ilmoittautunut kurssille, klikkaa ylhäällä välilehteä Opintokalenteri tai klikkaa sitä hampurilaisvalikosta
-
Selaa oikealla oikea kurssi näkyville, eli tässä tapauksessa Ohjelmointi 2
-
Klikkaa oikealla olevaa oikealle osoittavaa väkästä Ohjelmointi 2 -kurssin kohdalla
-
Skrollaa alaspäin, kunnes tulee alaotsikko Pääteohjaus
-
Jos ei vielä näy, niin skrollaa alaspäin, kunnes näkyy Muiden ryhmien tiedot ja klikkaa sitä
-
Nyt voit skrollaamalla alaspäin haluamiesi pääteohjauksien kohdalta klikata nappulaa Näytä tapahtumat kalenterissa.

-
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
-
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 muotostudent.jyu.fiei käy. Tunnuksen toimiminen vaatii, että olet hyväksynyt Office 365 -palvelut OMA-palvelussa (https://sso.jyu.fi). -
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ä.
-
Teams-sovelluksessa klikkaa Teams Join or create team Join a team with a code
-
Syötä koodi
nnobn49 -
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:
- 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)
- 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) - Aloita kokous New meeting toiminnolla
- Testaa Audio Test speaker & mikrofone toiminnolla että äänet pelittää
- Ota kokouslinkki talteen Participants Copy invite link
- Avaa ohjauspyyntölomake: https://forms.gle/5QULUPBHjjqS4ndf6
- Täytä omat tietosi ja HUOM Pasteta lisätietokenttään kohdassa 5 kopioimasi linkki
- Odota, että ohjaaja tulee huoneeseesi. Saatat joutua hyväksymään hänen sisäänpääsyn (riippuu kokoushuoneesi asetuksista)
Navigointi tässä materiaalissa
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:
-
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ää.
-
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. -
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):
- Keräät jokaisesta osasta vähintään 50% siitä pistemäärästä mitä harjoitustehtävien perustehtävistä voi saada1
- Teet harjoitustyön aikataulussa
- 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ään | Harjoitustehtä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:
| Osa | Takaraja DL-BONUS-pisteille |
|---|---|
| 1 | ma 19.1.2026 klo 11:59 (keskipäivä) |
| 2 | ma 26.1.2026 klo 11:59 (keskipäivä) |
| 3 | ma 2.2.2026 klo 11:59 (keskipäivä) |
| 4 | ma 9.2.2026 klo 11:59 (keskipäivä) |
| 5 | ma 16.2.2026 klo 11:59 (keskipäivä) |
| 6 | ma 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.
| Esim1 | Esim2 | Esim3 | |
|---|---|---|---|
| Osuus harjoitustehtävistä (sis DL-BONUS) | 70% | 90% | 70% |
| Harjoitustehtävien arvosana | 3 | 5 | 3 |
| Tentin arvosana | 1 | 4 | 5 |
| Painotettu keskiarvo | 1.8 | 4.4 | 4.6 |
| Pyöristetty arvosana | 2 | 4 | 5 |
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
- Keräät vähintään 100% pistettä harjoitustehtävistä (kaikista osista yhteensä) JA keräät kaikki DL-BONUS-pisteet (6 * 0.5 = 3 pistettä)
- Teet harjoitustyön aikataulussa
- Osallistut suulliseen kuulusteluun, jossa ohjaaja arvioi harjoitustyösi
Arvosanasi on tällöin 1, jota voit vapaaehtoisesti korottaa tentillä.
Suoritustapa 3
Harjoitustyö ja loppukoe.
- Teet harjoitustyön
- 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
- 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:
Esivalmistelut
Git
IntelliJ IDEA
Java Development Kit (JDK)
Avaa IntelliJ IDEA ja odota, kunnes pääset Welcome to IntelliJ IDEA -näkymään.
Klikkaa ikkunan keskellä tai ylädassa olevaa New Project -painiketta:
Avautuneesta ikkunasta klikkaa JDK-alasvetolaatikkoa ja valitse Download JDK... -painike:
Aseta avautuneessa ikkunassa asetukset seuraavasti:
- Version: 25
- Vendor: Oracle OpenJDK Älä muuta Location-kohdassa olevaa polkua! Paina lopuksi Select-painiketta.
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ää.
Kun projekti on latautunut eikä virheitä näy, kokeile ajaa projekti painamalla oikeassa ylälaidassa olevaa Play-painiketta:
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 = 5Voit nyt sulkea IntelliJ IDEA:n.
SceneBuilder
ComTest
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.
Klikkaa ikkunan vasemmassa alalaidassa oleva Configure (Ratas-ikoni) Settings.
Valitse vasemmalla puolella olevista asetusnäkymistä Plugins
Valitse Marketplace-välilehti ja hae hakusanalla
ComTestValitse Comtest Runner -pluginin kohdalta Install
Paina Save
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:
-
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.
-
Klikkaa ikkunan vasemmassa alalaidassa oleva Configure (Ratas-ikoni) Settings.
-
Valitse vasemmalla puolella olevista asetusnäkymistä Editor File Encodings
-
Aseta Create UTF-8 files -asetuksen arvoksi with no BOM.
-
Paina Save.
-
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ä.
- Sulje IntelliJ IDEA kokonaan.
- Avaa Rider.
- Sulje Rider.
- 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ää.

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

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
-
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 ollaKorttijaKorttipakka. Kulujen hallintasovelluksessa taas sopivat mallinnettavat asiat olisivatTapahtumajaKategoria. -
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 attribuutittehty,otsikko,kuvausjaprioriteetti.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 attribuuttinatehtavat-kokoelman, joka on vain kokoelma viitteitä tehtäviin, ja siten sitä ei laskettaisi tähän vaatimukseen mukaan. Sen sijaan muistikorttisovelluksessaKorttipakkasisältää korttikokoelman lisäksi korttipakan otsikon ja kuvauksen, jotka lasketaan sovelluksen kannalta oleellisiksi ja korttipakalle ominaisiksi. -
Sovelluksen dataa ei mallinneta käyttöliittymäkomponenteilla, vaan omilla malliluokilla.
-
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
ObservableListtai vastaava. -
Datan esittämiseen käyttöliittymässä käytetään tarkoituksenmukaista komponenttia.
Jos työssä on useita samantyyppisiä olioita,
TableViewon yleensä luonteva ratkaisu.
Vaatimus 2: Perustoiminnallisuus
-
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. -
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ä.
-
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
-
Sovelluksen tiedot tallennetaan tiedostoon.
Tiedot säilyvät ohjelman sulkemisen jälkeen. Tallennus voi tapahtua automaattisesti tai erillisenä "Tallenna"-toimintona.
-
Tallennetut tiedot ladataan takaisin ohjelman käynnistyessä
Vaatimus 4: Käyttöliittymä
-
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).
-
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
-
Sovelluksen rakenne noudattaa MVC-mallia (Model-View-Controller).
Pääasia on, että tietomalli, käyttöliittymä ja niitä yhteen kytkevä ohjainlogiikka on erotettu toisistaan.
-
Sovelluksen data ja tallennuslogiikka on erotettu ohjainluokasta.
Käyttöliittymän ohjain ei saa sisältää kaikkea sovelluksen dataa ja tallennuslogiikkaa.
-
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
-
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
-
Työlle on luotu julkinen Git-etävarasto (esim. GitLab tai GitHub).
-
Projektissa on
.gitignore-tiedosto jaREADME.md.README-tiedostossa kerrotaan lyhyesti, mikä sovellus on kyseessä ja miten se toimii.
-
Git-commitit on nimetty kuvaavasti.
Commiteista käy ilmi, mitä muutoksia kukin niistä sisältää.
-
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
-
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 ().
-
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).
-
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 ovatpublic, kun taas luokan omat apumetodit ovatprivate.
Tentti
Lukuvuonna 2025-2026 tenttejä järjestetään seuraavasti
| Tentti | Päivämäärä | Aika | Paikka | Ilmoittautumislinkki |
|---|---|---|---|---|
| Kevät 1 | ke 22.4.2026 | klo 10-14 | Agora Auditorio 1 / Zoom | Ilmoittaudu |
| Kevät 2 | ke 6.5.2026 | klo 12-16 | Agora Auditorio 2 / Zoom | Ilmoittaudu |
| Kevät 3 | ke 27.5.2026 | klo 12-16 | Agora Auditorio 2 / Zoom | Ilmoittaudu |
| Kesä 1 | 18.6.2026 | klo 12-16 | Agora / Zoom | Ilmoittaudu |
| Kesä 2 | 5.8.2026 | klo 12-16 | Agora / Zoom | Ilmoittaudu |
| Syksy 1 | pp.kk.2026 | klo xx-xx | Agora / Zoom | Ilmoittaudu |
| Syksy 2 | pp.kk.2026 | klo xx-xx | Agora / Zoom | Ilmoittaudu |
| Syksy 3 | pp.kk.2026 | klo xx-xx | Agora / Zoom | Ilmoittaudu |
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.
- Vaihtoehtoisesti laita sähköpostia osoitteeseen
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ä.
| Luento | Päivämäärä | Sijainti | Striimi ja nauhoite | Materiaalit |
|---|---|---|---|---|
| Luento 1: Java-kielen perusteet | ma 12.1.2026 klo 10.15 | Ag Auditorio 3 | YouTube, Moniviestin | Kalvot |
| Luento 2: Olio-ohjelmoinnin perusteet | ma 19.1.2026 klo 14.15 | Ag Auditorio 3 | YouTube, Moniviestin | Kalvot, Koodit |
| Luento 3: Perintä, polymorfismi | ma 26.1.2026 klo 14.15 | Ag Auditorio 3 | YouTube, Moniviestin | Kalvot, Koodit |
| Luento 4: Rajapinta, geneeriset luokat | ma 2.2.2026 klo 14.15 | Ag Auditorio 3 | YouTube, Moniviestin | Kalvot, Koodit |
| Luento 5: Tietorakenteita ja algoritmeja | vain nauhoite | YouTube, Moniviestin | Kalvot, Koodit | |
| Luento 6: Hyödyllisiä menetelmiä Javassa | ma 16.2.2026 klo 14.15 | Ag Auditorio 3 | YouTube (osa 1), Moniviestin (osa 1), YouTube (osa 2), Moniviestin (osa 2) | Koodit |
| Luento 7: JavaFX, osa 1 | ma 23.2.2026 klo 14.15 | Ag Auditorio 3 | YouTube (osa 1), Moniviestin (osa 1), YouTube (osa 2), Moniviestin (osa 2) | |
| Luento 8: JavaFX, osa 2 | ma 2.3.2026 klo 14.15 | Ag Auditorio 3 | YouTube (T8.1-T8.2), Moniviestin (T8.1-T8.2), YouTube (T8.2-T8.8), Moniviestin (T8.2-T8.8) | |
| Luento 9: Harjoitustyö, vaihe 1 | ma 9.3.2026 klo 14.15 | Ag Auditorio 3 | YouTube, 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ä:
-
Java-ohjelman suoritus alkaa
main-nimisestä aliohjelmasta.voidtarkoittaa, 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{. -
Tekstin tulostaminen komentorivi-ikkunaan onnistuu
IO.println-metodilla. Javassa lause loppuu yleensä puolipisteeseen;, kuten tässäkin. -
Aliohjelman runko lopetetaan aaltosululla
}. Ohjelman suoritus päättyy automaattisesti, kunmain-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, kutenif-,for-,while- jado-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:
-
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.
-
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):
-
-
Paina Create. Tämän jälkeen IDEA luo uuden projektin valitsemaasi kansioon.
Käydään pikaisesti läpi IDEAn olennaisimmat osat:
- Koodialue: projektissa olevien tiedostojen sisällöt näkyvät tässä, kun ne avataan. Kukin avattu tiedosto avautuu omaan välilehteen.
- Projektiselain: projektissa olevat kansiot ja tiedostot näkyvät tässä. Selaimen kautta voidaan lisätä, poistaa, siirtää tai uudelleennimetä tiedostoja ja kansioita.
- Projektin ajaminen ja debuggaus: tässä näkyy ajettavan Java-ohjelman nimi, ohjelman ajopainike () ja debuggauspainike ().
- 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:
-
Klikkaa hiiren toissijaisella painikkeella projektin nimeä projektinäkymässä (
HelloWorld) ja valitse New Module. -
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.
-
Klikkaa projektiselaimessa olevaa
HelloProgram-moduulin alasvetopainiketta. Sen jälkeen klikkaa toissijaisella hiiren painikkeellasrc-kansiosta ja valitse New Java Compact File. -
Anna lähdekooditiedoston nimeksi
Ohjelmaja 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:
-
Poista kaikki koodi
Ohjelma.java-tiedostosta.IDEA lisää yleensä valmista pohjakoodia uusiin lähdekooditiedostoihin. Tätä harjoitusta varten kirjoitamme kuitenkin koodia itse.
-
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 kirjoittamallamain. 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ä kursorimain-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:
-
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ä. -
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
.ideaworkspace.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 sulkeaOhjelma.javaja 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:
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.imlon projektin asetustiedosto, jolla IDEA tunnistaa kansion olevan Java-projektiHelloProgramon lähdekoodikansio, jossa kaikki lähdekooditiedostot sijaitsevatouton 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.imlon moduulin asetustiedosto, jolla IDEA tunnistaa kansion olevan Java-moduulisrcon 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ä:
| Aliohjelma | Esimerkki | Selitys |
|---|---|---|
println | IO.println("Moi!");
| Tulostaa parametrina annetun arvon ja lisää loppuun rivinvaihdon |
IO.println();
| Tulostaa rivin rivinvaihdolla | |
print | IO.print("Samalla rivillä!");
| Tulostaa parametrina annetun arvon ilman rivinvaihtoa |
readln | IO.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.
| Tyyppi | Koko (tavua /bittiä) | Lukualue (suuntaa antava) |
|---|---|---|
byte | 1 tavu (8 bittiä) | -128 ... 127 |
short | 2 tavua (16 bittiä) | -32 768 ... 32 767 |
int | 4 tavua (32 bittiä) | n. -2 miljardia ... 2 miljardia |
long | 8 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.
| Tyyppi | Koko (tavua) | Tarkkuus |
|---|---|---|
| float | 4 tavua (32 bittiä) | n. 7 merkitsevää numeroa |
| double | 8 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, esimerkiksi42,-7ja0.long-luvun literaali päättyy isoon tai pieneen kirjaimeenLtail, esimerkiksi12345678901L. - Liukuluvut (
float,double): Kirjoitetaan desimaalipisteellä erotettuna, esimerkiksi3.14,-0.001ja2.0. Voidaan käyttää myös tieteellistä muotoa:1.5e3(eli 1.5 × 10³ = 1500) ja2.0E-4(eli 2.0 × 10⁻⁴ = 0.0002). Oletuksena desimaaliluvut ovatdouble-tyyppiä. Jos haluat luodafloat-luvun, literaalin tulee päättyä isoon tai pieneen kirjaimeenFtaif, esimerkiksi3.14f. - Totuusarvot (
boolean): Kirjoitetaan avainsanoinatruejafalse.
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.
| Alkeistietotyyppi | Käärijäluokka |
|---|---|
| byte | Byte |
| short | Short |
| int | Integer |
| long | Long |
| float | Float |
| double | Double |
| char | Character |
| boolean | Boolean |
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ä.
| Metodi | Selitys |
|---|---|
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.
| Metodi | Selitys |
|---|---|
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:
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.
| Metodi | Selitys |
|---|---|
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.
| Toiminto | Java | C# | Python |
|---|---|---|---|
| Lukeminen tietystä paikasta | list.get(i) | list[i] | list[i] |
| Listan koko | list.size() | list.Count | len(list) |
| Poistaminen | list.remove(i) | list.RemoveAt(i) | list.pop(i) |
| Onko lista tyhjä? | list.isEmpty() tai list.size() == 0 | list.Count == 0 | if 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 kokonaislukuint).
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.
| Operaattori | Merkitys | Esimerkki (kun x=5, y=3) | Tulos |
|---|---|---|---|
| == | Yhtä suuri kuin | x == y | false |
| != | Eri suuri kuin | x != y | true |
| > | Suurempi kuin | x > y | true |
| < | Pienempi kuin | x < 4 | false |
| >= | Suurempi tai yhtä suuri | x >= 5 | true |
| <= | Pienempi tai yhtä suuri | y <= 3 | true |
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ä koodiacontinue: 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.lengthantaa taulukossa olevien taulukoiden lukumäärän, eli ns. "rivien" lukumäärän;taulu2D[riviNro]antaa tietyssä indeksissä olevanint[]-taulukon, joka sisältää kaikki rivillä olevat alkiot;taulu2D[riviNro][sarakeNro]antaataulu2D[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 onvoid. - (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:

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.
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 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 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 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:
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 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 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 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 })palauttaa2puuttuvaLuku(new int[] { 8, 2, 4, 1, 3, 5, 6 })palauttaa7puuttuvaLuku(new int[] { })palauttaa1puuttuvaLuku(new int[] { 2 })palauttaa1
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);
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.
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.
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ä.
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.
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.
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.
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.
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.
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.
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);
}
}
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 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.
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.
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ä.
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.
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.
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?
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.
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.
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.
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
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.
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
Kapselointi
osaamistavoitteet
- Tiedät, mitä näkyvyysmääreet kuten
publicjaprivatetarkoittavat. - 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.

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.
| Luokka | Pakkaus | Aliluokka | Muu maailma | |
|---|---|---|---|---|
public | Kyllä | Kyllä | Kyllä | Kyllä |
protected | Kyllä | Kyllä | Kyllä | Ei |
| oletus | Kyllä | Kyllä | Ei | Ei |
private | Kyllä | Ei | Ei | Ei |
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.
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.
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.
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.
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.
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ä.
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.
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.
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.
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
Toteuta luokka Ovi, joka mallintaa ovea, joka voi olla joko lukossa tai auki.
Attribuutit:
private boolean lukossaprivate 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. Palauttaatrue, jos ovi avattiin, muutenfalse.boolean lukitse(): lukitsee oven vain, jos se on auki. Palauttaatrue, jos lukitseminen onnistui, muutenfalse.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. Palauttaatrue, jos vaihto onnistui, muutenfalse.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
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ä).
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.
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.
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.
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 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.
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.
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 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.
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
Toteuta luokka Ovi, joka mallintaa ovea, joka voi olla joko lukossa tai auki.
Attribuutit:
private boolean lukossaprivate 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. Palauttaatrue, jos ovi avattiin, muutenfalse.boolean lukitse(): lukitsee oven vain, jos se on auki. Palauttaatrue, jos lukitseminen onnistui, muutenfalse.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. Palauttaatrue, jos vaihto onnistui, muutenfalse.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
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ä).
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.
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.
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ä.
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.
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.
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.
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
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.
Jatketaan edellistä tehtävää. Peri Tuote-luokasta myös luokka Elektroniikka.
Lisää erityispiirteitä kuhunkin aliluokkaan:
Vaate: attribuuttiString koko(esim. "M", "L", jne.), metodivoid sovita(String sovittajanKoko), joka tulostaa, onko vaate sopiva sovittajalle.Elektroniikka: attribuuttiint takuuKuukausina(esim. 24), metodiint takuutaJaljella(int kuukausiaKulunut)palauttaa montako kuukautta takuuta on jäljellä (tai 0, jos takuu on umpeutunut).Ruoka: attribuuttiString parastaEnnen(esim. "31.01.2026"), ja metodivoid syo(), joka tulostaa "Nautit ruoan, jonka viimeinen käyttöpäivä on DD.MM.YYYY." (korvaa DD.MM.YYYYparastaEnnen-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.
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.
-
Puhelin(periiElektroniikka)- 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 0401122330public 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).
- Lisää attribuutit:
-
Pakaste(periiRuoka)- 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.
- Lisää attribuutti:
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()

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:
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.
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.
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.
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()rajapintaanViesti. - 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.
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.
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
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)
- vakio
- metodit
lataa(), joka heikentää akun kuntoa 0.1%:lla jokaisella latauskerralla.toString(), joka kutsuu ensin yliluokan metodiatoSTring(), jonka jälkeen tulostaa akun kunnon prosentteina ja sitten toimintasäteen kilometreinä, jonka laskemiseen hyödynnetään kaavaa: (akunkunto / 100 * TOIMINTASADE_MAX).
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.
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.
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.

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.
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.
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.
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.
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 resepti, 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
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>"
Muuta Tehtävän 3.6 Ajoneuvo-luokka ja sen liiku()-metodi abstrakteiksi. Jätä
toString()-metodi edelleen tavalliseksi (ei-abstraktiksi) metodiksi.
-
Tee abstrakti luokka
Viestikanava. Sillä on attribuuttiString vastaanottaja, joka asetetaan konstruktorissa. Lisää abstrakti metodilahetaSisaisesti(String viesti), joka ei palauta mitään. -
Tee myös metodi
String getVastaanottaja(), joka palauttaa vastaanottajan. -
Tee konkreettinen metodi
laheta(String viesti), joka aluksi lopettaa metodin (return), jos viesti on tyhjä tainull. Muuten metodi kutsuu abstraktia metodialahetaSisaisesti(String viesti). -
Peri
Viestikanava-luokastaSahkopostijaTekstiviesti. Molemmissa luokissa ylikirjoita abstrakti metodilahetaSisaisesti(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 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):
- Muuta
Viestikanava-luokkaa siten, että se ottaa listan vastaanottajia, ei vain yhtä. Tämän seurauksena pitää muuttaa myöslahetaSisaisesti-metodeja. - Laita
Tekstiviesti-luokkaan merkkiraja (esim. 80 merkkiä). Jos viesti on tätä pidempi, niin viesti tulee pilkkoa merkkirajan mukaisiin pätkiin.
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.
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.
Jatketaan edellistä tehtävää. Peri Tuote-luokasta myös luokka Elektroniikka.
Lisää erityispiirteitä kuhunkin aliluokkaan:
Vaate: attribuuttiString koko(esim. "M", "L", jne.), metodivoid sovita(String sovittajanKoko), joka tulostaa, onko vaate sopiva sovittajalle.Elektroniikka: attribuuttiint takuuKuukausina(esim. 24), metodiint takuutaJaljella(int kuukausiaKulunut)palauttaa montako kuukautta takuuta on jäljellä (tai 0, jos takuu on umpeutunut).Ruoka: attribuuttiString parastaEnnen(esim. "31.01.2026"), ja metodivoid syo(), joka tulostaa "Nautit ruoan, jonka viimeinen käyttöpäivä on DD.MM.YYYY." (korvaa DD.MM.YYYYparastaEnnen-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.
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.
-
Puhelin(periiElektroniikka)- 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 0401122330public 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).
- Lisää attribuutit:
-
Pakaste(periiRuoka)- 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.
- Lisää attribuutti:
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)
- vakio
- metodit
lataa(), joka heikentää akun kuntoa 0.1%:lla jokaisella latauskerralla.toString(), joka kutsuu ensin yliluokan metodiatoSTring(), jonka jälkeen tulostaa akun kunnon prosentteina ja sitten toimintasäteen kilometreinä, jonka laskemiseen hyödynnetään kaavaa: (akunkunto / 100 * TOIMINTASADE_MAX).
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.
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 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>"
Muuta Tehtävän 3.6 Ajoneuvo-luokka ja sen liiku()-metodi abstrakteiksi. Jätä
toString()-metodi edelleen tavalliseksi (ei-abstraktiksi) metodiksi.
-
Tee abstrakti luokka
Viestikanava. Sillä on attribuuttiString vastaanottaja, joka asetetaan konstruktorissa. Lisää abstrakti metodilahetaSisaisesti(String viesti), joka ei palauta mitään. -
Tee myös metodi
String getVastaanottaja(), joka palauttaa vastaanottajan. -
Tee konkreettinen metodi
laheta(String viesti), joka aluksi lopettaa metodin (return), jos viesti on tyhjä tainull. Muuten metodi kutsuu abstraktia metodialahetaSisaisesti(String viesti). -
Peri
Viestikanava-luokastaSahkopostijaTekstiviesti. Molemmissa luokissa ylikirjoita abstrakti metodilahetaSisaisesti(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 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):
- Muuta
Viestikanava-luokkaa siten, että se ottaa listan vastaanottajia, ei vain yhtä. Tämän seurauksena pitää muuttaa myöslahetaSisaisesti-metodeja. - Laita
Tekstiviesti-luokkaan merkkiraja (esim. 80 merkkiä). Jos viesti on tätä pidempi, niin viesti tulee pilkkoa merkkirajan mukaisiin pätkiin.
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.
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ä.
/**
* 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.
/**
* 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.
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
-
Luo rajapinta nimeltään
Muunnin. Määrittele rajapintaan yksi metodi:String muunna(String syote). Muista, että rajapinnassa metodilla ei ole runkoa (ei aaltosulkeita{}). -
Tee luokat
PienetKirjaimet,IsotKirjaimetjaIsoAlkukirjain, jotka toteuttavatMuunnin-rajapinnan.
PienetKirjaimet-luokanmuunna-metodi muuntaa annetun merkkijonon pieniksi kirjaimiksi.muunna("Hei Maa")-->"hei maa".IsotKirjaimet-luokanmuunna-metodi muuntaa annetun merkkijonon suuraakkosiksi.muunna("Hei Maa")-->"HEI MAA".IsoAlkukirjain-luokanmuunna-metodi muuntaa annetun merkkijonon siten, että vain ensimmäinen kirjain on suuraakkonen ja muut pieniä.muunna("HEI MAA")-->"Hei maa".
- Testaa ohjelmaasi valmiiksi annetulla pääohjelmalla.
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.
- Luo rajapinta
Salaaja. Määrittele rajapintaan kaksi metodia
String salaa(String viesti);
String pura(String salattuViesti);
- Toteuta kolme erilaista luokkaa:
Kaantaja,HakkerijaSeuraavaKirjain, jotka toteuttavatSalaaja-rajapinnan seuraavilla logiikoilla:
-
Kaantaja(Peilikuvakirjoitus). Kääntää sanan väärinpäin. Esimerkki: "Agentti" → "ittnegA". Vihje: Voit käyttääStringBuilder-luokanreverse()-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: Javassacharon 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.
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:
| Tapaus | Merkitys | Tulkinta |
|---|---|---|
olioA.compareTo(olioB) < 0 | olioA < olioB | olioA on pienempi kuin olioB |
olioA.compareTo(olioB) == 0 | olioA == olioB | olioA on yhtä suuri kuin olioB |
olioA.compareTo(olioB) > 0 | olioA > olioB | olioA 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.
Tutki Javan dokumentaatiota. Vastaa kysymyksiin Comparable-rajapinnasta.
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:
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:
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:
// 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.
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ä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ä:
- Ilmatar
- Joukahainen
- Kokko
- Kyllikki
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ä:
- Chopin Frédéric
- Mozart Leopold
- Mozart Wolfgang Amadeus
- Pacius Fredrik
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")

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:
LeivanpaahdinonKeittiolaite(perintä), mutta se myös toteuttaaVerkkovirtalaite-rajapinnan.- Samoin
Sirkkelivoisi olla vaikkapaTyokalu(perintä), joka myöskin toteuttaa samanVerkkovirtalaite-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
- Verkkovirtalaite.java
- Leivanpaahdin.java
- Sirkkeli.java
- Keittiolaite.java
- Tyokalu.java
- Pistorasia.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, vaanList<Keittiolaite>-listassa. Tällöin kaikilla listan olioilla on luonnostaanpuhdista()-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- taiTyokalu-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.
| Kysymys | Abstrakti luokka | Rajapinta |
|---|---|---|
| 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 luokan | Luokka voi toteuttaa useita rajapintoja |
| Käyttötarkoitus | Yhteinen runko ja osittainen toteutus | Yhteinen sopimus käyttäytymisestä |
Tehtävät

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 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. Joskohdeon 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ö.
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
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.
- 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.
- 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.
- 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
}
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 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().
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:
-
lisaalisää parametrina annetun olion listan loppuun. -
otapalauttaa viimeisimmän olion ja ottaa sen pois listasta. -
katsopalauttaa viimeisimmän olion, mutta ei ota sitä pois listasta. -
sisaltaaottaa parametrina olion ja palauttaatrue, jos olio löytyy kontista. Muussa tapauksessa se palauttaafalse. -
tulostatulostaa 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.
Lisää edellisen tehtävän IsoKontti-luokkaan kaksi metodia.
-
Luokan metodi (static)
summaaNumerotottaa parametrinaIsoKontti-olion, joka sisältää numeroita eliNumber-luokan tai sen alityyppien olioita. Metodi palauttaa kontin numeroiden summan. -
Olion metodi
siirraKaikkiottaa parametrina toisenIsoKontti-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.
-
Kaikilla
Number-luokan olioilla ondoubleValue()-metodi, joka palauttaa sen arvondouble-muodossa. -
Huomaa, että konttien tyyppien ei tarvitse olla täysin samat;
Number-kontti voi sisältääInteger-olioita, silläIntegeron sen alityyppi.
Tee geneerinen funktio, joka kopioi yhdestä listasta kaikki tyyppiä T vastaavat alkiot toiseen listaan, jonka tyyppi voi olla T tai sen ylityyppi.
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.
-
Luo rajapinta nimeltään
Muunnin. Määrittele rajapintaan yksi metodi:String muunna(String syote). Muista, että rajapinnassa metodilla ei ole runkoa (ei aaltosulkeita{}). -
Tee luokat
PienetKirjaimet,IsotKirjaimetjaIsoAlkukirjain, jotka toteuttavatMuunnin-rajapinnan.
PienetKirjaimet-luokanmuunna-metodi muuntaa annetun merkkijonon pieniksi kirjaimiksi.muunna("Hei Maa")-->"hei maa".IsotKirjaimet-luokanmuunna-metodi muuntaa annetun merkkijonon suuraakkosiksi.muunna("Hei Maa")-->"HEI MAA".IsoAlkukirjain-luokanmuunna-metodi muuntaa annetun merkkijonon siten, että vain ensimmäinen kirjain on suuraakkonen ja muut pieniä.muunna("HEI MAA")-->"Hei maa".
- Testaa ohjelmaasi valmiiksi annetulla pääohjelmalla.
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.
- Luo rajapinta
Salaaja. Määrittele rajapintaan kaksi metodia
String salaa(String viesti);
String pura(String salattuViesti);
- Toteuta kolme erilaista luokkaa:
Kaantaja,HakkerijaSeuraavaKirjain, jotka toteuttavatSalaaja-rajapinnan seuraavilla logiikoilla:
-
Kaantaja(Peilikuvakirjoitus). Kääntää sanan väärinpäin. Esimerkki: "Agentti" → "ittnegA". Vihje: Voit käyttääStringBuilder-luokanreverse()-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: Javassacharon 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.

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 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. Joskohdeon 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ö.
Tutki Javan dokumentaatiota. Vastaa kysymyksiin Comparable-rajapinnasta.
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ä:
- Ilmatar
- Joukahainen
- Kokko
- Kyllikki
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ä:
- Chopin Frédéric
- Mozart Leopold
- Mozart Wolfgang Amadeus
- Pacius Fredrik
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 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().
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:
-
lisaalisää parametrina annetun olion listan loppuun. -
otapalauttaa viimeisimmän olion ja ottaa sen pois listasta. -
katsopalauttaa viimeisimmän olion, mutta ei ota sitä pois listasta. -
sisaltaaottaa parametrina olion ja palauttaatrue, jos olio löytyy kontista. Muussa tapauksessa se palauttaafalse. -
tulostatulostaa 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.
Lisää edellisen tehtävän IsoKontti-luokkaan kaksi metodia.
-
Luokan metodi (static)
summaaNumerotottaa parametrinaIsoKontti-olion, joka sisältää numeroita eliNumber-luokan tai sen alityyppien olioita. Metodi palauttaa kontin numeroiden summan. -
Olion metodi
siirraKaikkiottaa parametrina toisenIsoKontti-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.
-
Kaikilla
Number-luokan olioilla ondoubleValue()-metodi, joka palauttaa sen arvondouble-muodossa. -
Huomaa, että konttien tyyppien ei tarvitse olla täysin samat;
Number-kontti voi sisältääInteger-olioita, silläIntegeron sen alityyppi.
Tee geneerinen funktio, joka kopioi yhdestä listasta kaikki tyyppiä T vastaavat alkiot toiseen listaan, jonka tyyppi voi olla T tai sen ylityyppi.
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
equalsjahashCode-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".
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.
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.
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.
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.
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.
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 ainatrue. - Symmetrisyys: jos
x.equals(y)ontrue, niiny.equals(x)ontrue. - Transitiivisuus: jos
x.equals(y)jay.equals(z), niinx.equals(z). - Johdonmukaisuus: useat kutsut samalla datalla antavat saman tuloksen.
x.equals(null)on ainafalse.
Tyypillinen equals-toteutus etenee seuraavasti:
- Jos viitteet ovat samat, palauta
true. - Jos toinen on
nulltai tyyppi ei täsmää, palautafalse. - 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:
- Lokerointi:
hashCode-metodi tiivistää olion tiedot yhdeksi kokonaisluvuksi, jota kutsutaan hajautusarvoksi. Kokoelma käyttää tätä lukua päättääkseen, mihin lokeroon olio tallennetaan. - Nopeus: Kun etsit oliota kokoelmasta, Java laskee etsittävän olion hajautusarvon ja hyppää suoraan oikeaan lokeroon.
- 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) == truex.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
equalserottelee 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.
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.
Hakurakenteet
osaamistavoitteet
- Tunnet Java-kielen yleisimmät valmiit tietorakenteet:
Mapja sen toteutuksetHashMap,LinkedHashMapjaTreeMap. - 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:
- Hajautusarvo: Avaimelle (key) lasketaan kokonaisluku hashCode-metodin avulla.
- 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:
-
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. -
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:
| Rakenne | Etu | Haitta |
|---|---|---|
| HashMap | Yksittäisen avaimen haku on keskimäärin nopeampaa (). | Järjestys katoaa. Aikavälihaut vaatisivat kaikkien alkioiden läpikäynnin tai erillisen lajittelun. |
| LinkedHashMap | Säilyttää lisäysjärjestyksen. | Ei takaa aikajärjestystä, jos dataa ei syötetä kronologisesti. |
| PriorityQueue | Nopea 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.
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 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:
-
lisaaVarausottaa 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 palauttaatrue, jos uusi varaus lisätään tietorakenteeseen , muutenfalse. -
poistaVarausottaa parametrina päivämäärän ja poistaa sille päivälle sijoittuvan varauksen. Metodi palauttaatrue, jos varaus poistetaan tietorakenteesta, muutenfalse. -
tulostaVarauksetottaa 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ä.
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.
Joukko- ja jonorakenteet
osaamistavoitteet
- Tunnet Java-kielen yleisimmät valmiit tietorakenteet:
Set,Queue,Dequeja niiden toteutuksetHashSetjaArrayDeque. - 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
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 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.
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:
| Merkkijono | Tulos | Selite |
|---|---|---|
| "" | 0 | Tyhjä syöte on validi, 0 paria. |
| "()" | 1 | Yksi ehjä pari. |
| "(())" | 2 | Kaksi sisäkkäistä paria. |
| "([{}])" | 3 | Kolme sisäkkäistä paria. |
| "()[]{}" | 3 | Kolme vierekkäistä paria. |
| "a(b)c" | 1 | Kirjaimet sivuutetaan, yksi pari. |
| "(" | -1 | Sulkeva pari puuttuu. |
| "(()" | -1 | Yksi sulkeva pari puuttuu. |
| "()}" | -1 | Ylimääräinen sulkeva sulku. |
| ")(" | -1 | Väärä järjestys (alkava sulku puuttuu alussa). |
| "([)]" | -1 | Sulut 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).
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.
| Vaihe | Pino (alhaalta → ylös) | Mitä tapahtuu |
|---|---|---|
| 1 | summa(3) | Odottaa summa(2) |
| 2 | summa(3), summa(2) | Odottaa summa(1) |
| 3 | summa(3), summa(2), summa(1) | Odottaa summa(0) |
| 4 | summa(3), summa(2), summa(1), summa(0) | Perustapaus: palauttaa 0 |
| 5 | summa(3), summa(2), summa(1) | Paluu: summa(1) = 1 + 0 = 1 |
| 6 | summa(3), summa(2) | Paluu: summa(2) = 2 + 1 = 3 |
| 7 | summa(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));
}
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.
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.
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ä.
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.
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.
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.
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.
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 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 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:
-
lisaaVarausottaa 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 palauttaatrue, jos uusi varaus lisätään tietorakenteeseen , muutenfalse. -
poistaVarausottaa parametrina päivämäärän ja poistaa sille päivälle sijoittuvan varauksen. Metodi palauttaatrue, jos varaus poistetaan tietorakenteesta, muutenfalse. -
tulostaVarauksetottaa 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ä.
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.
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 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.
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:
| Merkkijono | Tulos | Selite |
|---|---|---|
| "" | 0 | Tyhjä syöte on validi, 0 paria. |
| "()" | 1 | Yksi ehjä pari. |
| "(())" | 2 | Kaksi sisäkkäistä paria. |
| "([{}])" | 3 | Kolme sisäkkäistä paria. |
| "()[]{}" | 3 | Kolme vierekkäistä paria. |
| "a(b)c" | 1 | Kirjaimet sivuutetaan, yksi pari. |
| "(" | -1 | Sulkeva pari puuttuu. |
| "(()" | -1 | Yksi sulkeva pari puuttuu. |
| "()}" | -1 | Ylimääräinen sulkeva sulku. |
| ")(" | -1 | Väärä järjestys (alkava sulku puuttuu alussa). |
| "([)]" | -1 | Sulut 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).
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.
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ä.
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.
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).
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:
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.
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.
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));
}
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.
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:
| Tapaus | Merkitys | Tulkinta |
|---|---|---|
cmp.compare(a, b) < 0 | a < b | a on järjestyksessä ennen b:tä |
cmp.compare(a, b) == 0 | a == b | a ja b ovat samanarvoisia |
cmp.compare(a, b) > 0 | a > b | a 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:
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);
}
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.
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
IntStreamjaDoubleStream
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:
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:
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.
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:
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:
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:
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:
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:
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 €:
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 yhteenmin()/max()- etsii pienimmän/suurimman luvunaverage()- laskee lukujen keskiarvonsummaryStatistics()- laskee kerrallaan summan, suurimman, pienimmän luvut ja keskiarvon
void main() {
IntStream lukuja = new Random().ints(20, 0, 100);
IO.println(lukuja.summaryStatistics());
}
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 suodatussorted(): alkioiden järjestäminenlimit(): alkioiden lukumäärän rajaaminentoList(): alkioiden kerääminen listaksi
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.
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:
- 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.
- Poikkeusten käsittely (engl. catching exceptions): Ohjelmoija voi
määritellä koodilohkoja, jotka käsittelevät tiettyjä poikkeuksia
try-catch-rakenteella. - 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.
Esimerkissä tapahtuu seuraavaa:
- Metodi
c()heittää poikkeuksen, esimerkiksi käsitellessään verkkoyhteyttä, mutta metodissac()ei ole sopivaa käsittelijää. - Ajonaikainen järjestelmä katsoo kutsupinossa seuraavana olevaa metodia, joka
on
b(). - Metodi
b():llä ei myöskään ole sopivaa käsittelijää, joten tutkitaan edelleen seuraavaa metodia, joka ona(). a()-metodilla on sopiva käsittelijä, joka ottaa poikkeuksen kiinni.- 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, jaSQLException, 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:
- käsiteltävä poikkeus
try–catch-rakenteella (vrt. ylemmän kuviona()-metodi), tai - ilmoitettava metodin määrittelyssä
throws-määreellä, että poikkeus voi siirtyä kutsujalle (vrt. ylemmän kuvionb()- jac()-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 onnull,IllegalArgumentException, joka tapahtuu, kun metodille annetaan sopimaton argumentti,ArrayIndexOutOfBoundsException, joka tapahtuu, kun yritetään käyttää taulukon indeksiä, joka on taulukon raja-arvojen ulkopuolella, jaArithmeticException, 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:
trysuoritetaan.- Jos poikkeus tapahtuu, sopiva
catchsuoritetaan. - Lopuksi
finallysuoritetaan 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:
- Peri luokka
Exception-luokasta, jos haluat tarkastetun poikkeuksen. - 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.
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 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.
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.
- Aloita luomalla uusi projekti.
- Anna projektin nimeksi "EkaMavenProjekti".
- Valitse IDEAssa Build System -kohdassa Maven.
- 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ä:
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.
groupIdtoimii projektin "organisaatiotunnisteena". Se on usein käänteinen verkkotunnus, kutenfi.jyu.ohjelmointi.artifactIdon projektin nimi, javersionkertoo 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.
- Mene osoitteeseen https://central.sonatype.com/
- Kirjoita hakukenttään "okhttp" ja paina Enter.
- Ensimmäinen hakutulos vie vanhempaan okHttp-kirjastoon, joka on nimeltään "okhttp". Valitse sen sijaan toinen hakutulos, joka on uudempi.
- Näet Snippets-kohdassa valmiin XML-koodin, jonka yleensä voit kopioida
suoraan
pom.xml-tiedostoosi. - 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
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.
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.
NumberFormatExceptionInteger.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ä
- kansion luominen epäonnistuu;
createDirectoriesheittääIOException-poikkeuksen, tai - JSON-tiedoston lukeminen epäonnistuu, jos tiedosto ei löydy, JSON on
virheellistä tai tyyppimuunnos epäonnistuu;
writeValueheittää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()- jahashCode()-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
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()jaString.toLowerCase(). - Poistaa tyhjät rivit.
- Poistaa duplikaatit. (Vinkki:
distinct()-metodi Stream API:lla, taiSet-kokoelma.) - Järjestää sanat aakkosjärjestykseen. (Vinkki:
sorted()-metodi Stream API:lla, taiCollections.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
EDIT 23.2.2026: Jackson-kirjaston riippuvuuksia ja esimerkkejä päivitetty materiaalissa. Pahoittelut virheistä.
- Tee uusi Maven-projekti, joka käyttää Jackson-kirjastoa JSON-tiedostojen käsittelyyn.
- Lisää
pom.xml-tiedostoosi tarvittava riippuvuus. - Lataa henkilot.json ja tallenna se projektiisi samaan kansioon kuin missä koodisi on.
Henkilo-luokka tai vastaava record, jolla on kentätString nimi,int ika,String kaupunki.- Lue tiedosto
henkilot.jsonja muuta se listaksiHenkilo-olioita. - Suodata mukaan vain vähintään 18-vuotiaat.
- 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" } ]
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 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.
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.
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.
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.
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 suodatussorted(): alkioiden järjestäminenlimit(): alkioiden lukumäärän rajaaminentoList(): alkioiden kerääminen listaksi
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 monivalintatehtävä TIMissä.
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 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 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.
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()jaString.toLowerCase(). - Poistaa tyhjät rivit.
- Poistaa duplikaatit. (Vinkki:
distinct()-metodi Stream API:lla, taiSet-kokoelma.) - Järjestää sanat aakkosjärjestykseen. (Vinkki:
sorted()-metodi Stream API:lla, taiCollections.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
EDIT 23.2.2026: Jackson-kirjaston riippuvuuksia ja esimerkkejä päivitetty materiaalissa. Pahoittelut virheistä.
- Tee uusi Maven-projekti, joka käyttää Jackson-kirjastoa JSON-tiedostojen käsittelyyn.
- Lisää
pom.xml-tiedostoosi tarvittava riippuvuus. - Lataa henkilot.json ja tallenna se projektiisi samaan kansioon kuin missä koodisi on.
Henkilo-luokka tai vastaava record, jolla on kentätString nimi,int ika,String kaupunki.- Lue tiedosto
henkilot.jsonja muuta se listaksiHenkilo-olioita. - Suodata mukaan vain vähintään 18-vuotiaat.
- 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" } ]
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 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.
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ää tunnisteenafi.jyu.ohj2.nimesi.todo, missänimesion etunimesi tai käyttäjätunnuksesi ilman erikoismerkkejä. - ArtifactId: Tämä täsmää projektin Name-kentän kanssa
- Version:
0.1
- GroupId: Julkinen, yksilöllinen tunniste sovellukselle. Javassa yleinen
käytäntö on kirjoittaa tunniste muodossa
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.xmlon Maven-projektin konfiguraatiotiedosto.fi/jyu/ohj2/nimesi/todovastaa äsken asettamaasi GroupId-arvoa ja on projektin pääpakkaus. Java-kooditiedostot sijaitsevat tässä kansiossa.App.java,MainController.javajaMain.javaovat JavaFX-sovellukseen liittyviä Java-luokkia.main.fxmlon 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):
-
Näkymän alustaminen: aivan alkuun käyttöliittymän ensisijainen näkymä alustetaan luomalla
Scene-olio.Sceneon kokoelma käyttöliittymässä olevia komponentteja, eli ns. näkymäolio. Tässä projektipohjassa komponentit ladataanmain.fxml-tiedostosta käyttäenFXMLLoader-apuluokkaa, joka alustaa näkymässä olevia komponentteja. Komponentteja voitaisiin luoda myös manuaalisesti alustamalla komponenttiolioita. -
Näkymän asettaminen ikkunaan:
stage.setScene()-metodilla voidaan asettaa luotuScene-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.) -
Ikkunan asetusten muuttaminen:
Stage-olio sisältää lukuisia metodeja, jolla sovelluksen ikkunan toimintaa voidaan muuttaa. Yleinen toiminto on esimerkiksisetTitle()-metodi, jolla voi muuttaa ikkunan otsikon. -
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-olionshow()-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
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.
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:
-
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.
-
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.
-
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.
-
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:
- JavaFX luo kontrollerin kutsumalla konstruktoria
new MainController(). @FXML-kentät ovat tässä vaiheessa vielänull.FXMLLoaderlukee FXML-tiedoston ja injektoi kenttiin oikeat komponentitfx:id-arvojen perusteella.- 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.
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 tekstinLabel-komponenttiin. - Näytä
Label-komponentissa kaikki tehtävät erottamalla ne toisistaan rivinvaihdolla.
Palauta projektisi tiedostot.
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.
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.
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). ActionEventluodaan jasetOnAction-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.
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öönCheckBox-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.
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:
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.
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 WidthjaPref 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 WidthjaMin Height): Pienin sallittu koko, johon komponentti voi kutistua. - Suurin koko (
Max WidthjaMax 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äVBoxkasvaisi)
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.
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.
Osan kaikki tehtävät
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.
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 tekstinLabel-komponenttiin. - Näytä
Label-komponentissa kaikki tehtävät erottamalla ne toisistaan rivinvaihdolla.
Palauta projektisi tiedostot.
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.
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öönCheckBox-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.
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.
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.
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ätObservableList-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ä. EsimerkiksiStringPropertyon havaittava versioString-tyypistä,BooleanPropertyvastaavastiBoolean-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.
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:
- Käyttäjä klikkaa valintaruutua.
luoCheckBox-metodinsetOnAction-tapahtumakäsittelijä muuttaatehtavat-listaa (removejaadd). Tässä vaiheessa VBox-komponentteihin ei vielä kosketa.tehtavat-listan kuuntelija (addListener) huomaa, että listan sisältö muuttui.- Kuuntelija kutsuu
paivitaNakyma()- jatallenna()-metodeja. - 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.
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.
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ä.
- Luodaan uusi sarake alustamalla
TableColumn<Tehtava, Boolean>-olio. Ensimmäinen tyyppiparametriTehtavakertoo, minkä typpisiä olioita taulukon riveillä on. Toinen tyyppiparametriBooleankertoo, minkä tyyppisiä arvoja sarakkeessa näytetään. Merkkijono "Tehty" on sarakkeen otsikko, joka näkyy taulukossa. - Sidotaan
tehtySarake-saraketehtyProperty-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 parametricd("cell data") sisältää tiedon, minkä rivin tietoja ollaan käsittelemässä, jacd.getValue()palauttaa kyseisen rivinTehtava-olion. EdelleenTehtava-oliontehtyProperty()sisältääObservableValue<Boolean>-olion, eli juurikin sellaisen, jotaTableViewpystyy seuraamaan. - 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 arvosetCellFactory()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.
Palauta osan 8.2 perusteella edistetty projekti. Kertaus tämän osan vaiheista:
- Korvaa tehtävien
VBox+CheckBox-listausTableView-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.
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äksiTehtavakokoelma-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:
MainControllerreagoi käyttäjän tekemiin toimintoihin, kuten painikkeen painallukseen, kutsuu mallin (Tehtavakokoelma) metodeja, ja sitoo näkymän (TableView) kiinni malliinObservable-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ösTehtavakokoelma) kuuluvat malliin, koska niiden tehtävä on kuvata sovelluksen dataa ja siihen liittyviä sääntöjä.MainControllerei 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:
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
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.
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:
10kaikkiin reunoihin - Pref Width:
400 - Pref Height:
300
- Spacing:
- Kaikki Label-nimiöt
- Min Width:
100 - Muut Width ja Height -arvot:
USE_COMPUTED_SIZE
- Min Width:
- HBox-säiliöt otsikkokentälle, prioriteettikentälle sekä painikkeille
- Vgrow:
NEVER
- Vgrow:
- HBox-säiliö kuvauskentälle
- Vgrow:
ALWAYS
- Vgrow:
- HBox-säiliö painikkeille
- Alignment:
TOP_RIGHT - Spacing:
10
- Alignment:
- TextField, ComboBox ja TextArea -kentät:
- Hgrow:
ALWAYS - Kaikki Width ja Height -arvot:
USE_COMPUTED_SIZE
- Hgrow:
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:
-
Näkymän alustaminen: lataamme näkymän FXML-tiedostosta käyttäen
FXMLLoader-apuluokkaa. Tämän jälkeen alustamme varsinaisenScene-näkymäolion, jolle annamme parametrina ladatun näkymän pääkomponentin. -
Näkymän asettaminen ikkunaan: asetamme näkymän aktiiviseksi
scene.setScene()-metodilla. NytStage-ikkunaolio ei tule JavaFX:stä suoraan, vaan alustamme sen itse. Tämä käytännössä luo uuden ikkunan. -
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. -
Ikkunan näyttäminen: lopuksi näytämme ikkunan. Käytämme tässä
showAndWait()-metodia. Metodi toimii kutenshow(), 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.
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.
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!
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.
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 listaanpoistaTehtava(tehtava)poistaa annetun tehtävän listastalisaaTehtava(" ")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.
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.
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.
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.
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
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
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
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:
pushlähettää paikalliset commitit etävarastoon.-u origin masterlinkittää paikallisenmaster-haaran varastonmaster-haaraan. Tämän avulla Git-työkalu jatkossa tietää, ettägit push-komento ilman parametreja lähettää koodia ainaorigin-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:
- Tee muutoksia koodiin.
git add ., joka lisää muutokset Git-työkalun "käsittelyjonoon".git commit -m "Lisätty muokkausikkuna", joka tekee jonossa olevista muutoksista commitin.git push, joka lähettää kaikki tähän mennessä tehdyt commitit etävarastoon talteen.
Tehtävät
Tee työllesi julkinen Git-etävarasto ja tallenna koodisi sinne.
Osan kaikki tehtävät
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.
Palauta osan 8.2 perusteella edistetty projekti. Kertaus tämän osan vaiheista:
- Korvaa tehtävien
VBox+CheckBox-listausTableView-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.
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.
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.
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.
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.
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 työllesi julkinen Git-etävarasto ja tallenna koodisi sinne.
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.
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ä.
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, jossanimesion yliopiston tunnuksesi jaaiheon 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.
- Tee IDEA-projekti. Voit käyttää opintojakson JavaFX-pohjaa.
- Alusta paikallinen Git-varasto.
- Lisää
README.md- ja (tarvittaessa).gitignore-tiedostot. - Tee ensimmäinen commit.
- Luo projektillesi julkinen etävarasto GitLab- tai GitHub-palvelussa.
- 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ä:
- GitLab (JY): https://gitlab.jyu.fi/dashboard/projects/personal
- GitHub: https://github.com/repos
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äkokoelmantehtavat-listaan itse, vaan tehtävän lisäys on tehtäväkokoelman vastuullalisaaTehtava-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 kutsutmainista). 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

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

**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.
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.
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ä.
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.
| Komponentti | Lyhenne / Pääte | Esimerkki (fx:id) |
|---|---|---|
| Button | btn tai Button | tallennaBtn, peruutaButton |
| TextField | txt tai Field | emailField, statusTxt |
| Label | lbl tai Label | ilmoitusLabel, virheLbl |
| ComboBox | combo | maaCombo |
| TableView | table | kayttajaTable, tehtavaTable |
| CheckBox | cb tai check | suodatusCheck |
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.
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.
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.
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.
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.
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.
Näyttäminen ohjaajalle
Kuten osassa 9, suosittelemme tässäkin vaiheessa näyttämään harjoitustyön vaiheen ohjaajalle.
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ä.
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
- Kirjaudu GitLabiin ja avaa projektisi.
- Klikkaa vasemmalta Code Commits.
- Näet listan commiteista. Valitse se commit, joka liittyy tehtävän palautukseen. Klikkaa sitä.
- 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
- Kirjaudu GitHubiin ja avaa projektisi.
- Klikkaa vihreän Code-kuvakkeen alta NNN Commits, jossa NNN on committien määrä.
- Näet listan commiteista. Valitse se commit, joka liittyy tehtävän palautukseen. Klikkaa sitä.
- 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.
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.
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.
Toteuta tiedon tallentaminen ja lukeminen tiedostosta.
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.
Toteuta tiedon validointi. Sovelluksessa ei saa olla mahdollista syöttää selkeästi virheellistä tietoa.
Toteuta tietomallille yksikkötestejä JUnitilla.
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.
Näytä vaihe ohjaajalle.
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.
<?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>
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.
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:
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.
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ä:
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:

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:
- ControlsFX (kirjaston Maven-sivu)
- GemsFX (kirjaston Maven-sivu)
- Awesome JavaFX: listaus erilaisista kiinnostavista JavaFX-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:
-
Avaa SceneBuilderissa muokattava
.fxml-tiedosto. -
Klikkaa Library-näkymän hakupalkin vieressä olevaa asetuspainiketta () ja valitse sieltä JAR/FXML Manager:
-
Valitse avautuneesta dialogista Manually add Library from repository.
-
Syötä avautuneeseen dialogiin pakkauksen
<dependency>-määreen tiedot:- Group ID: Sama arvo kuin
<groupId>. ControlsFX-kirjastolle tämä on esimerkiksiorg.controlsfx - Artifact ID: Sama arvo kuin
artifactId. ControlsFX-kirjastolle tämä on esimerkiksicontrolsfxPaina 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ä on11.2.3. Varmista, että SceneBuilderiin lisättävä versio on sama kuin projektinpom.xml:ään lisättävä versio.
- Group ID: Sama arvo kuin
-
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.
-
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:
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 lausekkeellapelaajat.size()pelaajienLkm.asString()havaitsee muutoksenpelaajienLkm-arvossa ja päivittää arvonsa kutsumallapelaajienLkm.toString()pelaajiaLkmLabel.textProperty()havaitsee muutoksenpelaajienLkm.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:
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ä.
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
- tehtävä-oliossa viitattava oikeaan kategoria-olioon, ja
- TableView-olion kategoria-sarakkeen on kuunneltava kategorian nimen
muutoksia
setCellValueFactory-metodissa käyttäenflatMap-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:
- Se kuuntelee ulompaa propertyä (
kategoriaProperty()). - Kun ulomman propertyn arvo on olemassa, se kutsuu annettua funktiota
(
kategoria -> kategoria.nimiProperty()) ja alkaa kuunnella palautettua sisempää propertyä. - Jos ulompi arvo vaihtuu,
flatMaplopettaa vanhan sisemmän propertyn kuuntelun ja alkaa kuunnella uuden arvon sisempää propertyä. - Tuloksena on yksi
ObservableValue<String>, joka päivittyy aina kun kategorian nimi muuttuu tai kun koko kategoriaviite vaihtuu.





