javastackthrowthrowable

Using Throwable for Things Other than Exceptions


I have always seen Throwable/Exception in the context of errors. But I can think of situations where it would be really nice to extend a Throwable just to break out of a stack of recursive method calls. Say, for example, you were trying to find and return some object in a tree by the way of a recursive search. Once you find it stick it in some Carrier extends Throwable and throw it, and catch it in the method that calls the recursive method.

Positive: You don't have to worry about the return logic of the recursive calls; since you found what you needed, why worry how you would carry that reference back up the method stack.

Negative: You have a stack trace that you don't need. Also the try/catch block becomes counter-intuitive.

Here is an idiotically simple usage:

public class ThrowableDriver {
    public static void main(String[] args) {
        ThrowableTester tt = new ThrowableTester();
        try {
            tt.rec();
        } catch (TestThrowable e) {
            System.out.print("All good\n");
        }
    }
}

public class TestThrowable extends Throwable {

}

public class ThrowableTester {
    int i=0;

    void rec() throws TestThrowable {
        if(i == 10) throw new TestThrowable();
        i++;
        rec();
    }
}

The question is, is there a better way to attain the same thing? Also, is there something inherently bad about doing things this way?


Solution

  • Actually, it's an excellent idea to use exceptions in some cases where "normal" programmers wouldn't think of using them. For instance, in a parser that starts down a "rule" and discovers that it doesn't work, an exception is a pretty good way to blow back to the correct recovery point. (This is similar to a degree to your suggestion of breaking out of recursion.)

    There is the classical objection that "exceptions are no better than a goto", which is patently false. In Java and most other reasonably modern languages you can have nested exception handlers and finally handlers, and so when control is transferred via an exception a well-designed program can perform cleanup, etc. In fact, in this way exceptions are in several ways preferable to return codes, since with a return code you must add logic at EVERY return point to test the return code and find and execute the correct finally logic (perhaps several nested pieces) before exiting the routine. With exception handlers this is reasonably automatic, via nested exception handlers.

    Exceptions do come with some "baggage" -- the stack trace in Java, eg. But Java exceptions are actually quite efficient (at least compared to implementations in some other languages), so performance shouldn't be a big issue if you're not using exceptions too heavily.

    [I'll add that I have 40 years of programming experience, and I've been using exceptions since the late 70s. Independently "invented" try/catch/finally (called it BEGIN/ABEXIT/EXIT) ca 1980.]

    An "illegal" digression:

    I think the thing that is often missed in these discussions is that the #1 problem in computing is not cost or complexity or standards or performance, but control.

    By "control" I don't mean "control flow" or "control language" or "operator control" or any of the other contexts where the term "control" is frequently used. I do sort of mean "control of complexity", but it's more than that -- it's "conceptual control".

    We've all done it (at least those of us that have been programming for longer than about 6 weeks) -- started out writing a "simple little program" with no real structure or standards (other than those we might habitually use), not worrying about its complexity, because it's "simple" and a "throwaway". But then, in maybe one case in 10 or one case in 100, depending on the context, the "simple little program" grows into a monstrosity.

    We loose "conceptual control" over it. Fixing one bug introduces two more. The control and data flow of the program becomes opaque. It behaves in ways that we can't quite comprehend.

    And yet, by most standards, this "simple little program" is not that complex. It's not really that many lines of code. Very likely (since we are skilled programmers) it's broken into an "appropriate" number of subroutines. Run it through a complexity measuring algorithm and likely (since it is still relatively small and "subroutine-ized") it will score as not particularly complex.

    Ultimately, maintaining conceptual control is the driving force behind virtually all software tools and languages. Yes, things like assemblers and compilers make us more productive, and productivity is the claimed driving force, but much of that productivity improvement is because we don't have to busy ourselves with "irrelevant" details and can focus instead on the concepts we want to implement.

    Major advancements in conceptual control occurred early in computing history as "external subroutines" came into existence and became more and more independent of their environments, allowing a "separation of concerns" where a subroutine developer did not need to know much about the subroutine's environment, and the user of the subroutine did not need to know much about the subroutine internals.

    The simple development of BEGIN/END and "{...}" produced similar advancements, as even "inline" code could benefit from some isolation between "out there" and "in here".

    Many of the tools and language features that we take for granted exist and are useful because they help maintain intellectual control over ever more complex software structures. And one can pretty accurately gauge the utility of a new tool or feature by how it aids in this intellectual control.

    One if the biggest remaining areas of difficulty is resource management. By "resource" here, I mean any entity -- object, open file, allocated heap, etc -- that might be "created" or "allocated" in the course of program execution and subsequently need some form of deallocation. The invention of the "automatic stack" was a first step here -- variables could be allocated "on the stack" and then automatically deleted when the subroutine that "allocated" them exited. (This was a very controversial concept at one time, and many "authorities" advised against using the feature because it impacted performance.)

    But in most (all?) languages this problem still exists in one form or another. Languages that use an explicit heap have the need to "delete" whatever you "new", eg. Opened files must be closed somehow. Locks must be released. Some of these problems can be finessed (using a GC heap, eg) or papered over (reference counts or "parenting"), but there's no way to eliminate or hide all of them. And, while managing this problem in the simple case is fairly straight-forward (eg, new an object, call the subroutine that uses it, then delete it), real life is rarely that simple. It's not uncommon to have a method that makes a dozen or so different calls, somewhat randomly allocating resources between the calls, with different "lifetimes" for those resources. And some of the calls may return results that change the control flow, in some cases causing the subroutine to exit, or they may cause a loop around some subset of the subroutine body. Knowing how to release resources in such a scenario (releasing all the right ones and none of the wrong ones) is a challenge, and it gets even more complex as the subroutine is modified over time (as all code of any complexity is).

    The basic concept of a try/finally mechanism (ignoring for a moment the catch aspect) addresses the above problem fairly well (though far from perfectly, I'll admit). With each new resource or group of resources that needs to be managed the programmer introduces a try/finally block, placing the deallocation logic in the finally clause. In addition to the practical aspect of assuring that the resources will be released, this approach has the advantage of clearly delineating the "scope" of the resources involved, providing a sort of documentation that is "forcefully maintained".

    The fact that this mechanism is coupled with the catch mechanism is a bit of serendipity, as the same mechanism that is used to manage resources in the normal case is used to manage them in the "exception" case. Since "exceptions" are (ostensibly) rare, it is always wise to minimize the amount of logic in that rare path, since it will never be as well tested as the mainline, and since "conceptualizing" error cases is particularly difficult for the average programmer.

    Granted, try/finally has some problems. One of the first among them is that the blocks can become nested so deeply that the program structure becomes obscured rather than clarified. But this is a problem in common with do loops and if statements, and it awaits some inspired insight from a language designer. The bigger problem is that try/finally has the catch (and even worse, exception) baggage, meaning that it is inevitably relegated to be a second-class citizen. (Eg, finally doesn't even exist as a concept in Java bytecodes, beyond the now-deprecated JSB/RET mechanism.)

    There are other approaches. IBM iSeries (or "System i" or "IBM i" or whatever they call it now) has the concept of attaching a cleanup handler to a given invocation level in the call stack, to be executed when the associated program returns (or exits abnormally). While this, in its current form, is clumsy and not really suited to the fine level of control needed in a Java program, eg, it does point at a potential direction.

    And, of course, in the C++ language family (but not Java) there is the ability to instantiate a class representative of the resource as an automatic variable and have the object destructor provide "cleanup" on exit from the variable's scope. (Note that this scheme, under the covers, is essentially using try/finally.) This is an excellent approach in many ways, but it requires either a suite of generic "cleanup" classes or the definition of a new class for each different type of resource, creating a potential "cloud" of textually bulky but relatively meaningless class definitions. (And, as I said, it's not an option for Java in its present form.)

    But I digress.