Skip to content
On this page

Go style guide

Оглавление

Введение

Стили - это соглашения, которые управляют нашим кодом. Термин "стиль" это не про форматирование исходного кода (gofmt делает это за нас), а про культуру разработки.

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

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

  1. Effective Go
  2. Go Common Mistakes
  3. Go Code Review Comments

Стиль написания кода

Размер блока кода

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

Рекомендуется ограничение длины строки в 99 символов. Разработчики должны стремиться к переносу строк до достижения этого предела, но это не является жестким ограничением. Коду разрешено превышать этот предел.

Группировка объявлений

Go поддерживает группировку похожих объявлений.

ПлохоХорошо
go
import "a"
import "b"
go
import (
    "a"
    "b"
)

Это также относится к константам, переменным и объявлениям типов.

ПлохоХорошо
go
const a = 1
const b = 2

var a = 1
var b = 2

type Area float64
type Volume float64
go
const (
    a = 1
    b = 2
)

var (
    a = 1
    b = 2
)

type (
    Area   float64
    Volume float64
)

Не группируйте объявления, которые не связаны между собой.

ПлохоХорошо
go
type Operation int

const (
    Unknown Operation = iota
    Add
    Subtract
    Multiply
    EnvVar = "MY_ENV"
)
go
type Operation int

const (
    Unknown Operation = iota
    Add
    Subtract
    Multiply
)

const EnvVar = "MY_ENV"

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

ПлохоХорошо
go
func foo() string {
    red := color.New(0xff0000)
    green := color.New(0x00ff00)
    blue := color.New(0x0000ff)
}
go
func foo() string {
    var (
        red   = color.New(0xff0000)
        green = color.New(0x00ff00)
        blue  = color.New(0x0000ff)
    )
}

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

ПлохоХорошо
go
func foo() {
    caller := c.name
    format := "json"
    timeout := 5*time.Second
    var err error
}
go
func foo() {
    var (
        caller  = c.name
        format  = "json"
        timeout = 5*time.Second
        err error
    )
}

Использование CamalCase

В Go принято использовать CamalCase для написания многословных имен. Исключение делается для тестовых функций, которые могут содержать символы подчеркивания с целью группировки связанных тестовых примеров, например, TestMyFunction_WhatIsBeingTested.

Наименование пакетов

При присвоении имен пакетам выберите имя, которое:

  • в нижнем регистре и без подчёркиваний;
  • коротко и лаконично;
  • не нуждается в переименовании с использованием псевдонима;
  • не во множественном числе (например, net/url, а не net/urls);
  • не использует неинформативные названия (например, common, util, shared или lib).

Импортирование пакетов

Должно быть три группы импорта:

  • стандартная библиотека;
  • внутренние пакеты;
  • внешние пакеты.
ПлохоХорошо
go
import (
    "fmt"
    "golang.org/x/sync/errgroup"
    "os"
    "example.com/internal/package"
)
go
import (
    "fmt"
    "os"

    "example.com/internal/package"

    "golang.org/x/sync/errgroup"
)

Псевдоним для импортированного пакета

Псевдоним для импортированного пакета должен использоваться, если имя пакета не совпадает с последним элементом пути импорта.

go
import (
    "net/http"

    client "example.com/client-go"
    trace "example.com/trace/v2"
)

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

ПлохоХорошо
go
import (
    "fmt"
    "os"

    nettrace "golang.net/x/trace"
)
go
import (
    "fmt"
    "os"
    "runtime/trace"

    nettrace "golang.net/x/trace"
)

Объявление переменных

Короткие объявления переменных (:=) следует использовать, если переменной явно присваивается какое-либо значение.

ПлохоХорошо
go
var s = "foo"
go
s := "foo"

Однако бывают случаи, когда значение по-умолчанию становится более четким при использовании ключевого слова var.

ПлохоХорошо
go
func foo(list []int) {
    filtered := []int{}
    for _, v := range list {
        if v > 10 {
            filtered = append(filtered, v)
        }
    }
}
go
func foo(list []int) {
    var filtered []int
    for _, v := range list {
        if v > 10 {
            filtered = append(filtered, v)
        }
    }
}

Нулевой slice

nil - это slice с длинной ноль. Это означает, что:

  • вы не должны явно возвращать slice нулевой длины. Вместо этого верните значение nil;

    ПлохоХорошо
    go
    if x == "" {
        return []int{}
    }
    go
    if x == "" {
        return nil
    }
  • чтобы проверить, пуст ли slice, всегда используйте len(s) == 0. Не проверяйте на значение nil;

    ПлохоХорошо
    go
    func isEmpty(s []string) bool {
        return s == nil
    }
    go
    func isEmpty(s []string) bool {
        return len(s) == 0
    }
  • нулевое значение (slice, объявленный с помощью var) можно использовать немедленно без make.

    ПлохоХорошо
    go
    nums := []int{}
    
    if add1 {
        nums = append(nums, 1)
    }
    
    if add2 {
        nums = append(nums, 2)
    }
    go
    var nums []int
    
    if add1 {
        nums = append(nums, 1)
    }
    
    if add2 {
        nums = append(nums, 2)
    }

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

Инициализация структуры

Использование имен полей структуры

Вы почти всегда должны указывать имена полей при инициализации структуры.

ПлохоХорошо
go
k := User{"John", "Doe", true}
go
k := User{
    FirstName: "John",
    LastName: "Doe",
    Admin: true,
}

Исключение: имена полей могут быть опущены в табличных тестах, если имеется 3 или менее полей.

go
testcases := []struct{
    op Operation
    want string
}{
    {Add, "add"},
    {Subtract, "subtract"},
}

Опускание полей структуры с нулевым значением

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

ПлохоХорошо
go
user := User{
    FirstName: "John",
    LastName: "Doe",
    MiddleName: "",
    Admin: false,
}
go
user := User{
    FirstName: "John",
    LastName: "Doe",
}

Это помогает уменьшить шум для читателей, опуская значения, которые используются по-умолчанию в этом контексте. Указываются только значимые значения.

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

go
testcases := []struct{
    give string
    want int
}{
    {give: "0", want: 0},
}

Использование var для структуры с нулевым значением

Когда все поля структуры опущены в объявлении, используйте форму var для объявления структуры.

ПлохоХорошо
go
user := User{}
go
var user User

Это отличает структуры с нулевым значением от структур с ненулевыми полями.

Инициализация указателя на структуру

Используйте &T{} вместо new(T) при инициализации указателя на структуру, чтобы это соответствовало инициализации структуры.

ПлохоХорошо
go
ptr := new(T)
ptr.Name = "bar"
go
ptr := &T{Name: "foo"}

Инициализация map

Предпочитайте make для пустых map и map, заполненных программно. Это визуально отличает инициализацию map от объявления и упрощает добавление подсказок по ёмкости, если таковые имеются.

ПлохоХорошо
go
var (
    m1 = map[T1]T2{}
    m2 map[T1]T2
)
go
var (
    m1 = make(map[T1]T2)
    m2 map[T1]T2
)

Там, где это возможно, предоставляйте подсказки о ёмкости при инициализации map с помощью make.

С другой стороны, если map содержит фиксированный список элементов, используйте литералы map для её инициализации.

ПлохоХорошо
go
m := make(map[T1]T2, 3)
m[k1] = v1
m[k2] = v2
m[k3] = v3
go
m := map[T1]T2{
    k1: v1,
    k2: v2,
    k3: v3,
}

Основное эмпирическое правило состоит в том, чтобы использовать литералы map при добавлении фиксированного набора элементов во время инициализации, в противном случае используйте make.

Методические рекомендации

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

При необходимости проверяйте тип на соответствие интерфейсу во время компиляции:

  • экспортируемые типы, необходимые для реализации определенных интерфейсов в рамках их контракта API;
  • экспортированные или неэкспортированные типы, являющиеся частью коллекции типов, реализующих один и тот же интерфейс;
  • другие случаи, когда нарушение интерфейса может привести к нарушению работы пользователей.
ПлохоХорошо
go
type Handler struct {
    // ...
}

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // ...
}
go
type Handler struct {
    // ...
}

var _ http.Handler = (*Handler)(nil)

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // ...
}

Мьютекс с нулевым значением

Нулевое значение sync.Mutex и sync.RWMutex допустимо, поэтому вам почти никогда не понадобится указатель на мьютекс.

ПлохоХорошо
go
mu := new(sync.Mutex)
mu.Lock()
go
var mu sync.Mutex
mu.Lock()

Если вы используете структуру по указателю, то мьютекс должен быть полем без указателя на неё. Не встраивайте мьютекс в структуру, даже если структура не экспортируется.

ПлохоХорошо
go
type SyncMap struct {
    sync.Mutex
    data map[string]string
}

func NewSyncMap() *SyncMap {
    return &SyncMap{
        data: make(map[string]string),
    }
}

func (m *SyncMap) Get(k string) string {
    m.Lock()
    defer m.Unlock()
    return m.data[k]
}
go
type SyncMap struct {
    mu sync.Mutex
    data map[string]string
}

func NewSyncMap() *SyncMap {
    return &SyncMap{
        data: make(map[string]string),
    }
}

func (m *SyncMap) Get(k string) string {
    m.mu.Lock()
    defer m.mu.Unlock()
    return m.data[k]
}

Поле мьютекса, а также методы Lock и Unlock непреднамеренно являются частью экспортируемого API SyncMap.

Мьютекс и его методы являются деталями реализации SyncMap, скрытыми от его вызывающих.

Копирование slice и map

Slice и map содержат указатели на базовые данные, поэтому будьте осторожны со сценариями, когда их необходимо скопировать.

Получение slice и map

Имейте в виду, что пользователи могут изменять slice и map, полученные вами в качестве аргумента, если вы сохраните указатель на них.

ПлохоХорошо
go
func (d *Driver) SetTrips(trips []Trip) {
    d.trips = trips
}

trips := ...
d1.SetTrips(trips)

trips[0] = ...
go
func (d *Driver) SetTrips(trips []Trip) {
    d.trips = make([]Trip, len(trips))
    copy(d.trips, trips)
}

trips := ...
d1.SetTrips(trips)

trips[0] = ...

Если изменить trips, то изменится и d1.trips

Теперь мы можем изменять trips, не затрагивая d1.trips

Возвращение slice и map

Аналогичным образом, будьте осторожны с пользовательскими изменениями slice и map, раскрывающими внутреннее состояние.

ПлохоХорошо
go
type Stats struct {
    mu sync.Mutex
    counters map[string]int
}

func (s *Stats) Snapshot() map[string]int {
    s.mu.Lock()
    defer s.mu.Unlock()
    return s.counters
}

stats := ...
snap := stats.Snapshot()
go
type Stats struct {
    mu sync.Mutex
    counters map[string]int
}

func (s *Stats) Snapshot() map[string]int {
    s.mu.Lock()
    defer s.mu.Unlock()
    result := make(map[string]int, len(s.counters))
    for k, v := range s.counters {
        result[k] = v
    }
    return result
}

stats := ...
snap := stats.Snapshot()

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

Snap это новая копия

Defer

Используйте defer для очистки ресурсов, таких как файлы и блокировки.

ПлохоХорошо
go
p.Lock()
if p.count < 10 {
    p.Unlock()
    return p.count
}

p.count++
newCount := p.count
p.Unlock()

return newCount
go
p.Lock()
defer p.Unlock()

if p.count < 10 {
    return p.count
}

p.count++
return p.count

Легко пропустить разблокировку из-за многократных возвратов

Бо́льшая читаемость

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

Panic

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

ПлохоХорошо
go
func run(args []string) {
    if len(args) == 0 {
        panic("an argument is required")
    }
}

func main() {
    run(os.Args[1:])
}
go
func run(args []string) error {
    if len(args) == 0 {
        return errors.New("an argument is required")
    }
    return nil
}

func main() {
    if err := run(os.Args[1:]); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

panic/recover - это не стратегия обработки ошибок. Программа должна вызывать panic только тогда, когда происходит что-то непоправимое, например, разыменование nil. Исключением из этого правила является инициализация программы: плохие вещи при запуске программы, которые должны прервать работу программы, могут вызвать panic.

go
var statusTemplate = template.Must(template.New("name").Parse("statusHTML"))

Даже в тестах используйте t.Fatal* или t.FailNow вместо panic, чтобы убедиться, что тест помечен как неудачный.

ПлохоХорошо
go
func TestFoo(t *testing.T) {
    f, err := os.CreateTemp("", "test")
    if err != nil {
        panic("failed to set up test")
    }
}
go
func TestFoo(t *testing.T) {
    f, err := os.CreateTemp("", "test")
    if err != nil {
        t.Fatal("failed to set up test")
    }
}

Обработка ошибок

Типы ошибок

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

  • нужно ли вызывающему абоненту сопоставлять ошибку, чтобы он мог с ней справиться? Если да, то мы должны поддерживать функции errors.Is или errors.As путем объявления переменной ошибки верхнего уровня или пользовательского типа;
  • является ли сообщение об ошибке статичной строкой или это динамичная строка, требующая контекстной информации? Для первого мы можем использовать errors.New, но для последнего мы должны использовать fmt.Errorf или пользовательский тип ошибки;
  • распространяем ли мы новую ошибку, возвращаемую нисходящей функцией? Если это так, см. раздел, посвященный оборачиванию ошибок.
Ошибка сопоставляется?Тип сообщенияИнициализация ошибки
НетСтатичныйerrors.New
НетДинамичныйfmt.Errorf
ДаСтатичныйглобальная переменная с errors.New
ДаДинамичныйпользовательский тип ошибки

Например, используйте errors.New для ошибки со статичным сообщением. Экспортируйте эту ошибку в качестве переменной, чтобы поддерживать сопоставление её с ошибками. Это если вызывающий должен сопоставить и обработать эту ошибку.

ПлохоХорошо
go
func Open() error {
    return errors.New("could not open")
}

if err := Open(); err != nil {
    panic("unknown error")
}
go
var ErrCouldNotOpen = errors.New("could not open")

func Open() error {
    return ErrCouldNotOpen
}

if err := Open(); err != nil {
    if errors.Is(err, ErrCouldNotOpen) {
        // ...
    } else {
        panic("unknown error")
    }
}

Обработка ошибки отсутвует.

Обработка ошибки присутсвует.

Для ошибки с динамической строкой используйте fmt.Errorf, если вызывающей стороне не нужно сопоставлять её, и пользовательскую ошибку, если вызывающей стороне действительно нужно сопоставить её.

Нет совпадения ошибокСовпадение ошибок
go
func Open(file string) error {
    return fmt.Errorf("file %q not found", file)
}

if err := Open("testfile.txt"); err != nil {
    panic("unknown error")
}
go
type NotFoundError struct {
    File string
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("file %q not found", e.File)
}

func Open(file string) error {
    return &NotFoundError{File: file}
}

if err := Open("testfile.txt"); err != nil {
    var notFound *NotFoundError
    if errors.As(err, &notFound) {
        // ...
    } else {
        panic("unknown error")
    }
}

Обработка ошибки отсутвует.

Обработка ошибки присутсвует.

Обратите внимание, что если вы экспортируете переменные или типы ошибок из пакета, они станут частью общедоступного API пакета.

Оборачивание ошибок

Существует три основных способа возврата ошибок:

  • возврат исходной ошибки как есть;
  • возврат ошибки с контекстом при помощи fmt.Errorf и спецификатора %w;
  • возврат ошибки с контекстом при помощи fmt.Errorf и спецификатора %v.

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

В противном случае, по возможности, добавьте контекст к сообщению об ошибке, чтобы вместо неопределенной ошибки, такой как connection refused, вы получили более полезные ошибки, такие как call service foo: connection refused.

Используйте fmt.Errorf для добавления контекста к вашим ошибкам, выбирая между спецификаторами %w или %v в зависимости от того, должен ли вызывающий объект быть в состоянии сопоставить и извлечь основную причину.

Используйте %w, если вызывающий должен иметь доступ к основной ошибке. Это хорошее значение по-умолчанию для большинства обернутых ошибок, но имейте в виду, что вызывающие могут начать полагаться на это поведение. Поэтому в тех случаях, когда обернутая ошибка является известной переменной или типом, документируйте и тестируйте её как часть контракта вашей функции. Используйте %v, чтобы скрыть основную ошибку. Вызывающие абоненты не смогут сопоставить его, но при необходимости вы можете переключиться на %w в будущем. При добавлении контекста к возвращаемым ошибкам сохраняйте контекст кратким, избегая фраз типа failed to, которые указывают на очевидное и накапливаются по мере того, как ошибка просачивается через стек.

ПлохоХорошо
go
s, err := store.New()
if err != nil {
    return fmt.Errorf(
        "failed to create new store: %w", err,
    )
}
go
s, err := store.New()
if err != nil {
    return fmt.Errorf(
        "new store: %w", err,
    )
}

failed to x: failed to y: failed to create new store: the error message

x: y: new store: the error message

Однако, как только ошибка отправляется в другую систему, должно быть ясно, что сообщение является ошибкой (например, err тег или префикс failed в логах).

Наименование ошибок

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

go
var (
    // Экспортируются следующие две ошибки
    // чтобы пользователи этого пакета могли сопоставлять их
    // с errors.Is.
    ErrBrokenLink = errors.New("link is broken")
    ErrCouldNotOpen = errors.New("could not open")

    // Эта ошибка не экспортируется, потому что
    // мы не хотим делать это частью нашего общедоступного API.
    // Мы все еще можем использовать его внутри пакета
    // с errors.Is.
    errNotFound = errors.New("not found")
)

Для пользовательских типов ошибок вместо этого используйте суффикс Error.

go
// Аналогично, эта ошибка экспортируется
// чтобы пользователи этого пакета могли сопоставить его
// с errors.As.
type NotFoundError struct {
    File string
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("file %q not found", e.File)
}

// И эта ошибка не экспортируется, потому что
// мы не хотим делать его частью общедоступного API.
// Мы все еще можем использовать его внутри пакета
// с errors.As .
type resolveError struct {
    Path string
}

func (e *resolveError) Error() string {
    return fmt.Sprintf("resolve %q", e.Path)
}

Нулевое значение константы

Стандартный способ введения перечислений в Go - объявить пользовательский тип и группу const с помощью iota. Поскольку переменные имеют значение по-умолчанию 0, обычно вы должны начинать свои перечисления с ненулевого значения.

ПлохоХорошо
go
type Operation int

const (
    Add Operation = iota
    Subtract
    Multiply
)
go
type Operation int

const (
    Unknown Operation = iota
    Add
    Subtract
    Multiply
)

Нулевое значение для типа operation является значение константы Add.

Нулевое значение для типа operation является значение константы Unknown.

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

go
type LogOutput int

const (
    LogToStdout LogOutput = iota
    LogToFile
    LogToRemote
)

Изменяемые глобальные переменные

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

ПлохоХорошо
go
var timeNow = time.Now

func sign(msg string) string {
    now := timeNow()
    return signWithTime(msg, now)
}
go
type signer struct {
    now func() time.Time
}

func newSigner() *signer {
    return &signer{
        now: time.Now,
    }
}

func (s *signer) Sign(msg string) string {
    now := s.now()
    return signWithTime(msg, now)
}

Встраивания типов в общедоступные структуры

Избегайте встравания типов в общедоступные структуры. Эти встроенные типы приводят к утечке деталей реализации, препятствуют эволюции типов и скрывают документацию.

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

go
type AbstractList struct{}

func (l *AbstractList) Add(e Entity) {
    // ...
}

func (l *AbstractList) Remove(e Entity) {
    // ...
}
ПлохоХорошо
go
type ConcreteList struct {
    *AbstractList
}
go
type ConcreteList struct {
    list *AbstractList
}

func (l *ConcreteList) Add(e Entity) {
    l.list.Add(e)
}

func (l *ConcreteList) Remove(e Entity) {
    l.list.Remove(e)
}

Go допускает встраивание типов в качестве компромисса между наследованием и композицией. Внешний тип получает неявные копии методов встроенного типа. Эти методы по-умолчанию делегируются одному и тому же методу встроенного экземпляра.

Структура также получает поле с тем же именем, что и тип. Итак, если встроенный тип является общедоступным, то поле является общедоступным. Чтобы поддерживать обратную совместимость, каждая будущая версия внешнего типа должна сохранять встроенный тип.

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

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

ПлохоХорошо
go
type AbstractList interface {
    Add(Entity)
    Remove(Entity)
}

type ConcreteList struct {
    AbstractList
}
go
type ConcreteList struct {
    list AbstractList
}

func (l *ConcreteList) Add(e Entity) {
    l.list.Add(e)
}

func (l *ConcreteList) Remove(e Entity) {
    l.list.Remove(e)
}

Либо со встроенной структурой, либо со встроенным интерфейсом, встроенный тип накладывает ограничения на эволюцию типа.

  • Добавление методов во встроенный интерфейс - это кардинальное изменение.
  • Удаление методов из встроенной структуры - это кардинальное изменение.
  • Удаление встроенного типа - это кардинальное изменение.
  • Замена встроенного типа, даже на альтернативный, который удовлетворяет тому же интерфейсу, является кардинальным изменением.

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

Функция init

Избегайте init, где это возможно. Когда выполнение функции init неизбежно или желательно, код должен выполнять следующие условия:

  1. Быть полностью детерминированным, независимо от среды программы или вызова.
  2. Избегать зависимости от порядка или побочных эффектов других функций init. Хотя порядок init хорошо известен, код может изменяться, и, следовательно, отношения между функциями init могут сделать код хрупким и подверженным ошибкам.
  3. Избегать доступа или манипулирования глобальным состоянием или состоянием среды, таким как информация о машине, переменные среды, рабочий каталог, аргументы/входные данные программы и т.д.
  4. Избегать операций ввода-вывода, включая вызовы файловой системы, сети и системы.

Код, который не может удовлетворить этим требованиям, скорее всего, относится к вспомогательному, который должен вызываться как часть main (или в другом месте жизненного цикла программы) или быть написан как часть самого main. В частности, библиотеки, предназначенные для использования другими программами, должны проявлять особую осторожность, чтобы быть полностью детерминированными и не выполнять "магию инициализации".

ПлохоХорошо
go
type Foo struct {
    // ...
}

var defaultFoo Foo

func init() {
    defaultFoo = Foo{
        // ...
    }
}
go
var defaultFoo = Foo{
    // ...
}

var defaultFoo = defaultFoo()

func defaultFoo() Foo {
    return Foo{
        // ...
    }
}

Выход из main

Программы Go используют os.Exit или log.Fatal для немедленного выхода (panic - не лучший способ выйти из программы, пожалуйста, не паникуйте).

Вызовите одну из os.Exit или log.Fatal только в main-функции. Все остальные функции должны возвращать ошибки, чтобы сигнализировать о сбое.

ПлохоХорошо
go
func main() {
    body := readFile(path)
    fmt.Println(body)
}

func readFile(path string) string {
    f, err := os.Open(path)
    if err != nil {
        log.Fatal(err)
    }

    b, err := io.ReadAll(f)
    if err != nil {
        log.Fatal(err)
    }

    return string(b)
}
go
func main() {
    body, err := readFile(path)
    if err != nil {
      log.Fatal(err)
    }
    fmt.Println(body)
}

func readFile(path string) (string, error) {
    f, err := os.Open(path)
    if err != nil {
        return "", err
    }

    b, err := io.ReadAll(f)
    if err != nil {
        return "", err
    }

    return string(b), nil
}

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

  • неочевидный поток управления: любая функция может выйти из программы, поэтому становится трудно рассуждать о потоке управления;
  • трудно протестировать: функция, которая завершает работу с программой, также завершит тест, вызывающий ее. Это затрудняет тестирование функции и создает риск пропуска других тестов, которые еще не были запущены go test;
  • пропущенная очистка: когда функция выходит из программы, она пропускает вызовы функций, поставленные в очередь с операторами defer. Это увеличивает риск пропуска важных задач очистки.

Производительность

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

Strconv вместо fmt

При преобразовании примитивов в/из строк strconv работает быстрее, чем fmt.

ПлохоХорошо
go
s := fmt.Sprint(rand.Int())
go
s := strconv.Itoa(rand.Int())

Повторное преобразование строки в байты

Не создавайте slice байтов из фиксированной строки повторно. Вместо этого выполните преобразование один раз и зафиксируйте результат.

ПлохоХорошо
go
w.Write([]byte("Hello world"))
go
data := []byte("Hello world")
w.Write(data)

Ёмкость контейнера

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

map

Там, где это возможно, предоставляйте подсказки о ёмкости при инициализации map с помощью make.

go
make(map[T1]T2, cap)

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

Обратите внимание, что, в отличие от slice, подсказки ёмкости map не гарантируют полного, упреждающего распределения, но используются для приблизительного определения количества требуемых сегментов hashmap. Следовательно, распределение всё равно может происходить при добавлении элементов в map, даже до указанной ёмкости.

ПлохоХорошо
go
m := make(map[string]os.FileInfo)

files, _ := os.ReadDir("./files")
for _, f := range files {
    m[f.Name()] = f
}
go
files, _ := os.ReadDir("./files")

m := make(map[string]os.DirEntry, len(files))
for _, f := range files {
    m[f.Name()] = f
}

slice

Там, где это возможно, предоставляйте подсказки о ёмкости при инициализации slice с помощью make, особенно при добавлении.

go
make([]T, len, cap)

В отличие от map, ёмкость slice не является подсказкой: компилятор выделит достаточно памяти для ёмкости slice, как указано в make, что означает, что последующие операции append будут выполняться с нулевым выделением (до тех пор, пока длина slice не будет соответствовать ёмкости, после чего любые добавления потребуют изменения размера для удержания дополнительных элементов).

ПлохоХорошо
go
for n := 0; n < b.N; n++ {
    data := make([]int, 0)
    for k := 0; k < size; k++ {
        data = append(data, k)
    }
}
go
for n := 0; n < b.N; n++ {
    data := make([]int, 0, size)
    for k := 0; k < size; k++ {
        data = append(data, k)
    }
}

Паттерны

Табличные тесты

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

ПлохоХорошо
go
host, port, err := net.SplitHostPort("192.0.2.0:8000")
require.NoError(t, err)
assert.Equal(t, "192.0.2.0", host)
assert.Equal(t, "8000", port)

host, port, err = net.SplitHostPort("192.0.2.0:http")
require.NoError(t, err)
assert.Equal(t, "192.0.2.0", host)
assert.Equal(t, "http", port)

host, port, err = net.SplitHostPort(":8000")
require.NoError(t, err)
assert.Equal(t, "", host)
assert.Equal(t, "8000", port)

host, port, err = net.SplitHostPort("1:8")
require.NoError(t, err)
assert.Equal(t, "1", host)
assert.Equal(t, "8", port)
go
testcases := []struct{
    give     string
    wantHost string
    wantPort string
}{
    {
        give:     "192.0.2.0:8000",
        wantHost: "192.0.2.0",
        wantPort: "8000",
    },
    {
        give:     "192.0.2.0:http",
        wantHost: "192.0.2.0",
        wantPort: "http",
    },
    {
        give:     ":8000",
        wantHost: "",
        wantPort: "8000",
    },
    {
        give:     "1:8",
        wantHost: "1",
        wantPort: "8",
    },
}

for _, tc := range testcases {
    t.Run(tc.give, func(t *testing.T) {
        host, port, err := net.SplitHostPort(tc.give)
        require.NoError(t, err)
        assert.Equal(t, tc.wantHost, host)
        assert.Equal(t, tc.wantPort, port)
    })
}

Табличные тесты упрощают добавление контекста к сообщениям об ошибках, уменьшают дублирование логики и добавляют новые тестовые примеры.

Следуйте соглашению о том, что фрагмент структур называется testcases, а каждый тестовый пример tc. Кроме того, рекомендуется указывать входные и выходные значения для каждого тестового примера с помощью префиксов give и want.

go
testcases := []struct{
    give     string
    wantHost string
    wantPort string
}{
    // ...
}

for _, tc := range testcases {
    // ...
}

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

go
testcases := []struct{
    give string
    // ...
}{
    // ...
}

for _, tc := range testcases {
    tc := tc // for t.Parallel
    t.Run(tc.give, func(t *testing.T) {
        t.Parallel()
        // ...
    })
}

В приведенном выше примере необходимо объявить переменную, ограниченную итерацией цикла, из-за использования t.Parallel() ниже. Если этого не сделать, большинство или все тесты получат неожиданное значение для tc или значение, которое меняется по мере их выполнения.

Функциональные опции

Функциональные опции - это паттерн, в котором вы объявляете непрозрачный тип параметра, который записывает информацию в некоторую внутреннюю структуру. Вы принимаете различное количество этих опций и действуете на основе полной информации, записанной опциями во внутренней структуре.

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

ПлохоХорошо
go
func Open(
    addr string,
    cache bool,
    logger *zap.Logger
) (*Connection, error) {
    // ...
}
go
type Option interface {
    // ...
}

func WithCache(b bool) Option {
    // ...
}

func WithLogger(log *zap.Logger) Option {
    // ...
}

func Open(
    addr string,
    opts ...Option,
) (*Connection, error) {
    // ...
}

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

go
db.Open(addr, db.DefaultCache, zap.NewNop())
db.Open(addr, db.DefaultCache, log)
db.Open(addr, false /* cache */, zap.NewNop())
db.Open(addr, false /* cache */, log)

Опции предоставляются только в случае необходимости.

go
db.Open(addr)
db.Open(addr, db.WithLogger(log))
db.Open(addr, db.WithCache(false))
db.Open(
    addr,
    db.WithCache(false),
    db.WithLogger(log),
)

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

go
type options struct {
    cache  bool
    logger *zap.Logger
}

type Option interface {
    apply(*options)
}

type cacheOption bool

func (c cacheOption) apply(opts *options) {
    opts.cache = bool(c)
}

func WithCache(b bool) Option {
    return cacheOption(b)
}

type loggerOption struct {
    Log *zap.Logger
}

func (l loggerOption) apply(opts *options) {
    opts.logger = l.Log
}

func WithLogger(log *zap.Logger) Option {
    return loggerOption{Log: log}
}

func Open(
    addr string,
    opts ...Option,
) (*Connection, error) {
    options := options{
        cache:  defaultCache,
        logger: zap.NewNop(),
    }

    for _, o := range opts {
        o.apply(&options)
    }

    // ...
}

Обратите внимание, что существует способ реализации этого патерна с замыканиями, но приведенный выше паттерн обеспечивает большую гибкость для разработчиков и его легче отлаживать и тестировать для пользователей. В частности, это позволяет сравнивать параметры друг с другом в тестах и макетах, в отличие от замыканий, где это невозможно. Кроме того, он позволяет реализовывать другие интерфейсы, включая fmt.Stringer, который позволяет создавать понятные пользователю строковые представления опций.

Статический анализ

Рекомендуется использовать golangci-lint в качестве средства статического анализа кода Go, в основном из-за его производительности в больших кодовых базах и возможности настраивать и использовать множество канонических линтеров одновременно.