Kotlin infix 关键字与高阶函数的应用[第一行代码 Kotlin 学习笔记]
使用 infix 函数构建更可读的语法
在前面的 Kotlin 学习笔记中,我们已经多次使用过 A to B 这样的语法结构构建键值对,包括 Kotlin 自带的 mapOf() 函数。
这种语法结构的优点是可读性高,相比于调用一个函数,它更接近于使用英语的语法来编写程序。可能你会好奇,这种功能是怎么实现的呢?to 是不是 Kotlin 语言中的一个关键字?本节我们就对这个功能进行深度解密。
首先,to 并不是 Kotlin 语言中的一个关键字,之所以我们能够使用 A to B 这样的语法结构,是因为 Kotlin 提供了一种高级语法糖特性:infix 函数。当然,infix 函数也并不是什么难理解的事物,它只是把编程语言函数调用的语法规则调整了一下而已,比如 A to B 这样的写法,实际上等价于 A.to(B) 的写法。
下面我们就通过两个具体的例子来学习一下 infix 函数的用法,先从简单的例子看起。
String 类中有一个 startsWith() 函数,你一定使用过,它可以用于判断一个字符串是否是以某个指定参数开头的。比如说下面这段代码的判断结果一定会是 true:
if ("Hello Kotlin".startsWith("Hello")) {
// 处理具体的逻辑
}
startsWith() 函数的用法虽然非常简单,但是借助 infix 函数,我们可以使用一种更具可读性的语法来表达这段代码。新建一个 infix.kt 文件,然后编写如下代码:
infix fun String.beginsWith(prefix: String) = startsWith(prefix)
首先,除去最前面的 infix 关键字不谈,这是一个 String 类的扩展函数。我们给 String 类添加了一个 beginsWith() 函数,它也是用于判断一个字符串是否是以某个指定参数开头的,并且它的内部实现就是调用的 String 类的 startsWith() 函数。
但是加上了 infix 关键字之后,beginsWith() 函数就变成了一个 infix 函数,这样除了传统的函数调用方式之外,我们还可以用一种特殊的语法糖格式调用 beginsWith() 函数,如下所示:
if ("Hello World" beginsWith "Hello") {
// 处理具体的逻辑
}
从这个例子就能看出,infix 函数的语法规则并不复杂,上述代码其实就是调用的 " HelloKotlin " 这个字符串的 beginsWith() 函数,并传入了一个 "Hello" 字符串作为参数。但是 infix 函数允许我们将函数调用时的小数点、括号等计算机相关的语法去掉,从而使用一种更接近英语的语法来编写程序,让代码看起来更加具有可读性。
另外,infix 函数由于其语法糖格式的特殊性,有两个比较严格的限制:首先,infix 函数是不能定义成顶层函数的,它必须是某个类的成员函数,可以使用扩展函数的方式将它定义到某个类当中;其次,infix 函数必须接收且只能接收一个参数,至于参数类型是没有限制的。只有同时满足这两点, infix 函数的语法糖才具备使用的条件,你可以思考一下是不是这个道理。
看完了简单的例子,接下来我们再看一个复杂一些的例子。比如这里有一个集合,如果想要判断集合中是否包括某个指定元素,一般可以这样写:
val list = listOf("Apple", "Banana", "Orange", "Pear", "Grape")
if (list.contains("Banana")) {
// 处理具体的逻辑
}
很简单对吗?但我们仍然可以借助 infix 函数让这段代码变得更加具有可读性。在 infix.kt 文件中添加如下代码:
infix fun <T> Collections<T>.has(element: T) = contains(element)
可以看到,我们给 Collection 接口添加了一个扩展函数,这是因为 Collection 是 Java 以及 Kotlin 所有集合的总接口,因此给 Collection 添加一个 has() 函数,那么所有集合的子类就都可以使用这个函数了。
另外,这里还使用了泛型函数的定义方法,从而使得 has() 函数可以接收任意具体类型的参数。而这个函数内部的实现逻辑就相当简单了,只是调用了 Collection 接口中的 contains() 函数而已。也就是说,has() 函数和 contains() 函数的功能实际上是一模一样的,只是它多了一个 infix 关键字,从而拥有了 infix 函数的语法糖功能。
现在我们就可以使用如下的语法来判断集合中是否包括某个指定的元素:
val list = listOf("Apple", "Banana", "Orange", "Pear", "Grape")
if (list has "Banana") {
// 处理具体的逻辑
}
好了,两个例子都已经看完了,你对于 infix 函数应该也了解得差不多了。但是或许现在你的心中还有一个疑惑没有解开,就是 mapOf() 函数中允许我们使用 A to B 这样的语法来构建键值对,它的具体实现是怎样的呢?为了解开谜团,我们直接来看一看 to() 函数的源码吧,如下所示:
public infix fun <A, B> A.to(that: B): Pair<A, B> = Pair(this, that)
可以看到,这里使用定义泛型函数的方式将 to() 函数定义到了 A 类型下,并且接收一个 B 类型的参数。因此 A 和 B 可以是两种不同类型的泛型,也就使得我们可以构建出字符串 to 整型这样的键值对。
再来看 to() 函数的具体实现,非常简单,就是创建并返回了一个 Pair 对象。也就是说,A to B 这样的语法结构实际上得到的是一个包含 A、B 数据的 Pair 对象,而 mapOf() 函数实际上接收的正是一个 Pair 类型的可变参数列表,这样我们就将这种神奇的语法结构完全解密了。
本着动手实践的精神,其实我们也可以模仿 to() 函数的源码来编写一个自己的键值对构建函数。在 infix.kt 文件中添加如下代码:
infix fun <A, B> A.with(that: B): Pair<A, B> = Pair(this, that)
这里只是将 to() 函数改名成了 with() 函数,其他实现逻辑是相同的,因此相信没有什么解释的必要。现在我们的项目中就可以使用 with() 函数来构建键值对了,还可以将构建的键值对传入 mapOf() 方法中:
val map = mapOf("Apple" with 1, "Banana" with 2, "Orange" with 3, "Pear" with 4, "Grape" with 5)
是不是很神奇?这就是 infix 函数给我们带来的诸多有意思的功能,灵活运用它确实可以让语法变得更具可读性。
高阶函数的应用
高阶函数非常适用于简化各种 API 的调用,一些 API 的原有用法在使用高阶函数简化之后,不管是在易用性还是可读性方面,都可能会有很大的提升。
为了进行举例说明,我们在本节会使用高阶函数简化 SharedPreferences 和 ContentValues 这两种 API 的用法,让它们的使用变得更加简单。
简化 SharedPreferences 的用法
首先来看 SharedPreferences,在开始对它进行简化之前,我们先回顾一下 SharedPreferences 原来的用法。向 SharedPreferences 中存储数据的过程大致可以分为以下 3 步:
- 调用 SharedPreferences 的 edit() 方法获取 SharedPreferences.Editor 对象;
- 向 SharedPreferences.Editor 对象中添加数据;
- 调用 apply() 方法将添加的数据提交,完成数据存储操作。
对应的代码示例如下:
val editor = getSharedPreferences("data", Context.MODE_PRIVATE).edit()
editor.putString("name", "Tom")
editor.putInt("age", 28)
editor.putBoolean("married", false)
editor.apply()
当然,这段代码其实本身已经足够简单了,但是这种写法更多还是在用 Java 的编程思维来编写代码,而在 Kotlin 当中我们明显可以做到更好。
接下来我们就尝试使用高阶函数简化 SharedPreferences 的用法,新建一个 SharedPreferences.kt 文件,然后在里面加入如下代码:
fun SharedPreferences.open(block: SharedPreferences.Editor.() -> Unit) {
val editor = edit()
editor.block()
editor.apply()
}
这段代码虽然不长,但是涵盖了高阶函数的各种精华,下面我来解释一下。
首先,我们通过扩展函数的方式向 SharedPreferences 类中添加了一个 open 函数,并且它还接收一个函数类型的参数,因此 open 函数自然就是一个高阶函数了。
由于 open 函数内拥有 SharedPreferences 的上下文,因此这里可以直接调用 edit() 方法来获取 SharedPreferences.Editor 对象。另外 open 函数接收的是一个 SharedPreferences.Editor 的函数类型参数,因此这里需要调用 editor.block() 对函数类型参数进行调用,我们就可以在函数类型参数的具体实现中添加数据了。最后还要调用 editor.apply() 方法来提交数据,从而完成数据存储操作。
定义好了 open 函数之后,我们以后在项目中使用 SharedPreferences 存储数据就会更加方便了,写法如下所示:
getSharedPreferences("data", Context.MODE_PRIVATE).open {
putString("name", "Tom")
putInt("age", 28)
putBoolean("married", false)
}
可以看到,我们可以直接在 SharedPreferences 对象上调用 open 函数,然后在 Lambda 表达式中完成数据的添加操作。注意,现在 Lambda 表达式拥有的是 SharedPreferences.Editor 的上下文环境,因此这里可以直接调用相应的 put 方法来添加数据。最后我们也不再需要调用 apply() 方法来提交数据了,因为 open 函数会自动完成提交操作。
怎么样,使用高阶函数简化之后,不管是在易用性还是在可读性上,SharedPreferences 的用法是不是都简化了很多?这就是高阶函数的魅力所在。好好掌握这个知识点,以后在诸多其他 API 的使用方面,我们都可以使用这个技巧,让API变得更加简单。
当然,最后不得不提的是,其实 Google 提供的 KTX 扩展库中已经包含了上述 SharedPreferences 的简化用法,这个扩展库会在 Android Studio 创建项目的时候自动引入 build.gradle 的 dependencies 中。
因此,我们实际上可以直接在项目中使用如下写法来向 SharedPreferences 存储数据:
getSharedPreferences("data", Context.MODE_PRIVATE).edit {
putString("name", "Tom")
putInt("age", 28)
putBoolean("married", false)
}
可以看到,其实就是将 open 函数换成了 edit 函数,但是 edit 函数的语义性明显要更好一些。当然,我前面命名成 open 函数,主要是为了防止和 KTX 的 edit 函数同名,以免你在理解的时候产生混淆。
那么你可能会问了,既然 Google 的 KTX 库中已经自带了一个 edit 函数,我们为什么还编写这个 open 函数呢?这是因为我希望你对于高阶函数的理解不要仅仅停留在使用的层面,而是要知其然也知其所以然。KTX 中提供的功能必然是有限的,但是掌握了它们背后的实现原理,你将可以对无限的 API 进行更多的扩展。
简化 ContentValues 的用法
接下来我们开始学习如何简化 ContentValues 的用法。
ContentValues 的基本用法在 7.4 节中已经学过了,它主要用于结合 SQLiteDatabase 的 API 存储和修改数据库中的数据,具体的用法示例如下:
val values = ContentValues()
values.put("name", "Game of Thrones")
values.put("author", "George Martin")
values.put("pages", 720)
values.put("price", 20.85)
db.insert("Book", null, values)
你可能会说,这段代码可以使用 apply 函数进行简化。这当然没有错,只是我们其实还可以做到更好。
不过在正式开始我们的简化之旅之前,我还得向你介绍一个额外的知识点。还记得在 2.6.1 小节中学过的 mapOf() 函数的用法吗?它允许我们使用 "Apple" to 1 这样的语法结构快速创建一个键值对。这里我先为你进行部分解密,在 Kotlin 中使用 A to B 这样的语法结构会创建一个 Pair 对象,暂时你只需要知道这些就可以了,至于为什么,我们将在第 9 章的 Kotlin 课堂中学习。
有了这个知识前提之后,就可以进行下一步了。新建一个 ContentValues.kt 文件,然后在里面定义一个 cvOf() 方法,如下所示:
fun cvOf(vararg pairs: Pair<String, Any?>): ContentValues {
}
这个方法的作用是构建一个 ContentValues 对象,有几点我需要解释一下。首先,cvOf() 方法接收了一个 Pair 参数,也就是使用 A to B 语法结构创建出来的参数类型,但是我们在参数前面加上了一个 vararg 关键字,这是什么意思呢?其实 vararg 对应的就是 Java 中的可变参数列表,我们允许向这个方法传入 0 个、1 个、2 个甚至任意多个 Pair 类型的参数,这些参数都会被赋值到使用 vararg 声明的这一个变量上面,然后使用 for-in 循环可以将传入的所有参数遍历出来。
再来看声明的 Pair 类型。由于 Pair 是一种键值对的数据结构,因此需要通过泛型来指定它的键和值分别对应什么类型的数据。值得庆幸的是,ContentValues 的所有键都是字符串类型的,这里可以直接将 Pair 键的泛型指定成 String。但 ContentValues 的值却可以有多种类型(字符串型、整型、浮点型,甚至是 null),所以我们需要将 Pair 值的泛型指定成 Any?。这是因为 Any 是 Kotlin 中所有类的共同基类,相当于 Java 中的 Object,而 Any? 则表示允许传入空值。
接下来我们开始为 cvOf() 方法实现功能逻辑,核心思路就是先创建一个 ContentValues 对象,然后遍历 pairs 参数列表,取出其中的数据并填入 ContentValues 中,最终将 ContentValues 对象返回即可。思路并不复杂,但是存在一个问题:Pair 参数的值是 Any? 类型的,我们怎样让它和 ContentValues 所支持的数据类型对应起来呢?这个确实没有什么好的办法,只能使用 when 语句一一进行条件判断,并覆盖 ContentValues 所支持的所有数据类型。结合下面的代码来理解应该更加清楚一些:
fun cvOf(vararg pairs: Pair<String, Any?>): ContentValues {
val cv = ContentValues()
for (pair in pairs) {
val key = pair.first
val value = pair.second
when (value) {
is Int -> cv.put(key, value)
is Long -> cv.put(key, value)
is Short -> cv.put(key, value)
is Float -> cv.put(key, value)
is Double -> cv.put(key, value)
is Boolean -> cv.put(key, value)
is String -> cv.put(key, value)
is Byte -> cv.put(key, value)
is ByteArray -> cv.put(key, value)
null -> cv.putNull(key)
}
}
return cv
}
可以看到,上述代码基本就是按照刚才所说的思路进行实现的。我们使用 for-in 循环遍历了 pairs 参数列表,在循环中取出了 key 和 value,并使用 when 语句来判断 value 的类型。注意,这里将 ContentValues 所支持的所有数据类型全部覆盖了进去,然后将参数中传入的键值对逐个添加到 ContentValues 中,最终将 ContentValues 返回。
另外,这里还使用了 Kotlin 中的 Smart Cast 功能。比如 when 语句进入 Int 条件分支后,这个条件下面的 value 会被自动转换成 Int 类型,而不再是 Any? 类型,这样我们就不需要像 Java 中那样再额外进行一次向下转型了,这个功能在 if 语句中也同样适用。
有了这个 cvOf() 方法之后,我们使用 ContentValues 时就会变得更加简单了,比如向数据库中插入一条数据就可以这样写:
val values = cvOf("name" to "Game of Thrones", "author" to "George Martin", "pages" to 720, "price" to 20.85)
db.insert("Book", null, values)
怎么样?现在我们可以使用类似于 mapOf() 函数的语法结构来构建 ContentValues 对象,有没有觉得很神奇?
当然,虽然 cvOf() 方法已经非常好用了,但是它和高阶函数却一点关系也没有。因为 cvOf() 方法接收的参数是 Pair 类型的可变参数列表,返回值是 ContentValues 对象,完全没有用到函数类型,这和高阶函数的定义不符。
从功能性方面,cvOf() 方法好像确实用不到高阶函数的知识,但是从代码实现方面,却可以结合高阶函数来进行进一步的优化。比如借助 apply 函数,cvOf() 方法的实现将会变得更加优雅:
fun cvOf(vararg pairs: Pair<String, Any?>) = ContentValues().apply {
for (pair in pairs) {
val key = pair.first
when (val value = pair.second) {
is Int -> put(key, value)
is Long -> put(key, value)
is Short -> put(key, value)
is Float -> put(key, value)
is Double -> put(key, value)
is Boolean -> put(key, value)
is String -> put(key, value)
is Byte -> put(key, value)
is ByteArray -> put(key, value)
null -> putNull(key)
}
}
}
由于 apply 函数的返回值就是它的调用对象本身,因此这里我们可以使用单行代码函数的语法糖,用等号替代返回值的声明。另外,apply 函数的 Lambda 表达式中会自动拥有 ContentValues 的上下文,所以这里可以直接调用 ContentValues 的各种 put 方法。借助高阶函数之后,你有没有觉得代码变得更加优雅一些了呢?
当然,虽然我们编写了一个非常好用的 cvOf() 方法,但是或许你已经猜到了,KTX 库中也提供了一个具有同样功能的 contentValuesOf() 方法,用法如下所示:
val values = contentValuesOf("name" to "Game of Thrones", "author" to "George Martin", "pages" to 720, "price" to 20.85)
db.insert("Book", null, values)
平时我们在编写代码的时候,直接使用 KTX 提供的 contentValuesOf() 方法就可以了,但是通过本小节的学习,你不仅掌握了它的用法,还明白了它的源码实现,有没有觉得收获了更多呢?