在Scala中,是否应该使用上下文界定(context bound)还是隐式参数(implicit ev)?

4
根据样式指南,在Scala中应该使用什么来表示类型类,是"上下文界定"还是"隐式证明"符号?这两个例子的功能相同。上下文界定具有更简洁的函数签名,但需要通过implicitly调用来评估val。
def empty[T: Monoid, M[_] : Monad]: M[T] = {
    val M = implicitly[Monad[M]]
    val T = implicitly[Monoid[T]]
    M.point(T.zero)
}

implicit ev 方法会自动将类型类插入函数参数,但是会污染方法签名:

def empty[T, M[_]](implicit T: Monoid[T], M: Monad[M]): M[T] = {
  M.point(T.zero)
}

我查看过大部分库(例如"com.typesafe.play" %% "play-json" % "2.6.2")都使用implicit ev

你们在用什么,为什么选择这个?

4个回答

3
这是非常主观的,但是使用隐式参数列表的一个实际原因是会执行较少的隐式搜索。

当你进行如下操作时:

def empty[T: Monoid, M[_] : Monad]: M[T] = {
  val M = implicitly[Monad[M]]
  val T = implicitly[Monoid[T]]
  M.point(T.zero)
}

这将由编译器转换为更简单的形式。
def empty[T, M[_]](implicit ev1: Monoid[T], ev2: Monad[M]): M[T] = {
  val M = implicitly[Monad[M]]
  val T = implicitly[Monoid[T]]
  M.point(T.zero)
}

现在,implicitly 方法需要进行另一个隐式搜索,以在作用域中找到 ev1ev2

这很不可能会导致明显的运行时开销,但在某些情况下可能会影响编译时间性能。

如果你改为使用

def empty[T, M[_]](implicit T: Monoid[T], M: Monad[M]): M[T] =
  M.point(T.zero)

您正在直接从第一个隐式搜索中访问MT

另外(这是我的个人意见),我更喜欢函数体较短,即使签名需要一些样板代码也无妨。

大多数我知道的大量使用隐式参数的库都会在需要访问实例时使用这种风格,所以我对这种符号更加熟悉。


如果您决定仍然使用上下文界定,通常最好在类型类上提供一个apply方法来搜索隐式实例。这样可以写成:

def empty[T: Monoid, M[_]: Monad]: M[T] = {
  Monad[M].point(Monoid[T].zero)
}

更多关于该技术的信息,请点击这里:https://blog.buildo.io/elegant-retrieval-of-type-class-instances-in-scala-32a524bbd0a7

我完全同意,但我想添加一个小的精确说明:隐式搜索是编译器在编译时执行的,因此绝对不会有运行时性能损失。结论:正如您所说,“基于意见”。 - Frederic A.
谢谢!使用apply方法的方式真的很好,我看了这篇文章。不过在scalaz的类型类中没有找到。 - Eugene Zhulkov
很不幸,因为这是一个方便的技巧。猫类型类使用它(通过simulacrum),它真正节省了按键次数。 - Gabriele Petronella
@EugeneZhulkov,它在scalaz中可用,至少在7.2.x版本中(例如,请参见Monoid companion object)。 - Oleg Pyzhcov
1
我进行了一个快速测试,Gabriele关于潜在的运行时开销是正确的,这有点令人惊讶。implicitly被注释为@inline,但Scala编译器似乎没有将其内联到生成的字节码中。因此,内联必须由JIT完成。Dotty的inline关键字应该可以解决这个问题。 - Joe Pallas
显示剩余2条评论

3

使用implicitly时需要注意依赖类型函数的情况。以下摘自《The type astronauts guide to shapeless》一书:该书介绍了Shapeless中的Last类型类,用于检索HList的最后一个类型。

package shapeless.ops.hlist

trait Last[L <: HList] {
  type Out
  def apply(in: L): Out
}

并表示:

scala.Predef中的implicitly方法具有这种行为(该行为意味着失去内部类型成员信息)。将Last summoned的实例类型与implicitly的类型进行比较:

implicitly[Last[String :: Int :: HNil]]
res6: shapeless.ops.hlist.Last[shapeless.::[String,shapeless
      .::[Int,shapeless.HNil]]] = shapeless.ops.hlist$Last$$anon$34@20bd5df0

针对使用 Last.apply 激活的实例类型:
Last[String :: Int :: HNil]
res7: shapeless.ops.hlist.Last[shapeless.::[String,shapeless
      .::[Int,shapeless.HNil]]]{type Out = Int} = shapeless.ops.hlist$Last$$anon$34@4ac2f6f

通过隐式方式召唤的类型没有 Out 类型成员,这是一个重要的限制条件,通常你会使用 summoner 模式而不是使用上下文界定和 implicitly
除此之外,一般来说这只是个风格问题。是的,implicitly 可能会稍微增加编译时间,但如果你有一个隐式 rich 应用程序,你很可能在编译时感受不到两者之间的差异。
就个人而言,有时编写 implicitly[M[T]] 会感觉比让方法签名变得稍微长一点更加“丑陋”,当你在声明具有命名字段的隐式时,对于读者可能会更加清晰易懂。

谢谢!我会查看这本书,之前不知道这个注意事项。 - Eugene Zhulkov
@EugeneZhulkov 这本书对类型级编程和 Shapeless 的介绍非常不错。 - Yuval Itzchakov

1
请注意,除了执行相同的操作外,您的两个示例实际上是相同的。上下文边界只是为添加隐式参数而添加的语法糖。
我很机会主义,尽可能多地使用上下文边界,即在我没有隐式函数参数时。当我已经有一些参数时,就不可能使用上下文边界,我别无选择,只能将其添加到隐式参数列表中。
请注意,您不需要像所做的那样定义val,这也可以正常工作(但我认为您应该选择使代码更易于阅读的方法)。
def empty[T: Monoid, M[_] : Monad]: M[T] = {
  implicitly[Monad[M]].point(implicitly[Monoid[T]].zero)
}

谢谢,我明白它的工作原理,问题只是关于代码风格而已。我相信这是个人口味的问题,但想向社区请教一下 :) - Eugene Zhulkov

1

函数式编程库通常为类型类提供语法扩展:

import scalaz._, Scalaz._
def empty[T: Monoid, M[_]: Monad]: M[T] = mzero[T].point[M]

我尽可能地使用这种风格。这使我的语法与标准库方法一致,并且让我能够编写适用于通用Functor / Monad的for-推导式。
如果不可能,我会在伴生对象上使用特殊的apply方法:
import cats._, implicits._ // no mzero in cats
def empty[T: Monoid, M[_]: Monad]: M[T] = Monoid[T].empty.pure[M]

我使用simulacrum为我的自定义类型类提供这些。

在上下文边界不足的情况下(例如,多个类型参数),我会使用implicit ev语法。


哦,谢谢!我完全忘记了 mzero[T].point[M]。但是我的问题更通用,不仅仅涉及到函数式编程库,还涉及到所有类型类。 - Eugene Zhulkov
@EugeneZhulkov,是的,我描述了我的偏好,但没有样式指南。实际上,许多库提供语法丰富化(例如spire中的运算符)或带有隐式参数的常规方法(例如play-json中的Json.toJson(instance)),允许您使用上下文边界,因此函数定义和实现都很简短和可读。对于自定义的内容,只需向特质添加单个注释即可。 - Oleg Pyzhcov

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接