Menu

Niezdefiniowane zachowanie w C++ (UB): czym jest i jak go unikać

Niezdefiniowane zachowanie (undefined behavior, UB) to kod, dla którego standard C++ nie ustala żadnych reguł: może się wysypać, uszkodzić dane albo pozornie działać. Poznaj najczęstsze przyczyny, dowiedz się, dlaczego "u mnie działało" niczego nie dowodzi, i poznaj narzędzia, które wyłapują UB.

Na tej stronie są działające edytory: edytuj, uruchamiaj i od razu zobacz wynik.

Co naprawdę oznacza "niezdefiniowane zachowanie"

Poprzednia strona pokazuje, jak try/catch obsługuje błędy, które twój program definiuje i celowo rzuca. Niezdefiniowane zachowanie to przeciwieństwo: zbiór operacji, którym standard C++ w ogóle odmawia znaczenia. Nie ma wyjątku do złapania, kodu błędu ani gwarancji awarii. Kompilator może założyć, że UB nigdy nie wystąpi, i zrobić cokolwiek zechce, gdy jednak wystąpi.

Ta swoboda sprawia, że UB jest tak niebezpieczne. Ta sama błędna linia może wypisać "poprawny" wynik na twoim laptopie, zwrócić śmieci na serwerze i zostać całkowicie usunięta przez optymalizator przy -O2. UB to nie "zachowanie, którego nie dokumentujemy", tylko "zachowanie, o którym język niczego nie obiecuje". Twoje zadanie to w ogóle go nie pisać.

int arr[3] = {1, 2, 3};
int x = arr[5];   // niezdefiniowane zachowanie: odczyt poza końcem tablicy

Nie ma tu błędu kompilacji, a przy wielu uruchomieniach program po cichu poda ci przypadkową liczbę całkowitą. Ten pozorny sukces to właśnie pułapka.

Odczyt lub zapis poza zakresem

Najczęstsza postać UB to sięganie do pamięci, która do ciebie nie należy. Wbudowane tablice i std::vector::operator[] nie sprawdzają zakresu: indeks za końcem (albo ujemny) to natychmiastowe UB, niezależnie od tego, czy czytasz, czy zapisujesz.

Wypatruj <= tam, gdzie miało być <: gdy i == v.size(), sięgasz o jeden element za ostatni, co jest UB. Gdy nie potrzebujesz indeksu, wybieraj pętlę for po zakresie (omówioną wcześniej), bo ona nie wyjdzie poza koniec. Gdy jednak indeksujesz ręcznie i chcesz siatki bezpieczeństwa, v.at(i) rzuca std::out_of_range zamiast po cichu psuć pamięć:

Używaj at() podczas polowania na błąd, a w gorących pętlach wróć do [], gdy już wiadomo, że indeksy są poprawne.

Wiszące wskaźniki i użycie po zwolnieniu

Wskaźnik lub referencja, które żyją dłużej niż obiekt, na który wskazują, są wiszące (dangling). Ich użycie to UB: pamięć mogła zostać ponownie użyta, zwolniona albo nigdy nie istnieć. Przed tą pułapką chronią inteligentne wskaźniki (z poprzedniego rozdziału), ale surowe wskaźniki wciąż pozwalają w nią wpaść.

Najostrzejsza wersja to zwracanie adresu zmiennej lokalnej. Zmienna lokalna znika, gdy funkcja się kończy, więc wywołujący zostaje ze wskaźnikiem na nic:

int* makeNumber() {
    int n = 42;
    return &n;   // zwraca adres zmiennej lokalnej: po return już jej nie ma
}
// Dereferencja wyniku to niezdefiniowane zachowanie.

To samo dzieje się po delete albo gdy vector realokuje pamięć i unieważnia iteratory lub wskaźniki do swoich elementów:

int* p = new int(5);
delete p;
cout << *p;   // użycie po zwolnieniu: niezdefiniowane zachowanie

vector<int> v = {1, 2, 3};
int* first = &v[0];
v.push_back(4);   // może realokować: 'first' jest teraz wiszący
cout << *first;   // niezdefiniowane zachowanie

Obrona to rzeczy, które już znasz: utrzymuj obiekty przy życiu tak długo, jak potrzebuje ich jakikolwiek wskaźnik, wybieraj referencje i inteligentne wskaźniki zamiast surowych wskaźników posiadających, a po każdej operacji, która może zmienić rozmiar kontenera, pobieraj wskaźniki i iteratory od nowa.

Niezainicjalizowane zmienne i przepełnienie liczb ze znakiem

Odczyt zmiennej typu wbudowanego, zanim nadasz jej wartość, to UB: nie ma domyślnego 0. Zmienna zawiera takie bity, jakie akurat były w tej pamięci, a optymalizator może założyć, że nigdy nie czytasz jej niezainicjalizowanej.

Gdyby sum zadeklarowano jako gołe int sum;, każde sum += i najpierw odczytywałoby nieokreśloną wartość. To UB i wyjątkowo trudny do znalezienia błąd, bo często wygląda, jakby wszystko działało. Wyrób sobie nawyk inicjalizacji: int x = 0; albo int x{};.

Kolejny cichy winowajca to przepełnienie liczby całkowitej ze znakiem. Przekroczenie maksimum przez int ze znakiem to UB (typy bez znaku zawijają się przewidywalnie, typy ze znakiem nie):

int big = 2147483647;   // INT_MAX dla 32-bitowego int
int oops = big + 1;     // przepełnienie ze znakiem: niezdefiniowane zachowanie

Nie licz na to, że wartość "zawinie się do liczby ujemnej": kompilator może założyć, że przepełnienie nie wystąpi, i optymalizować na tej podstawie. Jeśli potrzebujesz zdefiniowanego zawijania, użyj typu bez znaku albo sprawdź zakres przed dodawaniem.

Wykrywanie UB sanitizerami i ostrzeżeniami

Testami nie zdobędziesz pewności co do UB, bo udane uruchomienie niczego nie gwarantuje. Co naprawdę działa, to sprawienie, by UB było głośne w czasie działania, za pomocą sanitizerów kompilatora (dostępnych w GCC i Clang).

// AddressSanitizer: wyjście poza zakres, użycie po zwolnieniu, wycieki
g++ -fsanitize=address -g -O1 main.cpp -o app && ./app

// UndefinedBehaviorSanitizer: przepełnienie ze znakiem, dereferencja null, złe rzutowania
g++ -fsanitize=undefined -g main.cpp -o app && ./app

Uruchom istniejące testy z tymi flagami, a odczyt poza zakresem, użycie po zwolnieniu czy przepełnienie ze znakiem, które "działały bez problemu", zamienią się w precyzyjny raport z nazwą pliku i numerem linii. Połącz je z -Wall -Wextra, żeby kompilator zgłaszał też podejrzany kod (na przykład prawdopodobny odczyt niezainicjalizowanej zmiennej), zanim w ogóle uruchomisz program.

==1234==ERROR: AddressSanitizer: heap-use-after-free on address 0x...
READ of size 4 at 0x... thread T0
    #0 main.cpp:7 in main

Traktuj każdy raport sanitizera jako błąd do obowiązkowej naprawy, a nie ostrzeżenie do zignorowania: mówi ci, że standard niczego nie obiecuje w sprawie tej linii.

Podsumowanie

Niezdefiniowane zachowanie to ta część C++, w której zdejmuje się barierki: dostęp poza zakresem, wiszące wskaźniki, użycie po zwolnieniu, odczyt niezainicjalizowanych zmiennych i przepełnienie ze znakiem dają kod bez zdefiniowanego znaczenia, a "u mnie działało" nigdy nie dowodzi, że jest poprawny. Bezpieczeństwo daje programowanie defensywne: inicjalizuj każdą zmienną, szanuj granice kontenerów, oddaj pamięć na stercie inteligentnym wskaźnikom, a potem weryfikuj za pomocą -fsanitize=address, -fsanitize=undefined oraz -Wall -Wextra, żeby ciche UB stało się głośnym raportem, który da się naprawić.

To zamyka rozdział o błędach i debugowaniu. Dzięki wyjątkom, try/catch i zdrowemu respektowi wobec UB masz teraz narzędzia do pisania kodu w C++, który zawodzi głośno i celowo, a nie po cichu i przypadkiem.

Najczęściej zadawane pytania

Czym jest niezdefiniowane zachowanie w C++?

Niezdefiniowane zachowanie (UB) to każda operacja, której standard C++ celowo nie przypisuje żadnego określonego wyniku, na przykład odczyt poza końcem tablicy albo dereferencja wiszącego wskaźnika. Kompilator może wtedy zrobić cokolwiek: wysypać program, zwrócić śmieci, usunąć kod podczas optymalizacji albo działać dziś i przestać po ponownej kompilacji. To błąd w twoim programie, a nie cecha języka.

Dlaczego mój program w C++ działa, choć zawiera niezdefiniowane zachowanie?

"U mnie działało" niczego nie dowodzi w przypadku UB. Standard nie daje żadnej gwarancji w żadną stronę, więc błąd UB może dziś dać oczekiwany wynik na twoim komputerze i z twoim kompilatorem, a potem wysypać się przy innym poziomie optymalizacji, innej platformie lub innej wersji kompilatora. Nigdy nie traktuj udanego uruchomienia jako dowodu, że UB jest nieszkodliwe: użyj sanitizera, żeby naprawdę je wyłapać.

Jak wykryć niezdefiniowane zachowanie w C++?

Kompiluj z sanitizerami: -fsanitize=address (AddressSanitizer) znajduje odczyty i zapisy poza zakresem oraz użycie pamięci po zwolnieniu, a -fsanitize=undefined (UndefinedBehaviorSanitizer) zgłasza przepełnienie liczb ze znakiem, dereferencje null i błędne rzutowania. Włącz ostrzeżenia (-Wall -Wextra) i uruchamiaj testy z tymi flagami: zamieniają ciche UB w czytelny raport w czasie działania.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ