写在开头:本人打算开始写一个Kotlin系列的教程,一是使自己记忆和理解的更加深刻,二是可以分享给同样想学习Kotlin的同学。系列文章的知识点会以《Kotlin实战》这本书中顺序编写,在将书中知识点展示出来同时,我也会添加对应的Java代码用于对比学习和更好的理解。
Kotlin教程(一)基础
Kotlin教程(二)函数
Kotlin教程(三)类、对象和接口
Kotlin教程(四)可空性
Kotlin教程(五)类型
Kotlin教程(六)Lambda编程
Kotlin教程(七)运算符重载及其他约定
Kotlin教程(八)高阶函数
Kotlin教程(九)泛型
声明高阶函数
高阶函数就是以另外一个函数作为参数或者返回值的函数。在Kotlin中,函数可以用lambda或者函数引用来表示。因此,任何以lambda或者函数引用作为参数的函数,或者返回值为lambda或函数引用的函数,都是高阶函数。例如,标准库中的filter函数将一个判断式作为参数:
list.filter { x > 0 }
函数类型
为了声明一个以lambda作为实参的函数,你需要知道如何声明对应形参的类型。在这之前,我们先来看一个简单的例子,把lambda表达式保存在局部变量中。其实我们已经见过在不声明类型的情况下如何做到这一点,这依赖于Kotlin的类型推导:
val sum = { x: Int, y: Int -> x + y }
val action = { println(42) }
编译器推导出sum和action这两个变量具有函数类型。现在我们来看看这些变量的显示类型声明是什么样子的:
val sum: (Int, Int) -> Int = { x, y -> x + y }
val action: () -> Unit = { println(42) }
声明函数类型,需要将函数参数类型放在括号中,紧接着是一个箭头和函数的返回类型。
你应该还记得Unit类型用于表示函数不返回任何有用的值。在声明一个普通的函数时,Unit类型的返回值是可以省略的,但是一个函数类型声明总是需要一个显式地返回类型,所以这里Unit是不能省略的。
在lambda表达式{ x, y -> x + y }
中省略参数了类型,因为他们的类型已经在函数类型的变量声明部分指定了,不需要在lambda本身的定义中再重复声明。
就像其他方法一样,函数类型的返回值也可以标记为可空类型:
var canReturnNull: (Int, Int) -> Int? = { null }
也可以定义一个函数类型的可空变量,为了明确表示是变量本身可空,而不是函数类型的返回类型可空,你需要将整个函数类型的定义包含在括号内并在括号后面添加一个问号:
var funOrNull: ((Int, Int) -> Int)? = null
注意这两个例子的微妙区别。如果省略了括号,声明的将会是一个返回值可空的函数类型,而不是一个可空的函数类型的变量。
函数类型的参数名
可以为函数类型声明中的参数指定名字:
fun performRequest(
url: String,
callback: (code: Int, content: String) -> Unit //给函数类型的参数定义名字
) {
/*...*/
}
>>> val url = "http://kotl.in"
>>> performRequest(url) {code, content -> /*...*/} //可以使用定义的名字
>>> performRequest(url) {code, page -> /*...*/} //也可以改变参数名字
参数名称不会影响类型的匹配。当你声明一个lambda时,不必使用和函数类型声明中一模一样的参数名称,但命名会提升代码可读性并且能用于IDE的代码补全。
调用作为参数的函数
知道了怎样声明一个高阶函数,现在我们拉讨论如何去实现它。第一个例子会尽量简单并且使用之前的lambda sum 同样的声明。这个函数实现两个数字2和3的任意操作,然后打印结果。
fun twoAndThree(operation: (Int, Int) -> Int) {
val result = operation(2, 3)
println("The result is $result")
}
>>> twoAndThree { a, b -> a + b }
The result is 5
>>> twoAndThree { a, b -> a * b }
The result is 6
调用作为参数的函数和调用普通函数的语法是一样的:把括号放在函数名后,并把参数放在括号内。
来看一个更有趣的例子,我们来实现最常用的标准库函数:filter函数。为了让事情简单一点,将实现基于String类型的filter函数,但和作用与几何的泛型版本的原理是相似的:
fun String.filter(predicate: (Char) -> Boolean): String {
val sb = StringBuilder()
for (index in 0 until length) {
val element = get(index)
if (predicate(element)) sb.append(element)
}
return sb.toString()
}
filter函数以一个判断是作为参数,判断是的类型是一个函数,以字符作为参数并返回Boolean类型的值。如果让传递给判断式的字符出现在最终返回的字符串中,判断式需要返回true,反之返回false。
filter函数的实现非常简单明了。它检查每一个字符是否符合满足判断式,如果满足就将字符添加到包含结果的StringBuilder中。
在Java中使用函数类
其背后的原理是,函数类型被声明为普通的接口,一个函数类型的变量是FunctionN接口的一个实现。Kotlin标准库定义了一系列的接口,这些接口对应于不同参数数量的函数:Function0<R>
没有参数的函数、Function1<P1,R>
一个参数的函数等等。每个接口定义了一个invoke方法,调用这个方法就会执行函数。一个函数类型的变量就是实现了对应的FunctionN接口的实现类的实例,实现了类的invoke方法包含了lambda函数体。
在Java中可以很简单的调用使用了函数类型的Kotlin。Java 8的lambda会被自动转换为函数类型的值。
/*Kotlin定义*/
fun processTheAnswer(f: (Int) -> Int) {
println(f(42))
}
/*Java*/
>>> processTheAnswer(number -> number + 1)
43
在旧版的Java中,可以传递一个实现了函数接口中的invoke方法的匿名类的实例:
processTheAnswer(new Function1<Integer, Integer>() {
@Override
public Integer invoke(Integer integer) {
System.out.println(integer);
return integer + 1;
}
});
在Java中可以很容易地使用Kotlin标准库中以lambda作为参数的扩展函数。但是要注意它们看起来并没有Kotlin中name直观——必须显式地传递一个接收者对象作为第一个参数:
public static void main(String[] args) {
List<String> strings = new ArrayList<>();
strings.add("42");
CollectionsKt.forEach(strings, new Function1<String, Unit>() {
@Override
public Unit invoke(String s) {
System.out.println(s);
return Unit.INSTANCE;
}
});
}
//输出
42
在Java中,函数或者lambda可以返回Unit。但因为在Kotlin中Unit类型是有一个值的,所以需要显式地返回它。
函数类型的参数默认值和null
声明函数类型的参数的时候可以指定参数的默认值。要知道默认值的用处,我们回头看一下教程二中joinToString函数,以下是它的最终实现:
fun <T> Collection<T>.joinToString(
separator: String = ",",
prefix: String = "",
postfix: String = ""
): String {
val result = StringBuilder(prefix)
for ((index, element) in this.withIndex()) {
if (index > 0) result.append(separator)
result.append(element)
}
result.append(postfix)
return result.toString()
}
这个实现很灵活,但是它并没有让你控制转换的关键点:集合中元素是如何转换为字符串的。代码中使用了StringBuilder.append(o: Any?)
,它总是使用toString方法将对象转换为字符串。在大多数情况下这样就可以了,但并不总是这样。为了解决这个问题,可以定义一个函数类型的参数并用一个lambda作为它的默认值。
fun <T> Collection<T>.joinToString(
separator: String = ",",
prefix: String = "",
postfix: String = "",
transform: (T) -> String = { it.toString() } //默认实现
): String {
val result = StringBuilder(prefix)
for ((index, element) in this.withIndex()) {
if (index > 0) result.append(separator)
result.append(transform(element)) //使用函数参数转换
}
result.append(postfix)
return result.toString()
}
fun main(args: Array<String>) {
val letters = listOf("Alpha", "Beta")
println(letters.joinToString())
println(letters.joinToString { it.toLowerCase() })
println(letters.joinToString(separator = "! ", postfix = "! ", transform = { it.toUpperCase() }))
}
//输出
Alpha,Beta
alpha,beta
ALPHA! BETA!
这个一个泛型函数:它有一个类型参数T表示集合中的元素的类型。transform将接收这个类型的参数。
声明函数类型的默认值并不需要特殊的语法——只需要把lambda作为值放在=号后面。上面的例子展示了不同的函数调用方式:省略整个lambda(使用默认的toString做转换),在括号以外传递lambda,或者以命名参数形式传递。
除了默认实现的方式来达到选择性地传递,另一种选择是声明一个参数为可空的函数类型。注意这里不能直接调用作为参数传递进来的函数,需要先判空:
fun foo(callback: (() -> Unit)?){
if (callback != null) {
callback()
}
}
不想判空也是可以,利用函数类型是一个包含invoke方法的接口的具体实现。作为一个普通方法,invoke可以通过安全调用语法:callback?.invoke()
。
返回函数的函数
从函数中返回另一个函数并没有将函数作为参数传递那么常用,但它仍然非常有用。想象一下程序中的一段逻辑可能会因为程序的状态或者其他条件而产生变化——比如说,运输费用的计算依赖于选择的运输方式。可以定义一个函数用来选择恰当的逻辑变体并将它组委另一个函数返回。
enum class Delivery {STANDARD, EXPEDITED }
class Order(val itemCount: Int)
fun getShippingCostCalculator(delivery: Delivery): (Order) -> Double {
if (delivery == Delivery.EXPEDITED) {
return { order -> 6 + 2.1 * order.itemCount }
}
return { order -> 1.2 * order.itemCount }
}
>>> val calculator = getShippingCostCalculator(Delivery.EXPEDITED)
>>> println("Shipping costs ${calculator(Order(3))}")
Shipping costs 12.3
声明一个返回另一个函数的函数,需要指定一个函数类型作为返回类型。getShippingCostCalculator返回了一个函数,这个函数以Order作为参数并返回一个Double类型的值。要返回一个函数,需要写一个return表达式,跟上一个lambda、一个成员引用,或者其他的函数类型的表达式,比如一个函数类型的局部变量。
通过lambda去除重复代码
函数类型和lambda表达式一起组成了一个创建可重用代码的好工具。
我们来看一个分析网站访问的例子,SiteView类用于保存每次访问的路径。持续时间和用户的操作系统。不同的操作系统使用枚举类型来表示:
enum class OS {WINDOWS, LINUX, MAC, IOS, ANDROID }
data class SiteVisit(val path: String, val duration: Double, val os: OS)
val log = listOf(SiteVisit("/", 34.0, OS.WINDOWS),
SiteVisit("/", 22.0, OS.MAC),
SiteVisit("/login", 12.0, OS.WINDOWS),
SiteVisit("/signup", 8.0, OS.IOS),
SiteVisit("/", 16.3, OS.ANDROID))
想象一下如果你需要显示来自Windows机器的平均访问时间,可以用average函数来完成这个任务:
val averageWindowsDuration =
log.filter { it.os == OS.WINDOWS }
.map(SiteVisit::duration)
.average()
>>> println(averageWindowsDuration)
23.0
现在假设你要计算来自Mac用户的相同数据,为了避免重复,可以将平台类型抽象为一个参数。
fun List<SiteVisit>.averageDurationFor(os: OS) =
filter { it.os == os }.map { it.duration }.average()
>>> println(log.averageDurationFor(OS.WINDOWS))
23.0
>>> println(log.averageDurationFor(OS.MAC))
22.0
将这个函数作为扩展函数增强了可读性。如果它只在局部的上下文中有用,你甚至可以将这个函数声明为局部扩展函数。
但这还远远不够。想像一下,如果你对来自移动平台的访问的平均时间非常有兴趣。
val averageMoblieDuration =
log.filter { it.os in setOf(OS.IOS, OS.ANDROID) }
.map(SiteVisit::duration)
.average()
>>> println(averageMoblieDuration)
12.15
现在已经无法再用一个简单的参数表示不同的平台了。你可能还需要使用更加复杂的条件查询日志。比如,来自IOS平台对注册页面的访问的平均时间是多少?Lambda可以帮上忙,可以用函数类型将需要的条件抽象到一个参数中。
fun List<SiteVisit>.averageDurationFor(predicate: (SiteVisit) -> Boolean)
= filter(predicate).map(SiteVisit::duration).average()
>>> println(log.averageDurationFor {it.os in setOf(OS.ANDROID, OS.IOS)})
12.15
>>> println(log.averageDurationFor {it.os ==OS.IOS && it.path == "/signup"})
8.0
函数类型可以帮助去除重复代码。如果你禁不住复制粘贴了一段代码,那么很可能这段重复的代码是可以避免的。使用lambda,不仅可以抽取重复的数据,也可以抽取重复的行为。
一些广为人知的设计模式可以函数类型和lambda表达式进行简化。比如策略模式。没有lambda表达式的情况下,你需要声明一个接口,并为没一种可能的策略提供实现类,使用函数类型,可以用一个通用的函数类型来描述策略,然后传递不同的lambda表达式作为不同的策略。
内联函数:消除lambda带来的运行时开销
lambda表达式会被正常编译成匿名类。这表示没调用一次lambda表达式,一个额外的类就会被创建。并且如果lambda捕捉了某个变量,那么每次调用的时候都会创建一个新的对象。这会带来运行时的额外开销,导致使用lambda比使用一个直接执行相同代码的函数效率更低。
有没有可能让编译器生成跟Java语句同样高效的代码,但还是能够吧重复的逻辑抽取到库函数中呢?是的,Kotlin的编译器能做到。如果使用inline修饰符标记一个函数,在函数被使用的时候编译器并不会生成函数调用的代码,而是使用函数实现的真实代码替换每一次的函数调用。
内联函数如何运作
当一个函数被声明为inline时,它的函数体是内联的——换句话说,函数体会被直接替换到函数被调用的地方,而不是被正常调用。来看一个例子以便理解生成的最终代码。
inline fun <T> synchronized(lock: Lock, action: () -> T): T {
lock.lock()
try {
return action()
} finally {
lock.unlock()
}
}
val l = Lock()
synchronized(l) {...}
这个函数用于确保一个共享资源不会并发地被多个线程访问,函数锁住一个Lock对象,执行代码块,然后释放锁。
调用这个函数语法根Java中使用synchronized语句完全一样。区别在于Java的synchronized语句可以用于任何对象,而这个函数则要求传入一个Lock实例。这里展示的定义只是一个示例,Kotlin标准库中定义了一个可以接收任何对象作为参数的synchronized函数的版本。
因为已经将synchronized函数声明为inline,所以每次调用它所生成的代码跟Java的synchronized语句是一样的。看看下面这个使用synchronized()的例子:
fun foo(l: Lock) {
println("Before sync")
synchronized(l) {
println("Action")
}
println("After sync")
}
下面的代码将会编译成相同字节码:
fun __foo__(l: Lock) {
println("Before sync")
l.lock()
try {
println("Action")
} finally {
l.unlock()
}
println("After sync")
}
lambda表达式和synchronized函数的实现都被内联了。由lambda生成的字节码成为了函数调用者定义的一部分,而不是被包含在一个实现了函数接口的匿名类中。
注意,在调用内联函数的时候也可以传递函数类型的变量作为参数:
class LockOwner(val lock: Lock) {
fun runUnderLock(body: () -> Unit) {
synchronized(lock, body)
}
}
在这种情况下,lambda的代码在内联函数被调用点是不可用的,因此并不会被内联。只有synchronized的函数体被内联了,lambda才会被正常调用。runUnderLock函数会被编译成类似一下函数的字节码:
class LockOwner(val lock: Lock) {
fun __runUnderLock__(body: () -> Unit) {
lock.lock()
try {
body() //body没有被内联,应为在调用的地方还没有lambda
} finally {
lock.unlock()
}
}
}
如果两个不同的位置使用同一个内联函数,但是用的时不同的lambda,那么内联函数会在每一个被调用的位置被分别内联。内联函数的代码会被拷贝到使用它的两个不同地方,并把不同的lambda替换到其中。
内联函数的限制
鉴于内联的运作方式,不是所有使用lambda的函数都可以被内联。当函数被内联的时候,作为参数的lambda表达式的函数体会被直接替换到最终生成的代码中。这将限制函数体中对应lambda参数的使用。如果lambda参数被调用,这样的代码能被容易地内联。但如果lambda参数在某个地方被保存起来,以便后面可以继续使用,lambda表达式的代码将不能被内联,因为必须要有一个包含这些代码的对象存在。
一般来说,参数如果被直接调用或者作为参数传递给另外一个inline函数,它是可以被内联的。否则,编译器会禁止参数被内联并给出错误信息“Illegal usage of inline-parameter”。
例如,许多作用于序列的函数会返回一些类的实例,这些类代表对应的序列操作并接收lambda作为构造方法的参数。以下是Sequence.map函数的定义:
public fun <T, R> Sequence<T>.map(transform: (T) -> R): Sequence<R> {
return TransformingSequence(this, transform)
}
map函数没有直接调用作为transform参数传递进来的函数。而是将这个函数传递给一个类的构造方法,构造方法将它保存在一个属性中。为了支持这一点,作为transform参数传递的lambda需要被编译成标准的非内联的表示法,即一个实现了函数接口的匿名类。
如果一个函数期望两个或更多lambda参数,可以选择只内联其中一些参数。这样是有道理的,因为一个lambda可能会包含很多代码或者以不允许内联的方式使用。接收这样的非内联lambda的参数,可以用noline
修饰符来标记它:
inline fun foo(inlined: () -> Unit, noinline notInlined: () -> Unit) {}
编译器完全支持内联跨模块的函数或者第三方库定义的函数。也可以在Java中调用绝大部分内联函数,但这些调用并不会被内联,而是被编译成普通的函数调用。
内联集合操作
我们来仔细看一看Kotlin标准库操作集合函数的性能。大部分标准库中的集合函数都带有lambda参数,相比于使用标准库函数,直接实现这些操作不是更高效吗?
例如,让我们来比较以下两个代码中用来过滤一个人员列表的方式:
data class Person(val name: String, val age: Int)
val people = listOf(Person("Hubert", 26), Person("Bob", 31))
>>> println(people.filter{ it.age <= 30 })
[Person(name=Hubert, age=26)]
前面的代码不用lambda表达式也可以实现:
val result = mutableListOf<Person>()
for (person in people) {
if (person.age <= 30) result.add(person)
}
println(result)
在Kotlin中,filter函数被声明为内联函数。这意味着filter函数,以及传递给他的lambda的字节码会被一起内联戴filter被调用的地方。最终,第一种实现所产生的字节码和第二种实现所产生的字节码大致是一样的。你可以很安全地使用符合语言习惯的集合操作,Kotlin对内联函数的支持让你不必担心性能问题。
想象一下现在你联系调用filter和map两个操作:
>>> println(people.filter { it.age > 30 }.map(Person::name))
[Bob]
这个例子使用了一个lambda表达式和一个成员引用。再一次,filter和map函数都被声明为inline函数,所以它们的函数体会被内联,因此不会产生额外的类或者对象。但是上面的代码却创建了一个中间集合来保存列表过滤的结果,由filter函数生成的代码会向这个集合添加元素,而由map函数生成的代码会读取这个集合。
如果有大量集合元素需要处理,中间集合的运行开销将成为不可忽视的问题,这时可以在调用链后加上一个asSquence调用,用序列来替代集合。但正如你在前面看到的,用来处理序列的lambda没有被内联。每一个中间序列被表示成把lambda保存在其字段中的对象,而末端操作会导致由每一个中间序列调用组成的调用链被执行。因此,即便序列上的操作是惰性的,你不应该总是试图在集合操作的调用链后加上asSquence。这只在处理大量数据的集合时有用,晓得集合可以用普通的集合操作处理。
决定何时将函数声明成内联
inline虽然可以有效减少函数运行时开销(包含减少匿名类的创建),但这是基于将标记的的函数拷贝到每一个调用点来达成的,因此,如果函数体的代码过多,会增大字节码的大小。考虑到JVM本身已经提供了强大的内联支持:它会分析代码的执行,并在任何通过内联能够带来好处的时候将函数调用内联。还有一点就是Kotlin的内联函数在Java调用时并没有其内联的作用。最终,我们应该谨慎考虑添加inline,只将一些较小的,并且需要嵌入调用方的函数标记内联。
高阶函数中的控制流
当你开始使用lambda去替换像循环这样的命令式代码结构时,很快便发现遇到return表达式的问题。把一个return语句放在循环的中间是很简单的事情。但是如果将循环转换成一个类似filter的函数呢?在这种情况下return会如何工作?
lambda中的返回语句:从一个封闭的函数返回
来比较两种不同的遍历集合的方法。在下面的代码中,很明显如果一个的名字是Alice,就应该从函数lookForAlice返回:
fun main(args: Array<String>) {
val people = listOf(Person("Alice", 29), Person("Bob", 31))
lookForAlice(people)
}
data class Person(val name: String, val age: Int)
fun lookForAlice(people: List<Person>) {
for (person in people) {
if (person.name == "Alice") {
println("Found!")
return
}
}
println("Alice is not found")
}
//输出
Found!
使用forEach迭代重写这段代码安全吗?return语句还会是一样的表现吗?是的,正如下面的代码展示的,forEach是安全的。
fun lookForAlice(people: List<Person>) {
people.forEach {
if (it.name == "Alice") {
println("Found!")
return
}
}
println("Alice is not found")
}
如果你在lambda中使用return关键字,它会从调用lambda的函数中返回,并不只是从lambda返回。这样的return语句叫做非局部返回,因为它从一个比包含return的代码块更大的代码块中返回了。
为了理解这条规则背后的逻辑,想想Java函数中在for循环或者synchronized代码块中使用return关键字。显然会从函数中返回,而不是从循环或者代码块中返回,当使用以lambda作为参数的函数的时候Kotlin保留了同样的行为。
需要注意的是,只有在以lambda作为参数的函数是内联函数的时候才能从更外层的函数返回。上面的例子中forEach的函数体和lambda的函数体一起被内联了,所以在编译的时候很容易做到从包含它的函数中返回。在一个非内敛函数的lambda中使用return表达式是不允许的。
从lambda返回:使用标签返回
也可以在lambda表达式中使用局部返回。lambda中的局部返回跟for循环中的break表达式相似。它会终止lambda的执行,并接着从lambda的代码处执行。要区分布局返回和非局部返回,要用到标签。想从一个lambda表达式处返回你可以标记它,然后在return关键字后面引用这个标签。
fun lookForAlice(people: List<Person>) {
people.forEach label@{ //声明标签
if (it.name == "Alice") {
return@label //返回标签
}
}
println("Alice might be somewhere")
}
>>> lookForAlice(people)
Alice might be somewhere
要标记一个lambda表达式,在lambda的花括号之前放一个标签名(可以是任何标识符),接着放一个@符号。要从lambda返回,在return关键字后放一个@符号,接着放标签名。
或者默认使用lambda作为参数的函数的函数名作为标签:
fun lookForAlice(people: List<Person>) {
people.forEach {
if (it.name == "Alice") {
return@forEach
}
}
println("Alice might be somewhere")
}
如果你显式地指定了lambda表达式的标签,在使用函数名作为标签没有任何效果。一个lambda表达式的标签数量不能多以一个。
带标签的this表达式
同样的规则也使用于this表达式的标签。带接收者的lambda包含一个隐式上下文对象的lambda可以通过this引用去访问。如果你给带接收者的lambda指定标签,就可以通过对应的带标签的this表达式访问它的隐式接收者。
println(StringBuilder().apply sb@ {
listOf(1, 2, 3).apply {
this@sb.append(this.toString())
}
})
和return表达式中使用标签一样,可以显示地指定lambda表达式的标签,也可以直接使用函数名作为标签。
匿名函数:默认使用局部返回
匿名函数是一种不同的用于编写传递给函数的代码块的方式。先来看一个示例:
fun lookForAlice(people: List<Person>) {
people.forEach(
fun(person) { //使用匿名函数取代lambda
if (person.name == "Alice") {
return
}
println("${person.name} is not Alice")
}
)
}
>>> lookForAlice(people)
Bob is not Alice
匿名函数看起来跟普通函数很相似,除了它的名字和参数类型被省略了外。这里有另外一个例子:
people.filter(fun(person): Boolean {
return person.age < 30
})
匿名函数和普通函数有相同的指定返回值类型的规则。代码块体匿名函数需要显式地指定返回类型,如果使用表达式函数体,就可以省略返回类型。
people.filter(fun(person): Boolean = person.age < 30)
在匿名函数中,不带标签的return表达式会从匿名函数返回,而不是从包含匿名函数的函数返回。这条规则很简单:return从最近使用fun关键字声明的函数返回。lambda表达式没有使用fun关键字,所以lambda中的return从最外层的函数返回。匿名函数使用了fun,因此,在前一个例子中匿名函数是最近的符合规则的函数。所以return表达式从匿名函数返回,而不是从最外层的函数返回。
注意,尽管匿名函数看起来跟普通函数很相似,但它其实是lambda表达式的另一种语法形式而已。关于lambda表达式如何实现,以及内联函数中如何被内联的同样适用于匿名函数。
网友评论