Czy „zawsze inicjowanie zmiennych” nie prowadzi do ukrycia ważnych błędów?

35

Podstawowe wytyczne C ++ mają zasadę ES.20: Zawsze inicjuj obiekt .

Unikaj błędów wcześniej ustawionych i związanych z nimi niezdefiniowanych zachowań. Unikaj problemów ze zrozumieniem złożonej inicjalizacji. Uprość refaktoryzację.

Ale ta zasada nie pomaga znaleźć błędów, tylko je ukrywa.
Załóżmy, że program ma ścieżkę wykonania, w której wykorzystuje niezainicjowaną zmienną. To jest błąd. Nieokreślone zachowanie na bok oznacza również, że coś poszło nie tak, a program prawdopodobnie nie spełnia wymagań dotyczących produktu. Kiedy zostanie wdrożony do produkcji, może wystąpić utrata pieniędzy, a nawet gorzej.

Jak sprawdzamy błędy? Piszemy testy. Ale testy nie obejmują 100% ścieżek wykonania, a testy nigdy nie obejmują 100% danych wejściowych programu. Co więcej, nawet test obejmuje wadliwą ścieżkę wykonania - wciąż może przejść. W końcu jest to niezdefiniowane zachowanie, niezainicjowana zmienna może mieć nieco poprawną wartość.

Ale oprócz naszych testów mamy kompilatory, które mogą zapisywać coś takiego jak 0xCDCDCDCD do niezainicjowanych zmiennych. To nieznacznie poprawia wskaźnik wykrywalności testów.
Jeszcze lepiej - istnieją narzędzia takie jak Address Sanitizer, który przechwytuje wszystkie odczyty niezainicjowanych bajtów pamięci.

I w końcu są analizatory statyczne, które mogą spojrzeć na program i stwierdzić, że na tej ścieżce wykonania znajduje się ustawiony przed odczytem.

Mamy więc wiele potężnych narzędzi, ale jeśli zainicjalizujemy zmienną - środki dezynfekujące nie znajdą nic .

int bytes_read = 0;
my_read(buffer, &bytes_read); // err_t my_read(buffer_t, int*);
// bytes_read is not changed on read error.
// It's a bug of "my_read", but detection is suppressed by initialization.
buffer.shrink(bytes_read); // Uninitialized bytes_read could be detected here.

// Another bug: use empty buffer after read error.
use(buffer);

Jest jeszcze jedna zasada - jeśli wykonanie programu napotka błąd, program powinien umrzeć jak najszybciej. Nie musisz utrzymywać go przy życiu, po prostu rozbij się, napisz zrzut awaryjny, daj inżynierom do zbadania.
Inicjowanie zmiennych niepotrzebnie robi coś przeciwnego - program jest utrzymywany przy życiu, gdy w przeciwnym razie dostałby błąd segmentacji.

Abyx
źródło
10
Chociaż myślę, że to dobre pytanie, nie rozumiem twojego przykładu. Jeśli wystąpi błąd odczytu i bytes_readnie 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!=0później. Więc dobrze, że środki odkażające nie narzekają. Z drugiej strony, gdy bytes_readnie jest zainicjowany wcześniej, program nie będzie w stanie kontynuować w sposób rozsądny, więc nie inicjalizacji bytes_readfaktycznie wprowadza błąd, który nie był tam wcześniej.
Doc Brown
2
@Abyx: nawet jeśli jest to strona trzecia, jeśli nie zajmuje się buforem zaczynającym się od \0niego, jest błędna. Jeśli udokumentowano, że nie można sobie z tym poradzić, kod wywołujący jest wadliwy. Jeśli naprawisz swój kod telefoniczny, aby sprawdzić bytes_read==0przed użyciem, powrócisz do miejsca, w którym zacząłeś: kod jest błędny, jeśli nie zainicjujesz bytes_read, bezpieczny, jeśli to zrobisz. ( Zwykle funkcje mają wypełniać swoje parametry wyjściowe nawet w przypadku błędu : niezupełnie. Dość często dane wyjściowe są pozostawione same sobie lub niezdefiniowane.)
Mat
1
Czy jest jakiś powód, dla którego ten kod ignoruje err_tzwracane przez my_read()? Jeśli w tym przykładzie jest błąd, to wszystko.
Blrfl,
1
To proste: inicjuj zmienne tylko wtedy, gdy ma to sens. Jeśli nie, to nie rób tego. Zgadzam się jednak, że użycie do tego celu „fałszywych” danych jest złe, ponieważ ukrywa błędy.
Pieter B,
1
„Jest jeszcze jedna zasada - jeśli podczas wykonywania programu wystąpi błąd, program powinien umrzeć tak szybko, jak to możliwe. Nie trzeba go utrzymywać, po prostu awarii, napisać zrzut awaryjny, przekazać inżynierom do zbadania.”: Wypróbuj to podczas lotu oprogramowanie sterujące. Powodzenia w odzyskiwaniu zrzutu awaryjnego z wraku samolotu.
Giorgio

Odpowiedzi:

44

Twoje rozumowanie jest błędne na kilku kontach:

  1. Błędy segmentacji są dalekie od pewności. Użycie niezainicjowanej zmiennej powoduje niezdefiniowane zachowanie . Błędy segmentacji są jednym ze sposobów, w jaki takie zachowanie może się przejawiać, ale wydaje się, że funkcjonowanie normalne jest równie prawdopodobne.
  2. Kompilatory nigdy nie zapełniają niezainicjowanej pamięci zdefiniowanym wzorcem (jak 0xCD). Jest to coś, co robią niektóre debuggery, aby pomóc ci znaleźć miejsca, w których używane są niezainicjowane zmienne. Jeśli uruchomisz taki program poza debuggerem, zmienna będzie zawierać całkowicie losowe śmieci. Jest równie prawdopodobne, że licznik taki jak bytes_readma wartość 10, ponieważ ma wartość 0xcdcdcdcd.
  3. Nawet jeśli pracujesz w debuggerze, który ustawia niezainicjowaną pamięć na stały wzorzec, robią to tylko podczas uruchamiania. Oznacza to, że ten mechanizm działa niezawodnie tylko w przypadku zmiennych statycznych (i ewentualnie przydzielonych do stosu). W przypadku zmiennych automatycznych, które są przydzielane na stos lub żyją tylko w rejestrze, istnieje duże prawdopodobieństwo, że zmienna jest przechowywana w miejscu, w którym była używana wcześniej, więc wzorzec pamięci ostrzegawczej został już nadpisany.

Ideą przewodnią dotyczącą zawsze inicjowania zmiennych jest umożliwienie tych dwóch sytuacji

  1. Zmienna zawiera użyteczną wartość od samego początku jej istnienia. Jeśli połączysz to ze wskazówkami, aby zadeklarować zmienną tylko wtedy, gdy jej potrzebujesz, możesz uniknąć, że przyszli programiści zajmujący się konserwacją wpadną w pułapkę rozpoczęcia używania zmiennej między jej deklaracją a pierwszym przypisaniem, w którym zmienna istniałaby, ale nie byłaby inicjowana.

  2. Zmienna zawiera zdefiniowaną wartość, którą można przetestować później, aby stwierdzić, czy funkcja podobna my_readzaktualizowała wartość. Bez inicjalizacji nie można stwierdzić, czy bytes_readrzeczywiście ma prawidłową wartość, ponieważ nie wiadomo, od jakiej wartości się ona rozpoczęła.

Bart van Ingen Schenau
źródło
8
1) chodzi o prawdopodobieństwa, na przykład 1% vs 99%. 2 i 3) VC ++ generuje taki kod inicjujący, również dla zmiennych lokalnych. 3) zmienne statyczne (globalne) są zawsze inicjowane na 0.
Abyx
5
@Abyx: 1) Z mojego doświadczenia wynika, że ​​~ 80% „nie ma natychmiast widocznej różnicy w zachowaniu”, 10% „robi coś złego”, 10% „segfault”. Jeśli chodzi o (2) i (3): VC ++ robi to tylko w kompilacjach debugowania. Poleganie na tym jest strasznie złym pomysłem, ponieważ selektywnie psuje kompilacje wydań i nie pojawia się w wielu testach.
Christian Aichinger
8
Myślę, że „idea przewodnika” jest najważniejszą częścią tej odpowiedzi. Wytyczne absolutnie nie mówią ci, abyś przestrzegał każdej deklaracji zmiennej = 0;. Celem porady jest zadeklarowanie zmiennej w punkcie, w którym będziesz miał dla niej użyteczną wartość i natychmiast przypisać tę wartość. Jest to wyraźnie określone w bezpośrednio następujących regułach ES21 i ES22. Tych trzech należy rozumieć jako współpracę; nie jako indywidualne niepowiązane reguły.
GrandOpener
1
@GrandOpener Dokładnie. Jeśli nie ma żadnej znaczącej wartości do przypisania w punkcie, w którym zmienna jest deklarowana, zakres zmiennej jest prawdopodobnie nieprawidłowy.
Kevin Krumwiede
5
„Kompilatory nigdy się nie wypełniają” nie powinno to być nie zawsze ?
CodesInChaos
25

Napisałeś „ta reguła nie pomaga znaleźć błędów, tylko je ukrywa” - cóż, celem tej reguły nie jest pomoc w znajdowaniu błędów, ale ich unikanie . A kiedy unika się błędu, nic nie jest ukryte.

Omówmy kwestię w odniesieniu do twojego przykładu: załóżmy, że my_readfunkcja ma pisemną umowę do zainicjowania bytes_readw każdych okolicznościach, ale nie dzieje się tak w przypadku błędu, więc jest co najmniej wadliwa w tym przypadku. Twoim celem jest użycie środowiska wykonawczego, aby pokazać ten błąd, nie inicjując bytes_readnajpierw parametru. Tak długo, jak wiesz na pewno, że istnieje środek dezynfekujący adresy, jest to rzeczywiście możliwy sposób na wykrycie takiego błędu. Aby naprawić błąd, należy zmienić my_readfunkcję wewnętrznie.

Ale jest inny punkt widzenia, który jest co najmniej równie ważny: wadliwe zachowanie wynika jedynie z kombinacjibytes_read wcześniejszej inicjalizacji imy_read późniejszego wywołania (z oczekiwaniem bytes_readinicjowanym później). Jest to sytuacja, która często zdarza się w komponentach świata rzeczywistego, gdy zapisana specyfikacja takiej funkcji my_readnie jest w 100% jasna, a nawet błędna co do zachowania w przypadku błędu. Jednak dopóki bytes_readinicjowane jest do zera przed wywołaniem, program zachowuje się tak samo, jakby inicjalizacja została wykonana wewnątrz my_read, więc zachowuje się poprawnie, w tej kombinacji nie ma błędu w programie.

Tak więc moje zalecenie, które z tego wynika, jest następujące: użyj podejścia nieinicjalizującego tylko wtedy, gdy

  • chcesz sprawdzić, czy funkcja lub blok kodu inicjuje określony parametr
  • masz 100% pewności, że dana funkcja ma kontrakt, w którym zdecydowanie błędem jest nieprzypisanie wartości do tego parametru
  • masz 100% pewności, że środowisko to złapie

Są to warunki, które zazwyczaj można ustawić w kodzie testowym dla określonego środowiska narzędziowego.

Jednak w kodzie produkcyjnym lepiej zawsze inicjować taką zmienną wcześniej, jest to bardziej defensywne podejście, które zapobiega błędom w przypadku, gdy umowa jest niekompletna lub błędna, lub w przypadku, gdy dezynfekcja adresu lub podobne środki bezpieczeństwa nie są aktywowane. A jeśli poprawnie napisałeś, obowiązuje zasada „wczesnego awarii”, jeśli wykonanie programu napotka błąd. Ale jeśli wcześniej zainicjowano zmienną, oznacza to, że nie ma nic złego, nie ma potrzeby przerywania dalszego wykonywania.

Doktor Brown
źródło
4
Właśnie o tym myślałem, kiedy to czytałem. Nie zamiata rzeczy pod dywan, tylko zamiata je do kosza na śmieci!
corsiKa
22

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 + dataLengthzawinięty, będzie mniejszy niż putwskaź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 ifinstrukcji 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+dataLengthnigdy nie może być poniżej ptrbez przepełnienia wskaźnika UB, ifinstrukcja 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.

Cort Ammon - Przywróć Monikę
źródło
Dla bardzo zabawnej lektury na temat niezdefiniowanego zachowania, software.intel.com/en-us/blogs/2013/01/06/... jest niezwykle dobrze napisanym postem na temat tego, jak źle może to pójść. Jednak ten konkretny post dotyczy operacji atomowych, co dla większości jest bardzo mylące, dlatego unikam polecania go jako podkładu do UB i tego, jak może się nie udać.
Cort Ammon - Przywróć Monikę
1
Chciałbym, żeby C miał wbudowane wartości, aby ustawić wartość lub ich tablicę na niezainicjowane, nieokreślone nieokreślone wartości lub nieokreślone wartości, lub zamienić nieprzyjemne wartości na mniej nieprzyjemne (nieokreślające nieokreślone lub nieokreślone), pozostawiając określone wartości w spokoju. Kompilatory mogą korzystać z takich dyrektyw, aby wspomóc przydatne optymalizacje, a programiści mogą z nich korzystać, aby uniknąć konieczności pisania niepotrzebnego kodu, a jednocześnie blokować łamanie „optymalizacji” podczas korzystania z technik takich jak techniki macierzy rzadkich.
supercat
@ superuper To byłaby fajna funkcja, zakładając, że celujesz w platformy, na których jest to prawidłowe rozwiązanie. Jednym z przykładów znanych problemów jest zdolność do tworzenia wzorców pamięci, które są nie tylko nieprawidłowe dla typu pamięci, ale niemożliwe do uzyskania zwykłymi środkami. booljest doskonałym przykładem, w którym występują oczywiste problemy, ale pojawiają się one gdzie indziej, chyba że zakładasz, że pracujesz na bardzo pomocnej platformie, takiej jak x86, ARM lub MIPS, gdzie wszystkie te problemy zostały rozwiązane w czasie kodowania.
Cort Ammon - Przywróć Monikę
Rozważ przypadek, w którym optymalizator może udowodnić, że wartość zastosowana dla a switchjest mniejsza niż 8, ze względu na rozmiary arytmetyki liczb całkowitych, aby mogli skorzystać z szybkich instrukcji, które zakładały, że nie ma ryzyka pojawienia się „dużej” wartości. Nagle pojawia się nieokreślona wartość (której nigdy nie można skonstruować przy użyciu reguł kompilatora), robiąc coś nieoczekiwanego, i nagle masz ogromny skok z końca tabeli skoków. Zezwolenie na nieokreślone wyniki tutaj oznacza, że każda instrukcja switch w programie musi mieć dodatkowe pułapki, aby obsłużyć te przypadki, które „nigdy nie mogą wystąpić”.
Cort Ammon - Przywróć Monikę
Gdyby elementy wewnętrzne były znormalizowane, można by wymagać od kompilatorów zrobienia wszystkiego, co byłoby konieczne, aby uszanować semantykę; jeśli np. niektóre ścieżki kodu ustawiają zmienną, a niektóre nie, a wartość wewnętrzna mówi „konwertuj na wartość nieokreśloną, jeśli jest niezainicjowana lub nieokreślona; w przeciwnym razie pozostaw w spokoju”, kompilator dla platform z rejestrami „nie-wartości” musiałby wstaw kod, aby albo zainicjować zmienną albo przed dowolnymi ścieżkami kodu, albo na dowolnych ścieżkach kodu, w których inicjacja w przeciwnym razie zostałaby pominięta, ale wymagana do tego analiza semantyczna jest dość prosta.
supercat
5

Pracuję głównie w funkcjonalnym języku programowania, w którym nie wolno zmieniać przypisań zmiennych. Zawsze. To całkowicie eliminuje tę klasę błędów. Z początku wydawało się to ogromnym ograniczeniem, ale zmusza cię do strukturyzacji kodu w sposób zgodny z kolejnością uczenia się nowych danych, co upraszcza kod i ułatwia jego utrzymanie.

Te nawyki można również przenieść na języki imperatywne. Prawie zawsze jest możliwe refaktoryzacja kodu, aby uniknąć zainicjowania zmiennej wartością zastępczą. Właśnie to nakazują ci te wytyczne. Chcą, abyś umieścił tam coś znaczącego, a nie coś, co tylko uszczęśliwi automatyzowane narzędzia.

Twój przykład z interfejsem API w stylu C jest nieco trudniejszy. W tych przypadkach, gdy użyję funkcji, zainicjuję do zera, aby kompilator nie narzekał, ale jeden raz w my_readtestach jednostkowych zainicjuję coś innego, aby upewnić się, że warunek błędu działa poprawnie. Nie musisz testować każdego możliwego warunku błędu przy każdym użyciu.

Karl Bielefeldt
źródło
5

Nie, nie ukrywa błędów. Zamiast tego determinuje zachowanie w taki sposób, że jeśli użytkownik napotka błąd, programista może go odtworzyć.


źródło
1
Inicjalizacja -1 może mieć znaczenie. W przypadku, gdy „int bytes_read = 0” jest zły, ponieważ faktycznie można odczytać 0 bajtów, zainicjowanie go wartością -1 wyraźnie pokazuje, że żadna próba odczytu bajtów nie powiodła się, i można to sprawdzić.
Pieter B
4

TL; DR: Istnieją dwa sposoby na poprawienie tego programu, zainicjowanie zmiennych i modlitwa. Tylko jeden zapewnia spójne wyniki.


Zanim będę mógł odpowiedzieć na twoje pytanie, muszę najpierw wyjaśnić, co oznacza Niezdefiniowane zachowanie . Właściwie pozwolę autorowi kompilatora wykonać większość pracy:

Jeśli nie chcesz czytać tych artykułów, TL; DR to:

Undefined Behavior to umowa społeczna między deweloperem a kompilatorem; kompilator zakłada z ślepą wiarą, że jego użytkownik nigdy nie będzie polegał na Niezdefiniowanym Zachowaniu.

Archetyp „Demonów lecących z nosa” niestety, niestety, nie przekazał implikacji tego faktu. Choć miało to udowodnić, że wszystko może się zdarzyć, było to tak niewiarygodne, że w większości zostało zlekceważone.

Prawda jest jednak taka, że Undefined Behavior wpływa na samą kompilację na długo przed próbą użycia programu (instrumentalnego lub nie, w debuggerze lub nie) i może całkowicie zmienić swoje zachowanie.

Przykład w części 2 powyżej jest dla mnie uderzający:

void contains_null_check(int *P) {
  int dead = *P;
  if (P == 0)
    return;
  *P = 4;
}

przekształca się w:

void contains_null_check(int *P) {
  *P = 4;
}

ponieważ jest oczywiste, że tak Pnie jest, 0ponieważ jest sprawdzany przed odwołaniem.


Jak to się odnosi do twojego przykładu?

int bytes_read = 0;
my_read(buffer, &bytes_read); // err_t my_read(buffer_t, int*);
// bytes_read is not changed on read error.
// It's a bug of "my_read", but detection is suppressed by initialization.
buffer.shrink(bytes_read); // Uninitialized bytes_read could be detected here.

Popełniłeś powszechny błąd, zakładając, że Niezdefiniowane zachowanie spowoduje błąd w czasie wykonywania. Może nie.

Wyobraźmy sobie, że definicja my_readto:

err_t my_read(buffer_t buffer, int* bytes_read) {
    err_t result = {};
    int blocks_read = 0;
    if (!(result = low_level_read(buffer, &blocks_read))) { return result; }
    *bytes_read = blocks_read * BLOCK_SIZE;
    return result;
}

i postępuj zgodnie z oczekiwaniami dobrego kompilatora z wbudowanym:

int bytes_read; // UNINITIALIZED

// start inlining my_read

err_t result = {};
int blocks_read = 0;
if (!(result = low_level_read(buffer, &blocks_read))) {
    // nothing
} else {
    bytes_read = blocks_reads * BLOCK_SIZE;
}

// end of inlining my_read

buffer.shrink(bytes_read);

Następnie, zgodnie z oczekiwaniami dobrego kompilatora, optymalizujemy bezużyteczne gałęzie:

  1. Nie należy używać żadnej zmiennej niezainicjowanej
  2. bytes_readbyłby użyty niezainicjowany, gdyby resultnie był0
  3. Deweloper obiecuje, że resultnigdy nie będzie 0!

Więc resultnigdy nie jest 0:

int bytes_read; // UNINITIALIZED
err_t result = {};
int blocks_read = 0;
result = low_level_read(buffer, &blocks_read);

bytes_read = blocks_reads * BLOCK_SIZE;
buffer.shrink(bytes_read);

Och, resultnigdy nie jest używane:

int bytes_read; // UNINITIALIZED
int blocks_read = 0;
low_level_read(buffer, &blocks_read);

bytes_read = blocks_reads * BLOCK_SIZE;
buffer.shrink(bytes_read);

Och, możemy odroczyć deklarację bytes_read:

int blocks_read = 0;
low_level_read(buffer, &blocks_read);

int bytes_read = blocks_reads * BLOCK_SIZE;
buffer.shrink(bytes_read);

I oto jesteśmy, ściśle potwierdzająca transformacja oryginału, i żaden debugger nie złapie niezainicjowanej zmiennej, ponieważ nie ma żadnej.

Byłem na tej drodze, zrozumienie problemu, gdy oczekiwane zachowanie i montaż nie pasują do siebie, naprawdę nie jest zabawne.

Matthieu M.
źródło
Czasami myślę, że kompilatory powinny poprosić program o usunięcie plików źródłowych podczas wykonywania ścieżki UB. Programiści dowiedzą się, co UB oznacza dla ich użytkownika końcowego ...
mattnz
1

Przyjrzyjmy się przykładowemu kodowi:

int bytes_read = 0;
my_read(buffer, &bytes_read); // err_t my_read(buffer_t, int*);
// bytes_read is not changed on read error.
// It's a bug of "my_read", but detection is suppressed by initialization.
buffer.shrink(bytes_read); // Uninitialized bytes_read could be detected here.

// Another bug: use empty buffer after read error.
use(buffer);

To dobry przykład. Jeśli spodziewamy się takiego błędu, możemy wstawić linię assert(bytes_read > 0);i złapać ten błąd w czasie wykonywania, co nie jest możliwe w przypadku niezainicjowanej zmiennej.

Ale załóżmy, że nie, i znajdziemy błąd w funkcji use(buffer). Ładujemy program do debugera, sprawdzamy ślad i dowiadujemy się, że został on wywołany z tego kodu. Dlatego umieszczamy punkt przerwania na początku tego fragmentu, uruchamiamy ponownie i odtwarzamy błąd. Próbujemy to złapać.

Jeśli nie zainicjowaliśmy bytes_read, zawiera śmieci. Nie zawsze zawiera te same śmieci za każdym razem. Przechodzimy obok linii my_read(buffer, &bytes_read);. Teraz, jeśli jest to inna wartość niż wcześniej, być może w ogóle nie będziemy w stanie odtworzyć naszego błędu! Może zadziałać następnym razem, na tym samym wejściu, przez całkowity wypadek. Jeśli jest konsekwentnie zerowy, uzyskujemy spójne zachowanie.

Sprawdzamy wartość, być może nawet na śladzie wstecznym w tym samym przebiegu. Jeśli wynosi zero, możemy zobaczyć, że coś jest nie tak; bytes_readnie powinno wynosić zero w przypadku sukcesu. (Lub jeśli tak, możemy chcieć zainicjować go na -1.) Prawdopodobnie możemy tutaj złapać błąd. Jeśli jednak bytes_readjest prawdopodobna wartość, która akurat jest błędna, czy dostrzeglibyśmy ją na pierwszy rzut oka?

Jest to szczególnie prawdziwe w przypadku wskaźników: wskaźnik NULL zawsze będzie oczywisty w debuggerze, można go bardzo łatwo przetestować i powinien ulec awarii na nowoczesnym sprzęcie, jeśli spróbujemy go wyrejestrować. Wskaźnik śmieci może później spowodować nieodwracalne błędy uszkodzenia pamięci, a ich debugowanie jest prawie niemożliwe.

Davislor
źródło
1

OP nie opiera się na nieokreślonym zachowaniu, a przynajmniej nie do końca. Rzeczywiście, poleganie na niezdefiniowanym zachowaniu jest złe. Jednocześnie zachowanie programu w nieoczekiwanym przypadku jest również niezdefiniowane, ale innego rodzaju niezdefiniowane. Jeśli ustawisz zmienną do zera, ale nie zamierzają mieć ścieżki wykonania korzystające z tego początkowego zera, to program zachowuje się zdrowo, kiedy masz problem i zrobić mieć taką drogę? Jesteś teraz w chwastach; nie planowałeś użyć tej wartości, ale i tak jej używasz. Być może będzie to nieszkodliwe, a może spowoduje awarię programu, a może spowoduje dyskretne uszkodzenie danych. Nie wiesz

OP mówi, że istnieją narzędzia, które pomogą ci znaleźć ten błąd, jeśli im na to pozwolisz. Jeśli nie zainicjujesz wartości, ale i tak ją wykorzystasz, istnieją statyczne i dynamiczne analizatory, które poinformują cię, że masz błąd. Analizator statyczny powie ci zanim jeszcze zaczniesz testować program. Z drugiej strony, jeśli ślepo zainicjujesz wartość, analizatory nie mogą powiedzieć, że nie planujesz użyć tej wartości początkowej, więc twój błąd pozostaje niewykryty. Jeśli masz szczęście, jest to nieszkodliwe lub powoduje jedynie awarię programu; jeśli masz pecha, dyskretnie psuje dane.

Jedyne miejsce, w którym nie zgadzam się z OP, znajduje się na samym końcu, gdzie mówi „kiedy w przeciwnym razie dostałby się błąd segmentacji”. Rzeczywiście, niezainicjowana zmienna nie spowoduje niezawodnego błędu segmentacji. Zamiast tego powiedziałbym, że powinieneś używać narzędzi do analizy statycznej, które nie pozwolą ci przejść nawet do próby uruchomienia programu.

Jordan Brown
źródło
0

Odpowiedź na twoje pytanie musi być podzielona na różne typy zmiennych, które pojawiają się w programie:


Zmienne lokalne

Zwykle deklaracja powinna znajdować się dokładnie w miejscu, w którym zmienna najpierw otrzymuje swoją wartość. Nie usuwaj wcześniej zmiennych, jak w starym stylu C:

//Bad: predeclared variables
int foo = 0;
double bar = 0.0;
long* baz = NULL;

bar = getBar();
foo = (int)bar;
baz = malloc(foo);


//Correct: declaration and initialization at the same place
double bar = getBar();
int foo = (int)bar;
long* baz = malloc(foo);

Eliminuje to 99% potrzeby inicjalizacji, zmienne mają swoją ostateczną wartość od samego początku. Kilka wyjątków dotyczy inicjalizacji zależnej od pewnych warunków:

Base* ptr;
if(foo()) {
    ptr = new Derived1();
} else {
    ptr = new Derived2();
}

Uważam, że dobrym pomysłem jest napisanie takich przypadków w ten sposób:

Base* ptr = nullptr;
if(foo()) {
    ptr = new Derived1();
} else {
    ptr = new Derived2();
}
assert(ptr);

I. e. wyraźnie stwierdzaj, że przeprowadzana jest rozsądna inicjalizacja twojej zmiennej.


Zmienne składowe

Zgadzam się z tym, co powiedzieli inni odpowiedzieli: Powinny one być zawsze inicjowane przez listy konstruktorów / inicjatorów. W przeciwnym razie trudno jest zapewnić spójność między członkami. A jeśli masz zestaw elementów, które nie wymagają inicjalizacji we wszystkich przypadkach, refaktoryzuj swoją klasę, dodając tych członków do klasy pochodnej, w której są zawsze potrzebni.


Bufory

Tutaj nie zgadzam się z innymi odpowiedziami. Kiedy ludzie podchodzą do tematu inicjowania zmiennych, często inicjują bufory w następujący sposób:

char buffer[30];
memset(buffer, 0, sizeof(buffer));

char* buffer2 = calloc(30);

Uważam, że jest to prawie zawsze szkodliwe: Jedynym efektem tych inicjalizacji jest to, że powodują, że narzędzia stają się valgrindbezsilne. Każdy kod, który czyta więcej z zainicjowanych buforów, niż powinien, jest bardzo prawdopodobne, że jest to błąd. Ale przy inicjalizacji tego błędu nie można wykryć valgrind. Więc nie używaj ich, chyba że naprawdę polegasz na wypełnieniu pamięci zerami (w takim przypadku zostaw komentarz mówiący, do czego potrzebujesz zer).

Zdecydowanie poleciłbym również dodanie celu do systemu kompilacji, który uruchamia całe testsuite pod valgrindlub podobne narzędzie, aby ujawnić błędy przed użyciem i wycieki pamięci. Jest to cenniejsze niż wszystkie wstępne inicjalizacje zmiennych. valgrindCel ten powinien być wykonywany regularnie, co najważniejsze, zanim jakikolwiek kod zostanie opublikowany.


Zmienne globalne

Nie możesz mieć globalnych zmiennych, które nie zostały zainicjowane (przynajmniej w C / C ++ itp.), Więc upewnij się, że ta inicjalizacja jest tym, czego chcesz.

cmaster
źródło
Zauważ, że możesz napisać warunkowe inicjalizacje z operatorem trójskładnikowym, np. Base& b = foo() ? new Derived1 : new Derived2;
Davislor
@Lorehead Może to działać w prostych przypadkach, ale nie będzie działać w przypadku bardziej złożonych: Nie chcesz tego robić, jeśli masz trzy lub więcej przypadków, a twoi konstruktorzy biorą trzy lub więcej argumentów, po prostu dla czytelności powody. I to nawet nie bierze pod uwagę obliczeń, które mogą wymagać wykonania, jak szukanie argumentu dla jednej gałęzi inicjalizacji w pętli.
cmaster
Dla bardziej skomplikowanych przypadkach, można owinąć kod inicjalizacji w funkcji fabrycznego: Base &b = base_factory(which);. Jest to najbardziej przydatne, jeśli trzeba wywołać kod więcej niż jeden raz lub jeśli pozwala to na uzyskanie stałego wyniku.
Davislor,
@Lorehead To prawda i na pewno droga, jeśli wymagana logika nie jest prosta. Niemniej jednak uważam, że istnieje niewielka szara strefa pomiędzy inicjacją za pośrednictwem ?:PITA, a funkcja fabryczna jest nadal nadmierna. Te przypadki są nieliczne, ale istnieją.
cmaster
-2

Przyzwoity kompilator C, C ++ lub Objective-C z odpowiednim zestawem opcji kompilatora powie ci w czasie kompilacji, czy zmienna jest używana przed ustawieniem jej wartości. Ponieważ w tych językach używanie wartości niezainicjowanej zmiennej jest niezdefiniowanym zachowaniem, „ustaw wartość przed użyciem” nie jest wskazówką, wytyczną ani dobrą praktyką, jest to wymóg 100%; w przeciwnym razie twój program jest całkowicie zepsuty. W innych językach, takich jak Java i Swift, kompilator nigdy nie zezwoli na użycie zmiennej przed jej zainicjowaniem.

Istnieje logiczna różnica między „inicjowaniem” a „ustawieniem wartości”. Jeśli chcę znaleźć kurs wymiany między dolarami a euro i napisać „podwójny kurs = 0,0;” wtedy zmienna ma ustawioną wartość, ale nie jest inicjowana. Zapisane tutaj 0.0 nie ma nic wspólnego z poprawnym wynikiem. W tej sytuacji, jeśli z powodu błędu nigdy nie zapisujesz poprawnego współczynnika konwersji, kompilator nie ma szansy ci powiedzieć. Jeśli właśnie napisałeś „podwójna stawka”; i nigdy nie zapisywał znaczącego współczynnika konwersji, kompilator powiedziałby ci.

Więc: Nie inicjuj zmiennej tylko dlatego, że kompilator mówi ci, że jest używana bez inicjalizacji. To ukrywa błąd. Prawdziwy problem polega na tym, że używasz zmiennej, której nie powinieneś używać, lub że na jednej ścieżce kodu nie ustawiłeś wartości. Napraw problem, nie ukrywaj go.

Nie inicjuj zmiennej tylko dlatego, że kompilator może powiedzieć, że jest używana bez inicjalizacji. Znów ukrywasz problemy.

Zadeklaruj zmienne blisko do użycia. Zwiększa to szanse na zainicjowanie go znaczącą wartością w punkcie deklaracji.

Unikaj ponownego wykorzystywania zmiennych. Gdy ponownie użyjesz zmiennej, najprawdopodobniej zostanie ona zainicjowana na bezużyteczną wartość, gdy użyjesz jej do drugiego celu.

Skomentowano, że niektóre kompilatory mają fałszywe negatywy, a sprawdzanie inicjalizacji jest równoważne z problemem zatrzymania. Oba są w praktyce nieistotne. Jeśli cytowany kompilator nie może znaleźć użycia niezainicjowanej zmiennej dziesięć lat po zgłoszeniu błędu, nadszedł czas, aby poszukać alternatywnego kompilatora. Java implementuje to dwukrotnie; raz w kompilatorze, raz w weryfikatorze, bez żadnych problemów. Prostym sposobem na obejście problemu zatrzymania nie jest wymaganie, aby zmienna była inicjowana przed użyciem, ale inicjowana przed użyciem w sposób, który można sprawdzić za pomocą prostego i szybkiego algorytmu.

gnasher729
źródło
Brzmi to pozornie dobrze, ale zbytnio opiera się na dokładności ostrzeżeń o niezainicjowanej wartości. Osiągnięcie idealnej poprawności jest równoznaczne z Problemem Zatrzymania, a kompilatory produkcyjne mogą i cierpią z powodu fałszywych negatywów (tj. Nie diagnozują niezainicjowanej zmiennej, kiedy powinny); patrz na przykład błąd GCC 18501 , który nie jest naprawiany od ponad dziesięciu lat.
zwolnienie
To, co mówisz o gcc, jest właśnie powiedziane. Reszta jest nieistotna.
gnasher729,
Smutne jest z powodu gcc, ale jeśli nie rozumiesz, dlaczego reszta jest tak ważna, musisz się uczyć.
zwolnienie