Язык программирования Go известен своей простотой в использовании. Благодаря продуманному синтаксису, возможностям и инструментарию, Go позволяет писать легко читаемые и поддерживаемые программы произвольной сложности (см. этот список на GitHub).

Некоторые инженеры-программисты называют Go «скучным» и «устаревшим», поскольку в нем отсутствуют передовые возможности других языков программирования, такие как монады, опциональные типы, LINQ, средства проверки заимствований, абстракции с нулевыми издержками, аспектно-ориентированное программирование, наследование, перегрузка функций и процедур и т. д. Хотя эти возможности могут упростить написание кода для определенных областей, они имеют ненулевые издержки в дополнение к преимуществам. Эти возможности обычно хороши для тренировки мозга. Но нам не нужна дополнительная умственная нагрузка при работе с производственным кодом, поскольку мы и так заняты решением бизнес-задач. Основная цена всех этих возможностей – возрастание сложности результирующего кода:

  • становится труднее понять, что происходит, просто читая код;
  • отлаживать такой код становится сложнее, поскольку приходится перепрыгивать через десятки нетривиальных абстракций, прежде чем добраться до бизнес-логики;
  • становится сложнее добавлять новую функциональность в такой код из-за ограничений, которые накладывают эти функции.
Это может значительно замедлить и даже остановить темпы разработки кода. Это основная причина, по которой в Go изначально не было этих функций.

К сожалению, некоторые из этих функций начали появляться только в последних релизах Go:
  • В Go1.18 были добавлены дженерики. Многие инженеры-программисты хотели добавить дженерики в Go, так как считали, что это значительно повысит их производительность в Go. С момента выхода Go1.18 прошло два года, но никаких признаков повышения производительности не наблюдается. Общий уровень внедрения дженериков в Go остается низким. Почему? Потому что дженерики, как правило, не нужны в прикладном коде на Go. С другой стороны, дженерики значительно усложнили сам язык Go. Попробуйте, например, разобраться во всех тонкостях вывода типов в Go после добавления дженериков. По сложности он уже очень близок к выводу типов в C++ :) Другая проблема заключается в том, что дженерикам в Go не хватает существенных возможностей, которые есть в шаблонах C++. Например, дженерики Go не поддерживают родовые методы на родовых типах. Они также не поддерживают специализацию шаблонов и параметры шаблонов, а также многие другие возможности, которые необходимы для использования всех преимуществ родового программирования. Давайте добавим эти недостающие возможности в Go! Подождите, тогда мы получим еще один чрезмерно усложненный клон C++. Тогда зачем вообще добавлять частично работающие дженерики в Go? ?
  • Согласно этому коммиту, в Go 1.23 будут добавлены функции с охватом диапазона, также известные как итераторы, генераторы или корутины. Давайте рассмотрим эту «фичу» поближе.

Итераторы в Go 1.23


Если вы еще не знакомы с итераторами в Go, то прочтите это отличное введение. По сути, это синтаксический сахар, который позволяет писать циклы for… range над функциями со специальными сигнатурами. Это позволяет писать пользовательские итераторы над пользовательскими коллекциями и типами. Звучит как отличная возможность, не так ли? Давайте попробуем выяснить, какую практическую проблему решает эта возможность. Это описано здесь:

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

Только в стандартной библиотеке есть archive/tar.Reader.Next, bufio.Reader.ReadByte, bufio.Scanner.Scan, container/ring.Ring.Do, database/sql.Rows, expvar.Do, flag.Visit, go/token.FileSet.Iterate, path/filepath.Walk, go/token.FileSet.Iterate, runtime.Frames.Next, и sync.Map.Range, и почти ни одна из них не согласна с точными деталями итерации. Даже те функции, которые согласны с сигнатурой, не всегда согласны с семантикой. Например, большинство функций итерации, возвращающих (T, bool), следуют обычному соглашению Go, согласно которому bool указывает, является ли T действительным. В отличие от этого, булево значение, возвращаемое функцией runtime.Frames.Next, указывает, будет ли следующий вызов возвращать какую-то действительную полезную нагрузку.


Когда вы хотите перебрать какое-либо множество, вам сначала нужно узнать, как конкретный код, который вы вызываете, обрабатывает итерацию. Этот недостаток единообразия мешает цели Go — упростить перемещение по большой кодовой базе. Часто в качестве достоинства упоминают, что весь код Go выглядит примерно одинаково. Это просто не соответствует действительности для кода с пользовательской итерацией.

Опять же, это звучит вполне логично — иметь единый способ итерации над различными типами в Go. Но как насчет обратной совместимости, одной из главных сильных сторон Go? Все существующие пользовательские итераторы из стандартной библиотеки, о которых говорилось выше, останутся в стандартной библиотеке навсегда, согласно правилам совместимости Go. Таким образом, все новые релизы Go будут предоставлять как минимум два разных способа перебора различных типов в стандартной библиотеке — старый и новый. Это увеличивает сложность программирования на Go, поскольку:
  • Нужно знать о двух способах итерации над различными типами, а не об одном способе.
  • Нужно уметь читать и поддерживать старый код, в котором используются старые итераторы, и новый код, в котором могут использоваться либо старые итераторы, либо новые, либо оба типа итераторов одновременно.
  • При написании нового кода необходимо выбрать подходящий тип итератора.

Другие проблемы с итераторами в Go 1.23


До Go 1.23 цикл for ... range можно было применять только к встроенным типам: целым числам (с Go1.22), строкам, фрагментам, картам и каналам. Семантика этих циклов была ясна и понятна (циклы, оперирующие над каналами имеют более сложную семантику, но, если вы имеете дело с параллельным программированием, то должны легко ее понять).

Начиная с Go1.23, циклы for… range могут применяться к функциям со специальными сигнатурами (они же функции pull и push). Это делает невозможным понять, что может делать данный невинный цикл for… range под капотом, просто читая код. Он может делать все, что угодно, как может делать любой вызов функции. Единственное отличие в том, что в Go вызовы функций всегда были явными, например f(args), а цикл for… range скрывает фактический вызов функции. Кроме того, он применяет неочевидные преобразования для тела цикла.
  • Функция неявно оборачивает тело цикла в анонимную функцию и неявно передает эту функцию в функцию итератора push.
  • Функция неявно вызывает анонимную функцию pull и передает возвращаемые результаты в тело цикла.
  • Функция неявно преобразует инструкции return, continue, break, goto и defer в другие неочевидные утверждения внутри анонимной функции, передаваемой функции итератора push.

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

Go был известен как легко читаемый и понятный код с явными путями выполнения кода. В Go1.23 это свойство необратимо нарушено :( Что мы получаем взамен? Еще один способ перебора типов, который имеет нетривиальную неявную семантику. Этот способ не работает так, как заявлено, при переборе типов, которые могут вернуть ошибку при итерации (например, database/sql.Rows, path/filepath.Walk или любой другой тип, который осуществляет ввод/вывод во время итерации). Дело в том, что вам придётся вручную искать ошибки итерации внутри цикла или сразу после цикла, точно так же, как это делалось ранее.

Даже если использовать итератор, который не может возвращать ошибки, получившийся цикл for ... range выглядит менее понятным, чем при старом подходе с явным обратным вызовом. Какой код легче понять и отладить?

tree.walk(func(k, v string) {
  println(k, v)
})
for k, v := range tree.walk {
  println(k, v)
}

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

for k, v := range tree.walk {
  if k == "foo" {
    return v
  }
}

Он неявно преобразуется в трудно отслеживаемый код, подобный следующему:

var vOuter string
needOuterReturn := false
tree.walk(func(k, v string) bool {
  if k == "foo" {
    needOuterReturn = true
    vOuter = v
    return false
  }
})
if needOuterReturn {
  return vOuter
}

Просто такое отладить, правда :)

Этот код может сбоить, если tree.walk передаст v в обратный вызов через небезопасное преобразование из байтового среза, так что содержимое v может измениться на следующей итерации цикла. Поэтому неявно сгенерированный пуленепробиваемый код должен использовать strings.Clone(), что приводит к возможно ненужному выделению и копированию памяти:

var vOuter string
needOuterReturn := false
tree.walk(func(k, v string) bool {
  if k == "foo" {
    needOuterReturn = true
    vOuter = strings.Clone(v)
    return false
  }
})
if needOuterReturn {
  return vOuter
}

Функция «range over func» накладывает ограничения на сигнатуру функции. Эти ограничения не подходят для всех возможных случаев, когда требуется итерация над элементами набора. Это вынуждает разработчиков программного обеспечения делать нелегкий выбор между уродливыми хаками для цикла for ... range и написанием явного кода, идеально подходящего для данной задачи.

Заключение


Печально, что Go начал развиваться в сторону увеличения сложности и неявного выполнения кода. Вероятно, нужно перестать добавлять функции, которые усложняют Go, и вместо этого сосредоточиться на основных функциях Go — простоте, производительности и быстродействии. Например, недавно Rust начал отвоевывать долю Go в критическом для производительности пространстве. Я считаю, что эту тенденцию можно обратить вспять, если основная команда Go сосредоточится на оптимизации горячих циклов, таких как разворачивание циклов и использование SIMD. Это не должно сильно повлиять на скорость компиляции и линковки, поскольку оптимизировать нужно лишь небольшое подмножество скомпилированного Go-кода. Нет необходимости пытаться оптимизировать все вариации бестолкового кода — такой код останется медленным даже после оптимизации горячих циклов. Достаточно оптимизировать только конкретные паттерны, которые намеренно написаны инженерами-программистами, заботящимися о производительности своего кода.

Go гораздо проще в использовании, чем Rust. Почему Rust проигрывает в гонке производительности?

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

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


  1. SUNsung
    21.06.2024 10:44
    +2

    Хоть это и перевод, но полностью согласен с каждым словом.

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

    Реально большие и сложные вещи лучше решить генераторами кода, чем извращениями с дженериками и тд, которые имеют огромный шанс отстрелить колени.

    Простой пример - для полного раскрытия дженериков нужно использовать any на вход. Но это небезопасно, а проверка типов внутри с последующим вызовом методов с нужными типами - собственно и зачем в итоге дженерик? Не говоря уже про то, что проверки типов жрут ресурсы и такой "сахар" противопоказан в высокопроизводительном приложении


    1. Vadim_Aleks
      21.06.2024 10:44
      +5

      Простой пример - для полного раскрытия дженериков нужно использовать any на вход. Но это небезопасно, а проверка типов внутри с последующим вызовом методов с нужными типами - собственно и зачем в итоге дженерик?

      Это уже звучит не как дженерик, ведь они вычисляются на этапе компиляции, а не в рантайме. Можете привести пример?

      проверки типов жрут ресурсы

      В Go type cast в рантайме это один if, так что сложно в такое упереться


      1. ManGegenMann
        21.06.2024 10:44

        Это уже звучит не как дженерик, ведь они вычисляются на этапе компиляции, а не в рантайме.

        Ровно наоборот на этапе компиляции работают шаблоны, например, в С++ где на каждый типизированый шаблон создаётся свой класс. Дженерики же работают в рантайме и создаётся всего 1 класс, так работают они в С#.

        Конкретно в Go мне непонятно почему это называется дженерик хотя по факту это может быть исключительно шаблон ведь Go это коммпилируемый язык.


        1. Free_ze
          21.06.2024 10:44
          +1

          В дотнете для значимых типов (value types) в роли параметров-типов с соответствующими ограничениями (where T: struct) генерируются отдельные реализации. Тут с примерами.

          А что мешает синтаксисом дженериков объявить параметр с типом указателя-интерфейса в компилируемом языке? Если так, то сгенерится одна общая реализация и полиморфизм будет динамическим. Если будет предполагаться конкретный тип известного при компиляции размера - компилятор будет делать реализации под каждый конкретный используемый тип, т.е. статический полиморфизм. Так это в Rust работает, где в компилируемости сомнений нет. Да и в C# именно так, с поправкой на отложенную компиляцию/JIT.


        1. Sanchous98
          21.06.2024 10:44

          В Go "гибридный" подход. Все дженерики выводятся во время компиляции, кроме указателей. Указатели и интерфейсы будут выводиться как указатели на uint8, а затем в рантайме будет выводиться конкретный тип указателя(косвенный вызов). Автор на счет дженериков к слову неправ, просто дженерики создавались не для того же, для чего делались шаблоны в Go. В Go библиотеках очень много случаев когда дублируются функции для обработки строк и слайса байтов итп. В таких случаях дженерики в Go работают прекрасно и позволяют без потери в производительности избавиться от дублирования. Плюс дают дорогу к пакетам подобным slices без рантайм проверок входных данных


  1. Plesser
    21.06.2024 10:44
    +2

    Это прекрасный пост, но позволю на правах оффтопа спросить, вы на книги по мобильному программированию окончательно забили?


  1. 9982th
    21.06.2024 10:44
    +4

    дженерики Go не поддерживают родовые методы на родовых типах

    Даже те функции, которые согласны с сигнатурой, не всегда согласны с семантикой.

    строкам, фрагментам, картам и каналам

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

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


    1. ph_piter Автор
      21.06.2024 10:44

      Спасибо, поправим.


  1. AlexeyK77
    21.06.2024 10:44
    +8

    "Go гораздо проще в использовании, чем Rust. Почему Rust проигрывает в гонке "
    В оригинале:
    "Go is much easier to use than Rust. Why losing Rust in performance race? "
    может надо перевести как:
    "Go гораздо проще в использовании, чем Rust. Почему он Rust-у проигрывает в гонке?"


    1. selcon
      21.06.2024 10:44
      +4

      "Go гораздо проще в использовании, чем Rust. Зачем проигрывать Rust-у в производительности?" Имеется в виду, что не надо давать Rust`у аргумент для его использования вместо Go.


    1. dshum1
      21.06.2024 10:44

      Скорее так: Го гораздо проще в использовании. Зачем же проигрывать Расту в производительности?


  1. GrimAnEye
    21.06.2024 10:44

    Вводят излишние усложнения в язык, забросив в полурабочем состоянии остальное - например плагины.В виде отдельно библиотеки не собрать, зависимость от окружающей среды сборки и основного кода, отсутствие поддержки Windows (issues-19282, 7 лет проблеме!).


    1. JekaMas
      21.06.2024 10:44
      +1

      Плагины никто дорабатывать не собирался, как и поддержку Windows. Это уже особенности по сути закрытой разработки golang.


  1. Tairesh
    21.06.2024 10:44
    +8

    Когда программист уходит из PHP в Go средний уровень IQ повышается и там и там


  1. KReal
    21.06.2024 10:44
    +2

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


  1. Panzerschrek
    21.06.2024 10:44
    +10

    Весь язык Go - ошибка дизайна. Фундаментальная идея сделать "простой" язык обречена на провал. За кажущейся простотой кроется скудность возможностей и как следствие излишняя самоповторяемость и многословность кода. А улучшить теперь ничего уже толком нельзя, т. к. улучшения поверх скудной основы получаются весьма проблематичными.

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


  1. 9241304
    21.06.2024 10:44
    +2

    Язык должен развиваться. Иначе он умрёт. Главной фишкой языка всегда были горутины, классы без классов и быстрое подключение модулей. Дженерики - лишь приятное дополнение. Ещё бы тернарный оператор не помешал. Это все можно не использовать, не заставляют же. А претензия про плохо читаемый код - абсолютно пустая. Выпендрежники и так пишут такой код, что фиг поймёшь.

    Так что если там кому-то что-то кажется, то пусть подождёт, пока в плюсы рефлексию подвезут и модули довезут.


  1. risentveber
    21.06.2024 10:44

    Я один не понял, зачем нужен вызов strings.Clone вместо обычного присваивания?


  1. gexeg
    21.06.2024 10:44
    +2

    становится труднее понять, что происходит, просто читая код

    1) нужно уметь писать 2) нужно уметь читать

    Не абстракции ли дают упрощение? Гдето слышал, что они скрывают детали, которые можно игнорировать.

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


    1. Vladekk
      21.06.2024 10:44
      +1

      Дико читать, что цикл for с условиями легче читать, чем linq

      .Where(). Select()

      Любой If это источник ошибок


  1. daniaruly
    21.06.2024 10:44

    Эти фичи нужны не для написания производственного кода а для стандартизации библиотек.Я не буду использовать итераторы,но с удовольствием буду использовать библиотеки которые их применяют