Пишем лексер на Python

Соберём настоящий лексер, который превращает строку с выражением в список токенов.

Лексер (сканер) — модуль компилятора, который читает исходный текст символ за символом и выдаёт поток токенов.

Теория позади — пора писать код. Наш лексер примет строку вроде "12 + 3 * 4" и вернёт список токенов. Весь код — на стандартном Python, его можно запустить прямо в браузере.

Идея алгоритма

Лексер держит позицию-указатель внутри строки. На каждом шаге он смотрит на текущий символ и решает: пропустить пробел, собрать число (пока идут цифры), распознать оператор или скобку. Когда строка кончилась — выдать EOF.

Распознавание чисел

Число — это последовательность цифр. Встретив первую цифру, лексер «жадно» читает все следующие цифры подряд, пока они не кончатся, и собирает из них одно число.

def tokenize(text):
    tokens = []
    i = 0
    while i < len(text):
        ch = text[i]
        if ch == " ":
            i += 1
            continue
        if ch.isdigit():
            num = ""
            while i < len(text) and text[i].isdigit():
                num += text[i]
                i += 1
            tokens.append(("NUMBER", int(num)))
            continue
        ops = {"+": "PLUS", "-": "MINUS", "*": "STAR",
               "/": "SLASH", "(": "LPAREN", ")": "RPAREN"}
        if ch in ops:
            tokens.append((ops[ch], ch))
            i += 1
            continue
        raise ValueError("Неизвестный символ: " + ch)
    tokens.append(("EOF", None))
    return tokens

for tok in tokenize("12 + 3 * 4"):
    print(tok)

Вывод:

('NUMBER', 12)
('PLUS', '+')
('NUMBER', 3)
('STAR', '*')
('NUMBER', 4)
('EOF', None)

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

Обратите внимание на конструкцию continue после каждого случая. Она возвращает нас в начало цикла, не давая случайно обработать символ дважды. А внутренний цикл для чисел сдвигает указатель i ровно на длину числа — поэтому 12 становится одним токеном, а не двумя цифрами.

Многосимвольные числа и есть причина, по которой лексер сложнее простого перебора. Для одиночных операторов хватило бы и таблицы, но числа, имена и многосимвольные операторы (==, <=) требуют «заглядывания вперёд».

Обработка ошибок

Если встретился символ, который лексер не знает (например, @), мы поднимаем ошибку. Это правильно: лучше честно сообщить о непонятном символе на самом раннем этапе, чем тащить мусор дальше по конвейеру.

def tokenize(text):
    tokens = []
    i = 0
    while i < len(text):
        ch = text[i]
        if ch == " ":
            i += 1; continue
        if ch.isdigit():
            num = ""
            while i < len(text) and text[i].isdigit():
                num += text[i]; i += 1
            tokens.append(("NUMBER", int(num))); continue
        raise ValueError("Неизвестный символ: " + repr(ch))
    return tokens

try:
    tokenize("2 @ 3")
except ValueError as e:
    print("Ошибка:", e)

Вывод:

Ошибка: Неизвестный символ: '@'

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

Самая частая — забыть сдвинуть указатель i, из-за чего цикл зависает на одном символе вечно. Каждая ветка обязана либо увеличить i, либо обработать его внутри (как чтение числа).

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

Итог

  • Лексер держит указатель и решает, что делать с текущим символом.
  • Числа читаются «жадно» — все подряд идущие цифры собираются в один токен.
  • Неизвестные символы лучше отлавливать сразу с понятной ошибкой.
  • Каждая ветка обязана продвигать указатель, иначе цикл зависнет.
Проверьте себя
1. Как лексер распознаёт многозначное число вроде 12?
AВыдаёт по токену на каждую цифру
BЖадно читает все подряд идущие цифры в один токен
CИгнорирует все числа
DХранит число как отдельные строки
2. Что произойдёт, если ветка цикла не сдвинет указатель i?
AПрограмма ускорится
BЦикл зависнет на одном символе бесконечно
CЛексер пропустит пробелы
DПоявится токен EOF
3. Как лучше поступить с неизвестным символом?
AМолча пропустить
BПревратить в число
CПоднять понятную ошибку на этапе лексера
DПередать дальше парсеру как есть