Files
modern-cpp-tutorial/book/zh-cn/04-containers.md
2022-03-01 15:59:05 +01:00

311 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
title: 第 4 章 容器
type: book-zh-cn
order: 4
---
# 第 4 章 容器
[TOC]
## 4.1 线性容器
### `std::array`
看到这个容器的时候肯定会出现这样的问题:
1. 为什么要引入 `std::array` 而不是直接使用 `std::vector`
2. 已经有了传统数组,为什么要用 `std::array`?
先回答第一个问题,与 `std::vector` 不同,`std::array` 对象的大小是固定的,如果容器大小是固定的,那么可以优先考虑使用 `std::array` 容器。
另外由于 `std::vector` 是自动扩容的,当存入大量的数据后,并且对容器进行了删除操作,
容器并不会自动归还被删除元素相应的内存,这时候就需要手动运行 `shrink_to_fit()` 释放这部分内存。
```cpp
std::vector<int> v;
std::cout << "size:" << v.size() << std::endl; // 输出 0
std::cout << "capacity:" << v.capacity() << std::endl; // 输出 0
// 如下可看出 std::vector 的存储是自动管理的,按需自动扩张
// 但是如果空间不足,需要重新分配更多内存,而重分配内存通常是性能上有开销的操作
v.push_back(1);
v.push_back(2);
v.push_back(3);
std::cout << "size:" << v.size() << std::endl; // 输出 3
std::cout << "capacity:" << v.capacity() << std::endl; // 输出 4
// 这里的自动扩张逻辑与 Golang 的 slice 很像
v.push_back(4);
v.push_back(5);
std::cout << "size:" << v.size() << std::endl; // 输出 5
std::cout << "capacity:" << v.capacity() << std::endl; // 输出 8
// 如下可看出容器虽然清空了元素,但是被清空元素的内存并没有归还
v.clear();
std::cout << "size:" << v.size() << std::endl; // 输出 0
std::cout << "capacity:" << v.capacity() << std::endl; // 输出 8
// 额外内存可通过 shrink_to_fit() 调用返回给系统
v.shrink_to_fit();
std::cout << "size:" << v.size() << std::endl; // 输出 0
std::cout << "capacity:" << v.capacity() << std::endl; // 输出 0
```
而第二个问题就更加简单,使用 `std::array` 能够让代码变得更加“现代化”,而且封装了一些操作函数,比如获取数组大小以及检查是否非空,同时还能够友好的使用标准库中的容器算法,比如 `std::sort`
使用 `std::array` 很简单,只需指定其类型和大小即可:
```cpp
std::array<int, 4> arr = {1, 2, 3, 4};
arr.empty(); // 检查容器是否为空
arr.size(); // 返回容纳的元素数
// 迭代器支持
for (auto &i : arr)
{
// ...
}
// 用 lambda 表达式排序
std::sort(arr.begin(), arr.end(), [](int a, int b) {
return b < a;
});
// 数组大小参数必须是常量表达式
constexpr int len = 4;
std::array<int, len> arr = {1, 2, 3, 4};
// 非法,不同于 C 风格数组std::array 不会自动退化成 T*
// int *arr_p = arr;
```
当我们开始用上了 `std::array` 时,难免会遇到要将其兼容 C 风格的接口,这里有三种做法:
```cpp
void foo(int *p, int len) {
return;
}
std::array<int, 4> arr = {1,2,3,4};
// C 风格接口传参
// foo(arr, arr.size()); // 非法, 无法隐式转换
foo(&arr[0], arr.size());
foo(arr.data(), arr.size());
// 使用 `std::sort`
std::sort(arr.begin(), arr.end());
```
### `std::forward_list`
`std::forward_list` 是一个列表容器,使用方法和 `std::list` 基本类似,因此我们就不花费篇幅进行介绍了。
需要知道的是,和 `std::list` 的双向链表的实现不同,`std::forward_list` 使用单向链表进行实现,
提供了 `O(1)` 复杂度的元素插入,不支持快速随机访问(这也是链表的特点),
也是标准库容器中唯一一个不提供 `size()` 方法的容器。当不需要双向迭代时,具有比 `std::list` 更高的空间利用率。
## 4.2 无序容器
我们已经熟知了传统 C++ 中的有序容器 `std::map`/`std::set`,这些元素内部通过红黑树进行实现,
插入和搜索的平均复杂度均为 `O(log(size))`。在插入元素时候,会根据 `<` 操作符比较元素大小并判断元素是否相同,
并选择合适的位置插入到容器中。当对这个容器中的元素进行遍历时,输出结果会按照 `<` 操作符的顺序来逐个遍历。
而无序容器中的元素是不进行排序的,内部通过 Hash 表实现,插入和搜索元素的平均复杂度为 `O(constant)`
在不关心容器内部元素顺序时,能够获得显著的性能提升。
C++11 引入了的两组无序容器分别是:`std::unordered_map`/`std::unordered_multimap`
`std::unordered_set`/`std::unordered_multiset`
它们的用法和原有的 `std::map`/`std::multimap`/`std::set`/`set::multiset` 基本类似,
由于这些容器我们已经很熟悉了,便不一一举例,我们直接来比较一下`std::map``std::unordered_map`
```cpp
#include <iostream>
#include <string>
#include <unordered_map>
#include <map>
int main() {
// 两组结构按同样的顺序初始化
std::unordered_map<int, std::string> u = {
{1, "1"},
{3, "3"},
{2, "2"}
};
std::map<int, std::string> v = {
{1, "1"},
{3, "3"},
{2, "2"}
};
// 分别对两组结构进行遍历
std::cout << "std::unordered_map" << std::endl;
for( const auto & n : u)
std::cout << "Key:[" << n.first << "] Value:[" << n.second << "]\n";
std::cout << std::endl;
std::cout << "std::map" << std::endl;
for( const auto & n : v)
std::cout << "Key:[" << n.first << "] Value:[" << n.second << "]\n";
}
```
最终的输出结果为:
```txt
std::unordered_map
Key:[2] Value:[2]
Key:[3] Value:[3]
Key:[1] Value:[1]
std::map
Key:[1] Value:[1]
Key:[2] Value:[2]
Key:[3] Value:[3]
```
## 4.3 元组
了解过 Python 的程序员应该知道元组的概念,纵观传统 C++ 中的容器,除了 `std::pair` 外,
似乎没有现成的结构能够用来存放不同类型的数据(通常我们会自己定义结构)。
`std::pair` 的缺陷是显而易见的,只能保存两个元素。
### 元组基本操作
关于元组的使用有三个核心的函数:
1. `std::make_tuple`: 构造元组
2. `std::get`: 获得元组某个位置的值
3. `std::tie`: 元组拆包
```cpp
#include <tuple>
#include <iostream>
auto get_student(int id)
{
// 返回类型被推断为 std::tuple<double, char, std::string>
if (id == 0)
return std::make_tuple(3.8, 'A', "张三");
if (id == 1)
return std::make_tuple(2.9, 'C', "李四");
if (id == 2)
return std::make_tuple(1.7, 'D', "王五");
return std::make_tuple(0.0, 'D', "null");
// 如果只写 0 会出现推断错误, 编译失败
}
int main()
{
auto student = get_student(0);
std::cout << "ID: 0, "
<< "GPA: " << std::get<0>(student) << ", "
<< "成绩: " << std::get<1>(student) << ", "
<< "姓名: " << std::get<2>(student) << '\n';
double gpa;
char grade;
std::string name;
// 元组进行拆包
std::tie(gpa, grade, name) = get_student(1);
std::cout << "ID: 1, "
<< "GPA: " << gpa << ", "
<< "成绩: " << grade << ", "
<< "姓名: " << name << '\n';
}
```
`std::get` 除了使用常量获取元组对象外C++14 增加了使用类型来获取元组中的对象:
```cpp
std::tuple<std::string, double, double, int> t("123", 4.5, 6.7, 8);
std::cout << std::get<std::string>(t) << std::endl;
std::cout << std::get<double>(t) << std::endl; // 非法, 引发编译期错误
std::cout << std::get<3>(t) << std::endl;
```
### 运行期索引
如果你仔细思考一下可能就会发现上面代码的问题,`std::get<>` 依赖一个编译期的常量,所以下面的方式是不合法的:
```cpp
int index = 1;
std::get<index>(t);
```
那么要怎么处理?答案是,使用 `std::variant<>`C++ 17 引入),提供给 `variant<>` 的类型模板参数
可以让一个 `variant<>` 从而容纳提供的几种类型的变量(在其他语言,例如 Python/JavaScript 等,表现为动态类型):
```cpp
#include <variant>
template <size_t n, typename... T>
constexpr std::variant<T...> _tuple_index(const std::tuple<T...>& tpl, size_t i) {
if constexpr (n >= sizeof...(T))
throw std::out_of_range("越界.");
if (i == n)
return std::variant<T...>{ std::in_place_index<n>, std::get<n>(tpl) };
return _tuple_index<(n < sizeof...(T)-1 ? n+1 : 0)>(tpl, i);
}
template <typename... T>
constexpr std::variant<T...> tuple_index(const std::tuple<T...>& tpl, size_t i) {
return _tuple_index<0>(tpl, i);
}
template <typename T0, typename ... Ts>
std::ostream & operator<< (std::ostream & s, std::variant<T0, Ts...> const & v) {
std::visit([&](auto && x){ s << x;}, v);
return s;
}
```
这样我们就能:
```cpp
int i = 1;
std::cout << tuple_index(t, i) << std::endl;
```
### 元组合并与遍历
还有一个常见的需求就是合并两个元组,这可以通过 `std::tuple_cat` 来实现:
```cpp
auto new_tuple = std::tuple_cat(get_student(1), std::move(t));
```
马上就能够发现,应该如何快速遍历一个元组?但是我们刚才介绍了如何在运行期通过非常数索引一个 `tuple` 那么遍历就变得简单了,
首先我们需要知道一个元组的长度,可以:
```cpp
template <typename T>
auto tuple_len(T &tpl) {
return std::tuple_size<T>::value;
}
```
这样就能够对元组进行迭代了:
```cpp
// 迭代
for(int i = 0; i != tuple_len(new_tuple); ++i)
// 运行期索引
std::cout << tuple_index(new_tuple, i) << std::endl;
```
## 总结
本章简单介绍了现代 C++ 中新增的容器,它们的用法和传统 C++ 中已有的容器类似,相对简单,可以根据实际场景丰富的选择需要使用的容器,从而获得更好的性能。
`std::tuple` 虽然有效,但是标准库提供的功能有限,没办法满足运行期索引和迭代的需求,好在我们还有其他的方法可以自行实现。
[返回目录](./toc.md) | [上一章](./03-runtime.md) | [下一章 智能指针与内存管理](./05-pointers.md)
## 许可
<a rel="license" href="https://creativecommons.org/licenses/by-nc-nd/4.0/"><img alt="知识共享许可协议" style="border-width:0" src="https://i.creativecommons.org/l/by-nc-nd/4.0/80x15.png" /></a>
本教程由[欧长坤](https://github.com/changkun)撰写,采用[知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议](https://creativecommons.org/licenses/by-nc-nd/4.0/)许可。项目中代码使用 MIT 协议开源,参见[许可](../../LICENSE)。