Hmm that's certainly an interesting project. But I have to wonder if writing "defer file.close()" everywhere is really superior to RAII. Isn't defer also kind of hidden control flow? At that point I feel like RAII simply offers more, first of all you don't have to write what you want to defer everywhere, and second of all you can actually move resources around and still rely on it.
Also the integer overflow section struck me as kinda odd - what if I want wrapping behavior? That's not exactly a super uncommon scenario.
Wrapping is not a good default, and actually it's not that common a scenario.
When you want arithmetic to wrap, you should use some way to explicitly denote that you want it to wrap. (I think it could look like an operation with a modulo reduction or mask applied to it.)
defer is way more transparent than RAII, because your code is right there in the function call, not hidden in a pile of classes that might change underneath.
I don't mind it not being default - but the section doesn't mention at all how to get wrapping behavior. Just one sentence like "use this different operator to get wrapping behavior" would be fine, but since they didn't mention it at all I kind of went away with the idea that it is not supported at all, which honestly would be a massive problem.
>defer is way more transparent than RAII, because your code is right there in the function call, not hidden in a pile of classes that might change underneath.
I mean, sort of? It seems way less composable though, if you have an object which contains 5 different resources, now you have to defer 5 things? How does this interact with return values, if you defer file.close() for example but return the file, does it still get closed? And if you have a heap allocated array of those objects, where objects get inserted at different places, defer doesn't work at all anymore, since it's just bound to function scope? Because that's usually the scenario that can be annoying to handle in C (opposed to just closing whatever was opened in the same function scope), so it seems a bit odd to me to kind of violate that "no hidden control flow" principle for something that doesn't even solve the primary issue that C resource handling has.
> I mean, sort of? It seems way less composable though, if you have an object which contains 5 different resources, now you have to defer 5 things?
Defer is used for scope-local resources. If you create five different things in the local scope that need to be cleaned up, then they are most likely not part of the same object; yes, you will need to clean them all up. If you have an object with five different thing, then you would defer that object's cleanup routine.
> How does this interact with return values, if you defer file.close() for example but return the file, does it still get closed?
Yes. Why would you defer close on a file that you are planning to return? That's by definition not some scope-local resource with scope lifetime you want to cleanup at the end of scope. That's like implementing malloc that frees the block before returning.
Note that there is errdefer, which you can use to defer the release of resources that would be returned normally but need to be cleaned up on error. https://ziglang.org/documentation/master/#defer
> And if you have a heap allocated array of those objects, where objects get inserted at different places, defer doesn't work at all anymore, since it's just bound to function scope?
I don't understand what you're asking about here. If you malloc() an array in C, then you free() it when you're done with it. The exact same mechanism works in Zig, but defer also helps you ensure free() actually gets called when you go out of scope, so you can still use early returns instead of ret = foo; goto err_bar;
> Because that's usually the scenario that can be annoying to handle in C (opposed to just closing whatever was opened in the same function scope), so it seems a bit odd to me to kind of violate that "no hidden control flow" principle for something that doesn't even solve the primary issue that C resource handling has.
I don't agree that it's violating the "no hidden control flow constraint." It's explicit, right there in the scope, not stashed away in a bunch of destructors. It's as transparent as return or goto or break.
Again, I don't really understand the rest of your complaints (or what's your primary issue with C's resource handling). Defer is just a mechanism to make it easier to release resources whose lifetime is tied to a scope.
>I think that piece would get too long if it described everything in depth.
I don't think one sentence more about a very fundamental operation in a page-long document would really be misplaced. But as far as the language itself goes, those operators seem fine to me.
>Note that there is errdefer
That is definitely more useful, and covers the situation I was thinking of.
>I don't understand what you're asking about here. If you malloc() an array in C, then you free() it when you're done with it.
This is about composed objects. So an allocated array of objects, which in turn also hold allocated objects, which might in turn also hold allocated objects.
>I don't agree that it's violating the "no hidden control flow constraint." It's explicit, right there in the scope, not stashed away in a bunch of destructors. It's as transparent as return or goto or break.
Not sure I can completely agree with that. Fundamentally, defer means that something happens at a point where there is no associated code. If you read the line "return 5;", you don't know if that triggers one (or multiple) function calls. Yes, you only have to look for those in function scope (which could be 5000+ lines in some cases) (you only have to look for destructors in function scope too btw), but it is still a "hidden code path". For me personally it doesn't really matter at that point if I have to first search in the function body (and then potentially in the cleanup function), or first in the function body and then potentially in destructors. It removes the ability to reason about code line by line either way. Which might be a price that is worth to pay for easier cleanup logic, but the way I'm seeing it I'm only getting like a quarter of the benefit of destructors but I'm paying essentially the same price.
> This is about composed objects. So an allocated array of objects, which in turn also hold allocated objects, which might in turn also hold allocated objects.
The way this is solved by calling the cleanup routine for the first object in the hierarchy. If that object is responsible for the lifetime of other objects, then it will also call their cleanup routine. From the user's perspective, those are invisible, just as any calls to free() or close() or fflush() inside an fclose() are.
> If you read the line "return 5;", you don't know if that triggers one (or multiple) function calls.
If you read the line "continue;" or "break;", you don't know if that triggers one (or multiple) function calls until you read the surrounding code.
I still find it much easier to follow code inside a function than to jump through classes.
But the bigger picture is that it makes lifetime management explicit and thus transparent, and you needn't complicate the language with copy constructors and move semantics.
>If you read the line "continue;" or "break;", you don't know if that triggers one (or multiple) function calls until you read the surrounding code.
Not sure what you mean exactly - you have to read the surrounding code to know where the control flow continues, but there's no hidden function call between that point and and the break. That's like arguing you need to understand the entire code base to know what return does - since it returns control flow to potentially a completely different file. But that's not really the point, the point is that it doesn't do anything else in between - if you know where control flow is going to, you know everything that is happening. More so with break and continue even, since for break and continue where control flow continues doesn't even depend on runtime state, whereas return could return to different places depending on where the function was called.
With break, control flow goes out of the loop or out of the switch. You may find function calls there. With continue, control flow goes to the start of the loop. You may find function calls there. With return, control goes through the defer blocks and then out of the function. Similar reasoning for goto. In every case, you know where the control flow goes by looking at the context inside the function.
I don't agree with the assessment that one of them is more hidden than the other.
>With break, control flow goes out of the loop or out of the switch. You may find function calls there.
Yeah but you don't have to look for anything in between. You look for where it goes, and that's it. That there could be a function call after the continue has executed isn't relevant at all, there could also be function call after a return has executed. Stepping through the code in your head is trivial because the execution never jumps anywhere without an explicit statement to jump.
With defers, you need to make sure you find every single defer that was executed up to the return (which might not be trivial if defers are executed conditionally). With a break there is exactly one, unconditional, position at which execution continues - and if you have found it, there is no reason to keep looking anywhere else.
I mean wanting to make the defer trade-off but not the destructor trade-off, fine, that is ultimately a matter of opinion. But defer being hidden control flow is just objectively true, execution jumps somewhere without a corresponding explicit jump in the code (return only explicitly jumps out of the function).
I'm not sure how RAII could ever need more code, when the only difference is that functions get called automatically (instead of through close(), or a close() inside a defer).
With RAII, you’re expressing ownership in code. That means that you have to write all the extra code to track that ownership in the type system or at runtime. Every time I want to write a class that manages a resource with RAII, I am almost always writing a copy constructor, move constructor, assignment operator, move assignment operator, and destructor.
On the other hand, if I write the equivalent code in Go or Nim, I can just add something like a Close() method. Because ownership isn’t expressed in code, there is a higher chance to make mistakes, but the code is simpler.
If you need a deep copy you also need to write a copy() function for the object without RAII. (Although one could argue that that is still RAII, just without operator overloading.) But in any case, for deep copies, the copy code needs to be somewhere.
If you don't need a deep copy (which honestly you basically never need once you already have some container classes), you can pretty much always just use an unique_ptr or write a unique_handle class or something to that regard, which means you never need to write any of the operators.
If you just want a unique_ptr, then you should at least write the code to delete the relevant operators or make them private. Boilerplate, yes, but that is how idiomatic C++ code is written.
I'm not sure what you mean. If you have code like this
struct Foo {
unique_ptr p;
};
Foo is automatically not copyable. That code is about is perfectly good idiomatic C++, with zero boilerplate. And that is how most classes actually end up looking in idiomatic C++, there's almost never the need to implement operators.
> Every time I want to write a class that manages a resource with RAII, I am almost always writing a copy constructor, move constructor, assignment operator, move assignment operator, and destructor.
No you aren't. You only do that at the lowest abstraction level. For normal classes you can add a vector member and it will just work (see "rule of zero").
> On the other hand, if I write the equivalent code in Go or Nim, I can just add something like a Close() method.
Yes, and then find and update every place in the code where that class is instantiated. If the class was ever instantiated as a temporary, you also need to pull it out into a named variable so you can use defer. If the class was ever used in a vector, you need to find all the places where an element of the vector is removed and make sure close is being called there, etc.
This is really awful, so in a language without RAII, you are strongly incentivized not to tie ownership to object lifetime. I've found the best way is to aggressively simplify managed lifecycles and do allocation/cleanup in some kind of manager object.
> No you aren't. You only do that at the lowest abstraction level. For normal classes you can add a vector member and it will just work (see "rule of zero").
That’s what I mean when I write “a class that manages a resource with RAII”. Just on a personal note—it can be damn frustrating writing a comment on HN sometimes, because somebody will always find a way to misinterpret it completely.
> Yes, and then find and update every place in the code where that class is instantiated. If the class was ever instantiated as a temporary, you also need to pull it out into a named variable so you can use defer. If the class was ever used in a vector, you need to find all the places where an element of the vector is removed and make sure close is being called there, etc.
Once you step away from automated formal verification, which is very rare to begin with, the programmer is always responsible for maintaining some nonzero number of program invariants. In my years of experience with Go, the manual work to close file handles when you are done is not particularly burdensome or difficult, and I have seen very few errors slip into production related to resource management which would have been solved by RAII.
> This is really awful…
I would be interested to understand what design decisions led to the experiences you describe, because I have never seen a project suffer from those problems. Can you give more details?
> Once you step away from automated formal verification, which is very rare to begin with, the programmer is always responsible for maintaining some nonzero number of program invariants.
Of course! It's nice to have all the help with that you can get.
> In my years of experience with Go, the manual work to close file handles when you are done is not particularly burdensome or difficult, and I have seen very few errors slip into production related to resource management which would have been solved by RAII.
Go has a GC which greatly reduces the number of things to manage, but the discussion is about Zig. In Go, if you add, let's say, a string to a class, you don't have to do anything, it will just work because of the GC. In Zig, if you add a string to a class and it didn't already have a close method for some other reason, you now have to update all the users. Essentially its unreasonably burdensome to switch from not managing a resource to managing a resource unless there are few users.
> That’s what I mean when I write “a class that manages a resource with RAII”. Just on a personal note—it can be damn frustrating writing a comment on HN sometimes, because somebody will always find a way to misinterpret it completely.
This wasn't some kind of pedantic point-scoring. In something like C++/Rust every single class that so much as contains a string manages a resource with RAII, but only a tiny number of them actually require you to write any cleanup code. In a language without RAII, if you used the same design, a huge number of them would require writing cleanup code.
Also the integer overflow section struck me as kinda odd - what if I want wrapping behavior? That's not exactly a super uncommon scenario.