diff --git a/src/ch06.md b/src/ch06.md index 715a487..ea39592 100644 --- a/src/ch06.md +++ b/src/ch06.md @@ -1,7 +1,7 @@ # Динамическая память и аллокаторы -Всё, счем имели дело до сих пор, требовало знаний размеров всех сущностей +Всё, с чем имели дело до сих пор, требовало знаний размеров всех сущностей во время компиляции. Массивы всегда имеют размер, известный во время компиляции (фактически, длина массива является частью типа). Все наши строки были строковыми литералами, длина которых тоже понятна по @@ -16,7 +16,7 @@ модифицированы), переменные в стеке живут, пока работает функция, который принадлежит этот стековый кадр. -Эта главв содержит 2 темы. Первая это общий обзор нашей третьей области +Эта глава содержит 2 темы. Первая это общий обзор нашей третьей области памяти, кучи. А вот вторая более интересна - в языке Zig имеется вполне ясный, но тем не менее уникальный подход к управлению динамической памятью. Даже если Вы так или иначе умеете работать с кучей @@ -26,17 +26,17 @@ ## Динамическая память (heap, "куча") -Куча это наша третья область памяти в нашем распоряжении. -В сравнении с первыми двумя это немного "дикий запад": дозволено всё. -Именно, мы можем создавать сущности в куче с размером, -известным только во время исполнения программы и при этом -имеем полный контроль над временем жизни этих сущностей. +Куча это третья область памяти, имеющаяся в нашем распоряжении. В +сравнении с первыми двумя это немного "дикий запад": дозволено всё. +Именно, мы можем создавать сущности в куче с размером, известным только +во время исполнения программы и при этом имеем полный контроль над +временем жизни этих сущностей. Стек вызовов это замечательная штука ввиду простоты и предсказуемости способа, которым он управляет данными (создание/удаление стековых кадров). С другой стороны, эта простота одновременно является -недостатком: времена жизни переменных в стеке жёстко привязяны ко времени -жизни самого стекового кадра, в котором они "живут". Куча это полная +недостатком: времена жизни переменных в стеке жёстко привязаны ко времени +жизни самого стекового кадра, в котором они "живут". Куча это полная противоположность этому. Для объектов в куче нет никакого встроенного механизма контроля времени жизни, поэтому эти объекты могут существовать столь долго или, наоборот, столь коротко, сколько мы захотим. И эти @@ -87,7 +87,7 @@ fn getRandomCount() !u8 { как правило, имеет соответствующий вызов `free`. Первый выделяет память, второй освобождает. Однако, не дайте этому примеру ограничить Ваше воображение. Да, этот паттерн, `try alloc` и сразу следом `defer free` -действительно часто испольузется, и не зря: такое размещение инструкций +действительно часто используется, и не зря: такое размещение инструкций является относительно надёжной защитой от ошибок в виде того, что мы "забыли" освободить. Но так же часто используется совершенно иная схема: выделение памяти делается в одном месте, а освобождение совершенно в @@ -96,7 +96,7 @@ fn getRandomCount() !u8 { управления временем жизни объектов в куче, всё в нашей власти. Представьте себе веб-сервер с постадийной обработкой запроса, причём каждую стадию отрабатывает выделенная для этого нить - тогда память может -быть выделена на стадии получения HTTP-запроса, а освождена где-то на +быть выделена на стадии получения HTTP-запроса, а освобождена где-то на конечной стадии, например, при записи в журнал, в котором фиксируются все запросы. @@ -115,13 +115,313 @@ fn getRandomCount() !u8 { в Zig отложенные инструкции будут выполнены по завершении *блока*, где они заданы, а в Go они будут выполнены в конце *функции*. +Родственником `defer` является `errdefer`. Так же, как и `defer`, +он выполняет заданный код по завершении блока, но только в случае, +если произошла ошибка. Это полезно, когда логика инициализации +более сложная, чем в примере выше и нам нужно отменить +уже совершённые процедуры в случае, если на очередном шаге +случилась ошибка. + +Следующий пример - своего рода прыжок в сложность. Он демонстрирует +использование `errdefer`, а также общую схему, когда `init` +используется для выделения памяти, а `deinit` для её освобождения: + +```zig +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); + + // store 10 most recent moves per player + 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 и принятые к +использованию сообществом Zig. В некоторых случаях, включая стандартную +библиотеку, используются другие более подходящие для этих случаев имена, +например, `open` и `close`. ## Повторное освобождение и утечки памяти +Только что мы отметили, что не существует никаких правил относительно +того, кто и когда должен освободить память. Однако, это верно лишь +отчасти, всё таки имеется несколько важных правил, просто ничто, кроме +Вашей педантичности и аккуратности, не заставит Вас их соблюдать. + +Первое правило состоит, что Вы не можете освободить одну и ту же +область памяти дважды, смотрим на пример: + +```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", .{}); +} +``` + +Последняя строчка этого кода, как выяснится после попытки его запустить, +оказалась пророческой, действительно, строчка не будет напечатана. Так +получилось потому, что мы два раза делаем `allocator.free(arr);` и на +втором разе программа "вылетит" с ошибкой. Это называется +повторное/двойное освобождение (double free) и так делать не надо. Может +показаться, что этого довольно просто избежать, однако, в сложных больших +проектах со сложными правилами по поводу времён жизни бывает трудно всё +это отследить. + +Второе правило гласит, что Вы не можете освобождать память, на которую у +Вас нет ссылки. Это вроде как самоочевидно, но бывает не всегда ясно, кто +именно ответственен за освобождение. Вот функция, которая создаёт строку в +нижнем регистре: + +```zig +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; +} +``` + +Сама по себе эта функция корректная, но вот такое её использование неправильно: + +```zig +// For this specific code, we should have used 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`, мы уже никогда не сможем её освободить. В +языках со сборкой мусора память под данные, которые стали не нужны, в +конце концов будет освобождена сборщиком мусора. В коде выше после +возврата из `isSprecial` мы потеряли единственную ссылку на переменную +`lower`. В итоге память занята, но ни использовать, ни освободить мы её +не можем, потому что у нас нет нужного указателя. Занятая впустую память +будет освобождена операционной системой только тогда, когда наша +программа завершится. Функция `isSpecial`, возможно, не так уж и много +памяти теряет, но если она многократно вызывается в программе, которая по +своей сути работает "всегда" (любой сервер), то в итоге у нас на машине +кончится память. + +В случае с повторным освобождением программа просто аварийно завершится. +А вот утечки памяти могут быть весьма коварными: мало того, что +первопричину бывает довольно трудно установить, ситуацию ещё более могут +усугубить небольшие утечки или утечки в не часто вызываемом коде, тогда +Вы даже какое-то время даже и знать не будете, что имеет место утечка +памяти, соответственно и причину искать не будете. + +Поскольку утечки это довольно распространённая проблема, Zig +предоставляет некоторую помощь, о которой мы узнаем, когда будем изучать +аллокаторы. + ## `create` и `destroy` +Метод `alloc` структуры `std.mem.Allocator` возвращает срез с длиной, +указанной вторым параметром. Если Вам нужно одиночное значение, +вместо `alloc` и `free` используйте `create` и `destroy`, соответственно. +Пару глав назад, изучая указатели, мы создавали пользователя +и пытались увеличить его силу. Вот работающий пример с динамическим +выделением памяти при помощи метода `create`: + +```zig +const std = @import("std"); + +pub fn main() !void { + // again, we'll talk about allocators soon! + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + const allocator = gpa.allocator(); + + // create a User on the heap + var user = try allocator.create(User); + // free the memory allocated for the user at the end of this scope + defer allocator.destroy(user); + + user.id = 1; + user.power = 100; + + // this line has been added + 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`. У вас может возникнуть вопрос - а что, если мы таким образом +создадим пользователя, но при этом не назначим никаких значений полям +`id` и `power`? Тут всё просто - получится так же, как если бы назначили +"значения" `undefined`. + +Когда мы изучали висячие указатели, у нас была функция, которая ошибочно +возвращала адрес локальной переменной: + +```zig +pub const User = struct { + fn init(id: u64, power: i32) *User{ + var user = User{ + .id = id, + .power = power, + }; + // this is a dangling pointer + return &user; + } +}; +``` + +В этом случае имеет смысл возвращать `User`, а не указатель на него. +Но иногда нужно, чтобы функция возвращала именно указатель на то, +что она создала. Так нужно делать, если Вы хотите, чтобы время +жизни создаваемой сущности не было привязано к механизму стека вызовов. +Чтобы решить проблему висячего указателя, можно использовать `create`: + +```zig +// our return type changed, since init can now fail +// *User -> !*User +fn init(allocator: std.mem.Allocator, id: u64, power: i32) !*User{ + var user = try allocator.create(User); + user.* = .{ + .id = id, + .power = power, + }; + return user; +} +``` + +Тут у нас появилась новая синтаксическая конструкция, `user.* = .{...}`. +Она немного странная и некоторым она не нравится, но, так или иначе, она +используется. То, что написано справа от знака присваивания, мы уже +видели, это инициализатор структуры с выводимым из левой части типом. +Можно и явно написать, то есть `user.* = User{...}`, по сути ничего не +поменяется. А вот то, что слева от знака присваивания, называется +"разыменование указателя" (pointer dereference). Уже знакомый нам +оператор `&` (взятие адреса) берёт тип `T` и возвращает указатель на +него, то есть `*T`. А разыменование указателя, то есть `.*` делает +противоположное, то есть берёт `*T` и возвращает `T`. Помните, что +`create` тут возвращает `!*User`, стало быть, тип переменной `user` есть +указатель на `User`, то есть `*User` и чтобы заполнить память, где +расположен этот экземпляр, указатель нужно разыменовать. + ## Аллокаторы +Одним из базовых принципов Zig является принцип "никаких скрытых выделений памяти". +В зависимости от Вашего предыдущего опыта программирования, +возможно, для Вас в этом принципе нет ничего особенного. +Тем не менее, это сильно контрастирует с тем, что мы имеем в стандартной +библиотеке языка C, а там для выделения памяти мы имеем `malloc`. +В C для того, чтобы понять, использует ли та или иная функция кучу, +нужно глядеть в исходный код этой функции и искать там `malloc` +и родственные ей, например, `calloc`. + +А вот в Zig нет аллокатора "по умолчанию". Во всех примерах выше +функции, которые размещают сущности в куче, принимают в качестве +параметра `std.mem.Allocator`. По соглашению, обычно это первый параметр. +Вся стандартная библиотека Zig, а также большинство сторонних библиотек +требуют, чтобы вызывающая сторона передала аллокатор в случае, +если этим библиотекам требуется выделять память динамически. + +Такое явное указание аллокаторов может принимать две формы. +В простых случаях, аллокатор передаётся каждой функции, например: + +```zig +onst say = std.fmt.allocPrint(allocator, "It's over {d}!!!", .{user.power}); +defer allocator.free(say); +``` + +Функция `std.fmt.allocPrint` похожа на `std.debug.print`, только вместо +того, чтобы печатать в `stderr`, она выделяет память под строку и "печатает" +в эту строку (как `sprintf` в C). + +Другая форма это когда аллокатор передаётся в "конструктор" (`init`), +там запоминается и далее используется для внутренних нужд объекта. +Мы видели такую форму выше, в реализации структуры `Game`. +Такая форма менее явна, потому что Вы знаете, что объект будет +использовать динамическую память, но не знаете, в каких именно своих +методах он будет это делать. Такой подход более практичен для долго живущих сущностей. + +Преимущество "внедрения" аллокатора состоит не только в том, что +явно обозначено, что функция/объект будут использовать кучу, +но также и в том, что это предоставляет некоторую гибкость. +Дело в том, что `std.mem.Allocator` это *интерфейс* (а не конкретный аллокатор), +который предоставляет методы `alloc/free` и `create/destroy`, наряду с некоторыми другими. +До этого момента мы видели только `std.heap.GeneralPurposeAllocator`, +но в стандартной библиотеке имеются реализации и других аллокаторов. + +Если Вы разрабатываете библиотеку, лучше всего будет, если она будет +принимать аллокатор, это позволит пользователям Вашей библиотеки +сами выбирать, какой конкретно аллокатор будет использоваться. +В противном случае Вам будет нужно выбирать "правильный" аллокатор и, +как мы далее увидим, аллокаторы не являются взаимоисключающими - +программа может одновременно использовать несколько видов аллокаторов, +и на то могут быть свои причины. + + + ## Аллокатор общего назначения ## `std.testing.allocator` diff --git a/src/ex-ch06-01.zig b/src/ex-ch06-01.zig new file mode 100644 index 0000000..1a3b432 --- /dev/null +++ b/src/ex-ch06-01.zig @@ -0,0 +1,13 @@ + +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", .{}); +} diff --git a/src/ex-ch06-02.zig b/src/ex-ch06-02.zig new file mode 100644 index 0000000..901f307 --- /dev/null +++ b/src/ex-ch06-02.zig @@ -0,0 +1,30 @@ + +const std = @import("std"); + +pub fn main() !void { + // again, we'll talk about allocators soon! + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + const allocator = gpa.allocator(); + + // create a User on the heap + var user = try allocator.create(User); + + // free the memory allocated for the user at the end of this scope + defer allocator.destroy(user); + + user.id = 1; + user.power = 100; + + // this line has been added + levelUp(user); + std.debug.print("User {d} has power of {d}\n", .{user.id, user.power}); +} + +fn levelUp(user: *User) void { + user.power += 1; +} + +pub const User = struct { + id: u64, + power: i32, +};