Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Perintä ja rajapinnat olioiden yhteistyössä

osaamistavoitteet

  • Osaat hyödyntää rajapintoja ja abstrakteja luokkia luokkien välisen riippuvuuden välttämiseksi
  • Tunnistaa milloin perintää kannattaa käyttää, ja milloin koostaminen on parempi vaihtoehto. ("Composition over inheritance")

alt text

Perintä ja rajapinnat voivat toimia, ja usein toimivatkin yhdessä. Perintä määrittelee luokkien välisen hierarkian ja jakaa yhteistä toiminnallisuutta, kun taas rajapinnat määrittelevät kyvykkyyksiä, joita eri luokat voivat toteuttaa riippumatta niiden sijainnista luokkahierarkiassa.

Itse asiassa käytimme jo Älykoti-esimerkissämme sekä perintää (Laite abstraktina luokkana) että rajapintaa (Saadettava-rajapinta). Laajennetaan kuitenkin perinnän ja rajapintojen yhteistyötä hieman eteenpäin. Tarkastellaan tilannetta, jossa meillä on ohjelmassamme luokkia, jotka eivät jaa yhteistä yliluokkaa, mutta kuitenkin jakavat yhteisen kyvykkyyden.

Pistorasia ja sähkölaitteet

Tehdään pieni ajatusharjoitus. Kuvittele kotisi seinässä olevaa pistorasiaa. Pistorasia tarjoaa sähkövirtaa, mutta se ei anna sitä mihin tahansa. Se vaatii, että laitteessa on sopiva pistotulppa, joka sopii pistorasiaan.

Tässä analogiassa rajapinta on se standardi eli sopimus, jonka laitteen täytyy täyttää, jotta se voi käyttää pistorasiaa. Asiaa voidaan tarkastella myös niin päin, että jos laitteessa on pistorasiaan sopiva pistotulppa, niin sillä täytyy olla kyky toimia siinä tilanteessa, että se kytketään pistorasiaan.

Pistorasiaa ei kiinnosta, kytketkö siihen leivänpaahtimen vai sirkkelin. Laitteet ovat itse asiassa täysin erilaisia. Toisella voi tehdä ruokaa, toinen on työkalu. Niillä ei ole yhteistä "esi-isää" laitehierarkiassa samalla tavalla, kuin vaikkapa Auto ja Moottoripyora voisivat periä Ajoneuvo-luokan. Ainoa leivänpaahdinta ja sirkkeliä yhdistävä tekijä on kyky kytkeytyä verkkovirtaan.

Jos yrittäisimme mallintaa tämän perinnällä, joutuisimme ongelmiin heti, kun haluaisimme käyttää leivänpaahdinta. Onko leivänpaahdin Sahkolaite, Keittiolaite, vai kenties molempia? Javassa luokka ei kuitenkaan voi periä kahta yliluokkaa.

Rajapinta ratkaisee tämän ongelman tyylikkäästi:

  • Leivanpaahdin on Keittiolaite (perintä), mutta se myös toteuttaa Verkkovirtalaite-rajapinnan.
  • Samoin Sirkkeli voisi olla vaikkapa Tyokalu (perintä), joka myöskin toteuttaa saman Verkkovirtalaite-rajapinnan.

Näin pistorasia voi hyväksyä kumman tahansa laitteen, koska molemmat täyttävät sopimuksen eli toteuttavat rajapinnan vaatiman kytkennän.

Yksinkertaisimmillaan Verkkovirtalaite-rajapinnan sisältö olisi määritelmä siitä, että laitteen on pystyttävä reagoimaan siihen, kun se kytketään pistorasiaan ja virta alkaa kulkea johdossa.

public interface Verkkovirtalaite {
    // Tämä metodi on "pistotulppa". 
    // Kun pistorasia aktivoi tämän, laite saa sähköä.
    void kytkeVirta();
}

Nyt Leivanpaahdin ja Sirkkeli voivat toteuttaa tämän rajapinnan.

public class Leivanpaahdin implements Verkkovirtalaite {
    
    @Override
    public void kytkeVirta() {
        // Leivänpaahtimen oma tapa reagoida virtaan:
        IO.println("Leivänpaahdin: Vastukset alkavat hehkua punaisena.");
    }
}

public class Sirkkeli implements Verkkovirtalaite {
    
    @Override
    public void kytkeVirta() {
        // Sirkkelin oma tapa reagoida virtaan:
        IO.println("Sirkkeli: Moottori alkaa pyörittää terää 4000 rpm.");
    }
}

Nämä luokat voivat olla aivan eri puolella luokkahierarkiaa. Toinen on keittiölaite, toinen työkalu. Molemmat kuitenkin reagoivat sähkövirran kytkemiseen -- joskin omalla tavallaan. Tehdään vielä abstraktit Keittiolaite- ja Tyokalu-yliluokat, joista Leivanpaahdin ja Sirkkeli periytyvät. Jotta esimerkki olisi hieman mielekkäämpi, lisätään näihin yliluokkiin joitain ominaisuuksia ja metodeja.

public abstract class Keittiolaite {
    /**
     * Sisältääkö laite lämmitysvastuksia.
     */
    boolean lammittava;

    /**
     * Kaikki keittiölaitteet pitää voida pestä.
     */
    public abstract void puhdista();
}

public abstract class Tyokalu {
    /**
     * Laitteen käyttötunnit
     */
    private int kayttotunnit = 0;

    /**
     * Käytä laitetta
     * @param tunnit Montako tuntia laitetta käytetään.
     */
    public void kayta(int tunnit)
    {
        this.kayttotunnit = tunnit;
    }

    /**
     * Huolla laitetta
     * @return Onnistuiko huolto
     */
    public abstract boolean huolla();
}

Toteutetaan nyt nuo ominaisuudet ja metodit perivissä luokissa.

// Sirkkeli on Työkalu, joka toimii verkkovirralla
public class Sirkkeli extends Tyokalu implements Verkkovirtalaite {

    @Override
    public void kytkeVirta() {
        // Sirkkelin oma tapa reagoida virtaan:
        IO.println("Sirkkeli: Moottori alkaa pyörittää terää 4000 rpm.");

        // Kutsutaan tässä myös yliluokan kayta()-metodia, jolloin
        // käyttötunnit lisääntyvät.
        super.kayta(1);
    }

    /**
     * Huolletaan sirkkeli.
     * @return Onnistuiko huolto.
     */
    @Override
    public boolean huolla() {
        IO.println("Huolletaan sirkkeliä..."
         + "Teroitetaan terää ja säädetään kierrosnopeutta.");
        return true;
    }
}

// Leivänpaahdin on Keittiölaite, joka toimii verkkovirralla
public class Leivanpaahdin extends Keittiolaite 
implements Verkkovirtalaite {

    @Override
    public void kytkeVirta() {
        // Leivänpaahtimen oma tapa reagoida virtaan:
        IO.println("Leivänpaahdin: "
        + "Vastukset alkavat hehkua punaisena.");
    }

    @Override
    public void puhdista() {
        IO.println("Leivänpaahdin: Poistetaan murut "
        + "ja pyyhitään kevyesti kostealla rätillä.");
    }
}

Luokkahierarkia näyttäisi seuraavanlaiselta.

Tämä on tärkein kohta ymmärryksen kannalta: Pistorasia on luokka, joka käyttää rajapintaa.

public class Pistorasia {
    
    // Pistorasiaan voi kytkeä MINKÄ TAHANSA verkkovirtalaitteen.
    // Pistorasiaa ei kiinnosta, onko se sirkkeli vai paahdin.
    public void kytkeLaite(Verkkovirtalaite laite) {
        IO.println("--- Pistorasia antaa sähköä ---");
        
        // Pistorasia kutsuu sopimuksen mukaista metodia.
        // Tässä toteutuu polymorfismi: 
        // laite reagoi oikealla, sille ominaisella tavalla.
        laite.kytkeVirta();
    }
}

Huomaamme, että aliohjelman parametrin tyyppinä on Verkkovirtalaite-rajapinta! Parametrin ei tarvitse olla Leivanpaahdin, Sirkkeli tai mikään muukaan konkreettinen luokka. Riittää, että se toteuttaa Verkkovirtalaite-rajapinnan.

Tässä kytkeLaite()-metodi ottaa parametrinaan Verkkovirtalaite-rajapinnan mukaisen tyypin. Tämä tarkoittaa, että metodi voi hyväksyä minkä tahansa olion, joka toteuttaa tämän rajapinnan, riippumatta siitä, mihin luokkahierarkiaan kyseinen olio kuuluu.

Rajapinta muuttujan tyyppinä

Jotta Pistorasia-luokka pääsisi tositoimiin, tarvitsemme vielä pääohjelman, jossa luomme Pistorasia-olion ja kytkemme siihen erilaisia laitteita. Luodaan nyt pääohjelma, jossa kytketään ensin Leivanpaahdin pistorasiaan.

Esimerkki sisältää jo aika monta tiedostoa, joten lue esimerkki huolellisesti läpi. Voit vaihtoehtoisesti selata esimerkin tiedostoja GitHubissa.

main.java
public class KodinSahkot {

    public static void main(String[] args) {

        // 1. Luodaan infrastruktuuri: Pistorasia
        // Tässä kohtaa Pistorasia-olio syntyy tietokoneen muistiin.
        Pistorasia keittionPistoke = new Pistorasia();

        // 2. Luodaan laitteet
        Leivanpaahdin paahdin = new Leivanpaahdin();
        Sirkkeli sirkkeli = new Sirkkeli();

        // 3. Käytetään laitteita pistorasian kautta
        IO.println("--- Aamu keittiössä ---");

        // Kytketään paahdin seinään
        keittionPistoke.kytkeLaite(paahdin);
        IO.println("\n--- Remontti alkaa ---");

        // Kytketään sirkkeli SAMAAN pistorasiaan
        // Koska yhdessä pistorasiassa voi olla yksi laite kerrallaan,
        // paahdin irrotetaan, vaikka sitä ei erikseen
        // tässä esitetäkään.
        keittionPistoke.kytkeLaite(sirkkeli);
    }
}

Kuten Luvussa 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 Luvussa 3.2 alustettuun soitin-esimerkkiin. Oletetaan, että meillä on Soitin-rajapinta, joka määrittelee yleisölle musiikkia metodin soita().

public interface Soitin {
    /**
     * Esittää kappaleen yleisölle.
     */
    void soita();
}

Kaikki soittimet, kuten Kitara, Piano ja Rumpusetti, toteuttavat tämän rajapinnan. Konsertin järjestäjä haluaa varmistaa, että kaikki soittimet voivat soittaa huolimatta siitä, minkä tyyppisiä soittimia ne ovat. Tehdään Konsertti-luokka, jonka soitaKaikkiaSoittimia()-metodi laittaa kaikki soittimet soimaan.

public class Konsertti {
    public void soitaKaikkiaSoittimia(Soitin[] soittimet) {
        for (Soitin soitin : soittimet) {
            soitin.soita();
        }
    }
}

Tehdään nyt uusi soitin, HarjoitusPiano, jolla voi soittaa vain kuulokkeilla, jolloin yleisö ei kuule mitään. Tämäkin soitin toteuttaa Soitin-rajapinnan, mutta sen käyttäytyminen poikkeaa muista soittimista.

class HarjoitusPiano implements Soitin {
    @Override
    public void soita() {
        // Toteutus, joka tekee sinänsä jotain "järkevää", mutta rikkoo sopimuksen.
        IO.println("Harjoitellaan kuulokkeilla. Yleisö ei kuule mitään.");
    }
}

Kootaan nyt "konsertti" pääohjelmaan.

void main() {
    Soitin[] soittimet = {
        new Kitara(),
        new Piano(),
        new HarjoitusPiano()
    };

    Konsertti konsertti = new Konsertti();
    konsertti.soitaKaikkiaSoittimia(soittimet);
}

Koodi kyllä sinänsä toimii teknisesti. Silti HarjoitusPiano rikkoo Soitin-rajapinnan sopimusta: sen soita() ei "esitä kappaletta yleisölle", vaan vain soittajalle itselleen. Aliluokan toiminta voisi olla järkevää jossain toisessa kontekstissa (tässä, harjoittelutilanteessa). Hyvin suunnitellussa luokkahierarkiassa kuitenkin jokainen olio noudattaa yliluokan tai rajapinnan lupaamaa sopimusta. Tällöin polymorfismia voidaan käyttää luotettavasti ilman, että ohjelman käyttäjän tarvitsee tuntea kaikkia konkreettisia aliluokkia erikseen.

Myös kodin sähkölaitteet -esimerkkimme liittyy samaan aiheeseen. Muistetaan, että koska Esimerkissä 3.8 paahdin ja sirkkeli määriteltiin Verkkovirtalaite-tyyppisiksi, emme voi kutsua niille metodeja, jotka eivät ole määritelty kyseisessä rajapinnassa. Esimerkiksi emme voi kutsua paahdin.puhdista() tai sirkkeli.huolla(), koska nämä metodit eivät kuulu Verkkovirtalaite-rajapintaan. Jos todella tarvitsisimme pääsyn näihin metodeihin, meidän tulisi pysähtyä miettimään, miksi käsittelemme oliota ylipäätään pelkkänä verkkovirtalaitteena. Ohjelmistosuunnittelun näkökulmasta tilanne vihjaa siihen, että yritämme ehkä ratkaista kahta eri ongelmaa samassa paikassa.

Hyvässä suunnittelussa koodi, joka käsittelee Verkkovirtalaite-tyyppisiä olioita (kuten sähkömittari tai sulakekaappi), on kiinnostunut vain ja ainoastaan sähköön liittyvistä asioista. Sitä ei pitäisikään kiinnostaa, onko laite leivänpaahdin vai sirkkeli, eikä sen kuulu yrittää puhdistaa tai huoltaa niitä.

Jos huomaamme tarvitsevamme puhdista()-metodia, olemme todennäköisesti "väärässä huoneessa":

  • Väärä abstraktiotaso: Jos olemme rakentamassa sovelluslogiikkaa keittiön siivousta varten, meidän ei pitäisi säilyttää laitteita List<Verkkovirtalaite>-listassa, vaan List<Keittiolaite>-listassa. Tällöin kaikilla listan olioilla on luonnostaan puhdista()-metodi käytettävissä ilman kikkailua.

  • Vastuun jako (Single Responsibility): Jos samassa aliohjelmassa yritetään sekä mitata sähkönkulutusta (rajapinta) että pestä laite (abstrakti luokka), aliohjelma tekee liikaa asioita. Parempi ratkaisu on jakaa ohjelma osiin: yksi osa hallinnoi sähköverkkoa (Verkkovirtalaite-rajapinnan kautta) ja toinen osa huolehtii ylläpidosta (Keittiolaite- tai Tyokalu-tyyppien kautta).

Tiivistetysti: Sen sijaan, että yrittäisimme pakottaa yleisen rajapinnan kautta esiin erityisominaisuuksia, meidän tulisi valita muuttujan tyyppi sen mukaan, mitä olemme sillä hetkellä tekemässä. Sähkömies näkee sirkkelin verkkovirtalaitteena, puuseppä näkee sen työkaluna – ja koodin tulisi heijastaa tätä roolijakoa."

Abstrakti luokka vai rajapinta?

Alla on lyhyt yhteenvetotaulukko, joka tiivistää abstraktin luokan ja rajapinnan keskeiset erot syntaktin ja käyttötarkoituksen osalta.

KysymysAbstrakti luokkaRajapinta
Voiko sisältää attribuutteja?KylläEi
Voiko sisältää metodien toteutuksia?KylläEi (Java v8 alkaen mahdollisuus ns. default-metodeihin)
Kuinka monta voi periä/toteuttaa?Luokka voi periä vain yhden abstraktin luokanLuokka voi toteuttaa useita rajapintoja
KäyttötarkoitusYhteinen runko ja osittainen toteutusYhteinen sopimus käyttäytymisestä

Tehtävät

Tehtävä 4.3: Seikkailupeli. 1 p.

Toteutetaan yksinkertainen tekstiseikkailupeli (tai oikeammin pieni palanen pelistä), jossa pelaaja voi yrittää poimia esineitä maasta, sekä syödä saadakseen energiaa. Saat valmiina kaksi rajapintaa: Syotava ja Poimittava. Lisäksi saat osittain toteutetut luokat: Omena ja IsoKivi, jotka toteuttavat nämä rajapinnat. Edelleen, saat osittain toteutetun pääohjelman, jossa pelaajan energiaa ja repun tilaa seurataan.

Täydennä kaikki TODO-sanalla merkityt osat, jotta ohjelma toimii ohjeiden mukaisesti.

Tee tehtävä TIMissä
Bonus: Tehtävä 4.4: Kotityörobotti. 1 p.

Tee Robotti, joka osaa suorittaa erilaisia kotitöitä, kuten imurointia ja kukkien kastelua.

Toteuta tehtävä oheisen UML-kaavion mukaisesti. Katkoviiva, jossa on musta nuoli, tarkoittaa, että Robotti-luokka käyttää KayttoEsine-rajapintaa: Robotti-luokka sisältää attribuutin, joka on tyyppiä KayttoEsine.

Kuvaus sanallisessa muodossa

Tässä on kuvaus luokista ja niiden vaadituista ominaisuuksista (vastaavat kuin UML-kaaviossa):

Robotilla on seuraavat metodit:

  • void vaihdaKayttoEsine(KayttoEsine esine): Vaihtaa robotin käyttämän esineen (esim. imuri tai kastelukannu).
  • void teeTyota(String kohde): Suorittaa kotityön. Jos kohde on sillä listalla, jotka kyseiseltä käyttöesineeltä on kielletty (esim. Kastelukannu-oliolla ei saa kastella "Tietokone"-kohdetta), robotin tulee tulostaa virheilmoitus. Kielletyt käyttökohteet määritellään käyttöesineen attribuuttina merkkijonolistana.
  • Kastelukannu-olio ei kastele jos vettä ei ole riittävästi. Sen voi täyttää taytaVesi()-metodilla. Kastelukannun vesimäärä on aluksi 50 yksikköä. Voit halutessasi tehdä uuden muodostajan, joka asettaa vesimäärän alkutilan toiseksi.
  • Imuri-olio ei imuroi jos roskasäiliö on täynnä. Sen voi tyhjentää tyhjennaSailio()-metodilla. Roskasäiliön kapasiteetti on 100 yksikköä. Voit halutessasi tehdä uuden muodostajan, joka asettaa roskasäiliön alkutilan toiseksi.
  • Molemmat käyttöesineet palauttavat kayta(String kohde)-metodin avulla totuusarvon, joka kertoo onnistuiko työ.
Tee tehtävä TIMissä