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 / ()->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

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

  • 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)。


参考资料


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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,826评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,968评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,234评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,562评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,611评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,482评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,271评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,166评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,608评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,814评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,926评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,644评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,249评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,866评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,991评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,063评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,871评论 2 354