商城网站开发,做网站都需要准备什么软件,百度小程序开发者工具,自助建站自媒体0.前情提要
#xff08;很久#xff09;之前上编译原理时#xff0c;一次实验课需要补充完善一个用 c 写的词法分析器#xff1b;而这个分析器在定义语法树结点时使用了 union 存储语言中不同表达式的类型标签或值本身。因为当时刚好学完了 cpp#xff0c;拿着锤子看啥都…0.前情提要
很久之前上编译原理时一次实验课需要补充完善一个用 c 写的词法分析器而这个分析器在定义语法树结点时使用了 union 存储语言中不同表达式的类型标签或值本身。因为当时刚好学完了 cpp拿着锤子看啥都像钉子所以尝试并且勉强成功地将给好的程序用 cpp 重写了一遍好孩子不要学。
重写过程中遇到的最大问题就是源程序中的 union 与 cpp 的类型系统不太兼容不管怎么写编译器都会给我糊一个编译错误这就引出了一个问题cpp 的 union 究竟该如何使用。
1.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 cstringunion Breaker {int answer;double magic_number;char* magic_string;
};int main()
{// 活跃成员为 int 类型// 活跃成员是指最近一次存有有效数据的成员分量Breaker bk { 42 };std::cout bk.answer std::endl; // Okstd::cout bk.magic_number std::endl; // 能够通过编译但运行期行为未定义// std::cout strlen( bk.magic_string ) std::endl; // 同上但这样做通常会导致越界访问进而导致程序 crashbk.magic_string new char[21] { Say something };std::cout bk.magic_string std::endl;bk.magic_number 3.14; // 活跃成员的切换导致指向堆上资源的指针被覆盖// 最终导致内存泄漏发生
}得益于 cpp “充分信任程序员2”的理念除非编译器对这种行为有单独且明确的警告否则这种代码完全能够通过编译并执行cpp 是自由的但这种非主观地突破类型系统的行为通常会导致程序出现各种运行期错误最明显不过的就是上述代码的越界访问。
并且如果试图往 union 中填入标准库的容器类型或是其他自定义的对象类型这相当实用且常见编译器有时还会没头没尾地爆出“默认析构函数已被弃置”的编译错误这是为什么
2.让代码先通过编译
答案是标准的规定。根据 cpp 标准
在 cpp11 以前带有非平凡的构造函数和析构函数的非静态成员下称非平凡成员不能被放置在 union 中在 cpp11 之后非平凡成员可以被填入 union 中但这个 union 自己的复制、移动、默认构造函数以及复制赋值、移动赋值运算符和析构函数都不会被编译器默认提供并且由用户提供的默认构造函数中只允许一个成员使用默认成员初始化器就是构造函数里的那个冒号。
这都 4202 年了cpp11 之前的事情我们不管现在只需要把焦点聚焦在 cpp11 后的标准规定上。那么首先什么是“非平凡”
平凡类型指的是标量类型如 int 和 std::nullptr_t 等和平凡类类型以及前两种类型组成的数组类型。
平凡类类型必须满足
它是一个可平凡复制类也就是可以被以 memcpy 这种方式复制有一个以上的合格的平凡默认构造函数并且这些构造函数必须什么都不干即由编译器提供。
这里的定义很复杂一般也懒得看要求也很苛刻但不满足约束条件的结果只有一个该 union 的六大特殊成员函数3都会被默认弃置即编译器不会帮你自动生成这也就是前文中会爆出编译错误的原因因为编译器压根找不到要用的函数在哪。 从这里可以看出union 在语法功能上与 struct 和 class 极其类似它们都有析构函数也有构造函数也都可以有自己的成员函数甚至每个成员分量都可以有自己的访问控制权限。 通常来说一个这样的 union 在编译时会这样的编译错误
#include stringunion U {int integer;double floating;std::string str;
};int main()
{U uni;
} // error: use of deleted function U::~U()但如果为这个 union 类型添加一个什么都不做的析构函数和默认构造函数就一切都正常了。
#include stringunion U {int integer;double floating;std::string str;U() {}~U() {}
};int main()
{U uni;
} // everything ok你以为这么简单就结束了吗当然没有。不妨再细想一下当活跃成员是一个 std::string 时如果需要将活跃成员切换为另一个分量时我们是安全的吗
3.正确使用 union
这里有个前提由于编译器无从得知一个 union 的当前活跃成员是谁因此自然而然的union 内的对象的析构函数永远不会自动被执行。 因为 union 可以被作为参数在不同函数调用栈间传递与修改因此通过追踪代码流走向进而查出一个 union 的当前活跃成员绝对是一件不可能的事情。 这就导致了当 union 的活跃成员从一个非平凡成员上切走时我们必须主动调用该成员的析构函数如果不这样做答案自然是内存泄漏因为这种操作打破了 RAII 保证。
而当我们将 union 切换到另一个非平凡成员分量时在除了创建该 union 以外的情景下都必须使用 placement new 的方式在指定地址调用构造函数。 必须使用 placement new 是因为在 cpp 标准定义中任何对象在被声明后都一定被构造完毕可能是通过默认无参构造也可能是通过参数构造总之该对象所处的内存区域的数据必然有效且良定义 而 union 本身只能被视作是一块存有无序数据的内存因此位于其上的对象是完全不存在的这样的对象可能处于任何状态此时如果试图调用移动构造函数覆盖原有数据自然也是不符合标准的。 这就导致了正确使用 union 的代码极其割裂和丑陋。
#include string
#include iostreamtemplatetypename 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()
{UnionLikeint, std::string result( Unknown ); // 创建时不需要 placement newstatic_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 作为代餐。 至于 std::variant 是如何实现的就是一个相当复杂的问题了我也不想知道感兴趣的可以打开自己的 STL 头文件慢慢看反正 cpp 模板库都是开源的逃。 #include variant
#include iostreamint main()
{std::variantint, 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::getstd::string( result ) std::endl;result 42; // 使用赋值运算符直接切换活跃成员不需要手动析构成员// 也可以使用访问器std::visit( []( auto arg ) { std::cout arg std::endl; }, result );
}不过如果能确保使用 union 时都是一些非常底层的场景从头到尾都在干一些脏活而不会向 union 中填入非平凡类型的话大胆使用 union 就好了毕竟即使是“零抽象开销”的标准库也不是真的完全是毫无开销的。 与之相对的元组或者是 c/cpp 的结构体是一种“积类型”也就是可以在不同空间、同一时间存入不同数据。 ↩︎ 更多时候像是一种毫无约束的自由而放纵的自由就意味着混乱。 ↩︎ 分别是默认构造函数、复制构造函数、移动构造函数、复制赋值运算符、移动赋值运算符和析构函数。 ↩︎