-
-
Notifications
You must be signed in to change notification settings - Fork 2.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
ability to annotate functions which allocate resources, with a way to deallocate the returned resources #782
Comments
Er... wasn't that something I proposed a few months ago in the discussion on resources and the use of "#" etc? I cannot seem to find the issue :-( |
@kyle-github I think it was #494 |
@Ilariel, Ah, right. Thanks! I looked back, but not that far. I would like to see something like this proposal combined with some of the ideas in #494. I think (not carefully thought through!) that it might be possible to come close to Rust's ownership/borrow checker in power. Perhaps it is too easy to allow escapes for it to be workable, but even 90% coverage would catch a huge number of cases. Determining lifetime is not that simple, however. |
@andrewrk I was imagining something like this though with different keywords, but these are good keywords, too. Perhaps also allow a default standard (like your common self.deinit) so all you have to do is say clean(up) on the function header if you conform? @kyle-github I also have some ideas about the "90% coverage" for borrow checking kind of thing, too, but I'd rather just see 1.0 first. |
I know this is bike shedding, but it reads kind of weird:
Sounds like "cleanly attempt to Maybe something more like What exactly does errclean do? It sounds like "if no function call here annotated with Maybe the default thing should be: if the function "throws" and the resource has not been assigned to any object that lives outside this scope, it's automatically closed, and there's no need to add any annotation for that. If you want the resource to not autoclose (even on errors), that sounds like it needs a special syntax, for example: Another idea is to mention the cleaning strategy after:
And if desired maybe it can be customized:
And if no cleaning is needed:
But at this point it feels like the language is getting too complicated. |
@hasenj Interesting. My keyword plan for autoclean was Personally, I still think the keywords |
On the other hand, with this proposal in place, you could possibly drop the ad hoc |
make it harder to forget to clean up a resource Functions which allocate resources are annotated with the corresponding cleanup function. that is pretty much RAII so why reinvent the wheel? and if you basically add RAII to the language you probably need copy vs move semantics as well |
So the main critique of RAII is that it is type based. That means you need to define a type for each lock/unlock, open/close, allocate/free, etc. Then again, idk a better alternative or how this is any different. |
IMO you want ctor/ dtor and with that RAII semantics sometimes and also want defer keyword other times. Maybe just do it like rust does RAII, which is easier than cpp. |
@monouser7dig This solution does not require any of these features, because either:
|
Also, Rust RAII is easy, because Rust keeps track of ownership for you. Zig does not, so it would have to be as involved as C++. |
Well that is just a stripped down version of RAII
so the first two cases would be covered by the traditional RAII approach and the proposed syntax is just another syntax for doing it as far as I can see. I don't see why you would not just call it what it is. |
@monouser7dig |
I argue what andrew is proposing already is ctor dtor wrapper type and soon also needs to be copy and move. Now as soon as you copy such a type that was returned from „make**“ you need copy and move semantics as welll otherwise this example won’t hold for anything but trivial code. Concerning rust: |
I very much agree with @monouser7dig. As long as Zig aims itself to be an applicable alternative for C, I feel that this feature is too high level to be of good taste. It just feels like unneccesary sugaring to me. The way Zig does resource aquisition/destruction now is nice and elegant, and trying to imitate Rust and C++ here feels like a stab in the back to C-style simplicity of Zig.
Is this actually a problem for anyone? This is a valid concern, but I feel that this problem should only be addressed if it is a real-life problem, not just a hypothetical.
...therefore locking programmers into a single form of deallocation. There are many ways to have a "constructor", and depending on the problem, there may be many ways to have a "destructor" too. It is not the place of Zig (or any sane language) to force one form of resource destruction on the programmer. Zig as a language tries very hard to not hide allocations behind a programmer's back. It must also not hide deallocations either. |
Too complicated |
Not sure that is the correct final answer to the problem Language design might be complicated if it makes the programmers life less complicated in the end. But maybe it’s best to think about it more and start a new proposal in the future. |
I think It would be especially worth to investigate #782 (comment) this issue further because #782 (comment) |
Here's some real actual C code that wants to document ownership semantics for an array of strings returned by a user-supplied function: https://github.com/thejoshwolfe/consoline/blob/2c5e773442f89860f9ee82e13978b5ef3972ca99/consoline.h#L29 if this api were rewritten in zig, would it be possible to encode the desired ownership semantics with this proposal? |
@thejoshwolfe presumably it would by providing a default cleanup wherever caller deallocation is necessary. I guess the assumption is that no caller should free anything provided by a function unless it has a specified clean function. |
Better/more fn create_window(allocator: *std.mem.Allocator) void {
var w: *Window = ...;
suspend {}
allocator.free(w)
}
// allocate the window
var window_context = async create_window();
// get a nicer alias to the window
var w: *Window = window_context.w;
// do whatever with w
...
// dealloc the window
_ = await window_context; This treats the |
@pjz BTW, if #6965 is accepted, then one of the main applications would be proper context-wrapping macros, so we could implement a python-like with_window(allocator, "name", |w| {
// do something with w
});
// closing the window is handled by the macro without bringing async into this. |
@zzyxyzz
I hadn't read #6965 before (thanks for that, BTW), but I find that while I applaud the goal, the means end up feeling bit too abstract. What if, instead, we could just pass around stack frames or pointers to stack frames directly? // allocate the window
var window_context = async create_window();
// dealloc the window
defer { _ = await window_context; };
// get a nicer alias to the window
var w: *Window = window_context.w;
// do whatever with w
... And while, sure, I'd love some syntactic sugar to make the async/defer await bits nicer, the zen of zig is not in favor of that. var window_context = async create_window() deferred; with |
I'm new to Zig and I really like some of the things that people mentioned about simplicity within this language. While the explicit mem management that Zig offers is great, I for one would welcome this feature. Heres my take, Rust like borrow checking is awesome but it comes with its own overhead as people here have mentioned. So making it a language standard would probably upset many, especially those using zig on constrained devices. So what if within Zig there was an abstract that could be attached to blocks where they would be attached dispatched and executed within the frames of this borrow checker? The abstraction would oversee and 'own' all the data defined within its scope. |
since pub inline fn allocAndClean(self: Allocator, comptime T: type, n: usize) Error![]T {
defer foo(); // this would run at the end of 'allocAndClean'
inlinedefer bar(); // this would run at the end of 'allocAndClean' caller's scope
// eg:
var buf = self.alloc(T, n);
inlinedefer self.alloc.free(buf);
return buf;
} edit: this wouldnt solve the general case though when there's another function that calls |
Here's a possibility for implementing this with just 2 new keywords. To start with, I think that the declaration syntax described in the first comment works fine: fn create(self: &Allocator, comptime T: type) !&T
cleanup self.destroy(_)
{...} When used in a defer obj.cleanup;
errdefer obj.cleanup; are exactly the same as: defer the_allocator.destroy(obj);
errdefer the_allocator.destroy(obj); If the compiler has the logic to desugar this and then does the check for the defer statement on the desugared version, developers are able to choose if they want to use this or continue manually calling the respective cleanup functions (since some people seemed opposed to making the exact function implicit). More importantly existing code that does this would continue to compile correctly. Of course, to check this, there should be an escape hatch for cases where you don't want to clean up and that's why the second keyword would be needed. Something like |
Hi! Want to share my idea of stack pointers https://gist.github.com/PeyTy/7983dd85026cc061980a6a966bc1afc2 |
As a new zig learner having some construct that forces you to handle resource cleanup would be really great from both a memory safety and developer experience perspective. |
I'm wondering if attaching "uses" to variables/parameters would solve this somewhat. It would involve having something along the lines of For example, There should be no implicit cast from
References to linear things pretty much require borrowing (e.g TL;DR - introduce new modifiers for parameters / types along with a new type
|
@tauoverpi what the key differences from Rust's borrow checker? What you're proposing here would essentially make Zig a Rust 2.0 (😉) |
Declaring ownership is not the main problem. Enforcing via solving the semantics however is, because otherwise annotations only describe intended semantics. The main problem of the Rust borrow checker is summarized here #2301 (comment), so it is a committed local optimum. More general formal systems would boil down to more expressive annotations taking into account how the formal system is solved, which are not possible to bake into the syntax as it would mean to enclose all possible formal systems to prove all possible properties. Something like #14656 would be necessary to cover these use cases. |
Bringing up an idea inspired by a discord conversation from a while ago: fn make(ally: Allocator, n: usize) Allocator.Error!unmake#[]f16 {
const result = try ally.alloc(f16, n);
@memset(result, 0.5);
return result; // coerces implicitly to the obligation
}
fn unmake(
ret: []const f16,
allocator: std.mem.Allocator,
) void {
allocator.free(ret);
}
test {
const ally = std.testing.allocator;
// `defer(...)` is required to unwrap the result, and simultaneously does the
// same thing as `defer unmake(slice1, ally);` all the required state except
// for the result value itself must be passed as arguments - no closures or
// contexts or whatever else
// `errdefer(...)` may also be used for the behavior you'd expect
const slice1 = try make(ally) defer(ally);
// escape hatch to request to manage the resource manually, eliding automatic cleanup.
// syntax doesn't matter, could be anything like `foo() noclean`.
const slice2 = try make(ally) @noclean();
unmake(slice2, ally);
// this should be a compile error. "clean up unions" or whatever you want to call them
// should never be stored. They should be treated like half opaque types,
// in that using one as the type of a field or variable should be a compile error,
// but should be allowed as the type of an expression (e.g. result of a function call,
// in-place coercion with `@as`, etc). This helps to avoid double frees.
const slice3 = try make(ally);
} The pieces that comprise this are all very similar to other concepts brought up in this issue, but I feel this addresses a good bunch of the counter-points of those previous ideas. And to just quickly address the "no hidden control flow" argument that may be brought up against this, I would argue that there isn't any control flow that's truly hidden here, as the caller is explicitly forced to either opt-in or opt-out of the cleanup behavior, which effectively makes it as transparent as just deciding whether or not to call a function - it just happens that said function is being "recommended" to clean it up. Edit: it occurs to me that, with the syntax presented here, pairings like fn alloc(self: Allocator, comptime T: type, n: usize) Error!free#[]T {
// -- snip --
}
fn free(self: Allocator, #ptr: anytype) void {
// -- snip --
} and when it comes to how the arguments end up passed, it should behave as if the resource argument is put between the relevant arguments to fn make(ally: Allocator, n: usize, align_log2: Allocator.Log2Align) !unmake#[]align(1) i16 {
// -- snip --
}
fn unmake(ally: Allocator, #slice: []const i16, align_log2: Allocator.Log2Align) void {
// -- snip --
}
test {
const ally = std.testing.allocator;
const slice1 = try make(ally, 15, 0) defer(ally, 0); // the value is passed as if in between `ally` and `0`.
const slice2 = try make(ally, 31, 4) errdefer(ally, 4); // ditto
const slice3 = try make(ally, 63, 2) @noclean();
defer unmake(ally, slice3, 2); // can still call as normal function
} Edit 2: another idea would be to make the syntax look like this: const slice = try make(ally, 127, 0) defer(ally, _, 0); Which may look a bit odd, but keeps things simple, and is tangentially related to inference, with underscore being the same symbol that's used to infer array literal length. |
OK, a slightly revised version of the proposed ideas after some amount of deliberation: const std = @import("std");
// #unmake is an annotation of the whole function, not part of a type (syntax up for debate,
// maybe use `@cleanup(unmake)`?)
fn make(ally: Allocator, size: usize, align_log2: Allocator.Log2Align) E![]T #unmake {
// -- snip --
}
fn unmake(ally: Allocator, slice: []T, align_log2: Allocator.Log2Align) void {
// -- snip --
}
test {
const ally = std.testing.allocator;
// compiles fine, is the same as writing
// ```
// const slice1 = try make(ally, 31, 0);
// defer unmake(ally, slice1, 0);
// ```
const slice1 = try make(ally, 31, 0) defer(ally, _, 0);
// compiles fine, is the same as writing
// ```
// const slice2 = try make(ally, 127, 8);
// errdefer unmake(ally, slice2, 8);
// ```
// caller takes responsibility for freeing the resource in
// the absence of error
const slice2 = try make(ally, 127, 8) errdefer(ally, _, 8);
// this is the fine, it is the same as choosing to omit the
// `defer unmake(ally, slice4, 4);` code - caller takes responsibility
// for freeing the resource
//
// Syntax up for debate, maybe use a builtin like `@noclean()`, or maybe
// an operator using the # symbol, or whatever else. Could also be prefix
// instead of postfix to distinguish from normal defer auto-cleaning.
const slice3 = try make(ally, 63, 4) noclean;
defer unmake(ally, slice3, 4);
// // this would be a compile error, it's the same as writing
// // ```
// // const slice4 = make(ally, 31, 0);
// // defer unmake(ally, slice4, 0);
// // ```
// // basically, `E![]T` can't coerce to `[]T`
// const slice4 = make(ally, 15, 2) defer(ally, _, 2);
// this would be a compile error, because the expression resulting from
// a function annotated with a clean up function is not annotated with
// either a `defer`, `errdefer` or `noclean`.
const slice5 = try make(ally, 7, 1);
unmake(ally, slice2, 8); // don't forget to clean up after no errors occurred
} To put some of this into more concrete terms: An annotation on a function of the form When this function is called, the caller may transform the resulting expression in whatever arbitrary way is desired; we can say that the expression itself is flagged by the compiler as being the result of a call to a function that's annotated with a clean up procedure.
In essence, this is purely a formalization of the extremely common-place pattern of paired var list = try std.ArrayListUnmanaged(u8).initCapacity(allocator, 32);
defer list.deinit(allocator);
// -- snip --
return try list.toOwnedSlice(); into var list = try std.ArrayListUnmanaged(u8).initCapacity(allocator, 32)
defer(_, allocator);
// -- snip --
return try list.toOwnedSlice() noclean; |
@InKryption consider this, what comes at fn make_at_upper_scope(ally: Allocator, size: usize, align_log2: Allocator.Log2Align) E![]T #??? {
return try make(ally, 31, 0) ???;
}
fn make(ally: Allocator, size: usize, align_log2: Allocator.Log2Align) E![]T #unmake {
// -- snip --
}
fn unmake(ally: Allocator, slice: []T, align_log2: Allocator.Log2Align) void {
// -- snip --
}
test {
const ally = std.testing.allocator;
const slice1 = try make_at_upper_scope(ally, 31, 0) defer(ally, _, 0) /* or what? */;
} |
@PeyTy fn make_at_upper_scope(ally: Allocator, size: usize, align_log2: Allocator.Log2Align) E![]T #unmake {
return try make(ally, 31, 0) noclean;
} ... which is of course unsatisfactory, because you have to dig out the literal function doing the cleanup, fn make_at_upper_scope(ally: Allocator, size: usize, align_log2: Allocator.Log2Align) E![]T # {
return try make(ally, 31, 0) moveclean;
} Just thinking out loud. Maybe there's a simpler solution. |
Obligations definitely should not be implicit, as that essentially would start to fall under hidden control flow. Perhaps it could be part of type info, and be retrieved by a helper function as |
Speaking of hidden control flow. Most ideas around Zig memory management pretend that memory lifetime is simply linear and stack-like. I don't believe Thus, what about, instead of trying to automate A quick made up example: fn make(allocator ..., ...) #reminder {
...
}
test {
var gpa = ...;
var demo = make(gpa, ...);
// at the end of scope compiler would say something like "demo leaves the scope, action required"
gpa.free(@actionTaken(demo)); // @actionTaken simply removes the flag #reminder from this variable
{
// would work for ref counting too
// Doc comment saying that "you must call rc_decrement at scope leaving"
fn alloc_rc(allocator, ...) T #reminder_at_every_scope_leaving {
return ...
}
fn rc_decrement(allocator, object #action_taken, ...) {
if (object.rc-- == 0) allocator.free(object);
}
var refcounted = alloc_rc(gpa, ...);
// you must do something according to the documentation of the `alloc_rc` function
// in Zig it is assumed that you follow the docs anyway
rc_decrement(refcounted); // #action_taken implicitly means @actionTaken(refcounted)
}
} You may read more about similar idea in Vale: https://verdagon.dev/blog/higher-raii-7drl That part:
This way you don't have any automatic substitution, but compiler helps you to not forget to do something. |
It might be too limiting to tie the return value directly to one particular cleanup function, because some resources can be cleaned up in more than one way. For example:
|
Well, in the thread case, those are cases wherein it wouldn't make sense to call either of those functions to "release" the resource. If we look at something like C++ or Rust, which have destructors for "resources", they also do not join nor detach in the destructors of their standard thread types. |
(It's a real minor point, but Rust does detach when you drop a JoinHandle) If more complex resources are out of scope, fair enough, just thought it was worth mentioning |
I think having this issue open is confusing/misleading people. I'm not seriously considering this proposal and it's very likely that nothing will come of it. The only reason it was open was due to being possibly related to async cancellation. That is still an unsolved use case, but even so I'm confident the solution is not annotating resource-allocating functions with what is essentially destructors. |
This pattern is extremely common in zig code:
Generally:
This proposal is to
Strategy:
clean
corresponding todefer theCleanupFunction(resource);
errclean
corresponding toerrdefer theCleanupFunction(resource);
noclean
to indicate that you acceptresponsibility for the resource. Otherwise you get
error: must specify resource cleanup strategy
.The above code example becomes:
How to annotate cleanup functions:
Having functions which allocate a resource mention their cleanup functions will make generated documentation more consistent and helpful.
The text was updated successfully, but these errors were encountered: