函数类型,一个更好的选择

2021-12-26  本文已影响0人  TeaCChen

1. 概述

函数类型是Kotlin中有而Java中没有的内容,从Java转战Kotlin过程中容易忽略了函数类型的更多作用。

本文主要阐述函数类型对于接口冗余的优化语法糖优化运行时优化DSL支持优势,来说明函数类型是一个更好的选择。

2. 接口的冗余与函数类型的优化

2.1 接口定义的重复

2.1.1 场景与说明

假定A模块有以下的方法和接口定义:

interface CallbackA {
    fun callback(errorCode: Int)
}

fun funcA(callbackA: CallbackA) {
    /* 仅作举例 */
    callbackA.callback(0)
}

模块B中引入了模块A作二次封装,但又不想暴露模块A的内部细节,所以模块B中又有下面接口和方法:

(模块B引用模块A的假定为implementation

interface CallbackB {
    fun callback(errorCode: Int)
}

fun funcB(callbackB: CallbackB) {
    /* 仅作举例 */
    funcA(object : CallbackA {
        override fun callback(errorCode: Int) {
            callbackB.callback(errorCode)
        }
    })
}

最后,应用模块C需要引入模块B,使用模块B中的funcB函数。

注意到这里的testB方法,里头其实嵌套了接口调用,实际上为了作一次回调,定义了两个回调接口,一次产生了两个匿名内部类对象(callbackA和callbackB),看起来并没有必要,所以这么一想,接口CallbackB的定义和调用显得重复。

这个角度更好的写法应该是删除接口CallbackBfuncB方法改为如下方式:

fun funcB(callbackA: CallbackA) {
    /* 仅作举例 */
    funcA(callbackA)
}

看似完美解决了接口的重复问题,但再思考一下,由于模块C看不到模块A,所以其实看不到接口CallbackA的定义的,所以模块C中将无法正常使用testB方法:

1.png

看到这里,不妨先杠一波?

(重点请直接看对话最后的加粗部分即可,对话仅为带出各种思维细节内容)

杠1:让模块C中引入模块A,或者模块B中将模块A的引入方式从implementation改为api不就可以了?

答1:注意,这里的模块B本来就是模块A的二次封装,设计上本身是不想让模块A实现内容暴露,以达到封装隐藏的目的,无论C中引入A还是B中将A以api引入形式暴露,都不符合模块B二次封装的模块设计目的。

杠2:这个有什么的,最终打包成apk时,模块A的代码始终还是会打包到apk中的,所以模块C中引入模块A或者模块B用以api形式暴露A,没什么实际影响。

答2:再换个角度,模块C中仅需使用模块B,设计上是不需要依赖模块A的,只是模块B中使用了模块A中的一个接口,所以模块C中为了看见模块A中的一个接口,而让模块C看到了模块A的全部,其实是不符合“高内聚”思想的。

杠3:如果考虑封装与内聚,那就别这样改,模块B中还是保留自身的接口定义吧,多一个接口定义和多申请了一个对象,成本也不大;如果真的想要两者具备,那就还要有一个基础模块先定义好接口,其他模块均依赖这个模块也可解决这个问题。

答3:搞个基础模块定义通用接口,本质上可以解决这个问题,但是往往收益不高或者可执行性较低,因为某个模块某个功能需要定义接口时,往往会将其定义在模块内部,而不会考虑模块间的使用关系,所以要以基础模块的方式来优化,往往需要功能使用者甚至代码评审者去移动接口所在的模块。

杠4:对于应用来说,多定义少定义一个接口,多申请少申请一个对象,影响往往是微乎其微的,所以这种情况往往并不会引起重视。想要开发者主动意识并解决这些问题,在实际开发中可能性并不大,每个人水平、意识、关注点、风格都可能不一样。

答4:减少对象申请,减少内存使用,减低运行成本这些应该是持续需要关注。

如果模块较多,定义的接口数量会膨胀,接口反复套用时内存会有越来越多不必要的开销。在Android平台上,Java时代这个问题近乎于无解,但是如果使用Kotlin进行开发,这个问题将容易解决得多,因为Kotlin自带函数类型。

2.1.2 换用函数类型

其实,前文接口CallbackACallbackB的定义,其实都对应一种函数类型:

callback: (errorCode: Int) -> Unit

所以模块A中不需要定义接口,模块A中的funcA可以用以下写法:

fun funcA(callback: (errorCode: Int) -> Unit) {
    /* 仅作举例 */
    callback(0)
}

模块B中也不需要定义接口,模块B中的funcB直接就可以写成:

fun funcB(callback: (errorCode: Int) -> Unit) {
    funcA(callback)
}

模块A中,使用时则是:

funcB { errorCode ->  
    println(errorCode)
}

这样,一个回调也只有一个对象申请,不仅少定义了接口,甚至连基础模块的接口也省了。

或许,又有疑问,这里只是简单场景,万一模块A中对于回调A其实有非常多的使用呢?函数类型并不能像函数接口那样规约好每次使用时的回调参数?而且每次写函数类型也比较啰嗦?

没错,这里是函数类型的一个痛点,但是事实上,这个痛点是可以通过活用类型别名来解决的,如下:

typealias MyCallback = (errorCode: Int) -> Unit

fun funcA(callback: MyCallback) {
    /* 仅作举例 */
    callback(0)
}

这样,需要统一回调参数的地方,都用类型别名MyCallback去制约即可,也可解决每次以函数类型来写的啰嗦。

但是,这样定义了函数别名,好像又回到了前文中的可见性问题的制约了吗?然而并不会,类型别名不是真实存在的类型,而是会在使用处被展开替换,所以即使是看不到类型别名MyCallback的模块,也可以按别名对应的函数类型的方式来使用。

注:Kotlin语言中的typealias作用与原理类似于与C语言中的typedef

2.2 接口方法的冗余

函数类型对于单函数的回调接口可以取代,并不适用于多函数的回调。

然而,在实际开发当中,可能存在着可以精简为函数类型的接口,比如下面这种:

interface RequestCallback {
    fun onBegin()
    fun onResult(errorCode: Int)
}

初看起来很合理,请求开始前的回调,结果的回调。

比如下面这段代码:

req(object : RequestCallback {
    override fun onBegin() {
        showLoading() // 发起请求前的处理
    }

    override fun onResult(errorCode: Int) {
        // 结果的处理
    }
})

但是,细想一下,其实onBegin是非必要的回调函数。

因为用函数类型的时候,可以这么写:

showLoading() // 发起请求前的处理
req { errorCode ->
    // 结果的处理
}

所以,虽然RequestCallback因为有两个方法,所以看起来不能被函数类型所替代,但是从实际的使用角度出发,接口里的方法实际必须的还是只有一个,所以还是可以换用函数类型的。

这里仅以此作为例子,多函数的回调接口定义,可以进一步思考其中的函数定义是否必要或者缩减。

3. 函数类型的语法糖优势

3.1 场景与代码举例

简单场景:一个异步网络请求,通过异步来处理结果。

按照Java惯有思维下,会先定义个接口:

interface RequestCallback {
    fun callback(errorCode: Int)
}

然后在网络请求方法中作为参数:

fun requestSimple(callback: RequestCallback) {
    /* 简单示例代码, */
    callback.callback(0)
}

业务上的处调用如下:

requestSimple(object : RequestCallback {
    override fun callback(errorCode: Int) {
        println(errorCode)
    }
})

看起来,一切自然而然,但是,这种写法,个人将其归纳为Java式的代码,因为其按照了Java惯有方式进行。

3.2 用函数类型来写

请求方法用函数类型改写如下:

fun requestSimple(callback: (errorCode: Int) -> Unit) {
    callback(0)
}

注:这里与前面接口的方式相比,已经不需要定义一个单独的接口了!

调用处如下:

requestSimple { errorCode ->
    println(errorCode)
}

调用处与之前相比,非常简洁,再也没有接口类和接口方法等"不需要"的信息,可以专注于实现业务代码本身,可读性大大提升

或许对于函数类型以及lambda表达式不熟悉的时候,会觉得此种方式无论在定义处还是使用处可读性不高,但是事实上,对函数类型有一定熟悉后,再看此种方式并不存在可读性低的问题。

当一个接口在具体使用场景中完全可以被函数类型所替代,那么这个接口的存在价值将无法体现,所以,为什么还要定义接口呢?

3.3 Kotlin中的函数式接口与SAM转换

从Kotlin 1.4.0版本开始,支持函数式接口写法并提供SAM转换使得接口可以用lambda表达式来实例化。

注:SAM(Single Abstract Method)转换,主要指单函数方法的接口对象转换到lambda表达式用法。

也就是说,Kotlin 1.4.0版本开始,lambda表达式不再是Kotlin函数类型实例化的专属内容。

注:Java中的单函数接口在Kotlin中始终支持SAM转换,但Kotlin的接口类型在Kotlin中始终不支持lambda表达式实例化。

所以将前面2.1中的接口RequestCallback,显式声明为函数式接口:

fun interface RequestCallback {
    fun result(errorCode: Int)
}

那么fun requestSimple(callback: RequestCallback)的调用,同样也可以用lambda表达式来实例化接口:

requestSimple { errorCode ->
    println(errorCode)
}

这样看起来,使用处和用函数类型一样简洁了。

注:注意Kotlin与Java的函数式接口与SAM转换是有区别的——Kotlin的的函数式接口必须是显式的,即fun interface,如只是普通的interface而缺少了前面的关键字fun,这个接口的实例化仍不支持SAM转换的lambda表达式;Java中的函数式接口是不强制显式声明的(即Java中即使不显式注明为函数式接口,符合条件时即支持SAM转换);

既然Kotlin 1.4.0版本开始都开始支持函数式接口了,那函数类型显得就不那么重要了?函数式接口和SAM转换解决了调用处匿名内部类的层级累赘问题(仅优化了调用处的语法糖),但是没有解决需要接口重复累赘的问题(详见2.1小节的讨论),函数类型仍有其必要性。

4. 函数类型的深入优化

Kotlin的函数类型,本质上就是接口,只不过在编译时根据不同的情况而进行了不同的自动转换。

更详细的说,Kotlin定义了泛型接口,用以适配各种场景下的函数式接口的函数类型写法。

函数类型,在最普通的情况下,便是与匿名内部类对象等效。

无参数的函数类型:

var sample0: () -> Int = {
    0
}

实际上等效于是:

sample0 = object : Function0<Int> {
    override fun invoke(): Int {
        return 0
    }
}

2个参数的函数类型:

var sample2: (Int, String) -> Unit = { a, b ->
    println(a + b.length)
}

实际上等效于:

sample2 = object : Function2<Int, String, Unit> {
    override fun invoke(p1: Int, p2: String) {
        println(p1 + p2.length)
    }
}

如果对于lambda表达式不熟悉,会觉得匿名内部类方式更"可读",但这更多的是个伪命题,lambda表达式在使用上层次更为简单清晰,反而是匿名内部类方式层级显得繁杂。

不信,来看下线程启动上,用lambda表达式和匿名内部类的区别:

Kotlin中的lambda表达式方式(这里的lambda表达式实例化Runnable参数):

Thread {
    println("Thread runs")
}.start()

或者用类似于Java中的匿名内部类方式:

Thread(object : Runnable {
    override fun run() {
        println("Thread runs")
    }
}).start()

上述的匿名内部类方式,Android Studio其实有以下提示:


2.png

小声嘀咕:Android Studio都提示转换lambda表达式了,所以还停留在匿名内部类对象的舒适区里么?

额外提示,Kotlin里对于上面线程启动还有下面更简洁的封装方式:

thread {
    println("Thread runs")
}

注意:线程单词的第一个字符是小写的t还是大写的T,小写对应Kotlin的封装方法,大写对应Java类中的构造函数

再或者,更多情况下协程会是个更合适的选择。

此处,仅以此再次体会下lambda表达式带来的层级简化上的优势——简单可读,层次分明、逻辑清晰

然而,函数类型仅限于此吗?前面所阐述的这些,无非是一些语法糖上的进化或者写法上提供了更好的选择,那么,接下来的深入优化部分,则是Java代码未曾有的,以函数类型为基础展开的深入优化。

补充说明:上述仅简单举例说明Kotlin的函数类型与lambda表达式背后主要的原理,JVM或安卓平台对于SAM转换的支持可能有更多不同的优化与实现细节,这部分更多深入细究细节,可以参照.class文件的JVM字节码、.smali文件的具体指令与相应的JVM命令、Dalvik/ART中的命令的相关解析与运行。

4.1 对匿名内部类对象的运行时优化

接口对应的匿名内部类对象每次使用时必然伴随着对象的生成,但是函数类型的调用却不一定伴随着对象的生成。

fun transFunc(input: Int, someFunc: (Int) -> Int): Int =
    someFunc(input)

fun test() {
    for (num in 1..10000) {
        val result = transFunc(num) {
            it * it
        }
        println(result)
    }
}

这段目的是,依次输出1到10000中每个数的处理结果(这里以每个数的平方简单表示其处理)。

如果只是在意程序的功能,那么这段代码是没有问题的。

但,这段代码却会有引起内存抖动的风险,不妨简单以等效的Java代码来展示上面执行过程:

void test() {
    for (int num = 1; num <= 10000; num++) {
        int result = transFunc(num, new Function1<Integer, Integer>() {
            @Override
            public Integer invoke(Integer it) {
                return it * it;
            }
        });
        System.out.println(result);
    }
}

这样的话,容易可以看出,循环的过程中,每次都创建了一个匿名内部类对象,这里循环了10000次,所以一共产生了10000个对象,便有了内存抖动的风险。

这样,看起来,函数类型用起来是有成本的,所以应谨少用函数类型?

不,以前Java没有函数类型的时候,上述场景更多的是通过定义接口和匿名内部类对象来实现,函数类型与Lambda表达式只是提供了语法糖,并没有使得情况变得更差。

好像不对啊,Java里要避免上述的内存抖动风险,可以写成:

void test() {
    Function1<Integer, Integer> func = new Function1<Integer, Integer>() {
        @Override
        public Integer invoke(Integer it) {
            return it * it;
        }
    };
    for (int num = 1; num <= 10000; num++) {
        int result = transFunc(num, func);
        System.out.println(result);
    }
}

看起来,Kotlin里的lambda表达式写法虽然语法糖比较甜,但好像无法解决这种内存抖动风险?

这个看起来合理的质疑,反过来说明了对Kotlin的函数类型和lambda表达式仍停留在仅仅的语法糖上的理解。

其实,Kotlin里可以写成:

fun test() {
    val transLambda = { it: Int ->
        it * it
    }
    for (num in 1..10000) {
        val result = transFunc(num, transLambda)
        println(result)
    }
}

这样同样可以避免内存抖动风险。

所以说,函数类型与lambda表达式,并没有使得原有Java里的情况变差。非但没要,函数类型与lambda表达式还会使得情况变得更好。

先考虑上面避免内存抖动的写法,仍存在两个优化空间:

说起这两点,或者会有人嗤之以鼻或者觉得这里在吹毛求疵,因为这两点跟内存抖动的问题比起来不值一提,而且,在Java代码当中,这两点没有绝对的优化解。

Java里没有,不代表Kotlin里没有,这时候才体现出函数类型又一个优势——当函数类型配合着inline一起使用,可以彻底优化过程产生的对象申请

inline fun transFunc(input: Int, someFunc: (Int) -> Int): Int =
    someFunc(input)

fun test() {
    for (num in 1..10000) {
        val result = transFunc(num) {
            it * it
        }
        println(result)
    }
}

这里与前面唯一的区别,就是transFunc函数前加上了inline关键字!

这时候,循环里的代码再也不用修改了,因为这里再也没有内存抖动的风险,因为等效的java代码如下:

void test() {
    for (int num = 1; num <= 10000; num++) {
        int result = num * num;
        System.out.println(result);
    }
}

这样,写Kotlin代码时lambda表达式部分和循环部分不用再分离所以更便于阅读,最终连一个匿名内部类对象也不会申请,同时兼具了代码可读性和运行时内存考虑。

更多的,函数类型除了配合inline外,还配套有crossinlinenoinline以应对各种场景的不同优化需求。

<u>防杠</u>:上面的Kotlin代码中transFunc的函数体仅为作示例作用,可能有部分人觉得这个函数本身可以不必定义进而觉得此处的优化场景立不住脚,但实际开发中函数体内容还好有较多内容或者为了统一封装处理所以无法直接免去这一层函数的封装。

注:inline优化方式是有代价的,不恰当的使用容易引起编译后代码膨胀,仍要根据实际情况择优使用。

4.2 DSL写法优势

DSL(Domain Specific Language),即领域专用语言。

简单来说,

build.gradle中常见有下面这种DSL风格:

kotlinOptions {
    jvmTarget = "1.8"
}

Java里怎么写?无能为力。

不过Kotlin里可以实现:

object KotlinOptions {
    var jvmTarget = "1.7"
}

inline fun kotlinOptions(block: KotlinOptions.() -> Unit) =
    KotlinOptions.block()

fun dslTest() {
    kotlinOptions {
        jvmTarget = "1.8"
    }
}

关键即在于kotlinOptions定义的函数类型以及lambda表达式的使用!

没啥意思?再以更新视图的布局元素为例:

Kotlin代码中可以这么写:

view.updateLayoutParams<RelativeLayout.LayoutParams> {
    width = 100
    addRule(RelativeLayout.ALIGN_BOTTOM)
}

其实编译后基本等效于:

val param = view.layoutParams as RelativeLayout.LayoutParams
param.width = 100
param.addRule(RelativeLayout.ALIGN_BOTTOM)
view.layoutParams = param

因为updateLayoutParams实现是这样的:

@JvmName("updateLayoutParamsTyped")
inline fun <reified T : ViewGroup.LayoutParams> View.updateLayoutParams(block: T.() -> Unit) {
    val params = layoutParams as T
    block(params)
    layoutParams = params
}

此函数来源于安卓官方依赖库:androidx.core:core-ktx

再觉得意思不大?再来看看常见的apply函数

apply函数之所以可以省略对象,因为其本质上也是函数类型与DSL风格的设计:

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

这里还不够说服力,再来看看协程里的启动协程的launch方法:

public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {
    val newContext = newCoroutineContext(context)
    val coroutine = if (start.isLazy)
        LazyStandaloneCoroutine(newContext, block) else
        StandaloneCoroutine(newContext, active = true)
    coroutine.start(start, coroutine, block)
    return coroutine
}

协程库里函数类型几乎到处可见,DSL风格的函数类型设计和使用也非常多。

再如声明式UI框架Jetpack Compose里的大多数常用函数都是DSL风格,如ColomnRowLazyColumnLazyRow等。

所以,其实DSL风格写法在Kotlin中并不少见,而这种风格代码背后,同样也是函数类型在作支撑。

5. 函数类型在Java中的对接

Kotlin的函数类型在Java中是无条件适配的。

因为函数类型的背后必然是函数式接口,所以有以下两种适配方式:

这里不进行举例和分析,因为具体代码场景下,根据Android Studio本身的自动代码补全提示即可使用。

不得不提的一个点,其实Java8中也提供了各种场景下的函数类型(即包路径java.util.function的各式接口),但是Android平台上受Android SDK的限制,Java8中的函数类型在API 24(即Android7.0)以下的Android系统版本中无法使用,而java8中的函数式接口与lambda表达式用法则无此API限制,更重要的是,Kotlin中的函数类型无Android API相关限制。

6. 小结

Kotlin的函数类型在原Java的基础上提供了更多的选择以及深入优化空间。

在Kotlin代码中,函数类型有以下优势:

Kotlin中可以使用Java中的接口式开发风格,但是长远来看这并不会是个聪明的选择。目前从协程、Jetpack Compose等官方代码库来看,Java式的接口式风格或许会成为过去,函数类型更可能成为主流。

综合优化空间、语法糖、潮流趋势来看,函数类型,是一个更好的选择

上一篇下一篇

猜你喜欢

热点阅读