前言
扩展是 Kotlin 的一种语言特性,即:在不修改类 / 不继承类的情况下,向一个类添加新函数或者新属性。扩展使我们可以合理地遵循开闭原则,在大多数情况下是比继承更好的选择。
目录
前置知识
这篇文章的内容会涉及以下前置 / 相关知识,贴心的我都帮你准备好了,请享用~
1. 为什么要使用扩展?
在 Java 中,我们习惯于把通用代码封装到工具类中,诸如 StringUtils、ViewUtils 等,例如:
StringUtils.java
public static void firstChar(String str) { ... } 复制代码
在使用时,我们就需要调用StringUtils.firstChar(str)
。然而,这种传统的调用方式不够简单直接,表意性也不够强,会让调用方忽略 String 和 firstChar() 间的强联系。另外,调用方也希望省略 StringUtils 类名,让 firstChar() 看起来更像是 String 内部的一个属性和方法,像这样:"str".firstChar()
。
要实现这种方式,在 Java 中就需要修改或继承 String 类,然而 String 是 JDK 中的 final 类,不能修改或继承。
这个时候可以使用 Kotlin 扩展来解决这个问题,我们可以把 firstChar 定义为 String 的扩展函数:
StringUtils.kt
定义 String 的扩展函数 fun String.firstChar() { ... } 复制代码
此时,在使用时可以采用"str".firstChar()
的方式。在这里我们扩展了 String 类,却没有修改或继承 String。
总结:扩展是 Kotlin 中的一种特性,可以 在不修改类 / 不继承类的情况下,向一个类添加新函数或者新属性,更符合开闭原则。
开闭原则(OCP,Open Closed Principle)
开闭原则是面向对象软件设计的原则之一,即:对扩展开放,而对修改是封闭的。
2. 扩展函数 & 扩展属性
2.1 声明扩展
声明扩展非常简单,只需要在声明时增加「类或者接口名」。这个类的名称称为 接收者类型(receiver type),调用这个扩展的对象称为 接收者对象。大多数情况下,扩展会声明为「顶级成员」,例如:
Utils.kt
声明扩展函数: fun <T : Any?> MutableList<T>.exchange(fromIndex: Int, toIndex: Int) { val temp = this[fromIndex] this[fromIndex] = this[toIndex] this[toIndex] = temp } 声明扩展属性: val MutableList<Int>.sumIsEven get() = this.sum() % 2 == 0 复制代码
在使用时,就可以直接像使用普通成员函数 / 属性一样:
xxx.kt
val list = mutableListOf(1,2,3) 使用扩展函数: list.exchange(1,2) 使用扩展属性: val isEven = list.sumIsEven 复制代码
提示: MutableList 是接收者类型,list 是接收者对象。
在扩展函数内部,你可以像 「成员函数」 那样使用this
来引用接受者对象,当然有时也可以省略,例如:
声明扩展属性: val MutableList<Int>.sumIsEven get() = this.sum() % 2 == 0 // 省略了 this.sum() 中的 this 复制代码
2.2 可空接收者
在 第 2.1 节 中使用了「非空的接收者类型」来定义扩展(MutableList 没有关键词?
),当使用「可空变量」调用扩展时,会报编译时错误。例如:
val list:MutableList<Int>? = null list.sumIsEven // Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type MutableList<Int>? 复制代码
根据提示,我们知道可以 使用「可空的接收者类型」来定义扩展,同时还要在内部使用null == this
来对接收者对象进行判空。例如:
可空接收者类型的扩展函数 fun <T : Any?> MutableList<T>?.exchange(fromIndex: Int, toIndex: Int) { if (null == this) return val temp = this[fromIndex] this[fromIndex] = this[toIndex] this[toIndex] = temp } 可空接收者类型的扩展属性 val MutableList<Int>?.sumIsEven: Boolean get() = if (null == this) false else this.sum() % 2 == 0 复制代码
2.3 在 Java 中调用
扩展的本质:扩展函数是定义在类外部的静态函数,函数的第一个参数是接收者类型的对象。这意味着调用扩展时不会创建适配对象或者任何运行时的额外消耗。
在 Java 中,我们只需要像调用普通静态方法那样调用扩展即可。例如:
xxx.java
ArrayList<Integer> list = new ArrayList<>(3); 使用扩展函数: UtilsKt.exchange(list, 1, 2); 使用扩展属性: boolean isEven = UtilsKt.getSumIsEven(list); 复制代码
2.4 扩展的作用域
当你定义了一个扩展之后,它不会自动在整个项目内生效。在其它包路径下,需要使用improt
导入。例如:
import Utils.exchange 或 import Utils.* 复制代码
当你在不同包中定义了 「重名扩展」,并且需要在同一个文件中去使用它们,那么你需要使用as
关键字重新命名。例如:
import Utils.exchange as swap 使用时: list.swap(0,1) 复制代码
2.5 注意事项
- 1、扩展函数不能访问 private 或 protected 成员
扩展函数或扩展属性本质上是定义在类外部的静态方法,因此扩展不可能打破类的封装性而去调用 private 或 protected 成员;
- 2、不能重写扩展函数
扩展函数在 Java 中会被编译为静态函数,并不是类的一部分,不具备多态性。尽管你可以给父类和子类都定义一个同名的扩展函数,看起来像是方法重写,但实际上两个函数没有任何关系。当这个函数被调用时,具体调用的函数版本取决于变量的 「静态类型」,而不是 「动态类型」。
静态方法调用
关于 Java 方法调用的本质,在我之前写过的一篇文章里系统分析过:Java | 方法调用的本质(含重载与重写区别)。静态方法调用在编译后生成
invokestatic
字节码指令,它的处理逻辑如下:
- 1、编译阶段:确定方法的符号引用,并固化到字节码中方法调用指令的参数中;
- 2、类加载解析阶段:根据符号引用中类名,在对应的类中找到简单名称与描述符相符合的方法,如果找到则将符号引用转换为直接引用;否则,按照继承关系从下往上依次在各个父类中搜索;
- 3、调用阶段:符号引用已经转换为直接引用;调用
invokestatic
不需要将对象加载到操作数栈,只需要将所需要的参数入栈就可以执行invokestatic
指令。
- 3、如果类的成员函数和扩展函数拥有相同的签名,成员函数优先
- 4、扩展属性没有支持字段,不会保存任何状态
扩展属性是没有状态的,必须定义 getter 访问器。因为不可能给现有的 Java 类添加额外的字段,所以也就没有地方可以存储支持字段。举个例子,以下代码是编译错误的:
val MutableList<Int>?.sumIsEven: Boolean = true // (X) Initializer is not allowed here because this property has no backing field get() = if (null == this) false else this.sum() % 2 == 0 复制代码
3. 标准库中的函数
在 Kotlin 标准库中,定义了一系列通用的内联函数:T.apply、T.also、T.let、T.run、with
。你是否清楚理解它们的用法 & 本质,它们都是扩展函数吗?
val str1: String = "".run { println(this.length) this } val str2: String = with("") { println(this.length) this } val str3: String = "".apply { println(this.length) } val str4: String = "".also { println(it.length) } val str5: String = "".let { println(it.length) it } 复制代码
在上面的示例中,我们看到有的函数作用域内使用了this
,而其它又使用了it
。这两个关键字到底引用的是什么,为什么会有差别呢?
我们先找到这些函数的声明:
standard.kt
public inline fun <R> run(block: () -> R): R { return block() } public inline fun <T, R> T.run(block: T.() -> R): R { return block() } public inline fun <T, R> with(receiver: T, block: T.() -> R): R { return receiver.block() } public inline fun <T> T.apply(block: T.() -> Unit): T { block() return this } public inline fun <T> T.also(block: (T) -> Unit): T { block(this) return this } public inline fun <T, R> T.let(block: (T) -> R): R { return block(this) } 复制代码
一脸懵逼,别急,我们梳理一下:
函数 | 参数1 | 参数2 | 返回值 |
run |
/ | ()->R |
R |
T.run |
/ | T.()->R |
R |
with |
T |
T.()->R |
R |
T.apply |
/ | T.()->Unit |
T |
T.also |
/ | (T)->Unit |
T |
T.let |
/ | (T)->R |
R |
还是一脸懵逼,那我提几个问题:
run
vsT.run
,差了一个T
,区别是什么?区别在于:run
是普通函数,T.run
是扩展函数。run
中的this
是声明的类对象(顶级函数除外),T.run
中的this
是接收者对象;T.()->Unit
vs(T)->Unit
,或者T.()->R
vs(T)->R
,T 的位置不同,区别是什么?区别在于:T.()->Unit
中的 T 是接收者类型,(T)->Unit
中的 T 是函数参数;- 为什么
with
用this
,let
用it
?
- run、with、apply 函数中的参数 block 是 「T 的扩展函数」,所以采用 this 是扩展函数的接收者对象(receiver)。另外因为 block 没有参数,所以不存在 it 的定义。
- also 和 let 参数 block 是 「参数为 T 的函数」,所以采用 it 是唯一参数(argument)。另外因为 block 不是扩展函数,所以不存在 this 的定义。
lambda 表达式
lambda 表达式本质上是 「可以作为值传递的代码块」。在老版本 Java 中,传递代码块需要使用匿名内部类实现,而使用 lambda 表达式甚至连函数声明都不需要,可以直接传递代码块作为函数值。
当 lambda 表达式只有一个参数,可以用
it
关键字来引用唯一的实参。
4. 扩展的应用场景
在这一节里,我们来介绍一些在 Android 开发中使用扩展的应用场景。
4.1 封装工具 Utils
在 Java 中,我们习惯于把通用代码封装到工具类中。传统 Java 的工具方法的调用方式不够简单直接,表意性也不够强,会让调用方忽略 String 和 firstChar() 间的强联系。另外,调用方也希望省略 StringUtils 类名,让 firstChar() 看起来更像是 String 内部的一个属性和方法。这些需求对 Kotlin 扩展来说都不是问题。
4.2 解决烦人的 findViewById
在 Android 中,经常会调用 findViewById() 来找到视图树中的某一个 View 实例,例如:
旧版 SDK:loginButton = (Button) findViewById(R.id.btn_login); 新版 SDK:loginButton = findViewById(R.id.btn_login); 复制代码
提示: 新版的 SDK 中,findViewById() 是一个泛型方法,所以你就不再需要进行强制类型转换。
public <T extends View> T findViewById(@IdRes int id) { return getWindow().findViewById(id); } 复制代码
通常,我们会定义一个实例变量或者局部变量来承载 findViewById() 的返回值,很多时候,这些变量都只是 “临时变量” ,在进行事件绑定 / 赋值之后就没有很大的用处了。如果你面对一些比较复杂的界面,你甚至需要定义几十行临时变量!
能不能省略这些临时变量,直接操作R.id.*
呢?答案是可以的,我们可以利用 Kotlin 「高阶函数 + 扩展函数」。例如:
fun Int.onClick(click: () -> Unit) { findViewById<View>(this).apply { setOnClickListener { click() } } } 复制代码
此时,我们可以直接使用R.id.*
来绑定点击事件(R.id* 本质就是一个整数类型):
R.id.btn_login.onClick { // do something } 复制代码
这样就简洁多了,我们就不再需要定义一堆临时变量了。不过,你每次都需要写R.id
前缀,这似乎也很多余,能不能再省略呢?确实可以,我们需要使用 Kotlin 为 Android 量身定制的 Gradle 插件:kotlin-android-extensions
。
apply plugin : 'kotlin-android-extension' 复制代码
此时,我们可以直接用组件的 id 来操作 View 实例,例如:MainActivity .java
btn_login.setOnClickListener{ // do something } 复制代码
我们试试反编译这段代码,可以看到kotlin-android-extensions
插件自动在 Activity 类中插入了以下代码:
MainActivity.class
public class MainActivity extends AppCompatActivity { private HashMap _$_findViewCache; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); ((Button) this._$_findViewCache(id.btn_login)).setOnClickListener((View.OnClickListener) null.INSTANCE); } public View _$_findCachedViewById(int var1) { if (this._$_findViewCache == null) { this._$_findViewCache = new HashMap(); } View var2 = (View) this._$_findViewCache.get(Integer.valueOf(var1)); if (var2 == null) { var2 = findViewById(var1); this._$_findViewCache.put(Integer.valueOf(var1), var2); } return var2; } public void _$_clearFindViewByIdCache() { if (this._$_findViewCache != null) { this._$_findViewCache.clear(); } } } 复制代码
可以看到,在访问R.id.*
控件时,先在缓存集合_$_findViewCache
中查找,有就直接返回,没有就通过 findViewById() 进行查找,并添加到缓存集合中。
另外还提供了一个_$_clearFindViewByIdCache()方法,用于在彻底替换界面视图时清除彻底缓存。在 Fragment#onDestroyView() 中,会调用该方法清除缓存,而 Activity 中没有。
4.3 简洁的 LeetCode 题解
在解算法题时,使用扩展函数可以让代码更简洁,表意性更强。举个例子,我们需要交换数组中的两个位置上的元素。相对于传统的写法,可以看到扩展函数的写法意思更清楚。
fun swap(arr: IntArray, from: Int, to: Int) { ... } swap(arr,0,1) fun IntArray.swap(from: Int, toInt) { ... } arr.swap(0,1) 复制代码
5. 总结
- 扩展可以在不修改类 / 不继承类的情况下,向一个类添加新函数或者新属性,更符合开闭原则。相对于传统 Java 的工具方法的调用方式更简单直接,表意性更强;
- 扩展函数是定义在类外部的静态函数,函数的第一个参数是接收者类型,调用扩展时不会创建适配对象或者任何运行时的额外消耗。在 Java 中,我们只需要像调用普通静态方法那样调用扩展即可;
- 标准库提供的函数中,run、with、apply 函数中的参数 block 是「T 的扩展函数」,所以采用 this 是扩展函数的接收者对象(receiver);also 和 let 参数 block 是「参数为 T 的函数」,所以采用 it 是唯一参数(argument)。