程序员C语言快速上手——高级篇(九)

时间:2022-06-26
本文章向大家介绍程序员C语言快速上手——高级篇(九),主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。
  • 高级篇
    • 结构体
      • 背景
      • 结构体的声明与使用
      • 结构体变量的初始化
        • 按顺序初始化
        • 缺省的顺序初始化
        • 零值初始化
        • 指定成员初始化
      • 结构体与内存
      • 结构体与指针
      • 结构体的其他声明方式
        • 声明结构体同时还声明结构体变量
        • 声明匿名的结构体
      • 结构体类型定义
      • 结构体总结

高级篇

结构体

背景

结构体是一种聚合数据类型,C语言的数组也是一种聚合数据类型,它们显著的区别是,数组是相同数据类型的集合,而结构体可以是不同数据类型的集合。

假如要表示一个学生,那么我们可能需要声明多个变量

// 姓名
char *name;
// 年龄
int age;
// 编号
char *number;
// 年级
char *grade;

这在实际操作中非常麻烦,我们需要一种新的数据类型,将这些信息存放在一起,而不是这样分散的去表示和操作。数组显然是无法满足这个需求的,因为数组只能存放相同的数据类型,一个学生的信息,可能需要多种数据类型来表示,比如考试成绩,这个就需要float类型来表示。

结构体的声明与使用

为了实现类似上述的这种需求,结构体就诞生了

// 声明一个结构体
struct student {
    char *name;
    int age;
    char *number;
    char *grade;
};

结构体声明的一般格式

struct 标签名 {
	成员变量1
	成员变量2
    ……
};

结构体被声明之后,就相当于产生了一个新的数据类型,我们可以如下使用:

#include <stdio.h>
// 声明一个结构体
struct student
{
    char *name;
    int age;
    char *number;
    char *grade;
};

int main(){
    // 声明结构体变量:stu
    struct student stu;
    // 为结构体中的成员赋值
    stu.name = "zhangsan";
    stu.age = 19;
    stu.number = "A010";
    stu.grade = "18级";
    // 访问结构体中各个成员变量的内容
    printf("学生信息:%s,%d,%s,%sn",stu.name,stu.age,stu.number,stu.grade);
    return 0;
}

打印结果:

学生信息:zhangsan,19,A010,18级

有几个点需要注意:

  1. 使用关键字struct + 标签名 + 一对花括号 + 分号 来声明结构体。在花括号中,声明结构体需要包含的变量,这些变量被称为结构体的成员变量,或成员字段。一定要注意,结尾的分号不能掉
  2. 在使用时,需将struct + 标签名合起来看做一种新的类型,然后使用我们熟知的数据类型 + 变量名的格式来声明一个结构体变量。例如struct student stu;,这里struct student是一种新类型,stu则是变量名。这里一定要注意,声明结构体和声明结构体变量完全是两回事
  3. 使用英文句号.来访问结构体中的成员变量,这被称为结构体成员访问符。

通过这个例子,我们可以直观感知到,结构体就如同分组一样,将一伙人分成一个小组,然后给这个组命名,以后就用这个组名来代表这一伙人。结构体的出现,可以使代码变得简洁,例如以前我们需要将一个学生的信息作为参数传入到一个函数中处理,那么我们就必须给这函数定义一系列形式参数,现在则只需要定义一个结构体参数就行了,这个好处就如同将各种小物件打包在一起,出门只需要推一个旅行箱一样方便。

// 结构体类型做函数参数
void printStudent(struct student s){
    printf("学生信息:%s,%d,%s,%sn",s.name,s.age,s.number,s.grade);
}

int main(){
    struct student stu;
    stu.name = "zhangsan";
    stu.age = 19;
    stu.number = "A010";
    stu.grade = "18级";
    
    printStudent(stu);
    return 0;
}

结构体仅仅是构造了一种新的组合数据类型,其使用与普通的基本类型大致相同,它既可作为函数参数也可作为函数的返回值。

结构体变量的初始化

以上是通过结构体变量来访问成员变量来逐个进行赋值的,实际上结构体可以在声明的同时进行初始化,这点类似于数组。

按顺序初始化

struct student stu={"zhangsan",19,"A010","18级"};

按照声明结构体时的成员变量的顺序,在花括号中依次填入其值,如同数组初始化。

缺省的顺序初始化

与数组类似的,我们也可以进行缺省的初始化,如下,这样就只会对前两个成员变量赋值,后面省略的变量会被进行零值初始化。

struct student stu={"zhangsan",19};
printStudent(stu);

打印结果:

学生信息:zhangsan,19,(null),(null)

指针变量的零值就是NULL,可以看到省略的最后两个成员变量被赋了零值。

零值初始化

前面一直强调,局部变量应当先初始化再使用,当结构体变量做局部变量时,也应当遵循。以上代码示例是没有遵循的,通常人们都喜欢先声明,后面就直接去操作了,忽略了初始化步骤,特别是当我们不确定结构体成员变量的值时,就会先声明放在那,这是不好的习惯,我们应当先做零值初始化。

正确示范

int main(){
    // 声明同时对所有成员变量做零值初始化
    struct student stu={NULL};

    // 初始化之后再去使用,正确!
    stu.name = "zhangsan";
    stu.age = 19;
    stu.number = "A010";
    // stu.grade = "18级";
    
    printStudent(stu);
    return 0;
}

错误示范

int main(){
    // 声明但不初始化
    struct student stu;

    // 直接就去使用,但省略了对grade变量的赋值
    stu.name = "zhangsan";
    stu.age = 19;
    stu.number = "A010";
    // stu.grade = "18级";
    
    printStudent(stu);
    return 0;
}

以上代码异常退出,这是因为声明时未对结构体的所有成员进行零值初始化,在使用时,又没有对全部成员进行赋值,导致成员变量出现了野指针的情况。局部变量的特点是,声明但不初始化,那么它的值是随机的,如果是指针变量,那么它可能会指向一个随机的内存空间,这可能是一个不允许访问的内存空间,这就是所谓野指针。

除了可以使用NULL做零值初始化,也可以使用0,如下

struct student stu={0};

在GCC编译器中,甚至可以直接省略什么都不写,但不推荐,因为微软的VC编译器不支持,这样写的代码兼容性太差。

struct student stu={};

指定成员初始化

按顺序初始化是不够灵活的,而且还需要记忆结构体成员变量的顺序,当结构体成员变量比较多时,就有些糟心了。因此C99标准推出了新的语法,指定成员变量名进行初始化

    struct student stu={.age=18, .name="张三"};
    printStudent(stu);

在成员变量名前面加上一个成员访问符.,然后使用=号的形式进行初始化赋值,多个之间用逗号分隔。这种新的语法有两个明显的好处,一是语义化表达,每个值对应哪个成员变量非常清晰;第二是无序,不用再去关心成员变量声明时的顺序了。与顺序初始化相同的,没有被指定的成员变量,则会被自动的初始化为零值。

这种结构体初始化方式是我推荐的,它极大的提升了代码可读性,而且这种被称为声明式语法的表达,正是目前其他高级编程语言所流行的趋势。当我们掌握C语言再去学习Go语言时,会发现Go的结构体都是这样去初始化的。另外值得说明的是,这种语法虽然是C99的新特性,但是好东西,微软的VC编译器也是会支持的,在VS2013及以上版本,可以放心使用。你的VC6.0还不淘汰,更待何时?VS2017早出免费版了,你是否还在用盗版的破解的VC编译器?

结构体与内存

先看一个现象

struct A
{
    int a;
    char b;
    short c;
};

int main(){
    struct A struA={0};
    printf("size is %dn",sizeof(struA));
    return 0;
}

打印结果:

siez is 8

我们将结构体成员变量声明的顺序调整一下,在次打印

struct A
{
    char b;
    int a;
    short c;
};

打印结果:

siez is 12

这里就发生了很奇怪的现象,第一次我们使用sizeof获取的结构体占用内存大小是8,当调整成员变量声明的顺序后,即将char b;int a;顺序交换,其他都不变,结构体占用的内存大小增加了,变成了12,为什么会出现这样的情况呢?

有一些教材上说,结构体占用的内存大小就等于结构体各个成员变量占用的内存大小之和,这里char 1个字节,int4个字节,short2个字节,加起来是7字节,怎么跑出了8个字节呢?显然这种说法是存在问题的。要把这些问题全部搞清楚,就得了解结构体在内存中的分布情况。

首先我们得开动脑筋,学会该怎么去研究和分析问题,有时候一些资料自相矛盾时,我们感到迷糊时,都得自己想办法去探索和试验,所谓实践出真知,放到这里再合适不过了。

#include <stdio.h>

struct A
{
	int a;
	char b;
	short c;
};

int main(){
	struct A struA = {0};
	// 打印结构体的内存地址
	printf("A address is %xn", &struA);
	// 分别打印结构体每个成员变量的内存地址
	printf("A.a address is %xn", &struA.a);
	printf("A.b address is %xn", &struA.b);
	printf("A.c address is %xn", &struA.c);
	return 0;
}

观察打印结果:

A address is 46fd30
A.a address is 46fd30
A.b address is 46fd34
A.c address is 46fd36

我们观察到的第一个现象是,结构体变量的内存地址和它的第一个成员变量的地址是相同的。这一点和数组很相似,数组变量的地址与数组第一个元素的地址也是相同的。

第二个现象是,结构体在内存中的布局,是将它的所有成员变量,按照声明时的顺序连续排列到内存空间中。这个也很容易看出来,变量abc的内存地址编号都是有顺序的,aint类型,占用四个内存单元,它的起始地址是46fd30,紧随其后的b变量起始地址正好是46fd34

再看示例:

    // 设置有效值,查看内存分布情况
	struct A struA = {18,'a',127};
	printf("A address is %xn", &struA);
	printf("A.a address is %xn", &struA.a);
	printf("A.b address is %xn", &struA.b);
	printf("A.c address is %xn", &struA.c);

我们可以使用Visual Studio工具来查看真实内存分布图,为了便于理解,以下字节中的值都是用我们熟悉的十进制表示的

注意,这里-52是VC编译器将未使用的字节做自动填充时所用的十进制默认值,对应的也就是我们前面章节说的16进制数0xcc,它表示这个字节没有被使用。一定要搞清楚,0xcc的含义和0是不同的,如果这个字节的值是0,那么表示它是被使用的,只是它此刻的值就是0而已。

将结构体成员变量声明的顺序调整一下,再次查看内存布局

struct A
{
    char b;
    int a;
    short c;
};

struct A struA = { 'a', 18, 127 };

观察上图,很容易发现规律,char类型本来是一个字节,但是现在在结构体中却占了4个字节,int a紧随其后,接下来short c在结构体中又占了4个字节!

相信此时,大家心中已经有了一个模糊的答案。编译器为了提升内存访问的性能,它会做一件事,用通俗的话说,它会对结构体分组访问,通常在用32位来表示int类型的硬件平台上,它会将每四个字节分成一组来进行访问,这样可以提升内存访问效率。譬如上面的例子,结构体中有三个变量abc,如果不分组,正常情况下要向内存一个字节一个字节的读取数据,这样效率会比较低,但是分组访问就不一样了,假设我们约定4个字节为一组,那么int a正好是四个字节,第一组就访问它,剩下的char bshort c加起来总共3个字节,正好可以凑成第二组,这样一来,三个变量,只需要分两次访问就Ok了,大大减少了对内存的访问次数,提升了性能。

以上就是C语言中,所谓的结构体内存对齐的概念。带给我们的启示就是,在声明结构体成员变量时,不要随意去排列成员变量的顺序,要有意识的去安排变量的顺序适应内存对齐,这样可以减少结构体占用的内存大小。

如下这种排列就是不合理的,导致编译器做内存对齐时,将其分成三组,每组4个字节,这使得该结构体占用的内存变成了12字节。而将int a放在第一个成员位置时,编译器内存对齐后,结构体仅占用8字节大小。这正解惑了我们一开始提出的疑问。

struct A
{
    char b;
    int a;
    short c;
};

有人可能会疑问,char bint a为什么不贴在一起放,它们加起来虽是5个字节,但我们可以把前四个字节归为一组啊?这里要注意,int类型的四个字节是看成一个整体的。举个生活中的例子,我们都知道拼车可以提升小汽车的使用效率,这也是之前流行顺风车的一个原因,假如顺风车有三个空位,现在正好有三个人想拼车,但是其中一个人吨位比较大,他一个人必须要占两个人的位置才能坐下,那么现在该如何安排这三个人呢?我想,正常情况下这一趟都是装不下三个人的,必须分两批次,而且一个人也不能被分成两半,这个吨位大的人只能占后排两个位置是比较合理的,剩下两个人,一个坐副驾驶,一个再打另一辆车。想通了这个例子,基本也就想通了结构体内存对齐来提升效率的概念。

有些善于发现问题的朋友可能会想到,在64位系统里,long类型表示8字节,那么结构体怎么进行内存对齐呢?实际上,上面仅仅是打比方来说明问题,不同的编译器,其结构体内存对齐的规则也不尽相同,并不是简单的仅仅按照4字节来对齐。Windows下的VC编译器,主要按照4字节或8字节来对齐,而Linux下的GCC则使用2字节或4字节来对齐,这个对齐参数被称为对齐模数

如果我们不想优化性能,在某些特殊场景下,不希望某个结构体做内存对齐,则可以通过预编译指令进行设置

// 传入1,指定不做内存对齐,在结束处pack()不传参,恢复内存对齐
# pragma pack(1)
struct A
{
    char b;
    int a;
    short c;
};
# pragma pack()

int main(){
    struct A struA = { 0 };
	printf("size is %dn",sizeof(struA));
    return 0;
}

# pragma pack(1)# pragma pack()将不希望内存对齐的结构体包裹起来,再次查看打印结果

size is 7

结构体与指针

结构体与数组很像,本质上就是指的一片特定的连续的内存空间,结构体成员就在这边内存空间中按顺序分布。那么所谓结构体指针,也就是指向该结构体的指针,结合结构体内存分布知识可知,这个指针实际上就是保存了结构体空间的初始地址。

int main(){
    // 声明并初始化一个结构体变量
    struct student stu = {0};
    // 声明一个结构体指针变量,并指向一个结构体
    struct student *p_stu = &stu;

    // 通过结构体指针访问成员
	printf("学生信息:%s,%d,%s,%sn",p_stu->name,p_stu->age,p_stu->number,p_stu->grade);
    return 0;
}

事实上,将结构体作为一种新的类型,那么结构体指针与其他类型的指针用法也是相似的,唯一需要注意的地方是,结构体变量访问成员,使用成员访问符.,而结构体指针变量是不同的,它使用一个小箭头->来访问,要注意这两者的区别,万万不能混淆。

在C语言中,除了数组做函数参数是地址传递外,其他所有类型都是值传递,结构体也是如此。因而,在将结构体传入一个函数内部时,应当考虑使用结构体指针,避免对结构体做内存拷贝,用以提升性能。

结构体的其他声明方式

上面的结构体声明方式只是一般方式,除此之外,还有各种怪异的声明方式,大多数是不推荐的,但是要能看懂别人的代码。

声明结构体同时还声明结构体变量

int main(){
    // 声明结构体的同时,再声明两个结构体变量a、b
    struct student
    {
        int age;
        char *name;
        char *number;
        char *grade;
    }a,b;


    // 再声明一个结构体变量c
    struct student c = {0};
    return 0;
}

还可以在声明结构体并声明结构体变量的同时初始化

int main(){
    struct student
    {
        int age;
        char *name;
        char *number;
        char *grade;
    }a={0},b={.name="李四"};

    printf("%s",b.name);
    return 0;
}

声明匿名的结构体

声明结构体时的标签名是可以省略的

    // 声明一个结构体,并省略标签名,同时声明两个结构体变量a、b
    struct
    {
        int age;
        char *name;
        char *number;
        char *grade;
    }a,b;

匿名结构体与有名字的结构体有显著的区别,因为它没有名字,必须在声明的同时声明好需要的结构体变量,后面它是没法再去声明新的结构体变量的。这种用法有一个用处,如果我只指定声明一个结构体变量,那么全局就只有一个该结构体变量,后面无法定义新的结构体变量了。

结构体类型定义

在结构体的一般声明格式中,当我们声明好一个结构体后,使用的时候还需要将struct关键字+标签名作为一个整体来声明新的结构体变量,如struct student stu;,这样的语法表达非常麻烦。实际上在C语言中,结构体声明通常是和另一关键字typedef结合起来使用的。

// 使用typedef时,省略结构体标签名
typedef struct{
    int age;
    char *name;
    char *number;
    char *grade;
} Student;

typedef struct{
    int x;
    int y;
} Point;

int main(){
    // 声明结构体变量
    Student stu = {0};
    Point point = {10,20};
    return 0;
}

以上的结构体使用方式,才真正符合我们的编程直觉,看起来更像C++、Java中的类的使用。通常的,我们应该在头文件中用以上方式声明结构体,然后在源文件中包含头文件,使用相应的结构体。

小拓展

typedef是一个可以用来定义类型别名的关键字,它并不仅仅是用在结构体声明中

typedef 旧类型名 新别名;
#define false 0
#define true 1
typedef int bool;
typedef char byte;

int main(){
    bool b=false;
    byte stream[10];
    return 0;
}

要注意,typedef定义的类型别名后面,一定要跟上分号结束。

结构体总结

  1. 在声明结构体变量的时候,编译器就为其分配内存空间
  2. 结构体在内存中的分布,是一片连续的内存空间
  3. 结构体指针保存的是结构体在内存空间的起始地址
  4. 结构体的总内存大小并不一定等于其全部成员变量内存大小之和,当存在内存对齐时,可能会多占用一些额外的空间
  5. 结构体变量使用.访问成员,结构体指针使用->访问成员
  6. 声明结构体时,建议结合typedef关键字创建别名
  7. 结构体可以嵌套使用,即将一个结构体作为另一个结构体的成员