Модуль 3. Указатели, интерфейсы и композиция
Цели модуля
После модуля студент сможет:
- Понимать семантику указателей и когда их использовать
- Выбирать между value и pointer receiver осознанно
- Проектировать интерфейсы и понимать duck typing
- Использовать generics (type parameters) для типобезопасного кода
- Применять встраивание и композицию вместо наследования
- Реализовывать functional options pattern для конфигурации
- Понимать Dependency Inversion на примере шаблона проекта
3.1 Семантика указателей
Что такое указатель
Указатель хранит адрес переменной в памяти, а не её значение.
govar x int = 42 var p *int = &x // p хранит адрес переменной x fmt.Println(x) // 42 fmt.Println(p) // 0xc0000a (адрес в памяти) fmt.Println(*p) // 42 (разыменование) *p = 100 // изменяем x через указатель fmt.Println(x) // 100
Операторы
| Оператор | Назначение | Пример |
|---|---|---|
| & | Взять адрес переменной | p := &x |
| * | Разыменовать указатель | value := *p |
| * в типе | Объявить указатель | var p *int |
Когда использовать указатели
Используйте указатели когда:
- Нужно изменить оригинал (функция меняет данные)
- Структура большая (избегаем копирования)
- Нужно передать nil (опциональность)
- Работа с ресурсами (файлы, соединения)
Используйте значения когда:
- Небольшие неизменяемые структуры
- Примитивные типы (int, string, bool)
- Нужна изоляция (копия безопаснее)
Передача в функции
go// Передача по значению - функция получает копию func increment(n int) { n++ // не влияет на оригинал } // Передача по указателю - функция работает с оригиналом func incrementPtr(n *int) { *n++ // изменяет оригинал } x := 5 increment(x) fmt.Println(x) // 5 incrementPtr(&x) fmt.Println(x) // 6
Nil указатели
Указатель может быть nil. Разыменование nil указателя вызывает panic.
govar p *int // nil указатель fmt.Println(p == nil) // true // PANIC: // fmt.Println(*p) // runtime error: invalid memory address // Правильно: проверка перед разыменованием if p != nil { fmt.Println(*p) }
Указатели на структуры
gotype Repo struct { ID string Name string } // Создание через new (выделяет память, возвращает указатель) repo := new(Repo) // Более идиоматично: &Repo{} repo := &Repo{ ID: "repo-123", Name: "my-project", } // Доступ к полям через указатель (автоматическое разыменование) fmt.Println(repo.Name) // не нужно (*repo).Name
3.2 Value vs Pointer receivers
Value receiver
Метод получает копию структуры. Изменения не сохраняются.
gotype Counter struct { count int } func (c Counter) Increment() { c.count++ // изменяет копию, не оригинал } counter := Counter{} counter.Increment() fmt.Println(counter.count) // 0! Не изменилось
Pointer receiver
Метод получает указатель. Изменения сохраняются в оригинале.
gotype Counter struct { count int } func (c *Counter) Increment() { c.count++ // изменяет оригинал } counter := Counter{} counter.Increment() fmt.Println(counter.count) // 1
Правила выбора
| Критерий | Value receiver | Pointer receiver |
|---|---|---|
| Изменение состояния | Не нужно | Нужно |
| Размер структуры | Маленькая (< 64 байт) | Большая |
| Nil-safety | Не нужен | Нужен (метод может проверить nil) |
| Консистентность | Если все методы value | Если хотя бы один pointer |
| Интерфейсы | Если не нужен pointer | Если нужен pointer |
Главное правило: если хотя бы один метод типа требует pointer receiver, все методы должны быть pointer receiver. Это обеспечивает консистентность.
Пример из шаблона: internal/models/repo.go
go// Value receiver - не изменяет структуру func (r Repo) FullName() string { return r.OwnerID + "/" + r.Name } // Pointer receiver - изменяет структуру func (r *Repo) Rename(newName string) { r.Name = newName r.UpdatedAt = time.Now() }
Частая ошибка: смешивание receivers
gotype Service struct { cache map[string]string } // ОШИБКА: value receiver не может изменить map в оригинале func (s Service) Set(key, value string) { s.cache[key] = value // работает, но неочевидно } // ПРАВИЛЬНО: pointer receiver для консистентности func (s *Service) Set(key, value string) { s.cache[key] = value }
3.3 Интерфейсы в Go
Что такое интерфейс
Интерфейс определяет набор методов. Любой тип, реализующий эти методы, автоматически удовлетворяет интерфейсу.
Это называется duck typing: "Если это выглядит как утка, плавает как утка и крякает как утка - это утка."
gotype Reader interface { Read(p []byte) (n int, err error) } // os.File автоматически удовлетворяет Reader // потому что у него есть метод Read
Объявление интерфейсов
gotype RepoRepository interface { Create(ctx context.Context, repo *models.Repo) error GetByID(ctx context.Context, id string) (*models.Repo, error) Update(ctx context.Context, repo *models.Repo) error Delete(ctx context.Context, id string) error List(ctx context.Context, filter RepoFilter) ([]*models.Repo, error) }
Неявная реализация
В Go нет ключевого слова implements. Тип удовлетворяет интерфейсу автоматически если реализует все его методы.
go// PostgresRepoRepository удовлетворяет RepoRepository // автоматически, без явного объявления type PostgresRepoRepository struct { db *pgxpool.Pool } func (r *PostgresRepoRepository) Create(ctx context.Context, repo *models.Repo) error { // реализация } func (r *PostgresRepoRepository) GetByID(ctx context.Context, id string) (*models.Repo, error) { // реализация } // ... остальные методы
Пример из шаблона: internal/repository/
go// internal/repository/repo_repository.go package repository type RepoRepository interface { Create(ctx context.Context, repo *models.Repo) error GetByID(ctx context.Context, id string) (*models.Repo, error) Update(ctx context.Context, repo *models.Repo) error Delete(ctx context.Context, id string) error } // internal/repository/user_repository.go package repository type UserRepository interface { Create(ctx context.Context, user *models.User) error GetByEmail(ctx context.Context, email string) (*models.User, error) } // internal/repository/storage_repository.go package repository type StorageLayer interface { Write(ctx context.Context, path string, data []byte) error Read(ctx context.Context, path string) ([]byte, error) Delete(ctx context.Context, path string) error }
Интерфейсы как параметры функций
Интерфейсы позволяют писать функции, работающие с разными типами.
gofunc PrintRepoInfo(r RepoRepository, id string) { repo, err := r.GetByID(context.Background(), id) if err != nil { log.Fatal(err) } fmt.Printf("Repo: %s\n", repo.Name) } // Работает с любой реализацией RepoRepository: // - PostgresRepoRepository // - MockRepoRepository (в тестах) // - InMemoryRepoRepository (для разработки)
Пустой интерфейс и any
Пустой интерфейс не имеет методов. Любой тип удовлетворяет ему.
textvar i interface{} = 42 var j any = "hello" // any = interface{} (Go 1.18+)
Используйте sparingly: теряется типобезопасность.
Nil interface
Интерфейс равен nil только когда и тип, и значение nil.
govar i interface{} = nil fmt.Println(i == nil) // true var p *int = nil var j interface{} = p fmt.Println(j == nil) // false! Тип (*int) не nil
Это частая ловушка. Интерфейс хранит пару (тип, значение). Если тип не nil, интерфейс не nil, даже если значение nil.
Type assertion и type switch
Type assertion извлекает конкретный тип из интерфейса.
govar i interface{} = "hello" // Type assertion s := i.(string) // ok, s = "hello" s, ok := i.(string) // ok = true n, ok := i.(int) // ok = false, n = 0 // Type switch switch v := i.(type) { case string: fmt.Printf("string: %s\n", v) case int: fmt.Printf("int: %d\n", v) default: fmt.Printf("unknown: %T\n", v) }
Интерфейсы в стандартной библиотеке
| Интерфейс | Метод | Использование |
|---|---|---|
| io.Reader | Read([]byte) (int, error) | Чтение данных |
| io.Writer | Write([]byte) (int, error) | Запись данных |
| io.Closer | Close() error | Закрытие ресурса |
| fmt.Stringer | String() string | Строковое представление |
| error | Error() string | Обработка ошибок |
Дизайн интерфейсов в Go
Правила хорошего интерфейса:
- Маленькие интерфейсы (1-3 метода) лучше больших
- Интерфейс определяет потребитель, а не реализация
- Не создавайте интерфейс "на будущее" - только когда нужен
- Принимайте интерфейсы, возвращайте конкретные типы
3.4 Generics (type parameters)
Зачем нужны generics
До Go 1.18 приходилось писать дублирующий код для разных типов или использовать interface{} с потерей типобезопасности.
Generics позволяют писать переиспользуемый код с сохранением проверки типов на этапе компиляции.
Базовый синтаксис
go// Функция с type parameter func Contains[T comparable](items []T, target T) bool { for _, item := range items { if item == target { return true } } return false } // Использование Contains([]int{1, 2, 3}, 2) // true Contains([]string{"a", "b"}, "c") // false
Type constraints
Constraint ограничивает какие типы можно использовать.
go// comparable - встроенный constraint (поддерживает == и !=) func Contains[T comparable](items []T, target T) bool // numbers - кастомный constraint type Number interface { int | int32 | int64 | float32 | float64 } func Sum[T Number](items []T) T { var total T for _, item := range items { total += item } return total } Sum([]int{1, 2, 3}) // 6 Sum([]float64{1.5, 2.5}) // 4.0
Пакет constraints (golang.org/x/exp/constraints)
goimport "golang.org/x/exp/constraints" func Max[T constraints.Ordered](a, b T) T { if a > b { return a } return b }
Generic структуры
gotype Stack[T any] struct { items []T } func (s *Stack[T]) Push(item T) { s.items = append(s.items, item) } func (s *Stack[T]) Pop() (T, bool) { if len(s.items) == 0 { var zero T return zero, false } item := s.items[len(s.items)-1] s.items = s.items[:len(s.items)-1] return item, true } // Использование stack := &Stack[int]{} stack.Push(1) stack.Push(2) val, ok := stack.Pop() // val = 2, ok = true
Generic интерфейсы
gotype Repository[T any] interface { Get(ctx context.Context, id string) (T, error) List(ctx context.Context) ([]T, error) Create(ctx context.Context, item T) error }
Когда использовать generics
Используйте generics когда:
- Пишете коллекции (stack, queue, tree)
- Пишете утилиты (filter, map, reduce)
- Нужна типобезопасная обёртка
Не используйте generics когда:
- Достаточно interface{} и type assertion
- Код становится сложнее без реальной пользы
- Пишете бизнес-логику (обычно конкретные типы)
3.5 Встраивание и композиция
Embedding (встраивание)
В Go нет наследования. Вместо него - композиция через встраивание.
gotype Animal struct { Name string } func (a Animal) Speak() string { return "..." } type Dog struct { Animal // встраивание: Dog "содержит" Animal Breed string } dog := Dog{ Animal: Animal{Name: "Rex"}, Breed: "German Shepherd", } // Методы Animal доступны через Dog (делегирование) fmt.Println(dog.Name) // "Rex" (не dog.Animal.Name) fmt.Println(dog.Speak()) // "..."
Встраивание интерфейсов
gotype Reader interface { Read(p []byte) (n int, err error) } type Writer interface { Write(p []byte) (n int, err error) } // ReadWriter встраивает Reader и Writer type ReadWriter interface { Reader Writer }
Композиция вместо наследования
Вместо иерархии классов Go использует композицию:
go// НЕ ТАК (наследование, которого нет в Go): // class Dog extends Animal // ТАК (композиция): type Dog struct { Animal Breed string }
Преимущества композиции:
- Гибкость: можно комбинировать поведение
- Нет хрупкой иерархии
- Явные зависимости
- Легче тестировать
Пример: композиция в сервисе
gotype RepoService struct { repo repository.RepoRepository // зависимость logger *slog.Logger // зависимость } func NewRepoService(repo repository.RepoRepository, logger *slog.Logger) *RepoService { return &RepoService{ repo: repo, logger: logger, } } func (s *RepoService) GetRepo(ctx context.Context, id string) (*models.Repo, error) { s.logger.Info("getting repo", "id", id) return s.repo.GetByID(ctx, id) }
Пример из шаблона: DI через композицию
go// internal/app/modules.go func BuildRepoService(cfg *config.Config) (*service.RepoService, error) { db, err := pgxpool.New(context.Background(), cfg.PostgresDSN) if err != nil { return nil, err } repoRepo := postgres.NewRepoRepository(db) logger := slog.Default() return service.NewRepoService(repoRepo, logger), nil }
3.6 Functional options pattern
Проблема
Когда у функции много опциональных параметров, сигнатура становится нечитаемой:
go// ПЛОХО: func NewServer(host string, port int, timeout int, tls bool, logger *log.Logger) *Server
Решение: functional options
gotype Server struct { host string port int timeout time.Duration tls bool logger *slog.Logger } type Option func(*Server) func WithPort(port int) Option { return func(s *Server) { s.port = port } } func WithTimeout(d time.Duration) Option { return func(s *Server) { s.timeout = d } } func WithTLS() Option { return func(s *Server) { s.tls = true } } func WithLogger(l *slog.Logger) Option { return func(s *Server) { s.logger = l } } func NewServer(host string, opts ...Option) *Server { // Значения по умолчанию s := &Server{ host: host, port: 8080, timeout: 30 * time.Second, tls: false, logger: slog.Default(), } // Применяем опции for _, opt := range opts { opt(s) } return s }
Использование
goserver := NewServer("localhost", WithPort(9090), WithTimeout(10*time.Second), WithTLS(), )
Преимущества
- Читаемый API
- Значения по умолчанию
- Легко добавлять новые опции без breaking changes
- Опции можно комбинировать и переиспользовать
Привязка к шаблону проекта
Структура internal/repository/ в шаблоне
textinternal/repository/ ├── repo_repository.go # Интерфейс RepoRepository ├── user_repository.go # Интерфейс UserRepository ├── pull_request_repository.go ├── outbox_repository.go ├── saga_repository.go ├── storage_repository.go # Интерфейс StorageLayer ├── secret_repository.go # Интерфейс SecretProvider ├── git_repository.go # Интерфейс GitAdapter ├── webhook_repository.go # Интерфейс WebhookAdapter └── idempotency_repository.go
Что изучаем на примере шаблона
- Интерфейсы определены в internal/repository/
- Реализации в internal/adapters/ (postgres, nfs, s3)
- Сервисы зависят от интерфейсов, не от реализаций
- DI-контейнер подставляет конкретные реализации
Принцип Dependency Inversion
textinternal/service/ зависит от internal/repository/ (интерфейсы) internal/adapters/ реализует internal/repository/ (интерфейсы) internal/app/ связывает всё вместе через DI
Сервисы не знают о PostgreSQL, NFS, S3. Они знают только об интерфейсах. Это позволяет:
- Тестировать сервисы без реальной БД (mock)
- Менять инфраструктуру без изменения бизнес-логики
- Поддерживать несколько реализаций одновременно
Задание: изучите internal/repository/
- Какие интерфейсы определены?
- Сколько методов в каждом интерфейсе?
- Какие методы принимают context.Context?
- Почему интерфейсы в отдельной папке от реализаций?
💱 Компромиссы
- Value vs pointer receiver: value-семантика предсказуема — метод
работает с копией, но мутации не сохраняются и большие структуры
дорого копируются; pointer меняет оригинал и допускает
nil, но требует перевести все методы типа на pointer для консистентности. any+ type assertion vs generics:interface{}принимает что угодно без объявления, но тип проверяется в runtime — цена ошибки вi.(string)безok— panic; generics ловят типы на компиляции, но усложняют сигнатуры constraint-ами.- Embedding vs явное поле: встраивание
AnimalвDogделегирует методы без кода, но наружу торчат все поля встроенного типа; явное именованное поле многословнее, зато граница зависимости видна явно. - Functional options vs структурный конфиг: опции
WithPort(9090)расширяются без breaking changes и дают дефолты, но на каждый параметр нужен свой конструктор; plain-структура короче, но новое поле меняет все места вызова. - Интерфейс у потребителя vs «на будущее»: маленький интерфейс
в пакете клиента (
RepoRepository) легко мокается и меняется, но объявлений становится больше; заготовленный большой интерфейс экономит файлы, а платит лишней связностью реализаций.
Практическое задание
Задание 1: Проектирование интерфейса
Создайте файл internal/repository/repo_repository.go:
gopackage repository import ( "context" "yourproject/internal/models" ) type RepoFilter struct { OwnerID string Limit int Offset int } type RepoRepository interface { Create(ctx context.Context, repo *models.Repo) error GetByID(ctx context.Context, id string) (*models.Repo, error) Update(ctx context.Context, repo *models.Repo) error Delete(ctx context.Context, id string) error List(ctx context.Context, filter RepoFilter) ([]*models.Repo, error) }
Задание 2: In-memory реализация
Создайте in-memory реализацию для разработки и тестов:
gopackage memory import ( "context" "sync" "yourproject/internal/models" "yourproject/internal/repository" ) type InMemoryRepoRepository struct { mu sync.RWMutex repos map[string]*models.Repo } func NewInMemoryRepoRepository() *InMemoryRepoRepository { return &InMemoryRepoRepository{ repos: make(map[string]*models.Repo), } } func (r *InMemoryRepoRepository) Create(ctx context.Context, repo *models.Repo) error { r.mu.Lock() defer r.mu.Unlock() r.repos[repo.ID] = repo return nil } // Реализуйте остальные методы интерфейса
Задание 3: Mock для тестов
Создайте mock-реализацию с testify/mock:
gopackage mocks import ( "context" "github.com/stretchr/testify/mock" "yourproject/internal/models" ) type MockRepoRepository struct { mock.Mock } func (m *MockRepoRepository) Create(ctx context.Context, repo *models.Repo) error { args := m.Called(ctx, repo) return args.Error(0) } func (m *MockRepoRepository) GetByID(ctx context.Context, id string) (*models.Repo, error) { args := m.Called(ctx, id) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*models.Repo), args.Error(1) } // Реализуйте остальные методы
Задание 4: Generic коллекция
Реализуйте generic Stack и напишите тесты:
gopackage collections type Stack[T any] struct { items []T } func NewStack[T any]() *Stack[T] { return &Stack[T]{items: make([]T, 0)} } func (s *Stack[T]) Push(item T) { s.items = append(s.items, item) } func (s *Stack[T]) Pop() (T, bool) { if len(s.items) == 0 { var zero T return zero, false } item := s.items[len(s.items)-1] s.items = s.items[:len(s.items)-1] return item, true } func (s *Stack[T]) Peek() (T, bool) { if len(s.items) == 0 { var zero T return zero, false } return s.items[len(s.items)-1], true } func (s *Stack[T]) Size() int { return len(s.items) }
Задание 5: Functional options
Реализуйте конфигурацию HTTP-клиента с functional options:
gotype HTTPClient struct { baseURL string timeout time.Duration retries int logger *slog.Logger } type ClientOption func(*HTTPClient) func WithTimeout(d time.Duration) ClientOption { ... } func WithRetries(n int) ClientOption { ... } func WithLogger(l *slog.Logger) ClientOption { ... } func NewHTTPClient(baseURL string, opts ...ClientOption) *HTTPClient { ... }
Quiz "Проверь себя"
interface{} и any?nil?var i interface{} = (*int)(nil)
fmt.Println(i == nil)✅ Итоги модуля
Студент должен:
- Объяснить семантику указателей и когда их использовать
- Осознанно выбирать между value и pointer receiver
- Спроектировать интерфейс и реализовать его
- Объяснить nil interface и type assertion
- Использовать generics для типобезопасных коллекций
- Применить встраивание и композицию
- Реализовать functional options pattern
- Объяснить Dependency Inversion на примере шаблона
- Ответить на все вопросы Quiz
🔗 Мостик к Модулю 4
Следующий модуль: Модуль 4. Обработка ошибок - error type, errors.Is/As, оборачивание ошибок, sentinel errors, panic/recover. Привязка к internal/service/ и internal/usecase/ шаблона.