TG Telegram Group Link
Channel: Go Update
Back to Bottom
Channel created
Channel photo updated
Расс Кокс публикует новую статью или итераторам быть?

Позавчера Расс выложил новую статью под названием Storing Data in Control Flow, смысл которой сводится к рассуждению о хранении текущих данных внутри горутины, а примеры кода содержат фактически прямую реализацию паттерна «итератор». Те кто давно следят за блогом тех-лида Go компилятора знают, что каждая статья обычно предвещает выход нового предложения по улучшению языка. Так было с vgo (который стал go modules), так было с дженериками, так было с телеметрией в компиляторе. Не всегда эти предложения находят отклик среди сообщества - например предложение по телеметрии пришлось полностью переработать, сменив модель с opt-out на opt-in.

Кто-то спросит - а в чем собственно проблема? А проблем две, в которых вторая вытекает из первой:
1. range поддерживает только встроенные типы данных (массивы, слайсы, мапы и каналы). Итерацию по собственным типам приходиться делать с помощью создания и вызова методов Next, Scan, Iter. Отсутствие общего паттерна не было большой проблемой до появления дженериков, релиз которых состоялся в Go 1.18. Но вот с их приходом и появлением обобщенных функций для работы с коллекциями (в том числе и в стандартной библиотеке), собственные коллекции данных начинают все больше ощущаться как второсортные. А работа с ними разительно отличается от работы с встроенными коллекциями, что приводит к невозможности, например, создания обобщенного кода для работы со списками и слайсами.
2. Для map невозможно реализовать итератор стандартными средствами, в отличии от слайсов и каналов, т.к. не существует возможности сохранить нашу текущую позицию в мапе. Безусловно можно воспользоваться пакетом reflect (а любители темной магии могут подключить unsafe и скопировать обьявления из стандартной бибилотеки), но эти способы несут проблемы как по удобству, так по безопастности и скорости выполнения. Ситуацию можно решить и через каналы, но скорость подобного кода будет оставлять лучшего, а главное всегда будет существовать риск утечки памяти, если вы забудете закрыть генерирующую горутину.

Запрос на итераторы в open-source сообществе Go и в корпоративных кругах назрел давно. Дискуссия созданная Расс’ом от октября 2022 показывает, что разработчики языка проблему видят и даже имеют варианты решения. А недавняя заметка от разработчиков компилятора показывает, что решение первой проблемы планируют в Go 1.22. Поэтому вполне вероятно, что уже к следующему году нас снова ждет относительно большое обновление языка.
Тем временем Go 1.21 не за горами. Релиз будет меньше эпохального Go 1.18, но нас все равно ждут интересные вещи.

Главные из них:
▸ Улучшенные восходящая и обратная совместимости: начиная с Go 1.21 компилятор встречая строку go x.yy.z (где > 1.21.0) в файле go.mod сам скачает и использует соответствующий тулчейн. Это поведение можно настроить. В следующих заметках я постараюсь раскрыть эту особенность.

clear(x) builtin for maps: то, что мы раньше делали через range { delete … } теперь можно будет сделать одной строчкой. Необходимость подобного непонятна ровно до тех пор, пока вы не начинаете работать с ключами у которых тип данных это число с плавающей запятой. Для интересующихся — каков будет результат вот этой программы? Ответ на вопрос «а почему?» Расс Кокс дал еще в 2012ом году.

min / max функции: еще минус один повод для насмешек от пользователей других языков и возможность для легкого изменения в популярные проекты на Go.

▸ Обобщенные функции для работы со слайсами и мапами: больше не нужно помнить как вставить код в середину слайса.

▸ Пакет log/slog: zap / zerolog теперь и в стандартной библиотеке.

▸ Вывод типов для дженериков поумнел: теперь можно писать вот такой код.
Портал в ад комментарии открыты!
Предопределенный идентификатор zero - универсальное нулевое значение для возврата и сравнений.

Тем времени поезд нововведений от Расса и не думает останавливаться. Вообще это предложение уже высказывалось много раз, но только после прихода дженериков стало понятно, отрицать необходимость подобного больше возможности нет.

Из главного:
- Появлется новый предопределенный идентификатор zero. В отличии от ключевого слова (типо type) он может быть переопределен в контексте поэтому ваш старый код который использует zero как переменную или метод, продолжит работать и дальше.
- Переменные любого типа поддерживают сравнение с zero.
- Переменным имеющим тип всегда можно присвоить zero. Будут работать конструкции var my Struct = zero или myInt := int(zero) но вот i := zero работать не будет.
- Из функций и методов можно возвращать данные с использованием новой конструкции. Например вот так return zero, zero, err
- Сравнение с zero доступно внутри дженерик функций у которых на типы-параметры стоит констрейнт any. В данный момент это очень большая головная боль для тех кто пишет библиотеки со структурами данных.

Фактически zero это аналог nil только покрывающий вообще все типы.

Из главных мотиваторов нового предложения по улучшению языка Кокс выделел две вещи:
- Ему надоело объяснять зачем нужна конструкция *new(T) и он уверен что мы можем лучше.
- Вопрос сравнения с нулевым значением внутри дженерик функций встал особенно остро после принятия cmp.Or о котором я обязательно расскажу в других сообщениях.

На вопрос почему не использовать _ Рас ответил, что if myVal == _ { … } выглядит неидиоматично. А вот на вопрос почему не расширить nil ответ посложнее - многие потенциальные баги были пойманы на этапе компиляции за счет того, что nil работает только с map/slice/chan/interface/pointer и эту семантику хотелось бы сохранить и в будущем. Из реального примера, Расс приводит ситуацию когда человек написал if(someValue) вместо if(*someValue) в Сишном коде, что привело к значительно потере данных внутри Google (решилось через резервные системы).

От себя добавлю - как и в случае с min/max ситуация назрела и перезрела. Особенно остро это ощущается на бизнес логике, где люди что бы не писать return MySomeRandomStruct{}, err использовали указатели и не понимали к каким следствиям это может привести.

func checkForZero(potentialZero MyType) (SomeType, error) {
if val == zero {
return zero, errors.New("is zero")
}

return SomeType(val), zero
}


Скоро и в вашем коде…
По горячим следам:

- Зачем нужна конструция *new(T)?
В дженерик функциях существует только два способа вернуть нулевое значение типа-параметра. Первый это var zeroVal T; return zeroVal - две строчки после форматирования. Либо вот *new(T) - в одну.

- Почему нельзя просто обьявить IsEmpty(val) и CreateEmpty()?
Это будет новое встроенное определение которое покрывает лишь половину кейсов. Go славится своей попыткой снизить когнитивную нагрузку, поэтому отдельные конструкции для сравнения с нулем и создания нуля будут выглядеть как переусложение.

Задавайте ваши вопросы в комментариях, будем дополнять список.
Корутины в Go

Пока идет бурное обсуждение по поводу zero, Расс Кокс продолжает свои рассуждения о хранении данных внутри горутины-потока исполнения. В этот раз он рассматривает корутины, как вариант генераторов/итераторов.

Вообще роль корутин (т.е. сопрограмм) в Go с самого момента появления языка выполняли горутины - почти все изучая Go пишут что-то вроде func generate(num int) <-chan int { ... } для генерации списка чисел. Однако Расс утверждает, что в языке есть место и для корутин.

В общей терминологии это звучит так:
- Горутины нужны тогда, когда нужно асинхронное, параллельное исполнение. Две горутины могут исполнятся одновременно, независимо, им не нужно явно принимать и отдавать управление. Управлением горутинами, в общем случае, занимается рантайм.
- Корутины нужны тогда, когда нужно асинхронное, последовательное исполнение. Только одна корутина исполняется в момент времени, отдавая и принимая обратно управление в специальных точках-функциях (yield). Вызывающий код сам решает, в какой момент времени происходит переключение между корутинами.

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

Однако у всего это кода есть один существенный минус - скорость его исполнения. На MacBook Pro 2019 года один вызов yield занимает порядка 380ns, что очень долго если говорить про итерацию по коллекциям или относительно простые арифметические операции. Однако имея доступ к внутренностям рантайма и подправив несколько мест в нем (бонусы бытия техлидом Go 😄) специально для этого случая, Рассу удалось достигнуть затрат в 40ns на одну итерацию, что в 10 раз быстрее изначальной реализации и уже достаточно быстро для использования подобного паттерна в общем случае.

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

Я рекомендую посмотреть код в самой статье, даже если вы плохо знаете английский - это хорошая гимнастика для мозгов с использованием каналов как двунаправленного средства обмена данными.
proposal 61405: spec: add range over int, range over func

Вот и настал тот момент - Расс официально предлагает добавить поддержку range над следующими видами выражений:

Range over integer: for x := range n { ... } где n целочисленное выражение или переменная. Будет эквивалентно for x := 0; x < n; x++ { ... }

Range over function: теперь можно будет писать код for x, y := range f { … } где f это функция или метод со следующей сигнатурой:

1. func(yield func(T1, T2)bool) bool
2. func(yield func(T1)bool) bool
3. func(yield func()bool) bool


Значения которые не нужны запрашивающему коду (тот кто вызывает range) будут автоматически выброшены. Например для функции func(yield func(T1, T2) bool) bool допустимы следующие варианты range:

1. for x, y := range f { ... }
2. for x, _ := range f { ... }
3. for _, y := range f { ... }
4. for x := range f { ... }
5. for range f { ... }|


Возвращаемое yield булево значение позволяет определить вызываемому коду (тот кого вызывают через range), что пора прекратить исполнение и двигаться на выход (в случае break или return). Т.е. в случае for range f { break; } сразу после первого вызова yield вернет false.

Детали реализации и различные тонкости (такие как, что будет если yield будет вызван после выхода из тела range) пока обсуждаются.
И сразу ответы на вопросы:

▸ Почему такая странная сигнатура у f?

С приходом дженериков, необходимость итерации по кастомным структурам данных встала особенно остро - в особенности по тем, в которых невозможно или очень сложно зафиксировать текущее положение. В качестве примера - попробуйте придумать итератор который будет работать одновременно и для []string и map[string]string. Затем попробуйте придумать как добавить поддержку древовидной структуры данных. А затем добавьте в микс каналы. Когда дойдете до import "reflect" можно смело останавливаться.

▸ Почему нельзя сделать интерфейс?

На самом деле нет большой разницы между func(yield func(T1, T2)bool) bool
и type Iterator interface { Iter(yield (V T, V2 T2) bool) bool }. Более того, вариант с интерфейсом обсуждали, но решили отказаться от него, т.к. это будет противоречить принципам языка, где методы у типов не являются обязательным условием для полноценности этого самого типа. Кому интересно, можете погрузиться в дискуссии про дженерики - там это хорошо прописано. Кроме того, в текущем предложении это вызывает проблемы с взаимодействием с типом type MyInt int у которого обьявлен метод Iter.

▸ Как поиграться и попробовать на практике?

go install golang.org/dl/gotip@latest
gotip download 510541 # download and build CL 510541
gotip version # should say "(w/ rangefunc)"
gotip run foo.go
proposal 61489: add built-in null for zero value of pointers - немножко странного в этот пятничный вечер.

Иэн Ланс Тейлор (один из ключевых разработчиков компилятора и языка Go) предлагает добавить предопределенный идентификатор null в язык. И нет, это не первоапрельская шутка: новый идентификатор будет работать аналогично nil, но только для типов-указателей. Согласно задумке автора, это изменение поможет лучше понимать, что выражение err != nil не гарантирует отсутствие нулевого указателя на данные внутри переменной интерфейса.

В дальнейшей части документа рассказывается про переход на новые правила и возможное введение запрета на присваивание nil указателям в будущих версиях Go. Однако все остальные типы (slice/map/interface/chan) будут продолжать использовать nil.

Иэн признает, что предложение скорее всего будет отклонено, но ему было важно записать эту идею, что-бы в будущем люди могли ссылаться на нее при обсуждении проблемы typed nil и поиска решений для нее.
Дайджест предложений в которых участвует Go Core Team:

- spec: less error-prone loop variable scoping #60078 — изменение в механике «захвата» переменных внутри тела цикла. Минус один вопрос на интервью начиная с Go 1.22. О нем я все еще надеюсь написать отдельно.
- testing: add Name to track file and line of test case declaration #52751 — новая функция для тестов, который позволяет записывает где она была вызвана. Удобно для табличных тестов.
- x/net/quic: add QUIC implementation #58547 — HTTP3 все ближе.
- builtin: add abs to get absolute value of numbers( integer and float pointers) #60623 — если мы завезли min/max это не значит, что мы завезем и остальное.
- testing: a less error-prone API for benchmark iteration #48768 — корректные бенчмарки писать сложно, но это предложение ситуацию лучше не делает.
- proposal: net/http: enhanced ServeMux routing #61410 — роутер как у гориллы, теперь в стадартной библиотеке.

Полный список - тут.
Go 1.22 inlining overhaul

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

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

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

Результаты и прирост производительности будут ближе к Go 1.22. Ждем.
Generic Null[T] in sql package

Я часто слышу фразу: «Ну вот у нас есть дженерики, а зачем они конкретно мне?». Действительно, большинство использований дженериков приходиться на библиотечный код, и для разработчика бизнес логики их использование чаще всего довольно прозрачно. Чаще всего настолько прозрачно, что они скорее всего не увидят характерных квадратных скобок вне привычной сигнатуры словаря map, либо увидят их по минимуму. Те-же новые функции из пакетов slices/maps большинство будут использовать в вариантах похожих на println("First negative at index", slices.IndexFunc([]int{0, 42, -10, 8}, func(n int) bool { return n < 0 })) где тайп параметры не видны вызывающему.

Однако есть и полезные исключения типов и функций где тайп параметры все таки видно. В 1.19 нам завезли первый дженерик тип в пакет sync/atomic: atomic.Pointer[T] который полностью решил все задачи, которые раньше решал требующий тайп ассертов atomic.Value. Больше нет необходимости приводить тип вручную, или получить панику забыв это сделать при очередном рефакторинге. Да и памяти atomic.Pointer[T] ест в два раза меньше (8 байт вместо 16). Вопрос в комментарии: однако есть один cценарий который все еще лучше покрывается через atomic.Value. Что это за сценарий?

В 1.22 нас ждет еще одно полезное для всех нововведение: тип sql.Null[T] который обьединит собой не только типы sql.NullString или sql.NullFloat64, но еще и покроет ваши кастомные типы. Теперь работа со стандартными типами и со своими не будет отличаться, что даст еще один плюс к общей читаемости кода (единообразие тоже часть читаемости кода). Самое прекрасное, что полная реализация sql.Null[T] умещается в 22 строчки и которую легко поймет любой Go разработчик, даже тот который никогда не сталкивался с дженериками.

А какие у вас есть семейства типов и функций которые могли бы получить реализацию в дженериках и облегчить вам жизнь? Пишите в комментариях.
Update: proposal: spec: add untyped builtin zero

Собрав фидбек Расс предлагает немного изменить правила использования для zero: новый идентификатор можно будет использовать для присваивания и сравнения только там где недоступны другие «короткие» идентификаторы — nil, "", 0. В число этих случаев входят дженерики с констрейнтом any.

Никаких сложных выборов - писать return "" или return zero.
HTML Embed Code:
2025/07/05 06:15:56
Back to Top