4. 通用语言特性
# 4. 通用语言特性
在本书的这一部分,我们将探讨对C++语言进行的广泛改进,这些改进有可能让你的代码更加简洁且富有表现力。结构化绑定(structured binding)就是这类通用特性的一个完美示例。借助这一特性,你可以利用一种便捷的语法来处理元组(tuple)以及类似元组的表达式。在像Python这样的其他语言中轻而易举就能实现的功能,如今在C++17中也成为了可能。
在本章中,你将学到:
- 结构化绑定/分解声明
- 如何为自定义类提供结构化绑定接口
if
/switch
语句中的初始化语句- 内联变量(inline variables)及其对仅包含头文件的库的影响
- 可用于常量表达式(constexpr)上下文的lambda表达式
- 如何在lambda表达式中正确包装
this
指针 - 简化嵌套命名空间(nested namespaces)的使用
- 如何使用
__has_include
指令检测头文件是否存在
# 结构化绑定声明
你经常处理元组(tuples)或对组(pairs)吗?
如果没有,或许你应该开始了解这些实用的类型。元组能让你临时组合数据,并且有出色的库支持,而无需创建小型的自定义类型。像结构化绑定这样的语言特性,让代码的表达更加清晰简洁。
假设有一个函数以对组(std::pair
)的形式返回两个结果:
std::pair<int, bool> InsertElement(int el) { ... }
你可以这样写:
auto ret = InsertElement(...);
然后通过ret.first
或ret.second
来访问返回值。然而,使用.first
或.second
来访问值,表达性也不强,很容易混淆名称,而且代码可读性较差。另外,你也可以使用std::tie
,它能将元组/对组解包到局部变量中:
int index { 0 };
bool flag { false };
std::tie(index, flag) = InsertElement(10);
2
3
当你使用std::set::insert
(它返回std::pair
)时,这样的代码会很有用:
std::set<int> mySet;
std::set<int>::iterator iter;
bool inserted { false };
std::tie(iter, inserted) = mySet.insert(10);
if (inserted)
std::cout << "Value was inserted\n ";
2
3
4
5
6
如你所见,从函数返回多个值这样一个简单的模式,却需要好几行代码来实现。幸运的是,C++17让这变得简单多了!
在C++17中,你可以这样写:
std::set<int> mySet;
auto [iter, inserted] = mySet.insert(10);
2
现在,你可以使用具体的变量名,而不是pair.first
和pair.second
。此外,原本需要三行代码,现在一行就够了,代码也更易读。而且这样的代码更安全,因为iter
和inserted
在表达式中就完成了初始化。
这种语法被称为结构化绑定表达式(structured binding expression)。
# 语法
结构化绑定的基本语法如下:
auto [a, b, c, ...] = expression;
auto [a, b, c, ...] { expression };
auto [a, b, c, ...] ( expression );
2
3
编译器会将a, b, c, ...
列表中的所有标识符作为周围作用域中的名称引入,并将它们绑定到由expression
表示的对象的子对象或元素上。
在底层,编译器可能会生成如下伪代码:
auto tempTuple = expression;
using a = tempTuple.first;
using b = tempTuple.second;
using c = tempTuple.third;
2
3
4
从概念上讲,expression
被复制到一个类似元组的对象(tempTuple
)中,该对象的成员变量通过a
、b
和c
暴露出来。不过,变量a
、b
和c
不是引用,它们是生成对象成员变量的别名(或绑定)。临时对象有一个由编译器分配的唯一名称。
例如:
std::pair a(0, 1.0f);
auto [x, y] = a;
2
x
绑定到生成对象中存储的int
类型值,该生成对象是a
的副本。类似地,y
绑定到float
类型值。
# 修饰符
有几个修饰符可用于结构化绑定:
const
修饰符:
const auto [a, b, c, ...] = expression;
- 引用修饰符:
auto& [a, b, c, ...] = expression;
auto&& [a, b, c, ...] = expression;
2
例如:
std::pair a(0, 1.0f);
auto& [x, y] = a;
x = 10; // 可写访问
// a.first现在是10
2
3
4
在这个例子中,x
绑定到生成对象中的元素,该元素是对a
的引用。现在获取元组成员的引用也相当容易:
auto& [ refA, refB, refC, refD ] = myTuple;
你还可以在结构化绑定中添加[[attribute]]
:
[[maybe_unused]] auto& [a, b, c, ...] = expression;
# 结构化绑定还是分解声明?
你可能还见过这个特性的另一个名称:“分解声明(decomposition declaration)”。在标准化过程中,这两个名称都被考虑过,但最终选择了“结构化绑定”。
# 结构化绑定的限制
结构化绑定存在一些限制。它们不能被声明为static
或constexpr
,也不能用于lambda表达式的捕获列表中。例如:
constexpr auto [x, y] = std::pair(0, 0);
// 生成错误:结构化绑定声明不能是'constexpr'
2
绑定的链接属性也不明确。编译器可以自行选择实现方式(因此有些编译器可能允许在lambda表达式中捕获结构化绑定)。幸运的是,由于C++20提案(已被接受):P1091:扩展结构化绑定使其更像变量声明 (opens new window),这些限制可能会被消除。
# 绑定
结构化绑定(Structured Binding)不仅限于元组(tuples),我们可以从以下三种情况进行绑定:
- 如果初始化器是一个数组:
// 适用于数组:
double myArray[3] = {1.0, 2.0, 3.0};
auto [a, b, c] = myArray;
2
3
在这种情况下,数组会被复制到一个临时对象中,a
、b
和c
指向数组中复制出来的元素。标识符的数量必须与数组中的元素数量匹配。
2. 如果初始化器支持std::tuple_size<>
,提供get<N>()
,并且还公开了std::tuple_element
函数:
std::pair myPair(0, 1.0f);
auto [a, b] = myPair; // 绑定到myPair.first/myPair.second
2
在上面的代码片段中,我们对myPair
进行绑定。这也意味着,如果为自己的类添加get<N>
接口实现,那么你的类也可以支持结构化绑定。后面的部分会有相关示例。
3. 如果初始化器的类型仅包含非静态数据成员:
struct Point {
double x;
double y;
};
Point GetStartPoint() {
return {0.0, 0.0};
}
const auto [x, y] = GetStartPoint();
2
3
4
5
6
7
8
9
10
x
和y
分别指向Point
结构体中的Point::x
和Point::y
。这个类不一定是普通旧数据(POD)类型,但标识符的数量必须等于非静态数据成员的数量。并且这些成员必须在给定的上下文中是可访问的。
注:在C++17最初的版本中,只要类成员是公共的,就可以使用结构化绑定来绑定它们。但当在友元函数(friend functions)的上下文中,甚至在结构体实现内部访问这些成员时,这可能会成为一个问题。这个问题很快就被认定为一个缺陷,目前在C++17中已经得到修复。详见P0969R0 (opens new window)。
# 示例
本节将展示一些结构化绑定很有用的示例。第一个示例中,我们将使用它来编写更具表现力的代码;下一个示例中,你将看到如何为自己的类提供支持结构化绑定的API。
- 使用结构化绑定编写更具表现力的代码:如果你有一个元素映射(map),可能知道在其内部,元素是以
<const Key, ValueType>
对的形式存储的。现在,当遍历该映射的元素时:for (const auto& elem : myMap) { ... }
,你需要通过elem.first
和elem.second
来引用键和值。结构化绑定最酷的用例之一,就是可以在基于范围的for
循环中使用它:
std::map<KeyType, ValueType> myMap;
// C++14:
for (const auto& elem : myMap) {
// elem.first - 是键
// elem.second - 是值
}
// C++17:
for (const auto& [key,val] : myMap) {
// 直接使用键/值
}
2
3
4
5
6
7
8
9
10
在上面的示例中,我们绑定到[key, val]
对,这样就可以在循环中使用这些名称。在C++17之前,你必须操作从映射返回的迭代器,它返回的是<first, second>
对。使用真实的名称key
和value
更具表现力。
上述技术可以在General Language Features/city_map_iterate.cpp
中使用:
#include <map>
#include <iostream>
#include <string>
int main() {
const std::map<std::string, int> mapCityPopulation {
{ "Beijing", 21'707'000 },
{ "London", 8'787'892 },
{ "New York", 8'622'698 }
};
for (auto&[city, population] : mapCityPopulation)
std::cout << city << ": " << population << '\n';
}
2
3
4
5
6
7
8
9
10
11
12
13
在循环体中,你可以安全地使用city
和population
变量。
- 为自定义类提供结构化绑定接口:如前所述,你可以为自定义类提供结构化绑定支持。要做到这一点,你必须为自己的类型定义
get<N>
、std::tuple_size
和std::tuple_element
特化。例如,如果你有一个包含三个成员的类,但只想公开其公共接口:
// General Language Features/custom_structured_bindings.cpp
class UserEntry {
public:
void Load() {}
std::string GetName() const { return name; }
unsigned GetAge() const { return age; }
private:
std::string name;
unsigned age { 0 };
size_t cacheEntry { 0 }; // 不公开
};
2
3
4
5
6
7
8
9
10
11
结构化绑定的接口:
// General Language Features/custom_structured_bindings.cpp
// 使用if constexpr:
template <size_t I> auto get(const UserEntry& u) {
if constexpr (I == 0) return u.GetName();
else if constexpr (I == 1) return u.GetAge();
}
namespace std {
template <> struct tuple_size<UserEntry> : integral_constant<size_t, 2> {};
template <> struct tuple_element<0,UserEntry> { using type = std::string; };
template <> struct tuple_element<1,UserEntry> { using type = unsigned; };
}
2
3
4
5
6
7
8
9
10
11
12
tuple_size
指定了可用字段的数量,tuple_element
定义了特定元素的类型,get<N>
返回相应的值。
或者,你也可以使用显式的get<>
特化,而不是if constexpr
:
template <> string get<0>(const UserEntry &u) { return u.GetName(); }
template <> unsigned get<1>(const UserEntry &u) { return u.GetAge(); }
2
对于很多类型来说,编写两个(或几个)函数可能比使用if constexpr
更简单直接。
现在你可以在结构化绑定中使用UserEntry
,例如:
UserEntry u;
u.Load();
auto [name, age] = u; // 只读访问
std::cout << name << ", " << age << '\n';
2
3
4
这个示例只允许对类进行只读访问。如果你想要可写访问,那么类还应该提供返回成员引用的访问器。之后你还得实现支持引用的get
函数。
本节代码使用了if constexpr
,你可以在下一章《模板:if constexpr》中了解更多关于这个强大特性的内容。
# 额外信息
该变更在P0217 (opens new window)(措辞)、P0144 (opens new window)(论证和示例)、P0615 (opens new window)(将“分解声明(decomposition declaration)”重命名为“结构化绑定声明(structured binding declaration)”)中被提出。
# if和switch的初始化语句
C++17提供了if
和switch
语句的新版本:
if (init; condition)
和
switch (init; condition)
在init
部分,你可以像在for
循环的init
部分一样,指定一个新变量。然后在condition
部分检查这个变量。该变量仅在if
/else
作用域内可见。
在C++17之前,为了实现类似的结果,你必须这样写:
{
auto val = GetValue();
if (condition(val))
// 成功时
else
// 失败时...
}
2
3
4
5
6
7
请注意,val
有一个单独的作用域,否则它会“泄漏”到外部作用域。现在,在C++17中,你可以这样写:
if (auto val = GetValue(); condition(val))
// 成功时
else
// 失败时...
2
3
4
现在,val
仅在if
和else
语句内部可见,所以它不会“泄漏”。condition
可以是任何布尔条件。
这有什么用呢?假设你想在一个字符串中搜索一些内容:
const std::string myString = "My Hello World Wow";
const auto pos = myString.find("Hello");
if (pos != std::string::npos)
std::cout << pos << " Hello\n";
const auto pos2 = myString.find("World");
if (pos2 != std::string::npos)
std::cout << pos2 << " World\n";
2
3
4
5
6
7
你必须为pos
使用不同的名称,或者用单独的作用域将其括起来:
{
const auto pos = myString.find("Hello");
if (pos != std::string::npos)
std::cout << pos << " Hello\n";
}
{
const auto pos = myString.find("World");
if (pos != std::string::npos)
std::cout << pos << " World\n";
}
2
3
4
5
6
7
8
9
10
新的if
语句可以在一行内实现额外的作用域:
if (const auto pos = myString.find("Hello"); pos != std::string::npos)
std::cout << pos << " Hello\n";
if (const auto pos = myString.find("World"); pos != std::string::npos)
std::cout << pos << " World\n";
2
3
4
如前所述,在if
语句中定义的变量在else
块中也可见。所以你可以这样写:
if (const auto pos = myString.find("World"); pos != std::string::npos)
std::cout << pos << " World\n";
else
std::cout << pos << " not found!!\n";
2
3
4
此外,你可以将它与结构化绑定一起使用(参考赫伯·萨特(Herb Sutter)的代码 (opens new window)):
// 结构化绑定 + if初始化器 配合得更好:
if (auto [iter, succeeded] = mymap.insert(value); succeeded) {
use(iter); // 没问题
// ...
} // iter和succeeded在这里被销毁
2
3
4
5
在上面的示例中,你可以引用iter
和succeeded
,而不是mymap.insert
返回的pair.first
和pair.second
。
如你所见,结构化绑定和元组允许你在if
语句的初始化部分创建更多变量。但这样写代码会更易读吗?例如:
string str = "Hi World";
if (auto [pos, size] = pair(str.find("Hi"), str.size()); pos != string::npos)
std::cout << pos << " Hello, size is " << size;
2
3
有人可能会认为,在初始化部分放入更多代码会使代码可读性变差,所以在这种情况下要多加注意。
# 额外信息
该变更在P0305R1 (opens new window)中被提出。
# 内联变量
自C++11引入非静态数据成员初始化后,现在可以在一个地方声明并初始化成员变量:
class User {
int _age {0};
std::string _name {"unknown"};
};
2
3
4
然而,对于静态变量(或const static
变量),需要先声明,然后在实现文件中进行定义。
C++11中的constexpr
关键字允许在一个地方声明并定义静态变量,但仅限于常量表达式。
以前,只有方法/函数可以指定为inline
(内联),但现在在头文件中,变量也可以这样做。
根据提案P0386R2 (opens new window):
声明为inline 的变量与声明为inline 的函数具有相同的语义:它可以在多个翻译单元中进行相同的定义,并且在使用它的每个翻译单元中都必须进行定义,程序的行为就好像只有一个这样的变量。 |
---|
例如:
// 在头文件中:
struct MyClass {
static const int sValue;
};
// 稍后在同一个头文件中:
inline int const MyClass::sValue = 777;
2
3
4
5
6
甚至可以在一个地方进行声明和定义:
struct MyClass {
inline static const int sValue = 777;
};
2
3
另外,注意constexpr
变量隐式地是inline
的,所以无需写成constexpr inline myVar = 10;
。
内联变量比constexpr
变量更灵活,因为它不必用常量表达式进行初始化。例如,可以用rand()
初始化一个内联变量,但constexpr
变量无法这样做 。
它如何简化代码呢? 许多仅包含头文件的库可以减少使用技巧(比如使用内联函数或模板)的情况,转而使用内联变量。
例如:
class MyClass {
static inline int Seed(); // 静态方法
};
inline int MyClass::Seed() {
static const int seed = rand();
return seed;
}
2
3
4
5
6
7
可以改为:
class MyClass {
static inline int seed = rand();
};
2
3
C++17保证MyClass::seed
在所有编译单元中都具有相同的值(在运行时生成)!
# 额外信息
该变更在P0386R2 (opens new window)中被提出。
# 常量表达式(constexpr)lambda表达式
lambda表达式在C++11中被引入,从那时起,它已成为现代C++的重要组成部分。C++11的另一个重要特性是constexpr
说明符,用于表示函数或值可以在编译时计算。在C++17中,这两个元素可以一起使用,因此你的lambda表达式可以在常量表达式上下文中调用。
在C++11/14中,以下代码无法编译,但在C++17中可以正常运行:
Chapter General Language Features/lambda_square.cpp
int main () {
constexpr auto SquareLambda = [](int n) { return n*n; };
static_assert(SquareLambda(3) == 9, "");
}
2
3
4
自C++17起,遵循标准constexpr
函数规则的lambda表达式(其调用运算符operator()
)会隐式地声明为constexpr
。
constexpr
函数有哪些限制呢?以下是总结(来自10.1.5 constexpr
说明符 [dcl.constexpr (opens new window)]:
- 它们不能是虚函数;
- 它们的返回类型必须是字面量类型;
- 它们的参数类型必须是字面量类型;
- 它们的函数体不能包含:
asm
定义、goto
语句、try
块,或者非字面量类型、具有静态或线程存储期的变量。
在实践中,在C++17中,如果你希望函数或lambda表达式在编译时执行,那么该函数体不应调用任何非constexpr
的代码。例如,不能动态分配内存或抛出异常。
# 额外信息
该变更在P0170 (opens new window)中被提出。
# 在lambda表达式中捕获[*this]
在类方法中编写lambda表达式时,可以通过捕获this
来引用成员变量。例如:
Chapter General Language Features/capture_this.cpp
struct Test {
void foo() {
std::cout << m_str << '\n ';
auto addWordLambda = [this]() { m_str += "World"; };
addWordLambda();
std::cout << m_str << '\n ';
}
std::string m_str {"Hello "};
};
2
3
4
5
6
7
8
9
在auto addWordLambda = [this]() {... }
这一行中,我们捕获了this
指针,随后便可以访问m_str
。
请注意,我们通过值捕获了this
指针。我们访问的是成员变量,而不是它的副本。当使用[=]
或[&]
捕获时,效果也是如此。这就是为什么在某个Test
对象上调用foo()
时,会看到以下输出:
Hello
Hello World
2
foo()
打印了两次m_str
。第一次看到的是“Hello”,但下一次是“Hello World”,因为lambda表达式addWordLambda
修改了它。
更复杂的情况会怎样呢?你知道以下代码会发生什么吗?
# 从方法返回lambda表达式
#include <iostream>
struct Baz {
auto foo() {
return [=] { std::cout << s << '\n '; };
}
std::string s;
};
int main() {
auto f1 = Baz{"ala"}.foo();
f1();
}
2
3
4
5
6
7
8
9
10
11
12
13
这段代码声明了一个Baz
对象,然后调用foo()
。请注意,foo()
返回一个捕获了类成员的lambda表达式。
像[=]
这样的捕获块表示我们按值捕获变量,但如果在lambda表达式中访问类的成员,则会隐式地通过this
指针进行访问。所以我们捕获了this
指针的副本,一旦超出Baz
对象的生命周期,这个指针就会成为悬空指针。
在C++17中,可以写成[*this]
,这将捕获整个对象的副本。auto lam = [*this]() { std::cout << s; };
在C++14中,让代码更安全的唯一方法是使用初始化捕获*this
:auto lam = [self=*this] { std::cout << self.s; };
当lambda表达式的生命周期可能比对象本身更长时,捕获this
可能会变得很棘手。使用异步调用或多线程时,就可能出现这种情况。
在C++20中(见P0806 (opens new window)),如果在方法中捕获[=]
,还会看到额外的警告。这样的表达式捕获了this
指针,而这可能并非你真正想要的。
# 额外信息
该变更在P0018 (opens new window)中被提出。
# 嵌套命名空间(Nested Namespaces)
命名空间(Namespaces)允许将类型和函数分组到不同的逻辑单元中。
例如,常见的情况是,XY库中的每种类型或函数都会存储在名为xy
的命名空间中。就像下面这个例子,有一个SuperCompressionLib
库,它提供了Compress()
和Decompress()
函数:
namespace SuperCompressionLib {
bool Compress();
bool Decompress();
}
2
3
4
如果你有两个或更多的嵌套命名空间,事情就变得有趣起来。
namespace MySuperCompany {
namespace SecretProject {
namespace SafetySystem {
class SuperArmor {
// ...
};
class SuperShield {
// ...
};
} // SafetySystem
} // SecretProject
} // MySuperCompany
2
3
4
5
6
7
8
9
10
11
12
在C++17中,嵌套命名空间可以用更紧凑的方式书写:
namespace MySuperCompany::SecretProject::SafetySystem {
class SuperArmor {
// ...
};
class SuperShield {
// ...
};
}
2
3
4
5
6
7
8
这种语法很方便,对于有C#或Java等语言编程经验的开发者来说,使用起来会更容易。
# 通用语言特性
在C++17中,标准库(Standard Library)的多个地方也通过使用新的嵌套命名空间特性进行了 “简化”。
例如,对于正则表达式(regex)。在C++17中它的定义如下:
namespace std::regex_constants {
typedef T1 syntax_option_type;
// ...
}
2
3
4
在C++17之前,同样的内容声明如下:
namespace std {
namespace regex_constants {
typedef T1 syntax_option_type;
// ...
}
}
2
3
4
5
6
上述嵌套声明出现在C++规范中,但在标准模板库(STL)的实现中可能看起来有所不同。
# 额外信息
这项改动是在N4230 (opens new window)中提出的。
# has_include
预处理表达式
如果你的代码需要在两种不同的编译器下运行,那么你可能会遇到两组不同的可用特性以及特定于平台的变化。
在C++17中,你可以使用has_include
预处理常量表达式来检查某个给定的头文件是否存在:
#if has_include(<header_name>)
#if has_include("header_name")
2
多年来,has_include
在Clang中作为一个扩展已经可用,但现在它被添加到了标准中。它是 “特性测试” 辅助工具的一部分,允许你检查某个特定的C++特性或头文件是否可用。如果一个编译器支持这个宏,那么即使没有启用C++17标志,它也是可访问的。这就是为什么即使你在C++11或C++14 “模式” 下工作,也可以检查某个特性。
例如,我们可以测试某个平台是否有<charconv>
头文件,该头文件声明了C++17的底层转换例程:
Chapter General Language Features/has_include.cpp
#if defined has_include
# if has_include(<charconv>)
# define has_charconv 1
# include <charconv>
# endif
#endif
std::optional<int> ConvertToInt(const std::string& str) {
int value { };
#ifdef has_charconv
const auto last = str.data() + str.size();
const auto res = std::from_chars(str.data(), last, value);
if (res.ec == std::errc{} && res.ptr == last)
return value;
#else
// alternative implementation...
#endif
return std::nullopt;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
在上述代码中,我们根据has_include
条件声明了has_charconv
。如果头文件不存在,我们需要为ConvertToInt
提供一个替代实现。你可以在GCC 7.1和GCC 9.1上检查这段代码,看看效果,因为GCC 7.1没有提供<charconv>
头文件。
注意:在上述代码中,我们不能写成:
#if defined has_include && has_include(<charconv>)
因为在不支持has_include
的旧编译器中,我们会得到一个编译错误。编译器会抱怨由于has_include
未定义,整个表达式是错误的。
另一件需要记住的重要事情是,有时编译器可能会提供一个头文件存根。例如,在C++14模式下,<execution>
头文件可能存在(它定义了C++17并行算法执行模式),但整个文件可能是空的(由于#ifdef
条件)。如果你使用has_include
检查该文件并使用C++14模式,那么你会得到错误的结果。
在C++20中,我们将有标准化的特性测试宏,这些宏简化了对各种C++部分的检查。例如,要测试std::any
,你可以使用cpp_lib_any
;对于lambda表达式支持,有cpp_lambdas
。甚至有一个宏可以检查属性支持:has_cpp_attribute(attrib-name)
。GCC、Clang和Visual Studio在C++20正式发布之前就已经提供了许多这样的宏。在“Feature testing (C++20) - cppreference (opens new window)”中可以了解更多信息。
has_include
以及特性测试宏可以极大地简化多平台代码,这些代码通常需要检查可用的平台元素。
# 额外信息
has_include
是在P0061 (opens new window)中提出的。
# 编译器支持
特性 | GCC | Clang | MSVC |
---|---|---|---|
结构化绑定声明(Structured Binding Declarations) | 7.0 | 4.0 | VS 2017 15.3 |
if /switch 的初始化语句(Init-statement for if/switch) | 7.0 | 3.9 | VS 2017 15.3 |
内联变量(Inline variables) | 7.0 | 3.9 | VS 2017 15.5 |
constexpr lambda表达式(constexpr Lambda Expressions) | 7.0 | 5.0 | VS 2017 15.3 |
lambda捕获*this (Lambda Capture of *this ) | 7.0 | 3.9 | VS 2017 15.3 |
嵌套命名空间(Nested namespaces) | 6.0 | 3.6 | VS 2015 |
has_include | 5 | 是 | VS 2017 15.3 |