Семантический анализ и таблица символов

Грамматика проверяет форму, но не смысл — за смысл отвечает семантический анализ.

Семантический анализ — фаза, проверяющая правила, которые нельзя выразить грамматикой: объявлены ли имена, совместимы ли типы, корректны ли вызовы.

Строка x + 1 синтаксически безупречна. Но если x нигде не объявлен, программа бессмысленна. Грамматика этого не ловит — она не знает, какие переменные существуют. Это работа семантического анализа.

Что проверяет семантика

  • Объявлена ли переменная до использования.
  • Не объявлена ли переменная дважды в одной области.
  • Совместимы ли типы операндов (число + строка — обычно ошибка).
  • Совпадает ли число аргументов при вызове функции.

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

Таблица символов — структура, хранящая сведения об объявленных именах: их типы, области видимости, иногда адреса в памяти.

Простейшая таблица символов — это словарь «имя → информация». Когда встречается объявление, мы добавляем запись; когда использование — ищем имя в таблице.

class SymbolTable:
    def __init__(self):
        self.symbols = {}
    def declare(self, name, type_):
        if name in self.symbols:
            raise NameError("Повторное объявление: " + name)
        self.symbols[name] = type_
    def lookup(self, name):
        if name not in self.symbols:
            raise NameError("Не объявлено: " + name)
        return self.symbols[name]

table = SymbolTable()
table.declare("x", "int")
print("x имеет тип", table.lookup("x"))
try:
    table.lookup("y")
except NameError as e:
    print("Ошибка:", e)

Вывод:

x имеет тип int
Ошибка: Не объявлено: y

Области видимости

В реальных языках одно имя может означать разное внутри функции и снаружи. Поэтому таблицы символов организуют в стопку (стек) областей: при входе в блок добавляется новая таблица, при выходе — удаляется. Поиск идёт от внутренней области к внешним.

class Scopes:
    def __init__(self):
        self.stack = [{}]  # глобальная область
    def push(self): self.stack.append({})
    def pop(self): self.stack.pop()
    def declare(self, name, t): self.stack[-1][name] = t
    def lookup(self, name):
        for scope in reversed(self.stack):
            if name in scope:
                return scope[name]
        raise NameError("Не найдено: " + name)

s = Scopes()
s.declare("g", "int")      # глобальная
s.push()
s.declare("local", "str")  # локальная
print(s.lookup("local"))   # видна локальная
print(s.lookup("g"))       # видна и внешняя
s.pop()

Вывод:

str
int

Как работает под капотом

Семантический анализатор обходит AST (тем самым visitor из прошлого раздела). Встречая объявление — пишет в таблицу символов. Встречая использование имени — ищет его и проверяет тип. Так на дерево «навешивается» смысловая информация, которой потом пользуется кодогенератор.

Частые ошибки

Первая — пытаться поймать «необъявленную переменную» грамматикой. Это невозможно: грамматика контекстно-свободна, а знание о множестве объявленных имён — контекстная информация. Только таблица символов решает задачу.

Вторая — игнорировать области видимости и держать все имена в одном словаре. Тогда локальные и глобальные переменные конфликтуют, и язык ведёт себя непредсказуемо.

Итог

  • Семантика проверяет смысл: объявления, типы, корректность вызовов.
  • Таблица символов хранит сведения об именах (тип, область видимости).
  • Области видимости организуют в стек: поиск идёт изнутри наружу.
  • Эти проверки невозможны грамматикой — нужна контекстная информация.
Проверьте себя
1. Что проверяет семантический анализ, чего не может грамматика?
AРасстановку скобок
BОбъявлены ли переменные и совместимы ли типы
CНаличие пробелов
DДлину файла
2. Зачем нужна таблица символов?
AХранить исходный текст
BХранить сведения об объявленных именах: типы и области видимости
CУскорять лексер
DЗаменять AST
3. Как организуют области видимости при анализе?
AВ один общий словарь
BВ стек таблиц: поиск идёт от внутренней области к внешним
CВ случайном порядке
DВ виде машинного кода