C语言进阶之路:自定义类型

38 阅读21分钟

C语言进阶之路:自定义类型

1.前言

在我们的编程日常中,我们会发现有时候单纯靠C语言自带的数据类型是完全不够的,或者说对于我们日常生活而言,默认的数据类型只能够用来表示简单对象,但不能用来表示一些复杂对象,例如:

人,书

因此,自定义类型就油然而生,我们见到的自定义类型有:

struct(结构体),enum(枚举),union(联合体)

那么,接下来就跟着我的视野一起来看看这些在我们日常生活里发挥重要作用的自定义类型吧

2.结构体

1.结构体的构成

在知道其用法之前,我们首先得知道的就是它的构成

struct 结构体名称
{
	成员1;
    成员2;
    ....
    //根据情况来定制相应的结构体数量
}

在这里我们就能够看到一个结构的正常结构了

2.结构体的声明

我们在上面已经看见了结构体的构成,那接下来就是它的声明方式

在此之前,我们知道当我们创建一个结构体的时候,其不会在内存占取空间,因此,我们似乎可以将结构体的整体看作是一个类似int,short的数据类型

//我们知道函数的声明就是像这样
数据类型 函数名(形参1...)
//还有类似局部变量,全局变量
数据类型 变量名
//借此我们大概就能看出点玄机
//声明就是数据类型和变量之间的组合
//所以
struct 结构体名称 变量名;
//当然,既然我们构建了一个结构体,就可以对其进行简写,也可以将其写的详细一些
struct 结构体名称
{
	成员1;
    成员2;
    ....
    //根据情况来定制相应的结构体数量
}变量名;
//但是这种方法,就要求我们在定义这个结构体的时候就应该对这些变量进行声明
//而上面的那种方法就没有这种要求

下面就举个栗子:

struct Stu
{
    //学生的信息
    char name[20];//学生的姓名
    int age;//学生的年龄
    char sex[7];//学生的性别
}s1,s2;

int main()
{
    struct Stu s3,s4;
    return 0;
}

当然我们或许会感觉这种声明方式有些繁琐,所以我们可以对我们需要的结构体进行重定义,也就是typedef

typedef struct Stu
{
    //学生的信息
    char name[20];//学生的姓名
    int age;//学生的年龄
    char sex[7];//学生的性别
}Stu;

int main()
{
    Stu s1,s2;
    //然后就可以这样进行简单的声明了
    return 0;
}

当然声明中,也有有一些特殊的情况,我们见过各种形式的指针,像:

int* ,char* ,short* ,long*

那么,我们会思考一件事情,那就是结构体是否也有自己的指针呢?

答案是显然的,

那么其的形式是什么样呢?

struct 结构体名称
{
	成员1;
    成员2;
    ....
}* 变量名;
//这就像我们见到正常的指针一样,就是数据类型+*+指针变量名

这个主要是运用到函数中,因为我们知道函数中会产生一个临时变量,会在内存申请一个跟实参大小相同的一份内存,而指针的大小是固定的,在32位平台下,为4byte,在64位平台下,为8byte

所以,当我们传参的时候,结构体的内存过大,系统开销较大,这会导致程序的性能大幅下降,因此,在函数传参的使用中,大多是使用上面这种指针的形式

我们也知道:当我们知道一个结构体变量的地址的时候可以用箭头去表示结构体中的元素,如:

#include <stdio.h>

//依旧以学生作为结构体的内容
struct Stu
{
    //学生的个人信息
    char name[20];//学生姓名
    int age;//学生年龄
    char sex[8];//学生性别
};

int main()
{
    struct Stu s1 = {"zhangsan", 20, "male"};
    struct Stu* s2 = &s1;
    printf("%s %d %s\n",s2->name, s2->age, s2->sex);
    return 0;
}

输出结果:

屏幕截图 2024-11-13 081204.png

这就是这种特殊的声明

当然假如不使用指针就直接使用箭头的操作是错的,编译也会报错:

#include <stdio.h>

//依旧以学生作为结构体的内容
struct Stu
{
    //学生的个人信息
    char name[20];//学生姓名
    int age;//学生年龄
    char sex[8];//学生性别
};

int main()
{
    struct Stu s1 = {"zhangsan", 20, "male"};
    printf("%s %d %s\n",s1->name, s1->age, s1->sex);
    return 0;
}

报错内容:

屏幕截图 2024-11-13 081839.png

这句话的意思就是不是结构体的指针不能使用箭头

还有就是匿名结构体,就是没有结构体的名字,这种结构体只能在定义结构体的时候就声明相应的变量,而且这个结构体也只能进行这一次机会的声明

#include <stdio.h>

struct
{
    int val;
    char c;
}s1 = { 20,'c'};

int main()
{
    printf("%d %c\n", s1.val, s1.c);
    return 0;
}

运行结果:

屏幕截图 2024-11-13 105723.png

这种匿名结构体大多时候也用不到,除非存在只用一次的情况

3.结构体的自引用问题

自引用顾名思义就是结构体对自己的引用

举个栗子:

struct S
{
    int val;
    struct S s;
};
//这里不一定是对的!!!
//这只是随便举的一个例子

我们知道**sizeof( )**这个操作符可以计算一个类型的字节大小,那么我们会思考,那假如出现上面这种情况会计算出多少呢?

#include <stdio.h>

struct S
{
    int val;
    struct S s;
};

int main()
{
    printf("%d\n",sizeof(struct S));
    return 0;
}

编译器这时候会直接报错:

屏幕截图 2024-11-13 082253.png

我们自己仔细想一下,就会发现一个问题,假如真能这样写的话,那么这个空间理论上不就是无限大了吗?

所以这种方式很明显就是错的,那么正确的方式该如何去写呢?

#include <stdio.h>

struct S
{
    int val;
    struct S* s;//在这里将s的类型换为结构体指针类型
};

int main()
{
    printf("%d\n",sizeof(struct S));
    return 0;
}

这样我们就能看到结果(32位平台)了:

屏幕截图 2024-11-13 082547.png

这里能这样写的原因是:指针的大小是固定的

那么这个东西主要是用在什么地方呢?

在这里我就先透露一下,这主要使用在后面数据结构中的链表中,我们知道假如有一堆东西在内存的位置是随机的,那么我们应该如何将这些东西串在一起呢?这时候我们可以想到指针可以用来指向一个内存,那么只要我们用指针将这些内存串在一起,那么我们就可以达到上面的效果

屏幕截图 2024-11-13 083241.png

当然由于这是数据结构的内容,其应该是作为数据结构篇的主角,而不是在这里,所以就先点到为止

4.结构体的内存对齐

我们上来就先看个栗子:

#include <stdio.h>

struct S
{
    char c1;
    int val;
    char c2;
};

int main()
{
    printf("%d\n",sizeof(struct S));//会是6吗?//1+4+1
    return 0;
}

运行结果: 屏幕截图 2024-11-13 084518.png

#include <stdio.h>

struct S
{
    char c1;
    char c2;
    int val;
};

int main()
{
    printf("%d\n",sizeof(struct S));//这也还会是12吗?跟上面一样吗?
    //还是会是6呢?//1+4+1
    return 0;
}

运行结果: 屏幕截图 2024-11-13 084637.png

有些人这时候或许会开始感觉发懵,这是为什么呢?

那么下面就来讲结构体中的重点了:结构体的内存对齐

我们首先得知道结构体中对齐规则:

1.第一个成员在结构体变量中偏移量为0的位置

2.其他成员在对齐数的整数倍的位置

对齐数:编译器默认的对齐数与该成员的大小中的较小值

有些编译器没有默认对齐数,那么对齐数就是该成员的大小,单位都是字节

每个成员都有自己相应的对齐数

3.结构体的大小是最大对齐数的整数倍

4.如果嵌套了结构体的情况下,嵌套的结构体的对齐数就是其中的最大结构体的整数倍,结构体的大小依旧是其中最大对齐数的整数倍

我们可以先了解一下offsetof这个宏,因为这个可以帮助我们理解接下来的内容

cplusplus.com上面,我们看见offsetof的功能及作用

屏幕截图 2024-11-13 090641.png

翻译过来,差不多就是说:这个宏可以计算结构体和联合体成员相较于起始地址的偏移值

知道这个之后,我们就先开始讲第一点:

#include <stdio.h>
#include <stddef.h>

struct S
{
    char c1;
    int val;
    char c2;
}

int main()
{
    printf("%d\n",offsetof(struct S,c1));
    return 0;
}

运行结果:

屏幕截图 2024-11-13 091536.png

这样我们就确切的知道了:结构体中的第一个成员在结构体开辟的空间中的偏移值为0的位置

接下来就是第二点的内容:

#include <stdio.h>
#include <stddef.h>

struct S
{
    char c1;
    int val;
    char c2;
};

int main()
{
    printf("%d\n", offsetof(struct S, c1));
    printf("%d\n", offsetof(struct S, val));
    printf("%d\n", offsetof(struct S, c2));
    return 0;
}

运行结果:

屏幕截图 2024-11-13 091954.png

我们认为原本的空间只是这样的:

屏幕截图 2024-11-13 093003.png

我们通常会认为val就在1处跟上,但根据上面代码的运行结果,我们知道val应该在偏移值为4的地方

所以我们知道在内存中应该是这样的:

屏幕截图 2024-11-13 092419.png

所以,这也可以帮助我们理解第二点的内容

然后就是第三点:

我们从上面就知道了,这个结构体的大小是12,那么是为什么呢?

#include <stdio.h>

struct S
{
    char c1;//对齐数:1
    int val;//对齐数:4
    char c2;//对齐数:1
};

int main()
{
    printf("%d\n",sizeof(struct S));//会是6吗?//1+4+1
    return 0;
}

我们可以轻易的知道,假如到偏移值为8时就结束,那么就不符合我们的第三点的内容,在这些对齐数中,最大的对齐数就是4,那么最终结果就应该是4的整数倍,所以这里输出的结果也自然是12

同理,我们也知道了第二个例子的输出结果为什么会是8了

最后就是第四点了:

下面我们再举一个例子:

struct A
{
    char a1;//1
    int v1;//4
    char a2;//1
    int v2;//4
};

struct S
{
    char c1;//1
    int val1;//4
    struct A a;//4
    char c2;//1
};

int main()
{
    printf("%d\n", sizeof(struct S));
    return 0;
}

我们可以先看第四点,然后思考,就可以明白第四点的结果了

运行结果如下:

屏幕截图 2024-11-13 094408.png

那么我就在这里简单的解释一下:

起始内存:

屏幕截图 2024-11-13 095210.png

使用后的内存:

屏幕截图 2024-11-13 095456.png

我们通过第四点知道了struct A a的对齐数是其自身结构体中的最大对齐数,所以就是4,然后根据第二点,我们就可以知道了其位置,应该在8处,然后将其内容一个个放入内存中,之后再把c2放进去,但是这时候的结构体才25,没有满足第三点的要求,我们知道struct A a的对齐数和val1的对齐数都是4,所以,该结构体的最大对齐数就是4,所以内存的大小应该是4的整数倍,所以结果就是28

但是话又说回来,我们开始讲的那两个例子,我们知道了即使结构体的成员相同,但是排放顺序不同,其内存也不一定相同,所以,这时候我们就要思考如何节约空间的方法,防止造成大面积的内存浪费的问题

我总结了一个小技巧就是:尽量将内存较小的放在一起,这样可以在一定程度上,避免内存浪费的问题

但是这并不是完全解决内存浪费

那么,为什么要有这个内存对齐的概念呢?

根据查阅资料的内容,大概有下面两种原因

1.平台原因:

​ 不是所有的硬件平台都能随意访问任意地址上的任意数据的,某些硬件平台只能再某些地址上取特定类型的数据,否则会出现硬件问题

2.性能原因:

​ 数据结构应在自然边界上对齐,这样可以提高访问效率,像原本在内存上可能要访问两次,通过这种对齐方式,最后就只需要访问一次即可

那么我就为大家对于这个第二点进行解释一下:

假如没有内存对齐:

屏幕截图 2024-11-13 102515.png

那么此时在内存中的布局应该是这样的,当我们想要访问val的时候,我们就会发现一个很重要的问题,因为val是整形,所以我们访问其的时候是4个字节4个字节的访问,所以这时候,我们会先定位val在哪几个4个字节中,然后先访问到val的前3字节,然后再往后继续访问它剩下的1个字节,这样就是访问了2次

相反在有内存对齐的前提下:

屏幕截图 2024-11-13 103127.png

这时候直接定位到val的四个字节,这样就可以只需访问一次就能获得val的内容

简单的来说:这是一种交换,通过将内存与时间交换,最终达到提高效率的效果

5.内存对齐数的修改

在编译器有默认的对齐数的前提下,默认的对齐数实际上是可以进行修改的,仅需要用到**#pragma pack(目标对齐数的大小)**

下面就举一个例子:

#include <stdio.h>
#pragma pack(1)

struct S
{
    char c1;
    char c2;
    int val;
};

int main()
{
    printf("%d\n",sizeof(struct S));
    return 0;
}

运行结果:

屏幕截图 2024-11-13 103335.png

我们稍加分析就知道了

struct S
{
    char c1;//1
    char c2;//1
    int val;//1
};

对齐数都为1,此时,直接将它们的大小直接相加即可,最后就能得到结构体的大小

当然当我们不需要的时候,也可以将其取消

#include <stdio.h>
#pragma pack(1)

struct S
{
    char c1;
    char c2;
    int val;
};

#pragma pack()
//将对齐数恢复为默认值
struct SS
{
    char c1;
    char c2;
    int val;
};
int main()
{
    printf("%d\n", sizeof(struct S));
    printf("%d\n", sizeof(struct SS));
    return 0;
}

运行结果:

屏幕截图 2024-11-13 104104.png

有了这种方法后,我们可以使内存的使用变得更高效,更灵活

6.位段

下面我们就来看一下位段,位段其实在我们的网络使用中发挥着重要作用,下面就一起来看看其是什么吧! 位段其实就是将一些字节进行压缩了,下面我就给大家举个栗子:

struct S
{
    int val1:2;
    int val2:4;
    int val3:3;
    int val4:1;
};

int main()
{
    printf("%d\n",sizeof(struct S));//大家会认为这里的输出结果是多少呢?
    //会是4*4=16吗?
    return 0;
}

这里,我就不卖关子了,直接将输出的结果给出来:

屏幕截图 2024-11-16 173518.png

这里我们就能开始感受到位段了,没错,位段就是可以对空间进行压缩,当然,开辟的空间都是以int/signed int或是char/signed char作为单位进行开辟空间,其功能可以让我们将空间利用到极致

像以前,我们使用空间可能是申请了4个字节,但是最终可能就用到了其中的几个比特位,剩下的全都没有受到使用,这就会导致一部分的空间浪费,所以,位段就出现了 其使用场景主要在网络上的数据传输,因为我们知道网络传输的时候没法传太大的数据,而位段则可以在一定程度上对数据进行一次压缩,这样就可以将我们的数据传输到网络上

当然位段也有自己的最大的大小,像在32位平台下,位段最大也就是32bite,而16位平台下,其就是16bite

当然,讲了位段的好处,那下面我就要开始讲讲其不好的地方了

位段的局限性
1.数据类型的位段不确定

但我们知道不同的环境下,我们的char有可能不同,因为其没有被真正的定义为signed char还是unsigned char,所以这可能会导致一些问题的出现

2.位段的最大数目不能确定

因为我们知道,在不同的机器下,位段的大小会不同,所以这也就有可能会导致一些问题出现

3.位段的读取方向没有明确

因为我们知道在不同的机器下,也存在不同的字节的存储方式,像大端和小端,而读取方向不同,最后也会导致我们预期结果和实际结果有区别

4.当位段过大时,其操作是如何的也没有明确

假如我们设定的一个位段没有剩多少个bite位时,这时又加了一个较大的bite位进来,这时候我们不知道系统接下来会进行什么操作

纵使其有这些缺点,但其还是可以发挥很大的作用,但是这时候就需要我们自己手动的位段进行跨平台的操作,使位段在不同的平台下都能运行

3.枚举

接下来就是枚举了

枚举很简单就是将一堆名字数字化

1.枚举的构成

下面就是简单介绍一下枚举的构成

enum 枚举名
{
    成员1,
    成员2,
    成员3,
    ...
    //根据情况再看需要多少个成员
};

这就是枚举的基本构成

2.枚举的声明

枚举的声明跟结构体一样,分为了两种,一种是在定义完枚举后直接声明变量名,也可以到后面到需要使用的时候再临时地对枚举变量进行一个声明的操作

举个栗子:

#include <stdio.h>

enum color
{
    red,
    green,
    blue
}c1,c2;

int main()
{
    enum color c3,c4;
    return 0;
}

这样就是对其进行的声明,当然有一点要注意,对于枚举变量赋值只能赋值我们所定义的枚举中的值,不能定义在那之外的值

当然,枚举中也有像结构体中的匿名结构体的匿名枚举

#include <stdio.h>

enum
{
    red,
    green,
    blue
}c1 = red;

int main()
{
    return 0;
}

这个跟上面的匿名结构体一样只能用一次,所以也是大多时候用不到,只有少数时候只需要用一次的场景才会使用这东西

3.枚举的使用

下面我就简单的给一个例子,让大家知道枚举是如何使用的

#include <stdio.h>

enum sex
{
    male,
    female,
    secret
};

int main()
{
    enum sex s1 = male;
    if(s1==male)
    printf("男性\n");
    else if(s1==female)
    printf("女性\n");
    else
        printf("保密\n");
    return 0;
}

运行结果:

屏幕截图 2024-11-13 110237.png

这就是一个很基本的用法

4.枚举的数字化

枚举常量为什么叫枚举常量,下面我们就来一起见识一下:

#include <stdio.h>

enum sex
{
    male,
    female,
    secret
};

int main()
{
    enum sex s1 = male, s2 = female, s3 = secret;
    printf("%d\n",s1);
    printf("%d\n",s2);
    printf("%d\n",s3);
    return 0;
}

运行结果:

屏幕截图 2024-11-13 110722.png

这里我们就能看见这些名字类似的东西被转化为了数字

那或许会问该如何将将这些枚举中的量的初始值变成自己想要的呢?

其实也很简单

#include <stdio.h>

enum sex
{
    male = 1,
    female,
    secret
};

int main()
{
    enum sex s1 = male, s2 = female, s3 = secret;
    printf("%d\n",s1);
    printf("%d\n",s2);
    printf("%d\n",s3);
    return 0;
}

此时的运行结果:

屏幕截图 2024-11-13 111050.png

我们就能看见这里变成从1开始的了,所以对于修改里面的值,只需要将首成员的值进行一次手动的初始化,这样就能达到我们的目的

所以,我们大概也能推测出,其主要出现的场景还是项目为主,因为当项目中的选项过多,程序员自身也无法快速的判断哪个数字对应的功能是什么,这时候就可以通过枚举来完成这项工作

4.联合体

1.联合体的构成

联合体的构成和结构体非常相似

union 联合体名
{
    成员1;
    成员2;
    ...
    //根据情况设定成员数量
};

这里我们就能看到其的构成和结构体还是非常相似的

2.联合体的声明

联合体的声明跟上面的结构体和枚举一样,也是有两种方式

#include <stdio.h>

union S
{
    int val;
    char c1;
    char c2;
}s1,s2;//声明1

int main()
{
    union S s3,s4;//声明2
    return 0;
}

要是你感觉这样写麻烦的话,其实也是可以选择用typedef来为我们所定义的联合体重定义名字,这样可以在一定程度上减少其繁琐程度

当然了,它也是有匿名联合体的说法的

#include <stdio.h>

union
{
    int val;
    char c;
}s={0,'a'};

int main()
{
    return 0;
}

同样的,作为匿名的,其也只适用于只使用一次的场景下

3.联合体的使用

下面我就先给大家展示一下联合体是如何使用的

#include <stdio.h>

union s
{
    int val;
    char c1;
    char c2;
};

int main()
{
     union s s1;
	 s1.val = 2;
	 printf("%d\n", s1.val);
	 s1.c1 = 'a';
	 printf("%c\n", s1.c1);
	 s1.c2 = 'b';
	 printf("%c\n", s1.c2);
	 return 0;
}

输出结果如下:

屏幕截图 2024-11-13 183712.png

那么有人就会问:为什么要这要对联合体的变量进行初始化吗?不能像结构体那样直接对联合体变量进行初始化吗?

答案是:不行,假如可以的话,我在这里我就已经这样做了,下面我给大家展示一下像上面的疑问做法会出现什么问题

#include <stdio.h>

union s
{
    int val;
    char c1;
    char c2;
};

int main()
{
    union s s1 = { 2, 'a', 'b' };
    printf("%d %c %c\n",s1.val,s1.c1,s1.c2);
	return 0;
}

假如我们在编译器写入这串代码,此时就会出现这种问题:

屏幕截图 2024-11-13 184158.png

那又有人可能会发出疑问:那能一个一个对联合体中的每一个成员进行赋值后,最后再将这些成员输出吗?

对于疑问的最好方法就是自己亲自去尝试一下,下面就给大家展示一下假如像这次这种疑问去写代码

#include <stdio.h>

union s
{
    int val;
    char c1;
    char c2;
};

int main()
{
     union s s1;
	 s1.val = 2;
	 s1.c1 = 'a';
	 s1.c2 = 'b';
	 printf("%d %c %c\n",s1.val,s1.c1,s1.c2);
    //预期结果:2,a,b
	 return 0;
}

此时编译器上输出的结果:

屏幕截图 2024-11-13 184532.png

就会发现输出的结果跟我们预期的不一样,输出的要么是b的ASCII值,要么就是b,那接下来就是给大家讲解联合体在内存的布局了

4.联合体的内存

我们知道了联合体作为自定义类型的一员,其被定义的时候,相当于也只是一个数据类型,不会内存中申请空间,只有当有变量被声明的时候,才会开始向内存中申请内存

其实在这里我是想跟大家说一下联合体的别名:共用体,看到共用体,我们就能猜出来共用体共用的是内存,但是其在内存中到底是如何共用的,接下来就由我向大家讲解一下:

屏幕截图 2024-11-13 185344.png

这里我们就大概能看到联合体会共用联合体的起始位置,所以这就导致了上面的问题的发生,因为每当我对其进行赋一次值,就会将上一次共用的内存空间中存放的内容直接覆盖掉

当然,对于这个公共的空间我们也能对此进行验证一下:

#include <stdio.h>
#include <stddef.h>


union s
{
    int val;
    char c1;
    char c2;
};

int main()
{
    //依旧是通过offsetof来对其偏移值进行计算
    //假如有公共的空间,那么得到的输出结果都是相同的
    //反之,则无
    printf("%d\n",offsetof(union s,val));
    printf("%d\n",offsetof(union s,c1));
    printf("%d\n",offsetof(union s,c2));
    return 0;
}

此时输出结果如下:

屏幕截图 2024-11-13 190211.png

我们可以看见输出的三个结果都是相同的,说明是有公共空间的存在的,所以很显然我们上面的解释也就成立了

当然在联合体中,也存在着其相应的对齐原理

当最大成员的空间不是最大对齐数的整数倍时,就要自动到对齐到最大对齐数的整数倍

下面我就给大家举个栗子:

#include <stdio.h>


union s
{
    char ch[18];//对齐数为1
    //这个详细看上面的对齐规则,大多都是跟结构体的对齐规则相同
    int val;//对齐数为4
};

int main()
{
    printf("%d\n",sizeof(union s));
    //根据我们的对齐原理,我们估计输出结果为20
    return 0;
}

输出结果:

屏幕截图 2024-11-13 191146.png

这里发现也是很符合我们对其的预期结果,所以结果就显而易见了

5.总结

那么,了解了这些自定义类型,大概也已经知道了其相应的使用场景,像结构体可以用到像链表,还有通讯录这种很基础的项目

而枚举可以用在那些操作选择多的项目中

还有就是像联合体这种可以使用在反复只需要其成员中的一项的项目中,而不需要同时需要多个成员

那么,自定义类型就到此为止了,期待我们在接下来的旅途中继续见面!!!