文章目录
一、C++ 标准发布历史
2017年12月,正式发布了 C++17。
二、基本语言特性
1. 结构化绑定
结构化绑定允许用一个对象的成员或数组的元素,去初始化多个变量。
struct MyStruct {
int i = 0;
std::string s;
};
MyStruct ms;
声明用两个变量,直接绑定两个成员:
auto [u,v] = ms;
这里u
和v
的声明方式被称为结构化绑定。
结构化绑定对于返回结构体或数组的函数尤其有用。
例如,有一个返回一个结构体的函数:
MyStruct getStruct() {
return MyStruct{42, "hello"};
}
可以直接把返回值的两个数据成员赋值给两个局部变量:
auto [id,val] = getStruct(); //`id` 和`val` 分别对应返回结构体的`i` 和`s` 成员
在这里,id
和val
分别绑定到返回的结构体中名为i
和s
的成员。
它们的类型分别是int
和std::string
。
结构化绑定的两点优势
- 一是无需再用成员运算符间接访问,而是可以直接访问成员,简化代码
- 二是可以将值绑定到一个能体现语义的变量名上,增强代码的可读性
一个使用结构化绑定来改进代码的例子
在没有结构化绑定的情况下,为了迭代一个std::map<>
的元素会像下边这样写:
for (const auto& element : mymap) {
std::cout << element.first << ": " << element.second;
}
通过使用结构化绑定,可以这么写:
for (const auto& [key,val] : mymap) {
std::cout << key << ": " << val;
}
通过这种方式,我们可以直接用简单的变量名来访问每个元素的键和值,让代码的可读性更强。
2. 带初始化的if
和switch
语句
C++17 中,if
和switch
语句允许我们在条件表达式里声明一个初始化语句。
2.1 带初始化的if
语句
现在可以这样写if
语句:
if (status s = check(); s == status::success) {
return s;
}
if
语句中初始化了一个变量s
,这个变量在整个if
语句中都是可访问的。
也可以在else
语句中访问,例如:
if (status s = check(); s == status::success) {
return s;
} else {
std::cout << "Error happend: " << s;
}
// 到此处 s 不再有效
如果初始化的是一个类,那么析构函数会在整个if
语句结束时调用。
2.2 带初始化的switch
语句
可以在switch
语句的条件表达式之前,声明一个初始化语句来决定控制流。
enum class Color {
RED,
GREEN,
BLUE
}
......
switch(Color color = GetSelectedColor(); color) {
case RED:
std::cout << "color=" << static_cast<int>(color);
break;
case GREEN:
break;
case BLUE:
break;
default:
break;
}
初始化的color
可以在整个switch
语句中使用。
3. 内联变量
C++17 之前,在类中定义的非 const 静态变量,需要在类的外面进行初始化,如:
class MyClass {
static std::string msg;
...
};
std::string MyClass::msg{"OK"};
C++17 引入了内联变量的概念,可以直接在类中定义并初始化非 const 静态变量。
class MyClass {
inline static std::string msg{"OK"}; // 自从 C++17 起 OK
...
};
内联变量也可以用于修饰全局变量
C++17 之前,全局变量只能在 cpp 文件中定义,然后在头文件中声明。
如果在头文件中定义,并且该头文件被 include 到多个 cpp 文件,那么编译器会报重复定义错误。
C++17 可以直接在头文件里通过inline
来定义全局变量,即使该头文件被多次include
,也不会报重复定义的错误。
// .h 文件
#include<xxx.h>
inline MyClass myGlobalObj; // 即使被多个 cpp 文件包含也 OK
4. 聚合体扩展
C++17 之前就有一种聚合体专有的始化方法,叫做聚合体初始化。
这是从 C 语言引入的初始化方式,是用大括号括起来的一组值来初始化类:
struct Data {
std::string name;
double value;
};
Data x = {"test1", 6.778};
自从 C++11起,可以忽略等号:
Data x{"test1", 6.778};
聚合体指数组或者C 风格的简单类。C 风格的简单的类要求没有用户定义的构造函数、没有私有或保护的非静态数据成员、没有虚函数。另外,在 C++17 之前,还要求没有基类。
C++17 对聚合体的概念进行了扩展,聚合体可以拥有基类了。
也就是说像下面这样的派生类,也可以使用聚合体初始化:
struct MoreData : Data {
bool done;
}
MoreData y{{"test1", 6.778}, false}; // {"test1", 6.778} 用来初始化基类
派生类使用聚合体初始化时,使用一个子聚合体初始化来初始化基类的成员。
聚合体扩展的动机
如果没有这个特性,我们要像下面这样定义构造函数来进行初始化:
struct Cpp14Data : Data {
bool done;
Cpp14Data (const std::string& s, double d, bool b) : Data{s, d}, done{b} {
}
};
Cpp14Data y{"test1", 6.778, false};
现在我们不需要定义构造函数,可以直接使用聚合体初始化:
MoreData x{{"test1", 6.778}, false};
如果给出了基类初始化需要的所有值,可以省略内层的大括号:
MoreData y{"test1", 6.778, false};
5. lambda 表达式扩展
C++17 扩展了 lambda 表达式的应用场景:
- 在 constexpr(即常量表达式)中使用,也就是在编译期间使用
- 在需要
this
指针的拷贝时使用
5.1 constexpr lambda
自从 C++17 起,lambda 表达式会尽可能的被隐式声明为 constexpr。
这意味着,在 lambda 表达式内部,只使用有效的编译期上下文(例如,只有字面量,没有静态变量,没有虚函数,没有 try/catch,没有 new/delete),就可以被用于编译期。
例如,可以使用一个 lambda 表达式计算参数的平方,并将计算结果用作 std::array<> 的大小:
auto squared = [](auto val) { // 自从 C++17 起被隐式声明为 constexpr
return val*val;
};
std::array<int, squared(5)> a; // 自从 C++17 起 OK,等同于 std::array<int, 25>
使用 constexpr 中不允许的特性将会使 lambda 失去成为 constexpr 的能力,不过仍然可以在运行时上下文中使用 lambda:
auto squared2 = [](auto val) {
static int calls = 0; // OK, 但会使该 lambda 不能成为 constexpr
...
return val*val;
};
std::array<int, squared2(5)> a; // ERROR:在编译期上下文中使用了静态变量
std::cout << squared2(5); // OK
为了确定一个 lambda 是否能用于编译期,可以将它主动声明为 constexpr:
auto squared3 = [](auto val) constexpr {
return val*val;
};
如果在主动声明了 constexpr 的 lambda 内,使用了编译期上下文中不允许出现的特性,将会导致编译错误:
auto squared4 = [](auto val) constexpr {
static int calls = 0; // ERROR: 在编译期上下文中使用了静态变量
...
return val*val;
};
5.2 向 lambda 传递 this 的拷贝
在 C++17 之前,可以通过值或引用捕获 this:
class C {
private:
std::string name;
public:
...
void foo() {
auto l1 = [this] {std::cout << name;}; // OK
auto l2 = [=] {std::cout << name;}; // OK
auto l3 = [&] {std::cout << name;}; // OK
...
}
};
问题是即使是用拷贝的方式捕获 this,实质上获得的也是引用(因为只会拷贝 this 指针的值)。
当 lambda 的生命周期,比该对象的生命周期更长的时候,调用这样的 lambda 就可能导致问题。
自从 C++17 起,可以通过 *this 显式地捕获所指对象的拷贝:
class C {
private:
std::string name;
public:
...
void foo() {
auto l1 = [*this] {std::cout << name';}; // 捕获的是 this 所指对象的拷贝
...
}
};
6. 新属性
C++17 之前已有的属性:
属性 | 出现版本 | 含义 |
---|---|---|
[[noreturn]] | (since C++11) | 函数不会返回 |
[[deprecated("reason")]] | (since C++11) | 函数已经废弃,并给出提示 |
[[carries_dependency]] | (since C++11) | 让编译期跳过不必要的内存栅栏指令 |
[[deprecated]] | (since C++14) | 函数已经废弃 |
C++17 新增了三个属性。
6.1 [[fallthrough]] 属性
[[fallthrough]]
可以避免编译器在switch
语句中,当某一个标签缺少break
语句时发出警告。
void foo(int error)
{
switch (error) {
case 1:
[[fallthrough]];
case 2:
std::cout << "Error happened";
break;
default:
std::cout << "OK";
break;
}
}
[[fallthrough]]
必须被用作单独的语句,还要有分号结尾。另外,在switch
语句的最后一个分支不能使用。
6.2 [[nodiscard]] 属性
[[nodiscard]] (since C++17)
[[nodiscard("reason")]] (since C++20)
[[nodiscard]]
可以鼓励编译器在某个函数的返回值未被使用时给出警告。
[[nodiscard]] char* foo() {
char* p = new char[100];
...
return p;
}
...
foo(); // 编译器发出警告
6.3 [[maybe_unused]] 属性
[[maybe_unused]]
可以避免编译器在某个变量未被使用时发出警告。
例如,可以用来定义一个可能不会使用的函数参数:
void foo(int val, [[maybe_unused]] std::string msg)
{
#ifdef DEBUG
log(msg);
#endif
...
}
还可以定义一个可能不会使用的成员:
class MyStruct {
char c;
int i;
[[maybe_unused]] char makeLargerSize[100];
...
};
[[maybe_unused]]
可以应用于以下场景:类的声明、使用typedef
或者using
定义的类型、一个变量、一个非静态数据成员、一个函数、一个枚举类型、一个枚举值。
7. 其他语言特性
7.1 嵌套命名空间
以前:
namespace A {
namespace B {
namespace C {
...
}
}
}
C++ 17:
namespace A::B::C {
...
}
7.2 UTF-8 字符字面量
自从 C++11 起,C++ 就已经支持以 u8 为前缀的 UTF8 字符串字面量。
但是,这个前缀不能用于字符字面量。
C++17 修复了这个问题,现在可以这么写:
char c = u8'6'; // UTF-8 编码的字符 '6'
总结一下,字符和字符串字面量现在接受以下前缀:
- u8:用于单字节 USASCII 和 UTF8 编码
- u:用于两字节的 UTF16 编码
- U:用于四字节的UTF32 编码
- L:用于没有指定编码,可能是两个或者四个字节的宽字符集
7.3 单参数 static_assert
自从 C++17 起,static_assert()
消息参数变为可选的了。例如:
#include <type_traits>
template<typename t>
class foo {
static_assert(std::is_default_constructible<T>::value, "class foo: elements must be default-constructible"); // 自从 C++11 起 OK
static_assert(std::is_default_constructible_v<T>); // 自从 C++17 起 OK
...
};
7.4 预处理条件 __has_include
C++17 增加了__has_include
预处理指令,检查某个头文件是否存在。注意并不是检查是否已经被include
过了。
__has_include ("文件名")
__has_include (<文件名>)
#if __has_include(<Windows.h>)
#define WINDOWS_FLAG 1
#else
#define WINDOWS_FLAG 0
#endif
__has_include
是一个纯粹的预处理指令。所以不能在运行时使用它:
if (__has_include("xxx.h")) { // ERROR
}
三、模板特性
8. 类模板参数推导
在 C++17 之前,必须明确指出类模板的所有参数。例如,不可以省略下面的double
:
std::complex<double> c{5.1, 3.3};
也不可以省略下面的 std::mutex:
std::mutex mx;
std::lock_guard<std::mutex> lg(mx);
自从 C++17 起,不用必须指明类模板参数了。
通过使用类模板参数推导,只要编译器能根据初始值推导出所有模板参数,那么就可以不指明参数。
例如:
- 现在可以这么写:
std::complex c{5.1, 3.3}; // OK,推导出 std::complex<double>
- 也可以这么写:
std::mutex mx;
std::lock_guard lg{mx}; // OK,推 导 出std::lock_guard<std::mutex>
- 甚至可以让容器来推导元素类型:
std::vector v1{1, 2, 3}; // OK,推导出 std::vector<int>
std::vector v2{"hello", "world"}; // OK,推导出 std::vector<const char*>
9. if constexpr(编译期 if 语句)
template <typename T>
auto get_value(T t) {
if constexpr (std::is_pointer_v<T>)
return *t;
else
return t;
}
编译器在编译期决定使用if
语句的哪部分,未使用的部分不会生成代码。但是语法检查还是会进行的。
10. 折叠表达式
折叠表达式可以计算对模板参数包中的所有参数应用一个二元运算符的结果。
例如,下面的函数将会返回所有参数的总和:
template<typename... T>
auto sum(T... args) {
return (... + args); // ((arg1 + arg2) + arg3)...
}
如下调用:
sum(47, 11, val, -1);
会把模板实例化为:
return 47 + 11 + val + -1;
如下调用:
sum(std::string("hello"), "world", "!");
会把模板实例化为:
return std::string("hello") + "world" + "!";
四、新的标准库组件
C++17 新加了很多有用的标准库组件。
11. std::optional<>
我们有时候会有这样的需求,让函数返回一个对象,如下,错误情况下,怎么返回个空呢?
struct A {
...
};
A foo() {
bool ok = bar();
if (ok) {
return A();
} else {
// 怎么返回个空呢?
}
}
有一种办法是返回对象指针,失败情况下可以返回std::nullptr
。
但是这就涉及到了内存管理,虽然使用智能指针可以避免手动进行内存管理。
但 C++17 有了更方便的办法:std::optional<>
。
std::optional<int> asInt(const std::string &s) {
try {
return std::stoi(s);
} catch(...) {
return std::nullopt; // 返回一个空的 std::optional
}
}
...
std::string s{"123"};
std::optional<int> oi = asInt(s);
if (oi) { // 判断是否有值
cout << *oi << endl;
} else {
cout << "error" << endl;
}
12. std::variant<>
C++17 新增了std::variant
组件,实现类似union(联合体)
,但却比union
更方便。
比如,union
里面不能有std::string
这种类型,但std::variant
却可以,还可以支持更多复杂类型,如std::map
等。
#include <variant>
#include <iostream>
int main()
{
std::variant<int, std::string> var{"hi"}; // 可以持有 int, std::string 两种类型的值,初始化为 std::string
std::cout << var.index(); // 打印出 1
var = 42; // 现在持有 int 类型
std::cout << var.index(); // 打印出 0
try {
int i = std::get<0>(var); // 通过索引访问
std::string s = std::get<std::string>(var); // 通过类型访问(这里会抛出异常)
...
} catch (const std::bad_variant_access& e) { // 当索引/类型错误时进行处理
std::cerr << "EXCEPTION: " << e.what();
...
}
// get_if
// get_if<T> 返回指向类型为 T 的值的指针或 nullptr
// get_if<Index> 返回指向索引为 Index 的值的指针或 nullptr
std::string* s = std::get_if<std::string>(&var); // s == nullptr
}
13. std::any
std::any
可以存储任何类型的单个值。
std::any a; // a 为空
std::any b = 4.3; // b 有类型为 double 的值 4.3
a = 42; // a 有类型为 int 的值 42
b = std::string{"hi"}; // b 有类型为 std::string 的值 "hi"
if (a.type() == typeid(std::string)) {
std::string s = std::any_cast<std::string>(a);
useString(s);
} else if (a.type() == typeid(int)) {
useInt(std::any_cast<int>(a));
}
为了访问内部的值,必须使用std::any_cast<>
将它转换为真正的类型:
auto s = std::any_cast<std::string>(a);
如果转换失败(可能是因为对象为空,或者类型不匹配),会抛出一个std::bad_any_cast
异常:
try {
auto s = std::any_cast<std::string>(a);
...
} catch (std::bad_any_cast& e) {
std::cerr << "EXCEPTION: " << e.what();
}
也可以对std::any
对象的地址进行转换,如果转换失败,将返回std::nullptr
。
auto p = std::any_cast<std::string>(&a);
if (p != nullptr) {
...
}
14. std::shared_mutex
这个就是读写锁,就不多讲了。
15. std::string_view
void foo(std::string_view strv)
{
cout << strv << endl;
}
int main(void)
{
std::string str = "Hello World";
std::string_view strv(str.c_str(), str.size());
foo(strv);
return 0;
}
std::string_view
可以获取一个字符串的视图,字符串视图并不真正的创建或者拷贝字符串,而只是拥有一个字符串的查看功能。
std::string_view
比std::string
的性能高很多,std::string_view
只是记录了对应的字符串的指针和偏移位置。
std::string_view
对指向的内容是只读的,当我们在只是查看字符串内容的时候,使用std::string_view
来代替std::string
。
16. 文件系统
C++17 终于将文件系统纳入标准中,提供了关于文件系统的很多功能,基本上应有尽有,这里简单举几个例子:
namespace fs = std::filesystem;
// 创建目录
fs::create_directory(fs::path("tmp/test"));
// 创建目录树
fs::create_directories(fs::path("tmp/test/subdir"));
// 拷贝文件
fs::copy_file(src, dst, fs::copy_options::skip_existing);
// 判断文件是否存在
bool is_exist = fs::exists(filename);
// path 代表一个文件系统路径
std::filesystem::path p{"c:\\1.txt"};
// 路径 p 是普通文件吗?
if (is_regular_file(p)) {
std::cout << file_size(p) << " bytes";
}
// 路径 p 是目录吗?
if (is_directory(p)) {
for (auto& e : std::filesystem::directory_iterator{p}) { // 遍历目录
std::cout << " " << e.path() <<;
}
}
五、总结
-
本次介绍了 C++17 中大部分新特性,个别特性没有介绍。
如并行 STL 算法,这个特性为几乎所有标准库函数加上一个执行策略参数,可以让使用者选择并行还是串行。
-
C++17 是一个比较大的更新。
-
C++ 越来越方便,但是也越来越复杂。