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

Что есть:

ООП концепцияКак в Go
ИнкапсуляцияExported/unexported (заглавная буква)
ПолиморфизмИнтерфейсы (неявная реализация)
Наследование❌ Нет. Вместо него — композиция (embedding)
КлассыСтруктуры + методы
КонструкторыФункции New...() по конвенции
Абстрактные классыИнтерфейсы
go
// "Класс" в Go type User struct { name string // unexported — инкапсуляция Email string // exported } // "Конструктор" func NewUser(name, email string) *User { return &User{name: name, Email: email} } // "Метод" func (u *User) Name() string { return u.name }

Что отвечать на собесе: Go поддерживает ООП-концепции, но реализует их иначе. Вместо иерархий наследования — композиция и маленькие интерфейсы. Это by design, не ограничение.

  • В Go нет private/public/protected. Инкапсуляция — на уровне пакетов: заглавная буква = экспортировано, строчная = приватно
  • Единица инкапсуляции — пакет, не структура. Всё внутри пакета видит всё, даже "приватные" поля чужих структур
  • Нет friend-классов, нет protected — только два уровня: видно снаружи пакета или нет

Правило

go
package user type User struct { Name string // экспортировано — доступно из других пакетов email string // НЕ экспортировано — доступно только внутри пакета user } func (u *User) Email() string { return u.email } // геттер — единственный способ дать доступ func newSession() {} // приватная функция — только внутри пакета func NewUser() *User {} // экспортированная — конструктор

Ключевые отличия от классического ООП

Нет уровня структуры: в Java private поле недоступно даже другому классу в том же пакете (без геттера). В Go — любой код в пакете user видит email напрямую.

go
// Файл user.go type User struct { email string } // Файл admin.go (тот же пакет user) func resetEmail(u *User) { u.email = "" // ОК — тот же пакет, хоть и другой файл }

Нет protected: нет наследования — нет смысла в "видно только наследникам".

Зачем так

Go поощряет маленькие пакеты с чётким API. Пакет = команда из 1-3 файлов, все авторы контролируют инварианты. Если пакет разросся — разбей на подпакеты.

Подвох на собесе

go
json.Unmarshal(data, &user) // НЕ заполнит приватные поля (email)

Рефлексия и encoding/json не видят неэкспортированные поля чужого пакета.


  • Полиморфизм в Go — через интерфейсы. Имплицитная реализация: нет implements, тип автоматически удовлетворяет интерфейсу если имеет все методы
  • Интерфейсы маленькие (1-2 метода): io.Reader, io.Writer, fmt.Stringer, error. "The bigger the interface, the weaker the abstraction"
  • Определяй интерфейс на стороне потребителя, не на стороне реализации

Имплицитная реализация

go
type Writer interface { Write(p []byte) (n int, err error) } type FileWriter struct{ path string } func (f *FileWriter) Write(p []byte) (int, error) { /* ... */ } type BufferWriter struct{ buf []byte } func (b *BufferWriter) Write(p []byte) (int, error) { /* ... */ } // Оба удовлетворяют Writer без явного объявления func Save(w Writer, data []byte) error { _, err := w.Write(data) return err } Save(&FileWriter{"/tmp/x"}, data) // ОК Save(&BufferWriter{}, data) // ОК

Нет implements: связь между типом и интерфейсом — структурная (по набору методов), не номинальная (по объявлению).

Интерфейс на стороне потребителя

go
// ПЛОХО — интерфейс рядом с реализацией package db type UserStore interface { // зачем? есть только одна реализация Get(id int) User } type PostgresStore struct{} // ХОРОШО — интерфейс там, где он используется package handler type UserGetter interface { // минимальный интерфейс для handler Get(id int) User } func NewHandler(store UserGetter) *Handler { /* ... */ }

Потребитель определяет что ему нужно. Реализация ничего не знает об интерфейсе.

Композиция интерфейсов

go
type Reader interface { Read(p []byte) (int, error) } type Writer interface { Write(p []byte) (int, error) } type ReadWriter interface { Reader Writer }

Проверка реализации в compile time

go
var _ Writer = (*FileWriter)(nil) // ошибка компиляции если не реализует

Type switch — "pattern matching"

go
func describe(i interface{}) string { switch v := i.(type) { case string: return "string: " + v case int: return fmt.Sprintf("int: %d", v)\n case error: return "error: " + v.Error() default: return "unknown" } }

Сравнение с Java/C++

Java/C++Go
Связьimplements/extends (явная)Структурная (неявная)
ИерархияДерево типовНет иерархии
Размер интерфейсаЧасто 10+ методов1-2 метода (идиома)
Где определятьРядом с реализациейРядом с потребителем

  • В Go нет наследования. Вместо него — композиция через встраивание (embedding). Встроенные поля/методы "всплывают" наверх, но это синтаксический сахар, не is-a
  • Встраивание = has-a с удобным синтаксисом. Нет виртуальных методов, нет super, нет переопределения
  • Полиморфизм — через интерфейсы, не через иерархию типов

Встраивание (embedding)

go
type Logger struct{} func (l Logger) Log(msg string) { fmt.Println(msg) } type Server struct { Logger // встраивание — без имени поля Host string } s := Server{Host: "localhost"} s.Log("started") // вызов "всплыл" — как будто метод Server s.Logger.Log("started") // эквивалентно — явный вызов

Компилятор не копирует методы. Он генерирует обёртку:

go
// Компилятор неявно создаёт: func (s Server) Log(msg string) { s.Logger.Log(msg) }

Чем это НЕ наследование

Нет is-a: Server не является Logger. Нельзя передать Server туда, где ждут Logger.

go
func useLogger(l Logger) {} useLogger(s) // ОШИБКА компиляции useLogger(s.Logger) // ОК — явно достаём встроенное поле

Нет виртуальных методов: встроенный тип не знает о внешнем.

go
type Base struct{} func (b Base) Name() string { return "base" } func (b Base) Hello() string { return "hello from " + b.Name() } type Child struct{ Base } func (c Child) Name() string { return "child" } c := Child{} c.Hello() // "hello from base" — НЕ "hello from child"! // Base.Hello() вызывает Base.Name(), не Child.Name()

В Java/C++ было бы "hello from child" (виртуальная диспетчеризация). В Go — нет.

Нет super: нельзя вызвать "родительскую" версию метода, потому что нет родителя.

Композиция без встраивания

go
type Server struct { logger Logger // обычное поле — методы НЕ всплывают Host string } s.Log("x") // ОШИБКА — нет такого метода s.logger.Log("x") // ОК — через поле

Когда что использовать

Встраивание: когда хочешь "проксировать" интерфейс (io.ReadWriter встраивает Reader + Writer).

Обычное поле: когда зависимость — деталь реализации, не часть API.


  • Конвенция Go: геттер = Name(), НЕ GetName(). Сеттер = SetName(). Геттер без префикса Get
  • Встраивание через указатель (*Logger) — методы всплывают так же, но zero value структуры содержит nil (паника при вызове)
  • Встраивание указателя позволяет нескольким объектам разделять один экземпляр встроенного типа

Конвенция именования

go
// ПЛОХО — Java-стиль func (u *User) GetName() string { return u.name } func (u *User) GetEmail() string { return u.email } // ХОРОШО — Go-стиль func (u *User) Name() string { return u.name } func (u *User) Email() string { return u.email } func (u *User) SetEmail(e string) { u.email = e } // сеттер — с префиксом Set

Effective Go: "если поле называется owner, геттер — Owner(), не GetOwner()".

Когда нужны геттеры/сеттеры

Нужны: приватное поле, нужна валидация или побочный эффект при изменении.

go
func (a *Account) SetBalance(b int) error { if b < 0 { return errors.New("negative balance") } a.balance = b return nil }

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

go
type Point struct { X, Y float64 // публичные поля — норма для простых структур }

Встраивание через указатель

go
type Logger struct{ prefix string } func (l *Logger) Log(msg string) { fmt.Println(l.prefix + ": " + msg) }\n type App struct { *Logger // встраивание УКАЗАТЕЛЯ }

Плюс: несколько App могут разделять один Logger.

go
shared := &Logger{prefix: "APP"} a1 := App{Logger: shared} a2 := App{Logger: shared} // тот же логгер

Минус: zero value содержит nil — паника.

go
a := App{} // a.Logger == nil a.Log("x") // ПАНИКА: nil pointer dereference

При встраивании по значению паники не будет:

go
type App struct { Logger // по значению — zero value Logger, не nil } a := App{} a.Log("x") // ОК — вызовется на zero value Logger

Правило

Встраивай по значению если zero value встроенного типа работоспособен. Встраивай указатель если нужно разделять экземпляр или если тип работает только через конструктор.


  • В Go нет конструкторов. Конвенция: функция NewXxx() возвращает инициализированный объект
  • Возвращай *struct (конкретный тип), не интерфейс — "Accept interfaces, return structs"
  • Если zero value работоспособен (sync.Mutex, bytes.Buffer) — конструктор не нужен

Базовый паттерн

go
type Server struct { host string port int log *log.Logger } func NewServer(host string, port int) *Server { return &Server{ host: host, port: port, log: log.Default(), } }

Почему возвращать *struct, а не интерфейс

go
// ПЛОХО func NewServer(host string) ServerInterface { return &Server{host: host} } // ХОРОШО func NewServer(host string) *Server { return &Server{host: host} }

"Accept interfaces, return structs":

  • Вызывающий код решает, за какой интерфейс держать объект
  • Конкретный тип даёт доступ ко всем методам, не только к подмножеству интерфейса
  • Проще тестировать, проще рефакторить

Исключение: когда возвращаемый тип — реально скрытая деталь (unexported struct).

Functional Options

Для множества необязательных параметров:

go
type Option func(*Server) func WithPort(port int) Option { return func(s *Server) { s.port = port } } func WithLogger(l *log.Logger) Option { return func(s *Server) { s.log = l } } func NewServer(host string, opts ...Option) *Server { s := &Server{host: host, port: 8080, log: log.Default()} for _, opt := range opts { opt(s) } return s } // Использование: s := NewServer("localhost", WithPort(9090), WithLogger(myLog))

Когда конструктор НЕ нужен

Если zero value пригоден к использованию — не пиши конструктор:

go
var mu sync.Mutex // готов к использованию var buf bytes.Buffer // готов к использованию var wg sync.WaitGroup // готов к использованию

Это идиоматичный Go: проектируй структуры так, чтобы zero value был полезен.

Валидация

go
func NewEmail(raw string) (Email, error) { if !strings.Contains(raw, "@") { return "", fmt.Errorf("invalid email: %s", raw) } return Email(raw), nil }

Конструктор — единственное место для валидации, т.к. в Go нет конструкторов класса.


  • Методы можно определять для ЛЮБОГО именованного типа: type MyInt int, type Handler func(), type StringSlice string
  • Два ограничения: тип должен быть определён в том же пакете, и нельзя на интерфейсе
  • Именованный тип на основе примитива — мощный паттерн: enum'ы, валидация, Stringer, sort.Interface

Методы на примитивах

go
type Celsius float64 type Fahrenheit float64 func (c Celsius) ToFahrenheit() Fahrenheit { return Fahrenheit(c*9/5 + 32) } func (c Celsius) String() string { return fmt.Sprintf("%.1f°C", c) } temp := Celsius(36.6) fmt.Println(temp) // "36.6°C" (Stringer) fmt.Println(temp.ToFahrenheit()) // 97.88

Методы на функциональных типах

go
type HandlerFunc func(w http.ResponseWriter, r *http.Request) func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) { f(w, r) // функция сама себя вызывает через метод }

Это реальный паттерн из net/http. HandlerFunc — функция, но реализует интерфейс Handler.

Методы на слайсах

go
type IntSlice []int func (s IntSlice) Len() int { return len(s) } func (s IntSlice) Less(i, j int) bool { return s[i] < s[j] } func (s IntSlice) Swap(i, j int) { s[i], s[j] = s[j], s[i] } // Теперь sort.Sort(IntSlice(mySlice)) работает

Два ограничения

1. Тот же пакет: нельзя добавить метод к чужому типу.

go
func (s string) Reverse() string {} // ОШИБКА: string определён не здесь

Решение — обёртка:

go
type MyString string func (s MyString) Reverse() string { /* ... */ }

2. Нельзя на интерфейсе: интерфейс описывает поведение, не имеет реализации.

go
func (r io.Reader) Count() int {} // ОШИБКА

Enum-паттерн

go
type Status int const ( StatusPending Status = iota StatusActive StatusClosed ) func (s Status) String() string { switch s { case StatusPending: return "pending" case StatusActive: return "active" case StatusClosed: return "closed" default: return "unknown" } } func (s Status) IsTerminal() bool { return s == StatusClosed }

  • В Go нет перегрузки методов (method overloading) и нет перегрузки операторов. Одно имя = одна функция
  • Причина: простота. Перегрузка усложняет чтение кода (какая версия вызвана?) и ломает имплицитные интерфейсы
  • Альтернативы: разные имена, variadic args, interface{}/any, functional options

Что не работает

go
// ОШИБКА КОМПИЛЯЦИИ — нельзя два метода с одним именем func (s *Server) Start() error { /* ... */ } func (s *Server) Start(port int) error { /* ... */ }
go
// ОШИБКА — нет перегрузки операторов func (v Vector) +(other Vector) Vector { /* ... */ }

Почему так

1. Простота чтения: увидел s.Start() — точно знаешь какая функция вызвана. Нет неоднозначности.

2. Имплицитные интерфейсы сломались бы:

go
type Starter interface { Start() error }

Если бы у Server было два метода Start (с разными сигнатурами) — какой из них "реализует" интерфейс? Структурная типизация требует однозначности.

3. Философия Go: "There should be one — and preferably only one — obvious way to do it." Одно имя → одно поведение.

Альтернативы

Разные имена (самый идиоматичный):

go
func (s *Server) Start() error { return s.StartOnPort(8080) } func (s *Server) StartOnPort(port int) error { /* ... */ }

Variadic arguments:

go
func (s *Server) Start(opts ...int) error { port := 8080 if len(opts) > 0 { port = opts[0] } // ... }

Functional options (для сложных случаев):

go
func (s *Server) Start(opts ...Option) error { /* ... */ }

any / interface{} (крайний случай, теряем типобезопасность):

go
func Print(args ...any) { /* fmt.Println так и работает */ }

А перегрузка операторов?

Go не позволяет a + b для кастомных типов. Причина: + должен быть предсказуемым. Если видишь a + b — это числа или строки, не вызов произвольного кода с побочными эффектами.

Альтернатива: метод.

go
result := vec1.Add(vec2) // явно и читаемо

  • Нельзя добавить метод к типу из другого пакета напрямую. Компилятор запретит
  • Два способа: обёртка (type MyType OriginalType) или композиция (struct с встраиванием)
  • Обёртка — новый тип, теряет методы оригинала. Композиция — сохраняет методы через embedding

Проблема

go
import "net/http" // ОШИБКА: cannot define new methods on non-local type http.Request func (r *http.Request) GetUserID() int { /* ... */ }

Способ 1: Обёртка (type alias на основе)

go
type Headers http.Header func (h Headers) GetAuth() string { return http.Header(h).Get("Authorization") // приведение типа }

Плюс: простой новый тип.

Минус: Headers — это НЕ http.Header. Методы оригинала потеряны.

go
var h Headers h.Get("X-Custom") // ОШИБКА — у Headers нет метода Get http.Header(h).Get("X-Custom") // ОК — через приведение

Способ 2: Композиция (embedding)

go
type RichRequest struct { *http.Request // встраивание — все методы Request доступны } func (r *RichRequest) UserID() int { // используем методы оригинала напрямую id, _ := strconv.Atoi(r.Header.Get("X-User-ID")) return id } // Все оригинальные методы работают: rr := &RichRequest{Request: req} rr.FormValue("name") // метод http.Request — доступен rr.UserID() // наш новый метод

Плюс: сохраняет все методы оригинала + добавляет новые.

Минус: это другой тип, нельзя передать где ждут *http.Request (но можно достать через rr.Request).

Способ 3: Интерфейс-обёртка

go
type ReadCounter struct { io.Reader count int64 } func (rc *ReadCounter) Read(p []byte) (int, error) { n, err := rc.Reader.Read(p) // делегируем оригиналу rc.count += int64(n) // добавляем поведение return n, err } // ReadCounter реализует io.Reader — можно использовать везде

Это паттерн декоратор: оборачиваем интерфейс, добавляем функциональность.

Сводка

СпособМетоды оригиналаНовые методыСовместимость типов
type X OrigTypeПотеряныДаПриведение типа
struct + embeddingВсплываютДаЧерез поле
Интерфейс-обёрткаДелегируютсяДа (override)По интерфейсу

  • Shadowing: если внешняя структура объявляет поле/метод с тем же именем что у встроенной — внешнее "затеняет" внутреннее
  • Затенённое поле/метод не удаляется — доступно через явное имя встроенного типа
  • При двух встроенных типах с одинаковым методом — ambiguous, компилятор требует явный вызов

Shadowing поля

go
type Base struct { Name string } type Extended struct { Base Name string // затеняет Base.Name } e := Extended{ Base: Base{Name: "base"}, Name: "extended", } fmt.Println(e.Name) // "extended" — внешнее поле fmt.Println(e.Base.Name) // "base" — всё ещё доступно

Shadowing метода

go
type Logger struct{} func (Logger) Log() { fmt.Println("Logger.Log") }\n type App struct{ Logger } func (App) Log() { fmt.Println("App.Log") } // затеняет Logger.Log\n a := App{} a.Log() // "App.Log" a.Logger.Log() // "Logger.Log" — явный доступ

Это НЕ переопределение (override). Base не знает о затенении, виртуальной диспетчеризации нет.

Конфликт двух встроенных типов

go
type A struct{} func (A) Hello() { fmt.Println("A") }\n type B struct{} func (B) Hello() { fmt.Println("B") }\n type C struct { A B } c := C{} c.Hello() // ОШИБКА компиляции: ambiguous selector c.Hello c.A.Hello() // ОК — "A" c.B.Hello() // ОК — "B"

Решение конфликта: либо явный вызов, либо определи свой метод Hello на C (он затенит оба).

go
func (c C) Hello() { c.A.Hello() } // явный выбор

Правило приоритета

Компилятор ищет метод/поле по "глубине": сначала на самом типе, потом на встроенных типах глубины 1, потом глубины 2 и т.д. Одинаковая глубина + одинаковое имя = ошибка компиляции.


Каждый модуль (пакет, структура, функция) отвечает за одну вещь. Одна причина для изменения.

go
// ❌ Плохо: UserService делает всё type UserService struct{ db *sql.DB } func (s *UserService) Create(u User) error { ... } func (s *UserService) SendEmail(to, body string) error { ... } func (s *UserService) GenerateReport() ([]byte, error) { ... } // Изменения в email-логике ломают UserService // ✅ Хорошо: каждый сервис — одна ответственность type UserService struct{ db *sql.DB } func (s *UserService) Create(u User) error { ... } type EmailService struct{ smtp *SMTPClient } func (s *EmailService) Send(to, body string) error { ... } type ReportService struct{ db *sql.DB } func (s *ReportService) Generate() ([]byte, error) { ... }

Как понять что нарушается:

  • Структура имеет методы из разных доменов (юзеры + email + отчёты)
  • При изменении одной фичи приходится менять несвязанный код
  • Название сервиса слишком общее (Manager, Handler, Utils)

Открыт для расширения, закрыт для модификации. Добавляем новое поведение не меняя существующий код.

go
// ❌ Плохо: добавление нового типа уведомления — правим существующую функцию func Notify(method string, msg string) { if method == "email" { sendEmail(msg) } else if method == "sms" { sendSMS(msg) } // добавить telegram? правим эту функцию } // ✅ Хорошо: новый тип — новая структура, старый код не трогаем type Notifier interface { Notify(msg string) error } type EmailNotifier struct{ ... } func (e *EmailNotifier) Notify(msg string) error { ... } type SMSNotifier struct{ ... } func (s *SMSNotifier) Notify(msg string) error { ... } // Добавить telegram — просто новая структура: type TelegramNotifier struct{ ... } func (t *TelegramNotifier) Notify(msg string) error { ... } // Существующий код не меняется: func Send(n Notifier, msg string) error { return n.Notify(msg) }

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

go
type Storage interface { Save(data []byte) error Load(key string) ([]byte, error) } type FileStorage struct{ ... } type S3Storage struct{ ... } type RedisStorage struct{ ... } // Все три можно подставить — поведение корректно func ProcessData(s Storage) error { err := s.Save(data) // работает одинаково с любой реализацией return err }

Нарушение — реализация меняет контракт:

go
// ❌ Нарушение: одна реализация паникует вместо ошибки func (r *BrokenStorage) Save(data []byte) error { panic("not implemented") // вызывающий код ожидает error, а не panic } // ❌ Нарушение: Save молча ничего не делает func (r *NoopStorage) Save(data []byte) error { return nil // данные не сохранены, но ошибки нет — обманываем вызывающий код }

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

Много маленьких интерфейсов лучше одного большого. Потребитель зависит только от того что ему нужно.

go
// ❌ Плохо: God-interface type Storage interface { Save(data []byte) error Load(key string) ([]byte, error) Delete(key string) error List() ([]string, error) Watch(key string) <-chan Event Backup() error } // Функции которой нужно только читать — вынуждена зависеть от Backup, Watch... // ✅ Хорошо: маленькие интерфейсы type Reader interface { Load(key string) ([]byte, error) } type Writer interface { Save(data []byte) error } type Deleter interface { Delete(key string) error } // Каждая функция берёт только то что нужно func ProcessData(r Reader) error { ... } // нужно только чтение func SaveReport(w Writer) error { ... } // нужна только запись func Cleanup(d Deleter) error { ... } // нужно только удаление // Если нужно и то и другое — композиция type ReadWriter interface { Reader Writer }

Стандартная библиотека Go — образец: io.Reader (1 метод), io.Writer (1 метод), io.ReadWriter (композиция).

Зависим от интерфейса (абстракции), не от конкретного типа. Зависимости передаём снаружи через конструктор.

Общее правило: всё что делает I/O → за интерфейс. Чистая логика → конкретные типы.

go
// ❌ Плохо: сервис создаёт зависимость сам, привязан к PostgreSQL type OrderService struct { db *pgx.Pool } func NewOrderService() *OrderService { pool, _ := pgx.Connect(...) // жёсткая зависимость return &OrderService{db: pool} } // ✅ Хорошо: сервис принимает интерфейс type OrderRepo interface { Save(order Order) error GetByID(id string) (*Order, error) } type OrderService struct { repo OrderRepo // зависит от интерфейса } func NewOrderService(repo OrderRepo) *OrderService { return &OrderService{repo: repo} }

main собирает зависимости:

go
func main() { db := connectPostgres() repo := postgres.NewOrderRepo(db) // конкретная реализация service := NewOrderService(repo) // передаём через конструктор handler := NewHandler(service) http.ListenAndServe(":8080", handler) }

Что закрывать за интерфейсом:

  • БД (repo) — основной кандидат
  • Внешние API (биржи, платёжки, SMS)
  • Кэш (Redis)
  • Очереди (Kafka)
  • Файловое хранилище (S3)

В тестах:

go
type MockRepo struct{} func (m *MockRepo) Save(order Order) error { return nil } func (m *MockRepo) GetByID(id string) (*Order, error) { return &Order{ID: id, Status: "active"}, nil } func TestOrderService(t *testing.T) { service := NewOrderService(&MockRepo{}) // подменили БД на мок // тестируем бизнес-логику без реальной БД }