值类别
C++ 的值类别系统有时候被认为是混乱而邪恶的,是为了回溯兼容强行打上的补丁。C++17 进行了一次大规模的体系修补,把值类别系统等基本上修得比较严谨了(另一处重要的修补是求值顺序)。然而,这套设计存在一些小缺陷,使得有些地方看着不太优雅。近些年来随着要求加入重定位语义的呼声越来越大,使值类别系统进化为优雅设计的机会几乎要来了,可惜还是差一丝,保守派胜利了。
在 AutoLang 中,我基于现有系统和一些 C++ 提案,设计了一套稍微有点不同的值类别系统。
远古 AutoLang 的值类别系统
在 AutoLang 中,表达式拥有两种基本属性:类型和值类别,这两种属性是正交的。
类别
根据有无身份和是否要转移所有权,值类别分为三种:左值、右值、(将)亡值。
所有权 \ 身份 | 有身份 | 无身份 |
---|---|---|
转移所有权 | 亡值 | 右值 |
持有所有权 | 左值 | 无意义 |
左值的来源主要是:
- 具名存量、函数、成员存量的名字
- 任何类型为左值引用的表达式
- 不包括第 1 条
- 类型为
T&
的表达式是T&
类型左值
&
表达式- 字符串字面量是
Char[N]&
类型
右值的来源是:
- 除了字符串以外的字面量
- 类型是非引用类型的函数调用或重载运算符表达式
- 对象构造表达式
reloc
表达式auto
表达式
亡值的来源是:
&&
表达式- 临时量实质化
- 具有移动资格的表达式
左值与亡值统称为泛左值,亡值与右值统称为泛右值。
引用
AutoLang 的引用与 C++ 的引用有很多区别,具体来说有两种:左值引用和亡值引用。
左值引用可以绑定到左值表达式,但 const
左值引用不能绑定到右值或亡值。亡值引用可以绑定到亡值,但不能绑定到右值。左值引用与右值引用的混合引用折叠是非良构的。
名字表达式
名字表达式有特殊的地位,原因涉及到之后要说的统一推导规则,毕竟我们也不希望
auto x = 0;
auto y = x;
的 y
是 Int&
吧。
所以令名字表达式有一种特殊含义:
- 非引用的
T
类型存量名是T
类型左值 - 任何左值或右值引用
T&
或T&&
是T
类型左值
其实就是自动脱去引用修饰符。
引用表达式
引用表达式有两种:
- 左值引用
&expr
- 对于非亡值引用类型
T
或T&
的左值表达式expr
,&expr
是T&
左值 - 对于亡值引用类型
T&&
的亡值表达式expr
,&expr
是T&
左值 (有缺陷) - 其它表达式非良构
- 对于非亡值引用类型
- 亡值引用
&&expr
- 对于左值表达式
expr
,&&expr
是T&&
亡值 - 对于右值表达式
expr
,&&expr
进行临时量实质化,延长其生命周期,得到指代同一对象的亡值引用类型亡值表达式 (有缺陷) - 对于亡值表达式
expr
,&&expr
不做任何事
- 对于左值表达式
上古 AutoLang 的值类别系统
可以看到,远古 AutoLang 的值类别系统相比于 C++ 的值类别系统有意弱化了亡值和亡值引用的概念,这是因为我们在 AutoLang 中引入了破坏性移动——对应于 C++ 的重定位语义——即移动语义。在 C++ 中,出于兼容性的考虑,移动语义采用的是非破坏性移动,从而经常要用到亡值。而 AutoLang 没有兼容性考虑,主推破坏性移动,从而亡值的作用被大大弱化了。
如果我们以 AutoLang 思维来看,弱化后的亡值和亡值引用已经没有存在的意义了,它们只会徒增复杂度。所以中古 AutoLang 的值类别系统抛弃了亡值,仅剩左值和右值。
非破坏性移动
C++ 中的 std::move
仅生成亡值,除此之外什么都不做,而如果喂给它一个纯右值,则可能会产生悬垂引用。
AutoLang 的非破坏性移动被称作“取用”,使用关键词 take
表示,这种语义称作取用语义。
take
接受一个左值,然后 take
表达式是一个右值。被取用的对象依然存活,处于有效的 taken 状态,常常是空状态,常见的反例是 std::String
之类的有特殊机制的类型和可平凡取用的类型。
引用和引用表达式
删除了亡值和亡值引用的体系干净多了,我们只有一种引用,那就是左值引用,它可以绑定到一个左值表达式( T
或 &T
)上。然后仅有一种引用折叠规则,那就是 & &T
-> &T
。
表达式的值类别与类型交织出三种语义:
值类别 \ 类型 | T | &T |
---|---|---|
左值 | 复制语义 | 引用语义 |
右值 | 移动语义 | 不存在 |
具有移动资格的表达式
如果一个左值被使用的同时,它所指代的对象也同时被销毁,则会将其进行移动。例如 return
和 throw
的时候。
破坏契约
非破坏性移动只是改变了对象的状态,仍然处于存活且不违反不变式(如果有)的状态。但我们设想这种情况:
T: type = {
x: T1,
y: T2,
} // 假设存在不变式
t := T();
x := move t.x;
这时候虽然 t.y 能用,但是,如果 T 的不变式包含“成员 x
存活”,则违反了不变式。但如果只是非破坏性移动,则至少不会令其不存活。
更致命的是,想到这个的时候我突然发现,即使不引入不变式,也可能造成危险:
T: type = {
x: T1,
y: T2,
} // 假设没有不变式
f: (t: &T) = {
// 无论其内容,即便是空函数
}
t := T();
x = move t.x;
f(t); // boom!
上面这段代码的 T
明确了没有不变式,但函数 f
的入参 &T
却隐含了前条件:完整存活的对象。显然形式上第 12 行违反了前条件。
所以我正在设计一套方案,可能会是下面其一:
- 禁止对成员做破坏性移动
- 对成员做破坏性移动后,其所属对象不能再被单独引用(如同死亡,且会递归传染),但可以访问其他成员
- 其他未知方案
静态分析能够比较容易地防止使用已经移动的对象,但没有所有权和借用检查这一整套基本语义作为支撑的前提下,移动导致已有的引用或指针悬垂是难以通过静态分析得知的:
x := 1;
y := &x;
z := move x;
// 此时 y 悬垂
我保留非破坏性移动作为其中一个缓冲带,但还有一个计划是设计一套动态分析系统,这可能会在契约相关部分提到。