Files
Info/2 курс/Языки программирования/Цикл трансляции, лексический и синтаксический анализ.md
T

273 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Содержание
- [[#Введение]]
- [[#История]]
- [[#Цикл трансляции]]
- [[#Этапы]]
- [[#Таблицы]]
- [[#Компиляция]]
- [[#Типы ошибок компилятора]]
- [[#Проходы]]
- [[#Представление программы]]
- [[#Лексический анализ]]
- [[#Токенизация]]
- [[#Принцип максимального совпадения]]
- [[#Синтаксический анализ]]
- [[#Дерево разбора]]
- [[#Приоритет]]
- [[#Ассоциативность]]
- [[#AST]]
- [[#Табличный метод разбора]]
- [[#Практика]]
- [[#Рекурсивный спуск]]
- [[#Трансляция препроцессора]]
- [[#Утилита make]]
## Введение
Как было описано в [лекции 2](Формальные%20грамматики%20и%20теория%20автоматов.md), [конечные автоматы](Формальные%20грамматики%20и%20теория%20автоматов.md#Конечный%20автомат) не могут разложить код языка программирования. Из-за бесконечной вложенности большинство языков относится к типу 2 по [Иерархии Хомского](Формальные%20грамматики%20и%20теория%20автоматов.md#Иерархия%20Хомского) (контекстно-свободные грамматики).
Тем не менее, именно вложенность является основой любого языка программирования. Поэтому в данном случае необходимы автоматы с магазинной памятью. Но вводить их формально - дело скучное и по большей части, опять же, формальное, потому можно сразу рассмотреть данные автоматы с точки зрения инженерной реализации.
## История
Разбор языка программирования никогда не был простым приложением, созданным на основе теории вычислений. Напротив, разбор языка программирования является одной из первых по-настоящему прикладных задач теории вычислений.
В 1952 году [Грейс Хоппер](https://ru.wikipedia.org/wiki/Хоппер,_Грейс) создал [A-0](https://ru.wikipedia.org/wiki/A-0_System) - первую программу, которая была способна компоновать небольшие заранее подготовленные подпрограммы в одну программу, при этом сопоставляя их с помощью числовых кодов. По факту, данная система положила начало созданию [[#Компиляция|компиляторов]], и является их самым первым прототипом.
В 1957 году в языке [Fortran](https://ru.wikipedia.org/wiki/Фортран), созданном [IBM](https://ru.wikipedia.org/wiki/IBM) под руководством [Джона Бэкуса](https://ru.wikipedia.org/wiki/Бэкус,_Джон), появился первый по-настоящему массовый оптимизирующий компилятор. На данной примере компания IBM смогла показать, насколько большим может быть ускорение программы, прошедшей обработку через компилятор, при этом без изменения смысла кода.
В 1960-ых годах, как упоминалось [ранее](Формальные%20грамматики%20и%20теория%20автоматов.md#Форма%20Бэкуса-Наура), ALGOL 60 стал первым языком программирования, синтаксис которого был формально описан в БНФ. Это важно, ведь до него все спецификации писались словесно, а значит имели неоднозначность.
В 1965 году [Дональд Кнут](https://ru.wikipedia.org/wiki/Кнут,_Дональд_Эрвин) смог полностью формализовать [[#Табличный метод разбора|LR-парсинг]] (также известный как табличный метод разбора программы). По факту, таким образом была организована теоретическая база, на основе которой были построены все последующие быстрые [[#Дерево разбора|парсеры]].
В 1975 году [Bell Labs](https://ru.wikipedia.org/wiki/Лаборатории_Белла) смогла создать первые массовые генераторы [[#Токенизация|лексеров]] и [[#Дерево разбора|парсеров]], которые назывались lex (первый создатель [[#Токенизация|лексеров]]) и yacc (создатель табличных [[#Табличный метод разбора|LR-парсеров]]). По факту, это была первая по-настоящему формализованная инженерная реализация полноценной системы, которую называют компилятором.
## Цикл трансляции
Цикл трансляции является необходимым процессом, через который проходит любая программа на компилируемом языке программирования перед тем, как будет запущена. Он позволяет программу из простого текста с кодом превратить в по-настоящему исполняемый набор машинных команд, собранных в один исполняемый файл.
### Этапы
Существует всего четыре общих этапа транслирования программы в машинный код. В современных [[#Компиляция|компиляторах]] каждый такой этап можно выполнять по отдельности и анализировать, какие изменения с кодом происходят на каждом из этапов.
Первый этап называется **препроцессингом**. Он применяется напрямую к исходному коду программы и служит для того, чтобы выполнить все макроподстановки (да и вообще любые подстановки) в коде. Подробнее об этих подстановках описано в [[#Трансляция препроцессора|примере]]. В конце данного этапа исходный текст программы становится текстом после макроподстановки.
Второй этап называется **компиляцией**. Он применяется к полученном на первом этапе тексту программы после макроподстановки. Подробнее об этом этапе написано [[#Компиляция|ниже]]. В конце данного этапа текст программы превращается в полноценный ассемблерный код (который, в свою очередь, зависит от аппаратуры, на которой будет запускаться программа).
Третий и четвертый этапы называются **ассемблированием** и **компоновкой** (или линковкой) соответственно. Данные этапы были рассмотрены [ранее](Среда%20выполнения.%20Основы%20языка%20ассемблера.md#Компиляция%20ассемблерных%20программ) в одном из предыдущих семинаров.
### Таблицы
Во время цикла трансляции при [[#Компиляция|компиляции]] создаются вспомогательные таблицы с данными, используемые компилятором для сопоставления различных имен переменных с настоящими адресами памяти. Одними из таких таблиц являются таблицы символов и таблицы релокации, без которых современная компиляция практически невозможна.
>**Таблица символов** - это таблица, сопоставляющая различные имена в коде (функций, переменных) с их настоящими адресами или атрибутами внутри объектного файла. В данном случае имена, сопоставляющиеся таблицей, называют символами.
Тем не менее, даже с использованием таблицы символов, остается важная проблема. Как сопоставить символ в одном файле, который во время компиляции еще не известен и будет объявлен только в другом файле, с его настоящим адресом в памяти? Чтобы решить эту конкретную проблему, существует таблица релокации.
>**Таблица релокации** - это список мест в [объектном файле](Среда%20выполнения.%20Основы%20языка%20ассемблера.md#Компиляция%20ассемблерных%20программ), куда компоновщик должен поставить настоящий адрес вместо заглушки после объединения всех компилируемых файлов.
Но тут возникает еще одна проблема: откуда знать, что этот символ действительно будет объявлен в другом файле? Для того, чтобы предотвратить такое поведение, существуют заголовочные файлы. По факту, заголовочный файл является контрактом между единицами трансляции, который гарантирует существование доступных символов, но пока не дает их фактических адресов или реализации.
Именно благодаря таблицам символов и таблицам релокации возможна независимая компиляция несколько отдельных файлов кода, ведь компиляция каждого отдельного файла не ждет компиляции всех остальных. Окончательное соединение всех адресов и символов происходит только на этапе компановки.
### Компиляция
>**Компилятор** - это специальная программа, которая выполняет процесс преобразования программы из кода напрямую в машинные инструкции.
Полноценный процесс компиляции состоит в первую очередь из [[#Лексический анализ|лексического]] и [[#Синтаксический анализ|синтаксического]] анализа.
#### Типы ошибок компилятора
Все ошибки компилятора можно разделить на три вида: лексические, синтаксические и сематические, что очень напоминает [уровни формальных грамматик](Формальные%20грамматики%20и%20теория%20автоматов.md#Основы).
Лексические ошибки находятся [[#Токенизация|лексером]] при выполнении лексического анализа программы. Синтаксические ошибки находятся [[#Дерево разбора|парсером]] при выполнении синтаксического анализа программы. Семантические ошибки находятся уже после парсинга программы с помощью проверки типов, когда компилятор сверяет типы по обе стороны присваивания (подробнее в лекции 5).
#### Проходы
Также компиляторы делятся по количеству используемых проходов для компилирования программы. Существуют однопроходные и многопроходные компиляторы.
>**Однопроходный компилятор** - это компилятор, который читает исходный текст программы ровно один раз, и сразу же после этого генерирует итоговый исполняемый код.
>**Многопроходный компилятор** - это компилятор, который по исходному тексту программы строит промежуточное представление (к примеру, [[#AST]]), и проходит по нему несколько раз для разных задач.
Изначально, однопроходные компиляторы были эффективнее на старых компьютерах, ведь там приходилось гнаться за каждой возможной оптимизацией, даже если она доставляла некоторые неудобства. Поэтому однопроходные компиляторы являются именно исторической реализацией, и все еще используются.
Очевидно, у однопроходных компиляторов есть несколько проблем. Основной среди них является то, что для функции, объявленной ниже по файлу программы, требуется знать ее структуру именно сейчас, а не когда она уже будет объявлена.
В C, который стандартно использует однопроходный компилятор, данная проблема решена лишь частично. Поэтому существует требование объявлять функцию заранее, до ее первого использования (те же [[#Таблицы|заголовочные файлы]]).
Современные компиляторы, такие как Clang или GCC, в основном многопроходные. Они имеют отдельные проходы для [[#Синтаксический анализ|разбора]], проверки типов, оптимизации и генерации кода.
### Представление программы
Во время цикла трансляции программы из исходного кода в исполняемый файл программа проходит полноценный путь, приобретая несколько видов представлений самой себя. Всего таких представлений существует пять.
В самом начале программа является исходным текстом, написанным программистом. После этого программа проходит [[#Лексический анализ|лексический анализ]], превращаясь в [[#Токенизация|поток токенов]]. Далее происходит [[#Синтаксический анализ|синтаксический анализ]] программы, превращая поток токенов в полное [[#Дерево разбора|дерево разбора]], оставляя все использованные правила грамматики. После этого она превращается в [[#AST]], убирая служебные узлы и оставляя только смысловую структуру. Наконец, происходит генерация кода на основе AST, что окончательно и превращает программу в машинный код.
## Лексический анализ
Одной из функций [[#Компиляция|компилятора]] является проведение лексического анализа: проверка текста программы на соблюдение всех лексических правил языка.
### Токенизация
Одним из способов проверки соблюдения лексических правил языка является токенизация всего текста программы.
>**Токенизация** - разбиение потока символов на поток токенов.
Иными словами, каждое ключевое слово языка, каждый идентификатор, каждый литерал, каждый оператор, каждый разделитель (по факту все, что подсвечивается в редакторе кода) превращается в набор токенов, и вся остальная работа с программой происходит уже по токенам, а не с самим текстом.
>**Лексер** - специальная программа, которая превращает текст программы в поток токенов, чаще всего при этом отбрасывая все пробелы и комментарии. Фактически является [конечным автоматом](Формальные%20грамматики%20и%20теория%20автоматов.md#Конечный%20автомат).
### Правило максимального совпадения
>**Максимальное совпадение** (maximal munch) - правило лексера, при котором он выбирает самый длинный [[#Токенизация|токен]], который может распознать в данной позиции.
Если проще, то лексер просто жадно поглощает символ за символом, пока идентификатор остается валидным. И только если следующий символ сделает идентификатор окончательно инвалидным, лексер останавливается и начинает процесс поглощения идентификатора заново.
Это правило чаще всего не слишком очевидно, поэтому из-за него иногда возникают небольшие проблемы. И это несмотря на то, что такое поведение является следствием фактической цели и принципа работы лексера: он не знает о существовании [[#Синтаксический анализ|синтаксиса языка]] и о смыслах синтаксических конструкций, он просто локально, символ за символом, решает, что является валидным, а что нет.
## Синтаксический анализ
Одной из функций [[#Компиляция|компилятора]] является проведение синтаксического анализа: проверка текста программы на соблюдение всех синтаксических правил языка.
### Дерево разбора
Главным методом проверки кода на соблюдение синтаксических правил является построение полно дерева разбора.
>**Парсер** - это специальная программа, которая на вход получает [[#Токенизация|поток токенов]], созданный лексером, и [грамматику языка](Формальные%20грамматики%20и%20теория%20автоматов.md#Формальные%20грамматики), а на выходе получает полное дерево разбора.
>**Дерево разбора** - это структура, которая отражает, как какая-либо строка (по которой это дерево было построено) может быть выведена по заданной грамматике.
Самым интуитивным методом разбора кода программы является рекурсивный спуск. Он же является самым распространенным.
Основная суть такого метода заключается в том, что по каждому нетерминалу грамматики строится одна функция, внутри которой для каждого полученного в ней нетерминала также строится по одной функции, и так далее, пока не останутся только терминалы. Отсюда данный метод и получил свое название - рекурсия по всем нетерминалам граматики.
Данный метод рекурсивного спуска хорошо отражает вложенность языка. В то же время фактически данный метод можно рассматривать как [автомат с магазинной памятью](Формальные%20грамматики%20и%20теория%20автоматов.md#Иерархия%20Хомского), а стек вызовов - именно как ту память, которой не хватает конечному автомату для реализации такого метода.
#### Приоритет
Приоритет в синтаксическом анализе программы закладывается через уровни вложенности грамматики, а не через специальные правила [[#Дерево разбора|парсера]].
К примеру, в [[#Рекурсивный спуск|примере]] разбор выражения с операторами '+' и '-' (`parse_expr`) вызывает разбор выражения с операторами '\*' и '/' (`parse_term`). Отсюда получается, что связь '\*' и '/' теснее, чем между '+' и '-'. Как итог, получаем, что в данном случае парсер ведет себя "жаднее" с символами '\*' и '/' и по возможности "съедает" их полностью, прежде чем вернуться на предыдущий уровень.
#### Ассоциативность
Иногда при разборе выражения можно обойтись и без рекурсии. К примеру, если все возможные символы не будут менять уровень вложенности [[#Дерево разбора|парсера]], то тогда можно будет обойтись только один циклом внутри одного уровня, без рекурсии. Такая ситуация называется **левой ассоциативностью**.
Тем не менее, в некоторых случаях требуется именно рекурсия, и цикла будет недостаточно. Тогда такая ситуация называется **правой ассоциативностью**.
#### AST
>**Абстрактное синтаксическое дерево** (AST - Abstract Sintax Tree) - это вид синтаксического дерева, который при разборе отбрасывает всю ненужную синтаксическую упаковку и оставляет только смысловую структуру разбираемой программы.
AST и [[#Дерево разбора|дерево разбора]], хоть и выполняют примерно похожую функцию, имеют ряд принципиальных различий. Само их предназначение различается: дерево разбора создается для того, чтобы формально вывести строку по правилам [грамматики](Формальные%20грамматики%20и%20теория%20автоматов.md#Формальные%20грамматики), в то время как AST составляет дерево для дальнейшей работы (интерпретация, генерация кода), а значит больше ориентировано на практическую ценность.
Дерево разбора изначально призвано при разборе программы отразить каждое установленное правило грамматики, с учетом служебных символов, поэтому при разборе оно сохраняет все скобки (в виде отдельных узлов-токенов), ключевые слова-разделители (к примеру, точки с запятой) и подобные символы. AST же служит более практическим целям, поэтому все, что не имеет смысловой нагрузки, либо безжалостно отбрасывается за ненадобностью (как в случае с ключевыми словами-разделителями), либо учитывается при построении самого дерева (к примеру, скобки).
### Табличный метод разбора
Метод [[#Дерево разбора|рекурсивного спуска]], который был описан при определении дерева разбора и [[#Рекурсивный спуск|пример]] которого приведен ниже, не является единственным методом разбора программы в дерево. Помимо него также применятся и табличный метод.
В отличие от рекурсивного спуска, табличный метод разбирает программу снизу вверх, от листьев к корню, что позволяет ему обрабатывать большее количество [грамматик](Формальные%20грамматики%20и%20теория%20автоматов.md#Формальные%20грамматики) (все таки, рекурсивный спуск может разобрать не любую рекурсию). Помимо этого, табличный метод реализуется проще - он создается автоматически на основе грамматики, а не пишется вручную в виде обычного кода (как в случае с рекурсивным спуском). Для этого он заранее строит таблицы переходов, а после использует их при разборе.
Из-за таких положительных факторов табличного метода его чаще используют в промышленных компиляторах (к примеру, [yacc](https://ru.wikipedia.org/wiki/Yacc) или [bison](https://ru.wikipedia.org/wiki/GNU_Bison)), в то время как рекурсивный спуск можно использовать на каких-нибудь учебных компиляторах или простых языках программирования.
## Практика
### Рекурсивный спуск
Предположим, что у нас есть выражение "1 + 2 * 3", которое необходимо разобрать, используя метод рекурсивного спуска.
Для начала зададим грамматику.
```
expr ::= term (('+' | '-') term)*
term ::= factor (('*' | '/') factor)*
factor ::= NUMBER | '(' expr ')'
```
Далее образно распишем работу лексера для выражения "1 + 2 * 3".
```
parse_expr()
parse_term()
parse_factor() -> 1
// читает 1, далее нет '*' или '/', а значит term готов
left = 1 -> полученный узел "1"
// далее '+', значит expr продолжается
parse_term()
parse_factor() -> 2
// читает 2, далее идет '*', значит term прододжается
parse_factor() -> 3
// конец выражения, значит term готов
left = 2 * 3 -> полученный узел "2*3"
// expr закончился, значит получаем готовый узел
left = 1 + (2 * 3)
// токенов больше нет, а значит полученное выражение и есть итоговое дерево
```
Опишем все более подробно. `parse_expr()` последовательно разбивает полученную строку на узлы, и только в конце соединяет их в итоговое выражение. Иными словами, он не воспринимает те же символы "2, \*, 3" отдельно друг от друга, он их даже не читает. Вместо этого он получает от `parse_term` сразу готовый узел (2 \* 3) и подсоединяет его к строящемуся дереву.
Тот же разбор математического выражения можно расписать на языке C.
```c
Node *parse_expr(Lexer *lx) {
Node *left = parse_term(lx);
while (peek(lx) == '+' || peek(lx) == '-') {
char op = next(lx);
Node *right = parse_term(lx);
left = make_binary(op, left, right)
}
return left;
}
Node *parse_term(Lexer *lx) {
Node *left = parse_factor(lx);
while (peek(lx) == '*' || peek(lx) == '/') {
char op = next(lx);
Node *right = parse_factor(lx);
left = make_binary(op, left, right)
}
return left;
}
Node *parse_factor(Lexer *lx) {
if (peek(lx) == '(') {
next(lx); // съесть '('
Node *inner = parse_expr(lx); // рекурсия!
expect(lx, ')');
return inner;
}
return make_number(next_number(lx));
}
```
### Трансляция препроцессора
[[#Этапы|Препроцессор]] в языке C работает до [[#Лексический анализ|лексического]] или [[#Синтаксический анализ|синтаксического]] анализа программы. По факту, это просто подстановка текста в некоторые заранее определенные места программы, не более.
Поэтому макросы - не часть [грамматики](Формальные%20грамматики%20и%20теория%20автоматов.md#Формальные%20грамматики) языка C, а просто слой поверх нее. Именно поэтому макрос может породить [[#Типы ошибок компилятора|синтаксически некорректный]] C-код, а препроцессор об этом даже не узнает.
Для реализации таких макроподстановок используются специальные директивы, включая `#include`, `#define`, `#ifdef`, `#ifndef`.
Примером такой ошибки препроцессора может служить следующий код.
```c
#define SQUARE(x) x * x
int a = SQUARE(2 + 3)
... // производится подстановка
int a = 2 + 3 * 2 + 3 // = 11, а не 25
```
Поэтому при использовании макросов рекомендуется использовать защиту.
```c
#define SQUARE(x) ((x) * (x))
int a = SQUARE(2 + 3)
... // производится подстановка
int a = ((2 + 3) * (2 + 3)) // а вот это уже 25
```
### Утилита make
Утилита make является одной из главных систем сборки файлов. Чаще всего она используется для получения какого-либо исполняемого файла или промежуточного объектного файла.
Makefile описывает граф зависимостей, от которых зависит собираемый файл. Таким образом, ему легко видеть, от каких файлов зависит собираемая цель.
При пересборке цели make не выполняет всю пересборку целиком, а пересобирает только те файлы, хотя бы одна зависимость которых новее самих файлов. Это критически важно для больших систем, где одна цель может полностью пересобираться по несколько часов, а такой образ работы make сильно ускоряет этот процесс.
У make существует специальный синтаксис, который позволяет точно описать, какие зависимости нужны файлу и как их собирать. Для этого используются различные специальные символы (к примеру, $@ - имя цели, $< - первая зависимость, $^ - все зависимости), а также шаблонные правила (%), которые избавляют от повторения одного и того же правила для каждого файла.
Ниже представлен пример сборки некоторого исполняемого файла с помощью компилятора gcc, использующий все вышеперечисленные правила.
```
CC = gcc
CFLAGS = -Wall -std=c17
hello: hello.o string.o
$(CC) -o $@ $^
%.o: %.c
$(CC) -c $(CFLAGS) -o $@ $<
clean:
rm -f hello *.o
.PHONY: clean
```