学习C++17的新特性

1.构造函数模板推导

在之前,我们如果想用stl容器,都需要用<> 来手动指定参数类型。但在C++17中,我们不需要这么做了。

1
2
3
4
5
6
7
8
9
int main()
{
std::vector v1 = {1,2,3,4};
std::pair p1 = {1,2.4234};
cout << typeid(v1).name() << endl;
cout << typeid(p1).name() << endl;

return 0;
}

使用C++11编译,这个代码会报错。报错的意思是让我们指定参数的模板类型。

比如 std::pair p1 = {1,2.4234}; 在C++11中应该写成 std::pair<int,double> p1 = {1,2.4234};

1
2
3
4
5
6
7
8
test.cpp:16:10: error: use of class template 'std::pair' requires template arguments
std::pair p1 = {1,2.4234};
^
/usr/bin/../lib/gcc/x86_64-linux-gnu/11/../../../../include/c++/11/bits/stl_pair.h:211:12: note: template is declared here
struct pair
^
3 errors generated.
make: *** [makefile:3: test] Error 1

在C++17中,这样的写法就是可以被通过的了,也能正常推断出参数的类型,分别是一个int的vector,和一个int+double的pair;

1
2
3
4
5
$ make
clang++ test.cpp -o test -std=c++17
$ ./test
St6vectorIiSaIiEE
St4pairIidE

2.结构化绑定

我们可以用 auto[变量1,变量2]的方式来接受一个tuple或者pair的返回值,将其绑定到两个不同的变量上。

tuple是C++11新增的一个数据结构,它和pair的用法类似,不同的是元组支持无数个参数。而pair仅支持两个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
std::tuple<int, double> func_tuple()
{
return std::tuple<int,double>(1, 2.2);
}

std::pair<int, double> func_pair()
{
return {1,2};
}

int main()
{
auto [i, d] = func_tuple();
cout << typeid(i).name() << endl;
cout << typeid(d).name() << endl;

cout << endl;

auto [x,y] = func_pair();
cout << typeid(x).name() << endl;
cout << typeid(y).name() << endl;

return 0;
}

使用C++11来编译,编译器会报错,但编译依旧能成功。这是因为我们的编译器是支持C++17的,但又被指定了-std=c++11,所以给用户报了个警告,但没有报错(因为这个语法在C++17里面是正确的)

1
2
3
4
5
6
7
8
clang++ test.cpp -o test -std=c++11
test.cpp:34:10: warning: decomposition declarations are a C++17 extension [-Wc++17-extensions]
auto [i, d] = func_tuple();
^~~~~~
test.cpp:40:10: warning: decomposition declarations are a C++17 extension [-Wc++17-extensions]
auto [x,y] = func_pair();
^~~~~
2 warnings generated.

运行输出结果如下

1
2
3
4
5
6
$ ./test
i
d

i
d

注意:结构化绑定不能应用于constexpr!

结构化绑定不止可以绑定pair和tuple,还可以绑定数组和结构体等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 注意这里的struct的成员一定要是public的,不然外部无法访问,还怎么绑定?
struct Point
{
int x;
int y;
};

// 返回值是point的函数
Point func()
{
return {1, 2};
}

int main()
{
int array[3] = {1, 2, 3};
auto [a, b, c] = array;
cout << a << " " << b << " " << c << endl;
// 直接推导出两个成员变量并赋值给变量x和y
const auto [x, y] = func();
return 0;
}

成功编译并输出结果

1
2
3
4
5
$ make
clang++ test.cpp -o test -std=c++17
$ ./test
1 2 3
1 2

自定义类型也能实现结构化绑定,这里从网上扒了一个代码下来,就不自己做测试了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 需要实现相关的tuple_size和tuple_element和get<N>方法。
class Entry {
public:
void Init() {
name_ = "name";
age_ = 10;
}

std::string GetName() const { return name_; }
int GetAge() const { return age_; }
private:
std::string name_;
int age_;
};

template <size_t I>
auto get(const Entry& e) {
if constexpr (I == 0) return e.GetName();
else if constexpr (I == 1) return e.GetAge();
}

namespace std {
template<> struct tuple_size<Entry> : integral_constant<size_t, 2> {};
template<> struct tuple_element<0, Entry> { using type = std::string; };
template<> struct tuple_element<1, Entry> { using type = int; };
}

int main() {
Entry e;
e.Init();
auto [name, age] = e;
cout << name << " " << age << endl; // name 10
return 0;
}

3.if语句新增初始条件

在之前我们都是用 if(判断条件) 来使用if语句的,C++17中给if新增了一个类似for循环中第一个参数的相同参数

1
if(初始化条件,判断条件)

比如

1
2
3
if(int i=20;i<39){
cout <<"i<39!"<<endl;
}

运行效果如下

1
2
$ ./test
i<39!

4.内联变量

在之前我们想初始化一个类中的static变量,需要在类中定义,类外初始化。但如果是const的static变量,就能直接在类中通过缺省值的方式来初始化。

1
2
3
4
5
6
7
// 在头文件里面这样是能通过编译的,但是不建议在头文件中初始化static变量,会产生ODR冲突:
// Variable 'value' defined in a header file; variable definitions in header files can lead to ODR violations
struct A {
static int value;
static const int value_c=10; // const可以直接初始化
};
int A::value = 10;

在C++17中内联变量引入后,我们就可以直接实现在头文件中初始化static非const变量,或者直接用缺省值来初始化了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct A
{
static int value;
static const int value_c = 10;
// static int value = 10;
};

inline int A::value = 10;

// ========= 或者 ========
struct B
{
inline static int value = 10;
inline static const int value_c = 10;
};

相比于原本static变量初始化需要放到另外一个cpp源文件中,这种直接在头文件里面声明+初始化的方式能更好的确定变量的初始值。

5.折叠表达式

C++17引入了折叠表达式使可变参数模板编程更方便:

1
2
3
4
5
6
7
8
template <typename ... Ts>
auto sum(Ts ... ts) {
return (ts + ...);
}
int a {sum(1, 2, 3, 4, 5)}; // 15
std::string a{"hello "};
std::string b{"world"};
cout << sum(a, b) << endl; // hello world

实话说,可变模板参数这部分就没有弄明白过,实际上也没有用过,直接跳过!

6.constexpr+lambda表达式

C++17前lambda表达式只能在运行时使用,C++17引入了constexpr lambda表达式,可以用于在编译期进行计算。

1
2
3
4
int main() { // c++17可编译
constexpr auto lamb = [] (int n) { return n * n; };
static_assert(lamb(3) == 9, "a");
}

规则和普通的constexpr函数相同,参考我的C++11和14的文章。这里做简单说明:

constexpr修饰的函数体不能包含汇编语句、goto语句、label、try块、静态变量、线程局部存储、没有初始化的普通变量,不能动态分配内存,不能有new delete等,不能虚函数。

7.嵌套命名空间

在之前如果需要嵌套命名空间,需要这样写

1
2
3
4
5
6
7
namespace A {
namespace B {
namespace C {
void func();
}
}
}

C++17中可以直接用类似访问限定符的方式,前面加一个namespace来标明嵌套的命名空间。

1
2
3
4
// c++17,方便了,可读性也更好
namespace A::B::C {
void func();
}

8.__has_include预处理表达式

1
2
3
4
5
6
#if defined __has_include // 判断是否支持这个表达式
#if __has_include(<charconv>) // 支持,判断是否存在该头文件
#define has_charconv 1 // 头文件存在,定义一个宏
#include <charconv> // 引用这个头文件
#endif
#endif

如果一个代码会在多个不同的平台下跑,这个功能就很重要。比如我之前写项目的时候需要使用到jsoncpp,在centos和deepin下,安装jsoncpp的include路径是不同的

1
2
3
4
//centos
#include <json/json.h>
//deepin
#include <jsoncpp/json/json.h>

这种场景下就可以使用上面提到的这个预处理表达式进行判断,来确认你的jsoncpp路径到底在哪里。注意,这只能解决从yum和apt安装的jsoncpp,如果是自己手动安装的,那鬼知道你安装到哪里去了?🤣

所以很多大型项目如果需要使用jsoncpp这种第三方依赖项目,一般都会采用git submodule的方式,直接将第三方库下载到当前项目路径下,以避免不同平台的依赖项include路径不对而导致无法编译程序的问题。

9.this指针捕获(lambda)

在lambda表达式中,采用[this]方式捕获的this指针是值传递捕获的,但在一些情况下,会出现访问已经被释放了的空间的行为;比如如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <functional>
#include <iostream>
#include <memory>
using namespace std;

struct Foo
{
std::unique_ptr<int> p;

std::function<void()> f()
{
p.reset(new int(10));
return [&]
{
cout << 5 << endl;
cout << *p << endl; // 实际上是这一步报错的
// 这里对*p的访问可以解析为 *(this->p),但实际上this指针已经被销毁了
// 注意,这里采用了智能指针,不存在内存泄漏,p指针指向的空间也被销毁了
// 但我们的报错其实是对this指针解引用的时候就抛出了
cout << 6 << endl;
};
}
};

int main()
{
auto foo = new Foo();
cout << 1 << endl;
auto f = foo->f(); // 获取了一个类内成员函数
cout << 2 << endl;
delete foo; // 销毁这个对象
cout << 3 << endl;
// 尝试在销毁后继续使用这个对象,我们是通过lambda中=捕获的this指针来访问对象的
f(); // 这里直接报错了 Segmentation fault (core dumped)
cout << 4 << endl;

return 0;
}

运行这个程序,可以看到是在*p的位置报错退出的;具体的原因参考代码中的注释。

1
2
3
4
5
6
$ ./test
1
2
3
5
Segmentation fault (core dumped)

需要注意,lambda表达式中,使用=和&都会默认采用传值捕获this指针,因为this指针是存在于函数作用域中的一个隐藏参数,并不是独立在成员函数外的变量,所以是可以被捕捉到的;另外,this指针是不能被传引用捕获的,[&this] 的写法是不允许的;

1
2
3
4
5
clang++ test.cpp -o test -std=c++17
test.cpp:84:18: error: 'this' cannot be captured by reference
return [&this]
^
1 error generated.

C++17中提供了一个特殊的写法 [*this] 通过传值的方式捕获了当前对象本身,此时lambda表达式中存在的就是一个对象的拷贝,即便当前对象被销毁了,我们依旧可以通过这个拷贝访问到目标;

代码修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <functional>
#include <iostream>
#include <memory>
using namespace std;

struct Foo
{
std::shared_ptr<int> p; // 不能用unique_ptr,因为它的拷贝构造函数是被delete禁止使用的

std::function<void()> f()
{
p.reset(new int(10));
return [*this]
{
cout << 5 << endl;
cout << *p << endl;
cout << 6 << endl;
};
}
};

int main()
{
auto foo = new Foo();
cout << 1 << endl;
auto f = foo->f(); // 获取了一个类内成员函数
cout << 2 << endl;
delete foo; // 销毁这个对象
cout << 3 << endl;
// 尝试在销毁后继续使用这个对象,我们是通过lambda中*this捕获的新对象来访问的
f();
cout << 4 << endl;

return 0;
}

此时重新编译,就能成功访问到指针p指向的对象了,并不受foo对象已经被delete的影响;

1
2
3
4
5
6
7
8
$ ./test
1
2
3
5
10
6
4

10.字符串转换

没看懂这两个函数是干嘛的,找到的代码连编译都过不去,跳过吧

新增from_chars函数和to_chars函数

1
2
https://zh.cppreference.com/w/cpp/utility/from_chars
https://blog.csdn.net/defaultbyzt/article/details/120151801

11.std::variant

C++17增加std::variant实现类似union的功能,但却比union更高级,举个例子union里面不能有string这种类型,但std::variant却可以,还可以支持更多复杂类型,如map等,看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int main() { // c++17可编译
std::variant<int, std::string> var("hello");
cout << var.index() << endl;
var = 123;
cout << var.index() << endl;

try {
var = "world";
std::string str = std::get<std::string>(var); // 通过类型获取值
var = 3;
int i = std::get<0>(var); // 通过index获取对应值
cout << str << endl;
cout << i << endl;
} catch(...) {
// xxx;
}
return 0;
}

注意:一般情况下variant的第一个类型一般要有对应的构造函数,否则编译失败:

1
2
3
4
5
6
struct A {
A(int i){}
};
int main() {
std::variant<A, int> var; // 编译失败
}

如何避免这种情况呢,可以使用std::monostate来打个桩,模拟一个空状态。

1
std::variant<std::monostate, A> var; // 可以编译成功

12.std::optional

https://en.cppreference.com/w/cpp/utility/optional

有的时候,我们想在异常的时候抛出一个异常的对象,亦或者是在出现一些不可预期的错误的时候,返回一个空值。要怎么区分空值和异常的对象呢?

在python中,我们有一个专门的None对象可以来处理这件事。在MySQL中,我们也有NULL来标识空;但在CPP中,我们只剩下一个nullptr,其本质是个指针,与Py中的None和MySQL中的NULL完全不同!如果想用指针来区分空和异常对象,那就需要用到动态内存管理,亦或者是用智能指针来避免内存泄漏。

说人话就是,在CPP中没有一个类似None的含义为空的对象,来告诉调用这个程序的人,到底是发生了错误,生成了一个错误的对象,还是说压根什么都没有弄出来。

于是std::optional就出现了,其可以包含一个类型,并有std::nullopt来专门标识“空”这个含义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <optional>
std::optional<int> StoI(const std::string &s) {
try {
return std::stoi(s);
} catch(...) {
return std::nullopt;
}
}

void func() {
std::string s{"123"};
std::optional<int> o = StoI(s);
if (o) {
cout << *o << endl;
} else {
cout << "error" << endl;
}
}

这里我们进行了if的判断,首先判断变量o本身,为真代表的确返回了一个int值,为假代表返回的是nullopt

随后再使用*o来访问到内部托管的成员。

需要注意这里是两层的逻辑关系,只有optional对象中成功托管了一个指定的参数类型,其本身才是真的。如果想访问它托管的对象,则需要用解引用。

比如这里,我们的o对象托管的是一个bool类型的假,但假并不代表空,o对象本身的判断是真,内部对*o的判断才是判断托管的bool值到底是真是假。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <optional>
int main() {
std::optional<bool> o = false;
cout << typeid(o).name() << endl;
if (o) // 这里判断的是optional对象是否有托管一个bool值
{
if(*o){ // 这里判断的是托管的bool值本身
cout << "true" << endl;
}
else{
cout << "false" << endl;
}
} else { // 这里则代表托管的是nullopt
cout << "error" << endl;
}

return 0;
}

最终运行打印的结果是false

13.std::any

https://en.cppreference.com/w/cpp/utility/any

这个类型可以托管任意类型的值,与之对应的还有一个std::any_cast来将其托管的值转成我们需要的类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <any>

int main() { // c++17可编译
std::any a = 1;
cout << a.type().name() << " " << std::any_cast<int>(a) << endl;
a = 2.2f;
cout << a.type().name() << " " << std::any_cast<float>(a) << endl;
if (a.has_value()) {
cout << a.type().name();
}
a.reset();
if (a.has_value()) {
cout << a.type().name();
}
a = std::string("a");
cout << a.type().name() << " " << std::any_cast<std::string>(a) << endl;
return 0;
}

输出结果如下

1
2
3
i 1
f 2.2
fNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE a

虽然any的出现让cpp也在一定程度上能实现“弱类型”变量,但在具体的开发中,明确变量的类型依旧比使用any好得多。特别是在变量的类型并不可以被直接转换的情况下。

14.std::apply

使用std::apply可以将tuple/pair展开作为函数的参数传入,见代码:

1
2
3
4
5
6
7
8
9
10
11
#include <tuple>
int add(int first, int second) { return first + second; }

auto add_lambda = [](auto first, auto second) { return first + second; };

int main() {
std::cout << add(std::pair(1, 2)) << "\n"; // error

std::cout << std::apply(add, std::pair(1, 2)) << '\n';
std::cout << std::apply(add_lambda, std::tuple(2.0f, 3.0f)) << '\n';
}

15.std::make_from_tuple

使用make_from_tuple可以将tuple展开作为构造函数参数

1
2
3
4
5
6
7
8
9
struct Foo {
Foo(int first, float second, int third) {
std::cout << first << ", " << second << ", " << third << "\n";
}
};
int main() {
auto tuple = std::make_tuple(42, 3.14f, 0);
std::make_from_tuple<Foo>(std::move(tuple));
}

16.std::string_view

https://zhuanlan.zhihu.com/p/166359481

https://en.cppreference.com/w/cpp/string/basic_string_view

如果我们只需要一个string的只读类型的话,可以用string_view来托管。其内部只包含一个指向目标字符串的指针,以及字符串的长度。

string_view内部封装了string的所有只读接口,本来就是给你读的。

需要注意的是,因为内部只有一个指针,所以当string_view托管的string被销毁了,与之关联的所有string_view都会失效!同样是因为内部只有一个指针和字符串的长度两个变量,所以在传值拷贝的时候,string_view的效率会高很多。

  • 这和const string& 类型的传值又有什么区别呢?传引用不是也没有拷贝消耗吗?

这个问题很好,我不知道!百度也没有百度出来……

我能想到的就是用string_view作为参数的时候,如果入参是一个常量字符串,此时不需要构造string,而使用const string& 接受常量字符串的时候依旧需要构造一个string对象。这部分就会有一定的消耗。

17.as_const

C++17使用as_const可以将左值转成const类型

1
2
std::string str = "str";
const std::string& constStr = std::as_const(str);

18.file_system

C++17正式将file_system纳入标准中,提供了关于文件的大多数功能,基本上应有尽有,这里简单举几个例子:

1
2
3
4
5
namespace fs = std::filesystem;
fs::create_directory(dir_path); // 创建文件或者路径
fs::copy_file(src, dst, fs::copy_options::skip_existing); // 文件cp
fs::exists(filename); // 文件是否存在
fs::current_path(err_code); // 获取当前路径

19.shared_mutex

这玩意是个读写锁。简单介绍一下什么是读写锁:

  • 读者可以有多个,写者只能有一个
  • 写锁是互斥的,如果A有锁,B想拿锁就得阻塞等待
  • 读锁是共享的,C有读锁,D也想读,两个人可以一起看
  • 读写锁是互斥的,有人写的时候不能读,有人读的时候不能写

换到专业术语上,就是分为独占锁(写锁)和共享锁(读锁);

在C++14中其实已经有了一个shared_timed_mutex,C++17中这个锁的操作与其基本一致,只不过多了几个和时间相关的接口

1
2
3
4
try_lock_for(...);
try_lock_shared_for(...);
try_lock_shared_until(...);
try_lock_until(...);

具体使用可以参考

1
2
https://zh.cppreference.com/w/cpp/thread/shared_mutex
https://zhuanlan.zhihu.com/p/610781321

The end

关于C++17常用的基本就是这些了,后续遇到新的再更新本文。