# 07 | 高阶函数:为什么说函数是Kotlin的“一等公民”? 你好,我是朱涛。 高阶函数在Kotlin里有着举足轻重的地位。**它是Kotlin函数式编程的基石,是各种框架的关键元素。**高阶函数掌握好了,我们理解协程的launch、async函数就会轻松一些,阅读协程的源代码也会不那么吃力;高阶函数理解透彻了,我们学习Jetpack Compose也会得心应手,在特定业务场景下,我们甚至可以用它来实现自己的DSL(Domain 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中也有类似的代码模式。如果两者你都不熟悉,借助我提供的注释,也不难理解。 ```java // 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里设置点击监听的代码: ```java // 设置手指点击事件 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行代码。 ![图片](https://static001.geekbang.org/resource/image/18/ac/1876d635751fd11662fbfcc3285a0eac.jpg?wh=1852x494) 如果我们将其中的核心逻辑提取出来,会发现这样才是最简单明了的: ```plain // { gotoPreview() } 就是 Lambda // ↑ image.setOnClickListener({ gotoPreview() }) image.setOnContextClickListener({ gotoPreview() }) ``` 那么,Kotlin语言的设计者是怎么做的呢?实际上他们是分成了两个部分: * 用函数类型替代接口定义; * 用Lambda表达式作为函数参数。 这里我们再来看看与前面View.java的等价Kotlin代码: ```plain //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中引入高阶函数会带来几个**好处**:一个是针对定义方,代码中减少了两个接口类的定义;另一个是对于调用方来说,代码也会更加简洁。这样一来,就大大减少了代码量,提高了代码可读性,并通过减少类的数量,提高了代码的性能。 ![图片](https://static001.geekbang.org/resource/image/a7/eb/a7yy974d95cd3938f0f36781afb425eb.jpg?wh=1531x699) > 注:关于“inline”,我会在下节课中详细介绍。 通过上面的例子,我们已经清楚高阶函数存在的意义和价值了。不过,前面出现的一些新的概念我们还没来得及详细解释,比如,**函数类型、Lambda,它们到底是什么呢?还有高阶函数的具体定义是什么呢?** 接下来,我会通过一个具体的代码案例,来给你一一解读与高阶函数关系密切的概念及使用定义,让你能进一步夯实函数式编程的基础知识。 ## 理解高阶函数的相关概念 首先,我们来了解下什么是函数类型。 ### 函数类型 顾名思义,函数类型(Function Type)就是**函数的类型**。如果你之前没有函数式编程的经验,刚接触这个概念的话也许会觉得奇怪:函数也有类型吗? 是的,既然变量可以有类型,函数也可以有。在Kotlin的世界里,[函数是一等公民](https://kotlinlang.org/docs/lambdas.html)。你可以将其理解为Kotlin里的VIP,普通人有的东西,VIP当然也有。比如,我们可以仔细看看下面的函数: ```plain // (Int, Int) ->Float 这就是 add 函数的类型 // ↑ ↑ ↑ fun add(a: Int, b: Int): Float { return (a+b).toFloat() } ``` 请注意这里我给出代码注释,第二行注释里面的“↑”代表的是一种映射关系。其实,将第三行代码里的“Int Int Float”抽出来,就代表了该函数的类型。 我们可以用更精练的语言来描述函数类型的规律:**将函数的“参数类型”和“返回值类型”抽象出来后,就得到了“函数类型”**。(Int, Int) ->Float就代表了参数类型是两个Int,返回值类型为Float的函数类型。 理解了函数类型以后,我们再来看**函数的引用**。普通的变量也有引用的概念,我们可以将一个变量赋值给另一个变量。而这一点,在函数上也是同样适用的,函数也有引用,并且也可以赋值给变量。 ```plain // 函数赋值给变量 函数引用 // ↑ ↑ val function: (Int, Int) -> Float = ::add ``` ### 高阶函数 好,接着我们再来看看高阶函数的具体定义。当然,前面解释了这么多,现在我们对高阶函数应该已经有了比较清晰的认识了,我们用Kotlin实现的View点击事件函数,它就是一个高阶函数。 而它明确的定义其实是这样的:**高阶函数是将函数用作参数或返回值的函数。** 这句话有点绕,我们还是直接看例子吧。如果我们将Android里点击事件的监听用Kotlin来实现的话,它其实就是一个典型的高阶函数。 ```plain // 函数作为参数的高阶函数 // ↓ fun setOnClickListener(l: (View) -> Unit) { ... } ``` 换句话说,一个函数的参数或是返回值,它们当中有一个是函数的情况下,这个函数就是高阶函数。 ### Lambda 而前面我们还提到过,Kotlin语言的设计者是用Lambda表达式作为函数参数的,那么这里的Lambda,就可以理解为是**函数的简写**: ```plain fun onClick(v: View): Unit { ... } setOnClickListener(::onClick) // 用 Lambda 表达式来替代函数引用 setOnClickListener({v: View -> ...}) ``` 那么,如果你够细心的话,可能已经发现了一个问题:Android并没有提供View.java的Kotlin实现,那么为什么我们的Demo里面可以用Lambda来简化事件监听呢? ```plain // 在实际开发中,我们经常使用这种简化方式 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转换的条件,所以会自动做以下转换。 转换前: ```java public void setOnClickListener(OnClickListener l) ``` 转换后: ```plain fun setOnClickListener(l: (View) -> Unit) // 实际上是这样: fun setOnClickListener(l: ((View!) -> Unit)?) ``` 其中,((View!) -> Unit)?代表的是这个参数可能为空。 ## Lambda表达式引发的8种写法 当一个函数的参数是SAM的情况下,我们同样也可以使用Lambda作为参数。所以,**我们既可以用匿名内部类的方式传参,也可以使用Lambda的方式传参**。这两种方式在我们前面都已经提到过了。然而,在这两种写法的中间,还有6种“过渡状态”的写法。这对大部分初学者简直是噩梦:同样的代码,能有8种不同的写法,确实也挺懵的。 而要理解Lambda表达式的简写逻辑,其实很简单,那就是:**多写**。你也可以跟着我接下来的流程来一起写一写。 * 第1种写法 这是原始代码,它的本质是用 object 关键字定义了一个匿名内部类: ```plain image.setOnClickListener(object: View.OnClickListener { override fun onClick(v: View?) { gotoPreview(v) } }) ``` * 第2种写法 在这种情况下,object关键字可以被省略。这时候它在语法层面就不再是匿名内部类了,它更像是Lambda表达式了,因此它里面override的方法也要跟着删掉: ```plain image.setOnClickListener(View.OnClickListener { v: View? -> gotoPreview(v) }) ``` 上面的`View.OnClickListener`被称为SAM Constructor(SAM构造器),它是编译器为我们生成的。 * 第3种写法 由于Kotlin的Lambda表达式是不需要SAM Constructor的,所以它也可以被删掉: ```plain image.setOnClickListener({ v: View? -> gotoPreview(v) }) ``` * 第4种写法 由于Kotlin支持类型推导,所以View可以被删掉: ```plain image.setOnClickListener({ v -> gotoPreview(v) }) ``` * 第5种写法 当Kotlin Lambda表达式只有一个参数的时候,它可以被写成it: ```plain image.setOnClickListener({ it -> gotoPreview(it) }) ``` * 第6种写法 Kotlin Lambda的it是可以被省略的: ```plain image.setOnClickListener({ gotoPreview(it) }) ``` * 第7种写法 当Kotlin Lambda作为函数的最后一个参数时,Lambda可以被挪到外面: ```plain image.setOnClickListener() { gotoPreview(it) } ``` * 第8种写法 当Kotlin只有一个Lambda作为函数参数时,()可以被省略: ```plain image.setOnClickListener { gotoPreview(it) } ``` 这里,我把这8种写法的演进过程以动图的形式展现了出来,让你对Lambda这几种写法的差异有一个更加直观的认识。 ![图片](https://static001.geekbang.org/resource/image/e4/bf/e441323968c0c061898257fd06db37bf.gif?wh=1080x608) 按照这个流程,在IDE里多写几遍,你自然就会理解了。一定要写,光看是记不住的。 好了,到这里,你就搞明白这些概念是什么意思了。下面我们来做一个小的总结,在后续的Kotlin学习当中,这些都是要铭记在心的。 * 将函数的参数类型和返回值类型抽象出来后,我们就得到了函数类型。比如`(View) -> Unit` 就代表了参数类型是View,返回值类型为Unit的函数类型。 * 如果一个函数的“参数”或者“返回值”的类型是函数类型,那这个函数就是高阶函数。很明显,我们刚刚就写了一个高阶函数,只是它比较简单而已。 * Lambda就是函数的一种简写。 然后,你也可以再通过一张图来回顾下函数类型、高阶函数以及Lambda表达式三者之间的关系: ![图片](https://static001.geekbang.org/resource/image/ec/71/ecf85d05f6fdc5ea2d015a61d7e64771.png?wh=1920x914) 你也可以再回过头来看看官方文档提供的例子: ```plain fun Collection.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: ```plain if (user != null) { ... username.text = user.name website.text = user.blog image.setOnClickListener { gotoImagePreviewActivity(user) } } ``` 使用apply: ```plain user?.apply { ... username.text = name website.text = blog image.setOnClickListener { gotoImagePreviewActivity(this) } } ``` 请问这个apply方法应该怎么实现呢? 上面的写法其实是简化后的Lambda表达式,让我们来反推一下,看看它简化前是什么样的: ```plain // apply 肯定是个函数,所以有 (),只是被省略了 user?.apply() { ... } // Lambda 肯定是在 () 里面 user?.apply({ ... }) // 由于 gotoImagePreviewActivity(this) 里的 this 代表了 user // 所以 user 应该是 apply 函数的一个参数,而且参数名为:this user?.apply({ this: User -> ... }) ``` 所以,现在问题非常明确了,apply其实是接收了一个Lambda表达式:`{ this: User -> ... }`。那么现在,我们就尝试来实现这个apply方法: ```plain 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的语言设计者能做到这样: ```plain // 改为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实现会比较繁琐: * 需要我们传入this:user?.apply(this = user)。 * 需要我们自己调用:block(this)。 因此,Kotlin就引入了带接收者的函数类型,可以简化apply的定义: ```plain // 带接收者的函数类型 // ↓ 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()? ```plain class User() { val name: String = "" val blog: String = "" fun apply() { // 成员方法可以通过 this 访问成员变量 username.text = this.name website.text = this.blog image.setOnClickListener { gotoImagePreviewActivity(this) } } } ``` 所以,从外表上看,带接收者的函数类型,就等价于成员方法。但**从本质上讲,它仍是通过编译器注入this来实现的**。 我们可以再通过一张图,来理解下什么是带接收者的函数类型: ![图片](https://static001.geekbang.org/resource/image/9a/04/9acd54ac08d88c94ca52336a576b3304.png?wh=1890x1156) 看到这里,也许你会想起前面我们讲过的“扩展函数”。那么,带接收者的函数类型,是否也能代表扩展函数呢? 答案是肯定的。毕竟,**从语法层面讲,扩展函数就相当于成员函数。** ## 实战与思考 在[第5讲](https://time.geekbang.org/column/article/475058)当中,我们实现了“单例的抽象类模板”,在课程的最后,我还给你留了一个思考题: > 我们的抽象类模板BaseSingleton是否还有改进的空间? 这里,让我们先回顾一下BaseSingleton的代码: ```plain abstract class BaseSingleton { @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() { override fun creator(param: String): PersonManager = PersonManager(param) } } ``` 从上面的代码我们可以看到,BaseSingleton是单例抽象模板,而PersonManager则是实际的单例类。 在前面的[第2讲](https://time.geekbang.org/column/article/473349)里,我们就讨论过Person的isAdult应该是属性,而walk则应该是方法。那么现在,请看注释①,它是我们定义的一个抽象方法,名字叫做creator。这时候,相信你马上就能反应过来:creator应该定义成属性。 可是,**如何才能将一个方法改成属性呢?**答案当然就是刚学过的:**高阶函数**。 运用这节课学到的知识,我们很容易就能将creator改成一个类型为:`(P)->T`的属性,如下所示: ```plain abstract class BaseSingleton { @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的构造函数,会发现代码报错:类型不匹配。 ```plain class PersonManager private constructor(name: String) { companion object : BaseSingleton() { // 报错,类型不匹配 // ↓ override val creator = PersonManager(name) } } ``` 这段代码报错的原因其实也很简单,creator的类型是一个(String)-> PersonManager,而PersonManager构造函数这个表达式的值类型,是PersonManager类型。**前者是函数类型,后者是普通对象类型**。那么,如何才能正确实现creator这个函数类型的属性呢? 答案就是我们前面刚学的:**函数引用**! ```plain class PersonManager private constructor(name: String) { companion object : BaseSingleton() { // 函数引用 // ↓ override val creator = ::PersonManager } } ``` 从上面的代码中可以看到,我们直接将PersonManager的构造函数,以函数引用的方式传给了creator这个属性,这样就成功地实现了这个函数类型的属性。 在这个案例里,我们将函数引用以及高阶函数应用到了单例抽象类模板当中,而在这个过程当中,我们也就能更加透彻地理解这两个特性的使用场景了。 这里我制作了一个代码的转换动图,帮你建立一个更加直观的认识。 ![图片](https://static001.geekbang.org/resource/image/49/5c/49b134d723c11c7d50762497b44afa5c.gif?wh=1080x608) 从这个动图里,我们可以清晰地看到某些元素的转移过程。比如泛型P、T,还有PersonManager的构造函数,这些都是代码中的关键元素。这些关键元素只是换了一种语法排列规则:**从函数的语法变成了属性的语法,语法从复杂变得简洁,其中的关键元素并未丢失**。 因此,这两种代码是完全等价的,但后者更加简洁易懂。 ## 小结 到现在为止,咱们高阶函数部分的内容就进入尾声了。让我们再来做一次总结: * **为什么引入高阶函数?**答:为了简化。 * **高阶函数是什么?**答:函数作为参数or返回值。 * **函数类型是什么?**答:函数的类型。 * **函数引用是什么?**答:类比变量的引用。 * **Lambda是什么?**答:可以简单理解为“函数的简写”(官方定义我们以后再讨论)。 * **带接收者的函数类型是什么?**答:可以简单理解为“成员函数的类型”。 ![图片](https://static001.geekbang.org/resource/image/45/3e/45e23ce48a64711943366fec784af23e.jpg?wh=1920x1171) 事实上,对于初学者来说,要一下子理解并掌握Kotlin“高阶函数”不是一件容易的事情。在掌握好这节课内容的基础上,我们可以尝试去读一些优秀的代码。 比如Kotlin官方的源代码[StandardKt](https://github.com/JetBrains/kotlin/blob/1.5.30/libraries/stdlib/src/kotlin/util/Standard.kt),你可以去分析其中的with、let、also、takeIf、repeat、apply,来进一步加深对高阶函数的理解。还有就是[CollectionsKt](https://github.com/JetBrains/kotlin/blob/1.5.30/libraries/stdlib/common/src/generated/_Collections.kt),你可以去分析其中的map、flatMap、fold、groupBy等操作符,从而对高阶函数的应用场景有一个更具体的认知。 另外,在[第](https://time.geekbang.org/column/article/473349)[2讲](https://time.geekbang.org/column/article/473349)的时候,我们曾经提到过:理论上讲,Kotlin与Java是完全兼容的。那么问题来了,**Kotlin引入全新的高阶函数,最终变成JVM字节码后是怎么执行呢?**毕竟,JVM可不知道什么是高阶函数啊。 答案其实也很简单:**匿名内部类**。 而这样又引出了另一个问题:所以Kotlin弄了个这么高端的高阶函数,最终还是以匿名内部类的形式在运行呗?那它们两者的性能差不多?这不是多此一举吗? 答案当然是否定的,Kotlin高阶函数的性能,在极端情况下可以达到匿名内部类的100倍!具体是怎么回事儿呢?别着急,下节课讲“inline”时,我们就会来详细探讨。 ## 小作业 请你去阅读一下Kotlin官方的标准函数库[StandardKt的源代码](https://github.com/JetBrains/kotlin/blob/1.5.30/libraries/stdlib/src/kotlin/util/Standard.kt),尝试去解析其中任意一个高阶函数的原理和意义,并分享出来,我们一起探讨。 ```plain public inline fun run(block: () -> R): R { contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } return block() } public inline fun T.run(block: T.() -> R): R { contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } return block() } public inline fun with(receiver: T, block: T.() -> R): R { contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } return receiver.block() } public inline fun T.apply(block: T.() -> Unit): T { contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } block() return this } public inline fun T.also(block: (T) -> Unit): T { contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } block(this) return this } public inline fun T.let(block: (T) -> R): R { contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } return block(this) } public inline fun T.takeIf(predicate: (T) -> Boolean): T? { contract { callsInPlace(predicate, InvocationKind.EXACTLY_ONCE) } return if (predicate(this)) this else null } public inline fun 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) } } ``` 好了,这节课就到这里,如果觉得有收获,非常欢迎你把今天的内容分享给更多的朋友,咱们下节课再见。