前言
C语言中,结构体类型属于一种构造类型(其他的构造类型还有:数组类型,联合类型)。本文主要介绍关于结构体以下几部分。
一、概念
1、为什么要有结构体?
因为在实际问题中,一组数据往往有很多种不同的数据类型。例如,登记学生的信息,可能需要用到 char 型的姓名、in t 型或 char 型的学号、int 型的年龄、char 型的性别、float型的成绩等。又例如,对于记录一本书,需要用到 char 型的书名、char 型的作者名、float 型的价格等。在这些情况下,使用简单的基本数据类型甚至是数组都是很困难的。而结构体(类似Pascal中的“记录”),则可以有效的解决这个问题。
结构体本质上还是一种数据类型,但它可以包括若干个“成员”,每个成员的类型可以相同也可以不同,也可以是基本数据类型或者又是一个构造类型。
结构体的优点:结构体不仅可以记录不同类型的数据,而且使得数据结构是“高内聚,低耦合”的,更利于程序的阅读理解和移植,而且结构体的存储方式可以提高CPU对内存的访问速度。
2、结构声明(structure declaration)
结构声明(也见有称做定义一个结构体)是描述结构如何组合的主要方法。一般形式是:
struct 结构名 { 成员列表; ...; };
struct 关键词表示接下来是一个结构,如声明一个学生的结构:
struct Student //声明结构体 { char name[20]; //姓名 int num; //学号 float score; //成绩 };
上面的声明描述了一个包含三个不同类型的成员的结构,但它还没创建一个实际的数据对象,类似C++中的模板。每个成员变量都用自己的声明来描述,以分号结束。花括号之后的分号表示结构声明结束。结构声明可以放在函数外(此时为全局结构体,类似全局变量,在它之后声明的所有函数都可以使用),也可以放在函数内(此时为局部结构体,类似局部变量,只能放在该函数内使用,如果与全局结构体同名,则会暂时屏蔽全局结构体)。
要定义结构变量,则一般形式是: struct 结构体名 结构体变量名;
如:
struct Student stu1; //定义结构体变量
(1)结构体变量的定义可以放在结构体的声明之后:
struct Student //声明结构体 { char name[20]; //姓名 int num; //学号 float score; //成绩 }; struct Student stu1; //定义结构体变量
(2)结构体变量的定义也可以与结构体的声明同时,这样就简化了代码:
struct Student { char name[20]; int num; float score; }stu1; //在定义之后跟变量名
(3)还可以使用匿名结构体来定义结构体变量:
struct //没有结构名 { char name[20]; int num; float score; }stu1; //但要注意的是这样的方式虽然简单,但不能再次定义新的结构体变量了。
3、访问结构成员
虽然结构类似一个数组,只是数组元素的数据类型是相同的,而结构中元素的数据类型是可以不同的。但结构不能像数组那样使用下标去访问其中的各个元素,而应该用结构成员运算符点(.)。即访问成员的一般形式是: 结构变量名 . 成员名
如 stu1 . name 表示学生stu1的姓名:struct Birthday //声明结构体 Birthday { int year; int month; int day; }; struct Student //声明结构体 Student { char name[20]; int num; float score; struct Birthday birthday; //生日 }stu1;
则用 stu1.birthday.year 访问出生的年份。
4、结构体变量的初始化
(1)结构体变量的初始化可以放在定义之后:
- 对结构体的成员逐个赋值:
struct Student stu1, stu2; //定义结构体变量 strcpy(stu1.name, "Jack"); stu1.num = 18; stu1.score = 90.5; // 注意:不能直接给数组名赋值,因为数组名是一个常量。 // 如:stu1.name = "Jack";
- 对结构体进行整体赋值:
stu2 = (struct Student) { "Tom", 15, 88.0 }; // 注意:此时要进行强制类型转换,因为数组赋值也是使用{},不转换的话系统无法区分!
(2)结构体变量的初始化也可以与定义同时:
struct Student //声明结构体 Student { char name[20]; int num; float score; }stu = { "Mike", 15, 91}; //注意初始化值的类型和顺序要与结构体声明时成员的类型和顺序一致
- 也可以部分初始化:
struct Student stu4 = {.name = "Lisa"};
- 也可以按照任意的顺序使用指定初始化项目:
struct Student st = { .name = "Smith", .score = 90.5, .num = 18 };
(3)可以用一个已经存在的结构体去初始化一个新的相同类型的结构体变量,是整体的拷贝(每一个成员都一一赋值给新的结构体变量),而不是地址赋值。如:
stu3 = stu1; printf("stu1 addr: %p\nstu3 addr: %p\n", &stu1, &stu3); printf("stu1.num: %d\nstu3.num: %d\n", stu1.num, stu3.num); printf("stu1.num addr: %p\nstu3.num addr: %p\n", &stu1.num, &stu3.num); //输出结果: stu1 addr: 0x10000104c stu3 addr: 0x100001084 stu1.num: 18 stu3.num: 18 stu1.num addr: 0x100001060 stu3.num addr: 0x100001098
二、结构体数组
结构类型作为一种数据类型,也可以像基本数据类型那样,作为数组的元素的类型。元素属于结构类型的数组成为结构型数组。如开头提出的问题,生活中经常用到结构数组来表示具有相同数据结构的一个群体,如一个班的学生的信息,一个书店或图书馆的书籍信息等。
1、结构数组定义
一般格式:
struct 结构名 { 成员列表; ...; } 数组名[数组长度];
如:
struct Student //声明结构体 Student { char name[20]; int num; float score; }stu[5]; //定义一个结构结构数组stu,共有5个元素
2、结构数组的初始化
(1)定义结构数组的同时进行初始化:
struct Student stu[2] = { {"Mike", 27, 91}, {"Tom", 15, 88.0} };
(2)先定义,后初始化。整体赋值:
stu[2] = (struct Student) { "Jack", 12, 85.0 };
(3)将结构体变量的成员逐个赋值:
strcpy(stu[3].name, "Smith"); stu[3].num = 18; stu[3].score = 90.5;
3、输出结构体
//结构体数组的长度: int length = sizeof(stu) / sizeof(struct Student); //逐个输出结构数组的元素 for (int i = 0; i < length; i++) { printf("姓名:%s 学号:%d 成绩:%f \n", stu[i].name, stu[i].num, stu[i].score); }
输出结果:
在这个例子中,要注意的是:
三、结构与指针
当一个指针变量用来指向了一个结构变量,这个指针就成了结构指针变量。结构指针变量中的值是所指向的结构变量的首地址。可以通过指针来访问结构变量。
1、结构指针变量定义
一般形式:
struct 结构名 * 结构指针变量名
如:
struct Student *pstu; //定义了一个指针变量,它只能指向Student结构体类型的结构体变量
结构指针变量的定义也可以与结构体的定义同时。而且它必须先赋值后使用。数组名表示的是数组的首地址,可以直接赋值给数组指针。但结构变量名只是表示整个结构体变量,不表示结构体变量的首地址,所以不能直接赋值给结构指针变量,而应该使用 & 运算符把结构变量的的地址赋值给结构指针变量。即:
注意:结构名、结构变量名、结构体指针的区别。
2、通过结构指针间接访问成员值
访问的一般形式:
(*结构指针变量). 成员名 或 结构指针变量 -> 成员名
如:
(*pstu).name pstu->name // 注意(pstu)的小括号不能省略,因为成员符“.”优先级为1,取地址符“”优先级为2, // 去掉括号就相当于*(pstu.name)了。
四、结构体的嵌套
1、结构体中的成员可以又是一个结构体,构成结构体的嵌套
struct Birthday //声明结构体 Birthday
{
int year;
int month;
int day;
};
struct Student //声明结构体 Student
{
char name[20];
int num;
float score;
struct Birthday birthday; //生日
};
2、结构体不可以嵌套跟自己类型相同的结构体,但可以嵌套定义自己的指针
struct Student //声明结构体 Student
{
char name[20];
int num;
float score;
struct Student *friend; //嵌套定义自己的指针
}
3、甚至可以多层嵌套
struct Time //声明结构体 Time
{
int hh; //时
int mm; //分
int ss; //秒
};
struct Birthday //声明结构体 Birthday
{
int year;
int month;
int day;
struct Time dateTime //嵌套结构
};
struct Student //声明结构体 Student
{
char name[20];
int num;
float score;
struct Birthday birthday; //嵌套结构
}
//定义并初始化
struct Student stud =
{
"Jack", 32, 85,
{ 1990, 12, 3,
{ 12, 43, 23 }
}
};
//访问嵌套结构的成员并输出
printf("%s 的出生时刻:%d时 \n", stud.name, stud.birthday.dateTime.hh);
//输出结果:Jack 的出生时刻:12时
//注意如何初始化和对嵌套结构的成员进行访问。
五、结构与函数
- 结构体的成员可以作为函数的参数,属于值传递(成员是数组的除外)。如:
struct Student //声明结构体 Student
{
char name[20];
int num;
float score;
};
struct Student student0 =
{
"Mike", 27, 91
};
void printNum(int num) //定义一个函数,输出学号
{
printf("num = %d \n", num);
}
printNum(student0.num); //调用printNum 函数,以结构成员作函数的参数
//运行结果:num = 27
// 注意,函数printNum并不知道也不关心实际参数是不是结构成员,
// 它只要求实参是int类型的就可以了。
- 结构变量名也可以作为函数的参数传递,如:
struct Student student0 =
{
"Mike", 27, 91
};
void PrintStu(struct Student student) //定义 PrintStu 函数,以结构变量作函数的形参
{
student.num = 100; //修改学号
printf("PrintStu 修改后:姓名: %s, 学号: %d, 内存地址: %p \n", student.name, student.num, &student);
}
PrintStu(student0); //调用 PrintStu 函数,以结构变量名作函数的参数
printf("原来:姓名: %s, 学号: %d, 内存地址: %p \n", student0.name, student0.num, &student0);
运行结果:
形参和实参的地址不一样,是在函数中创建了一个局部结构体,然后实参对形参进行全部成员的逐个传送,在函数中对局部结构体变量进行修改并不影响原结构体变量。这样传送的时间空间开销都比较大,特别是当成员有数组的时候,程序效率较低。所以可以考虑使用指针:
- 使用指针传参,如:
struct Student student0 =
{
"Mike", 27, 91
};
void PrintStu2(struct Student *student) //定义 PrintStu2 函数,以结构指针作函数的形参
{
student->num = 100; //修改学号
printf("PrintStu2 修改后:姓名: %s, 学号: %d, 内存地址: %p \n", student->name, student->num, student);
}
PrintStu2(&student0); //调用 PrintStu 函数,以结构变量的地址作函数的参数
printf("原来:姓名: %s, 学号: %d, 内存地址: %p \n", student0.name, student0.num, &student0);
运行结果:
形参和实参的地址是一样的,所以是地址传递,在 PrintStu2 函数中,student 与&student0 指向同一块内存单元,用指针student修改结构变量会影响原结构变量。
六、结构体变量的存储原理
1、结构体数据成员对齐的意义
- 内存是以字节为单位编号的,某些硬件平台对特定类型的数据的内存要求从特定的地址开始,如果数据的存放不符合其平台的要求,就会影响到访问效率。所以在内存中各类型的数据按照一定的规则在内存中存放,就是对齐问题。而结构体所占用的内存空间就是每个成员对齐后存放时所占用的字节数之和。
- 计算机系统对基本数据类型的数据在内存中存放的限制是:这些数据的起始地址的值要求是某个数K的倍数,这就是内存对齐,而这个数 K 就是该数据类型的对齐模数。这样做的目的是为了简化处理器与内存之间传输系统的设计,并且能提升读取数据的速度。
- 结构体对齐不仅包括其各成员的内存对齐(即相对结构体的起始位置),还包括结构体的总长度。
2、结构体大小的对齐规则
- 第一个成员在与结构体变量偏移量为0的地址处。(即结构体的首地址处,即对齐到0处)
- 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
- 结构体的总大小为最大对齐数(每个成员变量都有一个对齐数)的整数倍。
- 如果嵌套了结构体,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
3、结构体大小计算
例1:
struct Test1
{
char a; //当前偏移量为0,是char所占字节数1的整数倍, 所以所占大小为1
char b; //当前偏移量为1, 是char所占字节数1的整数倍, 所以所占大小为1
char c; //当前偏移量为2,是char所占字节数的整数倍, 所以所占大小为1
int tmp; /*当前偏移量为3,不是int(默认为4个字节)所占字节数的整数倍
所以偏移量要+1变成4, 所以所占大小为4+1 */
// 一共加起来为 8
};
例2:
struct Test2
{
char a; // 1
int tmp; // 4+3 偏移量为1,不是int型的整数倍,所以要加3
char b; // 1 偏移量为8,是char型的整数倍,所以无需加
};
9是结构体第一个成员a(char)所占字节的整数倍没问题,9是结构体第二个成员 tmp(int) 所占大小的整数倍吗?显然不是,int在此编译器下默认占4个字节,所以9要加3变为12,这就满足了第三条规则了。 继续 ,12是结构体的第三个成员b(char)所占字节的整数倍 ,所以最后结果应为12。
例3:
struct Test3
{
char a; // 1
int tmp; // 4+3 偏移量为1,不是int型的整数倍所以要加3
char str[8]; // 8 偏移量为8,是int型的整数倍所,有8个元素,所以占8个字节
};
调用sizeof(struct Test3)的结果为16,注意最后的结果不考虑结构体成员中数组大小的整数倍。
例4:
struct Test4
{
char a; // 1
int tmp; // 4+3 偏移量为1不是int型的整数倍所以要加3
struct B
{ // 8
char c; // 1
int b; // 4+3 偏移量为1不是int型的整数倍所以要加3
};
float e; //4
};
对于内套的结构体,先整体计算内套的这个结构体大小,然后在加上外部的成员大小。最后调用sizeof(struct Test4)的结果为20,同上注意最后的结果不考虑结构体成员中内套整个结构体大小的整数倍。
注:有的编译器不计入未实例化结构体的大小。如例子Test4中的内套结构体struct B 未实例化对象,结果为:12,不计入内套结构体的大小,若要计入,就应实例化结构体如:
struct Test4
{
char a; // 1
int tmp; // 4+3 偏移量为1,不是int型的整数倍所以要加3
struct B{ // 8
char c; // 1
int b; // 4+3 偏移量为1,不是int型的整数倍所以要加3
}test; // 实例化结构体
float e; //4
};
例5:
#pragma pack(4) //设置默认对齐数为4
struct S1
{
char a; //1 1->4
int b; //4 4->4
char c; //1 1->4
}; // 4+4+4=12
#pragma pack() //取消设置的默认对齐数,还原为默认
#pragma pack(1) //设置默认对齐数为1
struct S2
{
char a; //1 1->1
int b; //4 4->1
char c; //1 1->1
}; //1+4+1=6
#pragma pack()//取消设置的默认对齐数,还原为默认
int main()
{
printf("%d\n", sizeof(struct S1));//打印结果为12
printf("%d\n", sizeof(struct S2));//打印结果为6
return 0;
}
预处理命令:#pragma pack()
如果在该预处理命令的括号内填上数字,那么默认对齐数将会被改为对应数字;如果只使用该预处理命令,不在括号内填写数字,那么会恢复为编译器默认的对齐数。