需要预先了解:


1. 联合(Union)

联合(union) 是一种特殊的数据类型,它允许在同一块内存空间中存储不同类型的数据,但同一时刻只能有一个成员是有效的。定义语法与 struct 类似,只是把关键字换为 union:

union Data
{
    int    i;
    double d;
    char   c;
};

使用时像结构体一样访问成员,但所有成员共享同一块内存:

#include <iostream>

int main()
{
    Data u;
    u.i = 42;                       // 写入 int
    std::cout << u.i << std::endl;  // 输出: 42

    u.d = 3.14;                     // 写入 double——覆盖了原来的 int
    std::cout << u.d << std::endl;  // 输出: 3.14
    std::cout << u.i << std::endl;  // 输出: 垃圾值(被 double 的字节覆盖了)

    return 0;
}

当 u.d = 3.14 执行后,之前写入的 u.i 就被覆盖了。再读 u.i 拿到的是 3.14 的 IEEE 754 二进制表示被重新解释为 int 的垃圾值。


1.1 内存布局

union 的内存布局只有一条核心规则:所有成员从同一地址(offset 0)开始,union 的大小等于其最大成员的大小(再按对齐要求补齐)。

                        union Data (大小为 8 字节,即 sizeof(double))
         ┌───────────────────────────────────────────────────────────────────────┐
         │                                                                       │
offset:  0        1        2        3        4        5        6        7        │
         ├────────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┤
         │◄─ int i (4字节,从 offset 0 开始) ─►│                                   │
         ├───────────────────────────────────────────────────────────────────────┤
         │◄────────────────── double d (8 字节,从 offset 0 开始) ────────────────►│
         ├────────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┤
         │ char c │                                                              │
         └────────┴────────┴────────┴────────┴────────┴────────┴────────┴────────┘

验证代码:

#include <iostream>

union Data
{
    int    i;
    double d;
    char   c;
};

int main()
{
    Data u;
    std::cout << "sizeof(Data)   = " << sizeof(Data) << std::endl;    // 8(通常)
    std::cout << "sizeof(u.i)    = " << sizeof(u.i) << std::endl;     // 4
    std::cout << "sizeof(u.d)    = " << sizeof(u.d) << std::endl;     // 8
    std::cout << "sizeof(u.c)    = " << sizeof(u.c) << std::endl;     // 1

    // 所有成员的地址相同
    std::cout << "&u    = " << &u << std::endl;
    std::cout << "&u.i  = " << (void*)&u.i << std::endl;
    std::cout << "&u.d  = " << (void*)&u.d << std::endl;
    std::cout << "&u.c  = " << (void*)&u.c << std::endl;

    return 0;
}

编译并运行后,四个地址完全相同——它们指向同一块内存的同一个起点。


1.2 union 的对齐规则

union 的对齐要求等于所有成员中对齐要求最严格的那一个的值,大小也需要是该对齐值的整数倍。

#include <iostream>

union Example
{
    char   c;      // 对齐 1,大小 1
    int    i;      // 对齐 4,大小 4
    double d;      // 对齐 8,大小 8
};

int main()
{
    std::cout << "sizeof(Example) = " << sizeof(Example) << std::endl;   // 8
    std::cout << "alignof(Example) = " << alignof(Example) << std::endl; // 8
    return 0;
}

double 对齐要求为 8,所以 alignof(Example) = 8;sizeof(Example) 取最大成员大小 8,正好是对齐值 8 的整数倍,所以不需要额外填充。

再看一个填充的例子:

#include <iostream>

union Padding
{
    char   arr[5];   // 大小 5,对齐 1
    int    i;        // 大小 4,对齐 4
};

int main()
{
    std::cout << "sizeof(Padding) = " << sizeof(Padding) << std::endl;   // 8
    std::cout << "alignof(Padding) = " << alignof(Padding) << std::endl; // 4
    return 0;
}

最大成员 arr 占 5 字节,但 int 要求对齐 4,所以 union 大小必须对齐到 4 的边界——结果 5 → 8(补齐 3 个字节的尾部填充)。


1.3 匿名 union

匿名 union 不声明类型名,其成员直接作为外层作用域的变量来访问:

#include <iostream>

struct Message
{
    int type;
    // 匿名 union——成员直接视为 Message 的成员
    union
    {
        int   int_val;
        float float_val;
    };
};

int main()
{
    Message msg;
    msg.type   = 1;
    msg.int_val = 42;            // 直接访问,不需要 msg.u.int_val

    std::cout << msg.int_val << std::endl;   // 输出: 42
    return 0;
}

匿名 union 常用于 struct/class 内部,减少一层命名嵌套。


1.4 union 的应用场景

1. 带标签的联合(Tagged Union)

由于 union 不记录当前活跃成员是谁,通常搭配一个 标签(tag,通常用 enum) 来跟踪:

#include <iostream>
#include <string>
#include <cstring>

enum class DataType { INT, FLOAT, STRING };

union DataValue
{
    int   i;
    float f;
    char  s[32];         // 固定大小的字符数组
};

struct TaggedData
{
    DataType  type;
    DataValue value;
};

void print(const TaggedData& data)
{
    switch (data.type)
    {
    case DataType::INT:
        std::cout << "int: " << data.value.i << std::endl;
        break;
    case DataType::FLOAT:
        std::cout << "float: " << data.value.f << std::endl;
        break;
    case DataType::STRING:
        std::cout << "string: " << data.value.s << std::endl;
        break;
    }
}

int main()
{
    TaggedData d1;
    d1.type   = DataType::INT;
    d1.value.i = 100;
    print(d1);     // 输出: int: 100

    TaggedData d2;
    d2.type = DataType::STRING;
    std::strcpy(d2.value.s, "Hello");
    print(d2);     // 输出: string: Hello

    return 0;
}

这是 C 语言中实现"多类型变量"的经典模式,也是 std::variant 的设计前身。

2. 类型双关(Type Punning)

利用 union 用不同的类型去解读同一块内存,可以在不借助指针强转的情况下实现类型双关:

#include <iostream>
#include <cstdint>

union FloatBits
{
    float    f;
    uint32_t bits;
};

int main()
{
    FloatBits fb;
    fb.f = 3.14159f;

    // 用整数视角逐个检查 float 的 IEEE 754 二进制表示
    std::cout << std::hex << "0x" << fb.bits << std::endl;

    return 0;
}

注意:在 C++ 中,读取 union 中非当前活跃成员的行为是未定义行为(Undefined Behavior,UB)——尽管在 C 语言中这是合法的,而且几乎所有 C++ 编译器也"实际允许"这样做。如果你需要可靠地重新解释字节,应使用 std::memcpy 或 C++20 的 std::bit_cast。

// C++20:安全的类型双关
float f = 3.14159f;
uint32_t bits = std::bit_cast<uint32_t>(f);

1.5 union 的缺点

1. 完全没有类型安全

编译器不知道——也不会帮你检查——当前哪个成员是活跃的。读错成员就是 UB,所有责任都在程序员身上:

union U { int i; double d; };
U u;
u.d = 3.14;
int x = u.i;       // 未定义行为!当前活跃的是 d,却读了 i

2. 不能包含"非平凡"类型的成员

在 C++ 中,如果一个类型有自定义构造函数、析构函数、拷贝/移动操作(即非平凡特殊成员函数),它不能直接作为 union 的成员。因为编译器不知道该在什么时候调用谁的构造和析构。

#include <string>

union Bad
{
    int         i;
    std::string s;   // 编译报错!std::string 有非平凡的构造/析构/拷贝
};

将得到类似下面的报错:

main.cpp:12:9: error: use of deleted function 'Bad::Bad()'
   12 |     Bad b;
      |         ^
main.cpp:4:7: note: 'Bad::Bad()' is implicitly deleted because the default definition would be ill-formed:
    4 | union Bad
      |       ^~~

C++11 起允许 union 包含非平凡类型,但你必须手动管理生命周期——通过 placement new 构造、显式调用析构函数销毁:

#include <iostream>
#include <string>
#include <new>

union ManualString
{
    int         i;
    std::string s;

    ManualString() : i(0) {}     // 默认激活 int
    ~ManualString() {}            // 析构函数留空——手动管理!
};

int main()
{
    ManualString ms;

    // 激活 string 成员——使用 placement new
    new (&ms.s) std::string("Hello, Union!");

    std::cout << ms.s << std::endl;   // 输出: Hello, Union!

    // 用完必须手动析构!
    ms.s.~basic_string();

    return 0;
}

一旦忘记手动析构,std::string 内部在堆上分配的字符串缓冲区就会泄漏。这种写法容易出错,极少被推荐。

3. 缺乏工程安全性

  • 无默认的构造/析构:编译器删除默认特殊成员函数,必须完全手写。
  • 无活跃成员查询:运行时完全无法获知哪个成员是活跃的。
  • 异常不安全:如果在构造新成员时抛出异常,union 的状态被破坏,不清楚谁在活跃。
  • 维护困难:每新增一种成员类型,所有 switch/if-else 分支都需要同步修改,容易遗漏。

简单总结:union 是 C 语言遗产,在 C++ 中仍有用——主要用于内存受限场景和底层字节操作。但在业务逻辑中使用 union,几乎等同于放弃了 C++ 的类型系统对你的保护。


2. std::variant

std::variant 是 C++17 引入的类型安全的联合体,定义在头文件 <variant>。它解决了 union 的所有核心缺陷:自动管理生命周期、编译期类型检查、运行时查询活跃成员。

#include <variant>
#include <string>
#include <iostream>

int main()
{
    // 定义一个 variant,可以存储 int 或 double 或 std::string
    std::variant<int, double, std::string> v;

    v = 42;                             // 存入 int
    v = 3.14;                           // 替换为 double
    v = std::string("Hello, Variant!"); // 替换为 std::string

    return 0;
}

std::variant<int, double, std::string> 表示"这个变量可以是 int、double 或 std::string 中的任意一种"。每次赋值都会:

  1. 销毁旧的值(调用对应类型的析构函数)
  2. 在新位置构造新的值(调用对应类型的构造函数)

2.1 std::variant 的内存布局

std::variant 内部需要存放最坏情况下最大的那个类型的存储空间,再加上一个**判别器(discriminator)**来记录当前活跃的是第几个类型。

#include <variant>
#include <iostream>
#include <string>

int main()
{
    using VarType = std::variant<int, double, std::string>;

    std::cout << "sizeof(int)          = " << sizeof(int) << std::endl;           // 4
    std::cout << "sizeof(double)       = " << sizeof(double) << std::endl;        // 8
    std::cout << "sizeof(std::string)  = " << sizeof(std::string) << std::endl;   // 32

    std::cout << "sizeof(VarType)      = " << sizeof(VarType) << std::endl;

    return 0;
}

内存开销总结:std::variant<T...> 的大小 ≈ max(sizeof(T)...) + 判别器大小。判断器通常占 1~8 字节,取决于类型的个数和平台的对齐规则。这意味std::variant 的内存开销接近于手动 tagged union,但多了类型安全和自动生命周期管理。


2.2 访问 std::variant 的值

std::get<类型/索引>:按类型或索引取值

#include <variant>
#include <iostream>

int main()
{
    std::variant<int, double, std::string> v;

    v = 3.14;

    // 按类型访问
    double d = std::get<double>(v);
    std::cout << d << std::endl;             // 输出: 3.14

    // 按索引访问——0=int, 1=double, 2=std::string
    double d2 = std::get<1>(v);
    std::cout << d2 << std::endl;            // 输出: 3.14

    return 0;
}

如果取错了类型,std::get 会抛出 std::bad_variant_access 异常:

#include <variant>
#include <iostream>

int main()
{
    std::variant<int, double> v;
    v = 42;                              // 当前活跃的是 int

    try
    {
        double d = std::get<double>(v);  // 错误:活跃的是 int
    }
    catch (const std::bad_variant_access& e)
    {
        std::cout << "异常: " << e.what() << std::endl;
    }

    return 0;
}

编译并运行后输出:

异常: bad variant access

std::get_if:安全地"尝试取值"

std::get_if<T>(&v) 返回指针而不是引用:如果当前活跃类型是 T,返回指向该值的指针;否则返回 nullptr。

#include <variant>
#include <iostream>

int main()
{
    std::variant<int, double, std::string> v;

    v = 42;

    // 尝试取 int——成功
    if (int* p = std::get_if<int>(&v))
        std::cout << "int: " << *p << std::endl;     // 输出: int: 42
    else
        std::cout << "不是 int" << std::endl;

    // 尝试取 double——失败
    if (double* p = std::get_if<double>(&v))
        std::cout << "double: " << *p << std::endl;
    else
        std::cout << "不是 double" << std::endl;      // 输出: 不是 double

    return 0;
}

std::get_if 不抛异常,特别适合在分支逻辑中快速判断。

std::visit:对所有可能的类型执行操作

std::visit 是最强大的访问方式——它接受一个**可调用对象(访问器)**和一个 variant,编译器会自动生成所有类型的分支跳转:

#include <variant>
#include <iostream>
#include <string>

int main()
{
    std::variant<int, double, std::string> v;

    // 定义 visitor——重载了 operator() 的结构体
    struct Visitor
    {
        void operator()(int i)         const { std::cout << "int: " << i << std::endl; }
        void operator()(double d)      const { std::cout << "double: " << d << std::endl; }
        void operator()(const std::string& s) const { std::cout << "string: " << s << std::endl; }
    };

    v = 42;
    std::visit(Visitor{}, v);              // 输出: int: 42

    v = 3.14;
    std::visit(Visitor{}, v);              // 输出: double: 3.14

    v = std::string("你好");
    std::visit(Visitor{}, v);              // 输出: string: 你好

    return 0;
}

也可以用 C++17 的泛型 lambda 简化书写:

auto printer = [](const auto& val)
{
    std::cout << "值是: " << val << std::endl;
};
std::visit(printer, v);

2.3 std::visit 的底层实现与性能

std::visit 不是简单的 if-else 链,编译器通常将其展开为跳转表(jump table):

// 编译器在内部大致生成等价于以下伪代码的汇编:
//
// switch (v.index()) {
// case 0: return visitor(get<0>(v));
// case 1: return visitor(get<1>(v));
// case 2: return visitor(get<2>(v));
// }
  • 时间复杂度为 O(1)——一次索引读取 + 一次跳转,与 variant 中类型的数量无关(在合理范围内)。
  • 如果 visitor 的每个 operator() 返回相同类型,std::visit 本身也是表达式,可直接用于赋值和 return。
  • 编译器还能对 visit 做内联优化,将整个调用展开为直接对活跃成员的操作。
// visit 的返回值
auto length = std::visit([](const auto& s) -> size_t
{
    return std::size(s);      // 适用于有 .size() 的类型
}, v);

2.4 std::variant 的安全性

编译期类型检查

std::variant 的类型列表在编译期就完全确定。如果你试图 get<NotListed>(),会在编译时报错:

std::variant<int, double> v;
v = 3.14f;                      // float 可隐式转为 int 或 double(此处优先匹配 double)
auto x = std::get<float>(v);    // 编译报错:float 不在类型列表中

运行时类型检查

  • std::get 在类型不匹配时抛出 std::bad_variant_access(运行时检查)。
  • std::get_if 在类型不匹配时返回 nullptr,不抛异常。
  • v.index() 返回当前活跃类型的索引(编译期常量),可以用于 switch。
std::variant<int, double, std::string> v;
v = 3.14;
std::cout << "当前活跃类型索引: " << v.index() << std::endl;   // 输出: 1

“valueless_by_exception”——当构造失败时

如果在对 variant 赋值时:

  1. 先销毁了旧值
  2. 然后在构造新值时抛出了异常

那么 variant 会进入一个特殊的 valueless_by_exception 状态:它不持有任何合法的值。

#include <variant>
#include <iostream>
#include <string>

struct MayThrow
{
    MayThrow() {}
    MayThrow(const MayThrow&) { throw std::runtime_error("拷贝失败"); }
};

int main()
{
    std::variant<int, MayThrow> v;
    v = 42;                  // 当前持有 int

    MayThrow mt;
    try
    {
        v = mt;              // 尝试替换为 MayThrow
    }
    catch (...)
    {
        // 构造 MayThrow 时抛异常,v 可能变成 valueless
    }

    if (v.valueless_by_exception())
        std::cout << "variant 处于无效状态!" << std::endl;

    return 0;
}

这是 variant 最危险的边缘情况。好在只有当类型列表中的类型可能抛出时才出现,且通常在异常后应当立即重建一个已知良好的值:

if (v.valueless_by_exception())
    v = 0;        // 用 int 重新初始化,恢复有效状态

2.5 与 union 的对比

union std::variant
类型安全 完全无,读错即 UB 编译期+运行时双重检查
非平凡类型 C++11 前不支持;之后需手动管理生命周期 完全支持
活跃成员查询 无,必须手动标记 v.index() / std::holds_alternative
内存开销 sizeof(最大成员) + 对齐 sizeof(最大成员) + 判别器(1~8 字节)
访问方式 直接 .member get / get_if / visit(异常安全)
新增类型成本 改 enum + 改所有 switch 分支 改类型列表 + 编译器强制补全 visitor
运行时性能 直接内存访问,零开销 get/visit 几乎零开销(跳转表 O(1))
适用场景 底层字节操作、C 接口兼容 业务逻辑、状态机、多态替代