你好,游客 登录
背景:
阅读新闻

第6章 Scala函数编程

[日期:2021-09-27] 来源:  作者: [字体: ]

本文来自艾叔编著的《零基础快速入门Scala》免费电子书,添加文末艾叔微信,获取完整版的PDF电子书

第6章  Scala函数编程

本章介绍Scala的函数编程。函数是Scala中的基本元素,同C/C++/Java等语言相比,Scala给函数赋予了更多的特性,在Scala中,使用函数就如同使用一个变量一样方便。

6.1  基本使用:函数定义、函数与方法、匿名函数

  1. 函数定义

1)直接定义函数

函数定义的通用格式如下:

val 函数引用(变量)= (参数列表) => {函数体}

例子代码如下,函数名为add2个输入参数,num1num2,都是Int类型,返回num1+num2的和。

scala> val add = (num1: Int, num2:Int) => { num1 + num2 }

函数的定义和变量的定义本质上是一样的,等于号左边是变量名,等于号右边都是赋初值,函数的值要复杂一些,包括:输入参数描述(num1:Int, num2:Int),函数体+返回值{num1+ num2}

前面所见的,使用def所定义的是方法,使用val定义的是函数;

函数的返回值由最后一行表达式的值决定,Scala不需要return,也不推荐使用return

函数的命名规则:函数名、参数的首字母小写,使用大写进行分隔。

add前面的关键字val,表示add不可更改,即不可以再次被赋值,如果使用var修饰,则可以被再次赋值,例子代码如下。

scala> var add = (num1: Int, num2:Int) => { num1 + num2 }

add再赋值,注意,传入参数和返回值类型,必须和前面的定义一致,即(Int, Int) => Int,如果不一致,会报错。

scala> add = (num1: Int, num2:Int) => { num1 * num2 }

验证,可以看到add变成了乘法操作,说明替换有效。

scala> add(2,3)

res1: Int = 6

1. 函数也是一个对象,定义时使用val/var修饰,而不是defdef定义的是方法;

2. 描述函数的2个要素:1. 输入参数类型;2. 返回值类型。在定义函数的时候,返回值类型可以由函数体中的代码推断而出,因此,不需要显示声明。

2)声明函数变量

也可以先声明函数变量,然后对其赋值。

下面的代码,声明了add是一个函数,此函数的描述是(Int, Int)=>Int,初始值是null

scala> var add:(Int,Int)=>Int = null

add: (Int, Int) => Int = null

赋值

scala> add = (num1:Int, num2:Int) => {num1 + num2}

因为,参数列表在声明时已经存在,因此,赋值时,可以进一步简化,省略掉参数列表,使用下划线表示参数,自左向右,第一个下划线表示第一个参数,第二个下划线表示第二个参数。

scala> add = _ + _

直接赋值

scala> val add:(Int,Int)=>Int = _ + _

验证

scala> add(5, 9)

res5: Int = 14

  1. 函数和方法

从语义和使用上讲,函数(Function)和方法(Method)没有本质区别,下面列出函数和方法的特性对比。

1)函数的定义使用val/var,方法的定义使用def

2)从实现上来说,函数是Trait Function0~22之间的一个实例,而方法是object/class的一个成员方法;

3)函数和方法都必须定义在objec/class中;

4)函数可以被赋值,但方法不能被赋值,例子如下;

下面定义了一个方法add

scala> def add(num1:Int, num2:Int) = {num1 + num2}

如果将add赋值给一个变量,是会报错的。

scala> val newAdd = add

<console>:12: error: missing argument list for method add

使用下划线_,可以将方法add展开成add函数,注意,下划线和add之间有一个空格。

scala> val newAdd = add _

验证。

scala> newAdd(2,3)

res0: Int = 5

函数是可以被赋值的,下面定义一个函数add

scala> val add = (num1:Int, num2:Int) => {num1 + num2}

add赋值给一个新变量newAdd

scala> val newAdd = add

5)函数A可以作为函数B的参数传递,方法A同样可以作为函数B的参数传递,在传递过程中,方法A被自动展开为函数A

6)函数可以在REPL中检验,例子代码如下

scala> val add = (num1:Int, num2:Int) => {num1 + num2}

输入add,可以看到,add是一个(Int,Int)=>Int函数,它是Trai function2的一个实例。

scala> add

res47: (Int, Int) => Int = <function2>

而方法则不能再REPL中检验,例子代码如下。

scala> def add(num1:Int, num2:Int) = {num1 + num2}

add: (num1: Int, num2: Int)Int

直接输入add,会报错。

scala> add

<console>:13: error: missing argument list for method add

7)函数是Function的实例,它有自己的一系列函数,例如toString,而方法则没有。

scala> println(add.toString)

<function2>

方法没有toString,打印报错

scala> println(add.toString)

<console>:13: error: missing argument list for method add

  1. 函数返回值

函数的返回值由函数体中最后一行的表达式结果的数据类型决定。

例子代码如下,函数体中有2行代码,最后一行代码是“Hello”,“Hello”是String类型,因此rsTs函数的返回值类型是String

scala> val rtTs = (num:Int) => {val rs=num+1; "Hello"}

rtTs: Int => String = <function1>

  1. return使用

一般情况下,函数的返回值类型可以由编译器根据上下文推断得出。但是,也有推断不出的情况,例如:1. 递归函数,且递归调用在最后一行; 2. 函数体中使用了return,此时,需要显式地指定返回值类型。

例子代码如下。

val rtTs = (num:Int) => {if (num<3) return -1 else return 0}

这个代码,在Scala shell中会报错。

<console>:12: error: return outside method definition

但是,在IDEA中,可以执行,但是结果不对,例如下面的调用,应该返回0,但执行后,没有任何回应,具体原因,下一节会解释。

rtTs(5)

使用def方法,需要显式地指明返回类型,否则会报下面的错。

scala> def rtTs(num:Int) = {if (num<3) return -1 else return 0}

<console>:12: error: method rtTs has return statement; needs result type

       def rtTs(num:Int) = {if (num<3) return -1 else return 0}

显式地指明返回值类型。

scala> def rtTs(num:Int):Int = {if (num<3) return -1 else return 0}

rtTs: (num: Int)Int

验证,调用,结果正确。

scala> rtTs(5)

res67: Int = 0

结论

  • 在函数中,不要使用return,编译和执行阶段都会出错;
  • 如果确实需要return,使用方法(def),并且需要显式地指明返回值类型。
  1. 匿名函数

匿名函数,从字面上理解,就是没有名字的函数。但是,除了函数名外,其它函数的要匿名函数描述的格式如下:

(参数列表)=> {函数体}

例子代码如下。

(num1:Int, num2:Int) => {num1 + num2}

匿名函数也可以看做是一种特殊的数据类型,因此,它同样可以定义变量、传参、或者作为返回值。

使用匿名函数定义变量,例子代码如下,=等于号右边就是一个匿名函数,=等于号左边是匿名函数的变量名。

scala> val add = (num1:Int, num2:Int) => {num1 + num2}

使用匿名函数传参,例子代码如下:getPrice2个参数,第一个参数f是一个匿名函数。

scala> val getPrice = (f: (Int)=>Int, num: Int) => { f(num) }

验证,定义符合f的函数变量vegetablePrice

scala> val vegetablePrice = (num: Int) => { num * 5 }

匿名函数传参,并计算。

scala> getPrice(vegetablePrice, 10)

res0: Int = 50

验证,定义符合f的函数变量meatPrice

scala> val meatPrice = (num: Int) => { num * 12 }

匿名函数传参,并计算。

scala> getPrice(meatPrice, 10)

res1: Int = 120

直接传参,其中,x表示num,即10,这是由函数体中的f(num),传参num所决定的。

scala> getPrice(x=>x*5, 10)

res2: Int = 50

去除参数列表,使用下划线_代替参数,可以进一步简化,只剩下函数体。

scala> getPrice(_*5, 10)

res3: Int = 50

参数列表简化去除后,匿名函数只剩下函数体,此时,为最简匿名函数;

如果一个函数的参数或者返回值中包含函数,那么称这个函数为高阶函数。

6.2  defval的区别

defcall-by-name,它并不是立即求值,而是在代码中用到def所定义的名字时,才求值,而valcall-by-value,它会马上求值;

例子代码中,value其实是一个方法,没有参数,也没有括号的方法。

scala> def value = 2 * 8

value: Int

很显然,value只有在被调用时,才会执行,才会求值。

scala> value

res59: Int = 16

如果使用val,则会马上求值。

scala> val value = 2 * 8

value: Int = 16

此外,defval所定义的变量都不能被修改。

6.3  函数、方法、返回值、return综合使用

Scala编程中,到底该如何选择:函数、方法、return

下面的代码给出了在函数中使用return的示例,该示例实现的功能是,对一个序列的元素依次处理,根据每个元素的大小范围,返回不同的值。这是一个反面示例,运行的结果和预期的结果并不一致。通过这个示例,我们将进一步学习到在Scala编程中,如何选择:函数、方法、return,来实现正确的功能。

例子代码如下所示。

1行定义了一个方法test

2~11行定义了一��内部函数getNum Int=>Int,传入参数n,根据n取值范围,返回不同的值;

10行,只有一个0,作为最后一行的表达式,即返回值,这是因为,使用val定义函数时,如果不写这个0,编译时会报错,事实上,程序永远不会执行到这;

13行,生成1~10的序列,将getNum传入map,依次处理序列中的每个元素,处理后的结果保存到rs中;

14行,输出rs中的每个元素。

      1 def test():Unit = {

      2   val getNum = (n: Int) => {

      3     if(n<3){

      4       println("n<3")

      5       return -1

      6     } else{

      7       println("n>=3")

      8       return n + 1

      9     }

     10     0

     11   }

     12

     13   val rs = (1 to 10).map(getNum)

     14   rs.foreach(n=>print(n + " "))

     15 }

调用test的执行结果如下,并没有按照预期所想,输出10个处理后的结果。

n<3

先不分析为什么,先找解决办法。

方法一:将第2行的代码修改为def,如下所示。因为函数体中使用了return,因此要显式地声明返回值类型为Int,此外,第10行可以去除。

  def getNum(n: Int): Int = {

执行后,可以按照预期,正确输出10个处理后的结果。

方法二:第2行不变,去除第5行,第8行的return关键字,保留返回值-1n+1,同时,去除第10行(必须要去除)。

执行后,同样可以按照预期,正确输出10个处理后的结果。

原因分析

  • 在方法(使用def)中,return只是返回本级函数,也就是getNum调用结束,后面的map可以依次进行;
  • 在函数(使用val)中,return表达式是通过抛出异常实现的,异常会被外层调用函数test所捕获,因此,会直接导致test退出,

 

6.4  函数使用总结

综合前面函数、方法、valdefreturn的说明和对比,对函数的使用总结如下。

  • 函数和方法没有本质区别,在语义层面,本书中并不严格区分函数和方法。下一节,我们会示例函数和方法的细微区别;
  • 在使用上,val/var用来定义普通变量和函数,def则只用来定义方法,val/var定义函数,有固定的格式,def定义方法,也有固定的格式,不要混用;
  • 如果需要将处理逻辑(函数体)赋值给多个变量,则应该定义函数;
  • 如果函数体内的表达式是可以直接计算出结果的,则应该定义函数,因为函数是使用val/var定义的,它在定义时就直接计算出表达式的值,后续使用此函数时,直接使用的是计算出的结果,如果使用方法,则每次使用方法时,都会重新计算值,而这个值又是不变的,会造成重复计算;
  • 能不用return,尽量不要用return
  • 如果一定要用return,使用方法(用def),不用函数(val/var);
  • 如果要用return,在方法中,要显式地声明返回类型,在函数中,则要在加上最后一行表达式(例子代码中的第10行)。

6.5  闭包(Closure

  1. 变量作用域

Scala的函数中,如果定义了一个变量,那么从定义该变量所在的行开始,到此函数结束之间的代码(即使这部分代码有子函数,也可以)都可以访问此变量,这部分代码也被称为此变量的作用域。

例子代码如下,函数add2个变量,num1num4,以num4为例,定义num4的行为2add函数结束的行为8,那么,从第3~8行的代码都可以访问num4,也就是说,num4的作用域是第3~8行(注意,第8行外的代码,就不能访问num4了),典型的,在第4行,ts2add的子函数ts1的子函数,依然可以访问num4

      1 val add = (num1: Int) => {

      2   val num4 = 4

      3   val ts1 = (num2: Int) => {

      4     val ts2 = (num3: Int)=>{num1 + num2 + num3 + num4}

      5     ts2

      6   }

      7   ts1

      8 }

反过来说,正是因为Scala变量的作用域,使得Scala的函数可以访问外部变量,例如ts2在第4行,那么,它可以访问第4行之上的父函数所有变量,这些变量要满足2个条件,第一个是变量的定义要在第4行之上;第二个是变量要在ts2的父系函数中,例如ts2的父函数ts1ts1的父函数add,以及add的父函数以及父函数的父函数等。假如,在add之上,还定义了一个函数A,但是Aadd是并列的,此时,虽然A所在的行是在ts2之上,但ts2是无法访问A中所定义的变量的,因为A不是ts2的父系函数。

  1. 闭包定义

下面,回到本节的正题:闭包(closure,下面给Scala中的闭包下一个定义。

闭包:函数代码+它所访问的外部环境(函数所使用到的外部变量的集合,如果一个外部变量可访问,但函数没用到它,那也不算在闭包内)。

例如,上面代码中,ts2的闭包就是:第4行代码+2行(num4+1行(num1);ts1的闭包就是:第3~6行代码+2行(num4+1行(num1)。

闭包中的包,和package没有半点关系,仅仅是closure的翻译而已;

Scala支持闭包,其实就是支持函数内部的代码,访问函数外部所定义的变量;

闭包也没有什么深奥和神奇的,不要被名字所迷惑,很多语言都支持闭包,我们平时编程时,也都自觉或者不自觉地在使用闭包;

闭包在Spark中应用非常多,RDD的处理函数中,很多时候会用到外部变量,那么各个节点处理RDD时,不仅需要具体的处理代码,代码中所用到的外部变量同样需要,因此这些内容都需要传输到各个节点,传输的这部分内容这就是闭包。有了闭包的概念,RDD并行处理时,就可以很方便地,将这部分内容整体分发到各个节点。

  1. 闭包容易出错的地方

下面给出闭包使用中,容易出错的一个例子,例子代码定义了一个函数数组,数组的每个元素是一个函数,数组赋初值时,使用了外部变量,结果导致逻辑错误,具体如下。

1)出错案例

1行,定义了一个函数数组funArray,数组的每个元素都指向一个Int=>Int的函数,

3~7行,对funArray的每个元素进行赋值;

5行,等于号右边是一个匿名函数,它使用了i这个外部变量,它的闭包是第5+3行;

9行,给funArray的每个元素输入参数2,打印结果。

      1 val funArray = new Array[Int=>Int](4)

      2

      3 var i=0

      4 while(i<funArray.length){

      5   funArray(i) = (num:Int) => {num*i}

      6   i+=1

      7 }

      8

      9 funArray.foreach(f=>println(f(2)))

funArray(0)~funArray(3),每个元素都是一个函数,返回值是输入参数*倍数,倍数由funArray的下标决定,例如funArray(0),倍数=0funArray(3),倍数=3。按照正常的逻辑,输出应该是:0246,但最终的结果却是:8888

为什么呢?

这是因为,在{num*i}中,访问了外部变量i,此时的i是一个对象引用,并不会立即赋值,也就是说,4funArray元素,访问的是同一个引用。到最后,跳出循环时,i=4,因此,所有的funArray元素都是{num*4},最终的结果就都是8了。

2)结论

闭包中,访问外部变量,一定要注意,此变量是否是立即赋值的?如果不是,则此变量的值还有可能被改变,这样可能会导致逻辑错误。

3)解决办法

方法一:第5行代码修改如下,第5行使用一个临时变量来存储i的值,每次循环到这,tmpi都会重新初始化,也就是说tmpi会指向一个新的地址,即每次的tmpi其实都是一个不同的变量,这样funArray的每个元素中的{num*tmpi}就不会相同了。

      5   val tmpi = i

      6   funArray(i) = (num:Int) => {num*tmpi}

方法二:修改whilefor,代码如下,此时的i0 to 3 Range的一个元素,数值不同,i也不同,因此,也会得到正确的结果。

      1 for(i <- 0 to 3){

      2   funArray(i) = (num:Int) => {num*i}

      3 }

 

  1. 闭包的应用场景
  • 函数中需要访问外部变量的时候;
  • 返回带状态(这个状态和父函数有关)的函数,例如函数A的返回值是一个函数BB的状态和A的输入参数有关,因此,B需要在代码中访问A的输入参数,此时就用到了闭包;
  • 柯里化(下一节会详细讲述)。

6.6  柯里化(Currying)

  1. 什么是柯里化

一般情况下,函数只有一个参数列表(参数列表是指:函数名后面的括号内容),参数列表中可能会有多个参数。如果把参数列表变成多个,每个列表里的参数变成只有1个,这个过程就称为柯里化(Curring)。

例如,函数add只有1个参数列表,有2个参数num1num2

scala> val add = (num1:Int, num2:Int) => {num1+num2}

利用柯里化,可以写成下面的形式,add只有1个参数num1,返回值是一个函数AA的输入参数是num2,返回值是{num1+num2},因为Aadd的子函数,利用闭包,A可以访问add中的num1

scala> val add = (num1:Int) => (num2:Int) => {num1 + num2}

调用add,只需要1个参数,例如add(1)

scala> add(1)

add(1)会返回一个函数,此函数的参数是Int,因此可以继续调用,输入参数2,最终的值是1+2=3

scala> add(1)(2)

res11: Int = 3

此外,上面的add函数还可以进一步简化为下面的形式,可以根据自己习惯,自行选择。

scala> def add(num1:Int)(num2:Int) = {num1+num2}

或者

scala> def add(num1:Int) = (num2:Int) => {num1+num2}

  1. 总结

1)柯里化将原函数的一个参数列表,变成多个参数列表(后面会讲柯里化有什么用);

2)柯里化利用了闭包的特性,在子函数中访问了父函数中的变量,上面的代码也可以写成下面的形式。

scala> val add = (num1: Int) => {

     | val newAdd = (num2: Int) => {num1+num2}

     | newAdd

     | }

add的返回值是子函数newAddnewAdd要访问num1num1是在父函数add中定义的,如果没有闭包特性,num1就无法访问。

3)除了闭包外,柯里化从本质上讲,还利用了高阶函数的特性,即函数的返回值是函数。

  1. 柯里化的应用场景

柯里化可以将多个参数的函数变成一个参数的函数,以适配传参。

例子如下,给定一个数组numList,指定一个范围(min,max),统计numList属于此范围内数字的个数,要求使用numListfilter来实现。

scala> val numList = Array(1, 3, 5, 6, 3, 8)

实现方法一

定义范围下界min

scala> var min=1

min: Int = 1

定义范围上界。

scala> var max=10

max: Int = 10

利用闭包,在filter中访问minmax,并统计符合条件的数字个数。

scala> numList.filter(n => {n>min & n<max}).map(_=>1).sum

res20: Int = 5

方法一的缺点,minmax暴露在外面,可以被其它代码修改,容易出错,而且也破坏了函数的封装性。

优化:去除minmax,将范围判断的逻辑变成一个函数,通过函数直接传参。正常情况下,这个函数可以这么写。

scala> val correctData = (min:Int, max:Int, data:Int) => { data>min & data<max}

但是,correctData3个参数,不符合filter传参的要求,filter传参要求如下,传入的函数只能有1个参数。

(p: Int => Boolean)

而这里有3个参数,怎么办?

利用柯里化,将3个参数的函数,变成只有1个参数的函数。

代码如下,correctData的第一个参数是min,它会返回一个函数AA的参数只有1个,就是maxA的返回值又是一个函数BB的参数是dataB的返回值是{data > min & data<max}B的函数体可以访问AcorrectData中的变量,正是利用了闭包的特性。

scala> val correctData = (min:Int)=>(max:Int)=>(data:Int)=>{ data>min & data<max}

调用如下

scala> numList.filter(correctData(1)(10)).map(_=>1).sum

res25: Int = 5

换一个范围,3~6

scala> numList.filter(correctData(2)(6)).map(_=>1).sum

res27: Int = 3

correctData的定义还有更简单的方式。

scala> def correctData(min:Int)(max:Int)(data:Int)={ data>min & data<max}

注意:使用def定义,这样调用会报错,而val 会返回一个函数。

scala> correctData(1)(10)

<console>:13: error: missing argument list for method correctData

总结

  • 柯里化可以调整函数的参数列表个数和参数的个数(无论是调整成一个,还是多个,柯里化都可以完成),以适配传参。例如函数A中的参数列表中,有一个是函数BB的参数只有1个(多个),而常规情况下,我们自己定义的函数C又需要多个参数,这种情况下,利用柯里化,可以将C的参数列表,变成一个个的单独参数,最终变成只有1个参数的函数,作为A的参数,在B的位置传入;
  • 反过来,如果我们定义了函数AA的参数列表中有一个函数B,因为有了柯里化,我们可以将B的接口统一为只有1个输入参数(外部函数调用时,总能通过柯里化,进行适配),这样也就是实现了接口形式的统一;
  • 利用柯里化,可以使得函数具有状态,例如 correctData(min:Int)(max:Int)(data:Int)3个参数列表,设置前面minmax不同的值,correctData就会有不同的处理,这就是correctData有不同的状态,此外,如果需要调用correctData很多次,而每次调用的minmax都相同,则可以val func = correctData(min)(max),这样func中就保留了minmax的信息,后续就只需要调用func(data)data输入不同的值,而不需要每次都再输入minmax���,代码简洁又不容易出错。

 

加艾叔微信,加入Linux(Shell+Zabbix)、大数据(Spark+Hadoop)、云原生(Docker+Kubernetes)技术交流群

 

 

 

 

关注艾叔公众号,获取更多一手信息

 

 

收藏 推荐 打印 | 阅读:
相关新闻