I think of myself as someone who is reasonably familiar with Zig, but I cannot understand, how an empty arraylist being deinitialized does not cause a crash from within the allocator.
Zig Documentation unconditionally frees allocatedSlice, which is just a slice of items, which is in an empty list items: Slice = &[_]T{}.
(Most?) Zig allocators will ignore a zero-length slice when passed to free. The ones in the standard library don’t rely on the allocation length being stored as metadata alongside the allocation itself, so need the length passed to them on free. If the slice is zero length, there’s nothing to do.
When you call free on an allocator this code in std.mem.Allocator will get run:
/// Free an array allocated with `alloc`.
/// If memory has length 0, free is a no-op.
/// To free a single item, see `destroy`.
pub fn free(self: Allocator, memory: anytype) void {
const Slice = @typeInfo(@TypeOf(memory)).pointer;
const bytes = mem.sliceAsBytes(memory);
const bytes_len = bytes.len + if (Slice.sentinel() != null) @sizeOf(Slice.child) else 0;
if (bytes_len == 0) return;
const non_const_ptr = @constCast(bytes.ptr);
@memset(non_const_ptr[0..bytes_len], undefined);
self.rawFree(non_const_ptr[0..bytes_len], .fromByteUnits(Slice.alignment), @returnAddress());
}
In particular the if (bytes_len == 0) return; line is of interest to you. This means that the interface in itself just does a nop if you give a zero length slice (of an ArrayList) to free. The rawFree call at the bottom is where the allocator implementation is then called.
In general the std source code is quite readable, so don’t be afraid to look into it.
Followup question: Is there any difference between &.{} and &[_]T{}? Not afaik, but yet again I dont get why it was written the more complicated way, though it is very plausible that it just happened to be written this way.
The . in &.{} is just a shorthand for cases where the type can be inferred. It’s similar to how you can do const foo: Foo = .{ .x = x}; for structs instead of const foo: Foo = Foo{ .x = x};. You usually see it in cases where a function wants a slice and you want to pass it at compile time like in a build.zig:
The latter explicitly gives a pointer to an 0 length array of T.
The former infers the type, if there is no type to infer then it will be a pointer to a tuple with no fields.
That will never be an important distinction, as such a tuple can coerce to any zero length array, and a pointer to one can coerce to any slice.
But can’t coerce to a pointer to a zero length array, which is an odd exception, perhaps they just forgot about it.
A more important, but still rare, distinction is explicitly specifying the type will create a new instance on the stack, even if assigning to an existing variable, whereas inferring the type will, if possible, write directly to where it is being assigned.
Which results in different behaviour here:
const S = struct { x: u8, y: u8 };
var a = S{ .x = 1, .y = 2};
a = S{ a.y, a.x }; // swaps x and y :)
// a = S{ .x = 2, .y = 1 }
a = .{ a.y, a.x }; // a.y is immediately written to `x` so the following `a.x` gives the new value from y, instead of its old value
// a = S{ .x = 1, .y = 1 )
in one of the ghostty build script files i saw some initialization like &[_][]const u8{ // strings } and shuddered in horror at what the old days of Zig must have been like.