随着码龄增大,渐渐意识到团队代码中的最大的敌人是“复杂度”。不合理的复杂度是降低代码质量,增加沟通成本的元凶。
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<String> { // 构建字符串列表 val result = ArrayList<String>() // 遍历文件的每一行并将内容添加到列表中 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 <T> 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 : 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) {} } } }
这次的扩展函数不是一个具体类,而是一个泛型,并且该泛型的上界是Closeable
,即为所有可以被关闭的类新增一个use()
方法。
use()
扩展方法中,lambda 表达式block
代表了业务逻辑,扩展对象作为实参传入其中。业务逻辑在try-catch
代码块中被执行,最后在finally
中关闭了资源。上层可以特别省心地使用这个扩展方法,因为不再需要在意异常捕获和资源关闭。
3. 重载运算符 & 约定
读取文件内容的场景中,use() 中的业务逻辑是将BufferReader
转换成LineSequence
,然后遍历它。这里的遍历和类型转换分别是怎么实现的?
// 将 BufferReader 转化成 Sequence public fun BufferedReader.lineSequence(): Sequence<String> = LinesSequence(this).constrainOnce()
还是通过扩展方法,直接构造了LineSequence
对象并将BufferedReader
传入。这种通过组合方式实现的类型转换和装饰者模式颇为类似(关于装饰者模式的详解可以点击使用组合的设计模式 | 美颜相机中的装饰者模式)
LineSequence 是一个 Sequence:
// 序列 public interface Sequence<out T> { // 定义如何构建迭代器 public operator fun iterator(): Iterator<T> } // 迭代器 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 <T> Sequence<T>.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<String> { override public fun iterator(): Iterator<String> { // 构建迭代器 return object : Iterator<String> { 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 利用了这些语法特性隐藏了实现常用业务功能的复杂度,并且在内部将复杂度分层。
分层是降低复杂度的惯用手段,它不仅让复杂度分散,使得同一时刻只需面对有限的复杂度,并且可以通过对每一层取一个好名字来概括本层的语义。除此之外,它还有助于定位问题(缩小问题范围)并增加代码可复用性(每层单独复用)。
是不是也可以效仿这种分层的思想方法,在写代码之前,琢磨一下,复杂度是不是太高了?可以运用那些语言特性实现合理的抽象将复杂度分层?以避免复杂度在一个层次被铺开。