Search code examples
exceptionrakugotorakudo

Actually CATCHing exceptions without creating GOTO


Looking over my Raku code, I've realized that I pretty much never use CATCH blocks to actually catch/handle error. Instead, I handle errors with try blocks and testing for undefined values; the only thing I use CATCH blocks for is to log errors differently. I don't seem to be alone in this habit – looking at the CATCH blocks in the Raku docs, pretty much none of them handle the error in any sense beyond printing a message. (The same is true of most of the CATCH blocks in Rakudo.).

Nevertheless, I'd like to better understand how to use CATCH blocks. Let me work through a few example functions, all of which are based on the following basic idea:

sub might-die($n) { $n %% 2 ?? 'lives' !! die 418 }

Now, as I've said, I'd normally use this function with something like

say try { might-die(3) } // 'default';

But I'd like to avoid that here and use CATCH blocks inside the function. My first instinct is to write

sub might-die1($n) {
    $n %% 2 ?? 'lives' !! die 418
    CATCH { default { 'default' }}
}

But this not only doesn't work, it also (very helpfully!) doesn't even compile. Apparently, the CATCH block is not removed from the control flow (as I would have thought). Thus, that block, rather than the ternary expression, is the last statement in the function. Ok, fair enough. How about this:

    sub might-die2($n) {
ln1:    CATCH { default { 'default' }}
ln2:    $n %% 2 ?? 'lives' !! die 418
    }

(those line numbers are Lables. Yes, it's valid Raku and, yes, they're useless here. But SO doesn't give line numbers, and I wanted some.)

This at least compiles, but it doesn't do what I mean.

say might-die2(3);  # OUTPUT: «Nil»

To DWIM, I can change this to

    sub might-die3($n) {
ln1:    CATCH { default { return 'default' }}
ln2:    $n %% 2 ?? 'lives' !! die 418
    }
say might-die3(3);  # OUTPUT: «'default'»

What these two reveal is that the result of the CATCH block is not, as I'd hopped, being inserted into control flow where the exception occurred. Instead, the exception is causing control flow to jump to the CATCH block for the enclosing scope. It's as though we'd written (in an alternate universe where Raku has a GOTO operator [EDIT: or maybe not that alternate of a universe, since we apparently have a NYI goto method. Learn something new every day…]

    sub might-die4($n) {
ln0:    GOTO ln2;
ln1:    return 'default';
ln2:    $n %% 2 ?? 'lives' !! GOTO ln1;
    }

I realize that some critics of exceptions say that they can reduce to GOTO statements, but this seems to be carrying things a bit far.

I could (mostly) avoid emulating GOTO with the .resume method, but I can't do it the way I'd like to. Specifically, I can't write:

    sub might-die5($n) {
ln1:    CATCH { default { .resume('default') }}
ln2:    $n %% 2 ?? 'lives' !! die 418
    }

Because .resume doesn't take an argument. I can write

    sub might-die6($n) {
ln1:    CATCH { default { .resume }}
ln2:    $n %% 2 ?? 'lives' !! do { die 418; 'default' }
    }
say might-die6 3;  # OUTPUT: «'default'»

This works, at least in this particular example. But I can't help feeling that it's more of a hack than an actual solution and that it wouldn't generalize well. Indeed, I can't help feeling that I'm missing some larger insight behind error handling in Raku that would make all of this fit together better. (Maybe because I've spent too much time programming in languages that handle errors without exceptions?) I would appreciate any insight into how to write the above code in idiomatic Raku. Is one of the approaches above basically correct? Is there a different approach I haven't considered? And is there a larger insight about error handling that I'm missing in all of this?


Solution

  • "Larger insight about error handling"

    Is one of the approaches [in my question] basically correct?

    Yes. In the general case, use features like try and if, not CATCH.

    Is there a different approach I haven't considered?

    Here's a brand new one: catch. I invented the first version of it a few weeks ago, and now your question has prompted me to reimagine it. I'm pretty happy with how it's now settled; I'd appreciate readers' feedback about it.

    is there a larger insight about error handling that I'm missing in all of this?

    I'll discuss some of my thoughts at the end of this answer.

    But let's now go through your points in the order you wrote them.

    KISS

    I pretty much never use CATCH blocks to actually catch/handle error.

    Me neither.

    Instead, I handle errors with try blocks and testing for undefined values

    That's more like it.

    Logging errors with a catchall CATCH

    the only thing I use CATCH blocks for is to log errors differently.

    Right. A judiciously located catchall. This is a use case for which I'd say CATCH is a good fit.

    The doc

    looking at the CATCH blocks in the Raku docs, pretty much none of them handle the error in any sense beyond printing a message.

    If the doc is misleading about:

    • The limits of the capabilities and applicability of CATCH / CONTROL blocks; and/or

    • The alternatives; and/or

    • What's idiomatic (which imo is not use of CATCH for code where try is more appropriate (and now my new catch function too?)).

    then that would be unfortunate.

    CATCH blocks in the Rakudo compiler source

    (The same is true of most of the CATCH blocks in Rakudo.).

    At a guess those will be judiciously placed catchalls. Placing one just before the callstack runs out, to specify default exception handling (as either a warning plus .resume, or a die or similar), seems reasonable to me. Is that what they all are?

    Why are phasers statements?

    sub might-die1($n) {
        $n %% 2 ?? 'lives' !! die 418
        CATCH { default { 'default' }}
    }
    

    this not only doesn't work, it also (very helpfully!) doesn't even compile.

    .oO ( Well that's because you forgot a semi-colon at the end of the first statement )

    (I would have thought ... the CATCH block [would have been] removed from the control flow)

    Join the club. Others have expressed related sentiments in filed bugs, and SO Q's and A's. I used to think the current situation was wrong in the same way you express. I think I could now easily be persuaded by either side of the argument -- but jnthn's view would be decisive for me.


    Quoting the doc:

    A phaser block is just a trait of the closure containing it, and is automatically called at the appropriate moment.

    That suggests that a phaser is not a statement, at least not in an ordinary sense and would, one might presume, be removed from ordinary control flow.

    But returning to the doc:

    Phasers [may] have a runtime value, and if evaluated [in a] surrounding expression, they simply save their result for use in the expression ... when the rest of the expression is evaluated.

    That suggests that they can have a value in an ordinary control flow sense.


    Perhaps the rationale for not removing phasers from holding their place in ordinary control flow, and instead evaluating to Nil if they don't otherwise return a value, is something like:

    • Phasers like INIT do return values. The compiler could insist that one assigns their result to a variable and then explicitly returns that variable. But that would be very un Raku-ish.

    • Raku philosophy is that, in general, the dev tells the compiler what to do or not do, not the other way around. A phaser is a statement. If you put a statement at the end, then you want it to be the value returned by its enclosing block. (Even if it's Nil.)


    Still, overall, I'm with you in the following sense:

    • It seems natural to think that ordinary control flow does not include phasers that do not return a value. Why should it?

    • It seems IWBNI the compiler at least warned if it saw a non-value-returning phaser used as the last statement of a block that contains other value-returning statements.

    Why don't CATCH blocks return/inject a value?

    Ok, fair enough. How about this:

        sub might-die2($n) {
    ln1:    CATCH { default { 'default' }}
    ln2:    $n %% 2 ?? 'lives' !! die 418
        }
    
        say might-die2(3);  # OUTPUT: «Nil»
    

    As discussed above, many phasers, including the exception handling ones, are statements that do not return values.

    I think one could reasonably have expected that:

    • CATCH phasers would return a value. But they don't. I vaguely recall jnthn already explaining why here on SO; I'll leave hunting that down as an exercise for readers. Or, conversely:

    • The compiler would warn that a phaser that did not return a value was placed somewhere a returned value was probably intended.


    It's as though we'd written ... a GOTO operator

    Raku(do) isn't just doing an unstructured jump.

    (Otherwise .resume wouldn't work.)

    this seems to be carrying things a bit far

    I agree, you are carrying things a bit too far. :P

    .resume

    Resumable exceptions certainly aren't something I've found myself reaching for in Raku. I don't think I've used them in "userspace" code at all yet.

    (from jnthn's answer to When would I want to resume a Raku exception?.)

    .resume doesn't take an argument

    Right. It just resumes execution at the statement after the one that led to an exception being thrown. .resume does not alter the result of the failed statement.

    Even if a CATCH block tries to intervene, it won't be able to do so in a simple, self-contained fashion, by setting the value of a variable whose assignment has thrown an exception, and then .resumeing. cf Should this Raku CATCH block be able to change variables in the lexical scope?.

    (I tried several CATCH related approaches before concluding that just using try was the way to go for the body of the catch function I linked at the start. If you haven't already looked at the catch code, I recommend you do.)

    Further tidbits about CATCH blocks

    They're a bit fraught for a couple reasons. One is what seems to be deliberate limits of their intended capability and applicability. Another is bugs. Consider, for example:

    Larger insight about error handling

    is there a larger insight about error handling that I'm missing in all of this?

    Perhaps. I think you already know most of it well, but:

    • KISS #1 You've handled errors without exceptions in other PLs. It worked. You've done it in Raku. It works. Use exceptions only when you need or want to use them. For most code, you won't.

    • KISS #2 Ignoring some native type use cases, almost all results can be expressed as valid or not valid, without leading to the semi-predicate problem, using simple combinations of the following Raku Truth value that provide ergonomic ways to discern between non-error values and errors:

      • Conditionals: if, while, try, //, et al

      • Predicates: .so, .defined, .DEFINITE, et al

      • Values/types: Nil, Failures, zero length composite data structures, :D vs :U type constraints, et al

    Sticking with error exceptions, some points I think worth considering:

    • One of the use cases for Raku error exceptions is to cover the same ground as exceptions in, say, Haskell. These are scenarios in which handling them as values isn't the right solution (or, in Raku, might not be).

    • Other PLs support exceptions. One of Raku's superpowers is being able to interoperate with all other PLs. Ergo it supports exceptions if for no other reason than to enable correct interoperation.

    • Raku includes the notion of a Failure, a delayed exception. The idea is you can get the best of both worlds. Handled with due care, a Failure is just an error value. Handled carelessly, it blows up like a regular exception.

    More generally, all of Raku's features are designed to work together to provide convenient but high quality error handling that supports all of the following coding scenarios:

    • Fast coding. Prototyping, exploratory code, one-offs, etc.

    • Control of robustness. Gradually narrowing or broadening error handling.

    • Diverse options. What errors should be signalled? When? By which code? What if consuming code wants to signal that producing code should be more strict? Or more relaxed? What if it's the other way around -- producing code wants to signal that consuming code should be more careful or can relax? What can be done if producing and consuming code have conflicting philosophies? What if producing code cannot be altered (eg it's a library, or written in another language)?

    • Interoperation between languages / codebases. The only way that can work well is if Raku provides both high levels of control and diverse options.

    • Convenient refactoring between these scenarios.

    All of these factors, and more, underlie Raku's approach to error handling.