ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

纪念托尼·霍尔:从快速排序到“十亿美元的错误”null

纪念托尼·霍尔:从快速排序到“十亿美元的错误”null 托尼·霍尔走了。听到消息时我正盯着一个空指针异常发呆——这大概是我能想到的一位写代码的人向这位图灵奖得主致敬的最应景方式。null这个被他亲手带进编程世界的概念至今仍然以NullPointerException、Cannot read property of undefined、attempt to index a nil value这样或那样的面目折磨着每一位靠代码吃饭的同行。我最早知道托尼·霍尔是因为排序和并发后来知道他发明了null又气又笑。再后来读了他公开演讲里那句“这是十亿美元的错误”反而对这个老爷子多了一份敬意。一个真正的技术大师不回避自己当年的失策甚至愿意把这个故事讲给全世界听。这篇文章不打算只写悼念我想顺着他的经历把null为什么会出现、凭什么让无数程序员头疼、以及今天的我们到底该怎么处理它一次讲清楚。无论是写Java、C#、PHP、前端还是数据库只要还在写代码这些内容应该都用得上。1. 托尼·霍尔是谁从古典学到图灵奖的跨界之路1.1 一个“文科生”怎么会走进计算机世界先把最反常识的事情说清楚托尼·霍尔一开始根本不是什么科班出身的技术专家。他1934年出生在斯里兰卡童年随家人回到英国后来进入牛津大学读书读的是古典学。这个专业今天讲可能有点陌生说白了就是古希腊语、拉丁语、古代史和哲学这些东西妥妥的“文科精英路线”。按正常剧本他应该去当一位古典学教授或者外交官而不是在几十年后拿图灵奖。事情发生变化是因为他对语言本身的兴趣。古典学教他的不是背单词而是怎么去拆解文本、怎么严谨地分析表达结构。接触了机器翻译课题之后他突然发现计算机程序本质上也是一种“形式语言”语法、语义、编译器这些概念和他在古典学里研究的文本结构有不少相通之处。于是他开始自学编程然后一步步扎进了编译器和程序设计语言研究。这段经历放到今天也很有参考价值。很多半路转行做技术的人总觉得自己基础不如科班觉得自己“不是学计算机的”但霍尔的路径说明跨领域视角反而可能成为优势。古典学训练的那种对语义精度的敏感后来体现在他对程序证明、类型系统等领域的贡献上。转行不怕晚怕的是只看代码不思考语言和逻辑本身的本质。1.2 图灵奖背后的三块基石快速排序、Hoare逻辑与CSP普通程序员可能不知道其实你每天都在用霍尔的工作成果。第一个就是快速排序。1960年前后他在国家物理实验室工作要给词典条目做排序于是设计了quicksort算法。平均时间复杂度O(n log n)不需要额外大块内存直到今天JDK里的排序实现、C标准库里sort的某些分支、各种数据库的sort算子都还带着它的影子。你每一次看淘宝订单按时间倒序排列背后都可能有快速排序的思想在跑。第二个是Hoare逻辑。他提出了一套公理化体系用来严格证明一段程序“做完之后一定满足什么条件”。这套东西早期看是纯学术但后来给了程序验证、形式化方法非常扎实的地基。即使你日常不写证明它也在潜移默化影响编译器优化和静态分析工具的落地思路。第三个是CSP通信顺序进程。1978年他发表的论文把并发系统建模成互相通信的顺序进程。你写Go语言的时候goroutine加上channel那个模型源头之一就是CSP。不管是Kubernetes里的调度、云原生中间件还是各种异步框架并发设计多多少少都能看到霍尔的影子。1980年他拿下图灵奖后来又因为对计算机科学的贡献被封爵。所以别只记住“发明null被骂了几十年”这个梗老爷子在正经科班领域做出的成就已经足够称得上现代计算机科学的奠基人之一。2. null的诞生一次“为了省事”的设计成了几十年的技术债2.1 1965年的那次设计决策时间拉回1965年霍尔正在设计ALGOL W语言。ALGOL W是ALGOL 60的后继版本也是早期面向对象语言的重要尝试。在设计引用类型的时候他碰到一个工程问题引用类型的变量初始状态到底应该是什么如果让每个变量创建时必须给一个合法对象程序员得多写很多初始化代码而且不少场景确实需要“还没有对象”的状态比如链表尾部、等待异步返回的占位、未配置的可选项。于是他决定给所有引用类型一个统一合法的“空值”叫做null。这样编译器在变量初始化的时候可以自动把引用置为空程序员不需要再显式处理“未初始化”状态。听起来很方便对吧在1965年的语境里这个决策真的很自然。问题出在后面一旦引用类型默认可能是null所有对引用的使用就都多了一个隐藏风险。当年的编译器不会帮你做严谨的空指针检查这份工作就落到了运行时。于是空指针错误开始蔓延。霍尔自己在2009年一个技术大会上的演讲题目就叫“Null References: The Billion Dollar Mistake”。他非常坦诚地说这是十亿美元的错误。他在演讲里的态度不是辩解而是反思当时设计这个特性时目标其实是想保证引用使用的安全性结果反而制造了一整个崩溃王国。后来各种语言虽然改了命名、加了语法糖但核心的“空引用”问题基本都还在。读到这篇演讲的时候我反而觉得一个敢于剖析自己设计缺陷的人才是真的大家。2.2 为什么说它是价值十亿美元的错误咱们把成本算一算。NullPointerException在Java领域常年霸榜是所有线上异常里最常见的那一类JavaScript里面的cannot read property of undefined、TypeError本质上也是同一个问题PHP的有名告警“Trying to access array offset on value of type null”一样是它C里稍微不小心就是个空指针崩溃。如果只算程序员查问题、打补丁、加班的时间成本这数字远不止十亿美元。更麻烦的是null不只是让单个程序崩溃它还会渗透到业务的每个角落。一个登录接口返回null前端直接白屏一个数据库字段允许NULL聚合查询SUM和AVG的结果就可能被悄悄忽略一个配置项没读到服务启动之后某个功能不声不响就挂了。我遇到过一个特别典型的线上故障订单支付回调成功后存储系统向消息队列推了一条记录因为订单备注字段为null消费方拿这个字段去拼字符串直接NPE导致后续对账流程中断最后人工查了两小时才知道是一条“空备注”惹的祸。这就是null的可怕之处你写代码时根本防不胜防。关键还在于不是所有崩溃都看得见。分布式系统里A系统收到null之后可能按业务逻辑走了错误分支给下游返回了一个错误的结果而下游完全不知道问题是空值导致的。整个排查链路变得又臭又长。霍尔把“十亿美元”挂在嘴边说的是他估算的代价。以我个人的实际感受这几十年算下来全球程序员为null付出的时间成本恐怕还要再乘上几倍。2.3 null的真正麻烦不是值而是“消失的上下文”我后来想明白一个问题null本身只是一个值它真正让人头疼的是语义过于模糊。你看这一列情况返回值是null的时候它到底在表达什么意思表格列一下null出现的位置可能的真实含义数据库字段为NULL从未填写、无意义、未知接口返回data为null查询无结果、请求失败、无权限缓存读取为null缓存未命中、缓存已过期Java反序列化结果为null报文格式不对、必填字段缺失方法参数为null调用方故意不传、调用方忘记传并发初始化时报null依赖未完成装配、生命周期错位同样是null原因千差万别。你排查空指针的时候最讨厌的不是那一行代码报错而是得从头猜这个值到底该不该是null如果它是null是数据有问题还是程序逻辑有问题还是环境配置问题这种“上下文缺失”让null的排查成本极高。而且从类型系统的角度看null等于给所有类型开了一个后门。类型写着String运行时却可能是一个“什么都没有”的值静态类型检查完全拦不住。你要么在代码里到处加判断把代码搞得像防空洞要么信任类型然后祈祷运行时不出意外。现代语言设计的一个重要方向就是把“空”从类型系统底层变成显式的一部分也就是后面要聊的Optional、Option这些方案。理解了这些你就明白为什么霍尔的反思会推动整个行业重新思考空安全问题。3. 当你遇见null现实世界里那些典型报错与排查思路3.1 数据库排序与nullasc/desc背后被忽视的语义最近看到有人说“asc和desc以及null”这几个关键词一起出现频率很高我一点不意外。数据库排序时null的默认位置十个开发九个会踩坑。问题是不同数据库对NULL的排序规则并不一致。MySQL里NULL被视为最小值所以升序排在最前降序排在最后而Oracle和PostgreSQL把NULL视为最大值升序排最后降序排最前。这就导致一个经典事故开发环境用MySQL测得好好的上线切到PostgreSQL列表顺序就变了。要解决这个问题最直接的办法是显式指定NULL的位置。PostgreSQL、Oracle和SQL Server 2022以后都支持NULLS FIRST或NULLS LAST所以在SQL里写清楚就行。比如SELECT id, update_time FROM article ORDER BY update_time DESC NULLS LAST;如果用的是MySQL那就要绕一下先按“是否为NULL”排序再按真实值排序也就是把null的判断当成一个排序键SELECT id, update_time FROM article ORDER BY (update_time IS NULL) ASC, update_time DESC;这里update_time IS NULL在MySQL里返回0或1ASC会让非NULL值排前面然后同组内再按时间倒序。这个写法看着有点绕但实际非常稳。还有个容易忽略的点ORM里面如果允许前端传排序字段一定要做白名单校验别让order by null这种情况出现。之前我在一个项目里就遇到过排序字段动态拼接结果前端传了个空字符串MyBatis生成的SQL变成ORDER BY后面什么东西都没有数据库直接报语法错误。这类问题本质上也是“排序逻辑里出现空值”的变种。3.2 框架与SDK中的null注入失败、链式调用和空值返回值框架层面的null问题最常见的是依赖注入拿到null。比如有同事写了一个Spring的过滤器里面Autowired了一个RedisTemplate结果一运行发现redisTemplate是null直接NPE。原因很好理解Filter的生命周期比Spring容器的Bean装配要早你在过滤器里用Autowired容器还没完成初始化自然拿不到东西。解决办法有几个。最简单的用Spring Boot提供的OncePerRequestFilter并把它注册成Bean让Spring管理生命周期。这样依赖注入就能正常生效。比如Component public class AuthFilter extends OncePerRequestFilter { private final StringRedisTemplate redisTemplate; public AuthFilter(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { // 这里redisTemplate不会为null chain.doFilter(request, response); } }另一种解法是在init方法里手动从WebApplicationContext里取Bean但不推荐直接用Spring Boot的FilterRegistrationBean管理生命周期才符合现在的工程习惯。再举一个加密库的坑Java项目里用国密SM2调ECNamedCurveTable.getParameterSpec(sm2p256v1)时返回了null导致后续创建密钥对直接失败。这种问题基本上有两个原因。第一BouncyCastleProvider没有注册到Security第二版本太老或依赖冲突曲线表没加载出来。排查时先看异常发生位置再确认JCE Provider处理办法也很直接import org.bouncycastle.jce.provider.BouncyCastleProvider; import java.security.Security; public class Sm2Helper { static { if (Security.getProvider(BC) null) { Security.addProvider(new BouncyCastleProvider()); } } }如果加了Provider还是null那基本就是bcprov依赖版本冲突用mvn dependency:tree查一下依赖关系统一BouncyCastle版本就好。你还经常能看到“trying to access array offset on value of type null”这种PHP报错本质就是代码里把null当成了数组来用比如$result[user][name]但$result其实是null。老PHP项目里特别常见。建议外部数据进来之后先做类型校验或者直接用null合并运算符兜底$name $data[user][name] ?? guest;至于“若依框架error adding module to project: null”这类报错一般不是业务代码问题而是前端工程化环节出了问题。Node版本不匹配、pnpm缓存坏了、node_modules安装不完整都可能报一个莫名其妙的null。遇到这种先别急着查代码清一下缓存重新安装依赖往往就好了。我把这些常见场景整理成了一张速查表报错/场景常见原因快速处理Redis注入为nullFilter生命周期早于Bean装配改OncePerRequestFilter注册为BeanSM2曲线参数返回nullProvider未注册、依赖版本冲突注册BouncyCastleProvider、统一bcprov版本若依模块加载nullNode/pnpm缓存问题清理缓存、重装依赖PHP数组偏移null变量本身不是数组用??兜底入口做类型校验3.3 接口与协议层的nullJSON、WebSocket和权限提示接口层面的null问题最经典的莫过于{code:5,message:login required,data:null}。这个response里code、message都有了但data是null。前端如果拿到这个结果不去判断直接data.userInfo马上就会炸。这里的问题在于接口文档里根本没写“未登录时data为null”前端自然也不会防御。我个人经验是接口约定必须明确失败时data要么不出现要么给一个解释清楚的结构别随手就是一个null。后端返回列表永远返回空数组而不是null返回单个对象可以用空对象模式或者Optional。这样前端才能少写一堆判空。前端这边也要有兜底比如axios拦截器里统一判断code不是成功码就直接抛业务错误别把null继续往下传。再看“unsupported_country_region_territory”这个报错的JSON里面param也是null。这个我看过之后特别有感触因为很多人一看到param为null就以为是传参少了其实这个报错是API服务端的业务限制真正信息在message里param是null只是因为它不适用。所以排查接口错误时第一眼要看的永远是code和message不要去猜null字段的含义那样容易被带偏。再说WebSocket。微信小程序里有一个高频报错handshake failed due to invalid upgrade header: null。这个问题的核心是后端对WebSocket握手协议的Upgrade头没有正确处理而最常见的幕后黑手是Nginx配置。默认Nginx配置代理WebSocket时没有把Upgrade和Connection头透传给后端导致前端发来的升级请求被后端当成普通HTTP请求握手失败。在Nginx里需要加这么几行location /ws/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 3600s; }加了之后握手通常就好了。这个地方也是排查null报错的一个典型路径报错信息里写着null实际上不是某个业务字段为空而是协议头根本没传上来。还有一些报错像“应用程序Siri没有权限打开null”多半是调用系统能力时传了空的URL或者intent重点检查传参和权限声明而void(document.oncontextmenunull)其实是一段老掉牙的禁用右键菜单代码虽然它确实用了null但这个场景里null是“移除事件处理器”的合法用法反而说明null在特定场景下还是有点用的。只不过从追溯问题的角度看这种用法也容易和“空状态”混为一谈所以我个人不太推荐在正经业务里用这种模糊的赋值。到这里我给出一个通用的排查方法论。遇见null报错别急着改代码按这三步走定位看异常栈的具体行搞清楚是哪个变量为null别只记住“报了个NPE”。还原顺着调用链往前找这个值是从哪来的是参数、数据库、Redis、第三方接口还是配置项。契约想清楚这个位置“应不应该”为空。不该为空说明上游有问题该为空但没处理就把空状态显式表达出来。4. 与null共处的正确姿势从防御式编程到语言级解法4.1 初期防守判空、Optional和注解标记如果你接手的是一个老项目短期内不可能换语言那就先做好防守。防守不是让你在每一行都写if (obj ! null)那是灾难而是集中在“边界”上。什么是边界外部接口的返回值、数据库查询结果、消息队列里的消息体、配置项读取这些不可信的输入必须处理。内部调用链里你只要保证边界整洁内部就可以尽量避免到处判空。Java里可以用Optional做返回值包装。比如查询订单详情public OptionalOrder findOrder(String orderId) { Order order orderMapper.selectById(orderId); return Optional.ofNullable(order); }调用方可以findOrder(orderId).ifPresent(this::processOrder);如果订单不存在代码也不会炸而是走“什么都不做”的路径。但这里也要提醒一句Optional别滥用特别是在字段定义、参数传参、集合元素里用多了反而让人糊涂。它更适合用来表达“返回值可能没有”的场景。前端同学可以多用可选链和空值合并运算符。TypeScript项目里把strictNullChecks打开类型系统就能帮你找出很多“可能为null”的地方。const userName resp?.data?.user?.name ?? anonymous;数据库层面字段设置尽量用NOT NULL加DEFAULT除非每个业务意义是“确实没有值”。一个很常见的坑是MySQL里很多字段默认允许NULL结果group by或order by的时候因为NULL值的特殊性统计结果和预期不一致。其实很多“不需要NULL”的字段改成DEFAULT空字符串或0可以省掉后面一大批麻烦。4.2 现代语言怎么直接消灭null最近这些年语言设计者们是真的把霍尔的反思听进去了。最典型的例子是Kotlin。Kotlin从语言层面把可空性写进类型系统普通String类型不允许是null只有String?才允许。你要读这个可空字符串的length就得先处理空的情况否则编译器直接报错val name: String? getUser()?.name val length name?.length ?: 0Swif里的Optional也是一样的逻辑。Rust更彻底语言里根本没有null空值用OptionT表达。想拿到里面的值要么match要么unwrap_or_else不管哪条路编译器都会强迫你面对“值可能不存在”这个事实let name user.get_name(); let display name.map(|s| s.to_uppercase()).unwrap_or_else(|| GUEST.to_string());TypeScript开strictNullChecks之后null和undefined会作为类型的一部分被追踪你也不会再随随便便就把可能为空的值当成普通值用。这些设计共同的特点就是让“空”变成显式状态而不是躲在所有类型的背后搞偷袭。回头看霍尔的失误本质上就是让一个“空引用”混进了所有引用类型里让编译器放弃了把关的能力。现代语言只是把把关能力重新拿回来而已。4.3 老代码翻新从“不报错”到“报得清晰”大厂现在很少让你从零开始选一门新语言更多是维护一堆五年十年的老项目。这时候怎么渐进式地改善null问题我的经验是别指望一次重构把所有判空都删掉先追求“报得清晰”。第一在入口处做一次“防null转换”。比如把第三方接口返回的数据映射成内部DTO入口处就解决字段缺失问题内部代码不再担心某个外部字段是null。这一步成本不高收益很大。第二日志要带上下文。丑陋的报错是日志里孤零零一个NullPointerException at OrderService.process(OrderService.java:88)你不知道订单号是多少不知道是哪个用户触发的。改成Objects.requireNonNull(order, order不能为空orderId orderId)问题定位时间能缩短一大半。Java里可以用java.util.Objects.requireNonNull显式表达约束错误信息是可控的而不是运行时给一个莫名其妙的不带上下文的NPE。第三返回值不要用null表达“空集合”。查询列表没数据你返回Collections.emptyList()调用方就可以直接遍历完全不用担心null。查询单个对象能改成Optional就改改不了也要在方法注释里写清楚会返回null这总比让后面同事凭空猜好。举一个实际重构的例子。改造前Order order orderService.findById(orderId); if (order ! null) { process(order); }改造后orderService.findById(orderId).ifPresent(this::process);看着只是少了一个if但实际上这是把“可能为空”的语义从调用方手里收回去了让方法签名自己说话。我自己的习惯是写每一个可能返回null的方法时都强迫自己问一句这个空状态到底在表达什么如果有明确的业务含义就用明确的类型表达如果完全不应该为空就在入口快速失败并给出详细的错误信息。这样至少能让“和null斗争”的复杂度控制在局部而不是全项目散得到处都是。托尼·霍尔的这一课说到底并不只是技术层面它也是设计决策层面的教训一个看似省事的小设计如果没有考虑长期边界条件可能会变成几十年都甩不掉的历史包袱。而作为写代码的人我们能做的就是从今天开始尽量让下一个null不再那么难以追踪。
返回列表