Отвратительные вопросы для Гофера
- пятница, 11 сентября 2026 г. в 00:00:21
Привет!
К вам пришёл кандидат, который идеально проходит по всем критериям, но у него "не тот вайбик" или вам нравится давать необоснованные отказы? Тогда эта статья для вас!
Я собрал несколько вопросов, которые абсолютно бесполезны в реальной разработке (только если вы не собеседуете Робов Пайков в Google), на которые почти точно ни один адекватный кандидат никогда не ответит.
Если отбросить шутки (вы же поняли, что я шучу?), то перед вами фан-подборка каверзных, глупых и душных вопросов по go. Я расскажу о всяких очень подкапотных вещах, которые редко встречаются в каких-либо подборках вопросов к собесам, да и в принципе редко обсуждаются. А может вам не повезёт и эти вопросы вам попадутся.
Мой хороший друг как-то рассказывал, что на технических собеседованиях иногда спрашивает сложные и отдалённые от стека вопросы кандидатам.
Условно, на собеседовании на позицию фронтендера можно спросить "Как работает сборщик мусора из Go 1.25". Обычный фронтендер, скорее всего, ответит "Чёрт его знает, я на JS пишу", но волк (читер) ответит сходу, чётко и ясно. К чему это я? Вопросы из данной статьи можно использовать как лакмусовую бумажку - если кандидат на них ответил, возможно, он использует AI.
Очевидно, данных вопросов недостаточно для того, чтобы однозначно сказать, кто перед вами - фанат go или волк, поэтому используйте их осторожно.
Основная секция статьи, тут собраны самые неприятные вопросы, вероятность встретить их в реальной работе - почти нулевая.
А если вы встречаете это в работе - значит вы делаете что-то не так или работаете со слишком странным кодом.
Например, в runtime есть функция throw:
func throw(v string)
Как её можно вызвать из нашего пользовательского пакета?
Очевидно, что через linkname
package main import ( _ "unsafe" ) //go:linkname throw runtime.throw func throw(v string) func main() { throw("error") // Вызовется функция отсюдова // https://github.com/golang/go/blob/go1.26.2/src/runtime/panic.go#L1220 }
go позволяет линковаться с символами (переменными, функциями и тд) из некоторых других пакетов, даже если они не были экспортированы. Это может сильно не понравиться создателям библиотек (в данном случае стандартной библиотеке), всё-таки приватные (неэкспортируемые) функции приватны по какой-то причине.
Поэтому злоупотребление может привести к публичному и официальному позору. Зато можно делать так:
package main import ( _ "unsafe" _ "github.com/rs/zerolog" ) //go:linkname needsQuote github.com/rs/zerolog.needsQuote func needsQuote(s string) bool func main() { // Можно линковаться даже к сторонним библиотекам! println(needsQuote("plain"), needsQuote("with space")) // > false true }
Есть 2 программы:
// A func main() { var a = new(struct{}) var b = new(struct{}) println(a == b, &a == &b, a, b) } // B func main() { var a = new(struct{}) var b = new(struct{}) println(a == b, *a == *b, a, b) }
Что выведет каждая и почему, если адрес первой структуры равен 0xabcd?
// A true false 0xabcd 0xabcd // B false true 0xabcd 0xabcd
Я придумал этот вопрос после случайного прочтения спецификации go и, признаюсь, сам не до конца понимаю, почему так происходит :)
По спецификации go адреса zero-sized объектов, например, структур, могут быть одинаковыми. Из-за этого довольно ожидаемо, что в обоих случаях println(a, b) даст одинаковый адрес. (*a == *b) == true тоже понятно, почему - обе структуры пустые, значит у них нет полей, что могли бы отличаться, поэтому они равны. (&a == &b) == false - адреса самих переменных a и b, то есть адреса адресов, конечно, отличаются.
А вот почему само сравнение a == b в одном случае true, а в другом false?
Связано это с тем, что в коде, где мы не берём адреса a и b (то есть адреса адресов структур), компилятор оптимизирует сравнение a == b в false, потому что a и b порождены из разных new (случай B).
В первом же случае go не оптимизирует a == b в false, так как a и b материализуются, при этом они имеют один и тот же адрес и
go честно сравнивает адреса.
Важно отметить, что данный ответ не закреплён в спецификации go, в будущих версиях поведение может поменяться.
Поэтому GPT-5.6 SOL без гугления и использования go ответит:
// A false false 0xabcd 0xabcd // B true true 0xabcd 0xabcd
А если ему дать время и go, то он получит правильный ответ.
Иными словами, если волк быстро отвечает - возможно, он использует LLM, но без компилятора.
Если тянет время - с компилятором.
Человек же, если знает, что адреса у пустых структур могут быть одинаковыми, скорее ответит так:
// A true false 0xabcd 0xabcd // B true true 0xabcd 0xabcd
Хорошего решения нет, но можно так:
func goid() int { var buf [64]byte runtime.Stack(buf[:], false) spaceIdx := bytes.IndexByte(buf[:], ' ') if spaceIdx == -1 { return 0 } secondSpaceIdx := bytes.IndexByte(buf[spaceIdx+1:], ' ') if secondSpaceIdx == -1 { return 0 } id, err := strconv.Atoi(string(buf[spaceIdx+1 : spaceIdx+secondSpaceIdx+1])) if err != nil { return 0 } return id }
Что?
Помните, когда вылетает паника, пишется stacktrace с айдишником горутины? Ну так вот, goid() как раз достаёт stacktrace как строчку и из него парсит айди горутины :). Есть, конечно, более быстрые решения на всяких ассемблерах
Кстати, из этой темы можно сделать адекватный вопрос: а почему в go нет хорошего решения для получения айди горутины?.
Причина - сознательное решение создателей go не делать goid, чтобы у разработчиков не было соблазна делать goroutine-local хранилища или как-либо иначе привязывать логику на идентификатор горутины.
fatal и throw - это что-то вроде внутренних паник из самого go. Пользователь go не может самостоятельно их вызвать (нормальным способом), при этом обе ошибки необрабатываемые, их нельзя зарекаверить.
Разница между throw и fatal в общем случае в том, что fatal вызывается, если накосячил разработчик, а throw - если создатели go.
По сути это те же Отвратительные вопросы, но обычный человек ответит на них почти правильно, а вот AI дополнит до корректного ответа. Либо подзабудет, потому что уж редко бывает необходимость думать о таком.
Что произойдёт, если одновременно читать и писать в map?
Прекращение работы программы с ошибкой, выкинутой через fatal. Не паника, как могли бы сказать многие, а fatal (см. предыдущий вопрос).
Сделано это из-за того, что в случае конкурентного доступа без мьютексов память в мапе повреждается. Да и go не всегда может задетектить нарушение доступа, поэтому можно повредить память и даже не получить fatal.
Аналогичное, кстати, происходит с разблокировкой не заблокированного мьютекса.
Что произойдёт при выполнении?
func main(){ defer func(){ fmt.Printf("%v", recover()) }() var wg sync.WaitGroup wg.Go(fn) wg.Wait() } func fn(){ panic("ERROR") }
В go нет пропагации паник по родительским горутинам, из-за чего любая паника, что не была обработана в какой-либо горутине, приведёт к остановке процесса.
Данный вопрос, так-то, полезный. Важно знать, что недостаточно поставить recover() в родительской горутине.
Рассмотрим следующий код:
const ( iterations = 10_000 batchSize = 10_000 ) v := make(map[int]int) for i := range iterations { base := batchSize * i for j := range batchSize { v[base+j] = j } for j := range batchSize { delete(v, base+j) } }
Если после каждой итерации запускать runtime.GC() и снимать занимаемый размер программы, будет ли он увеличиваться или нет и почему?
На этот вопрос есть 3 ответа:
Нет, потому что после удаления объекта из мапы её память подчищается, а так как мы удаляем то же количество, что и создаём, роста не будет
Да, будет увеличиваться, потому что в go мапы автоматически не уменьшаются в размере после delete
Да, вначале вырастет, а потом выйдет на плато, потому что хоть в мапе после удаления объекты не вычищаются, но после пересоздания для роста мапы они всё-таки удаляются. Также в go мапа может вырасти на тот же размер, что и занимает сейчас, то есть по сути просто очиститься.
Собственно, правильный ответ - 3.
Что вернёт данная функция?
func fn() (x int) { defer func() { x++ }() return 5 }
6
Связано это с тем, что return 5, по сути, записывает в x значение 5, а defer увеличивает его на 1, после чего возвращается именно x, а не 5.
В этом блоке находятся вопросы, на которые честный разработчик может знать ответ, а на часть без проблем ответит. Сами по себе эти вопросы не отвратительные, но в работе почти не встречаются.
Казалось бы, длина слайса не может быть отрицательной, но почему тогда длина слайса - int, а не uint?
Да так просто удобнее :)
Не нужно постоянно конвертировать int в uint, не нужно опасаться за случайные переполнения uint и тд
Средний честный программист на этот вопрос ответит просто - "что такое плагины?"
Если кратко, то это so/dll для go. С их помощью можно написать гошный код, скомпилить его, а потом вызывать его, подключая его в runtime, а не на compile time.
Нестабильные - плагин и конечный код надо компилировать на одной и той же версии компилятора, архитектуре, машине, фазе луны и тд и тп.
Не работают на Windows (ну и не надо).
Требуют cgo.
Интересный факт
Плагины настолько никому не нравятся, что, например, terraform для своей системы плагинов, которые пользователи могут разработать, скачать и установить без рекомпиляции всего проекта, использует... gRPC! Каждый плагин буквально поднимает свой gRPC сервер и общается с родителем через него.
Представим следующий код:
type WithTime interface{ Time() time.Time } func trim[T WithTime](arr []T, from time.Time) []T{ var newArr []T for _, v := range arr{ if v.Time().After(from){ newArr = append(newArr, v) } } return newArr } type Now struct {} func (n Now) Time() time.Time{ return time.Now() } type Location struct { createdAt time.Time } func (l *Location) Time() time.Time{ return l.createdAt }
С этим кодом без проблем будет работать тип Now, если передавать его как []Now, но вот Location придётся передавать как []*Location.
Вопрос: как надо переписать trim так, чтобы принимались слайсы WithTime с ресиверами на поинтеры, но без ссылок?
// * Имеется в виду, чтобы такой код работал: arr := []Location{{time.Now()}, {time.Now()}} trim(arr, time.Now())
Уточнение: менять тип слайса на arr []*T, убирать ресивер на поинтер или создавать хелпер структуру - запрещено, решение должно менять только функцию trim.
Очевидно, нам надо просто немного поменять сигнатуру и тело:
func trim[T any, PT interface { *T WithTime }](arr []T, from time.Time) []T { var newArr []T for i := range arr { if PT(&arr[i]).Time().After(from) { newArr = append(newArr, arr[i]) } } return newArr }
Да, так можно делать, сами интерфейсы дженериков могут ссылаться на другие дженерики, а также встраивать их. В результате тип T - это просто любой тип, а PT - это поинтер на T и одновременно тип, реализующий интерфейс WithTime. А сам слайс arr всё так же остаётся []T, то есть слайсом не на поинтеры, а на копии объектов типа T.
Какая из двух структур весит меньше и почему?
type X struct { a bool b bool c int32 d int64 e bool } type Y struct { d int64 c int32 a bool b bool e bool }
Y из-за выравнивания.
Казалось бы, обе должны весить 15 байт (8+4+3), но в go, как и в других языках и даже базах данных (например, postgres) есть такая штука как выравнивание.
Компилятор в структуре к некоторым полям добавляет пустые байты (padding), это нужно для удобства (и не только) работы процессора со структурой.
Правило оптимизации довольно простое - сортируйте поля по размеру по убыванию (вначале слайсы, интерфейсы, стринги, далее мапы, int64, float64 и тд и тп).
В данном же случае размер структуры X будет равен 24 байтам, а Y - 16 байтам.
type X struct { a bool // 1 byte b bool // 1 byte // 2 byte padding c int32 // 4 byte d int64 // 8 byte e bool // 1 byte // 7 byte tail padding } // total: 24 byte
А как работает выравнивание?
Если очень упрощать, то правила такие:
Для каждого типа в структуре вычисляется выравнивание (alignment), равное минимальному из размера типа и 8 байт (в основном для 64-битных архитектур). Например, поля bool, int16, int64 и interface будут иметь выравнивания 1, 2, 8 и 8. Массивы с compile-time размером (e.g. [12]byte), по сути, являются просто последовательностью из элементов соответствующего типа.
Каждый элемент структуры должен начинаться с отступа (offset) кратного своему выравниванию. Например, int32 может начинаться с 0, 4, 8, 12 и тд байта. Если это невозможно, то добавляются пустые байты до выравнивания. Эти байты называются паддингом.
Финальный размер структуры должен быть кратен максимальному из выравниваний, например, если в структуре есть int64, то её размер может быть 8, 16, 24 и тд. Недостающий размер заполняется через tail padding.
По моему опыту, многие даже мидлы знают о выравниваниях, поэтому для синьоров можно поспрашивать про типы аллокаторов, классы размеров, green tea gc и тд.
Когда интерфейсы девиртуализируются?
Что?
Девиртуализация интерфейса, по сути, это замена вызова метода интерфейса на обычный вызов метода объекта. Скажем, вместо того, чтобы вызывать метод Read() через io.Reader, go, при определенных обстоятельствах, может вызвать конкретный метод.
Когда go может гарантировать, что в указанном коде используется только 1 конкретный тип. Грубо говоря, если можно вручную переписать код на конкретные типы и ничего не сломается.
Также с go 1.21 девиртуализация может быть через PGO
Это скорее не сложный, а просто редкий вопрос.
Сравнивать каналы, мьютексы и атомики, конечно, не совсем корректно, плюс однозначно ответить на указанный вопрос нельзя, так как архитектура процессора может влиять на ответ, но если попытаться хотя бы как-то ответить, то ответ будет таков:
Любой канал состоит минимум из одного мьютекса.
RW мьютексы состоят из мьютекса, 2 семафоров и 2 атомиков.
Атомики работают на уровне CPU.
Следовательно, при прочих равных, атомик быстрее мьютекса, а мьютекс - канала.
Могут ли быть аллокации в strings.Builder.WriteString()?
Такого рода вопросы можно придумать бесконечно: В каких стандартных структурах Go используются не дженерики, а интерфейсы?; Из-за чего в структуре strings.Builder есть ссылка на саму себя?
Берёте любую тему, находите что-то сильно подкапотное и спрашиваете у кандидата. Без AI или погружения в код на такие вопросы почти не ответить. Из этих вопросов также можно сгенерировать хорошие вопросы, например, можно показать исходники strings.Builder
и попросить на их основании ответить.
А тут содержатся вопросы, на которые хороший разраб как раз ответит, а LLM, скорее всего - нет. Казалось бы, современные LLM крайне хорошо знают буквально всё, что есть публично. Но они также имеют чёткий срез времени, после которого не обладают знаниями. Например, GPT-5.6 SOL хорошо отвечает на вопросы про go до 1.25 (август 25 года) включительно, но уже ошибается на 1.26 (февраль 26 года). Проверял я это в августе 26 года. То есть в моем случае OpenAI отстаёт где-то на полгода.
Что будет выведено на экран:
a := new(1) println(*a)
В 1.26, конечно же, будет 1.
Но модели не обучались на 1.26 версии go, из-за чего они ответят, как было бы корректно раньше:

Можно просто брать любую фичу из последней версии go (да и любого другого проекта, фреймворка или языка) и спрашивать о ней.
type User struct{ ID uuid.UUID } type Admin struct{ User Roles []string } // Варианты // 1 a := Admin{User: User{ID: uuid.New()}} // 2 b := Admin{ID: uuid.New()}
AI ответит только первым способом, потому что второй появился только в 1.27 (которая вышла меньше месяца назад).
Справедливости ради, и человек не ответит на данный вопрос полностью :)
LLM не знают, что мы (разработчики) плохо запоминаем какие-то подкапотные и сложные вещи, из-за этого даже с дополнительными промптами LLM-ке трудно однозначно отделить честный вопрос от отвратительного. Даже если она сможет отделить, реалистичного ответа (то есть как дал бы разраб) AI всё равно не даёт. Во всяком случае у меня этого сделать не получилось.
Короче, LLM не знают, что мы не знаем то, что они знают.
Конечно, лучше знать ответы на данные вопросы, чем не знать. Я надеюсь, что вам понравилось и вы узнали что-то новое :)