Привет, Хабр! Меня зовут Рафаэль Мустафин, я ментор на курсе «Go-разработчик» в Яндекс Практикуме. Эта статья посвящена нюансам цикла range в Go. Мы рассмотрим распространённые подводные камни, лучшие практики и интересные изменения, ожидаемые в Go 1.22.

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

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

Навигация по статье

Понимание цикла range

Итак, давайте погрузимся в одну из интересных особенностей Go: цикл range. Это как швейцарский армейский нож для выполнения циклов в Go. Простой, но мощный, он помогает перебирать различные типы данных и призван избежать многих распространённых ошибок, связанных с традиционными циклами for.

Как работает цикл range

Представьте себе цикл range как удобный способ пройтись по структурам данных. Его задача — проводить вас по каждому элементу массива, слайса, map’ы и строки. Вот кратко, как это выглядит:

for index, value := range collection {
    // Ваш код здесь
}

У вас есть collection, и цикл range предоставляет вам как index (или ключ в map), так и текущее value. Нужно только значение, а не индекс? Нет проблем! Просто используйте знак подчёркивания (_), чтобы сказать: «Игнорируем индекс». И точно так же, если не нужно значение, его можно опустить.

Общие примеры использования

Массивы и слайсы

При итерации по массивам или фрагментам цикл range возвращает индекс и значение элементов в каждом индексе. Это очень полезно для таких задач, как перебор, модификация или простая печать каждого элемента в этих коллекциях.

for i, v := range mySlice {
	fmt.Println(i, v)
}

Maps

Для карт цикл range выполняет итерацию по парам «ключ — значение». Это особенно полезно в сценариях, где вам нужно пройтись по парам «ключ — значение» для таких операций, как поиск, изменение или агрегирование. Например, когда нужно проверить баллы или обновить их.

for username, score := range userScores {
	fmt.Println(username, "has a score of", score)
}

Строки

При использовании со строками цикл range выполняет итерацию по каждому символу (руне) в строке, возвращая индекс и саму руну. Это может быть удобно для таких задач, как подсчёт символов, работа со строками или преобразование кодировок.

for i, c := range "Go" {
	fmt.Println(i, c)
}

Понимание базового синтаксиса цикла range и его применения в различных структурах данных Go позволит изучить его нюансы, включая подводные камни и грядущие изменения в Go 1.22.

Распространённые ошибки при использовании цикла range

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

Забыть про индекс

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

scores := []int{90, 85, 70, 95, 80}

// НЕРПАВИЛЬНО!: Игнорирование значения и использование индекса вместо него
for i := range scores {
    if i == 85 {
        fmt.Println("Found score 85!")
    }
}

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

// ПРАВИЛЬНО
for _, score := range scores {
    if score == 85 {
        fmt.Println("Found score 85!")
        break
    }
}

Изменение элементов во время итерации

Заманчиво изменять элементы непосредственно в цикле диапазона, но это может привести к неожиданным результатам. Давайте рассмотрим неправильный и правильный способы решения этой проблемы.

Неправильный способ: Изменение элементов непосредственно в цикле диапазона.

Продолжим с оценками пользователя в игре, и мы хотим удвоить каждую из них. Вот как вы можете неправильно попытаться это сделать:

numbers := []int{90, 85, 70, 95, 80}

for _, num := range numbers {
    num *= 2 // Это не изменит элементы в слайсе
}

fmt.Println(numbers) // Выходными данными по-прежнему будут [90, 85, 70, 95, 80]

В этом примере num является копией каждого элемента слайса, а не ссылкой на него. Любые изменения num не влияют на исходный срез.

numbers := []int{90, 85, 70, 95, 80}

for i := range numbers {
    numbers[i] *= 2 // Модификация элемента среза через его индекс
}

fmt.Println(numbers) // На выходе получим [180, 170, 190, 160]

В этой версии мы используем индекс i для доступа и изменения каждого элемента в исходном слайсе.

Перехват переменных цикла в closure

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

Неправильный способ: Прямое использование переменных цикла в горутинах.

Вот распространённая ошибка, когда переменная цикла неправильно используется внутри горутины:

values := []int{1, 2, 3}

for _, val := range values {
    go func() {
        fmt.Println(val) // Это может вывести не то, что вы ожидаете!
    }()
}

time.Sleep(1 * time.Second) // Дождемся завершения работы горутин

В этом примере горутина может не вывести ожидаемые значения. Это связано с тем, что val является совместной переменной для всех горутин и может измениться до выполнения горутины. В результате вы можете увидеть неожиданные или повторяющиеся значения.

Правильный способ: Захват переменных цикла.

Вот как правильно перехватить переменную цикла в горутине:

values := []int{1, 2, 3}

for _, val := range values {
    localVal := val // Создаем локальную копию переменной цикла
    go func() {
        fmt.Println(localVal) // Теперь печатается обжидаемое значение
    }()
}

time.Sleep(1 * time.Second) // Ждем завершения работы горутин

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

Другой вариант связан с изменением сигнатуры функции:

values := []int{1, 2, 3}

for _, val := range values {
		go func(localVal int) {
			fmt.Println(localVal) // Теперь печатается ожидаемое значение
		}(val)
}

time.Sleep(1 * time.Second) // Ждем завершения работы горутин

В этой версии мы принимаем переменную в качестве аргумента функции вызываемой в горутине. При этом происходит копирование значения внутрь области видимости функции.

Использование range с указателями

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

Неправильный способ: Модификация указателей без понимания последствий.

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

values := []*int{new(int), new(int), new(int)}
*values[0], *values[1], *values[2] = 1, 2, 3

for _, ptr := диапазон значений {
    newValue := *ptr * 2
    ptr = &newValue // Неправильное присвоение нового адреса ptr
}

for _, ptr := range values {
    fmt.Println(*ptr) // все равно выведет 1, 2, 3
}

Здесь мы хотели удвоить значения, на которые указывают указатели в слайсе. Однако присваивание ptr = &newValue изменяет сам указатель, а не значение, на которое он указывает, оставляя исходные значения неизменными.

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

Чтобы правильно изменить значения, на которые указывают указатели в фрагменте, следует разыменовать указатель:

values := []*int{new(int), new(int), new(int)}
*values[0], *values[1], *values[2] = 1, 2, 3

for _, ptr := диапазон значений {
    *ptr *= 2 // Корректное изменение значения, на которое указывает ptr
}

for _, ptr := range values {
    fmt.Println(*ptr) // В результате будут выведены значения 2, 4, 6
}

В корректном варианте строка *ptr *= 2 разыменовывает ptr и удваивает значение, на которое он указывает. Это изменение отражается в исходных данных, на которые указывает ptr, что позволяет достичь желаемого результата.

Модификация структуры при итерации по ней

Модификация структуры данных во время итерации над ней может привести к непредсказуемому поведению и обычно считается плохой практикой. Давайте рассмотрим неправильные и правильные способы работы с этим сценарием.

Неправильный способ: Изменение структуры данных во время итерации.

Представьте, что есть map элементов, и необходимо удалить некоторые элементы на основе условия во время итерации по ней:

items := map[string]int{"apple": 5, "банан": 3, "апельсин": 4}

for key, value := range items {
    if value < 4 {
        delete(items, key) // Небезопасное удаление во время итерации
    }
}

fmt.Println(items) // Это может работать не так, как ожидалось

В этом примере удаление элементов из map во время итерации небезопасно и может привести к ошибкам во время выполнения или неожиданному поведению, поскольку сама map изменяется во время итерации.

items := map[string]int{"apple": 5, "банан": 3, "апельсин": 4}
var keysToDelete []string

for key, value := range items {
    if value < 4 {
        keysToDelete = append(keysToDelete, key) // Собираем ключи для удаления
    }
}

for _, key := range keysToDelete {
    delete(items, key) // Безопасное удаление после итерации
}

fmt.Println(items) // Это безопасно изменит карту

Понимание и оптимизация

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

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

  • Копирование элементов. Напомню, что каждый раз, когда вы выполняете обход массивов и слайсов, Go создает копию каждого элемента.

  • Итерация по мапам. При итерации по мапам порядок итераций не гарантируется. Эта случайность заложена изначально, и её всегда следует учитывать.

  • Базовая оптимизация: компилятор Go оптимизирует циклы range под капотом. Например, при выполнении итераций по слайсам компилятор не проверяет границы на каждой итерации. Знание о подобных оптимизациях может помочь вам написать более эффективный код. Понимая внутреннюю работу циклов диапазона, можно писать более эффективный код на Go, который будет одновременно производительным и надёжным.

Теперь, зная о некоторых особенностях цикла range, рассмотрим некоторые базовые способы оптимизации. Среди них, конечно, будут советы уровня Капитана Очевидность, но всегда полезно их напомнить.

  • Будьте проще: старайтесь, чтобы в цикле range всё было просто и быстро. Чем меньше действий выполняется на каждой итерации, особенно в больших циклах, тем быстрее они будут выполняться.

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

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

Когда следует использовать традиционные циклы for, а не циклы range

  • Прямое манипулирование индексами: если необходимо сложное обращение с индексами или требуются нелинейные переходы внутри цикла, цикл range не подойдет.

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

  • Разделы, критичные для производительности: в некоторых высокопроизводительных сценариях дополнительные накладные расходы цикла range могут быть нежелательны. В таких случаях традиционный цикл for может быть лучшим выбором.

Изменения в Go 1.22

В Go 1.22 появится несколько новых изменений, в частности в цикле range. Давайте рассмотрим, как вносимые изменения в поведение цикла range изменят ситуацию в способах написания кода на Go. Главная новость заключается в том, что Go 1.22 изменяет правила определения области видимости для переменных в циклах for. Раньше переменные цикла имели область видимости для всего цикла. Теперь каждая итерация получает свою собственную область видимости. Это позволит избежать путаницы и ошибок, особенно если вы имеете дело с горутинами или замыканиями внутри циклов. Таким образом, код вроде этого:

values := []string{"a", "b", "c"}
	for _, val := range values {
		go func() {
			fmt.Println(val) // печатается правильное значение
		}()
	}
	time.Sleep(1 * time.Second) // Ждем завершения работы горутин

Будет работать корректно в версии 1.22 без явного выделения локальной переменной.

Эти нововведения работают не только для циклов range, но и для простого цикла for. Рассмотрим пример с замыканиями.

var funcs []func()
    for i := 0; i < 3; i++ {
        funcs = append(funcs, func() {
	        // Для 1.22 будет выведено 0, 1, 2.
	        // Для предыдущих 3, 3, 3
            fmt.Println(i)
        })
    }
    for _, f := range funcs {
        f()
    }

Как это повлияет на поведение циклов range:

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

  • Более понятная логика кода: это обновление сделает поведение циклов более предсказуемым. Оно устраняет некоторые распространённые недоразумения, делая код более понятным и менее подверженным ошибкам.

Проблемы совместимости:

  • Плавный переход: Разработчики Go 1.22 позаботились о совместимости. Новые правила определения области видимости применяются только к коду в модулях с указанием go 1.22 или более поздней версии. Таким образом, ваш существующий код не начнёт вдруг вести себя по-другому.

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

  • Обратная совместимость: для кода, еще не готового к переходу на Go 1.22, всё будет работать как прежде. Такой подход Go обеспечивает плавный переход без ущерба для существующей кодовой базы.

Эти изменения в Go 1.22 направлены на то, чтобы сделать язык более безопасным, интуитивно понятным и уменьшить количество досадных ошибок, которые могут попасть в ваши циклы. Это шаг вперёд на пути превращения Go в ещё более надёжный и удобный для разработчиков язык.

Адаптация к новым изменениям

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

Перевод существующего кода на Go 1.22

1. Обновление модуля

Начните с обновления файла go.mod, в котором должен быть указан Go 1.22. Это будет означать, что ваш модуль готов к переходу на новые особенности.

     module mymodule

     go 1.22

2. Пересмотреть текущие варианты осуществления циклов

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

3. Тщательно всё перетестировать

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

4. Использование GOEXPERIMENT

В переходный период можно использовать переменную окружения GOEXPERIMENT, чтобы проверить, как код ведёт себя в соответствии с новыми правилами. Это может быть особенно полезно, если вы ещё не готовы полностью перейти на Go 1.22.

 GOEXPERIMENT=loopvar go test

Заключение

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

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

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


  1. ToniMazuretti
    09.01.2024 07:37

    Кто не ловил захват переменной в цикле , тот не писал на go

    База + update. Спасибо!


  1. QtRoS
    09.01.2024 07:37
    +2

    Копирование элементов. Напомню, что каждый раз, когда вы выполняете обход массивов и слайсов, Go создает копию каждого элемента

    Это вполне ожидаемое поведение, я не припомню языков где иначе (даже если значения массива ссылки/указатели, то по сути имеем их копию). А вот про то, что выражение range создаёт копию самого массива/слайса можно было упомянуть. В случае массива это полноценная копия, в случае слайса копия только описывающей слайс структуры. Отсюда всякие нюансы с изменением во время итерации, их полезно знать.


  1. ana_meow
    09.01.2024 07:37
    -2

    База + update. Благодарю


  1. Hamletghost
    09.01.2024 07:37
    +3

    В этом примере удаление элементов из map во время итерации небезопасно и может привести к ошибкам во время выполнения или неожиданному поведению, поскольку сама map изменяется во время итерации.

    Это откуда такая информация? Если почитать практику и спеки, везде написано, что это безопасно, ну или наоборот нигде не написано что так делать нельзя. Конкурирующий доступ запрещен да. Модификация мапы в одном потоке разрешена. Про range по мапе в спеке написано, что удаленные элементы, до которых вы еще не дошли не будут пройдены в цикле (что звучит логично), а добавленные могут а могут и не быть пройдены (т.к. порядок не гарантирован)


    1. kowack
      09.01.2024 07:37
      +2

      Добавлю ссылку на спеку https://go.dev/ref/spec#For_range
      3. The iteration order over maps is not specified
      and is not guaranteed to be the same from one iteration to the next.
      If a map entry that has not yet been reached is removed during iteration,
      the corresponding iteration value will not be produced. If a map entry is
      created during iteration, that entry may be produced during the iteration or
      may be skipped. The choice may vary for each entry created and from one
      iteration to the next.


      1. bullgare
        09.01.2024 07:37
        +1

        Зашёл написать ровно этот же комментарий ровно с этой же ссылкой.
        Спасибо за бдительность.


  1. NeoCode
    09.01.2024 07:37
    -1

    Что делает собственно ключевое слово range? Можно ли его использовать вне цикла? Или это просто такое украшательство чтобы отличить for по индексу от for по диапазону?


    1. Hamletghost
      09.01.2024 07:37
      +1

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

      исходники его тут кстати https://github.com/golang/go/blob/master/src/cmd/compile/internal/walk/range.go


  1. itmind
    09.01.2024 07:37

    Главная новость заключается в том, что Go 1.22 изменяет правила определения области видимости для переменных в циклах for. Раньше переменные цикла имели область видимости для всего цикла. Теперь каждая итерация получает свою собственную область видимости

    Но

    Копирование элементов. Напомню, что каждый раз, когда вы выполняете обход массивов и слайсов, Go создает копию каждого элемента

    т.е. в цикле

    for i,val = range arr {}

    Переменная val это копия (на стеке) в каждой итерации и эта копия со стека удаляется в конце каждой итерации и в начале следующей создается другая копия. Т.е. область видимости копии и так ограничена одной итерацией. Или нет? В чем тогда нововведение 1.22 в части области видимости копии?


    1. Deosis
      09.01.2024 07:37

      Я так понял, что переменная val существует в единственном экземляре, и на каждой итерации в неё копируется значение очередного элемента контейнера.