关于C++元组 std::tuple 的整理笔记。

概述

std::tuple是一个非常重要的C++标准库模块,它是一个固定大小且类型安全的数据容器, 提供了一种将任意数量的不同类型元素有序地打包组合在一起的方式, 在某些原本需要临时定义结构体的场合可以用std::tuple替代,使得代码更加简洁。

std::tuple的最大特点是编译期运算,与元组相关的标准库函数的实现属于模板元编程的范畴。在模板元编程中,元组也是一个重要角色,因为元组在编译期既含有整数信息也含有类型信息。

事实上从C++11开始,标准库的<tuple>就提供了std::tuple,但是语法既繁琐又简陋,一些直观的语法是不支持的。 在后续的标准中对std::tuple的支持在不断完善,用法变得更加简洁。 在本文中主要涉及的是C++20标准支持的用法,部分语法在C++11标准下是编译不过的。

简单示例

下面提供一个基于std::tuple实现具有多个返回值的函数示例

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

std::tuple<std::string, int, double> getPersonInfo() {
std::string name = "Alice";
int age = 30;
double height = 1.68;

return std::make_tuple(name, age, height);
}

int main() {
// 调用函数并获取返回的tuple,再使用结构化绑定解包tuple中的元素
auto [name, age, height] = getPersonInfo();

// 使用 std::format 格式化输出
std::cout << std::format("Name: {}\nAge: {}\nHeight: {:.2f} m\n", name, age, height);

return 0;
}

这里还利用了结构化绑定来解包元组,利用std::format格式化输出。 如果不使用这些新标准中的语法,例如在C++11之前,我们可能需要下面的实现

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
#include <cstdio>
#include <string>

struct PersonInfo {
std::string name;
int age = 0;
double height = 0;
};

PersonInfo getPersonInfo() {
PersonInfo personInfo;
personInfo.name = "Alice";
personInfo.age = 30;
personInfo.height = 1.68;

return personInfo;
}

int main() {
// 调用函数并获取返回的结构体
PersonInfo personInfo = getPersonInfo();

// 使用 printf 格式化输出
printf("Name: %s\nAge: %d\nHeight: %.2f m\n", personInfo.name.c_str(),
personInfo.age, personInfo.height);

return 0;
}

最主要的问题是这里必须在全局定义一个临时性的结构体PersonInfo,显得过于繁琐, 使用std::tuple就可以省去结构体的定义,这也是最常见的使用情景——将多值打包为元组返回。

元组替代结构体的方案其实有利有弊:

  • 优点是代码结构简洁,省略了临时性的结构体定义;
  • 缺点是代码可读性降低,失去了结构体中各个元素的语义,只保留了元素类型和值。

元组的定义

首先关注元组的定义,最基本的用法是我们显式地写出每一个元素的类型

1
2
3
4
std::tuple<int, double, std::string> s0{1, 2.0, "hello"};
std::tuple<int, double, std::string> s1(1, 2.0, "hello");
std::tuple<int, double, std::string> s2 = {1, 2.0, "hello"}; // C++11不支持
auto s3 = std::tuple<int, double, std::string>{1, 2.0, "hello"};

这种显式的定义显得特别繁琐,我们希望编译器自动根据参数推断出对应的类型(此时字符串的元素类型会被推断为const char *而非std::string),可以使用下面的语法

1
2
3
4
std::tuple s0{1, 2.0, "hello"};
std::tuple s1(1, 2.0, "hello");
std::tuple s2 = {1, 2.0, "hello"};
auto s3 = std::tuple{1, 2.0, "hello"};

由于C++11只支持模板函数的自动类型推断,对于模板类还不支持,上面几个省略语法C++11全部无法编译,在C++11中临时提供了make_tuple函数来辅助创建元组

1
auto s4 = std::make_tuple(1, 2.0, "hello");

此时借助函数的自动类型推断,才可以在创建元组时省略元素类型。(现在其实没必要使用std::make_tuple了)

注意:由于初始化列表的存在,下面这种最直观的语法是不能用于创建元组的,因为C++不可能像Python那样,奢侈地将{}直接与元组绑定

1
2
auto a = {1,2,3}; // 识别为 std::initializer_list<int>
auto b = {1,2.0,"hello"}; // 编译报错

元组的使用

按索引获取元素

对于元组的使用,首先关注如何获取元组中的元素, 我们使用std::get<N>()来获取std::tuple的第N个元素,例如

1
2
3
4
auto person = std::tuple{0, 3.8, std::string{"Lisa Simpson"}};

auto item = std::get<1>(person);
std::cout << "item = " << item;

由于std::tuple的很多行为是在编译期执行的,因此要求索引N必须是一个编译期可计算的正整数常量, 并且N不能超过std::tuple的合法指标范围(从0开始),否则编译报错。

std::tuple在编译期仍然记录了每一个元素的类型,因此获取元素时,可以使用auto来自动获取元素类型, 也可以指定类型,但是指定的类型必须与std::tuple存储的元素类型一致,或者可以进行自动转换,例如

1
2
3
4
5
6
7
8
9
10
auto person = std::tuple{0, 3.8, "Lisa Simpson"};

auto item0 = std::get<2>(person); // auto = const char *
std::cout << "item0 = " << item0 << "\n";

const char *item1 = std::get<2>(person);
std::cout << "item1 = " << item1 << "\n";

std::string item2 = std::get<2>(person);
std::cout << "item2 = " << item2 << "\n";

通过std::get<N>()获取的是元素的引用而非拷贝,因此可以对其进行修改,修改会反馈给元组自身。

1
2
3
4
auto person = std::tuple{0, 3.8, "Lisa Simpson"};
std::cout << "item0 = " << std::get<0>(person) << "\n"; // item0 = 0
std::get<0>(person) = 2;
std::cout << "item0 = " << std::get<0>(person) << "\n"; // item0 = 2

按类型获取元素

在C++14之后,我们还可以按照元素类型来直接获取元素:使用std::get<T>()来获取std::tuple的类型为T的元素, 显然这要求元组中指定的类型只有一个元素,否则会触发编译错误。

与按照元素索引获取元素的对比如下

1
2
3
4
5
6
7
8
9
auto t = std::tuple{1, "Foo", 3.14};

// Index-based access
std::cout << "( " << std::get<0>(t) << ", " << std::get<1>(t) << ", "
<< std::get<2>(t) << " )\n";

// Type-based access (C++14 or later)
std::cout << "( " << std::get<int>(t) << ", " << std::get<const char *>(t)
<< ", " << std::get<double>(t) << " )\n";

元组解包

我们很可能需要直接解包元组,以获取元组的所有元素。在支持结构化绑定之后,我们可以直接解包元组,就像解包一个简单的结构体一样

1
2
auto person = std::tuple{0, 3.8, "Lisa Simpson"};
auto [id, weight, name] = person;

在此之前,C++11临时提供了一个“低配版的结构化绑定语法糖”:std::tie(),用法如下

1
2
3
4
5
6
auto person = std::tuple{0, 3.8, "Lisa Simpson"};

int id;
double weight;
std::string name;
std::tie(id, weight, name) = person;

它比结构化绑定麻烦一些,因为每一个变量都需要提前定义好。对std::tie()的实现也非常简单,下面是MSVC2022的源码

1
2
3
4
5
_EXPORT_STD template <class... _Types>
_NODISCARD constexpr tuple<_Types&...> tie(_Types&... _Args) noexcept {
using _Ttype = tuple<_Types&...>;
return _Ttype(_Args...);
}

cppreference也提供了一种参考实现

1
2
3
4
5
template <typename... Args>
constexpr std::tuple<Args&...> tie(Args&... args) noexcept
{
return {args...};
}

在解包元组时有时我们只需要部分元素,而舍弃其它不需要的元素(在其它语言中通常使用~_占位), C++11提供了std::ignore来实现这种需求(仅针对std::tie有效)

1
2
3
4
5
std::tuple<int, double, std::string> data(42, 3.14, "Hello");

int intValue;
std::string strValue;
std::tie(intValue, std::ignore, strValue) = data;

这个做法对于结构化绑定是不行的,因为结构化绑定的过程需要定义新的变量,建议使用_作为占位符

1
2
std::tuple<int, double, std::string> data(42, 3.14, "Hello");
auto [intValue, _, strValue] = data;

但是这种做法是临时性的,重复使用仍然会出现_重定义的问题。 在C++20中并不支持标识符_的特殊角色,在更新的标准中应该会引入。

元组运算

我们可以使用==!=>>=等运算符对两个std::tuple进行比较,默认通过对每一个元素依次比较得到比较的结果,要求:

  • 两个std::tuple具有相同个数的元素
  • 对应位置的元素可以进行比较

C++11提供了将多个std::tuple拼接的工具:std::tuple_cat,例如

1
2
3
4
5
6
std::tuple<int, char> t1{10, 'a'};
std::tuple<std::string, double> t2{"hello", 3.14};
std::tuple<bool> t3{true};

auto combined = std::tuple_cat(t1, t2, t3);
// std::tuple<int, char, std::string, double, bool>

元组编译期信息

我们可以在编译期获取元组的信息:元素个数和每一个元素的类型。

通过std::tuple_size<T>::value可以获取元组类型T的元素个数,通常需要结合decltype()使用,例如

1
2
std::tuple a{10, 2.1, "abc"};
std::cout << std::tuple_size<decltype(a)>::value << std::endl;

通过std::tuple_element<N,T>::type可以获取元组类型T的第N个元素的类型,例如

1
2
using Type1 = std::tuple_element<1, std::tuple<int, int, int>>::type;
Type1 s = 1; // int

后续还提供了std::tuple_element_t<N,T>,比原本的::type简略了一点。

cppreference提供了std::tuple_element的一种参考实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

template<std::size_t I, class T>
struct tuple_element;

template<std::size_t I, class Head, class... Tail>
struct tuple_element<I, std::tuple<Head, Tail...>>
: std::tuple_element<I - 1, std::tuple<Tail...>>
{ };

// base case
template<class Head, class... Tail>
struct tuple_element<0, std::tuple<Head, Tail...>>
{
using type = Head;
};

补充

std::pair

std::tuple非常相似的是std::pair,它比std::tuple更简单,有且仅有两个数据成员,在STL的很多容器中都使用了std::pair,例如字典的键值对。

std::pair的使用示例如下(需要头文件<utility>

1
2
3
4
5
6
7
8
9
10
11
// 创建一个 std::pair,存储两个不同类型的值
std::pair<int, std::string> person = std::make_pair(25, "Alice");


// 使用 first 和 second 成员访问存储的值
std::cout << "Age: " << person.first << std::endl;
std::cout << "Name: " << person.second << std::endl;

// 修改 first 和 second 成员的值
person.first = 26;
person.second = "Bob";

std::tuple类似,C++11提供了专门的构造函数std::make_pair, 但是现在已经不需要了,我们可以使用更简单的方式进行初始化

1
2
3
4
5
6
7
8
9
std::pair<int, std::string> s0{1, "hello"};
std::pair<int, std::string> s1(1, "hello");
std::pair<int, std::string> s2 = {1, "hello"}; // C++11不支持
auto s3 = std::pair<int, std::string>{1, "hello"};

std::pair s0{1, "hello"};
std::pair s1(1, "hello");
std::pair s2 = {1, "hello"};
auto s3 = std::pair{1, "hello"};

std::tuple不同的是,因为std::pair有且仅有两个数据成员,只能通过a.firsta.second访问。