Недавно я увидел сообщение, демонстрирующее будущий дизайн итераторов для Go 1.23 (август 2024 года). Насколько я могу судить, многим людям этот дизайн не нравится. Я хотел высказать свои мысли по этому поводу как разработчик языка.
Предложения по итераторам можно найти здесь.
В нем есть подробное описание дизайна, объясняющее, почему были выбраны те или иные подходы, поэтому я рекомендую прочитать его, если вы знакомы с Go.
Вот один из примеров:
func Backward[E any](s []E) func(func(int, E) bool) {
return func(yield func(int, E) bool) {
for i := len(s)-1; i >= 0; i-- {
if !yield(i, s[i]) {
// Where clean-up code goes
return
}
}
}
}
s := []string{"a", "b", "c"}
for _, el in range Backward(s) {
fmt.Print(el, " ")
}
// c b a
Этот пример достаточно понятен, но вся его конструкция, на мой взгляд, немного безумна для общего/большинства случаев использования.
Насколько я понимаю, код будет преобразован в нечто такое:
Backward(s)(func(_ int, el string) bool {
fmt.Print(el, " ")
return true // `return false` would be the equivalent of an explicit `break`
})
Это означает, что итераторы Go гораздо ближе к тому, что есть в некоторых языках с методом "for each" (например, .forEach()
в JavaScript) и передачей ему callback. И что интересно, такой подход уже возможен в Go <1.23, но в нем нет синтаксического сахара для использования его внутри оператора for range
.
Попробую кратко изложить причины появления итераторов именно в таком виде:
Сделать так, чтобы итератор выглядел/действовал как генератор из других языков (в результате появляется
yield
);Свести к минимуму необходимость совместного использования слишком большого количества стек фреймов;
Разрешите очистку с помощью
defer
;Уменьшить количество данных, хранящихся вне потока управления.
Как объясняет Russ Cox (rsc) в оригинальном предложении:
Примечание относительно типов итераторов push и pull: В большинстве случаев push-итераторы более удобны в реализации и использовании, поскольку настройка и завершение работы могут быть выполнены в рамках вызовов yield, а не реализованы как отдельные операции и затем показаны вызывающей стороне. Прямое использование (в том числе в range loop) итератора push требует отказа от хранения любых данных в потоке управления, поэтому отдельные клиенты могут иногда захотеть использовать вместо него итератор pull. Любой такой код может вызвать Pull.
Russ Cox в своей статье Storing Data in Control Flow более подробно рассказывает о том, почему ему нравится такой подход к проектированию.
Более сложный пример
Пример гораздо более сложного подхода, требующий очистки, когда значения извлекаются напрямую:
// Pairs returns an iterator over successive pairs of values from seq.
func Pairs[V any](seq iter.Seq[V]) iter.Seq2[V, V] {
return func(yield func(V, V) bool) bool {
next, stop := iter.Pull(it)
defer stop()
v1, ok1 := next()
v2, ok2 := next()
for ok1 || ok2 {
if !yield(v1, v2) {
return false
}
}
return true
}
}
Альтернативное псевдопредложение (State Machine)
При разработке Odin я хотел, чтобы пользователь мог создавать свои собственные "iterators", но чтобы они были очень простыми; по сути, обычными процедурами. Я не хотел добавлять в язык специальную конструкцию - это слишком усложнило бы язык, а именно это я и хотел свести к минимуму в Odin.
Одно из возможных вариантом, который я мог бы предложить для итераторов Go, выглядело бы следующим образом:
func Backward[E any](s []E) func() (int, E, bool) {
i := len(s)-1
return func(onBreak bool) (idx int, elem E, ok bool) {
if onBreak || !(i >= 0) {
// Where clean-up code goes, if there is any
return
}
idx, elem, ok = i, s[i], true
i--
return
}
}
Оно будет работать следующим образом:
for it := Backward(s);; {
_, el, ok := it(false)
if !ok {
break // it(true) does not need to be called because the `false` was called
}
fmt.Print(el, " ")
}
Это похоже на то, что я делаю в Odin, НО Odin не поддерживает замыкания с захватом стекового фрейма, только литералы процедур без захвата скопа. Поскольку Go имеет сборку мусора, я не вижу необходимости использовать их подобным образом. Главное отличие в том, что Odin не пытается объединить эти идеи в одну конструкцию.
Я знаю, что некоторые люди считают такой подход гораздо более сложным. Cox предпочитает хранить данные в потоке управления, а эта реализация делает противоположное, она хранит данные вне его. Но обычно именно этого я и хочу от итератора, а не того, что собираются реализовать в Go.
Философия Go
Подход, который использует Go 1.23, кажется, идет вразрез с философией создания Go для обычного (откровенно посредственного) программиста в Google, который не хочет (или не может) использовать такой "сложный" язык, как C++.
Цитата Rob Pike:
Ключевой момент здесь в том, что наши программисты - это гуглеры, они не научные сотрудники. Как правило, они довольно молоды, только что закончили школу, возможно, изучали Java, возможно, C или C++, возможно, Python. Они не способны понять гениальный язык, но мы хотим использовать их для создания хорошего программного обеспечения. Поэтому язык, который мы им даем, должен быть простым для понимания и легким для освоения.
Я знаю, что многие люди обиделись на этот комментарий, но можно сделать гениальный дизайн языка, если вы понимаете, для кого вы разрабатываете этот язык. Это не оскорбление, а скорее констатация факта, поскольку Go изначально создавался для людей, работающих в Google и других подобных отраслях. Вы можете быть "лучшим и более способным" программистом, чем средний гуглер, но это не имеет значения. Есть причина, по которой люди любят Go: он прост, понятен, и большинство людей могут освоить его очень быстро.
Однако такой дизайн итератора кажется нехарактерным для Go, особенно для такого человека, как Russ Cox. Это делает Go намного сложнее и даже более "магическим". Я понимаю, как работает система итераторов, потому что в буквальном смысле занимаюсь разработкой языка и внедрением компилятора.
Возможно, аргументом в пользу его дизайна является то, что средний Go-программист не собирается реализовывать итераторы, а просто использует их. И что большинство итераторов, которые понадобятся людям, уже будут доступны в стандартной библиотеке Go или в самом стороннем пакете. Таким образом, вся нагрузка ложится на автора пакета, а не на его пользователя.
Вот почему, как мне кажется, многие люди "злятся" на дизайн. Он противоречит всему, чем Go изначально "должен был быть" в глазах многих людей. Я понимаю "красоту" того, что он выглядит как генератор с yield и подходом inline кода, но я не думаю, что это обязательно соответствует тому, чем является Go для многих людей. Go действительно многое скрывает, особенно в сборке мусора, горутинах, операторе select и многих других конструкциях. Однако мне кажется, что итераторы в текущем виде слишком сильно раскрывает магию для пользователя, а для обычного программиста Go выглядит слишком сложным.
Еще очень запутано выглядит, что func
возвращает func
которая принимает func
как аргумент. И тело for range
преобразуется в func
, а все break
преобразуются в return false
. Это всего лишь три уровня процедур, что опять же похоже на функциональный, а не императивный язык.
Обобщенный подход к итераторам, возможно, не был хорошей вещью в Go с самого начала. По крайней мере, для меня Go - это безоговорочный императивный язык с первоклассными CSP-подобными конструкциями. Он не пытается быть функционально-подобным языком. Итераторы могут быть очень элегантными в функциональных языках, но в некоторых императивных языках они всегда кажутся "странными", потому что их объединяют в отдельную конструкцию, а не разделяют на части (initialize+iterator+destroy).
Подход Odin
Как я уже упоминал, в Odin итератор - это просто вызов процедуры, где последнее значение возврата - это просто булево значение, указывающее, продолжать или нет. И поскольку Odin не поддерживает замыкания, эквивалентный итератор Go Backward в Odin требует немного больше кода.
Прежде чем люди скажут "это выглядит еще сложнее", пожалуйста, продолжайте читать статью. Большинство итераторов Odin не такие, и я бы никогда не рекомендовал писать такой итератор там, где тривиальный цикл for-loop был бы предпочтительнее как для читателя, так и для автора кода.
// Explicit struct for the state
Backward_Iterator :: struct($E: typeid) {
slice: []E,
idx: int,
}
// Explicit construction for the iterator
backward_make :: proc(s: []$E) -> Backward_Iterator(E) {
return {slice = s, idx = len(s)-1}
}
backward_iterate :: proc(it: ^Backward_Iterator($E)) -> (elem: E, idx: int, ok: bool) {
if it.idx >= 0 {
elem, idx, ok = it.slice[it.idx], it.idx, true
it.idx -= 1
}
return
}
s := []string{"a", "b", "c"}
it := backward_make(s)
for el, _ in backward_iterate(&it) { // `for el in` could have been written too
fmt.print(el, " ")
}
// c b a
Это кажется намного сложнее, чем подход Go, потому что требует написания гораздо большего количества кода. Однако на самом деле его гораздо проще понять, осмыслить и даже быстрее выполнить. Итератор не вызывает тело цикла for-loop, скорее тело вызывает итератор. Я знаю, что Cox нравится возможность хранения данных в потоке управления, и я согласен, что это здорово, но не очень хорошо вписывается в Odin, особенно с отсутствием замыканий (потому что Odin - это язык с ручным управлением памятью).
“iterator” - это просто синтаксический сахар для следующего:
for {
el, _, ok := backward_iterate(&it)
if !ok {
break
}
fmt.print(el, " ")
}
// With `or_break`
for {
el, _ := backward_iterate(&it) or_break
fmt.print(el, " ")
}
Подход Odin - это просто устранение магии и предельная ясность происходящего. "Construction" и "destruction" должны обрабатываться вручную с помощью явных процедур. А итерация - это простая процедура, которая называется each loop. Все три конструкции обрабатываются отдельно, а не сливаются в одну запутанную, как в Go 1.23.
Odin не скрывает магию, в то время как подход Go на самом деле очень магический. Odin заставляет вас вручную обрабатывать "closure-like" значения, а также строить и уничтожать сам "iterator".
Подход Odin также тривиально позволяет вам иметь столько возвращаемых значений, сколько вы захотите! Хорошим примером этого является пакет core:encoding/csv
, в котором Reader
может рассматриваться как итератор:
// core:encoding/csv
iterator_next :: proc(r: ^Reader) -> (record: []string, idx: int, err: Error, more: bool) {...}
// User code
for record, idx, err in csv.iterator_next(&reader) {
...
}
C++ Iterators
В этой статье я постараюсь не вдаваться в долгие размышления об "iterators" C++. Итераторы в C++ - это гораздо больше, чем просто итераторы, в то время как подход Go, по крайней мере, все еще является простым итератором. Я прекрасно понимаю, почему "iterators" в C++ делают то, что они делают, но в 99,99 % случаев мне нужен просто итератор, а не что-то, обладающее всеми алгебраическими свойствами, которые позволяют использовать его в более "общих" случаях.
Для тех, кто не очень хорошо знает C++, итератор - это пользовательский struct
/class
, который требует наличия перегруженных operators
. Исторически сложилось так, что в C++ "iterator" выглядел следующим образом:
{
auto && __range = range-expression ;
auto __begin = begin-expr ;
auto __end = end-expr ;
for ( ; __begin != __end; ++__begin) {
range-declaration = *__begin;
loop-statement
}
}
Самая большая проблема заключается в том, что "iterator" C++ требуют определения как минимум 5 различных операций.
Следующие три оператора перегружаются:
operator==
илиoperator!=
operator++
operator*
Наряду с двумя отдельными процедурами или связанными методами, которые возвращают значение итератора:
begin
end
Если бы я проектировал в C++ только итераторы, я бы добавил простой метод в struct
/class
под названием iterator_next
или что-то в этом роде. И все. Да, при этом теряются другие алгебраические свойства, но они мне, честно говоря, не нужны НИКОГДА для решения задач, над которыми я работаю. Когда я работаю над подобными проблемами, они всегда будут либо смежными массивами, либо я буду реализовывать алгоритм вручную, потому что хочу быть уверенным, что производительность будет хорошей для этой структуры данных. Однако я не просто так создал свой собственный язык (Odin), потому что я совершенно не согласен со всей философией C++ и хочу уйти от этого безумия.
Итераторы C++ намного сложнее, чем итераторы Go, но они гораздо более "прямые" по отношению к локальным операциям.
По крайней мере, в Go вам не нужно конструировать тип с 5 различными свойствами.
В заключение
Мне кажется, что итераторы в Go действительно имеют смысл с учетом примененных к ним принципов проектирования, но при этом кажутся противоположными тому, чем является Go в представлении большинства людей. Я понимаю, что Go "пришлось" усложнять с годами, особенно с введением дженериков (которые, на мой взгляд, действительно хорошо продуманы, только с некоторыми синтаксическими придирками), но введение итераторов такого рода кажется неправильным.
Короче говоря, мне кажется, что это противоречит явной философии Go, в которую верят многие люди, в сочетании с тем, что это очень функциональный способ делать вещи, а не императивный.
И по этим причинам, я думаю, людям не нравится итератор, даже если я полностью понимаю выбор дизайна. Для многих людей это не похоже на то, чем был оригинал Go.
Возможно, мои (и других людей) опасения слишком раздуты, и большинство людей никогда не будут их внедрять, а просто будут использовать, и что они настолько сложны в реализации.
Предпоследнее противоречивое мнение: Может быть, Go нужно было еще больше "gate-keep" и просто сказать "functional-bros", чтобы они ушли и перестали просить о таких функциях, которые делают Go гораздо более сложным и комплексным языком.
Последнее спорное замечание: я бы просто не разрешил использование пользовательских итераторов в Go, но я не в команде разработчиков Go (и не хочу быть).
Комментарии (6)
ddwu
18.06.2024 21:42вы прослушали нейтивку некоего языка Odin.. и немного срача с комментов :)
justwack Автор
18.06.2024 21:42+1Основной тезис в том, что такая реализация не совсем "go way". Что наверно имеет смысл.
Сравнение с Odin выглядит конечно неуклюже))
Go evolves in the wrong direction от Aliaksandr Valialkin интересней. Надо было его постить)
gnomeby
18.06.2024 21:42В превьюшке статьи находится вот такая картинка, предлагаю начать минусовать за размещение блевотных картинок.
kolya7k
18.06.2024 21:42Итераторы в C++ читаются и создаются легко и приятно, а все то, что вы выложили тут способно вызвать дьявола если где-то случайно сделать ошибку :)
Легкость чтения, понятность, удобство, очевидность - 1/10
Fancryer
Что с переводом?..