Safer version of methods with `raise`
I'm following the discussion of #3371 , but from a different and (to me) more Crystal-style point of view.
With trusted sources there's no problem of Exceptions, even more, allowing simpler code is great, but exceptions are bad when dealing with untrusted sources, they are a totally avoidable performance penalty.
My propose, just like when (unsafe [] + safe []?) was preferred over (safe [] + unsafe []!), is having a "safer" versions of unsafe methods, which return a union of (T | ErrorUsingStackMemory). Additionally returning more than 1 error type should be supported too.
I imagine something using a ! convention:
| name | return | check | handle |
|---|---|---|---|
foo |
T |
run-time | rescue |
foo? |
`(T | Nil)` | compile-time |
foo! |
`(T | Error)` | run-time, ensuring handle of all braches at compile-time |
TLDR; I don't what to be severe everywhere, but when I do, I don't want performance penalty and I want to use unions
PS: Having errors duplicated because of this can be a problem... The best thing I can think of now is having a convention with FooException for raise and FooError for union return.
What do you think? And thanks for reading :sweat_smile:
This is not exactly related, but, In my opinion, when a func can return T|Nil and has alternative that raises - then they should be named foo? and foo! respectively. That is: foo should not exists. For "lone" foo - I expect it to return T, or when results may be nil too: T|Nil. But not raise. So foo raises, foo? returns T|Nil feels "dangerous" imo.
Imagine using a class, and lib-author has defined getter on an ivar. Then the lib-author later change it to getter! in an update. Unexpected things will happen in your code. (Of course - such a change should be covered by semver major bump - but... Reality == Mistakes)
If this is simply a performance issue, is there any fundamental reason why exceptions have to be implemented by stack unwinding (obviously for printing stacktraces the stack has to be unwound) and not a more performant method? Crystal has the benefit of being able to see the program as a whole, and possibly reduce or remove exception throwing overhead (although I might be missing some fundamental roadblock), so if that was the case, would explicit return of errors be useful any more?
Being able to return Foo|Err, where Err is defined to be "falsey" at class-method level would be awesome (allowing generalizing the Foo|Nil conditional check type narrowing, allowing, perhaps even: Any|Types|AsLong|AsOnlyOne|IsTruthy - although that would not likely be a real world scenario: handling just a two-type-union would be good enough:
if x = maybe-foo()
p "x is a Foo here"
else
p "x is a Err here"
That is, if x is "truthy", we know it's the type that is truthy, since the other is defined as being a "falsey type", likewise, x is narrowed to the other type in the other branch.
As mentioned above, and in other issues I believe, when failures are expected to happen - this would be the clean (and more performant) way to implement the code rather than by throwing exceptions - with should be _exceptional.
Likely not going to change. For now, we are free to use unions and block yielding as alternative of exceptions. Besides performance, which should not be that huge deal since exceptions should be... exceptional, and Crystal being fast, one disadvantage of exceptions is knowing all possible errors of a method.
I propose to close this issue, such discussion can now go to the forum.
By the way, if you want to be absolutely safe the correct return type is a disjoint union such as {ok: T} | {err: E}, especially in situations where nil is a valid return value (e.g. Array(String?)#find). Then in an ideal world we would pattern-match on a NamedTuple's keys instead of augmenting the truthiness semantics.
Crystal also doesn't aim for bare-bones performance at the moment, compared to certain other LLVM-based languages.
exceptions are bad when dealing with untrusted sources, they are a totally avoidable performance penalty.
This premise isn't entirely true: performance of different error handling mechanisms depends a lot on specific circumstances, such as the ratio between success and error cases, for example. Exceptions are actually quite performant for the success path and only introduce overhead when an exception is raised. Errors in return values on the other hand require a status check every time in order to determine whether the result is a success value or an error. And this may happen in multiple locations in the call stack. (read more: https://purplesyringa.moe/blog/you-might-want-to-use-panics-for-error-handling/)
From a performance point of view, I think a good rule of thumb is that exceptions are best for errors which are expected to occur very rarely in comparison to a happy result. Error return values are better for errors that are expected to happen more frequently.