何时使用隐式参数

When to use implicit parameters

我一直在工作中使用Scala,我有一个与隐式参数相关的问题。

我经常看到 executionContext 在方法定义和 class 定义中定义。 同时,我看到 classes 接受包含配置数据(超时、适配器、端口等)作为常规参数的 case classes。

我的问题是为什么在传递配置时此参数未定义为隐式? 或者反过来,如果将 executionContext 定义为常规参数会怎样? 我试图了解何时使用隐式参数以及何时不使用它们。

编辑:也许传递案例的例子 class 不是最好的例子,这是我想到的第一个想法

在以下情况下可以使用隐式:

  • 您只需要某种类型的一个值
  • 如何定义这样的值是明确的
    • 这包括手动定义以及使用元编程生成值,例如基于它的类型是如何定义的

Futures 和 Akka 决定将一些“全局变量”作为隐式传递是一个合理的用例,因此它们将作为隐式传递:

  • ExecutionContext
  • ActorSystemMaterializer
  • 各种配置,例如 Timeout

一般情况下,您不想将其放入某个静态字段,但会在任何地方

传递

然而,Scala 世界的其余部分将通过使用一些抽象来解决这个问题,这些抽象将在引擎盖下传递这些东西,某种构建器,通过构造函数,对 (dependencies) => result 函数的抽象等。

例如cats.effect.IO 不需要传递 ExecutionContext 因为它在你 运行 时传递它的调度程序。只有当你想显式地改变池中的东西时,你才需要使用一些方法 运行 。在 Monix 运行ning 中,当整个计算完成时,还需要您在最后传递 Scheduler。所以两者都让你放弃传递所有这些 ExecutionContexts。在 Future 的情况下,这是必要的,因为您需要控制线程池,但您也急切地评估事物,手动放置 ec (futureA.flatMap(f)(ec)) 会破坏理解。

因此,在 Akka 生态系统和原始 Futures 之外,更常用于携带类型-类,作为将业务逻辑与特定实现分离的一种手段,允许添加支持对于新类型,无需修改使用这些实现的代码,等等。 (Scala 中有大量 type-类 的示例,因此我将在此处跳过)。

通常,当我读到人们使用隐式来传递配置时,以悲伤告终只是时间问题。 Akka 和 EC 有点需要它们,但你应该明确地传递配置。您可以将它们分组为 case classes 以传递它们,这不是什么大问题。您还可以将所有需要的东西明确地放在一个地方并执行:

case class Configs(dbEX: EC, mapEC: EC)
class SomeBehavior(configs: Configs) {

  def someAction = {
    if (...) {
      implicit val ec: EC = configs.dbEC
      ...
    } else {
      implicit val ec: EC = configs.mapEC
      ...
    }
  }
}

让它们只隐含在需要它们的地方。经验的一个很好的作用是:您是否关心是否传递了一些您在代码中看不到的东西?通常,答案是,是的,你更愿意看到它,只有例外情况,即价值从何而来有些明显,或者你有点知道价值是好的但你不知道不要管它从哪里来。

Scala 中有大量 implicit 的用例:在幕后,它们归结为利用编译器的隐式解析机制来填充可能没有明确提及的内容,但是用例差异很大,以至于在 Scala 3 中,每个用例(在 Scala 3 中幸存下来的用例……)都使用不同的关键字进行编码。

在执行上下文的情况下,implicit 参数被用于模仿通常为静态范围的语言中的动态范围。这样做的主要好处是,它允许在调用堆栈的更上层决定调用堆栈下层的行为,而不必总是通过堆栈的中间层显式地传递行为(同时为那些中间层以干净地强制执行不同的行为)。

从历史上看,这方面的一个主要例子是数字精度。许多数值运算最终通过迭代细化实现(例如,当在软件中实现平方根时,它可能使用牛顿法实现),这意味着在计算速度和精度(建议准确性)之间存在权衡。使用动态范围,有一种巧妙的方法可以实现这一点:一个全局变量,用于数学结果中所需的精度级别。您的数字例程会检查该变量的值并相应地进行自我管理。与静态范围语言中的全局变量的区别在于,当 A 调用 B 调用 C 时,如果 A 将 x 的值设置为 1,B 将其设置为 2,则检查时 x 将为 2在 C 或 B 中,但是一旦 B returns 到 A,x 将再次为 1(在动态作用域语言中,您可以将全局变量视为真正的一堆值的名称,并且语言实现会根据需要自动弹出堆栈。

动态作用域曾经相当流行(尤其是在 mid/late 1970 年代之前的 Lisps 中);如今,您真正看到它的唯一地方是在 Bourne shell(包括 bash)、Emacs Lisp 中;而某些语言(Perl 和 Common Lisp 可能是两个主要示例)是混合语言:以特殊方式声明变量以使其具有动态或静态作用域。静态作用域明显获胜:语言实现或程序员更容易推理。

这种轻松的代价是,在我们的数值计算示例中,我们最终得到如下内容:

def newtonSqrt(x: Double, precision: Int): Double = ???

/** Calculates the length of the hypotenuse of a right triangle with legs of given lengths
 */
def hypotenuse(x: Double, y: Double, precision: Int): Double =
  newtonSqrt(x*x + y*y, precision)

谢天谢地,Scala 支持默认参数,因此我们也避免了使用默认精度的版本。可以说,precision 暴露了一个实现细节(事实上我们的计算在数学上不一定完全准确):重要的是斜边的长度是腿.

在 Scala 中,我们可以隐含精度:

// DON'T ACTUALLY PASS AN INT IMPLICITLY!!!!!!
def newtonSqrt(x: Double)(implicit precision: Int): Double = ???

def hypotenuse(x: Double, y: Double)(implicit precision: Int): Double =
  newtonSqrt(x*x, y*y)

(传递一个原语或任何可能被合理地用于除了通过隐式机制描述所讨论的行为之外的东西的类型实际上是非常糟糕的:我在这里这样做是为了教学清晰)。

编译器会有效地将 newtonSqrt(x*x + y*y) 翻译成(非常类似于)newtonSqrt(x*x + y*y, precision)。现在 hypotenuse 的调用者可以决定通过 implicit val 修复 precision 或通过将隐式添加到他们的签名来将选择推迟到他们的调用者。

动态作用域长期以来一直存在争议,因此即使 implicit 嵌入的这种用法的受限动态作用域也存在争议也就不足为奇了。在 Scala 的例子中,在很多情况下,工具在帮助你找出隐式时会举手投降,这无济于事:人们遇到的大多数真正严重的编译器错误都与丢失隐式或冲突有关,并追踪到图在任何时候找出哪些值在隐式范围内并不是工具有帮助人们的历史。因此,许多开发人员认为通过配置显式线程化优于使用隐式。

这种行为描述最好是隐式还是显式传递,这在很大程度上取决于个人喜好和情况(值得注意的是 type-class 模式,尤其是在没有对连贯性提出硬性要求的情况下(有一种而且只有一种可能的方式来描述行为),这在 Scala 中很典型,只是这种行为描述的一个特例。

我还应该指出,将一些设置捆绑到一个案例 class 与隐式传递它们之间并不是二元选择:你可以两者都做:

case class ProcessSettings(sys: ActorSystem, ec: ExecutionContext)

object ProcessSettings {
  implicit def implicitly(implicit sys: ActorSystem, ec: ExecutionContext): ProcessSettings =
    ProcessSettings(sys, ec)
}

def doStuff(x: SomeInput)(implicit settings: ProcessSettings)

从概念上讲,隐式参数是应用程序逻辑的“外部”,显式参数是......好吧......显式。

考虑一个函数def f(x: Double): Double = x*x 它是将给定实数转换为另一个实数的纯函数。 x 作为显式参数是有意义的,因为它是此函数 .

的固有部分

现在,假设您正在实施某种近似乘法算法,并希望控制函数计算答案的精度。 你可以做 def f(x: Double, precision: Int): Double = ???。它会工作,但不方便而且有点笨拙:

  • 函数定义不再表达函数的概念“性质”是对实数集的纯变换
  • 这让调用点变得复杂,因为每个使用你的函数的人现在都必须知道要传递的这个附加参数(想象一下,你正在编写一个库供非工程数学专业的学生使用,他们理解抽象转换和复杂的公式,但不太关心数值精度:当您需要计算正方形的面积时,您多久考虑一次精度?)。
  • 这也使得现有代码更难阅读和修改

所以,为了让它更漂亮,你可以def f(x: Double)(implicit precision: Int) = ???。这有一个好处,可以准确地说出你想要什么:“我有一个转换 double => double,它将在计算实际结果时使用隐含的精度。那些数学专业的学生现在可以按照他们使用的方式编写他们的抽象公式至:val area = square(x) 不会用他们并不真正关心的恼人配置污染他们的逻辑。

什么时候使用它当然是一个意见和品味的问题(这在 SO 上被明确禁止)。有人肯定会争论上面的例子,precision 实际上是转换定义的一部分,因为 5.4295.54289f(2.33)(3)f(2.33)(4) 的结果分别)是两个不同的数字。

所以,归根结底,你只需要运用你的判断力和常识来为你遇到的每一个案例做出决定。

使用现有库时,还有一个考虑因素。考虑:

    def foo(f: Future[Int], ec: ExecutionContext) = 
       f.map { x => x*x }(ec)
        .map { _.toString } (ec)
        .foreach(println)(ec)

如果你把 ec 隐式化,这看起来会好很多,也不会那么乱,不管你在哲学上的立场是什么,是否认为它是你转型的一部分:

     def foo(f: Future[Int])(implicit ec: ExecutionContext) = 
        f.map { x => x*x }.map(_.toString).foreach(println)