Draco-lang / Language-suggestions

Collecting ideas for a new .NET language that could replace C#
75 stars 5 forks source link

[WIP] Exceptions, try-catch-finally #54

Open LPeter1997 opened 2 years ago

LPeter1997 commented 2 years ago

Introduction

Since there is not a lot of variation or redesign-choices here, I'll just propose the constructs for throwing and try-catch-finally.

Throwing construct

There is not a lot of variation here, throwing itself is an expression with the syntax:

throw expr

The returned expression type (even though the expression does not evaluate to anything, it needs a type) could be converted to any type implicitly, so type-inference does not trip up in cases like this:

func div(a: int, b: int): int =
    if (b == 0) throw DivByZeroExcepton()
    else a / b;

Try-catch-finally

This is the point where there is a bit more variation, I'll go through all I've come across. Since all other of our control structures are expressions, it would make sense for try-catch-finally as well.

C

Since the C# variant does not evaluate to values like expressions, it's not that interesting for us. In the most general form, try is mandatory, after that either a catch or finally block has to follow. If there is a finally block, there can be any number of catch blocks. Catch blocks try to match the exception type to the thrown exception, even allowing for a filter expression with the keyword when, implementing a limited pattern matching system. Full docs can be found here.

Personally, I think it's a bit overengineered, and became a bit inconsistent after pattern matching hit the scene. It's a decision from the past, so there's really no one to blame for it.

F

F# decided to introduce two different constructs, try-with and try-finally. This means, if you need all 3 blocks, you'd have to nest them (example taken from the docs):

let function1 x y =
   try
     try
        if x = y then raise (InnerError("inner"))
        else raise (OuterError("outer"))
     with
      | InnerError(str) -> printfn "Error1 %s" str
   finally
      printfn "Always print this."

An interesting note is that with (which is the equivalent of catch in C#) is actually a regular pattern-matching construct, which actually makes a lot of sense. Instead of implementing specialized systems with multiple catch blocks and filters, F# simply utilizes the fact that it already has pattern matching.

In try-with, if there are no exceptions, the try body is evaluated as a result, otherwise, an exception is matched in with, and the corresponding value is returned. In try-finally, only the try contributes to the result.

Personally, I think this nesting can be really annoying, and it was a quite inelegant design.

Kotlin

In Kotlin, try-catch-finally is also an expression:

val a: Int? = try { input.toInt() } catch (e: NumberFormatException) { null }

The semantics is very simple: if there are no exceptions, the body of try is returned, otherwise the body of the corresponding catch block is returned. The finally block does not contribute to the expression result.

Personally, I find this the best semantics out of all. What I found odd, is that Kotlin also does multiple catch blocks instead of utilizing its pattern-matching.

Proposed for Fresh

I'd like to propose a mixture of Kotlin and F# for our construct. More precisely, the evaluation semantics of Kotlin, and the pattern matching of F#. This way we get multi-catch free, get the filters of C# free and are able to utilize everything that will be present in the language for pattern matching.

Syntax proposal:

val a = try {
    int.Parse(line) / b
}
catch {
    DivByZeroException -> int.MaxValue;
    _ -> 0;
};

We could even have a one-liner versions, or a version of catch that simply matches everything and returns the specified value ("swallow-all" catch):

// Catch that simply ignores
val a = try { int.Parse(line) } catch { 0 };
// One-liner version
val a = try int.Parse(line) catch 0;

A one-liner catch could give us the standard try-single-catch blocks in C#:

try {
    // ...
}
catch Exception e -> {
    // ...
}

The finally block does not contribute to the evaluated expression, it is simply executed after the evaluation of try-catch. Example:

val a = try {
    int.Parse(line) / b
}
catch {
    DivByZeroException -> int.MaxValue;
    _ -> 0;
}
finally {
    WriteLine("This is always printed");
};

There can only be a single catch and a single finally block and at least one has to be present (there can't be a try block by itself). If there is a catch block, it has to evaluate to a compatible type with try.

svick commented 2 years ago

The returned expression type (even though the expression does not evaluate to anything, it needs a type) could be converted to any type implicitly

Would it make sense to have a more explicit support for the bottom type? Though I'm not sure how well that would work in the context of .Net, which doesn't have such a type. (There is the DoesNotReturnAttribute, but that only exists for nullable reference types.)

We could even have a one-liner versions, or a version of catch that simply matches everything and returns the specified value ("swallow-all" catch)

I think a catch-all is usually a bad idea (at least in production code), especially if it ignores the exception. So it shouldn't be encouraged by having a succinct syntax.

LPeter1997 commented 2 years ago

Would it make sense to have a more explicit support for the bottom type?

Definitely, we should consider bottom type as an option. It might be something that makes more sense on type level, than attribute level. We'll have to play around with the idea a bit.

I think a catch-all is usually a bad idea (at least in production code), especially if it ignores the exception. So it shouldn't be encouraged by having a succinct syntax.

Fair. Initially, we could only include the pattern matching syntax, then see how that plays out.

eatdrinksleepcode commented 2 years ago

Would it make sense to have a more explicit support for the bottom type?

Yeah came here to mention that Kotlin's throw expression has the type Nothing, which is a bottom type that is useful for having throw helpers (which are very common in .NET code IME) still be able to participate correctly in flow analysis.

(Also TIL that the type theory name for this concept is "bottom type".)