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

Jest faktem, że C++ jest - z jednej strony - jednym z popularniejszych języków programowania, również dla mikrokontrolerów, ale z drugiej programiści wciąż odczuwają opory przed tym, żeby oprogramowanie dla niedużego układu mikroprocesorowego pisać w tym języku (można tu zauważyć, że według niektórych badań opory te nawet narastają, a udział C++ w programowaniu dla mikrokontrolerów jednoukładowych spada).

Dzieje się tak dlatego, że pewne konstrukcje charakterystyczne dla C++ (np. szablony) mają opinię bardzo "obciążających" gotową aplikację, tak w kategoriach czasu wykonywania, jak i rozmiaru kodu wynikowego. Z tego względu nawet ci programiści, którzy przygotowując programy dla jednoukładowców, pracują w C++, używają przeważnie tylko mniej lub bardziej "obciętych" podzbiorów tego języka, tak żeby wykorzystując do maksimum jego możliwości, ograniczyć jednocześnie do minimum możliwe straty związane ze zwiększonym narzutem, jaki generuje on w programach.

Ale ten narzut może okazać się mniejszym problemem, niż się oczekuje, nie tylko ze względu na postęp w dziedzinie mikrokontrolerów, bo te oczywiście są coraz lepsze, ale też dlatego, że coraz lepsze są kompilatory języka C++. Ogólniej, podobnie zresztą, jak to ma miejsce w przypadku języka C, do efektywnego wykorzystania C++ trzeba wiedzieć, jak określone konstrukcje tego języka przekładane są przez kompilator na sekwencje rozkazów w asemblerze.

Programista uzbrojony w taką wiedzę będzie w stanie na własną rękę ocenić każdorazowo wydajność i rozmiary generowanego kodu, a co za tym idzie - pisać programy, które będą mieć szanse być mniejsze, szybsze, a na pewno stabilniejsze od takich, które powstają bez zastosowania C++.

Niniejszy artykuł ma stanowić wprowadzenie do tej tematyki, jego głównym celem jest pokazanie, jak konkretne konstrukcje C++ zaimplementowano w praktyce. Te zagadnienia zostaną zilustrowane przykładami kodu w C++ i równoważnego (lub niemal równoważnego) kodu w C. Zastanowimy się także, w jakie pułapki można wpaść, programując mikrokomputer jednoukładowy w C++ oraz jak ich można uniknąć.

Mity na temat C++

Najczęściej powtarzane opinie na temat C++ można spróbować streścić następująco:

  • C++ jest wolny,
  • C++ generuje przesadnie rozdęty kod wynikowy,
  • C++ tworzy olbrzymie, "ciężkie" binaria,
  • metody wirtualne działają wolno,
  • programów napisanych w C++ nie da się umieścić w pamięci ROM,
  • wielkie biblioteki zajmują mnóstwo pamięci,
  • duży stopień abstrakcji powoduje niewielką wydajność programów.

Kiedy zbada się szczegóły generowania kodu przez C++, większość z tego typu twierdzeń okazuje się nieprawdą, albo przynajmniej dużą przesadą.

C++ to jest w zasadzie C

Ale przede wszystkim trzeba wspomnieć o jednej właściwości C++, która jest tak oczywista, że aż nazbyt często przeoczana: tej mianowicie, że C++ jest to zasadniczo rozbudowana wersja języka C. Nie dokładnie, ale prawie: przeważająca większość konstrukcji języka C jest poprawna i daje takie same efekty w C++ (zachodzą nieliczne wyjątki, ale nie ma tu miejsca na ich omawianie).

Dlatego jeśli napiszemy kawałek kodu (albo nawet cały program), trzymając się wymogów "zwykłego" C, zwłaszcza tych zaliczanych do reguł eleganckiego stylu programowania, kompilator C++ też przeważnie zachowa się jak kompilator C, a kod wynikowy otrzymamy taki sam, jaki otrzymalibyśmy od takowego.

Ten prosty fakt sprawia, że wszystko, co można zrobić w C, można zrobić również w C++. Adaptacja istniejącego kodu źródłowego napisanego w języku C tak, żeby można go było skompilować jako program w C++, zwykle wprawdzie wymaga od programisty pewnego nakładu pracy, ale jest on niezbyt duży - w gruncie rzeczy podobną pracę trzeba włożyć w przystosowanie tego samego kodu do nowego kompilatora C.

Oznacza to również, że migracji z C do C++ można dokonywać stopniowo, traktując C++ jako stare C, ale z nowymi rozszerzeniami, które programista poznaje w swoim tempie, stosownym do własnego temperamentu i bieżących potrzeb. Mimo że nie jest to akurat najlepszy sposób przestawienia się na programowanie obiektowe, w ten jednak sposób zmniejsza się ryzyko nagłego zatrzymania prac rozwojowych z tak błahego powodu, jak ten, że programiści potrzebują czasu, żeby oswoić się z nowym językiem: zmiany w kierunku C++ mogą być wprowadzane stopniowo do ciągle działającego systemu.

Sama składnia nic nie kosztuje

Wiele nowych cech C++ sprowadza się do zagadnień czysto składniowych: nałożenie tu pewnych restrykcji i wprowadzenie nowych konstrukcji ułatwia - lub wręcz umożliwia - kompilatorowi przeniknięcie intencji programisty (i tym samym wyłapywanie błędów na etapie kompilacji) albo po prostu ma na celu ułatwienie programowania, nie obciążając jednocześnie w żaden sposób kodu wynikowego.

Przykładem tego typu konstrukcji są domyślne parametry funkcji: kompilator wstawia je w miejscu wywołania funkcji tam, gdzie kod źródłowy nie podaje żadnych.

Przeciążanie funkcji

Mniej oczywisty przykład to przeciążanie funkcji. Przeciążanie funkcji jest możliwe dzięki stosunkowo prostemu mechanizmowi przydzielania wywołaniom funkcji różnych etykiet w zależności od typów przekazywanych parametrów. Pozwala to zapobiegać wywołaniu danej funkcji z nieodpowiednimi parametrami, a także nadawać wielu funkcjom tę samą nazwę, co jest użyteczne, jeśli wszystkie robią podobne rzeczy, ale przyjmują parametry różnego typu - przykładem takiej funkcji może być np. print().

Przykładowe zastosowanie widać na listingu poniżej:

// Przykład przeciążania nazwy funkcji w C++ void jakas _ funkcja(int i)
{
// ...
}
void jakas _ funkcja(char const* s)
{
// ...
}
int main()
{
jakas _ funkcja(1);
jakas _ funkcja("Hello world");
return 0;
}

Kompilator dokona rozróżnienia obu funkcji (i ich wywołań) po przekazywanych parametrach i automatycznie nada im różne etykiety (nazywa się to w żargonie "dekorowaniem nazw", po ang. name mangling lub name decoration). W języku C programista musi to zrobić ręcznie:

void jakas _ funkcja _ numeryczna(int i)
{
/* ... */
}
void jakas _ funkcja _ tekstowa(const char *s)
{
/* ... */
}
int main()
{
jakas _ funkcja _ numeryczna(1);
jakas _ funkcja _ tekstowa("Hello world");
return 0;
}

Kod wynikowy będzie w obydwu przypadkach taki sam.

Przestrzenie nazw

Przestrzenie nazw w C++ to mechanizm pozwalający użyć tych samych nazw w różnych kontekstach. Kompilator po prostu dodaje nazwę przestrzeni nazw do deklaracji i odwołań podczas kompilacji, a to oznacza, że nazwy struktur czy funkcji nie muszą być unikalne w obrębie całej aplikacji - wystarczy, że będą unikalne w obrębie własnej przestrzeni nazw:

// Przykład zastosowania przestrzeni nazw namespace n1
{
void f()
{
}
void g()
{
f(); // wywołuje n1::f()
}
};
namespace n2
{
void f()
{
}
void g()
{
f(); // wywołuje n2::f()
}
};
int main()
{
n1::f();
n2::f();
return 0;
}

W dużych aplikacjach pisanych w czystym C, w którym pojęcie przestrzeni nazw nie istnieje, programista musi często dodawać przedrostki do etykiet funkcji, żeby uniknąć konfliktów nazw pomiędzy poszczególnymi modułami:

void n1 _ f()
{
}
void n1 _ g()
{
n1 _ f();
}
void n2 _ f()
{
}
void n2 _ g()
{
n2 _ f();
}
int main()
{
n1 _ f();
n2 _ f();
return 0;
}

Przestrzenie nazw to kolejna, czysto składniowa konstrukcja C++, która ułatwia programowanie, ale nie powoduje powiększenia złożoności kodu wynikowego.

Referencje

Za inny przykład podobnego rodzaju mogą posłużyć referencje. Referencja w C++ fizycznie jest tożsama ze wskaźnikiem, różnica leży w regułach składniowych. Referencja jest bezpieczniejsza niż wskaźnik, gdyż nie da się jej przypisać wartości NULL, nie może być niezainicjowana i nie można zmienić jej wartości tak, żeby zaczęła wskazywać coś innego. Najbliższym odpowiednikiem w C jest wskaźnik const, ale uwaga: nie wskaźnik do obiektu typu const, lecz wskaźnik, którego wartości nie można zmienić. Przykłady:

// Referencja w C++
void akumulacja(int &i, int j)
{
i += j;
}
// Odpowiedni kod w C
void akumulacja(int * const i, int j)
{
*i += j;
}