ПРИМЕЧАНИЕ: данный пост является адаптацией следующего твита (однако абсолютно самодостаточен): https://x.com/TheGingerBill/status/1802645945642799423

TL;DR язык Go сейчас воспринимается как слишком «функциональный», а не столь беззастенчиво императивный язык.

Недавно мне попался в твиттере пост, демонстрирующий, как будут устроены итераторы в 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]) {
				// Здесь идёт код очистки
				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 // `возврат false` был бы равноценен явному `break`
})

Таким образом, итераторы Go гораздо ближе к методам “for each”, применяемым в некоторых языках  (напр., .forEach() в JavaScript), например, в том, что такому методу передаётся обратный вызов. Причём вот что интересно: такой подход уже был возможен в Go и до версии 1.23, однако ещё отсутствовал синтаксический сахар, который позволял бы использовать его внутри оператора for range.

Попытаюсь обобщить, чем именно обосновано появление итераторов в Go 1.23, но представляется, что разработчики стремились добиться следующего, минимизировав нежелательные последствия этих факторов:

  • Добиться, чтобы итераторы выглядели/действовали как генераторы из других языков (следовательно, см. yield)

  • Минимизировать потребность в совместном использовании большого количества кадров стека

  • Обеспечить очистку с использованием defer

  • Сократить случаи хранения данных вне потока управления

Вот как всё это объясняет Расс Кокс (rsc) в исходном предложении:

Замечание относительно двух типов итераторов: проталкивающих и подтягивающих (push vs pull). В абсолютном большинстве случаев проталкивающие итераторы удобнее реализовывать и использовать, поскольку их можно и обустраивать, и сносить в контексте вызовов yield. Так гораздо лучше, чем если бы приходилось реализовывать их как отдельные операции, а затем предоставлять вызывающей стороне. При непосредственном использовании проталкивающих итераторов (в том числе с циклом range) приходится отказаться от хранения каких-либо данных в потоке управления, поэтому некоторым клиентам могут время от времени требоваться подтягивающие итераторы. В любом таком коде не составляет труда вызвать Pull и отложить stop.

Расс Кокс подробнее исследует эту ситуацию в своей статье Storing Data in Control Flow, рассказывая, чем именно ему нравится такой подход к проектированию.

Пример посложнее

Примечание: не старайтесь понять, что именно делает этот код. На этом примере я просто хочу показать, как организуется очистка в коде, где применяется нечто вроде  defer.

В примере из исходного пул-реквеста проиллюстрирован гораздо более сложный подход: здесь очистка требуется при работе с непосредственно подтягиваемыми значениями:

// Pairs возвращает итератор, перебирающий последовательные пары значений из данной последовательности.
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
	}
}

Альтернативное псевдо-предложение (с конечным автоматом)

Примечание: я не предполагаю, что в Go такое вообще делается.

При проектировании Odin я хотел предоставить пользователю возможность проектировать собственные «итераторы», но, чтобы эти элементы были очень простыми — фактически, обычными процедурами. Я не хотел добавлять в язык специальную конструкцию строго для этой цели. Из-за этого язык бы чрезмерно усложнялся, а именно такие сложности я стремился свести к минимуму в 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) {
			// Если у нас есть код очистки, то он идёт здесь 
			return
		}
		idx, elem, ok = i, s[i], true
		i--
		return
	}
}

Данное псевдо-предложение работало бы примерно так:

for it := Backward(s);; {
	_, el, ok := it(false)
	if !ok {
		break // it(true) не требуется вызывать, поскольку был вызван вариант `false` 
	}

	fmt.Print(el, " ")
}

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

Знаю, что некоторым такой подход кажется гораздо сложнее обычного. Здесь мы делаем ровно наоборот по сравнению с тем, что предпочитает Кокс — он предлагает хранить данные в потоке управления, а мы храним их вне потока управления. Но именно в таком качестве я обычно хочу использовать итератор, а не так, как это собираются реализовать в  Go. В этом и проблема: при моём подходе мы жертвуем красотой хранения данных в потоке управления, а значит — и удобным различением операций подтягивания и проталкивания (push/pull), о котором рассказывает Кокс.

Примечание: я по натуре императивный программист, мне нравится видеть, как именно выполняются те или иные вещи. Для меня это важнее, чем выстроить «внешне элегантный» код. Поэтому тот подход, который я описываю выше, принципиально заключается в следующем: мы думаем, прежде всего, о том, как что будет выполняться.

Кстати, маршрут через класс типов/интерфейс в Go работать не будет, поскольку с точки зрения проектирования такая концепция не является самостоятельной и, на самом деле, только вносит излишнюю путаницу. Именно поэтому я исходно её и не предлагал. В различных языках предъявляются разные требования, поэтому то, что будет работать в одних, неприменимо в других.

Какая философия просматривается в Go

По-видимому, тот подход, который предпринят в Go 1.23, идёт в русле явной философии сориентировать Go на типичного (честно говоря, посредственного) программиста, работающего в Google. Такой программист не хочет (и не может) пользоваться «сложным» языком, таким, как C++.

Процитирую здесь Роба Пайка:

Ключевой момент здесь таков: наши программисты — гуглеры, а не исследователи. То есть, они, как правило, совсем молоды, недавние выпускники, вероятно, учили Java, может быть, C или C++, также могли учить Python. Они не в состоянии понять высококлассный язык, но мы хотим, чтобы они делали для нас хороший софт. Поэтому тот язык, который мы им даём, должен быть для них легко понятен и таков, чтобы его было легко взять на вооружение.

Знаю, многие будут уязвлены такой ремаркой, но высококлассное проектирование языка требует от авторов понимать, для кого именно они делают этот язык. Это никакое не оскорбление, а просто констатация факта: исходно язык Go предназначался для нынешних и бывших сотрудников Google, а также для представителей схожих компаний. Возможно, вы «более умелый и способный», нежели типичный сотрудник Google, но это не имеет значения. Понятно, чем Go нравится людям: этот язык прост, категоричен, и большинство программистов может очень быстро вкатиться в работу с ним.

Но, если посмотреть, итераторы выше сконструированы вполне в духе Go, особенно это должно быть очевидно для таких членов команды Go как Расс Кокс (исхожу из того, что именно он — автор исходной версии рассматриваемого предложения). При таком подходе Go становится гораздо сложнее и даже кажется «магическим». Я понимаю, как работают итераторы, так как буквально вхожу в состав тех, кто отвечает за проектирование языка и реализацию компилятора. С этим подходом возможна и ещё одна проблема: он не может похвастаться высокой производительностью, поскольку требует задействовать замыкания или обратные вызовы.

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

Думаю, именно поэтому так много людей, которых «злит», как спроектирована эта версия. По мнению многих, такой подход противоречит всему, для чего изначально «предназначался» Go. Кроме того, данный вариант может видеться как переусложнённая «мешанина». Я же вижу красоту в том, что он выглядит как генератор с yield, также поддерживающий инлайнинг кода, но признаю, что не такого ждут от Go многие люди, привыкшие к этому языку. В Go значительная часть «магии» скрыта за кулисами, в особенности это касается сборки мусора, горутин, оператора select, а также многих других конструкций. Но я вижу, что Go немного перебарщивает с раскрытием этой магии пользователю, ведь для среднего Go-программиста такие конструкции всё равно выглядят слишком сложными.

Ещё один аспект, который многих смущает, связан с func, возвращающей такую func, которая принимает func в качестве аргумента. А также с тем, что тело for range преобразуется в func и все break (и другие конструкции, выходящие за пределы потока управления) преобразуются в return false. Здесь просто три уровня процедур в глубину, но язык с такой конструкцией воспринимается скорее как функциональный, чем как императивный.

Примечание: я не предлагаю заменить нынешнее устройство итераторов на такую структуру, какую описываю здесь, а указываю, что в Go вообще вряд ли хорошо приживётся такой обобщённый подход к итераторам. Как по мне, Go — это бесспорно императивный язык, и конструкции первого класса у него примерно такие же, как в Csharp. Он не претендует на статус функционального языка. Итераторы в Go занимают странное место, поскольку они находятся именно там, где итераторам положено находиться в императивных языках, но концептуально они очень «функциональные». В функциональных языках итераторы могут быть очень элегантными, но в откровенно императивных языках они всегда будут казаться странными. Дело именно в том, что они унифицируются в виде отдельной конструкции, а не служат разделителями в конструкции  (инициализация+итератор+устранение).

Отступление в сторону: подход, применяемый в Odin

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

Примечание: прежде чем начнёте говорить «стало ещё сложнее» — лучше листайте дальше. Большинство итераторов в Odin устроены иначе, и я ни в коем случае не рекомендую писать такой итератор в случаях, когда хватило бы тривиального цикла for. Такой цикл был бы более удобен для всех: кто пишет код, и кто его читает.

// Структура, в которой явно описано состояние
Backward_Iterator :: struct($E: typeid) {
	slice: []E,
	idx:   int,
}

// Конструкция, явно описывающая итератор
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` 
	fmt.print(el, " ")
}
// c b a

Этот код в самом деле кажется гораздо сложнее подхода, применяемого в Go, поскольку писать здесь требуется гораздо больше. Но при этом его гораздо проще понимать, осмысливать, он даже быстрее выполняется. Итератор не вызывает тело цикла for; напротив, в самом теле цикла вызывается итератор. Я знаю, насколько нравится Коксу возможность хранить данные в потоке управления и соглашусь — это в самом деле симпатично. Но такой подход плохо согласуется с Odin, в частности, потому, что в Odin нет замыканий. А Замыканий в Odin нет, поскольку управление памятью в этом языке происходит вручную.  

«Итератор» в данном случае — это просто синтаксический сахар для выполнения следующей операции:

for {
	el, _, ok := backward_iterate(&it)
	if !ok {
		break
	}
	fmt.print(el, " ")
}

// с `or_break`

for {
	el, _ := backward_iterate(&it) or_break
	fmt.print(el, " ")
}

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

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

Кроме того, при применяемом в Odin подходе вы без труда сможете сделать столько возвращаемых значений, сколько захотите! Хороший пример такого рода — пакет Odin core:encoding/csv, где с Reader можно обращаться как с итератором:

// core:encoding/csv
iterator_next :: proc(r: ^Reader) -> (record: []string, idx: int, err: Error, more: bool) {...}
// Пользовательский код
for record, idx, err in csv.iterator_next(&reader) {
	...
}

Ещё одно отступление: итераторы в C++

В этой статье я постараюсь не слишком критиковать «итераторы», применяемые в C++. В   C++ такие конструкции — далеко не просто итераторы, тогда как при подходе Go это всё ещё просто итераторы. Я целиком понимаю, почему «итераторы» в C++ работают именно так, а не иначе, но в 99,99% случаев мне требуется просто итератор, а не структура с полным набором алгебраических свойств и просто универсальной применимостью.

Те, кто не очень хорошо знает C++, могут считать итератор специализированным классом/структурой, который должен сопровождаться перегруженными операторами и действовать как «указатель». Исторически «итератор» в C++ выглядел так:

{
	auto && __range = range-expression ;
	auto __begin = begin-expr ;
	auto __end = end-expr ;
	for ( ; __begin != __end; ++__begin) {
		range-declaration = *__begin;
		loop-statement
	}
}

Кроме того, его обёртывали в макрос до тех пор, пока в C++11 у циклов не появился синтаксис, а также не сформировалось auto.

Самая большая проблема в том, что при работе с «итераторами» в C++ требуется определять как минимум 5 разных операций.

Во-первых, три следующие перегрузки операторов:

  • operator== или operator!=

  • operator++

  • operator*

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

  • begin

  • end

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

«Итераторы» в C++ донельзя сложнее, чем итераторы в Go, но при локальном использовании они действуют гораздо прямолинейнее. Как минимум, в Go вам не придётся конструировать тип с пятью разными свойствами.

Заключение

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

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

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

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

Предпоследний небесспорный тезис: может быть, в Go требуется поступить ещё строже, предложить «функциональщикам» убраться и прекратить запрашивать такие фичи, из-за которых Go только излишне усложняется.

Последний небесспорный тезис: будь моя воля, я бы вообще не допускал в Go пользовательские итераторы, но я не в команде разработчиков Go и не стремлюсь туда.

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


  1. qeeveex
    24.06.2024 11:41

    Боже, какой ужасный перевод...


    1. datacompboy
      24.06.2024 11:41
      +1

      Вот был чуть ранее перевод лучше https://habr.com/ru/articles/822697/