Wydaje się, że istnieją co najmniej dwa różne możliwe pytania. Jeden tak naprawdę dotyczy generalnie kompilatorów, a Java jest po prostu tylko przykładem tego gatunku. Drugi jest bardziej specyficzny dla Javy, którego używa określone kody bajtów.
Kompilatory w ogóle
Rozważmy najpierw ogólne pytanie: dlaczego kompilator miałby używać pośredniej reprezentacji w procesie kompilowania kodu źródłowego, aby działał na określonym procesorze?
Redukcja złożoności
Jedna odpowiedź na to pytanie jest dość prosta: przekształca problem O (N * M) w problem O (N + M).
Jeśli otrzymamy N języków źródłowych i M celów, a każdy kompilator jest całkowicie niezależny, potrzebujemy kompilatorów N * M do przetłumaczenia wszystkich tych języków źródłowych na wszystkie te cele (gdzie „cel” jest czymś w rodzaju kombinacji procesor i system operacyjny).
Jeśli jednak wszystkie te kompilatory zgadzają się na wspólną reprezentację pośrednią, wówczas możemy mieć N frontonów kompilatora, które tłumaczą języki źródłowe na reprezentację pośrednią, i M back endy kompilatora, które tłumaczą reprezentację pośrednią na coś odpowiedniego dla konkretnego celu.
Segmentacja problemu
Co więcej, dzieli problem na dwie mniej lub bardziej ekskluzywne domeny. Ludzie, którzy znają / troszczą się o projektowanie języka, parsowanie i tego typu rzeczy, mogą skoncentrować się na interfejsach kompilatora, podczas gdy ludzie, którzy wiedzą o zestawach instrukcji, projektowaniu procesorów i podobnych rzeczach mogą skoncentrować się na zapleczu.
Na przykład, biorąc pod uwagę coś takiego jak LLVM, mamy wiele interfejsów dla różnych języków. Posiadamy również zaplecze dla wielu różnych procesorów. Język facet może napisać nowy interfejs dla swojego języka i szybko wspierać wiele celów. Facet zajmujący się procesorem może napisać nowy back-end dla swojego obiektu docelowego bez zajmowania się projektowaniem języka, parsowaniem itp.
Rozdzielanie kompilatorów na front i back end z pośrednią reprezentacją do komunikacji między nimi nie jest oryginalne w Javie. Od dawna jest to dość powszechna praktyka (zresztą na długo przed pojawieniem się Java).
Modele dystrybucji
W zakresie, w jakim Java dodała coś nowego w tym względzie, było to w modelu dystrybucji. W szczególności, mimo że kompilatory były przez długi czas wewnętrznie dzielone na części frontonu i back-endu, zwykle były one dystrybuowane jako pojedynczy produkt. Na przykład, jeśli kupiłeś kompilator Microsoft C, wewnętrznie miał on „C1” i „C2”, które były odpowiednio frontonem i back-endem - ale kupiłeś tylko „Microsoft C”, który obejmował oba sztuk (z „sterownikiem kompilatora”, który koordynował operacje między nimi). Mimo że kompilator został zbudowany w dwóch częściach, dla zwykłego programisty korzystającego z kompilatora była to tylko jedna rzecz, która tłumaczyła się z kodu źródłowego na kod obiektowy, bez niczego pomiędzy nimi.
Zamiast tego Java dystrybuowała front-end w Java Development Kit, a back-end w Java Virtual Machine. Każdy użytkownik Java miał zaplecze kompilatora, aby celować w dowolny system, z którego korzystał. Programiści Java dystrybuowali kod w formacie pośrednim, więc kiedy użytkownik go załadował, JVM zrobił wszystko, co było konieczne, aby wykonać go na konkretnej maszynie.
Precedensy
Zauważ, że ten model dystrybucji również nie był zupełnie nowy. Na przykład system P UCSD działał podobnie: interfejsy kompilatora produkowały kod P, a każda kopia systemu P zawierała maszynę wirtualną, która zrobiła wszystko, co było konieczne do wykonania kodu P na tym konkretnym celu 1 .
Kod bajtowy Java
Kod bajtu Java jest dość podobny do kodu P. To w zasadzie instrukcje dla dość prostej maszyny. Ta maszyna ma być abstrakcją istniejących maszyn, więc dość szybko można ją szybko przełożyć na niemal każdy konkretny cel. Łatwość tłumaczenia była ważna na początku, ponieważ pierwotnie zamierzano interpretować kody bajtowe, podobnie jak zrobił to P-System (i tak, dokładnie tak działały wczesne wdrożenia).
Silne strony
Kod bajtowy Java jest łatwy do wytworzenia dla kompilatora. Jeśli (na przykład) masz dość typowe drzewo reprezentujące wyrażenie, zazwyczaj dość łatwo jest przejść przez drzewo i wygenerować kod dość bezpośrednio z tego, co znajdziesz w każdym węźle.
Kody bajtów Java są dość kompaktowe - w większości przypadków znacznie bardziej kompaktowe niż kod źródłowy lub kod maszynowy dla większości typowych procesorów (a zwłaszcza dla większości procesorów RISC, takich jak SPARC, które Sun sprzedał podczas projektowania Java). Było to wtedy szczególnie ważne, ponieważ jednym z głównych celów Java była obsługa apletów - kodu osadzonego na stronach internetowych, które zostaną pobrane przed wykonaniem - w czasie, gdy większość ludzi uzyskiwała dostęp do nas za pośrednictwem modemów przez linie telefoniczne około 28,8 kilobitów na sekundę (choć oczywiście sporo osób używa starszych, wolniejszych modemów).
Słabości
Główną słabością kodów bajtów Java jest to, że nie są one szczególnie ekspresyjne. Chociaż potrafią dość dobrze wyrażać koncepcje obecne w Javie, nie działają prawie tak dobrze w wyrażaniu koncepcji, które nie są częścią Java. Podobnie, chociaż na większości komputerów łatwo jest wykonać kody bajtowe, jest to o wiele trudniejsze w sposób, który w pełni wykorzystuje jakąkolwiek konkretną maszynę.
Na przykład, dość rutynową rzeczą jest, że jeśli naprawdę chcesz zoptymalizować kody bajtów Java, w zasadzie wykonujesz kilka inżynierii wstecznej, aby przetłumaczyć je wstecz z reprezentacji podobnej do kodu maszynowego i przekształcić je z powrotem w instrukcje SSA (lub coś podobnego) 2 . Następnie manipulujesz instrukcjami SSA w celu przeprowadzenia optymalizacji, a następnie przekładasz stamtąd na coś, co jest ukierunkowane na architekturę, na której naprawdę Ci zależy. Jednak nawet w przypadku tego dość złożonego procesu niektóre pojęcia obce dla Javy są wystarczająco trudne do wyrażenia, dlatego trudno jest przetłumaczyć z niektórych języków źródłowych na kod maszynowy, który działa (nawet blisko) optymalnie na większości typowych maszyn.
Podsumowanie
Jeśli zastanawiasz się, dlaczego ogólnie używać reprezentacji pośrednich, dwa główne czynniki to:
- Zmniejsz problem O (N * M) do problemu O (N + M) i
- Podziel problem na łatwiejsze do opanowania części.
Jeśli pytasz o specyfikę kodów bajtów Java i dlaczego wybrali tę konkretną reprezentację zamiast jakiejś innej, to powiedziałbym, że odpowiedź w dużej mierze wraca do ich pierwotnych zamiarów i ograniczeń sieci w tamtym czasie , co prowadzi do następujących priorytetów:
- Kompaktowa reprezentacja.
- Szybki i łatwy do odkodowania i wykonania.
- Szybkie i łatwe do wdrożenia na większości popularnych maszyn.
Zdolność do reprezentowania wielu języków lub wykonywania optymalnego dla wielu różnych celów była znacznie niższym priorytetem (jeśli w ogóle były one uważane za priorytety).
- Dlaczego więc najczęściej zapomina się o systemie P? Głównie sytuacja cenowa. System P sprzedawany całkiem przyzwoicie na Apple II, Commodore SuperPets itp. Gdy pojawił się komputer IBM, system P był obsługiwanym systemem operacyjnym, ale MS-DOS kosztował mniej (z punktu widzenia większości ludzi został wrzucony za darmo) i szybko udostępniono więcej programów, ponieważ pisali o tym Microsoft i IBM (między innymi).
- Na przykład tak działa Soot .