探索 C++20(十)
六十三、正则表达式
所有现代语言都以某种方式支持正则表达式。一些脚本语言在语言语法中突出了它们。C++ 通过它的<regex>模块为正则表达式提供了基本的支持。本文只探讨了 C++ 类和函数,并没有深入研究正则表达式语法。如果您需要复习正则表达式语法,许多印刷和在线资源都可以帮助您。C++ 支持多种正则表达式样式;默认是 ECMAScript 2019 中的RegeExp对象(更好的说法是 JavaScript)。
用正则表达式解析
编写解析器通常需要解析器生成器工具,但是我们的依赖工具有一个非常简单的语言,我们可以使用正则表达式来解析输入。在一行文本中输入两个字符串很容易通过正则表达式匹配,或者说 regex :
^[ \t]*(\S+)[ \t]+(\S+)[ \t]*$
让我们使输入更像make程序,并添加一个冒号将目标和它的依赖项分开:
^[ \t]*(\S+)[ \t]*:[ \t]*(\S+)[ \t]*$
我们还可以添加以#字符开头的可选注释,或者允许该行为空白:
^[ \t]*(?:#.*|(\S+)[ \t]*:[ \t]*(\S+)[ \t]*(?:#.*)?)?$
构造一个std::regex对象来编译正则表达式。如果正则表达式包含错误,构造器抛出std::regex_error。记住反斜杠(\)在字符串中有特殊的含义,在正则表达式中用作元字符,所以对于\\S必须重复使用。在\t中留下一个反斜杠,因为它是一个普通的制表符。regex_match()函数根据正则表达式测试一个字符串,并且根据您传递的参数,也可以返回捕获组。复制清单 62-5 并将解析器更改为使用正则表达式会产生清单 63-1 。
#include <cstdlib>
import <iostream>;
import <iterator>;
import <map>;
import <ranges>;
import <regex>;
import <sstream>;
import <stdexcept>;
import <string>;
import <unordered_map>;
import <unordered_set>;
import <vector>;
import artifact;
import topsort;
class dependency_graph
{
public:
using graph_type = std::unordered_map<artifact*,
std::unordered_set<artifact*>>;
void store_dependency(artifact* target, artifact* dependency)
{
graph_[dependency].insert(target);
graph_[target]; // ensures that target is in the graph
}
graph_type const& graph() const { return graph_; }
template<class OutIter>
requires std::output_iterator<OutIter, artifact*>
void sort(OutIter sorted)
const
{
topological_sort(graph_, sorted);
}
artifact* lookup_artifact(std::string const& name)
{
auto a( artifacts_.find(name) );
if (a != artifacts_.end())
return &a->second;
else
{
auto [iterator, inserted]{ artifacts_.emplace(name, name) };
return &iterator->second;
}
}
private:
graph_type graph_;
std::map<std::string, artifact> artifacts_;
};
int main()
{
dependency_graph graph{};
static const std::regex regex{
"^[ \t]*(?:#.*|(\\S+)[ \t]*:[ \t]*(\\S+)[ \t]*(?:#.*)?)?$"
};
std::string line{};
std::size_t line_number{};
while (std::getline(std::cin, line))
{
++line_number;
std::smatch match;
if (std::regex_match(line, match, regex))
{
// Skip comments and blank lines.
if (match[1].matched) {
auto target{graph.lookup_artifact(match[1].str())};
auto dependency{graph.lookup_artifact(match[2].str())};
graph.store_dependency(target, dependency);
}
}
else
// Input line cannot be parsed.
std::cerr << "line " << line_number << ": parse error\n";
}
try {
// Get the sorted artifacts.
std::vector<artifact*> sorted{};
graph.sort(std::back_inserter(sorted));
// Print in build order, which is reverse of dependency order.
for (auto artifact : sorted | std::ranges::views::reverse)
{
std::cout << artifact->name() << '\n';
}
} catch (std::runtime_error const& ex) {
std::cerr << ex.what() << '\n';
return EXIT_FAILURE;
}
}
Listing 63-1.Parsing with a Regular Expression
现在我们的解析器有了更多的功能,我们可以开始添加特性了。让我们添加变量。变量定义的形式为variable=value。变量引用的形式为$(variable),并被替换为variable的值。两个相邻的美元符号($$)被一个美元符号代替。未知变量扩展为空字符串。替换所有变量扩展后,重新扫描字符串以查看是否有更多的扩展要进行。因此,$$(var$(n))首先通过将$$转换为$并查找变量n来扩展。假设n具有值42。在用$替换$$和用n的值替换$(n)之后,通过查找var42来扩展结果字符串$(var42)。
在开始重写解析器之前,让我们将dependency_graph类移到它自己的模块中。确定它必须导入哪些模块,并编写一个导出dependency_graph的depgraph模块。更改store_dependency,使其接受两个字符串作为参数,并在本地处理所有工件指针,这样main()就不需要关心工件实际上是如何被管理的。编写depgraph模块。在清单 63-2 中将你的与我的进行比较。
export module depgraph;
import <iterator>;
import <map>;
import <string>;
import <unordered_map>;
import <unordered_set>;
import artifact;
import topsort;
export class dependency_graph
{
public:
using graph_type = std::unordered_map<artifact*,
std::unordered_set<artifact*>>;
void store_dependency(std::string const& target_name,
std::string const& dependency_name)
{
auto target{ lookup_artifact(target_name) };
auto dependency{ lookup_artifact(dependency_name) };
store_dependency(target, dependency);
}
graph_type const& graph() const { return graph_; }
template<class OutIter>
requires std::output_iterator<OutIter, artifact*>
void sort(OutIter sorted)
const
{
topological_sort(graph_, sorted);
}
artifact* lookup_artifact(std::string const& name)
{
auto a{ artifacts_.find(name) };
if (a != artifacts_.end())
return &a->second;
else
{
auto [iterator, inserted]{ artifacts_.emplace(name, name) };
return &iterator->second;
}
}
private:
void store_dependency(artifact* target, artifact* dependency)
{
graph_[dependency].insert(target);
graph_[target]; // ensures that target is in the graph
}
graph_type graph_;
std::map<std::string, artifact> artifacts_;
};
Listing 63-2.The depgraph Module
您将需要存储和检索变量。编写一个variables模块,它导出函数来定义、查找和扩展变量。对于扩展变量,我们将使用一个regex_iterator来查找输入字符串中的所有变量。迭代器值是一个smatch对象。勇敢一点,试着编写variables模块,或者看看清单 63-3 。
export module variables;
import <ranges>;
import <regex>;
import <string>;
import <unordered_map>;
std::unordered_map<std::string, std::string> variables;
export void define_variable(std::string const& name, std::string const& value)
{
variables[name] = value;
}
std::string const empty_string;
export std::string const& lookup_variable(std::string const& name)
{
if (auto var = variables.find(name); var == variables.end())
return empty_string;
else
return var->second;
}
export std::string expand_variables(std::string const& input)
{
static const std::regex regex{ "\\$(?:\\$|\\(([\\w.-_]+)\\))" };
std::string result{};
auto prefix_begin{ input.begin() };
auto begin{ std::sregex_iterator{input.begin(), input.end(), regex} };
auto end{ std::sregex_iterator{} };
bool matched{false};
using subrange = std::ranges::subrange<std::sregex_iterator>;
for (auto const& match: subrange(begin, end)){
// Copy the string prior to the match
result.append(prefix_begin, match[0].first);
prefix_begin = match[0].second;
if (match[1].matched)
{
result += lookup_variable(match[1].str());
matched = true;
}
else
result += '$'; // no variable, so the regex matched $$
}
// copy rest of unmatched string
result.append(prefix_begin, input.end());
if (not matched)
return result;
// try matching again.
return expand_variables(result);
}
Listing 63-3.The variables Module
有了新的depgraph和variables模块,是时候重新审视一下main()中的解析器了。你可以自己做这件事。在解析了目标和依赖名之后,展开其中的变量,并将它们添加到依赖图中。在清单 63-4 中将你的程序与我的程序进行比较。
#include <cstdlib>
import <iostream>;
import <iterator>;
import <ranges>;
import <regex>;
import <string>;
import <vector>;
import artifact;
import depgraph;
import variables;
int main()
{
dependency_graph graph{};
static const std::regex regex{
"^[ \t]*(?:#.*|[ \t]*(\\S+)[ \t]*=[ \t]*(.*)|(\\S+)[ \t]*:[ \t]*(\\S+)[ \t]*(?:#.*)?)?$"
};
std::string line{};
std::size_t line_number{};
while (std::getline(std::cin, line))
{
++line_number;
std::smatch match;
if (std::regex_match(line, match, regex))
{
if (match[1].matched)
// variable definition
define_variable(match[1].str(), match[2].str());
else if (match[3].matched) {
// target: dependency
auto target{expand_variables(match[3].str())};
auto dependency{expand_variables(match[4].str())};
graph.store_dependency(target, dependency);
}
// else comment or blank line
}
else
// Input line cannot be parsed.
std::cerr << "line " << line_number << ": parse error\n";
}
try {
// Get the sorted artifacts.
std::vector<artifact*> sorted{};
graph.sort(std::back_inserter(sorted));
// Print in build order, which is reverse of dependency order.
for (auto artifact : sorted | std::ranges::views::reverse)
{
std::cout << artifact->name() << '\n';
}
} catch (std::runtime_error const& ex) {
std::cerr << ex.what() << '\n';
return EXIT_FAILURE;
}
}
Listing 63-4.New Program Using depgraph and variables Modules
下一步是允许每个目标变量。也就是说,如果输入以目标名称和冒号开头,但后面是变量定义而不是依赖项名称,则变量定义仅适用于该目标,例如:
NUM=1
target$(NUM) : SRC=1
target$(NUM) : source$(SRC)
target2 : source$(NUM)
target2 : source$(SRC)
NUM变量是全局的,所以target2依赖于source1。SRC变量只适用于target1,所以target1依赖于source1。另一方面,最后一行说target2依赖于source,而不是source2,因为target2没有SRC变量,未知变量扩展为空字符串。
让我们从给每个工件添加一个局部变量映射开始。稍后,我们将细化实现以区分目标和依赖项,以便只有目标才有变量映射。让我们从重温variables模块开始。因为我们现在有了局部变量和全局变量,所以编写一个可以满足这两个目的的variables类似乎是个好主意。但是有一个问题。
在查找变量名时,需要在特定于目标的映射和全局映射中查找。所以查找要看是什么样的映射。修改 variables 模块,定义一个实现映射逻辑的基类,有两个局部和全局变量的派生类,不同之处仅在于它们的查找函数。在清单 63-5 中将你的模块与我的进行比较。
export module variables;
import <ranges>;
import <regex>;
import <string>;
import <unordered_map>;
class base
{
public:
virtual ~base() = default;
virtual std::string const& lookup(std::string const& name) const = 0;
void define(std::string const& name, std::string const& value)
{
map_[name] = value;
}
std::string expand(std::string const& input)
const
{
static const std::regex regex{ "\\$(?:\\$|\\(([\\w.-_]+)\\))" };
std::string result{};
auto prefix_begin{ input.begin() };
auto begin{ std::sregex_iterator{input.begin(), input.end(), regex} };
auto end{ std::sregex_iterator{} };
bool matched{false};
using subrange = std::ranges::subrange<std::sregex_iterator>;
for (auto const& match: subrange(begin, end)){
// Copy the string prior to the match
result.append(prefix_begin, match[0].first);
prefix_begin = match[0].second;
if (match[1].matched)
{
result += lookup(match[1].str());
matched = true;
}
else
result += '$'; // no variable, so the regex matched $$
}
// copy rest of unmatched string
result.append(prefix_begin, input.end());
if (not matched)
return result;
// try matching again.
return expand(result);
}
protected:
base() = default;
static const std::string empty_string;
std::unordered_map<std::string, std::string> map_;
};
const std::string base::empty_string;
class global : public base
{
public:
std::string const& lookup(std::string const& name)
const override
{
if (auto var = map_.find(name); var == map_.end())
return empty_string;
else
return var->second;
}
};
// Global variables
export global global_variables;
// Target-specific variables
export class variables : public base
{
public:
std::string const& lookup(std::string const& name)
const override
{
if (auto var = map_.find(name); var == map_.end())
return global_variables.lookup(name);
else
return var->second;
}
};
Listing 63-5.Adding Local and Global Maps to the variables Module
向 artifact添加一个 variables 数据成员(来自清单 62-3 )。为了隐藏variables对象,让artifact类提供它自己的define()和expand()成员函数来转发给variables成员。将您的模块与清单 63-6 中的模块进行比较。
export module artifact;
import <filesystem>;
import <string>;
import <system_error>;
import variables;
export class artifact
{
public:
using file_time_type = std::filesystem::file_time_type;
artifact() : name_{}, mod_time_{file_time_type::min()}, vars_{} {}
artifact(std::string const& name)
: name_{name}, mod_time_{get_mod_time()}, vars_{}
{}
std::string const& name() const { return name_; }
file_time_type mod_time() const { return mod_time_; }
/// Builds a target.
/// After completing the actions (not yet implemented),
/// update the modification time.
void build();
/// Looks up the modification time of the artifact.
/// Returns file_time_type::min() if the artifact does not
/// exist (and therefore must be built) or if the time cannot
/// be obtained for any other reason.
file_time_type get_mod_time()
{
std::error_code ec;
auto time{ std::filesystem::last_write_time(name_, ec) };
if (ec)
return file_time_type::min();
else
return time;
}
void define(std::string const& name, std::string const& value)
{
vars_.define(name, value);
}
std::string expand(std::string const& input)
const
{
return vars_.expand(input);
}
private:
std::string name_;
file_time_type mod_time_;
variables vars_;
};
Listing 63-6.The New artifact Module
现在您已经准备好更新解析器了。正则表达式越来越难看了,这是您应该开始考虑其他解析器选项的时候了。标准 C++ 没什么帮助,但是可以看看解析器生成器或者第三方解析器库。坚持使用标准 C++,我们只是让正则表达式稍微复杂一点。但是我们可以利用编译器自动连接相邻字符串的优势,使它更容易阅读。将正则表达式分成关键部分,并将每个部分放入自己的字符串中。在字符串之间添加适当的空格以增强可读性。
编写一个解析器模块,导出一个解析函数。该函数应该接受一个istream和一个dependency_graph参数,两者都通过引用传递。你的和我的清单 63-7 相似吗?
export module parser;
import <iostream>;
import <regex>;
import <string>;
import artifact;
import depgraph;
const std::regex regex{
"^[ \t]*"
"(?:"
"#.*" "|"
"[ \t]*(\\S+)[ \t]*=[ \t]*(.*)" "|"
"(\\S+)[ \t]*:[ \t]*(\\S+)[ \t]*=[ \t]*(.*)" "|"
"(\\S+)[ \t]*:[ \t]*(\\S+)[ \t]*(?:#.*)?"
")?$"
};
export void parse(std::istream& stream, dependency_graph& graph)
{
bool okay{true};
std::string line{};
std::size_t line_number{};
while (std::getline(stream, line))
{
++line_number;
std::smatch match;
if (std::regex_match(line, match, regex))
{
if (match[1].matched)
// var=value
global_variables.define(match[1].str(), match[2].str());
else if (match[3].matched) {
// target: var=value
auto target_name{ global_variables.expand(match[3].str()) };
auto target{graph.lookup_artifact(target_name)};
target->define(match[4].str(), target->expand(match[5].str()));
}
else if (match[6].matched) {
// target: dependency
auto target_name{ global_variables.expand(match[6].str()) };
auto target{target_name};
auto dependency{
graph.lookup_artifact(target)->expand(match[7].str())
};
graph.store_dependency(target, dependency);
}
// else comment or blank line
}
else
{
// Input line cannot be parsed.
std::cerr << "line " << line_number << ": parse error\n";
okay = false;
// Keep going in case there are more errors.
}
}
if (not okay)
throw std::runtime_error("Cannot continue due to parse errors");
}
Listing 63-7.The parser Module
现在主程序更简单了。编写新的主程序。我对它的看法在清单 63-8 中。
#include <cstdlib>
import <iostream>;
import <iterator>;
import <ranges>;
import <stdexcept>;
import <vector>;
import artifact;
import depgraph;
import parser;
int main()
{
try {
dependency_graph graph{};
parse(std::cin, graph);
// Get the sorted artifacts.
std::vector<artifact*> sorted{};
graph.sort(std::back_inserter(sorted));
// Print in build order, which is reverse of dependency order.
for (auto artifact : sorted | std::ranges::views::reverse)
{
std::cout << artifact->name() << '\n';
}
} catch (std::runtime_error const& ex) {
std::cerr << ex.what() << '\n';
return EXIT_FAILURE;
}
}
Listing 63-8.New Program Using the parser Module
在进入伪 make 程序的下一步之前,是时候深入研究一下std::move()的魔力了,我在《探索 40 中介绍了它,但没有做任何解释。接下来的探索最后解释一下std::move()及其在 C++ 编程中的重要性。
六十四、移动带有右值引用的数据
在 Exploration 40 中,我介绍了std::move(),但没有解释它真正做了什么或如何工作。不知何故,它将数据(如字符串)从一个变量移动到另一个变量,而不是复制字符串内容,但您一定想知道它是如何创造奇迹的。这个函数本身非常简单。这个简单实现背后的概念更加复杂,这就是为什么我一直等到现在才介绍其中的复杂性和微妙之处。
复制与移动
如您所知,std::vector 类型存储一个值数组。随着向 vector 添加更多的值,它可能需要分配一个新的数组来保存更多的值。当这种情况发生时,它必须将旧数组复制到新数组中。如果值是大对象,这可能是一个昂贵、耗时的操作。但是,如果值类型允许,vector 将移动这些值,而不是复制它们。想象一下,如果你买了一本战争与和平,你必须复制它的内容才能把书带回家,而不是把它从书店搬到家里。
诀窍是编译器知道什么时候可以移动数据,什么时候必须复制数据。例如,普通的赋值需要创建一个赋值源的副本,所以赋值的目标最终得到了源的精确副本。将参数传递给函数也需要制作参数的副本,除非该参数被声明为引用类型。
但有时,函数参数是临时的,编译器知道它是临时的。编译器知道它不必从临时对象中复制数据。临时的即将被摧毁;它不需要保留自己的数据,因此可以将数据移动到目标位置,而无需拷贝。
为了帮助您直观地看到对象被复制时会发生什么,让我们创建一个新的类来包装std::string并向您显示它的复制构造器和赋值操作符何时被调用。然后创建一个小程序,从std::cin中读取字符串,并将它们添加到一个向量中。编写程序,将你的程序与清单 64-1 中我的程序进行比较。
import <iostream>;
import <string>;
import <vector>;
class mystring : public std::string
{
public:
mystring() : std::string{} { std::cout << "mystring()\n"; }
mystring(mystring const& copy) : std::string{copy} {
std::cout << "mystring copy(\"" << *this << "\")\n";
}
};
std::vector<mystring> read_data()
{
std::vector<mystring> strings{};
mystring line{};
while (std::getline(std::cin, line))
strings.push_back(line);
return strings;
}
int main()
{
std::vector<mystring> strings{};
strings = read_data();
}
Listing 64-1.Exposing How Strings Are Copied
试着用几行输入运行程序。每个字符串被复制多少次? ______ 程序将line复制到push_back()中的矢量中。当编译器将strings变量返回给调用者时,它知道自己不必复制向量。它可以移动它。因此,你不会得到任何额外的副本。
怎样才能减少字符串被复制的次数?line变量存储临时数据。程序没有理由在调用push_back()后保留line中的值。所以我们知道将字符串内容从line移到data是安全的。调用std::move()告诉编译器可以将字符串移入向量。您还必须向mystring添加一个移动构造器。参见清单 64-2 中的新程序。现在每个字符串被复制了多少次? ______
import <iostream>;
import <string>;
import <utility>;
import <vector>;
class mystring : public std::string
{
public:
mystring() : std::string{} { std::cout << "mystring()\n"; }
mystring(mystring const& copy) : std::string{copy} {
std::cout << "mystring copy(\"" << *this << "\")\n";
}
mystring(mystring&& move) noexcept
: std::string{std::move(move)} {
std::cout << "mystring move(\"" << *this << "\")\n";
}
};
std::vector<mystring> read_data()
{
std::vector<mystring> strings{};
mystring line{};
while (std::getline(std::cin, line))
strings.emplace_back(std::move(line));
return strings;
}
int main()
{
std::vector<mystring> strings;
strings = read_data();
}
Listing 64-2.Moving Strings Instead of Copying Them
新的构造器用一个双&符号(&&)声明它的参数。它看起来有点像参考文献。注意参数不是const。这是因为从一个对象移动数据必然会修改该对象。最后,回想一下 Exploration 48 中的noexcept说明符告诉编译器,构造器不能抛出异常。还要注意的是,mystring构造器调用std::move()将其参数移入std::string构造器。您必须为任何命名的对象调用std::move(),即使该对象是一个特殊的&&引用。
确切的输出取决于你的库的实现,但是大多数从 vector 的少量内存开始,并且开始时增长缓慢,以避免浪费内存。因此,只需添加几个字符串,就可以揭示 vector 在重新分配数组时是如何移动或复制字符串的。表 64-1 显示了当我提供三路输入时,列表 64-1 和列表 64-2 的输出。
表 64-1。
比较清单 64-1 和清单 64-2 的输出
|清单 60-1
|
清单 60-2
|
| --- | --- |
| mystring()``mystring copy("one")``mystring copy("two")``mystring copy("one")``mystring copy("three")``mystring copy("two")``mystring copy("one") | mystring()``mystring move("one")``mystring move("two")``mystring move("one")``mystring move("three")``mystring move("two")``mystring move("one") |
本文的其余部分解释了 C++ 如何实现这种移动功能。
左值、右值等等
回想一下 Exploration 21 中的内容,表达式分为两类:左值或右值。非正式地,左值可以出现在赋值的左边,右值出现在右边。至少这是“l”和“r”名字的由来。更具体地说,左值有标识符,存储在内存中的某个地方。右值不一定要有这两者,尽管它们可能有。向函数传递参数类似于赋值:函数参数扮演左值的角色,参数是右值。
区分左值和右值的一个关键方法是你可以获取左值的地址(使用操作符&)。编译器不让你获取一个右值的地址,这是有意义的。42 的地址是什么?
编译器会在必要时自动将左值转换为右值,比如将左值作为参数传递给函数,或者将左值用作赋值的右侧。编译器将右值转换为左值的唯一情况是左值的类型是对const的引用。例如,一个将其参数声明为std::string const&的函数可以将一个右值std::string作为参数,编译器将该右值转换为左值。但是除了那种情况,你不能把右值变成左值。
当考虑对象的生存期时,左值和右值之间的区别很重要。您知道变量的作用域决定了它的生存期,所以任何有名称的左值(例如,变量或函数参数)的生存期都由名称的作用域决定。另一方面,右值是暂时的。除非您将名称绑定到该右值(记住名称的类型必须是对const的引用),否则编译器会尽快销毁临时对象。
例如,在下面的表达式中,创建了两个临时的std::string对象,然后传递给operator+来连接字符串。operator+函数将其std::string const&参数绑定到相应的参数,从而保证这些参数至少在函数返回之前有效。operator+函数返回一个新的临时std::string,然后打印到std::cout:
std::cout << std::string("concat") + std::string("enate");
一旦语句执行完毕,临时的std::string对象就可以被销毁。std::move()函数可以让你区分对象的生命周期和它包含的数据,比如组成一个字符串的字符或者一个向量的元素。该函数接受一个左值(其生存期由作用域决定),并将其转换为右值,因此内容可以被视为临时的。因此,在清单 64-1 中,line的寿命由作用域决定。但是在清单 64-2 中,通过调用std::move(),您说将line的字符串内容视为临时是安全的。
因为std::move()将左值转换为右值,所以返回类型(使用双&符号)被称为右值引用。mystring move 构造器的参数也使用双&符号,所以它们的类型是右值引用。单个&符号引用类型称为左值引用,以便与右值引用明确区分开来。
术语上有些混乱,带有右值引用类型的表达式既属于右值表达式范畴,也属于左值范畴。为了减少混淆,给这种表达式起了一个特殊的名字: xvalue ,表示“过期值”。也就是说,表达式仍然是一个左值,可以出现在赋值的左边,但它也是一个右值,因为它接近了生命周期的末尾,所以您可以随意窃取它的内容。
不是 xvalues 的右值有不同的名字:纯右值,或 prvalue 。纯右值是诸如数值、算术表达式、函数调用(如果返回类型不是引用类型)等表达式。完全缺乏对称性,就没有纯粹的左值。取而代之的是,左值这一术语用于表示不是 x 值的左值类。新术语广义左值,或 glvalue ,适用于所有左值和 x 值。图 64-1 描述了各种表达式类别。
图 64-1。
表达式类别
所以结果是std::move()实际上是一个微不足道的函数。它将左值引用作为参数,并将其转换为右值引用。这种差异对于编译器处理表达式的方式很重要,但是std::move()不生成任何代码,对性能没有影响。
总结一下:
-
调用返回左值引用类型的函数会返回左值类别的表达式。
-
调用返回右值引用类型的函数会返回类别 xvalue 的表达式。
-
调用返回非引用类型的函数会返回类别 prvalue 的表达式。
-
编译器将右值(xvalue 或 prvalue)参数与右值引用类型的函数参数进行匹配。它将左值参数与左值引用相匹配。
-
命名对象具有类别左值,即使该对象的类型是右值引用。
-
声明一个右值引用类型的参数(使用双&符号)以从参数中移动数据。
-
调用
std::move()作为赋值的源,或者当您想要从左值移动数据时,将参数传递给函数。这将左值引用转换为右值引用。
实施移动
为mystring类实现一个构造器很容易,因为它只是将其参数移动到基类的 move 构造器中。但是像std::string或std::vector这样的类是如何实现移动功能的呢?回到清单 63-6 中的artifact类。应该可以移动一个artifact。想想你会如何去移动一件艺术品。到底需要移动什么?怎么做?
为 artifact 类写一个 move 构造器。然后将您的解决方案与清单 64-3 中我的解决方案进行比较。
export module artifact;
import <filesystem>;
import <string>;
import <system_error>;
import variables;
export class artifact
{
public:
using file_time_type = std::filesystem::file_time_type;
artifact() : name_{}, mod_time_{file_time_type::min()}, vars_{} {}
artifact(std::string name)
: name_{std::move(name)}, mod_time_{get_mod_time()}, vars_{}
{}
artifact(artifact&& src) noexcept
: name_{std::move(src.name_)},
mod_time_{std::move(src.mod_time_)},
vars_{std::move(src.vars_)}
{}
std::string const& name() const { return name_; }
file_time_type mod_time() const { return mod_time_; }
file_time_type get_mod_time();
void define(std::string const& name, std::string const& value);
std::string expand(std::string const& input) const;
private:
std::string name_;
file_time_type mod_time_;
variables vars_;
};
Listing 64-3.Adding a Move Constructor to the artifact Class
瓦卢瓦还是卢瓦卢瓦?
所有这些 XYZ 值都会让人感到困惑。为了帮助你理解发生了什么,清单 64-4 展示了许多不同的表达式作为参数传递给重载的print()函数。
import <iostream>;
import <string>;
import <utility>;
void print(std::string&& move)
{
std::cout << "move: " << std::move(move) << '\n';
}
void print(std::string const& copy)
{
std::cout << "copy: " << copy << '\n';
}
int main()
{
std::string a{"a"}, b{"b"}, c{"c"};
print(a);
print(a + b);
print(a + b + c);
print(std::move(a + b));
print(a + std::move(b));
print(std::move(a));
}
Listing 64-4.Examining Expression Categories
预测产量。
当我运行该程序时,我得到以下结果:
copy: a
move: ab
move: abc
move: ab
move: ab
move: a
不能创建对引用的引用。如果一个类型已经是一个引用,比如说一个 typedef,那么在声明中添加一个额外的引用就会折叠成一个引用级别。如果类型是一个左值引用,你总是得到一个左值引用。如果原始类型是右值引用,那么您将获得与原始引用相同的类型,例如:
using lvalue = int&;
using rvalue = int&&;
int i;
lvalue& ref1 = i; // ref1 has type int&
lvalue&& ref2 = i; // ref2 has type int&
rvalue& ref3 = i; // ref3 has type int&
rvalue&& ref4 = 42; // ref4 has type int&&
当使用一个auto声明并且你不知道源类型是否是一个引用类型或者它是哪种类型的引用时,那么总是让auto声明成为一个右值引用。例如,如果范围迭代器返回左值引用,那么item将具有左值引用类型,如果范围迭代器返回右值引用,那么【】将具有右值引用类型。如果迭代器返回非引用类型,那么 item 将是对临时常量值的引用:
for (auto&& item : range) do_something(item);
特殊成员功能
当你写一个移动构造器时,你也应该写一个移动赋值操作符,反之亦然。您还必须考虑是否以及如何编写复制构造器和复制赋值运算符。编译器将通过隐式编写默认实现或删除特殊成员函数来帮助您。这一节将详细介绍编译器的隐式行为和编写您自己的特殊成员函数(构造器、赋值操作符和析构函数)的准则。
编译器可以隐式创建以下任何或所有内容:
-
默认构造器,例如
name() -
例如,复制构造器
name(name const&) -
例如,移动构造器
name(name&&) -
复制赋值运算符,例如
name& operator=(name const&) -
移动赋值运算符,例如
name& operator=(name&&) -
例如,
~name()析构函数
一个好的指导方针是,如果你写这些特殊函数中的任何一个,你应该写所有的函数。您可能会认为编译器的隐式函数正是您想要的,在这种情况下,您应该用=default明确说明这一点。这有助于维护代码的人了解您的意图。如果你知道编译器会抑制一个特殊的成员,注意用= delete显式地。
如您所知,如果您显式编写任何构造器,编译器会删除其隐式默认构造器。隐式默认构造器未初始化指针,所以如果您有任何指针类型的数据成员,您应该编写自己的默认构造器来初始化指向nullptr的指针,或者删除默认构造器。
如果您显式提供移动构造器或移动赋值运算符,编译器将删除其复制构造器和复制赋值运算符。
如果您显式提供以下任何特殊成员函数,编译器将删除其移动构造器:移动赋值、复制构造器、复制赋值或析构函数。
如果您显式提供以下任何特殊成员函数,编译器将删除移动赋值运算符:移动构造器、复制构造器、复制赋值或析构函数。
编译器的默认行为是为了确保安全。如果所有数据成员和基类都允许,它将隐式创建复制和移动函数。但是如果你开始编写自己的特殊成员,编译器会认为你最了解,并抑制任何可能不安全的内容。然后由您来添加对您的类有意义的特殊成员。当编译器隐式提供任何特殊成员函数时,它也会在安全和正确的情况下提供noexcept说明符,也就是说,当所有的数据成员和基类都声明了函数noexcept(或者是内置类型)时。
每当一个类使用外部资源时,比如分配堆内存或打开文件,您必须考虑所有特殊的成员函数,以确保资源得到正确管理。通常,为资源提供接口的 C++ 类会为您处理细节。例如,标准的<fstream>类实现移动逻辑并禁止复制;当流对象被销毁时,它们关闭文件。
确保指针类型的数据成员总是被初始化。确保 move 构造器和赋值操作符正确地将源指针设置为nullptr。如果必须实现复制构造器或复制赋值运算符,请实现适当的深度复制,或者确保两个对象不都持有相同的指针。确保该类分配的所有内容都被删除。
数据成员的要求适用于该类。因此,如果数据成员缺少复制构造器,则编译器会取消包含类的隐式复制构造器。毕竟,如果它不能复制所有的成员,又怎么能复制类呢?移动功能同上。
所有这些复杂性只适用于实际管理资源的类的作者。即使这样,您也可以利用 C++ 库来简化您的工作。让我们把artifact课变得更有趣一点。假设variables映射是一个庞大而笨拙的物体,即使是空的。(其实不是,不过我们假装一下。)所以您希望只有当用户真正为目标定义变量时才创建一个。大多数目标没有variables映射。因此variables映射成为一种需要管理的特殊资源。
为了改变artifact,使它有时有一个variables映射,有时没有,我们把它改为一个指针,但一种特殊的指针叫做unique_ptr。这个指针的独特之处在于它有一个独特的所有者,也就是说,一个artifact拥有一个特定的variables对象。如果你为artifact写一个复制构造器,它不能复制unique_ptr,因为两个artifact对象将“拥有”同一个variables映射,所以所有者不是唯一的。但是您可以移动一个unique_ptr,在移动构造器中将所有权从一个所有者转移到另一个所有者。
要创建一个新的variables对象,调用std::make_unique<variables>()。当唯一指针的所有者被销毁时,那么variables对象也将被销毁。修改artifact类,将std::unique_ptr用于variables映射。将您的解决方案与清单 64-5 进行比较。
export module artifact;
import <filesystem>;
import <memory>;
import <string>;
import <system_error>;
import variables;
export class artifact
{
public:
using file_time_type = std::filesystem::file_time_type;
artifact() : name_{}, mod_time_{file_time_type::min()}, vars_{} {}
artifact(std::string name)
: name_{std::move(name)}, mod_time_{get_mod_time()}, vars_{}
{}
artifact(artifact const&) = delete; // move-only
artifact& operator=(artifact const&) = delete; // move-only
artifact(artifact&&) = default;
artifact& operator=(artifact&&) = default;
~artifact() = default;
std::string const& name() const { return name_; }
file_time_type mod_time() const { return mod_time_; }
file_time_type get_mod_time();
void define(std::string const& name, std::string const& value)
{
if (not vars_)
vars_ = std::make_unique<variables>();
vars_->define(name, value);
}
std::string expand(std::string const& input)
const
{
if (vars_)
return vars_->expand(input);
else
return global_variables.expand(input);
}
private:
std::string name_;
file_time_type mod_time_;
std::unique_ptr<variables> vars_;
};
Listing 64-5.Using a Unique Pointer for the variables Member
当需要时,variables表被延迟构造。expand()函数检查它是否存在,如果不存在,只使用全局变量来扩展输入字符串。unique_ptr类实现了移动指针的所有逻辑,因此确保正确处理variables资源很容易实现。
unique_ptr类并不是 C++ 标准库中唯一的智能指针。接下来的探索将带你深入指针丛林。
六十五、智能指针
关于指针,我忽略了一个挑战,那就是使用指针的内在危险。保存所有工件对象的映射必须比程序中所有工件指针的使用寿命都长。到目前为止,这还不是一个问题,但是随着程序规模和复杂性的增长,所涉及的簿记可能会模糊指针的使用方式。因此,让我们探索对程序的一个微小的改变,以确保无论程序如何发展,所有的指针都可以安全使用。这个探索更深入地研究了指针、它们的问题以及如何避免它们。
指针和迭代器
也许你已经注意到迭代器语法和指针语法之间的相似性。C++ 委员会故意设计迭代器来模仿指针。事实上,指针满足了连续迭代器的所有要求,因此您可以使用 C 风格数组的所有标准算法,如下所示:
int data[4];
std::ranges::fill(data, 42);
因此,迭代器是智能指针的一种形式。迭代器尤其聪明,因为它们有六种不同的风格(参见 Exploration 44 以获得提示)。连续迭代器就像指针一样;其他类型的迭代器功能较少,所以它们很聪明。
迭代器和指针一样危险。在它们的纯形式中,迭代器几乎和指针一样未经检查、混乱和原始。毕竟,迭代器不会阻止你前进得太远,不会阻止你去引用一个未初始化的迭代器,不会阻止你比较指向不同容器的迭代器,等等。迭代器的不安全做法非常多。
因为这些错误会导致未定义的行为,所以库实现者可以自由地为每种错误选择任何结果。出于对性能的考虑,大多数库并没有实现额外的安全检查,而是将这一任务推给了程序员,程序员可以根据自己的喜好来决定安全/性能的取舍。
如果程序员更喜欢安全而不是性能,一些库实现提供了一个调试版本,它实现了许多安全检查。标准库的调试版本可以在比较迭代器时检查迭代器是否引用了同一个容器,如果没有,就抛出异常。允许迭代器在使用解引用(*)操作符之前检查它是否有效。迭代器可以确保它不会超过容器的末尾。
因此,迭代器是智能指针,因为它们非常非常智能。我强烈建议您充分利用标准库提供的所有安全特性。只有在您测量了程序的性能并发现某个特定的检查会显著降低性能,并且您已经准备好了评审和测试以使您对不太安全的代码有信心之后,才能逐个删除检查。
关于unique_ptr的更多信息
Exploration 64 引入了unique_ptr作为管理动态分配对象的一种方式。unique_ptr类模板重载了解除引用(*)和成员访问(->)操作符,并允许您像使用指针一样使用unique_ptr对象。同时,它扩展了普通指针的行为,这样当unique_ptr对象被销毁时,它会自动删除它持有的指针。这就是为什么unique_ptr被称为智能指针— 它就像一个普通的指针,只是更智能。使用unique_ptr有助于确保内存得到适当的管理,即使面对意外的异常。
正确使用时,unique_ptr的关键特性是恰好一个unique_ptr对象拥有一个特定的指针。你可以移动unique_ptr物体。每次这样做时,移动的目标都成为指针的新所有者。
调用reset成员函数将unique_ptr设置为空指针。
auto ap{std::make_unique<int>(42)};
ap.reset(); // deletes the pointer to 42
get()成员函数在不影响unique_ptr所有权的情况下检索原始指针。unique_ptr模板还重载了解引用(*)和成员(->)操作符,这样它们就可以像处理普通指针一样工作。这些函数不影响指针的所有权。
auto rp{std::make_unique<rational>(420, 10)};
int n{rp->numerator()};
rational r{*rp};
sendto(socket, rp.get(), sizeof(r), n, nullptr, 0);
为了加强它的所有权语义,unique_ptr有一个移动构造器和移动赋值操作符,但是删除了它的复制构造器和复制赋值操作符。如果对类中的数据成员使用unique_ptr,编译器会隐式删除该类的复制构造器和复制赋值操作符。
因此,使用unique_ptr可以让你不用考虑类的析构函数,但是你不能免除考虑构造器和赋值操作符。这是对指导方针的一个小调整,如果你必须处理一个,你必须处理所有的特殊成员。编译器的默认行为通常是正确的,但是您可能希望实现一个复制构造器来执行深度复制或其他非默认行为。
可复制的智能指针
有时候,你不想独占所有权。有些情况下,多个对象会共享一个指针的所有权。当没有对象拥有指针时,内存被自动回收。智能指针类型实现了共享所有权。
一旦你向一个shared_ptr传递了一个指针,shared_ptr对象就拥有了这个指针。当shared_ptr对象被销毁时,它会删除指针。shared_ptr和unique_ptr的区别在于,你可以自由的复制和赋值shared_ptr对象,并带有正常的语义。与unique_ptr不同,shared_ptr有复制构造器和复制赋值操作符。shared_ptr对象保存一个引用计数,所以赋值只是增加引用计数,而不必转移所有权。当一个shared_ptr对象被销毁时,它会减少引用计数。当计数达到零时,指针被删除。因此,您可以随意复制任意多的副本,将shared_ptr对象存储在一个容器中,将它们传递给函数,从函数中返回它们,复制它们,移动它们,分配它们,然后随心所欲地继续。就这么简单。清单 65-1 显示了复制shared_ptr的方式与unique_ptr不兼容。
import <iostream>;
import <memory>;
import <vector>;
class see_me
{
public:
see_me(int x) : x_{x} { std::cout << "see_me(" << x_ << ")\n"; }
~see_me() { std::cout << "~see_me(" << x_ << ")\n"; }
int value() const { return x_; }
private:
int x_;
};
std::shared_ptr<see_me> does_this_work(std::shared_ptr<see_me> x)
{
std::shared_ptr<see_me> y{x};
return y;
}
int main()
{
std::shared_ptr<see_me> a{}, b{};
a = std::make_shared<see_me>(42);
b = does_this_work(a);
std::vector<std::shared_ptr<see_me>> v{};
v.push_back(a);
v.push_back(b);
}
Listing 65-1.Working with shared_ptr
创建shared_ptr的最佳方式是调用make_shared。模板参数是您想要创建的类型,函数参数直接传递给构造器。由于实现细节的原因,以任何其他方式构造一个新的shared_ptr实例在空间和时间上都稍显低效。
使用shared_ptr,你可以重新实现清单 63-8 中的程序。旧程序使用artifact图来管理所有工件的生命周期。虽然很方便,但是没有理由将工件绑定到这个映射上,因为映射只用于解析。在真正的程序中,它的大部分工作在于实际构建目标,而不是解析输入。当程序构建目标时,所有的解析对象都应该被释放并早已消失。
重写清单 63-8 的工件查找部分,动态分配工件对象,通篇使用 shared_ptr 来引用工件指针。参见清单 65-2 了解我的解决方案。
std::unordered_map<std::string, std::shared_ptr<artifact>> artifacts;
std::shared_ptr<artifact>
lookup_artifact(std::string const& name)
{
std::shared_ptr<artifact> a{artifacts[name]};
if (a.get() == nullptr)
{
a = std::make_shared<artifact>(name);
artifacts[name] = a;
}
return a;
}
Listing 65-2.Using Smart Pointers to Manage Artifacts
我将 map 改为unordered_map,因为每个工件对象的地址永远不变不再重要。存储智能指针减轻了我们的这种限制。稍微小心一点,您可以使用unique_ptr而不是shared_ptr,但是这将导致代码的其余部分发生更大的变化。由于维护引用计数的开销,你应该更喜欢unique_ptr而不是shared_ptr。但如果你要求共享所有权,shared_ptr是你的选择。在所有情况下,都没有理由使用原始指针而不是智能指针。
智能阵列
分配单个对象与分配对象数组完全不同。因此,智能指针还必须区分指向单个对象的智能指针和指向对象数组的智能指针。当智能指针持有指向数组的指针时(即模板参数是数组类型,如unique_ptr<int[]>),它支持下标操作符,而不是*和->。
丘疹
不,那不是拼写错误。尽管程序员多年来一直在谈论他们程序中的粉刺和缺陷,通常指的是难看但不可避免的代码片段,Herb Sutter 将短语指向实现的指针与这些粉刺联系起来,提出了 pimpl 习语。
简而言之,pimpl 是一个在实现类中隐藏实现细节的类,公共接口对象只保存指向该实现对象的指针。您可以公开一个更易于使用的类,而不是强制您的类的用户分配和取消分配对象、管理指针以及跟踪对象生存期。具体来说,用户可以按照int和其他内置类型的方式,将类的实例视为值。
pimpl 包装器管理 pimpl 对象的生命周期。它通常实现特殊的成员函数:复制和移动构造器、复制和移动赋值操作符以及析构函数。它将大多数其他成员函数委托给 pimpl 对象。包装器的用户从来不需要关心这些。
因此,我们将重写artifact类,以便它包装一个 pimpl——即一个指向artifact_impl类的指针。artifact_impl类将完成真正的工作,而artifact将仅仅通过它的 pimpl 转发所有的函数。模块接口只有一个artifact_impl的前向声明。声明没有给编译器提供更多关于这个类的信息,所以这个类的类型是不完整的。对于如何处理不完整类型,您面临着许多限制。特别是,您不能定义该类型的任何对象或数据成员,也不能使用不完整的类作为函数参数或返回类型。不能引用不完整类的任何成员。但是在定义对象、数据成员、函数参数和返回类型时,可以使用指向该类型的指针或引用。特别是,你可以在artifact类中使用一个指向artifact_impl的指针。
普通的类定义是一个完整的类型定义。您可以将前向声明与相同类名的类定义混合使用。常见的模式是模块接口声明一个前向声明,实现模块填充完整的类定义。
因此,artifact类的定义可以有一个数据成员,它是指向artifact_impl的智能指针,即使编译器只知道artifact_impl是一个类,但不知道它的任何细节。接口模块只包含转发声明。实现细节隐藏在实现模块的单独文件中,程序的其余部分可以使用与artifact_impl完全隔离的artifact类。在大型项目中,这种障碍非常重要。
写artifact接口模块并不难。以artifact_impl的前向声明开始。在artifact类中,成员函数的声明与原始类中的相同。将数据成员改为指向artifact_impl的单个指针。阅读清单 65-3 来看看这个模块的一个可能的实现。
export module artifact;
import <filesystem>;
import <memory>;
import <string>;
class artifact_impl;
export class artifact
{
public:
using file_time_type = std::filesystem::file_time_type;
artifact();
artifact(std::string name);
artifact(artifact const&) = default;
artifact(artifact&&) = default;
artifact& operator=(artifact const&) = default;
artifact& operator=(artifact&&) = default;
~artifact() = default;
std::string const& name() const;
file_time_type mod_time() const;
std::string expand(std::string const& str) const;
void build() const;
file_time_type get_mod_time() const;
void define(std::string const& name, std::string const& value);
private:
std::shared_ptr<artifact_impl> pimpl_;
};
export bool operator==(artifact const& lhs, artifact const& rhs) {
return lhs.name() == rhs.name();
}
namespace std {
template<>
export struct hash<artifact> : std::hash<std::string> {
using super = std::hash<std::string>;
std::size_t operator()(artifact const& a) const {
return super::operator()(a.name());
}
};
}
Listing 65-3.Defining an artifact Pimpl Wrapper Class
该模块定义的artifact类只有一个artifact_impl的前向声明和pimpl_数据成员。因为依赖图将不再存储指针,而是存储artifact对象,编译器需要知道如何散列artifact对象,以及如何比较它们是否相等,以便将它们存储在无序容器中。这两个函数仅仅是委托工件的名称。
下一步是编写实现模块。这里编译器需要artifact_impl类的完整定义,从而使artifact_impl成为一个完整的类。artifact类本身做不了多少事情。相反,它只是将每个动作委托给artifact_impl类。详见清单 65-4 。
module artifact;
import <filesystem>;
import <memory>;
import <string>;
import <system_error>;
import variables;
class artifact_impl
{
public:
using file_time_type = std::filesystem::file_time_type;
artifact_impl() : name_{}, mod_time_{file_time_type::min()}, vars_{} {}
artifact_impl(std::string name)
: name_{std::move(name)}, mod_time_{get_mod_time()}, vars_{}
{}
std::string const& name() const { return name_; }
file_time_type mod_time() const { return mod_time_; }
file_time_type get_mod_time()
const
{
std::error_code ec;
auto time{ std::filesystem::last_write_time(name(), ec) };
if (ec)
return file_time_type::min();
else
return time;
}
void define(std::string const& name, std::string const& value)
{
if (not vars_)
vars_ = std::make_unique<variables>();
vars_->define(name, value);
}
std::string expand(std::string const& input)
const
{
if (vars_)
return vars_->expand(input);
else
return global_variables.expand(input);
}
private:
std::string name_;
file_time_type mod_time_;
std::unique_ptr<variables> vars_;
};
artifact::artifact() : pimpl_{std::make_shared<artifact_impl>()} {}
artifact::artifact(std::string name)
: pimpl_(std::make_shared<artifact_impl>(std::move(name)))
{}
std::string const& artifact::name()
const
{
return pimpl_->name();
}
artifact::file_time_type artifact::mod_time()
const
{
return pimpl_->mod_time();
}
std::string artifact::expand(std::string const& str)
const
{
return pimpl_->expand(str);
}
artifact::file_time_type artifact::get_mod_time()
const
{
return pimpl_->get_mod_time();
}
void artifact::define(std::string const& name, std::string const& value)
{
pimpl_->define(name, value);
}
Listing 65-4.Implementing the artifact Module
artifact_impl级不足为奇。该实现就像清单 64-5 中的旧artifact实现一样。
现在是修改depgraph模块的时候了。这一次,artifacts映射直接存储artifact物体。为新的工件类修改清单 63-2 。参见清单 65-5 中重写程序的一种方法。
export module depgraph;
import <iterator>;
import <map>;
import <string>;
import <unordered_map>;
import <unordered_set>;
import artifact;
import topsort;
export class dependency_graph
{
public:
using graph_type = std::unordered_map<artifact,
std::unordered_set<artifact>>;
void store_dependency(std::string const& target_name,
std::string const& dependency_name)
{
auto target{ lookup_artifact(target_name) };
auto dependency{ lookup_artifact(dependency_name) };
store_dependency(target, dependency);
}
graph_type const& graph() const { return graph_; }
template<class OutIter>
requires std::output_iterator<OutIter, artifact>
void sort(OutIter sorted)
const
{
topological_sort(graph_, sorted);
}
artifact lookup_artifact(std::string const& name)
{
auto a{ artifacts_.find(name) };
if (a != artifacts_.end())
return a->second;
else
{
auto [iterator, inserted]{ artifacts_.emplace(name, name) };
return iterator->second;
}
}
private:
void store_dependency(artifact target, artifact dependency)
{
graph_[dependency].insert(target);
graph_[target]; // ensures that target is in the graph
}
graph_type graph_;
std::map<std::string, artifact> artifacts_;
};
Listing 65-5.Rewriting the depgraph Module
如您所见,使用artifact对象的代码更简单、更易读。管理指针的复杂性被推到了artifact和artifact_impl类中。通过这种方式,复杂性保持在一个地方,而不是分散在整个应用程序中。因为使用artifact的代码现在更简单了,所以包含错误的可能性更小了。因为复杂性是本地化的,所以更容易彻底地审查和测试。代价是多一点开发时间,写两个类而不是一个,和多一点维护工作,因为任何时候在artifact公共接口中需要一个新函数,那个函数也必须被添加到artifact_impl。在很多很多情况下,收益远远大于成本,这就是这个成语如此流行的原因。
新的artifact类易于使用,因为您可以像使用int一样使用它。也就是说,您可以复制它、分配它、将它存储在一个容器中,等等,而不用担心一个artifact对象的大小或复制它的成本。不要把一个artifact当作一个又大又胖的对象,或者一个危险的指针,你可以把它当作一个值。用值语义定义一个类使得它易于使用。尽管实现起来需要更多的工作,但是值artifact是编写应用程序时最容易使用的实例。
新设计的一个优点是依赖图和构建系统的独立性。(当然,我们还没有编写一个构建系统,但是这可能是任何真正的类似于make的程序的主要部分。)清单 65-6 显示了主程序的一个小变化,展示了这种可分性。
import <iostream>;
import <iterator>;
import <ranges>;
import <stdexcept>;
import <vector>;
import artifact;
import depgraph;
import parser;
std::vector<artifact> get_dependency_order()
{
dependency_graph graph{};
parse(std::cin, graph);
std::vector<artifact> sorted;
graph.sort(std::back_inserter(sorted));
return sorted;
}
int main()
{
try {
std::vector<artifact> build_list{ get_dependency_order() };
// Print in build order, which is reverse of dependency order.
for (auto artifact : build_list | std::ranges::views::reverse)
{
std::cout << artifact.name() << '\n';
}
} catch (std::runtime_error const& ex) {
std::cerr << ex.what() << '\n';
return EXIT_FAILURE;
}
}
Listing 65-6.New Program Using the parser Module
几个音符。从一个函数中返回一个完整的向量看起来开销很大,但实际上向量被移动到调用者那里,而不是被复制。所以这是从函数中返回字符串列表的合理方式。但更重要的是,依赖图现在是get_dependency_order()函数中的一个临时变量。以前,它必须在程序的整个生命周期中运行,因为所有的工件指针都指向存储在图中的映射。现在,由于共享指针和 pimpls,程序的独立部分确实是独立的。
哑数组
有一个地方,智能指针和 C++ 的所有强大功能都无法帮助我们。函数拖了我们的后腿,把我们束缚在旧的 C 方式上。最简单的形式,没有争论,我们很好。但是对于一个要学习操作系统或命令 shell 传递给程序的命令行参数的程序来说,main()还有另一个签名。清单 65-7 展示了一个简单的类似 echo 的程序,演示了程序如何访问命令行参数。
#include <cstring>
import <iostream>;
int main(int argc, char **argv)
{
if (argc > 1) {
if (std::strcmp(argv[1], "--help") == 0)
std::cout << "usage: " << argv[0] << " [ARGS...]";
else {
std::cout << argv[1];
for (int argn{2}; argn < argc; ++argn)
std::cout << ' ' << argv[argn];
}
}
std::cout << '\n';
}
Listing 65-7.Demonstrating Command-Line Arguments
与任何函数一样,函数参数的名称由您决定。名称 argc 和 argv 仅仅是约定俗成的。正如您所看到和猜测的,第一个函数参数(argc)是命令行参数的数量,第一个是程序名(argv[0])。第二个函数参数argv的声明是一个指向命令行参数字符串的指针数组的指针,每个字符串都是一个以 NUL 终止的数组char,这就是 C 表示字符串的方式。
函数std::strcmp()是 C 语言比较以 NUL 结尾的字符串的方式。它是在<cstring>中声明的,但是因为它是 C 头文件,而不是 C++,所以必须使用 C #include指令。
argv数组有一个额外的条目nullptr,它跟在最后一个命令行参数后面,所以迭代参数的另一种方法是循环直到到达nullptr。清单 65-8 展示了使用这种风格的 echo 程序。
import <iostream>;
import <string_view>;
int main(int, char **argv)
{
if (argv[1] != nullptr) {
if (std::string_view{argv[1]} == "--help")
std::cout << "usage: " << argv[0] << " [ARGS...]";
else {
std::cout << *++argv;
while (*++argv != nullptr)
std::cout << ' ' << *argv;
}
}
std::cout << '\n';
}
Listing 65-8.Alternative Style of Accessing Command-Line Arguments
从以 NUL 结尾的 C 字符串构造一个std::string_view是在 C++ 程序中处理这些遗留问题的最好方法。string_view保存一个指向 C 字符串的指针,而不复制它的内容,所以没有性能损失,并且您可以获得 C++ 字符串的所有便利。图 65-1 展示了 C 如何组织它的命令行参数。
图 65-1。
命令行参数
这就完成了你的指针和内存之旅。现在,您已经知道如何阅读命令行参数,您已经准备好处理文件和文件系统了。
六十六、文件和文件名
除了读写文件,C++ 标准库还具有操作整个文件的功能,例如复制、重命名和删除文件。它也有一个可移植的方式来处理文件名和目录名。让我们看看文件系统库提供了什么。
这个探索中的所有内容都在<filesystem>中声明,并且位于std::filesystem名称空间中。虽然我更喜欢使用完全限定的名称,正如我在之前的 60 篇探索中所做的那样,但是这个名称太长了。在本文和后续研究中,假设使用以下名称空间别名:
namespace fsys = std::filesystem;
可移植文件名
<filesystem>库的关键是一种使用可移植 API 表示文件名和路径的方法,或者至少在使用许多不同文件系统的情况下尽可能地移植,从复杂的网络存储设备到灯泡和其他物联网(IoT)设备。使这成为可能的类是fsys::path。
fsys::path类根据根名称、根目录和相对路径来抽象路径名。相对路径是文件名和分隔符的序列。您可以使用文件系统的首选分隔符或'/',这称为后备分隔符。它也是 UNIX、POSIX 和类似操作系统的首选分隔符。在微软的 Windows 上,首选的分隔符是'\\',它会给 C++ 字符串带来各种各样的麻烦,所以使用'/'通常更容易,甚至在 Windows 上也是如此。
大多数用于桌面工作站和服务器的现代文件系统可以使用 UTF-8 或 UTF-16 编码处理 Unicode 文件名,但回到物联网和类似设备,它们可能不会。如果您需要确保在尽可能多的环境中的可移植性,您应该将文件名限制为字母数字字符、下划线('_')、连字符('-')和句点('.'),它们构成了 POSIX 可移植文件名字符集。
根名称可以是 DOS 驱动器号和冒号或两个分隔符来表示网络名称。它可以是后跟冒号的主机名。根名称的存在是可移植路径 API 的一部分,但是它的含义完全取决于本地环境。目录是分层的,从根开始,由初始分隔符表示。
单个 path 对象可以保存完整的路径名或部分路径名。如果路径包含分隔符,则最后一个分隔符之后的路径部分称为文件名。文件名可以有一个词干,后跟一个扩展名。扩展名是最右边的句点,后跟非句点字符。但是如果唯一的句点是第一个字符,则文件名等于词干,扩展名为空。
您可以从字符串构造一个 path 对象,也可以从多个 path 元素构造一个 path 对象。/操作符被重载,用一个插入的目录分隔符组合路径。给定一个路径对象,您可以将它分解成组成部分,修改各部分,并添加文件名。
清单 66-1 演示了路径对象的各种用法。
import <filesystem>;
import <iostream>;
namespace fsys = std::filesystem;
int main()
{
std::string line;
while (std::getline(std::cin, line))
{
fsys::path path{line};
std::cout <<
"root-name: " << path.root_name() << "\n"
"root-directory: " << path.root_directory() << "\n"
"relative-path: " << path.relative_path() << "\n"
"parent-path: " << path.parent_path() << "\n"
"filename: " << path.filename() << "\n"
"stem: " << path.stem() << "\n"
"extension: " << path.extension() << "\n"
"generic path: " << path.generic_string() << "\n"
"native path: " << path.string() << '\n';
fsys::path newpath;
newpath = path.root_path() / "top" / "subdir" / "stem.ext";
std::cout << "newpath = " << newpath << '\n';
newpath.replace_filename("newfile.newext");
std::cout << "newpath = " << newpath << '\n';
newpath.replace_extension(".old");
std::cout << "newpath = " << newpath << '\n';
newpath.remove_filename();
std::cout << "newpath = " << newpath << '\n';
}
}
Listing 66-1.Demonstrating the path Class
一些命名空间范围的函数对路径名执行其他操作:
-
path absolute(path const& p)将p转换为绝对路径。如果操作系统有当前工作目录的概念,current_path()将该目录作为path对象返回,absolute()可以使用current_path()作为参数p的前缀。 -
path canonical(path const& p)通过删除目录名"."(当前目录)和".."(父目录)并解析符号链接,将p转换为规范路径。 -
path relative(path const& p, path const& base=current_path())将p转换为相对于base的路径。
路径名是你可能遇到国际字符集的另一个地方(探索 59 )。path类型实际上是针对主机环境的basic_path的特化,通常是char或wchar_t。path 类以依赖于操作系统的方式将字符串转换为路径,然后再转换回来。
使用文件
除了文件名,标准库还提供了许多操作整个文件及其属性的函数。以一种可移植的方式查询和操作文件属性,比如权限、日期和时间等等是一个挑战,本书不能涵盖<filesystem>模块中的所有复杂性。这一节触及了一些重点。
一般来说,标准 C++ 库从 POSIX 标准中得到启示。例如,文件权限是 POSIX 权限的直接映射,并且不支持访问控制列表等复杂性。类似地,C++ 文件类型是 POSIX 文件类型的映射,比如套接字、管道和字符设备,尽管允许实现添加其他文件类型。
POSIX 为单个文件提供了两种拥有多个名称的方法。这两种方式都称为链节,分为硬链节和软链节。软链接也称为符号链接或简称符号链接。硬链接是直接指向文件内容的目录条目。相同文件内容的两个硬链接无法区分。fsys::hard_link_count()函数返回指向同一个文件的硬链接的数量。fsys::create_hard_link()函数创建一个新的硬链接。
符号链接是包含另一个文件路径的目录条目。该路径可以是绝对路径,也可以是相对于包含软链接的目录的路径。使用符号链接可能会导致目标路径不存在。要创建新的符号链接,调用fsys::create_directory_symlink()链接到一个目录,调用fsys::create_symlink()链接到一个文件。fsys::read_symlink()函数可以读取任何一种符号链接的内容。
要查询一个文件的属性,调用status(),它返回一个fsys::file_status对象,该对象又有一个permissions()成员函数来返回文件权限,还有type()返回文件类型,比如fsys::file_type::regular。如果文件是符号链接,则返回目标文件的状态。调用fsys::symlink_status()来获取符号链接本身的状态;如果文件不是符号链接,symlink_status()就像status()。此外,fsys::is_regular_file()和类似的功能存在,以直接查询一个文件类型。
可以通过fsys::last_write_time()查询和设置文件的修改时间。C++ 标准库在<chrono>模块中有丰富复杂的日期时间库。它的用途超出了本书的范围。std::format()函数理解文件时间。在冒号之后,使用{:%F %T}表示 ISO 日期和时间,或者使用{:%x %X}表示特定于地区的日期和时间格式。许多其他选项也是可能的。有关详细信息,请查阅最新参考资料。
清单 66-2 通过展示一个非常简单的文件清单程序,类似于 POSIX ls或 DOS dir命令,演示了这些函数的使用。对于第一个程序,它在命令行上只需要一个文件名。随着探索的进展,我们将扩展这个小程序的功能。
import <filesystem>;
import <format>;
import <iostream>;
import <iterator>;
namespace fsys = std::filesystem;
void print_file_type(std::ostream& stream, fsys::path const& path)
{
auto status{ fsys::symlink_status(path) };
if (fsys::is_symlink(status)) {
auto link{ fsys::read_symlink(path) };
stream << " -> " << link.generic_string();
}
else if (fsys::is_directory(status))
stream << '/';
else if (fsys::is_fifo(status))
stream << '|';
else if (fsys::is_socket(status))
stream << '=';
else if (fsys::is_character_file(status))
stream << "(c)";
else if (fsys::is_block_file(status))
stream << "(b)";
else if (fsys::is_other(status))
stream << "?";
}
void print_file_info(std::ostream& stream, fsys::path const& path)
{
std::format_to(std::ostreambuf_iterator<char>(stream),
"{0:>16} {1:%F %T} ",
fsys::file_size(path),
fsys::last_write_time(path));
stream << path.generic_string();
print_file_type(stream, path);
stream << '\n';
}
int main(int, char** argv)
{
if (argv[1] == nullptr)
{
std::cerr << "usage: " << argv[0] << " FILENAME\n";
return EXIT_FAILURE;
}
fsys::path path{ argv[1] };
try
{
print_file_info(std::cout, path);
}
catch(fsys::filesystem_error const& ex)
{
std::cerr << ex.what() << '\n';
}
}
Listing 66-2.Demonstrating the path Class
fsys::copy_symlink()函数顾名思义,创建一个包含现有符号链接副本的新符号链接。fsys::copy_file()函数创建一个新文件,并将现有文件的内容复制到新文件中。可选的最终参数允许您控制是否允许覆盖现有文件。fsys::copy()功能结合了其他复印功能及更多功能。复制选项指示它应该如何处理符号链接和目录,甚至允许递归复制整个目录树。
要重命名文件,调用fsys::rename(),要删除文件,调用fsys::remove()。要删除整个目录树,调用fsys::remove_all()。存在许多其他文件级函数;有关详细信息,请查阅好的参考资料。
错误
如果您尝试过运行清单 66-2 中的程序,或者自己做过实验,您可能会发现文件系统库会对任何类型的错误或异常结果抛出异常。通常,您会遇到某些问题,例如,如果用户键入错误的文件名,文件就会丢失。权限错误很常见,等等。所以这个库让你决定是抛出一个异常还是返回一个错误代码。
当您认为错误很常见时,将一个std::error_code对象作为附加的最终参数传递给任何文件系统函数。该函数将始终存储一个结果,而不是抛出一个异常,如果成功,该异常将为零,如果失败,则为其他值。将error_code视为布尔值意味着错误为真,成功为假。如果这让你感到困扰,.value()成员函数将代码作为一个整数返回,你可以显式地与零进行比较。
重写清单 66-2 使用 error_code 代替依赖异常。这也意味着接受多个命令行参数是有意义的。程序可以为每个参数发出一条错误消息,而不是在第一次出错时就终止。您可以将error_code本身打印到一个输出流中,但这只会显示数字代码。message()成员函数返回相应的字符串消息。参见我在清单 66-3 中的重写。
import <filesystem>;
import <format>;
import <iostream>;
import <iterator>;
import <string_view>;
import <system_error>;
namespace fsys = std::filesystem;
void print_file_type(std::ostream& stream, fsys::path const& path, fsys::file_status status)
{
if (fsys::is_symlink(status)) {
std::error_code ec;
auto link{ fsys::read_symlink(path, ec) };
if (ec)
stream << ": " << ec.message();
else
stream << " -> " << link.generic_string();
}
else if (fsys::is_directory(status))
stream << '/';
else if (fsys::is_fifo(status))
stream << '|';
else if (fsys::is_socket(status))
stream << '=';
else if (fsys::is_character_file(status))
stream << "(c)";
else if (fsys::is_block_file(status))
stream << "(b)";
else if (fsys::is_other(status))
stream << "?";
}
// There may be many reasons why a file has no size, e.g., it is
// a directory. So don't treat it as an error--just return zero.
uintmax_t get_file_size(fsys::path const& path)
{
std::error_code ec;
auto size{ fsys::file_size(path, ec) };
if (ec.value() != 0)
return 0;
else
return size;
}
// Similarly, return a false timestamp for any error.
fsys::file_time_type get_last_write_time(fsys::path const& path)
{
std::error_code ec;
auto time{ fsys::last_write_time(path, ec) };
if (ec)
return fsys::file_time_type{};
else
return time;
}
void print_file_info(std::ostream& stream, fsys::path const& path)
{
std::error_code ec;
auto status{ fsys::symlink_status(path, ec) };
if (ec)
stream << path.generic_string() << ": " << ec.message();
else
{
std::format_to(std::ostreambuf_iterator<char>(stream),
"{0:>16} {1:%F %T} {2}",
get_file_size(path),
get_last_write_time(path),
path.generic_string());
print_file_type(stream, path, status);
}
stream << '\n';
}
int main(int, char** argv)
{
if (argv[1] == nullptr or std::string_view(argv[1]) == "--help")
{
std::cerr << "usage: " << argv[0] << " FILENAME\n";
return EXIT_FAILURE;
}
while (*++argv != nullptr)
{
fsys::path path{ *argv };
print_file_info(std::cout, path);
}
}
Listing 66-3.Examining Errors with error_code
下一个任务是递归进入目录。下一节将介绍目录条目和迭代器。
导航目录
目录(通常称为文件夹)包含文件条目,可以是任何类型的文件,包括另一个目录。要发现目录中的条目,需要构造一个目录迭代器。使用fsys::directory_iterator查看单个目录中的条目,或者使用fsys::recursive_directory_iterator遍历子目录中的条目。像往常一样,用可选的error_code参数构造带有目录路径的迭代器类型。即使目录迭代器是一个迭代器,它也可以在一个 ranged for循环或 ranged 函数中用作一个范围。
目录迭代器的值类型是fsys::directory_entry,它包含文件的名称、状态和其他信息。所有的操作系统和文件系统在细节上有所不同,但是通常迭代目录的行为检索关于文件的信息,因此不需要进行单独的系统调用来获得相同的信息。因此,directory_entry存储文件状态、修改时间等等,否则您必须调用fsys函数来获取这些信息。
根据这些信息,你现在可以修改清单 66-3 到目录下。我的版本在清单 66-4 中。请注意我也是如何在命令行中使用directory_entry命名文件的。这通过一种显示文件信息的方式简化了代码。
import <filesystem>;
import <format>;
import <iostream>;
import <iterator>;
import <system_error>;
namespace fsys = std::filesystem;
void print_file_type(std::ostream& stream, fsys::directory_entry const& entry)
{
auto status{ entry.symlink_status() };
if (fsys::is_symlink(status)) {
std::error_code ec;
auto link{ fsys::read_symlink(entry.path(), ec) };
if (ec)
stream << ": " << ec.message();
else
stream << " -> " << link.generic_string();
}
else if (fsys::is_directory(status))
stream << '/';
else if (fsys::is_fifo(status))
stream << '|';
else if (fsys::is_socket(status))
stream << '=';
else if (fsys::is_character_file(status))
stream << "(c)";
else if (fsys::is_block_file(status))
stream << "(b)";
else if (fsys::is_other(status))
stream << "?";
}
// There may be many reasons why a file has no size, e.g., it is
// a directory. So don't treat it as an error--just return zero.
uintmax_t get_file_size(fsys::directory_entry const& entry)
{
std::error_code ec;
auto size{ entry.file_size(ec) };
if (ec)
return 0;
else
return size;
}
// Similarly, return a false timestamp for any error.
fsys::file_time_type get_last_write_time(fsys::directory_entry const& entry)
{
std::error_code ec;
auto time{ entry.last_write_time(ec) };
if (ec)
return fsys::file_time_type{};
else
return time;
}
void print_file_info(std::ostream& stream, fsys::directory_entry const& entry)
{
std::format_to(std::ostreambuf_iterator<char>(stream),
"{0:>16} {1:%F %T} {2}",
get_file_size(entry),
get_last_write_time(entry),
entry.path().generic_string());
print_file_type(stream, entry);
stream << '\n';
if (not entry.is_symlink() and entry.is_directory())
{
for (auto&& entry : fsys::directory_iterator{entry.path()})
print_file_info(stream, entry);
}
}
int main(int, char** argv)
{
if (argv[1] == nullptr or std::string_view(argv[1]) == "--help")
{
std::cerr << "usage: " << argv[0] << " FILENAME\n";
return EXIT_FAILURE;
}
while (*++argv != nullptr)
{
fsys::path path{ *argv };
std::error_code ec;
fsys::directory_entry entry{ path, ec };
if (ec)
std::cout << *argv << ": " << ec.message() << '\n';
else
print_file_info(std::cout, entry);
}
}
Listing 66-4.Recursing into Directories
下一个主题深入到 C++ 的位和字节。
六十七、处理位
这是一系列探索的开始,涵盖了 C++ 类型系统中更高级的主题。本系列文章首先探讨了如何处理单个位。这种探索从在比特级别操作整数的操作符开始,然后引入比特域——一种完全不同的处理比特的方式。最后一个主题是bitset类模板,它允许您使用任何大小的位集。
作为一组位的整数
计算机编程中的一个常见习惯是将整数视为位掩码。这些位可以表示一组小整数,使得如果位置 n 处的位为 1,则值 n 是该组的成员;如果相应的位为零,则 n 不在集合中。空集的数值为零,因为所有的位都是零。为了更好地理解这是如何工作的,考虑一下 I/O 流格式化标志(在 Exploration 39 中介绍)。
通常,使用操纵器来设置和清除标志。例如,Exploration 17 推出了skipws和noskipws机械手。这些操纵器通过调用setf和unsetf成员函数来设置和清除std::ios_base::skipws标志。换句话说,下面的语句
std::cin >> std::noskipws >> read >> std::skipws;
完全等同于
std::cin.unsetf(std::ios_base::skipws);
std::cin >> read;
std::cin.setf(std::ios_base::skipws);
其他格式标志包括boolalpha(在探索 12 中引入)、showbase(探索 58 )、showpoint(显示小数点,即使它本来会被隐藏),以及showpos(显示正数的加号)。查阅 C++ 参考资料,了解其余的格式化标志。
格式化标志的一个简单实现是将标志存储在一个int中,并为每个标志分配一个特定的位位置。编写以这种方式定义的标志的一种常见方式是使用十六进制表示法,如清单 67-1 所示。用0x或0X写一个十六进制整数文字,后面跟一个基数为 16 的值。大写或小写的字母A到F代表 10 到 15。(C++ 标准并不要求格式化标志的任何特定实现。您的库可能以不同的方式实现格式化标志。)
using fmtflags = int;
fmtflags const showbase = 0x01;
fmtflags const boolalpha = 0x02;
fmtflags const skipws = 0x04;
fmtflags const showpoint = 0x08;
fmtflags const showpos = 0x10;
// etc. for other flags...
Listing 67-1.An Initial Definition of Formatting Flags
下一步是编写setf和unsetf函数。前一个函数在flags_数据成员(属于std::ios_base类)中设置特定的位,后一个函数清除位。为了设置和清除位,C++ 提供了一些操作整数中各个位的运算符。总的来说,它们被称为按位运算符。
按位运算符执行通常的算术提升和转换(探索 26 )。然后,运算符对其参数中的连续位执行运算。&运算符实现按位和;|运算符实现按位包含或;而~运算符是一元运算符,用于执行按位求补。图 67-1 展示了这些运算符的按位性质(以&为例)。
图 67-1。
&(按位 and)运算符的工作原理
Operator Abuse
您可能会觉得奇怪(我当然会觉得奇怪),C++ 使用相同的操作符来获取对象的地址,并执行按位和。只有这么多角色可供选择。逻辑和运算符也用于右值引用。星号有双重功能:乘法和指针或迭代器的解引用。区别在于运算符是一元(一个操作数)还是二元(两个操作数)。所以没有歧义,只有不寻常的语法。在后面的探索中,您将了解到 I/O 操作符是改变用途的移位操作符。不过不用担心,你会习惯的。最终。
实现 setf 功能。这个函数接受一个单独的fmtflags参数,并在flags_数据成员中设置指定的标志。清单 67-2 显示了一个简单的解决方案。
void setf(fmtflags f)
{
flags_ = flags_ | f;
}
Listing 67-2.A Simple Implementation of the setf Member Function
unsetf函数稍微复杂一些。它必须清除标志,这意味着将相应的位设置为零。换句话说,该参数指定了一个位掩码,其中每个 1 位意味着清除(设置为 0)在flags_中的位。编写 unsetf 功能。将您的解决方案与清单 67-3 进行比较。
void unsetf(fmtflags f)
{
flags_ = flags_ & ~f;
}
Listing 67-3.A Simple Implementation of the unsetf Member Function
回想一下 Exploration 49 中,各种赋值运算符将算术运算符与赋值运算符结合在一起。赋值操作符也存在于按位函数中,所以你可以更简洁地编写这些函数,如清单 67-4 所示。
void setf(fmtflags f)
{
flags_ |= f;
}
void unsetf(fmtflags f)
{
flags_ &= ~f;
}
Listing 67-4.Using Assignment Operators in the Flags Functions
回想一下 Exploration 50 中的|操作符组合了 I/O 模式标志。现在你知道标志是位,I/O 模式是位掩码。如果需要,您可以在 I/O 模式上使用任何按位运算符。
位掩码
并非所有标志都是单独的位。例如,对准标志可以是left、right或internal。浮点型可以是fixed、scientific、hexfloat或通用。要表示三个或四个值,需要两位。对于这些情况,C++ 有一个双参数形式的setf函数。第一个参数指定要在字段中设置的位掩码,第二个参数指定要影响的位的掩码。
使用相同的位操作符,您可以将adjustfield定义为两位宽度的位掩码,例如0x300。如果两位都清零,这可能意味着向左调整;一位设置意味着正确调整;另一个位可能意味着“内部”对齐(在一个符号或十六进制值中的0x后对齐)。这样就多了一个可能的值(两个位都被设置),但是标准库只定义了三个不同的对齐值。
清单 67-5 显示了adjustfield和floatfield掩码及其相关值的一种可能实现。
fmtflags static constexpr adjustfield = 0x300;
fmtflags static constexpr left = 0x000;
fmtflags static constexpr right = 0x100;
fmtflags static constexpr internal = 0x200;
fmtflags static constexpr floatfield = 0xC00;
fmtflags static constexpr scientific = 0x400;
fmtflags static constexpr fixed = 0x800;
fmtflags static constexpr hexfloat = 0xC00;
// general does not have a name; its value is zero
Listing 67-5.Declarations for Formatting Fields
因此,为了将对齐设置为right,需要调用setf(right, adjustfield)。写出 setf 函数的双参数形式。将您的解决方案与清单 67-6 进行比较。
void setf(fmtflags flags_to_set, fmtflags field)
{
flags_ &= ~field;
flags_ |= flags_to_set;
}
Listing 67-6.Two-Argument Form of the setf Function
用这种方式定义位域的一个困难是,数值可能很难阅读,除非您花了很多时间处理十六进制值。另一个解决方案是对所有标志和字段使用更熟悉的整数,让计算机通过将这些值转移到正确的位置来完成这项艰巨的工作。
移位
清单 67-7 显示了定义格式化字段的另一种方式。它们表示与清单 67-1 所示完全相同的值,但是它们更容易校对。
int static constexpr boolalpha_pos = 0;
int static constexpr showbase_pos = 1;
int static constexpr showpoint_pos = 2;
int static constexpr showpos_pos = 3;
int static constexpr skipws_pos = 4;
int static constexpr adjust_pos = 5;
int static constexpr adjust_size = 2;
int static constexpr float_pos = 7;
int static constexpr float_size = 2;
fmtflags static constexpr boolalpha = 1 << boolalpha_pos;
fmtflags static constexpr showbase = 1 << showbase_pos;
fmtflags static constexpr showpos = 1 << showpos_pos;
fmtflags static constexpr showpoint = 1 << showpoint_pos;
fmtflags static constexpr skipws = 1 << showpoint_pos;
fmtflags static constexpr adjustfield = 3 << adjust_pos;
fmtflags static constexpr floatfield = 3 << float_pos;
fmtflags static constexpr left = 0 << adjust_pos;
fmtflags static constexpr right = 1 << adjust_pos;
fmtflags static constexpr internal = 2 << adjust_pos;
fmtflags static constexpr fixed = 1 << float_pos;
fmtflags static constexpr scientific = 2 << float_pos;
fmtflags static constexpr hexfloat = 3 << float_pos;
Listing 67-7.Using Shift Operators to Define the Formatting Fields
<<操作符(看起来就像输出操作符)是左移操作符。它将左边的运算符(必须是整数)移动右边的运算符指定的位数(也是整数)。空出的位用零填充。
1 << 2 == 4
10 << 3 == 80
尽管这种风格更冗长,但您可以清楚地看到这些位是用相邻的值定义的。您也可以很容易地看到多位位掩码的大小。如果您必须添加一个新标志,您可以这样做,而无需重新计算任何其他字段或标志。
什么是 C++ 右移运算符? ________________ 没错:>>,也是输入运算符。
如果右边的操作数是负的,则反转移位的方向。也就是说,左移负量等同于右移正量,反之亦然。可以对整数使用移位运算符,但不能对浮点数使用。右侧操作数不能大于左侧操作数的位数。(使用探索 26 中介绍的numeric_limits类模板,来确定一个类型中的位数,比如int。)
C++ 标准库重载 I/O 流类的移位操作符来实现 I/O 操作符。因此,>>和<<运算符是为移位整数中的位而设计的,后来被 I/O 所取代。因此,运算符优先级对于 I/O 来说并不完全正确。特别是,移位运算符的优先级高于位运算符,因为这对于操作位最有意义。因此,例如,如果您想要打印按位运算的结果,则必须用括号将表达式括起来。
std::cout << "5 & 3 = " << (5 & 3) << '\n';
使用右移运算符时需要注意的一点是:填充的位的值是由实现定义的。这对于负数来说尤其成问题。值-1 >> 1在某些实现上可能是正的,而在其他实现上可能是负的。幸运的是,C++ 有办法避免这种不确定性,下一节将对此进行解释。
无符号类型的安全移位
每个原始整数类型都有一个用关键字unsigned声明的对应类型。毫不奇怪,这些类型被称为无符号类型。普通(或有符号)整数类型和它们的无符号等效类型之间的一个关键区别是,无符号类型在右移时总是移零。因此,对于实现位掩码,无符号类型比有符号类型更可取。
using fmtflags = unsigned int;
写一个程序来确定你的 C++ 环境如何右移负值。将此与移位无符号值进行比较。你的程序看起来肯定与我的不同,如清单 67-8 所示,但是你应该能够识别出关键的相似之处。
import <iostream>;
import <string_view>;
template<class T>
void print(std::string_view label, T value)
{
std::cout << label << " = ";
std::cout << std::dec << value << " = ";
std::cout.width(8);
std::cout.fill('0');
std::cout << std::hex << std::internal << std::showbase << value << '\n';
}
int main()
{
int i{~0}; // all bits set to 1; on most systems, ~0 == -1
unsigned int u{~0u}; // all bits set to 1
print("int >> 15", i >> 15);
print("unsigned >> 15", u >> 15);
}
Listing 67-8.Exploring How Negative and Unsigned Values Are Shifted
在我的 Linux x86 系统上,我看到以下输出
int >> 15 = -1 = 0xffffffff
unsigned >> 15 = 131071 = 0x01ffff
这意味着右移一个有符号的值会用符号位的副本填充空出的位(这个过程称为符号扩展 n ),右移一个无符号的值会通过移入零位来正确工作。
有符号和无符号类型
普通的int型是signed int的简写。也就是说,int型有两种标志口味:signed int和unsigned int,默认为signed int。同理,short int与signed short int相同,long int与signed long int相同。因此,您没有理由对整数类型使用signed关键字。
然而,和许多规则一样,这一条也有例外:signed char。char型有三种口味,而不是两种:char、signed char和unsigned char。这三种类型占用相同的空间(一个字节)。普通char类型与signed char或unsigned char具有相同的表示,但它仍然是一个独特的类型。选择权留给了编译器;查阅你的编译器文档来了解你的实现的等价类型。因此,signed关键字可以用于signed char类型;当节省内存很重要时,signed char最常见的用途是表示一个微小的有符号整数。对文本使用普通的char,对微小的整数使用signed char,对微小的位掩码使用unsigned char。
不幸的是,I/O 流类将signed char和unsigned char视为文本,而不是微小的整数或位掩码。因此,读取或写入微小的整数比它应该的要困难。I/O 流类不是读写整数,而是读写单个字符,将signed char或unsigned char转换为char。一个简单的输出解决方案是调用std::format('{0}', byte),因为format()避免了流的 sin,并将char格式化为字符,bool格式化为bool,所有其他整数类型格式化为数字。输入更难。最好的解决方案可能是编写自己的函数来读取一个整数,并将其转换为所需的字节大小的整数类型。
无符号文字
如果整数文字不适合放在signed int中,编译器会尝试让它适合放在unsigned int中。如果这行得通,字面量的类型就是unsigned int。如果该值对于unsigned int来说太大,编译器会尝试long,然后unsigned long,然后long long,最后unsigned long long,然后放弃并发出错误消息。
您可以强制一个带有u或U后缀的整数为无符号整数。对于一个unsigned long字面量,U和L后缀可以以任何顺序出现。用ULL代替unsigned long long。(记住 C++ 允许小写l,但是我推荐大写L以避免与数字1混淆。)
1234u
4321UL
0xFFFFLu
这种灵活性的一个结果是,您不能总是知道整数文字的类型。例如,在 64 位系统上,0xFFFFFFFF的类型可能是int。在一些 32 位系统上,类型可能是unsigned int,而在其他系统上,类型可能是unsigned long。寓意是确保你写的代码能正确工作,不管整数文字的精确类型是什么,这并不难。例如,本书中的所有程序和片段都可以在任何 C++ 编译器上运行,不管一个int有多大。
类型转换
有符号类型和它的无符号对应类型总是占据相同的空间。您可以使用static_cast (Exploration 26 将一个转换成另一个,或者您可以让编译器隐式执行转换,如果您不小心的话,这可能会导致意外。考虑清单 67-9 中的例子。
import <iostream>;
void show(unsigned u)
{
std::cout << u << '\n';
}
int main()
{
int i{-1};
std::cout << i << '\n';
show(i);
}
Listing 67-9.Mixing Signed and Unsigned Integers
这会在我的系统上产生以下输出:
-1
4294967295
如果在一个表达式中混合有符号值和无符号值(通常是一个坏主意),编译器会将有符号值转换为无符号值,这通常会导致更多的意外。这种惊讶往往出现在比较中。大多数编译器至少会警告你这个问题。
import <algorithm>;
import <iostream>;
import <iterator>;
import <vector>;
template<class T>
void append(std::vector<T>& data, const T& value, int max_size)
{
if (data.size() < max_size - 1)
data.push_back(value);
}
int main()
{
std::vector<int> data{};
append(data, 10, 3);
append(data, 20, 2);
append(data, 30, 1);
append(data, 40, 0);
append(data, 50, 0);
std::ranges::copy(data, std::ostream_iterator<int>(std::cout, " "));
std::cout << '\n';
}
Listing 67-10.Mystery Program
在运行程序之前,预测什么清单 67-10 将打印。
试试看。你是对的吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _解释这个程序做什么。
程序成功地将10附加到data上,因为向量大小为零,小于 2。然而,对append的下一个调用什么都不做,因为向量大小是1,而max_size - 1也是1。由于类似的原因,下一次调用失败。那么为什么下一个调用成功地将40追加到了data?因为max_size是0,你可能会认为比较是和-1进行的,但是-1是有符号的,而data.size()是无符号的。因此,编译器将-1转换为 unsigned,这是一种实现定义的转换。在典型的工作站上,-1转换成最大的无符号整数,因此测试成功。
这个故事的第一个寓意是避免混合有符号和无符号值的表达式。当您混合有符号和无符号值时,您的编译器可能会通过发出警告来帮助您。无符号值的一个常见来源是标准库中的size()成员函数,它们都返回一个无符号结果。您可以使用标准的 typedefs 来定义大小,例如std::size_t,这是一种实现定义的无符号整数类型,从而减少出现意外的机会。标准容器都定义了一个成员类型,size_type,来表示容器的大小和类似的值。当您知道必须存储大小、索引或计数时,请为变量使用这些 typedefs。
“那容易!”你说。"只要把max_size的声明改成std::vector<T>::size_type,问题就解决了!"也许你可以通过坚持使用标准成员类型定义来避免这种问题,比如size_type和difference_type(探索 55 )。看看清单 67-11 ,看看你怎么想。
import <algorithm>;
import <iostream>;
import <iterator>;
import <vector>;
/** Return the index of a value in a range.
* Look for the first occurrence of @p value in the range
* <tt>first</tt>, <tt>last</tt>), and return the zero-based
* index or -1 if @p value is not found.
* @param first The start of the range to search
* @param last One past the end of the range to search
* @param value The value to search for
* @return [0, size), such that size == last-first, or -1
*/
template<class Range>
requires std::ranges::forward_range<Range>
std::ranges::range_difference_t<Range>
index_of(Range const& range, std::ranges::range_value_t<Range> const& value)
{
auto iter{std::ranges::find(range, value)};
if (iter == std::ranges::end(range))
return -1;
else
return std::distance(std::ranges::begin(range), iter);
}
/** Determine whether the first occurrence of a value in a container is
* in the last position in the container.
* @param container Any standard container
* @param value The value to search for.
* @return true if @p value is at the last position,
* or false if @p value is not found or at any other position.
*/
template<class T>
bool is_last(T const& container, typename T::value_type const& value)
{
return index_of(container, value) == container.size() - 1;
}
int main()
{
std::vector<int> data{};
if (is_last(data, 10))
std::cout << "10 is the last item in data\n";
}
Listing 67-11.Another Mystery Program
在运行清单 [67-11 中的程序之前预测输出。
试试看。你到底得到了什么?
我得到“10 是数据中的最后一项”,尽管data显然是空的。你能发现我犯的概念性错误吗?在标准容器中,difference_type typedef 总是有符号整数类型。因此,index_of()总是返回一个有符号的值。我错误地认为有符号值-1总是小于任何无符号值,因为它们总是大于或等于0。因此,is_last()不必作为特例检查空容器。
我没有考虑到的是,当 C++ 表达式混合了有符号和无符号值时,编译器会将有符号值转换为无符号值。因此,来自index_of的有符号结果变成无符号,并且-1变成最大可能的无符号值。如果容器为空,size()为零,size() - 1(编译器解释为size() - 1u)也是最大可能的无符号整数。
如果幸运的话,编译器会发出一个关于比较有符号和无符号值的警告。这给了你一个暗示,有些事情是错误的。修复程序。将您的解决方案与清单 67-12 进行比较。
import <algorithm>;
import <iostream>;
import <iterator>;
import <vector>;
/** Return the index of a value in a range.
* Look for the first occurrence of @p value in the range
* <tt>first</tt>, <tt>last</tt>), and return the zero-based
* index or -1 if @p value is not found.
* @param first The start of the range to search
* @param last One past the end of the range to search
* @param value The value to search for
* @return [0, size), such that size == last-first, or -1
*/
template<class Range>
requires std::ranges::forward_range<Range>
std::ranges::range_difference_t<Range>
index_of(Range const& range, std::ranges::range_value_t<Range> const& value)
{
auto iter{std::ranges::find(range, value)};
if (iter == std::ranges::end(range))
return -1;
else
return std::distance(std::ranges::begin(range), iter);
}
/** Determine
whether the first occurrence of a value in a container is
* in the last position in the container.
* @param container Any standard container
* @param value The value to search for.
* @return true if @p value is at the last position,
* or false if @p value is not found or at any other position.
*/
template<class T>
bool is_last(T const& container, typename T::value_type const& value)
{
auto index{ index_of(container, value) };
decltype(index) last{ container.size() - 1 };
return index == last;
}
int main()
{
std::vector<int> data{};
if (is_last(data, 10))
std::cout << "10 is the last item in data\n";
}
Listing 67-12.Fixing the Second Mystery Program
有许多方法可以确保比较的双方具有相同的类型。decltype()操作符接受一个表达式并产生该表达式的类型,而不对该表达式求值。在这种情况下,它只是用来匹配变量index的类型。
这个故事的第二个寓意是,如果没有必要,不要使用无符号类型。大多数时候,有符号类型工作得也很好。仅仅因为一个类型的合法值的范围恰好是非负的,并不是使用无符号类型的理由。这样做只会使任何必须与无符号类型协作的代码变得复杂。
但是,我听你说过,每个带有size()成员函数的类都返回一个无符号的std::size_t值。当 C++ 标准库本身没有注意到这个极好的建议时,我如何避免混合有符号和无符号类型呢?作为解决这个问题的一小步,您可以为任何知道其大小的容器或范围调用std::ranges::ssize()。因此,函数将大小作为有符号整数返回。也许如果ssize()变得流行,C++ 标准的未来修订版会强制所有容器都使用它。
Tip
使用标准库时,利用它提供的 typedef 和成员 typedef。当您可以控制类型时,请对所有数值类型(包括大小)使用有符号类型,并将无符号类型保留为位掩码。每当你写一个表达式,使用无符号类型和其他整数的时候,一定要非常非常小心。
泛滥
到目前为止,我已经告诉你忽略算术溢出。那是因为这是个很难的话题。严格地说,如果一个包含有符号整数或浮点数的表达式溢出,结果是不确定的。实际上,典型的桌面系统会包装整数溢出(因此将两个正数相加会产生一个负数)。浮点数溢出可能产生无穷大,或者程序可能终止。
如果必须防止溢出,应该在计算表达式之前检查值,以确保表达式不会溢出。使用std::numeric_limits<>检查该类型的min()和max()值。
如果您显式地将一个有符号的值强制转换为一个类型,使得该值溢出目标类型,结果就不会那么糟糕。大多数实现简单地丢弃多余的比特。因此,为了获得最大的安全性和可移植性,您应该检查溢出。使用numeric_limits(探索 [26 )学习一个类型的最大值或最小值。
无符号整数则不同。该标准明确允许无符号算术溢出。结果是丢弃任何额外的高阶位。从数学上来说,这意味着无符号算术是模 2 n ,其中 n 是无符号类型的位数。如果您必须执行您知道可能溢出的算术运算,并且您希望值回绕而不报告错误,则使用static_cast转换为相应的无符号类型,执行算术运算,然后static_cast返回原始类型。static_cast对性能没有影响,但是它们清楚地告诉编译器和人类正在发生什么。
旋转整数
尽管 C++ 有用于移位的操作符,但它缺少用于旋转的操作符。但是它在<bit>中有旋转比特、计数比特等功能。清单 67-13 展示了一些这样的函数。
import <bit>;
import <iostream>;
int main()
{
std::cout << std::hex << std::showbase <<
"std::rotl(0x12345678U, 8) = " << std::rotl(0x12345678U, 8) <<
"std::rotr(0x12345678U, 8) = " << std::rotr(0x12345678U, 8) <<
std::dec <<
"std::popcount(0x0110110U) = " << std::popcount(0x0110110U) <<
"std::bit_width(0x00ffffU) = " << std::bit_width(0x00ffffU) <<
'\n';
}
Listing 67-13.Examples from the <bit> Module
<bit>模块有几个其他的位调整函数,用于计算整数中的位数、测试字符顺序等等。
比特域简介
一个位域是一种将一个类中的整数分割成单个位或相邻位的掩码的方法。使用无符号整数类型或bool、字段名、冒号和字段中的位数声明一个位字段。清单 67-14 展示了如何使用位域存储 I/O 格式化标志。
struct fmtflags
{
bool skipws_f : 1;
bool boolalpha_f: 1;
bool showpoint_f: 1;
bool showbase_f: 1;
bool showpos_f: 1;
unsigned adjustfield_f: 2;
unsigned floatfield_f: 2;
static unsigned constexpr left = 0;
static unsigned constexpr right = 1;
static unsigned constexpr internal = 2;
static unsigned constexpr fixed = 1;
static unsigned constexpr scientific = 2;
static unsigned constexpr hexfloat = 3;
};
Listing 67-14.Declaring Formatting Flags with Bitfields
像使用任何其他数据成员一样使用位字段成员。例如,要设置skipws标志,使用
flags.skipws_f = true;
要清除该标志,请使用以下命令:
flags.skipws_f = false;
要选择科学记数法,请尝试以下代码:
flags.floatfield_f = fmtflags::scientific;
如您所见,使用位字段的代码比使用移位和位运算符的等效代码更容易读写。这就是位域受欢迎的原因。另一方面,很难写出setf、unsetf这样的函数。很难一次获取或设置多个不相邻的位。这就是为什么你的库可能不使用位域来实现 I/O 格式化标志。
另一个限制是您不能获取位域的地址(使用&操作符),因为在 C++ 内存模型中,一个单独的位是不可直接寻址的。
尽管如此,当选择一个实现时,位域提供的清晰性将它们放在列表的首位。有时,其他因素会将它们排除在列表之外,但您应该始终首先考虑位字段。有了位字段,您就不必关心按位运算符、移位运算符、混合运算符优先级等等。
轻便
C++ 标准将一些细节留给了每个实现。特别是,字段中的比特顺序由实现决定。一个位域不能跨越一个字边界,在这个字边界上一个字的定义也由实现来决定。流行的桌面和工作站计算机通常使用 32 位或 64 位,但不能保证一个字与一个int一样大。大小为零的未命名位域告诉编译器插入填充位,以便后续声明在字边界对齐。
class demo {
unsigned bit0 : 1;
unsigned bit1 : 1;
unsigned bit2 : 3;
unsigned : 0;
unsigned word1: 2;
};
一个demo对象的大小取决于实现。在demo的实际实现中,bit0是最低位还是最高位也因系统而异。bit2和word1之间的填充位数也取决于实现方式。
大多数代码不需要知道内存中位的布局。另一方面,如果您正在编写解释硬件控制寄存器中的位的代码,您必须知道位的顺序、填充位的确切性质等等。但是无论如何,你可能不期望编写高度可移植的代码。在最常见的情况下,当您试图表达单个集合成员的紧凑集合或小的位掩码时,位域非常有用。它们易于书写和阅读。然而,它们仅限于单个字,通常为 32 位。对于更大的位域,必须使用一个类,比如std::bitset。
bitset类模板
有时你必须存储比一个整数所能容纳的更多的位。在这种情况下,您可以使用std::bitset类模板,它实现了任意大小的固定大小的位串。
std::bitset类模板接受一个模板参数:集合中的位数。像使用任何其他值一样使用一个bitset对象。它支持所有的按位和移位操作符,为了更加方便,还支持一些成员函数。bitset可以执行的另一个巧妙的技巧是下标操作符,它允许你访问集合中作为离散对象的单个位。最右边(最低有效)的位在索引零处。从一个无符号长整型(设置bitset的最低有效位,将剩余位初始化为零)或从一串'0'和'1'字符构造一个bitset,如清单 67-15 所示。
import <bitset>;
import <iostream>;
/** Find the first 1 bit in a bitset, starting from the most significant bit.
* @param bitset The bitset to examine
* @return A value in the range [0, bitset.size()-1) or
* size_t(-1) if bitset.none() is true.
*/
template<std::size_t N>
std::size_t first(std::bitset<N> const& bitset)
{
for (std::size_t i{bitset.size()}; i-- != 0;)
if (bitset.test(i))
return i;
return std::size_t(-1);
}
int main()
{
std::bitset<50> lots_o_bits{"1011011101111011111011111101111111"};
std::cout << "bitset: " << lots_o_bits << '\n';
std::cout << "first 1 bit: " << first(lots_o_bits) << '\n';
std::cout << "count of 1 bits: " << lots_o_bits.count() << '\n';
lots_o_bits[first(lots_o_bits)] = false;
std::cout << "new first 1 bit: " << first(lots_o_bits) << '\n';
lots_o_bits.flip();
std::cout << "bitset: " << lots_o_bits << '\n';
std::cout << "first 1 bit: " << first(lots_o_bits) << '\n';
}
Listing 67-15.Example of Using std::bitset
在 Exploration 25 中,我将static_cast<>作为一种将一个整数转换成不同类型的方法。清单 67-14 展示了另一种转换整数类型的方法,使用构造器和初始化器语法:std::size_t(-1)或std::size{-1}。对于简单的类型转换,这种语法通常比static_cast<>更容易阅读。我建议只在转换文字时使用这种语法;对于更复杂的表达式,使用static_cast<>。
与使用位字段不同,bitset的大部分行为是完全可移植的。因此,当运行清单 67-14 中的程序时,每个实现都给出相同的结果。以下输出显示了这些结果:
bitset: 00000000000000001011011101111011111011111101111111
first 1 bit: 33
count of 1 bits: 28
new first 1 bit: 31
bitset: 11111111111111111100100010000100000100000010000000
first 1 bit: 49
编写一个函数模板, find_pair ,它需要两个参数:一个 bitset 用于搜索,一个 bool **用于比较。**该函数搜索与第二个参数相等的第一对相邻位,并返回该对中最高有效位的索引。如果函数找不到匹配的位对,应该返回什么?也写一个简单的测试程序。
将你的解决方案与我的进行比较,我的解决方案在清单 67-16 中给出。
import <bitset>;
import <iostream>;
template<std::size_t N>
std::size_t find_pair(std::bitset<N> const& bitset, bool value)
{
if (bitset.size() >= 2)
for (std::size_t i{bitset.size()}; i-- != 1; )
if (bitset[i] == value and bitset[i-1] == value)
return i;
return std::size_t(-1);
}
void test(bool condition) {
if (not condition)
throw std::logic_error("test failure");
}
int main()
{
auto constexpr static not_found{static_cast<std::size_t>(-1)};
std::bitset<0> bs0{};
std::bitset<1> bs1{};
std::bitset<2> bs2{};
std::bitset<3> bs3{};
std::bitset<100> bs100{};
test(find_pair(bs0, false) == not_found);
test(find_pair(bs0, true) == not_found);
test(find_pair(bs1, false) == not_found);
test(find_pair(bs1, true) == not_found);
test(find_pair(bs2, false) == 1);
test(find_pair(bs2, true) == not_found);
bs2[0] = true;
test(find_pair(bs2, false) == not_found);
test(find_pair(bs2, true) == not_found);
bs2.flip();
test(find_pair(bs2, false) == not_found);
test(find_pair(bs2, true) == not_found);
bs2[0] = true;
test(find_pair(bs2, false) == not_found);
test(find_pair(bs2, true) == 1);
test(find_pair(bs3, false) == 2);
test(find_pair(bs3, true) == not_found);
bs3[2].flip();
test(find_pair(bs3, false) == 1);
test(find_pair(bs3, true) == not_found);
bs3[1].flip();
test(find_pair(bs3, false) == not_found);
test(find_pair(bs3, true) == 2);
test(find_pair(bs100, false) == 99);
test(find_pair(bs100, true) == not_found);
bs100[50] = true;
test(find_pair(bs100, true) == not_found);
bs100[51] = true;
test(find_pair(bs100, true) == 51);
std::cout << "pass\n";
}
Listing 67-16.The find_pair Function and Test Program
虽然bitset的应用并不广泛,但是当你需要的时候,它却能提供极大的帮助。下一个探索涵盖了一个比bitset应用更广泛的语言特性:枚举。