Введение: Почему Go и почему так сложно?
Go (или Golang) — язык, который обожают за простоту и ненавидят за ту же простоту. На собеседованиях по Go редко спрашивают «напиши сортировку пузырьком» (хотя и такое бывает). Чаще всего вас ждут задачи на конкурентность, понимание устройства памяти и умение писать чистый, идиоматичный код. Если вы привыкли к Python или Java, Go может показаться слишком строгим. Но именно эта строгость и делает его идеальным языком для production-систем.
В этой статье мы разберём ключевые темы, которые обязательно всплывут на собеседовании. Вы получите не просто теорию, а конкретные примеры кода и объяснение «под капотом». Поехали!
1. Горутины и каналы: база, которую нужно знать наизусть
Горутины — это не потоки
Самый частый вопрос: «Чем горутина отличается от потока ОС?». Ответ: горутина легковесна (стек от 2 КБ), управляется планировщиком Go, а не ядром. Миллион горутин — нормально. Миллион потоков — убьёт систему.
// Пример: запуск 10 горутин
for i := 0; i < 10; i++ {
go func(id int) {
fmt.Printf("Горутина %d работает\n", id)
}(i)
}
// Ждём (плохой способ, только для примера)
time.Sleep(time.Second)
Каналы: синхронизация без боли
Каналы в Go — это типизированные «трубы» для передачи данных между горутинами. Запомните три правила:
- Запись в закрытый канал — паника.
- Чтение из закрытого канала — возвращает zero value.
- Небуферизированный канал блокирует горутину до появления получателя.
// Правильная работа с каналом
ch := make(chan int, 2) // буферизированный
ch <- 1
ch <- 2
close(ch)
for val := range ch {
fmt.Println(val) // 1, 2
}
Совет для собеседования: если спросят про «select», покажите пример с таймаутом:
select {
case msg := <-ch:
fmt.Println(msg)
case <-time.After(1 * time.Second):
fmt.Println("Таймаут!")
}
2. Интерфейсы: не просто методы, а контракты
В Go интерфейсы реализуются неявно. Это значит, что структура автоматически удовлетворяет интерфейсу, если у неё есть все нужные методы. Часто спрашивают: «В чём отличие interface{} от any?» — ответ: ни в чём, это синонимы (с Go 1.18).
Типичная задача на собеседовании
Напишите функцию, которая принимает io.Writer и записывает в него строку. Это проверяет понимание интерфейсов из стандартной библиотеки.
func WriteHello(w io.Writer) {
_, err := w.Write([]byte("Hello, Go!"))
if err != nil {
log.Fatal(err)
}
}
// Использование:
var buf bytes.Buffer
WriteHello(&buf)
fmt.Println(buf.String()) // Hello, Go!
Ловушка: все интерфейсы в Go — это два указателя (тип + значение). Пустой интерфейс (interface{}) не равен nil, если в нём лежит (*int)(nil). Запомните это, чтобы не провалить вопрос про nil != nil.
3. Дженерики: новое, но уже обязательное
С Go 1.18 дженерики стали частью языка. На собеседовании могут попросить написать обобщённую функцию. Например, функцию, которая принимает срез любого типа и возвращает его длину (тривиально) или находит максимум.
// Дженерик-функция для поиска максимума
func Max[T constraints.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
// Использование:
fmt.Println(Max(10, 20)) // 20
fmt.Println(Max(3.14, 2.71)) // 3.14
Обратите внимание на пакет constraints — он часто используется в вопросах. Но помните: с Go 1.21 его заменили на встроенные cmp.Ordered.
4. Обработка ошибок: не игнорируйте их
В Go нет try-catch. Ошибки — это значения. Типичный код:
f, err := os.Open("file.txt")
if err != nil {
return fmt.Errorf("не могу открыть файл: %w", err)
}
defer f.Close()
Что спросят:
- Разница между
%vи%wвfmt.Errorf(обёртывание ошибки с сохранением цепочки). - Как проверить тип ошибки? —
errors.Asиerrors.Is. - Зачем нужен
defer? — для закрытия ресурсов, даже если функция паникует.
5. Память и указатели: где живёт ваша переменная?
Вопросы про кучу и стек — классика. Go использует escape analysis для определения, где разместить переменную. Если компилятор видит, что переменная «убегает» (используется после возврата из функции), она попадает в кучу.
func createUser() *User {
u := User{Name: "Alice"} // выделяется на стеке?
return &u // но указатель уходит — escape в кучу
}
Совет: не пытайтесь микрооптимизировать. Go-компилятор умнее вас. Пишите чистый код, а профилировщик покажет узкие места.
6. Структуры и методы: value receiver vs pointer receiver
Вопрос, который разделяет junior и middle. Правило:
- Используйте pointer receiver (
(s *Struct)), если метод изменяет структуру или структура большая (чтобы избежать копирования). - Используйте value receiver (
(s Struct)), если структура маленькая и неизменяемая.
type Counter struct {
Value int
}
// Pointer receiver — изменяет состояние
func (c *Counter) Increment() {
c.Value++
}
// Value receiver — только читает
func (c Counter) GetValue() int {
return c.Value
}
Ловушка: если у структуры есть хотя бы один pointer receiver, все остальные методы тоже должны быть pointer receiver — иначе можно получить неожиданное поведение при копировании.
7. Стандартная библиотека: ваш лучший друг
На собеседовании редко спрашивают про сторонние фреймворки (Gin, Echo).