Привет, Хабр!
Сегодня разберёмся, зачем в Go существуют два способа создавать значения — make
и new
, чем они отличаются, как они работают и когда выбирать каждый из них
Что мы вообще создаём в Go
Чтобы понимать разницу между make
и new
, нужно начать с главного: в Go есть два больших семейства типов. Одни — это обычные значения (int, struct, массивы, float64, bool, и т. д.), другие — это ссылочные структуры, к которым относятся только три типа: slice
, map
и chan
.
Первое семейство — value types — можно аллоцировать где угодно: в стеке, в куче, в памяти другого объекта. Второе — это конструкты уровня рантайма, у которых есть свой внутренний механизм работы, и просто так через «var» их не завести — получим nil и панику при первом же обращении. Именно по этой границе и делится область применения new
и make
.
new — это просто malloc с нулями, но это не всё
В Go функция new(T)
выполняет, казалось бы, максимально простую и прозрачную операцию: она выделяет память под тип T
, обнуляет её и возвращает указатель *T
. Всё. Ни логики инициализации, ни вызовов конструктора (их в Go вообще нет), ни скрытой инициализации как в C++ — вы просто получаете сырой, нулевой объект в куче.
type Config struct {
Enabled bool
Count int
}
cfg := new(Config)
// cfg имеет тип *Config
// cfg.Enabled == false
// cfg.Count == 0
Внутри это всего лишь вызов runtime.newobject
, который делает malloc
на размер типа T
и очищает получившийся блок нулями. Это поведение стабильно, независимо от типа: будь то int
, string
, struct
, array
, bool
— результат всегда будет нулевой объект на куче.
new(T) vs &T{}
Зачем тогда вообще new
, если есть литералы? И вот тут начинается первый нюанс.
Смотрите на два идентичных по сути выражения:
type User struct {
Name string
Age int
}
u := new(User)
Оба создают указатель типа *User
, оба указывают на объект со значениями по умолчанию. Но есть разница: &User{}
создаёт сразу полностью известную структуру, и компилятор понимает, что она может быть создана в стеке, если позволяет escape analysis. А new(User)
всегда аллоцирует объект в куче, потому что такова семантика вызова new
.
То есть new(T)
жёстко уходит в heap:
func heapAlloc() *int {
return new(int) // гарантированная куча
}
А &T{}
— может остаться в стеке:
func maybeStackAlloc() *int {
v := 42
return &v // возможно, escape в heap, возможно, останется в стеке — зависит от анализатора
}
Если вы хотите писать высокоэффективный код с минимальным количеством аллокаций, то &T{}
даёт компилятору шанс оставить объект на стеке, что дешевле.
А зачем тогда всё-таки использовать new?
В продакшене new(T)
встречается нечасто, но он всё ещё полезен. Вот где:
1. В generic‑коде, где T
неизвестен на этапе компиляции:
func NewPointer[T any]() *T {
return new(T)
}
Это невозможно выразить через &T{}
, потому что T
может быть чем угодно: int
, []byte
, chan string
, и у вас просто нет доступа к литералу.
2. В низкоуровневых инициализациях без лишних данных:
Когда вы хотите получить нулевой объект, но не хотите указывать поля, и вам неважно, что они все обнулены:
conn := new(net.Conn) // если вы используете interface pointer внутри сложной структуры
3. В структурах, где не нужны начальные значения, например, когда вы вручную наполняете поля позже:
type Builder struct {
parts []string
}
b := new(Builder)
b.parts = append(b.parts, "step1", "step2")
Это не супер распространено, но в отдельных случаях повышает читаемость.
Можно ли делать new([]int)? А new(map[string]string)?
Да, можно. И да, будет *[]int
, указывающий на nil
‑слайс. Вроде бы безопасно — можно проверять на nil, можно передавать, но использовать — почти бессмысленно.
s := new([]int)
fmt.Println(*s == nil) // true
В этом и есть подвох: new
вернёт указатель на пустой контейнер. Это будет nil
, и при попытке использовать его как полноценный slice
или map
вы быстро огребёте:
m := new(map[string]int)
(*m)["foo"] = 42 // panic: assignment to entry in nil map
То есть да, new
допустим синтаксически, но на практике он не даёт работоспособного результата.
А что с базовыми типами — int, string, bool?
Работает точно так же:
i := new(int)
s := new(string)
b := new(bool)
fmt.Println(*i) // 0
fmt.Println(*s) // ""
fmt.Println(*b) // false
Это вполне легитимно. Иногда удобно в функциях, которые принимают указатели, но не хотят заранее инициализировать значение:
func SetDefaultPort(p *int) {
if p == nil {
p = new(int)
*p = 8080
}
fmt.Println("Port:", *p)
}
Или для паттерна «опциональное значение через nil»:
type Options struct {
RetryCount *int
}
make — фабрика рантайм-структур
make
в Go — это не про выделение памяти как new
. Это про инициализацию ссылочных структур, которые не могут существовать без подготовки. Вся фишка в том, что slice
, map
и chan
— это не просто типы, а компактные дескрипторы, указывающие на живую структуру в рантайме.
slice: не массив, а указатель–длина–вместимость
Когда вы пишете make([]int, 10, 100)
, вы не просто создаёте массив. Вы создаёте slice header, который сам по себе занимает 3 слова (24 байта на 64-битной архитектуре):
type sliceHeader struct {
Data uintptr // указатель на первый элемент массива
Len int
Cap int
}
Т.е по факту slice
в Go — это указатель с метаданными. Он не владеет памятью, он просто знает, где она начинается, сколько уже занято len
и сколько доступно cap
. Если вы создаёте слайс через make
, то Go делает примерно следующее:
// Псевдокод, близкий к реальности:
array := malloc(sizeof(T) * cap)
header := sliceHeader{
Data: &array[0],
Len: len,
Cap: cap,
}
return header
Вот почему make([]int, 0, 100)
— это рабочий, но нулевой по длине слайс. Его можно append
'ить без аллокаций до 100 элементов. Если бы вы написали var s []int
, вы бы получили nil
‑слайс, у которого и ptr
, и len
, и cap
— нули. Попробуйте взять у него s[0]
— получите панику. make
защищает от этого, создавая слайс, который реально готов к работе.
map: не просто хеш-таблица, а мутант с бакетами и копиями
Если вы думаете, что Go map — это обычная хеш‑таблица, как в Python или JavaScript, то вы недооцениваете её. Это — адаптивный runtime‑конструкт. При вызове make(map[string]int)
Go:
Выделяет
hmap
— внутреннюю структуру заголовка карты.Создаёт массив из
B
бакетов (по дефолту 8, если вы не указалиhint
).Заполняет их служебными битовыми масками, хешами, empty markers, counters.
Подготавливает механизм инкрементальной перестройки, если мапа начнёт расти.
Так выглядит внутренность hmap
(упрощённо):
type hmap struct {
count int
flags uint8
B uint8 // логарифм количества бакетов
buckets unsafe.Pointer
oldbuckets unsafe.Pointer // для перестройки
// и куча другой дичи
}
new(map[string]int)
даст вам *map[string]int
, указывающий на nil
. Вы не можете в него ничего записать, потому что сама структура hmap
не существует. Только make
инициализирует все внутренности карты. И это runtime‑only blackbox — вы не можете вручную создать hmap
или его части.
chan: два мира — синхронный и буферизированный
Когда вы пишете:
c := make(chan int, 5)
Go создаёт полноценную очередь сообщений, с указателями на начало и конец, со счётчиком, с буфером фиксированной длины. Но если вы напишите var c chan int
— вы получите nil
‑канал. Он будет с типом, но без поведения. И если вы сделаете:
<-c
Горутина залипнет. Навсегда. Потому что nil
‑канал в Go — это отдельная сущность: он не отправляет, не принимает, не паникует. Он просто блокирует. Это не ошибка — это фича.
А make(chan T, N)
запускает внутреннюю структуру hchan
, в которой:
Выделяется буфер
[]T
, еслиN > 0
Инициализируется список ожидающих горутин на
recv
иsend
Настраиваются счетчики для кольцевого буфера
Ставятся флаги закрытости
Каналы в Go — это не просто «поток сообщений». Это примитив синхронизации, встроенный в планировщик. И без make
он не живёт. new(chan int)
даст *chan int
, указывающий на nil
— и всё.
Когда использовать make, а когда new
Начнём с наблюдения: в продакшене new
почти не используется. И не потому что он плохой, а потому что в большинстве случаев вы просто не хотите получать указатель на нулевую структуру. Вы хотите что‑то живое, рабочее, с данными — а не пустую заготовку под объект. Поэтому &T{}
выигрывает, а new(T)
валяется где‑то рядом на всякий случай.
Но у new
есть три чётких применения:
Generic‑код. Когда вы не знаете, что за тип перед вами, и хотите просто сделать указатель на zero‑value. Никакой другой способ не даст вам этой универсальности:
func NewZero[T any]() *T { return new(T) }
Явная аллокация в куче. Бывает, что вы хотите гарантированно положить объект в heap, а не полагаться на escape analysis.
new(T)
делает это явно.pool := sync.Pool{ New: func() any { return new(MyStruct) }, }
Создание указателей для опциональных значений. Например, если у вас в структуре:
type Config struct { Timeout *int }
— вы явно хотите уметь различать «значение не указано» и «значение задано». В таких случаях удобно использовать
new(int)
.
Во всех остальных случаях — new
либо не нужен, либо делает ваш код менее читаемым.
Теперь про make
.
Тут всё, казалось бы, просто: slice
, map
, chan
— и точка. Но на деле — нюансов хватает.
Во‑первых, make
хорош не только потому, что он обязателен, но и потому, что он даёт контроль. Вы точно задаёте len
и cap
для слайса, указываете буферизацию канала, и намекаете runtime на предполагаемый размер мапы. Э
buf := make([]byte, 0, 4096) // чёткая заявка: сюда пойдут данные, не пересоздавай slice каждые 100 байт
m := make(map[string]int, 10000) // hint: мы сюда зальём кучу ключей, не распыляй бакеты каждый insert
Кроме того, make
— это декларация намерений. Когда ты читаешь make(chan error, 1)
, ты сразу видишь: «канал односторонний, используется для отправки единственного сигнала».
Когда make
использовать не стоит? Почти никогда не бывает такого. Потому что если ты работаешь с slice
, map
или chan
— ты либо вызываешь make
, либо бьёшься лбом о runtime‑панику.
А как быть с var?
Иногда в коде проскакивает var s []int
. Но s
— это nil
. Он не аллоцирован. Можно его передавать, но при попытке что‑то сделать руками (s[0] = 1
) — привет panic.
Поэтому если хочется рабочую структуру, ты её делаешь явно:
// не это:
var s []int
// а вот это:
s := make([]int, 0, 256)
То же самое с map
и chan
. Даже если мы просто собираемся передать объект дальше — нужно создавать его правильно.
make
создаёт рабочие map
, slice
и chan
с полной инициализацией, new
— просто выделяет память и возвращает указатель на ноль. Понимание разницы — обязательный минимум для нормального Go‑кода. А вы как используете make
и new
?
Если вы хотите вывести свои навыки программирования на новый уровень, предлагаю посетить открытые уроки, которые раскроют перед вами мощные возможности Go. Они помогут вам не только разобраться в важных аспектах языка, но и дадут реальный опыт в создании эффективных и масштабируемых решений.
29 апреля — Чат-радио на Go: брокер сообщений NATS в деле
Погрузитесь в работу с NATS и создайте свой собственный групповой мессенджер. Понимание того, как передавать сообщения между пользователями, откроет для вас новые горизонты в программировании.13 мая — Telegram-бот на Go с нуля: персональный менеджер задач
Разработайте Telegram-бота с нуля, который будет управлять задачами. Вы создадите не просто бота, а полноценного помощника для работы и жизни.22 мая — Взаимодействие с базой данных и миграции на Go
Освойте тонкости работы с базами данных в Go, научитесь проводить миграции и эффективно работать с запросами. Поднимите свой уровень работы с данными на новый уровень.