Öröklés C-ben

Czirkos Zoltán · 2022.06.21.

Objektumok memóriaképe I. A tartalmazás és az öröklés közötti összefüggés. Az öröklés megvalósítása C-ben. A virtuális függvények működése. C nyelvű OOP keretrendszerek.

Ebben az írásban a mélyére nézünk annak, mit csinál egy C++ fordító akkor, amikor osztályok közötti öröklést használunk. Megnézzük, miért működhetett az első C++ fordító úgy, hogy C kóddá fordította át a programszöveget. Körbejárjuk azt is, hogyan lehet C-ben megvalósítani ugyanezt, és hogy hogyan működnek a C nyelvű objektumorientált keretrendszerek.

1. Az objektumok felépítése

Az előadáson láttuk, hogy hogyan épít fel a fordító egy objektumot:

  • Az adattagok egymás után helyezkednek el a memóriában.
  • Az első adattag az objektum legelején található.
  • Leszármazás esetén a leszármazott objektum elején egy pont ugyanolyan memóriaképű részobjektum (subobject) lesz, mint az ő ősosztálya.

Az utóbbi kellett ahhoz, hogy az ősosztály lefordított tagfüggvényei működjenek a leszármazott objektumokon is. Mindegy, hogy ősosztálybeli, vagy leszármazott objektumot kap a tagfüggvény, mindkét esetben ugyanazokat az ofszeteket kell használnia.

Tehát az osztályok definíciója alapján a fordító meghatározza, hogy melyik adattag milyen ofszeten kezdődjön az objektumokban. A C szabványban van egy külön szabály arra vonatkozóan, hogy ez hogyan kell történjen. Így hangzik: egy struktúra első adattagjának ofszete pontosan nulla. Másképpen fogalmazva, a struktúra memóriacíme meg kell egyezzen az első adattagjának memóriacímével. Ezt a szabályt kihasználva tudunk olyan kódot írni C-ben, amely a C++ öröklést utánozza!

A megvalósítás módja a következő. Először is, definiáljuk az alaposztályt: struct Shape. Ebbe bármilyen adattagot írhatunk. Ezután definiáljuk a leszármazott osztályt: struct Rectangle, amelybe első adattagként tegyünk egy Shape objektumot. Ez itt a trükk, mert ennek az adattagnak lesz nulla az ofszete. Így lesz mindig pont a Rectangle objektumok címén a tartalmazott Shape objektum. A Rectangle* pointer így biztonságosan átadható egy Shape*-ot váró függvénynek is:

#include <stdio.h>

typedef struct Shape {
    int x, y;
} Shape;

typedef struct Rectangle {
    Shape base_obj; // első adattag!
    int width, height;
} Rectangle;

void print_shape(Shape const *s) {
    printf("x=%x y=%x\n", s->x, s->y);
}

int main() {
    Rectangle d1 = { {0x1122, 0x3344}, 0x5566, 0x7788 };

    printf("&d1.base_obj = %p\n", &d1.base_obj);
    printf("&d1 = %p\n", &d1);

    print_shape(&d1.base_obj);
    print_shape((Shape*) &d1); // Shape* = Rectangle*
}

Az eredmény és a belső működés teljesen ugyanolyan, mintha C++-ban írtuk volna és öröklést használtunk volna. A hátrány az, hogy nyelvi támogatás híján szintaktikailag nehézkes mindez, és könnyebb elrontani is. Egyrészt ha Rectangle típusú objektumot látunk, annak örökölt adattagjait csak közvetve tudjuk elérni: d1.x helytelen, helyette d1.base_obj.x vagy ((Shape*) &d1)->x kell. Másrészt paraméterátadáskor kézzel kell a pointer típusát átalakítani, print_shape(&d1) helyett print_shape((Shape*) &d1)-et írni, különben figyelmeztetést kapunk.

Nézzünk meg egy konkrét példát! Ha így kell dolgoznunk, akkor érdemes egy szabályrendszert kialakítani, hogy mit hogyan csinálunk, mit hogyan nevezünk el, és azt követve írni meg a kódot. Ilyen konvenció, hogy mindig self-nek nevezzük el a this-t helyettesítő pointert (vannak nyelvek, ahol pont így hívják), vagy hogy mindig első paraméterként kapják ezt a függvények. Tehát egy egyszerű C++ osztály, és a C párja így nézhet ki:

C++
struct Shape {
    int x, y;
    Shape(int x, int y) : x(x), y(y) {}
    void print() const {
        printf("Shape::print, %x %x\n", x, y);
    }
    ~Shape() {
        printf("Shape::destruct");
    }
};
C
typedef struct Shape {
    int x, y;
} Shape;

void shape_construct(Shape *self, int x, int y) {
    self->x = x;
    self->y = y;
}

void shape_print(Shape const *self) {
    printf("Shape::print, %x %x\n", self->x, self->y);
}

void shape_destruct(Shape *self) {
    printf("Shape::destruct");
}

A konkrét öröklés megvalósításánál pedig kézzel kell megírnunk azt, amit a C++-tól megtanultunk. Például hogy a leszármazott konstruktora hívja az ős konstruktorát, a destruktora pedig az ős destruktorát:

C++
struct Rectangle : Shape {
    int width, height;
    Rectangle(int x, int y, int width, int height)
        : Shape(x, y)
        , width(width), height(height) {}
    ~Rectangle() {
        printf("Rectangle::destruct");
    }
};
C
typedef struct Rectangle {
    Shape base_obj;
    int width, height;
} Rectangle;

void rectangle_construct(Rectangle *self, int x, int y, int width, int height) {
    shape_construct((Shape*) self, x, y); // ős konstruktora
    self->width = width;
    self->height = height;
}

void rectangle_destruct(Rectangle *self) {
    printf("Rectangle::destruct");
    shape_destruct((Shape*) self); // ős destruktora
}

Mivel az OOP-nél általában nem tudjuk, milyen típusokkal dolgozunk (bármelyik objektum lehet leszármazottja is annak az osztálynak, amit látunk), C-ben a legtöbb helyen pointerekkel kell dolgoznunk. Az objektumok nem egyforma méretűek, a pointerek azonban egyformák, és ezért ezekkel könnyebben tudunk dolgozni. Legtöbbször dinamikus memóriakezelést is használunk, hogy tudjunk olyan függvényt írni, ami valamilyen objektumot létrehoz. A dinamikus memóriakezelésnél arra kell figyelni, hogy a konstruktorok maguk nem képesek ellátni a foglalás feladatát. Ha a rectangle_construct() is foglalna egy memóriaterületet, meg az általa meghívott shape_construct() is, akkor már két objektumunk lenne! A foglalást a konstruktor hívása előtt, azzal egyező típusra kell elvégezni.

Rectangle * rectangle_new(int x, int y, int width, int height) {
    Rectangle *self = (Rectangle *) malloc(sizeof(Rectangle));
    rectangle_construct(self, x, y, width, height);
    return self;
}

2. A virtuális függvények

Az alakzatos példa közismert folytatása: minden alakzat a saját maga módján rajzolódik ki. Tegyünk ezért egy virtuális függvényt az alakzat ősosztályba! Emlékezzünk vissza: a virtuális függvény lényege az, hogy a hívás helyén ismert típustól függetlenül (általában: alaposztály pointere vagy referenciája), az objektum valós típusának megfelelő függvény hívódik (általában: valamilyen leszármazott). Nézzük meg, hogy néz ki egy ilyen objektum memóriaképe!

struct Shape {
    int x, y;
    Shape(int x, int y) : x(x), y(y) {}
    virtual void draw() const {
        std::cout << "Shape draw" << std::endl;
    }
};

struct Rectangle : Shape {
    int width, height;
    Rectangle(int x, int y, int width, int height)
        : Shape(x, y), width(width), height(height) {
    }
    virtual void draw() const {
        std::cout << "Rectangle draw" << std::endl;
    }
};

int main() {
    Shape b(0x1122, 0x3344);
    print_obj_hex(b);

    Rectangle d1(0x1122, 0x3344, 0x5566, 0x7788);
    print_obj_hex(d1);

    Shape *ps1 = &b, *ps2 = &d1;
    ps1->draw();
    ps2->draw();
}
a0 12 40 00 00 00 00 00 22 11 00 00 44 33 00 00
20 12 40 00 00 00 00 00 22 11 00 00 44 33 00 00 66 55 00 00 88 77 00 00
Shape draw
Rectangle draw

A nem virtuális függvényeknél fordítási időben, statikusan dől el az, hogy melyik függvény hívódik meg (early binding, static binding), a virtuális függvényeknél futási időben, dinamikusan (late binding, dynamic binding, dynamic dispatch). Ennek a legegyszerűbb megvalósítása az, ha a virtuális függvényhívást indirektté tesszük: az objektumokban egy pointer utal arra, hogy melyik a meghívandó függvény. Ezt a rejtett pointert látjuk most az objektumok elején.

Teljes bizonyossággal kijelenthetjük, hogy a virtuális függvény a legfontosabb dolog az OOP-ben, hiszen enélkül az öröklésnek marginális haszna lenne csak. Nem véletlen, hogy külön nyelvi elemet biztosít a C++ a virtuális függvények létrehozására, mert ezzel teszi egyszerűvé a használatukat. Az örökléshez hasonló a helyzet itt a C és a C++ közötti viszonyt tekintve: az, hogy C-ben nincsenek virtuális függvények, még nem jelenti azt, hogy nem tudunk hasonló működést létrehozni. Függvényre mutató pointereink vannak, használjuk hát azokat! A fenti kóddal ekvivalens C kód az alábbi:

typedef struct Shape Shape;
struct Shape {
    void (*draw) (Shape const *self); // a virtuális függvényre mutat
    int x, y;
};

void shape_draw(Shape const *self) {
    printf("Shape draw\n");
}

void shape_construct(Shape *self, int x, int y) {
    self->draw = &shape_draw; // a konstruktor beállítja
    self->x = x;
    self->y = y;
}

typedef struct Rectangle Rectangle;
struct Rectangle {
    Shape base_obj; // örökli a pointert is
    int width, height;
};

void rectangle_draw(Rectangle const *self) {
    printf("Rectangle draw\n");
}

void rectangle_construct(Rectangle *self, int x, int y, int width, int height) {
    shape_construct((Shape*) self, x, y);
    ((Shape*) self)->draw = (void(*)(Shape const *)) &rectangle_draw; // sajátjára
    self->width = width;
    self->height = height;
}

int main() {
    Shape b1;
    shape_construct(&b1, 0x1122, 0x3344);

    Rectangle d1;
    rectangle_construct(&d1, 0x1122, 0x3344, 0x5566, 0x7788);

    Shape *ps1 = (Shape*) &b1, *ps2 = (Shape*) &d1;
    ps1->draw(ps1);
    ps2->draw(ps2);
}

A működés a következő. A Shape osztály szokásos x és y adattagja elé fölveszünk egy draw, pointer típusú adattagot. Ez fogja mutatni azt, hogy a konkrét objektumpéldánynak melyik a saját típusához való kirajzolófüggvénye. A létrehozásnál shape_construct() konstruktor beállítja ezt az adattagot a shape_draw() függvényre. Ugyanígy tesz a rectangle_construct() konstruktor is; miután meghívta az alaposztály konstruktorát, átállítja a pointert a rectangle_draw()-ra. Az értékadás elég körülményes, de mindvégig szabályos, amit csinálunk benne. A bal oldalon a self pointert Rectangle* típusról Shape* típusra cast-oljuk; ezt szabad, mert a Rectangle struktúrában 0 ofszeten egy Shape struktúra van. A rectangle_draw függvényre mutató pointert pedig át kell cast-olni olyan típusú függvény pointerére, amelyet a Shape osztálynál is használtunk; de ezt is szabad, mivel a két függvény fejléce csak a pointer paraméter típusában tér el egymástól, és minden objektumra mutató pointer egyforma.

Az igazi varázslat a virtuális függvények hívásánál történik: ezek alakja objptr->fptr(objptr) kell legyen. A kifejezés objptr->fptr részlete kiolvassa az objektumból a függvényre mutató pointert, a () operátor pedig a szokásos módon meghívja azt a függvényt. A fenti példában a ps1 pointer a b1 objektumra mutat, ezért a shape_draw függvény címét kapjuk; a ps2 pointer pedig a d1 objektumra, amelybe a rectangle_construct() függvény a rectangle_draw() címét tette. Akármilyen meglepő, hogy C-ben le lehet írni egy obj.f() alakú vagy egy obj->f() alakú kifejezést, mégis így van: az objektumba tett függvénypointer esetén a kifejezés szintaktikailag értelmes, és az OOP tapasztalatok alapján tudjuk, hogy hasznos is.

Ahogy egy leszármazott objektum létrehozása folyik, a konstruktorok az adattagok inicializálása mellett a virtuális függvény pointerét is az újabb és újabb változatra cserélik. Az objektum egyfajta egyedfejlődésen megy keresztül: általános alakzatként kezdi az életét, csak utána fejlődik tovább, „upgrade-elődik” téglalappá. Ez a C++-os változatban is így történik, és könnyen ellenőrizhető is: ha a Shape() és a Rectangle() konstruktorba is teszünk egy draw() hívást, az előbbi „Shape draw”, az utóbbi „Rectangle draw” sort fog kiírni. Mindez visszafelé is megtörténik: miután a Rectangle destruktora elvégezte a dolgát, a függvény pointere visszaáll az eredeti értékre, a Shape::draw() függvényre. Tehát az objektum visszafejlődik, „downgrade-elődik”. Végeredményben így működik egy C++ konstruktor- és destruktorpár:

void rectangle_construct(Rectangle *self, int x, int y, int width, int height) {
    shape_construct((Shape*) self, x, y);
    ((Shape *) self)->draw = (void(*)(Shape const *)) &rectangle_draw; // upgrade
    /* ... */
}

void rectangle_destruct(Rectangle *self) {
    /* ... */
    ((Shape *) self)->draw = &shape_draw; // downgrade
    shape_destruct((Shape *) self);
}

Mindennek a célja az is, hogy a konstruktorból és a destruktorból ne lehessen olyan adattagot elérni, amely még (vagy már) nem létezik. Ha a Shape konstruktorából a Rectangle-specifikus virtuális függvényt hívnánk meg, az olyan adattagra is hivatkozhatna, amelyet majd csak a Rectangle() konstruktor fog létrehozni, és egyelőre csak memóriaszemét van a helyükön. A destruktoroknál is hasonló a helyzet: ha a ~Rectangle() destruktora után a ~Shape() destruktorból Rectangle-osztálybeli függvény hívódna, az már megszűnt adattagokon próbálna dolgozni. Ezért szokás úgy tanítani, hogy konstruktorból és destruktorból tilos virtuális függvényhívást csinálni. Mint látjuk, de jure nem tilos, azonban de facto nem az történik a hívás által, amire számítunk. Ha ilyet szeretnénk az osztálytól, az tervezési hibára utal.

3. Több virtuális függvény

Függvénypointer adattagok segítségével akárhány virtuális függvényt adhatunk egy osztálynak. Adjunk például a Shape osztálynak egy virtuális destruktort, és tegyük fel, hogy a Rectangle osztálynak adunk egy resize() függvényt is:

struct Shape {
    Shape(int x, int y);
    int x, y;
    virtual void draw() const;
    virtual ~Shape();
};

struct Rectangle : Shape {
    Rectangle(int x, int y, int width, int height);
    int width, height;
    virtual void draw() const;
    virtual void resize(int width, int height);
};

Mindez megoldható újabb és újabb függvénypointer adattagok felvételével. A probléma csak az, hogy így az objektumok egyre nagyobbak lennének. Semmi gond, ezen is segít egy újabb indirekció! Észrevehetjük, hogy a virtuális függvények nem konkrét objektumokhoz, hanem osztályokhoz tartoznak, osztályonként csoportosíthatóak. A fenti példában az összes Shape objektumhoz a Shape::draw() függvény és a Shape::~Shape() destruktor tartozik. Az összes Rectangle-höz pedig a Rectangle::draw() függvény, a fordító által írt Rectangle::~Rectangle() destruktor, és a Rectangle::resize().

Shape osztály
NévFüggvény
drawShape::draw()
destruktorShape::~Shape()
Rectangle osztály
NévFüggvény
drawRectangle::draw()
destruktorRectangle::~Rectangle()
resizeRectangle::resize()

Mint tudjuk, az összetartozó dolgokat struktúrába kell tenni, tegyük ezért a függvénypointereket is abba! Akkor az objektumokba nem kell sok pointert tenni, hanem csak egyetlen egyet, amelyik az adott objektum osztályához tartozó struktúrára mutat. Így születik meg a virtuális függvénytábla (virtual function table, virtual table, vtable).

Még egy dolgot észrevehetünk. A Shape-eknek virtuális destruktora van, és virtuális draw()-ja. Ahogyan a Rectangle-öknek is, csak az kiegészül egy virtuális resize() függvénnyel. Ez megint nem más, mint egy öröklési kapcsolat. A Rectangle virtuális függvénytáblája egy olyan struktúra, amely a Shape függvénytáblájából öröklődik. Ez általában is így van: a virtuális függvénytáblák között párhuzamosan ugyanaz a hierarchia van, mint az osztályok között, amelyekhez tartoznak.

Ezek a virtuális függvénytábla objektumok létre is jönnek a memóriában. A C++ fordító a programba belefordítja őket, globális, „örökéletű” objektumként, amely persze névtelen, a programozó számára láthatatlan. A minden részletet mutató C megvalósítás az alábbi:

typedef struct Shape Shape;
typedef struct ShapeVirtTable ShapeVirtTable;

struct Shape {
    ShapeVirtTable *vptr; // egyetlen pointer
    int x, y;
};

void shape_construct(Shape *self, int x, int y);
void shape_draw(Shape const *self);
void shape_destruct(Shape *self);

struct ShapeVirtTable {
    void (*draw) (Shape const *);
    void (*dtor) (Shape *);
} shape_virtual_table = { // globális élettartamú változó
    &shape_draw,
    &shape_destruct
};

Látszik, hogy az objektum egyetlen pointert tartalmaz csak. Bármennyi virtuális függvényünk is legyen, az objektumonkénti költség nem lesz több egyetlen egy pointernél! A leszármazás pedig:

typedef struct Rectangle Rectangle;
typedef struct RectangleVirtTable RectangleVirtTable;

struct Rectangle {
    Shape base_obj; // már van benne vptr
    int width, height;
};

void rectangle_draw(Rectangle const *self);
void rectangle_resize(Rectangle *self, int width, int height);
void rectangle_construct(Rectangle *self, int x, int y, int width, int height);
void rectangle_destruct(Rectangle *self);

struct RectangleVirtTable {
    ShapeVirtTable base_obj; // párhuzamos öröklődés
    void (*resize) (Rectangle *, int, int);
} rectangle_virtual_table = {
    { (void (*) (Shape const *)) &rectangle_draw,
      (void (*) (Shape *)) &rectangle_destruct
    },
    &rectangle_resize
};

A virtuális függvény hívásánál az objektumból előbb kivesszük a tábla pointerét, aztán a táblából pedig a függvény pointerét. Az előzőekhez hasonlóan a sok cast operátor azért kell, mert a C nem ismeri az öröklődés fogalmát. De mindegyik cast helyes, akár a függvények, akár az objektumok pointereiről van szó:

((Shape const *) &rect)->vptr->draw((Shape const *) &rect);

Persze ezt a körülményes dolgot elrejthetnénk egy void* paraméterű függvénybe is:

void shape_draw_wrapper(void const *self) {
    ((Shape const *) self)->vptr->draw((Shape const *) self);
}

Rectangle *r1 = /* ... */;
shape_draw_wrapper(&r1);

A virtuális függvényhívások sebessége

Gyakran éri az a vád a virtuális függvényeket, hogy lassúak. Az eddigiek alapján látszik, hogy egy virtuális függvényhívásnak két adattag elérésének idejével nagyobb a költsége, mint egy normál függvényhívásnak: előbb a tábla pointerét kell kiolvasni, utána pedig a táblából a függvény címét.

De ne feledjünk két dolgot. Egyik, hogy minderre csak akkor van szükség, ha az objektum típusa nem ismert. Ha egy objektumra ősosztály típusú pointerünk van, akkor természetesen végig kell csinálni a procedúrát. Ha azonban látszik az objektum definíciója is, akkor pontosan ismert a típusa; így nem csak a függvénytábla műveletei maradhatnak el, hanem inline függvénynél még akár a törzs beillesztése is megtörténhet:

void f(Shape *s) {
    s->draw();  // ismeretlen típus
}
Rectangle r( /* ... */ );

r.draw();       // ismert típus

Másik pedig, ha nem használnánk virtuális függvényeket, akkor helyettesítenünk kellene őket valamivel. Nem fair önmagukban összehasonlítani egy nem virtuális és egy virtuális függvényhívás sebességét. Sportszerűbb lenne például a virtuális draw() sebességét mondjuk ehhez a kódrészlethez mérni:

Shape *s = /* ... */;
switch (s->type) {
    case ShapeTypeRectangle:
        /* ... */
        break;
    case ShapeTypeCircle:
        /* ... */
        break;
    /* ... */
}

És akkor az így strukturált program fejlesztési költségeiről, a karbantartási nehézségekről és hibalehetőségekről még nem is beszéltünk.

4. Piszkos technikai részletek

A fenti bepillantás a virtuális függvények megvalósításába mindent elárul a működésükről, és számos dologra emlékeztet minket, mit szabad megtennünk a C++ objektumainkkal, és mit nem.

„Standard-layout” objektumok

A C++ ígérete szerint semmi olyat nem kényszerít rá a használóira, amire nincsen szükségük. Nem véletlenül kell megmondanunk minden metódusról, virtuálisként szeretnénk létrehozni azt, vagy nem. Ha nincs szükségünk a virtuális függvényre, nem kell fizetnünk az áráért futási időben.

Vegyük észre, hogy a virtuális függvényeknek nem csak a hívások pillanatában van jelentősége. Amint egy osztályba virtuális függvényt teszünk, a fordítónak a függvénytábla pointerét is be kell tennie az objektumba. Amíg semmi nem virtuális, az adattagok ofszetei megegyeznek a két nyelvben. A C++ az ilyen objektumokat „standard-layout” objektumoknak nevezi. Egy standard-layout objektum memóriaképe bitről bitre megegyezik azzal, mint amilyen a C-s párja. Ez fontos, mert emiatt a két nyelv kompatibilis, és ez lehetővé teszi azt, hogy C-ben és C++-ban írt programrészeket linkeljünk egymáshoz, és hogy azok egyszerűen, hatékonyan, konvertálás nélkül dolgozhassanak egymás adatain.

A standard-layout elrendezéshez az is kell, hogy az összes adattag egyforma elérhetőségű (publikus v. privát) legyen, mert a fordító az egyforma elérhetőségű tagokat átrendezheti, csoportosíthatja.

Virtuális tagok esetén azonban virtuális függvénytábla pointere is van az objektumnak, és ez nem csak a C miatt számít. Ez a láthatatlan adattag része az objektumnak, és beleszámít a méretébe is. Egy standard-layout objektumot könnyedén bináris fájlba írhatunk, egy virtuális függvényt tartalmazóval azonban ezt már nem tehetjük meg. Az alábbi hívás a fájlba írja a virtuális függvénytábla pointerét is:

Rectangle r( /*...*/ );
fwrite(&r, sizeof(r), 1, fp);

A fájlba írás még nem lenne nagy baj, de a visszaolvasásnál nagyon megütnénk a bokánkat, felülírnánk ezt a láthatatlan pointert is. Márpedig ennek a pointernek az értékét nem tudjuk irányítani. Gépenként, fordítónként, akár fordításonként is változhat: attól függ, épp hova került a tábla a memóriában.

Az objektumok címei: void* pointerek és többszörös leszármazás

A fenti C kódok magyarázatánál mindenhol azt mondtuk, „a Rectangle* és a Shape* pointerek egymásba konvertálhatóak, mert az ofszet mindig nulla, a konvertáláskor pedig nem történik semmi” – igen, de ez C++-ban már nem igaz. A pointerek konvertálásakor (cast-olásakor) itt az értékük is változhat. Tegyük fel, hogy van egy alaposztályunk, virtuális függvény nélkül, majd egy leszármazott, amelynek már van virtuális függvénye. Az alaposztálynak még nincs virtuális pointere, a leszármazottnak azonban már kell legyen:

struct A {
    A(int a) : a(a) {}
    int a;
};

struct B : A {
    B(int a, int b) : A(a), b(b) {}
    int b;
    virtual ~B() {}
};

int main() {
    A a(0x1122);
    B b(0x1122, 0x3344);
    std::cout << "a: "; print_obj_hex(a);
    std::cout << "b: "; print_obj_hex(b);
    std::cout << "&b:      " << &b << std::endl;
    std::cout << "(A*) &b: " << (A*) &b << std::endl;
}
a: 22 11 00 00
b: 20 14 40 00 00 00 00 00 22 11 00 00 44 33 00 00
&b:      0x7fff89ebbe98
(A*) &b: 0x7fff89ebbea0

A kiírásból látszik, hogy a b objektumba a pointer az objektum elejére épül be. Ezután jön az A-ból örökölt adattag, utána pedig a B-féle adattag. Ha az &b kifejezéssel az objektum címét kérjük, az elejének címét kapjuk. Azonban ha ezt a pointert A* típusúvá cast-oljuk, az értéke megváltozik! Ennek azért kell így lennie, mert az A* pointert tartalmazó kifejezések az A osztály ofszeteit használják; az A* pointer paramétert kapó függvények ennek felépítését ismerik. Márpedig az A-ban az első adattag ofszete nulla, mert nincs előtte virtuális pointer.

A fordító az osztályok definícióit ismerve tudja, hogy melyik irányú konvertálásnál mennyivel kell változtatni a pointer értékét. Ha a B*-ból A* lesz, akkor hozzáad 8-at. Ha A*-ból B*, kivon 8-at. Ezt mindenhol meg tudja tenni, ahol ismert a típus. Megpróbálhatjuk elkövetni azt a merényletet a fordító ellen, hogy a pointert void*-gá alakítjuk, ezzel teljesen eldobva a típusinformációt. Ha ilyet csinálunk, akkor azonban leginkább magunknak teszünk keresztbe, mert innentől a fordító nem tudja garantálni a helyes működést.

std::cout << "&b:                   " << &b << std::endl;
std::cout << "(A*) &b:              " << (A*) &b << std::endl;
std::cout << "(B*) (A*) &b:         " << (B*) (A*) &b << std::endl;
std::cout << "(B*) (void*) (A*) &b: " << (B*) (void*) (A*) &b << std::endl; // hibás!
&b:                   0x7fff5105af58  (eredeti)
(A*) &b:              0x7fff5105af60  (változott)
(B*) (A*) &b:         0x7fff5105af58  (visszakapjuk az eredetit)
(B*) (void*) (A*) &b: 0x7fff5105af60  hibás!

Ugyanez a helyzet áll elő többszörös leszármazás (multiple inheritance) esetén is, amikor egy osztálynak egynél több ősosztálya van. Ez a memóriaképeket vizsgálva nyilvánvaló is, mert ilyenkor a két helyről örökölt adattagokat a fordító kénytelen egymás mellé tenni. A pointert a két ősosztály típusa felé cast-olva eltérő értékeket kapunk:

struct A { int a; };
struct B { int b; };
struct C : A, B {};

C c1;
std::cout << "C*: " << &c1 << std::endl;
std::cout << "A*: " << (A*) &c1 << std::endl;
std::cout << "B*: " << (B*) &c1 << std::endl;
C*: 0x7fffa0cde530
A*: 0x7fffa0cde530
B*: 0x7fffa0cde534

Emiatt az is előfordulhat, hogy helytelen eredményt kapunk, ha nem az öröklődési utak mentén alakítjuk át a pointereket (pl. egy C objektumra mutató, A* típusú pointert közvetlenül B*-gá alakítunk), vagy nem használunk dynamic_cast-ot. A pointerek helytelen értéke pedig veszélyes memóriakezelési hiba, amelyre a fordító nem tud figyelmeztetni; az explicit cast-olásnál a típusok ellenőrzése ki van kapcsolva. Nem véletlen, hogy C++-ban nem javasolt a void* pointerek használata sem. Amire leggyakrabban használtuk, azt úgyis kiváltották a sablonok.

Többszörös leszármazás esetén a virtuális függvénytáblák tartalmazzák azt az információt is, hogy az objektumban melyik örökölt részobjektum (sub-object) hol kezdődik. Ezt figyeli a dynamic_cast. A történet azért nem egyszerű, mert a lehetséges konverziók miatt többszörös leszármazás esetén még olyan is lehet, hogy egy objektumnak nem egy, hanem több virtuális függvénytáblája van.

A virtuális függvénytábla mint globális változó

A virtuális függvénytáblákat a fordító teljesen automatikusan kezeli, a programozónak nincs velük dolga. Ez azt jelenti, hogy a táblákat, amelyek globális változók, a fordítónak kell létrehoznia. Minden olyan osztály programkódjának fordításakor, amely tartalmaz virtuális függvényt, generálnia kell egy globális változót is: a függvénypointereket tartalmazó struktúrát. Ezt a globális változót a programozó nem látja, a linker viszont igen.

A gond csak az, hogy a virtuális tábla szükségessége az osztályok definícióiból, azaz általában a fejlécfájlokból derül ki. Ugyanakkor egy fejlécfájlt általában több forrásfájlba is beillesztünk:

/* shape.h */
class Shape {
  public:
    virtual void draw() const; /* kell majd vtable */
    virtual ~Shape() {}
};
/* rectangle.cpp */
#include "shape.h"
/* ... */
/* triangle.cpp */
#include "shape.h"
/* ... */

A kész program pedig a forrásfájlokból előállított tárgykód (object) fájlokból lesz összerakva. Ha a fordító minden alkalommal, amikor az osztály definíciójával találkozik, létrehozza a függvénytábla globális változót, akkor az többször lesz definiálva, több fordítási egységben. Ez pedig linkelési hibához vezet, mivel mindent csak egyszer lehet definiálni. A problémát a fordítók általában úgy szokták megoldani, hogy a virtuális táblát abban a fordítási egységben helyezik el, ahol az első virtuális függvény definíciójával találkoznak. A függvény definícióját csak egyszer írhatjuk le, így virtuális táblából is csak egy lesz.

Vagy nulla, abban az esetben, ha elfelejtjük definiálni a függvényt. Mivel az osztály függvényeinek deklarációja megvan, a fordító ezért nem szól, és ez a hiba csak linkelés közben derül ki. A linker erre a helyzetre az „undefined reference to 'vtable for Xyz'” hibaüzenettel reagál:

class Shape {
  public:
    virtual void draw() const;
    virtual ~Shape() {}
};

int main() {
    Shape s;
}
$ clang++ proba.cpp
/tmp/proba-dK7nbD.o: In function `Shape::Shape()':
proba.cpp:(.text._ZN5ShapeC2Ev[_ZN5ShapeC2Ev]+0x8): undefined reference to `vtable for Shape'

A hibajelenség azért kellemetlen, mert a hibaüzenet nem utal a konkrét hibára, hogy nem definiáltuk a függvényt, arra főleg nem, hogy melyiket. Így jobb megjegyezni, hogy ez a hibaüzenet mindig egy hiányzó függvénydefinícióra utal.

5. Öröklés C-ben: a gyakorlatban

A fenti C kódok hűen leutánozzák egy C++ fordító működését, de a tényleges használathoz túlzottan kényelmetlenek. Ha valamiért C-ben kell igazi, örökléses objektumorientált programot írni, akkor más megoldás után érdemes nézni.

„Nyers” C kód

Ha úgy döntünk, mi írjuk meg a C kódot, akkor a nehézkes struktúrás bűvészkedés helyett érdemesebb union-okat használni. Igaz, így utólag nem lehet majd örökölni egy osztályból, de kisebb projekt esetén, ahol előre ismerjük az összes típust, az egyszerűség miatt ez célravezetőbb lehet.

A C11 bevezetett egy új nyelvi elemet, amely megkönnyíti itt a dolgunkat. Az új verzió megengedi azt, hogy egy struktúra belsejében létrehozzunk egy másik, névtelen struct vagy union adattagot. Ebben az esetben ennek belső adattagjai a külső struktúra adattagjaiként érhetők el:

C89
struct Number {
    enum {
        integer, real
    } type;

    union {
        int i;
        double d;
    } data; // !
};

struct Number sz;
sz.type = integer;
sz.data.i = 5; // !
C11
struct Number {
    enum {
        integer, real
    } type;

    union {
        int i;
        double d;
    }; // !
};

struct Number sz;
sz.type = integer;
sz.i = 5; // !

Így könnyen létrehozhatók egyszerűbb osztályhierarchiák. Az adott osztályokra jellemző adattagokat névtelen struktúrába kell tenni, ezeket a névtelen struktúrákat pedig egy névtelen union-ba:

#include <stdio.h>
#include <stdlib.h>

typedef struct Shape Shape;
struct Shape {
    int x, y;                    // alap adattagok

    union {
        struct { /* Rectangle */ // leszármazottak adattagjai
            int width, height;
        };
        struct { /* Circle */
            int radius;
        };
    };

    struct { /* Virtuals */
        void (*draw) (Shape const *);
    };
};

void rectangle_draw(Shape const *self) {
    printf("x:%d y:%d w:%d h:%d\n", self->x, self->y, self->width, self->height);
}

void circle_draw(Shape const *self) {
    printf("x:%d y:%d r:%d\n", self->x, self->y, self->radius);
}

void shape_draw(Shape const *self) { // kényelmi fv, sima hívásból virtuálisat csinál
    self->draw(self);
}

Shape * rectangle_new(int x, int y, int width, int height) {
    Shape *newshape = (Shape *) malloc(sizeof(Shape));
    *newshape = (Shape) {
        .x = x,
        .y = y,
        .width = width,
        .height = height,
        .draw = &rectangle_draw
    };
    return newshape;
}

int main(void) {
    Shape *s = rectangle_new(5, 10, 15, 20);
    shape_draw(s);
}

Így egyetlen típus használható az összes altípus (leszármazott) leírására. Ez valamennyire pazarolja a memóriát (a Shape struktúra mindig akkora lesz, amekkora a legnagyobb típus), ugyanakkor így spórolhatunk az indirekciókon is. A Shape objektumok egyforma méretűek, ezért építhetnénk belőlük tömböt, vagy értékként átadhatnánk őket függvénynek.

Előfordítók, makrócsomagok

Az első C++ fordítók, a még Stroustrup által írt C with Classes és Cfront programok a kapott C++ kódot C kóddá fordították át, amit aztán egy szokványos C fordító is kezelni tudott. Ehhez hasonló programokat manapság is használnak.

Az egyik ilyen a ClassC. Ez egy C előfordító, amelyik nem valósít meg igazi öröklést, hanem az öröklés helyett csak „vegyíti” az adattagokat. Ez inkább csak arra jó, hogy strukturáltabbá tegyük a programunkat, objektumokba rejtsük a túlzottan sok globális változót. (Vegyük észre a párhuzamot: a tagfüggvényeken belül tagváltozókkal dolgozni olyasmi szemantikailag, mint globális függvényekkel a globális változókon. Csak éppen sok, különálló példány lehet belőlük!) Egy ClassC kód és a lefordított változatának részlete:

ClassC
Counter {
    int counter;

    void increment() {
        counter++;
    }
}
struct Counter
{
    struct Counter_Class *_class;
    void **_components;
    int counter;
};

struct Counter_Class
{
    char *className;
    void *classId;
    void (*increment) (struct Counter *);

};

void Counter_increment(struct Counter *self)
{
    self->counter++;
}

A Counter_Class osztály a virtuális függvénytáblához hasonló szerepű; a függvényhívásoknál és a dinamikus cast-oknál használja a rendszer.

Az ooc (Object Oriented C) hasonló, de nem előfordítással működik, hanem makrók segítségével. Az előfordított forráskód az előzőekhez hasonlóan néz ki.

ooc
DeclareClass(Counter, Base);

ClassMembers(Counter, Base)
    int counter;
EndOfClassMembers;

void Counter_increment(Counter self) {
    assert(ooc_isInstanceOf(self, Counter));
    self->counter++;
}

A GObject keretrendszer

A GObject nevű keretrendszer az összes C alapú megoldás közül a legfejlettebb és legelterjedtebb. Számos linuxos program és keretrendszer erre épül. Az osztályok létrehozása mellett a GObject rendszer egyéb szolgáltatásokat is nyújt, mint pl. az automatikus memóriakezelés referenciaszámlált objektumokon keresztül, vagy a sztringgel megnevezett adattagok elérése.

Miért C, miért nem C++? Mert még mindig ez illeszthető a legtöbb másik programozási nyelvhez. Például a GObject alapon írt GTK+ grafikus keretrendszer C++, C#, Java, Python, JavaScript, Vala, Perl, Ruby, Pascal, PHP, R, Lua, Guile, Ada, OCaml, Haskell, FreeBASIC, D, Go és Fortran nyelveken használható.

Egy osztály létrehozásához itt két struktúrát kell definiálnunk. Az „instance struct” példányai lesznek a konkrét objektumok; a „class struct” pedig az osztályt és a virtuális függvényeket írja majd le. Néhány makrót is definiálni kell, amelyek segítségével az objektum típusa lekérdezhető, és a pointer cast-ok ellenőrzötten elvégezhetőek. A fejlécfájlban:

GObject
#include <glib-object.h>

typedef struct MyCounter MyCounter;
typedef struct MyCounterClass MyCounterClass;

struct MyCounter {
    GObject parent_instance;   // ősosztály (a GObject minden osztály őse)
    
    /* instance members */
    int count;
};

struct MyCounterClass {
    GObjectClass parent_class; // ősosztály virtuális táblája
    
    /* class members, virtual function pointers */
};

#define MY_TYPE_COUNTER             (my_counter_get_type())
#define MY_COUNTER(obj)             (G_TYPE_CHECK_INSTANCE_CAST((obj), MY_TYPE_COUNTER, MyCounter))
#define MY_IS_COUNTER(obj)          (G_TYPE_CHECK_INSTANCE_TYPE((obj), MY_TYPE_COUNTER))
#define MY_COUNTER_CLASS(klass)     (G_TYPE_CHECK_CLASS_CAST((klass), MY_TYPE_COUNTER, MyCounterClass))
#define MY_IS_COUNTER_CLASS(klass)  (G_TYPE_CHECK_CLASS_TYPE((klass), MY_TYPE_COUNTER))
#define MY_COUNTER_GET_CLASS(obj)   (G_TYPE_INSTANCE_GET_CLASS((obj), MY_TYPE_COUNTER, MyCounterClass))

Az osztályhoz tartozó forrásfájlban pedig meg kell adni néhány függvényt. A G_DEFINE_TYPE makró létrehozza a virtuális függvénytábla objektumot, és definiál néhány függvényt, amikre a GObject-nek van szüksége. A my_object_class_init() függvényt a rendszer egyszer fogja meghívni, az első objektum létrehozásakor; itt állíthatjuk be a virtuális függvények pointereit. A my_object_init() pedig tulajdonképpen a konstruktor. A nevek többé-kevésbé kötöttek: a G_DEFINE_TYPE makró paraméterével épp azt adjuk meg, hogy mi lesz a függvénynevek előtagja.

G_DEFINE_TYPE(MyCounter, my_counter, G_TYPE_OBJECT)

static void my_counter_class_init(MyCounterClass *klass) {
    /* set virtual function pointers etc. */
}

static void my_counter_init(MyCounter *obj) {
    /* constructor */
    obj->count = 0;
}

Az utolsó dolgunk a „publikus konstruktor” létrehozása: ez egy olyan függvény, amely az osztályunk egy dinamikusan foglalt adattagjával tér vissza. A publikus tagfüggvények is emellé mehetnek; ha vannak virtuális függvényeink, azokhoz érdemes egysoros csomagoló függvényt írni, amely az osztályt leíró objektumból kiveszi a megfelelő függvénypointert.

GObject *my_counter_new(void) {
    return g_object_new(MY_TYPE_COUNTER, NULL);
}

void my_counter_increment(MyCounter *obj) {
    obj->count ++;
}

A használat ezután egyszerű. A fenti függvénnyel lehet egy új objektumot létrehozni; a referenciaszámlálóját csökkentve pedig törölni lehet azt. A makrók helyettesítik a kevésbé olvasható pointer cast szintaxist, és futási időben ellenőrzik, hogy helyes-e az objektum típusa.

GObject *myobj = my_counter_new();

my_counter_increment(MY_COUNTER(myobj));
printf("%d\n", MY_COUNTER(myobj)->count);
 
g_object_unref(myobj);

6. Irodalom

  1. What's the difference between how virtual and non-virtual member functions are called? (C++ FAQ).
  2. What does it mean that the "virtual table" is an unresolved external? (C++ FAQ).
  3. C Object Oriented Programming « null program (nagyon jó blog).
  4. Cfront (Wikipedia).
  5. Fundamental theorem of software engineering (Wikipedia).
  6. Bjarne Stroustrup: The Design and Evolution of C++. Addison-Wesley, 1994.
  7. Class-C (keretrendszer).
  8. Object Oriented C (keretrendszer).
  9. GObject Reference Manual (keretrendszer).
  10. ooc-lang, C→C fordító.