本篇博客详细介绍C语言最后的三种自定义类型,它们分别有着各自的特点和应用场景,重点在于理解这三种自定义类型的声明方式和使用,以及各自的特点,最后重点掌握该章节常考的考点,如:结构体内存对齐问题,使用联合判断字节序的存储问题。学完本篇博客达到理解会运用!
一、结构体(struct)
数组可理解为一种自定义数据类型,它存放的是一组相同数据类型数据的集合,每个数据可看作是一个变量,但是现实生活只有这些内置类型还是不够的,假设我想描述学生,描述一本书,这时单一的内置类型是不行的。描述一个学生需要名字、年龄、学号、身高、体重等;描述一本书需要作者、出版社、定价等。需要不同的数据类型,因此,C语言为了解决这个问题,增加了结构体这种自定义的数据类型,让程序员可以自己创造适合的类型。
结构体是一种存放不同数据类型的数据集合,同样也是一种自定义数据类型。结构体是C语言由面向过程向面向对象的一种转变。
1.1 结构体类型的声明和结构体的嵌套以及结构体指针(是指针)
结构体类型的声明通常包括结构体的名称和其成员变量的定义,结构体的成员变量可以包括基本数据类型变量、数组、结构体类型(结构体的嵌套)、指针类型。
结构体指针是指向结构体类型变量内存地址的指针。通过使用结构体指针可以访问或者修改结构体成员变量;
下面使用一个具体的例子说明如何进行结构体类型的声明和结构体的嵌套以及定义结构体指针。
结构体的声明必须放在函数的外部,它告知编译器这个结构体包括哪些成员,按照如下格式进行声明:
struct 结构体名
{
成员列表(结构体所包含的变量或数组或结构体或指针)
};此时,结构体名就是类型名。
//.c文件中(C语言语法)普通的声明结构体的方式和定义结构体变量
struct Address
{
char name[20];
char area[20];
}; //分号不能少
struct Student
{
char * name1; //字符串指针
const char * name2; //字符串指针,用const修饰,只可通过指针解引用访问,不可进行修改
char name3[20]; //字符数组
int id;
float score;
Address address; //结构体嵌套
};
int main()
{
//定义结构体变量
struct Student s={"张三","李四","王五",111,98.5,{"王麻子","陕西"}};
//定义结构体指针
struct Student * ps=&s;
//.cpp文件中(C++语法)语普通的声明结构体的方式和定义结构体变量
struct Address
{
char name[20];
char area[20];
}; //分号不能少
struct Student
{
char * name1; //字符串指针
const char * name2; //字符串指针,用const修饰,只可通过指针解引用访问,不可进行修改
char name3[20]; //字符数组
int id;
float score;
Address address; //结构体嵌套
};
int main()
{
//定义结构体变量
Student s={"张三","李四","王五",111,98.5,{"王麻子","陕西"}};
//定义结构体指针
Student * ps=&s;
return 0;
}
从上面可以看出,.c和.cpp语法的区别在于:c++语法可以省略关键字struct,而c语言语法不能省略struct,为了实际开发统一使用,可以结合使用typedef进行类型重命名,便可在两个文件同时使用,省略关键字struct。
//结构体声明结合typedef使用
typedef struct Address
{
char name[20];
char area[20];
}Address; //分号不能少
typedef struct Student
{
char * name1; //字符串指针
const char * name2; //字符串指针,用const修饰,只可通过指针解引用访问,不可进行修改
char name3[20]; //字符数组
int id;
float score;
Address address; //结构体嵌套
}Student,*Ps;
//利用typedef对结构体和结构体指针进行重命名
//相当于:typedef struct Student Student
//相当于:typedef struct Student* Ps
int main()
{
//定义结构体变量
Student s={"张三","李四","王五",111,98.5,{"王麻子","陕西"}};
//定义结构体指针
Ps=&s;
注意事项:
在对结构体成员设计时,要考虑两个方面:
- 考虑数据的长度,用合适的数据类型变量保存!
- 对于较大的数据,可以用字符串进行保存!
如:字符数组 char str[20]; 内存开辟在栈区,保存的是实际的数据
字符串指针 const char * str; 字符串存储在常量区,指针开辟在栈区,保存的是这个数据的地址
整型数据int存放不下时,可以用字符串的方式存储也可使用上述方法。
1.2 结构体变量的定义及初始化
结构体和数组类似,因此初始化方式和数组一样,采用花括号赋值!结构体嵌套时,里面的结构体也是需要花括号进行初始化的。
注意对结构体中的字符数组或者字符串进行初始化,必须使用字符串拷贝函数,切不可直接赋值,如:s.name="张三",因为左边是字符数组名为字符首元素的地址,地址是编号,是一个常量,常量不可以做左值,左值必须是可以修改的变量,右边是一个字符串常量存储在数据区,且不可以进行修改,代表首元素的地址。改正:strcpy(s.name,"张三");
注意事项:
数组与结构体类似,但是存在以下两点不同之处:
- 结构体变量名和数组名不同,数组名在表达式中会被转换成指针/地址,而结构体变量名并不会,无论在任何表达式中它表示的都是整个集合本身,要想取得结构体变量的地址必须要加取地址符&;
- 数组在内存中存放的,并且随着下标的增大,数组元素的地址也随着增大,数组所占的字节数等于数组元素个数乘以单个元素所占字节数,而结构体所占内存空间的大小,需要结合内存对齐问题进行计算!
Student s={"张三","李四","王五",111,98.5,{"王麻子","陕西"}};
结构体是一种自定义的数据类型,是创建变量的模板,不占用内存空间;结构体变量才包含了实实在在的数据,需要内存空间来存储。
1.3 结构体成员的三种访问方式
结构体成员的访问,一共有3种方式,但实际为了开发方便,常常使用2种
1.通过结构体变量访问结构体成员,语法形式:结构体变量.结构体成员名
2.通过结构体指针变量先解引用找到该结构体变量再 . 访问,
语法形式:(*结构体指针变量).结构体成员名
3.为了简化第二种引入指向符: -> ,语法形式:结构体指针变量->结构体成员名
实际开发,常常用的是第1种和第3种,第3种更加实用。
#include <stdio.h>
typedef struct Address
{
char name[20];
char area[20];
}Address,* Pa;
typedef struct Student
{
const char* name1; //字符串指针,用const修饰,只可通过指针解引用访问,不可进行修改
int id;
float score;
Address address; //结构体嵌套
}Student, * Ps;
int main()
{
Student s = { "张三",22203,99.5,{"王恒","西安市"} };
Ps p = &s;
//使用代码实现访问s中的成员address中的area成员(嵌套访问)
//第一种访问方式:通过结构体变量访问结构体成员:结构体变量.结构体成员名
printf("%s ""%d ""%f ""%s ""%s \n",s.name1,s.id,s.score,s.address.name,s.address.area);
//第二种访问方式:通过结构体指针变量先解引用找到该结构体变量再 . 访问
printf("%s ""%d ""%f ""%s ""%s \n", (*p).name1, (*p).id, (*p).score, (*p).address.name, (*p).address.area);
//第三种访问方式:为了简化第二种引入指向符: ->访问
printf("%s ""%d ""%f ""%s ""%s ", p->name1, p->id, p->score, p->address.name, p->address.area);
return 0;
}
1.4 结构体数组(是数组)
结构体数组是包含结构体类型元素的数组。通过使用结构体数组,可以创建多个结构体类型的实例,并以数组的形式组织这些实例。
#include <stdio.h>
// 结构体声明
typedef struct Person
{
char name[50];
int age;
float height;
}Person;
int main()
{
// 创建结构体数组并初始化
Person people[3] = {{"Alice", 30, 160.5},{"Bob", 25, 175.0},{"Charlie", 35, 180.2}};
// 访问结构体数组元素
for (int i = 0; i < 3; ++i)
{
printf("Person %d\n", i + 1);
printf("Name: %s\n", people[i].name);
printf("Age: %d\n", people[i].age);
printf("Height: %.2f\n", people[i].height);
printf("\n");
}
return 0;
}
1.5 结构体的内存对齐及结构体占用内存的计算(重点)
1.5.1 为什么存在内存对齐?
计算机内存是以字节为单位划分的,理论上CPU可以访问任意编号的字节,但实际情况并非如此,CPU通过地址总线来访问内存,一次能处理几个字节的数据,就命令地址总线读取几个字节的数据,32位CPU一次可以处理四个字节的数据,那么就从内存中读取4个字节的数据少了浪费主频,多了没有用,64位处理器同样,每次读取8个字节。
以32位CPU为例,实际寻址步长为4个字节,也就是只对地址编号为4的倍数的内存寻址,例如:4,8,12....这样做可以以最快的速度寻址,不遗漏一个字节,也不重复对一个字节寻址,对于程序来说,一个变量的最好位于一个寻址步长的范围内,这样一次就可以读取变量的值,如果跨步长存储,就需要读取两次,然后拼接数据,效率显然降低!
将一个数据尽量放在一个步长之内,避免跨步长存储,这就要求各种数据类型按照一定的规则在内存空间上排列,这就是内存对齐,提高了读取效率,代价就是浪费内存空间!
总体来说: 结构体的内存对齐是拿空间来换取时间的做法。
那在设计结构体的时候,我们既要满足对齐,又要节省空间,如何做到???
让占用空间小的成员尽量集中在一起!!!
1.5.2 结构体大小的计算(重点)
(1)结构体变量的首地址从偶数地址开始(用0表示)
(2)结构体每个成员相对于结构体首地址的偏移量都是min{该成员数据类型字节数,指定对齐方式}大小的整数倍,即每个成员在内存上开辟的起始地址必须是这个数,否则存在空间浪费。
(3)结构体的大小为min{结构体最大基本数据类型成员所占字节数(将嵌套结构体里的基本数据类型也算上,得到最大的基本数据类型所占字节数),指定对齐方式}大小的整数倍。
预处理指令#pragma pack(n) 可以改变默认对齐数。n 取值是 1, 2, 4, 8 , 16。
vs中默认值 = 8,gcc中默认值 = 4
//练习1
#include<stdio.h>
struct S1
{
char c1;
int i;
char c2;
};
int main()
{
printf("%d\n", sizeof(struct S1));
return 0;
}
//练习2
#include<stdio.h>
struct S2
{
char c1;
char c2;
int i;
};
int main()
{
printf("%d\n", sizeof(struct S2));
return 0;
}
//练习3
#include<stdio.h>
struct S3
{
double d;
char c;
int i;
};
int main()
{
printf("%d\n", sizeof(struct S3));
return 0;
}
//练习4-结构体嵌套问题
#include<stdio.h>
struct S3
{
double d;
char c;
int i;
};
struct S4
{
char c1;
struct S3 s3;
double d;
};
int main()
{
printf("%d\n", sizeof(struct S4));
return 0;
}
结构在对齐方式不合适的时候,我们可以自己更改默认对齐数。
#include <stdio.h>
#pragma pack(8)//设置默认对齐数为8
struct S1
{
char c1;
int i;
char c2;
};
#pragma pack()//取消设置的默认对齐数,还原为默认
#pragma pack(1)//设置默认对齐数为1
struct S2
{
char c1;
int i;
char c2;
};
#pragma pack()//取消设置的默认对齐数,还原为默认
1.6 结构体传参
结构体变量名代表的是整个集合本身,作为函数参数时,传递的是整个集合,也就是说是原来结构体成员的一份临时拷贝,而不是像数组名一样被编译器转换成一个指针,如果结构体成员较多,尤其是成员为数组时,函数传参的时候,参数是需要压栈,会有时间和空间上的系统开销。 如果传递一个结构体对象的时候,结构体过大,参数压栈的的系统开销比较大,所以会导致性能的下降。传送的时间和空间开销会很大,影响程序的执行效率,此外,这种值传递不可以通过函数内部来修改外部数据!最好的办法是使用结构体指针,这时由实参传向形参只是传递一个地址,即结构体变量的地址,占用4个字节,非常迅速,并且可以在函数内部通过解引用结构体指针从而修改外部数据,另外如果不想改变外部的数据,可以给形参加上const进行修饰!
结论: 结构体传参的时候,要传结构体的地址。
1.7 位段(了解)
1.7.1 什么是位段
有些数据在存储时并不需要占用一个完整的字节,只需要占用一个或几个二进制位即可。例如开关只有通电和断电两种状态,用 0 和 1 表示足以,也就是用一个二进位。正是基于这种考虑,C语言又提供了一种叫做位段的数据结构。在结构体定义时,我们可以指定某个成员变量所占用的二进制位数(Bit),这就是位段。
- 位段的成员必须是 int、unsigned int 或signed int 或者char。
- 位段的成员名后边有一个冒号和一个数字。
struct bs
{
unsigned m;
unsigned n: 4;
unsigned char ch: 6;
};
:
后面的数字用来限定成员变量占用的位数。成员 m 没有限制,根据数据类型即可推算出它占用 4 个字节(Byte)的内存。成员 n、ch 被:
后面的数字限制,不能再根据数据类型计算长度,它们分别占用 4、6 位(Bit)的内存。C语言标准规定,位域的宽度不能超过它所依附的数据类型的长度。通俗地讲,成员变量都是有类型的,这个类型限制了成员变量的最大长度,:
后面的数字不能超过这个长度。
1.7.2 位段的内存分配
- 位段的成员可以是 int unsigned int signed int 或者是 char (属于整形家族)类型
- 位段的空间上是按照需要以4个字节( int )或者1个字节( char )的方式来开辟的。
- 位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用位段。
1.7.3 位段的跨平台问题
- int 位段被当成有符号数还是无符号数是不确定的。
- 位段中最大位的数目不能确定。(16位机器最大16,32位机器最大32,写成27,在16位机 器会出问题。
- 位段中的成员在内存中从左向右分配,还是从右向左分配标准尚未定义。
- 当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是 舍弃剩余的位还是利用,这是不确定的。
总结:跟结构相比,位段可以达到同样的效果,并且可以很好的节省空间,但是有跨平台的问题存在。
二、枚举(enum)
2.1 枚举类型的定义
在实际编程中,有些数据的取值往往是有限的,只能是非常少量的整数,并且最好为每个值都取一个名字,以方便在后续代码中使用,比如一个星期只有七天,一年只有十二个月,一个班每周有六门课程等。#define
命令虽然能解决问题,但也带来了不小的副作用,导致宏名过多,代码松散,看起来总有点不舒服。C语言提供了一种枚举(Enum)类型,能够列出所有可能的取值,并给它们取一个名字。
#include <stdio.h>
#define Mon 1
#define Tues 2
#define Wed 3
#define Thurs 4
#define Fri 5
#define Sat 6
#define Sun 7
int main(){
int day;
scanf("%d", &day);
switch(day){
case Mon: puts("Monday"); break;
case Tues: puts("Tuesday"); break;
case Wed: puts("Wednesday"); break;
case Thurs: puts("Thursday"); break;
case Fri: puts("Friday"); break;
case Sat: puts("Saturday"); break;
case Sun: puts("Sunday"); break;
default: puts("Error!");
}
return 0;
}
枚举类型定义方式如下:
enum 枚举类型名字 {枚举值列表};
enum
是一个新的关键字,专门用来定义枚举类型,这也是它在C语言中的唯一用途;注意最后的;
不能少。并且枚举值必须是有符号整型类型。枚举类型变量需要存放的是一个整数,它的字节长度和 int 相同。
例如,列出一个星期有几天:
enum week{ Mon, Tues, Wed, Thurs, Fri, Sat, Sun };
可以看到,我们仅仅给出了名字,却没有给出名字对应的值,这是因为枚举值默认从 0 开始,往后逐个加 1(递增);也就是说,week 中的 Mon、Tues ...... Sun 对应的值分别为 0、1 ...... 6。
我们也可以给每个名字都指定一个值:
enum week{ Mon = 1, Tues = 2, Wed = 3, Thurs = 4, Fri = 5, Sat = 6, Sun = 7 };
更为简单的方法是只给第一个名字指定值:
enum week{ Mon = 1, Tues, Wed, Thurs, Fri, Sat, Sun };
这样枚举值就从 1 开始递增,跟上面的写法是等效的。
枚举是一种类型,通过它可以定义枚举变量 ,有了枚举变量,就可以把列表中的值赋给它
enum week{ Mon = 1, Tues, Wed, Thurs, Fri, Sat, Sun };
enum week a, b, c;
enum week a = Mon, b = Wed, c = Sat;
判断用户输入的是星期几。
#include <stdio.h>
int main(){
enum week{ Mon = 1, Tues, Wed, Thurs, Fri, Sat, Sun } day;
scanf("%d", &day);
switch(day){
case Mon: puts("Monday"); break;
case Tues: puts("Tuesday"); break;
case Wed: puts("Wednesday"); break;
case Thurs: puts("Thursday"); break;
case Fri: puts("Friday"); break;
case Sat: puts("Saturday"); break;
case Sun: puts("Sunday"); break;
default: puts("Error!");
}
return 0;
}
需要注意的两点是:
1) 枚举列表中的 Mon、Tues、Wed 这些标识符的作用范围是全局的(严格来说是 main() 函数内部),不能再定义与它们名字相同的变量。
2) Mon、Tues、Wed 等都是常量,不能对它们赋值,只能将它们的值赋给其他的变量。
枚举和宏其实非常类似:宏在预处理阶段将名字替换成对应的值,枚举在编译阶段将名字替换成对应的值。我们可以将枚举理解为编译阶段的宏。
2.2 枚举类型的使用
enum Color//颜色
{
RED=1,
GREEN=2,
BLUE=4
};
enum Color clr = GREEN;//只能拿枚举常量给枚举变量赋值,才不会出现类型的差异。
clr = 5; //ok??错误!!!
2.3 枚举类型的特点
为什么使用枚举? 我们可以使用 #define 定义常量,为什么非要使用枚举?
枚举的优点:
- 增加代码的可读性和可维护性
- 和#define定义的标识符比较枚举有类型检查,更加严谨。
- 便于调试
- 使用方便,一次可以定义多个常量
三、联合(共用体)(union)
在C语言中,变量的定义是分配存储空间的过程。一般的,每个变量都具有其独有的 存储空间,那么可不可以在同一个内存空间中存储不同的数据类型(不是同时存储)呢? 答案是可以的,使用联合体就可以达到这样的目的。联合体也叫共用体,在C语言中 定义联合体的关键字是union。
3.1 联合类型的定义
它的定义格式为:
union 共用体名
{
成员列表
};共用体也是一种自定义类型,可以通过它来创建变量
union data{
int n;
char ch;
double f;
};
union data a, b, c;
3.2 联合类型的特点及利用联合判断判断字节序的存储方式
3.2.1 特点
联合的成员是共用同一块内存空间的,这样一个联合变量的大小,至少是最大成员的大小(因为联 合至少得有能力保存最大的那个成员)。
#include<stdio.h>
union Un
{
int i;
char c;
};
union Un un;
int main()
{
// 下面输出的结果是一样的吗?
printf("%d\n", &(un.i));
printf("%d\n", &(un.c));
在联合体中,所有成员共享同一块内存,因此这两行输出的地址应该是相同的。
//下面输出的结果是什么?
un.i = 0x11223344;
un.c = 0x55;
printf("%x\n", un.i);;
//由于联合体的特性,此时整型成员i的值将被覆盖为字符型成员c的值,因此输出的结果将是55。
return 0;
}
3.2.2 利用联合判断判断字节序的存储方式
#include <stdio.h>
union union_test
{
int s;
char t;
}test;
int main()
{
printf("联合体 union_test 所占的字节数:%d\n", sizeof(union union_test));
test.s = 0x12345678;
if (0x78 == test.t)
{
printf("小端模式\n");
}
else
{
printf("大端模式\n");
}
return 0;
}
以上便是自定义类型全部内容,认真理解消化,一定会有极大的收获,可以留下你们点赞、关注、评论,您的支持是对我极大的鼓励,下期再见!