Рекомендации по стилю для проектов Google с открытым исходным кодом


Лучшие практики Go


Этот документ — часть документации по стилю Go в Google. Он не является ни нормативным, ни каноничным, это дополнение к «Руководству по стилю». Подробности смотрите в Обзоре.


О документе


Здесь приведены рекомендации по лучшим практикам применения требований «Руководства по стилю» для Go. Это руководство охватывает общие и распространенные случаи, но не может применяться к каждому частному случаю. Обсуждение альтернатив, по возможности, включено в текст руководства вместе с указаниями о том, когда они применимы, а когда — нет.


Полная документация руководства по стилю описывается в обзоре.



Именование


Имена функций и методов


Избегайте повторений


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


  • В общем случае имя функции можно не включать следующую информацию:


    • типы входных и выходных данных, если отсутствие указания не вызовет путаницу;
    • тип приемника метода;
    • является ли указателем входной или выходной элемент данных.

  • В функциях не повторяйте имя пакета.

 // Плохо:
 package yamlconfig

 func ParseYAMLConfig(input string) (*Config, error)

 // Хорошо:
 package yamlconfig

 func Parse(input string) (*Config, error)

  • В методах не повторяйте имя приемника метода:


    // Плохо:
    func (c *Config) WriteConfigTo(w io.Writer) (int64, error)

    // Хорошо:
    func (c *Config) WriteTo(w io.Writer) (int64, error)

  • Не повторяйте имена переменных, которые указывались как параметры:


    // Плохо:
    func OverrideFirstWithSecond(dest, source *Config) error

    // Хорошо:
    func Override(dest, source *Config) error

  • Не повторяйте имена и типы возвращаемых значений:


    // Плохо:
    func TransformYAMLToJSON(input *Config) *jsonconfig.Config

    // Хорошо:
    func Transform(input *Config) *jsonconfig.Config


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


// Хорошо:
func (c *Config) WriteTextTo(w io.Writer) (int64, error)
func (c *Config) WriteBinaryTo(w io.Writer) (int64, error)

Преобразование имен


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


  • Имена возвращающих значение функций должны быть образованы от существительных:


    // Хорошо:
    func (c *Config) JobName(key string) (value string, ok bool)

    Таким образом, префикса Get в именах функций и методов нужно избегать:


    // Плохо:
    func (c *Config) GetJobName(key string) (value string, ok bool)

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


    // Хорошо:
    func (c *Config) WriteDetail(w io.Writer) (int64, error)

  • Идентичные функции, которые отличаются только используемым типом, в конце имени функции должны содержать имя типа:


    // Хорошо:
    func ParseInt(input string) (int, error)
    func ParseInt64(input string) (int64, error)
    func AppendInt(buf []byte, value int) []byte
    func AppendInt64(buf []byte, value int64) []byte

    Если есть «первичная» версия, тип в ее имени можно опустить:


    // Хорошо:
    func (c *Config) Marshal() ([]byte, error)
    func (c *Config) MarshalText() (string, error)


    Тестовые дубли пакетов и типов



При именовании тестовых пакетов и типов, особенно тестовых дублей (test doubles), применимо несколько правил. По своей функции тестовый дубль может быть заглушкой (stub), объектом-имитацией (fake), макетом объекта (mock) или тестовым шпионом (spy).


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


Допустим, у вас есть специализированный пакет, представляющий работающий код:


package creditcard

import (
 "errors"

 "path/to/money"
)

// ErrDeclined указывает на то, что эмитент отклоняет платеж.
var ErrDeclined = errors.New("creditcard: declined")

// Карта содержит информацию о кредитной карте, такую ​​как ее эмитент,
// срок действия и лимит.
type Card struct {

}

// Сервис позволяет совершать операции с кредитными картами внешних
// платежных систем, такие как взимание платы, авторизация, возмещение и подписка.
type Service struct {

}

func (s *Service) Charge(c *Card, amount money.Money) error { /* опущено */ }

Создание вспомогательных тестовых пакетов (test helper packages)


Вы хотите создать пакет с тестовыми дублями для другого пакета. Воспользуемся выражением package creditcard, взятым из приведенного выше кода.


Вариант: можно ввести для теста новый пакет Go, создав его на основе работающего пакета. Чтобы не перепутать эти пакеты, к имени пакета припишем слово test: ("creditcard" + "test"):


// Хорошо:
package creditcardtest

Если не указано иное, все примеры в последующих разделах будут писаться в рамках package creditcardtest.


Простой пример


Вы хотите добавить набор тестовых дублей для Service. Поскольку Card фактически заглушка, подобная сообщению Protocol Buffer, он не нуждается в специальной обработке в тестах, а значит, дублирование не требуется. Если вы ожидаете, что тестовые дубли будут применяться только для одного типа (например, Service), вы можете назвать дубли лаконично:


// Хорошо:
import (
 "path/to/creditcard"
 "path/to/money"
)

// Stub заглушает creditcard.Service и не предоставляет собственного поведения.
type Stub struct{}

func (Stub) Charge(*creditcard.Card, money.Money) error { return nil }

В отличии от имени StubService или, хуже того, StubCreditCardService, такой выбор имени открыто приветствуется, ведь имя базового пакета и типы его предметных областей дают достаточно информации о creditcardtest.Stub.


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


# Good:
go_library(
 name = "creditcardtest",
 srcs = ["creditcardtest.go"],
 deps = [
 ":creditcard",
 ":money",
 ],
 testonly = True,
)

Это стандартный, вполне понятный большинству инженеров подход.


См. также:



Поведение нескольких тестовых дублей


Когда для ваших тестов требуется более одного варианта заглушек (например, нужна заглушка, которая всегда выдает ошибку), рекомендуется давать им имена, согласно моделируемому поведению. Например, Stub можно переименовать в AlwaysCharges и ввести новую заглушку — AlwaysDeclines:


// Хорошо:
// AlwaysCharges — заглушка creditcard.Service, симулирующая успех операции.
type AlwaysCharges struct{}

func (AlwaysCharges) Charge(*creditcard.Card, money.Money) error { return nil }

// AlwaysDeclines — заглушка creditcard.Service, симулирующая отклонение платежа.
type AlwaysDeclines struct{}

func (AlwaysDeclines) Charge(*creditcard.Card, money.Money) error {
 return creditcard.ErrDeclined
}

Несколько дублей для нескольких типов


Предположим, что package creditcard содержит несколько типов, и для каждого имеет смысл создавать дубли, как показано ниже для Service и StoredValue:


package creditcard

type Service struct {

}

type Card struct {

}

// StoredValue управляет кредитными балансами клиентов. Структура 
// применима, когда возвращенный товар зачисляется на локальный счет 
// клиента, а не обрабатывается эмитентом кредита. По этой причине он
// реализован как отдельный сервис.
type StoredValue struct {

}

func (s *StoredValue) Credit(c *Card, amount money.Money) error { /* опущено */ }

В этом случае целесообразно давать тестовым дублям более явные имена:


// Хорошо:
type StubService struct{}

func (StubService) Charge(*creditcard.Card, money.Money) error { return nil }

type StubStoredValue struct{}

func (StubStoredValue) Credit(*creditcard.Card, money.Money) error { return nil }

Локальные переменные в тестах


Если переменные относятся к тестовым дублям, их имена должны четко отличать дубли от работающих типов с учетом контекста. Допустим, вы хотите протестировать работающий код:


package payment

import (
 "path/to/creditcard"
 "path/to/money"
)

type CreditCard interface {
 Charge(*creditcard.Card, money.Money) error
}

type Processor struct {
 CC CreditCard
}

var ErrBadInstrument = errors.New("payment: instrument is invalid or expired")

func (p *Processor) Process(c *creditcard.Card, amount money.Money) error {
 if c.Expired() {
 return ErrBadInstrument
 }
 return p.CC.Charge(c, amount)
}

Тестовый дубль CreditCard с именем "spy" располагается рядом с рабочими типами, поэтому префикс перед именем поможет внести ясность:


// Хорошо:
package payment

import "path/to/creditcardtest"

func TestProcessor(t *testing.T) {
 var spyCC creditcardtest.Spy

 proc := &Processor{CC: spyCC}

 // объявления опущены: карта и сумма
 if err := proc.Process(card, amount); err != nil {
 t.Errorf("proc.Process(card, amount) = %v, want %v", got, want)
 }

 charges := []creditcardtest.Charge{
 {Card: card, Amount: amount},
 }

 if got, want := spyCC.Charges, charges; !cmp.Equal(got, want) {
 t.Errorf("spyCC.Charges = %v, want %v", got, want)
 }
}

Так понятнее, чем без префикса.


// Плохо:
package payment

import "path/to/creditcardtest"

func TestProcessor(t *testing.T) {
 var cc creditcardtest.Spy

 proc := &Processor{CC: cc}

 // объявления опущены: карта и сумма
 if err := proc.Process(card, amount); err != nil {
 t.Errorf("proc.Process(card, amount) = %v, want %v", got, want)
 }

 charges := []creditcardtest.Charge{
 {Card: card, Amount: amount},
 }

 if got, want := cc.Charges, charges; !cmp.Equal(got, want) {
 t.Errorf("cc.Charges = %v, want %v", got, want)
 }
}

Затенение


В этом разделе употребляются два неофициальных термина — это сокрытие (stomping) и затенение (shadowing). Они не относятся к официальной терминологии языка Go.

Как и во многих других языках, в Go есть изменяемые переменные. Это означает, что оператор присвоения меняет значение переменной.


// Хорошо:
func abs(i int) int {
 if i < 0 {
 i *= -1
 }
 return i
}

При кратком объявлении переменных с помощью оператора := иногда новая переменная не создается. Мы называем это сокрытием переменной (stomping). Оно вполне допустимо, когда начальное значение переменной нам больше не потребуется.


// Хорошо:
// innerHandler — хелпер для обработчика запросов, самостоятельно
// отправляющий запросы другим бэкендам.
func (s *Server) innerHandler(ctx context.Context, req *pb.MyRequest) *pb.MyResponse {
 // Unconditionally cap the deadline for this part of request handling.
 ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
 defer cancel()
 ctxlog.Info("Capped deadline in inner request")

 // Код здесь больше не имеет доступа к исходному контексту.
 // Это хороший стиль, если при первом написании такого кода вы ожидаете,
 // что даже по мере роста кода ни одна корректная операция не должна
 // использовать (возможно, неограниченный) исходный контекст, 
 // предоставленный вызывающей стороной.

 // ...
}

Но будьте осторожны с коротким объявлением переменных в новой области видимости. Оно приводит к созданию новой переменной. Мы называем это затенением переменной. Код после окончания блока относится к начальному значению. Ниже представлена ошибочная попытка сократить крайний срок выполнения (deadline) по условию:


// Плохо:
func (s *Server) innerHandler(ctx context.Context, req *pb.MyRequest) *pb.MyResponse {
 // Попытка ограничить срок условием.
 if *shortenDeadlines {
 ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
 defer cancel()
 ctxlog.Info(ctx, "Capped deadline in inner request")
 }

 // БАГ: "ctx" здесь снова означает контекст, предоставленный 
 // вызывающей стороной.
 // Приведенный выше код с ошибками скомпилирован, потому что и ctx, и 
 // Cancel использовались внутри оператора if.

 // ...
}

Корректный код может выглядеть так:


// Хорошо:
func (s *Server) innerHandler(ctx context.Context, req *pb.MyRequest) *pb.MyResponse {
 if *shortenDeadlines {
 var cancel func()
 // Применяется простое присвоение, = а не :=.
 ctx, cancel = context.WithTimeout(ctx, 3*time.Second)
 defer cancel()
 ctxlog.Info(ctx, "Capped deadline in inner request")
 }
 // ...
}

Здесь мы скрыли (stomping) переменную. Поскольку новой переменной нет, назначаемый тип должен соответствовать типу начальной переменной. При затенении (shadowing) мы вводим полностью новый объект, который может иметь другой тип. Затенение может быть полезно, но здесь для ясности всегда используйте новое имя.


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


// Плохо:
func LongFunction() {
 url := "https://example.com/"
 // Oops, now we can't use net/url in code below.
}

Пакеты Util


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


Имена пакетов Go должны быть связаны с их функционалом. Назвать пакет всего одним словом: util, helper, common или подобным образом, как правило, не лучшее решение (однако это слово может стать частью имени). Неинформативные имена затрудняют чтение кода, а при частом использовании могут даже вызывать необоснованные конфликты при импорте.


Но точка вызова может выглядеть так:


// Хорошо:
db := spannertest.NewDatabaseFromFile(...)

_, err := f.Seek(0, io.SeekStart)

b := elliptic.Marshal(curve, x, y)

Приблизительное представление о функционале каждого объекта можно получить без списка импортов (cloud.google.com/go/spanner/spannertest, io и crypto/elliptic). С не столь содержательными именами код выглядел бы так:


// Плохо:
db := test.NewDatabaseFromFile(...)

_, err := f.Seek(0, common.SeekStart)

b := helper.Marshal(curve, x, y)

Размер пакета


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


А вот некоторые другие соображения и замечания.


В качестве пакета на одной странице пользователи видят godoc, а все методы, экспортируемые типами из пакета, группируются по их типу; godoc также группирует конструкторы вместе с возвращаемыми ими типами. Если клиентскому коду, скорее всего, потребуется взаимодействие двух значений разного типа, пользователю может быть удобно хранить их в одном пакете.


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


Тем не менее, если поместить весь проект в один пакет, такой пакет окажется непомерно раздутым. Когда часть проекта концептуально отличается от других частей, проще выделить аутентичную часть в отдельный пакет. Известное клиентам короткое имя пакета вместе с экспортируемым именем типа образуют понятный идентификатор, например bytes.Buffer, ring.New. В этой статье блога вы найдете больше примеров.


Стиль Go позволяет гибко менять размер файлов: при сопровождении пакета код можно перемещать внутри пакета из одного файла в другой без ущерба для вызывающих [частей кода]. Но, как показывает опыт, ни один файл со многими тысячами строк, ни множество маленьких файлов оптимальным решением не являются. В Go нет правила "один тип — один файл". Структура файлов организована достаточно хорошо, чтобы редактирующий его программист понимал, что и в каком файле искать. При этом файлы должны быть достаточно маленькими, чтобы в них было легче что-то найти. В стандартной библиотеке исходный код пакета часто разбивают на несколько файлов, группируя взаимосвязанный код в один файл. Хорошим примером может послужить код пакета bytes. В пакетах с объемной сопроводительной документацией один doc.go можно выделить для документации пакета и его объявления. В общем случае включать туда что-то еще не требуется.


В кодовой базе Google и в проектах на Bazel расположение каталогов кода Go отличается от расположения кода в проектах Go с открытым исходным кодом: можно иметь несколько целевых объектов (targets) go_library в одном каталоге. Если вы планируете сделать проект открытым, это хорошее обоснование, чтобы выделить каждому пакету отдельный каталог.


См. также:



Импорты


Протоколы и заглушки


Импортирование библиотек протоколов отличается от импортирования других импортируемых объектов Go в плане обработки кросс-языковой спецификой. Правило для переименованных импортов proto основано на правиле создания пакета:


  • Суффикс pb обычно используется в рамках правил go_proto_library.
  • Суффикс grpc обычно используется в рамках правил go_grpc_library.

Префикс обычно состоит из одной или двух букв:


// Хорошо:
import (
 fspb "path/to/package/foo_service_go_proto"
 fsgrpc "path/to/package/foo_service_go_grpc"
)

Если в пакете используется всего один протокол (proto) или пакет жестко привязан к протоколу, то префикс можно опустить:


import ( pb "path/to/package/foo\_service\_go\_proto" grpc "path/to/package/foo\_service\_go\_grpc" )

Если в протоколе используются универсальные (generic) или малоинформативные символы, а также неочевидные сокращения, префиксом может стать короткое слово:


// Хорошо:
import (
 mapspb "path/to/package/maps_go_proto"
)

Здесь, когда связь кода с картами неочевидна, mapspb.Address понять проще, чем mpb.Address.


Порядок импорта


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


  1. Стандартные библиотечные объекты, например "fmt".
  2. Другие импорты, например "/path/to/somelib".
  3. Опционально импорты протокольных буферов protobuf, например fpb "path/to/foo_go_proto".
  4. Опционально импорты побочных эффектов, например _ "path/to/package".

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


Как правило, допустима любая ясная, доступная для понимания группировка импорта. Участники команды могут выбрать группировку импорта gRPC отдельно от импорта protobuf.


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


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


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


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




Data Science и Machine Learning



Python, веб-разработка



Мобильная разработка



Java и C#



От основ — в глубину



А также


Комментарии (7)


  1. Sild
    20.04.2023 21:04

    Почему в го так модно использовать односимвольные имена переменных?

    // Хорошо:
    func (c *Config) WriteDetail(w io.Writer) (int64, error)
    

    Нет, не хорошо. Очень плохо.
    Да, меня покусал с++ и я хочу this, который некоторыми санитайзерами запрещен. Ну да ладно. Но почему односимвольные переменные не запрещены?


    1. miga
      20.04.2023 21:04
      +7

      Потому что го хоть и сделан для тупых (сурс), но все-таки предполагается что память среднего го-программиста получше, чем у среднего джависта, которому рыбка Дори даст фору в способности держать контекст, так что не надо писать GetSomethingFromAnotherThingByNameAndTimeStamp()

      /s


    1. mrobespierre
      20.04.2023 21:04
      +5

      Зачем их запрещать? Для этого нужны внятные, объективные аргументы.

      Аргументы почему это хорошо:

      1. В Go есть идеоматические названия для переменных. Посмотрите код стандартной библиотеки и увидьте как называются в ней Reader и Writer например. Go-программист видит "w" и ожидает там writer. Видит i и ожидает счётчик. Конфиг сюда же.

      2. Длинна имени переменной может являться подсказкой по области видимости переменной. Чем больше кода использует эту переменную, тем длиннее и описательнее должно быть её название. И наоборот, если она объявляется в сигнатуре и используется 2 раза внутри функции из 5 строк - одна буква это хороший выбор.

      Имя this - очевидно плохая практика. Оно по определению зависит от контекста, который мы вынуждены удерживать во внимании. Это не даёт ничего, но усложняет mental model нашего кода, а мы этого не хотим. Мы хотим чётко представлять в уме всю реализацию задачи от и до, со всеми нюансами, это и отличает инженерию от "просто кодинга".


      1. VladimirFarshatov
        20.04.2023 21:04
        +1

        Вот тут сильно не согласен. This внутри метода это во многих языках "ясно и понятно". Во внутренних контекстах ещё бывает closure как "that", что не вызывает никаких ментальных трудностей. А вот местное и часто сокращенное именование объекта в методе, часто усложняет когнитивное восприятие, и подсветка синтаксиса в ряде ИДЕ далеко не всегда улучшает ситуацию.. синенькое, желтенькое, беленькое .. в глаза пестрить начинает.

        Но .. не холивара для. ИМХО. Продолжать спор не планирую. На вкус каждому нравятся свои фломастеры. ;)


        1. miga
          20.04.2023 21:04

          Если серьезно, то именовать ресивер this - это плохая практика именно из-за семантики ресивера - thing.Func(args...) это всего лишь синтаксический сахар над (T).Func(thing, args...). В терминах С++ это все статические функции, и абсолютно легально вызывать их с нулевым ресивером (оф дока даже несколько издевательски утверждает, что _как правило_ все функции прекрасно работают с нулевым ресивером, но на практике это скорей фантастика :) )


  1. sedyh
    20.04.2023 21:04

    Перевод кем-то проверялся? Ссылки на gotip битые, потому что авторы оригинального codestyle ещё не сделали этот раздел.


  1. VladimirFarshatov
    20.04.2023 21:04
    +3

    Кодестайл - это замечательно. Кто-нибудь может подсказать где прочитать про недостатки ГО и его компилятора? Например:

    Эскейп анализ. Где подробно описано что за 80 попугаев определяют предельный размер инлайнинга функции или метода? В частности, как факт вот это:

    // концептуально правильнее себя обзывать this все же..
    func (this *MyObject) SetOutput(w io.Writer) {
    	this.blocker.Lock()
    	this.out = w
    	this.blocker.Unlock()
    }

    Уже не инлайнится т.к. тут инлайнятся функции мьютекса. Обьяснение эскейп анализа таково: " инлайн Lock, Unlock приводит к стоимости метода больше 80, ни магу"

    func (this *MyObject) SetOutput(w io.Writer) {
    	this.blocker.Lock()
        defer this.blocker.Unlock()
    
    	this.out = w
    }

    Не инлайнит ни методы мьютекса ни сам метод, т.к. в нем присутствует .. defer. Эскейп анализ так и обьясняет: "есть дефер, ни магу инлайнить"

    Однократно(!) вызываемая функция/метод не инлайнятся по тем же причинам! Привет "многоуровневый кровавый энтерпрайз" со своими хелперами, брокерами, сервисами и пр. многослойными ремаппингами proto туда-сюда-обратно.. "быстрый" язык говорите.. ;)

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

    Динамическое создание данных .. слайс - это две аллокации: подкапотный массив и собственно структуру слайса. Строка .. а ведь она тоже имеет указатель и длину.. сколько аллокаций? А у мапы?

    GC .. сколько времени исполнения выделено ДО его первого запуска? Что происходит, если горутина отработала шустрее? Мне так удавалось в бенчмарке раскручивать до мегабайта занимаемой памяти логером с аллокацией в 16 байт за проход..

    Писатель канала все ещё (1.20) не способен узнать о закрытии канала.. Даже специально добавлено в документацию. В итого, лепим дополнительные каналы, иные методы взаимодействия, обрамляем всё это мьютексами.. Доколе? :)

    И всё это обнаружено внезапно в версии 1.20..

    Где можно почитать про все эти и другие "фичи" скрытые под капотом?