C++语言在历史上经过了很多次的演进。最早的时候,C++语言没有模板、STL、异常等特性,之后加入这些特性形成大多数人所熟悉的C++98/03标准。在此之后,C++经过10多年又孕育出了拥有众多革命性变化的C++11标准(在标准正式发布前,被称为C++0x)。C++11包括了约140个新特性和约600个缺陷的修正。由于其变化实在太大,被很多人称为“现代C++语言”(Modern C++ Language),以和之前的经典C++语言作区分。在C++11后,标准委员会又发布了C++14和C++17标准,其内容大多是对C++11特性的一些修改和补充。
业界关于C++11/14/17新特性的书籍已有不少,网上的介绍文章更多。但是这些书籍和文章都只是对其中的一部分特性作有挑选的介绍。对于现代C++这种内容庞杂的语言改进来说,也不可能通过某一本书进行完全覆盖。开发人员在实际编程中碰到特定特性的问题,还是需要靠网络搜索来学习和解决。
本文试图从我司实际角度来阐述C++新特性所带来的影响,主要目标针对我司的存量产品嵌入式开发领域,挑选一些开发人员应当主动了解并在项目中多加使用的特性进行说明,并且尽可能的给出其他外部资源的链接,达到“授之以渔”的目的。
另一方面,现代C++语言的新特性虽然广受赞誉,但如果使用不当,也会掉入新的陷阱,产生令人头疼的问题,而这些陷阱却很少有文章提起。本文试图从个人经验的角度,多描述一下实际使用建议和注意事项。
0 目录
- 1 C++11/14/17新特性概述及编译器支持
- 2 C++11新特性(语言核心)
- 2.1 auto关键字
- 2.2 nullptr关键字和std::nullptr_t类型
- 2.3 Range-based for loops(基于范围的for循环)
- 2.4 constexpr(编译期常量类型)
- 2.5 模板别名
- 2.6 override和final成员函数
- 2.7 追踪返回类型
- 2.8 lamda表达式
- 2.9 成员函数控制:=delete和=default
- 2.10 列表初始化语法
- 2.11 Strongly-typed enums(强类型枚举)
- 2.12 右值引用、移动构造、移动赋值和完美转发
- 2.13 noexcept修饰符与noexcept操作符
- 2.14 decltype关键字
- 2.15 static_assert关键字
- 2.16 变长参数的模板
- 2.17 继承构造函数和委托构造函数
- 2.18 显式转换操作符
- 2.19 非静态数据成员的类定义内初始化
- 2.20 原生字符串字面量
- 3 C++11新特性(标准库)
- 4 C++14新特性(语言核心)
- 5 C++14新特性(标准库)
- 6 C++17新特性(语言核心)
- 6.1 Structured Binding(结构化绑定)
- 6.2 Init Statement for if/switch(if/switch语句中的初始化语句)
- 6.3 Inline Variables(内联变量)
- 6.4 Nested namespace definition(嵌入名字空间定义)
- 6.5 constexpr if-statements(静态条件语句)
- 6.6 Template Argument Deduction for Class Templates(类模板的模板参数推导)
- 6.7 Non-type Template Parameters with auto(auto关键字用于非类型模板参数)
- 7 C++17新特性(标准库)
1 C++11/14/17新特性概述及编译器支持
首先,哪些编译器支持哪些新特性?这个问题可不好回答。我们没有办法笼统地说“xxx版本的编译器支持C++11”或“xxx版本的编译器支持到C++14”。因为,每个编译器版本都是选择对部分特性的支持,甚至对单个特性,也可能只是支持其一部分场景。比如,常用的gcc 4.9版本,不光支持大部分的C++11特性,也支持一部分C++14特性,甚至还提前支持了个别C++17和C++20特性。因此要深入了解某特性支持状况,需要查询相关资料。
这里给出一个非常全面的主流编译器支持C++特性现状列表,地址如下:
https://en.cppreference.com/w/cpp/compiler_support
这个页面有很多好处:每个特性都给出了标准建议文档的链接,有些还包括在线的特性说明页面链接,可以方便的跳转到相应内容来进一步的了解语言特性。建议大家收藏这个页面供日后查询。
下文在介绍各个特性时,努力把更常用的特性放在前面。但是,由于各个项目的业务背景差别,在应用语言特性的需求上肯定大不一样,因此大家可以挑选自己感兴趣的内容重点阅读。
本文分开介绍C++核心语言特性和标准库特性,但标准库(STL)的很多改进同样是非常重要和常用的,不应当被忽视。
2 C++11新特性(语言核心)
2.1 auto关键字
在C++11之前,auto关键字用来指定存储期。在新标准中,它的功能变为类型推断。auto现在成了一个类型的占位符,通知编译器去根据初始化代码推断所声明变量的真实类型。各种作用域内声明变量都可以用到它。例如,名字空间中,程序块中,或是for循环的初始化语句中。
auto i = 42; // i is an int
auto l = 42LL; // l is an long long
auto p = new foo(); // p is a foo*
使用auto通常意味着更短的代码(除非你所用类型是int,它会比auto少一个字母)。试想一下当你遍历STL容器时需要声明的那些迭代器(iterator)。现在不需要去声明那些typedef就可以得到简洁的代码了。
std::map<std::string, std::vector<int>> map;
for(auto it = begin(map); it != end(map); ++it)
{
}
需要注意的是,C++11中的auto不能用来声明函数的返回值。但如果函数有一个尾随的返回类型时,auto是可以出现在函数声明中返回值位置。这种情况下,auto并不是告诉编译器去推断返回类型,而是指引编译器去函数的末端寻找返回值类型。在下面这个例子中,函数的返回值类型就是operator+操作符作用在T1、T2类型变量上的返回值类型。(C++14改进了这些限制)
template <typename T1, typename T2>
auto compose(T1 t1, T2 t2) -> decltype(t1 + t2)
{
return t1+t2;
}
auto v = compose(2, 3.14); // v's type is double
auto特性可以在很大程度上简化代码,节约编程人员的体力,而且不容易出现错误,因此应当大力提倡使用。
需要注意的有两点:第一是如果想要声明一个引用类型,必须用auto&,同理,对于指针、const、volatile等修饰符也可以用在auto上进一步限定类型。第二是C++11不允许直接用auto声明函数返回值,但C++14做了改进,可阅读相关资料了解。
相关链接:https://zh.cppreference.com/w/cpp/language/auto
2.2 nullptr关键字和std::nullptr_t类型
以前都是用0来表示空指针的,但由于0可以被隐式类型转换为整型,这就会存在一些问题。关键字nullptr是std::nullptr_t类型的值,用来指代空指针。nullptr和任何指针类型以及类成员指针类型的空值之间可以发生隐式类型转换,同样也可以隐式转换为bool型(取值为false)。但是不存在到整型的隐式类型转换。
void F(int a){
cout << a << endl;
}
void F(int *p){
assert(p != NULL);
cout << p << endl;
}
int main(){
int *p = nullptr;
int *q = NULL;
bool equal = (p == q); // equal的值为true,说明p和q都是空指针
int a = nullptr; // 编译失败,nullptr不能转型为int
F(0); // 在C++98中编译失败,有二义性;在C++11中调用F(int)
F(nullptr);
return 0;
}
nullptr可以有效地减少二义性,防止错误,应当大力鼓励使用。需要注意的是,由于以前的NULL仍然是有效的,而且有些历史代码中存在把NULL当作整型数字0的不良习惯,应当要求所有新代码都只用nullptr,彻底废除NULL,不要两者混用。
相关链接:https://zh.cppreference.com/w/cpp/language/nullptr
2.3 Range-based for loops(基于范围的for循环)
为了在遍历容器时支持“foreach”用法,C++11扩展了for语句的语法。用这个新的写法,可以遍历C类型的数组、初始化列表以及任何重载了非成员的begin()和end()函数的类型。
如果你只是想对集合或数组的每个元素做一些操作,而不关心下标、迭代器位置或者元素个数,那么这种foreach的for循环将会非常有用。
std::map<std::string, std::vector<int>> map;
std::vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);
map["one"] = v;
for (const auto& kvp : map)
{
std::cout << kvp.first << std::endl;
for (auto v : kvp.second)
{
std::cout << v << std::endl;
}
}
int arr[] = {1, 2, 3, 4, 5};
for(int& e : arr)
{
e = e * e;
}
基于范围的for循环已经在其他语言中被广为接受,相比于原来手工递增/递减循环变量的做法,可以减少越界、溢出、意外修改循环变量等很多问题,应该大力推广替代老式的for循环。需要注意的是,和以前一样,在循环内部严禁修改正在被遍历的容器大小(比如在循环内删除元素)。另外,对于自定义的容器类,都应该显式定义begin和end函数让其可以被用于新式的for循环。
由于新式的for循环内部也是会给声明的变量赋值的,因此某些性能方面的考虑仍然值得注意。比如,如果不希望拷贝容器内部的元素,那么在声明工作变量时应当用auto&而不是auto。
相关链接:https://zh.cppreference.com/w/cpp/language/range-for
2.4 constexpr(编译期常量类型)
C++11中对编译时期常量的回答是constexpr,及常量表达式(const expression),例如:
即在函数表达式前面加上constexpr关键字即可。有了常量表达式这样的声明,编译器可以在编译时期对GetConst()表达式进行值计算(evaluation),从而将其视为一个编译时期的常量。在C++11中,常量表达式实际上可以左右的实体不限于函数,还可以作用于数据声明,以及类的构造函数等。
constexpr int GetConst() { return 1; }
通常我们可以在函数返回类型前加入关键字constexpr来使其成为常量表达式函数。不过并非所有的函数都有资格成为常量表达式函数。事实上,常量表达式函数的要求非常严格,总结来说,大概有以下几点:
函数体只有单一的return返回语句。
函数必须有返回值(不能是void函数)。
在使用前必须已有定义。
return返回语句表达式中不能使用非常量表达式的函数、全局数据,且必须是一个常量表达式。(C++14放宽了这些要求)
以前的const修饰符并不能保证产生编译期常量,因此某些用法,比如用const常量声明数组长度不能保证可行。相比之下,constexpr类型具有更强的编译期检查,可以在编译阶段就发现常量能否在编译阶段确定。应当要求所有新代码中的编译期常量都声明为constexpr。
相关链接:https://zh.cppreference.com/w/cpp/language/constexpr
2.5 模板别名
在C++中,使用typedef为类型定义别名,比如:
typedef int myint;
typedef std::vector<std::string> strvec;
在C++11中,定义别名已经不再是typedef的专属能力,使用using同样也可以定义类型的别名,而且从语言能力上看,using丝毫不比typedef逊色。
#include <iostream>
#include <type_traits>
using namespace std;
using uint = unsigned int;
typedef unsigned int UINT;
using sint = int;
int main() {
cout << is_same<uint, UINT>::value << endl; // 1
}
在上面的例子中,使用了C++11标准库的is_same模板类来帮助我们判断2个类型是否一致。在使用模板编程的时候,using的语法甚至比typedef更加灵活,比如:
template <typename T> using MapString = std::map<T, char*>;
MapString<int> numberedString;
在这里,我们“模板式”的使用了using关键字,将std::map<T, char*>
定义为一个MapString类型,之后我们还可以使用类型参数对MapString进行类型的实例化,而使用typedef将无法达到这样的效果。
从功能和可读性上看,using模板别名都比原来的typedef要更好,因此新写的代码都应使用using来取代typedef定义。
相关链接:https://zh.cppreference.com/w/cpp/language/type_alias
2.6 override和final成员函数
以前C++中虚函数没有一个强制的机制来标识虚函数会在派生类里被改写。vitual关键字是可选的,这使得阅读代码变得很费劲。因为可能需要追溯到继承体系的源头才能确定某个方法是否是虚函数。为了增加可读性,可以在派生类里也写上virtual关键字。但即使这样,仍然会产生一些微妙的错误。看下面这个例子:
class B
{
public:
virtual void f(short) {std::cout << "B::f" << std::endl;}
};
class D : public B
{
public:
virtual void f(int) {std::cout << "D::f" << std::endl;}
};
D::f按理应当重写B::f。然而二者的声明是不同的,一个参数是short,另一个是int。因此D::f只是拥有同样名字的另一个函数(重载)而不是重写。当你通过B类型的指针调用f()可能会期望打印出D::f,但实际上则会打出 B::f 。
另一个很微妙的错误情况:参数相同,但是基类的函数是const的,派生类的函数却不是。
class B
{
public:
virtual void f(int) const {std::cout << "B::f " << std::endl;}
};
class D : public B
{
public:
virtual void f(int) {std::cout << "D::f" << std::endl;}
};
同样,这两个函数是重载而不是重写,所以你通过B类型指针调用f()将打印B::f,而不是D::f。
幸运的是,现在有一种方式能描述你的意图。新标准加入了两个新的标识符(不是关键字):
- override,表示函数应当重写基类中的虚函数。
- final,表示派生类不应当重写这个虚函数。
第一个的例子如下:
class B
{
public:
virtual void f(short) {std::cout << "B::f" << std::endl;}
};
class D : public B
{
public:
virtual void f(int) override {std::cout << "D::f" << std::endl;}
};
现在这将触发一个编译错误(后面那个例子,如果也写上override标识,会得到相同的错误提示):
‘D::f’ : method with override specifier ‘override’ did not override any base class methods
另一方面,如果你希望函数不要再被派生类进一步重写,你可以把它标识为final。可以在基类或任何派生类中使用final。在派生类中,可以同时使用override和final标识。
class B
{
public:
virtual void f(int) {std::cout << "B::f" << std::endl;}
};
class D : public B
{
public:
virtual void f(int) override final {std::cout << "D::f" << std::endl;}
};
class F : public D
{
public:
virtual void f(int) override {std::cout << "F::f" << std::endl;} // Error
};
被标记成final的函数将不能再被F::f重写。
这两个关键字可以让代码的可读性更好,而且可以有效地防止错误,应当作为强制要求。
相关链接:https://zh.cppreference.com/w/cpp/language/override
https://zh.cppreference.com/w/cpp/language/final
2.7 追踪返回类型
C++11引入了一种新语法——追踪返回类型,来处理函数模板的返回类型依赖于实际的入口参数类型的场景,例如:
template <typename T1, typename T2>
auto Sum(T1 & t1, T2 & t2) -> decltype(t1 + t2) {
return t1 + t2;
}
如上面的写法所示,我们把函数的返回值移至参数声明之后,复合符号->decltype(t1 + t2)被称为追踪返回类型。而原本函数返回值的位置由auto关键字占据。这样我们就可以让编译器来推导Sum函数模板的返回类型了。而auto占位符和->return_type也就是构成追踪返回类型函数的2个基本元素,追踪返回类型的函数和普通函数的声明最大的区别在于返回类型的后置。
追踪返回类型的另一个优势是简化函数的定义,提高代码的可读性,这种情况常见于函数指针中。
#include <type_traits>
#include <iostream>
using namespace std;
int (*(*pf())())() {
return nullptr;
}
// auto (*)() -> int(*) () 一个返回函数指针的函数(假设为a的函数)
// auto pf1() -> auto (*)() -> int (*)() 一个返回a函数的指针的函数
auto pf1() -> auto (*)() -> int (*)() {
return nullptr;
}
int main() {
cout << is_same<decltype(pf), decltype < pf1>::value << endl; // 1
}
除此之外,追踪返回类型也被广泛的应用在转发函数中,例如:
#include <iostream>
using namespace std;
double foo(int a) {
return (double) a + 0.1;
}
int foo(double b) {
return (int) b;
}
template<class T>
auto Forward(T t) -> decltype(foo(t)) {
return foo(t);
}
int main() {
cout << Forward(2) << endl; // 2.1
cout << Forward(0.5) << endl; // 0
}
如果孤立的看这个特性,其用处貌似不大,一般来说代码中很少遇到返回值类型非常复杂的函数定义,即使有,通常也用typedef简化过了。因此追踪返回类型这种新语法往往和其他特性联合使用才有价值,比如lamda表达式。
相关链接:https://zh.cppreference.com/w/cpp/language/function
2.8 lamda表达式
lambda表达式可以用于创建并定义匿名的函数对象,以简化编程工作。Lambda的语法如下:
[函数对象参数](操作符重载函数参数)->返回值类型{函数体}
vector<int> iv{5, 4, 3, 2, 1};
int a = 2, b = 1;
for_each(iv.begin(), iv.end(), [b](int &x){cout << (x + b) << endl;}); // (1)
for_each(iv.begin(), iv.end(), [=](int &x){x *= (a + b);}); // (2)
for_each(iv.begin(), iv.end(), [=](int &x)->int{return x * (a + b);});// (3)
[]内的参数指的是Lambda表达式可以取得的变量。(1)函数中的b就是指函数可以得到在Lambda表达式外的变量,如果在[]中传入=的话,即是可以取得所有的外部变量,如(2)和(3)Lambda表达式。
()内的参数是每次调用函数时传入的参数。
->后加上的是Lambda表达式返回值的类型,如(3)中返回了一个int类型的变量。
lamda表达式通常只有和能接收函数对象的算法接口(如STL中的algorithm)配合使用才有价值。而就我司的历史代码来看,开发人员普遍未形成使用函数对象的习惯,因此lamda表达式的推广也会受到已有编程习惯的限制。
lamda表达式经常需要和其他特性配合使用。比如auto,decltype,追踪返回类型等等。有的人可能会误解lamda表达式的类型是函数指针,其实不是,它是一个匿名的函数对象。当写一个新的lamda表达式时,即使其参数和返回值与其它函数一样,也会声明一个新的类型。因此,一些接口应当为接收lamda表达式做好设计(因为lamda表达式没有统一类型,因此要定义接口参数类型为模板)。
[]捕获列表对于未接触过函数对象的人来说需要认真理解。如果是按对象值捕获会产生隐式的拷贝构造过程,对于大对象有堆栈溢出风险;如果是按引用捕获必须要保证原对象的生存周期长于lamda表达式的生命周期,否则有访问野指针的风险。
相关链接:https://zh.cppreference.com/w/cpp/language/lambda
2.9 成员函数控制:=delete和=default
在C++中声明自定义的类,编译器会默认帮助程序员生成一些他们未自定义的成员函数。这样的函数版本被称为“默认函数”,这包括了以下一些自定义类型的成员函数:
构造函数
拷贝构造函数
拷贝赋值函数(operator=)
移动构造函数
移动拷贝函数
析构函数
此外C++编译器还会为以下这些自定义类型提供全局默认操作符函数:
operator
operator &
operator &&
operator *
operator ->
operator ->*
operator new
operator delete
在C++语言规则中,一旦程序员实现了这些函数的自定义版本,则编译器不会再为该类自动生成默认版本。不过一旦声明了自定义版本的构造函数,则有可能导致我们定义的类型不再是POD的。变为非POD类型带来一系列负面影响有时是程序员所不希望的,很多时候,这意味着编译器失去了优化这些简单的数据类型的可能,因此客观上我们需要一些方式来使得这一的简单类型“恢复”POD的特质。
在C++11中,标准通过提供新的机制来控制默认版本函数的生成。这个新机制重用了default关键字。程序员可以在默认函数定义或者声明时加上“=default”,从而显式的指示编译器生成该函数的默认版本。
#include <type_traits>
#include <iostream>
using namespace std;
class TwoCstor {
public:
// 提供了带参数版本的构造函数,再指示编译器
// 提供默认版本,则本自定义类型依然是POD类型
TwoCstor() = default;
TwoCstor(int i) : data(i) { }
private:
int data;
};
int main() {
cout << is_pod<TwoCstor>::value << endl; // 1
}
另一方面,程序员在一些情况下希望能限制一些默认函数的生成。最典型的,类的编写者有时候需要禁止使用者使用拷贝构造函数,在C++98标准中,我们的做法是将拷贝构造函数声明为private的成员,并且不提供函数实现。这样一来,一旦有人试图(或者无意识)使用拷贝构造函数,编译器就会报错。
在C++11中,标准中给出了更简单的方法,即在函数的定义或者声明加上“=delete”。“=delete”会指示编译器不生成函数的缺省版本,例如:
#include <type_traits>
#include <iostream>
using namespace std;
class NoCopyCstor {
public:
NoCopyCstor() = default;
// 使用 "=delete" 同样可以有效阻止用户
// 错用拷贝构造函数
NoCopyCstor(const NoCopyCstor &) = delete;
};
int main() {
NoCopyCstor a;
NoCopyCstor b(a); // 无法通过编译
}
“=delete”修饰符在很多类中都能派上用场,因为现有代码中不少类并没有为拷贝构造和赋值操作做好设计,显式的防止这种操作可以避免出乎意料的运行式错误,对于清除静态检查工具(如PC-Lint)的告警也有帮助。“=default”相对来说用处就没那么大了,通常一个类如果有需要初始化的成员,也不允许使用默认构造函数。
相关链接:https://zh.cppreference.com/w/cpp/language/function
2.10 列表初始化语法
C++11中,集合(列表)的初始化已经成为C++语言的一个基本功能,这种初始化的方法被称为“初始化列表”(initializer list),例如:
#include <vector>
#include <map>
using namespace std;
int a[] = {1, 3, 5};
int b[]{2, 4, 6};
vector<int> c{1, 3, 5};
map<int, float> d =
{{1, 1.0f},
{2, 2.0f},
{5, 3.2f}};
这样一来,自动变量和全局变量的初始化在C++11中被丰富了,程序员可以使用以下几种形式完成初始化的工作:
等号“=”加上赋值表达式(assignment-expression),比如 int a = 3 + 4;
等号“=”加上花括号式的初始化列表,比如 int a = {3 + 4};
圆括号式的表达式列表(expression-list),比如 int a (3 + 4);
花括号式的初始化列表,比如 int a {3 + 4};
后面2种形式也可以用于获取堆内存new操作符中,比如:
int* i = new int(1);
double* d = new double{1.2f};
自定义的类如果要使用初始化列表初始化,需要#include <initializer_list>头文件,并且声明一个以initializer_list模板类为参数的构造函数:
#include <vector>
#include <map>
using namespace std;
enum Gender {
boy, girl
};
class People {
public:
People(initializer_list<pair<string, Gender>> l) { // initializer_list的构造函数
auto i = l.begin();
for (; i != l.end(); ++i)
data.push_back(*i);
}
private:
vector<pair<string, Gender>> data;
};
People ship2012 = {{"Garfield", boy}, {"HelloKitty", girl}};
同样的,函数的参数列表也可以使用初始化列表:
#include <initializer_list>
using namespace std;
void Fun(initializer_list<int> iv) { }
int main() {
Fun({1, 2});
Fun({}); // 空列表
}
列表初始化语法对于STL容器和自定义类作用很大(尤其是一些测试代码),但对于数组等类型就没必要强制使用了。总的来说,这是一个能在部分场景下大大提高效率的值得推广的特性。
相关链接:https://zh.cppreference.com/w/cpp/language/list_initialization
2.11 Strongly-typed enums(强类型枚举)
传统的C++枚举类型存在一些缺陷:它们会将枚举常量暴露在外层作用域中(这可能导致名字冲突,如果同一个作用域中存在两个不同的枚举类型,但是具有相同的枚举常量就会冲突),而且它们会被隐式转换为整形,无法拥有特定的用户定义类型。
在C++11中通过引入了一个称为强类型枚举的新类型,修正了这种情况。强类型枚举由关键字enum class标识。它不会将枚举常量暴露到外层作用域中,也不会隐式转换为整形,并且拥有用户指定的特定类型(传统枚举也增加了这个性质)。
enum class Options {None, One, All};
Options o = Options::All;
强类型枚举进一步强化了编译器类型检查,值得推广。不过,以前有很多程序员喜欢利用枚举和整型之间的转换来完成一些编码技巧(比如用枚举值作为数组下标),这些做法现在行不通了。把枚举强制转换成整型不是一个好习惯,因此以前一些代码设计的思路可能需要转变。
相关链接:https://zh.cppreference.com/w/cpp/language/enum
2.12 右值引用、移动构造、移动赋值和完美转发
C++11加入了右值引用(rvalue reference)的概念(用&&标识),用来区分对左值和右值的引用。左值就是一个有名字的对象,而右值则是一个无名对象(临时对象)。move语义允许修改右值(以前右值被看作是不可修改的,等同于const T&类型)。
C++的class或者struct以前都有一些隐含的成员函数:默认构造函数(仅当没有显式定义任何其他构造函数时才存在),拷贝构造函数,析构函数还有拷贝赋值操作符。拷贝构造函数和拷贝赋值操作符提供bit-wise的拷贝(浅拷贝),也就是逐个bit拷贝对象。也就是说,如果你有一个类包含指向其他对象的指针,拷贝时只会拷贝指针的值而不会管指向的对象。在某些情况下这种做法是没问题的,但在很多情况下,实际上你需要的是深拷贝,也就是说你希望拷贝指针所指向的对象。而不是拷贝指针的值。这种情况下,你需要显式地提供拷贝构造函数与拷贝赋值操作符来进行深拷贝。
如果你用来初始化或拷贝的源对象是个右值(临时对象)会怎么样呢?你仍然需要拷贝它的值,但随后很快右值就会被释放。这意味着产生了额外的操作开销,包括原本并不需要的空间分配以及内存拷贝。
现在说说move constructor和move assignment operator。这两个函数接收T&&类型的参数,也就是一个右值。在这种情况下,它们可以修改右值对象,例如“偷走”它们内部指针所指向的对象。举个例子,一个容器的实现(例如vector或者queue)可能包含一个指向元素数组的指针。当用一个临时对象初始化一个对象时,我们不需要分配另一个数组,从临时对象中把值复制过来,然后在临时对象析构时释放它的内存。我们只需要将指向数组内存的指针值复制过来,由此节约了一次内存分配,一次元数组的复制以及后来的内存释放。
以下代码实现了一个简易的buffer。这个buffer有一个成员记录buffer名称(为了便于以下的说明),一个指针(封装在unique_ptr中)指向元素为T类型的数组,还有一个记录数组长度的变量。
template <typename T>
class Buffer
{
std::string _name;
size_t _size;
std::unique_ptr<T[]> _buffer;
public:
// default constructor
Buffer() : _size(16), _buffer(new T[16]){}
// constructor
Buffer(const std::string& name, size_t size) : _name(name), _size(size), _buffer(new T[size]){}
// copy constructor
Buffer(const Buffer& copy) : _name(copy._name), _size(copy._size), _buffer(new T[copy._size])
{
T* source = copy._buffer.get();
T* dest = _buffer.get();
std::copy(source, source + copy._size, dest);
}
// copy assignment operator
Buffer& operator=(const Buffer& copy)
{
if(this != 0)
{
_name = copy._name;
if(_size != copy._size)
{
_buffer = nullptr;
_size = copy._size;
_buffer = _size > 0 ? new T[_size] : nullptr;
}
T* source = copy._buffer.get();
T* dest = _buffer.get();
std::copy(source, source + copy._size, dest);
}
return *this;
}
// move constructor
Buffer(Buffer&& temp) : _name(std::move(temp._name)), _size(temp._size),
_buffer(std::move(temp._buffer))
{
temp._buffer = nullptr;
temp._size = 0;
}
// move assignment operator
Buffer& operator=(Buffer&& temp)
{
assert(this != &temp); // assert if this is not a temporary
_buffer = nullptr;
_size = temp._size;
_buffer = std::move(temp._buffer);
_name = std::move(temp._name);
temp._buffer = nullptr;
temp._size = 0;
return *this;
}
};
template <typename T>
Buffer<T> getBuffer(const std::string& name)
{
Buffer<T> b(name, 128);
return b;
}
int main()
{
Buffer<int> b1;
Buffer<int> b2("buf2", 64);
Buffer<int> b3 = b2;
Buffer<int> b4 = getBuffer<int>("buf4");
b1 = getBuffer<int>("buf5");
return 0;
}
默认的copy constructor以及copy assignment operator大家应该很熟悉了。C++11中新增的是move constructor以及move assignment operator,这两个函数根据上文所描述的move语义实现。如果你运行这段代码,你就会发现b4构造时,move constructor会被调用。同样,对b1赋值时,move assignment operator会被调用。原因就在于getBuffer()的返回值是一个临时对象——也就是右值。
你也许注意到了,move constuctor中当我们初始化变量name和指向buffer的指针时,我们使用了std::move。name实际上是一个string,std::string实现了move语义。std::unique_ptr也一样。但是如果我们写_name(temp._name),那么copy constructor将会被调用。不过对于_buffer来说不能这么写,因为std::unique_ptr没有copy constructor。但为什么std::string的move constructor此时没有被调到呢?这是因为虽然我们使用一个右值调用了Buffer的move constructor,但在这个构造函数内,它实际上是个左值。为什么?因为它是有名字的——“temp”。一个有名字的对象就是左值。为了再把它变为右值(以便调用move constructor)必须使用std::move。这个函数仅仅是把一个左值引用变为一个右值引用。
完美转发(perfect forwarding),指的是在函数模板中,完全依照模板的参数的类型,将参数传递给函数模板中调用的另外一个函数,来看一个例子(C++11中,用于完美转发的函数名为forward):
#include <iostream>
using namespace std;
void RunCode(int &&m) { cout << "rvalue ref" << endl; }
void RunCode(int &m) { cout << "lvalue ref" << endl; }
void RunCode(const int &&m) { cout << "const rvalue ref" << endl; }
void RunCode(const int &m) { cout << "const lvalue ref" << endl; }
template<typename T>
void PerfectForward(T &&t) { RunCode(forward<T>(t)); }
int main() {
int a;
int b;
const int c = 1;
const int d = 0;
PerfectForward(a); // lvalue ref
PerfectForward(move(b)); // rvalue ref
PerfectForward(c); // const lvalue ref
PerfectForward(move(d)); // const rvalue ref
}
右值引用对于简化代码并无好处(事实上它让代码更复杂了),其主要优势在于性能。但是,对于不太熟悉C++规则的开发人员来说,自己的代码是否能正确调用到移动构造函数不是件容易检查的事,因为不管是否使用右值引用,都不会出现编译和运行时错误,只有通过性能实测才能检验结果。右值引用也带来了一些新的陷阱,比如通过std::move来强行把左值转成右值会导致左值失效,如果后续继续使用这个左值将带来难以预测的后果。另一方面,自定义移动构造函数中如果忘了把传入的右值置为无效(如空指针),将导致析构函数出现运行时错误。结合标准库使用时,右值引用有更多隐含的注意事项,比如std::vector会检查移动构造函数是否带有noexcept修饰符,如果没有则在迁移容器元素时不会使用移动构造,使得性能无法提升。
鉴于上面这些难以觉察的陷阱,建议在开发时,对于性能要求并非特别高的场景,慎用右值引用。另一方面,必须给开发人员做好培训让他们了解清楚右值引用的各种注意事项。对于移动构造和移动赋值函数,应当声明为noexcept。
完美转发需要利用右值引用特性,其语法原理相当的晦涩和不直观,而且需要用到的场景也很有限。
相关链接:https://zh.cppreference.com/w/cpp/language/reference
2.13 noexcept修饰符与noexcept操作符
noexcept形如起名,表示其修饰的函数不会抛出异常(在C++11中如果noexcept修饰的函数抛出了异常,编译器可以选择直接调用std::terminate()函数来终止程序的运行),有2种语法形式:
一种就是简单的在函数声明后加上noexcept关键字,比如:
void excpt_func() noexcept;
另外一种则可以接受一个常量表达式(结果会被转换成一个bool类型的值,true表示不会抛出异常,反之有可能抛出异常)作为参数,如下所示:
void excpt_func() noexcept (常量表达式);
而noexcept作为一个操作符时,通常可以用于模板。比如:
template <class T>
void fun() noexcept(noexcept(T())) {}
以前的C++代码中,异常特性就很少被使用,因此noexcept需要被用到的场景也很少。但是有一个地方强烈建议使用:自定义类型的移动构造函数和移动赋值操作符。因为部分STL容器会判断:如果元素的移动构造函数和移动赋值操作符并非noexcept,将不会调用,而是继续用老的复制对象方式,导致性能上的损失。
相关链接:https://zh.cppreference.com/w/cpp/language/noexcept_spec
2.14 decltype关键字
decltype实际上有点像auto的反函数,auto可以让你声明一个变量,而decltype则可以从一个变量或表达式中得到类型。
int x = 3;
decltype(x) y = x;
一般的代码很少需要用到decltype,但是也有不得不用的场景:若要获取一个lamda表达式的类型,由于其类型不存在名字,唯一的方法只能是用decltype。另外在一些模板场景下也有用处。
相关链接:https://zh.cppreference.com/w/cpp/language/decltype
2.15 static_assert关键字
static_assert提供一个编译时的断言检查。如果断言为真,什么也不会发生。如果断言为假,编译器会打印一个特殊的错误信息。
相对于以前的运行时检查assert,static_assert更不容易被误用,也没有什么安全风险(因为运行时什么也不会干)。建议所有在编译期能够检查出来的错误都用static_assert来检查。
相关链接:https://zh.cppreference.com/w/cpp/language/static_assert
2.16 变长参数的模板
以前的C/C++允许函数参数为变长,但是可选参数都是没有类型检查的,这导致了一大类运行时问题——比如把自定义结构体传给printf打印。现在,C++允许模板参数也为变长,和函数不同的是,每个模板参数都是有类型检查的。
一个使用变长参数模板的例子是Print函数,在C语言中printf可以传入多个参数,在C++11中,我们可以用变长参数模板实现更简洁的Print:
template<typename head, typename... tail>
void Print(Head head, typename... tail) {
cout << head << endl;
Print(tail...);
}
变长参数的模板在普通场景下较少需要用到。对于打印输出来说,更好的做法是彻底弃用printf这类函数而改用std::ostream。另外,对于二进制文件大小敏感的场景,需要注意变长参数的模板会带来模板膨胀问题——任一个参数不同都会生成一个新的函数/类型实例。
相关链接:https://zh.cppreference.com/w/cpp/language/parameter_pack
2.17 继承构造函数和委托构造函数
继承构造函数允许子类声明一个和父类一样的构造函数;委托构造函数允许不同参数类型的构造函数之间进行复用。这两个特性需要被使用的场景并不多见,以前程序员普遍习惯了通过父类构造函数显式调用和缺省参数来达到同样的效果,很难说新特性写法的可读性是否更好。
相关链接:https://zh.cppreference.com/w/cpp/language/initializer_list
2.18 显式转换操作符
显式转换操作符限制自定义的类型转换操作符不能用于隐式转换,通常情况下应用场景有限。
相关链接:https://zh.cppreference.com/w/cpp/language/explicit
2.19 非静态数据成员的类定义内初始化
C++11允许直接在类定义内来初始化成员变量。
class S
{
int n; // 非静态数据成员
int& r; // 引用类型的非静态数据成员
int a[10] = {1, 2}; // 带初始化器的非静态数据成员 (C++11)
std::string s, *ps; // 二个非静态数据成员
struct NestedS {
std::string s;
} d5, *d6; // 二个嵌套类型的非静态数据成员
char bit : 2; // 2 位的位域
};
相比起原来只能在构造函数中初始化的方法,直接在类定义中给出初始化值的方法有着更好的可读性,值得推广。
相关链接:https://zh.cppreference.com/w/cpp/language/data_members
2.20 原生字符串字面量
原生字符串字面量(raw string literal)使得用户书写的字符串“所见即所得”,不需要如’\t’、’\n’等控制字符来调整字符串中的格式。C++11中引入了原生字符串字面量的支持,声明相当简洁,程序员只需要在字符串前加入前缀,即字母R,并在引号中使用括号左右标识,就可以声明该字符串字面量为原生字符串了。
#include <iostream>
using namespace std;
int main() {
cout << R"(hello,\n
world)" << endl;
return 0;
}
// 输出内容:
// hello,
// world
而对于Unicode的字符串,也可以通过相同的方式声明。声明UTF-8、UTF-16、UTF-32的原生字符串字面量,将其前缀分别设为u8R、uR、UR就可以了。不过有一点需要注意,使用了原生字符串的话,转义字符就不能再使用了,这会给想使用\u或者\U的方式写Unicode字符的程序员带来一定影响。
此外,原生字符串字面量也像C的字符串字面量一样遵从连接规则。
原生字符串在一些需要大段字符串常量的场景下很有用,比如当测试代码中要写一个很长的JSON字符串,用原生字符串就会比原来方便很多。
相关链接:https://en.cppreference.com/w/cpp/language/string_literal
3 C++11新特性(标准库)
3.1 unordered_set和unordered_map
C++11中出现了两种新的关联容器:unordered_set和unordered_map,其内部实现与set和map大有不同,set和map内部实现是基于RB-Tree,而unordered_set和unordered_map内部实现是基于哈希表(hashtable),unordered_set和unordered_map内部实现的公共接口大致相同。
unordered_set和unordered_map是基于哈希表,因此要了解unordered_set/unordered_map,就必须了解哈希表的机制。哈希表是根据关键码值而进行直接访问的数据结构,通过相应的哈希函数(也称散列函数)处理关键字得到相应的关键码值,关键码值对应着一个特定位置,用该位置来存取相应的信息,这样就能以较快的速度获取关键字的信息。面对哈希冲突时,unordered_set/unordered_map内部解决冲突采用的是——链地址法,当用冲突发生时把具有同一关键码的数据组成一个链表。
在一个unordered_set/unordered_map内部,元素不会按任何顺序排序,而是通过元素值的hash值将元素分组放置到各个槽(Bucker,也可以译为“桶”),这样就能通过元素值快速访问各个对应的元素(均摊耗时为O(1))。
#include <iostream>
#include <string>
#include <unordered_set>
int main ()
{
std::unordered_set<std::string> myset = {"red", "green", "blue"};
std::string input;
std::cout << "color?";
getline(std::cin,input);
std::unordered_set<std::string>::const_iterator got = myset.find(input);
if (got == myset.end())
std::cout << "not found in myset";
else
std::cout << *got << " is in myset";
std::cout << std::endl;
return 0;
}
C++标准库中没有提供hash表容器一直是一个被广为诟病的问题。hash表在当今的软件系统中有着非常广泛的用途,很多项目组都需要它来达到最好的查找性能。过去,大多数程序员通过set、map来达成类似的效果,但实际上它们的查找性能和hash表相比有着显著的差距。从C++11开始,hash表正式进入了标准库,各个项目也应当大力推广使用。
虽然unordered_set和unordered_map具有优秀的性能,但是对于用户自定义类型作为key的场景,如何实现恰当的hash函数是个不可忽视的技术活。对于set/map这种二叉树结构来说,用户自定义类型只要按固定范式来实现operator<就可以了。但hash函数可远不像operator<这么好实现。有些程序员觉得只要把所有成员的hash值加起来就可以了,这是种幼稚的实现,这种方法产生的hash容器性能会很差。
关于hash函数如何实现的讨论,可以参考这个页面:https://stackoverflow.com/questions/17016175/c-unordered-map-using-a-custom-class-type-as-the-key
鉴于hash函数的实现太过于考验程序员的功力,建议各项目组在使用hash表时,由项目统一提供辅助hash函数工具,不要让每个程序员自己去实现。
相关链接:https://zh.cppreference.com/w/cpp/container/unordered_set
https://zh.cppreference.com/w/cpp/container/unordered_map
3.2 std::tuple
类模板std::tuple是固定大小的异类值汇集,它是std::pair的推广。
#include <tuple>
#include <iostream>
#include <string>
#include <stdexcept>
std::tuple<double, char, std::string> get_student(int id)
{
if (id == 0) return std::make_tuple(3.8, 'A', "Lisa Simpson");
if (id == 1) return std::make_tuple(2.9, 'C', "Milhouse Van Houten");
if (id == 2) return std::make_tuple(1.7, 'D', "Ralph Wiggum");
throw std::invalid_argument("id");
}
int main()
{
auto student0 = get_student(0);
std::cout << "ID: 0, "
<< "GPA: " << std::get<0>(student0) << ", "
<< "grade: " << std::get<1>(student0) << ", "
<< "name: " << std::get<2>(student0) << '\n';
double gpa1;
char grade1;
std::string name1;
std::tie(gpa1, grade1, name1) = get_student(1);
std::cout << "ID: 1, "
<< "GPA: " << gpa1 << ", "
<< "grade: " << grade1 << ", "
<< "name: " << name1 << '\n';
}
在没有std::tuple时,要表示一个聚合就只能自定义一个结构体(或类)。std::tuple提供了另一种选择,可以在不额外定义类型的情况下表示任意几种类型的聚合。当然,在什么场景下用哪种方案更好还需要程序员自己决定。
相关链接:https://zh.cppreference.com/w/cpp/utility/tuple
3.3 std::tie
std::tie通常结合std::pair或std::tuple来使用。以前,C++一个函数要返回多个值,或者将多个值一对一的赋值/比较都是很麻烦的事,std::tie可以很有效的简化代码。
#include <iostream>
#include <string>
#include <set>
#include <tuple>
struct S {
int n;
std::string s;
float d;
bool operator<(const S& rhs) const
{
// 比较 n 与 rhs.n,
// 然后为 s 与 rhs.s,
// 然后为 d 与 rhs.d
return std::tie(n, s, d) < std::tie(rhs.n, rhs.s, rhs.d);
}
};
int main()
{
std::set<S> set_of_s; // S 为可比较小于 (LessThanComparable)
S value{42, "Test", 3.14};
std::set<S>::iterator iter;
bool inserted;
// 解包 insert 的返回值为 iter 与 inserted
std::tie(iter, inserted) = set_of_s.insert(value);
if (inserted)
std::cout << "Value was inserted successfully\n";
}
建议在新代码中,遇到自定义类型重载<运算符,以及临时解析std::pair、std::tuple内容时,都用std::tie来简化代码。
相关链接:https://zh.cppreference.com/w/cpp/utility/tuple/tie
3.4 std::array
std::array 是封装固定大小数组的容器。此容器是一个聚合类型,其语义等同于保有一个C风格数组T[N]作为其唯一非静态数据成员的结构体。不同于 C 风格数组,它不会自动退化成 T* 。
#include <string>
#include <iterator>
#include <iostream>
#include <algorithm>
#include <array>
int main()
{
// 用聚合初始化构造
std::array<int, 3> a1{ {1, 2, 3} }; // C++11中要求双花括号( C++14 中不要求)
std::array<int, 3> a2 = {1, 2, 3}; // 在 = 后都不要求双花括号
std::array<std::string, 2> a3 = { std::string("a"), "b" };
// 支持容器操作
std::sort(a1.begin(), a1.end());
std::reverse_copy(a2.begin(), a2.end(),
std::ostream_iterator<int>(std::cout, " "));
std::cout << '\n';
// 支持带范围 for 循环
for(const auto& s: a3)
std::cout << s << ' ';
}
std::array是个值得大力推广用来替代数组的特性。相比于数组,其at成员函数带有越界检查,可以杜绝一大类数组越界隐患(但是要注意真的越界后产生的std::out_of_range异常的处理方式)。另外,std::array不能自动转换成指针的特点也让它避免了被开发人员滥用,并杜绝了数组形参退化成指针导致的一大类常见问题。back、fill、swap等成员函数比起手写的数组操作也更加简洁。
相关链接:https://zh.cppreference.com/w/cpp/container/array
3.5 std::bind
很多算法接口要求提供一个函数对象,但程序员手头有可能只有一个参数并不匹配的现成函数,这时可以通过函数适配器或者lamda表达式来进行两个接口之间的适配。在过去,C++提供了std::bind1st,std::bind2st等函数来指定绑定哪一个参数。但显然这些工具用起来太麻烦。C++11废除了以前这些适配器,提供了一个统一的bind函数模板,可以自动的根据传入参数类型和个数决定如何来适配。
#include <random>
#include <iostream>
#include <memory>
#include <functional>
void f(int n1, int n2, int n3, const int& n4, int n5)
{
std::cout << n1 << ' ' << n2 << ' ' << n3 << ' ' << n4 << ' ' << n5 << '\n';
}
int g(int n1)
{
return n1;
}
struct Foo {
void print_sum(int n1, int n2)
{
std::cout << n1 + n2 << '\n';
}
int data = 10;
};
int main()
{
using namespace std::placeholders; // 对于 _1, _2, _3...
// 演示参数重排序和按引用传递
int n = 7;
// ( _1 与 _2 来自 std::placeholders,并表示将来会传递给 f1 的参数)
auto f1 = std::bind(f, _2, _1, 42, std::cref(n), n);
n = 10;
f1(1, 2, 1001); // 1 为 _1 所绑定, 2 为 _2 所绑定,不使用 1001
// 进行到 f(2, 1, 42, n, 7) 的调用
// 嵌套 bind 子表达式共享占位符
auto f2 = std::bind(f, _3, std::bind(g, _3), _3, 4, 5);
f2(10, 11, 12); // 进行到 f(12, g(12), 12, 4, 5); 的调用
// 常见使用情况:以分布绑定 RNG
std::default_random_engine e;
std::uniform_int_distribution<> d(0, 10);
std::function<int()> rnd = std::bind(d, e); // e 的一个副本存储于 rnd
for(int n=0; n<10; ++n)
std::cout << rnd() << ' ';
std::cout << '\n';
// 绑定指向成员函数指针
Foo foo;
auto f3 = std::bind(&Foo::print_sum, &foo, 95, _1);
f3(5);
// 绑定指向数据成员指针
auto f4 = std::bind(&Foo::data, _1);
std::cout << f4(foo) << '\n';
// 智能指针亦能用于调用被引用对象的成员
std::cout << f4(std::make_shared<Foo>(foo)) << '\n'
<< f4(std::make_unique<Foo>(foo)) << '\n';
}
std::bind相对于lamda表达式孰优孰劣就很难说了。如果函数的参数过多或重排序很复杂,_1、_2、_3这一堆占位符写在代码里恐怕算不上可读性多好。因此,建议最好只在参数绑定较简单的场景下使用std::bind。
相关链接:https://zh.cppreference.com/w/cpp/utility/functional/bind
3.6 Smart Pointers(智能指针):unique_ptr、shared_ptr、weak_ptr
现在能使用的,带引用计数,并且能自动释放内存的智能指针包括以下几种:
unique_ptr: 如果内存资源的所有权不需要共享,就应当使用这个(它没有拷贝构造函数),但是它可以转让给另一个unique_ptr(存在move构造函数)。
shared_ptr: 如果内存资源需要共享,那么使用这个(所以叫这个名字)。
weak_ptr: 持有被shared_ptr所管理对象的引用,但是不会改变引用计数值。它被用来打破依赖循环(想象在一个tree结构中,父节点通过一个共享所有权的引用(shared_ptr)引用子节点,同时子节点又必须持有父节点的引用。如果这第二个引用也共享所有权,就会导致一个循环,最终两个节点内存都无法释放)。
另一方面,auto_ptr已经被废弃,不会再使用了。
什么时候使用unique_ptr,什么时候使用shared_ptr取决于对所有权的需求,建议阅读以下的讨论:
http://stackoverflow.com/questions/15648844/using-smart-pointers-for-class-members
以下第一个例子使用了unique_ptr。如果你想把对象所有权转移给另一个unique_ptr,需要使用std::move。在所有权转移后,交出所有权的智能指针将为空,get()函数将返回nullptr。
void foo(int* p)
{
std::cout << *p << std::endl;
}
std::unique_ptr<int> p1(new int(42));
std::unique_ptr<int> p2 = std::move(p1); // transfer ownership
if(p1)
foo(p1.get());
(*p2)++;
if(p2)
foo(p2.get());
第二个例子展示了shared_ptr。用法相似,但语义不同,此时所有权是共享的。
void foo(int* p)
{
}
void bar(std::shared_ptr<int> p)
{
++(*p);
}
std::shared_ptr<int> p1(new int(42));
std::shared_ptr<int> p2 = p1;
bar(p1);
foo(p2.get());
第一个声明和以下这行是等价的:
auto p3 = std::make_shared<int>(42);
make_shared是一个非成员函数,使用它的好处是可以一次性分配共享对象和智能指针自身的内存。而显式的使用shared_ptr构造函数来构造则至少需要两次内存分配。除了会产生额外的开销,还可能会导致内存泄漏。在下面这个例子中,如果seed()抛出一个错误就会产生内存泄漏。
void foo(std::shared_ptr<int> p, int init)
{
*p = init;
}
foo(std::shared_ptr<int>(new int(42)), seed());
如果使用make_shared就不会有这个问题了。第三个例子展示了weak_ptr。注意,你必须调用lock()来获得被引用对象的shared_ptr,通过它才能访问这个对象。
auto p = std::make_shared<int>(42);
std::weak_ptr<int> wp = p;
{
auto sp = wp.lock();
std::cout << *sp << std::endl;
}
p.reset();
if (wp.expired())
std::cout << "expired" << std::endl;
如果你试图锁定(lock)一个过期(指被弱引用对象已经被释放)的weak_ptr,那你将获得一个空的shared_ptr。
智能指针可以在一定程度上对防范内存问题有帮助,但并不是万能的。智能指针如果使用不当,仍可能出现内存泄露、重复释放等各种问题。对于需要限制对象生命周期在某个局部作用域的场景,unique_ptr是个很好的选择,以前的auto_ptr应当被废弃。而对于对象生命周期超出局部作用域的场景来说,在设计上限定“谁申请,谁释放”很可能是比shared_ptr更好的解决方案。因为即使用了shared_ptr,也很难预知对象什么时候被释放,是否所有使用的代码都严格按规则使用,还是需要大量人工检查。而且,如果shared_ptr出了问题,有可能比以前更难定位。
相关链接:https://zh.cppreference.com/w/cpp/memory/unique_ptr
https://zh.cppreference.com/w/cpp/memory/shared_ptr
https://zh.cppreference.com/w/cpp/memory/weak_ptr
4 C++14新特性(语言核心)
4.1 返回类型推导
C++11允许lambda函数根据return语句的表达式类型推断返回类型,C++14为一般的函数也提供了这个能力。C++14还拓展了原有的规则,使得函数体并不是{return expression;}形式的函数也可以使用返回类型推导。
为了启用返回类型推导,函数声明必须将auto作为返回类型,但没有C++11的后置返回类型说明符:
// C++11
template <typename T, typename U>
auto sum(T x, U y) -> decltype(x + y) // 尾置返回类型
{
return x + y;
}
// C++14
template <typename T, typename U>
auto sum(T x, U y)
{
return x + y;
}
如果函数实现中含有多个return语句,这些表达式必须可以推断为相同的类型。
使用返回类型推导的函数可以前向声明,但在定义之前不可以使用。它们的定义在使用它们的翻译单元(translation unit)之中必须是可用的。
这样的函数中可以存在递归,但递归调用必须在函数定义中的至少一个return语句之后:
auto Correct(int i)
{
if (i == 1)
return i;// 返回类型被推断为int
else
return Correct(i-1)+i;// 正确,可以调用
}
auto Wrong(int i)
{
if(i != 1)
return Wrong(i-1)+i; // 不能调用,之前没有return语句
else
return i; // 返回类型被推断为int
}
返回类型推导虽然方便,但对于一般的函数来说为了可读性不建议随意使用。而在模板编程中,有些场景下使用返回类型推导可以带来很大的方便。
相关链接:https://zh.cppreference.com/w/cpp/language/auto
4.2 decltype(auto)
C++11中有两种推断类型的方式。auto根据给出的表达式产生具有合适类型的变量。decltype可以计算给出的表达式的类型。但是,decltype和auto推断类型的方式是不同的。特别地,auto总是推断出非引用类型,就好像使用了std::remove_reference一样,而auto&&总是推断出引用类型。然而decltype可以根据表达式的值类别和表达式的性质推断出引用或非引用类型:
int i;
int&& f();
auto x3a = i; // x3a的类型是int
decltype(i) x3d = i; // x3d的类型是int
auto x4a = (i); // x4a的类型是int
decltype((i)) x4d = (i); // x4d的类型是int&
auto x5a = f(); // x5a的类型是int
decltype(f()) x5d = f(); // x5d的类型是int&&
C++14增加了decltype(auto)的语法。允许auto的类型声明使用decltype的规则。也即,允许不必显式指定作为decltype参数的表达式,而使用decltype对于给定表达式的推断规则。
decltype(auto)的语法也可以用于返回类型推导,只需用decltype(auto)代替auto。
template <typename T, typename U>
decltype(auto) sum(T x, U y) // 根据return后面的表达式自动推断返回类型
{ // 注意这里是以decltype的类型推导规则来推断返回类型
return x + y;
}
decltype(auto)可以解决特定场景下的问题,在一般场景下用auto就足够了。
相关链接:https://zh.cppreference.com/w/cpp/language/auto
4.3 放开constexpr限制
在C++11之后,编译期的数值计算可以通过使用constexpr声明并定义编译期函数来进行。相对于模板元编程,使用constexpr函数更贴近普通的C++程序,计算过程显得更为直接,意图也更明显。但在C++11中constexpr函数所受到的限制较多,比如函数体通常只有一句return语句,函数体内既不能声明变量,也不能使用for语句之类的常规控制流语句。
C++14解除了对constexpr函数的大部分限制。在C++14的constexpr函数体内我们既可以声明变量,也可以使用goto和try之外大部分的控制流语句。
虽说constexpr函数所定义的是编译期的函数,但实际上在运行期constexpr函数也能被调用。事实上,如果使用编译期常量参数调用constexpr函数,我们就能够在编译期得到运算结果;而如果使用运行期变量参数调用constexpr函数,那么在运行期我们同样也能得到运算结果。
准确的说,constexpr函数是一种在编译期和运行期都能被调用并执行的函数。出于constexpr函数的这个特点,在C++11之后进行数值计算时,无论在编译期还是运行期我们都可以统一用一套代码来实现。编译期和运行期在数值计算这点上得到了部分统一。
#include <iostream>
using namespace std;
// C++11
constexpr int factorial(int n)
{
return n == 0 ? 1 : n * factorial(n - 1);
}
// C++14
constexpr int factorial2(int n)
{
int result = 1;
for (int i = 1; i <= n; ++i)
result *= i;
return result;
}
int main()
{
static_assert(Factorial<3>::value == 6, "error");
static_assert(factorial(3) == 6, "error");
static_assert(factorial2(3) == 6, "error");
int n = 3;
cout << factorial(n) << factorial2(n) << endl; //66
}
相关链接:https://zh.cppreference.com/w/cpp/language/constexpr
4.4 变量模板
变量模板是C++14的一个新的语法特性。C++新标准引入变量模板的主要目的是为了简化定义(simplify definitions)以及对模板化常量(parameterized constant)的支持
template<typename T>
T var; // not allowed in pre-C++14
var<int> = 5; // == (int var = 5;)
var<double> = 3.14;
var<char> = '$';
var<string> = "这是一个字符串";
cout << var<int> << endl << var<double> << endl << var<char> << endl << var<string> << endl;
变量模板是个较为生僻的特性,在一般的场景下较少会用到。
相关链接:https://zh.cppreference.com/w/cpp/language/variable_template
4.5 二进制数字和数字分隔符
除了原有的十进制、十六进制和比较不常用的八进制表示方法之外,C++程序员现在还可以使用二进制表示常量了。二进制常量以前缀0b(或0B)开头,二进制数字紧随其后。
在英美两国,在写数字时,我们习惯于使用逗号作为数字的分隔符,如:$1,000,000。这些数字分隔符纯为方便读者,它提供的语法线索使我们的大脑在处理长串的数字时更加容易。
基于完全相同的原因,C++标准委员会为C++语言增加了数字分隔符。数字分隔符不会影响数字的值,它们的存在仅仅是为了通过分组使数字的读写更容易。
使用哪个字符来表示数字分隔符呢?在C++中,几乎每个标点字符都已经有特定的用途了,因此并没有明显的选择。最终的结果是使用单引号字符,这使得百万美元在C++中写作1’000’000.00。记住,分隔符不会对常量的值有任何影响,因此,1’0’00’0’00.00也是表示百万。
下面是一个结合了这两种新特性的例子:
#include <iostream>
int main()
{
intval = 0b11110000;
std::cout << "Output mask: "
<< 0b1000'0001'1000'0000
<<"\n";
std::cout << "Proposed salary: $"
<< 300'000.00
<<"\n";
return0;
}
可以看到,当二进制数字不长的时候,使用二进制字面量和分隔符可以有效地改善代码可读性,值得推广。但如果是很长的二进制数字,有可能用16进制会显得更简短一些。
相关链接:https://zh.cppreference.com/w/cpp/language/integer_literal
5 C++14新特性(标准库)
5.1 std::make_unique
C++11标准中有std::make_shared可以让智能指针shared_ptr的初始化和对象构造在一条语句中完成,现在std::make_unique也被加入了标准,让unique_ptr也可以用同样的方式初始化。
#include <iostream>
#include <memory>
struct Vec3
{
int x, y, z;
Vec3() : x(0), y(0), z(0) { }
Vec3(int x, int y, int z) : x(x), y(y), z(z) { }
friend std::ostream& operator<<(std::ostream& os, Vec3& v) {
return os << '{' << "x:" << v.x << " y:" << v.y << " z:" << v.z << '}';
}
};
int main()
{
// 使用默认构造函数。
std::unique_ptr<Vec3> v1 = std::make_unique<Vec3>();
// 使用匹配这些参数的构造函数
std::unique_ptr<Vec3> v2 = std::make_unique<Vec3>(0, 1, 2);
// 创建指向 5 个元素数组的 unique_ptr
std::unique_ptr<Vec3[]> v3 = std::make_unique<Vec3[]>(5);
std::cout << "make_unique<Vec3>(): " << *v1 << '\n'
<< "make_unique<Vec3>(0,1,2): " << *v2 << '\n'
<< "make_unique<Vec3[]>(5): " << '\n';
for (int i = 0; i < 5; i++) {
std::cout << " " << v3[i] << '\n';
}
}
相比原来的构造方式,std::make_unique显然要更加安全,应当要求新代码使用这种新的初始化方式。
相关链接:https://zh.cppreference.com/w/cpp/memory/unique_ptr/make_unique
6 C++17新特性(语言核心)
6.1 Structured Binding(结构化绑定)
C++11提供的std::tie虽然好用,但是只能用于已经被声明的变量。有时为了解析一个函数返回的元组,不得不先声明很多个临时变量。结构化绑定允许用auto []自动声明很多个对应类型的变量,大大简化代码:
#include <cstdlib>
#include <iostream>
#include <set>
#include <string>
#include <iterator>
#include <tuple>
struct S {
int n;
std::string s;
float d;
bool operator<(const S& rhs) const
{
// compares n to rhs.n,
// then s to rhs.s,
// then d to rhs.d
return std::tie(n, s, d) < std::tie(rhs.n, rhs.s, rhs.d);
}
};
int main()
{
std::set<S> mySet;
// pre C++17:
{
S value{42, "Test", 3.14};
std::set<S>::iterator iter;
bool inserted;
// unpacks the return val of insert into iter and inserted
std::tie(iter, inserted) = mySet.insert(value);
if (inserted)
std::cout << "Value was inserted\n";
}
// with C++17:
{
S value{100, "abc", 100.0};
const auto [iter, inserted] = mySet.insert(value);
if (inserted)
std::cout << "Value(" << iter->n << ", " << iter->s << ", ...) was inserted" << "\n";
}
}
结构化绑定可以被用于std::pair、std::tuple、初始化列表(std::initializer_list)、数组、只包含简单成员的结构体、以及所有定义了get的自定义类型。灵活使用结构化绑定,可以让C++代码看起来像动态类型语言(如Python)。
std::map<MyKey, MyValue> myMap;
for (const auto& [k,v] : myMap)
{
// k - key
// v - value
}
结构化绑定让代码更简洁,值得推广。
相关链接:https://zh.cppreference.com/w/cpp/language/structured_binding
6.2 Init Statement for if/switch(if/switch语句中的初始化语句)
我们知道C++的for语句头部是可以声明变量的,该变量作用域只限定于for循环体内。现在if/switch语句也支持同样的用法了,这可以有效地避免临时变量之间重名,对于判断条件包含函数返回值时尤其有用。
#include <iostream>
#include <string>
int main()
{
const std::string myString = "Hello World";
// C++98/03 每次必须取不同的变量名
auto it = myString.find("Hello");
if (it != std::string::npos)
std::cout << it << " Hello\n";
auto it2 = myString.find("World");
if (it2 != std::string::npos)
std::cout << it2 << " World\n";
// additional enclosing scope so 'it' doesn't 'leak'
{
auto it = myString.find("Hello");
if (it != std::string::npos)
std::cout << "Hello\n";
}
{
auto it = myString.find("World");
if (it != std::string::npos)
std::cout << "World\n";
}
// C++17 with init if:
if (const auto it = myString.find("Hello"); it != std::string::npos)
std::cout << it << " Hello\n";
if (const auto it = myString.find("World"); it != std::string::npos)
std::cout << it << " World\n";
}
当和结构化绑定结合使用时,这个特性能发挥更大的威力:
#include <iostream>
#include <set>
#include <string>
#include <tuple>
#include <utility>
using namespace std;
int main()
{
set<pair<string, int>> mySet;
pair<string, int> itemsToAdd[3] { {"hello", 1}, { "world", 2 }, { "world", 2 } };
for (auto &p : itemsToAdd)
{
if (const auto [iter, inserted] = mySet.insert(p); inserted)
cout << "Value(" << iter->first << ", " << iter->second << ") was inserted" << "\n";
else
cout << "Value(" << iter->first << ", ...) not inserted!" << "\n";
}
}
这个特性减少了代码中的命名个数,增加了可读性,值得推广。
相关链接:https://zh.cppreference.com/w/cpp/language/if
6.3 Inline Variables(内联变量)
C++11的constexpr允许类的静态成员常量直接在类定义内部初始化,但是对于静态成员变量就不行了。内联变量特性允许非常量也在头文件中初始化,编译器会保证各个翻译单元看到的都是同一个变量实体。
#include <cstdlib>
class MyClass {
static const int mySecondVar;
};
inline const int MyClass::mySecondVar = rand();
需要用到这个特性的场景不多,很难说把静态成员初始化写在头文件中是不是一个值得推荐的做法。一旦遇到头文件不对齐,可能会造成一些相当麻烦的错误。但对于纯头文件的工具库来说,它还是很有用的。
相关链接:https://zh.cppreference.com/w/cpp/language/inline
6.4 Nested namespace definition(嵌入名字空间定义)
原来对于多层嵌套的名字空间定义必须写多层大括号,有了这个特性,现在可以这样写了:
namespace A::B::C {
// ...
}
这是一个影响不大的特性,有了它,在少数场景下可以简化一些代码。
相关链接:https://zh.cppreference.com/w/cpp/language/namespace
6.5 constexpr if-statements(静态条件语句)
这个特性可以让编译器在编译期就决定哪个分支被运行,去掉运行时的判断开销。比如下面这个get_value函数模板,可以在编译期确定对于指针返回其指向的值,对于非指针直接返回其本身。
#include <iostream>
#include <memory>
template <typename T>
auto get_value(T t) {
if constexpr (std::is_pointer_v<T>)
return *t;
else
return t;
}
int main()
{
auto pi = std::make_unique<int>(9);
int i = 9;
std::cout << get_value(pi.get()) << "\n";
std::cout << get_value(i) << "\n";
}
以前没有constexpr if时,用户需要使用函数重载和模板技巧来达成同样的效果,可能会借助C++11中的std::enable_if。现在使用constexpr if,可以让代码看起来更直观、容易理解。下面这段代码,用一个模板函数完成了以前需要写多个模板特例化才能完成的成员选择功能:
struct S
{
int n;
std::string s;
float d;
};
template <std::size_t I>
auto& get(S& s)
{
if constexpr (I == 0)
return s.n;
else if constexpr (I == 1)
return s.s;
else if constexpr (I == 2)
return s.d;
}
不过,constexpr if也带来了一个具有争议的问题:它是否增加代码的圈复杂度?以前在编译期选择分支通常用#ifdef预处理指令,它是不会被算作圈复杂度的。但是constexpr if就难说了。从可读性上讲,constexpr if确实和普通条件分支一样需要阅读者理解其判断过程,只不过没有运行时的开销。因此,对于是否应该全面推广使用constexpr if,很难给出统一的建议,只能留待开发人员根据具体场景来选择。
相关链接:https://zh.cppreference.com/w/cpp/language/if
6.6 Template Argument Deduction for Class Templates(类模板的模板参数推导)
在以前,函数模板可以根据传入参数的类型自动实例化,但类模板在使用时必须要显式指定模板参数类型。C++17修改了模板推导规则,允许类模板也根据构造函数的参数类型自动实例化。
#include <utility>
#include <tuple>
#include <iostream>
int main() {
std::pair d(0, 0.0);
std::tuple t(1, 2, 3);
std::cout << std::get<0>(t) << ", " << std::get<1>(t) << ", " << std::get<2>(t) << "\n";
}
有了这个特性,很多的辅助函数模板可以省掉了,比如std::make_pair、std::make_tuple(当然,对于标准库中已有的辅助函数,继续用它们也没坏处)。以后使用类模板只需直接调用构造函数就可以。
但是需要注意的是,对于智能指针,辅助函数(std::make_unique、std::make_shared)可不能省,它们的作用并不只是用来推导类型,还能保证对象构造和指针初始化同时进行。
相关链接:https://zh.cppreference.com/w/cpp/language/class_template_argument_deduction
6.7 Non-type Template Parameters with auto(auto关键字用于非类型模板参数)
以前模板的非类型参数需要显式的写出类型(一般是int或者bool),现在允许让编译器来自动推导了。
template <auto value> void f() { }
f<10>(); // deduces int
非类型参数的类型变化的场景并不多,所以这个特性能派上用场的地方也有限。一般来说还是推荐写出确定的类型。
相关链接:https://zh.cppreference.com/w/cpp/language/template_parameters
7 C++17新特性(标准库)
7.1 std::string_view
在很多编程语言中,字符串都是不可修改(immutable)的,这样就可以放心的让同一个字符串内容被多处引用共享。但是C++的std::string却是自带独立内存空间的可修改容器,这使得在某些场景下性能会劣化。比如,想要函数返回一个字符串,很难避免return时它被复制一次。当然,C++11以后我们可以用右值引用等方法来做一定程度的优化,但还是很难解决多处共享同一个字符串等场景的问题。
std::string_view为此而诞生,它不会对字符串内容进行任何修改,且没有内存管理的功能,仅仅只是将C的原生字符串做了一下包装。并且带有查找、越界检查、去除前缀/后缀、运算符重载等方便的功能。
#include <iostream>
#include <algorithm>
#include <string_view>
int main()
{
std::string str = " trim me";
std::string_view v = str;
v.remove_prefix(std::min(v.find_first_not_of(" "), v.size()));
std::cout << "String: '" << str << "'\n"
<< "View : '" << v << "'\n";
}
由于实际代码中很多字符串是并不打算被修改的,std::string_view是一个非常值得推广的价值特性。相比于C原生字符串那些难以受控的指针操作,使用std::string_view能使代码更简洁、更安全、更易理解,同时也没有性能的下降。但要注意的是,由于std::string_view不负责做任何内存管理,而且允许很多个std::string_view共享同一片内存,程序员必须注意内部字符串空间的释放,以防止内存泄露和访问野指针。
相关链接:https://zh.cppreference.com/w/cpp/string/basic_string_view
7.2 std::optional
程序员经常碰到的一个问题是:一个函数有可能返回一个对象,也有可能返回失败,这种函数如何声明?以前通常有两种办法:1、函数返回错误码,同时用一个引用参数来带出对象。这种方式给调用者带来的麻烦是,必须要多写一行代码声明一个对象用于接收返回值。2、函数返回一个对象的指针,如果为空指针则表示失败。这种方法的一大隐患是对象的内存由谁来释放?稍有不慎就可能出现内存泄露或者重复释放。
C++17的标准库给出了一种更好的解决方案:std::optional模板。函数返回std::optional时,表示既有可能含值也有可能不含值。调用者可以用bool表达式来判断其是否含值,也可以用value_or函数来提供一个当不含值时的默认值。内部对象的生命周期由std::optional来管理,当std::optional对象被释放时其内部管理的对象也自动被释放。
#include <string>
#include <functional>
#include <iostream>
#include <optional>
// optional 可用作可能失败的工厂的返回类型
std::optional<std::string> create(bool b) {
if (b)
return "Godzilla";
else
return {};
}
// 能用 std::nullopt 创建任何(空的) std::optional
auto create2(bool b) {
return b ? std::optional<std::string>{"Godzilla"} : std::nullopt;
}
// std::reference_wrapper 可用于返回引用
auto create_ref(bool b) {
static std::string value = "Godzilla";
return b ? std::optional<std::reference_wrapper<std::string>>{value}
: std::nullopt;
}
int main()
{
std::cout << "create(false) returned "
<< create(false).value_or("empty") << '\n';
// 返回 optional 的工厂函数可用作 while 和 if 的条件
if (auto str = create2(true)) {
std::cout << "create2(true) returned " << *str << '\n';
}
if (auto str = create_ref(true)) {
// 用 get() 访问 reference_wrapper 的值
std::cout << "create_ref(true) returned " << str->get() << '\n';
str->get() = "Mothra";
std::cout << "modifying it changed it to " << str->get() << '\n';
}
}
std::optional可以很有效的简化代码,并防止安全问题,在写查找功能的函数时尤其有用。但是使用std::optional时也要注意在构造时如何减少拷贝开销,一般可以使用std::make_optional或者常量表达式来完成构造。另一方面,由于std::optional析构时会自动把内部对象也给析构掉,使用者应注意不要访问野指针。
相关链接:https://zh.cppreference.com/w/cpp/utility/optional
7.3 std::variant
C++中的标准库容器都要求所有内部元素类型是一致的,但是实际开发中经常碰到需要把多个不同类型对象放到同一个容器中管理的需求,比如一个JSON对象中的value元素,既有可能是整型也有可能是浮点型。之前对这种场景有两种解决方案:1、对所有元素类型定义一个共同基类,容器中存放基类的指针。这是经典的面向对象多态设计方法,但在C++语言中的问题是这样做以后对象的内存就只能手工管理,得手写代码在合适的时期new和delete。2、容器中只存放无类型的内存(char或者void),由使用者根据上下文信息来将其转换为合适的对象类型。这种方法是不推荐的,一旦类型转换错误,会出现难以定位的运行时错误。
C++17提供了一种新的工具:std::variant。它允许多种不同的可能类型存放于同一个std::variant对象中,但同一时间内std::variant只会有某一种类型的对象。使用者可以通过index函数来检查当前类型,也可以用std::get指明类型序号或者类型名来获取其中的对象。std::variant对类型有强校验,当类型不符合时会触发编译错误或者抛出异常。
#include <variant>
#include <string>
#include <cassert>
using namespace std::literals;
int main()
{
std::variant<int, float> v, w;
v = 12; // v contains int
int i = std::get<int>(v);
w = std::get<int>(v);
w = std::get<0>(v); // same effect as the previous line
w = v; // same effect as the previous line
// std::get<double>(v); // error: no double in [int, float]
// std::get<3>(v); // error: valid index values are 0 and 1
try {
std::get<float>(w); // w contains int, not float: will throw
}
catch (const std::bad_variant_access&) {}
std::variant<std::string> x("abc"); // converting constructors work when unambiguous
x = "def"; // converting assignment also works when unambiguous
std::variant<std::string, bool> y("abc"); // casts to bool when passed a char const *
assert(std::holds_alternative<bool>(y)); // succeeds
y = "xyz"s;
assert(std::holds_alternative<std::string>(y)); //succeeds
}
std::variant对于不希望手工管理生命周期的多类型小对象来说是个很好的选择。使用std::variant并不会妨碍多态的优点,程序员仍然可以通过基类对象引用来获取std::variant的内部对象(自动upcast)并对其执行虚函数。