
Я уже больше десяти лет критикую 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.
Похожие статьи
Комментарии (12)
dersoverflow
28.08.2025 11:59Я переписал часть кода на другом языке
вот про это я и писал: Взрослые в ужасе убегают...
но уже есть решение https://habr.com/ru/posts/939726/
warkid
28.08.2025 11:59А зачем эта вторая f.Close? Чтобы ошибку обработать? А зачем тогда первая с defer? Чтобы вызвалось в любом случае?
anaxita
28.08.2025 11:59По идее там должно быть в дефере заполнение ошибки из close чтобы она вернулась из функции, а не вызов второго close.
но в целом оба варианта ок т.к более явные, а defer нужен чтобы закрыть файл при ошибки во write
но вообще если такая коротка функция то при ошибки во Write проще явно закрыть файл типа
err := f.Write() {
err2= f.Close()
return fmt.Errorf("%w: %w" err, err2)
}
Tishka17
28.08.2025 11:59Первая нужна чтобы при ошибке закрыть. А вторая нужна чтобы при отсутствии ошибок закрыть и обработать ошибку закрытия
pda0
28.08.2025 11:59Если вы предположили
[hello world !]
, то неплохо разбираетесь в причудах этого бестолкового языка программирования.Да, срезы и правда реализованы "сомнительно и не okay".
gohrytt
У кого-то ещё в процессе чтения было ощущение "Не, ну это точно троллиг"? Я не знаю кто этот чел чтобы его статьи переводить и размещать здесь, я сам имею к go много вопросов, но это все выглядит будто чувак тотально не разобрался
andreymal
Помогите нам тотально разобраться?
gohrytt
Что конкретно вас интересует?
andreymal
Всё, в чём «чувак тотально не разобрался»?
Допустим, я мимокрокодил, который выбирает язык программирования по постам вроде этого, спасите меня от потенциально неверного решения, разоблачите тролля или типа того)
gohrytt
"Область видимости переменной err вводит в заблуждение" или троллинг №1
Чувак показывает следующие примеры:
Первый пример его в целом устраивает, и хоть он и утверждает что его не смущает бойлерплейт, но делает про это сноску.
Проблемы начинаются на втором, а именно автор задается вопросом почему err переиспользуется в втором if-блоке и делает ложное утверждение, что если сменить
=
на:=
область видимости останется глобальной.Во первых переиспользование переменных существует буквально во всех языках и служит для удобства и экономии памяти. Даже в условном javascript у нас есть throw который может выкинуть только одну ошибку, если их две то придётся по€6@ться и обьединить их в одну.
Во вторых если в втором if-блоке сменить
=
на:=
то есть (внезапно) оператор присваивания сменить на (внезапно) оператор создания эта переменная-ошибка будет существовать только внутри блока и никак не затронет переменную-ошибку областью видимости выше.В третьих создавать переменные в if и for блоках - плохой тон в продакшн коде, код должен выглядеть так:
или так
в зависимости от бизнес-требований и жизнь становится значительно проще и понятнее.
"Два вида nil" или чувак никогда не ходил на собеседование по go
Чувак пытается сравнить интерфейс и указатель на структуру которая реализует этот интерфейс и недоумевает почему же они не равны.
Достаточно открыть первую страницу go tour чтобы обнаружить, что go - язык с строгой типизацией и если у нас есть два разных типа их не стоит сравнивать.
Каждый кто программирует на go (или хотя бы ходил на собеседование) знает или догадывается что любой интерфейс это на самом деле
Таким образом когда чувак присвоил переменной типа интерфейс nil-указатель на структуру - он заполнил как минимум одно поле интерфейса а именно
type
и очевидно после этого интерфейс не может считаться пустым.Также очевидно что пока в интерфейсе и type и data равны nil, то и весь интерфейс равен nil.
"Отсутствие портируемости" или смотрим узко
Тут чел решил наехать на build-тэги в комментарии в начале файла типа это программирование в закрытой комнате.
Тут можно поспорить сразу с двух сторон:
Во первых практика показывает что go, где есть fyne и ebitengine, легче использовать для мобильных приложений, чем например rust, где есть только slint который ещё и работает плохо, где всё сделано примерно как хочет автор.
Во вторых кроме build-тэгов есть множество других тэгов таких как //go:linkname ... или //go:embed ... и если уже мы говорим о вопросах к системе тэгов, то её проблема гораздо больше чем то что build-тэги вешаются только на файлы а не на конкретные функции.
Как я уже сказал я сам имею вопросы к go и мета-программирование, а вернее его отсутствие один из основных.
"append без определения владения" или троллинг №2
Чувак приходит к нам с этим
и спрашивает, а как так эта программа печатает "hello NIGHTMARE !"?
Это в мире где буквально каждое собеседование по go начинается с вопроса "Что такое слайс?". Я отношу это к троллингу, так как очевидно что автор не может не знать как работает слайс, а также по словам "определение владения" которые нам известны из мира rust.
Пояснение для несведующих: слайс на самом деле это
Знания этого факта достаточно чтобы понять что когда мы передаем в функцию слайс - мы на самом деле передаем копию всех этих полей и в том числе копию указателя на массив, куда в процессе append записываются значения пока length меньше capacity.
Да в расте напечатать "hello Nightmare !" не получилось бы. Но в расте с его концепцией владения такой код впринципе невозможен: ниже прикладываю листинг который вы можете попробовать запустить и убедится что это не компилируется.
Раст нас заставляет сделать копию вектора (тот же слайс только в профиль)
что возможно и очень безопасно, но совершенно точно тратит больше ресурсов системы и ограничивает программиста в его экспрессии.
Да наверное в каком-нибудь медицинском ПО или в крыле от боинга сделать копию вектора будет правильнее и безопаснее, но в мире быстрого сетевого программирования мы привыкли доверять коллегам и думать перед тем как пишем код и считаем перезапись значения в векторе по указателю полезной возможностью.
Я устал писать и считаю что доказав безосновательность 4 из 8 претензий автора статистически доказываю безосновательность всей статьи, но если вдруг вам необходимо продолжение - буду ожидать от 5 тысяч рублей по СБП по номеру +79998386230 на Цифра или Озон банк с комментарием "продолжаем спорить".
Также буду благодарен приглашениям на интересные вакансии. Интересные - это крипто, игровые или инфраструктурные проекты с вилкой не ниже $4000 и допустимой удалённой работой не с территории РФ.
KReal
Было. Похоже, что язык Go это ну точно толстый троллинг.