1.4Задачи «что выведет код»
Разберёшь короткие фрагменты Go по времени вычисления, копированию значений и общим ссылкам. Отделишь гарантированный результат от поведения, которое язык оставляет недетерминированным.
- Что здесь значение, а что ссылка? Слайс, мапа, канал, указатель, функция и интерфейс содержат внутри указатели; массив, структура и строка копируются целиком. На этом построена половина задач.
- Когда это вычисляется? Аргументы
deferвычисляются в момент объявления, а телоdeferвыполняется при выходе. Условиеrangeсчитается один раз, до цикла. - Кто на что смотрит, на переменную или на копию? Замыкание захватывает переменную, а в аргумент функции попадает копия значения.
- Есть ли тут недетерминированность? Порядок обхода мапы, выбор ветки
select, порядок выполнения горутин язык не определяет. Если задача про них, правильный ответ звучит как «не определено», и он же самый ценный. - Что произойдёт с памятью?
appendможет переиспользовать массив, а может аллоцировать новый, и от этого зависит, увидят ли изменения соседи.
Никогда не молчи и не гадай наугад. Рассуждай по шагам: «так, тут именованный результат,
значит return сначала присваивает… defer сработает после
присваивания, но до фактического возврата… значит он видит уже присвоенное значение…
получается 10». Даже если в итоге ошибёшься, интервьюер увидит рабочую модель, а её он и
оценивает. Отдельный приём: если ответ «не определено», скажи это прямо и объясни
почему. Кандидаты, уверенно называющие конкретный порядок обхода мапы, теряют больше, чем те,
кто честно говорит «порядок рандомизирован намеренно».
Вопросы
18Код
func f1() (result int) { // именованный результат
defer func() { result *= 2 }()
return 5
}
func f2() int { // анонимный результат
result := 5
defer func() { result *= 2 }()
return result
}
func f3() (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered: %v", r)
result = -1
}
}()
panic("boom")
}
func f4() (n int) {
defer func() { n++ }()
defer func() { n *= 10 }()
return 1
}
func main() {
fmt.Println(f1())
fmt.Println(f2())
fmt.Println(f3())
fmt.Println(f4())
}
105-1 recovered: boom11Почему: что на самом деле делает return
return X в Go распадается на три операции, строго в этом порядке:
- присвоить
Xпеременной-результату; - выполнить отложенные вызовы в порядке LIFO;
- фактически вернуть управление, отдав то, что лежит в переменной-результате сейчас.
Вся разница между f1 и f2 сводится к одному: существует ли эта
переменная как именованная. В f1 результат назван result, и
defer модифицирует ровно ту ячейку, из которой на шаге 3 будет взято значение:
result = 5 → result *= 2 → вернули 10.
В f2 результат анонимен. return result копирует значение локальной
переменной result в невидимый слот результата, и defer, изменяя
локальную result, к этому слоту уже никак не относится. Функция возвращает 5,
а result становится 10 в никуда.
f3 применяет ту же механику на практике и остаётся единственным способом вернуть
ошибку из recover. Без именованных результатов перехваченная паника не
превращается в error: писать некуда.
f4 проверяет порядок: отложенные вызовы выполняются LIFO, поэтому сначала
n *= 10 (1 → 10), затем n++ (10 → 11). Порядок объявления обратен
порядку выполнения.
Именованные результаты «протекают» и там, где их не ждут:
defer file.Close() в функции func read() (err error)
теряет ошибку закрытия. Писать надо так:
defer func(){ if cerr := f.Close(); cerr != nil && err == nil { err = cerr } }().
Для файла на запись это не педантизм: Close сбрасывает буферы, и именно там
обнаруживается «диск переполнен». Назвать этот приём выгодно: он показывает, что механику ты
умеешь применять, а не только помнишь.
Как звучит правильное объяснение на собесе
«return сначала присваивает результату, потом идёт defer, потом
собственно возврат. Если результат именованный, defer видит ту же переменную и
может её поменять, поэтому f1 вернёт 10. Если анонимный, значение уже скопировано, и
defer меняет локальную переменную впустую, так что f2 вернёт 5. На этом же
механизме держится превращение паники в ошибку через recover, и на нём же стоит
правильный defer Close(), который не теряет ошибку закрытия.»
Код
type T struct {
n int
}
func (t T) Show() { fmt.Println("Show (значение):", t.n) }
func (t *T) ShowP() { fmt.Println("ShowP (указатель):", t.n) }
func main() {
i := 0
defer fmt.Println("A defer со значением:", i)
defer func() { fmt.Println("B defer с замыканием:", i) }()
t := T{n: 1}
defer t.Show() // получатель-значение
defer t.ShowP() // получатель-указатель
i = 3
t.n = 2
fmt.Println("конец main:", i, t.n)
}
конец main: 3 2ShowP (указатель): 2Show (значение): 1B defer с замыканием: 3A defer со значением: 0Почему
Правило одно, но у него четыре следствия: defer вычисляет функцию и все её
аргументы немедленно, а вызывает при выходе.
- A.
iстоит аргументомfmt.Println, поэтому вычислен и скопирован в моментdefer, когдаiбыл равен 0. Последующееi = 3ни на что не влияет. - B. Аргументов у
func(){...}()нет вообще. Тело работает как замыкание, захватывает переменнуюi, а не её значение, и читает её при выполнении: 3. t.Show(). Получатель тоже считается аргументом, просто неявным. Метод объявлен на значении, поэтомуtкопируется в моментdefer: копия помнитn = 1.t.ShowP(). Метод на указателе, значит вычисляется и копируется&t, то есть сам указатель, а не то, на что он смотрит. Через него виден актуальныйn = 2.
Порядок вывода подчиняется LIFO: последний объявленный defer выполняется первым.
Вот почему defer trace(time.Now()) и
defer func(){ log.Println(time.Since(start)) }() делают разное. И поэтому
измерение времени пишут так: start := time.Now(); defer func(){
metrics.Observe(time.Since(start)) }(). А вот
defer metrics.Observe(time.Since(start)) зафиксирует нулевую длительность,
потому что time.Since вычислится немедленно. Эту ошибку регулярно допускают в
реальном коде, и на собесе её стоит назвать вслух.
Как звучит правильное объяснение на собесе
«defer значит "запомнить вызов и его аргументы сейчас, выполнить потом".
Аргументы, включая получателя метода, копируются в момент объявления. Поэтому
defer f(x) замораживает x, а defer func(){ f(x) }()
не замораживает: замыкание держит саму переменную. Если метод на значении, заморозится копия структуры;
если на указателе, заморозится указатель, и изменения будут видны. Выполняются в порядке
LIFO.»
Код
func main() {
for i := 0; i < 3; i++ {
defer fmt.Println("defer", i)
}
fmt.Println("конец цикла")
for i := 0; i < 3; i++ {
func() {
defer fmt.Println("вложенный defer", i)
}()
}
fmt.Println("конец main")
}
конец циклавложенный defer 0вложенный defer 1вложенный defer 2конец maindefer 2defer 1defer 0Почему
defer привязан к функции, а не к блоку. Ни фигурные скобки цикла, ни
if, ни switch не создают точку выполнения отложенных вызовов; её
создаёт только возврат из функции (или паника). Поэтому три defer из первого цикла
копятся в списке текущей функции и разряжаются в самом конце main, в порядке
LIFO.
Второй цикл показывает стандартное лекарство: обернуть тело итерации в немедленно вызываемую
анонимную функцию. Теперь у каждой итерации своя функция, и defer срабатывает
на её выходе сразу, в прямом порядке.
// Открывает 10 000 файлов и НИ ОДНОГО не закрывает до конца функции.
for _, name := range names {
f, err := os.Open(name)
if err != nil {
return err
}
defer f.Close() // ← накопится 10 000 отложенных Close
process(f)
}
На длинном списке это упрётся в лимит дескрипторов процесса и вернёт «too many open files».
Мягкий лимит (ulimit -n, часто 1024) Go с версии 1.19 при старте сам поднимает до
жёсткого, так что на тысяче файлов ошибки обычно не будет, но жёсткий лимит тоже конечен, а все
файлы висят открытыми до конца функции. То же самое с rows.Close() в цикле по
запросам к БД: соединения не возвращаются в пул, и пул исчерпывается. Лечится либо
анонимной функцией, либо явным f.Close() в конце итерации (но тогда придётся
аккуратно закрывать и на путях с ошибкой, так что обычно проще функция).
Отдельно про производительность: с Go 1.14 defer в большинстве случаев
инлайнится и стоит порядка одной-двух наносекунд. Это уже не тот дорогой
defer, о котором писали в 2017-м. Но defer в цикле остаётся
дорогим по другой причине: он не выполняется, а накапливается в списке, то есть
расходует память пропорционально числу итераций.
Как звучит правильное объяснение на собесе
«defer относится к функции, а не к блоку, поэтому в цикле он не выполняется на
каждой итерации, а копится и разряжается в конце функции в порядке LIFO. Для ресурсов внутри
цикла это утечка дескрипторов, поэтому тело итерации заворачивают в анонимную
функцию, чтобы у каждой итерации была своя точка выхода. В main из примера
сначала отработают все вложенные defer по ходу второго цикла, а три
"цикловых" сработают только после конец main, в обратном порядке.»
Код
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func() { defer wg.Done(); fmt.Print(i, " ") }()
}
wg.Wait()
fmt.Println()
var fns []func()
for _, s := range []string{"a", "b", "c"} {
fns = append(fns, func() { fmt.Print(s, " ") })
}
for _, f := range fns {
f()
}
fmt.Println()
ptrs := []*int{}
for i := 0; i < 3; i++ {
ptrs = append(ptrs, &i)
}
for _, p := range ptrs {
fmt.Print(*p, " ")
}
fmt.Println()
}
go 1.21 и ниже в go.mod):3 3 3c c c3 3 3Пять прогонов подряд - все пять одинаковые: старая семантика детерминирована, потому что все горутины читают одну ячейку уже после цикла.
Вывод начиная с Go 1.22:
0 1 2 - но в произвольном порядке; восемь прогонов подряд дали
2 1 0 пять раз и 2 0 1 три разаa b c0 1 2Вторая и третья строки, наоборот, детерминированы: там нет горутин, замыкания вызываются последовательно.
Почему: что именно изменили в Go 1.22
До Go 1.22 переменная цикла объявлялась один раз на весь цикл и переиспользовалась на
каждой итерации. Замыкание захватывает переменную, а не её значение, поэтому все три
горутины ссылались на одну и ту же ячейку памяти. К моменту их запуска цикл обычно уже
завершался, и в ячейке лежало финальное значение, 3 для счётчика и "c" для
range. Отсюда и &i: три указателя на одну ячейку.
Начиная с Go 1.22 переменная цикла объявляется заново на каждой итерации: формально компилятор создаёт новую переменную и копирует в неё значение перед телом итерации. Каждое замыкание получает свою ячейку, и задача перестаёт быть задачей.
Деталь про версию. Поведение определяется не версией установленного
компилятора, а строкой go 1.xx в go.mod модуля. Собранный Go 1.27
модуль с go 1.21 получит старую семантику. Именно поэтому вопрос всё ещё
живой: легаси-модулей с go 1.19 вокруг очень много, а миграция сводится к правке
одной строки, которую боятся трогать ровно из-за этого изменения.
Это вторая половина вопроса, и на ней срезаются те, кто выучил только про 1.22. Значения
теперь правильные, но порядок выполнения горутин языком не определён: планировщик
волен запустить их как угодно. На практике вывод часто получается 2 1 0:
у планировщика Go есть оптимизация «только что созданную горутину запускаем следующей»
(она кладётся в отдельную приоритетную ячейку, а не в хвост очереди), поэтому последняя
созданная часто и оказывается первой напечатавшей. Но это деталь реализации, а не
обещание языка, и опираться на неё нельзя.
Правильный ответ звучит как «выведет 0, 1 и 2 в произвольном порядке; если нужен
детерминированный порядок, надо писать в out[i] и печатать после
wg.Wait()».
Как писали до 1.22 - и почему это до сих пор встречается
// Способ 1: параметр функции, значение копируется при вызове go.
for i := 0; i < 3; i++ {
go func(i int) { fmt.Print(i, " ") }(i)
}
// Способ 2: затенение, новая переменная на итерацию вручную.
for i := 0; i < 3; i++ {
i := i // да, это легально и было идиомой
go func() { fmt.Print(i, " ") }()
}
// Оба способа под go 1.21 в go.mod печатают 0, 1 и 2, но в произвольном порядке.
// Шесть прогонов подряд дали: 2 0 1 / 2 1 0 / 2 1 0 / 2 1 0 / 2 1 0 / 2 0 1.
// (В самом фрагменте нет wg.Wait, так что для проверки его надо дописать,
// иначе main завершится раньше, чем горутины успеют напечатать хоть что-то.)
Оба приёма продолжают работать и на 1.22+, просто перестали быть обязательными. В новом коде
строка i := i теперь избыточна, и линтеры (copyloopvar) на неё
ругаются.
Как звучит правильное объяснение на собесе
«Замыкание захватывает переменную, а не значение. До Go 1.22 переменная цикла была одна на
весь цикл, поэтому все горутины видели её финальное значение, отсюда 3 3 3. С Go 1.22
на каждой итерации создаётся своя переменная, и выведется 0, 1, 2, но в произвольном
порядке, потому что порядок выполнения горутин не гарантирован. Семантику задаёт строка
go в go.mod, а не версия компилятора, так что в старом модуле,
собранном новым Go, поведение останется старым.»
Код
func main() {
a := []int{1, 2, 3, 4, 5}
b := a[1:3]
fmt.Println(len(b), cap(b))
b = append(b, 99)
fmt.Println(a)
fmt.Println(b)
}
2 4[1 2 3 99 5] ← четвёртый элемент a затёрт[2 3 99]Почему
Слайс устроен как заголовок из трёх полей: {ptr, len, cap}. Операция
a[1:3] не копирует данные, она делает новый заголовок на тот же массив:
указатель сдвигается на элемент 1, длина становится 2, а ёмкость считается до конца
массива: cap(b) = cap(a) - 1 = 4. Именно эта «лишняя» ёмкость и есть корень
проблемы.
append(b, 99) видит len(b) < cap(b) и понимает, что место есть,
а новый массив выделять не нужно. Он записывает 99 в следующую свободную ячейку
того же массива, а это физически a[3]. Значение 4 уничтожено.
Возвращается заголовок с len = 3, указывающий туда же.
len < cap. Если места хватает, запись идёт в существующий массив и видна всем,
кто на него смотрит. Если не хватает, выделяется новый массив, и слайсы «расходятся».
Поэтому результат append зависит не от кода рядом, а от ёмкости, которую
в коде обычно не видно.Где это встречается в реальном коде
// Популярный приём «отфильтровать без аллокации» легко превращается в баг,
// если вызывающий продолжает пользоваться исходным слайсом.
func filterInPlace(s []int, keep func(int) bool) []int {
out := s[:0] // тот же массив, len 0, cap полный
for _, v := range s {
if keep(v) {
out = append(out, v) // пишем поверх s
}
}
return out
}
a := []int{1, 2, 3, 4, 5}
b := filterInPlace(a, func(v int) bool { return v%2 == 0 })
// b = [2 4], но a = [2 4 3 4 5] - исходный слайс изуродован
Как звучит правильное объяснение на собесе
«Срез не копирует данные, он делает новый заголовок на тот же массив, и cap
считается до конца массива, а не до конца среза. У b получилось
len 2, cap 4, поэтому append нашёл свободное место в том же массиве
и записал 99 в ячейку, которая для a служит четвёртым элементом. Если нужно
гарантировать независимость, надо либо явно копировать через slices.Clone, либо
ограничить ёмкость трёхиндексным срезом a[1:3:3].»
Код
func main() {
x := make([]int, 3, 4) // len 3, cap 4, есть одна свободная ячейка
copy(x, []int{1, 2, 3})
y := append(x, 4)
z := append(x, 5)
fmt.Println(x, y, z)
p := []int{1, 2, 3} // len 3, cap 3, свободных ячеек нет
q := append(p, 4)
q[0] = 99
fmt.Println(p, q)
}
[1 2 3] [1 2 3 5] [1 2 3 5] ← y «потерял» четвёрку[1 2 3] [99 2 3 4] ← а тут слайсы независимыПочему
Это самая коварная пара примеров во всей теме, потому что они выглядят одинаково, а ведут себя противоположно. Разница ровно одна: хватило ли ёмкости.
Первый случай. При cap(x) = 4 и len(x) = 3 свободная ячейка
есть. append(x, 4) записывает 4 в array[3] и возвращает заголовок
{ptr, 4, 4}. Затем append(x, 5) смотрит на тот же
x, у которого по-прежнему len 3, и записывает 5 в ту же
array[3], затирая четвёрку. Теперь y и z держат два
заголовка на один массив, и оба показывают [1 2 3 5]. Сам x
остаётся [1 2 3], потому что его len никто не менял:
append не модифицирует исходный заголовок, он возвращает новый.
Второй случай. cap(p) = 3, места нет. append выделяет новый
массив (обычно вдвое больше), копирует туда три элемента, дописывает четвёртый. Слайс
q смотрит на новую память, поэтому q[0] = 99 никак не задевает
p.
Поведение append зависит от cap, а его в коде обычно
не видно. Слайс, пришедший из make([]T, 0, 100), из
json.Unmarshal или из другой функции, имеет непредсказуемую ёмкость. Отсюда
железное правило: всегда присваивай результат append обратно
(s = append(s, v)) и никогда не считай, что два слайса независимы, если ты
сам их не скопировал. Если функция принимает слайс и хочет его сохранить у себя,
она обязана сделать slices.Clone. Это ровно та причина, по которой в
net/http и в драйверах БД так много защитных копий.
Как растёт ёмкость
Точные коэффициенты остаются деталью реализации и менялись между версиями, но модель такая: для
маленьких слайсов ёмкость примерно удваивается, а после порога (около 256 элементов с Go 1.18)
рост становится плавнее и с ростом длины сходится к ×1.25, чтобы не тратить память на
больших объёмах: сразу за порогом коэффициент ещё около двух и снижается постепенно. Поэтому
append работает амортизированно за O(1): отдельная операция может стоить O(n) на
копировании, но суммарно за n добавлений копирований будет O(n). Заранее известный размер
стоит задавать: make([]T, 0, n) убирает все реаллокации.
var s []int
prev := cap(s)
for i := 0; i < 2000; i++ {
s = append(s, i)
if cap(s) != prev {
fmt.Printf("len=%d cap=%d\n", len(s), cap(s))
prev = cap(s)
}
}
// len=1 cap=4, len=5 cap=8, len=9 cap=16, len=17 cap=32, ... len=257 cap=512,
// а дальше шаг роста уменьшается: 512 -> 848 -> 1280 -> 1792 -> 2560
Как звучит правильное объяснение на собесе
«append смотрит на len и cap. Если места хватает, он
пишет в существующий массив и возвращает заголовок с увеличенной длиной; исходный слайс при
этом не меняется, но данные общие. Если места нет, выделяется новый массив, и слайсы
становятся независимыми. В первом примере ёмкости хватило, поэтому оба append
писали в одну и ту же ячейку, и второй затёр первый. Во втором не хватило, поэтому
q уехал на новую память. Из этого следует правило: не полагаться на то,
скопировал append данные или нет, а копировать явно, когда независимость
нужна.»
Код
func main() {
m := []int{1, 2, 3, 4, 5}
n := m[1:3:3] // low : high : max
fmt.Println(len(n), cap(n))
n = append(n, 99)
fmt.Println(m)
fmt.Println(n)
}
2 2 ← cap обрезан до max - low = 3 - 1 = 2[1 2 3 4 5] ← исходный слайс цел[2 3 99]Почему
Full slice expression a[low:high:max] задаёт все три поля заголовка явно:
ptr = &a[low], len = high - low, cap = max - low.
Действует ограничение low ≤ high ≤ max ≤ cap(a).
В примере cap(n) == len(n) == 2, свободного места нет вообще. Поэтому
append обязан выделить новый массив и скопировать туда данные, а исходный
m остаётся физически недосягаем. Ровно этого мы и добивались.
Приём применяют, когда отдают наружу кусок своего буфера и не хотят, чтобы вызывающий его случайно испортил:
type Buffer struct {
data []byte
}
// Плохо: вызывающий может append'нуть и затереть наши данные за границей len.
func (b *Buffer) Head(n int) []byte { return b.data[:n] }
// Хорошо: cap обрезан, любой append у вызывающего сделает копию.
func (b *Buffer) HeadSafe(n int) []byte { return b.data[:n:n] }
Идиома s[:n:n] (или s[:len(s):len(s)]) читается как «отдать
read-append-safe вид на слайс». Ровно в таком виде она встречается в стандартной библиотеке
и в хорошем чужом коде.
a[:n:n] защищает только от append за границей длины. Прямая запись
s[0] = 42 по-прежнему меняет исходный массив, ёмкость тут ни при чём. Если
нужна настоящая изоляция, остаётся только копия: slices.Clone(s) или
append([]T(nil), s...). Кто разводит эти два уровня вслух, сразу выделяется:
большинство кандидатов считают, что [:n:n] «делает слайс безопасным».
Как звучит правильное объяснение на собесе
«Третий индекс задаёт cap: a[low:high:max] даёт
cap = max - low. Когда я пишу a[1:3:3], ёмкость становится равной
длине, свободного места нет, и любой append вынужден скопировать данные в новый
массив. Это стандартный приём, когда отдаёшь наружу часть своего буфера: сосед не сможет
незаметно записать поверх твоих данных через append. От прямой записи по индексу
это не защищает, там нужна копия.»
Код
func mod(s []int) {
s[0] = 100 // запись по индексу
s = append(s, 4) // может переаллоцировать
s[1] = 200 // запись, но куда?
}
func main() {
a := []int{1, 2, 3} // len 3, cap 3
mod(a)
fmt.Println(a, len(a), cap(a))
b := make([]int, 3, 4) // len 3, cap 4
copy(b, []int{1, 2, 3})
mod(b)
fmt.Println(b, len(b), cap(b))
}
[100 2 3] 3 3 ← append переаллоцировал, s[1]=200 ушло в новый массив[100 200 3] 3 4 ← ёмкости хватило, писали в тот же массивПочему
В Go всё передаётся по значению, включая слайс. Функция получает копию заголовка
{ptr, len, cap}. Отсюда два разных эффекта, которые часто путают в один:
- Запись по индексу проходит через указатель, который в копии заголовка тот же.
Поэтому
s[0] = 100меняет данные, видимые снаружи. Работает всегда. appendменяет поляlen/capкопии заголовка, а копия локальна. Даже если данные записались в общий массив, снаружиlenостался старым, и новый элемент не виден. Именно поэтому обязательно писатьs = append(s, v)и возвращать результат.
Первый случай: cap(a) = 3, места нет, append выделил новый массив.
Локальный s теперь смотрит туда, и s[1] = 200 пишет в новую
память, которую снаружи никто не видит. На выходе [100 2 3].
Второй случай: cap(b) = 4, место есть, append дописал в тот же
массив. Локальный s всё ещё указывает на массив b, поэтому
s[1] = 200 меняет b[1]. Получается [100 200 3], а
четвёрка лежит в b[3], за границей длины, и снаружи не видна.
Она объединяет три механики сразу: передачу заголовка по значению, зависимость
append от cap и разницу между «изменить данные» и «изменить
заголовок». Кандидат, который разложил её по этим трём уровням, фактически закрыл всю тему
слайсов одним ответом. Формулировка-эталон: «слайс это значение, которое ссылается на
общие данные; функция может менять данные, но не может поменять мой заголовок».
Если нужно, чтобы функция могла, передавай *[]int или возвращай новый слайс.
Как надо было написать
// Вариант 1: вернуть результат, так идиоматичнее.
func mod(s []int) []int {
s[0] = 100
s = append(s, 4)
s[1] = 200
return s
}
a = mod(a)
// Вариант 2: указатель на слайс, когда возврат неудобен (редко).
func modP(s *[]int) {
(*s)[0] = 100
*s = append(*s, 4)
}
Как звучит правильное объяснение на собесе
«Слайс передаётся по значению, функция получает копию заголовка, но указатель внутри тот же.
Поэтому запись по индексу видна снаружи всегда, а append снаружи не виден: он меняет
len локальной копии. Дальше всё зависит от ёмкости: если append
переаллоцировал, локальный слайс уехал на новую память и последующие записи наружу не видны;
если места хватило, они по-прежнему идут в исходный массив. Поэтому в Go и пишут
s = append(s, v), а функции, которые дописывают в слайс, возвращают его.»
Код
type P struct {
Name string
Age int
}
func main() {
ps := []P{{"Аня", 30}, {"Боря", 40}}
for _, p := range ps {
p.Age++ // меняем копию
}
fmt.Println(ps)
for i := range ps {
ps[i].Age++ // меняем оригинал
}
fmt.Println(ps)
arr := [3]int{1, 2, 3} // МАССИВ
fmt.Print("массив: ")
for i, v := range arr {
if i == 0 {
arr[1] = 100
}
fmt.Print(v, " ")
}
fmt.Println(arr)
sl := []int{1, 2, 3} // СЛАЙС
fmt.Print("слайс: ")
for i, v := range sl {
if i == 0 {
sl[1] = 100
}
fmt.Print(v, " ")
}
fmt.Println(sl)
}
[{Аня 30} {Боря 40}] ← ничего не изменилось[{Аня 31} {Боря 41}]массив: 1 2 3 [1 100 3] ← цикл не увидел записислайс: 1 100 3 [1 100 3] ← цикл увидел записьПочему часть первая: переменная цикла - копия
for _, p := range ps на каждой итерации копирует элемент в переменную
p. Копия структуры полностью независима, p.Age++ инкрементирует
её и выбрасывает при следующей итерации. Чтобы изменить оригинал, нужно обращаться по
индексу: ps[i].Age++ (или брать &ps[i]).
Побочный эффект, о котором стоит знать: копирование не бесплатно. Если структура большая -
скажем, 200 байт, - цикл for _, v := range bigStructs копирует по 200 байт на
итерацию. Для горячего кода это заметно, и там пишут for i := range s с
обращением &s[i].
Почему часть вторая: range вычисляет выражение один раз
Перед началом цикла Go один раз вычисляет выражение справа от range и работает с
этой копией значения. И вот тут массив и слайс расходятся радикально:
- Массив - это значение целиком, все элементы. Копируется весь массив. Цикл идёт по
копии, поэтому
arr[1] = 100внутри тела на итерации не влияет: копия сделана до цикла. Печатается1 2 3, хотя самarrуже[1 100 3]. - Слайс - это заголовок
{ptr, len, cap}. Копируется заголовок, но данные общие. Поэтомуsl[1] = 100видно на следующей итерации: печатается1 100 3.
Отсюда же следует ещё одно свойство: число итераций фиксируется до цикла. Если внутри
range sl делать sl = append(sl, x), цикл всё равно пройдёт
исходное число раз - бесконечного цикла не будет, потому что len взят один раз.
Это отличает Go от языков, где такой код зацикливается.
range по указателю на массив
for i, v := range &arr - легально, и массив в этом случае не
копируется: итерация идёт по оригиналу, изменения видны. Это редкий, но настоящий
приём для больших массивов в горячем коде. Заодно он хорошо иллюстрирует главный тезис:
разница в поведении не в слове range, а в том, что именно копируется -
значение или ссылка на него.
Как звучит правильное объяснение на собесе
«range делает две вещи, о которых надо помнить. Первое: он один раз вычисляет
выражение справа. Для массива это копия всех элементов, поэтому изменения внутри цикла не
видны; для слайса - копия заголовка, а данные общие, поэтому видны. Второе: переменная
значения - это копия элемента, менять её бессмысленно, надо идти через индекс. Оба факта
сводятся к одному: в Go всё копируется по значению, а разница между массивом и слайсом -
в том, что именно лежит в этом значении.»
Код
func main() {
m := map[string]int{"a": 1, "b": 2, "c": 3, "d": 4, "e": 5, "f": 6}
for r := 0; r < 3; r++ {
fmt.Print("проход ", r, ": ")
for k := range m {
fmt.Print(k, " ")
}
fmt.Println()
}
m2 := map[int]int{1: 1, 2: 2, 3: 3, 4: 4}
seen := 0
for k := range m2 {
seen++
if k == 1 {
m2[100] = 100 // добавляем во время обхода
delete(m2, 4) // и удаляем
}
}
fmt.Println("итераций:", seen, "итог:", len(m2))
}
проход 0: a b c d e f / проход 1: e f a b c d /
проход 2: b c d e f aпроход 0: a b c d e f / проход 1: a b c d e f /
проход 2: d e f a b c ← первые два прохода случайно совпали, это тоже бываетА число итераций во втором цикле - 3 или 4 в зависимости от запуска: на 300 прогонах вышло 4 итерации 267 раз и 3 итерации 33 раза. Итоговый
len(m2) при этом
всегда 4.Почему порядок случаен - и почему это сделано специально
Рантайм Go намеренно рандомизирует стартовую позицию обхода мапы. Это не побочный эффект хеширования, а сознательное решение: чтобы никто не начал полагаться на порядок, который остаётся деталью устройства мапы. Иначе любая переделка внутренностей (а их переписывали: в Go 1.24 мапу перевели на SwissTable) ломала бы код по всей экосистеме. Плюс защита от HashDoS: подобрать ключи с одинаковым хешем не выйдет, потому что сид у каждой мапы свой и берётся при её создании.
Практическое следствие: если порядок нужен, его надо создать явно.
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys) // или slices.Sort(keys)
for _, k := range keys {
fmt.Println(k, m[k])
}
// Для m := map[string]int{"b": 2, "a": 1, "c": 3} выведет всегда одно и то же:
// a 1
// b 2
// c 3
Почему модификация во время обхода даёт разное число итераций
Спецификация Go описывает это буквально в двух правилах:
- Удаление безопасно и предсказуемо. Если ключ удалён во время обхода, а цикл до него
ещё не дошёл, он не будет выдан. Поэтому
for k := range m { delete(m, k) }законно очищает мапу (хотяclear(m)с Go 1.21 лучше). - Добавление недетерминированно. Новый ключ может быть выдан в этом же обходе, а может и нет. Зависит от того, попал он в уже пройденную область таблицы или в ещё не пройденную и вызвала ли вставка рост таблицы.
В примере: 4 ключа, а на итерации с ключом 1 один удаляется (4)
и один добавляется (100). Если удалённый ключ ещё не был выдан, а так выходит в большинстве
запусков (ключ 1 должен выпасть первым), получается минус одна итерация. Новый
попал в непройденную часть, значит плюс одна. Комбинации дают 3 или 4. Итоговый
len при этом всегда 4: было 4, минус один, плюс один.
Всё сказанное относится к обходу и модификации из одной горутины. Одновременный
доступ из разных горутин, где хотя бы одна пишет, даёт не гонку данных «в общем смысле», а
конкретное падение: рантайм ловит его и завершает процесс с
fatal error: concurrent map read and map write. Это не паника, её
нельзя перехватить через recover, процесс умирает целиком. Проверку встроили
в рантайм именно потому, что молчаливое повреждение хеш-таблицы отлаживать невозможно.
Лечится мьютексом, sync.Map или шардированием (см. вопрос 5 в главе 1.3).
Как звучит правильное объяснение на собесе
«Порядок обхода мапы в Go рандомизирован намеренно, чтобы на него не полагались. От HashDoS
защищает другое: случайный сид хеша у каждой мапы. Для стабильного порядка надо
собрать ключи в слайс и отсортировать. Модификация во время обхода из одной горутины
легальна, но с оговорками: удалённый ключ гарантированно не будет выдан, а добавленный
может быть выдан, а может нет. Поэтому число итераций тут не определено, 3 или 4. А
конкурентная модификация из другой горутины даёт fatal error от рантайма,
который не ловится через recover.»
Код
func main() {
var m map[string]int // объявлена, но НЕ создана - это nil
fmt.Println(m == nil, len(m), m["x"])
v, ok := m["x"]
fmt.Println("чтение:", v, ok)
delete(m, "x") // без паники
for range m { // 0 итераций, без паники
fmt.Println("не будет")
}
fmt.Println("до записи всё ок")
defer func() { fmt.Println("recover:", recover()) }()
m["x"] = 1 // ← вот тут
}
true 0 0чтение: 0 falseдо записи всё окrecover: assignment to entry in nil mapПочему
Nil-мапа хранит нулевой указатель на хеш-таблицу. Всё, что не требует самой таблицы, работает корректно и без специальных проверок в твоём коде:
| Операция | На nil-мапе | Почему |
|---|---|---|
len(m) | 0 | нечего считать |
m["x"] | нулевое значение типа | чтение из nil-мапы определено как «ключ не найден» |
v, ok := m["x"] | 0, false | то же самое, с явным флагом |
delete(m, "x") | no-op | с Go 1.0 удаление из nil-мапы легально |
for range m | 0 итераций | обходить нечего |
m == nil | true | мапу можно сравнивать только с nil |
m["x"] = 1 | паника | писать некуда: таблицы не существует |
Асимметрию сделали осознанно: nil-мапа ведёт себя как пустая мапа только для
чтения. Поэтому функция спокойно возвращает nil вместо пустой мапы, а
вызывающий её читает и обходит, не проверяя на nil. Так же устроен nil-слайс:
len, range и append на нём работают.
type Config struct {
Name string
Tags map[string]string // забыли инициализировать
}
c := Config{Name: "svc"}
fmt.Println(c.Tags["env"]) // "" - работает, никаких подозрений
c.Tags["env"] = "prod" // panic: assignment to entry in nil map
Мапа-поле структуры даёт эту панику чаще всего: нулевое значение структуры выглядит
рабочим, читается без ошибок, и падает только на первой записи, возможно, спустя месяцы
после релиза. Лечится конструктором NewConfig(), который создаёт все мапы
и каналы, или ленивой инициализацией в методе-сеттере
(if c.Tags == nil { c.Tags = make(map[string]string) }). Та же история с
json.Unmarshal: если в JSON поля не было, мапа останется nil.
Как звучит правильное объяснение на собесе
«Nil-мапа ведёт себя как пустая мапа, доступная только для чтения. Чтение, len,
range и delete на ней работают и возвращают нулевые значения: так
сделано, чтобы функции могли возвращать nil вместо пустой мапы. Паникует только запись,
потому что писать физически некуда. Чаще всего на это напарываются с мапой-полем структуры:
нулевое значение структуры содержит nil-мапу, и падение приходит на первой же записи.
Отсюда правило: мапы и каналы инициализируются в конструкторе.»
Код
type MyErr struct {
Code int
}
func (e *MyErr) Error() string { return fmt.Sprintf("my error %d", e.Code) }
func doWork(fail bool) *MyErr { // ← конкретный тип, не error
if fail {
return &MyErr{Code: 500}
}
return nil // честный nil типа *MyErr
}
func wrap(fail bool) error { // ← а тут уже интерфейс
return doWork(fail)
}
func main() {
err := wrap(false)
fmt.Println("err == nil:", err == nil)
fmt.Println("err печатается как:", err)
fmt.Printf("%v | %T\n", err, err)
var p *MyErr
var i any = p
fmt.Println("any(nil-указатель) == nil:", i == nil)
}
err == nil: false ← вот она, ловушкаerr печатается как: <nil> ← и это добивает окончательно<nil> | *main.MyErr ← а тип-то естьany(nil-указатель) == nil: falseПочему
Внутри значения интерфейса лежит пара (тип, значение). Интерфейс равен
nil тогда и только тогда, когда обе части пусты.
| Выражение | Динамический тип | Значение | == nil |
|---|---|---|---|
var e error | nil | nil | true |
var p *MyErr; var e error = p | *MyErr | nil | false |
e = &MyErr{} | *MyErr | адрес | false |
На return doWork(fail) срабатывает неявное преобразование
*MyErr → error. Компилятор упаковывает пару: тип
*MyErr есть, значение указателя равно nil. Интерфейс получился
«непустым», хотя внутри пусто.
Больнее всего делает баг строка err печатается как: <nil>.
fmt видит nil-указатель внутри и печатает <nil>,
так что в логах всё выглядит нормально, а if err != nil при этом срабатывает.
Классический сценарий: сервис отвечает «ошибка», в логе стоит <nil>, и
несколько часов уходит на поиск.
Как чинить
// Вариант 1 (лучший): функция сразу возвращает интерфейс error.
func doWork(fail bool) error {
if fail {
return &MyErr{Code: 500}
}
return nil // теперь это настоящий nil-интерфейс
}
// Вариант 2: если сигнатуру менять нельзя - явная проверка при конвертации.
func wrap(fail bool) error {
if e := doWork(fail); e != nil { // сравниваем УКАЗАТЕЛЬ с nil, до упаковки
return e
}
return nil
}
Правило, которое стоит произнести: «функции всегда возвращают error, а не
конкретный тип ошибки». Это официальная рекомендация из Go FAQ, а вот линтера, который
ловил бы этот случай, нет: nilnil проверяет другое - возврат nil, nil,
- а nilness из x/tools в go vet не входит и ищет
разыменование заведомо нулевого указателя.
Ловушка не про error, а про интерфейсы вообще. var w io.Writer = (*os.File)(nil)
тоже не равен nil, и вызов w.Write(...) уйдёт в метод с nil-получателем.
Иногда это даже работает: метод на указателе с nil-получателем вызывается нормально, пока
не разыменовывает поля. На этом построены, например, «нулевые» реализации деревьев,
где func (t *Tree) Size() int { if t == nil { return 0 }; ... }. Так что typed
nil не всегда баг, но всегда требует осознанности.
Как звучит правильное объяснение на собесе
«Интерфейс хранит пару тип-значение и равен nil только когда обе части пусты. Здесь
функция вернула nil типа *MyErr; при упаковке в error тип
сохранился, поэтому интерфейс не nil, хотя указатель внутри nil. Печатается он при этом как
<nil>, что делает баг особенно неприятным. Правило простое: возвращать из
функций error, а не конкретный тип ошибки; если сигнатуру не поменять, сравнить
конкретный указатель с nil до того, как он попадёт в интерфейс.»
Код
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("main recover:", r)
}
fmt.Println("defer в main")
}()
go func() {
defer fmt.Println("defer в горутине")
panic("boom")
}()
time.Sleep(100 * time.Millisecond)
fmt.Println("сюда не дойдём")
}
defer в горутинеpanic: boom + стек, и процесс падает с кодом выхода 2.Ни
main recover, ни defer в main, ни сюда не дойдём
не напечатаются.Почему
recover работает только внутри той же горутины, где случилась паника, и
только в отложенной функции, вызванной непосредственно паникующей цепочкой. У каждой горутины
свой стек и свой список defer; main о панике в другой горутине
ничего не знает: для неё эта паника не «происходит».
Механика раскрутки такая: паника поднимается по стеку своей горутины, выполняя её
отложенные вызовы, поэтому defer в горутине и печатается. Если ни один из них не
сделал recover, паника доходит до вершины стека горутины, и рантайм печатает
сообщение с трассировкой и завершает весь процесс. Отложенные вызовы остальных горутин,
включая main, при этом не выполняются вовсе.
recover в каждой горутине
// Обёртка, которую стоит завести в любом сервисе с фоновыми горутинами.
func Go(fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("паника в горутине: %v\n%s", r, debug.Stack())
metrics.PanicTotal.Inc()
}
}()
fn()
}()
}
Именно поэтому http.Server оборачивает каждый обработчик своим
recover: один кривой запрос не должен ронять весь сервис. По той же причине
воркер в пуле (см. главу 1.3) обязан иметь recover, иначе одна плохая задача
убивает процесс со всеми остальными.
Раньше в логе паники была только трассировка стека, и связать её с конкретным запросом
можно было лишь по соседним строкам лога. В Go 1.26 это добавили под
GODEBUG=tracebacklabels=1, а с Go 1.27 оно включено по умолчанию:
рантайм печатает в заголовке
горутины её метки, те самые, что ставятся через pprof.Labels и
pprof.SetGoroutineLabels для разрезания CPU-профиля:
goroutine 1 [running] {request_id: "abc-123", route: "/users/{id}"}:
main.main()
Проставить метки на входе в HTTP-обработчик занимает три строки, а в разборе инцидента это
разница между «где-то упало» и «упало на запросе abc-123». Отключается
GODEBUG=tracebacklabels=0.
Что recover не ловит вообще
- Панику из другой горутины: разобрано выше.
fatal errorот рантайма. «concurrent map writes», «all goroutines are asleep - deadlock», «out of memory», переполнение стека. Это не паники, а фатальные состояния рантайма: их не перехватить в принципе.os.Exit(). Он не разматывает стек и не выполняетdefer.recoverне вdefer. Вызовrecover()прямо в коде функции всегда вернётnil: он работает только из отложенной функции. И только из непосредственно отложенной:defer func(){ helper() }(), гдеhelperвызываетrecover, тоже не сработает.
Как звучит правильное объяснение на собесе
«recover действует только в своей горутине: у каждой горутины свой стек и свой
список defer. Паника раскручивает стек только этой горутины, поэтому её
собственный defer отработает, а потом рантайм печатает трассировку и убивает
процесс целиком. defer в main и в других горутинах при этом не
выполняется. Отсюда практическое правило: каждая горутина, которую ты запускаешь, обязана
иметь свой recover, иначе любая ошибка в фоновой задаче роняет весь сервис.»
Код
func main() {
a := make(chan int, 1)
b := make(chan int, 1)
counts := map[string]int{}
for i := 0; i < 10000; i++ {
a <- 1
b <- 2
select { // ОБА готовы
case <-a:
counts["a"]++
<-b
case <-b:
counts["b"]++
<-a
}
}
fmt.Println("случайный выбор:", counts)
c := make(chan int)
close(c)
for i := 0; i < 3; i++ {
select {
case v, ok := <-c:
fmt.Println("из закрытого:", v, ok)
}
}
var nilCh chan int
select {
case <-nilCh:
fmt.Println("никогда")
default:
fmt.Println("nil-канал не готов никогда - сработал default")
}
}
случайный выбор: map[a:5067 b:4933] ← примерно 50/50, числа пляшут от запуска
к запуску: шесть прогонов подряд дали a = 5053, 4979, 4905, 4958, 5054, 5013 из 10 000.
Один прогон тут ничего не доказывает - статистика на 10 000 итераций доказываетиз закрытого: 0 false - три раза подряд, без блокировкиnil-канал не готов никогда - сработал defaultПочему
Правил у select четыре, и все четыре стоит назвать:
- Если готова ровно одна ветка, выполняется она.
- Если готовы несколько, выбирается равновероятно случайная. Не первая, не
последняя, не по порядку в коде. Это гарантия языка, и держится она на перестановке
порядка опроса. Сделано, чтобы
selectне голодал ни по одной ветке: иначе быстрый канал навсегда заглушил бы медленный. - Если не готова ни одна и есть
default, выполняетсяdefault, иselectстановится неблокирующим. - Если не готова ни одна и
defaultнет, горутина блокируется, пока какая-нибудь не станет готова.select{}без веток блокируется навсегда.
Закрытый канал всегда готов на чтение и мгновенно отдаёт нулевое значение с
ok == false. Отсюда две вещи. Хорошая: на этом построены
done-каналы и ctx.Done(): закрытие будит всех ожидающих сразу.
Плохая: если забыть про ok, select в цикле по закрытому каналу
начнёт крутиться на 100% CPU, бесконечно получая нули. Лечит обнуление канала:
for a != nil || b != nil {
select {
case v, ok := <-a:
if !ok {
a = nil // выключаем ветку навсегда
continue
}
use(v)
case v, ok := <-b:
if !ok {
b = nil
continue
}
use(v)
}
}
Nil-канал в select не готов никогда, ни на чтение, ни на запись, и
просто «выключает» свою ветку. Это не костыль, а официальная идиома: приём с
ch = nil живёт в стандартной библиотеке и в любом нетривиальном
pipeline. Вне select операция с nil-каналом блокирует навсегда, и отсюда же
берётся её роль «выключателя».
time.After в цикле
for {
select {
case v := <-ch:
handle(v)
case <-time.After(time.Second): // НОВЫЙ таймер на каждой итерации
return
}
}
На каждой итерации создаётся новый Timer, и до Go 1.23 старые оставались в куче
до срабатывания: на быстром канале это заметная нагрузка на память и GC. Начиная с Go 1.23
неиспользуемые таймеры собираются GC сразу, так что утечки больше нет, но лишние аллокации
остались. Правильнее завести один time.NewTimer вне цикла с
Reset, или context.WithTimeout. Само изменение из 1.23 давно не новость, с тех пор вышли 1.24–1.27. Но на собесе оно
до сих пор играет в твою пользу: многие помнят старое правило «time.After
в цикле течёт» и не знают, что утечку починили. Сильный ответ называет обе половины
и добавляет, что таймер всё равно стоит переиспользовать.
Как звучит правильное объяснение на собесе
«Если в select готовы несколько веток, выбирается случайная. Это гарантия
языка, а не деталь реализации, и сделана она против голодания. Закрытый канал всегда готов и
отдаёт нулевое значение с ok = false, поэтому в цикле такой select
закрутится, если не проверять ok и не обнулять канал. Nil-канал, наоборот, не
готов никогда, и присваивание ch = nil служит стандартным способом выключить ветку
select. default делает select неблокирующим.»
Код
type S struct {
mu sync.Mutex
n int
sl []int
m map[string]int
p *int
}
func main() {
one := 1
a := S{n: 1, sl: []int{1, 2, 3}, m: map[string]int{"x": 1}, p: &one}
b := a // копирование структуры
b.n = 100
b.sl[0] = 100
b.m["x"] = 100
*b.p = 100
b.sl = append(b.sl, 4)
fmt.Println("a:", a.n, a.sl, a.m, *a.p)
fmt.Println("b:", b.n, b.sl, b.m, *b.p)
}
a: 1 [100 2 3] map[x:100] 100b: 100 [100 2 3 4] map[x:100] 100Плюс
go vet ругается:
assignment copies lock value to b: main.S contains sync.MutexПочему
Структура копируется поверхностно: побайтово копируются все поля. Дальше всё решает тип поля.
| Поле | Что скопировалось | Данные общие? |
|---|---|---|
n int | само значение | нет - независимы |
sl []int | заголовок {ptr,len,cap} | да - массив общий, но len у каждого свой |
m map[string]int | указатель на хеш-таблицу | да - полностью общая |
p *int | сам указатель | да - смотрят в одно место |
mu sync.Mutex | состояние мьютекса | нет - и это баг |
Тонкий момент со слайсом: b.sl[0] = 100 видно в a, потому что
массив общий. А b.sl = append(b.sl, 4) уже не видно: append
при cap 3 переаллоцировал, и b.sl уехал на новый массив. Даже если
бы ёмкости хватило, a.sl всё равно остался бы длиной 3, заголовки-то разные.
Одна строка кода показывает обе половины природы слайса.
sync.Mutex внутри устроен как обычная структура с полями state и
sema. При копировании b получает свой собственный
мьютекс: две горутины одновременно «захватывают блокировку», каждая свою, и спокойно
пишут в общую мапу. Взаимоисключения нет, а выглядит код совершенно корректно. Хуже:
если копировать структуру в момент, когда мьютекс захвачен, копия родится
в состоянии «заблокировано», и первый же Lock на ней повиснет навсегда.
Именно поэтому у go vet есть отдельная проверка copylocks, а все
типы с мьютексом внутри держат методы на указателе. Та же проверка ловит
sync.WaitGroup, sync.Once, sync.Cond и
atomic.Int64. А вот strings.Builder она пропускает: у него
внутри нет ни лока, ни noCopy, и копия падает уже в рантайме с
illegal use of non-zero Builder copied by value.
Как сделать настоящую независимую копию
func (s *S) Clone() *S {
s.mu.Lock()
defer s.mu.Unlock()
c := &S{n: s.n} // новый мьютекс в нулевом состоянии
c.sl = slices.Clone(s.sl) // копия слайса
c.m = maps.Clone(s.m) // копия мапы (тоже поверхностная!)
if s.p != nil {
v := *s.p
c.p = &v // копия значения по указателю
}
return c
}
Оговорка: slices.Clone и maps.Clone тоже поверхностные. Если внутри
слайса лежат структуры со слайсами, их придётся копировать рекурсивно. Настоящего глубокого
копирования в стандартной библиотеке нет, и на добивку правильно ответить: «сделал бы явный
Clone, потому что автоматического deep copy в Go нет».
Как звучит правильное объяснение на собесе
«Копирование структуры в Go поверхностное: копируются поля как есть. int
скопировался по-настоящему. Слайс скопировал заголовок, массив остался общий, но длина у
каждого своя, поэтому запись по индексу видна, а append уже нет. Мапа и указатель
сами по себе указатели, данные полностью общие. А мьютекс скопировался вместе со своим
состоянием, и это баг: у копии свой мьютекс, взаимоисключение сломано. go vet
ловит это правилом copylocks, поэтому типы с мьютексом всегда используются через указатель.»
Код
package main
import "fmt"
var a = b + 1 // объявлены в «неправильном» порядке
var b = c + 1
var c = f()
func f() int { fmt.Println("1. f() - вычисляется c"); return 1 }
func init() { fmt.Println("2. init A:", a, b, c) }
func init() { fmt.Println("3. init B") }
const (
A = iota // 0
B // 1
_ // 2 - пропущен
D // 3
)
const (
_ = iota
KB = 1 << (10 * iota) // 1 << 10
MB // повтор выражения с iota = 2
GB
)
type Weekday int
const (
Mon Weekday = iota + 1
Tue
Wed
)
func main() {
fmt.Println("4. main:", a, b, c)
fmt.Println("iota:", A, B, D)
fmt.Println("размеры:", KB, MB, GB)
fmt.Println("дни:", Mon, Tue, Wed)
}
1. f() - вычисляется c2. init A: 3 2 13. init B4. main: 3 2 1iota: 0 1 3размеры: 1024 1048576 1073741824дни: 1 2 3Почему: переменные пакета инициализируются по зависимостям, а не по порядку
Спецификация задаёт алгоритм явно: компилятор строит граф зависимостей между переменными пакета и раз за разом выбирает переменную, которая (а) ещё не инициализирована и (б) все зависимости которой уже готовы. Если таких несколько, берётся та, что раньше по порядку объявления в файле (файлы обрабатываются в порядке, в котором их передали компилятору, обычно лексикографическом по имени).
Здесь a зависит от b, b зависит от c,
а c не зависит ни от чего. Поэтому реальный порядок: c = f() = 1, затем
b = 2, затем a = 3. Текстовый порядок объявления полностью
игнорируется. Циклическая зависимость (var x = y; var y = x) даёт ошибку
компиляции, а не бесконечный цикл.
Полный порядок запуска программы: константы пакета → переменные пакета в порядке
зависимостей → все функции init() в порядке объявления (их может быть сколько
угодно, в том числе несколько в одном файле) → main(). Пакеты при этом
обрабатываются снизу вверх по дереву импортов: импортируемый пакет полностью
инициализируется раньше импортирующего, и каждый проходит это ровно один раз, даже если
импортирован многократно.
Почему iota ведёт себя именно так
iotaхранит порядковый номерConstSpecв блокеconst, считая с нуля. Не «счётчик констант», а именно номер строки спецификации. Поэтому пустая строка_тоже увеличиваетiota:A=0, B=1, _=2, D=3.- Пропущенное выражение повторяет предыдущее.
MBиGBне «наследуют значение», а повторяют формулу1 << (10 * iota)со своимiota: 2 и 3. Отсюда 1<<20 и 1<<30. Тут спотыкаются чаще всего. iotaобнуляется в каждом новом блокеconst, а внутри блока считает строки, а не константы: строкаX, Y = iota, iota*2даёт одну спецификацию и одинiota.- Тип наследуется вместе с выражением.
TueиWedполучают типWeekday, потому что повторяется вся спецификация целиком, включая тип.
Константы в Go вычисляются на этапе компиляции и по умолчанию нетипизированы,
с произвольной точностью. const Big = 1 << 62 компилируется, а
var x int32 = Big уже нет: константа не помещается в тип. Именно поэтому
const Pi = 3.14159... в math хранит больше знаков, чем влезает в
float64: обрезка происходит только в момент присваивания конкретному типу.
Побочный эффект: у константы нет адреса, на неё нельзя взять указатель, и она не может
быть результатом вызова функции (кроме len/cap от константного
выражения и подобных).
Как звучит правильное объяснение на собесе
«Переменные пакета инициализируются не сверху вниз, а по графу зависимостей: сначала то, у
чего нет неготовых зависимостей. Здесь это c, потом b, потом
a, получится 3, 2, 1. Дальше выполняются все init() в порядке
объявления, потом main. Пакеты инициализируются снизу вверх по импортам и по
одному разу. iota означает номер строки внутри блока const, поэтому
пропуск через _ его увеличивает, а пустая строка повторяет предыдущее выражение
целиком, включая тип, но уже с новым значением iota.»
Код
func main() {
var x any = 1
var y any = 1.0
fmt.Println("1)", x == y)
var s1 any = "go"
var s2 any = "go"
fmt.Println("2)", s1 == s2)
type Pt struct {
X, Y int
}
var p1 any = Pt{1, 2}
var p2 any = Pt{1, 2}
fmt.Println("3)", p1 == p2)
defer func() { fmt.Println("recover:", recover()) }()
var b1 any = []int{1, 2}
var b2 any = []int{1, 2}
fmt.Println("4)", b1 == b2) // ← компилируется, но падает
}
1) false ← int и float64 - разные динамические типы2) true3) true ← структуры из сравнимых полей сравнимыrecover: runtime error: comparing uncomparable type []intПочему
Два значения интерфейса равны, если совпадают их динамические типы И равны их динамические
значения. Отсюда первый результат: 1 и 1.0 - числа одинаковые,
но типы int и float64 разные, значит false. Никакого
неявного приведения при сравнении интерфейсов не происходит.
Второй шаг - сравнимость самого динамического типа. Go делит типы на две категории:
Сравнимые (== работает) | Несравнимые (== запрещён) |
|---|---|
числа, строки, bool, указатели, каналы, интерфейсы,массивы из сравнимых элементов, структуры, все поля которых сравнимы |
слайсы, мапы, функции (и любая структура/массив, содержащие их) |
Если несравнимый тип написан явно - []int{1} == []int{2}, - это ошибка
компиляции: компилятор видит типы и запрещает. Но когда значение спрятано в интерфейсе,
статически тип неизвестен: any == any компилируется всегда. Проверка переезжает
в рантайм, и там она превращается в панику
comparing uncomparable type []int. Именно этот переход «из compile-time в
runtime» и есть содержание вопроса.
m := map[any]int{}
m["строка"] = 1 // ок
m[42] = 2 // ок
m[[2]int{1, 2}] = 3 // ок: массив сравним
m[[]int{1, 2}] = 4 // panic: runtime error: hash of unhashable type []int
Ключ мапы обязан быть сравнимым. С map[any]V компилятор снова не может
проверить это статически, и падение приходит в рантайме - часто на редких данных, спустя
месяцы. Со структурой-ключом так не выйдет: добавишь в Key поле-слайс, и
map[Key]V перестанет компилироваться. Опасен именно any в ключе. Правило: не используй
any как ключ мапы, если не контролируешь, что туда кладут.
Чем сравнивать несравнимое
slices.Equal(a, b) // слайсы, поэлементно, быстро - Go 1.21+
maps.Equal(m1, m2) // мапы
reflect.DeepEqual(x, y) // что угодно, но медленно и с сюрпризами
reflect.DeepEqual стоит упомянуть вместе с его подвохом: он считает
[]int(nil) и []int{} разными, хотя обе длины нулевые.
В тестах это регулярно приводит к ложным падениям - поэтому в новом коде предпочитают
slices.Equal (который их как раз уравнивает) или
google/go-cmp.
Как звучит правильное объяснение на собесе
«Интерфейсы равны, когда совпадают и динамический тип, и значение. Поэтому
any(1) и any(1.0) не равны - типы разные. А если динамический тип
несравним - слайс, мапа или функция, - то сравнение компилируется (статически тип неизвестен),
но падает в рантайме с comparing uncomparable type. Та же паника прилетает при
использовании такого значения как ключа мапы. Для слайсов и мап есть
slices.Equal и maps.Equal; reflect.DeepEqual тоже
работает, но медленный и по-разному трактует nil и пустой слайс.»
Код
func main() {
s := "Привет, Go!"
fmt.Println("len:", len(s))
fmt.Println("рун:", utf8.RuneCountInString(s))
fmt.Printf("s[0] = %v, %c, %q\n", s[0], s[0], s[0])
fmt.Printf("s[:3] = %q\n", s[:3])
fmt.Print("range: ")
for i, r := range s[:12] {
fmt.Printf("(%d:%c) ", i, r)
}
fmt.Println()
fmt.Print("byte-loop: ")
for i := 0; i < 6; i++ {
fmt.Printf("%d ", s[i])
}
fmt.Println()
r := []rune(s)
fmt.Println("[]rune len:", len(r), "первый:", string(r[0]))
}
len: 17 ← байт, а не символоврун: 11s[0] = 208, Ð, 'Ð' ← половина буквы «П»s[:3] = "П\xd1" ← руна разрезана пополамrange: (0:П) (2:р) (4:и) (6:в) (8:е) (10:т) ← индексы через одинbyte-loop: 208 159 209 128 208 184 []rune len: 11 первый: ППочему
Строка в Go - это неизменяемая последовательность байт, а не символов. Литералы в
исходнике хранятся в UTF-8, где кириллическая буква занимает 2 байта, латиница и цифры - 1,
а эмодзи - 4. «Привет, Go!» - это 6 кириллических букв (12 байт) + запятая, пробел,
G, o, ! (5 байт) = 17 байт при 11 рунах.
s[i]возвращаетbyte(uint8), а не символ.s[0] = 208- это первый из двух байт буквы «П» (0xD0 0x9F).%cчестно печатает символ с кодом 208 -Ð.- Срез строки режет байты.
s[:3]захватывает «П» целиком и первый байт от «р» - получается невалидный UTF-8, который%qпоказывает как"П\xd1". rangeпо строке декодирует UTF-8 на лету и выдаёт пары(байтовое смещение, руна). Именно поэтому индексы идут 0, 2, 4, 6… - это не порядковые номера символов, а смещения в байтах. Ловушка: если использовать этотiкак «номер символа», всё сломается на первой же кириллице.[]rune(s)декодирует всю строку в слайсint32- по 4 байта на руну. Даёт случайный доступ и корректныйlen, но стоит O(n) времени и O(n) памяти.
Как делать правильно
| Задача | Правильно | Неправильно |
|---|---|---|
| число символов | utf8.RuneCountInString(s) | len(s) |
| перебрать символы | for _, r := range s | for i := 0; i < len(s); i++ |
| i-й символ | []rune(s)[i] (один раз конвертировать) | s[i] |
| первые n символов | считать руны в range и резать по смещению | s[:n] |
| развернуть строку | через []rune | через байты - сломает UTF-8 |
| сравнить без регистра | strings.EqualFold(a, b) | strings.ToUpper(a) == strings.ToUpper(b) |
Когда индексный цикл по байтам уместен: если ты осознанно работаешь с байтами - парсишь ASCII-протокол, ищешь разделитель, считаешь размер для буфера. Тогда это не ошибка, а оптимизация, и об этом стоит сказать вслух: «здесь я иду по байтам намеренно, потому что разделитель - ASCII, а ASCII-байт не может встретиться внутри многобайтовой руны в UTF-8». Последнее - самосинхронизирующееся свойство UTF-8, и знание про него ценится отдельно.
Руна - это code point Unicode, и она не всегда совпадает с тем, что пользователь видит как
один символ. «é» может быть одной руной U+00E9 или двумя
(e + U+0301). Флаг «🇷🇺» - две руны. Эмодзи с модификатором тона
кожи - тоже несколько. То, что видит человек, называется графемным кластером, и в
стандартной библиотеке его нет - нужна внешняя библиотека
(rivo/uniseg). Поэтому строго корректный ответ на «обрежь строку до 20
символов» - «до 20 рун; если нужны именно видимые символы, это графемные кластеры, и нужна
сторонняя библиотека».
Как звучит правильное объяснение на собесе
«Строка в Go - это байты в UTF-8, поэтому len даёт длину в байтах, а
s[i] - байт, а не символ: для кириллицы это половина буквы.
range по строке декодирует UTF-8 и выдаёт руну и её байтовое смещение -
поэтому индексы идут через один, и использовать их как номер символа нельзя. Если нужен
случайный доступ к символам - конвертирую в []rune один раз, помня, что это
O(n) памяти. Резать строку можно только по границам рун, иначе получится невалидный
UTF-8.»
Что стоит запомнить
- Аргументы defer вычисляются при выполнении defer statement, тело - при выходе из функции.
- Named result является переменной, которую может изменить deferred closure.
- Обход map и выбор готовой ветки select не задают стабильный порядок.
Частые ошибки
- Порядок запуска горутин нельзя выводить из порядка их создания.
- Typed nil может храниться в интерфейсе как ненулевое значение интерфейса.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Посчитай именованный результат
Что выведет программа и в какой момент defer меняет n?
package main
import "fmt"
func value() (n int) {
defer func(){ n++ }()
return 5
}
func main(){ fmt.Println(value()) }
Ответ и разбор
Программа выведет 6. Перед выполнением defer return присваивает named result значение 5; затем deferred closure меняет ту же переменную n на 6; после этого значение возвращается вызывающему коду.
Код-ревью
2. Верни nil вместо typed nil
Функция ниже возвращает nil, если ошибки нет, но вызов всё равно проходит проверку err != nil. Объясни причину и предложи исправление. Сохрани Error() string, чтобы тип действительно реализовывал интерфейс error.
type ParseError struct{}
func (ParseError) Error() string { return "parse failed" }
func parse() error {
var e *ParseError
return e
}
Ответ и разбор
Интерфейс состоит из динамического типа и значения. Возврат переменной типа *ParseError с nil создаёт интерфейс с типом *ParseError, поэтому сам интерфейс не равен nil; добавленный метод Error делает этот указатель допустимым значением интерфейса error. В ветке успеха возвращай literal nil, а указатель на ParseError только при реальной ошибке.
Применение
3. Отдели гарантии от порядка map
Программа обходит map и печатает ключи. После двух запусков порядок разный. Можно ли выбрать «правильный» порядок из спецификации Go и как получить стабильный вывод?
Ответ и разбор
Спецификация не обещает порядок итерации map, поэтому оба порядка допустимы. Собери ключи в слайс, отсортируй через sort.Ints или подходящую сортировку для типа ключа и печатай после этого.
Применение
4. Объясни alias после append
a и b := a[:2] делят backing array. После b = append(b, 9) почему значение a[2] может измениться, и что гарантирует ограничение capacity?
Ответ и разбор
Если у b есть свободная capacity, append записывает 9 в общий backing array, поэтому a[2] изменится. Трёхиндексный срез a[:2:2] ограничит capacity до длины; следующий append должен выделить отдельный массив. Не делай вывод о точном коэффициенте роста capacity: он не является фиксированной языковой гарантией.