Команда 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. Подписывайтесь, чтобы быть в курсе и ничего не упустить!