ar
Feedback
Библиотека Go для собеса | вопросы с собеседований

Библиотека Go для собеса | вопросы с собеседований

الذهاب إلى القناة على Telegram

Вопросы с собеседований по Go и ответы на них. Покажем, как запустить своего ии-агента: https://clc.to/tvpmD По рекламе: @proglib_adv Для обратной связи: @proglibrary_feeedback_bot Наши каналы: https://t.me/proglibrary/9197

إظهار المزيد
7 431
المشتركون
-224 ساعات
-127 أيام
+930 أيام
أرشيف المشاركات
Что такое префиксная сумма и где её использовать Префиксная сумма это массив, где каждый элемент prefix[i] хранит сумму всех элементов исходного массива от 0 до i включительно. Построение:
func buildPrefix(arr []int) []int {
    prefix := make([]int, len(arr)+1)
    for i, v := range arr {
        prefix[i+1] = prefix[i] + v
    }
    return prefix
}

func rangeSum(prefix []int, l, r int) int {
    return prefix[r+1] - prefix[l]
}
Задача: количество подмассивов с суммой K
func subarraySum(nums []int, k int) int {
    count := 0
    prefix := 0
    seen := map[int]int{0: 1}

    for _, v := range nums {
        prefix += v
        count += seen[prefix-k] // если ключа нет — вернёт 0, это фича Go
        seen[prefix]++
    }

    return count
}
Где использовать: • Запросы суммы на отрезке — самый очевидный случай. Если массив не меняется, а запросов много, строишь префикс один раз и отвечаешь за O(1). • Поиск подмассива с заданной суммой — сводишь к задаче "найти два индекса префикса с разностью K", решается через хэшмап за O(n). • Задачи на чётность/нечётность суммы — считаешь префикс по модулю 2, ищешь совпадения. • Задачи на матрицах — 2D префикс даёт сумму любого прямоугольника за O(1), используется в задачах с изображениями, тепловыми картами, grid-задачах. • Sliding window с условием на сумму — иногда проще через префикс, чем двумя указателями, особенно если окно не фиксированное. 🐸 Библиотека Go для собеса

Как можно развернуть срез без использования дополнительной памяти Два указателя, движущихся навстречу друг другу:
func reverse(s []int) {
    for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
        s[i], s[j] = s[j], s[i]
    }
}
Начиная с Go 1.21 в стандартной библиотеке есть готовая функция slices.Reverse(), которая делает то же самое под капотом:
import "slices"

s := []int{1, 2, 3, 4, 5}
slices.Reverse(s) // [5, 4, 3, 2, 1]
Оба варианта работают in-place, без выделения дополнительной памяти. 🐸 Библиотека Go для собеса

Что происходит со стеком горутины при её росте и почему это важно для производительности Когда стека не хватает, рантайм не паникует. Он выделяет новый стек большего размера, копирует туда всё содержимое старого, обновляет указатели и освобождает старый. Для производительности важны два момента. 1. Копирование не бесплатно. Если горутина постоянно растёт и сжимается на границе текущего размера, рантайм будет копировать снова и снова. Это stack thrashing и в горячем пути это даёт ощутимый overhead. 2. После копирования все указатели на стековые переменные перезаписываются. Поэтому нельзя просто передать указатель на стек в C через cgo, стек может переехать в любой момент, и указатель станет невалидным. 🐸 Библиотека Go для собеса

Почему интерфейс с нулём методов это полезная конструкция, а не бессмысленная Пустой интерфейс это не ошибка дизайна. В Go он встречается в двух формах. Первая — any, он же interface{}. Принимает значение любого типа. Используешь, когда тип заранее неизвестен: логгеры, универсальные утилиты, обёртки. Внутри всё равно нужен type switch, иначе с значением ничего не сделаешь. Вторая — marker interface. Это именованный пустой интерфейс. Методов нет, но имя несёт смысл:
type Serializable interface{}

func save(v Serializable) { ... }
Случайный тип сюда не пройдёт, ведь нужно явно объявить, что тип реализует Serializable. Это не защита через методы, это соглашение на уровне типа. Сигнал команде: сюда идут только конкретные объекты. 🐸 Библиотека Go для собеса

🏃‍♀️ Мы собрали бесплатный мега-гайд по ии-агентам 👇 В первой части постов навалили жесткой базы, чтобы вправить мозги на м
🏃‍♀️ Мы собрали бесплатный мега-гайд по ии-агентам 👇 В первой части постов навалили жесткой базы, чтобы вправить мозги на место. Во второй дали конкретные инструменты, фреймворки и пошаговые инструкции, что нужно кодить прямо сейчас. Часть 1. Введение, юзкейсы и реальность Разбираемся с терминами, снимаем розовые очки и смотрим, где ИИ реально приносит бабки, а где только жжет нервы: 1. «Так что вообще считается AI-агентом?» 2. «Где тут бот, а где уже AI-агент?» 3. «Не надо пихать AI-агента в каждую задачу» 4. «Что уже можно спокойно делать через AI-агентов?» 5. «А что через AI-агентов пока лучше не трогать?» Часть 2. Изнанка, ошибки и архитектура Как всё это устроено под капотом, чтобы не слить бюджет и не наломать дров на старте: 6. «Можно ли просто сесть вечером и собрать себе AI-агента?» 7. «С чего вообще начать, если хочется попробовать AI-агентов» 8. «Почему AI-агент может внезапно начать творить дичь» 9. «Где AI-агенты реально экономят время, а где только добавляют возни» 10. «Почему они жрут столько денег?» Часть 3. Хардкорная практика (Что делать руками) Хватит теории. Открываем ноут, запускаем Cursor и делаем нормальные, отказоустойчивые системы: 11. «Почему одного промпта мало?» 12. «Почему AI-агенту мало просто “дать доступ к данным”» 13. «Если не следить за AI-агентом, он быстро начинает жить своей жизнью» 14. «Собрать демку легко. Но как же сделать нормально» 15. «Как сделать, чтобы это не развалилось через неделю?» 👍 Сохраняйте пост в избранное, чтобы не потерять. 🤫 А завтра стартует наш курс по ии-агентам

Почему в go switch отличается от других языков В большинстве языков switch требует явного break в каждом case, иначе выполнение провалится в следующий блок. Go сделал иначе. В Go каждый case автоматически завершается — никакого проваливания:
switch status {
case 1:
    fmt.Println("one")   // выполнится только это
case 2:
    fmt.Println("two")   // сюда не попадём
}
В C/Java без break выполнились бы оба блока. Есть fallthrough, C-стайл поведение можно воспроизвести явно:
switch status {
case 1:
    fmt.Println("one")
    fallthrough   // явно говорим "провалиться" дальше
case 2:
    fmt.Println("two")   // выполнится тоже
}
Несколько значений в одном case:
switch day {
case "Saturday", "Sunday":
    fmt.Println("выходной")
case "Monday", "Friday":
    fmt.Println("почти выходной")
}
🐸 Библиотека Go для собеса

//go:build vs // +build — в чём разница // +build — старая форма, использовалась до Go 1.17. Требует пустой строки после блока директив, а логика объединяется через пробелы (AND) и запятые (OR), что легко перепутать:
// +build linux darwin
// +build amd64

package main
//go:build это новая форма, появилась в Go 1.17. Синтаксис стал читаемым: используются обычные логические операторы &&, ||, !:
//go:build (linux || darwin) && amd64

package main
🐸 Библиотека Go для собеса

Как передавать сигналы в каналах Первый и самый простой это пустая структура. Она не занимает памяти и означает только одно: событие произошло:
done := make(chan struct{})

go func() {
    // работа выполнена
    close(done)
}()

<-done // ждём сигнала
close — лучший способ сигналить завершение, потому что все читатели получат сигнал одновременно. Второй способ это передать булево значение или ошибку, когда важно передать не только факт события, но и его результат:
result := make(chan error, 1)

go func() {
    err := doSomething()
    result <- err
}()

if err := <-result; err != nil {
    log.Fatal(err)
}
Третий способ — context.Context. Это стандарт в продакшн-коде. Контекст несёт в себе сигнал отмены, дедлайн и значения. Внутри он тоже использует канал:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

select {
case result := <-work(ctx):
    fmt.Println(result)
case <-ctx.Done():
    fmt.Println("timeout:", ctx.Err())
}
🐸 Библиотека Go для собеса

Чем отличаются Lock-Free и Wait-Free алгоритмы Lock-Free алгоритмы это безмьютексные структуры данных, где хотя бы один поток всегда завершает операцию за конечное число шагов: • Гарантия: система в целом прогрессирует (нет глобальной блокировки). • Минус: некоторые потоки могут «застревать» в бесконечных retry-циклах (livelock). • Плюсы: высокая производительность, проще в реализации. Wait-Free алгоритмы это строгий подкласс lock-free, где каждый поток завершает операцию за конечное число шагов независимо от других: • Гарантия: индивидуальный прогресс для всех (полная справедливость, нет голодания ресурсов). • Минус: сложнее реализовать, ниже производительность из-за оверхеда на координацию. • Когда использовать: в реал-тайм системах, например, ABA-free структуры. 🐸 Библиотека Go для собеса

Как ведут себя срезы в Go на граничных значениях Срез это не массив, а заголовок из трёх полей: указатель на данные, длина и ёмкость. Это важно понимать, прежде чем говорить о границах. Правило границ. При нарезке a[low:high] должно выполняться 0 <= low <= high <= cap(a). Примечательно, что high ограничен именно cap, а не len — это позволяет «заглянуть» вперёд за текущую длину, если базовый массив это допускает. Нарушение границ — паника в рантайме. Компилятор не проверяет корректность индексов — это делает рантайм. a[0:len(a)+1] скомпилируется, но упадёт с slice bounds out of range при выполнении. Пустой срез — не nil. a[2:2] — валидный срез с длиной 0. Он инициализирован и указывает на память. var s []int — другое: nil-срез, у которого указатель равен nil. len и cap у обоих равны нулю, но s == nil вернёт true только для первого. Разделяемая память. Срезы, нарезанные от одного массива, указывают на те же данные. Запись через один срез изменит то, что видит другой — до тех пор, пока не произошёл рост. Трёхиндексная нарезка. a[low:high:max] задаёт ёмкость результата явно: cap = max - low. Используется, чтобы append не «прорвался» за нужный регион и не затронул соседние данные в исходном массиве. Рост при append. Когда len == cap, Go создаёт новый массив, копирует данные и возвращает срез с новым указателем. С этого момента два среза больше не делят память — и это один из самых частых источников неожиданного поведения на практике. 🐸 Библиотека Go для собеса

Как сделать кастомного обработчика логов с log/slog Нужно реализовать интерфейс slog.Handler: четыре метода, которые дают контроль над форматом, фильтрацией и транспортом логов:
type Handler interface {
    Enabled(context.Context, Level) bool
    Handle(context.Context, Record) error
    WithAttrs(attrs []Attr) Handler
    WithGroup(name string) Handler
}
Enabled вызывается до того, как рантайм начнёт вычислять аргументы лога. Если возвращает false, то Handle вообще не вызывается. Это точка оптимизации: дорогие вычисления в аргументах не будут выполнены для отфильтрованных уровней. Handle — основной метод. Получает Record с временем, уровнем, сообщением и атрибутами. Атрибуты итерируются через коллбэк r.Attrs(fn), а не через срез и это намеренная оптимизация аллокаций, первые несколько атрибутов хранятся инлайн. Метод должен быть goroutine-safe, поэтому нужен мьютекс на запись в io.Writer. WithAttrs вызывается при logger.With("key", "val"). Должен вернуть новый handler с сохранёнными полями — мутировать текущий нельзя, логгер может использоваться из нескольких горутин. WithGroup вызывается при logger.WithGroup("request"). Все последующие атрибуты должны быть вложены под этим ключом: request.method, request.ip. 🐸 Библиотека Go для собеса

Указатель на указатель: когда это реально нужно Двойной указатель решает конкретную задачу: изменить указатель внутри функции так, чтобы изменение было видно снаружи. Проблема без **:
func resetPointer(p *int) {
    newVal := 0
    p = &newVal // меняем локальную копию — снаружи ничего не изменится
}
p — это копия адреса. Переназначение p внутри функции не затрагивает оригинал. Решение через **int:
func resetPointer(p **int) {
    newVal := 0
    *p = &newVal // меняем сам указатель — изменение видно снаружи
}
 
func main() {
    a := 42
    ptr := &a
    resetPointer(&ptr)
    fmt.Println(*ptr) // 0
}
Теперь функция получает адрес самого указателя и может подменить его цель. Другие кейсы Динамические структуры данных — при построении связных списков или деревьев, когда нужно менять корневой указатель:
func insertHead(head **Node, val int) {
    newNode := &Node{val: val, next: *head}
    *head = newNode
}
Инициализация через функцию — паттерн, когда объект создаётся внутри функции и возвращается через параметр, а не через return:
func initConfig(cfg **Config) {
    *cfg = &Config{Timeout: 30}
}
Когда не нужен Если цель просто изменить *значение*, на которое уже указывает указатель, достаточно одного *. Двойной указатель нужен только тогда, когда требуется изменить сам адрес, хранящийся в указателе. 🐸 Библиотека Go для собеса

Что такое указатель на указатель Указатель хранит адрес переменной. Указатель на указатель хранит адрес другого указателя. Каждый уровень добавляет одну «звёздочку» к типу и одно разыменование для доступа к значению. Пример:
a := 100
var b *int  = &a  // b хранит адрес a
var c **int = &b  // c хранит адрес b
Цепочка в памяти выглядит так:
c → b → a → 100
Разыменование:
fmt.Println(a)   // 100          — исходное значение
fmt.Println(b)   // 0xc000014090 — адрес a
fmt.Println(*b)  // 100          — значение a через b
fmt.Println(c)   // 0xc00000e028 — адрес b
fmt.Println(*c)  // 0xc000014090 — адрес a через c
fmt.Println(**c) // 100          — значение a через c
Каждая * это один шаг по цепочке адресов. **c позволяет не только читать значение a, но и менять сам указатель b, то есть переключать его на другую переменную. Именно это делает двойной указатель полезным, а не просто экзотикой. 🐸 Библиотека Go для собеса

Когда использовать package aliasing 📌 Кейсы использования 1. Конфликт имён Два пакета с одинаковым именем. Без алиасов код не скомпилируется.
import (
    sqlDB   "project/sql/db"
    nosqlDB "project/nosql/db"
)
2. Длинный путь импорта Сокращает шум при частом обращении к пакету с громоздким путём:
import (
    mh "myproject/subproject/module/helpers"
)
3. Неудобное или неочевидное имя пакета Актуально для сгенерированного кода, когда имя пакета не совпадает с тем, что ожидает читатель:
import (
    validator "github.com/myorg/gen/v2/validate_pb"
)
4. Пустой импорт _ Специальный алиас для импорта ради побочных эффектов: регистрация драйвера, init()-функция; без использования пакета в коде:
import _ "github.com/lib/pq" // регистрирует PostgreSQL-драйвер
5. Импорт в текущее пространство имён . Позволяет обращаться к экспортируемым именам пакета без префикса:
import . "math"
 
r := Sqrt(16) // вместо math.Sqrt(16)
🐸 Библиотека Go для собеса

Что такое package aliasing Package aliasing это возможность присвоить псевдоним импортируемому пакету прямо в блоке import. Полезен в конкретных ситуациях, но злоупотреблять им не стоит: лишние алиасы усложняют чтение кода. Синтаксис:
import fm "fmt"

fm.Println("hello") // вместо fmt.Println
Алиас указывается перед путём к пакету и полностью заменяет его имя в текущем файле. 🐸 Библиотека Go для собеса

Что такое паттерн Circuit Breaker и зачем он нужен Circuit Breaker это защитный паттерн для распределённых систем. Он автоматически «отключает» вызовы к нестабильному сервису, чтобы предотвратить каскадные сбои и снизить нагрузку на и без того падающий узел. Аналогия проста: как автоматический выключатель в электрощитке защищает проводку от перегрузки, так и Circuit Breaker защищает систему от цепной реакции ошибок. Два состояния Замкнуто — штатный режим. Все запросы проходят в обычном режиме. Счётчик ошибок растёт при каждом сбое. Разомкнуто — защитный режим. После превышения порога ошибок паттерн перестаёт вызывать сервис и сразу возвращает ошибку. Сервис получает время на восстановление. Реализация:

type Circuit func(context.Context) (string, error)

func Breaker(circuit Circuit, failureThreshold uint) Circuit {
    var consecutiveFailures int = 0
    var lastAttempt = time.Now()
    var m sync.RWMutex

    return func(ctx context.Context) (string, error) {
        m.RLock()
        d := consecutiveFailures - int(failureThreshold)

        if d >= 0 {
            // Экспоненциальная выдержка: 2, 4, 8... секунд
            shouldRetryAt := lastAttempt.Add(time.Second * 2 << d)
            if !time.Now().After(shouldRetryAt) {
                m.RUnlock()
                return "", errors.New("service unreachable")
            }
        }
        m.RUnlock()

        response, err := circuit(ctx)

        m.Lock()
        defer m.Unlock()

        lastAttempt = time.Now()
        if err != nil {
            consecutiveFailures++
            return response, err
        }

        consecutiveFailures = 0 // Успех — сбрасываем счётчик
        return response, nil
    }
}
Что важно в этой реализации • sync.RWMutex защищает общее состояние при конкурентных вызовах • Экспоненциальная выдержка (2 << d) даёт сервису всё больше времени на восстановление с каждой неудачной попыткой • После успешного вызова счётчик сбрасывается — цепь «замыкается» обратно автоматически • Функция возвращает тот же тип Circuit, что позволяет прозрачно встраивать Breaker без изменения кода клиента 🐸 Библиотека Go для собеса

В Go существует несколько способов возврата структур или их частей. Назовите основные. 1. Возврат копии структуры Функция возвращает значение, изменения копии не затронут оригинал:
func returnCopy() MyStruct {
    return MyStruct{Value: 1}
}
2. Возврат указателя на структуру Позволяет избежать копирования и работать напрямую с объектом в памяти:
func returnPointer() *MyStruct {
    return &MyStruct{Value: 2}
}
3. Изменение через указатель-параметр Функция принимает указатель и напрямую изменяет оригинальный объект без возврата:
func modifyStruct(s *MyStruct) {
    s.Value = 3
}
4. Возврат части структуры Возвращает только нужное поле, а не всю структуру целиком:
func returnValue(s MyStruct) int {
    return s.Value
}
5. Возврат через интерфейс Позволяет скрыть конкретную реализацию за абстракцией:
type MyInterface interface { DoSomething() }

func returnInterface() MyInterface {
    return MyStruct{}
}
6. Срезы и мапы структур Возврат набора структур через срез или ассоциативный массив:
func returnSlice() []MyStruct {
    return []MyStruct{{Value: 4}, {Value: 5}}
}

func returnMap() map[string]MyStruct {
    return map[string]MyStruct{
        "first":  {Value: 6},
        "second": {Value: 7},
    }
}
7. Возврат через канал Передача структуры между горутинами для конкурентных сценариев:
func sendToChannel(ch chan MyStruct) {
    ch <- MyStruct{Value: 8}
}
Эти подходы можно комбинировать и адаптировать под разные сценарии — учитывая требования к производительности и управлению памятью. 🐸 Библиотека Go для собеса

Как работает sync.WaitGroup изнутри Внутри WaitGroup хранит счётчик. Вы управляете им через три метода: Add(n) увеличивает счётчик на n. Вызывать его нужно до запуска горутин, а не внутри них — иначе возникает гонка данных: Wait может сработать раньше, чем Add успеет зарегистрировать горутину. Done() уменьшает счётчик на 1. Его вызывают внутри горутины, когда работа завершена. Чтобы не забыть вызвать его даже при панике, принято писать defer wg.Done() в самом начале горутины. Wait() блокирует выполнение до тех пор, пока счётчик не вернётся к нулю. Как только все горутины вызвали Done() — основной поток продолжает работу. 🐸 Библиотека Go для собеса

Проект можно запустить быстро, но выдержит ли он рост нагрузки, требования к безопасности и отказоустойчивости? Часто на стар
Проект можно запустить быстро, но выдержит ли он рост нагрузки, требования к безопасности и отказоустойчивости? Часто на старте думают только о функциях. Производительность, масштабирование, защита — добавим потом. В реальности это «потом» оборачивается дорогими переделками и компромиссами. На бесплатном вебинаре: - разберём, как требования к нагрузке, отказоустойчивости и безопасности формируют архитектуру с первого дня. - поговорим о том, какие нефункциональные требования влияют на систему сильнее всего. - как собирать и формулировать их вместе с бизнесом и как принимать архитектурные решения с учётом сроков и бюджета. Спикер Александр Хохлов — архитектор платформенных решений в ГК Иннотех. Открытый урок проходит в преддверии старта курса «Проектирование систем». Регистрируйтесь сейчас - напомним перед вебинаром: регистрация Реклама. ООО «Отус онлайн-образование», ОГРН 1177746618576

Почему append на nil-срезе работает, а запись в nil-мапу вызывает панику nil-срез это валидное значение с len == 0 и cap == 0. Когда вызывается append(), Go сам выделяет новую память под капотом. Явная инициализация не нужна.
var a []int
a = append(a, 4, 5, 6) // Go выделяет память сам
fmt.Println(a)          // [4 5 6]
nil-мапа не инициализирована, у неё нет памяти для хранения данных. Любая попытка записи немедленно вызывает панику в рантайме. Перед использованием нужно явно вызвать make.
var m map[int]int
// m[1] = 1  ← паника!

m = make(map[int]int)
m[1] = 1 // теперь работает
append это функция, которая сама управляет памятью и возвращает новый срез. Мапа же это указатель на структуру данных, и без инициализации этой структуры просто не существует. 🐸 Библиотека Go для собеса