Tehtävät
Kymmenennen osion tavoitteet

Kymmenennessä osassa käsitellään tiedon ryhmittelyä hajautustaulun avulla, jolloin yhteen hajautustaulun avaimeen voi liittyä useampia arvoja. Tämän lisäksi tutustutaan olioiden monimuotoisuuteen sekä perintään.

Tämän osan jälkeen osaat ryhmitellä tietoa hajautustaulun avulla, sekä tiedät mistä perinnässä on kyse. Ymmärrät Javan perintähierarkian ja osaat luoda luokkia, jotka perivät toisen luokan muuttujia ja metodeja. Tiedät myös miten näkyvyysmääreet vaikuttavat perittäviin muuttujiin ja metodeihin. Tunnet lisäksi abstraktin luokan käsitteen, ja olet harjoitellut refaktorointia, eli ohjelma rakenteen muuttamista ilman ohjelman toiminnallisuuden muuttamista.

Ryhmittely hajautustaulun avulla

Hajautustaulu sisältää korkeintaan yhden arvon yhtä avainta kohti. Seuraavassa esimerkissä tallennamme henkilöiden puhelinnumeroita hajautustauluun.

Map<String, String> puhelinnumerot = new HashMap<>();
puhelinnumerot.put("Pekka", "040-12348765");

System.out.println("Pekan numero: " + puhelinnumerot.get("Pekka"));

puhelinnumerot.put("Pekka", "09-111333");

System.out.println("Pekan numero: " + puhelinnumerot.get("Pekka"));
Pekan numero: 040-12348765
Pekan numero: 09-111333

Useampi arvo yhdelle avaimelle

Entä jos haluaisimme liittää yhteen avaimeen useita arvoja, eli esimerkiksi useampia puhelinnumeroita yhdelle henkilölle?

Koska hajautustaulun avaimet ja arvot voivat olla mitä tahansa muuttujia, listojen käyttäminen hajautustaulun arvona onnistuu. Useamman arvon lisääminen yhdelle arvolle onnistuu liittämällä avaimeen lista. Muutetaan puhelinnumeroiden talletustapaa seuraavasti:

  Map<String, List<String>> puhelinnumerot = new HashMap<>();

Nyt hajautustaulussa on jokaiseen avaimeen liitettynä lista. Vaikka new-komento luo hajautustaulun, ei hajautustaulu sisällä alussa yhtäkään listaa. Ne on luotava tarvittaessa erikseen.

Map<String, List<String>> puhelinnumerot = new HashMap<>();

// liitetään Pekka-nimeen ensin tyhjä ArrayList
puhelinnumerot.put("Pekka", new ArrayList<>());

// ja lisätään Pekkaa vastaavalle listalle puhelinnumero
puhelinnumerot.get("Pekka").add("040-12348765");
// ja lisätään toinenkin puhelinnumero
puhelinnumerot.get("Pekka").add("09-111333");

System.out.println("Pekan numerot: " + puhelinnumerot.get("Pekka"));
  Pekan numero: [040-12348765, 09-111333]

Määrittelimme muuttujan puhelinnumero tyypiksi Map<String, List<String>>. Tämä tarkoittaa hajautustaulua, joka käyttää avaimena merkkijonoa ja arvona merkkijonoja sisältävää listaa. Hajautustauluun lisättävät arvot ovat siis List<String>-rajapinnan toteuttavia konkreettisia olioita, eli esimerkiksi ArrayListejä.

// liitetään Pekka-nimeen ensin tyhjä ArrayList
puhelinnumerot.put("Pekka", new  ArrayList<>());

// ...

Joukoista

Rajapinta Set kuvaa joukon toiminnallisuutta. Toisin kuin listalla, joukossa kutakin alkioita on korkeintaan yksi kappale, eli yhtään samanlaista oliota ei ole kahdesti. Olioiden samankaltaisuuden tarkistaminen toteutetaan equals ja hashCode -metodeja käyttämällä.

Yksi rajapinnan Set toteuttava luokka on HashSet. Toteutetaan sen avulla luokka Tehtavakirjanpito, joka tarjoaa mahdollisuuden tehtävien kirjanpitoon ja tehtyjen tehtävien tulostamiseen. Oletetaan että tehtävät ovat aina kokonaislukuja.

public class Tehtavakirjanpito {
    private Set<Integer> tehdytTehtavat;

    public Tehtavakirjanpito() {
        this.tehdytTehtavat = new HashSet<>();
    }

    public void lisaa(int tehtava) {
        this.tehdytTehtavat.add(tehtava);
    }

    public void tulosta() {
        this.tehdytTehtavat.stream().forEach(t -> System.out.println(t));
    }
}
Tehtavakirjanpito kirjanpito = new Tehtavakirjanpito();
kirjanpito.lisaa(1);
kirjanpito.lisaa(1);
kirjanpito.lisaa(2);
kirjanpito.lisaa(3);

kirjanpito.tulosta();
1
2
3

Yllä oleva ratkaisu toimii tilanteessa, jossa emme tarvitse tietoa eri käyttäjien tekemistä tehtävistä. Muutetaan sovelluksen toiminnallisuutta siten, että tehtävät tallennetaan käyttäjäkohtaisesti hajautustaulua hyödyntäen. Käyttäjät tunnistetaan käyttäjän yksilöivällä merkkijonolla (esimerkiksi opiskelijanumero), ja jokaiselle käyttäjälle on oma joukko tehdyistä tehtävistä.

public class Tehtavakirjanpito {
    private Map<String, Set<Integer>> tehdytTehtavat;

    public Tehtavakirjanpito() {
	this.tehdytTehtavat = new HashMap<>();
    }

    public void lisaa(String kayttaja, int tehtava) {
	// uudelle käyttäjälle on lisättävä HashMapiin tyhjä tehtäväjoukko jos sitä
	// ei ole jo lisätty
	this.tehdytTehtavat.putIfAbsent(kayttaja, new HashSet<>());

	// haetaan ensin käyttäjän tehtävät sisältävä joukko ja tehdään siihen lisäys
	Set<Integer> tehdyt = this.tehdytTehtavat.get(kayttaja);
	tehdyt.add(tehtava);

	// edellinen olisi onnitunut myös ilman apumuuttujaa seuraavasti
	// this.tehdytTehtavat.get(kayttaja).add(tehtava);
    }

    public void tulosta() {
	this.tehdytTehtavat.keySet().stream(kayttaja -> {
	    System.out.println(kayttaja + ": " + this.tehdytTehtavat.get(kayttaja));
	});
    }
}
Tehtavakirjanpito kirjanpito = new Tehtavakirjanpito();
kirjanpito.lisaa("Ada", 3);
kirjanpito.lisaa("Ada", 4);
kirjanpito.lisaa("Ada", 3);
kirjanpito.lisaa("Ada", 3);

kirjanpito.lisaa("Pekka", 4);
kirjanpito.lisaa("Pekka", 4);

kirjanpito.lisaa("Matti", 1);
kirjanpito.lisaa("Matti", 2);

kirjanpito.tulosta();
Matti: [1, 2]
Pekka: [4]
Ada: [3, 4]

Huomaamme että käyttäjien nimet eivät tulostu järjestyksessä. Tämä selittyy sillä että HashMap-tyyppisessä hajautustaulussa alkioiden tallennus tapahtuu hashCode-metodin palauttaman hajautusarvon perusteella. HashMap-hajautustaulu ei ota kantaa alkioiden järjestykseen.

Tehtävänäsi on toteuttaa pakkaukseen sanakirja luokka OmaUseanKaannoksenSanakirja, johon voidaan lisätä yksi tai useampi käännös jokaiselle sanalle. Luokan tulee toteuttaa tehtäväpohjassa annettu rajapinta UseanKaannoksenSanakirja, joka määrittelee seuraavat metodit:

  • public void lisaa(String sana, String kaannos) lisää käännöksen sanalle säilyttäen vanhat käännökset
  • public Set<String> kaanna(String sana) palauttaa Set-rajapinnan toteuttavan olion, jossa on kaikki käännökset sanalle. Jos sanalle ei ole yhtäkään käännöstä, metodin tulee palauttaa Set-olio, jossa ei ole yhtäkään alkiota
  • public void poista(String sana) poistaa sanan ja sen kaikki käännökset sanakirjasta.

Käännökset kannattanee lisätä Map<String, Set<String>>-tyyppiseen oliomuuttujaan.

Rajapinta UseanKaannoksenSanakirja:

package sanakirja;

import java.util.Set;

public interface UseanKaannoksenSanakirja {
    void lisaa(String sana, String kaannos);
    Set<String> kaanna(String sana);
    void poista(String sana);
}

Esimerkki:

UseanKaannoksenSanakirja sanakirja = new OmaUseanKaannoksenSanakirja();
sanakirja.lisaa("kuusi", "six");
sanakirja.lisaa("kuusi", "spruce");

sanakirja.lisaa("pii", "silicon");
sanakirja.lisaa("pii", "pi");

System.out.println(sanakirja.kaanna("kuusi"));
sanakirja.poista("pii");
System.out.println(sanakirja.kaanna("pii"));
[six, spruce]
[]

Tehtävänäsi on toteuttaa pakkaukseen tyokalut luokka OmaDuplikaattienPoistaja, joka tallettaa annetut merkkijonot siten, että annetuista merkkijonoista poistetaan samanlaiset merkkijonot (eli duplikaatit). Lisäksi luokka pitää kirjaa duplikaattien määrästä. Luokan tulee toteuttaa tehtäväpohjassa annettu rajapinta DuplikaattienPoistaja, jossa on seuraavat toiminnot:

  • public void lisaa(String merkkijono)
  • tallettaa merkkijonon, jos se ei ole duplikaatti
  • public int getHavaittujenDuplikaattienMaara()
  • palauttaa tähän mennessä havaittujen duplikaattien määrän
  • public Set<String> getUniikitMerkkijonot()
  • palauttaa Set<String>-rajapinnan toteuttavan olion, jossa on kaikki uniikit lisätyt merkkijonot (ei siis duplikaatteja!). Jos merkkijonoja ei ole, palautetaan tyhjä joukko-olio.
  • public void tyhjenna()
  • poistaa talletetut merkkijonot ja nollaa havaittujen duplikaattien määrän

Rajapinta:

package tyokalut;

import java.util.Set;

public interface DuplikaattienPoistaja {
    void lisaa(String merkkijono);
    int getHavaittujenDuplikaattienMaara();
    Set<String> getUniikitMerkkijonot();
    void tyhjenna();
}

Käyttöesimerkki:

public static void main(String[] args) {
    DuplikaattienPoistaja poistaja = new OmaDuplikaattienPoistaja();
    poistaja.lisaa("eka");
    poistaja.lisaa("toka");
    poistaja.lisaa("eka");

    System.out.println("Duplikaattien määrä nyt: " +
    poistaja.getHavaittujenDuplikaattienMaara());

    poistaja.lisaa("vika");
    poistaja.lisaa("vika");
    poistaja.lisaa("uusi");

    System.out.println("Duplikaattien määrä nyt: " +
    poistaja.getHavaittujenDuplikaattienMaara());

    System.out.println("Uniikit merkkijonot: " +
    poistaja.getUniikitMerkkijonot());

    poistaja.tyhjenna();

    System.out.println("Duplikaattien määrä nyt: " +
    poistaja.getHavaittujenDuplikaattienMaara());

    System.out.println("Uniikit merkkijonot: " +
    poistaja.getUniikitMerkkijonot());
}

Yllä oleva ohjelma tulostaisi esimerkiksi seuraavaa: (merkkijonojen järjestys saa vaihdella, sillä ei ole merkitystä)

Duplikaattien määrä nyt: 1
Duplikaattien määrä nyt: 2
Uniikit merkkijonot: [eka, toka, vika, uusi]
Duplikaattien määrä nyt: 0
Uniikit merkkijonot: []

Sama olio useammassa tietorakenteessa

Oliot ovat viittaustyyppisiä, eli muuttuja ei tallenna olioa itseään vaan viitteen. Tämä tarkoittaa myös sitä, että jos olio lisätään esimerkiksi listaan, listalle lisätään viite olioon. Mikään ei estä lisäämästä saman olion viitettä useampaan paikkaan.

Tarkastellaan esimerkkinä kirjastoa joka tallettaa kirjat hajautustauluihin sekä kirjailijan että kirjan isbn-numeron perusteella. Tämän lisäksi kirjasto pitää kirjaa lainassa olevista sekä hyllyssä olevista kirjoista erillisillä listoilla.

public class Kirja {
    private String ISBN;
    private String kirjailija;
    private String nimi;
    private int vuosi;
    // ...
}
public class Kirjasto {
    private Map<String, Kirja> kirjaIsbnNumeronPerusteella;
    private Map<String, List<Kirja>> kirjatKirjailijanPerusteella;
    private List<Kirja> lainassaOlevatKirjat;
    private List<Kirja> hyllyssaOlevatKirjat;

    public Kirjasto() {
	this.kirjaIsbnNumeronPerusteella = new HashMap<>();
	this.kirjatKirjailijanPerusteella = new HashMap<>();
	this.lainassaOlevatKirjat = new ArrayList<>();
	this.hyllyssaOlevatKirjat = new ArrayList<>();
    }

    public void lisaaKirjaKokoelmaan(Kirja uusiKirja) {
	this.kirjaIsbnNumeronPerusteella.put(uusiKirja.getIsbn(), uusiKirja);

	this.kirjatKirjailijanPerusteella.putIfAbsent(uusiKirja.getKirjailija(), new ArrayList<>());
	this.kirjatKirjailijanPerusteella.get(uusikirja.getKirjailija()).add(uusiKirja);

	this.hyllyssaOlevatKirjat.add(uusiKirja);
    }

    public Kirja haeKirjaIsbnNumeronPerusteella(String isbn){
	return kirjaIsbnNumeronPerusteella.get(isbn);
    }

    // ...
}

Jos olio on yhtäaikaa useassa kokoelmassa (listalla, joukossa tai map-rakenteessa), on kiinnitettävä erityistä huomiota, että kokoelmien tila on konsistentti. Jos esimerkiksi kirja päätetään poistaa, on se poistettava kaikista paikoista, missä kirjaan on viite.

Huom: jotta testit toimisivat, ohjelmasi saa luoda vain yhden syötteen lukemiseen tarkoitetun Scanner-olion.

Tehdään sovellus jonka avulla on mahdollista hallinnoida ihmisten puhelinnumeroita ja osoitteita.

Tehtävän voi suorittaa 1-5 pisteen laajuisena. Yhden pisteen laajuuteen on toteutettava seuraavat toiminnot:

  • 1 puhelinnumeron lisäys henkilölle
  • 2 henkilön puhelinnumeroiden haku

kahteen pisteeseen vaaditaan edellisten lisäksi

  • 3 numeroa vastaavan henkilön nimen haku

kolmeen pisteeseen vaaditaan edellisten lisäksi

  • 4 osoitteen lisäys henkilölle
  • 5 henkilön tietojen (osoite ja puhelinnumero) haku

neljään pisteeseen vaaditaan toiminto

  • 6 henkilön tietojen poisto

ja täysiin pisteeseen vaaditaan vielä

  • 7 hakusanalla filtteröity listaus (nimen mukaan aakkostettuna), hakusana voi esiintyä henkilön nimessä tai osoitteessa

Esimerkki ohjelman toiminnasta:

numerotiedustelu
käytettävissä olevat komennot:
1 lisää numero
2 hae numerot
3 hae puhelinnumeroa vastaava henkilö
4 lisää osoite
5 hae henkilön tiedot
6 poista henkilön tiedot
7 filtteröity listaus
x lopeta

komento: 1
kenelle: pekka
numero: 040-123456

komento: 2
kenen: jukka
  ei löytynyt

komento: 2
kenen: pekka
  040-123456

komento: 1
kenelle: pekka
numero: 09-222333

komento: 2
kenen: pekka
  040-123456
  09-222333

komento: 3
numero: 02-444123
  ei löytynyt

komento: 3
numero: 09-222333
  pekka

komento: 5
kenen: pekka
  osoite ei tiedossa
  puhelinnumerot:
    040-123456
    09-222333

komento: 4
kenelle: pekka
katu: ida ekmanintie
kaupunki: helsinki

komento: 5
kenen: pekka
  osoite: ida ekmanintie helsinki
  puhelinnumerot:
    040-123456
    09-222333

komento: 4
kenelle: jukka
katu: korsontie
kaupunki: vantaa

komento: 5
kenen: jukka
  osoite: korsontie vantaa
  ei puhelinta

komento: 7
hakusana (jos tyhjä, listataan kaikki): kk

  jukka
    osoite: korsontie vantaa
    ei puhelinta

  pekka
    osoite: ida ekmanintie helsinki
    puhelinnumerot:
      040-123456
      09-222333

komento: 7
hakusana (jos tyhjä, listataan kaikki): vantaa

  jukka
    osoite: korsontie vantaa
    ei puhelinta

komento: 7
hakusana (jos tyhjä, listataan kaikki): seppo
  ei löytynyt

komento: 6
kenet: jukka

komento: 5
kenen: jukka
  ei löytynyt

komento: x

Huomioita:

  • Testien kannalta on oleellista että käyttöliittymä toimii kuten yllä olevassa esimerkissä. Sovellus voi itse päättää kuinka epäkelvot syötteet käsitellään. Testit sisältävät vaan kelvollisia syötteitä.
  • Ohjelman tulee käynnistyä kun tehtäväpohjassa oleva main-metodi suoritetaan, tehtävässä saa luoda vain yhden Scanner-olion.
  • Älä käytä luokkein nimissä skandeja, ne saattavat aiheuttaa ongelmia testeihin!
  • Yksinkertaisuuden vuoksi oletetaan että nimi on yksittäinen merkkijono, eli jos halutaan sukunimen mukaan järjestetyn tulostus viimeiseen toimintoon, nimi on annettava muodossa mikkola pekka.
  • Henkilöllä voi olla useita puhelinnumeroja sekä osoite. Henkilöllä ei kuitenkaan ole välttämättä yhtään puhelinnumeroa tai osoite ei ole tiedossa.
  • Jos henkilö poistetaan, ei mikään haku saa enää palauttaa henkilön tietoja.

Olioiden monimuotoisuus

Olemme aiemmissa osissa törmänneet tilanteisiin, joissa viittaustyyppisillä muuttujilla on oman tyyppinsä lisäksi muita tyyppejä. Esimerkiksi kaikki oliot ovat tyyppiä Object, eli mikä tahansa olio voidaan oman tyyppinsä lisäksi esittää Object-tyyppisenä muuttujana.

String merkkijono = "merkkijono";
Object merkkijonoString = "toinen merkkijono";
String merkkijono = "merkkijono";
Object merkkijonoString = merkkijono;

Yllä olevissa esimerkeissä merkkijonomuuttuja esitetään sekä String-tyyppisenä että Object-tyyppisenä, jonka lisäksi String-tyyppinen muuttuja asetetaan Object-tyyppiseen muuttujaan. Asetus toiseen suuntaan, eli Object-tyyppisen muuttujan asettaminen String-tyyppiseksi ei kuitenkaan onnistu. Tämä johtuu siitä, että Object-tyyppiset muuttujat eivät ole tyyppiä String

Object merkkijonoString = "toinen merkkijono";
String merkkijono = merkkijonoString; // EI ONNISTU!

Mistä tässä oikein on kyse?

Jokainen muuttuja voidaan esittää muuttujan alkuperäisen tyypin lisäksi myös muuttujan toteuttamien rajapintojen sekä perimien luokkien tyyppisenä. Luokka String perii luokan Object, joten String-oliot ovat aina myös tyyppiä Object. Luokka Object ei peri String-luokkaa, joten Object-tyyppiset muuttujat eivät ole automaattisesti tyyppiä String. Tutustutaan tarkemmin String-luokan API-dokumentaatioon, erityisesti HTML-sivun yläosaan.

Kuvakaappaus String-luokan API-dokumentaatiosta. Kuvakaappauksessa näkyy, että String-luokka perii luokan Object.

String-luokan API-dokumentaatio alkaa yleisellä otsakkeella jota seuraa luokan pakkaus (java.lang). Pakkauksen jälkeen tulee luokan nimi (Class String), jota seuraa luokan perintähierarkia.

  java.lang.Object
  java.lang.String

Perintähierarkia listaa luokat, jotka luokka on perinyt. Perityt luokat listataan perimisjärjestyksessä, tarkasteltava luokka aina alimpana. String-luokan perintähierarkiasta näemme, että String-luokka perii luokan Object. Javassa jokainen luokka voi periä korkeintaan yhden luokan. Toisaalta, perittävä luokka on voinut periä toisen luokan, joten välillisesti luokka voi periä useampia luokkia.

Perintähierarkiaa voi ajatella myös listana tyypeistä, joita olio toteuttaa.

Tieto siitä, että oliot voivat olla montaa eri tyyppiä -- esimerkiksi tyyppiä Object -- suoraviivaistaa ohjelmointia. Jos tarvitsemme metodissa vain Object-luokassa määriteltyjä metodeja kuten toString, equals ja hashCode, voimme käyttää metodin parametrina tyyppiä Object. Tällöin metodille voi antaa parametrina minkä tahansa olion. Tarkastellaan tätä metodin tulostaMonesti avulla. Metodi saa parametrinaan Object-tyyppisen muuttujan ja tulostusten lukumäärän.

public class Tulostin {
    ...
    public void tulostaMonesti(Object object, int kertaa) {
        for (int i = 0; i < kertaa; i++) {
            System.out.println(object.toString());
            // tai System.out.println(object);
        }
    }
    ...
}

Metodille voi antaa parametrina minkä tahansa olion. Metodin tulostaMonesti sisällä oliolla on käytössään vain Object-luokassa määritellyt metodit, koska olio tunnetaan metodissa Object-tyyppisenä. Todellisuudessa olio voi olla myös toisen tyyppinen.

Tulostin tulostin = new Tulostin();

String merkkijono = " o ";
List<String> sanat = new ArrayList<>();
sanat.add("polymorfismi");
sanat.add("perintä");
sanat.add("kapselointi");
sanat.add("abstrahointi");

tulostin.tulostaMonesti(merkkijono, 2);
tulostin.tulostaMonesti(sanat, 3);
 o
 o
[polymorfismi, perintä, kapselointi, abstrahointi]
[polymorfismi, perintä, kapselointi, abstrahointi]
[polymorfismi, perintä, kapselointi, abstrahointi]

Jatketaan String-luokan API-kuvauksen tarkastelua. Kuvauksessa olevaa perintähierarkiaa seuraa listaus luokan toteuttamista rajapinnoista.

  All Implemented Interfaces:
  Serializable, CharSequence, Comparable<String>

Luokka String toteuttaa rajapinnat Serializable, CharSequence, ja Comparable<String>. Myös rajapinta on tyyppi. Luokan String API-kuvauksen mukaan String-olion tyypiksi voi asettaa seuraavat rajapinnat.

Serializable serializableString = "merkkijono";
CharSequence charSequenceString = "merkkijono";
Comparable<String> comparableString = "merkkijono";

Koska metodeille voidaan määritellä metodin parametrin tyyppi, voimme määritellä metodeja jotka vastaanottavat tietyn rajapinnan toteuttavan olion. Kun metodille määritellään parametrina rajapinta, sille voidaan antaa parametrina mikä tahansa olio, joka toteuttaa kyseisen rajapinnan.

Täydennetään Tulostin-luokkaa siten, että sillä on metodi CharSequence-rajapinnan toteuttavien olioiden merkkien tulostamiseen. Rajapinta CharSequence tarjoaa muunmuassa metodit int length(), jolla saa merkkijonon pituuden, ja char charAt(int index), jolla saa merkin tietyssä indeksissä.

public class Tulostin {
    ...
    public void tulostaMonesti(Object object, int kertaa) {
        for (int i = 0; i < kertaa; i++) {
            System.out.println(object.toString());
        }
    }

    public void tulostaMerkit(CharSequence charSequence) {
        for (int i = 0; i < charSequence.length(); i++) {
            System.out.println(charSequence.charAt(i));
        }
    }
    ...
}

Metodille tulostaMerkit voi antaa minkä tahansa CharSequence-rajapinnan toteuttavan olion. Näitä on muunmuassa String ja merkkijonojen rakentamisessa usein Stringiä tehokkaampi StringBuilder. Metodi tulostaMerkit tulostaa annetun olion jokaisen merkin omalle rivilleen.

Tulostin tulostin = new Tulostin();

String mjono = "toimii";

tulostin.tulostaMerkit(mjono);
t
o
i
m
i
i

Tässä tehtävässä teemme eliöita ja eliöistä koostuvia laumoja jotka liikkuvat ympäriinsä. Eliöiden sijaintien ilmoittamiseen käytetään kaksiulotteista koordinaatistoa. Jokaiseen sijaintiin liittyy kaksi lukua, x- ja y-koordinaatti. Koordinaatti x kertoo, kuinka pitkällä "nollapisteestä" mitattuna sijainti on vaakasuunnassa, ja koordinaatti y vastaavasti kuinka pitkällä sijainti on pystysuunnassa. Jos koordinaatiston käsite ei ole tuttu, voit lukea siitä lisää esimerkiksi wikipediasta.

Tehtävän mukana tulee rajapinta Siirrettava, joka kuvaa asiaa jota voidaan siirtää paikasta toiseen. Rajapinta sisältää metodin void siirra(int dx, int dy). Parametri dx kertoo, paljonko asia siirtyy x-akselilla ja dy y-akselilla.

Tehtävässä toteutat luokat Elio ja Lauma, jotka molemmat ovat siirrettäviä. Toteuta kaikki toiminnallisuus pakkaukseen siirrettava.

Elio-luokan toteuttaminen

Luo pakkaukseen siirrettava luokka Elio, joka toteuttaa rajapinnan Siirrettava. Eliön tulee tietää oma sijaintinsa (x, y -koordinaatteina). Luokan Elio APIn tulee olla seuraava:

  • public Elio(int x, int y)
    Luokan konstruktori, joka saa olion aloitussijainnin x- ja y-koordinaatit parametrina
  • public String toString()
    Luo ja palauttaa oliosta merkkijonoesityksen. Eliön merkkijonoesityksen tulee olla seuraavanlainen "x: 3; y: 6". Huomaa että koordinaatit on erotettu puolipisteellä (;)
  • public void siirra(int dx, int dy)
    Siirtää oliota parametrina saatujen arvojen verran. Muuttuja dx sisältää muutoksen koordinaattiin x, muuttuja dy sisältää muutoksen koordinaattiin y. Esimerkiksi jos muuttujan dx arvo on 5, tulee oliomuuttujan x arvoa kasvattaa viidellä

Kokeile luokan Elio toimintaa seuraavalla esimerkkikoodilla.

Elio elio = new Elio(20, 30);
System.out.println(elio);
elio.siirra(-10, 5);
System.out.println(elio);
elio.siirra(50, 20);
System.out.println(elio);
x: 20; y: 30
x: 10; y: 35
x: 60; y: 55

Lauman toteutus

Luo pakkaukseen siirrettava luokka Lauma, joka toteuttaa rajapinnan Siirrettava. Lauma koostuu useasta Siirrettava-rajapinnan toteutavasta oliosta, jotka tulee tallettaa esimerkiksi listarakenteeseen.

Luokalla Lauma tulee olla seuraavanlainen API.

  • public String toString()
    Palauttaa merkkijonoesityksen lauman jäsenten sijainnista rivin vaihdolla erotettuna.
  • public void lisaaLaumaan(Siirrettava siirrettava)
    Lisää laumaan uuden Siirrettava-rajapinnan toteuttavan olion
  • public void siirra(int dx, int dy)
    Siirtää laumaa parametrina saatujen arvojen verran. Huomaa että tässä sinun tulee siirtää jokaista lauman jäsentä.

Kokeile ohjelmasi toimintaa alla olevalla esimerkkikoodilla.

Lauma lauma = new Lauma();
lauma.lisaaLaumaan(new Elio(73, 56));
lauma.lisaaLaumaan(new Elio(57, 66));
lauma.lisaaLaumaan(new Elio(46, 52));
lauma.lisaaLaumaan(new Elio(19, 107));
System.out.println(lauma);
x: 73; y: 56
x: 57; y: 66
x: 46; y: 52
x: 19; y: 107

Luokan ominaisuuksien periminen

Luokkia käytetään olio-ohjelmoinnissa ongelma-alueeseen liittyvien käsitteiden selkeyttämiseen. Jokainen luomamme luokka lisää toiminnallisuutta käytössämme olevaan ohjelmointikieleen. Tätä toiminnallisuutta tarvitaan kohtaamiemme ongelmien ratkomiseen, ratkaisut syntyvät luokista luotujen olioiden välisen interaktion avulla. Olio-ohjelmoinnissa olio on itsenäinen kokonaisuus, jolla on olion tarjoamien metodien avulla muutettava tila. Olioita käytetään yhteistyössä; jokaisella oliolla on oma vastuualue. Esimerkiksi käyttöliittymäluokkamme ovat tähän mennessä hyödyntäneet Scanner-olioita.

Jokainen Javan luokka perii luokan Object, eli jokainen luomamme luokka saa käyttöönsä kaikki Object-luokassa määritellyt metodit. Jos haluamme muuttaa Object-luokassa määriteltyjen metodien toiminnallisuutta tulee ne korvata (Override) määrittelemällä niille uusi toteutus luodussa luokassa.

Luokan Object perimisen lisäksi myös muiden luokkien periminen on mahdollista. Javan ArrayList-luokan APIa tarkasteltaessa huomaamme että ArrayList perii luokan AbstractList. Luokka AbstractList perii luokan AbstractCollection, joka perii luokan Object.

  java.lang.Object
  java.util.AbstractCollection<E>
    java.util.AbstractList<E>
      java.util.ArrayList<E>

Kukin luokka voi periä suoranaisesti yhden luokan. Välillisesti luokka kuitenkin perii kaikki perimänsä luokan ominaisuudet. Luokka ArrayList perii luokan AbstractList, ja välillisesti luokat AbstractCollection ja Object. Luokalla ArrayList on siis käytössään luokkien AbstractList, AbstractCollection ja Object muuttujat, metodit ja rajapinnat.

Luokan ominaisuudet peritään avainsanalla extends. Luokan perivää luokkaa kutsutaan aliluokaksi (subclass), perittävää luokkaa yliluokaksi (superclass). Tutustutaan erään autonvalmistajan järjestelmään, joka hallinnoi auton osia. Osien hallinan peruskomponentti on luokka Osa, joka määrittelee tunnuksen, valmistajan ja kuvauksen.

public class Osa {

    private String tunnus;
    private String valmistaja;
    private String kuvaus;

    public Osa(String tunnus, String valmistaja, String kuvaus) {
        this.tunnus = tunnus;
        this.valmistaja = valmistaja;
        this.kuvaus = kuvaus;
    }

    public String getTunnus() {
        return tunnus;
    }

    public String getKuvaus() {
        return kuvaus;
    }

    public String getValmistaja() {
        return valmistaja;
    }
}

Yksi osa autoa on moottori. Kuten kaikilla osilla, myös moottorilla on valmistaja, tunnus ja kuvaus. Näiden lisäksi moottoriin liittyy moottorityyppi: esimerkiksi polttomoottori, sähkömoottori tai hybridi. Luodaan luokan Osa perivä luokka Moottori: moottori on osan erikoistapaus.

public class Moottori extends Osa {

    private String moottorityyppi;

    public Moottori(String moottorityyppi, String tunnus, String valmistaja, String kuvaus) {
        super(tunnus, valmistaja, kuvaus);
        this.moottorityyppi = moottorityyppi;
    }

    public String getMoottorityyppi() {
        return moottorityyppi;
    }
}

Luokkamäärittely public class Moottori extends Osa kertoo että luokka Moottori perii luokan Osa toiminnallisuuden. Luokassa Moottori määritellään oliomuuttuja moottorityyppi.

Moottori-luokan konstruktori on mielenkiintoinen. Konstruktorin ensimmäisellä rivillä on avainsana super, jolla kutsutaan yliluokan konstruktoria. Kutsu super(tunnus, valmistaja, kuaus) kutsuu luokassa Osa määriteltyä konstruktoria public Osa(String tunnus, String valmistaja, String kuvaus, jolloin yliluokassa määritellyt oliomuuttujat saavat arvonsa. Tämän jälkeen oliomuuttujalle moottorityyppi asetetaan siihen liittyvä arvo.

Kun luokka Moottori perii luokan Osa, saa se käyttöönsä kaikki luokan Osa tarjoamat metodit. Luokasta Moottori voi tehdä ilmentymän aivan kuten mistä tahansa muustakin luokasta.

Moottori moottori = new Moottori("polttomoottori", "hz", "volkswagen", "VW GOLF 1L 86-91");
System.out.println(moottori.getMoottorityyppi());
System.out.println(moottori.getValmistaja());
polttomoottori
volkswagen

Kuten huomaat, luokalla Moottori on käytössä luokassa Osa määritellyt metodit.

Näkyvyysmääreet private, protected ja public

Jos metodilla tai muuttujalla on näkyvyysmääre private, ei se näy aliluokille eikä aliluokalla ole mitään suoraa tapaa päästä käsiksi siihen. Moottori ei siis pääse suoraan käsiksi yliluokassa Osa määriteltyihin ominaisuuksiinsa (tunnus, valmistaja, kuvaus) -- tällä tarkoitetaan sitä, että Moottori-luokassa ohjelmoija ei voi suoraan käsitellä niitä yliluokan muuttujia, joilla on näkyvyysmääre private.

Aliluokka näkee kaiken yliluokan julkisen eli public-määreellä varustetun kaluston. Jos halutaan määritellä yliluokkaan joitain muuttujia tai metodeja joiden näkeminen halutaan sallia aliluokille, mutta estää muilta voidaan käyttää näkyvyysmäärettä protected.

Yliluokan konstruktorin ja metodien kutsuminen

Yliluokan konstruktoria kutsutaan avainsanalla super. Kutsulle annetaan parametrina yliluokan konstruktorin vaatiman tyyppiset arvot.

Konstruktoria kutsuttaessa yliluokassa määritellyt muuttujat alustetaan. Konstruktorikutsussa tapahtuu käytännössä täysin samat asiat kuin normaalissa konstruktorikutsussa. Jos yliluokassa ei ole määritelty parametritonta konstruktoria, tulee aliluokan konstruktorikutsuissa olla aina mukana yliluokan konstruktorikutsu.

Huom! Kutsun super tulee olla aina konstruktorin ensimmäisellä rivillä!

Yliluokan metodin kutsuminen

Yliluokassa määriteltyjä metodeja voi kutsua super-etuliitteen avulla, aivan kuten tässä luokassa määriteltyjä metodeja voi kutsua this-etuliitteellä. Esimerkiksi yliluokassa määriteltyä toString-metodia voi hyödyntää sen korvaavassa metodissa seuraavasti:

@Override
public String toString() {
    return super.toString() + "\n  Ja oma viestini vielä!";
}

Henkilo

Tee pakkaus henkilot ja sinne luokka Henkilo. Luokan tulee toimia seuraavan esimerkin mukaisesti.

Henkilo ada = new Henkilo("Ada Lovelace", "Korsontie 1 03100 Vantaa");
Henkilo esko = new Henkilo("Esko Ukkonen", "Mannerheimintie 15 00100 Helsinki");
System.out.println(ada);
System.out.println(esko);
Ada Lovelace
  Korsontie 1 03100 Vantaa
Esko Ukkonen
  Mannerheimintie 15 00100 Helsinki

Opiskelija

Tee pakkaukseen henkilot luokka Opiskelija joka perii luokan Henkilo.

Opiskelijalla on aluksi 0 opintopistettä. Aina kun opiskelija opiskelee, kasvaa opintopistemäärä. Luokan tulee toimia seuraavan esimerkin mukaisesti.

Opiskelija olli = new Opiskelija("Olli", "Ida Albergintie 1 00400 Helsinki");
System.out.println(olli);
System.out.println("opintopisteitä " + olli.opintopisteita());
olli.opiskele();
System.out.println("opintopisteitä "+ olli.opintopisteita());
Olli
  Ida Albergintie 1 00400 Helsinki
opintopisteitä 0
opintopisteitä 1

Opiskelijalle toString

Edellisessä tehtävässä Opiskelija perii toString-metodin luokalta Henkilo. Perityn metodin voi myös ylikirjoittaa, eli korvata omalla versiolla. Tee luokalle Opiskelija oma versio toString-metodista. Metodin tulee toimia seuraavan esimerkin mukaisesti.

Opiskelija olli = new Opiskelija("Olli", "Ida Albergintie 1 00400 Helsinki");
System.out.println(olli);
olli.opiskele();
System.out.println(olli);
Olli
  Ida Albergintie 1 00400 Helsinki
  opintopisteitä 0
Olli
  Ida Albergintie 1 00400 Helsinki
  opintopisteitä 1

Opettaja

Tee luokan Henkilo perivä luokka Opettaja. Opettajalla on palkka joka tulostuu opettajan merkkijonoesityksessä.

Luokan tulee toimia seuraavan esimerkin mukaisesti.

Opettaja ada = new Opettaja("Ada Lovelace", "Korsontie 1 03100 Vantaa", 1200);
Opettaja esko = new Opettaja("Esko Ukkonen", "Mannerheimintie 15 00100 Helsinki", 5400);
System.out.println(ada);
System.out.println(esko);

Opiskelija olli = new Opiskelija("Olli", "Ida Albergintie 1 00400 Helsinki");
for (int i = 0; i < 25; i++) {
    olli.opiskele();
}
System.out.println(olli);
Ada Lovelace
  Korsontie 1 03100 Vantaa
  palkka 1200 euroa/kk
Esko Ukkonen
  Mannerheimintie 15 00100 Helsinki
  palkka 5400 euroa/kk
Olli
  Ida Albergintie 1 00400 Helsinki
  opintopisteitä 25

Kaikki Henkilot listalle

Toteuta luokkaan HenkiloTulostus metodi public void tulostaLaitoksenHenkilot(List<Henkilo> henkilot), joka tulostaa kaikki metodille parametrina annetussa listassa olevat henkilöt. Metodin tulee toimia seuraavasti main-metodista kutsuttaessa.

public static void main(String[] args) {
    List<Henkilo> henkilot = new ArrayList<Henkilo>();
    henkilot.add(new Opettaja("Ada Lovelace", "Korsontie 1 03100 Vantaa", 1200));
    henkilot.add(new Opiskelija("Olli", "Ida Albergintie 1 00400 Helsinki"));

    new HenkiloTulostus().tulostaLaitoksenHenkilot(henkilot);
}
Ada Lovelace
  Korsontie 1 03100 Vantaa
  palkka 1200 euroa/kk
Olli
  Ida Albergintie 1 00400 Helsinki
  opintopisteitä 0

Todellinen tyyppi määrää suoritettavan metodin

Olion kutsuttavissa olevat metodit määrittyvät muuttujan tyypin kautta. Esimerkiksi jos Opiskelija-tyyppisen olion viite on talletettu Henkilo-tyyppiseen muuttujaan, on oliosta käytössä vain Henkilo-luokassa määritellyt metodit (sekä Henkilo-luokan yliluokan ja rajapintojen metodit):

Henkilo olli = new Opiskelija("Olli", "Ida Albergintie 1 00400 Helsinki");
olli.opintopisteita();        // EI TOIMI!
olli.opiskele();              // EI TOIMI!
String.out.println(olli);   // olli.toString() TOIMII

Oliolla on siis käytössä jokainen sen tyyppiin sekä sen yliluokkiin ja rajapintoihin liittyvä metodi. Esimerkiksi Opiskelija-tyyppisellä oliolla on käytössä Henkilo-luokassa määritellyt metodit sekä Object-luokassa määritellyt metodit.

Edellisessä tehtävässä korvasimme Opiskelijan luokalta Henkilö perimän toString uudella versiolla. Myös luokka Henkilö oli jo korvannut Object-luokalta perimänsä toStringin. Jos käsittelemme olioa jonkun muun kuin sen todellisen tyypin kautta, mitä versiota olion metodista kutsutaan?

Seuraavassa esimerkissä kahta opiskelijaa käsitellään erityyppisten muuttujien kautta. Mikä versio metodista toString suoritetaan, luokassa Object, Henkilo vai Opiskelija määritelty?

Opiskelija olli = new Opiskelija("Olli", "Ida Albergintie 1 00400 Helsinki");
String.out.println(olli);
Henkilo olliHenkilo = new Opiskelija("Olli", "Ida Albergintie 1 00400 Helsinki")
System.out.println(olliHenkilo);
Object olliObject = new Opiskelija("Olli", "Ida Albergintie 1 00400 Helsinki")
System.out.println(olliObject);

Object liisa = new Opiskelija("Liisa", "Väinö Auerin katu 20 00500 Helsinki");
String.out.println(liisa);
Olli
  Ida Albergintie 1 00400 Helsinki
  opintopisteitä 0
Olli
  Ida Albergintie 1 00400 Helsinki
  opintopisteitä 0
Olli
  Ida Albergintie 1 00400 Helsinki
  opintopisteitä 0
Liisa
  Väinö Auerin katu 20 00500 Helsinki
  opintopisteitä 0

Suoritettava metodi valitaan olion todellisen tyypin perusteella, eli sen luokan perusteella, jonka konstruktoria kutsutaan kun olio luodaan. Jos kutsuttua metodia ei ole määritelty luokassa, suoritetaan perintähierarkiassa olion todellista tyyppiä lähinnä oleva metodin toteutus.

Hieman yleisemmin: Suoritettava metodi valitaan aina olion todellisen tyypin perusteella riippumatta käytetyn muuttujan tyypistä. Oliot ovat monimuotoisia, eli olioita voi käyttää usean eri muuttujatyypin kautta. Suoritettava metodi liittyy aina olion todelliseen tyyppiin. Tätä monimuotoisuutta kutsutaan polymorfismiksi.

Tarkastellaan tätä vielä toisen esimerkin avulla.

Kaksiulotteisessa koordinaatiostossa sijaitsevaa pistettä voisi kuvata seuraavan luokan avulla:

public class Piste {

    private int x;
    private int y;

    public Piste(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int manhattanEtaisyysOrigosta() {
        return Math.abs(x) + Math.abs(y);
    }

    protected String sijainti(){
        return x + ", " + y;
    }

    @Override
    public String toString() {
        return "(" + this.sijainti() + ") etäisyys " + this.manhattanEtaisyysOrigosta();
    }
}

Metodi sijainti ei ole tarkoitettu ulkoiseen käyttöön, joten se on näkyvyysmääreeltään protected, eli aliluokat pääsevät siihen käsiksi. Esimerkiksi reitinhakualgoritmien hyödyntämällä Manhattan-etäisyydellä tarkoitetaan pisteiden etäisyyttä, jos niiden välin voi kulkea ainoastaan koordinaattiakselien suuntaisesti.

Värillinen piste on muuten samanlainen kuin piste, mutta se sisältää merkkijonona ilmaistavan värin. Luokka voidaan siis tehdä perimällä Piste.

public class VariPiste extends Piste {

    private String vari;

    public VariPiste(int x, int y, String vari) {
        super(x, y);
        this.vari = vari;
    }

    @Override
    public String toString() {
        return super.toString() + " väri: " + vari;
    }
}

Luokka määrittelee oliomuuttujan värin talletusta varten. Koordinaatit on valmiiksi määriteltynä yliluokassa. Merkkijonoesityksestä halutaan muuten samanlainen kuin pisteellä, mutta väri tulee myös ilmaista. Ylikirjoitettu metodi toString kutsuu yliluokan toString-metodia ja lisää sen tulokseen pisteen värin.

Seuraavassa esimerkki, jossa listalle laitetaan muutama piste. Osa pisteistä on "normaaleja" ja osa väripisteitä. Lopulta tulostetaan listalla olevat pisteet. Jokaisen pisteen metodi toString suoritetaan pisteen todellisen tyypin perusteella, vaikka lista tuntee kaikki pisteet Piste-tyyppisinä.

public class Main {
    public static void main(String[] args) {
        List<Piste> pisteet = new ArrayList<>();
        pisteet.add(new Piste(4, 8));
        pisteet.add(new VariPiste(1, 1, "vihreä"));
        pisteet.add(new VariPiste(2, 5, "sininen"));
        pisteet.add(new Piste(0, 0));

        pisteet.stream().forEach(p -> System.out.println(p));
    }
}
(4, 8) etäisyys 12
(1, 1) etäisyys 2 väri: vihreä
(2, 5) etäisyys 7 väri: sininen
(0, 0) etäisyys 0

Haluamme ohjelmaamme myös kolmiulotteisen pisteen. Koska kyseessä ei ole värillinen versio, periytetään se luokasta piste.

public class Piste3D extends Piste {

    private int z;

    public Piste3D(int x, int y, int z) {
        super(x, y);
        this.z = z;
    }

    @Override
    protected String sijainti() {
        return super.sijainti() + ", " + z;    // tulos merkkijono muotoa "x, y, z"
    }

    @Override
    public int manhattanEtaisyysOrigosta() {
        // kysytään ensin yliluokalta x:n ja y:n perusteella laskettua etäisyyttä
        // ja lisätään tulokseen z-koordinaatin vaikutus
        return super.manhattanEtaisyysOrigosta() + Math.abs(z);
    }

    @Override
    public String toString() {
        return "(" + this.sijainti() + ") etäisyys " + this.manhattanEtaisyysOrigosta();
    }
}

Kolmiulotteinen piste siis määrittelee kolmatta koordinaattia vastaavan oliomuuttujan ja ylikirjoittaa metodit sijainti, manhattanEtaisyysOrigosta ja toString siten, että ne huomioivat kolmannen ulottuvuuden. Voimme nyt laajentaa edellistä esimerkkiä ja lisätä listalle myös kolmiulotteisia pisteitä.

public class Main {

    public static void main(String[] args) {
        List<Piste> pisteet = new ArrayList<>();
        pisteet.add(new Piste(4, 8));
        pisteet.add(new VariPiste(1, 1, "vihreä"));
        pisteet.add(new VariPiste(2, 5, "sininen"));
        pisteet.add(new Piste3D(5, 2, 8));
        pisteet.add(new Piste(0, 0));

        pisteet.stream().forEach(p -> System.out.println(p));
    }
  }
(4, 8) etäisyys 12
(1, 1) etäisyys 2 väri: vihreä
(2, 5) etäisyys 7 väri: sininen
(5, 2, 8) etäisyys 15
(0, 0) etäisyys 0

Huomamme, että kolmiulotteisen pisteen metodi toString on täsmälleen sama kuin pisteen toString. Voisimmeko jättää toStringin ylikirjoittamatta? Vastaus on kyllä! Kolmiulotteinen piste pelkistyy seuraavanlaiseksi.

public class Piste3D extends Piste {

    private int z;

    public Piste3D(int x, int y, int z) {
        super(x, y);
        this.z = z;
    }

    @Override
    protected String sijainti() {
        return super.sijainti() + ", " + z;
    }

    @Override
    public int manhattanEtaisyysOrigosta() {
        return super.manhattanEtaisyysOrigosta() + Math.abs(z);
    }
}

Mitä tarkalleenottaen tapahtuu kuin kolmiulotteiselle pisteelle kutsutaan toString-metodia? Suoritus etenee seuraavasti.

  1. etsitään toString:in määrittelyä luokasta Piste3D, sitä ei löydy joten mennään yliluokkaan
  2. etsitään toString:in määrittelyä yliluokasta Piste, metodi löytyy, joten suoritetaan sen koodi
    • suoritettava koodi siis on return "("+this.sijainti()+") etäisyys "+this.manhattanEtaisyysOrigosta();
    • esimmäisenä suoritetaan metodi sijainti
    • etsitään metodin sijainti määrittelyä luokasta Piste3D, metodi löytyy ja suoritetaan sen koodi
    • metodin sijainti laskee oman tuloksensa kutsumalla yliluokassa olevaa metodia sijainti
    • seuraavaksi etsitään metodin manhattanEtaisyysOrigosta määrittelyä luokasta Piste3D, metodi löytyy ja suoritetaan sen koodi
    • jälleen metodi laskee tuloksensa kutsuen ensin yliluokassa olevaa samannimistä metodia

Metodikutsun aikaansaama toimintoketju siis on monivaiheinen. Periaate on kuitenkin selkeä: suoritettavan metodin määrittelyä etsitään ensin olion todellisen tyypin määrittelystä ja jos sitä ei löydy edetään yliluokkaan. Ja jos yliluokastakaan ei löydy metodin toteutusta siirrytään etsimään yliluokan yliluokasta jne...

Milloin perintää kannattaa käyttää?

Perintä on väline käsitehierarkioiden rakentamiseen ja erikoistamiseen; aliluokka on aina yliluokan erikoistapaus. Jos luotava luokka on olemassaolevan luokan erikoistapaus, voidaan uusi luokka luoda perimällä olemassaoleva luokka. Esimerkiksi auton osiin liittyvässä esimerkissä moottori on osa, mutta moottoriin liittyy lisätoiminnallisuutta mitä jokaisella osalla ei ole.

Perittäessä aliluokka saa käyttöönsä yliluokan toiminnallisuudet. Jos aliluokka ei tarvitse tai käytä perittyä toiminnallisuutta, ei perintä ole perusteltua. Perityt luokat perivät yliluokkiensa metodit ja rajapinnat, eli aliluokkia voidaan käyttää missä tahansa missä yliluokkaa on käytetty. Perintähierarkia kannattaa pitää matalana, sillä hierarkian ylläpito ja jatkokehitys vaikeutuu perintöhierarkian kasvaessa. Yleisesti ottaen, jos perintähierarkian korkeus on yli 2 tai 3, ohjelman rakenteessa on todennäköisesti parannettavaa.

Perinnän käyttöä tulee miettiä. Esimerkiksi luokan Auto periminen luokasta Osa (tai Moottori) on väärin. Auto sisältää moottorin ja osia, mutta auto ei ole moottori tai osa. Voimme yleisemmin ajatella että jos olio omistaa tai koostuu toisista olioista, ei perintää tule käyttää.

Perintää käytettäessä tulee varmistaa että Single Responsibility Principle pätee myös perittäessä. Jokaisella luokalla tulee olla vain yksi syy muuttua. Jos huomaat että perintä lisää luokan vastuita, tulee luokka pilkkoa useammaksi luokaksi.

Perinnän väärinkäyttö

Pohditaan postituspalveluun liittyviä luokkia Asiakas, joka sisältää asiakkaan tiedot, ja Tilaus, joka perii asiakkaan tiedot ja sisältää tilattavan tavaran tiedot. Luokassa Tilaus on myös metodi postitusOsoite, joka kertoo tilauksen postitusosoitteen.

public class Asiakas {

    private String nimi;
    private String osoite;

    public Asiakas(String nimi, String osoite) {
        this.nimi = nimi;
        this.osoite = osoite;
    }

    public String getNimi() {
        return nimi;
    }

    public String getOsoite() {
        return osoite;
    }

    public void setOsoite(String osoite) {
        this.osoite = osoite;
    }
}
public class Tilaus extends Asiakas {

    private String tuote;
    private String lukumaara;

    public Tilaus(String tuote, String lukumaara, String nimi, String osoite) {
        super(nimi, osoite);
        this.tuote = tuote;
        this.lukumaara = lukumaara;
    }

    public String getTuote() {
        return tuote;
    }

    public String getLukumaara() {
        return lukumaara;
    }

    public String postitusOsoite() {
        return this.getNimi() + "\n" + this.getOsoite();
    }
}

Yllä perintää on käytetty väärin. Luokkaa perittäessä aliluokan tulee olla yliluokan erikoistapaus; tilaus ei ole asiakkaan erikoistapaus. Väärinkäyttö ilmenee single responsibility principlen rikkomisena: luokalla Tilaus on vastuu sekä asiakkaan tietojen ylläpidosta, että tilauksen tietojen ylläpidosta.

Ratkaisussa piilevä ongelma tulee esiin kun mietimme mitä käy asiakkaan osoitteen muuttuessa.

Osoitteen muuttuessa joudumme muuttamaan jokaista kyseiseen asiakkaaseen liittyvää tilausoliota, mikä kertoo huonosta tilanteesta. Parempi ratkaisu olisi kapseloida Asiakas Tilaus-luokan oliomuuttujaksi. Jos ajattelemme tarkemmin tilauksen semantiikkaa, tämä on selvää. Tilauksella on asiakas. Muutetaan luokkaa Tilaus siten, että se sisältää Asiakas-viitteen.

public class Tilaus {

    private Asiakas asiakas;
    private String tuote;
    private String lukumaara;

    public Tilaus(Asiakas asiakas, String tuote, String lukumaara) {
        this.asiakas = asiakas;
        this.tuote = tuote;
        this.lukumaara = lukumaara;
    }

    public String getTuote() {
        return tuote;
    }

    public String getLukumaara() {
        return lukumaara;
    }

    public String postitusOsoite() {
        return this.asiakas.getNimi() + "\n" + this.asiakas.getOsoite();
    }
}

Yllä oleva luokka Tilaus on nyt parempi. Metodi postitusosoite käyttää asiakas-viitettä postitusosoitteen saamiseen sen sijaan että luokka perisi luokan Asiakas. Tämä helpottaa sekä ohjelman ylläpitoa, että sen konkreettista toiminnallisuutta.

Nyt asiakkaan muuttaessa tarvitsee muuttaa vain asiakkaan tietoja, tilauksiin ei tarvitse tehdä muutoksia.

Tehtäväpohjassa tulee mukana luokka Varasto, jonka tarjoamat konstruktorit ja metodit ovat seuraavat:

  • public Varasto(double tilavuus)
    Luo tyhjän varaston, jonka vetoisuus eli tilavuus annetaan parametrina; sopimaton tilavuus (<=0) luo käyttökelvottoman varaston, jonka tilavuus on 0.
  • public double getSaldo()
    Palauttaa arvonaan varaston saldon, eli varastossa olevan tavaran tilavuuden.
  • public double getTilavuus()
    Palauttaa arvonaan varaston kokonaistilavuuden (eli sen, joka annettiin konstruktorille).
  • public double paljonkoMahtuu()
    Palauttaa arvonaan tiedon, paljonko varastoon vielä mahtuu.
  • public void lisaaVarastoon(double maara)
    Lisää varastoon pyydetyn määrän; jos määrä on negatiivinen, mikään ei muutu, jos kaikki pyydetty ei enää mahdu, varasto laitetaan täydeksi ja loput määrästä "heitetään menemään", "vuotaa yli".
  • public double otaVarastosta(double maara)
    Otetaan varastosta pyydetty määrä, metodi palauttaa paljonko saadaan. Jos pyydetty määrä on negatiivinen, mikään ei muutu ja palautetaan nolla. Jos pyydetään enemmän kuin varastossa on, annetaan mitä voidaan ja varasto tyhjenee.
  • public String toString()
    Palauttaa olion tilan merkkijonoesityksenä tyyliin saldo = 64.5, tilaa 123.5

Tehtävässä rakennetaan Varasto-luokasta useampia erilaisia varastoja. Huom! Toteuta kaikki luokat pakkaukseen varastot.

Tuotevarasto, vaihe 1

Luokka Varasto hallitsee tuotteen määrään liittyvät toiminnot. Nyt tuotteelle halutaan lisäksi tuotenimi ja nimen käsittelyvälineet. Ohjelmoidaan Tuotevarasto Varaston aliluokaksi! Toteutetaan ensin pelkkä yksityinen oliomuuttuja tuotenimelle, konstruktori ja getteri nimikentälle:

  • public Tuotevarasto(String tuotenimi, double tilavuus)
    Luo tyhjän tuotevaraston. Tuotenimi ja vetoisuus annetaan parametrina.
  • public String getNimi()
    Palauttaa arvonaan tuotteen nimen.

Muista millä tavoin konstruktori voi ensi toimenaan suorittaa yliluokan konstruktorin!

Käyttöesimerkki:

Tuotevarasto mehu = new Tuotevarasto("Juice", 1000.0);
mehu.lisaaVarastoon(1000.0);
mehu.otaVarastosta(11.3);
System.out.println(mehu.getNimi()); // Juice
System.out.println(mehu);           // saldo = 988.7, tilaa 11.3
Juice
saldo = 988.7, vielä tilaa 11.3

Tuotevarasto, vaihe 2

Kuten edellisestä esimerkistä näkee, Tuotevarasto-olion perimä toString() ei tiedä (tietenkään!) mitään tuotteen nimestä. Asialle on tehtävä jotain! Lisätään samalla myös setteri tuotenimelle:

  • public void setNimi(String uusiNimi) asettaa tuotteelle uuden nimen.
  • public String toString() palauttaa olion tilan merkkijonoesityksenä tyyliin Juice: saldo = 64.5, tilaa 123.5

Uuden toString()-metodin voisi toki ohjelmoida käyttäen yliluokalta perittyjä gettereitä, joilla perittyjen, mutta piilossa pidettyjen kenttien arvoja saa käyttöönsä. Koska yliluokkaan on kuitenkin jo ohjelmoitu tarvittava taito varastotilanteen merkkiesityksen tuottamiseen, miksi nähdä vaivaa sen uudelleen ohjelmointiin. Käytä siis hyväksesi perittyä toStringiä.

Muista miten korvattua metodia voi kutsua aliluokassa!

Käyttöesimerkki:

Tuotevarasto mehu = new Tuotevarasto("Juice", 1000.0);
mehu.lisaaVarastoon(1000.0);
mehu.otaVarastosta(11.3);
System.out.println(mehu.getNimi()); // Juice
mehu.lisaaVarastoon(1.0);
System.out.println(mehu);           // Juice: saldo = 989.7, tilaa 10.299999999999955
Juice
Juice: saldo = 989.7, tilaa 10.299999999999955

Muutoshistoria

Toisinaan saattaa olla kiinnostavaa tietää, millä tavoin jonkin tuotteen varastotilanne muuttuu: onko varasto usein hyvin vajaa, ollaanko usein ylärajalla, onko vaihelu suurta vai pientä, jne. Varustetaan siksi Tuotevarasto-luokka taidolla muistaa tuotteen määrän muutoshistoriaa.

Aloitetaan apuvälineen laadinnalla.

Muutoshistorian muistamisen voisi toki toteuttaa suoraankin ArrayList<Double>-oliona luokassa Tuotevarasto, mutta nyt laaditaan kuitenkin oma erikoistettu väline tähän tarkoitukseen. Väline toteutetaan kapseloimalla ArrayList<Double>-olio.

Muutoshistoria-luokan julkiset konstruktorit ja metodit:

  • public Muutoshistoria() luo tyhjän Muutoshistoria-olion.
  • public void lisaa(double tilanne) lisää muutoshistorian viimeisimmäksi muistettavaksi määräksi parametrina annetun tilanteen.
  • public void nollaa() tyhjää muistin.
  • public String toString() palauttaa muutoshistorian merkkijonoesityksen. ArrayList-luokan antama merkkijonoesitys kelpaa sellaisenaan.

Muutoshistoria, vaihe 2

Täydennä Muutoshistoria-luokkaa analyysimetodein:

  • public double maxArvo() palauttaa muutoshistorian suurimman arvon. Jos historia on tyhjä, metodi palauttaa nollan.
  • public double minArvo() palauttaa muutoshistorian pienimmän arvon. Jos historia on tyhjä, metodi palauttaa nollan.
  • public double keskiarvo() palauttaa muutoshistorian arvojen keskiarvon. Jos historia on tyhjä, metodi palauttaa nollan.

Muutoshistoria, vaihe 3

Täydennä Muutoshistoria-luokkaa analyysimetodein:

  • public double suurinMuutos() palauttaa muutoshistorian isoimman (huom: -5:n kokoinen muutos on isompi kuin 4:n kokoinen muutos) yksittäisen muutoksen itseisarvon. Jos historia on tyhjä tai yhden arvon mittainen, metodi palauttaa nollan. Itseisarvo on luvun etäisyys nollasta. Esimerkiksi luvun -5.5 itseisarvo on 5.5, luvun 3.2 itseisarvo on 3.2.
  • public double varianssi() palauttaa muutoshistorian arvojen varianssin (käytetään otosvarianssin kaavaa). Jos historia on tyhjä tai yhden arvon mittainen, metodi palauttaa nollan.

Ohjeen varianssin laskemiseksi voit katsoa esimerkiksi Wikipediasta kohdasta populaatio- ja otosvarianssi. Esimerkiksi lukujen 3, 2, 7, 2 keskiarvo on 3.5, joten otosvarianssi on ((3 - 3.5)² + (2 - 3.5)² + (7 - 3.5)² + (2 - 3.5)²)/(4 - 1) ≈ 5,666667.)

Muistava tuotevarasto, vaihe 1

Toteuta luokan Tuotevarasto aliluokkana MuistavaTuotevarasto. Uusi versio tarjoaa vanhojen lisäksi varastotilanteen muutoshistoriaan liittyviä palveluita. Historiaa hallitaan Muutoshistoria-oliolla.

Julkiset konstruktorit ja metodit:

  • public MuistavaTuotevarasto(String tuotenimi, double tilavuus, double alkuSaldo) luo tuotevaraston. Tuotenimi, vetoisuus ja alkusaldo annetaan parametrina. Aseta alkusaldo sekä varaston alkusaldoksi että muutoshistorian ensimmäiseksi arvoksi.
  • public String historia() palauttaa tuotehistorian tyyliin [0.0, 119.2, 21.2]. Käytä Muutoshistoria-olion merkkiesitystä sellaisenaan.

Huomaa että tässä esiversiossa historia ei vielä toimi kunnolla; nyt vasta vain aloitussaldo muistetaan.

Käyttöesimerkki:

// tuttuun tapaan:
MuistavaTuotevarasto mehu = new MuistavaTuotevarasto("Juice", 1000.0, 1000.0);
mehu.otaVarastosta(11.3);
System.out.println(mehu.getNimi()); // Juice
mehu.lisaaVarastoon(1.0);
System.out.println(mehu);           // Juice: saldo = 989.7, vielä tilaa 10.3
...
    // mutta vielä historia() ei toimi kunnolla:
System.out.println(mehu.historia()); // [1000.0]
    // saadaan siis vasta konstruktorin asettama historian alkupiste...
...
Juice
Juice: saldo = 989.7, vielä tilaa 10.299999999999955
[1000.0]

Muistava tuotevarasto, vaihe 2

On aika aloittaa historia! Ensimmäinen versio ei historiasta tiennyt kuin alkupisteen. Täydennä luokkaa metodein

  • public void lisaaVarastoon(double maara) toimii kuin Varasto-luokan metodi, mutta muuttunut tilanne kirjataan historiaan. Huom: historiaan tulee kirjata lisäyksen jälkeinen varastosaldo, ei lisättävää määrää!
  • public double otaVarastosta(double maara) toimii kuin Varasto-luokan metodi, mutta muuttunut tilanne kirjataan historiaan. Huom: historiaan tulee kirjata poiston jälkeinen varastosaldo, ei poistettavaa määrää!

Käyttöesimerkki:

// tuttuun tapaan:
MuistavaTuotevarasto mehu = new MuistavaTuotevarasto("Juice", 1000.0, 1000.0);
mehu.otaVarastosta(11.3);
System.out.println(mehu.getNimi()); // Juice
mehu.lisaaVarastoon(1.0);
System.out.println(mehu);           // Juice: saldo = 989.7, vielä tilaa 10.3
...
// mutta nyt on historiaakin:
System.out.println(mehu.historia()); // [1000.0, 988.7, 989.7]
...
Juice
Juice: saldo = 989.7, vielä tilaa 10.299999999999955
[1000.0, 988.7, 989.7]

Muista miten korvaava metodi voi käyttää hyväkseen korvattua metodia!

Muistava tuotevarasto, vaihe 3

Täydennä luokkaa metodilla

  • public void tulostaAnalyysi(), joka tulostaa tuotteeseen liittyviä historiatietoja esimerkin esittämään tapaan.

Käyttöesimerkki:

MuistavaTuotevarasto mehu = new MuistavaTuotevarasto("Juice", 1000.0, 1000.0);
mehu.otaVarastosta(11.3);
mehu.lisaaVarastoon(1.0);
//System.out.println(mehu.historia()); // [1000.0, 988.7, 989.7]

mehu.tulostaAnalyysi();
Tuote: Juice
Historia: [1000.0, 988.7, 989.7]
Suurin tuotemäärä: 1000.0
Pienin tuotemäärä: 988.7
Keskiarvo: 992.8

Muistava tuotevarasto, vaihe 4

Täydennä analyysin tulostus sellaiseksi, että mukana ovat myös muutoshistorian suurin muutos ja historian varianssi.

Perintä, rajapinnat, kumpikin, vai eikö kumpaakaan?

Perintä ei sulje pois rajapintojen käyttöä, eikä rajapintojen käyttö sulje pois perinnän käyttöä. Rajapinnat toimivat sopimuksena luokan tarjoamasta toteutuksesta, ja mahdollistavat konkreettisen toteutuksen abstrahoinnin. Rajapinnan toteuttavan luokan vaihto on hyvin helppoa.

Aivan kuten rajapintaa toteuttaessa, sitoudumme perittäessä siihen, että aliluokkamme tarjoaa kaikki yliluokan metodit. Monimuotoisuuden ja polymorfismin takia perintäkin toimii kuin rajapinnat. Voimme antaa yliluokkaa käyttävälle metodille sen aliluokan ilmentymän.

Abstrakti luokka

Abstrakti luokka yhdistää rajapintoja ja perintää. Niistä ei voi tehdä ilmentymiä, vaan ilmentymät tehdään tehdään abstraktin luokan aliluokista. Abstrakti luokka voi sisältää sekä normaaleja metodeja, joissa on metodirunko, että abstrakteja metodeja, jotka sisältävät ainoastaan metodimäärittelyn. Abstraktien metodien toteutus jätetään perivän luokan vastuulle. Yleisesti ajatellen abstrakteja luokkia käytetään esimerkiksi kun abstraktin luokan kuvaama käsite ei ole selkeä itsenäinen käsite. Tällöin siitä ei tule pystyä tekemään ilmentymiä.

Sekä abstraktin luokan että abstraktien metodien määrittelyssä käytetään avainsanaa abstract. Abstrakti luokka määritellään lauseella public abstract class LuokanNimi, abstrakti metodi taas lauseella public abstract palautustyyppi metodinNimi. Pohditaan seuraavaa abstraktia luokkaa Toiminto, joka tarjoaa rungon toiminnoille ja niiden suorittamiselle.

public abstract class Toiminto {

    private String nimi;

    public Toiminto(String nimi) {
        this.nimi = nimi;
    }

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

    public abstract void suorita(Scanner lukija);
}

Abstrakti luokka Toiminto toimii runkona erilaisten toimintojen toteuttamiseen. Esimerkiksi pluslaskun voi toteuttaa perimällä luokka Toiminto seuraavasti.

public class Pluslasku extends Toiminto {

    public Pluslasku() {
        super("Pluslasku");
    }

    @Override
    public void suorita(Scanner lukija) {
        System.out.print("Anna ensimmäinen luku: ");
        int eka = Integer.parseInt(lukija.nextLine());
        System.out.print("Anna toinen luku: ");
        int toka = Integer.parseInt(lukija.nextLine());

        System.out.println("Lukujen summa on " + (eka + toka));
    }
}

Koska kaikki Toiminto-luokan perivät luokat ovat myös tyyppiä toiminto, voimme rakentaa käyttöliittymän Toiminto-tyyppisten muuttujien varaan. Seuraava luokka Kayttoliittyma sisaltaa listan toimintoja ja lukijan. Toimintoja voi lisätä käyttöliittymään dynaamisesti.

public class Kayttoliittyma {

    private Scanner lukija;
    private List<Toiminto> toiminnot;

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

    public void lisaaToiminto(Toiminto toiminto) {
        this.toiminnot.add(toiminto);
    }

    public void kaynnista() {
        while (true) {
            tulostaToiminnot();
            System.out.println("Valinta: ");

            String valinta = this.lukija.nextLine();
            if (valinta.equals("0")) {
                break;
            }

            suoritaToiminto(valinta);
            System.out.println();
        }
    }

    private void tulostaToiminnot() {
        System.out.println("\t0: Lopeta");
        for (int i = 0; i < this.toiminnot.size(); i++) {
            String toiminnonNimi = this.toiminnot.get(i).getNimi();
            System.out.println("\t" + (i + 1) + ": " + toiminnonNimi);
        }
    }

    private void suoritaToiminto(String valinta) {
        int toiminto = Integer.parseInt(valinta);

        Toiminto valittu = this.toiminnot.get(toiminto - 1);
        valittu.suorita(lukija);
    }
}

Käyttöliittymä toimii seuraavasti:

Kayttoliittyma kayttolittyma = new Kayttoliittyma(new Scanner(System.in));
kayttolittyma.lisaaToiminto(new Pluslasku());

kayttolittyma.kaynnista();
Toiminnot:
        0: Lopeta
        1: Pluslasku
Valinta: 1
Anna ensimmäinen luku: 8
Anna toinen luku: 12
Lukujen summa on 20

Toiminnot:
        0: Lopeta
        1: Pluslasku
Valinta: 0

Rajapintojen ja abstraktien luokkien suurin ero on siinä, että abstrakteissa luokissa voidaan määritellä metodien lisäksi myös oliomuuttujia sekä konstruktoreja. Koska abstrakteihin luokkiin voidaan määritellä toiminnallisuutta, voidaan niitä käyttää esimerkiksi oletustoiminnallisuuden määrittelyyn. Yllä käyttöliittymä käytti abstraktissa luokassa määriteltyä toiminnan nimen tallentamista.

Tehtäväpohjan mukana tulee luokat Tavara ja Laatikko. Luokka Laatikko on abstrakti luokka, jossa useamman tavaran lisääminen on toteutettu siten, että kutsutaan aina lisaa-metodia. Yhden tavaran lisäämiseen tarkoitettu metodi lisaa on abstrakti, joten jokaisen Laatikko-luokan perivän laatikon tulee toteuttaa se. Tehtävänäsi on muokata luokkaa Tavara ja toteuttaa muutamia erilaisia laatikoita luokan Laatikko pohjalta.

Lisää kaikki uudet luokat pakkaukseen laatikot.

package laatikot;

import java.util.Collection;

public abstract class Laatikko {

    public abstract void lisaa(Tavara tavara);

    public void lisaa(Collection<Tavara> tavarat) {
        tavarat.stream().forEach(t -> lisaa(t));
    }

    public abstract boolean onkoLaatikossa(Tavara tavara);
}

Tavaran muokkaus

Lisää Tavara-luokan konstruktoriin tarkistus, jossa tarkistetaan että tavaran paino ei ole koskaan negatiivinen (paino 0 hyväksytään). Jos paino on negatiivinen, tulee konstruktorin heittää IllegalArgumentException-poikkeus. Toteuta Tavara-luokalle myös metodit equals ja hashCode, joiden avulla pääset hyödyntämään erilaisten listojen ja kokoelmien contains-metodia. Toteuta metodit siten, että Tavara-luokan oliomuuttujan paino arvolla ei ole väliä. Voit hyvin hyödyntää NetBeansin tarjoamaa toiminnallisuutta equalsin ja hashCoden toteuttamiseen.

Maksimipainollinen laatikko

Toteuta pakkaukseen laatikot luokka MaksimipainollinenLaatikko, joka perii luokan Laatikko. Maksimipainollisella laatikolla on konstruktori public MaksimipainollinenLaatikko(int maksimipaino), joka määrittelee laatikon maksimipainon. Maksimipainolliseen laatikkoon voi lisätä tavaraa jos ja vain jos tavaran lisääminen ei ylitä laatikon maksimipainoa.

MaksimipainollinenLaatikko kahviLaatikko = new MaksimipainollinenLaatikko(10);
kahviLaatikko.lisaa(new Tavara("Saludo", 5));
kahviLaatikko.lisaa(new Tavara("Pirkka", 5));
kahviLaatikko.lisaa(new Tavara("Kopi Luwak", 5));

System.out.println(kahviLaatikko.onkoLaatikossa(new Tavara("Saludo")));
System.out.println(kahviLaatikko.onkoLaatikossa(new Tavara("Pirkka")));
System.out.println(kahviLaatikko.onkoLaatikossa(new Tavara("Kopi Luwak")));
true
true
false

Yhden tavaran laatikko ja Hukkaava laatikko

Toteuta seuraavaksi pakkaukseen laatikot luokka YhdenTavaranLaatikko, joka perii luokan Laatikko. Yhden tavaran laatikolla on konstruktori public YhdenTavaranLaatikko(), ja siihen mahtuu tasan yksi tavara. Jos tavara on jo laatikossa sitä ei tule vaihtaa. Laatikkoon lisättävän tavaran painolla ei ole väliä.

YhdenTavaranLaatikko laatikko = new YhdenTavaranLaatikko();
laatikko.lisaa(new Tavara("Saludo", 5));
laatikko.lisaa(new Tavara("Pirkka", 5));

System.out.println(laatikko.onkoLaatikossa(new Tavara("Saludo")));
System.out.println(laatikko.onkoLaatikossa(new Tavara("Pirkka")));
true
false

Toteuta seuraavaksi pakkaukseen laatikot luokka HukkaavaLaatikko, joka perii luokan Laatikko. Hukkaavalla laatikolla on konstruktori public HukkaavaLaatikko(). Hukkaavaan laatikkoon voi lisätä kaikki tavarat, mutta tavaroita ei löydy niitä etsittäessä. Laatikkoon lisäämisen tulee siis aina onnistua, mutta metodin onkoLaatikossa kutsumisen tulee aina palauttaa false.

HukkaavaLaatikko laatikko = new HukkaavaLaatikko();
laatikko.lisaa(new Tavara("Saludo", 5));
laatikko.lisaa(new Tavara("Pirkka", 5));

System.out.println(laatikko.onkoLaatikossa(new Tavara("Saludo")));
System.out.println(laatikko.onkoLaatikossa(new Tavara("Pirkka")));
false
false

Tehtäväpohjassa on vuonna 1979 julkaistun Asteroids-nimisen pelin hahmotelma. Peli käynnistyy kun suoritat pakkauksessa asteroids olevan luokan AsteroidsOhjelma main-metodin.

Peli näyttää seuraavalta:

 

Tehtävänäsi on refaktoroida lähdekoodia -- eli muuttaa sitä selvemmäksi ilman että pelin toiminnallisuus muuttuu. Keskity pakkauksessa asteroids.domain oleviin luokkiin ja tunnista luokille mahdollisia yhtenäisiä yliluokkia sekä rajapintoja. Siirrä tämän jälkeen luokille yhtenäistä toiminnallisuutta näihin yliluokkiin.

Yhteisten yliluokkien sekä rajapintojen tunnistaminen kannattaa aloittaa luokkien toiminnallisuuksien tarkastelulla. Mietin millä luokilla on yhteisiä metodeja, ja mitkä ovatko jotkut luokat jonkinlaisen yleisemmän käsitteen ilmentymiä.

Dokumentoi tekemäsi muutokset yliluokkien kommentteihin. Tehtävä on kahden pisteen arvoinen -- tehtävään ei ole automaattisia testejä.

Voit halutessasi myös palata täydentämään ja muokkaamaan pelin toimintaa, sekä lisätä siihen ominaisuuksia. Tee kuitenkin tätä ennen myös seuraava tehtävä.

Tämä tehtävä on neljän tehtäväpisteen arvoinen. Huom! Toteuta kaikki toiminnallisuus pakkaukseen luola.

Huom: jotta testit toimisivat, ohjelmasi saa luoda vain yhden Scanner-olion. Älä käytä luokkien nimissä skandeja. Älä myöskään käytä staattisia muuttujia, testit suorittavat ohjelman useita kertoja joten staattisiin muuttujiin edellisillä suorituskerroilla jääneet arvot todennäköisesti häiritsevät testien toimintaa! -- ohjelmassa on vain muutamia testejä, varmistathan itse myös ohjelmasi toiminnan.

Tässä tehtävässä pääset toteuttamaan luolapelin. Pelissä pelaaja on luolassa hirviöitten kanssa. Pelaajan tehtävänä on ehtiä tallata kaikki hirviöt ennen kuin hänen lampustaan loppuu virta ja hirviöt pääsevät pimeän turvin syömään hänet. Pelaaja voi nähdä hirviöiden sijainnit välkäyttämällä lamppua, jonka jälkeen hänen on liikuttava sokkona ennen seuraavaa välkäytystä. Pelaaja voi kulkea monta askelta yhden siirron aikana.

Pelitilanne eli luola, pelaaja ja hirviöt esitetään pelaajalle tekstimuotoisesti. Tulostuksen ensimmäinen rivi kertoo jäljellä olevien siirtojen (eli lampun jäljellä olevan virran) määrän. Virran määrää seuraa pelaajan ja hirviöitten sijainnit, joiden jälkeen on pelitilanteesta piirretty kartta. Alla olevassa esimerkissä näet pelaajan (@) ja kolme hirviötä (h). Alla olevassa esimerkissä pelaajalla on virtaa neljääntoista siirtoon.

14

@ 1 2
h 6 1
h 7 3
h 12 2

.................
......h..........
.@.........h.....
.......h.........

Yllä olevassa esimerkissä virtaa on 14 välkäytykseen. Pelaaja @ sijatsee koordinaatissa 1 2. Huomaa että koordinaatit lasketaan aina pelialueen vasemmasta ylälaidasta lähtien. Alla olevassa kartassa merkki X on koordinaatissa 0 0, Y koordinaatissa 2 0 ja Z koordinaatissa 0 2.

X.Y..............
.................
Z................
.................

Käyttäjä voi liikkua antamalla sarjan komentoja ja painamalla rivinvaihtoa. Komennot ovat:

  • w liiku ylöspäin
  • s liiku alaspäin
  • a liiku vasemmalle
  • d liiku oikealle

Kun käyttäjän antamat komennot on suoritettu (niitä voi olla useampi), piirretään uusi pelitilanne. Lampun virta vähenee yhdellä aina kun uusi pelitilanne piirretään. Jos virta menee nollaan, peli loppuu ja ruudulle tulostetaan teksti HÄVISIT.

Hirviöt liikkuvat pelissä satunnaisesti, yhden askeleen jokaista pelaajan askelta kohti -- voit käyttää luokkaa Random satunnaisen liikkeen luomiseen (satunnainen liike luodaan esim. arpomalla luku väliltä 0-3, jokainen luku vastaa yhtä suuntaa). Jos pelaaja ja hirviö osuvat samaan ruutuun (vaikka vain tilapäisesti), hirviö tuhoutuu. Jos hirviö yrittää siirtyä pelilaudalta ulos tai ruutuun jossa on jo hirviö, jätetään siirto suorittamatta. Kun kaikki hirviöt on tuhottu, peli loppuu ja tulostetaan VOITIT.

Testaamisen helpottamiseksi tee peliisi luokka Luola, jolla on:

  • konstruktori public Luola(int leveys, int korkeus, int hirvioita, int siirtoja, boolean hirviotLiikkuvat)

    Luvut leveys ja korkeus antavat luolan koon (se on aina neliskulmainen), hirvioita antaa hirviöiden lukumäärän alussa (hirviöiden sijainnin voi arpoa), siirtoja antaa siirtojen lukumäärän alussa ja jos hirviotLiikkuvat on false, hirviöt eivät liiku.

  • metodi public void run(Scanner lukija) joka käynnistää pelin, ja jolle annetaan uusi Scanner-olio parametriksi

Huom! pelaajan tulee aloittaa sijainnista 0,0!

Huom! jos pelaaja tai hirviö koittaa liikkua ulos luolasta tai kaksi hirviötä koittaa liikkua samaan ruutuun, ei liikettä tule tapahtua!

Alla vielä selkeyden vuoksi esimerkkipeli:

14

@ 0 0
h 1 2
h 7 8
h 7 5
h 8 0
h 2 9

@.......h.
..........
.h........
..........
..........
.......h..
..........
..........
.......h..
..h.......

ssd
13

@ 1 2
h 8 8
h 7 4
h 8 3
h 1 8

..........
..........
.@........
........h.
.......h..
..........
..........
..........
.h......h.
..........

ssss
12

@ 1 6
h 6 9
h 6 5
h 8 3

..........
..........
..........
........h.
..........
......h...
.@........
..........
..........
......h...

dd
11

@ 3 6
h 5 9
h 6 7
h 8 1

..........
........h.
..........
..........
..........
..........
...@......
......h...
..........
.....h....

ddds
10

@ 6 7
h 6 6
h 5 0

.....h....
..........
..........
..........
..........
..........
......h...
......@...
..........
..........

w
9

@ 6 6
h 4 0

....h.....
..........
..........
..........
..........
..........
......@...
..........
..........
..........

www
8

@ 6 3
h 4 0

....h.....
..........
..........
......@...
..........
..........
..........
..........
..........
..........

aa
7

@ 4 3
h 4 2

..........
..........
....h.....
....@.....
..........
..........
..........
..........
..........
..........

w
VOITIT

Sisällysluettelo