Лексер с заглядыванием вперёд

Реальные языки требуют от лексера смотреть не только на текущий символ, но и на следующий.

Заглядывание вперёд (lookahead) — приём, при котором лексер или парсер смотрит на ещё не обработанные символы, чтобы принять верное решение.

Наш базовый лексер обрабатывал одиночные символы. Но что, если оператор состоит из двух знаков, например == или <=? Увидев =, лексер не может сразу решить, это присваивание = или начало ==. Нужно заглянуть на следующий символ.

Многосимвольные операторы

Решение — посмотреть на пару символов. Если за = идёт ещё один =, это оператор сравнения, и мы съедаем оба символа. Иначе — одиночный оператор.

def tokenize(text):
    tokens = []
    i = 0
    while i < len(text):
        ch = text[i]
        nxt = text[i + 1] if i + 1 < len(text) else ""
        if ch == " ":
            i += 1; continue
        if ch == "=" and nxt == "=":
            tokens.append(("EQ", "==")); i += 2; continue
        if ch == "=":
            tokens.append(("ASSIGN", "=")); i += 1; continue
        if ch == "<" and nxt == "=":
            tokens.append(("LE", "<=")); i += 2; continue
        if ch == "<":
            tokens.append(("LT", "<")); i += 1; continue
        raise ValueError("Неизвестный символ: " + repr(ch))
    return tokens

# на входе только операторы, чтобы показать заглядывание вперёд
for tok in tokenize("== = <= <"):
    print(tok)

Вывод:

('EQ', '==')
('ASSIGN', '=')
('LE', '<=')
('LT', '<')

Главное правило при заглядывании: сначала проверяй более длинные варианты. Если бы мы сначала ловили одиночный =, мы бы никогда не дошли до ==. Порядок проверок имеет значение.

Числа с точкой

Похожая логика для дробных чисел. Цифры могут разделяться одной точкой. Лексер читает цифры, при встрече точки запоминает, что дробь уже началась, и читает дальше.

def read_number(text, i):
    num = ""
    seen_dot = False
    while i < len(text) and (text[i].isdigit() or text[i] == "."):
        if text[i] == ".":
            if seen_dot:
                break
            seen_dot = True
        num += text[i]
        i += 1
    value = float(num) if seen_dot else int(num)
    return value, i

val, _ = read_number("3.14abc", 0)
print(val, type(val).__name__)

Вывод:

3.14 float

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

Заглядывание вперёд формально характеризует мощность лексера. Лексеры на регулярных языках обычно требуют конечного, заранее известного заглядывания (часто на один символ — так называемые LL(1)-сканеры). Если для распознавания токена нужно «бесконечное» заглядывание, это сигнал, что задача уже не лексическая, а синтаксическая, и решать её должен парсер.

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

Главная ловушка — неправильный порядок проверок: одиночный вариант стоит раньше двойного, и двойной никогда не срабатывает. Всегда сначала проверяйте более длинные лексемы.

Вторая — вторая точка в числе. 3.14.15 не является корректным числом, и лексер должен остановиться на второй точке, а не клеить всё в одну строку и падать в float().

Итог

  • Lookahead позволяет распознавать многосимвольные операторы и числа.
  • Длинные варианты проверяй раньше коротких, иначе они не сработают.
  • Дробные числа требуют учёта одной точки и остановки на второй.
  • Потребность в «бесконечном» заглядывании — признак, что задача синтаксическая, а не лексическая.
Проверьте себя
1. Почему при распознавании операторов длинные варианты проверяют раньше коротких?
AТак быстрее работает процессор
BИначе одиночный оператор перехватит ввод и двойной никогда не сработает
CЭто требование Python
DЧтобы экономить память
2. Что должно случиться при чтении числа 3.14.15?
AЛексер склеит всё в одно число
BЛексер остановится на второй точке
CВозникнет токен EOF
DЧисло станет строкой