注册

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

随着码龄增大,渐渐意识到团队代码中的最大的敌人是“复杂度”。不合理的复杂度是降低代码质量,增加沟通成本的元凶。

Kotlin 在降低代码复杂度方面有着诸多法宝。这一篇就以两个常见的业务场景来剖析下简单和复杂的关系。若要用一句话概括这关系,我最喜欢这一句:“一切简单的背后都蕴藏着复杂”。

启动线程和读取文件容是 Android 开发中两个颇为常见的场景。分别给出 Java 和 Kotlin 的实现,在惊叹两种语言表达力上悬殊的差距的同时,逐层剖析 Kotlin 语法简单背后的复杂。

启动线程

先看一个简单的业务场景,在 java 中用下面的代码启动一个新线程:

 Thread thread = new Thread() {
@Override
public void run() {
doSomething() // 业务逻辑
super.run();
}
};
thread.setDaemon(false);
thread.setPriority(-1);
thread.setName("thread");
thread.start();

启动线程是一个常用操作,其中除了 doSomething() 之外的其他代码都具有通用性。难道每次启动线程时都复制粘贴这一坨代码吗?不优雅!得抽象成一个静态方法以便到处调用:

public class ThreadUtil {
public static Thread startThread(Callback callback) {
Thread thread = new Thread() {
@Override
public void run() {
if (callback != null) callback.action();
super.run();
}
};
thread.setDaemon(false);
thread.setPriority(-1);
thread.setName("thread");
thread.start();
return thread;
}

public interface Callback {
void action();
}
}

仔细分析下这里引入的复杂度,一个新的类ThreadUtil及静态方法startThread(),还有一个新的接口Callback

然后就可以像这样构建线程了:

ThreadUtil.startThread( new Callback() {
@Override
public void action() {
doSomething();
}
})

对比下 Kotlin 的解决方案thread()

public fun thread(
start:
Boolean = true,
isDaemon:
Boolean = false,
contextClassLoader:
ClassLoader? = null,
name:
String? = null,
priority:
Int = -1,
block: () ->
Unit
)
: Thread {
val thread = object : Thread() {
public override fun run() {
block()
}
}
if (isDaemon)
thread.isDaemon = true
if (priority > 0)
thread.priority = priority
if (name != null)
thread.name = name
if (contextClassLoader != null)
thread.contextClassLoader = contextClassLoader
if (start)
thread.start()
return thread
}

thread()方法把构建线程的细节全都隐藏在方法内部。

然后就可以像这样启动一个新线程:

thread { doSomething() }

这简洁的背后是一系列语法特性的支持:

1. 顶层函数

Kotlin 中把定义在类体外,不隶属于任何类的函数称为顶层函数thread()就是这样一个函数。这样定义的好处是,可以在任意位置,方便地访问到该函数。

Kotlin 的顶层函数被编译成 java 代码后就变成一个类中的静态函数,类名是顶层函数所在文件名+Kt 后缀。

2. 高阶函数

若函数的参数或者返回值是 lambda 表达式,则称该函数为高阶函数

thread()方法的最后一个参数是 lambda 表达式。在 Kotlin 中当调用函数只传入一个 lambda 类型的参数时,可以省去括号。所以就有了thread { doSomething() }这样简洁的调用。

3. 参数默认值 & 命名参数

thread()函数包含了 6 个参数,为啥在调用时可以只传最后一个参数?因为其余的参数都在定义时提供了默认值。这个语法特性叫参数默认值

当然也可以忽略默认值,重新为参数赋值:

thread(isDaemon = true) { doSomething() }

当只想重新为某一个参数赋值时,不用将其余参数都重写一遍,只需用参数名 = 参数值,这个语法特性叫命名参数

逐行读取文件内容

再看一个稍复杂的业务场景:“读取文件中每一行的内容并打印”,用 Java 实现的代码如下:

File file = new File(path)
BufferedReader bufferedReader = null;
try {
bufferedReader = new BufferedReader(new InputStreamReader(new FileInputStream(file)));
String line;
// 循环读取文件中的每一行并打印
while ((line = bufferedReader.readLine()) != null) {
System.out.println(line);
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
// 关闭资源
if (bufferedReader != null) {
try {
bufferedReader.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}

对比一下 Kotlin 的解决方案:

File(path).readLines().foreach { println(it) }

一句话搞定,就算没学过 Kotlin 也能猜到这是在干啥,语义是如此简洁清晰。这样的代码写的时候畅快,读的时候悦目。

之所以简单,是因为 Kotlin 通过各种语法特性将复杂度分层并隐藏在了后背。

1. 扩展方法

拨开简单的面纱,探究背后隐藏的复杂:

// 为 File 扩展方法 readLines()
public fun File.readLines(charset: Charset = Charsets.UTF 8): List {
// 构建字符串列表
val result = ArrayList()
// 遍历文件的每一行并将内容添加到列表中
forEachLine(charset) { result.add(it) }
// 返回列表
return result
}

扩展方法是 Kotlin 在类体外给类新增方法的语法,它用类名.方法名()表达。

把 Kotlin 编译成 java,扩展方法就是新增了一个静态方法:

final class FilesKt  FileReadWriteKt {
// 静态函数的第一个参数是 File
public static final List readLines(@NotNull File $this$readLines, @NotNull Charset charset) {
Intrinsics.checkNotNullParameter($this$readLines, "$this$readLines");
Intrinsics.checkNotNullParameter(charset, "charset");
final ArrayList result = new ArrayList();
FilesKt.forEachLine($this$readLines, charset, (Function1)(new Function1() {
public Object invoke(Object var1) {
this.invoke((String)var1);
return Unit.INSTANCE;
}

public final void invoke(@NotNull String it) {
Intrinsics.checkNotNullParameter(it, "it");
result.add(it);
}
}));
return (List)result;
}
}

静态方法中的第一个参数是被扩展对象的实例,所以在扩展方法中可以通过this访问到类实例及其公共方法。

File.readLines() 的语义简单明了:遍历文件的每一行,将其添加到列表中并返回。

复杂度都被隐藏在了forEachLine(),它也是 File 的扩展方法,此处应该是this.forEachLine(charset) { result.add(it) },this 通常可以省略。forEachLine()是个好名字,一眼看去就知道是在遍历文件的每一行。

public fun File.forEachLine(charset: Charset = Charsets.UTF 8, action: (line: String) -> Unit): Unit {
BufferedReader(InputStreamReader(FileInputStream(this), charset)).forEachLine(action)
}

forEachLine()中将 File 层层包裹最终形成一个 BufferReader 实例,并且调用了 Reader 的扩展方法forEachLine()

public fun Reader.forEachLine(action: (String) -> Unit): Unit = 
useLines { it.forEach(action) }

forEachLine()调用了同是 Reader 的扩展方法useLines(),从名字细微的差别就可以看出uselines()完成了文件所有行内容的整合,而且这个整合的结果是可以被遍历的。

2. 泛型

哪个类能整合一组元素,并可以被遍历?沿着调用链继续往下:

public inline fun  Reader.useLines(block: (Sequence<String>) -> T): T =
buffered().use { block(it.lineSequence()) }

Reader 在useLines()中被缓冲化:

public inline fun Reader.buffered(bufferSize: Int = DEFAULT BUFFER SIZE): BufferedReader =
// 如果已经是 BufferedReader 则直接返回,否则再包一层
if (this is BufferedReader) this else BufferedReader(this, bufferSize)

紧接着调用了use(),使用 BufferReader:

// Closeable 的扩展方法
public inline fun 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) {}
}
}
}

这次的扩展函数不是一个具体类,而是一个泛型,并且该泛型的上界是Closeable,即为所有可以被关闭的类新增一个use()方法。

use()扩展方法中,lambda 表达式block代表了业务逻辑,扩展对象作为实参传入其中。业务逻辑在try-catch代码块中被执行,最后在finally中关闭了资源。上层可以特别省心地使用这个扩展方法,因为不再需要在意异常捕获和资源关闭。

3. 重载运算符 & 约定

读取文件内容的场景中,use() 中的业务逻辑是将BufferReader转换成LineSequence,然后遍历它。这里的遍历和类型转换分别是怎么实现的?

// 将 BufferReader 转化成 Sequence
public fun BufferedReader.lineSequence(): Sequence =
LinesSequence(this).constrainOnce()

还是通过扩展方法,直接构造了LineSequence对象并将BufferedReader传入。这种通过组合方式实现的类型转换和装饰者模式颇为类似(关于装饰者模式的详解可以点击使用组合的设计模式 | 美颜相机中的装饰者模式

LineSequence 是一个 Sequence:

// 序列
public interface Sequence<out T> {
// 定义如何构建迭代器
public operator fun iterator(): Iterator
}

// 迭代器
public interface Iterator<out T> {
// 获取下一个元素
public operator fun next(): T
// 判断是否有后续元素
public operator fun hasNext(): Boolean
}

Sequence是一个接口,该接口需要定义如何构建一个迭代器iterator。迭代器也是一个接口,它需要定义如何获取下一个元素及是否有后续元素。

2 个接口中的 3 个方法都被保留词operator修饰,它表示重载运算符,即重新定义运算符的语义。Kotlin 中预定义了一些函数名和运算符的对应关系,称为约定。当前这个约定就是iterator() + next() + hasNext()for循环的约定。

for 循环在 Kotlin 中被定义为“遍历迭代器提供的元素”,需要和in保留词一起使用:

public inline fun  Sequence.forEach(action: (T) -> Unit): Unit {
for (element in this) action(element)
}

Sequence 有一个扩展法方法forEach()来简化遍历语法,内部就使用了“for + in”来遍历序列中所有的元素。

所以才可以在Reader.forEachLine()中用如此简单的语法实现遍历文件中的所有行。

public fun Reader.forEachLine(action: (String) -> Unit): Unit = 
useLines { it.forEach(action) }

关于 Sequence 的用法实例可以点击Kotlin 基础 | 望文生义的 Kotlin 集合操作

LineSequence 的语义是 Sequence 中每一个元素都是文件中的一行,它在内部实现iterator()接口,构造了一个迭代器实例:

// 行序列:在 BufferedReader 外面包一层 LinesSequence
private class LinesSequence(private val reader: BufferedReader) : Sequence {
override public fun iterator(): Iterator {
// 构建迭代器
return object : Iterator {
private var nextValue: String? = null // 下一个元素值
private var done = false // 迭代是否结束

// 判断迭代器中是否有下一个元素,并顺便获取下一个元素存入 nextValue
override public fun hasNext(): Boolean {
if (nextValue == null && !done) {
// 下一个元素是文件中的一行内容
nextValue = reader.readLine()
if (nextValue == null) done = true
}
return nextValue != null
}

// 获取迭代器中下一个元素
override public fun next(): String {
if (!hasNext()) {
throw NoSuchElementException()
}
val answer = nextValue
nextValue = null
return answer!!
}
}
}
}

LineSequence 内部的迭代器在hasNext()中获取了文件中一行的内容,并存储在nextValue中,完成了将文件中每一行的内容转换成 Sequence 中的一个元素。

当在 Sequence 上遍历时,文件中每一行的内容就一个个出现在迭代中。这样做的好处是对内存更加友好,LineSequence 并没有持有文件中所有行的内容,它只是定义了如何获取文件中下一行的内容,所有的内容只有等待遍历时,才一个个地浮现出来。

用一句话总结 Kotlin 逐行读取文件内容的算法:用缓冲流(BufferReader)包裹文件,再用行序列(LineSequence)包裹缓冲流,序列迭代行为被定义为读取文件中一行的内容。遍历序列时,文件内容就一行行地被添加到列表中。

总结

顶层函数、高阶函数、默认参数、命名参数、扩展方法、泛型、重载运算符,Kotlin 利用了这些语法特性隐藏了实现常用业务功能的复杂度,并且在内部将复杂度分层。

分层是降低复杂度的惯用手段,它不仅让复杂度分散,使得同一时刻只需面对有限的复杂度,并且可以通过对每一层取一个好名字来概括本层的语义。除此之外,它还有助于定位问题(缩小问题范围)并增加代码可复用性(每层单独复用)。

是不是也可以效仿这种分层的思想方法,在写代码之前,琢磨一下,复杂度是不是太高了?可以运用那些语言特性实现合理的抽象将复杂度分层?以避免复杂度在一个层次被铺开。

0 个评论

要回复文章请先登录注册