C语言里的结构是一种复合数据类型,如下述代码中的Employee类型。在该类型的单一对象里,同时存储了员工姓名、是否已退休、月薪、性别等信息。

1
2
3
4
5
6
struct Employee {
char sName[10]; //姓名
bool bRetired; //是否已退休
int iSalary; //月薪
GenderType gender; //性别
}; //注意末尾分号不能少

  在使用C++进行程序设计时,我们通常使用第13章中讨论的类(class)▲来达到类似目的,结构体并非必须,故本章以在线方式提供。

版权声明

本文可以在互联网上自由转载,但必须:注明出处(作者:海洋饼干叔叔)并包含指向本页面的链接。

本文不可以以纸质出版为目的进行改编、摘抄。

11. 结构

一尺之棰,日取其半,万世不竭。 —— 庄子


  朵拉同学家里运营着一家工厂,她希望编写一个程序来管理员工档案。考虑到每个雇员都有姓名、ID、性别、月薪等信息,她组织了如下的数据结构来存储雇员信息。

1
2
3
4
5
6
7
8
typedef enum {
male = 0, female = 1
} GenderType;

char idEmployees[1000][30]; //身份证号字符串数组
char nameEmployees[1000][256]; //姓名字符串数组
int salaryEmployees[1000]; //月薪字符串数组
GenderType genderEmployees[1000]; //性别数组

  在上述数据结构中,朵拉在内存中开辟了1000个员工档案的存储空间,其中,第i个员工的身份证号储存在idEmployees数组的下标i处,性别存储在genderEmployees数组的下标i处 …

  这种数据结构主要有两个缺点:

  • 数组的元素个数是固定的,当员工数量显著少于1000时,内存浪费,员工数量超过1000时,溢出。这个问题需要通过本书第19章介绍的容器类▲来解决。

  • 同一个员工的信息分散在不同的数组里,使用不便。这个问题可以通过C语言的结构(struct)类型来解决。

11.1 结构定义

  下述C语言程序定义并使用了一个名为Employee的结构类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//Project - EmployeeStruct
#include <stdio.h>
#include <stdbool.h>

typedef enum {
male = 0, female = 1
} GenderType;

struct Employee {
char sName[10]; //姓名
bool bRetired; //是否已退休
int iSalary; //月薪
GenderType gender; //性别
}; //注意末尾分号不能少

int main() {
struct Employee e = {.sName = "Jack Ma", .iSalary = 9000,
.gender = male, .bRetired = false};

printf("&e = %p, size = %lld\n", &e, sizeof(e));
printf("e.sName = %p, size = %lld\n", e.sName, sizeof(e.sName));
printf("&e.bRetired = %p, size = %lld\n", &e.bRetired, sizeof(e.bRetired));
printf("&e.iSalary = %p, size = %lld\n", &e.iSalary, sizeof(e.iSalary));
printf("&e.gender = %p, size = %lld\n", &e.gender, sizeof(e.gender));

return 0;
}

上述程序的执行结果为:

1
2
3
4
5
&e = 000000000061FE00, size = 20
e.sName = 000000000061FE00, size = 10
&e.bRetired = 000000000061FE0A, size = 1
&e.iSalary = 000000000061FE0C, size = 4
&e.gender = 000000000061FE10, size = 4

说明:在读者的计算机上,执行结果中的地址很可能与本书不同。

🚩第9 ~ 14行:定义了一个名为Employee的结构(struct),它有4个数据成员(data member),这4个数据成员分别负责记录一个雇员的姓名、是否已退休、月薪及性别信息。

  在上述定义之后,在C语言中,struct Employee便可以当成一个数据类型来使用。从面向对象程序设计的角度看,struct Employee类型与int、float、char一样,都是数据类型,区别在于,后3个是语言原生的,struct Employee是程序员通过编程“介绍”给编译器的。

  类似地,可以通过typedef定义一个名为Employee的结构类型,避免每次使用都必须带上struct关键字的烦恼:

1
2
3
4
5
6
typedef struct {
char sName[10]; //姓名
bool bRetired; //是否已退休
int iSalary; //月薪
GenderType gender; //性别
} Employee;

🚩第17 ~ 18行:定义了一名类型为struct Employee的对象e,并对其进行了初始化。与联合对象类似,其初始化值必须包裹在{ }之中。本例中,通过”.”操作符分别给各成员赋值。

1
struct Employee e = {"Jack Ma",false,9000,male}; //各成员初始值必须按顺序提供

  如上行代码所示:当{ }内的成员值按结构内成员定义顺序给出时,{ }内的成员名称可以省略。

🚩第20 ~ 24行:分别打印e、e.sName、e.bRetired、e.iSalary和e.gender的地址及字节数。与联合类型不同,结构的不同成员之间并不共享内存,它们是同一块内存的不同组成部分,相互之间是独立的。根据执行结果,我们画出了结构对象e的内存结构,如图11-1所示。

11_1

图11-1 结构对象e的内存结构

  如图11-1所示,结构对象e占20个字节的空间,其数据成员按定义顺序依次排列在这20个字节的空间内。其中:

  • e.sName类型为char[],占10个字节;

  • e.bRetired类型为bool,占1个字节,编译器出于数据对齐▲的原因,在该成员后安排了1个字节的空白区域;

  • e.iSalary类型int,占4个字节;

  • e.gender类型为GenderType枚举型,占4个字节。

11.2 结构对象

  在完成定义之后,结构类型在理论上与其他数据类型,比如int没有什么不同。下述C语言代码进一步展示了结构对象的使用方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
//Project - EmployeeInfo
#include <stdio.h>
#include <stdbool.h>

typedef enum {
male = 0, female = 1
} GenderType;

typedef struct {
char sName[10]; //姓名
bool bRetired; //是否已退休
int iSalary; //月薪
GenderType gender; //性别
} Employee;

void printEmployee(const Employee* p){
printf("------Employee Information--------\n");
printf("Name: \t%s\n",p->sName);
printf("Retired:\t%s\n",p->bRetired?"Yes":"No");
printf("Salary: \t%d\n",p->iSalary);
printf("Gender: \t%s\n",p->gender==male?"MALE":"FEMALE");
}

int main() {
Employee e = {"Jack Ma", false, 9000, male};
e.iSalary += 1000;

Employee* p = &e;
p->bRetired = true;
(*p).bRetired = false;

printEmployee(p);
return 0;
}

上述程序的执行结果为:

1
2
3
4
5
------Employee Information--------
Name: Jack Ma
Retired: No
Salary: 10000
Gender: MALE

🚩第25 ~ 26行:定义并初始化了一个名为e的Employee类型对象。第26行通过“.”操作符访问e的iSalary成员。

🚩第28 ~ 29行:定义了一个指向Employee结构的指针p。当通过指针p访问结构成员时,通过“->”操作符实现。p->bRetired表示p所指向的Employee类型结构对象的bRetired成员。

🚩第30行:对p应用间接操作符*之后,(*p)即表示p所指向的结构对象,此处为e。然后再对e应用“.”操作符,访问该结构对象的bRetired成员。这种访问方法跟p->bRetired等效。

🚩第32行:将结构对象e的指针传递给printEmployee()函数,该函数负责打印参数指针所指向的“雇员”信息。

🚩第16 ~ 22行:printEmployee()函数用于打印雇员信息。请读者注意形参e的类型为const Employee*,即指向常量Employee对象的指针。这个形参定义带来了三项益处。

  • 避免直接对Employee对象e进行传值。直接传值将“创建”一个e的复制品,该传值的代价为20个字节。而传递指针,无论对象本身有多大,指针的尺寸是固定的4字节(32位编译器)或者8字节(64位编译器)。

  • 避免在函数内意外修改p所指向的对象。按照printEmployee()函数的预期用途,这种“名不副实”的修改极其有害。

  • 让printEmployee()函数的使用者放心地将对象的指针传递给该函数使用。因为该函数“声称”不会修改参数指针指向的对象。

  当然,在C++里,我们更倾向于使用常量型的引用,而不是指针。

1
2
3
Employee e = {"Jack Ma", false, 9000, male};
Employee f;
f = e; //e,f类型相同

  C语言允许在同类型结构对象间进行整体赋值,上述代码的第3行将e完整复制到f。

1
2
3
4
5
float computeTax(Employee obj){   //用于计算员工的个税
...
}

computeTax(e);

  上述computeTax()函数的形参obj将导致传值调用(call by value)的发生。computeTax(e)将实参e复制传递给形参obj,本例中的代价为20个字节。为提高传参效率,应尽量避免对大对象进行传值。

练习巩固 👣

11-1 (人生使用进度)定义日期(Date)结构类型,其中应包括年、月、日三个成员。请实现函数lifeProgress()并编写代码加以验证。该函数接受你的出生日期及当前日期作为参数,计算并返回你的人生使用进度。

1
float lifeProgress(struct Date birth, struct Date today);

说明:按2019年我国居民人均预期寿命77.3岁进行估算;为降低难度,初始版本可以不考虑闰年,按每年365天计算,假设某人出生6900天,则其人生使用进度为6900/(77.3*365)。

11-2 (虚虚实实)结合typedef语句定义复数(Complex)结构类型,其中应包括浮点数类型的实部和虚部两个成员。请实现函数add()并编写代码加以验证。该函数用于计算并返回两个复数对象的和。

1
Complex add(const Complex a, const Complex b);

11-3 (三维向量)对于三维向量V1(x1,y1,z1)和V2(x2,y2,z2),其加法运算定义为:
$$
(x1,y1,z1) + (x2,y2,z2) = (x1+x2,y1+y2,z1+z2)
$$
  减法运算定义为:
$$
(x1,y1,z1) - (x2,y2,z2) = (x1-x2,y1-y2,z1-z2)
$$
  模长定义为:
$$
|(x,y,z)| = \sqrt{x^2+y^2+z^2}
$$
  请定义结构类型Vector3D,其三个分量为整数;请设计函数实现三维向量的加、减及求模长功能;编写合适的代码加以验证。

11.3 复合字面量

  复合字面量(compound literals),顾名思义,是由多个普通字面量组合而得。如果需要一个临时的结构对象,复合字面量很好用。下述C语言代码演示了复合字面量的用法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//Project - CompoundLiteral
#include <stdio.h>
#include <stdbool.h>

typedef struct {
float width;
float height;
} Rect; //表示一个矩形的结构体

float computeArea1(Rect r){ //矩形面积计算函数1
return r.width * r.height;
}

float computeArea2(const Rect* r){ //矩形面积计算函数2
return r->width * r->height;
}

int main() {
Rect r;
r = (Rect){15,10}; //复合字面量

float fArea1 = computeArea1((Rect){3,2}); //生成临时对象并传值
float fArea2 = computeArea2(&(Rect){3,2}); //对临时对象取地址

printf("fArea1 = %f, fArea2 = %f\n", fArea1, fArea2);
return 0;
}

上述代码的执行结果为:

1
fArea1 = 6.000000, fArea2 = 6.000000

🚩第20行:(Rect){15,10}即为一个Rect类型的复合字面量,注意类似于显式类型转换语法的(Rect)部分在这里是不可或缺的。该行创建了一个Rect类型的临时对象,然后把它赋值给r。

🚩第22行:使用复合字面量做为函数的实际参数。

🚩第23行:对复合字面量临时对象取地址,传地址调用函数。

11.4 结构对象数组

1
2
struct Employee a[3];
int b[3];

  对于编译器而言,a、b都是3个元素的数组,其数组名均为数组首元素的地址。区别在于,a的元素类型为struct Employee,b的元素类型为int。

  下述C语言程序演示了结构对象数组的使用语法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
//Project - EmployeeArray
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>

typedef enum {
male = 0, female = 1
} GenderType;

typedef struct {
char sName[20]; //姓名
int iSalary; //月薪
GenderType gender; //性别
} Employee;

int main() {
Employee es0[3] = { //3个数组元素初始化,提供3个复合字面量
{"Jack Ma", 9000, male},
{"Dorothy Henry", 5000, female},
{"Frank Bush", 6000, male}
};

Employee* es1 = (Employee*)calloc(3,sizeof(Employee));
for (int i=0;i<3;i++)
es1[i] = es0[i];

es0[1].iSalary += 200; //es0[1]指Dorothy
//es0.iSalary[1] += 200; //错误语法

Employee* p = es1;
p++; //p向右滑动一个对象,指向es1[1],即Dorothy
p->iSalary += 200; //修改的是es1[1],即Dorothy的Salary

printf("Name: %s, Salary: %d\n", es0[1].sName,es0[1].iSalary);
printf("Name: %s, Salary: %d\n", es1[1].sName,es1[1].iSalary);

free(es1); //释放calloc取得的动态内存空间
return 0;
}

上述程序的执行结果为:

1
2
Name:  Dorothy Henry,  Salary: 5200
Name: Dorothy Henry, Salary: 5200

🚩第17 ~ 21行:定义并初始化了包含3个Employee对象的数组es0。由于元素是结构对象,所以提供初始值的{ }内包含了3个复合字面量。请注意Employee是经由typedef定义的结构类型,在使用时可以省略前边的struct关键字。es0数组属于自动变量,其内存分配在栈里,生命周期由编译器负责管理。

🚩第23行:通过calloc()函数在堆里申请了3个sizeof(Employee)的空间,然后赋值给指针es1。根据第6章中的讨论,指向Employee对象的指针es1,可以当成数组名使用。

🚩第24 ~ 25行:通过循环,把数组es0中的三个结构体对象赋值给es1“数组”中的元素。

🚩第27 ~ 28行:es0是包含Employee对象的数组的数组名,es0[1]即为该数组下标为1的元素,它是一个结构体对象。es0[1].iSalary即为该结构对象的iSalary成员,本例中,指Dorothy的Salary。

🚩第30 ~ 32行:按第6章指针运算部分的讨论,p++并不是把p的值(地址)加1,而是增加一个sizeof(Employee)。p++执行后,p事实上指向es1“数组”中下标为1的元素,即Dorothy。p->iSalary即为p所指向的结构体对象Dorothy的iSalary成员。

🚩第34 ~ 35行:分别打印es0、es1数组的第1个元素的sName及iSalary。执行结果反应了前述数据修改的成果,Dorothy的工资由5000变成了5200。

🚩第37行:释放通过calloc()函数申请的堆空间。请注意,上述程序中作者为了演示指向Employee的指针运算(p++),有意新增了一个指针变量p,而没有直接对es1进行++操作。这很重要,动态内存的地址宜保持原值,因为它们是内存回收的唯一依据。

练习巩固 👣

11-4 (三维向量的较量)编程从键盘读入10个Vector3D三维向量(参考练习11-3),然后使用任意一种排序算法按照模长递增排序并依次输出。

11.5 结构的嵌套

  前述结构体包含了char[]、bool、int等各种类型的数据成员。当一个结构的成员类型也是一个结构时,称为结构的嵌套。下述C语言程序演示了一个结构嵌套的示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//Project - CircleStruct
#include <stdio.h>

struct Point { //平面上的一个点,(x,y)为坐标
int x;
int y;
};

typedef struct {
struct Point ptCenter; //圆心
float fRadius; //半径
} Circle;

void horizontalMove(Circle* c, int offset){
c->ptCenter.x += offset; //圆在水平方向上移动offset
}

int main() {
Circle c = {{0,100},4.1F};

c.ptCenter = (struct Point){0,0};
c.ptCenter.y = 0;

horizontalMove(&c, -12);

printf("center = (%d, %d), radius = %f",
c.ptCenter.x, c.ptCenter.y, c.fRadius);

return 0;
}

上述程序的执行结果为:

1
center = (-12, 0), radius = 4.100000

🚩第4 ~ 12行:定义了一个Circle类型的结构,其包含两个成员,分别是圆心ptCenter以及半径fRadius。其中,ptCenter的类型为Point结构,这属于结构嵌套的范畴。

🚩第19行:定义并初始化了Circle结构对象c。请注意{ }包裹的初始值的格式,其中,{0,100}部分用于初始化c的ptCenter成员,4.1F用于初始化fRadius成员。

🚩第21行:c.ptCenter代表c的ptCenter成员,而该成员是Point结构类型的对象,可以直接把一个struct Point类型的复合字面量赋值给它。该赋值完成后,ptCenter的x和y成员都被置为0。

🚩第22行:c.ptCenter.y代表c的ptCenter成员的y成员。

🚩第24行:调用horizontalMove()函数将c横向移动-12个坐标单位。

🚩第14 ~ 16行:形参c是一个指针,它指向被移动的Circle对象,offset代表移动偏移量。c->ptCenter.x代表指针c所指向的结构对象的ptCenter成员的x成员。由于这个函数预期要修改c所指向的圆,因此c的类型定义为Circle*,而不是const Circle*。

🚩第26 ~ 27行:打印c的值。从执行结果看,调用horizontalMove()函数对c的横向移动是成功的,其圆心坐标变成了(-12, 0)。

扩展阅读 结构体中的柔性数组成员*
通过柔性数组成员,C语言中的结构体对象可以相对灵活地存储不确定数量的数据。
http://codelearn.club/2022/05/flexiblearraymember/

练习巩固 👣

11-5 (凸六边形的面积)平面内的六边形可以用六个点坐标表示。

  请设计Point结构表示一个坐标点。

  请设计Hexagon结构表示一个六边形,其内包括6个Point成员。

  如图11-2所示,当六边形为凸六边形时,可以将其拆分成四个三角形,利用海伦-秦九韶公式分别计算三角形的面积并相加,即得凸六边形的面积。请实现凸六边形面积计算函数computeHexagonArea()并编写合适的代码加以验证。

1
float computeHexagonArea(Hexagon h);

hexagon

图11-2 凸六边形的三角形拆分