Kotlin函数式编程入门

一、什么是函数式编程?

函数式编程(Functional Programming)是一种编程范式,它将计算机运算视为数学上的函数计算,并且避免使用程序状态以及易变对象。函数编程语言最重要的基础是λ演算(lambda calculus)。而且λ演算的函数可以接受函数当作输入(引数)和输出(传出值)。
比起指令式编程,函数式编程更加强调程序执行的结果而非执行的过程,倡导利用若干简单的执行单元让计算结果不断渐进,逐层推导复杂的运算,而不是设计一个复杂的执行过程。

从函数式编程的角度来说,只关心输入数据和输出数据的关系,也就是更偏向数学上的“函数”的概念,“函数”本身就是输入和输出之间的映射关系。写代码时要做的就是定义好这个映射关系。主要有以下特点:

stateless:函数不维护任何状态。函数式编程的核心精神是 stateles,简而言之就是不存在状态,外面传进来数据处理完再扔出去,上下文的数据(如各种成员变量)是不变的,函数不关心自身之外的东西。
immutable:输入数据不能动,动了会有危险,所以每次都返回新的数据集。

在实际使用中,函数式编程最具代表性的就是lambda表达式的使用。lambda本质上就是可以传递给其他函数的一段代码,也就是说,在函数式编程中,函数本身可以作为参数和返回值。这就为抽取通用的代码结构提供了语言层面上的支持。第二节会以Kotlin为例来详细说明lambda表达式的使用。

二、Kotlin 函数式编程介绍

Kotlin简介

Kotlin是一门从一开始就支持函数式编程的语言,主要包括以下特性:

1.支持函数类型,允许函数接收其他函数作为参数,或返回其他函数
2.支持lambda表达式,用最少的样板代码方便地传递代码块
3.数据类,提供了创建不可变数据对象的简明语法
4.标准库中提供丰富的函数式API,让你用函数式编程风格操作对象和集合

下面将详细介绍比较重要的特性。

基础语法

首先来通过一个例子直观感受一下lambda表达式。Android开发中经常会给一个Button设置OnClickListener。比如我们需要让按钮点击后消失,平时我们可能是这样写的:

//代码段1-传统Java式写法
mButton.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View view) {
            view.setVisibility(View.GONE);
        }
    });

而在Kotlin中,使用函数式语法,我们可以这样写:

//代码段2-Kotlin函数式写法
mButton.setOnClickListener { 
    it.visibility = View.GONE
}

直观来讲,似乎跟我们平时的写法差别有点大,比如,函数调用的小括号不见了,匿名内部类直接被一个函数体取代了,View参数不见了,分号也消失了,还有那个it是什么鬼……其实,就像数学公式推导一样,精简的写法也是通过一步一步简化来的。下面就让我们来看一下代码段2的“推导过程”:

1.首先,代码段1转换为Kotlin代码,并替换为函数式写法:

mButton.setOnClickListener({ view: View ->
    view.visibility = View.GONE
})

这段代码非常清晰,花括号包裹的是一段lambda表达式,可以把它作为实参传递给函数,这一步把匿名内部类省略掉了;另外也干掉了分号,因为在Kotlin中行末尾的分号可以省略;最后还省略了set方法,在Kotlin中,会默认把对属性的直接访问转换成get/set方法调用。


2.然后,根据Kotlin的语法约定,如果lambda表达式是函数调用的最后一个实参,就可以把它挪到小括号外面:

mButton.setOnClickListener() { view: View ->
    view.visibility = View.GONE
}

3.当lambda是函数的唯一实参,就可以去掉空的小括号对:

mButton.setOnClickListener { view: View ->
    view.visibility = View.GONE
}

4.如果lambda的参数的类型可以被编译器推导出来,就可以省略它:

mButton.setOnClickListener { view ->
    view.visibility = View.GONE
}

5.最后,如果这个lambda只有一个参数,并且这个参数的类型可以被推断出来(也就是同时满足3和4),那么这个参数也可以省略掉。代码中引用这个参数的地方可以通过编译器自动生成的名称it来替代:

mButton.setOnClickListener {
    it.visibility = View.GONE
}

经过上述5个步骤,就得到了最简洁、最清晰的代码段2。跟代码段1相比简直是赏心悦目啊有没有!其实到这里,Kotlin中关于Lambda的基础语法也就讲清楚了。需要注意的是,在Kotlin语言里,函数是“一等公民”,也就是说:

  • 函数不一定要写在类里,顶层函数是允许的。
  • 函数也是对象,所以函数可以作为参数和返回值。使用函数对象作为参数和返回值的函数叫做高阶函数。在下一小节会介绍一些常用的库函数,这些函数基本都是高阶函数。关于高阶函数的具体实现方法,也会在后文详细介绍。

常用库函数与函数式集合操作

上面给出了一个简单的例子,介绍了Kotlin在语法层面上对于函数式编程的支持。其实对于一门优秀的生产力语言来讲,它的威力除了体现在语法层面,还体现在强大、丰富的函数库,Kotlin也不例外。而其中很多很常用、很方便的库函数都是使用函数式编程来实现的。下面就以常用的集合操作为例,介绍一些常用的库函数。

本节的例子都会使用如下的Person类组成的列表:

//Kotlin
//包含两个字段的数据类
data class Person(val name: String, val age: Int)
//people的类型是ArrayList<Person>,这里Kotlin自动推导出了类型
val people = arrayListOf(Person("Alan", 10), Person("Bob", 20), Person("Cort", 30))

filter 筛选

filter函数遍历集合并选出应用给定lambda后会返回true的那些元素。
//使用filter筛选出年龄大于15岁的人
println(people.filter { it.age > 15 })

//output:
//[Person(name=Bob, age=20), Person(name=Cort, age=30)]

map 映射

map函数对集合中的每一个元素应用给定的lambda并把结果收集到一个新集合。
//使用map打印所有人名的列表
println(people.map { it.name })

//output:
//[Alan, Bob, Cort]

reduce 归约

reduce函数将一个集合的所有元素通过传入的操作函数实现数据集合的累积操作效果。
//使用reduce拼接所有人名首字母 ("$acc$name"是Kotlin中的字符串拼接方式)
println(people.map { it.name.substring(0, 1) }.reduce { acc , name -> "$acc$name" } )

//output:
//ABC

统计与条件判断

很多时候我们需要做这样的判断:某个集合是否所有元素都符合某个条件;是否存在符合条件的元素;统计符合条件的元素的数量;查找符合条件的元素。在Kotlin中内置了丰富的库函数帮助我们实现这些功能:

  • all 集合中是否对每个元素执行给定的lambda表达式,都返回true
  • any 集合中是否存在某个元素,对其执行给定的lambda表达式返回true
  • count 对集合中每个元素执行给定的lambda表达式,返回true的元素数量
  • find 返回集合中第一个执行给定lambda表达式结果为true的元素,如果没有就返回null
  • maxBy/minBy 找到集合中的极值元素
//集合中是否都是成年人
println(people.all { it.age >= 18 })
//集合中是否存在未成年人
println(people.any { it.age < 18 })
//集合中未成年人的数量
println(people.count { it.age < 18 })
//找到集合中第一个成年人
println(people.find { it.age >= 18 })
//找到集合中年龄最大/最小的人
println(people.maxBy { it.age })
println(people.minBy { it.age })

//output:
//false
//true
//1
//Person(name=Bob, age=20)
//Person(name=Cort, age=30)
//Person(name=Alan, age=10)

groupBy 分组

groupBy也是一个非常实用的函数,它可以按照我们提供的lambda表达式,将集合分类为多个子集。它返回一个Map,key是分类依据,value是该分类的元素List。

//根据名字的长度分组
println(people.groupBy { it.name.length })

//得到的Map中,key是名字的长度值,value是名字长度为该值的Person列表
//output:
//{4=[Person(name=Alan, age=10), Person(name=Cort, age=30)], 3=[Person(name=Bob, age=20)]}

flatMap 平铺

flatMap首先使用传入的lambda对集合中每个元素做变换(映射),然后把多个列表合并(平铺)成一个列表。也就是说传入的lambda需要返回一个列表(Iterable)。

//得到所有人名的组成字母List
println(people.flatMap { it.name.toList() })

//output:
//[A, l, a, n, B, o, b, C, o, r, t]

高阶函数

前面介绍了一些库函数,可以接收lambda表达式作为参数来为集合操作提供很大的便利,那么这些库函数是如何实现的呢?其实,它们都是高阶函数。上文提到过,高阶函数就是使用函数对象作为参数或返回值的函数。

高阶函数的声明

上文提到过的maxBy就是Kotlin的一个库函数,它接收一个lambda表达式作为参数,源码如下(这里也把使用maxBy的例子再次贴上来方便查看):

/**
 * Returns the first element yielding the largest value of the given function or `null` if there are no elements.
 */
public inline fun <T, R : Comparable<R>> Iterable<T>.maxBy(selector: (T) -> R): T? {
    val iterator = iterator()
    if (!iterator.hasNext()) return null
    var maxElem = iterator.next()
    var maxValue = selector(maxElem)
    while (iterator.hasNext()) {
        val e = iterator.next()
        val v = selector(e)
        if (maxValue < v) {
            maxElem = e
            maxValue = v
        }
    }
    return maxElem
}

//例:找到集合中年龄最大的人
println(people.maxBy { it.age })

不难看出,maxBy函数的关键就是selector参数。它是接收一个T类型参数,返回一个可比较的R类型值的lambda表达式。其中,T类型是需要被比较的类型,在上面的例子中,就是Person类型;R类型是比较的依据,在上面的例子中是age的类型,也就是Int。看懂了这个lambda表达式,maxBy的源码也就很好理解了。

Kotlin中声明函数类型的语法如下:

(参数类型1, 参数类型2, ...) -> 返回类型

//一个最简单的求和函数
val sum: (Int, Int) -> Int = { x, y -> x + y }
println(sum(1, 2))

//output:
//3

从上面的例子可以看出,Kotlin中函数可以保存在一个变量中,留待后续使用。

lambda作为函数返回值的用法,相对来说没有lambda作为参数那么常见,所以这里就不介绍了,大家有兴趣可以了解一下。

高阶函数的控制流

最后,有必要介绍一下lambda中return的使用,这也是刚接触lambda时容易感到困惑的点。先来看一个例子,依然是使用上文定义的Person类和people列表:

fun lookForAlan(people: List<Person>) {
//forEach是Kotlin集合的库函数,用于遍历,并对集合中每个元素执行传入的lambda
    people.forEach {
        if (it.name == "Alan") {
            println("Found!")
            return
        }
    }
    println("after forEach")
}

//output:
//Found!

可以看到,外层的lookForAlan函数直接从return的地方返回了。这在Kotlin中叫做非局部返回。需要注意的是,只有内联函数的lambda参数中才可以调用return语句,也就是说,不能从非内联函数的lambda参数中使用return语句。这是因为非内联函数可以把它的lambda参数保存在一个变量里待以后使用,此时函数可能已经返回了。有关内联函数的介绍,请参见下一小节。

有时我们需要在lambda中使用局部返回(只从lambda返回),并从lambda的后面继续执行。这时有两种方法,第一种是用标签来标记返回点:

fun lookForAlan(people: List<Person>) {
//forEach是Kotlin集合的库函数,用于遍历,并对集合中每个元素执行传入的lambda
    people.forEach {
        if (it.name == "Alan") {
            println("Found!")
            return@forEach
        }
    }
    println("after forEach")
}

//output:
//Found!
//after forEach

上面的例子中使用@forEach标签指定了返回点,因此这里return从lambda返回了。forEach是编译器自动生成的标签,当然也可以自定义标签,有兴趣的同学可以了解一下。

第二种是使用匿名函数。匿名函数看起来有点像省略了函数名和参数类型的普通函数,但实际上只是lambda的另一种语法形式。在匿名函数中使用return会默认从匿名函数中返回:

fun lookForAlan2(people: List<Person>) {
    people.forEach (fun(person) {
        if (person.name == "Alan") {
            println("Found!")
            return
        }
    })
    println("after forEach")
}

//output:
//Found!
//after forEach

总的来说,Kotlin中return语句的规则可以归纳为一句话:除非使用标签指定了返回点,return从最近的使用fun关键字声明的函数返回

性能

函数式编程可以给我们的开发带来很多便利,但当我们看到一个黑盒的时候总不免担心里面的性能。下面,就让我们打开黑盒,一起探究一下性能相关的话题。

lambda的编译

每个非内联的lambda都会被编译成一个匿名类。与Java不同的是,在没有捕捉变量的情况下,生成的匿名类是单例的。也就是说,此时编译器会自动重用这个匿名类(回想Java中,需要每次都new一个匿名类,性能反而不如Kotlin的lambda)。而当lambda引用了外部函数中的变量(也就是进行了变量捕捉),每次调用lambda时才会创建新的匿名类对象。

如果把lambda传给了标记为inline的Kotlin函数,就不会创建任何匿名类。因为此时lambda被内联了。

使用内联优化lambda的性能

上面提到,Kotlin中函数是可以被内联的。例如,我们可以定义一个简单的同步函数来抽象繁琐的加锁和try操作:

inline fun <T> synchronized(lock: Lock, action: () -> T): T {
    lock.lock()
    try {
        return action()
    } finally {
        lock.unlock()
    }
}

该函数接收一把锁,以及需要加锁的操作,在函数内部帮我们完成try和加解锁工作。使用时就非常方便了:

val lock = Lock()
println("before")
synchronized(lock) {
    println("sync")
    //do something
}
println("after")

//output:
//before
//sync
//after

因为synchronized函数是inline的,所以编译时会生成类似下面的代码:

println("before")
lock.lock()
try {
    println("sync")
} finally {
    lock.unlock()
}
println("after")

可以看到,内联后并没有产生额外的运行时开销(没有生成额外的匿名类)。Kotlin大部分的库函数都被声明为inline的,因此,使用这些库函数并不会带来额外的性能损耗。

sequence 惰性序列操作

上文中介绍了很多函数式集合操作的库函数。其实很多时候需求是非常复杂的,需要多个操作组合起来使用。而许多操作都会生成新的集合,所以链式操作可能会生成多个中间集合,导致性能问题。为了提高效率,可以把操作“打包”成序列(sequance)

例如,我们想要得到所有名字以字母A开头的人的集合,可以简单使用链式调用:

println(people.map { it.name }.filter { it.startsWith("A") })

//output:
//[Alan]

但这样做会为map和filter操作分别创建一个列表,而我们实际只需要最后一个列表。如果集合元素很多,链式操作又很复杂,就会导致严重的性能问题。我们可以使用序列减少中间集合以提升效率:

println(people.asSequence()
            .map { it.name } //中间操作
            .filter { it.startsWith("A") } //中间操作
            .toList()) //末端操作

//output:
//[Alan]

可以调用asSequence把任何集合转为序列,调用toList把序列转换回List。

那么,序列操作是如何提升效率的呢?

首先需要引入两个概念。序列操作分为两类:中间操作末端操作。中间操作返回的是转换序列(TransformingSequence),这种序列知道如何变换原始序列中的元素。而末端操作返回的是一个结果,例如上面例子中返回了一个List。序列操作高效的地方就在于,中间操作始终是惰性的,它们会在获取结果(也就是末端操作调用)的时候才会被应用。所有操作按照顺序依次应用在每一个元素上,例如上面的例子中,就是先对Alan做map和filter,再对Bob做map和filter,最后对Cort做map和filter。这样就不会产生中间集合

另外,序列操作还有“短路”的特性。这一点跟我们熟悉的布尔表达式类似。举一个最简单的例子:

a == a || b == c

因为a == a为true,所以b == c根本不会被执行,因为这个布尔表达式的结果在前半部分就已经确定了。类似的,再看一个序列操作的例子:

//找到第一个平方大于3的数,输出它的平方
println(listOf(1, 2, 3, 4).asSequence()
        .map { it * it }
        .find { it > 3 })

//output:
//4

上面的例子中实际上只对前两个元素做了平方运算,后面元素的计算都被“短路”掉了。而普通的链式调用会进行四次平方运算。

与Java的交互

众所周知,Kotlin一个非常大的优点就是可以与Java互相调用。而Java8之前的版本是不支持lambda表达式的,Kotlin如何与低版本Java进行交互呢?
上文提到,Kotlin中没被内联的lambda会被编译成匿名类对象,其实这正是Kotlin lambda与Java交互的关键。例如,可以把lambda传给下面的Java函数:

val handler = Handler()
handler.post {
    Log.d("tag", "runnable log")
}

mButton.setOnClickListener { 
    it.visibility = View.GONE
}

这些Java函数大家作为Android开发者肯定都不会陌生了。像Runnable和OnClickListener这种只有一个抽象方法的接口,叫做函数式接口,或者SAM(单抽象方法)接口。Kotlin支持在调用接收函数式接口作为参数的方法时使用lambda,这样就可以在充分利用现有Java库的同时,让代码尽可能地整洁、简单。

参考资料

发布于 2018-10-29