Scala中的CBN、CBV和Currying

2020-12-24

< view all posts

CBN和CBV

对于scala中的值(value)而言,定义的时候是有三种方式:def, val 和 lazy val。它们的区别在于,def定义的值,只有在调用的时候才会执行,并且不会存储;val定义的值是立刻执行,并且存储;lazy val是在调用的时候才执行,不过执行过一次之后就会存储。

说完了值,下面就是函数。函数式语言里面有两种函数调用方式:call by name(CBN) 和 call by value(CBV)。区别在于,call by name 的调用,会先用代换模型执行这个函数,直到代换停止,再执行(evaluate)函数的参数,最后代入并得到最终结果。

而 call by value 的调用,首先执行(evaluate)所有的参数,之后再将参数代入函数,去开始代换。

举一个例子,定义一个死循环的递归函数loop:

def loop: Int = loop

再定义一个函数,它接受两个参数,然后只返回第一个参数:

def return1st(a: Int, b: Int): Int = a

那么实际上第二个参数是根本没有用到的。这时候如果用call by value的方式去调用它,会陷入死循环,因为程序会首先执行每个参数;而用call by name的方式,就不会有任何问题,因为程序首先对函数本身作代换,loop在这个过程中被排除掉了。

在Scala中,函数默认的调用方式是call by value,如果要对某个参数作call by name的调用,可以用=>符号来表示:

def return1st(a: Int, b: => Int): Int = a

注意冒号和=>之间必须有一个空格。

Currying

作为一个函数式语言,在Scala中,函数当然是一等公民。也就意味着函数可以作为函数的输入和输出。输入和输出函数的函数,叫做高阶函数(high order function),currying就是高阶函数的一种表达方式。不过scala的一个语法糖导致同一个高阶函数可以有几种很相似的写法,略有迷惑性。下面用例子来说明。

定义一个averageDamp()函数,它的作用在输入一个 f(x: Double): Double 后,输出 f() 的一个修改版 g() ,修改版的g(x)会返回x 和 f(x) 的平均值。那么我们可以这样写:

def averageDamp1(f: Double => Double):Double => Double = {
  def g(x: Double) = (x + f(x))/2
  g
}

这里我们在averageDamp()内部定义了一个函数g(),它会以自己的逻辑调用f()。之后再把这个g()作为外层函数的返回。因此 averageDamp() 在定义时,它的返回值类型是 Double => Double,表示它会返回一个函数。

题外话,这里外层函数的返回值类型 Double => Double 是必须加的,否则编译器会报错(IDEA会认为你是在调用g,但是忘记加参数了),这应该是Scala为了避免隐式bug而做出的一个规定。如果一定不想写,那么可以把最后的 g 改成 "g _" 或者 "g(_)",来明确表示希望把它作为一个函数返回。

在上面的写法中,g 的参数 x 是不需要从外面一个函数的参数值来接收的,因为它只是一个形式上的参数,用来描述 g() 对 f() 的调用逻辑。

调用的时候是这样的:

averageDamp1(f)(2)

先从 averageDamp(f) 得到一个新的函数,之后将2作为参数传递给这个函数。

那么也可以有另一种写法,把 f 和 x 同时作为参数传递给 g:

def averageDamp2(f: Double => Double, x: Double): Double = (f(x) + x) / 2

这种写法就比较直观,它返回的是一个Double类型的值,直接在函数体内完成对 f() 的调用,返回调用的结果。

调用的时候是这样的:

averageDamp2(f, 2)

最后,一种容易让人搞糊涂的地方是,上面的写法也可以把每个变量分别放到一个括号里,改成这样:

def averageDamp3(f: Double => Double)(x: Double): Double = (f(x) + x) / 2

在调用的时候,是这样的:

averageDamp3(f)(2)

看起来调用的方式和第一种写法完全一样,有没有区别呢,可以做这样的验证:

def f(x:Double) = x  //先随便定义一个f

averageDamp1(f)  //OK
averageDamp3(f)  //报错
averageDamp3(f)(_)  //OK

第一行可以运行,因为它是通过调用 averageDamp1 之后返回的一个独立的函数。而第二行会报错,因为它是对 averageDamp3 的调用,但是这个调用需要两个参数,只提供了一个。这说明 averageDamp3 在调用的时候,和 averageDamp2 是等价的。

不过第三行就比较神奇了,控制台的显示告诉我们 averageDamp3(f)(_) 是一个函数,并且它的类型和 averageDamp1(f) 的返回值一样,都是一个类型为 Double => Double 的函数。这说明我们在定义 averageDamp3 的时候,实际上隐式地定义了一个和 averageDamp1 一样的函数。再概括一点说,对多参数的函数(包括只有一个参数的),总是可以任意地去掉它的参数,改写成多层调用的形式。举例:

def sum1(x: Int, y: Int) = x + y

等价于:

def sum2(x: Int): Int => Int = {
  def g(y: Int) = x + y
  g
}

调用的时候 sum1(1,2) 也就等价于 sum2(1)(2)。

这一点最早是由一个叫Curry的人提出的,所以这种把参数分到不同括号里去的写法叫做Currying。

Scala提供了Currying的语法糖,也就是我们不需要特意像 averageDamp1 和 sum2 那样特地去定义一个返回函数的函数,而只需要像 averageDamp3 那样把每个参数分开到不同的括号中写,Scala就隐式地帮助我们准备好了和 averageDamp1 一样的函数,可以直接拿来用。

这个语法糖有什么用呢,例如程序中有一个 process(a)(b) 函数,而现在发现,实际业务中有很多情况,a都是一个固定值,比如说是1,那么我们就可以定义一个新的函数:

def processWithOne = precess(1)(_)

而不需要写成

def processWithOne(b: Double) = precess(1)(b)

等于说是通过这个语法糖,能让需要固定某个函数的部分参数不变的时候,写新的函数更加地简单。这可以说是它的作用之一。

另外,函数式编程中会非常频繁地用到高阶函数,这个语法糖使我们可以通过一个简单的定义之后,灵活地使用和它等价的所有高阶函数,提供了很大的方便。