Golang: когда make, когда new
- пятница, 25 апреля 2025 г. в 00:00:08
Привет, Хабр!
Сегодня разберёмся, зачем в Go существуют два способа создавать значения — make
и new
, чем они отличаются, как они работают и когда выбирать каждый из них
Чтобы понимать разницу между make
и new
, нужно начать с главного: в Go есть два больших семейства типов. Одни — это обычные значения (int, struct, массивы, float64, bool, и т. д.), другие — это ссылочные структуры, к которым относятся только три типа: slice
, map
и chan
.
Первое семейство — value types — можно аллоцировать где угодно: в стеке, в куче, в памяти другого объекта. Второе — это конструкты уровня рантайма, у которых есть свой внутренний механизм работы, и просто так через «var» их не завести — получим nil и панику при первом же обращении. Именно по этой границе и делится область применения new
и make
.
В 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
, если есть литералы? И вот тут начинается первый нюанс.
Смотрите на два идентичных по сути выражения:
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(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")
Это не супер распространено, но в отдельных случаях повышает читаемость.
Да, можно. И да, будет *[]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
допустим синтаксически, но на практике он не даёт работоспособного результата.
Работает точно так же:
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
в Go — это не про выделение памяти как new
. Это про инициализацию ссылочных структур, которые не могут существовать без подготовки. Вся фишка в том, что slice
, map
и chan
— это не просто типы, а компактные дескрипторы, указывающие на живую структуру в рантайме.
Когда вы пишете 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
защищает от этого, создавая слайс, который реально готов к работе.
Если вы думаете, что 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
или его части.
Когда вы пишете:
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
— и всё.
Начнём с наблюдения: в продакшене 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 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, научитесь проводить миграции и эффективно работать с запросами. Поднимите свой уровень работы с данными на новый уровень.