C++: mity i fakty

| Technika

W latach 90. rozpowszechniła się w kręgach zainteresowanych tematyką opinia, że język C++ nie nadaje się do tego, żeby pisać w nim programy dla mikrokontrolerów jednoukładowych. W przypadku małych systemów 8- i 16-bitowych, dominujących w tamtym okresie, jest to zapewne prawda. Ale dziś, w dobie tanich mikroprocesorów 32-bitowych o wielkiej (w porównaniu do tych z poprzednich dekad) mocy przetwarzania i znacznych zasobach, można sobie zadać pytanie, na ile ta niezbyt pochlebna dla C++ opinia ma nadal pokrycie w faktach, a na ile jest utrzymującym się siłą inercji mitem.

C++: mity i fakty

Inline

Modyfikator inline to konstrukcja C++, którą wprowadzono później także w języku C, zatem programiści piszący w C powinni być zaznajomieni z tym słowem kluczowym i jego działaniem. Niewątpliwym błędem, który często popełniają początkujący programiści pracujący w obu językach, jest deklarowanie wszystkich funkcji (czy metod) jak leci jako inline: prowadzi to do generowania przez kompilator bardzo przerośniętych plików wynikowych.

Dlatego należy z tej możliwości korzystać z głową, zwłaszcza że kompilator w trakcie optymalizacji programu niejednokrotnie sam podejmuje decyzję, czy daną funkcję (mimo że nie jest zadeklarowana jako inline) opłaca się "wkleić" w miejsce jej wywołania. Z tego powodu nieduże funkcje (zadeklarowane w C jako static) działają w praktyce jak makra, a modyfikator inline powinien zasadniczo służyć tylko do tego, żeby owo "wklejenie" na kompilatorze wymusić, jeśli z jakiegoś powodu nie chce on tego zrobić sam z siebie.

Czy jest to zabieg opłacalny, musi już w takim wypadku ocenić sam programista: z jednej strony oszczędza się czas zużywany normalnie na wywołanie funkcji, z drugiej, jeśli taka funkcja wywoływana jest więcej niż raz czy dwa - prowadzić to może do zauważalnego przyrostu rozmiarów kodu wynikowego; chociaż niejednokrotnie taka "wklejona" w kod funkcja może się po nim w pewnym, czasem znacznym stopniu "rozejść" w wyniku optymalizacji. Dlatego eksperymentując w tym kierunku, dobrze jest obejrzeć kompilat w postaci pliku wynikowego w asemblerze i na tej dopiero podstawie zdecydować, czy zastosowanie inline w danym miejscu się opłaca.

Wszystkie powyżej naszkicowane kwestie sprowadzają się wyłącznie do konstrukcji składniowych, których użycie ułatwia programowanie, ale nie przekłada się w żaden sposób na dodatkowe zużycie zasobów podczas wykonywania programu.

Konstruktory i destruktory

W C++ konstruktorem nazywamy metodę, która zostanie na pewno wywołana, kiedy dany obiekt będzie tworzony. Oznacza to zwykle, że kompilator wstawia wywołanie konstruktora w to miejsce programu, w którym obiekt jest deklarowany. Podobnie destruktor to metoda wywoływana w momencie, kiedy obiekt jest usuwany. A zatem, konstruktor zawiera kod dokonujący niezbędnego inicjowania, a destruktor - niezbędnego "sprzątania", kiedy obiekt przestaje być potrzebny.

Fakt, że kompilator wstawia do kodu wywołania konstruktorów i destruktorów bez wiedzy programisty, wywołuje zwykle niepokój u programistów pracujących w C. Rzeczywiście: programiści C++ głównie ze względu na to starają się unikać zbyt częstego wykorzystywania tak zwanych obiektów tymczasowych.

Ale z drugiej strony ten mechanizm zdejmuje z głowy programiście troskę o każdorazowe wywołanie inicjowania i deinicjowania struktur danych, a to już może być warte grzechu. W języku C, gdzie nie ma takiego mechanizmu, nierzadko mamy do czynienia z zapomnieniem o inicjowaniu struktur w pamięci oraz wyciekami zasobów (w efekcie braku deinicjowania).

New i delete

W C++ wywołania new i delete służą mniej więcej do tego samego, co odpowiednie funkcje biblioteczne malloc() i free() w języku C, z tym tylko, że zawierają też niejawne wywołania odpowiednich konstruktorów i destruktorów. Jak powyżej, zapobiega to powstawaniu błędów wynikłych z wadliwego inicjowania lub deinicjowania obiektów.

Dziedziczenie

Kwestię dziedziczenia w C++ sprowadzimy do prostego przypadku dziedziczenia pojedynczego, niewirtualnego. Wielodziedziczenie i dziedziczenie wirtualne to dość skomplikowane zagadnienia, a i ich użycie jest stosunkowo rzadkie, odłożymy je zatem na później.

Weźmy przykład, w którym klasa B dziedziczy po klasie A (można też powiedzieć, że klasa B jest pochodną klasy A albo że klasa A jest klasą bazową dla klasy B). Z tego, co powiedziano wyżej, wiemy, jaka jest struktura wewnętrzna klasy A. Ale jaka będzie struktura wewnętrzna klasy B? Na lekcjach programowania obiektowego uczą nas, że można użyć dziedziczenia wtedy, kiedy klasa B jest jakimś wyspecjalizowanym rodzajem klasy A; a zatem, jeśli utworzymy klasę Kwadrat pochodną od Figura, zapewne wszystko będzie w porządku - ale jeśli zechcemy utworzyć klasę Figura jako pochodną od klasy Kolor, przypuszczalnie coś tu nie będzie grało.

Czego nas zwykle nie uczą, to tego, że współzależność pomiędzy klasą bazową a pochodną opiera się na zasadach nie tylko ideowych, ale także fizycznych. W C++ obiekt zdefiniowany przez pochodną klasę B jest tworzony jako obiekt zdefiniowany przez klasę bazową A z elementami definiowanymi przez klasę B przyczepionymi na końcu.

Skutek jest taki, jakby obiekt B zawierał w sobie obiekt A jako jeden ze swoich elementów. W efekcie wskaźnik do obiektu B jest również wskaźnikiem do obiektu A; metody klasy A wywołane dla obiektu klasy B będą działać prawidłowo; a kiedy obiekt B jest powoływany do istnienia, konstruktor klasy A jest wywoływany przed konstruktorem klasy B - i z destruktorami jest podobnie, z tym że wywoływane są w odwrotnej kolejności (najpierw B).

Poniżej mamy przykład dziedziczenia - klasa B pochodzi od klasy A i dodaje od siebie metodę B::g() i zmienną B::drugazmienna:

class A
{
public:
A();
int f();
private:
int zmienna;
};
A::A()
{
zmienna = 1;
}
int A::f()
{
return zmienna;
}
class B: public A
{
private:
int drugazmienna;
public:
B();
int g();
};
B::B()
{
drugazmienna = 2;
}
int B::f()
{
return drugazmienna;
}
int main
{
B b;
b.f(); // zwraca 1
b.g(); // zwraca 2
return 0;
}

Poniższy kod pokazuje, jak taki efekt można zrealizować w języku C. Struktura B zawiera w sobie strukturę A jako swój pierwszy element i dodaje do niej zmienną drugazmienna. Funkcja konstruktor_B() wywołuje najpierw funkcję konstruktor_A() w celu zainicjowania swojej "klasy bazowej". Tam, gdzie na listingu powyżej funkcja main() wywołuje metodę b.f(), w języku C mamy do czynienia z wywołaniem funkcji f_A() ze wskaźnikiem do struktury B przekazanym jako parametr.

struct A
{
int zmienna;
};
void konstruktor _ A(struct A *this)
{
this->zmienna = 1;
}
int f _ A(struct A*this)
{
return this->zmienna;
}
struct B
{
struct A a;
int drugazmienna;
};
void konstruktor _ B(struct B *this)
{
konstruktor _ A(&this->a);
this->drugazmienna = 2;
}
int g _ B(struct B *this)
{
return this->drugazmienna;
}
int main()
{
struct B b;
konstruktor _ B(&b);
f_ A((struct A*)&b); /*
zwraca 1 */
g_ B(&b); /*
zwraca 2 */
return 0;
}

Czy to nie zadziwiające, jak prosty w istocie mechanizm odpowiada rzeczy pozornie tak abstrakcyjnej jak dziedziczenie? Co za tym idzie, dobrze przemyślane dziedziczenie klas nie będzie miało negatywnych skutków dla rozmiarów lub czasu wykonywania programu - na pewno nie bardziej niż odpowiednia konstrukcja języka C, jak to pokazano powyżej.

Niemniej trzeba zaznaczyć, że można się tu dopuścić nadużyć, w wyniku czego obiekty staną się "cięższe", niż potrzeba, głównie przez to, że wskutek nie do końca przemyślanej struktury klas, klasy bazowe i pochodne mogą niepotrzebnie zawierać wiele kopii tych samych informacji.

Metody wirtualne

Metody wirtualne pozwalają zastąpić funkcję zdefiniowaną w klasie bazowej A przez funkcję zdefiniowaną w klasie pochodnej B i sprawić, że ta nowo zdefiniowana funkcja będzie wywoływana przez kod "znający" tylko definicję klasy A. Metody wirtualne zapewniają polimorfizm, który jest jedną z kluczowych cech języka zorientowanego obiektowo.

Klasę, która definiuje przynajmniej jedną metodę wirtualną, nazywamy "klasą polimorficzną". Rozróżnienie pomiędzy klasą polimorficzną a niepolimorficzną ma pewne znaczenie, gdyż z obydwoma rodzajami klas związane są różnego typu kompromisy pomiędzy funkcjonalnością a narzutem dodatkowo wygenerowanego przez kompilator kodu wynikowego. I podczas gdy zwykłe metody, jak pokazano powyżej, nie powodują dodatkowego obciążenia gotowego programu, to w przypadku metod wirtualnych nie jest to już prawdą. Przyjrzyjmy się zatem, jak to działa, i w związku z tym, jakiego narzutu można się spodziewać.

Metody wirtualne implementuje się przy użyciu tablicy wskaźników funkcji (zwanej vtbl), oddzielnej dla każdej klasy definiującej takie metody. Każdy obiekt takiej klasy zawiera wskaźnik (zwany vptr) do tablicy vtbl odpowiadającej tej klasie. Ten wskaźnik jest tam wstawiany przez kompilator, a wygenerowany kod go używa, ale programista nie ma do niego dostępu: nie można się, innymi słowy, wskaźnikiem vptr jawnie posłużyć ani odwołać do niego w kodzie źródłowym. Ale obejrzenie obiektu pod debuggerem ujawni jego istnienie.

Wywołanie metody wirtualnej dla danego obiektu powoduje, że kod, za pośrednictwem wskaźnika vptr, dostaje się do odpowiedniej tablicy vtbl, pobiera stamtąd wskaźnik (adres) funkcji, a następnie wywołuje ją. Przykład poniżej ilustruje użycie tego mechanizmu - klasa A definiuje metodę wirtualną f(), która zostaje zastąpiona przez metodę klasy B:

class A
{
private:
int value;
public:
A();
virtual int f();
};
A::A()
{
value = 0;
}
int A::f()
{
return 0;
}
class B: public A
{
public:
B();
virtual int f();
};
B::B()
{
}
int B::f()
{
return 1;
}
int main()
{
B b;
A *aPtr = &b;
aPtr->f();
return 0;
}

Ekwiwalent w języku C jest nie tylko mniej przejrzysty, ale może też skłonić programistę do pójścia na łatwiznę i użycia rzutowania typu wskaźnika, jak w przedostatniej linijce funkcji main(), a to nie należy ani do zalecanych, ani do szczególnie bezpiecznych metod programowania:

struct A
{
void **vTable;
int value;
};
int f _ A(struct A *this);
void *vTable _ A[] =
{
(void *)&f _ A
};
void konstruktor _ A(struct A *this)
{
this->vTable = vTable _ A;
this->value = 1;
}
int f _ A(struct A *this)
{
return 0;
}
struct B
{
struct A a;
};
int f _ B(struct B *this);
void *vTable _ B[] =
{
(void *)&f _ B
};
void konstruktor _ B(struct B *this)
{
konstruktor _ A((struct A *)this);
this->a.vTable = vTable _ B;
}
int f _ B(struct B *this)
{
return 1;
}
int main()
{
struct B b;
struct A *aPtr;
konstruktor _ B(&b);
typedef void (*f _ A _ Type)(struct A *);
aPtr = (struct A *)&b;
((f _ A _ Type)aPtr->vTable[0])(aPtr);
return 0;
}

Wspomnianego rzutowania można oczywiście uniknąć przez staranniejszą deklarację typów, co nie zmienia faktu, że język C wymaga tu od programisty włożenia dużo większego wysiłku w zaprojektowanie całej konstrukcji, a co za tym idzie, stwarza okazję do powstawania nieprzejrzystego kodu i subtelnych, trudnych do wykrycia błędów.

Tak czy owak, jest to pierwsza z tu omawianych konstrukcji C++, która pociąga za sobą wyraźne skutki dla zasobożerności programu. Spróbujmy je ocenić.

Pierwszym bezpośrednim skutkiem jest fakt, że obiekty stają się większe: każdy obiekt zdefiniowany przez klasę, która ma metodę wirtualną, zawiera wskaźnik vptr do tablicy vtbl. A zatem każdy obiekt jest większy o rozmiar tego właśnie wskaźnika. Jeśli tworzymy klasę pochodną, każdy obiekt już ma ten wskaźnik, nie ponosimy tu zatem dodatkowych kosztów.

Ale samo dodanie metody wirtualnej do klasy bazowej może mieć nieproporcjonalnie duży wpływ na wielkość małych obiektów: obiekt może się składać z jednego bajtu, a kiedy kompilator wymusza wyrównywanie danych do granicy czterech bajtów, dodanie czterobajtowego wskaźnika skutkuje rozrostem obiektu do ośmiu bajtów. Ale z drugiej strony, jeśli obiekt zawiera kilka lub kilkanaście zmiennych, dodanie jednego wskaźnika nie robi wyraźnej różnicy.

Drugi koszt to koszt dostępu do tablicy vtbl, który trzeba ponieść, żeby wywołać metodę wirtualną. To są dokładnie dwa dodatkowe dostępy do pamięci: jeden odczyt celem pobrania wskaźnika vptr do tablicy vtbl i drugi odczyt celem pobrania docelowego wskaźnika z tej tablicy. Czy to dużo, czy mało, zależy już od kompilatora, a jeszcze bardziej od sprzętu, na którym program ma działać: w każdym razie opłacalność jest tu przedmiotem gorących niekiedy sporów.

Można spotkać się z opinią, że koszty są tu porównywalne z kosztem dodania parametru do funkcji w C - natomiast, jako że ten koszt oceniany jest zwykle jako nieznaczny, można byłoby z tego wyciągnąć logiczny wniosek, że koszt wywołania metody wirtualnej w C++ jest również nieznaczny.

Ważniejszą sprawą jest wpływ funkcji wirtualnych na rozmiar gotowego programu. Jak już wspomniano wyżej, linker podczas konsolidacji jest w stanie stwierdzić, które "zwykłe" funkcje, mimo że zdefiniowane, nie są używane, i usunąć je ze skompilowanych modułów. Ale w przypadku metod wirtualnych linker musi przypisać je do odpowiednich tablic vtbl, co oznacza, że wszystkie metody wirtualne, niezależnie od tego, czy rzeczywiście są używane, czy nie, zostaną dolinkowane do gotowego programu (bo np. wstawienie gdzieś wskaźnika do funkcji to już jest, z punktu widzenia linkera, jakieś jej "użycie").

Podsumowując: metody wirtualne mają niewielki wpływ na szybkość programu, ale mogą mieć poważny wpływ na jego rozmiary. W C++ nie ma obowiązku ich używania, a zatem, jeśli programista przekona się, że straty przeważają tu nad zyskami, może po prostu z nich zrezygnować.