golang

Go по-прежнему полон проблем

  • пятница, 29 августа 2025 г. в 00:00:05
https://habr.com/ru/companies/ruvds/articles/941106/

Я уже больше десяти лет критикую Go, о чём высказывался в своих предыдущих статьях «Why Go is not my favourite language» и «Go programs are not portable».

Описанные в них проблемы языка бесят меня всё больше, и в основном потому, что их явно можно было избежать. Мир знавал решения и получше, но Go почему-то состряпали именно таким.

Те, кто читал мои прежние статьи, встретят здесь частичные повторы, так что заранее прошу меня за них простить.

Область видимости переменной err вводит в заблуждение

Вот вам пример, в котором Go сам толкает вас на ошибку. Для чтения кода минимизация области действия переменной очень удобна (а код, насколько мы знаем, читается чаще, чем пишется). Если вы можете с помощью простого синтаксиса сообщить читающему, что переменная используется только в конкретных двух строках, то это хорошо.

Разберём пример:

if err := foo(); err != nil {
   return err
}

(О громоздкости этого бойлерплэйта уже сказано достаточно, так что повторяться не буду. Да меня это и не особо волнует).

Тут проблем нет. Читающий понимает, что err используется здесь и нигде больше.

Но потом ты встречаешь это:

bar, err := foo()
if err != nil {
  return err
}
if err = foo2(); err != nil {
  return err
}
[… много кода …]

Стоп, что? Почему err ещё раз используется в foo2()? Может, я не вижу каких-то деталей? Даже если изменить оператор на :=, мы не поймём, почему err находится в области действия в течение (потенциально) всей остальной части функции. Почему? Может, она считывается позже?

Опытный программист, особенно при поиске багов, увидев всё это, почует опасность и заострит внимание. Ладно, я достаточно отвлёкся на рассуждение о повторном использовании err для foo2(). Суть в другом.

Может ли этот факт объясняться тем, что функция заканчивается следующим:

// Возвращает ошибку foo99(). (но ведь это не так…)
foo99()
return err // Это err из того самого, верхнего вызова foo().

Почему область видимости err распространяется дальше, чем это необходимо?

Если сократить эту область, код будет намного проще читать. Вот только синтаксис Go этого не позволяет.

Разработчики совсем его не продумали.

Два вида nil

Только взгляните на этот бред:

package main
import "fmt"
type I interface{}
type S struct{}
func main() {
    var i I
    var s *S
    fmt.Println(s, i) // nil nil
    fmt.Println(s == nil, i == nil, s == i) // t,t,f: то есть они как бы равны, но в то же время не равны.
    i = s
    fmt.Println(s, i) // nil nil
    fmt.Println(s == nil, i == nil, s == i) // t,f,t: то есть они как бы не равны, но в то же время равны.
}

Создателям Go оказалось мало ошибки на миллиард долларов, и они запилили два вида NULL.

«А твой nil какого цвета?», — ошибка на два миллиарда долларов.

Причиной возникновения этого отличия снова является бездумный набор кода без должного внимания.

Отсутствие портируемости

Думаю, что добавление комментария в верхней части файла для условной компиляции можно отнести к одному из тупейших решений. Любой, кто занимался обслуживанием портируемой программы, подтвердит, что это лишь создаёт проблемы.

Это какой-то аристотелевский подход к проектированию языка — запереться в комнате и никогда не тестировать свои гипотезы в реальности.

Но ведь на дворе не 350 год до н. э. Нам уже известно, что без учёта сопротивления воздуха, тяжёлые и лёгкие объекты падают с одинаковым ускорением. И мы имеем богатый опыт работы с портируемыми программами, поэтому не стали бы совершать подобные глупости.

Да, если бы сейчас действительно был 350 год до н. э., то такое можно было и простить. В то время современных научных знаний ещё не было. Но мы живём в эпоху, когда концепция портируемости уже хорошо известна и прорабатывается не один десяток лет.

Подробнее об этом я писал здесь.

append без определения владения

Какой будет вывод этого кода:

package main
import "fmt"
func foo(a []string) {
    a = append(a, "NIGHTMARE")
}
func main() {
    a := []string{"hello", "world", "!"}
    foo(a[:1])
    fmt.Println(a)
}

Вероятно, [hello NIGHTMARE !]. А кому это нужно? Да никому.

Хорошо, а этого:

package main
import "fmt"
func foo(a []string) {
    a = append(a, "BACON", "THIS", "SHOULD", "WORK")
}
func main() {
    a := []string{"hello", "world", "!"}
    foo(a[:1])
    fmt.Println(a)
}

Если вы предположили [hello world !], то неплохо разбираетесь в причудах этого бестолкового языка программирования.

defer — это тупость

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

Здесь определённо не хватает принципа RAII (Resource Acquisition Is Initialization) или чего-то похожего.

В Java он есть:

try (MyResource r = new MyResource()) {
  /*
  Работает с ресурсом r, который будет очищен с помощью .close() при завершении области действия, а не когда это сочтёт нужным GC.
  */
}

В Python тоже такой механизм есть. Хотя этот язык почти полностью опирается на подсчёт ссылок, так что вполне можно полагаться на вызов завершающего метода __del__ . Но если сильно нужно, то можно обработать освобождение ресурсов с помощью оператора with.

with MyResource() as res:
# Здесь идёт какой-то код... По завершении блока кода для res будет вызван block exit.

А как обстоят дела в Go? А в Go нам нужно отправляться в мануал и выяснять, требуется ли для конкретного вида ресурса вызывать функцию defer, и какую именно.

foo, err := myResource()
if err != nil {
  return err
}
defer foo.Close()

Очень тупо. Одни ресурсы необходимо уничтожать с помощью defer, другие нет. Поди разберись.

Плюс вы то и дело получаете подобный монстро-код:

f, err := openFile()
if err != nil {
  return nil, err
}
defer f.Close()
if err := f.Write(something()); err != nil {
  return nil, err
}
if err := f.Close(); err != nil {
  return nil, err
}

Да, именно это необходимо проделать, чтобы безопасно записать что-либо в файл.

Что вообще такое эта вторая Close()? Ну конечно, без неё никуда. И безопасно ли её двойное выполнение, или тут нужен контроль defer?

В случае os.File опасности нет, но кто знает, как оно будет работать в других ситуациях.

Стандартная библиотека «глотает» исключения, так что выхода нет...

Создатели Go заявляют, что в нём исключений нет. Они специально затрудняют их использование, чтобы отучивать программистов так делать.

И вроде бы ничего страшного.

Но в итоге всем разработчикам на Go приходится писать код с учётом безопасной обработки исключений. Ведь, хоть сами они их не используют, это будет делать другой код. Будет возникать паника.

В итоге приходится писать такие перлы:

func (f *Foo) foo() {
    f.mutex.Lock()
    defer f.mutex.Unlock()
    f.bar()
}

Что за несуразная «среднеконечная» система (от англ. middle-endian, — прим. пер.)? Такая же тупая, как схема записи даты ММДДГГ, когда дни указываются посередине (заслуживает отдельной критики).

Но ведь паника приведёт к завершению программы, говорят разработчики — так зачем беспокоиться о разблокировке мьютекса за какие-то пять миллисекунд до этого?

А что, если возникшее исключение будет обработано, и программа продолжит выполнение? Мьютекс останется заблокирован.

Но ведь так делать никто бы не стал? И появление подобного бага можно было бы предотвратить, внедрив адекватные и строгие стандарты написания кода, несоблюдение которых грозило бы увольнением.

Встретить его можно в стандартной библиотеке. Так делает fmt.Print при вызове .String(), и HTTP-сервер поступает так с исключениями в HTTP-обработчиках.

Так что выхода нет. Вы обязаны писать код с защитой от исключений. Но использовать сами исключения нельзя. Можно испытывать на себе лишь их недостатки.

Не позволяйте разработчикам пудрить себе мозги.

Иногда формат данных не UTF-8

Если поместить произвольные двоичные данные в string, Go это нисколько не смутит, о чём подробнее расписано в этой статье.

Не один десяток лет я терял данные из-за того, что инструменты пропускали имена файлов, написанные не в формате UTF-8. Не моя же вина, что у меня есть файлы, проименованные до появления этого стандарта.

Хотя… сейчас этих файлов у меня не осталось, так как в ходе резервного копирования/восстановления они были пропущены.

Go хочет, чтобы вы продолжили терять свои данные. По меньшей мере, когда вы их потеряете, он просто скажет: «А во что они были одеты?» (шуточная метафора автора, в которой под одеждой он подразумевает кодировку, — прим. пер.)

А может, при разработке языка просто нужно тщательнее продумывать его структуру? Может, надо изначально делать правильно, а не создавать простые механизмы явно кривыми?

Использование памяти

Вы спросите, почему меня этот момент волнует? RAM нынче стоит дёшево, намного дешевле времени, необходимого для прочтения этой статьи. А волнует он меня, потому что мой сервис работает на облачном инстансе, где за RAM нужно платить. Ваши данные вполне могут вписываться в RAM, но всё равно получится дорого, если вашей тысяче контейнеров потребуется 4 ТиБ, а не 1 ТиБ.

Вы можете вручную запустить сборщика мусора с помощью runtime.GC(), но разработчики нам говорят: «Только не делайте этого. Он запустится в нужное время, просто доверьтесь ему.»

Согласен, в 90% случаев всё работает прекрасно. Но в остальных нет.

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

Этого можно было избежать

Индустрия знавала решения и получше. Это не из разряда дебатов вокруг COBOL о том, что лучше использовать: символы или английские слова.

И это не как в случае с Java, когда понимание неудачности каких-то решений пришло лишь после их реализации. В Go это было ясно изначально.

При создании этого языка уже были известны более удачные примеры. И что в итоге? А в итоге мы вынуждены работать с кривыми кодовыми базами Go.

Похожие статьи

Обсуждение этой статьи на HackerNews