Пакеты в Go: видимость, init и структура проекта
Собеседование по пакетам идёт лестницей: как сделать имя приватным — что такое пакет и при чём тут каталог — зачем internal — в каком порядке выполняются init — зачем импорт с подчёркиванием — как разложить проект. Прогон отвечает на каждый шаг: переменная, объявленная первой, инициализируется последней; пакет alpha инициализируется раньше zeta, хотя main импортирует zeta первым; импорт из internal запрещает не компилятор, а команда go; а у одного и того же нарушения видимости два разных сообщения компилятора.
Полное техническое изложение
TL;DR
Пакет — это каталог исходных файлов с одним и тем же именем в строке
package; он единица видимости и единица инициализации. Из другого пакета
видны только имена, которые начинаются с заглавной буквы, — модификаторов
доступа в Go нет. Импортируют пакет по пути (.../naming/strutil), а
обращаются по имени из его строки package (textkit.Shout) — и это не одно
и то же.
Порядок инициализации задаёт граф зависимостей, а не порядок в тексте.
var total = price*qty, объявленная первой, инициализируется третьей — после
price и qty. Функции init() вызываются после всех переменных пакета, по
порядку файлов. Пакет инициализируется только после всех, кого он
импортирует, и ровно один раз; среди готовых берётся первый по пути импорта —
поэтому alpha в прогоне инициализирован раньше zeta, хотя main
импортирует zeta первым.
Дальше — то, что отличает понимание от пересказа. Импорт из internal/
запрещает не компилятор и не спецификация, а команда go: use of internal package ... not allowed. cmd/ и pkg/ — соглашения, которых не проверяет
никто. Экспортируемая функция может вернуть значение неэкспортируемого типа —
вызвать его методы можно, назвать тип нельзя. У одного нарушения видимости два
разных сообщения компилятора (наблюдение gc go1.24.7, а не правило языка), и
какое выпадет, зависит от встраивания функций — подстановки тела функции в
место вызова.
Пустой импорт _ "image/png" нужен ради одного init, который регистрирует
декодер, — без него image.Decode отвечает image: unknown format. А
программа с одним fmt — это десятки пакетов, и все они проходят
инициализацию до main.
- как написать функцию, тип и переменную на уровне пакета;
- что такое структура, её поля и метод с получателем — об этом урок о структурах;
- что программа на Go начинается с функции
mainв пакетеmain; - как собрать и запустить программу командами
go buildиgo run— об этом урок об инструментах go (там же — ограничения сборки, которые здесь упоминаются мимоходом). - Тесты
_test.goи интерфейсы встретятся в паре мест; подробно о них — в уроках о тестах и об интерфейсах, они идут по линии позже.
- одно имя пакета на каталог, путь импорта против имени пакета,
go list; - экспорт по первой букве, неэкспортируемый тип в экспортируемом API,
internal/и соглашения раскладки; - порядок инициализации переменных, файлов,
init()и пакетов, цикл импортов, пустой импорт.
Что здесь на самом деле спрашивают
Лестница почти всегда такая:
- «Как в Go сделать имя приватным?» — разминка; ответ «с маленькой буквы» верен, но это половина правила.
- «Что такое пакет и как он связан с каталогом?» — проверяют, отличает ли кандидат путь импорта от имени пакета.
- «Зачем каталог
internal?» — и кто именно запрещает такой импорт. - «В каком порядке выполняются
init?» — главный вопрос: переменные, файлы, пакеты. - «Зачем импорт с подчёркиванием?» — драйверы
database/sql, форматыimage. - «Как разложить проект?» —
cmd/,internal/,pkg/: что из этого правило, а что привычка.
Стержень у лестницы один: у каждого правила о пакетах свой владелец —
спецификация языка, компилятор, команда go или никто. Экспорт и порядок
инициализации — язык. Одно имя на каталог и internal/ — команда go. cmd/
и pkg/ — соглашение. Сильный ответ начинается с того, кто проверяет
правило.
База: пакет — каталог файлов с одним именем
Спецификация определяет пакет через файлы, а не через каталог, — и оставляет каталог на усмотрение реализации:
A set of files sharing the same PackageName form the implementation of a
package. An implementation may require that all source files for a package
inhabit the same directory.
Набор файлов с одним и тем же PackageName образует реализацию пакета. Реализация может требовать, чтобы все исходные файлы пакета лежали в одном каталоге.
Команда go этим правом пользуется: пакет — это каталог, и все .go-файлы
в нём, которые входят в сборку, обязаны начинаться с одного и того же
package. Два файла с
package alpha и package beta в одном каталоге не собираются:
Вывод bench/gopkg/internals.go, раздел 1 (обе программы намеренно не собираются):
1) ПАКЕТ — ЭТО КАТАЛОГ: ОДНО ИМЯ НА КАТАЛОГ
───────────────────────────────────────────
go build ./twopkg (alpha.go: package alpha, beta.go: package beta)
found packages alpha (alpha.go) and beta (beta.go) in <cases>/twopkg
go build ./xtestname (util.go: package util, helper.go: package util_test)
found packages util_test (helper.go) and util (util.go) in <cases>/xtestname
Второй случай — подсказка к единственному исключению: имя с суффиксом _test
в файле, который сам называется *_test.go. Такой файл — внешний тест,
отдельный пакет в том же каталоге. В обычном файле то же имя — та же ошибка.
Внутри пакета видно всё — из любого файла, без импортов друг друга. Снаружи
видно только экспортируемое. Этого достаточно для ответа уровня Junior:
пакет — каталог .go-файлов с одним именем в строке package. Имена с
заглавной буквы видны из других пакетов, остальные — только внутри пакета.
Импортируют пакет по пути, обращаются — по имени пакета.
Всё это помещается в самый короткий полный пример: модуль из двух пакетов.
Модуль — дерево пакетов под одним файлом go.mod, который задаёт префикс
путей импорта; подробно — в уроке о модулях.
Три файла bench/gopkg/cases/example можно скопировать в пустой каталог и
запустить командой go run . рядом с go.mod.
Полный файл go.mod:
module example.com/hello
go 1.24Полный файл greet/greet.go:
// Package greet is the second package of the lesson's full example.
package greet
import "fmt"
// calls is set by init before main starts.
var calls int
func init() { calls = 1 }
// Hello is exported: its name starts with an uppercase letter.
func Hello(name string) string {
return fmt.Sprintf("%s, %s (init ran: %v)", prefix(), name, calls == 1)
}
// prefix is not exported: only files of package greet can call it.
func prefix() string { return "hello" }Полный файл main.go:
// Full example of the lesson "Packages in Go": two packages in one module.
//
// Run in this directory (next to go.mod): go run .
package main
import (
"fmt"
"example.com/hello/greet"
)
func main() {
fmt.Println(greet.Hello("gopher"))
}Вывод bench/gopkg/internals.go, раздел 14:
14) ПОЛНЫЙ ПРИМЕР: ДВА ПАКЕТА В ОДНОМ МОДУЛЕ
────────────────────────────────────────────
cd cases/example && go list ./...
example.com/hello
example.com/hello/greet
cd cases/example && go run .
hello, gopher (init ran: true)
Путь импорта example.com/hello/greet — префикс модуля плюс каталог;
обращение — greet.Hello, по имени пакета. init пакета greet выполнилась
раньше main. А greet.prefix из main.go вызвать нельзя — имя с маленькой
буквы; что именно ответит компилятор, показано в Механизме 2.
Одну таблицу стоит запомнить сразу — она отвечает на вопрос «кто это проверяет», с которого начинается половина следующих шагов:
| правило | кто его владелец | что будет при нарушении |
|---|---|---|
| экспорт по первой букве | спецификация, проверяет компилятор | ошибка компиляции |
| порядок инициализации | спецификация | — (порядок просто такой) |
| цикл импортов, импорт без обращений | спецификация | ошибка сборки |
| одно имя пакета на каталог | команда go | found packages ... |
internal/ | команда go | use of internal package ... not allowed |
cmd/, pkg/ | никто — соглашение | ничего |
Механизм 1: путь импорта и имя пакета — разные вещи
Строка import называет путь — где пакет лежит. Обращение в коде идёт по
имени — тому, что стоит в строке package импортированного пакета.
Спецификация так и говорит: если имя при импорте не указано, берётся имя из
строки package. Обычно путь кончается тем же словом, что и имя, — но это
договорённость, а не правило. В прогоне каталог naming/strutil содержит
package textkit:
Вывод bench/gopkg/internals.go, раздел 3 (wrongname намеренно не собирается; правильный вариант — строки выше него):
3) ПУТЬ ИМПОРТА И ИМЯ ПАКЕТА — РАЗНЫЕ ВЕЩИ
──────────────────────────────────────────
go list -f '{{.ImportPath}} -> package {{.Name}}' ./naming/strutil
deepengineering/bench/gopkg/cases/naming/strutil -> package textkit
go run ./naming (import ".../naming/strutil"; textkit.Shout("hello"))
HELLO!
go run ./naming/alias (tk ".../naming/strutil"; tk.Shout("alias"))
ALIAS!
go build ./naming/wrongname (strutil.Shout — по имени каталога)
naming/wrongname/main.go:6:2: "deepengineering/bench/gopkg/cases/naming/strutil" imported as textkit and not used
naming/wrongname/main.go:10:14: undefined: strutil
Последний случай — расхождение пути и имени на практике: читатель видит
import ".../strutil", пишет strutil.Shout и получает две ошибки сразу.
Поэтому имя пакета держат равным последнему элементу пути, а при расхождении
пишут явный псевдоним: tk ".../strutil".
Что именно входит в пакет, решают не только строки package, но и имена
файлов. Самый быстрый способ это увидеть — go list с шаблоном по полям
структуры Package, которые описывает go help list:
Вывод bench/gopkg/internals.go, раздел 2:
2) ЧТО ВХОДИТ В ПАКЕТ: go list
──────────────────────────────
файлы в cases/listing:
_draft.go api_test.go gen.go listing.go listing_linux.go listing_test.go listing_windows.go testdata/
GOOS=linux go list -f '…' ./listing
Name=listing
GoFiles=[listing.go listing_linux.go]
IgnoredGoFiles=[gen.go listing_windows.go]
TestGoFiles=[listing_test.go]
XTestGoFiles=[api_test.go]
Семь файлов и каталог — а в сборку пакета под Linux попали два.
listing_windows.go и gen.go (с //go:build ignore) отсеяли ограничения
сборки — о них урок об инструментах go; с
GOOS=windows тот же go list меняет _linux и _windows местами. gen.go
объявляет package main, и ошибки found packages нет: правило «одно имя на
каталог» касается только файлов, вошедших в сборку. А _draft.go и
testdata/ не упомянуты нигде — в обоих лежит не Go, и go vet ./listing
всё равно проходит. Команда go их не читает. По go help packages, каталоги и файлы на . и _
команда go пропускает, as are directories named "testdata"
(как и каталоги с именем testdata).
Механизм 2: экспорт решает первая буква — и только она
В Go нет public и private. Видимость за пределами пакета решает одно
правило из спецификации:
An identifier is exported if both: the first character of the identifier's
name is a Unicode uppercase letter (Unicode character category Lu); and the
identifier is declared in the package block or it is a field name or method
name.
Идентификатор экспортируется, если выполнены оба условия: первый символ имени — заглавная буква Юникода (категория Lu); идентификатор объявлен в блоке пакета либо это имя поля или метода.
Пакет shape для проверки — в нём неэкспортируемыми сделаны тип, поле, метод
и функции, а экспортируемая New возвращает неэкспортируемый тип. Фрагмент
cases/visibility/shape/shape.go, тела функций сокращены:
type circle struct{ radius float64 }
type Box struct{ Width, height float64 }
func New(r float64) *circle { return &circle{radius: r} }
func (c *circle) Area() float64 { ... }
func (c *circle) scale(k float64) { ... }
func area(r float64) float64 { ... }
func perimeter(r float64) float64 { ... }
func Perimeter(r float64) float64 { return perimeter(r) }
func Ωmega() string { ... } // и ωmega, _HiddenИз другого пакета — что можно:
Вывод bench/gopkg/internals.go, раздел 4:
4) ЭКСПОРТ — ПО ПЕРВОЙ БУКВЕ
────────────────────────────
go run ./visibility/ok
c := shape.New(2) type *shape.circle
c.Area() 12.57
var d = shape.New(1) type *shape.circle
b.Width, b.Height() 3 4
{Width:3 height:4}
Ωmega: exported (Ω is Lu)
И что нельзя — все девять обращений в одном файле (программа намеренно не
собирается). Тексты — вывод gc go1.24.7: это наблюдение конкретной версии
компилятора, а не формулировка спецификации; go1.25.1 к части сообщений
добавляет подсказки вида (but have Ωmega).
Продолжение того же раздела:
go build ./visibility/bad
visibility/bad/main.go:10:20: undefined: shape.area
visibility/bad/main.go:11:20: name perimeter not exported by package shape
visibility/bad/main.go:12:14: name circle not exported by package shape
visibility/bad/main.go:15:16: p.radius undefined (cannot refer to unexported field radius)
visibility/bad/main.go:16:4: p.scale undefined (cannot refer to unexported method scale)
visibility/bad/main.go:18:16: b.height undefined (type shape.Box has no field or method height, but does have method Height)
visibility/bad/main.go:19:26: cannot refer to unexported field height in struct literal of type shape.Box
visibility/bad/main.go:20:20: undefined: shape.ωmega
visibility/bad/main.go:21:20: undefined: shape._Hidden
Из этих двух выводов следуют три вещи, и каждую спрашивают отдельно.
Неэкспортируемый тип можно вернуть наружу. shape.New(2) вернул
*shape.circle, и экспортируемый метод Area вызвался. Нельзя другое —
назвать тип: name circle not exported.
Видимость решается для каждого имени отдельно. Box
экспортирован, его поле height — нет: ни прочитать, ни задать в литерале.
fmt при этом печатает его ({Width:3 height:4}): он читает поля через
рефлексию, а правило экспорта ограничивает исходный код; чем ограничена сама
рефлексия — в уроке о рефлексии.
«Заглавная» — значит категория Юникода Lu, а не «латинская большая».
Ωmega экспортируется, ωmega — нет; _Hidden — нет, потому что первый
символ — подчёркивание.
Строки 10 и 11 нарушают правило одинаково, а сообщения у них разные:
undefined: shape.area и name perimeter not exported by package shape. Для
ответа достаточно знать, что оба — ошибка компиляции; почему тексты
расходятся, разобрано в «Глубже».
Механизм 3: internal/ — правило команды go, остальное — соглашения
Единственное запрещающее правило о раскладке каталогов, которое кто-то
проверяет, — про каталог internal. Записано оно не в спецификации (слова internal в этом
смысле там нет), а в документации команды go:
Code in or below a directory named "internal" is importable only by code in
the directory tree rooted at the parent of "internal".
Код в каталоге с именем internal и ниже него может импортировать только код из дерева каталогов, корень которого — родитель internal.
Раскладка в прогоне — app/internal/secret, рядом с ним app/cmd/server и
app/api, а снаружи app — outsider и viaapi:
Вывод bench/gopkg/internals.go, раздел 6 (outsider и stdinternal намеренно не собираются; рабочий путь — viaapi):
6) internal/: ИМПОРТ ТОЛЬКО ИЗНУТРИ ДЕРЕВА РОДИТЕЛЯ
───────────────────────────────────────────────────
go run ./internalpkg/app/cmd/server (внутри app/)
app/cmd/server: s3cr3t
go run ./internalpkg/viaapi (снаружи, через app/api)
outsider via app/api: s3cr3t
go build ./internalpkg/outsider (снаружи, напрямую)
package deepengineering/bench/gopkg/cases/internalpkg/outsider
internalpkg/outsider/main.go:6:2: use of internal package deepengineering/bench/gopkg/cases/internalpkg/app/internal/secret not allowed
go build ./internalpkg/stdinternal (import "internal/cpu")
package deepengineering/bench/gopkg/cases/internalpkg/stdinternal
internalpkg/stdinternal/main.go:5:2: use of internal package internal/cpu not allowed
go build ./internalpkg/app/internal/secret (сам пакет — из командной строки)
собрано без ошибок
Родитель — это app, а не модуль. outsider лежит в том же модуле, но
вне app — и получает отказ. internal можно положить на любой глубине, и
каждый закрывает своё поддерево; internal/cpu стандартной библиотеки лежит
в корне, и импортировать его может только она сама.
Закрыт импорт, а не данные. viaapi получил тот же s3cr3t через
экспортируемый app/api: internal запрещает написать import, но не мешает
пакетам внутри дерева отдавать что угодно наружу.
Проверяет команда go, а не компилятор. Сообщение печатает загрузчик
пакетов команды go (cmd/go/internal/load/pkg.go, функция disallowInternal,
строка 1472 в go1.24.7). Пакет, названный в командной строке, по комментарию
в ней — не импорт, и правило к нему не применяется: поэтому
go build ./internalpkg/app/internal/secret собрался.
cmd/ и pkg/ — соглашения, которых не проверяет никто
Особый смысл для команды go есть, например, у таких имён каталогов:
internal, vendor, testdata и у всего, что начинается с . или _.
cmd/ среди них нет:
Вывод bench/gopkg/internals.go, раздел 13 (importmain намеренно не собирается):
13) РАСКЛАДКА: cmd/ — СОГЛАШЕНИЕ, А НЕ ПРАВИЛО
──────────────────────────────────────────────
go build -o <tmp>/bin/ ./layout/cmd/greet ./layout/tools/anywhere
бинарники: anywhere greet
go run ./layout/tools/anywhere
package main in tools/anywhere: builds just the same
go build ./layout/importmain (импортирует пакет main из cmd/greet)
layout/importmain/main.go:6:2: import "deepengineering/bench/gopkg/cases/layout/cmd/greet" is a program, not an importable package
package main в tools/anywhere собрался так же, как в cmd/greet, а
бинарники названы не по имени пакета main, а по последнему элементу пути
импорта — обычно это имя каталога. Привычка класть команды в
cmd/<имя>/ полезна ровно этим: имя каталога становится именем бинарника. pkg/ команда go тоже никак не толкует. Правило, которое
действительно есть, — последняя строка: пакет main импортировать нельзя,
поэтому всё, что нужно кому-то ещё, выносят из него в обычный пакет.
Механизм 4: порядок инициализации — по зависимостям, а не по тексту
Это главный вопрос лестницы, и у него три уровня: переменные внутри пакета,
файлы и init(), пакеты между собой. Все три описаны в спецификации, и на
каждом пересказ «сверху вниз» неверен.
Переменные. На каждом шаге берётся самая ранняя по объявлению переменная, которая уже готова:
Within a package, package-level variable initialization proceeds stepwise,
with each step selecting the variable earliest in declaration order which has
no dependencies on uninitialized variables.
Внутри пакета переменные уровня пакета инициализируются по шагам, и на каждом шаге выбирается самая ранняя в порядке объявления переменная, у которой нет зависимостей от неинициализированных переменных.
Фрагмент cases/initvars/main.go:
var total = trace("total", price*qty)
var price = trace("price", 10)
var qty = trace("qty", 3)
func init() { fmt.Println("init() total =", total) }Вывод bench/gopkg/internals.go, раздел 7:
7) ПЕРЕМЕННЫЕ ПАКЕТА — ПО ЗАВИСИМОСТЯМ
──────────────────────────────────────
порядок в файле: total = price*qty, потом price, потом qty
go run ./initvars
var price = 10
var qty = 3
var total = 30
init() total = 30
main()
total объявлена первой, но не готова: она ссылается на price и qty.
Поэтому первой инициализирована price, затем qty, и только потом total.
init() вызвана после всех переменных пакета и видит total = 30.
Файлы и init(). Пакет инициализируется целиком: сначала все
переменные всех файлов, потом все init() — в порядке исходника, possibly in multiple files, as presented to the compiler
(возможно, в нескольких файлах — так, как они поданы компилятору).
«Как поданы компилятору» — не фигура речи. В initfiles четыре файла, в
каждом переменная и init(), в main.go — две init(), а needsZ из a.go
ссылается на fromZ из z.go:
Вывод bench/gopkg/internals.go, раздел 8:
8) НЕСКОЛЬКО ФАЙЛОВ, НЕСКОЛЬКО init()
─────────────────────────────────────
go list -f '{{.GoFiles}}' ./initfiles
[a.go b.go main.go z.go]
go run ./initfiles
var fromA (a.go)
var fromB (b.go)
var fromMain (main.go)
var fromZ (z.go)
var needsZ (a.go), uses fromZ (z.go)
init() in a.go
init() in b.go
init() #1 in main.go
init() #2 in main.go
init() in z.go
main()
Команда go подаёт файлы по имени — так советует спецификация ради
воспроизводимости. Если перечислить их руками в обратном порядке
(go run z.go main.go b.go a.go, тот же раздел прогона), переворачиваются и
переменные, и init(). Две init() в одном файле законны и
вызываются обе. А зависимость сильнее файла: needsZ объявлена в первом
файле, а инициализирована последней из переменных — она ждала fromZ.
Пакеты. Пакет инициализируется после всех, кого импортирует, и один раз; среди готовых — первый по пути импорта:
Given the list of all packages, sorted by import path, in each step the first
uninitialized package in the list for which all imported packages (if any) are
already initialized is initialized.
Из списка всех пакетов, отсортированного по пути импорта, на каждом шаге инициализируется первый неинициализированный пакет, все импортируемые пакеты которого уже инициализированы.
Вывод bench/gopkg/internals.go, раздел 9:
9) ПАКЕТЫ: ЗАВИСИМОСТИ РАНЬШЕ, КАЖДЫЙ ОДИН РАЗ
──────────────────────────────────────────────
main импортирует zeta, alpha, mid (в этом порядке); mid импортирует zeta
go run ./initdeps
alpha: var Name
alpha: init()
zeta: var Name
zeta: init()
mid: var Name, zeta.Name = zeta
mid: init()
main: var name
main: init()
main() zeta alpha mid
alpha раньше zeta, хотя main импортирует zeta первой строкой: порядок
импортов в файле не значит ничего, решает путь. mid по алфавиту раньше
zeta, но ждёт её — mid её импортирует. zeta импортируют двое, а
инициализирована она один раз. И каждый пакет — целиком: переменные, затем
init(), и только потом следующий пакет.
Вывод bench/gopkg/internals.go, раздел 10 (программа намеренно не собирается):
10) init НЕЛЬЗЯ ВЫЗВАТЬ
───────────────────────
go build ./initcall (main вызывает init())
initcall/main.go:8:2: undefined: init
По спецификации у init нет ни параметров, ни результатов, а сам
идентификатор init не объявляется — сослаться на функцию init нельзя
нигде. Значит, ошибку она не вернёт, а повторить её, например из теста, не
выйдет: всё, что может не получиться, делают в явной функции, которую
вызывает main.
Механизм 5: пустой импорт ради init — и почему циклы запрещены
Импорт без обращений — ошибка. Но иногда пакет нужен только ради его init.
Для этого спецификация оставила пустой идентификатор:
It is illegal for a package to import itself, directly or indirectly, or to
directly import a package without referring to any of its exported
identifiers. To import a package solely for its side-effects (initialization),
use the blank identifier as explicit package name.
Пакету запрещено импортировать самого себя, прямо или косвенно, и напрямую импортировать пакет, не обращаясь ни к одному из его экспортируемых идентификаторов. Чтобы импортировать пакет только ради побочных эффектов (инициализации), используйте пустой идентификатор как явное имя пакета.
Канонический пример — форматы изображений. image.Decode умеет только те
форматы, которые кто-то зарегистрировал, а регистрирует их init пакета
формата (image/png/reader.go, строки 1052–1053:
image.RegisterFormat("png", ...)):
Вывод bench/gopkg/internals.go, раздел 12 (unused намеренно не собирается; исправление — withpng):
12) ПУСТОЙ ИМПОРТ РАДИ init
───────────────────────────
go run ./blank/nopng (импортирован только image)
image.Decode: image: unknown format
go run ./blank/withpng (плюс import _ "image/png")
image.Decode: png 2 x 1
go build ./blank/unused (import "image/png" без _ и без обращений)
blank/unused/main.go:5:2: "image/png" imported and not used
Один и тот же PNG, один и тот же вызов; разница — init пакета image/png.
Без подчёркивания такой импорт не собирается.
Драйверы database/sql устроены так же. В стандартной библиотеке драйверов
нет, поэтому в прогоне вместо драйвера — заглушка fakedb, которая, как и
настоящие, вызывает sql.Register в init. Продолжение раздела 12:
go run ./blank/nodriver (только database/sql)
sql.Drivers(): []
sql.Open("fakedb"): sql: unknown driver "fakedb" (forgotten import?)
go run ./blank/withdriver (плюс import _ ".../blank/fakedb")
fakedb: init() -> sql.Register("fakedb", ...)
sql.Drivers(): [fakedb]
sql.Open("fakedb"): ok, db.Ping(): fakedb: not a real database
Подсказка (forgotten import?) вписана в текст ошибки прямо в исходнике
(database/sql/sql.go, строка 868 в go1.24.7). Обратная сторона приёма тоже
видна: init выполняется, даже если из пакета не вызвана ни одна функция, —
импорт сам по себе имеет последствия.
Цикл импортов. Если orders импортирует users, а users — orders,
ни один из них нельзя инициализировать «после всех, кого он импортирует».
Спецификация запрещает такое, а команда go называет весь путь цикла:
Вывод bench/gopkg/internals.go, раздел 11 (программы намеренно не собираются):
11) ЦИКЛ ИМПОРТОВ
─────────────────
go build ./cycle/orders (orders -> users -> orders)
package deepengineering/bench/gopkg/cases/cycle/orders
imports deepengineering/bench/gopkg/cases/cycle/users from orders.go
imports deepengineering/bench/gopkg/cases/cycle/orders from users.go: import cycle not allowed
go build ./cycle/self (пакет импортирует сам себя)
package deepengineering/bench/gopkg/cases/cycle/self
imports deepengineering/bench/gopkg/cases/cycle/self from self.go: import cycle not allowed
Запрет циклов и делает порядок «зависимости раньше» всегда выполнимым. Цена — в дизайне: пакеты, которым нужны типы друг друга, придётся развести.
Глубже: кто выстраивает пакеты, сколько их в сборке и откуда два сообщения
Порядок из Механизма 4 можно увидеть не только по строкам, которые печатает
программа. Переменная окружения GODEBUG=inittrace=1 заставляет среду
выполнения печатать строку на каждый пакет, у которого есть работа
инициализации, — в том порядке, в каком они инициализируются. Конец вывода
раздела 9:
GODEBUG=inittrace=1 ./initdeps (пакеты с работой инициализации, по порядку)
стандартная библиотека, 14: internal/bytealg runtime math errors iter sync internal/godebug syscall time internal/poll io/fs os unicode reflect
наши, 4: initdeps/alpha initdeps/zeta initdeps/mid main
всего пакетов в сборке (go list -deps): 59
Из 59 пакетов графа сборки (в их числе псевдопакет unsafe и сам main)
строку получили 18. У остальных нет работы инициализации, и компилятор даже
не создаёт для них запись (cmd/compile/internal/pkginit/init.go, строки
120–122); по документации пакета runtime, ничего не печатается для пакетов without both user defined and compiler generated init work
(без работы инициализации — ни пользовательской, ни сгенерированной компилятором).
Вычисляет этот порядок компоновщик. Каждый пакет, которому есть что инициализировать, при компиляции получает запись инициализации со ссылками на записи импортируемых пакетов, а компоновщик упорядочивает все записи так, чтобы соблюсти зависимости, а в остальном — лексикографически. Это и есть правило спецификации «по пути импорта, но зависимости раньше»; среда выполнения при старте лишь проходит готовый список.
Правило «по пути импорта» появилось не сразу — это в «Истории версий» ниже. Отсюда практический вывод: полагаться можно только на порядок, выраженный импортом. Если пакет B должен быть инициализирован раньше A, A должен импортировать B; порядок «по алфавиту» завязывает программу на имена каталогов. И оговорка спецификации: зависимости между переменными она видит только по ссылкам в исходнике пакета. Если зависимость скрыта — например, за вызовом метода интерфейса, — порядок между такими переменными не определён.
Факультативный эксперимент: сколько пакетов в графе сборки
Это не знание о языке, которое стоит запоминать, а наблюдение одной
конфигурации: число зависит от версии Go, GOOS/GOARCH и настроек сборки.
Повторить его можно командой go list -deps . в каталоге программы с одним
import "fmt".
Вывод bench/gopkg/practice.go, раздел 2:
2) ЭКСПЕРИМЕНТ: СКОЛЬКО ПАКЕТОВ В ГРАФЕ СБОРКИ
──────────────────────────────────────────────
программа: package main, import "fmt", fmt.Println("hello")
конфигурация: go1.24.7, GOOS/GOARCH/CGO_ENABLED = linux/amd64/1, GOFLAGS пуст
пакетов в графе сборки (go list -deps, вместе с main и unsafe): 56
из них с работой инициализации (GODEBUG=inittrace=1): 14
internal/bytealg runtime math errors iter sync internal/godebug syscall time internal/poll io/fs os unicode reflect
На go1.25.1 в той же конфигурации пакетов 61, а с работой инициализации — те
же 14. Устойчиво здесь одно: все пакеты графа проходят инициализацию до
main, а строку inittrace получают только те, кому есть что делать.
Одно нарушение — два сообщения: данные экспорта и встраивание
Вернёмся к строкам 10 и 11 из Механизма 2: обе обращаются к неэкспортируемой
функции, а сообщения разные: undefined: shape.area и
name perimeter not exported by package shape. Если запретить встраивание —
флагом для всей сборки или директивой //go:noinline на Perimeter, —
разница пропадает.
Вывод bench/gopkg/internals.go, раздел 5 (программы намеренно не собираются):
5) ОДНО НАРУШЕНИЕ — ДВА СООБЩЕНИЯ
─────────────────────────────────
go build -gcflags=all=-l ./visibility/bad (встраивание выключено)
visibility/bad/main.go:10:20: undefined: shape.area
visibility/bad/main.go:11:20: undefined: shape.perimeter
go build ./visibility/noinline/bad (та же пара, Perimeter помечена //go:noinline)
visibility/noinline/bad/main.go:10:20: undefined: shape.perimeter
Импортированный пакет компилятор видит не по исходнику, а по данным экспорта
— тому, что пакет сообщил о себе. perimeter туда попала: её вызывает
короткая Perimeter, которую можно встроить в чужой код. area снаружи не
нужна никому, и компилятор её просто не видит. Знать это нужно для одного:
undefined: pkg.name не значит, что такого имени в пакете нет — оно
может быть, но с маленькой буквы.
Как отвечать на собеседовании
Короткий ответ: пакет — каталог файлов с одним именем в строке package.
Снаружи видны только имена с заглавной буквы. Инициализация идёт так:
сначала пакеты, которые импортированы, — каждый один раз; внутри пакета —
переменные по зависимостям, затем все init() в порядке файлов; и только
потом main.
Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.
Если интервьюер копает глубже
На «в каком порядке init» покажите переменную, объявленную первой.
«var total = price*qty объявлена до price и qty, а в прогоне
инициализирована третьей. Правило — самая ранняя по объявлению переменная, у
которой зависимости готовы. init() — после всех переменных пакета».
На «а между пакетами» — зависимости, потом путь импорта. «Пакет
инициализируется после всех, кого импортирует, и один раз. Независимые — по
пути импорта: в прогоне alpha раньше zeta, хотя main импортирует zeta
первым. Порядок импортов в файле значения не имеет».
На «зачем internal» — и кто его проверяет. «Импорт пакета из internal/
разрешён только коду из дерева родителя internal. Это правило команды go —
use of internal package ... not allowed, — а не языка. Закрыт импорт, а не
данные: экспортируемый пакет рядом может их отдать».
На «как разложить проект» — разделите правило и привычку. «cmd/<имя>/
для команд — соглашение: бинарник называется по последнему элементу пути, а package main
собирается где угодно. Правила два: пакет main нельзя импортировать, и
internal/ закрывает поддерево».
На «зачем _ в импорте» — init и регистрация. «import _ "image/png"
выполняет init пакета, который регистрирует декодер; без него
image.Decode отвечает image: unknown format. Драйверы database/sql так
же регистрируются в init».
Дальше спросят
Можно ли вернуть из экспортируемой функции значение неэкспортируемого типа?
Можно, и компилятор не возражает: shape.New(2) в прогоне вернул
*shape.circle, и вызывающий вызвал у него экспортируемый метод Area.
Нельзя другое — назвать этот тип снаружи (строка из раздела 4):
visibility/bad/main.go:12:14: name circle not exported by package shape
Значит, снаружи нельзя написать этот тип: в поле структуры, в параметре
функции, в var x shape.circle. Получить значение можно только с выводом
типа — := или var d = shape.New(1). Для API это неудобно: пользователь не
может объявить поле такого типа в своей структуре. Поэтому наружу обычно отдают экспортируемый тип с
неэкспортируемыми полями или интерфейс.
Как в одном каталоге оказываются пакеты x и x_test?
Это единственное исключение из правила «одно имя на каталог». Файл, имя
которого кончается на _test.go, может объявить package x_test — и станет
внешним тестом: отдельным пакетом, который импортирует x как обычный
пользователь и видит только экспортируемое. go list показывает такие файлы
отдельно (строки из раздела 2):
TestGoFiles=[listing_test.go]
XTestGoFiles=[api_test.go]
Исключение проверяется по имени файла (go/build/build.go, строки 948–953 в
go1.24.7): в файле без суффикса _test.go то же package util_test даёт
обычную ошибку found packages util_test (helper.go) and util (util.go).
Внешний тест видит пакет так же, как его пользователь, — документация
пакета testing называет это "black box" testing
(тестированием «чёрного ящика»)
и уточняет, что такому тесту доступны only its exported identifiers
(только экспортируемые идентификаторы).
Два пакета нужны друг другу. Что делать с циклом импортов?
Сначала понять, что именно нужно. Если это типы — orders нужен
users.User, а users — *orders.Order, — их выносят в третий пакет,
который импортируют оба, — цикл исчезает. Если же одному пакету нужно
поведение другого, его заменяют интерфейсом, объявленным на стороне
того, кто вызывает: пакет зависит от интерфейса, который объявил сам, а не от
пакета, который его реализует.
Склеить оба пакета в один — тоже выход: цикл часто значит, что граница между
пакетами проведена не там. Начинают с сообщения import cycle not allowed —
оно перечисляет весь путь цикла с файлами, где стоят импорты.
Частые заблуждения
init() — первое, что выполняется в пакете
Сначала инициализируются все переменные пакета, во всех файлах, и только потом вызываются init(). В прогоне init() увидел total = 30 — значение, вычисленное до него.
Переменные пакета инициализируются сверху вниз
По зависимостям: на каждом шаге — самая ранняя по объявлению из готовых. var total = price*qty, объявленная первой, инициализирована третьей.
Пакеты инициализируются в порядке импортов в файле
Зависимости — раньше, независимые — по пути импорта. main импортирует zeta первым, а alpha в прогоне инициализирован раньше. Порядок строк import не значит ничего.
Имя пакета — это последний элемент пути импорта
Это соглашение. Имя — то, что стоит в строке package: каталог naming/strutil содержит package textkit, и обращение strutil.Shout даёт undefined: strutil.
internal — правило компилятора или языка
В спецификации его нет. Импорт запрещает команда go — функция disallowInternal в её загрузчике пакетов — с сообщением use of internal package ... not allowed. Пакет, названный в командной строке, она не останавливает.
Проект на Go обязан иметь cmd/ и pkg/
Это соглашения. package main в tools/anywhere собирается так же, как в cmd/greet, а бинарник называется по последнему элементу пути импорта. Особый смысл для команды go есть, например, у internal, vendor, testdata и имён на . и _.
undefined: pkg.name значит, что такого имени в пакете нет
Не обязательно: shape.area в пакете есть, но с маленькой буквы, а компилятор (gc go1.24.7) сообщил undefined. Сообщение not exported by package появляется, только если имя попало в данные экспорта, — как perimeter, которую вызывает встраиваемая Perimeter.
init можно вызвать вручную — например, в тесте
Нельзя: undefined: init. Идентификатор init по спецификации не объявляется, и сослаться на функцию init нельзя нигде в программе.
История версий
| Версия | Изменение | Что это значит для кода |
|---|---|---|
| go1.13 и раньше | По комментарию в регрессионном тесте test/fixedbugs/issue31636.dir/main.go, независимые пакеты инициализировались в порядке импортов в исходнике; там же сказано, что с 1.14 реализация переходит к варианту лексикографического порядка. Тест импортирует c, b, a и сейчас ждёт вывода a b c. | порядок независимых пакетов зависел от строк import |
| спецификация go1.17 | Сказано только, что импортированные пакеты инициализируются раньше импортирующего и один раз. Правила «по пути импорта» нет (doc/go1.17_spec.html в поставке go1.24.7). | порядок независимых пакетов спецификацией не определён |
| спецификация go1.24.7 (локальная копия) | Правило есть: из списка пакетов, отсортированного по пути импорта, на каждом шаге — первый, чьи импорты уже инициализированы. В спецификации go1.17 его ещё нет; в какой версии между ними оно появилось, локальные источники не говорят. Прогон на go1.24.7 и go1.25.1: alpha, zeta, mid, main. | порядок определён, но полагаться стоит только на импорт |
| go1.25.1 | К сообщению undefined компилятор добавляет подсказку: undefined: shape.perimeter (but have Perimeter) (когда встраивание запрещено: -gcflags=all=-l или //go:noinline; при обычной сборке остаётся name perimeter not exported), undefined: shape.ωmega (but have Ωmega). В графе сборки программы с одним fmt на пять пакетов больше, чем на go1.24.7. | только текст сообщений и состав сборки |
| Go 1.26 и 1.27 | Урок их не измерял: прогоны — go1.24.7 и go1.25.1 на linux/amd64, сверено 14.10.2026. Нормативный источник порядка инициализации — текущая спецификация, раздел Program initialization; тексты диагностик и состав графа сборки в новых версиях могут отличаться. | правила — по спецификации; числа и тексты сообщений — мерить у себя |
Практика
Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.
Практика · что напечатает
// dep/dep.go
package dep
import "fmt"
var Ready = mark("dep: var Ready")
func mark(s string) bool {
fmt.Println(s)
return true
}
func init() { fmt.Println("dep: init") }
// main.go
package main
import (
"fmt"
"example/dep"
)
var total = trace("total", price*qty)
var price = trace("price", 10)
var qty = trace("qty", 3)
func trace(name string, v int) int {
fmt.Println("main: var", name)
return v
}
func init() { fmt.Println("main: init 1, total =", total) }
func init() { fmt.Println("main: init 2") }
func main() { fmt.Println("main: main, dep.Ready =", dep.Ready) }Практика · оцените
Проверка знаний
В пакете shape объявлена функция area с маленькой буквы. Что будет, если вызвать shape.area(1) из другого пакета?
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Пакет — это каталог исходных файлов с одним и тем же именем в строке
package; он единица видимости и единица инициализации. Из другого пакета видны только имена, которые начинаются с заглавной буквы, — модификаторов доступа в Go нет. Импортируют пакет по пути (.../naming/strutil), а обращаются по имени из его строкиpackage(textkit.Shout) — и это не одно и то же. - Порядок инициализации задаёт граф зависимостей, а не порядок в тексте.
var total = price*qty, объявленная первой, инициализируется третьей — послеpriceиqty. Функцииinit()вызываются после всех переменных пакета, по порядку файлов. Пакет инициализируется только после всех, кого он импортирует, и ровно один раз; среди готовых берётся первый по пути импорта — поэтомуalphaв прогоне инициализирован раньшеzeta, хотяmainимпортируетzetaпервым. - Дальше — то, что отличает понимание от пересказа. Импорт из
internal/запрещает не компилятор и не спецификация, а команда go:use of internal package ... not allowed.cmd/иpkg/— соглашения, которых не проверяет никто. Экспортируемая функция может вернуть значение неэкспортируемого типа — вызвать его методы можно, назвать тип нельзя. У одного нарушения видимости два разных сообщения компилятора (наблюдение gc go1.24.7, а не правило языка), и какое выпадет, зависит от встраивания функций — подстановки тела функции в место вызова. Пустой импорт_ "image/png"нужен ради одногоinit, который регистрирует декодер, — без негоimage.Decodeотвечаетimage: unknown format. А программа с однимfmt— это десятки пакетов, и все они проходят инициализацию доmain.
На самом деле
- Сначала инициализируются все переменные пакета, во всех файлах, и только потом вызываются
init(). В прогонеinit()увиделtotal = 30— значение, вычисленное до него. - По зависимостям: на каждом шаге — самая ранняя по объявлению из готовых.
var total = price*qty, объявленная первой, инициализирована третьей. - Зависимости — раньше, независимые — по пути импорта.
mainимпортируетzetaпервым, аalphaв прогоне инициализирован раньше. Порядок строкimportне значит ничего. - Это соглашение. Имя — то, что стоит в строке
package: каталогnaming/strutilсодержитpackage textkit, и обращениеstrutil.Shoutдаётundefined: strutil. - В спецификации его нет. Импорт запрещает команда go — функция
disallowInternalв её загрузчике пакетов — с сообщениемuse of internal package ... not allowed. Пакет, названный в командной строке, она не останавливает. - Это соглашения.
package mainвtools/anywhereсобирается так же, как вcmd/greet, а бинарник называется по последнему элементу пути импорта. Особый смысл для команды go есть, например, уinternal,vendor,testdataи имён на.и_. - Не обязательно:
shape.areaв пакете есть, но с маленькой буквы, а компилятор (gc go1.24.7) сообщилundefined. Сообщениеnot exported by packageпоявляется, только если имя попало в данные экспорта, — какperimeter, которую вызывает встраиваемаяPerimeter. - Нельзя:
undefined: init. Идентификаторinitпо спецификации не объявляется, и сослаться на функциюinitнельзя нигде в программе.
По версиям
- go1.13 и раньше
- По комментарию в регрессионном тесте
test/fixedbugs/issue31636.dir/main.go, независимые пакеты инициализировались в порядке импортов в исходнике; там же сказано, что с 1.14 реализация переходит к варианту лексикографического порядка. Тест импортируетc,b,aи сейчас ждёт выводаa b c.< - спецификация go1.17
- Сказано только, что импортированные пакеты инициализируются раньше импортирующего и один раз. Правила «по пути импорта» нет (
doc/go1.17_spec.htmlв поставке go1.24.7).< - спецификация go1.24.7 (локальная копия)
- Правило есть: из списка пакетов, отсортированного по пути импорта, на каждом шаге — первый, чьи импорты уже инициализированы. В спецификации go1.17 его ещё нет; в какой версии между ними оно появилось, локальные источники не говорят. Прогон на go1.24.7 и go1.25.1:
alpha,zeta,mid,main.< - go1.25.1
- К сообщению
undefinedкомпилятор добавляет подсказку:undefined: shape.perimeter (but have Perimeter)(когда встраивание запрещено:-gcflags=all=-lили//go:noinline; при обычной сборке остаётсяname perimeter not exported),undefined: shape.ωmega (but have Ωmega). В графе сборки программы с однимfmtна пять пакетов больше, чем на go1.24.7.< - Go 1.26 и 1.27
- Урок их не измерял: прогоны — go1.24.7 и go1.25.1 на linux/amd64, сверено 14.10.2026. Нормативный источник порядка инициализации — текущая спецификация, раздел Program initialization; тексты диагностик и состав графа сборки в новых версиях могут отличаться.<
Что разобрано
- Что здесь на самом деле спрашивают
- База: пакет — каталог файлов с одним именем
- Механизм 1: путь импорта и имя пакета — разные вещи
- Механизм 2: экспорт решает первая буква — и только она
- Механизм 3: `internal/` — правило команды go, остальное — соглашения
- Механизм 4: порядок инициализации — по зависимостям, а не по тексту
- Механизм 5: пустой импорт ради `init` — и почему циклы запрещены
- Глубже: кто выстраивает пакеты, сколько их в сборке и откуда два сообщения
- Как отвечать на собеседовании
- Дальше спросят
- Частые заблуждения
- История версий
- Практика
- Проверка знаний
Источники и что читать дальше
6 ИСТОЧНИКОВ
- Спецификация Go — Exported identifiersОфициальная документация. Правило экспорта целиком: «An identifier is exported if both: the first character of the identifier's name is a Unicode uppercase letter (Unicode character category Lu); and the identifier is declared in the package block or it is a field name or method name» (Идентификатор экспортируется, если выполнены оба условия: первый символ имени — заглавная буква Юникода (категория Lu); идентификатор объявлен в блоке пакета либо это имя поля или метода). И: «All other identifiers are not exported» (Все остальные идентификаторы не экспортируются). go_spec.html go1.24.7, строки 2396–2411.https://go.dev/ref/spec#Exported_identifiers
- Спецификация Go — Package clause и Import declarationsОфициальная документация. Что такое пакет: «A set of files sharing the same PackageName form the implementation of a package. An implementation may require that all source files for a package inhabit the same directory» (Набор файлов с одним и тем же PackageName образует реализацию пакета. Реализация может требовать, чтобы все исходные файлы пакета лежали в одном каталоге). Что запрещено при импорте и зачем пустой идентификатор: «It is illegal for a package to import itself, directly or indirectly, or to directly import a package without referring to any of its exported identifiers. To import a package solely for its side-effects (initialization), use the blank identifier as explicit package name» (Пакету запрещено импортировать самого себя, прямо или косвенно, и напрямую импортировать пакет, не обращаясь ни к одному из его экспортируемых идентификаторов. Чтобы импортировать пакет только ради побочных эффектов (инициализации), используйте пустой идентификатор как явное имя пакета). go_spec.html go1.24.7, строки 8013–8014 и 8084–8088.https://go.dev/ref/spec#Import_declarations
- Спецификация Go — Package initialization и Program initializationОфициальная документация. Переменные: «Within a package, package-level variable initialization proceeds stepwise, with each step selecting the variable earliest in declaration order which has no dependencies on uninitialized variables» (Внутри пакета переменные уровня пакета инициализируются по шагам, и на каждом шаге выбирается самая ранняя в порядке объявления переменная, у которой нет зависимостей от неинициализированных переменных). init: «The entire package is initialized by assigning initial values to all its package-level variables followed by calling all init functions in the order they appear in the source, possibly in multiple files, as presented to the compiler» (Пакет инициализируется целиком: сначала всем переменным уровня пакета присваиваются начальные значения, затем вызываются все функции init в том порядке, в каком они встречаются в исходнике, возможно, в нескольких файлах — так, как файлы поданы компилятору). Пакеты: «If multiple packages import a package, the imported package will be initialized only once» (Если пакет импортируют несколько пакетов, он будет инициализирован только один раз) и «Given the list of all packages, sorted by import path, in each step the first uninitialized package in the list for which all imported packages (if any) are already initialized is initialized» (Из списка всех пакетов, отсортированного по пути импорта, на каждом шаге инициализируется первый неинициализированный пакет, все импортируемые пакеты которого уже инициализированы). go_spec.html go1.24.7, строки 8194–8385.https://go.dev/ref/spec#Package_initialization
- Команда go — Internal Directories (go help gopath)Официальная документация. Правило internal записано в документации команды go, а не в спецификации: «Code in or below a directory named "internal" is importable only by code in the directory tree rooted at the parent of "internal"» (Код в каталоге с именем internal и ниже него может импортировать только код из дерева каталогов, корень которого — родитель internal). Текст — cmd/go/internal/help/helpdoc.go, строка 391 (go1.24.7); проверка — cmd/go/internal/load/pkg.go, функция disallowInternal, строка 1472.https://pkg.go.dev/cmd/go#hdr-Internal_Directories
- Команда go — Package lists and patterns (go help packages)Официальная документация. Имя пакета не обязано быть уникальным, а пакет main не импортируется: «Packages in a program need not have unique package names, but there are two reserved package names with special meaning. The name main indicates a command, not a library. Commands are built into binaries and cannot be imported» (Имена пакетов в программе не обязаны быть уникальными, но есть два зарезервированных имени с особым смыслом. Имя main обозначает команду, а не библиотеку. Команды собираются в бинарники и не могут быть импортированы). И что команда go пропускает: «Directory and file names that begin with "." or "_" are ignored by the go tool, as are directories named "testdata"» (Каталоги и файлы, чьи имена начинаются с . или _, команда go пропускает, как и каталоги с именем testdata).https://pkg.go.dev/cmd/go#hdr-Package_lists_and_patterns
- Пакет imageОфициальная документация. Зачем пустой импорт в стандартной библиотеке: «Decoding any particular image format requires the prior registration of a decoder function. Registration is typically automatic as a side effect of initializing that format's package so that, to decode a PNG image, it suffices to have import _ "image/png" in a program's main package» (Чтобы декодировать изображение в каком-либо формате, нужно заранее зарегистрировать функцию-декодер. Обычно регистрация происходит автоматически как побочный эффект инициализации пакета этого формата, так что для декодирования PNG достаточно написать import _ "image/png" в пакете main программы). Регистрация — image/png/reader.go, строки 1052–1053.https://pkg.go.dev/image