Kotlin把可否为null写进类型:T表示非空值,T?表示值也可能缺失。
?.只会安全地传播null,不会判断缺失是否符合业务规则;!!则把检查推迟到运行时。
在输入边界保留可空性,随后用智能转换、Elvis提前返回或显式结果类型,把已验证的数据收窄为非空领域值。
是什么,为什么存在
Kotlin空安全(null safety)把缺失值作为静态类型信息。普通的String不能保存null, 可空类型 String?才能保存字符串或null。调用方因此能从函数签名看出缺失是否属于契约,而不必依赖注释或等到解引用时才发现问题。
这套设计把许多空指针错误移到编译期。如果接收者是String?,编译器不会允许直接读取length;代码必须先证明值非空、使用安全调用,或明确承担非空断言的风险。它减少的是未经检查的解引用,不是所有NullPointerException。
当数据库列、JSON字段、查找结果、可选配置或Java API可能不给出值时,你会遇到可空类型。关键设计问题不是怎样最快去掉问号,而是null在当前边界代表什么:合法缺省、未找到、无效数据,还是程序状态错误。不同含义需要不同处理方式。
空安全也不会自动验证空字符串、负数、过期值或对象之间的不变量。String只能证明引用非空,不能证明文本非空白。进入领域层前仍要做内容校验,并让领域类型尽量只保存合法状态。
工作原理
两套相邻的类型
对于一个非空类型T,T?是允许额外null值的可空版本。非空值可以赋给对应的可空变量,所以String的值可以用在需要String?的位置;反向赋值需要先处理null。重复写问号没有新的含义,Kotlin不存在独立的T??层级。
可空性作用于它紧邻的类型。List<String>?表示列表本身可能缺失,但存在的列表只含非空字符串;List<String?>表示列表一定存在,而元素可能缺失;List<String?>?两层都可空。函数类型同样需要看括号:((String) -> Int)?是可空函数值,(String) -> Int?是一定存在但可能返回null的函数。
API应只在缺失确实有意义时暴露T?。查找不到记录可以返回User?,但已通过身份验证的请求上下文若仍把必需的用户写成User?,下游每一步都要重复处理一个本不该存在的状态。边界越早把输入规范化为有效非空值,后续代码的状态空间就越小。
检查与智能转换
显式检查是最直接的收窄方式。在if (name != null)的真分支里,编译器可以把稳定的name当作String;若空分支已经return或throw,检查后的路径也能使用非空类型。这种由控制流证明完成的收窄叫 智能转换 (smart cast),源码中没有运行时强制转换。
智能转换要求编译器能保证检查和使用之间的值没有改变。局部val通常满足条件,未在期间修改或被修改型lambda捕获的局部var也可能满足条件。可变属性不会被智能转换,因为其他代码或自定义访问器可能在两次读取之间给出不同结果。
遇到可变属性时,先读取一次局部快照通常是正确修复。val email = account.email固定了本次操作观察到的引用,之后对email的非空检查可以支配使用点。用account.email!!绕过编译器既会重新读取属性,也会把竞态或自定义getter的变化变成运行时崩溃。
is类型检查也能同时证明类型和非空。例如,value is String成立后,value已是非空String。如果转换失败本来就是普通分支,安全转换value as? String会返回String?;普通的as String在类型不匹配时抛出ClassCastException,并不属于空安全处理。
用表达式处理缺失
安全调用 ?.在接收者非空时读取属性或调用函数,否则整个表达式返回null。链式写法order?.customer?.address?.city会在第一个空接收者处停止。结果通常仍然可空,因为链条只回答能否得到值,没有决定得不到值时该怎么办。
安全调用也可以出现在赋值左侧。如果account?.address?.city = computeCity()的任一接收者为null,赋值被跳过,右侧的computeCity()也不会执行。这一点会影响带日志、计数或I/O的右侧表达式,不能只把它理解为简写的属性访问。
Elvis运算符 ?:在左侧非空时返回左侧,否则才计算右侧。右侧可以是默认值,也可以是return或throw,因为它们在Kotlin中可以出现在表达式位置。val user = lookup(id) ?: return NotFound很适合在函数入口结束缺失分支,并让后续的user保持非空。
value?.let { use(it) }只在value非空时调用lambda,并返回lambda的结果。这适合短小转换,却不比普通if更安全;如果use本身返回可空值,最终的null无法区分接收者缺失还是转换失败。需要保留原因时,应使用显式分支或带类型的结果。
非空断言!!把可空表达式视为非空,实际值为null时抛出NullPointerException。它适合让违反测试前置条件的测试立即失败,但在生产路径中通常说明不变量没有在边界表达清楚。优先使用requireNotNull给调用方错误、用checkNotNull表示对象状态错误,或用Elvis返回领域结果。
常用工具的语义如下:
| 写法 | 非空时 | 为空或失败时 | 结果特点 |
|---|---|---|---|
value?.member | 访问成员 | 返回null | 继续传播可空性 |
value ?: fallback | 返回value | 延迟计算fallback | 可提供非空结果或退出 |
value?.let { transform(it) } | 执行转换 | 不执行lambda | 结果也可能由lambda产生null |
value as? T | 返回转换后的T | 返回null | 同时处理类型不匹配 |
value!! | 返回非空值 | 抛出异常 | 将证明责任交给运行时 |
缺失策略属于API契约
同样的null在不同API中可能需要不同返回形状。缓存查询把null定义为未命中时,Entry?已经足够;命令执行还要区分拒绝、冲突和依赖故障时,一个可空返回值会丢失信息。类型设计应先保留调用方需要采取行动的差别,再选择语法。
处理可空输入时,可以按下面的顺序决定:
- 缺失是否合法;不合法就应在最接近输入的位置拒绝。
- 合法缺失是否只有一种含义;只有一种时才适合直接使用
T?。 - 调用方是否需要知道原因;需要时返回密封结果或抛出契约规定的异常。
- 默认值是否与显式提供该值完全等价;不等价时不能用Elvis抹平来源。
常见边界可以映射为不同形状:
| 边界语义 | 合适的形状 | 调用方得到的信息 |
|---|---|---|
| 未命中且无需解释 | T? | 有值或无值 |
| 缺失时采用等价默认值 | T配合?: | 已完成回退的值 |
| 缺失违反调用契约 | 非空参数或requireNotNull | 带责任归属的异常 |
| 多种可恢复失败 | 密封结果类型 | 可穷举的原因与成功值 |
默认值本身也属于业务数据。把缺少超时时间写成timeout ?: 30,等于承诺“未配置”与“明确配置30”对后续所有行为没有区别。如果系统需要展示继承来源、支持配置回写或审计变更,就必须保留是否缺失的信息。
requireNotNull(value)在失败时抛出IllegalArgumentException,适合检查调用者传入的参数;checkNotNull(value)抛出IllegalStateException,适合检查接收对象或当前执行阶段。二者都返回非空值,因此可以直接赋给局部变量。它们比!!多表达了责任归属,但仍不适合正常的未找到分支。
公开函数还要让Java调用方看懂契约。参数声明为String?表示函数必须接受Java传入的null,参数声明为String则会在Kotlin生成的入口检查非空。实现中的安全调用不能弥补签名选错,因为兼容性和调用方生成代码都先观察签名。
相等性与可空布尔值
Kotlin的结构相等运算符==可以安全比较可空引用。left == right会处理两边都为null的情况,不需要先写安全调用;不等运算符!=同样安全。引用相等===比较是否为同一个对象,也允许两侧为null,但它回答的是身份问题,不是值是否相等。
Boolean?有三个状态,而条件表达式要求Boolean。flag == true只在值明确为true时成立,false与null都进入另一分支;flag ?: false也会合并后两种状态。只有业务确实把“未知”当作“否”时,这种折叠才正确。
需要保留三个状态时,使用when (flag)分别处理true、false和null。这不仅避免编译错误,也让新增日志、指标或用户提示时仍能定位未知来源。把Boolean?到处改成Boolean并填false,通常是在数据模型层提前丢失信息。
可空接收者扩展仍会执行
扩展函数可以把接收者声明为可空类型,例如fun String?.display(): String。调用name.display()时,即使name是null,函数体仍会执行,体内的this保持String?。这种设计适合为一种类型集中定义稳定的缺失表示。
调用点写成name?.display()会改变语义:接收者为空时,安全调用直接跳过扩展函数体,结果为null。如果display()原本要把null变成占位文本,额外的?.反而绕过了回退。评审扩展调用时,要同时查看扩展的接收者类型和调用运算符。
可空接收者扩展不应隐藏需要上下文的业务决策。同一个缺失名称在日志中可能应显示固定标记,在用户界面中可能需要本地化文本,在持久化层则可能必须保留null。这些策略属于各自边界,不适合塞进一个全局字符串扩展。
测试这类扩展至少覆盖四个输入:null、空字符串、空白字符串和普通值。还应分别调用value.extension()与value?.extension(),因为两种写法在接收者为空时走不同路径。只测试非空值会完全错过该API最特殊的契约。
示例
下面四个程序从读取可空值逐步推进到边界规范化和泛型集合。每个文件都已使用Kotlin 2.4.10编译器编译,并在JRE 21上运行;紧随代码的输出来自实际执行。
收窄一次,继续使用非空值
第一个程序把可空输入留在label的入口。Elvis运算符在资料不存在时提前返回;此后局部变量current是非空Profile,只有真正可选的bio继续使用安全调用链。
data class Profile(val handle: String, val bio: String?)
fun label(profile: Profile?): String {
val current = profile ?: return "missing profile"
val detail = current.bio
?.trim()
?.takeIf { it.isNotEmpty() }
?: "no bio"
return "${current.handle}: $detail"
}
fun main() {
val profiles = listOf(
Profile("ada", " compiler engineer "),
Profile("lin", null),
null,
)
profiles.forEach { println(label(it)) }
}ada: compiler engineer
lin: no bio
missing profiletakeIf把空白简介也变成null,所以?: "no bio"同时处理原始缺失和校验失败。这在两种情况业务含义相同时很合适;若界面必须区分未填写和只填了空格,就要保留独立分支。
函数返回String,因此调用方不用继续处理可空结果。这个签名说明label已经完整处理了输入缺失,而不是把决定推给下一层。
安全调用和Elvis都会短路
第二个程序展示两种惰性行为。displayCity只在安全调用链得到null时计算默认城市;左侧安全赋值找不到接收者时,也不会计算右侧。
data class Address(var city: String?)
data class Account(val address: Address?)
fun defaultCity(): String {
println("default computed")
return "unknown"
}
fun displayCity(account: Account?): String =
account?.address?.city ?: defaultCity()
fun main() {
val known = Account(Address("Lyon"))
val absent: Account? = null
println(displayCity(known))
println(displayCity(absent))
var writes = 0
absent?.address?.city = run {
writes += 1
"ignored"
}
known.address?.city = "Paris"
println(known.address?.city)
println(writes)
}Lyon
default computed
unknown
Paris
0读取已知城市不会打印default computed,说明Elvis右侧没有提前求值。writes最后仍为0,则证明接收者缺失时,安全赋值右侧的run整体被跳过。
这种短路能避免无用工作,也可能隐藏预期的副作用。若审计记录无论对象是否存在都必须写入,就应把记录操作放在安全调用之外,并显式记录失败原因。
在边界保留失败原因
第三个程序模拟把宽松的外部记录转换为领域对象。原始字段保持可空或未知类型,解析函数用安全转换、内容校验和提前返回,一次性构造只含有效非空属性的Order。
data class RawOrder(val id: String?, val cents: Int?, val customer: Any?)
data class Order(val id: String, val cents: Int, val customer: String)
sealed interface ParseResult {
data class Valid(val order: Order) : ParseResult
data class Invalid(val reason: String) : ParseResult
}
fun parseOrder(row: RawOrder): ParseResult {
val id = row.id?.trim()?.takeIf { it.isNotEmpty() }
?: return ParseResult.Invalid("missing id")
val cents = row.cents?.takeIf { it >= 0 }
?: return ParseResult.Invalid("invalid cents")
val customer = (row.customer as? String)
?.trim()
?.takeIf { it.isNotEmpty() }
?: return ParseResult.Invalid("invalid customer")
return ParseResult.Valid(Order(id, cents, customer))
}
fun main() {
val rows = listOf(
RawOrder(" A-7 ", 950, " Ada "),
RawOrder("B-2", null, "Lin"),
RawOrder("C-3", 400, 42),
)
rows.map(::parseOrder).forEach(::println)
}Valid(order=Order(id=A-7, cents=950, customer=Ada))
Invalid(reason=invalid cents)
Invalid(reason=invalid customer)这里没有用!!,因为每个局部变量都由Elvis的提前返回收窄。成功分支只能携带完整的Order,失败分支则保留字段级原因;两者比一个含多个可空属性的半成品更容易使用。
as? String把类型不匹配纳入普通失败路径,而不是抛出ClassCastException。它不负责验证字符串内容,所以后面的trim和takeIf仍然必要。
区分可空集合与可空元素
第四个程序为泛型增加T : Any上界,使成功结果不能包含null。缺失的整个标签列表被定义为空列表,但已出现的列表若含空元素,就被视为损坏数据并报告索引。
fun <T : Any> requireAll(values: List<T?>): List<T> =
values.mapIndexed { index, value ->
requireNotNull(value) { "value at index $index is null" }
}
fun normalizedTags(raw: List<String?>?): List<String> {
val tags = raw ?: return emptyList()
return requireAll(tags).map(String::trim)
}
fun main() {
println(normalizedTags(listOf("red", "blue")))
val message = runCatching {
normalizedTags(listOf("red", null, "blue"))
}.exceptionOrNull()?.message
println(message)
println(normalizedTags(null).size)
}[red, blue]
value at index 1 is null
0如果改用filterNotNull(),程序会安静地删除损坏元素,输出长度也会改变。只有当丢弃元素就是契约时才应这样做;需要保持位置或数量时,校验并报告索引更可靠。
raw ?: return emptyList()是一项明确的业务决定,不是空安全规则。若缺失列表与空列表含义不同,返回类型应保留这种差别,例如使用密封结果,而不是机械调用orEmpty()。
陷阱
修复: 先判断null属于调用方错误、对象状态错误还是正常分支,再分别使用requireNotNull、checkNotNull或Elvis返回。只有当崩溃本身就是测试断言的一部分时,!!才能清楚表达意图。
修复: 对真正可选的尾部字段使用安全调用;对必需的中间对象,在边界逐项校验并返回具体错误。评审时列出链中每个可能产生null的位置,确认它们是否真的共享一个业务含义。
修复: 把属性读取到局部val,对这个快照检查并使用。如果操作必须针对最新值保持原子性,局部快照仍不够,应在状态所有者内部加锁或提供一个原子操作。
修复: 为容器和元素分别定义null的含义,并测试数量、顺序和索引。数据损坏时应拒绝输入;只有产品规则允许忽略缺失项时才过滤。
修复: 在互操作边界查看Java的可空性注解,把未注解或不可信的结果立即赋给显式T?,然后规范化。不要用一次测试结果、接口文档中的示例或生成器的猜测替代真实注解和边界测试。
Java边界与平台类型
平台类型不是非空承诺
Java源码通常没有Kotlin类型系统所需的完整可空性信息。Kotlin编译器把这类Java表达式表示为 平台类型 (platform type),诊断和IDE中常显示为String!一类形式;这个感叹号不是可以写进Kotlin类型声明的语法。
平台类型具有灵活性:调用方可以把结果当作可空或非空类型,也可以直接访问成员。这样才能平滑调用既有Java API,但风险并未消失。若实际结果是null,赋给非空Kotlin变量时插入的检查或随后成员调用仍会失败。
受支持的@Nullable与@NotNull一类注解会让编译器增强Java签名。增强后的可空返回值必须按T?处理,非空返回值可以按T使用;注解与实现不一致时,运行时仍可能违反契约。因此,第三方边界既要检查注解,也要用返回null的测试替身验证失败行为。
平台类型应尽快离开核心领域逻辑。把Java结果先保存为显式T?,完成缺省、拒绝或错误映射,再向内部返回T或领域结果。这样,风险集中在一个适配器中,后续调用不会反复猜测Java方法的约定。
库边界需要双向验证
Kotlin编译器会为非空参数和部分返回路径生成运行时检查,但这些检查是契约的后卫,不是输入解析器。Java调用方传入null时,失败通常发生在Kotlin方法入口;异常能阻止无效值继续传播,却不能替代面向调用方的校验结果或协议错误。
从Kotlin导出给Java的API时,应同时编译一个Java使用方。测试要覆盖传入null、接收可空返回值以及泛型参数,因为Kotlin调用点能看到的信息可能比Java源码更丰富。只在Kotlin测试中使用同一API,无法证明互操作签名易用或注解正确。
从Java导入时,则先检查声明位置、类型参数和覆盖链上的可空性注解。实现类可能继承接口契约,库升级也可能增强原本的平台类型;重新编译后,警告或类型错误可能改变。适配器测试应锁定你依赖的行为,而不是锁定IDE曾经显示的!。
对于反射和序列化框架,还要测试它们是否绕过构造函数、属性setter或参数检查。静态声明为非空并不保证每种对象创建路径都执行了同一验证。把框架产生的对象先当作外部数据验证,再交给领域服务,可把这种不确定性留在边界层。
泛型默认允许可空类型实参
未写上界的Kotlin类型参数默认上界是Any?。因此fun <T> keep(value: T): T可以用String?作为T,函数体不能假定value非空。需要拒绝可空类型实参时,声明T : Any,正如示例中的requireAll。
T : Any约束类型实参,而T & Any表达确定非空类型(definitely non-nullable type)。后者要求T原本有可空上界,主要用于覆盖带非空注解的Java泛型成员。纯Kotlin API通常用普通上界和类型推断即可,不应为了看起来更严格而到处添加交集写法。
泛型擦除也不会在运行时恢复元素可空性。一个外部框架可能把含null的数据交给静态声明为List<String>的边界,尤其是通过Java、反射或不安全转换时。对不可信数据的结构和元素仍需在进入领域模型前验证。
仍可能出现的空指针异常
Kotlin文档列出的运行时来源包括显式throw NullPointerException()、对null使用!!、初始化期间的数据不一致,以及Java互操作。lateinit属性在赋值前访问会抛出UninitializedPropertyAccessException;它避免了可空属性,却没有证明初始化顺序正确。
空安全是一套编译期契约,不是对运行时对象图的净化器。反射、反序列化器、Java实现和并发状态都可能越过静态假设。边界测试应故意提供null,并断言系统在最接近来源的位置给出有意义的失败,而不是在远处偶然解引用。
4个问题 · 1道输出预测题 · 1道找错题