Scala入门:函数定义和高阶函数

大数据学习路线图

【版权声明】博客内容由厦门大学数据库实验室拥有版权,未经允许,请勿转载!
[返回Spark教程首页]

Scala是一门多范式编程语言,混合了面向对象编程和函数式编程的风格。在过去很多年,面向对象编程一直是主流,但是,随着大数据时代的到来,函数式编程开始迅速崛起,因为,函数式编程可以较好满足分布式并行编程的需求(函数式编程一个重要特性就是值不可变性,这对于编写可扩展的并发程序而言可以带来巨大好处,因为它避免了对公共的可变状态进行同步访问控制的复杂问题)。

函数字面量

函数字面量可以体现函数式编程的核心理念。前面我们已经介绍过,字面量包括整数字面量、浮点数字面量、布尔型字面量、字符字面量、字符串字面量、符号字面量、函数字面量和元组字面量。

举例如下:

val i = 123  //123就是整数字面量
val i = 3.14 //3.14就是浮点数字面量
val i = true //true就是布尔型字面量
val i = 'A' //'A'就是字符字面量
val i = "Hello" //"Hello"就是字符串字面量

除了函数字面量我们会比较陌生以外,其他几种字面量都很容易理解。现在我们来认识什么是“函数字面量”。
在非函数式编程语言里,函数的定义包含了“函数类型”和“值”两种层面的内容。但是,在函数式编程中,函数是“头等公民”,可以像任何其他数据类型一样被传递和操作,也就是说,函数的使用方式和其他数据类型的使用方式完全一致了。这时,我们就可以像定义变量那样去定义一个函数,由此导致的结果是,函数也会和其他变量一样,开始有“值”。就像变量的“类型”和“值”是分开的两个概念一样,函数式编程中,函数的“类型”和“值”也成为两个分开的概念,函数的“值”,就是“函数字面量”。

函数的类型和值

下面我们一点点引导大家更好地理解函数的“类型”和“值”的概念。
我们现在定义一个大家比较熟悉的传统类型的函数,定义的语法和我们之前介绍过的定义“类中的方法”类似(实际上,定义函数最常用的方法是作为某个对象的成员,这种函数被称为方法):

def counter(value: Int): Int = { value += 1}

上面定义个这个函数的“类型”如下:

(Int) => Int

实际上,只有多个参数时(不同参数之间用逗号隔开),圆括号才是必须的,当参数只有一个时,圆括号可以省略,如下:

Int => Int

上面就得到了函数的“类型”,下面看看如何得到函数的“值”。
实际上,我们只要把函数定义中的类型声明部分去除,剩下的就是函数的“值”,如下:

(value) => {value += 1} //只有一条语句时,大括号可以省略

上面就是函数的“值”,需要注意的是,采用“=>”而不是“=”,这是Scala的语法要求。

现在,我们再按照大家比较熟悉的定义变量的方式,采用Scala语法来定义一个函数,如下:
声明一个变量时,我们采用的形式是:

val num: Int = 5 //当然,Int类型声明也可以省略,因为Scala具有自动推断类型的功能

照葫芦画瓢,我们也可以按照上面类似的形式来定义Scala中的函数:

val counter: Int => Int = { (value) => value += 1 }

从上面可以看出,在Scala中,函数已经是“头等公民”,单独剥离出来了“值”的概念,一个函数“值”就是函数字面量。这样,我们只要在某个需要声明函数的地方声明一个函数类型,在调用的时候传一个对应的函数字面量即可,和使用普通变量一模一样。

匿名函数、Lambda表达式与闭包

我们不需要给每个函数命名,这时就可以使用匿名函数,如下:

(num: Int) => num * 2

上面这种匿名函数的定义形式,我们经常称为“Lambda表达式”。“Lambda表达式”的形式如下:

(参数) => 表达式 //如果参数只有一个,参数的圆括号可以省略

我们可以直接把匿名函数存放到变量中,下面是在Scala解释器中的执行过程:

scala> val myNumFunc: Int=>Int = (num: Int) => num * 2 //这行是我们输入的命令,把匿名函数定义为一个值,赋值给myNumFunc变量
myNumFunc: Int => Int = <function1>  //这行是执行返回的结果
scala> println(myNumFunc(3)) //myNumFunc函数调用的时候,需要给出参数的值,这里传入3,得到乘法结果是6
6

实际上,Scala具有类型推断机制,可以自动推断变量类型,比如下面两条语句都是可以的:

val number: Int = 5
val number =5 //省略Int类型声明

所以,上面的定义中,我们可以myNumFunc的类型声明,也就是去掉“Int=>Int”,在Scala解释器中的执行过程如下:

scala> val myNumFunc = (num: Int) => num * 2
myNumFunc: Int => Int = <function1>
scala> println(myNumFunc(3))
6

下面我们再尝试一下,是否可以继续省略num的类型声明,在Scala解释器中的执行过程如下:

scala> val myNumFunc= (num) => num * 2
<console>:12: error: missing parameter type
       val myNumFunc= (num) => num * 2
                       ^

可以看出,解释器会报错,因为,全部省略以后,实际上,解释器也无法推断出类型,所有,需要你提供类型声明。
下面我们尝试一下,省略num的类型声明,但是,给出myNumFunc的类型声明,在Scala解释器中的执行过程如下:

scala> val myNumFunc: Int=>Int = (num) => num * 2
myNumFunc: Int => Int = <function1>

可以看出,顺利运行通过,不会报错,因为,给出了myNumFunc的类型为“Int=>Int”以后,解释器可以推断出num类型为Int类型。

下面再来看一下什么是“闭包”?
闭包是一个函数,一种比较特殊的函数。为了解释闭包这个概念,我们在Linux系统的“/usr/local/scala/mycode”目录下新建test.scala代码文件,里面包含以下内容:

object MyTest{
    def main(args: Array[String]): Unit={    
        def plusStep(step: Int) = (num: Int) => num + step
        //给step赋值
        val myFunc = plusStep(3)
        //调用myFunc函数
        println(myFunc(10))     
    }
}

然后,在Linux系统的Shell命令提示符下,运行scala命令运行test.scala代码,如下:

scala test.scala
13  //这是运行结果

在上面的MyTest中, step是一个自由变量,它的值只有在运行的时候才能确定,num的类型是确定的,num的值只有在调用的时候才被赋值。这样的函数,被称为“闭包”,它反映了一个从开放到封闭的过程。
如何准确理解“从开放到封闭的过程”呢?下面具体解释一下。
前面说过,闭包是一个函数,一种比较特殊的函数,它和普通的函数有很大区别。对于一个普通的函数而言,比如:

val addMore=(x:Int)=>x>0

上面就定义了一个普通的函数,在函数中只会应用函数中定义的变量,比如x就是函数的传入参数,不会引用函数外部的变量。而闭包则不同,闭包会引用函数外部的变量,下面是一个闭包定义的实例:

val addMore=(x:Int)=>x+more

可以看出,这个函数定义中,引用了变量more,而more并没有在函数中定义,是一个函数外部的变量。如果这个时候去编译执行这条语句,编译器会报错,因为,more是一个自由变量,还没有绑定具体的值,因此,我们说这个时候这个函数是“开放的”。相对而言,这时的x就是一个绑定的变量,已经在函数中给出了明确的定义。因此,为了能够让addMore正常得到结果,不报错,必须在函数外部给出more的值,如下:

scala> var more = 1 
more: Int = 1 
scala> val addMore = (x: Int) => x + more  
addMore: (Int) => Int = < function>  
scala> addMore(10)  
res19: Int = 11 

从上面代码可以看出,我们给more确定具体的值1以后,就让函数addMore中的more变量绑定了具体的值1,不再是“自由变量”,而是被绑定具体的值了,或者说“被关闭”了,这也是为什么我们说这样一个函数叫做“闭包”,它反映了一个从开放到封闭的过程。

而且,闭包(也是一个函数)对捕获变量作出的改变在闭包之外也是可见的,比如,上面的例子中,如果在addMore函数体中对more做了修改,在函数外部也可以看到这种变化。
另外,每次addMore函数被调用时都会创建一个新闭包。每个闭包都会访问闭包创建时活跃的more变量,比如:

scala> var more = 1 
more: Int = 1 
scala> val addMore = (x: Int) => x + more  
addMore: (Int) => Int = < function>  
scala> addMore(10)  
res19: Int = 11 
scala> more =9
more: Int = 9
scala> addMore(10)  
res20: Int = 19

从上面可以看出,第1次调用addMore(10)的时候,捕获到的自由变量more的值是1,因此,addMore(10)的结果是11。第2次调用addMore(10)的时候,捕获到的自由变量more的值是9,因此,addMore(10)的结果是19。结论:每个闭包都会访问闭包创建时活跃的more变量。

高阶函数

前面已经说过,函数在Scala中是“头等公民”,它的使用方法和任何其他变量是一样的。一个接受其他函数作为参数或者返回一个函数的函数就是高阶函数。

现在我们给出一个简单的高阶函数实例。假设有一个函数对给定两个数区间中的所有整数求和:

def sumInts(a: Int, b: Int): Int = {
if(a > b) 0 else a + sumInts(a + 1, b) 
}

现在,我们重新设计函数sumInts的实现方式,让一个函数作为另一个函数的参数:

//定义了一个新的函数sum,以函数f为参数
def sum(f: Int => Int, a: Int, b: Int): Int ={ 
  if(a > b) 0 else f(a) + sum(f, a+1, b)
}
//定义了一个新的函数self,该函数的输入是一个整数x,然后直接输出x自身
def self(x: Int): Int = x
//重新定义sumInts函数
def sumInts(a: Int, b: Int): Int = sum(self, a, b)

从上面定义可以看出,对于函数sum而言,它的参数类型是(Int=>Int, Int, Int),结果类型是Int,因此,函数sum的类型是:

(Int=>Int, Int, Int) => Int

也就是说,函数sum是一个接受函数参数的函数,因此,是一个高阶函数。

我们上面这种处理方式,看起来“很折腾”,原来很简单的函数sumInts,现在要绕一大圈来实现,这么曲折,到底有什么意义呢?
如果我们单纯只从sumInts的角度来看,上面这种折腾的做法,确实是得不偿失的,但是,如果存在下面这些应用场景,你就可以发现,这种折腾的做法是相当值得的。

比如,现在需要求出连续整数的平方和,代码如下:

def square(x: Int): Int = x * x
def sumSquares(a: Int, b: Int): Int = {
  if(a > b) 0 else square(a) + sumSquares(a + 1, b)
    }

再比如,现在需要求出连续整数的关于2的幂次和,代码如下:

def powerOfTwo(x: Int): Int = {
    if(x == 0) 1 else 2 * powerOfTwo(x-1)
}
def sumPowersOfTwo(a: Int, b: Int): Int = { 
  if(a > b) 0 else powerOfTwo(a) + sumPowersOfTwo(a+1, b)
}

上面的函数都是从a到b的f(n)的累加形式(其中a<=n<=b),唯一的区别就是各种场景下f(n)的具体实现不同,所以,我们可以抽取这些函数中共同的部分重新编写函数sum,并把定义的f(n)作为一个参数传入到高阶函数sum中,代码如下:

def sum(f: Int => Int, a: Int, b: Int): Int = {
  if(a > b) 0 else f(a) + sum(f, a+1, b)
}

def self(x: Int): Int = x
def square(x: Int): Int = x * x
def powerOfTwo(x: Int): Int = if(x == 0) 1 else 2 * powerOfTwo(x-1)

def sumInts(a: Int, b: Int): Int = sum(self, a, b)
def sumSquared(a: Int, b: Int): Int = sum(square, a, b)
def sumPowersOfTwo(a: Int, b: Int): Int = sum(powerOfTwo, a, b)

最后,让我们把上述代码放入到Linux系统的“/usr/local/scala/mycode”目录下新建test.scala代码文件进行测试,文件里面包含以下内容:

def sum(f: Int => Int, a: Int, b: Int): Int = {
  if(a > b) 0 else f(a) + sum(f, a+1, b)
}

def self(x: Int): Int = x
def square(x: Int): Int = x * x
def powerOfTwo(x: Int): Int = if(x == 0) 1 else 2 * powerOfTwo(x-1)

def sumInts(a: Int, b: Int): Int = sum(self, a, b)
def sumSquared(a: Int, b: Int): Int = sum(square, a, b)
def sumPowersOfTwo(a: Int, b: Int): Int = sum(powerOfTwo, a, b)
println(sumInts(1,5))
println(sumSquared(1,5))
println(sumPowersOfTwo(1,5))

然后,在Linux系统的Shell命令提示符下,运行scala命令运行test.scala代码,如下:

scala test.scala
15  //这是运行结果
55
62

从上面的介绍中可以发现,高阶函数实现了强大的功能。表面上都是定义了sum函数,形式都一样,但是,针对不同应用场景,每个sum函数中调用的函数f,都具备了不同的处理逻辑。

占位符语法

为了让函数字面量更加简洁,我们可以使用下划线作为一个或多个参数的占位符,只要每个参数在函数字面量内仅出现一次。
下面是一个实例(我们在Scala解释器中运行,从而可以立即看到运行结果):

scala> val numList = List(-3, -5, 1, 6, 9)
numList: List[Int] = List(-3, -5, 1, 6, 9)
scala> numList.filter(x => x > 0 )
res1: List[Int] = List(1, 6, 9)
scala> numList.filter(_ > 0)
res2: List[Int] = List(1, 6, 9)

从上面运行结果可以看出,下面两个函数字面量是等价的。

x => x>0
_ > 0

当采用下划线的表示方法时,对于列表numList中的每个元素,都会依次传入用来替换下划线,比如,首先传入-3,然后判断-3>0是否成立,如果成立,就把该值放入结果集合,如果不成立,则舍弃,接着再传入-5,然后判断-5>0是否成立,依此类推。

子雨大数据之Spark入门
扫一扫访问本博客