Zastanawiam się nad użyciem kodu takiego jak poniżej
int result = 0;
int factor = 1;
for (...) {
result = ...
factor *= 10;
}
return result;
Jeśli pętla jest iterowana w n
czasie, to factor
jest mnożona przez 10
dokładnie n
razy. Jednak factor
jest on używany tylko po pomnożeniu przez 10
łączną liczbę n-1
razy. Jeśli założymy, że factor
nigdy nie przepełnia się z wyjątkiem ostatniej iteracji pętli, ale może przepełniać się podczas ostatniej iteracji pętli, to czy taki kod powinien być akceptowalny? W takim przypadku wartość factor
prawdopodobnie nigdy nie zostanie wykorzystana po wystąpieniu przepełnienia.
Mam debatę na temat tego, czy taki kod powinien zostać zaakceptowany. Byłoby możliwe umieszczenie mnożenia wewnątrz instrukcji if i po prostu nie rób mnożenia na ostatniej iteracji pętli, gdy może ona przepełnić. Minusem jest to, że zaśmieca kod i dodaje niepotrzebną gałąź, która musiałaby sprawdzić na wszystkich poprzednich iteracjach pętli. Mógłbym również wykonać iterację raz na raz i powtórzyć treść pętli raz po pętli, to jeszcze bardziej komplikuje kod.
Właściwy kod, o którym mowa, jest wykorzystywany w ciasnej pętli wewnętrznej, która pochłania dużą część całkowitego czasu procesora w aplikacji graficznej w czasie rzeczywistym.
Odpowiedzi:
Kompilatory zakładają, że poprawny program C ++ nie zawiera UB. Rozważ na przykład:
Jeśli
x == nullptr
to, wyrejestrowanie go i przypisanie wartości to UB. Dlatego jedynym sposobem, w jaki może to zakończyć się prawidłowym programem, jest momentx == nullptr
, w którym nigdy nie da się uzyskać wartości true, a kompilator może założyć, że zgodnie z zasadą jakby, powyższe jest równoważne:Teraz w twoim kodzie
Ostatnie zwielokrotnienie
factor
nie może nastąpić w prawidłowym programie (podpisany przepełnienie jest niezdefiniowany). Stąd też przypisanie doresult
nie może się zdarzyć. Ponieważ nie ma możliwości rozgałęzienia przed ostatnią iteracją, poprzednia iteracja nie może się zdarzyć. W końcu część kodu, która jest poprawna (tj. Nigdy nie zdarza się niezdefiniowane zachowanie) to:źródło
INT_MAX >= 10000000000
, z inną funkcją wywoływaną w przypadku, gdyINT_MAX
jest mniejsza.i <= n
pętle są zawsze nieskończone, jaki<n
pętle. I promujint i
do szerokości wskaźnika w pętli zamiast ponawiać znak, aby możliwe było indeksowanie tablicy zawijania do pierwszych elementów tablicy 4G.Zachowanie
int
przepełnienia jest niezdefiniowane.Nie ma znaczenia, czy czytasz
factor
poza ciałem pętli; jeśli do tego czasu się przepełniło, to zachowanie kodu na, po i nieco paradoksalnie, zanim przepełnienie jest niezdefiniowane.Jednym z problemów, które mogą pojawić się przy utrzymywaniu tego kodu, jest to, że kompilatory stają się coraz bardziej agresywne, jeśli chodzi o optymalizację. W szczególności rozwijają nawyk, w którym zakładają, że niezdefiniowane zachowanie nigdy się nie zdarza. W tym celu mogą
for
całkowicie usunąć pętlę.Czy nie możesz użyć
unsigned
typu,factor
chociaż musisz martwić się niechcianą konwersjąint
naunsigned
w wyrażeniach zawierających oba?źródło
factor
jest „używane” w zadaniu z powrotem do siebie.Rozważanie optymalizatorów w świecie rzeczywistym może być wnikliwe. Rozwijanie pętli jest znaną techniką. Podstawową ideą rozwijania pętli op jest to
może być lepiej zaimplementowany za kulisami
Jest to łatwy przypadek ze stałym ograniczeniem. Ale współczesne kompilatory mogą to zrobić również dla zmiennych zmiennych:
staje się
Oczywiście działa to tylko wtedy, gdy kompilator wie, że N <= 3. I tu wracamy do pierwotnego pytania. Ponieważ kompilator wie, że podpisane przepełnienie nie występuje , wie, że pętla może wykonać maksymalnie 9 razy na architekturze 32-bitowej.
10^10 > 2^32
. Może zatem rozwinąć pętlę iteracyjną 9. Ale zamierzonym maksimum było 10 iteracji! .Może się zdarzyć, że otrzymasz skok względny do instrukcji asemblacji (9-N) o wartości N = 10, czyli przesunięcie -1, które jest samą instrukcją skoku. Ups Jest to doskonale poprawna optymalizacja pętli dla dobrze zdefiniowanego C ++, ale podany przykład zamienia się w ciasną nieskończoną pętlę.
źródło
Każde podpisane przepełnienie liczb całkowitych powoduje niezdefiniowane zachowanie, niezależnie od tego, czy przepełniona wartość jest lub może zostać odczytana.
Może w twoim przypadku użycia możesz podnieść pierwszą iterację z pętli, obracając ją
zaangażowany w to
Po włączeniu optymalizacji kompilator może rozwinąć drugą pętlę powyżej w jeden skok warunkowy.
źródło
factor *= 10;
To jest UB; w terminologii ISO C ++ całe zachowanie całego programu jest całkowicie nieokreślone dla wykonania, które ostatecznie uderza w UB. Klasycznym przykładem jest to, o ile chodzi o standard C ++, może sprawić, że demony wylecą z twojego nosa. (Odradzam stosowanie implementacji, w której demony nosowe są realną możliwością). Zobacz inne odpowiedzi, aby uzyskać więcej informacji.
Kompilatory mogą „powodować problemy” w czasie kompilacji dla ścieżek wykonywania, które widzą, co prowadzi do UB widocznego w czasie kompilacji, np. Zakładając, że te podstawowe bloki nigdy nie zostaną osiągnięte.
Zobacz także Co każdy programista C powinien wiedzieć o nieokreślonym zachowaniu (blog LLVM). Jak wyjaśniono tam, przepełnienie sygnatury UB pozwala kompilatorom udowodnić, że
for(... i <= n ...)
pętle nie są pętlami nieskończonymi, nawet dla nieznanychn
. Pozwala im także „promować” liczniki pętli int do szerokości wskaźnika zamiast powtarzania rozszerzenia znaku. (Tak więc konsekwencją UB w takim przypadku może być dostęp poza niskimi elementami 64k lub 4G tablicy, jeśli spodziewałeś się podpisanego zawijaniai
w jego zakresie wartości.)W niektórych przypadkach kompilatory będą wysyłały niedozwolone instrukcje, takie jak x86,
ud2
dla bloku, który prawdopodobnie spowoduje UB, jeśli kiedykolwiek zostanie wykonany. (Należy pamiętać, że funkcja może nigdy nie zostać wywołana, więc kompilatory nie mogą generalnie szaleć i łamać innych funkcji, a nawet możliwych ścieżek przez funkcję, która nie uderza w UB, tj. Kod maszynowy, do którego kompiluje, musi nadal działać dla wszystkie dane wejściowe, które nie prowadzą do UB.)Prawdopodobnie najbardziej wydajnym rozwiązaniem jest ręczne oderwanie ostatniej iteracji, aby
factor*=10
uniknąć niepotrzebnych .Lub jeśli ciało pętli jest duże, rozważ użycie po prostu typu bez znaku dla
factor
. Następnie możesz pozwolić, aby niepodpisane zwielokrotniło się, a po prostu wykona dobrze zdefiniowane zawijanie do pewnej potęgi 2 (liczba bitów wartości w typie bez znaku).Jest to w porządku, nawet jeśli używasz go z podpisanymi typami, szczególnie jeśli konwersja niepodpisana-> podpisana nigdy się nie przepełnia.
Konwersja między niepodpisanym a 2-znakowym uzupełnieniem jest bezpłatna (taki sam wzór bitowy dla wszystkich wartości); zawijanie modulo dla int -> unsigned określone przez standard C ++ upraszcza po prostu użycie tego samego wzorca bitowego, w przeciwieństwie do uzupełnienia lub znaku / wielkości.
I niepodpisany-> podpisany jest podobnie trywialny, chociaż jest zdefiniowany implementacyjnie dla wartości większych niż
INT_MAX
. Jeśli nie używasz ogromnego niepodpisanego wyniku z ostatniej iteracji, nie masz się czym martwić. Ale jeśli tak, zobacz Czy konwersja z niepodpisanego do podpisanego jest niezdefiniowana? . Przypadek „nie pasuje” jest zdefiniowany w implementacji , co oznacza, że implementacja musi wybrać pewne zachowanie; rozsądne po prostu obcinają (jeśli to konieczne) niepodpisany wzorzec bitów i używają go jako podpisanego, ponieważ działa to dla wartości w zakresie w ten sam sposób bez dodatkowej pracy. I zdecydowanie nie jest to UB. Tak duże wartości bez znaku mogą stać się liczbami całkowitymi ze znakiem ujemnym. np. point x = u;
gcc i clang nie optymalizujx>=0
jak zawsze są prawdziwe, nawet bez-fwrapv
, ponieważ określają zachowanie.źródło
Jeśli możesz tolerować kilka dodatkowych instrukcji montażu w pętli, zamiast
Możesz pisać:
aby uniknąć ostatniego pomnożenia.
!factor
nie wprowadzi oddziału:Ten kod
powoduje również montaż bez rozgałęzień po optymalizacji:
(Kompilowany z GCC 8.3.0
-O3
)źródło
factor
nieznacznie zwiększa opóźnienie łańcucha zależności przenoszonej przez pętlę . Czy nie: kiedy kompiluje do 2x LEA to prawie tak samo skuteczny jak LEA + dodaj do zrobieniaf *= 10
jakf*5*2
, ztest
opóźnieniami ukryte przez pierwszyLEA
. Ale wewnątrz pętli kosztuje to więcej, więc może wystąpić negatywny wpływ na przepustowość (lub przynajmniej problem związany z łatwym przeszukiwaniem)Nie pokazałeś, co jest w nawiasach
for
oświadczenia, ale założę, że jest to coś takiego:Możesz po prostu przenieść przyrost licznika i sprawdzić zakończenie pętli do ciała:
Liczba instrukcji montażu w pętli pozostanie taka sama.
Zainspirowany prezentacją Andrei Alexandrescu „Speed Is Found In The Minds of People”.
źródło
Rozważ funkcję:
Zgodnie z opublikowanym przesłanki, by oczekiwać autorzy standardu, że jeśli funkcja ta była wywoływana na (np) w banalnej 32-bitowego komputera z argumentami 0xC000 i 0xC000, promowanie operandy o
*
celusigned int
spowoduje obliczenie uzyskując -0x10000000 , które po przekształceniuunsigned
dałyby0x90000000u
- taką samą odpowiedź, jak gdyby sięunsigned short
promowałyunsigned
. Niemniej jednak gcc czasami zoptymalizuje tę funkcję w sposób, który zachowywałby się bezsensownie, gdyby nastąpiło przepełnienie. Każdy kod, w którym pewna kombinacja danych wejściowych może spowodować przepełnienie, musi być przetwarzany z-fwrapv
opcją, chyba że dopuszczalne jest zezwolenie twórcom celowo zniekształconych danych wejściowych na wykonanie dowolnego wybranego przez siebie kodu.źródło
Dlaczego nie to:
źródło
...
korpusu pętli dlafactor = 1
lubfactor = 10
, tylko 100 i więcej. Będziesz musiał obrać pierwszą iterację i zacząć od początku,factor = 1
jeśli chcesz, aby to zadziałało.Istnieje wiele różnych twarzy niezdefiniowanego zachowania, a to, co jest dopuszczalne, zależy od użycia.
To samo w sobie jest trochę niezwykłe, ale niech tak będzie ... jeśli tak rzeczywiście jest, to UB najprawdopodobniej mieści się w sferze „dopuszczalnej, dopuszczalnej” . Programowanie graficzne jest znane z hacków i brzydkich rzeczy. Tak długo, jak „działa” i nie trwa dłużej niż 16,6 ms, aby utworzyć ramkę, zwykle nikogo to nie obchodzi. Ale pamiętaj, co to znaczy wywołać UB.
Po pierwsze, jest standard. Z tego punktu widzenia nie ma o czym dyskutować i nie ma sposobu na uzasadnienie, twój kod jest po prostu nieprawidłowy. Nie ma ifs ani kiedy, to po prostu nie jest poprawny kod. Równie dobrze możesz powiedzieć, że jest to środkowy palec z twojego punktu widzenia, a 95-99% czasu i tak będziesz gotowy.
Następnie jest strona sprzętowa. Istnieją rzadkie, dziwne architektury, w których jest to problem. Mówię „niezwykłe, dziwne”, ponieważ w przypadku jednej architektury, która stanowi 80% wszystkich komputerów (lub dwóch architektur, które razem stanowią 95% wszystkich komputerów) przepełnienie to „tak, cokolwiek, nie przejmuj się” rzecz na poziomie sprzętowym. Na pewno dostajesz śmieci (choć nadal przewidywalne), ale nic złego się nie dzieje.
To nie jestw przypadku każdej architektury, możesz bardzo dobrze dostać pułapkę na przepełnienie (choć patrząc na to, jak mówisz o aplikacji graficznej, szanse na bycie na tak dziwnej architekturze są raczej niewielkie). Czy przenośność stanowi problem? Jeśli tak, możesz wstrzymać się od głosu.
Na koniec jest strona kompilatora / optymalizatora. Jednym z powodów, dla których przepełnienie jest niezdefiniowane, jest to, że po prostu pozostawienie go w tym miejscu było najłatwiejsze, aby poradzić sobie ze sprzętem. Ale innym powodem jest to, że na przykład
x+1
jest gwarantowane , aby zawsze być większy niżx
, a kompilator / optymalizator może wykorzystać tę wiedzę. Teraz, dla wcześniej wspomnianego przypadku, kompilatory rzeczywiście działają w ten sposób i po prostu usuwają kompletne bloki (kilka lat temu istniał exploit na Linuksa, który był oparty na tym, że kompilator usunął martwy kod weryfikacyjny właśnie z tego powodu).W twoim przypadku poważnie wątpiłbym, że kompilator wykonuje specjalne, nieparzyste optymalizacje. Co jednak wiesz, co ja wiem. W razie wątpliwości wypróbuj to. Jeśli to działa, możesz iść.
(I w końcu, oczywiście jest audyt kodu, być może będziesz musiał tracić czas na omawianie tego z audytorem, jeśli masz pecha.)
źródło