Scala3 Contextual Abstractions

Rename of implicit Keyword

Oct 25, 2020

见习魔法师

或许是所有的feature都做完了,Dotty compiler 的主页不再跟踪计划的功能的完成情况了。可能是Scala3即将发布了吧。

Scala3 的文档有一大部分在讲contextual abstraction,即Scala是如何抽象上下文的。我们有时候会期望不同的函数在不同的上下文中会有不同的行为,同时希望这个函数是纯函数。

Implicits

Scala2给出的答案是implicit,隐式参数会根据其类型在调用者的上下文中寻找相同类型的隐式变量,编译器将其自动传入。比如,在play framework中,request就是一个隐式参数,为处理当前的请求带来了极大的便利。

如果我们自己想要设计一个web framework,也可以如此模仿:

object Helpers {
  def addHeader(key: String, value: String)(implicit request: Request) = ???
}
def handle(implicit request: Request) = {
  Helpers.addHeader("Content-Type", "application/json")
}

在这个例子中,因为我们确保同时处理的请求只有一个,所以我们可以安心地把这个请求作为隐式参数,所有的操作,可以写为纯函数,请求作为第二个参数也可以自动传入。

当然,你可以说这里在某种程度上是依赖注入;此处我们不讨论java boys最爱的设计模式。

这样固然方便,但是Scala的implicits目前有这样的问题:

  • implicit单个关键字做了太多的事情,可能发生混淆

  • implicit这个机制可能被滥用甚至错用

  • 传递隐式参数和普通的参数没有什么不同

    例如:

    def params(implicit req: Request): Map[String, String]
    
    val id = params("id")
    
    以上代码不能通过编译,因为Scala认为你给params函数传递了一个String,而该函数期望一个Request,类型错误。 而实际上,我们希望的是获得返回的Map中的值,解决方案很简单,改为:params.apply("id")就可以。

关于implicit的坑,我们可以看如下的例子:

implicit val ls: List[String] = List("Holy", "Crap")
implicit def int2String(i: Int): String = i.toString

val x: String = 1

请问x的结果是什么? 很不幸,结果是"Crap",因为List[String]本身也实现了Function1[Int, String],可以用ls做隐式转换。

如果能有编译错误,再不济运行错误,都能较快定位到错误的位置。然而,这里不会有任何错误。

问题出在哪儿呢?Scala2 把 implicit defimplicit val中的函数类值等同看待了。而且,Scala2还倾向于使用implicit val同时不报ambiguity错误。 List[+T]实现Function1[Int, T]其实没有问题,函数和数组本质上都是IntT的映射,他们没有本质区别。

当然,以上的错误其实都不致命,事实上Scala的内部代码以及诸多的库都在使用这个特性。

我们追求更好的Scala,因此Scala3做出了许多改动。

Given & Using

Scala3出现了新的两个关键字,givenusing。其中,given是hard keyword,意味着你不再能把given作为标识符了, 而using是soft keyword,只有出现在参数列表的开头的时候才被认定为关键词。

这两个关键词是专门用于声明隐式变量和传递隐式参数的。在Scala2中这俩关键词其实都可以用implicit来替代。

implicit 参数有一个最经典的用处就是实现typeclass,即类型的类。

例如,我们规定有这么一个类型类Nextable[T],我们希望:

  • bool的next是自身取反

  • 数字的next是他们+1

  • 如果一个类型是Nextable的,那么该类型的容器也是Nextable的,其next是容器中的每一个值的next。

我们还希望对于所有Nextable的类型,为他们增加一个方法next,返回其next。

这样,我们的trait就写完了:

trait Nextable[T]:
  def nextOf(x: T): T

  extension (self: T):
    def next = nextOf(self)

啊嘞?这个语法我怎么不认识呢?原来这是Scala3的缩进语法。Scala3的缩进语法可以省略一些花括号,有些语法看上去甚至有点像python,具体的请看Optional Braces

以上代码等价于下面的代码:

trait Nextable[T] {
  def nextOf(x: T): T

  extension (self: T) {
    def next = nextOf(self)
  }
}

首先,bool的next是他们取反。那么我们就要「给出」这么一个实例,该实例实现Nextable[Boolean]。 为了表达「给出」这一层概念,就要使用given关键字。

given Nextable[Boolean]:
  def nextOf(x: Boolean): Boolean = !x

就只消这么几行代码,Boolean就已经奇迹般地“拥有”next方法了。

false.next // true
false.next.next // false

接下来实现第二个,数字的next是其加一。那么,什么是数字呢?原来Scala中已经定义了一个typeclass Numeric了,可以表达数字的概念。所以我们要「使用」Numeric[T]这个typeclass并「给出」Nextable[T]这个typeclass。

相当于\forall T, Numeric[T] \to Nextable[T]

为了「使用」Numeric,我们用using关键字:

given [T](using num: Numeric[T]) as Nextable[T] {
  def nextOf(x: T) = num.plus(x, num.one)
}

这样,Int, Long, Double, BigInt等等都拥有了next方法,如果你给你自己实现的某个类也声明了Numeric typeclass,那么该类也会拥有next方法。

1.next // 2
2.0.next // 3.0

接下来是第三种,可以直接认为:\forall T, Nextable[T] \to Nextable[Seq[T]]为此,我们要「使用」Nextable[T],此时代码已经呼之欲出了:

given [T](using nxt: Nextable[T]) as Nextable[Seq[T]] {
  def nextOf(x: Seq[T]) = x.map(_.next)
}

就是如此简单。

List(2, 3, 4).next // List(3, 4, 5)

如果有一个方法想要使用隐式参数也很方便:

def getNext[T](x: T)(using nxt: Nextable[T]): T = nxt.nextOf(x)

如果我们想手动传入隐式参数就变得有些许不同:

object EvilNext extends Nextable[Any]:
  override def nextOf(x: Any) = x

getNext(1)(using EvilNext) // 1

我们必须加上using关键词,否则编译错误。

如果我们想要直接使用某个被「给出」的值呢?我们怎么把他叫出来呢?很简单,我们使用summon函数把他「召唤」出来就可以了,summon函数是定义在Predef里的,意味着我们可以直接用,其定义已经不言而喻:

inline def summon[T](using x: T): x.type = x
所以getNext也可以这么定义:

def getNext[T: Nextable](x: T): T = summon nextOf x
// def getNext[T](x: T)(using Nextable[T]): T = summon[Nextable[T]].nextOf(x)

summonimplicitly最大的区别如下:

given Conversion[Int, Boolean] = _ != 0
summon[Int => Boolean](1) // true
implicitly[Int => Boolean](1)
/*
  |implicitly[Int => Boolean](1)
  |                           ^
  |                           Found:    (1 : Int)
  |                           Required: Int => Boolean
*/
第二个出现了编译错误,因为implicitly认为第二个传入的是其隐式参数,而这个参数往往不用显式给出。

Implicit Conversion

Scala支持值的隐式转换,自然Scala3不会废弃这个功能,但是做出了一些限制。

隐式转换发生于一下情况:

  • 表达式e具有类型T,但是这里需要U类型的值。

  • 表达式e.m中,e具有类型T但是T没有定义方法m

在以上情况下,会发生隐式转换。 Scala2 会搜索:

  • implicit val x,x具有T => U或者(=> T) => U类型

  • implicit def func(x: T): U或者implict def func(x: =>T): U

于此不同的是,Scala3会搜索:

  • implicit def func(x: T): U或者implict def func(x: =>T): U

  • implicit val x,x具有Conversion[T, U]类型

自然,Conversion[T, U]实现了T => U,但是这样可以防止Scala中的容器类不经意间成为了转换函数。

此时,implicit的Conversion值也可以由given来「给出」。

given Conversion[Int, String] = _.toString

Extension Methods

扩展方法在Scala2中是由implicit class实现的。例如我们要给Boolean类型的值添加一个flip方法取反。

implicit class BooleanExt(b: Boolean) {
  def flip = !b
}

false.flip // =>
(new BooleanExt(false)).flip

在Scala3中,扩展方法可以这么实现:

extension (b: Boolean) def flip = !b /// def extension_flip(b: Boolean): Boolean

false.flip // =>
extension_flip(false)

可以看出,Scala3的扩展方法就是定义了一个extension_为开头的函数,调用扩展方法实际上就是调用那个方法。减少了新建实例的次数,降低了GC压力。因此,Scala3的方法名不能以extension_开头了。不过Scala编码规范是camel case,应该也没人那么命名

虽然,Scala2中也有一种办法能减少无用的新对象的创建:

implicit class BooleanExt(b: Boolean) extends AnyVal {
  def flip = !b
}

false.flip // =>
BooleanExt$.MODULE$.extension$flip(false)
变成了一个静态方法的调用,但是用户通常会忘记这个extends,而且额外的语法开销会让人疑惑,这个extends AnyVal到底是什么意思,为什么一个类能继承值类型。

当然还有一种比较别扭的语法也能行,但是不知道为什么文档上没有。

def (b: Boolean) flip = !b
def (b: Boolean).flip = !b
方法名带不带点都一样,非常神秘。可能原来想抄Kotlin,但是后来觉得高贵的Scala应该走自己的路。

Appendix

Typeclass in Scala2

Scala2 也可以定义typeclass,只是需要用implicit。

trait Nextable[T] {
  def nextOf(x: T): T
}

object Nextable {
  implicit class NextableOps[T](x: T)(implicit nxt: Nextable[T]) {
    def next = nxt nextOf x
  }

  implicit object BoolIsNextable extends Nextable[Boolean] {
    override def nextOf(x: Boolean) = !x
  }

  implicit def numericIsNextable[T](implicit num: Numeric[T]) = new Nextable[T] {
    override def nextOf(x: T) = num.plus(x, num.one)
  }

  implicit def nextableSeqIsNextable[T](implicit next: Nextable[T]) = new Nextable[Seq[T]] {
    override def nextOf(x: Seq[T]) = x.map(_.next)
  }
}

import Nextable.NextableOps // Important! Otherwise next line won't compile.
2.next // 3

可以看出,这种实现的语法噪音很多,过多的implicit和必须命名的implicit value使得真正的意图难以被察觉。

Contextual Functions

上下文函数其实不是一个十分新的概念,在Scala2中也可以见到。如果一个函数接收了上下文参数(即implicit argument或using argument),那么这个函数的行为就和上下文相关了,这并不是破坏了函数的引用透明性,Scala只是提供了一种机制让函数感兴趣的上下文自动作为参数了而已。

这种函数在Scala外也常见,例如,flask framework的request变量永远表示当前的请求,react的Hooks的函数也是,他们的特征都是在不同的上下文调用都具有不同的行为。可惜python和JavaScript都没有对上下文进行有效的抽象,导致他们背后的实现都是修改了某个全局状态实现的。

回到Scala,Scala有专门的方法在不影响引用透明性的基础上实现上下文函数,特别地,如果一个函数只接收上下文参数,这个函数被称为“Contextual Functions”,Scala3 用 ?=>来表示上下文函数

object Assertions:
  def it[T](using r: T): T = r

  extension [T](x: T):
    def asserts(condition: T ?=> Boolean): T =
      assert(condition(using x))
      x
    end asserts

import Assertions.{asserts, it}

(1 + 1).asserts(it == 2)

这里的it在不同的时候会有不同的值,是不是就像Kotlin了? 其原理为:

T ?=> U可以表示为(using x: T) => U,如果一个函数接受一个类型为T ?=> U的参数,比如asserts(it == 2),会自动被转换为:

asserts((using x: T) => it == 2)

然后这个x就会传入it的using参数中,就像Scala2中的隐式参数一样。

这个性质有点像swift中的@autoclosure,利用这个性质我们可以做一些恶趣味的事情,比如模拟Kotlin中的it匿名函数参数。

object Kotlin:
  class Ctx[T](val x: T) extends AnyVal
  def fun[T, U](fn: Ctx[T] ?=> U)(x: T): U = fn(using Ctx(x))
  def it[T](using ctx: Ctx[T]) = ctx.x

import Kotlin.{fun, it}

List(2, 3, 4).map(fun { it * it }) // List(4, 9, 16)

至于为什么要增加一个Ctx[T]的wrapper,参见这个issue

Summary

其实,Scala3对上下文的抽象大多是可以用Scala2中的implicit模拟,事实上,这些也都是implicit的语法糖。 更多的关键字分散了implicit关键字的职能,在短期更难记,但是在长期更易读,因为given和using比implicit更能表达其意思。

同时,Scala3引入了contextual function,用来抽象在不同上下文中行为不同的函数,提供了强大的功能。

r{{ changeExtraDisplaySettings({blurMainImage: true}) }}