Как язык программирования работает под капотом
Вы когда-нибудь задумывались, как это работает под капотом языка программирования? Как компилятору удается правильно понимать наш код? Как машинам (компьютерам) удается правильно и точно выполнять наш код?
«Правильно, правильно и точно» подразумевается без ошибок, без потерь и без модификаций.
Чтобы ответить на эти вопросы, мы попробуем создать небольшой язык программирования под названием H#
и выполним все шаги, которые обычно выполняет компилятор.
Но прежде начнем с конца истории: какой код может понять машина (компьютер)?
На каком языке говорит машина?
Машина подразумевает оборудование, а оборудование подразумевает электрические цепи. Электрические цепи внутри компьютера могут находиться только в двух состояниях: включено и выключено.
Итак, как мы можем связать программное обеспечение с аппаратным обеспечением?
Это легко благодаря двум магическим числам (битам) 1 и 0, которые являются синонимами включения и выключения цепей в компьютере (Алгебра булева) .
Машинный код состоит из последовательности простых компьютерных инструкций, каждая из которых представлена строкой двоичных цифр или битов (т. е. единиц и нулей).
Важно отметить, что машиной может быть ваш компьютер или виртуальная машина, работающая на вашем компьютере, например виртуальная машина Java, поскольку виртуальная машина использует ресурсы хоста и выполняется внутри него.
Однако в наши дни редко кому приходится программировать в числовом машинном коде из-за сложности работы с ним.
Таким образом, чтобы облегчить работу разработчика, выше этого самого низкого уровня представления языки программирования предлагают различные уровни абстракции высокого уровня.
В качестве первого уровня абстракции можно привести ассемблерный код:
На этом уровне мы поняли, что понимает машина (компьютер), и я думаю, понятно, что будет делать компилятор, не так ли?
Роль компилятора состоит в том, чтобы сделать человеческий мир и мир машин совместимыми. Чтобы машина понимала инструкции (желания) человека. Идеальный!
Прежде чем углубиться в компилятор, давайте начнем с определения и представления языка программирования, который мы собираемся разработать!
Определение языка
Мы собираемся разработать небольшой язык программирования под названием H#
:
- Как и человеческий язык, язык программирования состоит из
dictionary
иgrammar
. dictionary
определяет набор слов (токенов), из которых состоит язык.grammar
проверяет правильность сборки слов.
В настоящее время наш текущий язык позволяет только:
- определение типизированных переменных
- выполнение простых арифметических действий
- определение и использование функций
- вывод сообщения в консоль
Другие характеристики:
- разделитель слов будет
space
- разделителем операторов будет точка с запятой
;
- каждый оператор должен начинаться с новой строки
- с учетом регистра
- комментарии начинаются с
//
- отступ не важен
Вот пример программы, написанной на H#
:
int value1 = 5; int value2 = 2; // calculate the sum of two integers int function sum(x: int, y: int) { return x + y; } int result = sum(value1, value2); print('The result is : ', result);
H#
будет разработан на основе языка C
, конечным результатом будет код на ассемблере:
Словарное определение (токены)
Токены — это наш словарь общепринятых слов.
Практическое представление может быть a map or a dictionary
:
Определение грамматики
Правило 1: объявление переменной
Правило 2: объявление функции
Правило 3: вызов функции
Определение ошибок
Лексические ошибки:
Ошибка 1: неизвестный идентификатор.
Ошибка 2: зарезервированные ключевые слова.
Синтаксические ошибки:
Ошибка 1: несоответствие декларации.
Ошибка 2: отсутствует открывающая/закрывающая скобка.
Мы готовы понять, что происходит внутри компилятора!
Что такое компилятор?
Компилятор — это инструмент, который переводит программное обеспечение, написанное на человеко-ориентированном языке, в машинно-ориентированный язык.
Типичным «исходным» языком может быть C, C++, Fortran, Java или ML. «Целевой» язык обычно представляет собой набор инструкций некоторого процессора.
Основные характеристики компилятора:
- он должен сохранять смысл компилируемой программы.
- он должен улучшить ввод (пример: удалить мертвый код).
- он должен выполнять свою работу с приемлемым порогом производительности: скоростью, энергией, размером сжатия и вывода.
В ходе своей работы компилятор проходит следующие этапы:
- Программный анализ (внешний интерфейс): производит промежуточное представление (IR).
- Оптимизация: преобразует IR в эквивалентную IR, которая работает более
эффективно. - Машинный таргетинг (бэкенд): преобразует IR в нативный код (машинный код).
Давайте погрузимся глубже!
Этапы анализа программы (интерфейс)
Прежде чем приступить к технической части, я хотел попросить вас перевести это предложение вашему французскому другу: «Компилятор — это волшебство и удивительный инструмент». Как вы собираетесь действовать?
Таким образом, некоторые предложат следующие шаги:
- разделить предложение на слова.
- разделителем слов может быть любой из знаков препинания.
- найти эквивалент каждого слова в словаре.
- соедини слова, чтобы образовать предложение.
- проверьте правильность синтаксиса и значения.
- просмотрите предложение и перефразируйте его, если необходимо.
- прочитать фразу.
Компилятор выполнит те же шаги, чтобы понять язык ввода:
Шаги выглядят так, как мы переводим предложение выше, не так ли?
Давайте углубимся в каждый шаг вместе!
Сканер
Сканер проанализирует поток символов исходной программы и превратит его в поток токенов. Это как искать слово в словаре посимвольно. Он также удаляет пробелы и комментарии.
Ошибки, которые могут возникнуть при сканировании, называются лексическими ошибками:
- Сканер обнаружит все неизвестные слова и выдаст
an unknown identifier error
. - Если ключевое слово использовано неправильно, сканер
a reserved keywords error
.
Простейшим алгоритмом распознавания сканера может быть посимвольный анализатор.
Например, предположим, что у нас есть этот входной исходный код:
int value1 = 5; int value2 = 2; // calculate the sum of two integers int function sum(x: int, y: int) { return x + y; }
После фазы сканирования вывод будет:
Эти случаи будут генерировать лексические ошибки:
integer value1 = 5; // unknown token integer int function = 5; // function is a reserved keyword INT value1 = 5; // unknown token INT, H# is case sensitive println() // unknown token println
Идеальный. На этом уровне компилятор знает все слова, которые мы использовали в нашем исходном коде, и мы уверены, что все используемые слова существуют в словаре токенов!
💡 Советы: Lex и Flex — это инструменты для создания сканеров.
Однако этого недостаточно, нам нужно следить за правильностью сборки токенов. Это все равно, что убедиться, что предложение грамматически правильно составлено (выражение: подлежащее + глагол). Это будет роль парсера. Давайте взглянем!
Парсер
Сканер проверяет правильность лексики, парсер проверяет правильность грамматики.
То есть сканер объединяет символы в токены, а синтаксический анализатор объединяет токены в предложения.
Синтаксический анализатор несет основную ответственность за распознавание синтаксиса, определяя, является ли компилируемая программа допустимым предложением в синтаксической модели языка программирования.
Хорошо, но как парсер может это сделать? Чтобы понять это, я собираюсь спросить вас, если бы вы были в школе, и я попросил бы вас рассчитать эту операцию (17 + 19) * (8 + 96)
, как бы вы это сделали?
Более простой способ расчета состоит в том, чтобы сделать это:
Косвенно вы проанализировали выражение, как компилятор: вы определили токены (операнды и операторы) и их отношения!
Парсер сделает то же самое: он преобразует наши токены в дерево, представляющее реальную структуру кода.
Практически каждый узел в AST хранится как объект с именованными полями, многие значения которых сами являются узлами дерева.
Ошибки, которые могут возникнуть во время синтаксического анализа, называются синтаксическими ошибками.
Эти случаи будут генерировать лексические ошибки:
int j = 4 * (6 − x; // missing a closing parenthesis int i = /5; // missing the first value int 42 = x * 3 // missing a semicolon, 42 can't be a variable name int value1 = 1, value2 = 1; // the language definition state that we must have a declaration per ligne
🔵 Полезно знать: некоторые инструменты Javascript, такие как Babel и ESLint, могут манипулировать AST. Вы также можете визуализировать AST любого кода Javascript с помощью AST Explorer.
💡 Советы: Yacc и Bison — это инструменты для создания парсеров.
Семантический анализатор
Во время семантического разбора нам нужно проверить правила легальности, и при этом мы связываем части синтаксического дерева (разрешая ссылки на идентификаторы, вставляя операции приведения для неявного приведения и т. д.), чтобы сформировать семантический граф.
Правила, которые мы можем проверить на этом этапе:
- Несколько объявлений переменной в области видимости.
- Ссылка на переменную перед ее объявлением.
- Ссылка на идентификатор, который не имеет объявления.
- Слишком много аргументов в вызове метода.
- Недостаточно аргументов в вызове метода.
- Несоответствия типов.
Средство проверки типов проверяет статическую семантику каждого узла AST:
- он проверяет, является ли конструкция законной и осмысленной (объявлены ли все задействованные идентификаторы, корректны ли типы и т. д.).
- если конструкция семантически верна, средство проверки типов «украшает» узел AST, добавляя к нему информацию о типах или таблицах символов.
- при обнаружении семантической ошибки выдается соответствующее сообщение об ошибке.
- проверка типов полностью зависит от семантических правил исходного языка. Он не зависит от целевой машины компилятора.
Ошибки, возникающие на этом этапе, называются статическими семантическими ошибками.
Подведем итоги!
✅ Сканер:
- ввод: исходный код.
- вывод: токены.
- Цель: проверка словарного запаса.
- лексические ошибки: неизвестный токен, зарезервированные ключевые слова, …
✅ Парсер:
- ввод: жетоны.
- выход: АСТ.
- Цель: проверка синтаксиса (формулировка предложения).
- синтаксические ошибки: отсутствие закрывающей скобки, отсутствие точки с запятой, неправильное имя переменной, …
✅ Семантический анализатор:
- ввод: АСТ.
- вывод: Аннотированный AST.
- цель: смысловая проверка (смысл).
- семантические ошибки: ошибки типов, ошибки объявления, ошибки аргументов, ошибки ссылок и т. д.
Промежуточные представления
Промежуточное представление — это независимая от машины и языка версия исходного кода.
Использование промежуточного представления дает преимущества в повышенной абстракции, более четком разделении между интерфейсом и сервером и добавляет возможности для перенацеливания/кросс-компиляции.
Промежуточные представления также подходят для поддержки расширенных оптимизаций компилятора, и большинство оптимизаций выполняется в этой форме кода.
Этапы таргетинга на компьютер (серверная часть)
Цель бэкенд-этапов — сгенерировать машинный код:
- Выбор инструкций — это этап компиляции, на котором промежуточное представление программ компилятором преобразуется в последовательность зависящих от цели машинных инструкций, оптимизирующих различные задачи компилятора (например, скорость и объем памяти).
- Планирование инструкций: для создания кода, который выполняется быстро, генератору кода может потребоваться изменить порядок операций, чтобы отразить целевую машину и ее конкретные ограничения производительности.
- Распределение регистров: при выборе инструкций компилятор игнорирует тот факт, что целевая машина имеет ограниченный набор регистров. Компиляция может создать больше требований к «виртуальным» регистрам, чем к «реальной» аппаратной поддержке. Распределитель регистров должен сопоставить «виртуальные» регистры с «реальными» регистрами целевой машины. Он определяет в каждой точке программы, какие значения будут находиться в регистрах и какой регистр будет содержать каждое из этих значений. Если распределитель не может сохранить значение в регистре на все время своего существования, это значение должно храниться в памяти частично или полностью на протяжении всего времени его существования.
Заключение
В этой статье мы рассмотрели, как работают языки программирования, от определения языка до среды выполнения.
Язык программирования регулируется: лексикой, грамматикой и семантикой.
Роль компилятора состоит в том, чтобы убедиться, что код, который будет выполнять машина, соответствует определению языка и характеристикам оборудования.
Код внутри компилятора проходит несколько этапов: лексический анализ, синтаксический анализ, затем семантический анализ. Корректность является обязательной характеристикой и поведением компилятора.
Помимо корректности, компилятор должен гарантировать производительность и выполнять оптимизацию для уменьшения и улучшения выполнения кода во время выполнения.
Спасибо, что прочитали мою статью.
Want to Connect? You can find me at GitHub: https://github.com/helabenkhalfallah