Одно из первых, что узнаёт .NET-разработчик, — деление типов на значимые (value types: struct, enum, примитивы) и ссылочные (reference types: class, interface, delegate, массив, string). Это деление определяет, что произойдёт при присваивании и передаче в метод, и является свойством самого типа: автор решает один раз через class или struct.
Первая крупная перенастройка мышления в Go: такого деления как категории языка не существует. В Go есть ровно одно правило — при присваивании и передаче аргумента значение всегда копируется. «Ссылочное» поведение здесь — не категория типа, а следствие того, что внутри скопированного значения лежит указатель. Эта глава ставит правильную ментальную модель до того, как мы перейдём к коллекциям (которые её наглядно демонстрируют).
Чтобы было от чего отталкиваться, вспомним модель C#:
- Значимые типы (
int,bool,double,struct,enum): переменная хранит сами данные; присваивание и передача копируют данные целиком. - Ссылочные типы (
class,interface,delegate, массивT[],string, record-class): переменная хранит ссылку на объект в куче; присваивание и передача копируют ссылку, а не объект, — поэтому несколько переменных видят один объект.
Ключевое: семантику задаёт автор типа (написал class — ссылочный, struct — значимый), и в месте использования она невидима. По строке Foo b = a; нельзя сказать, скопировались данные или ссылка, — это зависит от того, чем объявлен Foo. Плюс к этому в .NET прочно сидит фольклор «значимый тип → стек, ссылочный → куча» и боксинг при превращении value-типа в object.
В Go всё это устроено иначе — и проще, и непривычнее.
В Go присваивание b := a и вызов f(a) всегда копируют значение a. Без исключений. Нет скрытого правила «этот тип ссылочный, поэтому копируем ссылку». Разница лишь в том, что является «значением» для конкретного типа:
- для числа,
bool,struct, массива «значение» — это сами данные; копия дублирует их целиком и получается независимой; - для указателя
*T«значение» — это адрес (одно машинное слово); копия дублирует адрес, и обе копии указывают на один и тот же объект.
// Значение-данные: независимая копия
type Point struct{ X, Y int }
p := Point{1, 2}
q := p // копируются все поля
q.X = 99
fmt.Println(p.X, q.X) // 1 99 — p и q независимы
// Значение-указатель: копируется адрес, объект общий
pp := &Point{1, 2}
qp := pp // копируется адрес (не объект)
qp.X = 99
fmt.Println(pp.X, qp.X) // 99 99 — обе переменные смотрят на один объектflowchart TD
subgraph VAL["b := a, где значение — это данные (struct/массив)"]
A1["a: {X:1, Y:2}"]
B1["b: {X:99, Y:2}"]
N1["Независимы: правка b не видна в a"]
end
subgraph PTR["q := p, где значение — это указатель"]
P1["p: addr"]
Q1["q: addr (копия адреса)"]
OBJ["объект {X:99, Y:2}"]
P1 --> OBJ
Q1 --> OBJ
N2["Общий объект: правка через q видна через p"]
end
Обратите внимание: передача указателя — это тоже передача по значению. Вы копируете адрес. В Go нет «передачи по ссылке» в смысле C#-ного ref; чтобы расшарить данные, вы явно передаёте указатель, и сам этот указатель копируется как обычное значение.
То, что в Go по привычке называют «ссылочными типами» — слайс, мапа, канал, — не отдельная категория. Каждый из них — это маленькое значение-хедер, внутри которого лежит указатель. Когда вы копируете такой хедер (по значению!), вы дублируете и указатель внутри — поэтому обе копии делят указуемые данные.
Вот что физически представляет собой «значение» для каждого вида типов и что происходит при копировании:
| Тип | Что есть «значение» | При копировании (b := a) |
|---|---|---|
int, float64, bool |
сами данные | независимая копия |
struct, [N]T (массив) |
все поля/элементы инлайн | независимая копия (на один уровень вглубь) |
*T (указатель) |
адрес (1 слово) | копия адреса → общий объект |
[]T (слайс) |
хедер {ptr, len, cap} |
копия хедера; ptr общий → общий backing-массив |
map[K]V |
указатель на хеш-таблицу рантайма | копия указателя → одна и та же мапа |
chan T |
указатель на структуру канала | копия указателя → один и тот же канал |
string |
хедер {ptr, len}, байты неизменяемы |
копия хедера; байты общие, но иммутабельны |
interface (any) |
пара {тип, указатель на данные} |
копия пары |
func |
указатель на функцию/замыкание | копия указателя |
Называть слайс/мапу/канал «ссылочными» как сокращение — допустимо, и вы будете встречать этот термин в статьях. Но важно держать в голове механизм: это копирование по значению хедера, внутри которого указатель, а не категория типа, как class в C#. В Go нет правила «тип X — ссылочный»; есть лишь вопрос «содержит ли значение указатель».
Параллель с .NET: в C#
stringи массив — ссылочные типы по решению авторов. В Gostring— это значение-хедер (две машинных слова), а массив[N]T— вовсе значение-данные (копируется целиком!). То есть прямой перенос интуиции «массив — ссылочный» из C# в Go даёт неверный результат — массивы Go копируются полностью. Подробно это разобрано в главе Коллекции.
Это и есть инверсия модели. В C# семантику решает автор типа — один раз, для всех применений. В Go решаете вы, в каждом месте использования, выбирая значение или указатель:
type Counter struct{ n int }
func incCopy(c Counter) { c.n++ } // мутирует КОПИЮ — снаружи не видно
func incShare(c *Counter) { c.n++ } // мутирует оригинал ЧЕРЕЗ указатель
x := Counter{}
incCopy(x)
fmt.Println(x.n) // 0 — передали копию
incShare(&x)
fmt.Println(x.n) // 1 — передали указательОдин и тот же тип Counter участвует и в копирующей, и в разделяющей передаче — разница в сигнатуре функции и в месте вызова, а не в объявлении типа.
| C# / .NET | Go | |
|---|---|---|
| Кто решает семантику | автор типа (class vs struct) |
место использования (значение T vs указатель *T) |
| Категория типа | value type / reference type (фиксирована) | такой категории нет |
Что копируется при b := a |
для class — ссылка, для struct — данные |
всегда «значение»: для T — данные, для *T — адрес |
| Явность общего доступа | неявна (зависит от типа) | явна (виден * / &) |
Параллель с .NET: ближайший аналог Go-подхода — модификаторы
ref/out/in, которыми в C# можно для значимого типа осознанно выбрать передачу по ссылке прямо в месте вызова. По сути Go всегда работает в этом режиме «решаю на месте», только инструмент один — указатель*T, а само ключевое словоrefотсутствует. Механика указателей (когда брать*T, value- vs pointer-ресиверы, отсутствие арифметики) детально разобрана в Разделе 2.
Здесь .NET-интуиция «struct — значение, значит копия полностью независима» подводит. Копия структуры копирует её поля по значению — но если поле является слайсом или мапой (то есть хедером с указателем), копия разделит с оригиналом указуемые данные. Копия получается поверхностной (shallow).
type Cart struct {
Owner string // строка-значение
Items []string // слайс = хедер с указателем
}
a := Cart{Owner: "Alice", Items: []string{"book"}}
b := a // копия структуры по значению
b.Owner = "Bob" // независимо: поле-строка перепривязано в b
b.Items[0] = "pen" // НЕ независимо: слайсы делят backing-массив
fmt.Println(a.Owner) // Alice — поле-значение скопировано независимо ✅
fmt.Println(a.Items) // [pen] — слайс общий, оригинал изменился ❌ (если не ждали)flowchart TD
subgraph C["b := a — копия структуры Cart"]
AO["a.Owner: 'Alice'"]
BO["b.Owner: 'Bob' (своя строка)"]
AI["a.Items: хедер"]
BI["b.Items: хедер (копия)"]
BACK["backing-массив: ['pen']"]
AI --> BACK
BI --> BACK
end
Поле Owner после копии независимо, а Items у обеих структур указывает на один backing-массив. Чтобы получить действительно независимую копию, разделяемые поля нужно клонировать явно:
b := a
b.Items = slices.Clone(a.Items) // теперь Items независим ✅ (Go 1.21+)Это прямое следствие правила «копируется значение, а значение слайса — это хедер с указателем». Тот же эффект ждёт вас при копировании структуры с полем-мапой или каналом.
Важная развилка, на которой спотыкаются почти все выходцы из .NET. В мире C# крепко сидит «значимый тип → стек, ссылочный → куча». В Go это две независимые оси:
- значение или указатель — это про семантику копирования (о чём вся эта глава);
- стек или куча — это про то, где физически живёт память, и решает это escape analysis на этапе компиляции, отдельно от выбора значение/указатель.
Структура (значение!) может оказаться в куче, если она «убегает» из функции. Указатель может спокойно указывать на переменную, оставшуюся на стеке, — компилятор гарантирует, что она проживёт достаточно долго, поэтому в Go (в отличие от C/C++) нельзя получить «висячий указатель» на освобождённый кадр стека. То есть наличие * ничего не говорит о куче, а наличие struct — о стеке.
Параллель с .NET: в C# корреляция «struct ≈ стек/инлайн, class ≈ куча» в целом работает и формирует интуицию. В Go её нужно сознательно отключить: где живёт память — решает escape analysis (Раздел 2), а не категория «значение/указатель». Это ортогональные вещи.
-
Переменная цикла
range— копия.for _, v := range sкладёт вvкопию элемента; запись вvслайс не меняет.nums := []int{1, 2, 3} for _, v := range nums { v *= 10 // меняем копию } fmt.Println(nums) // [1 2 3] — без изменений for i := range nums { nums[i] *= 10 // изменяем по индексу } fmt.Println(nums) // [10 20 30] ✅
-
Value-ресивер метода получает копию. Метод с ресивером-значением (
func (c Counter) ...) не может изменить оригинал — нужен pointer-ресивер. Детали — в Разделе 2. -
Сравнимость
==. Числа, строки,bool, указатели, массивы и структуры из сравнимых полей — сравнимы оператором==. А вот слайсы, мапы и функции==не поддерживают (их можно сравнивать только сnil) — как раз потому, что это «хедеры с указателем», и что значило бы их равенство, неоднозначно.
- В Go нет категорий «значимый тип / ссылочный тип». Есть одно правило: присваивание и передача аргумента всегда копируют значение.
- «Значение» — это либо сами данные (числа,
struct, массив), либо маленький хедер, внутри которого может лежать указатель (слайс, мапа, канал, строка, интерфейс, функция). Отсюда «ссылочное» поведение — не свойство типа, а следствие указателя внутри значения. - Делиться данными вы решаете явно и в месте использования — через указатель (
*T/&), а не через выборclass/structавтором типа. Передача указателя — это тоже копирование (адреса). - Копия структуры со слайсом/мапой внутри — мелкая: вложенные разделяемые данные остаются общими; для независимости их клонируют (
slices.Cloneи т.п.). - value/pointer ≠ stack/heap. Где живёт память, решает escape analysis (Раздел 2), и это ортогонально семантике копирования.
Дальше — три встроенных типа-коллекции Go, на которых это правило видно в действии: массив копируется целиком (значение-данные), а слайс и мапа делят память (хедер с указателем).
⌂ Главная · ↑ Раздел · ← Предыдущий: Спартанский подход · → Следующий: Коллекции