Модуль 2. Базовые понятия языка

Цели модуля

После модуля студент сможет:

  • Объявлять переменные и константы с правильными типами
  • Использовать управляющие конструкции (if, for, switch)
  • Писать функции с множественными возвращаемыми значениями
  • Работать со слайсами и мапами, понимать их внутреннее устройство
  • Проектировать структуры с методами и конструкторами
  • Использовать embed для встраивания файлов в бинарник
  • Понимать разницу между value и pointer semantics

2.1 Переменные, константы, типы

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

В Go есть несколько способов объявить переменную:

go
// Явное объявление с типом
var name string = "repo-service"

// Объявление с выводом типа
var count = 42

// Короткое объявление (только внутри функций)
id := "repo-123"

// Множественное объявление
var (
    host = "localhost"
    port = 5432
    ssl  = true
)

Zero values

Каждый тип в Go имеет zero value - значение по умолчанию:

text
int, float64  -> 0
string        -> ""
bool          -> false
pointer       -> nil
slice         -> nil
map           -> nil
interface     -> nil

Zero values делают код предсказуемым: переменная всегда инициализирована.

Константы

Константы объявляются через const. Значение должно быть известно на этапе компиляции.

go
const MaxRetries = 3
const DefaultTimeout = 30 * time.Second

iota для перечислений

iota - автоинкрементный счётчик для const-блоков. Используется для создания перечислений (enum).

go
type Status int

const (
    StatusActive Status = iota   // 0
    StatusArchived               // 1
    StatusDeleted                // 2
)

Основные типы

КатегорияТипы
Целыеint, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64
Дробныеfloat32, float64
Строкиstring
Булевыbool
Композитныеarray, slice, map, struct
Специальныеpointer, function, interface, channel

Приведение типов

В Go нет неявного приведения типов. Все преобразования явные:

go
var i int = 42
var f float64 = float64(i)
var u uint = uint(f)

// Ошибка компиляции:
// var x float64 = i  // cannot use i (type int) as type float64

2.2 Управляющие конструкции

if / else

В Go условие не требует скобок. Можно объявлять переменную перед условием (короткий scope):

go
if err != nil {
    return err
}

// Переменная count видна только внутри if/else
if count := len(items); count > 10 {
    fmt.Println("too many items")
} else if count > 0 {
    fmt.Println("some items")
} else {
    fmt.Println("no items")
}

for - единственный цикл в Go

В Go есть только один цикл - for. Он заменяет for, while, do-while.

go
// Классический for
for i := 0; i < 10; i++ {
    fmt.Println(i)
}

// Аналог while
count := 0
for count < 10 {
    count++
}

// Бесконечный цикл
for {
    // ...
    break
}

// Range по слайсу
items := []string{"a", "b", "c"}
for index, value := range items {
    fmt.Println(index, value)
}

// Range по мапе
m := map[string]int{"a": 1, "b": 2}
for key, value := range m {
    fmt.Println(key, value)
}

switch

switch в Go не требует break. Каждый case завершается автоматически.

go
switch status {
case StatusActive:
    fmt.Println("active")
case StatusArchived:
    fmt.Println("archived")
default:
    fmt.Println("unknown")
}

switch без условия заменяет цепочку if/else:

go
switch {
case count > 100:
    fmt.Println("large")
case count > 10:
    fmt.Println("medium")
default:
    fmt.Println("small")
}

defer

defer откладывает выполнение функции до выхода из текущей. Выполняются в порядке LIFO (последний добавленный - первый выполненный).

go
func readFile(path string) ([]byte, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    defer f.Close()  // закроется при любом выходе из функции
	return io.ReadAll(f)
}

Типичные использования defer:

  • Закрытие файлов и соединений
  • Разблокировка мьютексов
  • Откат транзакций
  • Восстановление после panic

2.3 Функции и множественные возвращаемые значения

Базовый синтаксис

go
func add(a, b int) int {
    return a + b
}

Множественные возвращаемые значения

Go функции могут возвращать несколько значений. Это основа паттерна обработки ошибок.

go
func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

// Использование
result, err := divide(10, 2)
if err != nil {
    log.Fatal(err)
}

Именованные возвращаемые значения

go
func split(sum int) (x, y int) {
    x = sum * 4 / 9
    y = sum - x
    return  // возвращает x и y автоматически
}

Используйте с осторожностью: ухудшают читаемость при длинных функциях.

Функции как значения

Функции в Go - first-class citizens. Их можно передавать, возвращать, присваивать переменным.

go
func apply(items []int, fn func(int) int) []int {
    result := make([]int, len(items))
    for i, item := range items {
        result[i] = fn(item)
    }
    return result
}

// Использование
doubled := apply([]int{1, 2, 3}, func(x int) int {
    return x * 2
})

Variadic functions

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

sum(1, 2, 3)       // 6
sum([]int{1, 2}...) // распаковка слайса

2.4 Массивы, слайсы, мапы

Массивы (используются редко)

Массив имеет фиксированный размер, заданный на этапе компиляции.

go
var arr [3]int = [3]int{1, 2, 3}
var arr2 = [...]int{1, 2, 3}  // размер выводится

Массивы передаются по значению (копируются). В Go их используют крайне редко - обычно работают со слайсами.

Слайсы (основная структура данных)

Слайс - динамический массив. Состоит из трёх компонентов:

  • pointer на базовый массив
  • length (текущая длина)
  • capacity (ёмкость)
go
// Создание
items := []int{1, 2, 3}
items := make([]int, 0, 10)  // len=0, cap=10

// Добавление
items = append(items, 4)

// Срез (не создаёт новый массив, использует тот же базовый)
sub := items[1:3]

// Копирование (создаёт новый слайс)
copied := make([]int, len(items))
copy(copied, items)

Внутреннее устройство слайса

go
items := make([]int, 2, 4)

//items -> [pointer, len=2, cap=4]
//             |
//             v
//         [0, 0, _, _]  // базовый массив

При append если capacity недостаточно, выделяется новый массив с увеличенной ёмкостью (обычно x2).

Частые ошибки со слайсами

Ошибка 1: Неожиданное разделение памяти

go
original := []int{1, 2, 3, 4, 5}
sub := original[1:3]  // sub и original делят базовый массив
sub[0] = 99           // изменяет original[1]

Ошибка 2: Утечка памяти

go
func getFirst10(huge []byte) []byte {
    return huge[:10]  // держит ссылку на весь huge массив
}

// Правильно: копировать
func getFirst10(huge []byte) []byte {
    result := make([]byte, 10)
    copy(result, huge[:10])
    return result
}

Мапы

Мапа - хеш-таблица с ключами и значениями.

go
// Создание
m := make(map[string]int)
m := map[string]int{"a": 1, "b": 2}

// Операции
m["key"] = 42           // запись
value := m["key"]       // чтение (вернёт 0 если нет)
value, ok := m["key"]   // проверка существования
delete(m, "key")        // удаление
len(m)                  // количество элементов

Проверка существования ключа

go
value, exists := m["key"]
if !exists {
    // ключа нет
}

Итерация по мапе

Порядок итерации по мапе не определён (случайный намеренно).

go
for key, value := range m {
    fmt.Println(key, value)
}

Мапы не потокобезопасны

Одновременная запись в мапу из нескольких горутин вызывает panic. Для конкурентного доступа используйте sync.Mutex или sync.Map.


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

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

go
type Repo struct {
    ID        string
    Name      string
    OwnerID   string
    CreatedAt time.Time
}

Создание экземпляров

go
// Поля по имени
repo := Repo{
    ID:   "repo-123",
    Name: "my-project",
}

// По порядку полей (хрупко, не рекомендуется)
repo := Repo{"repo-123", "my-project", "user-1", time.Now()}

// Указатель на структуру
repo := &Repo{
    ID:   "repo-123",
    Name: "my-project",
}

Методы

Метод - функция с получателем (receiver).

go
// Value receiver - получает копию
func (r Repo) FullName() string {
    return r.OwnerID + "/" + r.Name
}

// Pointer receiver - может изменять оригинал
func (r *Repo) Rename(newName string) {
    r.Name = newName
}

Value vs Pointer receiver

КритерийValue receiverPointer receiver
ИзменениеНе изменяет оригиналМожет изменять
КопированиеКопирует структуруНе копирует
Когда использоватьМаленькие неизменяемыеБольшие или изменяемые
nil-safetyНе может быть nilМожет быть nil

Правило: если хотя бы один метод требует pointer receiver, все методы должны быть pointer receiver (консистентность).

Пример из шаблона: internal/models/repo.go

go
package models

import (
    "time"
)

type Repo struct {
    ID        string    `json:"id" db:"id"`
    Name      string    `json:"name" db:"name"`
    OwnerID   string    `json:"owner_id" db:"owner_id"`
    CreatedAt time.Time `json:"created_at" db:"created_at"`
    UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}

func NewRepo(name, ownerID string) *Repo {
    now := time.Now()
    return &Repo{
        ID:        generateID(),
        Name:      name,
        OwnerID:   ownerID,
        CreatedAt: now,
        UpdatedAt: now,
    }
}

func (r *Repo) Validate() error {
    if r.Name == "" {
        return errors.New("repo name is required")
    }
    if len(r.Name) < 3 || len(r.Name) > 100 {
        return errors.New("repo name must be 3-100 characters")
    }
    return nil
}

Теги структур

Теги (tags) - метаданные для reflection. Используются для:

  • JSON сериализации: json:"name"
  • БД маппинга: db:"name"
  • Валидации: validate:"required,min=3"
go
type User struct {
    ID    string `json:"id" db:"id"`
    Email string `json:"email" db:"email" validate:"required,email"`
    Name  string `json:"name" db:"name" validate:"max=255"`
}

2.6 Embed - встраивание файлов

Директива go:embed

embed позволяет включить файлы в бинарник на этапе компиляции. Это устраняет зависимость от файловой системы в runtime.

go
package config

import "embed"

// Встроить один файл
//go:embed templates/default.yaml
var defaultConfig string

// Встроить в []byte
//go:embed static/logo.png
var logoBytes []byte

// Встроить директорию
//go:embed static/*
var staticFiles embed.FS

Пример из шаблона: миграции в бинарнике

go
package migrations

import "embed"

//go:embed *.sql
var MigrationFiles embed.FS

func GetMigration(name string) (string, error) {
    data, err := MigrationFiles.ReadFile(name)
    if err != nil {
        return "", err
    }
    return string(data), nil
}

Когда использовать embed

  • Конфигурационные файлы по умолчанию
  • SQL миграции
  • Шаблоны
  • Статические файлы (для небольших проектов)
  • OpenAPI спецификации

Ограничения embed

  • Файлы должны быть в той же директории или поддиректории
  • Нельзя встроить файлы из вышестоящих директорий
  • Нельзя встроить скрытые файлы (начинающиеся с точки)
  • Размер файлов влияет на размер бинарника

Привязка к шаблону проекта

Структура internal/models/ в шаблоне

text
internal/models/
├── repo.go              # Модель репозитория
├── user.go              # Модель пользователя
├── pull_request.go      # Модель pull request
├── outbox_event.go      # Событие для outbox pattern
├── saga_instance.go     # Инстанс саги
└── idempotency_key.go   # Ключ идемпотентности

Что изучаем на примере шаблона

  1. Модели - структуры с тегами json и db
  2. Конструкторы - функции NewRepo(), NewUser()
  3. Валидация - метод Validate() на модели
  4. Zero values - как используются в коде
  5. Time handling - time.Time с тегами сериализации

Задание: изучите internal/models/repo.go

  • Какие поля есть у модели Repo?
  • Какие теги используются и зачем?
  • Есть ли конструктор? Какой у него паттерн?
  • Есть ли метод Validate? Какие правила проверяет?

💱 Компромиссы

  • Срез vs копия: sub := original[1:3] не аллоцирует память, но делит базовый массив — sub[0] = 99 изменяет original; copy изолирует данные, но платит новой аллокацией.
  • Ёмкость заранее vs рост через append: make([]int, 0, n) убирает реаллокации при известном размере, но завышенный n держит память впустую; чистый append экономен в коде и платит копированием при каждом удвоении.
  • map vs sync.Map: обычная мапа быстрее и проще, но паникует при конкурентной записи и требует внешнего sync.Mutex; sync.Map безопасна из коробки, но выигрывает только на редких обновлениях.
  • Zero values vs конструктор: var repo Repo всегда инициализирован, но ноль не отличим от «не задано»; NewRepo() делает состояние явным ценой дополнительного кода и вызова.
  • go:embed vs файлы на диске: встраивание миграций и конфигов в бинарник убирает зависимость от файловой системы в runtime, но размер бинарника растёт, а любое изменение файла требует пересборки.

Практическое задание

Задание 1: Создание модели Repo

Создайте файл internal/models/repo.go:

go
package models

import (
    "errors"
    "time"
)

type Repo struct {
    ID        string    `json:"id" db:"id"`
    Name      string    `json:"name" db:"name"`
    OwnerID   string    `json:"owner_id" db:"owner_id"`
    IsPrivate bool      `json:"is_private" db:"is_private"`
    CreatedAt time.Time `json:"created_at" db:"created_at"`
    UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}

func NewRepo(name, ownerID string, isPrivate bool) *Repo {
    now := time.Now()
    return &Repo{
        ID:        generateID(),
        Name:      name,
        OwnerID:   ownerID,
        IsPrivate: isPrivate,
        CreatedAt: now,
        UpdatedAt: now,
    }
}

func (r *Repo) Validate() error {
    if r.Name == "" {
        return errors.New("repo name is required")
    }
    if len(r.Name) < 3 || len(r.Name) > 100 {
        return errors.New("repo name must be 3-100 characters")
    }
    if r.OwnerID == "" {
        return errors.New("owner id is required")
    }
    return nil
}

Задание 2: Создание модели User

Создайте файл internal/models/user.go с полями:

  • ID string
  • Email string
  • Name string
  • CreatedAt time.Time

Добавьте конструктор NewUser() и метод Validate().

Задание 3: Работа со слайсами и мапами

Напишите функцию для работы с коллекцией репозиториев:

go
// FilterRepos фильтрует репозитории по владельцу
func FilterRepos(repos []*Repo, ownerID string) []*Repo

// CountReposByOwner считает репозитории по владельцам
func CountReposByOwner(repos []*Repo) map[string]int

// SortReposByName сортирует репозитории по имени
func SortReposByName(repos []*Repo)

Задание 4: Embed

Создайте файл с конфигурацией по умолчанию и встройте его через go:embed. Напишите функцию которая возвращает конфиг.

Задание 5: Юнит-тесты

Напишите table-driven тесты для:

  • NewRepo() - проверка инициализации полей
  • Validate() - проверка валидных и невалидных случаев
  • FilterRepos() - проверка фильтрации

Quiz "Проверь себя"

1. Что происходит при превышении capacity слайса в append?
2. Что такое iota?
3. Что выведет этот код?
go
m := map[string]int{}
fmt.Println(m["key"])
4. Как проверить, существует ли ключ в мапе?
go
m := map[string]int{"a": 1}
5. Что верно про value receiver и pointer receiver? — выберите все подходящие
6. В чём разница между var x int и x := 0?
7. Как встроить файл в бинарник через go:embed?
8. Что выведет этот код?
go
const (
    A = iota
    B
    C
)
fmt.Println(B)

✅ Итоги модуля

Студент должен:

  • Объявлять переменные и константы разными способами
  • Использовать if, for, switch, defer в коде
  • Писать функции с множественными возвращаемыми значениями
  • Работать со слайсами и мапами, понимать их устройство
  • Проектировать структуры с методами и конструкторами
  • Использовать embed для встраивания файлов
  • Объяснить разницу между value и pointer receiver
  • Написать модели Repo и User с валидацией
  • Ответить на все вопросы Quiz

🔗 Мостик к Модулю 3

Следующий модуль: Модуль 3. Указатели, интерфейсы и композиция - семантика указателей, интерфейсы, generics, встраивание, functional options. Привязка к internal/repository/ шаблона.