Компиляторы

Таблица символов и области видимости

Сценарий: два разработчика написали функции `process()` в разных файлах проекта. Как компилятор понимает, какую именно функцию вызывает каждая строчка кода? Ответ - таблица символов. Без неё компилятор слеп: он видит имена, но не знает, что за ними стоит.

  • **TypeScript compiler** хранит 400+ тысяч символов при компиляции крупного monorepo - именно из этой таблицы VS Code берёт автодополнение и 'Go to Definition' за ~1ms.
  • **GCC** с `-g` включает отладочную информацию на основе таблицы символов - без неё `gdb` не смог бы показать имена переменных, только адреса в памяти.
  • **Линковщик (ld/lld)** - это тоже resolver символов: он сшивает таблицы символов из разных `.o`-файлов, разрешая cross-file references и сообщая 'undefined symbol' когда что-то не найдено.

Symbol Table

Таблица символов (symbol table) - центральная структура данных семантического анализатора. Она отображает имена идентификаторов на их атрибуты: тип, область видимости, адрес, смещение в стеке. Без неё компилятор не может различить переменную `x` из одной функции и `x` из другой.

GCC хранит символы в хэш-таблице `binding_level`, Clang использует `DeclContext` - иерархию контекстов объявлений. Rustc строит таблицу символов в фазе name resolution (passes `resolve::Resolver`) до проверки типов. TypeScript compiler собирает символы в `SymbolTable` - это буквально `Map<string, Symbol>`, где `Symbol` хранит флаги (variable, function, class), тип и список объявлений.

Таблица символов строится за один (или несколько) проходов по AST. В C++ из-за шаблонов и ADL (argument-dependent lookup) компилятор вынужден делать многопроходный анализ - именно это делает Clang значительно сложнее, чем компиляторы более простых языков.

Что хранит таблица символов для каждого идентификатора?

Scope Stack

Вложенные области видимости реализуются стеком скоупов. При входе в блок (`{`) компилятор делает push нового скоупа, при выходе - pop. Поиск символа начинается с вершины стека и идёт вглубь до глобального скоупа.

GCC реализует это через `binding_level` - связный список с указателем `level_chain` на родительский скоуп. Clang использует `Sema::PushFunctionScope()` / `PopFunctionScope()` - пара вызовов оборачивает каждый новый контекст. В Rust `Resolver` хранит `Rib` (ребро) для каждого вложенного скоупа: `fn_item_ribs`, `label_ribs`, `lifetime_ribs` - разные стеки для разных namespace.

Python реализует LEGB rule (Local, Enclosing, Global, Builtin) - это ровно тот же стек скоупов, но с фиксированными четырьмя уровнями поиска. JavaScript до ES6 имел только function-scoped переменные (`var`), что упрощало стек; `let`/`const` добавили block-scoping и сделали поведение ближе к C/Java.

Что происходит со стеком скоупов при выходе из блока `{ ... }`?

Name Resolution

Name resolution - процесс связывания каждого использования имени с конкретным объявлением. Это происходит раньше type checking: сначала компилятор выясняет, что именно обозначает `foo` в данной точке кода, и только потом проверяет типы.

В простых языках resolution - линейный поиск по стеку скоупов. В C++ всё значительно сложнее: есть qualified lookup (`std::vector`), ADL (Koenig lookup), template-dependent name lookup (имена внутри шаблонов резолвятся дважды - при определении и при инстанциации). Именно из-за двухфазного lookup `template <typename T> void f() { g(T{}); }` - `g` может быть незнакомой функцией в момент определения шаблона.

TypeScript compiler делает name resolution в `binder.ts` (фаза bind), производя `Symbol` для каждого объявления. Затем в `checker.ts` каждое использование имени через `resolveEntityName()` связывается с символом. Это позволяет IDE (через Language Server Protocol) мгновенно показывать 'Go to Definition' - просто переход по уже готовой карте.

Почему в C++ name resolution значительно сложнее, чем в большинстве других языков?

Shadowing

Shadowing происходит, когда переменная во внутреннем скоупе объявляется с тем же именем, что и переменная во внешнем. Внутренняя 'затеняет' внешнюю - name resolution возвращает внутренний символ, внешний становится временно недоступен.

Shadowing - гибкий инструмент в Rust: позволяет 'трансформировать' переменную через несколько шагов, меняя тип без введения новых имён. Паттерн `let x = x.parse::<i32>()?.abs();` широко используется в production-коде. В отличие от мутации, каждый `let x` создаёт новую переменную - старая дропается при выходе из скоупа.

Shadowing и мутация (`let mut x`) - одно и то же

Shadowing создаёт новую переменную с тем же именем; мутация изменяет существующую переменную. Shadowing может менять тип, мутация - нет.

Оба механизма 'изменяют' значение переменной с точки зрения пользователя, но в таблице символов shadowing добавляет новую запись, а мутация работает с той же записью. Это различие критично для borrow checker в Rust.

В Rust `let x = 1; let x = x + 1;` - что происходит со старым `x`?

Ключевые идеи

  • **Таблица символов** - структура данных, связывающая имена с их атрибутами (тип, скоуп, смещение). Строится при обходе AST до type checking.
  • **Стек скоупов** - механизм вложенных областей видимости: push при входе в блок, pop при выходе. Поиск идёт от вершины вглубь.
  • **Name resolution** связывает каждое использование имени с конкретным объявлением (DefId в Rust, Symbol в TypeScript). Ошибки resolution - 'undefined variable' и 'ambiguous reference'.
  • **Shadowing** - легальное объявление переменной с уже занятым именем во внутреннем скоупе. В Rust это идиоматично; в C++ компилятор предупреждает с `-Wshadow`.

Связанные темы

Таблица символов - фундамент, на котором строятся все последующие фазы компилятора:

  • Проверка типов — Type checker читает типы из таблицы символов и записывает выведенные типы обратно
  • Семантические ошибки — Большинство семантических ошибок (use-before-define, redeclaration) обнаруживаются при построении таблицы символов
  • Линковка — Линковщик объединяет таблицы символов из разных объектных файлов, решая cross-module references

Вопросы для размышления

  • Python ищет имена по LEGB rule при каждом обращении в runtime, а не при компиляции. Какие преимущества и недостатки это даёт по сравнению со статическим name resolution?
  • В C++ функция может быть объявлена после использования (forward declaration). Как это влияет на построение таблицы символов - нужен ли дополнительный проход по AST?
  • Rust запрещает использование переменной до её объявления в одной функции, но позволяет функциям вызывать друг друга рекурсивно. Как resolver обрабатывает эту асимметрию?

Связанные уроки

  • ds-06-hash-intro
Таблица символов и области видимости

0

1

Войти