dart-lang / sdk

The Dart SDK, including the VM, JS and Wasm compilers, analysis, core libraries, and more.
https://dart.dev
BSD 3-Clause "New" or "Revised" License
10.08k stars 1.56k forks source link

Add `autoclosure` keyword #30988

Open joeconwaystk opened 6 years ago

joeconwaystk commented 6 years ago

The autoclosure keyword allows an expression to be automatically wrapped in a closure when passing it as an argument. For example, consider the case where a method takes a closure with no arguments and a return value.

String f(String expression()) {
  return expression();
}

var result = f(() => "Hello");
print(result); // Prints 'Hello'

A method argument with autoclosure would take an expression instead of a closure. The body of the generated closure is that expression.

String f(autoclosure String expression()) {
  return expression();
}

var result = f("Hello");
print(result); // Prints 'Hello'

This is valuable in a number of scenarios. For example, a Logger does not type its parameter so that the value may be a String or a closure:

logger.info("Hello");
logger.fine(() => "Hello");

This interface is surprising to the user in many ways; most notably, it was responsible for a significant performance problem in a popular JSON parsing library (see here).

In this case, autoclosure would have enforced that:

eernstg commented 6 years ago

This is basically a call-by-name mechanism, similar to the use of types on the form => T in Scala (note that the left operand of => is omitted, i.e., the argument list is not empty, it is missing). An example in Scala can be seen here.

It may be tempting to have such a feature, but it is worth noting that it depends crucially on static analysis: The semantics of passing an actual argument is modified for exactly those arguments which are declared to be call-by-name. That property is only known when the invoked function is known (in sufficient detail). As long as only statically checked invocations are supported and function types allow for a representation of this information, it is a simple matter of manipulating some abstract syntax trees representing actual arguments during compilation, and letting the rest of the compiler work with the modified argument (as in your example, before: "Hello", after: () => "Hello").

So let's consider a dynamic invocation (could be x.foo("Hello") where x has type dynamic).

The semantics of an expression given as a call-by-name argument is very different from the semantics of other actual arguments: The regular argument gets evaluated and the resulting value is passed; for the call-by-name argument we need to capture the expression as such (and we do not otherwise have a representation of expressions at runtime, so that's an extra cost which is imposed on all dynamic invocation sites), and then we need to create a closure at run time (which is beyond the capabilities of the current mirror system, and even that is considered so costly in terms of program size that it isn't fully supported). You might also consider compiling many different versions of every dynamic call (such that for a dynamic call foo(1, 2, 3) we compile 8 versions of the invocation, corresponding to the 2^3 different combinations of regular_argument/call-by-name_argument, and then we use some reflection-ish mechanisms to select which one to call, each time that call site is reached).

I hope this illustrates that it would be a substantial cost on dynamic invocations to support this kind of call-by-name arguments consistently.

joeconwaystk commented 6 years ago

What if dynamic invocation triggered a runtime error?

So a parameter marked as autoclosure is of type Function. When static information is available, the AST is manipulated such that the expression is in fact a Function. If no static information is available because of a dynamic invocation, the same error for passing a String when expecting a Function is thrown. In essence, using dynamic invocation + autoclosure is forbidden.

eernstg commented 6 years ago

That's certainly possible.

However, it does create an inconsistency in the semantics because foo(e) will do very different things when foo is typed as Function and when it is typed as something like void Function(autoclosure int Function()).

In the design of Dart it was originally intended that type annotations should not affect the dynamic semantics. It wasn't ever 100% (e.g., (int x) => x is int Function(int) will be true and (double x) => x is int Function(int) will be false, due to the change in the type annotation on the parameter, and it has always been possible in Dart to express a test with that meaning), but every step down the path where types are allowed to change the run-time semantics of an expression adds an extra cognitive cost on developers: When they write a function using autoclosure they can't just reason about what it will do, they also need to reason about "what will it do when it has the type T1? and when it has the type T2? ...).

As an example where language designers concluded that it went too far, Martin Odersky explained in his PLDI 2017 keynote (https://pldi17.sigplan.org/track/pldi-2017-keynotes) that they are trying to get rid of implicit coercions (where foo(e) is transformed into foo(g(e)) for some g which is in scope and declared implicit, when g will transform the result from evaluating e into a value of the type expected by foo). It's basically about having too much 'spooky action at a distance'.

joeconwaystk commented 6 years ago

I'm agree with on you the arguments against, but I think the benefit outweighs the negatives. It transfers a per-instance cost to the consumers of an API, to a one-time cost to the producer of the API.

It is a sparingly used feature in Swift, but when it is useful, it is very useful. Granted, the strict static typing of Swift means there is very little drawback, so it is a different scenario.

I think it could certainly be useful in Dart.

eernstg commented 6 years ago

Right, I acknowledge those benefits. I think the trade-offs are on the table now, so I won't comment further at this point.