ceylon / ceylon-spec

DEPRECATED
Apache License 2.0
108 stars 34 forks source link

Investigate checked exceptions as union returns #1341

Open sadmac7000 opened 9 years ago

sadmac7000 commented 9 years ago

In C, there are no exceptions. Rather we signal errors by using special return codes.

int get_int_from_socket()
{
    ...
    if (read_failed)
        return -1;
    return value_read;
}

The most problematic part of this is that we have to reserve a special value in our return type to signal an error. So the function above must never read a -1 as a normal value.

But something interesting happens if we port this to Ceylon and solve that problem with union types:

Integer|IOException getIntFromSocket() {
    ...
    if (read_failed) { return IOException("Read failed"); }
    return valueRead;
}

Consider what Ceylon's type system now enforces: the caller must check for the error, otherwise the return value isn't narrow enough to treat as an integer. In a sense this is like a checked exception, however it actually feels less clumsy than a checked exception. It doesn't depend on the exception type (an exception is checked if it is returned like this, and isn't if it is thrown), and it neatly matches error handling conventions for other languages like Haskell.

Checked exceptions require each direct caller to explicitly mention them, as does this mechanism. However, checked exceptions usually allow that to be done away with in an annotation (i.e. the exception can simply be "declared to be thrown" and then it will pass through the caller unhandled). This version requires the exception to be handled. However, we can nicely minimize that with a minor language feature change. Consider this statement:

assert(is Integer i = getIntegerFromSocket());

Suppose we said that this would throw IOException on failure, not an AssertionError. IOW suppose when a statement of the form assert(is ...) fails the object we have is actually of some exception type, we throw the object itself rather than an AssertionError. This is now not much more clunky than annotations for normal checked exceptions and feels much more semantically regular with the rest of the language.

There are other things we can investigate that would unite the C-like error handling path with exception handling to give us a cleaner and less demanding checked exception system, but I'll leave this as a start.

gavinking commented 9 years ago

That's actually a pretty cool idea, Casey. I like it a lot!

Sent from my iPhone

On 7 Jun 2015, at 1:22 pm, Casey Dahlin notifications@github.com wrote:

In C, there are no exceptions. Rather we signal errors by using special return codes.

int get_int_from_socket() { ... if (read_failed) return -1; return value_read; } The most problematic part of this is that we have to reserve a special value in our return type to signal an error. So the function above must never read a -1 as a normal value.

But something interesting happens if we port this to Ceylon and solve that problem with union types:

Integer|IOException getIntFromSocket() { ... if (read_failed) { return IOException("Read failed"); } return valueRead; } Consider what Ceylon's type system now enforces: the caller must check for the error, otherwise the return value isn't narrow enough to treat as an integer. In a sense this is like a checked exception, however it actually feels less clumsy than a checked exception. It doesn't depend on the exception type (an exception is checked if it is returned like this, and isn't if it is thrown), and it neatly matches error handling conventions for other languages like Haskell.

Checked exceptions require each direct caller to explicitly mention them, as does this mechanism. However, checked exceptions usually allow that to be done away with in an annotation (i.e. the exception can simply be "declared to be thrown" and then it will pass through the caller unhandled). This version requires the exception to be handled. However, we can nicely minimize that with a minor language feature change. Consider this statement:

assert(is Integer i = getIntegerFromSocket()); Suppose we said that this would throw IOException on failure, not an AssertionError. IOW suppose when a statement of the form assert(is ...) fails the object we have is actually of some exception type, we throw the object itself rather than an AssertionError. This is now not much more clunky than annotations for normal checked exceptions and feels much more semantically regular with the rest of the language.

There are other things we can investigate that would unite the C-like error handling path with exception handling to give us a cleaner and less demanding checked exception system, but I'll leave this as a start.

— Reply to this email directly or view it on GitHub.

pthariensflame commented 9 years ago

I like this, but I think special-casing assert(is ...) like that is a bad idea. Maybe introduce assert(try ...), or something like that?

gavinking commented 9 years ago

@pthariensflame I dunno, it seems pretty natural to me. I mean, in the case that the assert fails, I have the choice between:

  1. discarding the return value of getIntegerFromSocket(), which we know is an Exception and throwing a totally useless AssertionException, or
  2. just throwing the return value itself, thus preserving the information it contains.

Seems like 2 is simply a better and overall more friendly behavior. I like it so much, in fact, that it makes me feel like I would have considered using this pattern for out of bounds List indexes and the like, returning Element|IndexOutOfBounds instead of Element|Null like we do now. Too later for such a change now, but if I would have thought of it a few years ago...

The only thing that makes me slightly uncomfortable about it is what if the exception type is not an AssertionException. Does this behavior still apply? Or is it only for AssertionExceptions? That's not crystal clear to me.

jpragey commented 9 years ago

I've been using union types as error management for a while, and it always ended up with a dedicated final class having a list of causes - so it's a tree of errors instead of a list. It's not only convenient when you don't want a function to stop at the first error, but it also solves the classical java problem of exceptions thrown in finally blocks hiding original exceptions.

My error classes typically look like:

shared final class Error(shared String description, shared [Error *] causes = []) {

    shared actual String string => "Error :[ \"``description``\", causes:``causes``]";

    shared void printIndented(Anything(String) write, Integer indentCount = 0) {
        write("`` " ".repeat(indentCount) ````description``");
        causes*.printIndented(write, indentCount + 1);
    }
}

Now consider a function that computes some result and closes some result, both throwing:

    Integer computeSomething() {throw Exception("Processing exception");}
    void closeResource() {throw Exception("Close exception");}
    void doTheJob() {
        try {
            Integer result = computeSomething();    // throws
            print("Result: ``result``"); 
        } finally {
            closeResource(); // Hides computeSomething() exception 
        }
    }

    // Now use it
    try {
        doTheJob();
    } catch (Exception e) {
        e.printStackTrace(); // "Processing exception" is lost
    }

"Processing exception" is lost. Now with return values:

    Integer|Error computeSomething() {return Error("Processing error");}
    Error? closeResource() {return Error("Close error");}
    Error? doTheJob() {
        switch(result = computeSomething())
        case(is Error) {
            if(exists closeError = closeResource()) {
                return Error("doTheJob() failed:", [result, closeError]); // No lost error!
            }
        }
        case(is Integer) {
            print("Result: ``result``");
        }
        value closeError = closeResource(); //... but we have to call closeResource() twice
        return closeError;
    }

    // Now use it
    if(exists error = doTheJob()) {
        error.printIndented(print);
    }

It happily prints both errors. Drawbacks:

So IMHO It's the way to go to replace checked exceptions, but some syntax improvement would be very welcome.

A final word on using Exception as error class: switching on Result|Exception types is convenient, but doesn't work if Result is an interface, as Result and Exception are not provably disjoint. It works with my Error class because it's final - and it also works with Exception final subclasses. But Java Exceptions are not final.

jvasileff commented 9 years ago

Sadly it's currently an Assertion*Error*, which

a reasonable application should not try to catch

And passing around an object that indicates a problem so severe as to question the integrity of the running process seems like a bad idea. So I don't think Errors should be subclassed to indicate things like IOException or IOOB (or even the current NPE, CCE, etc equivalents). Doing so destroys the value in having a distinction between Exception and Error in the first place.

So I like @pthariensflame's suggestion. Basically, something that has the narrowing properties of assert(!is Exception x), but throws the Exception rather than a new AssertionError.

luolong commented 9 years ago

what about re-purposing try construct.

Integer|IOException getIntFromSocket() {
    ...
    if (read_failed) { return IOException("Read failed"); }
    return valueRead;
}

try (is Integer i = getIntegerFromSocket()) {
    ...
}
catch (IOException e) {
    ...
}

I know, this overloads try construct with yet another new semantic, but in some way, it has some internal logic to it...

gavinking commented 9 years ago

@luolong well it does seem to me that the most "natural" thing to want to write is:

try (i = getIntegerFromSocket());
print(i);

But yeah, it does run into the problem that it looks just like a resource expression, and the semantics are nothing like a resource expression.

What we really need is a keyword like exists or nonempty that means !is Exception. But introducing a new keyword is always a bit of a nasty thing to do.

Perhaps @pthariensflame's suggestion was the best option after all:

assert (try i = getIntegerFromSocket());
print(i);

The thing is that assert (try ... ) reads a bit funny to me.

RossTate commented 9 years ago

So I liked this at first, but then I remembered why errors-in-return-values is troublesome even if you don't forgot to handle the error. If you are calling multiple possibly-erroneous functions in a row, you have to explicitly handle the possible error in every function, and often you do so in the same way for each of them. This is what try-catch blocks are good for: when there may be multiple places an error can arise and you want to handle the error the same way regardless of where it happens. Having resource blocks as well handles the most common situation where your handler needs to change just a bit depending on where the error occurs.

So try calling multiple IO functions in a row, not just one, and see what you think of how this works for that (more common?) case.

jvasileff commented 9 years ago

+1 @RossTate

In fact, there is a huge class of errors that is just annoying to be forced to deal with/acknowledge.

sadmac7000 commented 9 years ago

I don't think it's much worse than what we have with optional types. What if we did it as an operator?

value gotInt = try getIntegerFromSocket();
sadmac7000 commented 9 years ago

Or... or.....

value gotInt = throw getIntegerFromSocket();
jvasileff commented 9 years ago

I don't think it's much worse

My concern is more about IOException and similar being used as checked exceptions. But in cases that we normally want (and already have) conditional logic, I like the idea of language support for more useful markers than Null. Maybe for Uri|ParseException parseUri(...).

I've thought about this from the other angle before—making else more flexible:

String|Exception x = something();
String y = x else(Exception) "default"; // not exactly this syntax, but you get the idea

or for types like Finished|Whatever and Undefined|Whatever.

jvasileff commented 9 years ago

try getIntegerFromSocket()

Funny, that's the exact syntax that was introduced for Swift today to call functions that may throw.

I like the throw version.

RossTate commented 9 years ago

Could you have some Absent abstract type that things like Null and Exception could extend? It would be disjoint from Object.

pthariensflame commented 9 years ago

@RossTate As in Anything of Object|Absent and then Absent of Null|Exception?

gavinking commented 9 years ago

@RossTate no, and I think it would be extremely disruptive to introduce such a thing at this stage.

If we would have thought of the idea sooner, perhaps we could have placed Exceptions on the Null branch of the class hierarchy, instead of on the Object branch. I'm not completely sure that it would have worked out—it would have caused problems if you ever wanted to put an exception in a collection or whatever—but perhaps it could have been made to work.

But I guess it's too late for that now.