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 ++)?
50
nullptr
) nie jeden starał się zdefiniować zachowanie, pisząc i / lub przyjmując proponowaną specyfikację ". : cOdpowiedzi:
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.
źródło
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)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.
źródło
valgrind
, który pokazywałby dokładnie, gdzie użyto niezainicjowanej wartości. Nie można używaćvalgrind
kodu java, ponieważ środowisko wykonawcze wykonuje inicjalizację, dzięki czemuvalgrind
czeki s są bezużyteczne.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
źródło
a + b
na kompilację doadd b a
instrukcji natywnej w każdej sytuacji, zamiast potencjalnie wymagać od kompilatora symulacji innej formy arytmetyki liczb całkowitych ze znakiem.HashSet
jest cudowna.<<
może być trudnym przypadkiem.x << y
ocenia na pewną prawidłową wartość typu,int32_t
ale 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. ...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).
źródło
this
zerowy?” Sprawdza jakiś czas temu, z uwagi na to,this
żenullptr
jest UB, a zatem nigdy nie może się zdarzyć.)JVM i języki .NET mają to łatwe:
Istnieją jednak dobre punkty do wyboru:
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.
źródło
unsafe
słowo kluczowe lub atrybuty wSystem.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.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.
źródło
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ą:
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
int
powinna być naturalnego rozmiaru sugerowanego przez architekturę. Tak więc, jeśli programuję 32-bitowy VAX,int
prawdopodobnie powinienem mieć 32 bity, ale jeśli programuję 36-bitowy Univac,int
prawdopodobnie 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ń.
źródło
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.
źródło
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.
źródło
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
int
różnią się w zależności od systemu.Int
jest zależny od architektury i generalnie wybierany jako najszybszy, najbardziej wydajny typ danych większy niż achar
.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ść.
źródło
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ę.
źródło
a
zachowania 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.