上周 Code Review 看到一个 15 行的判空逻辑,被同事改成一行就合进主干,我当时心里只有一个想法:瞧瞧人家这判空,那叫一个优雅。话说回来,写代码的人没有谁没遇过 NullPointerException,也没有谁没在代码里写过一堆if (xxx != null)。判空这件事看着不起眼,但干系重大——写得好,代码像流水一样顺;写得烂,层层嵌套、边界不清,藏满地雷。这篇我就想把这些年折腾 Java、Kotlin、前端 TypeScript 判空的经验整理出来,从原理到写法,从实战到避坑,一次说清楚。
1. 判空这件小事,为什么值得专门写一篇
1.1 一个让我破防的 Code Review 现场
先说那次让我破防的 review。需求很简单:根据用户 ID 查订单,订单里取收货地址,地址里取城市名,取不到就返回"未知"。我们原本的写法是这样:
public String getOrderCity(Long userId) { Order order = orderService.getById(userId); if (order == null) { return "未知"; } Address address = order.getAddress(); if (address == null) { return "未知"; } String city = address.getCity(); if (city == null) { return "未知"; } return city; }逻辑没错,编译也通过,但读起来真的很累。三层 if 层层嵌套,每一层都在做同一件事:判断"有没有",然后决定"是继续还是兜底"。这种代码在业务里一多,满屏都是防御性的分支,真正想看的业务规则反而被淹没了。
同事改完之后是这样的:
public String getOrderCity(Long userId) { return Optional.ofNullable(orderService.getById(userId)) .map(Order::getAddress) .map(Address::getCity) .orElse("未知"); }同样的业务逻辑,一个像绕迷宫,一个像流水线。从那之后我就开始系统性地整理判空的写法,今天这篇文章算是一个总结。
1.2 判空的本质:不是"判断",是"防御与表达"
很多人把判空简单地理解成"加个 if 防止崩掉",这么想没错,但想浅了。真正好的判空,在我看来承担了三层职责:
第一层是防御,防止 NPE 让程序崩溃,这是底线。第二层是兜底,数据缺失的时候给一个合理的默认值或错误提示,而不是把问题抛给上层去猜。第三层是表达,也就是告诉读代码的人:这里允许为空,空的含义是什么,接下来的逻辑如何继续。
第三层最容易被忽略。你看if (user != null)这个写法,它只表达了"我害怕 user 是 null",但没有表达"user 是 null 之后该怎么办"——处理方式散落在 if 分支、else 分支和更后面的兜底代码里。而Optional.ofNullable(user).map(...).orElse("默认值")这种写法,从头到尾只有一条主线,空与不空的两种结果清清楚楚。
理解了这层,你再看各种语言的判空技巧,会发现它们的本质是同一件事:把"空"这个状态,从散落的 if 里抽离出来,变成一个可以被组合、被传递、被兜底的值。后面的 Java、Kotlin、前端那几套玩法,都是在这个思路下衍生的工具。
2. Java 的判空进化:从 if 连环套到 Optional、Objects、Stream 的组合拳
2.1 最原始的 if 判空:能跑,但丑
Java 老玩家对下面这种写法应该不陌生:
if (order != null && order.getItems() != null) { for (OrderItem item : order.getItems()) { if (item != null && item.getSku() != null) { // 业务逻辑 } } }这种代码的问题不在于它错了,而在于它把"空值检查"和"业务逻辑"搅在一起。读代码的人必须瞪大眼睛辨认:哪一层 if 是在防 NPE,哪一层 if 才是真正的业务判断?一旦嵌套超过三层,基本就没人愿意去动它了——改一处怕影响另一处,这是烂代码的典型特征。
我知道有些朋友会说"我就在项目里这么写的,也没出多少问题"。没错,小型项目或者一次性脚本完全能跑,但如果这段代码要被维护五年、被十几个人接力改,那每多一层嵌套,都是在给后来的人埋棍子。
2.2 Objects.requireNonNull:把"我拒绝空值"写进代码
还有一种常见场景:方法内部要求参数绝对不能为空,但调用方乱传。与其在方法里写if (param == null) { throw new IllegalArgumentException(); },不如用 JDK7 就有的Objects.requireNonNull:
public void createOrder(Order order) { Objects.requireNonNull(order, "order must not be null"); // 业务逻辑 }这样写省了三行代码,而且语义更直白:这个方法根本不接受 null。跟手写 if 相比,它最大的价值是把"这个方法对空值的态度"直接摆在了方法第一行,后续维护的人一眼就能看懂契约。
2.3 Optional 的正确打开方式:map、filter、orElse 组合拳
Java 8 引入 Optional 之后,很多人欢呼,也有很多人骂,说它是"包装了 null 的 null"。我的态度是:Optional 本身没问题,问题是太多人把它用错了地方。正确的用法就是像开头那个例子一样,用它来处理单值对象的判空与兜底链。
再看一个更复杂的场景。假设有一个订单,支付完成后需要更新库存,但支付信息和订单都可能不存在:
return Optional.ofNullable(order) .map(Order::getPayment) .map(Payment::getPaidAmount) .filter(amount -> amount.compareTo(BigDecimal.ZERO) > 0) .map(this::deductStock) .orElseThrow(() -> new BizException("订单未支付,无法扣库存"));这里map负责"如果存在就取下一层",filter负责"如果值不满足条件就当作空",orElseThrow负责"最后兜底报错"。整条链读下来,就是在描述一个完整的业务前置条件。
要注意的是几个方法的差异:
| 方法 | 作用 | 使用场景 |
|---|---|---|
orElse(value) | 为空时返回固定值 | 常量、已经创建好的对象 |
orElseGet(Supplier) | 为空时才执行方法取默认值 | 默认值需要计算或查库 |
orElseThrow(Supplier) | 为空时抛异常 | 数据缺失属于异常情况 |
ifPresent(Consumer) | 不为空时才执行后续动作 | 判空后只做一件事,不需要返回值 |
这里有一个很多新手会踩的坑:orElse里的参数是立即求值的。如果你写optional.orElse(expensiveMethod()),哪怕 Optional 不为空,expensiveMethod()也会先被执行一遍,白白浪费性能。要用orElseGet(() -> expensiveMethod())才能做到非空不执行。我见过有人因为这个在线上接口里多打了几次数据库查询,排查了半天才发现是 orElse 惹的祸。
2.4 Stream 集合判空 + 工具库:一次处理一批
集合类的判空和单值判空不太一样,因为集合本身可能为空,集合里的元素也可能为空。最稳妥的组合拳就是 Stream 加Objects::nonNull:
List<Order> orders = getOrders(); List<String> cityList = orders.stream() .filter(Objects::nonNull) .map(Order::getAddress) .filter(Objects::nonNull) .map(Address::getCity) .filter(Objects::nonNull) .toList();filter(Objects::nonNull)这一行看起来简单,却解决了"集合里混进来 null 元素"这个老大难问题。如果你用的是 Spring,强烈建议顺手把CollectionUtils.isEmpty也用起来,判断空集合别再手写list == null || list.size() == 0了:
if (CollectionUtils.isEmpty(orderList)) { return new ArrayList<>(); }工具类的价值就是把这些重复劳动收编到一个语义清晰的名字后面,减少出错,也让代码的意图更明确。
3. 真正优雅的判空在 Kotlin:编译器替你盯着
3.1 可空类型:把风险写进类型系统
Kotlin 刚出来的时候,我被 Java 的判空折磨得不轻,看到 Kotlin 的可空类型设计后,第一反应是:这不就是把"判空"从运行时提前到了编译期吗?
在 Kotlin 里,String和String?是两种完全不同的类型。前者表示"这个变量永远不能是 null",后者表示"它可能是 null"。如果你试图把一个String?直接当String用,编译器直接报错:
val name: String? = null println(name.length) // 编译错误:name 可能为 null这意味着什么?意味着很多 Java 里要等到运行期才爆的 NPE,在 Kotlin 里写代码的那一步就被编译器拦住了。类型系统替你做了一层"静态判空",这是 Java 的 Optional 无论如何都做不到的。
3.2 安全调用和 Elvis:两行代码解决十行 if
Kotlin 最常用的判空工具是两个运算符。安全调用?.,含义是"如果对象不为空才继续往下访问";Elvis 运算符?:,含义是"如果结果是空,就使用默认值"。
val city = user?.address?.city ?: "未知"这一行就等价于开头 Java 版的十来行嵌套 if。它把"判空 + 逐层取属性 + 兜底"压缩成了一个表达式,读起来像一条从 user 到 city 的路径,中间任何一环为空,就落到?:后面的默认值。
我当时把这个写法带回团队的时候,有同事问:这不就是语法糖吗?本质上不还是判空吗?对,它就是语法糖,但这个语法糖把"判空"这个过程压缩到了几乎无感知的程度,让代码的主干逻辑清晰得可怕。业务代码里 80% 的判空,用?.和?:都能解决掉。
3.3 let/run/apply:判空后马上干活
还有一种场景:判空之后不是为了取一个值,而是要做一连串动作。这时候用?.let最顺手:
user?.let { currentUser -> logger.info("用户登录成功: ${currentUser.name}") updateLastLoginTime(currentUser.id) sendWelcomeMessage(currentUser.email) }let的作用是:把前面的非空对象作为参数传进代码块里,只有当对象不为 null 时代码块才会执行。比起 Java 里if (user != null) { ... },这种写法更紧凑,而且编译器还会提示你:在 let 的代码块内部,currentUser被自动推断为非空类型,不需要再做任何二次判空。
类似的还有run、apply、also,它们的核心区别在于返回值不同。let返回代码块最后一行,apply返回对象本身,run返回代码块结果,also返回原对象。实际使用时记住一个原则就够了:要返回新值用 let 或 run,要链式修改对象本身用 apply 或 also。
3.4 什么时候不推荐 !!
Kotlin 还有一个非空断言运算符!!,它的意思是"我很确定这个值不为空,如果为空就主动抛出 NPE,别拦我"。这玩意儿能少写判空,但一定要克制。
val orderId = order!!.id // 如果 order 为 null,直接崩我见过不少同事为了过编译顺手写!!,结果线上偶发崩溃全都发生在这些行。我的建议很明确:!!只允许出现在你显式声明过前置条件的地方,比如前面已经用if (order == null) return拦截过,或者这个值来自某个你明确保证非空的分支。其他地方一律用?.+?:,宁可用默认值,也别把崩溃的风险留给运行时。
4. 前端判空:JS/TS 的可选链和空值合并,真香
4.1 那些年被 undefined 支配的恐惧
如果你写过后端接口对接,大概率见过这个报错:
TypeError: Cannot read property 'city' of undefined后端返回的数据结构稍微一变化,前端拿到的对象就少了一层,然后整页白屏。前端老玩家以前都这么防:
if (res && res.data && res.data.user && res.data.user.address) { const city = res.data.user.address.city; }这串&&链读起来比 Java 的嵌套 if 还痛苦,每多一层要防的字段,链子就长一截。当年我维护过一个接口,返回结构五层嵌套,前端判空链写了三行,丑到没眼看。
4.2 ?. 可选链:一路点下去,点到空就停
现在浏览器和 Node.js 都普及了 ES2020,可选链?.直接把上面那种 && 链给终结了:
const city = res?.data?.user?.address?.city ?? '未知';?.的作用是:如果?.前面的值是 null 或 undefined,整个表达式直接短路返回 undefined,后面的属性访问不会执行。这样一处判空,覆盖了整条链路,代码从三行缩成一行,可读性反而更高。
需要注意一个细节:可选链不仅能用在属性访问上,还能用在方法调用上:
const result = calculator?.add?.(1, 2);第一个?.判断 calculator 是否存在,第二个?.判断 add 方法是否存在,两者都满足才执行调用。这在处理某些可能在不同版本接口里存在差异的方法时非常有用。
4.3 ?? 空值合并和 || 的区别:0、'' 要不要当成空
可选链常和空值合并运算符??搭配,但这里有个经典坑:很多人把??当成||的替代品,其实它们语义完全不同。
const retry = options.retryCount || 3; // 0 被当成空,结果是 3 const retry = options.retryCount ?? 3; // 0 保留,只有 null/undefined 才走默认值||做的是"真值判断"——0、空字符串、false、NaN 都会被当成"空";而??只关心"这个值是不是 null 或 undefined"。涉及配置项、数值参数这类场景,如果用||就会把合法的 0 给吞掉。我踩过一次:重试次数配置成 0(意思是完全不重试),结果被|| 3改成了 3,线上重试了三次才意识到问题。这条经验写出来希望你们别重蹈覆辙。
4.4 解构默认值和函数参数的默认值:少写一堆 if
还有一种隐形的判空,发生在"取对象属性"和"传函数参数"的时候。ES6 的解构默认值能提前兜底:
const { city = '未知', province = '未知' } = user?.address ?? {};当user.address为 undefined 时,?? {}会把它兜底成空对象,然后解构出来的 city 和 province 就会落到默认值上。这一行解决了"整个对象可能不存在 + 属性可能缺失"两层判空。
函数参数的默认值同理:
function renderList(items = []) { return items.map(item => item.name); }调用方哪怕传个 undefined 进来,也不会崩,因为参数默认值已经在函数签名里处理掉了。这些写法组合起来,前端代码里的显式 if 判空能少掉 70% 以上。
5. 集合、接口和数据库:判空的三大重灾区
5.1 集合一律返回空集合,不要返回 null
判空这件事,最根本的解法其实是从源头上消灭 null。我最强调的一条铁律就是:所有返回集合的方法,要么返回数据,要么返回空集合,永远不要返回 null。
// 反面教材 public List<Order> getOrders(Long userId) { List<Order> orders = orderMapper.selectByUserId(userId); return orders == null ? null : orders; } // 正面教材 public List<Order> getOrders(Long userId) { return orderMapper.selectByUserId(userId); // 约定查询层永远返回空集合 }一旦全链路都遵守"集合不为 null"这条约定,调用方就可以直接for (Order order : orders)或者orders.stream(),连判空都不用写。Java 9 之后还可以用List.of()、Map.of()创建不可变空集合,更安全,也免得有人往里 add 出问题。
这条约定一定要靠团队规范写死,因为 MyBatis、JPA 这类框架在不同配置下返回空集合还是 null 的行为并不一致。在一开始就把返回值约定清楚,能省掉后续海量的CollectionUtils.isEmpty。
5.2 Controller 入参校验:用注解代替手写判空
接口层的判空,很多人还在手写:
if (request.getUserId() == null) { return Result.fail("用户ID不能为空"); } if (request.getProductId() == null) { return Result.fail("商品ID不能为空"); }在 Spring Boot 里,这些统统可以用校验注解替代:
public class CreateOrderRequest { @NotNull(message = "用户ID不能为空") private Long userId; @NotBlank(message = "商品名称不能为空") private String productName; @Min(value = 1, message = "购买数量必须大于0") private Integer quantity; }Controller 方法加上@Valid,Spring 会在进入业务逻辑之前自动完成校验,不通过就抛出异常,再由全局异常处理器统一转成响应:
@PostMapping("/order") public Result createOrder(@RequestBody @Valid CreateOrderRequest request) { return orderService.create(request); }这样做的好处是:判空逻辑从遍布各处的 if 中收拢到 DTO 上,字段级别的约束一眼可见,新增字段也顺手就能加上校验。跟手写 if 相比,可维护性不是一个量级。
5.3 从数据库查出来的空:定义清楚是"不存在"还是"空"
数据库场景里最容易让人糊涂的是:查出来的结果到底是 null 还是空值?我的经验是,在代码层面要把三态定义清楚。
- null:表示"这条记录不存在"。
- 空字符串 / 空集合:表示"记录存在,但某项数据确实没填"。
- 默认值:表示"没有数据但有约定兜底",比如金额默认 0。
在 Java 里我倾向于用 Optional 表达"记录可能不存在":
@Override public Optional<User> findUserByEmail(String email) { User user = userMapper.selectByEmail(email); return Optional.ofNullable(user); }配合 Stream 的flatMap,可以很自然地表达"如果用户存在,再查他的订单"这种链路:
Optional<Order> activeOrder = findUserByEmail(email) .flatMap(user -> orderMapper.findActiveOrderByUserId(user.getId()));flatMap的核心作用是避免出现Optional<Optional<Order>>这种嵌套结构,让两个 Optional 之间可以无缝衔接。这一套组合,基本能覆盖掉后端开发 90% 的"数据库查出来可能是空的"场景。
6. 优雅判空的几种反模式:好看不等于好用
6.1 Optional 滥用:当字段、当参数,性能与语义双重透支
前面夸了 Optional 很多,这里必须泼冷水:Optional 不是哪里都能用。最典型的反模式是把它当成实体字段放进去:
public class User { private Optional<String> email; // 不要这么干 private Optional<Address> address; // 不要这么干 }这样做的直接后果是:序列化时 Optional 没有可靠的 JSON 映射,很多框架会直接抛异常或者输出奇怪的嵌套结构;而且公司里的 ORM 框架普遍不支持 Optional 字段映射,MyBatis 遇到 Optionl 属性基本束手无策。
Optional 的正确使用范围就是方法返回值和局部变量。它适合表达"这个方法的结果可能没有值",绝不适合做 DTO、PO 的字段类型。把这一点想清楚,就不会被"万物皆可 Optional"的歪风带偏。
6.2 安全调用把异常吞了:Bug 藏得更深
Kotlin 的?.和 Java 的 Optional 都很优雅,但过度使用会让真正的 bug 无处显形。举个真实例子,我同事在代码里写:
order?.payment?.button?.resend() // 看起来没毛病结果上线后,用户死活收不到重发支付链接的邮件,日志里一点异常都没有。排查了半天才发现:order在进入这个方法之前就已经因为前面的逻辑被置为 null 了,安全调用直接静默跳过,整个方法"什么都没做但也没报错"。
判空是有代价的——它会拦截"空值"这个信号。当你用?.把一连串可能为空的对象串起来时,任何一个环节为空,整条链都会静默短路。所以我的经验是:业务执行链路上的关键节点,不要全程用安全调用,该用?:兜底报错的地方就明确抛出来。判空的目的是防御,不是把问题藏起来。
6.3 "判空"和"防空"是两回事,别混为一谈
最后聊一个语义层面的坑:判空和防空是两回事。
- 判空的"空"是 null,表示"这个东西不存在"。处理方式是给默认值、走替代方案或者抛异常。
- 防空的"空"是空字符串、空集合、空数组、0,表示"东西存在,但内容是空的"。处理方式往往是提示用户"数据未填写"或者"暂无内容"。
把这两者混为一谈的典型错误是:if (StringUtils.isEmpty(str))处理占位符时,把空字符串当成 null 直接忽略掉了,结果页面上本该显示"该用户未填写昵称"的地方变成了空白。
所以我建议在团队里明确约定:能用Objects.isNull就用它,能用StringUtils.isNotBlank就说明你要同时处理 null 和空串。别图省事用一个"万金油判空方法"处理所有场景——那往往会同时漏掉你想拦截的空和不该拦截的空。
6.4 团队落地:把判空规范变成 Code Review 里的硬条款
工具和技巧讲再多,最后都得落到团队协作上。我这边在实践过一段时间后,沉淀出了几条 Code Review 必查的判空条款,分享出来供参考:
- 集合返回值不允许为 null,必须为空集合,Review 时看到
return null直接打回。 - Controller 入参必用校验注解,禁止在方法体内手写一堆 if 判空。
- Optional 只用于返回值,禁止出现在实体字段和方法参数里。
- Kotlin 代码里
!!必须有前置保障,普通取参一律?.+?:。 - 前端接口解析禁止长链
&&,强制可选链 + 空值合并。
这几条看着简单,落地之后最大的变化不是代码变短了,而是读代码的人不用再猜"这行有没有在防 NPE"。判空的写法稳定了,业务逻辑的主线就清晰了,新人接手的时候也知道该往哪里加自己的逻辑。
我个人其实并不提倡追求"一行代码解决所有判空"这种极致炫技,真正的优雅不是代码最短,而是让空值的语义清楚、处理路径唯一、出问题时能快速定位。工具都在这里了,选择权在你自己手上,希望这篇能帮你找到最适合团队的那套判空风格。