写更易懂的代码,Kotlin 是这样隐藏复杂度的(一)


highlight: arduino-light
theme: github

引子

代码是一种表达,凝聚了程序员的想法,得先保证表达的正确性,以免执行时报错。除此之外,表达的简洁性也值得关注,以免日后因看不懂而难以维护。代码不仅是用来执行的,也是用来读或修改的,读懂是修改的前提。

这一系列的主题是“复杂度”。复杂度是软件开发过程中最大的敌人。高复杂度影响着理解成本,维护难度甚至是迭代节奏和交付质量。系列文章目录如下:

  1. Kotlin 基础 | 拒绝语法噪音

  2. Kotlin 源码 | 降低代码复杂度的法宝

Kotlin 是降低复杂度的大师,这一篇将挑选几个 Kotlin 的特性结合实战代码分析下它降低复杂度之道。

隐藏 try-catch-finally

假设有下面这个方法:

public Result write(String id, byte[] bytes) {
    Result result = new Result();
    FileOutputStream fileOutputStream = null;
    BufferedOutputStream bufferedOutputStream = null;
    try {
        lock.writeLock().lock();
        fileOutputStream = new FileOutputStream(new File("xxx"));
        bufferedOutputStream = new BufferedOutputStream(fileOutputStream);
        bufferedOutputStream.write(bytes,0,bytes.length);
        boolean success = new Dao().insert(id);
        if(success) return result.success();
        else return result.error("failed");
    } catch (IOException e) {
        return result.error("file error");
    } catch (SQLiteException e){
        return result.error("db error") ;
    }finally {
        lock.writeLock().unlock();
        try {
            if (bufferedOutputStream != null) {
                bufferedOutputStream.close();
            }
        } catch (IOException e) {
        }
    }
}

方法中分别向文件和数据库输出内容,使用了 ReentrantReadWriteLock 保证线程安全,使用 try-catch 捕获异常,并且在 finally 中释放锁和 io 流。

若使用 kotlin 可以大幅降低这段代码的复杂度:

public fun write(id: String, bytes: ByteArray) =
    return runCatching {
        lock.write {
            File("xxx").outputStream().buffered().use { it.write(bytes, 0, bytes.size) }
            if (Dao().insert(id)) Result().success()
            else Result().error("failed")
        }
    }.getOrElse {
        when (it) {
            is SQLiteException -> Result().error("db error")
            is IOException -> Result().error("file error")
            else -> Result().error("other error")
        }
    }

kotlin 仅用了一半的代码量就表达出相同的语义。

runCatching() + getOrElse()

其中runCatching(),是一个扩展法方法:

public inline fun <T, R> T.runCatching(block: T.() -> R): Result<R> {
    return try {
        Result.success(block())
    } catch (e: Throwable) {
        Result.failure(e)
    }
}

该方法的参数是一个带返回值的 lambda,它会被嵌入 try-catch 执行。

该方法的返回值是 Result,lambda 执行的结果会被包装成 Result。

Kotlin 中try-catch是一个表达式,它是有值的,等于每个分支最后一条语句的值。这个特性使得不必多声明一个局部变量:

Result result = new Result();
try {
    result.success();
} catch (Exception e) {
    result.failure(e);
}
return result;

除此之外,Kotlin 中的 if-else 和 when 都是表达式,它们的值等于命中分支中最后一个表达式的值。上述代码借用了这个特性,消除了用于记录返回值的局部变量,将整个方法的返回值内聚在一个表达式中:

return runCatching { // 该方法返回值 = lambda的值
    lock.write { // 该方法返回值 = lambda的值 = if-else 表达式的值
        if (Dao().insert(id)) Result().success()
        else Result().error("failed")
    }
}.getOrElse { // 该方法返回值 = lambda的值 = when 表达式的值
    when (it) {
        is SQLiteException -> Result().error("db error")
        is IOException -> Result().error("file error")
        else -> Result().error("other error")
    }
}

getOrElse()是 Result 的扩展方法:

public inline fun <R, T : R> Result<T>.getOrElse(onFailure: (exception: Throwable) -> R): R {
    contract {
        callsInPlace(onFailure, InvocationKind.AT_MOST_ONCE)
    }
    return when (val exception = exceptionOrNull()) {
        null -> value as T
        else -> onFailure(exception)
    }
}

它用于去除包装类 Result,返回真正的结果。

runCatching() + getOrElse() 的组合配合各种表达式使得“在 try-catch ”代码块中返回值变得更简洁、更有表现力、更具有函数式编程的风格。

write()

write() 是 ReentrantReadWriteLock 的扩展方法:

public inline fun <T> ReentrantReadWriteLock.write(action: () -> T): T {
    contract { callsInPlace(action, InvocationKind.EXACTLY_ONCE) }
    val rl = readLock()

    val readCount = if (writeHoldCount == 0) readHoldCount else 0
    repeat(readCount) { rl.unlock() }

    val wl = writeLock()
    wl.lock()
    try {
        return action()
    } finally {
        repeat(readCount) { rl.lock() }
        wl.unlock()
    }
}

它把“在 try-catch-finally 中加/释放锁”的细节隐藏在了内部,留给外部的只剩下简洁:

lock.write { // io 操作 }

外部代码不再会出现 finally 了,复杂度就这样被隐藏。

拒绝嵌套构造

在 java 中,io 相关类使用装饰者模式,代码通常如下:

BufferedOutputStream stream = new BufferedOutputStream(new FileOutputStream(new File("xxx")));

把这种构建对象的方式叫“嵌套式构建”。在 kotlin 中,有更好的表达方式:链式构建,代码如下:

File("xxx").outputStream().buffered()

其中 outputStream() 和 buffered() 都是扩展方法:

// File 的扩展方法
public inline fun File.outputStream(): FileOutputStream {
    return FileOutputStream(this)
}
// OutputStream 的扩展方法
public inline fun OutputStream.buffered(bufferSize: Int): BufferedOutputStream =
    if (this is BufferedOutputStream) this else BufferedOutputStream(this, bufferSize)

嵌套式构造被隐藏在方法内部分批进行。

use()

public inline fun <T : Closeable?, R> T.use(block: (T) -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    var exception: Throwable? = null
    try {
        return block(this)
    } catch (e: Throwable) {
        exception = e
        throw e
    } finally {
        when {
            apiVersionIsAtLeast(1, 1, 0) -> this.closeFinally(exception)
            this == null -> {}
            exception == null -> close()
            else ->
                try {
                    close()
                } catch (closeException: Throwable) {
                    // cause.addSuppressed(closeException) // ignored here
                }
        }
    }
}

use() 方法是所有资源类的福音,它把“如何使用资源”、“如何捕获异常”以及“如何释放资源”内聚在一个方法内部。如此一来,外部代码中不会出现 try-catch-finally 代码块了。

用 DSL 隐藏不必要的接口

实现 Java 接口时,即使不需要其中的某些方法,也必须将其 implements 并保持其为空实现,傻傻地处在那:

AnimationSet animationSet = new AnimationSet(false);
animationSet.setAnimationListener(new Animation.AnimationListener() {
    @Override
    public void onAnimationStart(Animation animation) {
    }

    @Override
    public void onAnimationEnd(Animation animation) {
        showToast()
    }

    @Override
    public void onAnimationRepeat(Animation animation) {
    }
});

其实只是想在动画结束时展示一个 toast,另外两个回调对我来说没用。

利用 Kotlin 的语法糖可以只实现自己感兴趣的方法。

再介绍解决方案之前得引入一个概念:DSL

DSL

DSL = domain specific language,即“特定领域语言”,与它对应的一个概念叫“通用编程语言”,通用编程语言有一系列完善的能力来解决几乎所有能被计算机解决的问题,像 Java 就属于这种类型。而特定领域语言只专注于特定的任务,比如 SQL 只专注于操纵数据库,HTML 只专注于表述超文本。

既然通用编程语言能够解决所有的问题,那为啥还需要特定领域语言?因为它可以使用比通用编程语言中等价代码更紧凑的语法来表达特定领域的操作。比如当执行一条 SQL 语句时,不需要从声明一个类及其方法开始。

更紧凑的语法意味着更简洁的 API。应用程序中每个类都提供了其他类与之交互的可能性,确保这些交互易于理解并可以简洁地表达(低复杂度),对于软件的可维护性至关重要。

DSL 有一个普通API不具备特征:DSL 具有结构。而带接收者的lambda使得构建结构化的 API 变得容易。

带接收者的 lambda

它是一种特殊的 lambda,kotlin 中特有的。它的表达形式为:T.() -> R。即在常规的 lambda 前面声明了一个对象 T,该对象称为 lambda 的接收者。

可以把这种 lambda 理解成“为接收者声明的一个匿名扩展函数”。(扩展函数是一种在类体外为类添加功能的特性)

带接收者的 lambda 的函数体除了能访问其所在类的成员外,还能访问接收者的所有非私有成员,这个特性是它能够轻松地构建结构。

当带接收者的 lambda 配合高阶函数时,构建结构化的 API 就变得易如反掌。

高阶函数

它是一种特殊的函数,它的参数或者返回值是另一个函数。

比如启动协程的 launch() 方法就是一个高阶函数:

public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit // 该参数是另一个函数
): Job { ... }

launch() 的最后一个参数是“带接收者的 lambda”,接收者是 CoroutineScope,这样的设定使得在协程中构建子协程易如反掌:

scope.launch { // IDE 会提示这里有个隐含参数 this:CoroutineScope
    launch {...}
}

上述代码内部的 launch{},其实是 this.launch {},this 通常可以省略。又因为参数 block 被定义为CoroutineScope.() -> Unit,所以 this 的类型就是 CoroutineScope。

这样的代码就是有结构的。

下面运用 DSL 来解决当前的问题。

  1. 新建类用于存放接口中各个方法的实现
    class AnimatorListenerImpl {
     var onRepeat: ((Animator) -> Unit)? = null
     var onEnd: ((Animator) -> Unit)? = null
     var onCancel: ((Animator) -> Unit)? = null
     var onStart: ((Animator) -> Unit)? = null
    }

    它包含四个成员,每个成员的类型都是函数类型。Kotlin 中函数也是一种类型,它可以存储在变量中。

这四个函数类型变量的声明参照了Animator.AnimatorListener的定义:

public static interface AnimatorListener {
    void onAnimationStart(Animator animation);
    void onAnimationEnd(Animator animation);
    void onAnimationCancel(Animator animation);
    void onAnimationRepeat(Animator animation);
}

该接口中的每个方法都接收一个 Animator 参数并返回空值,用 lambda 可以表达成 (Animator) -> Unit。所以 AnimatorListenerImpl 将接口中的四个方法的实现都保存在函数变量中,并且实现是可空的。

  1. 为 Animator 定义一个高阶扩展函数

    fun AnimatorSet.addListener(action: AnimatorListenerImpl.() -> Unit) {
     AnimatorListenerImpl().apply { action }.let { builder ->
         //'将回调实现委托给AnimatorListenerImpl的函数类型变量'
         addListener(object : Animator.AnimatorListener {
             override fun onAnimationRepeat(animation: Animator?) {
                 animation?.let { builder.onRepeat?.invoke(animation) }
             }
    
             override fun onAnimationEnd(animation: Animator?) {
                 animation?.let { builder.onEnd?.invoke(animation) }
             }
    
             override fun onAnimationCancel(animation: Animator?) {
                 animation?.let { builder.onCancel?.invoke(animation) }
             }
    
             override fun onAnimationStart(animation: Animator?) {
                 animation?.let { builder.onStart?.invoke(animation) }
             }
         })
     }
    }

    为 Animator 定义了扩展函数 addListener(),该函数接收一个带接收者的lambdaaction

扩展函数体中构建了 AnimatorListenerImpl 实例并紧接着应用了 action ,最后为 Animator 设置动画监听器并将回调的实现委托给 AnimatorListenerImpl 中的函数类型变量。

将本节开头的代码改写:

AnimationSet().addListener {
    onEnd = { showToast() } 
}

这段调用拥有自己独特的结构,它解决了“必须实现全部 java 接口”这个特定的问题,所以它可以称得上是一个自定义 DSL (当然和 SQL 相比,它显得太简单了)。

关于 DSL 更广泛的应用可以点击 Android性能优化 | 把构建布局用时缩短 20 倍(下) - 掘金 (juejin.cn)

总结

高阶方法、带接收者的 lambda、扩展方法、函数类型、if-else/when/try-catch 表达式,综合运用这些语法糖,将啰嗦的语法隐藏在一个个扩展方法内部,把简洁留给上层,这就是 Kotlin 的化解复杂度之道。

推荐阅读

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿

作者:唐子玄 原文地址:https://juejin.cn/post/7124113005532413966

%s 个评论

要回复文章请先登录注册