union 的正确食用方法
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 标准:
- 在 cpp11 以前,带有非平凡的构造函数和析构函数的非静态成员(下称非平凡成员)不能被放置在
union
中; - 在 cpp11 之后,非平凡成员可以被填入
union
中,但这个union
自己的复制、移动、默认构造函数以及复制赋值、移动赋值运算符和析构函数都不会被编译器默认提供,并且由用户提供的默认构造函数中只允许一个成员使用默认成员初始化器(就是构造函数里的那个冒号)。
这都 4202 年了,cpp11 之前的事情我们不管,现在只需要把焦点聚焦在 cpp11 后的标准规定上。那么首先,什么是“非平凡”?
平凡类型指的是标量类型(如 int
和 std::nullptr_t
等)和平凡类类型,以及前两种类型组成的数组类型。
平凡类类型必须满足:
- 它是一个可平凡复制类(也就是可以被以
memcpy
这种方式复制); - 有一个以上的合格的平凡默认构造函数,并且这些构造函数必须什么都不干(即由编译器提供)。
这里的定义很复杂(一般也懒得看),要求也很苛刻,但不满足约束条件的结果只有一个:该 union
的六大特殊成员函数3都会被默认弃置(即编译器不会帮你自动生成);这也就是前文中会爆出编译错误的原因(因为编译器压根找不到要用的函数在哪)。
从这里可以看出,
union
在语法功能上与struct
和class
极其类似:它们都有析构函数,也有构造函数,也都可以有自己的成员函数;甚至每个成员分量都可以有自己的访问控制权限。
通常来说,一个这样的 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
就好了,毕竟即使是“零抽象开销”的标准库也不是真的完全是毫无开销的。
与之相对的,元组(或者是 c/cpp 的结构体)是一种“积类型”,也就是可以在不同空间、同一时间存入不同数据。 ↩︎
更多时候像是一种毫无约束的自由;而放纵的自由就意味着混乱。 ↩︎
分别是:默认构造函数、复制构造函数、移动构造函数、复制赋值运算符、移动赋值运算符和析构函数。 ↩︎
相关文章:
union 的正确食用方法
0.前情提要 (很久)之前上编译原理时,一次实验课需要补充完善一个用 c 写的词法分析器;而这个分析器在定义语法树结点时使用了 union 存储语言中不同表达式的类型标签或值本身。因为当时刚好学完了 cpp,拿着锤子看啥都…...

汇编语言在虚拟机中输出“Hello World!”
1.软件 Nasmide64.exe(李忠老师编写) Fixvhdw64.exe(李忠老师编写) VirtualBox虚拟机(免费 开源) 2.过程 01.Fixvhdw64.exe输入以下代码: mov ax,0xb800 mov ds,ax mov byte [0x00],H mov byte [0x02],e mov byte [0x04],l mov byte [0x06],l mov byte [0x08],o mov byte…...

JVM类的加载和类的加载器
JVM类的加载和类的加载器 一.类的加载过程 类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。类的加载的最终产品是位于…...

MLM:多模态大型语言模型的简介、微调方法、发展历史及其代表性模型、案例应用之详细攻略
MLM:多模态大型语言模型的简介、微调方法、发展历史及其代表性模型、案例应用之详细攻略 目录 相关文章 AI之MLM:《MM-LLMs: Recent Advances in MultiModal Large Language Models多模态大语言模型的最新进展》翻译与解读 MLM之CLIP:CLIP…...

Java健康养老智慧相伴养老护理小程序系统源码代办陪诊陪护更安心
健康养老,智慧相伴 —— 养老护理小程序,代办陪诊陪护更安心 🌈【开篇:智慧养老,新时代的温馨守护】🌈 在这个快节奏的时代,我们总希望能给予家人更多的关爱与陪伴,尤其是家中的长…...

Python | Leetcode Python题解之第390题消除游戏
题目: 题解: class Solution:def lastRemaining(self, n: int) -> int:a1 1k, cnt, step 0, n, 1while cnt > 1:if k % 2 0: # 正向a1 stepelse: # 反向if cnt % 2:a1 stepk 1cnt >> 1step << 1return a1...

Github 2024-09-01 开源项目月报 Top16
根据Github Trendings的统计,本月(2024-09-01统计)共有16个项目上榜。根据开发语言中项目的数量,汇总情况如下: 开发语言项目数量Python项目9TypeScript项目5Dart项目2C项目1Jupyter Notebook项目1Rust项目1开发者职业成长指南 创建周期:2670 天开发语言:TypeScript协议类…...

C++ 继承(二)
目录 1. 实现一个不能被继承的类 2. 友元与继承 3.继承与静态成员 4.多继承及其菱形继承问题 (1). 继承模型 (2). 虚继承 (2.1)虚继承解决数据冗余和二义性的原理 (3). 多继承中指针偏移问题 (4). IO库中的菱形虚拟继承 5. 继承和组合 1. 实现一个不能被继承的类 方法1…...

第 2 章:AJAX 的使用
AJAX 的使用 核心对象:XMLHttpRequest,AJAX 的所有操作都是通过该对象进行的。 1. 使用步骤 创建 XMLHttpRequest 对象 var xhr new XMLHttpRequest(); 设置请求信息 xhr.open(method, url);//可以设置请求头,一般不设置 xhr.setReques…...

ROS——视觉抓取
纲要 视觉抓取中的关键技术 内参标定 物体识别定位 抓取姿态分析 运动规划 外参标定 任意两个位姿之间的关系 眼在外 眼在内 手眼标定流程 robot 部分 标定效果 视觉抓取例程 grasping_demo.cpp 获取两个坐标系之间变换关系:waitForTransform 、 LookupTransform 求相…...

EPLAN2022基础教程
EPLAN2022软件介绍 EPLAN是一款专业的电气设计和绘图软件,它可以帮助我创建和管理电气项目,生成各种报表和文档,与其他软件和系统进行交互,优化工程流程和质量。与传统的CAD绘图对比,EPLAN更适合绘制电气原理图。 下…...
【JavaWeb】Servlet 详解(处理逻辑及常见方法)
文章目录 1. Tomcat1.1 常见的错误1.1.1 出现 4041.1.2 出现 4051.1.3 出现 500 1.2 HttpServlet1.2.1 Tomcat 的处理逻辑1.2.2 相关方法 1.3 HttpServletRequest1.3.1 常见方法1.3.2 jackson 处理逻辑 1.4 HttpServletResponse1.4.1 常见方法 1. Tomcat tomcat 是一个 HTTP 服…...
6 自研rgbd相机基于rk3566之深度计算库程序详解
自研rgbd相机基于rk3566之深度计算库详解 1 tof深度计算库框架读入深度图像参数配置tof模组标定参数读入及解析深度计算函数接口2 tof深度计算库程序详解深度计算程序头文件深度计算程序 源文件1 tof深度计算库框架 读入深度图像参数配置 支持raw8/raw10/raw16 格式 /*******…...
分布式系统框架hadoop3入门
分布式系统框架hadoop3入门 (qq.com) Hadoop3作为分布式系统架构的重要基石,为大规模数据存储与处理提供了强大支持 基本信息 hadoop:一个存储和处理大数据的分布式系统框架 组成: HDFS(数据存储)、MapReduce&…...
使用 i3.LayoutCell() 方法绘制版图并输出为 GDS 文件
使用 i3.LayoutCell 方法绘制版图并输出为 GDS 文件 引言正文引言 在 IPKISS i3.SRef() 函数 一文中我们介绍了如何使用 i3.SRef() 函数将 instance 对象添加到 i3.LayoutCell() 创建的 Cell 对象上。但是当我们使用 write_gdsii() 输出版图时代码就会报错。这里我们将介绍如何…...
mariadb容器
下载镜像 $ sudo docker pull mariadb启动容器 $ sudo docker run --name my-mariadb -d -e MARIADB_DATABASEtestdb -e MARIADB_ROOT_PASSWORDLetmein -p 3306:3306 mariadb上面命令会启动一个名为my-mariadb的容器,并初始化一个testdb数据库,同时设置…...

应用层协议Http
Http协议 1.1 什么是http协议 在进行网络通信时,应用层协议一般都是程序员自己写的,但是有一些大佬其实已经定义出了一些现成的应用层协议,例如:HTTP(超文本传输协议)、FTP(文件传输协议&#…...
display flex 的div 被子元素撑开不显示滚动条的一个解决demo
display flex 的div 被子元素撑开,不显示y轴滚动条的 一个解决demo。 注: 不一定适用所有人的的相同问题 less # less .contact {display: flex;flex-grow: 1;overflow: hidden auto;flex-direction: column;.contact-items {flex: 1 1 0;display: flex…...

判断键盘输入是数字、大写字母还是小写字母——C#学习笔记
以下代码将判断键盘输入字符是数字 还是字母: using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks;namespace ConsoleApp4 {using System;using System.Threading;public class BoolKeyTest{sta…...
进程控制块PCB的组织方式有哪些?
进程控制块(PCB,Process Control Block)是操作系统用来管理和跟踪进程的一个数据结构,它保存了与进程相关的各种信息。PCB 是操作系统调度进程的核心数据结构,通常通过某种组织方式进行管理。常见的 PCB 组织方式主要有…...

linux之kylin系统nginx的安装
一、nginx的作用 1.可做高性能的web服务器 直接处理静态资源(HTML/CSS/图片等),响应速度远超传统服务器类似apache支持高并发连接 2.反向代理服务器 隐藏后端服务器IP地址,提高安全性 3.负载均衡服务器 支持多种策略分发流量…...

边缘计算医疗风险自查APP开发方案
核心目标:在便携设备(智能手表/家用检测仪)部署轻量化疾病预测模型,实现低延迟、隐私安全的实时健康风险评估。 一、技术架构设计 #mermaid-svg-iuNaeeLK2YoFKfao {font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-svg…...
连锁超市冷库节能解决方案:如何实现超市降本增效
在连锁超市冷库运营中,高能耗、设备损耗快、人工管理低效等问题长期困扰企业。御控冷库节能解决方案通过智能控制化霜、按需化霜、实时监控、故障诊断、自动预警、远程控制开关六大核心技术,实现年省电费15%-60%,且不改动原有装备、安装快捷、…...
系统设计 --- MongoDB亿级数据查询优化策略
系统设计 --- MongoDB亿级数据查询分表策略 背景Solution --- 分表 背景 使用audit log实现Audi Trail功能 Audit Trail范围: 六个月数据量: 每秒5-7条audi log,共计7千万 – 1亿条数据需要实现全文检索按照时间倒序因为license问题,不能使用ELK只能使用…...
C++八股 —— 单例模式
文章目录 1. 基本概念2. 设计要点3. 实现方式4. 详解懒汉模式 1. 基本概念 线程安全(Thread Safety) 线程安全是指在多线程环境下,某个函数、类或代码片段能够被多个线程同时调用时,仍能保证数据的一致性和逻辑的正确性…...

【Oracle】分区表
个人主页:Guiat 归属专栏:Oracle 文章目录 1. 分区表基础概述1.1 分区表的概念与优势1.2 分区类型概览1.3 分区表的工作原理 2. 范围分区 (RANGE Partitioning)2.1 基础范围分区2.1.1 按日期范围分区2.1.2 按数值范围分区 2.2 间隔分区 (INTERVAL Partit…...

如何在网页里填写 PDF 表格?
有时候,你可能希望用户能在你的网站上填写 PDF 表单。然而,这件事并不简单,因为 PDF 并不是一种原生的网页格式。虽然浏览器可以显示 PDF 文件,但原生并不支持编辑或填写它们。更糟的是,如果你想收集表单数据ÿ…...

Unsafe Fileupload篇补充-木马的详细教程与木马分享(中国蚁剑方式)
在之前的皮卡丘靶场第九期Unsafe Fileupload篇中我们学习了木马的原理并且学了一个简单的木马文件 本期内容是为了更好的为大家解释木马(服务器方面的)的原理,连接,以及各种木马及连接工具的分享 文件木马:https://w…...
uniapp 字符包含的相关方法
在uniapp中,如果你想检查一个字符串是否包含另一个子字符串,你可以使用JavaScript中的includes()方法或者indexOf()方法。这两种方法都可以达到目的,但它们在处理方式和返回值上有所不同。 使用includes()方法 includes()方法用于判断一个字…...
Git常用命令完全指南:从入门到精通
Git常用命令完全指南:从入门到精通 一、基础配置命令 1. 用户信息配置 # 设置全局用户名 git config --global user.name "你的名字"# 设置全局邮箱 git config --global user.email "你的邮箱example.com"# 查看所有配置 git config --list…...