Руководство по языку Go
Материал рассчитан на Go 1.26.5 и команды Linux/macOS. Проверить установленную версию можно командой go version. go run собирает программу во временный исполняемый файл и сразу запускает его, поэтому удобен во время обучения. go build создаёт отдельный бинарный файл, который можно запускать повторно без новой компиляции.
Глава 1. Введение в язык Go
1. Что такое Go
Go — компилируемый язык со статической типизацией, автоматическим управлением памятью и небольшой системой базовых конструкций. Он особенно удобен для сетевых сервисов, командных утилит и инфраструктурных программ. В стандартную поставку входят компилятор, форматировщик, тестовый фреймворк и инструменты управления модулями.
Для macOS установщик доступен на официальном сайте Go; при использовании Homebrew можно выполнить brew install go. Для Linux официальный архив распаковывают в /usr/local, а каталог /usr/local/go/bin добавляют в PATH. Имя архива зависит от архитектуры компьютера:
uname -m
sudo tar -C /usr/local -xzf go1.26.5.linux-amd64.tar.gz
export PATH="$PATH:/usr/local/go/bin"
go versionСтроку с export следует поместить в ~/.zshrc, ~/.bashrc или другой файл конфигурации используемой оболочки. Перед заменой существующей установки лучше удалить именно каталог /usr/local/go, предварительно убедившись, что это нужная установка, а не произвольный пользовательский каталог.
Go компилирует исходный код в машинный код, однако готовая программа всё равно включает runtime: он управляет сборщиком мусора, планировщиком горутин, стеками и другими механизмами времени выполнения. Статическая типизация позволяет находить многие ошибки до запуска, а обратная совместимость Go 1 помогает старому исходному коду продолжать собираться новыми версиями языка.
2. Первая программа
Исполняемая программа находится в пакете main и начинает работу с функции main. Импорт fmt даёт функции форматированного ввода-вывода, в том числе Println.
Создадим отдельный модуль и файл программы:
mkdir hello-go
cd hello-go
go mod init example.com/hellopackage main
import "fmt"
func main() {
fmt.Println("Hello, Go!")
}Запуск без сохранения бинарника:
go run .Сборка и отдельный запуск:
go build -o hello .
./helloОбе команды сначала компилируют пакет. Разница в том, что go run . хранит промежуточный бинарник во временном каталоге, а go build -o hello . оставляет файл hello в текущем каталоге.
Глава 2. Основы языка Go
1. Структура программы
Каждый .go-файл начинается с объявления пакета. Затем идут импорты и объявления констант, переменных, типов и функций. Порядок функций внутри пакета не определяет порядок их вызова. Имена с заглавной буквы экспортируются из пакета, а с маленькой доступны только внутри него.
Точка с запятой обычно не пишется: лексер вставляет её автоматически в предусмотренных грамматикой местах. Канонический стиль формирует gofmt, поэтому конструкции принято размещать на отдельных строках. Комментарии записываются как // ... или /* ... */.
package main
import "fmt"
const language = "Go"
func greeting(name string) string {
return "Привет, " + name + "!"
}
func main() {
// main — входная точка исполняемого пакета.
fmt.Println(greeting(language))
}Файл форматируют командой gofmt -w main.go, а весь модуль — go fmt ./....
2. Переменные
var объявляет переменную явно. Если справа есть значение, компилятор часто может вывести тип. Краткая форма := работает только внутри функций и должна объявлять хотя бы одну новую переменную в текущем блоке. Объявленные локальные переменные обязаны использоваться; неиспользуемый локальный идентификатор считается ошибкой компиляции.
package main
import "fmt"
var application = "catalog" // переменная уровня пакета
func main() {
var attempts int
var host = "localhost"
port := 8080
host, secure := "api.local", true // secure — новая переменная
attempts = 1
fmt.Println(application, host, port, secure, attempts)
}Go всегда передаёт и присваивает значения. Что именно копируется — число, массив, указатель или небольшой дескриптор среза — зависит от типа и рассматривается далее.
3. Типы данных
Основные числовые типы: знаковые int8, int16, int32, int64; беззнаковые uint8, uint16, uint32, uint64; платформенные int и uint; дробные float32, float64; комплексные complex64, complex128. byte — псевдоним uint8, а rune — псевдоним int32, используемый для Unicode-кодовой точки. Также есть bool и неизменяемые строки string, содержащие байты, обычно в UTF-8.
У каждой переменной есть нулевое значение: 0 для чисел, false для bool, "" для строки и nil для указателей, срезов, карт, каналов, функций и интерфейсов. Отдельного синтаксиса неинициализированной переменной нет — после объявления она уже содержит zero value.
Go не выполняет произвольные неявные преобразования между именованными числовыми типами. Преобразование записывается явно как T(value) и может потерять точность или старшие биты.
package main
import "fmt"
func main() {
var count int
var ratio float64
var enabled bool
var title string
meters := 12
average := float64(meters) / 5
letter := 'Я' // rune
fmt.Printf("zero values: %d %.1f %t %q\n", count, ratio, enabled, title)
fmt.Printf("average=%.2f rune=%c type=%T\n", average, letter, letter)
}4. Константы
Константа вычисляется на этапе компиляции и не может изменяться. Допустимы константные значения строк, булевых величин и чисел. У нетипизированной константы ещё нет конкретного типа Go: контекст выбирает тип при присваивании или передаче аргумента. Благодаря произвольной точности промежуточных константных вычислений выражение может быть точным дольше, чем значение обычной переменной, но итог обязан помещаться в выбранный тип.
iota начинается с нуля в каждом блоке const и увеличивается для каждой спецификации константы. Пропущенное выражение повторяет предыдущую спецификацию.
package main
import "fmt"
type Permission uint8
const (
Read Permission = 1 << iota
Write
Execute
)
const secondsPerHour = 60 * 60 // нетипизированная константа
func main() {
var timeout int64 = 2 * secondsPerHour
rights := Read | Write
fmt.Println(timeout, rights, Execute)
}Константу нельзя инициализировать результатом обычного вызова функции или значением переменной: такие выражения вычисляются во время выполнения.
5. Вывод на консоль. Форматирование
Пакет fmt поддерживает обычный и форматированный вывод. Println разделяет аргументы пробелами и добавляет перевод строки, Print не добавляет перевод строки автоматически, а Printf интерпретирует строку формата.
Часто используемые глаголы: %v — значение по умолчанию, %+v — расширенное представление структуры, %#v — Go-синтаксис значения, %T — тип, %t — bool, %d — десятичное целое, %b — двоичное, %x — шестнадцатеричное, %f — дробное, %s — строка, %q — значение в кавычках, %p — указатель. Ширина и точность задаются, например, как %8.2f.
package main
import "fmt"
type Product struct {
Name string
Price float64
}
func main() {
p := Product{Name: "Book", Price: 12.5}
fmt.Println("product:", p.Name)
fmt.Printf("price=%8.2f type=%T value=%+v\n", p.Price, p, p)
message := fmt.Sprintf("%s costs %.2f", p.Name, p.Price)
fmt.Println(message)
}Sprintf ничего не печатает, а возвращает сформированную строку. Для вывода в конкретный io.Writer применяют Fprint, Fprintln и Fprintf.
6. Арифметические операции
Go поддерживает +, -, *, / и остаток %. Деление двух целых возвращает целое с отбрасыванием дробной части в направлении к нулю; для дробного результата хотя бы один операнд после типизации должен быть вещественным. % определён для целых. x++ и x-- являются отдельными инструкциями, а не выражениями: результат x++ нельзя присвоить другой переменной.
package main
import "fmt"
func main() {
a, b := 17, 5
fmt.Println(a+b, a-b, a*b)
fmt.Println("integer:", a/b, "remainder:", a%b)
fmt.Println("floating:", float64(a)/float64(b))
a++
b--
fmt.Println(a, b)
}Переполнение переменных целочисленных типов подчиняется их фиксированной разрядности; переполнение константы, которую нельзя представить целевым типом, обнаруживается компилятором.
7. Условные выражения
Сравнения ==, !=, <, <=, > и >= возвращают bool. Порядковые сравнения допустимы для чисел и строк; равенство также определено для булевых значений, указателей, каналов, интерфейсов и составных типов, все элементы которых сравнимы. Срезы, карты и функции нельзя сравнивать друг с другом — только с nil.
Логические операторы — отрицание !, И &&, ИЛИ ||. && и || вычисляются слева направо и используют короткое замыкание: правая часть не вычисляется, если результат уже известен.
package main
import "fmt"
func main() {
age := 21
hasTicket := true
allowed := age >= 18 && hasTicket
fmt.Println("allowed:", allowed)
fmt.Println("adult without ticket:", age >= 18 && !hasTicket)
fmt.Println("special case:", age < 12 || age >= 65)
}8. Поразрядные операции
Поразрядные операторы работают с целыми: & оставляет биты, установленные в обоих операндах; | — хотя бы в одном; ^ для двух операндов выполняет XOR, а как унарный оператор инвертирует биты; &^ очищает в первом числе биты, заданные вторым. << и >> сдвигают биты влево и вправо.
package main
import "fmt"
func main() {
const read, write, execute uint8 = 1, 2, 4
permissions := read | write
fmt.Printf("permissions: %03b\n", permissions)
fmt.Println("can write:", permissions&write != 0)
permissions &^= write
permissions |= execute
fmt.Printf("after update: %03b\n", permissions)
fmt.Println("shift:", uint8(1)<<3)
}Для сдвигов важно учитывать знаковость, ширину целевого типа и возможность потерять значащие биты после преобразования или присваивания.
9. Операции присвоения. Приоритет операций
= присваивает значение уже объявленной переменной, а := объявляет одну или несколько локальных переменных. Составные операции +=, -=, *=, /=, %=, &=, |=, ^=, &^=, <<= и >>= совмещают вычисление с присваиванием.
Унарные операции и умножение выполняются раньше сложения, затем идут сдвиги, сравнения, побитовые операции, && и ||. Явные скобки полезнее запоминания всей таблицы: они документируют намерение и защищают выражение при последующем изменении.
package main
import "fmt"
func main() {
value := 10
value += 5
value *= 2
withoutParentheses := 2 + 3*4
withParentheses := (2 + 3) * 4
condition := value > 20 && value < 40
fmt.Println(value, withoutParentheses, withParentheses, condition)
}10. Массивы
Массив [N]T содержит ровно N элементов типа T; длина входит в тип, поэтому [3]int и [4]int — разные типы. Неуказанные элементы получают zero value. Запись [...]T{...} просит компилятор вывести длину из литерала.
Массив — значение: присваивание и передача в функцию копируют все элементы. Массивы одинакового типа можно сравнивать через ==, если сравним тип элемента. В прикладном Go чаще используют срезы, но массивы полезны для данных фиксированного размера.
package main
import "fmt"
func main() {
ports := [...]int{8080, 8081, 8082}
copyOfPorts := ports
copyOfPorts[0] = 9000
matrix := [2][3]int{
{1, 2, 3},
{4, 5, 6},
}
fmt.Println(len(ports), ports, copyOfPorts)
fmt.Println(matrix[1][2], ports == [3]int{8080, 8081, 8082})
}Индексация начинается с нуля; выход за границы вызывает ошибку компиляции для очевидного константного индекса или panic во время выполнения для динамического индекса.
11. Условные конструкции
if принимает только выражение типа bool; числа и строки не преобразуются в истину автоматически. Перед условием можно выполнить короткую инструкцию, и объявленные в ней переменные доступны во всех ветвях этой конструкции.
switch по умолчанию прекращает работу после первого подходящего case, поэтому break обычно не нужен. В одном case можно перечислить несколько значений. switch без выражения заменяет цепочку логических условий. fallthrough безусловно переходит в тело следующего case и используется редко.
package main
import "fmt"
func category(score int) string {
if normalized := score / 10; normalized >= 9 {
return "excellent"
} else if normalized >= 6 {
return "passed"
}
return "retry"
}
func main() {
day := "sat"
switch day {
case "sat", "sun":
fmt.Println("weekend")
default:
fmt.Println("weekday")
}
fmt.Println(category(84))
}12. Циклы
В Go одна циклическая конструкция — for. Она работает как классический цикл с инициализацией, условием и пост-инструкцией; как цикл только с условием; как бесконечный цикл; либо вместе с range для перебора коллекций. В range над строкой индекс обозначает байтовое смещение, а значение — rune, а не отдельный байт.
continue переходит к следующей итерации, break завершает цикл. Метка позволяет выйти из внешнего цикла или продолжить именно его, но метки нужны значительно реже обычного структурированного управления потоком.
package main
import "fmt"
func main() {
values := [...]int{2, -1, 4, 7, 8}
sum := 0
for index, value := range values {
if value < 0 {
continue
}
if value > 7 {
break
}
sum += value
fmt.Println(index, value)
}
for i := 0; i < 2; i++ {
fmt.Println("iteration", i)
}
fmt.Println("sum:", sum)
}Начиная с современных версий Go, range умеет перебирать и целое число (for i := range 5), однако классическая форма остаётся понятной и совместимой с более старым кодом.
Глава 3. Функции
1. Функции и их параметры
Функция объявляется словом func, получает типизированные параметры и при необходимости возвращает значения. Соседние параметры одного типа можно объединить: func add(a, b int). Аргументы передаются по значению: функция получает копию. Вариативный последний параметр values ...T внутри функции имеет вид []T; существующий срез раскрывается при вызове как values....
Параметры типа позволяют описать алгоритм один раз для семейства типов. После имени функции или типа они записываются в квадратных скобках. any допускает любой тип, comparable — только типы, значения которых можно сравнивать через == и !=. Type set ~int | ~int64 означает типы с базовым типом int или int64, включая пользовательские определённые типы. Для числовых алгоритмов ограничения обычно оформляют именованным интерфейсом.
package main
import "fmt"
type Integer interface {
~int | ~int64
}
type Pair[K comparable, V any] struct {
Key K
Value V
}
func sum[T Integer](values ...T) T {
var result T
for _, value := range values {
result += value
}
return result
}
func lookup[K comparable, V any](items map[K]V, key K) (V, bool) {
value, ok := items[key]
return value, ok
}
func main() {
fmt.Println(sum(2, 3, 5)) // T выведен как int
fmt.Println(sum[int64](10, 20))
item := Pair[string, int]{Key: "books", Value: 3}
value, ok := lookup(map[string]int{"books": 3}, item.Key)
fmt.Println(item, value, ok)
}Вывод типов обычно позволяет опустить [int] у generic-функции, но у составного generic-типа аргументы типа указывают явно. Ограничение S ~[]E или M ~map[K]V полезно, когда функция должна сохранять именованный тип среза или карты; для обычной обработки достаточно []E и map[K]V. Generics не нужны, если код работает с одним конкретным типом, если поведение лучше выражает небольшой интерфейс с методами или если абстракция лишь скрывает несколько ясных строк.
2. Возвращение результата из функции
Тип результата указывают после параметров. Go позволяет возвращать несколько значений — распространённая форма (value, error) отделяет полезный результат от ошибки. Возвращаемые значения могут иметь имена и тогда получают zero value при входе в функцию, но явный return value, err обычно легче читать в длинном коде.
package main
import "fmt"
func divide(a, b float64) (float64, bool) {
if b == 0 {
return 0, false
}
return a / b, true
}
func minMax(a, b int) (min int, max int) {
if a < b {
return a, b
}
return b, a
}
func main() {
result, ok := divide(9, 4)
fmt.Println(result, ok)
low, high := minMax(12, 5)
fmt.Println(low, high)
}return немедленно завершает функцию. Если один из нескольких результатов не нужен, его принимают пустым идентификатором _.
3. Тип функции. Функция как параметр и результат функции
Тип функции включает типы её параметров и результатов: например, func(int, int) int. Функцию можно присвоить переменной, передать как аргумент и вернуть как результат. Именованный тип функции делает сигнатуру короче и может служить документацией роли callback-функции.
package main
import "fmt"
type Operation func(int, int) int
func calculate(a, b int, operation Operation) int {
return operation(a, b)
}
func choose(multiply bool) Operation {
if multiply {
return func(a, b int) int { return a * b }
}
return func(a, b int) int { return a + b }
}
func main() {
operation := choose(true)
fmt.Println(calculate(6, 7, operation))
}Нулевое значение функции равно nil; вызов nil-функции приводит к panic, поэтому необязательный callback проверяют перед вызовом.
4. Анонимные функции
Функциональный литерал func(...) { ... } создаёт функцию без объявления отдельного имени. Его можно сохранить в переменной, сразу вызвать, передать как callback или вернуть из другой функции. Короткое локальное действие удобно оставлять рядом с местом использования, но сложную повторно используемую логику лучше дать именованной функции.
package main
import "fmt"
func transform(values []int, operation func(int) int) []int {
result := make([]int, len(values))
for i, value := range values {
result[i] = operation(value)
}
return result
}
func main() {
doubled := transform([]int{1, 2, 3}, func(value int) int {
return value * 2
})
fmt.Println(doubled)
fmt.Println(func(a, b int) int { return a + b }(4, 5))
}5. Замыкания
Функциональный литерал образует замыкание, когда обращается к переменным окружающей области. Такая функция сохраняет доступ к переменным даже после завершения внешней функции. Несколько замыканий могут разделять одно окружение, а каждый новый вызов фабрики создаёт независимое состояние.
package main
import "fmt"
func counter(start int) func() int {
value := start
return func() int {
value++
return value
}
}
func main() {
first := counter(10)
second := counter(100)
fmt.Println(first(), first(), second(), first())
}Захваченная переменная может пережить исходную область видимости; компилятор сам решает, где её разместить. Замыкание удобно для небольшого состояния и callback-функций, но не заменяет явно синхронизируемую структуру при конкурентном доступе.
6. Рекурсивные функции
Рекурсивная функция вызывает себя напрямую или через другую функцию. Ей необходим базовый случай, который завершает цепочку, и шаг, приближающий аргумент к этому случаю. Рекурсия естественна для деревьев и рекурсивных грамматик; простой линейный подсчёт часто яснее и дешевле записать циклом.
package main
import "fmt"
func factorial(n uint64) uint64 {
if n <= 1 {
return 1
}
return n * factorial(n-1)
}
func main() {
for n := uint64(0); n <= 6; n++ {
fmt.Println(n, factorial(n))
}
}Go увеличивает стек горутины по мере необходимости, но бесконечная или слишком глубокая рекурсия всё равно исчерпает ресурсы. Компилятор не обещает оптимизацию хвостовой рекурсии, поэтому для больших линейных последовательностей предпочтителен for.
Глава 4. Указатели
1. Что такое указатели
Указатель хранит адрес значения определённого типа. Тип *int означает «указатель на int», оператор & получает адрес переменной, а * разыменовывает указатель — читает или изменяет значение по этому адресу. Арифметики указателей в обычном Go нет.
Нулевое значение любого указателя — nil. Разыменование nil вызывает панику, поэтому указатель, который может отсутствовать, проверяют до обращения. Встроенная функция new(T) создаёт нулевое значение T и возвращает *T; на практике чаще достаточно взять адрес обычной переменной или литерала структуры. Где окажется значение — на стеке или в куче, — решает компилятор с помощью escape analysis, а не сам синтаксис new.
package main
import "fmt"
func main() {
x := 4
p := &x
fmt.Println(*p) // 4
*p = 25
fmt.Println(x) // 25
zero := new(int)
fmt.Println(*zero) // 0
var missing *int
if missing == nil {
fmt.Println("значение отсутствует")
}
}Указатель не владеет значением в смысле ручного управления памятью: освобождать память не нужно, этим занимается сборщик мусора. Указатели применяют, когда нужно изменить исходное значение, сохранить идентичность объекта или явно представить необязательное значение.
2. Указатели и функции
В Go все аргументы передаются по значению. При передаче int функция получает копию числа; при передаче *int она получает копию адреса, но через неё обращается к той же переменной. Поэтому указатель позволяет функции изменить состояние вызывающего кода.
package main
import "fmt"
func squareValue(x int) {
x *= x // изменяется только локальная копия
}
func square(x *int) {
if x != nil {
*x *= *x
}
}
func newCounter(start int) *int {
counter := start
return &counter // безопасно: время жизни определит компилятор
}
func main() {
n := 5
squareValue(n)
fmt.Println(n) // 5
square(&n)
fmt.Println(n) // 25
counter := newCounter(10)
*counter++
fmt.Println(*counter) // 11
}Возвращать адрес локальной переменной безопасно: компилятор продлевает её время жизни. Но не следует выбирать указатель только ради предполагаемой скорости — маленькие значения часто дешевле копировать, а указатель добавляет разделяемое изменяемое состояние и проверку на nil.
Глава 5. Производные типы
1. Псевдонимы
В Go есть две похожие, но разные конструкции. Объявление type Mile uint создаёт новый определённый тип Mile с базовым типом uint. Mile и uint различаются для присваивания и вызова функций; между ними обычно нужно явное преобразование. На локальном определённом типе можно объявлять методы.
Объявление type MileAlias = uint создаёт псевдоним типа: это другое написание того же uint, а не новый тип. Псевдонимы полезны при совместимой реорганизации API, но не дают отдельной типовой защиты и собственного набора методов.
package main
import "fmt"
type Mile uint // новый определённый тип
type MileAlias = uint // псевдоним uint
func (m Mile) Kilometers() float64 {
return float64(m) * 1.609344
}
func printUint(value uint) {
fmt.Println(value)
}
func main() {
distance := Mile(5)
printUint(uint(distance)) // требуется преобразование
fmt.Println(distance.Kilometers())
var alias MileAlias = 7
printUint(alias) // alias и uint идентичны
}Начиная с современных версий Go псевдонимы могут быть обобщёнными. Например, type Set[T comparable] = map[T]struct{} — псевдоним для семейства типов map. Это по-прежнему не объявление нового типа.
package main
import "fmt"
type Set[T comparable] = map[T]struct{}
func main() {
ids := Set[int]{1: {}, 2: {}}
fmt.Println(len(ids)) // 2
}2. Структуры
Структура объединяет именованные поля разных типов. Нулевое значение структуры состоит из нулевых значений её полей, поэтому удачно спроектированная структура часто готова к безопасному использованию без конструктора. Значение структуры копируется при присваивании и передаче в функцию, хотя поля-срезы, карты и указатели после такой копии всё равно могут ссылаться на общие данные.
package main
import "fmt"
type User struct {
Name string
Age int
Active bool
}
func birthday(user User) User {
user.Age++
return user
}
func main() {
var empty User
fmt.Printf("%+v\n", empty) // {Name: Age:0 Active:false}
tom := User{Name: "Tom", Age: 40, Active: true}
olderTom := birthday(tom)
fmt.Println(tom.Age, olderTom.Age) // 40 41
}Имена с заглавной буквы экспортируются из пакета; строчные имена доступны только внутри пакета. В составных литералах предпочтительны именованные поля: User{Name: "Tom"} не зависит от порядка полей. Анонимные структуры удобны для коротких локальных значений, но повторно используемым моделям лучше дать имя.
Структуры сравнимы через ==, только если сравнимы все их поля. Например, структура с полем []string не сравнима; для неё нужно сравнивать поля вручную или подходящей функцией.
3. Структуры как поля других структур
Именованное поле создаёт обычную вложенность: к нему обращаются как account.Owner.Name. Встраивание записывается только именем типа, без отдельного имени поля; его поля и методы могут продвигаться во внешний тип, поэтому допустимо сокращённое account.Name. Встраивание — композиция, а не наследование: внешний тип не становится подтипом встроенного.
package main
import "fmt"
type Person struct {
Name string
}
func (p Person) Greeting() string {
return "Привет, " + p.Name
}
type Account struct {
Login string
Owner Person // именованное вложенное поле
}
type Admin struct {
Person // встроенное поле
Level int
}
type Node struct {
Value int
Next *Node // указатель разрывает бесконечную рекурсию размера
}
func main() {
account := Account{Login: "tom", Owner: Person{Name: "Tom"}}
fmt.Println(account.Owner.Name)
admin := Admin{Person: Person{Name: "Alice"}, Level: 2}
fmt.Println(admin.Name, admin.Greeting()) // продвинутые поле и метод
list := &Node{Value: 1, Next: &Node{Value: 2}}
fmt.Println(list.Next.Value)
}Структура не может непосредственно содержать поле собственного типа: тогда её размер был бы бесконечным. Для рекурсивных списков и деревьев используют *Node, срез или другую структуру фиксированного размера. При конфликте продвинутых имён нужно указывать полный путь через встроенное поле.
4. Методы
Метод — функция с дополнительным параметром-получателем: func (p Person) Name() string. Получателем может быть локально определённый тип, не обязательно структура. Получатель-значение копируется, поэтому такой метод естественен для чтения состояния и небольших неизменяемых значений.
package main
import (
"fmt"
"strings"
)
type Library []string
func (library Library) String() string {
return strings.Join(library, ", ")
}
type Rectangle struct {
Width float64
Height float64
}
func (rectangle Rectangle) Area() float64 {
return rectangle.Width * rectangle.Height
}
func main() {
books := Library{"Go in Action", "The Go Programming Language"}
fmt.Println(books.String())
rectangle := Rectangle{Width: 3, Height: 4}
fmt.Println(rectangle.Area()) // 12
}Методы объявляют в том же пакете, что и тип-получатель. Для типа T набор методов содержит методы с получателем T; набор методов *T содержит методы с получателями T и *T. Это особенно важно при проверке реализации интерфейса.
5. Методы указателей
Получатель *T нужен, когда метод меняет объект, когда копирование T нежелательно или когда остальные методы типа уже используют указатель. Внутри метода Go автоматически разыменовывает указатель при обращении к полям: user.Age++ эквивалентно (*user).Age++.
package main
import "fmt"
type User struct {
Name string
Age int
}
func (user User) Label() string {
return fmt.Sprintf("%s (%d)", user.Name, user.Age)
}
func (user *User) Birthday() {
if user != nil {
user.Age++
}
}
type BirthdayPerson interface {
Birthday()
}
func celebrate(person BirthdayPerson) {
person.Birthday()
}
func main() {
tom := User{Name: "Tom", Age: 40}
tom.Birthday() // компилятор преобразует адресуемое tom в (&tom).Birthday()
fmt.Println(tom.Label())
celebrate(&tom) // *User реализует интерфейс
// celebrate(tom) не скомпилируется: в наборе методов User нет Birthday
fmt.Println(tom.Age) // 42
}Автоматическое взятие адреса работает при обычном вызове метода у адресуемого значения, но не меняет набор методов при присваивании интерфейсу. Для типа с sync.Mutex и других значений, которые нельзя безопасно копировать после первого использования, почти всегда используют pointer receiver.
6. Срезы
Срез []T — небольшой дескриптор, который описывает участок базового массива: ссылку на данные, длину len и ёмкость cap. Копирование среза копирует дескриптор, а не элементы, поэтому разные срезы могут видеть изменения одного массива. Нулевой срез nil имеет длину и ёмкость 0 и подходит для range и append.
Выражение values[low:high] берёт элементы от low включительно до high исключительно. Трёхиндексная форма values[low:high:max] дополнительно ограничивает ёмкость до max-low и помогает не дать последующему append перезаписать соседний участок исходного массива.
package main
import (
"fmt"
"slices"
)
func main() {
array := [5]int{1, 2, 3, 4, 5}
view := array[1:4]
view[0] = 20
fmt.Println(array) // [1 20 3 4 5]
fmt.Println(len(view), cap(view)) // 3 4
limited := array[1:4:4] // len=3, cap=3
limited = append(limited, 99) // новый базовый массив
fmt.Println(array, limited)
copyOfView := slices.Clone(view)
copyOfView[0] = 200
fmt.Println(view[0], copyOfView[0]) // 20 200
view = slices.Delete(view, 1, 2) // удалить элемент с индексом 1
fmt.Println(view)
}append может использовать старый массив, а может выделить новый; поэтому его результат всегда присваивают обратно: items = append(items, value). Для независимой копии применяют slices.Clone или заранее создают целевой срез и вызывают copy. make([]T, length, capacity) создаёт срез заданной длины; если нужны только добавления, обычно начинают с длины 0: make([]T, 0, expected).
Передача среза в функцию копирует только его дескриптор. Функция может менять существующие элементы, но если она добавляет элементы, новый заголовок среза нужно вернуть вызывающему коду.
7. Карты
Карта map[K]V хранит пары «ключ — значение». Тип ключа должен быть сравнимым: числа, строки, указатели, каналы, интерфейсы и массивы/структуры со сравнимыми элементами допустимы; срезы, карты и функции ключами быть не могут. Если ключ имеет интерфейсный тип, его конкретное динамическое значение тоже обязано быть сравнимым, иначе операция с картой вызовет панику.
Нулевая карта равна nil: из неё можно читать, по ней можно итерироваться и вызывать delete, но запись вызовет панику. Для записи карту создают литералом или make. Присваивание карты другой переменной не копирует все пары — обе переменные обращаются к одной структуре данных.
package main
import (
"fmt"
"slices"
)
func main() {
scores := map[string]int{"Tom": 10, "Alice": 15}
scores["Bob"] = 12
if score, ok := scores["Tom"]; ok {
fmt.Println(score)
}
delete(scores, "Bob")
keys := make([]string, 0, len(scores))
for name := range scores {
keys = append(keys, name)
}
slices.Sort(keys) // порядок range по map не определён
for _, name := range keys {
fmt.Println(name, scores[name])
}
clear(scores)
fmt.Println(len(scores)) // 0
}Выражение value, ok := scores[key] отличает отсутствующий ключ от ключа с нулевым значением. Порядок обхода карты спецификацией не задан. Обычная map не поддерживает конкурентную запись или запись одновременно с чтением без синхронизации; для разделяемого состояния используют sync.Mutex, владение картой одной горутиной или, в специальных сценариях, sync.Map.
Глава 6. Пакеты и модули
1. Пакеты и их импорт
Пакет объединяет Go-файлы одного каталога. В начале каждого файла стоит package name; исполняемая программа использует package main и функцию main. Импорт указывает путь пакета, а в коде обычно используется объявленное им имя. В модульном проекте путь внутреннего пакета складывается из пути модуля и подкаталога.
package main
import (
"fmt"
"math"
)
func main() {
fmt.Println(math.Pow(2, 3)) // 8
}Идентификатор экспортируется из пакета, если начинается с заглавной Unicode-буквы: Parse, User. Неиспользуемый импорт — ошибка компиляции. Псевдоним импорта применяют при конфликте или для ясности: jsoniter "github.com/json-iterator/go". Импорт . скрывает происхождение имён и почти всегда ухудшает читаемость. Импорт _ "path" запускает инициализацию пакета ради побочного эффекта; его используют только там, где такой контракт ожидаем, например при регистрации драйвера.
Функции init выполняются после инициализации переменных пакета и до main, в порядке, определяемом графом импортов. Для обычной настройки приложения лучше явный вызов из main: скрытая инициализация усложняет тестирование.
2. Модули
Модуль — версионируемая коллекция пакетов с корневым файлом go.mod. Он задаёт путь модуля, минимальную поддерживаемую версию Go и требования к другим модулям. Новый проект для Go 1.26.5 можно создать так:
mkdir hello
cd hello
go mod init example.com/hello
go run .
go test ./...Минимальный go.mod:
module example.com/hello
go 1.26.5Директивы require задают минимально необходимые версии. Go строит build list алгоритмом Minimal Version Selection (MVS): для каждого пути модуля выбирается наибольшая из минимально потребованных версий во всём графе, а не автоматически самая свежая версия. Итог можно увидеть через go list -m all, граф требований — через go mod graph.
go.sum хранит криптографические суммы загруженных модулей и их go.mod, чтобы проверять целостность. Это не lock-файл и не полный список выбранных версий: там могут оставаться суммы версий, которых уже нет в текущем build list. Выбор версий определяют go.mod, граф зависимостей и MVS.
go mod tidy # добавить нужные и убрать ненужные требования/суммы
go mod download # заранее загрузить модули
go mod verify # проверить содержимое кэша по go.sum
go list -m all # показать build list
go mod graph # показать граф требованийgo mod tidy следует запускать после изменения импортов и коммитить получившиеся go.mod и go.sum. Директивы replace и exclude — явные исключения из обычного разрешения версий; применять их нужно осознанно.
3. Разделение проекта на пакеты
Пакеты выделяют по связной ответственности, а не по одному файлу или функции. Несколько файлов одного каталога с одинаковым package компилируются вместе. Внешний код видит только экспортируемые имена. Например, модуль можно разделить так:
hello/
├── go.mod
├── main.go
└── messages/
├── english.go
└── russian.go// messages/english.go
package messages
func Hello() string {
return "Hello"
}// messages/russian.go
package messages
func HelloRussian() string {
return "Привет"
}// main.go
package main
import (
"fmt"
"example.com/hello/messages"
)
func main() {
fmt.Println(messages.Hello())
fmt.Println(messages.HelloRussian())
}go mod init example.com/hello
go fmt ./...
go run .
go test ./...Относительные импорты вроде ./messages в модульном режиме не используют. Каталог может содержать подпакеты, но это независимые пакеты с отдельными путями, а не «вложенное пространство имён». Каталог internal запрещает импорт извне родительского дерева и подходит для деталей приложения; cmd/<name> обычно содержит точки входа нескольких бинарников.
4. Загрузка внешних зависимостей
Зависимость появляется в проекте, когда код импортирует внешний пакет, а go.mod требует содержащий его модуль. Версию лучше задавать явно при осознанном добавлении:
go get github.com/google/uuid@v1.6.0
go mod tidy
go test ./...package main
import (
"fmt"
"github.com/google/uuid"
)
func main() {
fmt.Println(uuid.NewString())
}go get package@version меняет зависимости текущего модуля. Для обновления лучше указывать область и версию явно, например go get example.com/lib@v1.4.2, а затем запускать тесты. Доступные версии показывает go list -m -versions example.com/lib.
Команду устанавливают отдельно от зависимостей проекта:
go install golang.org/x/tools/cmd/stringer@latestФорма go install package@version собирает исполняемый пакет и не добавляет его в go.mod текущего проекта. Бинарник попадает в GOBIN, а если он не задан — обычно в $GOPATH/bin.
Загруженные модули находятся в общем кэше. Его фактический путь надо узнавать у Go, а не собирать вручную:
go env GOPATH
go env GOMODCACHE
go clean -modcache # удалить весь кэш, если действительно нужна полная перезагрузкаОбычно GOMODCACHE равен $GOPATH/pkg/mod. Код в нём не редактируют: изменения пропадут и нарушат проверку целостности. Для временной разработки локальной версии используют workspace или точечный replace.
5. Управление зависимостями с помощью папки vendor
vendor содержит копии пакетов из build list, нужных для сборки и тестов основного модуля. Это полезно для офлайн-сборок, аудита исходников и окружений, где зависимости должны поставляться вместе с проектом. Каталог создаётся из согласованных go.mod и go.sum:
go mod tidy
go mod vendor
go test -mod=vendor ./...
go build -mod=vendor ./...Флаг относится к команде Go и ставится до списка пакетов: правильно go run -mod=vendor ., а не go run . -mod=vendor. При достаточно новой директиве go и согласованном vendor/modules.txt команды сборки обычно автоматически выбирают vendor-режим; явный флаг делает намерение заметным в CI.
После изменения зависимостей каталог нужно пересоздать командой go mod vendor. Не следует вручную править vendor/modules.txt или считать vendor заменой файлам модулей: источником выбранных версий остаются go.mod и MVS, go.sum проверяет загружаемое содержимое, а vendor фиксирует доставляемую копию пакетов. Если команда сообщает о несогласованном vendoring, синхронизируют зависимости и снова выполняют go mod vendor.
6. Создание разделяемого модуля
Публичный модуль получает устойчивый путь, обычно совпадающий с URL репозитория. Экспортируемые пакеты составляют его API, поэтому несовместимые изменения требуют новой major-версии. Для v2 и выше major входит в путь модуля и импорт: example.com/messages/v2.
Для двух локальных модулей основной современный инструмент — workspace. Пусть рядом находятся библиотека messageslib и приложение helloapp:
projects/
├── messageslib/
│ ├── go.mod
│ └── messages.go
└── helloapp/
├── go.mod
└── main.go// messageslib/go.mod
module example.com/messages
go 1.26.5// messageslib/messages.go
package messages
func Hello(name string) string {
return "Hello, " + name
}// helloapp/go.mod
module example.com/helloapp
go 1.26.5
require example.com/messages v0.0.0// helloapp/main.go
package main
import (
"fmt"
"example.com/messages"
)
func main() {
fmt.Println(messages.Hello("Go"))
}Из каталога projects:
go work init ./messageslib ./helloapp
go work sync
go run ./helloapp
go test ./messageslib/... ./helloapp/...go.work связывает локальные модули без подмены их опубликованных версий в go.mod. В монорепозитории workspace иногда коммитят как часть общей сборки; для личной временной конфигурации его обычно не добавляют в репозиторий.
Локальная директива replace example.com/messages => ../messageslib остаётся допустимым точечным override, например когда workspace неприменим. Это настройка потребителя, а не публикация библиотеки: другой пользователь её не получит. После публикации приложение подключает реальный тег, например go get example.com/messages@v1.0.0, без локального replace.
Глава 7. Строки
1. Введение в строки и руны
Строка в Go — неизменяемая последовательность байтов, а не срез байтов. Она может содержать любые байты, в том числе некорректный UTF-8. Исходный код и обычные текстовые строки обычно используют UTF-8, но тип string не проверяет кодировку автоматически.
len(text) возвращает число байтов, а text[i] — байт типа uint8. Цикл range декодирует UTF-8: индекс обозначает начальную позицию руны в байтах, значение имеет тип rune. Некорректная последовательность при обходе даёт utf8.RuneError. rune — псевдоним int32, обычно представляющий кодовую точку Unicode; одна руна не обязательно равна видимому пользователю символу, потому что тот может состоять из нескольких кодовых точек.
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
text := "Go, мир 👋"
fmt.Println(len(text)) // байты
fmt.Println(utf8.RuneCountInString(text)) // декодированные руны
for byteIndex, r := range text {
fmt.Printf("%d: %q (%U)\n", byteIndex, r, r)
}
bytes := []byte(text)
runes := []rune(text)
runes[4] = 'М'
fmt.Println(string(bytes))
fmt.Println(string(runes))
fmt.Println(utf8.ValidString(text))
}Индексировать строку как text[4] безопасно только когда нужен именно байт. Для работы с кодовыми точками используют range, utf8.DecodeRuneInString или преобразование в []rune. Для пользовательских «символов» вроде семейных emoji и букв с комбинируемыми знаками нужна сегментация по grapheme clusters — подсчёта рун для этого недостаточно.
Строку нельзя изменить по индексу. Если нужны байтовые изменения, создают []byte; если изменения по кодовым точкам — []rune, а затем формируют новую строку. Raw-литерал в обратных кавычках сохраняет переводы строк и почти все символы буквально, а интерпретируемый литерал в двойных кавычках поддерживает escape-последовательности.
2. Пакет string. Операции со строками
В стандартной библиотеке пакет называется strings — во множественном числе. Он выполняет поиск, разбиение, объединение, замену, обрезку и эффективное построение строк. Поиск Index, LastIndex и связанные функции возвращают байтовые индексы, потому что строки адресуются байтами.
package main
import (
"fmt"
"strings"
)
func main() {
text := " Привет, Go! Go! "
clean := strings.TrimSpace(text)
fmt.Println(strings.HasPrefix(clean, "Привет"))
fmt.Println(strings.Contains(clean, "Go"))
fmt.Println(strings.Count(clean, "Go"))
fmt.Println(strings.Replace(clean, "Go", "Gopher", 1))
fmt.Println(strings.ReplaceAll(clean, "Go", "Gopher"))
left, right, found := strings.Cut("name=Tom", "=")
fmt.Println(left, right, found)
words := strings.Fields("Go\tпишет\nсерверы")
fmt.Println(strings.Join(words, " "))
fmt.Println(strings.EqualFold("Go", "go"))
}Trim, TrimLeft и TrimRight трактуют второй аргумент как набор рун, а не как цельную подстроку. Для удаления точного начала или конца используют TrimPrefix и TrimSuffix. Fields разбивает по последовательностям Unicode-пробелов; Split — по точному разделителю. ToUpper и ToLower применяют Unicode-преобразования, но не реализуют правила конкретной локали; для безрегистрового сравнения обычно подходит EqualFold.
Многократная конкатенация через += в цикле может создавать промежуточные строки. strings.Builder накапливает результат эффективнее:
package main
import (
"fmt"
"strings"
)
func main() {
var builder strings.Builder
for i, word := range []string{"Go", "is", "simple"} {
if i > 0 {
builder.WriteByte(' ')
}
builder.WriteString(word)
}
fmt.Println(builder.String()) // Go is simple
}Нулевое значение strings.Builder готово к работе. После начала использования builder нельзя копировать и затем независимо использовать копии. Для потокового чтения строки есть strings.Reader, а для заранее заданного набора замен — strings.Replacer.
Глава 8. Интерфейсы
1. Введение в интерфейсы
Интерфейс описывает поведение через набор сигнатур методов. Он не содержит реализацию и не требует от типа специального объявления вроде implements: тип соответствует интерфейсу неявно, если его набор методов включает все методы интерфейса. Благодаря этому функция может зависеть от нужного ей поведения, а не от конкретной структуры.
Интерфейсы обычно определяют рядом с кодом-потребителем и делают небольшими. Если функции достаточно одного метода, ей не нужен широкий интерфейс со всеми возможностями реализации. Компиляторную проверку соответствия удобно записывать присваиванием пустому идентификатору.
package main
import "fmt"
type Mover interface {
Move() string
}
type Car struct {
Model string
}
func (c Car) Move() string {
return c.Model + " едет"
}
var _ Mover = Car{}
func printMove(m Mover) {
fmt.Println(m.Move())
}
func main() {
printMove(Car{Model: "Volvo"})
}Значение интерфейса хранит динамический тип и динамическое значение. Нулевой интерфейс не содержит ни того, ни другого и равен nil. Но интерфейс с типизированным нулевым указателем уже не равен nil: динамический тип в нём присутствует. Это называется ловушкой nil-интерфейса.
package main
import "fmt"
type Runner interface {
Run() string
}
type Job struct{}
func (j *Job) Run() string {
if j == nil {
return "задача отсутствует"
}
return "задача выполняется"
}
func main() {
var job *Job
var runner Runner = job
fmt.Println(job == nil) // true
fmt.Println(runner == nil) // false
fmt.Println(runner.Run())
}Пустой интерфейс записывается как any — это псевдоним interface{}. Он способен хранить значение любого типа, однако лишает вызывающий код статически известного поведения. Поэтому any уместен для действительно произвольных данных, а в обычных API предпочтительны конкретные типы, параметры типов или узкие интерфейсы.
2. Реализация интерфейсов для указателей структур
Соответствие интерфейсу зависит от набора методов типа. В набор методов значения T входят методы с получателем T. В набор методов указателя *T входят методы с получателями и T, и *T. Поэтому метод с получателем-значением позволяет передать интерфейсу как T, так и *T, а метод с получателем-указателем — только *T.
Указательный получатель нужен, когда метод изменяет структуру, структура велика или остальные её методы уже используют указатель. Автоматическое взятие адреса при вызове file.Write(...) работает для адресуемой переменной, но не меняет правила соответствия интерфейсу.
package main
import "fmt"
type Writer interface {
Write(string)
}
type File struct {
Text string
}
func (f *File) Write(text string) {
f.Text = text
}
var _ Writer = (*File)(nil)
func replace(w Writer, text string) {
w.Write(text)
}
func main() {
file := File{Text: "старый текст"}
replace(&file, "новый текст")
fmt.Println(file.Text)
// replace(file, "ошибка") // File не реализует Writer: метод у *File.
}Не следует выбирать указатель только ради интерфейса: сначала определяют семантику типа и его методов, а затем проверяют нужное соответствие.
3. Множественная реализация интерфейсов
Один тип может одновременно соответствовать любому числу интерфейсов. Например, структура с методами Read и Write подходит как отдельным интерфейсам чтения и записи, так и интерфейсу, который встраивает оба. Встраивание интерфейсов объединяет наборы методов; это композиция требований, а не наследование реализации.
package main
import "fmt"
type Reader interface {
Read() string
}
type Writer interface {
Write(string)
}
type ReaderWriter interface {
Reader
Writer
}
type Buffer struct {
text string
}
func (b *Buffer) Read() string {
return b.text
}
func (b *Buffer) Write(text string) {
b.text = text
}
var (
_ Reader = (*Buffer)(nil)
_ Writer = (*Buffer)(nil)
_ ReaderWriter = (*Buffer)(nil)
)
func rewrite(rw ReaderWriter, text string) string {
rw.Write(text)
return rw.Read()
}
func main() {
buffer := &Buffer{}
fmt.Println(rewrite(buffer, "Hello, Go!"))
}Широкие составные интерфейсы полезны только там, где потребителю действительно нужны все методы. Если функция лишь читает, она должна принимать Reader, а не ReaderWriter: маленький контракт проще реализовать, подменить в тесте и развивать без лишней связанности.
4. Проверка типа интерфейса
Подтверждение типа извлекает из интерфейса значение ожидаемого динамического типа. Безопасная форма value, ok := input.(T) возвращает false, если тип не совпал. Однозначная форма value := input.(T) в таком случае вызывает panic, поэтому на внешних данных обычно применяют вариант с ok.
Когда вариантов несколько, используется type switch. Внутри каждой ветви переменная имеет тип соответствующего case. Ветка case nil отдельно обрабатывает нулевой интерфейс.
package main
import "fmt"
func describe(value any) string {
switch v := value.(type) {
case nil:
return "nil"
case int:
return fmt.Sprintf("целое: %d", v)
case string:
return fmt.Sprintf("строка: %q", v)
case fmt.Stringer:
return "Stringer: " + v.String()
default:
return fmt.Sprintf("неизвестный тип %T", v)
}
}
func main() {
value := any(42)
if number, ok := value.(int); ok {
fmt.Println(number * 2)
}
fmt.Println(describe("Go"))
fmt.Println(describe(nil))
}Type assertion проверяет именно динамический тип значения, а не пытается выполнить произвольное преобразование. Если поведение можно выразить методом, лучше принять интерфейс с этим методом, чем постоянно ветвиться по конкретным типам.
Глава 9. Параллельное программирование. Горутины
1. Горутины
Горутина — независимо планируемое выполнение функции в адресном пространстве процесса Go. Она запускается оператором go, имеет небольшой растущий стек, а планировщик Go распределяет множество горутин по системным потокам. Конкурентность означает, что задачи могут продвигаться независимо; параллелизм — что они физически выполняются одновременно на нескольких ядрах. Конкурентная программа не обязана быть параллельной.
Завершение main завершает процесс вместе со всеми незавершёнными горутинами. Поэтому time.Sleep и ожидание ввода через fmt.Scanln не являются синхронизацией: они лишь надеются, что работа успеет закончиться. Нужный момент завершения передают каналом либо отслеживают через sync.WaitGroup.
package main
import "fmt"
func sum(values []int, result chan<- int) {
total := 0
for _, value := range values {
total += value
}
result <- total
}
func main() {
result := make(chan int)
go sum([]int{1, 2, 3, 4}, result)
fmt.Println(<-result)
}Порядок выполнения горутин обычно недетерминирован. Код не должен зависеть от того, какая из них стартует или завершится первой, если этот порядок не задан явной синхронизацией.
2. Синхронизация горутин и WaitGroup
sync.WaitGroup хранит счётчик работ, завершения которых нужно дождаться. Add увеличивает его до запуска горутины, Done уменьшает, а Wait блокируется до нуля. Done следует ставить через defer в самом начале функции горутины: тогда ранний return не оставит счётчик навсегда положительным. После начала использования WaitGroup нельзя копировать.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for id := 1; id <= 3; id++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Printf("задача %d завершена\n", id)
}()
}
wg.Wait()
fmt.Println("все задачи завершены")
}В Go 1.26.5 доступен метод WaitGroup.Go, который сам добавляет работу в группу, запускает функцию и уменьшает счётчик после возврата. Он удобен для обычных задач, но функция, переданная Go, не должна паниковать. Классическая пара Add/Done остаётся полезной, когда запуск устроен сложнее.
WaitGroup сообщает только о завершении и не переносит результаты или ошибки. Для результатов применяют каналы, а для отмены группы операций — context.Context или специализированную координацию.
3. Каналы
Канал — типизированный механизм передачи значений и синхронизации горутин. Канал создаётся через make(chan T), отправка записывается как ch <- value, получение — как value := <-ch. Нулевое значение канала равно nil; отправка и получение из nil-канала блокируются навсегда, поэтому такой канал нельзя использовать до инициализации.
У небуферизированного канала отправитель и получатель встречаются на одной операции: отправка завершается только после готовности получателя. Буферизированный канал make(chan T, n) разрешает до n отправок без немедленного получения. Буфер сглаживает кратковременную разницу скоростей, но не заменяет ограничение параллелизма и не исправляет неверный протокол завершения.
package main
import "fmt"
func main() {
jobs := make(chan string, 2)
jobs <- "compile"
jobs <- "test"
fmt.Println(<-jobs)
fmt.Println(<-jobs)
fmt.Printf("len=%d cap=%d\n", len(jobs), cap(jobs))
}len(ch) показывает число уже помещённых в буфер значений, а cap(ch) — размер буфера. Эти величины редко подходят для принятия конкурентных решений: состояние может измениться сразу после проверки.
4. Закрытие канала
close(ch) означает, что новых значений больше не будет. Закрывает канал владелец отправки — обычно горутина-производитель. Получатель не должен закрывать канал, а при нескольких отправителях закрытие координирует отдельный владелец после завершения всех производителей. Повторное закрытие и отправка в закрытый канал вызывают panic.
После закрытия получатель сначала забирает накопленные значения, затем немедленно получает нулевое значение типа и ok == false. Цикл for value := range ch сам завершается, когда канал закрыт и опустошён.
package main
import "fmt"
func generate(out chan<- int) {
defer close(out)
for value := 1; value <= 3; value++ {
out <- value
}
}
func main() {
numbers := make(chan int)
go generate(numbers)
for number := range numbers {
fmt.Println(number)
}
}Закрывать канал необязательно, если получателю не требуется сигнал «значений больше не будет» и канал станет недоступен сборщику мусора. Закрытие — часть протокола, а не аналог освобождения памяти.
5. Двухнаправленные и однонаправленные каналы
Тип chan T допускает отправку и получение. В параметрах и возвращаемых значениях направление лучше сужать: chan<- T разрешает только отправку, <-chan T — только получение. Двунаправленный канал неявно преобразуется в однонаправленный, но обратно — нет. Такое ограничение документирует владение и предотвращает ошибочную операцию на этапе компиляции.
package main
import "fmt"
func numbers(max int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for number := 1; number <= max; number++ {
out <- number
}
}()
return out
}
func printAll(in <-chan int) {
for number := range in {
fmt.Println(number)
}
}
func main() {
printAll(numbers(3))
}Возврат <-chan T оставляет право отправки и закрытия у производителя. Получатель видит только предназначенные ему операции.
6. Синхронизация с помощью каналов
Получение из небуферизированного канала ждёт отправителя, поэтому канал может обозначать не только данные, но и событие. Для сигнала без полезной нагрузки используют chan struct{}: пустая структура не несёт данных. Отправка одного значения будит одного получателя, а закрытие канала служит широковещательным сигналом для всех ожидающих.
package main
import "fmt"
func prepare(done chan<- struct{}) {
fmt.Println("данные подготовлены")
close(done)
}
func main() {
done := make(chan struct{})
go prepare(done)
<-done
fmt.Println("можно продолжать")
}Для ожидания фиксированной группы задач обычно яснее WaitGroup, для передачи результата — канал значений, для отмены и дедлайна — context.Context. Выбор примитива должен отражать смысл события, а не только обеспечивать случайную блокировку.
7. Каналы и конструкция select
select ожидает, пока станет возможной одна из нескольких операций с каналами. Если готовы несколько ветвей, одна выбирается псевдослучайно; порядок case не задаёт приоритет. Ветка default делает проверку неблокирующей, но цикл с постоянным default способен бесцельно загружать процессор.
Для ограниченных по времени операций используют дедлайн или таймаут контекста. Горутина, которая может заблокироваться на отправке, тоже должна наблюдать отмену — иначе получатель уйдёт, а производитель утечёт.
package main
import (
"context"
"fmt"
"time"
)
func delayedValue(ctx context.Context) <-chan int {
out := make(chan int)
go func() {
defer close(out)
timer := time.NewTimer(50 * time.Millisecond)
defer timer.Stop()
select {
case <-timer.C:
select {
case out <- 42:
case <-ctx.Done():
}
case <-ctx.Done():
}
}()
return out
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
select {
case value, ok := <-delayedValue(ctx):
if ok {
fmt.Println(value)
return
}
fmt.Println("производитель завершён")
case <-ctx.Done():
fmt.Println("операция отменена:", ctx.Err())
}
}context.Context передают первым параметром по цепочке вызовов и не хранят в структурах. Производный контекст всегда освобождают вызовом cancel, даже если дедлайн уже истёк.
8. Передача потоков данных
Поток значений часто строят как конвейер: одна стадия создаёт данные, следующая преобразует, получатель читает через range. Каждая стадия закрывает только собственный выходной канал после окончания отправки. Если потребитель может прекратить чтение раньше, одного close недостаточно: отмена должна дойти до всех производителей, а каждая потенциально блокирующая отправка должна выбирать между выходом и ctx.Done().
package main
import (
"context"
"fmt"
)
func generate(ctx context.Context, values ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, value := range values {
select {
case out <- value:
case <-ctx.Done():
return
}
}
}()
return out
}
func square(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for value := range in {
select {
case out <- value * value:
case <-ctx.Done():
return
}
}
}()
return out
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
results := square(ctx, generate(ctx, 1, 2, 3, 4, 5))
for result := range results {
fmt.Println(result)
if result >= 9 {
cancel()
break
}
}
}Если стадия объединяет несколько входов, отдельная горутина закрывает общий выход только после завершения всех копирующих горутин. Иначе возможны отправка в закрытый канал либо вечное ожидание. Отмена особенно важна в серверном коде: r.Context() прекращает работу, когда клиент отменил запрос или истёк дедлайн.
9. Гонка данных и ее обнаружение
Гонка данных возникает, когда несколько горутин одновременно обращаются к одной области памяти, хотя бы одна операция является записью и между обращениями нет синхронизации. Результат становится зависимым от планировщика; проблема может не проявляться в обычном запуске и появляться лишь под нагрузкой.
В следующем примере обе горутины изменяют counter без синхронизации. WaitGroup ждёт завершения, но не защищает переменную и потому не устраняет гонку.
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
counter := 0
for range 2 {
wg.Go(func() {
for range 1_000 {
counter++
}
})
}
wg.Wait()
fmt.Println(counter)
}Детектор гонок динамически анализирует реально выполненные пути. Его запускают на Linux и macOS вместе с тестами; для небольшого воспроизводящего файла можно использовать и go run:
go test -race ./...
go run -race main.goОтсутствие предупреждения не доказывает отсутствие гонок: тесты могли не пройти опасный путь. Конкурентный код нужно проектировать с явным владением данными, а тесты — запускать многократно и под -race. Исправляют гонку передачей данных через каналы, блокировкой или атомарными операциями, а не задержками.
10. Мьютексы
sync.Mutex обеспечивает взаимное исключение: между Lock и Unlock защищённый ресурс использует только одна горутина. Мьютекс обычно хранится рядом с данными, которые защищает, и не копируется после первого использования. defer mu.Unlock() снижает риск забыть разблокировку при раннем возврате.
sync.RWMutex различает общую блокировку чтения RLock и эксклюзивную блокировку записи Lock. Он может помочь при большом числе длительных чтений, но сложнее и не всегда быстрее обычного Mutex; выбор проверяют профилированием или бенчмарком. Блокировку держат как можно меньше и не удерживают во время сетевого запроса, файлового I/O или вызова неизвестного кода.
package main
import (
"fmt"
"sync"
)
type Counter struct {
mu sync.RWMutex
value int
}
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
func (c *Counter) Value() int {
c.mu.RLock()
defer c.mu.RUnlock()
return c.value
}
func main() {
var counter Counter
var wg sync.WaitGroup
for range 100 {
wg.Go(counter.Increment)
}
wg.Wait()
fmt.Println(counter.Value())
}Мьютекс защищает инвариант, а не отдельную строку кода. Если согласованность требует прочитать, проверить и изменить несколько полей вместе, вся последовательность должна находиться в одной критической секции.
Глава 10. Обработка ошибок
1. Изоморфная обработка ошибок
Ожидаемые сбои в Go возвращают обычным значением последнего результата: (T, error). При успехе ошибка равна nil; при неуспехе результат обычно содержит нулевое значение, а error объясняет причину. Тип interface{}/any для ошибки не используют: он не задаёт контракт Error() string и заставляет вызывающий код угадывать содержимое.
Ошибку проверяют сразу после вызова. Код успешного пути остаётся слева, а ошибочная ветка завершается ранним return. Игнорировать ошибку допустимо лишь тогда, когда API и контекст доказывают невозможность или несущественность сбоя; в учебном и прикладном коде это должно быть явно обосновано.
package main
import (
"errors"
"fmt"
)
func factorial(number int) (int, error) {
if number < 0 {
return 0, errors.New("factorial: число не может быть отрицательным")
}
result := 1
for value := 2; value <= number; value++ {
result *= value
}
return result, nil
}
func main() {
result, err := factorial(5)
if err != nil {
fmt.Println("ошибка:", err)
return
}
fmt.Println(result)
}Возвращаемая ошибка — часть API функции. Сообщение должно добавлять контекст операции, но решение о логировании, повторе или преобразовании в HTTP-ответ принимает вызывающий слой.
2. Тип error
error — встроенный интерфейс с методом Error() string. Простую неизменяемую категорию создают через errors.New; это sentinel error, которую сравнивают с помощью errors.Is. Когда вызывающему коду нужны дополнительные поля, объявляют собственный тип ошибки и извлекают его через errors.As.
Оборачивая ошибку как fmt.Errorf("операция: %w", err), код сохраняет исходную причину в цепочке и добавляет контекст. %v только форматирует текст и разрывает машинно проверяемую связь. errors.Join объединяет несколько независимых ошибок, например основной сбой и ошибку освобождения ресурса.
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("user not found")
type ValidationError struct {
Field string
}
func (e *ValidationError) Error() string {
return "invalid field: " + e.Field
}
func loadUser(id int) (string, error) {
if id <= 0 {
return "", &ValidationError{Field: "id"}
}
if id == 42 {
return "", fmt.Errorf("load user %d: %w", id, ErrNotFound)
}
return "Tom", nil
}
func main() {
_, err := loadUser(42)
if errors.Is(err, ErrNotFound) {
fmt.Println("пользователь не найден")
}
_, err = loadUser(0)
var validationErr *ValidationError
if errors.As(err, &validationErr) {
fmt.Println("проверьте поле", validationErr.Field)
}
}Не следует одновременно логировать и возвращать одну и ту же ошибку на каждом уровне: это создаёт дубликаты. Нижний слой добавляет контекст и возвращает ошибку, а внешний владелец операции обычно логирует её один раз.
3. Оператор defer и отложенное выполнение функций
defer откладывает вызов до выхода из текущей функции — при обычном return и при раскрутке стека из-за panic. Несколько вызовов выполняются в обратном порядке. Аргументы отложенного вызова вычисляются в момент объявления defer; замыкание, напротив, читает захваченные переменные во время выполнения.
Главное применение — освобождение успешно полученного ресурса. Сначала проверяют ошибку открытия, и только затем регистрируют Close: иначе код может вызвать метод у невалидного значения. Ошибка закрытия иногда существенна, особенно после записи, поэтому её можно объединить с основной через errors.Join.
package main
import (
"errors"
"fmt"
"os"
)
func writeFile(path string, data []byte) (err error) {
file, err := os.Create(path)
if err != nil {
return fmt.Errorf("create %q: %w", path, err)
}
defer func() {
err = errors.Join(err, file.Close())
}()
if _, err := file.Write(data); err != nil {
return fmt.Errorf("write %q: %w", path, err)
}
return nil
}
func main() {
if err := writeFile("example.txt", []byte("Hello, Go!\n")); err != nil {
fmt.Println("ошибка:", err)
return
}
if err := os.Remove("example.txt"); err != nil {
fmt.Println("не удалось удалить пример:", err)
}
}Не стоит бездумно помещать defer внутрь длинного цикла: ресурсы освободятся только при выходе из всей функции. Тело одной итерации можно вынести в отдельную функцию, чтобы её defer выполнялся своевременно.
4. Оператор panic
panic(value) прекращает обычное выполнение текущей функции и начинает раскручивать стек, выполняя зарегистрированные defer. Если панику никто не восстановил, программа выводит диагностический стек и завершается. Это не обычный способ вернуть ошибку вызывающему коду.
Панику используют для нарушенного внутреннего инварианта или в Must...-обёртке, когда невозможность создать значение означает ошибку программиста или некорректную обязательную конфигурацию. Ошибка файла, сети, пользовательского ввода или базы данных ожидаема и должна возвращаться как error.
package main
import (
"fmt"
)
func newPercentage(value int) (int, error) {
if value < 0 || value > 100 {
return 0, fmt.Errorf("percentage %d is outside 0..100", value)
}
return value, nil
}
func mustPercentage(value int) int {
percentage, err := newPercentage(value)
if err != nil {
panic(err)
}
return percentage
}
func main() {
fmt.Println(mustPercentage(75))
}Публичная функция newPercentage оставляет вызывающему коду выбор реакции. mustPercentage сознательно превращает ошибку в панику и потому должна применяться только там, где значение задано разработчиком и невалидность действительно является нарушением предположений программы.
5. Восстановление после ошибки и функция recover
recover() останавливает раскрутку только при вызове внутри отложенной функции той же горутины, в которой произошла паника. Он возвращает переданное в panic значение; вне активной паники результат равен nil. Восстановиться из другой горутины нельзя: каждая запущенная горутина сама отвечает за свою границу паник.
recover уместен на контролируемой границе — например, сервер может не позволить ошибке одного обработчика завершить весь процесс. Граница должна записать диагностический стек или преобразовать панику в явную ошибку. Восстанавливать панику глубоко в бизнес-логике и молча продолжать работу опасно: инварианты уже могут быть нарушены.
package main
import "fmt"
func safeRun(run func()) (err error) {
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("worker panic: %v", recovered)
}
}()
run()
return nil
}
func main() {
err := safeRun(func() {
panic("нарушен внутренний инвариант")
})
if err != nil {
fmt.Println(err)
}
}После восстановления выполнение не возвращается к инструкции после panic: функция, в которой шла раскрутка, завершается, а управление продолжает её вызывающая сторона. Поэтому recover — аварийный предохранитель на границе, а не аналог повсеместного try/catch.
Примеры в этих главах рассчитаны на Go 1.26.5; команды приведены для Linux и macOS.
Глава 11. Потоки и файлы
1. Операции ввода-вывода. Reader и Writer
В Go источник данных обычно представлен интерфейсом io.Reader, а приёмник — io.Writer. Благодаря этому один и тот же код может работать с файлом, сетевым соединением, стандартным потоком или буфером в памяти.
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}Контракт Reader важнее конкретного типа. Read записывает данные в p[:n]; он вправе одновременно вернуть n > 0 и ошибку, включая io.EOF. Поэтому сначала обрабатывают прочитанные байты и лишь затем ошибку. io.EOF означает штатное окончание потока. Вызов также может вернуть 0, nil, поэтому собственная реализация не должна бесконечно делать это без причины.
Успешный Writer обязан принять весь срез. Если записано меньше len(p), должна быть возвращена ненулевая ошибка; многие функции стандартной библиотеки преобразуют такую ситуацию в io.ErrShortWrite.
package main
import (
"errors"
"fmt"
"io"
"strings"
)
func printChunks(r io.Reader) error {
buf := make([]byte, 4)
for {
n, err := r.Read(buf)
if n > 0 {
fmt.Printf("%q\n", buf[:n])
}
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return fmt.Errorf("read stream: %w", err)
}
}
}
func main() {
if err := printChunks(strings.NewReader("Hello, Go!")); err != nil {
panic(err)
}
}На практике ручной цикл нужен не всегда. io.Copy(dst, src) корректно связывает io.Writer и io.Reader, а io.ReadAll читает всё содержимое в память и поэтому подходит только для заведомо ограниченных данных.
2. Создание и открытие файлов
Файл представлен *os.File и реализует как io.Reader, так и io.Writer. os.Create создаёт или полностью очищает файл, а os.Open открывает его только для чтения. Более точный контроль даёт os.OpenFile:
os.O_RDONLY,os.O_WRONLY,os.O_RDWRзадают доступ;os.O_CREATEсоздаёт отсутствующий файл;os.O_TRUNCочищает существующий файл;os.O_APPENDдобавляет данные в конец;os.O_EXCLвместе сos.O_CREATEтребует, чтобы файла ещё не было.
Права указывают в восьмеричной форме: 0o600 оставляет чтение и запись владельцу, 0o644 разрешает остальным только чтение. На Linux и macOS итоговые права дополнительно ограничивает umask. Параметр прав используется только при создании файла.
package main
import (
"errors"
"fmt"
"os"
"path/filepath"
)
func createConfig(root string) (err error) {
path := filepath.Join(root, "config", "app.conf")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create config directory: %w", err)
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return fmt.Errorf("open %q: %w", path, err)
}
defer func() {
err = errors.Join(err, f.Close())
}()
if _, err := f.WriteString("port=8080\n"); err != nil {
return fmt.Errorf("write %q: %w", path, err)
}
return nil
}
func main() {
dir, err := os.MkdirTemp("", "go-files-")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
if err := createConfig(dir); err != nil {
panic(err)
}
}defer ставят сразу после успешного открытия. Ошибка Close особенно важна для записываемого файла: часть данных могла оставаться в буферах операционной системы. os.Exit не выполняет отложенные функции, поэтому рабочую логику удобнее возвращать из отдельной функции с ошибкой.
3. Чтение и запись файлов
Для небольшого файла удобны os.ReadFile и os.WriteFile. Первая функция возвращает весь файл как []byte, вторая создаёт или перезаписывает файл. Для больших или неограниченных данных используют потоковое чтение через Read, bufio.Reader или io.Copy.
package main
import (
"fmt"
"os"
"path/filepath"
)
func main() {
dir, err := os.MkdirTemp("", "go-read-write-")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "message.txt")
if err := os.WriteFile(path, []byte("Привет, Go!\n"), 0o600); err != nil {
panic(fmt.Errorf("write file: %w", err))
}
data, err := os.ReadFile(path)
if err != nil {
panic(fmt.Errorf("read file: %w", err))
}
fmt.Print(string(data))
}При повторной записи os.WriteFile не заменяет права уже существующего файла. Если нужно атомарно обновить важный файл конфигурации, обычно создают временный файл в той же директории, проверяют запись и закрытие, затем вызывают os.Rename.
Потоковый вариант не должен считать, что один Read возвращает весь файл:
func consume(r io.Reader) error {
buf := make([]byte, 32*1024)
for {
n, err := r.Read(buf)
if n > 0 {
if err := process(buf[:n]); err != nil {
return err
}
}
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return err
}
}
}Это фрагмент: process определяется приложением. Он показывает порядок обработки n и err.
4. Стандартные потоки ввода-вывода и io.Copy
Пакет os предоставляет три готовых файла:
os.Stdin— стандартный ввод;os.Stdout— обычный вывод;os.Stderr— сообщения об ошибках и диагностика.
Их можно передавать любой функции, принимающей io.Reader или io.Writer. io.Copy переносит данные до io.EOF, повторно используя внутренний буфер и специальные оптимизированные методы источника или приёмника, если они доступны.
package main
import (
"fmt"
"io"
"os"
"strings"
)
func run(out, errOut io.Writer) error {
src := strings.NewReader("данные из Reader\n")
if _, err := io.Copy(out, src); err != nil {
fmt.Fprintln(errOut, "copy failed:", err)
return fmt.Errorf("copy stream: %w", err)
}
return nil
}
func main() {
if err := run(os.Stdout, os.Stderr); err != nil {
os.Exit(1)
}
}Для ограничения объёма входа применяют io.LimitReader, а для одновременной записи в несколько приёмников — io.MultiWriter. В библиотечном коде лучше принимать io.Reader/io.Writer параметрами: так функция не привязывается к консоли и проще тестируется через strings.Reader и bytes.Buffer.
5. Вывод/запись в поток
Функции fmt.Fprint, fmt.Fprintln и fmt.Fprintf записывают форматированные значения в любой io.Writer. Fprintln добавляет перевод строки, а Fprintf применяет строку форматирования. Все функции возвращают число записанных байтов и ошибку — при работе с файлом или сетью её нельзя игнорировать.
package main
import (
"bytes"
"fmt"
"io"
)
type Product struct {
Name string
Price int
}
func writeProduct(dst io.Writer, p Product) error {
if _, err := fmt.Fprintf(dst, "%s: %d ₽\n", p.Name, p.Price); err != nil {
return fmt.Errorf("format product: %w", err)
}
return nil
}
func main() {
var buf bytes.Buffer
if err := writeProduct(&buf, Product{Name: "Keyboard", Price: 5000}); err != nil {
panic(err)
}
fmt.Print(buf.String())
}Для простой строки без форматирования дешевле и яснее io.WriteString либо метод WriteString, если он есть у конкретного типа.
6. Форматируемый ввод
fmt.Fscan, fmt.Fscanln и fmt.Fscanf читают значения из io.Reader и преобразуют их в типы переданных указателей. Fscan разделяет ввод пробельными символами, Fscanln завершает набор значений на новой строке, а Fscanf ожидает заданный формат. Результат — число успешно разобранных аргументов и ошибка.
package main
import (
"fmt"
"strings"
)
func main() {
in := strings.NewReader("Tom 41 82.5")
var name string
var age int
var weight float64
n, err := fmt.Fscan(in, &name, &age, &weight)
if err != nil {
panic(fmt.Errorf("scan after %d values: %w", n, err))
}
fmt.Printf("%s: age=%d, weight=%.1f\n", name, age, weight)
}В Fscan обязательно передают адреса переменных. Пробельное сканирование плохо подходит для строки с пробелами вроде полного имени или произвольного текста — такую строку удобнее сначала прочитать через bufio.Scanner или bufio.Reader, затем явно разобрать.
7. Чтение с консоли
fmt.Scan, fmt.Scanln и fmt.Scanf читают из os.Stdin; это короткие аналоги соответствующих Fscan-функций. Для одной строки целиком удобнее bufio.Scanner. После цикла необходимо проверить Scanner.Err, иначе реальная ошибка ввода будет неотличима от нормального конца потока.
package main
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
func main() {
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 1024), 1024*1024)
fmt.Print("Введите имя и возраст через запятую: ")
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
fmt.Fprintln(os.Stderr, "read input:", err)
}
return
}
parts := strings.SplitN(scanner.Text(), ",", 2)
if len(parts) != 2 {
fmt.Fprintln(os.Stderr, "ожидался формат: имя, возраст")
return
}
age, err := strconv.Atoi(strings.TrimSpace(parts[1]))
if err != nil {
fmt.Fprintln(os.Stderr, "invalid age:", err)
return
}
fmt.Printf("%s: %d\n", strings.TrimSpace(parts[0]), age)
}По умолчанию Scanner ограничивает размер токена примерно 64 КиБ. Для потенциально длинной строки лимит задают через Scanner.Buffer, как в примере, либо используют bufio.Reader и вводят собственное ограничение.
8. Буферизированный ввод-вывод
bufio.Reader уменьшает число обращений к нижележащему источнику, а bufio.Writer накапливает небольшие записи перед отправкой. Для записи критичен вызов Flush: без него хвост данных может остаться только в памяти. Проверять надо и ошибку записи, и ошибку Flush.
bufio.Scanner удобен для последовательности строк или токенов. После Scan доступен Text или Bytes; метод Split меняет правило разбиения, например на bufio.ScanWords.
package main
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"strings"
)
func main() {
var storage bytes.Buffer
w := bufio.NewWriter(&storage)
for _, row := range []string{"alpha", "beta"} {
if _, err := w.WriteString(row + "\n"); err != nil {
panic(err)
}
}
if err := w.Flush(); err != nil {
panic(err)
}
r := bufio.NewReader(strings.NewReader(storage.String()))
for {
line, err := r.ReadString('\n')
if len(line) > 0 {
fmt.Print(line)
}
if errors.Is(err, io.EOF) {
break
}
if err != nil {
panic(fmt.Errorf("read row: %w", err))
}
}
}Flush не закрывает сам файл или соединение. Владелец ресурса отдельно завершает буферизацию, а затем закрывает нижележащий ресурс и обрабатывает обе ошибки.
Глава 12. Сетевое программирование
1. Отправка запросов
Пакет net предоставляет низкоуровневую работу с TCP, UDP и Unix-сокетами. net.Dialer.DialContext устанавливает соединение с учётом отмены и дедлайна контекста; возвращённый net.Conn одновременно является io.Reader и io.Writer.
TCP передаёт непрерывный поток байтов, а не отдельные сообщения. Один Write на клиенте не обязан соответствовать одному Read на сервере. Прикладной протокол должен задавать границы сообщения: например, завершать каждое сообщение \n, передавать длину перед телом или использовать готовый протокол вроде HTTP.
package main
import (
"bufio"
"context"
"fmt"
"io"
"net"
"time"
)
func request(ctx context.Context, address, message string) (string, error) {
dialer := net.Dialer{Timeout: 3 * time.Second}
conn, err := dialer.DialContext(ctx, "tcp", address)
if err != nil {
return "", fmt.Errorf("dial %s: %w", address, err)
}
defer conn.Close()
if err := conn.SetDeadline(time.Now().Add(5 * time.Second)); err != nil {
return "", fmt.Errorf("set deadline: %w", err)
}
if _, err := io.WriteString(conn, message+"\n"); err != nil {
return "", fmt.Errorf("write request: %w", err)
}
reply, err := bufio.NewReader(conn).ReadString('\n')
if err != nil {
return "", fmt.Errorf("read reply: %w", err)
}
return reply, nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
defer cancel()
reply, err := request(ctx, "127.0.0.1:4545", "hello")
if err != nil {
panic(err)
}
fmt.Print(reply)
}Пример ожидает TCP-сервер на 127.0.0.1:4545. Контекст ограничивает установление соединения, а дедлайн на net.Conn — последующие чтение и запись.
2. Сервер. Обработка подключений
net.Listen возвращает net.Listener. Сервер вызывает Accept в цикле, а каждое принятое соединение обычно обрабатывает в отдельной горутине. Соединение должен закрывать его обработчик. Ошибка одного клиента не должна останавливать весь сервер.
package main
import (
"bufio"
"errors"
"fmt"
"io"
"net"
"strings"
"time"
)
func handle(conn net.Conn) (err error) {
defer func() { err = errors.Join(err, conn.Close()) }()
if err := conn.SetDeadline(time.Now().Add(30 * time.Second)); err != nil {
return fmt.Errorf("set connection deadline: %w", err)
}
line, err := bufio.NewReader(conn).ReadString('\n')
if err != nil {
return fmt.Errorf("read request: %w", err)
}
reply := strings.ToUpper(strings.TrimSpace(line)) + "\n"
if _, err := io.WriteString(conn, reply); err != nil {
return fmt.Errorf("write response: %w", err)
}
return nil
}
func serve(listener net.Listener) error {
for {
conn, err := listener.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return nil
}
return fmt.Errorf("accept connection: %w", err)
}
go func() {
if err := handle(conn); err != nil {
fmt.Println("connection error:", err)
}
}()
}
}
func main() {
listener, err := net.Listen("tcp", "127.0.0.1:4545")
if err != nil {
panic(err)
}
defer listener.Close()
fmt.Println("TCP server listens on 127.0.0.1:4545")
if err := serve(listener); err != nil {
panic(err)
}
}Сервер конкурентный, но учебный: в production ограничивают число одновременных соединений, размер сообщения и время обработки, ведут учёт активных горутин и при остановке сначала закрывают listener, затем ждут завершения обработчиков.
3. Взаимодействие клиента и сервера
Стороны должны одинаково понимать формат данных. В следующем примере кадр заканчивается переводом строки: сервер читает одну строку и отвечает одной строкой. net.Pipe создаёт пару соединений в памяти, поэтому контракт можно показать без занятого порта.
package main
import (
"bufio"
"errors"
"fmt"
"io"
"net"
"strings"
)
func translateServer(conn net.Conn) (err error) {
defer func() { err = errors.Join(err, conn.Close()) }()
dictionary := map[string]string{"red": "красный", "blue": "синий"}
scanner := bufio.NewScanner(conn)
if !scanner.Scan() {
return scanner.Err()
}
word := strings.TrimSpace(scanner.Text())
translation, ok := dictionary[word]
if !ok {
translation = "неизвестно"
}
if _, err := io.WriteString(conn, translation+"\n"); err != nil {
return fmt.Errorf("write translation: %w", err)
}
return nil
}
func main() {
client, server := net.Pipe()
defer client.Close()
errCh := make(chan error, 1)
go func() { errCh <- translateServer(server) }()
if _, err := io.WriteString(client, "red\n"); err != nil {
panic(err)
}
reply, err := bufio.NewReader(client).ReadString('\n')
if err != nil {
panic(err)
}
fmt.Print(reply)
if err := <-errCh; err != nil {
panic(err)
}
}Для двоичного протокола распространён length-prefix: сначала фиксированное целое с размером кадра, затем ровно столько байтов через io.ReadFull. Размер необходимо проверять до выделения памяти. Нельзя считать conn.Read(buf) эквивалентом «получить одно сообщение».
4. Установка таймаута
SetDeadline задаёт абсолютный момент для всех будущих операций чтения и записи; SetReadDeadline и SetWriteDeadline ограничивают направления отдельно. Дедлайн не является таймером бездействия автоматически: для скользящего idle timeout его обновляют перед каждой операцией.
После истечения срока операция возвращает ошибку. Её можно проверить через errors.Is(err, os.ErrDeadlineExceeded) или привести посредством errors.As к net.Error и вызвать Timeout().
package main
import (
"errors"
"fmt"
"net"
"os"
"time"
)
func main() {
client, server := net.Pipe()
defer client.Close()
defer server.Close()
if err := client.SetReadDeadline(time.Now().Add(20 * time.Millisecond)); err != nil {
panic(err)
}
buf := make([]byte, 1)
_, err := client.Read(buf)
if errors.Is(err, os.ErrDeadlineExceeded) {
fmt.Println("read timed out")
return
}
if err != nil {
panic(err)
}
}Таймаут net.Dialer относится к соединению, а контекст DialContext — ещё и к его отмене. После успешного подключения отмена исходного контекста сама по себе не прерывает обычный conn.Read; для этого нужны дедлайны, закрытие соединения или отдельная координация.
5. Отправка запросов по HTTP
HTTP работает поверх транспорта, но вручную формировать строки запроса через net.Conn не следует: пакет net/http корректно реализует синтаксис, заголовки, чанки, перенаправления и повторное использование соединений.
Функции http.Get, http.Head, http.Post и http.PostForm — короткие обёртки над http.DefaultClient. Они подходят для простого скрипта, но после успешного запроса всё равно требуют закрыть Response.Body. Для управляемых таймаутов, контекста и повторного использования настроек обычно создают собственный http.Client, как в следующей теме.
Сервер строится из http.Handler и http.Server. Начиная с современного ServeMux, шаблон может включать метод и путь, например "GET /health". В production задают таймауты, ограничивают заголовки и выполняют graceful shutdown по сигналу.
package main
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func run() error {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok\n"))
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20,
}
signalCtx, stop := signal.NotifyContext(
context.Background(), os.Interrupt, syscall.SIGTERM,
)
defer stop()
errCh := make(chan error, 1)
go func() { errCh <- srv.ListenAndServe() }()
select {
case err := <-errCh:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return fmt.Errorf("serve HTTP: %w", err)
case <-signalCtx.Done():
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown HTTP server: %w", err)
}
if err := <-errCh; !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("serve HTTP: %w", err)
}
return nil
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}Запуск на Linux или macOS:
go run .
curl --fail --show-error http://127.0.0.1:8080/healthShutdown прекращает принимать новые подключения и ждёт активные в пределах переданного контекста. WebSocket и другие hijacked-соединения приложение отслеживает и закрывает отдельно.
6. http.Client
http.Client безопасен для конкурентного использования и должен переиспользоваться: он хранит пул соединений через Transport. Для запроса создают контекст, вызывают http.NewRequestWithContext, проверяют HTTP-статус и всегда закрывают Response.Body. Ошибка Do означает транспортную проблему; ответы 404 и 500 сами по себе ошибкой Do не являются.
package main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"time"
)
func fetch(ctx context.Context, client *http.Client, url string) (body []byte, err error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Accept", "text/plain")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
defer func() { err = errors.Join(err, resp.Body.Close()) }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
return nil, fmt.Errorf("unexpected HTTP status: %s", resp.Status)
}
const maxBody = 1 << 20
body, err = io.ReadAll(io.LimitReader(resp.Body, maxBody+1))
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if len(body) > maxBody {
return nil, fmt.Errorf("response body exceeds %d bytes", maxBody)
}
return body, nil
}
func main() {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, "hello")
}))
defer server.Close()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
client := &http.Client{Timeout: 5 * time.Second}
body, err := fetch(ctx, client, server.URL)
if err != nil {
panic(err)
}
fmt.Println(string(body))
}Поле Client.Timeout ограничивает весь обмен, включая чтение тела. Контекст конкретного запроса позволяет задать более узкий срок или отмену. Для тонкой настройки соединений клонируют http.DefaultTransport, меняют нужные поля и сохраняют один экземпляр Client, а не создают новый на каждый запрос.
Глава 13. Базы данных
1. Работа с реляционными база данных
database/sql задаёт общий API, а конкретный драйвер регистрирует СУБД. Значение *sql.DB — не одно соединение, а конкурентно-безопасный пул. sql.Open обычно лишь проверяет конфигурацию; реальную доступность проверяют PingContext. Пул создают один раз при старте приложения, настраивают и закрывают при остановке.
Для запросов используют ExecContext, QueryContext и QueryRowContext. Значения всегда передают отдельными аргументами через плейсхолдеры драйвера, а не вставляют в SQL строковой конкатенацией. QueryRowContext сообщает об отсутствии строки через sql.ErrNoRows при Scan.
INSERT, UPDATE и DELETE, которым не нужно возвращать строки, выполняют через ExecContext; RowsAffected позволяет проверить реальный результат. Для набора строк используют QueryContext, обязательно закрывают Rows и после цикла проверяют Rows.Err.
package main
import (
"context"
"database/sql"
"errors"
"fmt"
)
type Product struct {
ID int64
Model string
Price int
}
func findProduct(ctx context.Context, db *sql.DB, id int64) (Product, error) {
var p Product
err := db.QueryRowContext(ctx,
`SELECT id, model, price FROM products WHERE id = $1`, id,
).Scan(&p.ID, &p.Model, &p.Price)
if errors.Is(err, sql.ErrNoRows) {
return Product{}, fmt.Errorf("product %d: %w", id, sql.ErrNoRows)
}
if err != nil {
return Product{}, fmt.Errorf("find product %d: %w", id, err)
}
return p, nil
}
func listProducts(ctx context.Context, db *sql.DB) (products []Product, err error) {
rows, err := db.QueryContext(ctx, `SELECT id, model, price FROM products ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("query products: %w", err)
}
defer func() { err = errors.Join(err, rows.Close()) }()
for rows.Next() {
var p Product
if err := rows.Scan(&p.ID, &p.Model, &p.Price); err != nil {
return nil, fmt.Errorf("scan product: %w", err)
}
products = append(products, p)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate products: %w", err)
}
return products, nil
}
func createOrder(ctx context.Context, db *sql.DB, productID int64) (err error) {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin order transaction: %w", err)
}
defer func() {
if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
err = errors.Join(err, fmt.Errorf("rollback order: %w", rollbackErr))
}
}()
result, err := tx.ExecContext(ctx,
`UPDATE products SET stock = stock - 1 WHERE id = $1 AND stock > 0`, productID,
)
if err != nil {
return fmt.Errorf("reserve product: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("count reserved products: %w", err)
}
if affected != 1 {
return fmt.Errorf("product %d is missing or out of stock", productID)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO orders(product_id) VALUES ($1)`, productID,
); err != nil {
return fmt.Errorf("insert order: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit order: %w", err)
}
return nil
}
func main() {}Это компилируемый фрагмент общего слоя database/sql; он не открывает БД. Плейсхолдеры $1 соответствуют PostgreSQL. MySQL и популярный SQLite-драйвер используют ?.
Транзакцией владеет use case, которому нужна атомарность нескольких операций. После BeginTx все операции транзакции выполняют через *sql.Tx, не через *sql.DB; Commit может завершиться ошибкой и её тоже возвращают.
2. MySQL
Актуальный драйвер для database/sql — github.com/go-sql-driver/mysql. Зависимость добавляют внутри Go-модуля:
go get github.com/go-sql-driver/mysqlСтроку подключения нельзя хранить в исходном коде или коммите. Пример читает DSN из MYSQL_DSN; его формат и параметры, включая TLS, задаёт драйвер. Для значений MySQL использует плейсхолдер ?.
Следующий пример требует внешнюю зависимость и доступный сервер MySQL:
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"time"
_ "github.com/go-sql-driver/mysql"
)
func run(ctx context.Context) (err error) {
dsn := os.Getenv("MYSQL_DSN")
if dsn == "" {
return errors.New("MYSQL_DSN is required")
}
db, err := sql.Open("mysql", dsn)
if err != nil {
return fmt.Errorf("open MySQL: %w", err)
}
defer func() { err = errors.Join(err, db.Close()) }()
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(30 * time.Minute)
if err := db.PingContext(ctx); err != nil {
return fmt.Errorf("ping MySQL: %w", err)
}
result, err := db.ExecContext(ctx,
`INSERT INTO products(model, company, price) VALUES (?, ?, ?)`,
"Pixel", "Google", 64000,
)
if err != nil {
return fmt.Errorf("insert product: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("read inserted id: %w", err)
}
fmt.Println("inserted id:", id)
return nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := run(ctx); err != nil {
panic(err)
}
}LastInsertId поддерживается MySQL, но не является переносимым способом между всеми СУБД. Ограничения пула подбирают под сервер и нагрузку, а не копируют механически.
3. PostgreSQL
Для современного PostgreSQL-драйвера можно использовать pgx напрямую или его адаптер database/sql. Здесь применяется github.com/jackc/pgx/v5/stdlib, который регистрирует драйвер pgx:
go get github.com/jackc/pgx/v5/stdlibPostgreSQL обозначает параметры как $1, $2 и так далее. Для получения сгенерированного идентификатора используют INSERT ... RETURNING, а не LastInsertId.
Следующий пример требует внешнюю зависимость и доступный PostgreSQL:
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
func run(ctx context.Context) (err error) {
dsn := os.Getenv("POSTGRES_DSN")
if dsn == "" {
return errors.New("POSTGRES_DSN is required")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
return fmt.Errorf("open PostgreSQL: %w", err)
}
defer func() { err = errors.Join(err, db.Close()) }()
if err := db.PingContext(ctx); err != nil {
return fmt.Errorf("ping PostgreSQL: %w", err)
}
var id int64
err = db.QueryRowContext(ctx,
`INSERT INTO products(model, company, price)
VALUES ($1, $2, $3)
RETURNING id`,
"Mate", "Huawei", 35000,
).Scan(&id)
if err != nil {
return fmt.Errorf("insert product: %w", err)
}
fmt.Println("inserted id:", id)
return nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := run(ctx); err != nil {
panic(err)
}
}В production TLS и проверку сертификата не отключают без осознанной причины. DSN поступает из конфигурации или секрет-хранилища; в логи его целиком не выводят.
4. SQLite
SQLite хранит базу в локальном файле и удобен для небольших приложений, тестов и однопроцессных инструментов. Драйвер github.com/mattn/go-sqlite3 работает через database/sql, но требует CGO и установленный C-компилятор. Это важно для минимальных контейнеров и кросс-компиляции.
go get github.com/mattn/go-sqlite3Следующий пример требует внешнюю зависимость с CGO_ENABLED=1. Путь и параметры подключения читаются из SQLITE_DSN:
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"time"
_ "github.com/mattn/go-sqlite3"
)
func run(ctx context.Context) (err error) {
dsn := os.Getenv("SQLITE_DSN")
if dsn == "" {
return errors.New("SQLITE_DSN is required")
}
db, err := sql.Open("sqlite3", dsn)
if err != nil {
return fmt.Errorf("open SQLite: %w", err)
}
defer func() { err = errors.Join(err, db.Close()) }()
if err := db.PingContext(ctx); err != nil {
return fmt.Errorf("ping SQLite: %w", err)
}
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
model TEXT NOT NULL,
price INTEGER NOT NULL
)`); err != nil {
return fmt.Errorf("create products table: %w", err)
}
result, err := db.ExecContext(ctx,
`INSERT INTO products(model, price) VALUES (?, ?)`, "Keyboard", 5000,
)
if err != nil {
return fmt.Errorf("insert product: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("read inserted id: %w", err)
}
fmt.Println("inserted id:", id)
return nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := run(ctx); err != nil {
panic(err)
}
}Пример запуска на Linux или macOS:
export SQLITE_DSN='file:store.db?_foreign_keys=on&_busy_timeout=5000'
go run .SQLite допускает много читателей, но запись сериализуется. Для приложения важно продумать busy timeout, короткие транзакции, резервное копирование и режим журналирования; увеличение числа соединений не превращает файл в клиент-серверную СУБД.
5. MongoDB
MongoDB — документная, а не реляционная база. Для Go используется официальный модуль go.mongodb.org/mongo-driver/v2. Клиент владеет пулом соединений: его создают один раз, проверяют через Ping при необходимости и закрывают методом Disconnect.
go get go.mongodb.org/mongo-driver/v2/mongoСтруктурные теги bson сопоставляют поля Go с полями документа. Фильтры и операторы задаются типами bson.D или bson.M, а всем операциям передают context.Context.
Следующий пример требует внешнюю зависимость и доступный MongoDB:
package main
import (
"context"
"errors"
"fmt"
"os"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
)
type Product struct {
Model string `bson:"model"`
Company string `bson:"company"`
Price int `bson:"price"`
}
func run(ctx context.Context) (err error) {
uri := os.Getenv("MONGODB_URI")
if uri == "" {
return errors.New("MONGODB_URI is required")
}
client, err := mongo.Connect(options.Client().ApplyURI(uri))
if err != nil {
return fmt.Errorf("connect MongoDB: %w", err)
}
defer func() {
disconnectCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err = errors.Join(err, client.Disconnect(disconnectCtx))
}()
if err := client.Ping(ctx, readpref.Primary()); err != nil {
return fmt.Errorf("ping MongoDB: %w", err)
}
products := client.Database("productdb").Collection("products")
if _, err := products.InsertOne(ctx, Product{
Model: "Pixel", Company: "Google", Price: 64000,
}); err != nil {
return fmt.Errorf("insert product: %w", err)
}
var product Product
err = products.FindOne(ctx, bson.D{{Key: "model", Value: "Pixel"}}).Decode(&product)
if errors.Is(err, mongo.ErrNoDocuments) {
return fmt.Errorf("find product: %w", err)
}
if err != nil {
return fmt.Errorf("find product: %w", err)
}
if _, err := products.UpdateOne(ctx,
bson.D{{Key: "model", Value: product.Model}},
bson.D{{Key: "$set", Value: bson.D{{Key: "price", Value: 62000}}}},
); err != nil {
return fmt.Errorf("update product: %w", err)
}
fmt.Printf("%+v\n", product)
return nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := run(ctx); err != nil {
panic(err)
}
}URI поступает только из конфигурации или секрет-хранилища и не выводится в лог. Если Find возвращает курсор, его закрывают и после обхода проверяют cursor.Err; для одного документа FindOne(...).Decode возвращает mongo.ErrNoDocuments, если совпадения нет.
Глава 14. Дополнительные статьи
1. Рефлексия
Пакет reflect позволяет исследовать типы и значения во время выполнения. reflect.TypeOf возвращает динамический тип, reflect.ValueOf — представление значения, а Kind сообщает базовую категорию: Struct, Pointer, Slice, String и другие.
Методы допустимы не для каждого Kind: например, NumField вызывают только для структуры. Нарушение контракта приводит к panic. Перед раскрытием указателя нужно проверить IsNil, перед изменением поля — CanSet, а перед получением обычного значения — CanInterface.
package main
import (
"fmt"
"reflect"
)
type Person struct {
Name string `json:"name"`
Age int `json:"age"`
}
func describe(value any) error {
v := reflect.ValueOf(value)
if !v.IsValid() {
return fmt.Errorf("invalid nil value")
}
if v.Kind() == reflect.Pointer {
if v.IsNil() {
return fmt.Errorf("nil %s", v.Type())
}
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return fmt.Errorf("expected struct, got %s", v.Kind())
}
t := v.Type()
for i := 0; i < v.NumField(); i++ {
fieldType := t.Field(i)
fieldValue := v.Field(i)
if !fieldValue.CanInterface() {
continue
}
fmt.Printf("%s (%s, json=%q) = %v\n",
fieldType.Name,
fieldValue.Kind(),
fieldType.Tag.Get("json"),
fieldValue.Interface(),
)
}
return nil
}
func main() {
if err := describe(Person{Name: "Tom", Age: 41}); err != nil {
panic(err)
}
}Рефлексия полезна в универсальных библиотеках: сериализаторах, валидаторах, контейнерах кодогенерации и инструментах. Цена — проверки только во время выполнения, возможные panic, более сложный код и дополнительные расходы. Если задача выражается обычным интерфейсом, type switch или дженериками, этот вариант обычно безопаснее и понятнее.