Перейти к основному содержимому

5.10. Операторы и циклы

Разработчику Архитектору

Операторы и циклы

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

Рассмотрим операторы и управляющие конструкции в их естественном порядке появления в повседневной практике: сначала — операторы, затем — условные выражения, и наконец — циклы.


Операторы в Go

В языке Go операторы делятся на группы по выполняемой функции:
арифметические, сравнения, логические, побитовые, присваивания, адресные, а также специальные операторы, такие как :=, ..., *, &. Ниже рассматривается каждая группа с учётом семантических особенностей Go.

Арифметические операторы

Арифметические операторы применяются к числовым типам — целочисленным (int, int8int64, uint, uint8uint64, uintptr) и вещественным (float32, float64). Поддержка комплексных чисел (complex64, complex128) также включена, но с рядом ограничений: например, деление и умножение определены, а операции сравнения — запрещены.

Доступны следующие операторы:

  • + — сложение;
  • - — вычитание или унарный минус;
  • * — умножение;
  • / — деление;
  • % — остаток от деления (только для целочисленных типов);
  • ++ и -- — постфиксные инкремент и декремент.

Важно отметить, что в Go инкремент и декремент реализованы только как постфиксные операторы и только в виде отдельного выражения. Например, запись i++ допустима, а ++i, j = i++ или a[i++] — недопустимы. Это сделано сознательно, чтобы исключить неоднозначности, связанные с порядком вычисления и побочными эффектами. Инкремент и декремент не возвращают значение и не могут быть частью других выражений.

Оператор деления / ведёт себя по-разному в зависимости от типа операндов. При делении целых чисел результат округляется к нулю (так называемое усечение), например: 7 / 3 == 2, -7 / 3 == -2. При делении вещественных чисел результат точный в пределах разрядности типа.

Оператор % определяется так, что для любых целых a и b (где b != 0) выполняется тождество:
a == b * (a / b) + a % b.
Это гарантирует, что знак остатка совпадает со знаком делимого. Например:
7 % 3 == 1, -7 % 3 == -1, 7 % -3 == 1, -7 % -3 == -1.

Операторы сравнения

Операторы сравнения возвращают булево значение true или false. Поддерживаются следующие операторы:

  • == — равенство;
  • != — неравенство;
  • <, <=, >, >= — строгие и нестрогие отношения порядка.

Сравниваемые операнды должны быть сравнимыми. Сравнимость — это свойство типа, определяемое спецификацией языка. В частности:

  • Все базовые скалярные типы (числа, строки, булевы значения) — сравнимы;
  • Указатели сравнимы, если они указывают на объекты одного типа;
  • Интерфейсы сравнимы, если динамические типы и значения их содержимого сравнимы;
  • Структуры сравнимы, если все их поля сравнимы;
  • Срезы, карты и функции — несравнимы, за исключением сравнения с nil.

Сравнение nil определено только для указателей, срезов, карт, функций, интерфейсов и каналов. Попытка сравнить, например, структуру с nil вызывает ошибку компиляции.

Особое внимание заслуживает сравнение строк: оно выполняется лексикографически на основе значений байтов в UTF-8-представлении. Это означает, что сравнение не учитывает локаль и не является лингвистически корректным — только байтовым.

Логические операторы

Логические операторы применяются к булевым операндам и возвращают булево значение. Язык Go поддерживает:

  • && — логическое И (сокращённое вычисление: если левый операнд false, правый не вычисляется);
  • || — логическое ИЛИ (сокращённое вычисление: если левый операнд true, правый не вычисляется);
  • ! — логическое НЕ (унарный оператор).

Go не допускает неявного приведения чисел или других типов к bool. Это исключает классические ошибки вроде if (x = 5) вместо if (x == 5), поскольку присваивание не возвращает значение и не может быть использовано в условии.

Сокращённое вычисление гарантируется спецификацией. Это позволяет безопасно писать цепочки вида:
if ptr != nil && ptr.IsValid() — здесь ptr.IsValid() не будет вызвано, если ptr равен nil.

Побитовые операторы

Побитовые операторы работают с целочисленными типами и интерпретируют их как последовательности битов. Поддерживаются:

  • & — побитовое И;
  • | — побитовое ИЛИ;
  • ^ — побитовое исключающее ИЛИ;
  • &^ — побитовое И-НЕ (операция «очистки битов»);
  • << — сдвиг влево;
  • >> — сдвиг вправо.

Оператор &^ уникален: x &^ y эквивалентен x & (^y), но записан компактнее. Он часто используется для сброса определённых битов: например, flags &^ FlagDebug удаляет флаг FlagDebug, не затрагивая остальные.

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

Операторы присваивания

Go поддерживает обычные операторы присваивания и составные формы:

  • = — простое присваивание;
  • +=, -=, *=, /=, %=;
  • &=, |=, ^=, &^=;
  • <<=, >>=.

Все составные операторы эквивалентны соответствующей бинарной операции с последующим присваиванием, например:
x += yx = x + y.

Важной особенностью Go является отсутствие неявного приведения типов при присваивании. Если тип правого выражения не совпадает с типом левой переменной, требуется явное приведение, даже если типы совместимы в математическом смысле. Например:
var i int = 5; var f float64 = i — ошибка компиляции.
Корректно: var f float64 = float64(i).

Операторы работы с адресами и указателями

Операторы * и & выполняют противоположные функции:

  • &x — получение адреса переменной x; результат имеет тип *T, где T — тип x;
  • *p — разыменование указателя p; применяется только к значениям указательного типа.

Указатели в Go не поддерживают арифметику (в отличие от C), что повышает безопасность и предсказуемость. Единственное исключение — пакет unsafe, который предоставляет ограниченные и небезопасные возможности, но его использование считается крайней мерой.

Оператор new(T) выделяет память под значение типа T, инициализирует его нулевым значением и возвращает указатель *T. Эквивалентен var t T; p := &t, но более лаконичен.

Оператор := — короткое объявление

Оператор := сочетает в себе объявление и инициализацию переменной. Его можно использовать только внутри функций. Левая часть может содержать одно или несколько имён, правая — выражения. Типы выводятся из инициализирующих выражений.

Пример:
x, y := 10, "hello"
эквивалентен
var x int = 10; var y string = "hello".

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

Важно: := не является синтаксическим сахаром для var; это отдельная языковая конструкция с собственными правилами области видимости и переиспользования.

Оператор ... — распаковка среза

Оператор ... используется при вызове функции с переменным числом аргументов (variadic function). Он «распаковывает» элементы среза в отдельные аргументы.

Пример:

func sum(nums ...int) int {}
values := []int{1, 2, 3}
total := sum(values...) // передаётся три отдельных аргумента

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


Условные операторы

Оператор if

Синтаксис if в Go состоит из трёх частей, из которых обязательна только первая:

if инициализация; условие {
// тело, выполняемое при истинности условия
} else if инициализация; условие {
// альтернативное тело
} else {
// ветка по умолчанию
}
  • Инициализация — необязательное простое выражение, обычно объявление переменной с :=. Оно выполняется один раз перед проверкой условия и создаёт переменные, область видимости которых ограничена всем if-блоком (включая else-ветки). Это позволяет локализовать вспомогательные переменные, избегая загрязнения внешней области видимости.

  • Условие — выражение, результат которого должен иметь тип bool. В Go нет неявного преобразования в логический тип, поэтому конструкции вроде if x (где x — число или указатель) недопустимы. Требуется явное сравнение: if x != 0, if ptr != nil.

  • Фигурные скобки — обязательны, даже если тело состоит из одной инструкции. Это устраняет классическую ошибку «висячего else» и делает структуру кода однозначной.

  • Скобки вокруг условия — отсутствуют. Это требование синтаксиса: условие не окружается скобками. Причина — избежать визуального шума и усилить читаемость. Конструкция if (x > 0) содержит синтаксическую ошибку.

Пример идиоматичного использования:

if err := os.Open("file.txt"); err != nil {
log.Fatal(err)
} else {
defer f.Close()
// работа с файлом
}

Здесь переменная errf, возвращаемая os.Open) видна только внутри if-блока и его else-ветки. После завершения блока они уничтожаются. Это способствует локализации обработки ошибок и предотвращает случайное использование неинициализированных или уже закрытых ресурсов.

Важно: инициализация может содержать любое простое выражение — присваивание, короткое объявление, вызов функции. Но не может включать составные операторы (if, for, блоки). Цель — сохранить компактность и читаемость заголовка условия.

Оператор switch

switch в Go — мощная и безопасная альтернатива цепочкам if-else. Он поддерживает несколько форм и обладает рядом особенностей, повышающих надёжность.

Базовый синтаксис

switch инициализация; выражение {
case значение1, значение2:
// выполняется, если выражение == значение1 или значение2
case значение3:
// ...
default:
// выполняется, если ни один case не совпал
}
  • Как и в if, поддерживается необязательная инициализация, видимая во всём switch-блоке.
  • Выражение в заголовке вычисляется один раз перед сопоставлением с case-ами.
  • В каждом case перечисляются точные значения (или списки значений), с которыми сравнивается результат выражения. Сравнение выполняется оператором ==, поэтому типы должны быть совместимы и сравнимы.
  • Автоматического проваливания (fallthrough) нет. После выполнения тела case управление переходит за пределы switch; явное fallthrough требуется только при необходимости продолжить выполнение в следующем case.
  • Ветка default может находиться в любом месте, но выполняется только если ни один case не подошёл.

switch без выражения (tagless switch)

Особенно ценная форма — switch без выражения в заголовке:

switch {
case x < 0:
// ...
case x == 0:
// ...
case x > 0 && x < 10:
// ...
default:
// ...
}

Здесь каждый case содержит булево выражение. Конструкция эквивалентна цепочке if-else if, но читается линейнее, избегает дублирования условий и визуально группирует взаимоисключающие альтернативы. Компилятор часто оптимизирует такой switch эффективнее, чем длинную цепочку if.

Эта форма рекомендуется вместо if-else, когда число условий превышает два–три, особенно если условия сложные или требуют проверки нескольких взаимоисключающих состояний.

Сравнение с интерфейсами и типами (type switch)

Отдельная форма — switch по типу интерфейсного значения:

switch v := x.(type) {
case int:
fmt.Println("целое:", v)
case string:
fmt.Println("строка длиной", len(v))
default:
fmt.Println("неизвестный тип")
}

Здесь v получает значение x, приведённое к конкретному типу внутри соответствующего case. Это безопасная и эффективная замена последовательным if x, ok := x.(T); ok { … }.

Важно: в default-ветке v имеет тип исходного интерфейса, а не interface{} — это позволяет работать с динамическим значением, даже если тип неизвестен.

Отсутствие дублирования значений в case

Go требует, чтобы значения в case не повторялись. Попытка написать case 1, 2, 1: вызовет ошибку компиляции. Это исключает неочевидные логические ошибки.


Циклы

Go имеет единственный циклический оператор — for. Все виды циклов реализуются через его синтаксические варианты. Это упрощает язык, устраняет дублирование и делает структуру циклов предсказуемой.

Синтаксис for состоит из трёх компонентов, из которых обязательны только скобки и фигурные скобки:

for инициализация; условие; поститерационное_выражение {
// тело цикла
}
  • Инициализация — выполняется один раз перед началом цикла. Обычно — объявление счётчика (i := 0).
  • Условие — проверяется перед каждой итерацией. Если true — тело выполняется; если false — цикл завершается.
  • Поститерационное выражение — выполняется после каждой итерации, перед повторной проверкой условия. Чаще всего — i++.

Пример классического цикла:

for i := 0; i < 10; i++ {
fmt.Println(i)
}

Бесконечный цикл

Если опустить все три компонента, получится бесконечный цикл:

for {
// выполняется вечно, пока не встретится break, return или panic
}

Это идиоматичный способ написать «вечный» цикл. Явное for true { … } считается избыточным и не рекомендуется.

Цикл с предусловием (аналог while)

Опустив инициализацию и поститерационное выражение, получаем цикл с предусловием:

for условие {
// тело
}

Эквивалентно while (условие) в других языках. Например:

for scanner.Scan() {
line := scanner.Text()
process(line)
}

Итерация по коллекциям: for range

Оператор range — встроенная форма for, предназначенная для перебора элементов итерируемых структур: массивов, срезов, строк, карт, каналов.

Общий вид:

for индекс, значение := range коллекция {
// тело
}
  • Для срезов и массивов:

    • индекс — порядковый номер элемента (int);
    • значение — копия элемента (T, где T — тип элемента);
    • можно опустить индекс: for _, v := range slice;
    • можно опустить значение: for i := range slice (часто используется для создания нового среза той же длины).
  • Для строк:

    • индекс — позиция начала символа в байтах (не номер символа!);
    • значениеrune (Unicode-кодовая точка);
    • range корректно обходит UTF-8-последовательность, пропуская байты продолжения.

Пример:

s := "Привет"
for i, r := range s {
fmt.Printf("%d: %c (%U)\n", i, r, r)
}
// Вывод:
// 0: П (U+041F)
// 2: р (U+0440)
// 4: и (U+0438)
// 6: в (U+0432)
// 8: е (U+0435)
// 10: т (U+0442)

Обратите внимание: индексы идут с шагом 2 — потому что кириллические символы в UTF-8 занимают по 2 байта.

  • Для карт:

    • индекс — ключ (K);
    • значение — копия значения (V);
    • порядок итерации не определён и варьируется от запуска к запуску (это мера защиты от неявной зависимости от порядка).
  • Для каналов:

    • range читает из канала, пока он не закрыт;
    • возвращает только значение (v), индекс не предусмотрен;
    • блокируется при пустом незакрытом канале.

Важные нюансы range:

  • значение — это копия элемента. Изменение v внутри цикла не влияет на исходную коллекцию. Чтобы изменить элемент среза, нужно использовать индекс: slice[i] = newValue.
  • При итерации по карте, модификация карты внутри цикла запрещена и вызывает панику — это защита от неопределённого поведения.

Управление выполнением цикла: break и continue

  • break — немедленно завершает выполнение ближайшего for, switch или select.
  • continue — завершает текущую итерацию цикла и переходит к проверке условия (или к следующей итерации range).

Оба оператора могут иметь метку (label), если требуется управление вложенным циклом:

outer:
for i := 0; i < 3; i++ {
for j := 0; j < 3; j++ {
if i == j {
break outer // выйти из внешнего цикла
}
fmt.Println(i, j)
}
}

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

Отсутствие do-while и foreach

Go сознательно не включает цикл do-while, так как его можно легко выразить через for:

for {
// тело
if !условие { break }
}

Аналогично, отдельного foreach нет, потому что for range покрывает все сценарии итерации по коллекциям — более обобщённо и безопасно.


Идиомы и лучшие практики

Предпочтение switch перед цепочками if-else

Когда условное ветвление содержит более двух взаимоисключающих вариантов, и особенно если условия — это проверки на равенство констант, switch предпочтительнее if-else. Причины:

  • Читаемость: структура switch визуально группирует альтернативы, делая логику выбора прозрачной.
  • Безопасность: отсутствие автоматического fallthrough исключает случайные проваливания, характерные для C-подобных языков.
  • Поддерживаемость: добавление нового case не требует вставки else if в середину длинной цепочки и не нарушает баланс скобок.

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

Избегание избыточных скобок

Go требует фигурных скобок вокруг тел if, for, switch, но запрещает круглые скобки вокруг условий и управляющих выражений. Это часть грамматики. Конструкции вида:

if (x > 0) {}       // ошибка
for (i := 0; i < n; i++) {} // ошибка
switch (value) {} // ошибка

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

Локализация переменных через инициализацию в if и switch

Идиома объявления переменной непосредственно в заголовке if или switch — один из краеугольных камней идиоматичного Go-кода. Она позволяет:

  • Ограничивать область видимости переменной строго тем блоком, где она необходима.
  • Гарантировать, что переменная инициализирована перед использованием (в отличие от var x T; if … { x = … }, где x может остаться в нулевом состоянии).
  • Компактно связывать проверку и использование результата (особенно при обработке ошибок).

Пример:

if file, err := os.Open(path); err != nil {
return fmt.Errorf("не удалось открыть %s: %w", path, err)
} else {
defer file.Close()
return process(file)
}

Здесь file и err недоступны вне этого блока — это исключает случайное использование file после его закрытия или в ветке ошибки.

Эффективное использование range

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

  • for _, v := range slice — когда индекс не нужен.
  • for i := range slice — когда нужно только количество итераций (например, для инициализации нового среза: dest := make([]T, len(src)); for i := range src { dest[i] = transform(src[i]) }).

Важно помнить: v в for _, v := range slice — это копия элемента. Прямое присваивание v = newValue не изменит исходный срез. Для модификации требуется доступ по индексу:

for i := range slice {
slice[i] = transform(slice[i])
}

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

Избегание изменения коллекции внутри range

Модификация среза (например, добавление через append) во время итерации по нему допустима, но требует осторожности: длина может измениться, и цикл for i := 0; i < len(slice); i++ приведёт к пропуску или повторной обработке элементов. Цикл for range фиксирует длину среза на момент входа в цикл, поэтому новые элементы не будут пройдены — это часто желаемое поведение.

Напротив, модификация карты (вставка или удаление) внутри range по той же карте вызывает панику времени выполнения. Это намеренное ограничение, защищающее от неопределённого поведения.


Типичные ошибки и их причины

Попытка использовать ++ и -- внутри выражений

Как уже отмечалось, i++ — это самостоятельное выражение, не возвращающее значение. Попытки написать a[i++] = x или j = i++ приводят к ошибке компиляции. Это мера защиты: такие конструкции часто становятся источником неочевидных побочных эффектов, особенно при параллельном выполнении или сложных зависимостях.

Обход: выделить инкремент в отдельную строку:

a[i] = x
i++

Сравнение несравнимых типов с nil

Ошибка:

type Config struct { Host string }
var c Config
if c == nil {} // ошибка компиляции

Структуры, срезы, карты и функции без явного объявления как указателей или интерфейсов — не являются указателями и не могут сравниваться с nil. Только *Config, []T, map[K]V, func(…), interface{}, chan T могут быть nil.

Правильно:

var c *Config  // указатель
if c == nil {}

или

var m map[string]int
if m == nil {} // допустимо, так как m имеет тип map

Неправильное понимание nil в интерфейсах

Интерфейсное значение состоит из типа и значения. Оно считается nil только если оба компонента nil. Если переменная интерфейсного типа содержит ненулевой указатель на nil (например, *Config(nil)), сравнение i == nil вернёт false, хотя i «кажется пустым». Это частый источник ошибок при возврате ошибок из функций.

Иллюстрация:

func returnsNilPtr() error {
var err *os.PathError = nil
return err // возвращает интерфейс error с типом *os.PathError и значением nil
}

e := returnsNilPtr()
fmt.Println(e == nil) // false!

Решение — никогда не возвращать nil, завёрнутый в именованный тип указателя, из функции, возвращающей интерфейс. Используйте литерал nil напрямую.

Переполнение при арифметике

Go не проверяет переполнение целочисленных операций. Результат «оборачивается» по модулю разрядности типа. Например, при int8 значение 127 + 1 даёт -128.

Это соответствует поведению большинства современных процессоров и обеспечивает максимальную производительность. Проверка переполнения возлагается на программиста. Для критичных к безопасности вычислений рекомендуется использовать сторонние библиотеки (например, math/bits для проверки флагов, или пакеты вроде github.com/ericlagergren/decimal для точной арифметики).

Неправильная обработка range по строкам

Начинающие часто полагают, что for i, _ := range s перебирает символы строки по порядку, и что i — это номер символа. На самом деле i — это байтовый смещение начала символа в UTF-8. Для ASCII-строк это совпадает, но для Unicode — нет.

Пример ошибки:

s := "café"
for i := 0; i < len(s); i++ {
fmt.Printf("%c ", s[i]) // неправильно: s[i] — байт, не rune
}
// Вывод: c a f

Правильно использовать range или явно конвертировать в []rune:

runes := []rune(s)
for i := 0; i < len(runes); i++ {
fmt.Printf("%c ", runes[i])
}
// или
for _, r := range s {
fmt.Printf("%c ", r)
}

Сравнение с другими языками: что убрано и зачем

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

  • Нет тернарного оператора ?: — потому что он поощряет написание сложных однострочных выражений в ущерб читаемости. if-else с инициализацией достигает той же цели явно и безопасно.
  • Нет неявного приведения к bool — исключает ошибки вроде if (x = 5).
  • Нет арифметики указателей — повышает безопасность и упрощает сборку мусора.
  • Один цикл for вместо while, do-while, foreach — снижает число понятий, упрощает обучение и инструменты анализа.
  • Обязательные фигурные скобки — устраняют классические баги стиля («висячий else», забытые блоки при рефакторинге).

Эти решения отражают философию Go: «меньше — лучше, если это делает поведение предсказуемым».