Перейти к основному содержимому

Как решить простейшие задачи с собеседований Golang?

· 7 мин. чтения

Сегодня мы разберём подробный разбор нескольких задач с собеседований на Go: от работы со слайсами и указателей до типичных проблем с горутинами, гонками данных и правильного использования WaitGroup и мьютексов.

Вопрос 1. Что выведет программа при передаче слайса в функцию, если внутри функции изменяется первый элемент, а затем выполняется append нового элемента, при исходной длине и ёмкости слайса равными 4?​

Таймкод: 00:00:00

Ответ собеседника: Правильный. Будет выведен слайс [8 2 3 4], так как при достижении максимальной ёмкости append создаёт новый слайс, и дальнейшие изменения в функции не влияют на оригинальный слайс в main

Правильный ответ:

Рассмотрим поведение подробно на конкретном примере:

package main

import "fmt"

func modify(s []int) {
s[0] = 8 // Изменяем первый элемент — это затронет оригинальный массив
s = append(s, 99) // При len == cap создаётся новый базовый массив
s[0] = 999 // Это изменение уже в новом массиве, оригинал не затронут
}

func main() {
s := make([]int, 4, 4) // len=4, cap=4
s[0], s[1], s[2], s[3] = 1, 2, 3, 4

modify(s)
fmt.Println(s) // [8 2 3 4]
}

Вывод программы: [8 2 3 4]

Почему именно так — ключевые механизмы:

1. Слайс — это структура с тремя полями

Каждый слайс содержит указатель на базовый массив, длину (len) и ёмкость (cap). При передаче в функцию эта структура копируется по значению, но указатель ссылается на тот же базовый массив.

type sliceHeader struct {
Data uintptr // указатель на базовый массив
Len int
Cap int
}

2. Изменение элемента по индексу (s[0] = 8)

Это изменение записывается напрямую в базовый массив. Поскольку копия слайса в функции указывает на тот же массив, что и оригинал в main, это изменение видно извне. Поэтому после вызова modify первый элемент стал 8.

3. Append при len == cap

Когда ёмкость исчерпана, append выделяет новый базовый массив (обычно с удвоением ёмкости), копирует существующие элементы и добавляет новый. Локальная переменная s внутри функции теперь указывает на совершенно другой массив. Любые дальнейшие изменения через этот слайс не влияют на оригинал.

4. Что произошло пошагово

ШагДействиеОригинальный массивЛокальный слайс в modify
1s[0] = 8[8, 2, 3, 4]указывает на тот же массив
2append(s, 99)[8, 2, 3, 4]указывает на новый массив [8, 2, 3, 4, 99]
3s[0] = 999[8, 2, 3, 4]указывает на [999, 2, 3, 4, 99]

5. Как сделать так, чтобы append тоже был виден

Если нужно, чтобы изменения после append были видны в вызывающем коде, есть два подхода:

// Подход 1: Передача указателя на слайс
func modify(s *[]int) {
*s = append(*s, 99)
}

// Подход 2: Возврат нового слайса
func modify(s []int) []int {
s = append(s, 99)
return s
}

Ключевое правило для запоминания: модификация существующих элементов слайса внутри функции видна вызывающему коду, потому что копия слайса ссылается на тот же базовый массив. Но append, вызвавший реаллокацию, создаёт новый массив, и дальнейшая работа внутри функции идёт с ним — оригинал остаётся нетронутым.

Вопрос 2. Является ли неинициализированный слайс nil, и равна ли его длина нулю?​

Таймкод: 00:03:19

Ответ собеседника: Неполный. Сначала было сказано, что слайс не является nil, но затем исправлено — он является nil, так как проверяется указатель. Длина действительно равна нулю

Правильный ответ:

Неинициализированный слайс — это nil, его длина равна нулю. Однако важно понимать разницу между nil-слайсом и пустым инициализированным слайсом, потому что это влияет на поведение программы.

Детальный разбор:

package main

import "fmt"

func main() {
// Неинициализированный слайс — nil
var s1 []int

// Пустой инициализированный слайс — не nil
s2 := []int{}
s3 := make([]int, 0)

fmt.Println(s1 == nil) // true
fmt.Println(s2 == nil) // false
fmt.Println(s3 == nil) // false

fmt.Println(len(s1), cap(s1)) // 0 0
fmt.Println(len(s2), cap(s2)) // 0 0
fmt.Println(len(s3), cap(s3)) // 0 0
}

Что происходит в памяти:

Nil-слайс — это слайс-структура, у которой указатель на базовый массив равен nil. Никакой памяти под элементы не выделено. Это значение по умолчанию для переменной типа []T.

Пустой инициализированный слайс — указатель не nil (он указывает на выделенную, но пустую область памяти или на специальный zero-length массив), но длина и ёмкость равны нулю.

Почему это важно на практике:

1. Сериализация JSON

Nil-слайс сериализуется как null, а пустой слайс — как пустой массив []. Это частая причина багов в API:

type Response struct {
Items []int `json:"items"`
}

var s1 []int // JSON: {"items": null}
s2 := []int{} // JSON: {"items": []}

2. Проверка на пустоту

Для проверки на пустоту всегда используйте len(s) == 0, а не s == nil. Первый способ работает корректно в обоих случаях:

func process(items []int) {
if len(items) == 0 {
// Обработает и nil, и пустой слайс
return
}
// ...
}

3. Append работает одинаково

Append корректно работает с nil-слайсом — он выделит новый базовый массив:

var s []int // nil
s = append(s, 1) // Работает, s == [1]

4. Range работает одинаково

Итерация по nil-слайсу безопасна и не вызывает ошибок:

var s []int
for _, v := range s {
fmt.Println(v) // Не выполнится ни разу
}

5. Когда важно различать nil и пустой слайс

В некоторых библиотеках и фреймворках nil и пустой слайс имеют разную семантику. Например, в Google Protobuf nil-повторяемые поля означают «поле не установлено», а пустой слайс — «поле установлено, но пусто». Это влияет на поведение сериализации и десериализации.

Итог: неинициализированный слайс — nil, его len и cap равны нулю. Пустой инициализированный слайс — не nil, но len и cap тоже равны нулю. Для проверки на пустоту используйте len(s) == 0, а проверку на nil применяйте только когда вам важна именно эта разница (сериализация, специфичные протоколы).

Вопрос 3. Что произойдёт при запуске 1000 горутин, каждая из которых инкрементирует общий счётчик, без синхронизации?​

Таймкод: 00:03:58

Ответ собеседника: Правильный. Возникнут две проблемы: гонка данных из-за одновременного доступа к переменной и преждевременное завершение main до выполнения горутин. Для решения нужно использовать WaitGroup для ожидания завершения горутин и Mutex для защиты от гонки данных

Правильный ответ:

Запуск 1000 горутин с несинхронизированным инкрементом общего счётчика приводит к двум критическим проблемам: гонка данных и потенциальное преждевременное завершение программы.

Проблемный код:

package main

import "fmt"

func main() {
var counter int

for i := 0; i < 1000; i++ {
go func() {
counter++ // Гонка данных!
}()
}

fmt.Println(counter) // Резульат непредсказуем, часто 0
}

Проблема 1: Гонка данных (Data Race)

Инкремент counter++ — это не атомарная операция. Она состоит из трёх шагов:

  1. Чтение текущего значения counter из памяти
  2. Увеличение значения на 1
  3. Запись нового значения обратно в память

Когда две горутины одновременно выполняют эти шаги, может произойти следующее:

Горутина A: читает counter = 5
Горутина B: читает counter = 5
Горутина A: записывает counter = 6
Горутина B: записывает counter = 6 // Потерянный инкремент!

В результате итоговое значение будет меньше 1000, и оно будет отличаться при каждом запуске.

Проблема 2: Преждевременное завершение main

После цикла запуска горутин main сразу выводит значение counter. Горутины не успевают выполниться, потому что main не ждёт их завершения. Результат — счётчик часто равен 0 или очень малому числу.

Способы решения:

Вариант 1: Mutex + WaitGroup

package main

import (
"fmt"
"sync"
)

func main() {
var counter int
var mu sync.Mutex
var wg sync.WaitGroup

for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counter++
mu.Unlock()
}()
}

wg.Wait()
fmt.Println(counter) // 1000
}

Вариант 2: sync/atomic (самый производительный)

package main

import (
"fmt"
"sync"
"sync/atomic"
)

func main() {
var counter int64
var wg sync.WaitGroup

for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&counter, 1)
}()
}

wg.Wait()
fmt.Println(counter) // 1000
}

Атомарные операции реализуются на уровне процессора без блокировок, что делает их значительно быстрее мьютексов для простых операций.

Вариант 3: Через каналы (идиоматический Go)

package main

import "fmt"

func main() {
ch := make(chan int, 1000)

for i := 0; i < 1000; i++ {
go func() {
ch <- 1
}()
}

counter := 0
for i := 0; i < 1000; i++ {
counter += <-ch
}

fmt.Println(counter) // 1000
}

Как обнаружить гонку данных:

Go встроен детектор гонок. Запускайте программы и тесты с флагом -race:

go run -race main.go
go test -race ./...

Детектор значительно замедляет программу (в 5–10 раз), поэтому используется только при разработке и тестировании, не в production.

Сравнение подходов:

ПодходПроизводительностьСложностьКогда использовать
sync.MutexСредняяНизкаяСложные критические секции
sync/atomicВысокаяНизкаяПростые операции с числами
КаналыСредняяСредняяКоординация горутин, передача данных

Ключевое правило: если две горутины одновременно обращаются к одной переменной и хотя бы одна из них пишет — нужна синхронизация. Всегда.