Rajapinnoista ja olio-ohjelmoinnista

Termi rajapinta tarkoittaa ohjelmoinnin yhteydessä järjestelyä, jonka avulla ohjelmoijan mahdollisuuksia päästä suoraan käsiksi johonkin ohjelman osaan on rajoitettu. Ohjelmoija voi hyödyntää kätkettyjä/rajoitettuja osia vain joidenkin ennalta täsmällisesti määriteltyjen operaatioiden välityksellä. Rajapinta tarkoittaa konkreettisesti sitä, että ohjelmoijan ei ole tarpeen tietää, kuinka jokin asia on toteutettu, mutta hän pystyy silti hyödyntämään sen palveluita.

Esimerkkinä voidaan käyttää string-tietotyyppiä ja string-tyyppisiä muuttujia. Keskimääräisellä ohjelmoijalla ei ole tietoa tai ymmärrystä siitä, kuinka string-tyyppi on C++-kirjastossa toteutettu. Heillä ei ole käsitystä siitä, minkälaisia ongelmia on pitänyt ratkaista, jotta on saatu toteutettua rakenne, johon voidaan tallentaa ennalta määräämätön määrä tekstiä. Kuitenkin jokainen edellisen materiaaliosion sisäistänyt pystyy hyödyntämään string-tyyppiä ohjelmassaan, koska sille on määritelty joukko funktioita ja operaattoreita, joiden avulla kaikki tarpeelliset operaatiot saadaan suoritettua. Nämä valmiit operaatiot ovat string-tyypin (julkinen) rajapinta.

Julkista rajapintaa voi ajatella eräänlaisena käyttöliittymänä, joka määrää, mitä jollekin asialle voi tehdä ja mitä ei. Mitä termi yksityinen rajapinta voisi tarkoittaa?

Rajapinnat ovat oleellinen osa olio-ohjelmointia, ja oliokielillä voidaan kätevästi määritellä rajapintoja. Esimerkiksi C++ tarjoaa valmiit mekanismit julkisten ja yksityisten rajapintojen määrittelyyn. Olio-ohjelmoinnissa oliot kommunikoivat keskenään julkisten rajapintojen kautta, ja koko ohjelman kulku perustuu oikeastaan olioiden väliseen viestien välittämiseen ja olioiden reagoimiseen näihin viesteihin.

Olio-ohjelmoinnin idea tulee paremmin ilmi tämän kierroksen lopuissa materiaaleissa, joissa ensin tarkastellaan luokkia ja olioita, ja verrataan niitä Pythonin vastaaviin rakenteisiin. Sen jälkeen tutustumme osoittimiin, sillä ne ovat C++:ssa kätevin tapa toteuttaa monia olio-ohjelmointiin liittyviä ominaisuuksia. Tämä tullaan huomaamaan myöhemmin kurssilla. Vasta näiden kahden teoriaosuuden jälkeen pääsemme toteuttamaan ensimmäisen varsinaisen olio-ohjelman.

Luokat ja oliot

Palautetaan mieleen, kuinka yksinkertainen luokka määriteltiin Pythonissa ja kuinka sitä voitiin käyttää:

class Henkilö:
    def __init__(self, nimi, ikä):
        self.__nimi = nimi
        self.__ikä = ikä

    def hae_nimi(self):
        return self.__nimi

    def vietä_syntymäpäivää(self, monesko):
        self.__ikä = monesko

    def tulosta(self):
        print(self.__nimi, ":", self.__ikä)

def main():
    kaveri = Henkilö("Matti", 18)
    print(kaveri.hae_nimi())
    kaveri.tulosta()
    kaveri.vietä_syntymäpäivää(19)
    kaveri.tulosta()

main()

Mikä on luokan ja olion ero? Luokka on tietotyyppi ja olio on termi, jota käytetään arvosta tai muuttujasta, jonka tietotyyppi on jokin luokka. Joskus olioita kutsutaan myös luokan ilmentymiksi tai luokan instansseiksi (instance).

Vastaava luokka toteutettaisiin C++:ssa seuraavasti:

#include <iostream>
#include <string>

using namespace std;

class Henkilo {
  public:
    Henkilo(string const& nimi, int ika);
    string hae_nimi() const;
    void vieta_syntymapaivaa(int monesko);
    void tulosta() const;
  private:
    string nimi_;
    int    ika_;
};  // Huomaa puolipiste!

int main() {
    Henkilo kaveri("Matti", 18);
    cout << kaveri.hae_nimi() << endl;
    kaveri.tulosta();
    kaveri.vieta_syntymapaivaa(19);
    kaveri.tulosta();
}

Henkilo::Henkilo(string const& nimi, int ika):
    nimi_(nimi), ika_(ika)  {
}

string Henkilo::hae_nimi() const {
    return nimi_;
}

void Henkilo::vieta_syntymapaivaa(int monesko) {
    ika_ = monesko;
}

void Henkilo::tulosta() const {
    cout << nimi_ << " : " << ika_ << endl;
}

C++:ssa luokan määrittely jakautuu selkeästi kahteen osaan:

  • public-osassa esitellään luokan julkinen rajapinta, eli ne metodit (jäsenfunktiot), joiden avulla luokan olioita voidaan käsitellä
  • private-osaan kätketään muuttujat (jäsenmuuttujat, attribuutit), joiden avulla luokan kuvaama käsite halutaan ohjelmassa mallintaa.

Jäsenmuuttujiin, jotka sijaitsevat private-osassa, ei pääse suoraan käsiksi luokan ulkopuolelta. Jos ja kun niiden arvoja halutaan käsitellä, luokan julkiseen rajapintaan on lisättävä metodi, joka mahdollistaa tarvittavien operaatioiden suorittamisen.

Metodien rungossa jäsenmuuttujia käsitellään normaalien muuttujien tavoin, mutta niitä ei tarvitse erikseen määritellä, koska jokaisella oliolla on oma kopio jäsenmuuttujista.

Yllä olevassa esimerkissä merkkijonoparametri (nimi) välitetään vakioviitteenä syystä, joka selitettiin osion 3.2 Parametrien välittäminen kohdassa Vakioparametrit. Vastaava attribuutti (nimi_) on kuitenkin tavallinen merkkijono eikä vakio eikä viite. Miksi?

Metodit

C++:ssa on kaksi erikoisasemassa oleva metodia: rakentaja (konstruktori) ja purkaja (destruktori).

Rakentajametodin nimi on aina sama kuin luokan nimi, eikä sille merkitä paluuarvon tyyppiä laisinkaan. Rakentajaa kutsutaan automaattisesti aina, kun uusi olio luodaan. Sen tehtävänä on alustaa luotu olio.

Purkajaa kutsutaan automaattisesti, kun olion elinkaari päättyy. Esimerkkiohjelmassa ei ollut purkajaa. Jos ohjelmassa olisi ollut purkaja, sen nimi olisi ollut ~Henkilo, eli tilde (mato) + luokan nimi. Purkajalla ei ole parametreja.

Esimerkistä nähdään, että joissakin metodeissa on varattu sana const parametrilistan loppusulun perässä. Tällaisten metodien avulla olion tilaa (siis jäsenmuuttujien arvoja) voidaan vain tutkia mutta ei muuttaa. Jos const-sana puuttuu, metodi voi muuttaa jäsenmuuttujien arvoja.

Rakentajaa ja purkajaa lukuun ottamatta metodeja kutsutaan tavallisesti notaatiolla:

olio.metodi(parametrit);

missä metodi metodi kohdistetaan olioon olio parametreilla parametrit.

Rakentajan kutsu tapahtuu automaattisesti kulissien takana aina, kun uusi olio on tarpeen alustaa. Rakentajan parametrit kirjoitetaan (kaari)sulkeissa määriteltävänä olevan olion nimen perään. Rakentajafunktion määrittelyssä olion jäsenmuuttujat alustetaan rakentajan määrittelyssä alustuslistan avulla:

Luokka::Luokka(parametri1, parametri2):
  attribuutti1_(parametri1), attribuutti2_(parametri2) {
}

Alustuslista on kirjoitettu rakentajan määrittelyssä kaksoispisteen perässä ennen rakentajan runkoa (aaltosulkeet). Siinä luetellaan jokainen luokan attribuuteista siinä järjestyksessä, missä ne ovat luokan rajapinnassa, ja alustetaan niille alkuarvot.

Jos luokalla on purkaja, sitä kutsutaan automaattisesti olion elinkaaren lopussa. Paikallisten olioiden elinkaari päättyy niiden näkyvyysalueen lopussa.

Luokka on työkalu abstraktioiden (käsitteiden) muodostamiseen ohjelmassa. Luokan toteutusyksityiskohdat kätketään sen käyttäjältä, joka pystyy hyödyntämään luokkaa vain sen tarjoaman julkisen rajapinnan välityksellä.

Ohjelmaan muodostuu käsitteitä, joiden olemus määräytyy niiden mahdollisten käyttötarkoitusten kautta: “Henkilö on sitä, mitä sille voi tehdä.” Kokemus on osoittanut, että tällaisilla toiminnallisuuden avulla määritellyillä käsitteillä on taipumus selkeyttää ohjelmaa.

Operaattorimetodit

Tarkastellaan vielä yhtä metodien erikoistapausta eli operaattoreita.

Luokkien avulla voidaan määritellä omia tyyppejä (abstrakteja tietotyyppejä), ja näille voi olla luontevaa määritellä funktioita, joiden merkitys on sama kuin joidenkin valmiiden operaattoreiden kuten +, +=, == jne.

Esimerkiksi jos meillä on murtolukuja kuvaava luokka Murtoluku, jolla on kaksi kokonaislukuattribuuttia osoittaja_ ja nimittaja_, niin luokalle voidaan määritellä yhtäsuuruuden vertailua varten funktio nimeltä operator== seuraavasti:

bool Murtoluku::operator==(Murtoluku const& toinen) const
{
    return osoittaja_ == toinen.osoittaja_ && nimittaja_ == toinen.nimittaja_;
}

kun oletetaan, että murtoluvut ovat mahdollisimman supistetussa muodossa.

Nyt kahta murtolukua voi vertailla, kuten muita numeerisia tyyppejä:

Murtoluku m1(2, 3);
Murtoluku m2(3, 4);
if(m1 == m2) ...

kun oletetaan, että murtolukuluokan rakentaja saa kaksi parametria: toinen osoittajaa ja toinen nimittäjää varten.

Luokan toteuttaminen erilliseen tiedostoon

Edellisessä esimerkissä luokka oli toteutettu pääohjelman kanssa samaan tiedostoon, kuten teimme aina Python-ohjelmissa edellisellä opintojaksolla. Toteutetaan vielä uusi versio siten, että luokka on jaettu erillisiin tiedostoihin, koska tämä on se tavanomaisempi tapa C++:ssa. Ensin tutkitaan tiedoston main.cpp uudistettua sisältöä:

#include "henkilo.hh"
#include <iostream>

using namespace std;

int main() {
    Henkilo kaveri("Matti", 18);
    cout << kaveri.hae_nimi() << endl;
    kaveri.tulosta();
    kaveri.vieta_syntymapaivaa(19);
    kaveri.tulosta();
}

Huomaamme, että samalla kun luokan määrittely on poistettu, on tiedoston alkuun lisätty include-direktiivi:

#include "henkilo.hh"

Tällä tavalla toisessa tiedostossa toteutettu luokka saadaan käyttöön pääohjelmassa. Katsotaan seuraavaksi kyseisen tiedoston sisältöä, eli tiedostoa henkilo.hh:

#include <string>

using namespace std;

class Henkilo {
  public:
    Henkilo(string const& nimi, int ika);
    string hae_nimi() const;
    void vieta_syntymapaivaa(int monesko);
    void tulosta() const;
  private:
    string nimi_;
    int    ika_;
};  // Huomaa puolipiste!

Tämä .hh-päätteinen tiedosto on nk. otsikkotiedosto (header file) tai määrittelyosa. Tässä vain määritellään, millainen luokka on kyseessä. Kuten huomaat, tiedosto ei sisällä luokan metodien toteutuksia.

Metodien toteutukset löytyvät vastaavasta toteutustiedostosta henkilo.cpp:

#include "henkilo.hh"  // Huom! Toteutustiedosto includoi sitä vastaavan otsikkotiedoston!
#include <iostream>
#include <string>

using namespace std;

Henkilo::Henkilo(string const& nimi, int ika):
    nimi_(nimi), ika_(ika)  {
}

string Henkilo::hae_nimi() const {
    return nimi_;
}

void Henkilo::vieta_syntymapaivaa(int monesko) {
    ika_ = monesko;
}

void Henkilo::tulosta() const {
    cout << nimi_ << " : " << ika_ << endl;
}

Tässä vaiheessa herää kysymys, miten nämä metodien toteutukset tulevat osaksi ohjelmaa, kun tiedostossa main.cpp otettiin include-direktiivillä mukaan vain otsikkotiedosto henkilo.hh. Tiedosto henkilo.cpp pitää lisätä mukaan ohjelmaan käännösvaiheessa. Käsittelemme tätä seuraavan otsikon alla olevassa osiossa “Luokan toteuttaminen ja kääntäminen Qt Creatorissa”.

Yleisesti C++-ohjelmissa on tapana jakaa luokka kahteen osaan: määrittelyosaan ja toteutusosaan. Nämä kirjoitetaan molemmat omiin tiedostoihinsa. Jokaista luokkaa kohden toteutetaan siis kaksi tiedostoa:

  • Määrittelyosa sisältää luokan määrittelyn ja on talletettuna .hh-päätteiseen tiedostoon, jonka nimi on sama kuin luokan nimi, mutta alkukirjain pieni, edellisessä esimerkissä henkilo.hh.
  • Toteutusosa sisältää luokan metodien toteutukset ja on talletettuna .cpp-päätteiseen tiedostoon, jonka nimi on sama kuin luokan nimi, mutta alkukirjain pieni, edellisessä esimerkissä henkilo.cpp. (Terminologia voi tässä olla hieman hämäävää. Edellä mainittiin “metodin toteutus”, joka on (melkeinpä) synonyymi aikaisemmin mainitulle termille “funktion määrittely”, erotuksena termistä “funktion esittely”.)

Kun include-direktiivillä otetaan käyttöön C++:n kirjastoja, käytetään kulmasulkeita. Kun include-direktiivillä otetaan käyttöön omia tiedostoja, kirjoitetaan tiedoston nimi lainausmerkkien (“”) sisään.

Luokan toteuttaminen ja kääntäminen Qt Creatorissa

Kun haluat lisätä uuden luokan olemassa olevaan projektiin Qt Creatorissa, voit toimintoa “New File or Project” suorittaessasi valita avautuvasta ikkunasta “C++” ja “C++ Class”. Tällöin Qt Creator luo automaattisesti molemmat tarvitsemasi tiedostot (.cpp ja .hh). Tämän lisäksi Qt Creator lisää uuden toteutustiedoston automaattisesti myös projektissa käännettävien tiedostojen joukkoon.

Halutessasi voit katsoa projektitiedostoa (.pro-päätteinen tiedosto) Qt Creatorissa ja todeta, että siellä on määriteltynä kohta SOURCES, jossa kääntäjälle kerrotaan, mitkä kaikki tiedostot käännökseen otetaan mukaan. Tämä siis päivittyy automaattisesti, kun luot luokan yllämainitulla tavalla. Huomionarvoista on, että tähän ei tule otsikkotiedostoja, vaan ainoastaan toteutustiedostot.

Palaamme ohjelman kääntämiseen siinä vaiheessa, kun tutustumme modulaarisuuteen. Tällöin käymme myös läpi käännöksen vaiheita tarkemmin. Kurssin alkupuolella voit olla tyytyväinen siitä, että Qt Creator huolehtii käännöksen automatisoimisen.

Kellonaika-luokan 1. versio

Toteutetaan vielä esimerkkiluokka Kellonaika kahtena eri versiona. Ensimmäisessä versiossa ei ole mitään dramaattista uutta aiemmin opittuun verrattuna. Sen on tarkoitus olla johdanto seuraavaan esimerkkiin, eli vertailemme ensimmäistä ja toista toteutusta keskenään.

Luokan otsikkotiedosto näyttää tältä:

class Kellonaika {
  public:
    Kellonaika(int tunti, int minuutti);
    void tiktok();  // Aika kasvaa yhdellä minuutilla
    void tulosta() const;

  private:
    int tunnit_;
    int minuutit_;
};

Pääohjelmassa luokkaa voidaan käyttää näin:

#include "kellonaika.hh"

int main() {
    Kellonaika aika(23, 59);
    aika.tulosta();
    aika.tiktok();
    aika.tulosta();
}

Luokan toteutustiedosto näyttää tältä:

#include "kellonaika.hh"
#include <iostream>
#include <iomanip>

using namespace std;

Kellonaika::Kellonaika(int tunti, int minuutti):
    tunnit_(tunti), minuutit_(minuutti) {
}

void Kellonaika::tiktok() {
    ++minuutit_;
    if ( minuutit_ >= 60 ) {
        minuutit_ = 0;
        ++tunnit_;
    }
    if ( tunnit_ >= 24 ) {
        tunnit_ = 0;
    }
}

void Kellonaika::tulosta() const {
    cout << setw(2) << setfill('0') << tunnit_
         << "."
         << setw(2) << minuutit_
         << endl;
}

Ensimmäisen version toteutuksesta huomiotavana on lähinnä se, miten tulosta-metodissa käytettiin iomanip-kirjaston operaatioita tulostusasun säätämiseen. Ei murehdita niistä.

Kellonaika-luokan 2. versio

Toisessa versiossa muutetaan Kellonaika-luokan toteutusta hiukan. Kiinnitä aivan ensimmäiseksi huomiota attribuuteissa tapahtuneisiin muutoksiin, jotka näkyvät otsikkotiedostossa:

class Kellonaika {
  public:
    Kellonaika(int tunti, int minuutti);
    void tiktok();
    void tulosta() const;

  private:
    // Kello 00.00:sta kuluneet minuutit
    int kuluneet_minuutit_;
    int hae_tunti() const;
    int hae_minuutti() const;
};

Myös toteutustiedosto on muuttunut vastaavasti:

#include "kellonaika.hh"
#include <iostream>
#include <iomanip>

using namespace std;

Kellonaika::Kellonaika(int tunti, int minuutti):
    kuluneet_minuutit_(60 * tunti + minuutti) {
}

void Kellonaika::tiktok() {
    ++kuluneet_minuutit_;
    if ( kuluneet_minuutit_ >= 24 * 60 ) {
        kuluneet_minuutit_ = 0;
    }
}

void Kellonaika::tulosta() const {
    cout << setfill('0') << setw(2) << hae_tunti()
         << "."
         << setw(2) << hae_minuutti()
         << endl;
}

int Kellonaika::hae_tunti() const {
    // Kun kokonaisluku jaetaan kokonaisluvulla
    // tuloksena on kokonaisluku (jakojäännös
    // heitetään menemään).
    return kuluneet_minuutit_ / 60;
}

int Kellonaika::hae_minuutti() const {
    return kuluneet_minuutit_ % 60;
}

Uudessa toteutuksessa on tapahtunut alkuperäiseen verrattuna muutama mielenkiintoinen muutos:

  • Esimerkistä käy ilmi yksi luokkien (pikemminkin rajapintojen) hyvä puoli: Luokan yksityinen rajapinta (toteutus) on muutettu kokonaan, mutta koska julkinen rajapinta on pidetty yhteensopivana alkuperäisen kanssa, luokkaa hyödyntävää koodia (main) ei ole tarvinnut muuttaa laisinkaan.

  • Metodissa tulosta ei enää ole suoraa viittausta luokan jäsenmuuttujaan, vaan tulostettava kellonaika selvitetään kahden uuden metodin hae_tunti ja hae_minuutti avulla. Tämä tarkoittaa sitä, että tulosta-metodin ei tarvitse enää tietää, missä muodossa kellonaika on private-osassa esitetty.

    Luokan sisälle on siis muodostettu uusi (epämuodollinen) rajapinta: Osa luokan omista metodeista ei käsittele jäsenmuuttujia suoraan, vaan tyytyy operoimaan niillä hae_tunti- ja hae_minuutti-metodien välityksellä.

    Ratkaisun hyvä puoli on se, että jos luokan toteutusta (siis private-osaa ja sitä käsitteleviä metodeja) muutetaan, tulosta-metodin toteutukseen ei tarvitse koskea, kunhan huolehditaan siitä, että hae_tunti ja hae_minuutti toimivat samoin.

  • Huomaa myös, kuinka metodista voidaan kutsua toista saman luokan metodia: Kutsunotaatio on sama kuin normaaleja ei-metodifunktioita kutsuttaessa:

    metodi(parametrit);
    

    Uusi metodikutsu kohdistuu tällöin samaan olioon, jota käytettiin alkuperäistä metodin kutsuttaessa pisteen edessä.

Lisää esimerkkejä

Tämän kierroksen materiaaleistä löytyy myös esimerkki examples/03/students. Siitä voi olla hyötyä tämän kierroksen oliotehtävien tekemisessä.

Luokkien metodeitten toteutuksia ei välttämättä tarvitse ymmärtää. Ei esimerkiksi haittaa, vaikka et ymmärtäisi Date-luokan advance-metodin tai toisen rakentajan toteuksia.

Luokkien käytön hyödyt

Kun käsitteitä mallinnetaan ohjelmassa luokkien avulla, sillä saavutetaan lähes poikkeuksetta etuja:

  • Toteutuksen muuttaminen yksityisessä rajapinnassa on mahdollista, kun samaan aikaan julkinen rajapinta pysyy yhteensopivana aiemman toteutuksen kanssa.
  • Voidaan olla varmoja tiedon eheydestä. Konstruktorit ja mutaattorit voivat huolehtia siitä, että olio ei voi saada virheellistä arvoa.
  • Luokat selkeyttävät ohjelmaa, sen ymmärrettävyyttä ja ylläpidettävyyttä.
  • Luokat ovat usein uudelleenkäytettäviä (reusable).
  • Luokat auttavat ohjelman monimutkaisuuden hallinnassa, sillä niiden avulla ohjelman loogisia osia voidaan koota yhteen.

Itse asiassa nämä edut eivät ole pelkästään luokkien mukanaan tuoma etu, vaan kaikki muutkin mekanismit, joiden avulla ohjelmaan voidaan muodostaa selkeitä rajapintoja, tuottavat samat edut. Esimerkiksi funktiokin muodostaa rajapinnan. Kun ymmärtää, mitä funktiolle on tarkoitus antaa parametrina ja minkä arvon se tuottaa parametreistaan paluuarvona, toteutuksesta ei tarvitse tietää mitään. Kaikki edellä listatut edut voidaan siis yhdistää myös funktioihin.