Dlaczego C ++ ma „niezdefiniowane zachowanie” (UB), a inne języki, takie jak C # lub Java, nie?

50

W tym artykule Przepełnienie stosu wymieniono dość kompleksową listę sytuacji, w których specyfikacja języka C / C ++ deklaruje, że jest „niezdefiniowanym zachowaniem”. Chcę jednak zrozumieć, dlaczego inne współczesne języki, takie jak C # lub Java, nie mają pojęcia „niezdefiniowane zachowanie”. Czy to oznacza, że ​​projektant kompilatora może kontrolować wszystkie możliwe scenariusze (C # i Java), czy nie (C i C ++)?

Sisir
źródło
3
a jednak ten post SO odnosi się do niezdefiniowanego zachowania nawet w specyfikacji Java!
gbjbaanb
„Dlaczego C ++ ma„ niezdefiniowane zachowanie ”” Niestety wydaje się, że jest to jedno z tych pytań, na które trudno jest odpowiedzieć obiektywnie, poza stwierdzeniem ”, ponieważ z powodów X, Y i / lub Z (z których wszystkie mogą być nullptr) nie jeden starał się zdefiniować zachowanie, pisząc i / lub przyjmując proponowaną specyfikację ". : c
code_dredd
Zakwestionowałbym to założenie. Przynajmniej C # ma „niebezpieczny” kod. Microsoft pisze „W pewnym sensie pisanie niebezpiecznego kodu przypomina pisanie kodu C w programie C #” i podaje przykładowe powody, dla których warto by to zrobić: w celu uzyskania dostępu do sprzętu lub systemu operacyjnego i szybkości. Do tego właśnie wymyślono C (do diabła, napisali system operacyjny w C!), Więc masz to.
Peter - Przywróć Monikę

Odpowiedzi:

72

Nieokreślone zachowanie jest jedną z tych rzeczy, które zostały uznane za bardzo zły pomysł tylko z perspektywy czasu.

Pierwsze kompilatory były wspaniałymi osiągnięciami iz radością przyjęły ulepszenia w stosunku do alternatywy - języka maszynowego lub programowania w asemblerze. Problemy z tym były dobrze znane, a języki wysokiego poziomu zostały wymyślone specjalnie w celu rozwiązania tych znanych problemów. (Entuzjazm w tym czasie był tak wielki, że czasami HLL okrzyknięto „końcem programowania” - jakby odtąd musieliśmy tylko trywialnie zapisywać to, czego chcieliśmy, a kompilator wykonałby całą prawdziwą pracę.)

Dopiero później zdaliśmy sobie sprawę z nowszych problemów związanych z nowszym podejściem. Oddalenie się od rzeczywistej maszyny, na której działa kod, oznacza, że ​​istnieje większe prawdopodobieństwo, że rzeczy po cichu nie zrobią tego, czego się spodziewaliśmy. Na przykład przydzielenie zmiennej zwykle pozostawia wartość początkową niezdefiniowaną; nie było to uważane za problem, ponieważ nie przydzieliłbyś zmiennej, gdybyś nie chciał trzymać w niej wartości, prawda? Z pewnością nie było zbyt wiele, by oczekiwać, że profesjonalni programiści nie zapomną przypisać wartości początkowej, prawda?

Okazało się, że przy większych bazach kodu i bardziej skomplikowanych strukturach, które stały się możliwe dzięki mocniejszym systemom programistycznym, tak, wielu programistów rzeczywiście od czasu do czasu dokonywało takich przeoczeń, a wynikające z tego nieokreślone zachowanie stało się poważnym problemem. Nawet dzisiaj większość wycieków bezpieczeństwa od drobnych do okropnych jest wynikiem niezdefiniowanego zachowania w takiej czy innej formie. (Powodem jest to, że zwykle niezdefiniowane zachowanie jest w rzeczywistości bardzo ściśle zdefiniowane przez rzeczy na następnym niższym poziomie w dziedzinie komputerów, a atakujący, którzy rozumieją ten poziom, mogą użyć tego pokoju, aby program nie tylko robił niezamierzone rzeczy, ale dokładnie rzeczy oni zamierzają.)

Odkąd to zauważyliśmy, istnieje ogólny zamiar wyeliminowania niezdefiniowanych zachowań z języków wysokiego poziomu, a Java była szczególnie dokładna w tym względzie (co było stosunkowo łatwe, ponieważ i tak zostało zaprojektowane do działania na specjalnie zaprojektowanej maszynie wirtualnej). Starszych języków, takich jak C, nie można łatwo tak zmodernizować bez utraty kompatybilności z ogromną ilością istniejącego kodu.

Edycja: Jak wskazano, wydajność jest kolejnym powodem. Niezdefiniowane zachowanie oznacza, że ​​autorzy kompilatorów mają dużą swobodę w wykorzystywaniu architektury docelowej, dzięki czemu każda implementacja ucieka od najszybszej możliwej implementacji każdej funkcji. Było to ważniejsze na wczorajszych słabo wyposażonych maszynach niż na dzisiaj, kiedy wynagrodzenie programisty jest często wąskim gardłem w rozwoju oprogramowania.

Kilian Foth
źródło
56
Nie sądzę, aby wiele osób ze społeczności C zgodziło się z tym stwierdzeniem. Jeśli chciałbyś zmodernizować C i zdefiniować niezdefiniowane zachowanie (np. Domyślnie zainicjować wszystko, wybrać kolejność oceny parametru funkcji itp.), Duża baza dobrze zachowanego kodu nadal działałaby doskonale. Zakłócony zostanie tylko kod, który nie byłby dzisiaj dobrze zdefiniowany. Z drugiej strony, jeśli pozostaniesz niezdefiniowany jak dzisiaj, kompilatory będą mogły swobodnie wykorzystywać nowe osiągnięcia w architekturze procesorów i optymalizację kodu.
Christophe
13
Główna część odpowiedzi nie wydaje mi się przekonująca. To znaczy, w zasadzie niemożliwe jest napisanie funkcji, która bezpiecznie doda dwie liczby (jak w int32_t add(int32_t x, int32_t y)) w C ++. Zwykłe argumenty wokół tego są związane z wydajnością, ale często przeplatają się z niektórymi argumentami przenośności (jak w „Napisz raz, uruchom ... na platformie, na której napisałeś ... i nigdzie indziej ;-)”). Z grubsza jeden argument może zatem brzmieć: Niektóre rzeczy są niezdefiniowane, ponieważ nie wiesz, czy korzystasz z 16-bitowego mikrokontrolera, czy z 64-bitowego serwera (słabego, ale nadal jest to argument)
Marco13
12
@ Marco13 Zgoda - i pozbywanie się problemu „niezdefiniowanego zachowania” poprzez zrobienie czegoś „określonego zachowania”, ale niekoniecznie tego, czego chciał użytkownik i bez ostrzeżenia, gdy to się stanie ”zamiast„ niezdefiniowanego zachowania ”to po prostu gra w gry z kodem-prawnikiem IMO .
alephzero
9
„Nawet dzisiaj większość wycieków z zabezpieczeń od drobnych do okropnych jest wynikiem niezdefiniowanego zachowania w takiej czy innej formie”. Wymagany cytat. Myślałem, że większość z nich to teraz zastrzyk XYZ.
Jozuego
34
„Niezdefiniowane zachowanie jest jedną z tych rzeczy, które zostały uznane za bardzo zły pomysł tylko z perspektywy czasu”. To Twoja opinia. Wielu (łącznie ze mną) tego nie udostępnia.
Lekkość wyścigów z Monicą
103

Zasadniczo dlatego, że projektanci Java i podobnych języków nie chcieli nieokreślonego zachowania w ich języku. Była to kompromis - dopuszczenie nieokreślonego zachowania może poprawić wydajność, ale projektanci języków priorytetowo potraktowali bezpieczeństwo i przewidywalność.

Na przykład, jeśli alokujesz tablicę w C, dane są niezdefiniowane. W Javie wszystkie bajty muszą być inicjowane na 0 (lub inną określoną wartość). Oznacza to, że środowisko wykonawcze musi przejść przez tablicę (operacja O (n)), podczas gdy C może wykonać alokację w jednej chwili. Tak więc C zawsze będzie szybsze dla takich operacji.

Jeśli kod wykorzystujący tablicę i tak zapełni go przed odczytem, ​​jest to w zasadzie marnowany wysiłek dla Javy. Ale w przypadku, gdy kod zostanie odczytany jako pierwszy, otrzymasz przewidywalne wyniki w Javie, ale nieprzewidywalne wyniki w C.

JacquesB
źródło
19
Doskonała prezentacja dylematu HLL: bezpieczeństwo i łatwość użytkowania a wydajność. Nie ma srebrnej kuli: istnieją przypadki użycia dla każdej strony.
Christophe
5
@Christophe Aby być uczciwym, istnieje znacznie lepsze podejście do problemu niż pozwalanie UB przejść całkowicie bezsporne, takie jak C i C ++. Możesz mieć bezpieczny, zarządzany język z lukami ratunkowymi na niebezpiecznym terytorium, aby aplikować tam, gdzie jest to korzystne. TBH, byłoby naprawdę miło móc po prostu skompilować mój program C / C ++ z flagą z napisem „wstaw dowolne drogie maszyny uruchomieniowe, których potrzebujesz, nie obchodzi mnie to, ale po prostu powiedz mi o WSZYSTKIM występującym UB . ”
Alexander
4
Dobrym przykładem struktury danych, która celowo odczytuje niezainicjowane lokalizacje, jest rzadka reprezentacja zestawu Briggsa i Torczona (np. Patrz codingplayground.blogspot.com/2009/03/... ) Inicjalizacja takiego zestawu to O (1) w C, ale O ( n) z wymuszoną inicjalizacją Java.
Arch D. Robison
9
Chociaż prawdą jest, że wymuszanie inicjalizacji danych sprawia, że ​​uszkodzone programy są znacznie bardziej przewidywalne, nie gwarantuje to zamierzonego zachowania: jeśli algorytm spodziewa się odczytać znaczące dane podczas błędnego odczytu domyślnie zainicjowanego zera, jest to tak samo błąd, jak gdyby miał poczytaj śmieci. W programie C / C ++ taki błąd byłby widoczny, uruchamiając proces poniżej valgrind, który pokazywałby dokładnie, gdzie użyto niezainicjowanej wartości. Nie można używać valgrindkodu java, ponieważ środowisko wykonawcze wykonuje inicjalizację, dzięki czemu valgrindczeki s są bezużyteczne.
cmaster
5
@cmaster Dlatego kompilator C # nie pozwala na czytanie z niezainicjowanych lokalizacji lokalnych. Nie ma potrzeby sprawdzania środowiska uruchomieniowego, nie ma potrzeby inicjowania, po prostu analiza czasu kompilacji. Jest to jednak kompromis - w niektórych przypadkach nie masz dobrego sposobu na rozgałęzienie się wokół potencjalnie nieprzypisanych mieszkańców. W praktyce nie znalazłem żadnych przypadków, w których nie byłby to zły projekt i lepiej go rozwiązać poprzez ponowne przemyślenie kodu, aby uniknąć skomplikowanego rozgałęzienia (co jest trudne do przeanalizowania przez ludzi), ale przynajmniej jest to możliwe.
Luaan
42

Niezdefiniowane zachowanie umożliwia znaczną optymalizację, dając kompilatorowi swobodę robienia czegoś dziwnego lub nieoczekiwanego (lub nawet normalnego) na określonych granicach lub w innych warunkach.

Zobacz http://blog.llvm.org/2011/05/what-every-c-programmer-should-know.html

Zastosowanie niezainicjowanej zmiennej: Jest to powszechnie znane jako źródło problemów w programach C i istnieje wiele narzędzi do ich wychwycenia: od ostrzeżeń kompilatora po analizatory statyczne i dynamiczne. Poprawia to wydajność, nie wymagając, aby wszystkie zmienne były inicjowane zerem, gdy wchodzą w zakres (tak jak Java). W przypadku większości zmiennych skalarnych spowodowałoby to niewielki narzut, ale tablice stosów i pamięć malloc'd spowodowałyby zestaw pamięci, co może być dość kosztowne, zwłaszcza że pamięć jest zwykle całkowicie nadpisana.


Przepełnienie całkowitą ze znakiem: jeśli arytmetyka typu „int” (na przykład) przepełnia się, wynik jest niezdefiniowany. Jednym z przykładów jest to, że „INT_MAX + 1” nie jest gwarantowane jako INT_MIN. Takie zachowanie umożliwia określone klasy optymalizacji, które są ważne dla niektórych kodów. Na przykład wiedza o tym, że INT_MAX + 1 jest niezdefiniowana, pozwala zoptymalizować „X + 1> X” do „true”. Znając przepełnienie mnożenia „nie można” (ponieważ byłoby to niezdefiniowane), można zoptymalizować „X * 2/2” do „X”. Choć mogą wydawać się to trywialne, tego rodzaju rzeczy są zwykle ujawniane przez inliniowanie i ekspansję makr. Ważniejszą optymalizacją, jaką pozwala na to, jest dla takich pętli „<=”:

for (i = 0; i <= N; ++i) { ... }

W tej pętli kompilator może założyć, że pętla będzie iterować dokładnie N + 1 razy, jeśli „i” nie zostanie zdefiniowane przy przepełnieniu, co pozwala na uruchomienie szerokiego zakresu optymalizacji pętli. Z drugiej strony, jeśli zmienna jest zdefiniowana jako po obejściu przepełnienia, kompilator musi założyć, że pętla jest prawdopodobnie nieskończona (co dzieje się, gdy N to INT_MAX) - co następnie wyłącza te ważne optymalizacje pętli. Dotyczy to szczególnie platform 64-bitowych, ponieważ tak wiele kodów używa „int” jako zmiennych indukcyjnych.

Erik Eidt
źródło
27
Oczywiście prawdziwym powodem, dla którego przepełnienie liczby całkowitej ze znakiem jest niezdefiniowane, jest to, że w momencie opracowania C istniały co najmniej trzy różne reprezentacje podpisanych liczb całkowitych w użyciu (uzupełnienie jednego, uzupełnienie drugiego, wielkość znaku i być może binarny offset) , i każdy daje inny wynik dla INT_MAX + 1. Uczynienie przelewu niezdefiniowanym pozwala a + bna kompilację do add b ainstrukcji natywnej w każdej sytuacji, zamiast potencjalnie wymagać od kompilatora symulacji innej formy arytmetyki liczb całkowitych ze znakiem.
Mark
2
Umożliwianie luźnego definiowania przelewów liczb całkowitych pozwala na znaczną optymalizację w przypadkach, w których wszystkie możliwe zachowania spełniałyby wymagania aplikacji . Większość tych optymalizacji zostanie jednak utracona, jeśli programiści będą musieli unikać przepełnienia liczb całkowitych za wszelką cenę.
supercat
5
@ superuper Jest to kolejny powód, dla którego unikanie niezdefiniowanych zachowań jest częstsze w nowszych językach - czas programisty jest ceniony o wiele bardziej niż czas procesora. Optymalizacje, które C może wykonywać dzięki UB, są w zasadzie bezcelowe na nowoczesnych komputerach stacjonarnych i znacznie utrudniają rozumowanie na temat kodu (nie wspominając o implikacjach bezpieczeństwa). Nawet w kodzie krytycznym pod względem wydajności możesz skorzystać z optymalizacji wysokiego poziomu, które byłyby nieco trudniejsze (lub nawet trudniejsze) w C. Mam własny programowy renderer 3D w C #, a możliwość korzystania np. Z a HashSetjest cudowna.
Luaan
2
@ superupat: Wrt_loosely defined_, logicznym wyborem dla przepełnienia liczb całkowitych byłoby wymaganie zachowania zdefiniowanego w implementacji . To istniejąca koncepcja i nie stanowi nadmiernego obciążenia dla wdrożeń. Podejrzewam, że większość uciekłaby przed „uzupełnieniem 2 z zawijaniem”. <<może być trudnym przypadkiem.
MSalters
@MSalters Istnieje proste i dobrze zbadane rozwiązanie, które nie jest ani zachowaniem niezdefiniowanym, ani zachowaniem zdefiniowanym w ramach implementacji: zachowanie niedeterministyczne. Oznacza to, że można powiedzieć „ x << yocenia na pewną prawidłową wartość typu, int32_tale nie powiemy, która”. Pozwala to implementatorom korzystać z szybkiego rozwiązania, ale nie działa jako fałszywy warunek wstępny pozwalający na optymalizację stylu podróży w czasie, ponieważ niedeterminizm jest ograniczony do wyniku tej jednej operacji - specyfikacja gwarantuje, że nie ma to widocznego wpływu na pamięć, zmienne zmienne itp. przez ocenę wyrażenia. ...
Mario Carneiro
20

We wczesnych dniach C panował wielki chaos. Różne kompilatory różnie traktowały język. Gdy było zainteresowanie napisaniem specyfikacji dla języka, specyfikacja ta musiałaby być dość kompatybilna wstecznie z C, na którym programiści polegali ze swoimi kompilatorami. Ale niektóre z tych szczegółów są nieprzenośne i ogólnie nie mają sensu, na przykład przy założeniu szczególnego charakteru lub układu danych. Dlatego standard C rezerwuje wiele szczegółów jako zachowanie niezdefiniowane lub określone w implementacji, co pozostawia dużą elastyczność autorom kompilatorów. C ++ opiera się na C, a także posiada niezdefiniowane zachowanie.

Java starała się być znacznie bezpieczniejszym i prostszym językiem niż C ++. Java definiuje semantykę języka w kategoriach dokładnej maszyny wirtualnej. To pozostawia niewiele miejsca na niezdefiniowane zachowanie, z drugiej strony sprawia, że ​​wymagania, które może być trudne do wykonania dla implementacji Java (np. Że przypisania referencji muszą być atomowe lub jak działają liczby całkowite). Tam, gdzie Java obsługuje potencjalnie niebezpieczne operacje, są one zwykle sprawdzane przez maszynę wirtualną w czasie wykonywania (na przykład niektóre rzutowania).

amon
źródło
Więc mówisz, że kompatybilność wsteczna jest jedynym powodem, dla którego C i C ++ nie wychodzą z niezdefiniowanych zachowań?
Sisir
3
To zdecydowanie jeden z większych, @Sisir. Nawet wśród doświadczonych programistów zdziwiłbyś się, jak wiele rzeczy, które nie powinny się zepsuć , psuje się, gdy kompilator zmienia sposób, w jaki obsługuje nieokreślone zachowanie. (Przykładowo, było trochę chaosu, gdy GCC zaczęło optymalizować, czy „jest thiszerowy?” Sprawdza jakiś czas temu, z uwagi na to, thisże nullptrjest UB, a zatem nigdy nie może się zdarzyć.)
Justin Time 2 Przywróć Monikę
9
@Sir, kolejnym dużym jest prędkość. We wczesnych latach C sprzęt był o wiele bardziej niejednorodny niż obecnie. Po prostu nie określając, co się stanie, gdy dodasz 1 do INT_MAX, możesz pozwolić kompilatorowi robić to, co jest najszybsze dla architektury (np. System jednego dopełniacza wygeneruje -INT_MAX, podczas gdy system dwóch dopełnień wytworzy INT_MIN). Podobnie, nie określając, co dzieje się, gdy czytasz poza koniec tablicy, możesz sprawić, że system z ochroną pamięci zakończy program, podczas gdy taki bez nie będzie musiał implementować kosztownego sprawdzania granic środowiska uruchomieniowego.
Mark
14

JVM i języki .NET mają to łatwe:

  1. Nie muszą być w stanie pracować bezpośrednio ze sprzętem.
  2. Muszą współpracować tylko z nowoczesnymi systemami komputerowymi i serwerowymi lub względnie podobnymi urządzeniami lub przynajmniej urządzeniami dla nich zaprojektowanymi.
  3. Mogą narzucić zbieranie pamięci dla całej pamięci i wymusić inicjalizację, co zapewnia bezpieczeństwo wskaźnika.
  4. Zostały one określone przez jednego aktora, który również zapewnił jedno ostateczne wdrożenie.
  5. Mogą wybrać bezpieczeństwo zamiast wydajności.

Istnieją jednak dobre punkty do wyboru:

  1. Programowanie systemów to zupełnie inna gra, a bezkompromisowa optymalizacja do programowania aplikacji jest rozsądna.
  2. Wprawdzie cały czas jest mniej egzotycznego sprzętu, ale małe systemy wbudowane pozostaną.
  3. GC nie nadaje się do zasobów nie zamieniających się i wymienia znacznie więcej miejsca na dobrą wydajność. I większość (ale nie prawie wszystkie) wymuszone inicjalizacje można zoptymalizować.
  4. Większa konkurencja ma swoje zalety, ale komitety oznaczają kompromis.
  5. Wszystkie te granice kontrole nie sumują się, chociaż większość z nich może być zoptymalizowana precz. Sprawdzanie wskaźnika zerowego można przeważnie wykonać poprzez pułapkę dostępu dla zerowego obciążenia dzięki wirtualnej przestrzeni adresowej, chociaż optymalizacja jest nadal hamowana.

Tam, gdzie dostępne są luki ratunkowe, zapraszają z powrotem pełne, niezdefiniowane zachowanie. Ale przynajmniej są one zwykle używane tylko w kilku bardzo krótkich odcinkach, które są łatwiejsze do ręcznej weryfikacji.

Deduplikator
źródło
3
W rzeczy samej. Programuję w C # dla mojej pracy. Co jakiś czas sięgam po jeden z niebezpiecznych młotów ( unsafesłowo kluczowe lub atrybuty w System.Runtime.InteropServices). Trzymając te rzeczy dla niewielu programistów, którzy wiedzą, jak debugować niezarządzane rzeczy, a także tak mało, jak to praktyczne, rozwiązujemy problemy. Minęło ponad 10 lat od ostatniego niebezpiecznego młota związanego z wydajnością, ale czasami musisz to zrobić, ponieważ dosłownie nie ma innego rozwiązania.
Jozuego
19
Często pracuję na platformie z urządzeń analogowych, w których sizeof (char) == sizeof (short) == sizeof (int) == sizeof (float) == 1. Ma także nasycające dodawanie (więc INT_MAX + 1 == INT_MAX) , a zaletą C jest to, że mogę mieć zgodny kompilator, który generuje rozsądny kod. Jeśli w obowiązującym języku powiedzmy, że dwójka uzupełnia się zawijaniem, każdy dodatek kończy się testem i odgałęzieniem, czymś, co nie jest starterem w części skoncentrowanej na DSP. To jest bieżąca część produkcyjna.
Dan Mills
5
@BenVoigt Niektórzy z nas żyją w świecie, w którym mały komputer może mieć 4k miejsca na kod, stały 8-poziomowy stos wywołań / zwrotów, 64 bajty pamięci RAM, zegar 1MHz i kosztuje <0,20 $ w ilości 1000. Nowoczesny telefon komórkowy to mały komputer z prawie nieograniczoną przestrzenią dyskową do wszystkich celów i celów, i można go traktować jak komputer. Nie cały świat jest wielordzeniowy i nie ma twardych ograniczeń w czasie rzeczywistym.
Dan Mills
2
@ DanMills: Nie mówię tu o nowoczesnych telefonach komórkowych z procesorami Arm Cortex A, mówiąc o „telefonach funkcyjnych” około 2002 r. Tak. 192 kB SRAM to znacznie więcej niż 64 bajty (co nie jest „małe”, ale „małe”), ale 192 kB również nie było dokładnie nazywane „nowoczesnym” komputerem lub serwerem od 30 lat. Również w dzisiejszych czasach 20 centów da ci MSP430 z dużo więcej niż 64 bajtami SRAM.
Ben Voigt
2
@BenVoigt 192kB może nie być pulpitem w ciągu ostatnich 30 lat, ale mogę was zapewnić, że całkowicie wystarczy serwowanie stron internetowych, co, jak twierdzę, czyni z tego rodzaju serwer samą definicję tego słowa. Faktem jest, że jest to całkowicie rozsądna (hojna, nawet) ilość pamięci RAM dla wielu wbudowanych aplikacji, które często zawierają konfiguracyjne serwery sieciowe. Jasne, prawdopodobnie nie używam na nim amazonu, ale po prostu mogę mieć lodówkę z crapware IOT na takim rdzeniu (z czasem i przestrzenią do stracenia). Nie potrzeba do tego języków interpretowanych ani JIT!
Dan Mills
8

Java i C # charakteryzują się dominującym dostawcą, przynajmniej na wczesnym etapie ich rozwoju. (Odpowiednio Sun i Microsoft). C i C ++ są różne; od samego początku mieli wiele konkurencyjnych wdrożeń. C działał szczególnie na egzotycznych platformach sprzętowych. W rezultacie występowały różnice między implementacjami. Komitety ISO, które ustandaryzowały C i C ++, mogą uzgodnić duży wspólny mianownik, ale na krawędziach, gdzie implementacje różnią się, normy pozostawiały miejsce na wdrożenie.

Wynika to również z faktu, że wybranie jednego zachowania może być kosztowne w przypadku architektur sprzętowych, które są skłonne do innego wyboru - endianowość jest oczywistym wyborem.

MSalters
źródło
Co dosłownie oznacza „duży wspólny mianownik” ? Czy mówisz o podzbiorach lub nadzbiórkach? Czy naprawdę masz na myśli wystarczająco dużo wspólnych czynników? Czy to jest jak najmniej wspólna wielokrotność czy największy wspólny czynnik? Jest to bardzo mylące dla nas robotów, które nie mówią ulicznego żargonu, tylko matematykę. :)
tchrist
@tchrist: Wspólne zachowanie jest podzbiorem, ale ten podzbiór jest dość abstrakcyjny. W wielu obszarach, których nie określa wspólny standard, rzeczywiste wdrożenia muszą dokonać wyboru. Teraz niektóre z tych wyborów są dość jasne, a zatem zdefiniowane w implementacji, ale inne są bardziej niejasne. Układ pamięci przy starcie jest przykładem: nie musi być wybór, ale nie jest jasne, w jaki sposób chcesz je udokumentować.
MSalters
2
Oryginalne C zostało wykonane przez jednego faceta. Z założenia miał już dużo UB. Z pewnością pogorszyło się, gdy C stał się popularny, ale UB był tam od samego początku. Pascal i Smalltalk miały znacznie mniej UB i były rozwijane w tym samym czasie. Główną zaletą C było to, że przenoszenie było niezwykle łatwe - wszystkie problemy z przenośnością zostały przekazane programistom aplikacji: P Przeniesiłem nawet prosty kompilator C na mój (wirtualny) procesor; zrobienie czegoś takiego jak LISP lub Smalltalk byłoby znacznie większym wysiłkiem (chociaż miałem ograniczony prototyp dla środowiska uruchomieniowego .NET :).
Luaan
@Luaan: Czy to byłby Kernighan czy Ritchie? I nie, nie miało nieokreślonego zachowania. Wiem, że miałem na biurku oryginalną dokumentację kompilatora AT&T. Wdrożenie zrobiło to, co zrobiło. Nie było rozróżnienia między zachowaniem nieokreślonym a nieokreślonym.
MSalters
4
@MSalters Ritchie był pierwszym facetem. Kernighan dołączył (niewiele) później. Cóż, nie miał „Nieokreślonego zachowania”, ponieważ ten termin jeszcze nie istniał. Ale zachowywał się tak samo, jak dziś nazywany byłby niezdefiniowany. Ponieważ C nie miał specyfikacji, nawet „nieokreślony” to odcinek :) Było to po prostu coś, o co kompilator nie dbał, a szczegóły zależały od programistów aplikacji. Nie został zaprojektowany do tworzenia aplikacji przenośnych , tylko kompilator miał być łatwy do przeniesienia.
Luaan
6

Prawdziwy powód sprowadza się do zasadniczej różnicy intencji między C i C ++ z jednej strony, a Javą i C # (tylko dla kilku przykładów) z drugiej. Z przyczyn historycznych większość dyskusji tutaj mówi o C, a nie C ++, ale (jak zapewne już wiesz) C ++ jest dość bezpośrednim potomkiem C, więc to, co mówi o C, dotyczy w równym stopniu C ++.

Mimo że są w dużej mierze zapomniane (a ich istnienie czasem nawet zaprzecza się), pierwsze wersje UNIX zostały napisane w języku asemblera. Wiele (jeśli nie wyłącznie) pierwotnym celem C było przeniesienie UNIXa z języka asemblera na język wyższego poziomu. Częścią intencji było napisanie jak największej części systemu operacyjnego w języku wyższego poziomu - lub spojrzenie na to z drugiej strony, aby zminimalizować ilość napisów w asemblerze.

Aby to osiągnąć, C musiał zapewnić prawie taki sam poziom dostępu do sprzętu jak język asemblera. PDP-11 (na przykład) zmapowane rejestry we / wy do określonych adresów. Na przykład przeczytałeś jedną lokalizację pamięci, aby sprawdzić, czy klawisz został naciśnięty na konsoli systemowej. W tej lokalizacji ustawiono jeden bit, gdy dane czekały na odczyt. Następnie odczytałeś bajt z innej określonej lokalizacji, aby pobrać kod ASCII naciśniętego klawisza.

Podobnie, jeśli chcesz wydrukować niektóre dane, sprawdzasz inną określoną lokalizację, a gdy urządzenie wyjściowe będzie gotowe, zapisujesz dane w innej określonej lokalizacji.

Aby obsługiwać pisanie sterowników dla takich urządzeń, C umożliwił określenie dowolnej lokalizacji przy użyciu jakiegoś typu liczby całkowitej, konwersję do wskaźnika oraz odczyt lub zapisanie tej lokalizacji w pamięci.

Oczywiście ma to dość poważny problem: nie każda maszyna na ziemi ma swoją pamięć ułożoną identycznie jak PDP-11 z początku lat siedemdziesiątych. Tak więc, gdy weźmiesz tę liczbę całkowitą, przekształcisz ją we wskaźnik, a następnie odczytasz lub zapiszesz za pomocą tego wskaźnika, nikt nie będzie w stanie zapewnić żadnej rozsądnej gwarancji, co otrzymasz. Dla oczywistego przykładu, czytanie i pisanie może być mapowane na osobne rejestry w sprzęcie, więc ty (w przeciwieństwie do normalnej pamięci), jeśli coś piszesz, a następnie spróbuj go odczytać ponownie, to, co czytasz, może nie pasować do tego, co napisałeś.

Widzę kilka możliwości, które pozostawiają:

  1. Zdefiniuj interfejs dla całego możliwego sprzętu - określ adresy bezwzględne wszystkich lokalizacji, które możesz chcieć odczytać lub napisać w celu interakcji ze sprzętem w jakikolwiek sposób.
  2. Zakazaj tego poziomu dostępu i zarządzaj, że każdy, kto chce robić takie rzeczy, musi używać języka asemblera.
  3. Zezwól innym na to, ale pozostaw im przeczytanie (na przykład) podręczników dotyczących sprzętu, na który są kierowani, i napisanie kodu pasującego do używanego sprzętu.

Z nich 1 wydaje się na tyle niedorzeczna, że ​​nie jest wart dalszej dyskusji. 2 w zasadzie odrzuca podstawową intencję języka. To pozostawia trzecią opcję jako zasadniczo jedyną, którą mogliby w ogóle rozważyć.

Kolejną kwestią, która pojawia się dość często, są rozmiary typów całkowitych. C zajmuje „pozycję”, która intpowinna być naturalnego rozmiaru sugerowanego przez architekturę. Tak więc, jeśli programuję 32-bitowy VAX, intprawdopodobnie powinienem mieć 32 bity, ale jeśli programuję 36-bitowy Univac, intprawdopodobnie powinien mieć 36 bitów (i tak dalej). Prawdopodobnie nie jest rozsądne (i może nawet nie być możliwe) napisanie systemu operacyjnego dla komputera 36-bitowego przy użyciu tylko typów, które mają gwarantowaną wielokrotność 8 bitów. Być może jestem po prostu powierzchowny, ale wydaje mi się, że gdybym pisał system operacyjny dla maszyny 36-bitowej, prawdopodobnie chciałbym użyć języka, który obsługuje typ 36-bitowy.

Z punktu widzenia języka prowadzi to do jeszcze bardziej nieokreślonego zachowania. Jeśli wezmę największą wartość, która zmieści się w 32 bitach, co się stanie, gdy dodam 1? Na typowym 32-bitowym sprzęcie będzie się przewracał (lub ewentualnie powodował jakąś awarię sprzętową). Z drugiej strony, jeśli działa na 36-bitowym sprzęcie, po prostu ... doda jeden. Jeśli język ma obsługiwać pisanie systemów operacyjnych, nie możesz zagwarantować żadnego z tych zachowań - musisz tylko pozwolić, aby zarówno rozmiary typów, jak i zachowanie przepełnienia różniły się między sobą.

Java i C # mogą to wszystko zignorować. Nie są przeznaczone do obsługi pisania systemów operacyjnych. Dzięki nim masz kilka możliwości. Jednym z nich jest sprawienie, aby sprzęt obsługiwał to, czego żądają - ponieważ wymagają typów 8, 16, 32 i 64 bitów, wystarczy zbudować sprzęt obsługujący te rozmiary. Inną oczywistą możliwością jest, aby język działał tylko na innym oprogramowaniu zapewniającym pożądane środowisko, bez względu na to, czego może chcieć sprzęt.

W większości przypadków nie jest to tak naprawdę wybór. Przeciwnie, wiele implementacji robi trochę z obu. Zazwyczaj Java jest uruchomiona na maszynie JVM działającej w systemie operacyjnym. Najczęściej system operacyjny jest napisany w C, a JVM w C ++. Jeśli JVM działa na procesorze ARM, istnieje spora szansa, że ​​procesor zawiera rozszerzenia Jazelle ARM, aby lepiej dostosować sprzęt do potrzeb Javy, więc mniej trzeba robić w oprogramowaniu, a kod Java działa szybciej (lub mniej w każdym razie powoli).

Podsumowanie

C i C ++ mają niezdefiniowane zachowanie, ponieważ nikt nie zdefiniował akceptowalnej alternatywy, która pozwala im robić to, co zamierzają. C # i Java mają inne podejście, ale to podejście słabo (jeśli w ogóle) pasuje do celów C i C ++. W szczególności żadne nie wydaje się stanowić rozsądnego sposobu pisania oprogramowania systemowego (takiego jak system operacyjny) na większości dowolnie wybranych urządzeń. Oba zazwyczaj zależą od udogodnień zapewnianych przez istniejące oprogramowanie systemowe (zwykle napisane w C lub C ++) do wykonywania swoich zadań.

Jerry Coffin
źródło
4

Autorzy standardu C oczekiwali, że czytelnicy rozpoznają coś, co uważali za oczywiste, i nawiązali do opublikowanego uzasadnienia, ale nie powiedzieli wprost: Komitet nie powinien zamawiać autorów kompilatorów, aby spełnić potrzeby swoich klientów, ponieważ klienci powinni wiedzieć lepiej niż Komitet, jakie są ich potrzeby. Jeśli jest oczywiste, że oczekuje się, że kompilatory dla niektórych rodzajów plaform przetwarzają konstrukt w określony sposób, nikt nie powinien się przejmować, czy Standard mówi, że konstrukt wywołuje Nieokreślone Zachowanie. Niewykonanie przez Normę nakazu, aby zgodne kompilatory przetwarzały fragment kodu w żaden sposób użyteczny, w żaden sposób nie oznacza, że ​​programiści powinni chcieć kupować kompilatory, które tego nie robią.

Takie podejście do projektowania języka sprawdza się bardzo dobrze w świecie, w którym autorzy kompilatorów muszą sprzedawać swoje towary płacącym klientom. Zupełnie rozpada się w świecie, w którym autorzy kompilatorów są odizolowani od efektów rynkowych. Wątpliwe jest, aby kiedykolwiek istniały odpowiednie warunki rynkowe, aby sterować językiem w taki sposób, w jaki stał się popularny w latach 90., a jeszcze bardziej wątpliwe, aby każdy rozsądny projektant języków chciałby polegać na takich warunkach rynkowych.

supercat
źródło
Wydaje mi się, że opisałeś tutaj coś ważnego, ale mi się to wymyka. Czy możesz wyjaśnić swoją odpowiedź? Zwłaszcza drugi akapit: mówi, że warunki teraz i warunki wcześniejsze są różne, ale nie rozumiem; co dokładnie się zmieniło? Ponadto „droga” jest teraz inna niż wcześniej; może to też wyjaśnić?
anatolyg
4
Wygląda na to, że Twoja kampania zastąpi wszystkie niezdefiniowane zachowania nieokreślonymi lub coś bardziej ograniczonego wciąż się rozwija.
Deduplicator
1
@anatolyg: Jeśli jeszcze tego nie zrobiłeś, przeczytaj opublikowany dokument C Rationale (wpisz C99 Rationale w Google). Page 11 linie 23-29 mówią o „rynku”, a strony 13 linie 5-8 mówią o tym, co jest zamierzone w odniesieniu do przenośności. Jak myślisz, jak zareagowałby szef komercyjnej firmy zajmującej się kompilatorami, gdyby autor kompilatora powiedział programistom, którzy narzekali, że optymalizator złamał kod, że każdy inny kompilator poradził sobie z tym, że ich kod został „zepsuty”, ponieważ wykonuje on czynności nie zdefiniowane w standardzie, i odmówił wsparcia, ponieważ promowałoby to kontynuację ...
supercat
1
... użycie takich konstrukcji? Taki punkt widzenia jest łatwo widoczny na tablicach pomocniczych clang i gcc i służył do powstrzymania rozwoju wewnętrznych elementów, które mogłyby ułatwić optymalizację znacznie łatwiej i bezpieczniej niż zepsuty język gcc i clang chcą wspierać.
supercat
1
@ supercat: Marnujesz oddech narzekając na dostawców kompilatora. Może skierujesz swoje obawy do komisji językowych? Jeśli się z tobą zgodzą, zostanie wydana errata, której możesz użyć, aby pokonać zespoły kompilatorów nad głową. Ten proces jest znacznie szybszy niż opracowanie nowej wersji języka. Ale jeśli się nie zgodzą, przynajmniej dostaniesz rzeczywiste powody, podczas gdy autorzy kompilatora będą powtarzać (w kółko) „Nie oznacziliśmy tego kodu jako złamanego, decyzję podjęła komisja językowa, a my postępuj zgodnie z ich decyzją ”.
Ben Voigt
3

Zarówno C ++, jak i c mają opisowe standardy (w każdym razie wersje ISO).

Które istnieją tylko po to, aby wyjaśnić, jak działają języki, i aby zapewnić jedno odniesienie do tego, czym jest język. Zazwyczaj wiodącą rolę odgrywają dostawcy kompilatorów i autorzy bibliotek, a niektóre sugestie są uwzględniane w głównym standardzie ISO.

Java i C # (lub Visual C #, co, jak zakładam, masz na myśli) mają normatywne normy. Mówią ci, co jest w języku zdecydowanie z góry, jak to działa i co jest uważane za dozwolone zachowanie.

Co ważniejsze, Java faktycznie ma „implementację referencyjną” w Open-JDK. (Myślę, że Roslyn liczy się jako implementacja referencyjna Visual C #, ale nie mogła znaleźć źródła tego.)

W przypadku Javy, jeśli w standardzie występuje niejasność, a Open-JDK robi to w określony sposób. Sposób, w jaki robi to Open-JDK, jest standardem.

bobsburner
źródło
Sytuacja jest gorsza: nie sądzę, aby Komitet kiedykolwiek osiągnął konsensus co do tego, czy ma być opisowy czy nakazowy.
supercat
1

Niezdefiniowane zachowanie umożliwia kompilatorowi generowanie bardzo wydajnego kodu dla różnych architektów. Odpowiedź Erika wspomina o optymalizacji, ale wykracza poza to.

Na przykład, sygnalizowane przepełnienia są niezdefiniowanym zachowaniem w C. W praktyce oczekiwano, że kompilator wygeneruje prosty podpisany kod operacji dodawania dla procesora do wykonania, a zachowanie będzie takie, jakie zrobił ten konkretny procesor.

Dzięki temu C działał bardzo dobrze i tworzył bardzo kompaktowy kod na większości architektur. Gdyby standard określał, że liczby całkowite ze znakiem muszą się przepełnić w pewien sposób, wówczas procesory, które zachowywałyby się inaczej, potrzebowałyby znacznie więcej kodu do wygenerowania prostego podpisanego dodania.

To jest powód wielu niezdefiniowanych zachowań w C i dlaczego rzeczy takie jak rozmiar intróżnią się w zależności od systemu. Intjest zależny od architektury i generalnie wybierany jako najszybszy, najbardziej wydajny typ danych większy niż a char.

Kiedy C był nowy, rozważania te były ważne. Komputery były mniej wydajne, często miały ograniczoną prędkość przetwarzania i pamięć. C było używane tam, gdzie wydajność naprawdę miała znaczenie, i oczekiwano, że programiści zrozumieją, w jaki sposób komputery działają wystarczająco dobrze, aby wiedzieć, jakie byłyby te niezdefiniowane zachowania w ich systemach.

Późniejsze języki, takie jak Java i C #, wolą eliminować niezdefiniowane zachowanie niż surową wydajność.

użytkownik
źródło
-5

W pewnym sensie Java też to ma. Załóżmy, że podałeś niepoprawny komparator do Arrays.sort. Może rzucać wyjątkiem, że to wykrywa. W przeciwnym razie posortuje tablicę w jakiś sposób, który nie jest gwarantowany.

Podobnie, jeśli zmodyfikujesz zmienną z kilku wątków, wyniki są również nieprzewidywalne.

C ++ poszedł o krok dalej, aby stworzyć nieokreśloną więcej sytuacji (a raczej java zdecydowała się zdefiniować więcej operacji) i nadać mu nazwę.

RiaD
źródło
4
To nie jest nieokreślone zachowanie, o którym tutaj mówimy. „Nieprawidłowe komparatory” występują w dwóch typach: tych, które określają całkowitą kolejność i tych, które nie. Jeśli podasz komparator, który konsekwentnie definiuje względną kolejność elementów, zachowanie jest dobrze określone, po prostu nie jest to zachowanie, którego chciał programista. Jeśli podasz komparator, który nie jest spójny w kwestii względnej kolejności, zachowanie jest nadal dobrze zdefiniowane: funkcja sortowania zgłosi wyjątek (który prawdopodobnie również nie jest zachowaniem pożądanym przez programistę).
Mark
2
Jeśli chodzi o modyfikowanie zmiennych, warunki wyścigu zasadniczo nie są uważane za zachowanie niezdefiniowane. Nie znam szczegółów dotyczących tego, jak Java radzi sobie z przypisaniami do współużytkowanych danych, ale znając ogólną filozofię języka, jestem prawie pewien, że musi być atomowy. Jednoczesne przypisanie 53 i 71 do azachowania byłoby niezdefiniowane, gdybyś mógł z niego uzyskać 51 lub 73, ale jeśli możesz uzyskać tylko 53 lub 71, jest to dobrze zdefiniowane.
Mark
@ Mark Z fragmentami danych większymi niż natywny rozmiar słowa w systemie (na przykład zmienna 32-bitowa w 16-bitowym systemie wielkości słów), możliwe jest posiadanie architektury wymagającej osobnego przechowywania każdej 16-bitowej części. (SIMD to kolejna potencjalna taka sytuacja.) W takim przypadku nawet proste przypisanie poziomu kodu źródłowego niekoniecznie musi być atomowe, chyba że kompilator dołoży szczególnych starań, aby zapewnić wykonanie atomowe.
CVn