Kirjautuminen

Haku

Tehtävät

Oppaat: C++-ohjelmointi: Osa 11 - Luokkien perintä

  1. Osa 1 - Johdanto
  2. Osa 2 - Vakiot, muuttujat ja perustietotyypit
  3. Osa 3 - Ehdot, silmukat ja poikkeukset
  4. Osa 4 - Rakenteet, taulukot ja merkkijonot
  5. Osa 5 - Funktiot
  6. Osa 6 - Esittelyt, määrittelyt ja elinajat
  7. Osa 7 - Viittaukset, osoittimet ja dynaaminen muisti
  8. Osa 8 - Mallit
  9. Osa 9 - Luokkien perusteet
  10. Osa 10 - Luokkien erikoiset jäsenet
  11. Osa 11 - Luokkien perintä
  12. Liite 1 - C++-kehitysympäristöt
  13. Liite 2 - Valmis C++-kääntäjäpaketti

Kirjoittaja: Metabolix. Vuosi: 2013.

Olio-ohjelmoinnissa on tärkeää, että samantapaisia luokkia voidaan käsitellä samalla tavalla. Usein luokilla on myös jonkin verran yhteistä koodia. Esimerkiksi tietokonepelissä pelaajan oma hahmo ja viholliset liikkuvat ja toimivat usein samojen sääntöjen mukaan, paitsi että hahmon liikkeistä päättää pelaaja ja vihollisten liikkeistä tekoäly. Tällaisessa tilanteessa molemmat luokat voivat periä (engl. inherit) yhteisen kantaluokan tai yliluokan (engl. base class), joka sisältää yhteiset toiminnot. Näin muodostuvaa luokkaa kutsutaan aliluokaksi (engl. subclass) tai johdetuksi luokaksi (engl. derived class). Kantaluokkien ja aliluokkien kokonaisuutta kutsutaan luokkahierarkiaksi (engl. class hierarchy).

Yksinkertainen perintä

Kun luokka perii toisen, kantaluokan ominaisuudet tulevat myös aliluokan käyttöön: aliluokkaan varataan samat jäsenmuuttujat, ja käytössä ovat samat jäsenfunktiot. Kantaluokan yksityiset jäsenet eivät kuitenkaan näy aliluokassa. Tätä varten perimisen yhteydessä käytetäänkin suojattuja jäseniä, jotka näkyvät kantaluokasta aliluokkiin mutta eivät näy näiden luokkien ulkopuolelle.

Perintä merkitään luokan määrittelyyn niin, että luokan nimen perään tulee kaksoispiste, valinnainen suojamääre ja kantaluokan nimi. Tämä suojamääre vaikuttaa perittyjen jäsenten näkyvyyteen: mikään peritty jäsen ei enää aliluokassa näy tämän laajemmin. Tiukalla suojamäärellä voidaan siis tehdä luokkia, jotka sisäisesti perivät toisen luokan ominaisuudet mutta eivät ulospäin näytä enää samalta.

Kantaluokan muodostimia ei voi käyttää aliluokan olion luomiseen, mutta aliluokan muodostin voi alustaa kantaluokan jäsenet kutsumalla kantaluokan muodostinta. Kantaluokalla ja aliluokalla voi olla samannimisiä jäseniä, jolloin aliluokan jäsen piilottaa kantaluokan jäsenen. Jos jostain syystä on halutaan käyttää nimenomaan kantaluokan jäsentä, sitä voidaan merkitä nimellä kantaluokka::jäsen.

Seuraavassa koodissa on kantaluokka suorakulmio, joka sisältää yleiset suorakulmion ominaisuudet, ja aliluokka nelio, joka perii kantaluokan ominaisuudet mutta sisältää tähän pari lisäystä.

#include <iostream>

// Kantaluokka on tapotavallinen luokka. Perintää silmällä pitäen voi kuitenkin
// miettiä, mitkä muuttujat ovat todella yksityisiä (private) ja julkisia (public)
// ja mitkä suojattuja (protected) eli aliluokkien kanssa jaettavia.
class suorakulmio {
protected:
	double leveys, korkeus;

public:
	suorakulmio(double l, double k): leveys(l), korkeus(k) {
	}

	double ala() const {
		return leveys * korkeus;
	}

	void tulosta() const {
		std::cout
			<< "Suorakulmion sivut ovat " << leveys << " ja " << korkeus
			<< " ja pinta-ala " << ala() << "." << std::endl;
	}
};

// Perintä merkitään luokan määrittelyyn kaksoispisteellä ja kantaluokan nimellä.
// Väliin voi laittaa suojamääreen; jos se puuttuu, oletus on sama kuin muillakin
// jäsenillä struct-sanan kanssa public ja class-sanan kanssa private.
class nelio: public suorakulmio {
public:
	// Aliluokka voi alustaa kantaluokan jäsenet kutsumalla
	// kantaluokan muodostinta alustuslistassa.
	nelio(double sivu): suorakulmio(sivu, sivu) {
	}

	// Aliluokka voi peittää kantaluokan jäsenen omallaan.
	void tulosta() const {
		// Aliluokka voi käyttää kantaluokan julkisia ja suojattuja jäseniä.
		std::cout
			<< "Neliön sivun pituus on " << leveys
			<< " ja pinta-ala " << ala() << "." << std::endl;
		// Aliluokka voi käyttää kantaluokasta myös piilotettuja jäseniä:
		suorakulmio::tulosta();
	}
};

int main() {
	suorakulmio s(3, 4);
	nelio n(5);

	// Kantaluokassa ei ole edelleenkään mitään uutta.
	std::cout << "s.tulosta():" << std::endl;
	s.tulosta();

	// Aliluokassa on samanniminen funktio kuin kantaluokassa,
	// mutta kutsu tapahtuu aivan normaalisti.
	std::cout << "n.tulosta():" << std::endl;
	n.tulosta();

	// Aliluokasta voi kutsua myös kantaluokan funktiota,
	// kunhan erikseen vaatii.
	std::cout << "n.suorakulmio::tulosta():" << std::endl;
	n.suorakulmio::tulosta();

	// Kun aliluokassa ei ole samannimistä funktiota,
	// kantaluokan funktiot ovat suoraan aliluokastakin käytössä.
	std::cout << "n.ala() " << (n.ala() < s.ala() ? "<" : ">=") << " s.ala()." << std::endl;
}

Jos C++ ei sisältäisi erikseen keinoa perimiseen, samantapaiseen tulokseen päästäisiin toisellakin tavalla: kantaluokan olio laitettaisiinkin aliluokkaan erillisenä jäsenenä. Esimerkiksi C-kielessä monesti tehdäänkin juuri näin. Keinossa on eräitä huonoja puolia: kantaluokan jäseniin viittaaminen on vaivalloista, aliluokassa käytettävistä kantaluokan jäsenistä pitää tehdä aina julkisia, ja monet tämän oppaan edistyneemmät konstit vaatisivat huomattavasti ylimääräistä kikkailua. Toiminnallisesti ratkaisuissa ei ole paljon eroa, vaan ero on vain koodin mutkikkuudessa.

Kuormitetut funktiot

Aliluokan funktio peittää samannimiset funktiot kantaluokasta silloinkin, kun funktioilla on erilaiset parametrit. Jos kantaluokan funktiot halutaan käyttöön myös aliluokassa, täytyy käyttää erityistä using-esittelyä:

#include <iostream>

struct kanta {
	void funktio() const {
		std::cout << "kanta::funktio()" << std::endl;
	}
};

struct piilottaja: kanta {
	void funktio(char c) const {
		std::cout << "piilottaja::funktio('" << c << "')" << std::endl;
	}
};

struct kayttaja: kanta {
	using kanta::funktio;
	void funktio(char c) const {
		std::cout << "kayttaja::funktio('" << c << "')" << std::endl;
	}
};

int main() {
	piilottaja piilottaja;
	kayttaja kayttaja;

	// Aliluokkien omat funktiot toimivat aivan tavalliseen tapaan.
	piilottaja.funktio('x');
	kayttaja.funktio('x');

	// Kantaluokan funktioon pääsee yhä käsiksi, kun pyytää.
	piilottaja.kanta::funktio();
	kayttaja.kanta::funktio();

	// Kun aliluokassa ei ole using-esittelyä, sen oma funktio piilottaa
	// kantaluokan funktion, jolloin funktiota ei voi suoraan käyttää.
	std::cout << "piilottaja.funktio() => VIRHE!" << std::endl;
	// piilottaja.funktio();

	// Sen sijaan using-esittelyn kanssa kutsu menee kantaluokan funktiolle.
	std::cout << "kayttaja.funktio() => ";
	kayttaja.funktio();
}

Virtuaaliset funktiot

Kuvitellaanpa, että luokassa suorakulmio on funktio tulosta_kahdesti, joka kutsuu kaksi kertaa funktiota tulosta. Koska funktio sijaitsee luokassa suorakulmio, se myös kutsuu luokan suorakulmio funktioita riippumatta siitä, kutsutaanko sitä suorakulmio-oliosta vai nelio-oliosta. Usein on kuitenkin tavoitteena, että uusi funktio (tässä nelio::tulosta) korvaisi kokonaan vanhan funktion (suorakulmio::tulosta). Silloin täytyy tehdä funktioista virtuaalisia (engl. virtual). Olion luonnin yhteydessä olioon tallennetaan automaattisesti tieto sen virtuaalisista funktioista, ja kutsuhetkellä haetaan oikea funktio. Luokkaa, jossa on virtuaalisia funktioita, kutsutaan polymorfiseksi luokaksi (engl. polymorphic class).

Virtuaalisen funktion esittelyn perään voidaan lisätä merkintä = 0, jolloin funktiosta tulee puhtaasti virtuaalinen (engl. pure virtual) ja luokasta tulee abstrakti (engl. abstract). Abstraktista luokasta ei voi luoda oliota, vaan sitä voi käyttää ainoastaan muiden luokkien kantaluokkana.

Seuraavassa koodissa on kantaluokka kuvio ja kaksi erilaista kuviota, ympyra ja suorakulmio. Kantaluokassa määritellään virtuaalinen funktio kuvion tietojen tulostamiseen ja puhtaasti virtuaaliset funktiot kehän ja pinta-alan laskemiseen. Puhtaasti virtuaaliset funktiot ovat tarpeen, koska tulostusfunktio tarvitsee niiden palauttamia arvoja mutta itse laskut ovat mahdottomia ilman aliluokkien sisältämiä lisätietoja.

#include <iostream>

// Tämä luokka on abstrakti.
struct kuvio {
	// Tällä virtuaalifunktiolla on valmis toteutus.
	virtual void tulosta() const {
		std::cout
			<< "Erään kuvion ala on " << ala()
			<< " ja piiri on " << piiri() << "." << std::endl;
	}

	// Näillä funktioilla ei ole toteutusta lainkaan.
	// Ne ovat siis puhtaasti virtuaalisia.
	virtual double ala() const = 0;
	virtual double piiri() const = 0;
};

// Tämä luokka perii kuvio-luokan ominaisuudet.
// Jotta luokkaa voi käyttää, puhtaasti virtuaaliset funktiot täytyy toteuttaa.
struct suorakulmio: kuvio {
	double leveys, korkeus;

	suorakulmio(double l0, double k0): leveys(l0), korkeus(k0) {
	}

	// Nämä funktiot ovat kuvio-luokassa määriteltyjen
	// puhtaasti virtuaalisten funktioiden toteutukset.
	// Sanaa virtual ei tarvitse toistaa, vaan se käy ilmi kantaluokasta.
	double ala() const {
		return leveys * korkeus;
	}
	double piiri() const {
		return 2 * (leveys + korkeus);
	}
};

struct ympyra: kuvio {
	double sade;

	ympyra(double s0): sade(s0) {
	}

	// Ympyrän laskukaavat ovat erilaiset kuin suorakulmion.
	double ala() const {
		return pii() * sade * sade;
	}
	double piiri() const {
		return 2 * pii() * sade;
	}
	static double pii() {
		return 3.14159265358979323846;
	}

	// Ympyrä toteuttaa lisäksi oman version tulostuksesta.
	void tulosta() const {
		std::cout
			<< "Ympyrän säde on " << sade << ", ala on " << ala()
			<< " ja piiri on " << piiri() << "." << std::endl;
	}
};

int main() {
	// Luodaan suorakulmio-olio ja tulostetaan sen tiedot.
	suorakulmio(3, 4).tulosta();

	// Luodaan ympyra-olio ja tulostetaan sen tiedot.
	ympyra(5).tulosta();
}

Kantaluokassa virtuaalisiksi määritellyt funktiot ovat automaattisesti virtuaalisia myös aliluokissa, mutta virtual-sanan toistamisesta ei ole harmia. Sen sijaan aliluokkaan lisätty virtual-sana ei muuta tilannetta kantaluokassa, joten siitä ei ole mitään apua.

Jos C++ ei sisältäisi virtuaalisia funktioita, ne voisi enimmäkseen toteuttaa funktio-osoitinten avulla. Suunnilleen niin ne teknisesti toimivatkin, mutta näin tarvittava koodimäärä on huimasti pienempi.

Viittaukset ja osoittimet

Jos koodissa on tyyppiä kantaluokka& oleva viittaus, sen voi laittaa viittaamaan aliluokkaa edustavaan olioon. Sama koskee osoittimia. Tämän ansiosta esimerkiksi tietokonepelin kaikki hahmot (tyypistä riippumatta) tai käyttöliittymän kaikki komponentit (kuvat, napit ja tekstilaatikot tyypistä riippumatta) voivat olla osoittimina samassa listassa, kun osoittimen tyyppi on esimerkiksi hahmo* tai komponentti*. Jotta järjestely toimisi oikein, tarvitaan yllä kuvattuja virtuaalisia funktioita; muuten ohjelmalla ei ole keinoa käsitellä erilaisia olioita omilla tavoillaan.

Edellisen koodin tulosta-funktion voisi siis kirjoittaa myös luokan ulkopuolelle niin, että parametrina on viittaus kuvio-tyyppiseen olioon:

// Samat luokat kuin yllä, sitten:
void tulosta(kuvio const& k) {
	std::cout << "Kuvion ala on " << k.ala() << " ja piiri on " << k.piiri() << "." << std::endl;
}

int main() {
	tulosta(suorakulmio(3, 4));
	tulosta(ympyra(5));
}

Oikeastaan tyypinmuunnos tapahtuu myös aiemmissa koodeissa: kun kutsutaan kantaluokan funktiota, sille välittyvä (näkymätön) this-parametri on osoitin kantaluokan olioon eikä suinkaan aliluokan olioon.

Aliluokan tunnistus kantaluokan osoittimesta

Jos luokat ovat polymorfisia eli sisältävät virtuaalisia funktioita, kantaluokan viittauksen tai osoittimen voi turvallisesti muuttaa aliluokan viittaukseksi tai osoittimeksi. Tähän käytetään dynamic_cast-muunnosta. Se toimii kuin funktiomalli, jonka malliparametrina on kohdetyyppi (osoitin tai viittaus aliluokkaan) ja jonka parametriksi annetaan osoitin tai viittaus olioon. Osoittimilla epäonnistunut muunnos palauttaa tyhjän osoittimen, viittauksilla epäonnistunut muunnos heittää std::bad_cast-tyyppisen poikkeuksen.

#include <iostream>

struct kantaluokka {
	// Tehdään luokasta polymorfinen.
	virtual ~kantaluokka() {
	}

	// Lisätään luokkaan ei-virtuaalinen funktio.
	int funktio() const {
		return 1;
	}
};

struct aliluokka: kantaluokka {
	int funktio() const {
		return 2;
	}
};

void testaa(const kantaluokka& kanta) {
	std::cout << "kanta.funktio() palauttaa " << kanta.funktio() << "." << std::endl;

	// dynamic_cast toimii kuin funktiomalli, jolle annetaan
	// malliargumentiksi uusi tyyppi ja argumentiksi muunnettava arvo.

	// Jos tyyppinä on osoitin, epäonnistunut muunnos palauttaa tyhjän.
	if (const aliluokka* a = dynamic_cast<const aliluokka*>(&kanta)) {
		std::cout << "a->funktio() palauttaa " << a->funktio() << "." << std::endl;
	} else {
		std::cout << "Osoitinmuunnos epäonnistui." << std::endl;
	}

	// Jos tyyppinä on viittaus, epäonnistunut muunnos aiheuttaa poikkeuksen.
	try {
		const aliluokka& a = dynamic_cast<const aliluokka&>(kanta);
		std::cout << "a.funktio() palauttaa " << a.funktio() << "." << std::endl;
	} catch (...) {
		std::cout << "Viittausmuunnos epäonnistui." << std::endl;
	}
}

int main() {
	std::cout << "testaa(kantaluokka());" << std::endl;
	testaa(kantaluokka());

	std::cout << "testaa(aliluokka());" << std::endl;
	testaa(aliluokka());
}

Moniperintä

Sama luokka voi periä monta kantaluokkaa. Tällöin perittävät luokat erotellaan pilkuin.

struct uusi: public kanta1, private kanta2, protected kanta3, public kanta4 {
	uusi(int x, int y, int z, int w): kanta1(x), kanta2(y), kanta3(z), kanta4(w) {
	}
};

Jos kahdessa kantaluokassa on samanniminen jäsen, oikeaan jäseneen täytyy viitata kantaluokan nimen avulla, esimerkiksi kanta1::jasen. Funktioiden kohdalla tilanteen voi ratkaista valmiiksi using-esittelyllä, kuten kuormitettujen funktioiden yhteydessäkin.

Virtuaalinen perintä

Jos kaksi luokkaa, B1 ja B2, perivät luokan A ja jokin luokka C perii luokat B1 ja B2, päädytään tilanteeseen, jossa luokka C sisältää itse asiassa kahteen kertaan kaikki A:n ominaisuudet. Joskus tällä ei ole merkitystä, joskus tämä on jopa hyödyksi, mutta joskus täytyisi saada A:n ominaisuudet periytymään vain yhden kerran. Tällöin käytetään virtuaalista perintää.

Virtuaalinen perintä tekee kokonaiselle kantaluokalle saman, mitä virtuaaliset funktiot aiheuttavat yhden funktion kohdalla: olioon tallennetaan tieto tästä yhteisestä kantaluokasta, ja kantaluokan ominaisuuksia käytettäessä tiedot haetaan yhteisestä paikasta.

Seuraavassa koodissa on kantaluokka, joka laskee omia ilmentymiään. Perässä on kaksi versiota samasta luokkahierarkiasta: toinen virtuaalisella perinnällä ja toinen ilman. Koodin tulostuksesta nähdään, että ilman virtuaalista perintää syntyy tosiaan kaksi kantaluokan ilmentymää, kun taas sen kanssa syntyy vain yksi.

#include <iostream>

struct elio {
	int numero;
	elio() {
		static int maara = 0;
		numero = ++maara;
		std::cout << "Luotiin ohjelman " << numero << ". eliö." << std::endl;
	}
};

// Tavallinen moniperintä: kaksi kantaluokkaa ja niistä peritty luokka.
struct lihansyoja: elio { /* ... */ };
struct kasvi: elio { /* ... */ };

struct outo_lihansyojakasvi: lihansyoja, kasvi {
	void tulosta() const {
		// Tässä lihansyöjäkasvissa on kaksi osaa: lihansyoja ja kasvi.
		// Molemmat osat ovat erillisiä elio-luokan ilmentymiä.
		std::cout
			<< "Meitähän on kaksi! "
			<< lihansyoja::numero << " + " << kasvi::numero << " = "
			<< (lihansyoja::numero + kasvi::numero) << std::endl;
	}
};

// Virtuaaliperintä: laitetaan sana virtual ennen kantaluokkaa.
struct virtuaalilihansyoja: virtual elio { /* ... */ };
struct virtuaalikasvi: virtual elio { /* ... */ };

// Tässä ohjelmassa riittää, että elio-luokka periytyy virtuaalisesti,
// joten tähän kohtaan ei enää tarvita virtual-sanaa.
struct virtuaalilihansyojakasvi: virtuaalilihansyoja, virtuaalikasvi {
	void tulosta() const {
		// Virtuaaliperinnän ansiosta on vain yksi elio-kanta,
		// joten numero on yksiselitteinen.
		std::cout << "Olen taatusti eliö numero " << numero << "." << std::endl;
	}
};

int main() {
	std::cout << "outo_lihansyojakasvi():" << std::endl;
	outo_lihansyojakasvi().tulosta();

	std::cout << "virtuaalilihansyojakasvi():" << std::endl;
	virtuaalilihansyojakasvi().tulosta();
}

Muistinhallinta: virtuaalinen tuhoaja

Kaikkein tärkein virtuaalinen funktio oliossa on sen tuhoaja. Kun dynaamisesti eli new-operaattorilla luotu olio tuhotaan delete-operaattorilla, tuhoajan kutsumista koskevat samanlaiset säännöt kuin muitakin funktioita. Jos aliluokan osoitin on siis laitettu kantaluokan tyyppiä olevaan osoitinmuuttujaan, kutsutaankin kantaluokan tuhoajaa, ellei tuhoaja ole virtuaalinen.

Vaikka luokalla ei olisi itse määriteltyä tuhoajaa, olion tuhoamiseen sisältyy sen muuttujien tuhoaminen. Jos aliluokalle kutsutaan vain sen kantaluokan tuhoajaa, aliluokan omat muuttujat jäävät tuhoamatta. Tavallisen datan kohdalla tämä ei ole ongelma, mutta jos jäsen on osoitin dynaamisesti varattuun muistiin, muisti jää vapauttamatta, ja jos jäsen on toinen olio, tämän olion tuhoajaa ei ajeta.

#include <iostream>

// Hyvä kantaluokka: virtuaalinen tuhoaja.
struct oikea_kanta {
	oikea_kanta() {
		std::cout << "oikea_kanta()" << std::endl;
	}
	virtual ~oikea_kanta() {
		std::cout << "~oikea_kanta()" << std::endl;
	}
};

// Huono kantaluokka: ei virtuaalista tuhoajaa.
struct vaara_kanta {
	vaara_kanta() {
		std::cout << "vaara_kanta()" << std::endl;
	}
	~vaara_kanta() {
		std::cout << "~vaara_kanta()" << std::endl;
	}
};

// Molemmat kantaluokat perivä aliluokka; tuhoajasta tulee virtuaalinen, koska
// toisen kantaluokan tuhoaja on virtuaalinen.
struct aliluokka: oikea_kanta, vaara_kanta {
	aliluokka() {
		std::cout << "aliluokka()" << std::endl;
	}
	~aliluokka() {
		std::cout << "~aliluokka()" << std::endl;
	}
};

int main() {
	// Tuhotaan luokka aliluokkana. Tämä toimisi joka tapauksessa oikein.
	std::cout << "delete (aliluokka*) new aliluokka();" << std::endl;
	delete new aliluokka();

	// Luokalla oikea_kanta on virtuaalinen tuhoaja, joten tämä koodi
	// kutsuu aliluokan tuhoajaa, ja kaikki toimii yhä.
	std::cout << std::endl << "delete (oikea_kanta*) new aliluokka();" << std::endl;
	delete (oikea_kanta*) new aliluokka();

	// Luokalla vaara_kanta ei ole virtuaalista tuhoajaa, joten tämä koodi
	// ei kutsu lainkaan aliluokan omaa tuhoajaa!
	std::cout << std::endl << "delete (vaara_kanta*) new aliluokka();" << std::endl;
	delete (vaara_kanta*) new aliluokka();
}

Kirjoita kommentti

Huomio! Kommentoi tässä ainoastaan tämän oppaan hyviä ja huonoja puolia. Älä kirjoita muita kysymyksiä tähän. Jos koodisi ei toimi tai tarvitset muuten vain apua ohjelmoinnissa, lähetä viesti keskusteluun.

Muista lukea keskustelun ohjeet.
Tietoa sivustosta