Skip to content

rewrite rules mean implicit function variables cannot be safely passed as arguments #10889

Closed
@elfprince13

Description

@elfprince13

(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 and E 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) ?=> E

where the names x_1, ..., x_n are arbitrary. This expansion is performed
before the expression E is typechecked, which means that x_1, ..., x_n
are available as givens in E.

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 and mul, uncluttered.

[ Odersky, Blanvillain, Liu, Biboudis, Miller, and Stucki. 2018. Simplicitly: Foundations and Applications of Implicit Function Types ]

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions