最近一直在忙一些事情,我这篇文章都积压了好几周了。当然是原谅我啊哈哈
1. 数据类增加字段,反序列化 Json 有惊喜?
话说,我们有一个数据类:
代码语言:javascript复制data class Person(val name: String, val age: Int)
现在呢,我又有这样的一个 Json 字符串:
代码语言:javascript复制{"name":"benny","age":18}
这样我们对这个字符串进行解析并得到 Person
这个类实例,没有毛病。
Gson().fromJson(json, Person::class.java).let(::println)
那么后来,万恶的产品经理该需求啦,说这个 Person
里面还需要有一个公司,也就是我们要改成:
data class Person(val name: String, val age: Int, val company: String)
嗯,到这里似乎也没啥毛病啊。不过不巧,本地之前缓存了一份刚才的 Json,程序重新运行之后试图从这个 Json 解析出一个 Person
,程序跑着倒也没什么,只是输出有点儿奇怪:
Person(name=benny, age=18, company=null)
哎?? company
怎么还能等于 null
?为什么会这样?具体原因可以参考我很久之前的视频:Json 数据引发的血案
这主要是因为 Gson
通过 Unsafe
这个东西去实例化 Person
,所以里面的字段实际根本没有经过有效的初始化。而因为 Json 字符串当中有 name
age
这两个字段,所以他俩会在后面通过反射赋值, company
就没人疼没人爱了。
所以你就知道了,Kotlin 里面的字段在这种鬼畜的写法下面也会被突破限制,空类型安全似乎也很无力。因为我们反序列化 Json 的时候既然没有报错,那么后面的代码肯定会安心洗路的用里面的字段:
代码语言:javascript复制fun needACompany(company: String){
...
}
...
needACompany(person.company)
于是:
代码语言:javascript复制Exception in thread "main" java.lang.IllegalArgumentException: Parameter specified as non-null is null
所以我们的问题是,对于这种情况,怎样才能让我们的代码不出现“反常”的情况?
2 委曲求全, 用 Nullable 类型
太可怕了。也就是说我们如果新增字段的话,我们只能将他们定义为 nullable 的类型吗?
首先明确一点的是,定义为 nullable 类型,是一种解决方案,也就是说:
代码语言:javascript复制data class Person(val name: String, val age: Int, val company: String?)
使用的过程中就要这样:
代码语言:javascript复制person.company?.let(::needACompany)
不过,这个方案可能会让很多人感到不开心。于是有人说,我给这个 company
加个默认值行不行啊,毕竟可以给个空字符串嘛,总比 null
强吧。
3. 徒劳无功,默认参数救不了你
那么定义默认参数怎么定义,当然就是在构造器里面写了
代码语言:javascript复制data class Person(val name: String, val age: Int, val company: String = "")
这个默认参数虽然有了,如果真的可以在反序列化 Json 的时候遇到没有 company
字段的情形赋值为空字符串的话,那么我们也不会遇到前面的异常了。
可是它并不会被真正调用。如果你不知道默认参数的原理,那么我建议你反编译字节码看下。实际上只有真正调用这个主构造器的时候才可能去触发默认参数的逻辑, Gson
通过 Unsafe
去实例化的路径根本不会触发默认参数的赋值。
那么还有人说,干脆把 company
定义在类内部好了,就像这样:
data class Person(val name: String, val age: Int){
val company: String = ""
}
看上去到也还行,不过有个问题,作为数据类, company
字段的地位可比其他两个低多了,不信你去试试 copy
方法、或者试下解构赋值,感觉就是后娘养的。
val (name, age) = person //正确
val (name, age, company) = person //错误,没有 component3
所以这条路不通。
4. 柳暗花明,noArg 的妙用
我们再来理一下,我们的目标其实是要做到:
company
字段定义为 nonNull 类型- 在反序列化 Json 时,如果 Json 中没有这个字段,要赋值为空字符串,也就是要有个默认值
熟悉 Kotlin 数据类的坑的朋友们都知道,NoArg 和 AllOpen 无论如何都是少不了的。不过今天说的这个问题只是简单的应用这俩插件可不行。
我们知道有了 NoArg 插件,编译器会帮我们生成一个无参构造方法,这时候 Gson
就可以通过这个构造来实例化 Person
。可是问题还没解决呢,里面的字段还是没有初始化啊。没关系,调用这个默认无参构造的时候会首先调用父类构造,所以我们给 Person
搞一个父类好了:
abstract class PersonCompat
然后在这个默认无参构造调用的时候对可能不存在的字段进行初始化赋值,由于这个操作在前,如果这个字段在 Json 当中存在,那么就用 Json 当中的值,也即不会对正常的逻辑造成影响。
代码语言:javascript复制abstract class PersonCompat{
abstract var company: String
init {
company = "默认值"
}
}
@PoKo
data class Person(val name: String, val age: Int, override var company: String): PersonCompat()
那么这时候再去反序列化刚才那段 Json 的时候,得到的结果如下:
代码语言:javascript复制Person(name=benny, age=18, company=默认值)
这里面有几个细节,请大家注意
company
被定义为了可读写变量,而非之前的只读变量;company
在父类中定义为抽象的;父类当中一定要在init
中赋值。想想这是为什么。
这个方案至少是可行的,从使用的角度来看,也可以达到我们的需求。
不过似乎也看上去比较重,因为引入了一个父类。实际上,从代码设计的角度来看,数据类通常也不需要父类,这个意义上讲,这个方案是可用的。
那么对于新增的字段,我们通常实际上也是要做好兼容处理的,文档之类的必不可少,那么从这个意义上讲,这个方案还可以很清晰的告诉代码维护者哪些字段是做了兼容处理的,非常棒。
不知道大家有没有听说过这个梗,有人反映说为啥 Kotlin 对于 nullable 的字段这么苛刻,每次都写 ?. 感觉很丑啊;官方的人回复说,它就是很丑啊,就是要丑到让你难受然后去用 nonNull 的类型进而避免问题的产生嘛。从这个意义上来讲,设计本身是丑的,写出丑的代码就是理所当然的,警示作用。
5. 小结
如果哪天出个插件可以把主构造器里面的默认参数应用上就好了。嗯,就这样。