How to Decide When to Free Memory in Complex Allocator Flows

Hello Everyone,

I’m new to lower‑level programming languages, and Zig has been a great introduction. I’ve mostly worked with Python, where memory management is handled for you, so manually managing memory in Zig especially understanding when and how to free it has been challenging for me.

I’ve built a WIP GapBuffer implementation. The snippet below shows a simple insert operation that adds a single character. Internally, it may grow the buffer by allocating new memory and copying the existing data. Another helper, pushRightDataToEnd, rearranges the buffer by copying part of the data to the end of the current allocation

I’m unsure about the correct points to free allocated memory, which is causing both segfaults and leaks. I’ve added comments in the code to show all the places where I attempted to free memory.

Also, I have a pattern in insert method where I have function signature like (self: *GapBuffer, allocator: Allocator, data: []u8, char: u8), where I am passing the struct pointer, allocator, existing data and the character to be inserted. data argument is constant therefore, assign it to a new mutable variable new_data to let it mutate. After all the modifications we return the new_data back to the caller, is this a good pattern?

//! an example to display memory management issue (seg-fault for now)
//! Note, it is not the exact logic to implement gap buffer
//! this is truncated and modified to display of the said issue.
//! I am also not formatting the code to shorten the example by lines.
const std = @import("std");
const Allocator = std.mem.Allocator;
const print = std.debug.print;
/// GapBuffer struct
const GapBuffer = struct {
    gap_start: usize = 0,
    cursor_pos: usize = 0,
    gap_end: usize,
    capacity: usize,
    /// init method
    pub fn init(capacity: usize) !GapBuffer {
        return .{ .gap_end = capacity - 1, .capacity = capacity };
    }
    /// grow method when a buffer is full
    fn grow(self: *GapBuffer, allocator: Allocator, data: []u8) ![]u8 {
        // allolcate a new memory space
        const new_data = try allocator.alloc(u8, data.len + self.capacity);
        const right_data_start = self.gap_end + 1;
        const right_data_end = data.len - 1;
        var right_data: []u8 = &.{};
        if (right_data_start <= right_data_end) { right_data = data[right_data_start .. right_data_end + 1]; }
        // memcpy right data of the gap ends to the end of the new_data []u8.
        @memcpy(new_data[new_data.len - right_data.len ..], right_data);
        self.gap_end += self.capacity;
        // return new_data
        return new_data;
    }
    /// insert a character to the buffer
    pub fn insert(self: *GapBuffer, allocator: Allocator, data: []u8, char: u8) ![]u8 {
        // QUESTION: is it a good pattern?
        // data variable being a constant, use a new variable (new_data)
        // and finally return new data.

        // data being a constant, use a new variable (new_data)
        // so that we can mutate it later
        var new_data = data;
        // if the buffer is full then, free the buffer
        if (self.gap_end == self.gap_start) {
            // grow the buffer
            new_data = try self.grow(allocator, data);
            // QUESTION: free the buffer? may be not sure
            // allocator.free(new_data);
        }
        // if user wants to insert data at the cursor location
        if (self.cursor_pos != self.gap_start) {
            // push the right side data of the cursor to the end of the buffer
            new_data = try self.pushRightDataToEnd(allocator, new_data);
            // QUESTION: free the buffer? may be not sure
            // allocator.free(new_data);
        }
        // finally, assign the character at the gap start
        new_data[self.gap_start] = char;
        self.gap_start += 1;
        self.cursor_pos = self.gap_start;
        // return the new buffer data
        return new_data;
    }
    /// push the right side data of the cursor to the end of the buffer
    fn pushRightDataToEnd(self: *GapBuffer, allocator: Allocator, data: []u8) ![]u8 {
        const new_data = try allocator.alloc(u8, data.len);
        const left_data: []u8 = data[0..self.cursor_pos];
        @memcpy(new_data[0..self.cursor_pos], left_data);

        const right_data_start = self.cursor_pos;
        const right_data_end = self.gap_start - 1;
        var right_data: []u8 = &.{};
        if (right_data_start <= right_data_end) { right_data = data[right_data_start .. right_data_end + 1]; }
        // copy right of the gap
        const new_right_data_start = data.len - right_data.len;
        @memcpy(new_data[new_right_data_start..], right_data);
        self.gap_start = self.cursor_pos;
        self.gap_end = new_right_data_start - 1;
        return new_data;
    }
};

test "test left cursor movement" {
    const capacity: u8 = 3;
    var gap_buffer = try GapBuffer.init(capacity);
    const allocator = std.testing.allocator;
    const data = try allocator.alloc(u8, capacity);
    // QUESTION: May be free buffer here
    defer allocator.free(data);
    const insert_times: u8 = 3;
    // QUESTION: I also do not like the idea of using a new variable(new_data)
    // can we grow and shrink the original(data) buffer and insert the characters to the original buffer?
    // First set of inserts
    var new_data: []u8 = undefined;
    for (0..insert_times) |i| { new_data = try gap_buffer.insert(allocator, data, @intCast(97 + i)); }
    // QUESTION: May be free buffer here
    allocator.free(new_data);
    // second set of inserts
    for (0..insert_times) |i| { new_data = try gap_buffer.insert(allocator, new_data, @intCast(97 + i)); }
    // QUESTION: May be free buffer here
    allocator.free(new_data);
}

Any help will be really appreciated.

Cheers.

The complexity here comes from the fact that data is separate from the gap buffer and yet partially controlled by it. This will end up causing the creation of many slices. You should store the data in the GapBuffer and let it manage everything.

Once you do this creating the data can be done in init or grow. Freeing can be done in grow (freeing the old data after copying) and when you’re done with the GapBuffer you can call deinit on it.

This will result in only one slice being allocated at a time, except for a brief moment during grow.

I recommend looking at how std.ArrayList is implemented since an array list is effectively a gap buffer where the gap is always at the end.

9 Likes

thank you! like I was doing it before ( WIP Gap Buffer in Zig: Looking for Feedback ).

Yes!

(Post must be at least 10 characters)

1 Like

Below is the update implementation with data being the part of the GapBuffer struct and freeing of the memory in the grow method but I am still getting segfault at 96.

//! an exmaple to display memory management issue (segfault for now)
//! Note, it is not the exact logic to implement gap buffer
//! this is truncated and modified to display of the sanew_dataid issue.
//! I am also not formatting the code to shorten the example by lines.
const std = @import("std");
const Allocator = std.mem.Allocator;
const print = std.debug.print;

/// GapBuffer struct
const GapBuffer = struct {
    gap_start: usize = 0,
    cursor_pos: usize = 0,
    gap_end: usize,
    capacity: usize,
    data: []u8,

    /// init method
    pub fn init(allocator: Allocator, capacity: usize) !GapBuffer {
        const data = try allocator.alloc(u8, capacity);
        return .{ .data = data, .capacity = capacity, .gap_end = capacity - 1 };
    }

    /// grow method when a buffer is full
    fn growGap(self: *GapBuffer, allocator: Allocator) !void {
        // allolcate a new memory space
        const new_data = try allocator.alloc(u8, self.data.len + self.capacity);
        defer allocator.free(new_data);

        // copy left side data to a new data
        @memcpy(new_data[0..self.gap_start], self.data[0..self.gap_start]);

        const right_data_start = self.gap_end + 1;
        const right_data_end = self.data.len - 1;
        var right_data: []u8 = &[_]u8{};
        if (right_data_start <= right_data_end) {
            right_data = self.data[right_data_start .. right_data_end + 1];
        }
        // memcpy right data of the gap ends to the end of the new_data []u8.
        @memcpy(new_data[new_data.len - right_data.len ..], right_data);
        self.gap_end += self.capacity;

        self.data = new_data;
    }

    /// push the right side data of the cursor to the end of the buffer
    fn pushRightDataToEnd(self: *GapBuffer) !void {
        // const left_data: []u8 = self.data[0..self.cursor_pos];
        // left_data = self.data[0..self.cursor_pos];

        const right_data_start = self.cursor_pos;
        const right_data_end = self.gap_start - 1;
        var right_data: []u8 = &.{};
        if (right_data_start <= right_data_end) {
            right_data = self.data[right_data_start .. right_data_end + 1];
        }

        // copy right of the gap
        std.mem.rotate(u8, right_data, right_data_end - right_data_start);
        // const new_right_data_start = data.len - right_data.len;
        // @memcpy(new_data[new_right_data_start..], right_data);
        self.gap_start = self.cursor_pos;
        self.gap_end = self.data.len - right_data.len;
    }

    /// insert a character to the buffer
    pub fn insert(self: *GapBuffer, allocator: Allocator, char: u8) !void {

        // if the buffer is full then, free the buffer
        if (self.gap_end == self.gap_start) {
            // grow the buffer
            try self.growGap(allocator);
        }

        // if user wants to insert data at the cursor location
        if (self.cursor_pos != self.gap_start) {
            // push the right side data of the cursor to the end of the buffer
            try self.pushRightDataToEnd();
        }

        // finally, assign the character at the gap start
        self.data[self.gap_start] = char;
        self.gap_start += 1;
        self.cursor_pos = self.gap_start;
    }
};

test "test left cursor movement" {
    const capacity: u8 = 3;

    const allocator = std.testing.allocator;
    var gap_buffer = try GapBuffer.init(allocator, capacity);
    // defer allocator.free(gap_buffer.data);

    // First set of inserts
    for (0..capacity) |i| {
        try gap_buffer.insert(allocator, @intCast(97 + i));
    }
    print("{s}\n", .{gap_buffer.data});
}

and thanks for suggesting ArrayList however, I want to learn manual memory management therefore, sticking with manual GapBuffer for now.

You’re freeing the new_data after the assignment, that’s why it seg fualt. You need to free self.data before setting it.

const new_data = try allocator.alloc(u8, self.data.len + self.capacity);
// copy
allocator.free(self.data);
self.data = new_data;

To be clear, my suggestion was to study how the ArrayList manages its pointers rather than use it.

1 Like

thanks again! it is still segfaulting but I am too dumb to resolve it :D/

That’s strange! I only get a leak when I run it, fixed when freeing the gap buffer.

1 Like

thank you so much ! it worked. I missed the defer allocator.free(gap_buffer.data); in the main/test function.

    const capacity: u8 = 3;

    const allocator = std.testing.allocator;
    var gap_buffer = try GapBuffer.init(allocator, capacity);
    defer allocator.free(gap_buffer.data);  // missed this line

    // First set of inserts
    for (0..capacity) |i| {
        try gap_buffer.insert(allocator, @intCast(97 + i));
    }
    // Second set of inserts
    for (0..capacity) |i| {
        try gap_buffer.insert(allocator, @intCast(97 + i));
    }
    print("{s}\n", .{gap_buffer.data});

Cheers !

2 Likes