Здравствуйте. В прошлом уроке вы восстановили работу с массивами, срезами и 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" всё ещё скомпилируется. Ограничения поддерживаются соглашениями, валидацией и методами.
Посмотрите короткое объяснение базовой механики структур, методов и указательных ресиверов.
СТРУКТУРЫ и МЕТОДЫ golang(Экономь свое ВРЕМЯ!)
Посмотрите «СТРУКТУРЫ и МЕТОДЫ golang(Экономь свое ВРЕМЯ!)» от канала Gopher. Видео даст наглядную основу: структура объединяет поля, метод добавляет поведение, а указатель нужен, когда требуется изменить исходное значение.
Начните с структур: обратите внимание на синтаксис объявления типа и создания значений. Затем посмотрите методы, чтобы увидеть отличие метода от обычной функции. Завершите указательным ресивером: главное наблюдение — изменение копии структуры не меняет исходную переменную.
Создание значений 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)
}
Нулевое значение и конструктор
Нулевое значение структуры всегда доступно:
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.
Прочитайте фрагменты «Effective Go» от команды Go. Они помогут отделить два вопроса: готово ли нулевое значение типа к использованию и нужен ли конструктор, а затем закрепят правила выбора value- и pointer-ресивера.
В разделе Data, подразделе Allocation with new, прочитайте идею нулевого значения. Сравните sync.Mutex, который готов к работе сразу, с доменной Task, для которой нулевые ID и заголовок обычно не имеют бизнес-смысла. Затем в подразделе Constructors and composite literals прочитайте конструкторы и литералы. Сфокусируйтесь на форме &Type{Field: value} и на том, почему именованные поля делают инициализацию устойчивее к изменениям структуры. Наконец, в разделе Methods, подразделе Pointers vs. Values, прочитайте от объяснения ByteSlice до правил вызова. Особенно важно понять различие между копией значения и доступом к исходному объекту.
Конструкция:
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 во множестве разных мест, если изменение — правило домена.
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 прежде всего потому, что требуется изменить или разделить конкретную задачу, а не из предположения о производительности.
Удобный синтаксис вызова и его границы
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 или указатель, после копирования структуры обе копии могут всё ещё разделять данные внутри этих полей. Это продолжение правила о заголовке среза из прошлого урока: копируется контейнерное значение, но не обязательно данные за ним.
Итоги
Теперь у вас есть базовый способ представлять доменную модель в 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
Sign up