核对指令
**核对指令(Check Instructions)**用于确保满足约束条件。它们通常用于验证函数参数和结果。
核对指令通过表达非明显的要求来发现编程错误。它们还可以为以后阅读代码的读者提供文档。通常在函数的开头,用于确保参数合法性,以及在函数的末尾,用于检查函数的计算结果。
核对指令通常在失败时抛出异常。通常情况下,您可以使用核对指令代替显式地抛出异常。核对指令更容易编写和思考,并产生更易理解的代码。尽可能使用它们来测试和阐明您的程序。
require()
**设计契约(Design By Contract)**中的*前置条件(preconditions)*保证了初始化约束。Kotlin中的require()
通常用于验证函数参数,因此通常出现在函数体的开头。这些测试不能在编译时检查。前置条件相对容易包含在代码中,但有时可以转换为单元测试。
考虑一个表示儒略历月份的数值字段。您知道该值必须始终在1..12
的范围内。前置条件在值超出该范围时报告错误:
// CheckInstructions/JulianMonth.kt
package checkinstructions
import atomictest.*
data class Month(val monthNumber: Int) {
init {
require(monthNumber in 1..12) {
"Month out of range: $monthNumber"
}
}
}
fun main() {
Month(1) eq "Month(monthNumber=1)"
capture { Month(13) } eq
"IllegalArgumentException: " +
"Month out of range: 13"
}
我们在构造函数内执行require()
。如果其条件不满足,require()
会抛出IllegalArgumentException
。您总是可以使用require()
来代替抛出IllegalArgumentException
。
require()
的第二个参数是一个生成String
的lambda表达式。如果String
需要构造,那么除非require()
失败,否则不会发生该开销。
在Summary 2中,Quadratic.kt
的参数不当时,它会抛出IllegalArgumentException
。我们可以使用require()
简化代码:
// CheckInstructions/QuadraticRequire.kt
package checkinstructions
import kotlin.math.sqrt
import atomictest.*
class Roots(
val root1: Double,
val root2: Double
)
fun quadraticZeroes(
a: Double,
b: Double,
c: Double
): Roots {
require(a != 0.0) { "a is zero" }
val underRadical = b * b - 4 * a * c
require(underRadical >= 0) {
"Negative underRadical: $underRadical"
}
val squareRoot = sqrt(underRadical)
val root1 = (-b - squareRoot) / 2 * a
val root2 = (-b + squareRoot) / 2 * a
return Roots(root1, root2)
}
fun main() {
capture {
quadraticZeroes(0.0, 4.0, 5.0)
} eq "IllegalArgumentException: " +
"a is zero"
capture {
quadraticZeroes(3.0, 4.0, 5.0)
} eq "IllegalArgumentException: " +
"Negative underRadical: -44.0"
val roots = quadraticZeroes(3.0, 8.0, 5.0)
roots.root1 eq -15.0
roots.root2 eq -9.0
}
这段代码比原始的Quadratic.kt
代码要更清晰和简洁。
以下DataFile
类允许我们在IDE通过AtomicKotlin课程运行示例,或在独立的书籍构建中运行时,使用文件。所有的DataFile
对象都将文件存储在targetDir
子目录中:
// CheckInstructions/DataFile.kt
package checkinstructions
import atomictest.eq
import java.io.File
import java.nio.file.Paths
val targetDir = File("DataFiles")
class DataFile(val fileName: String) :
File(targetDir, fileName) {
init {
if (!targetDir.exists())
targetDir.mkdir()
}
fun erase() { if (exists()) delete() }
fun reset(): File {
erase()
createNewFile()
return this
}
}
fun main() {
DataFile("Test.txt").reset() eq
Paths.get("DataFiles", "Test.txt")
.toString()
}
DataFile
可以操作底层的操作系统文件,以便读写该文件。DataFile
的基类是java.io.File
,它是Java库中最古老的类之一;它出现在语言的第一个版本中,当时他们认为使用相同的类(File
)来表示文件和目录是个很好的主意。尽管File
古老,但Kotlin可以轻松地继承它。
在构造过程中,如果targetDir
不存在,则创建它。erase()
函数删除文件,而reset()
函数会删除文件并创建一个新的空文件。
Java标准库的Paths
类只包含一个重载的get()
函数。我们想要的get()
版本接受任意数量的String
,并构建一个Path
对象,表示与操作系统无关的目录路径。
打开文件通常有许多前置条件,通常涉及文件路径、命名和内容。考虑一个函数,该函数打开并读取以“file_”开头的文件名的文件。使用require()
,我们验证文件名是否正确,并且文件存在且不为空:
// CheckInstructions/GetTrace.kt
package checkinstructions
import atomictest.*
fun getTrace(fileName: String): List<String> {
require(fileName.startsWith("file_")) {
"$fileName must start with 'file_'"
}
val file = DataFile(fileName)
require(file.exists()) {
"$fileName
doesn't exist"
}
val lines = file.readLines()
require(lines.isNotEmpty()) {
"$fileName is empty"
}
return lines
}
fun main() {
DataFile("file_empty.txt").writeText("")
DataFile("file_wubba.txt").writeText(
"wubba lubba dub dub")
capture {
getTrace("wrong_name.txt")
} eq "IllegalArgumentException: " +
"wrong_name.txt must start with 'file_'"
capture {
getTrace("file_nonexistent.txt")
} eq "IllegalArgumentException: " +
"file_nonexistent.txt doesn't exist"
capture {
getTrace("file_empty.txt")
} eq "IllegalArgumentException: " +
"file_empty.txt is empty"
getTrace("file_wubba.txt") eq
"[wubba lubba dub dub]"
}
我们一直使用的是两个参数版本的require()
,但也有一个只有一个参数的版本,它会生成一个默认消息:
// CheckInstructions/SingleArgRequire.kt
package checkinstructions
import atomictest.*
fun singleArgRequire(arg: Int): Int {
require(arg > 5)
return arg
}
fun main() {
capture {
singleArgRequire(5)
} eq "IllegalArgumentException: " +
"Failed requirement."
singleArgRequire(6) eq 6
}
失败消息没有两个参数版本那么明确,但在某些情况下足够使用。
requireNotNull()
requireNotNull()
测试其第一个参数,并在该参数不为null
时返回该参数。否则,它会抛出IllegalArgumentException
。
在成功时,requireNotNull()
的参数会自动智能转换为非空类型。因此,您通常不需要requireNotNull()
的返回值:
// CheckInstructions/RequireNotNull.kt
package checkinstructions
import atomictest.*
fun notNull(n: Int?): Int {
requireNotNull(n) { // [1]
"notNull() argument cannot be null"
}
return n * 9 // [2]
}
fun main() {
val n: Int? = null
capture {
notNull(n)
} eq "IllegalArgumentException: " +
"notNull() argument cannot be null"
capture {
requireNotNull(n) // [3]
} eq "IllegalArgumentException: " +
"Required value was null."
notNull(11) eq 99
}
- [2] 注意,由于调用了
requireNotNull()
,n
不再需要空检查,因为它变成了非空类型。
与require()
一样,有一个两个参数的版本,可以自己构造消息([1]),还有一个带有默认消息的单参数版本([3])。由于requireNotNull()
测试的是特定问题(是否为null
),因此单参数版本比在require()
中更有用。
check()
设计契约的*后置条件(postcondition)*测试函数的结果。对于长而复杂的函数,后置条件对于验证结果的可靠性非常重要。每当您可以描述函数结果的约束时,最好将其表达为后置条件。
check()
与require()
完全相同,不同之处在于它会抛出IllegalStateException
,而不是IllegalArgumentException
。通常它在函数的末尾使用,以验证结果(或函数对象中的字段)是否有效,即事物是否变得不好。
假设一个复杂的函数写入一个文件,您不确定所有执行路径是否会创建该文件。在函数的末尾添加一个后置条件有助于确保正确性:
// CheckInstructions/Postconditions.kt
package checkinstructions
import atomictest.*
val resultFile = DataFile("Results.txt")
fun createResultFile(create: Boolean) {
if (create)
resultFile.writeText("Results\n# ok")
// ... other execution paths
check(resultFile.exists()) {
"${resultFile.name} doesn't exist!"
}
}
fun main() {
resultFile.erase()
capture {
createResultFile(false)
} eq "IllegalStateException: " +
"Results.txt doesn't exist!"
createResultFile(true)
}
假设您的前置条件确保了有效的参数,后置条件失败几乎总是表示编程错误。出于这个原因,您可能会更少地看到后置条件,因为一旦程序员确信代码是正确的,后置条件就可以被注释掉或删除,如果它影响性能的话。当然,最好保留这些测试,以便立即检测到由未来的代码更改引起的问题。一种做法是将后置条件移到单元测试中。
assert()
为了避免对check()
语句进行注释和取消注释,assert()
允许您启用和禁用assert()
检查。
assert()
来自Java。默认情况下,断言是禁用的,只有在您使用命令行标志明确启用它们时才会启用。在Kotlin中,该标志是-ea
。
我们建议始终使用require()
和check()
,它们在没有特殊配置的情况下始终可用。
练习和解决方案可以在www.AtomicKotlin.com找到。