tl;dr магия это плохо; глобальные состояние это магия → глобальные переменные в пакетах это плохо; функция init() не нужна.
Самое главное и лучшее свойство Go это то, что он, по-сути, антимагический. Не считая пары исключений, простое чтение Go кода не оставляет двусмысленности в определениях, зависимостях или поведении рантайма. Это делает Go относительно легким для чтения, что, в свою очередь, делает его легким для поддерживания, что является самым главным свойством в индустриальном программировании.
Но всё же есть пару мест, где магия может просочиться. Один из, к сожалению, распространённых путей это использование глобального состояния. Объекты, определенные в глобальном пространстве пакета могут хранить состояние или поведение, которое спрятано от внешнего наблюдателя. Код, который зависит от этих глобальных объектов может иметь неприятные побочные эффекты, которые разрушают способность читателя понимать и строить ментальную модель программы.
Функции (включая методы) по сути являются единственным механизмом, который есть в Go, чтобы строить абстракции. Давайте посмотрим на следующее определение функции:
func NewObject(n int) (*Object, error)
По соглашению, мы ожидаем, что функции в форме NewXXX это конструкторы типов. Это ожидание подтверждается, когда мы видим, что функция возвращает указатель на Object и ошибку. Из этого мы можем вывести, что конструктор может не сработать, и в этом случае он вернёт ошибку, в которой объяснена причина. Также мы видим, что функция принимает на вход единственный целочисленный параметр, который, как мы полагаем, контролирует какой-то аспект или свойство объекта, который будет возвращён. Вероятно, есть какие-то допустимые значения n, которые, будучи нарушенными, приведут к ошибке. Но, поскольку, функция больше не принимает других параметров, мы ожидаем, что функция больше никаких побочных эффектов нет, кроме (вероятно) выделения памяти.
Просто прочитав сигнатуру функции, мы могли сделать все эти выводы и построить ментальную модель функции. Этот процесс, будучи умноженным и повторенным много раз рекурсивно начиная с первой строки функции main — это то, как мы читаем и понимаем программы.
Теперь, давайте посмотрим на тело функции:
func NewObject(n int) (*Object, error) {
row := dbconn.QueryRow("SELECT ... FROM ... WHERE ...")
var id string
if err := row.Scan(&id); err != nil {
logger.Log("during row scan: %v", err)
id = "default"
}
resource, err := pool.Request(n)
if err != nil {
return nil, err
}
return &Object{
id: id,
res: resource,
}, nil
}
Функция использует глобальный объект из пакета sql — database/sql.Conn, чтобы произвести запрос к какой-то непонятной базе данных; затем глобальный для пакета логгер, чтобы записать строку в непонятном формате в непонятно куда; и глобальный объект пула запросов, чтобы запросить какой-то ресурс. Все эти операции имеют побочные эффекты, которые абсолютно невидимы при чтении сигнатуры функции. У программиста, читающего её, нет способа предсказать ни одно из этих действий, кроме как чтения тела функции и заглядывания в определения глобальных переменных.
Давайте попробуем такой вариант сигнатуры:
func NewObject(db *sql.DB, pool *resource.Pool, n int, logger log.Logger) (*Object, error)
Просто подняв все эти зависимости как параметры, мы позволили программисту достаточно аккуратно смоделировать зависимости и поведение функции. Теперь знает точно, в чем функция нуждается, чтобы выполнить свою работу, и может предоставить всё необходимое.
Если бы мы разрабатывали публичное API для этого пакета, мы бы могли пойти даже дальше:
// RowQueryer models part of a database/sql.DB.
type RowQueryer interface {
QueryRow(string, ...interface{}) *sql.Row
}
// Requestor models the requesting side of a resource.Pool.
type Requestor interface {
Request(n int) (*resource.Value, error)
}
func NewObject(q RowQueryer, r Requestor, logger log.Logger) (*Object, error) {
// ...
}
Смоделировав каждый конкретный объект как интерфейс, содержащий только методы, которые нам нужны, мы позволили вызывающему коду легко переключаться между реализациями. Это уменьшает связанность между пакетами, и позволяет нам легко мокать (mock) конкретные зависимости в тестах. Тестирование же оригинальной версии кода, с конкретными глобальными переменными, включает утомительную и склонную к ошибкам подмену компонентов.
Если бы все наши конструкторы и функции принимали зависимости явно, нам бы не нужны были глобальные переменные вообще. Взамен, мы бы могли создавать все наши соединения с базами данных, логгеры и пулы ресурсов, в функции main, давая возможность будущим читателям кода очень четко понимать граф компонентов. И мы можем очень явно передавать все наши зависимости, убирая вредную для понимания магию глобальных переменных. Также, заметьте, что если у нас нет глобальных переменных, нам больше не нужна функция init, чья единственная функция это создавать или изменять глобальное состояние пакета. Мы можем затем посмотреть на все случаи использования функций int со справедливым подозрением: что, собственно, это код вообще делает? Если он не в функции main, зачем он?
И это не просто возможно, но и очень просто, и, на самом деле, очень освежающе, писать Go программы, в которых практически нет глобального состояния. Из моего опыта, программирование таким способом ничуть не медленнее и не скучнее, чем использование глобальных переменных для уменьшения определений функций. Даже наоборот: когда сигнатура функции надежно и полноценно описывает её поведение, мы можем аргументировать, рефакторить и поддерживать код в больших кодовых базах гораздо более эффективно. Go kit был написан именно в таком стиле с самого начала, и только выиграл от этого.
--
И на этом моменте я могу сформулировать теорию современного Go. Отталкиваясь от слов Дейва Чини, я предлагаю следующие правила:
- отсутствие глобальных переменных в пакетах
- отсутствие функции init
Конечно, есть исключения. Но следуя этим правилам, остальные практики появляются естественным образом.