ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

0基础C语言积跬步之自定义类型结构体

0基础C语言积跬步之自定义类型结构体

目录

一、结构体类型的声明

1.1 一般结构体声明

1.2 结构的特殊声明(匿名)

1.3结构体的自引用

二、结构体变量的创建和初始化

(1)情况1:结构体变量的创建

(2)情况2:结构体变量的创建

(3)情况3:结构体变量的初始化

(4)情况4:结构体变量的初始化

三、结构成员访问操作符

四、结构体内存对齐

4.1对齐规则

4.2代码练习:判断结构体大小

(1)练习1

(2)练习2

(3)练习3

(4)练习4:结构体嵌套问题

4.3为什么存在内存对齐

4.4修改默认对齐数

五、结构体传参

六、结构体实现位段

6.1什么是位段

6.2位段的内存分配

6.3位段使用的注意事项

6.4位段的跨平台问题

6.5位段的使用


一、结构体类型的声明

1.1 一般结构体声明

结构体声明的语法:

struct tag { member - list; //成员名 }variable - list; //变量名

tag 是结构体的名字

member - list是成员名

variable - list是创建的变量名

比如我们想创建一个描述学生的结构体:

struct Stu { char name[20]; // 名字 int age; // 年龄 char sex[5]; // 性别 char id[20]; // 学号 };

1.2 结构的特殊声明(匿名)

// 匿名结构体:无类型名,直接定义变量 x struct { int a; char b; float c; } x; // 匿名结构体:定义数组 a[20]、指针 p struct { int a; char b; float c; } a[20], * p;

我们没有给这两个结构体结构体起名字,所以这两个结构体被称之为匿名结构体

匿名结构体的特点:

每个匿名结构体都是不同的类型,就像上述代码,虽然两个匿名结构体中存的内容一样,但它们其实属于两个不同的匿名结构体类型,如果此时我们让*p=&x,系统会报错,说前后类型不一

匿名的结构体类型,无法单独创建变量,只能在声明的时候就将变量创建好

匿名的结构体类型,如果没有对结构体类型重命名的话,只能使用一次,使用一次的意思是,只能在定义结构体的那一行,一次性创造好变量名,之后再想换行单独创建变量,是做不到的,但是后续赋值、访问、修改、打印都是正常使用的,都没问题,只是记住!!!只能一次性创造好变量名!

1.3结构体的自引用

在结构中包含一个类型为该结构本身的成员是否可以呢?

比如定义一个链表的节点:

struct Node { int data; struct Node next; };

我们写成上面这种代码的形式可行吗?sizeof(struct Node)能计算出该结构体的大小吗?

上面这种结构体中嵌套结构体的形式是不可行的,因为我们没法计算出结构体struct Node的大小,计算第一次时是计算 data(4字节)+ struct Node(未知大小),struct Node的大小未知,所以我们又会计算struct Node的大小,就这样会一直循环往复,根本无法计算出正确大小

所以我们正确自引用的写法应该是:

typedef struct Node { int data; struct Node* next; } Node;

在结构体中嵌套一个指针结构体变量,这个指针变量的大小是4或8,所以就能正确计算出我们struct Node结构体的大小,就可以正常使用

总结:结构体自引用 = 结构体里放一个【指向自己的指针】

二、结构体变量的创建和初始化

1.结构体变量的创建可以在声明的时候就创建好;也可以单独创建

2.结构体的初始化,如果在结构体声明的时候就创建了变量,那么可以顺带初始化,可以用到.结构体成员操作符

3.结构体的初始化也可以在单独创建结构体变量后初始化,可以用到.结构体成员操作符

.可以访问成员,也能配合变量初始化

->只能访问成员,绝对不能用来初始化

我们来把这创建变量和初始化的分别的两种情况都看一下:

(1)情况1:结构体变量的创建

在结构体声明的时候就创建好变量:

#include <stdio.h> struct Stu { char name[20]; // 名字 int age; // 年龄 }s1, s2, * s3; //全局变量

我们创建了struct Stu类型的变量s1,s2和struct Stu*类型的s3,它们都是全局变量

(2)情况2:结构体变量的创建

变量名也可以单独创建:

#include <stdio.h> struct Stu { char name[20]; // 名字 int age; // 年龄 }; struct Stu s1; //全局变量s1 int main() { struct Stu s2; //局部变量s2 struct Stu* s3; //局部变量s3 return 0; }

struct Stu s1在主函数外面创建,是全局变量;struct Stu s2和struct Stu* s3在{}内,是局部变量

(3)情况3:结构体变量的初始化

在结构体声明的时候就已创建好变量的初始化:

#include <stdio.h> struct Stu { char name[20]; // 名字 int age; // 年龄 }s1 = { .age = 22,.name = "love" }, s2 = { "kkeeper",20 }, * s3 = NULL;

s1使用.成员操作符去创建变量,可以不按照顺序,但s2初始化为{ "kkeeper",20 },没有用.成员操作符,就只能按照顺序进行初始化,结构体指针变量s3初始化为NULL空指针

(4)情况4:结构体变量的初始化

在结构体变量单独创建时初始化:

#include <stdio.h> struct Stu { char name[20]; // 名字 int age; // 年龄 }; struct Stu s1 = { .age = 20, .name="XiaoMing"}; //全局变量s1 int main() { struct Stu s2 = { .age = 20, .name = "DaMing" }; //局部变量s2 struct Stu* s3=&s1; //局部变量s3 return 0; }

三、结构成员访问操作符

结构体成员访问操作符有两个: . 和 ->

我们来看看具体使用方法:

. 结构体变量.结构体成员名

-> 结构体指针->结构体成员名

#include <stdio.h> struct Stu { char name[20]; // 名字 int age; // 年龄 }; struct Stu s1 = { .age = 20, .name="XiaoMing"}; int main() { struct Stu* s3=&s1; printf("%d %s\n", s1.age, s1.name); // .成员访问操作符 printf("%d %s\n", s3->age, s3->name); // ->成员访问操作符 return 0; }

四、结构体内存对齐

结构体的大小是怎么在内存中开辟的呢?这需要我们了解结构体内存对齐的知识

4.1对齐规则

结构体内存对齐规则:

1.结构体的第一个成员对齐到和结构体变量起始位置偏移量为 0 的地址处。

2.其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。 对齐数 = 编译器默认的一个对齐数 与 该成员变量大小的较小值。

  • VS 中默认的值为 8。
  • Linux 中 gcc 没有默认对齐数,对齐数就是成员自身的大小。

3.结构体总大小为最大对齐数(结构体中每个成员变量都有一个对齐数,所有对齐数中最大的)的整数倍。

4.如果嵌套了结构体的情况,嵌套的结构体成员对齐到自己的成员中最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体中成员的对齐数)的整数倍。

上述的规则,开辟的内存,都是在对齐好后才开始开辟内存的

具体是如何对齐和开辟内存的,我们通过下方的代码练习来讲解

4.2代码练习:判断结构体大小

(1)练习1

struct S1 { char c1; int i; char c2; }; printf("%d\n", sizeof(struct S1));

VS中默认对齐数为8,char c1变量大小为1,所以对齐数取较小的,为1,所以char c1开辟一个字节的大小,从偏移量为0处开始开辟一个字节的大小,开辟完后偏移量来到1;接着int i变量大小为4,比默认对齐数小,所以对齐数取4,我们要先将内存的偏移量达到4后,才会开辟int i的内存空间,开辟四个字节,此时偏移量到8;最后char c2,偏移量为1,现在偏移量是8,是1的倍数,所以我们直接紧接着给char c2开辟属于它的空间,一个字节,此时偏移量来到9;因为我们知道,最终的结构体的整体大小一定是所以对齐数中最大对齐数的正数倍,而偏移量为9,就是是只开辟了9个字节的空间,是对齐数1的倍数,但不是对齐数4的倍数,所以我们又会开辟3个字节的空间,让偏移量来到12,此时这个结构体大小就为12

运行截图:

但是因为为了达到我们的内存对齐,我们会浪费掉一些空间,上述的结构体中,在创建完char c1后,我们开辟了三个字节的空间,用于内存对齐,这三个空间是不使用的,接着我们开辟了四个字节的空间给int i,接着又开辟一个字节给char c2,此时偏移量来到9,最后又要开辟三个字节的空间,来保证我们的内存对齐,所以我们就浪费了六个字节的空间去内存对齐

(2)练习2

struct S2 { char c1; char c2; int i; }; printf("%d\n", sizeof(struct S2));

c1对齐数取1,c2对齐数取1,i对齐数取4,首先很简单c1,c2开辟了两个字节的空间,偏移量来到2,接着在创建int i的空间时,我们首先要开辟两个字节的空间,让偏移量来到4,接着才给我们的int i开辟四个字节的空间,此时偏移量为8,结构体总大小为8个字节,是最大对齐数4的倍数,所以结果为8

运行截图:

(3)练习3

struct S3 { double d; char c; int i; }; printf("%d\n", sizeof(struct S3));

double对齐数取8,char对齐数取1,int对齐数取4,初始时,创建八个字节的空间给double d,偏移量来到8,8是char对齐数1的倍数,所以直接给char c开辟一个字节的空间,此时偏移量来到9,9不是int对齐数4的倍数,所以我们要开辟3个空间,让对齐数来到12后,再去开辟4个空间给int i,此时总共开辟了16个字节的空间,浪费3个字节的空间

运行截图:

(4)练习4:结构体嵌套问题

struct S3 { double d; char c; int i; }; struct S4 { char c1; struct S3 s3; double d; }; printf("%d\n", sizeof(struct S4));

结构体struct S4中,char c1的对齐数为1,struct S3 s3结构体变量的对齐数为该结构体中的最大对齐数,是double类型的对齐数,为8,double d的对齐数是8。所以我们char c1首先先开辟一个字节内存空间,偏移量为1,因为struct S3的偏移量为8,所以我们又开辟了七个字节的空间,用于和struct S3对齐数对齐,偏移量来到8,然后开辟我们struct S3 s3的空间,练习3中我们知道,struct S3的结构体大小是16,所以此处我们开辟了16个字节的内存大小,偏移量来到24,24是8的倍数,所以我们直接给double d开辟8个字节的空间,此时总开辟的空间就为1+7+16+8=32

4.3为什么存在内存对齐

首先内存对齐的规则只发生于结构体/联合体中

为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。

  • 因为CPU 不是一个字节一个字节读内存的,而是按块读取(一次读 4/8 字节)。
  • 如果数据对齐存放,CPU一次就能读完。
  • 如果数据没对齐,CPU 要读两次 → 拼接数据 → 丢弃多余,速度慢很多

比如结构体中创建了一个char i 和 int j 变量:如果不存在内存对齐,那么就开辟六个字节的空间就足够了,此时CPU一次读取4个字节的空间话,读第一次,读掉了char i,但 int j 只读取了它的三个字节,我们还需要读取一次,才能把 int j 的最后一个字节读取到,所以单单 int j 这个数据,我们就分了两次去读取,读取效率较慢;如果存在内存对齐,那么就需要开辟八个字节的空间,char i占1个字节后,开辟3个字节的空间浪费掉用于对齐,然后给四个字节给int j,这样CPU在读取的时候,第一次就能把char i 和 开辟的用于对齐的3个字节的空间读取掉,然后再读int i,读 int i 时,就能一次性读取四个字节,刚好就是int i的四个字节,这样int i就不用分两次去读取了,一次就能将这个数据读取成功,这样读取效率更高

总的来说:结构体的内存对齐就是用空间换取时间

如果我们在设计结构体的时候,既要满足内存对齐,又要节省空间,我们该怎么做?

比如:

struct S1 //结构体大小为12 { char c1; int i; char c2; }; struct S2 //结构体大小为8 { char c1; char c2; int i; };

结构体struct S1 和 struct S2 中所存的数据类型一模一样,但创建的结构体大小却不一样,这是经典的内存对齐的问题,这是因为在S1中,我们浪费了6个字节的空间,但在S2中,只浪费了2个字节的空间,所以当我们把占用内存空间较小的成员集中在一起时,因为内存已经对齐好了,我们不需要给同样类型的成员再浪费空间进行内存对齐,这也就有助于节省我们的空间

总结:将占用内存空间小的成员集中在一起

4.4修改默认对齐数

#pragma 这个预处理指令,可以改变编译器的默认对齐数

  • #pragma pack(num) 设置默认对齐数
  • #pragma pack() 取消设置的对齐数,还原为默认

用法如下:

#include <stdio.h> #pragma pack(1)//设置默认对齐数为1 struct S1 { char c1; int i; char c2; }; #pragma pack()//取消设置的对齐数,还原为默认 struct S2 { char c1; int i; char c2; }; int main() { //输出的结果是什么? printf("%d\n", sizeof(struct S1)); printf("%d\n", sizeof(struct S2)); return 0; }

struct S1时,我们通过 #pragma pack(1) 将默认对齐数设置为 1,那么struct S1根据我们内存对齐的规则,就会开辟6个字节的空间给这个结构体;在struct S2时,我们先通过 #pragma pack() 将我们修改的对齐数,还原为默认对齐数,在VS中为8,根据内存对齐的规则,就会开辟12个字节的空间给这个结构体

运行截图:

五、结构体传参

结构体传参分为两种:传结构体变量和传结构体变量的地址

分别用结构体变量和结构体指针变量去接收

下面我们来看一串代码演示:

#include <stdio.h> struct S { int data[10]; int num; }; struct S s = { {1,2,3,4}, 1000 }; // 结构体传参(值传递) void print1(struct S s) { printf("%d\n", s.num); } // 结构体地址传参(指针传递) void print2(struct S* ps) { printf("%d\n", ps->num); } //主函数 int main() { print1(s); // 传结构体 print2(&s); // 传地址 return 0; }

运行截图:

我们将结构体变量 (s) 传给了printf1 函数,将结构体变量的地址 (&s) 传给了printf2 函数,再分别在这两个函数中调用我们的 . 和 -> 成员访问操作符去打印了我们的num数据,如上图截图所示,但其中我们要注意一个知识点,传结构体变量时,其实是传值调用,形参会单独开辟一份空间,并将传入的结构体复制一遍在这个空间,才是如果在函数内部修改这个形参的结构体数据,是不会改变实参的结构体数据的;传结构体变量的地址时,是传址调用,我们会通过地址找到我们实参的结构体,不会单独创立一个新的空间,此时如果在函数内部修改我们结构体的数据,改变的就是我们实参的结构体数据

我们来用代码观察一下以上说的传值调用和传参调用的区别:

#include <stdio.h> struct S { int data[10]; int num; }; struct S s = { {1,2,3,4}, 1000 }; // 结构体传参(值传递) void print1(struct S s) { s.num = 100; } // 结构体地址传参(指针传递) void print2(struct S* ps) { ps->num = 100; } int main() { print1(s); // 传结构体 printf("%d\n", s.num); print2(&s); // 传地址 printf("%d\n", s.num); return 0; }

运行截图:

由截图可以看到,传值调用确实不会改变实参,传址调用会改变实参

对比两种传参方式,我们更建议用传址调用,因为传值调用会重新开辟一块新空间将原结构体复制进去,在栈区中压栈入栈出栈都要时间,这样既耗费了空间也耗费了时间

结论:结构体传参的时候,要传结构体的地址

六、结构体实现位段

6.1什么是位段

位端的声明和结构体高度类似,它是一种特殊的结构体,位段等于在结构体里,给每位成员按bit位分配空间,而不是按字节分配,目的是节省内存空间,位段的成员必须是整数类型:int、unsigned int、signed int 类型,在C99标准之后的编译器中,也可以是char、short等整型家族的类型,但不能是float、double、指针 等

下面我们来看看位段的具体写法:

struct A { int _a : 2; int _b : 5; int _c : 10; int _d : 30; };

位段要在结构体变量之后加一个冒号和一个数字,如上述代码所示,A就是一个位段类型,_a就是位端的成员,简称一个位段,(:2)表示_a这个变量只占两个bit位的空间

6.2位段的内存分配

位段内存分配的规则:

1.位段类型的位段成员一般都是同一种类型

2.位段的内存分配是位段成员类型来决定的,都是int 类型的数据,就会以4个字节为单位开辟空间;都是short 类型的数据,就会以2个字节为单位开辟空间;都是char 类型的数据,就会以1个字节为单位来开辟空间

3.用完当前开辟的单位的空间,如果不够使用,才会再开辟下一个相同单位大小的空间

我们来看看具体的代码,以VS编译器中为例:思考下面这串代码是如何分配内存的?

#include <stdio.h> struct S { char _a : 3; char _b : 4; char _c : 5; char _d : 4; }; struct S s = { 0 }; int main() { s._a = 10; s._b = 12; s._c = 3; s._d = 4; printf("%zd\n", sizeof(struct S)); return 0; }

在VS中,我们是这样来给位段类型的数据开辟内存的:首先因为struct S这个位段类型中的数据都是char类型,所以我们是以一个字节为单位开辟内存,如果开辟的空间不够使用,我们才会再开辟下一个相同单位大小的空间,而且在VS中,我们都是从这个字节空间的低位开始存储的,在第一个字节中,一个字节为8个bit位,首先使用了3个bit位给_a,再使用了4个bit位给_b,剩下一个bit位,不够char c:5存放,所以我们又开辟了一个字节的空间,8个bit位,使用5个bit位给char _c,剩下3个bit位不够_d:4存放,所以我们又开辟了一个字节的空间,8个bit位,使用了4个bit位给char _d,所以我们总共就开辟了三个字节的空间,省了一个字节的空间大小

运行截图:

6.3位段使用的注意事项

在上述6.2的代码中,我们要注意一个位段使用的注意事项:由于我们是从字节的低位开始存放数据,比如我们在放_a 和 _b在第一个字节当中,我们占用的7个bit位是从低位开始占用的,也就是第1个bit位,并没有被占用,而是被浪费了,但这个字节的地址,就是指向的第一个bit位,内存中每个字节分配一个地址,而个字节内部的bit位是没有地址的,所以我们没法指向一个字节中的第二个bit位,由此我们可以知道,我们无法在内存中精确具体访问到我们的_a 和 _b数据,也就是不能对位段的成员进行取地址(&)操作,无法使用scanf库函数进行输入数据的操作,但是我们能通过赋值的操作,给位段的成员进行赋值,编译器会帮我们进行位运算,找到对应的 bit 位,修改数据

总结:位段成员不能进行取地址(&)操作,只能进行读取和赋值操作

6.4位段的跨平台问题

位段不适用于可移植性代码的书写,不具备可移植性,不能用于跨平台、可移植的程序开发

原因如下四点:

1.不同平台下的int类型或者char类型数据,是有符号数还是无符号数是不确定的,不同编译器解释不同

2.int 类型占4个字节,但在有些平台下,只占2个字节的大小,位段成员能开辟的最大bit数不一样,如果在int为4个字节大小的平台中,给一个位段成员开辟了30个bit位的空间,那么在移植到int为2个字节的平台中,就会发生错误

3.位段的成员空间,是从开辟的字节的低位开始分配,还是高位开始分配,不同平台规则不同

4.开辟完一块空间给一个位段成员时,如果有剩但不够存放下一个位段成员,这个剩余空间我们是浪费,还是给下一个位段成员,这是不确定的,C语言并未规定,由编译器自行实现

6.5位段的使用

数据报:其中很多的属性只需要几个bit位就能描述,此时使用位段就能达到想要的效果

感谢大家的观看,新人求互三,关注我必回关!下章见!

返回列表