A sketch for ownership in Zig
· read · zig, programming-languages
While Zig doesn’t codify ownership, it still has the concept of ownership, leaving it to documentation instead.
For example, ArrayList.toOwnedSlice
returns an owned slice. Failure to free
this slice with the right allocator may leak memory (unless it’s an arena
allocator, or similar), but Zig doesn’t provide any way to make sure this
happens.
There is an open issue for resource annotations which proposes runtime safety when resources are mishandled, and compile-time errors when the compiler can prove that resources must be mishandled at runtime.
What about something like the rejected resource types proposal: an ownership modifier, similar to the optional (?type
) and error (<error>!type
) modifiers?
Proposal 1
const File = i32;
fn open(path: []const u8) #File {
const fd: File = std.os.open(path);
return #fd;
}
Values must be explicitly wrapped; unary #
operates on both types (to create a resource type wrapper) and values.
Whenever a resource type is in scope, it must be either unwrapped or moved.
fn tell(file: File) void {
// snip
}
fn close(file: #File) void {
// snip
}
// Error: result of `open` is never unwrapped or moved
fn ignoresFile() void {
_ = open("file.txt");
}
// Error: `file` has already been moved
fn closeTwice(file: #File) void {
close(file); // note: first move occurs here
close(file);
}
// Okay --- moves owned file into caller
fn returnsFile() #File {
return open("file.txt");
}
Resource values can be implicitly coerced to their non-owned counterparts:
fn callTell(path: []const u8) {
const file = open(path);
tell(file); // `#File` is implicitly coerced to `File` when it's passed to `tell`, but ownership is not lost --- `file` still contains an owned file!
// `close` takes ownership of `file`.
close(file);
}
And now that ownership has been added to Zig (just like that!) there’s room for a little syntactic sugar: extended defer
.
fn openFileAndVerifyHeader(path: []const u8) !#File {
const file: #File = open(path);
// This defer is only executed if nothing moves `file` before this scope ends!
defer(file) close(file);
var header: [16]u8 = .{ 0 } ** 16;
std.os.read(file, &header);
std.debug.assert(std.mem.eql(u8, header, "awesome header!!"));
return file;
}
And finally, to wrap (hah) this all up, there’s no way to actually unwrap a wrapped value yet. Instead of adding syntax, let’s make it a builtin to discourage its use:
fn close(file: #File) void {
const fd = @unwrapResource(file);
std.os.close(fd);
}
The problem with this design is the flaws that it… has. There are… several… of these flaws. One of these problems is that ownership isn’t “viral”. For example (I do so love examples!), imagine this struct
:
pub const TwoFiles = struct {
file1: #File,
file2: #File,
};
This struct isn’t tracked as a resource, and there’s no good way to track its two files as resources. Fields can’t be moved out of, because the TwoFiles
struct isn’t owned!
var two_files: TwoFiles = [ snip ];
close(two_files.file1); // error: can't move out of non-owned variable `two_files`
And the core thesis of this proposal is that unwrapping a resource is basically a no-op.
var owned_two_files: #TwoFiles = [ snip ];
// I want to deinitialize `owned_two_files`, so:
var two_files = @unwrapResource(owned_two_files);
// ...
Wait a second… this is the same scenario as above! There’s no distinction between a non-owned value and a value that’s in the process of being destructed.
Let’s excise the good parts (extended defer
, mostly) and rework this into something workable.
Proposal 2
Let’s distinguish non-owned types and non-owned copies of owned types with yet another sigil (say, %
, because Zig doesn’t use that for errors anymore!)
This isn’t actually necessary: Rust doesn’t have this concept, and instead uses &
for non-owned pointers (versus %
which would be a non-owned copy).
const File = #i32;
fn open(path: []const u8) File;
fn close(file: File) void;
fn tell(file: %File) usize;
This design lets ownership be viral, and it necessarily forces non-ownership to be viral as well.
// `TwoFiles` is implicitly a resource type!
const TwoFiles = struct {
file1: File,
file2: File,
};
fn readFirst(files: TwoFiles) []const u8 {
return read(files.file1);
}
fn readSecond(files: TwoFiles) []const u8 {
return read(files.file2);
}
fn main() {
const two_files = [ snip ];
readFirst(two_files); // note: first moved here
readSecond(two_files); // error: use of moved variable `two_files`
// note: `TwoFiles` is an resource type because it contains a field of type `File`, which is a resource type
}
To fix this, readFirst
and readSecond
must take non-owned copies instead:
fn readFirst(files: %TwoFiles) []const u8 {
return read(files.file1);
}
// ...
Reading a field of a non-owned structure must result in a non-owned copy, otherwise “ownership laundering” would be allowed.
var two_files: %TwoFiles = [ snip ];
var file1 = TwoFiles.file1; // type: %File; if this were File, then...
var file1_also = TwoFiles.file1; // ...this would also have to be an owned value!
// And this would also be allowed:
fn closeFirst(files: %TwoFiles) void {
close(files.file1);
}
(The %
sigil should be a no-op on primitives and non-owned structures.)
This has been Rust without borrowing, that has been A sketch for ownership in Zig, goodbye.