Skip to content

值类别

C++ 的值类别系统有时候被认为是混乱而邪恶的,是为了回溯兼容强行打上的补丁。C++17 进行了一次大规模的体系修补,把值类别系统等基本上修得比较严谨了(另一处重要的修补是求值顺序)。然而,这套设计存在一些小缺陷,使得有些地方看着不太优雅。近些年来随着要求加入重定位语义的呼声越来越大,使值类别系统进化为优雅设计的机会几乎要来了,可惜还是差一丝,保守派胜利了。

在 AutoLang 中,我基于现有系统和一些 C++ 提案,设计了一套稍微有点不同的值类别系统。

远古 AutoLang 的值类别系统

在 AutoLang 中,表达式拥有两种基本属性:类型和值类别,这两种属性是正交的。

类别

根据有无身份和是否要转移所有权,值类别分为三种:左值、右值、(将)亡值。

所有权 \ 身份有身份无身份
转移所有权亡值右值
持有所有权左值无意义

左值的来源主要是:

  1. 具名存量、函数、成员存量的名字
  2. 任何类型为左值引用的表达式
    • 不包括第 1 条
    • 类型为 T& 的表达式是 T& 类型左值
  3. & 表达式
  4. 字符串字面量是 Char[N]& 类型

右值的来源是:

  • 除了字符串以外的字面量
  • 类型是非引用类型的函数调用或重载运算符表达式
  • 对象构造表达式
  • reloc 表达式
  • auto 表达式

亡值的来源是:

  • && 表达式
  • 临时量实质化
  • 具有移动资格的表达式

左值与亡值统称为泛左值,亡值与右值统称为泛右值。

引用

AutoLang 的引用与 C++ 的引用有很多区别,具体来说有两种:左值引用和亡值引用。

左值引用可以绑定到左值表达式,但 const 左值引用不能绑定到右值或亡值。亡值引用可以绑定到亡值,但不能绑定到右值。左值引用与右值引用的混合引用折叠是非良构的。

名字表达式

名字表达式有特殊的地位,原因涉及到之后要说的统一推导规则,毕竟我们也不希望

autolang
auto x = 0;
auto y = x;

yInt& 吧。

所以令名字表达式有一种特殊含义:

  • 非引用的 T 类型存量名是 T 类型左值
  • 任何左值或右值引用 T&T&&T 类型左值

其实就是自动脱去引用修饰符。

引用表达式

引用表达式有两种:

  • 左值引用 &expr
    • 对于非亡值引用类型 TT& 的左值表达式 expr&exprT& 左值
    • 对于亡值引用类型 T&& 的亡值表达式 expr&exprT& 左值 (有缺陷)
    • 其它表达式非良构
  • 亡值引用 &&expr
    • 对于左值表达式 expr&&exprT&& 亡值
    • 对于右值表达式 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
左值复制语义引用语义
右值移动语义不存在

具有移动资格的表达式

如果一个左值被使用的同时,它所指代的对象也同时被销毁,则会将其进行移动。例如 returnthrow 的时候。

破坏契约

非破坏性移动只是改变了对象的状态,仍然处于存活且不违反不变式(如果有)的状态。但我们设想这种情况:

autolang
T: type = {
    x: T1,
    y: T2,
} // 假设存在不变式

t := T();
x := move t.x;

这时候虽然 t.y 能用,但是,如果 T 的不变式包含“成员 x 存活”,则违反了不变式。但如果只是非破坏性移动,则至少不会令其不存活。

更致命的是,想到这个的时候我突然发现,即使不引入不变式,也可能造成危险:

autolang
T: type = {
    x: T1,
    y: T2,
} // 假设没有不变式

f: (t: &T) = {
    // 无论其内容,即便是空函数
}

t := T();
x = move t.x;
f(t); // boom!

上面这段代码的 T 明确了没有不变式,但函数 f 的入参 &T 却隐含了前条件:完整存活的对象。显然形式上第 12 行违反了前条件。

所以我正在设计一套方案,可能会是下面其一:

  • 禁止对成员做破坏性移动
  • 对成员做破坏性移动后,其所属对象不能再被单独引用(如同死亡,且会递归传染),但可以访问其他成员
  • 其他未知方案

静态分析能够比较容易地防止使用已经移动的对象,但没有所有权和借用检查这一整套基本语义作为支撑的前提下,移动导致已有的引用或指针悬垂是难以通过静态分析得知的:

autolang
x := 1;
y := &x;
z := move x;
// 此时 y 悬垂

我保留非破坏性移动作为其中一个缓冲带,但还有一个计划是设计一套动态分析系统,这可能会在契约相关部分提到。

基于 VitePress | 前往主站