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

Golang 100 вопросов с собеседований. Подготовка Go разработчика Часть 1

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

Сегодня мы разберём расшифровку видеоролика, в котором автор последовательно разбирает 100 вопросов для подготовки к собеседованию на позицию Go-разработчика — от базовых концепций языка до практических задач с кодом. Материал структурирован как методическое пособие: каждый вопрос сопровождается кратким теоретическим объяснением и примерами на Go, что делает его полезным как для начинающих, так и для опытных разработчиков, желающих систематизировать знания.

Вопрос 1. Как реализовано ООП в Go?

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

Ответ собеседника: Правильный. Полноценного классического ООП в Go нет, но это компенсируется другими механизмами. Отдельно описываются свойства (структуры) и отдельно — поведение (методы), что позволяет использовать композицию. Есть интерфейсы — набор методов. Инкапсуляция обеспечивается пакетами: видимость имён зависит от регистра первой буквы. Наследования нет, но есть встраивание структур (embedding), при котором методы встроенной структуры доступны родительской, и дочерняя структура может переопределять методы родительской.

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

Go сознательно отказался от классической парадигмы ООП в пользу более простой и прагматичной модели. Рассмотрим три столпа ООП и то, как они реализованы в Go.

Инкапсуляция

В Go инкапсуляция реализуется на уровне пакетов, а не классов. Правило простое: идентификатор, начинающийся с заглавной буквы, экспортируется (публичный), с прописной — нет (приватный в рамках пакета).

package user

// User — экспортируемая структура, доступна извне пакета
type User struct {
Name string // экспортируемое поле
email string // неэкспортируемое поле, доступно только внутри пакета user
}

// NewUser — конструктор, распространённый паттерн в Go
func NewUser(name, email string) *User {
return &User{
Name: name,
email: email,
}
}

// Email — геттер для приватного поля
func (u *User) Email() string {
return u.email
}

Важно понимать, что приватность — на уровне пакета, а не структуры. Два разных типа внутри одного пакета видят приватные поля друг друга.

Полиморфизм

В Go полиморфизм реализуется через интерфейсы. Ключевое отличие от классического ООП — реализация интерфейса неявная (duck typing на этапе компиляции). Тип автоматически реализует интерфейс, если имеет все его методы — никаких ключевых слов implements не требуется.

package shape

// Shape — интерфейс с двумя методами
type Shape interface {
Area() float64
Perimeter() float64
}

// Rectangle — структура, реализующая Shape неявно
type Rectangle struct {
Width, Height float64
}

func (r Rectangle) Area() float64 {
return r.Width * r.Height
}

func (r Rectangle) Perimeter() float64 {
return 2 * (r.Width + r.Height)
}

// Circle — другая структура, тоже реализующая Shape
type Circle struct {
Radius float64
}

func (c Circle) Area() float64 {
return 3.14159 * c.Radius * c.Radius
}

func (c Circle) Perimeter() float64 {
return 2 * 3.14159 * c.Radius
}

// PrintArea — полиморфная функция, принимающая любой Shape
func PrintArea(s Shape) {
fmt.Printf("Area: %.2f\n", s.Area())
}

Такой подход позволяет писать код, зависящий только от поведения (интерфейса), а не от конкретной реализации. Пустой интерфейс interface{} (или any в Go 1.18+) может принимать любой тип.

Композиция вместо наследования

В Go нет наследования в классическом смысле. Вместо этого используется встраивание (embedding) — мощный механизм композиции.

package animal

import "fmt"

// Animal — базовая структура
type Animal struct {
Name string
}

func (a Animal) Speak() string {
return "..."
}

// Dog — структура, встраивающая Animal
type Dog struct {
Animal // встраивание — методы Animal доступны через Dog
Breed string
}

// Speak — переопределение метода (shadowing)
func (d Dog) Speak() string {
return fmt.Sprintf("%s says Woof!", d.Name)
}

func main() {
dog := Dog{
Animal: Animal{Name: "Rex"},
Breed: "Labrador",
}

// Метод Animal доступен через Dog
fmt.Println(dog.Name) // "Rex" — прямой доступ к полю встроенной структуры
fmt.Println(dog.Speak()) // "Rex says Woof!" — вызван переопределённый метод

// Но полиморфизм работает только явно
var a Animal = dog.Animal // нужно явно извлечь встроенную структуру
fmt.Println(a.Speak()) // "..."
}

Важный нюанс: при встраивании методы встроенной структуры доступны напрямую, но Dog не является Animal. Если функция принимает Animal, передать Dog напрямую нельзя — нужно явно извлечь встроенное поле.

Интерфейс error — повсеместное использование

Один из самых важных интерфейсов в Go — error:

type error interface {
Error() string
}

Это интерфейс с одним методом, и любой тип, имеющий метод Error() string, может быть использован как ошибка. Это позволяет создавать кастомные типы ошибок:

type ValidationError struct {
Field string
Message string
}

func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error on field '%s': %s", e.Field, e.Message)
}

func ValidateUser(u *User) error {
if u.Name == "" {
return &ValidationError{Field: "Name", Message: "cannot be empty"}
}
return nil
}

Обобщения (Generics)

Начиная с Go 1.18, появились параметризованные типы, что дополнило ООП-модель:

// Min — обобщённая функция
func Min[T constraints.Ordered](a, b T) T {
if a < b {
return a
}
return b
}

// Использование
fmt.Println(Min(3, 7)) // 7 — int
fmt.Println(Min(3.14, 2.71)) // 2.71 — float64

Ключевые принципы Go в контексте ООП:

  • Структуры + методы вместо классов
  • Интерфейсы вместо наследования
  • Композиция вместо наследования (embedding)
  • Неявное удовлетворение интерфейсов
  • Маленькие интерфейсы (лучше один-два метода, чем десять)
  • Принимайте интерфейсы, возвращайте конкретные типы

Философия Go: простота и читаемость важнее формальной красоты ООП-иерархий. Композиция даёт большую гибкость, чем глубокие цепочки наследования, и код становится проще тестировать и поддерживать.

Вопрос 2. Каковы особенности Go по сравнению с Python и Java?

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

Ответ собеседника: Правильный. Сходства с Java: оба языка компилируются, имеют строгую статическую типизацию, поддерживают многопоточность. Отличия от Java: в Java есть виртуальная машина, Java — классический ООП-язык, а Go использует структуры и интерфейсы. В Go есть горутины — более легковесные, чем потоки Java, их можно запускать миллионами. Сходства с Python: у обоих простой синтаксис. Отличия от Python: Go — компилируемый и значительно быстрее, в Go гораздо лучше поддерживается параллелизм — достаточно добавить ключевое слово go перед вызовом функции. Go изначально проектировался под параллелизм.

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

Сравнение Go и Java

Сходства:

  • Оба языка компилируются и имеют статическую типизацию
  • Оба поддерживают многопоточность из коробки
  • Оба имеют развитую экосистему и широко используются в enterprise

Ключевые отличия Go от Java:

Компиляция и выполнение. Go компилируется напрямую в нативный машинный код для целевой платформы. Java компилируется в байт-код, который выполняется на JVM. Это даёт Go преимущество в скорости старта и меньшее потребление памяти — нет накладных расходов на JVM.

Модель конкурентности. В Java конкурентность реализуется через потоки ОС (с версии 21 появились виртуальные потоки, но они всё ещё тяжелее горутин). В Go горутины — это легковесные зелёные потоки, управляемые рантаймом Go. Стек горутины начинается с 2 КБ и растёт по мере необходимости, тогда как поток Java по умолчанию получает 512 КБ–1 МБ. Благодаря этому в одном процессе Go можно запустить сотни тысяч или даже миллионы горутин.

// Запуск горутины — тривиально
go func() {
fmt.Println("Hello from goroutine")
}()

// В Java аналог требует больше кода
// Thread.ofVirtual().start(() -> System.out.println("Hello from virtual thread"));

ООП-модель. Java — классический ООП-язык с наследованием, классами, иерартиями. Go использует структуры, интерфейсы и композицию. В Go нет исключений — вместо них явные ошибки как возвращаемые значения. Нет конструкторов — вместо них фабричные функции. Нет дженериков до версии 1.18 (и они до сих пор ограничены по сравнению с Java).

Управление памятью. Оба языка имеют сборщик мусора, но Go оптимизирован для низких задержек (low-latency GC), а Java — для высокой пропускной способности (throughput). В Go нет возможности тонкой настройки GC, как в Java (G1, ZGC, Shenandoah и т.д.).

Размер бинарника. Go статически линкует всё в один бинарник без внешних зависимостей. Java требует установленной JVM и поставляет JAR-файлы.

Сравнение Go и Python

Сходства:

  • Простой и читаемый синтаксис
  • Быстрое прототипирование
  • Богатая стандартная библиотека

Ключевые отличия Go от Python:

Производительность. Go компилируется в нативный код и работает в десятки-сотни раз быстрее интерпретируемого Python. Для CPU-bound задач разница колоссальна.

// Go — быстрый компилируемый код
func Sum(nums []int) int {
sum := 0
for _, n := range nums {
sum += n
}
return sum
}
# Python — интерпретируемый, медленнее
def sum(nums):
return sum(nums)

Параллелизм. В Python из-за GIL (Global Interpreter Lock) нельзя выполнять потоки Python параллельно на нескольких ядрах CPU — только конкурентно. Для реального параллелизма нужно использовать multiprocessing (отдельные процессы), что дорого по памяти. В Go горутины автоматически распределяются по всем доступным ядрам через планировщик runtime (модель M:N — M горутин на N потоках ОС).

// Go — настоящий параллелизм с горутинами
func processConcurrently(items []Item) {
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(i Item) {
defer wg.Done()
process(i)
}(item)
}
wg.Wait()
}
# Python — потоки не дают параллелизма из-за GIL
import threading

def process_concurrently(items):
threads = []
for item in items:
t = threading.Thread(target=process, args=(item,))
t.start()
threads.append(t)
for t in threads:
t.join() # Выполняется конкурентно, НЕ параллельно

Типизация. Go — статически типизированный язык с выводом типов. Python — динамически типизированный. Статическая типизация в Go ловит ошибки на этапе компиляции, улучшает производительность и делает код самодокументированным.

Развёртывание. Go-приложение — один статически слинкованный бинарник. Python требует установленного интерпретатора, виртуального окружения, зависимостей.

Типичные сценарии выбора:

КритерийGoJavaPython
Микросервисы и API✅ Отлично✅ Хорошо⚠️ Средне
Высоконагруженные системы✅ Отлично✅ Хорошо❌ Плохо
Data Science / ML❌ Плохо⚠️ Средне✅ Отлично
DevOps / Инструменты✅ Отлично⚠️ Средне✅ Хорошо
Enterprise с большими командами⚠️ Хорошо✅ Отлично⚠️ Средне
Быстрое прототипирование⚠️ Хорошо⚠️ Средне✅ Отлично

Итог. Go занимает нишу между Python (простота, скорость разработки) и Java (производительность, надёжность). Он сочетает простоту Python с производительностью компилируемого языка, при этом имея лучшую модель конкурентности из коробки. Это делает Go идеальным выбором для облачной инфраструктуры, микросервисов, CLI-инструментов и высоконагруженных систем.

Вопрос 3. Каковы преимущества и недостатки Go?

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

Ответ собеседника: Правильный. Преимущества: простой синтаксис (официальное руководство всего 50 страниц), много инструментов и библиотек, высокая производительность, надёжность, кроссплатформенность, одна из наиболее полных поддержек UTF-8 среди всех языков. Недостатки: ограниченный функционал из-за простоты, приходится писать собственные решения (велосипеды), время трения (friction), большой размер исполняемых файлов (Hello World — около 1,5 МБ), нет ручного управления памятью (в отличие от C/C++), все переменные передаются в функции исключительно по значению. Go хорошо подходит для микросервисов и бэкенда, но плохо — для создания графических интерфейсов.

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

Преимущества Go

Простота и читаемость. Go намеренно минималистичен. В языке около 25 ключевых слов, нет классов, исключений, наследования, перегрузки операторов, аннотаций. Это означает, что любой разработчик может прочитать и понять чужой код Go за считанные часы. Официальная спецификация языка занимает менее 50 страниц — для сравнения, спецификация Java — сотни страниц.

Быстрая компиляция. Go компилируется за секунды даже для больших проектов. Это достигается за счёт ациклического графа зависимостей пакетов, отсутствия препроцессора и эффективного дизайна компилятора.

Встроенные инструменты. В стандартной поставке: форматтер кода (gofmt), линтер (go vet), тестирование, бенчмаркингон, профилирование, управление зависимостями, документация. Не нужно выбирать и настраивать сторонние инструменты.

Статическая линковка. Результат компиляции — один бинарный файл без внешних зависимостей. Деплой тривиален: скопировать файл и запустить. Идеально для контейнеров.

Мощная модель конкурентности. Горутины и каналы — первоклассные конструкции языка, а не библиотеки. Планировщик runtime эффективно распределяет горутины по потокам ОС.

// Простейший параллельный паттерн
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
fmt.Printf("worker %d processing job %d\n", id, j)
results <- j * 2
}
}

func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)

// Запуск 3 воркеров
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}

// Отправка 9 заданий
for j := 1; j <= 9; j++ {
jobs <- j
}
close(jobs)

// Сбор результатов
for a := 1; a <= 9; a++ {
<-results
}
}

Отличная поддержка UTF-8. Тип rune представляет Unicode code point, строки по умолчанию в UTF-8. Работа с юникодом встроена в язык и стандартную библиотеку.

Кросс-компиляция. Компиляция под другую ОС и архитектуру — одна переменная окружения:

GOOS=linux GOARCH=amd64 go build -o app-linux
GOOS=windows GOARCH=amd64 go build -o app.exe

Надёжность и безопасность. Статическая типизация, сборщик мусора, отсутствие неопределённого поведения (в отличие от C/C++), проверка границ массивов — всё это снижает количество багов.

Недостатки Go

Многословность (verbosity). Отсутствие сокращений приводит к повторяющемуся коду. Нет тернарного оператора, нет map/filter/reduce для коллекций из коробки, нет сахара для работы с опциональными значениями.

// В Go — многословно
var result string
if condition {
result = "yes"
} else {
result = "no"
}

// В других языкаx — компактно
// result = condition ? "yes" : "no"

Обработка ошибок. Идиоматический Go требует проверки ошибки после каждого вызова, что приводит к множеству if err != nil. С версии 1.23 появился экспериментальный range over func, но фундаментально подход не изменился.

// Типичный Go-код — много повторяющихся проверок
result1, err := doSomething1()
if err != nil {
return err
}
result2, err := doSomething2(result1)
if err != nil {
return err
}
result3, err := doSomething3(result2)
if err != nil {
return err
}

Отсутствие зрелых библиотек в некоторых областях. Для GUI, машинного обучения, научных вычислений экосистема Go значительно уступает Python и Java. Приходится либо писать самому, либо использовать CGo-обёртки.

Дженерики ограничены. Появились только в Go 1.18 (2022) и до сих пор имеют ограничения: нет вариантности (covariance/contravariance), ограниченные операции с типами, нельзя использовать методы в ограничениях интерфейсов так гибко, как хотелось бы.

Размер бинарников. Даже «Hello World» весит 1,5–2 МБ, потому что рантайм Go (включая сборщик мусора, планировщик, аллокатор) статически линкуется в каждый бинарник. Для контейнеров это не критично, но для embedded-систем может быть проблемой. Можно уменьшить через -ldflags="-s -w" и upx.

Отсутствие ручного управления памятью. В отличие от C/C++ или Rust, нельзя точечно управлять выделением и освобождением памяти. Для высоконагруженных систем, где важна предсказуемость латентности, это может быть ограничением.

Передача по значению. Все параметры функций передаются по значению (копируются). Для больших структур это может быть дорого — нужно явно использовать указатели, что усложняет рассуждения о владении и мутабельности.

Когда выбирать Go, а нет:

ПодходитНе подходит
Микросервисы, APIGUI-приложения
CLI-инструментыData Science / ML
DevOps / инфраструктураСложная бизнес-логика с глубокими иерархиями
Высоконагруженные системыБыстрое прототипирование
Сетевые приложенияСистемное программирование (драйверы)

Итог. Go — это прагматичный выбор для серверной разработки и инфраструктурных задач. Его простота — это и сила, и ограничение. Язык жертвует выразительностью ради читаемости и предсказуемости, что окупается в больших проектах с командами разного уровня.

Вопрос 4. Какие основные типы данных есть в Go и чем отличается массив от среза?

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

Ответ собеседника: Правильный. В Go есть следующие типы данных: булевы (bool), числовые (int, float, complex), строки (string), массивы (array), срезы (slice), структуры (struct), функции (func), интерфейсы (interface), мапы — пары ключ-значение (map), каналы (channel) для связи между горутинами. Основное отличие массива от среза: у массива фиксированная длина, а у среза длина нет — срез является динамически расширяемым массивом. При создании массива указывается длина (или ...), а при создании среза — нет.

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

Основные типы данных Go

Булев тип:

var flag bool = true

Числовые типы. Go предоставляет явно заданные размеры, что отличает его от языков, где размер int зависит от платформы:

var i int // зависит от платформы: 32 или 64 бита
var i8 int8 // -128 to 127
var i16 int16
var i32 int32
var i64 int64
var u uint // unsigned
var u8 uint8 // 0 to 255, псевдоним: byte
var f32 float32
var f64 float64
var c64 complex64
var c128 complex128

Строки. Строка в Go — неизменяемая последовательность байт, по умолчанию в кодировке UTF-8:

s := "Hello, 世界"
fmt.Println(len(s)) // 13 (байт, не символов)
fmt.Println(utf8.RuneCountInString(s)) // 9 (рун/символов)

Массивы (Array). Фиксированная длина, является значимым типом (value type). Длина — часть типа, поэтому [3]int и [5]int — разные несовместимые типы:

var arr [5]int // массив из 5 нулей
arr2 := [3]int{1, 2, 3} // инициализация
arr3 := [...]int{1, 2, 3, 4} // длина вычисляется компилятором

// Присваивание копирует весь массив
arr4 := arr3 // полная копия, не ссылка

Срезы (Slice). Динамическая обёртка над массивом. Это ссылочный тип (reference type), содержащий указатель на базовый массив, длину и ёмкость:

s := []int{1, 2, 3} // литерал среза
s2 := make([]int, 5) // длина 5, ёмкость 5
s3 := make([]int, 5, 10) // длина 5, ёмкость 10

// Добавление элементов
s = append(s, 4, 5)

// Срез диапазона (sub-slicing)
sub := s[1:3] // элементы с индекса 1 по 2

Мапы (Map). Хеш-таблица пар ключ-значение:

m := make(map[string]int)
m["key"] = 42

// Проверка наличия ключа
if val, ok := m["key"]; ok {
fmt.Println(val) // 42
}

// Литерал
m2 := map[string]int{"a": 1, "b": 2}

Структуры (Struct). Составной тип для группировки полей:

type Point struct {
X, Y float64
}

p := Point{X: 1, Y: 2}
p.X = 10

Указатели (Pointer). Хранят адрес в памяти, но без арифметики указателей (в отличие от C):

var p *int
i := 42
p = &i
fmt.Println(*p) // 42

Функции. Функции — тип первого класса, можно передавать как аргументы и возвращать:

func apply(a, b int, op func(int, int) int) int {
return op(a, b)
}

Интерфейсы (Interface). Набор сигнатур методов:

type Writer interface {
Write(p []byte) (n int, err error)
}

Каналы (Channel). Типизированные каналы для коммуникации между горутинами:

ch := make(chan int) // небуферизированный
ch2 := make(chan int, 10) // буферизированный на 10 элементов

ch <- 42 // отправка
val := <-ch // получение

Глубокое сравнение массива и среза

Внутреннее устройство среза. Срез — это структура из трёх полей:

// Упрощённое представление (из runtime)
type slice struct {
array unsafe.Pointer // указатель на базовый массив
len int // текущая длина
cap int // ёмкость (максимальная длина без реаллокации)
}

Ключевые отличия:

ХарактеристикаМассив [N]TСрез []T
РазмерФиксированный, часть типаДинамический
Хранение в памятиЗначение (value type)Ссылка на базовый массив (reference type)
Передача в функциюКопируется полностьюКопируется только заголовок (24 байта)
СравнимостьСравним (==)Не сравним (кроме nil)
ИспользованиеРедко, для специфических задачПовсеместно

Поведение при append и разделяемой памяти:

original := []int{1, 2, 3, 4, 5}
sub := original[0:3] // [1, 2, 3] — делит базовый массив с original

sub = append(sub, 999) // перезаписывает original[3]!
fmt.Println(original) // [1, 2, 3, 999, 5] — неожиданное изменение!

Чтобы избежать этого, нужно создавать копию или ограничить ёмкость:

// Создание независимой копии
sub := make([]int, 3)
copy(sub, original[0:3])

// Или ограничение ёмкости (force copy при append)
sub := original[0:3:3] // len=3, cap=3 — append вызовет реаллокацию

Рост ёмкости среза. При нехватке ёмкости Go выделяет новый массив с увеличенной ёмкостью. Стратегия роста: при длине менее 1024 — удвоение, далее — примерно 25% роста. Это важно учитывать для производительности:

// Если знаем размер заранее — лучше аллоцировать сразу
data := make([]int, 0, 10000) // len=0, cap=10000
for i := 0; i < 10000; i++ {
data = append(data, i) // без реаллокаций
}

Практический вывод: массивы в Go используются редко — в основном как основа для срезов или для фиксированных структур данных (например, [32]byte для хешей). Срезы — основной инструмент работы с последовательностями элементов.

Вопрос 5. Как создаются переменные и строки в Go?

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

Ответ собеседника: Правильный. Переменные создаются с помощью ключевого слова var: var имя_переменной тип = значение. Также можно использовать сокращённое присвоение :=, при котором Go сам определяет тип. Для строк используются только двойные кавычки. Для создания многострочных строк используется символ обратной кавычки (backtick, где тильда). Массив создаётся с указанием длины: var arr [3]int = [3]int&#123;1,2,3&#125; или var arr [3]int (инициализация нулями). Мапа создаётся так: m := map[string]int&#123;"key": value&#125;.

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

Создание переменных

В Go есть несколько способов объявления переменных:

Полная форма с var:

var name string = "Alice"
var age int = 30
var isActive bool = true

С выводом типа (компилятор определяет тип по значению):

var name = "Alice" // string
var age = 30 // int
var isActive = true // bool

Без значения — инициализация нулевым значением:

var name string // ""
var age int // 0
var isActive bool // false
var ptr *int // nil

Групповое объявление:

var (
name string = "Alice"
age int = 30
city string = "Moscow"
)

Сокращённая форма (:=) — только внутри функций:

name := "Alice"
age := 30
x, y := 1, 2 // множественное присваивание

Важно: := можно использовать только для новых переменных. Если хотя бы одна переменная в левой части новая, остальные могут уже существовать — для них будет присваивание, а не объявление:

x := 1
x, y := 2, 3 // x перезаписывается, y — новая переменная

Множественное присваивание — мощная особенность Go:

// Обмен значений без временной переменной
a, b = b, a

// Возврат нескольких значений из функции
result, err := doSomething()

// Игнорирование ненужных значений
result, _ := doSomething()

Создание строки

Обычные строки — только двойные кавычки:

s1 := "Hello, World!"
s2 := "Привет, мир!" // UTF-8 поддерживается из коробки

Сырые строки (raw string literals) — обратные кавычки:

raw := `Это "сырая" строка.
Она может содержать:
- переносы строк
- "двойные" кавычки
- 'одинарные' кавычки
- и даже \n — он НЕ будет интерпретирован`

Сырые строки удобны для регулярных выражений, SQL-запросов, JSON-шаблонов, многострочного текста:

query := `SELECT u.id, u.name, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.active = true
GROUP BY u.id, u.name
ORDER BY order_count DESC`

Работа со строками:

s := "Hello"

// Конкатенация
greeting := s + ", World!"

// Длина в байтах
byteLen := len(s) // 5

// Длина в символах (рунах)
runeLen := utf8.RuneCountInString("Привет") // 6

// Итерация по рунам
for i, r := range "Hello" {
fmt.Printf("index=%d, rune=%c\n", i, r)
}

// Строка — неизменяема, но можно создать новую
runes := []rune(s)
runes[0] = 'h'
newS := string(runes) // "hello"

Особенности строк в Go:

  • Строки неизменяемы (immutable) — нельзя изменить отдельный символ
  • Внутри строка — это байтовая последовательность ([]byte)
  • Индексация s[i] возвращает байт, не руну
  • Для работы с Unicode нужно конвертировать в []rune

Создание массивов и мап (дополнение)

Массив:

var arr1 [5]int // [0, 0, 0, 0, 0]
arr2 := [3]int{1, 2, 3} // [1, 2, 3]
arr3 := [...]int{1, 2, 3, 4} // [1, 2, 3, 4], длина вычислена
arr4 := [5]int{1: 10, 3: 30} // [0, 10, 0, 30, 0] — по индексам

Мапа:

// Через make
m1 := make(map[string]int)

// Через литерал
m2 := map[string]int{
"Alice": 30,
"Bob": 25,
}

// Пустая мапа
m3 := map[string]int{}

Нулевые значения — важная концепция Go. Каждый тип имеет нулевое значение, что исключает неопределённое поведение:

ТипНулевое значение
int, float0
string""
boolfalse
*T (указатель)nil
funcnil
slicenil
mapnil
channelnil
interfacenil

Вопрос 6. Какие числовые типы данных есть в Go?

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

Ответ собеседника: Правильный. В Go есть целые числа (int, int8, int16, int32, int64, uint и т.д.), вещественные числа (float32, float64), комплексные числа (complex64, complex128 — complex128, потому что каждый компонент 64-битный). Также есть байт (byte) — 8-битный тип, и бит (bit) — псевдоним для типа uint8. Байт записывается в одинарных кавычках.

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

Целочисленные типы

Go предоставляет полный набор целочисленных типов с фиксированным размером, что является осознанным дизайнерским решением — размер не зависит от платформы:

Знаковые (signed):

var i8 int8 // 8 бит: от -128 до 127
var i16 int16 // 16 бит: от -32768 до 32767
var i32 int32 // 32 бита: от -2147483648 до 2147483647
var i64 int64 // 64 бита: от -9223372036854775808 до 9223372036854775807

Беззнаковые (unsigned):

var u8 uint8 // 8 бит: от 0 до 255
var u16 uint16 // 16 бит: от 0 до 65535
var u32 uint32 // 32 бита: от 0 до 4294967295
var u64 uint64 // 64 бита: от 0 до 18446744073709551615

Платформозависимые:

var i int // 32 или 64 бита, зависит от платформы
var u uint // 32 или 64 бита, зависит от платформы
var ptr uintptr // достаточно большой для хранения указателя

Псевдонимы:

byte // псевдоним для uint8
rune // псевдоним для int32, представляет Unicode code point

Числовые литералы — гибкий синтаксис:

decimal := 42 // десятичное
octal := 0o52 // восьмеричное (префикс 0o)
hex := 0x2A // шестнадцатеричное (префикс 0x)
binary := 0b101010 // двоичное (префикс 0b)

// Разряды для читаемости
million := 1_000_000

Тип rune и работа с Unicode:

r := 'A' // rune, значение 65
r2 := '世' // rune, значение 19990
r3 := '\u4e16' // тоже '世'

// rune — это int32, поэтому можно делать арифметику
next := 'A' + 1 // 'B'

Важный нюанс: разные именованные типы несовместимы. Нельзя напрямую присвоить int32 в int без явного приведения:

var i int = 42
var i32 int32 = int32(i) // явное приведение обязательно

Числа с плавающей точкой

var f32 float32 // 32 бита (IEEE 754): ~6-7 значащих десятичных цифр
var f64 float64 // 64 бита (IEEE 754): ~15-17 значащих десятичных цифр

По умолчанию тип литерала с плавающей точкой — float64:

x := 3.14 // float64
y := float32(3.14) // явное указание

Особенности работы с float:

// Ошибки точности — как во всех языках с IEEE 754
fmt.Println(0.1 + 0.2) // 0.30000000000000004

// Сравнение float — через epsilon
func equal(a, b float64) bool {
const epsilon = 1e-9
return math.Abs(a-b) < epsilon
}

// Специальные значения
inf := math.Inf(1) // +бесконечность
nan := math.NaN() // Not a Number

Комплексные числа

var c64 complex64 // два float32: реальная и мнимая части
var c128 complex128 // два float64: реальная и мнимая части
c := 1 + 2i // complex128 по умолчанию
c2 := complex(3, 4) // 3 + 4i

realPart := real(c) // 1.0
imagPart := imag(c) // 2.0
magnitude := cmplx.Abs(c) // модуль комплексного числа

Практические рекомендации:

  • По умолчанию используйте int и float64 — это самые распространённые типы
  • Используйте конкретные размеры (int32, uint8) только когда важна совместимость с бинарными протоколами, форматами файлов или экономия памяти
  • byte — для работы с сырыми байтами (файлы, сетевые протоколы)
  • rune — для работы с Unicode-символами
  • Избегайте арифметики между разными числовыми типами — всегда приводите явно
  • Для финансовых расчётов не используйте float — используйте int (копейки) или специализированные библиотеки для десятичной арифметики

Вопрос 7. Что такое рефлексия в Go и как она используется?

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

Ответ собеседника: Правильный. Рефлексия в Go реализована через пакет reflect. Она позволяет проверять типы во время выполнения с помощью TypeOf, читать значения через ValueOf, изменять значения с помощью SetInt и вызывать методы объектов.

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

Основы рефлексии

Рефлексия — это способность программы исследовать свою структуру во время выполнения. В Go рефлексия реализована через пакет reflect и работает через два центральных типа:

  • reflect.Type — описание типа (получается через reflect.TypeOf())
  • reflect.Value — описание значения (получается через reflect.ValueOf())
package main

import (
"fmt"
"reflect"
)

func main() {
x := 42
s := "hello"

// Получение информации о типе
fmt.Println(reflect.TypeOf(x)) // int
fmt.Println(reflect.TypeOf(s)) // string

// Получение значения
v := reflect.ValueOf(x)
fmt.Println(v.Int()) // 42
fmt.Println(v.Type()) // int
fmt.Println(v.Kind()) // int — базовый вид типа
}

Разница между Type и Kind. Type — это конкретный тип (включая именованные типы), Kind — базовый вид:

type MyInt int

var x MyInt = 42
v := reflect.ValueOf(x)

fmt.Println(v.Type()) // main.MyInt — конкретный тип
fmt.Println(v.Kind()) // int — базовый вид

Работа со структурами через рефлексию:

type User struct {
Name string `json:"name" validate:"required"`
Age int `json:"age" validate:"min=0"`
Email string `json:"email" validate:"email"`
}

func inspectStruct(s interface{}) {
t := reflect.TypeOf(s)
v := reflect.ValueOf(s)

for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
value := v.Field(i)

fmt.Printf("Field: %s, Type: %s, Value: %v, Tag: %s\n",
field.Name, field.Type, value.Interface(), field.Tag)
}
}

func main() {
u := User{Name: "Alice", Age: 30, Email: "alice@example.com"}
inspectStruct(u)
}

Изменение значений. Чтобы изменить значение через рефлексию, нужно передать указатель и использовать Elem() для разыменования:

func setInt(ptr interface{}, value int64) {
v := reflect.ValueOf(ptr) // получаем Value указатель
if v.Kind() != reflect.Ptr {
panic("expected pointer")
}
v = v.Elem() // разыменовываем указатель
if v.CanSet() { // проверяем, можно ли менять
v.SetInt(value)
}
}

func main() {
x := 10
setInt(&x, 42)
fmt.Println(x) // 42
}

CanSet() возвращает false, если значение неадресуемо (например, передали не указатель) или поле неэкспортируемое.

Вызов методов через рефлексию:

type Calculator struct{}

func (c Calculator) Add(a, b int) int {
return a + b
}

func main() {
c := Calculator{}
v := reflect.ValueOf(c)

method := v.MethodByName("Add")
if method.IsValid() {
args := []reflect.Value{
reflect.ValueOf(3),
reflect.ValueOf(5),
}
result := method.Call(args)
fmt.Println(result[0].Int()) // 8
}
}

Практические применения рефлексии

Сериализация/десериализация (json, xml, yaml). Пакеты вроде encoding/json используют рефлексию для автоматического маппинга полей структуры в JSON:

type Config struct {
Host string `json:"host"`
Port int `json:"port"`
}

// json.Marshal использует рефлексию для обхода полей
// и чтения тегов json

ORM и маппинг баз данных. Библиотеки вроде GORM используют рефлексию для маппинга структур на таблицы БД:

type Product struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"size:255"`
Price float64
}

// GORM через рефлексию определяет имена столбцов,
// типы, ограничения и т.д.

Валидация. Библиотеки валидации читают теги полей через рефлексию:

type RegisterRequest struct {
Email string `validate:"required,email"`
Password string `validate:"required,min=8"`
Age int `validate:"gte=18,lte=120"`
}

Тестирование. Рефлексия используется для сравнения структур, создания моков и т.д.

Ограничения и рекомендации

Рефлексия в Go имеет существенные недостатки:

  • Потеря типобезопасности. Ошибки обнаруживаются только во время выполнения, а не компиляции
  • Производительность. Рефлексия в 10-100 раз медленнее прямого доступа к полям и вызовов методов
  • Читаемость. Код с рефлексией сложнее читать и отлаживать
  • Хрупкость. Переименование полей не ловится компилятором — сломается только в рантайме

Правило: используйте рефлексию только когда нет альтернативы — в библиотеках общего назначения (сериализация, ORM, фреймворки). В бизнес-логике приложения рефлексия почти всегда излишняя. С появлением дженериков в Go 1.18+ многие задачи, ранее решавшиеся рефлексией, теперь можно решить через параметризованные типы.

Вопрос 8. Как определяются числовые константы в Go и чем они отличаются от переменных?

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

Ответ собеседника: Правильный. Константы определяются с помощью ключевого слова const, в отличие от переменных, которые создаются через var. Константу нельзя перезаписать — при попытке изменения возникает ошибка. Константы используются для хранения неизменяемых значений. Их можно определять целыми группами в блоке const, при этом тип можно явно указывать, а можно не указывать — Go определит его автоматически.

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

Объявление констант

Константы объявляются с помощью ключевого слова const и не могут быть изменены после определения:

const Pi = 3.14159
const MaxRetries = 3
const Greeting = "Hello"

// Попытка изменит вызовет ошибку компиляции
// Pi = 3.14 // cannot assign to Pi

Групповое объявление:

const (
StatusPending = 0
StatusActive = 1
StatusClosed = 2
)

Ключевые отличия констант от переменных

Неизменяемость. Константу нельзя переприсвоить — это ошибка компиляции, а не рантайма.

Типизация. Константы в Go могут быть нетипизированными (untyped). Это уникальная особенность — константа получает тип только в момент использования:

const x = 42

// x может быть использована как любой числовой тип
var i int = x // x становится int
var f float64 = x // x становится float64
var c complex128 = x // x становится complex128

Для переменных такое невозможно — каждая переменная имеет конкретный тип на этапе компиляции.

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

const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
)

// Можно использовать для размера массива (требуется константа)
var buffer [KB]byte

Область видимости. Константы, как и переменные, подчиняются правилам видимости на основе регистра первой буквы.

Ограничения типов. Константами могут быть только строки, булевы значения и числа. Срезы, мапы, структуры и функции быть константами не могут:

const valid1 = "hello" // OK — строка
const valid2 = true // OK — булево
const valid3 = 42 // OK — целое
const valid4 = 3.14 // OK — вещественное
const valid5 = 1 + 2i // OK — комплексное

// const invalid = []int{1, 2, 3} // ОШИБКА: срез не может быть константой
// const invalid2 = make(map[string]int) // ОШИБКА

Оператор iota — автоматический счётчик

iota — предопределённый идентификатор, который автоматически инкрементируется в блоке const:

const (
Sunday = iota // 0
Monday // 1
Tuesday // 2
Wednesday // 3
Thursday // 4
Friday // 5
Saturday // 6
)

iota начинается с 0 в каждом блоке const и увеличивается на 1 для каждой следующей строки, даже если не используется явно:

const (
_ = iota // 0 (пропущено)
KB = 1 << (10 * iota) // 1 << 10 = 1024
MB // 1 << 20 = 1048576
GB // 1 << 30 = 1073741824
TB // 1 << 40
)

Можно использовать выражения с iota:

const (
Read = 1 << iota // 1 (001)
Write // 2 (010)
Execute // 4 (100)
)

// Комбинация прав
const permissions = Read | Write // 3 (011)

Практические рекомендации:

  • Используйте константы для всех магических чисел и строк в коде
  • Группируйте связанные константы в блоки const
  • Используйте iota для последовательных значений (перечисления, флаги)
  • Нетипизированные константы дают гибкость — используйте это для универсальных значений
  • Для создания перечислений с типобезопасностью используйте именованные типы:
type StatusCode int

const (
StatusOK StatusCode = 200
StatusNotFound StatusCode = 404
StatusError StatusCode = 500
)

Вопрос 9. Как создаются каналы в Go и чем отличается буферизированный канал от небуферизированного?

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

Ответ собеседника: Правильный. Каналы используются для связи между горутинами. Для создания канала используется make(chan int). Если ёмкость не указана — канал небуферизированный; если указана (например, make(chan int, 5)) — буферизированный. Небуферизированный канал блокирует горутину до тех пор, пока другая горутина не прочитает из него. Буферизированный канал имеет ограниченную ёмкость: при попытке записать больше элементов, чем вмещает буфер, возникает блокировка. При выводе каналов отображаются ссылки на области памяти. Канал следует закрывать со стороны отправителя с помощью close(), а не со стороны получателя, иначе при записи в закрытый канал возникнет паника.

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

Создание каналов

Каналы создаются с помощью make() и могут быть типизированными — передавать можно только значения указанного типа:

// Небуферизированный канал
ch := make(chan int)

// Буферизированный канал с ёмкостью 5
bufCh := make(chan int, 5)

// Канал только для отправки (send-only)
var sendCh chan<- int = ch

// Канал только для получения (receive-only)
var recvCh <-chan int = ch

Направленность каналов (chan<- и <-chan) — мощный инструмент типобезопасности. Функция может принимать канал только для записи или только для чтения, что предотвращает ошибки:

// Функция может только отправлять в канал
func produce(ch chan<- int) {
for i := 0; i < 10; i++ {
ch <- i
}
close(ch)
}

// Функция может только получать из канала
func consume(ch <-chan int) {
for val := range ch {
fmt.Println(val)
}
}

Небуферизированные каналы (синхронные)

Небуферизированный канал имеет ёмкость 0. Запись блокирует отправителя до тех пор, пока получатель не прочитает значение, и наоборот. Это гарантирует синхронизацию — операция отправки и получения происходят одновременно (handshake):

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

go func() {
fmt.Println("Sending...")
ch <- 42 // блокируется, пока main не прочитает
fmt.Println("Sent!")
}()

time.Sleep(time.Second) // имитация работы
val := <-ch // разблокирует горутину
fmt.Println("Received:", val)
}

Буферизированные каналы (асинхронные)

Буферизированный канал имеет внутренний буфер заданной ёмкости. Запись блокируется только когда буфер полон, чтение — когда буфер пуст:

func main() {
ch := make(chan int, 3) // буфер на 3 элемента

ch <- 1 // не блокируется
ch <- 2 // не блокируется
ch <- 3 // не блокируется
// ch <- 4 // ЗАБЛОКИРУЕТСЯ — буфер полон

fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
fmt.Println(<-ch) // 3
}

Ключевые отличия:

ХарактеристикаНебуферизированныйБуферизированный
Созданиеmake(chan T)make(chan T, n)
ЗаписьБлокируется до полученияБлокируется при полном буфере
ЧтениеБлокируется до отправкиБлокируется при пустом буфере
СинхронизацияСтрогая (handshake)Ослабленная
СкоростьМедленнееБыстрее (меньше блокировок)
Гаратия доставкиДа (получатель точно получил)Нет (значение в буфере)

Закрытие каналов

Канал закрывается функцией close(). Правило: всегда закрывает отправитель, никогда — получатель:

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

go func() {
defer close(ch) // отправитель закрывает канал
for i := 0; i < 5; i++ {
ch <- i
}
}()

// range автоматически завершается при закрытии канала
for val := range ch {
fmt.Println(val)
}
}

Запись в закрытый канал вызывает панику. Чтение из закрытого канала возвращает нулевое значение. Для проверки, было ли значение реально отправлено или это нулевое значение из закрытого канала, используется двухформное присваивание:

val, ok := <-ch
if !ok {
fmt.Println("Channel closed")
}

Паттерны использования каналов

Fan-out (один источник, много потребителей):

func fanOut(input <-chan int, n int) []<-chan int {
channels := make([]<-chan int, n)
for i := 0; i < n; i++ {
ch := make(chan int)
channels[i] = ch
go func() {
defer close(ch)
for val := range input {
ch <- val * val
}
}()
}
return channels
}

Fan-in (много источников, один потребитель):

func fanIn(channels ...<-chan int) <-chan int {
merged := make(chan int)
var wg sync.WaitGroup

output := func(c <-chan int) {
defer wg.Done()
for val := range c {
merged <- val
}
}

wg.Add(len(channels))
for _, c := range channels {
go output(c)
}

go func() {
wg.Wait()
close(merged)
}()

return merged
}

Select — мультиплексирование каналов:

func main() {
ch1 := make(chan string)
ch2 := make(chan string)

go func() { ch1 <- "from ch1" }()
go func() { ch2 <- "from ch2" }()

for i := 0; i < 2; i++ {
select {
case msg1 := <-ch1:
fmt.Println(msg1)
case msg2 := <-ch2:
fmt.Println(msg2)
case <-time.After(time.Second):
fmt.Println("timeout")
}
}
}

Таймеры и тикеры:

// Одноразовый таймер
timer := time.After(2 * time.Second)
<-timer // блокировка на 2 секунды

// Периодический тикер
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for range ticker.C {
fmt.Println("tick")
}

Когда использовать буферизированные каналы:

  • Когда отправитель и получатель работают с разной скоростью и нужна «подушка»
  • Для паттерна worker pool с ограничением количества задач в очереди
  • Когда нужно избежать блокировки при burst-нагрузке

Когда использовать небуферизированные:

  • Когда нужна гарантия, что получатель обработал значение
  • Для сигнализации (notification) — chan struct{}
  • Для синхронизации горутин (барьеры, handshake)

Вопрос 10. Как работают строки в Go и как эффективно склеивать строки?

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

Ответ собеседника: Правильный. Строки создаются через var с указанием типа или через сокращённую запись — Go сам определяет тип. Из строки можно сделать срез рун, где каждая руна представлена своим Unicode-кодом. Две записи — создание среза рун из строки и сборка строки из среза рун — эквивалентны. Функция len() измеряет длину строки в байтах, а не в символах (например, слово «привет» занимает 12 байт, так как каждый символ весит 2 байта). Для эффективного склеивания строк можно использовать оператор +=, структуру strings.Builder или модифицированный Builder. При небольшом количестве символов (10 000) оператор += быстрее, но при большом объёме модифицированный Builder работает эффективнее.

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

Внутреннее устройство строк

Строка в Go — это неизменяемая (immutable) последовательность байт. Внутри строка представлена структурой:

// Упрощённое представление (из runtime)
type stringStruct struct {
str unsafe.Pointer // указатель на массив байт
len int // длина в байтах
}

Строки в Go хранятся в UTF-8 по умолчанию. Это значит, что латинские символы занимают 1 байт, кириллица — 2 байта, иероглифы и эмодзи — 3-4 байта.

Длина строки: байты vs символы

s := "Привет"

fmt.Println(len(s)) // 12 — байт
fmt.Println(utf8.RuneCountInString(s)) // 6 — рун (символов)
fmt.Println(len([]rune(s))) // 6 — рун через конвертацию

Индексация строки возвращает байт, а не символ:

s := "Hello"
fmt.Println(s[0]) // 72 — это байт 'H', а не символ
fmt.Printf("%c\n", s[0]) // H — для отображения как символа

s2 := "Привет"
fmt.Println(s2[0]) // 208 — первый байт буквы 'П', а не сама буква

Для работы с отдельными символами нужно конвертировать в []rune:

s := "Привет"
runes := []rune(s)
fmt.Println(runes[0]) // 1055 — Unicode code point буквы 'П'
fmt.Printf("%c\n", runes[0]) // П

Итерация по строке:

s := "Hello, 世界"

// range по строке — итерация по рунам
for i, r := range s {
fmt.Printf("index=%d, rune=%c, size=%d\n", i, r, utf8.RuneLen(r))
}
// index=0, rune=H, size=1
// index=1, rune=e, size=1
// ...
// index=7, rune=世, size=3
// index=10, rune=界, size=3

Неизменяемость строк. Строки нельзя модифицировать на месте — любая «модификация» создаёт новую строку:

s := "Hello"
// s[0] = 'h' // ОШИБКА компиляции: cannot assign to s[0]

// Нужно создать новую строку
runes := []rune(s)
runes[0] = 'h'
s = string(runes) // "hello"

Способы конкатенации строк

Оператор + и +=. Самый простой способ, но наименее эффективный при большом количестве конкатенаций, потому что каждая операция создаёт новую строку и копирует все байты:

s := "Hello"
s += ", " // новая аллокация + копирование
s += "World" // ещё одна аллокация + копирование

Сложность: O(n²) при n конкатенациях, потому что каждый раз копируется всё накопленное.

strings.Builder. Рекомендуемый способ для сборки строк. Использует внутренний буфер, который растёт по мере необходимости:

var builder strings.Builder

builder.WriteString("Hello")
builder.WriteString(", ")
builder.WriteString("World")
builder.WriteRune('!')
builder.WriteByte(' ') // запись одного байта

result := builder.String() // "Hello, World! "
fmt.Println(builder.Len()) // длина в байтах
fmt.Println(builder.Cap()) // ёмкость буфера

Можно заранее зарезервировать память, если известен примерный размер:

var builder strings.Builder
builder.Grow(1000) // зарезервировать 1000 байт

fmt.Sprintf. Удобен для форматирования, но медленнее strings.Builder:

name := "Alice"
age := 30
s := fmt.Sprintf("Name: %s, Age: %d", name, age)

strings.Join. Эффективен для склеивания среза строк с разделителем:

parts := []string{"Hello", "World", "Go"}
s := strings.Join(parts, ", ") // "Hello, World, Go"

bytes.Buffer. Альтернатива strings.Builder, реализует интерфейсы io.Reader и io.Writer:

var buf bytes.Buffer
buf.WriteString("Hello")
buf.WriteString(" World")
s := buf.String()

Сравнение производительности:

func BenchmarkConcat(b *testing.B) {
for i := 0; i < b.N; i++ {
var s string
for j := 0; j < 1000; j++ {
s += "a"
}
}
}

func BenchmarkBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
var builder strings.Builder
builder.Grow(1000)
for j := 0; j < 1000; j++ {
builder.WriteString("a")
}
_ = builder.String()
}
}

Результат: strings.Builder с Grow() быстрее оператора + в десятки раз при большом количестве конкатенаций.

Полезные функции пакета strings:

// Поиск
strings.Contains("hello world", "world") // true
strings.HasPrefix("hello world", "hello") // true
strings.HasSuffix("hello world", "world") // true
strings.Index("hello world", "world") // 6

// Разделение и объединение
strings.Split("a,b,c", ",") // ["a", "b", "c"]
strings.Fields(" hello world ") // ["hello", "world"]

// Замена
strings.Replace("hello world", "world", "Go", 1) // "hello Go"
strings.ReplaceAll("aaa", "a", "b") // "bbb"

// Модификация
strings.ToLower("HELLO") // "hello"
strings.ToUpper("hello") // "HELLO"
strings.TrimSpace(" hello ") // "hello"

// Повтор
strings.Repeat("ab", 3) // "ababab"

Практические рекомендации:

  • Для 1-2 конкатенаций — используйте +, это читаемо и достаточно быстро
  • Для циклов и большого количества конкатенаций — strings.Builder с Grow()
  • Для склеивания среза строк — strings.Join
  • Для форматирования — fmt.Sprintf
  • Помните, что len(s) возвращает байты, не символы
  • Для работы с Unicode-символами конвертируйте в []rune
  • Строки неизменяемы — любая модификация создаёт новую строку

Вопрос 11. Какие числовые типы данных есть в Go и какие у них разрядности?

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

Ответ собеседника: Правильный. В Go есть целые числа (int и uint различных разрядностей: 8, 16, 32, 64 бита), вещественные числа (float32, float64), комплексные числа (complex64, complex128), псевдонимы byte (псевдоним для uint8) и rune. Буква U в названии означает unsigned (беззнаковое число). Тип int нельзя делить на ноль — возникает паника. Для конвертации текста в число используется функция Atoi из пакета strconv, а для обратного преобразования — соответствующая функция из той же библиотеки. При переполнении типа возникает паника, для работы с большими числами нужно использовать тип большей разрядности.

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

Этот вопрос уже был подробно рассмотрен ранее (вопрос 6). Краткое резюме:

Целочисленные типы:

ТипРазмерДиапазон
int88 бит−128 до 127
int1616 бит−32768 до 32767
int3232 бита−2³¹ до 2³¹−1
int6464 бита−2⁶³ до 2⁶³−1
uint88 бит0 до 255
uint1616 бит0 до 65535
uint3232 бита0 до 2³²−1
uint6464 бита0 до 2⁶⁴−1
int32/64 битазависит от платформы
uint32/64 битазависит от платформы

Псевдонимы: byte = uint8, rune = int32

Вещественные: float32 (6-7 значащих цифр), float64 (15-17 значащих цифр)

Комплексные: complex64 (два float32), complex128 (два float64)

Дополнение по конвертации и переполнению:

// Конвертация строки в число
import "strconv"

i, err := strconv.Atoi("42") // string -> int
i64, err := strconv.ParseInt("42", 10, 64) // string -> int64
f, err := strconv.ParseFloat("3.14", 64) // string -> float64

// Конвертация числа в строку
s := strconv.Itoa(42) // int -> string
s2 := strconv.FormatInt(42, 10) // int64 -> string
s3 := fmt.Sprintf("%d", 42) // универсальный способ

// Переполнение — обёртка (wrapping), не паника для беззнаковых
var u uint8 = 255
u++ // 0 — обернулось

// Для знаковых типов переполнение — неопределённое поведение в рантайме
// (в отличие от деления на ноль, которое вызывает панику)
var i int8 = 127
i++ // -128 — обернулось (wrap around)

// Деление на ноль — паника в рантайме
// x := 1 / 0 // runtime error: integer divide by zero

// Для безопасной работы с большими числами — пакет math/big
import "math/big"

bigInt := big.NewInt(1)
bigInt.Lsh(bigInt, 1000) // 2^1000
fmt.Println(bigInt.String())

Рекомендации:

  • По умолчанию используйте int и float64
  • Используйте конкретные размеры только для бинарных протоколов и экономии памяти
  • Явно приводите типы при смешивании в выражениях
  • Для финансовых расчётов не используйте float — используйте int (копейки) или math/big

Вопрос 12. Какая проблема возникает при конкурентном доступе горутин к общей переменной и как её решить?

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

Ответ собеседника: Правильный. Проблема заключается в состоянии гонки (race condition): несколько горутин одновременно увеличивают общую переменную counter на единицу, и пока одна горутина не завершила работу с переменной, другая уже её изменяет, что приводит к непредсказуемым результатам. Решение — использование механизмов синхронизации, таких как мьютексы (mutex), чтобы обеспечить эксклюзивный доступ к переменной в каждый момент времени.

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

Состояние гонки (Race Condition)

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

// НЕПРАВИЛЬНО — гонка данных
func main() {
var counter int
var wg sync.WaitGroup

for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // гонка! не атомарная операция
}()
}

wg.Wait()
fmt.Println(counter) // результат < 1000, каждый раз разный
}

Операция counter++ на самом деле состоит из трёх шагов: чтение, инкремент, запись. Если две горутины выполняют эти шаги одновременно, один инкремент теряется.

Обнаружение гонок

Go имеет встроенный детектор гонок:

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

Детектор добавляет накладные расходы, но незаменим при разработке и тестировании.

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

1. Мьютекс (sync.Mutex). Обеспечивает эксклюзивный доступ к критической секции:

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
}

Лучше использовать defer mu.Unlock() для безопасности:

mu.Lock()
defer mu.Unlock()
counter++

2. RWMutex — блокировка с разделением на чтение и запись. Позволяет множеству горутин читать одновременно, но запись эксклюзивна:

type SafeMap struct {
mu sync.RWMutex
data map[string]int
}

func (m *SafeMap) Get(key string) (int, bool) {
m.mu.RLock() // множество читателей одновременно
defer m.mu.RUnlock()
val, ok := m.data[key]
return val, ok
}

func (m *SafeMap) Set(key string, value int) {
m.mu.Lock() // эксклюзивный доступ для записи
defer m.mu.Unlock()
m.data[key] = value
}

3. Атомарные операции (sync/atomic). Для простых типов (int32, int64, uint32, uint64, uintptr) — самый быстрый способ:

func main() {
var counter int64 // atomic работает с конкретными размерами
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(atomic.LoadInt64(&counter)) // всегда 1000
}

4. Каналы — идиоматический подход Go. «Не общайтесь через общую память, а разделяйте память через коммуникацию»:

func main() {
ch := make(chan int)
var wg sync.WaitGroup

// Один «владелец» данных — единственная горутина, которая читает и пишет counter
counter := 0
go func() {
for val := range ch {
counter += val
}
fmt.Println(counter) // всегда 1000
}()

for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
ch <- 1
}()
}

wg.Wait()
close(ch)
time.Sleep(time.Millisecond) // дать время на вывод
}

5. sync.Map — конкуренто-безопасная мапа из стандартной библиотеки:

var m sync.Map

// Запись
m.Store("key", 42)

// Чтение
if val, ok := m.Load("key"); ok {
fmt.Println(val.(int))
}

// Атомарная запись если не существует
actual, loaded := m.LoadOrStore("key", 100)

// Удаление
m.Delete("key")

6. Once — гарантия однократного выполнения:

var once sync.Once
var instance *Database

func GetInstance() *Database {
once.Do(func() {
instance = &Database{} // выполнится ровно один раз
})
return instance
}

7. WaitGroup — ожидание завершения группы горутин:

func main() {
var wg sync.WaitGroup

for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("Worker %d done\n", id)
}(i)
}

wg.Wait() // блокируется, пока все не вызовут Done()
fmt.Println("All workers finished")
}

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

ПодходКогда использоватьПроизводительность
sync.MutexОбщий случай, сложные структурыСредняя
sync.RWMutexМного чтений, мало записейВысокая для чтения
sync/atomicПростые счётчики, флагиМаксимальная
КаналыПередача данных, координацияСредняя
sync.MapКэши, реестры, редкие записиОптимизирована для чтения

Практические рекомендации:

  • Предпочитайте каналы для передачи данных между горутинами
  • Используйте sync/atomic для простых счётчиков — это самый быстрый способ
  • sync.Mutex — универсальное решение для защиты сложных структур
  • Всегда запускайте тесты с флагом -race
  • Минимизируйте область действия блокировки
  • Не копируйте sync.Mutex — передавайте структуры с мьютексом по указателю

Вопрос 13. Как решить проблему гонки данных при конкурентном доступе горутин к общей переменной?

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

Ответ собеседника: Правильный. Для решения проблемы гонки данных используется мьютекс (mutex). Перед тем как горутина начинает работать с общей переменной, она блокирует мьютекс, а после завершения работы — разблокирует. Это гарантирует, что в каждый момент времени только одна горутина имеет доступ к переменной. В Go это реализуется с помощью методов Lock() и Unlock() из пакета sync.

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

Этот вопрос уже был подробно рассмотрен в предыдущем (вопрос 12). Краткое резюме с дополнительными деталями:

Мьютекс — основной механизм защиты:

var mu sync.Mutex
var counter int

mu.Lock() // блокировка
counter++ // критическая секция
mu.Unlock() // разблокировка

Лучшая практика — defer для разблокировки:

func increment() {
mu.Lock()
defer mu.Unlock() // гарантированно разблокирует даже при панике
counter++
}

RWMutex для сценариев с преобладанием чтения:

var rwmu sync.RWMutex

// Множество читателей одновременно
rwmu.RLock()
value := sharedData
rwmu.RUnlock()

// Только один писатель
rwmu.Lock()
sharedData = newValue
rwmu.Unlock()

Атомарные операции для простых типов:

var counter int64
atomic.AddInt64(&counter, 1)
result := atomic.LoadInt64(&counter)

Каналы — идиоматический Go-подход:

// Передача владения данными через каналы вместо общего доступа
ch := make(chan int)
go func() {
for i := 0; i < 1000; i++ {
ch <- 1
}
close(ch)
}()

counter := 0
for val := range ch {
counter += val
}

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

Вопрос 14. Как выполнить несколько условий в одном операторе switch в Go и как работает ключевое слово fallthrough?

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

Ответ собеседника: Правильный. В операторе switch можно перечислить несколько значений в одном case через запятую (например, case 1, 2, 3). Ветка default выполнится, если ни одно из условий выше не подошло. Ключевое слово fallthrough заставляет выполнить следующий case без проверки его условия — выполнение «проваливается» в следующую ветку независимо от того, совпадает ли значение с условием этого case.

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

Switch в Go — особенности

Switch в Go значительно мощнее, чем во многих других языках, и имеет ряд уникальных особенностей.

Несколько значений в одном case через запятую:

func dayType(day string) string {
switch day {
case "Saturday", "Sunday":
return "Weekend"
case "Monday", "Tuesday", "Wednesday", "Thursday", "Friday":
return "Weekday"
default:
return "Unknown"
}
}

Switch без условия — замена длинным if-else:

func classify(x int) string {
switch {
case x < 0:
return "negative"
case x == 0:
return "zero"
case x > 0 && x <= 100:
return "small positive"
default:
return "large positive"
}
}

Switch по типу (type switch) — уникальная возможность:

func describeValue(v interface{}) string {
switch val := v.(type) {
case int:
return fmt.Sprintf("Integer: %d", val)
case string:
return fmt.Sprintf("String: %s", val)
case bool:
return fmt.Sprintf("Boolean: %t", val)
case nil:
return "nil"
default:
return fmt.Sprintf("Unknown type: %T", val)
}
}

fallthrough — проваливание в следующий case

В отличие от C/Java, где выполнение автоматически «проваливается» в следующий case, в Go после выполнения case происходит автоматический выход из switch. Ключевое слово fallthrough явно указывает «провалиться» в следующий case:

func describeGrade(grade int) string {
switch {
case grade >= 90:
return "A - Excellent"
case grade >= 80:
fallthrough // проваливается в следующий case
case grade >= 70:
return "B - Good (or C if fell through)"
case grade >= 60:
return "C - Satisfactory"
default:
return "F - Fail"
}
}

Важные ограничения fallthrough:

  • Нельзя использовать в последнем case
  • Нельзя использовать в case с типом (type switch)
  • Проваливание безусловное — условие следующего case не проверяется
  • Не рекомендуется злоупотреблять — ухудшает читаемость

Практический пример с fallthrough — упрощённая обработка команд:

func processCommand(cmd string) {
switch cmd {
case "quit", "exit":
fmt.Println("Saving state...")
fallthrough
case "restart":
fmt.Println("Shutting down...")
fallthrough
case "start":
fmt.Println("Starting service...")
}
}

// processCommand("quit") выводит:
// Saving state...
// Shutting down...
// Starting service...

Switch с инициализацией:

switch os := runtime.GOOS; os {
case "darwin":
fmt.Println("macOS")
case "linux":
fmt.Println("Linux")
default:
fmt.Printf("%s\n", os)
}

Идиоматические паттерны с switch:

Защитные проверки (guard clauses):

func divide(a, b float64) (float64, error) {
switch {
case b == 0:
return 0, errors.New("division by zero")
case math.IsNaN(a) || math.IsNaN(b):
return 0, errors.New("NaN operand")
default:
return a / b, nil
}
}

Разбор ошибок:

switch err := doSomething(); {
case errors.Is(err, ErrNotFound):
log.Warn("not found, using default")
return defaultValue
case errors.Is(err, ErrUnauthorized):
return fmt.Errorf("auth failed: %w", err)
case err != nil:
return fmt.Errorf("unexpected error: %w", err)
default:
return result, nil
}

Ключевые отличия switch в Go от других языков:

  • Нет автоматического fallthrough (нужно явно указывать)
  • Условия в case — не только константы, но и выражения
  • Можно использовать без условия (switch как замена if-else цепочкам)
  • Поддержка type switch для определения типа интерфейса
  • Переменные в case имеют область видимости только внутри этого case

Вопрос 15. Как работают указатели и указатели на указатели в Go?

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

Ответ собеседника: Правильный. Обычный указатель создаётся с помощью одной звёздочки (*int), указатель на указатель — с помощью двух (**int). Операция разыменования позволяет получить значение по адресу указателя. Для указателя на указатель нужно выполнить двойное разыменование: первое — чтобы получить указатель, второе — чтобы получить значение. Например, если переменная A = 100, указатель B ссылается на A, а указатель на указатель C ссылается на B, то двойное разыменование C даст значение 100.

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

Основы указателей

Указатель — это переменная, хранящая адрес в памяти другой переменной. В Go указатели просты и безопасны — нет арифметики указателей (в отличие от C/C++).

Объявление и использование:

var x int = 42
var p *int = &x // p хранит адрес x

fmt.Println(&x) // 0xc0000b2008 — адрес в памяти
fmt.Println(p) // 0xc0000b2008 — тот же адрес
fmt.Println(*p) // 42 — разыменование, получение значения по адресу

*p = 100 // изменение значения через указатель
fmt.Println(x) // 100

Нулевой указатель:

var p *int
fmt.Println(p) // nil
// fmt.Println(*p) // паника: nil pointer dereference

Операции с указателями в Go:

  • & — взятие адреса (address-of)
  • * — разыменование (dereference)
  • new(T) — выделение памяти для типа T, возвращает *T
p := new(int) // *int, указатель на нулевое значение int
*p = 42
fmt.Println(*p) // 42

Указатели на указатели

Указатель на указатель — это переменная, хранящая адрес другой переменной-указателя. Используется редко, но имеет свои применения:

func main() {
x := 100
p := &x // *int — указатель на x
pp := &p // **int — указатель на указатель p

fmt.Println(x) // 100
fmt.Println(*p) // 100 — разыменование p даёт x
fmt.Println(**pp) // 100 — двойное разыменование: *pp = p, **pp = *p = x

**pp = 200 // изменение x через двойной указатель
fmt.Println(x) // 200
}

Зачем нужны указатели на указатели:

  1. Изменение самого указателя внутри функции:
// Без указателя на указатель — не изменить указатель вызывающей стороны
func allocateWrong(p *int) {
p = new(int) // меняем локальную копию указателя
*p = 42
}

// С указателем на указатель — изменить указатель вызывающей стороны
func allocateRight(pp **int) {
*pp = new(int) // меняем оригинальный указатель
**pp = 42
}

func main() {
var p *int
allocateWrong(p)
fmt.Println(p) // nil — не изменился

allocateRight(&p)
fmt.Println(p, *p) // 0xc0000b2010 42 — изменился
}

В практике Go для этого чаще используют возвращаемый указатель:

func allocate() *int {
p := new(int)
*p = 42
return p
}

p := allocate() // проще и идиоматичнее
  1. Динамические структуры данных — связные списки, деревья:
type Node struct {
Value int
Next *Node
}

// Вставка в начало списка через указатель на указатель головы
func insert(head **Node, value int) {
newNode := &Node{
Value: value,
Next: *head,
}
*head = newNode
}

Указатели и функции

Указатели позволяют изменять оригинальные данные внутри функции, а не работать с копией:

// Передача по значению — изменения не влияют на оригинал
func incrementByValue(x int) {
x++
}

// Передача по указателю — изменения влияют на оригинал
func incrementByPointer(x *int) {
*x++
}

func main() {
x := 10
incrementByValue(x)
fmt.Println(x) // 10 — не изменился

incrementByPointer(&x)
fmt.Println(x) // 11 — изменился
}

Указатели на структуры:

type User struct {
Name string
Age int
}

u := &User{Name: "Alice", Age: 30}

// Не нужно писать (*u).Name — Go автоматически разыменовывает
fmt.Println(u.Name) // "Alice" — автоматическое разыменование
u.Age = 31 // тоже автоматическое разыменование

Особенности указателей в Go:

  • Нет арифметики указателей (p + 1 нельзя)
  • Нельзя приводить указатель к числу и обратно (без unsafe)
  • Сборщик мусора корректно работает с указателями — не нужно вручную освобождать память
  • Nil-указатель при разыменовании вызывает панику
  • Указатели на элементы срезов и мап могут стать невалидными при реаллокации

Когда использовать указатели:

  • Когда нужно изменить аргумент внутри функции
  • Для больших структур — чтобы избежать копирования
  • Для опциональных полей (nil = отсутствие значения)
  • Для реализации структур данных (списки, деревья, графы)
  • Для реализации интерфейсов с методами, изменяющими состояние

Вопрос 16. Как ведут себя срезы на граничных значениях в Go?

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

Ответ собеседника: Правильный. При взятии среза (например, s[0:2]) всё работает корректно, если индексы не выходят за пределы. Если попытаться взять срез с индексом, превышающим количество элементов (например, s[0:4] при трёх элементах), возникнет паника. У срезов есть длина (len) — текущее количество элементов, и ёмкость (cap) — размер нижележащего массива. Эти понятия не следует путать.

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

Внутреннее устройство среза

Срез — это структура из трёх полей:

type slice struct {
array unsafe.Pointer // указатель на базовый массив
len int // текущая длина
cap int // ёмкость (максимум без реаллокации)
}

Создание срезов и граничные случаи:

arr := [5]int{1, 2, 3, 4, 5}
s := arr[1:4] // [2, 3, 4], len=3, cap=4 (от arr[1] до конца arr)

fmt.Println(len(s)) // 3
fmt.Println(cap(s)) // 4

Операция sub-slicing (взятие среза от среза):

s := []int{1, 2, 3, 4, 5}

fmt.Println(s[1:3]) // [2, 3] — индексы 1 и 2
fmt.Println(s[:3]) // [1, 2, 3] — от начала до индекса 3 (не включая)
fmt.Println(s[2:]) // [3, 4, 5] — от индекса 2 до конца
fmt.Println(s[:]) // [1, 2, 3, 4, 5] — полная копия среза

Граничные значения и паники:

s := []int{1, 2, 3}

// Корректные операции
fmt.Println(s[0:3]) // [1, 2, 3] — длина среза = 3
fmt.Println(s[3:3]) // [] — пустой срез, но корректно

// Паника: индекс выходит за пределы длины
// s[0:4] — runtime error: runtime error: slice bounds out of range [:4] with capacity 3
// s[4:4] — runtime error: index out of range

Правила для s[low:high]:

  • low должен быть ≥ 0
  • high должен быть ≤ cap(s) (не len!)
  • low должен быть ≤ high

Разница между len и cap при sub-slicing:

s := make([]int, 3, 10) // len=3, cap=10
s[0], s[1], s[2] = 1, 2, 3

sub := s[1:2] // [2], len=1
fmt.Println(cap(sub)) // 9 — ёмкость от s[1] до конца базового массива

Расширение среза за пределы len, но в пределах cap:

s := make([]int, 3, 10)
s[0], s[1], s[2] = 1, 2, 3

// Можно расширить до cap без реаллокации
extended := s[:6]
fmt.Println(len(extended)) // 6
fmt.Println(cap(extended)) // 10
fmt.Println(extended) // [1 2 3 0 0 0] — новые элементы = нулевые значения

Разделяемая память — подводный камень:

original := []int{1, 2, 3, 4, 5}
sub := original[0:3] // [1, 2, 3] — делит базовый массив с original

sub[0] = 999
fmt.Println(original) // [999, 2, 3, 4, 5] — original тоже изменился!

// append может перезаписать соседние элементы
sub = append(sub, 888) // перезаписывает original[3]
fmt.Println(original) // [999, 2, 3, 888, 5]

Как избежать разделяемой памяти:

// Способ 1: Явное копирование
sub := make([]int, 3)
copy(sub, original[0:3])

// Способ 2: Ограничение ёмкости (force copy при append)
sub := original[0:3:3] // len=3, cap=3 — append вызовет реаллокацию
sub = append(sub, 888) // новый массив, original не затронут

Пустой срез vs nil-срез:

var s1 []int // nil-срез: nil, len=0, cap=0
s2 := []int{} // пустой срез: не nil, len=0, cap=0
s3 := make([]int, 0) // пустой срез: не nil, len=0, cap=0

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

// Все три ведут себя одинаково при append, range, len, cap

Практические рекомендации:

  • Помните, что sub-slicing делит базовый массив — изменения видны всем
  • Используйте copy() или третий параметр s[low:high:max] для независимых копий
  • append к срезу с достаточной ёмкостью может затронуть другие срезы
  • Проверяйте длину перед доступом по индексу, если данные извне
  • nil-срез и пустой срез ведут себя одинаково в большинстве операций, но различимы через == nil

Вопрос 17. Как добавить элементы в срез в Go и как работает внутренняя реализация append?

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

Ответ собеседника: Правильный. Элементы можно добавить двумя способами: 1) с помощью встроенной функции append — она добавляет элемент(ы) в конец среза; 2) через прямое присвоение по индексу (s[i] = value). При использовании индексов есть риск выйти за пределы ёмкости среза, что вызовет ошибку. Внутренняя реализация append: определяется длина и ёмкость среза; если ёмкость достаточна, содержимое копируется в новый срез; затем срез обрезается по ёмкости и копируется ещё раз; в итоге возвращается срез с добавленным элементом. Если элемент не вмещается в текущую ёмкость, срез динамически расширяется.

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

Добавление элементов в срез

Прямое присвоение по индексу — изменяет существующий элемент:

s := make([]int, 3, 5)
s[0] = 10
s[1] = 20
s[2] = 30
// s[3] = 40 // паника: index out of range [3] with length 3

append — добавляет элементы в конец, возвращая новый срез:

s := []int{1, 2, 3}

// Добавление одного элемента
s = append(s, 4) // [1, 2, 3, 4]

// Добавление нескольких элементов
s = append(s, 5, 6, 7) // [1, 2, 3, 4, 5, 6, 7]

// Добавление другого среза (распаковка)
other := []int{8, 9}
s = append(s, other...) // [1, 2, 3, 4, 5, 6, 7, 8, 9]

Важно: append возвращает новый срез — всегда присваивайте результат:

// ПРАВИЛЬНО
s = append(s, 42)

// НЕПРАВИЛЬНО — изменения потеряются
append(s, 42) // результат игнорируется

Внутренняя реализация append

Логика работы append:

  1. Проверяется, достаточно ли ёмкости для добавления элементов
  2. Если достаточно — элементы записываются в существующий базовый массив, длина увеличивается
  3. Если недостаточно — выделяется новый базовый массив с увеличенной ёмкостью, данные копируются, добавляются новые элементы
// Упрощённая логика append
func append(slice []Type, elems ...Type) []Type {
newLen := len(slice) + len(elems)

if newLen <= cap(slice) {
// Ёмкости достаточно — расширяем существующий срез
return slice[:newLen]
}

// Нужна реаллокация
newCap := growCap(cap(slice), newLen)
newSlice := make([]Type, newLen, newCap)
copy(newSlice, slice)
copy(newSlice[len(slice):], elems)
return newSlice
}

Стратегия роста ёмкости:

func growCap(oldCap, newLen int) int {
newCap := oldCap

// Для маленьких срезов (< 1024) — удвоение
if oldCap < 1024 {
newCap = oldCap * 2
} else {
// Для больших — примерно 25% роста
newCap = oldCap + oldCap/4
}

// Если вычисленная ёмкость меньше требуемой — берём требуемую
if newCap < newLen {
newCap = newLen
}

return newCap
}

Наглядный пример роста:

func main() {
s := []int{}
for i := 0; i < 20; i++ {
s = append(s, i)
fmt.Printf("len=%2d cap=%2d ptr=%p\n", len(s), cap(s), s)
}
}
// len= 1 cap= 1 ptr=0xc0000b8000
// len= 2 cap= 2 ptr=0xc0000b8010 — удвоение
// len= 3 cap= 4 ptr=0xc0000b8030 — удвоение
// len= 5 cap= 8 ptr=0xc0000b8060 — удвоение
// len= 9 cap= 16 ptr=0xc0000b80b0 — удвоение
// len=17 cap= 32 ptr=0xc0000b8110 — удвоение

Вставка элемента в середину среза:

// Вставка элемента по индексу
func insert(s []int, index int, value int) []int {
s = append(s, 0) // расширяем на один элемент
copy(s[index+1:], s[index:]) // сдвигаем элементы вправо
s[index] = value // записываем новое значение
return s
}

s := []int{1, 2, 4, 5}
s = insert(s, 2, 3) // [1, 2, 3, 4, 5]

Удаление элемента из среза:

// Удаление по индексу
func remove(s []int, index int) []int {
copy(s[index:], s[index+1:]) // сдвигаем элементы влево
return s[:len(s)-1] // обрезаем последний
}

// Или без сохранения порядка (быстрее)
func removeFast(s []int, index int) []int {
s[index] = s[len(s)-1] // последний элемент на место удаляемого
return s[:len(s)-1] // обрезаем последний
}

Оптимизация: предварительное выделение памяти

Если известен примерный размер, реаллокаций можно избежать:

// Без оптимизации — множественные реаллокации
var result []int
for i := 0; i < 10000; i++ {
result = append(result, i)
}

// С оптимизацией — одна аллокация
result := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
result = append(result, i)
}

Практические рекомендации:

  • Всегда присваивайте результат append: s = append(s, val)
  • Используйте make([]T, 0, capacity) при известном размере
  • Помните о разделяемой памяти при sub-slicing
  • Для вставки/удаления в середину — используйте copy
  • Не злоупотребляйте вставкой в начало — это O(n) операция

Вопрос 18. Как работает функция copy для срезов и можно ли реализовать её функциональность вручную?

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

Ответ собеседника: Правильный. Функция copy(dst, src) копирует содержимое исходного среза (src) в целевой срез (dst). Эту же функциональность можно реализовать вручную: в цикле пройти по элементам исходного среза и добавить каждый элемент в целевой срез с помощью append. На выходе получается срез со всеми скопированными значениями.

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

Функция copy

copy(dst, src) копирует элементы из исходного среза в целевой. Возвращает количество скопированных элементов — минимум из len(dst) и len(src).

Базовое использование:

src := []int{1, 2, 3, 4, 5}
dst := make([]int, 3)

n := copy(dst, src)
fmt.Println(n) // 3 — скопировано 3 элемента (len(dst))
fmt.Println(dst) // [1, 2, 3]

Поведение при разных размерах:

// dst короче src — копируется только len(dst)
src := []int{1, 2, 3, 4, 5}
dst := make([]int, 3)
copy(dst, src)
fmt.Println(dst) // [1, 2, 3]

// dst длиннее src — копируется всё из src, остальное не трогается
src := []int{1, 2, 3}
dst := make([]int, 5)
dst[3] = 99
dst[4] = 100
copy(dst, src)
fmt.Println(dst) // [1, 2, 3, 99, 100]

// dst пустой — ничего не копируется
src := []int{1, 2, 3}
dst := make([]int, 0)
n := copy(dst, src)
fmt.Println(n, dst) // 0, []

// src пустой — ничего не копируется
src := []int{}
dst := make([]int, 3)
n := copy(dst, src)
fmt.Println(n, dst) // 0, [0, 0, 0]

Копирование части среза:

src := []int{1, 2, 3, 4, 5}

// Копирование диапазона
dst := make([]int, 3)
n := copy(dst, src[1:4]) // копируем src[1], src[2], src[3]
fmt.Println(n, dst) // 3, [2, 3, 4]

Копирование с перекрытием (overlapping slices):

// copy корректно обрабатывает перекрывающиеся срезы
s := []int{1, 2, 3, 4, 5}
n := copy(s[1:], s[:4]) // [1, 1, 2, 3, 4]
fmt.Println(n, s) // 4, [1, 1, 2, 3, 4]

Реализация copy вручную:

// Простая реализация
func manualCopy(dst, src []int) int {
n := len(src)
if len(dst) < n {
n = len(dst)
}

for i := 0; i < n; i++ {
dst[i] = src[i]
}

return n
}

// Универсальная реализация через интерфейс (Go < 1.18)
func manualCopyInterface(dst, src interface{}) int {
d := dst.([]interface{})
s := src.([]interface{})

n := len(s)
if len(d) < n {
n = len(d)
}

for i := 0; i < n; i++ {
d[i] = s[i]
}

return n
}

Сравнение производительности:

func BenchmarkBuiltinCopy(b *testing.B) {
src := make([]int, 1000)
dst := make([]int, 1000)
for i := 0; i < b.N; i++ {
copy(dst, src)
}
}

func BenchmarkManualCopy(b *testing.B) {
src := make([]int, 1000)
dst := make([]int, 1000)
for i := 0; i < b.N; i++ {
for j := 0; j < len(src); j++ {
dst[j] = src[j]
}
}
}

Встроенная copy значительно быстрее — она использует оптимизированную реализацию на уровне runtime (memmove), которая копирует блоками памяти, а не поэлементно.

Практическое применение copy:

Создание независимой копии среза:

original := []int{1, 2, 3, 4, 5}

// НЕПРАВИЛЬНО — делит базовый массив
alias := original

// ПРАВИЛЬНО — независимая копия
clone := make([]int, len(original))
copy(clone, original)

Обрезка среза:

s := []int{1, 2, 3, 4, 5}
s = append(s[:2], s[3:]...) // удаление элемента по индексу 2
fmt.Println(s) // [1, 2, 4, 5]

Сдвиг элементов:

s := []int{1, 2, 3, 4, 5}
// Сдвиг вправо на одну позицию
copy(s[1:], s[:len(s)-1])
s[0] = 0
fmt.Println(s) // [0, 1, 2, 3, 4]

Ключевые свойства copy:

  • Возвращает количество скопированных элементов
  • Берёт минимум из len(dst) и len(src)
  • Корректно обрабатывает перекрывающиеся срезы
  • Не изменяет ёмкость dst, только перезаписывает элементы
  • Работает с срезами одинакового типа (не с []int в []string)
  • Значительно быстрее поэлементного копирования

Вопрос 19. Как ведут себя подсрезы при изменении — влияют ли изменения подсреза на оригинальный срез?

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

Ответ собеседника: Правильный. При взятии подсреза (например, s[2:5]) он ссылается на ту же область памяти, что и оригинальный срез. Поэтому при изменении подсреза автоматически изменяется и оригинальный срез. Ёмкость подсреза может быть больше, чем его длина — она определяется размером оставшейся части оригинального массива. Это означает, что при добавлении элементов в подсрез через append можно случайно изменить оригинальный срез.

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

Этот вопрос уже подробно рассмотрен ранее (вопрос 16). Краткое резюме с дополнительными примерами:

Подсрезы делят базовый массив:

original := []int{1, 2, 3, 4, 5}
sub := original[1:4] // [2, 3, 4]

sub[0] = 999
fmt.Println(original) // [1, 999, 3, 4, 5] — original изменился!

append к подсрезу может перезаписать оригинал:

original := make([]int, 3, 5)
original[0], original[1], original[2] = 1, 2, 3

sub := original[:2] // [1, 2], len=2, cap=5
sub = append(sub, 999) // перезаписывает original[2]!
fmt.Println(original) // [1, 2, 999] — неожиданное изменение

Способы защиты:

// Способ 1: Ограничение ёмкости (третий параметр)
sub := original[0:2:2] // len=2, cap=2 — append вызовет реаллокацию
sub = append(sub, 999) // новый массив, original не затронут

// Способ 2: Явное копирование
sub := make([]int, 2)
copy(sub, original[0:2])
sub = append(sub, 999) // безопасно — свой массив

Правило: всегда помните о разделяемой памяти при работе с подсрезами. Если нужна независимая копия — используйте copy() или ограничение ёмкости.

Вопрос 20. Что такое table-driven тесты в Go?

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

Ответ собеседника: Правильный. Table-driven тесты — это подход к тестированию, при котором проверочные условия (тестовые случаи) собраны в таблицы (слайсы структур). Каждая строка таблицы содержит входные данные и ожидаемый результат. Тест проходит по всем строкам таблицы и проверяет, что функция возвращает ожидаемый результат для каждого набора входных данных. Это позволяет компактно описать множество тестовых сценариев.

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

Table-driven tests — идиоматический подход к тестированию в Go

Table-driven tests — это паттерн, при котором тестовые случаи описываются как данные (таблица), а не как отдельные функции. Это позволяет компактно описать множество сценариев и легко добавлять новые.

Базовый пример:

// Тестируемая функция
func Add(a, b int) int {
return a + b
}

// Table-driven тест
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive numbers", 2, 3, 5},
{"negative numbers", -1, -2, -3},
{"mixed numbers", -1, 1, 0},
{"zeros", 0, 0, 0},
{"large numbers", 1000000, 2000000, 3000000},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Add(tt.a, tt.b)
if result != tt.expected {
t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, result, tt.expected)
}
})
}
}

Преимущества table-driven тестов:

  • Компактность — множество случаев в одном месте
  • Легко добавить новый случай — просто добавить строку в таблицу
  • Каждый случай выполняется как подтест (через t.Run) — при падении видно, какой именно случай не прошёл
  • Покрытие граничных случаев и ошибок одним взглядом

Тестирование ошибок:

func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}

func TestDivide(t *testing.T) {
tests := []struct {
name string
a, b float64
expected float64
expectError bool
}{
{"normal division", 10, 2, 5, false},
{"division by zero", 10, 0, 0, true},
{"negative result", -10, 2, -5, false},
{"zero dividend", 0, 5, 0, false},
{"fractional result", 7, 2, 3.5, false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := Divide(tt.a, tt.b)

if tt.expectError {
if err == nil {
t.Errorf("expected error, got nil")
}
return
}

if err != nil {
t.Errorf("unexpected error: %v", err)
return
}

if result != tt.expected {
t.Errorf("got %f, want %f", result, tt.expected)
}
})
}
}

Тестирование сложных структур:

type User struct {
Name string
Age int
Email string
}

func ValidateUser(u User) error {
if u.Name == "" {
return errors.New("name is required")
}
if u.Age < 0 || u.Age > 150 {
return errors.New("invalid age")
}
if !strings.Contains(u.Email, "@") {
return errors.New("invalid email")
}
return nil
}

func TestValidateUser(t *testing.T) {
tests := []struct {
name string
user User
expectError bool
errMsg string
}{
{
name: "valid user",
user: User{Name: "Alice", Age: 30, Email: "alice@example.com"},
expectError: false,
},
{
name: "empty name",
user: User{Name: "", Age: 30, Email: "alice@example.com"},
expectError: true,
errMsg: "name is required",
},
{
name: "negative age",
user: User{Name: "Bob", Age: -1, Email: "bob@example.com"},
expectError: true,
errMsg: "invalid age",
},
{
name: "invalid email",
user: User{Name: "Charlie", Age: 25, Email: "not-an-email"},
expectError: true,
errMsg: "invalid email",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateUser(tt.user)

if tt.expectError {
if err == nil {
t.Fatalf("expected error, got nil")
}
if err.Error() != tt.errMsg {
t.Errorf("error = %q, want %q", err.Error(), tt.errMsg)
}
return
}

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}

Бенчмарки в table-driven стиле:

func BenchmarkAdd(b *testing.B) {
tests := []struct {
name string
a, b int
}{
{"small", 2, 3},
{"large", 1000000, 2000000},
{"negative", -100, -200},
}

for _, tt := range tests {
b.Run(tt.name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(tt.a, tt.b)
}
})
}
}

Использование мап вместо слайсов:

func TestMultiply(t *testing.T) {
tests := map[string]struct {
a, b int
expected int
}{
"simple": {2, 3, 6},
"by zero": {5, 0, 0},
"negative": {-2, 3, -6},
"identity": {7, 1, 7},
}

for name, tt := range tests {
t.Run(name, func(t *testing.T) {
result := Multiply(tt.a, tt.b)
if result != tt.expected {
t.Errorf("got %d, want %d", result, tt.expected)
}
})
}
}

Лучшие практики:

  • Используйте t.Run() для каждого случая — это создаёт подтесты с понятными именами
  • Называйте тестовые случаи осмысленно — имя должно объяснять, что проверяется
  • Покрывайте граничные случаи: нули, пустые значения, максимумы/минимумы
  • Покрывайте ошибочные сценарии
  • Используйте t.Fatalf для критических ошибок (дальнейшее выполнение бессмысленно)
  • Используйте t.Errorf для некритических (чтобы увидеть все проблемы за один прогон)
  • Храните таблицу рядом с тестом, а не в отдельном файле

Вопрос 21. Что такое deadlock в Go и когда он возникает?

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

Ответ собеседния: Правильный. Deadlock (взаимная блокировка) возникает, когда горутина пытается записать больше элементов в буферизированный канал, чем его ёмкость, и при этом нет читателя, который освобождает место. Например, если канал имеет ёмкость 4 и горутина пытается записать 5-й элемент, происходит полная блокировка — deadlock. Также deadlock может возникнуть при попытке прочитать из пустого канала, если нет писателя.

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

Deadlock в Go

Deadlock — ситуация, когда все горутины программы заблокированы и не могут продолжить выполнение. Go runtime обнаруживает эту ситуацию и завершает программу с паникой.

Классический deadlock с каналами:

// Deadlock: запись в небуферизированный канал без читателя
func main() {
ch := make(chan int)
ch <- 42 // блокировка навсегда — нет читателя
fmt.Println("never reached")
}
// fatal error: all goroutines are asleep - deadlock!
// Deadlock: переполнение буферизированного канала
func main() {
ch := make(chan int, 2)
ch <- 1
ch <- 2
ch <- 3 // deadlock — буфер полон, нет читателя
}
// fatal error: all goroutines are asleep - deadlock!
// Deadlock: чтение из пустого канала
func main() {
ch := make(chan int)
val := <-ch // блокировка навсегда — нет писателя
fmt.Println(val)
}
// fatal error: all goroutines are asleep - deadlock!

Deadlock между несколькими горутинами:

func main() {
ch1 := make(chan int)
ch2 := make(chan int)

go func() {
ch1 <- 1 // ждём, что прочитают из ch1
<-ch2 // потом читаем из ch2
}()

go func() {
ch2 <- 2 // ждём, что прочитают из ch2
<-ch1 // потом читаем из ch1
}()

time.Sleep(time.Second)
fmt.Println("never reached")
}
// Обе горутины ждут друг друга — deadlock

Deadlock с мьютексами:

func main() {
var mu sync.Mutex

mu.Lock()
mu.Lock() // deadlock — повторная блокировка того же мьютекса
mu.Unlock()
mu.Unlock()
}
// fatal error: all goroutines are asleep - deadlock!

Как избегать deadlock:

  1. Всегда имейте читателя для писателя. Используйте горутины для чтения:
func main() {
ch := make(chan int, 2)

go func() {
ch <- 1
ch <- 2
close(ch) // закрываем, когда закончили писать
}()

for val := range ch {
fmt.Println(val) // 1, 2
}
}
  1. Используйте select с timeout:
func main() {
ch := make(chan int)

select {
case ch <- 42:
fmt.Println("sent")
case <-time.After(time.Second):
fmt.Println("timeout — не удалось отправить")
}
}
  1. Используйте context для отмены:
func worker(ctx context.Context, ch chan int) {
for i := 0; ; i++ {
select {
case ch <- i:
case <-ctx.Done():
return
}
}
}

func main() {
ch := make(chan int, 10)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()

go worker(ctx, ch)

for val := range ch {
fmt.Println(val)
if val >= 5 {
cancel()
break
}
}
}
  1. Используйте sync.RWMutex вместо sync.Mutex для сценариев с преобладанием чтения.

  2. Соблюдайте порядок блокировки мьютексов — всегда блокируйте в одном и том же порядке.

Важно: Go обнаруживает deadlock только когда все горутины заблокированы. Если хотя бы одна горутина работает, deadlock не будет обнаружен, даже если другие горутины заблокированы навсегда. Это не deadlock в терминах Go, а «утечка горутин» (goroutine leak).

Вопрос 22. Как создаются горутины в Go, чем они отличаются от потоков и как завершить горутины?

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

Ответ собеседника: Правильный. Горутина создаётся с помощью ключевого слова go перед вызовом функции: go func(). Горутины — это легковесные объекты, выполняющиеся конкурентно. Потоки весят около 2 МБ, а горутины гораздо легче, поэтому их можно запускать тысячами. Горутины контролируются средой выполнения Go, а не операционной системой. Для завершения одной горутины можно закрыть канал, через который она получает значения, с помощью close(). Для завершения множества горутин используется sync.WaitGroup: при запуске горутины счётчик увеличивается (Add), при завершении — уменьшается (Done), а функция Wait() блокирует выполнение до завершения всех горутин.

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

Создание горутин

Горутина создаётся добавлением ключевого слова go перед вызовом функции:

// Запуск анонимной функции
go func() {
fmt.Println("Hello from goroutine")
}()

// Запуск именованной функции
go process(data)

// Запуск с аргументами
go func(id int, name string) {
fmt.Printf("Worker %d: %s\n", id, name)
}(1, "Alice")

Горутины vs потоки ОС

ХарактеристикаГорутинаПоток ОС
Стек2 КБ (растёт динамически)1-8 МБ (фиксирован)
СозданиеМикросекундыДесятки микросекунд
Переключение контекста~200 нс~1-10 мкс
Максимум в процессеСотни тысяч — миллионыТысячи
ПланировщикGo runtime (M:N)ОС (1:1)

Go использует модель M:N — M горутин распределяются по N потокам ОС. Планировщик Go сам решает, когда и на каком потоке выполнить горутину.

Способы завершения горутин

1. Закрытие канала — сигнал к завершению:

func worker(ch <-chan int, done chan<- struct{}) {
for {
select {
case val, ok := <-ch:
if !ok {
fmt.Println("Channel closed, exiting")
done <- struct{}{}
return
}
fmt.Println("Processing:", val)
}
}
}

func main() {
ch := make(chan int)
done := make(chan struct{})

go worker(ch, done)

for i := 0; i < 5; i++ {
ch <- i
}
close(ch) // сигнал к завершению
<-done // ждём завершения
}

2. Context — рекомендуемый способ:

func worker(ctx context.Context, id int) {
for {
select {
case <-ctx.Done():
fmt.Printf("Worker %d: cancelled (%v)\n", id, ctx.Err())
return
default:
// Работа
time.Sleep(100 * time.Millisecond)
}
}
}

func main() {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()

go worker(ctx, 1)
go worker(ctx, 2)

<-ctx.Done() // ждём отмены по таймауту
time.Sleep(time.Second) // даём горутинам завершиться
}

3. Специальный канал отмены (done channel):

func worker(done <-chan struct{}, jobs <-chan int) {
for {
select {
case <-done:
fmt.Println("Received stop signal")
return
case job, ok := <-jobs:
if !ok {
fmt.Println("Jobs channel closed")
return
}
fmt.Println("Processing job:", job)
}
}
}

func main() {
done := make(chan struct{})
jobs := make(chan int)

go worker(done, jobs)

jobs <- 1
jobs <- 2

close(done) // сигнал к завершению
time.Sleep(time.Second)
}

4. sync.WaitGroup — ожидание завершения группы:

func main() {
var wg sync.WaitGroup

for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("Worker %d started\n", id)
time.Sleep(time.Duration(id) * time.Second)
fmt.Printf("Worker %d finished\n", id)
}(i)
}

wg.Wait() // блокируется, пока все горутины не вызовут Done()
fmt.Println("All workers finished")
}

5. errgroup — WaitGroup с обработкой ошибок:

import "golang.org/x/sync/errgroup"

func main() {
g, ctx := errgroup.WithContext(context.Background())

urls := []string{"http://example.com", "http://example.org"}

for _, url := range urls {
url := url // захват переменной
g.Go(func() error {
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
resp.Body.Close()
return nil
})
}

if err := g.Wait(); err != nil {
fmt.Printf("Error: %v\n", err)
}
}

6. runtime.Goexit — завершение текущей горутины:

func main() {
go func() {
defer fmt.Println("defer executed")
fmt.Println("before Goexit")
runtime.Goexit() // завершение горутины, defer выполняется
fmt.Println("never reached")
}()

time.Sleep(time.Second)
}

Важно: Горутина не может быть принудительно завершена извне — она должна сама «согласиться» на завершение, проверив сигнал через канал или context. Это обеспечивает чистое завершение без утечек ресурсов.

Паттерн graceful shutdown:

func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

var wg sync.WaitGroup

wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
fmt.Println("Shutting down gracefully...")
// cleanup
return
default:
// работа
time.Sleep(time.Second)
}
}
}()

<-ctx.Done() // ждём сигнала ОС
wg.Wait() // ждём завершения горутин
fmt.Println("Shutdown complete")
}

Вопрос 23. Как реализовать функцию reverse для разворачивания среза целых чисел без использования дополнительного среза?

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

Ответ собеседника: Правильный. Функция reverse реализуется путём итерации по первой половине среза и обмена местами пар элементов с начала и конца. Например, для среза [3, 2, 1]: меняем элементы с индексами 0 и 2 → [1, 2, 3]. Обмен можно выполнить в стиле Python: s[i], s[len(s)-1-i] = s[len(s)-1-i], s[i]. Таким образом все пары соседних элементов с противоположных концов меняются местами, и весь срез разворачивается.

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

Реализация reverse in-place

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

Базовая реализация:

func reverse(s []int) {
for i := 0; i < len(s)/2; i++ {
s[i], s[len(s)-1-i] = s[len(s)-1-i], s[i]
}
}

func main() {
s := []int{1, 2, 3, 4, 5}
reverse(s)
fmt.Println(s) // [5, 4, 3, 2, 1]

s2 := []int{1, 2, 3, 4}
reverse(s2)
fmt.Println(s2) // [4, 3, 2, 1]
}

Через два указателя (более явный вариант):

func reverse(s []int) {
left, right := 0, len(s)-1
for left < right {
s[left], s[right] = s[right], s[left]
left++
right--
}
}

Универсальная версия с дженериками (Go 1.18+):

func reverse[T any](s []T) {
for i := 0; i < len(s)/2; i++ {
s[i], s[len(s)-1-i] = s[len(s)-1-i], s[i]
}
}

func main() {
ints := []int{1, 2, 3, 4, 5}
reverse(ints)
fmt.Println(ints) // [5, 4, 3, 2, 1]

strs := []string{"a", "b", "c", "d"}
reverse(strs)
fmt.Println(strs) // ["d", "c", "b", "a"]
}

Версия с использованием пакета slices (Go 1.21+):

import "slices"

func main() {
s := []int{1, 2, 3, 4, 5}
slices.Reverse(s)
fmt.Println(s) // [5, 4, 3, 2, 1]
}

Пошаговая визуализация алгоритма:

Начало: [1, 2, 3, 4, 5]

Шаг 1: i=0, меняем s[0] и s[4]
[5, 2, 3, 4, 1]

Шаг 2: i=1, меняем s[1] и s[3]
[5, 4, 3, 2, 1]

Шаг 3: i=2, len/2 = 2, цикл завершается
(средний элемент остаётся на месте)

Результат: [5, 4, 3, 2, 1]

Сложность:

  • Время: O(n/2) = O(n)
  • Память: O(1) — без дополнительного среза

Тесты:

func TestReverse(t *testing.T) {
tests := []struct {
name string
input []int
expected []int
}{
{"odd length", []int{1, 2, 3, 4, 5}, []int{5, 4, 3, 2, 1}},
{"even length", []int{1, 2, 3, 4}, []int{4, 3, 2, 1}},
{"single element", []int{1}, []int{1}},
{"empty", []int{}, []int{}},
{"two elements", []int{1, 2}, []int{2, 1}},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reverse(tt.input)
if !slices.Equal(tt.input, tt.expected) {
t.Errorf("got %v, want %v", tt.input, tt.expected)
}
})
}
}

Вопрос 24. Что такое глобальные переменные в Go и как работают области видимости?

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

Ответ собеседника: Правильный. Глобальная переменная — это переменная, объявленная вне области видимости любой функции (например, в пакете main вне функции main). Также переменные можно определять в блоках (var (...)), что позволяет изолировать их в определённой области. Переменная, созданная внутри блока (например, внутри цикла for или блока var), не видна вне этого блока. Таким образом, помимо глобальных переменных, существуют локальные области видимости внутри функций, которые можно дополнительно ограничивать с помощью блоков.

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

Области видимости в Go

Go имеет три уровня области видимости: пакетный, блочный и уровень файла.

Пакетная область видимости:

package main

import "fmt"

// Глобальная переменная — видна во всех функциях пакета
var globalVar = "I'm global"

// Групповое объявление
const (
StatusOK = 200
StatusError = 500
)

func main() {
fmt.Println(globalVar) // доступна
localFunc()
}

func localFunc() {
fmt.Println(globalVar) // тоже доступна
}

Блочная область видимости:

func main() {
x := 10 // видна только внутри main

if true {
y := 20 // видна только внутри этого блока if
fmt.Println(x, y) // 10, 20
}
// fmt.Println(y) // ОШИБКА: y не определена

for i := 0; i < 3; i++ {
// i видна только внутри цикла
fmt.Println(i)
}
// fmt.Println(i) // ОШИБКА: i не определена
}

Затенение переменных (shadowing):

var x = "global"

func main() {
fmt.Println(x) // "global"

x := "local" // новая переменная, затеняет глобальную
fmt.Println(x) // "local"

if true {
x := "block" // ещё одна новая переменная
fmt.Println(x) // "block"
}
fmt.Println(x) // "local" — блочная x уже не существует
}

Область видимости импортов (файловая):

// file1.go
package main

import "fmt" // виден только в этом файле

func foo() {
fmt.Println("foo")
}
// file2.go
package main

// fmt НЕ виден здесь — нужно импортировать отдельно
import "fmt"

func bar() {
fmt.Println("bar")
}

Область видимости и замыкания:

func counter() func() int {
count := 0 // захвачена замыканием
return func() int {
count++
return count
}
}

func main() {
c := counter()
fmt.Println(c()) // 1
fmt.Println(c()) // 2
fmt.Println(c()) // 3
// count напрямую недоступна — только через замыкание
}

Практические рекомендации:

  • Минимизируйте использование глобальных переменных — они усложняют тестирование и создают скрытые зависимости
  • Используйте блоки {} для ограничения области видимости, когда это уместно
  • Будьте внимательны с затенением — компилятор не предупреждает об этом (но go vet и линтеры могут)
  • Переменные, объявленные в if, for, switch, видны только внутри этого блока:
if x := getValue(); x > 0 {
fmt.Println(x) // OK
}
// fmt.Println(x) // ОШИБКА: x не определена здесь

Вопрос 25. Как написать функцию для проверки палиндрома в Go?

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

Ответ собеседника: Правильный. Палиндром — это слово, которое читается одинаково в обе стороны. Способы реализации: 1) В цикле сравнивать элементы, находящиеся на одинаковом расстоянии от начала и конца. 2) Развернуть строку и сравнить с оригиналом. 3) Использовать пакет bytes для сравнения байтовых срезов. 4) Рекурсивная реализация. Все варианты корректны и работают.

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

Проверка палиндрома — несколько подходов

Двухуказательный подход — самый эффективный:

func isPalindrome(s string) bool {
runes := []rune(s) // конвертируем в руны для корректной работы с Unicode
left, right := 0, len(runes)-1

for left < right {
if runes[left] != runes[right] {
return false
}
left++
right--
}
return true
}

func main() {
fmt.Println(isPalindrome("racecar")) // true
fmt.Println(isPalindrome("hello")) // false
fmt.Println(isPalindrome("шалаш")) // true — Unicode работает корректно
fmt.Println(isPalindrome("a")) // true
fmt.Println(isPalindrome("")) // true
}

Сравнение с развёрнутой строкой:

func isPalindrome(s string) bool {
runes := []rune(s)
n := len(runes)
for i := 0; i < n/2; i++ {
if runes[i] != runes[n-1-i] {
return false
}
}
return true
}

Рекурсивный подход:

func isPalindromeRecursive(s string) bool {
runes := []rune(s)
return check(runes, 0, len(runes)-1)
}

func check(runes []rune, left, right int) bool {
if left >= right {
return true
}
if runes[left] != runes[right] {
return false
}
return check(runes, left+1, right-1)
}

Игнорируя регистр и не-буквенные символы (классическая задача):

func isPalindromeAdvanced(s string) bool {
runes := []rune(strings.ToLower(s))
left, right := 0, len(runes)-1

for left < right {
// Пропускаем не-буквенно-цифровые символы слева
for left < right && !isAlphanumeric(runes[left]) {
left++
}
// Пропускаем не-буквенно-цифровые символы справа
for left < right && !isAlphanumeric(runes[right]) {
right--
}

if runes[left] != runes[right] {
return false
}
left++
right--
}
return true
}

func isAlphanumeric(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r)
}

func main() {
fmt.Println(isPalindromeAdvanced("A man, a plan, a canal: Panama")) // true
fmt.Println(isPalindromeAdvanced("race a car")) // false
fmt.Println(isPalindromeAdvanced(" ")) // true
}

Для чисел (без конвертации в строку):

func isNumberPalindrome(n int) bool {
if n < 0 || (n%10 == 0 && n != 0) {
return false // отрицательные и числа, заканчивающиеся на 0 (кроме 0)
}

reversed := 0
for n > reversed {
reversed = reversed*10 + n%10
n /= 10
}

// Для чётного количества цифр: n == reversed
// Для нечётного: n == reversed/10 (средняя цифра не важна)
return n == reversed || n == reversed/10
}

func main() {
fmt.Println(isNumberPalindrome(121)) // true
fmt.Println(isNumberPalindrome(-121)) // false
fmt.Println(isNumberPalindrome(1221)) // true
fmt.Println(isNumberPalindrome(12321)) // true
}

Тесты:

func TestIsPalindrome(t *testing.T) {
tests := []struct {
input string
expected bool
}{
{"racecar", true},
{"hello", false},
{"шалаш", true},
{"a", true},
{"", true},
{"ab", false},
{"aba", true},
{"abba", true},
{"abcba", true},
{"abccba", true},
}

for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := isPalindrome(tt.input)
if result != tt.expected {
t.Errorf("isPalindrome(%q) = %v, want %v", tt.input, result, tt.expected)
}
})
}
}

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

ПодходВремяПамятьПримечание
ДвухуказательныйO(n)O(n) для рунОптимальный
РекурсивныйO(n)O(n) стек вызововРиск переполнения стека
Разворот и сравнениеO(n)O(n)Простой, но лишняя работа
ЧисловойO(log n)O(1)Только для чисел

Важно: всегда используйте []rune вместо []byte для строк с Unicode — иначе многобайтовые символы будут сравниваться некорректно.