Учебник Go (Golang) для начинающих

Маскот языка Go – знаменитый рисованный гофер, созданный художницей Рене Френч в 2009 году. Этот забавный талисман стал символом простоты и дружелюбия Go.

Главная идея Go — минимализм: меньше «магии», больше понятного и предсказуемого кода. Поэтому он быстро осваивается, даже если вы только начинаете путь в программировании.

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

Полезные ресурсы:

🔥 https://t.me/+RAiQoS5k4Bg4NGYy – огромное количество уроков, библиотек и примеров с кодом в канале для Go разработчиков.

📌 https://t.me/addlist/MUtJEeJSxeY2YTFi – тут я собрал гигантскую папку маст-хэв для Golang программистов.

📌 https://t.me/golang_interview – здесь разобрано 1900 вопросов с собеседований GO

Зачем изучать Go

Go (часто называют Golang) – современный компилируемый язык программирования, разработанный компанией Google в 2007 году и открыто представленный сообществу в 2009 годуen.wikipedia.org. Go сочетает в себе эффективность низкоуровневых языков с простотой и выразительностью высокоуровневых. Вот несколько причин обратить внимание на Go:

  • Простота и лаконичность. Синтаксис Go предельно простой и чистый, без избыточной сложной семантики. У языка всего 25 ключевых слов, а особенности спроектированы так, чтобы программист мог удержать всю специфику в голове одновременно. Это снижает порог входа для новичков и ускоряет разработку.
  • Высокая производительность. Go – статически типизированный компилируемый язык, то есть исходный код компилируется в машинный код, что обеспечивает высокую скорость выполнения программ. По замыслу создателей, Go сочетает эффективность C (низкоуровневое быстродействие и строгая типизация) с удобством Python (читабельность и простота использования).
  • Встроенная поддержка конкурентности. Уникальной особенностью Go является легковесная конкурентность: горутины и каналы. Это позволяет легко писать программы, эффективно использующие многопроцессорные системы. Авторы языка сделали акцент на решении проблем параллелизма: «Не организуйте связь посредством общей памяти; вместо этого организуйте общую память посредством связи». В Go встроены механизмы для безопасного обмена данными между потоками выполнения (каналы), что упрощает написание корректного конкурентного кода.
  • Богатая стандартная библиотека. Стандартная библиотека Go предоставляет множество пакетов «из коробки» – для работы с сетью, форматирования, ввода-вывода, управления процессами и др. Это означает, что для многих задач не требуется сторонних зависимостей. По словам авторов, Go известен простотой синтаксиса и эффективностью разработки, достигаемой за счёт богатой стандартной библиотеки, покрывающей потребности типичных проектов.
  • Области применения и сообщество. Go изначально создавался для масштабных проектов Google, поэтому отлично подходит для разработки серверных приложений, микросервисов, облачных сервисов и CLI-утилит. За прошедшие годы Go завоевал популярность во многих компаниях. На Go написаны такие известные системы, как платформа контейнеризации Docker и оркестратор контейнеров Kubernetes, что демонстрирует его пригодность для высоконагруженных и критически важных приложений. Активное сообщество разработчиков, обилие библиотек и инструментов, а также поддержка от Google – всё это делает Go привлекательным выбором.

Краткая история Go

Go был создан командой из трёх известных инженеров – Робертом Гризмером (Robert Griesemer), Робом Пайком (Rob Pike) и Кеном Томпсоном (Ken Thompson) – в недрах Google около 2007 год.. Язык рождался как ответ на потребности крупного IT-гиганта: требовался инструмент, который ускорит разработку в эпоху многоядерных систем, сетевых вычислений и гигантских кодовых баз. Разработчики стремились устранить недостатки существующих языков, сохранив при этом их сильные стороны. Так, Go вобрал в себя:

  • Строгую статическую типизацию и высокую скорость, как в C.
  • Лаконичность и легкость скриптовых языков (синтаксически Go напоминает упрощённый Си, но без лишней церемониальности и с автоматическим сборщиком мусора).
  • Встроенную поддержку сетевого взаимодействия и параллельного выполнения (конкурентность по модели CSP, как эволюция идеи communicating sequential processes).

Язык развивался открыто с момента анонса в 2009 году. Первая стабильная версия Go 1.0 вышла в марте 2012 года. С тех пор команда Go придерживается политики стабильности: программа, написанная под Go 1, должна и сегодня компилироваться и работать на актуальной версии компилятора (на ноябрь 2025 года актуальна ветка Go 1.21/1.22). В 2022 году язык получил давно ожидаемые дженерики (параметризированные типы) с выходом версии Go 1.18, что расширило возможности абстракции в коде.

Отдельно стоит отметить философию разработки Go: упор сделан не только на сам язык, но и на инструменты и практики, сопровождающие цикл разработки. Стандартизированная система сборки, форматирования кода, тестирования, управления зависимостями – всё это было продумано с самого начала, что выгодно отличает Go. Маскотом (символом) Go стал изображённый забавный гофер (суслик), который олицетворяет дружелюбие и простоту языка. Это изображение часто используется в презентациях, статьях и на сувенирах, связанных с Go, и стало неотъемлемой частью культуры сообщества Golang.

Области применения Go

Go разрабатывался как язык общего назначения (general-purpose), поэтому на нём можно создавать широкий спектр приложений. Наибольшее распространение он получил в серверной и облачной разработке:

  • Веб-серверы и сетевые сервисы. Встроенные библиотеки для работы с HTTP, JSON, шаблонами, а также лёгкая конкурентность сделали Go популярным выбором для создания API-серверов, веб-сервисов, микросервисной архитектуры. Многие веб-фреймворки и сервисы написаны на Go.
  • Инфраструктурные проекты и DevOps. Высокая производительность и простота деплоя (Go-компилятор выпускает статически слинкованный бинарник без зависимостей) привели к тому, что множество инструментов для разработчиков и администрирования созданы на Go. Например, Docker – система контейнеризации приложений – написана на Go, равно как и Kubernetes – система оркестрации контейнеров. Такие проекты подтвердили, что Go отлично подходит для утилит командной строки и серверных демонов.
  • Распределённые системы и облако. Go широко используется для сервисов в облачных платформах, благодаря удобной работе с сетью (в стандартной библиотеке есть все необходимое для HTTP, RPC, REST) и способности обрабатывать большое количество параллельных соединений с малыми накладными расходами.
  • Системное программное обеспечение. Хотя Go не предназначен для разработки ядра ОС или драйверов, он успешно применяется для написания различных системных утилит, прокси-серверов, брокеров сообщений, баз данных (например, CockroachDB – распределённая SQL СУБД – тоже написана на Goen.wikipedia.org).
  • Научные и консольные приложения. Благодаря простоте, Go используют и в учебных целях, и для написания простых утилит, скриптов для автоматизации, парсеров, генераторов кода и др. Отсутствие сложностей с управлением памятью (сборщик мусора) и встроенные профилировщики позволяют сосредоточиться на логике задач.

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

2. Установка и настройка окружения

Чтобы начать программировать на Go, нужно установить среду разработки: сам компилятор и связанные инструменты (часто это называют Go toolchain – цепочка инструментов Go). Также стоит выбрать удобный редактор или IDE с поддержкой Go.

Установка Go

  1. Скачивание дистрибутива. Перейдите на официальный сайт Go (раздел Download) и скачайте установщик для вашей платформы (Windows, macOS, Linux). Дистрибутив Go включает компилятор, инструменты сборки и всю стандартную библиотеку.
  2. Установка. Запустите установщик и следуйте инструкциям. На Windows по умолчанию Go установится в C:\Program Files\Go\, на Linux и macOS – в /usr/local/go. Альтернативный способ – скачать архив с Go и распаковать его вручную в нужное место.
  3. Настройка PATH. Убедитесь, что путь к исполняемым файлам Go добавлен в переменную окружения PATH. Обычно установщик делает это автоматически. Например, для Linux это /usr/local/go/bin. На Windows установщик тоже прописывает путь. Для проверки можно открыть терминал и выполнить команду: go version Если установка прошла успешно, вы увидите сообщение с номером версии Go. Например: go version go1.xx linux/amd64.
  4. Рабочее пространство (опционально). Раньше Go требовал задать переменную GOPATH – каталог, где будут располагаться ваши проекты и зависимости. В современных версиях (начиная с Go 1.11) используется механизм модулей, и необходимость явно задавать GOPATH отпала (по умолчанию GOPATH равен ~/go в вашем домашнем каталоге). Однако знать о нём полезно: внутри GOPATH по умолчанию есть папки src/, bin/, pkg/. При установке через go install исполняемые файлы попадут в GOPATH/bin.

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

Настройка редактора

Для продуктивной работы рекомендуется использовать редактор кода или интегрированную среду разработки, которая поддерживает подсветку синтаксиса Go, автодополнение, запуск тестов и пр. Visual Studio Code (VS Code) с официальным расширением Go – один из самых популярных и бесплатных вариантов. Также распространены IDE GoLand (коммерческий продукт от JetBrains) и разнообразные плагины для Vim/Neovim, Sublime Text, Atom и других редакторовgo.dev. Выбор редактора зависит от ваших предпочтений; Go довольно хорошо поддерживается большинством средств разработки.

После установки редактора имеет смысл установить дополнительные инструменты:

  • Go plugin/extension. Для VS Code это расширение Go от Google, которое добавляет поддержку автодополнения, отладки, форматирования кода.
  • Linter и подсказки. Многие редакторы при наличии расширения автоматически предложат установить линтеры (например, golangci-lint) и утилиты как gopls (Go Language Server для интеллектуального анализа кода). Эти инструменты помогают сразу подсвечивать ошибки, предупреждения по стилю, возможные баги.
  • go tools. Утилита gopls обычно устанавливается автоматически через редактор. Она отвечает за анализ кода и автодополнение. Другие утилиты, как dlv (delve) для отладки, могут потребоваться для продвинутых сценариев.

Go: компиляция и запуск программ

Go поставляется с утилитой командной строки go, которая управляет всем циклом разработки. Рассмотрим самые базовые команды:

  • go run – компилирует и сразу запускает указанную программу. Обычно используется для быстрого запуска скриптов и во время разработки. Например, находясь в директории с файлом hello.go, выполните go run hello.go – и Go скомпилирует этот файл во временный бинарник, запустит его, а по завершении программа удалится. Также можно указать директорию: go run . запустит пакет из текущей папки (если в ней есть main.go или другой файл с функцией main).
  • go build – компилирует пакет (по умолчанию текущий) и генерирует исполняемый файл. Запустив go build в папке с main.go, вы получите бинарный файл (на Windows – .exe, на Linux/macOS – без расширения) с тем же именем, что и пакет (или можно указать флаг -o для названия). go build не устанавливает бинарник в системные пути, а просто собирает его в текущей директории. Эта команда удобна для сборки релизных версий программ.
  • go install – компилирует и устанавливает бинарник в $GOPATH/bin (либо в указанный GOBIN). После go install ваш бинарный файл окажется в этой папке, и при условии, что она есть в PATH, вы сможете запускать программу по имени из любого места. В Go 1.18+ если вы вызываете go install с указанием модуля (например, go install github.com/user/repo/cmd/tool@latest), то утилита напрямую скачает и установит указанный инструмент.
  • go get – в старых версиях Go использовалась для загрузки пакетов (сейчас её функциональность частично заменена на go install с версией и на go mod tidy). В контексте модулей go get обновляет зависимости в вашем модуле.
  • go mod tidy – приводит в порядок файл зависимостей go.mod, добавляя недостающие и удаляя неиспользуемые модули.

Пример: давайте создадим и запустим самую простую программу на Go – традиционное «Hello, World!».

  1. Откройте терминал, создайте новую папку для проекта и перейдите в неё: mkdir hello cd hello
  2. Инициализируйте модуль (об этом подробно далее, пока просто выполним команду): go mod init example/hello Она создаст файл go.mod с указанием имени модуля example/hello (вы можете подставить свой путь). Инициализация модуля нужна, даже если вы пока не используете внешние зависимости – это современный подход организации кода в Gogo.dev.
  3. Создайте файл hello.go в папке и откройте его в редакторе. Напишите в нём следующий код: package main import "fmt" func main() { fmt.Println("Hello, World!") } Здесь мы объявляем пакет main (исполняемые программы всегда должны быть в пакете main и содержать функцию main). Импортируем пакет fmt из стандартной библиотеки – он предоставляет функции форматированного ввода-вывода. И в функции main вызываем fmt.Println, чтобы напечатать строку на экран.
  4. Сохраните файл и вернитесь в терминал. Выполните: go run . Команда go run . скомпилирует и сразу запустит программу в текущей папке (точка означает «текущий пакет»). Вы должны увидеть вывод: Hello, World!

Поздравляем, вы успешно запустили первую программу на Go! 🎉 Теперь перейдем к изучению основ языка, чтобы разбираться, как писать более сложный код.

3. Основы языка

В этом разделе мы рассмотрим базовые элементы Go: синтаксис объявления переменных, функции, основные управляющие конструкции, коллекции (массивы, срезы, отображения), а также такие ключевые понятия как структуры, методы, интерфейсы и указатели. Все примеры снабжены комментариями на русском, чтобы было понятнее начинающему.

Переменные и типы

Go – статически типизированный язык, поэтому каждая переменная имеет определенный тип, известный на этапе компиляции. Объявлять переменные можно несколькими способами:

  • С ключевым словом var. Например, var x int объявляет переменную x типа int (целое число) без инициализации, её значение по умолчанию будет 0 (ноль). Можно сразу инициализировать: var y int = 10. Если указываем выражение, можно не повторять тип – компилятор сам выведет тип: var z = 3.14 (в этом случае z будет типа float64).
  • Короткое объявление :=. Внутри функции допустим сокращенный синтаксис: a := 5. Он одновременно объявляет переменную a и присваивает ей значение 5. Тип выводится автоматически (здесь a будет int). Такая нотация экономит время и делает код компактнее, но работает только для новых переменных внутри функций.

Приведем пример кода с различными способами объявления переменных и продемонстрируем их значения по умолчанию и вывод типов:

package main

import "fmt"

func main() {
    // Объявление переменной с явным указанием типа и инициализацией
    var age int = 30
    fmt.Println("Возраст:", age)

    // Объявление без начального значения: получим zero-value (ноль для int)
    var count int
    fmt.Println("Count =", count) // count == 0

    // Объявление нескольких переменных одного типа сразу
    var width, height float64 = 5.5, 7.2
    fmt.Println("Ширина и высота:", width, height)

    // Компилятор выведет тип сам (widthSum станет float64)
    widthSum := width + height
    fmt.Printf("Сумма ширины и высоты: %f (тип %T)\n", widthSum, widthSum)

    // Короткое объявление нескольких переменных разных типов
    name, age2 := "Alice", 25
    fmt.Printf("%s, %d лет\n", name, age2)
}

Запустив эту программу (go run), вы увидите примерно такой вывод:

Возраст: 30  
Count = 0  
Ширина и высота: 5.5 7.2  
Сумма ширины и высоты: 12.700000 (тип float64)  
Alice, 25 лет

Обратите внимание на несколько моментов:

  • Для неинициализированных переменных Go задает нулевое значение (zero value) в соответствии с типом: для чисел это 0, для строк – пустая строка "", для булевых – false, для указателей, срезов, мап и интерфейсов – nil (специальное «нулевое» значение).
  • Оператор := удобен, но им нельзя переобъявить уже существующую переменную в той же области видимости. Также вне функций (например, на уровне пакета) := не работает – там нужно использовать var.
  • Go имеет ряд базовых встроенных типов: числовые (int, float64, complex128 для комплексных чисел, и др.), строки (string), булевый (bool). Также есть байтовые типы (byte, rune). byte эквивалентен uint8 (число 0-255), а rune эквивалентен int32 и предназначен для хранения символов Unicode.

Кроме явных типов, Go поддерживает типизацию по умолчанию для нетипизированных констант. Например, константа 5 может быть и int, и float64 в зависимости от контекста. Но это детали, в основном же вы будете явно работать с определёнными типами.

Функции

Функции в Go – основной блок кода, позволяющий структурировать программы и переиспользовать логику. Синтаксис объявления функции следующий:

func имя(параметры) (список_возвращаемых_типов) {
    // тело функции
}

Некоторые особенности функций в Go:

  • Множественные возвращаемые значения. Функция может вернуть несколько результатов. Например, стандартная функция fmt.Println возвращает два значения: количество байт и ошибку. Множественные результаты удобно использовать для передачи ошибки вызывающему коду (подробно разберём в разделе об ошибках).
  • Именованные возвращаемые значения. В определении функции можно дать имя возвращаемому значению прямо в сигнатуре. Это редко используется, но позволяет присвоить значения по именам и не использовать явный return expr (достаточно return).
  • Передача аргументов по значению. В Go все аргументы передаются по значению (то есть функция получает копию). Для больших структур это может быть накладно, поэтому часто используют указатели, чтобы передать ссылку (адрес).

Рассмотрим примеры функций:

// Функция без возвращаемого значения (процедура)
func greet(name string) {
    fmt.Println("Привет,", name)
}

// Функция, возвращающая сумму двух int
func add(a int, b int) int {
    result := a + b
    return result
}

// Функция, возвращающая два значения: результат и ошибку
func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, fmt.Errorf("деление на ноль")
    }
    return a / b, nil
}

Здесь:

  • greet принимает строку и просто печатает приветствие. Возвращаемых значений нет.
  • add складывает два целых и возвращает int.
  • divide делит a на b и может вернуть ошибку (error – встроенный интерфейс для ошибок в Go). Если b == 0, возвращаем 0 и не nil ошибку, иначе возвращаем результат и nil как индикатор отсутствия ошибки.

Использование этих функций:

func main() {
    greet("Мир")

    sum := add(3, 5)
    fmt.Println("3 + 5 =", sum)

    res, err := divide(10, 2)
    if err != nil {
        fmt.Println("Ошибка:", err)
    } else {
        fmt.Println("10 / 2 =", res)
    }

    _, err2 := divide(5, 0) // нам не нужен результат, только ошибка
    if err2 != nil {
        fmt.Println("Ожидаемая ошибка при делении:", err2)
    }
}

Вывод программы будет:

Привет, Мир  
3 + 5 = 8  
10 / 2 = 5  
Ожидаемая ошибка при делении: деление на ноль

Обратите внимание на некоторые идиомы Go:

  • Мы спокойно присваиваем результат функции divide в два отдельных значения res, err. Если ошибка не нужна, на её месте можно указать _ (пустой идентификатор), как сделано во втором вызове divide(5, 0).
  • Проверка ошибок – важная часть стиля Go. Обычно сразу после вызова функции, возвращающей error, пишут if err != nil { ... } для обработки ошибки. Подробно об ошибках – в отдельном разделе, пока запомните паттерн.
  • Тип error – это интерфейс (см. раздел про интерфейсы), но пока можно считать его специальным типом для ошибок. Конвенция Go: возвращать ошибку как последний из возвращаемых результатов функции. Если ошибка nil, значит всё хорошо и другие возвращаемые значения валидны. Если ошибка не nil, то обычно другие значения либо не важны, либо находятся в нулевом состоянии.

Также в Go есть анонимные функции и замыкания (closures). Вы можете определять функцию прямо внутри другой функции без имени и вызывать её. Это полезно, например, в горутинах. Пример замыкания:

func main() {
    counter := 0
    increment := func() {
        counter++
        fmt.Println("Счетчик:", counter)
    }

    increment() // Счетчик: 1
    increment() // Счетчик: 2
}

Анонимная функция increment захватывает переменную counter из внешней области (создаётся замыкание). При каждом вызове она увеличивает counter. Такие конструкции часто применяются для функциональных паттернов или отложенных вычислений.

Условия и циклы

Go предоставляет знакомые конструкции управления потоком выполнения: условные операторы (if, switch) и цикл for (единственный вид цикла в Go, который заменяет аналоги while и do..while других языков).

if – синтаксис стандартный, но в Go не нужны круглые скобки вокруг условия, однако фигурные скобки обязательны. Кроме того, в if можно перед условием вставить короткое заявление (инициализацию), которое выполнится перед проверкой условия и будет доступно внутри блока if. Например:

if x := compute(); x < 0 {
    fmt.Println("x отрицательный:", x)
} else if x == 0 {
    fmt.Println("x равен нулю")
} else {
    fmt.Println("x положительный:", x)
}

Здесь мы в условии сразу вызываем compute() и сохраняем результат в x, после чего проверяем x. Переменная x будет видна в if и else if / else блоках, но не вне их.

Пример простого условия:

a := 7
b := 5
if a > b {
    fmt.Println("a больше b")
} else if a < b {
    fmt.Println("a меньше b")
} else {
    fmt.Println("a равно b")
}

Цикл for. В Go оператор for объединяет возможности сразу трех циклов из Си-подобных языков (for, while, do while). Его общая форма:

for инициализация; условие; пост-итерационное действие {
    // тело цикла
}

Все три части опциональны, их можно опускать, получая различные вариации:

  • Классический for с счетчиком: for i := 0; i < 10; i++ { ... }.
  • Если убрать инициализацию и пост-действие, останется for условие { ... } – аналог while из других языков.
  • Если убрать условие тоже: for { ... } – бесконечный цикл (аналог while(true)).

Примеры:

// 1. Классический for со счетчиком
for i := 1; i <= 5; i++ {
    fmt.Println("Итерация", i)
}

// 2. Цикл как while
n := 1
for n < 100 {
    n *= 2
}
fmt.Println("Двоичный размах превысил 100, n =", n)

// 3. Бесконечный цикл
count := 0
for {
    if count == 3 {
        break // выходим из цикла
    }
    fmt.Println("Бесконечный цикл, шаг", count)
    count++
}

В примерах выше:

  • Первый цикл выводит номера итераций от 1 до 5.
  • Второй цикл удваивает n, пока оно меньше 100. Когда условие n < 100 перестает выполняться, цикл заканчивается. Этот вариант демонстрирует, что for без указанных инициализации и пост-действия работает как while.
  • Третий цикл – бесконечный, но внутри мы вручную выходим из него через break, когда count достигнет 3. Также для прерывания итерации и перехода к следующей используется continue.

switch. Оператор множественного выбора в Go также похож на аналог из C/Java, но имеет приятные особенности:

  • Можно переключаться не только по числам или строкам, но и по любым сравнимым типам.
  • Автоматическое прерывание после выполнения подходящего случая (в Go не нужен явный break в каждом case – он подразумевается по умолчанию, что снижает число ошибок). Если же вы хотите намеренно провалиться в следующий case, можно использовать ключевое слово fallthrough.
  • Условие в switch необязательно должно быть выражением – может быть и само условие в кейсах. Если switch написан без выражения, каждый case представляет собой условие, и выполняется тот, который первым окажется true.

Примеры switch:

day := 3
switch day {
case 1:
    fmt.Println("Понедельник")
case 2:
    fmt.Println("Вторник")
case 3:
    fmt.Println("Среда")
default:
    fmt.Println("Другой день")
}

// Пример switch без выражения (логический switch)
x := -5
switch {
case x < 0:
    fmt.Println("x отрицательный")
case x == 0:
    fmt.Println("x равен нулю")
case x > 0:
    fmt.Println("x положительный")
}

В первом примере мы выбираем сообщение по значению переменной day. Во втором – switch проверяет подряд условия x < 0, x == 0, x > 0 и выбирает подходящий блок.

Массивы, срезы и отображения (map)

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

Массивы. Массив – это набор элементов фиксированной длины одного типа, расположенных подряд в памяти. Размер массива является частью его типа. Объявляется массив как [N]T, где N – число элементов, T – тип. Например: var nums [5]int – массив из 5 целых. По умолчанию все элементы инициализируются нулевыми значениями типа (0 для int). Доступ к элементам – по индексу (начиная с 0). Длина массива – len(nums).

Пример использования массива:

var grades [3]int
grades[0] = 85
grades[1] = 90
grades[2] = 92
fmt.Println("Оценки:", grades)
fmt.Println("Первая оценка:", grades[0])
fmt.Println("Количество оценок:", len(grades))

// Объявление и инициализация массива литералом:
days := [3]string{"yesterday", "today", "tomorrow"}
fmt.Println(days)

Однако массивы в Go используются не так часто напрямую – гораздо чаще применяются срезы.

Срезы (slice). Срез – это динамическая последовательность элементов одного типа. Можно представить его как надстройку над массивом, которая может менять размер. Срез указывает на участок базового массива. Когда вы передаете срез в функции, копируется не весь массив, а лишь структура с указателем на данные, длиной и ёмкостью.

Объявление среза: var s []T. Обратите внимание: в квадратных скобках не указывается размер! Например, var list []int. По умолчанию nil-срез (значение nil) считается пустым. Часто срезы создают с помощью встроенной функции make: make([]T, длина, емкость). Емкость (capacity) – необязательный параметр, по умолчанию равна длине. Емкость – это размер подлежащего массива, который срез может использовать, не выделяя новую память.

Примеры работы со срезами:

// Создаем срез на 5 строк, начально заполненных пустыми строками
names := make([]string, 5)
fmt.Println("Длина:", len(names), "Емкость:", cap(names))  // Длина:5, Емкость:5

names[0] = "Tom"
names[1] = "Bob"
fmt.Println(names)  // [Tom Bob  ]

// Функция append добавляет элементы в срез (и при необходимости расширяет емкость)
names = append(names, "Alice")
fmt.Println(names)                          // [Tom Bob  Alice]
fmt.Println("Новая длина:", len(names))     // 6
fmt.Println("Новая емкость:", cap(names))   // 10 (например, емкость могла удвоиться)

// Можно объявлять срез литералом напрямую (не указывая длину)
primes := []int{2, 3, 5, 7, 11}
fmt.Println("Простые числа:", primes)

// Срез от среза (операция high:low)
// Возьмем элементы со 2-го по 4-й (не включая индекс 4)
slice := primes[1:4]
fmt.Println("Срез [1:4] ->", slice)  // [3 5 7]
slice[0] = 99
fmt.Println("Модифицировали срез:", slice)   // [99 5 7]
fmt.Println("Исходный массив:", primes)      // [2 99 5 7 11] - тоже изменился!

В этом коде демонстрируются важные моменты:

  • append – встроенная функция для добавления элементов. Она возвращает новый срез (может либо указывать на прежний массив, если места хватило, либо на новый массив, если емкость переполнилась). Поэтому результат append часто нужно присвоить обратно, т.к. базовый массив мог измениться.
  • Срез можно получать от существующего массива или другого среза через синтаксис a[low:high]. Это не копирует данные, а создает новый срез, указывающий на ту же память с элементами от индекса low до high-1. Изменения через этот срез отразятся на исходном массиве (как видно: мы поменяли slice[0], затронув primes).
  • Функции len(s) и cap(s) дают текущую длину и емкость среза.
  • Если опустить low или high, используются значения по умолчанию (начало или конец). Например, primes[:3] – первые три элемента, primes[2:] – от индекса 2 до конца.
  • Итерация по срезу. Чаще всего срезы обходят с помощью for ... range: for index, value := range primes { fmt.Println(index, value) } Эта конструкция перебирает индекс и значение каждого элемента. Если индекс не нужен, можно использовать _.

Map (отображение). Map – это ассоциативный массив или словарь, хранящий пары ключ-значение. В Go мапа объявляется как map[KeyType]ValueType. Ключи должны быть сравнимого типа (строки, числа, структуры без срезов и т.п., а вот срез или map не могут быть ключом). Значения – любые типы.

Инициализировать map можно с помощью make или литерала:

  • scores := make(map[string]int) – создает пустой map с ключом строкой и значением int.
  • Литерал: products := map[string]float64{ "Шоколад": 99.50, "Молоко": 60.00, }

Операции с map:

  • Добавление или обновление: map[key] = value.
  • Чтение: val = map[key]. Если ключа нет, вернется zero-value типа значения (например, 0 для int). Чтобы отличить «ключа нет» от случая, когда значением как раз является нулевое значение, используют второй возвращаемый признак: val, exists := map[key] if !exists { // ключ отсутствует } Здесь exists – булевый, будет true, если такой ключ присутствует.
  • Удаление: delete(map, key) – встроенная функция.
  • Длина: len(map) – число пар в словаре.
  • Итерация: for k, v := range mapVar { ... } – перебор в неопределенном порядке (Go не гарантирует порядка ключей, на каждое выполнение iteration order может меняться).

Пример работы с map:

scores := make(map[string]int)
scores["Alice"] = 95
scores["Bob"] = 88

fmt.Println("Alice score:", scores["Alice"])

scores["Bob"] = 90            // обновляем значение
fmt.Println("Bob score:", scores["Bob"])

joeScore, ok := scores["Joe"] // пробуем получить несуществующий ключ
if !ok {
    fmt.Println("Для Joe нет результатов")
}

// Литерал map с инициализацией
capitals := map[string]string{
    "France": "Paris",
    "Italy":  "Rome",
}
capitals["Japan"] = "Tokyo"

fmt.Println("Столицы:")
for country, capital := range capitals {
    fmt.Printf("  %s -> %s\n", country, capital)
}

delete(capitals, "France")
fmt.Println("После удаления France, capitals:", capitals)

Вывод может быть примерно таким:

Alice score: 95  
Bob score: 90  
Для Joe нет результатов  
Столицы:  
  Italy -> Rome  
  Japan -> Tokyo  
После удаления France, capitals: map[Italy:Rome Japan:Tokyo]

Обратите внимание: при итерации по map, порядок вывода не гарантирован. В примере, возможно, сначала выведется Japan, потом Italy – это нормально.

Итого, массивы в Go используются редко (чаще во внутренних оптимизациях), срезы – основной рабочий инструмент для списков, а map – для словарей/таблиц соответствий. В отличие от некоторых скриптовых языков, срезы и мапы в Go не требуют дополнительной библиотеки – это встроенные типы, работающие очень эффективно.

Структуры и методы

Структуры (struct) в Go позволяют объединять данные разного типа в одну логическую единицу. Это аналог записей (record) или объектов без методов (plain old data structures). Вы определяете свой тип структуры, перечисляя поля с их типами.

Объявление структуры:

type Person struct {
    Name string
    Age  int
}

Здесь мы определили новый тип Person с двумя полями: Name и Age. После этого можем создавать переменные этого типа, заполнять поля и использовать.

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

  • Литерал структуры: p := Person{"Alice", 30} – присваивание позиционно (в порядке объявленных полей).
  • Именованный литерал: p := Person{Name: "Alice", Age: 30} – явно указываем имя полей (порядок не важен, не обязательно все поля заполнять – незаданным присвоится zero-value).
  • var без инициализации: var p Person – создаст p с пустой строкой Name и 0 в Age.

Обращение к полям: через точку (p.Name, p.Age).

Структуры могут содержать поля любого типа, в том числе и другие структуры, срезы, мапы и т.д. Они являются значимыми типами (value types), то есть при присваивании структуры переменной или передачи в функцию – она копируется.

Пример использования структур:

type Rectangle struct {
    Width  float64
    Height float64
}

func main() {
    // Инициализация структур различными способами:
    var r1 Rectangle            // поля Width=0, Height=0
    r2 := Rectangle{10.5, 4.2}  // поля по порядку
    r3 := Rectangle{Height: 5, Width: 3} // явное указание полей

    fmt.Println("r1:", r1) // {0 0}
    fmt.Println("r2:", r2) // {10.5 4.2}
    fmt.Println("r3:", r3) // {3 5} (обратите внимание, мы указали поля в другом порядке)

    // Доступ и изменение полей:
    r1.Width = 7
    r1.Height = 8
    fmt.Println("Площадь r1 =", r1.Width * r1.Height)
}

Методы. В Go отсутствует классическое ООП-понятие классов, но есть методы, которые можно прикреплять к типам (в том числе к структурам). Метод – это просто функция, объявленная с параметром-получателем (receiver). Синтаксис:

func (p Person) SayHi() {
    fmt.Println("Привет, меня зовут", p.Name)
}

Здесь SayHi – метод типа Person. В скобках (p Person) мы указали, что функция ассоциирована с типом Person, и внутри неё доступна переменная p типа Person, представляющая экземпляр. Вызывать метод можно так: personInstance.SayHi().

Важно понимать, что метод с получателем по значению (p Person) получает копию структуры. Изменения p внутри метода не затронут оригинал. Чтобы метод мог менять состояние объекта, получатель делают указателем: (p *Person). Тогда внутри метода p будет указатель на оригинальную структуру, и изменения полей через p отразятся снаружи.

Добавим метод к нашему Rectangle:

// Метод расчета площади (получатель по значению)
func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

// Метод изменения размера (получатель - указатель, чтобы менять текущий объект)
func (r *Rectangle) Scale(factor float64) {
    r.Width *= factor
    r.Height *= factor
}

Теперь в функции main можно делать:

rect := Rectangle{Width: 2, Height: 3}
fmt.Println("Площадь:", rect.Area()) // 6

rect.Scale(2)
fmt.Println("Новый размер:", rect.Width, "x", rect.Height) // 4 x 6
fmt.Println("Новая площадь:", rect.Area()) // 24

Таким образом, мы реализовали подобие методов класса, хотя под капотом Go не создает никаких скрытых классов – методы просто связаны с определенным типом. Кстати, методы можно объявлять не только для структур, но и для любых пользовательских типов (например, тип, основанный на int, тоже может иметь методы).

Интерфейсы

Интерфейс в Go – это набор методов без реализации, который может реализовать любой тип. Интерфейсы позволяют писать функции, не привязанные к конкретным типам, а работающие с “чем-то, что имеет вот такие методы”. Это основной механизм полиморфизма в Go.

Объявление интерфейса:

type Shape interface {
    Area() float64
    Perimeter() float64
}

Этот интерфейс Shape требует наличие двух методов: Area() и Perimeter(), возвращающих float64. Любой тип, у которого есть методы с точно такими сигнатурами, неявно реализует интерфейс Shape. В Go не нужно явно указывать implements или наследовать интерфейс – реализация происходит автоматически по совпадению методов.

Например, наш Rectangle имеет метод Area(). Добавим ему Perimeter():

func (r Rectangle) Perimeter() float64 {
    return 2*(r.Width + r.Height)
}

Теперь Rectangle удовлетворяет интерфейсу Shape (он реализует оба метода). Можно написать функцию, принимающую Shape:

func printShapeInfo(s Shape) {
    fmt.Println("Площадь:", s.Area())
    fmt.Println("Периметр:", s.Perimeter())
}

Эта функция умеет работать с любым “геометрическим фигурным” типом, который реализует интерфейс Shape. Вызовем её:

rect := Rectangle{3, 4}
printShapeInfo(rect)

Вывод:

Площадь: 12  
Периметр: 14

Также создадим ещё один тип, реализующий Shape, например, Circle:

import "math"

type Circle struct {
    Radius float64
}

func (c Circle) Area() float64 {
    return math.Pi * c.Radius * c.Radius
}
func (c Circle) Perimeter() float64 {
    return 2 * math.Pi * c.Radius
}

Теперь Circle тоже реализует Shape. И printShapeInfo(circle) будет работать.

Пустой интерфейс interface{} – особый случай, который содержит нулевой методов и тем самым реализуется любыми типами вообще. interface{} аналогичен понятию “любой тип”. Его часто используют, когда функция должна принимать значение неопределенного типа (но дальше придётся определять тип через приведение). Однако чрезмерное использование пустого интерфейса не рекомендуется без необходимости – лучше по возможности использовать конкретные типы или интерфейсы с методами для явности.

Тип-переменные интерфейса: когда вы храните значение в переменной типа интерфейс, внутри она содержит два компонента: конкретное значение и его конкретный тип. Чтобы извлечь конкретное значение, делают type assertion – приведение типа:

var s Shape = rect  // интерфейс Shape теперь хранит Rectangle
rectCopy := s.(Rectangle)  // assertion, что внутри s именно Rectangle
fmt.Println(rectCopy.Width)

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

if cir, ok := s.(Circle); ok {
    // удалось преобразовать к Circle
} else {
    // не Circle
}

Также есть механизм type switch для интерфейсных значений:

switch v := s.(type) {
case Rectangle:
    fmt.Println("Это прямоугольник шириной", v.Width)
case Circle:
    fmt.Println("Это круг радиуса", v.Radius)
default:
    fmt.Println("Неизвестная Shape")
}

Здесь v.(type) в switch позволяет ветвиться по конкретным типам, которые может содержать интерфейс.

Интерфейсы – мощнейшая особенность Go, позволяющая добиться гибкости без классического наследования. Стандартная библиотека определяет множество интерфейсов (например, io.Reader, io.Writer для ввода-вывода, fmt.Stringer для представления в виде строки и т.д.). Мы еще встретим интерфейсы, например, когда будем обсуждать работу с файлами (там os.File реализует io.Reader/io.Writer).

Указатели

Указатели в Go работают похожим образом, что и в Си, но с двумя важными отличиями: нельзя делать адресную арифметику (нельзя прибавлять к указателю число, двигаться по памяти – это защищает от многих ошибок)en.wikipedia.org, и сборщик мусора избавляет от явного освобождения памяти. Указатели используются, чтобы передать ссылку на данные, позволяя функции изменять значение по месту, или для структуры, чтобы избежать копирования больших объемов данных.

Синтаксис указателей:

  • Оператор & берет адрес переменной. Пример: p := &x – теперь p имеет тип указателя на тип x.
  • Оператор * применяется к указателю для разыменования (получения значения). Например, value := *p.

Go сам распределяет переменные либо на стеке, либо куче, поэтому вы можете спокойно возвращать указатель на локальную переменную из функции – язык гарантирует, что она не «пропадет» (escape analysis).

Пример работы с указателями:

x := 10
p := &x                     // p имеет тип *int, указывает на x
fmt.Println("x через указатель =", *p)  // разыменуем p, получим 10

*p = 20                     // через указатель изменяем x
fmt.Println("новое значение x =", x)   // теперь x = 20

// Функция, использующая указатель для модификации аргумента
func increment(num *int) {
    *num = *num + 1  // увеличиваем значение, на которое указывает num
}

y := 5
increment(&y)
fmt.Println("y после increment =", y)  // y стал 6

В этом коде видно, что через указатель p мы можем читать и писать значение x. Функция increment принимает *int (указатель на int) и успешно меняет реальную переменную, переданную с помощью &.

Указатели часто используются:

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

Важно отметить, что встроенные типы, срезы и мапы в Go уже по своей природе «ссылочные» на данные. Например, если у вас есть срез s := []int{1,2,3}, то при передаче s в функцию копируется структура среза (содержит указатель на массив, длину и емкость), но не сами элементы. Поэтому внутри функции изменение s[0] изменит и исходный массив. Однако изменение длины s = append(s, 4) затронет только локальную копию структуры. Детали могут сперва путать, но общее правило: при передаче в функцию тратится столько же ресурсов, как передать указатель (3 машинных слова), а значит срезы/мапы нет нужды передавать как указатель на них (они и так эффективно ссылаются на данные). Их внутренние элементы – отдельный вопрос.

Go не поддерживает арифметику указателей (нельзя делать p+1 как в Си), что повышает безопасность. Также Go имеет встроенный тип unsafe.Pointer для низкоуровневых манипуляций, но начинающему программисту он не нужен.

Теперь, разобрав базовые конструкции языка, перейдем к организациям кода: как Go управляет пакетами, модулями и проектами.

4. Пакеты, модули и структура проекта

Одним из главных принципов Go является простая организация кода. Программы на Go состоят из пакетов, а для управления зависимостями и версионированием кода используются модули.

Пакеты

Пакет (package) в Go – это коллекция исходных файлов в одной директории, которые объединены под одним именем пакета. Каждый файл Go начинается с объявления package. Например, все файлы, начинающиеся с package math, принадлежат пакету math. Пакет может содержать функции, типы, переменные, константы – они видны друг другу внутри пакета.

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

Импорт пакетов. Чтобы использовать код из другого пакета, нужно его импортировать. В начале файла (после package ...) пишут:

import "<путь_к_пакету>"

Например: import "fmt" – импорт пакета форматирования. Или import "math/rand" – импорт пакета для генерации случайных чисел. Путь может быть:

  • Стандартный библиотечный (без префиксов – fmt, os, net/http и т.д.).
  • Внешний (например, github.com/user/repo/package). Такие пути отражают URL репозитория.

После импорта вы можете вызывать экспортированные (публичные) элементы пакета, то есть те, которые начинаются с заглавной буквы (Go использует простое правило видимости: идентификатор с заглавной буквы – экспортируется, доступен вне пакета; с маленькой – не экспортируется, внутреннее использование). Например, fmt.Println – функция с экспортируемым именем Println из пакета fmt.

Пример проекта с несколькими пакетами:

myapp/
   main.go            (package main)
   utils/
       mathutil.go    (package utils)

Файл mathutil.go:

package utils

// Экспортируемая функция (с заглавной буквы)
func Max(a, b int) int {
    if a > b {
        return a
    }
    return b
}

Файл main.go:

package main

import (
    "fmt"
    "myapp/utils"  // импортируем наш пакет utils
)

func main() {
    m := utils.Max(3, 7)
    fmt.Println("Максимум:", m)
}

При запуске go run . (в корне myapp) или go build, Go соберет пакет utils, затем пакет main и слинкует их в исполняемый файл. Заметьте: имя модуля (о модулях далее) проставляется в импорте, например myapp/utils.

Модули

Ранее, до 2018 года, Go использовал GOPATH для управления внешними зависимостями, и не было удобного встроенного версионирования. Теперь же применяется система Modules – она позволяет задать модуль (как единицу выпуска кода) и его зависимости с версионированием (в файлах go.mod и go.sum).

Модуль – это, по сути, репозиторий (или проект) с набором пакетов, который имеет имя (импортный путь) и версию. Модуль содержит все пакеты в своем каталоге и подкаталогах (до тех пор, пока не встретится другой go.mod).

Чтобы инициализировать новый модуль, мы выполняем go mod init имя_модуля. Обычно в качестве имени указывают либо адрес репозитория (если планируется публиковать), либо что-то вроде example.com/project или просто локальное имя. Например:

go mod init github.com/username/myapp

создаст файл go.mod:

module github.com/username/myapp

go 1.21

В нем прописано имя модуля и версия языка. Когда мы импортируем внешние пакеты, Go добавит их с версиями в этот файл, а контрольные суммы – в go.sum.

Пример использования внешнего модуля:
В коде хотим использовать стороннюю библиотеку, например, пакет для обработки строк с Github:

import "github.com/golang/example/stringutil"

При попытке сборки Go обнаружит, что этот импорт относится к внешнему модулю, и предложит запустить go get для него, либо сам при сборке выполнит получение. В go.mod появится запись с нужной версией:

require github.com/golang/example v0.0.0-... (и т.д.)

А в go.sum – контрольная сумма для удостоверения целостности. Далее, Go будет хранить загруженные модули в своем кеше ($GOPATH/pkg/mod).

Внутри проекта можно использовать go mod tidy для авто-обновления списка зависимостей: он уберет неиспользуемые и добавит недостающие (например, если вы просто вписали импорт в код, но еще не собирали).

Структура проекта. Благодаря модулям, вы можете организовывать код без привязки к GOPATH. Типичный Go-проект имеет:

  • В корне go.modgo.sum после первого получения зависимостей).
  • Один или несколько пакетов, например, пакет maincmd/ или прямо в корне) и несколько библиотечных пакетов (например, internal/ для внутреннего кода, pkg/ или просто пакеты в директориях по функциональности).
  • Каталог cmd/ часто используют, если проект предоставляет несколько утилит: например, cmd/server/main.go, cmd/worker/main.go – разные входные точки.
  • Конвенция: пакет internal – особый, Go не позволит импортировать его извне модуля (т.е. myapp/internal/utils нельзя импортировать из другого модуля). Это для инкапсуляции.
  • Тестовые файлы (*_test.go) обычно лежат рядом с кодом.

Пример: структура гипотетического веб-приложения:

myapp/
   go.mod
   go.sum
   cmd/
       myapp/
           main.go          (package main)
   internal/
       db/
           db.go           (package db)
       handlers/
           handlers.go     (package handlers)
   pkg/
       utils/
           strings.go      (package utils)
   api/
       models.go           (package api)

Здесь:

  • cmd/myapp/main.go содержит функцию main, запускающую сервер.
  • internal/db – пакет работы с базой, internal/handlers – HTTP-обработчики. Они не предназначены для использования вне myapp, поэтому лежат в internal.
  • pkg/utils – какие-то утилиты, которые, теоретически, могут быть полезны и внешним проектам (поэтому pkg, а не internal).
  • api – пакет, где определены структуры данных (модели) и, возможно, интерфейсы API, который могут использоваться другими (если myapp предоставляет SDK).

Это лишь один из стилей, не обязательный – Go не диктует жестко структуру, но рекомендуется придерживаться простоты: не вкладывать глубоко пакеты без необходимости, называть пакеты коротко и уникально. Именование пакетов обычно в единственном числе (пакет net/http – вложенный, fmt, os, strconv, database/sql и т.д.). Имена файлов .go не существенны (кроме _test, _linux, _amd64 суффиксов для специфичных целей), главное – правильный package в начале.

Сборка и запуск. После организации кода, вы можете скомпилировать или запустить проект целиком:

  • go build в корне модуля – соберет бинарник (по умолчанию названный как папка модуля или можно -o).
  • go run ./cmd/myapp – соберет и запустит конкретный исполняемый пакет.
  • go install ./... – соберет и установит все бинарники всех пакетов main, найденных рекурсивно (./… значит «все подпакеты»).

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

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

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

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

func doSomething(...) (ResultType, error)

Если во время выполнения произошло что-то непредвиденное, функция возвращает второй результат (типа error), отличный от nil (нулевого значения ошибки), а основной результат либо незначим, либо в нулевом состоянии.

Тип error – это интерфейс:

type error interface {
    Error() string
}

То есть любая структура, имеющая метод Error() string, удовлетворяет error. В стандартной библиотеке есть готовый тип errors.errorString (внутри errors пакета), который реализует error. Вы не будете напрямую с ним работать – вместо этого есть функции для создания ошибок.

  • errors.New("описание ошибки") – создает простую ошибку с заданным сообщением.
  • fmt.Errorf("формат %d: %v", code, err) – форматирует строку ошибки, может оборачивать другую ошибку (%v формат для error).
  • В Go 1.13+ появились возможности оборачивания ошибок: если вы делаете fmt.Errorf("context: %w", err), то новая ошибка содержит вложенную исходную (err). Позже, при обработке, можно использовать errors.Is и errors.As для проверки цепочки ошибок.

Паттерн обработки ошибок:

result, err := doSomething()
if err != nil {
    // обработать ошибку, либо вернуть дальше
    return ..., err   // иногда обернутая fmt.Errorf, иногда прямо
}
// продолжаем работу с result, т.к. ошибки не было

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

Пример:

func readFile(path string) ([]byte, error) {
    data, err := os.ReadFile(path)  // стандартная функция, возвращает []byte и error
    if err != nil {
        // тут можно добавить контекст к ошибке, указав путь
        return nil, fmt.Errorf("не удалось прочитать файл %s: %w", path, err)
    }
    return data, nil
}

func main() {
    content, err := readFile("notes.txt")
    if err != nil {
        // выводим ошибку и завершаем программу
        fmt.Println("Ошибка:", err)
        return
    }
    fmt.Println(string(content))
}

В функции readFile мы читаем файл. Если os.ReadFile вернула ошибку (например, файл не найден или нет прав), мы оборачиваем её в своё сообщение (через %w в fmt.Errorf – это важно, чтобы сохранить возможность извлечь исходную ошибку). Возвращаем nil и ошибку. Если всё хорошо, возвращаем данные и nil ошибку.

В main проверяем: если err != nil, печатаем и прекращаем. Если ошибки нет – используем результат.

Преимущества подхода:

  • Контроль над потоком явно виден. Нет скрытых исключений, которые могут выскочить где угодно – все возможные ошибки прописаны в типах функций.
  • Меньше сюрпризов: вы точно обрабатываете ошибку там, где можете что-то с ней сделать.
  • Можно обогащать информацию об ошибке на каждом уровне. Например, функция низкого уровня дала ошибку “connection refused”, вы можете наверху добавить “не удалось подключиться к БД: …” и так далее.

Недостаток – некоторая многословность, иногда получается много однотипных if err != nil подряд. Это правда, но со временем глаза “привыкают” и воспринимают это как нормальный контрольный код.

panic и recover: В Go есть механизм паники, но он используется редко – в ситуациях, когда продолжение работы невозможно (например, критическая несогласованность в данных, на которую нет смысла возвращать error). panic немедленно останавливает нормальное выполнение, “раскручивает” стек (но дает шанс выполнить отложенные defer-функции) и либо завершается программой (если не перехвачено), либо может быть перехвачена с помощью recover. recover() работает только внутри defer и позволяет остановить панику, получив значение ошибки. Как правило, panic используют в двух случаях: (1) фатальные, непоправимые ошибки (например, не удалось инициализировать необходимые глобальные ресурсы при старте) – тогда дают панике завершить программу, (2) при написании библиотек, где вы хотите, например, чтобы при панике внутри горутины основной поток не упал – можно поймать recover и превратить это в обычный error.

Для новичка можно посоветовать: старайтесь не использовать panic без необходимости. 99% ошибок лучше обрабатывать через error.

Пример panic/recover (для понимания):

func mayPanic() {
    panic("что-то пошло совсем не так")
}

func main() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Восстановились после паники:", r)
        }
    }()
    fmt.Println("Перед вызовом mayPanic")
    mayPanic()
    fmt.Println("После вызова (не выполнится)")
}

Здесь defer с анонимной функцией поставлен в начало main. Он выполнится при выходе из main, в том числе если случится panic. Внутри него мы вызываем recover(), которое вернет то, что было передано panic (в нашем случае строку). Если r != nil, значит была паника, и мы её обработали, не дав программе упасть. В итоге main продолжит выполнение после места, где паника произошла (но фактически, после вызова recover). Этот механизм довольно тонкий, и его применение ограничено. Обычно recover применяют, чтобы превратить падение всей программы в возвращение ошибки из горутины или сервера.

Итого по ошибкам:

  • Всегда возвращайте ошибку как последний результат, если она возможна.
  • Всегда проверяйте ошибки. Компилятор Go предупредит, если вы объявили переменную err и не используете её – это поможет не забыть обработать.
  • Для создания ошибок используйте пакет errors или fmt.Errorf. Никогда не возвращайте errors.New("...") без контекста наверх – старайтесь хотя бы в месте, где есть понимание, что делалось, обернуть ошибку, чтобы при логировании было понятно, откуда проблема.
  • Можно определять свои типы ошибок (реализуя метод Error()), чтобы отличать ситуации. Например, часто делают: var ErrNotFound = errors.New("не найдено") И возвращают этот конкретный объект в ситуациях, когда что-то не найдено. Потом вызывающий может сравнить if errors.Is(err, ErrNotFound) { ... }.
  • Помните, что error – это интерфейс, можно делать цепочки. errors.Is помогает проверять цепочки, errors.As – вытаскивать конкретный тип ошибки из цепочки.

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

6. Конкурентность в Go

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

Важно понять разницу терминов:

  • Concurrency (конкурентность) – это способ организации программы как набора независимых процессов, которые может выполняться параллельно.
  • Parallelism (параллелизм) – физическое одновременное выполнение нескольких вычислений (например, на разных ядрах CPU). Конкурентная программа не гарантирует параллелизм, но может быть выполнена параллельно, если аппаратные ресурсы позволяют.

Go изначально создавался с упором на конкурентность, чтобы упростить написание программ, эффективно работающих на мультипроцессорных системахen.wikipedia.org. Ключевой слоган, упомянутый ранее: «Не делитесь данными через память, а делитесь памятью через общение»go.dev. Это означает, что вместо того, чтобы иметь общие переменные и использовать блокировки, Go предлагает передавать сообщения (данные) между горутинами через каналы, избегая тем самым классических ошибок многопоточности (гонок данных, дедлоков).

Рассмотрим основные элементы:

Goroutines (горутины)

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

go функция(аргументы)

Это запускает функцию конкурентно, не дожидаясь её завершения. Основная программа продолжает выполнение дальше. Когда функция (запущенная как горутина) завершится, управление просто вернется планировщику – никакого явного присоединения (join) не происходит, кроме случаев, когда вы сами это организуете.

Пример:

func printNumbers(prefix string) {
    for i := 1; i <= 5; i++ {
        fmt.Println(prefix, ":", i)
        time.Sleep(100 * time.Millisecond)
    }
}

func main() {
    go printNumbers("A") // запускаем concurrent
    go printNumbers("B") // вторая горутина

    time.Sleep(1 * time.Second)
    fmt.Println("Главная функция завершена")
}

Здесь две горутины будут печатать числа с префиксами “A” и “B” вперемешку. Мы даем паузу 1s в main, чтобы они успели отработать (это грубый способ синхронизироваться; лучше использовать каналы, как мы рассмотрим ниже, но для примера сойдет). Результат может выглядеть так (порядок может отличаться):

A : 1  
B : 1  
A : 2  
B : 2  
A : 3  
B : 3  
A : 4  
B : 4  
A : 5  
B : 5  
Главная функция завершена

Горутины “A” и “B” работали одновременно. Обратите внимание: если не поставить time.Sleep в main, программа могла завершиться до того, как горутины что-либо напечатают (main отработал и программа завершилась, убив все горутины). Поэтому почти всегда нужен механизм ожидания завершения горутин – либо через каналы, либо через sync.WaitGroup, либо другие синхронизации.

Стоимость горутины: очень низкая. В отличие от системных потоков, которые тяжелые (на создание уходит время, память под стек ~1 МБ по умолчанию и т.д.), горутина стартует с крошечным стеком (несколько килобайт)go.dev, который автоматически растет по мере надобности. Создать десятки тысяч горутин – нормально для Go (особенно если многие из них большую часть времени ждут I/O). Планировщик Go M:N – распределяет M горутин на N OS-потоков (N примерно равно количеству CPU по умолчанию).

Channels (каналы)

Канал – встроенный тип для безопасной передачи данных между горутинами. Представьте канал как трубу, в которую одна горутина может отправить значение, а другая – получить. При этом канал может синхронизировать горутины: по умолчанию, отправка ждет, пока другая сторона не получит значение (unbuffered channel), что фактически выстраивает взаимодействие.

Объявление канала: make(chan Тип) создает канал для Тип. К примеру, ch := make(chan int) – канал для int. Отправка: ch <- x (положить значение x в канал). Получение: y := <- ch (вытащить значение из канала, операция блокируется, пока значение не появится). Можно также просто <- ch в выражении или в for range по каналу.

Пример:

func worker(ch chan string) {
    time.Sleep(500 * time.Millisecond)
    // Отправляем сообщение через канал
    ch <- "результат работы"
}

func main() {
    ch := make(chan string)
    go worker(ch)            // запускаем горутину-работника

    fmt.Println("Ждем результата от горутины...")
    result := <-ch           // блокируемся, пока не придет сообщение
    fmt.Println("Получено:", result)
}

Здесь main создает канал ch и запускает горутину worker(ch). Горутина, задержавшись на 0.5 секунды, отправляет строку "результат работы" через канал. В main мы делаем <-ch, что приостанавливает main-горутины до тех пор, пока какая-то другая горутина не пришлет значение. Когда worker послал, main разблокируется, получает строку в result и печатает её.

Вывод будет:

Ждем результата от горутины...
Получено: результат работы

и между строками будет пауза ~0.5 секунды.

Таким образом, каналы позволяют согласовать действия: worker завершил работу и сообщением сигналит об этом, main ждет этого сигнала.

Буферизированные каналы. По умолчанию канал без буфера – то есть отправка ждет немедленного получения. Но можно создать канал с буфером, указав второй аргумент make(chan T, N). Тогда в канал поместится до N элементов без ожидания получателя. Если буфер заполнен, отправка блокируется; если буфер пуст, получение блокируется. Буферированные каналы полезны для очередей, пулов задач и т.д.

Пример:

ch2 := make(chan int, 3)
ch2 <- 1
ch2 <- 2
ch2 <- 3
// Теперь буфер полон (емкость 3). Следующая отправка блокируется до чтения.

Закрытие канала. Когда поток данных окончен, можно закрыть канал (close(ch)), чтобы сигнлизировать получателям, что больше ничего не будет. После закрытия: чтение из канала, если буфер пуст, сразу дает zero-value типа без блока. Можно проверять, закрыт ли канал, при чтении:

v, ok := <- ch
if !ok {
    fmt.Println("канал закрыт, получено значение по умолчанию", v)
}

Здесь ok == false означает, что канал закрыт и данных нет (v тогда zero-value). for range по каналу автоматически завершается, когда канал закрыт.

Пример использования закрытия:

func producer(ch chan int) {
    for i := 0; i < 5; i++ {
        ch <- i*i
    }
    close(ch)
}

func main() {
    ch := make(chan int)
    go producer(ch)
    for val := range ch {
        fmt.Println("получено", val)
    }
    fmt.Println("канал закрыт, цикл завершен")
}

Здесь функция producer отправляет 5 чисел и закрывает канал. В main, for val := range ch будет получать значения до закрытия, после чего автоматически выйдет из цикла. Вывод:

получено 0  
получено 1  
получено 4  
получено 9  
получено 16  
канал закрыт, цикл завершен

Оператор select

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

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

select {
case msg := <-ch1:
    fmt.Println("получили", msg, "из ch1")
case x := <-ch2:
    fmt.Println("получили", x, "из ch2")
case <-time.After(1 * time.Second):
    fmt.Println("ни сообщений за 1с, таймаут")
}

Здесь select ждет или сообщения из ch1, или из ch2, или если ни один не пришел в течение 1 секунды (используя канал, возвращаемый time.After), то выполнит третий case.

select очень мощен для организации одновременного ожидания событий. Если готово сразу несколько каналов, выбирается случайный case среди готовых (чтобы избежать звездворса). Можно указать default: – который выполнится, если ни один канал не готов моментально (т.е. неблокирующий опрос). Обычно default используют редко, в случаях типа попытки отправить если кто-то слушает, иначе продолжить работу.

Пример практический:
Допустим, у нас есть две горутины-источника данных, и мы хотим читать от них, кого бысто ответит:

select {
case res1 := <- chResult1:
    fmt.Println("получен результат от первой горутины:", res1)
case res2 := <- chResult2:
    fmt.Println("получен результат от второй горутины:", res2)
}

Кто быстрее прислал – того и берем.

Мьютексы (sync.Mutex) и др. примитивы

Несмотря на то, что философия Go – по возможности избегать общих данных между горутинами, иногда проще воспользоваться классическими примитивами синхронизации. В пакете sync есть:

  • Mutex (взаимное исключение)sync.Mutex предоставляет методы .Lock() и .Unlock(). Горутина, вызвавшая Lock, эксклюзивно захватывает мьютекс, и другие, попытавшиеся Lock тот же, будут ждать Unlock. Используется для защиты разделяемых данных.
  • RWMutex – вариант мьютекса, разделяющий блокировки на читательские и писательские (RLock, RUnlock для чтения; Lock, Unlock для записи). Несколько читателей могут держать замок одновременно, но писатель – эксклюзивно.
  • WaitGroup – удобный счетчик для ожидания завершения группы горутин. Инициализируется count, потом каждая горутина вызывает Done(), а основная – Wait().
  • Once – для выполнения инициализации один раз (несмотря на многократный вызов).
  • Cond – условные переменные (notifier/monitor pattern).

Рассмотрим простой пример race condition и исправление с Mutex:

var counter = 0

func increment(wg *sync.WaitGroup, m *sync.Mutex) {
    defer wg.Done()
    for i := 0; i < 1000; i++ {
        m.Lock()
        counter++
        m.Unlock()
    }
}

func main() {
    var wg sync.WaitGroup
    var m sync.Mutex
    wg.Add(2)
    go increment(&wg, &m)
    go increment(&wg, &m)
    wg.Wait()
    fmt.Println("Counter =", counter)
}

Здесь две горутины параллельно увеличивают общий counter на 1000 каждая. Без защиты (мьютекса) почти наверняка произойдет гонка данных (race condition) – одновременное изменение памяти приведет к потере некоторых инкрементов. С мьютексом мы поочередно увеличиваем: одна горутина заблокировала, увеличила, отпустила, потом другая. В конце мы используем WaitGroup, чтобы дождаться обеих (wg.Add(2) перед запуском, wg.Done() в конце функции каждой горутины, wg.Wait() – блокируем main пока счетчик не обнулится).

Если убрать мьютекс и запустить с флагом -race (go run -race program.go), то гонка будет обнаружена: Go runtime умеет проверять такие ситуации. С мьютексом гонки не будет, и в конце counter корректно будет 2000. Без мьютекса, вероятно, меньше.

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

Примечание о runtime:
Go runtime также предоставляет планировщику функции:

  • runtime.Gosched() – уступить выполнение другой горутине (даже если текущая еще может работать).
  • runtime.GOMAXPROCS(n) – установить число OS-потоков для исполнения Go (по умолчанию равно числу логических процессоров).
  • runtime.Goexit() – завершить текущую горутину (не трогая остальные).
    Обычно в прикладном коде это редко нужно.

Важный момент:
Конкурентное программирование сложно, и хотя Go его упрощает, нужно быть внимательным. Гонки данных могут приводить к непредсказуемым багам. Используйте -race в период отладки – это отличный инструмент, встроенный в Go, для выявления конфликтов доступа к памяти.

Далее перейдем к более прикладным вещам: как в Go работать с файлами и вводом/выводом.

7. Работа с файлами и ввод/вывод

Ввод-вывод (I/O) в Go во многом строится вокруг интерфейсов io.Reader и io.Writer. Большинство функций стандартной библиотеки используют эти интерфейсы, позволяя единообразно работать с файлами, сетевыми соединениями, буферами и прочим.

Начнем с работы с файлами на диске – это в основном пакет os и io/ioutil (в Go1.16 функции ioutil перенесены в os, но старые названия остаются как обертки).

Чтение файлов

Простой способ прочитать файл целикомos.ReadFile (ранее ioutil.ReadFile). Он возвращает содержимое файла в виде байтового слайса []byte.

Пример:

data, err := os.ReadFile("example.txt")
if err != nil {
    log.Fatal(err)
}
fmt.Printf("Содержимое файла:\n%s\n", data)

Здесь весь файл загружается в память (будьте осторожны с очень большими файлами). log.Fatal(err) просто выведет ошибку и завершит программу.

Построчное чтение или чтение по частям:
Можно открыть файл через os.Open, получить дескриптор (структура *os.File), а затем читать из него. os.File реализует io.Reader, поэтому с ним можно использовать bufio.Scanner или bufio.Reader для удобства.

Пример построчного чтения:

file, err := os.Open("example.txt")
if err != nil {
    log.Fatal(err)
}
defer file.Close()

scanner := bufio.NewScanner(file)
for scanner.Scan() {
    line := scanner.Text()
    fmt.Println("Прочитали строку:", line)
}
if err := scanner.Err(); err != nil {
    log.Fatal("Ошибка чтения файла:", err)
}

Здесь bufio.NewScanner читает построчно (по умолчанию разделитель – newline). В цикле scanner.Scan() возвращает true, пока удаётся прочитать строку; scanner.Text() даёт текст строки (без символа переноса). По окончании проверяем scanner.Err() – вдруг в процессе чтения произошла ошибка, отличная от обычного EOF.

Чтение части файла:
Вместо Scanner можно читать по блокам:

buf := make([]byte, 100) // буфер на 100 байт
n, err := file.Read(buf)
if err != nil && err != io.EOF {
    log.Fatal(err)
}
fmt.Printf("прочитано %d байт: %s\n", n, string(buf[:n]))

Метод Read читает до len(buf) байт, возвращает количество и ошибку (io.EOF – не считается “ошибкой” обычно, а сигнализирует о конце файла). Обычно такой способ используется в цикле, пока не достигнут EOF.

Запись в файлы

Быстрая запись всего содержимого: os.WriteFile("out.txt", data, 0644) – пишет []byte data в файл (с правами 0644 для Unix). Создает файл или перетирает если существует.

Запись построчно/частями:
Используем os.OpenFile или os.Create:

file, err := os.Create("out.txt")
if err != nil {
    log.Fatal(err)
}
defer file.Close()

_, err = file.Write([]byte("Hello, Go!\n"))
if err != nil {
    log.Fatal(err)
}
// можно так же file.WriteString("text")

os.Create создаёт файл (если файл уже существовал, он откроется с обнулением). os.OpenFile более универсален: позволяет указать флаги (напр. O_APPEND для дозаписи, O_EXCL для “открыть только если нет” и т.д.) и права.

bufio.Writer может быть полезен для буферизированной записи (накопит и сбросит при заполнении буфера или при Flush). Но часто можно обойтись и без него, т.к. Write сам по себе иногда буферизован ОС.

Другие типы I/O

Пакет fmt также умеет писать/читать в/из io.Writer/io.Reader. Например, fmt.Fprintln(file, "строка") – печатает строку в файл.

Консольный ввод/вывод:

  • fmt.Println, fmt.Printf по умолчанию выводят в стандартный вывод (os.Stdout).
  • Для ввода можно использовать fmt.Scanln, fmt.Scanf – они читают из os.Stdin. Однако Scanln довольно примитивен и читает через пробелы. Часто удобнее использовать всё тот же bufio.Scanner: scanner := bufio.NewScanner(os.Stdin) for scanner.Scan() { text := scanner.Text() if text == "" { break } // остановимся на пустой строке, к примеру fmt.Println("Ввод:", text) } или fmt.Fscanln(os.Stdin, &var1, &var2) для форматированного ввода.

Пример: копирование из одного файла в другой

Используя io.Copy, можно легко перенаправлять потоки:

src, err := os.Open("source.dat")
if err != nil { log.Fatal(err) }
defer src.Close()
dst, err := os.Create("dest.dat")
if err != nil { log.Fatal(err) }
defer dst.Close()

bytesCopied, err := io.Copy(dst, src)
if err != nil {
    log.Fatal("Ошибка копирования:", err)
}
fmt.Printf("Скопировано %d байт\n", bytesCopied)

io.Copy читает из src и пишет в dst до EOF, возвращает количество байт и ошибку. Это очень удобный способ, т.к. поддерживает любые Reader/Writer (можно копировать из сети в файл, из файла в сжатие и т.д.).

Работа с папками, путями

Пакет os предоставляет функции:

  • os.Mkdir / os.MkdirAll – создать каталог (один или вложенные).
  • os.Remove / os.RemoveAll – удалить файл или папку рекурсивно.
  • os.Rename – переименовать/переместить файл.
  • os.Stat – получить информацию о файле (размер, права, модификация).
  • os.ReadDir (Go1.16+) – прочитать список файлов в директории, возвращает слайс os.DirEntry (там можно IsDir() и т.д.).
  • filepath.Join – удобная склейка путей (учитывает слэши ОС).
  • filepath.Glob – поиск по шаблону файлов (или use path/filepath).
  • path/filepath – пакет для работы с путями в ОС (разделители, расширения, абсолютные/относительные).

Пример: перечисление файлов:

entries, err := os.ReadDir(".")
if err != nil { log.Fatal(err) }
for _, entry := range entries {
    if entry.IsDir() {
        fmt.Println("[DIR] ", entry.Name())
    } else {
        info, _ := entry.Info()
        fmt.Printf("%s (%d bytes)\n", entry.Name(), info.Size())
    }
}

Это выведет список файлов текущей директории, отмечая папки и размер файлов.

На этом базовое знакомство с файловым вводом-выводом завершим. Далее рассмотрим, как создавать простые веб-сервисы на Go.

8. HTTP и создание простого веб-сервера

Go широко известен как отличный инструмент для веб-разработки, во многом благодаря мощному стандартному пакету net/http. Этот пакет позволяет запускать полноценные HTTP-серверы без внешних зависимостей. Разберёмся, как написать простой веб-сервер и обработчики маршрутов.

Базовый HTTP-сервер:

package main

import (
    "fmt"
    "net/http"
)

func main() {
    // Регистрируем обработчик для корневого пути "/"
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Привет, мир!")  // Пишем ответ в ResponseWriter
    })

    // Запускаем сервер на порту 8080
    fmt.Println("Сервер запущен на http://localhost:8080/")
    err := http.ListenAndServe(":8080", nil)
    if err != nil {
        fmt.Println("Ошибка сервера:", err)
    }
}

Объяснение:

  • http.HandleFunc(pattern, func) регистрирует функцию-обработчик для URL, подходящих под шаблон. Шаблон “/” означает “все запросы, начинающиеся с /” (по сути, корневой и всё вложенное, если ничего более специфичного не зарегистрировано).
  • Функция-обработчик получает параметры w http.ResponseWriter (через него мы пишем ответ клиенту) и r *http.Request (запрос клиента).
  • В примере обработчик отвечает текстом “Привет, мир!” (мы используем fmt.Fprintf для записи в w, можно также w.Write([]byte("text"))).
  • http.ListenAndServe(":8080", nil) запускает сервер на указанном адресе. nil означает, что используемый mux (маршрутизатор) – стандартный глобальный (он заполняется функциями HandleFunc и Handle). ListenAndServe заблокирует выполнение, слушая порт 8080.

Запустив эту программу, вы сможете открыть браузер на http://localhost:8080 и увидеть сообщение.

Маршрутизация и несколько обработчиков:
Можно регистрировать разные пути:

http.HandleFunc("/hello", helloHandler)
http.HandleFunc("/goodbye", goodbyeHandler)

Тогда /hello и /goodbye будут обрабатываться разными функциями.

Пример с параметром в пути:
В чистом net/http нет встроенного парсинга URL-параметров (например, /hello/{name}), это делают сторонние роутеры. Но можно вручную:

http.HandleFunc("/hello/", func(w http.ResponseWriter, r *http.Request) {
    name := strings.TrimPrefix(r.URL.Path, "/hello/")
    if name == "" {
        name = "гость"
    }
    fmt.Fprintf(w, "Привет, %s!", name)
})

Теперь запрос /hello/Max выдаст “Привет, Max!”, а /hello/ или /hello – “Привет, гость!”.

Статические файлы:
Можно легко организовать отдачу файлов (например, сайт с HTML/CSS/JS). Пакет http предлагает http.FileServer. Например, чтобы раздавать все файлы из папки ./static по URL /static/...:

fs := http.FileServer(http.Dir("./static"))
http.Handle("/static/", http.StripPrefix("/static/", fs))

http.Dir открывает файловую систему, FileServer создает обработчик, StripPrefix убирает префикс из URL, чтобы /static/index.html искалось как ./static/index.html.

Запросы и ответы:
Внутри обработчика r *http.Request даёт доступ к данным запроса:

  • r.Method – метод (“GET”, “POST”, etc.).
  • r.URL.Path – путь.
  • r.URL.Query() – словарь GET-параметров (map[string][]string), также есть r.Form (после парсинга).
  • r.Header – заголовки запроса (map[string][]string).
  • r.Bodyio.ReadCloser с телом (например, JSON от клиента).
  • r.FormValue("name") – удобный метод получить параметр (ищет и в Query, и в теле формы POST).
  • r.Cookie(name) – получить cookie.
  • r.Context() – контекст, можно использовать для отмены, дедлайнов (сервер сам отменит контекст, если клиент разорвал соединение).

Для ответа:

  • Писать тело через w.Write.
  • Установить статус: по умолчанию 200 OK, изменить w.WriteHeader(code) до записи тела. Например, w.WriteHeader(http.StatusNotFound) чтобы послать 404.
  • Заголовки ответа: w.Header().Set("Content-Type", "text/plain") и т.д., нужно вызывать до WriteHeader/Write.
  • Для JSON: советуют w.Header().Set("Content-Type","application/json") и кодировать через json.NewEncoder(w).Encode(data).

Пример ответа с JSON:

http.HandleFunc("/api/time", func(w http.ResponseWriter, r *http.Request) {
    type TimeResponse struct {
        CurrentTime string `json:"current_time"`
    }
    resp := TimeResponse{ CurrentTime: time.Now().Format(time.RFC3339) }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(resp)
})

При GET запросе на /api/time, получим JSON вида: {"current_time": "2025-11-23T14:30:00+07:00"}.

Запуск HTTPS:
Для TLS (HTTPS) есть http.ListenAndServeTLS(":443", "cert.pem", "key.pem", nil) – требует сертификат и ключ.

Graceful shutdown:
В простом ListenAndServe нет, но начиная с Go 1.8+ можно создавать сервер явно:

srv := &http.Server{Addr: ":8080"}
go srv.ListenAndServe()
// ... по сигналу или условию:
srv.Shutdown(ctx)

Shutdown плавно остановит прием новых соединений и дождется завершения текущих в течение контекста.

HTTP-клиент:
Для отправки запросов есть http.Get, http.Post, http.Client. Например:

resp, err := http.Get("https://api.example.com/data")
if err != nil { ... }
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println("Status:", resp.Status)
fmt.Println("Body:", string(body))

http.Client позволяет настраивать таймауты, редиректы, транспорт (прокси, tls).

WebSockets: В стандартной библиотеке напрямую нет, но есть golang.org/x/net/websocket (устаревший) или популярная библиотека Gorilla WebSocket.

Frameworks: Многие проекты используют сторонние маршрутизаторы (chi, gorilla/mux, httprouter), web-фреймворки (Gin, Echo, Fiber). Они облегчают работу с путями, привязкой запросов к структурам и т.п. Однако базовое понимание net/http важно, т.к. фреймворки строятся сверху него.

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

9. Работа с базами данных

В Go для взаимодействия с реляционными СУБД (MySQL, PostgreSQL, SQLite, MSSQL и др.) используется стандартный пакет database/sql. Он предоставляет общий интерфейс для SQL-баз данных; конкретная поддержка БД реализуется через драйверы (например, github.com/go-sql-driver/mysql для MySQL, github.com/lib/pq для Postgres, modernc.org/sqlite для SQLite без CGO, или github.com/mattn/go-sqlite3 с CGO).

Также есть ORM и библиотеки более высокого уровня (GORM, SQLX, etc.), но разберём базовый подход с database/sql.

Подключение к базе:

import (
    "database/sql"
    _ "github.com/mattn/go-sqlite3"  // драйвер SQLite
)
...
db, err := sql.Open("sqlite3", "example.db")
if err != nil {
    log.Fatal(err)
}
defer db.Close()

Функция sql.Open(driverName, dataSourceName) не сразу устанавливает соединение, а готовит пул. driverName должен соответствовать импортированному драйверу (обратите внимание на импорт _ "..." – это подключение пакета драйвера анонимно, чтобы его init() зарегистрировал себя). dataSourceName – строка подключения, формат зависит от драйвера. Для SQLite это просто имя файла базы (или :memory: для памяти). Для Postgres что-то вроде "host=localhost user=postgres password=... dbname=mydb sslmode=disable", или URL.

db – это объект *sql.DB – не конкретное соединение, а пул подключений. Он безопасен для конкурентного использования из разных горутин, и внутри поддерживает установление нужного количества соединений. Есть методы db.SetMaxOpenConns(n), db.SetMaxIdleConns(n), но по умолчанию все достаточно хорошо (SQLite, например, просто один файл-лок, Postgres – несколько).

Выполнение запросов:
Два основных метода:

  • db.Query(query, args...) – выполнить запрос, возвращающий строки (SELECT), возвращает *sql.Rows.
  • db.Exec(query, args...) – выполнить команду (INSERT/UPDATE/DELETE/DDL) без результатных строк, возвращает sql.Result (откуда можно получить кол-во затронутых строк или последний insert id, если поддерживается).
  • Еще db.QueryRow(query, args...) – как Query, но для одного ожидаемого результата (возвращает *sql.Row).

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

_, err = db.Exec(`CREATE TABLE IF NOT EXISTS users(id INTEGER PRIMARY KEY, name TEXT, age INT)`)
if err != nil {
    log.Fatal("Создание таблицы:", err)
}
res, err := db.Exec(`INSERT INTO users(name, age) VALUES(?, ?)`, "Alice", 30)
if err != nil {
    log.Fatal("Ошибка вставки:", err)
}
id, _ := res.LastInsertId()
count, _ := res.RowsAffected()
fmt.Printf("Добавлен пользователь с id=%d (затронуто %d строк)\n", id, count)

(В SQLite placeholder – ?, в Postgres $1, $2).

Получение данных:

rows, err := db.Query("SELECT id, name, age FROM users WHERE age > ?", 20)
if err != nil { log.Fatal(err) }
defer rows.Close()
for rows.Next() {
    var id int
    var name string
    var age int
    err = rows.Scan(&id, &name, &age)
    if err != nil { log.Fatal(err) }
    fmt.Printf("User %d: %s (%d лет)\n", id, name, age)
}
if err = rows.Err(); err != nil {
    log.Fatal("Ошибка итерации:", err)
}

rows.Next() двигает курсор построчно. Scan записывает столбцы в переданные адреса. Количество и типы target должны соответствовать колонкам запроса. Если columns можно узнать динамически: cols, _ := rows.Columns() выдаст имена колонок, но для разного кол-ва колонок, возможно, sql.RawBytes или interface{} придется использовать. Обычно struct scanning упрощают ORM или sqlx.

Если ожидается единственная строка, удобнее:

var name string
var age int
err = db.QueryRow("SELECT name, age FROM users WHERE id = ?", idParam).Scan(&name, &age)
if err == sql.ErrNoRows {
    fmt.Println("Пользователь не найден")
} else if err != nil {
    log.Fatal(err)
} else {
    fmt.Printf("Имя: %s, возраст: %d\n", name, age)
}

QueryRow не вернет ошибку, пока не вызван Scan; если ничего не найдено, Scan вернет sql.ErrNoRows.

Prepared Statements:
Если нужно многократно выполнять похожий запрос, есть db.Prepare("INSERT ...") -> возвращает *sql.Stmt, на котором можно делать .Exec(args) много раз. Это чуть быстрее, т.к. запрос парсится один раз БД. Также Stmt может быть удобно, когда один и тот же запрос используют разные функции.

Транзакции:

tx, err := db.Begin()
if err != nil { ... }
// выполнить несколько Exec/Query через tx.Query, tx.Exec
err = tx.Commit() // или tx.Rollback() при ошибке

В транзакции используйте методы объекта *sql.Tx, а не db (db.* вне транзакции не знает о ней).

Работа с NoSQL/другими хранилищами:
В стандартную библиотеку включены драйверы для SQLite (на CGO), но нет для MongoDB, Redis – для них есть отдельные пакеты (mongo-driver, go-redis). Они не через database/sql работают (кроме, например, experimental neo4j driver).

Пример: подключение к SQLite и простой запрос:

db, err := sql.Open("sqlite3", ":memory:")
if err != nil { log.Fatal(err) }
defer db.Close()
db.Exec("CREATE TABLE foo(bar TEXT)")
db.Exec("INSERT INTO foo(bar) VALUES('baz')")
var bar string
_ = db.QueryRow("SELECT bar FROM foo").Scan(&bar)
fmt.Println(bar) // baz

В этом коротком коде мы:

  • Создали in-memory SQLite DB.
  • Создали таблицу foo.
  • Добавили запись.
  • Выбрали и распечатали её.

Советы:

  • Всегда закрывайте *sql.Rows (через defer).
  • Используйте ? плейсхолдеры и параметры вместо подстановки строкой (чтобы избежать SQL-инъекций).
  • Проверяйте ошибки rows.Err() после цикла чтения.
  • Обрабатывайте sql.ErrNoRows отдельно, если отсутствие результатов не является ошибкой логики.
  • Пакет database/sql не обрабатывает типы автоматически, например, time.Time можно сканировать (поддерживается драйверами), но кастомные типы – нет. Можно реализовать интерфейсы Scanner (для чтения) и Valuer (для записи) для своих типов.
  • Для отладки SQL-логов можно использовать обертки или логеры (например, db.ExecContext(ctx) с контекстом, который содержит логгер, но проще бывает печатать самому).

Для полноты, упомянем про ORM: Если проект большой, можно посмотреть в сторону GORM, SQLX (расширение sql, позволяет Scan в структуру напрямую), ent (генератор ORM) и др. Но часто чистый database/sql бывает достаточно.

В завершение, обсудим тестирование кода Go и измерение производительности.

10. Тестирование и бенчмарки

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

Написание тестов

Тесты представляют собой функции, имеющие имя вида TestXxx (с большой буквы) и принимающие аргумент t *testing.T. Они располагаются в файлах с суффиксом _test.go в том же пакете, что и тестируемый код.

Простейший пример теста:
Допустим, у нас есть функция:

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

Напишем для неё тест в файле mathutil_test.go:

import "testing"

func TestAdd(t *testing.T) {
    sum := Add(2, 3)
    if sum != 5 {
        t.Errorf("Add(2,3) = %d; ожидалось 5", sum)
    }
}

Здесь мы вызываем функцию и проверяем условие. Если что-то не так, используем t.Errorf (или t.Fatalf если хотим сразу прекратить тест) для сообщения об ошибке. Errorf отмечает тест как проваленный, но позволяет ему продолжить (вдруг есть еще проверки, которые мы хотим провести перед выходом).

Мы могли бы также использовать t.Fail() (отметить провал) или t.FailNow() (провал и прерывание), но Errorf удобнее – форматирует сообщение.

Запуск тестов:
Выполняем go test в пакете (или в модуле go test ./... для всех пакетов). Go найдет все _test.go файлы, скомпилирует их вместе с кодом, запустит функции Test....

Если хотим видеть детали, запускаем go test -v (verbose):

=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
PASS
ok      example/mathutil    0.123s

Табличные тесты:
Часто, чтобы не дублировать код, используют технику table-driven tests – перечисляют набор входов и ожидаемых результатов и проходят по ним в цикле.

Например, тест для функции Min(a, b int) int:

func TestMin(t *testing.T) {
    tests := []struct{
        a, b int
        want int
    }{
        {1, 2, 1},
        {2, 1, 1},
        {5, 5, 5},
    }
    for _, tt := range tests {
        got := Min(tt.a, tt.b)
        if got != tt.want {
            t.Errorf("Min(%d,%d) = %d; ожидалось %d", tt.a, tt.b, got, tt.want)
        }
    }
}

Можно использовать t.Run для под-тестов с именами, чтобы каждый случай считался отдельным тестом:

for _, tt := range tests {
    t.Run(fmt.Sprintf("%d,%d", tt.a, tt.b), func(t *testing.T) {
        if got := Min(tt.a, tt.b); got != tt.want {
            t.Fatalf("...") // здесь Fatalf, т.к. в подтесте сразу можно прерывать
        }
    })
}

В выводе тогда будут видны подварианты.

Coverage (покрытие тестами):
go test -cover покажет процент покрытия кода тестами в пакете. Опция -coverprofile=cover.out и потом go tool cover -html=cover.out – откроет отчет в браузере.

Тестирование поведения, ошибок:
В тестах, помимо проверок результатов, можно проверять, не произошла ли ошибка:

if err == nil {
    t.Fatal("ожидалась ошибка, а её не было")
}

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

func TestSomethingPanics(t *testing.T) {
    defer func() {
        if r := recover(); r == nil {
            t.Errorf("ожидалась panic, но не было")
        }
    }()
    DangerousFunction()  // которая должна паниковать
}

Здесь мы отложенной функцией ловим recover(). Если она nil, значит паники не случилось, тест не прошел.

Fuzzing (фаззинг): (Go 1.18+)
Go теперь поддерживает fuzz-тесты – функции с именем FuzzXxx(f *testing.F). Они позволяют перебирать случайные входы, находя краевые случаи. Это более продвинутая тема; но знать, что она есть, полезно.

Вспомогательные библиотеки:
Часто в тестах используют пакеты со вспомогательными ассертами (например, github.com/stretchr/testify/assert), чтобы одной строкой проверять условия. Это не встроено, но популярно.

Бенчмарки

Функции-бенчмарки имеют вид BenchmarkXxx(b *testing.B). go test при флаге -bench будет их запускать.

Внутри бенчмарка цикл for i := 0; i < b.N; i++ { ... } – Go сам подбирает N (количество итераций) таким образом, чтобы тест шел достаточное время (нужна точность), обычно ~ секунды. Поэтому B.N начинается с 1 и увеличивается экспоненциально, пока не наберется хотя бы 0.1s.

Пример бенчмарка для функции Fib(n):

func BenchmarkFib10(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Fib(10)
    }
}

Если нужно оценить разные вводы:

func BenchmarkFib(b *testing.B) {
    benchmarks := []int{10, 20, 30}
    for _, n := range benchmarks {
        b.Run(fmt.Sprintf("Fib%d", n), func(b *testing.B) {
            for i := 0; i < b.N; i++ {
                Fib(n)
            }
        })
    }
}

Запуск: go test -bench=. (точка означает все бенчмарки, можно regex). Вывод, например:

goos: linux
goarch: amd64
BenchmarkFib/Fib10-8         1000000000               0.298 ns/op
BenchmarkFib/Fib20-8         200000000                5.12 ns/op
BenchmarkFib/Fib30-8         20000000                98.7 ns/op
PASS
ok   package/name   3.45s

Здесь ns/op – наносекунд на операцию (в среднем), число итераций (например 1000000000). -8 указывает, что параллельно 8 threads запустилось (обычно = GOMAXPROCS).

Можно сравнивать разные реализации, используя sub-benchmarks или разные функции Benchmark.

Дополнительные возможности:

  • b.ReportAllocs() – заставит выводить количество аллокаций на операцию (allocs/op).
  • b.StopTimer() / b.StartTimer() – остановить/запустить счетчик, чтобы исключить, например, время подготовки данных вне цикла.
  • b.ResetTimer() – сбросить счетчики (если, допустим, перед циклом вы сделали какую-то работу, и хотите, чтобы измерение шло после неё).
  • Параллельные бенчмарки: b.RunParallel(func(pb *testing.PB) { for pb.Next() { work() } }) – запустит N горутин, выполняющих work() параллельно, чтобы измерить сквозную производительность с учетом конкуренции.

Профилирование:
go test -bench=. -benchmem -cpuprofile=cpu.out -memprofile=mem.out – запустит бенчмарки, сохранит CPU и memory профили. Потом можно go tool pprof cpu.out – интерактивно смотреть, какие функции сколько CPU съели, или go tool pprof -http=:8081 cpu.out – откроет профили в веб-интерфейсе (графики, флеймграфы).

Race detector:
go test -race – запускает тесты с включенным инструментом обнаружения гонок данных.

Пример реальный:
Например, хотим проверить, что наша функция сортировки работает правильно и ее скорость. Мы пишем TestSort (с разными входами, проверяем отсортированность) и BenchmarkSort (генерируем массив случайный, b.N раз сортируем).

Окружение тестов:

  • TestMain(m *testing.M) – если присутствует, вызывается вместо обычного main тестового runner’а. Можно использовать для setup/teardown всего пакета (например, подключиться к тестовой БД).
  • Флаги: -v (verbose), -run=Regex (запуск только тестов, чьи имена подходят под Regex), -count=N (повторить тесты N раз, для ловли flakey-tests). -timeout=dur (по умолчанию 10 минут).
  • t.Skip() можно вызвать в тесте, чтобы пропустить (например, если нет необходимых условий).

Пример Skip:

func TestIntegration(t *testing.T) {
    if testing.Short() {
        t.Skip("пропущен в коротком режиме")  // go test -short активирует
    }
    // ... тест, требующий, например, соединения с БД
}

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

11. Лучшие практики, стиль кода, линтеры, форматирование

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

Форматирование кода (gofmt)

Первое, что нужно знать: весь код Go должен быть отформатирован с помощью gofmt (или эквивалент go fmt). Этот инструмент автоматически устраняет споры о стиле форматирования. Он решает, где ставить отступы, пробелы, переносы, выравнивает столбцы комментариев и т.д. Разработчики Go шутят: “красота в глазах beholder’а, а beholder у нас gofmt” 😊.

Применяется просто:

go fmt ./...

проходит по всем .go файлам в пакете/модуле и форматирует их.

При сохранении файлов в редакторах (VSCode, GoLand) обычно настроено автоматическое форматирование. Никогда не правьте отступы вручную – доверьте это инструменту. Это устранит целый класс диффов “о пробелах”.

Стиль отступов: gofmt по умолчанию использует табуляции для отступов (визуально 8-символьные табы, но редакторы обычно отображают как 4). Это сделано ради облегчения выравнивания, но на практике об этом не думаешь – просто форматируешь.

Длина строки: официально нет ограничения, но руководствуются здравым смыслом. Можно легко поддерживать до ~120 символов, хотя стандартных 80 тоже ок, но gofmt не ломает длинные строки автоматически (кроме комментариев/doc). Так что длинные выражения, возможно, вы сами разбиваете на несколько строк для читаемости.

Именование и идиомы

  • Имена экспортируемых (публичных) элементов должны быть понятными и желательно короткими. Принято использовать CapWords (PascalCase) для экспортируемых: ParseInt, UserID. Для неэкспортируемых – camelCase: parseInt, userID. Аббревиатуры внутри стараются форматировать единообразно: либо ID, URL (оба буквы капсом) либо Id, Url (Go склоняется оставить все буквы аббр. заглавными, если аббревиатура короткая: HTTP, TLS, JSON; поэтому ParseJSON а не ParseJson).
  • Пакетам дают короткие имена, обычно одно слово, малые буквы. Имя пакета используется при импорте, поэтому оно должно быть уникальным в контексте проекта. Напр. package util – плохая идея, слишком общее; лучше stringsutil или разделить по сфере.
  • Не нужно добавлять префиксы/суффиксы в имена при экспорте, если контекст понятен. Например, если у вас пакет mathutil, внутри нет смысла называть функцию MathutilAdd – достаточно Add. Имя пакета уже выступает пространством имен. При импорте mathutil.Add ясно, что это.
  • Именование интерфейсов: принято, что интерфейсы-сингл-метод заканчиваются на -er (то есть по роли): Reader, Writer, Closer, Formatter… См. стандарт lib: io.Reader (имеет метод Read). Если у интерфейса один метод, можно назвать по методу + er (метод Read -> Reader). Если методы несколько, имя описывает сущность (например, database/sql. RowScanner – выдумал, хотя в std нет, но могли бы).
  • Маленькие интерфейсы лучше, чем большие (принцип интерфейс-сегрегации). Go-принцип: интерфейс должен описывать минимально достаточный набор методов.
  • Avoid stutter: когда имя повторяется. Классический пример, названный “stutter”: package util type UtilConfig struct { ... } // при использовании util.UtilConfig – дублирование Лучше: type Config struct{} -> util.Config при использовании.
  • Нет точек с запятой: В Go не ставят ; в конце строк – лексер сам вставляет их на основе правила: если строка заканчивается токеном, который может завершать оператор (идентификатор, число, закрывающая скобка и т.д.), считается, что перед переносом строки стоит точка с запятойen.wikipedia.org. Исключение: нельзя переносить { на новую строку, иначе лексер вставит ; и синтаксис сломается. Поэтому { всегда на той же строке, что if или for. (Пример: if x {\n – нельзя переносить после x, надо if x {\n ).

Комментарии и документация

  • Документирующие комментарии: пишутся перед объявлением (функции, типа, пакета) и начинаются с названия того, что документируют. Например: // ComputeSum вычисляет сумму элементов среза. func ComputeSum(slice []int) int { ... } Такие комментарии попадают в документацию (через godoc). Важно: на русском или английском? Официальные доки – на английском. Если библиотека open-source – пишите по-английски. Для внутренних корпоративных, возможно, можно и по-русски, но лучше придерживаться английского, т.к. технические термины все равно английские и чтобы не смешивать. В нашем учебнике уместно писать на русском для пояснения, но настоящий godoc обычно на английском.
  • Комментарии к пакетам: файл doc.go с package foo в начале и большим комментарием – описывает пакет. Godoc показывает эти комментарии на главной странице пакета.
  • TODO комментарии: иногда используют // TODO: помечать, что что-то надо доделать. Многие IDE их подсвечивают.
  • Не комментируйте очевидное: Если функция называется ClearBuffer и вы в комменте напишете “ClearBuffer очищает буфер” – это дублирование без пользы. Лучше опишите нюансы: “ClearBuffer освобождает используемую память и сбрасывает указатель на начало.” – что-то, что не ясно из одной только сигнатуры.
  • Лицензии: если открытый исходник, добавляют лицензионный комментарий хедером.

Линтеры и статические анализаторы

Помимо go fmt есть go vet – утилита статического анализа, входящая в стандарт (запускается go vet ./...). Она ловит подозрительные места: бесполезные присваивания, формат строки Printf не соответствует аргументам, вызов копирования типа на себя (может, опечатка?), использование копии range переменной в горутине (типичная ловушка)go.dev, и многое другое.

go vet запускается по умолчанию при go test (с 1.12, если не ошибаюсь). Если vet находит проблему, тест завалится (даже если сами тесты прошли).

Гонщик (Race Detector): уже упоминали -race, тоже стоит относиться как к статанализатору – обязательно запускать при разработке и в CI.

golangci-lint – очень популярный meta-linter. Он объединяет десятки отдельных анализаторов (включая vet, staticcheck, stylecheck, errcheck, deadcode, varcheck…). Его удобно запускать одной командой. Настраивается через .golangci.yml. В большинстве серьезных проектов он используется для проверки pull request’ов. Примеры, что он может поймать: неиспользованные переменные/импорты (хотя их и так компилятор отсеет), неправильное форматирование импорта (есть goimports – расширение gofmt, упорядочивает блоки import), слишком сложные функции (метрика cyclomatic complexity), нарушение naming conventions, опечатки в словах, и т.д.

Прочие инструменты:

  • staticcheck – мощный анализатор (много правил, включая поиск нитей утечек, неэффективных конструкций).
  • errcheck – проверяет, что все ошибки из функций вы обрабатываете (иначе смысл).
  • revive или golint – стиль и потенциальные проблемы (golint был популярен, но сейчас депрекейтят в пользу revive).
  • megacheck – устаревшее название staticcheck suite.

Обычно golangci-lint включает staticcheck, revive, errcheck и др., поэтому достаточно его.

Code review comments: Есть официальный документ “Effective Go” и также “CodeReviewComments” (в репо Go на GitHub) – набор рекомендаций по стилю. Некоторые:

  • Не использовать названные возвращаемые параметры без необходимости, они не должны использоваться как замену документации.
  • Не злоупотреблять defer внутри циклов (накладно).
  • Стремиться к простоте, избегать “умных” однострочников, если они снижают читаемость.
  • Проверять ошибки, не оставлять игнорированными (_ = foo() если точно можно игнорить).
  • Использовать slices и maps на уровне nil (не надо инициализировать map перед использованием if you’re just reading, but to write you must init).
  • Конструкция if err := do(); err != nil { return err } – обычная вещь, не нужно оборачивать в else, если дальше весь код – внутри if. Линтеры могут ругнуться на это (“indent-error-flow”: предпочтительно обрабатывать ошибку и выйти, а основной код писать без лишнего вложенного уровня).
  • Документировать экспортируемые символы.

Автоматизация:

  • go fmt, go vet можно добавить в git pre-commit hooks.
  • CI: запускать go test -race -cover, golangci-lint run.
  • Модернизация: go имеет go fix (для автоисправления старого кода, если синтаксис поменялся, но это редко, так как Go1 гарантия стабильности).

Модульность и версия:

  • Поддерживать go.mod аккуратно, указывать минимальные требуемые версии зависимостей.
  • Если публикуете модуль, следовать семантическому версионированию (v1, v2…; для v2+ нужно изменить имя модуля с суффиксом /v2).

Комьюнити:
Go-сообщество поддерживает единый стиль. Лучше написать просто и ясно, чем хитро и умно, но непонятно другим. В Go-коде ценят читаемость.

Пример стиля:

// Плохо:
if err != nil { panic(err) }

// Лучше:
if err != nil {
    return fmt.Errorf("не смог выполнить: %w", err)
}

(только паниковать в исключительных случаях, как упоминалось)

Пример именования:

// Плохо:
type XStruct struct {}

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

(лишнее “Struct” не добавляет смысла; имя типа и так явно структура)

Пример пробелов:

// Плохо (нет пробела вокруг операторов):
x:=a* b+c>>1

// gofmt исправит на:
x := a * b + c >> 1

Пример отступов:

// Плохо (сложное выравнивание вручную):
if condition {
        doSomething()
            doAnother()
}

// gofmt:
if condition {
    doSomething()
    doAnother()
}

То есть доверьтесь инструментам.

Рекомендации по коду

  • Разбивайте код на функции по задачам. Длинные функции (>100 строк) – повод подумать о рефакторинге.
  • Избегайте глобальных переменных, по возможности. Лучше передавать контекст или использовать структуры с полями, или встроенный context.Context для параметров исполнения (например, timeouts, cancellation).
  • Конкурентность: пользуйтесь mуtex’ами правильно, либо каналами – но убедитесь, что понимаете, где возможны гонки. Race detector – ваш друг.
  • Не используйте runtime.Goexit или os.Exit в библиотечном коде – пусть ошибки возвращаются, а уже main решит, нужно ли завершать процесс.
  • Use context: В долгих операциях (I/O, запросы к БД, сетевые) делайте функции с параметром ctx context.Context. Он позволяет отменить операции по требованию извне. Стандартные пакеты (http, sql) поддерживают ctx. Например, db.QueryContext(ctx, ...).
  • Profiling/Tracing: Знать, что net/http и database/sql и др. имеют встроенную поддержку trace (via net/http/pprof, OpenTelemetry, etc.), но это не для новичка, просто имейте в виду.

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

12. Мини-проекты для практики

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

  • TODO-лист (список дел) – консольное приложение. Можно реализовать программу, которая ведет список задач: добавляет, отмечает выполненными, удаляет, сохраняет между запусками. Здесь вы попрактикуетесь с:
    • Обработкой ввода из консоли (например, с помощью bufio.Scanner).
    • Хранением данных в срезах или списках.
    • Чтением и записью в файл (например, хранить задачи в JSON-файле или простом тексте).
    • Разбиением программы на файлы/пакеты (например, пакет models для структур Task, пакет persistence для функций сохранения/загрузки).
    • (Дополнительно) Флагами командной строки: пакет flag позволяет обрабатывать аргументы. Например, todo -add "Купить молоко".
  • Простой HTTP API-сервер (сервер заметок). Создайте веб-сервер, который позволяет через HTTP запросы создавать, получать, удалять, обновлять, например, заметки или задачи:
    • GET /notes -> список заметок (можно вернуть JSON массив).
    • POST /notes с JSON телом -> создать новую заметку.
    • GET /notes/{id} -> вернуть конкретную.
    • PUT /notes/{id} -> обновить.
    • DELETE /notes/{id} -> удалить.
    Можно хранить данные просто в памяти (срез структур). Если хотите сохранение – записывать в файл JSON при изменениях. Тут практика:
    • Работа с net/http, маршрутизация. Возможно, стоит использовать стороннюю библиотеку роутинга для удобства (например, Gorilla Mux).
    • JSON (пакет encoding/json): маршалинг и анмаршалинг (преобразование структур Go <-> JSON).
    • Обработка HTTP методов, статусов (201 Created, 404 Not Found, 400 Bad Request и т.д.).
    • Конкурентность: если ваш сервер будет модифицировать общую структуру заметок из нескольких горутин (HTTP запросы обрабатываются параллельно), нужно подумать о блокировках или использовать потокобезопасные структуры (в простейшем случае можно защищать срез мьютексом или каналом для сериализации доступа).
    • Тестирование: Можно написать тесты, которые поднимают сервер (например, с httptest.NewServer) и отправляют HTTP запросы, проверяя ответы.
  • CLI утилита для копирования файлов (упрощенный cp). Реализовать программу, которая копирует файл А в файл Б (возможно, рекурсивно каталог). Практика:
    • Чтение аргументов (источник, приемник).
    • Использование os.Stat для проверки типа (файл или директория).
    • Если директория – рекурсивно создавать структуру. (Пакет path/filepath поможет обойти файловую систему: filepath.WalkDir).
    • Копирование данных: использовать io.Copy для копирования файловых содержимых, это очень упростит задачу.
    • Обработка ошибок (например, права доступа, файл не найден).
    • Вывод прогресса (опционально): можно вывести, сколько файлов скопировано, или индикатор.
  • Приложение “Чат” (консольный или веб). Например, простой консольный чат-сервер: одна программа сервер, другие – клиенты. Сервер принимает соединения (TCP, net пакет), ретранслирует сообщения всем подключенным. Практика:
    • Работа с сокетами (TCP listen, accept, handle).
    • Горутины для обслуживания каждого клиента.
    • Каналы или мьютекс для рассылки сообщений всем.
    • Клиентская часть: подключается к серверу, читает ввод с консоли и отправляет, и параллельно выводит, что пришло от сервера (две горутины: одна слушает os.Stdin, другая сокет).
  • Парсер CSV и статистика. Например, есть CSV файл с данными (скажем, продажи: дата, товар, сумма). Нужно прочитать, проанализировать (посчитать сумму продаж по товарам или среднее). Практика:
    • Работа с encoding/csv для чтения.
    • Использование strconv для преобразования чисел из строк.
    • Сохранение результатов в map[string]float64 (по товару сумму).
    • Форматированный вывод таблицы (можно научиться выравнивать столбцы, либо просто вывести CSV).
  • Игра “Угадай число”. Компьютер загадывает число, игрок пытается угадать, программа подсказывает “больше/меньше”. Практика:
    • Генерация случайных чисел (пакет math/rand).
    • Цикл взаимодействия с пользователем.
    • Конверсия ввода в число (strconv.Atoi).
    • Простая логика, но можно оформить ее в функции и написать тесты (например, функция CompareGuess(secret, guess) -> int/enum, тестируется легко).
  • Веб-скрейпер. Программа, которая загружает веб-страницу по URL и извлекает что-то (например, все ссылки). Практика:
    • HTTP GET запросы (net/http клиент).
    • Парсинг HTML – пакет golang.org/x/net/html или github.com/PuerkitoBio/goquery (второй проще, подобен jQuery).
    • Работа с URL (стандартный net/url для парсинга и разрешения относительных).
    • Конкурентность: можно распараллелить загрузку нескольких страниц.
    • Сбор результатов, сохранение, например, в файл.

Выбирайте проект по душе. Главное – начать писать код. По ходу столкнетесь с вопросами, и это нормально: обращайтесь к документации (на pkg.go.dev есть документация стандартных пакетов и популярных внешних). Гуглите ошибки и примеры – сообщество Go очень активное, на StackOverflow много решений.

Ещё пара советов под конец:

  • Читайте исходный код стандартной библиотеки. Он очень качественный и зачастую простой. Например, посмотрите, как реализованы strings.Contains или bytes.Buffer.
  • Изучите “A Tour of Go” (tour.golang.org, есть русский перевод) – это интерактивное введение, покрывающее синтаксис и особенности.
  • Читайте книгу “Effective Go”go.dev – она хоть и старая, но многие идеи по стилю кода там объяснены.
  • Практика, практика и еще раз практика. Решите несколько задач на CodinGame, LeetCode или Rosetta Code с помощью Go, чтобы привыкнуть к синтаксису.

Поздравляем, вы прошли насыщенный курс по основам Go! Теперь у вас есть общее представление об этом языке – от истории и установки до написания веб-серверов и тестов. Впереди – огромный простор для совершенствования: горутины и каналы можно применять для построения сложных конкурентных систем, с помощью Go можно писать микросервисы, CLI-приложения, и даже мобильные и веб-ассемблер приложения (через transpiler).

Удачи в вашем пути изучения Go – пусть ваши программы компилируются без ошибок, горутины работают без гонок, а код проходит все тесты с первого раза! 😉

Источники и ссылки:

  • Официальный сайт Go: https://go.dev (здесь можно найти документацию, “Tour of Go”, блог, спецификацию языка).
  • Книга “The Go Programming Language” – Брайан Керниган, Аллан Донован (детально и понятно о Go, включая примеры).
  • “Effective Go” – советы по стилю кодаgo.dev.
  • Раздел о конкурентности в Effective Gogo.devgo.dev – объясняет философию “общение вместо памяти”.
  • Документация пакета net/http – для более глубокого понимания веб-серверовgobyexample.comgobyexample.com.
  • Официальный блог Go, статьи:
    • “Error handling in Go” (Боб Пайк) – про подход к ошибкам.
    • “Go Concurrency Patterns” – про использование горутин и каналов на реальных примерах.
  • Сообщество: форум golangbridge, субреддит r/golang, множество чатов в Slack/Telegram.

Продолжайте практиковаться, и вскоре вы сможете назвать себя Go-разработчиком! Успехов в кодинге на Go!

+1
1
+1
2
+1
0
+1
0
+1
0

Ответить

Ваш адрес email не будет опубликован. Обязательные поля помечены *