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

Как решить простейшие задачи с собеседований 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ВысокаяНизкаяПростые операции с числами
КаналыСредняяСредняяКоординация горутин, передача данных

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