Scala :Function as Value

1,321 阅读10分钟

OOP,Object Oriented Programming,面向对象编程,它的核心思想是:

  • 封装状态 state,让系统中的各个组件保持有限的通信与访问,同时各司其职,降低耦合。OOP 适用于通过大量内部变量来维护状态的复杂系统。

FP,Funtional Programming,面向函数编程 ( 或称函数式编程 ),它的核心思想是:

  • 封装行为 action ,推崇简单的行为去组合,描述复杂的行为。FP 适用于无状态的,基于数据流管道执行纯计算任务的系统。

Scala 是一门多范式的语言,OOP 和 FP 在这里可以共存,乃至相辅相成。因此,面对不同的设计任务时,Scala 开发者仍能自由地切换最合适的编程范式去解决问题。Scala 的函数有更丰富的语义,了解它们是之后学习并投入到函数式编程的基础。

函数 是 Scala 的一等公民。最直观地,我们可以直接将函数声明为变量,然后在程序的任何一处传递。比如:

val f : Int => Int = (a : Int) => 2 * a     // f(a) = 2a

在这里,Int => Int 代表一个输入 Int ,输出也为 Int表达式。由于 Scala 的类型推断机制,上式还有多种表述方式:

val f1 : Int => Int = a => 2 * a     // f(a) = 2a
val f2 : Int => Int = 2 * _
val f3 = 2 * (_ : Int)

Scala 使用 函数标识符 来表示传递函数本身,而非调用。有时为了避免歧义,也可以在标识符后面加上 _。如:

def f(a : Int) = 2 * a
// val g : Int => Int = f _  
val g: Int => Int = f
g(10) // == f(10)

传名调用与传值调用

Scala 默认:当调用某个函数时,入参要被率先求值,这个行为称 传值调用 call by value。比如:

def foo(a : Int) = 2 * a
def goo(b : Int) = 3 * b
// 首先调用 goo,然后调用 foo
foo(goo(1))

按照传值调用的说法,程序首先计算出 goo(1) 的值为 3,然后再将它传入到 foo 函数内计算得到 6

传值调用的优点是:由于入参已经在调用前计算好了,因此函数的内部无需再对这个变量反复求值。但有些情况下,我们希望某个入参的计算被推迟,直到它被真正调用。这种情况下需要使用 传名调用 call by name。Scala 中使用 : => A 来表示推迟某个参数的计算:

def foo(a : => Int) = 2 * a
def goo(b : Int) = 3 * b
// 首先调用 foo, 然后调用 goo
foo(goo(3))

传名调用是延迟计算 ( 又称:非严格求值,惰性求值 ) 的手段之一,而延迟计算又是函数式编程的其中一个重要特性。见:探究 Scala 非严格求值与流式数据结构 - 掘金 (juejin.cn)

副作用与纯函数

函数的副作用(side effect),指函数 通过返回值以外的其它方式与系统 ( 或称上下文 ) 进行交互。这种交互包括:修改某个全局变量,I/O 读写,控制台输出等。比如,下文的 f 修改了外部变量 global 的值:

var global = 0
def f() : Unit = global = global + 1

那些没有副作用的函数称之纯函数,反之则称非纯 ( not pure ) 函数。更严格点说,纯函数不会改变外部状态,同时也不受外部的影响。

def g(a : Int, b : Int) = a + b

函数 g 就是一个纯函数,它的返回值仅取决于输入的参数 ab

副作用本身是中性的,对于通过记录全局变量来维护状态的系统而言,函数具备副作用并无不妥,甚至还是必要的。比如,我们总是通过让程序输出结果到控制台的方式进行人机交互。

而纯函数最接近数学中的 "映射",是函数式编程中构建代数推理模型的最基础概念。见:Scala 函数式数据结构与递归的艺术 - 掘金 (juejin.cn)。由于副作用会破坏引用透明特性,因此 在函数式编程中,我们必须要隔离副作用

从经验上来看,返回 Unit 的函数一定会包含副作用。因为如果一个函数既不返回值,又不通过副作用与外界交互,那么声明这样的函数实际上是没有意义的。

统一访问原则

Scala 允许声明不带任何参数列表的 无参数函数。形如:

def str : String = "hello world"

这里的 str 虽然是一个函数,但是看起来和声明变量没什么区别:

val str : String = "hello world"

从程序用户的角度去考虑:如果他们仅为了获得结果,那么 str 究竟是一个函数还是变量其实并不重要。这个思想就是统一访问原则 ( Uniform access principle )。统一访问原则常用于屏蔽获取某个对象属性的具体细节,比如:

val bill = new Trolley(amount = 10,price = 2,coupon = 0.8)
bill.total

假设 Trolley 是一个购物车类,每个实例记录了一单物品个数,单价,折扣。在上面的代码中,用户看似是通过访问 bill.total 属性得到了交易总额,但实际上,total 是一个方法,而统一访问原则隐藏了总价 total 的具体计算过程:

class Trolley(amount : Int,price : Int, coupon : Double) {
   def total: Double = amount * price * coupon
}

无参数函数 parameterless function空括号函数 empty-paren method 存在一些细微的差别。比如:

def g1() : Int = 100
def g2   : Int = 100

显然,调用无参数函数不需要携带参数,而调用空括号函数则需要携带 ()

println(s"value of invoke g1() : ${g1()}")
println(s"value of invoke g2   : ${g2}")

另外,Scala 有一个默认的约定:如果一个函数不接受任何函数,且不具备副作用,则可以将其定义为无参数函数。否则,应该定义为空括号函数。

闭包与高阶函数

在 Scala 的函数式编程中,运用高阶函数组合函数逻辑是一项基本的技能。

对于一个函数而言,如果它内部的某个变量的定义在其函数体之外,则这个变量被称之为自由变量。比如在下面的例子中,y 是函数 g 的自由变量,而 xg 的约束变量。

var y = 0
def g(x : Int): Int = x + y

闭包,指代一个函数和它相关的引用环境所组合而成的一个实体,计算这个函数所需要的全部信息仅在该实体内就可全部确定。比如,将上述的代码块视作一个整体 closure,它封装了函数 g 以及它的自由变量 y,因此称 closure 是函数 g 的闭包。

val closure: Unit = {
  var y = 0
  def g(x : Int): Int = x + y
}

我们不妨从闭包的推广到高阶函数 ( higher order function ),即,接收函数的函数,比如:

def hof(f: (Int, Int) => Int, a: Int, b: Int): Int = f(a,b)

在这个例子中,f 是一个接收两个 Int ( 或认为是接收一个 (Int,Int) 类型的二元组 ),并返回一个 Int 类型的表达式,记作 (Int,Int)=>Int。高阶函数 hof 所表现的具体行为实际上由 f 决定。

从闭包的角度来看,高阶函数充当了内部函数的上下文 ( context )。比如:

def context(x : Int, y : Int) : Int = {
  def run(a : Int) : Int = x * y - a 
  run(10)
}

xy 对于 run 而言是自由变量。如果只从 run 本身分析,它是一个非纯函数,因为它的返回值不完全取决于它的参数列表。但从高阶函数 context 的作用域分析,context 包含了计算 run 所需的信息。比如给定 context(x = 1, y = 2)run 的所有自由变量转为常量,此时它可被认为是纯函数。由此还可知,函数的纯粹性是在一个上下文中去讨论的。

特殊地,如果一个函数不接收任何参数,也不返回任何值,那么它可被认为是对一段闭包代码块的抽象,简称 控制抽象

def action (f : () => Unit): Unit = f()
action {()=>
  val a = 1
  val b = 1
  println(a + b)
}

在这个例子中,我们可以将任意形式的代码块作为 f 传到高阶函数 action 内部。进一步,由于 f 本身不需要接收任何参数,因此不妨将它声明为 => Unit 的传名调用,以此精简我们的程序表达。如:

def action (f : => Unit): Unit = f
action {
  val a = 1
  val b = 1
  println(a + b)
}

这里有个精简的例子,一个完全由函数组成的 while 循环。

def while_(condition: => Boolean,action : => Unit): Unit = if(condition) {action;while_(condition, action)}
​
var i = 0
var sum = 0
while_({i <= 10},{ sum += i;i+=1})
println(sum)

这个例子其实暗示了 一切迭代都可以使用尾递归函数替代,读者仍然可以从 Scala 函数式数据结构与递归的艺术 - 掘金 (juejin.cn) 那里获取一些线索。不过,目前 while_ 的表达方式好像还差点意思,我们在稍后解决这一问题。

柯里化

考虑这样嵌套的闭包结构:

def c1(x: Int): Int => Int => Int = {  
  def c2(y: Int): Int => Int = {
    def c3(z: Int): Int = {x + y - z}
    c3
  }
  c2
}

函数 c3 在存在两个自由变量 xy,而这两个自由变量分别存在于在闭包 c1c2 上。我们逐步分析各个函数的类型:

  1. c3 自身是接受 Int 并返回另一个 Int 的普通函数,因此它的类型是 Int => Int
  2. c2 自身是接受 Int 并返回 c3 的闭包,因此它的类型是 Int => (Int => Int)
  3. c1 自身是接受 Int 并返回 c2 的闭包,因此它的类型是 Int => (Int => (Int => Int))

对于 Scala 而言,Int => (Int => Int)Int => Int => Int 没有什么不同,同样,c1 也会被翻译成 Int => Int => Int => Int 类型。它可以被看作是描述了这样的计算路径:首先通过 c1 确定参数 x ,然后通过 c2 确定参数 y,最后通过 c1 确定参数 z,并最终计算出 x + y - z 的值。

val g1 = c1(10)  // x = 10
val g2 = g1(5)   // y = 5
val g3 = g2(3)   // z = 3
​
println(g3)      // 10 + 5 - 3 = 12 

上述的计算可以一步到位:

println(c1(10)(5)(3))

实际上,我们将 x + y -z 拆解为了三个步骤,这种拆解被称之为柯里化 ( currying )。柯里化是另一种对函数延迟求值的手段。从另一个角度理解,柯里化记忆了参数,使得复用计算路径称为可能。 如:

val f1 = c1(5) // x = 5, y = ?, z = ?
val f2 = f1(2) // x = 5, y = 2, z = ?
val f3 = f1(3) // x = 5, y = 3, z = ? 
val f4 = f2(1) // x = 5, y = 2, z = 1
val f5 = f3(4) // x = 5, y = 2, z = 4

好在我们不需要通过声明嵌套闭包的形式实现柯里化。Scala 提供了下面的语法糖,通过拆分多段参数列表的形式来构建参数的逐步确认过程:

def closure(x : Int)(y : Int)(z : Int) = x + y - z
​
val g1 = closure(10)(5)(2)   // x = 10, y = 5, z = 2
val g2 = closure(10)(2)      // x = 10, y = 5, z = ?
val g3 = closure(10)         // x = 10, y = ?, z = ?

下面是一个普通的函数声明,它只有一个参数列表。

def epxr(x : Int, y : Int, z  : Int) = x * y - z

即便如此,我们仍然有办法在不改变原有函数签名的前提下进行柯里化调用。比如创建一个这样的匿名表达式:

val hof = expr(_ : Int, y = 10, _ : Int)

这里没有立刻提供 xz 的值,而是使用 _ 做了留空处理。换句话说,目前声明的 hof 是一个高阶函数,它仅仅确定了状态 y = 10。想要这么做,必须需要给 Scala 编译器留下足够的类型信息,比如显式标注每个缺省位置的参数类型,如 _ : Int 这样。或者是声明 hof 完整的函数类型:

val hof: (Int, Int) => Int = expr(_, y = 10, _)
// 2 * 10 - 3 = 17
println(hof(2,3))

柯里化的另一个用途就是编写高可读性的代码 ( 这会随着 Scala 编程经验的积累而逐步体现出来 ) 。以刚才自实现的 while_ 函数为例子,尝试着将它转换成柯里化形式:

def while_(condition: => Boolean)(action : => Unit): Unit = if(condition) {action;while_(condition)(action)}

这样一来,我们完全就能够以近乎原生的写法调用自实现的 while 循环了。

while_(i<=10){
  sum += i
  i+=1
}

参考链接