Структуры и методы
Кратко о теме
Заголовок раздела «Кратко о теме»Структура в Go — это описание непрерывного участка памяти: набор полей, лежащих подряд с учётом выравнивания. type Point struct { X, Y int } создаёт именованный (defined) тип, у которого есть свой размер, своё расположение полей и свой набор методов. Значение структуры — это сами данные, а не ссылка на них: присваивание и передача в функцию копируют весь набор полей побайтово (shallow copy — слайсы, мапы, указатели внутри копируются как заголовки/адреса, разделяя один буфер). Отсюда два практических следствия, которые тянутся почти во все вопросы этой подтемы: размер структуры важен (копирование стоит денег, выравнивание раздувает размер), а «изменить структуру внутри функции» можно только через указатель.
Методы в Go — не часть структуры, а отдельные объявления функций с получателем: func (p Point) Len() float64. Компилятор превращает их в обычные функции, где получатель — неявный первый аргумент; это видно через method expression Point.Len(p). Метод можно объявить на любом именованном типе, объявленном в том же пакете, а не только на структуре: на type Celsius float64, на type Handler func(...), на type IDs []int. Нельзя — на указательном типе, на интерфейсном типе и на типе из чужого пакета (включая int, string и прочие предопределённые). Ключевое различие получателей: у типа T метод-сет содержит только методы с получателем T, у *T — методы и с T, и с *T. Именно поэтому интерфейс чаще удовлетворяет *T, а не T, и именно поэтому json.Unmarshal требует указатель.
Наследования в языке нет — есть встраивание (embedding): анонимное поле внутри структуры, чьи поля и методы продвигаются (promotion) на внешний тип. Это синтаксический сахар над делегированием: child.Method() компилятор переписывает в child.Parent.Method(). Никакого позднего связывания при этом не возникает: если метод родителя вызывает другой метод родителя, вызовется именно родительский, даже если внешний тип «переопределил» его. Полиморфизм в Go даётся только интерфейсами, инкапсуляция — регистром первой буквы идентификатора (экспортируемость на уровне пакета, а не на уровне типа).
Третий блок, вокруг которого крутятся вопросы, — теги полей. Тег — это просто строковый литерал после поля, который компилятор не интерпретирует вообще; его читает reflect.StructTag во время выполнения. Соглашение (json:"name,omitempty" db:"name") поддерживают библиотеки, а не язык, поэтому опечатка в теге не ломает сборку — ловится только go vet (проверка structtag) и тестами.
Вопросы и ответы
Заголовок раздела «Вопросы и ответы»Сталкивались ли вы с линтером, который проверяет выравнивание полей в структурах? Как он называется?
Заголовок раздела «Сталкивались ли вы с линтером, который проверяет выравнивание полей в структурах? Как он называется?»Коротко. Да — канонический анализатор называется fieldalignment, он живёт в golang.org/x/tools/go/analysis/passes/fieldalignment и в golangci-lint включается как настройка линтера govet (enable: fieldalignment). Он предлагает переупорядочить поля так, чтобы структура занимала меньше байт.
Глубже. Исторически ту же задачу решал maligned, но он давно deprecated и выпилен из golangci-lint в пользу fieldalignment. Из соседних инструментов: betteralign (github.com/dkorunic/betteralign) умеет не только находить, но и автоматически переставлять поля (-apply), а structlayout/structlayout-optimize из honnef.co/go/tools печатают фактическую раскладку с padding. Важное «но» для собеседования: fieldalignment — линтер про экономию памяти, и включать его на весь репозиторий обычно вредно. Порядок полей часто несёт смысл (читаемость, порядок в конфиге/протоколе), а выигрыш заметен только там, где структура создаётся миллионами штук. Правильный ответ звучит так: знаю про него, применяю точечно к «горячим» структурам, проверяю результат unsafe.Sizeof, а не верю линтеру на слово.
type Bad struct { // 24 байта b bool i int64 c bool}type Good struct { // 16 байт i int64 b bool c bool}Как ваш продукт интегрировался в инфраструктуру заказчика?
Заголовок раздела «Как ваш продукт интегрировался в инфраструктуру заказчика?»Коротко. Вопрос про личный опыт: интервьюер проверяет, понимаете ли вы систему целиком за пределами своего кода — как её ставят, конфигурируют, наблюдают и обновляют у клиента. Отвечать надо конкретикой по способу поставки, контрактам на границе и эксплуатации.
Глубже. Каркас ответа: (1) форма поставки — SaaS в нашем облаке, on-premise в контуре заказчика, helm-чарт в их Kubernetes, RPM/DEB-пакет, docker-compose для маленьких инсталляций; (2) точки интеграции — какие протоколы наружу (REST/gRPC/Kafka/файловый обмен по SFTP), как аутентификация (mTLS, OAuth2/OIDC поверх их Keycloak/AD, статические токены в их Vault); (3) зависимости — своя БД или их существующий кластер, свой брокер или их шина; (4) конфигурация — переменные окружения/ConfigMap, как разруливали различия между стендами; (5) наблюдаемость — отдавали ли метрики в их Prometheus, логи в их ELK/Loki, трейсы в Jaeger, был ли /healthz и /readyz; (6) обновления и совместимость — версионирование API, миграции БД, откат; (7) что было больно — закрытый контур без интернета (нужны зеркала образов), корпоративный TLS-MITM (свой CA в trust store), согласование сетевых доступов. Типичные ошибки: рассказ уровня «мы отдали докер-образ», отсутствие деталей про безопасность и наблюдаемость, невозможность назвать конкретную проблему интеграции и как её решили.
How to implement an OOP approach in Go? Inheritance a) Encapsulation: private variables and methods are written in lowercase b) Inheritance: composition and method embedding c) Polymorphism: use of interfaces
Заголовок раздела «How to implement an OOP approach in Go? Inheritance a) Encapsulation: private variables and methods are written in lowercase b) Inheritance: composition and method embedding c) Polymorphism: use of interfaces»Коротко. Всё перечисленное верно с одной поправкой: наследования в Go нет, вместо него композиция и встраивание. Инкапсуляция — регистром первой буквы (экспортируемость на уровне пакета), «наследование» — встраивание с продвижением методов, полиморфизм — интерфейсы с неявной реализацией.
Глубже. Уточнения, которые отличают сильный ответ от заученного. Инкапсуляция в Go работает на уровне пакета, а не типа: любой код в том же пакете видит unexported-поля любой структуры этого пакета, включая приватные поля чужого типа. Настоящая граница — internal/ и API пакета. Встраивание ≠ наследование: нет виртуальных вызовов, «дочерний» тип не является подтипом родительского (Child нельзя передать туда, где ждут Parent), нет super, а «переопределение» метода — это просто затенение имени в селекторе. Полиморфизм даётся интерфейсами, причём интерфейс объявляется на стороне потребителя, реализация неявная (duck typing, проверяемый компилятором); дженерики (Go 1.18+) добавили параметрический полиморфизм для случаев, где интерфейс с any терял типы. Ещё стоит сказать, что в Go нет конструкторов (есть функции NewX), нет деструкторов (есть defer и runtime.AddCleanup c Go 1.24 вместо устаревшего SetFinalizer), и нет перегрузки методов.
Какой размер структуры Foo { a int32, b bool } в байтах?
Заголовок раздела «Какой размер структуры Foo { a int32, b bool } в байтах?»Коротко. 8 байт. a занимает 4 байта по смещению 0, b — 1 байт по смещению 4, дальше 3 байта padding, потому что выравнивание всей структуры равно максимальному выравниванию поля (4 для int32), и размер обязан быть кратен ему.
Глубже. Правило простое: каждое поле кладётся по адресу, кратному его alignment; итоговый размер округляется вверх до alignment структуры. Проверяется это unsafe.Sizeof, unsafe.Alignof, unsafe.Offsetof — на амд64 unsafe.Sizeof(Foo{}) == 8. Если поменять int32 на int64, ответ станет 16 (8 + 1 + 7 padding). Обратный случай: struct{ a int32; b bool; c bool; d bool } — тоже 8 байт, три bool уместятся в хвостовой padding. Отдельно помните два края: пустая структура struct{} имеет размер 0, а структура, у которой последнее поле нулевого размера (struct{ x int32; _ struct{} }), получает дополнительный padding — рантайм не даёт указателю указывать за пределы объекта. Размеры зависят от архитектуры: int, uintptr, указатель — 8 байт на 64-битных платформах и 4 на 32-битных, поэтому в ответе всегда уточняйте платформу.
Вопросы: базовые вопросы по Go, структуры.
Заголовок раздела «Вопросы: базовые вопросы по Go, структуры.»Коротко. Это не вопрос, а пометка секции интервью. Что реально спрашивают в этом блоке: объявление и инициализация структур, значение по умолчанию, копирование и передача в функцию, указательные и значимые получатели, сравнение через ==, теги, встраивание, struct{} и выравнивание.
Глубже. Минимальный набор фактов, которые надо выдавать без запинки: нулевое значение структуры — все поля в своих нулевых значениях, и оно всегда пригодно к использованию, если тип так спроектирован (sync.Mutex, bytes.Buffer); литералы бывают позиционные Point{1, 2} и по именам Point{X: 1} (в проде — только по именам, иначе добавление поля ломает код); &Point{} и new(Point) эквивалентны и обе могут дать как стек, так и кучу — решает escape-анализ, а не форма записи; структура передаётся копией, поэтому мутирующие методы объявляют на *T; смешивать значимые и указательные получатели у одного типа не принято.
Почему при работе с пакетами вроде json.Marshal/json.Unmarshal объекты, особенно структуры, рекомендуется передавать по ссылке?
Заголовок раздела «Почему при работе с пакетами вроде json.Marshal/json.Unmarshal объекты, особенно структуры, рекомендуется передавать по ссылке?»Коротко. Для Unmarshal указатель обязателен: функция должна записать результат в вашу переменную, а через копию это невозможно — при передаче значения вернётся json.InvalidUnmarshalError. Для Marshal указатель не обязателен, но полезен: не копируется большая структура при упаковке в any, и в метод-сет попадают методы с указательным получателем (MarshalJSON, MarshalText).
Глубже. Механика такая: Unmarshal(data []byte, v any) внутри делает reflect.ValueOf(v) и требует Kind() == Pointer и не-nil, потому что только через указатель reflect получает адресуемое значение и может вызвать Set. Второй, менее очевидный аргумент — метод-сеты: если вы объявили func (t *T) MarshalJSON() ([]byte, error), то при json.Marshal(t) (значение T) метод не будет вызван, если значение не адресуемо, и вы получите стандартную сериализацию полей вместо своей; с json.Marshal(&t) всё работает. Ровно та же ловушка с UnmarshalJSON и с интерфейсами driver.Valuer/sql.Scanner. Третий аргумент — стоимость: json.Marshal(bigStruct) кладёт копию структуры в интерфейсное значение, что почти всегда означает аллокацию в куче и копирование всех полей. Замечание про Go 1.25+: в стандартной библиотеке появилась экспериментальная вторая версия encoding/json (GOEXPERIMENT=jsonv2), но семантика указателей там та же.
func roundtrip(b []byte) ([]byte, error) { var cfg Config if err := json.Unmarshal(b, &cfg); err != nil { // & обязателен return nil, err } return json.Marshal(&cfg) // & желателен: без копии и с учётом *T-методов}Структура;
Заголовок раздела «Структура;»Коротко. Обрывок исходника, вопрос не восстанавливается — это пункт списка вариантов ответа, вырванный из контекста.
Объект типа struct .
Заголовок раздела «Объект типа struct .»Коротко. Обрывок исходника, вопрос не восстанавливается — тоже вариант ответа из тестового вопроса. По сути: в Go нет «объектов», есть значения типов; значение структурного типа — это просто набор полей в памяти, без заголовка, без vtable и без ссылки на тип.
Как объявить новую структуру в Go?
Заголовок раздела «Как объявить новую структуру в Go?»Коротко. Через объявление типа: type S struct { ... }. Отдельно существуют анонимные структурные типы (var x struct{ A int }) и создание значений — литералом S{...}, через new(S) или &S{...}.
Глубже. Полный набор форм, который стоит показать:
// 1. именованный типtype User struct { ID int64 `json:"id"` Name string `json:"name"` Tags []string}
// 2. значенияu1 := User{ID: 1, Name: "a"} // литерал по именам полей — предпочтительноvar u2 User // нулевое значение: 0, "", nilu3 := &User{ID: 3} // указатель на структуруu4 := new(User) // то же самое, что &User{}
// 3. анонимная структура — удобно в табличных тестах и разовых DTOtests := []struct { name string in int want int}{ {"zero", 0, 0},}
// 4. вложенное объявление внутри другого типаtype Server struct { Addr string TLS struct { Cert, Key string }}
fmt.Println(u1, u2, u3, u4, tests, Server{})Ключевое: struct{...} — это тип-литерал, а type S struct{...} — объявление именованного типа, у которого только и можно определять методы. new(S) и &S{} полностью эквивалентны; куда попадёт значение (стек или куча), решает escape-анализ (go build -gcflags='-m').
new struct S {...} ;
Заголовок раздела «new struct S {...} ;»Коротко. Неверный вариант: такого синтаксиса в Go нет. new — встроенная функция, принимающая уже существующий тип: new(S) возвращает *S с нулевым значением; она не объявляет типов и не принимает литерал полей.
make struct S {...} ;
Заголовок раздела «make struct S {...} ;»Коротко. Неверный вариант. make применим только к трём встроенным типам — слайсам, мапам и каналам, — и возвращает инициализированное значение, а не указатель. Для структур make не используется вообще.
type struct S {...} ;
Заголовок раздела «type struct S {...} ;»Коротко. Формально это опечатка: правильный порядок — type S struct { ... }, сначала имя, потом вид типа. В тестах этот вариант обычно и подразумевают «правильным», но на собеседовании стоит проговорить корректный синтаксис вслух.
Глубже. Грамматика объявления: type ИмяТипа [параметры типа] Тип. Отсюда и type S struct{...}, и type Pair[T any] struct{ A, B T } (дженерики с Go 1.18), и алиас type Alias = S (алиас — то же самое имя типа, а не новый тип; с Go 1.24 алиасы тоже могут быть параметризованными).
E struct S {...} .
Заголовок раздела «E struct S {...} .»Коротко. Неверный вариант, синтаксического смысла не имеет — вероятно, обрывок буквенной метки варианта ответа («e) …»).
Какая структура HTTP запроса?
Заголовок раздела «Какая структура HTTP запроса?»Коротко. В HTTP/1.1 запрос — это текст: стартовая строка METHOD /path?query HTTP/1.1, затем заголовки Key: Value по одному на строку, пустая строка CRLF, затем тело. В HTTP/2 и HTTP/3 та же семантика упакована в бинарные фреймы: заголовки в HEADERS (сжатые HPACK/QPACK), тело — в DATA.
Глубже. Обязательный заголовок в HTTP/1.1 — Host (в HTTP/2 его роль играет псевдозаголовок :authority, а метод, схема и путь — :method, :scheme, :path). Длина тела задаётся либо Content-Length, либо Transfer-Encoding: chunked. В Go это отражено в net/http.Request: Method, URL *url.URL, Proto, Header http.Header (это map[string][]string, ключи канонизируются — Content-Type), Body io.ReadCloser, Host, Form/PostForm после ParseForm, ctx через Request.Context(). Подводные камни, которые любят спрашивать: тело — поток, читается один раз (для повторного чтения буферизуют через io.ReadAll + io.NopCloser); на сервере тело закрывать не нужно (это делает сервер), на клиенте resp.Body.Close() обязателен, иначе течёт соединение из пула; заголовки, которые не мапятся в канонический вид (например, в HTTP/2 всё в нижнем регистре), лежат в Header уже канонизированными, а «сырые» — недоступны.
Какая структура у JWT токена?
Заголовок раздела «Какая структура у JWT токена?»Коротко. Три части, разделённые точками: base64url(header).base64url(payload).base64url(signature). Header содержит alg и typ, payload — набор claims (iss, sub, aud, exp, iat, nbf, jti плюс кастомные), signature — подпись первых двух частей ключом.
Глубже. Кодирование — base64url без padding, поэтому в токене нет =, +, /. Важно: header и payload не зашифрованы, а лишь закодированы — класть туда секреты нельзя (это JWS; зашифрованный вариант — JWE, другой формат из пяти частей). Подпись считается над строкой header.payload в её исходном base64-виде, а не над распарсенным JSON. Алгоритмы: симметричные HS256/384/512 (HMAC, один общий секрет) и асимметричные RS256, PS256, ES256, EdDSA (проверяющей стороне нужен только публичный ключ — отсюда JWKS-эндпоинты и kid в заголовке). Классические уязвимости, которые ждут в ответе: alg: none; подмена RS256 на HS256, когда сервер использует публичный ключ как HMAC-секрет; доверие kid/jku без валидации; отсутствие проверки exp, aud, iss; невозможность отозвать токен до истечения (отсюда короткий access + refresh + денилист по jti). В Go де-факто стандарт — github.com/golang-jwt/jwt/v5; там обязательно указывать ожидаемые методы подписи через jwt.WithValidMethods, иначе получаете первую же уязвимость из списка.
Какие ограничения HTTP2 вносит для инфраструктуры?
Заголовок раздела «Какие ограничения HTTP2 вносит для инфраструктуры?»Коротко. Главное — все запросы клиента идут в одно долгоживущее TCP-соединение, поэтому L4-балансировка перестаёт распределять нагрузку: трафик прилипает к одному бэкенду. Плюс практическое требование TLS с ALPN, необходимость поддержки h2 всеми промежуточными прокси и TCP head-of-line blocking, от которого спасает только HTTP/3.
Глубже. Разбор по пунктам. (1) Прилипание соединения: в Kubernetes Service/kube-proxy балансирует соединения, а не запросы, — для gRPC (который всегда HTTP/2) это классическая проблема неравномерной нагрузки; лечится L7-балансировщиком (Envoy/nginx/Traefik), headless-сервисом с клиентской балансировкой (grpc.WithDefaultServiceConfig + round_robin) или service mesh, а также принудительным MAX_CONNECTION_AGE на сервере, чтобы клиенты периодически переподключались. (2) TLS/ALPN: браузеры не поддерживают h2 без TLS вообще; открытый h2c возможен только между своими сервисами и требует явной настройки — в Go до 1.23 через golang.org/x/net/http2/h2c, а с Go 1.24 в net/http появились поля Server.Protocols/Transport.Protocols с SetUnencryptedHTTP2. (3) Прокси и middlebox’ы: старые L7-прокси, WAF, SSL-инспекция могут терминировать h2 и ходить дальше по HTTP/1.1 — тогда часть свойств (сервер-пуш, приоритеты, трейлеры gRPC) теряется; gRPC поверх такого прокси ломается, если он не умеет трейлеры. (4) Flow control и лимиты: у HTTP/2 есть оконное управление потоком на уровне соединения и стрима, и SETTINGS_MAX_CONCURRENT_STREAMS; при неверных значениях получаете искусственный потолок RPS на соединение. (5) Наблюдаемость: netstat/L4-метрики врут — одно соединение вместо сотни, надо снимать метрики на уровне стримов. (6) Безопасность: серия DoS в реализациях (HPACK bomb, Rapid Reset CVE-2023-44487) заставила ставить лимиты на скорость создания/сброса стримов — в Go это исправлено в 1.21.3/1.20.10 и настраивается http2.Server.MaxConcurrentStreams. (7) Idle/keepalive: длинные соединения умирают на NAT и LB с коротким idle timeout — нужны PING-keepalive.
Как преобразовать поле структуры в JSON?
Заголовок раздела «Как преобразовать поле структуры в JSON?»Коротко. Поле должно быть экспортируемым (с заглавной буквы), имя и поведение задаются тегом json:"...", дальше json.Marshal(v). Неэкспортируемые поля encoding/json не видит вообще — ни при записи, ни при чтении.
Глубже. Опции тега: json:"name" — переименование; json:"-" — полностью исключить поле (а json:"-," — поле с именем -); json:",omitempty" — пропустить «пустое» значение (false, 0, "", nil, пустой слайс/мапа — но не пустая структура и не нулевой time.Time); json:",omitzero" — новинка Go 1.24: пропускает нулевое значение типа, а если у типа есть метод IsZero() bool, использует его — именно то, чего не хватало для time.Time и структур; json:",string" — кодировать число/bool строкой (частая необходимость для JS-клиентов и int64). Если нужна логика сложнее — реализуйте json.Marshaler/json.Unmarshaler (или более дешёвые encoding.TextMarshaler/TextUnmarshaler), но помните про метод-сет: с указательным получателем передавайте &v. Для приватного поля вариантов два: сериализовать через собственный MarshalJSON или завести отдельный DTO-тип и маппить. Полезные мелочи: json.Decoder.DisallowUnknownFields() для строгого разбора, json.RawMessage для отложенного парсинга, встраивание безымянной структуры для «плоского» JSON, а вот встроенная структура с именем поля даст вложенный объект.
type Event struct { ID int64 `json:"id,string"` Name string `json:"name"` StartedAt time.Time `json:"started_at,omitzero"` // Go 1.24: учтёт time.Time.IsZero() internal string // не попадёт в JSON никогда}Для чего используется пустая структура?
Заголовок раздела «Для чего используется пустая структура?»Коротко. struct{} занимает 0 байт, поэтому её используют как «значение без данных»: сигнальные каналы chan struct{}, множества map[string]struct{}, типы-носители методов без состояния, маркеры.
Глубже. Все значения нулевого размера рантайм размещает по одному общему адресу runtime.zerobase, аллокации в куче фактически нет. Три канонических применения. Сигнальный канал: done := make(chan struct{}), закрытие канала — broadcast всем читателям; chan bool здесь хуже, потому что провоцирует передавать смысл через значение. Множество: map[string]struct{} против map[string]bool экономит по байту (и по padding) на элемент и синтаксически говорит «значение не важно»; ценой чуть менее удобной записи _, ok := set[k]. Тип без состояния: type noopLogger struct{} с методами интерфейса, или встраивание struct{ noCopy }. Также struct{} — элемент для слайса-заглушки make([]struct{}, n) в for range (хотя с Go 1.22 есть for i := range n). Тонкости, которые стоит упомянуть: спецификация разрешает двум различным переменным нулевого размера иметь одинаковый адрес — сравнивать указатели на них бессмысленно; а поле нулевого размера в конце структуры получает хвостовой padding в один байт выравнивания, поэтому struct{ x int32; _ [0]byte } больше, чем кажется.
Можно ли сравнить 2 структуры через == ?
Заголовок раздела «Можно ли сравнить 2 структуры через == ?»Коротко. Да, если все поля структуры сравнимы (comparable). Сравнение идёт пополе: два значения равны, когда равны все соответствующие поля. Если хоть одно поле — слайс, мапа или функция, == не компилируется.
Глубже. Нюансы, ради которых вопрос и задают. (1) Интерфейсное поле проходит проверку компилятора, но может паниковать в рантайме: если в обоих интерфейсах лежит один и тот же несравнимый динамический тип (например, []int), получите panic: comparing uncomparable type []int. (2) float64-поля: NaN != NaN, поэтому структура с NaN не равна сама себе. (3) Padding не участвует: компилятор генерирует либо memequal (когда padding нет), либо честное пополевое сравнение, так что «мусор в дырках» на результат не влияет. (4) Сравнимые структуры можно использовать как ключи мапы — это главное практическое следствие. (5) Для несравнимых структур есть reflect.DeepEqual (медленно, сравнивает и неэкспортируемые поля, nil-слайс ≠ пустой слайс) и google/go-cmp (cmp.Diff — стандарт в тестах, требует опций для приватных полей). (6) В дженериках ограничение comparable означает «строго сравнимый на этапе компиляции»; до Go 1.20 обычные интерфейсы ему не удовлетворяли вовсе, с Go 1.20 их разрешено подставлять как аргумент типа — но рантайм-паника при сравнении несравнимого динамического типа никуда не делась.
type Key struct { A int B string}
func demo() { fmt.Println(Key{1, "x"} == Key{1, "x"}) // true m := map[Key]int{{1, "x"}: 42} // структура как ключ — ok fmt.Println(m[Key{1, "x"}]) // 42}
type NoCmp struct{ S []int }
// NoCmp{} == NoCmp{} // ошибка компиляции: invalid operationсделать написанный код переиспользуемым (т.e. не все в main, а создать структуру мини проекта и разнести по пакетам);
Заголовок раздела «сделать написанный код переиспользуемым (т.e. не все в main, а создать структуру мини проекта и разнести по пакетам);»Коротко. Это требование из тестового задания: вынести логику из main в пакеты, оставив в main только сборку зависимостей и запуск. Минимальный вменяемый скелет — cmd/<app>/main.go для точки входа, internal/... для доменной логики, internal/<domain>/{service,repository,handler} по слоям.
Глубже. Практические правила, за которые дают баллы. Пакеты именуются по ответственности, а не по слою реализации: order, payment, storage/postgres, а не models, utils, helpers, common — «свалочные» пакеты сразу заметны. Зависимости направлены внутрь: домен ничего не знает про HTTP и SQL; интерфейсы объявляются на стороне потребителя (в сервисе — type OrderRepo interface{...}), реализация — в internal/storage/postgres, что даёт подмену на mock в тестах без внешних библиотек. Всё, что не является публичным API библиотеки, кладётся в internal/ — компилятор физически запретит импорт снаружи модуля. Конструкторы New... возвращают конкретный тип и принимают зависимости аргументами (DI руками, без фреймворка — этого достаточно для тестового). main делает: чтение конфига → создание пула БД/клиентов → конструирование сервисов → регистрация HTTP-роутов → http.Server + graceful shutdown по signal.NotifyContext. Импортных циклов между пакетами быть не должно (Go их запрещает — это индикатор неверных границ). Про pkg/ из golang-standards/project-layout можно упомянуть, но оговориться, что это не официальный стандарт и для маленького проекта достаточно cmd/ + internal/.
Для чего используются структуры?
Заголовок раздела «Для чего используются структуры?»Коротко. Чтобы объединить связанные данные в одно значение с известной раскладкой в памяти, дать ему имя, набор методов и, при необходимости, теги для сериализации. Это основной способ моделировать сущности в Go — аналог классов, но без наследования.
Глубже. Практические роли структур: доменные сущности и value-объекты; DTO для JSON/protobuf/SQL; конфиги; носители зависимостей сервиса (type Service struct { repo Repo; log *slog.Logger }); группировка параметров вместо функций с 8 аргументами (functional options — тоже поверх структуры); ключи мапы, когда нужен составной ключ; пустая структура как маркер. Отдельно стоит сказать про производительность: структура — это непрерывный кусок памяти, слайс структур ([]T) лежит в памяти подряд и дружелюбен к кешу, в отличие от []*T, где каждый элемент — отдельная аллокация и разыменование. Именно поэтому в горячем коде предпочитают []T, а расположение полей внутри структуры влияет на её размер (см. вопрос про fieldalignment).
Чем отличается метод от функции?
Заголовок раздела «Чем отличается метод от функции?»Коротко. Метод — это функция с получателем (receiver), объявленная для конкретного именованного типа: она входит в его метод-сет, вызывается через селектор v.M() и участвует в удовлетворении интерфейсов. Функция самостоятельна и живёт в пространстве имён пакета.
Глубже. На уровне реализации метод — обычная функция, где получатель передан первым аргументом; это буквально видно через method expression: Point.Len имеет тип func(Point) float64, и Point.Len(p) эквивалентно p.Len(). Есть ещё method value: f := p.Len — замыкание, в котором получатель уже захвачен (для значимого получателя — его копия на момент взятия, это частый источник багов). Практические отличия: у методов нельзя делать перегрузку по сигнатуре (одно имя на тип), метод можно объявить только в пакете, где объявлен тип, и только на именованном не-указательном и не-интерфейсном типе. Вызов метода на значении/указателе автоматически берёт адрес или разыменовывает, если операнд адресуем: p.PtrMethod() для p Point компилируется в (&p).PtrMethod(), а вот Point{}.PtrMethod() — ошибка, литерал неадресуем. И главное для интерфейсов: метод-сет T содержит только методы с получателем T, метод-сет *T — все.
Помимо структур какие-либо еще объекты могут иметь методы?
Заголовок раздела «Помимо структур какие-либо еще объекты могут иметь методы?»Коротко. Да: методы можно объявить на любом именованном типе, объявленном в том же пакете, — на числовом, строковом, на слайсе, мапе, канале, функции, массиве и на указательном типе, определённом через type. Нельзя — на интерфейсных типах, на типах из чужих пакетов и на неименованных типах-литералах.
Глубже. Каноничные примеры из стандартной библиотеки: time.Duration — это int64 с методами String(), Seconds(); http.HandlerFunc — это func(ResponseWriter, *Request) с методом ServeHTTP, что превращает функцию в интерфейс (классический адаптер); sort.IntSlice — []int с методами Len/Less/Swap; net.IP — []byte. Чтобы «добавить метод» к чужому типу, его оборачивают: type MyTime time.Time (новый тип, методы time.Time не наследуются) либо встраивают: type MyTime struct{ time.Time } (методы продвигаются). Ограничение «только в своём пакете» — намеренное: оно гарантирует, что метод-сет типа известен целиком по его пакету и никто не сможет незаметно сделать чужой тип реализующим ваш интерфейс.
type Celsius float64
func (c Celsius) String() string { return fmt.Sprintf("%.1f°C", float64(c)) }
type Middleware func(http.Handler) http.Handler
func (m Middleware) Then(h http.Handler) http.Handler { return m(h) }Зачем нужна пустая структура?
Заголовок раздела «Зачем нужна пустая структура?»Коротко. См. выше «Для чего используется пустая структура?»: нулевой размер, отсюда сигнальные каналы chan struct{}, множества map[K]struct{} и типы без состояния. Отличие формулировки — здесь чаще ждут именно мотивации «зачем», то есть акцент на экономии памяти и на выражении намерения «значение не несёт информации».
Глубже. Если спрашивают дважды, добавьте количественный аргумент: map[string]struct{} на миллион элементов экономит около мегабайта против map[string]bool — на практике решающим это бывает редко, поэтому основной аргумент всё-таки семантический. И контрпример честности ради: в chan struct{} нельзя передать полезную нагрузку, а close() можно вызвать один раз — для многократной сигнализации нужен sync.Cond, context.Context или канал с буфером.
Есть ли опыт использования собственных тэгов в описании структур?
Заголовок раздела «Есть ли опыт использования собственных тэгов в описании структур?»Коротко. Вопрос про опыт. Правильный ответ: да, тег — это произвольная строка, читаемая через reflect.StructTag.Lookup("myapp"), и свои теги пишут, когда нужно декларативно описать поведение поля — маскирование PII в логах, маппинг в свой формат, метаданные для генератора кода.
Глубже. Каркас ответа: (1) синтаксис — соглашение key:"value" key2:"v1,v2", пары разделяются пробелами, значение — в двойных кавычках; формально это просто строковый литерал, поэтому пишут его в backticks; (2) чтение — reflect.TypeOf(v).Field(i).Tag.Lookup("mask"), и обязательно Lookup, а не Get, чтобы отличить «тега нет» от «тег пустой»; (3) валидация — компилятор теги не проверяет, поэтому go vet (проверка structtag) ловит нарушение синтаксиса и дубли ключей, а остальное — на тестах; (4) производительность — рефлексия дорогая, реальные библиотеки кешируют разбор тегов по reflect.Type, и вы должны сделать так же (sync.Map[reflect.Type]metadata) либо вообще уйти в кодогенерацию go:generate; (5) когда не надо — если задача решается интерфейсом (Redact() string) или явной функцией, тег добавляет магию и ломает переименование полей без предупреждения. Хороший конкретный пример из опыта: тег log:"mask" + собственный slog.LogValuer/хук, который перед записью в лог обнуляет помеченные поля.
type User struct { Email string `json:"email" log:"mask"` Name string `json:"name"`}
func maskedFields(v any) { t := reflect.TypeOf(v) for i := range t.NumField() { // range по int — Go 1.22+ if tag, ok := t.Field(i).Tag.Lookup("log"); ok && tag == "mask" { fmt.Println("маскируем поле", t.Field(i).Name) } }}Какие бывают тэги?
Заголовок раздела «Какие бывают тэги?»Коротко. Тег — это одна строка, а «виды» тегов задаются библиотеками-потребителями. Самые ходовые ключи: json, xml, yaml, toml, bson, db/sqlx, gorm, protobuf, validate (go-playground/validator), mapstructure (viper), env, form, binding (gin), csv.
Глубже. Структура тега формально описана в документации reflect.StructTag: строка — это последовательность пар key:"value", разделённых пробелами; ключ не должен содержать пробелов, кавычек и двоеточий; значение цитируется в стиле Go. Всё, что не соответствует формату, просто не будет найдено Lookup — молча. Типичные опции внутри значения (разделяются запятыми): json:"name,omitempty,string", json:"-", db:"created_at", validate:"required,email,max=64", env:"PORT" envDefault:"8080". Что важно проговорить: (1) один пакет игнорирует чужие ключи, поэтому теги мирно сосуществуют; (2) go vet проверяет синтаксис и дублирование ключей, а также совпадение имён в json/xml тегах; (3) на неэкспортируемых полях теги бесполезны — рефлексия не сможет их прочитать/записать; (4) теги видны в reflect только у полей структуры, у методов и функций тегов не бывает.
Какой любимый flow разработки? Структура веток, правила, соглашения …Личный опыт.
Заголовок раздела «Какой любимый flow разработки? Структура веток, правила, соглашения …Личный опыт.»Коротко. Вопрос про опыт и вкус: интервьюер хочет услышать осознанный выбор под контекст, а не заученное название. Сильный ответ — «trunk-based с короткоживущими ветками и обязательным CI на PR», с объяснением, почему не GitFlow.
Глубже. Каркас: (1) модель ветвления — trunk-based (одна main, ветки живут часы-дни, feature flags для незавершённого) против GitFlow (develop, release/*, hotfix/* — оправдан для версионируемых коробочных продуктов с несколькими поддерживаемыми версиями) против GitHub Flow; скажите, что выбирали по частоте релизов; (2) именование веток — feat/JIRA-123-short-desc, fix/..., chore/...; (3) коммиты — Conventional Commits, что даёт автогенерацию CHANGELOG и semver-версии; squash-merge, чтобы история main была линейной и каждый коммит соответствовал PR; (4) правила PR — обязательный ревью хотя бы одного, зелёный CI (build, go vet, golangci-lint, go test -race, покрытие), запрет прямого push в main через branch protection, размер PR до ~400 строк; (5) релизы — теги vX.Y.Z, semver, автоматический деплой из тега, отдельно stage/prod; (6) что не работает — долгие feature-ветки (ад мержей), rebase уже опубликованных веток, «CI чинится потом». Типичные ошибки на собеседовании: назвать GitFlow «правильным» без обоснования, не различать merge/squash/rebase, не сказать ни слова про то, что запускается на CI.
Как реализованы многомерные или вложенные структуры в Go?
Заголовок раздела «Как реализованы многомерные или вложенные структуры в Go?»Коротко. Никакой отдельной сущности нет: поле структуры может иметь тип другой структуры, массива или слайса, и всё это раскладывается в памяти по обычным правилам. Массив массивов [3][4]int — один непрерывный блок из 12 значений; слайс слайсов [][]int — заголовок, указывающий на массив заголовков, каждый из которых указывает на свой буфер.
Глубже. Про структуры: вложение бывает именованным (Address Address — в JSON даст вложенный объект, доступ u.Address.City) и анонимным-встроенным (Address без имени — поля продвигаются, u.City работает, в JSON поля «схлопываются» на верхний уровень). Вложенная структура-значение лежит внутри родителя, без отдельной аллокации; вложенный указатель *Address — отдельный объект в куче и возможность отличить «нет адреса» от «пустой адрес». Про многомерность: [N][M]T — истинно двумерный непрерывный массив, размер известен в компайл-тайме, копируется целиком при присваивании; [][]T — «рваный» массив, строки могут быть разной длины и разбросаны по куче, поэтому для матриц в горячем коде обычно берут один плоский []T длины rows*cols с индексацией i*cols+j — лучше локальность и одна аллокация. Инициализация [][]int требует цикла (for i := range m { m[i] = make([]int, cols) }) — забыть это и получить nil-строку с паникой при записи очень легко.
type Address struct{ City, Street string }type User struct { Name string Address Address // именованное вложение: u.Address.City, JSON {"Address":{...}} Prev *Address // необязательное: nil = данных нет}
func matrices() { grid := [3][4]int{} // непрерывный блок из 12 значений flat := make([]int, 3*4) // предпочтительно для матриц: flat[i*4+j] fmt.Println(grid, flat)}Доводилось ли вам использовать библиотеку MapStruct? Как решали задачу маппинга между структурами в Go?
Заголовок раздела «Доводилось ли вам использовать библиотеку MapStruct? Как решали задачу маппинга между структурами в Go?»Коротко. MapStruct — это Java-библиотека (кодогенератор мапперов на аннотациях), в Go её нет. В Go задачу решают тремя способами: руками (самый частый и предпочтительный), кодогенерацией (goverter, copygen) и рефлексией (mitchellh/mapstructure, jinzhu/copier).
Глубже. Что стоит сказать по существу. Руками — обычная функция func toDTO(u domain.User) api.User: явно, быстро, компилятор ловит изменение полей, легко читается в ревью; недостаток — рутина при больших структурах, и добавленное поле легко забыть (лечится тестом на полноту маппинга или exhaustruct-линтером, который требует заполнять все поля литерала). Кодогенерация — goverter ближе всего по духу к MapStruct: описываете интерфейс с сигнатурами конверсий и директивами в комментариях, go:generate создаёт обычный Go-код; получаете и явность, и отсутствие рутины, и ошибки на этапе сборки. Рефлексия — mapstructure заточен под map[string]any → структура (его использует viper для конфигов, теги mapstructure:"..."), copier копирует между структурами по совпадающим именам; удобно, но медленно (десятки-сотни наносекунд на поле против единиц), ошибки только в рантайме, и рефакторинг переименования полей ломается молча. Рекомендация, которую хорошо озвучить: на границах (DTO ↔ домен ↔ строка БД) писать маппинг руками или генерировать, а рефлексию оставить для конфигов и разового кода.
Рассказ про композицию: как это делается и какие свойства возникают?
Заголовок раздела «Рассказ про композицию: как это делается и какие свойства возникают?»Коротко. Композиция в Go — это либо обычное именованное поле (делегирование вручную), либо встраивание анонимного поля, при котором поля и методы встроенного типа продвигаются на внешний. Продвижение — сахар над outer.Inner.M(), оно даёт переиспользование кода и автоматическое удовлетворение интерфейсов, но не даёт подтипирования и виртуальных вызовов.
Глубже. Свойства, которые надо перечислить. (1) Продвижение по глубине: селектор ищется на минимальной глубине; если на одной и той же глубине нашлись два одинаковых имени (встроили A и B, у обоих Test()), селектор неоднозначен — но ошибка компиляции возникает только в месте использования c.Test(), само объявление типа валидно; разрешается явным путём c.A.Test() или объявлением своего Test() на внешнем типе (он на глубине 0 и перекрывает оба). (2) Метод-сет: если встроено значение Inner, внешний тип Outer получает методы с получателем Inner, а *Outer — ещё и с *Inner; если встроен указатель *Inner, то и Outer, и *Outer получают все методы (но нужно не забыть проинициализировать указатель, иначе nil-паника). (3) Удовлетворение интерфейсов задаром: встроив sync.Mutex, вы получаете Lock/Unlock на внешнем типе; встроив интерфейс в структуру (struct{ io.Reader }), получаете «частичную реализацию» — компилируется, но паникует на невызываемых методах, если поле nil (популярный приём для моков и для оборачивания одной-двух операций). (4) Нет подтипирования: Child не является Parent, присвоить нельзя, слайс []Child не конвертируется в []Parent. (5) Нет позднего связывания — см. следующий вопрос. (6) Инкапсуляция ломается: встраивание экспортирует все методы внутреннего типа наружу, включая те, что вы не хотели показывать, — это главный аргумент в пользу именованного поля и явного делегирования. (7) Имя встроенного поля — это имя типа без пакета: struct{ sync.Mutex } → поле Mutex; поэтому нельзя встроить два типа с одинаковым базовым именем из разных пакетов.
Вопрос о наследовании методов при эмбединге: если есть родительская структура parent и дочерняя child, которая встраивает parent, то у кого какие методы применяются?
Заголовок раздела «Вопрос о наследовании методов при эмбединге: если есть родительская структура parent и дочерняя child, которая встраивает parent, то у кого какие методы применяются?»Коротко. child получает все методы parent через продвижение. Если child объявил метод с тем же именем, он затеняет родительский при вызове через child, но родительский остаётся доступен явно: c.Parent.Who(). Главное: методы parent, вызывающие другие методы parent, всегда вызовут именно родительские реализации — переопределения в child они не видят.
Глубже. Это и есть ключевое отличие встраивания от наследования: нет виртуальной таблицы и нет this, указывающего на внешний объект. Внутри Parent.Hello() получатель имеет тип Parent и знает только о себе.
type Parent struct{ Name string }
func (p Parent) Who() string { return "parent" }func (p Parent) Hello() string { return "hello from " + p.Who() }
type Child struct{ Parent }
func (c Child) Who() string { return "child" }
func main() { c := Child{} fmt.Println(c.Who()) // "child" — своё имя затеняет продвинутое fmt.Println(c.Parent.Who()) // "parent" — явный доступ к встроенному fmt.Println(c.Hello()) // "hello from parent" ← НЕ "child"}Если нужно именно позднее связывание, его делают руками — через интерфейс, который родитель хранит как зависимость: type Parent struct{ impl Whoer }, и тогда Hello() вызывает p.impl.Who(). Ещё нюанс про метод-сеты: если Parent имеет методы с указательным получателем (func (p *Parent) Set()), то Child (значение) их в метод-сете не имеет — их получит только *Child; поэтому var _ Setter = Child{} не скомпилируется, а var _ Setter = &Child{} — да. И последнее: встраивание не меняет тип — Child нельзя передать в функцию, принимающую Parent, нужно передавать c.Parent.
Частые ошибки на собесе
Заголовок раздела «Частые ошибки на собесе»- Называть встраивание наследованием и обещать, что «дочерний» метод переопределит родительский внутри родительских вызовов — виртуальной диспетчеризации в Go нет.
- Путать
new(S)/&S{}/make:makeк структурам неприменим, аnew(S)и&S{}полностью эквивалентны и обе не означают «в куче» — это решает escape-анализ. - Считать, что структура передаётся по ссылке, или что копия структуры глубокая: копируются поля, а слайсы, мапы и указатели внутри продолжают ссылаться на те же данные.
- Забывать, что
encoding/jsonи другие reflect-библиотеки видят только экспортируемые поля, и чтоUnmarshalтребует указателя, а методы с получателем*Tне попадают в метод-сетT. - Утверждать, что
==для структур работает всегда: со слайсом/мапой/функцией внутри — ошибка компиляции, с интерфейсным полем — возможная паника в рантайме. - Смешивать значимые и указательные получатели у одного типа и не уметь объяснить, почему
Point{}.PtrMethod()не компилируется (литерал неадресуем). - Думать, что теги проверяет компилятор: опечатка в
json:"..."не ломает сборку и обнаруживается толькоgo vetили тестами. - Считать
struct{}«структурой размера 1» и не знать проruntime.zerobase, а также механически применятьfieldalignmentко всему коду ради мнимой экономии.
Что почитать
Заголовок раздела «Что почитать»- The Go Programming Language Specification — Struct types, Method sets, Selectors — первоисточник по продвижению полей, method sets и сравнимости.
- Effective Go — Embedding — каноническое объяснение композиции вместо наследования.
reflect.StructTagиencoding/json.Marshal— точный формат тегов и все опции (omitempty,omitzero,string,-).golang.org/x/tools/.../fieldalignment— анализатор выравнивания полей.- Go 1.24 Release Notes —
omitzeroвencoding/json,Server.Protocols/unencrypted HTTP/2,runtime.AddCleanup.