auto button = Button::create();
window.addSubview(button);
button.setLabel("test");
button.setAlignment(Alignment::TOP_LEFT);
button.sizeToFit();
If setLabel() throws an exception, you'll end up with a half-initialized button in the view hierarchy.For exceptions to work predictably, all mutation within a try-catch block should be captured in a transaction that can be rolled back. Then you don't need to worry about each function possibly throwing an exception. Either the entire block commits its changes, or it's as if the entire block didn't happen at all.
You need total knowledge of what can throw exceptions, and while the same is true for status codes, the raison d'etre for exceptions is shrinking the amount of error handling in your code. If you're checking for exceptions at the same rate you'd be checking for status codes, exceptions are pointless.
But generally, your suggestion to change the architecture points to what the problem with these "ergonomic" changes engender. You more or less never have to rearchitect or refactor things when using status codes, and rearchitecting and refactoring are error prone and generally hugely fraught. The cognitive load of things like exceptions leads to lower productivity or lower quality, because you only have so many brain cycles and something has to give.
I think ergonomic changes are helpful to get more dynamic programmers into stricter languages like Rust. But I think we should keep in mind that typing and boilerplate are really never our problems and prioritize accordingly.
Rust tries to make it impossible in the general cases to do the wrong thing. And gives you an unsafe escape hatch for when you need to do something the compiler won't allow. The result is vastly safer code because the compiler guards against the most typical human failings.
Wouldn't that just be beautiful?
Another good-stuff perspective are processes or equivalents (e.g. erlang's internal processes or docker containers). Happy-path code can ignore errors; sufficiently serious errors tear down the "process" and the calling code can decide how to deal with that (ideally without cleanup responsibilities).
As long as indirect effects are well-contained, there's nothing necessarily wrong with ignoring errors. Especially when fine-grained error handling simply isn't interesting, it can be a relief simply to ignore errors. E.g. DB style fine-grained locking is really only possible because you don't need to manually deal with each and every possible failure moment (because there are unbelievably many).
not sure if i'd sign the statement "you typically communicate via a fairly narrow, well-defined channel, and not via a morass of side-effects" though ;)
[code] try { system("rm foo.txt");} catch (...) {} [/code]
Not everything can be rolled back. Often, automatic rollbacks lead to states unaccounted for by the developer.
Exceptions depends on RAII, that's true.
You could make this general idea work, though, by adding a "button.commit();" at the end of the function. The button destructor would remove itself unless commit() had been called -- basically an ad-hoc transaction on the level of a single button.
fn make_button(window: &mut Window) {
let mut button = Button::create();
button.set_label("test");
button.set_alignment(Alignment::TopLeft);
button.size_to_fit();
window.add_subview(button);
}
(complete toy example at https://play.rust-lang.org/?gist=cb1db59a3ee1f4bad0a5472267c... )In that situation, the button's destructor absolutely could delete itself.
The drawback is "properly-RAII-ed code". It's hard to write because where you write the RAIIed classes you don't have the context of the place / places where it's used. It's extremely hard to read and step through with a debugger - because control flow is all over the place. I don't want to write classes instead of simple regular control flow.
And regarding standard control flow code, I've never seen a code base that handles exceptions correctly / elegantly / in a disciplined way. The only way to deal with exceptions is handling them immediately, i.e.
try:
f = open(filepath)
except FileNotFoundError:
handle_file_not_found()
To be honest I prefer C-style return code handling. It would be only 2 or 3 lines instead of these 4, and no additional indentation, and no additional value return mechanism. The only cost of C-style handling vs exceptions is duplicating a few library functions: Need to offer interfaces that die and ones that allow handling the error. And there's the possibility of forgetting to handle the error (if the caller considers it an error, after all) once in a long while. But you have that problem with exceptions, too...In other words, exceptions are good for writing sloppy code (I like writing Python scripts, but I rarely handle exceptions), and very bad for serious code bases.
The proper way to use exceptions is when you want behavior that bubbles up. So if you do x=f(g(h(x))) and h(x) fails because the user didn't configure something, you only need the config-checking logic in h, not in g or f. This use-case is quite rare though, and overall not worth the extra language complexity.
I think there is one underused error handling mechanism: "asynchronous error handling" where you have to to ask for errors explicitly. Like in the OpenGL API, for example. It might be the best choice where there is a lot of communication between largely isolates states.
So that could be one answer: If possible design the API in such a way that functions that can have expected errors [1] do not return values that only make sense if there was no error. Because this means that control flow gets very irregular on the calling side. Such a design could be hard to do though, I don't know.
The other answer could be: Don't have errors that you don't know how to handle. Unless you write very low-level code, like a library (which means you probably want to delegate the handling), just die on errors that you don't know how to handle. Make the die() call a best-effort cleanup routine if possible, and keep the important to-be-cleaned up state as global as possible (NOT on the call stack), to support such a cleanup routine.
[1] I hate the vagueness of that term, but take it to mean "no statically obvious wrong usage by the caller", like passing in made-up or corrupted values
Often yes (one of the exceptions being a simple die() routine that cleans up only global state). It's just pseudocode, and in real code you will often not call any functions in the exception handling block.
open filepath `catch` handle_file_not_found >>= use_file_handle
handle_file_not_found :: FileNotFoundError -> IO a
use_file_handle :: FileHandle -> IO aA sibling comment hints at the way out:
> For exceptions to work predictably, all mutation within a try-catch block should be captured in a transaction that can be rolled back.
The instant an exception can be raised from an area in code is the instant you need to make that entire area into functionally-pure side-effect-free logic, so that you can reason about it even when the exception is raised.
And then you've got a function that could just as well return an Either<Result, Error> -- what a strange "coincidence"!
I mean, that would be lovely, but is really only feasible for exceptions raised from code that is operating exclusively on values in memory (NPEs, for example). Rather a lot of exceptions are raised from necessarily side-effectful operations, especially those dealing with I/O.
Since you can't predict the future, you can't guarantee purity/transactionality of code that raises that kind, so saying "code should be pure when it can throw" is a true-but-impossible statement. That's why most languages with an emphasis on purity tend to eschew exceptions and/or invert control flow such that effectful stuff happens externally to the functions handling its output (e.g. the Maybe or IO monads).
The further you push the potentially complicated code towards functional purity, the more the I/O and other side-effecting/hard-to-roll-back things float to the top... and it becomes almost natural to group all of them together...
... into what you might almost be tempted to call a "transaction".
(Indeed, like the IO monad leads one to do. I've explicitly been trying to avoid saying the "m" word, though, because it seems to be a cognitive stop-sign for a lot of folks, for whatever reason.)
Great insight. But i would say that explicit error handling doesn't help you much there. Side effects are hard and side effects can by nature normally not be reverted anyway. Deleted a file? Tough luck getting it back. IO and external interfaces could also be an issue, wrote 50% of a file to USB-stick when the user yanked it from the port? No amount of explicit error handling will help you remove it there.
If(error detected) { COME FROM (Error location); Handle Error }
It trades ease of writing error handling code for ease of debugging.
Your best bet to maintain a clean state with exceptions is to never catch them and just let the whole process die. Otherwise there will be a never ending stream of bugs that your maintenance programmers will be dealing with. They'll be cursing exceptions the whole time.
It puzzles me a bit. For numerical software, exceptions work quite well: for performance, all allocations are in the constructors, so the RAII idiom fits naturally. Using exceptions saves from writing a great deal of error-handling, which easily can have errors itself.
Has anyone seen properly-RAII-ed code where exceptions still have drawbacks ?