Команда Go for Devs подготовила перевод статьи о том, почему спустя десять лет автор по-прежнему критикует Go. Ошибки на миллиард долларов, загадочный nil, проблемы с памятью и «магия» defer — по мнению автора, всё это делает язык излишне сложным и болезненным. А стоит ли оно того?


Область видимости переменной ошибки

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

Пример:

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

(Уже достаточно сказано об этом многословном повторяющемся бойлерплейте, так что мне не нужно. И мне это особо не интересно.)

Так что это нормально. Читатель знает, что err существует здесь и только здесь.

Но затем вы сталкиваетесь с этим:

bar, err := foo()

if err != nil {
  return err
}

if err = foo2(); err != nil {
  return err
}

[… a lot of code below …]

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

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

Может быть, ошибка в том, что функция заканчивается так?

// Возвращаем foo99() error. (упс, но на самом деле мы делаем не это)
foo99()
return err // Это `err` из самого верха, из вызова foo().

Почему область видимости err простирается далеко за пределы того, где она актуальна?

Код был бы намного легче читать, если бы область видимости 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 или что-то похожее.

В Java это есть:

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

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

with MyResource() as res:
  # какой-то код. В конце блока у res будет вызван __exit__.

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

foo, err := myResource()

if err != nil {
  return err
}

defer foo.Close()

Это так глупо. Некоторые ресурсы требуют отложенного уничтожения. Некоторые нет. Какие именно? Удачи, блин.

И вы также регулярно сталкиваетесь с такими чудовищами:

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
}

Да, это то, что вам НУЖНО сделать, чтобы безопасно записать что-то в файл в Go.

Что это, второй Close()? О да, конечно, это нужно. Безопасно ли вообще закрывать дважды, или мой defer должен это проверять? На os.File это безопасно, но на других вещах: КТО ЗНАЕТ?!

Стандартная библиотека поглощает исключения, так что вся надежда потеряна

В Go утверждают, что в языке нет исключений. Go делает использование исключений крайне неудобным, потому что разработчики языка хотят «наказывать» программистов, которые их используют.

Хорошо, пока что так.

Но все Go-программисты всё равно должны писать отказоустойчивый код. Потому что, хотя они и не используют исключения, другой код будет их использовать. Будут паники (panic).

Поэтому вам нужно, не просто следует, а НЕОБХОДИМО писать код вроде:

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

Что это за дурацкая система с «перевёрнутым» порядком? Это так же глупо, как ставить день посередине даты. ММДДГГ, серьёзно?

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

А что, если что-то «проглотит» это исключение и продолжит работу как ни в чем не бывало, а вы останетесь с заблокированным мьютексом?

Но ведь никто так не поступит, верно? Разумные и строгие стандарты кодирования наверняка предотвратят это под угрозой увольнения?

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

Вся надежда потеряна. Вы ОБЯЗАНЫ писать отказоустойчивый код. Но вы не можете использовать исключения. Вам остаются только недостатки исключений, которые обрушиваются на вас.

Не позволяйте им вводить вас в заблуждение.

Иногда данные не являются UTF-8

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

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

Ну… они у меня были. Теперь их нет. Они были тихо пропущены при резервном копировании/восстановлении.

Go хочет, чтобы вы продолжали терять данные. Или, по крайней мере, когда вы потеряете данные, он скажет: «Ну, в какую кодировку были обёрнуты данные?».

Или, может быть, вы просто сделаете что-то более продуманное, когда будете проектировать язык? Как насчёт того, чтобы сделать правильную вещь, вместо очевидно неправильной простой вещи?

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

Почему меня волнует использование памяти? Оперативная память дешевая. Гораздо дешевле, чем время, которое требуется, чтобы прочитать этот пост в блоге. Меня волнует, потому что мой сервис работает на облачном экземпляре, где вы на самом деле платите за оперативную память. Или вы запускаете контейнеры и хотите запустить тысячу из них на одной машине. Ваши данные могут поместиться в оперативной памяти, но это всё равно дорого, если вам приходится выделять тысяче контейнеров 4 ТБ оперативной памяти вместо 1 ТБ.

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

Да, в 90% случаев это работает всегда. Но потом перестаёт.

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

Так не должно было быть

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

И это не похоже на то время, когда мы не знали, что идеи Java были плохими, потому что мы знали, что идеи Go были плохими.

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

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

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

Комментарии (21)


  1. mapcuk
    01.10.2025 11:16

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


    1. JuPk
      01.10.2025 11:16

      Может, этот?

      https://www.roc-lang.org/


      1. 696620657272213d206e696c
        01.10.2025 11:16

        Язык хороший, но он пока в альфа-версии. Ещё неизвестно, станет ли он кому-то нужен.


    1. sqooph
      01.10.2025 11:16

      Purebasic)))


    1. hddn
      01.10.2025 11:16

      Gleam: https://gleam.run/

      Про него был подкаст недавно у "Подлодки"



    1. DigLik_228653
      01.10.2025 11:16

      C#


  1. pda0
    01.10.2025 11:16

    Уже было.


  1. DwarfMason
    01.10.2025 11:16

    Тут либо я совсем уж тупой, либо лыжи не едут.

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

    Мы создали слайс 0:0 по массиву и отправили его в функцию. В функции мы решили при помощи append растянуть массив. Append по слайсу, Карл! Потом удивляемся а почему не отработало.

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

    nil как отсутствие ссылки и nil как отсутствие значения, сначала люди ноют, что документация в каком-либо продукте плохая, а затем сами её не читают.

    Постойте, что? Почему err используется повторно для foo2()?

    Берем и именуем внешние и внутренние поля скоупов по разному и получаем однозначное предсказуемое поведение.


    1. pda0
      01.10.2025 11:16

      Потом удивляемся а почему не отработало.

      Оно сработало. Просто неожиданно. :)

      Тут либо я совсем уж тупой, либо лыжи не едут.

      Лыжи не едут. Срезы в go устроены так: структура (ссылка на буфер, размер, вместимость). Пока кол-во вставляемых элементов не превышает вместимость среза - они просто дописываются в буфер и увеличивается размер. Если превышено - увеличивается вместимость, выделяется новый буфер, память копируется в него, переключаемся на новый буфер. И далее как выше. Вместимость кратна степени двойки.

      Первый случай:

      a - срез с размером 3, вместимостью 4.

      Передаём в функцию срез с него с тем же буфером, размером 1, вместимостью 4.

      Добавляем один элемент. Это не превышает вместимость, буфер остаётся прежним, NIGHTMARE пишется в буфер, переписывая world.

      Второй случай.

      Добавляем 4 элемента, это превышает вместимость - размер, по этому в функции буфер среза копируется, отвязывая его от буфера исходного среза и добавляет в него элементы. Но буфер исходного среза не затронут.

      По этому я и написал в первой статье тот каммент.


  1. poiqwe
    01.10.2025 11:16

    С самого начала же проговаривается, что slice это структура данных только указывающая на массив. Очевидно, что и при копировании скопируется только slice, а не массив на который он указывает.

    Насчет defer, кажется можно сделать так

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



  1. sqooph
    01.10.2025 11:16

    – Бэрримор, кто снова воет на болотах!?

    – Гоферы, сэр....


    1. koreychenko
      01.10.2025 11:16

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

      P.S. странно, что автор не докопался до контекстов и горутин. Вот где пострадать можно! А то давайте в 100500 раз поноем про область видимости ошибки в го - явно, что автор сам на языке не писал нормально :-)


  1. diafour
    01.10.2025 11:16

    Код был бы намного легче читать, если бы область видимости err была меньше. Но это синтаксически невозможно в Go.

    Почему же синтаксически невозможно? Можно обработку по блокам раскидать:

    	var bar Type1
    	{
    		var err error
    		bar, err = foo()
    
    		if err != nil {
    			return err
    		}
    	}
    
    	{
    		if err := foo2(); err != nil {
    			return err
    		}
    	}
    


    1. diderevyagin
      01.10.2025 11:16

      Человек не выучил и не разобрался в Golang. И начинает писать на нем как привык в XYZ. Вот и результат - поток чуши. Нет бы покритиковать GC к примеру (есть что критиковать).


      1. koreychenko
        01.10.2025 11:16

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

        Это автор явно не трогал горутины, каналы и контекст. Вот там вот дофига не очевидного.


        1. diderevyagin
          01.10.2025 11:16

          Тут есть еще такая ловушка, как результат позиционирования "Golang простой и ничего там такого нет".

          И многие потому решают что документацию читать - не надо. понимать как работает Runtime и компоненты - не надо. Сдедить за релизами и изменением поведения - не надо.

          а ведь это нет так - нюансов много и разбираться надо без дураков


          1. koreychenko
            01.10.2025 11:16

            На самом деле все ещё интереснее.

            1. Го простой. Но только по синтаксису.

            2. В го можно писать без многопоточности, каналов, синхронизаций и прочей магии просто тупо в лоб как на PHP, но тогда в чем смысл его брать?

            3. Асинхронности и её синхронизация это тема в принципе непростая и здравый смысл здесь часто работает не очевидно :-)

            4. И это я ещё про оптимизации по памяти ничего не писал.


  1. artalex
    01.10.2025 11:16

    Было же уже тут не так давно https://habr.com/ru/companies/ruvds/articles/941106/


  1. diderevyagin
    01.10.2025 11:16

    Человек не удосужился выучить Golang и требует, чтобы в нем было то, к чему он привык в условной Java. Особенно "умилили" претензии к defer который назван "глупым"


  1. manyakRus
    01.10.2025 11:16

    Всё правильно написано.
    Не надо привыкать к глюкам, надо чтоб глюков не было.
    Но это всё равно мелкие недостатки, не страшно