Filozofia stojąca za niezdefiniowanym zachowaniem


59

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:

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.


7
kto kiedykolwiek słyszał o deterministycznym komputerze?
sova,

1
jak wskazuje doskonała odpowiedź litb programmers.stackexchange.com/a/99741/192238 , tytuł i treść tego pytania wydają się nieco niedopasowane: „zachowania, które kompilatorzy mogą wdrożyć na swój własny sposób” są zwykle określane jako zdefiniowane w implementacji . pewnie, autor implementacji może zdefiniować rzeczywistą UB, ale najczęściej nie zawracają sobie głowy (i wszystko optymalizują itd.)
underscore_d

Odpowiedzi:


49

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:

  1. Język powinien obsługiwać jak najszerszy zakres sprzętu (najlepiej cały „rozsądny” sprzęt do rozsądnego dolnego limitu).
  2. Język powinien obsługiwać pisanie możliwie szerokiej gamy oprogramowania dla danego środowiska.

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.


25
Myślę, że twierdzenia Godela to czerwony śledź. Zajmują się one sprawdzaniem systemu z własnych aksjomatów, co nie ma tutaj miejsca: C nie musi być określany w C. Jest całkiem możliwe, aby mieć całkowicie określony język (rozważ maszynę Turinga).
poolie

9
Przepraszam, ale obawiam się, że całkowicie nie zrozumiałeś Twierdzeń Godela. Dotyczą niemożności udowodnienia wszystkich prawdziwych stwierdzeń w spójnym systemie logicznym; pod względem obliczeniowym twierdzenie o niekompletności jest analogiczne do stwierdzenia, że ​​istnieją problemy, których żaden program nie może rozwiązać - problemy są analogiczne do prawdziwych instrukcji, programów do proofów i modelu obliczeń do układu logicznego. Nie ma żadnego związku z nieokreślonym zachowaniem. Zobacz wyjaśnienie analogii tutaj: scottaaronson.com/blog/?p=710 .
Alex ten Brink

5
Powinienem zauważyć, że maszyna Von Neumann nie jest wymagana do implementacji C. Opracowanie implementacji C dla architektury Harvarda jest całkowicie możliwe (a nawet bardzo trudne) (i nie zdziwiłbym się, gdy zobaczyłem wiele takich implementacji w systemach wbudowanych)
bdonlan

1
Niestety, współczesna filozofia kompilatora C przenosi UB na zupełnie nowy poziom. Nawet w przypadkach, gdy program był przygotowany na poradzenie sobie z prawie wszystkimi prawdopodobnymi „naturalnymi” konsekwencjami określonej formy niezdefiniowanego zachowania, a te, z którymi nie mógł sobie poradzić, byłyby przynajmniej rozpoznawalne (np. Uwięzione przepełnienie liczb całkowitych), nowa filozofia faworyzuje pomijając dowolny kod, który nie mógłby zostać wykonany, chyba że wystąpiłby UB, zamieniając kod, który zachowywałby się poprawnie przy każdej większości implementacji w kod, który jest „bardziej wydajny”, ale po prostu błędny.
supercat,

20

Uzasadnienie C wyjaśnia

Terminy nieokreślone zachowanie, niezdefiniowane zachowanie i zachowanie zdefiniowane w implementacji są używane do kategoryzacji wyników pisania programów, których właściwości Standard nie opisuje lub nie może całkowicie opisać. Celem przyjęcia tej kategoryzacji jest dopuszczenie pewnej różnorodności wśród implementacji, która pozwala, aby jakość implementacji była aktywną siłą na rynku, a także dopuszczenie niektórych popularnych rozszerzeń , bez usuwania cła zgodności ze standardem. Dodatek F do katalogu Standardowego zawiera zachowania, które należą do jednej z tych trzech kategorii.

Nieokreślone zachowanie daje implementatorowi pewną swobodę w tłumaczeniu programów. Ta szerokość geograficzna nie rozciąga się na tyle, że nie udało się przetłumaczyć programu.

Niezdefiniowane zachowanie pozwala licencji na implementację nie wychwytywać niektórych błędów programu, które są trudne do zdiagnozowania. Identyfikuje również obszary możliwego rozszerzenia języka zgodnego: implementator może ulepszyć język, podając definicję oficjalnie niezdefiniowanego zachowania.

Zachowanie zdefiniowane w implementacji daje implementatorowi swobodę wyboru odpowiedniego podejścia, ale wymaga wyjaśnienia użytkownikowi tego wyboru. Zachowania oznaczone jako zdefiniowane w implementacji to na ogół te, w których użytkownik może podejmować znaczące decyzje dotyczące kodowania na podstawie definicji implementacji. Realizatorzy powinni wziąć pod uwagę to kryterium przy podejmowaniu decyzji o tym, jak szeroka powinna być definicja implementacji. Podobnie jak w przypadku nieokreślonego zachowania, zwykła niemożność przetłumaczenia źródła zawierającego zachowanie zdefiniowane w implementacji nie jest odpowiednią odpowiedzią.

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:

Kod C może być nieprzenośny. Chociaż starał się dać programistom możliwość pisania prawdziwie przenośnych programów, Komitet nie chciał zmusić programistów do przenośnego pisania, aby wykluczyć użycie C jako `` wysokiego poziomu asemblera '': możliwość pisania specyficznego dla maszyny Kod jest jedną z mocnych stron C. To właśnie ta zasada w dużej mierze motywuje rozróżnienie między programem ściśle zgodnym a programem zgodnym (§ 1.7).

A w 1.7 zauważa

Trzykrotna definicja zgodności służy do poszerzenia populacji programów zgodnych i rozróżnienia programów zgodnych za pomocą pojedynczej implementacji i przenośnych programów zgodnych.

Program ściśle zgodny jest innym terminem określającym maksymalnie przenośny program. Celem jest zapewnienie programiście szansy na stworzenie potężnych programów C, które są również wysoce przenośne, bez poniżania doskonale użytecznych programów C, które nie są przenośne. Zatem przysłówek ściśle.

Tak więc ten mały brudny program, który działa idealnie w GCC, nadal jest zgodny !


15

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.


+1 za drugi akapit, który pokazuje coś, co byłoby niezręczne, gdyby określono jako zachowanie zdefiniowane w ramach implementacji.
David Thornley,

3
Przesunięcie bitów to tylko przykład akceptacji niezdefiniowanego zachowania kompilatora i wykorzystania możliwości sprzętowych. Określenie wyniku C dla przesunięcia bitowego byłoby trywialne, gdy liczba jest większa niż typ, ale jest kosztowna do wdrożenia na niektórych urządzeniach.
mattnz

7

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ę.


3
Język C mógł zostać określony w taki sposób, że zawsze musiał używać odczytów bajt po bajcie w systemach z ograniczeniami wyrównania, i taki, że musiał zapewniać pułapki wyjątków z dobrze zdefiniowanym zachowaniem w przypadku nieprawidłowego dostępu do adresu. Ale oczywiście wszystko to byłoby niezwykle kosztowne (pod względem wielkości kodu, złożoności i wydajności) i nie przyniosłoby żadnych korzyści rozsądnemu, poprawnemu kodowi.
R ..

6

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ą.


1
Co? Co wyjątki mają wspólnego z wbudowanym sprzętem?
Mason Wheeler,

2
Wyjątki mogą blokować system w sposób, który jest bardzo zły dla systemów wbudowanych, które muszą szybko reagować. Są sytuacje, w których fałszywy odczyt jest o wiele mniej szkodliwy niż spowolniony system.
Inżynier światowy,

1
@Mason: Ponieważ sprzęt musi przechwycić nieprawidłowy dostęp. System Windows łatwo wykrywa naruszenie zasad dostępu, a trudniej jest wbudować sprzęt bez systemu operacyjnego, który mógłby zrobić wszystko poza śmiercią.
DeadMG,

3
Pamiętaj również, że nie każdy procesor ma jednostkę MMU, która na początku chroni przed nieprawidłowym dostępem do sprzętu. Jeśli zaczniesz wymagać od swojego języka sprawdzania wszystkich dostępów do wskaźnika, musisz emulować MMU na procesorach bez niego - w ten sposób KAŻDY dostęp do pamięci staje się niezwykle drogi.
puszysty

4

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?


5
Podejrzewam, że myślisz o Uniksie - C był pierwotnie używany na PDP-11, który w rzeczywistości był dość konwencjonalnym standardem. Myślę jednak, że podstawowa idea jest jednak aktualna.
Jerry Coffin

@Jerry - tak, masz rację - ja się starzeję!
Martin Beckett,

Tak - obawiam się, że to najlepsze z nas.
Jerry Coffin,

4

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).


Zastanawiam się, co skłoniło zmieniającą się filozofię UB z „Zezwól programistom na korzystanie z zachowań ujawnionych przez ich platformę” na „Znajdź wymówki pozwalające kompilatorom na stosowanie całkowicie zwariowanych zachowań”? Zastanawiam się także, ile takich optymalizacji ostatecznie poprawia rozmiar kodu po zmodyfikowaniu kodu, aby działał pod nowym kompilatorem? Nie zdziwiłbym się, gdyby w wielu przypadkach jedynym efektem dodania takich „optymalizacji” do kompilatora było zmuszenie programistów do pisania większego i wolniejszego kodu, aby uniknąć kompilacji kompilatora.
supercat

Dryf w POV. Ludzie stali się mniej świadomi maszyny, na której działa ich program, bardziej zainteresowali się przenośnością, więc unikali zależnie od nieokreślonego, nieokreślonego i określonego dla implementacji zachowania. Na optymalizatorach pojawiła się presja, aby uzyskać jak najlepsze wyniki w testach porównawczych, a to oznacza wykorzystanie każdej łagodności pozostawionej przez specyfikę języków. Istnieje również fakt, że Internet - Usenet w tym czasie, obecnie SE - prawnicy językowi również mają tendencyjny pogląd na uzasadnienie i zachowanie twórców kompilatorów.
AProgrammer

1
To, co mnie ciekawi, to stwierdzenia, które widziałem, że „C zakłada, że ​​programiści nigdy nie będą angażować się w nieokreślone zachowania” - fakt, który historycznie nie był prawdą. Prawidłowe stwierdzenie brzmiałoby: „C założył, że programiści nie wyzwalaliby zachowania niezdefiniowanego przez standard, chyba że byliby przygotowani do radzenia sobie z naturalnymi konsekwencjami platformy dla tego zachowania. Biorąc pod uwagę, że C został zaprojektowany jako język programowania systemowego, duża część jego celu było umożliwienie programistom robienia rzeczy specyficznych dla systemu, nieokreślonych przez standard językowy; pomysł, że nigdy by tego nie zrobili, jest absurdalny
supercat 15.04.15

Dobrze jest, aby programiści podjęli dodatkowe wysiłki, aby zapewnić przenośność w przypadkach, w których różne platformy z natury robiłyby różne rzeczy , ale autorzy kompilatorów marnują czas każdego z nas, eliminując zachowania, których programiści historycznie mogliby oczekiwać, że będą wspólne dla wszystkich przyszłych kompilatorów. Podane liczby całkowite ii n, tak, że n < INT_BITSi i*(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ą?
supercat

Chociaż myślę, że byłoby dobrze, gdyby standard dopuszczał pułapki na wiele rzeczy, które nazywa UB (np. Przepełnienie liczb całkowitych), i istnieją dobre powody, dla których nie wymaga, aby pułapki robiły coś przewidywalnego, ale sądzę, że z każdego możliwego punktu widzenia standard zostałby ulepszony, gdyby wymagał, aby większość form UB albo przyniosła nieokreśloną wartość, albo udokumentowała fakt, że zastrzegają sobie prawo do zrobienia czegoś innego, bez konieczności bezwzględnego dokumentowania tego, co to może być. Kompilatory, który sprawił, że wszystko „UB” byłoby legalne, ale prawdopodobnie niekorzystne ...
Supercat

3

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.


Dodanie liczby całkowitej jest interesującym przypadkiem; poza możliwością zachowania pułapki, która w niektórych przypadkach byłaby przydatna, ale w innych przypadkach mogłaby spowodować losowe wykonanie kodu, istnieją sytuacje, w których kompilator miałby rozsądne wnioskowanie w oparciu o fakt, że nie podano przepełnienia liczb całkowitych. Na przykład kompilator, w którym intjest 16 bitów, a przesunięcia z rozszerzeniem znaku są drogie, można obliczyć (uchar1*uchar2) >> 4przy użyciu przesunięcia bez rozszerzenia znaku. Niestety, niektóre kompilatory rozszerzają wnioski nie tylko na wyniki, ale także na operandy.
supercat

2

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.


Pierwotnie wiele zachowań pozostawiono niezdefiniowanych, aby umożliwić możliwość, że różne systemy zrobiłyby różne rzeczy, w tym wyzwalanie pułapki sprzętowej za pomocą procedury obsługi, która może, ale nie musi być konfigurowalna (a może, jeśli nie skonfigurowana, powodować dowolnie nieprzewidywalne zachowanie). Wymaganie na przykład, aby przesunięcie w lewo wartości ujemnej nie było pułapką, spowodowałoby uszkodzenie dowolnego kodu, który został zaprojektowany dla systemu, w którym to zrobił i polegał na takim zachowaniu. Krótko mówiąc, pozostawiono je niezdefiniowane, aby nie uniemożliwić implementatorom zachowań, które uważali za przydatne .
supercat

Niestety, zostało to obrócone w taki sposób, że nawet kod, który wie, że działa na procesorze, który zrobiłby coś użytecznego w konkretnym przypadku, nie może skorzystać z takiego zachowania, ponieważ kompilatory mogą wykorzystać fakt, że standard C nie nie określa zachowania (chociaż platforma by to zrobiła), aby zastosować do kodu dziwne przeróbki świata.
supercat

1

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.


lub możesz przydzielić wszystkie wskaźniki jako weak_ptri unieważnić wszystkie odwołania do wskaźnika, który dostaje delete... och, czekaj, zbliżamy się do wyrzucania elementów bezużytecznych: /
Matthieu M.

boost::weak_ptrImplementacja jest całkiem dobrym szablonem na początek dla tego wzorca użytkowania. Zamiast śledzenia i unieważniania weak_ptrszewnętrznego, weak_ptrjust przyczynia się do shared_ptrsłabej liczby, a słaba liczba jest w zasadzie przelicznikiem samego wskaźnika. W ten sposób możesz anulować plik shared_ptrbez konieczności jego natychmiastowego usuwania. To nie jest idealne (nadal możesz mieć wiele przeterminowanych weak_ptrutrzymujących bazę shared_countbez uzasadnionego powodu), ale przynajmniej jest szybkie i wydajne.
puszysty

0

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.


Istniałaby semantyczna różnica między powiedzeniem „Kompilator może zachowywać się tak, jakby X był prawdą” a powiedzeniem „Każdy program, w którym X nie jest prawdą, angażuje się w Nieokreślone Zachowanie”, chociaż niestety standardy, które nie wyjaśniają rozróżnienia. W wielu sytuacjach, w tym na przykładzie aliasingu, poprzednie oświadczenie umożliwiłoby wiele optymalizacji kompilatora, które w innym przypadku byłyby niemożliwe; ta ostatnia pozwala na więcej „optymalizacji”, ale wiele z tych ostatnich optymalizacji jest rzeczą, której programiści nie chcieliby.
supercat

Na przykład, jeśli jakiś kod ustawia wartość foona 42, a następnie wywołuje metodę, która używa nielegalnie zmodyfikowanego wskaźnika do ustawienia foona 44, widzę korzyść z powiedzenia, że ​​do czasu następnego „prawidłowego” zapisu foopróba odczytania może być zgodna z prawem wydaj 42 lub 44, a wyrażenie podobne foo+foomoż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.
supercat,

0

Historycznie, niezdefiniowane zachowanie miało dwa podstawowe cele:

  1. Aby uniknąć wymagania od autorów kompilatora generowania kodu do obsługi warunków, które nigdy nie powinny wystąpić.

  2. 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 intkodem takim jak:

uint32_t foo(uint16_t q, int *p)
{
  if (q > 46340)
    *p++;
  return q*q;
}

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 *pjako 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.


-6

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.


7
OP określił to: „Moje pytanie nie dotyczy tego, co jest niezdefiniowanym zachowaniem, czy też jest naprawdę złe. Znam niebezpieczeństwa i większość istotnych niezdefiniowanych cytatów ze standardu, więc proszę powstrzymać się od publikowania odpowiedzi na temat tego, jak złe jest . ” Wygląda na to, że nie przeczytałeś pytania.
Etienne de Martel,
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.