LINUX-C成长之路(九):复合数据类型

咱们知道,C语言中有许多基本数据类型,比如int型,float型,double型等,我们经常使用这些基本数据类型来表达一些简单的数据,比如一个人的年龄可以用 int 型数据来表示,一本书的价格可以用 float  型数据来表示等等。


但另一方面,在我们的日常生活中遇到更多的数据是复合的数据类型,比如一个学生,或者一本书。一个学生包含很多元素:姓名、性别、年龄、电话、住址等等,一本书也包含很多信息:价格、页码、出版社、售价等等,如果我们使用一堆基本数据类型来表示一个学生就会显得非常笨拙,需要动用很多相对分散的数据来表示一个逻辑上完整的独立的学生,很不方便,相对而言,我们更希望有一种叫做“学生”的数据类型,它本身就包含了“学生”这种数据的相关信息,是一个完整的独立的整体,同理我们也希望有一个叫做“书”的数据类型,来玩完整地表达一本书的属性。


很显然,C语言本身不可能提供这样的”自定义“的数据类型,因为一个具体的数据类型将会包含什么样的元素,要是具体情况而定,世界上的事物何止千万种,C语言不可能包含对它们的定义。那怎么办呢?不用担心,C语言虽然不可能为我们定义好每个具体的复合数据类型,但是它给我们提供了一套机制,让我们可以自定义我们需要的复合数据类型,来表达我们想要表达的数据。这种机制,就是所谓的结构体。


闲话少说,来一睹结构体的芳容:

struct student  // 表达一个学生的结构体
{
    char name[32];
    int age;
    float score;
};

struct book  // 表达一本书的结构体
{
    char book_name[50];
    int pages;
    float price;
    char author[20];
};

如你所见,以上就是我们自定义的两种结构体数据类型,一种叫做“student”的结构体,一种叫做“book”的结构体,没错,这两种数据类型是我们自己刚刚创造的,C语言本身并没有这两种数据类型,我们使用了C语言提供的struct 关键字,创造了它们!!

仔细观察一下以上代码,语法要点是:

1,struct 关键字不能写错。

2,student和book被称为结构体的标签,就像你花了一张设计图纸,你给这张图纸命了一个名字。这个标签是可以省略的。

3,将结构体包含的元素一一罗列在一对花括号 { } 里面

4,结构体的元素,可以是 int, float, char数组等等,事实上,除了函数(包含了函数就变成C++的类了呵呵),结构体可以包含任何数据元素(甚至包含另一个结构体)。

5,最后,以一个分号结束。


此外,非常值得关注的一点是:以上定义被称为结构体的模板,也就是相当于一张设计图纸。你此时仅仅完成了对某一种你需要的具体复合数据类型的设计,你尚未定义任何该种类型的变量。如果你要创造几个实实在在的代表学生的变量,或者创造几个实实在在的代表图书的变量,就可以使用以上模板来定义了:

int main(void)
{
    struct student Jack, Rose; // 定义了两个代表学生的变量,Jack和Rose
    struct book APUE, LKD;  // 定义了两个代表图书的变量,APUE(《UNIX环境高级编程》)和LKD(《LINUX内核设计与实现》)
}

像以上代码那样,你就定义了几个实实在在的复合数据类型变量。


咱们现在知道了所谓的结构体是怎么回事了,无非就是使用struct机制将一堆数据组合在一起,形成一种新的自定义的复合数据类型,来统一地表达一种事物而已。而且我们知道,我们应该先设计好我们想要的结构体模板,然后使用它来定义具体的变量。


下面,来看看结构体的使用,跟以前学习的基本数据类型有什么异同:


1,结构体的初始化

struct student Jack = {"Jack", 20, 88.5}; // 对Jack的初始化
struct book APUE = {"Advance Programming in UNIX Environment", 500, 99.0, "W.Richard Stevens"}; // 对APUE的初始化 

大家看到,结构体的初始化跟数组的初始化很相似,都是用一对花括号将初始化列表括起来,里面一一对应地放着结构体中的每一个元素。对每一个元素的初始化,跟对每一种基本数据类型的初始化是一致的。

另外我们关注到,以上初始化方式事实上是有缺陷的,因为初始化列表里面的元素的位置固定死了,所以,万一以后需要对该结构体进行升级维护,增加某些新的成员,或者调整已有成员的次序,都将导致这些初始化语句失效。好在,我们有更好的办法来进行初始化:

struct student Jack = {
                         .name = "Jack", 
                         .score = 88.5,
                         .age = 20 
                      }; // 对Jack的指定元素初始化,score和age的次序可以任意
struct book APUE = {
                      .book_name = "Advance Programming in UNIX Environment", 
                      .pages = 500, 
                      //.price = 99.0, 
                      .author = "W.Richard Stevens"
                    }; // 对APUE的指定元素初始化,价格成员.price可以不写
我们用”指定元素初始化”的方法,对结构体中的元素“指定”初始化,这样就能避免上述的结构体模板升级问题。另外注意到,指定元素初始化时,不一定要初始化所有的成员,也不一定要按照模板定义的成员次序来初始化,这样就使得我们的初始化非常灵活。

在上述代码中,.name 中的 小圆点被称为“ 成员引用符”,用来引用复合数据类型中的某一个成员。


2,结构体的赋值操作

struct student Mike;
Mike = Jack; // 将结构体Jack赋值给Mike
以上代码就是结构体的赋值操作,你会发现跟普通的基本数据类型的赋值操作没有任何区别。事实上,C语言的设计者当初在发明结构体这种东西的时候,其中一个要求就是要让结构体使用起来就跟普通基本数据类型一样,让用户感觉没有什么区别。

两个结构体可以直接赋值,当然也是有前提的,前提就是这两个结构体必须是同一种类型的,比如上面的Mike和Jack的类型都是 struct student,因此它们可以相互直接赋值,赋值的结构就是右边的操作数的每一个成员一一对应地赋值到左边的操作数中去。

一个有趣的地方是:数组不能这样直接赋值,但是如果两个结构体里面包含数组,结构体却可以直接赋值。


3,结构体数组

struct student class[50]; // 定义了一个具有50个元素的数组,每个元素都是一个结构体

class[0] = Jack; // 直接赋值

strcpy(class[1].name, "Michael"); // 对class[1]的成员分别赋值,不能写成class[1] = "Michael",因为数组不能直接赋值(除非是初始化)
class[1].age = 23;
class[1].score = 80.0; 

4,结构体的 取成员操作

struct book holy_bible;

holy_bible.price = 59.0;
holy_bible.pages = 700;
strcpy(holy_bible.book_name, "Holy Bible");
strcpy(holy_bible.author, "Unknown");

代码中的小圆点,就是成员引用符,使用了成员引用符的表达式应该被看做一个整体,比如holy_bible.price,这是一个float型的变量,可以在任何使用 float 型变量的地方使用。


5,指向结构体的指针(结构体指针):

struct student *p; // 定义了一个专门指向struct student 型数据的指针p
p = &Jack;         // 将Jack的地址赋给了p
(*p).age = 25;     // 通过指针的解引用访问Jack中的成员age
p->age = 25;       // 使用->来简化上一行代码

由于结构体一般体型巨大,因此很多情况下直接操作结构体显得不那么廉价(比如函数传参时),更好的方式是使用一个指向结构体的指针,定义结构体指针跟定义一个基本数据类型指针一样,给结构体指针赋值也跟普通基本数据类型一样。

对结构体指针进行解引用再取成员:(*p).age 显得很笨拙,因此C语言发明了另一个运算符来替代:p->age  请注意:箭头-> 的前面一定是一个复合数据类型的指针。


6,结构体的大小

乍看起来,一个结构体变量的大小,就应该等于其各个成员的大小之和,但事实并非如此,各个成员之间,会常常由于所谓的“地址对齐”的问题而被填充一些零,因此一般来说一个结构体的大小往往要大于其各个成员的大小之和(至少是相等)。比如:

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

printf("%d\n", sizeof(struct node));

以上代码,将会打印出 12,而不是 7(1+4+2),原因就是所谓的地址对齐问题。要详细的搞明白这个问题,我们得从CPU的存取数据的能力说起。

我们经常说一个CPU是32位的,或者是64位的,这个数值也称为该CPU的字长,这个字长的概念,说的是CPU每次到内存中存取的数据的长度,32位的CPU指的是每次到内存中可以存取4个字节(32位),或者换个角度说:CPU每次到内存中存取数据时都是4字节对齐的,4字节4字节地进行存取!

试想,站在32位CPU的角度,对于一个4字节的 int  型数据,其起始地址最好是4的倍数,因为那样的话就可以一次存取完成了,否则,如果一个 int  型数据横跨了两个4字节单元,比如其起始地址是0xFFFF00A5(即其占用的4个字节的地址分别是0xFFFF00A5,0xFFFF00A6,0xFFFF00A7,0xFFFF00A8),那么CPU就需要进行两次存取。看下图:


如前所述,一个变量的存放位置并非可以是随意的,而是会影响CPU的性能的,CPU对数据的存放位置不规范可以有不同的反应,有些CPU直接罢工,有些则以牺牲性能为代价可以继续运行。数据存放位置的问题,就是数据的地址对齐问题。注意到地址对齐问题的源头,是CPU对存取数据的性能要求这一点很重要,有助于我们对此概念的理解。

那么,问题是:一个变量究竟要存放到哪里,CPU才高兴呢?答案是:

1,如果一个变量的长度 length <= CPU的字长,那么要求该变量自然对齐

2,如果一个变量的长度 length >= CPU的字长,那么该变量只要按CPU的字长对齐即可。

所谓的自然对齐,指的是变量的起始地址是其长度的整数倍。比如 double 型数据的起始地址如果是8的整数倍,就称之为自然对齐, int 型数据的起始地址如果是4的整数倍,那么也是自然对齐的,同理,如果一个 short 型数据的起始地址是偶数,也是自然对齐的,char 型数据则不管放到哪里,都是自然对齐的。

如果是 double 型数据,占用了8个字节,比CPU的字长还要长,则不需要自然对齐,只需要按4字节对齐即可,因为即使这个 double 型的起始地址是8的整数倍,CPU也至少要存取两次才能正确操作该数据,自然对齐并没有为CPU提高效率。所以我们不需要他8字节对齐,只要4字节对齐就可以了。


注意到,每个变量因为要讨好CPU,所以每个变量的存放地址都需要是“某个数的整数倍”,这某个数,我们称之为一个变量的 m 值。

比如在32位系统里面:

int 数据的 m 值是4

short 数据的 m 值是2

char 数据的 m 值是1

double 数据的 m 值是4

... ...

请着重品味一下,m值不是数据的大小!而是CPU对这个数据的起始地址的要求!!(必须是m值的整数倍,否则CPU可能生气罢工)

那么结构体呢? 比如 struct node 这个结构体,它的 m 值是多少呢? 答案是:取决于其成员中 m 值最大的那个。

从上面的结构体定义得知,struct node 这个结构体包含了三个成员,分别是char 型、int 型和short型数据,m值分别是1、4和2,因此最大值是4,结构体的m值就是4。因此毫无疑问,结构体的起始地址必须是4的整数倍,如下图:


仔细查看上图,几个你可能不是很明白的地方:

1,a 是 char 型数据,m值是1,理论上应该可以随便放,为什么一定要放在4的倍数的地址上? 原因是:a的地址也是整个结构体的地址,而整个结构体的m值是4

2,a 的后面为什么要补三个零?原因是:b是 int 型数据,m值是4,其起始地址必须是4的整数倍,因此必须补三个零。

3,为什么 short 型数据 c 的后面还要补两个零?原因是:一个变量的m值,其实不仅仅是对其起始地址做出要求(必须是m的整数倍),而且还同时对其末端地址也做出要求(必须是m的整数倍),因此c的后面要补两个零,使得整个结构体的末端地址也必须是4的整数倍。(试想:如果不对末端地址做出要求,那么我如果定义一个该类型结构体数组会如何?考虑一下紧挨着的下一个结构体的地址分布)


变量的 m 值,甚至可以人为地指定:

struct node
{
    char a;
    int b __attribute__((aligned(256))); // 人为指定变量b的m值为256,即要求b的起始地址必须是256的整数倍
    short c;
};

代码中的__attribute__((aligned(256))) 是GNU的扩展语法(gcc编译器支持该类语法),用来改变一个变量的地址对齐属性(即m值),这是GNU扩展语法中众多attribute机制当中的比较常用的一种。注意:我们可以增大变量的m值但是不能减小它。

尝试计算该变量的大小,可以通过实验,来验证你的理解。


C语言的复合数据类型除了结构体之外,还有所谓的联合体,长成这个样:

union example
{
    int a;
    char b;
    double c;
};
可以看到,联合体(亦称为共用体)跟结构体非常类似,不同点在于:

1,关键字名字不同:union和struct

2,内部成员的内存分布不同,我们刚刚讨论了结构体内部各个成员的内存分布情况,它们每个人独自占用自己的内存,由于地址对齐的问题有时还可能要在彼此之间填充一些零。二联合体完全不同,联合体的所有成员的起始地址都是一样的,换句话讲,它们将来将会相互覆盖!最后对哪一个成员赋值,哪一个成员就有效,其他的统统失效。结构体的大小计算比较复杂,要考虑地址对齐,联合体的大小非常简单,决定于最大成员。


什么时候会使用这样的联合体呢?答案是:当你要表达的东西本质上是互斥的时候:比如人的性别:男或者女,不会出现既是男又是女的情况;黑或白,不会出现既是黑又是白的情况;运行和睡眠,不会出现一个进程既正在运行又正在睡眠的情况。这些时候,使用联合体不仅可以节省代码尺寸,更为重要的是使得程序更具有可读性。

你可能感兴趣的:(c,linux,C语言,语言,结构体大小)