Усе, що ми бачили досі, мало наперед відомий розмір. Масиви завжди мають відому довжину під час компіляції (фактично довжина є частиною типу). Усі наші рядки були рядковими літералами, які мають відому довжину під час компіляції.
Крім того, дві стратегії керування пам’яттю, які ми бачили — глобальні дані та стек викликів, — хоч і є простими та ефективними, але обмеженими. Жодна з них не може мати справу з даними динамічного розміру, і обидві є жорсткими щодо часу життя даних.
Ця частина розділена на дві теми. Перша — це загальний огляд нашої третьої області пам’яті, купи. Інша — простий, але унікальний підхід Zig до керування пам’яттю купи. Навіть якщо ви знайомі з куповою пам’яттю, скажімо, з використанням malloc у C, ви захочете прочитати першу частину, оскільки вона досить специфічна для Zig.
Динамічна памʼять (heap, «куча»)
Купа — це третя й остання область пам’яті в нашому розпорядженні. Порівняно як із глобальними даними, так і зі стеком викликів, купа виглядає як дикий захід: усе можливо. Зокрема, у купі ми можемо створити пам’ять під час виконання з розміром, відомим лише під час виконання, та мати повний контроль над її часом життя.
Стек викликів дивовижний завдяки простому та передбачуваному способу керування даними (через додавання та зняття кадрів стеку). Ця перевага є також недоліком: час життя даних прив’язаний до їхнього місця у стеку викликів. Купа — точнісінько навпаки. Вона не має вбудованого життєвого циклу, тому наші дані можуть зберігатися стільки, скільки потрібно. І ця перевага є водночас її недоліком: вона не має вбудованого життєвого циклу, тому, якщо ми не звільнимо дані, ніхто цього не зробить.
Давайте розглянемо приклад:
learning.zig
const std = @import("std");
pub fn main() !void {
// незабаром ми поговоримо про розподільники
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
// ** Наступні два рядки є важливими **
var arr = try allocator.alloc(usize, try getRandomCount());
defer allocator.free(arr);
for (0..arr.len) |i| {
arr[i] = i;
}
std.debug.print("{any}\n", .{arr});
}
fn getRandomCount() !u8 {
var seed: u64 = undefined;
try std.posix.getrandom(std.mem.asBytes(&seed));
var random = std.Random.DefaultPrng.init(seed);
return random.random().uintAtMost(u8, 5) + 5;
}
Незабаром ми розглянемо розподільники Zig, а поки знаємо, що allocator — це std.mem.Allocator. Ми використовуємо два його методи: alloc і free. Оскільки ми викликаємо allocator.alloc з try, ми знаємо, що цей виклик може завершитися помилкою. Наразі єдиною можливою помилкою є OutOfMemory. Його параметри здебільшого розповідають нам, як він працює: йому потрібен тип (T), а також кількість, і, якщо виклик успішний, він поверне зріз []T. Цей розподіл відбувається під час виконання — наша кількість відома лише під час виконання.
Як правило, кожен alloc матиме відповідний free. Якщо alloc виділяє пам’ять, free її звільняє. Не дозволяйте цьому простому коду обмежувати вашу уяву. Цей шаблон try alloc + defer free є поширеним явищем, і небезпідставно: звільнення поблизу того місця, де ми розподіляємо, відносно надійне. Але не менш поширеним є виділення в одному місці, а звільнення в іншому. Як ми вже говорили раніше, купа не має вбудованого керування життєвим циклом. Ви можете виділити пам’ять в обробнику HTTP та звільнити її у фоновому потоці — у двох цілком окремих частинах коду.
defer & errdefer
Як невеликий відступ, наведений вище код представив нову можливість мови: defer, яка виконує заданий код або блок під час виходу з області видимості. «Вихід з області видимості» включає досягнення кінця області або повернення з неї. defer не пов’язаний суто з розподільниками чи керуванням пам’яттю; ви можете використовувати його для виконання будь-якого коду. Але наведене вище використання є поширеним.
defer у Zig схожий на Go, з однією істотною відмінністю. У Zig defer буде запущено в кінці його області видимості. У Go defer запускається в кінці функції, що його містить. Підхід Zig, імовірно, менш несподіваний, якщо ви не розробник Go.
Схожим на defer є errdefer, який так само виконує заданий код або блок під час виходу з області видимості, але лише тоді, коли повертається помилка. Це корисно, коли виконується складніше налаштування і потрібно скасувати попередній розподіл через помилку.
Наступний приклад — стрибок у складності. Він демонструє як errdefer, так і загальний шаблон, який передбачає виділення в init і звільнення в deinit:
const std = @import("std");
const Allocator = std.mem.Allocator;
pub const Game = struct {
players: []Player,
history: []Move,
allocator: Allocator,
fn init(allocator: Allocator, player_count: usize) !Game {
var players = try allocator.alloc(Player, player_count);
errdefer allocator.free(players);
// зберігати 10 останніх ходів на гравця
var history = try allocator.alloc(Move, player_count * 10);
return .{
.players = players,
.history = history,
.allocator = allocator,
};
}
fn deinit(game: Game) void {
const allocator = game.allocator;
allocator.free(game.players);
allocator.free(game.history);
}
};
Сподіваюся, це підкреслює дві речі. По-перше, корисність errdefer. За нормальних умов players виділяється в init і звільняється в deinit. Але є граничний випадок, коли ініціалізація history не вдається. У цьому, і лише в цьому випадку, нам потрібно скасувати розподіл players.
Другий аспект цього коду, вартий уваги, полягає в тому, що життєвий цикл наших двох динамічно розподілених зрізів, players та history, базується на логіці нашої програми. Немає правила, яке б вказувало, коли потрібно викликати deinit або хто має його викликати. Це добре, бо дає нам довільний час життя, але й погано, бо ми можемо зіпсувати це, ніколи не викликаючи deinit або викликаючи його більше одного разу.
Назви
initіdeinitне є особливими. Це просто те, що використовує стандартна бібліотека Zig і що прийняла спільнота. У деяких випадках, зокрема у стандартній бібліотеці, використовуютьсяopenтаcloseабо інші доречніші назви.
Повторне звільнення та витоки пам’яті
Трохи вище я згадав, що не існує правил, які регулюють, коли щось потрібно звільнити. Але це не зовсім так: є кілька важливих правил, які просто ніхто не нав’язує силоміць, окрім вашої власної педантичності.
Перше правило полягає в тому, що ви не можете звільнити ту саму пам’ять двічі.
learning.zig
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
var arr = try allocator.alloc(usize, 4);
allocator.free(arr);
allocator.free(arr);
std.debug.print("Цей рядок не надрукується\n", .{});
}
Останній рядок цього коду є пророчим: його не буде надруковано. Це тому, що ми двічі звільняємо (free) ту саму пам’ять. Це відоме як подвійне вивільнення (double-free), і так робити не можна. Може здатися, що цього просто уникнути, але у великих проєктах зі складним часом життя об’єктів таке буває важко відстежити.
Друге правило полягає в тому, що ви не можете звільнити пам’ять, на яку у вас немає посилання. Це може здатися очевидним, але не завжди зрозуміло, хто відповідальний за її звільнення. Наступне створює новий рядок у нижньому регістрі:
const std = @import("std");
const Allocator = std.mem.Allocator;
fn allocLower(allocator: Allocator, str: []const u8) ![]const u8 {
var dest = try allocator.alloc(u8, str.len);
for (str, 0..) |c, i| {
dest[i] = switch (c) {
'A'...'Z' => c + 32,
else => c,
};
}
return dest;
}
Наведений вище код правильний. Але наступне використання — ні:
// Для цього конкретного коду ми повинні були використовувати std.ascii.eqlIgnoreCase
fn isSpecial(allocator: Allocator, name: [] const u8) !bool {
const lower = try allocLower(allocator, name);
return std.mem.eql(u8, lower, "admin");
}
Це витік пам’яті. Пам’ять, створена в allocLower, ніколи не звільняється. Мало того, після повернення з isSpecial її вже не можна буде звільнити. У мовах зі збирачами сміття, коли дані стають недоступними, вони зрештою звільняються збирачем сміття. Але в наведеному вище коді, коли isSpecial повертається, ми втрачаємо єдине посилання на виділену пам’ять — змінну lower. Пам’ять втрачена до самого завершення нашого процесу. Наша функція може втрачати лише кілька байтів, але якщо це довготривалий процес і ця функція викликається неодноразово, втрати накопичуватимуться, і врешті-решт у нас вичерпається пам’ять.
Принаймні у випадку з подвійним вивільненням ми отримаємо жорсткий збій. Витоки пам’яті можуть бути підступними. Справа не тільки в тому, що першопричину важко визначити. Зовсім невеликі витоки або витоки в коді, що виконується рідко, може бути ще важче виявити. Це настільки поширена проблема, що Zig справді надає допомогу, як ми побачимо, коли говоритимемо про розподільники.
create та destroy
Метод alloc у std.mem.Allocator повертає зріз із довжиною, яку було передано як другий параметр. Якщо вам потрібне єдине значення, використовуйте create і destroy замість alloc і free. Кілька частин тому, коли ми вивчали вказівники, ми створили User і спробували збільшити його силу. Ось робоча версія цього коду на основі купи, яка використовує create:
learning.zig
const std = @import("std");
pub fn main() !void {
// знову ж таки, скоро ми поговоримо про розподільники!
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
// створюємо User у купі
var user = try allocator.create(User);
// звільняємо пам'ять, виділену для користувача, в кінці цієї області
defer allocator.destroy(user);
user.id = 1;
user.power = 100;
// додали цей рядок
levelUp(user);
std.debug.print("Користувач {d} має силу {d}\n", .{user.id, user.power});
}
fn levelUp(user: *User) void {
user.power += 1;
}
pub const User = struct {
id: u64,
power: i32,
};
Метод create приймає один параметр, тип (T). Він повертає вказівник на цей тип або помилку — !*T. Можливо, вам цікаво, що станеться, якщо ми створимо нашого user, але не встановимо id та/або power. Це схоже на встановлення для цих полів значення undefined, і поведінка буде, ну, невизначеною.
Коли ми досліджували висячі вказівники, у нас була функція, яка неправильно повертала адресу локального користувача:
pub const User = struct {
fn init(id: u64, power: i32) *User{
var user = User{
.id = id,
.power = power,
};
// це "висячий" вказівник
return &user;
}
};
У цьому випадку було б доцільніше повернути User. Але іноді вам буде потрібно, щоб функція повертала вказівник на те, що вона створює. Ви зробите це, коли захочете, щоб час життя об’єкта не був прив’язаний до жорсткості стеку викликів. Щоб вирішити проблему висячого вказівника вище, ми могли б використати create:
// наш тип повернення змінено, оскільки ініціалізація тепер може завершитися помилкою
// *User -> !*User
fn init(allocator: std.mem.Allocator, id: u64, power: i32) !*User{
const user = try allocator.create(User);
user.* = .{
.id = id,
.power = power,
};
return user;
}
Я представив новий синтаксис, user.* = {...}. Він трохи дивний, і мені він не подобається, але ви його побачите. Права сторона — це те, що ви вже бачили: це ініціалізатор структури з виведеним типом. Ми могли б бути явними і використати user.* = User{...}. У лівій частині, user.*, ми розіменовуємо вказівник. & приймає T і дає нам *T. .* — протилежна операція: застосована до значення типу *T, вона дає нам T. Пам’ятайте, що create повертає !*User, тому наш user має тип *User.
Розподільники (Allocators)
Одним з основних принципів Zig є те, що в ньому немає прихованого розподілу пам’яті. Залежно від вашого попереднього досвіду це може не звучати чимось особливим. Але це різкий контраст із тим, що ви знайдете в C, де пам’ять виділяється за допомогою функції malloc зі стандартної бібліотеки. У C, якщо ви хочете знати, чи функція виділяє пам’ять, вам потрібно прочитати вихідний код і знайти виклики malloc.
Zig не має розподільника за замовчуванням. В усіх наведених вище прикладах функції, які виділяли пам’ять, отримували параметр std.mem.Allocator. За домовленістю це зазвичай перший параметр. Уся стандартна бібліотека Zig і більшість сторонніх бібліотек вимагають від того, хто їх викликає, надати розподільник, якщо вони мають намір виділити пам’ять.
Ця явність може мати одну з двох форм. У простих випадках розподільник надається під час кожного виклику функції. Є багато прикладів цього, але std.fmt.allocPrint — той, який вам, швидше за все, знадобиться рано чи пізно. Він схожий на std.debug.print, який ми використовували, але виділяє та повертає рядок замість запису його в stderr:
const say = std.fmt.allocPrint(allocator, "Це більше ніж {d}!!!", .{user.power});
defer allocator.free(say);
Інша форма — коли розподільник передається в init, а потім використовується об’єктом усередині. Ми бачили це вище з нашою структурою Game. Це менш явно, оскільки ви надали об’єкту розподільник для використання, але не знаєте, виклики яких саме методів справді щось розподілятимуть. Такий підхід практичніший для довгоживучих об’єктів.
Перевага введення розподільника полягає не лише в чіткості, але й у гнучкості. std.mem.Allocator — це інтерфейс, який надає функції alloc, free, create і destroy, а також деякі інші. Поки що ми бачили лише std.heap.GeneralPurposeAllocator, але інші реалізації доступні в стандартній бібліотеці або як сторонні бібліотеки.
Zig не має гарного синтаксичного цукру для створення інтерфейсів. Одним із шаблонів поведінки, подібної до інтерфейсу, є об’єднання з тегами (tagged union), хоча воно відносно обмежене порівняно зі справжніми інтерфейсами. З’явилися й інші шаблони, які використовуються в стандартній бібліотеці, як-от
std.mem.Allocator. Якщо вам цікаво, я написав окрему публікацію в блозі, де пояснюю інтерфейси.
Якщо ви створюєте бібліотеку, то найкраще приймати std.mem.Allocator і дозволяти користувачам вашої бібліотеки самим вирішувати, яку реалізацію розподільника використовувати. Інакше вам доведеться вибрати правильний розподільник, і, як ми побачимо, вони не є взаємовиключними. Можуть бути вагомі причини для створення різних розподільників у вашій програмі.
Розподільник загального призначення (General Purpose Allocator)
Як випливає з назви, std.heap.GeneralPurposeAllocator — це універсальний потокобезпечний розподільник загального призначення, який може правити за основний розподільник вашої програми. Для багатьох програм це буде єдиний необхідний розподільник. Під час запуску програми створюється розподільник і передається функціям, які його потребують. Хороший приклад — код з моєї бібліотеки HTTP-сервера:
learning.zig
const std = @import("std");
const httpz = @import("httpz");
pub fn main() !void {
// створюємо наш розподільник загального призначення
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
// отримуємо з нього std.mem.Allocator
const allocator = gpa.allocator();
// передаємо наш розподільник функціям і бібліотекам, які його потребують
var server = try httpz.Server().init(allocator, .{.port = 5882});
var router = server.router();
router.get("/api/user/:id", getUser);
// блокуємо поточний потік
try server.listen();
}
Ми створюємо GeneralPurposeAllocator, отримуємо з нього std.mem.Allocator і передаємо його функції init HTTP-сервера. У складнішому проєкті allocator буде передано в кілька частин коду, кожна з яких, можливо, передасть його далі до своїх власних функцій, об’єктів і залежностей.
Ви можете помітити, що синтаксис створення gpa є трохи дивним. Що це таке: GeneralPurposeAllocator(.{}){}? Це все речі, які ми бачили раніше, просто зібрані разом. std.heap.GeneralPurposeAllocator — це функція, і оскільки вона використовує PascalCase, ми знаємо, що вона повертає тип. (Докладніше про узагальнені функції ми поговоримо в наступній частині.) Знаючи, що вона повертає тип, цю явнішу версію, можливо, буде легше зрозуміти:
const T = std.heap.GeneralPurposeAllocator(.{});
var gpa = T{};
// те саме що
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
Можливо, ви все ще не впевнені щодо значення .{}. Це теж те, що ми бачили раніше: це ініціалізатор структури з неявним типом. Який це тип і де поля? Типом є std.heap.general_purpose_allocator.Config, хоча він не представлений тут безпосередньо — це одна з причин, чому ми не зазначаємо його явно. Поля не встановлюються, оскільки структура Config визначає значення за замовчуванням, які ми й використовуватимемо. Це поширений шаблон для конфігурацій/опцій. Власне, ми бачимо це знову кількома рядками нижче, коли передаємо .{.port = 5882} в init. У цьому випадку ми використовуємо значення за замовчуванням для всіх полів, крім одного — port.
Розподільник для тестування std.testing.allocator
Сподіваюся, ви достатньо занепокоїлись, коли ми говорили про витоки пам’яті, і захотіли дізнатися більше, коли я згадав, що Zig може допомогти. Ця допомога надходить від std.testing.allocator, який є std.mem.Allocator. Наразі він реалізований за допомогою GeneralPurposeAllocator з додатковою інтеграцією у виконавець тестів Zig, але це деталь реалізації. Важливо те, що, використовуючи std.testing.allocator у наших тестах, ми можемо виловити більшість витоків пам’яті.
Імовірно, ви вже знайомі з динамічними масивами, які часто називають ArrayList. У багатьох мовах динамічного програмування всі масиви є динамічними. Динамічні масиви підтримують змінну кількість елементів. Zig має відповідну узагальнену структуру ArrayList, але ми створимо її спеціально для зберігання цілих чисел і для демонстрації виявлення витоків:
pub const IntList = struct {
pos: usize,
items: []i64,
allocator: Allocator,
fn init(allocator: Allocator) !IntList {
return .{
.pos = 0,
.allocator = allocator,
.items = try allocator.alloc(i64, 4),
};
}
fn deinit(self: IntList) void {
self.allocator.free(self.items);
}
fn add(self: *IntList, value: i64) !void {
const pos = self.pos;
const len = self.items.len;
if (pos == len) {
// закінчилось місце
// створюємо зріз, що в 2 рази більший
var larger = try self.allocator.alloc(i64, len * 2);
// копіюємо елементи які ми перед цим додали в наш простір
@memcpy(larger[0..len], self.items);
self.items = larger;
}
self.items[pos] = value;
self.pos = pos + 1;
}
};
Цікава частина відбувається в add, коли pos == len вказує, що ми заповнили наш поточний масив і потрібно створити більший. Ми можемо використовувати IntList так:
learning.zig
const std = @import("std");
const Allocator = std.mem.Allocator;
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
var list = try IntList.init(allocator);
defer list.deinit();
for (0..10) |i| {
try list.add(@intCast(i));
}
std.debug.print("{any}\n", .{list.items[0..list.pos]});
}
Код запускається та виводить правильний результат. Однак, попри те, що ми справді викликали deinit у list, є витік пам’яті. Нічого страшного, якщо ви його не помітили, бо ми зараз напишемо тест і використаємо std.testing.allocator:
const testing = std.testing;
test "IntList: add" {
// ми використовуємо testing.allocator тут!
var list = try IntList.init(testing.allocator);
defer list.deinit();
for (0..5) |i| {
try list.add(@intCast(i+10));
}
try testing.expectEqual(@as(usize, 5), list.pos);
try testing.expectEqual(@as(i64, 10), list.items[0]);
try testing.expectEqual(@as(i64, 11), list.items[1]);
try testing.expectEqual(@as(i64, 12), list.items[2]);
try testing.expectEqual(@as(i64, 13), list.items[3]);
try testing.expectEqual(@as(i64, 14), list.items[4]);
}
@asє вбудованим, який виконує приведення типу. Якщо вам цікаво, чому в нашому тесті було використано так багато з них, ви не єдині. Технічно це тому, що другий параметр, «фактичний», примусово ставиться до першого, «очікуваного». У наведеному вище «очікуваними» є всіcomptime_int, що викликає проблеми. Багато хто, включаючи мене, вважають це дивною та невдалою поведінкою.
Якщо ви виконуєте все за прикладом, помістіть тест у той самий файл, що й IntList і main. Тести Zig зазвичай записуються в одному файлі, часто поруч із кодом, який вони тестують. Коли ми використовуємо zig test learning.zig для запуску нашого тесту, ми отримуємо дивовижну помилку:
Test [1/1] test.IntList: add... [gpa] (err): memory address 0x101154000 leaked:
/code/zig/learning.zig:26:32: 0x100f707b7 in init (test)
.items = try allocator.alloc(i64, 2),
^
/code/zig/learning.zig:55:29: 0x100f711df in test.IntList: add (test)
var list = try IntList.init(testing.allocator);
... MORE STACK INFO ...
[gpa] (err): memory address 0x101184000 leaked:
/code/test/learning.zig:40:41: 0x100f70c73 in add (test)
var larger = try self.allocator.alloc(i64, len * 2);
^
/code/test/learning.zig:59:15: 0x100f7130f in test.IntList: add (test)
try list.add(@intCast(i+10));
Ми маємо численні витоки пам’яті. На щастя, тестовий розподільник повідомляє нам, де саме було виділено пам’ять, що витекла. Чи можете ви зараз помітити витік? Якщо ні, пам’ятайте, що загалом кожен alloc повинен мати відповідний free. Наш код викликає free один раз — у deinit. Однак alloc викликається один раз у init, а потім щоразу, коли викликається add і нам потрібно більше місця. Щоразу, коли ми виділяємо більше місця через alloc, нам потрібно звільняти попередні self.items через free:
// існуючий код
var larger = try self.allocator.alloc(i64, len * 2);
@memcpy(larger[0..len], self.items);
// доданий код
// звільнюємо попередні алокації
self.allocator.free(self.items);
Додавання цього останнього рядка після копіювання елементів у наш «більший» зріз вирішує проблему. Якщо ви запустите zig test learning.zig, помилки не повинно бути.
Розподільник на основі регіонів (ArenaAllocator)
GeneralPurposeAllocator — розумний вибір за замовчуванням, оскільки він добре працює в усіх можливих випадках. Але в програмі ви можете зіткнутися з шаблонами розподілу, для яких краще пасуватимуть більш спеціалізовані розподільники. Один із прикладів — потреба в короткочасному стані, який можна відкинути після завершення обробки. Парсери часто мають таку вимогу. Каркасна функція parse може виглядати так:
fn parse(allocator: Allocator, input: []const u8) !Something {
const state = State{
.buf = try allocator.alloc(u8, 512),
.nesting = try allocator.alloc(NestType, 10),
};
defer allocator.free(state.buf);
defer allocator.free(state.nesting);
return parseInternal(allocator, state, input);
}
Хоч цим не надто важко керувати, parseInternal може потребувати інших короткочасних розподілів, які доведеться звільняти. Як альтернативу ми можемо створити ArenaAllocator, який дасть нам змогу звільнити всі розподіли за один раз:
fn parse(allocator: Allocator, input: []const u8) !Something {
// створюємо ArenaAllocator з наданого allocator
var arena = std.heap.ArenaAllocator.init(allocator);
// це звільнить усе, що створено в цьому регіоні
defer arena.deinit();
// створюємо std.mem.Allocator з arena — це буде
// розподільник, який ми використаємо всередині
const aa = arena.allocator();
const state = State{
// тут ми використовуємо aa!
.buf = try aa.alloc(u8, 512),
// і тут теж використовуємо aa!
.nesting = try aa.alloc(NestType, 10),
};
// ми передаємо aa далі, тож гарантуємо, що
// будь-яке виділення памʼяті буде в нашому регіоні
return parseInternal(aa, state, input);
}
ArenaAllocator приймає дочірній розподільник — у цьому випадку розподільник, який було передано в init, — і створює новий std.mem.Allocator. Коли цей новий розподільник використовується для виділення або створення пам’яті, нам не потрібно викликати free або destroy. Усе буде звільнено, коли ми викличемо deinit на arena. Насправді виклики free та destroy в ArenaAllocator нічого не роблять.
ArenaAllocator слід використовувати обережно. Оскільки немає способу звільнити окремі виділення, ви повинні бути впевнені, що deinit арени буде викликано в межах розумного зростання пам’яті. Цікаво, що це знання може бути внутрішнім або зовнішнім. Наприклад, у наведеному вище каркасі використання ArenaAllocator має сенс зсередини парсера, оскільки деталі часу життя стану є внутрішньою справою.
Такі розподільники, як
ArenaAllocator, що мають механізм звільнення всіх попередніх виділень, можуть порушувати правило, згідно з яким коженallocповинен мати відповіднийfree. Однак, якщо ви отримуєтеstd.mem.Allocator, вам не слід робити жодних припущень щодо реалізації під ним.
Те саме не можна сказати про наш IntList. Його можна використовувати для зберігання як 10, так і 10 мільйонів значень. Час його життя може вимірюватися мілісекундами або тижнями. Він не в змозі визначити, який тип розподільника використовувати. Це знання має код, який використовує IntList. Спочатку ми керували нашим IntList так:
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
var list = try IntList.init(allocator);
defer list.deinit();
Натомість ми могли б вибрати ArenaAllocator:
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const aa = arena.allocator();
var list = try IntList.init(aa);
// Чесно кажучи, я сумніваюся, чи варто викликати list.deinit чи ні.
// Технічно це не потрібно, оскільки ми викликаємо defer arena.deinit() вище.
defer list.deinit();
...
Нам не потрібно змінювати IntList, оскільки він має справу лише з std.mem.Allocator. І якби IntList створював власну арену внутрішньо, це теж спрацювало б. Немає причин, чому ви не могли б створити арену всередині арени.
Як останній швидкий приклад, HTTP-сервер, про який я згадував вище, надає розподільник арени у Response. Після надсилання відповіді арена звільняється. Передбачуваний час життя арени (від початку запиту до його кінця) робить її ефективним варіантом. Ефективним як з погляду продуктивності, так і з погляду простоти використання.
Розподільник із фіксованим буфером (FixedBufferAllocator)
Останній розподільник, який ми розглянемо, — це std.heap.FixedBufferAllocator, який виділяє пам’ять із буфера (тобто []u8), наданого нами. Цей розподільник має дві основні переваги. По-перше, оскільки вся пам’ять, яку він може використовувати, створюється заздалегідь, він швидкий. По-друге, він природним чином обмежує обсяг пам’яті, який можна виділити. Це жорстке обмеження можна розглядати і як недолік. Іншим недоліком є те, що free і destroy працюватимуть лише на останньому виділеному/створеному елементі (уявіть собі стек). Звільнення не останнього розподілу безпечне для виклику, але нічого не дасть.
learning.zig
const std = @import("std");
pub fn main() !void {
var buf: [150]u8 = undefined;
var fa = std.heap.FixedBufferAllocator.init(&buf);
// це звільнить всю пам'ять, виділену цим розподільником
defer fa.reset();
const allocator = fa.allocator();
const json = try std.json.stringifyAlloc(allocator, .{
.this_is = "an anonymous struct",
.above = true,
.last_param = "are options",
}, .{.whitespace = .indent_2});
// Ми можемо звільнити цей розподіл, але оскільки ми знаємо, що наш розподільник є
// FixedBufferAllocator, ми можемо покладатися на наведений вище `defer fa.reset()`
defer allocator.free(json);
std.debug.print("{s}\n", .{json});
}
це виведе:
{
"this_is": "an anonymous struct",
"above": true,
"last_param": "are options"
}
Але змініть наш buf на [120]u8, і ви отримаєте помилку OutOfMemory.
Поширений шаблон для FixedBufferAllocator і, меншою мірою, для ArenaAllocator — це викликати reset і використовувати їх повторно. Це звільняє всі попередні виділення і дозволяє повторно використовувати розподільник.
Через відсутність розподільника за замовчуванням Zig є прозорим і гнучким щодо розподілу пам’яті. Інтерфейс std.mem.Allocator є потужним: він дозволяє спеціалізованим розподільникам обгортати більш загальні, як ми бачили з ArenaAllocator.
У ширшому сенсі, сподіваюся, потужність і пов’язана з нею відповідальність розподілу купи стали очевидними. Можливість виділяти пам’ять довільного розміру з довільним часом життя є важливою для більшості програм.
Однак через складність динамічної пам’яті варто шукати альтернативи. Наприклад, вище ми використовували std.fmt.allocPrint, але в стандартній бібліотеці також є std.fmt.bufPrint. Останній використовує буфер замість розподільника:
const std = @import("std");
pub fn main() !void {
const name = "Leto";
var buf: [100]u8 = undefined;
const greeting = try std.fmt.bufPrint(&buf, "Hello {s}", .{name});
std.debug.print("{s}\n", .{greeting});
}
Цей API перекладає тягар керування пам’яттю на того, хто викликає функцію. Якби ми мали довший name або менший buf, наш bufPrint міг би повернути помилку NoSpaceLeft. Але є багато сценаріїв, коли програма має відомі обмеження, наприклад максимальну довжину імені. У таких випадках bufPrint безпечніший і швидший.
Інша можлива альтернатива динамічному розподілу — потокова передача даних у std.io.Writer. Як і наш Allocator, Writer є інтерфейсом, реалізованим багатьма типами, наприклад файлами. Вище ми використали stringifyAlloc для серіалізації JSON у динамічно виділений рядок. Ми могли б використати stringify і надати Writer:
learning.zig
const std = @import("std");
pub fn main() !void {
const out = std.io.getStdOut();
try std.json.stringify(.{
.this_is = "an anonymous struct",
.above = true,
.last_param = "are options",
}, .{.whitespace = .indent_2}, out.writer());
}
У той час як розподільники часто вказуються як перший параметр функції, автори зазвичай є останніми.
ಠ_ಠ
У багатьох випадках обгортання нашого записувача в std.io.BufferedWriter дасть хороший приріст продуктивності.
Мета полягає не в тому, щоб усунути всі динамічні розподіли. Це не спрацює, оскільки ці альтернативи мають сенс лише в окремих випадках. Але тепер у вашому розпорядженні багато варіантів. Від кадрів стеку до розподільника загального призначення та всього, що між ними: статичних буферів, потокових записувачів і спеціалізованих розподільників.