文章

数据类型 Variant

数据类型 Variant

[TOC]

1. union

1.1 union 的使用

union 是 C++ 中一个较老的特性,它允许在相同的内存位置存储不同的数据类型,但一次只能存储其中一种类型的值。union 不需要额外的赋值和强制类型转换,同一个数据可解释为两个不一样的东西。

union 的定义格式:1

1
2
3
4
union/*共用体名*/
{    
    /*成员列表*/
}/*共用体变量名*/;
  • 简单示例:2

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    
    union MyUnion {
        int intValue;
        float floatValue;
    };
      
    void main() {
        MyUnion u;
        u.intValue = 5;
        std::cout << "Integer value: " << u.intValue << std::endl;
          
        // 现在存储了一个float值,上面赋的intValue的值将变得无效
        u.floatValue = 3.14f;
        std::cout << "Float value: " << u.floatValue << std::endl;
    }
    
  • 使用union对数据进行打包:1

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    
    void main(void) {
        // 定义一个联合体
        union ByteSplit {
            unsigned int word;
            unsigned char byte[4];
        } data;
      
        // 给联合体的字节成员赋值
        data.byte[0] = 0x11;
        data.byte[1] = 0x22;
        data.byte[2] = 0x33;
        data.byte[3] = 0x44;
      
        // 输出整数和字节
        printf("整数值为:%u\n", data.word);
    }
    
  • 使用union优化空间利用:1

    在大多数编程环境中,union 通常用于空间优化。由于 union 的所有成员共享同一块内存空间,因此可以通过使用不同的数据类型来优化内存使用。以下是一个CANopen的例子:

    在Canopen 中,有一种PDO的通信形式,他就相当于预定义好了一条CAN 帧中 8 个自己的数据意义。我们可以简单的理解为,不同的 id对应了不同类型数据的具体意义,比如id ==1 时代表4个速度变量,id ==1 代表4个温度变量。

    也就是说,发送的消息并不会同时要包含速度和温度,而是每个帧分开来的发送的,这个时候,我们就可以采用 union 的特性来构造一个数据结构,这样做的好处是能够缩减变量占用的内存。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    
    struct buffer
    {
        short id;   /*CAN——ID*/
        union 
        {
            struct 
            {
                short speed1; 
                short speed2;
                short speed3;
                short speed4;
            } speed;
      
            struct 
            {
                short temp1; 
                short temp2;
                short temp3;
                short temp4;
            } temp;
        }Info;
      
    }my_buff;
    

    采用上述的结构的话,我们可以计算一下,结构体占用的存储空间是 10个字节,如果我们分别为温度和速度的解析来定义缓存变量,那么占用的内存空间将几乎增大一倍。

    这里有了 id的加入,我们可以在接收端对数据进行解析了。

    通过上述的这个例子可以了解,如果不使用 union 的话,在进行数据传输的时候,直接将由 struct 构造的数据形成数据帧发送过去,发送的数据包要比使用 union 构造的数据大不少,使用 union 构造数据,既能够帮助我们节省了存储空间,还节省了通信时的带宽。

1.2 union 的问题

  1. 使用 union 时,程序员需要确保正确地使用了当前存储的类型。不正确的使用可能导致数据损坏或未定义行为。2
  2. 无法知道当前使用的类型是什么,因此通常需要额外的变量来追踪。23
  3. union 无法自动调用底层数据成员的析构函数3。所以union 不能存储有复杂构造函数或析构函数的类型(例如,标准库中的容器类型)。2

2. std::variant

std::variant 是 C++17 引入的一个类型安全的联合体(union)。它可以存储并操作几种不同类型中的一个,类似于传统的联合体(union)。

但比传统联合体提供了更高的类型安全性和更方便的接口,并且知道它自己当前存储的是哪种类型。

当存储的类型有非平凡的构造函数或析构函数时,std::variant 可以处理异常,而 union 不能。

std::variant 存在一定的限制:虽然它可以存储多种类型,但在任何给定时间点,它只能存储其中一种。这就像是一个变色的蜥蜴,虽然它可以变成多种颜色,但一次只能是一种。4

简单来说,std::variant 提供了比传统 union 更安全、更灵活的方式来处理存储多种类型的需求。尽管 std::variant 可能在性能和内存占用上略微不如 union,但其带来的类型安全和易用性在许多情况下是值得的。2

下面是一个基本用法的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <variant>

using MyVariant = std::variant<int, double, std::string>;

int main() 
{
    MyVariant v1 = 42;
    MyVariant v2 = 3.14;
    MyVariant v3 = "hello";

    // 访问存储的值(不安全,需确保类型正确)
    std::cout << std::get<int>(v1) << std::endl;

    // 安全地访问存储的值
    if (auto pval = std::get_if<int>(&v1)) 
    {
        std::cout << *pval << std::endl;
    }

    return 0;
}

在上面的例子中,MyVariant 可以存储 intfloatstd::string 类型的数据。

不同于 unionstd::variant 保留了存储的数据类型的信息,并提供了类型安全的访问方法 get_if()

3. std::visit

std::visit 是 C++17 中引入的一个工具,用于访问和操作存储在 std::variant 类型中的数据。

std::visit 的工作原理见参考文章4

3.1 基本接口与使用原理

std::visit基本接口如下:

1
2
template<class Visitor, class... Variants>
constexpr visit(Visitor&& vis, Variants&&... vars);
  • Visitor:一个可调用对象,它应该能够接受 Variants 中每种类型的值。它通常是一个重载了 operator() 的结构或类。
  • Variants:一个或多个 std::variant 类型的对象。

使用 std::visit 的典型方式是:4

  1. 定义一个结构体、类或lamda函数(也就是Visitor),Visitor中重载了针对 std::variant 可能持有的每种类型的 operator() 方法。

  2. 将这个Visitor的实例以及 std::variant 对象传递给 std::visit

  3. std::visit 将自动调用与 std::variant 当前存储的值类型相匹配的重载方法。

3.2 使用方法:泛型 lambda 表达式

std::visit 允许传入一个可调用对象(callable object),通常是一个 lambda 表达式。

现代 C++ 提供了一种特殊的 lambda 表达式,称为泛型 lambda 表达式(generic lambda)。

泛型 lambda

泛型 lambda 是一个使用 auto 关键字作为参数类型的 lambda 表达式。

这意味着 lambda 可以接受任何类型的参数,并在函数体内进行处理。

1
2
3
auto generic_lambda = [](auto x) {
    // do something with x
};

这种灵活性在处理 std::variant 时尤为有用,因为你可能需要根据多种可能的类型来编写逻辑。

3.3 使用方法:泛型 lambda 与类型判断4

编程就像是一场高级的拼图游戏。你需要一种机制来判断哪块拼图适用于当前的情况。4

std::visit 的上下文中,这通常是通过 if constexpr5类型萃取(type traits)6 来完成的。

通过结合 if constexpr 和类型萃取,可以写出高度灵活且类型安全的代码。

我们可以把 泛型lambda类型判断 融合到一起,优雅地使用 std::visit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
std::variant<int, double, std::string> v = "hello";

std::visit([](auto&& arg) 
{
    // 类型萃取:
    // 定义一个新类型T,它是传递给它的arg参数的去引用、去cv限定符版本的类型。
    // 例如,如果arg是一个const int&类型的参数,T 就会是int
    using T = std::decay_t<decltype(arg)>;
    // 条件编译:
    if constexpr (std::is_same_v<T, int>) {
        std::cout << "int: " << arg << std::endl;
    } 
    else if constexpr (std::is_same_v<T, double>) {
        std::cout << "double: " << arg << std::endl;
    } 
    else {
        // 编译代码时检查T是不是std::string
        static_assert(std::is_same_v<T, std::string>);
        std::cout << "string: " << arg << std::endl;
    }
}, v);

这里,我们使用了泛型 lambda 来接受任何类型的 arg,然后用 if constexpr 和类型萃取来确定 arg 的实际类型,并据此执行相应的操作。

3.4 使用方法:访问者模式

下面是一个简单的 std::visit 使用示例。

在这个例子中使用 std::variant 来存储不同类型的数据,并展示如何使用 std::visit 以类型安全的方式访问和处理这些数据。

假设我们有一个 std::variant,它可以存储一个 int、一个 double 或一个 std::string 类型的值。我们将编写一个访问者函数对象,这个对象会根据 std::variant 当前存储的类型执行不同的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>
#include <variant>
#include <string>
#include <functional>

// 定义 variant 类型
using MyVariant = std::variant<int, double, std::string>;

// 访问者函数对象
struct VariantVisitor {
    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;
    }
};

int main() {
    MyVariant v1 = 10;        // v1 存储 int
    MyVariant v2 = 3.14;      // v2 存储 double
    MyVariant v3 = "hello";   // v3 存储 string

    std::visit(VariantVisitor(), v1); // 输出: 处理 int: 10
    std::visit(VariantVisitor(), v2); // 输出: 处理 double: 3.14
    std::visit(VariantVisitor(), v3); // 输出: 处理 string: hello

    return 0;
}

在这个例子中:

  • 我们定义了一个 std::variant 类型 MyVariant,它可以存储 intdoublestd::string
  • VariantVisitor 是一个重载了 operator() 的结构体,对每种可能的类型提供了一个处理方法。
  • main 函数中,我们创建了三个 MyVariant 实例,分别存储不同的类型。
  • 使用 std::visit 调用 VariantVisitor 实例,它会自动选择并调用与 variant 当前存储的类型相匹配的重载函数。

这个例子展示了 std::visit 如何提供一种类型安全、灵活的方式来处理存储在 std::variant 中的不同类型的数据。

参考文章7891011

  1. 活用 C语言之union的精妙之用 ↩︎ ↩︎2 ↩︎3

  2. C++17 variant ↩︎ ↩︎2 ↩︎3 ↩︎4 ↩︎5

  3. 【C++】std::variant  ↩︎ ↩︎2

  4. 【C++ 17 新功能 std::visit 】深入解析 C++17 中的 std::visit:从原理到实践  ↩︎ ↩︎2 ↩︎3 ↩︎4 ↩︎5

  5. 在C++17中引入的 if constexpr 是一个条件编译语句,它允许编译器在编译时根据条件表达式决定是否编译某段代码。if constexpr是模板元编程中特别有用的特性,它可以基于模板参数进行条件编译,从而在编译期间就消除不必要的分支和相关代码。 constexpr.md ↩︎

  6. 类型萃取(Type Traits)是 C++11 引入的一组模板,用于在编译时获取类型的属性,就是把类型提取出来。类型萃取.md ↩︎

  7. static_assert.md ↩︎

  8. 经典 C 语言编程,结构体和联合体如何共用? ↩︎

  9. C语言中的内存覆盖技术:共同体的概念和操作 ↩︎

  10. 【C++】内存对齐 ↩︎

  11. C++编程新技能!std::variant详解,掌握多类型值存储! ↩︎

本文由作者按照 CC BY 4.0 进行授权