在本章中,我们将介绍以下食谱:
- 使用结构化绑定来解包捆绑的返回值
- 将变量范围限制为
if
和switch
语句 - 根据新的括号初始值设定项规则进行分析
- 让构造函数自动推导出生成的模板类类型
- 用 constexpr-if 简化编译时决策
- 启用带有内联变量的纯头文件库
- 用折叠表达式实现方便的助手函数
C++ 在 C++ 11、C++ 14 以及最近的 C++ 17 中有很多增加。到目前为止,与十年前相比,这是一种完全不同的语言。C++ 标准不仅使语言标准化,因为它需要被编译器理解,而且还使 C++ 标准模板库(STL)标准化。
这本书用大量的例子解释了如何最好地使用 STL。但首先,本章将集中讨论最重要的新语言特性。掌握它们将极大地帮助您编写可读性、可维护性和表达性强的代码。
我们将看到如何使用结构化绑定轻松访问对、元组和结构的单个成员,以及如何使用新的if
和switch
变量初始化功能来限制变量范围。语法歧义是由 C++ 11 用新的括号初始化语法引入的,括号初始化语法看起来与初始化列表相同,由新的括号初始化规则修复。模板类实例的确切的类型现在可以从实际的构造函数参数中推导出来,如果模板类的不同专门化将导致完全不同的代码,这现在可以很容易地用 constexpr-if 来表达。在许多情况下,使用新的折叠表达式,模板函数中变量参数包的处理变得更加容易。最后,在只有头文件的库中定义静态的全局可访问对象变得更加容易,因为它具有声明内联变量的新能力,这在以前只有函数才有可能。
对于库的实现者来说,本章中的一些示例可能比实现应用的开发人员更有趣。虽然出于完整性的原因,我们将看一看这些特性,但是为了理解本书的其余部分,立即理解本章的所有示例并不太重要。
C++ 17 自带一个新功能,结合了语法糖和自动类型推演:结构化绑定。这些有助于将来自对、元组和结构的值赋给单个变量。在其他编程语言中,这也被称为解包。
应用结构化绑定以便从一个捆绑结构中分配多个变量总是一个步骤。我们先来看看在 C++ 17 之前是怎么做到的。然后,我们可以看一下多个例子,展示我们如何在 C++ 17 中做到这一点:
- 访问
std::pair
的单个值:假设我们有一个数学函数divide_remainder
,它接受一个被除数和一个除数参数,并返回两者的分数以及余数。它使用std::pair
包返回这些值:
std::pair<int, int> divide_remainder(int dividend, int divisor);
考虑以下访问结果对的单个值的方式:
const auto result (divide_remainder(16, 3));
std::cout << "16 / 3 is "
<< result.first << " with a remainder of "
<< result.second << 'n';
我们现在可以将单个值赋给具有表达性名称的单个变量,而不是像前面的代码片段所示的那样,这样读起来更好:
auto [fraction, remainder] = divide_remainder(16, 3);
std::cout << "16 / 3 is "
<< fraction << " with a remainder of "
<< remainder << 'n';
- 结构化绑定也适用于
std::tuple
:我们来看下面的示例函数,它获取我们的在线股票信息:
std::tuple<std::string,
std::chrono::system_clock::time_point, unsigned>
stock_info(const std::string &name);
将其结果分配给单个变量,就像前面的示例一样:
const auto [name, valid_time, price] = stock_info("INTC");
- 结构化绑定也适用于自定义结构:让我们假设如下结构:
struct employee {
unsigned id;
std::string name;
std::string role;
unsigned salary;
};
现在,我们可以使用结构化绑定来访问这些成员。我们甚至可以在一个循环中完成,假设我们有一个完整的向量:
int main()
{
std::vector<employee> employees {
/* Initialized from somewhere */};
for (const auto &[id, name, role, salary] : employees) {
std::cout << "Name: " << name
<< "Role: " << role
<< "Salary: " << salary << 'n';
}
}
结构化绑定总是以相同的模式应用:
auto [var1, var2, ...] = <pair, tuple, struct, or array expression>;
- 变量列表
var1, var2, ...
必须与被赋值表达式包含的变量数量完全匹配。 <pair, tuple, struct, or array expression>
必须是下列之一:- 安
std::pair
。 - 安
std::tuple
。 - 一
struct
。所有成员必须是非静态的并且定义在同一个基类中。第一个声明的成员分配给第一个变量,第二个成员分配给第二个变量,依此类推。 - 固定大小的数组。
- 安
- 类型可以是
auto
、const auto
、const auto&
,甚至auto&&
。
Not only for the sake of performance, always make sure to minimize needless copies by using references when appropriate.
如果我们在方括号之间写了太多的或者没有足够的变量,编译器就会出错,告诉我们我们的错误:
std::tuple<int, float, long> tup {1, 2.0, 3};
auto [a, b] = tup; // Does not work
这个例子显然试图将一个有三个成员的元组变量填充到两个变量中。编译器立即对此感到窒息,并告诉我们我们的错误:
error: type 'std::tuple<int, float, long>' decomposes into 3 elements, but only 2 names were provided
auto [a, b] = tup;
STL 中的许多基本数据结构都可以使用结构化绑定立即访问,而无需我们进行任何更改。例如,考虑打印一个std::map
的所有项目的循环:
std::map<std::string, size_t> animal_population {
{"humans", 7000000000},
{"chickens", 17863376000},
{"camels", 24246291},
{"sheep", 1086881528},
/* … */
};
for (const auto &[species, count] : animal_population) {
std::cout << "There are " << count << " " << species
<< " on this planet.n";
}
这个特殊的例子之所以有效,是因为当我们迭代一个std::map
容器时,我们在每个迭代步骤中都得到了std::pair<const key_type, value_type>
节点。确切地说,这些节点是使用结构化绑定功能来解包的(key_type
是species
字符串,value_type
是种群数量size_t
,以便在循环体中单独访问它们。
在 C++ 17 之前,使用std::tie
可以达到类似的效果:
int remainder;
std::tie(std::ignore, remainder) = divide_remainder(16, 5);
std::cout << "16 % 5 is " << remainder << 'n';
此示例显示了如何将结果对解包为两个变量。std::tie
不如结构化绑定强大,因为我们必须在之前定义所有想要绑定到的变量。另一方面,这个例子显示了结构化绑定不具有的std::tie
的强度:值std::ignore
充当虚拟变量。结果的分数部分被分配给它,这导致该值被丢弃,因为在该示例中我们不需要它。
When using structured bindings, we don't have tie
dummy variables, so we have to bind all the values to named variables. Doing so and ignoring some of them is efficient, nevertheless, because the compiler can optimize the unused bindings out easily.
回到过去,divide_remainder
功能可以通过以下方式实现,使用输出参数:
bool divide_remainder(int dividend, int divisor,
int &fraction, int &remainder);
访问它应该如下所示:
int fraction, remainder;
const bool success {divide_remainder(16, 3, fraction, remainder)};
if (success) {
std::cout << "16 / 3 is " << fraction << " with a remainder of "
<< remainder << 'n';
}
许多人仍然更喜欢这样,而不是返回像对、元组和结构这样的复杂结构,他们认为这样代码会更快,因为避免了这些值的中间副本。对于现代编译器来说,这不再是了,因为现代编译器会优化中间副本。
Apart from the missing language features in C, returning complex structures via return value was considered slow for a long time because the object had to be initialized in the returning function and then copied into the variable that should contain the return value on the caller side. Modern compilers support return value optimization (RVO), which enables for omitting intermediate copies.
尽可能限制变量的范围是好的风格。然而,有时首先需要获得一些值,并且只有当它符合某个条件时,它才能被进一步处理。
为此,C++ 17 附带了带有初始值设定项的if
和switch
语句。
在这个方法中,我们在两个支持的上下文中都使用了初始值设定项语法,以便查看它们如何整理我们的代码:
if
语句:假设我们想使用std::map
的find
方法在角色图中找到一个角色:
if (auto itr (character_map.find(c)); itr != character_map.end()) {
// *itr is valid. Do something with it.
} else {
// itr is the end-iterator. Don't dereference.
}
// itr is not available here at all
switch
语句:这是从输入中获取一个字符,同时检查switch
语句中的值以控制计算机游戏的方式:
switch (char c (getchar()); c) {
case 'a': move_left(); break;
case 's': move_back(); break;
case 'w': move_fwd(); break;
case 'd': move_right(); break;
case 'q': quit_game(); break;
case '0'...'9': select_tool('0' - c); break;
default:
std::cout << "invalid input: " << c << 'n';
}
带有初始值设定项的if
和switch
语句基本上只是语法糖。以下两个示例是等效的:
之前 C++ 17:
{
auto var (init_value);
if (condition) {
// branch A. var is accessible
} else {
// branch B. var is accessible
}
// var is still accessible
}
自 C++ 17:
if (auto var (init_value); condition) {
// branch A. var is accessible
} else {
// branch B. var is accessible
}
// var is not accessible any longer
这同样适用于switch
语句:
在 C++ 17 之前:
{
auto var (init_value);
switch (var) {
case 1: ...
case 2: ...
...
}
// var is still accessible
}
从 C++ 17 开始:
switch (auto var (init_value); var) {
case 1: ...
case 2: ...
...
}
// var is not accessible any longer
这个特性对于保持变量的范围尽可能短非常有用。在 C++ 17 之前,这只能通过在代码周围使用额外的大括号来实现,正如 C++ 17 之前的示例所示。较短的生存期减少了作用域中的变量数量,这使我们的代码保持整洁,并且更容易重构。
另一个有趣的用例是关键部分的有限范围。考虑以下示例:
if (std::lock_guard<std::mutex> lg {my_mutex}; some_condition) {
// Do something
}
首先,创建一个std::lock_guard
。这是一个接受互斥参数作为构造函数参数的类。它在构造函数中锁定互斥体,当它超出范围时,它在析构函数中再次解锁。这样就不可能忘记解锁互斥。在 C++ 17 之前,需要一对额外的大括号来确定它再次解锁的范围。
另一个有趣的用例是弱指针的范围。请考虑以下几点:
if (auto shared_pointer (weak_pointer.lock()); shared_pointer != nullptr) {
// Yes, the shared object does still exist
} else {
// shared_pointer var is accessible, but a null pointer
}
// shared_pointer is not accessible any longer
这是另一个例子,我们会有一个无用的shared_pointer
变量泄漏到当前范围内,尽管它在if
条件块或嘈杂的额外括号之外有一个潜在的无用状态!
当使用带有输出参数的传统应用编程接口时,带有初始值设定项的if
语句特别有用:
if (DWORD exit_code; GetExitCodeProcess(process_handle, &exit_code)) {
std::cout << "Exit code of process was: " << exit_code << 'n';
}
// No useless exit_code variable outside the if-conditional
GetExitCodeProcess
是一个 Windows 内核 API 函数。它返回给定进程句柄的退出代码,但前提是该句柄有效。离开这个条件块后,变量就没用了,所以我们不再需要它在任何范围内。
能够在if
块内初始化变量显然在很多情况下非常有用,尤其是在处理使用输出参数的遗留 API 时。
Keep your scopes tight using if
and switch
statement initializers. This makes your code more compact, easier to read, and in code refactoring sessions, it will be easier to move around.
C++ 11 附带了新的大括号初始化语法{}
。其目的是允许聚合初始化,但也允许通常的构造函数调用。不幸的是,当把这个语法和auto
变量类型结合在一起时,表达错误的东西太容易了。C++ 17 附带了一组增强的初始化规则。在这个食谱中,我们将阐明如何在 C++ 17 中用哪种语法正确初始化变量。
变量一步初始化。使用初始值设定项语法,有两种不同的情况:
- 使用大括号初始值设定项语法而不使用
auto
类型演绎:
// Three identical ways to initialize an int:
int x1 = 1;
int x2 {1};
int x3 (1);
std::vector<int> v1 {1, 2, 3}; // Vector with three ints: 1, 2, 3
std::vector<int> v2 = {1, 2, 3}; // same here
std::vector<int> v3 (10, 20); // Vector with 10 ints,
// each have value 20
- 使用大括号初始化器语法配合
auto
类型推演:
auto v {1}; // v is int
auto w {1, 2}; // error: only single elements in direct
// auto initialization allowed! (this is new)
auto x = {1}; // x is std::initializer_list<int>
auto y = {1, 2}; // y is std::initializer_list<int>
auto z = {1, 2, 3.0}; // error: Cannot deduce element type
没有auto
类型演绎,至少在初始化常规类型时,大括号{}
操作符没什么好惊讶的。初始化容器时,如std::vector
、std::list
等,括号初始化器将匹配该容器类的std::initializer_list
构造器。它以贪婪的方式做到这一点,这意味着不可能匹配非聚合构造函数(与接受初始化列表的构造函数相比,非聚合构造函数是常见的构造函数)。
std::vector
例如,提供了一个特定的非聚合构造函数,它用相同的值任意填充许多项:std::vector<int> v (N, value)
。写std::vector<int> v {N, value}
时,选择initializer_list
构造函数,用两项初始化向量:N
和value
。这是一个人们应该知道的特殊陷阱。
与使用普通()
括号调用构造函数相比,{}
运算符的一个很好的细节是,它们不进行隐式类型转换:int x (1.2);
和int x = 1.2;
将通过静默舍入浮点值并将其转换为 int 来初始化x
为值1
。相比之下,int x {1.2};
不会编译,因为它想让和完全匹配构造函数类型。
One can controversially argue about which initialization style is the best one.
Fans of the bracket initialization style say that using brackets makes it very explicit, that the variable is initialized with a constructor call, and that this code line is not reinitializing anything. Furthermore, using {}
brackets will select the only matching constructor, while initializer lines using ()
parentheses try to match the closest constructor and even do type conversion in order to match.
C++ 17 中引入的额外规则会影响auto
类型演绎的初始化——虽然 C++ 11 会正确地使变量auto x {123};
的类型成为只有一个元素的std::initializer_list<int>
,但这很少是我们想要的。C++ 17 会把同一个变量变成int
。
经验法则:
auto var_name {one_element};
推断var_name
与one_element
同类型auto var_name {element1, element2, ...};
无效,不编译auto var_name = {element1, element2, ...};
演绎为一个std::initializer_list<T>``T
与列表中所有元素的类型相同
C++ 17 使得意外定义初始化列表变得更加困难。
Trying this out with different compilers in C++ 11/C++ 14 mode will show that some compilers actually deduce auto x {123};
to an int
, while others deduce it to std::initializer_list<int>
. Writing code like this can lead to problems regarding portability!
C++ 中的许多类通常专门处理类型,这很容易从用户在构造函数调用中输入的变量类型中推断出来。然而,在 C++ 17 之前,这并不是一个标准化的特性。C++ 17 让编译器自动从构造函数调用中推导出模板类型。
一个非常方便的用例是构建std::pair
和std::tuple
实例。这些可以在一个步骤中专门化、实例化和专门化:
std::pair my_pair (123, "abc"); // std::pair<int, const char*>
std::tuple my_tuple (123, 12.3, "abc"); // std::tuple<int, double,
// const char*>
让我们定义一个示例类,其中自动模板类型扣除将是有价值的:
template <typename T1, typename T2, typename T3>
class my_wrapper {
T1 t1;
T2 t2;
T3 t3;
public:
explicit my_wrapper(T1 t1_, T2 t2_, T3 t3_)
: t1{t1_}, t2{t2_}, t3{t3_}
{}
/* … */
};
好吧,这只是另一个模板类。为了实例化它,我们之前必须编写以下内容:
my_wrapper<int, double, const char *> wrapper {123, 1.23, "abc"};
我们现在可以省略模板专门化部分:
my_wrapper wrapper {123, 1.23, "abc"};
在 C++ 17 之前,这只能通过实现 make 函数助手来实现:
my_wrapper<T1, T2, T3> make_wrapper(T1 t1, T2 t2, T3 t3)
{
return {t1, t2, t3};
}
使用这样的助手,有可能产生类似的效果:
auto wrapper (make_wrapper(123, 1.23, "abc"));
The STL already comes with a lot of helper functions such as that one: std::make_shared
, std::make_unique
, std::make_tuple
, and so on. In C++ 17, these can now mostly be regarded as obsolete. Of course, they will be provided further for compatibility reasons.
我们刚刚了解到的是隐式模板式推演。在某些情况下,我们不能依赖隐式类型演绎。考虑以下示例类:
template <typename T>
struct sum {
T value;
template <typename ... Ts>
sum(Ts&& ... values) : value{(values + ...)} {}
};
这个结构sum
接受任意数量的参数,并使用一个 fold 表达式将它们加在一起(在本章稍后部分查看 fold 表达式配方,以获得关于 fold 表达式的更多细节)。结果总和保存在成员变量value
中。现在的问题是,T
是什么类型?如果我们不想显式地指定它,它肯定需要依赖于构造函数中提供的值的类型。如果我们提供字符串实例,它需要是std::string
。如果我们提供整数,它需要是int
。如果我们提供整数、浮点数和双精度数,编译器需要在不丢失信息的情况下找出适合所有值的类型。为了实现这一点,我们提供了一个明确的扣除指南:
template <typename ... Ts>
sum(Ts&& ... ts) -> sum<std::common_type_t<Ts...>>;
这个推导指南告诉编译器使用std::common_type_t
特性,它能够找出哪个公共类型适合所有的值。让我们看看如何使用它:
sum s {1u, 2.0, 3, 4.0f};
sum string_sum {std::string{"abc"}, "def"};
std::cout << s.value << 'n'
<< string_sum.value << 'n';
在第一行中,我们用类型为unsigned
、double
、int
和float
的构造函数参数实例化了一个sum
对象。std::common_type_t
返回double
作为常用类型,所以我们得到一个sum<double>
实例。在第二行中,我们提供了一个std::string
实例和一个 C 风格的字符串。遵循我们的推导指南,编译器构建了类型sum<std::string>
的实例。
运行该代码时,将打印10
为数字和,abcdef
为字符串和。
在模板化代码中,根据模板专用的类型,经常需要以不同的方式做某些事情。C++ 17 附带了 constexpr-if 表达式,这大大简化了这种情况下的代码*。*
*# 怎么做...
在这个食谱中,我们将实现一个小助手模板类。它可以处理不同的模板类型专门化,因为它能够在某些段落中选择完全不同的代码,这取决于我们专门化它的类型:
- 编写代码中通用的部分。在我们的示例中,它是一个简单的类,支持使用
add
函数向类型T
成员值添加类型U
值:
template <typename T>
class addable
{
T val;
public:
addable(T v) : val{v} {}
template <typename U>
T add(U x) const {
return val + x;
}
};
- 想象一下
T
型是std::vector<something>
,而U
型只是int
。把一个整数加到整个向量上意味着什么?假设这意味着我们把整数加到向量的每一项上。这将循环进行:
template <typename U>
T add(U x)
{
auto copy (val); // Get a copy of the vector member
for (auto &n : copy) {
n += x;
}
return copy;
}
- 下一步也是最后一步是联合两个世界。如果
T
是U
项的向量,执行循环变量。如果不是,则执行正常添加:
template <typename U>
T add(U x) const {
if constexpr (std::is_same_v<T, std::vector<U>>) {
auto copy (val);
for (auto &n : copy) {
n += x;
}
return copy;
} else {
return val + x;
}
}
- 这个类现在可以使用了。让我们看看它与完全不同的类型配合得有多好,例如
int
、float
、std::vector<int>
和std::vector<string>
:
addable<int>{1}.add(2); // is 3
addable<float>{1.0}.add(2); // is 3.0
addable<std::string>{"aa"}.add("bb"); // is "aabb"
std::vector<int> v {1, 2, 3};
addable<std::vector<int>>{v}.add(10);
// is std::vector<int>{11, 12, 13}
std::vector<std::string> sv {"a", "b", "c"};
addable<std::vector<std::string>>{sv}.add(std::string{"z"});
// is {"az", "bz", "cz"}
新的 constexpr-if 与通常的 if-else 构造完全一样。不同的是,它测试的条件必须在编译时进行评估。编译器从我们的程序中创建的所有运行时代码将不包含任何来自 constexpr-if 条件的分支指令。也可以说,它的工作方式类似于预处理器#if
和#else
文本替换宏,但对于这些宏,代码甚至不必在语法上格式良好。constexpr-if 构造的所有分支都需要是句法格式良好的,但不是的分支不需要是语义有效的。
为了区分代码是否应该将值x
添加到向量中,我们使用类型特征std::is_same
。如果A
和B
属于同一类型,则表达式std::is_same<A, B>::value
计算为布尔值true
。我们的配方中使用的条件是std::is_same<T, std::vector<U>>::value
,如果用户在T = std::vector<X>
上专门化了类,并试图用类型为U = X
的参数调用add
,则该条件评估为true
。
当然,在一个 constexpr-if-else 块中可以有多个条件(注意a
和b
必须依赖于模板参数,而不仅仅是编译时常数):
if constexpr (a) {
// do something
} else if constexpr (b) {
// do something else
} else {
// do something completely different
}
有了 C++ 17,很多元编程的情况更容易表达和阅读。
为了说明 constexpr-if 构造对 C++ 的改进有多大,我们可以看看在 C++ 17 之前,同样的事情是如何实现的*:*
template <typename T>
class addable
{
T val;
public:
addable(T v) : val{v} {}
template <typename U>
std::enable_if_t<!std::is_same<T, std::vector<U>>::value, T>
add(U x) const { return val + x; }
template <typename U>
std::enable_if_t<std::is_same<T, std::vector<U>>::value,
std::vector<U>>
add(U x) const {
auto copy (val);
for (auto &n : copy) {
n += x;
}
return copy;
}
};
在不使用 constexpr-if 的情况下,这个类适用于我们想要的所有不同类型,但是它看起来超级复杂。它是如何工作的?
两个不同的 add
函数的单独实现看起来很简单。这是它们的返回类型声明,这使它们看起来很复杂,并且包含一个技巧——如果condition
是true
,则表达式如std::enable_if_t<condition, type>
计算为type
。否则,std::enable_if_t
表达式不评估任何东西。这通常会被认为是一个错误,但我们会看到为什么不是。
对于第二个add
功能,以反转的方式使用相同的条件。这样,对于两个实现中的一个,只能同时是true
。
当编译器看到同名的不同模板函数,必须从中选择一个时,一个重要的原则就起作用了: SFINAE ,代表替换失败不是错误。在这种情况下,这意味着如果不能从错误的模板表达式中推导出这些函数之一的返回值,编译器不会出错(如果其条件评估为false
,则为std::enable_if
)。它将简单地看得更远,并尝试其他功能实现。这就是诀窍;这就是它的工作原理。
*真麻烦。很高兴看到这在 C++ 17 中变得如此容易。
虽然在 C++ 中总是可以内联声明单个函数*,但是 C++ 17 还允许我们内联声明变量。这使得实现纯头文件库变得更加容易,而这在以前只有使用变通方法才能实现。*
*# 是怎么做到的...
在这个方法中,我们创建了一个示例类,它可以作为一个典型的仅头库的成员。目标是给它一个静态成员,并使用inline
关键字以全局可用的方式实例化它,这在 C++ 17 之前是不可能的:
process_monitor
类应该包含一个静态成员,并且本身是全局可访问的,当包含在多个翻译单元中时,会产生双重定义的符号:
// foo_lib.hpp
class process_monitor {
public:
static const std::string standard_string
{"some static globally available string"};
};
process_monitor global_process_monitor;
- 如果我们现在将它包含在多个
.cpp
文件中,以便编译和链接它们,这将在链接器阶段失败。为了解决这个问题,我们添加了inline
关键字:
// foo_lib.hpp
class process_monitor {
public:
static const inline std::string standard_string
{"some static globally available string"};
};
inline process_monitor global_process_monitor;
瞧,就是这样!
C++ 程序通常由多个 C++ 源文件组成(这些文件有.cpp
或.cc
就够了)。这些被单独编译成模块/目标文件(通常有。o 就够了)。最后一步是将所有模块/目标文件链接到一个可执行文件或共享/静态库。
在链接阶段,如果链接器可以多次找到一个特定符号的定义,则认为是错误的。比方说,我们有一个带有签名的函数,比如int foo();
。如果两个模块定义同一个函数,哪个是正确的?链接器不能只是掷骰子。嗯,有可能,但这很可能不是任何程序员都希望发生的事情。
提供全局可用函数的传统方式是在头文件中声明,任何需要调用它们的 C++ 模块都会包含这些头文件。每一个功能的定义将被放入单独的模块文件中*。然后,这些与希望使用这些功能的模块链接在一起。这也叫做一个定义规则 ( ODR )。为了更好地理解,请查看下图:*
然而,如果这是唯一的方法,那么就不可能提供仅头库。只有头文件的库非常方便,因为它们只需要使用#include
包含在任何 C++ 程序文件中,然后就可以立即使用。为了使用不仅仅是头文件的库,程序员还必须修改构建脚本,以便让链接器将库模块和他自己的模块文件链接在一起。尤其是对于只有很短功能的库,这是不必要的不舒服。
对于这种情况,可以使用inline
关键字进行例外处理,以便允许在不同模块中对同一符号进行多个定义。如果链接器发现多个符号具有相同的签名,但它们是内联声明的,它将只选择第一个符号,并相信其他符号具有相同的定义。所有相等的内联符号被定义为完全相等,这基本上是程序员的承诺*。*
*关于我们的配方示例,链接器将在包含foo_lib.hpp
的每个模块中找到process_monitor::standard_string
符号。没有inline
关键字,它不知道选择哪个,所以会中止并报告错误。这同样适用于global_process_monitor
符号。哪一个是正确的?
在声明两个符号inline
后,它将只接受每个符号的第一次出现,丢弃所有其他符号。
在 C++ 17 之前,唯一干净的方法是通过一个额外的 C++ 模块文件来提供这个符号,这将迫使我们的库用户在链接步骤中包含这个文件。
inline
关键字传统上也有另一个功能。它告诉编译器,它可以通过获取函数的实现并直接将其放在被调用的地方来消除函数调用。这样,调用代码少包含一个函数调用,这通常可以被认为是更快。如果函数非常短,则生成的程序集也将更短(假设执行函数调用、保存和恢复堆栈等的指令数量高于实际有效负载代码)。如果内联的函数很长,二进制文件的大小就会增加,这有时甚至不会导致更快的代码。
因此,编译器将只使用inline
关键字作为提示,并可能通过内联它们来消除函数调用。但是它也可以内联一些函数*,而不需要程序员将其声明为内联。*
在 C++ 17 之前,一个可能的解决方法是提供一个static
函数,该函数返回对一个static
对象的引用:
class foo {
public:
static std::string& standard_string() {
static std::string s {"some standard string"};
return s;
}
};
这样,将头文件包含在多个模块中是完全合法的,但仍然可以在任何地方访问完全相同的实例。然而,对象是而不是在程序开始时立即构造的*,但只是在这个 getter 函数的第一次调用时。对于一些用例,这确实是一个问题。想象一下,我们希望静态的、全局可用的对象的构造函数在程序开始时做一些重要的事情(就像我们的示例库类一样),但是由于 getter 在接近程序结束时被调用,这已经太晚了。*
另一种变通方法是将非模板类foo
做成模板类,这样它就可以从与模板相同的规则中获益。
这两种策略在 C++ 17 中都可以避免。
从 C++ 11 开始,就有可变模板参数包,可以实现接受任意多参数的函数。有时,这些参数都被组合成一个表达式,以便从中导出函数结果。这个任务在 C++ 17 中变得非常容易,因为它带有折叠表达式。
让我们实现一个函数,该函数接受任意多的参数并返回它们的和:
- 首先,我们定义它的签名:
template <typename ... Ts>
auto sum(Ts ... ts);
- 所以,我们现在有了一个参数包
ts
,函数应该展开所有参数,并使用 fold 表达式将它们相加在一起。如果我们将任何运算符(【在本例中为 T1】)与...
一起使用,以便将其应用于参数包的所有值,我们需要用括号将表达式括起来:
template <typename ... Ts>
auto sum(Ts ... ts)
{
return (ts + ...);
}
- 我们现在可以这样称呼它:
int the_sum {sum(1, 2, 3, 4, 5)}; // Value: 15
- 它不仅适用于
int
类型;我们可以用任何只实现+
运算符的类型来称呼它,比如std::string
:
std::string a {"Hello "};
std::string b {"World"};
std::cout << sum(a, b) << 'n'; // Output: Hello World
我们刚才所做的是一个简单的递归应用二进制运算符(+
)到它的参数。这一般叫做折叠。C++ 17 自带折叠表达式,有助于用更少的代码表达相同的思想。
这种表达叫做一元折叠。C++ 17 支持使用以下二进制运算符折叠参数包:+
、-
、*
、/
、%
、^
、&
、|
、=
、<
、>
、<<
、>>
、+=
、-=
、*=
、/=
、%=
、^=
、&=
、|=
、<<=
、>>=
、==
、!=
顺便说一下,在我们的示例代码中,我们写(ts + …)
还是(… + ts)
并不重要;两者都有效。然而,在其他情况下可能存在相关的差异-如果…
点位于操作员的右侧侧,则该折叠称为右侧折叠。如果他们在左手边,那就是一个左折。
在我们的sum
例子中,一元左折叠扩展到1 + (2 + (3 + (4 + 5)))
,而一元右折叠将扩展到(((1 + 2) + 3) + 4) + 5
。根据使用的操作员,这可能会有所不同。当添加数字时,它不会。
如果有人用而不是参数调用sum()
,变量参数包不包含可以折叠的值。对于大多数操作者来说,这是一个错误(对于一些人来说,不是;我们将在一分钟后看到这一点)。然后,我们需要决定这应该是一个错误,还是一个空的总和应该导致一个特定的值。显而易见的想法是,无的和就是0
。
事情是这样的:
template <typename ... Ts>
auto sum(Ts ... ts)
{
return (ts + ... + 0);
}
这样,sum()
评估为0
,sum(1, 2, 3)
评估为(1 + (2 + (3 + 0)))
。这种有初始值的褶皱叫做二元褶皱。
同样,如果我们写(ts + ... + 0)
或(0 + ... + ts)
,它会工作,但是这使得二进制折叠再次变为二进制右折叠或二进制左折叠。查看下图:
当使用二进制折叠来实现无参数情况时,标识元素的概念通常很重要——在这种情况下,向任何数字添加0
都不会改变什么,这使得0
成为标识元素。由于这个属性,我们可以用运算符+
或-
在任何一个 fold 表达式中添加一个0
,如果参数包中没有参数,就会得到结果0
。从数学的角度来看,这是正确的。从实现的角度来看,我们需要定义什么是正确的,这取决于我们需要什么。
同样的原理也适用于乘法。这里,身份元素为1
:
template <typename ... Ts>
auto product(Ts ... ts)
{
return (ts * ... * 1);
}
product(2, 3)
的结果为6
,无参数的product()
的结果为1
。
逻辑**、** ( &&
)、或 ( ||
)运算符自带内置标识元素。用&&
折叠空参数包会得到true
,用||
折叠空参数包会得到false
。
当应用于空参数包时,默认为某个表达式的另一个运算符是逗号运算符(,
),然后默认为void()
。
为了点燃一些灵感,让我们看看更多的小助手,我们可以使用这个特性来实现。
一个函数来告诉某个范围是否包含至少一个我们作为变量参数提供的值,怎么样?
template <typename R, typename ... Ts>
auto matches(const R& range, Ts ... ts)
{
return (std::count(std::begin(range), std::end(range), ts) + ...);
}
辅助函数使用 STL 中的std::count
函数。该函数取三个参数:前两个参数是某个可迭代范围的开始和结束迭代器,作为第三个参数,它取一个值,该值将与该范围的所有项目进行比较。std::count
方法然后返回范围内等于第三个参数的所有元素的数量。
在我们的 fold 表达式中,我们总是将相同参数范围的开始和结束迭代器送入std::count
函数。但是,作为第三个参数,每次我们将参数包中的另一个参数放入其中。最后,该函数将所有结果相加并返回给调用者。
我们可以这样使用它:
std::vector<int> v {1, 2, 3, 4, 5};
matches(v, 2, 5); // returns 2
matches(v, 100, 200); // returns 0
matches("abcdefg", 'x', 'y', 'z'); // returns 0
matches("abcdefg", 'a', 'd', 'f'); // returns 3
我们可以看到,matches
辅助函数是相当通用的——它可以直接在向量上甚至字符串上调用。它还可以在初始化列表上工作,在std::list
、std::array
、std::set
等实例上工作!
让我们编写一个助手,在一个std::set
中插入任意数量的变量参数,如果所有的插入都成功则返回:
template <typename T, typename ... Ts>
bool insert_all(T &set, Ts ... ts)
{
return (set.insert(ts).second && ...);
}
那么,这是如何工作的呢?std::set
的insert
功能有以下签名:
std::pair<iterator, bool> insert(const value_type& value);
文档中说,当我们试图插入一个项目时,insert
函数将返回一对中的一个iterator
和一个bool
变量。如果插入成功,bool
值为true
。如果成功,迭代器指向集合中的新元素。否则,迭代器指向现有的项,这将使与要插入的项相冲突。
我们的助手函数在插入后访问.second
字段,这只是反映成功或失败的bool
变量。如果所有的插入都导致所有返回对中的true
,那么所有的插入都是成功的。fold 表达式将所有插入结果与&&
运算符组合在一起,并返回结果。
我们可以这样使用它:
std::set<int> my_set {1, 2, 3};
insert_all(my_set, 4, 5, 6); // Returns true
insert_all(my_set, 7, 8, 2); // Returns false, because the 2 collides
请注意,如果我们尝试插入例如三个元素,但第二个元素已经不能插入,则&& ...
折叠将短路并停止插入所有其他元素:
std::set<int> my_set {1, 2, 3};
insert_all(my_set, 4, 2, 5); // Returns false
// set contains {1, 2, 3, 4} now, without the 5!
如果我们可以检查一个变量是否在某个特定范围内,我们也可以使用 fold 表达式对多个变量执行相同的操作:**
template <typename T, typename ... Ts>
bool within(T min, T max, Ts ...ts)
{
return ((min <= ts && ts <= max) && ...);
}
表达式(min <= ts && ts <= max)
确实说明了参数包的每个值是否在min
和max
(包括 min
和max
)之间。我们选择&&
运算符将所有布尔结果简化为单个结果,如果所有单个结果都是true
,则只有true
。
这是它在行动中的样子:
within( 10, 20, 1, 15, 30); // --> false
within( 10, 20, 11, 12, 13); // --> true
within(5.0, 5.5, 5.1, 5.2, 5.3) // --> true
有趣的是,这个功能非常通用,因为它对我们使用的类型的唯一要求是它们是可与<=
操作符相媲美的。而std::string
也满足了这一要求,例如:
std::string aaa {"aaa"};
std::string bcd {"bcd"};
std::string def {"def"};
std::string zzz {"zzz"};
within(aaa, zzz, bcd, def); // --> true
within(aaa, def, bcd, zzz); // --> false
还可以编写一个助手,它不会减少任何结果,但会处理多个同类操作。就像在不返回任何结果的std::vector
中插入项目一样(std::vector::insert()
通过抛出异常来发出错误信号):
template <typename T, typename ... Ts>
void insert_all(std::vector<T> &vec, Ts ... ts)
{
(vec.push_back(ts), ...);
}
int main()
{
std::vector<int> v {1, 2, 3};
insert_all(v, 4, 5, 6);
}
请注意,我们使用逗号(,
)运算符将参数包扩展为单独的vec.push_back(...)
调用,而不折叠实际结果。这个函数也可以很好地处理一个空的参数包,因为逗号操作符有一个隐式的标识元素,T2,它翻译成什么也不做。****