Specyfikacje C \ C ++ pomijają wiele zachowań, które kompilatory mogą wdrożyć na swój własny sposób. Jest wiele pytań, które ciągle zadawane są tutaj o to samo i mamy kilka świetnych postów na ten temat:
- https://stackoverflow.com/questions/367633/what-are-all-the-common-undefined-behaviour-that-ac-programmer-should-know-abo
- https://stackoverflow.com/questions/4105120/what-is-undefined-behavior
- https://stackoverflow.com/questions/4176328/undefined-behavior-and-sequence-points
Moje pytanie nie dotyczy tego, co to jest niezdefiniowane zachowanie, czy jest naprawdę złe. Znam niebezpieczeństwa i większość istotnych niezdefiniowanych zachowań z normy, więc proszę nie publikować odpowiedzi na temat tego, jak źle jest. To pytanie dotyczy filozofii pominięcia tylu zachowań otwartych na implementację kompilatora.
Przeczytałem świetny post na blogu, w którym stwierdzono, że głównym powodem jest wydajność. Zastanawiałem się, czy wydajność jest jedynym kryterium pozwalającym na to, czy też są jakieś inne czynniki, które wpływają na decyzję o pozostawieniu możliwości implementacji kompilatora?
Jeśli masz przykłady do cytowania na temat tego, w jaki sposób określone niezdefiniowane zachowanie zapewnia wystarczającą przestrzeń dla kompilatora do optymalizacji, proszę wymienić je. Jeśli znasz inne czynniki niż wydajność, poproś o odpowiedź z wystarczającymi szczegółami.
Jeśli nie rozumiesz pytania lub nie masz wystarczających dowodów / źródeł na poparcie swojej odpowiedzi, nie publikuj szeroko spekulujących odpowiedzi.
źródło
Odpowiedzi:
Po pierwsze, zauważę, że chociaż wspominam tu tylko o „C”, to samo dotyczy również w równym stopniu C ++.
Komentarz dotyczący Godela był częściowo (ale tylko częściowo) trafny.
Kiedy się do tego zabierasz, niezdefiniowane zachowanie w standardach C w dużej mierze po prostu wskazuje granicę między tym, co standard próbuje zdefiniować, a tym, czego nie określa.
Twierdzenia Godela (są dwa) w zasadzie mówią, że niemożliwe jest zdefiniowanie systemu matematycznego, który można udowodnić (według własnych zasad), aby był zarówno kompletny, jak i spójny. Możesz stworzyć swoje reguły, aby były kompletne (przypadek, którym zajmował się, były „normalnymi” regułami dla liczb naturalnych), albo możesz umożliwić udowodnienie jego spójności, ale nie możesz mieć obu.
W przypadku czegoś takiego jak C nie ma to bezpośredniego zastosowania - w większości przypadków „sprawdzalność” kompletności lub spójności systemu nie jest priorytetem dla większości projektantów języków. Jednocześnie tak, prawdopodobnie wpłynęło to na nich (przynajmniej w pewnym stopniu), wiedząc, że określenie „idealnego” systemu jest niemożliwe do udowodnienia - takiego, który jest kompletnie i spójny. Świadomość, że coś takiego jest niemożliwe, mogła nieco ułatwić cofnięcie się, odetchnąć i zdecydować o granicach tego, co spróbowaliby zdefiniować.
Ryzykując (jeszcze raz) oskarżenie o arogancję, scharakteryzuję standard C jako podlegający (częściowo) dwóm podstawowym ideom:
Pierwszy oznacza, że jeśli ktoś zdefiniuje nowy procesor, powinno być możliwe zapewnienie do tego celu dobrej, solidnej, użytecznej implementacji języka C, o ile projekt będzie co najmniej dość zbliżony do kilku prostych wytycznych - w zasadzie jeśli postępuje zgodnie z ogólną kolejnością modelu von Neumanna i zapewnia przynajmniej pewną rozsądną minimalną ilość pamięci, która powinna wystarczyć, aby umożliwić implementację C. W przypadku implementacji „hostowanej” (takiej, która działa w systemie operacyjnym), musisz obsługiwać pewne pojęcia, które dość ściśle odpowiadają plikom i mieć zestaw znaków z pewnym minimalnym zestawem znaków (wymagane jest 91).
Drugi oznacza, że powinno być możliwe pisanie kodu, który bezpośrednio manipuluje sprzętem, dzięki czemu można pisać takie rzeczy, jak programy ładujące, systemy operacyjne, oprogramowanie wbudowane, które działa bez żadnego systemu operacyjnego itp. Ostatecznie istnieją pewne ograniczenia w tym zakresie, więc prawie każdy praktyczny system operacyjny, moduł ładujący itp. może zawierać co najmniej trochę kodu napisanego w języku asemblera. Podobnie nawet mały wbudowany system może zawierać co najmniej pewien rodzaj wcześniej napisanych procedur bibliotecznych zapewniających dostęp do urządzeń w systemie hosta. Chociaż trudno jest precyzyjnie określić granicę, chodzi o to, aby zależność od takiego kodu była ograniczona do minimum.
Nieokreślone zachowanie w języku wynika w dużej mierze z zamiaru, aby język wspierał te możliwości. Na przykład język pozwala przekonwertować dowolną liczbę całkowitą na wskaźnik i uzyskać dostęp do wszystkiego, co dzieje się pod tym adresem. Standard nie próbuje powiedzieć, co się stanie, kiedy to zrobisz (np. Nawet czytanie z niektórych adresów może mieć widoczne efekty zewnętrzne). W tym samym czasie, nie ma próbę uniemożliwia robi takie rzeczy, bo trzeba do niektórych rodzajów oprogramowania jesteś ma być w stanie napisać w C
Istnieją również nieokreślone zachowania wynikające z innych elementów projektu. Na przykład jednym innym celem C jest obsługa oddzielnej kompilacji. Oznacza to (na przykład), że zamierzone jest „łączenie” elementów za pomocą linkera, który z grubsza podąża za tym, co większość z nas uważa za zwykły model linkera. W szczególności powinna istnieć możliwość łączenia oddzielnie skompilowanych modułów w kompletny program bez znajomości semantyki języka.
Istnieje inny rodzaj niezdefiniowanego zachowania (który jest znacznie bardziej powszechny w C ++ niż C), który występuje po prostu z powodu ograniczeń technologii kompilatora - rzeczy, które w zasadzie wiemy, są błędami i prawdopodobnie chcieliby, aby kompilator diagnozował jako błędy, ale biorąc pod uwagę obecne ograniczenia technologii kompilatora, wątpliwe jest, aby można je było zdiagnozować w każdych okolicznościach. Wiele z nich wynika z innych wymagań, takich jak osobna kompilacja, więc w dużej mierze chodzi o zrównoważenie sprzecznych wymagań, w którym to przypadku komitet zasadniczo zdecydował się na wsparcie większych możliwości, nawet jeśli oznacza to brak diagnozy niektórych możliwych problemów, zamiast ograniczać możliwości, aby zdiagnozować wszystkie możliwe problemy.
Te różnice w umyśle powodują większość różnic między C a czymś takim jak Java lub systemy oparte na CLI Microsoftu. Te ostatnie są dość wyraźnie ograniczone do pracy ze znacznie bardziej ograniczonym zestawem sprzętu lub wymagają oprogramowania do emulacji bardziej specyficznego sprzętu, na który są kierowane. Mają również na celu zapobieganie wszelkim bezpośrednim manipulacjom sprzętowym, zamiast tego wymagają użycia czegoś takiego jak JNI lub P / Invoke (i kodu napisanego w języku C), aby nawet podjąć taką próbę.
Wracając przez chwilę do twierdzeń Godela, możemy narysować coś podobnego: Java i CLI wybrali alternatywę „wewnętrznie spójną”, podczas gdy C wybrał alternatywę „kompletną”. Oczywiście, jest to bardzo szorstki analogia - wątpię ktoś usiłuje formalny dowód zarówno wewnętrznej spójności i kompletności w obu przypadkach. Niemniej jednak ogólne pojęcie dość dobrze pasuje do dokonanych wyborów.
źródło
Uzasadnienie C wyjaśnia
Ważna jest także korzyść dla programów, nie tylko korzyść dla wdrożeń. Program zależny od nieokreślonego zachowania może nadal być zgodny , jeśli zostanie zaakceptowany przez implementację zgodną. Istnienie nieokreślonego zachowania pozwala programowi na użycie nieprzenośnych funkcji wyraźnie oznaczonych jako takie („niezdefiniowane zachowanie”), bez stania się niezgodnym. Uzasadnienie uzasadnia:
A w 1.7 zauważa
Tak więc ten mały brudny program, który działa idealnie w GCC, nadal jest zgodny !
źródło
Szybkość jest szczególnie problemem w porównaniu do C. Gdyby C ++ zrobił pewne rzeczy, które mogą być sensowne, takie jak inicjowanie dużych tablic prymitywnych typów, straciłby mnóstwo testów porównawczych do kodu C. Tak więc C ++ inicjuje własne typy danych, ale pozostawia typy C takimi, jakie były.
Inne niezdefiniowane zachowanie po prostu odzwierciedla rzeczywistość. Jednym z przykładów jest przesunięcie bitów z liczbą większą niż typ. To faktycznie różni się pomiędzy generacjami sprzętu tej samej rodziny. Jeśli masz aplikację 16-bitową, dokładnie ten sam plik binarny da różne wyniki dla 80286 i 80386. Standard językowy mówi, że nie wiemy!
Niektóre rzeczy są po prostu zachowywane takimi, jakimi były, na przykład nieokreślony porządek oceny podwyrażeń. Początkowo uważano, że pomaga to autorom kompilatorów lepiej zoptymalizować. W dzisiejszych czasach kompilatory są wystarczająco dobre, aby to rozgryźć, ale koszt znalezienia wszystkich miejsc w istniejących kompilatorach korzystających z wolności jest po prostu zbyt wysoki.
źródło
Jako jeden przykład, dostęp do wskaźnika prawie musi być niezdefiniowany i niekoniecznie tylko ze względu na wydajność. Na przykład w niektórych systemach ładowanie określonych rejestrów wskaźnikiem wygeneruje wyjątek sprzętowy. Podczas uzyskiwania dostępu do SPARC niewłaściwie wyrównany obiekt pamięci spowoduje błąd magistrali, ale na x86 „po prostu” byłby powolny. W takich przypadkach ustalenie zachowania jest trudne, ponieważ podstawowy sprzęt decyduje o tym, co się stanie, a C ++ jest przenośny na tak wiele rodzajów sprzętu.
Oczywiście daje to kompilatorowi swobodę korzystania z wiedzy specyficznej dla architektury. W przypadku nieokreślonego przykładu zachowania prawidłowe przesunięcie podpisanych wartości może być logiczne lub arytmetyczne w zależności od sprzętu bazowego, aby umożliwić korzystanie z dowolnej dostępnej operacji zmiany i nie zmuszanie jej do emulacji oprogramowania.
Wierzę również, że sprawia to, że praca kompilatora-pisarza jest łatwiejsza, ale nie mogę sobie teraz przypomnieć tego przykładu. Dodam to, jeśli przypomnę sobie sytuację.
źródło
Prostota: szybkość i przenośność. Jeśli C ++ zagwarantuje, że dostaniesz wyjątek, gdy odwołasz odwołanie do niepoprawnego wskaźnika, to nie będzie przenośny na osadzony sprzęt. Gdyby C ++ gwarantował pewne inne rzeczy, takie jak zawsze zainicjowane prymitywy, wtedy byłoby wolniej, a w czasie powstawania C ++ wolniej było naprawdę, bardzo złą rzeczą.
źródło
C został wynaleziony na maszynie z 9-bitowymi bajtami i bez jednostki zmiennoprzecinkowej - przypuśćmy, że nakazał, aby bajty miały 9 bitów, słowa 18 bitów i że zmiennoprzecinkowe powinny być zaimplementowane przy użyciu arytmatyki wcześniejszej niż IEEE754?
źródło
Nie sądzę, aby pierwszym uzasadnieniem dla UB było pozostawienie miejsca kompilatorowi na optymalizację, ale tylko możliwość użycia oczywistej implementacji dla celów w czasach, gdy architektura była bardziej różnorodna niż teraz (pamiętaj, czy C został zaprojektowany na PDP-11, który ma nieco znaną architekturę, pierwszy port był do Honeywell 635, który jest znacznie mniej znany - adresowalne słowo, używając 36 słów, 6 lub 9 bitów bajtów, adresów 18 bitów ... no cóż, przynajmniej użył 2 komplement). Ale jeśli ciężka optymalizacja nie była celem, oczywista implementacja nie obejmuje dodawania kontroli w czasie wykonywania pod kątem przepełnienia, liczby przesunięć w stosunku do wielkości rejestru, co powoduje aliasy w wyrażeniach modyfikujących wiele wartości.
Pod uwagę wzięto również łatwość wdrożenia. Kompilator prądu przemiennego w tym czasie miał wiele przebiegów przy użyciu wielu procesów, ponieważ posiadanie jednego procesu obsługi wszystkiego nie byłoby możliwe (program byłby zbyt duży). Pytanie o sprawdzanie spójności nie było przeszkodą - szczególnie, gdy dotyczyło kilku jednostek CU. (Zastosowano do tego inny program niż kompilatory C, lint).
źródło
i
in
, tak, żen < INT_BITS
ii*(1<<n)
nie przepełnić, chciałbym rozważyći<<=n;
być jaśniejsze niżi=(unsigned)i << n;
; na wielu platformach byłby szybszy i mniejszy niżi*=(1<<N);
. Co zyskuje się, gdy kompilatorzy tego zabraniają?Jednym z pierwszych klasycznych przypadków było dodanie liczby całkowitej. Na niektórych używanych procesorach spowodowałoby to awarię, a na innych kontynuowałoby z wartością (prawdopodobnie odpowiednią wartością modułową). Określenie obu przypadków oznaczałoby, że programy dla komputerów z nielubianym stylem arytmetycznym musiałyby mieć dodatkowy kod, w tym gałąź warunkową, dla czegoś podobnego jak dodawanie liczb całkowitych.
źródło
int
jest 16 bitów, a przesunięcia z rozszerzeniem znaku są drogie, można obliczyć(uchar1*uchar2) >> 4
przy użyciu przesunięcia bez rozszerzenia znaku. Niestety, niektóre kompilatory rozszerzają wnioski nie tylko na wyniki, ale także na operandy.Powiedziałbym, że w mniejszym stopniu chodziło o filozofię niż o rzeczywistość - C zawsze był językiem wieloplatformowym, a standard musi to odzwierciedlać oraz fakt, że w momencie opublikowania jakiegokolwiek standardu będzie duża liczba wdrożeń na wielu różnych urządzeniach. Norma zabraniająca niezbędnego zachowania zostałaby albo zignorowana, albo stworzyła konkurencyjny organ normalizacyjny.
źródło
Niektórych zachowań nie można zdefiniować w żaden rozsądny sposób. Mam na myśli dostęp do usuniętego wskaźnika. Jedynym sposobem na jego wykrycie byłoby zablokowanie wartości wskaźnika po usunięciu (zapamiętanie jego wartości gdzieś i niedozwolenie, aby jakakolwiek funkcja alokacji zwróciła ją). Takie zapamiętywanie byłoby nie tylko przesadą, ale dla długo działającego programu spowodowałoby wyczerpanie dopuszczalnych wartości wskaźników.
źródło
weak_ptr
i unieważnić wszystkie odwołania do wskaźnika, który dostajedelete
... och, czekaj, zbliżamy się do wyrzucania elementów bezużytecznych: /boost::weak_ptr
Implementacja jest całkiem dobrym szablonem na początek dla tego wzorca użytkowania. Zamiast śledzenia i unieważnianiaweak_ptrs
zewnętrznego,weak_ptr
just przyczynia się doshared_ptr
słabej liczby, a słaba liczba jest w zasadzie przelicznikiem samego wskaźnika. W ten sposób możesz anulować plikshared_ptr
bez konieczności jego natychmiastowego usuwania. To nie jest idealne (nadal możesz mieć wiele przeterminowanychweak_ptr
utrzymujących bazęshared_count
bez uzasadnionego powodu), ale przynajmniej jest szybkie i wydajne.Dam ci przykład, w którym właściwie nie ma rozsądnego wyboru poza niezdefiniowanym zachowaniem. Zasadniczo każdy wskaźnik może wskazywać na pamięć zawierającą dowolną zmienną, z niewielkim wyjątkiem zmiennych lokalnych, o których kompilator może wiedzieć, że nigdy nie wziął ich adresu. Jednak, aby uzyskać akceptowalną wydajność nowoczesnego procesora, kompilator musi skopiować wartości zmiennych do rejestrów. Działanie całkowicie bez pamięci to nie starter.
Zasadniczo daje to dwie możliwości:
1) Opróżnij wszystko z rejestrów przed jakimkolwiek dostępem przez wskaźnik, na wypadek, gdyby wskaźnik wskazywał pamięć tej konkretnej zmiennej. Następnie załaduj wszystko, co potrzebne, z powrotem do rejestru, na wypadek gdyby wartości zostały zmienione za pomocą wskaźnika.
2) Posiadaj zestaw reguł określających, kiedy wskaźnik może aliować zmienną, a kiedy kompilator może założyć, że wskaźnik nie aliasuje zmiennej.
C wybiera opcję 2, ponieważ 1 byłby straszny dla wydajności. Ale co się stanie, jeśli wskaźnik aliuje zmienną w sposób zabroniony przez reguły C. Ponieważ efekt zależy od tego, czy kompilator rzeczywiście zapisał zmienną w rejestrze, standard C nie ma możliwości zagwarantowania określonych wyników.
źródło
foo
na 42, a następnie wywołuje metodę, która używa nielegalnie zmodyfikowanego wskaźnika do ustawieniafoo
na 44, widzę korzyść z powiedzenia, że do czasu następnego „prawidłowego” zapisufoo
próba odczytania może być zgodna z prawem wydaj 42 lub 44, a wyrażenie podobnefoo+foo
może nawet dawać 86, ale widzę o wiele mniej korzyści, pozwalając kompilatorowi na wyciąganie rozszerzonych, a nawet retroaktywnych wniosków, zmieniając Nieokreślone Zachowanie, którego prawdopodobne „naturalne” zachowania byłyby łagodne, w licencję generować bezsensowny kod.Historycznie, niezdefiniowane zachowanie miało dwa podstawowe cele:
Aby uniknąć wymagania od autorów kompilatora generowania kodu do obsługi warunków, które nigdy nie powinny wystąpić.
Aby pozwolić na to, że przy braku kodu jawnie obsługującego takie warunki, implementacje mogą mieć różnego rodzaju „naturalne” zachowania, które w niektórych przypadkach byłyby przydatne.
Jako prosty przykład na niektórych platformach sprzętowych próba dodania dwóch liczb całkowitych ze znakiem dodatnim, których suma jest zbyt duża, aby zmieścić się w liczbie całkowitej ze znakiem, da określoną liczbę całkowitą ze znakiem ujemnym. W innych implementacjach wyzwoli pułapkę procesora. Aby standard C nakazał takie zachowanie, wymagałoby, aby kompilatory dla platform, których naturalne zachowanie różniło się od normy, musiałyby wygenerować dodatkowy kod, aby uzyskać prawidłowe zachowanie - kod, który może być droższy niż kod do faktycznego dodania. Co gorsza, oznaczałoby to, że programiści, którzy chcieli „naturalnego” zachowania, musieliby dodać jeszcze więcej dodatkowego kodu, aby to osiągnąć (i ten dodatkowy kod byłby znowu droższy niż dodanie).
Niestety, niektórzy autorzy kompilatorów przyjęli filozofię, aby kompilatory starały się znaleźć warunki, które wywołałyby Nieokreślone Zachowanie, i zakładając, że takie sytuacje mogą nigdy nie wystąpić, wyciągają z tego wyciągnięte wnioski. Zatem w systemie z 32-bitowym
int
kodem takim jak:norma C pozwoliłaby kompilatorowi powiedzieć, że jeśli q wynosi 46341 lub więcej, wyrażenie q * q da wynik zbyt duży, aby zmieścił się w
int
, co w konsekwencji spowoduje niezdefiniowane zachowanie, w wyniku czego kompilator byłby uprawniony do założenia, że nie może się zdarzyć, a zatem nie musiałby się zwiększać*p
. Jeśli kod wywołujący używa*p
jako wskaźnika, że powinien odrzucić wyniki obliczeń, efektem optymalizacji może być pobranie kodu, który dałby sensowne wyniki w systemach, które działają w prawie każdy możliwy sposób z przepełnieniem liczb całkowitych (pułapki mogą być brzydkie, ale przynajmniej byłoby rozsądne) i zamieniło go w kod, który może zachowywać się bezsensownie.źródło
Wydajność jest zwykłą wymówką, ale bez względu na to, niezdefiniowane zachowanie jest okropnym pomysłem na przenośność. W efekcie niezdefiniowane zachowania stają się niezweryfikowanymi, niepotwierdzonymi założeniami.
źródło