Object pools with structs and arrays of varying sizes by using `std.mem.bytesAsValue/Slice`

I’ve been experimenting with object pools that allow you to mint arbitrary types, very much inspired by Zig’s InternPool. Like InternPool, the backing buffer is []u32 and the index is u32.

The first API I added was addUndefined, which takes a count argument:

pub const PackedData = struct {
    entries: []u32,
    capacity: u32,
    index: u32,

    pub fn addUndefined(
          data: *PackedData,
          allocator: std.mem.Allocator,
          T: type,
          count: u32
    ) !u32 {
        // Ensure space for ⌈@sizeOf(T) * count / @sizeOf(u32)⌉
        // Increment index by that much
        // Return original index
    }
};

That lets me mint variable-length data of varying integer size that can be safely reified into slices, which I fill in later. The way I’m doing that is by doing u32[]std.mem.sliceAsBytesstd.mem.bytesAsSliceu16[] or u8[]. I tested that this is safe:

test "bytes as slice larger than the bytes slice" {
    var array: [2]u32 = undefined;
    const slice: []u32 = &array;
    _ = std.mem.bytesAsSlice(u128, std.mem.sliceAsBytes(slice)); // panics at 8 / 16
}

Next I wanted to mint structs. It looks like InternPool splits up struct fields and puts each one in its own u32 slot. I tried this, but it didn’t work out for me. And since entries already has a len, and @sizeOf(T) is known, shouldn’t std.mem.bytesAsValue be able to safety-check just like std.mem.bytesAsSlice does? It doesn’t currently, because bytesAsValue is implemented merely as a @ptrCast:

const Four = packed struct {a: u16, b: u16, c: u16, d: u16, ohno: u16};

test "bytes as struct larger than the bytes slice" {
    var array: [2]u32 = undefined;
    const slice: []u32 = &array;
    var four = std.mem.bytesAsValue(Four, std.mem.sliceAsBytes(slice)); // no error!
    four.ohno = 1; // traps, but is it safe to get that far?
}

So my questions are:

  1. Why doesn’t mem.bytesAsValue add a check like if (@sizeOf(T) > bytes.len) unreachable;? Or is that not all that’s needed?
  2. Are there better solutions to storing variable sized types together? Has anyone had to implement anything similar?
  1. You also should care at least a little about alignment. pointers of type *T have (implicitly) some alignment data about them which Zig enforces during non-casting operations. The cast in mem.bytesAsValue does not contain an @alignCast, so you should a compile error if you get this wrong.
    Also, yes, that’s not all that’s needed. Many types in Zig are not “total” in the sense that there are bit patterns of @sizeOf(T) bytes which do not represent a valid object of type T. Exhaustive enums which don’t cover the range of their backing integer are a good example of this, as are tagged unions.
  2. It sounds like if you squint for a second you’ll realize that you’re on your way to implementing std.mem.Allocator, which is designed precisely to dole out memory for variably sized types.
1 Like

Thanks for the response! Curiously, std.mem.bytesAsSlice doesn’t check alignment:

test "does bytesAsSlice fail on misalignment?" {
    var array: [4]u8 = undefined;
    const slice: []u8 = &array;
    _ = std.mem.bytesAsSlice(u16, std.mem.sliceAsBytes(slice[1..3])); // no!
}

So bytesAsSlice checks for fit, but not alignment, and bytesAsValue is the opposite. Ideally they both check both, no?

Many types in Zig are not “total”

I see. I tried switching an exhaustive enum where the backing integer isn’t covered, and that is safety-checked, at least.

I did start to think “is this just Allocator with extra steps?”. But if I use an allocator, I’d need 8 bytes to reference an object instead of 4, and I don’t get temporal memory safety. These objects all live together, so I don’t want to have to free them individually. I could use an arena allocator, but it’d be nice if they took up contiguous memory, since they pretty much all get accessed and in logical order. I can’t think of how to make it perfect with an Allocator.

Maybe I’ll implement my own bytesAsValue with length check if it doesn’t sound crazy.

I’m not sure that it is failing. to properly test for that you should actually give the return value type *u16 before discarding it. Probably the function is doing a sensible if slightly perverse thing of returning a pointer of type *align(1) u16