Zinc regions
The most distinctive feature of Zinc is the capability system, which allows you to pass some arguments implicitly just because the function is called inside a with block. Regions are what you get when the compiler also tracks which block a value came from.
Note: Capabilities work today; regions are designed and not implemented yet, so treat the second half as a preview.
The most common usage is with the memory management system. Every program needs an allocator. In C you use malloc and free most of the time, but in modern systems programming languages like Zig or Odin you need an explicit allocator object to allocate and free the memory. The allocator could be a simple malloc wrapper, an arena allocator, or a wrapper that track all allocations and frees them all at once.
There are different strategies based on the needs of the program you're writing, Zig requires an explicit allocator everywhere, which is very annoying: you have to pass a parameter that adds noise to the function signature. I understand that Zig aims to be very explicit, but I want something more ergonomic for my language.
Odin, instead, has a special context object that is passed implicitly to every function and contains the allocator. It's very powerful, but it costs an extra parameter on every function call. It's not a big cost, bug for a language that aims to be explicit, it hides too much. My proposal sits somewhere between the two:
main :: fn () i32 {
with allocator := Allocator.new() {
myObj: *MyStruct = new(sizeof MyStruct)
use(myObj)
}
return 0
}
new :: fn (bytes: usize) *u8 with allocator: Allocator {
return allocator.alloc(bytes)
}
The idea is that functions called inside the with block don't need the parameter to be passed explicitly: the compiler passes it for you.
It's worth saying that Allocator is a facet (Zinc's structural interface that captures the object and its method table). So a general purpose allocator and an arena allocator both satisfies with Allocator so that you can switch allocator just by changing one line. If the compiler doesn't found what a function asks for, that's an error and there is no implicit global allocator hiding anywhere.
A function can declare several of these special parameters, which I call capabilities, although they're just normal parameters. Naming the capability in the function signature is optional, since the function block may only need to forward it to the functions it calls without using it directly:
// capability allocator passed without the name
new_user :: fn () *User with Allocator {
self: *User = alloc(sizeof User)
return self
}
Blocks can be nested to provide several capabilities at once, and when two blocks provide the same type, the compiler picks the innermost one, so a capability can be overridden at any point:
with outer := arena.&.allocator() {
with _ := frame.&.allocator() {
a := alloc(64) // frame, the innermost one
b := outer.alloc(64) // arena, asked for by name
}
}
Any object can be a capability: the allocator is the most common case, but a File or a Socket or a normal integer works just as well.
Only functions that declare a capability get the hidden parameter, so a signature with no capability list is exactly what it looks like. And a capability with no fields is erased completly:
Syscall :: struct { } // zero cost at runtime
It costs only a compile-time check.
Capabilities also work as a privileges: a function that requires a capability can only be called where that capability is in scope. For example, a foreign function can be capability declared without restrictions, or with a capability that simply means "this capability must be in scope to call this function":
Syscall :: struct { }
sys :: foreign with Syscall {
write: fn (i32, *u8, usize) isize
read: fn (i32, *u8, usize) isize
open: fn (*char, i32) i32 with Fs
}
libm :: foreign { // pure, nothing to declare
sqrt: fn (f64) f64
}
main :: fn () i32 {
buff := "hello world"
libc.write(1, buff.ptr, buff.len) // Error: capability Syscall not found in the current scope
with sc := Syscall{} {
libc.write(1, buff.ptr, buff.len) //allowed
}
return 0
}
Capabilities have many use cases, but the feature is entirely optional: every parameter can still be passed explicitly, as in other languages.
Regions come from a simple idea: if I have an allocator that is only valid in a certain scope, then all the variables that use the allocator should be invalid outside that scope, because I need the allocator to free the memory, outside the scope the allocator is invalid.
buff: *u8 = none
with _ := Allocator.new() {
buff = new(sizeof MyStruct) // this memory is inly valid inside this scope
}
// is buff still valid here!?!
The idea is that every type carries its provenance: the compiler knows where each value comes from and check whether that provenance is still valid in the current scope. That provenance is not a side table, it's part of the type. There is one pointwer former, *T @ r a pointer into region r and writing a base *T just infers r = raw, the top region that outlives everyting: globals, literals, malloc results and foreign returns are all @ raw with no syntax at all. You only ever see the @ when you write it yourself.
Once the region is in the type, the whole check collapses into one rule: *T @ r can only be spelled where the name r is in scope. Leaving the block deletes the name, so an escape stops being a dataflow analysis and becomes an ordinary undefined-name error:
buff: *u8 = none
with allocator := Allocator.new() {
buff = new(sizeof MyStruct) // Invalid provenance: @block and @allocator have different lifetimes
}
The variables that don't use any capabilities don't have a provenance: an integer or a struct value don't need the provenance since they are passed by value and their lifetime is not a problem. If you are trying to take the address of a variable:
test :: fn () *i32 {
x := 10
return x.& // compiler error
}
This is because when you take the address of a variable its provenance is the block where it's declared and outside that block the address is not valid. Every lexical {} is a region on the same stack as with, which is the one place regions are not optional, and this simple rule solves the problem of returning a stack address. Interior pointers follow the container rather than the block, so myObj,field.& takes myObj's region, not the region of the line it's written on.
Note that a pointer trapped by r does not trap the memory it points at. Copying the pointee out is fine, because the copied value does not have an @ r anywhere in its type.
test :: fn () MyStruct {
result = MyStruct{}
with _ := Allocator.new() {
tmp := alloc(sizeof MyStruct)
fill(tmp)
result = tmp.* // copies bytes, nothing escapes
}
return resul
}
Sometimes you don't know exactly what's the provenance of a variable because it depends on a runtime value:
with r := Allocator.new() {
val := new_val() // inferred type: *MyStruct @ r
with s := DifferentAllocator.new() {
val2 := new_val() // inferred type *MyStruct @ s
max(val, val2) // @ a or @ b !?!
}
}
max :: fn (a: *Node, b: *Node) *Node {
if a > b return a
else return b
}
The compiler cannot know which one comes back, so it takes the innermost region among the arguments and the capabilities of the call, here @ b. That's always sound, since a outlives b and trating @ a value as @ b can't dangle; it's just too conservative, so it'll sometimes reject code that would have been fine. When you want the exact answer you can write it, because @ r is a syntax allowed for types and max can declare the regions in its signature:
max :: fn (a: *Node @ r, b: *Node @ r) *Node @ r { ... }
Inference is the dfault and the annotation is there when the default is too conservative.
To make this more ergonomic the compiler allows casts along the outlives order. going from raw to r is safe and explicit, because a value that outlives everything is usable wherever something shorter-lived is wanted, so nothing is written. Going in the other way, out to a longer-lived region or all the way to raw is the unsafe path: you write the cast and you own what happens, just like C pointers.
Then every capability can implement a special Region facet which lets it initialize and deinitialize at the start and end of the with block:
Region :: facet {
init: fn () u0
deinit: fn () u0
}
Allocator :: impl(Region) {
...
}
with r := Allocator.new() { // r.init() called here
...
} // r.deinit() called here
So this could be very useful for different scenarios like the arena allocator which requires one big allocation and frees all the memory at once; also with files that require open and close:
with f := File.new("path/to/") { // .init() is just f.open()
} // .deinit() is just f.close()
The deinit method is fired like a defer statement to ensure that it's fired also with return/break/continue statements.
So the compiler helps you to handle resource cleanup but it doesn't do the work for you:
It tells you when an expression has outlived its scope, and it never frees by the compiler automatically.
You have the Region facet and defer and with them most cases are covered, but there's still some cases that need a solution:
The first one is a fallible init. File.open could fail and Transaction.begin can fail too but the signature is init: fn () u0 which has nowhere to put that. Making it return an error type turns with into something that can fail, so i need a syntactic form to handle the result but it ruins the syntax. The same for deinit.
Another problem is about resources whose lifetime is not a block (connection pool, cache ...). Today the workaround is @ raw and C-level responsibility which removes all the 'safety'.
Note: capabilities work today in the zinc compiler but regions are just a proposal, a sketched idea that probably I'm going to implement.
The source code: zinc