Заимствование и владение
Есть одна тема, о которой редко говорят отдельно, хотя она играет ключевую роль. Её понимание поможет вам избежать множества ошибок и проблем, связанных с управлением памятью. Речь идёт о “владении” и “заимствовании” данных. В языках со сборщиком мусора эту тему обычно обходят стороной — разработчику не нужно задумываться о выделении и освобождении памяти вручную. Но в языках без сборщика мусора очень важно точно понимать, кто владеет данными в каждый момент времени и кто их заимствует.
Впервые я серьёзно столкнулся с этим вопросом, когда начал изучать Rust и понял, насколько это важно. В Rust управление памятью напрямую основано на концепциях владения и заимствования, поэтому этим темам уделяется так много внимания. Однако, поработав с этими идеями на практике, я пришёл к выводу: знание этих принципов необходимо даже тогда, когда вы работаете с другими языками программирования, где нет встроенной поддержки владения (borrow checker) и заимствования (lifetimes).
Прежде чем разобраться, что такое владение, необходимо рассмотреть несколько базовых понятий — в частности, копирование переменных и область жизни переменной.
Область жизни переменной (lifetimes)
Каждая переменная в программе имеет свою “область жизни”, которая может быть явной или неявной.
-
Явная область жизни — это ситуация, когда срок жизни переменной строго ограничен определённым блоком кода. Например, локальная переменная, созданная внутри функции, существует только в рамках выполнения этой функции и уничтожается сразу после её завершения.
-
Неявная область жизни возникает, когда срок жизни переменной определяется более сложной логикой, например, временем существования бизнес-процесса в системе. В этом случае переменная должна существовать до окончания бизнес-процесса. Формальный владелец переменной — сам бизнес-процесс, однако в коде этот процесс может быть выражен неявно: через цепочку структур и переменных, которые передаются друг другу по ходу выполнения процесса. При этом переменная может менять своего владельца несколько раз.
Однако в любой момент времени у переменной может быть только один владелец и передача владения всегда должна быть явной. Именно это правило лежит в основе концепции безопасного управления памятью без использования сборщика мусора.
Давайте рассмотрим классический пример проблемы, где нарушается правила владения переменной и это приводит к ошибке:
const std = @import("std");
const User = struct {
name: []const u8,
age: u8,
};
fn badExample(name: []const u8, age: u8) *User {
var user = User{
.name = name,
.age = age,
};
return &user;
}
pub fn main() !void {
const user1 = badExample("Alice", 30);
const user2 = badExample("Bob", 25);
std.debug.print("user 1: {s}\n", .{user1.*.name});
std.debug.print("user 2: {s}\n", .{user2.*.name});
}
Если мы запустим этот пример, то, скорее всего, увидим не имена двух наших пользователей, а случайный мусор. Всё дело в том, что в нашем примере нарушается правило владения переменной. В функции badExample
переменная user
принадлежит самой функции, точнее — её телу. То есть владелец переменной user
— функция badExample
. Когда выполнение функции завершается, переменная уничтожается. Точнее говоря, уничтожается стековый кадр, в котором хранились все локальные переменные функции.
Однако в примере мы возвращаем указатель на переменную user
, то есть мы заимствуем нашу переменную user
у нашей функции badExample
. Заимствование — это передача доступа к ресурсу без передачи владения. В Zig это обычно делается через:
- Срезы ([]T) — ссылка на часть массива.
- Указатели (*T) — но без права освобождения памяти.
И у заимствования есть два важных момента:
- Заимствование не должно переживать владельца.
- Изменяемое заимствование должно быть уникальным (аналог &mut в Rust).
И в нашем случае такое заимствование переживает сам объект user
, так как после окончания выполнения функции badExample
, переменная user
уничтожается. Это приводит к тому, что по нашему указателю могут находиться данные другого объекта или просто случайный мусор. Именно это мы и наблюдаем при запуске примера.
В современных языках программирования вы, скорее всего, не столкнетесь с этой проблемой: например, Rust укажет на неё ещё на этапе компиляции, а компилятор Go автоматически решит её за вас, переместив данные в кучу и тем самым продлив их срок жизни. Но язык Zig не принимает никаких решений за вас, так что нам прийдется решать эту проблему самим.
Для того, чтобы избежать этой проблемы у нас есть два решения - копирования данных или передача владения переменной. Копирование наверно самая простая концепция, но при этом у нее есть свои минусы. Если у нас есть какой-то объект и нам нужно сохранить его жизнь в не области видимости его владельца, то мы просто должны скопировать его. Давайте исправим наш пример применив этот подход:
const std = @import("std");
const User = struct {
name: []const u8,
age: u8,
};
fn badExample(name: []const u8, age: u8) User {
const user = User{
.name = name,
.age = age,
};
return user;
}
pub fn main() !void {
const user1 = badExample("Alice", 30);
const user2 = badExample("Bob", 25);
std.debug.print("user 1: {s}\n", .{user1.name});
std.debug.print("user 2: {s}\n", .{user2.name});
}
Теперь в нашем примере мы возвращаем не указатель на объект, а сам объект, что приводит к копированию данных. Мы больше не заимствуем данные у нашей функции. Правда с копированием надо быть осторожными и понимать как оно работает. В случае языка Zig копирование структуры данных это поверхностное копирование. И если об этом забыть, то это может привести к утечкам или ошибкам, когда ваше приложение будет паниковать. Давайте рассмотрим пример:
const std = @import("std");
const User = struct {
name: []const u8,
age: u8,
};
fn badExample(name: []const u8, age: u8) !User {
var buf: [20]u8 = undefined;
const fullName = try std.fmt.bufPrint(&buf, "User {s}!!!", .{name});
const user = User{
.name = fullName,
.age = age,
};
return user;
}
pub fn main() !void {
const user1 = try badExample("Alice", 30);
const user2 = try badExample("Bob", 25);
std.debug.print("user 1: {s}\n", .{user1.name});
std.debug.print("user 2: {s}\n", .{user2.name});
}
Мы немного изменили наш пример, чтобы показать проблемы, связанные с копированием. Теперь в функции мы формируем полное имя пользователя. Однако буфер buf
всё ещё остаётся локальной переменной для нашей функции, и освобождается при выходе из нее. В результате при возврате объекта из функции будет скопирован только адрес, по которому лежат данные имени, но не само имя. Поскольку выделенная под имя память очищается, возникает проблема: при запуске приложения оно выведет мусор.
zig build run
user 1: +Jop+Jo
user 2: +Jop+J
Второй недостаток копирования заключается в том, что мы вынуждены дублировать использование памяти, даже если в нашем случае в этом нет необходимости. Конечно, если объекты небольшие или их немного, это не так критично. Но что если объектов будет много и вам придётся создавать несколько их копий? Тогда это может стать проблемой, поэтому давайте рассмотрим другой способ решения этой проблемы.
Второй способ решения проблем с владением данных — передача владения другому объекту. В этом случае наша задача — передать владение объектом и больше не использовать его данные, а также не заботиться об освобождении его ресурсов. Давайте рассмотрим пример:
const std = @import("std");
const User = struct {
name: []const u8,
age: u8,
};
fn badExample(allocator: std.mem.Allocator, name: []const u8, age: u8) !*User {
const user = allocator.create(User) catch return error.OutOfMemory;
user.* = User{
.name = name,
.age = age,
};
return user;
}
pub fn main() !void {
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const user1 = try badExample(allocator, "Alice", 30);
defer allocator.destroy(user1);
const user2 = try badExample(allocator, "Bob", 25);
defer allocator.destroy(user2);
std.debug.print("user 1: {s}\n", .{user1.*.name});
std.debug.print("user 2: {s}\n", .{user2.*.name});
}
В новом примере мы снова возвращаем указатель на объект из функции, но теперь это не заимствование данных, а передача владения. В отличие от предыдущей реализации, теперь мы создаём структуру с помощью аллокатора. Поскольку объект создаётся в куче, после завершения работы функции он не будет уничтожен. Но теперь появляется другая особенность: так как мы передали владение объектом вызывающему коду, освобождение ресурсов этого объекта также становится его ответственностью. Именно поэтому в вызывающем коде появляется освобождение памяти через аллокатор.
Один из основных недостатков передачи владения заключается в том, что по коду не всегда очевидно: объект передаётся по ссылке потому, что мы хотим изменить его, или потому, что передаём владение. В языке Zig нет встроенного контроля работы со ссылками, поэтому эту проблему приходится решать вручную. Причём система типов не помогает в таких случаях, и зачастую приходится полагаться на комментарии в коде. Единственное, что можно взять за правило: если функция принимает указатель, но не изменяет переданные данные, используйте тип *const T
, чтобы явно отразить это в сигнатуре функции.
Конечно приведенные здесь примеры довольно простые, но они могут возникать и в более сложном коде, где их будет труднее обнаружить. Давайте рассмотрим следующий пример кода:
const std = @import("std");
const Allocator = std.mem.Allocator;
pub fn main() !void {
var gpa = std.heap.DebugAllocator(.{}).init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const res1 = Collection.init(allocator);
defer res1.deinit();
const res2 = Collection.init(allocator);
res2.deinit();
const warning = try std.fmt.allocPrint(res1.allocator, "{s}\n", .{"Something"});
std.debug.print("{s}\n", .{warning});
}
const Collection = struct {
arena: std.heap.ArenaAllocator,
allocator: Allocator,
fn init(parent_allocator: Allocator) Collection {
var arena = std.heap.ArenaAllocator.init(parent_allocator);
const allocator = arena.allocator();
return Collection{
.arena = arena,
.allocator = allocator,
};
}
fn deinit(self: Collection) void {
self.arena.deinit();
}
};
На первый взгляд в нем все кажется хорошо, но его запуск приведет к падению программы. И ошибка тут все таже - время жизни arena
ограничено временем жизни нашей функции init
. Когда мы вызываем метод arena.allocator()
мы заимствуем allocator
из arena
, который будет ссылаться на arena
, но она будет уничтожена при выходе из функции init
. Поэтому очень важно каждый раз следить за тем какое время жизни у ваших объектов и есть ли кто-то кто заимствует ваш объект.
Как можно исправить эту ошибку? Мы можем создать arena
в глобальной области видимости и передавать его в функции, которые его используют. Это позволит нам управлять временем жизни arena
и избежать ошибок. Или, если нам все же надо чтобы наш аллокатор был доступен внутри нашей структуру Collection
, мы можем создать его на куче:
const std = @import("std");
const Allocator = std.mem.Allocator;
pub fn main() !void {
var gpa = std.heap.DebugAllocator(.{}).init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const res1 = try Collection.init(allocator);
defer res1.deinit(allocator);
const res2 = try Collection.init(allocator);
res2.deinit(allocator);
const warning = try std.fmt.allocPrint(res1.allocator, "{s}\n", .{"Something"});
std.debug.print("{s}\n", .{warning});
}
const Collection = struct {
arena: *std.heap.ArenaAllocator,
allocator: Allocator,
fn init(parent_allocator: Allocator) !Collection {
var arena = try parent_allocator.create(std.heap.ArenaAllocator);
arena.* = std.heap.ArenaAllocator.init(parent_allocator);
const allocator = arena.allocator();
return Collection{
.arena = arena,
.allocator = allocator,
};
}
fn deinit(self: Collection, parent_allocator: Allocator) void {
self.arena.deinit();
parent_allocator.destroy(self.arena);
}
};
Теперь наш код работает, но нам приходится передавать основной аллокатор теперь и в функцию deinit
, чтобы очищать память, занимаемую нашим внутренним аллокатором.
Владение в коллекциях данных
Давайте теперь рассмотрим коллекции данных такие как ArrayList
, HashMap
, LinkedList
и другие. И рассмотрим, как владение и заимствование работает с этими структурами данных.
Когда мы помещаем наши данные в коллекцию, например в HashMap
, то наша коллекция владеет нашими данными и надо это помнить и понимать как с этим работать. Давайте рассмотрим ряд проблем, которые часто возникают при работе с коллекциями данных.
Если мы при работе с коллекцией, например с HashMap
, получаем ссылку на значение нашей коллекции, то такая ссылка может стать невалидной со временем. Рассмотрим пример, когда ссылка на значения коллекции становится невалидной:
const std = @import("std");
const User = struct {
id: usize,
name: []const u8,
};
pub fn main() !void {
var gpa = std.heap.DebugAllocator(.{}).init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var collection = std.HashMap(usize, User, std.hash_map.AutoContext(usize), std.hash_map.default_max_load_percentage).init(allocator);
_ = try collection.put(1, User{ .id = 1, .name = "Alice" });
_ = try collection.put(2, User{ .id = 2, .name = "Bob" });
const borrowed = collection.getPtr(1);
for (2..12) |i| {
collection.put(i, User{ .id = i, .name = "User" }) catch unreachable;
}
std.debug.print("Item: {}\n", .{borrowed.?.*});
}
Поскольку в нашем примере коллекция HashMap
владеет хранимыми данными, ссылки на эти данные могут стать невалидными, если структура хранения внутри коллекции изменится. Это может произойти по разным причинам. В нашем случае ссылки становятся невалидными из-за перераспределения памяти (реаллокации), происходящего при добавлении новых элементов, что может привести к перемещению уже существующих данных в другое место в памяти.
Однако это не единственный сценарий, при котором ссылки теряют свою актуальность. Например, если элемент удаляется из коллекции, все ранее полученные ссылки на него тоже становятся невалидными. Такая ситуация особенно критична в многопоточных приложениях: один поток может получить ссылку на элемент, а другой — одновременно удалить этот элемент из HashMap
. В результате первая ссылка будет указывать на уже освобождённую или переиспользованную память и ее использование приведёт к неопределённому поведению.
Чтобы избежать подобных проблем, важно учитывать, что содержимое коллекции может меняться. Один из подходов — не сохранять долгоживущие ссылки на элементы коллекции, а получать ссылку в момент использования. Альтернативный подход — хранить в коллекции указатели (в том числе с подсчетом ссылок) на данные, и самостоятельно управлять временем жизни этих объектов, тем самым контролируя их создание и удаление независимо от изменений в самой коллекции. Давайте рассмотрим пример работы с указателями:
const std = @import("std");
const User = struct {
id: usize,
name: []const u8,
};
pub fn main() !void {
var gpa = std.heap.DebugAllocator(.{}).init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var collection = std.HashMap(usize, *User, std.hash_map.AutoContext(usize), std.hash_map.default_max_load_percentage).init(allocator);
defer collection.deinit();
const user1 = try allocator.create(User);
defer allocator.destroy(user1);
user1.* = User{ .id = 1, .name = "Alice" };
const user2 = try allocator.create(User);
defer allocator.destroy(user2);
user2.* = User{ .id = 2, .name = "Bob" };
_ = try collection.put(1, user1);
_ = try collection.put(2, user2);
const borrowed = collection.get(1).?;
for (2..12) |i| {
const user = try allocator.create(User);
defer allocator.destroy(user);
user.* = User{
.id = i,
.name = "User",
};
collection.put(i, user) catch unreachable;
}
std.debug.print("Item: {}\n", .{borrowed.*});
}
Так как теперь мы храним в нашей хеш-карте только ссылки на наши объекты, наша хеш-карта не владеет нашими значениями, а просто заимствует их. Поэтому теперь управлять освобождением наших ресурсов мы должны самостоятельно. Более того нам теперь необходимо самим синхронизироваь время жизни нашей хеш-карты и объектов помещенных туда, так как может произойти ситуация, когда хеш-карта может пережить объекты, которые она хранит.
Второй важной особенностью использования хеш-карт является то, что хеш-карта не отвечает за освобождение вложенных объектов, в те объекты что она хранит. Давайте рассмотрим пример:
const std = @import("std");
const User = struct {
id: usize,
name: []const u8,
};
pub fn main() !void {
var gpa = std.heap.DebugAllocator(.{}).init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var collection = std.StringHashMap(User).init(allocator);
defer {
var it = collection.valueIterator();
while (it.next()) |value_ptr| {
allocator.free(value_ptr.name);
}
collection.deinit();
}
const name1 = try allocator.dupe(u8, "Alice");
const name2 = try allocator.dupe(u8, "Bob");
_ = try collection.put(name1, User{ .id = 1, .name = name1 });
_ = try collection.put(name2, User{ .id = 2, .name = name2 });
std.debug.print("Item: {s}\n", .{collection.get(name1).?.name});
}
Многие разработчики, пришедшие из Rust, привыкли, что освобождение памяти происходит автоматически при уничтожении объекта и рекурсивно охватывает всё дерево вложенных объектов. В результате, работая с коллекциями в языке Zig, они нередко забывают освобождать память, выделенную внутри самих объектов. Важно помнить, что в Zig освобождение памяти не происходит автоматически при уничтожении объекта — вы должны вручную управлять всей ресурсной очисткой.
В нашем примере мы имитируем выделение памяти для хранения имени внутри объекта User. Поскольку имя выделяется вручную через аллокатор, при уничтожении объекта User мы также обязаны вручную освободить связанную с ним память. Это особенно важно при работе с коллекциями: перед тем как вызвать collection.deinit(), необходимо пройтись по всем элементам коллекции и освободить ресурсы, которые были выделены для каждого пользователя — в нашем случае, строки с именами. Для этого используется allocator.free(), которым нужно явно освободить память для каждого имени.
Иными словами, управление ресурсами в Zig требует чёткой дисциплины: если вы выделили память вручную, вы обязаны её освободить. Иначе — получите утечку памяти.
Заключение
Понимание концепций владения и заимствования в Zig имеет фундаментальное значение для написания безопасного, эффективного и надежного кода. В отличие от языков со сборщиком мусора, Zig требует явного управления памятью, что делает эти концепции особенно важными.
Хотя Zig не имеет встроенного анализатора заимствований как Rust, понимание принципов владения и заимствования поможет вам избежать множества проблем с управлением памятью, таких как:
- Использование освобожденной памяти (use-after-free)
- Двойное освобождение памяти (double-free)
- Утечки памяти
- Обращение к недействительным ссылкам