[Перевод] Почему люди злятся из-за итераторов в Go 1.23

0b42d090c66cf3c2d2495bb25df01efa.jpg

Недавно я увидел сообщение, демонстрирующее будущий дизайн итераторов для Go 1.23 (август 2024 года). Насколько я могу судить, многим людям этот дизайн не нравится. Я хотел высказать свои мысли по этому поводу как разработчик языка.

Предложения по итераторам можно найти здесь: https://github.com/golang/go/issues/61897

В нем есть подробное описание дизайна, объясняющее, почему были выбраны те или иные подходы, поэтому я рекомендую прочитать его, если вы знакомы с 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 различных операций.

Следующие три оператора перегружаются:

Наряду с двумя отдельными процедурами или связанными методами, которые возвращают значение итератора:

Если бы я проектировал в C++ только итераторы, я бы добавил простой метод в struct/class под названием iterator_next или что-то в этом роде. И все. Да, при этом теряются другие алгебраические свойства, но они мне, честно говоря, не нужны НИКОГДА для решения задач, над которыми я работаю. Когда я работаю над подобными проблемами, они всегда будут либо смежными массивами, либо я буду реализовывать алгоритм вручную, потому что хочу быть уверенным, что производительность будет хорошей для этой структуры данных. Однако я не просто так создал свой собственный язык (Odin), потому что я совершенно не согласен со всей философией C++ и хочу уйти от этого безумия.

Итераторы C++ намного сложнее, чем итераторы Go, но они гораздо более «прямые» по отношению к локальным операциям.

По крайней мере, в Go вам не нужно конструировать тип с 5 различными свойствами.

В заключение

Мне кажется, что итераторы в Go действительно имеют смысл с учетом примененных к ним принципов проектирования, но при этом кажутся противоположными тому, чем является Go в представлении большинства людей. Я понимаю, что Go «пришлось» усложнять с годами, особенно с введением дженериков (которые, на мой взгляд, действительно хорошо продуманы, только с некоторыми синтаксическими придирками), но введение итераторов такого рода кажется неправильным.

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

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

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

Предпоследнее противоречивое мнение: Может быть, Go нужно было еще больше «gate-keep» и просто сказать «functional-bros», чтобы они ушли и перестали просить о таких функциях, которые делают Go гораздо более сложным и комплексным языком.

Последнее спорное замечание: я бы просто не разрешил использование пользовательских итераторов в Go, но я не в команде разработчиков Go (и не хочу быть).

© Habrahabr.ru