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
就是一个纯函数,它的返回值仅取决于输入的参数 a
和 b
。
副作用本身是中性的,对于通过记录全局变量来维护状态的系统而言,函数具备副作用并无不妥,甚至还是必要的。比如,我们总是通过让程序输出结果到控制台的方式进行人机交互。
而纯函数最接近数学中的 "映射",是函数式编程中构建代数推理模型的最基础概念。见: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
的自由变量,而 x
是 g
的约束变量。
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)
}
x
和 y
对于 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
在存在两个自由变量 x
和 y
,而这两个自由变量分别存在于在闭包 c1
和 c2
上。我们逐步分析各个函数的类型:
c3
自身是接受Int
并返回另一个Int
的普通函数,因此它的类型是Int => Int
。c2
自身是接受Int
并返回c3
的闭包,因此它的类型是Int => (Int => Int)
。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)
这里没有立刻提供 x
和 z
的值,而是使用 _
做了留空处理。换句话说,目前声明的 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
}