Команда 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 на самом деле есть две реализации сканера:
Сканер из стандартной библиотеки (
src/go/scanner/) — его используют, если вы пишете инструменты для работы с Go-кодом.Сканер компилятора (
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() по шагам:
Читает идентификатор. Продолжает считывать символы, пока они являются буквами или цифрами (обрабатывая и ASCII, и Unicode).
Проверяет, не ключевое ли это слово. Когда слово прочитано целиком, оно ищется в
keywordMapGo с помощью хеш-функции для ускорения.Возвращает соответствующий токен. Если в карте ключевых слов есть совпадение, возвращается соответствующий токен ключевого слова (например,
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.
Сканер начинает с буквы
p. Он читает её, затем продолжает:a,c,k,a,g,e. Получается словоpackage. Сканер проверяет, не ключевое ли это слово. Да, ключевое. Он возвращает токенpackage.Далее —
m, снова буква. Затемa,i,n. Получаетсяmain. Это уже не ключевое слово. Значит, сканер возвращает токенIDENTс литералом"main".Следом — перевод строки. Предыдущий токен был идентификатором, значит сюда нужно вставить точку с запятой. Сканер делает это автоматически.
Далее идёт
import, и это ключевое слово. Сканер возвращает токенimport.Потом сканер встречает
", начало строки. Он читает всю строку"fmt"и возвращает токенSTRING.После очередного перевода строки (и вставки точки с запятой) сканер видит
func— снова ключевое слово.Потом ещё одно
main— идентификатор.Символы
(,)и{— это односимвольные токены, поэтому сканер сразу же выдаёт их.Затем —
fmt(идентификатор), точка.(соответствующий токен),Println(ещё один идентификатор).Дальше
((открывающая скобка), строка"Hello world"(токен строки),)(закрывающая скобка), и}(закрывающая фигурная скобка).В конце сканер вставляет точку с запятой после
}, достигает конца файла и возвращаетEOF.
Вот так полностью выглядит токенизация простой программы «hello world».
Итоги
Сканер — это первый этап работы компилятора Go. Он посимвольно читает исходный код и преобразует его в поток токенов — более структурированное представление, с которым могут работать последующие фазы компиляции.
Мы увидели, как сканер:
Автоматически вставляет точки с запятой, чтобы вам не приходилось писать их вручную
Различает ключевые слова и идентификаторы с помощью таблицы быстрых поисков
Обрабатывает многосимвольные операторы, заглядывая вперёд
Разбирает разные типы токенов, сочетая просмотр вперёд и сопоставление по шаблонам
Если хотите копнуть глубже, очень советую изучить исходный код сканера. Там полно интересных деталей про обработку строк, чисел и разных нестандартных случаев, о которых я тут не рассказывал.
Русскоязычное Go сообщество

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