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ą:
- 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.
- Zakazaj tego poziomu dostępu i zarządzaj, że każdy, kto chce robić takie rzeczy, musi używać języka asemblera.
- 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 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ń.