Description
(discussion carried over from #10825 at @LPTK's suggestion).
Minimized code
import scala.annotation.tailrec
object Explode {
trait Txn {}
type AtomicOp[Z] = Txn ?=> Z
object AbortAndRetry extends scala.util.control.ControlThrowable("abort and retry this transaction") {}
def beginTxn:Txn = new Txn {}
def commit(using txn:Txn) = throw AbortAndRetry
@tailrec def atomic[Z](txnOp:AtomicOp[Z]):Z = {
try {
given Txn = beginTxn
val ret:Z = txnOp
commit
ret
} catch {
case AbortAndRetry => atomic(txnOp)
}
}
def main(args:Array[String]) = {
Console.println(atomic {
"hello world"
})
}
}
Output
StackOverflowError originating at Explode$.atomic$$anonfun$1(Explode.scala:19)
:
case AbortAndRetry => atomic(txnOp)
Expectation
STMs and other optimistic approaches to speculative execution of composite atomic operations typically rely on an abort/retry loop, and are a pretty standard example for the benefits of contextual abstractions (see, e.g, ScalaSTM).
Unfortunately, the current version of dotty uses the following rule for context functions passed as arguments:
Conversely, if the expected type of an expression
E
is a context function type
(T_1, ..., T_n) ?=> U
andE
is not already an
context function literal,E
is converted to a context function literal by rewriting it to(x_1: T1, ..., x_n: Tn) ?=> Ewhere the names
x_1
, ...,x_n
are arbitrary. This expansion is performed
before the expressionE
is typechecked, which means thatx_1
, ...,x_n
are available as givens inE
.
This means that even if E
is already a variable of type (T_1, ..., T_n) ?=> U
, the wrapper is still produced.
In the abort/retry loop handler structure above, this means that (a) a potentially very large number of transient objects are invisibly produced in a performance-sensitive context, (b) the stack space consumed by invoking txnOp
grows linearly with the number of abort/retry cycles, and, in the pathological case, potentially side-effecting control flow with a StackOverflowError.
At a more basic level it breaks one of the fundamental assumptions that I think most programmers have about type systems, which is that if I have a variable x:S
and function f:S=>T
, then invoking f(x)
will actually pass the value x
to f
.
We can work around this by falling back to something which is closer to the Scala 2 style of "implicit functions":
import scala.annotation.tailrec
object Explode {
trait Txn {}
type AtomicOp[Z] = Txn ?=> Z
type VanillaAtomicOp[Z] = Txn => Z
object AbortAndRetry extends scala.util.control.ControlThrowable("abort and retry this transaction") {}
def beginTxn:Txn = new Txn {}
def commit(using txn:Txn) = throw AbortAndRetry
@tailrec def atomic[Z](txn:VanillaAtomicOp[Z]):Z = {
try {
given Txn = beginTxn
val ret:Z = txn.asInstanceOf[AtomicOp[Z]]
commit
ret
} catch {
case AbortAndRetry => atomic(txn)
}
}
def main(args:Array[String]) = {
Console.println(atomic {
(txn:Txn) =>
given Txn = txn
"hello world"
})
}
}
But this is clearly a lot less elegant and feels like it should not be necessary. In fact it runs counter to the stated goal of implicit functions:
The new formulation of comp might seem like a minor syntactic twist, but it has far-reaching consequences. Because
implicit Ord[T] => Boolean
is now a type, it can be abstracted over by giving it a name and using only the name afterwards. This is exploited the following example, which shows how to approach the configuration problem [Kiselyov and Shan 2004] in a type-safe manner. The goal is to propagate a run-time value, the modulus, while doing arithmetic with the usual functions,add
andmul
, uncluttered.
[ Odersky, Blanvillain, Liu, Biboudis, Miller, and Stucki. 2018. Simplicitly: Foundations and Applications of Implicit Function Types ]