Periyttämisen alkeet

Huomautus

Tämän kierroksen varsinaisena aiheena on graafisten käyttöliittymien toteuttaminen. Qt sisältää kuitenkin paljon luokkia, jotka on periytetty toisista luokista. Tämän vuoksi perehdymme ennen Qt:n aloittamista vielä hyvin pintapuolisesti C++:n periytymismekanismiin. Tällä opintojaksolla käytät periytymistä vain tilanteissa, joissa sinulle kerrotaan, mitä pitää periyttää ja mistä. Seuraavilla opintojaksoilla (Ohjelmointi 3: Tekniikat sekä Ohjelmistojen suunnittelu) opetellaan suunnittelemaan periytettyjä luokkia itsekin.

Periyttäminen (inheritance) ohjelmointikielissä on mekanismi, jonka avulla olemassa olevasta luokasta (kantaluokka) voidaan muodostaa uusia luokkia (aliluokkia, periytettyjä luokkia), joilla on samat perusominaisuudet kuin kantaluokalla mutta myös joitakin lisäominaisuuksia. Tämä on erittäin epämuodollinen ja epätäsmällinen määritelmä periyttämiselle, mutta siitä voidaan lähteä liikkeelle.

Tutkitaan yksinkertaista esimerkkiä, jossa on nyt toteutettuna kaksi luokkaa ja pääohjelma. Tässä esimerkissä kaikki on kirjoitettu samaan tiedostoon, jotta ohjelmakoodilistaus ei pitkity include-riveillä ja olennaisen löytää selvemmin. Edellisen kierroksen opeilla osaisit toki toteuttaa tämän hienommin siten, että molemmilla luokilla olisi omat esittely- ja toteutustiedostonsa ja pääohjelma olisi omassa tiedostossaan.

#include <iostream>
#include <string>

using namespace std;

//--------------------------------------------------------

class Kulkuneuvo {
  public:
    Kulkuneuvo(double nopeus, string const& vari);
    double hae_nopeus() const;
    void aseta_nopeus(double nopeus);
    string hae_vari() const;
    void raportoi_matka(double aika) const;

  private:
    double nopeus_;
    string vari_;
};

Kulkuneuvo::Kulkuneuvo(double nopeus, string const& vari):
   nopeus_(nopeus), vari_(vari) {
}

double Kulkuneuvo::hae_nopeus() const {
   return nopeus_;
}

void Kulkuneuvo::aseta_nopeus(double nopeus) {
   nopeus_ = nopeus;
}

string Kulkuneuvo::hae_vari() const {
   return vari_;
}

void Kulkuneuvo::raportoi_matka(double aika) const {
   cout << hae_vari() << " kulkuneuvo, jonka nopeus on "
        << hae_nopeus() << ", liikkuu "
        << aika << " sekunnissa "
        << hae_nopeus() * aika / 3600.0 * 1000.0 << " m"
        << endl;
}

//--------------------------------------------------------

class Auto: public Kulkuneuvo {
  public:
    Auto(double nopeus, string const& vari, string const& reknum);
    string hae_rekisterinumero() const;
    void raportoi_tunnusmerkit() const;

  private:
    string rekisterinumero_;
};

Auto::Auto(double nopeus, string const& vari, string const& reknum):
   Kulkuneuvo(nopeus, vari), rekisterinumero_(reknum) {
}

string Auto::hae_rekisterinumero() const {
   return rekisterinumero_;
}

void Auto::raportoi_tunnusmerkit() const {
   cout << "Poliisi etsii autoa: vari " << hae_vari()
        << ", rekisterinumero " << hae_rekisterinumero()
        << endl;
}

//--------------------------------------------------------

int main() {
   Kulkuneuvo kneuvo(20.0, "punainen");  // 20 km/h

   kneuvo.raportoi_matka(1.0);           // 1.0 sekunnissa
   kneuvo.aseta_nopeus(50.0);            // 50.0 km/h
   kneuvo.raportoi_matka(2.5);           // 2.5 sekunnissa

   Auto subaru(75.0, "siniharmaa", "ABC-123");
   subaru.raportoi_matka(1.0);
   subaru.aseta_nopeus(120.0);
   subaru.raportoi_matka(2.5);
   subaru.raportoi_tunnusmerkit();
}

Esimerkissä Kulkuneuvo-luokasta on periytetty Auto-luokka. Tämä tarkoittaa loogisesti sitä, että kaikki autot ovat kulkuneuvoja, mutta kaikki kulkuneuvot eivät ole autoja. Autoilla on siis samat ominaisuudet kuin kulkuneuvoilla, mutta sen lisäksi joitakin lisäominaisuuksia, jotka erottavat ne kulkuneuvoista.

Tai jos saman haluaa ilmaista hiukan toisin: autoille voi tehdä samoja asioita kuin kulkuneuvoille, mutta sen lisäksi niille voi tehdä jotakin vain autoille tyypillisiä asioita. Usein periytymistä esitetään kuvallisesti alla olevan tyylisellä kaaviolla.

Periytyminen kaaviona

Tällainen nk. is-a -suhde saadaan C++:ssa aikaan periyttämällä aliluokka kantaluokasta käyttämällä luokan määrittelyssä syntaksia

class PeriytettyOmaLuokka : public Kantaluokka { ... };

Käyttämällä public-periytymistä kaikista kantaluokan public-rajapinnassa olevista metodeista tulee automaattisesti aliluokan public-metodeja. Konkreettisesti tämä tarkoittaa sitä, että aliluokan olioihin voidaan kohdistaa kaikki ne metodit, jotka on määritelty kantaluokan julkisessa rajapinnassa.

Aliluokan omista metodeissa ei kuitenkaan päästä suoraan käsittelemään kantaluokan private-osassa olevia jäsenmuuttujia, vaan kaikki niihin kohdistuvat operaatiot on suoritettava kantaluokan julkisia metodeja kutsumalla.

Kannattaa myös panna merkille syntaksi, jonka avulla aliluokan rakentajan alustuslistassa saadaan alustettua kantaluokalta perityt jäsenmuuttujat:

Auto::Auto(double nopeus, string const& vari, string const& reknum):
    Kulkuneuvo(nopeus, vari), rekisterinumero_(reknum) {
}

Tässä siis kutsutaan kantaluokan rakentajaa aliluokan alustuslistassa. Kantaluokan rakentajan kutsun pitää olla alustuslistassa ensimmäisenä.

Periyttäminen edellä esitetyssä perusmuodossaan on käyttökelpoinen, jos on olemassa valmis luokka, joka melkein vastaa tarvetta mutta jonka julkisesta rajapinnasta puuttuu muutamia operaatioita.

Voidaan esimerkiksi kuvitella, että C++:n valmiista string-luokasta haluttaisiin versio, jonka julkisessa rajapinnassa on operaatio onko_palindromi:

#include <iostream>
#include <string>

using namespace std;

class OmaString: public string {
  public:
    OmaString();
    OmaString(string const& alkuarvo);
    bool onko_palindromi() const;
  private:
    // Ei välttämättä mitään täällä
};

OmaString::OmaString(): string("") {
}

OmaString::OmaString(string const& alkuarvo): string(alkuarvo) {
}

bool OmaString::onko_palindromi() const {
    string::size_type vasen = 0;
    while ( vasen < length() / 2 ) {
        if ( at(vasen) != at(length() - vasen - 1) ) {
            return false;
        }
        ++vasen;
    }

    return true;
}

int main() {
    OmaString s1;
    OmaString s2( string("abcba") );
    OmaString s3(s2);

    cout << s1.onko_palindromi() << endl;
    cout << s2.onko_palindromi() << endl;
    cout << s3.onko_palindromi() << endl;

    s1.append("ab");
    s2.append("z");
    s3.append("z");

    cout << s1.onko_palindromi() << endl;
    cout << s2.onko_palindromi() << endl;
    cout << s3.onko_palindromi() << endl;

}

Valitettavasti monimutkaisemmista kantaluokista periyttäminen ei ole aivan noin suoraviivaista, mutta ideaa tämä havainnollistaa hyvin.

Koska kaikki aliluokan oliot kuuluvat myös kantaluokkaan, niitä voi periaatteessa käyttää kaikkialla, missä voisi käyttää kantaluokan oliota. Käytännössä C++ ei kuitenkaan ole noin joustava. Aliluokan oliota voi kuitenkin käyttää parametrina funktiolle, jonka muodollisen parametrin tyyppi on viite tai osoite kantaluokan olioon.

void prosessoi_kulkuneuvo(Kulkuneuvo const& kn) {
    kn.raportoi_matka(15.0);
}

int main() {
   Kulkuneuvo kneuvo(20.0, "punainen");
   Auto subaru(75.0, "siniharmaa", "ABC-123");
   prosessoi_kulkuneuvo(kneuvo);
   prosessoi_kulkuneuvo(subaru);
}

Pohjimmiltaan tämäkin on mekanismi, jolla staattisesti tyypitetyssä kielessä yritetään saavuttaa dynaamisen tyypityksen iloja.

Yleisiä huomioita periyttämisestä

Periyttäminen on mielenkiintoinen mekanismi, joten sen käyttö houkuttaa pelkästään uutuudenviehätyksen vuoksi. Tämä johtaa helposti hölmöihin ratkaisuihin. Käytä periyttämistä säästeliäästi ja vain tilanteissa, joissa voit perustella itsellesi sen olevan oikeasti hyvä ratkaisu.

Hyvä nyrkkisääntö on seuraava:

Periytä kantaluokasta X aliluokka Y, vain jos pystyt perustelemaan itsellesi, että jokainen Y on myös X.

Siis auto on perusteltua periyttää kulkuneuvosta, koska jokainen auto on myös kulkuneuvo. Autoa ei kuitenkaan ole järkevää periyttää polttomoottorista, koska auto ei ole polttomoottori. Tässä on kyseessä nk. has-a -suhde: autossa on polttomoottori. Toteutusmekanismin pitäisi siis olla se, että Auto-luokan private-osassa on Polttomoottori-tyyppinen jäsenmuuttuja.

Edellinen esimerkki oli niin ilmiselvä, että melkein naurattaa. Tosiasia on kuitenkin se, että aloitteleva luokkien suunnittelija lankeaa vastaavaan ansaan vähänkin juonikkaammassa tilanteessa, vaikka silloinkin kyseessä saattaa tarkemmin analysoituna olla tismalleen vastaava is-a vs. has-a -tilanne.

Tässä monisteessa esitetyt esimerkit periyttämisestä ovat vain pinnallinen kosketus kaikkiin mahdollisiin periyttämismekanismeihin, joita C++ tarjoaa. Jokaisella pitäisi nyt kuitenkin olla perusidea siitä, mitä periyttämisellä tarkoitetaan.