在编程语言中空引用(Null Reference)一直是一个不太好的概念。空引用带来了一系列的麻烦,在 2016 年的 QCon 中 Tony Hoare 博士将空引用称作十亿美元的错误。
本次主要介绍一些常见的处理 null 的方式,特别是 kotlin 中的方案:
从历史上看,在编程语言中空引用(Null Reference)一直是一个不太好的概念。空引用最早在 1964 年由Tony Hoare 博士发明,随后的主流语言中都延续了空引用的使用,包括 C、C++、 Java、C# 等。空引用在编程中带来了一系列的麻烦,在 2016 年的 QCon 中 Tony Hoare 博士将空引用称作十亿美元的错误(The Billion Dollar Mistake)。
静态检查的编程语言可以由编译器确保类型正确,不需要等到运行时实际执行代码。 例如在 Java 中当我们写下 x.toUpperCase()
, 编译器会检查 x
的类型。如果 x
得类型是 String
, 那么检查通过;如果是其他类型,比如 Servlet
, 那么检查失败。 静态类型检查在编写大型的复杂软件时会提供强大的帮助。但是在 Java 中,由于任何引用都可以是 Null,编译器得静态检查变得非常痛苦。例如
toUppercase()
可以由任意 String
类型的对象安全调用,除非是 Null
read()
可以由任意 InputStream
及其子类对象安全的调用,除非对象是 Null
toString()
可以由任意 Object
调用,除非 Object 是 Null
由于静态编译检查的失败,我们不得不做大量的运行时检查来防止 NullPointerException
得出现。例如
// 字符串判断if (str == null || str.equals("")) {}// 集合判断if (list == null || list.isEmpty()) {}
在 Google Guava 库中提供了 String.isNullOrEmpty
的方法来统一对 String 做检查。
由于 null 的存在,在设计 API 的时候很容易产生歧义,比如对于一个类似 getByName()
的方法,由于可能返回值可能为 null ,那么方法命名为 getByNameOrNullIfNotFound()
更合适。
以 Java Collection 中的 HashMap 为例,假设我们要从数据库中获取用户的电话号码,我们使用 HashMap 来缓存以避免重复请求。
Map phoneNumberStore = new HashMap<>();phoneNumberStore.put("Li Lei", "138-1100-0011"); phoneNumberStore.get("Li Lei"); // 返回 Li Lei 的号码 "138-1100-0011"phoneNumberStore.get("Han Meimei"); // 返回 null,表示 Han Meimei 不存在
如果某个用户的号码不存在,我们仍然可以缓存,这样就不需要重新请求。
Map phoneNumberStore = new HashMap<>();phoneNumberStore.put("Lucy", null); // Lucy 没有电话,我们缓存 null 代替phoneNumberStore.get("Lucy");
这里对于返回值就产生了歧义:
这个用户不在缓存中(Han Meimei)
这个用户在缓存中和,但是没有电话号码(Lucy)
Java 可以自动的转换包装类型和原生类型,由于 null 的存在,使得这一行为变得诡异并且难以调试。
int x = null; // 编译错误// 编译通过,但是运行时抛出NullPointerExceptionInteger i = null;int x = i; // 运行时错误
如果将 Integer i = null
作为参数值传递到 int
类型的方法参数里,那更是一个灾难,甚至很难定位到 null 的对象。
鉴于 null 的种种问题,也诞生了一系列针对 null 处理的方案。
空对象模式(Null object pattern)是一种设计模式,核心是使用单独定义的空对象来代替 null 的返回值。空对象可以在数据不可用时提供默认的行为。 在空对象模式中,我们需要先创建一个指定各种要执行的操作的抽象类或接口、扩展该类的实体类,还创建一个未对该类做任何实现的空对象类,该空对象类将无缝地使用在需要检查空值的地方。
public interface Animal { void makeSound() ;}public class Dog implements Animal { public void makeSound() { System.out.println("woof!"); }}public class NullAnimal implements Animal { public void makeSound() { // 静音的 }}
空对象模式可以比较好的简化调用端的处理逻辑,并且可以定制空对象的行为。但是空对象模式也有几个问题:
代码更加繁琐,需要定义一个抽象类或者接口,并且无法处理原生对象,比如 String, Integer
函数仍然可以返回 null
标签联合(Tagged Union)是一种代数数据类型,也被称为 Sum Type, variants 等等。 对 Tagged Union 的抽象一般写为 Optional.T = Some(T) | None
。 在guava的早期版本中就提供了 com.google.common.base.Optional
类来专门处理 null 相关的场景,Optional 类提供了三个静态方法来实例化:
Optional.of(T)
:构造一个 Optional 对象,其内部包含了一个非 null 的T数据类型实例
Optional.absent()
:构造一个代表空值的 Optional 对象
Optional.fromNullable(T)
:将一个T的实例转换为 Optional 对象,T可以为空
同时 Optional 类也提供了几个实例方法来处理具体的值:
boolean isPresent()
: 判断当前包含的实例是否为 null
T get()
: 获取实例,如果为 null 则抛出 IllegalStateException
T or(T)
: 获取实例,如果为 null 则以参数中的值代替
可以看到 Tagged Union 可以很好的处理 null,这一方法也被众多编程语言所采用,在 rust、Haskell、swfit 等语言中甚至完全去掉了 null 。从 Java 8 开始,Jdk 也内置了 java.util.Optional
类来处理 null, 使用方法和 guava 类似。 但是由于 null 的存在,实际项目中我们还是很容易犯错,比如 Optional 类实例本身为 null,参数也可能为 null。
guava 和 RxJava 等库中大量使用类似断言的方式检查输入参数, ObjectHelper.requireNonNull
, Preconditions.checkNotNull
以及从 Java 7 开始加入的 java.util.Objects.requireNonNull
等,如果输入为 null 则立即抛出 IllegalArgumentException
。 这种方式可以在第一时间检查输入,防止 null 变量继续进入后续的逻辑,另外重新抛出异常的方式能帮助我们更好的定位到有问题的变量。但是这种方式还是在运行时做的检查,出现异常调用要等到运行时才能发现。
JSR 305 定义了几个 Annotation 来标记变量或者方法返回值是否可以为 null。
Nullness annotations: @Nonnull
, @CheckForNull
Nullable annotations: @Nullable
Android, Guava, JetBrains等都提供了类似的实现。使用Annotation标记之后,如果出现参数传递错误的情况,IDE中会给出相应的提示,一些静态检查工具如 FindBugs 等也会检查到问题,通过配置编译器能给出 Warning 。
比如在 Guava 中,使用 @Nullable
来标记可空参数(在 Guava 中所有参数默认都是不可以为 null 的)。
```javapublic static boolean isNullOrEmpty(@Nullable String string) { return string == null || string.length() == 0; // string.isEmpty() in Java 6}```
Kotlin 中对 null 的处理采用的是 UnTagged Union 的方案,和 Taggged Unio 的区别可以表示为 Optional.T = T | None
。这也是 Kotlin 和 Swift 的不同,语法上来说两者非常接近,但是从语义上又不完全相同。 Kotlin 中所有变量默认都是不可以为空的,变量是否允许为空,必须在定义时明确,例如:
var a: String = "abc"a = null // 编译错误var b: String? = "abc" // 变量声明时在类型的后面加上 ? 表示可空b = null // okprint(b)
Kotlin 可以保证调用 a
的方法不会出现 NullPointerException
,我们可以安全的调用 a
的任何实例方法:
val upper = a.toUpperCase()
但是,当我们要调用 b
的方法时,由于 b
是可空类型,编译器会报告错误:
val upper = b.toUpperCase() // error: variable 'b' can be null
那么我们怎么来处理 Kotlin 中的可空类型呢?
Kotlin 没有针对可空类型进行隐式转换,但是提供了基于控制流的类型推断(Flow-sensitive typing),它可以很好的和 untagged union 结合。当编译器可以确定 Optional.T
类型的变量不为 null(None) 时,将自动转为 T 类型。 还是以上边的 b
为例:
if (b != null) { val upper = b.toUpperCase() // 编译通过}
需要注意的是,只有在当前作用域内确定不会重现变化的属性才可以进行自动转换,比如线程不安全的可变变量是无法自动转换的,例如:
class SmartCast { var nonSafe: String? = null fun cannotCast() { if (nonSafe != null) { print(nonSafe.length) // 编译错误, nonSafe 无法进行自动转换 } }}
但是,对于一些自定义的场景 Kotlin 无法帮我们确定一个可空变量是否为 null ,也就无法完成类型的自动转换,例如:
fun String?.isNotNull(): Boolean = this != nullfun foo(s: String?) { if (s.isNotNull()) s.length // 编译错误,s 无法转换为 String}
从 Kotlin 1.3 版本开始,引入了实验性的 contract 特性,借助 contract 我们可以更好的处理自动类型转换,例如:
@kotlin.internal.InlineOnlypublic inline fun CharSequence?.isNullOrEmpty(): Boolean { contract { returns(false) implies (this@isNullOrEmpty != null) } return this == null || this.length == 0}fun bar(x: String?) { if (!x.isNullOrEmpty()) { println("length of '$x' is ${x.length}") // s已经自动转换为非空类型 }}
fun main(args: Array) { var s: String? = "Hello world" // print(s.length) ---- 编译错误 if (s != null) { print(s.length) }}
运行输出:
$ java kotlinc example.kt -include-runtime -d example.jar$ java java -jar example.jar 11
Kotlin 和 Java 有非常好的互操作性,对于 JSR 305 也提供了很好的支持。在调用 Java 编写的 API 的时候,Koltin 默认认为所有的参数和返回值都是可空的,比如在继承类或者实现接口的时候,通过 IDE 生成的模板代码中,参数默认是都可空类型。
class CustomSerializer: org.codehaus.jackson.map.JsonSerializer() { override fun serialize(value: UserFollow?, jgen: JsonGenerator?, provider: SerializerProvider?) { // TODO }}
上面的例子中,我们实现一个自定义的 Jaskson JsonSerializer,IDE 默认生成的代码会将参数设定为可空类型。当然我们仍然可以手动将参数类型转换为非可空类型。
interface WithJSR305 { @Nullable String foo(String x); @Nonnull String bar(String x, @Nullable String y); String baz(@Nonnull String x);}
我们定义个一个接口,给相应的参数和方法标注 JSR 305 的 annotation,IDE 会自动将对应的类型标注正确,更重要的是,如果类型不匹配,IDE 和 编译器都会报错。
class CustomImpl: WithJSR305 { override fun foo(x: String?): String? { TODO() } override fun bar(x: String?, y: String?): String { TODO() } override fun baz(x: String): String? { TODO() }}
Reactor 库中使用 @NonNull
, @Nullable
, @NonNullApi
为 Kotlin 的 null safety 提供了全面的支持。
首先定义个一个使用JSR305标记的 Java 类
import org.jetbrains.annotations.NotNull;import org.jetbrains.annotations.Nullable;public class JSR305Test { @NotNull String nonNullApi(@NotNull String x) { return "NON_NULL: " + x; } @Nullable String nullableApi(@Nullable String x) { if (x == null) { return null; } return "nullable: " + x; }}
在 kotlin 调用的时候如果使用了错误的类型,编译器会报错。
fun main(args: Array) { val jsr305 = JSR305Test() println(jsr305.nonNullApi(null)) // 编译错误: 参数不可以是 null println(jsr305.nullableApi("hello world").length) // 编译错误: 返回值可能为 null ,不能直接使用 length 属性}
很多时候我们需要可空类型的变量参数等等,但是每次使用之前都要进行空值判断比较繁琐,Kotlin 提供了一些操作符来帮忙我们简化操作。
?.
来进行安全调用使用 ?.
可以安全的调用可空类型的方法和属性,如果为空那么返回null,否则调用对应的方法或者属性。
val b: String? = nullprintln(b?.toUpperCase())
这个例子中, b?.toUpperCase()
如果 b
不为 null,那么返回b.toUpperCase()
, 否则返回 null
, b?.toUpperCase
的型是 String?
。
对于嵌套比较深的复杂对象,使用 ?.
能够方便的进行链式调用。
user?.name?.firstName?.toUpperCase()
中间任意一个属性或者方法为 null
那么整个表达式就返回 null
。
fun main(args: Array) { var userInput: String? userInput = null userInput?.let { // 只有当 userInput 不为 null 时执行 let 方法 println("$userInput is not null") }}
$ java kotlinc example.kt -include-runtime -dexample.jar$ java java -jar example.jar$
?:
对于一个可空变量,很多时候我们希望可以在不为空时直接使用对应的值,空时使用默认值, ?:
操作符可以实现对应的效果。
val l = b?.length ?: -1
?:
左边的部分不为 null 时 ?:
返回对应的值,否则返回右边的值另外 ?:
也遵循短路原则,如果左边的值不为 null
右边的表达式是会执行的。
在函数中, ?:
可以用来提前从函数中退出。
fun foo(node: Node): String? { val parent = node.getParent() ?: return null val name = node.getName() ?: throw IllegalArgumentException("name expected") // ...}
class Person(val name: String, val age: Int)fun main(args: Array) { val john : Person? = Person("John", 32) val age = john?.age ?: 25 val offsetAge = age + 1 //编译通过 println("offsetAge: $offsetAge") val ageWithThrow = john?.age ?: throw IllegalArgumentException("John is null") val offsetThrowAge = ageWithThrow + 2 //编译通过 println("offsetThrowAge: $offsetThrowAge")}
运行结果:
$ java kotlinc example.kt -include-runtime -dexample.jar$ java java -jar example.jaroffsetAge: 33offsetThrowAge: 34
lateinit
通常 kotlin 类里的非空属性必须在构造期间初始化,但是很多时候我们会过其他方式来完成赋值操作,比如依赖注入(@Inject
, @Autowired
),单元测试的 setup
方法, @PostConstruct
等。这时如果我们属性声明为可空类型就会带来很多的麻烦,使用时都要做可空判断或者使用!!
,非常的不方便。
这种场景下,我们可以使用 lateinit
关键词来修饰属性,来避免null
相关检查。
class UserController() { @Resource private lateinit var userService: UserService @GetMapping("/users/{uid}") fun getUserFavPostList(@PathVariable("uid") uid: uid): User { return userService.getUserInfo(uid) // 不需要空判断 }}
lateinit
使用有一定的条件限制,只能修饰 var
声明的属性,并且能有自定义的 getter/setter
,只能用在 class body 中的属性在 primary constructor 中的不可用。如果在属性初始化之前使用变量然会抛出 NPE
,从1.2开始,可以使用 isInitialized
来检查lateinit var
是否被初始化。
if (this::userService.isInitialized) { println(userService.getUserInfo(uid))}
lateinit
的实现是基于 kotlin 的属性委托,使用 notNull
也以达到类似的效果。
以 junit 的 testcase 为例:
import org.junit.Assertimport org.junit.Beforeimport org.junit.Testclass LateInitTest { private lateinit var lateVar: String @Before fun setup() { this.lateVar = "Hello world!" } @Test fun call() { Assert.assertEquals(12, lateVar.length) }}
执行结果:
$ kotlinc example.kt -include-runtime -d example.jar-cp ./lib/junit-4.12.jar$ java -cp .lib/hamcrest-core-1.3.jar:./libjunit-4.12.jar:./example.jar org.junit.runner.JUnitCore LateInitTestJUnit version 4.12.Time: 0.008OK (1 test)
!!
!!
会忽略变量的类型,强制转换为非空类型,但是这个操作符是不安的,如果变量为 null
那么会抛出 NPE
。
val upper = b!!.toUpperCase()
本文首发于 GitChat,未经授权不得转载,转载需与 GitChat 联系。
阅读全文: http://gitbook.cn/gitchat/activity/5d57af98b5ee3365573907eb
您还可以下载 CSDN 旗下精品原创内容社区 GitChat App ,阅读更多 GitChat 专享技术内容哦。