最近忙着调研gRPC做服务治理,尝试用protobuf3重写现有的接口逻辑,发现了一个问题:protobuf3的基本类型不支持nullable。如果想表达“没有”,就只能用对应数据类型的默认值,比如,字符串的默认值是"",整数是0,布尔类型是false。在团队里展开了一个讨论——程序里要不要表达“没有”,和怎么表达“没有”。本文就是讨论中一些关键内容的总结啦。
能不能不要“没有”?
很简单——不能。”没有“这个概念是业务上非常普遍存在的现象。比如我们根据id查询数据,可能因为某种原因,这个数据不存在,而我们的程序需要某种方式表达这个“不存在”。
当然,这时可以用
NoSuchElementException
的方式表达,但如果在系统中这个情况是正常情况,而非异常,那么用异常处理会显得比较臃肿;并且因为一般RPC协议都没有异常支持,所以也不能很好的跨系统表达这个异常。
另外一个例子是数据因为某种原因缺失。比如,我们会从第三方数据源读取所有中国公募基金的净值数据,那么就有极小的概率录入错误,或者当天净值就是不公布,因此没有数据。这时,净值在系统中必须表达为“没有”,而不是0。在产品的界面上看到的也应该是"--"。其他类似的数据,比如年化收益、业绩表现等也是如此。
在业务开发中,不管用什么开发语言,一般都会用空来表示“没有”,比如Java中的null,MySQL中的NULL,js中的null和undefined,Python中的None等等。这个用法尽管存在一些问题,但已经形成了事实的标准。
回到我最初的问题,尽管protobuf3不支持nullable的基本类型,“没有”还是要表达。于是大家想各种办法来曲折的解决这个问题。比较有代表性的有"oneof"法和“Wrapper”法。具体细节的讨论可以见这里。
但是null的确存在问题(特别是在静态语言开发者的眼中),它会让类型系统的消除程序错误的功能失效。
从null到Optional
计算机科学里有一个著名的梗叫做“billion-dollar mistake“问题。大神Hoare(C. A. R. Hoare,CSP和ALGOL的发明者,结构化程序思想的先驱),曾经描述到:
I call it my billion-dollar mistake… At that time, I was designing the first comprehensive type system for references in an object-oriented language. My goal was to ensure that all use of references should be absolutely safe, with checking performed automatically by the compiler. But I couldn’t resist the temptation to put in a null reference, simply because it was so easy to implement. This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years.
这段解释了最早null
引用是怎么来的,以及这个东西对随后几十年软件工业带来的无数闹心的问题。
静态类型语言强调“尽可能的在编译期找到程序的错误”,而null这个奇葩的存在无疑是与这个目标对着干。比如C++里,你如果这样写:
char * p = 123;
编译器会告诉你123不是个表示字符数据的地址,这很好。但,编译器却允许:
char * p = 0;
因为0在C++里表示空指针,所以编译器做了特殊处理,视作合法。直到运行时触发了segment fault。
Java也类似,你可以
Integer a = null;
这可以绕开编译器,然后有可能在运行时得到一个NPE。
于是静态语言们开始逐渐采用一个新的方法,即用Optional
来明确的表达”可能没有“。比如C++ 17增加了std:optional
,Java 8 开始也支持了Optional<T>
(其实guava很早就有Optional了),Scala支持了Option[T]
。Optional用类型的方法描述了一个数据可能不存在,这样就可以写出看起来比较优雅的代码,比如:
Optional<User> userOpt = findUserById(userId);
userOpt.ifPresent(user -> System.out.println(user.getName()))
.orElseThrow(NoSuchElementException::new);
这个似乎就比下面if + null的形式进行检查更好一些:
User user = findUserById(userId);
if (user == null) throw new NoSuchElementException();
System.out.println(user.getName());
Optional是个好办法嘛?
很可惜,直到目前为止,我的周边基本上没有听说谁真的广泛的采用Optional的方案。原因很简单:很多程序都是跨多个组件的程序,而其中一两个地方有Optional支持,其他地方没有,那整体得到的麻烦和混乱比用if + null的写法还要多。
比如,一个常见的Web程序需要访问数据库,并把结果用json传输到客户端。也许程序本身有Optional支持,但是数据库和json并没有“Optional”的概念。当然,已经能看到一些相关的努力,比如Hibernate 5.2开始支持Optional。而Json用Jackson的一些的hook的机制也能解决,但是整体上面对原有的,基于null形成的前中后端的事实约定,还是太小众了些。推动起来会形成相当的阻力。
另外一个更严重一些的问题是,也许从语言的角度会觉得用一个有类型的”没有“替代null形式的“没有”感觉更优雅,但实际上从上层开发的角度,并没有什么明显的区别。开发者还是要去检查这个东西到底有没有,无论是用if + null,还是用Optional#ifPresent
,都是一样的。要做好检查,就要求开发者有这个意识去做这个检查。如果说开发者看到了Optional就有了检查的意识,也就意味着开发者在传统做法时,也应该有这个意识去做检查。如果使用了Optional,但是强行直接get,一旦“没有”发生了,也会得到一个如NoSuchElementExcepiton这样的异常。这个异常和NPE并没有什么本质区别。
有人说Optional可以帮助让开发人员区别哪些变量是必须非null的,哪些是真的可能是null 的。但现实当中往往是,你一直认为一个变量是非null的,直到它第一次是null。比如你会觉得某个数据一定是经过严格的录入检查,必然不为空;你可能会觉得某个数据在数据库里是"not null",所以不可能为空等等。但是只要程序是可以改的,数据是可以改的,就会出现一个非null的数据转变为nullable数据,并且影响一片将其视作必然非null的程序的可能性。一旦发生这种情况,你就需要把一片程序从原始的类型都改成Optional的写法,改动量也比较大。
应该承认使用Optional时对开发者做检查的推动力是要强过if + null式检查,并且还很“类型”,但从使用者的角度整体的性价比还是很差。
如果细细反思Optional这个方案就能发现Optional并非是问题的关键,而真正的关键是:
- 用一个最简单的办法来表达“没有”,这个表达容易在前中后端形成约定,就连初学者都很容易明白和使用;
- 想办法“助推”,让开发者能主动写好对“没有”的检查。
我查来查去,终于发现Kotlin的方案是比较靠谱的。
Kotlin的方案
Kotlin是这样解决问题的。首先Kotlin里有null。这就解决了上面第一个问题,大家都会很喜欢和习惯于使用,也很方便和其他系统集成。
但kotlin中的null不能随便用。kotlin要求开发人员要自己控制一个变量的类型是nullable还是非nuallable的。比如
var user: User
user = null // 编译失败
是会编译失败的。为了能让变量赋值为null,必须声明变量的类型是nullable的。
var nullableUser: User? // ?表示nullable
nullableUser = null // 合法
然后是最关键的,如果你试直接访问一个nullable的变量的属性,或者调用其方法,会直接编译报错。
nullableUser.doSomething(); // 编译报错
因为kotlin并不确认nullableUser
是不是为null,所以选择直接报编译错误,逼着开发者一定要明确这里可能是null。
nullableUser?.doSomething();
通过这个语法,如果nullableUser
是null的话,表达式就会直接返回null,而不是抛出一个NPE。当然,如果开发者想自定义如果是null的处理代码的话,可以这样写:
nullableUser?.doSomething() ?: handleNullError()
如果开发人员真的认为这段代码一定不应该存在null,一旦有了null,最好立刻抛出NPE,立刻修问题,可以这样做:
nullableUser!!.doSomething();
kotlin的做法对实际的工程开发非常友好。不像Optional仅仅是提出了一个“优雅”但实际上难以用起来的方案。kotlin给了开发者一个选择:对于null,到底是要严格对待(立刻抛NPE),还是容忍着对待(默认返回null或者自定义处理)。如果一个数据本来认为是非null的,随后修改为nullable的,改动也比Optional方案小很多。在我看来,这就是一种助推,可以很积极的鼓励开发者更好的处理“没有”的问题。
助推的含义是“自由主义的温和专制主义”,详见 Richard H. Thaler 和 Cass R. Sunstein合著的《助推》。
值得提一句,像kotlin这样处理null的语言还有C#和swfit。此外,Groovy也有"?."这样的操作符,但是因为Groovy算是动态语言,并不会用编译错误迫使开发者做对null的处理。
提示一下:我先看的kotlin,再看的其他几门语言。因此,本文用kotlin举例子,并不代表C#,swfit和kotlin在这个功能的设计上谁先谁后。
使用其他语言的该怎么办
Java目前看最好的方案就是半吊子的Optional了。并且个人建议是如果是已有代码的话,不要迁移到这套方案上,因为代价很高,却没有解决什么问题,而应该继续使用传统的if + null判断,以及严格的code review。
顺便歪歪一下,静态类型语言的开发者往往会习惯于编译器能处理大部分错误,然后在“没有”需要运行时检测这个事情上意识不足。对此我鼓励所有的静态类型语言的开发者都要至少尝试写一种动态代码,吸收一些编译器搞不定的情况下如何避免出问题的思路和习惯。现实开发中总有编译器无法防范的问题。
而动态语言,当然就做运行时的检查了。以javascript为例,它的coerce语法和"||"操作符会让这个事情很轻松。
let user = findUserByUserId(userId) || {}; // 如果真的没找到,这里可以选择性的返回一个空object
此外,lodash也是个非常好用的帮手。
const _ = require('lodash'); // lodash是个好东西
if (_.isEmpty(user)) { /* 处理空 */ }
最后的话
“没有”这个事情在业务上是普遍存在的,是刚需,并不以某些人的一厢情愿而消失。在编写代码的时候,我们需要用最简单的方式来表达“没有”,这个方式就是null。但是在传统的静态语言中,null会绕开编译器,因此容易造成null safety问题。对于此问题,最好的办法不是干掉“没有”,而是想方设法让开发者尽可能方便和灵活的检查null,尽量避免不检查带来的问题。如果你用的编程语言恰好有这种机制,好好利用它;如果没有,就要学会“不相信”你的数据源,多做检查,多做code review。
最后的最后,不管是静态语言,还是动态语言,都应该好好写测试。测试才是能确认程序不出问题的最终手段。