Kotlin 函数签名应该诚实:领域错误与函数式错误处理的最佳实践
JetBrains 官方博客发表文章,由 Salmon 公司的首席软件工程师 Sergey Chernov 撰写,探讨了 Kotlin 中函数签名与错误处理的重要话题。文章以一个文档签名函数为例,指出 Kotlin 中返回 Unit 的函数签名隐藏了所有可能的失败情况,开发者需要阅读实现、调用的服务、异常处理器、路由映射、测试、OpenAPI 规范和客户端代码才能发现可能的失败和处理方式,而这些信息本应在函数签名中就一目了然。文章结合金融科技行业的认证和验证系统实践,系统讲解了如何用函数式的方式处理领域错误,让函数签名诚实反映所有可能的结果。本文基于 JetBrains 官方文章,系统解读这一主题的核心理念、技术方案和最佳实践。
背景:函数签名的诚实性问题
一个引发思考的例子
文章以一个简单的函数签名开始:
fun signDocument(
documentId: UUID,
code: String,
): Unit
在 Kotlin 中,Unit 表示函数完成时不返回有意义的值——大致相当于 Java 中的 void。
问题来了:这个函数可能出什么错?
- 验证码可能无效
- 签名窗口可能已关闭
- 数据库可能宕机
- 文档可能已经签名
- 文档可能已过期
- 请求可能因客户端 bug 而乱序到达
每一个都是这个函数必须面对的真实结果。但没有一个在上面那行签名中可见。
发现失败的成本
要发现可能的失败和处理方式,开发者需要:
- 打开函数实现
- 阅读它调用的服务
- 阅读异常处理器
- 阅读路由映射
- 阅读测试
- 阅读 OpenAPI 规范
- 阅读消费它的客户端代码
可以阅读所有东西,除了那个本应在第一时间告诉你的东西:签名。
为什么这很重要
在金融科技行业,错误处理的代价很高:
- 处理不当的失败很少是表面问题
- 两个错误案例之间的区别可能是让正确的人通过和让错误的人通过之间的区别
- 认证和验证系统中,错误处理直接关系到安全
- 不明确的错误处理可能导致安全漏洞
- 隐藏的失败情况可能在生产中造成意外行为
函数式错误处理的核心理念
函数式错误处理的核心理念:
- 函数签名应该诚实:签名应该反映所有可能的结果
- 错误是值:错误应该作为返回值的一部分,而不是异常
- 显式优于隐式:显式的错误处理优于隐式的异常传播
- 类型安全:利用类型系统确保错误被处理
- 组合性:错误处理应该是可组合的
- 可测试性:错误情况应该容易测试
传统异常处理的问题
异常处理的常见模式
Kotlin/Java 中传统的异常处理模式:
fun signDocument(documentId: UUID, code: String) {
val document = findDocument(documentId)
?: throw DocumentNotFoundException()
if (document.isSigned) throw AlreadySignedException()
if (document.isExpired) throw ExpiredException()
if (!verifyCode(code)) throw InvalidCodeException()
if (!isSigningWindowOpen()) throw WindowClosedException()
// ... 签名逻辑
}
异常处理的问题
1. 签名不诚实
- 函数签名只显示返回 Unit
- 所有可能的异常都隐藏在实现中
- 调用者不知道需要处理哪些错误
- 编译器不强制处理异常
- 错误处理是可选的,容易被忽略
2. 异常不是类型安全的
- Kotlin 没有受检异常(checked exceptions)
- 编译器不会提醒你处理异常
- 异常类型只能在文档中说明
- 重构时容易遗漏异常处理
- 异常的传递路径不明确
3. 异常控制流不透明
- 异常可以在调用栈的任何层级被捕获
- 异常的处理位置不明确
- 异常可以被意外捕获
- 异常可以被意外忽略
- 异常的控制流难以追踪
4. 异常性能开销
- 异常的创建和抛出有性能开销
- 异常栈的收集成本高
- 在控制流中使用异常是反模式
- 频繁的异常会影响性能
- 异常不应该用于正常的业务逻辑
5. 异常难以组合
- 多个可能失败的操作组合困难
- 需要嵌套 try-catch
- 错误聚合困难
- 部分失败处理复杂
- 函数式组合不友好
什么时候异常仍然合适
异常并不是完全没有用武之地:
- 真正的意外错误:如 OutOfMemoryError、StackOverflowError
- 不可恢复的系统错误:如数据库连接完全失败
- 编程错误:如空指针、非法参数(应该在开发阶段修复)
- 跨层传播:在某些架构中,异常用于跨层传播
- 与 Java 互操作:与 Java 库交互时
但对于领域错误(业务逻辑中可预期的失败),异常不是最佳选择。
函数式错误处理方案
方案一:Result 类型
Kotlin 标准库提供了 Result 类型:
fun signDocument(
documentId: UUID,
code: String,
): Result<Unit>
Result 类型的特点:
- 封装成功或失败
- 成功时包含值
- 失败时包含异常
- 提供 map、flatMap 等操作
- 可以用 getOrElse、getOrThrow 等提取值
使用示例:
when (val result = signDocument(documentId, code)) {
is Result.Success -> println("签名成功")
is Result.Failure -> println("签名失败: ${result.exception}")
}
Result 类型的局限:
- 错误类型固定为 Throwable
- 不能指定具体的错误类型
- 错误仍然是异常的包装
- 没有领域特定的错误类型
- 错误处理不够精确
方案二:密封类(Sealed Class)
使用密封类定义领域结果:
sealed interface SignDocumentResult {
data object Success : SignDocumentResult
data class InvalidCode(val attemptsLeft: Int) : SignDocumentResult
data object AlreadySigned : SignDocumentResult
data object Expired : SignDocumentResult
data object WindowClosed : SignDocumentResult
data object DocumentNotFound : SignDocumentResult
}
fun signDocument(
documentId: UUID,
code: String,
): SignDocumentResult
密封类的优势:
- 签名诚实:所有可能的结果都在签名中
- 类型安全:编译器强制处理所有情况
- 领域特定:错误类型反映领域概念
- 数据携带:错误可以携带额外数据
- 穷尽检查:when 表达式必须覆盖所有情况
使用示例:
when (val result = signDocument(documentId, code)) {
SignDocumentResult.Success ->
redirectToSuccessPage()
is SignDocumentResult.InvalidCode ->
showError("验证码无效,剩余 ${result.attemptsLeft} 次尝试")
SignDocumentResult.AlreadySigned ->
showInfo("文档已签名")
SignDocumentResult.Expired ->
showError("文档已过期")
SignDocumentResult.WindowClosed ->
showError("签名窗口已关闭")
SignDocumentResult.DocumentNotFound ->
showError("文档不存在")
}
方案三:Either 类型
Either 是函数式编程中常见的类型,Kotlin 中可以自己定义或使用 Arrow 库:
sealed interface Either<out L, out R> {
data class Left<L>(val value: L) : Either<L, Nothing>
data class Right<R>(val value: R) : Either<Nothing, R>
}
sealed interface SignDocumentError {
data class InvalidCode(val attemptsLeft: Int) : SignDocumentError
data object AlreadySigned : SignDocumentError
data object Expired : SignDocumentError
data object WindowClosed : SignDocumentError
data object DocumentNotFound : SignDocumentError
}
fun signDocument(
documentId: UUID,
code: String,
): Either<SignDocumentError, Unit>
Either 的优势:
- 左右分离:Left 表示错误,Right 表示成功
- 泛型错误:可以指定任意错误类型
- 函数式操作:支持 map、flatMap、fold 等
- 可组合:多个 Either 可以组合
- 通用模式:函数式编程中的标准模式
使用 Arrow 库的示例:
import arrow.core.Either
import arrow.core.left
import arrow.core.right
import arrow.core.flatMap
import arrow.core.getOrElse
fun signDocument(
documentId: UUID,
code: String,
): Either<SignDocumentError, Unit> = either {
val document = findDocument(documentId)
.getOrElse { return@either DocumentNotFound.left() }
ensure(!document.isSigned) { AlreadySigned }
ensure(!document.isExpired) { Expired }
ensure(isSigningWindowOpen()) { WindowClosed }
ensure(verifyCode(code)) { InvalidCode(attemptsLeft = 3) }
// ... 签名逻辑
Unit.right()
}
方案四:Raise 上下文(Arrow 1.2+)
Arrow 库的 Raise 上下文提供了更简洁的错误处理:
fun signDocument(
documentId: UUID,
code: String,
): Either<SignDocumentError, Unit> = either {
val document = findDocument(documentId)
.getOrElse { raise(DocumentNotFound) }
ensure(!document.isSigned) { raise(AlreadySigned) }
ensure(!document.isExpired) { raise(Expired) }
ensure(isSigningWindowOpen()) { raise(WindowClosed) }
ensure(verifyCode(code)) { raise(InvalidCode(3)) }
// ... 签名逻辑
}
Raise 上下文的优势:
- 命令式风格:代码看起来像命令式编程
- 自动短路:raise 后自动停止执行
- 无需嵌套:不需要嵌套 when 或 flatMap
- 类型安全:编译器确保所有错误被处理
- 性能好:没有异常的性能开销
领域错误的设计原则
原则一:错误是领域概念
错误类型应该反映领域概念,而不是技术概念:
// 好:领域特定的错误
sealed interface SignDocumentError {
data class InvalidCode(val attemptsLeft: Int) : SignDocumentError
data object AlreadySigned : SignDocumentError
data object Expired : SignDocumentError
}
// 不好:通用技术错误
sealed interface ApiError {
data object BadRequest : ApiError
data object NotFound : ApiError
data object InternalError : ApiError
}
原则二:错误携带有用信息
错误应该携带处理它所需的信息:
// 好:携带额外信息
data class InvalidCode(val attemptsLeft: Int, val nextAttemptAt: Instant?)
// 不好:只有错误名称
data object InvalidCode
原则三:错误粒度适中
错误的粒度应该适中:
- 太粗:无法区分不同的失败原因
- 太细:处理起来过于繁琐
- 适中:调用者需要区分的情况才分开
// 适中的粒度
sealed interface SignDocumentError {
data class InvalidCode(val attemptsLeft: Int) : SignDocumentError // 需要不同处理
data object AlreadySigned : SignDocumentError // 需要不同处理
data object Expired : SignDocumentError // 需要不同处理
data object WindowClosed : SignDocumentError // 可以与 Expired 合并?
data object DocumentNotFound : SignDocumentError // 需要不同处理
}
原则四:错误层次结构
可以使用层次结构组织相关错误:
sealed interface SignDocumentError {
sealed interface CodeError : SignDocumentError {
data class Invalid(val attemptsLeft: Int) : CodeError
data object Expired : CodeError
data object AttemptsExceeded : CodeError
}
sealed interface DocumentError : SignDocumentError {
data object NotFound : DocumentError
data object AlreadySigned : DocumentError
data object Expired : DocumentError
}
data object WindowClosed : SignDocumentError
}
调用者可以选择处理粒度:
when (result) {
is SignDocumentError.CodeError -> handleCodeError(result) // 处理所有代码错误
is SignDocumentError.DocumentError -> handleDocumentError(result) // 处理所有文档错误
SignDocumentError.WindowClosed -> handleWindowClosed()
}
原则五:错误与 HTTP 状态码分离
领域错误不应该直接绑定 HTTP 状态码:
// 好:领域错误独立于 HTTP
sealed interface SignDocumentError { /* ... */ }
// 在 API 层映射到 HTTP
fun SignDocumentError.toHttpStatus(): Int = when (this) {
is SignDocumentError.InvalidCode -> 400
SignDocumentError.AlreadySigned -> 409
SignDocumentError.Expired -> 410
SignDocumentError.WindowClosed -> 410
SignDocumentError.DocumentNotFound -> 404
}
函数式错误处理的最佳实践
实践一:在边界处转换异常
在与外部系统交互的边界处,将异常转换为领域错误:
class DocumentRepository(private val db: Database) {
fun findById(id: UUID): Either<DbError, Document?> = try {
db.query("SELECT * FROM documents WHERE id = ?", id)
.map { it.toDocument() }
.right()
} catch (e: SQLException) {
DbError(e.message).left()
}
}
实践二:使用 Either 组合多个操作
使用 flatMap 或 Raise 上下文组合多个可能失败的操作:
fun completeSigning(
documentId: UUID,
code: String,
userId: UUID,
): Either<SignDocumentError, SignedDocument> = either {
val document = findDocument(documentId).bind()
val user = findUser(userId).bind()
val verification = verifyCode(code, document).bind()
val signed = sign(document, user, verification).bind()
sendNotification(signed).bind()
signed
}
实践三:在调用点处理错误
错误应该在最了解如何处理它的地方处理:
// API 层:将错误映射为 HTTP 响应
fun signDocumentRoute(call: ApplicationCall) {
val result = signDocumentService.signDocument(
documentId = call.parameters["documentId"]!!.toUUID(),
code = call.request.queryParameters["code"]!!,
)
result.fold(
{ error -> call.respond(error.toHttpStatus(), error.toMessage()) },
{ call.respond(HttpStatusCode.OK, "签名成功") }
)
}
实践四:测试所有错误情况
每个错误情况都应该有对应的测试:
@Test
fun `signDocument returns InvalidCode when code is invalid`() {
val result = signDocument(documentId, "wrong-code")
assertTrue(result is Either.Left && result.value is SignDocumentError.InvalidCode)
}
@Test
fun `signDocument returns AlreadySigned when document is already signed`() {
// ...
}
// 每个错误情况都有测试
实践五:避免过度使用 Either
不是所有函数都需要返回 Either:
- 不会失败的函数:直接返回值
- 只有技术失败的函数:可以使用 Result 或异常
- 内部辅助函数:可以使用异常,在边界处转换
- 真正意外的错误:使用异常
- 领域可预期的失败:使用 Either 或密封类
总结
JetBrains 博客文章探讨的 Kotlin 函数签名诚实性与函数式错误处理是一个重要的软件工程主题。传统的异常处理存在签名不诚实(隐藏所有可能的失败)、类型不安全(编译器不强制处理)、控制流不透明(异常传递路径不明)、性能开销(异常创建和抛出成本高)、难以组合(多个失败操作组合困难)等问题。函数式错误处理通过让错误成为返回值的一部分,使函数签名诚实反映所有可能的结果。主要方案包括:Result 类型(标准库提供,但错误类型固定为 Throwable)、密封类(领域特定的错误类型,编译器强制穷尽处理,最推荐)、Either 类型(左右分离,泛型错误,函数式操作,可使用 Arrow 库)、Raise 上下文(Arrow 1.2+ 提供,命令式风格,自动短路,最简洁)。领域错误的设计原则包括:错误是领域概念(反映业务语义而非技术概念)、错误携带有用信息(包含处理所需的数据)、错误粒度适中(调用者需要区分的才分开)、错误层次结构(用密封接口组织相关错误)、错误与 HTTP 状态码分离(在 API 层映射)。最佳实践包括:在边界处转换异常(外部系统异常转为领域错误)、使用 Either 组合多个操作(flatMap 或 Raise 上下文)、在调用点处理错误(最了解如何处理的地方处理)、测试所有错误情况(每个错误分支都有测试)、避免过度使用 Either(不会失败或只有技术失败的函数不需要)。在金融科技等对错误处理敏感的行业,函数式错误处理可以显著提升代码的安全性、可维护性和可测试性,让函数签名成为文档的一部分,让编译器成为错误处理的保障。这一理念不仅适用于 Kotlin,也适用于所有支持代数数据类型和模式匹配的语言,是函数式编程在工业界的重要实践。
来源:https://blog.jetbrains.com/kotlin/2026/08/signatures-be-true-domain-errors-and-functional-handling-in-kotlin/