Pull to refresh

Go развивается в неверном направлении

Reading time7 min
Views17K
Original author: Aliaksandr Valialkin
Язык программирования 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. Разве в таком случае нормально, что Go проигрывает Rust в производительности?

Еще одним примером полезных возможностей, которые Go может получить без увеличения сложности самого языка и кода Go, использующего эти возможности, являются небольшие улучшения качества жизни, подобные этому.
Tags:
Hubs:
Total votes 30: ↑24 and ↓6+23
Comments23

Articles

Information

Website
piter.com
Registered
Founded
Employees
201–500 employees
Location
Россия