Lesson illustration

Структуры, методы и указатели в моделировании предметной области

Здравствуйте. В прошлом уроке вы восстановили работу с массивами, срезами и map: теперь вы умеете выбирать контейнер для списка, фиксированного набора или поиска по ключу. Следующий шаг — определить, что именно будет храниться в этих контейнерах. В backend-сервисе это обычно не отдельные строки и числа, а сущности предметной области: пользователь, проект, задача.

Сегодня вы научитесь моделировать такую сущность через struct, добавлять ей поведение методами и осознанно выбирать указатели. В результате вы сможете представить задачу как Task, хранить список как []Task или []*Task, а правила изменения статуса не размазывать по HTTP-обработчикам и SQL-коду.


Структура: один тип для связанных данных

В Go нет классов в смысле Java или C#. Вместо этого данные обычно группируют в структуру (struct), а поведение добавляют отдельными методами.

Для сервиса управления задачами минимальная модель может выглядеть так:

type TaskStatus string

const (
	StatusTodo       TaskStatus = "todo"
	StatusInProgress TaskStatus = "in_progress"
	StatusDone       TaskStatus = "done"
)

type Task struct {
	ID         int64
	Title      string
	Status     TaskStatus
	AssigneeID *int64
}

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

  • ID идентифицирует задачу;
  • Title хранит её заголовок;
  • Status хранит состояние;
  • AssigneeID содержит ID назначенного пользователя либо nil, если исполнитель пока не назначен.

Поле AssigneeID показывает один важный вариант применения указателя: указатель может выражать необязательное значение.

var task Task

fmt.Println(task.AssigneeID == nil) // true

Если бы вместо *int64 было int64, значение 0 пришлось бы интерпретировать как «исполнитель отсутствует». Это часто неудобно: ноль является обычным числом, а nil явно означает отсутствие значения.

Тип TaskStatus — не просто украшение. Он сообщает компилятору и читающему код человеку, что строка предназначена именно для статуса задачи, а не для произвольного текста. Константы фиксируют допустимые значения в одном месте. Однако сам тип string не делает недопустимое состояние невозможным: код вроде task.Status = "unknown" всё ещё скомпилируется. Ограничения поддерживаются соглашениями, валидацией и методами.

Посмотрите короткое объяснение базовой механики структур, методов и указательных ресиверов.

{"type":"video","title":"СТРУКТУРЫ и МЕТОДЫ golang(Экономь свое ВРЕМЯ!)","learning_duration":253,"video_id":"YhUAHrovsH8","par_intro":"Посмотрите «СТРУКТУРЫ и МЕТОДЫ golang(Экономь свое ВРЕМЯ!)» от канала Gopher. Видео даст наглядную основу: структура объединяет поля, метод добавляет поведение, а указатель нужен, когда требуется изменить исходное значение.","par_directions":"Начните с <span data-type=\"resource_video_timerange\" data-resource-subitem-id=\"5b95062e\" data-range-start=\"62\" data-range-end=\"138\">структур</span>: обратите внимание на синтаксис объявления типа и создания значений. Затем посмотрите <span data-type=\"resource_video_timerange\" data-resource-subitem-id=\"25298a06\" data-range-start=\"138\" data-range-end=\"230\">методы</span>, чтобы увидеть отличие метода от обычной функции. Завершите <span data-type=\"resource_video_timerange\" data-resource-subitem-id=\"9ceed64f\" data-range-start=\"230\" data-range-end=\"315\">указательным ресивером</span>: главное наблюдение — изменение копии структуры не меняет исходную переменную.","video_duration":382,"isV2":true,"blockId":"9b0f4c1c-595e-49f4-8881-34a50267089f","lessonId":"467622bb-ea43-4711-9041-ad9e36f49871"}



Создание значений Task

Структуру можно создать литералом с перечислением полей по порядку:

task := Task{
	101,
	"Prepare project README",
	StatusTodo,
	nil,
}

Такой вариант компилируется, но для прикладного кода он хрупкий. Если порядок полей изменится или в структуру добавят новое поле, литерал станет труднее безопасно поддерживать.

Поэтому обычно используют именованные поля:

task := Task{
	ID:     101,
	Title:  "Prepare project README",
	Status: StatusTodo,
}

Поле AssigneeID здесь не указано, поэтому получит нулевое значение, то есть nil.

Чтобы назначить исполнителя, сначала нужно иметь переменную, адрес которой можно сохранить:

assigneeID := int64(42)

task.AssigneeID = &assigneeID

fmt.Println(*task.AssigneeID) // 42

Оператор & получает адрес переменной, а * разыменовывает указатель и получает значение по этому адресу. Разыменовывать nil нельзя:

var assigneeID *int64

// fmt.Println(*assigneeID) // panic

Перед чтением необязательного поля сначала проверяйте его на nil:

if task.AssigneeID != nil {
	fmt.Println("Assignee:", *task.AssigneeID)
}
{
  "type": "exercise",
  "id": "c4a159d6-b9ab-4250-8353-35e3c7852959"
}

Нулевое значение и конструктор

Нулевое значение структуры всегда доступно:

var task Task

fmt.Printf("%+v\n", task)
// {ID:0 Title: Status: AssigneeID:<nil>}

Это корректное значение с точки зрения языка, но оно не обязательно корректно с точки зрения предметной области. Задача без ID и заголовка, скорее всего, не должна попадать в базу данных или API.

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

func NewTask(id int64, title string) *Task {
	return &Task{
		ID:     id,
		Title:  title,
		Status: StatusTodo,
	}
}

Теперь вызывающий код не забудет установить начальный статус:

task := NewTask(101, "Prepare project README")

fmt.Println(task.Status) // todo

Функция возвращает *Task, потому что задача — изменяемая сущность: разные части программы могут работать с одной и той же задачей. Но это не обязательный шаблон для любого конструктора. Небольшие неизменяемые значения часто разумно возвращать как T, а не *T.

Прочитайте выбранные фрагменты официального руководства: они формулируют важную Go-идею о полезном нулевом значении и показывают, почему литералы структур часто удобнее многословной инициализации через new.

{"type":"reading","par_intro":"Прочитайте фрагменты «Effective Go» от команды Go. Они помогут отделить два вопроса: готово ли нулевое значение типа к использованию и нужен ли конструктор, а затем закрепят правила выбора value- и pointer-ресивера.","par_directions":"В разделе **Data**, подразделе **Allocation with new**, прочитайте <span data-type=\"resource_reading_textrange\" data-resource-subitem-id=\"09e78e50\" data-range-start=\"Since the memory returned by new is zeroed\" data-range-end=\"sync.Mutex is defined to be an unlocked mutex.\">идею нулевого значения</span>. Сравните `sync.Mutex`, который готов к работе сразу, с доменной `Task`, для которой нулевые ID и заголовок обычно не имеют бизнес-смысла.\n\nЗатем в подразделе **Constructors and composite literals** прочитайте <span data-type=\"resource_reading_textrange\" data-resource-subitem-id=\"66e9165b\" data-range-start=\"Sometimes the zero value isn't good enough and an initializing constructor is necessary\" data-range-end=\"The fields of a composite literal are laid out in order and must all be present.\">конструкторы и литералы</span>. Сфокусируйтесь на форме `&Type{Field: value}` и на том, почему именованные поля делают инициализацию устойчивее к изменениям структуры.\n\nНаконец, в разделе **Methods**, подразделе **Pointers vs. Values**, прочитайте от объяснения `ByteSlice` до <span data-type=\"resource_reading_textrange\" data-resource-subitem-id=\"269b3c23\" data-range-start=\"The rule about pointers vs. values for receivers is that value methods can be invoked on pointers and values, but pointer methods can only be invoked on pointers.\" data-range-end=\"inserting the address operator automatically.\">правил вызова</span>. Особенно важно понять различие между копией значения и доступом к исходному объекту.","learning_duration":"12 minutes","url":"https://go.dev/doc/effective_go","title":"Effective Go","isV2":true,"blockId":"42a123f7-a7e6-4b2b-a90b-efe0ee0e5afe","lessonId":"467622bb-ea43-4711-9041-ad9e36f49871"}



Конструкция:

new(Task)

создаёт указатель на нулевую структуру Task. Для доменной сущности она обычно менее выразительна, чем:

&Task{
	ID:     101,
	Title:  "Prepare project README",
	Status: StatusTodo,
}

new не вызывает какой-либо скрытый конструктор и не задаёт доменные правила. Он лишь выделяет нулевое значение и возвращает его адрес.


Методы: поведение рядом с моделью

Обычная функция получает все необходимые данные аргументами:

func canStart(status TaskStatus) bool {
	return status == StatusTodo
}

Это допустимо. Но если операция относится именно к задаче, метод часто делает код понятнее:

func (t *Task) Start() bool {
	if t.Status != StatusTodo {
		return false
	}

	t.Status = StatusInProgress
	return true
}

Часть (t *Task) называется ресивером. Она связывает метод Start с типом Task; внутри метода t — конкретная задача, у которой вызвали метод.

Использование выглядит естественно:

task := NewTask(101, "Prepare project README")

if task.Start() {
	fmt.Println(task.Status) // in_progress
}

Метод не заменяет все функции в программе. Полезное практическое правило:

  • метод подходит, когда операция естественно принадлежит одному значению: задача запускается, переименовывается, назначается пользователю;
  • функция подходит, когда операция работает с несколькими сущностями или не имеет естественного владельца: например, FilterTasks(tasks, status) или BuildProjectReport(project, tasks).

Для доменной модели метод позволяет держать простое правило рядом с данными, которых оно касается:

func (t *Task) Complete() bool {
	if t.Status != StatusInProgress {
		return false
	}

	t.Status = StatusDone
	return true
}

Теперь вызывающий код не должен сам помнить, что задачу нельзя завершить прямо из состояния todo. В модуле проектирования сервиса вы расширите такие правила до полноценной модели переходов состояния и будете возвращать содержательные ошибки. Пока важна сама идея: HTTP-обработчик не должен вручную менять task.Status во множестве разных мест, если изменение — правило домена.

{
  "type": "exercise",
  "id": "27d845a8-2bcb-4c80-bd3b-560c97577ee1"
}

Value receiver и pointer receiver

Основной выбор при написании метода — передавать ли ресивер как значение или как указатель.

Value receiver: метод получает копию

func (t Task) RenameWrong(title string) {
	t.Title = title
}

На первый взгляд кажется, что метод переименует задачу. Но в него передаётся копия всей структуры:

task := Task{
	ID:     101,
	Title:  "Old title",
	Status: StatusTodo,
}

task.RenameWrong("New title")

fmt.Println(task.Title) // Old title

Изменилась лишь локальная копия t, которая исчезла после завершения метода.

Value receiver уместен для маленьких значений с семантикой значения, особенно если метод не должен менять исходный объект. Например, статус можно проверить так:

func (s TaskStatus) IsFinal() bool {
	return s == StatusDone
}
if task.Status.IsFinal() {
	fmt.Println("The task cannot be reopened yet")
}

TaskStatus — небольшой тип на основе строки, его копирование дешево и логично.

Pointer receiver: метод получает доступ к исходному объекту

Чтобы переименовать именно существующую задачу, нужен указательный ресивер:

func (t *Task) Rename(title string) {
	t.Title = title
}
task := Task{
	ID:     101,
	Title:  "Old title",
	Status: StatusTodo,
}

task.Rename("New title")

fmt.Println(task.Title) // New title

Указатель даёт методу доступ к одному и тому же значению, а не к его копии. По этой же причине Start и Complete выше используют *Task.

Для изменяемой доменной сущности вроде Task разумно придерживаться указательных ресиверов последовательно, даже для методов, которые только читают поля:

func (t *Task) IsAssigned() bool {
	return t.AssigneeID != nil
}

Это делает API предсказуемым: работа с Task предполагает работу с её экземпляром, который имеет идентичность и потенциально меняется.

СитуацияОбычный выбор
Метод должен изменить поля исходной структурыPointer receiver: *Task
Тип маленький и ведёт себя как неизменяемое значениеValue receiver: TaskStatus
В типе уже есть методы с pointer receiverОбычно pointer receiver для остальных методов того же типа
Указатель нужен только «на всякий случай ради скорости»Не используйте его без семантической причины

Указатель не означает, что значение обязательно будет выделено в куче. Решение о размещении принимает компилятор на основе escape analysis; эту тему вы разберёте отдельно при подготовке к собеседованиям. Выбирайте *Task прежде всего потому, что требуется изменить или разделить конкретную задачу, а не из предположения о производительности.

{
  "type": "exercise",
  "id": "26b54cc4-fd90-4bac-8f0b-19e712993771"
}

Удобный синтаксис вызова и его границы

Go умеет автоматически взять адрес адресуемой переменной при вызове pointer-метода:

task := Task{
	ID:     101,
	Title:  "Prepare README",
	Status: StatusTodo,
}

task.Start()

Хотя task имеет тип Task, компилятор в этом случае ведёт себя так, как если бы вы написали:

(&task).Start()

Переменная task находится в памяти и имеет адрес, поэтому такое сокращение безопасно.

Но не любое выражение адресуемо. Литерал структуры не имеет переменной, адрес которой можно неявно взять:

// Task{Status: StatusTodo}.Start() // compilation error

Сначала сохраните значение в переменную либо сразу работайте с указателем:

task := NewTask(101, "Prepare README")
task.Start()

Go также автоматически разыменовывает указатель при доступе к полям:

task := NewTask(101, "Prepare README")

fmt.Println(task.Title)
fmt.Println((*task).Title)

Оба варианта читают то же поле, но первый — идиоматичный. Явное (*task).Title почти никогда не нужно.


Целостный пример: задача как доменная сущность

Ниже собраны структура, необязательное поле, конструктор и методы. Это пока обычная программа в одном пакете; позднее Task окажется в доменном или прикладном пакете, а HTTP и PostgreSQL будут работать с ней через выделенные границы.

package main

import "fmt"

type TaskStatus string

const (
	StatusTodo       TaskStatus = "todo"
	StatusInProgress TaskStatus = "in_progress"
	StatusDone       TaskStatus = "done"
)

type Task struct {
	ID         int64
	Title      string
	Status     TaskStatus
	AssigneeID *int64
}

func NewTask(id int64, title string) *Task {
	return &Task{
		ID:     id,
		Title:  title,
		Status: StatusTodo,
	}
}

func (t *Task) Assign(userID int64) {
	t.AssigneeID = &userID
}

func (t *Task) Start() bool {
	if t.Status != StatusTodo {
		return false
	}

	t.Status = StatusInProgress
	return true
}

func (t *Task) Complete() bool {
	if t.Status != StatusInProgress {
		return false
	}

	t.Status = StatusDone
	return true
}

func (t *Task) IsAssigned() bool {
	return t.AssigneeID != nil
}

func main() {
	task := NewTask(101, "Prepare project README")
	task.Assign(42)

	fmt.Println("Assigned:", task.IsAssigned())
	fmt.Println("Started:", task.Start())
	fmt.Println("Status:", task.Status)

	anotherReference := task
	fmt.Println("Completed:", anotherReference.Complete())
	fmt.Println("Original task status:", task.Status)

	tasksByID := map[int64]*Task{
		task.ID: task,
	}

	foundTask, ok := tasksByID[101]
	if ok {
		fmt.Println("Found:", foundTask.Title)
	}
}

После вызова anotherReference.Complete() исходная task также имеет статус done. Здесь копируется только указатель, а обе переменные указывают на одну задачу.

Это напрямую связано с прошлым уроком:

tasks := []*Task{task}
tasksByID := map[int64]*Task{
	task.ID: task,
}
  • []*Task — упорядоченный изменяемый список ссылок на задачи;
  • map[int64]*Task — быстрый поиск конкретной задачи по ID;
  • *Task — разделяемая конкретная сущность.

Иногда сервису достаточно []Task, особенно когда он читает данные и формирует ответ. Но если элементы должны изменяться совместно, их часто хранят и передают как *Task. Выбор зависит от требуемой семантики: нужна независимая копия задачи или один общий объект.


Несколько ловушек, которые стоит замечать на ревью

Изменять структуру через value receiver

func (t Task) Complete() {
	t.Status = StatusDone
}

Этот метод не изменит исходную задачу. Для изменения используйте func (t *Task) Complete().

Считать new(Task) полноценным созданием задачи

task := new(Task)

Код корректен, но создаёт задачу с ID == 0, пустым названием и пустым статусом. Если у типа есть обязательное начальное состояние, используйте литерал с полями или конструктор NewTask.

Разыменовывать необязательный указатель без проверки

fmt.Println(*task.AssigneeID)

Это безопасно только если назначение уже произошло. В противном случае программа завершится с panic.

Путать копирование структуры с полной независимостью данных

Структура копируется при присваивании:

first := Task{
	ID:     101,
	Title:  "Prepare README",
	Status: StatusTodo,
}

second := first
second.Title = "Write tests"

fmt.Println(first.Title)  // Prepare README
fmt.Println(second.Title) // Write tests

Но это не всегда означает глубокую независимую копию. Если в структуре есть поле-срез, map или указатель, после копирования структуры обе копии могут всё ещё разделять данные внутри этих полей. Это продолжение правила о заголовке среза из прошлого урока: копируется контейнерное значение, но не обязательно данные за ним.

{
  "type": "exercise",
  "id": "c18386c2-f57f-4fbe-af6c-ca623d43f766"
}

Итоги

Теперь у вас есть базовый способ представлять доменную модель в Go:

  • struct группирует связанные поля в один именованный тип;
  • именованные литералы структур делают создание значений яснее и устойчивее к изменениям;
  • нулевое значение всегда существует, но для доменной сущности может быть невалидным с точки зрения бизнеса;
  • *T может выражать как доступ к изменяемому объекту, так и необязательное значение через nil;
  • value receiver получает копию структуры;
  • pointer receiver работает с исходным значением и нужен для методов, которые меняют состояние;
  • методы помогают держать правила изменения сущности рядом с её данными, а не в HTTP-обработчиках или SQL-запросах;
  • []*Task и map[int64]*Task естественно соединяют сегодняшнюю модель с коллекциями из прошлого урока.

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

Can't find a good explanation? Sign up and we'll make it for you