golang

Golang: когда make, когда new

  • пятница, 25 апреля 2025 г. в 00:00:08
https://habr.com/ru/companies/otus/articles/903144/

Привет, Хабр!

Сегодня разберёмся, зачем в 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:

  1. Выделяет hmap — внутреннюю структуру заголовка карты.

  2. Создаёт массив из B бакетов (по дефолту 8, если вы не указали hint).

  3. Заполняет их служебными битовыми масками, хешами, empty markers, counters.

  4. Подготавливает механизм инкрементальной перестройки, если мапа начнёт расти.

Так выглядит внутренность 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 есть три чётких применения:

  1. Generic‑код. Когда вы не знаете, что за тип перед вами, и хотите просто сделать указатель на zero‑value. Никакой другой способ не даст вам этой универсальности:

    func NewZero[T any]() *T {
        return new(T)
    }
  2. Явная аллокация в куче. Бывает, что вы хотите гарантированно положить объект в heap, а не полагаться на escape analysis. new(T) делает это явно.

    pool := sync.Pool{
        New: func() any {
            return new(MyStruct)
        },
    }
  3. Создание указателей для опциональных значений. Например, если у вас в структуре:

    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. Они помогут вам не только разобраться в важных аспектах языка, но и дадут реальный опыт в создании эффективных и масштабируемых решений.