Deep Engineering

ЗАМЕР

bench/gostruct/sets.go

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/interview/golang/structs
Как запустить
go run bench/gostruct/internals.go
bash bench/gostruct/compile.sh
go run bench/gostruct/sets.go
GOEXPERIMENT=noswissmap go run bench/gostruct/sets.go
/usr/local/go1.25.1/bin/go run bench/gostruct/sets.go
go run bench/gostruct/practice.go
bash bench/gostruct/example.sh

Запись прогона

Замеры для урока «Структуры и методы»

Файл Что делает
internals.go восемь наблюдений без единого замера времени: нулевое значение без конструктора, копия при присваивании, передаче и range, поверхностность копии, сравнение == и структура ключом карты, размер и выравнивание при двух порядках полей, пустая структура, метод как функция с получателем и определённый тип без методов базового, анонимная структура; после аудита 14.10.2026 — раздел 9 (slices.Clone/maps.Clone поверхностны, массив указателей копирует указатели, рекурсивный Clone) и раздел 10 (поле any: сравнимо при компиляции, паника при выполнении)
compile.sh собирает cases/* (или проверяет их go vet и golangci-lint) и печатает ответ инструмента: == и ключ карты у структуры со срезом, метод на чужом типе, смешанный и позиционный литерал, копия структуры с sync.Mutex, анализатор fieldalignment
cases/*/ подопытные программы к compile.sh, у каждой свой go.mod; большинство намеренно не собирается
sets.go память карты на миллион строковых ключей со значениями struct{}, bool и int64; размер слота «ключ–значение»
example.sh, cases/example/ полный короткий пример урока (go run . в чистом каталоге с go.mod)
practice.go источник ответов двух практических задач урока
go run bench/gostruct/internals.go
bash bench/gostruct/compile.sh
go run bench/gostruct/sets.go
GOEXPERIMENT=noswissmap go run bench/gostruct/sets.go
/usr/local/go1.25.1/bin/go run bench/gostruct/sets.go
go run bench/gostruct/practice.go
bash bench/gostruct/example.sh

Записи прогонов — в runs/, go1.24.7 linux/amd64, 2 ядра: internals.txt, compile.txt, sets.txt, sets-noswissmap.txt (та же go1.24.7 с прежней реализацией карт), example.txt, practice.txt; на go1.25.1 — internals-go1.25.txt, compile-go1.25.txt, example-go1.25.txt, sets-go1.25.txt: отличаются от go1.24.7 только строкой версии. Сверено 14.10.2026; Go 1.26 и 1.27 не измерялись. golangci-lint в compile.sh — версии 2.5.0, собранный go1.25.1.

Что получилось

Нулевое значение может быть рабочим — если тип так написан. var mu sync.Mutex сразу берётся Lock, var buf bytes.Buffer сразу пишется. Свой тип Stats, который создаёт карту при первой записи, тоже работает без конструктора, а Registry, который ждёт карту от конструктора, падает: assignment to entry in nil map.

Присваивание копирует структуру целиком, но копия поверхностная. После b := a и правок в b у a не изменились строка и массив [3]int, а изменились элемент среза, значение в карте и значение под указателем: unsafe.Sizeof(a) = 80 — копируются заголовки, а не данные за ними. range по срезу структур отдаёт копию: v.X = 0 не меняет pts[0].X. Копию структуры с sync.Mutex компилятор пропускает, а go vet ловит в трёх местах.

== у структуры — пока сравнимы все поля. Point{1, 2} == Point{1, 2} — true, Point работает ключом карты. Структура с полем []string не сравнивается (struct containing []string cannot be compared) и не может быть ключом (invalid map key type Team), а за интерфейсом сравнение собирается и паникует: comparing uncomparable type main.Team; ключом map[any]int — тоже паника, с другим текстом: hash of unhashable type main.Team. Указатели сравниваются адресами: две структуры с указателями на равные числа не равны.

Порядок полей меняет размер. struct{ a bool; b int64; c bool } — 24 байта, смещения 0, 8, 16; struct{ b int64; a bool; c bool } — 16, смещения 0, 8, 9. Полезных байт в обоих 10. fieldalignment: struct of size 24 could be 16 — в обычный go vet он не входит, в cases/fieldalign/.golangci.yml он включён явно.

Пустая структура весит 0 — кроме как в конце другой структуры. struct{ z struct{}; n int64 } — 8 байт, struct{ n int64; z struct{} } — 16: за пустым полем в конце компилятор добавляет байт (cmd/compile/internal/types/size.go, строки 517–523 в go1.24.7), и выравнивание делает из него восемь.

Поэтому map[string]struct{} на go1.24 весит столько же, сколько map[string]bool. Миллион строковых ключей: 56 МБ, 56 МБ и 56 МБ у int64, кратность bool к struct{} — 1.00. Слот карты — структура «ключ, значение», и struct{ key string; elem struct{} } весит те же 24 байта, что с bool и int64. На go1.25.1 — те же 56 МБ. С прежней реализацией карт (GOEXPERIMENT=noswissmap) — 40, 42 и 58 МБ: там ключи и значения корзины лежат раздельно.

Метод — функция с получателем первым аргументом. Celsius.Fahrenheit(100) даёт то же, что Celsius(100).Fahrenheit(), а тип выражения — func(main.Celsius) float64. Метод на time.Duration и на int объявить нельзя: cannot define new methods on non-local type. Новый тип MyDuration с тем же базовым int64 методов time.Duration не получает: печатается 1500000000, а не 1.5s.

Литерал: имена полей или позиции. Смешивать нельзя (mixture of field:value and value elements). Позиционный литерал чужого типа собирается, но go vet предупреждает (struct literal uses unkeyed fields), а после того как в тип добавили поле, перестаёт собираться: too few values in struct literal of type geo.Point.

Clone тоже поверхностный (раздел 9). После slices.Clone у [][]int правка c[0][0] видна в исходном (s[0][0] = 99), после maps.Clone у map[string][]int — тоже; у []int и map[string]int — нет. b := a для [2]*int копирует указатели: *a[0] = 99. Метод Doc.Clone, клонирующий каждый внутренний срез, даёт независимую копию: doc.Grid[0][0] = 1.

Поле-интерфейс (раздел 10). type Key struct{ X any } сравним при компиляции; Key{[]int{1}} == Key{[]int{1}} — паника comparing uncomparable type []int, вставка ключом карты — hash of unhashable type []int.

Скрипт

143 строк
//go:build ignore

// Множество на карте: сколько памяти экономит значение struct{}.
//
// ЗАЧЕМ ЭТА ПРОГРАММА. map[string]struct{} советуют как множество, «потому что
// пустая структура весит 0 байт». Само утверждение верно — unsafe.Sizeof
// пустой структуры равен нулю (internals.go, раздел 6). Но карта хранит не
// значение отдельно, а пару ключ–значение, и пара — это тоже структура. Если
// пустое значение стоит в ней последним, компилятор добавляет к паре байт, а
// выравнивание превращает его в целое слово. Сколько от экономии остаётся,
// видно только замером.
//
// ЧТО МЕРИТСЯ. Три карты на один и тот же миллион строковых ключей:
// значения struct{}, bool и int64. Ключи создаются заранее и в замер не
// входят. Мерится прирост HeapAlloc после runtime.GC(): то, что карта держит
// живым. Каждая карта строится трижды, берётся наименьший прирост; округление
// до целого мегабайта убирает шум в сотню килобайт между запусками.
//
// ВТОРОЙ РАЗДЕЛ — модель слота: структура из ключа и значения, такая же, какую
// компилятор строит для слота карты (cmd/compile/internal/reflectdata/
// map_swiss.go, SwissMapGroupType). Её размер печатает unsafe.Sizeof.
//
// ЗАПУСК:
//
//	go run bench/gostruct/sets.go
//	GOEXPERIMENT=noswissmap go run bench/gostruct/sets.go   # прежняя реализация карт
package main

import (
	"fmt"
	"os"
	"runtime"
	"strconv"
	"strings"
	"unsafe"
)

const N = 1_000_000

func show(title string) {
	fmt.Println()
	fmt.Println(title)
	fmt.Println(strings.Repeat("─", len([]rune(title))))
}

func heap() uint64 {
	runtime.GC()
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	return m.HeapAlloc
}

var (
	sinkEmpty map[string]struct{}
	sinkBool  map[string]bool
	sinkInt   map[string]int64
)

// grow строит карту и возвращает, сколько байт кучи она держит. Карта
// остаётся в глобальной переменной до замера, затем отпускается.
func grow(keys []string, build func([]string)) uint64 {
	best := ^uint64(0)
	for round := 0; round < 3; round++ {
		before := heap()
		build(keys)
		if d := heap() - before; d < best {
			best = d
		}
		sinkEmpty, sinkBool, sinkInt = nil, nil, nil
	}
	return best
}

type slotEmpty struct {
	key  string
	elem struct{}
}

type slotBool struct {
	key  string
	elem bool
}

type slotInt struct {
	key  string
	elem int64
}

func main() {
	exp := os.Getenv("GOEXPERIMENT")
	if exp == "" {
		exp = "по умолчанию"
	}
	fmt.Printf("%s %s/%s | множество на карте: сколько весит значение (GOEXPERIMENT: %s)\n",
		runtime.Version(), runtime.GOOS, runtime.GOARCH, exp)

	keys := make([]string, N)
	for i := range keys {
		keys[i] = "key-" + strconv.Itoa(i)
	}

	empty := grow(keys, func(ks []string) {
		sinkEmpty = make(map[string]struct{})
		for _, k := range ks {
			sinkEmpty[k] = struct{}{}
		}
	})
	boolean := grow(keys, func(ks []string) {
		sinkBool = make(map[string]bool)
		for _, k := range ks {
			sinkBool[k] = true
		}
	})
	integer := grow(keys, func(ks []string) {
		sinkInt = make(map[string]int64)
		for _, k := range ks {
			sinkInt[k] = 1
		}
	})

	mb := func(b uint64) float64 { return float64(b) / 1e6 }
	show("1) КАРТА НА 1 000 000 СТРОКОВЫХ КЛЮЧЕЙ")
	fmt.Printf("  map[string]struct{}   %3.0f МБ\n", mb(empty))
	fmt.Printf("  map[string]bool       %3.0f МБ\n", mb(boolean))
	fmt.Printf("  map[string]int64      %3.0f МБ\n", mb(integer))
	fmt.Printf("  bool против struct{}: %.2f\n", float64(boolean)/float64(empty))
	fmt.Println("  Наименьший прирост HeapAlloc из 3 построений; ключи созданы")
	fmt.Println("  заранее и в замер не входят.")

	show("2) СЛОТ КАРТЫ: КЛЮЧ И ЗНАЧЕНИЕ ОДНОЙ СТРУКТУРОЙ")
	fmt.Printf("  struct{ key string; elem struct{} }  %d байт\n", unsafe.Sizeof(slotEmpty{}))
	fmt.Printf("  struct{ key string; elem bool }      %d байт\n", unsafe.Sizeof(slotBool{}))
	fmt.Printf("  struct{ key string; elem int64 }     %d байт\n", unsafe.Sizeof(slotInt{}))
	if strings.Contains(exp, "noswissmap") {
		fmt.Println("  Эта реализация карт так пары не хранит: в корзине сначала лежат")
		fmt.Println("  все ключи, затем все значения (runtime/map_noswiss.go), и")
		fmt.Println("  выравнивать пустое значение не к чему.")
		return
	}
	fmt.Println("  Пустое значение в конце пары получает байт, и выравнивание до 8")
	fmt.Println("  делает из пары те же 24 байта, что у bool и int64.")
}