02. Основы Go. База

«Простота — это предварительное условие для надёжности. Вы не можете доверять тому, чего не понимаете до конца».
— Кен Томпсон


Архитектура языка: минимализм, типобезопасность и машинный код

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

flowchart TD
    classDef entry   fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef accent  fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    classDef system  fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef success fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#f8fafc

    Src["Исходный код (.go)"]:::entry --> Lex["Лексический анализ и парсер (AST)"]:::process
    Lex --> TypeCheck["Проверка типов и констант"]:::process
    TypeCheck --> SSA["Генерация SSA (Static Single Assignment)"]:::accent
    SSA --> Opt["Оптимизации: Inlining, Dead Code, Escape Analysis"]:::accent
    Opt --> CodeGen["Генерация машинного кода (ABIInternal)"]:::system
    CodeGen --> Link["Линковка пакетов и Runtime (go link)"]:::system
    Link --> Bin["Исполняемый файл (ELF / Mach-O / PE)"]:::success
  
flowchart TD
    classDef entry   fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef memory  fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc

    Decl["Объявление переменной (var x T / x := expr)"]:::entry --> Zero["Инициализация Zero Value или значением"]:::process
    Zero --> Escape{"Escape Analysis:<br/>Утечёт ли ссылка из фрейма?"}:::warning
    Escape -->|"Нет (время жизни локально)"| Stack["Аллокация на стеке горутины (0 ns)"]:::process
    Escape -->|"Да (escape to heap)"| Heap["Аллокация в куче (runtime.newobject)"]:::memory
    Stack --> StackFree["Автоматическое освобождение при выходе из функции"]:::success
    Heap --> GC["Отслеживание и сборка мусора (GC Tri-color)"]:::memory
  

  1. Что такое Go и зачем он создан?
  2. Какие ключевые принципы Go?
  3. Чем Go отличается от Java/C++/Python?
  4. Что такое статическая типизация?
  5. Что такое компиляция в Go?
  6. Как работает go build?
  7. Что такое zero value?
  8. Какие есть базовые типы (bool, int, float, string)?
  9. Что такое rune?
  10. Что такое byte?
  11. Чем rune отличается от byte?
  12. Как объявить переменную?
  13. Чем var отличается от :=?
  14. Что такое short declaration (:=)?
  15. Что такое область видимости (scope)?
  16. Что такое пакет (package)?
  17. Как устроен main пакет?
  18. Что такое init()?
  19. В каком порядке вызываются init()?
  20. Как работает импорт пакетов?
  21. Что такое экспортируемые имена (заглавная буква)?
  22. Что такое unused variable error?
  23. Почему Go строгий к неиспользуемым переменным?
  24. Что такое множественный возврат (multiple return)?
  25. Почему Go поддерживает множественный возврат?
  26. Что такое именованные возвращаемые значения?
  27. Что такое пустой идентификатор _?
  28. Когда можно использовать _?
  29. Почему в Go нет исключений?
  30. Что такое язык со сборщиком мусора (garbage collected)?
  31. Что такое семантика значений (value semantics)?
  32. Что такое передача по значению (pass by value)?
  33. Есть ли в Go передача по ссылке (pass by reference)?
  34. Что такое iota?
  35. Что такое const?
  36. Чем отличаются типизированные и нетипизированные константы?
  37. Что такое приведение типов (type conversion)?
  38. Что такое утверждение типа (type assertion)?
  39. Что такое переключатель типа (type switch)?
  40. Как работает for цикл в Go?
  41. Какие есть формы for цикла?
  42. Как работает if, else, switch?
  43. Что такое goto и почему его не стоит использовать?
  44. Что такое fallthrough в switch?
  45. Как работает label и break с меткой?
  46. Что такое комментарии и зачем нужен godoc?
  47. Как форматировать код с помощью go fmt?
  48. Что такое go vet?
  49. Как работают операторы сравнения?
  50. Как работают логические операторы?
  51. Какова размерность (битность) базовых типов на разных архитектурах (int на x86 vs x64)?
  52. Что такое «синтаксический сахар» в Go и какие примеры можно привести?
  53. Как работает оператор &^ (AND NOT)?
  54. Что такое range over integers в Go 1.22?
  55. Как в Go 1.22 изменилась семантика loop variable capture?
  56. Чем math/rand/v2 отличается от math/rand?
  57. Что такое range over func в Go 1.23?
  58. Что такое пакет iter в Go 1.23?
  59. Что такое new(expr) в Go 1.26?

Что такое Go и зачем он создан?

Краткий ответ (30–60 секунд)

Go (или Golang) — компилируемый, статически типизированный язык программирования с автоматическим управлением памятью (сборщик мусора). Он создан в Google Робом Пайком, Кеном Томпсоном и Робертом Гризмером для решения проблем, с которыми инженеры Google сталкивались при разработке серверной инфраструктуры на C++ и Java: медленная компиляция, сложность языка, громоздкая модель конкурентности. Целью было соединить простоту написания, как у Python, с производительностью и безопасностью компилируемых языков, но с «лёгкой» конкурентностью из коробки.

Развёрнутый ответ

Go начали проектировать в 2007 году, публичный анонс — 2009, стабильный релиз 1.0 — 2012 год. Основные боли, которые он решал:

  • Долгая компиляция: в больших C++ проектах сборка могла идти часы. Go компилирует крупный сервис за секунды.
  • Сложность C++: обилие фич (шаблоны, исключения, множественное наследование) усложняло чтение и поддержку кода.
  • Отсутствие эффективной конкурентности: потоки OS дороги, а модель «памяти с общей памятью» была уязвима к гонкам. Go ввёл горутины и каналы, вдохновлённые CSP (Communicating Sequential Processes) Хоара.
  • Управление зависимостями: требовался чёткий импорт без циклических зависимостей и воспроизводимые сборки.
  • Инструментарий: не было единого форматирования, линтеров, простого тестирования — Go дал их из коробки.

Язык намеренно минималистичен: нет классов и наследования (только структуры и интерфейсы с утиной типизацией), нет исключений, дженериков до версии 1.18 (и даже сейчас они сдержанные). Это дисциплинирует разработчика, упрощает код-ревью и обучение новых членов команды.

Под капотом (internals)

С точки зрения компилятора, Go использует собственный front-end (gc), который строит AST, затем преобразует в промежуточное представление SSA (Static Single Assignment). Компилятор сразу делает escape analysis, решая, выделяется ли переменная на стеке или в куче. Далее генерируется машинный код, а объектные файлы упаковываются в статические бинарники с включённым рантаймом и GC. Рантайм инициализирует планировщик горутин (M:N модель), выделяет память потокам, запускает сборку мусора — всё это встроено в исполняемый файл, никакой внешней виртуальной машины.

Исходники компилятора: src/cmd/compile/internal/gc, src/cmd/compile/internal/ssa.

Типичные ошибки кандидатов

  1. Считать Go “улучшенным Python” или интерпретируемым языком. Go — строго компилируемый язык, это не скрипт. Производительность и модель памяти — как у C, не как у CPython. Попытка писать на нём в динамическом стиле ведёт к постоянной борьбе с типами.

    • Исправление: принять статическую типизацию и философию явной обработки ошибок, не пытаться эмулировать динамические фичи через interface{} без нужды.
  2. Миф: Go создан только для сетевых сервисов. Хотя его ниша — серверная разработка, Go успешно применяется в системном программировании (Docker, Kubernetes), CLI-утилитах, компиляторах, embedded (TinyGo). Не стоит ограничивать область применения.

    • Исправление: изучить спектр успешных проектов: etcd, Terraform, CockroachDB, чтобы понять широту языка.

Возможные дополнительные вопросы интервьюера

  • Почему авторы отказались от дженериков при создании? Роб Пайк и команда считали, что дженерики усложняют язык и компилятор, а практическая потребность в них меньше, чем в простом и быстром инструменте. С введением дженериков в 1.18 они добавили минималистичную реализацию на основе контрактов (интерфейсов типов), не жертвуя скоростью компиляции.
  • В чём концептуальная разница между горутинами и потоками ОС? Горутины — это легковесные кооперативные потоки поверх OS-потоков (M:N). Планировщик Go мультиплексирует миллионы горутин на десятки потоков, переключение между ними обходится в десятки наносекунд, не требует системных вызовов.

Какие ключевые принципы Go?

Краткий ответ (30–60 секунд)

Философия Go строится на простоте, продуктивности и надёжности. Главные принципы: ортогональность (мало фич, но они комбинируются мощно), явный код (читаемость важнее краткости), эффективная компиляция, встроенная конкурентность, безопасность памяти с GC, статическая типизация и строгий инструментарий (форматирование, тестирование, документация из коробки). Принцип «меньше — значит больше»: язык намеренно избегает излишеств вроде исключений или наследования.

Развёрнутый ответ

  • Простота: синтаксис укладывается в голове целиком, стандартная библиотека следует этому же принципу. Отсутствие исключений, классов, перегрузки операторов снижает когнитивную нагрузку.
  • Ортогональность: фичи независимы и комбинируются предсказуемо. Например, интерфейсы удовлетворяются неявно — можно добавить абстракцию, не меняя существующий код.
  • Читаемость: go fmt снимает споры о стиле, соглашения о нейминге (заглавная буква — public) прозрачны.
  • Конкурентность «из коробки»: горутины и каналы — часть языка, а не библиотек, что даёт единый стиль.
  • Быстрая компиляция и статическая линковка: даёт простоту деплоя (один бинарник без зависимостей).
  • Явная обработка ошибок: возвращаемые ошибки — значения, поток управления линеен, нет скрытых прыжков в catch.
  • Сборка мусора: устраняет целый класс багов управления памятью, при этом GC оптимизирован для низких задержек (таргет < 1 мс в последних версиях).

Под капотом (internals)

Простота компиляции обеспечена архитектурой компилятора: пакетная компиляция, минимум глобального анализа. Программа разбита на пакеты, каждый компилируется независимо, объектные файлы кешируются. Отсутствие шаблонов C++ исключало экспоненциальный рост времени инстанцирования, а минималистичные дженерики Go используют диспетчеризацию на основе “словарей” (shape-based) без генерации кода под каждую комбинацию типов (пока реализация в gc использует частичную мономорфизацию). Быстродействие планировщика горутин опирается на кооперативную многозадачность и разделение стеков, растущих динамически. GC — tricolor concurrent mark-sweep, с барьерами записи, внедрёнными компилятором.

Типичные ошибки кандидатов

  1. Жертвовать читаемостью ради «производительности». Писать неидиоматичный код, избегая выделений в куче там, где это не критично, или использовать unsafe для микрооптимизаций. Принцип Go — сначала пиши просто, затем профилируй.

    • Исправление: следовать принципу “ясность кода > производительность, пока бенчмарки не покажут обратное”.
  2. Эмуляция ООП через встраиваемые структуры и путаница с “наследованием”. В Go нет классов, встраивание — только композиция и делегирование методов, но не полиморфизм подтипов.

    • Исправление: использовать интерфейсы для полиморфизма и встраивание только для повторного использования кода, ясно различая понятия “is-a” (нет) и “has-a” (да).

Возможные дополнительные вопросы интервьюера

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

Чем Go отличается от Java/C++/Python?

Краткий ответ (30–60 секунд)

Go — компилируемый в нативный код язык с минимальным рантаймом, GC и встроенной конкурентностью, в отличие от интерпретируемого Python. От C++ он убирает исключения, ручное управление памятью, множественное наследование и шаблоны старого образца, предлагая простоту и безопасность. От Java — отсутствие JVM, классов, огромной иерархии типов и аннотаций, заменяя это лёгкой моделью интерфейсов и статически слинкованными бинарниками.

Развёрнутый ответ

Язык Компиляция Парадигма Память Конкурентность Сложность языка
Go Нативный код, статический бинарь Императивный, процедурный + интерфейсы GC Горутины, каналы Низкая
C++ Нативный код Мультипарадигма, включая ООП Ручное/RAII Потоки, std::thread, async Очень высокая
Java Байт-код + JIT Объектно-ориентированный GC (JVM) Потоки, Project Loom (со временем) Средняя/высокая
Python Интерпретация (CPython) Императивный, динамический GC GIL, asyncio (базовая) Низкая

Go занимает нишу «эффективного C»: его рантайм минимален (~2 МБ на бинарник), старт мгновенный, а производительность близка к C при значительно меньших затратах на разработку. В отличие от Python, статическая типизация ловит ошибки на этапе компиляции, а не в проде. По сравнению с Java, отсутствие JVM упрощает развертывание и избавляет от прогрева. Конкурентность в Go была продумана изначально, а не добавлена позже, как асинхронность в C++ или Java.

Под капотом (internals)

Рантайм Go полностью статически включается в исполняемый файл; GC — триколорный, работает конкурентно с приложением. Планировщик горутин (src/runtime/proc.go) работает в пространстве пользователя, без переключений в ядро при смене горутины. Java-подобная JVM выполняет байт-код с динамической компиляцией (JIT), что даёт хорошую пиковую производительность, но ценой долгого старта и прогрева. В Go компиляция в SSA и статическая линковка гарантирует предсказуемую производительность “с коробки” без холодных путей JIT. Python-подобных языков интерпретатор тратит время на диспетчеризацию типов во время выполнения; Go типы все разрешены статически, включая вызовы методов интерфейсов через itab-таблицы.

Типичные ошибки кандидатов

  1. Привнесение паттернов Java/C++ (фабрики, иерархии классов). В Go интерфейсы маленькие и неявные, инъекция зависимостей делается через явные параметры или конструкторы. Ненужная абстракция портит простоту.

    • Исправление: следовать правилу “чем проще, тем лучше”; интерфейсы определять там, где используются, а не там, где реализуются.
  2. Ожидание таких же гарантий GC, как в JVM с Generational GC. Go GC использует non-generational concurrent mark-sweep, который оптимизирован для низких задержек, но не для максимальной пропускной способности. При массовом создании мусора (короткоживущие объекты) может быть больше CPU-нагрузки.

    • Исправление: использовать пулы объектов (sync.Pool), избегать излишних аллокаций через escape analysis, но без преждевременной оптимизации.

Возможные дополнительные вопросы интервьюера

  • Что бы вы выбрали для высоконагруженного сервиса — Go или Java, и почему? Выбор зависит от команды и требований. Если нужна низкая стоимость поддержки, простое горизонтальное масштабирование и быстрый деплой — Go. Если есть глубокие корпоративные библиотеки Java или нужен зрелый JIT для числодробилок — Java. Тем не менее, модель горутин часто выигрывает у Java-потоков по потреблению памяти на соединение.
  • В чём Go близок к Python по философии? Оба ценят читаемость и простоту, один очевидный способ сделать что-то (хотя Python допускает несколько). Однако Go жёстко типизирован и компилируем, что приближает его к безопасности C.

Что такое статическая типизация?

Краткий ответ (30–60 секунд)

Статическая типизация означает, что тип каждой переменной и выражения известен и проверяется на этапе компиляции. В Go вы обязаны объявлять типы явно или через выведение (:=), и компилятор не допустит операций между несовместимыми типами без явного преобразования. Это гарантирует отсутствие ошибок типов во время исполнения, улучшает документированность и позволяет компилятору генерировать более эффективный код.

Развёрнутый ответ

Статическая типизация — краеугольный камень безопасности Go. В отличие от динамических языков (Python, JavaScript), где переменная может менять тип в рантайме, в Go тип фиксирован на всё время жизни переменной. Система типов включает базовые типы, структуры, интерфейсы и не допускает неявного приведения чисел с плавающей запятой к целым (float64 → int без явного приведения).

Плюсы статической типизации:

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

Go при этом использует вывод типов в коротких объявлениях (x := 42 — int), чтобы сохранить лаконичность динамических языков, но без потери гарантий.

Под капотом (internals)

В компиляторе Go типы представлены как объекты types.Type в src/go/types. Каждое выражение после парсинга проходит проверку типов, где компилятор вычисляет тип каждого узла AST и проверяет совместимость. Внутреннее представление использует алгоритм, схожий с алгоритмом Дамаса-Хиндли-Милнера, но с более простой системой, рассчитанной на скорость. Например, для константных выражений применяется безграничная точность (math/big). После проверки типов компилятор переходит к SSA, где типы определяют инструкции и выделение памяти. В сгенерированном коде почти не остаётся информации о типах, кроме таблиц методов интерфейсов (itab). Поэтому динамическое приведение типов (type assertion) работает через проверку встроенного в итаб дескриптора типа.

Типичные ошибки кандидатов

  1. Попытка обойти типизацию через interface{}: злоупотребление “пустым интерфейсом” ради эмуляции динамической типизации ведёт к падениям в рантайме и потере производительности.

    • Исправление: использовать конкретные типы или, при необходимости, дженерики (Go 1.18+), минимизировать interface{}.
  2. Убеждение, что неявное присваивание похожих типов допустимо: например, var x int = 5; var y int32 = x не скомпилируется. Кандидаты из C могут ожидать автоматического расширения.

    • Исправление: всегда делать явное приведение y := int32(x), ясно отдавая себе отчёт о возможной потере данных.

Возможные дополнительные вопросы интервьюера

  • Можно ли в Go достичь полиморфизма? Да, через интерфейсы, которые задают поведение. Полиморфизм в Go — “структурный” (пригодный по форме), а не номинальный, как в Java.
  • Как статическая типизация взаимодействует с nil для интерфейсов? Интерфейс имеет тип и значение; var i Interface = nil — интерфейс nil, а var p *Type = nil; i = p — интерфейс не nil (тип информации есть), что приводит к ошибкам. Нужна явная проверка на nil внутреннего значения.

Что такое компиляция в Go?

Краткий ответ (30–60 секунд)

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

Развёрнутый ответ

Процесс компиляции можно разбить на этапы:

  1. Парсинг — код разбивается на токены, строится AST.
  2. Проверка типов — анализ и аннотирование дерева типов, разрешение идентификаторов.
  3. Трансляция в SSA — AST преобразуется в независимое от языка статическое однократное присваивание (SSA) с учётом специфики Go (например, безопасная работа со срезами, интерфейсы).
  4. Оптимизации — удаление мёртвого кода, инлайнинг, упрощение выражений, escape analysis.
  5. Генерация кода — SSA транслируется в ассемблер целевой архитектуры, затем объектные файлы.
  6. Линковка — компоновщик (go tool link) объединяет объектные файлы, рантайм и статические библиотеки в один бинарник.

Компилятор gc написан на Go (самокомпилируемый) и использует пакетный подход: изменение одного файла пересобирает только его пакет и пакеты, зависящие от него. Кэш сборки (GOCACHE) делает пересборку почти мгновенной. При кросс-компиляции достаточно установить GOOS и GOARCH, так как весь инструментарий включает кросс-компиляторы.

Под капотом (internals)

Входной точкой является go build, который запускает цепочку go tool compile, go tool asm (для ассемблерных файлов) и go tool link. Компилятор (src/cmd/compile/internal/gc) переводит AST в SSA-форму в пакете ssa. На этом этапе выполняется escape analysis (алгоритм в src/cmd/compile/internal/escape), который решает, размещать переменную в стеке или в куче. Интересная деталь: в Go SSA специально спроектирована так, чтобы облегчить генерацию безопасного кода — например, вставку проверок границ срезов. Ассемблер Go использует диалект Plan 9, что исторически, но добавляет прослойку совместимости. Оптимизации SSA работают на графах потока данных, умеют выносить инварианты циклов, сворачивать константы и удалять лишние проверки nil.

Типичные ошибки кандидатов

  1. Игнорирование кэша сборки и флагов: изменение CGO-файлов не всегда триггерит пересборку из-за кэширования, флаг -a заставляет пересобрать всё. Кандидат должен знать, как принудительно пересобрать.

    • Исправление: для дебага невоспроизводимых ситуаций использовать go build -a -v -x, показывающий команды.
  2. Ожидание, что go build создаёт исполняемый файл с таким же именем, как у пакета. На самом деле, если не указан -o, имя берётся по имени родительской директории для main.

    • Исправление: всегда задавать -o output, если нужно конкретное имя.

Возможные дополнительные вопросы интервьюера

  • Как включить подробный вывод компиляции для диагностики? go build -gcflags="-m -m" покажет решения escape analysis и инлайнинга.
  • Что такое “bootstrap” компилятора? Go 1.5 переписан на Go, для сборки нужен более ранний компилятор Go. Процесс “bootstrap” использует цепочку версий для сборки компилятора текущей версии.

Как работает go build?

Краткий ответ (30–60 секунд)

go build — основная команда, компилирующая пакеты Go и их зависимости. Для пакета main она создаёт исполняемый файл в текущей директории, для обычных пакетов — проверяет компилируемость, но не сохраняет результат. В процессе она разрешает зависимости через файлы go.mod, скачивает модули (при необходимости), компилирует исходники в объектные файлы, кэширует их и компонует финальный бинарник.

Развёрнутый ответ

Команда запускает несколько утилит:

  • go vet (эмбеддинг, но проверки на уровне go build отключены без -vet)
  • сбор информации о пакетах с помощью go list
  • разрешение импортов, кэширование в $GOCACHE
  • компиляция каждого пакета командой compile
  • линковка исполняемого файла link.

Важные флаги:

  • -o <file> — имя выходного файла.
  • -race — включить детектор гонок данных (встраивает инструментированный рантайм).
  • -ldflags — передать аргументы линковщику (например, установить версию).
  • -tags — условная компиляция (build tags).
  • -v — показать компилируемые пакеты.
  • -x — показать выполняемые команды (отладка).

go build не удаляет старые объектные файлы — для очистки используется go clean. Сборка по умолчанию не включает оптимизации линкера (strip), поэтому бинарник может быть большим, для продакшена используют -ldflags="-s -w".

Под капотом (internals)

go build реализован в пакете cmd/go/internal/work. Он строит граф зависимостей, проверяет кэш на основе хеша исходных файлов, флагов компиляции, версий компилятора. Компилятор (cmd/compile) вызывается с флагом -p для указания пути пакета и -o для объектного файла. Линковщик (cmd/link) получает список объектных файлов и формирует ELF/Mach-O/PE файл. Рантайм и GC вшиваются из предварительно скомпилированного статического архива ($GOROOT/pkg/$GOOS_$GOARCH/runtime.a). Важно: символы инициализации (init()) укладываются в специальную секцию, линкер строит таблицу вызовов инициализации, которая запускается рантаймом при старте.

Типичные ошибки кандидатов

  1. Попытка запустить go build для пакета без main, ожидая бинарник. Ошибка: “package is not main”. Понимание, что go build без флага -o для не-main ничего не производит, необходимо.

    • Исправление: если нужен бинарник, использовать go build -o mylib.so -buildmode=c-shared для библиотеки.
  2. Использование go build внутри GOPATH без модулей. Начиная с Go 1.16 модули обязательны, и код вне модуля не соберётся без GO111MODULE=off. Кандидат часто забывает про go.mod.

    • Исправление: всегда инициализировать модуль go mod init module/path.

Возможные дополнительные вопросы интервьюера

  • Как собираться программе с CGO? Тогда вызывается препроцессор, компилятор C, и объектные файлы C линкуются вместе с Go-объектниками. CGO может замедлить компиляцию.
  • Что такое “тринити” компилятора (compile, asm, link) и зачем они разделены? Разделение позволяет кэшировать объектные файлы и линковать только изменившиеся пакеты, ускоряет инкрементальную сборку.

Что такое zero value?

Краткий ответ (30–60 секунд)

Zero value — значение по умолчанию, которое получает любая переменная в Go при объявлении без явной инициализации. Это гарантирует, что память всегда проинициализирована и не содержит мусора, предотвращая undefined behavior. Каждый тип имеет свой zero value: 0 для чисел, "" для строк, nil для указателей, срезов, карт, каналов, интерфейсов, false для bool, структуры — рекурсивно нулевые значения полей.

Развёрнутый ответ

Концепция zero value — одна из основ безопасности Go. Вы никогда не получите неинициализированную память. Даже при выделении через new(T) или var t T память обнуляется. Это важно для программирования без лишних конструкторов: структура с нулевыми полями часто готова к использованию (например, sync.Mutex, bytes.Buffer имеют полезный zero value).

Примеры zero value для разных типов:

  • int → 0, float64 → 0.0
  • string → ""
  • bool → false
  • указатели, срезы, карты, каналы, интерфейсы → nil
  • массивы → массив элементов с zero value каждого элемента
  • структуры → структура, где каждое поле — zero value

Стоит помнить: nil-срез всё ещё имеет длину 0, может быть использован с append, len, range, но nil-карта при вставке вызовет панику.

Под капотом (internals)

Когда компилятор встречает var x int, он выделяет память на стеке (или в куче, если escape анализ так решит) и генерирует инструкции её обнуления. В рантайме есть функции, такие как runtime.newobject, runtime.mallocgc, которые обнуляют выделенную память (через memclrNoHeapPointers). Go гарантирует, что память, возвращаемая из GC, всегда обнулена, даже если она была использована ранее. Это отличается от C/C++, где неинициализированные переменные содержат случайные данные.

Для составных литералов, где поля не указаны, компилятор вставляет пропущенные нули: p := Point{X: 1} автоматически установит Y = 0, что транслируется в обнуление всей выделенной памяти.

Типичные ошибки кандидатов

  1. Работа с nil-картой, срезом как с полноценными. var m map[string]int; m["key"] = 1 — паника, потому что карта не проинициализирована. Положиться на zero value недостаточно — нужна инициализация make или литерал.

    • Исправление: всегда явно создавать карту через make(map[string]int) или map[string]int{}.
  2. Проверка nil интерфейса без учёта типизированного nil. var p *MyType = nil; var i interface{} = p; i != nil — true, так как интерфейс хранит информацию о типе. Ошибка может привести к вызовам на nil-приёмнике.

    • Исправление: проверять i == nil только в том случае, если присваивался именно nil без типа; использовать рефлексию или избегать передачи типизированных nil в интерфейсы.

Возможные дополнительные вопросы интервьюера

  • Почему nil-срез можно использовать, а nil-карту — нет? Срез — это структура (указатель, len, cap), и его методы доступа не требуют выделенной памяти для массива, пока длина 0. Карта — сложная динамическая структура, запись в неё требует выделения бакетов, поэтому паника.
  • Как убедиться, что структура инициализирована корректно после zero value? Написать функцию-конструктор NewMyStruct() *MyStruct, которая устанавливает валидное состояние, следуя конвенции “Make the zero value useful”.

Какие есть базовые типы (bool, int, float, string)?

Краткий ответ (30–60 секунд)

В Go определены bool (true/false), целочисленные типы (int8..int64, uint8..uint64, int, uint, uintptr, byte, rune), типы с плавающей точкой (float32, float64) и комплексные (complex64, complex128), а также string. Каждый тип имеет чёткий размер, кроме int/uint, размер которых зависит от архитектуры (4 или 8 байт). Строка — неизменяемая последовательность байт, обычно UTF-8.

Развёрнутый ответ

  • Boolean: bool — значения true или false.
  • Целочисленные:
    • Знаковые: int8, int16, int32, int64
    • Беззнаковые: uint8, uint16, uint32, uint64
    • Машинно-зависимые: int, uint (32 или 64 бита под платформу)
    • byte = uint8, rune = int32
    • uintptr — беззнаковое целое, вмещающее указатель (достаточно для хранения значения указателя).
  • Плавающая точка: float32 (IEEE-754 32-bit), float64 (64-bit) — предпочтительный тип для вычислений.
  • Комплексные: complex64 (float32 реальная и мнимая часть), complex128 (float64). Редко используются.
  • Строка: string — неизменяемый (immutable) срез байт, хранит длину и указатель на данные в памяти.

Выбор типа влияет на производительность и поведение; Go требует явного приведения между числовыми типами, например int и int32. Строковые литералы поддерживают escape-последовательности и raw strings (обратные кавычки).

Под капотом (internals)

В рантайме строка — это структура reflect.StringHeader или runtime.stringStruct: {Data unsafe.Pointer, Len int}. Неизменяемость гарантирует безопасную передачу строк без копирования. При конкатенации компилятор вызывает runtime.concatstrings, который выделяет новую память и копирует байты. Для чисел компилятор генерирует соответствующие инструкции процессора. int на 64-битной системе — qword (RAX), на 32-битной — dword (EAX). Арифметика с константами выполняется на этапе компиляции с использованием math/big. В памяти структуры, содержащие базовые типы, выравниваются по границам, определяемым размером типа, что компилятор учитывает при размещении полей.

Типичные ошибки кандидатов

  1. Использование неэффективного размера int для счётчиков и циклов: на 64-битной платформе int занимает 8 байт, но для индексации по слайсу достаточно int (да, это правильно, так как длина и ёмкость — int). Однако путать с int64 там, где нужна совместимость с базами данных, — частая ошибка.

    • Исправление: использовать int для слайсовых индексов и длин, int64 для внешних интерфейсов.
  2. Прямое обращение с байтами строки как с символами: s[i] даёт i-й байт, а не i-ю руну. Для много-байтовых UTF-8 символов это ломает их.

    • Исправление: для доступа к символам использовать []rune(s) или for _, r := range s.

Возможные дополнительные вопросы интервьюера

  • Почему нет типа char? Авторы осознанно отказались: byte и rune покрывают нужды, строка — иммутабельный срез байт. Неявное превращение байт в символы запутывает.
  • Как быстро преобразовать []byte в string и наоборот? string(b) и []byte(s) копируют данные. Для временного преобразования без копирования в unsafe режиме можно использовать unsafe.Slice и unsafe.StringData, но это опасно, если оригинал меняется.

Что такое rune?

Краткий ответ (30–60 секунд)

rune — это встроенный тип Go, представляющий собой Unicode кодовую точку (code point). По сути это псевдоним int32. Одна руна может представлять любой символ Unicode, включая смайлики и символы из дополнительных плоскостей. В исходном коде литералы в одиночных кавычках 'A' имеют тип rune.

Развёрнутый ответ

Rune — логическое понятие символа, в отличие от байта. Поскольку Go строки являются последовательностью байт (обычно UTF-8), один кодовый пункт может кодироваться от 1 до 4 байт. rune позволяет работать с символами на абстрактном уровне. Примеры:

Go
r := '日'      // rune, значение 0x65E5
s := "hello"
for i, r := range s {
    // r — rune, i — байтовый индекс начала символа
}

Функции из пакета unicode/utf8 используются для кодирования/декодирования рун. Преобразование строки в срез рун []rune(s) декодирует UTF-8 в последовательность Unicode кодовых точек, занимая уже 4 байта на символ.

Под капотом (internals)

Внутренне rune — int32. В строковых данных, хранящихся в памяти, руны не присутствуют явно — там байты UTF-8. Операция for range строки вставляет вызовы runtime.decoderune, которые разбирают UTF-8 и возвращают руну и её размер в байтах. Компилятор раскладывает литералы рун в int32 значение ещё на этапе компиляции. Механизм итерации по строке в рантайме src/runtime/utf8.go использует lookup-таблицы для определения первого байта многобайтовой последовательности.

Типичные ошибки кандидатов

  1. Измерение длины строки в рунах через len(s): len(s) возвращает количество байт, а не символов. Это частая ошибка порождает баги при работе с локализованными текстами.

    • Исправление: utf8.RuneCountInString(s) или len([]rune(s)) для количества рун.
  2. Использование индексации по строке для получения руны: s[i] — это байт, а не руна. При многобайтовом символе получаем его часть.

    • Исправление: переводить строку в []rune и брать по индексу, либо использовать range.

Возможные дополнительные вопросы интервьюера

  • Какой максимальный размер руны в памяти? Одна руна занимает 4 байта (int32), что достаточно для любой кодовой точки Unicode (макс. 0x10FFFF).
  • Почему итерация строки через range безопасна и даёт руны, а индексный доступ — нет? Потому что range специально обрабатывает строку, декодируя UTF-8 на лету, в то время как индексный доступ просто возвращает байт и не предназначен для декодирования.

Что такое byte?

Краткий ответ (30–60 секунд)

byte в Go — это псевдоним типа uint8. Он представляет один октет данных. Используется для работы с сырыми бинарными данными, строками как наборами байт, чтения/записи в I/O. Все string могут быть легко приведены к []byte и наоборот с выделением копии. Обработка на уровне байт — основа для реализации протоколов и сериализации.

Развёрнутый ответ

Тип byte выделен для подчёркивания работы с данными, а не с числами. Его арифметика — обычная беззнаковая 8-битная. Полезные свойства:

  • []byte — мутабельный срез байт, удобный строитель строк (bytes.Buffer).
  • string ↔ []byte требует аллокации, так как строки иммутабельны, но иногда компилятор может избежать копирования при передаче строки в функцию, принимающую []byte (через временный срез, но это не рекомендуется).
  • Базовые операции чтения/записи интерфейсов io.Reader/Writer оперируют []byte, что делает byte фундаментом I/O.
  • Ковертация string(someBytes) проходит валидацию UTF-8? Нет, строка хранит любые байты, не обязательно валидный UTF-8.

Под капотом (internals)

byte — просто uint8, но в исходниках компилятора он имеет идентификатор types.BuiltinType с алиасом на uint8. Когда вы пишете []byte("hello"), компилятор вызывает runtime.stringtoslicebyte, который аллоцирует слайс и копирует данные с помощью memmove. Обратно string(bytes) вызывает runtime.slicebytetostring, также с копированием (за исключением, если компилятор видит, что слайс не будет изменён после преобразования, он может избежать копирования в некоторых сценариях через оптимизацию — она появилась в версии 1.17+). Внутренне []byte — это slice header {pointer, len, cap}.

Типичные ошибки кандидатов

  1. Модификация строки через преобразование в []byte с последующим изменением и обратным преобразованием: не всегда осознаётся, что это создаёт новый аллокации, что может быть узким горлышком в высоконагруженном коде.

    • Исправление: работать напрямую с []byte, если нужны мутации; использовать strings.Builder для построения строк без лишних копий.
  2. Сравнение []byte напрямую через ==: нельзя (срезы не comparable). Кандидат может попытаться написать bytesSlice1 == bytesSlice2, что не скомпилируется.

    • Исправление: использовать bytes.Equal для сравнения содержимого.

Возможные дополнительные вопросы интервьюера

  • Чем []byte лучше string для I/O? Потому что []byte мутабельен, позволяет читать данные в заранее выделенный буфер без дополнительных аллокаций, переиспользовать буферы (sync.Pool), а строка каждый раз выделяет новую память при построении.
  • Как скопировать срез байт без выделения общего подлежащего массива? Использовать copy(dst, src) — он копирует элементы, а не разделяет память.

Чем rune отличается от byte?

Краткий ответ (30–60 секунд)

byte — это uint8, один октет, минимальная адресуемая единица в памяти. rune — это int32, значение Unicode Code Point. byte работает с сырыми байтами и может представлять часть многобайтового символа UTF-8, тогда как rune всегда представляет целый символ. В контексте строк byte — это то, что лежит в памяти, rune — то, что видит разработчик при работе с текстом.

Развёрнутый ответ

Основное различие — семантическое и размерное:

Свойство byte (uint8) rune (int32)
Размер 1 байт 4 байта
Диапазон 0–255 0–0x10FFFF (все Unicode)
Представление в строке Может быть частью многобайтового символа Всегда представляет целый символ
Использование Бинарные данные, протоколы Обработка текста, Unicode

Пример:

Go
s := "Привет"
fmt.Println(len(s))          // 12 байт (каждая кириллическая буква — 2 байта)
fmt.Println(len([]rune(s)))  // 6 рун

range по строке итерирует по rune, а индексация — по byte.

Под капотом (internals)

Рантайм при итерации по строке с range внутри вызывает runtime.decoderune, который анализирует последовательность байт согласно UTF-8: берёт первый байт, по его старшим битам определяет длину символа (от 1 до 4), проверяет корректность оставшихся байт, декодирует значение в int32. Неправильная последовательность даёт rune = 0xFFFD (Replacement Character). Для byte такого декодирования нет — это просто значение из памяти.

В памяти строка — это просто последовательность байт. Срез []rune, полученный через []rune(s), аллоцирует слайс int32, а string([]rune{...}) кодирует руны обратно в UTF-8.

Типичные ошибки кандидатов

  1. Прямое преобразование одного байта строки в руну: rune(s[0]) даёт код первого байта, а не символа. Это ломается на многобайтовых символах.

    • Исправление: использовать []rune(s)[0] или for _, r := range s { break }.
  2. Ручное декодирование UTF-8 без использования unicode/utf8: велосипеды часто ошибочно определяют длину последовательности.

    • Исправление: всегда полагаться на utf8.DecodeRuneInString или range.

Возможные дополнительные вопросы интервьюера

  • Может ли руна быть отрицательной? Нет, диапазон int32 позволяет отрицательные значения, но допустимые Unicode кодовые точки находятся в диапазоне 0..0x10FFFF, поэтому корректная руна всегда >=0.
  • Как преобразовать строку в последовательность рун без промежуточного среза? Использовать range в цикле — это итерационный подход без выделения целого среза.

Как объявить переменную?

Краткий ответ (30–60 секунд)

В Go переменную можно объявить тремя способами: полное объявление с ключевым словом var (и опциональной инициализацией), короткое объявление := (только внутри функций), и new(T) для выделения указателя на zero value. var поддерживает указание типа, инициализацию, блочное объявление. := выводит тип из выражения справа и подходит для быстрой инициализации локальных переменных.

Развёрнутый ответ

  1. var с типом и без: var x int (zero value), var y = 42 (тип int выведен).
  2. Блочное объявление:

    Go
    var (
        name   string = "Go"
        version int    = 1
    )
    
  3. Короткое объявление :=: доступно только внутри функций.

    Go
    result, err := compute()
    
  4. new: p := new(int) — *p имеет *int, значение 0. Возвращает указатель.

Каждый способ имеет свою область применимости: var + тип — когда нужно zero value, := — для лаконичной инициализации, new — редко, так как чаще используется &T{}.

Под капотом (internals)

Компилятор при парсинге объявления создаёт объект ir.Name в AST. Для var без инициализации выполняется выделение памяти и обнуление. Для x := expr компилятор вычисляет тип правой части и присваивает левой. При := в области видимости может быть создана новая переменная или переиспользована существующая (если хотя бы одна новая в списке). Escape analysis решает, уходит ли указатель в кучу. Локальные переменные, не сбежавшие, размещаются на стеке и живут до выхода из функции.

Процесс инициализации с var x = expr и x := expr порождает одинаковый код, разница лишь в том, что := не применима вне функций и может переинициализировать переменную, если в multi-value контексте хотя бы одно имя новое.

Типичные ошибки кандидатов

  1. Использование := на уровне пакета (вне функций): syntax error: non-declaration statement outside function body. Только var или const.

    • Исправление: применять var для пакетных переменных.
  2. Затенение переменных: при использовании := внутри блока создаётся новая переменная с тем же именем, перекрывающая внешнюю.

    Go
    var err error
    if result, err := fetch(); err != nil { ... }
    // Здесь err внешняя всё ещё nil (ошибка)
    
    • Исправление: осознанно использовать = для присваивания существующей, либо var для явного контроля.

Возможные дополнительные вопросы интервьюера

  • Можно ли переопределить переменную с := повторно в той же области? Нельзя, если все имена уже объявлены. Хотя бы одно имя должно быть новым, иначе ошибка компиляции.
  • Когда предпочтительнее new, а когда &T{}? &T{} — составной литерал, позволяющий сразу задать поля; new только выделяет нулевую память и редко используется. На практике &T{} идиоматичнее.

Чем var отличается от :=?

Краткий ответ (30–60 секунд)

var — ключевое слово для объявления переменных с возможностью указания типа, может использоваться на любом уровне (пакетном и локальном). := — синтаксический сахар короткого объявления с автоматическим выводом типа, допустимо только внутри функций. var не требует инициализации (присваивается zero value), а := требует выражения справа для определения типа и значения.

Развёрнутый ответ

  • Место использования:

    Go
    package main
    var global int // ок
    // global2 := 0 // ошибка
    
    func f() {
        var a int = 1
        b := 2        // идентично var b = 2
    }
    
  • Наличие типа: var может фиксировать тип, отличный от выведенного: var i int64 = 10. := всегда выводит тип из правой части, но можно привести.

  • Переиспользование переменной: при := в multi-value присваивании, если слева хотя бы одно новое имя, существующие переменные просто получают новое значение. var всегда создаёт новые.
  • Читаемость: var более явный и часто используется для важных переменных, где нужно подчеркнуть тип, := — для локального быстрого кода.

Под капотом (internals)

Различие обрабатывается на этапе разрешения идентификаторов. var порождает объявление с обязательным аллоцированием памяти и, возможно, инициализацией, заданной выражением. := транслируется в то же AST-представление, что и var, но с меткой “короткого объявления”, которая влияет на проверки (например, запрет вне функций). В итоговом SSA оба варианта дают одинаковые инструкции, если выражение инициализации идентично.

Типичные ошибки кандидатов

  1. Попытка использовать := для полей структуры или пакетного уровня — компилятор отвергнет.

    • Исправление: использовать var или явное присваивание в конструкторе.
  2. Ошибочная вера, что := всегда создаёт новую переменную, и игнорирование переиспользования. Это приводит к трудноуловимым багам с err, когда ошибочно думают, что создаётся новый err, но на самом деле присваивается в существующую.

    • Исправление: следить за областью видимости, использовать var err error в начале функции, если нужна единственная err.

Возможные дополнительные вопросы интервьюера

  • Как сделать var с выводом типа? var x = 42 — тип выведен из 42; var y MyType = expr — явный при необходимости.
  • Почему := не разрешено на уровне пакета в отличие от var? Пакетный уровень не имеет последовательного порядка выполнения (init и зависимости). Вывод типа и инициализация с побочными эффектами могли бы нарушить предсказуемость инициализации пакетов.

Что такое short declaration (:=)?

Краткий ответ (30–60 секунд)

Short declaration := — оператор объявления и инициализации переменной внутри функций без ключевого слова var. Он автоматически выводит тип из значения правой части и создаёт одну или несколько новых переменных (хотя бы одна должна быть новой). Повышает лаконичность кода, но ограничен блочной областью видимости.

Развёрнутый ответ

Синтаксис:

Go
x := 5                    // int
ch := make(chan int)      // chan int
a, b := 1, "hello"

Особенности:

  • Не может использоваться на уровне пакета.
  • В multi-value случае (функции возвращают несколько значений) может одновременно объявить новые и присвоить существующим: f, err := os.Open(...) — если err уже объявлена в данной области, то будет присвоена, если нет — создана.
  • Тип выводится по тем же правилам, что и для var, но нельзя задать тип явно: для этого надо писать var x T = expr.

Под капотом (internals)

В AST оператор := различается как OASOP (assignment with declaration). При проверке типов компилятор разделяет левые идентификаторы на новые и существующие, добавляет новые в таблицу символов блока, а существующим присваивает значения. В SSA всё сводится к обычным операциям записи в регистры/стек. Никакого рантайм-оверхеда по сравнению с var нет. Правило «хотя бы одна новая» гарантирует, что не будет случайного переопределения всех переменных в одной области, предотвращая затенение без явного намерения.

Типичные ошибки кандидатов

  1. Затенение через := в новых блоках: в if, for, switch, при использовании := с уже существующим именем создаётся новая переменная, скрывающая внешнюю. Особенно коварно с ошибкой err.

    • Исправление: явно объявлять переменные до блока и использовать = внутри, либо внимательно проверять, создаётся ли новая переменная.
  2. Использование := на нулевом левом значении без новых переменных: a, b := 1, 2 второй раз в одной области вызовет ошибку “no new variables on left side of :=”.

    • Исправление: использовать = для переприсваивания.

Возможные дополнительные вопросы интервьюера

  • Что произойдёт с := в замыкании? Если переменная объявлена в замыкании через :=, она принадлежит этому замыканию и не перекрывает внешнюю. Это может привести к неожиданному поведению, если вы рассчитывали на изменение внешней переменной.
  • Как быть, если тип правой части нежелателен, а var неудобен? Можно привести: x := int64(42), тип будет int64.

Что такое область видимости (scope)?

Краткий ответ (30–60 секунд)

Область видимости в Go — это регион исходного текста, в котором объявленный идентификатор доступен. Блоки ограничены фигурными скобками {}, а также существуют неявные блоки: вселенной, пакета, файла, if, for, switch. Переменная видна от места объявления до конца блока, и внутренний блок может затенять внешний идентификатор. Правила просты и обеспечивают лексическую видимость.

Развёрнутый ответ

Иерархия блоков (от внешнего к внутреннему):

  • Вселенная: встроенные идентификаторы (make, len, int, true и т.д.).
  • Пакет: все идентификаторы уровня пакета.
  • Файл: импортированные пакеты.
  • Функция: аргументы, возвращаемые значения, тело.
  • Внутренний произвольный блок: внутри if, for, switch, явные {}.

Объявления в одном блоке не могут дублироваться, но вложенный блок может объявить идентификатор с тем же именем, перекрывая внешний (shadowing). Правила однозначны: имя разрешается в ближайшем охватывающем блоке.

Под капотом (internals)

Компилятор при разборе поддерживает стек областей видимости. Каждый { открывает новый scope, } закрывает. Идентификаторы хранятся в таблицах символов (пакет go/types). При разрешении имени компилятор идёт из текущего scope вверх по стеку до вселенной. Эта модель крайне эффективна, так как полностью статична. Shadowing выявляется компилятором (может быть warning в некоторых линтерах, например, go vet -shadow). На уровне SSA все переменные уже преобразованы в уникальные регистры или стековые слоты, от scope остаётся только время жизни.

Типичные ошибки кандидатов

  1. Случайное затенение переменных пакетного уровня локальными: объявление var err error в функции скрывает пакетную err, что может сломать логику.

    • Исправление: избегать одинаковых имён, использовать линтеры (например, staticcheck), следить за warning’ами.
  2. Непонимание, что управляющие конструкции создают неявные блоки: switch x := f(); { ... } — x видна только в switch. Кандидат может попытаться использовать x вне.

    • Исправление: объявить переменную до блока, если нужна после.

Возможные дополнительные вопросы интервьюера

  • Чем область видимости отличается от времени жизни переменной? Область видимости — статическое понятие (регион текста), время жизни — динамическое (пока переменная достижима). Например, переменная в цикле живёт до конца итерации, но область видимости ограничена телом цикла.
  • Как проверить потенциальные затенения на проекте? go vet -shadow (входит в go vet с Go 1.12), также golangci-lint с линтером shadow.

Что такое пакет (package)?

Краткий ответ (30–60 секунд)

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

Развёрнутый ответ

Каждая директория с Go-файлами, относящимися к одному пакету, не может содержать несколько разных пакетов (кроме тестов с суффиксом _test). Имя пакета обычно совпадает с именем директории, но не обязано. Импорт осуществляется по пути модуля и директории, например import "github.com/user/project/pkg". После импорта к экспортируемым именам обращаются через pkg.ExportedName.

Пакеты предоставляют:

  • Разделение ответственности.
  • Контроль доступа (публичное/приватное).
  • Изолированный неймспейс.
  • Возможность инициализации через init().

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

Под капотом (internals)

Компиляция пакета производится отдельно. Объектный файл содержит экспортированные символы и метаданные. При импорте линкер связывает символы. Система импорта кэширует результаты компиляции каждого пакета по хешу его исходников и зависимостей. Go запрещает циклические импорты, потому что это упрощает граф сборки и порядок инициализации — он определяется топологической сортировкой. Каждый пакет может иметь несколько init() функций (по одной на файл, и любые объявления init в одном файле); рантайм выполняет их в определённом порядке.

Типичные ошибки кандидатов

  1. Создание утилитарных пакетов типа “util”, “helpers”, “common”: это размывает ответственность, нарушает cohesion. Пакет должен обладать чёткой смысловой единицей.

    • Исправление: называть пакет по функциональности, которую он предоставляет (например, parser, auth). Мелкие хелперы держать в том же пакете, где используются, если это небольшой объём.
  2. Несоответствие имени пакета и его пути: например, путь foo/bar/baz, а имя пакета qux. При импорте придётся писать qux.Something, что сбивает с толку.

    • Исправление: приводить имя пакета к последнему сегменту пути, либо переименовывать при импорте (import myqux "foo/bar/baz") только в обоснованных случаях.

Возможные дополнительные вопросы интервьюера

  • Можно ли иметь несколько пакетов в одной директории? Только два: основной пакет и пакет с внешними тестами (_test с тем же именем плюс _test). Иначе ошибка компиляции.
  • Что такое internal-пакеты? Директория с именем internal или её поддиректории могут быть импортированы только пакетами, находящимися в том же дереве с корнем, который содержит internal. Это enforced компилятором.

Как устроен main пакет?

Краткий ответ (30–60 секунд)

Пакет main — точка входа исполняемой программы. Он обязан содержать функцию func main(), которая не принимает аргументов и не возвращает значений. Этот пакет должен присутствовать в единственном экземпляре среди всех файлов, объявленных как package main в одной директории. Из него go build создаёт исполняемый бинарник, а не архивный объектный файл.

Развёрнутый ответ

Функция main вызывается автоматически средой выполнения Go после завершения всех инициализаций init() импортированных пакетов и самого пакета main. Она может использовать аргументы командной строки через os.Args или пакет flag. Завершение main вызывает выход из программы с кодом 0, если не был вызван os.Exit. При панике в main без recover программа завершается с ненулевым кодом.

Пакет main ничем не отличается от других, кроме обязательной main(). Он может содержать вспомогательные функции и переменные. Для тестирования используется _test файлы, которые могут находиться в том же пакете main или main_test.

Под капотом (internals)

Старт программы описан в runtime (ассемблерный файл src/runtime/asm_amd64.s — _rt0_amd64). Точка входа _rt0_amd64 вызывает runtime.rt0_go, который инициализирует планировщик, выделяет g0, запускает GC, затем вызывает runtime.main. runtime.main выполняет цепочку init() всех пакетов в порядке детерминированной топологической сортировки (сначала зависимости, потом зависимые), затем вызывает main.main(). После возврата из main.main() вызывается exit(0). Если происходит паника, которая не обработана recover в main горутине, рантайм печатает стек и вызывает exit(2).

Типичные ошибки кандидатов

  1. Определение нескольких main функций в разных файлах одного пакета main: ошибка компиляции “main redeclared”.

    • Исправление: в одном пакете main только одна функция с сигнатурой main(). Вспомогательные логики выносить в другие функции.
  2. Возврат из main с кодом ошибки через os.Exit без корректной обработки деферов: defer не сработают после os.Exit, потому что это немедленное завершение процесса.

    • Исправление: стараться возвращать из main естественным образом, использовать os.Exit только для фатальных ошибок, и учитывать, что деферы не выполнятся. Либо оборачивать логику в другую функцию с возвратом ошибки.

Возможные дополнительные вопросы интервьюера

  • Может ли main пакет импортироваться другими пакетами? Формально да, но это бессмысленно и обычно ведёт к циклическому импорту или путанице. Исполняемая программа не экспортирует объекты библиотеки.
  • Как передать аргументы командной строки в main без глобального os.Args? Обычно используют flag.Parse() или чтение аргументов в начале main, их нельзя передать как параметры из-за фиксированной сигнатуры.

Что такое init()?

Краткий ответ (30–60 секунд)

init() — специальная функция без аргументов и возвращаемых значений, которая автоматически вызывается при инициализации пакета. Она используется для настройки состояния пакета до его первого использования. В одном пакете может быть несколько init() (даже в одном файле), они выполняются в порядке объявления. Выполняется после инициализации переменных пакета и до main.main().

Развёрнутый ответ

Основное назначение init():

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

init() нельзя вызвать явно, компилятор не разрешает ссылаться на него. Побочные эффекты init() должны быть минимальны и предсказуемы. Ошибки в init() обрабатываются либо через panic, либо через log.Fatal, что завершает программу до входа в main.

Под капотом (internals)

Компилятор собирает все функции init() пакета в единый массив, который рантайм вызывает последовательно. В коде пакета генерируется структура init и связывается с символом inittask. Функция runtime.doInit проходит по этим задачам в порядке, заданном линкером при обходе графа импорта. Каждая инициализация защищена блокировкой на случай конкурентного доступа (хотя обычно всё в одном потоке). После всех init() рантайм запускает main.main.

Типичные ошибки кандидатов

  1. Злоупотребление init() для “скрытой” инициализации, которая делает поведение неочевидным: например, регистрация драйверов баз данных через import _ "driver" — это нормально, но если init() выполняет сетевые вызовы или долгую работу, программа может зависнуть до main.

    • Исправление: выносить инициализацию с побочными эффектами в явно вызываемые функции, init() использовать только для конфигурации без внешних зависимостей.
  2. Использование init() для установки глобальных переменных, зависящих от других пакетов, без учёта порядка инициализации. Циклические зависимости импортов ломают компиляцию, но неявная зависимость через глобальные переменные с init() может привести к их неинициализированному использованию.

    • Исправление: обеспечить явный поток данных, использовать паттерн “конструктор” с отложенной инициализацией.

Возможные дополнительные вопросы интервьюера

  • Можно ли в init() запустить горутину? Технически да, но крайне не рекомендуется, так как горутина может попытаться использовать ещё не инициализированные пакеты, либо main завершится раньше. Лучше отложить старт горутин до main.
  • Что произойдёт при панике в init()? Рантайм завершит программу с трассировкой стека, не дойдя до main.

В каком порядке вызываются init()?

Краткий ответ (30–60 секунд)

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

Развёрнутый ответ

Спецификация гарантирует:

  • Зависимости: если пакет A импортирует B, то B инициализируется раньше A.
  • Внутри пакета переменные инициализируются раньше init(). Если переменная ссылается на выражение с вызовом функции, эта функция может использовать функции или переменные из того же пакета, которые уже инициализиованы ранее в порядке объявления.
  • Порядок следования init() в файле соблюдается по мере их объявления.
  • Порядок обработки файлов в пакете — лексикографический по имени файла (по умолчанию компилятор принимает файлы в порядке, заданном командной строкой, но go build даёт их отсортированными).

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

Под капотом (internals)

Компилятор создаёт для пакета структуру initTask с указателями на функции инициализации переменных и init(). Линкер при сборке бинарника строит граф импорта, топологически сортирует его и генерирует таблицу инициализации, которую рантайм вызывает последовательно. Для инициализаторов переменных компилятор может объединять несколько выражений в одну функцию, сохраняя порядок объявления. Функции init() собираются в слайс init-функций в том порядке, как они записаны в исходнике. Рантайм вызывает их одну за другой.

Типичные ошибки кандидатов

  1. Попытка использовать переменную пакета B в init() пакета A, ожидая, что B проинициализируется раньше, но зависимость импорта обратная или вообще отсутствует (если оба пакета импортируются третьим, порядок между ними не гарантирован, кроме транзитивных зависимостей).

    • Исправление: обеспечить явную зависимость через импорт, если нужен порядок. Если нельзя, вынести инициализацию за пределы init().
  2. Создание глобальных переменных с побочными эффектами (например, открытие файла) на уровне пакета, которые зависят от ещё не инициализированной переменной в другом файле — приводит к unpredictable behavior.

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

Возможные дополнительные вопросы интервьюера

  • Что произойдёт, если пакет A импортирует B, а B импортирует C, а в C есть init(), который ссылается на переменную из A? Такой граф невозможен, так как образуется циклический импорт, запрещённый компилятором.
  • Гарантирован ли порядок init() файлов, если их имена изменятся? Нет, гарантируется только в рамках одного файла и в рамках лексикографического порядка файлов пакета для данного запуска go build. Зависимость от порядка файлов — хрупкая практика.

Как работает импорт пакетов?

Краткий ответ (30–60 секунд)

Импорт в Go осуществляется ключевым словом import, которое указывает путь к пакету. Путь может быть стандартным или модульным. При импорте компилятор находит соответствующий исходный код или объектный файл, проверяет экспортируемые имена и предоставляет к ним доступ через имя пакета. Разрешены переименование импорта, импорт с точкой (не рекомендовано) и анонимный импорт для побочных эффектов.

Развёрнутый ответ

Синтаксис:

Go
import "fmt"
import (
    "net/http"
    _ "github.com/lib/pq" // анонимный импорт ради init()
    myhttp "other/http"    // переименование
)

Пакеты с одинаковым именем из разных путей разрешаются переименованием. Анонимный импорт _ только выполняет init() пакета, не давая доступа к его экспортируемым функциям. Импорт с точкой (. "some/package") импортирует все экспортируемые имена в текущее пространство имён, что снижает читаемость.

Импорты разрешаются на основе go.mod (в модульном режиме) и GOPATH (в legacy). Ищется директория, содержащая .go файлы с соответствующим именем пакета. Если имя пакета не совпадает с последним элементом пути, импортёр должен использовать переименование.

Под капотом (internals)

Процесс импорта включает:

  1. Поиск пакета: go toolchain сканирует GOPATH, module cache ($GOPATH/pkg/mod), GOROOT. Найденный пакет компилируется, если отсутствует в кеше.
  2. Построение графа зависимостей: все импорты пакета A включают идентификаторы A в область видимости.
  3. Линковка: символы импортированных пакетов связываются с вызывающим кодом.
  4. Запрет циклического импорта проверяется go при анализе графа — ошибка на этапе загрузки пакетов.

Интересно, что при обнаружении неиспользуемого импорта компилятор выдаёт ошибку, что заставляет код быть «чистым».

Типичные ошибки кандидатов

  1. Циклический импорт: пакет A импортирует B, а B (прямо или косвенно) A. Компилятор выдаёт ошибку.

    • Исправление: пересмотреть дизайн: выделить общий интерфейс в третий пакет, который импортируют оба, либо использовать инверсию зависимостей через интерфейсы.
  2. Импорт неиспользуемого пакета “на будущее” — ошибка компиляции. Кандидат может не знать, что Go это запрещает.

    • Исправление: удалить или закомментировать с _, если нужен только init(), или использовать goimports для автоматической уборки.

Возможные дополнительные вопросы интервьюера

  • Что такое “vendoring” и актуален ли он? С появлением модулей это устаревший механизм (Go 1.14+). Ранее позволял хранить копии зависимостей в директории vendor. Сейчас модули и кеш решают те же проблемы.
  • Как Go решает конфликт двух пакетов с одинаковым именем при разных путях? Требуется переименование при импорте, иначе ошибка компиляции.

Что такое экспортируемые имена (заглавная буква)?

Краткий ответ (30–60 секунд)

В Go идентификатор (переменная, функция, тип, поле структуры, метод) экспортируется из пакета, если его имя начинается с заглавной буквы Unicode. Строчная буква делает идентификатор приватным для пакета. Это простой и эффективный механизм инкапсуляции, позволяющий чётко отделить публичное API от внутренней реализации без дополнительных ключевых слов.

Развёрнутый ответ

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

Go
type User struct {
    Name string // экспортируется
    email string // приватный
}

Внешний пакет может обращаться к u.Name, но не к u.email. Экспортируемые функции и методы формируют контракт пакета. Это позволяет в будущем менять внутренности без нарушения обратной совместимости.

Под капотом (internals)

На этапе проверки типов компилятор аннотирует каждый идентификатор флагом Exported на основе первой руны. Затем эта информация сохраняется в объектных файлах и используется линкером для разрешения символов. Приватные имена не видны другим пакетам; они не попадают в таблицу экспорта объектного файла. Таким образом, на уровне рантайма и линковки действует строгий барьер.

Типичные ошибки кандидатов

  1. Размещение внутренней логики в экспортируемых методах, которые позволяют неконтролируемое изменение состояния. Недостаточно просто сделать поле неэкспортируемым, если экспортируемые методы позволяют его изменить некорректно.

    • Исправление: проектировать API так, чтобы экспортируемые функции поддерживали инварианты структуры.
  2. Использование нетипизированных констант или глобальных переменных экспортируемых без необходимости. Это создаёт жёсткую связь и усложняет будущий рефакторинг.

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

Возможные дополнительные вопросы интервьюера

  • Как получить доступ к приватной функции другого пакета в тесте? Использовать пакет _test с суффиксом _test (внешние тесты) нельзя получить доступ к приватным. Можно разместить тест в том же пакете (без суффикса), тогда приватные имена будут доступны.
  • Как работает экспорт для встроенных полей? Методы встроенного типа “продвигаются” и становятся методами внешнего типа, если они экспортированы в том пакете, где определены. Поля встроенного типа тоже продвигаются, но имена могут конфликтовать.

Что такое unused variable error?

Краткий ответ (30–60 секунд)

Unused variable error — ошибка компиляции Go, возникающая, когда объявленная локальная переменная (или переменная уровня пакета) никогда не используется в коде. Компилятор требует, чтобы все переменные были либо прочитаны, либо записаны (использованы). Это часть философии чистоты кода: неиспользуемый код — мусор, усложняющий поддержку и свидетельствующий о возможных ошибках.

Развёрнутый ответ

Ошибка распространяется на:

  • Локальные переменные, объявленные через var или :=.
  • Переменные уровня пакета (глобальные).
  • Импортированные пакеты (unused import error — отдельно, но тоже ошибка).
    Исключение: пустой идентификатор _, который намеренно отбрасывает значение. Также не ошибка, если переменная присваивается, но не читается? Нет, “использование” подразумевает любое обращение: чтение или запись? Только запись без чтения тоже считается использованием — ошибки не будет. Компилятор считает, что если переменной присвоено значение, она “использована” (даже если потом не читается). Но если переменная только объявлена и нигде не присвоена — ошибка. Точнее, unused variable — “variable declared and not used”, т.е. переменная объявлена, но никогда не используется в выражениях. Присваивание ей значения считается использованием? Да, x := 42 — x объявлена и ей присвоено значение, это считается использованием, даже если дальше x нигде не читается. Однако компилятор видит, что значение записано и не прочитано? Нет, в Go ошибка “not used” возникает, если переменная нигде не упоминается, кроме объявления (с инициализацией или без). Если есть присваивание позже, это уже использование. Так что x := 5 без дальнейшего — ошибка, потому что переменная объявлена и нигде не используется (нет даже чтения). Присваивание само по себе это использование? В спецификации: “a variable is used if it is used in an expression; assigning to it is not a use”. Значит, присваивание без чтения всё равно ошибка! Пример: x := 5 — x не используется в выражениях, поэтому ошибка. Но x := 5; fmt.Println(x) — ок. Присваивание не считается использованием. Это важно.

Под капотом (internals)

Компилятор после построения AST проводит анализ использования идентификаторов. Для каждой переменной отслеживаются места её чтения. Если таких нет, а переменная не пустой идентификатор — ошибка. Анализ происходит на уровне пакета, межфайлово. Это относительно простая проверка, но она заставляет разработчиков держать код опрятным.

Типичные ошибки кандидатов

  1. Заявление, что присваивание переменной делает её использованной. Это неверно: присвоение без чтения — всё ещё неиспользуемая переменная. Такой код не скомпилируется:

    Go
    x := 10      // not used
    x = 20
    
    • Исправление: удалить переменную или использовать её значение (например, _ = x намеренно заглушить).
  2. Оставление временной переменной для отладки после напиcания кода. Кандидаты могут забыть удалить, и компиляция упадёт.

    • Исправление: перед коммитом проверять сборку, линтеры подскажут.

Возможные дополнительные вопросы интервьюера

  • Как обойти ошибку, если значение действительно не нужно? Присвоить _ (пустой идентификатор), или конкретно заглушить _ = unusedVar. Но лучше пересмотреть логику.
  • Почему Go запрещает неиспользуемые переменные, но не запрещает неиспользуемые метки или функции? Неиспользуемые функции тоже ошибка (“func declared but not used”). Метки — разрешены без использования. Это дисциплинирует код.

Почему Go строгий к неиспользуемым переменным?

Краткий ответ (30–60 секунд)

Go вынуждает удалять неиспользуемые переменные для поддержания чистоты кодовой базы, уменьшения когнитивной нагрузки при чтении, избежания скрытых ошибок (например, опечаток в именах) и для ускорения компиляции — меньше кода требует меньше анализа. Это прямое следствие принципа «явного кода» и минимализма языка.

Развёрнутый ответ

Строгость имеет несколько мотиваций:

  • Чистота кода: мёртвый код отвлекает, создаёт иллюзию важности, замедляет код-ревью и усложняет рефакторинг.
  • Предотвращение ошибок: если переменная осталась неиспользованной после рефакторинга, это может сигнализировать о пропущенной логике. Компилятор сразу подсказывает.
  • Быстрая компиляция: меньше переменных — меньше работы для escape analysis, SSA оптимизаций, линковки.
  • Идеологическая: Роб Пайк и команда считали, что хороший код не содержит лишнего. Отсутствие исключений из правила не позволяет команде накапливать «временные» заглушки, которые потом живут годами.

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

Под капотом (internals)

При проверке компилятор подсчитывает ссылки на переменные. Если список ссылок на чтение пуст, переменная помечается как неиспользуемая. Алгоритм прост и выполняется за один проход AST пакета. Ошибка не позволяет продолжить компиляцию, то есть мусор не попадает в объектный файл. В отличие от C, где предупреждение компилятора можно проигнорировать, в Go это блокирующая ошибка.

Типичные ошибки кандидатов

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

    • Исправление: если нужно сохранить значение для будущего, но сейчас не используется — использовать _ = x для подавления ошибки, с комментарием почему.
  2. Игнорирование ошибки через _ = без осознания последствий. Иногда кандидаты тушат ошибку, но позже забывают, оставляя неиспользуемое значение, а это может быть неверным.

    • Исправление: перед глушением убедиться, что это действительно осознанное решение, возможно, добавить тест.

Возможные дополнительные вопросы интервьюера

  • Почему не добавили уровень предупреждения вместо ошибки? Потому что авторы хотели избежать культуры игнорирования варнингов, которая распространена в C/C++. Жёсткая ошибка заставляет всех участников процесса поддерживать код чистым немедленно.
  • Как Go обрабатывает неиспользуемые поля структуры? Не ошибка, потому что поле может быть нужно для сериализации или отражения. Хотя линтеры вроде unused могут предупредить.

Что такое множественный возврат (multiple return)?

Краткий ответ (30–60 секунд)

Множественный возврат — способность функции возвращать более одного значения одновременно. В Go это базовая конструкция, широко используемая для возврата результата и ошибки: func divide(a, b int) (int, error). При вызове значения можно присвоить нескольким переменным, в том числе с коротким объявлением.

Развёрнутый ответ

Синтаксис позволяет list типов в возвращаемом значении:

Go
func getPoint() (float64, float64) {
    return 1.5, 3.2
}

Вызов:

Go
x, y := getPoint()
// или
_, y2 := getPoint()

Чаще всего паттерн result, error: data, err := os.ReadFile("file"). Это позволяет обрабатывать ошибку сразу, не загромождая код try-catch. Возвращаемые значения можно распаковать в уже объявленные переменные, если в := есть хотя бы одно новое имя.

Под капотом (internals)

На уровне реализации функция с множественным возвратом возвращает свои значения через несколько регистров или через стек в зависимости от ABI. Go использует стековый ABI: возвращаемые значения размещаются в стеке вызывающей функции, а вызываемая пишет их напрямую в стек caller’а. Компилятор SSA вставляет необходимые инструкции. Множественный возврат не создаёт скрытых структур (tuple), это просто соглашение о записи нескольких значений в регистры/стек. При вызове x, y := f() компилятор генерирует обращение к стековым слотам, где callee оставила значения.

Типичные ошибки кандидатов

  1. Игнорирование возвращаемой ошибки с помощью _. data, _ := fetch() — это допустимо, но крайне опасно в проде, так как ошибка маскируется.

    • Исправление: всегда проверять ошибку явно, если только гарантированно не может быть (но и тогда лучше писать Must... обёртку, которая паникует).
  2. Попытка присвоить несколько значений в выражении, где это неожиданно: например, присваивание в теле if вне statement, a, b := if ..., неверно.

    • Исправление: использовать только допустимые конструкции, знать, что if statement может инициализировать переменные.

Возможные дополнительные вопросы интервьюера

  • Что будет, если функция возвращает значения, а при вызове мы игнорируем все? Ошибка компиляции: “multiple-value f() in single-value context”. Нужно хотя бы присвоить их переменным или использовать _.
  • Можно ли вернуть “голый return” с именованными результатами? Да, при наличии именованных параметров return без аргументов возвращает их текущие значения.

Почему Go поддерживает множественный возврат?

Краткий ответ (30–60 секунд)

Множественный возврат — осознанная дизайнерская замена исключений и out-параметрам. Он позволяет элегантно отделять результат от ошибки, сохраняя линейный поток управления. Это делает обработку ошибок явной и предсказуемой, без скрытых переходов. Кроме того, полезно для возврата нескольких связанных значений (например, координат) без создания лишних структур.

Развёрнутый ответ

Основные причины:

  • Явная обработка ошибок: возврат (result, error) — идиоматический паттерн. Программист обязан либо обработать ошибку, либо явно проигнорировать через _. Это повышает надёжность.
  • Отсутствие исключений: Go отказался от исключений, которые усложняют понимание потока управления. Множественный возврат даёт аналогичную информативность без неявной раскрутки стека.
  • Простота и минимализм: не нужны перегрузка функций или out-параметры (как в C#).
  • Самодокументирование: сигнатура функции сразу показывает, что она может вернуть ошибку.

Под капотом (internals)

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

Типичные ошибки кандидатов

  1. Злоупотребление множественным возвратом для возврата большого числа значений (более 3-4) — это ухудшает читаемость.

    • Исправление: если значений много, создать структуру или выделить отдельный тип.
  2. Перепутывание порядка значений при вызове: если не запомнить сигнатуру, можно случайно поменять местами result и error. Некоторые линтеры предупреждают, если ошибка игнорируется, но порядок менять нельзя.

    • Исправление: придерживаться конвенции: последнее возвращаемое значение — ошибка.

Возможные дополнительные вопросы интервьюера

  • Можно ли в Go перегружать функции с разным возвращаемым списком? Нет, перегрузки функций нет в принципе.
  • Как обрабатывать ситуацию, когда функция должна вернуть ошибку и частичный результат? Возвращать результат с ошибкой, документировать, что при non-nil ошибке результат может быть невалидным.

Что такое именованные возвращаемые значения?

Краткий ответ (30–60 секунд)

Именованные возвращаемые значения (named return values) — синтаксическая возможность Go задать имена для возвращаемых параметров функции. Они действуют как локальные переменные, инициализированные zero value. Их можно использовать в теле функции, и return без аргументов (“голый return”) вернёт их текущие значения. Повышает читаемость, когда имена хорошо описывают результат.

Развёрнутый ответ

Пример:

Go
func split(sum int) (x, y int) {
    x = sum * 4 / 9
    y = sum - x
    return // возвращает x, y
}

Применяется для:

  • самодокументирования того, что означает каждое возвращаемое значение;
  • уменьшения повторений в return при присваиваниях внутри функции;
  • возможности использовать defer для модификации возвращаемых значений (например, обработка ошибок).
    Но злоупотребление может запутать, особенно в длинных функциях, где трудно отследить, что именно возвращается.

Под капотом (internals)

Именованные результаты размещаются на стеке вызывающей функции, точно так же, как анонимные. Компилятор резервирует под них память, инициализирует нулями. Присваивания в теле транслируются в обычные инструкции записи. При return без параметров компилятор просто генерирует стандартный код возврата значений из этих переменных, никакой дополнительной магии. Если используется именованные, но return перечисляет значения явно, компилятор игнорирует имена и возвращает явные значения.

Типичные ошибки кандидатов

  1. Смешивание явного return с голым в одной функции: это сбивает с толку. Если часть путей возвращает явно, а часть голым, читатель теряется.

    • Исправление: либо везде использовать голый, либо везде явный, предпочтительно явный для ясности в длинных функциях.
  2. Изменение именованной возвращаемой переменной после назначения и до return: коварная ошибка, когда в ветвлении if забыли, что переменная модифицирована. Отладка усложняется.

    • Исправление: избегать длинных функций, где переменная живет долго, или использовать локальные переменные, а затем явный return.

Возможные дополнительные вопросы интервьюера

  • Как именованные возвращаемые значения взаимодействуют с defer? Дефер может изменить возвращаемые значения после выполнения тела функции, но до фактического возврата. Это позволяет реализовать шаблон обработки ошибок с логированием.
  • Будут ли именованные возвращаемые параметры захвачены замыканием? Да, они рассматриваются как локальные переменные; замыкание может их изменять, что повлияет на результат.

Что такое пустой идентификатор _?

Краткий ответ (30–60 секунд)

Пустой идентификатор _ — специальный анонимный placeholder, который можно использовать в объявлениях и присваиваниях для отбрасывания значений, когда синтаксис требует переменную, но значение не нужно. Он не создает настоящей переменной и не может быть прочитан. Помогает избежать ошибок неиспользуемых переменных и указывает намерение игнорировать значение.

Развёрнутый ответ

Основные сценарии применения:

  • Присваивание нескольких возвращаемых значений: _, err := someFunc().
  • В циклах for _, v := range slice для игнорирования индекса.
  • Подавление ошибки компиляции unused variable: _ = unusedVar.
  • При импорте _ "package" для запуска init() без использования экспорта.
  • В map: if _, ok := m[key]; ok { ... } для проверки наличия ключа.

Использование _ как переменной невозможно; компилятор не разрешит fmt.Println(_).

Под капотом (internals)

На уровне компилятора _ просто не генерирует аллокацию; он распознаётся как специальный идентификатор и не добавляется в таблицу символов. Соответствующий регистр или стековый слот не выделяется. В AST такие присваивания помечаются, но кодогенерация пропускает запись, связанную с _. Благодаря этому нет накладных расходов.

Типичные ошибки кандидатов

  1. Злоупотребление _ для игнорирования ошибок: data, _ := fetch() — классический маркер безответственного подхода. Такой код скрывает проблемы.

    • Исправление: всегда обрабатывать ошибку явно. Если необходимо быстрое прототипирование, помечать TODO.
  2. Попытка использовать _ справа от присваивания или в выражении: x := _ + 5 — синтаксическая ошибка. _ не является значением.

    • Исправление: понимать, что _ — только левосторонний приёмник.

Возможные дополнительные вопросы интервьюера

  • Чем _ отличается от использования настоящей переменной, которую потом не читают? _ не занимает память и не вызывает ошибку unused. С реальной переменной вы обязаны её прочитать (или заглушить _ =).
  • Можно ли объявить переменную с именем _? Да, можно объявить локальную переменную с именем _, но она будет затмевать настоящий пустой идентификатор и станет обычной переменной, которую нужно использовать. Это крайне плохая практика.

Когда можно использовать _?

Краткий ответ (30–60 секунд)

Использовать _ уместно во всех случаях, где значение требуется синтаксически, но логически не нужно: отбрасывание ошибки (с осторожностью), игнорирование индекса в range, пропуск неиспользуемого результата после явной заглушки, анонимный импорт, проверка наличия ключа в map. Главное правило — _ должен явно отражать осознанное игнорирование, а не лень.

Развёрнутый ответ

Конкретные допустимые ситуации:

  • Подавление неиспользуемой переменной (см. выше).
  • В range срез/карту, когда нужен только один из параметров.
  • При чтении из канала: _, ok := <-ch (хотя чаще _, ok не пишут, используют v, ok или просто проверку).
  • Импорт с побочными эффектами: import _ "net/http/pprof".
  • В тестах, когда результат функции не проверяется, но нужна его инициализация.
  • В многозначных присваиваниях, где некоторые значения не нужны.

Категорически нельзя использовать _ для игнорирования ошибок в production-коде без веской причины (например, в defer закрытии файла, где ошибку некуда вернуть, но тогда лучше defer func() { _ = f.Close() } с комментарием). Всегда лучше залогировать ошибку.

Под капотом (internals)

Технически идентификатор _ является частью языка с нулевой стоимостью. Компилятор не выделяет под него память и не генерирует машинный код, связанный с записью значения. В случае _, v := range, кодогенератор просто не загружает индекс в регистр.

Типичные ошибки кандидатов

  1. Использование _ вместо err в функции, возвращающей ошибку, без комментария. Это маскирует потенциально фатальный сбой.

    • Исправление: писать явную заглушку err := ...; _ = err с комментарием, почему ошибка безопасна, либо передавать в log.
  2. Путаница между _ и пустым селектором при импорте. Иногда пишут import . "something" (импорт с точкой), что засоряет пространство имён. _ для импорта — лучший выбор, когда нужен только init().

    • Исправление: используйте анонимный импорт, а не импорт с точкой.

Возможные дополнительные вопросы интервьюера

  • Что произойдёт, если в multi-value присваивании все левые имена — _? _, _ = f() — допустимо, все значения будут отброшены, ошибок не будет.
  • Можно ли использовать _ как имя переменной для последующего использования? Можно, но это будет обычная переменная с именем _, что почти всегда является ошибкой дизайна, потому что конфликтует с встроенным идентификатором.

Почему в Go нет исключений?

Краткий ответ (30–60 секунд)

Go осознанно исключил механизм исключений в пользу явной обработки ошибок через возвращаемые значения. Исключения усложняют статический анализ потока управления, скрывают возможные точки отказа и способствуют написанию небезопасного кода. Конструкция panic/recover существует только для действительно исключительных ситуаций, а обычные ошибки обрабатываются как значения.

Развёрнутый ответ

Роб Пайк аргументировал отказ от исключений тем, что:

  • Они делают поток управления нелинейным, трудно отследить все места, куда может перепрыгнуть исключение.
  • Зачастую приводят к игнорированию ошибок или громоздким catch-блокам.
  • Поощряют модель «пусть вышестоящий вызов разберётся», что плохо сказывается на устойчивости.
  • Явная обработка ошибки дисциплинирует разработчика — каждая ошибка видна в коде.

Альтернатива в Go — идиоматический паттерн value, err := doSomething(). Код становится линейным, и поток управления очевиден. Для фатальных ошибок (например, некорректное состояние программы) предусмотрен panic, который можно перехватить через recover, но это не замена исключениям, а инструмент для краха горутины или аварийного завершения.

Под капотом (internals)

panic немедленно останавливает выполнение текущей функции и начинает раскрутку стека, выполняя defer-функции. Во время раскрутки может быть вызван recover, который останавливает панику и возвращает контроль. Внутренне паника создаёт объект _panic в g (горутине), связанный список паник позволяет обрабатывать вложенные паники. Раскрутка стека происходит с помощью вспомогательных функций runtime.gopanic и runtime.deferreturn. В отличие от исключений, recover работает только внутри отложенного вызова, что локализует обработку.

Типичные ошибки кандидатов

  1. Использовать panic/recover как substitute исключений. Это антипаттерн: panic для обычных ошибок (например, файл не найден) делает код непредсказуемым и неидиоматичным.

    • Исправление: применять panic только для программистских ошибок (некорректный аргумент, нарушение инварианта), а бизнес-ошибки возвращать.
  2. Забывать, что recover работает только внутри отложенного вызова. if r := recover(); r != nil вне defer не сработает.

    • Исправление: всегда вызывать recover в теле defer-анонимной функции.

Возможные дополнительные вопросы интервьюера

  • Как обрабатывать ошибки в глубоких цепочках вызовов без исключений? Оборачивать ошибки с контекстом через fmt.Errorf("%w", err) и возвращать вверх, сохраняя семантику. Это даёт стек ошибок, сохраняя явность.
  • Можно ли остановить панику без recover? Нет, паника неизбежно завершит программу, если не перехвачена.

Что такое язык со сборщиком мусора (garbage collected)?

Краткий ответ (30–60 секунд)

Язык со сборщиком мусора автоматически освобождает память, которая больше не используется программой, избавляя разработчика от ручного free() или delete. Go имеет встроенный параллельный, триколорный mark-and-sweep сборщик мусора, работающий с низкими паузами (< 1 мс в актуальных версиях). Он освобождает разработчиков от большинства проблем управления памятью, сохраняя высокую производительность.

Развёрнутый ответ

GC устраняет целый класс ошибок: use-after-free, двойное освобождение, утечки памяти из-за забытого освобождения. В Go сборщик компактный, интегрирован в рантайм и планировщик. Он работает конкурентно с приложением, приостанавливая горутины только на коротких фазах (mark termination, sweeping).

Основные фазы GC в Go (по циклу):

  1. Mark setup — подготовка, включение write barrier.
  2. Concurrent marking — обход живых объектов, начатый с roots (стеков горутин, глобальных переменных).
  3. Mark termination — короткая остановка мира (STW) для завершения пометок.
  4. Sweeping — освобождение не помеченных объектов, выполняется конкурентно в фоне или при аллокациях.

Go GC не генерирует поколения (non-generational), поэтому может немного уступать JVM в пропускной способности при массовых короткоживущих объектах, но выигрывает в предсказуемости и простоте настройки.

Под капотом (internals)

Рантайм активирует GC автоматически при достижении порога выделенной памяти (GOGC, по умолчанию 100% рост кучи). Алгоритм — tricolor concurrent: объекты изначально белые, корни становятся серыми, затем рекурсивно сканируются, становясь чёрными. Барьер записи (write barrier) отслеживает изменения указателей во время конкурентного маркирования. В src/runtime/mgc.go реализована логика циклов. GPA (garbage collector pacer) рассчитывает, когда начинать следующий цикл, чтобы уложиться в целевое время паузы.

Типичные ошибки кандидатов

  1. Ручное вмешательство в GC (runtime.GC() или изменение GOGC без понимания). Вызывать GC вручную в проде — дурной тон, это нарушает пейсинг и может привести к резким паузам.

    • Исправление: довериться автоматическому управлению, настраивать GOGC только при профилировании и документации.
  2. Считать, что GC полностью решает проблему утечек памяти. Утечки возможны, если сохраняются ссылки на неиспользуемые объекты (например, глобальная map накапливает ключи). GC не освобождает, пока объект достижим.

    • Исправление: профилировать кучу (pprof), следить за растущей кучей, очищать ссылки явно.

Возможные дополнительные вопросы интервьюера

  • Как Go GC работает с системными стеками горутин? Стеки горутин — это корневые объекты для маркировки; GC сканирует их, находя живые указатели. При росте стека старый стек копируется, что учитывается в барьерах.
  • Что такое “write barrier” и зачем он нужен? Он предотвращает ситуацию, когда чёрный объект получает ссылку на непомеченный белый объект без возможности его найти. Барьер помечает целевой объект серым.

Что такое семантика значений (value semantics)?

Краткий ответ (30–60 секунд)

Семантика значений означает, что при присваивании, передаче в функцию или возврате из функции создаётся полная копия значения. Изменение копии не влияет на оригинал. В Go все типы, кроме указателей, срезов, карт, каналов и интерфейсов, обладают семантикой значений. Это включает числа, строки, структуры, массивы. Понимание value semantics критично для написания безопасного многопоточного кода.

Развёрнутый ответ

Принцип value semantics упрощает рассуждения о коде: у каждой переменной своя независимая копия данных. Даже передача указателя — это передача значения указателя (копия адреса). Сама структура копируется при присваивании:

Go
type Point struct { X, Y int }
p1 := Point{1, 2}
p2 := p1
p2.X = 100
fmt.Println(p1.X) // 1

Это отличается от reference semantics (как в Java для объектов), где переменные ссылаются на один экземпляр. В Go, чтобы работать с одним экземпляром, используют указатели или ссылочные типы (map, slice). Срезы — это структура {ptr, len, cap}, передаётся копией, но базовый массив разделяется, что создаёт гибридное поведение.

Под капотом (internals)

Копирование структур транслируется в команды memmove или по-членное копирование, в зависимости от размера. Компилятор может оптимизировать копирование, если видит, что исходная переменная после этого не используется. Escape analysis решает, где размещать переменную — стек или кучу, и влияет на стоимость копий. Большие структуры, передаваемые по значению, могут дорого обходиться, так как вся память копируется, но компилятор иногда заменяет передачу на указатель внутри, если это безопасно (не всегда, только в определённых условиях, например, для строк). Важно: всегда копируется заголовок среза, а не массив.

Типичные ошибки кандидатов

  1. Попытка изменить элементы массива внутри функции, передав массив по значению: функция получает копию массива, изменения теряются.

    • Исправление: передавать указатель на массив или (предпочтительнее) использовать срез.
  2. Полагание, что структура со срезом при копировании создаёт глубокую копию среза. Копируется заголовок среза, и оба экземпляра разделяют один базовый массив.

    • Исправление: при необходимости глубокой копии среза, делать copy в новый срез.

Возможные дополнительные вопросы интервьюера

  • Как value semantics влияет на многопоточность? Отсутствие разделяемого изменяемого состояния, если строго следовать копиям, устраняет гонки. Поэтому в Go поощряется передача копий или использование каналов для обмена значениями.
  • Можно ли реализовать reference semantics для своих типов? Можно использовать указатели (*T), но сами ссылки (как в C#) не предусмотрены дизайном языка.

Что такое передача по значению (pass by value)?

Краткий ответ (30–60 секунд)

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

Развёрнутый ответ

Go
func increment(x int) {
    x++
    fmt.Println("inside", x)
}
a := 1
increment(a) // inside: 2
fmt.Println(a) // 1

Для модификации внешней переменной нужно передать указатель:

Go
func increment2(x *int) {
    *x++
}
increment2(&a)

Типы map, slice, chan ведут себя как ссылки, но на самом деле передаётся копия дескриптора (указателя на внутреннюю структуру). Поэтому изменения в map внутри функции видны снаружи, но присваивание нового map параметру — нет.

Под капотом (internals)

Каждый параметр функции в Go копируется в регистры или стек вызываемой функции. Для больших структур (например, массива из 1000 элементов) это может быть дорого. Компилятор иногда может оптимизировать, подставляя значение, но в целом передача большой структуры по значению менее эффективна, чем по указателю. ABI на amd64 передаёт аргументы в регистрах, начиная с AX, BX и т.д., параметры-структуры могут передаваться целиком через стек, если не влезают в регистры.

Типичные ошибки кандидатов

  1. Ожидание, что изменение среза внутри функции обновит исходный срез при присваивании append. append может вернуть новый срез, если произошло выделение нового массива; исходный срез вне функции останется без изменений длины и ёмкости.

    Go
    func add(s []int) {
        s = append(s, 10)
    }
    
    • Исправление: либо возвращать новый срез, либо передавать указатель на срез (*[]int), но лучше возвращать.
  2. Передача указателя на большой массив “для эффективности” вместо среза. Массив фиксированного размера дорого копировать, но и *[1e6]int — усложняет синтаксис, лучше использовать срез.

    • Исправление: использовать слайсы вместо массивов для аргументов, они уже содержат ссылку на массив.

Возможные дополнительные вопросы интервьюера

  • Является ли передача указателя “передачей по ссылке”? Нет, в Go передаётся копия указателя, и нельзя заменить оригинальный указатель вызывающей стороны (если не использовать **int). Это всё равно pass by value.
  • Можно ли изменить строку, переданную в функцию? Строки неизменяемы, поэтому даже передача указателя на строку не позволит изменить байты (строка — структура {ptr, len}).

Есть ли в Go передача по ссылке (pass by reference)?

Краткий ответ (30–60 секунд)

В Go нет истинной передачи по ссылке в смысле возможности перепривязать переменную вызывающей стороны. Все аргументы передаются по значению: либо само значение, либо значение указателя. Go не поддерживает синтаксиса ссылочных параметров, как в C++ (int& x). Тем не менее, передача указателя позволяет изменять содержимое по адресу, но сам указатель копируется. Карты, срезы, каналы — это тоже значения, содержащие указатели на внутренние структуры.

Развёрнутый ответ

Пример, демонстрирующий отсутствие true pass-by-reference:

Go
func reassign(p *int) {
    old := p
    p = new(int) // p теперь указывает на новый int
    *p = 100
    // old всё ещё указывает на старую переменную вне функции.
}

Если бы p передавался по ссылке, присваивание p = ... изменило бы переменную-указатель снаружи. Этого не происходит. Чтобы изменить сам указатель, нужно передать двойной указатель **int. Поэтому говорят, что Go — “call by value always”. Map и chan — тоже значения дескрипторов, и их присваивание новому экземпляру не влияет на внешний мир.

Под капотом (internals)

В рантайме передача аргументов реализована копированием: все параметры помещаются на стек/регистры вызываемой функции. Указатель — просто целочисленный адрес, который копируется. Никакого скрытого механизма ссылок нет. Это упрощает escape analysis и генерацию кода, поскольку каждое использование параметра не может изменить лексическую переменную родителя, кроме как через разыменование.

Типичные ошибки кандидатов

  1. Утверждение, что map, chan, slice передаются по ссылке. Хотя изменения внутри видны снаружи, это следствие копирования заголовка с внутренним указателем, а не истинной ссылочной семантики. Присваивание новой map параметру не обновит внешнюю переменную.

    • Исправление: точно формулировать: “map передаётся как копия указателя на структуру данных, поэтому изменения в содержимом видны, но присваивание нового map — нет”.
  2. Попытка написать функцию swap(x, y int), меняющую местами значения, без указателей. Не сработает.

    • Исправление: использовать func swap(a, b *int) { *a, *b = *b, *a }.

Возможные дополнительные вопросы интервьюера

  • Как бы вы реализовали операцию swap для любых типов без указателей? Никак, нужны указатели или дженерики с указателями. Значения копируются, изменить оригиналы невозможно.
  • Почему авторы Go выбрали только pass by value? Это снижает когнитивную сложность: всегда ясно, что функция работает с копией или требует указателя для изменений. Нет скрытых сайд-эффектов.

Что такое iota?

Краткий ответ (30–60 секунд)

iota — предопределённый идентификатор, используемый внутри блока объявления констант для генерации последовательных целых чисел, начиная с 0 в каждом новом const-блоке и увеличиваясь на 1 с каждой новой спецификацией константы. Позволяет элегантно создавать перечисления и битовые маски без ручного присваивания.

Развёрнутый ответ

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

Go
const (
    A = iota // 0
    B        // 1
    C        // 2
)

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

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

iota сбрасывается для каждого нового const-блока, независимо от имени. Можно смешивать с другими константами, но iota увеличивается для каждой строки внутри блока, даже если в строке нет iota явно. Продвинутый трюк с повторением последнего выражения: при отсутствии выражения на строке компилятор повторяет последнее выражение из предыдущей строки, и iota продолжает наращиваться.

Под капотом (internals)

iota существует только во время компиляции. Компилятор в процессе обработки const-блока поддерживает счётчик iota, начиная с 0. При переходе к следующей строке счётчик инкрементируется. Когда встречается идентификатор iota, он заменяется на текущее значение счётчика и вычисляется как константное выражение. Это чисто синтаксический сахар, нет никакого рантаймового представления. Благодаря этому генерируются простые константы в объектном файле.

Типичные ошибки кандидатов

  1. Предположение, что iota продолжает наращиваться вне блока. Снаружи iota не определён, каждый block scope имеет свой счётчик.

    • Исправление: держать связанные константы в одном const-блоке.
  2. Забыть, что пустые строки копируют предыдущее выражение и увеличивают iota. Это может привести к неожиданному значению:

    Go
    const (
        X = iota * 2
        Y
        Z
    )
    // X=0, Y=2, Z=4
    
    • Исправление: явно указывать выражения, когда нужен контроль, либо документировать.

Возможные дополнительные вопросы интервьюера

  • Как iota взаимодействует с несколькими константами в одной строке через запятую? Каждая спецификация константы в строке — это один экземпляр iota; он увеличивается на 1 для каждой строки, а не для каждого имени. Пример: A, B = iota, iota + 1 даст A=0, B=1 на первой строке, затем на следующей строке iota станет 1 и т.д.
  • Можно ли сбросить iota внутри блока? Нет, сброс происходит только с началом нового блока const.

Что такое const?

Краткий ответ (30–60 секунд)

const в Go объявляет константные значения, которые должны быть известны на этапе компиляции. Константами могут быть булевы, числовые и строковые значения, но не срезы, карты или структуры. Константы типизированы или нетипизированы, и их значение должно вычисляться без побочных эффектов. Использование const гарантирует неизменяемость и позволяет компилятору применять оптимизации.

Развёрнутый ответ

Go
const Pi = 3.14159          // нетипизированная
const Two int = 2           // типизированная
const (
    flag = 1 << iota
    flag2
)

Правила:

  • Значением может быть только: число, строка, булево, или выражение из констант.
  • Нельзя присвоить результат функции (даже чистой, такой как len("abc")). Однако len от строкового литерала — константа, но вызов функции len в общем случае — нет. (Встроенная функция len с константной строкой/массивом вычисляется компилятором, но не в общем случае.)
  • Константы существуют только на этапе компиляции, не имеют адреса, не выделяют память.

Под капотом (internals)

Компилятор обрабатывает константы в src/go/constant и src/cmd/compile/internal/typecheck. Константные выражения вычисляются с произвольной точностью с помощью math/big. Затем, при присваивании переменной или использовании, значение преобразуется в целевую разрядность с проверкой переполнения. Нетипизированные константы имеют “default type” (например, int для целых), который используется, если тип не выведен явно. Константы не порождают машинного кода сами по себе — они встраиваются в места использования как непосредственные операнды.

Типичные ошибки кандидатов

  1. Попытка создать константную структуру или массив: const p = Point{1,2} — ошибка. Только примитивные типы разрешены.

    • Исправление: использовать var для структур, но для настоящей неизменяемости нужно делать поля неэкспортируемыми и предоставлять getter.
  2. Конфуз с нетипизированной константой в операциях с typed переменными: var x float64 = 5; y := x / 2 — 2 будет приведено к float64, ок. Но const two int = 2; y := x / two — ошибка, потому что two имеет тип int, и требуется явное преобразование.

    • Исправление: либо делать константу нетипизированной (const two = 2), либо приводить тип.

Возможные дополнительные вопросы интервьюера

  • Почему iota работает только с const? Потому что iota генерирует константные значения на этапе компиляции, это часть механизма перечислений только для const. В var нет смысла.
  • Можно ли взять адрес константы? Нет, адреса не существует. Но если константа присвоена переменной, можно взять адрес переменной.

Чем отличаются типизированные и нетипизированные константы?

Краткий ответ (30–60 секунд)

Нетипизированная константа не имеет фиксированного типа до её использования в контексте, требующем конкретного типа. Она обладает “идеальной” точностью и может быть неявно приведена к совместимому типу, если значение представимо. Типизированная же константа объявляется с явным типом и ведёт себя как переменная этого типа, требуя явного приведения при присваивании другому типу.

Развёрнутый ответ

Пример:

Go
const untyped = 42          // нетипизированная, default int
const typed int64 = 42      // типизированная int64

var i int = untyped         // OK, 42 помещается в int
var j int64 = untyped       // OK
var k float64 = untyped     // OK
var l int = typed           // ошибка: несовместимые типы int и int64

Нетипизированные константы позволяют гибко работать без явного приведения, до тех пор пока значение вписывается в диапазон целевого типа. Они бывают разной “киндности”: булевы, числовые (целые, дробные, комплексные), строковые. У каждой есть свой дефолтный тип для оператора :=.

Типизированные константы закрепляют тип и защищают от случайного использования в неожиданном контексте.

Под капотом (internals)

В компиляторе нетипизированные константы хранятся как constant.Value с произвольной точностью (int/float/string/complex). При присваивании переменной или передаче в функцию компилятор проверяет, может ли значение быть точно представлено в целевом типе, если да — преобразует. Типизированные константы хранятся с известным типом и точностью; они сразу ограничены диапазоном. Нетипизированная 64 на деле — точное значение “64” с бесконечной точностью, и при преобразовании в int8 будет проверено, что 64 < 128, иначе ошибка компиляции.

Типичные ошибки кандидатов

  1. Использование типизированных констант для общих величин типа MaxUint, что вынуждает писать явные приведения при использовании. Лучше оставить нетипизированной: const MaxUint = ^uint(0) — нетипизированная, и будет подходить и к int, и к uint после приведения.

    • Исправление: осознанно выбирать: типизировать, только если требуется фиксация типа для предотвращения ошибок, иначе оставить нетипизированной.
  2. Мнение, что нетипизированная константа имеет тип int по умолчанию всегда. Нет, дефолтный тип зависит от “kind”: целые → int, дробные → float64, комплексные → complex128, строковые → string. В := используется дефолтный тип.

    • Исправление: помнить про дефолтные типы, при необходимости уточнять.

Возможные дополнительные вопросы интервьюера

  • Что произойдёт при присвоении нетипизированной константы 300 типу int8? Ошибка компиляции: constant 300 overflows int8, потому что 300 > 127.
  • Можно ли объявить нетипизированную константу с типом? Нет, если стоит тип, она типизированная. const x int = 5 — типизированная.

Что такое приведение типов (type conversion)?

Краткий ответ (30–60 секунд)

Приведение типов в Go — явная операция T(expr), преобразующая значение выражения expr в тип T, если это разрешено спецификацией. В отличие от C, Go не делает неявных преобразований, даже безопасных (int -> int64). Разрешены конвертации между совместимыми базовыми типами, между типами с одинаковой структурой в некоторых случаях, а также между строкой и срезом байт/рун.

Развёрнутый ответ

Допустимые конвертации:

  • Числовые типы: int64(42).
  • Строка ↔ срез байт: []byte("abc"), string(byteSlice).
  • Строка ↔ срез рун: []rune(s), string(runeSlice).
  • Между именованными типами, имеющими идентичный базовый тип: type MyInt int; var i int = int(myInt).
  • Указатели на совместимые типы в unsafe пакете (unsafe).
  • Интерфейсы через утверждение типа (type assertion, но это другое).

Приведение может менять представление в памяти (int64 ↔ float64) или просто переинтерпретировать (int32 ↔ int64). Неправильное приведение, выходящее за границы (например, int64 -> int8, значение 1000), компилятор разрешит (в отличие от констант), но произойдёт усечение по модулю.

Под капотом (internals)

Арифметические конвертации генерируют соответствующие инструкции: для расширения — знаковое/беззнаковое расширение, для сужения — truncation. Конвертация между int и float порождает вызовы рантаймовых функций (runtime.int64tofloat64 и т.п.), так как требуют проверок на переполнение и точное преобразование. Конвертация string ↔ []byte вызывает runtime.stringtoslicebyte / runtime.slicebytetostring с копированием. Компилятор может оптимизировать некоторые конвертации, если видит, что исходный слайс не изменится.

Типичные ошибки кандидатов

  1. Ожидание неявного приведения, например, float64 к int в арифметике. var f float64 = 3.14; i := f — ошибка. Нужно явно: i := int(f).

    • Исправление: всегда использовать явное преобразование, осознавая потерю точности.
  2. Использование string(i) для целого числа, expecting to get string representation. string(65) даёт “A” (rune), а не “65”. Это частая путаница.

    • Исправление: для конвертации числа в строку используйте strconv.Itoa или fmt.Sprintf.

Возможные дополнительные вопросы интервьюера

  • Чем type conversion отличается от type assertion? Conversion меняет конкретный тип значения, assertion извлекает динамический тип из интерфейса. Conversion работает с конкретными типами, assertion — с интерфейсными.
  • Безопасно ли приведение []byte к string без копирования через unsafe? Технически возможно через unsafe.SliceData, но нарушает неизменяемость string, и изменение исходного слайса изменит строку, что ведёт к undefined behavior. Не делайте этого.

Что такое утверждение типа (type assertion)?

Краткий ответ (30–60 секунд)

Type assertion — операция x.(T), где x — интерфейс, а T — конкретный тип или другой интерфейс. Она проверяет, что динамический тип значения x совместим с T, и возвращает значение этого типа. Если проверка проваливается, то без второй переменной (comma ok) происходит паника; с ней — вернётся false, и значение будет zero value.

Развёрнутый ответ

Синтаксис:

Go
var i interface{} = "hello"
s := i.(string)       // вернёт "hello", если ок, иначе panic
s, ok := i.(string)   // безопасный вариант, ok = true

Если T — интерфейс, то проверяется, реализует ли динамический тип данный интерфейс. Это позволяет “расширять” поведение.
Type assertion работает только с интерфейсами; для конкретных типов используйте type conversion.

Под капотом (internals)

Интерфейсное значение состоит из двух указатель: на таблицу типа (itab) и на данные. При x.(T) рантайм проверяет, что itab соответствует T (или его подтипу). Для конкретного типа T выполняется сравнение типа из itab с T (используется runtime.typeeq или подобное). Если не совпадает, происходит паника (или возврат false). В случае интерфейса проверка сложнее: сравниваются методы. Результатом является копия данных, в случае указателей — копия указателя. Компилятор вставляет вызовы runtime.assertI2I, runtime.assertE2I и т.д.

Типичные ошибки кандидатов

  1. Забвение второй переменной и паника в проде. value := i.(string) без ok приводит к панике, если там не строка. Это критично.

    • Исправление: всегда использовать comma ok, если тип не гарантирован, либо оборачивать в switch v := i.(type).
  2. Попытка утвердить тип на nil-интерфейсе: паника, потому что nil-интерфейс не имеет динамического типа.

    • Исправление: проверять на nil до утверждения: if i != nil { v, ok := i.(T) }.

Возможные дополнительные вопросы интервьюера

  • В чём отличие от type conversion? Conversion требует знания точного типа на момент компиляции и совместимости, assertion работает в рантайме с интерфейсами.
  • Можно ли привести интерфейс к интерфейсу через type assertion? Да, например var r io.Reader; w, ok := r.(io.Writer) — проверит, реализует ли динамический тип интерфейс Writer.

Что такое переключатель типа (type switch)?

Краткий ответ (30–60 секунд)

Type switch — конструкция switch x := v.(type), позволяющая осуществить множественную проверку утверждения типа для интерфейсной переменной v. Каждый case проверяет динамический тип, и ветка выполняется с приведённым значением x соответствующего типа. Позволяет элегантно обрабатывать полиморфное поведение без цепочки if-assertion.

Развёрнутый ответ

Синтаксис:

Go
var i interface{} = 42
switch v := i.(type) {
case int:
    fmt.Println("int:", v) // v — int
case string:
    fmt.Println("string:", v) // v — string
default:
    fmt.Printf("unknown type %T\n", v)
}

Можно объявить switch i.(type) без переменной, тогда в case доступно значение того же типа, но без нового имени (используется i). Case можно перечислять типы через запятую. Тип в case может быть интерфейсом, тогда проверяется его удовлетворение. Ветка nil срабатывает, если интерфейс равен nil.

Под капотом (internals)

Type switch транслируется в последовательность вызовов рантаймовых функций сравнения itab. Компилятор строит конструкцию, аналогичную if v, ok := i.(T1); ok { ... } else if .... В сгенерированном коде последовательно проверяется тип, и при совпадении извлекается значение. Это чисто компиляторная магия, никаких специальных инструкций процессора.

Типичные ошибки кандидатов

  1. Использование fallthrough в type switch. fallthrough не разрешён в type switch, так как каждая ветка имеет разный тип.

    • Исправление: для перехода к следующей ветке нужно вынести общую логику за switch.
  2. Отсутствие nil case, что может привести к пропуску обработки нетипизированного nil. Если интерфейс nil, все case проваливаются и попадают в default, если он есть. Без default — просто ничего не выполняется, что может быть багом.

    • Исправление: добавлять case nil:, если nil-интерфейс возможен.

Возможные дополнительные вопросы интервьюера

  • Как type switch работает с дженериками и интерфейсами, имеющими типовые параметры? Можно использовать type switch на интерфейсе, но типовые параметры в case не разрешены. Только конкретные типы.
  • Можно ли смешивать type switch с обычными выражениями? Нет, switch v := i.(type) использует специальную форму, нельзя добавить ещё и значения.

Как работает for цикл в Go?

Краткий ответ (30–60 секунд)

Цикл for — единственная конструкция цикла в Go. Он может принимать форму классического for init; condition; post, форму for condition (аналог while), и for без параметров как бесконечный цикл. Кроме того, существует for range для итерации по массивам, срезам, мапам, строкам, каналам. Все формы управляются блочной областью видимости и поддерживают break/continue.

Развёрнутый ответ

Формы:

Go
// классический
for i := 0; i < 10; i++ {
    ...
}
// while-like
for x > 0 { ... }
// бесконечный
for {
    ...
}
// range по срезу
for i, v := range slice { ... }
// range по map
for k, v := range m { ... }
// range по каналу
for v := range ch { ... }
// range по строке
for i, r := range s { ... }

for range на массиве/срезе возвращает индекс и копию элемента (в Go до 1.22 была проблема захвата переменной, теперь v создаётся на каждой итерации, начиная с Go 1.22). Для map порядок итерации недетерминирован. В каналы цикл читает до его закрытия. Операторы break с меткой позволяют выйти из вложенных циклов.

Под капотом (internals)

Классический for транслируется в условные и безусловные переходы. range по срезу или массиву превращается в обычный цикл с индексацией и проверкой границ; компилятор может исключить проверки границ, если докажет их безопасность. Для map генерируется итератор с вызовом runtime.mapiternext, который ходит по bucket-ам с учётом рандомизации. Для каналов — блокирующие операции runtime.chanrecv. Важной особенностью с версии Go 1.22 является то, что переменные цикла в range создаются заново на каждой итерации, что исключает классические баги с замыканиями.

Типичные ошибки кандидатов

  1. Захват переменной цикла до Go 1.22: в замыканиях или горутинах v имела значение последней итерации. Это приводило к багам.

    • Исправление: на старых версиях нужно передавать v := v внутри тела цикла. С 1.22 проблема исправлена.
  2. Попытка изменять мапу во время итерации. Спецификация гласит, что можно удалять элементы, но добавление может привести к непредсказуемому поведению и даже панике.

    • Исправление: собирать ключи для добавления в отдельный срез и добавлять после цикла.

Возможные дополнительные вопросы интервьюера

  • Почему for range строки даёт индексы байт, а не рун? Индексы — это стартовый байт каждой руны, что полезно для срезов и работы с UTF-8.
  • Можно ли выйти из внешнего цикла с помощью break без метки? Нет, только с меткой, иначе break выйдет из самого внутреннего цикла.

Какие есть формы for цикла?

Краткий ответ (30–60 секунд)

Go поддерживает четыре формы цикла: классический for init; cond; post, цикл-условие for cond (while), бесконечный for {}, и for range для итерации по коллекциям и каналам. Все они одинаково эффективны и выбираются под задачу.

Развёрнутый ответ

Детальнее:

  1. Классический:

    Go
    for i := 0; i < N; i++ { ... }
    
  2. Условный (аналог while):

    Go
    for err == nil { ... }
    
  3. Бесконечный: for { ... } с явным выходом через break/return.

  4. Range:
    • Срез/массив: for i, elem := range arr {}
    • Строка: for pos, runeValue := range str {}
    • Map: for key, value := range m {}
    • Канал: for v := range ch {}
      Можно опустить индекс или значение, используя _.

Под капотом (internals)

Все формы реализуются одним и тем же внутренним IR-узлом OFOR. range транслируется в ряд инструкций с вызовом итераторных функций. Для срезов используется инкремент индекса и проверка границ, для map — вызов runtime.mapiterinit, runtime.mapiternext. С версии Go 1.22 for range генерирует код с инициализацией новой переменной на каждой итерации (ранее переменная была одной, что изменяется в ранних версиях).

Типичные ошибки кандидатов

  1. Использование for range для копирования среза без учёта, что значение — копия элемента (value copy). Если нужно изменить оригинал, требуется индекс и присвоение по индексу.

    • Исправление: for i := range slice { slice[i] = newVal }.
  2. Ожидание, что порядок итерации по map детерминирован. Go специально рандомизирует порядок, чтобы не полагались на него.

    • Исправление: сортировать ключи перед итерацией, если нужен порядок.

Возможные дополнительные вопросы интервьюера

  • Можно ли объявить несколько переменных в init классического for? Да, через запятую, но только если все одинакового типа? Нет, можно использовать parallel assignment: for i, j := 0, 10; i < j; i, j = i+1, j-1 {}, работает.
  • Что такое range-over-func в Go 1.23? Это новый экспериментальный итератор (раньше как iter.Seq), позволяющий пользовательским типам поддерживать range.

Как работает if, else, switch?

Краткий ответ (30–60 секунд)

if в Go может включать необязательный statement (простой оператор) перед условием, разделяемый точкой с запятой; else и else if добавляют ветвления. switch вычисляет выражение и сравнивает со значениями case, поддерживает пропуск проверки (switch без выражения — эквивалент true), а также type switch. switch не проваливается в следующий case без явного fallthrough, что устраняет распространённую ошибку.

Развёрнутый ответ

Go
if x := compute(); x < 0 {
    ...
} else if x == 0 {
    ...
} else {
    ...
}

Переменная x видна во всех ветках if-else.

Switch expressions:

Go
switch day {
case 1:
    fmt.Println("Monday")
default:
    ...
}

Возможен switch без тега:

Go
switch {
case x > 0: ...
}

Ветки могут объединяться через запятую. fallthrough переносит выполнение на следующий case без проверки условия (редко используется). Для type switch используется специальная форма.

Под капотом (internals)

if транслируется в условные переходы. switch с малым числом вариантов превращается в цепочку if-else, с большим количеством целочисленных case компилятор может построить таблицу переходов (jump table) для эффективности. Проверки case вычисляются по порядку сверху вниз. switch v := x.(type) обрабатывается последовательностью проверок itab.

Типичные ошибки кандидатов

  1. Случайный провал в switch из-за забытого break (как в C)? В Go провала нет по умолчанию, так что ошибка скорее в противоположном — не ожидают, что fallthrough единственный способ провалиться.

    • Исправление: если нужен провал, явно писать fallthrough, но обычно лучше избегать.
  2. Использование if с инициализацией без учёта области видимости. Переменная, объявленная в statement, не видна за пределами if-else цепочки, что путает новичков.

    • Исправление: если нужна за пределами, объявлять до if.

Возможные дополнительные вопросы интервьюера

  • Можно ли в switch сравнивать с интерфейсами? Да, case значения могут быть интерфейсного типа, тогда выполняется сравнение через == на равенство динамических значений.
  • Как switch без выражения сравнивает case? Он по сути switch true, каждый case должен быть булевым выражением.

Что такое goto и почему его не стоит использовать?

Краткий ответ (30–60 секунд)

goto — инструкция безусловного перехода на метку в пределах той же функции. Хотя она сохранилась в языке для редких низкоуровневых сценариев (например, оптимизированная обработка ошибок в C-стиле), её применение в Go считается вредным, так как нарушает структурированность, делает код трудночитаемым и усложняет анализ потока управления. Почти всегда лучше использовать if, циклы и defer.

Развёрнутый ответ

Пример допустимого, но спорного использования goto — выход из глубоко вложенного цикла:

Go
for i := 0; i < n; i++ {
    for j := 0; j < m; j++ {
        if condition {
            goto cleanup
        }
    }
}
cleanup:
    doCleanup()

Но это можно заменить на break с меткой, что гораздо понятнее. goto может перепрыгивать через объявления переменных (с ограничениями), создавая неинициализированные состояния. Спецификация запрещает переход через объявление переменной (если она создаётся с :=) — ошибка компиляции. Тем не менее, код с goto сложно поддерживать и дебажить, он не соответствует принципам ясности Go.

Под капотом (internals)

goto реализуется тривиальной инструкцией JMP на уровне ассемблера. С точки зрения компилятора, это усложняет построение SSA и анализ доминирования переменных. Наличие goto может отключать некоторые оптимизации, делая код медленнее. Метки и goto разрешены исключительно в одной функции, прыжки между функциями невозможны.

Типичные ошибки кандидатов

  1. Попытка эмулировать исключения или сложный control flow через goto. Вместо goto error для обработки ошибок следует использовать идиоматический if err != nil { return err } или defer.

    • Исправление: применять структурированное программирование, разделять функции для уменьшения вложенности.
  2. Переход через инициализацию переменной: ошибка компиляции, но некоторые кандидаты не знают правила и удивляются.

    • Исправление: избегать goto, а если приходится — тщательно следить за областями видимости переменных.

Возможные дополнительные вопросы интервьюера

  • Где goto может быть оправдан? В очень ограниченных случаях, например, в сгенерированном коде или при реализации конечных автоматов. Но и там часто можно обойтись switch.
  • Чем break label лучше goto? break label выходит из помеченного цикла, оставаясь в рамках структурированных конструкций, что понятнее.

Что такое fallthrough в switch?

Краткий ответ (30–60 секунд)

fallthrough — ключевое слово, заставляющее выполнение перейти к следующему блоку case в switch-инструкции без проверки его условия. Оно применяется, когда нужно выполнить несколько последовательных case подряд, но в Go проваливание не является поведением по умолчанию. fallthrough должно быть последним оператором в case, и не допускается в type switch.

Развёрнутый ответ

Go
switch num {
case 1:
    fmt.Println("one")
    fallthrough
case 2:
    fmt.Println("two") // выполнится и если num==1, и если num==2
default:
    fmt.Println("other")
}

Здесь при num=1 будет выведено “one” и “two”. Это полезно для логики, где несколько меток должны выполнить общий код. Но часто лучше использовать перечисление через запятую в case: case 1, 2: .... fallthrough не может идти после чего-то ещё, кроме как последним, и не может пропускать несколько case подряд (только следующий).

Под капотом (internals)

Внутренне fallthrough — это инструкция безусловного перехода на метку начала следующего case. Компилятор при генерации кода видит fallthrough и не вставляет проверку условия перед следующим блоком. Оптимизатор может объединить последовательные case с fallthrough в один блок.

Типичные ошибки кандидатов

  1. Ожидание провала по умолчанию, как в C. Пишут несколько case без fallthrough, думая, что все выполнятся.

    • Исправление: помнить, что Go автоматически завершает case; если нужен провал — использовать fallthrough.
  2. Использование fallthrough в type switch. Это запрещено спецификацией, так как типы разные и несовместимы.

    • Исправление: вынести общий код в функцию или обработчик.

Возможные дополнительные вопросы интервьюера

  • Что будет, если после fallthrough в следующем case объявить переменную, и она перекроет что-то? Переменные в разных case имеют разные области видимости, проблем не будет, но код станет запутанным.
  • Есть ли альтернатива fallthrough? Да, можно просто перечислить значения через запятую в одном case, что почти всегда предпочтительнее.

Как работает label и break с меткой?

Краткий ответ (30–60 секунд)

Метка — это идентификатор, за которым следует двоеточие, размещённый перед оператором (for, switch, select). break с меткой завершает выполнение помеченного оператора, а не самого внутреннего. Это используется для выхода из вложенных циклов или switch. continue с меткой переходит на следующую итерацию помеченного цикла. goto с меткой передаёт управление в любую точку функции (с ограничениями).

Развёрнутый ответ

Go
outerLoop:
for i := 0; i < n; i++ {
    for j := 0; j < m; j++ {
        if someCondition {
            break outerLoop
        }
    }
}

Метка должна быть объявлена на уровне блочного оператора, который охватывает break. Нельзя использовать break с меткой, объявленной вне охватывающей конструкции. Метки имеют собственную область видимости функции, не конфликтуя с переменными.

Под капотом (internals)

break label транслируется в безусловный переход на инструкцию сразу после завершения помеченного блока. Компилятор отслеживает, какой именно цикл или switch нужно завершить, и генерирует соответствующий переход. При этом может потребоваться очистка стека, закрытие деферов в рамках этого блока (если есть). defer в циклах с прерыванием добавляет сложности, но метка не влияет напрямую.

Типичные ошибки кандидатов

  1. Постановка метки на неверный оператор. Например, метка перед if не является допустимой целью для break. Ошибка компиляции.

    • Исправление: вешать метку только на for, switch, select.
  2. Путаница между break label и goto label: goto может переходить куда угодно в функции, break — только выход из внешнего блока. Использование goto для выхода из цикла считается дурным тоном.

    • Исправление: использовать break label, когда логически нужно выйти из цикла, а не перемещаться.

Возможные дополнительные вопросы интервьюера

  • Можно ли continue с меткой? Да, continue label начинает следующую итерацию помеченного цикла.
  • Как defer взаимодействует с break label? Defer-функции, зарегистрированные внутри блока, выполняются при любом выходе (включая break), как обычно.

Что такое комментарии и зачем нужен godoc?

Краткий ответ (30–60 секунд)

Комментарии в Go — строки, начинающиеся с // или заключённые в /* */. Они служат не только пояснением, но и частью документации: godoc (теперь go doc) извлекает комментарии, предшествующие экспортируемым объявлениям, и формирует документацию пакета. Хорошие комментарии — неотъемлемый элемент качественного Go-кода.

Развёрнутый ответ

Стиль документирующих комментариев:

  • Комментарий к пакету должен быть в одном из файлов пакета перед package ....
  • Комментарий к экспортируемой функции/типу/переменной начинается с имени этой сущности.
  • Генерируемая документация видна через go doc pkg.Name или онлайн в pkg.go.dev.

godoc извлекает эти специальные комментарии и конвертирует в HTML/текст. Это обеспечивает единообразие документации и стимулирует разработчиков писать понятные описания.

Под капотом (internals)

Инструмент go doc парсит исходный код, строит AST и извлекает документационные комментарии, связанные с узлами. Пакет go/doc предоставляет API для этого. Генератор pkgsite использует те же механизмы для отображения на pkg.go.dev. Компилятор игнорирует комментарии, они не влияют на сборку.

Типичные ошибки кандидатов

  1. Писать комментарий к функции не с заглавной буквы или без её имени: // compute area. Правильно: // Area computes the area of the circle. Иначе godoc не свяжет.

    • Исправление: всегда начинать с имени экспортируемой сущности, завершать точкой.
  2. Отсутствие комментария пакета, из-за чего документация выглядит пусто. Команды go doc выводят только общую информацию, а без пакетного комментария теряется контекст.

    • Исправление: добавлять поясняющий комментарий к пакету, описывающий его назначение.

Возможные дополнительные вопросы интервьюера

  • Как сгенерировать локальную документацию в браузере? godoc -http=:6060 (устарело, сейчас go run golang.org/x/tools/cmd/godoc@latest -http=:6060). pkgsite рекомендован.
  • Можно ли документировать неэкспортируемые функции? Да, но godoc их по умолчанию не показывает. Документировать можно для разработчиков пакета.

Как форматировать код с помощью go fmt?

Краткий ответ (30–60 секунд)

go fmt (или gofmt) — утилита автоматического форматирования исходного кода Go согласно каноническому стилю. Она выравнивает отступы, расставляет пробелы, приводит группировку импортов к одному виду, убирая споры о стиле. Запуск без флагов переформатирует файлы in-place. Команда go fmt применяет gofmt -l -w к файлам пакета.

Развёрнутый ответ

Команда:

Bash
go fmt ./...          # все пакеты в модуле
gofmt -w file.go      # записать изменения
gofmt -d file.go      # показать diff

Стандарт оформления кода зафиксирован, и gofmt является авторитетным «референсным» форматтером. Также существует goimports, который дополнительно управляет импортами (добавляет недостающие и удаляет неиспользуемые). Форматтеры интегрированы во все редакторы и CI-пайплайны, что приводит к единобразованию кода по всему миру Go.

Под капотом (internals)

gofmt работает как парсер, строит AST, затем печатает его обратно с использованием принтера, соблюдающего жёсткие правила форматирования. Парсер тот же, что и у компилятора, поэтому форматирование всегда соответствует синтаксису. go fmt вызывает gofmt -l -w для каждого исходного файла.

Типичные ошибки кандидатов

  1. Ручное выравнивание кода вопреки gofmt. Например, пытаться красиво выровнять поля структур пробелами — gofmt всё равно переформатирует.

    • Исправление: принять стиль gofmt как данность, не бороться; читаемость от этого не страдает.
  2. Забывчивость запускать gofmt перед коммитом, из-за чего CI падает. Многие проекты включают проверки с go fmt или golangci-lint.

    • Исправление: настроить pre-commit hook или автоформатирование при сохранении в редакторе.

Возможные дополнительные вопросы интервьюера

  • Можно ли настроить gofmt под свой стиль? Нет, у него нет опций для изменения стиля. Это осознанно, чтобы сохранить единство.
  • Чем goimports лучше gofmt? Он добавляет/удаляет импорты автоматически, что удобно. Рекомендуется использовать его как основной форматтер вместо gofmt.

Что такое go vet?

Краткий ответ (30–60 секунд)

go vet — статический анализатор кода, встроенный в Go toolchain, который выявляет подозрительные конструкции, потенциальные ошибки и неидиоматичный код, не проверяемые компилятором. Он анализирует AST и SSA, предупреждая о проблемах: неформатированные строки в printf, неиспользуемые присваивания, затенения переменных, неправильные вызовы, и др. Является обязательным этапом CI в профессиональной разработке.

Развёрнутый ответ

go vet включает несколько проверок (анализаторов), можно запустить их все:

Bash
go vet ./...

Список проверок: print (printf format), loopclosure, shadow, unreachable, structtags и многие другие. В Go 1.22+ vet интегрирован глубже в go test. Многие ошибки, находимые vet, трудно поймать ревью. Например, передача не того количества аргументов в printf, или реиспользование переменной цикла в горутине (до 1.22 показывало предупреждение). go vet -shadow рекомендуется, хотя по умолчанию не включено.

Под капотом (internals)

Vet загружает скомпилированные пакеты (через go/packages) и применяет анализаторы из golang.org/x/tools/go/analysis. Каждый анализатор — функция, получающая информацию о пакете и выдающая предупреждения. Механизм схож с линтерами. Встроенные проверки реализованы в src/cmd/vet. Они используют факты о типах и значениях, полученные из компилятора.

Типичные ошибки кандидатов

  1. Игнорирование вывода vet при сборке. Даже если код собирается, vet может сигнализировать о серьёзной ошибке, проявляющейся в рантайме (например, неправильные теги структур).

    • Исправление: всегда прогонять go vet перед коммитом, включать в CI с флагом -vet=all в тестах.
  2. Отключение vet без понимания последствий. Некоторые используют -vet=off при тестировании, маскируя проблемы.

    • Исправление: разбирать каждый warning, исправлять или осознанно игнорировать.

Возможные дополнительные вопросы интервьюера

  • Какие анализаторы стоит добавить в vet? Можно использовать анализаторы из staticcheck, golangci-lint. go vet — минимальный набор, профессиональный проект дополняется линтерами.
  • Почему vet не заменяет линтеры? Он фокусируется на корректности, а не на стиле или архитектуре. Линтеры дополняют его правилами типа “неэкспортируемые поля”, “лишние импорты” и т.п.

Как работают операторы сравнения?

Краткий ответ (30–60 секунд)

Операторы сравнения в Go (==, !=, <, <=, >, >=) применимы к операндам, которые сравнимы (comparable). Они возвращают булево значение. Числа сравниваются арифметически, строки — лексикографически побайтово (на основе байт, не Unicode collation). Указатели равны, если указывают на одну и ту же память. Интерфейсы равны, если оба динамических типа и значения равны. Структуры и массивы сравнимы, если все поля/элементы сравнимы; сравнение поэлементное.

Развёрнутый ответ

Особенности:

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

Под капотом (internals)

Для целых чисел компилятор генерирует простой CMP, для float — UCOMIS с проверкой на NaN. Строки побайтового сравнения используют runtime.memequal сравнение байт. Сравнение структур транслируется в по-членное сравнение, оптимизатор может заменить на блочное сравнение memcmp для больших структур. Интерфейсы сравниваются через сравнение itab и значение, с дополнительной проверкой на nil.

Типичные ошибки кандидатов

  1. Сравнение []int{1,2} == []int{1,2} — ошибка компиляции. Срезы несравнимы (за исключением nil), нужно использовать reflect.DeepEqual или slices.Equal.

    • Исправление: slices.Equal(a, b).
  2. Сравнение двух NaN через ==. math.NaN() == math.NaN() даёт false. Это соответствует IEEE-754, но удивляет.

    • Исправление: для проверки NaN используйте math.IsNaN.

Возможные дополнительные вопросы интервьюера

  • Можно ли сравнивать интерфейсы, содержащие слайсы? Можно, но при сравнении динамических значений, если оба значения — слайсы и равны в смысле DeepEqual, сравнение вернёт true (если слайсы не nil? На самом деле, сравнение интерфейсов с динамическим типом-слайсом допустимо, но два слайса сравниваются как значения интерфейса: они должны быть одинаковыми указателями на заголовок. Т.е. два одинаковых литерала слайса []int{1} будут иметь разные адреса, и i1 == i2 будет false. Но само сравнение не паникует, так как слайс является comparable (заголовок сравним). slice type comparable, поэтому интерфейсы можно сравнивать безопасно без паники.
  • Как работает сравнение указателей? Сравниваются адреса. Два указателя на разные переменные с одинаковым содержимым не равны.

Как работают логические операторы?

Краткий ответ (30–60 секунд)

&& и || — бинарные логические операторы, работающие на булевых операндах с короткой схемой вычисления (short-circuit): правая часть && не вычисляется, если левая false; для || — если левая true. ! — унарное отрицание. Эти операторы могут использоваться только с типом bool. В отличие от некоторых языков, нет неявного преобразования чисел в логический тип.

Развёрнутый ответ

Примеры:

Go
if x > 0 && y/x > threshold { } // короткая схема защищает от деления на 0
if s != nil || len(s) == 0 { }

Благодаря короткому вычислению можно писать безопасные условия. Операторы применимы только к булевым значениям, что исключает ошибки типа if 1 из C.

Под капотом (internals)

Для каждого оператора компилятор генерирует условные переходы. && превращается в последовательность: вычислить левую часть, если false — переход к метке, иначе вычислить правую и проверить. || аналогично. Аппаратная логика использует флаги процессора. Эффективность высокая, так как избегается лишняя работа.

Типичные ошибки кандидатов

  1. Использование побитовых операторов &, | для булевой логики. Это приводит к путанице и неожиданному поведению, так как они всегда вычисляют оба операнда.

    • Исправление: применять && и || для булевых условий.
  2. Попытка скомбинировать несколько сравнений без скобок и неверный порядок. Хотя приоритеты определены, лучше явно ставить скобки: a == 1 || b == 2 && c == 3 может быть не очевидно (&& приоритетнее).

    • Исправление: явно расставлять скобки для читаемости.

Возможные дополнительные вопросы интервьюера

  • Можно ли перегрузить логические операторы? Нет, перегрузка операторов в Go отсутствует.
  • Что будет, если правая часть && вызывает функцию с побочным эффектом, но short-circuit её пропустит? Побочный эффект не произойдёт, это надо учитывать при проектировании условий.

Какова размерность (битность) базовых типов на разных архитектурах (int на x86 vs x64)?

Краткий ответ (30–60 секунд)

Размер int и uint зависит от целевой архитектуры: на 32-битных платформах (x86, ARM) они 4 байта, на 64-битных (x86-64, ARM64) — 8 байт. uintptr также меняет размер под размер указателя. Остальные числовые типы (int32, int64, float64 и т.д.) имеют фиксированный размер независимо от платформы. Это важно для кросс-платформенной совместимости: если нужен точный размер, используйте конкретные типы.

Развёрнутый ответ

Таблица фиксированных типов:

  • int8, uint8 (byte) — 1 байт.
  • int16, uint16 — 2.
  • int32, rune, uint32 — 4.
  • int64, uint64 — 8.
  • float32 — 4, float64 — 8.
  • complex64 — 8, complex128 — 16.
  • bool — 1 байт (хотя занимает 1 байт, выравнивание может добавить padding).
  • string — 16 байт (указатель + длина) на 64-битной.
  • int/uint — машинное слово.

Размер указателя и uintptr — 4 или 8 байт. Пакет unsafe.Sizeof позволяет узнать размер во время компиляции.

Под капотом (internals)

Компилятор знает целевую архитектуру из переменных окружения GOARCH. При генерации кода он использует соответствующие размеры машинных регистров. Для 64-бит gen код загружает/сохраняет QWORD. Тип int — это псевдоним на int32 или int64 в зависимости от платформы. В исходниках это задаётся в файлах go/types и runtime.

Типичные ошибки кандидатов

  1. Использование int для сериализации данных или взаимодействия с сетью/файлами без учёта битности. На 64-битной запишет 8 байт, на 32-битной — 4, ломая протокол.

    • Исправление: всегда применять конкретные типы типа int64 для бинарного представления.
  2. Полагание, что int быстрее int32 на 64-битной. int машинного слова может быть немного эффективнее для индексации, но в большинстве случаев разница ничтожна. Преждевременная оптимизация.

    • Исправление: выбирать тип по смыслу, а не по микро-бенчмаркам.

Возможные дополнительные вопросы интервьюера

  • Может ли программа скомпилированная для 64-бит использовать 32-битный int? Нет, int будет 64-битным. Но можно использовать int32.
  • Как определить размер типа во время выполнения? unsafe.Sizeof(variable) — константа времени компиляции, но можно использовать в коде.

Что такое «синтаксический сахар» в Go и какие примеры можно привести?

Краткий ответ (30–60 секунд)

Синтаксический сахар в Go — конструкции языка, которые не добавляют новой функциональности, но делают код короче и выразительнее, улучшая читаемость. Примеры: короткое объявление :=, for range, defer, iota, type switch, композитные литералы, автоматическое разыменование указателей при обращении к полям (p.X вместо (*p).X), ... для срезов в аргументах и другие.

Развёрнутый ответ

  • := — сахар для объявления и инициализации с выводом типа.
  • for range — скрывает итераторный код.
  • defer — вместо явного try-finally.
  • iota — упрощает создание перечислений.
  • Type switch — альтернатива цепочке if с assertion.
  • Композитные литералы: Point{1, 2} вместо отдельного p := Point{}; p.X=1; p.Y=2.
  • Автоматический * при доступе к полям через указатель.
  • Множественный возврат и _.
  • append — встроенная функция, но она же является сахаром для работы со срезами.

Все эти вещи разворачиваются компилятором в более примитивные конструкции.

Под капотом (internals)

Каждый синтаксический сахар преобразуется на ранних стадиях компиляции. Например, p.X для указателя превращается в (*p).X. for range раскладывается в эквивалентный цикл с индексом и, возможно, копией элемента. defer вставляет вызов runtime.deferproc и runtime.deferreturn. iota — чисто лексическая замена. Это гарантирует, что нет скрытых накладных расходов или неожиданного поведения.

Типичные ошибки кандидатов

  1. Злоупотребление сахаром там, где он ухудшает читаемость. Например, голый return с именованными параметрами в длинной функции запутывает.

    • Исправление: применять сахар осознанно, когда код становится яснее, а не просто короче.
  2. Незнание, что := — сахар, и попытка использовать его в неподходящем месте (вне функций).

    • Исправление: изучить, где какие конструкции разрешены.

Возможные дополнительные вопросы интервьюера

  • Какой синтаксический сахар добавили в последних версиях? В Go 1.22: переменные цикла создаются заново на каждой итерации, что можно считать устранением старого неинтуитивного поведения. В 1.21 добавили clear для map и срезов, min, max — встроенные функции, что тоже сахар.
  • Можно ли реализовать range для своего типа? С помощью экспериментальных итераторов (range over func) в Go 1.22+, да.

Как работает оператор &^ (AND NOT)?

Краткий ответ (30–60 секунд)

Оператор &^ (bit clear, AND NOT) выполняет побитовое “И” первого операнда с инверсией второго: z = x &^ y устанавливает бит в 1 в z только если в x он 1, а в y — 0. Иначе говоря, сбрасывает биты в x, которые установлены в y. Используется для манипуляции флагами и масками, очистки битов.

Развёрнутый ответ

Эквивалент: x &^ y == x & (^y). Но ^ как унарный оператор — побитовое отрицание, действующее на всё слово, поэтому x & (^y) даёт тот же результат. Для каждого бита: если бит в y равен 1, то соответствующий бит результата равен 0, независимо от x; иначе бит из x копируется. Пример:

Go
x := 0b1101
y := 0b0110
// x &^ y = x & (^y) = 1101 & 1001 = 1001

Применяется в стандартной библиотеке для битовых флагов:

Go
attr &^= flag

Под капотом (internals)

Компилятор транслирует &^ в инструкцию ANDN на x86 (BMI1), если доступна, иначе в AND с инвертированным операндом (или через NOT+AND). Это эффективная операция, часто используемая для сброса битов. На ARM есть аналогичная инструкция BIC.

Типичные ошибки кандидатов

  1. Путаница с приоритетом: &^ имеет тот же приоритет, что &. Поэтому выражение без скобок может быть неправильно понято. x | y &^ z эквивалентно x | (y &^ z).

    • Исправление: использовать скобки для ясности.
  2. Попытка использовать &^ для булевых операндов. Он работает только с целочисленными значениями.

    • Исправление: для булевых очисток использовать &&, || и !.

Возможные дополнительные вопросы интервьюера

  • Для каких битовых операций ещё нет отдельных операторов в Go? Нет циклического сдвига, пришлось бы реализовывать через сдвиги и маски.
  • Как сбросить конкретный бит в числе? Использовать x &^ (1 << n) — сбросит n-й бит.

Что такое range over integers в Go 1.22?

Краткий ответ (30–60 секунд)

В Go 1.22 появилась возможность итерировать for range напрямую по целому числу: for i := range N эквивалентно for i := 0; i < N; i++. Это устраняет необходимость в ручном объявлении счётчика и делает код чище.

Развёрнутый ответ

До Go 1.22 для выполнения цикла фиксированное число раз требовалось писать полную форму:

Go
for i := 0; i < 5; i++ {
    fmt.Println(i)
}

Начиная с Go 1.22 доступна лаконичная форма:

Go
for i := range 5 {
    fmt.Println(i) // 0, 1, 2, 3, 4
}

Если значение счётчика не нужно, переменную можно опустить:

Go
for range 5 {
    doWork()
}

Тип переменной итерации выводится из типа аргумента (по умолчанию int, либо uint8, int64 и т.д.).

Под капотом (internals)

Это синтаксическое расширение компилятора (cmd/compile). При парсинге range по целочисленному типу разворачивается в стандартный SSA-граф счётчика с проверкой границы. Никаких рантайм-выделений памяти или срезов не создаётся, оверхед равен строго нулю тактов.

Типичные ошибки кандидатов

  1. Предположение о создании слайса. Некоторые кандидаты думают, что range 5 аллоцирует срез из 5 элементов. Это не так: это чистый счётчик.
  2. Отрицательные числа: for i := range -5 не выполняется ни разу (число итераций 0).

Возможные дополнительные вопросы интервьюера

  • Какой тип у переменной i в for i := range int16(10)? — int16.
  • Можно ли использовать break и continue? — Да, стандартная семантика циклов сохраняется полностью.

Как в Go 1.22 изменилась семантика loop variable capture?

Краткий ответ (30–60 секунд)

До Go 1.22 переменная цикла for i, v := range slice имела область видимости всего цикла и переиспользовалась между итерациями. Замыкания и горутины захватывали ссылку на одну и ту же переменную, из-за чего все видели последнее значение. Начиная с Go 1.22, на каждой итерации создаётся новая переменная, и каждое замыкание захватывает собственный независимый экземпляр.

Развёрнутый ответ

Классическая ловушка новичков в Go до версии 1.22 выглядела так:

Go
// ДО Go 1.22 — опасный баг!
values := []int{1, 2, 3}
for _, v := range values {
    go func() {
        fmt.Println(v) // все горутины печатали 3
    }()
}

Разработчикам приходилось делать ручное затенение v := v.
В Go 1.22 поведение было официально исправлено на уровне спецификации языка. Теперь код:

Go
// Go 1.22+ — корректно работает из коробки!
for _, v := range values {
    go func() {
        fmt.Println(v) // каждая горутина получает свой v (1, 2, 3)
    }()
}

Переменная цикла теперь лексически локальна для каждой итерации.

Под капотом (internals)

Компилятор анализирует захват переменной замыканием. Если переменная «утекает» (escape analysis) в горутину или замыкание, компилятор размещает новую ячейку в куче (runtime.newobject) для каждой итерации. Для старых модулей поведение определяется директивой go в go.mod: если там go 1.21 или ниже, сохраняется старая семантика.

Типичные ошибки кандидатов

  1. Незнание того, что поведение управляется директивой в go.mod (backward compatibility).
  2. Избыточное написание v := v в современном коде (не ошибка компиляции, но маркер устаревших привычек).

Возможные дополнительные вопросы интервьюера

  • Как вернуть старое поведение в Go 1.22+? — Указать //go:build go1.21 либо установить переменную окружения GOEXPERIMENT=loopvar=0.

Чем math/rand/v2 отличается от math/rand?

Краткий ответ (30–60 секунд)

math/rand/v2 — первый пакет v2 в стандартной библиотеке Go. Он убирает глобальный мьютекс, разделяемый между всеми горутинами, внедряет современные высокопроизводительные генераторы псевдослучайных чисел (PCG и ChaCha8), переименовывает методы в соответствии с современными конвенциями (IntN, UintN) и полностью исключает устаревший rand.Seed().

Развёрнутый ответ

Ключевые архитектурные улучшения math/rand/v2:

  1. Устранение contention: Глобальные функции вроде rand.IntN(100) больше не блокируются на общем sync.Mutex. Рантайм использует thread-local состояние на каждый логический процессор P.
  2. Алгоритмы нового поколения:
    • PCG-DXSM: Быстрый, с превосходными статистическими свойствами, заменяет старый линейный конгруэнтный алгоритм.
    • ChaCha8: Криптографически стойкий генератор случайных чисел (CSPRNG), оптимизированный под векторные инструкции.
  3. Современный API:
    • Появились generic-функции rand.N[T constraints.Integer](n T) T.
    • rand.Seed() удалён: для воспроизводимых последовательностей создаётся явный объект rand.New(rand.NewPCG(seed1, seed2)).

Под капотом (internals)

В старом пакете math/rand глобальный генератор был обёрнут в lockedSource:

Go
// Старый math/rand:
type lockedSource struct {
    lk  sync.Mutex
    src Source
}

При тысячах параллельных запросов сервис упирался в contention на мьютексе рандома. В math/rand/v2 генератор привязан к локальному контексту выполнения, масштабируясь линейно по ядрам CPU.

Типичные ошибки кандидатов

  1. Использование math/rand/v2 для генерации секретных криптографических ключей. Для безопасности по-прежнему обязателен crypto/rand!
  2. Попытка вызвать rand.Seed() в v2.

Что такое range over func в Go 1.23?

Краткий ответ (30–60 секунд)

В Go 1.23 появилась нативная поддержка пользовательских итераторов через range over func. Теперь функции определённых сигнатур (принимающие коллбэк yield) могут напрямую использоваться в конструкции for range. Это позволяет писать ленивые генераторы и обходить сложные структуры данных (деревья, графы) без выделения промежуточных срезов в памяти.

Развёрнутый ответ

Итератор в Go — это функция высшего порядка, принимающая функцию yield:

Go
import "fmt"

// Итератор четных чисел
func EvenNumbers(max int) func(yield func(int) bool) {
    return func(yield func(int) bool) {
        for i := 0; i <= max; i += 2 {
            if !yield(i) { // если вызывающий код сделал break, yield вернет false
                return
            }
        }
    }
}

func main() {
    for n := range EvenNumbers(10) {
        fmt.Println(n) // 0, 2, 4, 6, 8, 10
    }
}

Поддерживаются 3 типа сигнатур итераторов:

  1. func(yield func() bool) — итерация без значений (N раз).
  2. func(yield func(V) bool) — итерация по одному значению (аналог элементов среза).
  3. func(yield func(K, V) bool) — итерация по паре ключ-значение (аналог map).

Под капотом (internals)

Компилятор трансформирует тело цикла for range в тело функции yield, передаваемой в итератор. Если внутри цикла встречается break или return, yield возвращает false, заставляя итератор немедленно остановиться. Это чистый синхронный вызов функций в рамках одного стека горутины, без создания дополнительных горутин или каналов.

Типичные ошибки кандидатов

  1. Игнорирование возвращаемого значения yield(): если yield вернул false, итератор обязан немедленно завершить работу.
  2. Повторный вызов yield после возврата false — рантайм вызовет панику.

Что такое пакет iter в Go 1.23?

Краткий ответ (30–60 секунд)

Пакет iter определяет стандартные обобщённые типы для итераторов (iter.Seq[V] и iter.Seq2[K, V]), а также функции iter.Pull и iter.Pull2 для превращения push-итераторов (коллбэков) в pull-итераторы (ручной вызов next()).

Развёрнутый ответ

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

Go
package iter

type Seq[V any] func(yield func(V) bool)
type Seq2[K, V any] func(yield func(K, V) bool)

Использование iter.Pull:

Go
seq := EvenNumbers(10)
next, stop := iter.Pull(seq)
defer stop() // ОБЯЗАТЕЛЬНО для предотвращения утечки ресурсов

for {
    val, ok := next()
    if !ok {
        break
    }
    fmt.Println(val)
}

iter.Pull необходим, когда нужно одновременно обходить две последовательности (например, проверять равенство или сливать отсортированные списки).

Под капотом (internals)

iter.Pull запускает push-итератор в отдельной сокрытой горутине, координируя передачу данных с вызывающим кодом через оптимизированные примитивы рантайма (подобно корутинам). Функция stop() гарантирует корректное завершение вспомогательной горутины, если обход завершён досрочно.

Типичные ошибки кандидатов

  1. Утечка ресурсов при пропуске stop(): Если не вызвать stop() при раннем выходе из цикла с iter.Pull, вспомогательная горутина останется заблокированной навсегда.

Что такое new(expr) в Go 1.26?

Краткий ответ (30–60 секунд)

В Go 1.26 встроенная функция new была расширена: теперь она может принимать не только имя типа new(T), но и выражение new(expr). Это выделяет память под тип выражения, инициализирует её вычисленным значением и возвращает указатель. Это устраняет необходимость во вспомогательных переменных при заполнении опциональных указателей в структурах (DTO, Protobuf, JSON).

Развёрнутый ответ

До Go 1.26 взятие указателя от литерала примитивного типа было невозможно:

Go
// До Go 1.26:
val := 42
ptr := &val // требовалась отдельная переменная

// Или самописный хелпер:
func Ptr[T any](v T) *T { return &v }

Начиная с Go 1.26:

Go
// Go 1.26+:
pInt := new(42)                  // *int со значением 42
pStr := new("production")         // *string
pDur := new(15 * time.Second)     // *time.Duration

type UserConfig struct {
    MaxRetries *int           `json:"max_retries,omitempty"`
    Timeout    *time.Duration `json:"timeout,omitempty"`
}

cfg := UserConfig{
    MaxRetries: new(3),
    Timeout:    new(5 * time.Second),
}

Под капотом (internals)

Выражение new(expr) компилируется так: компилятор определяет тип выражения, аллоцирует память (на стеке или куче в зависимости от результатов escape analysis), записывает значение выражения и возвращает указатель. Семантически это эквивалентно созданию анонимной переменной tmp := expr; &tmp.

Типичные ошибки кандидатов

  1. Путаница между new(T) (где T — тип, память зануляется) и new(expr) (где expr — выражение, память инициализируется значением).
  2. Попытка передать нетипизированный nil: new(nil) вызовет ошибку компиляции, так как у нетипизированного nil нет типа.