2.1 可空类型与调用链手术


2.1 可空类型与调用链手术

本节摘要:可空类型是 Kotlin 空安全的核心:类型系统把"可能为 null"变成类型的一部分,未经消解不能访问成员。本节讲全五种消解手段——安全调用 ?.、Elvis ?:、let 块、显式判空、非空断言 !!——各自的语义边界与选择标准,配 Java 对照与真实崩溃案例,最后给出可空类型的类型系统透视。

从一次线上崩溃说起

某电商应用的订单详情页,崩溃平台显示这样一条高频堆栈(脱敏后):

java.lang.NullPointerException: Attempt to invoke virtual method 'java.lang.String com.example.model.Coupon.getCode()' on a null object reference at com.example.order.OrderDetailActivity.showCoupon(OrderDetailActivity.java:132)

对应 Java 代码:

private void showCoupon(Order order) { // 优惠券是促销活动的赠品,没有活动时为 null——这个约定只存在于注释里 String code = order.getCoupon().getCode().toUpperCase(); tvCoupon.setText("优惠码:" + code); }

三层链式调用,任何一环为 null 直接崩溃。事后复盘发现:getCoupon 的注释写着"无优惠券时返回 null",但调用方是新来的同事,注释没传到他那里。这类事故的结构性原因在第 1 章讲过——Java 类型系统的承诺是假的,null 约定靠文档和口口相传

Kotlin 版本里,这个约定必须写进签名:

class Order(val coupon: Coupon?) // 可空性是类型的一部分 fun showCoupon(order: Order) { val code = order.coupon?.code?.uppercase() tvCoupon.text = "优惠码:$code" // code 的类型是 String?,编译器盯着它 }

同一个业务,可空性从注释变成了类型。调用方不处理 String?,下一行就用不了它——崩溃在编译期被拦下。本节余下部分把这把刀的每个用法过一遍。

类型规则:可空是另一个类型

先把地基打准。String? 不是"特殊的 String",它是独立的类型,与 String 是超类-子类关系:StringString? 的子类型。这带来三条推论:

val a: String = "hi" val b: String? = a // 合法:非空可以赋给可空 val c: String = b // 编译错误:可空不能赋给非空 val d: String = b ?: "" // 合法:Elvis 消解了可空

可空类型的成员访问被限制:b.length 编译不过,必须先消解。这是空安全的全部骨架——限制 + 消解。编译器在字节码层面做两件事兜底:对公开函数的非空参数插入 Intrinsics.checkNotNull 断言(Java 调用方传 null 时快速失败),对平台类型做推断。后者第 7 章展开。

安全调用:链式手术刀

?. 的语义一句话说清:左边为 null 则整个表达式为 null,否则取成员

val city = user?.address?.city // 任一环节为 null,city 即为 null

对照 Java 的四层嵌套判空:

String city = null; if (user != null) { Address addr = user.getAddress(); if (addr != null) { city = addr.getCity(); } }

安全调用链是空安全使用频率最高的形态,但有一个容易忽略的细节:链的每一环都是独立的短路点a?.b()?.c 里任何一个可空环节为 null,后面的环节全部跳过。这在语义上完全正确,但调试时要意识到:链断了,你未必知道断在哪一环。链超过三环且都关键时,建议拆开:

val address = user?.address ?: return val city = address.city // address 已非空,直接点

这也是一条通用纪律:安全调用链适合"取到就取、取不到拉倒"的展示型数据;关键路径数据用 Elvis 提前收口

Elvis:空时给个说法

?:(读作 Elvis 操作符,转九十度看像猫王发型)处理"为 null 时的替代方案":

val displayName = user.nickname ?: user.name ?: "匿名用户" val length = nickname?.length ?: 0

它比 Java 的三目运算符多用武之地在于右侧可以放任何表达式,包括提前返回:

fun bind(order: Order?) { val o = order ?: run { log("订单缺失,放弃渲染") return // Elvis 右支直接返回外层函数 } render(o) }

returnthrow 都可以出现在 Elvis 右支——编译器知道这些是"不产生值"的路径,所以整个表达式类型仍是 Order 而非 Order?。这个技巧叫"提前收口",是把判空从嵌套结构改成卫语句的关键工具。错误处理场景下,右支放 throw IllegalStateException("订单不存在") 也常见,此时崩溃信息比 NPE 明确得多——要么给值、给默认、要么明确地死,唯独不许不明不白地死

一个必须知道的坑:Elvis 不会区分 null 与 Kotlin 所谓的"falsy"值。val n = list?.size ?: 0 在 list 为空列表时返回 0(size 为 0),在 list 为 null 时也返回 0——两种业务含义可能完全不同。需要区分时显式判空。

let:只在非空时执行整段逻辑

安全调用取单个成员够用,但"非空时执行一段逻辑、空时整段跳过"需要 let:

val token: String? = sessionManager.token() token?.let { t -> api.addAuthHeader(t) log("已附加认证头,长度 ${t.length}") }

?.let { } 的组合读作"非空则把它交给这段代码"。lambda 参数 t 在块内是非空 String——智能转换在起作用。对照 Java:

String token = sessionManager.getToken(); if (token != null) { api.addAuthHeader(token); Log.d(TAG, "已附加认证头,长度 " + token.length()); }

形态相近,但 Kotlin 版的保证是结构性的:你不可能在块外用 t,也不可能忘写判空——判空和作用域绑定了。it 是单参数的默认名,块超过三行就显式命名({ t -> }),可读性差距明显。

显式判空与智能转换

第四种手段是最朴素的 if 判空,但 Kotlin 给它加了智能转换:

val nickname: String? = ... if (nickname != null) { println(nickname.length) // 编译器已知非空,无需 ?. }

判空之后,nickname 在分支内自动被当作 String 使用。智能转换的边界也要知道:它只对编译器能证明"判空后不会再变"的值生效——val 局部变量、不可变的类属性可以;var 局部变量在多线程下可能被并发修改、自定义 getter 每次取值可能不同,这两种情况智能转换会失效并报错,提示你先复制到局部 val。这个"失效提示"本身就是一处安全设计:编译器拒绝在不能保证的时刻给你保证

!! :信任的代价

最后一种是非空断言 !!:告诉编译器"我担保非空,崩了算我的"。

val length = nickname!!.length // nickname 为 null 时抛 NPE(Kotlin 版)

它存在的原因是互操作与泛型场景里有时无法向编译器证明非空。但工程纪律上应把它当作代码异味信号

  • 团队规范里可以约定 !! 必须伴随注释说明"为什么这里不可能为 null";
  • 每一个 !! 都应该先尝试用 ?:throw 替代——后者至少给出了体面的错误信息;
  • CI 里可以用 Detekt 的 UnsafeCallOnNullableType 规则直接报警(第 8 章配置)。

!! 的瞬间,你退回了 Java 的世界。差别只是:Java 是全员裸奔,Kotlin 里你至少知道自己在裸奔。

空安全类型系统透视

下图把本章的概念放进一张类型层次透视:非空类型是可空类型的子类型,五种消解手段是可空世界回到非空世界的五座桥,桥下的裂缝是平台类型。

空安全类型系统透视

一道完整的改写练习

把本节开头的优惠券崩溃改成"健壮版",综合运用四种手段:

class Order( val id: String, val coupon: Coupon?, // 可空性进签名 val promoter: User? ) fun showCoupon(order: Order) { // 关键路径:优惠券存在才渲染整块区域,否则隐藏——用 let 跳过 order.coupon?.let { coupon -> couponArea.isVisible = true // 展示链:code 理论必有,但防御性兜底小写回退 tvCoupon.text = "优惠码:${coupon.code?.uppercase() ?: "已失效"}" tvDesc.text = coupon.description ?: "无说明" } ?: run { couponArea.isVisible = false } // promoter 链可能为空,展示型数据直接安全调用加 Elvis tvPromoter.text = order.promoter?.name ?: "官方活动" }

逐条对上决策图:coupon 的"存在才渲染"用 let;code 的展示用安全调用链加 Elvis;promoter 同理。没有一处 !!,没有一处嵌套超过两层。Java 版本实现同等健壮性大约需要三倍行数的嵌套判空——而且没有任何机制阻止下一个人在新调用点偷懒。

收尾:三条团队纪律

把本节收成可直接落进 code review 的三条。其一,可空性必须出现在签名里:函数参数、返回值、类属性,凡是可能为 null 的都标问号,不许靠注释传递约定。其二,!! 需要注释陪同:写明为什么不可能为 null,让断言成为有据可查的决策而不是偷懒。其三,安全调用链不超过三环:超过就拆开收口,让断点可定位。三条都不难,难的是坚持——好在这三条都是机器可查的,第 8 章的 Detekt 配置会把它们变成 CI 的硬门槛。

常见问题

问:安全调用链 a?.b?.c 写起来很爽,但出问题时怎么知道断在哪一环?
这是安全调用链的真实代价:短路点不可观测。三个实用对策。其一,关键路径不用长链,改用 Elvis 提前收口(本节的纪律),收口处的日志能定位到"断在收口之前"。其二,调试期临时把链拆成带日志的中间变量,定位后合回。其三,链的长度本身要控制——三环以上的链几乎总是意味着数据建模有问题:如果 user?.address?.city?.name 频繁出现,说明业务上真正需要的是一个 userCityName(): String? 的领域函数,把可空性知识封装在一处,调用点只管消解一次。

问:团队里 Java 老代码返回的值全是平台类型,?.!! 满天飞,怎么治理?
分两步。短期靠边界收容:给高频 Java 调用点写门面函数,在门面里完成可空性判决(第 7 章的架构纪律),业务代码只见确定类型。长期靠注解:给自家 Java 代码逐步补 Nullable 与 NonNull 注解,每补一个方法,全工程对该方法的调用点自动升级为受保护状态。治理的优先级按崩溃平台排——哪类 Java 返回值历史上贡献过 NPE,先收容哪个。别追求一次清完,边界问题要按事故频率还债。

问:既然 !! 这么危险,语言为什么不直接禁掉它?
因为总有编译器无法证明、但开发者掌握额外信息的场合:泛型擦除后的类型收窄、测试代码里的前置断言、互操作边界的契约。语言的设计立场是"不阻止你冒险,但要求你签字"——!! 的两个感叹号在代码评审里非常显眼,配合静态检查可以做到"每个 !! 都有注释陪同"。把它禁掉反而会逼出更糟的写法,比如先判空再硬转。工具的边界感也是设计能力:好的语言把危险操作的可见性做足,而不是假装危险不存在。

本节要点回顾

  • 可空是独立类型String?String 是超类-子类关系,非空可赋给可空,反向必须消解;
  • 安全调用 ?.:链式取值的短路刀,适合展示型数据;链超三环应拆开收口;
  • Elvis ?::兜底与卫语句,右支可放 return 与 throw 实现提前收口,注意不区分 null 与默认值碰撞;
  • let 块:非空才执行整段逻辑,附作用场景首选;块内智能转换为非空类型;
  • 显式判空加智能转换:复杂分支的正路;var 与自定义 getter 上智能转换失效是刻意的安全设计;
  • !! 是自毁开关:能用 Elvis 加 throw 替代就替代,必须用时注释说明,交给静态检查兜底;
  • 平台类型是裂缝:Java 边界的值绕过全部机制,第 7 章专门收容。

可空解决的是"值可能不存在",但安卓里还有一类"值暂时不存在"——视图要等 onCreate、依赖要等注入。下一节处理时间维度上的空窗。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U