C++ bez cholesterolu

Niekompatybilności z językiem C

Powoli i nie bez bólu zgodzono się na to, że nie powinno być niczym nie uzasadnionych niezgodności między językiem C++ a językiem ANSI C, ale też przyjęto, że istnieje coś takiego, jak uzasadniona niezgodność. [...] Później uznano taką zasadę: `język C++: tak bliski języka C, jak tylko możliwe, ale nie bliższy' [...]. Miarą sukcesu takiej polityki jest to, że każdy przykład w książce Kernighana i Ritchie'ego jest napisany w języku C będącym podzbiorem języka C++. Bjarne Stroustrup

Wstęp

Wiele z zamieszczonych tu informacji były już po części wspominane wcześniej. Może i nie będą specjalnie przydatne, ale warto się z nimi zapoznać przed zapoznaniem się z elementami biblioteki standardowej C. Nie wszystkie są specjalnie uciążliwe, postanowiłem tutaj jednak zebrać to wszystko “do kupy”.

Struktury, wskaźniki, konwersje

W C panowały następujące zasady, które nie przetrwały w C++:

  1. Typ strukturalny i wyliczeniowy był poprzedzony słowem kluczowym identyfikującym (struct/union/enum); w konsekwencji nie zajmowało to identyfikatora w bieżącym zasięgu (co prawda nadal można używać tego identyfikatora przed nazwą typu, ale zajmowanie nazwy typu ma swoje konsekwencje).
  2. Struktura zagnieżdżona w innej ma w C++ nazwę z podaniem operatora zasięgu, podczas gdy w C była identyczna, jak gdyby była zadeklarowana normalnie w zewnętrznym zasięgu.
  3. Wskaźnik void* jest typem uniwersalno-wskaźnikowym; jest on niejawnie konwertowany na każdy typ wskaźnikowy (z zastrzeżeniami co do wariancji); w szczególności zresztą w C można konwertować niejawnie dwa dowolne wskaźniki między sobą i z wartością całkowitą, void* wyróżniłem z uwagi na to, że jest to powszechnie w C stosowane, bo kompilator nie rzuca wtedy ostrzeżeń. W C++ niejawne konwersje pomiędzy wartościami wskaźnikowymi niepokrewnych typów są zabronione.

Kwestia 'const' i typy literałów

Język ANSI C, mimo wprowadzenia (zapożyczonego zresztą z C++) słowa const, niewiele mógł na jego rzecz w języku zrobić. Efektem tego wszystkiego są zupełnie odmienne sposoby traktowania owego const w C i C++.

Przede wszystkim, w języku C const nie służy w ogóle do tworzenia stałych (w języku C zresztą pojęcie "stałej" nie istnieje). To, co const w ANSI C ma wspólnego z const w C++ to tylko i wyłącznie stałość wskaźnika (tyle że przypisanie wskaźnika na obiekt stały do wskaźnika na obiekt zmienny nie jest w ANSI C błędem). Co do obiektów zaś, to obiekt zadeklarowany z const w ANSI C nie różni się od obieku zadeklarowanego bez const właściwie niczym, poza tym, że zabrania się taki obiekt zmieniać. W C++ np. stałe mają wiązanie statyczne i dlatego np. można je deklarować w plikach nagłówkowych (w przypadku języka C taki myk wymagałby "static const", co zresztą i tak nie wniesie niczego w kwestii optymalizacji). W C stałe, podobnie jak zmienne, mają wiązanie takie, jakie im zadeklarowano.

Stałe w C++ (a już zwłaszcza stałe całkowite, bo to tych stałych głównie najbardziej owa różnica dotyka) są elementami na poziomie podobnym do literałów. Aby osiągnąć to samo w języku C, należy użyć w tym celu makra preprocesora (ewentualnie enum). Za pomocą takiej stałej bowiem możemy deklarować rozmiar dla tablicy, możemy też podawać jako stałe w konstrukcji switch/case. W C nie jest to dozwolone; tam stała to jest tylko taka zmienna, tyle że stała :).

Takoż jedną z rzeczy, której wprowadzenie do ANSI C nie zmienił fakt dodania const, jest typ stałej napisowej. W C++ napis składający się z N liter ma zawsze typ "const char [N+1]". Zwracam tu uwagę na owo const, bo to nadal nie jest takie proste (znów, kompromis). Ponieważ w ANSI C taki literał ma typ tablicy o elementach char (a nie const char), zatem w C++ dozwolono (wyjątkowo!) na przypisywanie tego do wskaźnika na char (nie tylko na const char). Efektywnie zatem, kosztem spójności reguł typów w C++, złagodzono impakt, jaki to niesie. Jest to jednak "wyklęta" właściwość (no i szkoda, że kompilatory nie ostrzegają, że taka konwersja została użyta).

No i niestety, nie usuwa to wszystkich "przykrych konsekwencji". Ponieważ napis jest tablicą o stałych znakach, zatem kompilator ma prawo (i zaręczam, że z niego korzysta) umieścić te obiekty w pamięci nie przeznaczonej do zapisu (co oznacza, że próba dokonania zapisu w takim obiekcie kończy się wylotem). W praktyce to oznacza, że jeśli mamy program w ANSI C, gdzie do char* przypisuje się napis w cudzysłowiu, to nosi on w sobie pewien mały potencjał destrukcyjny. Co prawda dokonywanie zmian w napisie, który jest zahardkodowany w cudzysłowiu, wydaje się być absurdalne, to jednak trzeba pamiętać, że jeśli użyjemy const char*, to mamy żywy dowód, że nikt tam nic nie zmienia, znacznie bardziej wiarygodny niż niewiele warta w praktyce (niestety) wiara w to, że programiści nie robią głupot w programie (co prawda przy const char* należy jeszcze brać poprawkę na rzutowanie wymuszone, ale już nie popadajmy w paranoję:). Dodatkowo, kompilatory mają też zazwyczaj opcje, które pozwalają traktować napis w cudzysłowiu jako zmienną.

Dodatkowo jeszcze jedna różnica w typach literałów. Literał w apostrofach, np. 'x' jest w C++ wartością typu char, natomiast w C jest typu int. Widywałem takoż wykorzystanie tego w C, na przykład podając więcej, niż jeden znak w apostrofach ('FORM'). Jest to oczywiście tak samo poprawne w C i w C++, czyli efekt jest zależny od implementacji (najczęściej robi się z tego liczbę całkowitą, której kolejne bajty - ułożone w tej samej kolejności, czyli niezależnie od endianu! - mają wartości podanych znaków). Jest to zatem właściwość, której nie powinno się używać (i jeśli kompilator takie coś łyka i obsługuje, to znaczy że jest to rozszerzenie kompilatora!). W gcc tak właśnie się dzieje, choć oczywiście stosowane jest ostrzeżenie (w C++ taki 'FORM' ma wtedy typ int).

Opuszczanie `int'; styl `K&R' definiowania argumentów funkcji

Jest to składnia (K&R to oczywiście "Kernighan & Ritchie", twórcy języka C; pierwotnie w języku C obowiązywała wyłącznie taka właśnie składnia) o następującej postaci (podam już przykładowo, bez schematów):

int strcpy( dest, source )
char *source, *dest;
{
...
}

Zaletą tego stylu jest to, że można podawać listę zmiennych, tak jak przy deklaracjach zmiennych lokalnych. Nie obowiązuje też oczywiście w takich deklaracjach żadna kolejność.

Posiada również wady. Czytelności tej składni niestety zarzucić nie można. W ogóle nie widać tego, jakie typy argumentów są oczekiwane na konkretnych pozycjach (nawet jeśli programista zachowa kolejność w deklaracjach). Założenia standardu C nie nakazują zachowania zgodności z normalnymi zapowiedziami; niektóre archaiczne kompilatory C nie dopuszczają innych zapowiedzi, niż bez specyfikacji argumentów. Z kolei gdyby używać tych specyfikacji, to ich synchronizacja jest w ten sposób dodatkowo utrudniona.

Ta składnia oczywiście nie zaakceptowała się w C++. Przytaczam to tylko jako ciekawostkę, którą nadal można spotkać w programach, których autorzy na siłę starają się dostosować swoje dzieła do kompilatorów z epoki kamienia łupanego. Nawet sami twórcy C uważają, że jest to anachronizm, który prawdopodobnie zniknie w którymś z następnych standardów.

W C, jak też w pierwotnych wersjach C++, istniała z trudem stłumiona zasada, że w deklaracjach czegokolwiek oprócz zmiennych, można było pominąć nazwę "typu czołowego", jeśli był nim int. W sumie dziś pozostała ona wyłącznie (w C) w formie ostrzeżeń kompilatora (np. jeśli deklaruje się funkcję, lub zmienną ze słowem static, extern czy auto). Proszę sobie wyobrazić, co w efekcie oznaczała ona w połączeniu ze stylem K&R:

fn( a, b, c ) char* b;
{
...
}

co odpowiada "normalnemu" nagłówkowi:

int fn( int a, char* b, int c );

extern "C"

Wspominałem już o tym niejednokrotnie, chciałbym tutaj jedynie uściślić kwestię używania extern "C". Dyrektywa ta pozwala na zaimportowanie kodu dostowowanego do C dla C++. Okoliczności, w których konieczne jest jego użycie to przede wszystkim deklaracje funkcji C. Nazwa takiej funkcji nie jest manglowana, a zewnętrznie funkcja jest traktowana jak funkcja C, co oznacza, że nie może być nią operator (tzn. właściwie to może, sprawdziłem to na g++ faktycznie, operator + miał zewnętrzną niezmanglowaną nazwę - __pl!) i nie może być przeciążona (nie znaczy to oczywiście, że nie da się jej przeciążyć, a jedynie, że tylko jedna z przeciążonych funkcji może być extern "C").

Brak manglowania nazwy jest jedną z istotniejszych rzeczy w extern "C". Podczas wiązania poszukuje się funkcji o określonej nazwie, a nazwy funkcji z biblioteki standardowej C są przecie zapisane w "gołej" postaci.

Restrykcyjność

Poza tym, co już wymieniłem należy jeszcze pamiętać o następujących rzeczach: