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

Структуры и методы

Структура в 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-битных, поэтому в ответе всегда уточняйте платформу.

Коротко. Это не вопрос, а пометка секции интервью. Что реально спрашивают в этом блоке: объявление и инициализация структур, значение по умолчанию, копирование и передача в функцию, указательные и значимые получатели, сравнение через ==, теги, встраивание, 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-методов
}

Коротко. Обрывок исходника, вопрос не восстанавливается — это пункт списка вариантов ответа, вырванный из контекста.

Коротко. Обрывок исходника, вопрос не восстанавливается — тоже вариант ответа из тестового вопроса. По сути: в Go нет «объектов», есть значения типов; значение структурного типа — это просто набор полей в памяти, без заголовка, без vtable и без ссылки на тип.

Коротко. Через объявление типа: 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, "", nil
u3 := &User{ID: 3} // указатель на структуру
u4 := new(User) // то же самое, что &User{}
// 3. анонимная структура — удобно в табличных тестах и разовых DTO
tests := []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').

Коротко. Неверный вариант: такого синтаксиса в Go нет. new — встроенная функция, принимающая уже существующий тип: new(S) возвращает *S с нулевым значением; она не объявляет типов и не принимает литерал полей.

Коротко. Неверный вариант. make применим только к трём встроенным типам — слайсам, мапам и каналам, — и возвращает инициализированное значение, а не указатель. Для структур make не используется вообще.

Коротко. Формально это опечатка: правильный порядок — 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) …»).

Коротко. В 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 уже канонизированными, а «сырые» — недоступны.

Коротко. Три части, разделённые точками: 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.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 } больше, чем кажется.

Коротко. Да, если все поля структуры сравнимы (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 ко всему коду ради мнимой экономии.