【翻译】Kotlin致简代码之路

2017-06-30 by Liuqingwen | Tags: Kotlin 翻译

一、前言

挺适合新手的一篇建议性博文。原文链接:Clean Code with Kotlin

因本人水平有限,翻译不好之处还请多包涵,文章中 “ Clean Code ” 我喜欢翻译成“致简代码”,但是这个名字的书一般是被翻译成“整洁代码”,建议英文水平可以的朋友尽量看原版更可口。 :grin:

二、正文

Kotlin致简代码之道

利用 Koltin 我们可以写出易懂、简短、安全而又富有表现力的代码。就像是致简代码,不是吗?在这篇文章里,我会通过讲解一些关于简洁代码方面的建议和原则,来求证 Kotlin 是否有助于达成这样的目的。另外,我也会指出一些我们应该谨慎注意的限制之处。在文章的最后,我会最终讨论 Kotlin 是走向 “一个黑暗之路还是光明之路”。

会议讲话

这篇文章内容基于我在慕尼黑的 Clean Code Days 会议上的演讲主题: Kotlin 致简代码之路(德语),于 2017 年 6 月份。

重述:什么是致简代码?

在我们开始之前,弄清楚什么是致简代码很重要。首先,致简代码是一种很容易理解的代码。代码必须直观且易读。我们可以通过让自己的代码更加简洁、简短、简单并富有表现力来达到这个目的。我们在处理最少形式主义和语法噪点的时候也会遇到致简代码。

wordle-clean-code.svg

致简代码和 Kotlin

让我们考虑几个出自 Robert C. Martin 的著名书籍《 Clean Code 》里的建议和规则。我们将会发掘 Kotlin 在哪里可以帮我们写出致简代码而在哪里又不能。让我们从使用 Kotlin 能够明显提升的地方开始。之后,我将会聊一些限制和缺陷相关内容。

clean-code-book-cover

函数

函数应该小巧

“规则 1 :函数应该小巧!
规则 2 :函数要比那样还小!”
“ Clean Code ” —— Robert C. Martin ,页码 34

根据致简代码定义,函数应该很小并且职责单一。我们应该分解子程序并给他们取一个可描述性的名称。这样的话,我们的代码就会变得像一个故事。而且,我们应该从主要逻辑中辨别出细节。 Kotlin 可以帮我们做到这点吗?不行,因为这些规则是无关于语言的。这仍然取决于开发者自己来创建小的函数。

然而,使用 Java 有时候很难写出小而富有表达力的函数。让我来举个例子。假设我们需要把 HTTP 响应的有效信息映射成一个对象并且能正确的处理各种错误分类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Java
public Product parseProduct(Response response){
if (response == null){
throw new ClientException("Response is null");
}
int code = response.code();
if (code == 200 || code == 201){
return mapToDTO(response.body());
}
if (code >= 400 && code <= 499){
throw new ClientException("Sent an invalid request");
}
if (code >= 500 && code <= 599){
throw new ClientException("Server error");
}
throw new ClientException("Error. Code " + code);
}

事实上这段代码并没有做很多事情。它只是处理一些错误分类( null 空指针响应和错误的 HTTP 状态码 )。它甚至没有做实际的映射机制。尽管如此,这些代码很冗繁并且包含有语法噪点。作为比较,看一下在 Kotlin 中同样功能实现的代码。

1
2
3
4
5
6
7
8
// Kotlin
fun parseProduct(response: Response?) = when (response?.code()){
null -> throw ClientException("Response is null")
200, 201 -> mapToDTO(response.body())
in 400..499 -> throw ClientException("Sent an invalid request")
in 500..599 -> throw ClientException("Server error")
else -> throw ClientException("Error. Code ${response.code()}")
}

我假设你并不知道这个例子里所包含的每个 Kotlin 的特性,但是这段代码确很容易看懂。这才是最神奇的地方!不需要了解每一个特性就能看懂代码,意味着这些代码非常符合直觉。那就是所谓的致简代码!使用 Kotlin ,我们可以用更少的代码( 15 行对比 6 行)最少的形式主义前提下达到业务逻辑的实现。

我们可以在这里发掘 Kotlin 很多很酷的特性,这些细节我会在后面提到,但是我想告诉你的是 when 表达式。 Kotlin 中的 when 表达式就像 Java 中的 switch 语句,但是它更加强大。它不仅简洁而且你还能在分支里做一系列检测的事情( null ,多种值,范围,类型检测等)。

通过介绍这个列子可以显示出 Kotlin 有助于减少语法噪点,并保持函数小且富有表达力。

无有副作用

致简代码告诉我们应该减少副作用。我们不应该制造出那些一看到函数名称就感觉意图不明显,发生非期望中的隐藏的变化。但是副作用到底是什么问题呢?有副作用的代码容易产生错误,很难以理解,很难做测试,不容易并行化运行(非线程安全),不能被缓存并且不能做到延迟加载。我们可以通过函数式编程的概念来避免副作用的产生。这基本上就意味着编写纯函数(等于无副作用函数)。

Kotlin 在这里能派上用场是因为它有比 Java 更好的方式支持函数式编程

  • 表达式
  • 不可变性
  • 函数类型
  • 简洁的 Lambda 表达式
  • Kotlin 丰富的集合 API

当然,值得注意的是, Kotlin 的函数功能并不能和 Haskel 甚至 Scala 相提并论。

表达式

流程控制结构作为表达式

在 Kotlin 中,流程控制结构体是表达式而不是语句。我们刚才已经看到 when 表达式实践了。其实 if-elsetry-catch 在 Kotlin 中也同样是表达式。这真的很方便:

1
2
3
4
5
6
val json = """{"message": "HELLO"}"""
val message = try {
JSONObject(json).getString("message")
} catch (ex: JSONException) {
json
}

在 Java 里,我们必须另起一行在 try 之前定义 message 变量。更加好的是,这个变量是不能被修改的( final )。使用 Kotlin 的 try 表达式,我们在减少一行的同时还能让变量做到不可变( val )。在 Java 中的一个解决方案就是把 try 分解成子程序。尽管我们可以给这个子程序一个具有很好描述性的名称,但有时候还是有点过头了。

单函数表达式

另外一个很贴心的特性是单函数表达式。如果一个函数仅包含一个表达式,我们可以省略大括号 {} 以及返回值类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fun getMessage(json: String): String {
val message = try {
JSONObject(json).getString("message")
} catch (ex: JSONException) {
json
}
return message
}
// 变成这样:
fun getMessage(json: String) = try {
JSONObject(json).getString("message")
} catch (ex: JSONException) {
json
}

这个单表达式函数变得更加简洁:它的基本逻辑立马能展示出来,这得益于语法噪点的降低。就像我们所看到的, Kotlin 的表达式支持允许我们将一些控制结构与其他表达式一起,更加简洁高效地组合起来使用。

注意残缺

把所有东西压缩到单个表达式确实很有诱惑力。就因为你这样做,并不意味着那一定就是个好方法。在这一点上,开发者保持整洁代码与易读性的规则是至关重要的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 你没必要一行一行的阅读 :-)
// 不要这样:
fun map(dto: OrderDTO, authData: RequestAuthData) = OrderEntity(
id = dto.id,
shopId = try {
extractItemIds(dto.orderItems[0].element.href).shopId
} catch (e: BatchOrderProcessingException) {
restExc("Couldn't retrieve shop id from first order item: ${e.msg}")
},
batchState = BatchState.RECEIVED,
orderData = OrderDataEntity(
orderItems = dto.orderItems.map { dto -> mapToEntity(dto) },
shippingType = dto.shipping.shippingType.id,
address = mapToEntity(dto.shipping.address),
correlationOrderId = dto.correlation?.partner?.orderId,
externalInvoiceData = dto.externalInvoiceData?.let { ExternalInvoiceDataEntity(
url = it.url,
total = it.total,
currencyId = it.currency.id
)}
),
partnerUserId = authData.sessionOwnerId ?: restExc("No sessionId supplied", 401),
apiKey = authData.apiKey,
dateCreated = if (dto.dateCreated != null) dto.dateCreated else Instant.now(),
)

如果使用有问题请选择临时变量和子程序。我们的目标不应该是使用表达式,而应该是创建更加易读的代码。有时候,这种能够达到一致的目的但并非必须。

可读性打败把一切压缩到单行

不可变性

在 Kotlin 中使用不可变性感觉非常自然且容易。实际上,这是 Kotlin 中一种惯用的写代码方式。在这个方面, Kotlin 鼓励使用不可变的变量、数据结构以及集合。因此,结果就是这会让你的代码更加健壮并且易于理解。

不可变引用

在 Kotlin 中,我们应该一直使用 val 关键字来定义一个变量。这会创建一个不可变变量。在 Java 中,我们不得不额外添加 final 这个关键字(再次出现语法噪点!)。如果你的变量必须是可以修改的,你可以使用 var 。但是请你在使用 var 之前三思。

1
2
3
4
val id = 1
id = 2 // 编译错误!
var id2 = 1
id2 = 2
只读集合

在 Kolint 中创建一个列表的惯用方式是使用 listOf() 方法。这会创建一个只读列表,因此你不能向它添加任何元素。注意 Kotlin 的集合并不是不可变的因为它是基于 Java 的可变性集合,这是迫于互操作性的原因。不过在实践中,大部分情况下这已经足够好了。

1
2
val list = listOf(1,2,3,4)
list.add(1) //编译错误。这个方法在 Kotlin 的只读列表接口中并不存在。

Kotlin 的集合 API 同样返回一个新的只读列表。原始的列表不会被改变。

1
val evenList = list.filter { it % 2 == 0 }

同样请注意这个简洁的 API 和 lambda 表达式。我们可以直接在这个列表上调用 filter() 方法(并不像 Java 8 中那样要求使用 stream() 方法)。并且 filter() 方法已经返回了一个新的列表(不需要再使用 collect(Collectors.toList()) 方法)。最终,我们可以看到一个非常简洁的 lambda 表示方式:我们可以省略括号 () 在只有一个参数并且这个参数是一个 lambda 表达式的情况下。此外,如果仅有一个参数的话,我们可以省略 lambda 中参数的定义。这种情况下,我们可以用 it 代替这个参数。也只有在这种情况下, it 所表示引用的含义很明显。其他情形下,最好是用一个具有表达力的名字来显式声明这些参数吧。总的来说,集合 API 避免了形式主义和模板。

不可变数据类

在 Kotlin 中使用 data class 能够非常容易地创建不可变数据类。实际上,这是 Kotlin 中一个杀手级特性。在 Java 中,我们需要大量的模板和形式来正确地创建一个不可变类:我们需要定义由 final 修饰的字段、属性和构造函数(把参数赋值给对应的字段),定义 hashCode()equals()toString() 同时把类标记为 final 。在 Kotlin 中,我们可以这样使用数据类:

1
2
3
4
5
6
7
data class DesignData(
val id: Int,
val fileName: String,
val uploaderId: Int,
val width: Int = 0,
val height: Int = 0
)

就是这么简单!这段代码由这些主要部分组成:类的名字和属性的定义,符合最少的形式主义原则。我们仅需要编写、阅读并维护最少量的代码!

此外, Kotlin 支持默认参数(像这样 val width: Int = 0 这样)。这直接淘汰了那种使用冗长而又繁琐的构造函数链来模拟默认参数的古老方式。

更加可喜可贺的是,它还能够在构造函数里直接使用。

1
val design = DesignData(id = 1, fileName = "cat.jpg", uploaderId = 2)

第一,我们不再需要毫无用处的 new 关键字了。第二, Kotlin 支持命名式参数,这明显提升了代码的可读性和健壮性。我们再也不会意外地混淆了具有相同类型的参数了。

而且,我们还能够通过使用属性缩写访问的语法来访问这些属性值。没必要再调用一个 getter 方法。

1
2
val id = design.id
design.id = 2 //编译错误。非可变属性。

这个 copy() 方法在函数编程中特别的实用。因为所有的数据结构都应保持不可变,所以我们需要这种方式来方便地创建一个对象的拷贝。而且, copy() 允许仅传递参数给那些需要改变的属性值。而其他的属性在复制过程中将会保持不变。

1
val design2 = design.copy(fileName = "dog.jpg")

异常处理

让我们分析一下,对应于致简代码建议条例中 Kotlin 在错误处理方面是否有利。

致简代码建议条例 Kotlin 是否支持?
错误处理和逻辑分离
不要使用已检查异常 已检查异常不存在
使用策略避免 null (异常、空集合、空物体、特殊场合对象)
不要返回 null 。理由:
a) 分散的 null 检查代码 简洁的语法处理 null
b) 很容易忘记 null 检查。空指针异常。 空类型。编译器强制处理。

如同我们所看到的,大部分建议都是和语言无关的。我只想指出最后的那三行。即使是 Kotlin ,避免返回 null 也是取决于开发者的。但是我们所面对的现实是: null 空值和 NullPointerExceptions 空异常还是一直在我们的代码中产生。这是个事实。因此我们必须处理好。辛运的是, Kotlin 有着强大的处理 null 空指针的能力。让我们一起来看看吧。

可空类型和非空类型

这个 null 空安全体系也是 Kotlin 的另一个杀手级特性。 Kotlin 扩展了 Java 类型体系。首先,编译器知道变量的类型( StringIntDate )因此我们可以在某个对象上调用某个方法。并且 Kotlin 的类型体系能够做的更多。其次,我们可以将一个类型标记为可空类型( 可以取值 null )或者非可空类型(不能为 null )。一个可空类型相对于它所对应的非空类型提供了不同的方法,这都是编译器能检测到的。

nullable-and-non-null-types.svg

1
2
3
4
5
6
7
val value: String = "Clean Code"
val value: String = null // 编译错误!不可以把 null 赋值给非空类型
val nullableValue: String? = "Clean Code"
val nullableValue: String? = null
val value: String = nullableValue // 编译错误!不可以把可空变量赋值给非空变量

要把一个可空变量值赋值给非空值变量我们必须做一个 null 检测:

1
val value: String = if (nullableValue == null) "default" else nullableValue // nullableValue 变量智能转换

这能够成功编译。编译器进行空检查后把 nullableValue 值转换成非空类型。这种自动转换叫做“智能转换”,在某些场合下直接淘汰了显示手动转换(再一次,更少的形式主义!)。另外我们可以把上面的那行代码变得更简短,通过使用 elvis 操作符 ?:

1
val value: String = nullableValue ?: "default"

如果 elvis 操作符左边( nullableValue )不是 null 的话,整个表达式会将 nullableValue 的值赋值给变量( value )。如果左边是 null 那么右边的部分(“ default ”字符串)会被赋值。

空安全实践

让我们假定有一个嵌套域的层级结构:一个订单有一个客户属性,客户拥有一个地址,因此也就有了城市的信息。现在,我们想要深入这个层级获取相应城市信息。这是一个很常见的使用情形。然而,这个链条中每个元素都有可能是空值,因此都是可空类型。所以,下面的代码是不能通过编译的:

1
val city = order.customer.address.city // 编译错误!订单、客户、地址都可以为空!

编译器不允许我们在 order 订单属性上直接访问 customer 属性,因为我们并没有处理 order 属性值为 null 的情况。编译器在编译阶段给我们指出了这个可能会发生的错误。这显著地减少了错误的发生从而提高了安全性能。

那么我们该怎么做呢?有几个选择。选择 1 是使用非空断言申明符 !!

1
val city = order!!.customer!!.address!!.city // 避免这种情况!

这能够满足编译器的要求。但是当这个链条里有一个元素是空值得时候,就会抛出一个 NullPointerException 的异常。还是让我们力求另一个更好的方式吧。

选择 2 是 Java 风格的形式:使用几个 if-null 来作判断:

1
2
3
4
if (order == null || order.customer == null || order.customer.address == null){
throw IllegalArgumentException("Invalid Order")
}
val city = order.customer.address.city // 智能转换

这能达到目的但是非常的繁琐。这很冗余又容易出错,因为我们很容易忘了某个变量的 null 检查。顺便说一下,在空检查之后编译器允许我们通过 . 符号来进行成员访问,这得益于编译器之前检测到的 null 空检查操作。

选择 3 :我们能够做得更好。这里就是安全访问操作符 ?. 派上用场的时刻了。它只在目标对象非 null 的前提下才会派发调用。否则,整个表达式都为 null

1
val city = order?.customer?.address?.city

因此,只要链条里的任何一个元素是 null 那么 city 就会变成 null 。非常的方便。尽管如此,我们还想在 null 发生的情况下抛出一个异常。那么这个时候 elvis 操作符就非常有用了:

1
val city = order?.customer?.address?.city ?: throw IllegalArgumentException("Invalid Order")

只要链条里任何一个元素是 null 那么都将会抛出一个异常。安全访问和 elvis 操作符强强组合是 Kotlin 中一个非常强大的惯用组合方式。它允许达到非常简洁地处理 null 空值目的。

总之, Kotlin 中的空值处理体系使得我们的代码既安全而又少出错。这仅仅只需要添加一些语法结构就能实现的(比方说在类型后面的 ? )。对于我来说,这种安全类型方式非常好。这样的结果就是, Kotlin 为 null 空值处理提供了简洁而又富有表现力的方法。他们删除了一大堆语法噪点和形式主义,最终写出更易读的代码。

更少的形式主义

减少语法噪点

相对于 Java , Kotlin 降低了语法噪点并且更加富有表现力。

  • 访问构造函数不需要 new 关键字。
  • 不需要写分号。
  • 类型自动推断。没必要写出来。只需要写 val 即可。
  • 大多数情况下,不需要显示转换(智能转换)。
  • 三个引号字符串里无需转义

下面的表来自 Dmitry Jemerov 和 Svetlana Isakova 的书 “ Kotlin in Action ”(表格 11.1 ,283页)。

常规语法 简洁语法 特性使用
StringUtil.capitalize(s) s.capitalize() 扩展函数
1.to("one") 1 to "one" 中缀访问符
set.add(2) set += 2 操作符重载
map.get("key") map["key"] get 方法转换
file.use({ f -> f.read() }) file.use { it.read() } 扩后外使用 Lambda
sb.append("yes") sb.append("no") with(sb) { append("yes") append("no") } 带接收器的 Lambda

特别是函数扩展功能能让我们的代码既富有表现力又更加整洁。但是要谨慎使用操作符重载。它虽然能够写出简洁的代码但是也能够导致写出很差的代码。只在操作符非常直观的前提下使用它(就像 + 用在数字、字符串和日期上)。而其他的场所,优先使用带有清晰描述和意图的名字所表示的函数方法。

流行的 Java 习惯和内建模式

在 Java 中有很多惯用方式和模式都需要一大堆的模板代码。比方说,在 Java 中实现单列模式,观察者模式或者代理模式,代码都很冗余。大部分情况这只会暴露出 Java 语言的缺陷。辛运的是,这些惯用方法和模式都很好的集成在 Kotlin 中了。详细信息可以参考我博客里关于 Kotlin 习惯用法的文章。

局限

良好的设计有益于致简代码

到目前为止,我们只考虑到了 Kotlin 中的函数和错误处理功能。除了这些,我还能发现 Kotlin 在对象和数据结构(通过数据类)以及并行性( Kotlin 1.1 中的协程 )上的改进。但是 Martin 的书籍覆盖了更多的主题:

clean-code-book-cover

  • 有含义的名称
  • 函数
  • 注释
  • 格式
  • 对象和数据结构
  • 错误处理
  • 下标边界
  • 系统
  • 访问权限暴露
  • 并行性

关于命名如何处理?命名应该具有代表意义。当然与所使用的语言是无关的。同样也适用于注释、格式、边界、类结构设计等等。如我们所见,好的软件设计对于写出致简代码是很重要的,这和使用的语言无关。想想关于适当的数据抽象、小巧的类型、迪米特法则、边界包装、单一职责原则、信息隐藏等等。使用语言只是达到致简代码的一个方面。为了突出这一点,我查阅了 Martin 的书 “整洁代码” 的第 17 章 “味道和启发” ,并分析了 Kotlin 是否能够有助于避免每个味道。

pie-chart-smells-and-heuristics.png

在我看来‘整洁代码’一书中大部分的味道和启发都是与语言无关的

在我看来,大部分的规则都是独立于语言的。它们中很多都考虑到了这个(面向对象)设计。因此是否符合这些规则还取决于开发者以及他对致简代码的认识。

特性迷恋

就因为有这么一个特性,并不意味着你就要到处使用它。特别要注意:

  • 很难读的怪诞表达式(看上面的一节:注意残缺)
  • 复杂的安全访问和 elvis 结构

关于后面那一点让我来给你举几个例子。我们假定需要在一个映射中放一个可空的字符串,这个字符串要满足不为 null 且非空白的条件。听起来很简单,对吗?看一下下面的实现方式:

1
2
3
4
5
// 不要这样做
value?.emptyToNull()?.let { map.put("bla", it) } // 额,“ it ” 是智能转换的。
// 需要辅助方法:
fun String.emptyToNull() = if (this.isEmpty()) null else this

痴迷于单表达式以及智能转换(避免非空断言)会导致写出极其难读的代码。另一种实现方式:

1
2
3
4
// 不要这样做
if (value?.isNotEmpty() ?: false){
map.put("key", value!!)
}

更加糟糕了。特别是刚开始学习 Kotlin 的时候,很容易迷失在复杂的安全引用、 elvis 操作符以及表达式之中。在这种情况下,最好是想想那套陈旧且好用的“如果是空指针或空白”的陈述语句:

1
2
3
4
// 拥抱它!
if (!value.isNullOrEmpty()){
map.put("key", value!!)
}

是的,这里仅有一个非 null 的断言申明符 !! ,因为编译器在 isNullorEmpty() 中不能侦测到 null 的检查。但是这段代码非常具有可读性且简洁明了。

可读性和简单性才是(仍然是)王道!

有时候是没必要特意使用某些特性的。任何时候可读性和简单性原则比起使用 Kotlin 那些有趣的特性来说更加重要。

总结

我们可以使用 Kotlin 写出更加简洁的代码吗?是的,毫无疑问!理由如下:

  • 提升可读性,得益于更少的模板和语法噪点
  • 提升安全性能
  • Koltin 鼓励更好的设计方式

但是仍然有两件事我们需要牢记于心:

  • 整洁代码和好的设计方式在 Kotlin 中并不是自动形成的。开发者的个人准则仍然很重要。
  • 慎重使用某些特性。有时候这种“老的”方式可能是更好的选择。时常牢记表述清晰是王道

结语:光明大道

回溯到我刚开启自己专业软件开发职业生涯的时候, Bob 大叔的这本 “整洁代码” 一书给了我启发并改变了我写代码的方式。但是我不得不赞同他批判 Kotlin (以及 Swift )的这篇“黑暗大道”文章。事实上,我很失望。这里我也要发表自己的意见:

  • 类和方法默认为 final :是的,这种设计意图是在社区引起了激烈的争议。但是对于我来说,这在日常工作中并不是什么大问题。
  • 空指针安全:这是我个人最爱的 Kotlin 杀手级特性。我坦言:只要是人类都容易犯错。所以开发者也会时常犯错。这是不可避免的。也因此 Kotlin 能够帮助开发者指出可能存在的错误(空指针异常)是很好的。但是这并不意味着我们就能粗心大意且停止编写测试了。这只是一个额外的安全层次。我不认为这很差,特别是在遇到额外的少量的语法时候。

“让汽车更安全并不意味着你可以粗心驾驶。” sebaslogen

顺便提一下,在谷歌 I/O 2017 大会上安卓团队正式宣布了 Kotlin 为安卓开发的官方语言。因此有很多的人(不仅仅是在谷歌)欢迎拥抱 Kotlin 以及它的特性。

在这篇文章里,我努力指出 Kotlin 中提供的大量优秀的特性来让你们写出更加简洁的代码。所以,即使你不喜欢这两种设计方式,但你不得不承认 Kotlin 的代码基本上更具可读性,直观性,富有表现力和安全性。这不就是致简代码所要表达的目的吗?因此, Kotlin 毫无疑问是跨入“星光大道”的又一大步!


Comments: