📢📢📢 最近我们团队在翻译《Effective Kotlin: Best practices》,关注我们,我们会定时更新,欢迎指正,欢迎收藏 😁
条目 5:明确对参数和状态的预期条件
TL;DR
- 使用
require
块对参数进行校验- 使用
check
块对状态进行校验- 使用
assert
块在测试场景下判断某个对象是否为true
- 使用带有
return
和throw
的 Elvis 表达式
在程序执行过程中应尽早地进行必要的校验以满足你的预期条件。在 Kotlin 中我们主要使用以下几种方式:
require
块—— 一种对函数参数进行校验的常用方法check
块—— 一种对状态进行校验的常用方法assert
块 —— 一种校验值是否为true
的常用方法,这种方法一般只用在 JVM 测试相关的代码中- 携带
return
或者throw
的 Elvis 操作符
示例:
// Part of Stack<T>
fun pop(num: Int = 1): List<T> {
require(num <= size) {
"Cannot remove more elements than current size"
}
check(isOpen) { "Cannot pop from closed stack" }
val ret = collection.take(num)
collection = collection.drop(num)
assert(ret.size == num)
return ret
}
但即使用了这些方式,我们也依然需要在文档做必要的说明,这是很有帮助的。上面这些声明性的检查有很多优点:
- 即使没有阅读文档的开发人员依然可以看到预期条件
- 如果不满足预期,函数会抛出一个异常来避免一些不可预期的行为。在状态被修改之前抛出异常是十分重要的,我们不希望一些状态被修改了,而另外一些却没有,这种情况是危险的并且很难管理。由于这些严格检查,错误将很难被遗漏,我们的状态也会更加稳定
- 这些代码在一定程度上可以自我检查。在代码中检查这些条件时,不需要进行单元测试,
译注:理解只是针对这些条件不需要单元测试,如果方法中还有其他复杂逻辑,仍需要对该方法单元测试,但可以不用验证这些已检查的条件
。 - 上面列的所有检查都适用于智能类型转换,所以不用手动进行类型转换
让我们一起讨论下不同种类的检查,以及我们为什么要使用他们。接下来我们就从最常用的函数参数检查开始。
函数参数
当你定义了一个有参数的函数时,对别人传进来的参数要求一些预期条件并不罕见,经常已经通过入参类型进行约束了,但这不一定够。这里举几个例子:
- 当你计算数字的阶乘时,你可能会要求这个数字是一个正数
- 当你在查找集群时,你可能会要求传入的参数列表不是能空的
- 当你给一个用户发邮件时,你可能会要求该用户要有一个正确邮件地址(这里假设在使用发邮件功能之前应该检查电子邮件的正确性)
在 Kotlin 中最普遍最直接的方式是使用 require
函数进行参数检查,如果不满足条件则抛出一个异常:
fun factorial(n: Int): Long {
require(n >= 0)
return if (n <= 1) 1 else factorial(n - 1) * n
}
fun findClusters(points: List<Point>): List<Cluster> {
require(points.isNotEmpty())
//...
}
fun sendEmail(user: User, message: String) {
requireNotNull(user.email)
require(isValidEmail(user.email))
//...
}
这些约束条件非常的明显,因为他们就写在函数的最开始,这使得开发者读这些函数的代码时很清晰(尽管如此这些约束条件也需要写在文档中,因为不是每个人都会去阅读函数内容)
这些预期条件不能被忽视,因为当这些条件不满足时 require
函数会抛出 IllegalArgumentException
。当这些代码块写在程序的最开头时,我们知道如果判断条件不成立,函数就会离开停止执行。这个异常会十分明显,避免错误的值被传递很远后出现失败了才发现。换句话说,当我在函数开头正确地指定了我们的预期条件时,我们可以假定那些条件被满足时该函数才能继续执行。
我们也可以在 require
的lambda表达式中指定特定的文本,当异常发生时作为异常信息。
fun factorial(n: Int): Long {
require(n >= 0) {
"Cannot calculate factorial of $n 3 because it is smaller than 0"
}
return if (n <= 1) 1 else factorial(n - 1) * n
}
我们需要对函数参数进行明确的约束时使用 require
函数。
另外一个场景案例是当我们在函数中对类当前的某个状态有预期条件时我们可以使用 check
函数来代替 require
。
状态
我们只需要在一些正确的条件下使用某些函数的情况并不罕见。这里举几个例子:
- 一些函数可能要求一个对象必须先初始化
- 只有在用户登录时才允许操作
- 函数可能会要求一个对象的值为true时才能执行
检查这些状态是否符合预期的标准办法是使用 check
函数:
fun speak(text: String) {
check(isInitialized)
// ...
}
fun getUserInfo(): UserInfo {
checkNotNull(token)
// ...
}
fun next(): T {
check(isOpen)
// ...
}
check
函数和 require
函数的效果很相似,不过当预期条件不满足时它抛出的异常是 IllegalStateException
它检查一个状态是否是正确的。和 require
类似,可以通过 lazyMessage
自定义异常信息。当这个预期条件是针对当前这个函数时,我们会把它放在函数的最前面,一般情况下它是放在 require
块后面。也有一些状态是局部变量,check
操作会在后面才进行。
我们使用这些 check
操作,特别是当我们怀疑调用者可能破坏我们之间约定,不应该调用这个函数的时候却调用了这个函数。与其相信调用者不会那样做,不如让我们自己对状态进行检查并抛出一个合适的异常。当我们不相信我们自己的实现能够正确处理状态时,我们也可以使用它。此外,当我们是要测试我们的实现代码时,我们会使用另外一个函数,叫做 assert
。
断言
当一个函数被正确实现的时候我们会认为一切都 OK 了。举个例子,当一个函数被要求返回 10 个元素时我们会预期它真的会返回 10 个元素,但并不意味着我们总是对的,我们都会犯错,可能是我们在函数的实现中制造了一个错误,可能是某个人修改了我们所使用的函数以至于让它再也不能正常工作,还可能是我们的函数因为被重构了导致其不能正常工作。这些问题最常用的解决方案是我们应该写单元测试来验证真实的结果是否符合我们的预期:
class StackTest {
@Test
fun `Statck pops correct number of elements`() {
val stack = Stack(20) {it}
val ret = stack.pop(10)
assertEquals(10, ret.size)
}
// ...
}
单元测试应该是我们检查实现正确的最基本的方式,但需要注意的是,对于这个函数来说实际 pop
出的列表大小和预期的值相匹配是再正常不过的事情,当每次调用 pop
函数式添加一个这样的检查是十分有用的。仅针对这个用途做单一的检查显得有些过于天真,因为可能还会有一些边界问题场景,一个更好的做法是将这个断言放在 pop
方法内:
fun pop(num: Int = 1): List<T> {
//...
assert(ret.size == num)
return ret
}
assert
的执行条件只会在 Kotlin/JVM 中打开,除非你在JVM选项中打开 -ea
否则它不会执行检查。我们更应该把它当作单元测试的一部分来检查我们的代码是否按照预期工作了。默认情况下它不会在生产环境抛出任何异常,而会在单元测试中默认打开。这是我们想要的效果,因为即使我们出错了可能我们也并不希望影响到用户。如果这是一个严重的错误,并可能造成重大的后果,请使用 check
代替 。将 assert
检查直接写在函数中而不是写在单元测试中主要有以下几点优势:
- 断言能够让代码进行自我检查,并且测试效果更好
- 可以检查每一个用例的预期条件而不是某个具体的用例
- 我们可以使用他们在具体的某个执行点进行检查
- 我们让代码在越早的时候执行失败,那么就越接近问题实际发生的地方。如此,在不可预期的行为发生时我们可以更加轻易地找到发生的时间点和位置
需要记住的是在使用它们时,我们仍然需要写单元测试。不过在一个应用程序正常的执行期间,assert
是不会抛出任何异常。
不仅仅是在 Java 中,在 Python 中使用这些断言也是很常见的事。在 Kotlin 中,请随时使用它们来使您的代码更可靠。
可空性和智能类型转换
require
和 check
函数都和 Kotlin 编译器约定,当该检查函数返回后,检查之后状态值一定为 true
。
public inline fun require(value: Boolean): Unit {
contract {
returns() implies value
}
require(value) { "Failed requirement." }
}
在同一个函数中,通过这种方式进行检查的变量,在后面使用时都会被当作 true
。这很适合智能类型转换,因为只要我们检查类型正确,编译器也会这么处理。在下面的例子中我们会要求 person.outfit
是 Dress
类型。在这个检查之后,如果 outfit
是 final
类型(译注: 通过 val
来修饰)的, 他会被智能转换为 Dress
。
fun changeDress(person: Person) {
require(person.outfit is Dress)
val dress: Dress = person.outfit
// ...
}
当我们检查一个对象是否为空时,这个特性非常有用:
class Person(val email: String?)
fun sendEmail(person: Person, message: String) {
require(person.email != null)
val email: String = person.email
}
针对这个场景,我们有特定的函数: requireNotNull
和 checkNotNull
。他们都有能力对变量进行智能类型转换,也可以被当作一个表达式让变量解构出来:
class Person(val email: String?)
fun validateEmail(email: String) { /*...*/}
fun sendEmail(person: Person, text: String) {
val email = requireNotNull(person.email)
validateEmail(email)
// ...
}
fun sendEmail(person: Person, text: String) {
requireNotNull(person.email)
validateEmail(person.email)
// ...
}
对于可空的场景,更常用的方式是使用 Elvis 表达式 配合 throw
和 return
,这种方式也具有高度可读性,同时,它使我们在决定我们想要实现什么行为方面具有更大的灵活性。首先,我们可以使用 return
代替抛异常来更简单地终止一个函数执行:
fun sendEmail(person: Person, text: String) {
val email: String = person.email ?: return
// ...
}
当一个属性的值为空则不正确时,如果我们需要实现多个行为,可以将这些行为添加到包裹 return
或 throw
的run
方法中。如果我需要通过日志打印为什么这个函数会被停止,这个能力是非常有用的:
fun sendEmail(person: Person, text: String) {
val email: String = person.email ?: run {
log("Email not sent, no email addresss")
return
}
// ...
}
对处理可空变量的场景时使用带有 return
或 throw
的 Elvis 表达式是很常见且符合习惯的,我们应该毫不犹豫地使用它。如果可以,应该将这些检查放在函数的最开头让它们更显眼。
总结
明确你的预期条件:
- 使它们更显眼
- 保护您的应用程序稳定性
- 保证您代码的正确性
- 变量智能转换
我们使用的四个主要机制是:
require
块 - 一种对函数参数进行校验的常用方法check
块 - 一种对状态进行校验的常用方法assert
块 - 一种在测试场景下判断对象是否为true
的常用方法- 带有
return
或throw
的 Elvis 表达式
你也可以直接使用 throw
抛出一些其他的错误。