Blog


12. Kotlin 作用域函数(scope function)run/let/apply/also

0. 绕不开的四兄弟

学习 Kotlin 一定绕不开 run/let/apply/also 这四兄弟,它们是 Kotlin 使用频率最高的扩展方法(扩展方法在之前文章有介绍),它们也被称为作用域函数(scope functions)。今天我们就来了解一下它们。本文依然是按代码比较,字节码分析,和扩展思考三个方面进行分析。

搞懂其中一个,其他作用域函数可以视为其变种。这篇文章我们先看 run 方法。

1. run 方法使用

在工程中,我们有一段这样的 Java 代码:

public class PlayManager {
    /** 初始值为空,需要在资源初始化之后再拿到对象 */
    private Player player = null;

    /** 播放 */
    public void play(String path) {
        if (player != null) {
            player.init(path);
            player.prepare();
            player.start();
        }
    }
}

Kotlin等效代码为:

public class PlayManager {
    /** 初始值为空,需要在资源初始化之后再拿到对象 */
    private var player: Player? = null

    /** 播放 */
    fun play(path: String) {
        player?.init(path)
        player?.prepare()
        player?.start()
    }
}

如果用上 Kotlin 的 run 会是这样的:

public class PlayManager {
    /** 初始值为空,需要在资源初始化之后再拿到对象 */
    private var player: Player? = null

    /** 播放 */
    fun play(path: String) {
        player?.run {
            init(path)
            prepare()
            start()
        }
    }
}

这里的 run 调用是一种函数调用的特殊写法,即当 lambda 作为函数的最后一个参数时,可以写在函数括号外部,也就是说object.run { }object.run({ })是等价的。这种写法的好处在我看来,一是不用再去末尾数括号了,写 Java 的时候声明一个匿名类比如View.OnClickListener,总是忘了加括号,在 Kotlin 没有这个烦恼;二是像runmap 这种以函数作为参数的高阶函数,代码写起来看起来都更简洁利落。

看起来简洁了不少。

run的功能很简单,它就做了两件事:

  1. 把 lambda 内部的 this 改成了对应的调用对象。这个看起来很神奇,我们稍后再分析;
  2. run 函数会返回 lambda 的返回值。

run 方法达到了三个效果:

  1. 因为this 的变化,不再需要重复的输入变量,和链式调用异曲同工,但你并不需要额外花费精力来编写链式调用的代码;

  2. 把可空对象转换为了非空对象,因为run方法是问号调用,player 不为空才会执行。因为考虑到并发,Kotlin 要求每次调用可空属性的时候都进行判空,如此一来属性这个小朋友就会有很多问号。。使用 run 方法等效于先把可空属性用临时变量持有再使用,这样就消除了并发竞争的影响(Java 经常也有这种代码,不过要自己手写罢了)。

  3. 在一个函数里声明的这个一个小“代码块“,表示和其他无关的代码隔离,实现了函数内部的高内聚。这个效果可以增加代码的可读性,让人一看就明白:“哦,这是针对这个对象的一系列操作,这个函数里关于这个对象的使用我只需要关注这个代码块就可以了”。

第三点是我尤其喜欢的一个点,我觉得这样的设计不仅是为了提高开发效率,它更是在引导开发者写出好维护的代码。在写 Java 的时候,大家都很容易不自觉的写出某个对象在函数头操作一下,隔几行调用一下,隔几行又操作一下的代码。阅读者很容易误以为这些代码之间有着顺序上的耦合,从而继续按照这个“隐含的规则“来维护代码。却不知当时的开发者只是想到哪写到哪,实际并不存在这样的隐含关系。使用 run 可以在函数内部快速建立起一个个代码块,让函数拥有更清晰的结构,又不用花费很大精力把代码块拆成一个个小函数,毕竟给函数起名字可是非常头秃的事情。

说到不用起名字,lambda 本身就有“匿名函数”的外号,这样的使用方法可以说十分贴切了。而从耦合程度来看,代码块介于函数和过程代码之间。

函数是面向过程的产物,它天生就很容易产生耦合度高的代码。就我看来,作用域函数更像是给函数打上的一个“补丁”。

3. run 方法代码分析

run 源码如下:

@kotlin.internal.InlineOnly
public inline fun < T, R> T.run(block: T.() -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return block()
}

卧槽看起来好吊(看不懂),不是说好了很简单吗?因为这函数涉及的基本都是编译器相关的,平时开发用不到。这里包含了泛型,inline,类扩展 lambda(T.() -> R),contract 这 4 个概念。泛型我就默认你懂了,毕竟这里只讲 Kotlin 的新东西,Kotlin 泛型和 Java 的泛型除了写法没有什么区别。剩下的三个概念我们简单过一下吧。

inline,中文名内联函数,是 C/C++ 的老活儿了。inline 的意思是,虽然你声明了一个函数,但在编译期调用这个函数的地方会被替换为函数包含的代码。inline 的好处是调用该方法不再有调用方法的性能消耗,即不会跳转和产生栈帧;坏处是可能会使二进制文件膨胀,尤其是函数很大的时候。所以 inline 适合被频繁调用但代码量很小的函数,run 就很符合这个条件。我们可以因此得出结论:由于编译器编译时会把 inline 函数内联到实际调用位置,所以使用 run 方法不会有方法调用的性能损耗。

@kotlin.internal.InlineOnly,实际效果为对 Java 不可见(private),因为 Java 不支持 inline。对 Java 不可见后,这个 inline 方法则可以不在字节码里存在,因为调用的地方全部都内联了。

值得注意的是,和 C/C++ 一样,Kotlin 的 inline 也不是必然内联的。具体机制,我们有机会再聊(还没有学到)。

虽然 Java 没有内联函数,但是 JVM 是有内联优化的,只是这个优化无法精确控制。Java 的设计者一直尽可能让 Java 语言保持简单,这可能也是 Java 为什么能持续热门这么久的原因。

类扩展 lambda(关键字 lambda with class extension),即入参的声明 T.() -> R。lambda 我们了解了,扩展方法我们也了解了(强行假设你看过之前的文章),扩展 lambda 也可以理解为给类扩展一个 lambda 函数。它的效果也和扩展方法一样,在 扩展 lambda 作用域内,你可以以对象作为 this 来操作这个对象。

最后一个 contract 契约,指的是代码和 Kotlin 编译器的契约。举一个例子,我们对局部变量增加了如果为空则 return 的逻辑,Kotlin 编译器便可以智能的识别出 return 之后的局部变量一定不为空,局部变量的类型会退化为非空类型。但如果我们把是否为空的代码封装进一个扩展方法如 Any?.isNotNull() 里,那么编译器就无法识别 return 后面的代码局部变量是否为空了,这个局部变量依然是可空类型。

那么这个时候 contract 就派上用场了。我们可以声明一个 contract,告诉编译器如果Any?.isNotNull() 返回了 true,则表示对象非空。这样我们在代码里执行了 isNotNull() 方法之后,return 后面的代码,局部变量也能正确退化为非空类型。具体例子我们可以看官方 Collections.kt 的 Collection.isNullOrEmpty()

了解了 contract 的作用后我们再看 run 包含的契约。它意思是这个 lambda 只会被 run 方法执行一次,且 run 执行完后不会再被执行。对于了解到这个额外信息的 Kotlin 编译器,他就可以更有针对性的优化这里的代码(怎么针对,也还没有学到。。)。

4. 为何 Java 没有作用域函数?

作用域函数需要类扩展内联这两个能力,才能最大化体现其价值。没有类扩展,this 的切换需要通过继承或者匿名类来实现,且做不到通用;而像 let 这种不需要切换 this 的作用域函数,因为没有类扩展能力而为了追求通用性,也只能通过静态工具类来实现,效果是打折扣的。

而没有内联能力的 Java,虽然有 JVM 内联优化支撑,但内联优化只对 final 且调用次数数量级较大的方法有效。如果像 Kotlin 这样规模化的使用作用域函数,对性能是有不可忽视的影响的。

5. 其他作用域函数的使用和适用场景

下一篇!

版权所有,转载请注明出处:
https://sickworm.com/?p=1823




11. Kotlin 类声明与伴生对象(companion)

1. companion object 的诞生

Scala 说,要有伴生对象。

于是 Kotlin 便有了 companion object。

companion object 的出现是为了解决 Java static 方法的反面向对象(Anti-OOP)的问题。static 方法无法声明为接口,无法被重写——用更学术的话来说,static 方法没有面向对象的消息传递延迟绑定特性[参考]。而 Scala 为了完成一切皆对象的使命,以及提高与 Java 的兼容性,提出了伴生对象这个概念来代替 static 方法。随后出身的 Kotlin 也借鉴了这个概念。

companion 伴生对象是一个对象,它在类初始化时被实例化。 因为伴生对象不再是类的 static 方法,而是某个类的实例化对象,所以它可以声明接口,里面的方法也可以被重写,具备面向对象的所有特点。

2. companion 的实现

在 Kotlin 中,调用 Java 的 static 方法和调用 Kotlin 的 companion object 方法是一样的:

AJavaClass.staticFun()
AKotlinClass.companionFun()

而在 Java 中,调用 static 方法和 Kotlin 的伴生 companion object 方法,有一点不同:

AJavaClass.staticFun();
AKotlinClass.Companion.companionFun();

从 Java 的调用我们可以发现,companion object 的 JVM 字节码体现,是一个声明了一个叫 Companion 的 static 变量。

而 Kotlin 调用一致,其实是编译器的特殊处理的结果。

如果我们反编译AKotlinClass,可以看到:

// AKotlinClass.class
public final class AKotlinClass {
    public static final Companion Companion = new Companion(null);
}
// AKotlinClass$Companion.class
public final class AKotlinClass$Companion {
    private DownloadExecutor$Companion() {
    }

    public /* synthetic */ DownloadExecutor$Companion(DefaultConstructorMarker $constructor_marker) {
        this();
    }

    public final void companionFun() {
    }
}

可以看到,Companion 是一个叫 AKotlinClass$Companion 的类的实例,带 $ 符号表示这个类是 AKotlinClass 的内部类,名字叫 Companion,所以在AKotlinClass中直接new Companion(null)即可。

你也许还留意到实例化 Companion 使用的是一个带 DefaultConstructorMarker 入参的构造器。它出现的场景是,如果是 Kotlin 编译器生成的特殊构造器,就会带这个参数。在这里,Kotlin 希望能够实例化 Companion 类,但又不想声明一个 public 的构造器,于是就声明了这样一个特殊的构造器。DefaultConstructorMarker 值永远为 null。

DefaultConstructorMarker 出现的另一个场景是:带默认参数的构造器。因为带了默认参数后,构造器需要增加 int 类型的 bitmask 入参,来识别哪个入参需要被替换为默认参数。而为构造器增加一个入参,容易和其他构造器“撞车”,即构造器的入参完全一样,导致编译失败。所以默认参数的构造器末尾还会增加一个 DefaultConstructorMarker 入参,来防止构造器参数一致的问题。[参考]

版权所有,转载请注明出处:
https://sickworm.com/?p=1816




10. Kotlin 类声明和构造器(constructor)

1. Java 和 Kotlin 构造器代码对比

Java 的构造器声明和方法声明没有太大区别,也支持重载,唯一的限制是:必须调用父类构造器(如果父类只有一个构造器而且是无参的,编译器会帮你自动加上,这是特例)。我们使用 Java 多年,构造器很少会给我们带来不便,也不曾听人吐槽 Java 的构造器声明的不合理,便是无功无过,规规矩矩。但现代编程语言还是从构造器身上找到了优化空间,Scala–Kotlin 是其中之一。

我们不妨直接上代码对比 Kotlin 和 Java 的构造器声明的区别。现在我们有这样一个 android.view.View 的子类,它的 Java 实现:

public class RecordingBottomView extends ConstraintLayout implements View.OnClickListener {

    private static final String TAG = "RecordingBottomView";
    private static final int DEFAULT_VALUE = 100;
    private EffectBarDialog effectBarDialog = new EffectBarDialog(getContext());

    private TextView channelButton;
    public TextView effectButton;
    private TextView restartButton;
    private TextView finishBtn;

    private RecordPlayButton playButton;
    private TextView switchCameraButton;
    private TextView filterButton;
    private TextView resumeTips;

    private int defaultValue = DEFAULT_VALUE;

    public RecordingBottomView(Context context) {
        super(context);
        init(context, null, 0);
    }

    public RecordingBottomView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init(context, attrs, 0);
    }

    public RecordingBottomView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context, attrs, defStyleAttr);
    }

    private void init(Context context, AttributeSet attrs, int defStyleAttr) {
        LayoutInflater.from(context).inflate(R.layout.recording_bootom_panel, this, true);
        findViewById(R.id.recording_channel_switch_btn).setOnClickListener(this);
        channelButton = findViewById(R.id.recording_channel_switch_btn);
        effectButton = findViewById(R.id.recording_effect_btn);
        effectButton.setOnClickListener(this);


        restartButton = findViewById(R.id.recording_restart_btn);
        restartButton.setOnClickListener(this);
        finishBtn = findViewById(R.id.recording_finish_btn);
        finishBtn.setOnClickListener(this);

        switchCameraButton = findViewById(R.id.song_record_camera_switch);
        switchCameraButton.setOnClickListener(this);
        filterButton = findViewById(R.id.song_record_filter);
        filterButton.setOnClickListener(this);
        playButton = findViewById(R.id.recording_play_button);
        playButton.setOnClickListener(this);
        resumeTips = findViewById(R.id.song_record_resume_tip_btn);

        effectBarDialog.setSoundSelectListener(reverbId -> {
            LogUtil.i(TAG, "soundSelect");
            return Unit.INSTANCE;
        });
        effectBarDialog.setToneChangeListener((isUp, toneValue) -> {
            LogUtil.i(TAG, "toneChange");
            return Unit.INSTANCE;
        });
        effectBarDialog.setVolumeChangeListener(volume -> {
            LogUtil.i(TAG, "volumeChange");
            return Unit.INSTANCE;
        });

        if (attrs != null) {
            TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.RecordingBottomView);
            defaultValue = ta.getInt(R.styleable.RecordingBottomView_default_value, DEFAULT_VALUE);
        }
    }

    @Override
    public void onClick(View v) {
        // handle click
    }
}

非常经典的代码,在构造器中初始化所有的子 View 成员变量以及 View 参数。

对应的,Kotlin 风格的实现如下:

class RecordingBottomView(context: Context, attrs: AttributeSet?, defStyleAttr: Int):
        ConstraintLayout(context, attrs, defStyleAttr),
        View.OnClickListener {

    constructor(context: Context): this(context, null, 0)

    constructor(context: Context, attrs: AttributeSet?): this(context, attrs, 0)

    private val effectBarDialog = EffectBarDialog(context).also {
        it.soundSelectListener = { reverbId: Int? ->
            LogUtil.i(TAG, "soundSelect")
        }
        it.toneChangeListener = { isUp: Boolean?, toneValue: Int? ->
            LogUtil.i(TAG, "toneChange")
        }
        it.volumeChangeListener = { volume: Float? ->
            LogUtil.i(TAG, "volumeChange")
        }
    }

    private val channelButton = findViewById<TextView>(R.id.recording_channel_switch_btn).also {
        it.setOnClickListener(this)
    }

    var effectButton = findViewById<TextView>(R.id.recording_effect_btn).also {
        it.setOnClickListener(this)
    }

    private val restartButton = findViewById<TextView>(R.id.recording_restart_btn).also {
        it.setOnClickListener(this)
    }

    private val finishBtn = findViewById<TextView>(R.id.recording_finish_btn).also {
        it.setOnClickListener(this)
    }

    private val playButton: RecordPlayButton? = null
    private val switchCameraButton = findViewById<TextView>(R.id.recording_finish_btn).also {
        it.setOnClickListener(this)
    }

    private val filterButton = findViewById<TextView>(R.id.recording_finish_btn).also {
        it.setOnClickListener(this)
    }

    private val resumeTips = findViewById<TextView>(R.id.song_record_resume_tip_btn)

    private val defaultValue: Int = let {
        attrs?: DEFAULT_VALUE

        val ta = context.obtainStyledAttributes(attrs, R.styleable.RecordingBottomView)
        val value = ta.getInt(R.styleable.RecordingBottomView_default_value, DEFAULT_VALUE)
        ta.recycle()
        value
    }

    init {
        LayoutInflater.from(context).inflate(R.layout.recording_bootom_panel, this, true)
        findViewById<View>(R.id.recording_channel_switch_btn).setOnClickListener(this)
    }

    override fun onClick(v: View) {}

    companion object {
        private const val TAG = "RecordingBottomView"
        private const val DEFAULT_VALUE = 100
    }
}

这里暂且不展开谈 默认参数,view binding,also let 闭包的知识点,这些知识点会在其他文章单独介绍。

对我而言,在我接触 Kotlin 这种构造器声明之前,我没有想过 Java 的构造器声明有什么缺点。但当我接触之后,我开始思考 Kotlin 为什么要这样设计构造器声明,以及 Java 构造器声明的不足之处:

1. **Java 构造器成员变量如果依赖构造参数,它们的声明和最终赋值是分离的,同一个成员变量的代码是低内聚的。**而且像```defaultValue```这个参数,虽然他有初始值,但在一定条件下,他可能会被重新赋值。这些问题都会增加阅读者的心智负担;

2. 所有的初始化代码都在一个函数中,很容易出现“超级函数”。**不同成员变量的初始化代码大部分互相没有联系,但是却以先后顺序的形式耦合在同一个函数中,这是高耦合的。**

3. Java 构造器允许重载,虽然设计规范提倡重载函数应最终调用同一个实现,以得到清晰的逻辑表达。但这不是强制性的。这意味着你可能会遇到多个构造器各自拥有自己的实现,这会加重问题 1,2 的严重性。

对应的,Kotlin 采用了如下对策来一一解决这些问题:

1. property 声明初始化时允许使用主构造器参数,变量声明和初始化代码都写在同一个地方,代码是高内聚的;

2. 使用 let 闭包后,成员变量的所有的初始化代码都可以写在闭包内。不同的成员变量初始化代码相互独立,代码是低耦合的;

3. 仅允许一个主构造器,其他构造器为从构造器,并约定从构造器必须调用主构造器,让主构造器去调用父构造器。

如果 Kotlin 类没有声明主构造器,全部都是从构造器,则退化为 Java 构造器风格,没有调用主构造器的约束。这样的设计一是为了 Java 转 Kotlin 代码时能兼容旧代码结构,不用重构也能直接转换为 Kotlin 代码;二也方便了 Java 转 Kotlin 自动化工具的实现。但 property 的初始化无法引用从构造器的入参,因为从构造器是可以有多个的,从调用上无法保证每个从构造器的每个参数都存在。

2. Kotlin 构造器实现分析

上面我们简单的过了一遍 Kotlin 对 Java 构造器的优化,但 Java 采用这样的设计,是因为它忠实的反映了 JVM 的构造器实现。而 Kotlin 的构造器设计,并不符合 JVM 的实现。Kotlin 要最终在 JVM 上运行,必须在编译期处理,最终变回类似 Java 构造器的实现。

我们直接看一下 Kotlin 编译再反编译后的字节码:

public RecordingBottomView(@NotNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        Intrinsics.checkParameterIsNotNull(context, "context");
        super(context, attrs, defStyleAttr);
        EffectBarDialog it = new EffectBarDialog(context);
        it.setSoundSelectListener(RecordingBottomView$effectBarDialog$1$1.INSTANCE);
        it.setToneChangeListener(RecordingBottomView$effectBarDialog$1$2.INSTANCE);
        it.setVolumeChangeListener(RecordingBottomView$effectBarDialog$1$3.INSTANCE);
        this.effectBarDialog = it;
        View findViewById = findViewById(R.id.recording_channel_switch_btn);
        ((TextView) findViewById).setOnClickListener(this);
        this.channelButton = (TextView) findViewById;
        View findViewById2 = findViewById(R.id.recording_effect_btn);
        ((TextView) findViewById2).setOnClickListener(this);
        this.effectButton = (TextView) findViewById2;
        View findViewById3 = findViewById(R.id.recording_restart_btn);
        ((TextView) findViewById3).setOnClickListener(this);
        this.restartButton = (TextView) findViewById3;
        View findViewById4 = findViewById(R.id.recording_finish_btn);
        ((TextView) findViewById4).setOnClickListener(this);
        this.finishBtn = (TextView) findViewById4;
        View findViewById5 = findViewById(R.id.recording_finish_btn);
        ((TextView) findViewById5).setOnClickListener(this);
        this.switchCameraButton = (TextView) findViewById5;
        View findViewById6 = findViewById(R.id.recording_finish_btn);
        ((TextView) findViewById6).setOnClickListener(this);
        this.filterButton = (TextView) findViewById6;
        this.resumeTips = (TextView) findViewById(R.id.song_record_resume_tip_btn);
        RecordingBottomView recordingBottomView = this;
        if (attrs == null) {
            Integer.valueOf(100);
        }
        TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.RecordingBottomView);
        int value = ta.getInt(R.styleable.RecordingBottomView_default_value, 100);
        ta.recycle();
        this.defaultValue = value;
        LayoutInflater.from(context).inflate(R.layout.recording_bootom_panel, (ViewGroup) this, true);
        findViewById(R.id.recording_channel_switch_btn).setOnClickListener(this);
    }

public RecordingBottomView(@NotNull Context context) {
    Intrinsics.checkParameterIsNotNull(context, "context");
    this(context, null, 0);
}

public RecordingBottomView(@NotNull Context context, @Nullable AttributeSet attrs) {
    Intrinsics.checkParameterIsNotNull(context, "context");
    this(context, attrs, 0);
}

可以看到,property 和 init 块的初始化代码会被顺序的放入主构造器中,也就是说代码是从上往下按顺序执行的。因此 Kotlin 的初始化代码不仅可以使用主构造器的参数,还可以使用比自己先初始化的 property 和 init 块。

版权所有,转载请注明出处:
https://sickworm.com/?p=1810