(很久)之前上编译原理时,一次实验课需要补充完善一个用 c 写的词法分析器;而这个分析器在定义语法树结点时使用了 union
存储语言中不同表达式的类型标签或值本身。因为当时刚好学完了 cpp,拿着锤子看啥都像钉子,所以尝试(并且勉强成功地)将给好的程序用 cpp 重写了一遍(好孩子不要学)。
重写过程中遇到的最大问题就是:源程序中的 union
与 cpp 的类型系统不太兼容,不管怎么写编译器都会给我糊一个编译错误;这就引出了一个问题:cpp 的 union
究竟该如何使用。
union
与 cpp从类型论角度来看,union
是一种“和类型1”,这种类型允许在同一个地址空间、但在不同时间存放不同类型的数据。
#include <bitset>
#include <iostream>
// 例如说对于下面这个 union
union U {
float mem1;
int mem2;
};
int main()
{
static_assert(sizeof(U) == std::max(sizeof(float), sizeof(int)));
// union 的大小通常等于其最大的成员分量的大小
U tmp { 3.14f }; // 可以先存入一个 float 类型数据
std::cout << tmp.mem1 << std::endl; // 使用掉它
tmp.mem2 = 114514; // 稍后再往同一个内存空间存另一种类型的数据
std::cout << tmp.mem1 << std::endl;
// 这样就实现了一段内存空间的复用
}
union
类型经常被用在一些语法解析器中,因为它用起来实在是很方便(指能够以定长空间存储多种类型数据)。
自 cpp11 后,cpp 标准提高了类型安全的要求,但如果我们看一下 union
定义就会发现,这个语言功能天生就极其的类型不安全。举个例子,下面这段代码就直接“击穿”了 cpp 的类型系统(虽然这种击穿随处可见):
#include <iostream>
#include <cstring>
union Breaker {
int answer;
double magic_number;
char* magic_string;
};
int main()
{
// 活跃成员为 int 类型
// 活跃成员是指最近一次存有有效数据的成员分量
Breaker bk { 42 };
std::cout << bk.answer << std::endl; // Ok
std::cout << bk.magic_number << std::endl; // 能够通过编译,但运行期行为未定义
// std::cout << strlen( bk.magic_string ) << std::endl; // 同上,但这样做通常会导致越界访问,进而导致程序 crash
bk.magic_string = new char[21] { "Say something" };
std::cout << bk.magic_string << std::endl;
bk.magic_number = 3.14; // 活跃成员的切换导致指向堆上资源的指针被覆盖
// 最终导致内存泄漏发生
}
得益于 cpp “充分信任程序员2”的理念,除非编译器对这种行为有单独且明确的警告,否则这种代码完全能够通过编译并执行(cpp 是自由的);但这种非主观地突破类型系统的行为通常会导致程序出现各种运行期错误,最明显不过的就是上述代码的越界访问。
并且,如果试图往 union
中填入标准库的容器类型或是其他自定义的对象类型(这相当实用且常见),编译器有时还会没头没尾地爆出“默认析构函数已被弃置”的编译错误;这是为什么?
答案是标准的规定。根据 cpp 标准:
union
中;union
中,但这个 union
自己的复制、移动、默认构造函数以及复制赋值、移动赋值运算符和析构函数都不会被编译器默认提供,并且由用户提供的默认构造函数中只允许一个成员使用默认成员初始化器(就是构造函数里的那个冒号)。这都 4202 年了,cpp11 之前的事情我们不管,现在只需要把焦点聚焦在 cpp11 后的标准规定上。那么首先,什么是“非平凡”?
平凡类型指的是标量类型(如 int
和 std::nullptr_t
等)和平凡类类型,以及前两种类型组成的数组类型。
平凡类类型必须满足:
memcpy
这种方式复制);这里的定义很复杂(一般也懒得看),要求也很苛刻,但不满足约束条件的结果只有一个:该 union
的六大特殊成员函数3都会被默认弃置(即编译器不会帮你自动生成);这也就是前文中会爆出编译错误的原因(因为编译器压根找不到要用的函数在哪)。
通常来说,一个这样的 union
在编译时会这样的编译错误:
#include <string>
union U {
int integer;
double floating;
std::string str;
};
int main()
{
U uni;
} // error: use of deleted function 'U::~U()'
但如果为这个 union
类型添加一个什么都不做的析构函数和默认构造函数,就一切都正常了。
#include <string>
union U {
int integer;
double floating;
std::string str;
U() {}
~U() {}
};
int main()
{
U uni;
} // everything ok
你以为这么简单就结束了吗?当然没有。不妨再细想一下:当活跃成员是一个 std::string
时,如果需要将活跃成员切换为另一个分量时,我们是安全的吗?
union
这里有个前提:由于编译器无从得知一个 union
的当前活跃成员是谁,因此自然而然的,union
内的对象的析构函数永远不会自动被执行。
这就导致了,当 union
的活跃成员从一个非平凡成员上切走时,我们必须主动调用该成员的析构函数;如果不这样做,答案自然是内存泄漏(因为这种操作打破了 RAII 保证)。
而当我们将 union
切换到另一个非平凡成员分量时,在除了创建该 union
以外的情景下,都必须使用 placement new 的方式在指定地址调用构造函数。
这就导致了正确使用 union
的代码极其割裂和丑陋。
#include <string>
#include <iostream>
template<typename T, typename E>
union UnionLike { // 没错,union 当然可以模板化
T result_value_;
E error_info_;
UnionLike( T value ) : result_value_ { move( value ) } {}
UnionLike( E error ) : error_info_ { move( error ) } {}
~UnionLike() {}
};
int main()
{
UnionLike<int, std::string> result( "Unknown" ); // 创建时不需要 placement new
static_assert(sizeof( result ) == std::max( sizeof( std::string ), sizeof( int ) ));
// 没问题
std::cout << result.error_info_ << std::endl;
// 未定义行为
// std::cout << result.result_value_ << std::endl;
// 切换活跃成员之前必须主动调用析构函数
result.error_info_.~basic_string();
// 然后通过 placement new 在原地址上构造新对象
new (&result) int( 42 ); // 当然,对于平凡类型不必如此
cout << result.result_value_ << endl;
// here is definitely an UB
// std::cout << result.error_info_ << std::endl;
}
因此通常来说,要想安全使用 union
都需要使用一个 class
做一遍封装。
令人高兴的是,自 cpp17 后标准库中有了 std::variant
,这就是一个类型安全的 union
;而在 cpp17 以前,则可以选择 boost::variant
作为代餐。
#include <variant>
#include <iostream>
int main()
{
std::variant<int, std::string> result( "Unknown" );
// 因为实现机制的问题,所以求 std::variant 的实际大小时需要减去一个指针的长度
static_assert((sizeof( result ) - sizeof( void* )) == std::max( sizeof( std::string ), sizeof( int ) ));
// 但和 cpp 中其他泛型容器一样,东西放进去容易,取出来很麻烦
// 可以获取当前活跃成员所在的索引下标
std::cout << "Index of: " << result.index();
// 然后通过指定类型与 std::get 访问对应成员,但如果活跃成员不是这个类型,就会抛出异常
std::cout << " is value of: " << std::get<std::string>( result ) << std::endl;
result = 42; // 使用赋值运算符直接切换活跃成员,不需要手动析构成员
// 也可以使用访问器
std::visit( []( auto&& arg ) { std::cout << arg << std::endl; }, result );
}
不过如果能确保使用 union
时都是一些非常底层的场景,从头到尾都在干一些脏活而不会向 union
中填入非平凡类型的话,大胆使用 union
就好了,毕竟即使是“零抽象开销”的标准库也不是真的完全是毫无开销的。
与之相对的,元组(或者是 c/cpp 的结构体)是一种“积类型”,也就是可以在不同空间、同一时间存入不同数据。 ↩︎
更多时候像是一种毫无约束的自由;而放纵的自由就意味着混乱。 ↩︎
分别是:默认构造函数、复制构造函数、移动构造函数、复制赋值运算符、移动赋值运算符和析构函数。 ↩︎