How to express ownership semantics in zig?

I’m new to zig, and when dabbling in a test project, I came across the ownership and lifetime considerations. How does one express those in zig?

For example, if I have a type that should not be copied around to prevent double freeing or other invalid states, how would I achieve that? Another example would be a type that must be explicitly copied to prevent bugs related to incorrect lifetimes. In other words, how to enforce explicit copying via a function?

1 Like

Ownership is easy. But lifetime stuff is nowhere ‘enforced’

const Owner = struct
{
    // some stuff
};

const Thing = struct
{
    owner: *Owner, // reference to the owner

    fn init(owner: *Owner) Thing
    {
        return Thing { .owner = owner };
    }
}

So, If I understand correctly, lifetimes are purely by convention?

Something like:

const HeapAllocatedStruct = struct { ... };

pub fn consume_ptr(ptr: *HeapAllocatedStruct) void {
    allocator.free(ptr);
}

pub fn main() !void {
    const ptr = try allocator.create(HeapAllocatedStruct);
    defer allocator.free(ptr); // double free

    consume_ptr(ptr);
}

That example is a very simplified version of the erroneous behavior, but situations like that can arise with complex code and owned pointers without explicit requirements for type copying and usage (imagine the pointer was inside a struct that has ownership).

There’s a proposal to introduce the concept of pinned values. I don’t remember if it got accepted, but in any case this is a concept that has good chances of becoming a language feature.

Other than that, “ownership” is but one way of looking at memory management that leads you towards ways of thinking that at times detract from the range of possible (and valuable) things you could make the computer do. That does come with its set of tradeoffs (modeling memory management as variables being “owned” by a component does lead to some “comfortable” patterns), but if you never tried it, it’s definitely worth a shot.

As a concrete example, memory arenas are one way of managing memory that both works extremely well in a context where there is no language feature to express ownership and are much more efficient at both allocating and deallocating memory than doing single-item allocations with OOP-style “ownership” semantics.

If you want to get philosophical about it, I would say that the only true owners of memory are allocators.

I guess the manner of allocation could really be up to the use case and implementation, if we think outside the single allocation box.

If we shift the focus from “Who owns the memory?” to “How to keep track of allocated memory, and who has the privilege to deallocate memory that’s no longer in use?”, how would I find the answer to the question?

Hm… sounds like I really do not like that. Didnt dive into it though.
I did not leave Rust without reasons.

Entirely based on conventions, so you would have to read doc comments of the involved types. As an example check out the docs for ArrayList.toOwnedSlice.

One important thing is that while reading about this stuff in comments is clearly more error prone than having a language feature enforce a usage pattern, the point here is that you will find plenty of situations where overriding default ownership semantics is genuinely the optimal solution for your use case.

To bring back the arena allocator example, if you place the arraylist in one, toOwnedSlice will downsize the array list buffer, return a slice to it and reset the ArrayList, but the memory will still be part of the arena so despite what toOwnedSlice seems to suggest, the arena-based memory management pattern is the one that will be one actually in action (i.e. to free that memory you will have to reset the arena).

This is to say that consumers of APIs are expected to be able to tweak these things. Said this way it might seem a bit chaotic but in practice you can usually get Zig libraries to play nice to your allocation scheme.

1 Like

You, the programmer.
Zig gives you complete freedom and thus resonsibility in my opinion.

In 99% of the cases don’t we just make the structs (objects in other languages) the owner of its data? (create destroy, init deinit)?

A pinned type annotation just means that the bytes aren’t allowed to move. It makes the address of the pointer to the structure a part of the type instance: once that address is created, it’s invalid to copy the data, in whole, to another instance of that type.

That can be pretty useful. Think of it like const: instead of “you can’t change these bytes with this pointer” it’s “you can’t copy these bytes to another instance of this type with this pointer”. Just another instance of the compiler helping you uphold an invariant your code needs to have, for whatever reason.

In Rust it’s a Trait, and. I’m having trouble saying anything nice here so I’m going to stop. But as proposed it’s a way of making sure that @fieldParentPtr doesn’t get your code rekt, that’s it.

5 Likes

Thanks. I’ll look into that link.
For me always (in zig syntax) a *const Other (ref) or *const Owner (tree) was sufficient and use my own brain to ensure that thing is valid (lifetime).

1 Like

Lifetimes are about temporal memory safety, pinning is about spacial memory safety.

It’s generally accepted that the compiler can’t be accurate about lifetime tracking without adding them to the type system, and with Rust as the example of that, it’s a pretty heavyweight thing to do. Zig has decided that memory policy belongs to the author / maintainer of the code, period. It does a lot to make getting this correct pleasant, in comparison to other languages, but ownership and lifetime bugs are allowed to happen.

The idea is that in safe modes, spacial† and temporal memory violations will all trap and panic. That vision isn’t fully realized, but it’s come a long way. One still has to test and exercise the code thoroughly enough to have some confidence that, if those conditions can be made to exist, that state has been reached by tests and corrected.

Pinning hasn’t been implemented, and that isn’t easy to do it either. But it can (probably) be handled at the type-definition level, as proposed, and work correctly. So that’s a feature which can improve code correctness, which we only pay for when we’re using it (and we don’t pay at runtime either). I see that as strictly beneficial.

† Use of a cooperative safe-mode allocator is required for temporal violations (use-after-free, double free) to be detected.

4 Likes