Заимствование и владение

2025-04-29 2584 13

Есть одна тема, о которой редко говорят отдельно, хотя она играет ключевую роль. Её понимание поможет вам избежать множества ошибок и проблем, связанных с управлением памятью. Речь идёт о “владении” и “заимствовании” данных. В языках со сборщиком мусора эту тему обычно обходят стороной — разработчику не нужно задумываться о выделении и освобождении памяти вручную. Но в языках без сборщика мусора очень важно точно понимать, кто владеет данными в каждый момент времени и кто их заимствует.

Впервые я серьёзно столкнулся с этим вопросом, когда начал изучать 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 это обычно делается через:

И у заимствования есть два важных момента:

И в нашем случае такое заимствование переживает сам объект 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, понимание принципов владения и заимствования поможет вам избежать множества проблем с управлением памятью, таких как:

#borrowing #ownership #zig #zigbook

0%