Go — не классический ООП язык. Нет классов, наследования, конструкторов, перегрузки методов.
Что есть:
| ООП концепция | Как в Go |
|---|---|
| Инкапсуляция | Exported/unexported (заглавная буква) |
| Полиморфизм | Интерфейсы (неявная реализация) |
| Наследование | ❌ Нет. Вместо него — композиция (embedding) |
| Классы | Структуры + методы |
| Конструкторы | Функции New...() по конвенции |
| Абстрактные классы | Интерфейсы |
// "Класс" в 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 — только два уровня: видно снаружи пакета или нет
Правило
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 напрямую.
// Файл user.go
type User struct { email string }
// Файл admin.go (тот же пакет user)
func resetEmail(u *User) {
u.email = "" // ОК — тот же пакет, хоть и другой файл
}
Нет protected: нет наследования — нет смысла в "видно только наследникам".
Зачем так
Go поощряет маленькие пакеты с чётким API. Пакет = команда из 1-3 файлов, все авторы контролируют инварианты. Если пакет разросся — разбей на подпакеты.
Подвох на собесе
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"
- Определяй интерфейс на стороне потребителя, не на стороне реализации
Имплицитная реализация
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: связь между типом и интерфейсом — структурная (по набору методов), не номинальная (по объявлению).
Интерфейс на стороне потребителя
// ПЛОХО — интерфейс рядом с реализацией
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 { /* ... */ }
Потребитель определяет что ему нужно. Реализация ничего не знает об интерфейсе.
Композиция интерфейсов
type Reader interface { Read(p []byte) (int, error) }
type Writer interface { Write(p []byte) (int, error) }
type ReadWriter interface {
Reader
Writer
}
Проверка реализации в compile time
var _ Writer = (*FileWriter)(nil) // ошибка компиляции если не реализует
Type switch — "pattern matching"
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)
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") // эквивалентно — явный вызов
Компилятор не копирует методы. Он генерирует обёртку:
// Компилятор неявно создаёт:
func (s Server) Log(msg string) { s.Logger.Log(msg) }
Чем это НЕ наследование
Нет is-a: Server не является Logger. Нельзя передать Server туда, где ждут Logger.
func useLogger(l Logger) {}
useLogger(s) // ОШИБКА компиляции
useLogger(s.Logger) // ОК — явно достаём встроенное поле
Нет виртуальных методов: встроенный тип не знает о внешнем.
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: нельзя вызвать "родительскую" версию метода, потому что нет родителя.
Композиция без встраивания
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 (паника при вызове)
- Встраивание указателя позволяет нескольким объектам разделять один экземпляр встроенного типа
Конвенция именования
// ПЛОХО — 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()".
Когда нужны геттеры/сеттеры
Нужны: приватное поле, нужна валидация или побочный эффект при изменении.
func (a *Account) SetBalance(b int) error {
if b < 0 { return errors.New("negative balance") }
a.balance = b
return nil
}
Не нужны: если поле можно сделать экспортированным без последствий — сделай публичным. Go не заставляет оборачивать всё в геттеры.
type Point struct {
X, Y float64 // публичные поля — норма для простых структур
}
Встраивание через указатель
type Logger struct{ prefix string }
func (l *Logger) Log(msg string) { fmt.Println(l.prefix + ": " + msg) }\n
type App struct {
*Logger // встраивание УКАЗАТЕЛЯ
}
Плюс: несколько App могут разделять один Logger.
shared := &Logger{prefix: "APP"}
a1 := App{Logger: shared}
a2 := App{Logger: shared} // тот же логгер
Минус: zero value содержит nil — паника.
a := App{} // a.Logger == nil
a.Log("x") // ПАНИКА: nil pointer dereference
При встраивании по значению паники не будет:
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) — конструктор не нужен
Базовый паттерн
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, а не интерфейс
// ПЛОХО
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
Для множества необязательных параметров:
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 пригоден к использованию — не пиши конструктор:
var mu sync.Mutex // готов к использованию
var buf bytes.Buffer // готов к использованию
var wg sync.WaitGroup // готов к использованию
Это идиоматичный Go: проектируй структуры так, чтобы zero value был полезен.
Валидация
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
Методы на примитивах
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
Методы на функциональных типах
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.
Методы на слайсах
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. Тот же пакет: нельзя добавить метод к чужому типу.
func (s string) Reverse() string {} // ОШИБКА: string определён не здесь
Решение — обёртка:
type MyString string
func (s MyString) Reverse() string { /* ... */ }
2. Нельзя на интерфейсе: интерфейс описывает поведение, не имеет реализации.
func (r io.Reader) Count() int {} // ОШИБКА
Enum-паттерн
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
Что не работает
// ОШИБКА КОМПИЛЯЦИИ — нельзя два метода с одним именем
func (s *Server) Start() error { /* ... */ }
func (s *Server) Start(port int) error { /* ... */ }
// ОШИБКА — нет перегрузки операторов
func (v Vector) +(other Vector) Vector { /* ... */ }
Почему так
1. Простота чтения: увидел s.Start() — точно знаешь какая функция вызвана. Нет неоднозначности.
2. Имплицитные интерфейсы сломались бы:
type Starter interface {
Start() error
}
Если бы у Server было два метода Start (с разными сигнатурами) — какой из них "реализует" интерфейс? Структурная типизация требует однозначности.
3. Философия Go: "There should be one — and preferably only one — obvious way to do it." Одно имя → одно поведение.
Альтернативы
Разные имена (самый идиоматичный):
func (s *Server) Start() error { return s.StartOnPort(8080) }
func (s *Server) StartOnPort(port int) error { /* ... */ }
Variadic arguments:
func (s *Server) Start(opts ...int) error {
port := 8080
if len(opts) > 0 { port = opts[0] }
// ...
}
Functional options (для сложных случаев):
func (s *Server) Start(opts ...Option) error { /* ... */ }
any / interface{} (крайний случай, теряем типобезопасность):
func Print(args ...any) { /* fmt.Println так и работает */ }
А перегрузка операторов?
Go не позволяет a + b для кастомных типов. Причина: + должен быть предсказуемым. Если видишь a + b — это числа или строки, не вызов произвольного кода с побочными эффектами.
Альтернатива: метод.
result := vec1.Add(vec2) // явно и читаемо
- Нельзя добавить метод к типу из другого пакета напрямую. Компилятор запретит
- Два способа: обёртка (type MyType OriginalType) или композиция (struct с встраиванием)
- Обёртка — новый тип, теряет методы оригинала. Композиция — сохраняет методы через embedding
Проблема
import "net/http"
// ОШИБКА: cannot define new methods on non-local type http.Request
func (r *http.Request) GetUserID() int { /* ... */ }
Способ 1: Обёртка (type alias на основе)
type Headers http.Header
func (h Headers) GetAuth() string {
return http.Header(h).Get("Authorization") // приведение типа
}
Плюс: простой новый тип.
Минус: Headers — это НЕ http.Header. Методы оригинала потеряны.
var h Headers
h.Get("X-Custom") // ОШИБКА — у Headers нет метода Get
http.Header(h).Get("X-Custom") // ОК — через приведение
Способ 2: Композиция (embedding)
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: Интерфейс-обёртка
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 поля
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 метода
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 не знает о затенении, виртуальной диспетчеризации нет.
Конфликт двух встроенных типов
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 (он затенит оба).
func (c C) Hello() { c.A.Hello() } // явный выбор
Правило приоритета
Компилятор ищет метод/поле по "глубине": сначала на самом типе, потом на встроенных типах глубины 1, потом глубины 2 и т.д. Одинаковая глубина + одинаковое имя = ошибка компиляции.
Каждый модуль (пакет, структура, функция) отвечает за одну вещь. Одна причина для изменения.
// ❌ Плохо: 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)
Открыт для расширения, закрыт для модификации. Добавляем новое поведение не меняя существующий код.
// ❌ Плохо: добавление нового типа уведомления — правим существующую функцию
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)
}
Любая реализация интерфейса должна быть взаимозаменяема без поломки логики. Если функция работает с интерфейсом, подставь любую реализацию — программа должна остаться корректной.
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
}
Нарушение — реализация меняет контракт:
// ❌ Нарушение: одна реализация паникует вместо ошибки
func (r *BrokenStorage) Save(data []byte) error {
panic("not implemented") // вызывающий код ожидает error, а не panic
}
// ❌ Нарушение: Save молча ничего не делает
func (r *NoopStorage) Save(data []byte) error {
return nil // данные не сохранены, но ошибки нет — обманываем вызывающий код
}
Правило: если функция работает с интерфейсом, подставь любую реализацию — программа должна остаться корректной.
Много маленьких интерфейсов лучше одного большого. Потребитель зависит только от того что ему нужно.
// ❌ Плохо: 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 → за интерфейс. Чистая логика → конкретные типы.
// ❌ Плохо: сервис создаёт зависимость сам, привязан к 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 собирает зависимости:
func main() {
db := connectPostgres()
repo := postgres.NewOrderRepo(db) // конкретная реализация
service := NewOrderService(repo) // передаём через конструктор
handler := NewHandler(service)
http.ListenAndServe(":8080", handler)
}
Что закрывать за интерфейсом:
- БД (repo) — основной кандидат
- Внешние API (биржи, платёжки, SMS)
- Кэш (Redis)
- Очереди (Kafka)
- Файловое хранилище (S3)
В тестах:
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{}) // подменили БД на мок
// тестируем бизнес-логику без реальной БД
}