了解Scala中的连续定理

时间:2019-01-19 22:07:50

标签: scala continuations

因此,我正在尝试学习T。我碰到以下话(link):

  

假设您在冰箱前的厨房里,正在考虑三明治。您可以在那继续拍摄并贴在口袋里。然后,您从冰箱中取出一些火鸡和面包,然后自己做一个三明治,该三明治现在正坐在柜台上。您在口袋里调用延续,然后发现自己再次站在冰箱前,想着一个三明治。但幸运的是,柜台上有一个三明治,所有用于制作它的材料都已用完。所以你吃了。 :-) —卢克·帕尔默

此外,我在Continuation中看到了一个程序:

Scala

我真的不了解var k1 : (Unit => Sandwich) = null reset { shift { k : Unit => Sandwich) => k1 = k } makeSandwich } val x = k1() 的语法(看起来与ScalaJava混合在一起),但是我想了解C的概念。

首先,我尝试运行该程序(通过将其添加到Continuation中)。但是失败了,我认为由于main附近的)而导致语法错误,但是我不确定。我删除了它,但仍然无法编译。

  1. 如何创建一个完整的示例来显示上面故事的概念?
  2. 此示例如何显示Sandwich的概念。
  3. 在上面的链接中有以下说法:“在Scala中不是一个完美的类比,因为makeSandwich并不是第一次执行(与Scheme不同)。”是什么意思?

2 个答案:

答案 0 :(得分:1)

由于您似乎对“延续”的概念比对特定代码更感兴趣,所以让我们暂时忘记该代码(尤其是因为它已经很老了,我真的不喜欢这些示例,因为恕我直言,除非您已经知道延续是什么,否则无法正确理解它们。

注意:这是一个很长的答案,它试图描述延续是什么以及为什么有用。在类似Scala的伪代码中有一些示例,但实际上都无法编译和运行(最后只有一个可编译示例,而从答案的中间引用了另一个示例)。希望花大量时间阅读此答案。

续篇简介

要理解延续性,可能要做的第一件事就是忘记大多数命令式语言的现代编译器如何工作,以及大多数现代CPU的工作方式,尤其是调用堆栈的思想。这实际上是实现细节(尽管在实践中非常流行并且非常有用)。

假设您有一个可以执行某些指令序列的CPU。现在,您需要一种高级语言来支持可以互相调用的方法的思想。您面临的明显问题是CPU需要一些“仅转发”命令序列,但是您需要某种方式将结果从子程序“返回”给调用者。从概念上讲,这意味着您需要某种方式在调用之前存储某个位置,以便在计算子程序结果之后继续运行该调用程序方法的所有状态,并将其传递给子程序然后要求最后的子程序从该存储状态继续执行。此存储状态恰好是延续。在大多数现代环境中,这些延续存储在调用堆栈中,并且通常有一些专门用于帮助处理它的汇编指令(例如callreturn)。但这又只是实现细节。可能它们可以以任意方式存储,并且仍然可以工作。

因此,现在让我们再次重申这个想法:延续是程序在某个点的状态,该状态足以从该点继续执行,通常不需要任何额外的输入或一些小的已知输入(例如返回值)。被调用的方法)。运行连续与方法调用的不同之处在于,通常,连续从不显式地将执行控制返回给调用方,它只能将其传递给另一个连续。可能您可以自己创建这样的状态,但实际上,要使该功能有用,您需要编译器的一些支持以自动构建延续或以其他方式模拟延续(这就是您看到的Scala代码需要编译器插件的原因)

异步调用

现在有一个明显的问题:为什么延续根本没有用?实际上,除了简单的“返回”情况外,还有更多其他方案。一种这样的情况是异步编程。实际上,如果您执行一些异步调用并提供回调以处理结果,则可以将其视为传递延续。不幸的是,大多数现代语言不支持自动延续,因此您必须自己掌握所有相关状态。如果您有需要一系列许多异步调用序列的逻辑,则会出现另一个问题。而且,如果某些调用是有条件的,则很容易进入回调回调。延续性帮助您避免这种情况的方法是,允许您构建一种具有有效反向控制流的方法。对于典型的呼叫,呼叫者知道被呼叫者并期望以同步方式返回结果。通过继续操作,您可以为处理逻辑的不同阶段编写一种具有多个“入口点”(或“返回点”)的方法,您可以将其传递给其他方法,而该方法仍可以精确返回该位置。

考虑以下示例(采用类似于Scala的伪代码,但实际上在许多细节上与实际的Scala差距很大):

def someBusinessLogic() = {
  val userInput = getIntFromUser()
  val firstServiceRes = requestService1(userInput)
  val secondServiceRes = if (firstServiceRes % 2 == 0) requestService2v1(userInput) else requestService2v2(userInput) 
  showToUser(combineUserInputAndResults(userInput,secondServiceRes))
}

如果所有这些调用都被同步阻塞调用,则此代码很容易。但是,假设所有这些getrequest调用都是异步的。如何重新编写代码?将逻辑放在回调中的那一刻,您就失去了顺序代码的清晰度。在这里,延续可能会为您提供帮助:

def someBusinessLogicCont() = {
  // the method entry point

  val userInput
  getIntFromUserAsync(cont1, captureContinuationExpecting(entry1, userInput))
  // entry/return point after user input
  entry1:

  val firstServiceRes
  requestService1Async(userInput, captureContinuationExpecting(entry2, firstServiceRes))
  // entry/return point after the first request to the service
  entry2:

  val secondServiceRes
  if (firstServiceRes % 2 == 0) {
      requestService2v1Async(userInput, captureContinuationExpecting(entry3, secondServiceRes)) 
      // entry/return point after the second request to the service v1
      entry3:
  } else {
      requestService2v2Async(userInput, captureContinuationExpecting(entry4, secondServiceRes)) 
      // entry/return point after the second request to the service v2
      entry4: 
  }
  showToUser(combineUserInputAndResults(userInput, secondServiceRes))
}

很难用伪代码捕获这个想法。我的意思是所有这些Async方法都不会返回。继续执行someBusinessLogicCont的唯一方法是调用传递给“ async”方法的延续。假设captureContinuationExpecting(label, variable)调用将在label处创建当前方法的延续,并将输入(返回)值绑定到variable。通过这样的重写,即使具有所有这些异步调用,您仍然具有顺序看似的业务逻辑。因此,现在对于getIntFromUserAsync,第二个参数看起来就像是另一个异步(即永不返回)方法,该方法只需要一个整数参数。我们将此类型称为Continuation[T]

trait Continuation[T] {
   def continue(value: T):Nothing
}

逻辑上Continuation[T]看起来像一个函数T => Unit或更确切地说是T => Nothing,其中Nothing作为返回类型表示该调用实际上从未返回(请注意,在实际的Scala实现中,呼叫确实会返回,所以那里没有Nothing,但是从概念上讲,我认为考虑不返回延续很容易。

内部与外部迭代

另一个例子是迭代问题。迭代可以是内部的或外部的。内部迭代API如下所示:

trait CollectionI[T] {
     def forEachInternal(handler: T => Unit): Unit
}

外部迭代如下:

trait Iterator[T] {
     def nextValue(): Option[T]
}

trait CollectionE[T] {
     def forEachExternal(): Iterator[T]
}

注意:通常Iterator有两种方法,例如hasNextnextValue返回T,但这只会使故事变得更加复杂。在这里,我使用合并的nextValue返回Option[T],其中值None表示迭代结束,而Some(value)表示下一个值。

假设Collection是由比数组或简单列表更复杂的东西(例如某种树)实现的,那么如果您使用的是API的实现者和API用户之间存在冲突典型的命令式语言。冲突在于一个简单的问题:谁控制堆栈(即程序易于使用的状态)?对于实施者而言,内部迭代更容易,因为他可以控制堆栈,并且可以轻松地存储移至下一个项目所需的任何状态,但是对于API用户而言,如果她想对存储的数据进行某种聚合,事情就变得棘手了,因为现在必须将对handler的两次调用之间的状态保存在某个地方。另外,您还需要一些其他技巧,以使用户可以在数据结束之前的任意位置停止迭代(考虑到您正在尝试通过find实现forEach)。相反,外部迭代对用户来说很容易:她可以以任何方式将处理数据所需的所有状态存储在局部变量中,但是API实现者现在必须在两次调用nextValue的其他位置之间存储其状态。因此,从根本上讲会出现问题,因为只有一个位置可以轻松存储程序“调用”部分的状态(调用堆栈),而该位置有两个冲突的用户。如果您可以为状态拥有两个不同的独立位置,那就太好了:一个用于实现者,另一个用于用户。延续恰恰提供了这一点。这个想法是,我们可以使用两个延续(程序的每个部分一个)在两个方法之间来回传递执行控制。让我们将签名更改为:

// internal iteration
// continuation of the iterator
type ContIterI[T] = Continuation[(ContCallerI[T], ContCallerLastI)] 
// continuation of the caller
type ContCallerI[T] = Continuation[(T, ContIterI[T])]
// last continuation of the caller
type ContCallerLastI = Continuation[Unit]

// external iteration
// continuation of the iterator
type ContIterE[T] = Continuation[ContCallerE[T]]
// continuation of the caller
type ContCallerE[T] = Continuation[(Option[T], ContIterE[T])]


trait Iterator[T] {
     def nextValue(cont : ContCallerE[T]): Nothing
}

trait CollectionE[T] {
     def forEachExternal(): Iterator[T]
}

trait CollectionI[T] {
     def forEachInternal(cont : ContCallerI[T]): Nothing
}

例如,这里的ContCallerI[T]类型意味着这是一个继续(即程序的状态),它期望两个输入参数继续运行:类型T(下一个元素)另一个类型为ContIterI[T](继续切换回去)。现在您可以看到新的forEachInternal和新的forEachExternal + Iterator具有几乎相同的签名。信号通知迭代结束的唯一区别是:在一种情况下,它是通过返回None来完成的,而在另一种情况下,则是通过传递并调用另一个延续(ContCallerLastI)来完成的。

这是使用这些签名的Int数组中元素总数的幼稚伪代码实现(使用数组而不是更复杂的例子来简化示例):

 class ArrayCollection[T](val data:T[]) : CollectionI[T] {
     def forEachInternal(cont0 : ContCallerI[T], lastCont: ContCallerLastI): Nothing = {
        var contCaller = cont0
        for(i <- 0 to data.length) {
            val contIter = captureContinuationExpecting(label, contCaller)
            contCaller.continue(data(i), contIter)
            label:
        }
     }
 }


 def sum(arr: ArrayCollection[Int]): Int = {
     var sum = 0
     val elem:Int
     val iterCont:ContIterI[Int]
     val contAdd0 = captureContinuationExpecting(labelAdd, elem, iterCont)
     val contLast = captureContinuation(labelReturn)
     arr.forEachInternal(contAdd0, contLast)

     labelAdd:
     sum += elem
     val contAdd = captureContinuationExpecting(labelAdd, elem, iterCont)
     iterCont.continue(contAdd)
     // note that the code never execute this line, the only way to jump out of labelAdd is to call contLast 

     labelReturn:
     return sum
 }           

请注意,forEachInternalsum方法的实现看起来都相当顺序。

多任务处理

Cooperative multitasking也称为coroutines实际上与迭代示例非常相似。协作多任务是一种想法,程序可以自愿将其执行控制权放弃(“屈服”)给全局调度程序或其他已知的协程。实际上,sum的最后一个(重写)示例可以看作是两个协同程序一起工作:一个执行迭代,另一个执行求和。但是更一般而言,您的代码可能会将其执行交给某些调度程序,然后调度程序将选择接下来要运行的其他协程。调度程序所做的是管理一堆连续操作,以决定下一个继续执行。

Preemptive multitasking可以看成是类似的事情,但是调度程序是由一些硬件中断来运行的,然后调度程序需要一种方法来创建正在执行的程序的延续,就在该程序外部中断之前而不是从内部。

Scala示例

您看到的是一篇非常古老的文章,它指的是Scala 2.8(而当前版本是2.11、2.12,很快是2.13)。正如@igorpcholkin正确指出的那样,您需要使用Scala continuations compiler plugin and librarysbt compiler plugin page给出了一个如何完全启用该插件的示例(对于Scala 2.12,@ igorpcholkin的答案具有适用于Scala 2.11的魔术字符串):

val continuationsVersion = "1.0.3"

autoCompilerPlugins := true

addCompilerPlugin("org.scala-lang.plugins" % "scala-continuations-plugin_2.12.2" % continuationsVersion)

libraryDependencies += "org.scala-lang.plugins" %% "scala-continuations-library" % continuationsVersion

scalacOptions += "-P:continuations:enable"

问题在于该插件已被半弃,并且在实践中并未广泛使用。另外,自Scala 2.8以来,语法已经发生了变化,因此即使您修复了一些明显的语法错误(如到处缺少(),也很难使这些示例运行。这种状态的原因在GitHub上表示为:

  

您可能还对https://github.com/scala/async感兴趣,该书涵盖了延续插件最常见的用例。

该插件的作用是使用代码重写来模拟连续性(我想很难在JVM执行模型上实现真正的连续性)。在这种重写下,代表连续性的自然事物是某些功能(在这些示例中通常称为kk1)。

因此,现在,如果您设法阅读上面的文字墙,则可能可以正确解释三明治示例。 AFAIU该示例是使用延续作为模仿“返回”的手段的示例。如果我们对它进行更多调整,它可能会像这样:

您(您的大脑)处于某种功能内,在某些时候它决定要三明治。幸运的是,您有一个知道如何做三明治的子例程。您将当前的大脑状态作为延续存储在口袋中,并调用子例程对它说,当工作完成时,它应该从口袋继续延续。然后,根据该子例程制作一个三明治,将其与先前的大脑状态弄混。在子例程的结尾,它从口袋开始继续运行,您返回到子例程调用之前的状态,忘记了该子例程中的所有状态(即,如何制作三明治),但是可以看到外部世界的变化,即三明治已经制成。

要为scala-continuations的当前版本提供至少一个 可编译示例 ,以下是我的异步示例的简化版本:

case class RemoteService(private val readData: Array[Int]) {
  private var readPos = -1

  def asyncRead(callback: Int => Unit): Unit = {
    readPos += 1
    callback(readData(readPos))
  }
}

def readAsyncUsage(rs1: RemoteService, rs2: RemoteService): Unit = {
  import scala.util.continuations._
  reset {
    val read1 = shift(rs1.asyncRead)
    val read2 = if (read1 % 2 == 0) shift(rs1.asyncRead) else shift(rs2.asyncRead)
    println(s"read1 = $read1, read2 = $read2")
  }
}

def readExample(): Unit = {
  // this prints 1-42
  readAsyncUsage(RemoteService(Array(1, 2)), RemoteService(Array(42)))
  // this prints 2-1
  readAsyncUsage(RemoteService(Array(2, 1)), RemoteService(Array(42)))
}

这里,使用数组中提供的固定数据模拟(模拟)远程调用。请注意,readAsyncUsage看起来像是完全顺序的代码,尽管取决于第一次读取的结果,在第二次读取中调用哪个远程服务的逻辑很简单。

答案 1 :(得分:0)

  1. 对于完整示例,您需要准备Scala编译器以使用延续,还需要使用特殊的编译器插件和库。 最简单的方法是在IntellijIDEA中使用以下文件创建一个新的sbt.project:build.sbt-在项目根目录中,CTest.scala-在main / src内部。 这是两个文件的内容:

build.sbt:

M = pd.DataFrame({'A': X1[' Time (ms) '], 
                  'B': X2[' Time (ms) '],
                  'C': X3[' Time (ms) '], 
                  'D': X4[' Time (ms) ']})

CTest.scala:

name := "ContinuationSandwich"

version := "0.1"

scalaVersion := "2.11.6"

autoCompilerPlugins := true

addCompilerPlugin(
  "org.scala-lang.plugins" % "scala-continuations-plugin_2.11.6" % "1.0.2")

libraryDependencies +=
  "org.scala-lang.plugins" %% "scala-continuations-library" % "1.0.2"

scalacOptions += "-P:continuations:enable"

上面的代码本质上所做的就是调用makeSandwich函数(以复杂的方式)。因此,执行结果将只是在控制台中打印“制作三明治”。无需继续即可获得相同的结果:

import scala.util.continuations._

object CTest extends App {
  case class Sandwich()
  def makeSandwich = {
    println("Making sandwich")
    new Sandwich
  }
  var k1 : (Unit => Sandwich) = null
  reset {
    shift { k : (Unit => Sandwich) => k1 = k }
    makeSandwich
  }
  val x = k1()
}
  1. 那有什么意义?我的理解是我们想“准备一个三明治”,而忽略了我们可能还没有做好准备的事实。在所有必要条件都满足之后(即我们已准备好所有必要食材),我们会标记一个时间点。取完所有食材后,我们可以回到商标并“准备三明治”,“忘记我们以前做不到的事情”。继续使我们能够“标记过去的时间点”并返回到该点。

现在逐步。 k1是一个变量,用于保存指向函数的指针,该函数应允许“创建三明治”。我们知道这是因为k1声明为:object CTest extends App { case class Sandwich() def makeSandwich = { println("Making sandwich") new Sandwich } val x = makeSandwich } 。 但是,最初该变量未初始化((Unit => Sandwich),“尚无制作三明治的成分”)。所以我们还不能调用使用该变量准备三明治的函数。

因此,我们使用“ reset”语句标记要返回到的执行点(或希望返回的过去时间点)。 makeSandwich是指向实际上允许制作三明治的函数的另一个指针。这是“重置块”的最后一条语句,因此将其作为参数(k1 = null传递给“ shift”(函数)。在shift中,我们将该参数分配给k1变量shift { k : (Unit => Sandwich)...,从而使k1准备好称为函数,之后返回到以reset标记的执行点,下一条语句是k1变量所指向的函数的执行,该变量现已正确初始化,因此最后我们调用makeSandwich,将“制作三明治”打印到控制台上。返回一个三明治类的实例,该实例被分配给x变量。

  1. 不确定,这可能意味着makeSandwich不是在reset块内调用的,而是在之后称为k1()的时候调用的。