阅读 2562

Kotlin教程(六)Lambda编程

写在开头:本人打算开始写一个Kotlin系列的教程,一是使自己记忆和理解的更加深刻,二是可以分享给同样想学习Kotlin的同学。系列文章的知识点会以《Kotlin实战》这本书中顺序编写,在将书中知识点展示出来同时,我也会添加对应的Java代码用于对比学习和更好的理解。

Kotlin教程(一)基础
Kotlin教程(二)函数
Kotlin教程(三)类、对象和接口
Kotlin教程(四)可空性
Kotlin教程(五)类型
Kotlin教程(六)Lambda编程
Kotlin教程(七)运算符重载及其他约定
Kotlin教程(八)高阶函数
Kotlin教程(九)泛型


Lambda表达式,或简称lambda,本质上级就是可以传递给其他函数的一小段代码。有了lambda,可以轻松地把通用的代码结构抽取成库函数,Kotlin标准库就大量地使用了它们。

Lambda表达式和成员引用

把lambda引入Java 8是Java这门语言演变过程中让人望眼欲穿的变化之一。为什么它是如此重要?这一节中,你会发现为何lambda这么好用,以及Kotlin的lambda语法看起来是什么样子的。

Lambda简介:作为函数参数的代码块

在你代码中存储和传递一小段行为是常有的任务。例如,你常常需要表达像这样的想法:“当一个时间发生的时候运行这个事件处理器”又或者是“把这个操作应用到这个数据接口中所有元素上”。在老版本的Java中,可以使用匿名内部类来实现。这种技巧可以工作但是语法太啰嗦了。
函数式编程提供了另外一种解决问题的方法:把函数当做值来对待。可以直接传递函数,而不需要先声明一个类再传递这个类的实例。使用lambda表达式之后,代码会更加简洁。都不需要声明函数了,可以高效地直接传递代码块作为函数参数。
我们来看一个例子。假设你要定义一个点击按钮的行为,添加一个负责处理点击的监听器。监听器实现了相应的接口OnClickListener和它的一个方法onClick:

/* Java */
button.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            //do something
        }
});
复制代码

这样声明匿名内部类的写法实在是太啰嗦了。在Kotlin中我们可以像Java 8一样使用lambda来消除这些冗余代码。

/* Kotlin */
button.setOnClickListener{ /* do someting */ }
复制代码

这段代码做了与上面同样的事情,但是不用再写啰嗦的匿名内部类了。
之前也说过Kotlin可以使用关键字object 匿名内部类,因此,你想写成普通的方式也是可以的:

button.setOnClickListener(object : View.OnClickListener {
            override fun onClick(v: View?) {
                println("on click")
            }
        })
复制代码

上面两种方式转换成Java代码:

button.setOnClickListener((OnClickListener)null.INSTANCE);
button.setOnClickListener((OnClickListener)(new OnClickListener() {
     public void onClick(@Nullable View v) {
        String var2 = "on click";
        System.out.println(var2);
     }
  }));
复制代码

匿名内部类转换成了Java的匿名内部类。但是lambda应该是Kotlin自己做了特出处理,无法转换成相应的Java代码。

Lambda和集合

我们先来看一个例子,你会用到一个Person类,它包含这个人的名字和年龄信息:

data class Person(val name: String, val age: Int)
复制代码

假设现在你有一个人的列表,需要找到列表中年龄最大的那个人。如果完全不了解lambda,你可能会这样做:

fun findTheOldest(people: List<Person>) {
    var maxAge = 0
    var theOldest: Person? = null
    for (person in people) {
        if (person.age > maxAge) {
            maxAge = person.age
            theOldest = person
        }
    }
    println(theOldest)
}

>>> val people = listOf(Person("Alice", 29), Person("Hubert", 26))
>>> findTheOldest(people)
Person("Alice", 29)
复制代码

可以完成目的,但是代码稍微有点多。而Kotlin有更好的方法,可以使用库函数:

>>> val people = listOf(Person("Alice", 29), Person("Hubert", 26))
>>> println(people.maxBy{ it.age })
Person("Alice", 29)
复制代码

maxBy函数可以在任何集合上调用,且只需要一个实参:一个函数,指定比较哪个值来找到最大元素。花括号中的代码{ it.age } 就是实现了这个逻辑的lambda。它接收一个集合中的元素作为实参(使用it引用它)并且返回用来比较的值。这个例子中,集合元素是Person对象,用来比较的是存储在其age属性中的年龄。
如果lambda刚好是函数或者属性的委托,可以用成员引用替换:

people.maxBy{ Person::age }
复制代码

虽然lambda看上去很简洁,但是你可能不是很明白到底是如何写lambda,以及里面的规则,我们来学习下lambda表达式的语法吧。

Lambda表达式的语法

一个lambda把一小段行为进行编码,你能把它当做值到处传递。它可以被独立地声明并存储到一个变量中。但是更常见的还是直接声明它并传递给函数。

   //参数           //函数体
{ x: Int, y: Int -> x + y }
复制代码

Kotlin的lambda表达式始终用花括号包围。->把实参和函数体分割开,左边是参数列表,右边是函数体。注意参数并没有用() 括起来。
可以把lambda表达式存储在一个变量中,把这个变量当做普通函数对待(即通过相应实参调用它):

>>> val sum = {x:Int,y:Int -> x + y}
>>> println(sum(1, 2))
3
复制代码

如果你乐意,还可以直接调用lambda表达式:

>>> { println(42) }()
42
复制代码

但是这样的语法毫无可读性,也没有什么意义(它等价于直接执行lambda函数体中的代码)。如果你确实需要把一小段代码封闭在一个代码块中,可以使用库函数run来执行传递它的lambda:

>>> run{ println(42) }
42
复制代码

在之后的章节我们会了解到这种调用和内建语言结构一样高效且不会带来额外运行时开销,以及背后的原因。现在我们继续看“找到列表中年龄最大”的例子:

>>> val people = listOf(Person("Alice", 29), Person("Hubert", 26))
>>> println(people.maxBy{ it.age })
Person("Alice", 29)
复制代码

如果不用任何简明语法来重写这个例子,你会得到下面的代码:

people.maxBy({ p: Person -> p.age })
复制代码

这段代码一目了然:花括号中的代码片段是lambda表达式,把它作为实参传给函数。这个lambda接收一个类型为Person的参数并返回它的年龄。
但是这段代码有点啰嗦。首先,过多的标点符号破坏了可读性。其次,类型可以从上下文推断出来并可以省略。最后,这种情况下不需要给lambda的参数分配一个名称。
让我们来改进这些地方,先拿花括号开刀。Kotlin有这样一种语法约定,如果lambda表达式是函数调用的最后一个实参,它可以放到括号的外边。这个例子中,lambda是唯一的实参,所以可以放到括号的后边:

people.maxBy() { p:Person -> p.age }
复制代码

当lambda时函数唯一的实参时,还可以去掉调用代码中的空括号:

people.maxBy { p:Person -> p.age }
复制代码

三种语法形式含义都是一样的,但最后一种最易读。如果lambda是唯一的实参,你当然愿意在写代码的时候省掉这些括号。而当你有多个实参时,即可以把lambda留在括号内来强调它是一个实参,也可以把它放在括号的外面,两种选择都是可行的。如果你想传递两个更多的lambda,不能把超过一个lambda放在外面。
我们来看看这些选项在更复杂的调用中是怎样的。还记得外面在教程二中定义的joinToString函数吗?Kotlin标准库中也有定义它,不同之处在于它可以接收一个附加的函数参数。这个函数可以用toString函数以外的方法来把一个元素转换成字符串。下面的例子展示了你可以用它只打印出人的名字:

>>> val names = people.joinToString(separator = " ", transform = { p: Person -> p.name })
>>> println(names)
Alice Hubert
复制代码

这种方式使用命名实参来传递lambda,清楚地表示了lambda应用到了哪里。
下面的例子展示课可以怎样重写这个调用,把lambda放在括号外:

>>> val names = people.joinToString(" ") { p: Person -> p.name }
>>> println(names)
Alice Hubert
复制代码

这种方式没有显式地表明lambda引用到了哪里,所以不熟悉被调用函数的那些人可能更难理解。

在as或者IDEA中可以使用Alt+Enter唤起操作,使用“Move lambda expression out of parentheses ”把lambda表达式移动到括号外,或“Move lambda expression into parentheses”把lambda表达式移动到括号内。

我们继续简化语法,移除参数的类型。

people.maxBy { p:Person -> p.age }
people.maxBy { p -> p.age }  //推导出参数类型
复制代码

和局部变量一样,如果lambda参数的类型可以被推导出来,你就不需要显示地指定它。以这里的maxBy函数为例,其参数类型始终和集合的元素类型相同。编译器知道你是对一个Person对象的集合调用maxBy函数,所以它能推导lambda参数也会是Person类型。
也存在编译器不能推断出lambda参数类型的情况,但这里我们暂不讨论。可以遵循这样的一条简单的规则:先不声明类型,等编译器报错后再来指定它们。
这个例子你能做的最后简化是使用默认参数名称it代替命名参数。如果当前上下文期望的是只有一个参数的lambda且这个参数的类型可以推断出来,就会生成这个名称。

people.maxBy { it.age }  //it是自动生成的参数名称
复制代码

仅实参名称没有显示地指定时这个默认的名称才会生成。

it约定能大大缩短你的代码,但你不应该滥用它。尤其是在嵌套lambda的情况下。最好显式地声明每个lambda的参数。fouz,很难搞清楚it引用的到底是那个值。如果上下文中参数的类型或意义都不是很明朗,显式声明参数的方法也很有效。

如果你用变量存储lambda,那么就没有可以推断出参数类型的上下文,所以你必须显式地指定参数类型:

>>> val getAge = { p:Person -> p.age }
>>> people.maxBy(getAge)
复制代码

至此你看到的例子都是单个表达式或语句构成的lambda。但是lambda并没有 被限制在这样小的规模,它可以包含更多的语句。下面这种情况,最后一个表达式就是(lambda的)结果:

val sum = { x: Int, y: Int ->
        println("Computing the sum of $x and $y ...")
        x + y
    }
>>> println(sum(1, 2))
Computing the sum of 1 and 2 ...
3
复制代码

在作用域中访问变量

当在函数内声明一个匿名内部类的时候,能够在这个匿名内部类引用这个函数的参数和局部变量。也可以用lambda作同样的事情。如果在函数内部使用lambda,也可以访问这个函数的参数,还有在lambda之前定义的局部变量。
我们用标准库函数forEach来展示这种行为。这个函数能够遍历集合中的每一个元素,并在该元素上调用给定的lambda。forEach函数只是比普通for循环更简洁一些。

fun printMessageWithPrefix(message: Collection<String>, prefix: String) {
    //接受lambda作为实参指定对每个元素的操作
    message.forEach {
        println("$prefix $it")  //在lambda中访问prefix参数
    }
}

>>> val errors = listOf("403 Forbidden", "404 Not Found")
>>> printMessageWithPrefix(errors, "Error:")
Error: 403 Forbidden
Error: 404 Not Found
复制代码

这里Kotlin和Java的一个显著区别是:在Kotlin中不会仅限于访问final变量,在lambda内部也可以修改这些变量:

fun printProblemCounts(response: Collection<String>) {
    var clientErrors = 0
    var serverErrors = 0
    response.forEach {
        if (it.startsWith("4")) {
            clientErrors++
        } else if (it.startsWith("5")) {
            serverErrors++
        }
    }
    println("$clientErrors client errors, $serverErrors server errors")
}

>>> val response = listOf("200 OK", "418 I'm a teapot", "500 Internal Server Error")
>>> printProblemCounts(response)
1
复制代码

和Java不一样,Kotlin允许在lambda内部访问非final变量甚至修改它们。从lambda内访问外部变量,我们称这些变量被lambda捕获,就像这个例子中的prefix、clientErrors以及serverErrors一样。

访问非final变量甚至修改它们的原理

注意,默认情况下,局部变量的生命周期被限制在声明这个变量的函数中,但是如果它被lambda捕获了,使用这个变量的代码可以被存储并稍后再执行。你可能会问这事什么原理?当你捕获final变量时,它的值和使用这个值的lambda代码一起存储。而对非final变量来说,它的值被封装在一个特殊的包装器中,这样你就可以改变这个值,而对这个包装器的引用会和lambda代码一起存储。
这个原理我在教程三中的匿名内部类中也有提到:访问创建匿名内部类的函数中的变量是没有限制在final变量,当时举了这个例子:

var clickCount = 0 
B().setListener(object : Listener {
    override fun onClick() {
        clickCount++ //修改变量
    }
})
复制代码

并且转换成了Java代码:

final IntRef clickCount = new IntRef();
clickCount.element = 0;
(new B()).setListener((Listener)(new Listener() {
   public void onClick() {
      int var1 = clickCount.element++;
   }
}));
复制代码

可以看到真实被使用clickCount是int类型数,但在Java中使用确实包装类IntRef,而真实int变成了clickCount.element。
任何时候你捕获了一个final变量(val),它的值被拷贝下来,这和Java一样。而当你捕获了一个可变变量(var)时,它的值被作为Ref类的一个实例被存储下来。Ref变量是final的能轻易被捕获,然而实际值被存储在其字段中,并且可以在lambda内修改。

这里有个重要的注意事项,如果lambda被用作事件处理器或者用在其他异步执行的情况,对局部变量的修改只会在lambda执行的时候发生。例如下面这段代码并不是记录按钮点击次数的正确方法:

fun tryToCountButtonOnClicks(button: Button): Int {
    var clicks = 0
    button.setOnClickListener { clicks++ }
    return clicks
}
复制代码

这个函数始终返回0。尽管onClick处理器可以修改clicks的值,你并不能观察到值发生了变化,因为onClick处理器是在函数返回之后调用的。这个函数正确的实现需要把点击次数存储在函数外依然可以访问的地方——例如类的属性,而不是存储在函数的局部变量中。

成员引用

你已经看到lambda是如何让你把代码块作为参数传递给函数的。但是如果你想要当做参数传递的代码已经被定义成了函数,该怎么办?当然可以传递一个调用这个函数的lambda,但这样做有点多余。name你能直接传递函数吗?
Kotlin和Java 8一样,如果把函数转换成一个值,你就可以传递它。使用:: 运算符来转换:

val getAge = Person::age
复制代码

这种表达式称为成员引用,它提供了简明语法,来创建一个调用单个方法或者访问单个属性的函数值。双冒号把类名称与你要引用的成员(一个方法或者一个属性)名称隔开。
同样的内容用lambda表达式实现是这样的:

val getAge = { person: Person -> person.age }
复制代码

不管你引用的函数还是属性,都不要在成员引用的名称后面加括号。成员引用和调用该函数的lambda具有一样的类型,所以可以互换使用。

还可以引用顶层函数(不是类的成员):

fun salute() = println("Salute!")
>>> run(::salute)
Salute!
复制代码

这种情况下,你省略了类名称,直接以:: 开头。成员引用::salute 被当做实参传递库函数run,它会调用相应的函数。
如果lambda要委托给一个接收多个参数的函数,提供成员引用代替它将会非常方便:

val action = { person: Person, message: String ->
    sendEmail(person, message)  //这个lambda委托给sendEmail函数
}
val nextAction = ::sendEmail  //可以用成员引用代替
复制代码

可以用构造方法引用存储或者延期执行创建类实例的作用。构造方法引用的形式是在双冒号后指定类名称:

data class Person(val name: String, val age: Int)

>>> val createPerson = ::Person
>>> val p = createPerson("Hubert", 26)  //创建Person实例的动作被保存成了值
>>> println(p)
Person(name=Hubert, age=26)
复制代码

还可以用同样的方式引用扩展函数:

fun Person.isAdult() = age >= 18
val predicate = Person::isAdult
复制代码

尽管isAdult不是Person类的成员,还是可以通过引用访问它,这和访问实例的成员没什么两样:person.isAdult()

绑定引用

在Kotlin 1.0 中,当接受一个类的方法或属性引用时,你始终需要提供一个该类的实例来调用这个引用。Kotlin 1.1 计划支持绑定成员引用,它允许你使用成员引用语法捕获特定实例对象上的方法引用。

>>> val p = Person("Hubert", 26)
>>> val personsAgeFunction = Person::age
>>> println(personsAgeFunction(p))
26
>>> val hubertsAgeFunction = p::age  //Kotlin 1.1 中可以使用绑定成员引用
>>> println(hubertsAgeFunction())
26
复制代码

注意personsAgeFunction是一个单参数函数(返回给定人的年龄),而hubertsAgeFunction是一个没有参数的函数(返回p对象的年龄)。在Kotlin 1.1 之前,你需要显式地写出lambda{ p.age } ,而不是使用绑定成员引用p::age

集合的函数式API

函数式编程风格在操作集合时提供了很多优势。大多数任务都可以通过库函数完成,来简化你的代码。

filter 和 map

filter和map函数形成了集合操作的基础,很多集合操作都是借助它们来表达的。
每个函数我们都会给出两个例子,一个使用数字,另一个使用熟悉的Person类:

data class Person(val name: String, val age: Int)
复制代码

filter函数遍历集合并选出应用给定lambda后会返回true的那些元素:

>>> val list = listOf(1, 2, 3, 4)
>>> println(list.filter {  it % 2 == 0 })  //只留下偶数
[2, 4]
复制代码

上面的结果是一个新的集合,它只包含输入集合中那些满足判断是的元素。
如果你想留下那些超过30岁的人,可以用filter:

>>> val people = listOf(Person("Hubert", 26), Person("Bob", 31))
>>> println(people.filter { it.age > 30 })
Person(name=Bob, age=31)
复制代码

filter函数可以从集合中移除你不想要的元素,但是它并不会改变这些元素。元素的变换是map的用武之地。
map函数对集合中的每一个元素应用给定的函数并把结果收集到一个新集合。可以把数字列表变换成它们平方的列表,比如:

>>> val list = listOf(1, 2, 3, 4)
>>> println(list.map { it * it })
[1, 4, 9, 16]
复制代码

结果是一个新集合,包含的元素个数不变,但是每个元素根据给定的判断式做了变换。
如果你想打印的只是一个姓名的列表,而不是人的完整信息列表,可以用map来变换列表:

>>> val people = listOf(Person("Hubert", 26), Person("Bob", 31))
>>> println(people.map { it.name })
[Hubert, Bob]
复制代码

这个例子也可以同成员引用漂亮地重写:

people.map(Person::name)
复制代码

可以轻松地把多次这样的调用链接起来。例如,打印出年龄超过30岁的人的名字:

>>> people.filter { it.age > 30 }.map(Person::name)
[Bob]
复制代码

现在,如果说需要这个分组中所有年龄最大的人的名字,可以先找到分组中人的最大年龄,然后返回所有这个年龄的人,很容易就用lambda写出如下代码:

people.filter { it.age == people.maxBy(Person::age).age }
复制代码

但是注意,这段代码对每个人都会重复寻找最大年龄的过程,假设集合中有100个人,寻找最大年龄的过程就会执行100遍!下面的解决方法做出了改进,只计算了一次最大年龄:

val maxAge = people.maxBy(Person::age).age
people.filter { it.age == maxAge }
复制代码

如果没有必要就不要重复计算!使用lambda表达式的代码看起来简单,有时候却掩盖底层操作的复杂性。始终牢记你写的代码在干什么。
还可以对map集合应用过滤和变换函数:

>>> val numbers = mapOf(0 to "zero", 1 to "one")
>>> println(numbers.mapValues { it.value.toUpperCase() })
{0=ZERO, 1=ONE}
复制代码

键和值分别由各自的函数来处理。filterKeys和mapKeys过滤和变换map集合的键,而另外的filterValues和mapValues过滤和变换对应的值。

"all" "any" "count"和"find":对集合应用判断

另一种常见的任务是检查集合中所有元素是否都符合某个条件(或者它的变种,是否存在符合的元素)。Kotlin中,它们是通过all和any函数表达的。count函数检查有多少元素满足判断式,而find函数返回第一个符合条件的元素。
为了演示这些函数,我们先来定义一个判断式,来检查一个人是否还没有到28岁:

val canBeInClub27 = { p:Person -> p.age <= 27 }
复制代码

如果你对是否所有元素都满足判断式感兴趣,应该使用all函数:

>>> val people = listOf(Person("Hubert", 26), Person("Bob", 31))
>>> println(people.all(canBeInClub27))
false
复制代码

如果你需要检查集合中是否至少存在一个匹配的元素,那就用any:

>>> val people = listOf(Person("Hubert", 26), Person("Bob", 31))
>>> println(people.any(canBeInClub27))
true
复制代码

注意,!all(不是所有)加上某个条件,可以用any加上这个条件的取反来替换,反之亦然。为了让你的代码更容易理解,应该选择前面不需要否定符号的函数:

>>> val list = listOf(1, 2, 3)
>>> println(!list.all { it == 3 }) //!否定不明显,这种情况最好使用any
true
>>> println(list.any { it != 3 })  //lambda参数中的条件要取反
true
复制代码

第一行检查是保证不是所有元素都等于3.这和至少有一个元素不是3是一个意思,这正式你在第二行用any做的检查。
如果你想知道有多少个元素满足了判断式,使用count:

>>> val people = listOf(Person("Hubert", 26), Person("Bob", 31))
>>> println(people.count(canBeInClub27))
1
复制代码

使用正确的函数完成工作:count VS size

count方法容易被遗忘,然后通过过滤集合之后再取大小来实现它:

>>> println(people.filter(canBeInClub27).size)
1
复制代码

在这种情况下,一个中间集合会被创建并用来存储所有满足判断式的元素。而另一方面,count方法只是跟踪匹配元素的数量,不关心元素本身,所以更高效。

要找到一个满足判断式的元素,使用find函数:

>>> val people = listOf(Person("Hubert", 26), Person("Bob", 31))
>>> println(people.find(canBeInClub27))
Person(name=Hubert, age=26)
复制代码

如果有多个匹配的元素就返回其中第一个元素;或者返回null,如果没有一个元素能满足判断式。find还有一个同义方法firstOrNull,可以使用这个方法更清楚地表达你的意图。

groupBy:把列表转换成分组的map

假设你需要把所有元素按照不同的特征划分成不同的分组。例如,你想把人按年龄分组,相同的年龄的人在一组。把这个特征直接当做参数传递十分方便。groupBy函数可以帮你做到这一点:

>>> val people = listOf(Person("Hubert", 26), Person("Bob", 31), Person("Carol", 31))
>>> println(people.groupBy { it.age })
复制代码

这次操作的结果是一个map,是元素分组依据的键(这个例子中是age)和元素分组(persons)之间的映射:

{26=[Person(name=Hubert, age=26)], 31=[Person(name=Bob, age=31), Person(name=Carol, age=31)]}
复制代码

每一个分组都是存在一个列表中,结果的类型就是Map<Int, List<Person>> 。可以使用像mapKeys和mapValues这样的函数对这个map做进一步修改。
我们再来看另外一个例子,如何使用成员引用把字符串按照首字母分组:

>>> val list = listOf("a", "ab", "b")
>>> println(list.groupBy(String::first))
{a=[a, ab], b=[b]}
复制代码

这里的first并不是String类的成员,而是一个扩展,也可以把它当做成员引用访问。

flatmap 和 flatten :处理嵌套集合中的元素

假设你有一堆书,使用Book类表示:

data class Book(val title: String, val authors: List<String>)
复制代码

每本书都可能有一个或者多个作者,可以统计出图书馆中的所有作者的set:

books,flatMap { it.authors }.toSet()
复制代码

flatMap函数做了两件事:首先根据作为实参给定的函数对集合中每个元素做变换(或者说映射),然后把多个列表合并(或者说平铺)成一个列表。下面这个字符串的例子很好地阐明了这个概念:

>>> val strings = listOf("abc", "def")
>>> println(strings.flatMap { it.toList() })
[a, b, c, d, e, f]
复制代码

字符串上的toList函数把它转换成字符串列表。如果和toList一起使用的是map函数,你会得到一个字符列表的列表,就如同下图的第二行。flapMap函数还会执行后面的步骤,并返回一个包含所有元素的列表。

应用flatMap函数之后的结果

让我们回到书籍作者的例子,每一本数都可能有多位作者,属性book.authors存储了每本书籍的作者集合,flatMap函数把所有书籍作者合并成了一个扁平的列表。toSet调用移除了结果集合中的所有重复元素。
当你卡壳在元素集合不得不合并一个的时候,你可能会想起flapMap来。如果你不需要做任何变换,只是需要平铺一个集合,可以使用flatten函数:listOfLists.flatten()

惰性集合操作:序列

在上一节,你看到了许多链式结合函数调用的例子,比如map和filter。这些函数会及早地创建中间集合,也就是说每一步的中间结果都被存储在一个临时列表。序列给了你执行这些操作的另一种悬着,可以避免创建这些临时中间对象。
先来看个例子:

people.map(Person::name).filter { it.startsWith("A") }
复制代码

Kotlin标准库参考文档有说明,filter和map都会返回一个列表。这意味着上面例子中的链式调用会创建两个列表:一个保存filter函数的结果,另一个保存map函数的结果。如果原列表只有两个元素,这不是什么问题,但是如果有一百万个元素,链式调用就会变得十分低效。
为了提高效率,可以把操作变成使用序列,而不是直接使用集合:

people.asSequence()     //把初始集合转换成序列
            .map(Person::name)
            .filter { it.startsWith("A") }
            .toList()   //把结果序列转换回列表
复制代码

应用这次操作后的结果和前面的例子一模一样:一个以字母A开头的人名列表。但是第二个例子没有创建任何用于存储元素的中间集合,所以元素数量巨大的情况下性能将显著提升。
Kotlin的惰性集合操作的入口就是Sequence接口。这个接口表示的就是一个可以逐个列举元素的元素序列。Sequence只提供了一个方法:iterator,用来从序列中获取值。
Sequence接口的强大之处在于其操作的实现方式。序列中的元素求值是惰性的。因此,可以使用序列更高效地对集合元素执行链式操作,而不需要穿件额外的集合来保存过程中产生的中间结果。
可以调用扩展函数asSequence把任意集合转换成序列,调用toList来做反向的转换。

执行序列操作:中间和末端操作

序列操作分为两类:中间的和末端的。一次中间操作返回的时另一个序列,这个新序列知道如何变换原始序列中的元素。而一次末端操作返回的是一个结果,这个结果可能是集合、元素、数字,或者其他从初始集合的变换序列中获取的任意对象。

                    //中间操作         //末端操作
people.asSequence().map{..}.filter {..}.toList() 
复制代码

中间操作始终都是惰性的。先看看下面这个缺少了末端操作的例子:

>>> listOf(1, 2, 3, 4).asSequence()
            .map { print("map($it)"); it *it }
            .filter { print("filter($it)");it % 2 == 0 }
复制代码

执行这段代码并不会再控制台上输出任何内容。这意味着map和filter变换被延期了,它们只有在获取结果的时候才会被应用(即末端操作调用的时候):

>>> listOf(1, 2, 3, 4).asSequence()
            .map { print("map($it)"); it *it }
            .filter { print("filter($it)");it % 2 == 0 }
            .toList()
map(1) filter(1) map(2) filter(4) map(3) filter(9) map(4) filter(16)
复制代码

末端操作触发执行了所有的延期计算。
这个例子中另外一件值得注意的事情是计算执行的顺序。一个笨办法是现在每个元素上调用map函数,然后在结果序列的每个元素上在调用filter函数。map和filter对集合就是这样做的,而序列不一样。对序列来说,所有操作是按顺序应用在每一个元素上的,处理完第一个元素(先映射在过滤),然后完成第二个元素的处理,以此类推。
这种方法意味着部分元素根本不会发生任何变换,如果在轮到它们之前就已经取得了结果。我们来看一个map和find的例子。首先把一个数字映射成它的平方,然后找到第一个比数字3大的条目:

>>> println(listOf(1, 2, 3, 4)
            .map { print("map($it); "); it * it }
            .find { print("$it > 3 ?; "); it > 3 })
>>> println("----------------")
>>> println(listOf(1, 2, 3, 4).asSequence()
        .map { print("map($it); "); it * it }
        .find { print("$it > 3 ?; "); it > 3 })
        
map(1); map(2); map(3); map(4); 1 > 3 ?; 4 > 3 ?; 4
----------------
map(1); 1 > 3 ?; map(2); 4 > 3 ?; 4
复制代码

第一种情况,当你使用集合的时候,列表被变换成了另一个lieb,所以map变换应用戴每一个元素上,包括了数字3和4.然后第一个满足判断式的元素被找到了:数字2的平方。
第二种情况,find调用一开始就逐个地处理元素。从原始序列中取一个数字,用map变换它,然后在检查它是满足传给find的判断式。当进行到数字2时,返现它的平方已经比3大,就把它作为find操作结果返回了。不再需要继续检查数字3和4,因为这之前你已经找到结果。
在集合上执行操作的顺序也会影响性能。假设你有一个集合,想要打印集合中哪些长度小于某个限制的人名。你需要做两件事:把每个人映射成他们的名字,然后过滤掉其中哪些不够短的名字。这种情况可以用任何顺序应用map和filter操作。两种顺序得到的结果一样,但他们应该执行的变化总次数不一样的:

>>> val people = listOf(Person("Hubert", 26), Person("Alice", 29),
            Person("Bob", 31), Person("Dan", 21))
>>> println(people.asSequence()
       .map { print("map(${it.name}); "); it.name }
        .filter { print("filter($it); ");it.length < 4 }
        .toList())
>>> println("----------------")
>>> println(people.asSequence()
        .filter { print("filter(${it.name}); ");it.name.length < 4 }
        .map { print("map($it); "); it.name }
        .toList())
        

map(Hubert); filter(Hubert); map(Alice); filter(Alice); map(Bob); filter(Bob); map(Dan); filter(Dan); [Bob, Dan]
----------------
filter(Hubert); filter(Alice); filter(Bob); map(Bob); filter(Dan); map(Dan); [Bob, Dan]
复制代码

可以看到,如果map在前,每个元素都被变换。而如果filter在前,不合适的元素会被尽早地过滤掉且不会发生变换。

流 VS 序列 如果你很熟悉Java 8 中的流这个概念,你会发现序列就是它的翻版。Kotlin提供了这个概念自己的版本,原因是Java 8的流并不支持哪些基于Java老版本的平台,例如Android。如果你的目标版本是Java 8,流提供了一个Kotlin集合和序列目前还没有实现的重要特性:在多个CPU上并行执行流操作(比如map和filter)的能力。可以根据Java的目标版本和你的特殊要求在流和序列之间做出选择。

创建序列

前面的列表都是使用同一个方法创建序列:在集合上调用asSquence()。另一个可能性是使用generateSequence函数。给定序列中的前一个元素,这个函数会计算出下一个元素。下面这个例子就是如何使用generateSequence计算100以内所有自然数之和。

>>> val naturalNumbers = generateSequence(0) { it + 1 }
>>> val numbersTo100 = naturalNumbers.takeWhile { it <= 100 }
>>> println(numbersTo100.sum())
5050
复制代码

这个例子中的naturalNumbers和numbersTo100都是有延期操作的序列。这些序列中的实际数字直到你调用末端操作(这里是sum)的时候才会求值。
另一种常见的用例是父序列。如果元素的父元素和它的类型相同(比如人类或者Java文件),你可能会对它所有祖先组成的序列的特质感兴趣。下面这个例子可以查询文件是否放在隐藏目录中,通过创建一个其父类目录的序列并检查每个目录的属性来实现。

fun File.isInsideHiddenDirectory() = 
        generateSequence(this) { it.parentFile }.any{ it.isHidden}
>>> val file = File("/Users/svtk/.HiddenDir/a.txt")
true
复制代码

你生成一个序列,通过提供第一个元素和获取每个后续元素的方式来实现。如果把any换成find,你还可以得到想要的那个目录(对象)。注意,使用序列允许你找到需要的目录之后立即停止遍历目录。

使用Java函数式接口

Kotlin的lambda也可以无缝地和Java API互操作。在文章开头,我们就把lambda传给Java方法的例子:

/* Kotlin */
button.setOnClickListener{ /* do someting */ }
复制代码

Button类通过接收类型为OnClickListner的实参的setOnClickListener方法给按钮设置一个新的监听器,在Java(8之前)中我们不得不创建一个匿名类来作为实参传递:

/* Java */
button.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            //do something
        }
});
复制代码

在Kotlin中,可以传递一个lambda,代替这个例子:

/* Kotlin */
button.setOnClickListener{ view -> ... }
复制代码

这个lambda用来实现OnClickListener,它有一个类型为View的参数,和onclick方法一样。
这种方法可以工作的原因是OnClickListener接口只有一个抽象方法。这种接口被称为函数式接口,或者SAM接口,SAM代表单抽象方法。Java API 中随处可见像Runnable和Callable这样的函数式接口,以及支持它们的方法。Kotlin允许你在调用接收函数式接口作为参数的方法时使用lambda,来保证你的Kotlin代码即整洁又符合习惯。

和Java不同,Kotlin拥有完全的函数类型。正因为这样,需要接收lambda作为参数的Kotlin函数应该使用函数类型而不是函数式接口类型,作为这些参数的类型。Kotlin不支持把lambda自动转换成Kotlin接口对象。我们会在之后的章节中讨论声明函数类型的用法。

是不是非常好奇把lambda传给Java时到底发生了什么,是如何衔接的?

把lambda当做参数传递给Java方法

可以把lambda传给任何期望函数式接口的方法。例如下面这个方法,它有一个Runnable类型的参数:

/* Java */
void postponeComputation(int delay, Runnable computation);
复制代码

在Kotlin中,可以调用它并把一个lambda作为实参传给它。编译器会自动把它转换成一个Runnable的实例。

postponeComputation(1000) { println(42) }
复制代码

当我们说一个Runnable的实例时,指的是一个实现了Runnable接口的匿名内部类的实例。编译器会帮你创建它,并使用lambda作为单抽象方法的方法体。
通过显示的创建一个实现了Runnable的匿名对象也能达到同样的效果:

postponeComputation(1000, object: Runnable {
    override fun run() {
        println(42)
    }
})
复制代码

但是这里有一点不一样。当你显式地声明对象时,每次调用都会创建一个新的实例。使用lambda的情况不同:如果lambda没有访问任何来自定义它的函数的变量,相应的匿名类实例可以在多次调用之间重用。
因此完全等价的实现应该是下面这段代码中显示object声明,它把Runnable实例存储在一个变量中,并且每次调用的时候都使用这个变量:

val runnable = Runnable { println(42) }
fun handleComputation() {
    postponeComputation(1000, runnable)
}
复制代码

如果lambda从包围它的作用域中捕获了变量,每次调用就不再可能重用一同一个实例了。这种情况下,每次调用时编译器都要创建一个新对象,其中存储着被捕获的变量的值。

fun handleComputation(id: String) { //lambda会捕获id这个变量
    postponeComputation(1000) { println(id) } //每次都创建一个Runnable新实例
}
复制代码

Lambda的实现细节

自Kotlin 1.0起,每个lambda表达式都会被编译成一个匿名类,除非它是一个内联lambda。后续版本计划支持生成Java 8字节码。一旦实现,编译器就可以避免为每一个lambda表达式都生成一个独立的.class文件。如果lambda捕获了变量,每个被捕获的变量会在匿名类中有对应的字段,而且每次调用都会创建一个这个匿名 类的新实例。否则,一个单例就会被创建。类的名称由lambda声明所在的函数名称加上后缀衍生出来:上面一个例子就是HandleComputation$1。如果你反编译之前lambda表达式的代码,就会看到:

class HandleComputation$1(val id: String) : Runnable {
    override fun run() {
        println(42)
    }
}
fun handleComputation(id: String) {
    postponeComputation(100, HandleComputation$1(id))
}
复制代码

如你所见,编译器给每个被捕捉的变量生成了一个字段和一个构造方法参数。

SAM构造方法:显式地把lambda转换成函数式接口

SAM构造方法是编译器生成的函数,让你执行从lambda到函数式接口实例的显式转换。可以在编译器不会自动应用转换的上下文中使用它。例如,如果有一个方法返回的时一个函数式接口的实例,不能直接返回一个lambda,要用SAM构造方法把它包起来:

fun createAllDoneRunnable(): Runnable {
    return Runnable { println("All Done!") }
}
>>> createAllDoneRunnable().run()
All Done!
复制代码

SAM构造方法的名称和底层函数式接口的名称一样。SAM构造方法只接收一个参数——一个被用作函数式接口单抽象方法体的lambda,并返回实现了这个接口的类的一个实例。
除了返回值外,SAM构造方法还可以用在需要把从lambda省城的函数式接口实例存储在一个变量中的情况。假设你要在多个按钮上重用同一个监听器,就像下面的代码一样:

val listener = OnClickListener { view ->
        val text = when(view.id) {
            R.id.button1 -> "First Button"
            R.id.button2 -> "Second Button"
            else -> "Unknown Button"
        }
        toast(text)
}
button1.setOnClickListener(listener)
button2.setOnClickListener(listener)
复制代码

listener会检查哪个按钮是点击事件源并作出相应的行为。可以使用实现了OnClickListener的对象声明来定义监听器,但是SAM构造方法给你更简洁的选择。

Lambda和添加/移除监听器

注意lambda内部没有匿名对象那样的this:没有办法引用到lambda转换成的匿名类实例,从编译器的角度来看,lambda是一个代码块,不是一个对象,而且也不能把它当成对象引用。Lambda中的this指向的是包围它的类。
如果你的事件监听器在处理事件时还需要取消它自己,不能使用lambda这样做。这种情况使用实现了接口的匿名对象,在匿名对象内,this关键字指向该对象实例,可以把它传给移除监听器的API。

带接收者的lambda:with 与 apply

with 函数

很多语法都有这样的语句,可以用它对同一个对象执行多次操作,而不需要反复把对象的名称写出来。Kotlin也不例外,但它提供的是一个叫with的库函数,而不是某种特殊的语言结构。
要理解这种用法,我们先看看下面这个例子,稍后你会用with来重构它:

fun alphabet(): String {
    val result = StringBuilder()
    for (letter in 'A'..'Z') {
        result.append(letter)
    }
    result.append("\nNow I know the alphabet!")
    return result.toString()
}

>>> println(alphabet)
ABCDEFGHIJKLMNOPQRSTUVWXYZ
Now I know the alphabet!
复制代码

上面这个例子中,你调用了result实例上好几个不同的方法,而且每次调用都要重复result这个名称。这里情况还不算太糟,但是如果你用到的表达式更长或者重复得更多,该怎么办?
我们来看看使用with函数重写这段代码:

fun alphabet(): String {
    val stringBuilder = StringBuilder()
    return with(stringBuilder) { //指定接收者的值,你会调用它的方法
        for (letter in 'A'..'Z') {
            this.append(letter)  //通过显式地this来调用接收者值得方法
        } 
        append("\nNow I know the alphabet!") //this可以省略
        this.toString() //从lambda返回
    }
}
复制代码

with结构看起来像是一个特殊的语法结构,但它实际上是一个接收两个参数的函数:这个例子中两个参数分别是stringBuilder和一个lambda。这里利用了把lambda放在括号外的约定,这样整个调用看起来就像是内建的语言功能。当然你也可以选择把它写成with(stringBuilder, {...})
with函数把它的第一个参数转换成第二个参数传给他的lambda的接收者。可以显式地通过this引用来访问这个接收者。或者可以省略this引用,不用任何限定符直接访问这个值得方法和属性。这个例子中this指向了stringBuilder,这是传给with的第一个参数。

带接收者的lambda和扩展函数

你可能回忆起曾经见过相似的概念,this指向的时函数接收者。在扩展函数体内部,this指向了这个函数的那个类型的实例,而且也可以被省略掉,让你直接访问接收者的成员。一个扩展函数某种意义上来说就是带接收者的函数。

让我们进一步重构初始的alphabet函数,去掉额外的stringBuilder变量:

fun alphabet(): String = with(StringBuilder()) {
    for (letter in 'A'..'Z') {
        append(letter)
    }
    append("\nNow I know the alphabet!")
    toString()
}
复制代码

现在这个函数只返回一个表达式,所以使用表达式函数体语法重写了它。可以创建一个新的StringBuilder实例直接当做实参传给这个函数,然后在lambda中不需要显示地this就可以引用这个实例。

方法名冲突

如果你当做参数传给with的对象已经有这样的方法,该方法的名称和你正在使用with的类中的方法一样,怎么办?这种情况下,可以给this引用加上显式地标签来表明你要调用的时哪个方法。假设函数的alphabet是类OuterClass的一个方法。如果你想引用的是定义在外部类的toString方法而不是StringBuilder,可以用下面这种语句:

this@OuterClass.toString()
复制代码

with返回的值是执行lambda代码的结果,该结果就是lambda中的最后一个表达式(的值)。但有时候你想返回的是接收者对象,而不是执行lambda的结果。这时apply库函数就派上用场了。

apply 函数

apply函数几乎和with函数一模一样,唯一的区别是apply始终会返回作为实参传递给它的对象(接收者对象)。让我们再一次重构alphabet函数,这一次用的是apply:

fun alphabet(): String = StringBuilder().apply {
    for (letter in 'A'..'Z') {
        append(letter)
    }
    append("\nNow I know the alphabet!")
}.toString()
复制代码

apply被声明成一个扩展函数,他的接收者编程了作为实参的lambda的接收者。执行apply的结果是StringBuilder,所以接下来你可以调用toString把它转换成String。
许多情况下apply都很有效,其中一种是在创建一个对象实例需要用正确的方式初始化它的一些属性的时候。在Java中,这通常是通过另外一个单独的Builder对象来完成的,而在Kotlin中,可以在任意对象上使用apply,完全不需要任何任何来自定义该对象的库的特别支持。
我们来用apply演示一个Android中创建TextView实例的例子:

fun createViewWithCustomAttr(context: Context) = 
    TextView(context).apply {
        text = "Sample Text"
        textSize = 20.0f
        setPadding(10, 0, 0, 0)
    }
复制代码

apply函数允许你使用紧凑的表达式函数体风格。新的TextView实例创建之后立即被传给了apply。在传给apply的lambda中,TextView实例变成了接收者,你就可以调用它的方法并设置它的属性。Lambda执行之后,apply返回已经初始化过的接收者实例,它变成了createViewWithCustomAttr函数的结果。
with函数和apply函数是最基本和最通用的使用带接收者的lambda的例子。更多具体的函数函数也可以使用这种模式。例如,你可以使用标准库函数buildString进一步简化alphabet函数,它会负责创建StringBuilder并调用toString:

fun alphabet(): String = buildString {
    for (letter in 'A'..'Z') {
        append(letter)
    }
    append("\nNow I know the alphabet!")
}
复制代码
关注下面的标签,发现更多相似文章
评论