高阶函数定义:高阶函数就是以另一个函数作为参数或者返回值的函数。 在kotlin中,函数可以用lambda或者函数引用来表示。因此,任何以lambda或者函数引用作为参数的函数,或者返回值为lamda或函数应用的函数,或者两者都满足的函数都是高阶函数。
为了声明一个以lamda作为实参的函数,你需要知道如何声明对应形参的类型。在这之前,我们先来看一个简单的例子,把lambda表达式保存在局部变量中。其实我们已经见过在不声明类型的情况下如何做到这一点,这依赖于kotlin的类型推导。
在这个例子中,编译器推导出sum和action这两个变量具有函数类型。现在我们来看看这些变量的显式类型声明是什么样的:
声明函数类型,需要将函数参数类型放在括号中,紧接着是一个箭头和函数的返回类型。
你应该还记得,Unit类型用于表示函数不返回任何有用的值。在声明一个普通的函数时,Unit类型的值是可以省略的,但是一个函数类型声明总是需要一个显式的返回类型,所以在这种场景下Unit是不能省略的。
注意,在lambda表达式{x, y -> x + y}中是如何省略参数x, y的类型的。因为它们的类型已经在函数类型的变量声明部分指定了,不需要在lamda本身的定义当中再重复声明。
也可以定义一个函数类型的可空变量。为了明确表示是变量本身可空,而不是函数类型的返回类型可空,你需要将整个函数类型的定义包含在括号内并在括号后面添加一个问号:
注意这个例子和前一个例子的微妙区别。如果省略了括号,声明的将会是一个返回值可空的函数类型,而不是一个可空的函数的变量。
调用作为参数的函数和调用普通函数的语法是一样的:把括号放在函数名后,并把参数放在口号内。
来看一个更有趣的例子,我们来实现最常用的标准库函数:filter函数。为了让事情简单一点,将实现基于String类型的filter函数,但和作用于集合的泛型版本的原理是相似的。函数声明如下:
filter函数以一个判断式作为参数。判断式的类型是一个函数,以字符作为参数并返回boolean类型的值。如果要让传递给判断式的字符出现在最终返回的字符串中,判断式需要返回true,反之返回false。下面是具体实现:
filter函数的实现非常明了。它检查每个字符是否满足判断式,如果满足就将字符添加到包含结果的StringBuilder中。
其背后的原理是,函数类型被声明为普通的接口:一个函数类型的变量是FunctionN接口的一个实现。kotlin标准库定义了一系列的接口,这些接口对应于不同参数数量的函数:Function0
在java中可以很简单地调用函数类型的kotlin函数。java8的lambda会被自动转换为函数类型的值。
在java中可以很容易地使用kotlin中以lambda作为参数的扩展函数。但是要注意他们看起来并没有kotlin中那么直观——必须要显示地传递一个接收者对象作为第一个参数:
在java中,函数或者lambda可以返回Unit。但因为在kotlin中Unit类型是有一个值的,所以需要显式地返回它。一个返回返回void的lambda不能作为返回Unit的函数类型的实参,就像之前的例子中(String)-> Unit。
声明函数类型的参数的时候可以指定参数的默认值。要知道默认值的用处,我们回头看下第三章讨论的joinToString函数。
注意这是一个泛型函数:它有一个类型参数T表示集合中的元素的类型。Lambda transform函数将接受这个类型的参数。
声明函数类型的默认值并不需要特殊的语法——只需要把lambda作为值放在=号后面。 上面的例子展示了不同的函数调用方式:省略整个lambda(使用默认的toString()做转换),在括号以外传递lambda,或者以命名参数形式传递。
另一种选择是声明一个参数为可空的函数类型。注意这里不能直接调用作为参数传递进来的函数:kotlin会因为检测到潜在的空指针异常而导致编译失败。一种可选的方法时显式地检查null。
还有一个更简单的版本,它利用了这样一个事实,函数类型是一个包含invoke方法的接口的具体实现。作为一个普通方法,invoke可以通过安全调用语法被调用:callback?.invoke()。
现在你已经知道了如何编写接收另外的函数作为参数的函数。
从函数中返回另一个函数并没有将函数作为参数传递那么常用,但它仍然非常有用。想象一下程序中一段逻辑可能会因为程序的状态或者其他条件而产生变化——比如说,运输费用的计算依赖于选择的运输方式。可以定义一个函数用来选择恰当的逻辑变体并将它作为另一个函数返回。
声明一个返回另一个函数的函数,需要指定一个函数类型作为返回类型。
函数类型和lambda表达式一起组成了一个创建可重用代码的好工具。许多以前只能通过复杂笨重的结构来避免的重复代码,现在可以通过使用简洁的lambda表达式被消除。
我们来看一个分析网站访问的例子。SiteVisit类用来保存每次访问的路径、持续时间和用户的操作系统。不同的操作系统使用枚举类型来表示。
想象一下如果你需要显式来自Windows机器的平均访问时间,可以用average函数来完成这个任务。
现在假设你要计算来自Mac用户的相同数据,为了避免重复,可以将平台类型抽象为一个参数。
注意将这个函数作为扩展函数增强了可读性。如果它只在局部的上下文中有用,你甚至可以将这个函数声明为局部扩展函数。
但这远远不够。想象一下,如果你对来自移动平台(目前你识别出来的只有两种:ios和android)的访问的平均时间非常感兴趣。
现在已经无法再用一个简单的参数表示不同的平台了。您肯呢个还需要使用更加复杂的条件查询日志,比如“来自ios平台对注册页面的访问的平均时间是多少?”lambda可以帮上忙。可以用函数将需要的条件抽取到一个参数中。
函数类型可以帮助去除重复代码。如果你禁不住复制粘贴了一段代码,那么很可能这段重复代码时可以避免的。使用lambda,不仅可以抽取重复的数据,也可以抽取重复的行为。
在第五章我们解释了lambda表达式会被正常地编译成匿名类。这表示每调用一次lambda表达式,一个额外的类就会被创建。并且如果lambda捕捉了某个变量,那么每次调用的时候都会创建一个新的对象。这会带来运行时的额外开销,导致使用lambda比使用一个直接执行相同代码的函数效率更低。
有没有可能让编译器生成跟java语句同样高效的代码,但还是能够把重复的逻辑抽取到库函数中呢?是的,kotlin的编译器能够做到。如果使用inline修饰符标记一个函数,在函数被使用的时候编译器并不会生成函数调用的代码,而是使用函数实现的真实代码替换每一次的函数调用。
当一个函数被声明为inline时,它的函数体是内联的——换句话说,函数体会被直接替换到函数被调用的地方,而不是被正常调用。
下面代码中的函数用于确保一个共享资源不会并发地被多个线程访问。函数锁住一个Lock对象,执行代码块,然后释放锁。
调用这个函数的语法跟java中使用synchronized语句完全一样。区别在于java的synchronized语句可以用于任何对象,而这个函数则要求传入一个Lock实例。这里展示的只是一个示例,kotlin标准库中定义了一个可以接收任何对象作为参数的synchronized函数的版本。
但在同步操作时使用显式的对象锁能提高代码的可读性和可维护性。在8.2.5节中,我们会介绍kotlin标准库中的withLock函数:在需要加锁执行给定的操作时,它将成为你的最佳选择。
因为已经将synchronized函数声明为inline,所以每次调用它所生成的代码跟java的synchronized语句是一样的。看看下面这个例子:
注意lambda表达式和synchronized函数的实现都被内联了。由lambda生成的字节码成为了函数调用者定义的一部分,而不是被包含在一个实现了函数接口的匿名类中。
注意,在调用内联函数的时候也可以传递函数类型的变量作为参数。
在这种情况下,lambda的代码在内联函数被调用点是不可用的,因此并不会被内联。只有synchronized的函数体被内联了,lambda才会被正常调用。runUnderLock函数会被编译成类似以下函数的字节码:
如果在两个不同的位置使用同一个内联函数,但是用的是不同的lambda,那么内联函数会在每一个被调用的位置被分别内联。内联函数的代码会被拷贝到使用它的两个不同位置,并把不同的lambda替换到其中。
鉴于内联的运作方式,不是所有使用lambda的函数都可以被内联。当函数被内联的时候,作为参数的lambda表达式的函数体会被直接替换到最终生成的代码中。这将限制函数体中的对应(lambda)参数的使用。如果(lambda)参数被调用,这样的代码能被容易地内联。但如果(lambda)参数在某个地方被保存起来,以便后面可以继续使用,lambda表达式的代码将不能被内联,因为必须要有一个包含这些代码的对象存在。
一般来说,参数如果被直接调用或者作为参数传递给另一个inline函数。它是可以被内联的。否则,编译器会禁止参数被内联并给出错误信息“Illegal usage of inline-parameter”(非法使用内联参数)。
例如,许多作用于序列的函数会返回一些类的实例,这些类代表对应的序列操作并接收lambda作为构造方法的参数。以下是Sequence.map函数的定义:
map函数没有直接调用作为transform参数传递进来的函数。而是将这个函数传递给一个类的构造方法,构造方法将它保存在一个属性中。为了支持这一点,作为transform参数传递的lambda需要被编译成标准的非内联的表示法,即一个实现了函数接口的匿名类。
如果一个函数期望两个或更多lambda参数,可以选择只内联其中一些参数。这是有道理的,因为一个lambda可能会包含很多代码或者以不允许内联的方式使用。接收这样的非内联lambda的参数,可以用oninline修饰符来标记它:
注意,编译器完全支持内联跨模块的函数或者第三方库定义的函数。也可以在java中调用绝大部分内联函数,但这些调用并不会被内联,而是被编译成普通的函数调用。
来看看kotlin标准库中操作集合的函数的性能。大部分标准库中的集合函数都带有lambda参数。相比于使用标准库函数,直接实现这些操作不是更高效吗?
例如,让我们来比较以下两个代码清单中用来过滤一个人员列表的方式:
在kotlin中,filter函数被声明为内联函数。这意味着filter函数,以及传递给它的lambda的字节码会被一起内联到filter被调用的地方。最终,第一种实现所产生的字节码和第二种实现所产生的字节码大致是一样的。你可以很安全地使用符合语言习惯的集合操作,kotlin 对内联函数的支持让你不必担心性能的问题。
这个例子使用了一个lambda表达式和一个成员引用。再一次,filter和map函数都被声明为inline函数,所以他们的函数体会被内联,因此不会产生额外的类或者对象。但是上面的代码却创建了一个中间集合来保存列表过滤的结果,由filter函数产生的代码会向这个集合中添加元素,而由map函数生成的代码会读取这个集合。
如果有大量元素需要处理,中间集合的运行开销将成为不可忽视的问题,这时可以在调用链后加上一个asSequence调用,用序列来代替集合。但正如你在前一节中看到的,用来处理序列的lambda没有被内联。每一个中间序列被表示成把lambda保存在其字段中的对象,而末端操作会导致由每一个中间序列调用组成的调用链被执行。因此,即便序列上的操作时惰性,你不应总是试图在集合操作的调用链后加上asSequence。这只在处理大量数据的集合时有用,小的集合可以用普通的集合操作处理。
现在你已经知道了inline关键字带来的好处,可能已经想要开始在代码中使用inline,试图让你的代码运行得更快。事实证明,这并不是一个好主意。使用inline关键字只能提高带有lambda参数的函数的性能,其他的情况需要额外的度量和研究。
对于普通的函数调用,JVM已经提供了强大的内联支持。它会分析代码的执行,并在任何通过内联能够带来好处的时候将函数调用内联。这是在将字节码转换成机器代码时自动完成的。在字节码中,每一个函数的实现只会出现一次,并不需要跟kotlin的内联函数一样,每个调用的地方都拷贝一次。再说,如果函数被直接调用,调用栈会更加清晰。
另一方面,将带有lambda参数的函数内联你能带来好处。首先,通过内联避免的运行时开销更明显了。不仅节约了函数调用的开销,而且节约了为lambda创建匿名类,以及创建lambda实例对象的开销。其次,JVM目前并没有聪明到总是能够将函数调用内联。最后,内联使得我们可以使用一些不可能被普通lambda使用的特性,比如非局部返回,我们将在本章的后面讨论它。
但是在使用inline关键字的时候,你还是应该注意代码的长度。如果你要内联的函数很大,将它的字节码拷贝到每一个调用点将会极大地增加字节码的长度。在这种情况下,你应该将那些与lambda参数无关的代码抽取到一个独立的非内联函数中。你可以去验证一下,在kotlin标准库中的内联函数总是很小的。
lambda可以去除重复代码的一个常见模式是资源管理:先获取一个资源,完成一个操作,然后释放资源。这里的资源可以表示很多不同的东西:一个文件、一个锁、一个数据库事务等。实现这个模式的标准做法是使用try/finally语句。资源在try代码块之前被获取,在finally代码中被释放。
在本节的前面部分你看到了一个例子,将try/finally的逻辑封装在一个函数中,然后将使用资源的代码作为lambda传递给这个方法。那个例子展示了synchronized函数,它跟java的synchrozied语句语法一样:将一个锁对象作为参数。kotlin标准库定义了另一个叫做withLock的函数,它提供了实现同样功能的更符合语言习惯的API: 它是Lock接口的扩展函数,下面来看如何使用它:
文件是另一种可以使用这种模式的常见资源类型。java7甚至为这种模式引入了特殊的语法:try-with-resource语句。下面的代码清单展示了一个使用这个语句来读取文件第一行的java方法:
kotlin中并没有等价的语法,因为通过使用一个带有函数类型的参数的函数(接收lambda参数)可以无缝地完成相同的事情。这个kotlin标准库中的函数叫做use。现在使用use函数将上面代码重写:
use函数是一个扩展函数,被用来操作可关闭的资源,它接收一个lambda作为参数。这个方法调用lambda并且确保资源被关闭,无论lambda正常执行还是抛出了异常。当然,use函数是内联函数,使用它并不会引发任何的性能开销。
注意,在lambda的函数体中,使用了非局部return从readFirstLineFromFile函数中返回一个值。我们来详细讨论lambda中return表达式的细节。
当你开始使用lambda去替换像循环这样的命令式代码结构时,很快便会遇到return表达式的问题。把一个return语句放在循环的中间是很简单的事情。但如果将循环换成一个类似filter的函数呢?在这种情况下return会如何工作?
来比较两种不同的遍历集合的方法。在下面的代码清单中,很明显如果一个人的名字是Alice,就应该从函数lookForAlice返回。
使用forEach迭代重写这段代码安全吗?return语句还会是一样的表现吗?是的,正如下面的代码所展示的,使用forEach是安全的。
如果你在lambda中使用return关键字,它会从调用lambda的函数中返回,并不只是从lambda中返回。这样的return语句叫做非局部返回,因为它从一个比包含return的代码块更大的代码块中返回了。
为了理解这条规则背后的逻辑,想想java函数中在for循环或者synchronized代码块中使用return关键字。显然会从函数中返回,而不是从循环中或代码块中返回。当使用以lambda作为参数的函数的时候kotlin保留了同样的行为。
需要注意的是,只有在以lambda作为参数的函数是内联函数的时候才能从更外层的函数返回。在上面代码中,forEach的函数体和lambda的函数体一起被内联了,所以编译的时候能够很容易做到从包含它的函数体中返回。在一个非内联函数的lambda中使用return表达式是不允许的。一个非内联函数可以把传给它的lambda保存在变量中,以便在函数返回以后可以继续使用,这个时候lambda想要去影响函数的返回已经太晚了。
也可以在lambda表达式中使用局部返回。lambda中的局部返回跟for循环中的break表达式相似。它会终止lambda的执行,并接着从调用lambda的代码处执行。要区分局部返回和非局部返回,要用到标签。想从一个lambda表达式处返回你可以标记它,然后再return关键字后面引用这个标签。
要标记一个lambda表达式,在lambda的花括号之前放一个标签名(可以是任何标识符),紧接着放一个@符号。要从一个lambda返回,在return关键字后放一个@符号,接着放标签名。
另一种选择是,使用lambda作为参数的函数的函数名可以作为标签。
如果你显式地指定了lambda表达式的标签,再使用函数名作为标签没有任何效果。一个lambda表达式的标签数量不能多于1个。 局部返回的语法相当冗长,如果一个lambda包含逗哥返回语句会变得更加笨重,解决方案是,可以使用另一种可选的语法来传递代码块:匿名函数。
匿名函数时一种不同的用于编写传递给函数的代码块的方式。先看一个例子。 匿名函数和普通函数有相同的指定返回值类型的规则。正如上面代码一样,代码块体匿名函数需要显式地指定返回类型,如果使用表达式函数体,就可以省略返回类型。 在匿名函数中,不带标签的return表达式会从匿名函数返回,而不是从包含匿名函数的函数返回。这条规则很简单:return从最近的使用fun关键字声明的函数返回。lambda表达式没有使用fun关键字,所以lambda中的return从最外层的函数返回。匿名函数使用了fun,因此,在前一个例子中匿名函数时最近的符合规则的函数。所以,return表达式从匿名函数返回,而不是从最外层的函数返回。
注意,尽管匿名函数看起来跟普通函数很相似,但它其实是lambda表达式的另一种语法形式而已。关于lambda表达式如何实现,以及在内联函数中如何被内联的讨论同样适用于匿名函数。