В настоящата статия ще разгледаме понятията „компилатор“ и „интерпретатор“ – какво означават тези термини? Какви са разликите? Как инструментите за работа с някои от най-популярните в момента динамични езици ги използват? Ще направим кратко въведение в теорията на езиците за програмиране, като ще обърнем специално внимание на това какво отношение има тази теория към практическите задачи на един софтуерен разработчик. Ще покажем някои от задачите, които решават авторите на езикови компилатори и интерпретатори и ще дадем примери къде в ежедневието може да се сблъскаме с подобни задачи. За пример ще използваме V8 JavaScript интерпретатора, голяма част от кода на който е написан на C++. Какво могат да научат софтуерните инженери от тези техники и похвати, разработени в областта на програмните езици? Прочетете нататък, за да разберете.

Въведение

Преди около година някак се оказах във водовъртежа на един от най-използваните в момента JavaScript “двигатели” (engines) – V8, който задвижва гиганти като Chrome, Node.js и Electron. Давайки най-доброто от себе си да допринеса за този огромен проект и при все, че не е първият голям C++ проект, по който работя, осъзнах колко много специфична теория може да приложи човек при работата си и колко много може да научи за системното програмиране като цяло от тази област. Тази статия е опит да убедя читателя в това и да повиша малко интереса по темата, предвид оскъдните публикации на български език. Също така това е възможност да популяризирам любимата си книга на тема езици за програмиране “Crafting Interpreters” и да споделя радостта си от факта, че във ФМИ на СУ за пръв път от поне 5 години насам ще има курс на тема “Реализация на езици за програмиране” (като всички други лекции в държавен университет, и тези са достъпни за външни слушатели). И накрая, да мотивирам читателя да се докосне поне за малко до безкрайната като лента от машина на Тюринг област на езиците за програмиране и да се порадва на факта, че те са доста по-прости за учене и интерпретиране от естествените езици, но не по-малко полезни.

Компилатор и интерпретатор – що е то?

Полезно е читателят да се запознае с основната разлика между традиционните компилатори и интерпретатори. И двете понятия описват програми, чиято задача се свежда до “превеждане” на изходния код на дадено приложение до език, разбираем за машината. В този смисъл потребителите са други програмисти, а целева платформа ще наричаме машината, за която бива подготвян изходният код. Класическият компилатор има за задача да преведе изходния код (най-често написан на език от по-високо ниво) до машинен код или друг език от по-ниско ниво. Резултатът от неговото изпълнение представлява изпълним файл, специфичен за дадена хардуерна и софтуерна платформа. Например приложение, компилирано за Arm не може да се изпълни директно на процесор с друга архитектура, а също изпълним файл за Windows не може да бъде пуснат под Linux без емулатор. Това определя основния недостатък на компилаторите, а именно липсата на преносимост между резултантните изпълними файлове. Защо обаче хората все пак използват компилатори? Тъй като компилаторът работи с цялата програма наведнъж, той има възможност да прави оптимизации на ниво програма и като цяло да генерира по-бърз код.

Интерпретаторът, за разлика от него, има за задача в реално време да преведе код, написан на високо ниво (най-често на динамичен език) до машинен код или код за виртуална машина. Това обуславя главната полза от използването на интерпретатори – пишем изходния код на нашето приложение веднъж, дистрибутираме го и той се изпълнява при всеки потребител, независимо от платформата му. Същественият недостатък на този подход е, че тъй като интерпретаторът много често няма достъп до цялата програма, неговият набор от възможни оптимизации е крайно ограничен. Това ни води и до практичното решение, което се използва в популярните езикови двигатели за някои от модерните напоследък динамични езици, а именно – комбинирана или “слоеста” архитектура. Схема на най-простата такава архитектура, имплементирана във V8, е показана на следващата фигура:

Първият етап от работата на двигателя е т.нар. “парсър” – той превръща текстовата линейна форма на изходния код в “абстрактно синтактично дърво” (Abstract syntax tree или AST) – структура, която носи смисъла на нашата програма, но подлежи на лесни трансформации с цел оптимизация.

Така построеното дърво влиза в интерпретатора, който генерира байткод за съответната виртуална машина. При изпълнението на байткода виртуалната машина събира данни за изпълнението на програмата, например за да установи кои функции са “горещи”, т. е. биват изпълнявани достатъчно често, за да оправдаят допълнителни оптимизации. Когато дадена функция бъде засечена като “гореща”, нейният код се подава на оптимизиращия компилатор (заедно с данни за състоянието на програмата), който прилага различни техники за неговото ускоряване. В резултат той генерира нова, оптимизирана версия на байткода, която в определени случаи може да се сравнява със скоростта на статично компилирано системно приложение.

Принципът за оптимизиращия компилатор може да се приложи на стъпки, като вместо един интерпретатор и един компилатор, архитектурата се състои от интерпретатор и няколко компилатора с нарастващо ниво на оптимизациите (и съответно нарастващи изисквания откъм процесорно време и памет за тяхната работа). Подробно описание на архитектурата на актуалните в момента двигатели за JavaScript (тези, задвижващи браузърите Chrome, Edge, Firefox и Safari) може да откриете в статията “JavaScript engine fundamentals: Shapes and Inline Caches”.

Защо компилатори?

Ще продължим изложението с една съпоставка между под-областите на теорията на езиците за програмиране и сфери от ИТ, в които съответните понятия намират приложение. Това дава едно по-широко разбиране на темите от тази теория, което излиза отвъд класическото схващане, че тази област е сложна и запазена за програмисти с магически умения.

Какви задачи ни поставят компилаторите?

Както читателят може да се убеди, елементи от теорията на програмните езици могат да намерят приложение в най-разнообразни други области на ИТ, което обосновава тяхното изучаване. Вече можем да преминем към по-подробно разглеждане на това какви задачи при създаването на един интерпретатор или компилатор могат да се решат с тези теоретични елементи. Както заглавието на статията подсказва, става дума за 7 елемента:

1. Дървета, графи и регулярни изрази

a. Етап на “сканиране” на кода – разпознаване на лексемите на езика, превръщането им от линейна в дървовидна структура, проверка за синтактични грешки и съобщаване на потребителя;
b. Изграждане на абстрактно синтактично дърво. За съжаление на авторите на интерпретатори, подобно дърво често се оказва граф, което усложнява алгоритмите, необходими за неговата обработка.

2. Статистика и евристика – вземане на решения за оптимизация на кода, особено съществени за JIT (Just-in-time) компилаторите, които взимат решения в реално време (runtime) за това кои функции се изпълняват достатъчно често, за да си заслужава отделянето на време за компилирането им до изпълним код спрямо интерпретирането им.

3. Конвейери за обработка на информацията – цялостна архитектура на интерпретатора/компилатора, в която намират място отделните, максимално изолирани етапи на превръщането на оригиналния изходен код от едно представяне в друго.

4. Компютърни архитектури – генериране на финалния код, алокация на регистри, оптимизации на ниво инструкции. Колкото повече физически архитектури поддържа компилаторът, толкова по-интересна е тази задача – как да се възползваме максимално от възможностите на конкретната платформа без да превръщаме 90% от кода си в написан на ръка асемблер? В процеса на решаването ѝ могат да бъдат взети различни решения за имплементацията на “вградените” езикови функции (builtins), например създаване на макро-асемблери (CodeStubAssembler) или дори нови езици (Torque). Е, поне не се налага да избирате перфектния front-end framework, нали?

На помощ в решаването на тези проблеми може да дойде и готов набор от инструменти, като например LLVM екосистемата (“Low-Level Virtual Machine”). Чудесен пример за това как тя може да се използва в съществуващ native проект може да видите в това видео от CG 2 Talk Code.

5. Паралелизация – оптимизиращи компилатори. Тук е важно да отговорим на въпроса каква част от работата на JIT компилатора може да бъде изпълнена паралелно с основната работа на двигателя, така че:

a. Да се запази валидността на програмата;
b. Да не “избодем очи, вместо да изпишем вежди” – ако отделяме повече време за синхронизация между нишките, отколкото спестяваме от паралелизирането на работата;
c. Да не заемaмe прекалено много от паметта на потребителя – тук отново “търгуваме” бързодействие за памет. Ако искаме да има малко синхронизация между нишките, трябва да копираме повече данни, част от които може да са ненужни в този момент. Различните JavaScript оптимизиращи компилатори решават въпроса по различен начин, което обуславя разликите в бързодействието/паметта, която консумират базираните на тях браузъри. Често няма универсално решение на този въпрос, особено когато един двигател бива използван на множество, принципно различни системи – например мощен настолен компютър или сървър срещу мобилно или вградено устройство.

6. Дизайн на програмни интерфейси – често езиковите двигатели биват вграждани в по-големи системи с критично бързодействие. За тази цел отново се налага добър компромис между създаването на програмен интерфейс (API), който осигурява относително добро поведение на двигателя за 90% от потребителите, но оставя останалите 10% без контрол върху ключова част от тяхната система или – от друга страна – осигуряването на пълен контрол върху работата на двигателя с риск по-незапознатите потребители да го “счупят” в критичен за тях момент.

7. Софтуерно инженерство – като всички проекти, които се развиват с години и обслужват множество потребители, популярните езикови двигатели имат нужда от стабилна, но гъвкава архитектура и добър инструментариум около основния код (инструменти за дебъгване, инфраструктура за тестване). Това обуславя налагането на конвейерна архитектура и голямото внимание, което се обръща на дизайна на интерфейсите между съставящите я елементи.

Заключение

В заключение ще споменем няколко случая от ежедневната практика на програмиста, в които е полезно да разбираме поне на високо ниво как работят интерпретаторите и компилаторите.

● Искаме да направим приложението си да работи по-бързо (тук дискутираме основно динамичните езици; за статичните езици е необходимо едно по-дълбоко навлизане в теорията на компютърните архитектури и често оптимизациите, които са ни достъпни в един статичен език са строго специфични за него, а понякога и компилатора, който използваме – напр. шаблони срещу наследяване в C++);

● Искаме да построим (компилираме) приложението си по-бързо. Това е особено важно за производителността ни по време на разработката на приложението;

● Искаме да разширим системно (native) приложение с богатите възможности на скриптов език, без от това да страда неговата производителност. Важно е да сме запознати с базовата “цена”, която заплащаме за превръщане (англ. – marshalling) на данните при извикване на функции между различните езици (среди, в този контекст – отново runtime на англ.), механизма на работа на чистачът на памет (garbage collector), ако има такъв, как можем да го контролираме и кога не искаме да го правим. Сходен пример с този, описан във видеото за LLVM – тук целта е да запазим скоростта на скриптирания код близка до тази на предварително компилирания C++;

● Искаме да създадем собствен DSL (Domain specific language – език, специфичен за предметната област). Това може на пръв поглед да звучи като задача за теоретиците, но реално дори измислянето на формат на един конфигурационен файл представлява дефиниране на такъв специфичен език. Характерно за тях е, че можем да изградим собствен малък конвейер за парсване, проверка за синтактични грешки и изпълнение (прилагане на конфигурацията), за което можем да използваме отделни елементи от набор инструменти като LLVM.


Автор:
Мая Лекова

>>> Мая има интереси в областта на C++ и JavaScript.
>>> В момента работи като програмист във V8 екипа на Google.
>>> Предишният ѝ професионален опит е свързан основно с програмиране на игри.
>>> Завършва бакалавър по Информатика във ФМИ на Софийския университет, а след това магистър по Електронно обучение.
>>> Живо се интересува от темата за образованието и вярва, че знанието е затова, да се споделя.


Стани част от потребителските групи на DEV.BG. Абонирай се и ще ти изпращаме информация за всичко, което предстои в групата.

Прочети още:
Реактивни уеб услуги със Spring 5 WebFlux

Share This