- Spark大数据处理与分析
- 雷擎编著
- 5377字
- 2021-03-27 00:15:42
3.4 Scala高级语法
3.4.1 高阶函数
高阶函数是指使用其他函数作为参数,或者返回一个函数作为结果的函数。因为在Scala中函数使用得最多,该术语可能引起混淆,对于将函数作为参数或返回函数的方法和函数,我们将其定义为高阶函数。从计算机科学的角度看,函数可以具有多种形式,例如一阶函数、高阶函数或纯函数。从数学的角度看也是如此,使用高阶函数时可以执行以下操作之一:
(1)将一个或多个函数作为参数执行某些操作。
(2)将一个函数返回作为结果。
除高阶函数外的所有其他函数均为一阶函数。但是,从数学的角度看,高阶函数也称为运算符或函数。另一方面,如果函数的返回值仅由其输入确定,则称为纯函数。本节将简要讨论为什么以及如何在Scala中使用不同的函数范式。本节将讨论纯函数和高阶函数,还将提供使用匿名函数的简要概述,因为在使用Scala开发Spark应用程序时经常使用匿名函数。
纯函数是这样一种函数,输入输出数据流全是显式的。显式的意思是,函数与外界交换数据只有一个渠道:参数到返回值,函数从函数外部接收的所有输入信息都通过参数传递到该函数内部;函数输出到函数外部的所有信息都通过返回值传递到该函数外部。如果一个函数通过隐式方式从外界获取数据,或者向外部输出数据,那么该函数就是非纯函数。隐式的意思是,函数通过参数和返回值以外的渠道和外界进行数据交换,如读取全局变量和修改全局变量都叫作以隐式的方式和外界进行数据交换;例如利用输入输出系统函数库读取配置文件,或者输出到文件,打印到屏幕,都叫作以隐式的方式与外界进行数据交换。下面是纯函数与非纯函数的例子。
· 纯函数
· 非纯函数
那么,纯函数有什么优点?纯函数通常比其他函数的代码少,当然这也取决于其他因素,如编程语言。并且由于纯函数看起来像数学函数,因此更容易被解释和理解。纯函数是函数式编程的核心功能,也是一种最佳实践,通常需要使用纯函数构建应用程序的核心部分。在编程领域中,函数是一段通过名称调用的代码,可以传递数据作为参数,以对其进行操作,并可以返回数据传递给函数的参数都会显式传递。另一方面,方法也是一段通过名称调用的代码。但是,一个方法始终与一个对象相关联,作为对象的一个属性。在大多数情况下,方法与函数是相同的,除了以下两个主要区别:
(1)方法被隐式地传递给被调用的对象。
(2)方法可以对类中包含的数据进行操作。
有时,在代码中我们不想在使用函数之前先定义一个函数,也许是因为只需要在一个地方被使用,而不需要通过函数名在其他地方调用。在函数式编程中,有一类函数非常适合这种情况,称为匿名函数。Scala中的匿名函数没有方法名,也不用def定义函数。一般地,匿名函数都是一个表达式,因此非常适合替换那些只用一次且任务简单的常规函数,所以使得代码变得更简洁了。匿名函数的语法很简单,箭头“=>”左边是参数列表,右边是函数体。定义匿名函数的语法为
下面的表达式就定义了一个接收Int类型输入参数的匿名函数。
上述定义的匿名函数,其实是下面这个常规函数的简写:
以上范例中的inc被定义一个值,使用方式如下:
同样,可以在匿名函数中定义多个参数:
也可以不给匿名函数设置参数,如下所示:
下画线“_”可用作匿名函数参数的占位符,但对于每个参数,只能用下画线占位一次。在Scala中,_∗_表示匿名函数接受两个参数,函数返回值是两个参数的乘积。例如,下列Scala代码中的print(_)相当于x=>print(x):
最常见的一个例子是Scala集合类的高阶函数map():
函数doubleSalary()有一个整型参数x,返回x∗2。一般来说,在“=>”左边的元组是函数的参数列表,而右边表达式的值则为函数的返回值。在map()中调用函数doubleSalary()将其应用到列表salaries中的每一个元素上。为了简化压缩代码,可以使用匿名函数,直接作为参数传递给map()。注意,在上述示例中x没有被显式声明为Int类型,这是因为编译器能够根据map函数期望的类型推断出x的类型。对于上述代码,一种更惯用的写法为
既然Scala编译器已经知道了参数的类型,可以只给出函数的右半部分,不过需要使用“_”代替参数名。同样,可以传入一个对象方法作为高阶函数的参数,这是因为Scala编译器会将方法强制转换为一个函数。
在这个例子中,方法convertCtoF()被传入forecastInFahrenheit()。这是可以的,因为编译器强制将方法convertCtoF()转成了函数x=>convertCtoF(x),x是编译器生成的变量名,保证在其作用域是唯一的。有一些情况,我们希望生成一个函数,例如:
urlBuilder的返回类型是(String,String)=>String,这意味着返回的是匿名函数,其有两个String参数,返回一个String。
在Scala相关的教程与参考文档里,经常会看到柯里化函数这个词。但是,对于具体什么是柯里化函数,柯里化函数又有什么作用,其实可能很多用户都会有疑惑,首先看两个简单的函数。
以上两个函数实现的都是两个整数相加的功能。对于add函数,调用的方式为add(1,2)。对于addCurry函数,调用的方式为addCurry(1)(2),这种方式叫作柯里化。addCurry(1)(2)实际上是依次调用两个普通函数,第一次使用一个参数x调用,返回一个函数类型的值,第二次使用参数y调用这个函数类型的值,那么这个函数是什么意思?接收一个x为参数,返回一个匿名函数,该匿名函数的定义是:接收一个Int型的参数y,函数体为x+y。下面对这个函数进行调用。
例子中返回一个result,那么result的值应该是一个匿名函数:(y:Int)=>1+y,所以,为了得到结果,可以继续调用result,最后打印的结果是3。
柯里化函数最大的意义在于,把多个参数的函数等价转化成多个单参数函数的级联,这样,所有的函数就都统一方便做lambda演算了。在Scala中,函数的柯里化对类型推演也有帮助,Scala的类型推演是局部的,在同一个参数列表中,后面的参数不能借助前面的参数类型进行推演。通过柯里化函数后,后面的参数可以借助前面的参数类型进行推演。两个参数的函数可以拆分。同理,三个参数的函数也可以柯里化。
简单看一个柯里化函数foldLeft()的定义:
def foldLeft[B](z:B)(op:(B,A)⇒B):B
这个函数在集合中很有用,其中B表示泛型,第一个(z:B)传递一个B类型的参数z,第二个(op:(B,A)⇒B)表示op参数表示为一个匿名函数,foldLeft()函数返回一个B类型的参数。foldLeft()函数将包含两个参数的函数op应用于初始值z和该集合的所有元素上,从左到右。下面显示的是其用法示例。从初始值0开始,此处foldLeft将函数(m,n)=>m+n作为参数op,应用于列表array中的每个元素和先前的累加值0。
注意,如果使用多个参数列表的柯里化函数,则能够利用Scala类型推断使代码更简洁。下画线在Scala中很有用,如在初始化某一个变量的时候下画线代表的是这个变量的默认值。在函数中,下画线代表的是占位符,用来表示一个函数的参数,其名字和类型都会被隐式指定。当然,如果Scala无法判断下画线代表的类型,就可能报错。另外,Scala还定义了foldLeft()另外一种替换方式:
def/:[B](z:B)(op:(B,A)⇒B):B
所以,上面的代码也可以写为
3.4.2 泛型类
泛型类指可以接受类型参数的类。泛型类在集合类中被广泛使用。泛型类使用方括号[]接受类型参数。一个惯例是使用字母A作为参数标识符,当然,可以使用任何参数名称。
上面的Stack类的定义中接受类型参数A,这意味着其内部的列表elements只能存储类型A的元素,方法push()只接受类型A的实例对象作为参数,将x添加到elements前面,然后重新分配给一个新的列表。要使用一个泛型类,需要将一个具体类型放到方括号中代替A。
上面的实例对象stack只能接受整型值,然而,如果类型参数有子类型,则子类型可以被传入:
类Apple和类Banana都继承自类Fruit,所以可以把实例对象apple和banana压入stack中。泛型类型的子类不可变,这表示如果有一个Char类型的栈Stack[Char],那么它不能被用作一个Int的栈Stack[Int],否则就是不安全的。只有类型B=A时,Stack[A]是Stack[B]的子类才成立。Scala提供了一种类型参数注释机制,用以控制泛型类型的子类行为。
型变是复杂类型的子类型关系与其组件类型的子类型关系的相关性。Scala支持泛型类的类型参数的型变注释,允许它们是协变的、逆变的,或在没有使用注释的情况下是不变的。在类型系统中使用型变允许在复杂类型之间建立直观的连接,而缺乏型变则会限制类抽象的重用性。
1.协变
使用注释+A,可以使一个泛型类的类型参数A成为协变。对于某些类class List[+A],使A成为协变意味着对于两种类型A和B,如果A是B的子类型,那么List[A]就是List[B]的子类型。这允许我们使用泛型创建非常有用和直观的子类型关系。
考虑以下简单的类结构:
类型Cat和Dog都是Animal的子类型。Scala标准库有一个通用的不可变的类sealed abstract class List[+A],其中类型参数A是协变的。这意味着,List[Cat]是List[Animal],List[Dog]也是List[Animal]。直观地说,猫的列表和狗的列表都是动物的列表是合理的,应该能够用它们中的任何一个替换List[Animal]。
在下例中,方法printAnimalNames()将接受动物列表作为参数,并且逐行打印出它们的名称。如果List[A]不是协变的,最后两个方法调用将不能编译,这将严重限制printAnimalNames()方法的适用性。
2.逆变
通过使用注释-A,可以使一个泛型类的类型参数A成为逆变。与协变类似,这会在类及其类型参数之间创建一个子类型关系,但其作用与协变完全相反。也就是说,对于某个类class Writer[-A],使A逆变意味着对于两种类型A和B,如果A是B的子类型,那么Writer[B]是Writer[A]的子类型。
考虑在下例中使用上面定义的类Cat、Dog和Animal:
这里,Printer[A]是一个简单的类,用来打印某种类型的A。下面定义一些特定的子类。
如果Printer[Cat]知道如何在控制台打印出任意Cat,并且Printer[Animal]知道如何在控制台打印出任意Animal,那么Printer[Animal]也应该知道如何打印出Cat是合理的。反向关系不适用,因为Printer[Cat]并不知道如何在控制台打印出任意Animal。因此,如果用户愿意,能够用Printer[Animal]替换Printer[Cat],而使Printer[A]逆变允许用户做到这一点。
这个程序的输出如下:
3.不变
默认情况下,Scala中的泛型类是不变的。这意味着,它们既不是协变的,也不是逆变的。在下例中,类Container是不变的。Container[Cat]不是Container[Animal],反之亦然。
可能看起来一个Container[Cat]自然也应该是一个Container[Animal],但允许一个可变的泛型类成为协变并不安全。在这个例子中,Container是不变的非常重要。假设Container实际上是协变的,可能发生下面的情况:
幸运的是,编译器在此之前就会阻止我们。
另一个可以帮助理解型变的例子是Scala标准库中的trait Function1[-T,+R]。Function1表示具有一个参数的函数,其中第一个类型参数T表示参数类型,第二个类型参数R表示返回类型。Function1在其参数类型上是逆变的,并且在其返回类型上是协变的。对于这个例子,我们将使用文字符号A=>B表示Function1[A,B]。
假设前面使用过的类似Cat、Dog、Animal的继承关系,加上以下内容:
假设正在处理接受动物类型的函数,并返回它们的食物类型。如果想要一个Cat=>SmallAnimal(因为猫吃小动物),但是给它一个Animal=>Mouse,程序仍然可以工作。直观地看,一个Animal=>Mouse的函数仍然会接受一个Cat作为参数,因为Cat既是一个Animal,并且这个函数返回一个Mouse,也是一个SmallAnimal。既然可以安全地、隐式地用后者代替前者,那么就可以说Animal=>Mouse是Cat=>SmallAnimal的子类型。
某些与Scala类似的语言以不同的方式支持型变。例如,Scala中的型变注释与C#中的非常相似,在定义类抽象时添加型变注释(声明点型变)。但是,在Java中,当类抽象被使用时(使用点型变),才会给出型变注释。
3.4.3 隐式转换
Scala的隐式转换定义了一套查找机制,当编译器发现代码出现类型转换时,编译器试图寻找一种隐式的转换方法,从而使得编译器能够自我修复完成编译。在Scala语言中,隐式转换是一项强大的程序语言功能,它不仅能够简化程序设计,也能够使程序具有很强的灵活性,可以在不修改原有类的基础上,对类的功能进行扩展。例如,在Spark源码中,经常会发现RDD类没有reduceByKey()、groupByKey()等方法定义,但是却可以在RDD上调用这些方法,这就是Scala隐式转换导致的。如果需要在RDD上调用这些函数,RDD必须是RDD[(K,V)]类型,即键值对类型。可以参考Spark源码文件,在RDD对象上定义一个rddToPairRDDFunctions隐式转换。
rddToPairRDDFunctions为隐式转换函数,即将RDD[(K,V)]类型转换为PairRDDFunctions对象,从而可以在原始的RDD对象上调用reduceByKey()之类的方法。rddToPairRDDFunctions隐式函数位于1.3之前的SparkContext中,必须使用import SparkContext._启用它们,现在将它们移出,以使编译器自动找到它们。但是,我们仍将旧功能保留在SparkContext中,以实现向后兼容,并直接转发至以下功能。隐式转换是Scala的一大特性,如果对其不是很了解,在阅读Spark代码时就会感到很困难。上面对Spark中的隐式类型转换做了分析,现在从Scala语法的角度对隐式转换进行总结。从一个简单例子出发,定义一个函数接受一个字符串参数,并进行输出。
这个函数在func("11")调用时正常,但是在执行func(11)或func(1.1)时会报error:type mismatch的错误,对于这个问题,有多种解决方式,其中包括:
(1)针对特定的参数类型,重载多个func函数,但是需要定义多个函数。
(2)msg参数使用超类型,如使用AnyVal或Any(Any是所有类型的超类,具有两个直接子类:AnyVal和AnyRef),但是需要在函数中针对特定的逻辑做类型转化,从而进一步处理。
这两个方式使用了面向对象编程的思路,虽然都可以解决该问题,但是不够简洁。在Scala中,针对类型转换提供了特有的隐式转化功能。我们通过一个函数实现隐式转化,这个函数可以根据一个变量在需要的时候调用进行类型转换。针对上面的例子,可以定义intToString函数:
此时,在调用func(11)的时候,Scala编译器会自动对参数11进行intToString函数的调用,从而通过Scala的隐式转换实现func函数对字符串参数类型的支持。上例中,隐式转换依据的条件是输入参数类型(Int)和目标参数类型(String)的匹配,至于函数名称并不重要。如果取为intToString,可以直观地表示,如果使用int2str也一样。隐式转换只关心类型,所以,如果同时定义两个类型相同的隐式转换函数,但是函数名称不同时,这时函数调用过程中如果需要进行类型转换,就会报二义性的错误,即不知道使用哪个隐式转换函数进行转换。