golang

Как устроен компилятор Go: сканер (лексер)

  • среда, 3 декабря 2025 г. в 00:00:06
https://habr.com/ru/articles/971348/

Команда Go for Devs подготовила перевод статьи о том, как работает первый этап компиляции Go — сканер. Автор подробно показывает, как исходный код превращается в поток токенов, что происходит с каждым символом и откуда берётся автоматическая вставка точек с запятой. Если вы хотите понять Go «изнутри» — начинайте именно отсюда.


Это первая статья в серии, где я шаг за шагом проведу вас по всему компилятору Go — от исходного кода до исполняемого файла. Если вам когда-то было интересно, что происходит под капотом, когда вы запускаете go build, вы по адресу.

Примечание: статья основана на Go 1.25.3. Внутренние механизмы компилятора могут измениться в будущем, но базовые идеи, скорее всего, останутся прежними.

Я собираюсь использовать максимально простой пример, чтобы провести нас через весь процесс — классическую программу «hello world»:

package main

import "fmt"

func main() {
    fmt.Println("Hello world")

Начнём с самого первого шага — со сканера.

Что делает сканер

Сканер Go (его ещё называют лексером) — это первый компонент компилятора. Его задача проста: преобразовать ваш исходный код в токены. Каждый токен обычно представляет собой слово или символ — на��ример, package, main, {, (, или строковые литералы.

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

Сканер также отвечает за автоматическую вставку точек с запятой. Вы можете не писать их в своём Go-коде, но сканер добавляет их после определённых токенов, когда встречает перевод строки. С вашей точки зрения точки с запятой — необязательны. С точки зрения компилятора — они всегда присутствуют.

Две реализации сканера

В Go на самом деле есть две реализации сканера:

  1. Сканер из стандартной библиотеки (src/go/scanner/) — его используют, если вы пишете инструменты для работы с Go-кодом.

  2. Сканер компилятора (src/cmd/compile/internal/syntax/scanner.go) — это настоящая рабочая лошадка, которую использует сам компилятор.

Мы будем разбирать именно сканер компилятора.

Токены: результат работы сканера

Давайте посмотрим, как выглядят токены на самом деле. Когда сканер обрабатывает нашу программу «hello world», он выдаёт такую последовательность:

Position   Token      Literal
--------   -----      -------
1:1        package    "package"
1:9        IDENT      "main"
1:14       ;          "\n"
3:1        import     "import"
3:8        STRING     "\"fmt\""
3:13       ;          "\n"
5:1        func       "func"
5:6        IDENT      "main"
5:10       (          ""
5:11       )          ""
5:13       {          ""
6:5        IDENT      "fmt"
6:8        .          ""
6:9        IDENT      "Println"
6:16       (          ""
6:17       STRING     "\"Hello world\""
6:30       )          ""
6:31       ;          "\n"
7:1        }          ""
7:2        ;          "\n"

Обратите внимание, что сканер автоматически вставил точки с запятой (они отображены как переводы строки) после main, после импорта "fmt" и в конце вызова Println. Это и есть та самая автоматическая вставка точек с запятой, о которой я говорил.

Попробуйте сами

В стандартной библиотеке Go есть пакет scanner, который позволяет поиграть с процессом разбиения кода на токены. Вот полноценная программа, которую можно запустить:

package main

import (
	"fmt"
	"go/scanner"
	"go/token"
)

func main() {
	src := []byte(`package main

import "fmt"

func main() {
    fmt.Println("Hello world")
}`)

	var s scanner.Scanner
	fset := token.NewFileSet()
	file := fset.AddFile("", fset.Base(), len(src))
	s.Init(file, src, nil, scanner.ScanComments)

	for {
		pos, tok, lit := s.Scan()
		if tok == token.EOF {
			break
		}
		fmt.Printf("%s\t%s\t%q\n", fset.Position(pos), tok, lit)
	}
}

Теперь, когда мы разобрались, что именно выдаёт сканер, посмотрим, как он работает внутри.

Внутри сканера

Перед тем как сканер сможет начать работу, его нужно инициализировать. Это происходит в функции init (src/cmd/compile/internal/syntax/scanner.go):

func (s *scanner) init(src io.Reader, errh func(line, col uint, msg string), mode uint) {
    s.source.init(src, errh)
    s.mode = mode
    s.nlsemi = false
}

Здесь настраиваются три ключевых компонента: базовый источник данных (откуда читается код), режим сканирования (например, нужно ли учитывать комментарии) и состояние вставки точек с запятой (изначально выключено).

Под капотом инициализация источника делает основную работу:

func (s *source) init(in io.Reader, errh func(line, col uint, msg string)) {
    s.in = in
    s.errh = errh

    if s.buf == nil {
        s.buf = make([]byte, nextSize(0))
    }
    s.buf[0] = sentinel
    s.ioerr = nil
    s.b, s.r, s.e = -1, 0, 0
    s.line, s.col = 0, 0
    s.ch = ' '
    s.chw = 0
}

Здесь создаётся буферизированный ридер, оптимизированный под код Go. Буфер (buf) хранит фрагменты исходного текста, а три индекса (b, r, e) отслеживают, какие части уже прочитаны и какие сейчас обрабатываются. Поля line и col фиксируют текущую позицию в файле для отчётов об ошибках. sentinel — специальный маркер, который позволяет быстрее определить, достигли ли мы конца загруженного в буфер содержимого. Наконец, ch хранит текущий символ, который сканер рассматривает (по умолчанию пробел), и после этого можно начинать чтение.

После инициализации сканер готов выдавать токены. Каждый вызов функции next продвигается по исходному тексту до тех пор, пока не найдёт следующий токен.

Как сканер распознаёт токены

Итак, тут и начинается магия. Давайте по шагам разберём функцию next.

Сначала сканер обрабатывает вставку точек с запятой:

func (s *scanner) next() {
    nlsemi := s.nlsemi
    s.nlsemi = false

Поле nlsemi отслеживает, нужно ли вставить точку с запятой, если сканер встретит перевод строки. Так Go позволяет вам не писать точки с запятой — сканер добавляет их сам после определённых токенов.

Дальше он пропускает пробельные символы:

 redo:
    // skip white space
    s.stop()
    startLine, startCol := s.pos()
    for s.ch == ' ' || s.ch == '\t' || s.ch == '\n' && !nlsemi || s.ch == '\r' {
        s.nextch()
    }

Вызов stop гарантирует, что мы начинаем разбор с «чистого листа» для нового токена. Затем сканер проглатывает все пробельные символы, пока не наткнётся на что-то осмысленное.

После этого он записывает метаданные токена — в частности, позицию начала токена в исходном файле:

   // token start
    s.line, s.col = s.pos()
    s.blank = s.line > startLine || startCol == colbase
    s.start()

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

Теперь сканеру нужно понять, что это за токен. Он делает это, ориентируясь на первый символ. Начнём с идентификаторов и ключевых слов:

   if isLetter(s.ch) || s.ch >= utf8.RuneSelf && s.atIdentChar(true) {
        s.nextch()
        s.ident()
        return
    }

Если текущий символ — буква (или допустимый Unicode-символ для идентификатора), сканер понимает, что смотрит либо на ключевое слово (например, package или func), либо на идентификатор (например, main или fmt). Он считывает первый символ с помощью nextch(), а затем передаёт управление методу ident, который дочитывает остальные символы и решает, ключевое это слово или идентификатор:

func (s *scanner) ident() {
    // accelerate common case (7bit ASCII)
    for isLetter(s.ch) || isDecimal(s.ch) {
        s.nextch()
    }

    // general case
    if s.ch >= utf8.RuneSelf {
        for s.atIdentChar(false) {
            s.nextch()
        }
    }

    // possibly a keyword
    lit := s.segment()
    if len(lit) >= 2 {
        if tok := keywordMap[hash(lit)]; tok != 0 && tokStrFast(tok) == string(lit) {
            s.nlsemi = contains(1<<_Break|1<<_Continue|1<<_Fallthrough|1<<_Return, tok)
            s.tok = tok
            return
        }
    }

    s.nlsemi = true
    s.lit = string(lit)
    s.tok = _Name
}

Вот что делает ident() по шагам:

  1. Читает идентификатор. Продолжает считывать символы, пока они являются буквами или цифрами (обрабатывая и ASCII, и Unicode).

  2. Проверяет, не ключевое ли это слово. Когда слово прочитано целиком, оно ищется в keywordMap Go с помощью хеш-функции для ускорения.

  3. Возвращает соответствующий токен. Если в карте ключевых слов есть совпадение, возвращается соответствующий токен ключевого слова (например, Package или Func). Если совпадения нет, это обычный идентификатор, тогда возвращается _Name, а сам текст записывается в s.lit.

Флаг nlsemi тоже устанавливается здесь — он сообщает сканеру, нужно ли автоматически вставить точку с запятой после этого токена, если дальше идёт перевод строки.

Обработка символов и операторов

Напомню: если первый символ был не буквой, путь через ident() не срабатывает. Вместо этого сканер продолжает выполнение в next с большим оператором switch, который проверяет, что это за символ. Здесь распознаются символы, операторы, числа, строки и другие токены. Давайте посмотрим на примеры.

Конец файла обрабатывается просто:

switch s.ch {
case -1:
    if nlsemi {
        s.lit = "EOF"
        s.tok = _Semi
        break
    }
    s.tok = _EOF

Когда сканер натыкается на -1 (EOF), он возвращает соответствующий токен. Если перед EOF нужно вставить точку с запятой, он сначала делает это.

Простые символы обрабатываются напрямую:

case ',':
    s.nextch()
    s.tok = _Comma

case ';':
    s.nextch()
    s.lit = "semicolon"
    s.tok = _Semi

Запятая — это запятая. Точка с запятой — это точка с запятой. Ничего хитрого.

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

case '+':
    s.nextch()
    s.op, s.prec = Add, precAdd
    if s.ch != '+' {
        goto assignop
    }
    s.nextch()
    s.nlsemi = true
    s.tok = _IncOp

Вот здесь появляется просмотр вперёд (lookahead). Когда сканер видит +, он не может сразу понять, что это за токен — это может быть +, ++ или +=. Поэтому он считывает + через nextch(), а затем смотрит, что сейчас в s.ch (следующий символ в потоке), но ещё его не потребляет. Это и есть просмотр вперёд: заглянуть на следующий символ, чтобы принять решение.

Если в s.ch ещё один +, значит это оператор инкремента ++ — мы считываем второй + и устанавл��ваем соответствующий токен. Если нет — переходим к метке assignop, чтобы проверить, += это или просто одиночный +:

assignop:
    if s.ch == '=' {
        s.nextch()
        s.tok = _AssignOp
        return
    }
    s.tok = _Operator

Если следующий символ — =, значит перед нами оператор присваивания вроде +=. Если нет — это одиночный оператор, и сканер не потребляет следующий символ, оставляя его для следующего токена.

Более сложные случаи

Я затронул здесь не всё. Сканер также обрабатывает строковые токены (с escape-последовательностями), числовые токены (включая числа с плавающей точкой, экспонентой и разными системами счисления — шестнадцатеричной, двоичной и т.д.), а также комментарии. Работают они по тем же принципам, но логика там заметно сложнее. Если вам интересно, очень рекомендую заглянуть в src/cmd/compile/internal/syntax/scanner.go и посмотреть на это своими глазами.

Пошаговый разбор примера

Мы уже обсудили многое — инициализацию, распознавание токенов, просмотр вперёд и разные ветки кода, по которым идёт сканер. Теперь соберём всё вместе и пройдёмся по нашей программе «hello world» построчно. Так вы увидите, как все эти части работают вживую — от первого символа до финального токена EOF.

  1. Сканер начинает с буквы p. Он читает её, затем продолжает: a, c, k, a, g, e. Получается слово package. Сканер проверяет, не ключевое ли это слово. Да, ключевое. Он возвращает токен package.

  2. Далее — m, снова буква. Затем a, i, n. Получается main. Это уже не ключевое слово. Значит, сканер возвращает токен IDENT с литералом "main".

  3. Следом — перевод строки. Предыдущий токен был идентификатором, значит сюда нужно вставить точку с запятой. Сканер делает это автоматически.

  4. Далее идёт import, и это ключевое слово. Сканер возвращает токен import.

  5. Потом сканер встречает ", начало строки. Он читает всю строку "fmt" и возвращает токен STRING.

  6. После очередного перевода строки (и вставки точки с запятой) сканер видит func — снова ключевое слово.

  7. Потом ещё одно main — идентификатор.

  8. Символы (, ) и { — это односимвольные токены, поэтому сканер сразу же выдаёт их.

  9. Затем — fmt (��дентификатор), точка . (соответствующий токен), Println (ещё один идентификатор).

  10. Дальше ( (открывающая скобка), строка "Hello world" (токен строки), ) (закрывающая скобка), и } (закрывающая фигурная скобка).

  11. В конце сканер вставляет точку с запятой после }, достигает конца файла и возвращает EOF.

Вот так полностью выглядит токенизация простой программы «hello world».

Итоги

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

Мы увидели, как сканер:

  • Автоматически вставляет точки с запятой, чтобы вам не приходилось писать их вручную

  • Различает ключевые слова и идентификаторы с помощью таблицы быстрых поисков

  • Обрабатывает многосимвольные операторы, заглядывая вперёд

  • Разбирает разные типы токенов, сочетая просмотр вперёд и сопоставление по шаблонам

Если хотите копнуть глубже, очень советую изучить исходный код сканера. Там полно интересных деталей про обработку строк, чисел и разных нестандартных случаев, о которых я тут не рассказывал.

Русскоязычное Go сообщество

Друзья! Эту статью подготовила команда «Go for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Go. Подписывайтесь, чтобы быть в курсе и ничего не упустить!