空值与可空类型
空值与可空类型
阅读本文的读者至少了解Java或者C,文字内容会涉及到多种编程语言。
本文的提出的设计与实现主要在第五章;本文提出的一个有趣的想法在第六章;其他章节作为阅读补充。
1. 前置:编程语言类型系统
除了Java的类型系统,其他语言在类型系统中通常会引入以下类型构造和类型关系:[1]
1.1. 类型关系
1.1.1. 单元类型
一般来说,类型运算的单位元(Unit,幺元、恒等元(Identity))称为单元类型(Unit Type)。
例如说,设有一种类型运算:
type A;
type Unit;
type C1 = A * Unit;
type C2 = Unit * A;
如果存在C1 == A == C2
,则称这里定义的Unit
为类型运算*
的单元类型。
1.1.2. 子类型
如果类型S的实例s可以当作类型T的实例t使用,则称S是T的子类型(subtype),写作S <: T
。
子类型关系通常具有:
- 自反性:
S <: S
。即类型S是自身的子类型; - 传递性:若
R <: S
且S <: T
,则R <: T
。即如果类型R是S的子类型且S是T的子类型,那么R是T的子类型; - 反对称性:若
R <: S
且S <: R
,则S==T
。即如果类型R是S的子类型,且S是R的子类型,那么R和S等价。
子类型可以实现比基类更多的特性。
通过实现接口、或者通过继承(Inheritance)可以实现子类型关系。
1.1.3. 类型构造器与子类型型变
称一个输出为类型的函数为类型构造器(Type Constructor)。如果类型构造器的输入也为类型,在本文中称它为泛型构造器,而称输出结果为泛型。
泛型构造器中,相对于两个输入类型的子类型关系,相应两个输出类型的子类型关系会做相应的变化,这种变化称为型变(Variances)[2]。例如有泛型构造器Con
,以及类型A、B,且A是B的子类型,即A <: B
;泛型记为Con<A>
和Con<B>
:
协变(Covariant):
Con<A>
是Con<B>
的子类型,即Con<A> <: Con<B>
。协变通常情况下只能用来按照基类型读取数据,而不能按照基类型添加数据(否则会出现
((FinalList<Animal>)dogList).add(cat)
的错误),因此主要用于向外提供数据的泛型。例如说,FinalList<Dog>
的列表应该可以当作FinalList<Animal>
读出Animal
类型的数据;反过来则不行。[3]逆变(Contravariant)
Con<B>
是Con<A>
的子类型,即Con<B> <: Con<A>
。逆变通常情况下只能用来按照子类型添加数据,而不能按照子类型读取数据(否则会出现
Dog dog = ((Container<Animal>)dogList).get(0);
的错误),因此主要用于存储数据的泛型。例如说,ContainerList<Animal>
的列表应该可以当成ContainerList<Dog>
去添加Dog
类型的数据;反过来则不行。不变(Invariant):
Con<B>
和Con<A>
没有子类型关系。
一种常见的型变形式如:函数类型Animal -> Mouse
是Cat -> Animal
的子类型;这种型变可以理解为我们要求一个“输入是猫输出是动物”的函数,而我们提供一个“输入是动物输出是老鼠”的函数也符合要求。
1.2. 类型构造
1.2.1. 函数类型
函数类型(Function Type)表达“从一个类型出发可以得到另一个类型”的语义。
通常来说,一个函数的类型不是它的返回值类型,而是包含参数类型与返回值类型的组合。例如Integer increment(Integer a){ return a+1; }
中函数increment
的类型应该为Integer → Integer
。
type A;
type B;
type C = A → B;
C function = (A a)->{ return (B)b; }
1.2.2. 积类型
积类型(Product Type)表达“多个类型的组合”的语义。
通常来说,一个元组(Tuple)的类型就是元组各个项的类型的积类型。例如函数调用时set("name",2)
的参数列表("name",2)
是一个二元组,它的参数类型是(String key, Integer value)
也写作String × Integer
。
type C = A × B;
C c = (a,b);
1.2.3. 联合类型
联合类型(Union Type)表达“可能被当作多个类型中的某一个类型使用”的语义。
不具名联合类型(Untagged Union Type):
type C1 = A | B;
C1 c11 = a;
C1 c12 = b;
具名联合类型(Tagged Union Type),在一些地方可以当作和类型(Sum Type)、或类型、变体类型(Variant Type)、余积类型(Coproduct Type)、无交并(Disjoint Union)。写成:
type C2 = {tag:"A",value:A} | {tag:"B",value:B};
C2 c21 = {tag:"A",value:a};
C2 c22 = {tag:"B",value:b};
或者写成:
enum C2 {
First(A a);
Second(B b);
}
C2 c21 = First(a);
C2 c22 = Second(b);
为了强调具名与不具名的区别,在本文中:
- 不具名联合类型指的是
type C1 = A | B
,类型A的对象a能够直接转型成C1,也就是在子类型关系上,A和B是C1的子类型;此外,T|T == T
。 - 具名联合类型则需要类型构造器进行包装,将名称添加到标签上。
当然目前编程语言实现起来的不具名联合和具名联合只是在写法上有区别,可能底层实现上不具名联合类型依然需要标签,而且使用起来都还是可以用模式匹配或者类型判断去区分类型的。
1.2.4. 交叉类型
交叉类型(Intersection Type)表达“可以同时被当作多个类型使用”的语义。
交叉类型和联合类型相似,都可以表达本类型满足两个类型的要求,但又有所不同。交叉类型的变量能够同时满足两个类型的要求,而联合类型的变量只能满足其中一个类型的要求。
从子类型关系上来说,type T = A & B
,交叉类型T同时是A和B的子类型。
1.3. 特殊类型
1.3.1. 顶类型
顶类型(Top Type,⊤类型),它只有唯一的一个实例,也就是单例。类型和它的实例没有承载任何信息。
在特殊情况下,顶类型可以为作为积类型的单位元(Unit Type),即[4]。
在子类型关系中,顶类型可以设计为所有类的基类,例如Typescript中的unknown
(它还是any
的基类)、Java面向对象部分的Object
、Scala和Kotlin中的Any
。
1.3.2. 底类型
底类型(Bottom Type,⊥类型),它的实例不能被构造出来,也就是没有实例只有类型。
在特殊情况下,底类型可以作为和类型的单位元,即。
在子类型关系中,底类型可以作为所有类的子类,例如Typescript中的never
,Scala和Kotlin中的Nothing
。
2. 空语义与空值的问题
2.1. 空值设计
空值设计是什么?
首先按照惯例提一嘴空值设计时总会提到的著名内容《十亿美元的错误》[5][6][7]:
托尼·霍尔爵士(Tony Hoare,著名的计算机科学家、图灵奖获得者、null 的发明者)在2009 年的演讲 《Null References: The Billion Dollar Mistake》 中说到:
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.
我称之为我的“十亿美元的错误“。当时,我在为一个面向对象语言设计第一个综合性的面向引用的类型系统。我的目标是通过编译器的自动检查来保证所有引用的使用都应该是绝对安全的。不过我未能抵抗住引入一个空引用的诱惑,仅仅是因为它是这么的容易实现。这引发了无数错误、漏洞和系统崩溃,在之后的四十多年中造成了数十亿美元的苦痛和伤害
空值设计即——通过编程语言的设计以及程序设计规范的制定,对使用空值的场景进行抽象与封装,设计编译时或者运行时检查流程,尽可能地实现安全保证。
2.2. 空语义场景
既然空值会带来一些显著的错误,那为什么还要设计空值呢?
因为无论是最基本的编程语言的底层场景,还是业务场景都需要表示空语义。
例如JavaScript甚至有null
以及undefined
两种(以下归纳摘自阮一峰[8]):
- null表示"没有对象",该处不应该有值。
- 作为函数的参数,表示该函数的参数不是对象。
- 作为对象原型链的终点。
- undefined表示"缺少值",该处应该有一个值,但是还没有定义。
- 变量被声明了,但没有赋值时,就等于undefined。
- 调用函数时,应该提供的参数没有提供,该参数等于undefined。
- 对象没有赋值的属性,该属性的值为undefined。
- 函数没有返回值时,默认返回undefined。
这些场景可以归结为:
- 表示已有变量未定义(没有初始值);
- 表示已有变量定义为空(初始值为空);
- 表示函数返回值为空(比如说返回值类型为void);
- 表示函数没有返回值(比如说函数被TODO中断了,或者抛出一个异常了)。
2.3. 空值和零值
空值代表未进行初始化时的值还是初始化时首先赋予的值?
在上述提到的场景中,我们可以通过给已有变量初始化一个值,来防止出现未定义的情景。像这样初始化的值,在本文中称之为零值。
许多编程语言都有引用类型(或者组合类型)和基本类型的概念,基本类型变量通常来说初始化的值应该为具体值,例如boolean类型的零值是false,Integer、Byte类型的零值是0等等。
而引用类型的初始化有一种做法是,给已有变量初始化一个空值。也就是说,空值常常是引用类型的零值,但是在使用时,这种设计并不符合直觉。
2.4. 空值带来的问题
为什么空值总会导致错误?[9]
2.4.1. “类型允许空值”违背类型的语义直觉
因为我们常常设想一个类型应该有某种属性与操作,同时常常假设一个变量拥有该类型的属性与操作,而忽略了假如它可能是空值(空值并没有这样的属性与操作)。比如说如下代码:
public class A{
public void print(){}
}
class Main{
public static void main(String[] args){
A a = null;
a.print();
}
}
//Exception in thread "main" Java.lang.NullPointerException:
//Cannot invoke "A.print()" because "a" is null
在Java语言或者C语言中,像这样变量未经判空,就调用变量中的属性与操作,直接导致空指针异常NullPointerException
。
这种设想,其实是一种语义直观带来的语义直觉——直觉上,我们看到的a是A类型,就应该有属性和操作(换言之,就不应该为空)。
我们应该遵循这种语义直觉去设计空值,因此区分可空类型和非空类型是必要而有效的。
2.4.2. 繁琐的判空语句妨碍代码整洁
常见的判空语句通常展现为一系列嵌套冗长、地狱般缩进(或者称为“冗宽”)的条件语句。而错误常常隐藏在冗长之中。
if (a!=null){
var b=a.b;
if(b!=null){
var c=b.c;
if(c!=null){
}
}
}
空值的出错与难以使用的空值管理常常有着联系——
- 难以使用的空值管理会让用户不想去写空值检查;
- 会导致代码文件不简洁而增加非常多的语法噪声,导致代码逻辑不够清晰;
- 可能会略过某处的空值检查。
2.4.3. 不同的空语义使用同一个空值表示导致语义歧义
在一些支持可空类型的列表或者容器中,“容器某一格中为空”可能代表两种意思:
- 容器不存在这一格;
- 容器存在这一格,但是这一格为空。
例如问卷调查中有些填空项目用户没有填写,空值被用作零值使用。那么从收集到的问卷中获取该项为空(例如var res = getItem("Li")
),是表示该用户不存在,还是表示该项用户未填写?
2.4.4. 基本类型与引用类型对待空值不一致
假设基本类型的零值不是通用的空值,则在引用类型与基本类型装箱和拆箱的时候,可能存在空值向基本类型零值转型的过程,但是这个过程通常不符合直觉。
Integer i = null; //Integer 类型零值是null
int p = i; //int 类型零值是0
这种不一致成为一种隐性的编程负担,同时也成了一种不必要的门槛。Kotlin和C的做法都是自动装箱和拆箱,然后让用户使用Integer
这种装箱之后的类型变得没有负担,以此统一类型。
3. 空值的设计
如何设计更好的空值呢?
我觉得有如下方面:
- 把针对空值的检查提前到编译时、书写代码时——也就是需要针对空值设计类型检查;
- 明确区分不同场景的空语义;
- 在类型上明确区分空值和非空值;
- 方便的空值管理。
3.1. 明确区分不同场景的空语义
需要考虑以下问题:
- 所有地方的空语义是否都是用同一个空值来表达?
- 使用泛型嵌套表示的类型的空语义,要怎么表达嵌套?
我们参考Scala在表示空语义方面[10]的设计:
- None:可空类型的空值(实现上用case object,样例类单例);
- Nil:列表的空值(实现上用case object),表示一个
List<Nothing>
的单例; - Null:空值的类型,字面量空值为null。它同时是所有引用类型的子类型。;
- Unit:表示函数没有参数时的参数类型,同时也表示函数返回值为空时的返回值类型。Unit类型不带任何信息,具有单例
()
,它同时作为元组的单元类型; - Nothing:空类型,它没有任何实例。它是一种底类型,是所有类型的子类型(或者实现了所有类型的功能)。
3.2. 在类型上明确区分空值和非空值
类型系统中如何给空值一个类型呢?
在这里需要强调的是,空值和空类型是不同的概念,虽然它们都表达空语义。
3.2.1. 空类型
在一些场景中,我们不需要一个“空值”,而是用一个“空类型”
例如说函数的返回值类型上:
函数的返回值为空时,函数的返回值类型:
在C语言或者C++语言中,我们使用void类型来表达这种情况。
但假如一个函数可以有多个返回值,例如一个元组(Tuple),那么当函数的返回值为空时,其实返回的是一个空元组。按照上面的类型系统来说,返回类型是积类型的单位类型(Unit Type)。也就是说,我们用空元组语义来表示函数返回值为空的语义,即采用一种单位类型作为函数返回值为空时的类型。
函数没有返回值时,函数的返回值类型:
这种情况通常发生于函数报错,中断函数的执行。这时我们要区分程序的运行时和编译时。通常情况下,函数运行时报错或者没有返回值,也需要通过编译时的类型检查,因此使用底类型来表达空语义,既可以通过编译时类型检查(作为所有类的子类型,符合类型要求),又可以表示此处出错截断。
Kotlin提供了返回值类型为Nothing(所有类型的子类型)的TODO函数,可以通过编译时静态检查,但是运行时未完成。
3.2.2. 空值的类型
需要考虑以下的问题:
- 不同地方如果用不同的空值来表达,那这些空值的类型都是同一个吗?
- 当一个类型可以包含空值时,这个类型和空值的类型之间是什么关系呢?
假如所有类型默认可空,则空值本身的类型应该是所有类型的子类型;
假如引入可空类型,则空值本身的类型应该属于Option<T>
的一部分或者子类型。
Option<T>
3.2.3. 类型可空与可空类型 在一些语言的设计上,默认空值是引用类型的子类型,也就是说 Type a; a = null;
这个语句不会报错。这种设计所有类型都可空,意味着如果我们添加一种包装Option<Type> a = empty();
,它也能够接受语言提供的空值a = null;
,而不仅仅是我们包装的空值empty()
。
而一些语言的设计上,可空类型和原来的类型有做基本区分,也就是说Type a; a = null;
这个语句是不合法的,需要包装成Option<Type> a; a = null;
才是合法的。这种设计严格区分可空类型与非空类型。
3.3. 方便的空值管理
究竟是带空值的类型常用,还是空值与非空值区分开的类型更常用、更不会出错,这里仍需要调查研究。
方便的空值管理包括以下方面:
- 方便的可空类型的构建、标注(或者非空类型的构建、标注);
- 方便的判空语句;
- 把可空类型无障碍地当作非空类型使用——这也是“方便的空值管理”的根本目的。
3.3.1. 问号与感叹号运算符??!!
运算符的使用可以简化可空类型的装包、拆包操作等。
?
可空运算符:对于严格区分空值类型与非空值类型的语言来说,写于类型前面或者后面,表示将类型转为可空类型。例如String a=null;
不合法,String? a=null;
合法;!
或者!!
非空断言运算符用于把可空的类型转为非空类型,如果被断言的可空类型赋予空值,则报错,例如a!!.length;a = null;
报错;?.
可选链:用于引出可空对象的属性,这样就不用一直在判空了,例如a?.length
;?:
Elvis运算符在判空之后返回一个默认值,例如a?:"default"
;在C中是??
双问号运算符。
3.3.2. 基于控制流的类型推断(Flow-sensitive Typing)
例如当判空之后,x能够无障碍地被当作非空的Integer使用。
Integer x; // x instance of nullable Integer
if(x!=null){
x; // x instance of Integer
}
else {
x; // x instance of Null
}
4. 可空类型的设计
4.1. 运行时断言判空
运行时上,Java设计了Objects.requireNonNull
,如果参数为空值,则报错IllegalArgumentException
。这通常适用于一些参数要求必须非空的场景。
然而事实上,这些参数要求必须非空的场景是代码书写时决定的,我们实际上可以在编译时即可发现这些断言标记,然后把检查提前到编译时。
一些IDE的语言服务器即提供这样的“书写代码时”断言语句代码分析(类似于IDE为Kotlin的智能转换(Smart Cast)提供类型提示)。
a.fly(); //IDE在这里提供一个“a可能为空值”的警告。
Objects.requireNonNull(a);
a.fly(); //IDE在这里不会报错。
@Nullable
和@NonNull
4.2. 被注解的类型的参数会进行编译时静态检查,因此我们可以认为@Nullable Integer
和@NonNull Interger
是两种不同的类型。这两个注解通常选一者使用,而另一者作为默认情况。
在C中是使用[MaybeNull]
/[AllowNull]
以及[NotNull]
等属性来做标注。
T|Null
4.3. 不具名联合类型 假如类型系统支持不具名联合类型,而且限定默认类型非空,则可以设计如下的语句:
type Option<T> = T|Null;
Option<Integer> number = null;
number = 10;
事实上,不具名联合类型可以这样和注解方式对应:
Option<Integer>
对应@Nullable Integer
Integer
对应@NotNull Integer
4.3.1. Kotlin的实现
Kotlin提供了?
语法糖用于区分T
和T|Null
,并默认所有类型非空。
var number:Int? = null
number = 10
Kotlin提供了方便的空值管理语法糖,如?.
可选链调用、?:
如果为空则返回默认值、!!
重新标记值为非空等运算符;以及基于空值流的类型推断和智能转换。
的实现
4.3.2. CC提供了Nullable<T>
(缩写为T?
)作为可为空类型。此外,对于返回值,T?
等效于 [MaybeNull]T
;对于参数值,T?
等效于 [AllowNull]T
。
C还提供了一系列空值管理的语法糖,如?.
可选链调用、??
如果为空则返回默认值,!
null 包容运算符 ;以及一系列空值状态监控等。
请参阅:可为空引用类型、可以为 null 的值类型、了解为 Null 性、编译器解释的属性:可为 null 的静态分析
4.3.3. 采用不具名联合类型实现方式的缺陷
不具名联合类型无法表示嵌套的空语义场景,即Option<Option<T>>
总是合并成Option<T>
,导致常常在各个地方使用同一个null来表示空语义,因此可能产生歧义
例如,如果一个二维表格能够存储可空字符串;当表格某一格为空时,无法通过这个格子判断究竟是这一整行是否为空;而是得首先判断整行是否为空,再继续操作。
又例如,如果一个列表能够存储可空字符串;当列表填入null时,列表的第一项为空表示的是“这是一个空列表”,还是说“第一项为空”?
val table:List<List<String?>> = mutableListOf()
//没办法通过某一格来判断一行是否为空
list.get(1)?.get(2)
val list:List<String?> = mutableListOf()
//first()返回值类型为String?而不是String??
list.first()
4.4. 每一个类型都有一个空子类型/空对象单例
在语言内核没有支持自动生成空对象单例的时候,这种设计称为“空对象设计模式”。例如如下代码,使用NullCarrier
来处理和Carrier
有关的所有代码。
abstract class Carrier{}
class Car extends Carrier{}
class NullCarrier extends Carrier{
//TODO //标记TODO使得编译时不正确引用报错
}
但如果把这种特性提升到语言级别的支持,在具有子类型的情况下:
//自动生成 object Carrier.Null
class Carrier{}
//自动生成 object Car.Null
class Car extends Carrier{}
此时,Car.Null
应同时为Car
、Carrier.Null
的子类型。换言之,在判断if(a instanceof Carrier.Null)
的时候,Car.Null
也应满足条件。
推广开来,也就是说有两条继承的链路:Object
是所有引用类型的基类;Null
是所有空类型的基类;这两者同时是空子类型的基类。
4.5. 采用属性包装的Option
public final class Optional<T> {
//构造函数: 通过OfNullable方法包装一个值
private final T value;
private Optional(T value) {
this.value = value;
}
public static <T> Optional<T> ofNullable(T value) {
return value == null ? (Optional<T>) EMPTY
: new Optional<>(value);
}
//单例:EMPTY单例表示空语义
private static final Optional<?> EMPTY = new Optional<>(null);
public static<T> Optional<T> empty() {
Optional<T> t = (Optional<T>) EMPTY;
return t;
}
}
属性包装方法是一种具名联合类型的实现方式,我们可以把它看成是(假设可以支持下述的enum构造器):
enum Optional<T>{
record HAS_VALUE(T value){},
EMPTY;
}
然而由于这种实现依赖于现成的null,因此有缺陷:
- 本身的
EMPTY
的实现依赖于null。 - 检查
EMPTY
是值角度的检查,而非类型角度的检查,因此在使用被包装的T的时候,仍然需要考虑上下文语境,这个T是否是从Optional<T>
中get出来的。 Optional<Integer> checked = null;
是被支持的,不能保证它的所有值都是从ofNullable
中构造出来的。(此外,Optional.of
的实现没有排除Optional.of(null)
的情况,当然这个应该使用ofNullable
代替。)
4.6. 采用具名联合类型包装的Option
4.6.1. “代数数据类型”与模式匹配
包含构造器标签的“代数数据类型”(Algebraic Data Type)也是一种具名联合类型的实现方式。例如二叉树,我们定义两种构造器标签,其中EmptyTree
是一个无参构造函数,而Node
是一个三参数构造函数。我们写成:
data Tree<T> {
record EmptyTree();
record Node(T vale, Tree<T> left, Tree<T> right);
}
模式匹配分为两个部分,一个是模式解构,一个是匹配模式并执行子语句。
模式解构最简单的例子是元组类型的解构:例如let [a,b] = [1,2];
把数据1分配给a,2分配给b。更复杂的例子是构造器的参数解构,例如let Some(value) = Some(1);
把数据1分配给value。
通常在函数体上需要匹配模式,并定义执行语句。如果需要处理一颗二叉树,首先需要匹配构造器EmptyTree
,书写对应的执行语句;再匹配构造器Node
,书写对应的执行语句。
<T> void preShow(Tree<T> tree){
switch(tree){
case EmptyTree()-> { println(""); }
case Node(n,left,right)->{ //在这里还要模式解构
var leaf = n.toString();
var leftShow = preShow(left);
var rightShow = preShow(right);
println(left + leftShow + rightShow);
}
}
}
以上的伪代码在Haskell中的实现如下:
data Tree a = EmptyTree | Node a (Tree a) (Tree a)
deriving (Show)
-- 示例树
exampleTree :: Tree Int
exampleTree =
Node 1
(Node 2
(Node 4 EmptyTree EmptyTree)
(Node 5 EmptyTree EmptyTree)
)
(Node 3
(Node 6 EmptyTree EmptyTree)
EmptyTree
)
-- 前序遍历
preOrder :: Tree a -> [a]
-- 模式匹配
preOrder EmptyTree = []
preOrder (Node x left right) = [x] ++ preOrder left ++ preOrder right
-- 测试前序遍历
preOrder exampleTree -- 输出: [1,2,4,5,3,6]
“代数数据类型”与模式匹配互相配合,即可以实现优雅的判空逻辑。
4.6.2. Haskell的实现方式
Haskell的data关键字构造的类型支持具名构造器联合Union。同时,这种途径实现的Maybe[11]可以作为一种Monad[2:1],这里不展开了。
data Maybe a = Nothing | Just a
deriving (Eq, Ord)
option :: Int -> Maybe Int
option 0 = Nothing
option x = Just x
g :: Int -> Maybe Int
g 100 = Nothing
g x = Just x
h :: Int -> Maybe Int
h x = case option x of
Just n -> g n
Nothing -> Nothing
4.6.3. Rust的实现方式
在Rust中的实现称为枚举类,枚举类也是一种具名联合类型。
Rust的模式匹配语法match可以配合枚举类实现优雅的处理。
Rust还提供了?
宏用于空值管理,如果遇到None
或者Error<R>
就返回。
enum Option<T>{
Some(T),
None,
}
fn f1()-> Option<i32>{
None;
}
fn f2(num: Option<i32>) {
match num {
Option::Some(value) => {
println!("{}", value);
}
Option::None => {
println!("null");
}
}
}
fn main() {
f2(Some(10));
f2(None);
//解构语法
let option = Some(10);
let Some(value) = option else {
//处理为空的情况
};
//如果为None则返回,不再执行。
let option = f1()?;
println!("not print");
}
4.6.4. Scala的实现方式
Scala对于“代数数据类型”的实现,则涉及到密封类(sealed class,被直接继承的子类是有限的)、样例类(case class,构造器可以直接被解构)。
sealed abstract class Option[+A] extends IterableOnce[A] with Product with Serializable {
def get: A
}
final case class Some[+A](value: A) extends Option[A] {
def get: A = value
}
case object None extends Option[Nothing] {
def get: Nothing = throw new NoSuchElementException("None.get")
}
def prevent(str:Option[String]):String= {
str match {
case Some(x)=>x
case None => "NA"
}
}
@main
def main():Unit = {
println(prevent(Some("test")))
}
4.6.5. 采用具名联合类型实现方式的缺陷
从语义直觉角度,
Option<T>
应该能够容纳T
类型,或者Some<T>
是T
的子类型(Some<T> <: T
),类似于C++的std::variant
。std::variant<int,std::string> v; v = 10; // 存储int类型的值 v = "null"; // 存储std::string类型的值
这种语义直觉体现在参数传递上,具名联合类型的
Option<T>
每次传递时都需要包装,而不是能够直接传入参数。fn f2(num: Option<i32>) {} f2(Some(10)); //而不是f2(10);然而,多一层包装可以实现Monad式的函数式传递,
在运算过程需要繁琐的装包拆包。
当然,多一层包装可以实现Monad式的函数式传递。Haskell通过
>>=
运算等,能够联系多个Monad,简化了装包拆包的过程。tentimes x = Just (10*x) addtwo x = Just (x + 2) -- 使用>>=对包装的值进行传递 Just 4 >>= tentime >>= addtwo
通常的计算API只会支持
T
或者Option<T>
其中一种,如果都要支持得重载函数。另外,在一些计算过程很可能出现利用到T|Option<T>
的场景,而这个场景出现的本质是Option<T>
应该能够容纳T
的语义直觉。
5. 空值设计的改进
不具名联合类型的方式解决了语义直觉的问题(即Option<T>
能够容纳T
);具名联合类型的方式解决了嵌套空语义的表示问题。
而我们如果要让一种空值设计同时拥有这两种优势,则可以做如下改进:
- 对于具名联合类型的实现方式,提供
T
到Option<T>
的直接转型(例如仓颉、C++的variant
)。然而编译器开洞的方式不够统一与优雅;提供赋值运算符或者初始构造器重载(为了实现自动转型)则容易在其他地方出现问题。 - 对于不具名联合类型的实现方式,采用嵌套空值类型的方式区分不同层次的空值。
本文主要介绍第二种改进的思路与设想。
5.1. 不具名联合类型结合嵌套空值类型
按照以下方式定义联合类型:type Option<T> = T|None<T>
,则对于嵌套的Option有如下的展开:
// 第一次嵌套 <第二层嵌套>
Option<Option<T>>
= Option<T> | None<Option<T>>
= T | None<T> | None<T|None<T>>
其中,T
对应第一层嵌套与第二层嵌套存在值的情况;None<T>
对应第一层嵌套存在值,而第二层嵌套不存在值的情况;None<T|None<T>>
对应第一层嵌套不存在值的情况。
嵌套语义中不会出现第一层嵌套不存在值反而第二层嵌套存在值的情况;而且我们通常情况下使用比较多的是None<None<T>>
以表示第一层和第二层嵌套都不存在值的情况。但是None<T|None<T>>
不能够直接展开为None<T>|None<None<T>>
。
我们利用子类型关系进行展开。因为None<T>
是T|None<T>
的子类型,如果泛型构造器None
实现了协变,则None<None<T>>
是None<T|None<T>>
的子类型。在这种情况下,对于Option<Option<T>>
我们只要分三类进行讨论即可:
T
:第一层嵌套与第二层嵌套存在值;None<T>
:第一层嵌套存在值,而第二层嵌套不存在值;None<None<T>>
:第一层和第二层嵌套都不存在值。
T|None<T>
5.1.1. 不区分单个空值和嵌套空值的设计 在Scala中,我们可以为空值提供一个默认值以帮助模式匹配中的类型解构。这种设计将空值进一步区分,适用于同一种可空类型表达多种不同的空语义。
case class None[+T](default:T);
type Optional[T] = None[T] | T ;
def f2(b:Optional[Optional[Int]]):Int = {
b match
case n:Int => n
case None(None(_)) => 13
case None(_) => 14
}
@main
def main2(): Unit = {
println(f2(10))
println(f2(None(0)))
println(f2(None(None(0))))
}
即使是写成T|None[T]
,依然能够补充map、flatMap方法,和Scala的Option的功能保持一致。
object Optional{
def of[T](value:Optional[T]): Optional[T] = value
}
extension [A](x: Optional[A])
def map[B](f: A => B): Optional[B] = {
x match
case None(n:A) => None(f(n))
case x:A => f(x)
}
def flatMap[B](f: A => Optional[B]): Optional[B] = {
x match
case None(n: A) =>{
f(n) match
case None(n1:B) => None(n1)
case x1 : B => None(x1)
}
case x: A => f(x)
}
@main
def main2(): Unit = {
val res = for{
o1 <- Optional.of(1)
o2 <- Optional.of(2)
} yield o1+o2
println(res)
}
T|None<T>
5.1.2. 区分单个空值和嵌套空值的设计 在Scala中,我们可以实现一个最基层的空值Nil
来表示最内层嵌套为空值,而外层嵌套都有值的情况,这种设计可以让所有嵌套空值都利用一系列相同的对象。
sealed trait None[+T];
case object Nil extends None[Nothing]{
//匹配null
override def equals(obj: Any): Boolean = obj==null || super.equals(obj)
};
case class Non[T](default:None[T]) extends None[None[T]];
type Optional[T] = None[T] | T;
def f2(b: Optional[Optional[Int]]): Int = {
b match
case n: Int => n
case Nil => 14
case Non(Nil) => 13
}
@main
def main3(): Unit = {
println(f2(10))
println(f2(Nil))
println(f2(Non(null))) //null也会被Nil匹配到。
}
值得一提的是,如果写成case class Nil[T]() extends None[T];
,意思是想通过Nil[Int]()
和Nil[Byte]()
做区分,但事实上在Scala中这二者是相等的。
6. 空值与错误处理的联系
在非空类型中,出现空值是一种错误;在可空类型中,将空值当作正常类型使用也是一种错误(也就是Java与C中的空指针异常)。
同时,空语义可能被用来表达出现错误,但空语义本身不应该被视为一种错误;而空语义可能会像错误那样往后传播,例如可选链中,a.b.c.d
,倘若a为空,则空语义传播到d处。
如果能够将处理空值的过程与处理错误的过程有机联系,可以提高用户对空值错误的敏感度,并使语言的语法设计更加统一。
6.1. 采用具名联合类型包装
在含有类型类的语言中,常常将带有错误的类型设计为Result<T>
,而可空类型设计为Option<T>
。采用相同的包装方式,代表他们可以采取相似的处理过程。
例如Rust中,可空类型和带错误的类型采用近似的包装,并且提供相同的?
宏,以及它们之间的转换函数:
Result.ok()
:Result转Option;Option.ok_or(Error)
:Option转Result;Result.map_error(|_|Error)
:Result转Result(Error1转Error2)。
enum Option<T>{
Some(T),
None,
}
enum Result<T, E> {
Ok(T),
Err(E),
}
T|None<T>|Error<R,T>
6.2. 将错误类型同时作为不具名联合类型的一部分同时我们设计一个语法糖:
- 类型
T!R
表示Result<T,R> = T|Error<T,R>
; - 类型
T?R
表示ResultOrNull<T,R> = T|None<T>|Error<T,R>
。
6.3. 像“像代数运算一样传播副作用”一样传播空值与错误
“像代数运算一样传播副作用”也被翻译成代数效应(Algebraic Effects)。
书写这样的程序通常分为两个部分:
一个可以进行联想的事实是——空值也是一个hole;而且在我们的设计里,不同类型的空值的类型也是不同的——通过“像代数运算一样传播副作用”来处理空值会是一种有趣的想法。
以下讨论一个除零错误的例子(用类似Kotlin的伪代码书写):
//定义一个错误
object DivZeroError:Error<()->Nothing> {
//触发错误时被调用
val thrown:()->Nothing = ()->{
println("divide 0 error!")
}
}
//定义一个会引起错误的函数
val testDiv :(Int,Int?)->Int!DivZeroError = (i,j)->{
//j!!会throw NullReferenceError()
if((j!!)==0) throw DivZeroError()
else return i/j
}
//定义处理错误的函数,
val fail : Int?DivZeroError -> Int = (continuation)->{
when(continuation){
None<Int> -> return 1 //填充的位置是(j!!)
DivZeroError -> return Int.MAX_VALUE //填充的位置是throw DivZeroError()
else -> return continuation
}
}
val main = ()->{
try{
println(testDiv(10,2))
println(testDiv(10,null))
println(testDiv(10,0))
} with fail
}
//或者设计这样的语法糖:
val main = ()->{
println(testDiv(10,2)?<fail)
println(testDiv(10,null)?<fail)
println(testDiv(10,0)?<fail)
}
//5
//10
//divide 0 error!
//2147483647