Управление потоком

2025-02-24 3289 16

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

Операторы управления потоком

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

Блоки

В Zig блоки кода создаются с помощью фигурных скобок {}. Блок представляет собой группу выражений или операторов, заключённых в эти скобки. Все выражения внутри одного блока принадлежат к единой области видимости.

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

Блоки встречаются в разных конструкциях Zig:

Вы можете создавать блоки внутри блоков с несколькими уровнями вложенности. Вы также можете (если хотите) дать метку определенному блоку с помощью двоеточия (:). Просто напишите метка: прежде чем открыть пару фигурных скобок, которые ограничивают ваш блок. Когда вы помечаете блок в Zig, вы можете использовать ключевое слово break, чтобы вернуть значение из этого блока, как если бы это было тело функции. Вы просто пишете ключевое слово break, за которым следует метка блока в формате :label и выражение, которое определяет значение, которое вы хотите вернуть.

Давайте рассмотрим пример определения блока:

const std = @import("std");

pub fn main() void {
    const result = my_block: {
        if (true) break :my_block 42;
        break :my_block 0;
    };

    std.debug.print("Результат: {}\n", .{result});
}

Здесь блок my_block используется как выражение. Оператор break :my_block 42; завершает блок и возвращает значение 42, как если бы это была функция. Таким образом блоки можно использовать, если вам необходимо провести сложную инициализацию переменной, но Вы не хотите “захламлять” код основной функции переменными.

Оператор if

Оператор if/else управляет выполнением кода в зависимости от заданного условия. Этот механизм, называемый условным управлением потоком или управлением выбором, позволяет программе либо выполнить определённый блок команд, либо пропустить его на основе логического выражения. В программировании и компьютерных науках этот процесс также часто называют «ветвлением». Проще говоря, if/else использует результат логической проверки, чтобы определить, следует ли выполнять конкретный фрагмент кода.

Оператор if в Zig имеет следующий синтаксис:

if (условие) {
    // код, который выполняется, если условие истинно
} else if (условие) {
    // код, который выполняется, если второе условие истинно
} else {
    // код, который выполняется, если все условия ложны
}

В Zig оператор if/else записывается с использованием ключевых слов if и else. Сначала идет if, за которым следует логическое выражение в круглых скобках. Затем открываются фигурные скобки, внутри которых находится код, который выполняется, если условие истинно (true). В отличие от других языков, в Zig наличие круглых скобок вокруг выражения является обязательным. Также, так как Zig не приводит неявно типы, то выражение в круглых скобках должно быть явно приведено к типу bool.

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

Давайте рассмотрим пример использования оператора if:

const std = @import("std");

pub fn main() void {
    const number: i32 = -5;

    if (number > 0) {
        std.debug.print("Число {} положительное\n", .{number});
    } else if (number < 0) {
        std.debug.print("Число {} отрицательное\n", .{number});
    } else {
        std.debug.print("Число равно нулю\n", .{});
    }
}

В данном примере мы проверяем значение переменной number и выводим соответствующее сообщение в зависимости от ее знака. Если number больше нуля, то выводится сообщение “Число {number} положительное”. Если number меньше нуля, то выводится сообщение “Число {number} отрицательное”. Если number равен нулю, то выводится сообщение “Число равно нулю”.

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

const should_drive = if (speed > 10) {
  true
} else {
  false
};

В случае, если в блоках вашего оператора if/elseif/else всего одна инструкция, мы можем сделать наш условный оператор еще короче, убрав фигурные скобки:

const should_drive = if (speed > 10) true else false;

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

var maybe_number: ?i32 = 42;
if (maybe_number) |number| {
    std.debug.print("The number is {}\n", .{number});
} else {
    std.debug.print("There is no number\n", .{});
}

Оператор switch

Оператор switch в Zig позволяет проверять значение переменной и выполнять соответствующий код в зависимости от совпадения. Это удобный способ обработки множества вариантов без громоздких if/else if конструкций. Оператор switch в Zig похож на аналогичный оператор в Rust тем, что он требует указания всех возможных вариантов. Вы также можете использовать диапазоны или перечисления в операторе switch.

Базовый синтаксис оператора switch в Zig выглядит следующим образом:

switch (выражение) {
    значение1 => {
        // Действия, если выражение равно значению1
    },
    значение2 => {
        // Действия, если выражение равно значению2
    },
    // ...
    else => {
        // Действия по умолчанию, если ни одно из значений не совпало
    },
}

В данном примере:

В Zig оператор switch должен быть полным, то есть все возможные значения выражения должны быть обработаны. Если вы используете switch с перечислением (enum), компилятор потребует, чтобы все варианты перечисления были явно обработаны, либо чтобы была указана ветка else.

Рассмотрим простой пример, где мы используем switch для обработки различных значений целочисленной переменной:

const std = @import("std");

pub fn main() void {
    const number = 2;

    switch (number) {
        1 => {
            std.debug.print("Число равно 1\n", .{});
        },
        2, 3 => {
            std.debug.print("Число равно 2 или 3\n", .{});
        },
        4 => {
            std.debug.print("Число равно 4\n", .{});
        },
        else => {
            std.debug.print("Число не равно 1, 2, 3 или 4\n", .{});
        },
    }
}

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

const number = 5;

switch (number) {
    1...3 => {
        std.debug.print("Число между 1 и 3\n", .{});
    },
    4...6 => |n| {
        std.debug.print("Число {} между 4 и 6\n", .{n});
    },
    else => {
        std.debug.print("Число вне диапазонов\n", .{});
    },
}

Для того, чтобы при использовании диапазонов получить конкретное число, можно использовать оператор захвата значения |n|. В данном примере, если наше число будет в интервале от 4 до 6, то мы получим конкретное число, которое будет присвоено переменной n. Это позволяет использовать фактическое значение внутри блока кода.

const number = 5;

switch (number) {
    1...3 => |n| {
        std.debug.print("Число {} между 1 и 3\n", .{n});
    },
    4...6 => |n| {
        std.debug.print("Число {} между 4 и 6\n", .{n});
    },
    else => {
        std.debug.print("Число вне диапазонов\n", .{});
    },
}

Кроме перечисленных вариантов, в Zig, в операторе switch мы также можем использовать значение, вычисляемое на этапе компиляции:

const number = 2;

switch (number) {
    blk: {
        const a = 10;
        const b = 8;
        break :blk a - b;
    } => {
        std.debug.print("Число равно 2\n", .{});
    },
    else => {
        std.debug.print("Число вне диапазонов\n", .{});
    },
}

В данном примере мы используем блок для вычисления значения, с которым будет сравниваться наша переменная при выполнении оператора switch. В этом случае значение блока равно 2 (результат выражения 10 - 8), что соответствует нашей переменной.

Оператор switch как и оператор if может возвращать значение, что позволяет использовать его для инициализации переменных:

const level: u8 = 4;
const log_level = switch (level) {
    1 => "Error",
    2 => "Warning",
    3 => "Info",
    4 => "Debug",
    5 => "Trace",
    else => unreachable(),
};
std.debug.print("{s}\n", .{log_level});

Когда мы будем рассматривать перечисления, мы увидим еще варианты как можно использовать оператор switch.

Осталось еще одна вещь, которую стоит упомянуть для оператора switch - разворачивание (inline) оператора switch. Ключевое слово inline в Zig позволяет компилятору выполнять подстановку кода во время компиляции, что особенно полезно при работе с switch. Это даёт возможность писать более оптимизированный и выразительный код, который компилятор разворачивает в конкретные выражения, устраняя необходимость в рантаймовых проверках. Давайте рассмотрим пример использования inline вместе с оператором switch:

const std = @import("std");

fn getTypeSize(comptime T: type) usize {
    return @sizeOf(T);
}

fn typeSize(comptime T: type) usize {
    return switch (T) {
        inline else => getTypeSize(T),
    };
}

pub fn main() void {
    const size = typeSize(u32);
    std.debug.print("Size: {}\n", .{size});
}

В этом примере inline в блоке else приведет к тому, что компилятор развернет все возможные варианты для всех возможных типов T, используемых в программе, и в нашем случае вернет значение 4, что позволяет избежать избыточных проверок во время выполнения.

Оператор while

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

while (условие) {
    // тело цикла
}

Так же как и в предыдущих операторах, наличие круглых скобок в операторе обязательно и значение внутри круглых скобок должно быть типа bool. Для того, чтобы прервать цикл, мы можем использовать два оператора - break и continue. Оператор continue позволяет пропустить оставшуюся часть тела цикла и перейти к следующей итерации, а оператор break позволяет прервать цикл и перейти к следующей инструкции после цикла. Обычно оператор while используется примерно в следующей форме:

var i: u8 = 1;
while (i < 5) {
    try stdout.print("{d} | ", .{i});
    i += 1;
}

В данном примере мы проводим инкремент переменной i на 1 на каждой итерации цикла, а в операторе while мы проверяем наше условие. В таких простых случаях оператор while поддерживает дополнительный синтаксис, позволяющий перенести изменение итератора цикла в конструкцию while:

var i: u8 = 1;
while (i < 5) : (i += 1) {
    try stdout.print("{d} | ", .{i});
}

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

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

const std = @import("std");

pub fn main() void {
    outer: while (true) {
        var i: u8 = 0;
        while (i < 5) : (i += 1) {
            if (i == 3) break :outer; // выход из внешнего цикла
            std.debug.print("{} ", .{i});
        }
        std.debug.print("Эта строка не будет выведена\n", .{});
    }
    std.debug.print("\nВыход из обоих циклов\n", .{});
}

Также как операторы if и switch оператор while поддерживает возвращение значения из цикла, с использованием оператора break:

var i: u8 = 1;
const expected = 3;
const found = while (i < 5) : (i += 1) {
    if (i == expected) break true;
} else false;

std.debug.print("Found: {}\n", .{found});

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

const std = @import("std");

var index: usize = 0;
var array = [_]u8{ 1, 2, 3, 4, 5, 0, 6, 7, 8 };

fn checkElement() ?usize {
    return if (array[index] == 0) null else blk: {
        index += 1;
        break :blk index;
    };
}

pub fn main() !void {
    while (checkElement()) |i| {
        std.debug.print("{d} | ", .{i});
    }
}

В этом примере функция checkElement() возвращает значение типа ?usize, то есть либо индекс элемента, либо null, если найден элемент со значением 0. В цикле while мы используем синтаксис |i| для разворачивания опционального значения. Если функция возвращает числовое значение, оно присваивается переменной i и используется внутри цикла. Если функция возвращает null, цикл завершается.

Этот код выведет индексы элементов массива от 1 до 5, а затем прекратит работу, когда функция вернет null при обнаружении элемента со значением 0 на позиции с индексом 5.

Как и оператор switch, оператор while можно использовать совсместно с ключевым словом inline для встраивания результата цикла в код. Это полезно в случаях, когда итерации цикла зависят от значений известных на этапе компиляции, и позволяет компилятору упростить или полностью убрать цикл, повышая производительность кода. Давайте сначал рассмотрим пример, без использования inline:

fn sum(n: u32) u32 {
    var i: u32 = 0;
    var total: u32 = 0;

    while (i < n) : (i += 1) {
        total += i;
    }

    return total;
}

Этот цикл выполняется во время выполнения программы, так как n — это обычный аргумент функции. Теперь давайте посмотрим как мы можем использовать оператор inline для встраивания результата цикла в код:

fn comptimeSum(comptime n: u32) u32 {
    comptime var i: u32 = 0;
    comptime var total: u32 = 0;

    inline while (i < n) : (i += 1) {
        total += i;
    }

    return total;
}

Здесь ключевое слово inline заставляет компилятор разворачивать итерации цикла while на этапе компиляции. Если, например, n равно 3, код после компиляции будет аналогичен следующему:

fn comptimeSum_3() u32 {
    return 0 + 1 + 2;
}

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

fn initArray(comptime N: usize) [N]u32 {
    var arr: [N]u32 = undefined;
    comptime var i: usize = 0;

    inline while (i < N) : (i += 1) {
        arr[i] = @intCast(i * 2);
    }

    return arr;
}

pub fn main() void {
    const arr = initArray(4);
    @import("std").debug.print("{any}\n", .{arr});
}

В этом коде массив arr заполняется значениями [0, 2, 4, 6] на этапе компиляции, а inline while гарантирует, что сам цикл не будет выполняться во время выполнения.

Оператор for

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

Базовый синтаксис оператора for в Zig выглядит следующим образом:

for (collection) |value| {
    // код, который выполняется для каждого элемента
}

Здесь:

Рассмотрим простой пример использования оператора for для итерации по массиву:

const std = @import("std");

pub fn main() void {
    const numbers = [_]i32{ 1, 2, 3, 4, 5 };

    for (numbers) |number| {
        std.debug.print("{} ", .{number});
    }
    std.debug.print("\n", .{});
}

В первом цикле мы просто выводим каждое число из массива. Если Вам необходимо также обрабатывать индекс элемента массива Вы можете использовать открытый диапазон:

const std = @import("std");

pub fn main() void {
    const numbers = [_]i32{ 1, 2, 3, 4, 5 };

    // Используем открытый диапазон для получения индекса элемента массива
    for (numbers, 0..) |number, i| {
        std.debug.print("Элемент [{}] = {}\n", .{ i, number });
    }
}

В этом примере мы использовали оператор определения диапазона с открытой правой границей .. для итерации по массиву numbers. Это позволяет нам получить индекс каждого элемента массива на каждой итерации цикла. Если вам нужен только индекс, но не значение элемента, можно использовать символ подчеркивания _ для игнорирования значения:

const std = @import("std");

pub fn main() void {
    const numbers = [_]i32{ 1, 2, 3, 4, 5 };

    // С использованием только индекса
    for (numbers, 0..) |_, i| {
        std.debug.print("Элемент {}\n", .{i});
    }
}

Также в Zig есть возможность итерирования по нескольким коллекциям одновременно с помощью многоколлекционного for:

const names = [_][]const u8{ "Алиса", "Боб", "Чарли" };
const ages = [_]u8{ 25, 30, 35 };

for (names, ages) |name, age| {
    std.debug.print("{s} - {} лет\n", .{name, age});
}

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

Как и другие операторы управления потоком в Zig, for поддерживает операторы break и continue для раннего выхода из цикла или перехода к следующей итерации:

const numbers = [_]i32{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

for (numbers) |number| {
    if (number % 2 == 0) continue; // Пропускаем четные числа
    if (number > 7) break; // Выходим из цикла, если число больше 7
    std.debug.print("{} ", .{number});
}
// Выведет: 1 3 5 7

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

outer: for (rows) |row| {
    for (row) |cell| {
        if (cell == target) {
            std.debug.print("Найдено!\n", .{});
            break :outer; // Выходим из внешнего цикла
        }
    }
}

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

Если вы хотите изменять элементы в цикле for, нужно использовать конструкцию |*item| вместо обычной |item|. Символ * указывает, что переменная item будет указателем на элемент, а не копией его значения.

Рассмотрим пример:

const std = @import("std");

pub fn main() void {
    // Обратите внимание, что массив должен быть объявлен как var, а не const
    var numbers = [_]i32{ 1, 2, 3, 4, 5 };

    // Вывод оригинальных значений
    std.debug.print("Исходный массив: ", .{});
    for (numbers) |num| {
        std.debug.print("{} ", .{num});
    }
    std.debug.print("\n", .{});

    // Изменение значений: умножаем каждый элемент на 2
    for (&numbers) |*num| {
        num.* *= 2;  // Используем .* для доступа к значению по указателю
    }

    // Вывод измененных значений
    std.debug.print("Измененный массив: ", .{});
    for (numbers) |num| {
        std.debug.print("{} ", .{num});
    }
    std.debug.print("\n", .{});
}

Этот код выведет:

Исходный массив: 1 2 3 4 5
Измененный массив: 2 4 6 8 10

Обратите внимание на несколько важных моментов:

  1. Массив numbers объявлен с использованием var, а не const, так как мы планируем модифицировать его содержимое.
  2. В цикле модификации используется синтаксис |*num|, что создает переменную num как указатель на текущий элемент. Кроме этого в цикл for мы передаем не массив, а указатель на массив numbers.
  3. Для доступа к значению, на которое указывает указатель, используется оператор разыменования .*.

И наконец, как и другие конструкции управления потоком в Zig, for может возвращать значение с помощью оператора break:

const std = @import("std");

pub fn main() void {
    const target = 5;
    const numbers = [_]i32{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

    const found_index = for (numbers, 0..) |number, i| {
        if (number == target) break i;
    } else unreachable; // Выполняется, если break не был вызван

    std.debug.print("Found index: {d}\n", .{found_index});
}

Этот код ищет индекс элемента target в массиве numbers. Если элемент найден, цикл возвращает его индекс с помощью break i. Блок else выполняется только если цикл завершился естественным путем, то есть если элемент не был найден.

Заключение

В этой главе мы рассмотрели основные конструкции управления потоком в языке Zig: блоки кода, условные операторы if и switch, а также циклы while и for. Мы увидели, что эти конструкции в Zig имеют некоторые особенности и расширенные возможности по сравнению с другими языками программирования:

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

#conditional operators #zig #zigbook

0%