Указатели и аллокаторы
Указатели — это одна из ключевых концепций в программировании, которая позволяет работать с памятью на низком уровне. В 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
В этом примере:
- ptr — это опциональный указатель, который содержит адрес переменной x.
- nullPtr — это опциональный указатель, который равен null.
Для чего могут использоваться опциональные указатели:
- Безопасность: Опциональные указатели позволяют явно указать, что указатель может быть нулевым. Это помогает избежать ошибок, связанных с разыменованием нулевого указателя, которые могут привести к сбоям программы.
- Выразительность: Использование
?*T
делает код более читаемым, так как явно показывает, что указатель может быть null. - Упрощение логики: Опциональные указатели позволяют использовать встроенные механизмы Zig для работы с null, такие как оператор
orelse
илиif
с проверкой на 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;
В этом примере:
- x — это переменная типа i32.
- ptr — это указатель на x.
- ptrToPtr — это указатель на 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
}
В этом примере:
- Мы передаем в функцию changePointer указатель на указатель ptr.
- Внутри функции мы изменяем значение, на которое указывает ptr, создавая новую переменную newVar и присваивая ее адрес указателю ptr.
Указатели на коллекцию элементов
В 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
}
В этом примере:
- ptr указывает на первый элемент массива.
- ptr + 2 перемещает указатель на третий элемент массива.
- newPtr[0] разыменовывает указатель и возвращает значение 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
}
В этом примере:
- ptr1 указывает на первый элемент массива.
- ptr2 указывает на четвертый элемент массива.
- ptr2 - ptr1 возвращает количество элементов между указателями, то есть 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
}
В этом примере:
- ptr изначально указывает на первый элемент массива.
- ptr += 2 перемещает указатель на третий элемент.
- ptr -= 1 перемещает указатель на второй элемент.
Таким образом, мы видим что если наш указатель это указатель на коллекцию элементов, то используя арифметику указателей мы можем эффективно перемещаться по массивам и другим структурам данных, а также вычислять расстояния между указателями.
Однако работая с арифметикой указателей надо помнить об одной важной вещи - в этом случае Zig не проверяет правильность вашей арифметики и Вы можете получить выход за границу вашей коллекции элементов, что может привести к неопределенному поведению (undefined behavior) в вашей программе:
- Чтение или запись в непредназначенную область памяти.
- Сбои программы (segmentation fault, access violation).
- Повреждение данных.
- Уязвимости безопасности (например, утечка данных или выполнение произвольного кода).
Почему же 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}); // Неопределенное поведение!
}
В этом примере:
- ptr + 5 перемещает указатель за пределы массива.
- Попытка разыменовать dangerousPtr приводит к неопределенному поведению. Программа может:
- Вызвать сбой (segmentation fault).
- Вернуть “мусорное” значение.
- Повести себя непредсказуемо.
Если вы работаете с указателями напрямую, всегда проверяйте, что указатель остается в пределах допустимой области памяти или используйте специальные встроенные функции в 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", .{});
}
}
В этом примере:
- Мы проверяем, что index меньше длины массива, прежде чем разыменовывать указатель.
Аллокаторы
Как уже упоминалось во введении, 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!
}
Разберем этот пример:
- Аллокатор:
- Мы используем DebugAllocator для выделения памяти под строку.
- std.fmt.allocPrint:
- Форматирует строку (аналогично printf в C).
- Выделяет память для результата с помощью переданного аллокатора.
- defer allocator.free(message):
- Освобождает память, выделенную для строки, после завершения работы.
Явная передача аллокатора в функции позволяет разработчикам управлять памятью более точно и предсказуемо, в том числе и выбирать какой именно аллокатор они хотят использовать в каждой конкретной ситуации. Если Вы разрабатываете библиотеку или модуль, то Вам не надо выбирать “правильный” аллокатор для всех возможных случаев использования, вы позволяете пользователям вашей библиотеки самим выбрать аллокатор, который они хотят использовать в конкретной ситуации.
Итак, аллокатор в Zig это объект, предоставляющий методы для выделения, освобождения и изменения размера памяти. В Zig аллокаторы представлены интерфейсом std.mem.Allocator
, который определяет методы для работы с памятью, такие как alloc
, create
, destroy
, free
, realloc
и другие. Стандартная схема работы с аллокатором в Zig выглядит следующим образом:
- Выделяем память через alloc или create
- Используем память
- Освобождаем память через free или destroy
Отличие методов 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 предоставляет несколько встроенных аллокаторов, каждый из которых подходит для разных сценариев использования:
- Page Allocator (std.heap.page_allocator):
- Самый простой аллокатор, который выделяет память непосредственно у операционной системы.
- Не всегда эффективно расходует память.
- Не эффективен для частых выделений и освобождений.
- DebugAllocator (std.heap.DebugAllocator):
- Универсальный аллокатор, который можно использовать в качестве временного решения, если вы еще не определились с выбором основного аллокатора.
- Поддерживает отслеживание утечек памяти (в режиме отладки).
- Fixed Buffer Allocator (std.heap.FixedBufferAllocator):
- Аллокатор, который использует заранее выделенный буфер памяти.
- Полезен для выделения памяти на стеке или в статических буферах.
- C Allocator (std.heap.c_allocator):
- Использует стандартные функции malloc, free и realloc из библиотеки C.
- Полезен для взаимодействия с кодом на C.
- Arena Allocator (std.heap.ArenaAllocator):
- Аллокатор, который выделяет память блоками и освобождает ее только при уничтожении.
- Подходит для сценариев, где много мелких выделений, а освобождение происходит одновременно.
- 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 в этом аллокаторе всё же присутствуют, но они просто ничего не делают.
Этот аллокатор нужно использовать осторожно, так как у него есть два важных ограничения:
- Он не поддерживает раздельное освобождение памяти для отдельных сущностей, размещённых на “арене”. Вам нужно осторожно вызывать метод
arena.deinit
убедившись, что все сущности, размещённые на “арене”, больше не нужны. - Если вам нужно управлять долгоживущими объектами с различными временами жизни, ArenaAllocator может быть не самым подходящим выбором.
Аллокатор с фиксированным буфером (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, используя этот буфер, и получаем доступ к аллокатору. Затем выделяем память для нескольких объектов и используем её.
У данного аллокатора есть ряд ограничений, о которых нужно помнить при его использовании:
- Поскольку FixedBufferAllocator использует только предоставленный буфер, он ограничен его размером. Если буфер исчерпан, дальнейшие выделения памяти будут невозможны.
- 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:
Это довольная частая проблема, когда у Вас довольно большой объем кода и память выделяется в одних методах, а освобождается в других. К тому же отлавливать такие ошибки довольно сложно, поэтому очень важно правильно строить логику выделения и освобождения памяти в вашей программе.