Инструменты 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.
Что здесь на самом деле спрашивают
Лестница почти всегда такая:
- «Чем
go runотличается отgo build?» — разминка; ответ «run ещё и запускает» верен, но неполон. - «Почему вторая сборка мгновенная?» — про кеш сборки.
- «Зачем
gofmtи можно ли с ним спорить?» — проверяют, понимает ли кандидат, почему формат в Go один. - «Код компилируется — зачем ещё
go vet?» — главный вопрос: что компилятор пропускает и что из этого ловит vet. - «Как собрать бинарник под Windows на Linux?» — про
GOOS,GOARCHи ограничения сборки. - «Как вшить версию в бинарник?» — про
-ldflags.
Стержень у лестницы один: у каждого инструмента своя граница — что он видит
и что пропускает. Компилятор проверяет типы, но не формат-строки; vet
проверяет формат-строки, но не проигнорированные ошибки; go test запускает
vet, но не весь; кеш видит содержимое файлов, но не время их изменения.
Ответы, которые отличают знающего, — это знание границ.
База: одна команда, несколько подкоманд
Всё, о чём спрашивают, — подкоманды одной программы 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:
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 сравнивает его с тем, что
получилось бы сейчас: совпало — файл не трогается.
Что считается «теми же входами»? Документация обещает:
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, компиляторов, флагов компилятора и так далее: явно очищать кеш при обычной работе не нужно.
И прогон показывает, по какому признаку он это делает:
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 компилирует бинарник без информации, нужной отладчикам, чтобы сократить время сборки.
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 не
вызвал ни компилятор, ни компоновщик: он запустил бинарник прямо из кеша
сборки.
Это видно и в исходнике 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 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 нет: по go doc cmd/gofmt его флаги — -d, -e,
-l, -r, -s и -w, и ни один не задаёт ни ширину отступа, ни место
скобки. Это и
есть ответ на «можно ли с ним спорить»: нельзя, и в этом смысл. Код любого
проекта на Go выглядит одинаково, ревью не тратится на пробелы, а diff
показывает только изменения смысла.
Одну вещь gofmt не исправит, потому что это не стиль. Открывающая скобка на
своей строке:
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
При разбиении входа на лексемы точка с запятой автоматически вставляется в поток лексем сразу после последней лексемы строки, если эта лексема —
— и дальше в списке есть закрывающая скобка ). Строка 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 пользуется эвристиками, которые не гарантируют, что каждое сообщение — настоящая ошибка, но он может найти ошибки, которые компиляторы пропускают.
Семь подопытных программ, по одной ошибке в каждой. Например, printf и
copylock:
name := "gopher"
fmt.Printf("hello, %d\n", name)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.
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), то у файла считается неявное ограничение сборки, требующее этих значений (в дополнение к явным ограничениям в файле).
В 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: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 переключают и целевую платформу всей
сборки:
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, кросс-компиляция — это две переменные окружения. Как только в зависимостях появляется код на C, нужен компилятор C для целевой платформы — и это уже не «две переменные».
Глубже: что делает -ldflags — компоновщик подставляет строки и выбрасывает символы
Флаг -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 не сработает, если инициализатор вызывает функцию или ссылается на другие переменные.
Программа cases/stamp с тремя переменными на три случая из этой цитаты:
// Неинициализированная строка: -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 и другими. Его нужно запускать явно.
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 пропускает переменную, чей инициализатор вызывает функцию.
Практика
Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.
Практика · что напечатает
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"Практика · оцените
Проверка знаний
Метод Inc объявлен с получателем-значением у структуры с полем типа sync.Mutex. go build проходит, go test проходит. Что скажет go vet?
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
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.
На самом деле
- Компилятор проверяет типы, а не смысл формат-строк и не копирование замков. В прогоне
Printf("%d")со строкой, копияsync.Mutex, недостижимый код и выброшенныйfmt.Sprintfсобрались без ошибок —go vetпоймал все четыре. - Запускает подмножество из десяти проверок:
printfв нём есть,copylocks— нет. Тест пакета с копией мьютекса прошёл и напечаталafter Inc: 0; с-vet=all—[build failed]. - Он собирает без DWARF и таблицы символов: бинарник совпал по размеру с
go build -ldflags "-s -w". Кладёт его во временный каталог, повторно (в go1.24.7 и go1.25.1) запускает из кеша — и код выхода программы не передаёт:os.Exit(3)стал кодом 1. - Это синтаксическая ошибка: после
)в конце строки лексер вставляет точку с запятой.gofmtтакой файл не форматирует, а сообщаетunexpected semicolon or newline before. - Достаточно
GOOS=windows go build: стандартная библиотека собирается под цель тем же компилятором, в прогоне получился файл PE. Условие одно — без cgo: при кросс-компиляции он выключен по умолчанию. - Не упадёт: опечатка
main.verisonдала код выхода 0 и версиюdev. Так же молча-Xпропускает переменную, чей инициализатор вызывает функцию.
Что разобрано
- Что здесь на самом деле спрашивают
- База: одна команда, несколько подкоманд
- Механизм 1: кеш сборки — и что на самом деле делает `go run`
- Механизм 2: `gofmt` — формат, который не обсуждается
- Механизм 3: что компилятор пропускает, а `go vet` ловит
- Механизм 4: `go test` запускает vet — но не весь
- Механизм 5: ограничения сборки и кросс-компиляция
- Глубже: что делает `-ldflags` — компоновщик подставляет строки и выбрасывает символы
- Как отвечать на собеседовании
- Дальше спросят
- Частые заблуждения
- Практика
- Проверка знаний
Источники и что читать дальше
11 ИСТОЧНИКОВ
- Команда 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
- Команда 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
- Команда 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
- Команда 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
- Команда 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
- Команда 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
- Команда 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
- Команда 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
- Спецификация 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
- 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 - Команда 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