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

Klasy

Jednymi z najważniejszych nowych konstrukcji w C++ są klasy, metody i obiekty. Niestety, przy nauczaniu C++ wprowadza się te pojęcia bez objaśnienia, w jaki sposób je zaimplementowano, a to z kolei powoduje początkową dezorientację u programistów znających C. Potem następują męki oswajania się z programowaniem obiektowym, w wyniku czego kwestia implementacji znika wszystkim z oczu na dobre.

A klasa w C++ to jest w zasadzie to samo co definicja struktury w C. I rzeczywiście, w C++ strukturę zdefiniowano jako klasę, w której wszystkie elementy składowe są domyślnie publiczne. "Metoda" natomiast to po prostu funkcja, która jako parametr przyjmuje niejawnie wskaźnik do obiektu zdefiniowanego przez klasę, do której sama należy.

A zatem, rozumując w kategoriach generatora kodu, klasa z metodą w C++ jest równoważna w języku C strukturze z funkcją, która przyjmuje adres danych tej struktury w pamięci (czyli wskaźnik) jako parametr. Przykład poniżej demonstruje prostą klasę A z jedną zmienną x i jedną metodą f():

// prosta klasa
class A
{
private:
int x;
public:
void f();
};
void A::f()
{
x = 0;
}

Części klasy można zadeklarować jako prywatne (private), chronione (protected) lub publiczne (public). Fizycznie żadne różnice pomiędzy elementami prywatnymi, chronionymi i publicznymi nie zachodzą: odpowiednie słowa kluczowe pozwalają po prostu zapobiec nadużyciom zdefiniowanych struktur danych przez wykrywanie prób niedozwolonego dostępu już na etapie kompilacji.

Poniżej mamy równoważny kod zaimplementowany w języku C: struktura A zawiera taką samą zmienną x jak klasa A w powyższym listingu dla C++, a metodę A::f() zastąpiono przez funkcję f_A(struct A*). Proszę zauważyć, że nazwa parametru tej funkcji to this - w C++ byłoby to słowem kluczowym, w C nim nie jest. Taką nazwę wybrano świadomie, żeby podkreślić fakt, że w C++ wskaźnik obiektu, nazwany this, zostaje niejawnie przekazany funkcji jako parametr.

struct A
{
int x;
};
void f _ A(struct A *this)
{
this->x = 0;
}

Oczywiście w języku C nie ma słów kluczowych private i public, nie ma więc przeszkód, żeby programista odwołał się bezpośrednio do dowolnego elementu struktury z pominięciem wymyślonego do tego celu interfejsu (czyli zdefiniowanej jak powyżej "metody"), a gdy to zrobi, kompilator nie będzie w stanie wykryć takiego nadużycia. Język C wymaga więc od programistów samodzielnego utrzymywania dyscypliny, którą w C++ kompilator po prostu na nich wymusza.

Obiekty

Obiekt w C++ jest to zwykła zmienna, której typ definiuje klasa. Jak już powiedziano wyżej, odpowiednikiem klasy w C++ jest w języku C definicja struktury, a obiektu - zdefiniowana w ten sposób struktura danych w pamięci. Ponadto na klasę składa się pewna liczba metod (czyli funkcji) mających dostęp do elementów tejże klasy. Jest rzeczą jasną, że jest to mechanizm bardzo użyteczny i dający wiele możliwości. Jest równie jasne, że jego stosowanie nie ma w ogóle ujemnego wpływu na efektywność programu, gdy porównać to z zastosowaniem struktur i funkcji w tradycyjnym C.

Kiedy kompiluje się obiektową aplikację w C++, na dane składają się przeważnie obiekty, a na kod - metody zdefiniowane przez klasy. Można się wobec tego zastanawiać, jaki wpływ na wielkość gotowego programu ma zastosowanie klas, które przeważnie zawierają większą liczbę metod niż programista przyzwyczajony do języka C spodziewałby się kiedykolwiek użyć.

Jest tak dlatego, że dobrze zaprojektowana klasa zawiera metody, którymi można się posłużyć do zrobienia czegokolwiek z obiektami zdefiniowanymi przez tę klasę. Liczba tych metod może być skądinąd całkiem rozsądna, ale i tak przeważnie zaskakuje programistów piszących dotąd w C.

Nie oznacza to jednak, że wszystkie metody zdefiniowane w danej klasie trafią do gotowego programu: współczesne linkery są w stanie dokonać konsolidacji tylko tych metod (funkcji), z których program faktycznie korzysta. Linker po prostu traktuje skompilowany moduł jak rodzaj biblioteki. To oznacza, że nieużywane metody są po prostu pomijane w procesie konsolidacji, a klasa, która na oko jest bardzo rozbudowana, może w rzeczywistości zajmować bardzo mało miejsca w pamięci. Niestety, nie dotyczy to metod wirtualnych, o czym jeszcze będzie mowa poniżej.

Ocena wielkości obiektów jest dużo łatwiejsza: jest to cała zawartość klasy, odjąć metody. Żeby dowiedzieć się, ile miejsca w pamięci zajmie obiekt, wystarczy potraktować klasę jako strukturę samych danych, plus ewentualnie dodać wskaźnik, jeśli klasa definiuje jakieś metody wirtualne. Obliczenia te można sprawdzić przy użyciu operatora sizeof(), a stanie się od razu jasne, że wielkość danych definiowanych przez klasę nie jest większa niż to, co definiuje struktura w C. Jest tak dlatego, że optymalne wymodelowanie danego procesu wymaga zawsze takiej samej liczby stanów niezależnie od tego, czy związane z tym zmienne pamięciowe są zorganizowane w obiekt, czy nie.

Przeciążanie operatorów

Kiedy kompilator C++ napotyka w kodzie źródłowym przeciążony operator, podstawia zamiast tego wywołanie odpowiedniej funkcji. A zatem wyrażenie x+y powoduje wywołanie funkcji operator+(x, y) względnie x.operator+(y), jeśli którąś z nich zadeklarowano. Jest to znowu kwestia czysto składniowa, po prostu jeden ze sposobów, w jaki programista może wywołać zdefiniowaną przez siebie metodę.

Dla równowagi można zauważyć, że z przeciążaniem operatorów nie należy przesadzać, gdyż łatwo da się doprowadzić do sytuacji, kiedy w danym miejscu kodu źródłowego znaczenie (i działanie) operatora zaczyna całkowicie zależeć od operandów (czy też ogólniej: kontekstu) - a to może znacznie utrudniać późniejszą analizę programu.