引言
-
struct
(结构)是由任意类型元素(即成员,member
)构成的序列。 -
union
是一种struct
,同一时刻只保存一个元素的值。 -
enum
(枚举)是包含一组命名常量(称为枚举值)的类型。 -
enum class
(限定作用域的枚举类型)是一种enum
,枚举值位于枚举类型的作用域内,不存在向其他类型的隐式类型转换。
结构
#include <iostream>
struct Address {
const char* name;
int number;
char state[2];
};
int main() {
Address address1 {
"John Doe",
123,
{'N', 'J'} // 注意,不能是 "NJ",因为 "NJ" 是 3 个字符(结尾有一个'\0')
};
std::cout << address1.name << std::endl;
Address* add_ptr = &address1;
std::cout << add_ptr->name << std::endl;
Address address2 {"Jane Doe", 456, {'N', 'Y'}};
// 默认情况下,以下操作不适用于数组
//address1 == address2; // 不OK
//address1 != address2; // 不OK
}
布局
在内存中为成员分配空间时,顺序与声明结构的时候保持一致。
struct Readout {
char hour;
int value;
char seq;
};
因此,hour
的地址一定在 value
的地址之前。
然而,一个 struct
对象的大小不一定恰好等于它所有元素大小的累积之和。因为很多机器要求一些特定类型的对象沿着系统结构设定的边界分配空间,以便机器能高效地处理这些对象。例如,整数通常沿着字的边界分配空间。在这类机器上,我们说对象对齐(aligned)得很好。这种做法会导致在结构中存在“空洞”。在 4 字节 int
的机器上,Readout
的布局很可能是:
你也可以把成员按照各自的尺寸排序(大的在前),这样能在一定程度上减少空间浪费。例如:
struct Readout {
int value;
char hour;
char seq;
};
此时,Readout
的存储方式是:
在 Readout
中仍然包含一个 2 字节的“空洞”(未使用空间),sizeof(Readout)== 8
。这一点也不奇怪,毕竟当我们将来把两个 Readout
对象放在一起时(构成数组),肯定也希望它们是对齐的。
名字
类型名字只要一出现就能马上使用了,无须等到该类型的声明全部完成。例如:
struct Link {
Link* prev;
Link* next;
};
但是,只有等到 struct
的声明全部完成,才能声明它的对象。例如:
struct No_Good {
No_Good member; // 递归定义,会导致编译错误
};
因为编译器无法确定 No_good
的大小,所以程序会报错。
要想让两个或更多 struct
互相引用,必须提前声明好 struct
的名字。例如:
struct List; // 结构体名字声明,稍后再定义List
struct Link {
Link* pre;
Link* suc;
List* member_of;
int data;
};
struct List {
Link* head;
};
我们可以在真正定义一个 struct
类型之前就使用它的名字,只要在此过程中不使用成员的名字和结构的大小就行了。然而,直到 struct
的声明全部完成之前,它都是一个不完整的类型。
struct S;
extern S a;
S f();
void g(S);
S* h(S*);
// 上面的操作都OK
void k(S* p) {
S a; // 错误:还没有定义S,分配空间需要用到S的尺寸 error C2079: 'a' uses undefined struct 'S'
f(); // 错误:还没有定义S,返回值需要用到S的尺寸 error C2027: use of undefined type 'S'
g(a); // 错误:还没有定义S,传递实参需要用到S的尺寸 error C2664: 'void g(S)': cannot convert argument 1 from 'S' to 'S'
p->m = 7; // 错误:还没有定义S,成员名字未知 error C2027: use of undefined type 'S'
S* q = h(p); // OK,指针尺寸已知
q->m = 7; // 错误:还没有定义S,成员名字未知 error C2027: use of undefined type 'S'
}
为了符合 C 语言早期的规定,C++ 允许在同一个作用域中分别声明一对同名的 struct
和非 struct
。例如:
struct stat { /* ... */ };
int stat(char* name, struct stat* buf);
此时,普通的名字(stat
)默认是非 struct
的名字,要想表示 struct
必须在 stat
前加上前缀 struct
。类似地,我们还可以让关键字 class
、union
(见 8.3 节)和 enum
(见 8.4 节)作为名字的前缀以避免二义性。
建议:程序员应该尽量避免使用这种同名实体。
结构与类
struct
是一种 class
,它的成员默认是 public
的。struct
可以包含成员函数,尤其是构造函数。
如果你只想按照默认的顺序初始化结构的成员,则不需要专门定义一个构造函数。
但是如果你需要改变实参的顺序、检验实参的有效性、修改实参或者建立不变式,则应该编写一个专门的构造函数。
#include <iostream>
#include <initializer_list>
struct Point1 {
const char* name;
int a;
};
struct Point2 {
const char* name;
int a;
Point2() : name("name"), a(0) {
std::cout << "Point2() called" << std::endl;
}
};
struct Point3 {
const char* name;
int a;
Point3(int a, const char* name) : a(a), name(name) {};
};
struct Point4 {
const char* name;
int a;
Point4(int a, const char* name) : a(a), name(name) {
std::cout << "Point4(int a, const char* name) called" << std::endl;
};
Point4(const char* name) : a(0), name(name) {
std::cout << "Point4(const char* name) called" << std::endl;
};
Point4(std::initializer_list<const char*> init) {
if (init.size() > 0) {
name = *init.begin(); // 假设初始化列表中的第一个元素是name
}
else {
name = "defaultName"; // 如果没有提供name,则使用默认值
}
// 由于int类型不能直接从const char*初始化,这里需要额外的处理
// 这里只是一个示例,实际上你可能需要从init中获取一个int类型的值
a = 0; // 默认值
std::cout << "Point4(std::initializer_list<const char*> init) called" << std::endl;
}
};
int main() {
Point1 p1_1{ "p1", 1 };
Point1 p1_2; // OK,可以理解为调用无参的默认构造函数(实际上只是在栈上划分一块内存,没任何操作)
Point1 p1_3(); // 注意:不报错,但是语义和认为的不同,编译器会认为这是在定义函数
//Point1 p1_4("p1", 1); // 不OK,Point1没有定义构造函数,而编译器提供的默认构造函数不接受参数,所以参数列表不匹配
//Point2 p2_1{ "p1", 1 }; // 不OK,找不到接收两个参数的构造函数
Point2 p2_2; // OK,调用 Point2()
Point2 p2_3(); //注意:不报错,但是语义和认为的不同,编译器会认为这是在定义函数
//Point3 p3_1; // 不OK,Point3没有定义无参的默认构造函数
//Point3 p3_1 = Point3(); // 不OK,Point3没有定义无参的默认构造函数
Point3 p3_2(); // 注意:不报错,但是语义和认为的不同,编译器会认为这是在定义函数
Point3 p3_3(2, "p3");
Point3 p3_4{ 3, "p3" };
//Point3 p3_5{ "p3", 4 }; // 不OK,因为列表初始化器与构造函数参数列表不匹配
Point4 p4_1(2, "p3"); // 调用 Point4(int a, const char* name)
Point4 p4_2("p3"); // 调用 Point4(const char* name)
Point4 p4_3{ "p3" }; // 调用 Point4(std::initializer_list<const char*> init)
Point4 p4_4{ 3, "p3" }; // 调用 Point4(int a, const char* name)
//Point4 p4_5{ 3.0, "p3" }; // 不OK,不会窄化转换
}
总结如下:
-
对于没有定义构造函数的结构体
- 可使用列表初始化器,按照定义参数的顺序初始化所有成员
- 编译器提供了无参的默认构造函数,因此可使用无参的构造函数
-
对于定义了构造函数的结构体
-
编译器不再提供无参的默认构造函数
-
使用初始化列表器时
- 查看有没有一个构造函数,函数的参数与该初始化器列表
std::initializer_list<T>
一致(可用于重新定义列表初始化器的行为,如std::vector
) - 查找与列表初始化器中参数对应的构造函数
- 如果上面都没有,则报错
- 查看有没有一个构造函数,函数的参数与该初始化器列表
-
注意,永远不要尝试写下面的代码,来定义结构体(或类)变量:
T t();
编译器会认为这是在声明函数!!!
结构与数组
与内置数组相比,std::array
有两个明显的优势:首先它是一种真正的对象类型(可以执行赋值操作),其次它不会隐式地转换成指向元素的指针。
std::array
也有不足,我们无法从初始化器的长度推断元素的数量。
类型等价
对于两个 struct
来说,即使它们的成员相同,它们本身仍是不同的类型。
struct S1 { int a; };
struct S2 { int a; };
S1 x;
S2 y = x; // 错误,类型不匹配
此外,在程序中,每个 struct
只能有唯一的定义。
普通旧数据(POD)
普通旧数据(Plain Old Data,简称 POD)是 C++ 中非常重要的一个概念,用来描述一个类型的属性其中 Plain 表示这个类型是个平凡的类型,Old 表示其与 C 的兼容性。
很久很久以前,C 语言统一了江湖。几乎所有的系统底层都是用 C 写的,当时定义的基本数据类型有 int、char、float 等整数类型、浮点类型、枚举、void、指针、数组、结构等等。然后只要碰到一串 01010110010 之类的数据,编译器都可以正确的把它解析出来。
那么到了 C++ 诞生之后,出现了继承、派生这样新的概念,于是就诞生了一些新的数据结构。比如某个派生类,C 语言中哪有派生的概念啊,遇到这种数据编译器就不认识了。可是我们的计算机世界里,主要的系统还是用 C 写的啊,为了和旧的 C 数据相兼容,C++ 就提出了 POD 数据结构概念。
对于 POD,可以:
-
字节赋值,我们可以放心的使用
memset
和memcpy
对 POD 类型进行初始化和拷贝。-
例如,如果要拷贝一个含有 100 个元素的数组,假设元素是结构体类型
- 如果不是 POD,则需要调用构造函数
- 如果是 POD,则可以直接
memcpy(to,from,100*sizeof(T))
-
-
提供对 C 内存的兼容。POD 类型的数据在 C 与 C++ 间的操作总是安全的。
使用 std::is_pod<T>::value
检查是否是 POD
#include <iostream>
#include <type_traits> // std::is_pod 所需头文件
struct Point1 {
const char* name;
int a;
};
int main() {
std::cout << std::is_pod<Point1>::value << std::endl;
}
POD 在 C++20 已经被移除,取而代之的是更精细的 trivial(平凡)、standard-layout(标准布局)
以下信息参考自:【C/C++ POD 类型】深度解析 C++ 中的 POD 类型:从理论基础到项目实践
在 C++ 中,POD 类型是由两种类型组合而成的:Trivial 类型和 Standard layout 类型。下面我们将详细介绍这两种类型。
Trivial 类型是一种简单的类型,它的所有操作都可以通过简单的内存复制来完成。具体来说,一个类型是 Trivial 类型,需要满足以下条件:
- 它的所有非静态成员都是 Trivial 类型。
- 它是一个类类型(class 或 struct),但没有用户定义的构造函数。
- 它没有虚函数和虚基类。
- 它没有非静态成员的类类型或数组,或者所有这些类类型和数组都是 Trivial 类型。
例如,以下的类型都是 Trivial 类型:
class Trivial1 { int a; char b; }; struct Trivial2 { double x; Trivial1 y; }; typedef int Trivial3[10];
Standard layout 类型是一种内存布局可以被完全预测的类型。具体来说,一个类型是 Standard layout 类型,需要满足以下条件:
- 它的所有非静态成员都是 Standard layout 类型。
- 它是一个类类型,但没有虚函数和虚基类。
- 它的所有非静态成员,包括在其所有基类中的非静态成员,都有相同的访问控制(public、private、protected)。
- 它的所有非静态成员,包括在其所有基类中的非静态成员,都是 Standard layout 类型。
- 它和其所有基类中最多只有一个类有非静态数据成员。
例如,以下的类型都是 Standard layout 类型:
class StandardLayout1 { public: int a; char b; }; struct StandardLayout2 { public: double x; private: StandardLayout1 y; }; typedef int StandardLayout3[10];
如果一个类型既是 Trivial 类型又是 Standard layout 类型,那么它就是 POD 类型。这意味着 POD 类型的对象可以被视为一段原始的、可以被任意读写的内存。这使得 POD 类型非常适合用于低级的内存操作,例如内存映射、二进制文件读写等。
域
域也称为位域(bit-field),
#include <iostream>
#include <cstddef> // 包含offsetof宏
struct PPN {
unsigned int aaa; // 常规变量
unsigned int PFN : 22; // 页框编号
int : 3; // 未使用
unsigned int CCA : 3; // 缓存一致性算法
bool nonreachable : 1;
bool dirty : 1;
bool valid : 1;
bool global : 1;
};
void pard_of_VM_system(PPN* p) {
// ...
if (p->dirty) { // 更改了内容
// 拷贝到磁盘
p->dirty = 0;
}
}
int main() {
std::cout << "sizeof(PPN) = " << sizeof(PPN) << std::endl; // 12字节
// 22+3+3+1+1+1+1=32bit+4字节,多出来的可能对齐了
}
使用位域打包变量并不总是节省空间,反而可能导致代码更长、更复杂。将二进制变量存储为字符或整数通常能显著减小程序规模,并提高访问速度。位域只是通过位逻辑运算来提取或插入信息的一种便捷方式。
联合
union
是一种特殊的 struct
,它的所有成员都分配在同一个地址空间上。因此,一个 union
实际占用的空间大小与其最大的成员一样。自然地,在同一时刻 union
只能保存一个成员的值。
联合与类
从技术上来说,union
是一种特殊的 struct
(见 8.2 节),而 struct
是一种特殊的 class
(第 16 章)。然而,很多提供给 class
的功能与 union
无关,因此对 union
施加了一些限制:
-
union
不能含有虚函数。 -
union
不能含有引用类型的成员。 -
union
不能含有基类。 -
如果
union
的成员含有用户自定义的构造函数、拷贝操作、移动操作或者析构函数,则此类函数对于union
来说被delete
掉了。换句话说,union
类型的对象不能含有这些函数。也能就是说,如果成员有析构函数,则需要手动管理元素的声明周期(比如手动调用析构函数)
-
在
union
的所有成员中,最多只能有一个成员包含类内初始化器。union U2 { int a; const char* p {"m"}; }; U2 x1; // 执行默认初始化,使得 x1.p = "m"; U2 x2 = {7}; // 使得 x2.a == 7
此时,该初始化器被用于默认初始化。
-
union
不能被用作其他类的基类。
这些约束规则有效地阻止了很多错误的发生,同时简化了 union
的实现过程。
匿名 union
匿名联合是一个对象而非一种类型,我们无须对象名就能直接访问它的成员。因此,我们使用匿名联合的成员的方式与使用类成员的方式完全一样,只要谨记同一时刻只能使用 union
的一个成员就可以了。
#include <iostream>
class Entity {
public:
union {
int a;
short b;
};
};
int main() {
Entity e;
e.a = 100; // 小端序,所以可以这么玩
std::cout << e.b << std::endl; // Output: 100
}
枚举
枚举类型分为两种:
-
enum class
,它的枚举值名字(比如red
)位于enum
的局部作用域内,枚举值不会隐式地转换成其他类型。enum class Color { red, green, blue };
-
“普通的
enum
”,它的枚举值名字与enum
类型本身位于同一个作用域中,枚举值可隐式地转换成整数。enum Direction { north, south, east, west };
枚举常用的一些整数类型表示,每个枚举值是一个整数。我们把用于表示某个枚举的类型称为它的基础类型(underlying type)。基础类型必须是一种带符号或无符号的整数类型,默认是 int
。我们可以显式地指定:
int main() {
enum class Warning : char { greed, yellow, orange=10, red };
static_assert(sizeof(Warning) == 1,"sizeof(Warning) != 1");
static_assert(static_cast<char>(Warning::greed) == 0, "static_cast<char>(Warning::greed) != 0");
static_assert(static_cast<char>(Warning::yellow) == 1, "static_cast<char>(Warning::yellow) != 1");
static_assert(static_cast<char>(Warning::orange) == 10, "static_cast<char>(Warning::orange) != 10");
static_assert(static_cast<char>(Warning::red) == 11, "static_cast<char>(Warning::red) != 11");
}
默认情况下,枚举值从 0 开始,依次递增(如果有指定,则在指定后递增)。
注意:其他类型不会隐式转换成枚举,不管是 enum class
还是“普通的 enum
”。
一个整数类型的值可以显式地转换成枚举类型。如果这个值属于枚举的基础类型的取值范围,则转换是有效的;否则,如果超出了合理的表示范围,则转换的结果是未定义的。
enum class Flag :char { x = 1, y = 2, z = 4, e = 8 };
Flag f0{}; //f0的默认值是0
Flag f1 = 5; //类型错误:5不属于Flag类型
Flag f2 = Flag{ 5 }; //错误:不允许窄化转换成enum class类型
Flag f3 = static_cast<Flag>(5); //“不近人情”的转换
Flag f4 = static_cast<Flag>(999); //错误:999不是一个char类型的值(也许根本捕获不到)
C++ 允许先声明一个 enumclass,稍后再给出它的定义。
enum class Color_code : char;
void foobar(Color_code* p);
// 使用声明
enum class Color_code : char { red, yellow, green, blue };
未命名的枚举
一个普通的 enum
可以是未命名的,例如
enum { arrow_up = 1, arrow_down, arrow_sideways };
如果我们需要的只是一组整型常量,而不是用于声明变量的类型,则可以使用未命名的 enum
。