C++17新特性(一)基本语言特性

1. 结构化绑定

假设你有两个不同成员的结构体:

struct MyStruct
{
  int i = 0;
  std::string s;
};
MyStruct ms;

你可以通过如下声明直接把两个成员绑定到新的变量名:

auto[u,v] = ms;

这种声明方式就称为结构化绑定。

下面这段代码演示了结构化绑定带来的好处。在不使用结构化绑定遍历std::map是这样的。

for(const auto& elem : mymap)
{
	cout << elem.first << " " << elem.second << endl;
}

我们知道map中每一个元素都是一个pair类型,使用结构化绑定:

for(const auto&[key,value] : mymap)
{
	cout << key << " " << value<< endl;
}
1.1 细说结构化绑定
auto [u,v] = ms;

上面这段代码等价于:

auto e = ms;
aliasname u = e.i;
aliasname v = e.s;

这就意味着u和v是ms的一份成员拷贝的别名。因此,修改u和v变量并不会影响结构体成员的数据,反过来也一样。

当我们将结构化绑定声明为引用,修改变量的值会影响结构体变量的值:

auto& [u,v] = ms;

u = 10;
cout << ms.i << endl; // 10

除此之外,使用auto结构化绑定也不会发生类型退化(decay)。例如,我们有一个结构体包含两个原生数组:

struct S
{
	const char x[6];
	const char y[3];
};

S s1{};
auto [a,b] = s1;

a仍然是const char[6],b仍然是const char[3]

在move语义下,也遵循介绍的规则。被移走的对象会处于一个未定义但却有效的状态。不要对打印的值做任何假设。

MyStruct ms = {42,"Jim"};
auto&& [v,n] = std::move(ms);
cout << v << endl; // 42;
cout << ms.i << endl; // 未定义
1.2 结构化绑定的适用场景

结构化绑定适用以下场景:

  • 对于所有非静态数据成员都是public的结构体或者类。
  • 对于原生数组,可以把每一个元素都绑定在新的变量上。
  • 对于任何类型,可以使用tuple-like API来绑定新的名称,对于一个类型type需要如下组件:
    • std::tuple_size<type>::value要返回元素的数量。
    • std::tuple_element<idx,type>::type返回第idx个元素的类型。
    • 一个全局或成员函数get<idx>()要返回第idx个元素的值。

标准库类型std::pair<>std::tuple<>std::array<>就是提供了这些API。

有的时候,如果结构化绑定的所有元素并非自己想要的,你可以使用_来作为名称,但是同一个作用域只能使用一次。

auto [_,val1] = ms;
1.2.1 结构体和类

结构体绑定需要继承时遵循一定的规则。成员要么直接来自最终的类,要么全部来自一个父类。

struct B
{
	int a = 1;
	int b = 2;
};
struct D : B {};

auto [x,y] = D{}; // OK

struct D1 : B
{
    int c = 3;
};

auto [i,j,k] = D1{}; // ERROR
1.2.2 原生数组
int arr[] = {47,11};
auto [x,y] = arr;
auto [z] = arr; // ERROR

当数组长度已知是才可以使用结构化绑定。数组按值传入的参数不能使用结构化绑定,否则会退化为相应的指针类型。

C++允许通过引用来返回带有大小信息的数组,结构化绑定可以应用于返回这种数组的函数:

auto getArr() -> int(&)[2];
auto [x,y] = getArr();
1.2.3 pair,tuple,array

结构化绑定机制是可扩展的,你可以为任何类型都添加对结构化绑定的支持。标准库就对pairtuplearray添加了支持。

array<int,4> getArray();
auto [a,b,c,d] = getArray();

tuple<char,float,std::string> getTuple();
auto [a,b,c] = getTuple();

std::map<std::string,int> coll;
auto [pos,ok] = coll.insert({"new",42});
if(!ok) // 插入失败
{
    // ...
}   

在声明了一个结构化绑定之后,你通常不能同时修改所有绑定的变量,因为结构化绑定只能一起声明但不能一起使用。然而,如果被赋的值可以赋予一个pair或者tuple,你可以使用tie()把值一起赋给变量,例如:

tuple<char,float,std::string> getTuple();

auto [a,b,c] = getTuple();

tie(a,b,c) = getTuple(); // a和b和c三个值同时被修改
1.3 为结构化绑定提供Tuple-Like API

你可以通过提供tuple-like API为任何类型添加结构化绑定的支持。

支持只读结构化绑定

class Customer
{
private:
	string first;
	string last;
	long val;
public:
	Customer(string f, string l, long v)
		: first(f), last(l), val(v)
	{}
	string getFirst() const
	{
		return first;
	}
	string getLast() const
	{
		return last;
	}
	long getVal() const
	{
		return val;
	}
};

template<>
struct tuple_size<Customer>
{
	static constexpr int value = 3; // 有三个成员变量
};

// 指定下标为2的类型为long
template<>
struct tuple_element<2, Customer>
{
	using type = long; // 最后一个类型是long
};
// 指定其他下标的类型为string
template<size_t Idx>
struct tuple_element<Idx, Customer>
{
	using type = string;
};

// 定义特化的getter
//template<size_t> auto get(const Customer& c);
//template<> auto get<0>(const Customer& c) { return c.getFirst(); }
//template<> auto get<1>(const Customer& c) { return c.getLast(); }
//template<> auto get<2>(const Customer& c) { return c.getVal(); }

// 可以使用C++17支持的编译期if语句特性
template<size_t Idx>
auto get(const Customer& c)
{
	static_assert(Idx < 3);
	if constexpr (Idx == 0)
		return c.getFirst();
	else if constexpr (Idx == 1)
		return c.getLast();
	else
		return c.getVal();
}

有了这个,我们可以对自定义类支持只读结构化绑定操作:

int main()
{
	Customer c{ "Tim","Starr",42 };
	auto [f, l, v] = c;
	cout << f << l << v << endl;
}

支持可写结构化绑定

class Customer
{
private:
	string first;
	string last;
	long val;
public:
	Customer(string f, string l, long v)
		: first(f), last(l), val(v)
	{}
	const string& getFirst() const
	{
		return first;
	}
	string& getFirst() 
	{
		return first;
	}
	const string& getLast() const
	{
		return last;
	}
	string& getLast() 
	{
		return last;
	}
	const long& getVal() const
	{
		return val;
	}
	long& getVal() 
	{
		return val;
	}
};

template<>
struct tuple_size<Customer>
{
	static constexpr int value = 3; // 有三个属性
};

// 指定下标为2的类型为long
template<>
struct tuple_element<2, Customer>
{
	using type = long; // 最后一个类型是long
};
// 指定其他下标的类型为string
template<size_t Idx>
struct tuple_element<Idx, Customer>
{
	using type = string;
};

// 定义特化的getter
template<size_t Idx>
decltype(auto) get(Customer& c)
{
	static_assert(Idx < 3);
	if constexpr (Idx == 0)
		return c.getFirst();
	else if constexpr (Idx == 1)
		return c.getLast();
	else
		return c.getVal();
}

template<size_t Idx>
decltype(auto) get(const Customer& c)
{
	static_assert(Idx < 3);
	if constexpr (Idx == 0)
		return c.getFirst();
	else if constexpr (Idx == 1)
		return c.getLast();
	else
		return c.getVal();
}

template<size_t Idx>
decltype(auto) get(Customer&& c)
{
	static_assert(Idx < 3);
	if constexpr (Idx == 0)
		return std::move(c.getFirst());
	else if constexpr (Idx == 1)
		return std::move(c.getLast());
	else
		return c.getVal();
}

必须提供3个版本的特化来处理常量对象、非常量对象、可移动对象。为了能返回引用,使用decltype(auto)来作为返回类型。

2. 带初始化的if和switch语句

if和switch语句允许在条件表达式添加一条初始化语句。

// s只在if语句里有效
if(status s = check(); s != status::success)
{
	return s;
}
2.1 带初始化的if语句

在if语句的条件表达式里定义的变量在整个if语句有效:

if(ostream strm = getLogStrm(); coll.empty())
{
	cout << "<no data>" << endl;
}
else 
{
	for(const auto& elem : coll)
	{
		strm << elem << "\n";
	}
}

另一个例子是锁的使用:

if(lock_guard<mutex> lg{collMutex}; !coll.empty())
{
	cout << coll.front() << '\n';
}

// 等价于
{
    lock_guard<mutex> lg{collMutex}; 
    if(!coll.empty())
    {
        cout << coll.front() << '\n';
    }
}
2.2 带初始化的switch语句

例如,我们可以声明一个文件系统路径,根据它的类别进行处理:

#include <filesystem>
namespace fs = std::filesystem; // C++17新增

int main()
{
	string name = "";
	switch (fs::path p{ name }; status(p).type())
	{
	case fs::file_type::not_found:
		cout << p << "not found\n";
		break;
	case fs::file_type::directory:
		cout << p << ":\n";
		for (const auto& e : fs::directory_iterator{ p })
		{
			cout << "-" << e.path() << '\n';
		}
	default:
		cout << p << "exists\n";
		break;
	}
}

3. 内联变量

出于可移植性和易于整合的目的,在头文件提供完整的类和库的定义时很重要的。在C++17之前,只有当这个库既不提供也不需要全局对象的时候才可以这样做。

自从C++17开始,你可以在头文件中以inline的方式定义全局变量/对象。

class MyClass
{
	inline static string msg{"OK"};
};

inline MyClass myGlobalObj; // 可以被多个CPP文件包含
3.1 内联变量产生的动机

C++里不允许在类里面初始化非常量静态成员:

class MyClass
{
	static string msg{"OK"}; // ERROR
};

可如果在类外面初始化非常量静态成员,如果被多个CPP文件同时包含又会引发链接错误:

class MyClass
{
	static string msg;
};

string MyClass::msg{"OK"};

根据一次定义原则,一个变量或者实体的定义只能在一个编译单元内,除非该变量或者实体被定义为inline

对于一些特殊场景,也有一些解决办法:

可以在类内定义中初始化数字或枚举类型的常量静态成员:

class MyClass
{
	static const bool trace = false; // OK,字面常量
};

然而,这种方法只能初始化字面类型,比如基本的整型、浮点型、指针类型或者用常量表达式初始化了所有内部非静态成员的类,并且该类不能有用户自定义的或虚的析构函数。

3.2 使用内联变量

现在,使用inline修饰符之后,即使定义所在的头文件被多个CPP包含,也只会有一个全局对象:

class MyClass
{
	inline static string msg{"OK"};
};

inline MyClass myGlobalObj; // 可以被多个CPP文件包含

这里使用的inline和函数声明时的inline有相同的定义:

  • 它可以在多个编译单元中定义,只要所有的定义都是相同的。
  • 它必须在每个使用它的编译单元中定义。

注意,你仍然必须确保你初始化内联变量之前它们的类型必须是完整的。例如,如果你有一个自身类型的static成员,这个成员只能在类型声明后在进行定义:

struct MyType
{
	int value;
	MyType(int i) : value(i) {}
	static MyType max; // 声明
};

inline MyType MyType::max{0};
3.3 constexpr static成员现在隐含inline

对于静态成员,constexpr修饰符现在隐含inline。自从C++17起,如下声明定义了静态数据成员n:

struct D
{
	static constexpr int n = 5; // 在C++17,隐含在前面添加了inline
};

在C++17之前,如果只有声明没有定义。如果D::n以引用传递到一个非内联函数,并且该函数调用没有被优化掉的话,会导致错误。

int twice(const int& i);
cout << twice(D::n);

这段代码违反了一次定义原则。如果编译器进行了优化,那么这段代码可能会像预期一样开始工作,也可能因为缺少定义导致链接错误。如果不进行优化,那么几乎肯定会因为缺少D::n的定义而导致错误。

因此,在C++17之前,必须在一个编译单元内定义D::n

constexpr int D::n;
3.4 内联变量和thread_local

通过使用thread_local可以为每个线程创建一个内联变量:

struct ThreadData
{
	inline static thread_local string name;
};

inline thread_local vector<string> cache; // 每个线程都有一份cache

案例:

// ThreadData.hpp
#pragma once
#include <string>
#include <iostream>

struct MyData
{
	inline static std::string gName = "global"; // 整个程序有一个
	inline static thread_local std::string tName = "tls"; // 每个线程有一个
	std::string lName = "local"; // 每个实例有一个

	void print(const std::string& msg) const
	{
		std::cout << msg << '\n';
		std::cout << "-gName:" << gName << '\n';
		std::cout << "-tName:" << tName << '\n';
		std::cout << "-lName:" << lName << '\n';
	}
};

inline thread_local MyData myThreadData; // 每个线程有一个对象

// main.cpp
#include "ThreadData.hpp"
#include <thread>

void foo()
{
	myThreadData.print("foo() begin:");
	myThreadData.gName = "thread2 name";
	myThreadData.tName = "thread2 name";
	myThreadData.lName = "thread2 name";
	myThreadData.print("foo() end");
}

int main()
{
	myThreadData.print("main() begin:");

	myThreadData.gName = "thread1 name";
	myThreadData.tName = "thread1 name";
	myThreadData.lName = "thread1 name";
	myThreadData.print("main() later:");

	thread t(foo);
	t.join();
	myThreadData.print("main() end");

}

输出结果:

main() begin:
-gName:global
-tName:tls
-lName:local
main() later:
-gName:thread1 name
-tName:thread1 name
-lName:thread1 name
foo() begin:
-gName:thread1 name
-tName:tls
-lName:local
foo() end
-gName:thread2 name
-tName:thread2 name
-lName:thread2 name
main() end
-gName:thread2 name
-tName:thread1 name
-lName:thread1 name

4. 聚合体扩展

C++有很多初始化对象的方法。其中之一叫做聚合体初始化,这是聚合体转悠的一种初始化方式。

struct Data
{
	string name;
	double value;
};

Data x = {"test",6.7};

C++11之后,可以忽略等号:

Data x{"test",6.7};

C++17起,聚合体可以拥有基类。并且可以使用如下的初始化方法:

struct MoreData : Data
{
	bool done;
};

MoreData y{{"test",6.7},false};
4.1 扩展聚合体初始化的动机

如果没有这个特性,派生类都不能使用聚合体初始化,也就是必须要实现如下的构造函数:

struct MoreData : Data
{
	bool done;
	MoreData(const string& s,double d,bool b) : Data{s,d},done{b} {}
};

C++17起,就无须定义任何构造函数就可以做到:

MoreData y{{"test",6.7},false}; // OK
MoreData y{"test",6.7,false}; // OK
4.2 聚合体的定义

总的来说,C++17中满足如下条件之一的对象被认为是聚合体:

  • 数组
  • 类类型(class、struct、union)
  • 没有用户定义的和explicit的构造函数
  • 没有使用using声明继承的构造函数
  • 没有private和protected的非静态数据成员
  • 没有virtual函数
  • 没有virtual、private、protected的基类

想使用聚合体初始化还必须满足以下约束:

  • 基类中没有privateprotected成员
  • 没有privateprotected的构造函数

C++17引入了一个新的类型特征is_aggregate<>来测试一个类型是否是聚合体:

template<typename T>
struct D : string,complex<T> 
{
	string data;
};

D<float> s{{"hello"},{4.5,6.7},"world"};
cout << is_aggregate<decltype(s)>::value; // 1
4.3 向后的不兼容性

下面这个例子不能通过编译:

struct Derived;

struct Base
{
    friend struct Derived;
private:
    Base() {}
};

struct Derived : Base {}

int main()
{
    Derived d1{}; // C++17之后ERROR
    Derived d2;
}

C++17之前,Derived不是聚合体。因为,在进行{}创建对象的时候,会调用Derived的默认构造函数,然后子类的默认构造函数又会调用父类的构造函数,即使父类的构造函数是私有的,但是因为派生类被声明为友元类,因此可以调用父类的私有构造函数。

但在C++17之后,Derived是一个聚合体,会默认认为d1是进行聚合体初始化,但是不满足父类的构造函数不能私有的情况,因此会导致不能使用花括号来进行初始化。

5. 强制省略拷贝或传递未实质化的对象

  • C++17引入了一个新的规则:当以值传递或返回一个临时对象的时候,必须省略对该临时对象的拷贝。
  • 从效果上讲,我们实际上是传递了一个未实质化的对象
5.1 强制省略临时变量拷贝的动机

自从第一次标准开始,C++就允许在某些情况下省略拷贝操作,即使这么做可能会影响程序的运行结果。例如:

class MyClass
{
	// ...
};

void foo(MyClass param)
{
	// ...
}

MyClass bar()
{
	return MyClass{};
}

int main()
{
	foo(MyClass{});
	MyClass x = bar();
    foo(bar());
}

然而,这种优化并不是强制性的,也就是说,即使优化之后并不会调用拷贝或者移动构造,但是它们必须存在。

自从C++17起用临时变量初始化对象时省略拷贝变成了强制性。事实上,之后我将会看到我们传递为参数或者作为返回值的临时变量将会被用来实质化一个新的对象。这意味着即使不允许MyClass拷贝,但也能成功编译。

MyClass bar(MyClass obj) // 传递临时变量会省略拷贝
{
	return obj; // 仍然需要拷贝/移动支持
}
5.2 强制省略临时变量拷贝的作用

这个特性的一个显而易见的作用就是减少拷贝带来更好的性能。尽管很多主流编译器之前就已经对这种进行了优化,但现在这一行为有了标准的保证。尽管移动语义能显著的减少拷贝的开销,但直接不进行拷贝会带来很大的性能提升。另外这个特性可以减少输出参数的使用,转而直接返回一个值。

另一个作用是可以定义一个总是可以工作的工厂函数,因为现在它甚至可以返回不允许拷贝或移动的对象。例如:

#include <utility>

template<typename T, typename... Args>
T create(Arg&&... args)
{
	return T{std::forward<Args>(args)};
}

即使像atomic这种既没有拷贝也没有移动构造的类也是可以使用的:

#include <memory>
#include <atomic>

int main()
{
	int i = create<int>(42);
	std::unique_ptr<int> up = create<std::unique_ptr<int>>(new int{42});
	std::atomic<int> ai = cteate<std::atomic<int>>(42);
}

另一个效果就是对于移动构造函数被显示删除的类,也可以返回临时对象来初始化新的对象:

class CopyOnly
{
public:
	CopyOnly() {}
	CopyOnly(int) {}
	CopyOnly(const CopyOnly&) = default;
	CopyOnly(CopyOnly&&) = delete;
};

CopyOnly ret() {
    return CopyOnly{}; // C++17起OK
}

CopyOnly x = 42; // C++17起ok

5.3 更明确的值类型体系

用临时变量初始化新对象时强制省略临时变量拷贝的提议的一个副作用就是,为了支持这个提议,值类型体系进行了很多修改。

5.3.1 值类型体系

C++从C语言继承而来的有左值右值,之后C++11引入了可移动对象。引入了将亡值的概念,原本的右值被重新命名为纯右值

左值的例子:

  • 只含单个变量、函数或成员的表达式。
  • 只含有字符串字面量的表达式。
  • 内建的一元*运算符的结果。
  • 一个返回左值引用的函数的返回值。

纯右值的例子:

  • 除字符串字面量和用户自定义的字面量之外的字面量组成的表达式。
  • 内建的一元&运算符的运算结果。
  • 内建的数学运算符的结果。
  • 一个返回值的函数的返回值。
  • 一个lambda表达式。

将亡值的例子:

  • 一个返回右值引用的函数的返回值。
  • 把一个对象转换为右值引用的操作的结果。

简单来说:

  • 所有用作表达式的变量名都是左值。
  • 所有用作表达式的字符串字面量是左值。
  • 所有其他的字面量(4.2,true,nullptr)是纯右值。
  • 所有临时对象是纯右值。
  • move()的结果是一个将亡值。
class X
{};

X v;
const X c;

void f(const X&); // 接受任何值类型
void f(X&&); // 只接受纯右值和将亡值

f(v); // 传递了一个可以修改的左值
f(c); // 传递了一个不可以修改的左值
f(X()); // 传递了一个纯右值
f(std::move(v));// 传递了将亡值
5.3.2 C++17起的值类型体系

C++17再次明确了值类型体系,从广义上来说,我们只有两种类型的表达式:

  • glvalue:描述对象或函数位置的表达式。
  • prvalue:用于初始化的表达式。

而原本的将亡值可以认为是一种特殊的位置,它代表一个资源可被回收利用的对象。

C++17引入了一个术语,(临时对象)实质化,目前prvalue就是一种临时对象。因此,临时对象实质化转换,就是一种从右值到将亡值的转换。

void f(const X& p); // 可以接受任何值类型
f(X()); // 传递了一个纯右值,该纯右值实质化为将亡值

以上就是实质化的过程,这个过程并没有创建新的对象。因为右值不在是对象而是可以被用来初始化对象的表达式,当使用右值来初始化对象的时候不再需要右值是可移动的,进而省略临时拷贝的特性可以完美实现。

5.4 未实质化的返回值传递

所有以值返回临时对象的过程都是在传递未实质化的返回值:

  • 当我们返回一个非字符串字面量的字面量时:

    int f1() 
    {
    	return 42;
    }
    
  • 当我们用auto或类型名作为返回类型并返回一个临时对象时:

    auto f2()
    {
    	return MyType{};
    }
    
  • 当我们使用decltype(auto)作为返回类型并返回临时对象时:

    decltype(auto) f3()
    {
    	return MyType{};
    }
    

以上场景都是以值返回一个右值,不需要任何拷贝/移动。

6. lambda表达式扩展

C++11引入了lambda表达式和C++14引入的泛型lambda是一个很大的成功。

C++17拓展了lambda表达式的应用场景:

  • 在常量表达式中使用。
  • 在需要当前对象的拷贝时使用。
6.1 constexpr lambda

自从C++17起,lambda表达式会尽可能的隐式声明constexpr。也就是说,任何只使用有效的编译器上下文(只有字面量、没有静态变量、没有虚函数、没有try/catch,没有new/delete)的lambda表达式都可以用作编译期。

例如:

auto squared = [](auto val) // 隐式constexpr
{
return val * val;
};
array<int, squared(5)> a; // C++17起OK

为了确认一个lambda表达式能否用于编译期,你可以声明为constexpr

auto squared = [](auto val) constexpr -> int 
{
	return val * val;	
};

这个表达式将会转换为如下类型:

class CompilerSpecificName
{
public:
	template<typename T>
	constexpr auto operator()(T val) const
	{
		return val * val;
	}
};

注意以下两个定义是不同的:

auto squared1 = [](auto val) constexpr
{
	return val * val;	
};

constexpr auto squared2 = [](auto val)
{
	return val * val;	
};

第一个例子是lambda表达式可以在编译期调用,第二个例子是编译期会初始化lambda表达式。

6.1.1 使用constexpr lambda

假设我们有一个字符序列的哈希函数,这个函数迭代字符串中的每一个字符反复更新哈希值:

int main(int argc,char *argv[])
{
	auto hashed = [](const char* str)
	{
		size_t hash = 5381;
		while (*str != '\0')
		{
			hash = hash * 33 ^ *str++;
		}
		return hash;
	};
	// 用于enum
	enum Hashed {
		beer = hashed("beer"),
		wine = hashed("wine"),
		water = hashed("water")
	};
	// 用于case标签
	switch (hashed(argv[1]))
	{
	case hashed("beer"):
		break;
	case hashed("wine"):
		break;
	default:
		break;
	}
}

如果我们使用编译期lambda表达式初始化一个容器,那么编译器优化时很可能在编译期就计算出容器的值。

array arr{
	hashed("beer"),
	wine = hashed("wine"),
    water = hashed("water")
};

甚至可以在一个constexpr lambda里使用另一个:

auto hashed = [](const char* str, auto combine)
{
    size_t hash = 5381;
    while (*str != '\0')
    {
    	hash = combine(hash,*str++);
    }
    return hash;
};

constexpr size_t hv1{ hashed("wine", [](auto h, char c) { return h * 33 + c;})};
constexpr size_t hv1{ hashed("wine", [](auto h, char c) { return h * 33 ^ c;})};
6.2 向lambda表达式传递this指针

当在非静态成员函数里使用lambda时,你不能隐式获取该对象成员的使用权。也就是说,如果你不捕获this的话你将不能在lambda里使用该对象的任何成员。

class C
{
private:
	string name;
public:
	void foo()
	{
		auto l1 = [] {cout << name << '\n'; }; // ERROR
		auto l2 = [] {cout << this->name << '\n'; }; // ERROR
	}
};

在C++11和C++14里,可以通过值或引用捕获this:

class C
{
private:
	string name;
public:
	void foo()
	{
		auto l1 = [this] {cout << name << '\n'; };
		auto l2 = [=] {cout << this->name << '\n'; };
		auto l3 = [&] {cout << this->name << '\n'; };
	}
};

然而,问题是即使用拷贝的方式捕获this实质上获得的也是引用。当lambda表示的生命周期比该对象的生命周期更长的时候,调用这样的函数就可能导致问题。比如,在lambda表达式开启一个线程来完成某些任务,调用新线程时正确的做法是传递整个对象的拷贝来避免并发和生存周期的问题,而不是传递对象的引用。

C++14有一个解决方案:

class C
{
private:
	string name;
public:
	void foo()
	{
		auto l1 = [thisCopy = *this] {cout << thisCopy.name << '\n'; };
	}
};

自从C++17起,你可以通过*this来显示地捕获当前对象的拷贝:

class C
{
private:
	string name;
public:
	void foo()
	{
		auto l1 = [*this] {cout << name << '\n'; };
	}
};

这里有一个完整的例子:

class Data
{
private:
	string name;
public:
	Data(const string& s) : name(s) {}

	auto startThreadWithCopyOfThis() const
	{
		// 开启并返回新线程,新线程在3秒后使用this
		using namespace std::literals; // 可以使用3s,表示3秒
		thread t([*this] {
			this_thread::sleep_for(3s);
			cout << name << "\n";
			});
		return t;
	}
};

int main()
{
	thread t;
	{
		Data d{ "c1" };
		t = d.startThreadWithCopyOfThis();
	}
	t.join();
}

7. 新属性和属性特性

从C++11起,可以指明属性。属性是允许或禁用某些警告的注解。C++17引入了新的属性,还扩展了属性的使用场景。

7.1 [[nodiscard]]属性

新属性[[nodiscard]]可以鼓励编译器在某个函数的返回值未被使用时给出警告。应该是防止返回值未被使用会导致的不当行为,可能是内存泄漏、不必要的开销、未知或出乎意料的行为。

一个很好的例子是:std::async()会在后台异步地执行一个任务并返回一个可以用来等待任务执行结束的句柄。然而,如果返回值没有被使用的话该调用将变成同步的调用,因为在启动任务的语句结束之后未被使用的返回值的析构函数会立即执行,而析构函数会阻塞等待任务运行结束。另一个例子是成员函数empty(),它的作用是检查一个对象或者容器是否为空。

class MyContainer 
{
	[[nodiscard]] bool empty() const noexcept;
};

如果你对一个不想使用被标记的[[nodiscard]]的函数的返回值,你可以吧返回值转换为void,例如:

(void)coll.empty();

注意,如果成员函数被覆盖或者隐藏时基类中的标记不会被继承:

struct B
{
	[[nodiscard]] int* foo();
};

struct D : B
{
    int* foo();
};

D d;
d.foo(); // 没有警告
7.2 [[maybe_unused]]属性

新的属性[[maybe_unused]]可以避免编译器在某个变量未被使用时发出警告。

void foo(int val, [[maybe_unused]]string msg)
{
#ifdef DEBUG
	log(msg);
#endif
}

不能对一条语句使用[[maybe_unused]],因此,不能用这个来抵消[[nodiscard]]的作用。

7.3 [[fallthrough]]属性

新的属性[[fallthrough]]可以避免编译器在switch语句中某一标签缺少break发出警告。(比较鸡肋)

void commentPlace(int place)
{
	switch(place)
	{
	case 1:
		cout << "very";
		[[fallthrough]];
	case 2:
		cout << "well";
		break;
	default:
		break;
	}
}
7.4 通用的属性扩展

自从C++17起,下列有关属性的通用特性变得可用:

  • 属性现在可以用来标记命名空间。例如,弃用一个命名空间:
namespace [[deprecated]] DraftAPI
{
	// ...
}

也可以引入新的一个枚举值作为已有枚举值的替代:

enum class City 
{
	Berlin = 0,
    NewYork = 1,
    Mumbai = 2,
    Bombay [[deprecated]] = Mumbai
};

8. 其他语言特性

C++17中一些微小的核心语言特性。

8.1 嵌套命名空间
namespace A::B::C
{
	// ...
}

// 上面代码等价于
namespace A
{
	namespace B
	{
		namespace C
		{}
	}
}
8.2 有定义的表达式求值顺序

先看一个例子,在一个字符串中替换多个子串:

string s = "I heard it even works if you don't believe";
s.replace(0,8,"").replace(s.find("even"),4,"sometimes").replace(s.find("you don't"),9,"I");

通常的假设是前8个字符被空串替换,even替换成sometimesyou don't替换成I。结果是:

it sometimes works if I believe

然而在C++17之前最后的结果并没有任何保证。因为查找子串位置的find()函数可能在需要它们的返回值之前的任意时刻调用。事实上,所有的find()调用可能在执行第一次替换之前就全部执行,因此结果为:

it even worsometimesf youIlieve

也可能是:

it sometimes workIdon't believe
it even worsometiIdon't believe

另外一个例子是,输出运算符打印几个相互依赖的值:

cout << f() << g() << h();

为了解决这种未定义的问题,C++17标准重新定义了一些运算符的求值顺序,因此这些运算符有了固定的求职顺序:

  • 对于运算
    • e1[e2]
    • e1.e2
    • e1.*e2
    • e1->*e2
    • e1 << e2
    • e1 >> e2

e1现在保证一定会在e2之前求值,因此求值顺序是从左到右的。然而,同一个函数调用中的不同参数的计算顺序仍然是未定义的。

e1.f(a1,a2,a3);

a1和a2和a3的求值顺序让人是未定义的。

  • 对于赋值运算:
    • e2 = e1
    • e2 += e1
    • e2 *= e1

e1现在保证一定会在e2之前求值。

  • new表达式

因此,自从C++17起,会保证replace()操作在find()操作之前。但是对于大多数运算符还是未知的,比如:

i = 0;
i = i++ + i;

这样的修改可能会影响现有程序的输出。例如:

void print10elems(const vector<int>& v)
{
	for (int i = 0; i < 10; ++i)
	{
		cout << "value: " << v.at(i) << '\n';
	}
}

int main()
{
	try
	{
		vector<int> vec{ 7,14,21,28 };
		print10elems(vec);
	}
	catch (const exception& e)
	{
		cerr << "EXCEPTION:" << e.what() << '\n';
	}
	catch (...)
	{
		cerr << "EXCEPTION of unknown type\n";
	}
}

C++17之前可能的结果是:

value: 7
value: 14
value: 21
value: 28
EXCEPTION: ..

C++17之后的结果,保证是:

value: 7
value: 14
value: 21
value: 28
value: EXCEPTION: ..
8.3 更宽松的用整型初始化枚举值的规则

对于一个有固定底层类型的枚举类型变量,C++17起可以用一个整型值进行列表初始化。

// 指明底层类型但无作用域枚举类型
enum MyInt : char {};
MyInt i1{42}; // C++17起OK

// 默认底层类型有作用域枚举
enum class Weekday { mon, tue, wed, thu, fri, sat, sun };
Weekday w1{0}; // C++17起OK

// 指明底层类型且有作用域枚举
enum class Weekday : char { mon, tue, wed, thu, fri, sat, sun };
Weekday w2{0}; // C++17起OK

// 没有指明底层类型也无作用域枚举类型
enum Flag { bit = 1, bit2 = 2, bit3 = 3 };
Flag f1{0}; // ERROR
8.4 修正auto类型的列表初始化

自从C++17中引入了花括号统一初始化之后,每当使用auto代替明确类型进行初始化就会出现一些和直觉不一致的结果:

int x{42};
int y{1,2,3}; // ERROR
auto a{42}; // initializer_list<int>
auto b{1,2,3}; // initializer_list<int>

这些直接使用初始化列表时不一致的行为现在已经被修复了。

int x{42};
int y{1,2,3}; // ERROR
auto a{42};  // int
auto b{1,2,3};  // ERROR

注意,这是一个破坏性的更改,可能导致许多代码的行为无法使用。

注意,当使用auto进行拷贝列表初始化时仍然是initializer_list

auto a = {42}; // initializer_list<int>
auto b = {1,2,3}; // initializer_list<int>
8.5 十六进制浮点数字面量8

C++17允许指定十六进制浮点数字面量。

#include <iomanip>

int main()
{
	initializer_list<double> values
	{
		0x1p4, // 1 * 4 ^ 2 = 16
		0xA,   // A = 10
		0xAp2, // 10 * 2 ^ 2 = 40
		5e0,   // 5 * 1.0 = 5
		0x1.4p+2,  // 1.25 * 2 ^ 5 = 5
		1e5,    // 1 * 1.0 ^ 5 = 100000
		0x1.86Ap+16, // 100000
		0xC.68p+2 // 49.625
	};
}
8.6 UTF-8字符字面量

自从C++11起,C++支持u8为前缀的UTF-8字符串字面量。然而,C++17之前,这个前缀不能用于字符字面量。C++17修复了这个问题。

auto c = u8'6';
8.7 异常声明作为类型的一部分

自从C++17之后,异常处理声明变成了函数类型的一部分。也就是说,如下的两个函数的类型不同:

void MightThrow();
void Noexcept() noexcept;

在C++17之前,这两个类型相同,就有可能将一个可能抛出异常的函数赋给一个不会抛出异常的函数指针:

void (*fp)() noexcept; 
fp = fNoexcept;
fp = MightThrow; // C++17起ERROR

但是如果将一个不会抛出异常的函数赋给一个可能抛出异常的函数指针仍然有效:

void (*fp2)(); 
fp = fNoexcept;
fp = MightThrow; 

不仅如此,在派生类重写基类的函数时,也是符合这一规则。

class Base
{
public:
	virtual void foo() noexcept;
};

class Derived : Base
{
public:
	void foo() override; // ERROR
};

使用传统的异常声明时,函数的是否抛出取决于条件为true或者false

void f1();
void f2() noexcept;
void f3() noexcept(sizeof(int)<4); 

noexcept作为类型的一部分会对泛型库产生一些影响。例如:


template<typename T>
void call(T op1, T op2)
{
	op1();
	op2();
}

void f1()
{
	cout << "f1()\n";
}

void f2() noexcept
{
	cout << "f2()\n";
}

int main()
{
	call(f1, f2); // C++17起ERROR
}
8.8 单参数static_assert

C++17起,static_assert()的错误信息的参数变为可选。

#include <type_traits>

template<typename T>
class C
{
	static_assert(is_default_constructible_v<T>); // C++17起有效
};
8.9 预处理条件__has_include

C++17扩展了预处理,增加了一个检查某个头文件是否可以被包含的宏,例如:

#if __has_include(<filesystem>)
#include <filesystem>
#define HAS_FILESYSTEM 1
#elif __has_include (<experimental/filesystem>)
#include <experimental/filesystem>
#define HAS_FILESYSTEM 1
#define FILESYSTEM_IS_EXPERIMENTAL 1
#else 
#define HAS_FILESYSTEM 0
#endif
  • 22
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值