文章目录
c语言自定义类型
在c语言中,有很多类型的数据如char,int,long int,double等。但是为了解决更多生活中的问题,光有这些变量肯定是不够的。因为描述一个人性别,名字,成绩都是需要很多不同的变量进行描述的。所以在c语言中,引进了一种叫做自定义类型的数据。本篇文章将对这些自定义类型进行讲解。
结构体
结构体变量的创建和声名
引入:
假设现在需要一个变量STU,能够包含学生的名字,年龄,成绩。在以前的学习我们知道,这不太可能。因为一次只能声名一个变量。是无法包含那么多变量的。为了解决这个问题,c语言引入了一个叫做结构体的变量。
变量声明:
结构体的创建是需要使用关键字struct的。表明这是一个结构体的变量。现在按照要求声名一个结构体类型STU,包含学生名字,年龄,成绩。
如代码所示:
int main(){
struct STU{
char name[20];
int age;
double score;
}stu1,stu2;
struct STU s;
return 0;
}
如图所示,结构体的声名十分简单。使用struct+变量名。然后使用大括号,将需要的变量加入进打括号内。最后注意,末尾需要加分号;。
当然我们可以在定义结构体的时候直接声名变量,如代码中s1,s2。这就表示我们在定义结构体struct STU的时候就已经声名了这个类型的结构体变量s1,s2了。
需要注意的是:
1.这个结构体变量的类型叫做struct STU,而不是STU。声名变量s时前面的类型要使用正确
2.结构体里面的每个变量叫做成员,声名结构体类型的时候不需要对里面的成员变量进行初始化。因为这是个类型,旨在说明这个结构体类型包含哪些变量。
匿名结构体
当然,c语言中还存在一种特殊的写法,就是声名结构体的时候不加入变量名,如上述STU。
如代码所示:
Struct{
int a;
char b;
double c;
}s1,s2;
当我们这样创建一个结构体类型的时候,是匿名的。如果我们想在后续进行这个类型的结构体变量声名的话是不成功的。不能写成 struct s3={1,2,3};
这样的写法。
要想声名变量,则必须在类型创建的时候就进行声名,如代码中的s1,s2。这样子在后续的使用是可以正常使用的。但因为不太严谨,建议少用,或者只需要使用一次这样类型的变量的时候就可以使用一下。
结构体变量的初始化和赋值(引用)
既然声明了一个变量,那就要给其初始化或者赋值。结构体变量也是不例外。现在我们将讲解如何对结构体变量初始化。
初始化:
假设有一个学生,名字叫张三,年龄18,成绩95。上一个部分已经声明了一个结构体变量STU,不妨就使用这个变量。
初始化代码如下:
int main(){
struct STU stu={"张三",18,95};
return 0;
}
初始化的时候,使用大括号,从结构体类型的上至下一次赋值,中间用逗号分隔。
当我们不知道具体要初始化为什么值的时候,可以像其他变量的初始化一样,将变量初始化为0;如:struct STU stu={0};
当然,为了方便,如果不想每次都写struct这个关键字,我们可以对其进行简化。需使用关键字typedef。这个关键字的作用就是对名字进行重定义。
使用如下:
typedef struct STU{
char name[20];
int age;
double score;
}STU;
通过typedef的使用,我们成功的struct STU类型定义成了STU类型。这两个是一样的。所以声明变量的时候可以这么写:STU stu={"张三”,18,95};
结构体成员的赋值与访问:
现在我们来讲一下结构体变量是如何进行访问的。
一段代码如下:
int main() {
typedef struct STU {
char name[20];
int age;
double score;
}STU;
STU A = { "张三",18,95 };
STU B = { 0 };
return 0;
}
初始化了两个STU类型的变量A和B,当我们想将A中的变量打印在屏幕上的时候该怎么办呢?
有两种方法:
1.使用操作符. :
当前声明了变量A。我们可以直接使用符号.对A中的成员进行访问。
使用:printf("%s %d %lf",A.name,A.age,A.score);
就可以将A中的成员访问并打印。
如图:
如果想把A中的成员值赋给B,可以使用如下代码:
B.age=A.age;
B.score=B.score;
strcpy(B.name,A.name);
这里需要注意:
结构体中的成员name是一个数组。数组赋值的时候是不能直接使用=进行赋值的,这时候就可以使用库函数strcpy进行复制,达到赋值的效果。
对于strcpy这类库函数,我在另一篇文章讲过,感兴趣的读者可以前往查看:部分库函数模拟和实现
2.使用操作符-> :
还有另一种方法,这种方法是需要指针进行操作。
一段代码如下:
int main() {
typedef struct STU {
char name[20];
int age;
double score;
}STU;
STU A = { "张三",18,95 };
STU* pA = &A;
printf("%s %d %lf", pA->name, pA->age, pA->score);
return 0;
}
声名了一个指针变量,类型为STU*,即指向一个STU(struct STU)类型的指针。此时pA为结构体变量A的地址。我们可以通过地址指向的成员进行访问。使用操作符->
代码运行结果:
仍然能够正常运行并且打印。
结构体自引用
结构体内部中成员变量有很多种,可以是数组,整形数据,浮点型数据,甚至也可以是结构体。结构体可以是与本身不相同的结构体,也可以是与自身相同类型的结构体
如代码所示:
struct Node{
int data;
struct Node next;
}
我们定义一个节点,节点包含整形数据data,包含另一个节点。这就有点像是将几个节点连载一起。其实有点像链表。链表我将会放在数据结构部分中进行深入讲解,在这只做了解。
假设第一个节点为A
当我们访问第二个节点的数据时,应该使用语句A.next.data;
第三个:A.next.next.data;
感兴趣的读者可以对其进行赋值编译,但其实用处较少。因为链表使用更多是指针应用,对应于结构体自引用的用法较少,这不是重点,在这不做深究。
结构体的传参
上个部分讲到,结构体的成员访问方式有两种。当定义一个函数,其传入参数包含结构体变量时,传参的方式应该也是有两种。现在让我们一探究性。
假设我们要写一个函数,将传入结构体变量的信息打印
1.传值调用:
void Print_STU_by_val(STU s) {
printf("%s %d %lf\n", s.name, s.age, s.score);
}
当传入的是结构体变量本身的值的时候,使用操作符.进行访问成员变量。然后进行打印。
2.传址调用:
void Print_STU_by_pointer(STU* s) {
printf("%s %d %lf\n", s->name, s->age, s->score);
//或者写成printf("%s %d %lf\n", *s.name, *s.age, *s.score);
}
使用指针变量作为参数的时候,可以使用操作符->进行访问。当然如果想使用.也是可以的,s是结构体指针变量,对其解引用就是结构体本身。然后就可以使用操作符.进行访问了
乍一看,好像也没啥区别,都是一样的效果。为什么要传地址呢?
最主要的原因有两个:
1. 传址调用可以节省空间
在这里我们涉及一点c语言内存分配区域的知识。c语言几个内存区域。其中有个区域叫做栈区。我们要知道的是:栈区会自动回收内存。 这是专门用来存放临时变量,运行的函数等。当我们在栈区开辟一个空间运行函数,内部开辟临时变量,此时等到函数进程结束的时候,函数会被销毁,那里面的临时变量也会销毁。
现在让我们来看一下传值调用和传址调用的区别:
传值调用时,函数内部的参数s实际上是我们传入结构体变量stu的一份临时拷贝,但是这个变量s也是需要内存存储,所以会在栈区开辟一块空间使用。而传址调用我们传入的是变量stu的地址,此时内部开辟的临时变量是一个指针,这个指针会找到传入变量的地址,从而进行成员访问等一系列操作。而指针相比于我们传入的结构体,内存小的多。所需要的内存少一些。
2. 当我们需要使用函数来修改结构体的内容时需使用指针
我们之前说到过swap()的例子,上述也提到了传值调用和传址调用的区别。当我们需要通过函数来修改一个变量的值的时候,我们是没办法使用传值调用的。因为形参是实参的一份临时拷贝,其生命周期只在函数运行时。所以形参并不能改变实参的内容。而传入地址就不一样了,形参是指针变量,可以通过这个地址找到传入的变量,从而进行修改。
结构体在内存中的存储
现在我们想知道结构体这个变量的大小是多少。但我们首先得明确的是,结构体是自定义类型数据,内部成员变量取决于用户的定义,所以大小是不同的。所以我们要探究的应该是结构体变量在内存中存储的规律
现有一个结构体声名如下:
typedef struct S{
char a;
int b;
double c;
}S;
请问结构体变量S的大小是多少?
很多人应该会不假思索的回答1+4+8=13这个答案吧,答案真的对吗?
答案是惊人的16,这是为什么?
这里我们就得提到结构体变量存储一个特殊规则:内存对齐
首先我们得直到结构题内存对其的规则:
1. 结构体的第⼀个成员对齐到和结构体变量起始位置偏移量为0的地址处
2. 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。 对齐数 = 编译器默认的⼀个对齐数 与 该成员变量大小的较小值。 VS 中默认的值为 8。Linux中 gcc 没有默认对齐数,对齐数就是成员自身的大小
3. 结构体总大小为最大对齐数(结构体中每个成员变量都有⼀个对齐数,所有对齐数中最大的)整数倍。
4. 如果嵌套了结构体的情况,嵌套的结构体成员对齐到自己的成员中最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体中成员的对齐数)的整数倍
我们先将第一个概念:偏移量
结构体变量的其实位置就是结构体变量的地址。假设图中S指向的位置就是结构体变量S的地址。规定,结构体变量的地址为偏移量为的地方。然后从偏移量为的地方开始,每往后一个字节,偏移量就+1。结构体中的第一个成员总是从偏移量为0的地方开始存储,所以偏移量0的位置开始存放char a。占用一个字节。
其他成员变量要对齐到(对齐数)的整数倍的地址处。对齐数 = 编译器默认的⼀个对齐数 与 该成员变量大小的较小值。在这里我们选取的编译器是vs 2022,这个默认的值是8。结构体S第二个成员是int类型,占四个字节。所以对齐数=min{4,8}=4。所以该int类型数据需要向下寻找到偏移量为第一个的对齐数整数倍的地方。即偏移量=4处。
第三个成员变量double类型,对齐数=min{8,8}=8。向后找到第一个偏移量=8的倍数的地方,即偏移量=8处。
此时三个变量被存储在偏移量0~15的这十六个字节中,最后需要根据对齐规则3来判断。
规则3:结构体总大小为最大对齐数(结构体中每个成员变量都有⼀个对齐数,所有对齐数中最大的)整数倍。
我们发现,上述结构体存储完后,大小为16个字节。而结构体内三个成员中,最大的最大对齐数=8,16刚好是8的倍数,所以不需要再对齐。所以这个结构体的大小就是16。
当然,vs2022中还提供了一个预处理指令#prgma来修改默认对齐数。
具体操作为:#prgma pack(n) 其中这个n为一个正整数,需要用户自己填写。
修改之后,默认值就改变成了n,方法就和上述操作一样,只不过是默认值改变罢了。我就不再具体举例说明了额。但是值得建议的是,这个默认数一般都是2的整数次幂(幂>=0)。具体原因我会放在后面内存对齐的优点时说明。
讲完上述这个例子,我们发现对齐规则的第四条并没有用上,所以我们来看一下结构体里面包含另一个结构体的情况。
struct S {
char a;
int b;
double c;
};
struct S1 {
int d;
char arr[5];
struct S;
};
我们现在来测试一下struct S1的大小:
答案是32,画图解释一下。
首先,第一个成员从偏移量为0的地方开始存储:
然后,第二个成员时char arr[5],这里要注意:这个数组的在与默认值8对比的时候,应该使用char类型的大小1,而不是char arr[5]的大小5比较。所以第二个成员变量的最大对齐数时是min{1,8}=1。往后找第一个为偏移量为1的倍数的地方,即偏移量=4的地方。如图所示:
第三个成员是个结构体变量,这个变量在上个例子中讲过,大小为16个字节。我们重点看结构体的对齐数定义:结构体的整体大小就是所有最大对齐数(含嵌套结构体中成员的对齐数)的整数倍,所以结构体S的最大对齐数是8,往后找到第一个偏移量为8的倍数处,即偏移量等于16的地方:
然后整个结构体变量的大小应该对齐到所有成员变量中对齐数最大的那个的整数倍。我们发现此时已经占用了32个字节了,刚好是8的四倍,所以无需再对齐。整个结构体大小就是32
现在我们来讲一下结构体对齐存在的意义:
1 . 平台原因 (移植原因):
不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
2 . 性能原因:
数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要⼀次访问。假设⼀个处理器总是从内存中取8个字节,则地址必须是8的倍数。如果我们能保证将所有的double类型的数据的地址都对齐成8的倍数,那么就可以用⼀个内存操作来读或者写值了。否则,我们可能需要执行两次内存访问,因为对象可能被分放在两个8字节内存块中。
也就是说,如果不进行内存对齐,很有可能造成一个数据被分开在两块八个字节的空间上,访问的时候需要对内存进行两次读取。而对齐之后,可能会浪费一些空间,但是让这些数据都存储在一块8个字节的空间内,但是只需要访问一次就可以。
所以总的来说:内存对齐就是拿空间换取时间,牺牲一些空间,但是加快了内存访问速度。提升性能。
但是我们又希望结构体占用尽可能少一些,我们发现对于char类型数据,对齐数为1,而只要是自然数,都是1的倍数。所以可以直接对齐在上一个元素的后边。而对占用字节越小的数据类型,它的对齐数越小,就能更快的向后找到对齐的位置。所以我们可以尽可能地把数据类型小的数据定义在前面。
如:下列代码可以进行改进
改进前:
struct stu{
char name[20];
double score;
int age;
};
改进后:
struct stu{
char name[20];
int age;
double score;
};
结构体实现位段
接下来我们将进行结构体构造位段的用法。
引入:为什么需要位段?
加入我们定义一个变量int a=1;
我们发现,只有第一个字节存储了一个非0的数据,其他的都是0。就需要两个比特位就能表示1。其实是有一些浪费的,特别是我们计算机网络中的ip数据报的传输,里面有很多部分的段码,表示不同的数据。如果每一个都用四个字节进行存储,那真的是浪费。所以就让几个段码合成一个字节进行存储,每个段码只占一定数量的比特,如下列ip数据报格式;
而在vs 2022编译器中,结构体变量是支持定义位段结构的。
1 . 位段的成员可以是 int unsigned int signed int 或者是 char 等类型
2 . 位段的空间上是按照需要以4个字节( int )或者1个字节( char )的方式来开辟的。
3 . 位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用位段。
struct A{
int _a : 2;
int _b : 5;
int _c : 10;
int _d : 30;
};
在变量名后面加入:和常数n,最后加上分号。就成功声明了一个位段变量,占用n个字节。
但是,并不是每个平台都支持这种写法的。有如下几个原因:
位段的跨平台问题
1 . int 位段被当成有符号数还是无符号数是不确定的。
2 . 位段中最大位的数目不能确定。(16位机器最大16,32位机器最⼤32,写成27,在16位机器会出问题。
3 . 位段中的成员在内存中从左向右分配,还是从右向左分配,标准尚未定义。
4 . 当⼀个结构包含两个位段,第⼆个位段成员比较大,无法容纳于第⼀个位段剩余的位时,是舍弃剩余的位还是利用,这是不确定的。
我们只在vs 2022编译器上进行调试,我们先来看看这个位段大小:
答案是8。在这里我先给出结论,vs 2022是从右向左分配,不足的舍弃,从新分配字节存储。
因为每个位段类型是int,所以每次开辟四个字节存储位段。当我们把_a, _b , _c三个位段存储在内存中,发现还剩15个比特位,不够存储_d的30个比特位。
所以要另外开阔四个字节,存储这30个比特。
一共加起来是八个字节,所以最后大小是8。
注意:位段的几个成员共有同⼀个字节,这样有些成员的起始位置并不是某个字节的起始位置,那么这些位置处是没有地址的。内存中每个字节分配⼀个地址,⼀个字节内部的bit位是没有地址的。
所以不能对位段的成员使⽤&操作符,这样就不能使用scanf直接给位段的成员输入值,只能是先输如放在⼀个变量中,然后赋值给位段的成员。
总结:跟结构相比,位段可以达到同样的效果,并且可以很好的节省空间,但是有跨平台的问题存在。
联合
联合体变量的创建和声名
联合体和结构体是有一些相似的,但也有不同。联合体变量的创建要用到关键字union。
如代码所示
union A {
int a;
char c;
};
这样我们就完成了一个联合体变量的创建,声名很简单,如:union A a={0};
同样的,内部的成员不需要进行初始化,因为是表明这个联合体的成员。
联合体变量与结构体变量的区别
结构体和联合体长得是有一点像,但是既然两个关键字不一样,那也就说明是有区别的。
联合体的大小
假设把这个联合体A先当成结构体,那根据内存对齐规则,大小应该为8。
我们看看这个联合体的大小是多少:
联合体大小为4。这就是联合体和结构体的区别了。
解密:结构体每个成员的存储的都是独立的,而联合体所有成员变量公用一块字节空间,且联合体大小至少是成员中字节数最大的那个,还要对齐联合体中成员中所有对齐数的最大的那个的整数倍。
如这个联合体A中成员字节数最大的是int a,4个字节。又要对齐最大对齐数4,所以最后大小为4.
我们现在让a.a=0x11223344; a.c=0x55;
让我们打印看看什么结果:
很明显,通过这个实验发现,联合体中的成员确实占用同一块内存空间。具体流程我就不推导了,之前指针与大小端应用时讲过这个类似的。可自行推导。
所以我们可以使用联合体的特性来判断该编辑器是大端还是小端。
int check(){
union A{
int i;
char c;
}a;
a.i=1;
return a.c;//小端返回0,大端返回1
}
同样,只需要知道成员指向的是同一个内存空间,都是从最低的地址开始存储即可。
枚举
枚举变量的声名
前文提到,很多类型变量的值不一定是整数,字符,字符串等。如性别,我们是希望它的变量的值有男性MALE,女性FEMALE,甚至可以是不知道UNKNOWN。
有两种方法:
1.预处理指令
我们可以定义:
# define MALE 0
# define FEMALE 1
# define UNKNOWN 2
这样子就可以通过对应的数字判断其性别。但是会有些麻烦。如果当变量多起来了,那么就要写很多次预处理指令,繁琐至极。同时,这些值并不具有特殊性,不是较好理解。我们更希望的是能创建一个变量类型SEX,使其对应的值能有上述三种。所以有第二种方法。
:2.自定义类型,enum
使用关键字enum,可以创建自定以类型变量,同时可以规定其的值有哪些。
定义性别:
enum SEX{
MALE,
FEMALE,
UNKNOWN
};
定义星期几:
enum DAY{
Monday,
Tuesday,
Wednesday,
Thursday,
Friday,
Saturday,
Sunday
};
枚举变量的使用
当我们定义好了这些变量之后,我们就得研究如何使用。
通过这段代码的实验,我们发现,enum里面定义的变量从上至下存储的值其实就是数字,且默认从0开始依次递增+1。虽然这些变量背后存储的都是数字,但是我们定义的变量的值MALE等是可以直接使用或者赋值的。刚好满足上面说到的要求。
那能不能不让默认数字从0开始呢?答案是可以的。
当我们改动第一个时候,后面的仍然是按顺序递增。
也可以进行随意赋值,赋为我们想要的值。
如果不进行赋值,则会按照默认的值存储,进行赋值的则按照赋值进行存储,且赋值后的按照当前赋值后再递增。
枚举变量的优点
枚举类型的优点:为什么使用枚举?
我们可以使用 #define 定义常量,为什么非要使要枚举?
枚举的优点:
1 . 增加代码的可读性和可维护性
2 . 和#define定义的标识符比较枚举有类型检查,更加严谨。
3 . 便于调试,预处理阶段会删除 #define 定义的符号
4 . 使用方便,一次可以定义多个常量
5 . 枚举常量是遵循作用域规则的,枚举声明在函数内,只能在函数内使用
以上就是本篇文章的全部内容了,感谢大家收看!