Указатели и аллокаторы

2025-02-23 5237 25

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

Что такое указатель

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

Объявление указателей в Zig

В Zig указатели объявляются с помощью символа *. Например, если у вас есть переменная типа i32 (32-битное целое число), то указатель на эту переменную будет иметь тип *i32. В общем виде указатель на переменную типа T будет иметь тип *T.

const std = @import("std");

pub fn main() void {
    var x: i32 = 42;
    const ptr: *i32 = &x;

    std.debug.print("Value: {}, Pointer: {*}\n", .{ x, ptr });
}

Выведет:

Value: 42, Pointer: 0x7ffeeb5f9f9c

В этом примере ptr — это указатель на константное значение типа i32, которое хранится в переменной x. Оператор & используется для получения адреса переменной.

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

const std = @import("std");

pub fn main() void {
    const value: i32 = 42;
    var ptr: *const i32 = &value;

    ptr.* = 50; // Этот код не будет компилироваться, так как указатель на константу не может быть изменен

    std.debug.print("Const value: {}\n", .{ptr.*});
}

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

const c1: u8 = 5;
const c2: u8 = 6;
var pointer = &c1;
try stdout.print("{d}\n", .{pointer.*});
pointer = &c2;
try stdout.print("{d}\n", .{pointer.*});

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

const std = @import("std");

// Функция, которая пытается изменить значение переменной
fn modifyValue(x: i32) void {
    x = 42;
}

pub fn main() void {
    var value: i32 = 10;
    std.debug.print("Before: {}\n", .{value});

    modifyValue(value);

    value = value + 1;

    std.debug.print("After: {}\n", .{value});
}

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

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

const std = @import("std");

// Функция, которая изменяет значение переменной
fn modifyValue(ptr: *i32) void {
    ptr.* = 42;
}

pub fn main() void {
    var value: i32 = 10;
    std.debug.print("Before: {}\n", .{value}); // Вывод: 10

    modifyValue(&value);

    std.debug.print("After: {}\n", .{value}); // Вывод: 42
}

Если мы запустим данную функцию, мы увидим, что значение переменной value изменилось. Это потому, что функция modifyValue принимает указатель на переменную, а не ее значение. Изменение значения внутри функции влияет на исходную переменную.

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

Опциональные указатели

В Zig, как и в других современных языках программирования, существует концепция опциональных типов (optional types), которая позволяет выразить возможность отсутствия значения. Это особенно полезно при работе с указателями, где может возникнуть необходимость указать, что указатель может быть нулевым (null). По умолчанию Zig гарантирует разработчику, что указатель не может быть нулевым. Если Вы попробуете обнулить указатель, то получите ошибку компиляции, что гораздо безопаснее поведения в языке C. Но тем не менее иногда бывает полезно работать с указателем, который может содержать значение null. Для этого в Zig используется синтаксис ?T, где T — это тип данных. В случае с указателями, это выражается как ?*T, что означает “опциональный указатель на тип T”.

Опциональный указатель — это указатель, который может либо содержать адрес памяти, либо быть нулевым (null). В Zig это выражается с помощью синтаксиса ?*T. Например, ?*i32 — это опциональный указатель на тип i32.

var x: i32 = 42;
var ptr: ?*i32 = &x; // ptr содержит адрес x
var nullPtr: ?*i32 = null; // ptr равен null

В этом примере:

Для чего могут использоваться опциональные указатели:

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

const std = @import("std");

fn printValue(ptr: ?*i32) void {
    if (ptr) |nonNullPtr| {
        std.debug.print("Value: {}\n", .{nonNullPtr.*});
    } else {
        std.debug.print("Pointer is null\n", .{});
    }
}

pub fn main() void {
    var x: i32 = 42;
    var ptr: ?*i32 = &x;

    printValue(ptr); // Вывод: Value: 42

    ptr = null;
    printValue(ptr); // Вывод: Pointer is null
}

В данном примере если наш указатель не равен null, то сработает первый блок оператора if, если же указатель будет нулевой, то сработает блок условия else.

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

Указатель на указатель

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

В Zig это выражается с помощью двойного символа *. Например, если у вас есть указатель на переменную типа i32, то указатель на этот указатель будет иметь тип **i32.

var x: i32 = 42;
var ptr: *i32 = &x;
var ptrToPtr: **i32 = &ptr;

В этом примере:

Для чего нужны указатели на указатель:

Рассмотрим пример, где мы изменяем указатель внутри функции, используя указатель на указатель:

const std = @import("std");

// Функция, которая изменяет указатель через указатель на указатель
fn changePointer(ptrToPtr: **i32, newValue: i32) void {
    var newVar: i32 = newValue;
    ptrToPtr.* = &newVar; // Изменяем указатель, на который указывает ptrToPtr
}

pub fn main() void {
    var x: i32 = 42;
    var ptr: *i32 = &x;
    std.debug.print("Before: {}\n", .{ptr.*}); // Вывод: Before: 42

    changePointer(&ptr, 100); // Передаем указатель на указатель

    std.debug.print("After: {}\n", .{ptr.*}); // Вывод: After: 100
}

В этом примере:

Указатели на коллекцию элементов

В Zig существует два типа указателей - одиночный указатель и указатель на коллекцию элементов. Так если у вас тип переменной указателя это *u32, то это указатель на одиночную переменную с типом u32. Если ваш указатель это указатель на коллекцию элементов, то его тип будет выглядеть как [*]T, то есть наш указатель * располагается в квадратных скобках. Так как же нам получить указатель на коллекцию элементов. Для этого мы можем использовать массивы, которые мы изучали в главе 4:

const array = [_]i32{ 1, 2, 3, 4 };
var ptr: [*]const i32 = &array;

Не важно, одиночный у вас указатель или указатель на коллекцию элементов, при использовании оператора получения указателя & вы всегда получаете одиночный указатель - либо указатель на одиночный элемент, либо указатель на первый элемент коллекции. Так как же нам быть если мы работаем с массивом и получили указатель только на первый элемент коллекции? Как нам изменить второй элемент в массиве? Для этого в zig как и в некоторых других языках необходимо использовать арифметику на указателях.

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

Указатели в Zig поддерживают следующие арифметические операции:

Когда вы добавляете целое число к указателю, указатель перемещается вперед на указанное количество элементов. Размер шага зависит от типа данных, на который указывает указатель. Например, для указателя на i32 каждый шаг будет равен 4 байтам (размер i32).

const std = @import("std");

pub fn main() void {
    var array = [5]i32{ 10, 20, 30, 40, 50 };
    const ptr: [*]i32 = &array; // Указатель на первый элемент

    const newPtr = ptr + 2; // Перемещаем указатель на 2 элемента вперед

    std.debug.print("Value: {}\n", .{newPtr[0]}); // Вывод: Value: 30
}

В этом примере:

Вычитание целого числа из указателя перемещает указатель назад на указанное количество элементов. Вычитание двух указателей возвращает количество элементов между ними. Результат имеет тип isize (знаковое целое число).

const std = @import("std");

pub fn main() void {
    var array = [5]i32{ 10, 20, 30, 40, 50 };
    const ptr1: *i32 = &array[0]; // Указатель на первый элемент
    const ptr2: *i32 = &array[3]; // Указатель на четвертый элемент

    const distance = ptr2 - ptr1; // Количество элементов между указателями

    std.debug.print("Distance: {}\n", .{distance}); // Вывод: Distance: 3
}

В этом примере:

Инкремент (++) и декремент (--) позволяют увеличить или уменьшить указатель на один элемент.

const std = @import("std");

pub fn main() void {
    var array = [5]i32{ 10, 20, 30, 40, 50 };
    var ptr: [*]i32 = &array; // Указатель на первый элемент

    ptr += 2; // Перемещаем указатель на третий элемент
    std.debug.print("Value after increment: {}\n", .{ptr[0]}); // Вывод: Value after increment: 30

    ptr -= 1; // Перемещаем указатель на второй элемент
    std.debug.print("Value after decrement: {}\n", .{ptr[0]}); // Вывод: Value after decrement: 20
}

В этом примере:

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

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

Почему же Zig не проверяет результат нашей арифметики. Арифметика указателей в Zig (как и в C) работает на очень низком уровне, близком к аппаратному. Она предполагает, что программист знает, что делает, и несет ответственность за корректное использование указателей. Проверка границ добавляет накладные расходы на выполнение, что противоречит философии Zig как языка, ориентированного на производительность и контроль над ресурсами.

Рассмотрим пример, где арифметика указателей приводит к выходу за границы массива:

const std = @import("std");

pub fn main() void {
    var array = [3]i32{ 10, 20, 30 };
    const ptr: [*]i32 = &array; // Указатель на первый элемент

    // Опасная операция: выход за границы массива
    const dangerousPtr = ptr + 5; // Указывает на область памяти за пределами массива
    const value = dangerousPtr[0]; // Чтение из непредназначенной области памяти

    std.debug.print("Value: {}\n", .{value}); // Неопределенное поведение!
}

В этом примере:

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

const std = @import("std");

pub fn main() void {
    var array = [3]i32{ 10, 20, 30 };
    const ptr: [*]i32 = &array;

    const index = 5;
    if (index < array.len) {
        const value = (ptr + index)[0];
        std.debug.print("Value: {}\n", .{value});
    } else {
        std.debug.print("Index out of bounds!\n", .{});
    }
}

В этом примере:

Аллокаторы

Как уже упоминалось во введении, Zig следует принципу полного контроля над выделением памяти. Это означает, что никакие встроенные механизмы не выделяют память в куче автоматически — всё происходит только явно. В отличие, например, от C, где функции могут использовать malloc и free скрытно, и чтобы понять, выделяет ли встроенная функция дополнительную память, приходится заглядывать в ее исходный код, в Zig такой проблемы нет, здесь любое выделение памяти всегда явно указано.

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

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

const std = @import("std");

pub fn main() void {
    // Создаем аллокатор
    var gpa = std.heap.DebugAllocator(.{}).init;
    defer _ = gpa.deinit();

    const allocator = gpa.allocator();

    // Форматируем строку с использованием аллокатора
    const message = std.fmt.allocPrint(allocator, "Hello, {s}!", .{"Zig"}) catch unreachable;
    defer allocator.free(message); // Освобождаем память

    // Выводим строку
    std.debug.print("{s}\n", .{message}); // Вывод: Hello, Zig!
}

Разберем этот пример:

  1. Аллокатор:
  1. std.fmt.allocPrint:
  1. defer allocator.free(message):

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

Итак, аллокатор в Zig это объект, предоставляющий методы для выделения, освобождения и изменения размера памяти. В Zig аллокаторы представлены интерфейсом std.mem.Allocator, который определяет методы для работы с памятью, такие как alloc, create, destroy, free, realloc и другие. Стандартная схема работы с аллокатором в Zig выглядит следующим образом:

Отличие методов alloc и create состоит в том, что alloc возвращает указатель на выделенную память, а create возвращает указатель на созданный объект, который уже содержит выделенную память. Аналогично метод free освобождает память, которая была выделена через alloc, а метод destroy освобождает память, которая была выделена через create. Обычно create используют для создания единичного обьекта, а alloc для создания массивов или других структур данных.

Но прежде чем мы перейдем к рассмотрению доступных аллокаторов, давайте расмотрим использование конструкций defer и errdefer.

defer и errdefer

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

Общая тенденция всех новых низкоуровневых языков программирования это безопасность. По мере того как программы создаваемые в современном мире становятся все сложнее и объемней, обеспечение безопасности и надежности вашего кода выходит на первое место. Одна из частых проблем безопасности это утечка памяти и ресурсов. К сожалению простого универсального решения для проблем безопасности нет, поэтому каждый язык решает ее немного по своему. В Rust например отслеживание освобождения памяти и ресурсов осуществляется с помощью RAII (Resource Acquisition Is Initialization), а в Zig это делается с помощью блоков defer и errdefer.

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

const std = @import("std");

fn foo() !void {
    defer std.debug.print("Exiting function ...\n", .{});

    std.debug.print("Execute function body\n", .{});

    {
        defer std.debug.print("Exiting function nested block ...\n", .{});

        std.debug.print("Execute function nested block\n", .{});
    }
}

pub fn main() !void {
    try foo();
}

Если мы запустим данный код то увидим следующее:

$ zig build run
Execute function body
Execute function nested block
Exiting function nested block ...
Exiting function ...

В этом примере мы используем конструкцию defer два раза - один раз в основном блоке функции foo, второй раз во вложенном блоке в функции foo. Как мы видим код будет выполнен в обратном порядке, т.е. сначала выполнится код в блоке defer вложенной функции, а затем код в блоке defer основной функции. Но будьте осторожны при использовании конструкции defer внутри циклов for или while. Код конструкции defer будет выполнятся после завершения каждой итерации цикла, а не в конце всего процесса итерирования.

const std = @import("std");

pub fn main() !void {
    for ([_]i32{ 1, 2, 3, 4, 5 }) |value| {
        std.debug.print("Начало итерации: {}\n", .{value});

        defer std.debug.print("Конец итерации: {}\n", .{value});

        if (value == 3) {
            std.debug.print("Найдено значение 3!\n", .{});
        }
    }
}

Выполнив этот код мы увидим следующий вывод:

Начало итерации: 1
Конец итерации: 1
Начало итерации: 2
Конец итерации: 2
Начало итерации: 3
Найдено значение 3!
Конец итерации: 3
Начало итерации: 4
Конец итерации: 4
Начало итерации: 5
Конец итерации: 5

Кроме конструкции defer, Zig предоставляет еще одну конструкцию errdefer, которая позволяет откладывать выполнение кода до момента завершения блока кода, но только в случае возникновения ошибки. Это полезно для выполнения кода, который должен быть выполнен только в случае ошибки, например для освобождения ресурсов, которые были успешно инициализированы:

const std = @import("std");

pub fn main() void {
    const allocator = std.heap.page_allocator;

    // Выделяем память для массива
    const array = allocator.alloc(i32, 5) catch |err| {
        std.debug.print("Ошибка выделения памяти: {}\n", .{err});
        return;
    };
    errdefer allocator.free(array); // Освобождаем память только в случае ошибки

    // Заполняем массив числами
    for (array, 0..) |*value, i| {
        value.* = @intCast(i * 2);
    }

    // Выводим массив
    std.debug.print("{any}\n", .{array});

    // Освобождаем память вручную, так как errdefer сработает только при ошибке
    allocator.free(array);
}

В этом примере, если после выделения памяти для array произойдет какая-то ошибка, то errdefer будет вызван и освободит память, выделенную для array. Так как errdefer сработает только при ошибке, то в случае успешного выполнения функции, нам нужно вручную освободить память. Этот пример конечно искусственный, в реальной жизни обычно код аллоцирующий память и освобождающий ее разбит на разные методы и поэтому использование errdefer в инициализации является довольно частым паттерном.

И defer и errdefer в случае наличия нескольких конструкций будут выполнятся в обратном порядке, начиная с последней. Давайте вернется обратно к рассмотрению доступных аллокаторов в Zig.

Встроенные аллокаторы

Zig предоставляет несколько встроенных аллокаторов, каждый из которых подходит для разных сценариев использования:

  1. Page Allocator (std.heap.page_allocator):
  1. DebugAllocator (std.heap.DebugAllocator):
  1. Fixed Buffer Allocator (std.heap.FixedBufferAllocator):
  1. C Allocator (std.heap.c_allocator):
  1. Arena Allocator (std.heap.ArenaAllocator):
  1. Memory Pool Allocator (std.heap.MemoryPoolAllocator):

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

Отладочный аллокатор (DebugAllocator)

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

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

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

const std = @import("std");

pub fn main() void {
    var debug = std.heap.DebugAllocator(.{}).init; // Создаем аллокатор
    defer std.debug.print("Allocator memory status: {}\n", .{debug.deinit()}); // Проверяем утечки памяти

    const allocator = debug.allocator(); // Получаем из него интерфейс std.mem.Allocator

    // Выделяем память для строки
    const str = allocator.alloc(u8, 10) catch unreachable;
    defer allocator.free(str);

    // Заполняем строку данными
    std.mem.copyForwards(u8, str, "Hello, Zig");

    // Выводим строку
    std.debug.print("{s}\n", .{str}); // Вывод: Hello, Zig
}

Здесь мы создаём DebugAllocator, получаем из него std.mem.Allocator и затем используем его для выделения памяти для строки. В более сложном проекте аллокатор, скорей всего, будет передаваться во многие другие подсистемы, каждая из которых, в свою очередь, будет передавать аллокатор дальше по цепочке для своих функций, объектов и т.п.

Возможно Вам показался странным синтаксис создания аллокатора, почему там такая странная конструкция (.{}).init, но не обращайте пока на это внимание, мы поймем это, когда дойдем до работы со структурами, а пока просто запомните, что это создание экземпляра структуры DebugAllocator с пустым блоком инициализации.

Страничный аллокатор (Page Allocator)

PageAllocator — это самый низкоуровневый аллокатор в Zig, который выделяет память блоками, кратными размеру страницы (обычно 4 КБ на большинстве систем). Он полезен, когда требуется выделять крупные блоки памяти или соблюдать выравнивание по границам страниц.

В отличие от аллокаторов, таких как DebugAllocator, PageAllocator не отслеживает мелкие выделения, что минимизирует накладные расходы. Это делает его эффективным для работы с крупными блоками данных, например, буферами, изображениями и другими ресурсами.

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

Часто PageAllocator служит базовым механизмом для создания более сложных аллокаторов. Например, DebugAllocator или ArenaAllocator могут использовать его для выделения крупных блоков памяти, которые затем управляются на более высоком уровне.

const std = @import("std");

pub fn main() !void {
    // Получаем глобальный page_allocator
    const allocator = std.heap.page_allocator;

    // Выделяем память для 1000 байт
    const memory = try allocator.alloc(u8, 1000);
    defer allocator.free(memory); // Освобождаем память при выходе из области видимости

    // Используем выделенную память
    for (memory) |*byte| {
        byte.* = 0xAA;
    }

    std.debug.print("Память выделена и инициализирована.\n", .{});
}

В данном примере мы используя страничный аллокатор выделяем память для 1000 байт, что на самом деле приводит к выделению целой страницы памяти, равной на нашей системе 4096 байт.

Аллокатор на основе регионов (ArenaAllocator)

Иногда вам необходимо обработать множество мелких объектов, постоянно выделяя и освобождая память. В этом случае использование DebugAllocator может быть неудобным из-за частых операций выделения и освобождения памяти.

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

const std = @import("std");

pub fn main() !void {
    // Создаем ArenaAllocator с использованием page_allocator в качестве базового аллокатора
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit(); // Освобождаем всю память при выходе из области видимости

    const allocator = arena.allocator();

    // Выделяем память для множества мелких объектов
    const slice1 = try allocator.alloc(u8, 100);
    const slice2 = try allocator.alloc(u8, 200);
    const slice3 = try allocator.alloc(u8, 300);

    // Используем выделенную память
    for (slice1) |*byte| {
        byte.* = 0xAA;
    }
    for (slice2) |*byte| {
        byte.* = 0xBB;
    }
    for (slice3) |*byte| {
        byte.* = 0xCC;
    }

    std.debug.print("Память выделена и инициализирована.\n", .{});
}

Этот аллокатор принимает “дочерний” аллокатор, который в данном случае передаётся в функцию, создающую другой аллокатор — ArenaAllocator. При использовании этого аллокатора нам не нужно очищать память для каждой отдельной сущности, размещённой на “арене”: всё будет освобождено разом при вызове arena.deinit(). Методы free и destroy в этом аллокаторе всё же присутствуют, но они просто ничего не делают.

Этот аллокатор нужно использовать осторожно, так как у него есть два важных ограничения:

Аллокатор с фиксированным буфером (FixedBufferAllocator)

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

const std = @import("std");

pub fn main() void {
    // Создаем фиксированный буфер на 1024 байта
    var buffer: [1024]u8 = undefined;

    // Инициализируем FixedBufferAllocator с использованием буфера
    var fba = std.heap.FixedBufferAllocator.init(buffer[0..]);
    const allocator = fba.allocator();

    // Выделяем память для нескольких объектов
    const slice1 = allocator.alloc(u8, 100) catch {
        std.debug.print("Ошибка выделения памяти для slice1\n", .{});
        return;
    };
    const slice2 = allocator.alloc(u8, 200) catch {
        std.debug.print("Ошибка выделения памяти для slice2\n", .{});
        return;
    };

    // Используем выделенную память
    for (slice1) |*byte| {
        byte.* = 0xAA;
    }
    for (slice2) |*byte| {
        byte.* = 0xBB;
    }

    std.debug.print("Память выделена и инициализирована.\n", .{});
}

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

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

Аллокатор для тестов

Нам осталось рассмотреть еще один аллокатор, который немного выбивается из всех рассмотренных нами аллокаторов, но тем не менее очень полезен - std.testing.allocator. Этот аллокатор специально разработан для использования в тестах и помогает обнаруживать утечки памяти и другие ошибки, связанные с управлением памятью.

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

const std = @import("std");

// Функция, которая выделяет память, но "забывает" её освободить
fn createSliceWithLeak(allocator: std.mem.Allocator, size: usize) !void {
    const slice = try allocator.alloc(u8, size);

    for (slice) |*byte| {
        byte.* = 0xAA;
    }
    // Забываем вызвать allocator.free(slice) — утечка памяти!
}

test "createSliceWithLeak should detect memory leak" {
    // Используем std.testing.allocator для тестирования
    const allocator = std.testing.allocator;

    // Проверяем, что память не была освобождена
    try std.testing.checkAllAllocationFailures(allocator, createSliceWithLeak, .{100});
}

Если мы запустим наш тест, то получим ошибку о том что у нас есть утечка памяти:

test
└─ run test 1/1 passed, 1 leaked
error: 'main.test.createSliceWithLeak should detect memory leak' leaked: [gpa] (err): memory address 0x100740000 leaked:
/Users/roman/Projects/zig/simple/src/main.zig:5:38: 0x10051879f in createSliceWithLeak (test)
    const slice = try allocator.alloc(u8, size);

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

const std = @import("std");

// Функция, которая выделяет память, но "забывает" её освободить
fn createSliceWithLeak(allocator: std.mem.Allocator, size: usize) !void {
    const slice = try allocator.alloc(u8, size);
    defer allocator.free(slice);
    for (slice) |*byte| {
        byte.* = 0xAA;
    }
    // Забываем вызвать allocator.free(slice) — утечка памяти!
}

test "createSliceWithLeak should detect memory leak" {
    // Используем std.testing.allocator для тестирования
    const allocator = std.testing.allocator;

    // Проверяем, что память не была освобождена
    try std.testing.checkAllAllocationFailures(allocator, createSliceWithLeak, .{100});
}

Мы добавили строчку defer allocator.free(slice);, которая освобождает память, выделенную для среза, после завершения функции createSliceWithLeak. Это гарантирует, что память будет освобождена, даже если функция завершится с ошибкой или исключением. Если мы запустим теперь наши тесты, то увидим что мы успешно исправили проблемы:

Build Summary: 5/5 steps succeeded; 2/2 tests passed
test success
├─ run test 1 passed 2ms MaxRSS:1M
│  └─ zig test Debug native cached 67ms MaxRSS:37M
└─ run test 1 passed 443ms MaxRSS:1M
   └─ zig test Debug native success 1s MaxRSS:259M

Заключение

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

В заключение этой главы хочется упомянуть ещё одну важную проблему, связанную с аллокаторами — двойное освобождение памяти (double free). Если вы попытаетесь дважды освободить память, выделенную через аллокатор, это может привести к непредсказуемому поведению программы, включая падения или повреждение данных.

Давайте рассмотрим следующую программу:

const std = @import("std");

pub fn main() void {
    const allocator = std.heap.page_allocator;

    // Выделяем память
    const array = allocator.alloc(i32, 5) catch unreachable;

    // Освобождаем память
    allocator.free(array);

    // Повторное освобождение памяти (ОШИБКА!)
    allocator.free(array); // Это вызовет неопределённое поведение
}

Если мы запустим эту программу, то наша программа упадет:

Segmentation fault at address 0x100b44000
aborting due to recursive panic
run
└─ run simple failure
error: the following command terminated unexpectedly:
/Users/roman/Projects/zig/simple/zig-out/bin/simple
Build Summary: 5/7 steps succeeded; 1 failed
run transitive failure
└─ run simple failure
error: the following build command failed with exit code 1:

Это довольная частая проблема, когда у Вас довольно большой объем кода и память выделяется в одних методах, а освобождается в других. К тому же отлавливать такие ошибки довольно сложно, поэтому очень важно правильно строить логику выделения и освобождения памяти в вашей программе.

#allocators #pointers #zig #zigbook

0%