第二周
这一周的内容主要围绕着“函数”来进行。本来想顺着书的内容往下讲,不过那样就没有自己的东西了,所以,就想到哪里写到哪里吧~
普通函数
我们从一个最普通的函数开始。它的作用跟函数名一样,你给它输入什么,它给你返回什么。
def echo(x : Int) : Int = {
x
}
在语法上:
- 函数定义以def开头。
- 函数的返回值类型在函数名之后。
- 注意那个等号。
- 不需要显式的写return。(请问是为什么呢)
泛型函数
当然,这个例子只是为了引出接下来的例子,因为普通的版本,只能接受Int作为输入和输入,如果要支持任意类型,就需要让函数支持泛型。
def echo[T](x : T) : T = {
x
}
通过在函数名之后的中括号和T,可以支持单个参数的泛型,如果要支持多个,可以写成类似[T, V]的形式。
带默认值的函数
好了,水完跟Java类似的部分,下面讲讲Scala做得好的部分。
相信你一定见过这样的代码,把一个字符串类型的日期,转换为Date,通常会根据不同的合适进行适配,比如20201226和2020-12-26。通常大部分日期格式都是第二种,因此会定义2个函数,一个需要str和pattern 2个参数,一个只需要str一个参数。
但是,在Scala中,你可以通过一个带默认值的函数来完成Java里2个函数的功能。
def str2Date(str : String, pattern : String = "yyyy-MM-dd") : Date = {
…
}
str2Date("20201226", "yyyy-MM-dd")
str2Date("2020-12-26")
在语法上,通过pattern参数后面加一个等号来完成。
递归和尾递归
递归大家都懂,但很多人都不知道尾递归是什么。
在我的理解里,尾递归就是满足这样条件的递归:递归函数的调用出现在函数的最后一行,并且,这一行有且只有这个函数调用本身。
比如下面的写法就是一个普通的递归函数。
def fact(i : Int) : Int = {
if (i == 1) 1
else i * fact(i - 1)
}
如果要改成尾递归形式,可以这么写。
def fact(i : Int) : Int = {
@annotation.tailrec
def inner(n : Int, result : Int) : Int = { // 1
if (n == 1) result // 2
else inner(n - 1, n * result) // 3
} // 4
inner(i, 1) // 5
}
这里用了一个嵌套函数的例子,证明了在Scala中函数属于一等公民。(这个点我们稍后再展开)
一次函数调用意味着JVM中一个栈帧的入栈和出栈,因此普通的递归函数容易出现“爆栈”。而编译器在看到尾递归的写法的时候,可以进行优化:把递归改为迭代,从而消除函数调用。
从递归到迭代是有范式的,比如上面的写法可以转化为以下的等价写法,不过,这是我的一个理解,而真正的实现,感觉可以再留一个坑在这里后面来填。
def fact(i : Int) : Int = {
var result : Int = 1 // 根据5的第二个参数
var n : Int = i // 根据5的第一个参数
while (n > 1) { // 根据2和3
result *= n // 根据3的n * result
n -= 1 // 根据3的n - 1
}
result
}
(顺带提一句,在Java里是没有尾递归优化的说法的)
下面终于来到了FP(函数式编程)的世界(激动)
一等公民
先叨叨一下什么是刚才说到的一等公民。在Java的世界里,类是一等公民,因为你可以把它赋值给一个变量、你可以把它作为一个方法的输入和输出、你可以在任何地方定义一个类(在方法里、在类里、甚至是定义一个匿名内部类)。在Java的世界里,函数是“从属”于类的,这就会显得很繁琐和臃肿,比如要根据不同的方式给一个List排序,还需要定义一个排序的接口。
而在Scala的世界(或者说函数式的世界),函数就是一等公民,我们参考一下刚才的定义,你可以把它赋值给一个变量、你可以把它作为一个方法的输入和输出、你可以在任何地方定义一个函数(比如刚才那个例子里面的inner就是定义在fact里面的辅助函数)。
当我们用i这个变量,val i : Int = 1
,去接收一个值的时候,实际上我们需要声明i的类型是什么。而现在,要支持把函数赋值给i这样的特性,对于程序员来说是有“额外”的负担的。那就是你需要知道函数的类型,并把它显式的声明出来。
在写Java的时候,其实你不会那么的关注一个方法的类型。比如刚才的fact函数,类型是(Int) => Int
,而inner函数的类型是(Int, Int) => Int
。可以看到,函数的类型和入参的类型及个数,以及返回值都相关。(这和Java里判断方法是否重载的逻辑不同)。
(考考你,Int => Int => Int
,代表的是怎样的一个函数?)
高阶函数
刚才讲了函数赋值给变量i的场景,那相应的,一个函数A可以作为另一个函数B的入参或者返回值。像B这样的函数,我们称为高阶函数。
大家最耳熟能详的高阶函数应该是Map和Reduce。如果是从Java或者Spark切换过来的,可能对于Map的理解是Stream的一个方法或者RDD的一个方法。实际上最正统的Map(来自于Lisp之父约翰·麦卡锡),应该支持2个参数,一个参数是一个集合,另一个参数是一个函数。它的作用是把函数应用在每一个集合元素上,并返回一个新的集合。
// 定义一个map函数
def map(f : Int => Int, c : List[Int]) : List[Int] = {
var res = List[Int]()
for(e <- c) {
res = res :+ f(e)
}
res
}
// 将列表的每个元素值翻倍
map(e => e * 2, List(1, 2, 3, 4))
// 将列表的每个元素值加1
map(e => e + 1, List(1, 2, 3, 4))
基于高阶函数,可以实现Java中很多的设计模式,比如在高阶函数中定义好一个通用的骨架,然后把需要动态变化的部分,通过函数参数传进来,能实现类似于模版模式、策略模式、代理模式等多种效果。
多扯几句,高阶函数这个词,听着比较学究,实际上,如果把上面那个例子的=>
改成->
就是Java中的Lambda表达式的写法了,从使用的角度来说,我们不需要高阶函数这样的说法,不过如果在面试时候能把这一套讲清楚,无疑是一个加分项。
更多关于Java中的Lambda的内容,可以参考《State of the Lambda》[2]这篇文档(需要的化,可以翻译给你哦)。
柯里化
上面讲了以函数作为参数的高阶函数,下面我们讲讲以函数作为返回值的高阶函数。
假设有这样一个东西(A):
当我输入1时,它会返回给我这样一个东西(B),接受一个Int型的输入,并返回输入加1后的结果。
当我输入10时,它会返回给我这样一个东西(C),接受一个Int型的输入,并返回输入加10后的结果。
在Java的世界里,我们可能会定义一个叫Increase接口,B和C都是它的实现类。,而A则是Increase的工厂。
一个字,繁琐。在FP的世界里,我们Duck不必这样。
def increaseMaker(i : Int)(n : Int) : Int = {
i + n
}
increaseMaker(5)(10)
// 得到15
val increase1 = increaseMaker(1) _
val increase5 = increaseMaker(5) _
increase1(10)
// 得到11
increase5(10)
// 得到15
定义一个构造increase的函数,它有2个参数。
通过increaseMaker(1) _
我们绑定了它的第一个参数,在第二个参数的位置,用占位符代替,代表未来再来绑定。这样我们就构造了一个部分应用的函数。
再通过类似increase1(10)
的形式,拿到最后的结果。
这底层所用到的技术,我们称之为柯里化。它是指,一个多参数的函数,可以接受一个参数,并返回一个可以接受其余参数的函数的技术。
而返回的那个函数,我们成为闭包。意思是,一个携带着状态的函数,而这个状态或者说变量的定义,来自于函数之外。
刚才那个例子,实际上还可以这么写,我认为参数组,就是下面这种写法的语法糖:
def increaseMaker(i : Int) : Int => Int = {
def inner(n : Int) = {
i + n
}
inner
}
因为它,像Lambda演算这样只能支持单参数的东西,才有了支持多参数的能力。
插播一下,柯里化这个词,是为了纪念数学家柯里而命名的。他的全名叫Haskell Curry,first name和last name都贡献给了PL界。