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 voidaan luokitella neljään kategoriaan:
- Rakentaja eli konstruktori:
Rakentajafunktion 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.
- Valitsin eli selektori:
Valitsimet ovat metodeja, joiden avulla olion tilaa (siis jäsenmuuttujien arvoja) voidaan tutkia, mutta niitä ei voida muuttaa.
Valitsimet ilmaistaan lisäämällä varattu sana
const
parametrilistan loppusulun perään. Tämä estää olion tilan muuttamisen kyseisessä metodissa.- Mutaattori eli muuttaja:
- Mutaattorien avulla on mahdollista muuttaa olion tilaa eli jäsenmuuttujien arvoja.
- Purkaja eli destruktori:
- Purkajaa kutsutaan automaattisesti, kun olion elinkaari päättyy. Esimerkkiohjelmassa ei ollut purkajaa.
Rakentajaa ja purkajaa lukuun ottamatta metodeja kutsutaan tavallisesti notaatiolla:
olio.metodin_nimi(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.
Operaattorifunktiot¶
Edellä lueteltiin metodien neljä kategoriaa. Näiden lisäksi on eräs erikoistapaus: operaattorit.
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.
Tämän jälkeen 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. Ensin tutkitaan tiedoston main.cpp
uudistettua sisältöä:
#include <iostream>
#include "henkilo.hh"
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 <iostream>
#include <string>
#include "henkilo.hh" // Huom! Toteutustiedosto includoi sitä vastaavan otsikkotiedoston!
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-luokka¶
Toteutetaan toinen 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.
#include <iostream>
#include <iomanip>
using namespace std;
class Kellonaika {
public:
Kellonaika(int tunti, int minuutti);
void tiktok(); // Aika kasvaa yhdellä minuutilla
void tulosta() const;
private:
int tunnit_;
int minuutit_;
};
int main() {
Kellonaika aika(23, 59);
aika.tulosta();
aika.tiktok();
aika.tulosta();
}
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äisestä toteutuksesta huomiotavana on lähinnä se, miten
tulosta
-metodissa käytettiin iomanip
-kirjaston operaatioita
tulostusasun säätämiseen. Ei murehdita niistä.
Toisessa versiossa muutetaan Kellonaika
-luokan toteutusta hiukan.
Kiinnitä aivan ensimmäiseksi huomiota attribuutteihin.
#include <iostream>
#include <iomanip>
using namespace std;
class Kellonaika {
public:
Kellonaika(int tunti, int minuutti);
void tiktok();
int hae_tunti() const;
int hae_minuutti() const;
void tulosta() const;
private:
// Kello 00.00:sta kuluneet minuutit
int kuluneet_minuutit_;
};
int main() {
Kellonaika aika(23, 59);
aika.tulosta();
aika.tiktok();
aika.tulosta();
}
Kellonaika::Kellonaika(int tunti, int minuutti):
kuluneet_minuutit_(60 * tunti + minuutti) {
}
void Kellonaika::tiktok() {
++kuluneet_minuutit_;
if ( kuluneet_minuutit_ >= 24 * 60 ) {
kuluneet_minuutit_ = 0;
}
}
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;
}
void Kellonaika::tulosta() const {
cout << setfill('0') << setw(2) << hae_tunti()
<< "."
<< setw(2) << hae_minuutti()
<< endl;
}
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 metodinhae_tunti
jahae_minuutti
avulla. Tämä tarkoittaa sitä, ettätulosta
-metodin ei tarvitse enää tietää, missä muodossa kellonaika onprivate
-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
- jahae_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
jahae_minuutti
toimivat samoin.Huomaa myös, kuinka metodista voidaan kutsua toista saman luokan metodia: Kutsunotaatio on sama kuin normaaleja ei-metodifunktioita kutsuttaessa:
metodin_nimi(parametrit);
Uusi metodikutsu kohdistuu tällöin samaan olioon, jota käytettiin alkuperäistä metodin kutsuttaessa pisteen edessä.
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.