Lesson 11: 求两点距离 CNB
练习任务
编写一个 C 程序,定义表示二维坐标点的结构体 point_t(包含 float x 和 float y),实现函数 float calculate(point_t p1, point_t p2) 计算两点之间的欧氏距离,并从标准输入读取两个点的坐标后输出结果(保留两位小数)。
期望输出(输入 0 0 3 4):
5.00期望输出(输入 1 1 4 5):
5.00提示:结构体可以用
struct { ... }定义,用typedef起别名。欧氏距离公式是 sqrt((x1-x2)² + (y1-y2)²)(勾股定理)。scanf可以直接读取结构体成员(如&p1.x)。结构体作为函数参数时,C 语言会整体复制一份传给函数——这和传基本类型如int是一样的机制。
核心知识点
struct结构体定义 — 将多个相关变量打包成一个新的复合类型- 成员访问运算符
.—p.x访问结构体变量p的成员x - 指针访问运算符
->—ptr->x等价于(*ptr).x,通过指针访问结构体成员 typedef类型别名 —typedef struct point point_t为结构体类型起一个简洁的名字_t后缀命名约定 — POSIX 标准保留_t后缀给系统类型,用户代码中慎用但广泛使用- 结构体内存布局 — 成员在内存中按声明顺序排列,编译器插入对齐填充(padding)
sizeof分析 — 用sizeof验证结构体实际大小,理解对齐规则- 结构体初始化 — 三种方式:位置初始化
{1, 2}、C99 指定初始化.x=1、传统 GNU 语法x:1 - 部分初始化与零初始化 — 未显式指定的成员自动初始化为 0
- 结构体作为函数参数 — 按值传递(整体复制),修改形参不影响实参
- 传结构体指针 vs 传值 — 大结构体传指针避免复制开销,用
const保护只读参数 - 欧氏距离公式 —
sqrt((x1-x2)² + (y1-y2)²)即勾股定理的二维形式 sqrt与math.h— 回顾 Lesson 07 数学库,需要-lm链接,sqrtf处理floatprintf格式说明符 —%.2f精度控制、%fvs%lf、字段宽度与对齐- 结构体包含数组 — 结构体赋值会完整复制数组成员(深拷贝),区别于指针的浅拷贝
- 嵌套结构体 — 结构体包含结构体成员、结构体数组
- 函数返回结构体 — 按值返回,编译器可能使用 RVO(返回值优化)
- 复合字面量 — C99 Compound Literals 的语法和用途
- 匿名结构体与联合体 — C11 匿名成员的特性
- struct vs C++ class — C 语言如何用函数指针模拟面向对象
- 设计决策 — 何时将字段打包为结构体,何时保持分离
- 内存优化 — 通过重排字段减少对齐填充
代码框架
#include <stdio.h>
#include <math.h>
// 第一步: 用 struct 定义坐标点类型
// struct point { float x; float y; };
// 然后用 typedef 起别名: typedef struct point point_t;
// 第二步: 实现距离计算函数
// float calculate(point_t p1, point_t p2)
// {
// float dx = p1.x - p2.x;
// float dy = p1.y - p2.y;
// return sqrt(dx * dx + dy * dy);
// }
int main(void)
{
point_t p1, p2;
float distance;
// 第三步: 从 stdin 读取四个浮点数
scanf("%f %f %f %f", /* 四个地址 */);
distance = calculate(p1, p2);
printf("%.2f\n", distance); // %.2f: 保留两位小数
return 0;
}填充框架的关键思考:typedef 写在结构体定义之后还是之前?scanf 读取结构体成员时地址怎么写?sqrt 需要链接什么库?
TIP
先不要往下翻看参考解答。尝试自己定义结构体、写距离函数——编译时记得加 -lm。
深度讲解
1. struct 结构体:制造自己的复合类型
1.1 为什么需要结构体
一个二维坐标点需要两个相关联的值(x 和 y)来完整描述。用单独的变量也能处理:
float x1, y1, x2, y2; // 各行其是,但缺乏逻辑关联但当有多个点时,这种分离写法迅速失控:
float x1, y1, x2, y2, x3, y3, x4, y4; // 完全扁平,看不出哪些配对每增加一个点,就需要两个新变量——代码的复杂度呈线性增长,而可读性呈指数下降。struct 将相关的变量打包成一个有名字的复合类型:
struct point {
float x;
float y;
};
// 现在 struct point 是一个「拥有 x 和 y 两个成员」的类型类型的语义:不是说 struct point 是「一种东西」——它是一种新的类型,就和 int、float 一样可以声明变量、作为函数参数。这是 C 语言赋予程序员的类型扩展能力——你不再局限于语言内置的那几个基本类型,可以创造适合自己问题域的类型。
结构体的核心价值在于语义聚合:当多个数据总是「一起出现、一起传递、一起有效」时,把它们封装成一个结构体,代码的意图就从分散的变量名变成了一个有名字的抽象。
1.2 struct 的定义语法
struct point // struct 关键字 + 类型名(tag)
{
float x; // 成员: 类型 + 名称
float y;
}; // ← 别忘了分号!这是初学者最常见遗忘的分号之一逐元素拆解:
struct:关键字,告诉编译器「我要定义一个结构体类型」point:类型名(tag),用于后续引用这个类型{ ... }:成员列表,每个成员用类型 名称;声明;:结构体定义的结束分号——这是 C 语言中为数不多的「在}后面还要加分号」的地方
WARNING
忘记结构体定义末尾的 ; 会导致编译器报出令人困惑的错误信息。编译器会把下一行代码当作结构体定义的一部分,从而产生连锁错误。如果你看到「expected ';' after struct definition」之类的错误,第一时间检查结构体的右花括号后面有没有分号。
相当于在 C 语言的类型系统中注册了一种新类型,名为 struct point。注意:类型的完整名字是 struct point,而不是 point——point 只是 tag,必须和 struct 关键字一起使用(除非用了 typedef,见第 2 节)。
1.3 声明变量与成员访问(. 运算符)
struct point p1; // 声明一个 struct point 类型的变量
p1.x = 100.0f; // . 运算符访问成员
p1.y = 200.0f;
struct point p2;
p2.x = 200.0f;
p2.y = 100.0f;. 是成员访问运算符——左边是结构体变量名,右边是要访问的成员名。它的优先级非常高(高于 & 取地址),因此 &p1.x 的含义是「取 p1.x 的地址」而不是「取 p1 的地址再取成员」。
可以同时声明和初始化:
struct point p1 = {100.0f, 200.0f}; // 声明 + 位置初始化
struct point p2 = {.x = 200.0f, .y = 100.0f}; // 声明 + 指定初始化(C99)1.4 结构体的内存布局与对齐
理解结构体在内存中的布局对于编写正确和高效的代码至关重要。
struct point {
float x; // 4 字节
float y; // 4 字节
};
printf("sizeof(struct point) = %zu\n", sizeof(struct point));
// 输出: sizeof(struct point) = 8(通常)对于只有两个相同类型成员的结构体,内存布局简单明了:
struct point 的内存布局:
┌────────────┬────────────┐
│ x │ y │
│ (4 bytes) │ (4 bytes) │
└────────────┴────────────┘
offset 0 offset 4
低地址 ← → 高地址但是当成员类型混合时,情况就复杂了——编译器会插入对齐填充(padding):
#include <stdio.h>
struct mixed {
char c; // 1 字节
int i; // 4 字节
short s; // 2 字节
};
int main(void)
{
printf("sizeof(struct mixed) = %zu\n", sizeof(struct mixed));
// 预期: 1 + 4 + 2 = 7,实际: 12(有填充)
return 0;
}为什么实际大小是 12 而不是 7? 因为 CPU 访问内存时,对齐的数据读取效率更高。以 int(4 字节)为例,它必须存放在地址是 4 的倍数的位置。编译器因此插入填充字节:
struct mixed 的内存布局(含填充):
┌──────┬───────────────────────────┬──────┬──────┬───────────────────┐
│ c │ padding │ i │ s │ padding │
│1 byte│ 3 bytes │4 byte│2 byte│ 2 bytes │
└──────┴───────────────────────────┴──────┴──────┴───────────────────┘
offset:0 1 2 3 4 8 10 11
(4的倍数)对齐规则(简化版):
- 每个成员的起始地址必须是其类型大小的整数倍(
char=1,short=2,int=4,double=8,指针=8) - 结构体的总大小必须是其最大成员对齐值的整数倍(称为 stride)
- 成员之间可能插入填充字节以满足规则 1
- 结构体末尾可能插入填充字节以满足规则 2
TIP
sizeof 运算符返回的值包含了所有填充字节。用 offsetof(struct mixed, i)(定义在 <stddef.h> 中)可以精确获取每个成员在结构体中的偏移量。
对齐对性能的影响:未对齐的内存访问在 x86 上可能只需要额外几个 CPU 周期,但在某些架构(如 ARM、SPARC)上会直接导致总线错误(bus error)或异常。编译器自动插入填充是为了保证代码在所有平台上的正确性。
2. typedef:给类型起别名
2.1 问题:struct point 写起来太啰嗦
每次声明变量都要写完整 struct point:
struct point p1, p2, p3; // 啰嗦
struct point calc_midpoint(struct point a, struct point b); // 更啰嗦对于频繁使用的类型,struct 前缀成为视觉噪音。typedef 解决了这个问题。
2.2 typedef 的语法与语义
typedef struct point point_t;
// └─原类型名─┘ └新别名─┘
// 现在可以用 point_t 替代 struct point
point_t p1, p2; // 简洁
point_t calc_midpoint(point_t a, point_t b); // 清晰typedef 的语义:纯粹是类型名称的替换,不影响运行时行为。编译器在语义分析阶段将别名替换为原始类型,生成的机器码完全一致。可以把 typedef 理解为给类型起了一个「花名」——人的身份不变,只是多了个称呼。
2.3 _t 后缀命名约定
typedef struct point point_t; // _t 后缀表示「这是一个类型别名」_t 后缀是 Unix/POSIX 社区的广泛惯例(如 size_t、time_t、pid_t)。但有一个重要的注意点:
IMPORTANT
POSIX 标准保留所有以 _t 结尾的标识符给系统使用。在严格遵循 POSIX 的代码中,自定义类型不应使用 _t 后缀以避免未来与标准库冲突。但在教学、竞赛和大多数实际项目中,_t 后缀已约定俗成,广泛使用。
替代命名风格(如果不想用 _t):
typedef struct point Point; // 首字母大写
typedef struct point point_type; // 完整 _type 后缀2.4 合并写法与两种风格对比
// 一步到位: 定义结构体 + 起别名
typedef struct {
float x;
float y;
} point_t;结构体名(struct point 中的 point)可以省略——因为 typedef 创建的类型别名足够用了。这种写法称为「匿名结构体的 typedef」。
两种风格的详细对比:
| 维度 | 风格 A:先定义,后别名 | 风格 B:合并定义 |
|---|---|---|
| 写法 | struct point { ... };typedef struct point point_t; | typedef struct { ... } point_t; |
| 结构体 tag | 有(point) | 无(匿名) |
| 自引用能力 | 可以(成员中有 struct point *next) | 不可以(无法引用自己) |
| 前向声明 | 支持(struct point;) | 不支持 |
| 适用场景 | 链表、树等需要自引用的数据结构; Linux 内核风格 | 简单的值类型(如坐标点、颜色、矩形); 一次性使用的类型 |
| 可读性 | tag 名 + 别名 = 两个名字,略冗余 | 简洁,一个名字 |
// 风格 A 支持自引用——链表的经典写法
typedef struct node {
int data;
struct node *next; // 这里必须用 struct node,因为 node_t 还没定义完
} node_t;
// 风格 B 无法自引用——匿名结构体没有 tag 名
// typedef struct {
// int data;
// ??? *next; // 写什么?没有名字可用
// } node_t; // 编译错误!TIP
对于本课的 point_t(没有自引用需求),两种风格都适用。当你不确定时,选择风格 A——它在功能上更完备,且是 Linux 内核等大型 C 项目的标准做法。
3. 结构体的三种初始化方式
3.1 方式 1:位置初始化(Positional Initialization,C89)
point_t p = {100, 200};
// x=100 y=200(按成员声明顺序依次赋值)规则:初始化列表中的值按成员声明顺序依次赋值。这是 C89 就有的经典方式,也是 C 语言诞生之初就支持的初始化方法。
缺点:
- 成员多时容易搞错顺序
- 可读性差——
{100, 200}中哪个是 x 哪个是 y 需要查结构体定义 - 增加新成员到结构体中间时,所有初始化代码都需要调整顺序
3.2 方式 2:指定初始化(Designated Initializer,C99)
point_t p = {.x = 100, .y = 200}; // 按名称初始化,顺序无关
point_t q = {.y = 200, .x = 100}; // 顺序无所谓,效果完全相同
point_t r = {.x = 100}; // 只初始化 x,y 自动为 0.0f优势:
- 自文档化——一眼看出哪个值对应哪个成员,不需要查结构体定义
- 顺序无关——不需要记忆成员声明顺序,编译器按名称匹配
- 可部分初始化——未指定的成员被隐式初始化为 0(算术类型为 0,指针为 NULL)
- 可维护性——结构体增加新成员时,已有的指定初始化代码不受影响(新成员自动为 0)
- 健壮性——如果成员类型改变(如
float改成double),指定初始化不受影响
3.3 方式 3:传统 GNU 语法(Legacy)
point_t p = {x : 100, y : 200}; // GCC 旧扩展语法这是 GCC 在 C99 标准化之前提供的扩展语法,用 : 代替 . 和 =。现代 C 代码中应避免使用——它不在任何 ISO C 标准中,且 GCC 的 -pedantic 选项会对此发出警告。这里提及仅为了在阅读老代码时能够识别。
3.4 局部初始化与零初始化
point_t p1 = {0}; // x=0, y=0(第一个成员为 0,其余自动为 0)
point_t p2 = {.x = 100}; // x=100, y=0(只指定了 x,y 自动为 0)
point_t p3; // x 和 y 都是垃圾值零初始化的更多方式:
// 方式 1: {0} 惯用写法
point_t a = {0};
// 方式 2: 指定初始化(效果相同但更明确)
point_t b = {.x = 0, .y = 0};
// 方式 3: C23 空初始化器(最新标准)
// point_t c = {}; // C23 新增,等效于 {0}
// 方式 4: 全局变量自动零初始化(BSS 段)
point_t global_p; // 自动全零WARNING
未初始化的局部结构体变量(point_t p3;)其成员是垃圾值,和基本类型变量一样分布在栈上。全局结构体变量则自动清零(存储在 BSS 段,操作系统在加载程序时保证)。永远不要依赖局部变量的初始值。
4. 结构体作为函数参数:按值传递的完整复制
4.1 整体复制的机制
float calculate(point_t p1, point_t p2)
{
float dx = p1.x - p2.x;
float dy = p1.y - p2.y;
return sqrt(dx * dx + dy * dy);
}
// 调用时
point_t a = {100, 200};
point_t b = {200, 100};
float d = calculate(a, b); // a 和 b 各被复制一份传入函数这和 int、float 的传参机制完全一致——C 语言所有参数都是按值传递的(pass by value)。区别只在于结构体可能包含多个值,复制的工作量更大。
复制发生在哪里? 在函数调用的栈帧(stack frame)中:调用者的栈上存着实参 a 和 b,被调用者的栈上存着它们的完整副本 p1 和 p2。对于 point_t(8 字节),复制开销微乎其微。
函数调用时的栈布局:
┌────────────────────┐ 高地址
│ 调用者的栈帧 │
│ a (x=100, y=200) │ ← 实参
│ b (x=200, y=100) │ ← 实参
├────────────────────┤
│ 返回地址 │
├────────────────────┤
│ p1 (x=100, y=200)│ ← 形参 = a 的副本
│ p2 (x=200, y=100)│ ← 形参 = b 的副本
│ 局部变量 dx, dy │
└────────────────────┘ 低地址4.2 传值 vs 传指针:性能与安全性权衡
| 方式 | 写法 | 复制的内容 | 内存开销 | 原变量安全性 |
|---|---|---|---|---|
| 传值 | calc(point_t p1, point_t p2) | 完整结构体 | sizeof(struct) × 2 | 安全,函数不能修改原值 |
| 传指针 | calc(const point_t *p1, const point_t *p2) | 仅复制指针(8 字节) | 固定 8 × 2 字节 | 安全(const 保护),推荐 |
// 传值: 函数内修改不影响原变量
void move_value(point_t p, float dx, float dy) {
p.x += dx; // 只修改了副本
p.y += dy;
}
// 传指针: 函数内修改反映到原变量
void move_ptr(point_t *p, float dx, float dy) {
p->x += dx; // 通过指针修改原结构体
p->y += dy;
}
// 传 const 指针: 只读访问,高效且安全
float calculate_const(const point_t *p1, const point_t *p2) {
float dx = p1->x - p2->x;
float dy = p1->y - p2->y;
return sqrtf(dx * dx + dy * dy);
// p1->x = 0; // 编译错误: const 保护
}选择指南:
- 结构体很小(<= 16 字节):传值,代码更简洁,编译器可能用寄存器传参
- 结构体较大(> 16 字节):传
const指针,避免复制开销 - 需要修改原结构体:传指针(不带
const) - 不确定时:传
const指针总是安全的默认选择
4.3 -> 运算符:通过指针访问成员
point_t p = {100, 200};
point_t *ptr = &p;
// 两种等价的成员访问方式
(*ptr).x = 300; // 先解引用,再用 . 访问——括号不可省略(. 优先级高于 *)
ptr->x = 300; // -> 运算符: 等价于 (*ptr).x——推荐写法a->b 是 (*a).b 的语法糖。为什么需要括号?因为 . 的优先级高于 *:*ptr.x 等价于 *(ptr.x),编译器会把 ptr 当成结构体而非指针,产生错误。-> 运算符解决了这个优先级陷阱——它直接表达「通过指针访问成员」,不需要括号。
-> 的链式使用:
struct line {
point_t start;
point_t end;
};
struct line l = {{0, 0}, {3, 4}};
struct line *lp = &l;
// 链式访问:lp 指向 line,->start 访问 start 成员,.x 访问 start 的 x 成员
float start_x = lp->start.x; // 注意: 最后一个仍然是 . 不是 ->
// 如果是指针链:
struct line *get_line(void); // 返回 line 指针的函数
float x = get_line()->start.x; // -> 用于指针,. 用于值——交替使用TIP
对于本课的 point_t(只有 2 个 float,8 字节),传值和传指针的性能差异可忽略。对于大型结构体(如数百字节到数 KB),传指针是更优选择。const 关键字是免费的文档——它告诉调用者「这个函数不会修改你的数据」。
5. 欧氏距离:勾股定理的计算实现
5.1 从数学公式到 C 代码
欧氏距离(Euclidean distance)是二维平面上两点之间的直线距离。它的数学定义直接来自勾股定理(Pythagorean theorem):
$$ d = \sqrt{(x_1 - x_2)^2 + (y_1 - y_2)^2} $$
几何推导:以两点为对角顶点可以构造一个直角三角形。水平直角边长度为 |x1 - x2|,垂直直角边长度为 |y1 - y2|。根据勾股定理,斜边(即两点间的直线距离)的平方等于两直角边平方之和。
float dx = p1.x - p2.x; // 水平差
float dy = p1.y - p2.y; // 垂直差
return sqrt(dx * dx + dy * dy); // 勾股定理注意:这里没有取绝对值——因为平方运算会消除符号,(-3)² = 3² = 9。
5.2 sqrt 与 math.h:精度选择
#include <math.h>
double sqrt(double x); // 接受 double,返回 double
float sqrtf(float x); // 接受 float,返回 float(C99)
long double sqrtl(long double x); // 接受 long double,返回 long double(C99)sqrt 的三个变体对应三种浮点精度:
float dx = 1.0f, dy = 1.0f;
// 方式 1: 全部 float——最高效,精度足够
float d1 = sqrtf(dx * dx + dy * dy);
// 方式 2: float 提升为 double——隐式转换,精度稍高但稍慢
double d2 = sqrt(dx * dx + dy * dy);
// 方式 3: 显式 double——精度最高
double d3 = sqrt((double)dx * dx + (double)dy * dy);选择建议:
- 对于本课的
point_t(成员是float),用sqrtf最合适——类型一致,无隐式转换 - 如果结构体成员是
double,用sqrt - 科学计算中追求最高精度时,全部用
double和sqrt
5.3 printf 格式说明符:精确控制输出
float f = 5.0f;
double d = 5.0;
printf("%f\n", f); // 5.000000(默认 6 位小数)
printf("%.2f\n", f); // 5.00(保留 2 位小数)
printf("%.0f\n", f); // 5(保留 0 位小数,四舍五入)
printf("%8.2f\n", f); // " 5.00"(总宽度 8,右对齐)
printf("%-8.2f\n", f); // "5.00 "(总宽度 8,左对齐)
printf("%lf\n", d); // double 也可用 %lf(scanf 中 %lf 是必须的)%f vs %lf 在 printf 中的区别:
printf中%f和%lf都用于double(因为float在可变参数中会被提升为double),两者等价scanf中必须严格区分:%f读取float*,%lf读取double*
float f;
double d;
scanf("%f", &f); // 正确: %f 对应 float*
scanf("%lf", &d); // 正确: %lf 对应 double*
// scanf("%f", &d); // 错误: %f 期望 float*,实际传入 double*——未定义行为5.4 常见错误
// 错误 1: 参数类型不匹配——未用结构体,参数散落
float calculate(int x1, int y1, int x2, int y2);
// 错误 2: 对 sqrt 的参数做整数运算导致截断
int dx = 3, dy = 4;
sqrt(dx * dx + dy * dy); // 这本身没问题,但 sqrt(25) 的结果取决于整数→浮点转换
// 错误 3: ^ 不是幂运算!是 XOR!
sqrt((x1-x2)^2 + (y1-y2)^2); // ^ 是按位异或,不是平方!这是一个经典的初学者陷阱
// 错误 4: 忘记 -lm 链接
// gcc point.c -o point ← 链接错误: undefined reference to 'sqrt'
// gcc point.c -o point -lm ← 正确
// 正确写法
float dx = p1.x - p2.x;
float dy = p1.y - p2.y;
return sqrtf(dx * dx + dy * dy);CAUTION
C 语言中 ^ 是按位异或(XOR)运算符,不是幂运算。C 语言没有内置的幂运算符——计算平方用 x * x,计算任意次方用 pow(x, n)(也在 math.h 中)。
6. 结构体包含数组的复制行为:深拷贝 vs 浅拷贝
6.1 结构体赋值复制整个数组
这是一个重要且反直觉的行为:
struct student {
char name[64];
int score;
};
struct student s1 = {"Alice", 90};
struct student s2;
s2 = s1; // 整个结构体被复制,包括 name[64] 的全部 64 个字符
// s2.name 现在是独立的 "Alice" 副本,不是指向 s1.name 的指针验证独立性:
#include <stdio.h>
#include <string.h>
struct student { char name[64]; int score; };
int main(void)
{
struct student s1 = {"Alice", 90};
struct student s2;
s2 = s1; // 整体赋值(深拷贝)
s2.name[0] = 'B'; // 修改副本
s2.score = 100;
printf("s1: name=%s, score=%d\n", s1.name, s1.score); // Alice, 90
printf("s2: name=%s, score=%d\n", s2.name, s2.score); // Blice, 100
// s1 完全不受影响——数组被完整复制了
return 0;
}6.2 深拷贝 vs 浅拷贝
| 特性 | 结构体包含数组 char name[64] | 结构体包含指针 char *name |
|---|---|---|
| 赋值行为 | 深拷贝:复制数组的全部内容 | 浅拷贝:只复制指针地址 |
| 内存布局 | 数据在结构体内部 | 数据在结构体外部(堆或静态区) |
| 独立性 | 副本完全独立 | 副本与原变量共享同一块数据 |
| 修改影响 | 修改副本不影响原值 | 修改副本会影响原值! |
| 内存管理 | 无需额外管理 | 需要注意谁负责释放内存 |
struct student_ptr {
char *name; // 指针!
int score;
};
struct student_ptr s1 = {"Alice", 90}; // s1.name 指向字符串字面量
struct student_ptr s2 = s1; // 浅拷贝——s2.name 指向同一个字符串
// s2.name[0] = 'B'; // 未定义行为!修改字符串字面量
// 即使 name 指向堆内存,修改 s2.name 指向的内容也会影响 s16.3 与普通数组赋值的对比
// 普通数组: 不能直接赋值
char arr1[100] = "hello";
char arr2[100];
// arr2 = arr1; // 编译错误!数组名不能被赋值
// 但结构体包含数组时: 可以赋值
struct wrapper { char arr[100]; };
struct wrapper w1 = {"hello"};
struct wrapper w2;
w2 = w1; // 合法!复制了完整的 100 字节IMPORTANT
C 语言禁止直接赋值数组(arr2 = arr1),但结构体赋值会递归复制所有成员——包括内嵌的数组。这个设计上的「不一致」是很多初学者的困惑点,也是面试中的经典问题。背后的原因是:数组名在表达式中会退化为指针,所以 arr2 = arr1 等价于尝试给一个常量指针赋值。而结构体赋值是编译器生成的 memcpy 操作,不涉及数组名的退化。
6.4 结构体赋值的底层实现
point_t p1 = {100, 200};
point_t p2;
p2 = p1; // 编译器将其翻译为 memcpy(&p2, &p1, sizeof(point_t))对于小结构体(如 point_t),编译器可能直接生成几条 mov 指令而不是调用 memcpy,效率更高。对于大结构体,编译器通常会生成 memcpy 调用。这两种情况程序员不需要关心——编译器的优化器会自动选择最优方案。
7. 结构体的高级用法
7.1 嵌套结构体
结构体可以包含另一个结构体作为成员:
typedef struct {
float x;
float y;
} point_t;
typedef struct {
point_t start; // 结构体嵌套
point_t end;
} line_t;
typedef struct {
point_t center; // 圆心
float radius; // 半径
} circle_t;访问嵌套成员:
line_t line = {{0, 0}, {3, 4}}; // 嵌套初始化
// 或者用指定初始化:
line_t line2 = {.start = {.x = 0, .y = 0}, .end = {.x = 3, .y = 4}};
float start_x = line.start.x; // 多层 . 访问
float dx = line.end.x - line.start.x; // 嵌套成员的运算嵌套结构体的内存布局:嵌套结构体展开后与把内部成员直接放在外层结构体中效果相同(但不完全等价——嵌套结构体有自己独立的对齐边界)。
7.2 结构体数组
#define N 5
point_t points[N]; // 包含 N 个 point_t 的数组
// 初始化结构体数组
point_t triangle[3] = {
{0, 0}, // triangle[0]
{3, 0}, // triangle[1]
{0, 4} // triangle[2]
};
// 访问数组中的结构体成员
for (int i = 0; i < 3; i++) {
printf("point %d: (%.1f, %.1f)\n", i, points[i].x, points[i].y);
}
// 注意优先级: points[i].x = (points[i]).x,先取数组元素,再取成员遍历结构体数组查找最值:
point_t farthest = points[0];
float max_dist = sqrtf(points[0].x * points[0].x + points[0].y * points[0].y);
for (int i = 1; i < N; i++) {
float d = sqrtf(points[i].x * points[i].x + points[i].y * points[i].y);
if (d > max_dist) {
max_dist = d;
farthest = points[i]; // 结构体整体赋值
}
}7.3 函数返回结构体
C 语言允许函数返回结构体(按值返回):
// 返回两个点的中点
point_t midpoint(point_t a, point_t b)
{
point_t result;
result.x = (a.x + b.x) / 2.0f;
result.y = (a.y + b.y) / 2.0f;
return result; // 按值返回——复制到调用者的接收变量中
}
// 调用
point_t mid = midpoint(p1, p2);
// 直接使用返回值
printf("midpoint: (%.1f, %.1f)\n", midpoint(p1, p2).x, midpoint(p1, p2).y);返回结构体的底层实现:调用者在自己的栈帧中预留空间,将隐藏的指针传给被调用函数,被调用函数将返回值写入该空间。编译器可能使用 RVO(Return Value Optimization)消除不必要的复制。
7.4 复合字面量(Compound Literals,C99)
复合字面量允许在表达式中「当场」创建一个匿名的结构体值:
// 不需要先声明变量,直接传字面量
float d = calculate(
(point_t){.x = 0, .y = 0}, // 当场创建第一个点
(point_t){.x = 3, .y = 4} // 当场创建第二个点
);
// 结构体数组的复合字面量
point_t *triangle = (point_t[3]){
{0, 0}, {3, 0}, {0, 4}
};
// 直接传字面量给需要结构体指针的函数
void move(point_t *p, float dx, float dy);
move(&(point_t){100, 200}, 10, 20); // 取复合字面量的地址语法:(类型名){ 初始化列表 }。复合字面量是左值(lvalue),可以取地址。在函数内部创建的复合字面量具有自动存储期(在当前块结束时销毁),在函数外部创建的具有静态存储期。
7.5 匿名结构体与联合体(C11)
C11 允许在结构体中嵌套匿名结构体或联合体,其成员可以直接访问,就像属于外层结构体一样:
struct person {
char name[32];
int age;
union { // 匿名联合体
struct { // 匿名结构体
char street[64];
char city[32];
}; // 没有名字——可以直接访问 street 和 city
char address[96]; // 和上面的匿名结构体共享内存
};
};
struct person p = {"Alice", 30, .street = "Main St", .city = "NYC"};
printf("%s\n", p.street); // 直接访问!不需要 p.addr.street
printf("%s\n", p.city); // 直接访问!NOTE
匿名结构体和联合体是 C11 新增的特性,不是所有编译器的默认模式都支持。使用 -std=c11 编译选项启用。
8. 真实世界案例分析
8.1 Linux 内核中的 file_operations(指定初始化的经典案例)
/* linux/drivers/char/raw.c — Linux 内核真实代码 */
static const struct file_operations raw_fops = {
.read = do_sync_read,
.aio_read = generic_file_aio_read,
.write = do_sync_write,
.aio_write = blkdev_aio_write,
.fsync = blkdev_fsync,
.open = raw_open,
.release = raw_release,
.unlocked_ioctl = raw_ioctl,
.llseek = default_llseek,
.owner = THIS_MODULE,
};这是 Linux 字符设备驱动 /dev/raw/raw.c 中的真实代码。struct file_operations 是一个函数指针表——每个成员是一个函数指针,指向对应的操作实现。
为什么使用指定初始化?
- 成员数量庞大:
file_operations包含数十个函数指针成员,位置初始化根本无法记住顺序 - 只初始化需要的成员:其余自动为 NULL(零),内核根据 NULL 判断操作是否支持
- 自文档化:每个初始化项明确表达了语义——一眼看出这个驱动支持哪些操作
- 可维护性:内核版本升级时增加新成员,已有驱动代码不受影响(新成员自动为 NULL)
两个不同的 fops 实例对比:
// raw_fops: 支持完整的读写操作
static const struct file_operations raw_fops = {
.read = do_sync_read,
.write = do_sync_write,
.open = raw_open,
// ... 更多成员
};
// raw_ctl_fops: 只支持 ioctl 和 open——更精简
static const struct file_operations raw_ctl_fops = {
.unlocked_ioctl = raw_ctl_ioctl,
.open = raw_open,
.owner = THIS_MODULE,
.llseek = noop_llseek,
// 未初始化的成员自动为 NULL——表示不支持该操作
};这就是 「数据结构驱动编程」(回顾 Lesson 10 Rob Pike 格言)的核心体现——定义了清晰的数据结构后,算法的调用关系自然呈现。通过组合不同的函数指针,同一个 struct file_operations 可以描述完全不同的设备行为。
8.2 struct vs C++ class:概念对比
C 语言的 struct 和 C++ 的 class 在概念上同源,但有关键差异:
| 特性 | C struct | C++ struct | C++ class |
|---|---|---|---|
| 数据成员 | ✓ | ✓ | ✓ |
| 成员函数(方法) | ✗(用函数指针模拟) | ✓ | ✓ |
| 访问控制(public/private) | ✗ | ✓(默认 public) | ✓(默认 private) |
| 继承 | ✗ | ✓ | ✓ |
| 构造函数/析构函数 | ✗ | ✓ | ✓ |
| 虚函数(多态) | ✗(手动 vtable) | ✓ | ✓ |
| 运算符重载 | ✗ | ✓ | ✓ |
C 语言如何模拟面向对象:
// C 语言模拟「类」: 结构体 + 函数指针 = 接口
typedef struct {
float x;
float y;
} point_t;
// 「方法」实现为普通函数,第一个参数是「this」指针
void point_move(point_t *self, float dx, float dy) {
self->x += dx;
self->y += dy;
}
float point_distance(const point_t *self, const point_t *other) {
float dx = self->x - other->x;
float dy = self->y - other->y;
return sqrtf(dx * dx + dy * dy);
}
// 使用
point_t p = {100, 200};
point_move(&p, 10, -5); // 相当于 p.move(10, -5)Linux 内核的 vtable 模式:
// 相当于 C++ 中的抽象基类
struct device_ops {
int (*init)(void *dev);
int (*read)(void *dev, char *buf, size_t len);
int (*write)(void *dev, const char *buf, size_t len);
void (*destroy)(void *dev);
};
// 相当于 C++ 中的派生类——实现具体的操作
struct device_ops uart_ops = {
.init = uart_init,
.read = uart_read,
.write = uart_write,
.destroy = uart_destroy,
};
// 统一的调度代码——相当于 C++ 的多态调用
int device_read(void *dev, char *buf, size_t len) {
struct device *d = (struct device *)dev;
if (d->ops && d->ops->read)
return d->ops->read(dev, buf, len); // 虚函数调用
return -1; // 操作不支持
}IMPORTANT
C 语言不需要 C++ 也能实现面向对象的核心思想——封装、多态、接口抽象。Linux 内核正是用这种「结构体 + 函数指针」的模式管理成千上万的设备驱动,证明了数据结构设计先于算法的格言。
9. 设计决策与内存优化
9.1 何时将字段打包为结构体
这是一个重要的设计判断。以下信号表明应该创建结构体:
- 数据总是成组出现:如坐标 (x, y)、颜色 (r, g, b, a)、日期 (年, 月, 日)——缺一个就没有意义
- 数据总是一起传递:同一个函数需要多个相关的参数——考虑用结构体封装
- 需要维护不变量:如矩形的 width 必须 > 0——封装为结构体后可以在赋值时检查
- 需要整体复制或比较:结构体可以直接赋值和按位比较
- 数组的元素是复合数据:如「点的数组」「学生的数组」
反面信号——何时不应该用结构体:
// 反面案例: 强行打包不相关的数据
struct random_stuff {
int age;
float temperature;
char filename[256];
void *ptr;
};
// age 和 temperature 毫无逻辑关联,不应放在同一个结构体中9.2 通过重排字段减少对齐填充
结构体成员的不同排列顺序会影响总大小。这是一个被低估的优化技巧:
#include <stdio.h>
#include <stddef.h>
// 原始顺序(有大量填充)
struct bad_layout {
char a; // 1 byte
// 3 bytes padding(使 b 对齐到 4 字节边界)
int b; // 4 bytes
char c; // 1 byte
// 3 bytes padding(使 d 对齐到 4 字节边界)
int d; // 4 bytes
short e; // 2 bytes
// 2 bytes padding(使结构体总大小为最大对齐值的倍数)
};
// sizeof(struct bad_layout) = 20
// 优化顺序(减少填充)
struct good_layout {
int b; // 4 bytes
int d; // 4 bytes
short e; // 2 bytes
char a; // 1 byte
char c; // 1 byte
// 0 bytes padding(正好 12 字节,是 4 的倍数)
};
// sizeof(struct good_layout) = 12
// 节省了 40% 的内存!重排原则:按对齐要求从大到小排列成员——double(8 字节)→ long long / 指针(8 字节)→ int / float(4 字节)→ short(2 字节)→ char(1 字节)。
printf("bad_layout: %zu bytes\n", sizeof(struct bad_layout)); // 20
printf("good_layout: %zu bytes\n", sizeof(struct good_layout)); // 12
// 验证偏移量
printf("offset of a in bad: %zu\n", offsetof(struct bad_layout, a)); // 0
printf("offset of b in bad: %zu\n", offsetof(struct bad_layout, b)); // 4(跳过了 3 字节填充)
printf("offset of c in bad: %zu\n", offsetof(struct bad_layout, c)); // 8
printf("offset of d in bad: %zu\n", offsetof(struct bad_layout, d)); // 12(跳过了 3 字节填充)TIP
使用 pahole 工具(part of dwarves package)可以可视化结构体的填充情况:pahole your_program。在性能敏感的场景(如嵌入式系统、高频交易、内核开发)中,字段重排可以显著减少缓存失效和提高内存利用率。
9.3 使用 #pragma pack 和 __attribute__((packed))
// 强制紧凑打包——消除所有填充
struct __attribute__((packed)) tight_layout {
char a;
int b;
char c;
};
// sizeof(struct tight_layout) = 6(无填充,但 b 可能未对齐)CAUTION
紧凑打包会导致未对齐的内存访问,在 x86 上只影响性能,但在 ARM、SPARC 等架构上可能导致硬件异常。仅在以下情况使用:
- 定义网络协议或文件格式的二进制布局
- 内存极其受限的嵌入式系统
- 与硬件寄存器映射时
普通应用代码不应使用紧凑打包——让编译器处理对齐是最安全的选择。
参考解答
求两点欧氏距离(struct + typedef + sqrt)
#include <stdio.h>
#include <math.h>
typedef struct {
float x;
float y;
} point_t;
float calculate(point_t p1, point_t p2)
{
float dx = p1.x - p2.x;
float dy = p1.y - p2.y;
return sqrt(dx * dx + dy * dy);
}
int main(void)
{
point_t p1, p2;
float distance;
scanf("%f %f %f %f", &p1.x, &p1.y, &p2.x, &p2.y);
distance = calculate(p1, p2);
printf("%.2f\n", distance);
return 0;
}核心逻辑:typedef struct { float x; float y; } point_t 定义坐标点类型。calculate 用欧氏距离公式 sqrt(dx² + dy²)。scanf 的 %f 格式符读取 float 成员地址。%.2f 控制输出两位小数。
使用结构体指定初始化 + sqrtf
#include <stdio.h>
#include <math.h>
typedef struct {
float x;
float y;
} point_t;
float calculate(point_t p1, point_t p2)
{
float dx = p1.x - p2.x;
float dy = p1.y - p2.y;
return sqrtf(dx * dx + dy * dy); // sqrtf 是 sqrt 的 float 版本
}
int main(void)
{
point_t p1 = {.x = 0, .y = 0}; // C99 指定初始化
point_t p2 = {.x = 3, .y = 4};
printf("%.2f\n", calculate(p1, p2));
return 0;
}用 C99 指定初始化 .x = ... 替代位置初始化 {0, 0}——语义更清晰。sqrtf 是 sqrt 的 float 版本(sqrt 处理 double),对于只用 float 的场景更精确且避免隐式类型提升。
传 const 指针版本(性能优化)
#include <stdio.h>
#include <math.h>
typedef struct {
float x;
float y;
} point_t;
// 传 const 指针——避免复制,const 保证只读
float calculate(const point_t *p1, const point_t *p2)
{
float dx = p1->x - p2->x; // -> 运算符访问指针成员
float dy = p1->y - p2->y;
return sqrtf(dx * dx + dy * dy);
}
int main(void)
{
point_t p1, p2;
scanf("%f %f %f %f", &p1.x, &p1.y, &p2.x, &p2.y);
printf("%.2f\n", calculate(&p1, &p2)); // 传地址
return 0;
}对于只有 8 字节的 point_t,传值和传指针性能差异可忽略。但这种写法是正确的优化实践——当结构体增大时不需要修改函数签名。
sizeof 对齐分析演示
#include <stdio.h>
#include <stddef.h>
typedef struct {
float x;
float y;
} point_t;
typedef struct {
char flag; // 1 byte
point_t center; // 8 bytes
float radius; // 4 bytes
} circle_t;
typedef struct {
char a; // 1 byte
double b; // 8 bytes
int c; // 4 bytes
short d; // 2 bytes
} mixed_t;
int main(void)
{
printf("sizeof(point_t) = %zu\n", sizeof(point_t)); // 8
printf("sizeof(circle_t) = %zu\n", sizeof(circle_t)); // 16 (含填充)
printf("sizeof(mixed_t) = %zu\n", sizeof(mixed_t)); // 24 (含大量填充)
printf("\n--- mixed_t 成员偏移量 ---\n");
printf("offsetof(a) = %zu\n", offsetof(mixed_t, a)); // 0
printf("offsetof(b) = %zu\n", offsetof(mixed_t, b)); // 8 (跳过了 7 字节填充)
printf("offsetof(c) = %zu\n", offsetof(mixed_t, c)); // 16
printf("offsetof(d) = %zu\n", offsetof(mixed_t, d)); // 20
return 0;
}编译运行:gcc -o demo sizeof_alignment_demo.c && ./demo。观察每个结构体的实际大小与预期大小(成员之和)的差异,理解对齐填充的作用。
对照检查:
struct定义后面有没有分号?typedef写法正确吗?scanf四个参数都写了&吗?编译时加了-lm吗?输出格式用了%.2f吗?
课堂讨论
- 在
calculate调用时传入的p1,和在函数实现中出现的p1是不是同一个p1?(按值传递的含义) - 如果结构体包含一个字符数组
char name[64],那么结构体的赋值s2 = s1是否会复制字符串?这和包含char *name指针有什么区别? - 结构体初始化有哪些方式?指定初始化
.field = value比起位置初始化有什么优势? point_t的成员类型为什么用float而不是int?如果坐标是整数且距离也需要整数,如何处理?- 看
struct file_operations的例子——为什么 Linux 内核用结构体来组织函数指针?这和面向对象编程有什么关系? - 结构体成员的不同排列顺序会影响
sizeof的结果吗?如何排列能最小化内存占用?
讨论答案
Q1: 传入的 p1 和函数内部的 p1 是不是同一个?
不是同一个变量,但初始值相同。 C 语言的参数传递是按值传递——实参的值被完整复制到了形参:
point_t a = {100, 200};
calculate(a); // a 的值被复制给函数内的 p1
float calculate(point_t p1, point_t p2)
{
// p1 是 a 的副本——有相同的内容,但是不同的内存位置
p1.x = 999; // 修改副本,不影响外部的 a
}这和在 Lesson 04 中讨论的 printf 传值机制完全一致——区别只在于这次复制的是一个完整的结构体(8 字节),而不只是一个 int(4 字节)。但语义完全相同:函数内部修改的是副本,原变量不受影响。
深层原因:C 语言的设计哲学是「简单、可预测」——所有参数传递都遵循同一种规则(按值传递),不给基本类型和复合类型开不同的「后门」。如果需要修改原变量,程序员必须显式传递指针——这个显式性本身就是一种文档。
Q2: 结构体赋值会复制内嵌的字符数组吗?和指针有什么区别?
会。 结构体赋值是整体复制——递归地复制所有成员包括数组。这是深拷贝(deep copy):
struct student {
char name[64];
int score;
};
struct student s1 = {"Alice", 90};
struct student s2;
s2 = s1; // s2.name 现在是独立的 "Alice" 副本——完整的 64 字节被复制
s2.name[0] = 'B';
printf("%s\n", s1.name); // 输出 "Alice"(不受影响)
printf("%s\n", s2.name); // 输出 "Blice"与指针的对比——浅拷贝(shallow copy):
struct student2 {
char *name; // 指针!不是数组
int score;
};
struct student2 s1 = {"Alice", 90}; // name 指向字符串字面量
struct student2 s2 = s1; // 只复制了指针地址
// s2.name[0] = 'B'; // 危险!修改字符串字面量是未定义行为
// 即使 name 指向堆内存,修改 s2.name 指向的内容也会影响 s1为什么 C 语言这样设计? 结构体赋值被定义为「逐字节复制」(memcpy 语义)。数组在结构体内部是「内联」存储的(数据在结构体的内存区域内),所以逐字节复制自然就复制了数组内容。而指针存储的是一个地址——逐字节复制只复制了地址,不复制地址指向的数据。
Q3: 结构体初始化的方式和指定初始化的优势
三种初始化方式:
// 方式 1: 位置初始化(C89)
point_t p1 = {100, 200};
// 方式 2: 指定初始化(C99)
point_t p2 = {.x = 100, .y = 200};
// 方式 3: GNU 扩展(已废弃,不应使用)
point_t p3 = {x : 100, y : 200};指定初始化的优势:
- 顺序无关——
{.y = 200, .x = 100}与{.x = 100, .y = 200}完全等价 - 可部分初始化——
{.x = 100}会让.y自动为 0,不需要写{100, 0} - 自文档化——每个值都有标签,一眼看懂含义,不需要查结构体定义
- 可维护性——结构体增加新成员时,已有初始化不受影响(新成员自动为 0)
- 类型安全——如果成员类型改变,指定初始化不需要调整
- 与大型项目兼容——Linux 内核、FreeBSD 等大型 C 项目全部使用指定初始化
实际场景:当结构体有 20 个成员而你只需要初始化其中 3 个时,位置初始化需要写 17 个 0——指定初始化只需要写 3 个。这是代码质量的巨大差异。
Q4: 为什么用 float 而不是 int?如果坐标是整数怎么处理?
用 float 的原因:
- 距离公式中有平方根
sqrt——结果绝大多数情况下是浮点数 - 即使坐标是整数,
sqrt(3*3 + 4*4) = 5.0结果也是浮点数 - 坐标本身可能不是整数(如 (1.5, 2.3)),
float比int更通用
如果坐标始终是整数:
- 距离可能是无理数(如
sqrt(1² + 1²) ≈ 1.414...),无法用int精确表示 - 如果只需要整数近似,可以用强制类型转换:
int dx = p1.x - p2.x;
int dy = p1.y - p2.y;
int distance_int = (int)(sqrt(dx * dx + dy * dy) + 0.5); // 四舍五入但即使坐标是 int,距离仍建议用 float 或 double 计算后再根据需要转换——避免中间过程截断精度。
精度的进一步讨论:float(32 位)提供约 7 位有效数字,double(64 位)提供约 15 位。对于坐标范围在数千以内、距离精度要求在小数点后两位的应用场景,float 完全够用。对于 GPS 坐标、科学计算等场景,应使用 double。
Q5: file_operations 和面向对象编程的关联
struct file_operations 是 C 语言模拟面向对象编程的经典模式——vtable(虚函数表)模式:
struct file_operations {
int (*open)(struct inode *, struct file *); // 相当于「虚方法」
ssize_t (*read)(struct file *, char *, size_t, loff_t *);
int (*release)(struct inode *, struct file *);
// ...
};对应关系:
| C (struct + 函数指针) | OOP (class/interface) |
|---|---|
struct file_operations | 接口 / 抽象基类 |
函数指针成员 (.open, .read) | 虚方法 / 纯虚函数 |
raw_fops.open = do_sync_read | 方法实现 / override |
| 未初始化的指针为 NULL | 未实现的方法(返回 -ENOSYS) |
多个 file_operations 实例 | 多个派生类 |
Linux 内核的设计智慧:
- 零开销抽象:函数指针调用只有一次间接跳转,没有虚函数表的多次查找
- 显式而非隐式:所有「方法」都是可见的函数指针,没有隐藏的 this 指针调整
- 编译时检查:初始化不存在的成员会编译失败(指定初始化器的优势)
- 运行时灵活性:可以动态替换函数指针实现热升级
这证明了数据结构设计先于算法(Rob Pike 格言)——正确设计了 struct file_operations,所有驱动的接入方式就自然统一了。
Q6: 成员排列顺序如何影响 sizeof?如何优化?
是的,排列顺序显著影响 sizeof 的结果。
// 顺序 A: 差——大量填充
struct A {
char c1; // 1 byte + 7 bytes padding
double d; // 8 bytes
char c2; // 1 byte + 3 bytes padding
int i; // 4 bytes
};
// sizeof(A) = 24
// 顺序 B: 好——最小化填充
struct B {
double d; // 8 bytes
int i; // 4 bytes
char c1; // 1 byte
char c2; // 1 byte + 2 bytes padding (stride)
};
// sizeof(B) = 16(节省了 33% 的内存)优化原则:按对齐要求从大到小排列——
double/long long/ 指针(8 字节对齐)int/float/long(4 字节对齐)short(2 字节对齐)char/_Bool(1 字节对齐)
为什么默认不自动优化? 编译器不能随意重排成员——C 标准保证结构体成员的地址按声明顺序递增,且第一个成员的地址等于结构体本身的地址。这些保证被某些代码依赖(如将结构体指针转换为第一个成员的指针)。因此,优化排列顺序是程序员的责任。
课后练习
最远点查找:给定一个
point_t结构体数组(如 10 个点),找出其中离原点(0, 0)距离最远的那个点并输出其坐标和距离。知识点提示:结构体数组
point_t arr[10]、遍历比较法、最大值更新模式最远两点距离:在上一题的数组中,找出所有点对中距离最远的两个点。
知识点提示:嵌套循环遍历所有点对
(i, j) where i < j、组合数 C(n,2)学生成绩管理:定义
student结构体(学号、姓名、三门课成绩),输入 5 个学生的数据,计算每位学生的总分和平均分,支持通过学号查询。知识点提示:结构体嵌套(学生包含课程成绩)、
strcmp字符串比较、strcpy字符串复制矩形与点:定义
rect_t结构体(包含左上角坐标point_t和宽高float),实现函数判断一个点是否在矩形内部。知识点提示:嵌套结构体、边界条件判断(点在边上算不算内部?)
结构体对齐实验:定义包含
char、int、short、double的结构体,分别用不同顺序排列成员,用sizeof和offsetof验证内存布局,总结对齐规律。知识点提示:
sizeof、offsetof(<stddef.h>)、__attribute__((packed))、#pragma pack
练习1: 查找离原点最远的点
#include <stdio.h>
#include <math.h>
#define N 10
typedef struct {
float x;
float y;
} point_t;
float distance_from_origin(point_t p)
{
return sqrtf(p.x * p.x + p.y * p.y);
}
int main(void)
{
point_t pts[N];
int i, max_idx = 0;
float max_dist = 0.0f;
for (i = 0; i < N; i++)
scanf("%f %f", &pts[i].x, &pts[i].y);
for (i = 0; i < N; i++)
{
float d = distance_from_origin(pts[i]);
if (d > max_dist)
{
max_dist = d;
max_idx = i;
}
}
printf("farthest point: (%.1f, %.1f), dist = %.2f\n",
pts[max_idx].x, pts[max_idx].y, max_dist);
return 0;
}结构体数组 pts[N] 每个元素是一个 point_t。pts[i].x 访问第 i 个点的 x 坐标。最大值更新模式:维护当前最大值,遇到更大的值就更新。sqrtf 直接计算到原点的距离(原点坐标是 (0,0),不需要减法)。
练习2: 寻找最远两点距离
#include <stdio.h>
#include <math.h>
#define N 10
typedef struct { float x, y; } point_t;
float calc_dist(point_t a, point_t b)
{
float dx = a.x - b.x;
float dy = a.y - b.y;
return sqrtf(dx * dx + dy * dy);
}
int main(void)
{
point_t pts[N];
int i, j, max_i = 0, max_j = 1;
float max_dist = 0.0f;
for (i = 0; i < N; i++)
scanf("%f %f", &pts[i].x, &pts[i].y);
for (i = 0; i < N; i++)
{
for (j = i + 1; j < N; j++) // j > i 避免重复计算和自我配对
{
float d = calc_dist(pts[i], pts[j]);
if (d > max_dist)
{
max_dist = d;
max_i = i;
max_j = j;
}
}
}
printf("farthest pair: %d and %d, dist = %.2f\n",
max_i, max_j, max_dist);
return 0;
}嵌套循环遍历所有点对 (i, j),j = i + 1 确保每对只计算一次(组合数 C(N,2) = N×(N-1)/2 次距离计算)。这是一个 O(N²) 的暴力算法——对于 N=10 来说完全可接受。
练习3: 学生成绩管理
#include <stdio.h>
#include <string.h>
#define N 5
typedef struct {
int id;
char name[32];
float scores[3];
float total;
float average;
} student_t;
int main(void)
{
student_t stus[N];
int i, j;
int query_id;
for (i = 0; i < N; i++)
{
scanf("%d %s %f %f %f", &stus[i].id, stus[i].name,
&stus[i].scores[0], &stus[i].scores[1], &stus[i].scores[2]);
stus[i].total = 0.0f;
for (j = 0; j < 3; j++)
stus[i].total += stus[i].scores[j];
stus[i].average = stus[i].total / 3.0f;
}
scanf("%d", &query_id);
for (i = 0; i < N; i++)
{
if (stus[i].id == query_id)
{
printf("%s: total=%.1f, avg=%.1f\n",
stus[i].name, stus[i].total, stus[i].average);
break;
}
}
return 0;
}student_t 结构体嵌套了成绩数组 scores[3]。先计算所有学生的总分平均分,再按学号查找。结构体赋值 stus[i] = ... 会自动复制 name[32] 和 scores[3] 数组——这是结构体包含数组的深拷贝特性的实际应用。
练习4: 矩形与点
#include <stdio.h>
#include <stdbool.h>
typedef struct {
float x;
float y;
} point_t;
typedef struct {
point_t top_left; // 嵌套结构体
float width;
float height;
} rect_t;
bool point_in_rect(point_t p, rect_t r)
{
return p.x >= r.top_left.x
&& p.x <= r.top_left.x + r.width
&& p.y >= r.top_left.y
&& p.y <= r.top_left.y + r.height;
}
int main(void)
{
rect_t r = {{10, 10}, 100, 50}; // 嵌套初始化
point_t p;
printf("rect: top-left=(%.0f,%.0f), w=%.0f, h=%.0f\n",
r.top_left.x, r.top_left.y, r.width, r.height);
printf("enter point x y: ");
scanf("%f %f", &p.x, &p.y);
if (point_in_rect(p, r))
printf("point (%.1f, %.1f) is INSIDE the rect\n", p.x, p.y);
else
printf("point (%.1f, %.1f) is OUTSIDE the rect\n", p.x, p.y);
return 0;
}rect_t 嵌套了 point_t,展示了结构体层层组合的能力。point_in_rect 使用 >= 和 <= 意味着点在矩形的边上也算内部——这是几何库的常见约定。<stdbool.h> 提供了 bool、true、false 类型(C99)。
练习5: 结构体对齐实验
#include <stdio.h>
#include <stddef.h>
// 实验 1: 差的对齐
struct layout_bad {
char c1;
double d;
int i;
char c2;
};
// 实验 2: 好的对齐
struct layout_good {
double d;
int i;
char c1;
char c2;
};
// 实验 3: 紧凑打包
struct __attribute__((packed)) layout_packed {
char c1;
double d;
int i;
char c2;
};
int main(void)
{
printf("=== sizeof 对比 ===\n");
printf("layout_bad: %zu bytes\n", sizeof(struct layout_bad));
printf("layout_good: %zu bytes\n", sizeof(struct layout_good));
printf("layout_packed: %zu bytes\n", sizeof(struct layout_packed));
printf("\n=== layout_bad 成员偏移量 ===\n");
printf("c1: %zu\n", offsetof(struct layout_bad, c1));
printf("d: %zu\n", offsetof(struct layout_bad, d));
printf("i: %zu\n", offsetof(struct layout_bad, i));
printf("c2: %zu\n", offsetof(struct layout_bad, c2));
printf("\n=== layout_good 成员偏移量 ===\n");
printf("d: %zu\n", offsetof(struct layout_good, d));
printf("i: %zu\n", offsetof(struct layout_good, i));
printf("c1: %zu\n", offsetof(struct layout_good, c1));
printf("c2: %zu\n", offsetof(struct layout_good, c2));
return 0;
}编译运行:gcc -o align alignment_experiment.c && ./align。观察三个版本的 sizeof 差异,分析每个成员的偏移量,理解填充字节的位置和数量。可以尝试更多类型组合(如 short、long long、指针)来总结完整的对齐规律。
参考资料
- ISO C99 Standard, Section 6.7.2.1 — 结构体成员的声明与对齐规则
- The C Programming Language, Section 6.1-6.2 — Kernighan & Ritchie 对结构体的基础讲解
- Linux Kernel: file_operations — Linux 内核
file_operations结构体的完整定义 - C99 Designated Initializers — GCC 对指定初始化器特性的支持说明
- The Lost Art of Structure Packing — Eric S. Raymond 对结构体对齐与填充的深入讲解
"Show me your flowchart and conceal your tables, and I shall continue to be mystified. Show me your tables, and I won't usually need your flowchart." — Fred Brooks