Wśród dziwnych błędów wykonania, jakie mogą przytrafić się źle napisanym programom w C++, poczesne miejsce zajmuje przypadek, gdy obiekt zadeklarowany po prostu jako zmienna:
w rzeczywistości nie istnieje (jeszcze). Dokładniej mówiąc: pamięć na zmienną foo
, jest jak najbardziej zaalokowana, ale konstruktor klasy Foo
nie został jeszcze wywołany.
Czy taka sytuacja jest w ogóle możliwa? Odpowiedź brzmi: jak najbardziej, jeśli rzeczona zmienna jest globalna lub statyczna w klasie (static
) i odwołujemy się do niej podczas konstrukcji innej takowej zmiennej.
Uważa się to za bardzo złą praktykę z prostego powodu: kolejność inicjalizacji zmiennych globalnych/statycznych umieszczonych w różnych jednostkach translacji (czyli plikach .cpp) jest niezdefiniowana. Ci, którzy znają trochę terminologię używaną w standardach C/C++ wiedzą, iż znaczy to tyle, że porządek tej inicjalizacji może się zmieniać w zależności od pory dnia, fazy księżyca i poziomu opadów w dorzeczu Amazonki – czyli w praktyce od platformy, wersji kompilatora czy nawet specyficznych jego ustawień (np. optymalizacji). Nie można więc na niej polegać, bo jeśli nawet “teraz u mnie działa”, to wcale nie oznacza, że za jakiś czas nadal będzie.
Niestety, napisanie kodu zależnego od porządku konstrukcji zmiennych globalnych jest prostsze niż się wydaje. Wystarczy chociażby wyobrazić sobie grę złożoną z kilku podsystemów (dźwięku, grafiki, fizyki, UI, itp.), z których każdy wystawia globalny obiekt będący jego interfejsem. Jeśli rozpoczęcie pracy któregoś z tych podsystemów wymaga innego, już gotowego do pracy (np. UI korzystające z części graficznej), to wówczas pojawią się opisywane wyżej zależności między inicjalizacją obiektów globalnych. A wtedy mamy klops :)
Z problemem, jak to zwykle bywa, możemy poradzić sobie dwojako. Można go obejść, stosując funkcję dostępową do obiektu i zmienną statyczną wewnątrz tej funkcji:
Mamy wówczas pewność, że zwróci ona zawsze odwołanie do już skonstruowanego obiektu. Jest to możliwe dzięki własnościom zmiennych statycznych w funkcjach, które sprawiają, że obiekt ten zostanie po prostu utworzony przy pierwszym użyciu (wywołaniu funkcji).
Nazywam ten sposób obejściem, bo w najlepszym razie jest on nieelegancki, a w najgorszym może powodować problemy na drugim końcu życia obiektów, czyli podczas ich destrukcji. Można rzecz rozwiązać lepiej i w sposób bardziej oczywisty, chociaż mniej “efektowny” niż automatyczna konstrukcja obiektów przy pierwszym użyciu.
Mam tu na myśli stare, dobre inicjalizowanie jawne na początku działania programu:
i równie wyraźne zwalnianie wszystkiego na końcu. Może to wszystko być w samej funkcji main
/WinMain
, może być w wydzielonym do tego miejscu – te szczegóły nie są aż takie ważne. Grunt, że w ten sposób żadna nietypowa (i niezdefiniowana) sytuacja nie powinna się już nam przytrafić.
Używanie zmiennych bez uprzedniego nadania im wartości to stosunkowo częsty błąd. Wiele języków kompilowanych będzie z tego powodu stroiło fochy wahające się od ostrzeżeń podczas kompilacji przez błędy tejże aż po wyjątki w czasie wykonania programu (przynajmniej w wersji debugowej). To zresztą bardzo dobrze, gdyż alternatywą jest korzystanie ze zmiennej, której zawartością są jakieś losowe śmieci w pamięci.
Tak więc zmienne trzeba inicjalizować i w niemal każdym języku da się to zrobić mniej więcej tak:
W C++ oczywiście też. Jednak w C++ mamy też feature, który nazywa się inicjalizacją domyślną. Opiera się on założeniu, że dany typ (tj. obiekt tego typu) może się sam zainicjalizować bez żadnych dodatkowych informacji – czyli że np. ma rozsądną wartość domyślną albo bezparametrowy konstruktor. Stąd T()
będzie “domyślnym obiektem typu T
“, który możemy łatwo uzyskać, nie mając w ogóle pojęcia, czym ów typ T
w istocie jest (codzienność szablonów, swoją drogą).
Inicjalizacja zmiennej tego typu domyślną wartością będzie więc wyglądała mniej więcej tak:
Co bardziej spostrzegawczy zauważą jednak, że mamy tutaj dwie operacje: konstrukcję obiektu i kopiowanie go. Nie radzę jednak próby “optymalizacji” w postaci zamiany na deklarację T foo();
– można się nielicho zaskoczyć. Najlepiej pozostawić to zadanie kompilatorowi; przynajmniej w teorii powinien on sobie z nim bez problemu poradzić.
W powyższy sposób możemy domyślnie inicjalizować dowolne typy zmiennych. Dla pewnej ich podgrupy – zwanej agregatami – możemy jednak zastosować inne rozwiązanie. Cóż to jednak są te agregaty? Otóż są to typy złożone (tablice, struktury, itp.), które nie posiadają zdefiniowanych przez programistę konstruktorów. Mimo że instrukcja T()
działa zupełnie dobrze również dla nich, dopuszczalne jest stosowanie nieco innej formy inicjalizacji.
Formą ta jest lista inicjalizacyjna. Można ją niekiedy spotkać w kodzie korzystającym z jakiegoś API, które operuje na dużych strukturach:
Chodzi tu po prostu o podanie wartości dla kolejnych elementów agregatu, czyli pól w strukturze/klasie lub komórek tablicy; całą tę listę zamykamy w nawiasy klamrowe. Nie musi ona być przy tym pełna: jeśli jakieś pozycje zostaną pominięte, to odpowiadające im pola/komórki zostaną automatycznie zainicjalizowane w sposób domyślny. W przykładzie powyżej inicjalizujemy więc strukturę tak, że pierwsze pole zawiera jej rozmiar, a reszta domyślne wartości (czyli pewnie zera).
Dane dla pierwszego pola zostały wprawdzie tutaj podane jawnie, jednak w ogólności nie jest to wymogiem. Na liście inicjalizacyjnej możemy równie dobrze opuścić wszystkie pozycje i to właśnie jest ten drugi, uniwersalny sposób inicjalizacji agregatu. Wygląda on więc po prostu tak:
Jakkolwiek nietypowo (jak na kod C++) linijka ta wygląda, jest ona najzupełniej poprawna. W wyniku jej wykonania nowo zadeklarowany wektor v
będzie miał wszystkie współrzędne wyzerowane.
Jak wiadomo, do konstruktorów w C++ możemy doczepić listy inicjalizacyjne, pozwalające nadawać początkowe wartości polom macierzystej klasy (i nie tylko zresztą). Związany jest z tym pewien pozornie zaskakujący fakt, dotyczący kolejności inicjalizacji tych pól:
Są one zawsze ustawiane w porządku zgodnym z ich deklaracjami w klasie. Tak więc powyżej inicjalizowane będzie najpierw pole a
, zaś potem b
– mimo że w konstruktorze kolejność jest odwrotna. Oczywiście w takim prostym przypadku nie ma to znaczenia, ale jeśli między polami występują zależności, wtedy może to być przyczyną “dziwnych” błędów. Porządniejsze kompilatory ostrzegają na szczęście przed rozbieżnymi kolejnościami pól w klasie i na liście w konstruktorze.
Można naturalnie zapytać, dlaczego w ogóle jest to zorganizowane tak nieintuicyjnie. Pierwsza nasuwająca się odpowiedź – “bo to C++” – nie jest bynajmniej zadowalająca :) Tak naprawdę przyczyną jest to, iż konstruktorów może być naturalnie więcej niż jeden:
i mogą mieć one różną kolejność pozycji na liście inicjalizacyjnej. Gdyby to ona liczyła się bardziej, wtedy różne obiekty tej samej klasy byłyby konstruowane na różne sposóby. Co gorsza, sposób tej konstrukcji (kolejność inicjalizacji pól) musiałby zostać gdzieś zapamiętany na czas życia obiektu, jako że jego pola powinny być później zniszczone w kolejności odwrotnej do konstrukcji – zgodnie z zasadami języka.
Krótko mówiąc, zastosowanie “intuicyjnego” podejścia skutkowałoby nie tylko znacznie większymi niejasnościami, ale i dodatkowymi kłopotami zarówno dla programisty, jak i kompilatora.