The Problem
In the context of multi-threaded programming, or dealing with a lot of objects with a “lifetime,” we often are forced to write code like this:
lockA.read {
lockB.write {
...
}
}
use(ResourceA()) { resourceA ->
use(ResourceB()) { resourceB ->
use(ResourceC()) { resourceC ->
...
}
}
}
Meanwhile in C++, programmers often simplify such places using RAII, or in other words, using stack objects’ constructors and destructors to handle scope or lifecycle automatically:
void MyFunction() {
ReadLock lock(&someLock);
... // The lock will be released when the function is complete.
}
void MyFunction2() {
ReadLock lockA(&someLockA);
ReadLock lockB(&someLockB);
... // The locks will be released in reverse order of declaration.
}
Setting aside the verbosity of C++, this syntax is much more concise, and accustomed programmers will have no difficulty reasoning about its meaning.
The Solution
While we don’t have stack-based object allocation in Kotlin, we could still reduce the number of lines and indentation with a purely syntactic change. The proposal is simply this: allow us to invoke lambda-accepting functions and pass their variables into the current scope without extra indentation or curly braces! Here’s the proposed syntax:
val resource = &use(MyResource())
...
val (x, y, z) = &myFunction()
...
&with(MyContext())
...
These calls would treat all code between them and the current scope’s closing brace as though it were inside a new lambda passed into the function – in other words, the following is equivalent to the above:
use(MyResource()) { resource ->
...
myFunction { x, y, z ->
...
with(MyContext()) {
...
}
}
}
Functions callable in this manner would be restricted to those annotated with contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
, since other cases necessarily involve transforming code in a bizarre way that falls outside helpful use cases, in my opinion.
With these changes, the examples given at the start would look like this:
&lockA.read()
&lockB.write()
...
val resourceA = &use(ResourceA())
val resourceB = &use(ResourceB())
val resourceC = &use(ResourceC())
...
Caveats
The contract pattern given above should be mandatory in my opinion. However, since the point of contracts is to alleviate the work of validation from the compiler by trusting the programmer, this feature could still be abused to create some truly bizarre code:
inline fun <T> Array<out T>.forEach2(block: (T) -> Unit) {
contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } // WRONG
this.forEach(block)
}
fun myBizarreCode() {
val array = arrayOf("x", "y", "z")
val v = &array.forEach2
println(v) // gets invoked three times!
}
However, this would be a pretty extreme case in my opinion – even the most basic language features can be horribly abused, but we still allow them to empower the best programmers, not protect against the worst. That aside, I would argue the above case falls well outside of ignorance and into malicious (if not hilarious) behavior.
Another possibility is simply not requiring a contract and always allowing the code transformation - while this could perhaps empower DSLs in some way (which are already fairly abstract as far as code goes), I think it’s too easy to misuse for a newcomer. The existing callsInPlace
contract pattern maps very well onto use cases we’d ever like to support, in my opinion.
Aside from requiring a certain contract
pattern, the biggest issue is discarding the called function’s return value if non-Unit
. I don’t believe restricting this syntax to functions returning Unit
is advisable, since we would often like to call functions returning a generic type, which often resolve to non-Unit
despite not being used as such. If the return value is needed for any reason, then it should simply be that you cannot use this syntax, as the extra curly braces and indentation are necessary to delineate lambda from non-lambda code in that case.
EDIT: another concern is how to handle when the lambda has a return value. The Unit
case is naturally fine, and the generic case can always infer a type of Unit
. For other cases, I’m not sure there’s a solution more permissive than simply disallowing it. Trying to handle the return type in some intelligent way would likely complicate semantics around return/break/continue
, and I don’t see a use case for it yet.
Alternative Syntax
I’m not at all decided on the specifics of the syntax. I thought it might be neat to reuse the arrow syntax somehow, but this seemed less clear to me than using the ampersand:
val resource <- use(MyResource())
val resource -> use(MyResource())
val resource => use(MyResource()) // I like this one, but C#'s existence makes this unclear imo
val resource = ^use(MyResource()) // very unique symbol, but bad parallel to managed GC in C++
val resource = *use(MyResource()) // parallel with spread operator, and dereference in C++
val resource = &use(MyResource()) // yet unused symbol, and parallel to reference in C++
I like the ampersand because it’s not used anywhere else in the language to my knowledge, and has parallels to C++. The star was a close contender to me, and even better in terms of analogies, but its smallness and use elsewhere make it seem less clear.
The use of val
and flipping the variables to the left-hand-side helps explain the feature imo - the arguments are very obviously introduced into the current scope as local variables, which is a major understandability win.
The weakest syntactic case imo is here:
&lockA.read() // simply the val syntax without the left-hand-side
lockA.&read() // symbol closer to function of interest, but harder to notice
At least having the ampersand on the very far left lets the reader instantly know something special is happening on this line. In your head, you know “when I see an ampersand, some closing code will be invoked later.”