dart-lang / language

Design of the Dart language
Other
2.65k stars 201 forks source link

A null-aware exception catching expression. #2656

Open lrhn opened 1 year ago

lrhn commented 1 year ago

Sometimes you want to catch an exception and just return null to signal that something went wrong. That's currently cumbersome since catching exceptions can only happen as a statement.

You can write a function like:

R? tryOrNull<R, E>(R Function() action) {
  try {
    return action();
  } on E {
    return null;
  }
}

It suffers from needing two type arguments, where you only want to provide the E one, and you need to introduce a closure when using it, so:

tryOrNull<int, Exception>(() => theCodeToRun(args));

I propose introducing an expression form of try looking like:

try(expression)
try<E>(expression)

The try operator evaluates expression to a value. If expression throws (if a type argument is provided, then only if it throws an E), the try expression evaluates to null, otherwise it evaluates to the result of evaluating expression.

Proposal

Grammar:

primary :: = ...
  | `try' <typeArguments>? '(' expression ')'

It's a compile-time error if there is more than one type argument.

An expression of the form try(e) is equivalent to an expression of the form try<Object>(e), where Object refers to the type from dart:core (whether it's in scope or not). You're allowed to write Object? or dynamic as the type argument, it won't make any difference, the type is only used for subtype checks on values that are non-null.

We won't need to restrict an expression statement from starting with try, since the grammar for try expressions and try statements differ at the second token. The first token after an expression try is ( or <, and it's{` for the statement, so there is no parsing ambiguity.

Semantics

Let e be an expression of the form try<T>(e2) or `try(e2)~.

Static

Static typing of e with context type schema C proceeds as:

Runtime

Evaluation of e proceeds as follows:

eernstg commented 1 year ago

Interesting! Note that this feature is somewhat similar to the proposals about adding other expressions with control flow, https://github.com/dart-lang/language/issues/2025, so it might be good to think about them as group.

Cat-sushi commented 1 year ago

I would like finally clause, as well.

await mutex.lock();
var ret = try<E>(retrieveData()) finally { mutex.unlock(); };

In comparison with the example below, the type of ret can be inferred. And, mutex.unlock() is proved even in case of exception other than E, as well as the example below.

await mutex.lock();
RetType? ret;
try {
  ret = retrieveData();
} on E {
  ret = null;
} finally {
  mutex.unlock();
}
lrhn commented 1 year ago

I like the finally clause. The only problem I see is that the suggested syntax won't allow you a try (e) finally { stmt } which doesn't catch exceptions. You'd have to write that as try<Never>(e) finally {stmt}, which is a little backwards.

Maybe make Never the default catch-type if a finally is supplied, and Object if no finally is supplied. The difference is still a little jarring.

Cat-sushi commented 1 year ago

I am not a syntax man, so I don't stick to such syntax.

a try (e) finally { stmt } which doesn't catch exceptions.

I'm not sure what does it mean.

await mutex.lock();
var ret = try /* <E> */ (retrieveData()) finally { mutex.unlock(); };

can't be treated as

await mutex.lock();
RetType? ret;
try {
  ret = retrieveData();
} catch (e) {
  ret = null;
} finally {
  mutex.unlock();
}

?

lrhn commented 1 year ago

The point is that if you only want the finally functionality, and don't want to catch any errors, corresponding to:

await mutex.lock();
RetType ret;
try {
  ret = retrieveData();
} finally {
  mutex.unlock();
}

then you couldn't just do var ret = try (retrieveData()) finally { mutex.unlock(); }, because the definition of try (expr) given above would catch Object. There was no syntax for try(...) without catching, because it wasn't needed. With finally, it might actually be needed.

So we could change try (expr) to not catch anything, but that's silly because then it doesn't do anything.

That's why I suggested:

try (e)       // Catches any thrown object and converts it to `null`
try<E>(e)  // Catches `E`s and converts them to `null`
try (e) finally {s}  // Catches no objects, executes {s}` afterwards. (Does not promote to nullable).
try<E>(e) finally {S} // catches `E`s and converts them to `null`, executes `{s}` afterwards.

That gives the shortest syntax to the simplest usable behavior, but allows you to explicitly write a catch type to override it.

You can still write try<Never>(e) to do ... well, nothing. Promote to nullable, I guess. Or try<Object>(e) finally {s} to catch every thrown object and do a finally afterwards.

Cat-sushi commented 1 year ago

and don't want to catch any errors

I did wanted catch all errors in my previous example.

I want some syntax not to catch any errors, as well. But, I think this is another feature request so called "make try able to be evaluated as expression" without introducing default null value, something like #27 and #307

Jetz72 commented 1 year ago

The idea of an expression try/catch came up in #1884, as a shorthand for declaring variables whose initialization processes can fail. Instead of needing to write a whole try block with the declaration outside it so the variable stays in the scope you need it in, you could just declare it and its fallback all in one line: var v = try(getValue()) ?? fallback; instead of

FooType v;
try {
  v = getValue();
}
on Object{
  v = fallback;
}

Having it evaluate to null rather than falling into a subsequent catch expression means you can't extract info from the exception and do anything useful with it (e.g. log a warning). But for cases where you don't need that, it does seem elegant to be able to use the existing null-aware tools to specify the fallback, so I like this idea.

Though if finally is being considered, I'd still like to suggest an optional catch clause to the expression that can use the exception in the fallback. E.g. in try<E>(foo()) catch((e, st) => bar(e)) (not sure about that syntax; ideally the StackTrace could optionally be left off), if foo() throws, use bar(e) as the value instead, where e is the exception (implicitly type E). If foo has static type T, bar (and the overall try/catch expression) should have type T?.