Historia: Przed C++20 standard C++ zezwalał na trzy różne reprezentacje liczb całkowitych ze znakiem: znak-magnituda, dopełnienie jedynkowe i dopełnienie dwóch. Ta neutralność architektoniczna zmusiła standard do oznaczenia przesunięcia bitowego w prawo dla ujemnych liczb całkowitych ze znakiem jako zależnego od implementacji, co uniemożliwiło gwarancje przenośności co do tego, czy operacja wykona przesunięcie arytmetyczne (zachowujące bit znaku) czy przesunięcie logiczne (uzupełnione zerami). Programiści systemów niskiego poziomu byli w konsekwencji zmuszeni do defensywnego rzutowania na typy bez znaku lub polegania na rozszerzeniach kompilatora, aby zapewnić spójne zachowanie ekstrakcji bitów na różnych platformach sprzętowych.
Problem: Brak obowiązkowej reprezentacji stworzył niebezpieczeństwo przenośności dla zadań programowania systemowego, takich jak analiza protokołów sieciowych, przetwarzanie sygnałów wbudowanych i arytmetyka stałoprzecinkowa. Kod, który opierał się na arytmetycznym przesunięciu w prawo dla wydajnego dzielenia przez dwa dla liczb ujemnych (np. -5 >> 1, co daje -3), mógł cicho generować błędne wyniki na architekturach wykorzystujących reprezentacje znak-magnituda lub dopełnienie jedynkowe, prowadząc do subtelnych uszkodzeń danych lub błędów w przepływie sterowania, które były trudne do zdiagnozowania podczas kompilacji krzyżowej.
Rozwiązanie: C++20 standaryzuje dopełnienie dwóch jako jedyną dozwoloną reprezentację dla liczb całkowitych ze znakiem. Ta standaryzacja gwarantuje, że przesunięcie w prawo dla ujemnej liczby całkowitej ze znakiem wykonuje przesunięcie arytmetyczne, matematycznie równoważne dzieleniu przez dół (zaokrąglając w kierunku ujemnej nieskończoności). W konsekwencji, E1 >> E2 teraz niezawodnie daje $\lfloor E_1 / 2^{E_2}
floor$, nawet gdy $E_1$ jest ujemne. Jednak ta gwarancja dotyczy wyłącznie operacji bitowej; pozostaje odrębna od operatora dzielenia całkowitego /, który zaokrągla do zera i nie usuwa nieokreślonego zachowania z przesunięć w lewo ani scenariuszy przepełnienia.
#include <iostream> int main() { int neg = -5; // C++20 gwarantuje przesunięcie arytmetyczne: -5 / 2^1 zaokrąglone w dół = -3 int shifted = neg >> 1; // Dzielenie całkowite zaokrągla do zera: -5 / 2 = -2 int divided = neg / 2; std::cout << "Przesunięty: " << shifted << " (dzielenie przez dół) "; std::cout << "Podzielony: " << divided << " (zaokrąglenie do zera) "; }
Szczegółowy przykład: Zespół programistów utrzymywał bibliotekę telemetryczną przeznaczoną do różnych platform dla czujników przemysłowych, która używała arytmetyki stałoprzecinkowej do kodowania wysokiej precyzji odczytów temperatury jako 32-bitowych liczb całkowitych ze znakiem. Aby zmaksymalizować wydajność na mikro kontrolerach o ograniczonych zasobach, oprogramowanie układowe przybliżało kosztowne dzielenie zmiennoprzecinkowe poprzez stosowanie bitowego przesunięcia w prawo do skalowania surowych wartości ADC na jednostki inżynieryjne. Podczas wysiłku przenoszenia mającego na celu walidację biblioteki w stosunku do symulatora starych głównych programów używanego do testów regresyjnych zespół odkrył, że negatywne odczyty temperatury (reprezentujące warunki poniżej zera) są błędnie obliczane o jeden bit, co powoduje, że wyzwalacze bezpieczeństwa w symulatorze nie działają.
Opis problemu: Kompilator symulatora starych głównych programów wykorzystywał reprezentację dopełnienia jedynkowego dla liczb całkowitych ze znakiem, w której przesunięcie w prawo dla wartości ujemnej nie przenosiło bitu znaku zgodnie z oczekiwaniami. Ta niespójność spowodowała, że logika skalowania stałoprzecinkowego zaokrąglała wartości ujemne w kierunku zera zamiast w kierunku ujemnej nieskończoności, wprowadzając systematyczny błąd o jeden LSB (najmniej znaczący bit), który narastał w wielu obliczeniach fuzji czujników i przekraczał progi tolerancji bezpieczeństwa.
Rozwiązanie 1: Defensywne rzutowanie na typ bez znaku.
Zespół rozważał przepisanie każdej operacji przesunięcia w prawo, aby rzutować liczbę całkowitą ze znakiem na uint32_t, wykonać przesunięcie, a następnie ręcznie odbudować znak przy użyciu maskowania bitów i logiki warunkowej. Chociaż zmusiłoby to do sformułowania dobrze zdefiniowanej semantyki bez znaku niezależnie od architektury, to obraziłoby bazę kodu przez rozbudowane makra do manipulacji bitami, obniżając czytelność formuł matematycznych i wprowadzając wysokie ryzyko błędów w fazie ręcznej odbudowy znaku.
Rozwiązanie 2: Warstwa abstrakcji preprocesora. Rozważyli wdrożenie nagłówka wykrywania kompilatora, który emitowałby różne implementacje przesunięcia w zależności od zdefiniowanych makr, stosując rekonstrukcję arytmetyczną dla egzotycznych platform i przesunięcia natywne dla standardowych. To podejście utrzymywało optymalną wydajność na głównym celu, ale fragmentowało kod źródłowy przez warunki kompilacji, wymagało utrzymania wszechstronnej bazy danych specyficznych dla kompilatora i komplikowało proces CI, wymagając oddzielnych konfiguracji kompilacji dla przestarzałego symulatora.
Rozwiązanie 3: Nakaz modernizacji narzędzi. Zespół zdecydował się na zaktualizowanie środowiska symulatora do narzędzia zgodnego z C++20 i zaniechanie wsparcia dla reprezentacji dopełnienia jedynkowego. Pozwoliło im to zachować oryginalną, czystą arytmetykę opartą na przesunięciu z gwarancją, że wszystkie cele będą teraz interpretowały negatywne przesunięcia w prawo jako dzielenie przez dół, eliminując potrzebę tworzenia defensywnych wzorców kodowania lub gałęzi specyficznych dla platformy.
Które rozwiązanie zostało wybrane (i dlaczego): Wybrano rozwiązanie 3, ponieważ koszt inżynieryjny modernizacji infrastruktury testowej był znacząco niższy niż ciągły ciężar konserwacji wsparcia dla przestarzałej reprezentacji liczb całkowitych. Gwarancja dopełnienia dwóch w C++20 zapewniała umowę opartą na standardzie, która zapewniała identyczną semantykę na poziomie bitów na roboczym stanowisku dewelopera, serwerach CI i produkcyjnych mikro kontrolerach.
Rezultat: Biblioteka telemetryczna skompilowała się bez modyfikacji na zaktualizowanym narzędziu, a testy jednostkowe krytyczne dla bezpieczeństwa przeszły przy pierwszym uruchomieniu. Zespół usunął około 150 linii makr defensywnego rzutowania i warunkowych bloków kompilacji. Ostateczne oprogramowanie układowe osiągnęło dokładność skalibrowaną zgodnie z normami ISO zarówno na nowym symulatorze, jak i na sprzęcie fizycznym, przechodząc walidację regulacyjną bez konieczności stosowania poprawek sprzętowych.
Pytanie: Dlaczego gwarancja reprezentacji dopełnienia dwóch w C++20 implikuje, że przesunięcie w prawo dla ujemnej liczby całkowitej ze znakiem daje matematycznie inny wynik niż dzielenie tej liczby przez odpowiednią potęgę dwóch przy użyciu operatora /?
Odpowiedź: W C++20 przesunięcie w prawo dla ujemnej liczby całkowitej ze znakiem wykonuje przesunięcie arytmetyczne, co implementuje dzielenie przez dół (zaokrąglając w kierunku ujemnej nieskończoności). Z kolei operator dzielenia całkowitego / zaokrągla wynik do zera. Na przykład, wyrażenie -5 >> 1 ocenia się na -3, podczas gdy -5 / 2 ocenia się na -2. Kandydaci często zakładają, że operacje te są wymiennymi optymalizacjami, ale ta tożsamość jest prawdziwa tylko dla operandów nieujemnych. Zrozumienie tej różnicy jest kluczowe przy implementacji arytmetyki stałoprzecinkowej lub algorytmów zaokrąglania, gdzie kierunek zaokrąglania wpływa na stabilność numeryczną obliczeń.
Pytanie: Czy nakaz dopełnienia dwóch w C++20 czyni wyrażenie (-1) << 1 dobrze zdefiniowanym?
Odpowiedź: Nie, przesunięcie w lewo dla ujemnej liczby całkowitej ze znakiem wciąż pozostaje nieokreślonym zachowaniem. Standard C++20 nadal zabrania przesunięć w lewo, gdzie operand jest ujemny, gdzie wartość przesunięcia jest równa lub większa niż szerokość bitowa typu, lub gdzie wynik przepełnia bit znaku. Chociaż dopełnienie dwóch naprawia wzór bitowy, standard nie definiuje znaczeniowego wyniku przesunięcia do lub przez bit znaku, ani nie zezwala na przepełnienie. Deweloperzy wymagający zdefiniowanej manipulacji bitami muszą wciąż rzutować na typ bez znaku (np. unsigned int), aby uzyskać przenośne, modułowe semantyki dla dwóch do potęgi N.
Pytanie: Jak wymóg dopełnienia dwóch w C++20 wpływa na wynik std::abs(std::numeric_limits<int>::min())?
Odpowiedź: C++20 gwarantuje, że std::numeric_limits<int>::min() równa się $-2^{31}$ (dla 32-bitowych liczb całkowitych) z wzorem bitowym 100...0. Jednak dodatni zakres liczby całkowitej ze znakiem tylko sięga do $2^{31}-1$. W konsekwencji, wartość bezwzględna minimalnej liczby całkowitej nie może być reprezentowana jako dodatnia int, a wywołanie std::abs na INT_MIN wywołuje nieokreślone zachowanie z powodu przepełnienia liczby całkowitej ze znakiem. Nakaz dopełnienia dwóch wyjaśnia reprezentację bitową, ale nie zmienia asymetrycznej natury zakresu liczb całkowitych ze znakiem, co jest subtelnością, którą często pomija się przy pisaniu defensywnych kontroli granicznych lub porównań dużych wartości.