Модуль 3. Указатели, интерфейсы и композиция

Цели модуля

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

  • Понимать семантику указателей и когда их использовать
  • Выбирать между value и pointer receiver осознанно
  • Проектировать интерфейсы и понимать duck typing
  • Использовать generics (type parameters) для типобезопасного кода
  • Применять встраивание и композицию вместо наследования
  • Реализовывать functional options pattern для конфигурации
  • Понимать Dependency Inversion на примере шаблона проекта

3.1 Семантика указателей

Что такое указатель

Указатель хранит адрес переменной в памяти, а не её значение.

go
var 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.

go
var p *int  // nil указатель
fmt.Println(p == nil)  // true

// PANIC:
// fmt.Println(*p)  // runtime error: invalid memory address

// Правильно: проверка перед разыменованием
if p != nil {
    fmt.Println(*p)
}

Указатели на структуры

go
type 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

Метод получает копию структуры. Изменения не сохраняются.

go
type Counter struct {
    count int
}

func (c Counter) Increment() {
    c.count++  // изменяет копию, не оригинал
}

counter := Counter{}
counter.Increment()
fmt.Println(counter.count)  // 0! Не изменилось

Pointer receiver

Метод получает указатель. Изменения сохраняются в оригинале.

go
type Counter struct {
    count int
}

func (c *Counter) Increment() {
    c.count++  // изменяет оригинал
}

counter := Counter{}
counter.Increment()
fmt.Println(counter.count)  // 1

Правила выбора

КритерийValue receiverPointer 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

go
type 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: "Если это выглядит как утка, плавает как утка и крякает как утка - это утка."

go
type Reader interface {
    Read(p []byte) (n int, err error)
}

// os.File автоматически удовлетворяет Reader
// потому что у него есть метод Read

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

go
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)
}

Неявная реализация

В 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
}

Интерфейсы как параметры функций

Интерфейсы позволяют писать функции, работающие с разными типами.

go
func 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

Пустой интерфейс не имеет методов. Любой тип удовлетворяет ему.

text
var i interface{} = 42
var j any = "hello"  // any = interface{} (Go 1.18+)

Используйте sparingly: теряется типобезопасность.

Nil interface

Интерфейс равен nil только когда и тип, и значение nil.

go
var 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 извлекает конкретный тип из интерфейса.

go
var 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.ReaderRead([]byte) (int, error)Чтение данных
io.WriterWrite([]byte) (int, error)Запись данных
io.CloserClose() errorЗакрытие ресурса
fmt.StringerString() stringСтроковое представление
errorError() stringОбработка ошибок

Дизайн интерфейсов в Go

Правила хорошего интерфейса:

  1. Маленькие интерфейсы (1-3 метода) лучше больших
  2. Интерфейс определяет потребитель, а не реализация
  3. Не создавайте интерфейс "на будущее" - только когда нужен
  4. Принимайте интерфейсы, возвращайте конкретные типы

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)

go
import "golang.org/x/exp/constraints"

func Max[T constraints.Ordered](a, b T) T {
    if a > b {
        return a
    }
    return b
}

Generic структуры

go
type 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 интерфейсы

go
type 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 нет наследования. Вместо него - композиция через встраивание.

go
type 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()) // "..."

Встраивание интерфейсов

go
type 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
}

Преимущества композиции:

  • Гибкость: можно комбинировать поведение
  • Нет хрупкой иерархии
  • Явные зависимости
  • Легче тестировать

Пример: композиция в сервисе

go
type 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

go
type 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
}

Использование

go
server := NewServer("localhost",
    WithPort(9090),
    WithTimeout(10*time.Second),
    WithTLS(),
)

Преимущества

  • Читаемый API
  • Значения по умолчанию
  • Легко добавлять новые опции без breaking changes
  • Опции можно комбинировать и переиспользовать

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

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

text
internal/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

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

  1. Интерфейсы определены в internal/repository/
  2. Реализации в internal/adapters/ (postgres, nfs, s3)
  3. Сервисы зависят от интерфейсов, не от реализаций
  4. DI-контейнер подставляет конкретные реализации

Принцип Dependency Inversion

text
internal/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:

go
package 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 реализацию для разработки и тестов:

go
package 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:

go
package 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 и напишите тесты:

go
package 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:

go
type 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 "Проверь себя"

1. Что верно про выбор receiver'ов у одного типа?
2. Что верно про embedding (встраивание) в Go?
3. В чём разница между interface{} и any?
4. Как generics улучшают типобезопасность?
5. Когда интерфейс равен nil?
6. Когда использовать type switch, а не type assertion?
7. Что выведет этот код?
go
var i interface{} = (*int)(nil)
fmt.Println(i == nil)
8. Когда использовать pointer receiver? — выберите все подходящие

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

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

  • Объяснить семантику указателей и когда их использовать
  • Осознанно выбирать между 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/ шаблона.