golang

Отвратительные вопросы для Гофера

  • пятница, 11 сентября 2026 г. в 00:00:21
https://habr.com/ru/articles/1079104/

Привет!

К вам пришёл кандидат, который идеально проходит по всем критериям, но у него "не тот вайбик" или вам нравится давать необоснованные отказы? Тогда эта статья для вас!

Я собрал несколько вопросов, которые абсолютно бесполезны в реальной разработке (только если вы не собеседуете Робов Пайков в 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

Как получить ID текущей горутины?

Ответ

Хорошего решения нет, но можно так:

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 хранилища или как-либо иначе привязывать логику на идентификатор горутины.

Чем отличаются panic, fatal и throw?

Ответ

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 ответа:

  1. Нет, потому что после удаления объекта из мапы её память подчищается, а так как мы удаляем то же количество, что и создаём, роста не будет

  2. Да, будет увеличиваться, потому что в go мапы автоматически не уменьшаются в размере после delete

  3. Да, вначале вырастет, а потом выйдет на плато, потому что хоть в мапе после удаления объекты не вычищаются, но после пересоздания для роста мапы они всё-таки удаляются. Также в go мапа может вырасти на тот же размер, что и занимает сейчас, то есть по сути просто очиститься.

Собственно, правильный ответ - 3.

Defer и именованные возвращаемые значения

Что вернёт данная функция?

func fn() (x int) {
  defer func() {
      x++
  }()

  return 5
}
Ответ

6

Связано это с тем, что return 5, по сути, записывает в x значение 5, а defer увеличивает его на 1, после чего возвращается именно x, а не 5.

Каверзные вопросы

В этом блоке находятся вопросы, на которые честный разработчик может знать ответ, а на часть без проблем ответит. Сами по себе эти вопросы не отвратительные, но в работе почти не встречаются.

Почему длина слайса - int?

Казалось бы, длина слайса не может быть отрицательной, но почему тогда длина слайса - int, а не uint?

Ответ

Да так просто удобнее :)

Не нужно постоянно конвертировать int в uint, не нужно опасаться за случайные переполнения uint и тд

Какие есть подводные камни у плагинов?

Средний честный программист на этот вопрос ответит просто - "что такое плагины?"

Что же это?

Если кратко, то это so/dll для go. С их помощью можно написать гошный код, скомпилить его, а потом вызывать его, подключая его в runtime, а не на compile time.

Ответ
  1. Нестабильные - плагин и конечный код надо компилировать на одной и той же версии компилятора, архитектуре, машине, фазе луны и тд и тп.

  2. Не работают на Windows (ну и не надо).

  3. Требуют 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

А как работает выравнивание?

Если очень упрощать, то правила такие:

  1. Для каждого типа в структуре вычисляется выравнивание (alignment), равное минимальному из размера типа и 8 байт (в основном для 64-битных архитектур). Например, поля bool, int16, int64 и interface будут иметь выравнивания 1, 2, 8 и 8. Массивы с compile-time размером (e.g. [12]byte), по сути, являются просто последовательностью из элементов соответствующего типа.

  2. Каждый элемент структуры должен начинаться с отступа (offset) кратного своему выравниванию. Например, int32 может начинаться с 0, 4, 8, 12 и тд байта. Если это невозможно, то добавляются пустые байты до выравнивания. Эти байты называются паддингом.

  3. Финальный размер структуры должен быть кратен максимальному из выравниваний, например, если в структуре есть 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 отстаёт где-то на полгода.

new(выражение)

Что будет выведено на экран:

a := new(1)
println(*a)
Ответ

В 1.26, конечно же, будет 1.

Но модели не обучались на 1.26 версии go, из-за чего они ответят, как было бы корректно раньше:

Ответ от GPT-5.6 SOL
Ответ от GPT-5.6 SOL

Можно просто брать любую фичу из последней версии 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 не знают, что мы не знаем то, что они знают.

Конечно, лучше знать ответы на данные вопросы, чем не знать. Я надеюсь, что вам понравилось и вы узнали что-то новое :)