15. 标准库中的其他变化
# 15. 标准库中的其他变化
C++17是该语言的一次重大更新,它为标准库带来了许多特性。到目前为止,本书已经涵盖了最重要的方面,但还有很多内容有待介绍!
本书的这部分内容简要总结了标准库中的其他变化:
- 什么是
std::byte
? - 映射(map)和集合(set)有哪些新功能?
- 新算法:抽样(sampling)
- 特殊数学函数
- 共享指针(Shared Pointers)和数组
- 非成员函数
size()
、data()
和empty()
- 标准库中的
constexpr
扩展 - 如何使用
scoped_lock
锁定多个互斥锁(mutex)? - 什么是多态分配器(polymorphic allocator)?它如何助力内存管理?
# std::byte
std::byte
是一种小型类型,它让你能够查看字节和位,而不是数字或字符值(比如unsigned char
)。它被定义为枚举(enum):
enum class byte : unsigned char {} ; // 在<cstddef>中
你可以用unsigned char
来初始化byte
,这实际上是C++17的另一个便捷特性,它允许你用基础类型初始化作用域枚举。要将byte
转换为数字类型,可以使用std::to_integer()
。
来看一个基本示例:
// Chapter STL Other/byte.cpp
constexpr std::byte b{1};
// std::byte c{3535353}; // 错误:从int进行窄化转换
constexpr std::byte c{255};
// 移位操作:
constexpr auto b1 = b << 7;
static_assert (std::to_integer<int>(b) == 0x01);
static_assert (std::to_integer<int>(b1) == 0x80);
// 各种位运算符,如&、|、^等
constexpr auto c1 = b1 ^ c;
static_assert (std::to_integer<int>(c) == 0xff);
static_assert (std::to_integer<int>(c1) == 0x7f);
constexpr auto c2 = ~c1;
static_assert (std::to_integer<int>(c2) == 0x80);
static_assert(c2 == b1);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
std::byte
背后的主要动机是在内存/字节访问场景下保证类型安全。
# 额外信息
查看参考论文P0298R3 (opens new window)。
# 映射和集合的改进
在标准中,映射和集合有两个值得注意的特性:
- 拼接映射和集合——P0083 (opens new window)
- 新的插入例程——N4279 (opens new window)
# 拼接
现在,你可以将节点从一个基于树的容器(映射/集合)移动到另一个容器中,而无需额外的内存开销或分配。
例如:
// 文件名:STL Other/set_extract_insert.cpp
#include <set>
#include <string>
#include <iostream>
struct User {
std::string name;
User(std::string s) : name(std::move(s)) {
std::cout << "User::User(" << name << ")\n ";
}
~User() {
std::cout << "User::~User(" << name << ")\n ";
}
User(const User& u) : name(u.name) {
std::cout << "User::User(copy, " << name << ")\n ";
}
friend bool operator<(const User& u1, const User& u2) {
return u1.name < u2.name;
}
};
int main() {
std::set<User> setNames;
setNames.emplace("John");
setNames.emplace("Alex");
setNames.emplace("Bartek");
std::set<User> outSet;
std::cout << "move John...\n ";
// 将John移动到outSet
auto handle = setNames.extract(User("John"));
outSet.insert(std::move(handle));
for (auto& elem : setNames)
std::cout << elem.name << '\n ';
std::cout << "cleanup...\n ";
}
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
35
36
37
38
输出:
User::User(John)
User::User(Alex)
User::User(Bartek)
move John...
User::User(John)
User::~User(John)
Alex
Bartek
cleanup...
User::~User(John)
User::~User(Bartek)
User::~User(Alex)
2
3
4
5
6
7
8
9
10
11
12
在上述示例中,“John”这个元素从setNames
中被提取并移动到outSet
中。extract
方法将找到的节点从集合中移出,并从物理上使其与容器分离。之后,被提取的节点可以插入到相同类型的容器中。
在C++17之前,如果你想将一个对象从一个映射(或集合)移动到另一个映射(或集合)中,你必须先从第一个容器中移除它,然后再复制或移动到另一个容器中。有了新功能,你可以操作树节点(通过使用实现定义的类型node_type
),并且不会影响对象本身。这种技术可以处理不可移动的元素,当然,也更加高效。
# 映射和无序映射的插入增强
在C++17中,映射和无序映射有两个新方法:
try_emplace()
——如果对象已经存在,则不进行任何操作,否则其行为与emplace()
相同。- 当键已经在映射中时,
emplace()
可能会从输入参数中移动数据,所以在进行这种插入操作之前最好先使用find()
查找。
- 当键已经在映射中时,
insert_or_assign()
——比operator[]
提供更多信息,因为它会返回元素是新插入的还是被更新的,并且对没有默认构造函数的类型也适用 。
# try_emplace
方法
下面是一个示例:
// 文件名:STL Other/try_emplace_map.cpp
#include <iostream>
#include <string>
#include <map>
int main() {
std::map<std::string, int> m;
m["hello"] = 1;
m["world"] = 2;
// C++11方式:
if (m.find("great") == std::end(m))
m["great"] = 3;
// 如果"great"不在映射中,则查找操作会执行两次
// C++17方式:
m.try_emplace("super", 4);
m.try_emplace("hello", 5); // 不会插入,因为它已经在映射中
for (const auto& [key, value] : m)
std::cout << key << " -> " << value << '\n ';
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
当你将元素移动到映射中时,try_emplace
的行为很重要:
// 文件名:STL Other/try_emplace_map_move.cpp
#include <iostream>
#include <string>
#include <map>
int main() {
std::map<std::string, std::string> m;
m["Hello"] = "World";
std::string s = "C++";
m.emplace(std::make_pair("Hello", std::move(s)));
// 字符串's'会怎样呢?
std::cout << s << '\n ';
std::cout << m["Hello"] << '\n ';
s = "C++";
m.try_emplace("Hello", std::move(s));
std::cout << s << '\n ';
std::cout << m["Hello"] << '\n ';
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
这段代码试图将["Hello", "World"]
替换为["Hello", "C++"]
。
如果你运行这个示例,emplace
之后字符串s
为空,并且“World”并没有被改为“C++”!
当键已经在容器中时,try_emplace
不会执行任何操作,所以字符串s
保持不变。
# insert_or_assign
方法
第二个函数insert_or_assign
,用于在映射中插入一个新对象或分配新的值。但与operator[]
不同的是,它对非默认构造类型也适用。
例如:
// 文件名:STL Other/insert_or_assign.cpp
#include <iostream>
#include <map>
#include <string>
struct User {
std::string name;
User(std::string s) : name(std::move(s)) {
std::cout << "User::User(" << name << ")\n ";
}
~User() {
std::cout << "User::~User(" << name << ")\n ";
}
User(const User& u) : name(u.name) {
std::cout << "User::User(copy, " << name << ")\n ";
}
friend bool operator<(const User& u1, const User& u2) {
return u1.name < u2.name;
}
};
int main() {
std::map<std::string, User> mapNicks;
//mapNicks["John"] = User("John Doe"); // 错误:User没有默认构造函数
auto [iter, inserted] = mapNicks.insert_or_assign("John", User("John Doe"));
if (inserted)
std::cout << iter->first << " entry was inserted\n ";
else
std::cout << iter->first << " entry was updated\n ";
}
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
输出:
User::User(John Doe)
User::User(copy, John Doe)
User::~User(John Doe)
John entry was inserted
User::~User(John Doe)
2
3
4
5
在上面的示例中,我们不能使用operator[]
向容器中插入新值,因为它不支持没有默认构造函数的类型。但我们可以使用这个新函数来实现。
insert_or_assign
返回一个<迭代器, bool>
对。如果布尔值为true
,则意味着元素被插入到容器中;否则,元素是被重新赋值。
# 额外信息
在《拼接映射和集合》P0083 (opens new window)和《新插入例程》N4279 (opens new window)中查看更多信息。
# 插入方法的返回类型
自C++11起,大多数标准容器都有了.emplace*
方法。使用这些方法,你可以就地创建一个新对象,而无需额外的对象复制操作。
然而,在C++17之前,大多数.emplace*
方法都不返回任何值,即返回类型为void
。从C++17开始,这一情况发生了改变,现在它们返回插入对象的引用类型。
例如:
// C++11到C++17期间,std::vector的emplace_back方法
template < class... Args >
void emplace_back( Args&&... args );
// C++17起,std::vector的emplace_back方法
template < class... Args >
reference emplace_back( Args&&... args );
2
3
4
5
6
7
这一修改缩短了向容器中添加元素,然后对新添加的对象执行某些操作的代码。
例如:
// 文档中未提及emplace_return.cpp具体位置,仅按原文保留
#include <vector>
#include <string>
int main() {
std::vector<std::string> stringVector;
// 在C++11/14中:
stringVector.emplace_back("Hello");
// emplace不返回任何值,所以需要使用back()方法
stringVector.back().append(" World");
// 在C++17中:
stringVector.emplace_back("Hello").append(" World");
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 额外信息
更多信息请查看论文:P0084R2 (opens new window) 。
# 抽样算法
新算法std::sample
,用于从序列中选择n
个元素:
// 文档中未提及sample.cpp具体位置,仅按原文保留
#include <iostream>
#include <random>
#include <iterator>
#include <algorithm>
int main() {
std::vector<int> v { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
std::vector<int> out;
std::sample(v.begin(), // 范围起始
v.end(), // 范围结束
std::back_inserter(out), // 存放位置
3, // 抽样元素数量
std::mt19937{std::random_device{}()});
std::cout << "抽样值: ";
for (const auto &i : out)
std::cout << i << ", ";
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
可能的输出: 抽样值: 1, 4, 9,
# 额外信息
新的抽样算法源自对库基础V1技术规范(Library Fundamentals V1 TS)组件“抽样(Sampling)”(P0220R1 (opens new window) )的采用。
# 新的数学函数
C++17引入了许多新的数学函数,如std::gcd
(最大公约数,Greatest Common Divisor)、std::lcm
(最小公倍数,Least Common Multiple)、std::clamp
以及其他特殊函数。
例如,在P0295R0 (opens new window) 中引入的std::gcm
和std::lcm
,在<numerics>
头文件中声明:
// Chapter STL Other/sample.cpp
#include <iostream>
#include <numeric> // 用于gcm, lcm
int main() {
std::cout << std::gcd(24, 60) << ', ';
std::cout << std::lcm(15, 50) << '\n ';
}
2
3
4
5
6
7
8
输出:
12, 150
另一个有用的函数是std::clamp(v, min, max)
,在<algorithm>
中声明,源自P0025 (opens new window) :
// Chapter STL Other/clamp.cpp
#include <iostream>
#include <algorithm> // clamp
int main() {
std::cout << std::clamp(300, 0, 255) << ', ';
std::cout << std::clamp(-10, 0, 255) << '\n ';
}
2
3
4
5
6
7
8
输出:255, 0
此外,在<cmath>
头文件中还定义了一些新的特殊函数。
函数 | 描述 |
---|---|
assoc_laguerre | 计算其各自参数n 、m 和x 的连带拉盖尔多项式 |
assoc_legendre | 计算其各自参数l 、m 和x 的连带勒让德函数 |
beta | 计算其各自参数x 和y 的贝塔函数 |
comp_ellint_1 | 计算其各自参数k 的第一类完全椭圆积分 |
comp_ellint_2 | 计算其各自参数k 的第二类完全椭圆积分 |
comp_ellint_3 | 计算其各自参数k 和nu 的第三类完全椭圆积分 |
cyl_bessel_i | 计算其各自参数nu 和x 的正则修正圆柱贝塞尔函数 |
cyl_bessel_j | 计算其各自参数nu 和x 的第一类圆柱贝塞尔函数 |
cyl_bessel_k | 计算其各自参数nu 和x 的非正则修正圆柱贝塞尔函数 |
cyl_neumann | 计算其各自参数nu 和x 的圆柱诺伊曼函数,也称为第二类圆柱贝塞尔函数 |
ellint_1 | 计算其各自参数k 和phi (phi 以弧度为单位)的第一类不完全椭圆积分 |
ellint_2 | 计算其各自参数k 和phi (phi 以弧度为单位)的第二类不完全椭圆积分 |
ellint_3 | 计算其各自参数k 、nu 和phi (phi 以弧度为单位)的第三类不完全椭圆积分 |
expint | 计算其各自参数x 的指数积分 |
hermite | 计算其各自参数n 和x 的埃尔米特多项式 |
laguerre | 计算其各自参数n 和x 的拉盖尔多项式 |
legendre | 计算其各自参数l 和x 的勒让德多项式 |
riemann_zeta | 计算其各自参数x 的黎曼ζ函数 |
sph_bessel | 计算其各自参数n 和x 的第一类球贝塞尔函数 |
sph_legendre | 计算其各自参数l 、m 和theta (theta 以弧度为单位)的球连带勒让德函数 |
sph_neumann | 计算其各自参数n 和x 的球诺伊曼函数,也称为第二类球贝塞尔函数 |
# 额外信息
上述特殊函数在N1542 ver 3 (opens new window) 中引入。
# 共享指针和数组
在C++17之前,只有unique_ptr
能够直接处理数组(无需定义自定义删除器)。现在shared_ptr
也可以做到这一点。
std::shared_ptr<int []> ptr(new int [10]);
请注意,在C++17中std::make_shared
不支持数组。但这一问题将在C++20中得到修复(参见已被合并到C++20中的P0674 (opens new window) )。
另一个重要的注意事项是应避免使用原始数组。通常使用标准容器会更好。然而,有时你可能无法使用vector
或list
,例如在嵌入式环境中,或者在使用第三方API时。在这种情况下,你可能会得到一个指向数组的原始指针。有了C++17,你可以将这些指针封装到智能指针(std::unique_ptr
或std::shared_ptr
)中,确保内存被正确释放。
# 额外信息
查看初始提案:P0414R2 (opens new window) 。
# 非成员函数size()
、data()
和empty()
遵循C++11中关于非成员函数std::begin()
和std::end()
的设计思路,C++17引入了三个新函数。
// Chapter STL Other/non_member_functions.cpp
#include <iostream>
#include <vector>
template <class Container>
void PrintBasicInfo(const Container& cont) {
std::cout << typeid(cont).name() << '\n ';
std::cout << std::size(cont) << '\n ';
std::cout << std::empty(cont) << '\n ';
if (!std::empty(cont))
std::cout << *std::data(cont) << '\n ';
}
int main() {
std::vector<int> iv { 1, 2, 3, 4, 5 };
PrintBasicInfo(iv);
float arr[4] = { 1.1f, 2.2f, 3.3f, 4.4f };
PrintBasicInfo(arr);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
输出(来自GCC 8.2):
St6vectorIiSaIiEE
5
0
1
A4_f
4
0
1.1
2
3
4
5
6
7
8
# 额外信息
这些新函数位于<iterator>
头文件中,相关论文为N4280 (opens new window) 。
# 标准库中的constexpr
扩展
通过这一增强,你可以在constexpr
上下文中使用迭代器、std::array
以及基于范围的for
循环。
下面的示例展示了accumulate
算法的constexpr
基本实现(C++11/14/17版本的std::accumulate
不是constexpr
的):
// Chapter STL Other/constexpr_additions.cpp
#include <array>
template <typename Range, typename Func, typename T>
constexpr T SimpleAccumulate(const Range& range, Func func, T init) {
for (auto &&obj : range) { // begin/end是constexpr
init += func(obj);
}
return init;
}
constexpr int square(int i) { return i*i; }
int main() {
constexpr std::array arr{ 1, 2, 3 }; // 类推导...
// 使用constexpr lambda
static_assert(SimpleAccumulate(arr, [](int i) constexpr {
return i * i;
}, 0) == 14);
// 使用constexpr函数
static_assert(SimpleAccumulate(arr, &square, 0) == 14);
return arr[0];
}
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
C++14编译器无法编译上述示例,但在支持C++17的情况下就可以编译。
这段代码使用了几个特性:
SimpleAccumulate
是一个constexpr
模板函数,它使用范围访问(隐藏在基于范围的for
循环中)来迭代输入范围。arr
被推导为std::array<3, int>
,这里类模板推导起作用。- 代码使用了
constexpr lambda
。 - 使用了不带任何消息的
static_assert
。 - 自C++17起,
std::begin()
和std::end()
也是constexpr
的。
你还可以在关于通用语言特性的“constexpr lambda
”章节中看到constexpr
扩展的另一个示例。
每个C++标准都允许越来越多的代码成为constexpr
。在C++17中,我们可以开始在常量表达式中使用基本容器。在C++20中,我们将获得更多声明为constexpr
的标准算法。
# 额外信息
主要参考论文是P0031 - 《向反向迭代器、移动迭代器、数组和范围访问添加constexpr
修饰符的提案 (opens new window)》(Proposal to Add Constexpr Modifiers to reverse_iterator, move_iterator, array and Range Access) 。
# std::scoped_lock
在C++11和C++14中,我们有了线程库以及许多支持功能。
例如,使用std::lock_guard
,你可以以RAII(Resource Acquisition Is Initialization,资源获取即初始化)风格获取互斥锁(mutex
)的所有权并锁定它:
std::mutex m;
std::lock_guard<std::mutex> lock_one(m);
// lock_one离开作用域时解锁...
2
3
上述代码仅适用于单个互斥锁。如果你想要锁定多个互斥锁,就必须使用不同的模式,例如:
std::mutex first_mutex;
std::mutex second_mutex;
// ...
std::lock(fist_mutex, second_mutex);
std::lock_guard<std::mutex> lock_one(fist_mutex, std::adopt_lock);
std::lock_guard<std::mutex> lock_two(second_mutex, std::adopt_lock);
// ..
2
3
4
5
6
7
在C++17中,事情变得更简单了,使用std::scoped_lock
你可以同时锁定多个互斥锁。
std::scoped_lock lck(first_mutex, second_mutex);
由于兼容性问题,std::lock_guard
无法扩展为接受多个输入互斥锁,这就是需要新类型scoped_lock
的原因。
# 额外信息
你可以在P0156 (opens new window)中了解更多信息。
# 多态分配器(pmr
)
多态分配器是对标准库中标准分配器的增强。
简而言之,多态分配器遵循标准库中分配器的规则,但核心是它使用内存资源对象来执行内存管理。多态分配器包含一个指向内存资源类的指针,这就是它可以使用虚方法调度的原因。你可以在运行时更改内存资源,同时保持分配器的类型不变。
多态分配器的所有类型都位于单独的命名空间std::pmr
(PMR
代表多态内存资源,Polymorphic Memory Resource)中,在<memory_resource>
头文件中定义。
pmr
的核心元素:
std::pmr::memory_resource
:所有其他实现的抽象基类。它定义了以下纯虚方法:do_allocate
、do_deallocate
和do_is_equal
。std::pmr::polymorphic_allocator
:标准分配器的一种实现,它使用memory_resource
对象来执行内存分配和释放操作。- 通过
new_delete_resource()
和null_memory_resource()
访问的全局内存资源。 - 一组预定义的内存池资源类:
synchronized_pool_resource
unsynchronized_pool_resource
monotonic_buffer_resource
- 具有多态分配器的标准容器的模板特化,例如
std::pmr::vector
、std::pmr::string
、std::pmr::map
等。每个特化都在与相应容器相同的头文件中定义。
# 库中的其他变化
以下是对预定义内存资源的简要概述:
资源 | 描述 |
---|---|
new_delete_resource() | 一个自由函数,返回指向全局 “默认” 内存资源的指针。它使用全局的new 和delete 来管理内存 |
null_memory_resource() | 一个自由函数,返回指向全局 “空” 内存资源的指针,每次分配内存时都会抛出std::bad_alloc 异常 |
synchronized_pool_resource | 线程安全的分配器,管理不同大小的内存池。每个内存池由一组块组成,这些块被划分为大小统一的内存块 |
unsynchronized_pool_resource | 非线程安全的内存池资源 |
monotonic_buffer_resource | 非线程安全、快速的专用资源,从预分配的缓冲区获取内存,但释放时不会归还内存。值得一提的是,池资源(包括monotonic_buffer_resource )可以链式连接。因此,如果某个内存池中没有可用内存,分配器将从 “上游” 资源分配内存 |
下面是monotonic_buffer_resource
和pmr::vector
的一个简单示例:
// Chapter STL Other/pmr_monotonic_resource.cpp
#include <iostream>
#include <memory_resource>
#include <vector>
int main() {
char buffer[64] = {};
std::fill_n(std::begin(buffer), std::size(buffer) - 1, '_ ');
std::cout << buffer << '\n ';
std::pmr::monotonic_buffer_resource pool{std::data(buffer), std::size(buffer)};
std::pmr::vector<char> vec{ &pool };
for (char ch = 'a '; ch <= 'z '; ++ch)
vec.push_back(ch);
std::cout << buffer << '\n ';
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 可能的输出
aababcdabcdefghabcdefghijklmnopabcdefghijklmnopqrstuvwxyz
在上述示例中,我们使用了一个由栈上内存块初始化的单调缓冲区资源。通过使用一个简单的char buffer[]
数组,我们可以轻松打印 “内存” 的内容。向量从内存池中获取内存,如果没有更多可用空间,它将向 “上游” 资源(默认是new_delete_resource
)请求内存。该示例展示了向量在需要插入更多元素时的重新分配情况。每次向量获取更多空间,最终可以容纳所有字母。
# 更多信息
本节只是触及了多态分配器和内存资源的概念。如果你想深入了解这个主题,尼古拉·约苏蒂斯(Nicolai Josuttis)所著的《C++17完全指南》中有一整章的内容。也有一些会议演讲,例如2017年C++大会(CppCon 2017)上巴勃罗·哈尔彭(Pablo Halpern)的《分配器:精华部分 (opens new window)》。
# 额外信息
更多信息请参见P0220R1 (opens new window)和P0337R0 (opens new window)。
# 编译器支持
特性 | GCC | Clang | MSVC |
---|---|---|---|
std::byte | 7.1 | 5.0 | VS 2017 15.3 |
映射(Maps)和集合(Sets)的改进 | 7.0 | 3.9 | VS 2017 15.5 |
映射的insert_or_assign() / try_emplace() | 6.1 | 3.7 | VS 2017 15 |
emplace 返回类型 | 7.1 | 4.0 | VS 2017 15.3 |
抽样算法 | 7.1 | 开发中 | VS 2017 15 |
最大公约数(gcd )和最小公倍数(lcm ) | 7.1 | 4.0 | VS 2017 15.3 |
clamp | 7.1 | 3.9 | VS 2015.3 |
特殊数学函数 | 7.1 | 未支持 | VS 2017 15.7 |
共享指针和数组 | 7.1 | 开发中 | VS 2017 15.5 |
非成员函数size() 、data() 和empty() | 6.1 | 3.6 | VS 2015 |
标准库的constexpr 扩展 | 7.1 | 4.0 | VS 2017 15.3 |
作用域锁(scoped_lock ) | 7.1 | 5.0 | VS 2017 15.3 |
多态分配器和内存资源 | 9.1 | 开发中 | VS 2017 15.6 |