结构体定义
了解各种结构,以编写更优秀、更有条理的代码。
引言
假设我们想要编写一个程序,对二维点进行数学数据处理。我们可能需要计算以下内容:
- 两点之间的距离。
- 给定两点时线段的中点。
- 给定两点时直线的方程。
- 直线的斜率。
为了给出一个具体的例子,我们来编写代码计算两点之间的距离。点 与点 之间的距离为 。
在下面的代码片段中,我们在 第5–6行 和 第8–9行 初始化两个点,然后使用公式计算它们之间的距离(第11行)。
注意:出于技术原因,我们没有包含
math.h头文件。代码使用了一个自定义的隐藏sqrt函数,而不是引入math.h。
1#include <stdio.h> 2 3int main() 4{ 5 int x1 = 5; 6 int y1 = 6; 7 8 int x2 = 7; 9 int y2 = 9; 10 11 float distance = sqrt((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)); 12 printf("两点之间的距离是 %f\n", distance); 13 return 0; 14}
输出结果正确,代码看起来运行良好。然而,这并不是最佳设计。我们用两个独立的变量表示一个点,因此很容易混淆两个点的坐标。为了编写更好的代码,我们必须将这两个坐标捆绑在一起。
结构体
我们可以使用结构体来解决这个问题。结构体允许我们在 C 语言中通过以任意方式组合基本数据类型,来定义自定义数据类型。
以下是定义一个点结构体的例子:
1struct point 2{ 3 int x; 4 int y; 5};
point 结构体是一种自定义数据类型,它将两个整型变量捆绑在一起,x 和 y 是其成员。我们可以将这种结构体用作变量的数据类型:
1struct point p;
变量 p 就像其他变量一样,它的类型是 struct point。我们可以通过 variable.member 的语法访问其成员。例如:
1p.x = 1; 2p.y = 2;
我们可以在原始代码中做出这些修改,并观察变化。
1#include <stdio.h> 2 3struct point 4{ 5 int x; 6 int y; 7}; 8 9int main() 10{ 11 struct point p1; 12 p1.x = 5; 13 p1.y = 6; 14 15 struct point p2; 16 p2.x = 7; 17 p2.y = 9; 18 19 float distance = sqrt((p2.x - p1.x) * (p2.x - p1.x) + (p2.y - p1.y) * (p2.y - p1.y)); 20 printf("两点之间的距离是 %f\n", distance); 21 return 0; 22}
代码产生了相同的输出,但现在我们将点的坐标整合到了结构体变量中。
我们还可以这样初始化结构体:
1struct point p1 = {5, 6};
这会将第一个成员 x 初始化为 5,第二个成员 y 初始化为 6。
类型别名
如前所述,声明结构体类型的变量时必须使用关键字 struct。例如,我们必须写 struct point p1,而不能写 point p1。这可能会令人感到烦琐,因此我们可以使用类型别名来避免这个问题:
1struct point 2{ 3 int x; 4 int y; 5}; 6 7typedef struct point TPoint;
现在 TPoint 是 struct point 的别名。这里的 T 代表类型,通常习惯在结构体类型前加上字母 T。但这并非强制要求。
然后,要创建一个 TPoint 类型的变量,我们可以这样写:
1TPoint p;
如你所见,这看起来更接近常见的变量声明形式,如 int p。
查看以下代码,其中我们使用类型别名在 第13行 声明一个点 p,并在 第17行 打印其成员:
1#include <stdio.h> 2 3struct point 4{ 5 int x; 6 int y; 7}; 8 9typedef struct point TPoint; 10 11int main() 12{ 13 TPoint p; 14 p.x = 5; 15 p.y = 6; 16 17 printf("(%d, %d)\n", p.x, p.y); 18 return 0; 19}
还有一种更简洁的结构体类型别名写法:
1typedef struct point 2{ 3 int x; 4 int y; 5} TPoint;
效果相同,但这样写更好,因为可以在同一代码块中同时看到结构体和别名,无需在代码中来回查找。
1#include <stdio.h> 2 3typedef struct point 4{ 5 int x; 6 int y; 7} TPoint; 8 9int main() 10{ 11 TPoint p; 12 p.x = 5; 13 p.y = 6; 14 15 printf("(%d, %d)\n", p.x, p.y); 16 return 0; 17}
请注意,在上述代码中进行类型别名时,结构体名称 point 是可选的。请看 第3行 的更改,我们移除了结构体名称。结果是,现在只能使用 TPoint 来引用该结构体,不能再写 struct point。
1#include <stdio.h> 2 3typedef struct 4{ 5 int x; 6 int y; 7} TPoint; 8 9int main() 10{ 11 TPoint p; 12 p.x = 5; 13 p.y = 6; 14 15 printf("(%d, %d)\n", p.x, p.y); 16 return 0; 17}
内存表示
我们可以将结构体视为一块连续的内存区域,其大小足以容纳所有成员。例如,对于 point 结构体,编译器可能会分配一块足够容纳两个整数的内存空间。一个整数占 4 字节,因此这块内存应为 8 字节。
第一个成员变量的起始地址可能与结构体的起始地址相同,而另一个成员则从结构体开始后 4 字节处开始。我们可以通过以下代码打印这些地址来验证这一点:
1#include <stdio.h> 2 3typedef struct 4{ 5 int x; 6 int y; 7} TPoint; 8 9int main() 10{ 11 TPoint p; 12 p.x = 5; 13 p.y = 6; 14 15 printf("&p = %u\n", &p); 16 printf("&(p.x) = %u\n", &(p.x)); 17 printf("&(p.y) = %u\n", &(p.y)); 18 return 0; 19}
可能的输出:
1&p = 1052057528 2&(p.x) = 1052057528 3&(p.y) = 1052057532
结构体的地址与第一个成员 x 的地址一致,均为 1052057528。第二个成员 y 的地址比结构体起始地址偏移了 4 字节。
1052057528 + 4 = 1052057532。
注意:最好不要假设内存中的特定布局。应使用正确的语法(如
p.x)访问成员,不要试图自行推导内存布局。编译器可能会根据需要添加填充字节来调整布局。我们稍后会讨论填充问题。
这次我们幸运地得到了预期的结果。这是一个纯粹的教学示例,帮助我们直观理解结构体在内存中的样子。
考虑以下反例:我们将成员 x 的类型从 int 改为 char。
1#include <stdio.h> 2 3typedef struct 4{ 5 char x; 6 int y; 7} TPoint; 8 9int main() 10{ 11 TPoint p; 12 13 printf("&p = %u\n", &p); 14 printf("&(p.x) = %u\n", &(p.x)); 15 printf("&(p.y) = %u\n", &(p.y)); 16 return 0; 17}
可能的输出:
1&p = 499487560 2&(p.x) = 499487560 3&(p.y) = 499487564
char 类型占 1 字节,我们本期望成员 y 从 &p + 1 = 499487560 + 1 = 499487561 开始。但实际上它从地址 499487564 开始。看起来我们根本没有改变变量类型。
我们很快就会解释原因,但提前展示这个例子是为了让我们意识到这种行为。否则,我们可能会尝试用指针运算访问结构体成员,但可能失败,从而困惑于为何如此!
将结构体传递给函数
我们定义一个结构体来跟踪书籍信息。一本书可以用诸如书名、作者、出版社、出版年份、页数等字段来表示。
1typedef struct 2{ 3 char name[32]; 4 char author[64]; 5 char publisher[32]; 6 int releaseYear; 7 int numberOfPages; 8} TBook;
如上例所示,为了存储字符串(如作者姓名),我们使用 char 数组。
我们再检查一下内存布局,忽略编译器可能添加的任何填充。
在代码中,我们在 第11行 定义结构体并将其别名为 TBook。
接着,我们在 第14行 编写函数 printBookData,用于打印传入的书籍信息。我们可以像传递其他变量一样传递结构体作为参数。
在 main 函数中,我们创建了两个书籍结构体。在 第26行,我们使用花括号语法定义并初始化了 warAndPeace。
在 第29–35行,我们定义了 vicomteBragelonne,并手动初始化每个成员。
1#include <stdio.h> 2#include <string.h> 3 4typedef struct 5{ 6 char name[32]; 7 char author[64]; 8 char publisher[32]; 9 int releaseYear; 10 int numberOfPages; 11} TBook; 12 13// 接收一个书籍并打印其信息 14void printBookData(TBook book) 15{ 16 printf("书名 %s\n", book.name); 17 printf("\t作者: %s\n", book.author); 18 printf("\t出版社: %s\n", book.publisher); 19 printf("\t出版年份: %d\n", book.releaseYear); 20 printf("\t页数: %d\n", book.numberOfPages); 21 printf("\n"); 22} 23 24int main() 25{ 26 TBook warAndPeace = {"战争与和平", "列夫·托尔斯泰", "俄罗斯信使", 1865, 1225}; 27 printBookData(warAndPeace); 28 29 TBook vicomteBragelonne; 30 strcpy(vicomteBragelonne.name, "布拉热隆子爵"); 31 strcpy(vicomteBragelonne.author, "大仲马"); 32 strcpy(vicomteBragelonne.publisher, "未知"); 33 vicomteBragelonne.releaseYear = 1847; 34 vicomteBragelonne.numberOfPages = 2544; 35 printBookData(vicomteBragelonne); 36 37 return 0; 38}
如我们所见,代码运行正常,并产生预期输出。
按值传递与按引用传递
考虑以下结构体:
1typedef struct 2{ 3 char list[60000]; 4 int id; 5} TLarge;
这是一个很大的结构体,占用大量字节。如果按值传递给函数,编译器将为参数分配一个新的结构体并复制数据。这一过程非常缓慢。
我们可以改用指针传递结构体,即按引用传递。此时不会发生复制。
我们运行以下代码,分别以值传递和引用传递的方式将结构体传入两个函数,并统计函数调用所需时间。
1#include <stdio.h> 2#include <stdlib.h> 3#include <time.h> 4 5typedef struct 6{ 7 char list[4000000]; 8 int id; 9} TLarge; 10 11void func(TLarge t) 12{ 13 14} 15 16void func2(TLarge* t) 17{ 18 19} 20 21int main() 22{ 23 TLarge t; 24 25 clock_t begin = clock(); 26 func(t); 27 clock_t end = clock(); 28 29 double time_spent = (double)(end - begin) / CLOCKS_PER_SEC; 30 printf("按值传递: %f 毫秒\n", time_spent * 1000); 31 32 begin = clock(); 33 func2(&t); 34 end = clock(); 35 36 time_spent = (double)(end - begin) / CLOCKS_PER_SEC; 37 printf("按引用传递: %f 毫秒\n", time_spent * 1000); 38}
可能的输出:
1按值传递: 2.309000 毫秒 2按引用传递: 0.001000 毫秒
按值传递的速度比按引用传递慢了约 2309 倍。请注意,这个时间仅用于函数调用本身,因为 func 和 func2 函数为空,不执行任何操作。
当然,2.3 毫秒仍然很快,但代码可以快得多(精确来说快 2309 倍)。
注意:在可能的情况下,最好按引用传递结构体,以避免因复制带来的性能下降。