Вивчаємо Zig Українською

Усе, що ми бачили досі, мало попередньо відомий розмір. Масиви завжди мають відому довжину під час компіляції (фактично, довжина є частиною типу). Усі наші рядки були рядковими літералами, які мають відому довжину під час компіляції.

Крім того, два типи стратегій керування пам’яттю, які ми бачили, глобальні дані та стек викликів, хоч і є простими та ефективними, але є обмеженими. Жодна з них не може мати справу з даними динамічного розміру, і обидва є жорсткими щодо тривалості життя даних.

Ця частина розділена на дві теми. Перша - це загальний огляд нашої третьої області пам'яті, купи. Інша — простий, але унікальний підхід 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 не пов'язаний суворо з розподільниками чи керуванням пам'яттю; ви можете використовувати його для виконання будь-якого коду. Але наведене вище використання є поширеним.

Відкладення Zig схоже на Go, з однією істотною відмінністю. У Zig відкладення буде запущено в кінці його області видимості. У 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 ту саму пам’ять. Це відоме як подвійне вивільнення памʼяті і це не працює. Це може здатися досить простим, щоб уникнути, але у великих проектах зі складним терміном служби це може бути важко відстежити.

Друге правило полягає в тому, що ви не можете звільнити пам'ять, на яку не маєте посилання. Це може здатися очевидним, але не завжди зрозуміло, хто відповідальний за його звільнення. Наступне створює новий рядок у нижньому регістрі:

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 повертає фрагмент із довжиною, яка була передана як 2-й параметр. Якщо вам потрібно єдине значення, використовуйте 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 не має гарного синтаксичного цукру для створення інтерфейсів. Одним із шаблонів поведінки, подібної до інтерфейсу, є об’єднання тегів, хоча це відносно обмежено порівняно зі справжніми інтерфейсами. З’явилися й інші шаблони, які використовуються в стандартній бібліотеці, як-от 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-сервера. У складнішому проекті «розподільник» буде передано до кількох частин коду, кожна з яких, можливо, передасть його до своїх власних функцій, об’єктів і залежностей.

Ви можете помітити, що синтаксис створення 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 у наших тестах, ми можемо вловити більшість витоків пам'яті.

Ймовірно, ви вже знайомі з динамічними масивами, які часто називають ArrayLists. У багатьох мовах динамічного програмування всі масиви є динамічними масивами. Динамічні масиви підтримують змінну кількість елементів. 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 більше місця, нам потрібно free попередні self.items:

// існуючий код
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{
    // ми використовуєсо using aa тут!
    .buf = try aa.alloc(u8, 512),

    // ми використовуєсо using 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.

Загальний шаблон для FixedBufferAllocators і, меншою мірою, ArenaAllocators, полягає в тому, щоб 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 дасть хороше підвищення продуктивності.

Мета полягає не в тому, щоб усунути всі динамічні розподіли. Це не спрацює, оскільки ці альтернативи мають сенс лише в окремих випадках. Але тепер у вашому розпорядженні багато варіантів. Від кадрів стека до розподілювача загального призначення та всього іншого, наприклад статичних буферів, потокових записувачів і спеціалізованих розподільників.