Урок курса

Собственные пакеты: структура папок, exported и unexported имена

Golang с нуля

До этого момента весь код жил в одном файле или в одной папке с пакетом main. Как только проект растёт — появляются вспомогательные функции, которые хочется переиспользовать — нужна другая структура. Go решает это очень конкретно: одна папка = один пакет.

Структура папок модуля и объявление пакетов main и mathutil

Создадим папку проекта и инициализируем модуль:

mkdir myapp && cd myapp
go mod init example.com/myapp

Команда go mod init не создаёт никаких вложенных папок с именем модуля — она просто записывает module path в файл go.mod текущего каталога. Физическая структура на диске выглядит так:

myapp/
├── go.mod
├── main.go
└── mathutil/
    └── mathutil.go

А содержимое go.mod — вот так:

module example.com/myapp

go 1.21

example.com/myapp — это module path: строка-идентификатор модуля, которую Go использует при импорте пакетов. Она не обязана совпадать с именем папки на диске; папка называется myapp, а module path — example.com/myapp.

Файл main.go начинается с package main — это точка входа. Файл mathutil/mathutil.go начинается с package mathutil. Это всё, что требуется от каждого файла: объявить, к какому пакету он принадлежит.

Главное правило: все .go-файлы в одной папке должны объявлять одно и то же имя пакета. Попытка смешать package mathutil и package util в одной папке приведёт к ошибке компиляции. Компилятор считает папку единицей: он компилирует всё содержимое сразу и требует единого package-объявления.

Имя папки и имя пакета принято делать одинаковыми — mathutil/ содержит package mathutil. Технически компилятор не требует этого совпадения, но весь инструментарий Go (включая go doc и большинство IDE) рассчитывает именно на такое соответствие.

Если хочется добавить ещё одну группу функций — создаём ещё одну подпапку со своим .go-файлом и своим package-объявлением. Глубина вложенности не ограничена, но каждая папка всё равно остаётся одним пакетом.

Exported и unexported имена: правило заглавной буквы и ошибка компилятора при нарушении

Go не использует ключевые слова вроде public или private. Видимость имени определяется первой буквой его написания — и это единственное правило.

Идентификатор начинается с заглавной буквы → он exported, то есть виден из любого другого пакета. Начинается со строчной → unexported, виден только внутри того пакета, где объявлен.

Добавим в mathutil/mathutil.go две функции:

package mathutil

// Add — exported: доступна снаружи пакета
func Add(a, b int) int {
    return a + b
}

// subtract — unexported: доступна только внутри mathutil
func subtract(a, b int) int {
    return a - b
}

Теперь попробуем вызвать subtract из main:

package main

import "example.com/myapp/mathutil"

func main() {
    mathutil.subtract(5, 3) // это не скомпилируется
}

Компилятор вернёт:

./main.go:6:2: cannot refer to unexported name mathutil.subtract

Это не предупреждение — это ошибка, программа не соберётся. Исправление ровно одно: переименовать subtract в Subtract в файле mathutil/mathutil.go. Никаких аннотаций, никаких модификаторов доступа — только заглавная буква.

Правило распространяется на всё: функции, переменные, константы, типы. Если Pi начинается с заглавной — её можно импортировать, если pi — нельзя. Это делает видимость немедленно читаемой прямо из исходника, без необходимости искать объявление в отдельном месте.

Импорт собственного пакета по пути модуля, блок import и циклическая зависимость

Путь импорта собственного пакета строится из двух частей: имя модуля из go.mod плюс путь подпапки относительно корня модуля. Если в go.mod написано module example.com/myapp, а пакет лежит в папке mathutil/, то импорт выглядит так:

import "example.com/myapp/mathutil"

После этого к exported-именам обращаются через имя пакета: mathutil.Add(2, 3). Имя, которое используется в коде — это именно package-имя из файла, не имя папки и не последний сегмент пути (хотя обычно они совпадают).

Когда нужно импортировать несколько пакетов, их группируют в блок:

import (
    "fmt"

    "example.com/myapp/mathutil"
)

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

Циклическая зависимость. Представим, что пакет api импортирует пакет storage, а storage решает импортировать api — например, чтобы использовать какой-то тип оттуда. Go-компилятор немедленно останавливается:

import cycle not allowed

Циклы запрещены полностью, без исключений. Решение зависит от причины цикла.

Если цикл возник из-за общего типа или константы, которая нужна обоим пакетам, — такой код выносят в третий пакет, например models, который не импортирует ни api, ни storage. Тогда оба пакета импортируют только models, и цикл исчезает:

 api  →  models
          ↑
storage →

Этот приём работает именно тогда, когда общий код действительно независим и имеет смысл как отдельная единица.

В других ситуациях решение иное: иногда стоит пересмотреть направление зависимости — перенести ответственность в один из пакетов, чтобы второй больше не нуждался в обратном импорте. Иногда два тесно связанных пакета имеет смысл объединить в один. Универсального рецепта нет: цикл — это сигнал, что границы между пакетами стоит пересмотреть.

Попробуйте решить

В пакете geometry объявлены: func Area(r float64) float64 { ... } и func perimeter(r float64) float64 { ... }. Из пакета main написано geometry.perimeter(5.0). Что произойдёт при компиляции?

Продолжить с проверкой и прогрессом

Откройте интерактивный раннер с заданиями урока.

Перейти к интерактивному уроку