Go vs GoF: положите паттерны ООП на пол и отойдите
- четверг, 2 июля 2026 г. в 00:00:11
Меня зовут Нина Пакшина, и я работаю в IT уже более 15 лет. Я программировала ПЛК на языках МЭК, писала на Python. И последние 5 лет я пишу на Go в Lenta Tech (ИТ-бренд «Группы Лента»).
Эту статью я пишу по следам своего доклада в BI.ZONE весной 2026. Cпустя пару месяцев после доклада, я немного сбавила категоричность относительно ООП-паттернов в Go. Но название статьи оставляю прежним, так как и в первый раз оно было умышленно провокационным.
Язык Go сейчас очень популярен, и на него переходит много разработчиков с Java, C#, PHP и Python.
У многих возникают проблемы с Go по одной любопытной причине: все кажется слишком простым. «Где наш фабричный метод?», «Где наследование?», «Где привычные паттерны?».
Больше 30 лет назад «банда четырех», она же GoF, выпустила книгу Design Patterns, где впервые описала 23 паттерна, которые сейчас считаются классическими. Это была революция: раньше приходилось долго объяснять, что ты делаешь в коде. А теперь можно просто сказать: «Я пишу адаптер».
Пару раз я встречала в описаниях вакансий на позицию Go‑разработчика требования знать ООП‑паттерны. Или слышала мнение, что Go частично реализует ООП, или что это вообще недо‑ООП язык. При этом сами разработчики языка на вопрос «Является ли Go ООП языком?» отвечают уклончиво: и да, и нет.
В этой статье я хочу обсудить особенности Go, которые, на мой взгляд, формируют собственные паттерны — те, которые в какой-то степени реализуют поведение классических ООП‑паттернов, но делают это в своем гошном стиле.
В Go функции — это объекты первого класса. Это означает, что они существуют вне «классов» (в отличие от Java или C#, где нельзя объявить функцию вне класса).
В Go функция может быть аргументом другой функции, может возвращаться как результат, храниться в словаре и слайсе. И на этом строится множество паттернов. Вместо иерархии объектов в Go используется композиция функций. А поведение кода формируется их комбинацией.
Вот интересный пример, который есть в исходниках языка Go (файл lex.go пакета parse):
// stateFn represents the state of the scanner as // a function that returns the next state. type stateFn func(*lexer) stateFn
Тип stateFn — это состояние сканера. Каждая такая функция возвращает следующую функцию‑состояние (полный код). Получается цепочка: одна функция возвращает другую, та — следующую, и так далее.
Получается, это state‑машина, реализованная через вызовы функций:
func lexComment(l *lexer) stateFn { ... return lexText } func lexText(l *lexer) stateFn { ... return lexLeftDelim ... }
Чтобы пройти такую машину, достаточно просто итерироваться по состояниям: вызываем текущее состояние, получаем следующее, вызываем его — и так, пока не дойдем до конечного состояния. Когда дошли — цепочка событий завершена.
func (l *lexer) nextItem() item { ... for { state = state(l) if state == nil { return l.item } } }
В Go есть еще одна важная особенность — интерфейсы.
Наверняка вы знаете интерфейс io.Reader. Это интерфейс, который содержит всего один метод — Read:
type Reader interface { Read(p []byte) (n int, err error) }
Интерфейсы в Go обычно маленькие и определяются на стороне потребителя. Конечно, вы можете сделать интерфейс и на стороне исполнителя, но хорошей практикой считается именно ограничивающий подход.
Любая структура, у которой есть метод Read(b []byte) (n int, err error), автоматически является io.Reader:
// Read implements the [io.Reader] interface. func (r *Reader) Read(b []byte) (n int, err error) { ... return }
Иногда в литературе в отношении Go можно встретить выражение «утиная типизация», но сами разработчики языка говорят, что в Go используется структурная типизация.
Как работает структурная типизация? Например, у нас есть структура Duck, которая реализует метод Quack. Есть интерфейс Quacker с методом Quack. В Go структура Duck неявно удовлетворяет интерфейсу Quacker, то есть без какой-либо явной привязки:
type Duck struct{} func (d Duck) Quack() {} type Quacker interface { Quack() } func main() { // Простая проверка на соответствие интерфейсу. var d interface{} = Duck{} if _, ok := d.(Quacker); ok { fmt.Println("Duck реализует Quacker") } }
В отличие от Java, где имплементацию нужно написать явно, через implements:
class Duck implements Quacker {} interface Quacker { void quack(); } class Duck implements Quacker { public void quack() { System.out.println("quack"); } }
В Go есть еще одна интересная особенность — Zero Value. Когда мы инициализируем структуру, все её поля автоматически получают нулевые значения.
Для bool это false, для строки — пустая строка "", для чисел — ноль 0 и так далее.
Если продумать структуру заранее, то Zero Value можно использовать как способ задать безопасное поведение по умолчанию.
Что это значит?
Посмотрим для примера на исходники Go, а конкретно на реализацию дескриптора горутины:
type g struct { ... // asyncSafePoint is set if g is stopped at an asynchronous // safe point. This means there are frames on the stack // without precise pointer information. asyncSafePoint bool ... }
В структуре g поле asyncSafePoint описывает состояние, в котором на стеке горутины есть фреймы с неактуальной информацией об указателях.
Детали не важны — важно то, что при создании горутины структура инициализируется, и это поле по умолчанию находится в безопасном состоянии:
func malg(stacksize int32) *g { newg := new(g) ... }
То есть новая горутина сразу может выполняться, обеспечивая безопасное поведение «из коробки» (asyncSafePoint будет иметь значение по умолчанию false).
Если же нам нужно включить какое‑то «опасное» или «особенное» состояние, мы должны явно записать в поле значение true:
func asyncPreempt2() { ... mcall(func(gp *g) { gp.asyncSafePoint = true ... }) }
Получается, что с помощью Zero Value можно создать «из коробки» структуру, которая подготовлена к безопасной работе. А любое особое состояние записывается явно.
Это хороший стиль: Zero Value задает корректное состояние, а все нестандартное — только по явному запросу.
Embedding (встраивание) в Go — это скорее композиция, а не inheritance (наследование). Мы можем «встроить» в нашу структуру другую структуру, у которой уже есть готовые методы, и использовать их через нашу структуру.
Однако у встраивания в Go есть важная особенность: методы встроенной структуры можно затенять.
Например, в структуре handlers был свой метод Lock, и он затенил Lock от sync.Mutex. Если я вызываю h.Lock(), вызывается переопределенный метод.
При этом изначальный метод мьютекса никуда не делся — он просто затенен, и его все еще можно вызвать напрямую через h.Mutex.Lock().
type handlers struct { sync.Mutex ... } func (h *handlers) Lock() { fmt.Println("lock") } func main() { h := &handlers{} h.Lock() // вызов метода handlers h.Mutex.Lock() // вызов метода встроенного Mutex ... }
В Go методы первичной структуры затеняет методы встроенных структур, но эти методы по‑прежнему доступны.
Это отличается от Java или C#, где при наследовании дочерний класс переопределяет метод родительского, и доступ к родительской реализации теряется:
class Animal { void Eat() { System.out.println("Animal"); } } class Dog extends Animal { void Eat() { System.out.println("Dog"); } } Animal a = new Dog(); a.Eat(); // Dog
К слову, в ООП‑языках также давно продвигают принцип composition over inheritance — лучше избегать наследования и использовать композицию.
Еще больше особенностей в Go добавляют его собственные примитивы конкурентности — каналы, контекст, errgroup. Все это рождает огромное количество конкурентных паттернов, характерных именно для Go. Они появляются благодаря примитивам языка и тому, как Go предлагает организовывать параллельную работу.
Подробнее я не буду о них рассказывать — о них можно почитать здесь:
Мы разобрали особенности Go, но что все это значит на практике? Есть ли в Go ООП‑паттерны? Давайте порассуждаем вместе.
У нас есть функция Process, которая один из аргументов принимает io.Reader:
func Process(str string, r io.Reader) error { buf := []byte(str) n, err := r.Read(buf) if err != nil && err != io.EOF { return fmt.Errorf("read: %w", err) } fmt.Printf("'%s' read %d bytes\n", str, n) return nil }
В эту функцию мы можем передать любую структуру, которая реализует метод Read(b []byte) (n int, err error), например, чтение строки, чтение из файла, собственную структуру:
type MyReader struct{} func (r MyReader) ConvertToRunes(b []byte) []rune { return []rune(string(b)) } func (r MyReader) Read(p []byte) (n int, err error) { runes := r.ConvertToRunes(p) for _, b := range runes { fmt.Printf("%v ", b) } return len(runes), nil }
Поведение Process определяется переданной структурой и поведением метода Read:
func main() { // Reader строки r1 := strings.NewReader("hello from string") if err := Process("string", r1); err != nil { panic(err) } // Собственный Reader r2 := MyReader{} if err := Process("reader", r2); err != nil { panic(err) } }
Не напоминает ли это паттерн «Стратегия»?
Еще один пример, который на самом деле очень часто используется в Go. У нас есть путь и функция‑обработчик определенной сигнатуры, которые регистрируются в мультиплексере сервера:
func main(){ ... mux := http.NewServeMux() mux.HandleFunc("/flag", FlagMethod) ... } func FlagMethod(w http.ResponseWriter, req *http.Request) { ... }
Не похоже ли это на паттерн «Стратегия»? Мое мнение — да! Просто в Go это выражено функциями, а не объектами.
Мы можем определить структуру так, чтобы ее нулевое состояние было безопасным. Например, в Downloader поле DeleteAfterDownload по умолчанию false, и это безопасное поведение. Если нужно опасное — включаем явно.
type Downloader struct { DeleteAfterDownload bool } d := &Downloader{} // DeleteAfterDownload = false func (d *Downloader) DownloadOnServer(path string) error { if d.DeleteAfterDownload { if err := os.Remove(path); err != nil { ... } } }
Это напоминает паттерн Конструктора. При этом установка безопасного значения по умолчанию встроена в сам язык через Zero Value.
Еще один интересный специфический паттерн — Functional Options (или «Option types»), который позволяет гибко инициализировать структуры с помощью функциональных параметров, передаваемых в конструктор. Подробнее можно почитать тут.
Например, инициализация структуры через NewDownloader позволяет передавать специфические параметры через опции :
type Options func(d *Downloader) func WithDeleteAfterDownload() Options { return func(d *Downloader) { d.deleteAfterDownload = true } } func NewDownloader(opts ...Options) *Downloader { d := &Downloader{} for _, opt := range opts { opt(d) } return d } func main(){ loader := NewDownloader(WithDeleteAfterDownload()) ... }
Это гошный вариант конструктора, который помогает задать гибкую конфигурацию объекта.
Как мы увидели, некоторые ООП‑паттерны действительно существуют в Go, но их реализация заметно отличается от привычной семантики классических ООП‑паттернов. Более того, со временем эти паттерны начинают размываться и становятся практически неотличимыми от самой архитектуры языка.
Но в Go можно реализовать ООП‑паттерны, которые практически идеально и без серьезных преобразований ложатся на особенности языка. Сейчас рассмотрим один из них.
Представьте, что у нас есть несколько сценариев передачи заказа клиенту: доставка до дома и самовывоз из магазина.
Каждый сценарий состоит из нескольких последовательных шагов. Какие-то шаги могут совпадать для разных сценариев, могут выполняться в разных последовательностях:

Если мы будем писать код «в лоб», мы можем сделать отдельные модули для каждого сценария и использовать if для определения перехода на следующий шаг. Но как сделать код более гибким, проще переиспользовать шаги и быстро менять их местами?
Для этого можно использовать ООП-паттерн — «Цепочка обязанностей».
Сначала, мы определяем интерфейс Chain для шага цепочки:
package chain type Chain interface { Next() Chain SetNext(Chain) Execute() }
Общее поведение можно вынести в базовую структуру:
type chainBase struct { next Chain } func (b *chainBase) Next() Chain { return b.next } func (b *chainBase) SetNext(chain Chain) { b.next = chain }
Для каждого шага реализуем свою структуру и метод Execute(), а базовое поведение будет встроено в каждую конкретную реализацию шага через embedding структуры chainBase:
type CreateOrder struct { chainBase // Embedding базового поведения. } func (c *CreateOrder) Execute() { // Здесь реализация шага "создание заказа". ... if c.Next() != nil { c.Next().Execute() } }
После того как каждый шаг реализован, их нужно связать между собой. Для этого мы должны построить цепочку шагов:

Для простой линковки шагов можно написать вспомогательную функцию:
func BuildChain(steps ...Chain) Chain { for i := 0; i < len(steps)-1; i++ { steps[i].SetNext(steps[i+1]) } return steps[0] }
И в зависимости от сценария достаточно запустить нужную цепочку шагов:
const ( pickup = "самовывоз" delivery = "доставка" ) func startProcess(orderType string) { switch orderType { case delivery: orderFlow := chain.BuildChain( &chain.CreateOrder{}, &chain.PaidOrder{}, &chain.AssembledOrder{}, &chain.DeliveredOrder{}, &chain.CompletedOrder{}, ) orderFlow.Execute() case pickup: orderFlow := chain.BuildChain( &chain.CreateOrder{}, &chain.AssembledOrder{}, &chain.ClientArrived{}, &chain.PaidOrder{}, &chain.CompletedOrder{}, ) orderFlow.Execute() } } func main() { startProcess(delivery) }
Согласно задумке разработчиков Go частично реализует концепцию ООП-паттернов. Но при этом не копирует ООП‑паттерны. Go предлагает свои примитивы, и многие решения в нем выражаются проще. Сами паттерны — это не про ООП, а про инженерное мышление, и в Go они просто выглядят иначе. Переносить паттерн «как есть» без учета особенностей и семантики Go — не очень хорошая идея.
Нужно ли изучать ООП‑паттерны программистам Go?
Я считаю, что да.
Стоит ли спрашивать ООП-паттерны на собеседованиях у программистов Go?
Я считаю, что нет. Но не по причине, что это не актуально. Просто слишком много других важных вещей, о которых стоит поговорить в первую очередь.
Мое мнение, если вы только начинаете писать на Go, важнее сначала:
понять устоявшиеся правила написания кода — для этого отлично подходят исходники Go;
разобраться с конкурентными паттернами — это фундаментальная часть языка.
И уже после этого попробовать натянуть ООП-паттерны на семантику Go.