golang

Честный взгляд на Go: сильные стороны и болезненные ограничения

  • понедельник, 12 января 2026 г. в 00:00:05
https://habr.com/ru/articles/984048/

Команда Go for Devs подготовила перевод обзора языка Go от практикующего разработчика. Автор без прикрас разбирает сильные стороны Go — конкурентность, простоту и эргономику, — а затем подробно объясняет, почему его разочаровывают enum’ы, неизменяемость и модель ошибок.


Я написал несколько небольших проектов на Go, так что не стоит воспринимать всё ниже как экспертное мнение о языке. Это всего лишь мои первые впечатления от работы с ним.

Последние несколько месяцев я писал на Go. Сейчас я подумываю вернуться к Rust, но прежде хочу изложить, что мне в Go нравится, а что — нет.

Что мне нравится

Конкурентность

В отличие от большинства других языков, конкурентность в Go — не придаток, добавленный постфактум. Каналы и goroutine встроены прямо в язык как полноценные, первоклассные возможности, и, по моему опыту, с ними в основном приятно работать. Go удаётся избежать знаменитой проблемы «окрашенных функций», которая преследует модели конкурентности во многих других языках. Кроме того, каналы и оператор select в целом очень удобны в использовании. Реализовать корректную конкурентность чрезвычайно сложно, и тот факт, что Go в целом справился с этой задачей, действительно впечатляет.

Система типов

Система типов Go намеренно сделана очень простой и не допускает сложных иерархий наследования. Хотя в Go есть встраивание структур:

// all methods of Animal are now implemented on Dog
type Dog struct {
    Animal 
}

Это отличается от одиночного наследования, поскольку можно встраивать несколько структур, а само встраивание по своей сути является синтаксическим сахаром. Вместо того чтобы писать так:

type Animal struct { ... }

func (a Animal) DoSomething() { ... }

type Dog struct { Animal Animal }

func main() {
    Dog{}.Animal.DoSomething()
}

Вы пишете так:

type Animal struct { ... }

func (a Animal) DoSomething() { ... }

type Dog struct { Animal }

func main() {
    Dog{}.DoSomething()
}

При этом Dog может переопределить DoSomething:

func (d Dog) DoSomething() { ... }

func main( 
    Dog{}.DoSomething() 
)

Но исходная реализация всё равно остаётся доступной:

// both work
Dog{}.DoSomething()
Dog{}.Animal.DoSomething() 

Встраивание структур включает в себя не только методы, но и поля.

Кроме того, в Go структура не обязана явно объявлять, что она реализует интерфейс, чтобы считаться ему соответствующей. Это отличается от большинства других языков, где интерфейсы нужно реализовывать явно. Благодаря этому пустой интерфейс interface{} или any можно использовать для фактического введения динамической типизации — типы, например, можно различать во время выполнения с помощью type switch. Это делает такие вещи, как Printf, а также HTML- и текстовые шаблоны, гораздо проще для понимания и, что самое важное, позволяет реализовывать их без использования макросов, как в C и Rust.

Синтаксис

Если остальная часть этого текста ещё может делать вид, что она объективна, то здесь — чистое, неотфильтрованное мнение.

Мне нравится компактный синтаксис Go с точки зрения эргономики. Аннотации типов пишутся гораздо проще — без двоеточий и прочих символов, и это экономит время на набор.

Также мне нравится использование заглавных и строчных букв для управления видимостью:

// only accessible to the package
type hello struct{ ... }
// public
type World interface { ... }

Это просто логично.

И да, мне откровенно не нравится необходимость постоянно писать pub в Rust.

Что мне не нравится

Enum’ы¹

Да, я знаю, что эту тему уже затёрли до дыр, но она по-прежнему бесит, так что я тоже внесу свой вклад в добивание этой лошади — для надёжности.

Одна из самых нелюбимых мной вещей в Go — отсутствие какого-либо типа enum. Иногда просто хочется иметь enum, а Go делает это максимально неудобным. Самое распространённое и «общепринятое» решение — объявить набор констант, принадлежащих одному типу:

type State int

const (
    Off State = iota
    On
    Error
)

Это «решение» для enum’ов — как изолента для обрушившегося моста. Мало того что синтаксис неудобный и логически разорванный, он ещё и не выполняет свою основную задачу. Нет никакой гарантии, что любое значение State будет On, Off или Error. А значит, если вы не проверяете исчерпывающе все возможные значения State в каждом switch в каждой функции вашей программы, у вас вообще нет никаких гарантий. Пользователь вашего API может просто передать State(500), и остаётся только гадать, как именно программа сломается. В любом вменяемом языке был бы какой-то синтаксический сахар для групп констант, гарантирующий, что множество значений замкнуто, но Go предпочитает переложить эту работу на программиста.

Хуже того, Go даже не понимает, что в switch вы хотите исчерпывающую проверку.

// this code compiles without warnings!
var st State
switch st {
case On: ...
case Off: ...
}

Да, можно просто надеяться, что пользователь вашего API не станет делать глупости вроде передачи State(500), и, возможно, подключить линтеры для проверки исчерпываемости. Но это поразительно плохое решение, навязанное разработчику языком, который гордится своей простотой и элегантностью.

Неизменяемость

В Go есть два типа переменных: константы и изменяемые переменные, объявляются они так:

const a = 45 + 77
var b = 22

И вот в чём проблема: переменные, объявленные через const, должны быть константами времени компиляции. Во всех примерах ниже константе присваивается значение, известное на этапе компиляции, но ни один из них не сработает:

type A struct{ val int }
const a = A{3}

func B() int { return 3 }
const b = B()

const hash = map[string]int {
    "HELLO": 1,
    "WORLD": 2,
}

Какой же выход? Конечно, использовать var:

var a = A{3}
var b = b()
var hash = map[string]int {
    "HELLO": 1,
    "WORLD": 2,
}

Это абсолютно ужасное решение, особенно если ваш API будут использовать другие люди и эти символы экспортируются из пакета. Любой может изменить эти переменные и сломать ваш пакет. «Решение», которое обычно предлагают для этой проблемы, — использовать функцию:

var _data = map[string]int { ... }

func Data() map[string]int { return _data }

Формально это работает, но на практике — отвратительно.

Ошибки

В идиоматичном Go управление ошибками строится вокруг типа error. Например, безопасную функцию деления можно реализовать так:

func safe_divide(a,b int) (float, error) {
    if b == 0 { 
        return 0.0, fmt.Errorf("divide by zero") 
    }
    return a/b, nil
}

Используется это значение следующим образом:

res, err := safe_divide(4,2)
if err != nil {
    log.Fatal(err)
}
doSomethingWith(res)

Стало почти мемом жаловаться на якобы сложность постоянного написания if err != nil. Я не буду развивать эту мысль, потому что считаю её крайне поверхностной: её уже обсудили вдоль и поперёк, и я не думаю, что «многословность» этого фрагмента кода является реальной проблемой.

У меня есть две принципиальные претензии к такому подходу: одна связана с использованием «кортежа» в качестве возвращаемого значения, другая — с самим типом error.

Кортежей не существует

Этот тип — (T, error) — на самом деле не является полноценным типом. В системе типов Go кортежей нет. Такое выражение может быть обработано только через немедленное деструктурирование.

Например, следующий (пусть и искусственный) фрагмент кода не будет работать:

func doIfErrNil[T](val (T, error), f func(T)) {
    v, err := val
    if err != nil { return }
    f(v)
}

Это ограничение не позволяет делать чейнинг, который и так не считается идиоматичным (и, возможно, не без причины), а также вообще как-либо оперировать значениями вида (T, error).

Тип error — отстой

Вот полное определение типа error:

type error interface {
    Error() string
}

То есть, если я пишу библиотеку, которая запускает какие-то программы, я могу создать свой тип, реализующий error:

type progError struct {
    prog string
    code int
    reason string
}

func (e progError) Error() string {
    return fmt.Sprintf("%q returned code %d: %q", e.prog, e.code, e.reason)
}

В Go идиоматично «прятать» progError за возвращаемым значением типа error. Но тогда информация, которую мы храним в структуре, фактически теряется. У пользователя остаётся интерфейсное значение error, и единственное, что он может сделать, — получить строковое представление. Чем такая ошибка полезна людям, которые пишут программу? Да, её можно вывести на экран, но разве программисту не нужно делать разные вещи в зависимости от того, какой тип ошибки вернулся? В итоге у потребителя этой библиотеки остаётся один выход: парсить строку ошибки в поисках полезной информации. Это ужасная стратегия, потому что ничто не мешает авторам библиотеки изменить текст ошибки — а это, на мой взгляд, полностью на их усмотрение.

И хуже всего то, что это не гипотетический сценарий. Со мной это действительно случилось, когда я писал код, который использовал os.Stat для получения информации о файле ²:

func rootInfo(root, path string) (has bool, isDir bool, err error) {
    path = pathpkg.Clean(path)
    info, err := os.Stat(root + "/" + path)
    if errors.Is(err, os.ErrNotExist) {
        return false, false, nil
    }
    if err != nil && strings.HasSuffix(err.Error(), "not a directory") {
//                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//                   Manually checking if the error is a certain type of error
        return false, false, nil
    }
    if err != nil {
        return false, false, err
    }
    return true, info.IsDir(), nil
}

Да, этот код так себе, и, вероятно, был лучший способ. Вероятно, правда и то, что сообщение "not a directory" почти наверняка не изменится в будущем. Но проблема всё равно остаётся — и она вытекает из одного-единственного факта:

В Go ошибки — это значения. Просто это не особенно полезные значения.

Сравните это с тем, как с ошибками работает Rust.

В Rust ошибки тоже являются значениями, но это действительно полезные значения. Если я выполняю операцию ввода-вывода в Rust, то с большой вероятностью получу std::io::Result<T>, который на самом деле является Result<T, std::io::Error>. У типа std::io::Error можно легко получить его разновидность с помощью метода kind() из std::io::error, что позволяет без труда понять, с каким именно типом ошибки вы имеете дело.

Почему же в Rust ошибки полезнее, чем в Go?

  • В Rust есть enum’ы и суммарные типы

  • В Rust нет повсеместной идиомы скрывать ошибку за интерфейсом, который почти ничего о ней не сообщает

Подробнее см. документацию std::io

Русскоязычное Go сообщество

Друзья! Эту статью подготовила команда «Go for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Go. Подписывайтесь, чтобы быть в курсе и ничего не упустить!