Hacker News new | ask | show | jobs
by winternewt 169 days ago
Destructors are vastly superior to the finally keyword because they only require us to remember a single time to release resources (in the destructor) as opposed to every finally clause. For example, a file always closes itself when it goes out of scope instead of having to be explicitly closed by the person who opened the file. Syntax is also less cluttered with less indentation, especially when multiple objects are created that require nested try... finally blocks. Not to mention how branching and conditional initialization complicates things. You can often pair up constructors with destructors in the code so that it becomes very obvious when resource acquisition and release do not match up.
7 comments

I couldn't agree more. And in the rare cases where destructors do need to be created inline, it's not hard to combine destructors with closures into library types.

To point at one example: we recently added `std::mem::DropGuard` [1] to Rust nightly. This makes it easy to quickly create (and dismiss) destructors inline, without the need for any extra keywords or language support.

[1]: https://doc.rust-lang.org/nightly/std/mem/struct.DropGuard.h...

The scope guard statement is even better!

https://dlang.org/articles/exception-safe.html

https://dlang.org/spec/statement.html#ScopeGuardStatement

Yes, D also has destructors.

I use this library a lot for scope guards in C++ https://github.com/Neargye/scope_guard, especially for rolling back state on errors, e.g.

In a function that inserts into 4 separate maps, and might fail between each insert, I'll add a scope exit after each insert with the corresponding erase.

Before returning on success, I'll dismiss all the scopes.

I suppose the tradeoff vs RAII in the mutex example is that with the guard you still need to actually call it every time you lock a mutex, so you can still forget it and end up with the unreleased mutex, whereas with RAII that is not possible.

The tradeoff with RAII is once you have 3 things that must all succeed or all fail, that is very clumsy to do with RAII, but easy with scope guard.
Scope guards are neat, particularly since D has had them since 2006! (https://forum.dlang.org/thread/dtr2fg$2vqr$4@digitaldaemon.c...) But they are syntactically confusing since they look like a function invocations with some kind of aliased magic-value passed in.
A writable file closing itself when it goes out of scope is usually not great, since errors can occur when closing the file, especially when using networked file systems.

https://github.com/isocpp/CppCoreGuidelines/issues/2203

You need to close it and check for errors as part of the happy path. But it's great that in the error path (be that using an early return or throwing an exception), you can just forget about the file and you will never leak a file descriptor.

You may need to unlink the file in the error path, but that's best handled in the destructor of a class which encapsulates the whole "write to a temp file, rename into place, unlink on error" flow.

Any fallible cleanup function is awkward, regardless of error handling mechanism.
Java solved it by having exceptions be able to attach secondary exceptions, in particular those occurring during stack unwinding (via try-with-resources).

The result is an exception tree that reflects the failures that occurred in the call tree following the first exception.

I often miss this feature in other languages. It has saved me more times than I can count.
The entire point of the article is that you cannot throw from a destructor. Now how do you signal that closing/writing the file in the destructor failed?
You are allowed to throw from a destructor as long as there's not already an active exception unwinding the stack. In my experience this is a total non-issue for any real-world scenario. Propagating errors from the happy path matters more than situations where you're already dealing with a live exception.

For example: you can't write to a file because of an I/O error, and when throwing that exception you find that you can't close the file either. What are you going to do about that other than possibly log the issue in the destructor? Wait and try again until it can be closed?

If you really must force Java semantics into it with chains of exception causes (as if anybody handled those gracefully, ever) then you can. Get the current exception and store a reference to the new one inside the first one. But I would much rather use exceptions as little as possible.

Just panic. What's the caller realistically going to do with that information?
> The entire point of the article is that you cannot throw from a destructor.

You need to read the article again because your assertion is patently false. You can throw and handle exceptions in destructors. What you cannot do is not catch those exceptions, because as per the standard uncaught exceptions will lead the application to be immediately terminated.

You can throw in a destructor but not from one, as the quoted text rightly notes.
So inside a destructor throw has a radically different behaviour that makes it useless for communicating non-fatal errors
> So inside a destructor throw has a radically different behaviour that makes it useless for communicating non-fatal errors

It's weird how you tried to frame a core design feature of the most successful programming language in the history of mankind as "useless".

Perhaps the explanation lies in how you tried to claim that exceptions had any place in "communicating non-fatal errors", not to mention that your scenario, handling non-fatal errors when destroying a resource, is fundamentally meaningless.

Perhaps you should take a step back and think whether it makes sense to extrapolate your mental models to languages you're not familiar with.

That tastes like leftover casserole instead of pizza.
But they're addressing different problems

Sure destructors are great but you still want a "finally" for stuff you can't do in a destructor

Python has that too, it's called a context manager, basically the same thing as C++ RAII.

You can argue that RAII is more elegant, because it doesn't add one mandatory indentation level.

It's not the same thing at all because you have to remember to use the context manager, while in C++ the user doesn't need to write any extra code to use the destructor, it just happens automatically.
To be fair, that's just an artifact Python's chosen design. A different language could make it so that acquiring the object whose context is being managed could require one to use the context manager. For example, in Python terms, imagine if `with open("foo") as f:` was the only way to call `open`, and gave an error if you just called it on its own.
How do you return a file in the happy path when using a context manager?

If you can't, it's not remotely "basically the same as C++ RAII".

Destructors and finally clauses serve different purposes IMO. Most of the languages that have finally clauses also have destructors.

> Syntax is also less cluttered with less indentation, especially when multiple objects are created that require nested try... finally blocks.

I think that's more of a point against try...catch/maybe exceptions as a whole, rather than the finally block. (Though I do agree with that. I dislike that aspect of exceptions, and generally prefer something closer to std::expected or Rust Result.)

> Most of the languages that have finally clauses also have destructors.

Hm, is that true? I know of finally from Java, JavaScript, C# and Python, and none of them have proper destructors. I mean some of them have object finalizers which can be used to clean up resources whenever the garbage collector comes around to collect the object, but those are not remotely similar to destructors which typically run deterministically at the end of a scope. Python's 'with' syntax comes to mind, but that's very different from C++ and Rust style destructors since you have to explicitly ask the language to clean up resources with special syntax.

Which languages am I missing which have both try..finally and destructors?

In C# the closest analogue to a C++ destructor would probably be a `using` block. You’d have to remember to write `using` in front of it, but there are static analysers for this. It gets translated to a `try`–`finally` block under the hood, which calls `Dispose` in `finally`.

    using (var foo = new Foo())
    {
    }
    // foo.Dispose() gets called here, even if there is an exception
Or, to avoid nesting:

    using var foo = new Foo(); // same but scoped to closest current scope
These also is `await using` in case the cleanup is async (`await foo.DisposeAsync()`)

I think Java has something similar called try with resources.

Java's is

    try (var foo = new Foo()) {
    }
    // foo.close() is called here.
I like the Java method for things like files because if the there's an exception during the close of a file, the regular `IOException` block handles that error the same as it handles a read or write error.
What do you do if you wanna return the file (or an object containing the file) in the happy path but close it in the error path?
You'd write it like this

    void bar() {
      try (var f = foo()) {
        doMoreHappyPath(f);
      }
      catch(IOException ex) {
        handleErrors();
      }
    }

    File foo() throws IOException {
      File f = openFile();
      doHappyPath(f);
      if (badThing) {
        throw new IOException("Bad thing");
      }
      return f;
    }
That said, I think this is a bad practice (IMO). Generally speaking I think the opening and closing of a resource should happen at the same scope.

Making it non-local is a recipe for an accident.

*EDIT* I've made a mistake while writing this, but I'll leave it up there because it demonstrates my point. The file is left open if a bad thing happens.

That approach doesn't allow you to move the file into some long lived object or return it in the happy path though, does it?
As someone coming from RAII to C#, you get used to it, I'd say. You "just" have to think differently. Lean into records and immutable objects whenever you can and IDisposable interface ("using") when you can't. It's not perfect but neither is RAII. I'm on a learning path but I'd say I'm more productive in C# than I ever was in C++.
I agree with this. I don't dislike non-RAII languages (even though I do prefer RAII). I was mostly asking a rhetorical question to point out that it really isn't the same at all. As you say, it's not a RAII language, and you have to think differently than when using a RAII language with proper destructors.
You can move the burden of disposing to the caller (return the disposable object and let the caller put it in a using statement).

In addition, if the caller itself is a long-lived object it can remember the object and implement dispose itself by delegating. Then the user of the long-lived object can manage it.

> You can move the burden of disposing to the caller (return the disposable object and let the caller put it in a using statement).

That doesn't help. Not if the function that wants to return the disposable object in the happy path also wants to destroy the disposable object in the error path.

Technically CPython has deterministic destructors, __del__ always gets called immediately when ref count goes to zero, but it's just an implementation detail, not a language spec thing.
I don't view finalizers and destructors as different concepts. The notion only matters if you actually need cleanup behavior to be deterministic rather than just eventual, or you are dealing with something like thread locals. (Historically, C# even simply called them destructors.)
There's a huge difference in programming model. You can rely on C++ or Rust destructors to free GPU memory, close sockets, free memory owned through an opaque pointer obtained through FFI, implement reference counting, etc.

I've had the displeasure of fixing a Go code base where finalizers were actively used to free opaque C memory and GPU memory. The Go garbage collector obviously didn't consider it high priority to free these 8-byte objects which just wrap a pointer, because it didn't know that the objects were keeping tens of megabytes of C or GPU memory alive. I had to touch so much code to explicitly call Destroy methods in defer blocks to avoid running out of memory.

For GCed languages, I think finalizers are a mistake. They only serve to make it harder to reason about the code while masking problems. They also have negative impacts on GC performance.

Java is actively removing it's finalizers.

> I don't view finalizers and destructors as different concepts.

They are fundamentally different concepts.

See Destructors, Finalizers, and Synchronization by Hans Boehm - https://dl.acm.org/doi/10.1145/604131.604153

It would suffice to say I don't always agree with even some of the best in the field, and they don't always agree with each other, either. Anders Hejlsberg isn't exactly a random n00b when it comes to programming language design and still called the C# equivalent a "destructor", though it is now known as a finalizer in line with other programming languages. They are things that clean up resources at the end of the life of an object; the difference between GC'd languages and RAII languages is that in a GC'd runtime the lifespan of an object is non-deterministic. That may very well change the programming model, as it does in many other ways, but it doesn't make the two concepts "fundamentally different" by any means. They're certainly related concepts...
They are related but fundamentally different. It is a vital semantic difference (influencing the programming model itself) since destructors (C++ style) are synchronous and deterministic while finalizers (Java style) are asynchronous and non-deterministic.

It is because of all the problems that the finalize method was deprecated in Java 9 and marked "deprecated for removal"(JEP 421) in Java 18. More details at https://stackoverflow.com/questions/56139760/why-is-the-fina... and https://inside.java/2022/01/12/podcast-021/

PS: JEP 421: Deprecate Finalization for Removal - https://openjdk.org/jeps/421 Also details alternative features/techniques to use.

Sometimes „eventually“ is „At the end of the process“. For many resources this is not acceptable.