gitbook/朱涛 · Kotlin编程第一课/docs/476637.md
2022-09-03 22:05:03 +08:00

27 KiB
Raw Permalink Blame History

07 | 高阶函数为什么说函数是Kotlin的“一等公民”

你好,我是朱涛。

高阶函数在Kotlin里有着举足轻重的地位。**它是Kotlin函数式编程的基石是各种框架的关键元素。**高阶函数掌握好了我们理解协程的launch、async函数就会轻松一些阅读协程的源代码也会不那么吃力高阶函数理解透彻了我们学习Jetpack Compose也会得心应手在特定业务场景下我们甚至可以用它来实现自己的DSLDomain Specific Language

不过如果你没有函数式编程的相关经验在初次接触高阶函数的时候很可能会被绕晕。因为它是一个全新的概念你很难从经典的C/Java里找到同等的概念迁移过来Java从1.8开始才引入相关概念。然而对于高阶函数这么重要的概念Kotlin官方文档又惜字如金。

文档里只是突兀地介绍了高阶函数、函数类型、Lambda表达式的简单用法接着就丢出一段复杂的代码案例然后丢出一个更复杂的概念“带接收者的函数类型”Function Types With Receiver接着又丢出了一段更复杂的代码案例。说实话这真的让人难以理解。

所以今天这节课我会采用Java和Kotlin对照的方式来给你讲解Kotlin高阶函数的核心概念。并且我会通过一个实际案例来帮助你理解其中最晦涩难懂的“带接收者的函数类型”为你今后的Kotlin学习之路打下坚实的基础。

Kotlin为什么要引入高阶函数

想要掌握好高阶函数我们首先要知道Kotlin为什么要引入这一全新的概念。这个问题Kotlin官方并没有给出解释但是我们很容易在它的使用上找到蛛丝马迹。

我们来看个实际的例子这是Android中的View定义这里我省略了大部分代码主要是想带你来看看Kotlin高阶函数的一个典型使用场景。

补充如果你不了解Android开发也没关系Java Swing中也有类似的代码模式。如果两者你都不熟悉借助我提供的注释也不难理解。

// View.java

// 成员变量
private OnClickListener mOnClickListener;
private OnContextClickListener mOnContextClickListener;

// 监听手指点击事件
public void setOnClickListener(OnClickListener l) {
    mOnClickListener = l;
}

// 为传递这个点击事件,专门定义了一个接口
public interface OnClickListener {
    void onClick(View v);
}

// 监听鼠标点击事件
public void setOnContextClickListener(OnContextClickListener l) {
    getListenerInfo().mOnContextClickListener = l;
}

// 为传递这个鼠标点击事件,专门定义了一个接口
public interface OnContextClickListener {
    boolean onContextClick(View v);
}

这段代码,其实是一个典型的“可以用高阶函数来优化”的例子。让我们来看看它都做了什么:

  • 首先为了设置点击事件的监听代码里特地定义了一个OnClickListener接口
  • 接着为了设置鼠标点击事件的监听又专门定义了一个OnContextClickListener接口。

乍一看,我们貌似是可以复用同一个接口就行了,对吧?但事实上,借助高阶函数,我们一个接口都不必定义

当然了上面的代码是Android团队十几年前用Java写的在那个场景下这么写代码是完全没问题的。可是这段代码在使用的时候问题更大。比如我们可以来看看如下所示的Android里设置点击监听的代码

// 设置手指点击事件
image.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        gotoPreview();
    }
});

// 设置鼠标点击事件
image.setOnContextClickListener(new View.OnContextClickListener() {
    @Override
    public void onContextClick(View v) {
        gotoPreview();
    }
});

看完了这两段代码之后你有没有觉得这样的代码会很啰嗦因为真正逻辑只有一行代码gotoPreview()而实际上我们却写了6行代码。

图片

如果我们将其中的核心逻辑提取出来,会发现这样才是最简单明了的:

//                      { gotoPreview() } 就是 Lambda
//                             ↑
image.setOnClickListener({ gotoPreview() })
image.setOnContextClickListener({ gotoPreview() })

那么Kotlin语言的设计者是怎么做的呢实际上他们是分成了两个部分

  • 用函数类型替代接口定义;
  • 用Lambda表达式作为函数参数。

这里我们再来看看与前面View.java的等价Kotlin代码

//View.kt
//                     (View) -> Unit 就是「函数类型 」
//                       ↑        ↑ 
var mOnClickListener: ((View) -> Unit)? = null
var mOnContextClickListener: ((View) -> Unit)? = null

// 高阶函数
fun setOnClickListener(l: (View) -> Unit) {
    mOnClickListener = l;
}

// 高阶函数
fun setOnContextClickListener(l: (View) -> Unit) {
    mOnContextClickListener = l;
}

那么通过对比我们能看到Kotlin中引入高阶函数会带来几个好处:一个是针对定义方,代码中减少了两个接口类的定义;另一个是对于调用方来说,代码也会更加简洁。这样一来,就大大减少了代码量,提高了代码可读性,并通过减少类的数量,提高了代码的性能。

图片

关于“inline”我会在下节课中详细介绍。

通过上面的例子,我们已经清楚高阶函数存在的意义和价值了。不过,前面出现的一些新的概念我们还没来得及详细解释,比如,函数类型、Lambda它们到底是什么呢还有高阶函数的具体定义是什么呢

接下来,我会通过一个具体的代码案例,来给你一一解读与高阶函数关系密切的概念及使用定义,让你能进一步夯实函数式编程的基础知识。

理解高阶函数的相关概念

首先,我们来了解下什么是函数类型。

函数类型

顾名思义函数类型Function Type就是函数的类型。如果你之前没有函数式编程的经验,刚接触这个概念的话也许会觉得奇怪:函数也有类型吗?

是的既然变量可以有类型函数也可以有。在Kotlin的世界里函数是一等公民。你可以将其理解为Kotlin里的VIP普通人有的东西VIP当然也有。比如我们可以仔细看看下面的函数

//         (Int,  Int) ->Float 这就是 add 函数的类型
//           ↑     ↑      ↑
fun add(a: Int, b: Int): Float { return (a+b).toFloat() }

请注意这里我给出代码注释第二行注释里面的“↑”代表的是一种映射关系。其实将第三行代码里的“Int Int Float”抽出来就代表了该函数的类型。

我们可以用更精练的语言来描述函数类型的规律:将函数的“参数类型”和“返回值类型”抽象出来后,就得到了“函数类型”。(Int, Int) ->Float就代表了参数类型是两个Int返回值类型为Float的函数类型。

理解了函数类型以后,我们再来看函数的引用。普通的变量也有引用的概念,我们可以将一个变量赋值给另一个变量。而这一点,在函数上也是同样适用的,函数也有引用,并且也可以赋值给变量。

// 函数赋值给变量                    函数引用
//    ↑                              ↑
val function: (Int, Int) -> Float = ::add

高阶函数

接着我们再来看看高阶函数的具体定义。当然前面解释了这么多现在我们对高阶函数应该已经有了比较清晰的认识了我们用Kotlin实现的View点击事件函数它就是一个高阶函数。

而它明确的定义其实是这样的:高阶函数是将函数用作参数或返回值的函数。

这句话有点绕我们还是直接看例子吧。如果我们将Android里点击事件的监听用Kotlin来实现的话它其实就是一个典型的高阶函数。

//                      函数作为参数的高阶函数
//                              ↓
fun setOnClickListener(l: (View) -> Unit) { ... }

换句话说,一个函数的参数或是返回值,它们当中有一个是函数的情况下,这个函数就是高阶函数。

Lambda

而前面我们还提到过Kotlin语言的设计者是用Lambda表达式作为函数参数的那么这里的Lambda就可以理解为是函数的简写

fun onClick(v: View): Unit { ... }
setOnClickListener(::onClick)

// 用 Lambda 表达式来替代函数引用
setOnClickListener({v: View -> ...})

那么如果你够细心的话可能已经发现了一个问题Android并没有提供View.java的Kotlin实现那么为什么我们的Demo里面可以用Lambda来简化事件监听呢

// 在实际开发中,我们经常使用这种简化方式
setOnClickListener({ gotoPreview() }

原因是这样的由于OnClickListener符合SAM转换的要求因此编译器自动帮我们做了一层转换让我们可以用Lambda表达式来简化我们的函数调用。

那么SAM又是个什么鬼

SAM转换

SAM是Single Abstract Method的缩写意思就是只有一个抽象方法的类或者接口。但在Kotlin和Java 8里SAM代表着只有一个抽象方法的接口。只要是符合SAM要求的接口编译器就能进行SAM转换也就是我们可以使用Lambda表达式来简写接口类的参数。

注意Java 8中的SAM有明确的名称叫做函数式接口FunctionalInterface。FunctionalInterface的限制如下缺一不可

  • 必须是接口,抽象类不行;
  • 该接口有且仅有一个抽象的方法抽象方法个数必须是1默认实现的方法可以有多个。

也就是说对于View.java来说它虽然是Java代码但Kotlin编译器知道它的参数OnClickListener符合SAM转换的条件所以会自动做以下转换。

转换前:

public void setOnClickListener(OnClickListener l)

转换后:

fun setOnClickListener(l: (View) -> Unit)
// 实际上是这样:
fun setOnClickListener(l: ((View!) -> Unit)?)

其中,((View!) -> Unit)?代表的是这个参数可能为空。

Lambda表达式引发的8种写法

当一个函数的参数是SAM的情况下我们同样也可以使用Lambda作为参数。所以我们既可以用匿名内部类的方式传参也可以使用Lambda的方式传参。这两种方式在我们前面都已经提到过了。然而在这两种写法的中间还有6种“过渡状态”的写法。这对大部分初学者简直是噩梦同样的代码能有8种不同的写法确实也挺懵的。

而要理解Lambda表达式的简写逻辑其实很简单那就是多写。你也可以跟着我接下来的流程来一起写一写。

  • 第1种写法

这是原始代码,它的本质是用 object 关键字定义了一个匿名内部类:

image.setOnClickListener(object: View.OnClickListener {
    override fun onClick(v: View?) {
        gotoPreview(v)
    }
})

  • 第2种写法

在这种情况下object关键字可以被省略。这时候它在语法层面就不再是匿名内部类了它更像是Lambda表达式了因此它里面override的方法也要跟着删掉

image.setOnClickListener(View.OnClickListener { v: View? ->
    gotoPreview(v)
})

上面的View.OnClickListener被称为SAM ConstructorSAM构造器它是编译器为我们生成的。

  • 第3种写法

由于Kotlin的Lambda表达式是不需要SAM Constructor的所以它也可以被删掉

image.setOnClickListener({ v: View? ->
    gotoPreview(v)
})

  • 第4种写法

由于Kotlin支持类型推导所以View可以被删掉

image.setOnClickListener({ v ->
    gotoPreview(v)
})

  • 第5种写法

当Kotlin Lambda表达式只有一个参数的时候它可以被写成it

image.setOnClickListener({ it ->
    gotoPreview(it)
})

  • 第6种写法

Kotlin Lambda的it是可以被省略的

image.setOnClickListener({
    gotoPreview(it)
})

  • 第7种写法

当Kotlin Lambda作为函数的最后一个参数时Lambda可以被挪到外面

image.setOnClickListener() {
    gotoPreview(it)
}

  • 第8种写法

当Kotlin只有一个Lambda作为函数参数时()可以被省略:

image.setOnClickListener {
    gotoPreview(it)
}

这里我把这8种写法的演进过程以动图的形式展现了出来让你对Lambda这几种写法的差异有一个更加直观的认识。

图片

按照这个流程在IDE里多写几遍你自然就会理解了。一定要写光看是记不住的。

好了到这里你就搞明白这些概念是什么意思了。下面我们来做一个小的总结在后续的Kotlin学习当中这些都是要铭记在心的。

  • 将函数的参数类型和返回值类型抽象出来后,我们就得到了函数类型。比如(View) -> Unit 就代表了参数类型是View返回值类型为Unit的函数类型。
  • 如果一个函数的“参数”或者“返回值”的类型是函数类型,那这个函数就是高阶函数。很明显,我们刚刚就写了一个高阶函数,只是它比较简单而已。
  • Lambda就是函数的一种简写。

然后你也可以再通过一张图来回顾下函数类型、高阶函数以及Lambda表达式三者之间的关系

图片

你也可以再回过头来看看官方文档提供的例子:

fun <T, R> Collection<T>.fold(
    initial: R, 
    combine: (acc: R, nextElement: T) -> R
): R {
    var accumulator: R = initial
    for (element: T in this) {
        accumulator = combine(accumulator, element)
    }
    return accumulator
}

你看到这个函数类型:(acc: R, nextElement: T) -> R是不是瞬间就懂了呢这个函数接收了两个参数第一个参数类型是R第二个参数是T函数的返回类型是R。

难点:带接收者的函数类型

那么现在我们就把高阶函数这个知识点理解得有80%了。而在这节课一开始我还提到在Kotlin的函数类型这个知识点当中还有一个特殊的概念叫做带接收者的函数类型,它尤其晦涩难懂。

说实话,这个名字也对初学者不太友好,“带接收者的函数类型”,这里面的每一个字我都认识,可放到一块我就懵了。所以我们其实还是绕不开一个问题:为什么?

为什么要引入带接收者的函数类型?

这里让我们先来看一下Kotlin的标准函数apply的使用场景。

不用 apply

if (user != null) {
    ...
    username.text = user.name
    website.text = user.blog
    image.setOnClickListener { gotoImagePreviewActivity(user) }
}

使用apply

user?.apply {
    ...
    username.text = name
    website.text = blog
    image.setOnClickListener { gotoImagePreviewActivity(this) }
}

请问这个apply方法应该怎么实现呢

上面的写法其实是简化后的Lambda表达式让我们来反推一下看看它简化前是什么样的

// apply 肯定是个函数,所以有 (),只是被省略了
user?.apply() {
    ...
}

// Lambda 肯定是在 () 里面
user?.apply({ ... })

// 由于 gotoImagePreviewActivity(this) 里的 this 代表了 user
// 所以 user 应该是 apply 函数的一个参数而且参数名为this
user?.apply({ this: User -> ... })

所以现在问题非常明确了apply其实是接收了一个Lambda表达式{ this: User -> ... }。那么现在我们就尝试来实现这个apply方法

fun User.apply(self: User, block: (self: User) -> Unit): User{
    block(self)
    return this
}

user?.apply(self = user) { self: User ->
            username.text = self.name
            website.text = self.blog
            image.setOnClickListener { gotoImagePreviewActivity(this) }
}

由于Kotlin里面的函数形参是不允许被命名为this的因此我这里用的是self。另外这里我们自己写出来的apply仍然还要通过self.name这样的方式来访问成员变量但Kotlin的语言设计者能做到这样

//           改为this             改为this
//               ↓                    ↓ 
fun User.apply(this: User, block: (this: User) -> Unit): User{
//    这里还要传参数
//         ↓ 
    block(this)
    return this
}

user?.apply(this = user) { this: User ->
    ...
//               this 可以省略
//                   ↓ 
    username.text = this.name
    website.text = blog
    image.setOnClickListener { gotoImagePreviewActivity(this) }
}

从上面的例子能看到我们反推的apply实现会比较繁琐

  • 需要我们传入thisuser?.apply(this = user)。
  • 需要我们自己调用block(this)。

因此Kotlin就引入了带接收者的函数类型可以简化apply的定义

//              带接收者的函数类型
//                     ↓  
fun User.apply(block: User.() -> Unit): User{
//  不用再传this
//       ↓ 
    block()
    return this
}

user?.apply { this: User ->
//               this 可以省略
//                   ↓
    username.text = this.name
    website.text = this.blog
    image.setOnClickListener { gotoImagePreviewActivity(this) }
}

现在关键来了。上面的apply方法是不是看起来就像是在User里增加了一个成员方法apply()

class User() {
    val name: String = ""
    val blog: String = ""

    fun apply() {
        // 成员方法可以通过 this 访问成员变量
        username.text = this.name
        website.text = this.blog
        image.setOnClickListener { gotoImagePreviewActivity(this) }
    }
}

所以,从外表上看,带接收者的函数类型,就等价于成员方法。但从本质上讲它仍是通过编译器注入this来实现的

我们可以再通过一张图,来理解下什么是带接收者的函数类型:

图片

看到这里,也许你会想起前面我们讲过的“扩展函数”。那么,带接收者的函数类型,是否也能代表扩展函数呢?

答案是肯定的。毕竟,从语法层面讲,扩展函数就相当于成员函数。

实战与思考

第5讲当中,我们实现了“单例的抽象类模板”,在课程的最后,我还给你留了一个思考题:

我们的抽象类模板BaseSingleton是否还有改进的空间

这里让我们先回顾一下BaseSingleton的代码

                     
abstract class BaseSingleton<in P, out T> {
    @Volatile
    private var instance: T? = null
    //                       ①
    //                       ↓
    protected abstract fun creator(param: P): T

    fun getInstance(param: P): T =
        instance ?: synchronized(this) {
            instance ?: creator(param).also { instance = it }
    }
}

class PersonManager private constructor(name: String) {
    companion object : BaseSingleton<String, PersonManager>() {
        override fun creator(param: String): PersonManager = PersonManager(param)
    }
}

从上面的代码我们可以看到BaseSingleton是单例抽象模板而PersonManager则是实际的单例类。

在前面的第2讲我们就讨论过Person的isAdult应该是属性而walk则应该是方法。那么现在请看注释①它是我们定义的一个抽象方法名字叫做creator。这时候相信你马上就能反应过来creator应该定义成属性。

可是,**如何才能将一个方法改成属性呢?**答案当然就是刚学过的:高阶函数

运用这节课学到的知识我们很容易就能将creator改成一个类型为(P)->T的属性,如下所示:

abstract class BaseSingleton<in P, out T> {
    @Volatile
    private var instance: T? = null
    //               变化在这里,函数类型的属性
    //                  ↓              ↓
    protected abstract val creator: (P)-> T

    fun getInstance(param: P): T =
        instance ?: synchronized(this) {
            instance ?: creator(param).also { instance = it }
    }
}

上面的代码中我们将creator改成了一个抽象的属性如果其他的单例类继承了BaseSingleton这个类就必须实现这个creator属性。不过这时候一个问题就出现了PersonManager该怎么写呢

如果我们依葫芦画瓢在实现creator的时候传入PersonManager的构造函数会发现代码报错类型不匹配。

class PersonManager private constructor(name: String) {
    companion object : BaseSingleton<String, PersonManager>() {
    //             报错,类型不匹配
    //                  ↓ 
        override val creator = PersonManager(name)
    }
}

这段代码报错的原因其实也很简单creator的类型是一个(String)-> PersonManager而PersonManager构造函数这个表达式的值类型是PersonManager类型。前者是函数类型,后者是普通对象类型。那么如何才能正确实现creator这个函数类型的属性呢

答案就是我们前面刚学的:函数引用

class PersonManager private constructor(name: String) {
    companion object : BaseSingleton<String, PersonManager>() {
    //                             函数引用
    //                                ↓ 
        override val creator = ::PersonManager
    }
}

从上面的代码中可以看到我们直接将PersonManager的构造函数以函数引用的方式传给了creator这个属性这样就成功地实现了这个函数类型的属性。

在这个案例里,我们将函数引用以及高阶函数应用到了单例抽象类模板当中,而在这个过程当中,我们也就能更加透彻地理解这两个特性的使用场景了。

这里我制作了一个代码的转换动图,帮你建立一个更加直观的认识。

图片

从这个动图里我们可以清晰地看到某些元素的转移过程。比如泛型P、T还有PersonManager的构造函数这些都是代码中的关键元素。这些关键元素只是换了一种语法排列规则从函数的语法变成了属性的语法,语法从复杂变得简洁,其中的关键元素并未丢失

因此,这两种代码是完全等价的,但后者更加简洁易懂。

小结

到现在为止,咱们高阶函数部分的内容就进入尾声了。让我们再来做一次总结:

  • **为什么引入高阶函数?**答:为了简化。
  • **高阶函数是什么?**答函数作为参数or返回值。
  • **函数类型是什么?**答:函数的类型。
  • **函数引用是什么?**答:类比变量的引用。
  • **Lambda是什么**答:可以简单理解为“函数的简写”(官方定义我们以后再讨论)。
  • **带接收者的函数类型是什么?**答:可以简单理解为“成员函数的类型”。

图片

事实上对于初学者来说要一下子理解并掌握Kotlin“高阶函数”不是一件容易的事情。在掌握好这节课内容的基础上我们可以尝试去读一些优秀的代码。

比如Kotlin官方的源代码StandardKt你可以去分析其中的with、let、also、takeIf、repeat、apply来进一步加深对高阶函数的理解。还有就是CollectionsKt你可以去分析其中的map、flatMap、fold、groupBy等操作符从而对高阶函数的应用场景有一个更具体的认知。

另外,在2讲的时候我们曾经提到过理论上讲Kotlin与Java是完全兼容的。那么问题来了**Kotlin引入全新的高阶函数最终变成JVM字节码后是怎么执行呢**毕竟JVM可不知道什么是高阶函数啊。

答案其实也很简单:匿名内部类

而这样又引出了另一个问题所以Kotlin弄了个这么高端的高阶函数最终还是以匿名内部类的形式在运行呗那它们两者的性能差不多这不是多此一举吗

答案当然是否定的Kotlin高阶函数的性能在极端情况下可以达到匿名内部类的100倍具体是怎么回事儿呢别着急下节课讲“inline”时我们就会来详细探讨。

小作业

请你去阅读一下Kotlin官方的标准函数库StandardKt的源代码,尝试去解析其中任意一个高阶函数的原理和意义,并分享出来,我们一起探讨。

public inline fun <R> run(block: () -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return block()
}

public inline fun <T, R> T.run(block: T.() -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return block()
}

public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return receiver.block()
}

public inline fun <T> T.apply(block: T.() -> Unit): T {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    block()
    return this
}

public inline fun <T> T.also(block: (T) -> Unit): T {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    block(this)
    return this
}


public inline fun <T, R> T.let(block: (T) -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return block(this)
}

public inline fun <T> T.takeIf(predicate: (T) -> Boolean): T? {
    contract {
        callsInPlace(predicate, InvocationKind.EXACTLY_ONCE)
    }
    return if (predicate(this)) this else null
}

public inline fun <T> T.takeUnless(predicate: (T) -> Boolean): T? {
    contract {
        callsInPlace(predicate, InvocationKind.EXACTLY_ONCE)
    }
    return if (!predicate(this)) this else null
}

public inline fun repeat(times: Int, action: (Int) -> Unit) {
    contract { callsInPlace(action) }

    for (index in 0 until times) {
        action(index)
    }
}

好了,这节课就到这里,如果觉得有收获,非常欢迎你把今天的内容分享给更多的朋友,咱们下节课再见。