原文:
zh.annas-archive.org/md5/3cc74f1869cbe375d71d8bca644fbb25译者:飞龙
第十一章:随机数
在上一章中,你学习了正则表达式,这是一个自 C++11 以来一直是 C++标准库的一部分的功能,但许多程序员仍然不太了解。你看到正则表达式在 C++光谱的两端都很有用——在需要对复杂输入格式进行坚如磐石解析的复杂程序中,以及在需要可读性和开发速度的简单脚本中。
另一个位于这两个类别中的库特性是随机数生成。许多脚本程序需要一点随机性,但几十年来,C++程序员一直被告知经典的 libc rand() 函数已经过时。在光谱的另一端,rand() 对于密码学和复杂的数值模拟来说都是极其不合适的。然而,C++11 <random> 库却成功地实现了这三个目标。
在本章中,我们将涵盖以下主题:
-
真正随机数序列与伪随机数序列之间的区别
-
随机比特生成器与产生数据值的分布之间的区别
-
为随机数生成器设置种子的三种策略
-
几种标准库生成器和分布,以及它们的用例
-
如何在 C++17 中洗牌一副牌
随机数与伪随机数
在计算机编程的语境中谈论随机数时,我们必须小心地区分真正随机的数,这些数来自物理上非确定性的来源,以及伪随机数,这些数来自一个算法,该算法以确定性的方式产生一系列“看起来随机”的数。这样的算法被称为伪随机数生成器(PRNG)。每个 PRNG 在概念上都以相同的方式工作——它有一些内部状态,并且有一些方式让用户请求下一个输出。每次我们请求下一个输出时,PRNG 都会根据某种确定性的算法打乱其内部状态,并返回该状态的一部分。以下是一个例子:
template<class T>
class SimplePRNG {
uint32_t state = 1;
public:
static constexpr T min() { return 0; }
static constexpr T max() { return 0x7FFF; }
T operator()() {
state = state * 1103515245 + 12345;
return (state >> 16) & 0x7FFF;
}
};
这个 SimplePRNG 类实现了一个线性同余生成器,这可能与你的标准库中 rand() 的实现非常相似。请注意,SimplePRNG::operator() 产生 [0, 32767] 15 位范围内的整数,但其内部 state 有 32 位范围。这种模式在现实世界的 PRNG 中也是成立的。
例如,标准的梅森旋转算法几乎保持 20 千字节的状态!保持如此多的内部状态意味着有很多位可以混淆,并且每次生成时只有 PRNG 内部状态的一小部分泄露出来。这使得人类(或计算机)在只有少量先前输出的情况下难以预测 PRNG 的下一个输出。预测其输出的难度使我们称这个为伪随机数生成器。如果其输出充满了明显的模式和易于预测,我们可能会称其为非随机数生成器!
尽管具有伪随机的特性,PRNG 的行为始终是完美的确定性;它严格遵循其编码的算法。如果我们运行一个使用 PRNG 的程序并连续运行几次,我们期望每次都能得到完全相同的伪随机数序列。它的严格确定性使我们称这个为伪-随机数生成器。
假随机数生成器的一个方面是,两个运行相同算法但初始状态有微小差异的生成器会迅速放大这些差异,发散彼此,并产生看起来完全不同的输出序列——就像两滴水被放在你手背上的不同位置,会向完全不同的方向流去。这意味着如果我们想在每次运行程序时得到不同的伪随机数序列,我们只需确保我们为我们的 PRNG 使用不同的初始状态。设置 PRNG 的初始状态被称为播种PRNG。
我们至少有三种为 PRNG 播种的策略:
-
使用从外部提供的种子*——*来自调用者或最终用户。这对于需要可重复性的任何事物都最合适,例如蒙特卡洛模拟或任何需要进行单元测试的事物。
-
使用可预测但可变的种子,例如当前时间戳。在 C++11 之前,这是最常见的策略,因为 C 标准库提供了一个便携且方便的
time函数,但它不提供任何真正的随机位源。基于像time这样可预测的东西进行播种不适合任何与安全相关的事物。从 C++11 开始,你不应该再使用这种策略。 -
使用从某些特定平台来源直接获得的真正随机种子。
真正随机的位是通过操作系统基于各种随机事件收集的;一个经典的方法是对于每个系统调用,收集硬件周期计数器的低阶位,并将它们通过 XOR 操作合并到操作系统的熵池中。内核内部的伪随机数生成器(PRNG)会定期用熵池中的位重新初始化;该 PRNG 的输出序列被暴露给应用程序开发者。在 Linux 上,原始的熵池作为/dev/random暴露,PRNG 的输出序列作为/dev/urandom暴露。幸运的是,你永远不需要直接处理这些设备;C++标准库已经为你解决了这个问题。请继续阅读。
rand()的问题
传统的 C 语言生成随机数的方法是调用rand()函数。这个rand()函数仍然是 C++的一部分,它不接受任何参数,并在[0, RAND_MAX]范围内产生一个单一、均匀分布的整数。内部状态可以通过调用库函数void srand(unsigned int seed_value)来初始化。
自 1980 年代以来,生成[0, x)范围内的随机数的经典代码没有变化,如下所示:
#include <stdlib.h>
int randint0(int x) {
return rand() % x;
}
然而,这段代码有几个问题。第一个也是最明显的问题是它没有以相等的可能性生成所有的x输出。假设为了论证,rand()返回一个在[0, 32767]范围内的均匀分布值,那么randint0(10)将比返回8或9更频繁地返回[0, 7]范围内的每个值,频率是 1/3276。
第二个问题是rand()访问全局状态;在 C++程序中的每个线程都共享同一个随机数生成器。这不是线程安全的问题–rand()自 C++11 以来被保证是线程安全的。然而,这是一个性能问题(因为每次调用rand()都必须获取全局互斥锁),这也是一个可重复性问题(因为如果你从多个线程并发使用rand(),不同的程序运行可能会得到不同的结果)。
rand()函数的第三个问题,也是与其全局状态相关的问题,是任何程序中的函数都可以通过调用rand()来修改该状态。这使得在单元测试驱动的环境中使用rand()变得实际上是不可能的。考虑以下代码片段:
int heads(int n) {
DEBUG_LOG("heads");
int result = 0;
for (int i = 0; i < n; ++i) {
result += (rand() % 2);
}
return result;
}
void test_heads() {
srand(17); // nail down the seed
int result = heads(42);
assert(result == 27);
}
显然,单元测试 test_heads 将在开始并行化单元测试时立即中断(因为来自其他线程对 rand() 的调用将干扰这个测试的微妙工作)。然而,更微妙的是,它也可能因为有人更改了 DEBUG_LOG 的实现,添加或删除对 rand() 的调用而中断!这种 遥远的神秘作用 是任何依赖于全局变量的架构的问题。我们在第八章 分配器中看到了类似的危险。在每种情况下,我强烈推荐的治疗方法都是相同的–不要使用全局变量。不要使用全局状态。
因此,C 库有两个问题–它没有提供生成真正均匀分布的伪随机数的方法,并且它从根本上依赖于全局变量。让我们看看 C++ 标准库的 <random> 头文件是如何解决这两个问题的。
使用 <random> 解决问题
<random> 头文件提供了两个核心概念–生成器 和 分布。一个 生成器(一个模拟 UniformRandomBitGenerator 概念的类)将 PRNG 的内部状态封装到一个 C++ 对象中,并提供了一个以函数调用操作符 operator()(void) 形式的下一个输出成员函数。一个 分布(一个模拟 RandomNumberDistribution 的类)是你可以在生成器的输出上放置的一种过滤器,这样你得到的不是像从 rand() 得到的均匀分布的随机位,而是根据指定的数学分布实际数据值分布,并限制在特定范围内,如 rand() % n,但更数学上合适且具有更大的灵活性。
<random> 头文件包含总共七种 生成器 类型以及二十种 分布 类型。其中大部分是模板,需要很多参数。这些生成器中大多数比实际应用更有历史意义,而大多数分布只对数学家感兴趣。因此,在本章中,我们将专注于几个标准的生成器和分布,每个都展示了标准库的一些有趣之处。
处理生成器
对于任何 生成器 对象 g,你可以对其执行以下操作:
-
g(): 这会打乱生成器的内部状态并产生下一个输出。 -
g.min(): 这告诉你g()的最小可能输出(通常是0)。 -
g.max(): 这告诉你g()的最大可能输出。也就是说,g()的可能输出范围是从g.min()到g.max(),包括两端。可能的输出范围是
g.min()到g.max(),包括g.max()。 -
g.discard(n): 这实际上是对g()进行n次调用并丢弃这些结果。结果。在一个好的库实现中,你将支付打乱生成器内部状态
n次的费用,但节省与从状态计算下一个输出相关的任何成本。
使用 std::random_device 的真正随机位
std::random_device 是一个 生成器。它的接口极其简单;它甚至不是一个类模板,而是一个普通的类。一旦你使用其默认构造函数构造了一个 std::random_device 的实例,你就可以使用其重载的调用操作符来获取类型为 unsigned int 的值,这些值在闭区间 [rd.min(), rd.max()] 内均匀分布。
一个需要注意的地方是,std::random_device 并不完全符合 UniformRandomBitGenerator 的概念。最重要的是,它既不可复制也不可移动。在实践中,这并不是一个大问题,因为你通常不会长时间保留一个 真正 随机的生成器。相反,你会使用一个短暂的 std::random_device 实例来为某种类型的长期伪随机生成器生成一个 种子,如下所示:
std::random_device rd;
unsigned int seed = rd();
assert(rd.min() <= seed && seed <= rd.max());
现在我们来看看你唯一需要了解的伪随机生成器。
使用 std::mt19937 的伪随机位
你唯一需要了解的伪随机生成器被称为 梅森旋转器 算法。这个算法自 1997 年以来就为人所知,在任何编程语言中的高质量实现都很容易找到。从技术上讲,梅森旋转器算法定义了一个相关 PRNG 的整个家族–它是 C++模板的算法等价物–但这个家族中最常用的成员被称为 MT19937。这一串数字可能看起来像时间戳,但并非如此;它是旋转器内部状态的大小(以位为单位)。因为梅森旋转器的下一个输出函数完美地打乱了其状态,它最终会达到(除了一个之外)所有可能的状态,然后再回到开始–MT19937 生成器的 周期 是 2¹⁹⁹³⁷-1。与此相比,我们本章开头的 SimplePRNG 只有一个 32 位的内部状态和一个周期为 2³¹。(我们的 SimplePRNG 生成器有 2³² 种可能的内部状态,但在它再次循环之前,只有一半的状态被达到。例如,state=3 从初始的 state=1 是无法到达的。)
理论已经足够。让我们看看梅森旋转器在实际中的应用!对应于梅森旋转器 算法模板 的 C++类模板是 std::mersenne_twister_engine<...>,但你不会直接使用它;你将使用便利的 typedef std::mt19937,如下所示:
std::mt19937 g;
assert(g.min() == 0 && g.max() == 4294967295);
assert(g() == 3499211612);
assert(g() == 581869302);
assert(g() == 3890346734);
std::mt19937 的默认构造函数将其内部状态设置为众所周知的标准值。这确保了从默认构造的 mt19937 对象获得的输出序列在所有平台上都是相同的–与 rand() 相比,rand() 在不同平台上往往给出不同的输出序列。
要获得不同的输出序列,你需要向std::mt19937的构造函数提供一个种子。 在 C++17 中有两种方法–繁琐的方法和简单的方法。 繁琐的方法是构建一个真正的 19937 位种子,并将其通过一个种子序列复制到std::mt19937对象中,如下所示:
std::random_device rd;
uint32_t numbers[624];
std::generate(numbers, std::end(numbers), std::ref(rd));
// Generate initial state.
SeedSeq sseq(numbers, std::end(numbers));
// Copy our state into a heap-allocated "seed sequence".
std::mt19937 g(sseq);
// Initialize a mt19937 generator with our state.
在这里,SeedSeq类型可以是std::seed_seq(一个被美化的std::vector;它使用堆分配)或者一个正确编写的“种子序列”类,如下所示:
template<class It>
struct SeedSeq {
It begin_;
It end_;
public:
SeedSeq(It begin, It end) : begin_(begin), end_(end) {}
template<class It2>
void generate(It2 b, It2 e) {
assert((e - b) <= (end_ - begin_));
std::copy(begin_, begin_ + (e - b), b);
}
};
当然,仅仅为了构建一个单一的 PRNG 对象就需要写这么多代码! (我告诉过你,这是繁琐的方法。) 简单的方法,也是你将在实践中看到的方法,是将 MT19937 用单个真正的32 位整数进行初始化,如下所示:
std::random_device rd;
std::mt19937 g(rd());
// 32 bits of randomness ought to be enough for anyone!
// ...Right?
警惕!32 比 19937 小得多!这种简单的初始化方法只能产生 40 亿种不同的输出序列,永远;这意味着如果你用随机种子反复运行你的程序,你可以在运行了几十万次之后看到一些重复。 (这是著名的生日悖论的应用。) 然而,如果你认为这种可预测性很重要,你可能还应该知道,梅森旋转器不是密码学安全的。 这意味着即使你用真正的 19937 位种子序列初始化它,恶意攻击者也可以逆向工程你的原始种子中的所有 19937 位,并在只看到输出序列的几百项之后,完美准确地预测后续的每个输出。 如果你需要一个密码学安全的伪随机数生成器(CSPRNG),你应该使用类似 AES-CTR 或 ISAAC 的东西,这两种东西都不是 C++标准库提供的。 你仍然应该将你的 CSPRNG 实现包装在一个模拟UniformRandomBitGenerator的类中,这样它就可以与标准算法一起使用,我们将在本章末尾讨论这一点。
使用适配器过滤生成器输出
我们提到,生成器的原始输出通常需要通过单个分布进行过滤,以便将生成器的原始比特转换为可用的数据值。有趣的是,也可以将生成器的输出通过一个生成器适配器发送,该适配器可以以各种可能有用的方式重新格式化原始比特。标准库提供了三种适配器–std::discard_block_engine、std::shuffle_order_engine和std::independent_bits_engine。这些适配器类型的工作方式与我们在第四章“容器动物园”中讨论的容器适配器(如std::stack)类似–它们提供一定的接口,但将大部分实现细节委托给其他某个类。
std::discard_block_engine<Gen, p, r>的一个实例保留了一个类型为Gen的底层生成器,并将所有操作委托给该底层生成器,除了discard_block_engine::operator()将只返回底层生成器每p个输出中的前r个。例如,考虑以下示例:
std::vector<uint32_t> raw(10), filtered(10);
std::discard_block_engine<std::mt19937, 3, 2> g2;
std::mt19937 g1 = g2.base();
std::generate(raw.begin(), raw.end(), g1);
std::generate(filtered.begin(), filtered.end(), g2);
assert(raw[0] == filtered[0]);
assert(raw[1] == filtered[1]);
// raw[2] doesn't appear in filtered[]
assert(raw[3] == filtered[2]);
assert(raw[4] == filtered[3]);
// raw[5] doesn't appear in filtered[]
注意,可以通过g2.base()检索底层生成器的引用。在上面的示例中,g1被初始化为g2.base()的一个副本;这解释了为什么调用g1()不会影响g2的状态,反之亦然。
std::shuffle_order_engine<Gen, k>的一个实例保留其底层生成器最后k个输出的缓冲区,以及一个额外的整数Y。每次调用
shuffle_order_engine::operator()将Y = buffer[Y % k]设置为buffer[Y] = base()()。(从Y计算缓冲区索引的公式实际上比简单的模运算更复杂,但它基本上有相同的效果。)值得注意的是,std::shuffle_order_engine并不使用std::uniform_int_distribution将Y映射到0, k)范围。这不会影响生成器输出的随机性——如果底层生成器已经是伪随机的话,稍微打乱其输出并不会使它们变得更加或更少随机,无论我们使用什么算法来进行打乱。因此,shuffle_order_engine使用的算法是专门挑选的,因为它具有历史兴趣——它是唐纳德·克努特在《计算机程序设计艺术》中描述的经典算法的一个构建块:
using knuth_b = std::shuffle_order_engine<
std::linear_congruential_engine<
uint_fast32_t, 16807, 0, 2147483647
>,
256
>;
std::independent_bits_engine<Gen, w, T>的一个实例除了其底层生成器类型为Gen之外,不保留任何状态。independent_bits_engine::operator()函数调用base()()足够多次以计算至少w个随机位;然后,它通过一个比实际应用更有历史意义的算法精确地拼接这些位,并将它们作为类型为T的无符号整数提供。 (如果T不是无符号整数类型,或者T的位数少于w位,则是一个错误。)
以下是一个independent_bits_engine从多个base()()调用中拼接位的示例:
std::independent_bits_engine<std::mt19937, 40, uint64_t> g2;
std::mt19937 g1 = g2.base();
assert(g1() == 0xd09'1bb5c); // Take "1bb5c"...
assert(g1() == 0x22a'e9ef6); // and "e9ef6"...
assert(g2() == 0x1bb5c'e9ef6); // Paste and serve!
以下是一个使用independent_bits_engine从mt19937的输出中移除所有但最低有效位(创建一个翻转生成器)的示例,然后,将这个生成器的 32 个输出拼接起来,以重建一个 32 位生成器:
using coinflipper = std::independent_bits_engine<
std::mt19937, 1, uint8_t>;
coinflipper onecoin;
std::array<int, 64> results;
std::generate(results.begin(), results.end(), onecoin);
assert((results == std::array<int, 64>{{
0,0,0,1, 0,1,1,1, 0,1,1,1, 0,0,1,0,
1,0,1,0, 1,1,1,1, 0,0,0,1, 0,1,0,1,
1,0,0,1, 1,1,1,0, 0,0,1,0, 1,0,1,0,
1,0,0,1, 0,0,0,0, 0,1,0,0, 1,1,0,0,
}}));
std::independent_bits_engine<coinflipper, 32, uint32_t> manycoins;
assert(manycoins() == 0x1772af15);
assert(manycoins() == 0x9e2a904c);
注意,independent_bits_engine对其底层生成器的位不执行任何复杂的操作;特别是,它假设其底层生成器没有偏差。如果WeightedCoin生成器倾向于偶数。你将看到这种偏差也会在independent_bits_engine<WeightedCoin, w, T>的输出中体现出来。
尽管我们花费了数页的篇幅来讨论这些生成器,但请记住,在你的代码中没有任何理由使用这些神秘的类!如果你需要一个伪随机数生成器,请使用 std::mt19937;如果你需要一个加密安全的伪随机数生成器,请使用类似 AES-CTR 或 ISAAC 的东西;如果你需要相对较少的真正随机位来为你的伪随机数生成器设置种子,请使用 std::random_device。这些是你将在实践中唯一使用的生成器。
处理分布
现在我们已经看到了如何按需生成随机位,让我们看看如何将这些随机位转换为匹配特定 分布 的数值。这个两步过程–生成原始位,然后将它们格式化为数据值–与我们前面在 [第九章 中介绍的缓冲和解析的两步过程非常相似,即 Iostreams。首先,获取原始位和字节,然后执行某种操作将这些位和字节转换为类型化的数据值。
对于任何分布对象 dist,你可以对其执行以下操作:
-
dist(g): 这将根据适当的数学分布产生下一个输出。这可能需要多次调用g(),或者根本不需要,这取决于dist对象的内部状态。 -
dist.reset(): 这将清除dist对象的内部状态(如果有的话)。你永远不会需要使用这个成员函数。 -
dist.min()和dist.max(): 这些告诉你dist(g)对于任何随机位生成器g的最小和最大可能输出。通常,这些值要么是显而易见的,要么是没有意义的;例如,std::normal_distribution<float>().max()是INFINITY。
让我们看看几个分布类型在实际中的应用。
使用 uniform_int_distribution 投掷骰子
std::uniform_int_distribution 方法是标准库中最简单的分布类型。它执行的操作与我们本章前面尝试用 randint0 执行的操作相同–将一个随机无符号整数映射到给定的范围中–但它没有任何偏差。uniform_int_distribution 的最简单实现看起来可能像这样:
template<class Int>
class uniform_int_distribution {
using UInt = std::make_unsigned_t<Int>;
UInt m_min, m_max;
public:
uniform_int_distribution(Int a, Int b) :
m_min(a), m_max(b) {}
template<class Gen>
Int operator()(Gen& g) {
UInt range = (m_max - m_min);
assert(g.max() - g.min() >= range);
while (true) {
UInt r = g() - g.min();
if (r <= range) {
return Int(m_min + r);
}
}
}
};
实际的标准库实现必须做一些事情来消除那个 assert。通常,他们会使用类似 independent_bits_engine 的东西来一次生成正好 ceil(log2(range)) 个随机位,从而最小化 while 循环需要运行的次数。
如前例所示,uniform_int_distribution 是无状态的(尽管这并不是 技术上 保证的),因此最常见的使用方式是在每次生成数字时创建一个新的分布对象。因此,我们可以像这样实现我们的 randint0 函数:
int randint0(int x) {
static std::mt19937 g;
return std::uniform_int_distribution<int>(0, x-1)(g);
}
现在可能是时候指出 <random> 库的一些奇怪之处了。一般来说,每次你向这些函数或构造函数提供一个 整数数值范围 时,它被视为一个 闭区间。这与 C 和 C++ 中范围通常的工作方式形成鲜明对比;我们甚至在 第三章,迭代器对算法 中看到,偏离 半开区间 规则通常是代码有问题的标志。然而,在 C++ 随机数库的情况下,有一条新的规则–闭区间 规则。为什么?
好吧,半开区间的关键优势是它可以轻松地表示一个 空区间。另一方面,半开区间不能表示一个 完全满的区间,也就是说,一个覆盖整个域的区间。(我们在 第四章,容器动物园 的实现中看到了这个问题。)假设我们想要表达在整个 long long 范围上均匀分布的概念。我们不能将其表示为半开区间 [LLONG_MIN, LLONG_MAX+1),因为 LLONG_MAX+1 会溢出。然而,我们可以将其表示为闭区间 [LLONG_MIN, LLONG_MAX]–因此,这就是 <random> 库的函数和类(如 uniform_int_distribution)所做的事情。《uniform_int_distribution(0,6)方法是在[0,6]七个数范围内的分布,而uniform_int_distribution(42,42)是一个完全有效的分布,总是返回42`。
另一方面,std::uniform_real_distribution<double>(a, b) 确实 在一个半开区间上操作!std::uniform_real_distribution<double>(0, 1) 方法产生类型为 double 的值,在 0, 1) 范围内均匀分布。在浮点数域中,没有溢出问题–[0, INFINITY) 的半开区间实际上是可以表示的,尽管当然,在无限范围内不存在 均匀分布。浮点数也使得很难区分半开区间和闭区间;例如,std::uniform_real_distribution<float>(0, 1)(g) 可以合法地返回 float(1.0),只要它生成的随机实数足够接近 1,以至于每 2²⁵ 个结果中大约有一个会被四舍五入。 (在出版时,libc++ 的行为如上所述。GNU 的 libstdc++ 应用了一个补丁,使得接近 1 的实数向下而不是向上舍入,因此略低于 1.0 的浮点数出现的频率略高于随机预测。)
使用 normal_distribution 生成种群
实值分布最有用的例子可能是正态分布,也称为钟形曲线。在现实世界中,正态分布无处不在,尤其是在一个群体中物理特征的分布中。例如,成年人类身高的直方图往往会呈现出正态分布——许多个体围绕着平均身高聚集,其他人则向两边延伸。反过来,这意味着你可能想要使用正态分布来为游戏中的模拟个体分配身高、体重等。
std::normal_distribution<double>(m, sd) 方法构建了一个具有均值(m)和标准差(sd)的 normal_distribution<double> 实例。(如果你没有提供这些参数,这些参数默认为 m=0 和 sd=1,所以要注意拼写错误!)以下是一个使用 normal_distribution 创建 10,000 个正态分布样本的“人口”,然后通过数学方法验证其分布的示例:
double mean = 161.8;
double stddev = 6.8;
std::normal_distribution<double> dist(mean, stddev);
// Initialize our generator.
std::mt19937 g(std::random_device{}());
// Fill a vector with 10,000 samples.
std::vector<double> v;
for (int i=0; i < 10000; ++i) {
v.push_back( dist(g) );
}
std::sort(v.begin(), v.end());
// Compare expectations with reality.
auto square = [ { return x*x; };
double mean_of_values = std::accumulate(
v.begin(), v.end(), 0.0) / v.size();
double mean_of_squares = std::inner_product(
v.begin(), v.end(), v.begin(), 0.0) / v.size();
double actual_stddev =
std::sqrt(mean_of_squares - square(mean_of_values));
printf("Expected mean and stddev: %g, %g\n", mean, stddev);
printf("Actual mean and stddev: %g, %g\n",
mean_of_values, actual_stddev);
与本章中(或将要看到的)的其他分布不同,std::normal_distribution 是有状态的。虽然为每个生成的值构造一个新的 std::normal_distribution 实例是可以的,但如果你这样做,实际上会减半你程序的效率。这是因为生成正态分布值的最流行算法每次产生两个独立值;std::normal_distribution 不能一次给你两个值,所以它会将其中一个值保留在成员变量中,以便下次请求时提供给你。可以使用 dist.reset() 成员函数清除这个保存的状态,尽管你永远不会想这样做。
使用 discrete_distribution 进行加权选择
std::discrete_distribution<int>(wbegin, wend) 方法在 [0, wend - wbegin) 的半开区间上构建一个离散的或加权的分布。以下示例可以最容易地解释这一点:
template<class Values, class Weights, class Gen>
auto weighted_choice(const Values& v, const Weights& w, Gen& g)
{
auto dist = std::discrete_distribution<int>(
std::begin(w), std::end(w));
int index = dist(g);
return v[index];
}
void test() {
auto g = std::mt19937(std::random_device{}());
std::vector<std::string> choices =
{ "quick", "brown", "fox" };
std::vector<int> weights = { 1, 7, 2 };
std::string word = weighted_choice(choices, weights, g);
// 7/10 of the time, we expect word=="brown".
}
std::discrete_distribution<int> 方法会将其传入的权重在自己的私有成员变量 std::vector<double> 中创建一个内部副本(并且,像 <random> 中的常规操作一样,它不是分配器感知的)。你可以通过调用 dist.probabilities() 来获取这个向量的副本,如下所示:
int w[] = { 1, 0, 2, 1 };
std::discrete_distribution<int> dist(w, w+4);
std::vector<double> v = dist.probabilities();
assert((v == std::vector{ 0.25, 0.0, 0.50, 0.25 }));
你可能不想直接在自己的代码中使用 discrete_distribution;最好的办法是将它的使用封装在类似前面的 weighted_choice 函数中。然而,如果你需要避免堆分配或浮点运算,使用一个更简单的不分配函数可能更有利,如下所示:
template<class Values, class Gen>
auto weighted_choice(
const Values& v, const std::vector<int>& w,
Gen& g)
{
int sum = std::accumulate(w.begin(), w.end(), 0);
int cutoff = std::uniform_int_distribution<int>(0, sum - 1)(g);
auto vi = v.begin();
auto wi = w.begin();
while (cutoff > *wi) {
cutoff -= *wi++;
++vi;
}
return *vi;
}
然而,discrete_distribution 的默认库实现之所以将其所有数学运算作为浮点数进行,是因为它为你节省了担心整数溢出的麻烦。如果 sum 超出了 int 的范围,前面的代码将会有不良行为。
使用 std::shuffle 洗牌
让我们通过查看std::shuffle(a,b,g)来结束这一章,这是唯一一个接受随机数生成器作为输入的标准算法。根据第三章的定义,它是一个排列算法–它接受一个元素范围 [a,b) 并对其进行洗牌,保留其值但不保留其位置。
std::shuffle(a,b,g)方法是在 C++11 中引入的,用于取代旧的std::random_shuffle(a,b)算法。那个旧的算法“随机”地洗牌 [a,b) 范围,但没有指定随机性的来源;在实践中,这意味着它将使用全局 C 库的rand(),并带来所有相关问题。一旦 C++11 通过<random>引入了关于随机数生成器的标准化方法,就到了摆脱基于旧rand()的random_shuffle的时候了;并且,截至 C++17,std::random_shuffle(a,b)不再是 C++标准库的一部分。
这是我们可以如何使用 C++11 的std::shuffle来洗牌一副扑克牌的方法:
std::vector<int> deck(52);
std::iota(deck.begin(), deck.end(), 1);
// deck now contains ints from 1 to 52.
std::mt19937 g(std::random_device{}());
std::shuffle(deck.begin(), deck.end(), g);
// The deck is now randomly shuffled.
回想一下,<random>中的每个生成器都是完全指定的,例如,使用固定值初始化的std::mt19937实例将在每个平台上产生完全相同的输出。对于像uniform_real_distribution这样的分布,以及shuffle算法,情况并非如此。从 libc++切换到 libstdc++,或者只是升级编译器,可能会导致你的std::shuffle行为发生变化。
9 different shuffles--out of the 8 × 1067 ways, you can shuffle a deck of cards by hand! If you were shuffling cards for a real casino game, you'd certainly want to use the "tedious" method of seeding, described earlier in this chapter, or--simpler, if performance isn't a concern--just use std::random_device directly:
std::random_device rd;
std::shuffle(deck.begin(), deck.end(), rd);
// The deck is now TRULY randomly shuffled.
无论你使用什么生成器和初始化方法,你都可以直接将其插入到std::shuffle中。这是标准库对随机数生成可组合方法的好处。
摘要
标准库提供了两个与随机数相关的概念–生成器和分布。生成器是有状态的,必须进行初始化,并通过operator()(void)产生无符号整数输出(原始比特)。两种重要的生成器类型是std::random_device,它产生真正的随机比特,以及std::mt19937,它产生伪随机比特。
分布通常是无状态的,并通过operator()(Gen&)产生数值数据。对于大多数程序员来说,最重要的分布类型将是std::uniform_int_distribution<int>(a,b),它产生闭区间 [a,b] 内的整数。标准库还提供了其他分布,例如std::uniform_real_distribution、std::normal_distribution和std::discrete_distribution,以及许多对数学家和统计学家有用的神秘分布。
使用随机性的唯一标准算法是std::shuffle,它取代了旧式的std::random_shuffle。不要在新代码中使用random_shuffle。
注意,std::mt19937在所有平台上具有完全相同的行为,但任何分布类型,以及std::shuffle,情况并非如此。
第十二章:文件系统
C++17 最大的新特性之一是其 <filesystem> 库。这个库,就像现代 C++ 的许多其他主要特性一样,起源于 Boost 项目。2015 年,它成为了一个标准技术规范以收集反馈,最终,根据这些反馈进行了一些修改后,被合并到 C++17 标准中。
在本章中,你将学习以下内容:
-
<filesystem>如何返回动态类型错误而不抛出异常,以及你如何也能做到 -
路径 的格式,以及 POSIX 和 Windows 在这个问题上的根本不兼容立场
-
如何使用可移植的 C++17 来获取文件状态和遍历目录
-
如何创建、复制、重命名和删除文件和目录
-
如何获取文件系统的空闲空间
关于命名空间的一些说明
标准的 C++17 文件系统功能都包含在一个单独的头文件中,即 <filesystem>,并且该头文件中的所有内容都放置在其自己的命名空间中:namespace std::filesystem。这遵循了 C++11 的 <chrono> 头文件及其 namespace std::chrono 所设定的先例。(本书省略了对 <chrono> 的全面介绍。它与 std::thread 和 std::timed_mutex 的交互在 第七章,并发 中简要介绍。)
这种命名空间策略意味着当你使用 <filesystem> 功能时,你将使用诸如 std::filesystem::directory_iterator 和 std::filesystem::temp_directory_path() 这样的标识符。这些完全限定名称相当难以处理!但是,使用 using 声明将整个命名空间拉入当前上下文可能是一种过度行为,尤其是如果你需要在文件作用域中这样做。在过去十年中,我们都被告知永远不要写 using namespace std,而且无论标准库的命名空间嵌套有多深,这条建议都不会改变。考虑以下代码:
using namespace std::filesystem;
void foo(path p)
{
remove(p); // What function is this?
}
对于日常用途来说,更好的解决方案是在文件作用域(在.cc文件中)或命名空间作用域(在.h文件中)定义一个命名空间别名。命名空间别名允许你通过一个新名称来引用现有的命名空间,如下面的示例所示:
namespace fs = std::filesystem;
void foo(fs::path p)
{
fs::remove(p); // Much clearer!
}
在本章的剩余部分,我将使用命名空间别名 fs 来引用 namespace std::filesystem。当我提到 fs::path 时,我的意思是 std::filesystem::path。当我提到 fs::remove 时,我的意思是 std::filesystem::remove。
在某个全局位置定义一个命名空间别名 fs 也有另一个实用的好处。截至出版时,在所有主要的库供应商中,只有 Microsoft Visual Studio 声称已经实现了 C++17 <filesystem> 头文件。然而,<filesystem> 提供的功能与 <experimental/filesystem> 中由 libstdc++ 和 libc++ 提供的功能以及 Boost 中的 <boost/filesystem.hpp> 非常相似。因此,如果你始终通过自定义命名空间别名,如 fs,来引用这些功能,你将能够通过更改该别名的目标来从一家供应商的实现切换到另一家——只需一行更改,而不是在整个代码库上进行大量且容易出错的搜索和替换操作。这可以在以下示例中看到:
#if USE_CXX17
#include <filesystem>
namespace fs = std::filesystem;
#elif USE_FILESYSTEM_TS
#include <experimental/filesystem>
namespace fs = std::experimental::filesystem;
#elif USE_BOOST
#include <boost/filesystem.hpp>
namespace fs = boost::filesystem;
#endif
关于错误报告的非常长的笔记
C++ 与错误报告有着爱恨交加的关系。在这里,“错误报告”指的是“当你无法完成所要求的事情时,应该怎么做”。在 C++ 中,报告这类“失望”的经典、典型且至今仍被视为最佳实践的方法是抛出一个异常。我们在前面的章节中看到,有时抛出异常是唯一合理的做法,因为没有其他方式可以返回调用者。例如,如果你的任务是构建一个对象,而构建失败,你无法返回;当构造函数失败时,唯一相同的行动就是抛出异常。然而,我们也已经看到(在第九章 Chapter 9*,Iostreams*),C++ 自身的 <iostream> 库并没有采取这种理智的行动!如果一个 std::fstream 对象的构建失败(因为无法打开指定的文件),你会得到一个异常;你将得到一个完全构建的 fstream 对象,其中 f.fail() && !f.is_open()。
我们在第九章Chapter 9,Iostreams 中给出的理由是 fstream 的“不良”行为是“相对较高的文件无法打开的可能性”。每次文件无法打开时都抛出异常,这让人不舒服地接近使用异常进行控制流,这是我们被正确教导要避免的。因此,而不是强迫程序员在所有地方都编写 try 和 catch 块,库返回操作似乎已成功完成,但允许用户检查(使用正常的 if,而不是 catch)操作是否真的成功了。
也就是说,我们可以避免编写以下繁琐的代码:
try {
f.open("hello.txt");
// Opening succeeded.
} catch (const std::ios_base::failure&) {
// Opening failed.
}
相反,我们可以简单地写这个:
f.open("hello.txt");
if (f.is_open()) {
// Opening succeeded.
} else {
// Opening failed.
}
当操作的结果可以用一个重型的对象(如 fstream)描述,该对象具有自然的 失败 状态,或者在设计阶段可以添加这样的 失败 状态时,iostreams 方法工作得相当好。然而,它也有一些缺点,如果没有涉及重型的类型,则根本不能使用。我们在 第九章 的末尾看到了这种情况,iostreams,当时我们讨论了从字符串中解析整数的方法。如果我们不期望失败,或者不介意“使用异常进行控制流”的性能损失,那么我们使用 std::stoi:
// Exception-throwing approach.
try {
int i = std::stoi(s);
// Parsing succeeded.
} catch (...) {
// Parsing failed.
}
如果我们需要 C++03 的可移植性,我们使用 strtol,它通过线程局部全局变量 errno 报告错误,如以下代码所示:
char *endptr = nullptr;
errno = 0;
long i = strtol(s, &endptr, 10);
if (endptr != s && !errno) {
// Parsing succeeded.
} else {
// Parsing failed.
}
而在 bleeding-edge C++17 风格中,我们使用 std::from_chars,它返回一个包含字符串结束指针和表示成功或失败的强枚举类型 std::errc 的轻量级结构体,如下所示:
int i = 0;
auto [ptr, ec] = std::from_chars(s, end(s), i);
if (ec != std::errc{}) {
// Parsing succeeded.
} else {
// Parsing failed.
}
<filesystem> 库在错误报告方面的容量大约与 std::from_chars 相同。几乎你可以在文件系统中进行的任何操作都可能因为系统上运行的其他进程的操作而失败;因此,每次失败时抛出异常(类似于 std::stoi)似乎与使用异常进行控制流非常接近。但是,将“错误结果”如 ec 在整个代码库中传递也可能很繁琐,并且(不是字面意义上的)容易出错。因此,标准库决定既要吃蛋糕又要吃蛋糕,为 <filesystem> 头文件中的几乎每个函数都提供了 两个接口!
例如,以下是在磁盘上确定文件大小的两个 <filesystem> 函数:
uintmax_t file_size(const fs::path& p);
uintmax_t file_size(const fs::path& p,
std::error_code& ec) noexcept;
前面的两个函数都接受一个 fs::path(我们将在本章后面进一步讨论),并返回一个 uintmax_t,表示以字节为单位命名的文件的大小。但如果文件不存在,或者文件存在,但当前用户账户没有查询其大小的权限呢?那么,第一个重载将简单地 抛出一个异常,类型为 fs::filesystem_error,指示出了什么问题。但第二个重载永远不会抛出(实际上,它被标记为 noexcept)。相反,它接受一个类型为 std::error_code 的输出参数,库将填充一个指示出错的指示(如果没有出错,则清除)。
比较一下 fs::file_size 和 std::from_chars 的签名,你可能会注意到 from_chars 处理的是 std::errc,而 file_size 处理的是 std::error_code。这两个类型虽然相关,但并不相同!要理解这种差异——以及非抛出 <filesystem> API 的整个设计——我们不得不快速浏览一下 C++11 标准库的另一个部分。
使用 <system_error>
std::from_chars 和 fs::file_size 的错误报告机制之间的区别在于它们固有的复杂性。from_chars 可以以两种方式失败——要么给定的字符串根本没有任何初始数字字符串,要么有太多的数字,以至于读取它们会导致溢出。在前一种情况下,报告错误的一个经典(但效率低下且通常危险的)方法是将 errno 设置为 EINVAL(并返回一些无用的值,如 0)。在后一种情况下,一个经典的方法是将 errno 设置为 ERANGE(并返回一些无用的值)。这大致上是(但远不如前者)strtol 所采取的方法。
突出的要点是,使用 from_chars 时,可能出错的两种情况是完全可以由 POSIX <errno.h> 提供的单个错误代码集来描述的。因此,为了将 1980 年代的 strtol 带入 21 世纪,我们只需要修复使其直接将错误代码返回给调用者,而不是通过线程局部 errno 间接返回。这就是标准库所做的一切。经典的 POSIX <errno.h> 值仍然通过 <cerrno> 作为宏提供,但自 C++11 以来,它们也通过 <system_error> 中的强类型枚举提供,如下面的代码所示:
namespace std {
enum class errc {
// implicitly, "0" means "no error"
operation_not_permitted = EPERM,
no_such_file_or_directory = ENOENT,
no_such_process = ESRCH,
// ...
value_too_large = EOVERFLOW
};
} // namespace std
std::from_chars 通过返回一个包含类型为 enum std::errc 的成员变量的结构体(struct from_chars_result)来报告错误,该成员变量可以是 0 表示 无错误,或者两个可能的错误指示值之一。
那么,关于 fs::file_size 呢?file_size 可能遇到的一组错误要多得多——实际上,当你想到存在的操作系统的数量,以及每个操作系统支持的不同文件系统的数量,以及某些文件系统(如 NFS)分布在各种类型的 网络 上时,可能出现的错误集合看起来就像一个 开集。可能可以将它们全部归结为七十八个标准的 sys::errc 枚举器(每个 POSIX errno 值一个,除了 EDQUOT、EMULTIHOP 和 ESTALE),但这会丢失很多信息。实际上,至少缺失的 POSIX 枚举器之一(ESTALE)是 fs::file_size 的一个合法失败模式!当然,你的底层文件系统可能想要报告它自己的特定于文件系统的错误;例如,虽然有一个标准的 POSIX 错误代码用于 名称过长,但没有 POSIX 错误代码用于 名称包含不允许的字符(原因将在本章下一主要部分中看到)。文件系统可能想要报告那个错误,而不用担心 fs::file_size 会将其压缩到某种固定的枚举类型中。
这里基本的问题是,fs::file_size报告的错误可能并不都来自同一个域,因此,它们不能由一个固定的类型(例如,std::errc)来表示。C++异常处理优雅地解决了这个问题;程序的不同级别抛出不同类型的异常是正常且自然的。如果程序最低级别抛出myfs::DisallowedCharacterInName,则最高级别可以捕获它——无论是通过名称、基类还是通过...。如果我们遵循在程序中抛出的所有内容都应该从std::exception派生的通用规则,那么任何catch块都将能够使用e.what(),这样至少用户可以得到一些模糊上可读的问题指示,无论问题是什么。
标准库将多个错误域的概念具体化为基类std::error_category,如下面的代码所示:
namespace std {
class error_category {
public:
virtual const char *name() const noexcept = 0;
virtual std::string message(int err) const = 0;
// other virtual methods not shown
bool operator==(const std::error_category& rhs) const {
return this == &rhs;
}
};
} // namespace std
error_category的行为与第八章中提到的memory_resource非常相似,分配器;它定义了一个经典的多态接口,某些类型的库预期会从它派生。我们看到了,一些memory_resource的子类是全球单例,而另一些则不是。对于error_category,每个子类必须是一个全局单例,否则它将无法工作。
为了使内存资源有用,库为我们提供了容器(参见第四章,容器动物园)。在最基本层面上,一个容器是一个表示某些已分配内存的指针,以及一个指向内存资源的句柄,该资源知道如何释放该指针。(回想一下,指向内存资源的句柄被称为分配器。)
为了使error_category子类有用,库为我们提供了std::error_code。在最基本层面上(在这个例子中,这是唯一的层面),一个error_code是一个表示错误枚举的int值,以及一个指向error_category的句柄,该句柄知道如何解释该枚举。它看起来是这样的:
namespace std {
class error_code {
const std::error_category *m_cat;
int m_err;
public:
const auto& category() const { return m_cat; }
int value() const { return m_err; }
std::string message() const { return m_cat->message(m_err); }
explicit operator bool() const { return m_err != 0; }
// other convenience methods not shown
};
} // namespace std
因此,要创建一个挑剔的文件系统库子系统,我们可以编写以下代码:
namespace FinickyFS {
enum class Error : int {
success = 0,
forbidden_character = 1,
forbidden_word = 2,
too_many_characters = 3,
};
struct ErrorCategory : std::error_category
{
const char *name() const noexcept override {
return "finicky filesystem";
}
std::string message(int err) const override {
switch (err) {
case 0: return "Success";
case 1: return "Invalid filename";
case 2: return "Bad word in filename";
case 3: return "Filename too long";
}
throw Unreachable();
}
static ErrorCategory& instance() {
static ErrorCategory instance;
return instance;
}
};
std::error_code make_error_code(Error err) noexcept
{
return std::error_code(int(err), ErrorCategory::instance());
}
} // namespace FinickyFS
上述代码定义了一个新的错误域,即FinickyFS::Error域,通过FinickyFS::ErrorCategory::instance()实例化。这允许我们通过如make_error_code(FinickyFS::Error::forbidden_word)这样的表达式创建std::error_code类型的对象。
注意,依赖参数的查找(ADL)将无需我们的任何帮助就找到make_error_code的正确重载。make_error_code与swap一样,是一个定制点:只需在枚举的命名空间中定义一个具有该名称的函数,它就会工作而无需任何额外的工作。
// An error fits comfortably in a statically typed
// and value-semantic std::error_code object...
std::error_code ec =
make_error_code(FinickyFS::Error::forbidden_word);
// ...Yet its "what-string" remains just as
// accessible as if it were a dynamically typed
// exception!
assert(ec.message() == "Bad word in filename");
现在我们有了一种方法,可以通过将它们包装在简单可复制的 std::error_code 对象中来无损地传递 FinickyFS::Error 代码——并在最高级别获取原始错误。当我那样说的时候,它听起来几乎像是魔法——就像没有异常的异常处理!但正如我们刚才看到的,实现起来非常简单。
错误代码和错误条件
注意到 FinickyFS::Error 不能隐式转换为 std::error_code;在最后一个例子中,我们使用了 make_error_code(FinickyFS::Error::forbidden_word) 语法来构造我们的初始 error_code 对象。如果我们告诉 <system_error> 启用从 FinickyFS::Error 到 std::error_code 的隐式转换,那么 FinickyFS::Error 对程序员来说会更加方便,如下所示:
namespace std {
template<>
struct is_error_code_enum<::FinickyFS::Error> : true_type {};
} // namespace std
在重新打开 std 命名空间时要小心——记住,当你这样做的时候,你必须处于任何其他命名空间之外!否则,你将创建一个嵌套的命名空间,例如 FinickyFS::std 命名空间。在这种情况下,如果你出错,编译器会在你尝试特化不存在的 FinickyFS::std::is_error_code_enum 时友好地报错。只要你在特化模板时只重新打开 std 命名空间(并且只要你不搞砸模板特化语法),你就不必太担心任何 静默 失败。
一旦你为你的枚举类型特化了 std::is_error_code_enum,库就会处理其余部分,如下代码所示:
class error_code {
// ...
template<
class E,
class = enable_if_t<is_error_code_enum_v<E>>
>
error_code(E err) noexcept {
*this = make_error_code(err);
}
};
之前代码中看到的隐式转换使得方便的语法变得可能,例如通过 == 进行直接比较,但由于每个 std::error_code 对象都携带其域,比较是强类型的。error_code 对象的值相等不仅取决于它们的 整数 值,还取决于它们相关错误类别单例的 地址。
std::error_code ec = FinickyFS::Error::forbidden_character;
// Comparisons are strongly typed.
assert(ec == FinickyFS::Error::forbidden_character);
assert(ec != std::io_errc::stream);
特化 is_error_code_enum<X> 对于你经常将 X 赋值给 std::error_code 类型的变量,或者从返回 std::error_code 的函数中返回 X 来说是有帮助的。换句话说,如果你的类型 X 真正代表 错误的来源——方程的抛出方,那么关于捕获方呢?假设你注意到你已经编写了这个函数,以及几个类似的函数:
bool is_malformed_name(std::error_code ec) {
return (
ec == FinickyFS::Error::forbidden_character ||
ec == FinickyFS::Error::forbidden_word ||
ec == std::errc::illegal_byte_sequence);
}
前面的函数定义了一个在整个错误代码宇宙上的 一元 谓词;就我们的 FinickyFS 库而言,它对任何与名称格式错误概念相关的错误代码返回 true。我们只需将此函数直接放入我们的库中作为 FinickyFS::is_malformed_name()——实际上,这正是我个人的推荐做法——但标准库还提供了另一种可能的方法。你可以定义一个 error_condition 而不是 error_code,如下所示:
namespace FinickyFS {
enum class Condition : int {
success = 0,
malformed_name = 1,
};
struct ConditionCategory : std::error_category {
const char *name() const noexcept override {
return "finicky filesystem";
}
std::string message(int cond) const override {
switch (cond) {
case 0: return "Success";
case 1: return "Malformed name";
}
throw Unreachable();
}
bool equivalent(const std::error_code& ec, int cond) const
noexcept override {
switch (cond) {
case 0: return !ec;
case 1: return is_malformed_name(ec);
}
throw Unreachable();
}
static ConditionCategory& instance() {
static ConditionCategory instance;
return instance;
}
};
std::error_condition make_error_condition(Condition cond) noexcept
{
return std::error_condition(int(cond),
ConditionCategory::instance());
}
} // namespace FinickyFS
namespace std {
template<>
struct is_error_condition_enum<::FinickyFS::Condition> : true_type
{};
} // namespace std
一旦完成这些,你可以通过编写比较 (ec == FinickyFS::Condition::malformed_name) 来获得调用 FinickyFS::is_malformed_name(ec) 的效果,如下所示:
std::error_code ec = FinickyFS::Error::forbidden_word;
// RHS is implicitly converted to error_code
assert(ec == FinickyFS::Error::forbidden_word);
// RHS is implicitly converted to error_condition
assert(ec == FinickyFS::Condition::malformed_name);
然而,因为我们没有提供一个函数 make_error_code(FinickyFS::Condition),将无法轻松构造一个包含这些条件的 std::error_code 对象。这是合适的;条件枚举是在捕获侧进行测试的,而不是在抛出侧转换为 error_code。
标准库提供了两种代码枚举类型(std::future_errc 和 std::io_errc),以及一种条件枚举类型(std::errc)。没错——POSIX 错误枚举 std::errc 实际上枚举的是 条件,而不是 代码!这意味着如果你试图将 POSIX 错误代码塞入一个 std::error_code 对象中,你做错了;它们是 条件,这意味着它们是在捕获侧 测试 的,而不是用于抛出的。遗憾的是,标准库至少在两个方面犯了错误。首先,正如我们所看到的,std::from_chars 会抛出一个 std::errc 类型的值(这很不方便;抛出一个 std::error_code 会更一致)。其次,存在一个函数 std::make_error_code(std::errc),这会弄乱语义空间,而实际上只需要存在 std::make_error_condition(std::errc)(它确实存在)。
使用 std::system_error 抛出错误
到目前为止,我们考虑了 std::error_code,这是 C++ 异常处理的一个巧妙的非抛出替代方案。但有时,你需要在不同级别的系统中混合非抛出和抛出库。标准库为你解决了问题——至少是问题的一半。std::system_error 是从 std::runtime_error 派生出的具体异常类型,它有足够的空间存储单个 error_code。因此,如果你正在编写一个基于抛出而不是 error_code 的库 API,并且从系统较低级别收到表示失败的 error_code,将那个 error_code 包装在 system_error 对象中并向上抛出是完全合适的。
// The lower level is error_code-based.
uintmax_t file_size(const fs::path& p,
std::error_code& ec) noexcept;
// My level is throw-based.
uintmax_t file_size(const fs::path& p)
{
std::error_code ec;
uintmax_t size = file_size(p, ec);
if (ec) {
throw std::system_error(ec);
}
return size;
}
在相反的情况下——当你编写了非抛出异常的库 API,但你调用较低级别的可能抛出异常的代码时——标准库基本上没有提供帮助。但你可以自己轻松地编写一个 error_code 解包器:
// The lower level is throw-based.
uintmax_t file_size(const fs::path& p);
// My level is error_code-based.
uintmax_t file_size(const fs::path& p,
std::error_code& ec) noexcept
{
uintmax_t size = -1;
try {
size = file_size(p);
} catch (...) {
ec = current_exception_to_error_code();
}
return size;
}
current_exception_to_error_code(), which is a non-standard function you can write yourself. I recommend something along these lines:
namespace detail {
enum Error : int {
success = 0,
bad_alloc_thrown = 1,
unknown_exception_thrown = 2,
};
struct ErrorCategory : std::error_category {
const char *name() const noexcept override;
std::string message(int err) const override;
static ErrorCategory& instance();
};
std::error_code make_error_code(Error err) noexcept {
return std::error_code(int(err), ErrorCategory::instance());
}
} // namespace detail
std::error_code current_exception_to_error_code()
{
try {
throw;
} catch (const std::system_error& e) {
// also catches std::ios_base::failure
// and fs::filesystem_error
return e.code();
} catch (const std::future_error& e) {
// catches the oddball
return e.code();
} catch (const std::bad_alloc&) {
// bad_alloc is often of special interest
return detail::bad_alloc_thrown;
} catch (...) {
return detail::unknown_exception_thrown;
}
}
这就结束了我们对 <system_error> 中混乱世界的离题讨论。我们现在回到你正在进行的常规 <filesystem>。
文件系统和路径
在 第九章 Iostreams 中,我们讨论了 POSIX 的文件描述符概念。
表示数据源或汇,可以通过 read 和/或 write 来定位;通常,但不总是,它对应于磁盘上的一个文件。(回想一下,文件描述符号 1 指的是 stdout,它通常连接到人类的屏幕。文件描述符还可以指网络套接字、例如 /dev/random 的设备等。)
此外,POSIX 文件描述符、<stdio.h> 和 <iostream> 都与磁盘上文件(或任何地方)的 内容 有关,具体来说,是与构成文件 内容 的字节序列有关。在 文件系统 意义上的文件有许多显著的属性,这些属性并未通过文件读取和写入 API 暴露出来。我们不能使用第九章的 API,即 Iostreams,来确定文件的所有权或其最后修改日期;也不能确定给定目录中的文件数量。《`的目的就是允许我们的 C++程序以可移植、跨平台的方式与这些 文件系统 属性交互。
让我们再次开始。什么是文件系统?文件系统是通过 路径 到 文件 的 目录条目 的抽象映射。如果你能以宽容的心态看待,也许一个图表会帮到你:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/ms-cpp17-stl/img/00025.jpeg
在前面图的最上方,我们有一个相对抽象的“名称”世界。我们有一个从那些名称(如 speech.txt)到 POSIX 称为 inode 的具体结构的映射。术语“inode”在 C++标准中并未使用——它使用通用术语“文件”——但当我需要精确时,我会尝试使用 inode 这个术语。每个 inode 包含一组完整的属性,描述磁盘上的单个文件:其所有者、其最后修改日期、其 类型 等。最重要的是,inode 还确切地说明了文件的大小,并提供了一个指向其实际内容的指针(类似于std::vector或std::list持有指向其内容的指针)。inode 和块在磁盘上的确切表示取决于你运行的是哪种类型的文件系统;一些常见文件系统的名称包括 ext4(在 Linux 上常见)、HFS+(在 OS X 上)和 NTFS(在 Windows 上)。
注意到图中的一些块包含的数据只是将 名称 映射到 inode 编号 的表格映射。这让我们回到了起点!一个 目录 只是一个具有特定 类型 的 inode,其内容是名称到 inode 编号的表格映射。每个文件系统都有一个特殊的、众所周知的 inode,称为其 根目录。
假设我们图中的 inode 标签为"2"的是根目录。那么我们可以明确地通过一系列从根目录到该文件的名称路径来识别包含"现在是时候…"的文件。例如,/My Documents/speech.txt就是这样一条路径:从根目录开始,My Documents映射到 inode 42,这是一个包含speech.txt映射到 inode 17 的目录,它是一个包含磁盘上内容为"现在是时候…"的普通文件。我们使用斜杠将这些单个名称组合成一个路径,并在前面加上一个斜杠以表示我们从根目录开始。(在 Windows 中,每个分区或驱动器都有一个独立的根目录。因此,我们可能写成c:/My Documents/speech.txt来表示我们从驱动器 C 的根目录开始。)
或者,“/alices-speech.txt” 是从根目录直接指向 inode 17 的路径。我们说这两个路径(“/My Documents/speech.txt” 和 “/alices-speech.txt”)都是对同一底层 inode 的硬链接,也就是说,对同一底层文件的链接。某些文件系统(例如许多 USB 闪存驱动器使用的 FAT 文件系统)不支持对同一文件有多个硬链接。当支持多个硬链接时,文件系统必须计算每个 inode 的引用次数,以便知道何时可以安全地删除和释放 inode–这个过程与我们在第六章中看到的shared_ptr引用计数过程完全类似,即智能指针。
当我们要求库函数(如open或fopen)“打开文件"时,这就是它在文件系统内部深处所经历的过程。它接受你给出的文件名并将其视为路径–在斜杠处将其拆分,并进入文件系统的目录结构,直到最终到达你请求的文件的 inode(或者直到它遇到死胡同)。请注意,一旦我们到达 inode,就不再有意义地问"这个文件的名称是什么?”,因为它至少有与它的硬链接一样多的名称。
在 C++ 中表示路径
在第九章的Iostreams中,每个期望参数为"文件名"(即路径)的函数都乐于接受这个路径作为一个简单的 const char *。但在<filesystem>库中,我们将因为 Windows 而使这个情况复杂化。
所有 POSIX 文件系统都将名称(如speech.txt)存储为简单的原始字节字符串。POSIX 中的唯一规则是,你的名称不能包含'\0',并且你的名称不能包含'/'(因为这是我们将要分割的字符)。在 POSIX 中,"\xC1.h"是一个完全有效的文件名,尽管它不是有效的 UTF-8,不是有效的 ASCII,并且当你ls .时它在屏幕上的显示完全取决于你的当前区域设置和代码页。毕竟,它只是一个由三个字节组成的字符串,其中没有一个字节是'/'。
另一方面,Windows 的本地文件 API,例如CreateFileW,总是以 UTF-16 存储名称。这意味着,根据定义,Windows 中的路径始终是有效的 Unicode 字符串。这是 POSIX 和 NTFS 之间的一大哲学差异!让我再慢一点说一遍:在 POSIX 中,文件名是字节字符串。在 Windows 中,文件名是Unicode 字符字符串。
如果你遵循第九章中的一般原则第九章,即世界上所有东西都应该使用 UTF-8 编码,那么 POSIX 和 Windows 之间的差异将是可管理的——也许甚至可以忽略不计。但如果你需要在某个系统上调试具有奇怪名称的文件的问题,请记住:在 POSIX 中,文件名是字节字符串。在 Windows 中,文件名是字符字符串。
由于 Windows API 期望 UTF-16 字符串(std::u16string)和 POSIX API 期望字节字符串(std::string),这两种表示方法对于跨平台库来说都不是完全合适的。因此,<filesystem>发明了一个新的类型:fs::path。(回想一下,在本章中我们使用的是我们的命名空间别名。实际上,那就是std::filesystem::path。)fs::path看起来像这样:
class path {
public:
using value_type = std::conditional_t<
IsWindows, wchar_t, char
>;
using string_type = std::basic_string<value_type>;
const auto& native() const { return m_path; }
operator string_type() const { return m_path; }
auto c_str() const { return m_path.c_str(); }
// many constructors and accessors omitted
private:
string_type m_path;
};
注意,在 Windows 中fs::path::value_type是wchar_t,尽管 C++11 的 UTF-16 字符类型char16_t可能更合适。这仅仅是库历史根源在 Boost 中的体现,Boost 的历史可以追溯到 C++11 之前。在本章中,每当提到wchar_t时,你可以假设我们在谈论 UTF-16,反之亦然。
要编写可移植的代码,请注意你使用的任何将fs::path转换为字符串的函数的返回类型。例如,请注意path.c_str()的返回类型不是 const char *——它是 const value_type *!
fs::path p("/foo/bar");
const fs::path::value_type *a = p.c_str();
// Portable, for whatever that's worth.
const char *b = p.c_str();
// OK on POSIX; compilation error on Windows.
std::string s = p.u8string();
const char *c = s.c_str();
// OK on both POSIX and Windows.
// Performs 16-to-8 conversion on Windows.
上述示例,情况c,保证可以编译,但在两个平台上的行为不同:在 POSIX 平台上,它会给你想要的原始字节字符串,而在 Windows 上,它会昂贵地将path.native()从 UTF-16 转换为 UTF-8(这正是你要求的——但如果你找到了避免请求的方法,你的程序可能会更快)。
fs::path 有一个模板构造函数,可以从几乎任何参数构建一个 path。参数可以是任何字符类型的序列(char、wchar_t、char16_t 或 char32_t),并且该序列可以表示为指向空终止字符串的指针、空终止字符串的 迭代器、basic_string、basic_string_view 或迭代器对。像往常一样,我提到这种大量的重载不是因为你想要使用它们中的任何一个,而是让你知道如何避免它们。
标准还提供了一个自由函数 fs::u8path("path"),它是 fs::path("path") 的同义词,但可能作为提醒,你传递的字符串应该是 UTF-8 编码的。我建议忽略 u8path。
这一切可能听起来比实际情况要可怕。请记住,如果你坚持使用 ASCII 文件名,你就不必担心编码问题;如果你记得避免使用“本地”访问器方法,path.native() 和 path.c_str(),以及避免隐式转换为 fs::path::string_type,那么你就不必过于担心可移植性。
路径操作
x (except path itself) represents the return value of the member function path.x():
assert(root_path == root_name / root_directory);
assert(path == root_name / root_directory / relative_path);
assert(path == root_path / relative_path);
assert(path == parent_path / filename);
assert(filename == stem + extension);
assert(is_absolute == !is_relative);
if (IsWindows) {
assert(is_relative == (root_name.empty() ||
root_directory.empty()));
} else {
assert(is_relative == (root_name.empty() &&
root_directory.empty()));
}
例如,给定路径 p = "c:/foo/hello.txt",我们有 p.root_name() == "c:",p.root_directory() == "/",p.relative_path() == "foo/hello.txt",p.stem() == "hello",和 p.extension() == ".txt"。至少,在 Windows 上是这样的!请注意,在 Windows 上,绝对路径需要根名称和根目录("c:foo/hello.txt" 或 "/foo/hello.txt" 都不是绝对路径),而在 POSIX 中,由于不存在根名称,绝对路径只需要根目录("/foo/hello.txt" 是绝对路径,而 "c:foo/hello.txt" 是以奇怪目录名称 "c:foo" 开头的相对路径)。
operator/ to concatenate paths. fs::path supports both operator/ and operator/= for this purpose, and they do almost exactly what you'd expect--concatenate two pieces of a path with a slash in between them. If you want to concatenate pieces of a path without adding that slash, use operator+=. Unfortunately, the C++17 standard library is missing operator+ for paths, but it's easy to add as a free function, as follows:
static fs::path operator+(fs::path a, const fs::path& b)
{
a += b;
return a;
}
路径还支持在令人困惑的成员函数名 path.concat("foo")(不带斜杠)和 path.append("foo")(带斜杠) 下进行带斜杠和不带斜杠的连接。请注意,这与你的预期正好相反!因此,我强烈建议永远不要使用命名成员函数;始终使用运算符(可能包括你在前面代码中描述的自定义定义的 operator+)。
关于 fs::path 的最后一个可能令人困惑的问题是,它提供了 begin 和 end 方法,就像 std::string 一样。但与 std::string 不同,迭代的单位不是单个字符——迭代的单位是 名称!这在以下示例中可以看到:
fs::path p = "/foo/bar/baz.txt";
std::vector<fs::path> v(p.begin(), p.end());
assert((v == std::vector<fs::path>{
"/", "foo", "bar", "baz.txt"
}));
在实际代码中,你永远不会有一个迭代绝对 fs::path 的理由。在 p.relative_path().parent_path() 上迭代——其中每个迭代的元素都保证是目录名称——在特殊情况下可能有一些价值。
使用 directory_entry 检查文件状态
警告!directory_entry 是 C++17 <filesystem> 库中最前沿的部分。我即将描述的内容既不是由 Boost 实现的,也不是由 <experimental/filesystem> 实现的。
从文件的 inode 中检索文件元数据是通过查询类型为fs::directory_entry的对象来完成的。如果你熟悉 POSIX 方法来检索元数据,想象一下fs::directory_entry包含一个type fs::path类型的成员和一个type std::optional<struct stat>类型的成员。调用entry.refresh()基本上等同于调用 POSIX 函数stat();调用任何accessor方法,例如entry.file_size(),如果可选成员仍然未连接,则会隐式调用stat();如果可选成员已经连接,则可能只是使用上次查询时缓存的值。仅仅构造一个fs::directory_entry实例并不会查询文件系统;库会在你提出具体问题之前保持等待。提出具体问题,例如entry.file_size(),可能会使库查询文件系统,或者(如果可选成员已经连接)它可能只是使用上次查询时缓存的值。
fs::path p = "/tmp/foo/bar.txt";
fs::directory_entry entry(p);
// Here, we still have not touched the filesystem.
while (!entry.exists()) {
std::cout << entry.path() << " does not exist yet\n";
std::this_thread::sleep_for(100ms);
entry.refresh();
// Without refresh(), this would loop forever.
}
// If the file is deleted right now, the following
// line might print stale cached values, or it
// might try to refresh the cache and throw.
std::cout << entry.path() << " has size "
<< entry.file_size() << "\n";
实现相同目标的一种较老的方法是使用fs::status("path")或fs::symlink_status("path")来检索fs::file_status类的实例,然后通过诸如status.type() == fs::file_type::directory之类的繁琐操作从file_status对象中提取信息。我建议你不要尝试使用fs::file_status;最好使用entry.is_directory()等。对于喜欢自虐的人,你仍然可以直接从directory_entry中检索fs::file_status实例:entry.status()等同于fs::status(entry.path()),而entry.symlink_status()等同于fs::symlink_status(entry.path()),这反过来又是一个稍微快一点的等效操作。
fs::status(entry.is_symlink() ? fs::read_symlink(entry.path()) : entry.path()).
顺便提一下,自由函数fs::equivalent(p, q)可以告诉你两个路径是否都硬链接到同一个 inode;而entry.hard_link_count()可以告诉你这个特定 inode 的总硬链接数。(确定那些硬链接的名称的唯一方法是在整个文件系统中进行遍历;即使如此,你的当前用户账户可能没有权限对这些路径进行stat操作。)
使用 directory_iterator 遍历目录
fs::directory_iterator正是其名字所暗示的。这种类型的对象允许你逐个条目地遍历单个目录的内容:
fs::path p = fs::current_path();
// List the current directory.
for (fs::directory_entry entry : fs::directory_iterator(p)) {
std::cout << entry.path().string() << ": "
<< entry.file_size() << " bytes\n";
}
顺便提一下,注意前面代码中 entry.path().string() 的使用。这是必需的,因为 operator<< 在路径对象上表现得非常奇怪——它总是输出为如果你已经写下了 std::quoted(path.string())。如果你想输出路径本身,没有任何额外的引号,你总是必须在输出之前将其转换为 std::string。(同样,std::cin >> path 也不能用来从用户那里获取路径,但这不是很讨厌,因为你根本不应该使用 operator>>。有关从用户那里解析输入的更多信息,请参阅第九章 Chapters 9,Iostreams,和第十章 Chapter 10,Regular Expressions)。)
递归目录遍历
要以 Python 的 os.walk() 风格递归地遍历整个目录树,你可以使用以下基于先前代码片段的递归函数:
template<class F>
void walk_down(const fs::path& p, const F& callback)
{
for (auto entry : fs::directory_iterator(p)) {
if (entry.is_directory()) {
walk_down(entry.path(), callback);
} else {
callback(entry);
}
}
}
或者,你可以简单地使用一个 fs::recursive_directory_iterator:
template<class F>
void walk_down(const fs::path& p, const F& callback)
{
for (auto entry : fs::recursive_directory_iterator(p)) {
callback(entry);
}
}
fs::recursive_directory_iterator 的构造函数可以接受一个额外的 fs::directory_options 类型的参数,它修改了递归的确切性质。例如,你可以传递 fs::directory_options::follow_directory_symlink 来跟随符号链接,尽管如果恶意用户创建了一个指向其自身父目录的符号链接,这可能会是一个导致无限循环的好方法。
修改文件系统
<filesystem> 头文件的大多数功能都涉及检查文件系统,而不是修改它。但其中隐藏着一些宝贵的功能。许多这些函数似乎是为了使经典的 POSIX 命令行工具的效果在可移植的 C++ 中可用而设计的:
-
fs::copy_file(old_path, new_path):将位于old_path的文件复制到一个新的文件(即新的 inode)中,类似于cp -n。如果new_path已经存在,则出错。 -
fs::copy_file(old_path, new_path, fs::copy_options::overwrite_existing): 将old_path复制到new_path。如果可能,覆盖new_path。如果new_path已存在且不是常规文件,或者它与old_path相同,则出错。 -
fs::copy_file(old_path, new_path, fs::copy_options::update_existing): 将old_path复制到new_path。只有当new_path比位于old_path的文件旧时,才覆盖new_path。 -
fs::copy(old_path, new_path, fs::copy_options::recursive | fs::copy_options::copy_symlinks): 以cp -R的方式将整个目录从old_path复制到new_path。 -
fs::create_directory(new_path): 以mkdir的方式创建一个目录。 -
fs::create_directories(new_path): 以mkdir -p的方式创建一个目录。 -
fs::create_directory(new_path, old_path)(注意参数的顺序反转!):创建一个目录,但其属性来自old_path目录。 -
fs::create_symlink(old_path, new_path): 从new_path创建指向old_path的符号链接。 -
fs::remove(path): 以rm的方式删除文件或空目录。 -
fs::remove_all(path): 以rm -r的方式删除文件或目录。 -
fs::rename(old_path, new_path): 通过mv重命名文件或目录。 -
fs::resize_file(path, new_size): 通过零扩展或截断常规文件。
报告磁盘使用情况
说到经典的命令行工具,我们可能还想要对文件系统做的一件事是询问它有多满。这是命令行工具 df -h 或 POSIX 库函数 statvfs 的领域。在 C++17 中,我们可以使用 fs::space("path") 来实现,它返回一个类型为 fs::space_info 的结构体:
struct space_info {
uintmax_t capacity;
uintmax_t free;
uintmax_t available;
};
每个这些字段都是以字节为单位的,我们应该有 available <= free <= capacity。available 和 free 之间的区别与用户限制有关:在一些文件系统中,一部分空闲空间可能被保留给 root 用户,而在其他系统中,可能会有每个用户账户的磁盘配额。
摘要
使用命名空间别名以节省输入,并允许使用替代实现
库命名空间,例如 Boost。
std::error_code 提供了一种非常巧妙的方式来向上传递整数错误代码,而不需要异常处理;如果你在一个不赞成异常处理的领域中工作,请考虑使用它。(在这种情况下,这可能是你从这个特定章节中能得到的唯一东西!<filesystem> 库提供了抛出异常和非抛出异常的 API;然而,两个 API 都使用堆分配(并且可能抛出 fs::path 作为词汇类型。使用非抛出 API 的唯一原因是在某些情况下消除“使用异常进行控制流”的情况。)
std::error_condition 只提供了“捕获”错误代码的语法糖;像瘟疫一样避免使用它。
一个 path 由一个 root_name、一个 root_directory 和一个 relative_path 组成;最后一个是由斜杠分隔的 names。对于 POSIX,一个 name 是一串原始字节;对于 Windows,一个 name 是一串 Unicode 字符。fs::path 类型试图为每个平台使用适当的字符串类型。为了避免可移植性问题,请注意 path.c_str() 和隐式转换为 fs::path::string_type。
目录存储从 names 到 inodes 的映射(C++ 标准将其称为“files”)。在 C++ 中,你可以通过 fs::directory_iterator 循环来检索 fs::directory_entry 对象;fs::directory_entry 上的方法允许你查询相应的 inode。重置 inode 就像调用 entry.refresh() 一样简单。
<filesystem> 提供了一系列用于创建、复制、重命名、删除和调整文件和目录大小的免费函数,以及一个用于获取文件系统总容量的最后函数。
本章讨论的大部分内容(至少是 <filesystem> 部分)都是 C++17 的前沿技术,截至出版时,任何编译器供应商都没有实现。使用这些新功能时请谨慎。
4万+

被折叠的 条评论
为什么被折叠?



