13 基础特性和其他优化
约 13420 字大约 45 分钟
2025-06-22
清洗,洗刷(洗内存) stdlaunder()还是有必要说明一下的,因为它想要解决的是C++语言的一个核心问 题。
struct X {
const int n;
};
union U {
X x;
float f;
};
int main(){
U u={{1}};
}
const int类型数据成员n被初始化为1,由于n的常量性,编译器可以总是认为u.x.n为1。 接下来我们使用replace new的方法重写初始化这块内存区域:
X *p = new (&u.x) X {2};
新创建的p->n的值为2。 现在看来u.x.n的值为2 由于u.x.n是一个常量且初始 化为1,因此编译器有理由认为u.x.n是无法被修改的,通过一些优化后u.x.n的结果有可能为1。 实际上在标准看来,这个结果是未定义的。在经过replace new的操作后,我们不能直接使用 u.x.n,只能通过p来访问n。
C++标准规定:如果新的对象在已被某个对象占用的内存上进行构建,那么原始 对象的指针、引用以及对象名都会自动转向新的对象,除非对象是一个常量类型或对象中有常量 数据成员或者引用类型。简单来说就是,如果数据结构X的数据成员n不是一个常量类型,那么 u.x.n的结果一定是2。但是由于常量性的存在,从语法规则来说x已经不具备将原始对象的指针、 引用以及对象名自动转向新对象的条件,因此结果是未定义的,要访问n就必须通过新对象的指针 p。
标准库引入std::launder()就是为了解决上述问题
assert(*std::launder(&u.x.n) == 2);
它是一个有定义的行为,而且获取n的值也保证为2。 它的目的是防止编译器追踪到数据的来源以阻止编译器对数据的优化。
返回值优化
返回值优化是C++中的一种编译优化技术,它允许编译器将函数返回的对象直接构造到它们本来要存储的变量空间中而不产生临时对象。 严格来说返回值优化分为RVO(Return Value Optimization)和NRVO(Named Return Value Optimization) 不过在优化方法上的区别并不 大,一般来说当返回语句的操作数为临时对象时,我们称之为RVO;而当返回语句的操作数为具 名对象时,我们称之为NRVO。 在C ++ 11标准中,这种优化技术被称为复制消除(copy elision) 在GCC编译器中,这项优化技术是默认开启的,取消优化需要额外的编译参数“-fno-elide- constructors”。
class X {
public:
X() { cout << "默认构造" << endl; }
X(const X &x) { cout << "拷贝构造" << endl; }
~X() { cout << "析构函数" << endl; }
};
X make_x() {
X x1;
return x1;
}
int main() {
X x2 = make_x();
}
函数make_x()返回了对象x1并赋值到x2上,理论上说这其中必定需要经过两次复制构 造函数,第一次是x1复制到临时对象,第二次是临时对象复制到x2。 GCC编译并且 运行这份代码,会输出结果:
构造函数
析构函数
整个过程一次复制构造都没有调用,这就是NRVO的效果。 如果这里将make_x 函数改为:
X make_x(){
return X();
}
也会收到同样的效果,只不过优化技术名称从NRVO变成了RVO。 接下来在编译命令行中添加开关“-fno-elide-constructors”,然后再次编译运行该代码, 这时的输出结果如下:
构造函数
拷贝构造
析构函数
拷贝构造
析构函数
析构函数
这才是预想的结果,一个默认构造函数和两个复制构造函数的调用。从结果可以看出返回值优化减少了两次复制构造和析构,对于比较复杂或者占用内存很大的对象来说是很重要的优化。 实际上返回值优化很容易失效
class X {
public:
X() { cout << "构造函数" << endl; }
X(const X &x) { cout << "拷贝构造" << endl; }
~X() { cout << "析构函数" << endl; }
};
X make_x() {
X x1, x2;
if (time(nullptr) % 50 == 0) {
return x1;
} else {
return x2;
}
}
int main() {
X x3 = make_x();
}
time_t time(time_t * t)函数 返回值:从1970-01-01 00:00:00 到系统当前时间所经过的时间以秒为单位
参数可为指向time_t的指针或者为nullptr,当参数不为空时,其返回值也存储于参数指针中
time_t seconds;
time(&seconds); //time_t time(time_t *t)
// seconds = time(nullptr) 和以上等同
cout << "从1970-01-01起的秒数 = " << seconds << "s" << endl;
现在make_x()函数不确定会返回哪个对象了,如果继续在GCC中添加“-fno-elide-constructors”开关进行编译,则运行时依然会出现两次复制构造函数
构造函数
构造函数
拷贝构造
析构函数
析构函数
拷贝构造
析构函数
析构函数
若删除“-fno-elide-constructors”开关不会消除复制构造函数,这时只能消除一次复制构造
构造函数
构造函数
拷贝构造
析构函数
析构函数
析构函数
由于以上代码中究竟由x1还是x2复制到x3是无法在编译期决定的,因 此编译器无法在默认构造阶段就对x3进行构造,它需要分别将x1和x2构造后,根据运行时的结果 将x1或者x2复制构造到x3,在这个过程中返回值优化技术也尽其所能地将中间的临时对象优化掉了,所以这里只会看到一次复制构造函数的调用。
虽然返回值优化技术可以省略创建临时对象和复制构造的过程,但是 C++11标准规定复制构造函数必须是存在且可访问的,否则程序是不符合语法规则的
class X {
private:
X(const X &x) { cout << "拷贝构造" << endl; }
public:
X() { cout << "构造函数" << endl; }
~X() { cout << "析构函数" << endl; }
};
X make_x() {
return X();
}
int main() {
X x2 = make_x();
}
将类X的复制构造函数设置为私有。根据返回值优化的要求,复制构造 函数必须是可访问的,所以上面的代码在C++11的编译环境下将会导致编译错误。 C++14标准对返回值优化做了进一步的规定,规定中明确了对于常量表达式和常量初始化而言,编译器应该保证RVO,但是禁止NRVO。 在C++17标准中提到了确保复制消除的新特性,它从另一个角度出发对C++进行了性能优 化,而且也能达到RVO的效果。该特性指出,在传递临时对象或者从函数返回临时对象的情况 下,编译器应该省略对象的复制和移动构造函数,即使这些复制和移动构造还有一些额外的作 用,最终还是直接将对象构造到目标的存储变量上,从而避免临时对象的产生。标准还强调,这 里的复制和移动构造函数甚至可以是不存在或者不可访问的。 对于临时对象强制省略对象的复制和移动构造函数, 这一点实际上和RVO一样,只是对编译器提出了硬性要求。它允 许复制和移动构造函数是不存在和不可访问的。返回值优化对于 这一点是不允许的在C++17标准中是可以成功编译的甚至可以显式删除复制构造函数依然能正确地编译运行。这一点带来的最大好处是,所有类型都能使 用工厂函数,即使该类型没有复制或者移动构造函数 返回值优化虽然能够帮助我们减少返回对象的复制,但是作为程序员还 是应该尽量减少对这些优化的依赖,因为不同的编译器对其的支持可能是不同的。面对传递对象 的需求,我们可以尽量通过传递引用参数的方式完成,不要忘了C++11中支持的移动语义,它也 能在一定程度上代替返回值优化的工作。
允许按值进行默认比较
以下代码在C++20标准之前是无法编译成功的
struct C {
int i;
friend bool operator==(C, C) = default;
};
因为在C++20之前的标准中,类的默认比较规则要求类C可以有一个参数为const C&的非静态 成员函数,或者有两个参数为const C&的友元函数。而C++20标准对这一条规则做了适度的放 宽,它规定类的默认比较运算符函数可以是一个参数为const C&的非静态成员函数,或是两个参数 为const C&或C的友元函数。这里的区别在于允许按值进行默认比较,于是上面的代码可以顺利地通过编译。 但下面这两种情况依旧是标准不允许的
struct A {
friend bool operator==(A, const A&) = default;
};
struct B {
bool operator==(B) const = default;
};
A因为混用const A&和A而不符合标准要求,所以编译失败。 标准并没有放宽默认比较中对于非静态成员函数的要求,B依然无法通过编译。
支持new表达式推导数组长度
C++在声明数组的时候都支持通过初始化时的元素个数推导数组长度
int x[]{ 1, 2, 3 };
char s[]{ "hello world" };
这种声明数组的方式非常方便,特别是对于字符串数组而言,将计算数组所需长度的任务交给编译器,省去了我们挨个数字符检查的烦恼。 但在用new表达式声明数组的时候无法把推导数组长度的任务交给编译器 以下代码在C++20之前无法编译
int *x = new int[]{ 1, 2, 3 };
char *s = new char[]{ "hello world" };
在C++20标准解决了以上问题。提案文档中强调在数组声明时根据初始化元素个数推导数 组长度的特性应该是一致的,所以用以上方式声明数组理应是一个合法的语法规则。
允许数组转换为未知范围的数组
在C++20标准中允许数组转换为未知范围的数组
void f(int(&)[]) {}
int arr[1];
int main() {
f(arr);
int(&r)[] = arr;
}
以上代码在C++20标准下可以正常编译通过。对于重载函数的情况,编译器依旧会选择更为精准匹配的函数
void f(int(&)[]) {
cout << "call f(int(&)[])"<<endl;
}
void f(int(&)[1]) {
cout << "call f(int(&)[1])"<<endl;
}
int arr[1];
int main() {
f(arr);
}
void f(int(&)[1])明显更匹配int arr[1];,所以输出结果为call f(int(&) [1])。
空作用域符
在C++中,“”右面的符号来自绝对的全局——这其实非常好理解:明确指明这是一个“空的命名空间”,而“空”就是“没有”,没有被命名空间限定的符号就是全局的符号。
func();
这样写的函数,是指在当前的上下文里,能找到的最近的函数——显然,它有可能正好也是全局的那位,如果当前代码往上找不存在更近定义的志玲的话。但如果当前往上能找到一个新定义的函数,那这样写的函数应该是就近的那位
void operator delete(X *ptr, destroying_delete_t) {
cout << "call delete" << endl;
::operator delete(ptr);
}
所以这里的第一个delete是我们自己重载的运算符 第二个delete用::表示std的没用重载的delete
在delete运算符函数中析构对象
通常情况下delete一个对象,编译器会先调用该对象的析构函数,之后才会调用 delete运算符删除内存
在C++20标准以前,这个析构和释放内存的操作完全由编译器控制,我们无法将其分解开来。但是从C++20标准开始,这个过程可以由我们控制了,而且实现方法也非常简单
struct X {
X() {}
~X() {cout << "call dtor" << endl;}
void *operator new(size_t s) {
return ::operator new(s);
}
void operator delete(X *ptr, destroying_delete_t) {
cout << "call delete" << endl;
::operator delete(ptr);
}
};
int main(){
X* x = new X;
delete x;
}
delete运算符发生的两个变化:第一个参数类型由void * 修改为X * ;增加了一个类型为std::destroying_delete_t的形参,且我们并不会用到它。编译器会识别到 delete运算符形参的变化,然后由我们去控制对象的析构。比如在上面的代码中,我们没有调用 析构函数,于是输出的结果如下:
call delete
在这种情况下,我们需要自己调用析构函数:
void operator delete(X* ptr, std::destroying_delete_t){
ptr->~X();
cout << "call delete" << endl;
::operator delete(ptr);
}
调用伪析构函数结束对象声明周期
C++20标准完善了调用伪析构函数结束对象声明周期的规则。 在过去,调用伪析构函数会根据对象的不同执行不同的行为
template<typename T>
void destroy(T* p) {
p->~T();
}
当T是非平凡类型时,p->~T();会结束对象声明周期;相反当T为平凡类型 时,比如int类型,p->~T();会被当成无效语句。 C++20标准修补了这种行为不一致的规则,它规 定伪析构函数的调用总是会结束对象的生命周期,即使对象是一个平凡类型。
修复const和默认复制构造函数不匹配造成无法编译的问题
一个类或者结构体,它编写复制构造函数的时候没有使用const
struct A {
A() = default;
A(A&) {};
};
template <typename T>
struct B {
B() = default;
B(const B&) = default;
T t;
};
B<A> var;
A的复制构造函数的形参是const版本而其成员B不是,这种不匹配在C++17和以前的标准中是不被允许的。 但这样的规定并不合理,因为代码并没有试图去调用复制构造函数。在C++20标准中修正了这一点,如果不发生复制动作,这样的写法是可以通过编译的。
Wrapper var1;
Wrapper var2(var1);
这样的写法无论如何都会编译失败。
不推荐使用volatile的情况
volatile是一个非常著名的关键字,用于表达易失性。它能够让编译器不要对代码做过多的优 化,保证数据的加载和存储操作被多次执行,即使编译器知道这种操作是无用的,也无法对其进行优化。 在现代的计算机环境中,volatile限定符的意义已经不大了。 该限定符并不能保证数据的同步,无法保证内存操作不被中断,它的存在不能代替原子操 作。其次,虽然volatile操作的顺序不能相对于其他volatile操作改变,但是可以相对于非 volatile操作改变。更进一步来说,即使从C++编译代码的层面上保证了操作执行的顺序,但是对 于现代CPU而言这种操作执行顺序也是无法保证的。
因为volatile限定符现实意义的减少以及部分程序员对此理解的偏差,C++20标准在部分情况中不推荐volatile的使用,这些情况包括以下几种:
- 不推荐算术类型的后缀++和--表达式以及前缀++和--表达式使用volatile限定符
volatile int d = 5;
d++;
--d;
- 不推荐非类类型左操作数的赋值使用volatile限定符
// E1 op= E2
volatile int d = 5;
d += 2;
d *= 3;
- 不推荐函数形参和返回类型使用volatile限定符
volatile int f() { return 1; }
int g(volatile int v) { return v; }
- 不推荐结构化绑定使用volatile限定符
struct X {
int a;
short b;
};
X x{ 11, 7 };
volatile auto [a, b] = x;
以上4种情况在C++20标准的编译环境中编译都会给出'volatile'- qualified type is deprecated的警告信息。
不推荐在下标表达式中使用逗号运算符
逗号运算符可以让多个表达式按照从左往右的顺序进行计算,整 体的结果为系列中最后一个表达式的值
int a[]{ 1,2,3 };
int x = 1, y = 2;
cout << a[x, y]<<endl;
stdcout << a[y];,最后输出结果是3。 从C++20标准开始,std::cout << a[x, y];这句代码会被编译器提出警告,因为标准已经不推荐在 下标表达式中使用逗号运算符了。该规则的提案文档明确地表示,希望array[x,y]这种表达方式能 用在矩阵、视图、几何实体、图形API中。 对于老代码的维护者或者依旧想在下标表达式中使用逗号运算符,可以在下标表达式外加上小括号来消除警告
cout << a[(x, y)]<<endl;
模块
模块(module)是C++20标准引入的一个新特性,它的主要用途是将大型工程中的代码拆分成独立的逻辑单元,以方便大型工程的代码管理。 模块能够大大减少使用头文件带来的问题,例 如在使用头文件时经常会遇到宏和函数的重定义,而模块则会好很多,因为宏和未导出名称对于 导入模块是不可见的。使用模块也能大幅提升编译效率,因为编译后的模块信息会存储在一个二进制文件中,编译器对于它的处理速度要远快于单纯使用文本替换的头文件方法。
// helloworld.ixx
export module helloworld;
import std.core;
export void hello() {
cout << "Hello world!"<<endl;
}
// modules_test.cpp
import helloworld;
int main() {
hello();
}
上面的代码很容易理解,helloworld.ixx是接口文件,它将编译成一个名为helloworld的导出 模块。在模块中使用import引入了std.core,std.core是一个STL模块,包含了STL中最主要的容器 和算法。 模块还使用export导出了一个hello函数。编译器编译helloworld.ixx会生成一 个helloworld.ifc,该文件包含了模块的元数据。modules_test.cpp可以通过import helloworld;导入 helloworld模块,并且调用它的导出函数hello。
联合、强枚举、聚合类型
C++中对C语言的联合类型和枚举类型做了升级
非受限联合类型
联合类型在C++中的局限性
在编程的问题中,用尽量少的内存做尽可能多的事情一直都是一个重要的课题。C++中的联合类型(union)可以说是节约内存的一个典型代表。因为在联合类型中多个对象可以共享一片内存,相应的这片内存也只能由一个对象使用
union U {
int x1;
float x2;
};
int main() {
U u;
u.x1 = 5;
cout << u.x1 <<','<< u.x2<< endl;
u.x2 = 5.0;
cout << u.x1<<','<< u.x2 <<endl;
}
联合类型U里的成员变量x1和x2共享同一片内存,所以修改x1的值,x2的值也会发生相应的变化,反之亦然。不过需要注意的是,虽然x1和x2共享同一片内存,但是由于CPU 对不同类型内存的理解存在区别,因此即使内存相同也不能随意使用联合类型的成员变量,而是应该使用之前初始化过的变量。像这样多个对象共用一片内存的情况在内存紧缺时是非常实用的。不过令人遗憾的是,过去的联合类型在C++中的使用并不广泛,因为C++中的大多数对象不能成为联合类型的成员。过去的C++标准规定,联合类型的成员变量的类型不能是一个非POD类型,也就是说它的成员类型不能有自定义构造函数、静态成员、引用类型成员
union U {
int x1;
float x2;
string x3;
};
上面的代码是无法通过编译的,因为x3存在自定义的构造函数,所以它是一个非平凡类型。 于是大多数情况下,我们会为自己的类添加一个好用的构造函数,但是这种良好的设计却造成了这个类型无法在联合类型中使用。
placement new
一般情况下,使用new申请空间时,是从系统的堆(heap)中分配空间,申请所得的空间的位置是根据当时的内存的实际使用情况决定的。但是,在某些特殊情况下,可能需要在已分配的特定内存创建对象,这种操作就叫做placement new即定位放置 new。 语法:
ClassName* ptr = new (定位的内存地址)ClassName;
示例:
class Base{
public:
Base() {}
~Base() {}
void print(){cout << "number value: " << number << endl;}
private:
int number;
};
int main(){
int n = 100;
Base* b = new (&n)Base;
b->print();//number value: 100
return 0;
}
使用定位放置的方式为指针b申请了一块内存,也就是说此时指针 b指向的内存地址和变量 n对应的内存地址是同一块(栈内存),而在Base类中成员变量 number的起始地址和Base对象的起始地址是相同的,所以打印出 number 的值为100也就是整形变量 n 的值。
- 使用定位放置new操作,既可以在栈(stack)上生成对象,也可以在堆(heap)上生成对象,这取决于定位时指定的内存地址是在堆还是在栈上。
- 从表面上看,定位放置new操作是申请空间,其本质是利用已经申请好的空间,真正的申请空间的工作是在此之前完成的。
- 使用定位放置new 创建对象时会自动调用对应类的构造函数,但是由于对象的空间不会自动释放,如果需要释放堆内存必须显示调用类的析构函数。
- 使用定位放置new操作,我们可以反复动态申请到同一块堆内存,这样可以避免内存的重复创建销毁,从而提高程序的执行效率(比如网络通信中数据的接收和发送)。
使用非受限联合类型
为了让联合类型更加实用,在C++11标准中解除了大部分限制,联合类型的成员可以是除了引用类型外的所有类型。 不过这样的修改引入了另外一个问题,如何精确初始化联合类型成员对象。 这一点在过去的联合类型中不是一个问题,因为对于平凡类型,编译器只需要对成员对象都 执行编译器提供的默认构造即可,虽然从同一内存多次初始化的角度来说这是不正确的,但是从 结果上看没有任何问题。现在情况发生了变化,由于允许非平凡类型的存在,对所有成员一一进 行默认构造明显是不可取的,因此我们需要有选择地初始化成员对象。
在C++11中如果有联合类型中存在非平凡类型,那么这个联合类型的特殊成员函数将被隐式删除,也就是说我们必须 自己至少提供联合类型的构造和析构函数
union U{
U() {} // 存在非平凡类型成员,必须提供构造函数
~U() {} // 存在非平凡类型成员,必须提供析构函数
int x1;
float x2;
string x3;
vector<int> x4;
};
int main(){
U u;
u.x3 = "hello world";
cout << u.x3;
}
由于x3和x4的类型stdvector是非平凡类型,因此U必须提供构造和析构函数。虽然这里提供的构造和析构函数什么也没有做,但是代码依然可以成功编译,但并不代表没有问题,这段代码会运行出错,因为非平凡类型x3并没有被构造,所以在赋值操作的时候必然会出错。
union U{
U() : x3() {}
~U() { x3.~basic_string(); }
int x1;
float x2;
string x3;
vector<int> x4;
};
int main(){
U u;
u.x3 = "hello world";
cout << u.x3;
}
在上面的代码中,我们对联合类型U的构造和析构函数进行了修改。其中在构造函数中添加了 初始化列表来构造x3,在析构函数中手动调用了x3的析构函数。前者很容易理解,而后者需要注 意,联合类型在析构的时候编译器并不知道当前激活的是哪个成员,所以无法自动调用成员的析构函数,必须由程序员编写代码完成这部分工作。现在联合类型U的成员对象x3可以正常工作了, 但是这种解决方案依然存在问题,因为在编写联合类型构造函数的时候无法确保哪个成员真正被 使用。具体来说,如果在main函数内使用U的成员x4,由于x4并没有经过初始化,因此会导致程序出错
基于这些考虑,比较推荐让联合类型的构造和析构函数为空,也就是什么也不做,并且将其成员的构造和析构函数放在需要使用联合类型的地方。
union U {
U() {}
~U() {}
int x1;
float x2;
string x3;
vector<int> x4;
};
int main() {
U u;
new(&u.x3) string("hello world");
cout << u.x3 << endl;
u.x3.~basic_string();
new(&u.x4) vector<int>;
u.x4.push_back(58);
cout << u.x4[0] << endl;
u.x4.~vector();
}
上面的代码用了placement new的技巧来初始化构造x3和x4对象,在使用完对象后手动 调用对象的析构函数。通过这样的方法保证了联合类型使用的灵活性和正确性。
非受限联合类型对静态成员变量的支持
联合类型的静态成员不属于联合类型的任何对象,所以并不是对象构造时被定义的,不能在联合类型内部初始化。这一点 和类的静态成员变量是一样的,它的初始化方法也和类的静态成员变量相同:
union U{
static int x1;
};
int U::x1 = 42;
int main(){
std::cout << U::x1 << std::endl;
}
在C++17标准,大部分情况下我们可以使用std:: variant来代替联合体。
匿名非受限联合体
一般情况下我们使用的非受限联合体都是具名的(有名字),但是我们也可以定义匿名的非受限联合体,一个比较实用的场景就是配合着类的定义使用 场景: 疫情期间要对来到本地的人进行筛查,人员的登记方式如下: 学生只需要登记所在学校的编号 本地非学生人员需要登记其身份证号码 本市外来人员需要登记户口所在地+联系方式 这样对于登记的人员类就需要提供四个成员:
- 对于学生需要一个整形存储学校编号
- 对于本地非学生市民需要一个字符串类型存储身份证号
- 对于外来人员需要两个字符串类型分别用于存储户口所在地和联系方式 这样对于一个人员不管他是属于哪种,记录其他人员登记信息的成员变量就用不到,也就浪费了内存空间 而使用匿名非受限联合体就可以解决这种情况,节省内存空间
//外来人口信息
class Foreigner{
public:
string address;
string phone;
Foreigner(string addr, string phone):address(addr),phone(phone){}
};
//登记人口信息
class Person{
public:
enum class Category:char{Student,Local,Foreigner};
Person(int num):number(num),type(Category::Student){}
Person(string id):id(id),type(Category::Local){}
Person(string addr, string phone):foreign(addr,phone),type(Category::Foreigner){}
~Person(){}
void print(){
cout << "type:" << (int)type << endl;
switch (type) {
case Category::Student:
cout << "number:" << number << endl;
break;
case Category::Local:
cout << "id:" << id << endl;
break;
case Category::Foreigner:
cout << "address:" << foreign.address << endl;
cout << "phone:" << foreign.phone << endl;
break;
default:
break;
}
}
private:
Category type;//人员类型
union {
int number;//学校编号
string id;//身份证号
Foreigner foreign;//外来人员信息
};
};
int main() {
Person p1(20231004);
Person p2("Wheezecat_Jun06");
Person p3("八嘎小姐", "Wheezecat_Jun06");
p1.print();
p2.print();
p3.print();
return 0;
}
强枚举类型
[!NOTE] wchar_t类型 枚举和强枚举都可以使用整形类型指定底层类型,而wchar_t除外
它是双字节类型,或宽字符类型,是C/C++的一种扩展的存储方式,一般为16位或32位,所能表示的字符数远超char型。
主要用在国际化程序的实现中,但它不等同于 unicode 编码。unicode 编码的字符一般以wchar_t类型存储。
枚举类型的弊端
enum类型破坏了C++的类型安全。大多数情况下,我们说C++是一门类型安全的强类型语言,但是枚举类型在一定程度上却是一个例外,具体来说有以下几个方面的原因。 首先,虽然枚举类型存在一定的安全检查功能,一个枚举类型不允许分配到另外一种枚举类型,而且整型也无法隐式转换成枚举类型。但是枚举类型却可以隐式转换为整型,因为C++标准文档提到“枚举类型可以采用整型提升的方法转换成整型”。
enum School {
principal,
teacher,
student
};
enum Company {
chairman,
manager,
employee
};
int main(){
School x = student;
Company y = manager;
bool b = student >= manager; // 不同类型之间的比较操作
b = x < employee;
int y = student; // 隐式转换为int
}
上面的代码中两个不同类型的枚举标识符student和manager可以进行比较,这在C++语言的 其他类型中是很少看到的。这种比较合法的原因是枚举类型先被隐式转换为整型,然后才进行比较。
枚举类型会把其内部的枚举标识符导出到枚举被定义的作用域。也是就说,我们使用枚举标识符的时候,可以跳过对于枚举类型的描述
School x = student;
Company y = manager;
无论是初始化x,还是初始化y,我们都没有对student和manager的枚举类型进行描述。因为枚举类型的作用域是全局,作用域并不封闭。
如果两个枚举类型都拥有相同的枚举标识符就会发生重复定义,无法通过编译,解决这种问题的方法就是使用命名空间
enum HighSchool {
student,
teacher,
principal
};
namespace AcademicInstitution
{
enum University {
student,
professor,
principal
};
}
这样一来,University的枚举标识符就会被导出到AcademicInstitution的作用域,和HighSchool 的全局作用域区分开来。
还有一个比较好但并不完美的解决方案
class AuthorityType {
private:
enum InternalType {
ITBan,
ITGuest,
ITMember,
ITAdmin,
ITSystem,
};
InternalType self_;
public:
AuthorityType(InternalType self) : self_(self) {}
bool operator<(const AuthorityType &other) const {
return self_ < other.self_;
}
bool operator>(const AuthorityType &other) const {
return self_ > other.self_;
}
bool operator<=(const AuthorityType &other) const {
return self_ <= other.self_;
}
bool operator>=(const AuthorityType &other) const {
return self_ >= other.self_;
}
bool operator==(const AuthorityType &other) const {
return self_ == other.self_;
}
bool operator!=(const AuthorityType &other) const {
return self_ != other.self_;
}
const static AuthorityType System, Admin, Member, Guest, Ban;
};
#define DEFINE_AuthorityType(x) const AuthorityType \
AuthorityType::x(AuthorityType::IT ## x)
DEFINE_AuthorityType(System);
DEFINE_AuthorityType(Admin);
DEFINE_AuthorityType(Member);
DEFINE_AuthorityType(Guest);
DEFINE_AuthorityType(Ban);
int main() {
bool b = AuthorityType::System > AuthorityType::Admin;
cout <<boolalpha << b << endl;
}
将枚举类型变量封装成类私有数据成员,保证无法被外界访问。访问枚举类型的数据成 员必须通过对应的常量静态对象。另外,根据C++标准的约束,访问静态对象必须指明对象所属 类型。也就是说,如果我们想访问ITSystem这个枚举标识符,就必须访问常量静态对象System,而 访问System对象,就必须说明其所属类型,这使我们需要将代码写成AuthorityType:: System才能编译通过 由于重载了比较运算符,因此可以对枚举类型进行比较。但是比较运算符函数只接 受同类型的参数,所以只允许相同类型进行比较。 当然,这样做也有缺点: 枚举类型本身是一个POD类型,而我们实现的类破坏了这种特性。 无法指定枚举类型的底层类型。因此,不同的编译器对于相同枚举 类型可能会有不同的底层类型,甚至有无符号也会不同。
enum E {
e1 = 1,
e2 = 2,
e3 = 0xfffffff0
};
int main(){
bool b = e1 < e3;
cout << boolalpha << b << endl;
}
不同的编译器会得到不同的结 果。在GCC中,结果返回true,我们可以认为E的底层类型为unsigned int。如果输出e3,会发现 其值为4294967280。但是在MSVC中结果输出为false,很明显在编译器内部将E定义为了int类型,输出e3的结果为−16。
但枚举类型缺乏类型检查的问题倒是成就了一种特殊用法。如果了解模板元编程,那么肯定见过一种被称为enum hack的枚举类型的用法。简单来说就是利用枚举值在编译期就能确定下来的特性,让编译器帮助我们完成一些计算
template<int a, int b>
struct add {
enum {
result = a + b
};
};
int main(){
cout << add<5, 8>::result << endl;
}
使用强枚举类型
[!NOTE]
enum struct
和enmu class
没有任何区别 enum class 的成员没有公有私有之分,也不会使用模板来支持泛化的声明
由于枚举类型确实存在一些类型安全的问题,因此在C++11标准中对其做出了重大升级,增加了强枚举类型。为了保证老代码的兼容性,也保留了枚举类型之前的特性。强枚举类型具备以下3个新特性。
- 枚举标识符属于强枚举类型的作用域。
- 枚举标识符不会隐式转换为整型。
- 能指定强枚举类型的底层类型,底层类型默认为int类型。
定义强枚举类型的语法,只需要在枚举定义的enum关键字之后加上class关键字就可以。
enum class HighSchool {
student,
teacher,
principal
};
enum class University {
student,
professor,
principal
};
int main(){
HighSchool x = HighSchool::student;
University y = University::student;
bool b = x < HighSchool::headmaster;
cout << boolalpha << b << endl;
}
在不使用命名空间的情况下,两个有着相同枚举标识符的 强枚举类型可以在一个作用域内共存。这符合强枚举类型的第一个特性,其枚举标识符属于强枚 举类型的作用域,无法从外部直接访问它们,所以在访问时必须加上枚举类型名,否则会编译失败 相同枚举类型的枚举标识符可以进行比较,但是不同枚举类型 就无法比较其枚举标识符了,因为它们失去了隐式转换为整型的能力,这一点符合强枚举类型的第二个特性
HighSchool x = student; // 编译失败,找不到student的定义
bool b = University::student < HighSchool::headmaster;// 编译失败,比较的类型不同
int y = University::student; // 编译失败,无法隐式转换为int类型
有了这两个特性的支持,强枚举类型就可以完美替代上面实现的AuthorityType类,强枚 举类型不仅实现起来非常简洁,而且还是POD类型。 对于强枚举类型的第三个特性,我们可以在定义类型的时候使用:符号来指明其底层类型。利 用它可以消除不同编译器带来的歧义:
enum class E : unsigned int {
e1 = 1,
e2 = 2,
e3 = 0xfffffff0
};
int main(){
bool b = E::e1 < E::e3;
cout << boolalpha << b << endl;
}
上面这段代码明确指明了枚举类型E的底层类型是无符号整型,这样一来无论使用GCC还是 MSVC,最后返回的结果都是true。如果这里不指定具体的底层类型,编译器会使用int类型。但 GCC和MSVC的行为又出现了一些区别:MSVC会编译成功,e3被编译为一个负值;而GCC则会报错,因为0xfffffff0超过了int能表达的最大正整数范围。
我们除了能指定强枚举类型的底层类型,还可以指定枚举类型的底层类型
enum E : unsigned int {
e1 = 1,
e2 = 2,
e3 = 0xfffffff0
};
int main(){
bool b = e1 < e3;
cout << boolalpha << b << endl;
}
虽然强枚举类型的枚举标识符是无法隐式转换为整型的,但还是可以通过static_cast对其进行强制类型转换。不建议这样做
列表初始化有底层类型枚举对象
C++17开始对有底层类型的枚举类型对象可以直接使用列表初始化。这条规则适用 于所有的强枚举类型,因为它们都有默认的底层类型int,而枚举类型就必须显式地指定底层类型 才能使用该特性
enum class Color {
Red,
Green,
Blue
};
int main(){
Color c0{ 5 }; // 编译成功
Color c1 = 5; // 编译失败
Color c2 = { 5 }; // 编译失败
Color c3(5); // 编译失败
}
c0可以在C++17环境下成功编译运行,因为Color有默认底层类型int,所以能够通过列表初始化对象,但是c1、c2和c3的初始化方法都是非法的。 同样的道理,下面的代码能编译通过
enum class Color1 : char {};
enum Color2 : short {};
int main(){
Color1 c0{ 7 };
Color2 c1{ 11 };
Color2 c2 = Color2{ 5 };
}
虽然Color2 c2 = Color2{ 5 }和Color c2 = { 5 }在代码上有些类似,但是其含义是完 全不同的。对于Color2 c2 = Color2{ 5 }来说,代码先通过列表初始化了一个临时对象,然后再赋 值到c2,而Color c2 = { 5 }则没有这个过程。另外,没有指定底层类型的枚举类型是无法使用列 表初始化的
enum Color3 {};
int main(){
Color3 c{ 7 };//error
}
在假设一个场景,我们需要一个新整数类型,该类型必须严格区别于其他整型,也就是说 不能够和其他整型做隐式转换,显然使用typedef的方法是不行的。另外,虽然通过定义一个类的 方法可以到达这个目的,但是这个方法需要编写大量的代码来重载运算符,也不是一个理想的方 案。所以,C++的专家把目光投向了有底层类型的枚举类型,其特性几乎完美地符合以上要求, 除了初始化整型值的时候需要用到强制类型转换。于是,C++17为有底层类型的枚举类型放宽了 初始化的限制,让其支持列表初始化:
enum class Index : int {};
int main(){
Index a{ 5 };
Index b{ 10 };
// a = 12;
// int c = b; cout << "a < b is "<< boolalpha<< (a < b) << endl;
}
在上面的代码中,定义了Index的底层类型为int,所以可以使用列表初始化a和b,由于a和b的 枚举类型相同,因此所有a < b的用法也是合法的。但是a = 12和int c = b无法成功编译,因为强枚举类型是无法与整型隐式相互转换的。 在C++17的标准库中新引入的std::byte类型就是用这种方法定义的。
使用using打开强枚举类型
C++20标准扩展了using功能,它可以打开强枚举类型的命名空间。
enum class Color {
Red,
Green,
Blue
};
int main(){
using enum Color;
Color r=Red;
Color g=Green;
Color b=Blue;
return 0;
}
扩展的聚合类型
聚合类型的新定义
C++17标准对聚合类型的定义做出了大幅修改,即从基类公开且非虚继承的类也可能是一个 聚合。同时聚合类型还需要满足常规条件。
- 没有用户提供的构造函数。
- 没有私有和受保护的非静态数据成员。
- 没有虚函数。 在新的扩展中,如果类存在继承关系,则额外满足以下条件。
- 必须是公开的基类,不能是私有或者受保护的基类。
- 必须是非虚继承。
这里并没有讨论基类是否需要是聚合类型,也就是说基类是否是聚合类型与派生类 是否为聚合类型没有关系,只要满足上述5个条件,派生类就是聚合类型。在标准库中提供了一个聚合类型的甄别办法is_aggregate_v< x>,它可以帮助我们判断目标类型(x)是否为聚合类型 is_aggregate_v< x>返回的是一个布尔值,可以使用boolalpha说明符在输出时让输出的布尔值为true或false而不是1或0
class MyString : public string {};
int main(){
cout << "std::is_aggregate_v<string> = "<< is_aggregate_v<string> << endl;
cout << "std::is_aggregate_v<MyString> = "<< is_aggregate_v<MyString> << endl;
}
string存在用户提供的构造函数,所以一定是非聚合类型。 MyString,虽然继承了string,但因为它是公开继承且是非虚继承,在类中不存在用户提供的构造函数、虚函数以及私有或者受保护的数据成员,所以MyString是聚合类型。
聚合类型的初始化
class MyStringWithIndex : public string {
public:
MyStringWithIndex(const string &str, int idx) : string(str), index(idx) {}
int index = 0;
};
ostream &operator<<(ostream &o, const MyStringWithIndex &s) {
o << s.index << ":" << s.c_str();
return o;
}
int main() {
MyStringWithIndex s("hello world", 11);
cout << s << endl;
}
为了初始化基类我们不得不为MyStringWithIndex提供一个构造函数,用构造 函数的初始化列表来初始化std::string。现在,由于聚合类型的扩展,这个过程得到了简化。需 要做的修改只有两点,第一是删除派生类中用户提供的构造函数,第二是直接初始化
class MyStringWithIndex : public string {
public:
int index_ = 0;
};
ostream &operator<<(ostream &o, const MyStringWithIndex &s) {
o << s.index_ << ":" << s.c_str();
return o;
}
int main() {
MyStringWithIndex s{{"hello world"}, 11};
cout << s << endl;
}
MyStringWithIndex s{ {"hello world"}, 11}是典 型的初始化基类聚合类型的方法。其中{"hello world"}用于基类的初始化,11用于index的初始化。这里的规则总是假设基类是一种在所有数据成员之前声明的特殊成员。所以实际上,{"hello world"}的大括号也可以省略,直接使用MyStringWithIndex s{ "hello world", 11}也是可行的。
如果派生类存在多个基类,那么其初始化的顺序与继承的顺序相同
class Count {
public:
int Get() { return count_++; }
int count_ = 0;
};
class MyStringWithIndex:public string,public Count {
public:
int index_ = 0;
};
ostream &operator<<(ostream &o, MyStringWithIndex &s) {
o << s.index_ << ":" << s.Get() << ":" << s.c_str();
return o;
}
int main() {
MyStringWithIndex s{"hello world", 7, 11};
cout << s << endl;
cout << s << endl;
}
类MyStringWithIndex先后继承了stdstring,7 对应基类Count,11对应数据成员index_。
禁止聚合类型使用用户声明的构造函数
用户提供的构造函数和用户声明的构造函数是有区别的
struct X {
X() = default;
};
struct Y {
Y() = delete;
};
int main() {
cout << boolalpha<< "is_aggregate_v<X> : " << is_aggregate_v<X> << endl;
cout << "is_aggregate_v<Y> : " << is_aggregate_v<Y> << endl;
}
虽然类X和Y都有用户声明的构造函数,但是它们依旧是聚合类型。对于结构体Y,因为它的默认构造函数被显式地删除了,所以该类型应该无法实例化对象 Y y1; // 编译失败,使用了删除函数
但是作为聚合类型,却可以通过聚合初始化的方式将其实例化 Y y2{}; // 编译成功
编译成功的这个结果显然不是类型Y的设计者想看到的,而且这个问题很容易在真实的开发过程中被忽略,从而导致意想不到的结果。除了删除默认构造函数,将其列入私有访问中也会有同样的问题
struct Y {
private:
Y() = default;
};
int main(){
Y y1; // 编译失败,构造函数为私有访问
Y y2{}; // 编译成功
return 0;
}
这里Y() = default;中的= default不能省略,否则Y会被识别为一个非聚合类型。
为了避免以上问题的出现,在C++17标准中可以使用explicit说明符或者将= default声明到 结构体外
struct X {
explicit X() = default;
};
struct Y {
Y();
};
Y::Y() = default;
这样一来,结构体X和Y被转变为非聚合类型,也就无法使用聚合初始化了。不过即使这样, 还是没有解决相同类型不同实例化方式表现不一致的尴尬问题,所以在C++20标准中禁止聚合类 型使用用户声明的构造函数,这种处理方式让所有的情况保持一致,是最为简单明确的方法。
这个规则的修改会改变一些旧代码的意义,比如我们经常用到的禁止复制构造的方法
struct X {
string s;
vector<int> v;
X() = default;
X(const X&) = delete;
X(X&&) = default;
};
上面这段代码中结构体X在C++17标准中是聚合类型,所以可以使用聚合类型初始化对象。但 是升级编译环境到C++20标准会使X转变为非聚合对象,从而造成无法通过编译的问题。一个可行 的解决方案是,不要直接使用= delete;来删除复制构造函数,而是通过加入或者继承一个不可复制构造的类型来实现类型的不可复制
struct X {
string s;
vector<int> v;
[[no_unique_address]] NonCopyable nc;
};
// 或者
struct X : NonCopyable {
string s;
vector<int> v;
};
这种做法能让代码看起来更加简洁,所以我们往往会被推荐这样做。
使用带小括号的列表初始化聚合类型的对象
对于一个聚合类型可以使用带大括号的列表对其进行初始化
struct X {
int i;
float f;
};
X x{ 11, 7.0f };
如果将上面初始化代码中的大括号修改为小括号,C++17标准的编译器会给出无法匹配到对 应构造函数X::X(int, float)的错误,这说明小括号会尝试调用其构造函数。这一点在C++20标准 中做出了修改,它规定对于聚合类型对象的初始化可以用小括号列表来完成,其最终结果与大括 号列表相同。所以以上代码可以修改为:
struct X {
int i;
short f;
};
int main(){
X x1{ 11, 7.0 }; // 编译失败,7.0从double转换到short是缩窄转换
X x2( 11, 7.0 ); // 编译成功
return 0;
}
用户自定义推导指引
使用自定义推导指引推导模板实例
我们自己实现一个简单的make_pair
template<typename T1, typename T2>
struct MyPair {
MyPair(const T1& x, const T2& y)
: first(x), second(y) {}
T1 first;
T2 second;
};
int main(){
MyPair p(5, "hello"); //error
auto p3 = std::make_pair(5, "hello"); // T2 = const char*
std::pair p4(5, "hello"); // T2 = const char*
}
编译出错了,编译器提示T2是一个char [6]类型。 使用stdmake_pair推导出的T2都是const char * 类型
这是因为数组类型衰退为指针 由于stdmake_pair还是stdmake_pair来说,从C++11开始它使用stdmake_pair写出自己的make_mypair
template<typename T1, typename T2>
inline MyPair <T1, T2>
make_mypair(T1 x, T2 y) {
return MyPair<T1, T2>(x, y);
}
int main() {
auto p5 = make_mypair(5, "hello");
}
接下来的问题是stdpair的实现代码中并不能发现任 何一个按值传参的构造函数。 想解决上面的问题就需要用到用户自定义推导指引了。
template<typename _T1, typename _T2> pair(_T1, _T2) -> pair<_T1, _T2>;
这是一条典型的用户自定义推导指引,其中template<typename _ T1, typename _ T2> pair是类模板名,(_ T1, T2)是形参声明,pair< T1, _ T2>是指引的目标类型。它在语法上有点类似函数的返回类型后置,只不过以类名代替了函数名。用户自定义推导指引的目的是告诉编译器如何进行推导,比如这条语句,它告诉编译器直接推导按值传递的实参,更直观地说,编译器按照pair(_ T1,_ T2)的形式推导stdpair的代码中没有按值传参的构造函数,但是用户自定义推导指引强行让编译器进行了这种推导。 用户自定义推导指引并不会改变类模板本身的定义,只是在模板的推导阶段起到引导作用, 也就是说std::pair中依旧不会存在按值传参的构造函数
我们只需要给MyPair加上一句类似的用户自定 义推导指引即可:
template<typename T1, typename T2> MyPair(T1, T2)->MyPair<T1, T2>;
MyPair p6(5, "hello");
用户自定义推导指引的用途并不局限于以上这一种,我们可以根据实际需要来灵活 使用
std::vector v{ 1, 5u, 3.0 };
它希望将1、5u和3.0都装进stdvector的容器是无法满足需求的,因为初始化元素的类型不同。为了让上述代码能够合法使用,添加用户自定义推导指引是一个不错的方案
namespace std {
template<class ...T> vector(T&&...t)->vector<common_type_t<T...>>;
}
vector v{ 1, 5u, 3.0 };
在这条用户自定义推导指引的作用下,编译器将1、5u和3.0的类型int、unsigned int和double 交给stdvector。
上面的两个例子用户自定义推导指引的对象都是模板,但事实上用户自定义推导指引不一定是模板
MyPair(int, const char*)->MyPair<long long, string>;
MyPair p7(5, "hello");
p7的类型为MyPair,因为初始化列表中5和hello 符合指引的形参声明,所以按照自定义的规则该类模板应该被实例化为MyPair。
在语法上用户自定义推导指引还支持explicit说明符,作用和其他使用场景类 似,都是要求对象显式构造:
explicit MyPair(int, const char*)->MyPair<long long, string>;
MyPair p7_1(5, "hello");
MyPair p7_2{ 5, "hello" };
MyPair p7_3 = { 5, "hello" };
在explicit说明符的作用下p7_3无法编译成功,这是因为p7_3并非显式构造,所以无法触发用 户自定义推导指引。
用户自定义推导指引声明的前半部分就如同一个构造函数声明,这就引发了一个新的问题,当类模板的构造函数和用户自定义推导指引同时满足实例化要求的时候编译器是如何选择的?
template<typename T1, typename T2>
struct MyPair {
MyPair(T1 x, T2 y):first(x), second(y) {}
T1 first;
T2 second;
};
MyPair(int, const char*)->MyPair<long long, string>;
MyPair p8(5u, "hello");
MyPair p9(5, "hello");
MyPair的构造函数的形参被修改为按值传递的方式。最终代码能够顺利地 编译通过,但是编译器对p8和p9的处理方式却不相同,对于p8,编译器使用了默认的推导规则, 其推导类型为MyPair;而对p9,编译器使用了用户自定义的推导规则 MyPair。由此可见,当类模板的构造函数和用户自定义推导指引同时满足 实例化要求的时候,编译器优先选择用户自定义推导指引。
聚合类型类模板的推导指引
在C++20标准发布之前聚合类型的类模板是无法进行模板实参推导的
template<class T>
struct Wrap {
T data;
};
Wrap w1{ 7 };
Wrap w2 = { 7 };
中w1和w2都会编译报错,错误信息提示w1和w2的类型推导失败。为了让代码顺利 地通过编译,一种方法是显式地指定模板实参,另一种方法就是为类模板Wrap编写一条用户自定义推导指引
Wrap w1{ 7 };
Wrap w2 = { 7 };
template<class T> Wrap(T)->Wrap<T>;
但C++20之后就不需要了
贡献者
版权所有
版权归属:PinkDopeyBug