1. Карта местности: что возвращают new и make
Если вы новичок, то вполне честная реакция на new и make такая: «Почему нельзя было оставить одно слово, а второе удалить с помощью rm -rf?» Понимаю. Но у Go тут вполне взрослая причина: разные типы данных в языке устроены по-разному, и им нужно разное «создание». Одним достаточно получить память под zero value — и они уже готовы к работе. А другим нужно ещё инициализировать внутреннюю структуру, иначе они выглядят как коробка без дна: вроде форма есть, а использовать нельзя.
И вот ключевая идея: new(T) и make(...) решают разные задачи, поэтому они и не заменяют друг друга.
Прежде чем углубляться, полезно иметь «карту местности», чтобы не потеряться. Давайте зафиксируем это как таблицу, потому что таблицы — это списки, которые притворились культурными.
| Инструмент | Что делает | Что возвращает | Для каких типов обычно используют |
|---|---|---|---|
|
Выделяет место под zero value типа T | |
Для любых T, когда нужен указатель |
|
Создаёт готовое к использованию значение со внутренним состоянием | |
Для slice, map, chan |
Это различие — основная мысль лекции. Всё остальное ниже — детали, которые сделают эту мысль привычной.
2. new(T): «дай мне указатель на zero value»
Когда вы пишете new(T), Go делает очень простую вещь: выделяет память под значение типа T, записывает туда zero value, и возвращает адрес этого места, то есть *T.
Важно почувствовать вкус формулировки: new возвращает указатель. Не значение, а адрес. Поэтому new(int) — это *int, new(string) — это *string, new(Task) — это *Task и так далее.
Мини‑пример: new(int) и разыменование
package main
import "fmt"
func main() {
p := new(int) // p: *int, внутри лежит 0
fmt.Println(*p) // 0
*p = 10
fmt.Println(*p) // 10
}
Здесь new(int) даёт нам адрес, по которому лежит 0. Мы меняем *p, и теперь по адресу лежит 10.
new со структурой: new(struct)
Да, структуры (struct) мы будем разбирать подробно отдельно, но сегодня нам нужен самый минимальный вариант: структура — это просто «значение с полями». Без методов, без тегов, без драм.
package main
import "fmt"
type Task struct {
Title string
Done bool
}
func main() {
t := new(Task) // t: *Task, поля = zero values
fmt.Println(t.Title, t.Done) // "" false
}
У string zero value — пустая строка, у bool — false. Поэтому свежесозданная через new(Task) задача выглядит как «пустая задача и ещё не выполнена». Логично.
4. make: «собери мне рабочий механизм»
В отличие от new, make не про «просто выделить память». make нужен для типов, у которых есть внутреннее состояние, которое должно быть корректно инициализировано.
Классические три типа, для которых существует make:
- []T (slice),
- map[K]V,
- chan T.
Слайсы и карты вы уже активно используете. Каналы мы пока трогать будем очень аккуратно (чисто чтобы понимать, почему они упоминаются рядом с make).
Кстати, полезный факт для мышления: слайс внутри — это не «массив». Это маленькая структура-заголовок, которая хранит ссылку на данные + длину + ёмкость. Именно поэтому слайсы ведут себя «ссылочно» по данным и почему append иногда меняет буфер. Эта модель «указатель + len + cap» — стандартная и очень полезная для понимания поведения слайсов.
make([]T, len, cap): создаём слайс, который можно использовать сразу
Слайс без make тоже бывает (например, var s []int), но тогда он будет nil-слайсом. Он во многих местах удобен и безопасен, но важно понимать разницу: make создаёт слайс уже с заданной длиной и/или ёмкостью.
make со длиной: можно индексировать
package main
import "fmt"
func main() {
nums := make([]int, 3) // len=3, внутри [0 0 0]
nums[0] = 7
fmt.Println(nums) // [7 0 0]
}
Если вы сделали make([]int, 3), то nums[0], nums[1], nums[2] — легальны.
make с ёмкостью: удобно для append
package main
import "fmt"
func main() {
nums := make([]int, 0, 5) // len=0, cap=5
nums = append(nums, 1)
nums = append(nums, 2)
fmt.Println(nums) // [1 2]
}
Так вы заранее говорите: «я планирую добавлять элементы, не заставляй рантайм часто расширяться».
make(map[K]V): создаём map, в которую можно писать
У карты (map) есть отдельный важный момент: в nil-map писать нельзя. Читать можно (получите zero value), а вот запись приводит к аварии во время выполнения.
Поэтому если вы хотите создавать карту «с нуля и писать в неё», вам нужен make(map[K]V).
package main
import "fmt"
func main() {
m := make(map[string]int)
m["go"] = 25
fmt.Println(m["go"]) // 25
}
make(chan T): создаём канал
Каналы — это инструмент для общения между горутинами. Мы не будем прямо сейчас строить конкурентность, но нам важно понять механически: канал создаётся через make. Не через new.
package main
import "fmt"
func main() {
ch := make(chan int)
fmt.Printf("%T\n", ch) // chan int
}
Мы даже не отправляем и не читаем значения — просто фиксируем: канал — это отдельный тип, и создаётся он make.
5. Почему new и make нельзя поменять местами
Сейчас будет раздел, где мозг обычно делает «щелчок». Если вы его почувствуете — поздравляю, вы только что сэкономили себе несколько часов будущей отладки.
Почему new([]int) — почти всегда странный выбор
new([]int) возвращает *[]int, то есть указатель на слайс. А внутри этого слайса будет zero value — то есть nil-слайс.
package main
import "fmt"
func main() {
ps := new([]int) // ps: *[]int
fmt.Println(*ps == nil) // true
fmt.Println(len(*ps)) // 0
}
Вы получаете указатель на nil-слайс. Сам по себе nil-слайс нормальный, но вопрос: зачем вам указатель на него? В большинстве задач — незачем. Слайс и так маленький (заголовок), его удобно передавать и возвращать значением.
Почему new(map[string]int) не заменяет make(map[string]int)
Это одна из самых частых ловушек:
- new(map[string]int) даёт *map[string]int, внутри — nil-map.
- nil-map нельзя записывать.
package main
import "fmt"
func main() {
pm := new(map[string]int) // *map[string]int, внутри nil
fmt.Println(*pm == nil) // true
// (*pm)["a"] = 1 // так делать нельзя: запись в nil-map => panic
*pm = make(map[string]int) // вот теперь карта настоящая
(*pm)["a"] = 1
fmt.Println((*pm)["a"]) // 1
}
Вывод: new(map[...]...) может быть полезен только если вам по смыслу нужен указатель на карту (что редкость). А «чтобы просто создать карту» — нужен make.
6. Указатель или готовая структура данных
Давайте проговорим человеческим языком.
Если вы хотите «создать объект и менять его через указатель» — у вас естественно появляется new(T) или &T{...} (второе мы тоже позже полюбим). Но смысл всегда один: вам нужен адрес, то есть *T.
Если вы хотите создать коллекцию/механизм (slice, map, chan), который внутри должен быть правильно инициализирован — вы используете make.
И это не философия. Это практическое правило, которое делает код предсказуемым.
Нюанс: make возвращает значение, но оно может вести себя «ссылочно»
Этот момент важен, чтобы не запутаться в формулировках. make возвращает значение типа []T, map[K]V или chan T. Не указатель. Но поведение часто похоже на «ссылочное», потому что внутри этих значений есть ссылки на общее состояние.
У слайса это заголовок, который содержит ссылку на массив данных (и поэтому изменения элементов часто видны снаружи). У map и chan внутри тоже есть указатели на структуры рантайма, и поэтому «копия map» всё равно указывает на ту же карту.
Это не противоречит «в Go всё передаётся по значению». Просто значение может быть маленькой «ручкой», которая ведёт к большому общему объекту.
Быстрая интуитивная шпаргалка
Полезно закрепить «интуицию»:
new(T) — это когда вы хотите получить адрес (указатель) на значение T, обычно чтобы:
- передать и менять одно и то же значение из разных мест,
- вернуть «большой объект» из функции без копирования его целиком (хотя копирование — отдельная тема),
- иметь возможность обозначить «нет объекта» через nil (*T может быть nil).
make — это когда вы создаёте контейнер/механизм, который должен быть работоспособным сразу после создания.
7. Учебное приложение TaskBox: new для задач, make для хранилищ
Чтобы не оставлять тему слишком абстрактной, давайте встроим её в маленький кусочек приложения. Пусть у нас будет консольный TaskBox: хранит задачи в памяти, умеет добавить несколько задач и распечатать.
Мы сделаем две вещи:
- задачи будем создавать через new(Task) (потому что будем хранить указатели на задачи),
- коллекции будем создавать через make: слайс и карту.
Описываем задачу
package main
type Task struct {
ID int
Title string
Done bool
}
Никаких методов, ничего «сложного». Просто поля.
Фабрика задач через new(Task)
Почему фабрика? Потому что так удобнее: вся инициализация в одном месте, а в main не будет каши.
package main
func NewTask(id int, title string) *Task {
t := new(Task) // *Task с zero values
t.ID = id
t.Title = title
return t
}
Да, можно было бы и иначе, но сегодня нам важно именно new.
Хранилища: make([]*Task, 0, ...) и make(map[int]*Task)
Слайс будет хранить порядок добавления, а карта — быстрый доступ по ID.
package main
func main() {
tasks := make([]*Task, 0, 8)
byID := make(map[int]*Task)
_ = tasks
_ = byID
}
Обратите внимание: tasks и byID — уже готовы к использованию. В tasks можно append, в byID можно писать byID[id] = ....
Склеиваем всё: добавить пару задач и вывести
Сделаем короткий, но цельный пример.
package main
import "fmt"
type Task struct {
ID int
Title string
Done bool
}
func NewTask(id int, title string) *Task {
t := new(Task)
t.ID = id
t.Title = title
return t
}
func main() {
tasks := make([]*Task, 0, 8)
byID := make(map[int]*Task)
t1 := NewTask(1, "Выучить new vs make")
t2 := NewTask(2, "Не путать nil-map и пустую map")
tasks = append(tasks, t1, t2)
byID[t1.ID] = t1
byID[t2.ID] = t2
fmt.Println(tasks[0].Title) // Выучить new vs make
fmt.Println(byID[2].Title) // Не путать nil-map и пустую map
}
Здесь важная мысль: NewTask возвращает *Task, и эти указатели мы храним и в слайсе, и в карте. Это значит, что если мы изменим задачу по указателю — изменения будут видны из обеих структур (это нормально, если мы так задумали).
8. Типичные ошибки
Ошибка №1: пытаться заменить make(map[K]V) на new(map[K]V).
new(map[K]V) возвращает *map[K]V, и внутри будет nil-map. Читать из неё можно, но запись m[k] = v приводит к аварии во время выполнения. Если нужна рабочая карта — используйте make(map[K]V), а не new.
Ошибка №2: ожидать, что new([]T) создаст слайс нужной длины.
new([]T) создаёт указатель на zero value слайса, то есть на nil-слайс длины 0. Это не то же самое, что make([]T, n). Если вы планируете индексировать s[i] — вам нужен make с длиной.
Ошибка №3: делать *map или *[]T «на всякий случай».
Новички иногда добавляют указатели везде, где видят возможность. Но map и []T и так обычно достаточно «ссылочны» по поведению, и лишние указатели только усложняют код: добавляются проверки на nil, появляется больше неочевидных связей и становится труднее понять, кто и где меняет данные.
Ошибка №4: думать, что make возвращает указатель.
make возвращает значение типа []T, map[K]V или chan T. Не *[]T и не *map[K]V. Если в коде вы начинаете писать *m после make(map[...]...), значит вы перепутали модель в голове и пора остановиться, выдохнуть и перечитать таблицу из начала лекции.
Ошибка №5: забывать, что у map есть два разных «пустых состояния»: nil и «пустая, но созданная».
var m map[string]int даёт nil-map: читать можно, писать нельзя. m := make(map[string]int) даёт пустую, но рабочую map: и читать, и писать можно. Эти состояния выглядят похоже («там ничего нет»), но ведут себя по-разному — и это одна из самых частых причин неожиданных паник.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ