Heikki Kainulainen

MONIPERINTÄ VASTAAN RAJAPINNAT

Tietotekniikan LuK-tutkielma, 17.4.2002

Jyväskylän Yliopisto, Tietotekniikan laitos

Tekijä: Heikki Kainulainen

Yhteystiedot: <heikki.kainulainen@cc.jyu.fi>

Työn nimi: Moniperintä vastaan rajapinnat

Title in English: Multiple inheritance vs. interfaces

Työ: LuK-tutkielma

Avainsanat: perintä, moniperintä, rajapinnat

Keywords: inheritance, multiple inheritance, interfaces

Tiivistelmä: Moniperintä on ristiriitainen oliokielen ominaisuus. Eräät ohjelmointikielet tarjoavatkin mahdollisuuden ainoastaan täysin abstraktien luokkien, rajapintojen, moniperintään. Tässä tutkielmassa esitetään syitä tähän ja pohditaan moniperinnän tarpeellisuutta.

Sisältö

1 Johdanto

Moniperintä (multiple inheritance) on eräs olio-ohjelmoinnin kiistellyimpiä kysymyksiä. Tässä tutkielmassa pyritään listaamaan ja selittämään syitä tähän. Lisäksi pohditaan sitä, onko moniperintä ohjelmointikielen tarpeellinen ominaisuus.

Eräissä ohjelmointikielissä sallitaan ainoastaan rajapintojen (interface), täysin abstraktien luokkien, moniperintä. Tässä tutkielmassa selvitetään myös sitä, että miten hyvin tällä tekniikalla saadaan korvattua perinteinen toteutusten moniperintä.

Luvussa 2 esitellään perinnän (inheritance) alkeet lyhyesti ja selitetään tärkeimpiä aiheeseen liittyviä termejä.

Luvussa 3 kerrotaan abstrakteista luokista. Lisäksi luvussa käsitellään rajapintoja, jotka ovat äärimmilleen vietyjä abstrakteja luokkia.

Luvussa 4 selvitetään moniperinnän ideaa ja pyritään valottamaan niitä syitä, jotka tekevät siitä niin kiistanalaisen aiheen.

Luvuissa 5, 6 ja 7 päästäänkin jo kieliopin puolelle. Näissä luvuissa käsitellään kolmea ohjelmointikieltä, jotka ovat C++, Object Pascal (Delphi) ja Java. Kieliä käsitellään tämän tutkielman aihepiirin puitteissa, eli valokeilassa ovat moniperintä ja rajapinnat näiden kielten näkökulmasta.

Luku 8 on yhteenveto.

2 Perintä

Tässä luvussa käsitellään perinnän alkeet.

2.1 Esimerkkitilanne

Kuvitellaan tilannetta, jossa joudumme simuloimaan kaupungin liikennettä. Esimerkiksi tavallinen auto ja poliisiauto eroavat toisistaan vain siinä, että poliisiauto saa ajaa päin punaisia ja kaikki muut autot pelkäävät poliisiautoja.

Oletetaan, että meillä on valmiina auton koodi:

class Auto {
public:
  virtual void liiku();
};

Poliisiauto on siis Auto, johon on tehty vain muutamia muutoksia. Tällaisessa tilanteessa on järkevää yrittää käyttää samaa pohjaa hyväkseen. Ensimmäisenä tulee mieleen, että tavallisen auton koodin voisi manuaalisesti kopioida toiseen tiedostoon käyttäen "leikkaa & liimaa" -tekniikkaa. Sitten muutettaisiin vain luokan nimeä ja lisättäisiin tarvittavat uudet metodit ja attribuutit.

Edellä kuvattu tapa on luonnollisesti huonoin mahdollinen ratkaisumalli. Poliisiauto ja Auto eivät tuossa toimintamallissa tiedä toistensa olemassaolosta; Auto ei tunne Poliisiautoa ja sama toisinpäin. Auto ja Poliisiauto eivät ole ollenkaan yhteensopivia, vaikka rakenteeltaan ne muistuttavatkin paljon toisiaan.

Parempi tapa luokan laajentamiseen olisi esimerkiksi koostaminen. Tällä tarkoitetaan sitä, että halutut luokat liitetään uuteen luokkaan attribuutteina. Poliisiauto-luokkaan voidaan siis liittää Auto-luokka attribuuttina. Tämäkään ei kuitenkaan vaikuta parhaalta mahdolliselta toimintatavalla tässä tilanteessa. Auto on tässä Poliisiauton ominaisuutena, mutta Poliisiauto ei edelleenkään ole Auto.

Seuraavaksi voimmekin siirtyä parhaaseen mahdolliseen tilanteen toteutustapaan: Teemme uuden luokan nimeltään Poliisiauto ja perimme sen luokasta Auto. C++-kielellä koodi voisi näyttää tällaiselta:

class Poliisiauto : public Auto{
public:
  virtual void liiku();
  virtual void takaa_aja();
};

2.2 Alkeet

Ohjelmoinnissa perinnällä tarkoitetaan sitä, että luokka joka perii saa käyttöönsä perittävän luokan ominaisuudet.

Sitä luokkaa, joka perii, sanotaan lapsiluokaksi (child class) tai aliluokaksi (subclass). Perittävää luokkaa puolestaan kutsutaan isäluokaksi (parent class) tai yliluokaksi (superclass). Periytymisestä ilmoittaa aliluokka ja tästä seuraa se, että luokka tuntee yliluokkansa, mutta ei aliluokkiaan.

Periytymisen etuja ovat koodin uudelleenkäyttö, mahdollisuus monimuotoisuuteen (polymorphism) ja mahdollisuus mallintaa suoraan asioiden luonnollista hierarkiaa. Koodin uudelleenkäyttö aiheutuu aliluokan kyvystä käyttää perittyjä piirteitä kuin omiaan ja luonnollisten hierarkioiden mallintamismahdollisuuksien edut ovat ilmeisiä.

Monimuotoisuudella tarkoitetaan tässä mahdollisuutta käyttää aliluokan viitteitä tai osoittimia yliluokan viitteiden tai osoittimien sijasta. Esimerkiksi jos funktio haluaa parametrina Auto-tyyppisen viitteen tai osoittimen, voidaan sille yhtä hyvin antaa viite tai osoitin Poliisiautoon, Pakettiautoon tai mihin tahansa luokasta Auto perittyyn luokkaan. Päinvastainen logiikka ei päde, sillä Poliisiauto on Auto, mutta Auto ei ole välttämättä Poliisiauto.

Olkoon meillä valtatietä kuvaava funktio. Jos erilaisia autoja kuvaavat luokat olisi tehty alkuperäisen suunnitelman mukaan ilman perinnän käyttöä, jouduttaisiin jokaiselle autotyypille tekemään oma funktio, mutta nyt riittää yksi funktio, joka saa parametrina Auto-tyyppisen viitteen tai osoittimen. Samoin Auto-luokasta ja sen aliluokista voidaan periä vaikka kuinka monta uutta ajoneuvoa, eikä valtatietä kuvaavalle funktiolle tarvitse tehdä muutoksia, mikä taas olisi ilman monimuotoisuutta tarpeellista jokaisen uuden ajoneuvon teon jälkeen.

void valtatie(const Auto &a){
  a.liiku();
};

Jos yliluokan funktiot on määritelty virtuaalisiksi (virtual) ja aliluokka korvaa (override) ne samannimisillä funktioilla, saadaan monimuotoisuudesta täysi hyöty. Jos nimittäin tällöin esimerkiksi valtatietä kuvaavalle funktiolle annetaan parametrinä viite Poliisiautoon, joka on korvannut Auton liikkumismetodin omallaan, suoritetaan rivillä a.liiku() Poliisiauton liikkumismetodi.

Javassa funktiot ovat oletuksena virtuaalisia. Funktion virtuaalisuus voidaan kumota käyttämällä sen yhteydessä avainsanaa final. Jos kielenä on C++ tai Object Pascal, käytetään funktion tekemiseksi virtuaaliseksi avainsanaa virtual.

Jos funktiota kutsuttaessa on ristiriidan vaaraa, on esimerkiksi C++-kielessä käytettävä näkyvyysoperaattoria (scope resolution operator) :: . Jos ristiriidan vaaraa ei ole, on näkyvyysoperaattorin käyttö turhaa. Javassa yliluokan ominaisuuksiin voidaan viitata avainsanalla super ja Object Pascalissa avainsanalla inherited.

Perintä voidaan nähdä joko koodin kirjoittamistarvetta vähentävänä tekijänä tai käsitteellistä mallintamista tukevana asiana. Jälkimmäisestä seuraa yleensä edellinen, mutta päinvastainen ei välttämättä päde.

Perintä kuvataan piirroksessa siten, että lapsiluokasta on nuoli isäluokkaan. Esimerkki tällaisesta piirroksesta löytyy luvusta 4.2.2. Kyseinen kuva on ainakin melko lähellä nykyisin muodissa olevaa UML-esitystä.

Perinnän lisäksi toinen hyvä tapa luokan laajentamiseen on siis koostaminen. Valinnassa perinnän ja koostamisen välillä voi yleensä soveltaa seuraavaa sääntöä: Jos lapsiluokka on isäluokka, peritään, mutta jos lapsiluokassa on isäluokka, koostetaan. Poliisiauto on auto, joten perintä on paras ratkaisu. Auto puolestaan ei ole moottori, joten tässä on hyvä käyttää koostamista perinnän sijasta.

3 Abstrakti luokka

On olemassa luokkia, jotka ovat mielekkäitä ainoastaan aliluokkien yliluokkina. Esimerkkinä tällaisesta luokasta voisi olla kulkuneuvo. Tiedämme kyllä kulkuneuvon tarvitsemat tärkeimmät metodit, mutta emme niiden toteutustapaa, sillä esimerkiksi auton ja riippuliittimen liikkumismetodit ovat hyvin erilaisia.

Tiedämme kuitenkin sen, että jokainen kulkuneuvo tarvitsee ainakin metodin liiku(). Emme kuitenkaan pysty kulkuneuvo-luokkaa tehdessämme ottamaan kantaa tuon metodin tarkempaan toteutukseen. Tällöin on järkevää tehdä liiku() -metodista abstrakti metodi, jonka tarkemmat yksityiskohdat perivä luokka voi määritellä.

class Liikkuva {
public:
  virtual void liiku() = 0;
//...
};

Abstrakti metodi määrittelee siis ainoastaan metodin nimen, parametrit ja paluuarvon. Varsinainen funktion toteutus jätetään mahdollisten aliluokkien tehtäväksi.

Jos luokassa on vähintään yksi abstrakti metodi, se on tällöin abstrakti luokka. Abstraktiin luokkaan kuuluvia olioita ei voi luoda, eli niitä voidaan käyttää vain muiden luokkien yliluokkina. Jos abstrakteja metodeja ei määritellä luokassa, joka perii kyseisen abstraktin luokan, pysyy kyseinen metodi abstraktina. Tästä on tietenkin seurauksena se , että myös tästä perivästä luokasta tulee abstrakti luokka. [Stroustrup2, §12.3.]

On korostettava, että abstrakti luokka tarkoittaa ainoastaan sitä, että kyseiseessä luokassa on ainakin yksi abstrakti metodi. Luokassa voi toki määritellä myös attribuutteja ja normaaleja funktioita.

Abstraktien metodien luomiseen käytetään Javassa ja Object Pascalissa avainsanaa abstract. C++ -kielessä puolestaan metodista tehdään abstrakti lisäämällä sen otsikon perään merkintä "=0". Outo syntaksi johtuu siitä, että tohtori Stroustrup ei halunnut aikanaan ottaa uutta avainsanaa käyttöön. Tämä olisi hänen mukaansa aiheuttanut riitaa ja eripuraa ja olisi tästä johtuen viivästyttänyt abstraktien luokkien saamista kieleen. [Stroustrup, 1994 §13.2.3]

3.1 Rajapinnat

Rajapinta tarkoittaa paljon muutakin, mutta tässä tutkielmassa sanalla rajapinta tarkoitetaan toimintatapaa, josta esimerkiksi Delphissä ja Javassa käytetään avainsanaa interface. Jos tarkoitetaan sanaa rajapinta laajemmassa merkityksessä, käytetään tässä tutkielmassa sanaa liittymä. Tämä ei ole mikään yleinen käytäntö, mutta käytän näitä sääntöjä sekaannusten välttämiseksi, tai mikä on todennäköisempää, niiden lisäämiseksi.

Rajapinnat ovat täysin abstrakteja luokkia. Ne sisältävät ainoastaan abstrakteja metodeja.

Eräissä kielissä on siis oma syntaksi ja avainsana täysin abstrakteille luokille. Eräs tärkeä syy tähän on mahdollisuus saavuttaa osa moniperinnän hyödyistä ja välttää kyseisen tekniikan pahimmat ongelmat sallimalla ainoastaan rajapintojen moniperintä. Tähän aiheeseen palataan useita kertoja myöhemmissä luvuissa.

Rajapinta takaa sen, että liittymä tosiaankin on mahdollisimman abstrakti. Tämä puolestaan mahdollistaa liittymät, jotka ovat riippumattomia muun muassa varsinaisesta toteutuskielestä ja käyttöjärjestelmästä.

Kielestä riippuen rajapinnat voivat sisältää myös muutakin kuin abstrakteja metodeja. Esimerkiksi Javassa rajapinnassa voi määritellä vakioarvoja. Nämä mahdolliset lisäominaisuudet ovat kuitenkin aina sellaisia, että ne eivät vähennä rajapinnan abstraktiutta.

C++ ei sisällä omaa avainsanaa rajapinnoilla. Sama toiminnallisuus saadaan kuitenkin tekemällä luokka, joka sisältää ainoastaan abstrakteja metodeja.

4 Moniperintä

Joissain ohjelmointikielissä luokka voidaan periä suoraan useammasta kuin yhdestä kantaluokasta. Tällöin on kyseessä moniperintä.

Grady Booch kirjoittaa moniperinnästä muun muassa seuraavasti [Booch, §3.4.]:

"Moniperintä on kuin laskuvarjo: sitä tarvitaan harvoin, mutta kun sitä tarvitaan, saa sen olemassaolosta olla todella kiitollinen."

Edellinen lause on aika vahva kannanotto moniperinnän puolesta mieheltä, jolla on pitkä kokemus käytännön ohjelmointitehtävistä. Moniperinnällä riittää myös runsaasti vastustajia. Luvuissa 4.1 ja 4.2 esitellään syitä, joita moniperinnän vastustajat yleensä esittävät kantansa tueksi. Luvussa 5.2 on kritiikkiä, joka koskee erityisesti C++:n versiota moniperinnästä.

Kirjallisuudessa esiintyvät esimerkit moniperinnästä ovat usein näytöksiä, joiden tarkoitus on esittää lista kaikista mahdollisista toimintatavan ongelmista. Tämä onkin järkevää, mutta monille ohjelmoijille jää tämän seurauksena kielteinen kuva moniperinnästä.

Syynä moniperintää kunnolla puoltavien esimerkkien puuttumiseen kirjallisuudesta voidaan pitää sitä, että parhaiten moniperintää puoltavat esimerkit ovat liian laajoja esitettäväksi varsinkin sellaisissa kirjoissa, jotka eivät varsinaisesti keskity moniperintään.

4.1 Hierarkioiden monimutkaistuminen

Moniperintää käytettäessä luokkien periytymissuhteet monimutkaistuvat. Hierarkia muuttuu puumaisesta rakenteesta verkkomaiseksi rakenteeksi. [Koskimies §3.2]

Hierarkioiden monimutkaistumista pidetään moniperinnän vakavana ongelmana, mutta mielestäni tulisi muistaa, että ei moniperintä suinkaan välttämättä aiheuta tätä ongelmaa, ainakaan jos sitä käytetään oikein. Pikemminkin itse mallinnettava hierarkia on monimutkainen ja moniperintä vain auttaa mallintamaan sitä. Täytyy muistaa, että eivät kaikki luonnollisetkaan hierarkiat ole aina puumaisia.

4.2 Toteutuksen ongelmat

Ohjelmointikielten kannalta moniperintä aiheuttaa kaksi tärkeää ongelmaa. Ensinnäkin moniperintä mahdollistaa nimikonfliktit eri yliluokkien välillä. Toiseksi moniperintä mahdollistaa toistuvan periytymisen. [Booch §2.2]

4.2.1 Nimikonfliktit

Nimikonflikteja syntyy, kun kahdella tai useammalla yliluokalla on samannimisiä ominaisuuksia. Tällöin ei tiedetä, mitä kyseisistä ominaisuuksista tarkoitetaan. Ratkaisuna on yleensä kertoa perittyjä ominaisuuksia käytettäessä minkä yliluokan ominaisuuksia tarkoitetaan.

4.2.2 Toistuva perintä

Toistuva periytyminen tarkoittaa saman luokan perimistä useita kertoja eri reittejä pitkin. Tässä tilanteessa on kaksi vaihtoehtoista mahdollisuutta hierarkialle.

Erottelevassa moniperinnässä monta kertaa peritty luokka esiintyy tosiaankin monta kertaa perivässä luokassa. Yhdistävässä moniperinnässä puolestaan luokka esiintyy vain kerran perivässä luokassa, vaikka se olisikin peritty useita reittejä pitkin. Yhdistävää moniperintää kutsutaan myös virtuaaliseksi ja erottelevaa puolestaan tavalliseksi moniperinnäksi.

Hierarkian muodosta johtuen käytetään toistuvasta perinnästä usein termiä timanttiperintä. Joskus termistä käytetään myös suomennosta "salmiakkiperintä".

Oheisen kuvan pitäisi selventää erottelevan ja yhdistävän perinnän käsitteitä:

Moniperintä

Vasemmalla esimerkkihierarkia yhdistävästä eli virtuaalisesta moniperinnästä ja oikealla erottelevasta eli tavallisesta moniperinnästä.

4.3 Moniperinnän välttäminen

Moniperintä voidaan haluttaessa välttää. Jos kieli ei tue moniperintää, on tämä myöskin ainoa toimintamahdollisuus.

Jotta moniperintä voitaisiin välttää, täytyy hierarkiaa mahdollisesti muuttaa. Moniperinnän puuttuminen estää kaikkien hierarkioiden siirtämisen suoraan luokiksi.

Jos käytettävissä on edes rajapintojen moniperintä, on koostamisen ja rajapintojen moniperinnän yhdistelmä järkevin keino moniperinnän poistamiseen. Tekeillä olevaan luokkaan voidaan periä tärkein potentiaalisista yliluokista ja loput halutut luokat voidaan liittää koostamalla. Lisäksi luokkaan otetaan käyttöön tarvittavat rajapinnat, jotta moniperinnän mukanaan tuoma monimuotoisuus saadaan täysin hyödynnettyä.

Rajapinnat tuovat mukanaan ohjelmointitavan, jossa miltei kaikki mahdolliset luokat peritään rajapinnoista. Jotta rajapinnoilla saataisiin korvattua moniperintä, on tämä myös tarpeen.

Jos minkäänlaista moniperinnän muotoa ei ole käytettävissä, voidaan sitä yrittää simuloida hyödyntämällä mahdollisuutta määritellä luokan sisällä uusia luokkia, sisäluokkia. Tätä mahdollisuutta voidaan hyödyntää esimerkiksi perimällä luokka yhdestä luokasta ja määrittelemällä luokan sisällä toinen luokka, joka perii toisen halutuista yliluokista. Tuossa oletettiin, että haluttuja yliluokkia olisi kaksi kappaletta, mutta samaa voidaan luonnollisesti soveltaa useammankin halutun yliluokan tapauksessa. [Sutter §38]

4.4 Moniperinnän käyttö

C++-kielen kehittäjä Bjarne Stroustrup esittää kolme tapausta, joissa moniperintä on osoittanut hyödyllisyytensä [Stroustrup, 1994 §12.6]:

· Erillisten hierarkioiden yhdistäminen. Tämä on hyödyllistä varsinkin silloin, jos hierarkiat ovat toisen yrityksen toimittamia ja niiden toteutuskoodi ei ole käyttäjän muunneltavissa.

· Liittymien yhdistäminen.

· Liittymän ja toteutuksen yhdistäminen luokaksi.

Näiden kolmen aliotsikon alle on kerättävissä kaikki moniperinnän käyttömuodot. Mielenkiintoinen asia on se, että kaksi jälkimmäistä tapausta voidaan pitkälti toteuttaa sallimalla ainoastaan rajapintojen moniperintä. Liittymän ja toteutuksen yhdistämisessä voi tosin tällöin olla ainoastaan yksi toteutus.

Kaksi rajapintojenkin moniperinnän mahdollistavaa kohtaa kattavat moniperinnän käytön abstraktiomekanismina. Ensimmäinen kohta siis lähinnä auttaa koodin kierrättämistä.

Luettelon ensimmäisen kohdan merkitystä ei kuitenkaan sovi vähätellä. Juuri mahdollisuutta yhdistää kaksi yhteen kuulumatonta luokkaa osana uuden luokan toteutusta voidaan pitää eräänä suurimmista moniperinnän käytännön hyödyistä.

5 C++ ja moniperintä

C++ tukee moniperintää. Syntaksi on ilmeinen:

class Maija: public Pakettiauto, public Poliisiauto {
  //...
};

5.1 Suojaustasot

Perittäessä ilmoitetaan millä suojaustasolla luokka halutaan periä. Käytettävissä ovat normaalit tasot public, protected ja private. Logiikka suojaustasoilla on sama kuin tavallisten jäsentenkin suojaustasoilla.

Luokka saatetaan periä toistuvasti eri reittejä pitkin ja eri suojausmääreillä. Tällöin luokka on käytettävissä matalimman esitetyn suojausmäärittelyn mukaan. Luokka on siis käytettävissä, jos se on käytettävissä jotakin reittiä pitkin. [Stroustrup §15.3.2.1]

5.2 Motiivi

C++:n kaikki luokat eivät periydy yhteisestä yliluokasta, kuten Javassa Object-luokasta, vaan siinä voi olla useita erillisiä hierarkioita. Moniperintä on hyödyllinen ominaisuus yhdistettäessä eri hierarkioihin kuuluvia luokkia ja samalla yhdistettäessä erillisiä hierarkioita toisiinsa.

Perinteinen moniperinnän käyttötarkoitus erillisten luokkahierarkioiden mahdollistavissa kielissä on yleiskäyttöisten säiliöiden tekeminen. Tämähän onnistuu perimällä luokka molemmissa hierarkioissa olevista luokista ja tekemällä säiliö, joka tallentaa tätä tyyppiä olevia olioita. Mallien tulo C++-ohjelmointikieleen on kuitenkin tehnyt tämän käyttötavan tarpeettomaksi.

5.3 Nimikonfliktit

Nimikonfliktien mahdollisuus ei ole virhe. Metodin kutsuminen tavalla, joka on moniselitteinen, puolestaan on virhe. Tällaisissa tilanteissa on määriteltävä, mitä metodia tarkalleen tarkoitetaan. Paras tapa yleensä on kuitenkin määritellä perivässä luokassa uusi metodi, joka korvaa moniselitteiset metodit. [Stroustrup §15.2.1]

Joskus kuitenkin luokka perii samannimisiä metodeja, joilla kuitenkin on aivan eri käyttötarkoitukset. Jos halutaan, että molempia metodeja tulisi voida käyttää aliluokasta, voidaan esimerkiksi määritellä uudet luokat, joiden ainoa tarkoitus on saada samannimiset metodit nimettyä uudelleen, ennen kuin ne moniperitään. [Sutter §39]

Eri yliluokkiin kuuluvien metodien moniselitteisyyksiä ei ratkaista argumenttityyppien perusteella. Jos valinnan halutaan perustuvan argumenttityyppeihin, voidaan metodit tuoda samalle viittausalueelle käyttämällä using-esittelyä. [Stroustrup §15.2.2]

5.4 Toistuva perintä

Toistuvan periytymisen esiintyessä C++ tukee sekä tavallisia että virtuaalisia kantaluokkia. Tavalliset ei-virtuaaliset kantaluokat ovat oletuksena, koska se on vähemmän raskas toimintatapa. Molempien toimintatapojen tukeminen johtuu siitä, että kumpaakin vaihtoehtoa saatetaan tarvita. [Stroustrup, 1994 §12.3]

5.5 Kritiikkiä

Kritiikkiä, joka koskee erityisesti C++:n moniperintää [Stroustrup, 1994 §12.6]:

· Moniperintä oli ensimmäisiä suuria lisäyksiä kieleen ja tämän pelättiin aiheuttavan uusien ominaisuuksien vyöryn. Muutos on aina joidenkin mielestä pahasta.

· Moniperintä teki myös tavallisesta perinnästä hieman raskaamman.

· Moniperinnän sanotaan vaikeuttavan muun muassa automaattisen muistin siivouksen ja vastaavien ominaisuuksien toteuttamista.

Jotta virtuaalista moniperintää saataisiin käytettyä, on jo luokkia Poliisiauto ja Pakettiauto tehtäessä käytettävä avainsanaa virtual perittäessä luokkaa Auto. Tuntuisi järkevämmältä,jos jokaisen mahdollisen toistuvasti perittävän luokan voisi määritellä jo kyseisessä luokassa virtuaaliseksi.

Ongelmaksi tulee joka tapauksessa se, että virtuaalinen periytyminen pitää määritellä jo luokissa Poliisiauto ja Pakettiauto. Käytännössä kukaan ei yleensä muista omaa luokkahierarkiaansa tehdessään, että joku voisi periä hänen luokkiaan moniperintää käyttäen tavalla, joka aiheuttaa toistuvaa perintää. Moniperinnän, ja varsinkin virtuaalisen sellaisen, käyttö parhaalla mahdollisella tavalla vaatii siis usein sitä, että luokkahierarkian aikaisemmat luokat ovat muunneltavissa ainakin niiltä osin, kuin virtual-avainsanoja sinne pitää lisäillä.

Bjarne Stroustrup kirjoittaa moniperintää vastaan esitetystä kritiikistä muun muassa seuraavasti [Stroustrup, 1994 §12.6]:

"...tärkein virhe näissä väitteissä on se, että ne suhtautuvat moniperintään aivan liian vakavasti. Moniperintä ei ratkaise kaikkia ohjelmointiongelmia, mutta ei sen tarvitsekaan..."

6 Java

6.1 Rajapinnat

Javassa ei ole perinteistä tavallisten luokkien moniperintää. Perimmäinen syy tähän on tietenkin halu välttää moniperinnän ongelmat. Suurin osa näistä ongelmista esiintyy ainoastaan perittäessä useita toteutuksia ja tästä johtuen Java kyllä sallii rajapintojen moniperinnän. [Arnold §4.2]

Javassa rajapinta luodaan käyttämällä sanaa interface sanan class tilalla. Luokka ottaa rajapinnan käyttöönsä käyttämällä implements-avainsanaa. Javassa rajapintoihin voidaan sisällyttää myös attribuutteja, mutta näiden on kuitenkin aina oltava vakioita.

Rajapintojen ajonaikainen tyyppitunnistus muistuttaa ainakin syntaksiltaan tavallisten luokkien vastaavaa.

if(olio instanceof Lastattava)
  ((Lastattava)olio).lastaa();

6.2 Luokkahierarkia

Javassa on kaikille luokille yhteinen yliluokka, joka on nimeltään Object. Kaikki Javan luokat, myös kaikki käyttäjän tekemät, peritään vähintäänkin tästä luokasta. Luokka peritään automaattisesti Object-luokasta, vaikka extends -avainsanaa ei käytettäisikään. Javassa siis kaikki luokat muodostavat yhden puumaisen hierarkian, jonka juurena Oject-luokka on. Monimuotoisuuden ansiosta tästä seuraa se, että jos esimerkiksi funktio haluaa parametrinaan Object-tyyppisen olion, voidaan sille antaa parametrina mikä tahansa olio.

Koska Javassa kaikki luokat kuuluvat samaan hierarkiaan, vähentää tämä osittain tarvetta perinteiseen moniperintään. Toisaalta tämä yhden hierarkian periaate myöskin vaikeuttaa moniperinnän toteuttamista. Koska kaikki luokat, joista mahdollisesti peritään, kuuluvat samaan hierarkiaan, aiheuttaisi joka ikinen moniperinnän käyttäminen toistuvaa periytymistä ja todennäköisesti myös nimikonflikteja. vähintäänkin Object-luokka perittäisiin aina useita kertoja.

Ensimmäisenä johtopäätöksenä tästä voisi sanoa, että erotteleva moniperintä ei tulisi missään nimessä kysymykseen, vaan kaiken toistuvan periytymisen täytyisi olla yhdistävää tyyppiä. Tämä puolestaan tekisi toteutuksesta raskaamman ja toteutusta olisi vaikea tehdä tavalla, joka ei vaikuttaisi tämä myös normaaliin yhden luokan perintään.

Perinnällä on, kuten jo luvussa 2.2 kerrottiin, kaksi tärkeää käyttötarkoitusta. Ensinnäkin sitä voidaan pitää abstraktiomekanismina, joka mahdollistaa hierarkioiden mallintamisen ja tätä kautta monimuotoisuuden. Toiseksi perintää voidaan pitää koodin uudelleenkäyttöä helpottavana tekniikkana.

Javassa on mahdollisuus rajapintojen moniperintään ja tällä saadaan, kuten jo luvussa 4.4 todettiin, pitkälti korvattua tavallisen moniperinnän hyödyt abstraktiomekanismina. Ohessa on kuva luokkahierarkiasta, jossa on yritetty simuloida moniperintää, kun käytössä on rajapintojen moniperintä:

Esimerkkiohjelman hierarkia

Liitteenä 2 olevan esimerkkiohjelman hierarkia. Myös Delphi-versio, joka on liitteenä 3, käyttää samaa hierarkiaa.

Ensimmäisenä tulisi mieleen, että koostetaan Maija Pakettiautosta. Tällöin kuitenkin saisimme aikaan Maijan, jolla on esimerkiksi kahdeksan pyörää. Oikea tapa onkin tehdä uusi luokka, jonka tehtävänä on toteuttaa Lastattava-rajapinta.

Koska oikeaa moniperintää ei ole käytössä, luokan Maija käyttöön ei saada Pakettiauton tyyppiä ja toiminnallisuutta. Maija ei oheisessa hierarkiassa suinkaan ole Pakettiauto, se vain toteuttaa saman rajapinnan kuin Pakettiauto ja käyttää toteutuksessaan samaa attribuuttia LastattavaToteutus.

Pakettiauton toiminnallisuus Maijalle kyllä saadaan, mutta toteutus ei ole kaunein mahdollinen:

class Maija extends Poliisiauto implements Lastattava{
  private LastattavaToteutus paku;
  Maija(){
    paku = new LastattavaToteutus();
  }
  public void lastaa(){
    paku.lastaa();
  }
}

Oheinen tapa lienee kuitenkin paras moniperinnän suppeaan simulointiin, jos rajapintojen moniperintä on kuitenkin käytössä. Toteutustapa jopa toimii, kunhan muistaa esimerkiksi käyttää funktioiden parametrien tyyppinä rajapintaa Lastattava luokan Pakettiauto sijasta. On kuitenkin syytä muistaa, että moniperintää ei voida täysin simuloida kielissä joissa sitä ei ole, sillä muutoinhan niissä olisi moniperintä.

7 Delphi ja rajapinnat

7.1 IInterface

Delphissä on Javan tapaan yksijuurinen luokkahierarkia. Delphissä tämän hierarkian juuri on nimeltään TObject. Kaikki luokat periytyvät tästä luokasta suoraan tai epäsuoraan. Rajapinnat muodostavat oman erillisen hierarkiansa, jonka juurena on rajapinta IInterface.

Luokka voi periä vain yhden luokan, mutta rajapintoja se voi ottaa käyttöönsä miten monta tahansa. Rajapinnoissa voidaan määritellä propertyjä, mutta ne ovat ainoastaan nimiä, joihin liittyy kirjoitus- ja lukumetodit.

IInterface sisältää kolme funktion esittelyä. Nämä ovat _Addref, _Release ja QueryInterface. Nämä on toteutettu valmiiksi luokassa TInterfacedObject, jonka rajapintoja käyttävä luokka voi periä käyttöönsä. Funktiot liittyvät ajonaikaiseen tyyppitunnistukseen ja automaattiseen rajapintojen muistinsiivoukseen.

Delphissä on mahdollista luoda ilmentymiä abstrakteista luokista. Kääntäjä kuitenkin antaa varoituksen, jos tällaista yrittää. Jos abstraktin metodin toteuttava metodi käyttää kutsua inherited, eli siis kutsuu metodia, josta se on peritty, osaa kääntäjä jättää tämän kutsun huomioimatta.

7.2 Muistinsiivous

Rajapinta-tyyppiset oliot tuhotaan automaattisesti. Aina, kun rajapintaan viitataan, kasvatetaan viittauksia laskevaa laskuria yhdellä ja vastaavasti viittauksen hävitessä vähennetään laskuria yhdellä. Kun viittauksia ei enää ole, tuhotaan vastaava olio automaattisesti.

Normaalien luokkien kohdallahan Delphi ei tarjoa täysin automaattista muistinsiivousta. Jos halutaan luokalle automaattinen tuhoaminen, on toimittava siten, että määritetään rajapinta-tyyppinen muuttuja, ja luodaan tähän olio, joka on rajapinnan toteuttavaa luokkaa.

Tämä kuulostaa liian hyvältä ollakseen totta ja niin se tietenkin onkin. Aiheeseen palataan luvussa 7.5.

7.3 GUID

Rajapinnat tulivat Delphiin versiossa kolme ja niiden alkuperäinen tärkein käyttötarkoitus oli auttaa Microsoftin COM-ohjelmoinnin tukemista. Myös COM-tuki oli ensimmäistä kertaa mukana versiossa kolme. Tästä historiallisesta taustasta seuraavat muutamat Delphin rajapintojen kummallisuudet. On kuitenkin syytä korostaa sitä, että rajapintoja voi käyttää myös aivan normaalisti abstraktiomekanismina ja moniperinnän osittaisena korvaajana. Nykyisin tämä lieneekin niiden pääasiallinen käyttötarkoitus. Rajapinnat eivät myöskään millään tavalla vaadi COM-tekniikkkaa ja ne ovat täysin käyttöjärjestelmäriippumattomia. [Cantù §19]

GUID (Globally Unique Identifier) on eras Delphin rajapintojen kummallisuuksista. GUID on satunnaisluku, jota voidaan käyttää rajapinnan tunnistamiseen. Rajapinnalle voidaan määritellä tällainen menemällä editorissa rajapinnan koodin sisälle ja painamalla näppäinyhdistelmää, joka oletuksena on Ctrl+Shift+G.

type
  Liikkuva = interface
  ['{2C2934C1-42FF-11D6-93DA-AD8563783C51}']
    function Liiku: string;
  end;

Vain jos rajapinnassa on määritelty GUID, voidaan sen yhteydessä käyttää metodia QueryInterface ja operaatiota as. On siis järkevää, vaikkakaan ei pakollista, määritellä GUID jokaisessa Delphin rajapinnassa.

7.4 Ajonaikainen tyyppitunnistus

Rajapintoja käytettäessä on tärkeää pystyä testaamaan se, että toteuttaako tietty luokka tietyn rajapinnan. Tämä onnistuu käyttämällä Supports()-funktiota, joka on Delphin SysUtils-kirjastossa. Tämän jälkeen voi as-operaatiota käyttää normaalisti. On kuitenkin muistettava, että tämä vaatii rajapinnalta GUID-sarjanumeroa.

if Supports(olio,Lastattava) then
  Writeln((olio as Lastattava).Lastaa);

7.5 Delegointi

Liitteenä 3 olevan Delphi-ohjelman hierarkia on aivan samanlainen kuin liitteenä 2 olevalla Java-ohjelmalla. Kuva hierarkiasta löytyy luvusta 6.2. Delphi tarjoaa kyseiselle hierarkialle kuitenkin siistimmän ja paremman toteutustavan kuin Java, nimittäin delegoinnin.

Delphissä on mahdollista delegoida rajapinnan toteuttaminen toiselle luokalle. Esimerkiksi luokka Maija käyttää rajapintaa Lastattava ja tämähän on jo toteutettu luokassa Pakettiauto. Maija voikin määritellä attribuuttinaan kyseistä luokkaa vastaavan olion. Tyypiltään tämä olio voi olla joko Pakettiauto tai Lastattava. Tämän jälkeen Maija voi ilmoittaa, että kyseinen olio hoitaa rajapinnan Lastattava toteutuksen.

property Paku: Lastattava read fPaku implements Lastattava;

Tämä tekniikka ei kuitenkaan ole aivan ongelmatonta. Rajapintoihin liittyvä automaattinen muistinsiivous ei nimittäin tällöin toimi toivotulla tavalla. Lisäksi teimme taas Maijan, jolla on kahdeksan pyörää.

Jos luokka, jolle rajapinnan toteutus on delegoitu, on peritty luokasta TAggregatedObject, ei ongelmia viitelaskurissa esiinny. Tämä luokka hoitaa rajapinnassa IInterface esiteltyjen metodien välittämisen tavalla, joka estää ristiriitaisuuksien syntymisen viitteiden laskemisessa.

Ongelmaksi tulee kuitenkin oikean moniperinnän puuttuminen. Luokkaa Pakettiauto ei voi periä sekä luokasta TAggregatedObject että luokasta Auto. Tähän on ratkaisuna esimerkiksi se, että tehdään luokka LastattavaToteutus, jonka ainoa tehtävä on hoitaa rajapinnan Lastattava toteutus muiden luokkien puolesta ja peritään tuo luokka luokasta TAggregatedObject. Sitten myös Pakettiauto voi delegoida rajapinnan Lastattava toteutuksen kyseiselle luokalle, jos halutaan välttää turhaa koodin kopiointia.

Täytyy myöskin muistaa, että ilman erillistä luokkaa LastattavaToteutus olisi esimerkkiohjelman Maija epälooginen. Ei ole järkevää sanoa, että Maijassa on Pakettiauto.

Pakettiauto siis perii luokan Auto ja toteuttaa rajapinnan Lastattava, mutta delegoi kyseisen rajapinnan toteuttamisen luokalle LastattavaToteutus. Samoin Maija delegoi rajapinnan Lastattava toteutuksen kyseiselle luokalle. Java-esimerkissä idea oli sama, mutta Javassa ei ole ominaisuutena delegointia, joten idean toteuttaminen siinä oli paljon työläämpää.

8 Yhteenveto

Moniperintä mahdollistaa erinäisiä ongelmia, joista pahimpina voidaan pitää toistuvaan periytymiseen liittyviä asioita. Moniperintää on siis käytettävä harkiten, mutta tietyissä tilanteissa se on erittäin hyödyllinen työkalu. Jos moniperintää ei ole käytössä, ei kaikkien hierarkioiden siirtäminen suoraan luokiksi välttämättä onnistu.

Java ja Delphi käyttävät yksijuurista luokkahierarkiaa, joka vähentää tarvetta periä luokkaan suoraan monia luokkia. C++ puolestaan voi sisältää useita luokkahierarkioita ja on tärkeää, että niitä pystytään tarvittaessa yhdistämään.

Sallimalla ainoastaan rajapintojen moniperintä, kuten esimerkiksi Javassa ja Delphissä on tehty, saadaan ainakin osittain korvattua tavallinen toteutusten moniperintä abstraktiomekanismina. Rajapintojen moniperinnällä vältetään pahimmat moniperinnän ongelmat, mutta tällöin ei myöskään saavuteta osaa moniperinnän hyödyistä.

Lähteet

[Arnold] Ken Arnold and James Gosling, "The Java Programming Language 2nd Edition", Addison-Wesley, 1997.

[Booch] Grady Booch, "Object-Oriented Analysis and Design with Applications, 2nd Edition", Benjamin/Cummings, 1994.

[Cantù] Marco Cantù, "Mastering Delphi 6", SYBEX, 2001.

[Koskimies] Kai Koskimies, "Oliokirja", Satku, 2000.

[Stroustrup, 1994] Bjarne Stroustrup, "The Design and Evolution of C++", Addison-Wesley, 1994.

[Stroustrup] Bjarne Stroustup, "C++ -ohjelmointi", Teknolit, 2000.

[Sutter] Herb Sutter, "Guru of the Week", saatavilla HTML-muodossa osoitteessa <URL:http://www.gotw.ca/gotw/>, 1997-2002.

Liitteet

Liite 1. Poliisi.cpp

// Poliisi.cpp
// Heikki Kainulainen   25.03.2002   15.04.2002

#include <iostream>
using namespace std;

class Liikkuva{
public:
  virtual void liiku() const=0;
};

class Auto: public Liikkuva{
public:
  virtual void liiku() const{
    cout<<"Liikun!\n";
  };
};

class Poliisiauto: public virtual Auto{
public:
  virtual void liiku() const{
    Auto::liiku();
    cout<<"PIIPAA!\n";
  };
  virtual void takaa_aja() const{
    cout<<"Seis, tai ammun!";
  };
};

class Pakettiauto: public virtual Auto{
public:
  virtual void lastaa() const{
    cout<<"Lastaan.\n";
  };
};

class Maija: public Poliisiauto, public Pakettiauto{
public:
  virtual void liiku() const{
    Poliisiauto::liiku();
  };
};

void valtatie(const Auto &a){
  a.liiku();
  cout<<"\n";
};

int main(){
  Auto ha;
  Poliisiauto pa;
  Pakettiauto van;
  Maija mm;
  valtatie(ha);
  valtatie(pa);
  valtatie(van);
  valtatie(mm);
};

Liite 2. Poliisi.java

// Poliisi.java   
// Heikki Kainulainen   25.03.2002   15.04.2002 

interface Liikkuva{
  void liiku();
}

interface TakaaAjava{
  void takaa_aja();
}

interface Lastattava{
  void lastaa();
}

class Auto implements Liikkuva{
  public void liiku(){
    System.out.println("Liikun!");
  }
}

class Poliisiauto extends Auto implements TakaaAjava{
  public void liiku(){ 
    super.liiku();
    System.out.println("PIIPAA!");
  }
  public void takaa_aja(){
    System.out.println("Seis, tai ammun!");
  }
}

class LastattavaToteutus implements Lastattava{
  public void lastaa(){
    System.out.println("Lastaan.");
  }
}

class Pakettiauto extends Auto implements Lastattava{
  private LastattavaToteutus paku;
  Pakettiauto(){
    paku = new LastattavaToteutus();
  }
  public void lastaa(){
    paku.lastaa();
  }
}

class Maija extends Poliisiauto implements Lastattava{
  private LastattavaToteutus paku;
  Maija(){
    paku = new LastattavaToteutus();
  }
  public void lastaa(){
    paku.lastaa();
  }
}

class Poliisi {
  public static void main(String[] args) {
    Liikkuva[] autot = new Liikkuva[4];
    autot[0] = new Auto();
    autot[1] = new Poliisiauto();
    autot[2] = new Pakettiauto();
    autot[3] = new Maija();
    for(int i = 0; i < autot.length; i++){
      autot[i].liiku();    
      if(autot[i] instanceof Lastattava)
        ((Lastattava)autot[i]).lastaa();
      if(autot[i] instanceof TakaaAjava)
        ((TakaaAjava)autot[i]).takaa_aja();
      System.out.println("---");
    }
  }
}

Liite 3. Poliisi.pas

// Poliisi.pas
// Heikki Kainulainen   25.03.2002   15.04.2002

unit Poliisi;

interface

type
  Liikkuva = interface
  ['{2C2934C1-42FF-11D6-93DA-AD8563783C51}']
    function Liiku: string;
  end;

  TakaaAjava = interface
  ['{2C2934C2-42FF-11D6-93DA-AD8563783C51}']
    function Takaa_aja: string;
  end;

  Lastattava = interface
  ['{2C2934C3-42FF-11D6-93DA-AD8563783C51}']
    function Lastaa: string;
  end;

  Auto = class(TInterfacedObject, Liikkuva)
  public
    function Liiku: string; virtual;
    destructor Destroy; override;
  end;

  Poliisiauto = class(Auto, TakaaAjava)
  public
    function Liiku: string; override;
    function Takaa_aja: string; virtual;
  end;


  LastattavaToteutus = class(TAggregatedObject, Lastattava)
  public
    function Lastaa: string; virtual;
    destructor Destroy; override;
  end;

  Pakettiauto = class(Auto, Lastattava)
  private
    fPaku: LastattavaToteutus;
  public
    constructor Create;
    destructor Destroy; override;
    property Paku: LastattavaToteutus read fPaku implements Lastattava;
  end;

  Maija = class(Poliisiauto, Lastattava)
  private
    fPaku: LastattavaToteutus;
  public
    constructor Create;
    destructor Destroy; override;
    property Paku: LastattavaToteutus read fPaku implements Lastattava;
  end;

  procedure Testaa;

implementation
uses Dialogs, Windows, SysUtils;

{ Auto }

destructor Auto.Destroy;
var x:string;
begin
  x:=self.ClassName;
  MessageBox(0, PChar(x), 'Destructor', MB_OK);
  inherited;
end;
function Auto.Liiku: string;
begin
  Result := 'Liikun!';
end;

{ Poliisiauto }

function Poliisiauto.Liiku: string;
begin
  Result := inherited Liiku + ' PIIPAA!';
end;

function Poliisiauto.Takaa_aja: string;
begin
  Result := 'Seis, tai ammun!';
end;

{ LastattavaToteutus }

destructor LastattavaToteutus.Destroy;
begin
  MessageBox(0, 'LastattavaToteutus', 'Destructor', MB_OK);
  inherited;
end;

function LastattavaToteutus.Lastaa: string;
begin
  Result := 'Lastaan';
end;

{ Pakettiauto }

constructor Pakettiauto.Create;
begin
  fPaku := LastattavaToteutus.Create(self);
end;


destructor Pakettiauto.Destroy;
begin
  fPaku.Free;
  inherited;
end;

{ Maija }

constructor Maija.Create;
begin
  fPaku := LastattavaToteutus.Create(self);
end;

destructor Maija.Destroy;
begin
  fPaku.Free;
  inherited;
end;

{ Testaa }

procedure Testaa;
var
  a:array of Liikkuva;
  i:integer;
begin
  SetLength(a,4);
  a[0]:=Auto.Create;
  a[1]:=Poliisiauto.Create;
  a[2]:=Pakettiauto.Create;
  a[3]:=Maija.Create;
  for i := 0 to high(a) do
  begin
    Writeln(a[i].Liiku);
    if Supports(a[i],Lastattava) then
      Writeln((a[i] as Lastattava).Lastaa);
    if Supports(a[i],TakaaAjava) then
      Writeln((a[i] as TakaaAjava).Takaa_aja);
    Writeln('===');
  end;
end;

end.