阅读 1747

Kotlin | 扩展函数(终于知道为什么 with 用 this,let 用 it)

点赞关注,不再迷路,你的支持对我意义重大!

🔥 Hi,我是丑丑。本文 「Android 路线」| 导读 —— 从零到无穷大 已收录,这里有 Android 进阶成长路线笔记 & 博客,欢迎跟着彭丑丑一起成长。(联系方式在 GitHub)


历史上的今天

2000 年 2 月 29 日,R 1.0.0 正式发布。 R 语言最初是由新西兰奥克兰大学的罗斯·伊哈卡和罗伯特·杰特曼开发的,由来由 “R 开发核心团队” 负责。R 是基于 S 语言的一个 GNU 计划项目,语法来自 Schema,主要用于统计分析、绘图和数据挖掘。RStudio 是针对 R 语言设计的广泛使用的集成开发环境。 —— 《了不起的程序员》


前言

扩展是 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/()->RR
T.run/T.()->RR
withTT.()->RR
T.apply/T.()->UnitT
T.also/(T)->UnitT
T.let/(T)-RR

还是一脸懵逼,那我提几个问题:

  • runvsT.run,差了一个T,区别是什么?

区别在于:run是普通函数,T.run是扩展函数。run中的this是声明的类对象(顶级函数除外),T.run中的this是接收者对象;

  • T.()->Unitvs(T)->Unit,或者T.()->Rvs(T)->R,T 的位置不同,区别是什么?

区别在于:T.()->Unit中的 T 是接收者类型,(T)->Unit中的 T 是函数参数;

  • 为什么withthisletit
    • 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)。


参考资料


创作不易,你的「三连」是丑丑最大的动力,我们下次见!

文章分类
Android
文章标签