在C++中,结构体(以及类)可以拥有其他程序定义类型的成员。有两种方式可以实现这一点。
首先,我们可以在全局作用域中定义一种程序定义类型,然后将其用作另一种程序定义类型的成员:
#include <iostream>
struct Employee
{
int id {};
int age {};
double wage {};
};
struct Company
{
int numberOfEmployees {};
Employee CEO {}; // Employee是Company结构体内的一个结构体
};
int main()
{
Company myCompany{ 7, { 1, 32, 55000.0 } }; // 使用嵌套初始化列表初始化Employee
std::cout << myCompany.CEO.wage << '\n'; // 打印CEO的薪资
return 0;
}
在上述例子中,我们定义了一个Employee
结构体,然后将其作为Company
结构体的一个成员。当我们初始化Company
时,我们也可以通过使用嵌套初始化列表来初始化Employee
。如果我们想知道CEO的薪资是多少,我们只需两次使用成员选择运算符:myCompany.CEO.wage
。
其次,类型也可以嵌套在其他类型内部。因此,如果Employee
仅作为Company
的一部分存在,那么Employee
类型可以嵌套在Company
结构体内部:
#include <iostream>
struct Company
{
struct Employee // 通过Company::Employee访问
{
int id{};
int age{};
double wage{};
};
int numberOfEmployees{};
Employee CEO{}; // Employee是Company结构体内的一个结构体
};
int main()
{
Company myCompany{ 7, { 1, 32, 55000.0 } }; // 使用嵌套初始化列表初始化Employee
std::cout << myCompany.CEO.wage << '\n'; // 打印CEO的薪资
return 0;
}
这种做法更常用于类,因此我们将在后续课程(第15.3课——嵌套类型(成员类型))中进一步讨论。
作为所有者的结构体应拥有作为所有者的成员
在第5.9课——std::string_view
(第二部分)中,我们介绍了所有者和观察者的双重概念。所有者管理自己的数据,并控制其销毁时间。观察者查看其他对象的数据,但不控制数据何时被修改或销毁。
在大多数情况下,我们希望我们的结构体(以及类)是其所包含数据的所有者。这带来了一些好处:
- 成员数据的有效期将与结构体(或类)的生命周期一致。
- 成员数据的值不会意外改变。
使结构体(或类)成为所有者的最简单方法是为每个成员分配一个所有者类型(例如,不是观察者、指针或引用)。如果一个结构体或类的所有成员都是所有者,那么该结构体或类本身也会自动成为所有者。
如果一个结构体(或类)有一个成员是观察者,那么该成员所观察的对象可能会在观察该对象的成员之前被销毁。如果发生这种情况,结构体将留下一个悬挂的成员,访问该成员将导致未定义行为。
最佳实践
在大多数情况下,我们希望我们的结构体(以及类)是所有者。实现这一点的最简单方法是确保每个成员都有一个所有者类型(例如,不是观察者、指针或引用)。
作者注
使用安全的结构体。不要让成员悬挂。
这就是为什么字符串成员几乎总是std::string
类型(所有者),而不是std::string_view
类型(观察者)。以下示例说明了这一点的重要性:
#include <iostream>
#include <string>
#include <string_view>
struct Owner
{
std::string name{}; // std::string是所有者
};
struct Viewer
{
std::string_view name {}; // std::string_view是观察者
};
// getName()函数将用户输入的字符串作为临时std::string返回
// 这个临时的std::string将在包含函数调用的完整表达式结束时被销毁
std::string getName()
{
std::cout << "Enter a name: ";
std::string name{};
std::cin >> name;
return name;
}
int main()
{
Owner o { getName() }; // getName()的返回值在初始化后立即销毁
std::cout << "The owner's name is " << o.name << '\n'; // 正常工作
Viewer v { getName() }; // getName()的返回值在初始化后立即销毁
std::cout << "The viewer's name is " << v.name << '\n'; // 未定义行为
return 0;
}
getName()
函数将用户输入的名称作为临时std::string
返回。这个临时返回值将在调用该函数的完整表达式结束时被销毁。
在o
的情况下,这个临时std::string
被用来初始化o.name
。由于o.name
是std::string
类型,o.name
会复制这个临时std::string
。然后临时std::string
被销毁,但o.name
不受影响,因为它是一个副本。在随后的语句中打印o.name
时,它按预期工作。
在v
的情况下,这个临时std::string
被用来初始化v.name
。由于v.name
是std::string_view
类型,v.name
只是临时std::string
的一个视图,而不是副本。然后临时std::string
被销毁,留下v.name
悬挂。在随后的语句中打印v.name
时,会出现未定义行为。
结构体大小和数据结构对齐
通常,结构体的大小是其所有成员大小之和,但并不总是这样!
考虑以下程序:
#include <iostream>
struct Foo
{
short a {};
int b {};
double c {};
};
int main()
{
std::cout << "The size of short is " << sizeof(short) << " bytes\n";
std::cout << "The size of int is " << sizeof(int) << " bytes\n";
std::cout << "The size of double is " << sizeof(double) << " bytes\n";
std::cout << "The size of Foo is " << sizeof(Foo) << " bytes\n";
return 0;
}
在作者的机器上,该程序打印:
The size of short is 2 bytes
The size of int is 4 bytes
The size of double is 8 bytes
The size of Foo is 16 bytes
请注意,short
+ int
+ double
的大小是14字节,但Foo
的大小是16字节!
事实证明,我们只能说结构体的大小至少与其包含的所有变量的大小一样大。但它可能会更大!出于性能原因,编译器有时会在结构体中添加空隙(这被称为填充)。
在上述Foo
结构体中,编译器在成员a
之后无形中添加了2字节的填充,使结构体的大小变为16字节,而不是14字节。
对于高级读者
编译器可能添加填充的原因超出了本教程的范围,但希望了解更多信息的读者可以在维基百科上阅读有关数据结构对齐的内容。这是可选阅读内容,并非理解结构体或C++所必需的!
这实际上可能会对结构体的大小产生相当大的影响,以下程序展示了这一点:
#include <iostream>
struct Foo1
{
short a{}; // a后面会有2字节的填充
int b{};
short c{}; // c后面会有2字节的填充
};
struct Foo2
{
int b{};
short a{};
short c{};
};
int main()
{
std::cout << sizeof(Foo1) << '\n'; // 打印12
std::cout << sizeof(Foo2) << '\n'; // 打印8
return 0;
}
该程序打印:
12
8
请注意,Foo1
和Foo2
具有相同的成员,唯一的区别是声明顺序。然而,由于添加了填充,Foo1
比Foo2
大50%。
提示
可以通过按大小降序定义成员来最小化填充。
C++编译器不允许重新排序成员,因此必须手动完成。