Zawsze inicjuj swoje zmienne
Różnica między sytuacjami, które rozważasz, polega na tym, że przypadek bez inicjalizacji powoduje niezdefiniowane zachowanie , podczas gdy przypadek, w którym zainicjowałeś czas, tworzy dobrze zdefiniowany i deterministyczny błąd. Nie mogę podkreślić, jak bardzo różne są te dwa przypadki.
Rozważ hipotetyczny przykład, który mógł się przytrafić hipotetycznemu pracownikowi w programie hipotetycznych symulacji. Ten hipotetyczny zespół próbował hipotetycznie przeprowadzić deterministyczną symulację, aby wykazać, że produkt, który sprzedawali hipotetycznie, spełniał potrzeby.
Okej, przestanę od słowa zastrzyki. Myślę, że rozumiesz o co chodzi ;-)
W tej symulacji były setki niezainicjowanych zmiennych. Jeden z programistów przeprowadził symulację valgrind i zauważył, że wystąpiło kilka błędów „rozgałęzienia przy niezainicjowanej wartości”. „Hmm, to wygląda na to, że może powodować niedeterminizm, utrudniając powtarzanie testów, gdy najbardziej tego potrzebujemy”. Deweloper poszedł do zarządzania, ale zarządzanie było bardzo napięte i nie mógł oszczędzić zasobów, aby wyśledzić ten problem. „W końcu inicjalizujemy wszystkie nasze zmienne przed ich użyciem. Mamy dobre praktyki kodowania”.
Kilka miesięcy przed ostateczną dostawą, gdy symulacja jest w trybie pełnego odejścia, a cały zespół biegnie, aby dokończyć wszystko, co obiecało zarządzanie przy budżecie, który - jak każdy finansowany projekt - był zbyt mały. Ktoś zauważył, że nie mogli przetestować istotnej funkcji, ponieważ z jakiegoś powodu deterministyczna karta SIM nie zachowywała się deterministycznie podczas debugowania.
Cały zespół mógł zostać zatrzymany i spędził większą część 2 miesięcy na przeczesywaniu całej bazy kodu symulacji naprawiając niezainicjowane błędy wartości zamiast implementacji i testowania funkcji. Nie trzeba dodawać, że pracownik pominął „Powiedziałem ci tak” i od razu pomógł innym programistom zrozumieć, jakie są niezainicjowane wartości. O dziwo, standardy kodowania zostały zmienione wkrótce po tym incydencie, zachęcając programistów do zawsze inicjowania swoich zmiennych.
I to jest strzał ostrzegawczy. Jest to kula, która przecięła ci nos. Rzeczywisty problem jest o wiele daleko bardziej podstępny, niż można sobie wyobrazić.
Używanie niezainicjowanej wartości jest „niezdefiniowanym zachowaniem” (z wyjątkiem kilku przypadków narożnych, takich jak char
). Nieokreślone zachowanie (w skrócie UB) jest dla ciebie tak obłędnie i całkowicie złe, że nigdy nie powinieneś nigdy wierzyć, że jest lepsze niż alternatywa. Czasami możesz stwierdzić, że Twój konkretny kompilator definiuje UB, a następnie jest bezpieczny w użyciu, ale w przeciwnym razie niezdefiniowane zachowanie to „dowolne zachowanie, jakie odczuwa kompilator”. Może zrobić coś, co nazwałbyś „zdrowym”, na przykład o nieokreślonej wartości. Może emitować nieprawidłowe kody, potencjalnie powodując uszkodzenie twojego programu. Może wywołać ostrzeżenie w czasie kompilacji lub kompilator może nawet uznać to za błąd.
Lub może nic nie robić
Mój kanarek w kopalni węgla dla UB pochodzi z silnika SQL, o którym czytałem. Wybacz, że nie powiązałem go, nie udało mi się ponownie znaleźć tego artykułu. Wystąpił problem z przepełnieniem bufora w silniku SQL, gdy przekazałeś większy rozmiar bufora do funkcji, ale tylko w określonej wersji Debiana. Błąd został należycie zarejestrowany i zbadany. Zabawna część była: przekroczenie bufor był sprawdzany . Wprowadzono kod do obsługi przekroczenia bufora. Wyglądało to mniej więcej tak:
// move the pointers properly to copy data into a ring buffer.
char* putIntoRingBuffer(char* begin, char* end, char* get, char*put, char* newData, unsigned int dataLength)
{
// If dataLength is very large, we might overflow the pointer
// arithmetic, and end up with some very small pointer number,
// causing us to fail to realize we were trying to write past the
// end. Check this before we continue
if (put + dataLength < put)
{
RaiseError("Buffer overflow risk detected");
return 0;
}
...
// typical ring-buffer pointer manipulation followed...
}
Dodałem więcej komentarzy w moim wykonaniu, ale pomysł jest taki sam. Jeśli zostanie put + dataLength
zawinięty, będzie mniejszy niż put
wskaźnik (dla ciekawskich musieli sprawdzić czas kompilacji, aby upewnić się, że unsigned int ma rozmiar wskaźnika). Jeśli tak się stanie, wiemy, że standardowe algorytmy bufora pierścieniowego mogą się pomylić z powodu tego przepełnienia, więc zwracamy 0. Czy my?
Jak się okazuje, przepełnienie wskaźników jest niezdefiniowane w C ++. Ponieważ większość kompilatorów traktuje wskaźniki jako liczby całkowite, otrzymujemy typowe zachowania polegające na przepełnieniu liczb całkowitych, które akurat są zachowaniem, którego chcemy. Jednak to jest niezdefiniowane zachowanie, co oznacza, że kompilator może zrobić cokolwiek chce.
W przypadku tego błędu, Debian się wybrać do korzystania z nowej wersji gcc, że żaden z pozostałych głównych smaków Linux był zaktualizowany w ich wersjach produkcyjnych. Ta nowa wersja gcc miała bardziej agresywny optymalizator dead-code. Kompilator dostrzegł niezdefiniowane zachowanie i zdecydował, że wynikiem if
instrukcji będzie „cokolwiek, co optymalizuje kod najlepiej”, co było absolutnie legalnym tłumaczeniem UB. W związku z tym przyjęto założenie, że ponieważ ptr+dataLength
nigdy nie może być poniżej ptr
bez przepełnienia wskaźnika UB, if
instrukcja nigdy się nie uruchomi, i zoptymalizowała kontrolę przekroczenia bufora.
Użycie „rozsądnego” UB faktycznie spowodowało, że główny produkt SQL wykorzystał lukę przepełnienia bufora , której napisał kod, aby tego uniknąć!
Nigdy nie polegaj na nieokreślonym zachowaniu. Zawsze.
bytes_read
nie zostanie on zmieniony (więc zero), dlaczego to ma być błąd? Program może nadal być kontynuowany w rozsądny sposób, o ile nie spodziewa siębytes_read!=0
później. Więc dobrze, że środki odkażające nie narzekają. Z drugiej strony, gdybytes_read
nie jest zainicjowany wcześniej, program nie będzie w stanie kontynuować w sposób rozsądny, więc nie inicjalizacjibytes_read
faktycznie wprowadza błąd, który nie był tam wcześniej.