2 关键字
约 26759 字大约 89 分钟
2025-06-22
内联命名空间
C++11 标准增强了命名空间的特性,提出了内联命名空间的概念。内联命名空间能够把空间内函数和类型导出到父命名空间中,这样即使不指定子命名空间也可以使用其空间内的函数和类型
namespace Parent {
namespace Child1
{
void foo() { cout << "Child1::foo()" << endl; }
}
inline namespace Child
{
void foo() { cout << "Child2::foo()" << endl; }
}
}
Parent::Child1::foo();//Child1::foo()
Parent::foo();//Child2::foo()
Child1 不是一个内联命名空间,所以调用 Child1 的 foo 函数需要明确指定所 属命名空间。 而调用 Child2 的 foo 函数,直接指定父命名空间即可。 这里删除内联命名空间,将 foo 函数直接纳入 Parent 命名空间也能达 到同样的效果。
该特性可以帮助库作者无缝升级库代码,让客户不用修改任何代码也能够自由选择新老库代码。 代码中只能有一个内联命名空间,否则编译时会造成二义性问题,编译器不知道使用哪个内联命名空间的 foo 函数。
嵌套命名空间
有时候打开一个嵌套命名空间可能只是为了向前声明某个类或者函数,但是却需要编写冗长 的嵌套代码,加入一些无谓的缩进,C++17 标准允许使用一种更简洁的形式描述嵌套命名空间
namespace A::B::C {
int foo() { return 5; }
}
等同于
namespace A {
namespace B {
namespace C {
int foo() { return 5; }
}
}
}
嵌套内联命名空间
namespace A::B::inline C {
int foo() { return 5; }
}
//或者
namespace A::inline B::C {
int foo() { return 5; }
}
等同于
namespace A::B {
inline namespace C {
int foo() { return 5; }
}
}
namespace A {
inline namespace B {
namespace C {
int foo() { return 5; }
}
}
}
扩展的inline说明符
在C++17标准之前,定义类的非常量静态成员变量是一件让人头痛的事情,因为变量的声明 和定义必须分开进行
class X {
public:
static string text;
};
string X::text{ "hello" };
int main(){
X::text += " world";
cout << X::text << endl;
}
为了保证代码能够顺利地编译,我们必须保证静态成员变量的定义 有且只有一份,稍有不慎就会引发错误,比较常见的错误是为了方便将静态成员变量的定义放在头文件中
#ifndef X_H
#define X_H
class X {
public:
static string text;
};
string X::text{ "hello" };
#endif
将上面的代码包含到多个CPP文件中会引发一个链接错误,因为include是单纯的宏替换,所以会存在多份Xstring这种非字面量类型,这种方法是无能为力的。
内联定义静态变量
class X {
public:
inline static string text{"hello"};
};
int main(){
X::text += " world";
cout << X::text << endl;
}
上面的代码可以成功编译和运行,而且即使将类X的定义作为头文件包含在多个CPP中也不会有任何问题。在这种情况下,编译器会在类 X的定义首次出现时对内联静态成员变量进行定义和初始化。
using
类型别名
在C++的程序中,我们经常会看到特别长的类型名,为了让代码看起来更加简洁,往往会使用typedef为较长的类型名定义一个别名
和typedef作用一致,但语法不同
typedef 旧类型 新类型名
using 新类型名=旧类型;
//定义普通类型
typedef unsigned int int8u;
using int8u=unsigned int;
//定义函数指针
typedef int(*func)(int,double);
using func1=int(*)(int,double);
但typedef不能用于定义类模板的别名
template<typename T>
typedef map<int,T> maptype;
会报错 也有解决方法,即使用类或结构体把它包起来,这种操作被称为添加了外敷类
template<typename T>
struct MyMap{
typedef map<int,T> maptype;
};
使用using可以直接定义
template<typename T>
using mmp=map<int,T>;
使用
MyMap<int> m1;
mmp<int> m2;
别名模板
除了定义别名using还承担着一个更加重要的特性——别名模板 别名模板本质上也应该是一种模板,它的实例化过程是用自己的模板参数替换原始模板的模板参数,并实例化原始模板。 定义别名模板的语法和定义类型别名相似,但多了模板形参列表
template<class T>
using int_map = map<int, T>;
int main(){
int_map<string> int2string;
int2string[11] = "7";
}
int_map是一个别名模板,它有一个模板形参。当int_map发生实例化的时 候,模板的实参stdmap中的T,所以真正实例化的类型是std::map。通过这种方式,我们可以在模板形参比较多的时候简化模板形参。 typedef也能做到
template<class T>
struct int_map {
typedef map<int, T> type;
};
int main(){
int_map<string>::type int2string;
int2string[11] = "7";
}
但这种方案更复杂,不仅要定义一个int_map的结构体类型,还需要在类型里使用typedef来定义目标类型,最后必 须使用int_map::type来声明变量。除此之外,如果遇上了待决的类型,还需要在变量声明前加上typename关键字
template<class T>
struct int_map {
typedef map<int, T> type;
};
template<class T>
struct X {
typename int_map<T>::type int2other; // 必须带有typename关键字,否则编译错误
};
类模板X没有确定模板形参T的类型,所以int_maptype既有可能是一个类型,也有可能是一个静态成员变量,编译器是无 法处理这种情况的。这里的typename关键字告诉编译器应该将int_maptype的困扰
typename
允许使用typename声明模板形参
在C++17标准之前,必须使用class来声明模板形参,而typename是不允许的
template<typename T>
struct A {};
template<template<typename> class T>
struct B {};
int main() {
B<A> ba;
}
上面的代码可以顺利地编译通过,但是如果将B的定义修改为template <template < typename>typename T> struct B {};则可能会发生编译错误。
在过去,能作为模板形参的只有类模 板,并没有其他可能性,所以规定必须使用class来声明模板形参是合情合理的。但是自从C++11 标准诞生,随着别名模板的引入,类模板不再是模板形参的唯一选择了
template <typename T> using A = int;
template <template <typename> class T> struct B {};
int main(){
B<A> ba;
}
这里的A实际上就是int类型而不是一个类模板。 现在已经没有必要强调 必须使用class来声明模板形参了,删除这个规则可以让语言更加简单合理。所以在C++17标准中 使用typename来声明模板形参已经不是问题了
减少typename使用的必要性
当使用未决类型的内嵌类型时,例如XY是一个类型,否则编译器会将其当作一个表达式的名称,比如一个静态数据成员或者静态 成员函数
template<class T> void f(T::R);
template<class T> void f(typename T::R);
在C++20标准之前,只有两种情况例外,它们分别是指定基类和成员初始化
struct Impl {};
struct Wrap {using B = Impl;};
template<class T>
struct D : T::B {
D() : T::B() {}
};
int main() {
D<Wrap> var;
}
struct D : TB() {}都没有指定typename,但是编译器依然可以 正确地识别程序意图。实际上,除了以上两种情况外,还有很多时候也可以从语义中明确地判断 出XB;中typename 完全没有存在的必要。 在C++20标准中,增加了一些情况可以让我们省略typename关键字。
- 在上下文仅可能是类型标识的情况,可以忽略typename。 static_cast、const_cast、reinterpret_cast或dynamic_cast等类型转换 :static_cast(p); 定义类型别名: using R = TB; 模板类型形参的默认参数: template struct X;
- 还有一些声明的情况也可以忽略typename。 全局或者命名空间中简单的声明或者函数的定义: template T::R f(); 结构体的成员:
template< class T>
struct D : T::B {
D() : T::B() {}
T::B b; // 编译成功
};
作为成员函数或者lambda表达式形参声明:
template<class T>
struct D : T::B {
D() : T::B() {}
T::B f(T::B) {return T::B(); } // 编译成功
};
扩展的friend语法
[!quote] Title C++并不是一个纯面向对象的语言,其原因就是支持友元,而友元破坏了类的封装。
声明一个类为另外一个类的友元时,不再需要使用class关键字,并且还可以使用类的别名(使用 typedef 或者 using 定义)。 看起来是个无足轻重的修改,但其实在声明友元时去掉class关键字能够给模板声明友元了,这是改进前所不具有的
[!tip] Title 友元声明可以声明为友元类或友元函数。友元可以声明在任何保护权限中,这是不受限的,不管声明在哪个保护权限,友元都可以访问该类的所有成员(包括private)
声明友元类时可以使用类名也可以使用类的别名(通过typedefine定义的别名,或改进后的using定义的别名)
为类模板声明友元
这样一来,我们就可以在模板实例化时才确定一个模板类是否有友元,以及谁是这个模板类的友元。
//矩形类
template<class T>
class Rectangle{
private:
friend T;
int width, height;
public:
Rectangle(int w, int h):width(w), height(h){}
};
//圆形类
template<class T>
class Circle{
private:
friend T;
int radius;
public:
Circle(int r):radius(r){}
};
//校验类
class Verify{
public:
Verify(int w, int h,Rectangle<Verify>& rectangle){
if (rectangle.width>w && rectangle.height>h){
cout<<"矩形符合要求"<<endl;
}else{
cout<<"矩形不符合要求"<<endl;
}
}
Verify(int r,Circle<Verify>& circle){
if (circle.radius>r){
cout<<"圆形符合要求"<<endl;
}else{
cout<<"圆形不符合要求"<<endl;
}
}
};
int main() {
Rectangle<Verify> rectangle(10, 20);
Circle<Verify> circle(30);
Verify verify1(10, 20, rectangle);
Verify verify2(30, circle);
return 0;
}
若给友元模板类传递的模板类是一个基础类型(如:int、double、char等)友元声明将自动忽略 这样一来,我们就可以在模板实例化时才确定一个模板类是否有友元,以及谁是这个模板类的友元。
内联命名空间
C++11标准增强了命名空间的特性,提出了内联命名空间的概念。内联命名空间能够把空间内函数和类型导出到父命名空间中,这样即使不指定子命名空间也可以使用其空间内的函数和类型
namespace Parent {
namespace Child1
{
void foo() { cout << "Child1::foo()" << endl; }
}
inline namespace Child2
{
void foo() { cout << "Child2::foo()" << endl; }
}
}
Parent::Child1::foo();//Child1::foo()
Parent::foo();//Child2::foo()
Child1不是一个内联命名空间,所以调用Child1的foo函数需要明确指定所 属命名空间。 而调用Child2的foo函数,直接指定父命名空间即可。 这里删除内联命名空间,将foo函数直接纳入Parent命名空间也能达 到同样的效果。
该特性可以帮助库作者无缝升级库代码,让客户不用修改任何代码也能够自由选择新老库代码。 代码中只能有一个内联命名空间,否则编译时会造成二义性问题,编译器不知道使用哪个内联命名空间的foo函数。
嵌套命名空间
有时候打开一个嵌套命名空间可能只是为了向前声明某个类或者函数,但是却需要编写冗长 的嵌套代码,加入一些无谓的缩进,C++17标准允许使用一种更简洁的形式描述嵌套命名空间
namespace A::B::C {
int foo() { return 5; }
}
等同于
namespace A {
namespace B {
namespace C {
int foo() { return 5; }
}
}
}
嵌套内联命名空间
namespace A::B::inline C {
int foo() { return 5; }
}
//或者
namespace A::inline B::C {
int foo() { return 5; }
}
等同于
namespace A::B {
inline namespace C {
int foo() { return 5; }
}
}
namespace A {
inline namespace B {
namespace C {
int foo() { return 5; }
}
}
}
自动类型推导
decltype和auto的使用方式有一些相似之处,但是推导规则却有所不同,
auto占位符
在C++98时auto是用来声明自动变量的(拥有自动生命周期的变量) 几乎不会使用,C++11标准赋予了auto新的含义:声明变量时根据初始化表达式自动推断该变量的类型、声明函数时函数返回值的占位符。
可以结合decltype表示函数的返回值 语法:
auto 变量名=变量值;
通过变量的值推导出变量的类型
还可以和指针、引用结合,也可以用const、volatile(修饰变量,告诉编译器在处理的时候不要做任何的优化。一般做多线程编程时会用)修饰 在不同的场景下有不同的推导规则: 当变量不是指针或引用时,推导结果不会保留const、volatile关键字 当变量是指针或引用时,推导的结果中会保留const、volatile关键字
auto a=10;
int temp=20;
auto *b=&temp;//int
auto &c=temp;//int
//有const修饰
int tmp = 250;
const auto a1 = tmp;//int
auto a2=a1;//int
const auto& a3=tmp;//const int&
auto& a4=a3;//const int&
注意
- 当用一个auto关键字声明多个变量的时候,编译器遵从由左往右的推导规则,以最左边的表达式推断auto的具体类型
int n = 5;
auto *pn = &n, m = 10;
因为&n类型为int * ,所以pn的类型被推导为int * ,auto被推导为int,于是 m被声明为int类型
int n = 5;
auto *pn = &n, m = 10.0; // 编译失败,声明类型不统一
- 当使用条件表达式初始化auto声明的变量时,编译器总是使用表达能力更强的类型:
auto i = true ? 5 : 8.0; // i的数据类型为double
cout<< typeid(i).name()<<endl;
虽然能够确定表达式返回的是int类型,但是i的类型依旧会被推导为表达能力更强的类型double
- 虽然C++11标准已经支持在声明成员变量时初始化(见第8章),但是auto却无法在这种 情况下声明非静态成员变量:
struct A {
auto i = 5; // 错误,无法编译通过
};
在C++11中静态成员变量是可以用auto声明并且初始化的,但前提是auto必须使用const限定符
struct A {
static const auto i = 5;
};
遗憾的是,const限定符会导致i常量化,显然这不是我们想要的结果。 但是,在C++17 标准中,对于静态成员变量,auto可以在没有const的情况下使用(但必须加inline)
struct sometype {
static inline auto i = 5; // C++17
};
- 按照C++20之前的标准,无法在函数形参列表中使用auto声明形参(注意,在C++14 中,auto可以为lambda表达式声明形参): 可以简写类模板
void echo(auto str) {…} // C++20之前编译失败,C++20编译成功
限制:
- C++14之后可用于函数返回值及参数使用
auto func(auto a,auto b){}
- 不用于类的非静态成员变量的初始化(静态常量类型的初始化)
class Test{
auto a=0;//error
static auto b=0;//error
static const auto c=10;//ok
}
- 不能用于定义数组
int array[]={1,2,3};
auto a=array;//ok int*
auto b[]=array;//error
auto c[]={1,2,3};//error
- 无法推导模板参数
template<typename T>
struct Test{};
Test<double>t;
Test<auto>t1=t//error
应用:
1、用于STL容器的遍历
for (map<T1,T2>::iterator it=mp.begin;it!=mp.end();it++){}
//使用auto可写为
for(auto it=m.begin();it!=m.end(),it++){}
2、用于泛型编程
class T1{
public:
static int get(){
return 10;
}
};
class T2{
public:
static char get(){
return 'a';
}
};
template<class T>
void func(){
auto ret=T::get();
cout<<ret<<endl;
}
int main(){
func<T1>();
func<T2>();
return 0;
}
非类型模板形参占位符
C++17对auto关键字又进行了一次扩展使它可以作为非类型模板形参的占位符。但必须保证推导出来的类型是可以用作模板形参的
它允许将常量值如下:
- 如整数
- 枚举
- 指向函数的指针或引用
- 指向对象的指针或引用
- std::nullptr_t
- 浮点类型(C++20之后)
- 指向成员函数的指针或引用
- String等类(C++20之后) 作为模板参数,而不是类型。
可以把一些程序的细节放在编译环节而不是运行环节,这带来的好处之一就是可以把错误放在编译期解决或者性能优化在编译期解决
这个是运行期常量
template<typename N>
void func(N n){cout<<n<<endl;}
func(10);
定义编译期常量C++17以前需要这样定义
template<typename T,T t>
void func(){
T value=t;
cout<<t<<endl;
}
func<int,10>();
但现在可以使用auto简化写法
template<auto N>
void func(){cout <<N<<endl;}
func<5>();//5
func<'a'>();//a
func<2.1>();//error
推导规则
- 如果auto声明的变量是按值初始化,则推导出的类型会忽略cv限定符。进一步解释为,在使用auto声明变量时,既没有使用引用,也没有使用指针,那么编译器在推导的时候会忽略const和volatile限定符。当然auto本身也支持添加cv限定符:
const int i = 5;
auto j = i; // auto推导类型为int,而非const int
auto &m = i; // auto推导类型为const int,m推导类型为const int&
auto *k = i; // auto推导类型为const int,k推导类型为const int*
const auto n = j; // auto推导类型为int,n的类型为const int
- 使用auto声明变量初始化时,目标对象如果是引用,则引用属性会被忽略
int i = 5;
int &j = i;
auto m = j; // auto推导类型为int,而非int&
- 使用auto和万能引用声明变量时,对于左值会将auto推导为引用类型:
int i = 5;
auto&& m = i;// auto推导类型为int& (这里涉及引用折叠的概念)
auto&& j = 5; // auto推导类型为int
- 使用auto声明变量,如果目标对象是一个数组或者函数,则auto会被推导为对应的指针类型
int i[5];
auto m = i;// auto推导类型为int*
int sum(int a1, int a2) {return a1+a2;}
auto j = sum // auto推导类型为int (__cdecl *)(int,int)
- 当auto关键字与列表初始化组合时,这里的规则有新老两个版本,这里只介绍新规则 (C++17标准)。 (1)直接使用列表初始化,列表中必须为单元素,否则无法编译,auto类型被推导为单元素 的类型。 (2)用等号加列表初始化,列表中可以包含单个或者多个元素,auto类型被推导为 std::initializer_list,其中T是元素类型。请注意,在列表中包含多个元素的时候,元素的类 型必须相同,否则编译器会报错。
auto x1 = { 1, 2 };// x1类型为 std::initializer_list
auto x2 = { 1, 2.0 };// 编译失败,花括号中元素类型不同
auto x3{ 1, 2 }; // 编译失败,不是单个元素
auto x4 = { 3 }; // x4类型为std::initializer_list
auto x5{ 3 }; // x5类型为int
decltype占位符
declare type声明类型,推导在编译期完成的,并不会计算表达式的值但是会检查表达式是否正确 语法
decltype(表达式)
int a=10;
decltype(a) b=20;//int b=20;
decltype(a+3.14) c;//double c;
decltype可以推导有类参与的表达式(auto不能在非静态成员中使用,但decltype却可以)
class A{
public:
int value;
double p;
};
decltype(A::value) c=0;//int c=0;
A a;
decltype(a.p) pi=3.14;
如果推导的表达式是函数的调用,使用decltype推导出的类型和函数返回值一致。 右值:常量 左值:变量 如果函数返回的是纯右值(像字符串那样不变的值,或数字那样的固定的数值),只有属于一个类的类型才可携带const、volatile限定符,除此之外都要忽略这两个限定符 表达式是一个左值,或者被()包裹,使用decltype推导出的是表达式类型的引用(如果有const、volatile限定符不能忽略)
返回类型后置
(将auto和decltype组合使用) 常用于泛型编程
template<typename R,typename T,typename U>
R add(T t,U u){
return t+u;
}
int x=1;
double y=3.14;
auto ret= add<decltype(x+y),int,double>(x,y);//4.14
但是使用decltype推导的R类型只适用于我们知道函数的表达式的情况,并不适用于复杂操作
template<typename T,typename U>
auto add(T t,U u)-> decltype(t+u){
return t+u;
}
int x=1;
double y=3.14;
auto ret= add(x,y);//4.14
decltype里面的t+u并不等于return的t+u,在实际操作的时候函数体中会有很多代码,decltype中并不需要也写同样的代码,而只需要写入的表达式能把返回值类型推导出来就行。
在C++11标准中只用decltype关键字也能写出自动推导返回类型的函数模板,但是 函数可读性却差了很多
template<class T1, class T2>
decltype(T1() + T2()) sum2(T1 t1, T2 t2) {
return t1 + t2;
}
int main() {
sum2(4, 2);
return 0;
}
以上代码使用decltype(T1()+T2())让编译器为我们推导函数的返回类型,其中T1()+T2()表达式 告诉编译器应该推导T1类型对象与T2类型对象之和的对象类型。但是这种写法并不通用,它存在一个潜在问题,由于T1() + T2()表达式使用了T1和T2类型的默认构造函数,因此编译器要求T1和T2 的默认构造函数必须存在,否则会编译失败
class IntWrap {
public:
IntWrap(int n) : n_(n) {}
IntWrap operator+ (const IntWrap& other)
{
return IntWrap(n_ + other.n_);
}
private:
int n_;
};
int main() {
sum2(IntWrap(1), IntWrap(2)); // 编译失败,IntWrap没有默认构造函数
}
虽然编译器在推导表达式类型的时候并没有真正计算表达式,但是会检查表达式是否正确, 所以在推导IntWrap() + IntWrap()时会报错。为了解决这个问题,需要既可以在表达式中让T1和T2 两个对象求和,又不用使用其构造函数方法,于是就有了以下两个函数模板:
template<class T1, class T2>
decltype(*static_cast<T1 *>(nullptr) + *static_cast<T2 *>(nullptr)) sum3(T1 t1, T2 t2)
{
return t1 + t2;
}
template<class T>
T&& declval();
template<class T1, class T2>
decltype(declval<T1>() + declval<T2>()) sum4(T1 t1, T2 t2)
{
return t1 + t2;
}
int main() {
sum3(IntWrap(1), IntWrap(2));
sum4(IntWrap(1), IntWrap(2));
return 0;
}
在上面的代码中,函数模板sum3使用指针类型转换和解引用求和的方法推导返回值,其 中* static_cast(nullptr)+ * static_cast(nullptr)分别将nullptr转换为T1和T2的指针 类型,然后解引用求和,最后利用decltype推导出求和后的对象类型。由于编译器不会真的计算求 值,因此这里求和操作不会有问题 函数模板sum4则是利用了另外一个技巧,其实本质上与sum3相似。在标准库中提供了一个 stddeclval的实现比较复杂,因此我在这 里实现了一个简化版本。declval() + declval()表达式分别通过declval将T1和T2转换为引 用类型并且求和,最后通过decltype推导返回类型
虽然这两种方法都能达到函数返回类型后置的效果,但是它们在实现上更加复 杂,同时要理解它们也必须有一定的模板元编程的知识。为了让代码更容易被其他人阅读和理 解,还是建议使用函数返回类型后置的方法来推导返回类型。
以上代码只推荐在C++11标准的编译环境中使用,C++14标准已经支持对auto声明的返回类型进行推导 可简化为
template<typename T1, typename T2>
auto sum(T1 a, T2 b){
return a + b;
}
cout<<sum(5, 10.5)<<endl;
也可以使用auto替代函数模板
auto sum(auto a, auto b){
return a + b;
}
这样看似decltype是多余的,但并不是,auto作为返回类型的占位符还存在一些问题
template<class T>
auto return_ref(T& t){
return t;
}
int x1 = 0;
static_assert(is_reference_v<decltype(return_ref(x1))>); // 编译错误,返回值不为引用类型
预估return_ref返回的是一个引用类型,但auto被推导为值类型 如果想正确 地返回引用类型,则需要用到decltype说明符
template<class T>
auto return_ref(T& t)->decltype(t){
return t;
}
int x1 = 0;
static_assert(is_reference_v<decltype(return_ref(x1))>); // 编译成功
推导规则
decltype(t)(其中t的类型为T)的推导规则有5条。
- 如果t是一个未加括号的标识符表达式(结构化绑定除外)或者未加括号的类成员访问, 则decltype(t)推断出的类型是t的类型T。如果并不存在这样的类型,或者t是一组重载函数,则无法进行推导。
- 如果t是一个函数调用或者仿函数调用,那么decltype(t)推断出的类型是其返回值的类型。
- 如果t是一个类型为T的左值,则decltype(e)是T&。
- 如果t是一个类型为T的将亡值,则decltype(e)是T&&。
- 除去以上情况,则decltype(t)是T。
decltype(auto)
在C++14标准中出现了decltype和auto两个关键字的结合体:decltype(auto)。它的作用简单来说,就是告诉编译器用decltype的推导表达式规则来推导auto。 但是,decltype(auto)必须单独声明,也就是它不能结合指针、引用以及cv限定符。
int i;
int&& f(); auto x1a = i; // x1a推导类型为int
decltype(auto) x1d = i; // x1d推导类型为int
auto x2a = (i); // x2a推导类型为int
decltype(auto) x2d = (i); // x2d推导类型为int&
auto x3a = f(); // x3a推导类型为int
decltype(auto) x3d = f(); // x3d推导类型为int&&
auto x4a = { 1, 2 }; // x4a推导类型为std::initializer_list
decltype(auto) x4d = { 1, 2 }; // 编译失败, {1, 2}不是表达式
auto *x5a = &i; // x5a推导类型为int*
rdecltype(auto)*x5d = &i; // 编译失败,decltype(auto)必须单独声明
有了decltype(auto)组合,我们可以进一步简化代码,消除返回类型后置的语法
template<class T>
decltype(auto) return_ref(T& t){
return t;
}
int x1 = 0;
static_assert(is_reference_v<decltype(return_ref(x1))>);// 编译成功
decltype(auto)作为非类型模板形参占位符
与auto一样,在C++17标准中decltype(auto)也能作为非类型模板形参的占位符,其推导规则 和上面介绍的保持一致
template<decltype(auto) N>
void f(){
cout<<N<<endl;
}
static const int x = 11;
static int y = 7;
f<x>(); // N为const int类型
f<(x)>(); // N为const int&类型
f<y>(); // 编译错误
f<(y)>(); // N为int&类型
函数返回类型后置(C++11)
auto foo()->int { return 42; }
以上代码中的函数声明等同于int foo(),只不过采用了函数返回类型后置的方法,其中auto 是一个占位符,函数名后->紧跟的int才是真正的返回类型。 这个例子中传统的函数声明 方式更加简洁。而在返回类型比较复杂的时候,比如返回一个函数指针类型,返回类型后置可能 会是一个不错的选择。
int bar_impl(int x) {
return x;
}
typedef int(*bar)(int);
bar foo1() {
return bar_impl;
}
auto foo2() -> int (*)(int) {
return bar_impl;
}
int main() {
auto func = foo2();
func(58);
auto fun=foo1();
fun(78);
return 0;
}
函数foo2的返回类型不再是简单的int而是函数指针类型。使用传统函数声 明语法的foo1无法将函数指针类型作为返回类型直接使用,所以需要使用typedef给函数指针类型 创建别名bar,再使用别名作为函数foo1的返回类型。而使用函数返回类型后置语法的foo2则没有 这个问题。同样,auto作为返回类型占位符,在->后声明返回的函数指针类型int(* )(int)即可。
新增关键字
override和final关键字
重写、重载和隐藏
- 重写(override)的意思更接近覆盖,在C++中是指派生类覆盖了基类的虚函数,这里的 覆盖必须满足有相同的函数签名和返回类型,也就是说有相同的函数名、形参列表以及返回类 型。
- 重载(overload),它通常是指在同一个类中有两个或者两个以上函数,它们的函数名相 同,但是函数签名不同,也就是说有不同的形参。这种情况在类的构造函数中最容易看到,为了 让类更方便使用,我们经常会重载多个构造函数。
- 隐藏(overwrite)的概念也十分容易与上面的概念混淆。隐藏是指基类成员函数,无论 它是否为虚函数,当派生类出现同名函数时,如果派生类函数签名不同于基类函数,则基类函数 会被隐藏。如果派生类函数签名与基类函数相同,则需要确定基类函数是否为虚函数,如果是虚 函数,则这里的概念就是重写;否则基类函数也会被隐藏。另外,如果还想使用基类函数,可以 使用using关键字将其引入派生类。
重写引发的问题
重写虚函数很容易出现错误,原因是C++语法对重写的要求很高,稍不注意就会无法重写基类虚函数。更糟糕的是,即使我们写错了代码,编译器也可能不会提示任何错误信息,直到程序编译成功后,运行测试才会发现其中的逻辑问题
class Base {
public:
virtual void some_func() {}
virtual void foo(int x) {}
virtual void bar() const {}
void baz() {}
};
class Derived : public Base {
public:
virtual void sone_func() {}
virtual void foo(int &x) {}
virtual void bar() {}
virtual void baz() {}
};
以上代码可以编译成功,但是派生类Derived的4个函数都没有触发重写操作。第一个派生类 虚函数sone_func的函数名与基类虚函数some_func不同,所以它不是重写。第二个派生类虚函数 foo(int &x)的形参列表与基类虚函数foo(int x)不同,所以同样不是重写。第三个派生类虚函数 bar()相对于基类虚函数少了常量属性,所以不是重写。最后的基类成员函数baz根本不是虚函数, 所以派生类的baz函数也不是重写。
override重写说明符
用于重写虚函数,并不是必须的
class A{
public:
virtual void test(){cout<<"A class"<<endl;}
};
class B:public A {
public:
void test()override{cout<<"B class"<<endl;}
};
在不出错的情况下重写不加override不会发生任何事情 如果在重写时不小心把函数名写错不写override编译器就会认为写了一个新函数而不是重写。写了override后,如果函数名写错,编译器就会报错,帮助纠正。
final
能用于修饰类或函数,修饰函数只能修饰虚函数,并且要把final关键字放在类或者函数的参数列表后面 用于限制某个类不能被继承,或者某个虚函数不能被重写 final声明表示此次之后不能重写,此次能重写
class A{
public:
virtual void test(){cout<<"A class"<<endl;}
};
class B:public A{
public:
void test(){cout<<"B class"<<endl;}
};
class C:public B{
public:
void test(){cout<<"C class"<<endl;}
};
如果在class B中的test函数加入final,class C中的test重写会报错
class B:public A{
public:
void test()final{cout<<"B class"<<endl;}
};
final说明符不仅能声明函数,也可以用于声明类,被声明的类不可被继承 如果在B继承A时使用final,则B不能被继承
class B final :public A {};
override和final关键字可以同时使用
noexpect
使用throw声明函数是否抛出异常一直没有什么问题,直到C++11标准引入了移动构造函数。 移动构造函数中包含着一个严重的异常陷阱。 当我们将一个容器的元素移动到另外一个新的容器中时。在C++11之前,由于没有移动语义,我们只能将原始容器的数据复制到新容器中。如果在数据复制的过程中复制构造函数发生了 异常,那么我们可以丢弃新的容器,保留原始的容器。在这个环境中,原始容器的内容不会有任何变化。 但是有了移动语义,原始容器的数据会逐一地移动到新容器中,如果数据移动的途中发生异 常,那么原始容器也将无法继续使用,因为已经有一部分数据移动到新的容器中。如果发生异常就做一个反向移动操作,恢复原始容器的内容并不可靠,因为我们无法保证恢复的过程中不会抛出异常。 throw并不能根据容器中移动的元素是否会抛出异常来确定移动构造函数是否允许抛出异常。但noexcept()
作为运算符时可以做到。
noexcept 编译期完成声明和检查工作.noexcept 主要是解决的问题是减少运行时开销. 运行时开销指的是, 编译器需要为代码生成一些额外的代码用来包裹原始代码,当出现异常时可以抛出一些相关的堆栈stack unwinding错误信息, 这里面包含,错误位置, 错误原因, 调用顺序和层级路径等信息.当使用noexcept声明一个函数不会抛出异常候, 编译器就不会去生成这些额外的代码, 直接的减小的生成文件的大小, 间接的优化了程序运行效率.
noexcept只是告诉编译器不会抛出异常,但函数不一定真的不会抛出异常。该符号只是一种指示符号,不是承诺。
noexcept是一个与异常相关的关键字,它既是一个说明符,也是一个运算符。作为说明符,它能够用来说明函数是否会抛出异常
当 noexcept 是标识符时, 它的作用是在函数后面声明一个函数是否会抛出异常. 当noexcept 是函数时, 它的作用是检查一个函数是否会抛出异常.
传入的表达式的结果是在编译时计算的,表达式必须是一个常量表达式 是一种不求值表达式,即不会执行表达式。
1 . noexcept 标识符
noexcept 标识符有几种写法: noexcept、noexcept(true)、noexcept(false)、noexcept(expression)、throw()
其中 noexcept 默认表示 noexcept(true) 当 noexcept 是 true 时表示函数不会抛出异常 当 noexcept 是 false 时表示函数可能会抛出异常 throw()表示函数可能会抛出异常, 相当于noexcept(false),C++20 放弃这种写法 常量表达式的结果会被转换成一个 bool 类型的值,该值为 true,表示函数不会抛出异常
noexcept
函数用来检查一个函数是否声明了 noexcept
, 如果声明了noexcept(true)
则返回true
, 如果声明了noexcept(false)
则返回false
// noexcept 作为标识符
void foo() noexcept{throw 4;}
// noexcept 作为标识符
void bar() noexcept(false) {throw 4;}
// noexcept 作为标识符
void fun(){throw 4;}
int main() {
// noexcept 函数
cout << boolalpha << noexcept(foo()) << endl;// true
cout << boolalpha << noexcept(bar()) << endl;// false
cout << boolalpha << noexcept(fun()) << endl;// false
return 0;
}
用noexcept优化数据拷贝函数
对于一个类型T 如果T是一个普通的编译器内置类型,那么该函数永远不会抛出异常,可以直接使用, 假如T是一个很复杂的类型,那么在拷贝的过程中,很有可能抛出异常,直接声明noexcept会导致当函数遇到异常的时候程序被终止,而不给我们处理异常的机会 只有在T是一个基础类型 时复制函数才会被声明为noexcept,因为基础类型的复制是不会发生异常的。
template <typename T>
T copy(const T& s) noexcept(is_fundamental<T>::value){}
stdvalue 用来判断类型是一个普通类型还是复杂的类型,如果是普通类型,返回true,则表示不会抛出异常,否则将表示可能会抛出异常。 但很多自定义类型的拷贝构造也是很简单的,几乎不会抛出异常,我们可以利用noexcept运算符的能力,判断类型的拷贝构造是否会抛出异常。
template <typename T>
T copy(const T& s) noexcept(noexcept(T(s))){}
先判断T(s) 拷贝构造函数是否会抛异常.如果不会,则返回false,此时函数定义如下,表示可能抛出异常,否则相反。
T copy(const T& s) noexcept(false){}
用noexcept解决移动构造问题
noexcept()可以判断目标类型的移动构造函数是否可能抛出异常,那么我们可以先判断有没有抛出异常的可能,如果有,那么使用传统的复制操作,否则执行移动构造。
以swap函数为例
template<class T>
void swap(T& a, T& b)
noexcept(noexcept(T(move(a))) && noexcept(a.operator=(move(b))))
{
T tmp(move(a));
a = move(b);
b = move(tmp);
}
参数列表后使用noexcept检查类型T的移动构造函数和移动赋值函数是否都不会抛出异常; 函数体通过移动构造函数和移动赋值函数移动对象a和b。 使用noexcept的好处在于,它让编译器可以根据类型移动函数是否抛出异常来选择不同的优化策略。但是这个函数并没有解决上面容器移动的问题。 改进swap函数
template<class T>
void swap(T& a, T& b)
noexcept(noexcept(T(move(a))) && noexcept(a.operator=(move(b))))
{
static_assert(noexcept(T(move(a)))&& noexcept(a.operator=(move(b))));
T tmp(move(a));
a = move(b);
b = move(tmp);
}
改进版的swap在函数内部使用static_assert对类型T的移动构造函数和移动赋值函数进行检 查,如果其中任何一个抛出异常,那么函数会编译失败。使用这种方法可以迫使类型T实现不抛出 异常的移动构造函数和移动赋值函数。
想要在不满足移动要求的时候,有选择地使用复制方法完成移动操作。
struct X {
X() {}
X(X &&) noexcept {}
X(const X &) {}
X operator= (X &&) noexcept { return *this; }
X operator= (const X &) { return *this; }
};
struct X1 {
X1() {}
X1(X1 &&) {}
X1(const X1 &) {}
X1 operator= (X1 &&) { return *this; }
X1 operator= (const X1 &) { return *this; }
};
template<typename T>
void swap_impl(T& a, T& b, integral_constant<bool, true>) noexcept
{
T tmp(move(a));
a = move(b);
b = move(tmp);
}
template<typename T>
void swap_impl(T& a, T& b, integral_constant<bool, false>)
{
T tmp(a);
a = b;
b = tmp;
}
template<typename T>
void swap(T& a, T& b)
noexcept(noexcept(swap_impl(a, b,integral_constant<bool, noexcept(T(move(a)))&& noexcept(a.operator=(move(b)))>())))
{
swap_impl(a, b, integral_constant<bool, noexcept(T(move(a)))&& noexcept(a.operator=(move(b)))>());
}
int main()
{
X x1, x2;
swap(x1, x2);
X1 x3, x4;
swap(x3, x4);
}
以上代码实现了两个版本的swap_impl,它们的形参列表的前两个形参是相同的,只有第三个 形参类型不同。第三个形参为stdintegral_ constant的函数则会使用复制的方法来交换数 据。swap函数会调用swap_impl,并且以移动构造函数和移动赋值函数是否会抛出异常为模板实参 来实例化swap_impl的第三个参数。这样,不抛出异常的类型会实例化一个类型为 std::integral_constant的对象,并调用使用移动方法的swap_impl;反之则调用使用复 制方法的swap_impl。
noexcept和throw()
如果用noexcept运算符去探测noexcept和throw()声明的函数,会返回相同的结果。
在C++11标准中,它们在实现上确实是有一些差异的。如果一个函数在声明了 noexcept的基础上抛出了异常,那么程序将不需要展开堆栈,并且它可以随时停止展开。另外,它 不会调用stdterminate结束程序。而throw()则需要展开堆栈,并调用 std::unexpected。这些差异让使用noexcept程序拥有更高的性能。 在C++17标准中,throw()成为 noexcept的一个别名,throw()和noexcept拥有了同样的行为和实现。且只有throw()被保留了下来,其他用throw声明函数抛出异常的方法都被移除了。 在C++20中 throw()也被标准移除了
默认使用noexcept的函数
自定义实现的函数默认不会带有noexcept声明
默认构造函数、默认复制构造函数、默认赋值函数、默认移动构造函数和默认移动赋值函数。默认带有noexcept声明
struct X {
};
#define PRINT_NOEXCEPT(x) \
cout << #x << " = " << x << endl
int main(){
X x;
cout << boolalpha;
PRINT_NOEXCEPT(noexcept(X()));//true
PRINT_NOEXCEPT(noexcept(X(x)));//true
PRINT_NOEXCEPT(noexcept(X(move(x))));//true
PRINT_NOEXCEPT(noexcept(x.operator=(x)));//true
PRINT_NOEXCEPT(noexcept(x.operator=(move(x))));//true
}
但对应的函数在类型的基类和成员中也具有noexcept声明,否则其对应函数将不再默认带有noexcept声明。
struct M {
M() {}
M(const M&) {}
M(M&&) noexcept {}
M operator= (const M&) noexcept { return *this; }
M operator= (M&&) { return *this; }
};
struct X {
M m;
};
#define PRINT_NOEXCEPT(x) \
cout << #x << " = " << x << endl
int main(){
X x;
cout << boolalpha;
PRINT_NOEXCEPT(noexcept(X()));//false
PRINT_NOEXCEPT(noexcept(X(x)));//false
PRINT_NOEXCEPT(noexcept(X(move(x))));//true
PRINT_NOEXCEPT(noexcept(x.operator=(x)));//true
PRINT_NOEXCEPT(noexcept(x.operator=(move(x))));//false
}
类型的析构函数以及delete运算符默认带有noexcept声明,即使自定义实现的析构函数也会默认带有noexcept声明,除非类型本身或者其基类和成员明确使用noexcept(false)声明析构函数(或delete运算符)
异常规范
在C++17标准之前,下面的代码在编译阶段不会出现问题,当时异常规范没有作为类型系统的一部分
void(*fp)() noexcept = nullptr;
void foo() {}
int main(){
fp = &foo;
}
上面的代码中fp是一个指向确保不抛出异常的函数的指针,而函数foo则没有不抛出异常的 保证。在C++17之前,它们的类型是相同的 这种宽松的规则会带来一些问题,例如一个会抛出异常的函数通过一个保证不抛出异常的函数指针进行调用,结果该函数确实抛出了异常,正常 流程本应该是由程序捕获异常并进行下一步处理,但是由于函数指针保证不会抛出异常,因此程序直接调用std::terminate函数中止了程序
为了解决此类问题,C++17标准将异常规范引入了类型系统。这样一来,fp = &foo就无法通 过编译了,因为fp和&foo变成了不同的类型 虽然类型系统引入异常规范导致noexcept声明的函数指针无法接受 没有noexcept声明的函数,但是反过来却是被允许的
void(*fp)() = nullptr;
void foo() noexcept {}
int main(){
fp = &foo;
}
虚函数的重写也遵守这个规则
class Base {
public:
virtual void foo() noexcept {}
};
class Derived : public Base {
public:
void foo() override {};
};
无法编译成功,因为派生类试图用没有声明noexcept的虚函数重写基类中声明 noexcept的虚函数但反过来是可以通过编译的
还需要注意模板带来的兼容性问题
void g1() noexcept {}
void g2() {}
template<class T> void f(T *, T *) {}
int main(){
f(g1, g2);
}
g1和g2已经是不同类型的函数,编译器无法推导出同一个模板参数,导致编译失败
线程局部存储
操作系统和编译器对线程局部存储的支持
线程局部存储是指对象内存在线程开始后分配,线程结束时回收且每个线程有该对象自己的 实例,简单地说,线程局部存储的对象都是独立于各个线程的。实际上,这并不是一个新鲜的概 念,虽然C++一直没有在语言层面支持它,但是很早之前操作系统就有办法支持线程局部存储 了。
由于线程本身是操作系统中的概念,因此线程局部存储这个功能是离不开操作系统支持的。 而不同的操作系统对线程局部存储的实现也不同,以至于使用的系统API也有区别,这里主要以 Windows和Linux为例介绍它们使用线程局部存储的方法。
在Windows中可以通过调用API函数TlsAlloc来分配一个未使用的线程局部存储槽索引(TLS slot index),这个索引实际上是Windows内部线程环境块(TEB)中线程局部存储数组的索引。 通过API函数TlsGetValue与TlsSetValue可以获取和设置线程局部存储数组对应于索引元素的值。 API函数TlsFree用于释放线程局部存储槽索引。
Linux使用了pthreads(POSIX threads)作为线程接口,在pthreads中我们可以调用 pthread_key_create与pthread_key_delete创建与删除一个类型为pthread_key_t的键。利用这个键可 以使用pthread_setspecific函数设置线程相关的内存数据,当然,我们随后还能够通过 pthread_getspecific函数获取之前设置的内存数据。
在C++11标准确定之前,各个编译器也用了自定义的方法支持线程局部存储。比如gcc和 clang添加了关键字__thread来声明线程局部存储变量,而Visual Studio C++则是使用 __declspec(thread)。虽然它们都有各自的方法声明线程局部存储变量,但是其使用范围和规则却 存在一些区别,这种情况增加了C++的学习成本,也是C++标准委员会不愿意看到的。于是在 C++11标准中正式添加了新的thread_local说明符来声明线程局部存储变量。
thread_local说明符
thread_local说明符可以用来声明线程生命周期的对象,它能与static或extern结合,分别指 定内部或外部链接,不过额外的static并不影响对象的生命周期。换句话说,static并不影响其线 程局部存储的属性
struct X {
thread_local static int i;
};
thread_local X a;
int main() {
thread_local X b;
}
声明一个线程局部存储变量相当简单,只需要在普通变量声明上添 加thread_local说明符。被thread_local声明的变量在行为上非常像静态变量,只不过多了线程属 性,当然这也是线程局部存储能出现在我们的视野中的一个关键原因,它能够解决全局变量或者 静态变量在多线程操作中存在的问题,一个典型的例子就是errno。
errno通常用于存储程序当中上一次发生的错误,早期它是一个静态变量,由于当时大多数程 序是单线程的,因此没有任何问题。但是到了多线程时代,这种errno就不能满足需求了。设想一下,一个多线程程序的线程A在某个时刻刚刚调用过一个函数,正准备获取其错误码,也正是这个 时刻,另外一个线程B在执行了某个函数后修改了这个错误码,那么线程A接下来获取的错误码自 然不会是它真正想要的那个。这种线程间的竞争关系破坏了errno的准确性,导致不可确定的结 果。为了规避由此产生的不确定性,POSIX将errno重新定义为线程独立的变量,为了实现这个定 义就需要用到线程局部存储,直到C++11之前,errno都是一个静态变量,而从C++11开始errno 被修改为一个线程局部存储变量。
在了解了线程局部存储的意义之后,让我们回头仔细阅读其定义,会发现线程局部存储只是 定义了对象的生命周期,而没有定义可访问性。也就是说,我们可以获取线程局部存储变量的地 址并将其传递给其他线程,并且其他线程可以在其生命周期内自由使用变量。不过这样做除了用 于诊断功能以外没有实际意义,而且其危险性过大,一旦没有掌握好目标线程的声明周期,就很 可能导致内存访问异常,造成未定义的程序行为,通常情况下是程序崩溃。
使用取地址运算符&取到的线程局部存储变量的地址是运行时被计算出来的, 它不是一个常量,也就是说无法和constexpr结合
线程局部存储对象的初始化和销毁
在同一个线程中,一个线程局部存储对 象只会初始化一次,即使在某个函数中被多次调用。这一点和单线程程序中的静态对象非常相 似。相对应的,对象的销毁也只会发生一次,通常发生在线程退出的时刻。
constexpr常量表达式
常量的不确定性
const只能变量只读和修饰常量,const修饰过的变量依旧是变量 在C++11标准以前,我们没有一种方法能够有效地要求一个变量或者函数在编译阶段就计算 出结果。由于无法确保在编译阶段得出结果,导致很多看起来合理的代码却引来编译错误。这些 场景主要集中在需要编译阶段就确定的值语法中,比如case语句、数组长度、枚举成员的值以及 非类型的模板参数。
const int index0 = 0;
#define index1 1
// case语句
switch (argc){
case index0:
cout << "index0" <<endl;
break;
case index1:
cout << "index1" <<endl;
break;
default:
cout << "none" <<endl;
}
const int x_size = 5 + 8;
#define y_size 6 + 7
// 数组长度
char buffer[x_size][y_size] = {0};
// 枚举成员
enum {
enum_index0 = index0,
enum_index1 = index1,
};
tuple<int, char> tp = make_tuple(4, '3');
// 非类型的模板参数
int x1 = get<index0>(tp);
char x2 = get<index1>(tp);
const定义的常量和宏都能在要求编译阶段确定值的语句中使用。其中宏在 编译之前的预处理阶段就被替换为定义的文字。而对于const定义的常量,上面这种情况下编译器 能在编译阶段确定它们的值,并在case语句以及数组长度等语句中使用。让人遗憾的是上面这些 方法并不可靠。首先,C++程序员应该尽量少使用宏,因为预处理器对于宏只是简单的字符替 换,完全没有类型检查,而且宏使用不当出现的错误难以排查。其次,对const定义的常量可能是一个运行时常量,这种情况下是无法在case语句以及数组长度等语句中使用的。 修改以上代码
int get_index0() {
return 0;
}
int get_index1() {
return 1;
}
int get_x_size() {
return 5 + 8;
}
int get_y_size() {
return 6 + 7;
}
int main() {
const int index0 = get_index0();
#define index1 get_index1()
switch (argc) {
case index0:
cout << "index0" << endl;
break;
case index1:
cout << "index1" << endl;
break;
default:
cout << "none" << endl;
}
const int x_size = get_x_size();
#define y_size get_y_size()
char buffer[x_size][y_size] = {0};
enum {
enum_index0 = index0,
enum_index1 = index1,
};
tuple<int, char> tp = make_tuple(4, '3');
int x1 = get<index0>(tp);
char x2 = get<index1>(tp);
}
我们这里做的修改仅仅是将宏定义为一个函数调用以及用一个函数将const变量进行初始化, 但是编译这段代码时会发现已经无法通过编译了。因为,无论是宏定义的函数调用,还是通过函 数返回值初始化const变量都是在运行时确定的。
像上面这种尴尬的情况不仅可能出现在我们的代码中,实际上标准库中也有这样的情况,其 中就是一个典型的例子。在C语言中存在头文件,在这个头文件中用宏定义了各种整型类型的最大值和最小值
#define UCHAR_MAX 0xff // unsigned char类型的最大值
我们可以用这些宏代替数字,让代码有更好的可读性。这其中就包括要求编译阶段必须确定 值的语句,例如定义一个数组:
char buffer[UCHAR_MAX] = { 0 };
标准库 为我们提供了一个< limit>,使用它同样能获得unsigned char类型的最大值
std::numeric_limits::max()
但是,如果想用它来声明数组的大小是无法编译成功的 原因和之前讨论过的一样,std max()函数的返回值必须在 运行时计算 为了解决以上常量无法确定的问题,C++标准委员会决定在C++11标准中定义一个新的关键 字constexpr,它能够有效地定义常量表达式,并且达到类型安全、可移植、方便库和嵌入式系统开发的目的。
constexpr值
constexpr值即常量表达式值,是一个用constexpr说明符声明的变量或者数据成员,它要求该 值必须在编译期计算。另外,常量表达式值必须被常量表达式初始化。
constexpr int x = 42; char buffer[x] = { 0 };
以上代码定义了一个常量表达式值x,并将其初始化为42,然后用x作为数组长度定义了数组 buffer。从这段代码来看,constexpr和const是没有区别的,我们将关键字替换为const同样能达到目的 从结果来看确实如此,在使用常量表达式初始化的情况下constexpr和const拥有相同的作用。 但是const并没有确保编译期常量的特性,所以在下面的代码中,它们会有不同的表现:
int x1 = 42;
const int x2 = x1; // 定义和初始化成功
char buffer[x2] = { 0 }; // 编译失败,x2无法作为数组长度
在上面这段代码中,虽然x2初始化编译成功,但是编译器并不一定把它作为一个编译期需要确定的值,所以在声明buffer的时候会编译错误。
int x1 = 42; constexpr int x2 = x1; // 编译失败,x2无法用x1初始化
char buffer[x2] = { 0 };
修改后,编译器编译第二句代码的时候就会报错,因为常量表达式值必须由常量表达式初始化,而x1并不是常量,明确地违反了constexpr的规则,编译器自然就会报错。可以看出,constexpr是一个加强版的const,它不仅要求常量表达式是常量,并且要求是一个编译阶段就 能够确定其值的常量。
constexpr函数
constexpr不仅能用来定义常量表达式值,还能定义一个常量表达式函数,即constexpr函数, 常量表达式函数的返回值可以在编译阶段就计算出来。不过在定义常量表示函数的时候,我们会遇到更多的约束规则
- 函数必须返回一个值,所以它的返回值类型不能是void。
- 函数体必须只有一条语句:return expr,其中expr必须也是一个常量表达式。如果函数有 形参,则将形参替换到expr中后,expr仍然必须是一个常量表达式。
constexpr int max_unsigned_char() {
return 0xff;
}
constexpr int square(int x) {
return x * x;
}
constexpr int abs(int x) {
return x > 0 ? x : -x;
}
int main() {
char buffer1[max_unsigned_char()] = {0};
char buffer2[square(5)] = {0};
char buffer3[abs(-8)] = {0};
}
定义了3个常量表达式函数,由于它们的返回值能够在编译期计算出来,因此可以 直接将这些函数的返回值使用在数组长度的定义上。需要注意的是square和abs两个函数,它们接 受一个形参x,当x确定为一个常量时(这里分别是5和−8),其常量表达式函数也就成立了。我们 通过abs可以发现一个小技巧,由于标准规定函数体中只能有一个表达式return expr,因此是无法 使用if语句的,幸运的是用条件表达式也能完成类似的效果
反例
constexpr void foo() {}
constexpr int next(int x) {return ++x;}
int g() {return 42;}
constexpr int f() {return g();}
constexpr int max_unsigned_char2();
enum {
max_uchar = max_unsigned_char2()
};
constexpr int abs2(int x) {
if (x > 0) {
return x;
} else {
return -x;
}
}
constexpr int sum(int x) {
int result = 0;
while (x > 0) {
result += x--;
}
return result;
}
以上constexpr函数都会编译失败。其中函数foo的返回值不能为void,next函数体中的++x和f 中的g()都不是一个常量表达式,函数max_unsigned_ char2只有声明没有定义,函数abs2和sum不能 有多条语句。我们注意到abs2中if语句可以用条件表达式替换,可是sum函数这样的循环结构有办 法替换为单语句吗?答案是可以的,我们可以使用递归来完成循环的操作,现在就来重写sum函数
constexpr int sum(int x) {
return x > 0 ? x + sum(x - 1) : 0;
}
于是我们能通过递归调用sum函数完成循环计算的任务。有趣的是,在刚开始提出常量表达式函数的时候
虽然常量表达式函数的返回值可以在编译期计算出来,但是这个行为并不是确定的。例如,当带形参的常量表达式函数接受了一个非常量实参时,常量表达式函数可能会退化为普通函数
constexpr int square(int x){
return x * x;
}
int main() {
int x = 5;
cout << square(x);
}
由于x不是一个常量,因此square的返回值也可能无法在编译期确定,但是它依然能成功 编译运行,因为该函数退化成了一个普通函数。这种退化机制对于程序员来说是非常友好的,它 意味着我们不用为了同时满足编译期和运行期计算而定义两个相似的函数。另外,这里也存在着 不确定性,因为GCC依然能在编译阶段计算square的结果,但是MSVC和CLang则不行。
有了常量表达式函数的支持,C++标准对STL也做了一些改进,比如在中增加了 constexpr声明,正因如此下面的代码也可以顺利编译成功了
char buffer[std::numeric_limits::max()] = { 0 };
constexpr构造函数
constexpr可以声明基础类型从而获得常量表达式值,除此之外constexpr还能够声明用户自定义类型
struct X {
int x1;
};
int main() {
constexpr X x = { 1 };
char buffer[x.x1] = { 0 };
}
有时候我们并不希望成员变量被暴露出来,于是修改了X的结构
class X {
private:
int x1;
public:
X() : x1(5) {}
int get() const{
return x1;
}
};
int main(){
constexpr X x; // 编译失败,X不是字面类型
char buffer[x.get()] = { 0 }; // 编译失败,x.get()无法在编译阶段计算
}
constexpr说明符不能用来声明这样的自定义类型。解 决上述问题的方法很简单,只需要用constexpr声明X类的构造函数,也就是声明一个常量表达式构造函数 这个构造函数也有一些规则需要遵循
- 构造函数必须用constexpr声明。
- 构造函数初始化列表中必须是常量表达式。
- 构造函数的函数体必须为空(这一点基于构造函数没有返回值,所以不存在return expr)。
class X {
private:
int x1;
public:
constexpr X() : x1(5) {}
constexpr X(int i) : x1(i) {}
constexpr int get() const{
return x1;
}
};
int main(){
constexpr X x;
char buffer[x.get()] = { 0 };
}
它们本身都符合常量表达式构造函数和常量表达式函数的要求,我们称这样的类为字面量类型 其实代码中constexpr int get()const的const有点多余,因为在C++11 中,constexpr会自动给函数带上const属性。 常量表达式构造函数拥有和常量表达式函数 相同的退化特性,当它的实参不是常量表达式的时候,构造函数可以退化为普通构造函数,这么做的前提是类型的声明对象不能为常量表达式值
int i = 8;
constexpr X x(i); // 编译失败,不能使用constexpr声明
X y(i); // 编译成功
由于i不是一个常量,因此X的常量表达式构造函数退化为普通构造函数,这时对象x不能用 constexpr声明,否则编译失败。
使用constexpr声明自定义类型的变量,必须确保这个自定义类型的析构 函数是平凡的,否则也是无法通过编译的。平凡析构函数必须满足下面3个条件。
- 自定义类型中不能有用户自定义的析构函数。
- 析构函数不能是虚函数。
- 基类和成员的析构函数必须都是平凡的。
C++14标准对常量表达式函数的增强
C++11标准对常量表达式函数的要求非常的严格,这一点影响该特性的实用性。这个问题在C++14中得到了非常巨大的改善
- 函数体允许声明变量,除了没有初始化、static和thread_local变量。
- 函数允许出现if和switch语句,不能使用go语句。
- 函数允许所有的循环语句,包括for、while、do-while。
- 函数可以修改生命周期和常量表达式相同的对象。
- 函数的返回值可以声明为void。
- constexpr声明的成员函数不再具有const属性。
对于常量表达式函数的增强同样也会影响常量表达式构造函数
class X {
private:
int x1;
public:
constexpr X() : x1(5) {}
constexpr X(int i) : x1(0) {
if (i > 0) {
x1 = 5;
} else {
x1 = 8;
}
}
constexpr void set(int i) {
x1 = i;
}
constexpr int get() const {
return x1;
}
};
constexpr X make_x() {
X x;
x.set(42);
return x;
}
int main() {
constexpr X x1(-1);
constexpr X x2 = make_x();
constexpr int a1 = x1.get();
constexpr int a2 = x2.get();
cout << a1 << endl;
cout << a2 << endl;
}
constexpr lambdas表达式
从C++17开始,lambda表达式在条件允许的情况下都会隐式声明为constexpr。这里所说的条件,即是前面提到的常量表达式函数的规则
constexpr int foo(){
return []() { return 58; }();
}
auto get_size = [](int i) { return i * 2; };
char buffer1[foo()] = { 0 };
char buffer2[get_size(5)] = { 0 };
lambda表达式却可以用在常量表达式函数和数组长度中,可见该lambda表达式的结果在编译阶段已经计算出来了。 当lambda表达式不满足constexpr的条件时,lambda表达式也不会出现编译错误,它会作为运行 时lambda表达式存在
我们也可以强制要求lambda表达式是一个常量表达式,用constexpr去声明它 即可。这样做的好处是可以检查lambda表达式是否有可能是一个常量表达式,如果不能则会编译报错
auto get_size = [](int i) constexpr -> int { return i * 2; };
char buffer2[get_size(5)] = { 0 };
auto get_count = []() constexpr -> int {
static int x = 5; // 编译失败,x是一个static变量
return x;
};
int a2 = get_count();
constexpr的内联属性
在C++17标准中,constexpr声明静态成员变量时,也被赋予了该变量的内联属性
class X {
public:
static constexpr int num{ 5 };
};
代码中,num是只有声明没有定义的,虽然我们可以通过stdnum << stdnum直接替换为了5。如果将输出语句修改为 stdnum << stdnum缺少定义。但是从C++17开始情 况发生了变化,static constexpr int num{5}既是声明也是定义,所以在C++17标准中stdnum << stdnum产生定义并不是必需的,如果代码只是引用了X::num的值,那么编译器完全可以使用直接 替换为值的技巧。只有当代码中引用到变量指针的时候,编译器才会为其生成定义。
if constexpr
if constexpr是C++17标准提出的一个非常有用的特性,可以用于编写紧凑的模板代码,让代码能够根据编译时的条件进行实例化。
- if constexpr的条件必须是编译期能确定结果的常量表达式。
- 条件结果一旦确定,编译器将只编译符合条件的代码块。
该特性只有在使用模板的时候才具有实际意义
void check1(int i) {
if constexpr (i > 0) { // 编译失败,不是常量表达式
cout << "i > 0" << endl;
} else {
cout << "i <= 0" << endl;
}
}
void check2() {
if constexpr (sizeof(int) > sizeof(char)) {
cout << "sizeof(int) > sizeof(char)" << endl;
} else {
cout << "sizeof(int) <= sizeof(char)" << endl;
}
}
对于函数check1,由于if constexpr的条件不是一个常量表达式,因此无法编译通过。而对于 函数check2,这里的代码最后会被编译器省略为:
void check2() {
cout << "sizeof(int) > sizeof(char)" << endl;
}
template<class T>
bool is_same_value(T a, T b) {
return a == b;
}
template<>
bool is_same_value<double>(double a, double b) {
if (std::abs(a - b) < 0.0001) {
return true;
} else {
return false;
}
}
int main() {
double x = 0.1 + 0.1 + 0.1 - 0.3;
cout << boolalpha;
cout << "is_same_value(5, 5) : " << is_same_value(5, 5) << endl;
cout << "x == 0.0 : " << (x == 0.) << endl;
cout << "is_same_value(x, 0.) : " << is_same_value(x, 0.) << endl;
}
浮点数的比较和整数是不同的,通常情况下它们的差小于某个阈值就认为两个浮点 数相等。我们把is_same_value写成函数模板,并且对double类型进行特化。这里如果使用if constexpr表达式,代码会简化很多而且更加容易理解
template<class T>
bool is_same_value(T a, T b) {
if constexpr (std::is_same<T, double>::value) {
if (std::abs(a - b) < 0.0001) {
return true;
} else {
return false;
}
} else {
return a == b;
}
}
直接使用if constexpr判断模板参数是否为double,如果条件成立,则使 用double的比较方式;否则使用普通的比较方式,代码变得简单明了。再次强调,这里的选择是 编译期做出的,一旦确定了条件,那么就只有被选择的代码块才会被编译;另外的代码块则会被忽略。
constexpr虚函数
在C++20标准之前,虚函数是不允许声明为constexpr的。看似有道理的规则其实并不合理, 因为虚函数很多时候可能是无状态的,这种情况下它是有条件作为常量表达式被优化的 C++20标准明确允许在常量表达式中使用虚函数
constexpr函数中的try……catch
C++20标准允许Try-catch出现在constexpr函数中但是throw语句依旧是被禁止的,所以try语句是不能抛出异常的,这 也就意味着catch永远不会执行。实际上,当函数被评估为常量表达式的时候Try-catch是没有任何作用的。
在constexpr中进行平凡的默认初始化
C++20标准允许在constexpr中进行平凡的默认初始化,这样进一步减少constexpr的特殊性。
struct X {
bool val;
};
constexpr void f() {
X x;
}
int main(){
f();
}
C++17无法编译,但C++20可以编译 虽然标准放松了对constexpr上下文对象默认初始化的要求,但是我们依然应该养成声明对象时随手初始 化的习惯,避免让代码出现未定义的行为。
在constexpr中更改联合类型的有效成员
C++20标准允许
union Foo {
int i;
float f;
};
constexpr int use() {
Foo foo{};
foo.i = 3;
foo.f = 1.2f; // C++20之前编译失败
return 1;
}
除了上面提到的修改以外,还修改了一些并不常用的 地方,包括允许dynamic_cast和typeid出现在常量表达式中;允许在constexpr函数使用未经评估的内联汇编。
consteval说明符
constexpr声明函数时并不依赖常量表达式上下文环境,在非常量表达式 的环境中,函数可以表现为普通函数。 有时候,我们希望确保函数在编译期就执行计算,对 于无法在编译期执行计算的情况则让编译器直接报错。 于是在C++20标准中出现了一个新的概 念——立即函数,该函数需要使用consteval说明符来声明
consteval int sqr(int n) {
return n*n;
}
constexpr int r = sqr(100); // 编译成功
int x = 100;
int r2 = sqr(x); // 编译失败
如果一个立即函数在另外一个立即函数中被调用,则函数定义时的 上下文环境不必是一个常量表达式
consteval int sqrsqr(int n) {
return sqr(sqr(n));
}
sqrsqr是否能编译成功取决于如何调用,如果调用时处于一个常量表达式环境,那么就能通 过编译,反之则编译失败
int y = sqrsqr(100);//ok
int y = sqrsqr(x);//error
lambda表达式也可以使用consteval说明符
auto sqr = [](int n) consteval { return n * n; };
int r = sqr(100);
auto f = sqr; // 编译失败,尝试获取立即函数的函数地址
constinit说明符
在C++中有一种典型的错误叫作“Static Initialization Order Fiasco”,指的是因为静态初始 化顺序错误导致的问题。因为这种错误往往发生在main函数之前,所以比较难以排查。 假设有两个静态对象x和y分别存在于两个不同的源文件中。其中一个对象x的构造函数 依赖于对象y。这样我们有50%的可能性会出错,因为我们没有办法控制哪个对 象先构造。如果对象x在y之前构造,那么就会引发一个未定义的结果。 为了避免这种问题的发生,我们通常希望使用常量初始化程序去初始化静态变量。不幸的是,常量初始化的规则很复 杂,需要一种方法帮助我们完成检查工作,当不符合常量初始化程序的时候可以在编译阶段报 错。于是在C++20标准中引入了新的constinit说明符。
主要用于具有静态存储持续时间的变量声明上,它要求变量具有常量初始化程序。 constinit说明符作用的对象是必须具有静态存储持续时间的
constinit int x = 11; // 编译成功,全局变量具有静态存储持续
int main() {
constinit static int y = 42; // 编译成功,静态变量具有静态存储持续
constinit int z = 7; // 编译失败,局部变量是动态分配的
}
constinit要求变量具有常量初始化程序
const char* f() { return "hello"; }
constexpr const char* g() { return "cpp"; }
constinit const char* str1 = f(); // 编译错误,f()不是一个常量初始化程序
constinit const char* str2 = g(); // 编译成功
constinit还能用于非初始化声明,以告知编译器thread_local变量已被初始化
extern thread_local constinit int x;
int f() { return x; }
虽然constinit说明符一直在强调常量初始化,但是初始化的对象并不要求具有常量属性。
判断常量求值环境
std::is_constant_evaluated是C++20新加入标准库的函数,它用于检查当前表达式是否是一 个常量求值环境,如果在一个明显常量求值的表达式中,则返回true;否则返回false。该函数包 含在头文件中,虽然看上去像是一个标准库实现的函数,但实际上调用的是编译器内置函数:
constexpr inline bool is_constant_evaluated() noexcept{
return __builtin_is_constant_evaluated();
}
该函数通常会用于代码优化中,比如在确定为常量求值的环境时,使用constexpr能够接受的 算法,让数值在编译阶段就得出结果。而对于其他环境则采用运行时计算结果的方法。
constexpr double power(double b, int x) {
if (std::is_constant_evaluated() && x >= 0) {
double r = 1.0, p = b;
unsigned u = (unsigned) x;
while (u != 0) {
if (u & 1) r *= p;
u /= 2;
p *= p;
}
return r;
} else {
return std::pow(b, (double) x);
}
}
int main() {
constexpr double kilo = power(10.0, 3); // 常量求值
int n = 3;
double mucho = power(10.0, n); // 非常量求值
return 0;
}
power函数根据stdis_ constant_evaluated() && x >= 0返回true,编译器在编译阶段求出结果。反之,mucho = power(10.0, n)则需要调用std::pow在运行时求值。
常量求值在标准文 档中列举了下面几个类别。
- 常量表达式,这个类别包括很多种情况,比如数组长度、case表达式、非类型模板实参等。
- if constexpr语句中的条件。
- constexpr变量的初始化程序。
- 立即函数调用。
- 约束概念表达式。
- 可在常量表达式中使用或具有常量初始化的变量初始化程序。
override和final
重写、重载和隐藏
- 重写(override)的意思更接近覆盖,在C++中是指派生类覆盖了基类的虚函数,这里的 覆盖必须满足有相同的函数签名和返回类型,也就是说有相同的函数名、形参列表以及返回类 型。
- 重载(overload),它通常是指在同一个类中有两个或者两个以上函数,它们的函数名相 同,但是函数签名不同,也就是说有不同的形参。这种情况在类的构造函数中最容易看到,为了 让类更方便使用,我们经常会重载多个构造函数。
- 隐藏(overwrite)的概念也十分容易与上面的概念混淆。隐藏是指基类成员函数,无论 它是否为虚函数,当派生类出现同名函数时,如果派生类函数签名不同于基类函数,则基类函数 会被隐藏。如果派生类函数签名与基类函数相同,则需要确定基类函数是否为虚函数,如果是虚 函数,则这里的概念就是重写;否则基类函数也会被隐藏。另外,如果还想使用基类函数,可以 使用using关键字将其引入派生类。
重写引发的问题
重写虚函数很容易出现错误,原因是C++语法对重写的要求很高,稍不注意就会无法重写基类虚函数。更糟糕的是,即使我们写错了代码,编译器也可能不会提示任何错误信息,直到程序编译成功后,运行测试才会发现其中的逻辑问题
class Base {
public:
virtual void some_func() {}
virtual void foo(int x) {}
virtual void bar() const {}
void baz() {}
};
class Derived : public Base {
public:
virtual void sone_func() {}
virtual void foo(int &x) {}
virtual void bar() {}
virtual void baz() {}
};
以上代码可以编译成功,但是派生类Derived的4个函数都没有触发重写操作。第一个派生类 虚函数sone_func的函数名与基类虚函数some_func不同,所以它不是重写。第二个派生类虚函数 foo(int &x)的形参列表与基类虚函数foo(int x)不同,所以同样不是重写。第三个派生类虚函数 bar()相对于基类虚函数少了常量属性,所以不是重写。最后的基类成员函数baz根本不是虚函数, 所以派生类的baz函数也不是重写。
override重写说明符
用于重写虚函数,并不是必须的
class A{
public:
virtual void test(){cout<<"A class"<<endl;}
};
class B:public A {
public:
void test()override{cout<<"B class"<<endl;}
};
在不出错的情况下重写不加override不会发生任何事情 如果在重写时不小心把函数名写错不写override编译器就会认为写了一个新函数而不是重写。写了override后,如果函数名写错,编译器就会报错,帮助纠正。
final
能用于修饰类或函数,修饰函数只能修饰虚函数,并且要把final关键字放在类或者函数的参数列表后面 用于限制某个类不能被继承,或者某个虚函数不能被重写 final声明表示此次之后不能重写,此次能重写
class A{
public:
virtual void test(){cout<<"A class"<<endl;}
};
class B:public A{
public:
void test(){cout<<"B class"<<endl;}
};
class C:public B{
public:
void test(){cout<<"C class"<<endl;}
};
如果在class B中的test函数加入final,class C中的test重写会报错
class B:public A{
public:
void test()final{cout<<"B class"<<endl;}
};
final说明符不仅能声明函数,也可以用于声明类,被声明的类不可被继承 如果在B继承A时使用final,则B不能被继承
class B final :public A {};
override和final关键字可以同时使用
noexcept
使用throw声明函数是否抛出异常一直没有什么问题,直到C++11标准引入了移动构造函数。 移动构造函数中包含着一个严重的异常陷阱。 当我们将一个容器的元素移动到另外一个新的容器中时。在C++11之前,由于没有移动语义,我们只能将原始容器的数据复制到新容器中。如果在数据复制的过程中复制构造函数发生了 异常,那么我们可以丢弃新的容器,保留原始的容器。在这个环境中,原始容器的内容不会有任何变化。 但是有了移动语义,原始容器的数据会逐一地移动到新容器中,如果数据移动的途中发生异 常,那么原始容器也将无法继续使用,因为已经有一部分数据移动到新的容器中。如果发生异常就做一个反向移动操作,恢复原始容器的内容并不可靠,因为我们无法保证恢复的过程中不会抛出异常。 throw并不能根据容器中移动的元素是否会抛出异常来确定移动构造函数是否允许抛出异常。但noexcept()
作为运算符时可以做到。
noexcept 编译期完成声明和检查工作.noexcept 主要是解决的问题是减少运行时开销. 运行时开销指的是, 编译器需要为代码生成一些额外的代码用来包裹原始代码,当出现异常时可以抛出一些相关的堆栈stack unwinding错误信息, 这里面包含,错误位置, 错误原因, 调用顺序和层级路径等信息.当使用noexcept声明一个函数不会抛出异常候, 编译器就不会去生成这些额外的代码, 直接的减小的生成文件的大小, 间接的优化了程序运行效率.
noexcept只是告诉编译器不会抛出异常,但函数不一定真的不会抛出异常。该符号只是一种指示符号,不是承诺。
noexcept是一个与异常相关的关键字,它既是一个说明符,也是一个运算符。作为说明符,它能够用来说明函数是否会抛出异常
当 noexcept 是标识符时, 它的作用是在函数后面声明一个函数是否会抛出异常. 当noexcept 是函数时, 它的作用是检查一个函数是否会抛出异常.
传入的表达式的结果是在编译时计算的,表达式必须是一个常量表达式 是一种不求值表达式,即不会执行表达式。
1 . noexcept 标识符
noexcept 标识符有几种写法: noexcept、noexcept(true)、noexcept(false)、noexcept(expression)、throw()
其中 noexcept 默认表示 noexcept(true) 当 noexcept 是 true 时表示函数不会抛出异常 当 noexcept 是 false 时表示函数可能会抛出异常 throw()表示函数可能会抛出异常, 相当于noexcept(false),C++20 放弃这种写法 常量表达式的结果会被转换成一个 bool 类型的值,该值为 true,表示函数不会抛出异常
noexcept
函数用来检查一个函数是否声明了 noexcept
, 如果声明了noexcept(true)
则返回true
, 如果声明了noexcept(false)
则返回false
// noexcept 作为标识符
void foo() noexcept{throw 4;}
// noexcept 作为标识符
void bar() noexcept(false) {throw 4;}
// noexcept 作为标识符
void fun(){throw 4;}
int main() {
// noexcept 函数
cout << boolalpha << noexcept(foo()) << endl;// true
cout << boolalpha << noexcept(bar()) << endl;// false
cout << boolalpha << noexcept(fun()) << endl;// false
return 0;
}
用noexcept优化数据拷贝函数
对于一个类型T 如果T是一个普通的编译器内置类型,那么该函数永远不会抛出异常,可以直接使用, 假如T是一个很复杂的类型,那么在拷贝的过程中,很有可能抛出异常,直接声明noexcept会导致当函数遇到异常的时候程序被终止,而不给我们处理异常的机会 只有在T是一个基础类型 时复制函数才会被声明为noexcept,因为基础类型的复制是不会发生异常的。
template <typename T>
T copy(const T& s) noexcept(is_fundamental<T>::value){}
stdvalue 用来判断类型是一个普通类型还是复杂的类型,如果是普通类型,返回true,则表示不会抛出异常,否则将表示可能会抛出异常。 但很多自定义类型的拷贝构造也是很简单的,几乎不会抛出异常,我们可以利用noexcept运算符的能力,判断类型的拷贝构造是否会抛出异常。
template <typename T>
T copy(const T& s) noexcept(noexcept(T(s))){}
先判断T(s) 拷贝构造函数是否会抛异常.如果不会,则返回false,此时函数定义如下,表示可能抛出异常,否则相反。
T copy(const T& s) noexcept(false){}
用noexcept解决移动构造问题
noexcept()可以判断目标类型的移动构造函数是否可能抛出异常,那么我们可以先判断有没有抛出异常的可能,如果有,那么使用传统的复制操作,否则执行移动构造。
以swap函数为例
template<class T>
void swap(T& a, T& b)
noexcept(noexcept(T(move(a))) && noexcept(a.operator=(move(b))))
{
T tmp(move(a));
a = move(b);
b = move(tmp);
}
参数列表后使用noexcept检查类型T的移动构造函数和移动赋值函数是否都不会抛出异常; 函数体通过移动构造函数和移动赋值函数移动对象a和b。 使用noexcept的好处在于,它让编译器可以根据类型移动函数是否抛出异常来选择不同的优化策略。但是这个函数并没有解决上面容器移动的问题。 改进swap函数
template<class T>
void swap(T& a, T& b)
noexcept(noexcept(T(move(a))) && noexcept(a.operator=(move(b))))
{
static_assert(noexcept(T(move(a)))&& noexcept(a.operator=(move(b))));
T tmp(move(a));
a = move(b);
b = move(tmp);
}
改进版的swap在函数内部使用static_assert对类型T的移动构造函数和移动赋值函数进行检 查,如果其中任何一个抛出异常,那么函数会编译失败。使用这种方法可以迫使类型T实现不抛出 异常的移动构造函数和移动赋值函数。
想要在不满足移动要求的时候,有选择地使用复制方法完成移动操作。
struct X {
X() {}
X(X &&) noexcept {}
X(const X &) {}
X operator= (X &&) noexcept { return *this; }
X operator= (const X &) { return *this; }
};
struct X1 {
X1() {}
X1(X1 &&) {}
X1(const X1 &) {}
X1 operator= (X1 &&) { return *this; }
X1 operator= (const X1 &) { return *this; }
};
template<typename T>
void swap_impl(T& a, T& b, integral_constant<bool, true>) noexcept
{
T tmp(move(a));
a = move(b);
b = move(tmp);
}
template<typename T>
void swap_impl(T& a, T& b, integral_constant<bool, false>)
{
T tmp(a);
a = b;
b = tmp;
}
template<typename T>
void swap(T& a, T& b)
noexcept(noexcept(swap_impl(a, b,integral_constant<bool, noexcept(T(move(a)))&& noexcept(a.operator=(move(b)))>())))
{
swap_impl(a, b, integral_constant<bool, noexcept(T(move(a)))&& noexcept(a.operator=(move(b)))>());
}
int main()
{
X x1, x2;
swap(x1, x2);
X1 x3, x4;
swap(x3, x4);
}
以上代码实现了两个版本的swap_impl,它们的形参列表的前两个形参是相同的,只有第三个 形参类型不同。第三个形参为stdintegral_ constant的函数则会使用复制的方法来交换数 据。swap函数会调用swap_impl,并且以移动构造函数和移动赋值函数是否会抛出异常为模板实参 来实例化swap_impl的第三个参数。这样,不抛出异常的类型会实例化一个类型为 std::integral_constant的对象,并调用使用移动方法的swap_impl;反之则调用使用复 制方法的swap_impl。
noexcept和throw()
如果用noexcept运算符去探测noexcept和throw()声明的函数,会返回相同的结果。
在C++11标准中,它们在实现上确实是有一些差异的。如果一个函数在声明了 noexcept的基础上抛出了异常,那么程序将不需要展开堆栈,并且它可以随时停止展开。另外,它 不会调用stdterminate结束程序。而throw()则需要展开堆栈,并调用 std::unexpected。这些差异让使用noexcept程序拥有更高的性能。 在C++17标准中,throw()成为 noexcept的一个别名,throw()和noexcept拥有了同样的行为和实现。且只有throw()被保留了下来,其他用throw声明函数抛出异常的方法都被移除了。 在C++20中 throw()也被标准移除了
默认使用noexcept的函数
自定义实现的函数默认不会带有noexcept声明
默认构造函数、默认复制构造函数、默认赋值函数、默认移动构造函数和默认移动赋值函数。默认带有noexcept声明
struct X {
};
#define PRINT_NOEXCEPT(x) \
cout << #x << " = " << x << endl
int main(){
X x;
cout << boolalpha;
PRINT_NOEXCEPT(noexcept(X()));//true
PRINT_NOEXCEPT(noexcept(X(x)));//true
PRINT_NOEXCEPT(noexcept(X(move(x))));//true
PRINT_NOEXCEPT(noexcept(x.operator=(x)));//true
PRINT_NOEXCEPT(noexcept(x.operator=(move(x))));//true
}
但对应的函数在类型的基类和成员中也具有noexcept声明,否则其对应函数将不再默认带有noexcept声明。
struct M {
M() {}
M(const M&) {}
M(M&&) noexcept {}
M operator= (const M&) noexcept { return *this; }
M operator= (M&&) { return *this; }
};
struct X {
M m;
};
#define PRINT_NOEXCEPT(x) \
cout << #x << " = " << x << endl
int main(){
X x;
cout << boolalpha;
PRINT_NOEXCEPT(noexcept(X()));//false
PRINT_NOEXCEPT(noexcept(X(x)));//false
PRINT_NOEXCEPT(noexcept(X(move(x))));//true
PRINT_NOEXCEPT(noexcept(x.operator=(x)));//true
PRINT_NOEXCEPT(noexcept(x.operator=(move(x))));//false
}
类型的析构函数以及delete运算符默认带有noexcept声明,即使自定义实现的析构函数也会默认带有noexcept声明,除非类型本身或者其基类和成员明确使用noexcept(false)声明析构函数(或delete运算符)
异常规范
在C++17标准之前,下面的代码在编译阶段不会出现问题,当时异常规范没有作为类型系统的一部分
void(*fp)() noexcept = nullptr;
void foo() {}
int main(){
fp = &foo;
}
上面的代码中fp是一个指向确保不抛出异常的函数的指针,而函数foo则没有不抛出异常的 保证。在C++17之前,它们的类型是相同的 这种宽松的规则会带来一些问题,例如一个会抛出异常的函数通过一个保证不抛出异常的函数指针进行调用,结果该函数确实抛出了异常,正常 流程本应该是由程序捕获异常并进行下一步处理,但是由于函数指针保证不会抛出异常,因此程序直接调用std::terminate函数中止了程序
为了解决此类问题,C++17标准将异常规范引入了类型系统。这样一来,fp = &foo就无法通 过编译了,因为fp和&foo变成了不同的类型 虽然类型系统引入异常规范导致noexcept声明的函数指针无法接受 没有noexcept声明的函数,但是反过来却是被允许的
void(*fp)() = nullptr;
void foo() noexcept {}
int main(){
fp = &foo;
}
虚函数的重写也遵守这个规则
class Base {
public:
virtual void foo() noexcept {}
};
class Derived : public Base {
public:
void foo() override {};
};
无法编译成功,因为派生类试图用没有声明noexcept的虚函数重写基类中声明 noexcept的虚函数但反过来是可以通过编译的
还需要注意模板带来的兼容性问题
void g1() noexcept {}
void g2() {}
template<class T> void f(T *, T *) {}
int main(){
f(g1, g2);
}
g1和g2已经是不同类型的函数,编译器无法推导出同一个模板参数,导致编译失败
alignas和alignof
数据对齐问题
alignof运算符可以用于获取类型的对齐字节长度 alignas说明符可以用来改变类型的默认对齐字节长度 这两个关键字的出现解决了长期 以来C++标准中无法对数据对齐进行处理的问题。
一个类型的属性除了其数据长度,还有一个重要的属性——数据对齐的字节长度。
CPU对数据对齐有着迫切的需求, 一个好的对齐字节长度可以让CPU运行起来更加轻松快速。反过来说,不好的对齐字节长度则会 让CPU运行速度减慢,甚至抛出错误。通常来说所谓好的对齐长度和CPU访问数据总线的宽度有 关系,比如CPU访问32位宽度的数据总线,就会期待数据是按照32位对齐,也就是4字节。这样 CPU读取4字节的数据只需要对总线访问一次,但是如果要访问的数据并没有按照4字节对齐,那 么CPU需要访问数据总线两次,运算速度自然也就减慢了。另外,对于数据对齐问题引发错误的 情况(Alignment Fault),通常会发生在ARM架构的计算机上。 除了CPU之外,还有其他硬件也需要数据对齐,比如通过DMA访问硬盘,就会要求内存必须是4K对齐的。总的来说,配合现 代编译器和CPU架构,可以让程序获得令人难以置信的性能,但这种良好的性能取决于某些编程 实践,其中一种编程实践是正确的数据对齐。
alignof运算符
获得类型的对齐字节长度 返回值类型是size_t
auto x1 = alignof(int);
auto x2 = alignof(void(*)());
int a = 0;
auto x3 = alignof(a); // *C++标准不支持这种用法
cout<<x1<<endl;
cout<<x2<<endl;
cout<<x3<<endl;
alignof的计算对象并不是一个类型,而是一个变量。但是C++标准 规定alignof必须是针对类型的。不过GCC扩展了这条规则,alignof除了能接受一个类型外还能接受一个变量
可以结合decltype
int a = 0;
auto x3 = alignof(decltype(a));
但实际情况是,这种做法只有在类型使用默认对齐的时候才是正确的,如果用在下面的情况中会产生错误的结果:
alignas(8) int a = 0;
auto x3 = alignof(decltype(a)); // 错误的返回4,而并非设置的8
还可以通过alignof获得类型stdmax_align_t的对齐字节长度。 C++标准还规定,诸如new和malloc之类的分配函数返回的指针需要适合于任何对象,也就是说内 存地址至少与stdmax_ align_t对齐字节长度具体是什么样的,因此不同的平台会有不同的值,通常情况下是8字节和16字节。
for (int i = 0; i < 100; i++) {
auto *p = new char();
auto addr = reinterpret_cast<uintptr_t>(p);
cout << addr % alignof(max_align_t) << endl;
delete p;
}
编译运行以上代码,会发现输出的都是0,也就是说即使我们分配的是1字节的内存,内存分 配器也会将指针定位到与std::max_align_t对齐的地方。
#define SHOW_SIZEOF_AND_ALIGNOF(T) \
do { \
cout << "sizeof(" << #T << "):\t" << sizeof(T) << ",\t"\
<< "alignof(" << #T << "):\t" << alignof(T) << endl;\
}while(0)
int main() {
SHOW_SIZEOF_AND_ALIGNOF(char);
SHOW_SIZEOF_AND_ALIGNOF(int);
SHOW_SIZEOF_AND_ALIGNOF(long);
SHOW_SIZEOF_AND_ALIGNOF(float);
SHOW_SIZEOF_AND_ALIGNOF(double);
return 0;
}
alignas说明符
该说明符可以接受类型或者常量表达式 该常量表达式计算的结果必须是一个2的幂值,否则是无法通过编译的。
struct X {
char a1;
int a2;
double a3;
};
struct X1 {
alignas(16) char a1;
alignas(double) int a2;
double a3;
};
struct alignas(16) X2 {
char a1;
int a2;
double a3;
};
struct alignas(16) X3 {
alignas(8) char a1;
alignas(double) int a2;
double a3;
};
struct alignas(4) X4 {
alignas(8) char a1;
alignas(double) int a2;
double a3;
};
#define COUT_ALIGN(s) cout << "alignof(" #s ") = " << alignof(s) << endl
int main() {
X x;
X1 x1;
X2 x2;
X3 x3;
X4 x4;
alignas(4) X3 x5;
alignas(16) X4 x6;
COUT_ALIGN(x);
COUT_ALIGN(x1);
COUT_ALIGN(x2);
COUT_ALIGN(x3);
COUT_ALIGN(x4);
COUT_ALIGN(x5);
COUT_ALIGN(x6);
COUT_ALIGN(x5.a1);
COUT_ALIGN(x6.a1);
}
alignas既可以用于结构体,也可以用于 结构体的成员变量。如果将alignas用于结构体类型,那么该结构体整体就会以alignas声明的对齐字节长度进行对齐 在例子中,X的类型对齐字节长度为8字节,而X2在使用了alignas(16)之 后,对齐字节长度修改为了16字节。 如果修改结构体成员的对齐字节长度,那么结构体本 身的对齐字节长度也会发生变化,因为结构体类型的对齐字节长度总是需要大于或者等于其成员变量类型的对齐字节长度。 X1的成员变量a1类型的对齐字节长度修改为了16字节,所有X1类型也被修改为16字节对齐。 X3类型的对齐字节长度被指定为16字 节,虽然其成员变量a1的类型对齐字节长度被指定为8字节,但是并不能改变X3类型的对齐字节长 度。X4就恰恰相反,由于X4指定的对齐字节长度为4字节,明显小于其成员变量类型需要的对齐字 节长度的字节数,因此这里X4的alignas(4)会被忽略。最后要说明的是,结构体类型的对齐字节长 度,并不能影响声明变量时变量的对齐字节长度,比如X5、X6。不过在变量声明时指定对齐字节 长度,也不影响变量内部成员变量类型的对齐字节长度,比如x5.a1、x6.a1。上面的代码用结构体 作为例子,实际上对于类也是一样的。
其他关于对齐字节长度的支持
C++11标准除了提供了关键字alignof和alignas来支持对齐字节长度的控制以外,还提供了 stdaligned_storage stdalign函数模板 来支持对于对齐字节长度的控制。
std::alignment_of和alignof的功能一样,可以获取类型的对齐字节长度
cout << alignment_of<int>::value << endl; // 输出4
cout << alignment_of<int>() << endl; // 输出4
cout << alignment_of<double>::value << endl; // 输出8
cout << alignment_of<double>() << endl; // 输出8
std::aligned_storage可以用来分配一块指定对齐字节长度和大小的内存
aligned_storage<128, 16>::type buffer;
cout << sizeof(buffer) << endl; // 内存大小指定为128字节
cout << alignof(buffer) << endl; // 对齐字节长度指定为16字节
stdsize_t作为分配内存的大小,以及不定数量的类型。 std::aligned_union会获取这些类型中对齐字节长度最严格的(对齐字节数最大)作为分配内存的 对齐字节长度
aligned_union<64, double, int, char>::type buffer;
cout << sizeof(buffer) << endl; // 内存大小指定为64字节
cout << alignof(buffer) << endl; // 对齐字节长度自动选择为double,8字节对齐
std::align函数模板接受一个指定大小的缓冲区空间的指针和一个对齐字节长度,返回一个该缓冲区中最近的能找到符合指定对齐字节长度的指针。传入的缓冲区内存大小为预分配的缓冲区大小加上预指定对齐字节长度的字节数。
#include <memory>
#include <chrono>
using namespace std;
static inline void *__movsb(void *d, const void *s, size_t n) {
asm volatile ("rep movsb"
: "=D" (d),
"=S" (s),
"=c" (n)
: "0" (d),
"1" (s),
"2" (n)
: "memory");
return d;
}
int main(int argc, char *argv[]) {
constexpr int align_size = 32;
constexpr int alloc_size = 10001;
constexpr int buff_size = align_size + alloc_size;
char dest[buff_size]{0};
char src[buff_size]{0};
void *dest_ori_ptr = dest;
void *src_ori_ptr = src;
size_t dest_size = sizeof(dest);
size_t src_size = sizeof(src);
char *dest_ptr = static_cast<char *>(std::align(align_size, alloc_size, dest_ori_ptr, dest_size));
char *src_ptr = static_cast<char *>(std::align(align_size, alloc_size, src_ori_ptr, src_size));
if (argc == 2 && argv[1][0] == '1') {
++dest_ptr;
++src_ptr;
}
auto start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < 10000000; i++) {
__movsb(dest_ptr, src_ptr, alloc_size - 1);
}
auto end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> diff = end - start;
std::cout << "elapsed time = " << diff.count();
}
用汇编语言实现了一个memcpy函数以确保复制内存函数都是通过汇编指令movsb完 成的。然后我们预先分配了两个10001+32字节大小的内存作为目标缓冲区和源缓冲区。此后通 过std::align找到两个缓冲区中按照32字节对齐的指针,该指针指向的内存大小至少为10001字 节。最后我们用自己实现的内存复制函数进行内存复制。如果运行的时候不带任何参数,则使用 32字节对齐的内存进行复制,否则用1字节对齐的内存进行内存复制,复制动作重复10000000 次。
使用new分配指定对齐字节长度对象
内存分配器会按照stdalign_ val_t类型的参数来获得分配对象需要的对齐字节长度来 实现的
void* operator new(std::size_t, std::align_val_t);
void* operator new[](std::size_t, std::align_val_t);
编译器会自动从类型对齐字节长度的属性中获取这个参数并且传参,不需要额外的代码介 入。
union alignas(256) X {
char a1;
int a2;
double a3;
};
int main() {
X *x = new X();
cout << "x = " << x << endl;
}
分别使用C++11和C++17标准进行编译
g++ -std=c++11 test_new.cpp -o cpp11
./cpp11
x = 0x1071620
g++ -std=c++17 test_new.cpp -o cpp17
./cpp17
x = 0x1d1700
在使用C++11标准的情况下,new分配的对象指针(0x1071620)并没有按照X指定 的对齐字节长度(256字节)对齐,而在使用C++17标准的情况下,new分配的对象指针 (0x1d1700)正好为X指定的对齐字节长度。
explicit显式自定义类型转换
隐式类型转换
class Point {
public:
int x;
Point(int x = 0):x(x){}
};
void displayPoint(const Point &p) {
cout<< p.x<< endl;
}
int main() {
displayPoint(1);
Point p = 1;
}
函数displayPoint
需要的是Point
类型的参数, 而我们传入的是一个int
, 这个程序却能成功运行, 就是因为这隐式调用. 在对象刚刚定义时, 即使使用的是赋值操作符, 也是会调用构造函数, 而不是重载的operator=
运算符 这样悄悄发生的事情, 有时可以带来便利, 而有时却会带来意想不到的后果. explicit
关键字用来避免这样的情况发生.
explicit关键字
- 指定构造函数或转换函数 (C++11起)为显式, 即它不能用于隐式转换和复制初始化
- explicit 指定符可以与常量表达式一同使用. 函数当且仅当该常量表达式求值为 true 才为显式. (C++20起)
在Point(int x = 0, int y = 0)
前加了explicit
修饰, 就无法通过编译了
explicit Point(int x = 0):x(x){}
如果我们能预料到某种情况的发生, 就不要把这个情况的控制权交给编译器.
对于布 尔转换,C++11标准为其准备了一些特殊规则以减少代码冗余:在某些期待上下文为bool类型的语境中,可以隐式执行布尔转换(将其他类型转换为bool),即使这个转换被声明为显式。这些语境包括以下几种: if、while、for的控制表达式。 内建逻辑运算符!、&&和||的操作数。 条件运算符?:的首个操作数。 static_assert声明中的bool常量表达式。 noexcept说明符中的表达式。 以上语境对类型进行布尔转换是非常自然的,并不会产生其他不良的影响,而且会让代码更加简练,容易理解。 新标准库也充分利用了显式自定义类型转换特性,比如stdifstream定义了显式 bool类型转换运算符来指示是否成功打开了目标文件等。
explicit(bool)
C++20标准扩展了explicit说明符的功能,在新标准中它可以接受一个求值类型为bool的常量表达式,用于指定explicit的功能是否生效。
std::pair<std::string, std::string> safe() {
return {"w", "purr"}; // 编译成功
}
std::pair<std::vector<int>, std::vector<int>> unsafe() {
return {11, 22}; // 编译失败
}
safe()函数可以通过编译,unsafe()则会编译报错。这个结果符合预期,整型 转换为stdvector,为何unsafe()函数编译失败了 stdvector。 但是这里stdpair的构 造,因为std::pair的实现类似于以下代码:
template<class T1, class T2>
struct MyPair {
template <class U1, class U2>
MyPair(const U1& u1, const U2& u2) : first_(u1), second_(u2) {}
T1 first_;
T2 second_;
};
MyPair<std::vector<int>, std::vector<int>> unsafe() {
return { 11, 22 }; // 编译成功
}
上面这段代码是可以通过编译的,这说明stdvector,而是通过 first_(u1)和second_(u2)间接构造std::vector,这个过程显然是一个显式构造。要解决这个问 题,我们需要对MyPair的构造函数使用explicit说明符。
template<class T1, class T2>
struct MyPair {
template <class U1, class U2>
explicit MyPair(const U1& u1, const U2& u2) : first_(u1), second_(u2) {}
T1 first_;
T2 second_;
};
MyPair<std::vector<int>, std::vector<int>> unsafe() {
return { 11, 22 }; // 编译失败
}
MyPair<std::string, std::string> safe() {
return { "wdd", "purr" }; // 编译失败
}
但是这样一来又会导致safe()编译失败。为了解决这一系列的问题,标准库采用SFINAE和概 念的方法实现了std::pair的构造函数,其代码类似于:
// SFINAE版本
template <typename T1, typename T2>
struct pair {
template <typename U1=T1, typename U2=T2,
std::enable_if_t<
std::is_constructible_v<T1, U1> &&
std::is_constructible_v<T2, U2> &&
std::is_convertible_v<U1, T1> &&
std::is_convertible_v<U2, T2>
, int> = 0>
constexpr pair(U1&&, U2&& );
template <typename U1=T1, typename U2=T2,
std::enable_if_t<
std::is_constructible_v<T1, U1> &&
std::is_constructible_v<T2, U2> &&
!(std::is_convertible_v<U1, T1> &&
std::is_convertible_v<U2, T2>)
, int> = 0>
explicit constexpr pair(U1&&, U2&& );
};
// 概念版本
template <typename T1, typename T2>
struct pair {
template <typename U1=T1, typename U2=T2>
requires std::is_constructible_v<T1, U1> &&
std::is_constructible_v<T2, U2> &&
std::is_convertible_v<U1, T1> &&
std::is_convertible_v<U2, T2>
constexpr pair(U1&&, U2&& );
template <typename U1=T1, typename U2=T2>
requires std::is_constructible_v<T1, U1> &&
std::is_constructible_v<T2, U2>
explicit constexpr pair(U1&&, U2&& );
};
标准库利用SFINAE和概念实现了两套构造函数,对于类型可以转换 地(使用std::is_convertible_v判定)采用无explicit说明符的构造函数,而对于其他情况使用有 explicit说明符的构造函数。 尽管使用以上方法很好地解决了上述一系列问题,但是不得不说它的实现非常复杂。幸好 explicit(bool)的引入有效地缩减了解决上述问题的编码:
// SFINAE版本
template <typename T1, typename T2>
struct pair {
template <typename U1=T1, typename U2=T2,
std::enable_if_t<
std::is_constructible_v<T1, U1> &&
std::is_constructible_v<T2, U2>
, int> = 0>
explicit(!std::is_convertible_v<U1, T1> ||
!std::is_convertible_v<U2, T2>)
constexpr pair(U1&&, U2&& );
};
// 概念版本
template <typename T1, typename T2>
struct pair {
template <typename U1=T1, typename U2=T2>
requires std::is_constructible_v<T1, U1> &&
std::is_constructible_v<T2, U2>
explicit(!std::is_convertible_v<U1, T1> ||
!std::is_convertible_v<U2, T2>)
constexpr pair(U1&&, U2&& );
};
std::pair不再需要实现两套构造函数了。取而代之的是:
explicit(!std::is_convertible_v || !std::is_convertible_v)
当U1、U2不能转换到T1和T2的时候,!stdis_ convertible_v的求值为true,explicit(true)表示该构造函数为显式的。反之,当U1、U2可 以转换到T1和T2时,最终结果为explicit(false),explicit说明符被忽略,构造函数可以隐式执 行。
贡献者
版权所有
版权归属:PinkDopeyBug