union 的正确食用方法

news/2024/9/18 5:46:39/ 标签: c++, 算法, 开发语言, 数据结构

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 <cstring>union 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 标准:

  1. 在 cpp11 以前,带有非平凡的构造函数和析构函数的非静态成员(下称非平凡成员)不能被放置在 union 中;
  2. 在 cpp11 之后,非平凡成员可以被填入 union 中,但这个 union 自己的复制、移动、默认构造函数以及复制赋值、移动赋值运算符和析构函数都不会被编译器默认提供,并且由用户提供的默认构造函数中只允许一个成员使用默认成员初始化器(就是构造函数里的那个冒号)。

这都 4202 年了,cpp11 之前的事情我们不管,现在只需要把焦点聚焦在 cpp11 后的标准规定上。那么首先,什么是“非平凡”?

平凡类型指的是标量类型(如 intstd::nullptr_t 等)和平凡类类型,以及前两种类型组成的数组类型。

平凡类类型必须满足:

  1. 它是一个可平凡复制类(也就是可以被以 memcpy 这种方式复制);
  2. 有一个以上的合格的平凡默认构造函数,并且这些构造函数必须什么都不干(即由编译器提供)。

这里的定义很复杂(一般也懒得看),要求也很苛刻,但不满足约束条件的结果只有一个:该 union 的六大特殊成员函数3都会被默认弃置(即编译器不会帮你自动生成);这也就是前文中会爆出编译错误的原因(因为编译器压根找不到要用的函数在哪)。

从这里可以看出,union 在语法功能上与 structclass 极其类似:它们都有析构函数,也有构造函数,也都可以有自己的成员函数;甚至每个成员分量都可以有自己的访问控制权限。

通常来说,一个这样的 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 时,如果需要将活跃成员切换为另一个分量时,我们是安全的吗?

3.正确使用 union

这里有个前提:由于编译器无从得知一个 union 的当前活跃成员是谁,因此自然而然的,union 内的对象的析构函数永远不会自动被执行。

因为 union 可以被作为参数在不同函数调用栈间传递与修改,因此通过追踪代码流走向,进而查出一个 union 的当前活跃成员绝对是一件不可能的事情。

这就导致了,当 union 的活跃成员从一个非平凡成员上切走时,我们必须主动调用该成员的析构函数;如果不这样做,答案自然是内存泄漏(因为这种操作打破了 RAII 保证)。

而当我们将 union 切换到另一个非平凡成员分量时,在除了创建该 union 以外的情景下,都必须使用 placement new 的方式在指定地址调用构造函数。

必须使用 placement new 是因为:在 cpp 标准定义中,任何对象在被声明后都一定被构造完毕(可能是通过默认无参构造,也可能是通过参数构造),总之该对象所处的内存区域的数据必然有效且良定义;

union 本身只能被视作是一块存有无序数据的内存,因此位于其上的对象是完全不存在的,这样的对象可能处于任何状态;此时如果试图调用移动构造函数覆盖原有数据自然也是不符合标准的。

这就导致了正确使用 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 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 <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 就好了,毕竟即使是“零抽象开销”的标准库也不是真的完全是毫无开销的。


  1. 与之相对的,元组(或者是 c/cpp 的结构体)是一种“积类型”,也就是可以在不同空间、同一时间存入不同数据。 ↩︎

  2. 更多时候像是一种毫无约束的自由;而放纵的自由就意味着混乱。 ↩︎

  3. 分别是:默认构造函数、复制构造函数、移动构造函数、复制赋值运算符、移动赋值运算符和析构函数。 ↩︎


http://www.ppmy.cn/news/1520749.html

相关文章

SQL典型练习题

with可以解决很多想用子表解决的问题 over可以加想加的&#xff0c;改变表的结构 例题&#xff1a; 表(driver)说明&#xff1a;司机登录登出明细表&#xff0c;由于同一司机有可能同时登录两个司机端&#xff0c;所以同一时间段一个司机有可能会产生两条或者更多条数据。 …

九、安装artifactory并配置PostgreSQL--失败了

八、centos7安装mysql5.7-CSDN博客 基于八&#xff0c;克隆出一个新的虚拟机&#xff0c;用来安装制品库并配置mysql数据库。 比较官方的文章&#xff1a; How to install JFrog Artifactory on CentOS 7 | CentLinux 仅参考&#xff0c;未使用&#xff08;配置的centos自带…

网络压缩之参数量化(parameter quantization)

参数量化&#xff08;parameter quantization&#xff09;。参数量化是说能否只 用比较少的空间来储存一个参数。举个例子&#xff0c;现在存一个参数的时候可能是用64位或32位。 可能不需要这么高的精度&#xff0c;用16或8位就够了。所以参数量化最简单的做法就是&#xff0c…

什么是云计算?

1.云计算的概念&#xff1f; 现阶段广为人们所接受的是美国国家标准与技术研究院&#xff08;National Institute of Standards and Technology&#xff0c;NIST&#xff09;给出的定义&#xff1a;“云计算”是一种按使用量付费的模式&#xff0c;这种模式提供可用的、便捷的、…

Lua:条件断点

如果有很多方式都要经过这个函数&#xff0c;但是你只需要满足其中例如参数等于Test的这一种&#xff0c;可以在断点处右键点击编辑断点打上条件断点&#xff0c;只有参数EventName等于Test的才会断上。

《JavaEE进阶》----4.<SpringMVC①简介、基本操作(各种postman请求)>

本篇博客讲解 MVC思想、及Spring MVC&#xff08;是对MVC思想的一种实现&#xff09;。 Spring MVC的基本操作、学习了六个注解 RestController注解 RequestMappering注解 RequestParam注解 RequestBody注解 PathVariable注解 RequestPart注解 MVC View(视图) 指在应⽤程序中…

★ 算法OJ题 ★ 力扣 LCR179 - 和为 s 的两个数字

Ciallo&#xff5e;(∠・ω< )⌒☆ ~ 今天&#xff0c;小诗歌剧将和大家一起做一道双指针算法题--和为 s 的两个数字~ 目录 一 题目 二 算法解析 三 编写算法 一 题目 LCR 179. 查找总价格为目标值的两个商品 - 力扣&#xff08;LeetCode&#xff09; 二 算法解析 …

NCH DrawPad Pro for Mac/Win:强大的图像编辑处理软件

NCH DrawPad Pro for Mac/Win是一款功能全面的图像编辑和设计软件&#xff0c;专为Mac和Windows用户设计。它不仅适用于专业设计师&#xff0c;也深受业余爱好者和创意工作者的喜爱。DrawPad Pro凭借其丰富的绘图工具、强大的编辑功能和便捷的模板库&#xff0c;为用户提供了卓…

集成电路学习:什么是LCD液晶显示器

一、LCD&#xff1a;液晶显示器 LCD&#xff0c;全称Liquid Crystal Display&#xff0c;即液晶显示器&#xff0c;是一种平面超薄的显示设备。它由一定数量的彩色或黑白像素组成&#xff0c;放置于光源或者反射面前方。LCD的主要原理是以电流刺激液晶分子产生点、线、面配合背…

五,Spring Boot中的 Spring initializr 的使用

五&#xff0c;Spring Boot中的 Spring initializr 的使用 文章目录 五&#xff0c;Spring Boot中的 Spring initializr 的使用1. 方式1&#xff1a;IDEA创建2. 方式2&#xff1a;start.spring.io 创建3. 注意事项和细节4. 最后&#xff1a; 需要&#xff1a;使用 Spring initi…

ReentrantLock可重入锁又是怎么回事?

前言&#xff1a;有关Synchronized锁的知识可以参考我上篇写的内容synchronized必知必会的知识点 一&#xff1a;ReentrantLock的实现原理 锁的实现原理基本是为了达到一个目的:让所有的线程都能看到某种标记。Synchronized通过在对象头中设置标记实现了这一目的&#xff0c;是…

MFC工控项目实例之十添加系统测试对话框

承接专栏《MFC工控项目实例之九选择下拉菜单主界面文本框显示菜单名》 参考前期我的博客文章《MFC3d立体按钮制作》 这里只给出相关代码 1、在SysTest.h文件中添加代码 #include "ShadeButtonST.h" #include "BtnST.h" class CSysTest : public CDialog {…

H5手机端调起支付宝app支付

1.调起APP页面如下 步骤 1.让后端对接一下以下文档&#xff08;手机网站支付通过alipays协议唤起支付宝APP&#xff09; https://opendocs.alipay.com/open/203/107091?pathHash45006f4f&refapi 2.后端接口会返回一个form提交表单 html&#xff1a;在页面中定义一个d…

C++学习笔记(3)

101、从结构体到类 对面向对象编程来说&#xff0c;一切都是对象&#xff0c;对象用类来描述。 类把对象的数据和操作数据的方法作为一个整体考虑。 定义类的语法&#xff1a; class 类名 { public: 成员一的数据类型 成员名一; 成员二的数据类型 成员名二; 成员三的数据类型 成…

安防监控视频平台LntonAIServer视频智能分析平台新增视频质量诊断功能

随着安防行业的快速发展&#xff0c;视频监控系统已经成为维护公共安全和个人隐私的重要工具。然而&#xff0c;由于各种因素的影响&#xff0c;视频流的质量可能会受到影响&#xff0c;从而导致监控效果不佳。为了解决这一问题&#xff0c;LntonAIServer推出了全新的视频质量诊…

「邀您参会」9月20日 中国可观测日成都站

随着首届中国可观测日上海站的圆满落幕&#xff0c;中国站第二站将于 9 月 20 日在成都盛大开启。在此&#xff0c;我们诚挚邀请您参与这场专注于监控观测领域的技术交流盛会&#xff0c;与行业精英共同探讨可观测性技术的前沿趋势和实践应用。 活动亮点 1、技术交流盛宴&…

什么是rest参数?

Rest参数是JavaScript中的一种特殊参数类型&#xff0c;也称为剩余参数或可变参数&#xff0c;它允许开发者定义一个函数&#xff0c;以便接收不定数量的参数。Rest参数的使用是通过在参数列表末尾添加...符号来实现的&#xff0c;这些额外的参数会被收集到一个数组中&#xff…

Docker 容器编排之 Docker Compose

目录 1 Docker Compose 概述 1.1 主要功能 1.2 工作原理 1.3 Docker Compose 中的管理层 2 Docker Compose 的常用命令参数 2.1 服务管理 2.1.1 docker-compose up &#xff1a; 2.1.2 docker-compose down &#xff1a; 2.1.3 docker-compose start &#xff1a; 2.1.4 docker…

【Qt的TS文件转换器】利用Python实现自动化TS文件转换

TS 文件转换器 在开发多语言Qt应用时&#xff0c;管理和更新翻译文件是一项繁琐但必要的任务。这个工具旨在自动化Qt Linguist TS文件的转换过程&#xff0c;支持不同语言之间的转换&#xff0c;特别关注中文变体和其他语言。 目录 &#x1f30e;背景⭐特性&#x1f512;前提条…

go常用代码

连接阿波罗&#xff1a; 默认properties类型 package mainimport ("fmt""github.com/apolloconfig/agollo/v4""github.com/apolloconfig/agollo/v4/env/config" )func main() {c : &config.AppConfig{AppID: "2222",Cl…