Deep Engineering
Средний·Опубликовано·45 МИН

Инструменты go: build, vet, fmt и что они проверяют

Собеседование по инструментам идёт лестницей: чем go run отличается от go build — почему вторая сборка мгновенная — зачем gofmt — что ловит go vet, если код компилируется — как собрать бинарник под Windows на Linux — как вшить в него версию. Прогон отвечает на каждый шаг: холодная сборка вызвала компилятор 55 раз, повторная — ни разу; Printf с неверным глаголом и копия sync.Mutex собрались без единой ошибки, а go vet поймал оба; go test пропустил копию мьютекса, потому что запускает только часть проверок vet; а -ldflags -X молча проигнорировал опечатку в имени переменной.

Полное техническое изложение

TL;DR

go — одна команда, которая собирает, запускает, форматирует и проверяет код. go build компилирует пакеты и компонует бинарник, go run делает то же во временный каталог и сразу запускает результат, gofmt приводит код к единственному формату, go vet ищет ошибки, которые компилятор пропускает. Всё это идёт со стандартной поставкой Go — без отдельной установки.

Компилятор пропускает много настоящих ошибок, и go test ловит не все из них. Printf("%d") со строкой, копия sync.Mutex в получателе-значении, недостижимый код, выброшенный результат fmt.Sprintf — всё в прогоне собралось без единой ошибки, а go vet поймал все четыре. Но go test запускает только часть проверок vet: тест пакета с копией мьютекса прошёл и напечатал after Inc: 0 — счётчик не увеличился.

Дальше — то, что отличает понимание от пересказа. Холодная сборка программы из fmt и os вызвала компилятор 55 раз, повторная — ни разу и прошла в десятки раз быстрее: пакеты лежат в кеше, а ключ кеша — содержимое файлов, не время их изменения. go run собирает без отладочной информации — его бинарник совпал по размеру с go build -ldflags "-s -w" до байта — и код выхода программы 3 превращает в свой 1. Открывающая скобка на своей строке — синтаксическая ошибка, а не стиль. GOOS=windows go build на Linux дал файл PE без единого внешнего инструмента. А -ldflags "-X …" молча пропускает опечатку в имени переменной: версия осталась dev, код выхода 0.

Порог входа
Перед уроком достаточно понимать
  • как написать программу из пакета main и запустить её;
  • что такое модуль и файл go.mod;
  • что метод может иметь получатель-значение или получатель-указатель.
Заранее знать не нужно
  • кеш сборки и разница между go run и go build;
  • что ловит go vet, какие его проверки запускает go test и что добавляет golangci-lint;
  • ограничения сборки, кросс-компиляция, флаги -ldflags и go generate.

Что здесь на самом деле спрашивают

Лестница почти всегда такая:

  1. «Чем go run отличается от go build?» — разминка; ответ «run ещё и запускает» верен, но неполон.
  2. «Почему вторая сборка мгновенная?» — про кеш сборки.
  3. «Зачем gofmt и можно ли с ним спорить?» — проверяют, понимает ли кандидат, почему формат в Go один.
  4. «Код компилируется — зачем ещё go vet?» — главный вопрос: что компилятор пропускает и что из этого ловит vet.
  5. «Как собрать бинарник под Windows на Linux?» — про GOOS, GOARCH и ограничения сборки.
  6. «Как вшить версию в бинарник?» — про -ldflags.

Стержень у лестницы один: у каждого инструмента своя граница — что он видит и что пропускает. Компилятор проверяет типы, но не формат-строки; vet проверяет формат-строки, но не проигнорированные ошибки; go test запускает vet, но не весь; кеш видит содержимое файлов, но не время их изменения. Ответы, которые отличают знающего, — это знание границ.

База: одна команда, несколько подкоманд

контракт языкаПоведение подкоманд — документация команды go (go help build, go help run, go help test), команд vet, gofmt и link. Прогон bench/gotool/internals.go его иллюстрирует, а не устанавливает.

Всё, о чём спрашивают, — подкоманды одной программы go и одна отдельная утилита gofmt из той же поставки:

командачто делаетчто остаётся после неё
go buildкомпилирует пакеты и зависимости; для пакета main компонует бинарникисполняемый файл в текущем каталоге (или по -o) — только для пакета main
go runто же, во временный каталог, и сразу запускаетничего в каталоге проекта
go testсобирает тестовый бинарник, запускает часть проверок vet, запускает тестырезультат в кеше тестов
go vetстатические проверки подозрительных конструкцийтолько сообщения
gofmt -l / go fmtпоказывает / исправляет файлы, чей формат отличается от каноническогоgo fmt переписывает файлы
go generateзапускает команды из комментариев //go:generateто, что эти команды написали

Две вещи из таблицы стоит произнести сразу. Первая: подкоманды go работают с пакетами — go build ./... собирает все пакеты модуля, go vet ./... проверяет их; список .go-файлов одного каталога (как в go run gen.go) команда собирает в один пакет. gofmt же — отдельная утилита и работает с файлами и каталогами. Вторая: go fmt — это обёртка над gofmt, по документации он запускает gofmt -l -w на названных пакетах.

Ответ уровня Junior: go build компилирует и оставляет бинарник, go run компилирует во временный каталог и запускает, go test собирает и запускает тесты, gofmt форматирует код единственным принятым способом, go vet ищет ошибки, которые компилятор пропускает, — например, неверный глагол в Printf.

Механизм 1: кеш сборки — и что на самом деле делает go run

Программа в cases/hello использует только fmt и os. Соберём её трижды: с пустым кешем, с тёплым кешем и ещё раз, когда бинарник уже на месте. Сколько раз вызывались компилятор и компоновщик, видно по строкам go build -x:

наблюдение замераbench/gotool/internals.go, раздел 1, go1.24.7 linux/amd64, 2 ядра; лучший из 3 запусков (холодный кеш очищается перед каждым) и из 5 (тёплый). Секунды не переносятся на другую машину; переносится то, что тёплая сборка не вызывает компилятор вовсе.
1) КЕШ СБОРКИ: ХОЛОДНАЯ И ПОВТОРНАЯ СБОРКА
──────────────────────────────────────────
  go env GOCACHE (по умолчанию) -> <home>/.cache/go-build
  программа: cases/hello — fmt и os, ничего больше
  каталог $GOROOT/pkg поставки: include tool
  go build -o hello ./hello:
  холодный кеш                           6.6 с    compile: 55, link: 1
  тёплый кеш, файла hello ещё нет       0.16 с    compile:  0, link: 1
  тёплый кеш, hello уже собран          0.04 с    compile:  0, link: 0
  холодная / тёплая:                    41

Из пятидесяти пяти вызовов компилятора один — сам пакет main, остальные 54 — стандартная библиотека: fmt, os и всё, от чего они зависят. Скомпилированных пакетов стандартной библиотеки в поставке нет — в каталоге $GOROOT/pkg лежат только include и tool. Каждый пакет компилируется при первой сборке и кладётся в кеш, каталог которого печатает go env GOCACHE. Дальше он берётся оттуда. На go1.25.1 вызовов было 60 — стандартная библиотека от версии к версии меняется.

Третья строка показывает ещё одно: если бинарник уже собран из тех же входов, go build не вызывает даже компоновщик. В сам бинарник записан идентификатор его входов (build ID), и go build сравнивает его с тем, что получилось бы сейчас: совпало — файл не трогается.

деталь реализации · Go 1.24Устройство build ID описано в комментарии cmd/go/internal/work/buildid.go, строки 68–92 (go1.24.7): бинарник хранит расширенный build ID, и по нему go решает, что компоновку можно не повторять. Прогон показывает только следствие — link: 0.

Что считается «теми же входами»? Документация обещает:

The build cache correctly accounts for changes to Go source files, compilers, compiler options, and so on: cleaning the cache explicitly should not be necessary in typical use.

перевод

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

— Команда go — кеш сборки

И прогон показывает, по какому признаку он это делает:

11) КЛЮЧ КЕША — СОДЕРЖИМОЕ, А НЕ ВРЕМЯ ФАЙЛА
────────────────────────────────────────────
  повторная сборка без изменений      compile: 0, link: 0
  после touch main.go (время +1 ч)    compile: 0, link: 0
  после правки комментария            compile: 1, link: 1

touch сдвинул время изменения файла на час — сборка не заметила. Правка комментария, который на код не влияет, — перекомпилировала пакет main и заново скомпоновала бинарник. Ключ кеша — содержимое входов, а не время файлов. Поэтому go clean -cache «на всякий случай» почти никогда не нужен; документация называет исключение — изменения C-библиотек, подключённых через cgo, кеш не видит.

Теперь go run. Его часто описывают как «go build плюс запуск», и документация добавляет к этому важную деталь:

By default, 'go run' compiles the binary without generating the information used by debuggers, to reduce build time.

перевод

По умолчанию go run компилирует бинарник без информации, нужной отладчикам, чтобы сократить время сборки.

— Команда go — go run
2) go run: ВРЕМЕННЫЙ КАТАЛОГ И КОД ВЫХОДА
─────────────────────────────────────────
  первый go run   compile: 1, link: 1
    бинарник: <tmp>/go-build<N>/b001/exe/hello
    после выхода файл на месте: false
  второй go run   compile: 0, link: 0
    бинарник: <GOCACHE>/<xx>/<hash>-d/hello
    после выхода файл на месте: true

Первый go run шёл сразу после go build с тёплым кешем — и всё равно вызвал компилятор: пакет main для него собирается с другими флагами, без DWARF, а это другой ключ кеша. Бинарник лёг во временный каталог go-build<N>, и после выхода программы его там нет. Второй go run не вызвал ни компилятор, ни компоновщик: он запустил бинарник прямо из кеша сборки.

деталь реализации · Go 1.24Сверено по исходнику go1.24.7: cmd/go/internal/run/run.go, строка 148 — OmitDebug, строка 172 — CacheExecutable; work/gc.go, строки 110–111 и 612–613. В go1.25.1 поведение то же (CacheExecutable — строка 170). Более ранние версии урок не проверял.

Это видно и в исходнике cmd/go: go run ставит пакету OmitDebug, отчего компилятор получает -dwarf=false, а компоновщик — -s и -w; и ставит CacheExecutable, отчего готовый бинарник кладётся в кеш. Размер это подтверждает (раздел 9 прогона):

  размер cases/hello:
    go build                     2 224 064 байт   .symtab есть, .debug_info есть
    go build -ldflags "-s -w"    1 446 072 байт   .symtab нет, .debug_info нет
    бинарник go run из кеша      1 446 072 байт   .symtab нет, .debug_info нет

Бинарник go run по размеру совпал с go build -ldflags "-s -w" до байта. И последнее отличие — код выхода:

The exit status of Run is not the exit status of the compiled binary.

перевод

Код выхода Run — не код выхода скомпилированного бинарника.

— Команда go — go run
  go run ./hello fail:
    exe: <GOCACHE>/<xx>/<hash>-d/hello
    exit status 3
    код выхода go run: 1
    код выхода самой программы: 3

Программа вызвала os.Exit(3); go run напечатал exit status 3 и сам завершился с кодом 1. Для скрипта, который проверяет код выхода, — это разные программы. Отсюда практическое правило: go run — для разработки; в CI, в Docker-образе и в любом месте, где важны код выхода, отладка или повторяемость, — go build и запуск бинарника.

Механизм 2: gofmt — формат, который не обсуждается

gofmt делает одно: приводит файл к каноническому виду. Флаг -l печатает имена файлов, чей формат отличается, -d — разницу, -w — переписывает файл. Подопытный cases/messy/main.go собирается без ошибок, но написан как попало. Ниже — строки diff, которые касаются структуры и main (diff целиком, с импортами, — в записи прогона):

3) gofmt -l И gofmt -d
──────────────────────
  gofmt -l messy/ tags/
    messy/main.go
  go build ./messy/
    собрано, ошибок нет
  gofmt -d messy/main.go
    -type Config struct{
    -    Name string
    -    Port int
    -    Debug bool
    -}
    -func  main ( ) {
    -    c:=Config{Name:"api",Port:8080}
    -    if c.Port>1024 {fmt.Println( strings.ToUpper(c.Name),c.Port )}
    +type Config struct {
    +	Name  string
    +	Port  int
    +	Debug bool
    +}
    +
    +func main() {
    +	c := Config{Name: "api", Port: 8080}
    +	if c.Port > 1024 {
    +		fmt.Println(strings.ToUpper(c.Name), c.Port)
    +	}

Видно всё, что gofmt решает за автора: отступ — табуляция, а не пробелы; выравнивание полей структуры — пробелами; пробелы вокруг :=, после : в литерале и вокруг >; пустые строки между объявлениями; тело if — на своих строках. Файлы tags/ в списке -l не появились: они уже в каноническом виде. Документация формулирует правило одной фразой:

Gofmt formats Go programs. It uses tabs for indentation and blanks for alignment.

перевод

Gofmt форматирует программы на Go. Для отступов он использует табуляцию, для выравнивания — пробелы.

— Команда gofmt

Настроек стиля у gofmt нет: по go doc cmd/gofmt его флаги — -d, -e, -l, -r, -s и -w, и ни один не задаёт ни ширину отступа, ни место скобки. Это и есть ответ на «можно ли с ним спорить»: нельзя, и в этом смысл. Код любого проекта на Go выглядит одинаково, ревью не тратится на пробелы, а diff показывает только изменения смысла.

Одну вещь gofmt не исправит, потому что это не стиль. Открывающая скобка на своей строке:

GO
func main()
{
}
  gofmt -l brace/  (открывающая скобка на своей строке)
    brace/main.go:5:1: unexpected semicolon or newline before {
  go build ./brace/
    brace/main.go:5:1: syntax error: unexpected semicolon or newline before {

Это синтаксическая ошибка, и gofmt её не форматирует, а сообщает. Причина — в спецификации, в правиле автоматической точки с запятой:

When the input is broken into tokens, a semicolon is automatically inserted into the token stream immediately after a line's final token if that token is

перевод

При разбиении входа на лексемы точка с запятой автоматически вставляется в поток лексем сразу после последней лексемы строки, если эта лексема —

— Спецификация Go — Semicolons

— и дальше в списке есть закрывающая скобка ). Строка func main() кончается на ), лексер ставит после неё точку с запятой, и { на следующей строке оказывается после законченного объявления. Положение скобки в Go решает грамматика, а не договорённость.

Механизм 3: что компилятор пропускает, а go vet ловит

Компилятор Go строг: неиспользуемая переменная у него — ошибка, а не предупреждение (спецификация разрешает компилятору такое ограничение, и gc им пользуется). Отсюда ощущение, что «раз собралось — всё в порядке». Документация vet говорит иначе:

Vet uses heuristics that do not guarantee all reports are genuine problems, but it can find errors not caught by the compilers.

перевод

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

— Команда vet

Семь подопытных программ, по одной ошибке в каждой. Например, printf и copylock:

GO
name := "gopher"
fmt.Printf("hello, %d\n", name)
GO
type Counter struct {
	mu sync.Mutex
	n  int
}
 
func (c Counter) Inc() {
	c.mu.Lock()
	c.n++
	c.mu.Unlock()
}

Что сказали компилятор и vet:

4) КОМПИЛЯТОР ПРОТИВ go vet
───────────────────────────
  unusedvar:
    go build: ./main.go:7:2: declared and not used: count
    go vet:   vet: ./main.go:7:2: declared and not used: count
  printf:
    go build: собрано, ошибок нет
    go vet:   ./main.go:8:2: fmt.Printf format %d has arg name of wrong type string
  copylock:
    go build: собрано, ошибок нет
    go vet:   ./main.go:15:9: Inc passes lock by value: example.com/toolcases/copylock.Counter contains sync.Mutex
  unreachable:
    go build: собрано, ошибок нет
    go vet:   ./main.go:8:2: unreachable code
  unusedresult:
    go build: собрано, ошибок нет
    go vet:   ./main.go:8:13: result of fmt.Sprintf call not used
  errcheck:
    go build: собрано, ошибок нет
    go vet:   замечаний нет
  ineffassign:
    go build: собрано, ошибок нет
    go vet:   замечаний нет
  copylock, запуск: c.Inc(), затем c.n -> 0

Граница проходит по типам. Компилятор проверяет, что программа корректна по типам: Printf принимает ...any, и строка в нём законна. Что глагол %d ждёт число, знает только тот, кто читает формат-строку, — это делает vet. Копия мьютекса тоже законна по типам: Counter — обычная структура, её можно копировать. Но получатель-значение означает, что Inc работает с копией и замка, и счётчика: в прогоне после c.Inc() счётчик остался 0. Программа не падает — метод просто молча ничего не делает.

Две последние программы vet пропустил: os.Remove с выброшенной ошибкой и присваивания limit := 10 и limit = 20, которые перезаписываются, не будучи прочитанными. Их ловят сторонние линтеры. golangci-lint с линтерами по умолчанию:

6) golangci-lint СВЕРХ go vet
─────────────────────────────
  golangci-lint has version 2.5.0 built with go1.25.1 from ff63786c on 2025-09-21T19:04:05Z
  golangci-lint run --no-config (линтеры по умолчанию):
  errcheck:
    main.go:7:11: Error return value of `os.Remove` is not checked (errcheck)
  ineffassign:
    main.go:7:2: ineffectual assignment to limit (ineffassign)
    main.go:8:2: ineffectual assignment to limit (ineffassign)

Остальные пять программ golangci-lint поймал тоже — тем же vet: его линтер govet в прогоне выдал те же сообщения с пометкой (govet), а неиспользуемую переменную — с пометкой (typecheck), то есть ошибку компилятора. Сверх vet в прогоне сработали errcheck и ineffassign. golangci-lint — сторонняя программа, в поставку Go не входит, и его набор линтеров настраивается проектом; go vet не требует ничего, кроме Go.

Механизм 4: go test запускает vet — но не весь

Многие уверены, что раз go test прошёл, vet тоже пройден. Документация go test уточняет:

Only a high-confidence subset of the default go vet checks are used. That subset is: atomic, bool, buildtags, directive, errorsas, ifaceassert, nilfunc, printf, stringintconv, and tests.

перевод

Используется только подмножество проверок go vet по умолчанию, в которых высока уверенность. Это подмножество: atomic, bool, buildtags, directive, errorsas, ifaceassert, nilfunc, printf, stringintconv и tests.

— Команда go — go test

printf в списке есть, copylocks — нет. Два пакета с тестами: в одном fmt.Sprintf с неверным глаголом, в другом — тот же Counter с получателем-значением:

5) go vet ВНУТРИ go test
────────────────────────
  go test ./testvet/printf/
    testvet/printf/greet.go:7:9: fmt.Sprintf format %d has arg name of wrong type string
    FAIL	example.com/toolcases/testvet/printf [build failed]
    FAIL
  go test -v ./testvet/copylock/
    === RUN   TestInc
        counter_test.go:8: after Inc: 0
    --- PASS: TestInc
    PASS
    ok  	example.com/toolcases/testvet/copylock
  go test -vet=all ./testvet/copylock/
    testvet/copylock/counter.go:11:9: Inc passes lock by value: example.com/toolcases/testvet/copylock.Counter contains sync.Mutex
    FAIL	example.com/toolcases/testvet/copylock [build failed]
    FAIL

Первый пакет до тестов не дошёл: [build failed] — vet для go test часть сборки, и находка останавливает её. Второй прошёл зелёным, хотя тест сам напечатал after Inc: 0. С -vet=all тот же пакет падает.

Почему подмножество? Судя по словам «высока уверенность», go test не должен падать на ложных срабатываниях, а vet, как сказано выше, эвристичен. Вывод для проекта простой: go vet ./... в CI — отдельный шаг, а не «уже есть в go test». Обратное тоже полезно знать: если тест падает с [build failed], а компилятор доволен, — это vet, и сообщение над строкой FAIL говорит, какая проверка.

Механизм 5: ограничения сборки и кросс-компиляция

Один пакет может содержать файлы для разных платформ. Какие из них попадут в сборку, решают два механизма: суффикс имени файла и строка //go:build.

If a file's name, after stripping the extension and a possible _test suffix, matches any of the following patterns: *_GOOS *_GOARCH *_GOOS_GOARCH (example: source_windows_amd64.go) where GOOS and GOARCH represent any known operating system and architecture values respectively, then the file is considered to have an implicit build constraint requiring those terms (in addition to any explicit constraints in the file).

перевод

Если имя файла без расширения и возможного суффикса _test подходит под один из шаблонов *_GOOS, *_GOARCH, *_GOOS_GOARCH (например, source_windows_amd64.go), то у файла считается неявное ограничение сборки, требующее этих значений (в дополнение к явным ограничениям в файле).

— Команда go — ограничения сборки

В cases/tags шесть файлов: platform_linux.go и platform_windows.go — по суффиксу; platform_other.go — со строкой //go:build !linux && !windows; debug_on.go и debug_off.go — со строками //go:build debug и //go:build !debug; шестой — main.go, без ограничений:

GO
//go:build debug
 
package main
 
const debug = true

Что попадает в пакет — показывает go list:

7) ОГРАНИЧЕНИЯ СБОРКИ: ИМЯ ФАЙЛА И //go:build
─────────────────────────────────────────────
  файлы в tags/: debug_off.go debug_on.go main.go platform_linux.go platform_other.go platform_windows.go
  linux (по умолчанию)   [debug_off.go main.go platform_linux.go]
  GOOS=windows           [debug_off.go main.go platform_windows.go]
  GOOS=darwin            [debug_off.go main.go platform_other.go]
  -tags debug            [debug_on.go main.go platform_linux.go]

Из шести файлов в каждую сборку попадают три. Функция platform() объявлена трижды — и это не ошибка, потому что в одну сборку попадает ровно одно объявление. -tags debug добавляет пользовательский тег, и вместо debug_off.go берётся debug_on.go. По go help buildconstraint строка //go:build должна стоять до package, а за ней должна идти пустая строка — чтобы строку не приняли за документацию пакета.

Подвох суффикса — в том, что он срабатывает и без желания автора. Файл handler_windows.go, названный так «потому что про окна интерфейса», на Linux в сборку не попадёт — так же, как platform_windows.go в прогоне, — и всё, что в нём объявлено, для остального пакета просто не существует.

Те же переменные GOOS и GOARCH переключают и целевую платформу всей сборки:

наблюдение замераbench/gotool/internals.go, раздел 8, go1.24.7 на linux/amd64. Размеры бинарников зависят от версии Go: на go1.25.1 те же программы на 1–2 % больше, и пакетов собирается 58–62.
8) КРОСС-КОМПИЛЯЦИЯ: GOOS И GOARCH
──────────────────────────────────
  GOOS=linux   GOARCH=amd64 CGO_ENABLED=1  compile:  0  ELF, EM_X86_64                 2 224 064 байт
  GOOS=linux   GOARCH=arm64 CGO_ENABLED=0  compile: 55  ELF, EM_AARCH64                2 252 642 байт
  GOOS=windows GOARCH=amd64 CGO_ENABLED=0  compile: 57  PE, IMAGE_FILE_MACHINE_AMD64   2 306 560 байт
  GOOS=darwin  GOARCH=arm64 CGO_ENABLED=0  compile: 54  Mach-O, CpuArm64               2 335 714 байт

На одной машине linux/amd64, одной командой go build, получились ELF для ARM, PE для Windows и Mach-O для macOS на Apple Silicon. Столбец compile показывает, как это возможно: для новой пары GOOS/GOARCH стандартная библиотека компилируется из исходников тем же компилятором (55, 57 и 54 пакета), а для родной linux/amd64 всё уже лежало в кеше. Отдельный «кросс-тулчейн» не нужен.

Одно условие у этого есть, и оно в столбце CGO_ENABLED: на родной платформе 1, при кросс-компиляции — 0.

It is disabled by default when cross-compiling as well as when the CC environment variable is unset and the default C compiler (typically gcc or clang) cannot be found on the system PATH.

перевод

По умолчанию он выключен при кросс-компиляции, а также когда переменная CC не задана и компилятор C по умолчанию (обычно gcc или clang) не найден в PATH.

— Команда cgo

Пока программа не использует cgo, кросс-компиляция — это две переменные окружения. Как только в зависимостях появляется код на C, нужен компилятор C для целевой платформы — и это уже не «две переменные».

Глубже: что делает -ldflags — компоновщик подставляет строки и выбрасывает символы

контракт языкаФлаги -X, -s и -w описаны в документации команды link (go doc cmd/link); прогон bench/gotool/internals.go, раздел 9, go1.24.7, показывает их на программе cases/stamp.

Флаг -ldflags передаёт аргументы компоновщику (go tool link). На собеседовании о нём спрашивают в двух связках: версия в бинарнике и его размер.

Версия при сборке — -X. Компоновщик может записать строку в строковую переменную пакета:

Set the value of the string variable in importpath named name to value. This is only effective if the variable is declared in the source code either uninitialized or initialized to a constant string expression. -X will not work if the initializer makes a function call or refers to other variables.

перевод

Задаёт значение строковой переменной name в пакете importpath. Действует, только если переменная объявлена в исходнике неинициализированной или инициализированной константным строковым выражением. -X не сработает, если инициализатор вызывает функцию или ссылается на другие переменные.

— Команда link — флаг -X

Программа cases/stamp с тремя переменными на три случая из этой цитаты:

GO
// Неинициализированная строка: -X её заполнит.
var commit string
 
// Инициализирована константной строкой: -X её заменит.
var version = "dev"
 
// Инициализирована вызовом функции: -X не сработает — и ничего не скажет.
var built = defaultBuilt()
 
func defaultBuilt() string { return fmt.Sprint("unknown") }
9) -ldflags: ВЕРСИЯ ЧЕРЕЗ -X И РАЗМЕР С -s -w
─────────────────────────────────────────────
  go run ./stamp
    version = dev
    commit  =
    built   = unknown
  go run -ldflags "-X main.version=1.4.2 -X main.commit=9f3c2ab -X main.built=2026-10-14" ./stamp
    version = 1.4.2
    commit  = 9f3c2ab
    built   = unknown
  go run -ldflags "-X main.verison=1.4.2" ./stamp   (опечатка в имени)
    version = dev
    commit  =
    built   = unknown
    код выхода: 0

Две ловушки видны сразу. built осталась unknown: инициализатор вызывает функцию, и -X её не тронул — без единого сообщения. И опечатка verison вместо version дала код выхода 0: переменной с таким именем нет, и компоновщик молча ничего не сделал. В CI это значит, что сломанная строка сборки выпустит бинарник с версией dev, и заметит это только тот, кто спросит у бинарника его версию. Защита дешёвая: после сборки запустить ./app -version и сравнить с ожидаемым.

Размер — -s -w. По документации -s убирает таблицу символов и отладочную информацию, -w — таблицу DWARF. В прогоне это 2 224 064 байт против 1 446 072 — в 1,54 раза меньше. Причём -s сам включает -w — по документации Implies the -w flag (подразумевает флаг -w), — и -ldflags "-s" в прогоне дал те же 1 446 072 байта: -s -w — привычная, но избыточная запись. Чего стоит такое уменьшение? Отладчику нечего читать: секций .symtab и .debug_info в файле нет. А трассировка паники остаётся:

  паника в бинарнике, собранном с -s -w:
    exe: <tmp>/hello-sw
    panic: boom
    goroutine 1 [running]:
    main.main()
    	<cases>/hello/main.go:18 +0x<N>
    код выхода: 2

Имя функции и строка исходника на месте: рантайм хранит их в своей таблице, отдельно от таблицы символов ELF и от DWARF, — иначе после -s -w им неоткуда было бы взяться. Поэтому -s -w убирает то, что нужно отладчику, но не трогает читаемость паник.

Как отвечать на собеседовании

Короткий ответ: go build компилирует и оставляет бинарник, go run собирает во временный каталог без отладочной информации и запускает; обе берут пакеты из кеша сборки, ключ которого — содержимое входов. gofmt приводит код к единственному формату, и спорить с ним не принято. go vet ловит ошибки, которые компилятор пропускает, — неверный глагол Printf, копию мьютекса, недостижимый код; go test запускает только часть его проверок, поэтому go vet ./... в CI — отдельный шаг.

Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.

Если интервьюер копает глубже

На «почему вторая сборка мгновенная» — кеш и его ключ. «В прогоне холодная сборка вызвала компилятор 55 раз — почти все вызовы приходятся на стандартную библиотеку, — повторная ни разу. Ключ — содержимое входов: touch кеш не сбрасывает, правка комментария перекомпилирует пакет. Если бинарник уже собран из тех же входов, go build не вызывает даже компоновщик».

На «чем go run хуже go build в CI» — код выхода и отладка. «go run собирает без DWARF и символов — бинарник совпал по размеру с -ldflags "-s -w". И код выхода программы он не передаёт: os.Exit(3) превратился в 1».

На «код компилируется — зачем vet» — граница по типам. «Компилятор проверяет типы. Printf принимает ...any, копировать структуру с мьютексом законно. Что %d ждёт число и что получатель-значение копирует замок, — это vet. А проигнорированную ошибку не видит и vet — нужен errcheck».

На «как собрать под Windows» — GOOS и cgo. «GOOS=windows go build — стандартная библиотека скомпилируется под цель тем же компилятором. Работает, пока нет cgo: при кросс-компиляции он выключен по умолчанию, и для кода на C нужен компилятор C под цель. Файлы под платформу выбираются суффиксом имени или //go:build».

На «как вшить версию» — -X и его молчание. «-ldflags "-X main.version=…" — переменная должна быть строкой без вызова функции в инициализаторе. Опечатка в имени ошибки не даёт, поэтому после сборки стоит спросить у бинарника его версию».

Дальше спросят

Спросят дальше

Что делает go generate и почему его надо запускать руками?

Короткий ответ

Он ищет в файлах комментарии //go:generate команда аргументы и запускает эти команды — больше ничего. Сам он код не порождает:

Go generate is never run automatically by go build, go test, and so on. It must be run explicitly.

перевод

go generate никогда не запускается автоматически командами go build, go test и другими. Его нужно запускать явно.

— Команда go — go generate
10) go generate: ТОЛЬКО ПО ЯВНОМУ ВЫЗОВУ
────────────────────────────────────────
  go build .
    ./main.go:9:28: undefined: codes
  go generate -x .
    go run gen.go
  файлы после: codes_gen.go gen.go main.go
  go run .
    codes: 3

main.go ссылается на codes, которую пишет генератор, — до go generate сборка падает с undefined. Поэтому сгенерированные файлы держат в репозитории: тот, кто делает go build или go install, генератор не запускает и, возможно, не имеет. В сгенерированном файле до первого кода по соглашению из go help generate стоит строка // Code generated … DO NOT EDIT. — по ней инструменты и люди понимают, что править его руками бесполезно.

Спросят дальше

Если собрать с -s -w, паника станет нечитаемой?

Короткий ответ

Нет. В прогоне бинарник, собранный с -ldflags "-s -w", на панике напечатал main.main() и <cases>/hello/main.go:18 — имя функции и строку. Таблицу функций и строк рантайм хранит отдельно от таблицы символов ELF и от DWARF: она нужна ему самому, чтобы печатать стек.

Теряется то, чем пользуется отладчик: секций .symtab и .debug_info в файле нет. Поэтому -s -w ставят на production-сборку, а для отладки собирают без них. Путь к исходнику в трассировке, кстати, абсолютный — по документации go build флаг -trimpath убирает из бинарника все пути файловой системы.

Частые заблуждения

Утверждение

Раз код скомпилировался, go vet ничего не найдёт

На самом деле

Компилятор проверяет типы, а не смысл формат-строк и не копирование замков. В прогоне Printf("%d") со строкой, копия sync.Mutex, недостижимый код и выброшенный fmt.Sprintf собрались без ошибок — go vet поймал все четыре.

Утверждение

go test уже запускает go vet, отдельный шаг не нужен

На самом деле

Запускает подмножество из десяти проверок: printf в нём есть, copylocks — нет. Тест пакета с копией мьютекса прошёл и напечатал after Inc: 0; с -vet=all — [build failed].

Утверждение

go run собирает то же, что go build, только сразу запускает

На самом деле

Он собирает без DWARF и таблицы символов: бинарник совпал по размеру с go build -ldflags "-s -w". Кладёт его во временный каталог, повторно (в go1.24.7 и go1.25.1) запускает из кеша — и код выхода программы не передаёт: os.Exit(3) стал кодом 1.

Утверждение

Открывающую скобку можно перенести на новую строку, это вопрос стиля

На самом деле

Это синтаксическая ошибка: после ) в конце строки лексер вставляет точку с запятой. gofmt такой файл не форматирует, а сообщает unexpected semicolon or newline before.

Утверждение

Чтобы собрать бинарник под Windows, нужна Windows или кросс-компилятор

На самом деле

Достаточно GOOS=windows go build: стандартная библиотека собирается под цель тем же компилятором, в прогоне получился файл PE. Условие одно — без cgo: при кросс-компиляции он выключен по умолчанию.

Утверждение

Если -X не нашёл переменную, сборка упадёт

На самом деле

Не упадёт: опечатка main.verison дала код выхода 0 и версию dev. Так же молча -X пропускает переменную, чей инициализатор вызывает функцию.

Практика

Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.

Практика · что напечатает

Программа собрана командой из последней строки. В уроке такого случая не было — вспомните цитату из документации -X целиком. Что программа напечатает?
package main

import "fmt"

const base = "dev"

var tag = "v0"
var commit string
var version = base + "-local"
var channel = tag

func main() {
fmt.Println(version, channel, commit)
}

// go build -ldflags "-X main.version=1.4.2 -X main.channel=stable -X main.commit=9f3c2ab"

Практика · оцените

Две программы, go build без флагов, go1.24.7, linux/amd64: hello из одного fmt.Println и HTTP-сервер из десяти строк на net/http. Во сколько раз бинарник сервера больше?
раза

Проверка знаний

Вопрос 1 из 5

Метод Inc объявлен с получателем-значением у структуры с полем типа sync.Mutex. go build проходит, go test проходит. Что скажет go vet?

Источники и что читать дальше

11 ИСТОЧНИКОВ

  1. Команда go — go runОфициальная документация. Чем бинарник go run отличается от go build: «By default, 'go run' compiles the binary without generating the information used by debuggers, to reduce build time» (По умолчанию go run компилирует бинарник без информации, нужной отладчикам, чтобы сократить время сборки). И про код выхода: «The exit status of Run is not the exit status of the compiled binary» (Код выхода Run — не код выхода скомпилированного бинарника).https://pkg.go.dev/cmd/go#hdr-Compile_and_run_Go_program
  2. Команда go — кеш сборки и тестовОфициальная документация. Что учитывает кеш: «The build cache correctly accounts for changes to Go source files, compilers, compiler options, and so on: cleaning the cache explicitly should not be necessary in typical use» (Кеш сборки правильно учитывает изменения исходников Go, компиляторов, флагов компилятора и так далее: явно очищать кеш при обычной работе не нужно). Где он лежит: «running 'go env GOCACHE' prints the current cache directory» (go env GOCACHE печатает текущий каталог кеша).https://pkg.go.dev/cmd/go#hdr-Build_and_test_caching
  3. Команда vetОфициальная документация. Зачем vet при наличии компилятора и чего от него не ждать: «Vet uses heuristics that do not guarantee all reports are genuine problems, but it can find errors not caught by the compilers» (Vet пользуется эвристиками, которые не гарантируют, что каждое сообщение — настоящая ошибка, но он может найти ошибки, которые компиляторы пропускают).https://pkg.go.dev/cmd/vet
  4. Команда go — go test и vetОфициальная документация. Какие проверки vet запускает go test: «Only a high-confidence subset of the default go vet checks are used. That subset is: atomic, bool, buildtags, directive, errorsas, ifaceassert, nilfunc, printf, stringintconv, and tests» (Используется только подмножество проверок go vet по умолчанию, в которых высока уверенность. Это подмножество: atomic, bool, buildtags, directive, errorsas, ifaceassert, nilfunc, printf, stringintconv и tests). Что происходит при находке: «If go vet finds any problems, go test reports those and does not run the test binary» (Если go vet находит проблемы, go test сообщает о них и не запускает тестовый бинарник). Текст одинаков в go help test у go1.24.7 и go1.25.1.https://pkg.go.dev/cmd/go#hdr-Test_packages
  5. Команда go — ограничения сборкиОфициальная документация. Суффикс имени файла: «If a file's name, after stripping the extension and a possible _test suffix, matches any of the following patterns: *_GOOS *_GOARCH *_GOOS_GOARCH (example: source_windows_amd64.go) where GOOS and GOARCH represent any known operating system and architecture values respectively, then the file is considered to have an implicit build constraint requiring those terms (in addition to any explicit constraints in the file)» (Если имя файла без расширения и возможного суффикса _test подходит под один из шаблонов *_GOOS, *_GOARCH, *_GOOS_GOARCH (например, source_windows_amd64.go), где GOOS и GOARCH — любые известные значения операционной системы и архитектуры, то у файла считается неявное ограничение сборки, требующее этих значений (в дополнение к явным ограничениям в файле)).https://pkg.go.dev/cmd/go#hdr-Build_constraints
  6. Команда link — флаги -X, -s, -wОфициальная документация. Флаг -X: «Set the value of the string variable in importpath named name to value. This is only effective if the variable is declared in the source code either uninitialized or initialized to a constant string expression. -X will not work if the initializer makes a function call or refers to other variables» (Задаёт значение строковой переменной name в пакете importpath. Действует, только если переменная объявлена в исходнике неинициализированной или инициализированной константным строковым выражением. -X не сработает, если инициализатор вызывает функцию или ссылается на другие переменные). Флаг -s: «Omit the symbol table and debug information. Implies the -w flag, which can be negated with -w=0» (Не записывать таблицу символов и отладочную информацию. Подразумевает флаг -w, который можно отменить через -w=0); флаг -w: «Omit the DWARF symbol table» (Не записывать таблицу символов DWARF).https://pkg.go.dev/cmd/link
  7. Команда gofmtОфициальная документация. Что делает gofmt: «Gofmt formats Go programs. It uses tabs for indentation and blanks for alignment» (Gofmt форматирует программы на Go. Для отступов он использует табуляцию, для выравнивания — пробелы). И с чем он работает: «Given a file, it operates on that file; given a directory, it operates on all .go files in that directory, recursively» (Получив файл, он обрабатывает этот файл; получив каталог — все .go-файлы в этом каталоге, рекурсивно).https://pkg.go.dev/cmd/gofmt
  8. Команда cgo — кросс-компиляцияОфициальная документация. Почему кросс-компиляция не требует компилятора C: «It is disabled by default when cross-compiling as well as when the CC environment variable is unset and the default C compiler (typically gcc or clang) cannot be found on the system PATH» (По умолчанию он выключен при кросс-компиляции, а также когда переменная CC не задана и компилятор C по умолчанию (обычно gcc или clang) не найден в PATH).https://pkg.go.dev/cmd/cgo
  9. Спецификация Go — SemicolonsОфициальная документация. Почему открывающую скобку нельзя перенести: «When the input is broken into tokens, a semicolon is automatically inserted into the token stream immediately after a line's final token if that token is» (При разбиении входа на лексемы точка с запятой автоматически вставляется в поток лексем сразу после последней лексемы строки, если эта лексема —) — далее список, в котором есть идентификатор и закрывающая скобка «)».https://go.dev/ref/spec#Semicolons
  10. cmd/go/internal/run/run.go и work/gc.go, go1.24.7Исходный код Go. Как go run собирает бинарник: run.go, строка 148 — p.Internal.OmitDebug = true; строка 172 — a1.CacheExecutable = true (в go1.25.1 — строка 170). В work/gc.go при OmitDebug пакет компилируется с -dwarf=false (строки 110–111), а компоновщику передаются -s и -w (строки 612–613).https://github.com/golang/go/blob/go1.24.7/src/cmd/go/internal/run/run.go
  11. Команда go — go generateОфициальная документация. Когда запускается генерация: «Go generate is never run automatically by go build, go test, and so on. It must be run explicitly» (go generate никогда не запускается автоматически командами go build, go test и другими. Его нужно запускать явно).https://pkg.go.dev/cmd/go#hdr-Generate_Go_files_by_processing_source