11 ms·
My hot take is that the root of this issue is that the destructor side of RAII in general is a bad idea. That is, registering custom code in destructors and run
by zbentley 2y ago
My hot take is that the root of this issue is that the destructor side of RAII in general is a bad idea. That is, registering custom code in destructors and running them invisibly, implicitly, maybe sometimes but only if you're polite, is not and never was a good pattern.
This pattern causes issues all over the place: in C++ with headaches around destruction failure and exceptions; in C++ with confusing semantics re: destruction of incompletely-initialized things; in Rust with "async drop"; in Rust (and all equivalent APIs) in situations like the one in this article, wherein failure to remember to clean up resources on IO multiplexer cancellation causes trouble; in Java and other GC-ful languages where custom destructors create confusion and bugs around when (if ever) and in the presence of what future program state destruction code actually runs.
Ironically, two of my least favorite programming languages are examples of ways to mitigate this issue: Golang and JavaScript runtimes:
Golang provides "defer", which, when promoted widely enough as an idiom, makes destructor semantics explicit and provides simple and consistent error semantics. "defer" doesn't actually solve the problem of leaks/partial state being left around, but gives people an obvious option to solve it themselves by hand.
JavaScript runtimes go to a similar extreme: no custom destructors, and a stdlib/runtime so restrictive and thick (vis-a-vis IO primitives like sockets and weird in-memory states) that it's hard for users to even get into sticky situations related to auto-destruction.
Zig also does a decent job here, but only with memory allocations/allocators (which are ironically one of the few resource types that can be handled automatically in most cases).
I feel like Rust could have been the definitive solution to RAII-destruction-related issues, but chose instead to double down on the C++ approach to everyone's detriment. Specifically, because Rust has so much compile-time metadata attached to values in the program (mutability-or-not, unsafety-or-not, movability/copyabiliy/etc.), I often imagine a path-not-taken in which automatic destruction (and custom automatic destructor code) was only allowed for types and destructors that provably interacted only with in-user-memory state. Things referencing other state could be detected at compile time and required to deal with that state in explicit, non-automatic destructor code (think Python context-managers or drop handles requiring an explicit ".execute()" call).
I don't think that world would honestly be too different from the one we live in. The rust runtime wouldn't have to get much thicker--we'd have to tag data returned from syscalls that don't imply the existence of cleanup-required state (e.g. select(2), and allocator calls--since we could still automatically run destructors that only interact with cleanup-safe user-memory-only values), and untagged data (whether from e.g. fopen(2) or an unsafe/opaque FFI call or asm! block) would require explicit manual destruction.
This wouldn't solve all problems. Memory leaks would still be possible. Automatic memory-only destructors would still risk lockups due to e.g. pagefaults/CoW dirtying or infinite loops, and could still crash. But it would "head off at the pass" tons of issues--not just the one in the article:
Side-effectful functions would become much more explicit (and not as easily concealable with if-error-panic-internally); library authors would be encouraged to separate out external-state-containing structs from user-memory-state-containing ones; destructor errors would become synonymous with specific programmer errors related to in-memory twiddling (e.g. out of bounds accesses) rather than failures to account for every possible state of an external resource, and as a result automatic destructor errors unconditionally aborting the program would become less contentious; the surface area for challenges like "async drop" would be massively reduced or sidestepped entirely by removing the need for asynchronous destructors; destructor-related crash information would be easier to obtain even in non-unwinding environments...
Maybe I'm wrong and this would require way too much manual work on the part of users coding to APIs requiring explicit destructor calls.
But heck, I can dream, can't I?
- vacuity 2y agoI think Austral and Vale's linear typing is a good start, although it would probably have to be opt-in in practice. This goes along with explicit, manual destructors and alleviates issues like async drop. Even with automatic destructors, they can have more visibility and customizability. Exceptions are a can of worms and need to be redesigned (but not removed). I think automatic destruction doesn't have to mean oh-wait-what-do-you-mean-it-unwound-and-ran-a-destructor-uh-oh-double-exception-abort and similar very weird cases. The concept should have its own scope and purpose, same with exceptions.
- int_19h 2y agoI don't think Java finalizers should be considered in the same bucket as C++-style destructors. They are very explicitly not about RAII. If explicit destruction is desirable, IMO the C#-style `using` pattern (try-with-resource in Java, `with` in Python etc) makes more sense than `defer` since it still forces the use of the correct cleanup code for a given type while retaining the explicitness and allowing linters to detect missing calls (because destructability is baked into the type). `defer` is unnecessarily generic here IMO. In JS, you still end up having to write try/finally for anything that needs explicit resource management, so I don't think it's a good example. They are adding `using`, though, and TS already provides it.
- zbentley 2y agoThat's fair, my phrasing was less than precise. More accurately: I think that automatic and outside-of-written-code destruction is generally a risky/bad idea for nontrivial (in-user-memory only) objects. Both GC-ful languages' finalization systems and the automatic-destruction side of RAII are problematic. I agree that moving more logic into explicit acquire/perform/destroy blocks like TWR and 'with' is good. I wish we had a language (rather than, say, just specific libraries) that could require such handling for certain datatypes.
- int_19h 2y agoRequiring it for certain datatypes would be tricky given that the pattern doesn't scale once you start composing data - if I have class AB that owns an A and a B, in a world with explicit cleanup, I can't express that with a with-block - I have to write AB.close() that does A.close() and B.close(). Now, if you track ownership, you could enforce this. But it seems like at this point you might as well just have proper destructors, since ownership is explicit in the declared type of the variable, and thus having an additional keyword like "defer" is redundant wrt making that destructor call explicit. You just need to make sure that syntax for variable declarations makes ownership or lack thereof very clear. Separately from that, explicit destruction doesn't resolve many of the issues that you have mentioned with C++ specifically, such as exception safety. Again, in the example above, what should happen if A.close and B.close both throw? A naive implementation would propagate the exception from A and leak B. A fancy one will throw something like Java's SuppressedError, but that's still far from ideal. Similarly, with object state during incomplete initialization, the explicit destructors still have to handle all that somehow.