Cpp virtuaalifunktiot

Mureakuha

Loikkaa: valikkoon, hakuun

Tässä luvussa tarkastellaan erästä C++:n tärkeimpiä ominaisuuksia: virtuaalifunktioita. Virtuaalifunktioita käytetään tukemaan ajonaikaista polymorfismia. Polymorfismia tuetaan C++:ssa kahdella tavalla. Ensiksikin sitä tuetaan käännösaikaisesti operaattoreiden ja funktioiden kuormituksella. Toiseksi sitä tuetaan ajonaikaisesti virtuaalifunktioiden avulla. Tulet oppimaan, kuinka ajonaikaisella polymorfismilla saavutetaan suurin joustavuus.

Johdettujen luokkien osoitteet ovat avainasemassa tarkasteltaessa virtuaalifunktioita. Niinpä asian käsittely aloitetaan tarkastelemassa johdettujen luokkien osoitteita.

Sisällysluettelo

Osoittimet johdettuihin luokkiin

Luvussa 4 (Taulukot, osoittimet ja referenssit) puhuttiin C++:n osoittimista, mutta eräs puoli jätettiin tarkoituksellisesti käsittelemättä, koska kyseinen asia liittyy niin oleellisesti virtuaalifunktio-käsitteeseen. Käsittelemätön asia on seuraava: Osoitinta, joka on määritelty osoittamaan kantaluokkaa, voidaan käyttää myös osoittamaan mitä tahansa kyseisestä kantaluokasta johdettuja luokkia.

Oletetaan, että luokka nimeltä derived perii luokan nimeltä base. Seuraavat lauseet ovat täysin oikein:

base  *p;    // osoitin kantaluokkaan
 
base  base_ob;  // base tyyppinen olio
derived  derived_ob;  // derived tyyppinen olio
 
// p voidaan myös asettaa osoittamaan oliota, joka on base tyyppinen
p = &base_ob;    // p osoittaa base_ob-oliota
 
// p voidaan myös asettaa osoittamaan oliota, joka on derived tyyppinen
p = &derived_ob;  // p osoittaa derived_ob-oliota 
 

Vaikka kantaluokan osoitinta voidaan käyttää osoittamaan johdettua luokkaa, kyseisen osoittimen avulla voidaan käsitellä vain perittyjä jäseniä. Kantaluokan osoittimella ei ole mitään tietoa sittä, mitä jäseniä johdettuun luokkaan on lisätty.

Kantaluokan osoitinta voidaan käyttää osoittamaan johdettua luokkaa, mutta päinvastainen ei ole mahdollista. Osoitinta, joka on määritelty osoittamaan johdetun luokan oliota, ei voi käyttää osoittamaan kantaluokkaan määriteltyä oliota. ( Tyyppimuunnosta (cast) voitaisiin käyttää, mutta se ei ole suositeltu tapa toimia).

Vielä viimeinen pointti: Osoitin aritmetiikkaa suoritetaan sen suhteen, mihin tyyppiin osoitin on määritelty. Jos kantaluokan osoittimella osoitetaan johdetun luokan oliota ja esimerkiksi inkrementoidaan osoitinta, osoitin ei osoita seuraavaa johdetun luokan oliota vaan jotain, mitä se kuvittelee seuraavaksi kantaluokan olioksi.

Seuraavassa lyhyt esimerkki, jossa kantaluokan osoitinta käytetään johdetun luokan olion käsittelyssä.

#include <iostream>
 
class base {
  int x;
public:
  void setx( int i ) { x = i; }
  int getx() { return x; }
};
 
class derived : public base {
  int y;
public:
  void sety( int i ) { y = i; }
  int gety() { return y; }
};
 
main()
{
  base    *p;
  base    b_ob;
  derived  d_ob;
 
  p = &b_ob;
  p->setx(10);
  cout << "Base object x: " << p->getx() << '\n';
 
  p = &d_ob;
  p->setx(99);
  // y:tä ei voi käsitellä p:n avulla
  d_ob.sety(88);
  cout << "Derived object x: " << p->getx() << ' ';
  cout << "Derived object y: " << d_ob.gety() << '\n';
 
  return 0;
}

Vielä tässä esimerkissä ei näy mitään mieltä, miksi pitäisi käyttää kantaluokan osoittimia, mutta jo seuraavan kappaleen jälkeen ymmärrät, missä ko. osoittimien käytön salaisuus piilee.

Virtuaalifunktiot, esittely

Virtuaalifunktio on luokan jäsenfunktio, joka on määritelty kantaluokassa ja uudelleen johdetussa luokassa. Virtuaalifunktiota määriteltäessä sen eteen laitetaan sana virtual. Kun sellainen luokka, joka sisältää virtuaalifunktion, peritään, johdettu luokka määrittelee virtuaalifunktion uudelleen omaan luokkaansa. Virtuaalifunktioilla toteutetaan polymorfismin perusperiaate: "yksi liittymä, monta metodia" filosofia. Kantaluokkaan määritelty virtuaalifunktio määrittelee kyseisen liittymän muodon. Jokaisessa johdettujen luokkien virtuaalifunktiossa toteutetaan itse toiminta liittyen juuri kyseiseen johdettuun luokkaan. Uudelleen määrittelyssä luodaan siis tietty metodi. Johdetussa luokassa olevassa virtuaalifunktiossa ei käytetä avainsanaa virtual.

Virtuaalifunktiota voidaan kutsua kuin mitä tahansa jäsenfunktiota. Se mikä tekee virtuaalifunktioista mielenkiintoisia - ja kykeneviä tukemaan polymorfismia - on siinä, kun virtuaalifunktiota kutsutaan osoittimen avulla. Kun kantaluokkaan määritelty osoite osoittaa johdetun luokan oliota ja johdetussa luokassa olevaa virtuaalifunktiota kutsutaan kyseisen osoittimen avulla, kääntäjä tekee päätöksen, mitä kyseisen funktion versiota kutsutaan. Kääntäjä perustaa päätöksensä siihen, minkä tyyppistä oliota kyseinen osoitin itseasiassa osoittaa. Siis osoitetun olion tyyppi määrää, mitä virtaalifunktiota kutsutaan.

Kun kaksi tai useampi luokka on johdettu samasta kantaluokasta, joka sisältää virtuaalifunktion, ja kantaluokan osoittimen avulla kutsutaan johdettujen luokkien kyseistä virtuaalifunktiota, mitä funktiota milloinkin kutsutaan riippuu olioiden tyypeistä. Tämä on se tapa miten ajonaikainen polymorfismi saavutetaan.

Jos selitykset eivät olleet aivan selviä, esimerkit valaisevat asiaa:

#include <iostream>
 
class base {
public:
  int  i;
  base (int x) { i = x; }
  virtual void func()
  {
    cout << "Using base-version of func(): ";
    cout << i << "\n";
  }
};
 
class derived1 : public base {
public:
  derived1 (int x) : base(x) {}
  void func()
  {
    cout << "Using derived1-version of func(): ";
    cout << i*i << "\n";
  }
};
 
class derived2 : public base {
public:
  derived2 (int x) : base(x) {}
  void func()
  {
    cout << "Using derived2-version of func(): ";
    cout << i*i*i << "\n";
  }
};
 
main()
{
  base    *p;
  base    ob(10);
  derived1  d_ob1(10);
  derived2  d_ob2(10);
 
  p = &ob;
  p->func();
 
  p = &d_ob1;
  p->func();
 
  p = &d_ob2;
  p->func();
 
  return 0;
}

Ohjelma tulostaa:

Using base-version of func(): 10
Using derived1-version of func(): 100
Using derived2-version of func(): 1000

Virtuaalifunktion uudelleen määrittely johdetussa luokassa saattaa aluksi näyttää vain funktion kuormitukselta. Tästä ei kuitenkaan ole lainkaan kysymys. Kuormitettujen funktioiden on erottava toisistaan parametrien tyyppien tai lukumäärän suhteen. Uudelleen määritellyssä virtuaalifunktiossa on oltava täsmälleen samat parametrit ja sama palautustyyppi kuin kantaluokankin virtuaalifunktiossa. (Jos funnktion argumenttien tyyppejä tai lukumäärää tai funktion palautustyyppiä muutetaan, funktio muuttuu tavalliseksi kuormitetuksi funktioksi ja sen virtuaaliluonne katoaa.) Virtuaalifunktioiden on aina oltava luokan jäsenfunktioita, kuormitetuilla funktioilla ei tarvitse olla näin. Destruktorifunktiot voivat olla virtuaalisia, konstruktorit eivät voi olla. Kun taas konstruktoreita voidaan kuormittaa, destruktoreita ei voi.

Virtuaalifunktiot ovat hierarkisia perimysjärjestyksessä. Jos johdetussa luokassa ei ole määritelty virtuaalifunktiota uudelleen, kutsutaan kantaluokassa määriteltyä funktiota:

#include <iostream>
 
class base {
public:
  int  i;
  base (int x) { i = x; }
  virtual void func()
  {
    cout << "Using base-version of func(): ";
    cout << i << "\n";
  }
};
 
class derived1 : public base {
public:
  derived1 (int x) : base(x) {}
  void func()
  {
    cout << "Using derived1-version of func(): ";
    cout << i*i << "\n";
  }
};
 
class derived2 : public base {
public:
  derived2 (int x) : base(x) {}
  // ei sisällä func():ta
};
 
main()
{
  base    *p;
  base    ob(10);
  derived1  d_ob1(10);
  derived2  d_ob2(10);
 
  p = &ob;
  p->func();
 
  p = &d_ob1;
  p->func();
 
  p = &d_ob2;
  p->func();
 
  return 0;
}

Ohjelma tulostaa:

Using base-version of func(): 10
Using derived1-version of func(): 100
Using base-version of func(): 10
#include <iostream>
 
class base {
public:
  int  i;
  base (int x) { i = x; }
  virtual void func()
  {
    cout << "Using base-version of func(): ";
    cout << i << "\n";
  }
};
 
class derived1 : public base {
public:
  derived1 (int x) : base(x) {}
  void func()
  {
    cout << "Using derived1-version of func(): ";
    cout << i*i << "\n";
  }
};
 
class derived2 : public derived1 {
public:
  derived2 (int x) : derived1(x) {}
  // ei sisällä func():ta
};
 
main()
{
  base    *p;
  base    ob(10);
  derived1  d_ob1(10);
  derived2  d_ob2(10);
 
  p = &ob;
  p->func();
 
  p = &d_ob1;
  p->func();
 
  p = &d_ob2;
  p->func();
 
  return 0;
}

Ohjelma tulostaa:

Using base-version of func(): 10
Using derived1-version of func(): 100
Using derived1-version of func(): 100
#include <iostream>
 
class base {
public:
  int  i;
  base (int x) { i = x; }
  virtual void func()
  {
    cout << "Using base-version of func(): ";
    cout << i << "\n";
  }
};
 
class derived1 : public base {
public:
  derived1 (int x) : base(x) {}
  // ei sisällä func():ta
};
 
class derived2 : public derived1 {
public:
  derived2 (int x) : derived1(x) {}
  // ei sisällä func():ta
};
 
main()
{
  base    *p;
  base    ob(10);
  derived1  d_ob1(10);
  derived2  d_ob2(10);
 
  p = &ob;
  p->func();
 
  p = &d_ob1;
  p->func();
 
  p = &d_ob2;
  p->func();
 
  return 0;
}

Ohjelma tulostaa:

Using base-version of func(): 10
Using base-version of func(): 10
Using base-version of func(): 10

Seuraavassa esitetään, kuinka virtuaalifunktioilla voidaan vastata salunnaisiin ajon aikana sattuviin tapahtumiin. Ohjelma valitsee kahden olion ( d_ob1 ja d_ob2) välillä riippuen siitä, mitä satunnaislukugeneraattori palauttaa.

#include <iostream>
#include <stdlib.h>
 
class base {
public:
  int  i;
  base( int x ) { i = x; }
  virtual void func()
  {
    cout << "Base version of func(): ";
    cout << i << "\n";
  }
};
 
class derived1 : public base {
public:
  derived1( int x ) : base( x ) {}
  void func()
  {
    cout << "Derived1 version of func(): ";
    cout << i*i << "\n";
  }
};
 
class derived2 : public base {
public:
  derived2( int x ) : base( x ) {}
  void func()
  {
    cout << "Derived2 version of func(): ";
    cout << (i+i) << "\n";
  }
};
 
main()
{
  base      *p;
  derived1    d_ob1(10);
  derived2    d_ob2(10);
  int      i, j;
 
  for (i=0; i<10; i++) {
    j = rand();
    if (j%2) p = &d_ob1;
    else p = &d_ob2;
 
    p->func();
  }
  return 0;
}

Seuraavassa ohjelmassa luodaan yleinen kantaluokka nimeltä area, joka ylläpitää kuvion kahta dimensiota. Luokassa on virtuaalifunktio getarea(), joka ei tee juuri mitään itse kantaluokassa, mutta johdettujen luokkien getarea()-funktiot palauttavat kyseisen kuvion alan. Kantaluokassa määritellään vain liittymän muoto ja itse toteutus tapahtuu johdetuissa luokissa.

#include <iostream>
 
class area {
  double  dim1, dim2;
public:
  void setarea( double d1, double d2 ) {
    dim1 = d1;
    dim2 = d2;
  }
  void getdim( double &d1, double &d2 )
  {
    d1 = dim1;
    d2 = dim2;
  }
  virtual double getarea()
  {
    cout << "You mus override this function\n";
    return 0.0;
  }
};
 
class rectangle : public area {
public:
  double getarea()
  {
    double d1, d2;
    getdim( d1, d2 );
    return d1*d2;
  }
};
 
class triangle : public area {
public:
  double getarea()
  {
    double d1, d2;
    getdim( d1, d2 );
    return d1*d2/2.0;
  }
};
 
main()
{
  area      *p;
  rectangle  r;
  triangle    t;
 
  r.setarea( 3.3, 4.5 );
  t.setarea( 4.0, 5.0 );
 
  p =&t;
  cout << "Triangle area: " << p->getarea() << '\n';
 
  p =&r;
  cout << "Rectangle area: " << p->getarea() << '\n';
 
  return 0;
}

Lisää virtuaalifunktioista

Kuten edellä olleessa area-esimerkissä usein virtuaalifunktiolla ei ole mitään järkevää tekemistä kantaluokassa. (Minkä alan kantaluokka olisi tulostanut suorakulmion, kolmion, ehkäpä neliön??) Tilanne on hyvin tavallinen. Usein kantaluokka ei määrittele kokonaista luokkaa (olioita ei voida luoda kantaluokan tyyppiseksi). Kantaluokka luo ja ylläpitää ainoastaan ydinjoukkoa muuttujia ja jäsenfunktioita, joihin johdettu luokka luo ja ylläpitää puuttuvat osat. Mikäli kantaluokan virtuaalifunktiolle ei ole järkevää operaatiota, jokaisessa johdetussa luokassa on oltava vastaava funktio. Jotta tämä voidaan taata jo käännösvaiheessa, C++:ssa on aidot virtuaalifunktiot (pure virtual function).

Aidossa virtuaalifunktiossa ei ole mitään operaatiota kantaluokassa. Kantaluokassa on vain funktion prototyyppi. Aidon virtuaalifunktion muoto:

virtual  tyyppi funktion_nimi( argumentit ) = 0;

Virtuaalifunktion asettaminen kantaluokassa nollaksi tekee siitä aidon virtuaalifunktion, jolloin kääntäjä tarkistaa, että kaikissa johdetuissa luokissa on vastaava funktio.

Kun luokka sisältää vähintään yhden aidon virtuaalifunktion, sanotaan, että kyseessä on abstrakti luokka. Kyseinen luokka on teknisesti epätäydellinen, jolloin kyseiseen tyyppiin ei voida luoda olioita. Abstraktit luokat ovat olemassa vain perittäviksi. On tärkeää ymmärtää, että abstraktiin luokkaan voidaan kuitenkin luoda osoitin, jolloin ajonaikainen polymorfismi voidaan saavuttaa. Seuraavassa area-esimerkki, siten että getarea()-funktio on aito virtuaalifunktio.

#include <iostream>
 
class area {
  double  dim1, dim2;
public:
  void setarea( double d1, double d2 ) {
    dim1 = d1;
    dim2 = d2;
  }
  void getdim( double &d1, double &d2 )
  {
    d1 = dim1;
    d2 = dim2;
  }
  virtual double getarea() = 0;
};
 
class rectangle : public area {
public:
  double getarea()
  {
    double d1, d2;
    getdim( d1, d2 );
    return d1*d2;
  }
};
 
class triangle : public area {
public:
  double getarea()
  {
    double d1, d2;
    getdim( d1, d2 );
    return d1*d2/2.0;
  }
};
 
main()
{
  area      *p;
  rectangle  r;
/* Jos rectangle luokka ei sisältäisi getarea()-funktiota, saataisiin:
  Error NONAME00.CPP 42: Cannot create instance of abstract class 
    'rectangle' in function main()
  Error NONAME00.CPP 42: Class 'rectangle' is abstract because of 
    'area::getarea() = 0' in function main()
*/
  triangle    t;
 
  r.setarea( 3.3, 4.5 );
  t.setarea( 4.0, 5.0 );
 
  p =&t;
  cout << "Triangle area: " << p->getarea() << '\n';
 
  p =&r;
  cout << "Rectangle area: " << p->getarea() << '\n';
 
  return 0;
}

Polymorfismin soveltaminen

Nyt kun tiedät, miten virtuaaliunktioita käytetään ajon aikaisen polymorfismin saavuttamiseksi, lienee aika tarkastella, kuinka ja miksi käyttää niitä. Vielä kerran: polymorfismi on prosessi, jonka avulla saavutetaan yhteinen liittymä kahdelle tai useammalle samankaltaiselle (teknisesti erilaiselle) toiminnolle. Siis toteutetaan yksi liittymä, monta metodia filosofia. Polymorfismin avulla voidaan huomattavasti yksinkertaistaa monimutkaisia järjestelmiä. Käytetään yhtä hyvin määriteltyä liittymää ja suoritetaan erilaisia mutta yhteenkuuluvia toiminteita, jolloin keinotekoinen kompleksisuus katoaa. (Syötetään lasta, syötetään jääkiekossa, syötetään jalkapallossa, syötetään paperia kirjoittimeen, jne: kaikki ovat syöttämisiä, mutta tapauskohtaisesti toiminta voi olla hyvinkin erilaista.) Polymorfismin avulla ohjelmat tulevat helpommin ymmärrettäviksi ja ylläpidettäviksi. Tarvitsee muistaa vähemmän, kun samankaltaisilla toimenpiteillä on samanlainen liittymä.

OOP:hen ja erityisesti C++:aan liitetään yleisesti kaksi termiä: aikainen sidonta (early binding) ja myöhäinen sidonta (late binding). On tärkeää ymmärtää termien merkitys. Aikainen sidonta viittaa tapahtumiin, jotka tiedetään käännösaikana. Erityisesti se viittaa niihin funktiokutsuihin, jotka tiedetään käännöshetkellä. Aikaiseen sidontaan kuuluvat tavalliset funktiot, kuormitetut funktiot ja ei-virtuaaliset jäsen- sekä friend-funktiot. Kun edellisiä funktioita käännetään, kaikki se osoiteinformaatio, mitä tarvitaan niiden kutsumiseksi, on tiedossa käännöshetkellä. Päähyöty (ja syy, miksi sitä käytetään niin paljon) aikaisesta sidonnasta tulee siitä, että aikainen sidonta on tehokasta. Nopeimmat funktiokutsut ovat sellaisia, jotka on sidottu käännösaikana. Päähaitta aikaisesta sidonnasta on joustavuuden puute.

Myöhäinen sidonta viittaa ajon aikaisiin tapahtumiin. Myöhään sidottu funktiokutsu on sellainen, että kutsuttavan funktion osoite on tiedossa vasta ajon aikana. C++:ssa virtuaalifunktiot ovat myöhään sidottuja olioita. Kun virtuaalifunktiota kutsutaan kantaluokan osoittimen avulla, ohjelman on pääteltävä ajon aikana, minkä tyyppistä oliota kantaluokan osoitin osoittaa ja sitten valittava kyseiseen tyyppiin liittyvä virtuaalifunktion versio. Päähyöty myöhaisestä sidonnasta on ajonaikaisessa joustavuudessa. Ohjelma voi käsitellä suurta joukkoa satunnaisia tapahtumia ilman valtavaa ehtojen käsittelykoodia (if ... elseif ... elseif ...). Päähaitta on funktiokutsuihin liittyvässä järjestelmän tuottamassa lisäkuormassa. Tällaiset funktiokutsut ovat yleensä hitaampia kuin aikaisen sidonnan kutsut.

Mahdollisen tehokkuuden laskun takia, on tehtävä päätös siitä, milloin on sopivaa käyttää aikaista sidontaa, milloin myöhäistä.

Käyttöjärjestelmissä kuten Windows järjestelmän liittymä ohjelmiin tapahtuu sanomien välityksellä. Näitä sanomia generoituu satunnaisesti riippuen käyttäjän ja järjestelmän toiminnasta. Ohjelman on vastattava saamiinsa sanomiin. Kuten arvata saattaa ajonaikaisesta polymorfismista on hyötyä ohjelmissa, jotka on tehty näihin järjestelmiin.

Tämän dokumentin kopiointi, levittäminen sekä muokkaaminen on sallittua GNU Free Documentation Licensen version 1.2 tai uudemman Free Software Foundationin julkaiseman version mukaisesti, ilman muuttumattomuuslauseketta tai kansitekstejä. Tätä koskee vastuuvapaus.
Kopio lisenssistä (englanniksi) löytyy täältä.

Alkuperäinen (c) Petteri Hämäläinen

Henkilökohtaiset työkalut