Глоссарий

Go

Короткие ответы на вопросы, которые всплывают, когда пишешь на Go. У каждого вопроса есть пример.

40 вопросов с примерами

Язык и программа

01 Что такое Go?

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

Язык сознательно небольшой. Нет классов и наследования, интерфейс выполняется неявно, ошибки — это обычные значения. Отсюда прямой стиль: мало скрытой магии, много явного кода.

Обзор языка и спецификация: A Tour of Go и The Go Programming Language Specification.

Пример

Go

точка входа

package main

import "fmt"

func main() {
    fmt.Println("программа началась здесь")
}

02 Как устроена программа на Go?

Каталог — это один пакет. Исполняемая программа начинается с package main и функции func main(). Остальные пакеты сами по себе не запускаются: их импортирует main или кто-то из его зависимостей.

Импорт без использования — ошибка компиляции. Имя пакета в import — это путь модуля, а имя в коде обычно совпадает с последним элементом пути: fmt.Println.

Пример

Go

main вызывает свой пакетный код

package main

import "fmt"

func greet(name string) string {
    return "привет, " + name
}

func main() {
    fmt.Println(greet("мир"))
}

03 Что такое модуль и файл go.mod?

Модуль — это корень проекта и единица версионирования. go.mod задаёт путь модуля и версию языка. Команда go mod init example.com/notes создаёт этот файл, go.sum хранит контрольные суммы зависимостей.

Импорты внутри модуля начинаются с его пути: пакет example.com/notes/internal/web. Внешние библиотеки появляются в go.mod после go get или первой сборки, которая их увидела.

Пример

mod

go.mod

module example.com/notes

go 1.25

04 Какие имена видны другим пакетам?

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

Внутри своего пакета видны и те и другие. encoding/json тоже смотрит на это правило: в JSON попадают только экспортированные поля.

Пример

Go

Hello снаружи, hello внутри

package greet

import "fmt"

func Hello(name string) {
    fmt.Println(hello(name))
}

func hello(name string) string {
    return "привет, " + name
}

05 Зачем gofmt, если код и так компилируется?

gofmt приводит файл к единому виду: табы, пробелы, порядок импорта, переносы. В Go не спорят о стиле в ревью — спор закрывает инструмент. Перед коммитом достаточно gofmt -w . или go fmt ./....

Пример

Go

так выглядит уже отформатированная программа

package main

import "fmt"

func main() {
    fmt.Println("gofmt расставляет отступы и импорт")
}

Типы и значения

06 Как объявить переменную и константу?

var объявляет переменную и может стоять где угодно. Если тип опущен, его выводят из значения справа. const — это неизменяемое значение, известное компилятору. Внутри функции короткое := сразу объявляет и присваивает.

Пример

Go

три способа дать имя значению

package main

import "fmt"

func main() {
    var count int
    count = 2

    var title = "заметка"
    name := "Аня"
    const pi = 3.14159

    fmt.Println(count, title, name, pi)
}

07 Чем := отличается от обычного присваивания?

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

В примере внутренние n и err живут только внутри if. Снаружи остаются n == 10 и err == nil, хотя разбор строки "нет" завершился ошибкой.

Если нужно обновить те же переменные, пиши n, err = strconv.Atoi(...) — одно равно, без двоеточия.

Пример

Go

внутренний err не трогает внешний

package main

import (
    "fmt"
    "strconv"
)

func main() {
    n, err := strconv.Atoi("10")
    if err != nil {
        panic(err)
    }

    if n, err := strconv.Atoi("нет"); err != nil {
        fmt.Println("внутри if:", err)
        fmt.Println("внутренний n:", n)
    }

    fmt.Println("снаружи n =", n, "err =", err)
}

08 Что такое нулевое значение?

У var без начального значения уже есть предсказуемое содержимое. Числа — 0, bool — false, строка — "". Указатели, срезы, карты, каналы, функции и интерфейсы — nil.

На нулевое значение можно опираться. Пустая строка и nil-срез ведут себя спокойно в len и range. Запись в nil-карту и вызов метода по nil-указателю — уже паника, если метод сам разыменовывает получателя.

Пример

Go

var без инициализации

package main

import "fmt"

func main() {
    var (
        n  int
        ok bool
        s  string
        p  *int
        sl []int
        m  map[string]int
    )
    fmt.Printf("%#v %#v %q %#v %#v %#v\n", n, ok, s, p, sl, m)
}

09 Какие базовые типы есть в Go?

Логический bool, строка string, целые int/uint и их размеры int8…int64, числа с плавающей точкой float32/float64, комплексные complex64/complex128. byte — это псевдоним uint8, rune — псевдоним int32 и означает кодовую точку Unicode.

Размер int зависит от платформы: 32 или 64 бита. Для файла, сети и протокола бери явный размер, например int64 или uint32. Для счётчика в памяти обычного int достаточно.

Пример

Go

печать конкретных типов

package main

import "fmt"

func main() {
    var (
        flag bool
        b    byte    = 'A'
        r    rune    = 'Я'
        i    int     = -3
        u    uint    = 3
        f    float64 = 1.5
    )
    fmt.Printf("%T %T %T %T %T %T\n", flag, b, r, i, u, f)
    fmt.Println(string(r))
}

10 Чем строка отличается от набора символов?

Строка в Go — неизменяемая последовательность байт, почти всегда в UTF-8. len(s) считает байты, а не буквы. Буква «Я» занимает два байта, поэтому в "Привет" шесть рун и двенадцать байт.

Цикл for i, r := range s идёт по рунам, а i — это байтовый сдвиг, поэтому индексы будут 0, 2, 4… Чтобы получить срез символов, преобразуй строку к []rune. Чтобы править байты на месте, бери []byte — строку так менять нельзя.

Пример

Go

байты и руны в слове Привет

package main

import (
    "fmt"
    "unicode/utf8"
)

func main() {
    s := "Привет"
    fmt.Println(len(s))
    fmt.Println(utf8.RuneCountInString(s))

    for i, r := range s {
        fmt.Println(i, string(r))
    }
}

11 Как задать набор связанных констант?

В блоке const идентификатор iota равен номеру строки и начинается с нуля. Каждая следующая строка без выражения повторяет предыдущее и увеличивает iota. Так получают перечисление без ручной нумерации.

Если ноль не должен быть «настоящим» состоянием, первой константой часто ставят имя вроде Unknown. Тогда забытая нулевая переменная не притворяется рабочим режимом.

Пример

Go

Status через iota

package main

import "fmt"

type Status int

const (
    Unknown Status = iota
    Ready
    Running
    Done
)

func main() {
    fmt.Println(Unknown, Ready, Running, Done)
}

12 Что такое указатель и когда он нужен?

Указатель хранит адрес значения. *T — тип «указатель на T», &x берёт адрес, *p читает или меняет то, что по адресу. Функция с аргументом *int меняет переменную вызывающего. Арифметики указателей в языке нет.

Указатель нужен, когда значение должно измениться у вызывающего, когда nil означает «значения нет», или когда структура большая и её незачем копировать на каждый вызов. Маленькие структуры спокойно передают по значению.

Пример

Go

inc меняет x снаружи

package main

import "fmt"

func inc(n *int) {
    *n++
}

func main() {
    x := 1
    inc(&x)
    fmt.Println(x)
}

13 Чем make отличается от new?

new(T) выделяет память под T, обнуляет её и возвращает *T. make нужен только для среза, карты и канала: он не просто выделяет память, а создаёт готовую к работе структуру. Срез после make можно индексировать, в карту — писать, в канал — отправлять.

Пример

Go

make для трёх ссылочных структур и new для int

package main

import "fmt"

func main() {
    nums := make([]int, 2, 4)
    fmt.Println(len(nums), cap(nums), nums)

    ages := make(map[string]int)
    ages["Аня"] = 30

    ch := make(chan string, 1)
    ch <- "ok"
    fmt.Println(<-ch)

    p := new(int)
    fmt.Println(*p)
    *p = 5
    fmt.Println(*p)
}

Функции

14 Как функция возвращает несколько значений?

В списке результатов пишут несколько типов, чаще всего значение и error. Вызывающий принимает их парой: n, err := parse(raw). Ненужный результат отбрасывают подчёркиванием: _, err := parse(raw).

Имена у результатов необязательны. Именованные результаты (func f() (n int, err error)) удобны в коротких функциях, но в длинных они прячут, что именно вернулось. Явный return n, err читается проще.

Пример

Go

число или ошибка разбора

package main

import (
    "fmt"
    "strconv"
)

func parse(raw string) (int, error) {
    return strconv.Atoi(raw)
}

func main() {
    n, err := parse("42")
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(n)

    if _, err := parse("x"); err != nil {
        fmt.Println("второе значение — ошибка:", err)
    }
}

15 Как в Go принято обрабатывать ошибки?

Ошибка — это значение интерфейса error, а не исключение. Функция возвращает её последним результатом, вызывающий сразу проверяет if err != nil. Контекст добавляют обёрткой fmt.Errorf("запись %d: %w", id, err), а потом узнают исходную ошибку через errors.Is.

%w сохраняет цепочку, чтобы errors.Is и errors.As могли её развернуть. %v превращает ошибку в текст и цепочку рвёт.

Сентинел вроде errNotFound объявляют через errors.New и сравнивают по идентичности, а не по тексту сообщения.

Пример

Go

обёртка и errors.Is

package main

import (
    "errors"
    "fmt"
)

var errNotFound = errors.New("не найдено")

func load(id int) error {
    if id == 0 {
        return fmt.Errorf("запись %d: %w", id, errNotFound)
    }
    return nil
}

func main() {
    err := load(0)
    fmt.Println(err)
    fmt.Println(errors.Is(err, errNotFound))
}

16 Что делает defer и в каком порядке он выполняется?

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

Поэтому defer f.Close() и defer mu.Unlock() ставят сразу после успешного захвата ресурса. В примере i уже скопирован в аргумент Println и равен нулю, хотя к концу функции он стал единицей.

Пример

Go

порядок LIFO и ранние аргументы

package main

import "fmt"

func main() {
    defer fmt.Println("первый defer, выйдет последним")
    defer fmt.Println("второй defer")

    i := 0
    defer fmt.Println("значение i на момент defer:", i)
    i++
    fmt.Println("тело функции, i =", i)
}

17 Чем panic отличается от error?

error — ожидаемая неудача: файл не найден, сеть оборвалась, строка не разобралась. panic — сломан инвариант программы. Её ловят только в defer через recover, и почти всегда на границе процесса или запроса, а не в обычной библиотечной функции.

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

Пример

Go

recover живёт внутри defer

package main

import "fmt"

func safe() {
    defer func() {
        if v := recover(); v != nil {
            fmt.Println("поймали:", v)
        }
    }()
    panic("учебный сбой")
}

func main() {
    safe()
    fmt.Println("программа продолжилась")
}

18 Что захватывает замыкание?

Замыкание — это функция, которая видит переменные окружающего блока. Она держит сами переменные, а не копию на момент создания. С Go 1.22 у каждой итерации for собственная переменная цикла, поэтому замыкание внутри range видит значение этой итерации.

До Go 1.22 переменная цикла была одна на весь цикл. Замыкания, собранные в цикле, потом печатали последнее значение три раза. Обходной путь тех версий — v := v в теле итерации.

На Go 1.22 и новее пример ниже печатает a, b и c.

Пример

Go

по функции на каждое значение

package main

import "fmt"

func main() {
    values := []string{"a", "b", "c"}
    funcs := make([]func(), 0, len(values))
    for _, v := range values {
        funcs = append(funcs, func() { fmt.Println(v) })
    }
    for _, fn := range funcs {
        fn()
    }
}

19 Как принять произвольное число аргументов?

Последний параметр вида nums ...int собирает аргументы в срез. Внутри функции это обычный []int, возможно пустой. Готовый срез передают наружу с тем же многоточием: sum(nums...).

Пример

Go

sum от списка и от среза

package main

import "fmt"

func sum(nums ...int) int {
    total := 0
    for _, n := range nums {
        total += n
    }
    return total
}

func main() {
    fmt.Println(sum(1, 2, 3))
    nums := []int{4, 5}
    fmt.Println(sum(nums...))
}

Коллекции

20 Чем slice отличается от array?

Массив [3]int — это значение фиксированной длины. Два массива разной длины — разные типы, присваивание копирует все элементы. Slice []int — это заголовок над массивом: указатель на данные, длина и ёмкость. Заголовок копируется дёшево, сами элементы остаются общими.

В примере sl := arr[:] смотрит на тот же массив, поэтому sl[0] = 9 меняет arr. append, которому не хватает ёмкости, выделяет новый массив. После этого запись в grown массив arr уже не трогает.

Пример

Go

общий массив и разрыв после append

package main

import "fmt"

func main() {
    arr := [3]int{1, 2, 3}
    sl := arr[:]
    sl[0] = 9
    fmt.Println("массив", arr)
    fmt.Println("срез", sl)

    grown := append(sl, 4)
    grown[0] = 7
    fmt.Println("после append массив", arr)
    fmt.Println("новый срез", grown)
}

21 Что лежит внутри slice?

Три поля: указатель на первый элемент, len и cap. len — сколько элементов видно при индексе и в range. cap — сколько элементов помещается в текущем массиве, начиная с этого указателя. len никогда не больше cap.

Передача slice в функцию копирует заголовок, не элементы. Функция может заменить элементы по индексу, и это увидит вызывающий. Если функция делает append и не возвращает новый заголовок, вызывающий может остаться со старым len.

Выражение s[low:high] строит новый заголовок на том же массиве. Ёмкость такого среза — от low до конца исходной ёмкости.

Пример

Go

len и cap после среза

package main

import "fmt"

func main() {
    nums := make([]int, 3, 5)
    nums[0], nums[1], nums[2] = 1, 2, 3
    window := nums[1:3]
    fmt.Println("nums", len(nums), cap(nums), nums)
    fmt.Println("window", len(window), cap(window), window)
}

22 Когда append перевыделяет массив?

append дописывает элементы в хвост. Если len + сколько_добавили не больше cap, элементы пишутся в тот же массив. Если ёмкости не хватает, рантайм выделяет новый массив, копирует старые элементы и возвращает заголовок уже на него. Старый slice при этом не обновляется сам — результат нужно сохранить: s = append(s, x).

В примере у a ёмкость ровно 2, поэтому третий элемент не влезает. b получает новый массив. Запись b[0] = 9 не меняет a, и сравнение адресов первых элементов даёт false.

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

Пример

Go

старый срез не видит новый массив

package main

import "fmt"

func main() {
    a := make([]int, 2, 2)
    a[0], a[1] = 1, 2

    b := append(a, 3)
    b[0] = 9

    fmt.Println("a", a)
    fmt.Println("b", b)
    fmt.Println("делят массив:", &a[0] == &b[0])
}

23 Как устроена map и что будет при чтении отсутствующего ключа?

map[K]V — это неупорядоченный набор пар. Чтение отсутствующего ключа возвращает нулевое значение типа V и не паникует. Чтобы отличить «лежит ноль» от «ключа нет», берут второе значение: n, ok := age[name].

В nil-карту можно читать — получишь нулевое значение. Запись в неё паникует. Перед записью нужна карта из make или литерала map[K]V{}.

delete(m, k) по отсутствующему ключу ничего не делает. Порядок обхода range специально не зафиксирован и меняется от запуска к запуску.

Пример

Go

запись, comma-ok и чтение nil-карты

package main

import "fmt"

func main() {
    age := map[string]int{"Аня": 30}
    age["Боря"] = 25

    if n, ok := age["Нет"]; !ok {
        fmt.Println("ключа нет, нулевое значение", n)
    }
    fmt.Println("len", len(age))
    delete(age, "Аня")

    var empty map[string]int
    fmt.Println("чтение nil-карты", empty["x"])
}

24 Что именно отдаёт range?

По slice range даёт индекс и копию элемента. По строке — байтовый индекс и руну. По карте — ключ и копию значения, в случайном порядке. По каналу — значения, пока канал не закроют.

Раз переменная цикла — копия, n *= 10 не меняет slice. Чтобы изменить элемент, пиши по индексу: nums[i] *= 10.

То же с картой: значение из range — копия. Обновление поля этой копии не попадает обратно в карту, нужна запись m[k] = v.

Пример

Go

копия элемента и запись по индексу

package main

import "fmt"

func main() {
    nums := []int{10, 20, 30}
    for i, n := range nums {
        fmt.Println(i, n)
    }

    for _, n := range nums {
        n *= 10
    }
    fmt.Println("после правки копии", nums)

    for i := range nums {
        nums[i] *= 10
    }
    fmt.Println("после записи по индексу", nums)
}

Свои типы

25 Как описать свою структуру?

struct собирает поля в один тип. Литерал User{ID: 1, Name: "Аня"} задаёт поля по имени. Тег в обратных кавычках — это метаданные для других пакетов. encoding/json читает тег json и берёт только экспортированные поля.

Нулевая структура заполнена нулевыми значениями полей. Сравнивать структуры через == можно, если все поля сравнимы: нет срезов, карт и функций.

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

Пример

Go

структура с json-тегами

package main

import "fmt"

type User struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
    Bio  string `json:"bio,omitempty"`
}

func main() {
    u := User{ID: 1, Name: "Аня"}
    fmt.Println(u.ID, u.Name, u.Bio)
}

26 Чем метод отличается от функции?

У метода есть получатель — значение, на котором его вызывают. Получатель по значению работает с копией, получатель-указатель меняет оригинал. Если переменная адресуема, Go сам берёт адрес: у c := Counter{} вызов c.Inc() означает (&c).Inc().

Набор методов у T и *T разный. Методы с получателем-указателем входят в набор *T, но не в набор T. Поэтому интерфейс, который требует Inc(), выполнит *Counter, а не Counter, если Inc объявлен на указателе.

Получатель — обычное имя, чаще одна-две буквы от типа: c Counter, u User. this и self в Go не используют.

Пример

Go

Value читает копию, Inc меняет оригинал

package main

import "fmt"

type Counter struct {
    N int
}

func (c Counter) Value() int { return c.N }

func (c *Counter) Inc() { c.N++ }

func main() {
    c := Counter{N: 1}
    c.Inc()
    fmt.Println(c.Value())
}

27 Что даёт встраивание структуры?

Поле без имени встраивает другой тип и поднимает его поля и методы наружу. Admin можно звать как a.Greet(), хотя метод объявлен на User. Это композиция: внешний тип не становится подтипом внутреннего, и метод User не вызывает «переопределённый» метод Admin.

Свой метод с тем же именем на внешней структуре затеняет поднятый. Внутренний по-прежнему доступен как a.User.Greet().

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

Пример

Go

Greet поднялся из User

package main

import "fmt"

type User struct {
    Name string
}

func (u User) Greet() string {
    return "привет, " + u.Name
}

type Admin struct {
    User
    Level int
}

func main() {
    a := Admin{User: User{Name: "Аня"}, Level: 2}
    fmt.Println(a.Greet(), a.Level)
}

28 Как тип начинает выполнять интерфейс?

Интерфейс — это набор методов. Тип выполняет его сам, без объявления implements: достаточно иметь нужные методы. Чем меньше интерфейс, тем проще его выполнить. io.Reader — один метод Read.

Значение интерфейса хранит два поля: динамический тип и динамическое значение. Пустой интерфейс any принимает что угодно, потому что у него ноль методов.

Интерфейс описывают там, где его используют, а не рядом с конкретной структурой. Функция say(s Speaker) не знает про Dog и не должна знать.

Пример

Go

Dog выполняет Speaker без явной пометки

package main

import "fmt"

type Speaker interface {
    Speak() string
}

type Dog struct{}

func (Dog) Speak() string { return "гав" }

func say(s Speaker) {
    fmt.Println(s.Speak())
}

func main() {
    say(Dog{})
}

29 Как достать конкретный тип из интерфейса?

Утверждение типа v, ok := x.(int) проверяет один тип и не паникует, если второй результат принят. type switch перебирает несколько вариантов. В каждой ветке у переменной уже конкретный тип.

Утверждение без ok (v := x.(int)) паникует, если тип другой. В обычном коде бери форму с ok или type switch.

any — это псевдоним interface{} с Go 1.18. Он ничего не обещает, кроме того, что значение можно положить в переменную.

Пример

Go

ветки int, string и всё остальное

package main

import "fmt"

func describe(v any) {
    switch x := v.(type) {
    case int:
        fmt.Println("целое", x)
    case string:
        fmt.Println("строка", x)
    default:
        fmt.Printf("другое %T\n", x)
    }
}

func main() {
    describe(7)
    describe("go")
    describe(1.5)
}

30 Почему интерфейс с nil внутри не равен nil?

Интерфейс равен nil, только когда в нём нет ни типа, ни значения. Если положить туда типизированный nil, например (*Cat)(nil), тип уже есть. Сравнение s == nil даёт false, хотя указатель внутри нулевой.

Отсюда классическая ошибка: функция возвращает error как return (*MyError)(nil) или возвращает конкретный указатель из функции с результатом error. Вызывающий видит ненулевой error.

Возвращай буквальный nil, когда ошибки нет: return nil, а не типизированную пустую переменную.

Пример

Go

пустой интерфейс и интерфейс с nil-указателем

package main

import "fmt"

type Speaker interface {
    Speak() string
}

type Cat struct{}

func (c *Cat) Speak() string { return "мяу" }

func main() {
    var s Speaker
    fmt.Println("пустой:", s == nil)

    var c *Cat
    s = c
    fmt.Println("внутри nil-указатель:", s == nil)
    fmt.Printf("динамический тип: %T\n", s)
}

Конкурентность

31 Что такое горутина?

Горутина — это функция, которую планировщик Go выполняет одновременно с другими. Запуск пишется как go f(). Горутина дешевле потока операционной системы: рантайм сажает многие горутины на меньшее число потоков. Сама по себе она ничего не возвращает вызывающему.

main — тоже горутина. Когда она заканчивается, процесс завершается и остальные горутины обрываются. Чтобы дождаться работы, нужен канал, WaitGroup или другой явный сигнал.

Общая память без синхронизации даёт гонку. Её ищет go test -race.

Пример

Go

запуск и ожидание одной горутины

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        fmt.Println("привет из горутины")
    }()
    wg.Wait()
}

32 Как устроен канал?

Канал передаёт значения между горутинами. Небуферизованный make(chan int) отдаёт значение только когда на другом конце его уже ждут: отправка и приём встречаются. Буферизованный make(chan int, n) принимает до n значений без встречного приёма.

Закрывает канал отправитель, когда значений больше не будет. Приём из закрытого канала возвращает нулевое значение и ok == false. Цикл for v := range ch сам останавливается на закрытии. Отправка в закрытый канал и закрытие nil-канала паникуют. Чтение и запись в nil-канал блокируются навсегда — это иногда используют в select, чтобы ветка перестала быть готовой.

Пример

Go

отправитель закрывает канал, range его читает

package main

import "fmt"

func main() {
    jobs := make(chan int)
    go func() {
        defer close(jobs)
        for i := 1; i <= 3; i++ {
            jobs <- i
        }
    }()
    for n := range jobs {
        fmt.Println(n)
    }
}

33 Что делает select?

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

time.After создаёт канал, в который придёт время. В серверном цикле на каждой итерации так делать не стоит: таймеры копятся. Для повторяющегося таймаута бери time.NewTimer и сбрасывай его, либо context.

Пример

Go

первая готовая ветка

package main

import (
    "fmt"
    "time"
)

func main() {
    a := make(chan string, 1)
    b := make(chan string, 1)
    a <- "из a"

    select {
    case v := <-a:
        fmt.Println(v)
    case v := <-b:
        fmt.Println(v)
    case <-time.After(time.Second):
        fmt.Println("таймаут")
    }
}

34 Зачем context.Context?

context несёт отмену и дедлайн сквозь цепочку вызовов. Первым аргументом функции, которая может ждать, ставят ctx context.Context. Отмена родителя отменяет всех детей. defer cancel() вызывают сразу после создания, чтобы освободить таймер.

ctx.Err() после отмены — это context.Canceled или context.DeadlineExceeded. Значения в контекст кладут редко и только данные запроса вроде идентификатора трассировки, не бизнес-объекты и не соединение с базой.

Контекст не хранят в структуре надолго. Его передают аргументом.

Пример

Go

работа обрывается по таймауту

package main

import (
    "context"
    "fmt"
    "time"
)

func work(ctx context.Context) error {
    select {
    case <-time.After(time.Second):
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
    defer cancel()

    fmt.Println(work(ctx))
}

35 Как дождаться группы горутин?

sync.WaitGroup считает незавершённые задачи. Add(1) вызывают до go, в горутине ставят defer wg.Done(), в конце — wg.Wait(). Add внутри горутины опаздывает: Wait может проскочить раньше, чем счётчик увеличится.

Копировать WaitGroup нельзя: копия не знает про чужой счётчик. Передают указатель или держат группу в поле структуры, которую тоже не копируют после старта.

WaitGroup не передаёт результат и ошибку. Для этого рядом нужен канал или общая переменная под мьютексом.

Пример

Go

три задачи и один Wait

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    for i := 1; i <= 3; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            fmt.Println(i)
        }()
    }
    wg.Wait()
}

36 Когда нужен Mutex?

sync.Mutex нужен, когда несколько горутин меняют одни и те же данные. Lock берёт исключительный доступ, Unlock отпускает. Между ними — критическая секция. Без замка одновременный n++ — это гонка, и результат меньше числа итераций.

defer mu.Unlock() страхует от раннего return. Мьютекс нельзя копировать: замок должен быть один на данные. Обычно его кладут рядом с данными в структуру и передают эту структуру по указателю.

RWMutex пускает много читателей или одного писателя. Он окупается, когда чтений заметно больше записей. Для короткого счётчика хватает обычного Mutex.

Пример

Go

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

package main

import (
    "fmt"
    "sync"
)

func main() {
    var (
        mu sync.Mutex
        n  int
        wg sync.WaitGroup
    )
    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            mu.Lock()
            n++
            mu.Unlock()
        }()
    }
    wg.Wait()
    fmt.Println(n)
}

Практика

37 Как разложить код по пакетам?

Один каталог — один пакет. Имя пакета по соглашению совпадает с именем каталога и пишется коротким словом: user, а не userpkg. Каталог internal виден только родительскому дереву модуля, снаружи его импортировать нельзя.

Пакет собирают вокруг задачи, а не вокруг слоя «все модели» или «все утилиты». Маленький internal/web честнее, чем общий utils, в который стекается несвязанный код.

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

Пример

Go

экспортированная точка входа пакета

package greet

import "fmt"

func Hello(name string) {
    fmt.Println(hello(name))
}

func hello(name string) string {
    return "привет, " + name
}

38 Что такое дженерики и зачем ограничение типа?

Параметр типа пишется в квадратных скобках: func Min[T cmp.Ordered](a, b T) T. Один и тот же код работает для всех типов, которые подходят под ограничение. cmp.Ordered означает «можно сравнить через <»: числа и строки. any не даёт сравнивать, потому что под ним годится любой тип.

Ограничение — это интерфейс. В нём бывают методы и набор типов: interface{ ~int | ~string }. Тильда ~ включает и именованные типы с таким исходным типом, например type Status int.

Дженерик уместен, когда алгоритм один, а типы разные: контейнер, Min, хелпер для срезов. Если различается поведение, обычный интерфейс с методом яснее.

Пример

Go

один Min для чисел и строк

package main

import (
    "cmp"
    "fmt"
)

func Min[T cmp.Ordered](a, b T) T {
    if a < b {
        return a
    }
    return b
}

func main() {
    fmt.Println(Min(3, 1))
    fmt.Println(Min("b", "a"))
}

39 Как писать тест?

Тест лежит в файле *_test.go того же пакета. Функция называется TestXxx и принимает *testing.T. Падение — это t.Fatalf или t.Errorf. Запуск из каталога модуля: go test ./....

Таблица случаев в срезе структур держит вход и ожидание рядом. Имя подтеста через t.Run видно в выводе, когда упала одна строка таблицы.

Тест в пакете calc видит неэкспортированные имена. Тест в пакете calc_test проверяет пакет так, как его видит чужой код: только через экспортированное API.

Пример

Go

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

package calc

import "testing"

func Add(a, b int) int { return a + b }

func TestAdd(t *testing.T) {
    tests := []struct {
        a, b int
        want int
    }{
        {a: 1, b: 2, want: 3},
        {a: -1, b: 1, want: 0},
    }
    for _, tt := range tests {
        if got := Add(tt.a, tt.b); got != tt.want {
            t.Fatalf("Add(%d, %d) = %d, хотим %d", tt.a, tt.b, got, tt.want)
        }
    }
}

40 Как закодировать структуру в JSON?

json.Marshal читает экспортированные поля и теги json. json.Unmarshal пишет в указатель на значение. Тег omitempty пропускает нулевое поле. Имя в теге — это имя в JSON, имя поля в Go может быть другим.

Неэкспортированное поле в JSON не попадает и из JSON не заполняется. Если нужно отличить отсутствующее поле от нуля, берут указатель (*string) или тип вроде json.RawMessage и отдельную проверку.

Marshal и Unmarshal возвращают error. Для Marshal ошибки редки, но игнорировать её не стоит: канал Encoder к потоку уже про ошибку не промолчит.

Пример

Go

туда и обратно

package main

import (
    "encoding/json"
    "fmt"
)

type User struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
    Bio  string `json:"bio,omitempty"`
}

func main() {
    raw, err := json.Marshal(User{ID: 1, Name: "Аня"})
    if err != nil {
        panic(err)
    }
    fmt.Println(string(raw))

    var out User
    if err := json.Unmarshal(raw, &out); err != nil {
        panic(err)
    }
    fmt.Println(out.Name)
}