Go без глобальных переменных

в 9:24, , рубрики: global variables, Go

Перевод статьи Дейва Чини — ответа на предыдущий пост Питера Бургона "Теория современного Go" — с попыткой провести мысленный эксперимент, как бы выглядел Go без переменных в глобальной области видимости вообще. Хотя в некоторых абзацах можно сломать язык, но пост достаточно интересный.

Давайте проведём мысленный эксперимент, как бы выглядел Go, если бы мы избавились от переменных в глобальной области видимости пакетов. Какие бы были последствия и что мы можем узнать о дизайне Go программ из этого эксперимента?

Я буду говорить только о вычёркивании var, остальные пять определений верхнего уровня остаются разрешены в нашем эксперименте, так как они, по сути, являются константами на этапе компиляции. И, конечно же, вы можете продолжать определять переменные в функциях и любых блоках.

Почему глобальные переменные в пакетах это плохо?

Но сначала, давайте ответим на вопрос почему глобальные переменные в пакетах это плохо? Оставив в стороне очевидную проблему глобального видимого состояния в языке со встроенной конкурентностью (concurrency), глобальные переменные в пакетах, по сути, являются синглтонами, использующимися для неявного изменения состояний между слабо не очень связанными вещами, создавая прочную зависимость и делая код сложным для тестирования.

Как недавно написал Питер Бургон:

tl;dr магия это плохо; глобальные состояние это магия → глобальные переменные в пакетах это плохо; функция init() не нужна.

Избавляемся от глобальных переменных, на практике

Чтобы проверить эту идею, я подробно изучил самую популярную кодовую базу на Go — стандартную библиотеку, чтобы посмотреть как в ней используются глобальные переменные в пакетах, и постарался оценить эффект от нашего эксперимента.

Ошибки

Одно из самых частых использований глобальных var в публичных пакетах это ошибки — io.EOF, sql.ErrNoRows, crypto/x509.ErrUnsupportedAlgorithm и т.д. Без этих переменных мы не сможем сравнить ошибки с заранее предопределёнными значениями. Но можем ли мы их чем-то заменить?

Я писал ранее, что вы должны стараться смотреть на поведение, а не на тип при анализе ошибок. Если же это невозможно, то определение констант для ошибок избавляет от возможной модификации ошибок и сохраняет их семантику.

Оставшиеся переменные для ошибок будут приватными и просто дают символическое имя сообщению об ошибке. Эти переменные не экспортируемые, поэтому их нельзя будет использовать для сравнения извне пакета. Определение их на верхнем уровне пакета, а не в том месте, где они происходит, лишает нас возможности добавлять какой-то дополнительный контекст к ошибке. Вместо этого я рекомендую использовать что-то вроде pkg/errors чтобы сохранять стектрейс в ошибку в момент её происхождения.

Регистрация

Паттерн регистрации используется в нескольких пакетах стандартной библиотеки, таких как net/http, database/sql, flag и немного также в log. Обычно он заключается в глобальной переменной типа map или структуре, которая изменяется некой публичной функцией — классический синглтон.

Невозможность создавать такую переменную-пустышку, которая должна инициализироваться извне лишает возможности пакеты image, database/sql и crypto регистрировать декодеры, драйверы баз данных и криптографические схемы. Но это как раз та самая магия, о которой говорит Питер в своей статье — импортирование пакета, для того, чтобы тот неявно изменил глобальное состояние другого пакета и это действительно зловеще выглядит со стороны.

Регистрация также поощряет повторение бизнес-логики. К примеру, пакет net/http/pprof регистрирует себя и, как побочный эффект, net/http.DefaultServeMux, что не совсем безопасно — другой код теперь не может использовать мультиплексор по умолчанию без того, чтобы не светить наружу информацию, которую выдает pprof — и зарегистрировать его на другой мультиплексор не так уж тривиально.

Если бы глобальных переменных в пакетах не было, такие пакеты как net/http/pprof могли бы предоставлять функцию, которая регистрировала бы пути URL для заданного http.ServeMux, и не зависеть от неявного изменения глобального состояния другого пакета.

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

Проверка удовлетворения интерфейса

Есть такая идиома для проверки на принадлежность типа интерфейсу:

var _ SomeInterface = new(SomeType)

Она встречается по крайней мере 19 раз в стандартной библиотеке. По моему убеждению, такие проверки это, по сути, тесты. Они не должны вообще компилироваться, чтобы потом быть убранными при сборке пакета. Их нужно вынести в соответствующие _test.go файлы. Но если мы запрещаем глобальные переменные в пакетах, это относится также и к тестам, так как же мы можем сохранить эту проверку?

Одним из решений было бы вынести это определение переменной из глобальной области видимости в область видимости функции, которая по-прежнему перестанет компилироваться, если SomeType вдруг перестанет удовлетворять интерфейсу SomeInterface

func TestSomeTypeImplementsSomeInterface(t *testing.T) {
       // won't compile if SomeType does not implement SomeInterface
       var _ SomeInterface = new(SomeType)
}

Но, так как это, по сути, просто тест, то мы можем переписать эту идиому в виде обычного теста:

func TestSomeTypeImplementsSomeInterface(t *testing.T) {
       var i interface{} = new(SomeType)
       if _, ok := i.(SomeInterface); !ok {
               t.Fatalf("expected %t to implement SomeInterface", i)
       }
}

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

Но не всё так просто

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

Реальные синглтоны

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

package os 

var (
        Stdin  = NewFile(uintptr(syscall.Stdin), "/dev/stdin")
        Stdout = NewFile(uintptr(syscall.Stdout), "/dev/stdout")
        Stderr = NewFile(uintptr(syscall.Stderr), "/dev/stderr")
)

С этим определением есть несколько проблем. Во-первых, Stdin, Stdout и Stderr это переменные типа *os.File, а не io.Reader или io.Writer интерфейсы. Это делает их замену альтернативами достаточно проблематичной. Но даже сама идея их замены это как раз та магия, от которой наш эксперимент пытается избавиться.

Как показал предыдущий пример с константными ошибками, мы можем оставить сущность синглтона для стандартных IO дескрипторов, так чтобы пакеты вроде log и fmt могли использовать их напрямую, но не объявлять их как изменяемые глобальные переменные. Что-то вроде такого:

package main

import (
        "fmt"
        "syscall"
)

type readfd int

func (r readfd) Read(buf []byte) (int, error) {
        return syscall.Read(int(r), buf)
}

type writefd int

func (w writefd) Write(buf []byte) (int, error) {
        return syscall.Write(int(w), buf)
}

const (
        Stdin  = readfd(0)
        Stdout = writefd(1)
        Stderr = writefd(2)
)

func main() {
        fmt.Fprintf(Stdout, "Hello world")
}

Кеши

Второй самый популярный способ использования неэкспортируемых глобальных переменных в пакетах это кеши. Они бывают двух типов — реальные кеши, состоящие из объектов типа map (смотри паттерн регистрации выше) или sync.Pool, и квазиконстантные переменные, которые улучшают стоимость компиляции (прим. переводчика — "шта?")

В пример можно привести пакет crypto/ecsda, в котором есть тип zr, чей метод Read() обнуляет любой буфер, который ему передаётся на вход. Пакет содержит единственную переменную типа zr, потому что она встроена в другие структуры вроде io.Reader, потенциально убегая в кучу каждый раз, когда она объявляется.

package ecdsa 

type zr struct {
        io.Reader
}

// Read replaces the contents of dst with zeros.
func (z *zr) Read(dst []byte) (n int, err error) {
        for i := range dst {
                dst[i] = 0
        }
        return len(dst), nil
}

var zeroReader = &zr{}

Но при этом тип zr не содержит встроенный io.Reader — он сам имплементирует io.Reader — поэтому мы можем убрать неиспользуемое поле zr.Reader, сделав тем самым zr пустой структурой. В моих тестах этот модифицированный тип можно инициализировать явно без потерь в производительности:

csprng := cipher.StreamReader{
    R: zr{},
    S: cipher.NewCTR(block, []byte(aesIV)),
}

Возможно, есть смысл пересмотреть некоторые решения для кешей, поскольку инлайнинг (inlining) и escape-анализ очень заметно улучшились с тех времен написания стандартной библиотеки.

Таблицы

И последнее самое частое использование приватных глобальных переменных в пакетах это таблицы — например, в пакетах unicode, crypto/* и math. Эти таблицы обычно кодируют константные данные в виде целочисленных массивов или, чуть реже, простых структур или объектов типа map.

Замена глобальных переменных на константы потребует изменений в языке, что-то подобное описанному тут. Так что, если считать, что нет способа изменить эти таблицы во время работы программы, они могут быть исключением для этого предложения (proposal).

Слишком далеко зашли

Несмотря на то, что этот пост был всего лишь мысленным экспериментом, уже ясно, что запрет всех глобальных переменных в пакетах это слишком драконовская мера, чтобы быть реальной в языке. Обход возникших с запретом проблем может быть очень непрактичным с точки зрения производительности, и это будет всё равно что повесить плакат "ударь меня" на спины и пригласить всех Go хейтеров повеселиться.

Но при этом, мне кажется есть несколько очень конкретных советов, которые можно извлечь из этого мысленного эксперимента без того, чтобы ударяться в крайности и менять спецификацию языка:

  • Во-первых, от использования публичных var определений лучше отказаться. Это не какая-то спорная тема, и она точно не уникальна для Go. Синглтон паттерн лучше не использовать, и мутная публичная переменная, которая может быть изменена в любой момент любым, кто знает её имя — это автоматически сигнал "стоп".
  • Во-вторых, если уж где-то публичная переменная и определятся, то нужно быть крайне внимательным к её типу и постараться, чтобы это было как можно более простая конструкция. Не должно быть такого, что тип, по идее, используется on per instance basis (прим. переводчика — не знаю как это правильно интерпретировать), а мы присваиваем его переменной в глобальной области видимости пакета.

Приватные определения глобальных переменных более спецефичны, но некоторые паттерны можно извлечь:

  • Приватные переменные с публичными сеттерами (функциями Set()), которые я называю "registries" по сути имеют тот же эффект, что их их публичные аналоги. Вместо того, чтобы регистрировать зависимости в глобальной области видимости, они должны передаваться во время создания с помощью функций конструкторв, литералов, структур с конфигурацией или функций для опций.
  • Кэши в виде переменных типа []byte часто могут быть определены как константы без потерь производительности. Не забывайте, что компилятор очень хорошо оптимизирует вызовы вроде string([]byte) там где они не выходят за рамки функции.
  • Приватные переменные, содержащие таблицы, вроде как в пакете unicode — это неизбежное следствие отсутствия типа константного массива в Go. До тех пор пока они приватные, и не дают никакого способа их менять, их можно считать фактически константами в рамках этого обсуждения.

Резюмируя, подумайте дважды и трижды, прежде чем добавлять в пакет глобальные переменные, которые могут менять значение во время работы программы. Это может быть признаком того, что вы добавили магическое глобальное состояние.

Автор: divan0

Источник

Поделиться

* - обязательные к заполнению поля