跳转到内容

Lesson 11: 求两点距离 CNB

练习任务

编写一个 C 程序,定义表示二维坐标点的结构体 point_t(包含 float xfloat 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)²) 即勾股定理的二维形式
  • sqrtmath.h — 回顾 Lesson 07 数学库,需要 -lm 链接,sqrtf 处理 float
  • printf 格式说明符 — %.2f 精度控制、%f vs %lf、字段宽度与对齐
  • 结构体包含数组 — 结构体赋值会完整复制数组成员(深拷贝),区别于指针的浅拷贝
  • 嵌套结构体 — 结构体包含结构体成员、结构体数组
  • 函数返回结构体 — 按值返回,编译器可能使用 RVO(返回值优化)
  • 复合字面量 — C99 Compound Literals 的语法和用途
  • 匿名结构体与联合体 — C11 匿名成员的特性
  • struct vs C++ class — C 语言如何用函数指针模拟面向对象
  • 设计决策 — 何时将字段打包为结构体,何时保持分离
  • 内存优化 — 通过重排字段减少对齐填充

代码框架

exercise.c
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)来完整描述。用单独的变量也能处理:

separate_vars.c
c
float x1, y1, x2, y2;   // 各行其是,但缺乏逻辑关联

但当有多个点时,这种分离写法迅速失控:

too_many_vars.c
c
float x1, y1, x2, y2, x3, y3, x4, y4;  // 完全扁平,看不出哪些配对

每增加一个点,就需要两个新变量——代码的复杂度呈线性增长,而可读性呈指数下降。struct 将相关的变量打包成一个有名字的复合类型:

struct_point_def.c
c
struct point {
    float x;
    float y;
};
// 现在 struct point 是一个「拥有 x 和 y 两个成员」的类型

类型的语义:不是说 struct point 是「一种东西」——它是一种新的类型,就和 intfloat 一样可以声明变量、作为函数参数。这是 C 语言赋予程序员的类型扩展能力——你不再局限于语言内置的那几个基本类型,可以创造适合自己问题域的类型。

结构体的核心价值在于语义聚合:当多个数据总是「一起出现、一起传递、一起有效」时,把它们封装成一个结构体,代码的意图就从分散的变量名变成了一个有名字的抽象。

1.2 struct 的定义语法

struct_syntax.c
c
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 声明变量与成员访问(. 运算符)

member_access.c
c
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 的地址再取成员」。

可以同时声明和初始化:

declare_and_init.c
c
struct point p1 = {100.0f, 200.0f};   // 声明 + 位置初始化
struct point p2 = {.x = 200.0f, .y = 100.0f};  // 声明 + 指定初始化(C99)

1.4 结构体的内存布局与对齐

理解结构体在内存中的布局对于编写正确和高效的代码至关重要。

sizeof_simple.c
c
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):

alignment_demo.c
c
#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的倍数)

对齐规则(简化版):

  1. 每个成员的起始地址必须是其类型大小的整数倍(char=1,short=2,int=4,double=8,指针=8)
  2. 结构体的总大小必须是其最大成员对齐值的整数倍(称为 stride)
  3. 成员之间可能插入填充字节以满足规则 1
  4. 结构体末尾可能插入填充字节以满足规则 2

TIP

sizeof 运算符返回的值包含了所有填充字节。用 offsetof(struct mixed, i)(定义在 <stddef.h> 中)可以精确获取每个成员在结构体中的偏移量。

对齐对性能的影响:未对齐的内存访问在 x86 上可能只需要额外几个 CPU 周期,但在某些架构(如 ARM、SPARC)上会直接导致总线错误(bus error)或异常。编译器自动插入填充是为了保证代码在所有平台上的正确性。


2. typedef:给类型起别名

2.1 问题:struct point 写起来太啰嗦

每次声明变量都要写完整 struct point

verbose_struct.c
c
struct point p1, p2, p3;          // 啰嗦
struct point calc_midpoint(struct point a, struct point b);  // 更啰嗦

对于频繁使用的类型,struct 前缀成为视觉噪音。typedef 解决了这个问题。

2.2 typedef 的语法与语义

typedef_basic.c
c
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 后缀命名约定

c
typedef struct point point_t;   // _t 后缀表示「这是一个类型别名」

_t 后缀是 Unix/POSIX 社区的广泛惯例(如 size_ttime_tpid_t)。但有一个重要的注意点:

IMPORTANT

POSIX 标准保留所有以 _t 结尾的标识符给系统使用。在严格遵循 POSIX 的代码中,自定义类型不应使用 _t 后缀以避免未来与标准库冲突。但在教学、竞赛和大多数实际项目中,_t 后缀已约定俗成,广泛使用。

替代命名风格(如果不想用 _t):

c
typedef struct point Point;       // 首字母大写
typedef struct point point_type;  // 完整 _type 后缀

2.4 合并写法与两种风格对比

combined_typedef.c
c
// 一步到位: 定义结构体 + 起别名
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 名 + 别名 = 两个名字,略冗余简洁,一个名字
self_referencing.c
c
// 风格 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)

positional_init.c
c
point_t p = {100, 200};
//            x=100  y=200(按成员声明顺序依次赋值)

规则:初始化列表中的值按成员声明顺序依次赋值。这是 C89 就有的经典方式,也是 C 语言诞生之初就支持的初始化方法。

缺点

  • 成员多时容易搞错顺序
  • 可读性差——{100, 200} 中哪个是 x 哪个是 y 需要查结构体定义
  • 增加新成员到结构体中间时,所有初始化代码都需要调整顺序

3.2 方式 2:指定初始化(Designated Initializer,C99)

designated_init.c
c
point_t p = {.x = 100, .y = 200};   // 按名称初始化,顺序无关
point_t q = {.y = 200, .x = 100};   // 顺序无所谓,效果完全相同
point_t r = {.x = 100};             // 只初始化 x,y 自动为 0.0f

优势

  1. 自文档化——一眼看出哪个值对应哪个成员,不需要查结构体定义
  2. 顺序无关——不需要记忆成员声明顺序,编译器按名称匹配
  3. 可部分初始化——未指定的成员被隐式初始化为 0(算术类型为 0,指针为 NULL)
  4. 可维护性——结构体增加新成员时,已有的指定初始化代码不受影响(新成员自动为 0)
  5. 健壮性——如果成员类型改变(如 float 改成 double),指定初始化不受影响

3.3 方式 3:传统 GNU 语法(Legacy)

gnu_legacy_init.c
c
point_t p = {x : 100, y : 200};    // GCC 旧扩展语法

这是 GCC 在 C99 标准化之前提供的扩展语法,用 : 代替 .=。现代 C 代码中应避免使用——它不在任何 ISO C 标准中,且 GCC 的 -pedantic 选项会对此发出警告。这里提及仅为了在阅读老代码时能够识别。

3.4 局部初始化与零初始化

partial_zero_init.c
c
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 都是垃圾值

零初始化的更多方式

zero_init_methods.c
c
// 方式 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 整体复制的机制

pass_by_value.c
c
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 各被复制一份传入函数

这和 intfloat 的传参机制完全一致——C 语言所有参数都是按值传递的(pass by value)。区别只在于结构体可能包含多个值,复制的工作量更大。

复制发生在哪里? 在函数调用的栈帧(stack frame)中:调用者的栈上存着实参 ab,被调用者的栈上存着它们的完整副本 p1p2。对于 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 保护),推荐
pass_ptr_vs_value.c
c
// 传值: 函数内修改不影响原变量
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 -> 运算符:通过指针访问成员

arrow_operator.c
c
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 当成结构体而非指针,产生错误。-> 运算符解决了这个优先级陷阱——它直接表达「通过指针访问成员」,不需要括号。

-> 的链式使用

arrow_chaining.c
c
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|。根据勾股定理,斜边(即两点间的直线距离)的平方等于两直角边平方之和。

distance_formula.c
c
float dx = p1.x - p2.x;    // 水平差
float dy = p1.y - p2.y;    // 垂直差
return sqrt(dx * dx + dy * dy);  // 勾股定理

注意:这里没有取绝对值——因为平方运算会消除符号,(-3)² = 3² = 9

5.2 sqrtmath.h:精度选择

sqrt_variants.c
c
#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 的三个变体对应三种浮点精度:

precision_comparison.c
c
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
  • 科学计算中追求最高精度时,全部用 doublesqrt

5.3 printf 格式说明符:精确控制输出

printf_formats.c
c
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 %lfprintf 中的区别

  • printf%f%lf 都用于 double(因为 float 在可变参数中会被提升为 double),两者等价
  • scanf 中必须严格区分:%f 读取 float*%lf 读取 double*
scanf_format.c
c
float  f;
double d;

scanf("%f",  &f);   // 正确: %f 对应 float*
scanf("%lf", &d);   // 正确: %lf 对应 double*
// scanf("%f", &d); // 错误: %f 期望 float*,实际传入 double*——未定义行为

5.4 常见错误

common_mistakes.c
c
// 错误 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_with_array.c
c
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 的指针

验证独立性

deep_copy_verify.c
c
#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
赋值行为深拷贝:复制数组的全部内容浅拷贝:只复制指针地址
内存布局数据在结构体内部数据在结构体外部(堆或静态区)
独立性副本完全独立副本与原变量共享同一块数据
修改影响修改副本不影响原值修改副本会影响原值!
内存管理无需额外管理需要注意谁负责释放内存
shallow_copy_pitfall.c
c
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 指向的内容也会影响 s1

6.3 与普通数组赋值的对比

array_vs_struct_assign.c
c
// 普通数组: 不能直接赋值
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 结构体赋值的底层实现

struct_assign_memcpy.c
c
point_t p1 = {100, 200};
point_t p2;

p2 = p1;   // 编译器将其翻译为 memcpy(&p2, &p1, sizeof(point_t))

对于小结构体(如 point_t),编译器可能直接生成几条 mov 指令而不是调用 memcpy,效率更高。对于大结构体,编译器通常会生成 memcpy 调用。这两种情况程序员不需要关心——编译器的优化器会自动选择最优方案。


7. 结构体的高级用法

7.1 嵌套结构体

结构体可以包含另一个结构体作为成员:

nested_struct.c
c
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;

访问嵌套成员

nested_access.c
c
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 结构体数组

struct_array.c
c
#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,先取数组元素,再取成员

遍历结构体数组查找最值

find_farthest.c
c
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 语言允许函数返回结构体(按值返回):

return_struct.c
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)

复合字面量允许在表达式中「当场」创建一个匿名的结构体值:

compound_literal.c
c
// 不需要先声明变量,直接传字面量
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 允许在结构体中嵌套匿名结构体或联合体,其成员可以直接访问,就像属于外层结构体一样:

anonymous_struct.c
c
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_file_ops.c
c
/* 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 是一个函数指针表——每个成员是一个函数指针,指向对应的操作实现。

为什么使用指定初始化?

  1. 成员数量庞大file_operations 包含数十个函数指针成员,位置初始化根本无法记住顺序
  2. 只初始化需要的成员:其余自动为 NULL(零),内核根据 NULL 判断操作是否支持
  3. 自文档化:每个初始化项明确表达了语义——一眼看出这个驱动支持哪些操作
  4. 可维护性:内核版本升级时增加新成员,已有驱动代码不受影响(新成员自动为 NULL)

两个不同的 fops 实例对比

linux_two_fops.c
c
// 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 structC++ structC++ class
数据成员
成员函数(方法)✗(用函数指针模拟)
访问控制(public/private)✓(默认 public)✓(默认 private)
继承
构造函数/析构函数
虚函数(多态)✗(手动 vtable)
运算符重载

C 语言如何模拟面向对象

c_oop_pattern.c
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 模式

vtable_pattern.c
c
// 相当于 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 何时将字段打包为结构体

这是一个重要的设计判断。以下信号表明应该创建结构体:

  1. 数据总是成组出现:如坐标 (x, y)、颜色 (r, g, b, a)、日期 (年, 月, 日)——缺一个就没有意义
  2. 数据总是一起传递:同一个函数需要多个相关的参数——考虑用结构体封装
  3. 需要维护不变量:如矩形的 width 必须 > 0——封装为结构体后可以在赋值时检查
  4. 需要整体复制或比较:结构体可以直接赋值和按位比较
  5. 数组的元素是复合数据:如「点的数组」「学生的数组」

反面信号——何时不应该用结构体

when_not_to_struct.c
c
// 反面案例: 强行打包不相关的数据
struct random_stuff {
    int age;
    float temperature;
    char filename[256];
    void *ptr;
};
// age 和 temperature 毫无逻辑关联,不应放在同一个结构体中

9.2 通过重排字段减少对齐填充

结构体成员的不同排列顺序会影响总大小。这是一个被低估的优化技巧:

reorder_demo.c
c
#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 字节)。

layout_comparison.c
c
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))

packed_struct.c
c
// 强制紧凑打包——消除所有填充
struct __attribute__((packed)) tight_layout {
    char  a;
    int   b;
    char  c;
};
// sizeof(struct tight_layout) = 6(无填充,但 b 可能未对齐)

CAUTION

紧凑打包会导致未对齐的内存访问,在 x86 上只影响性能,但在 ARM、SPARC 等架构上可能导致硬件异常。仅在以下情况使用:

  • 定义网络协议或文件格式的二进制布局
  • 内存极其受限的嵌入式系统
  • 与硬件寄存器映射时

普通应用代码不应使用紧凑打包——让编译器处理对齐是最安全的选择。


参考解答

求两点欧氏距离(struct + typedef + sqrt)
point_distance.c
c
#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
point_distance_designated.c
c
#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}——语义更清晰。sqrtfsqrtfloat 版本(sqrt 处理 double),对于只用 float 的场景更精确且避免隐式类型提升。

传 const 指针版本(性能优化)
point_distance_const_ptr.c
c
#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 对齐分析演示
sizeof_alignment_demo.c
c
#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 吗?


课堂讨论

  1. calculate 调用时传入的 p1,和在函数实现中出现的 p1 是不是同一个 p1?(按值传递的含义)
  2. 如果结构体包含一个字符数组 char name[64],那么结构体的赋值 s2 = s1 是否会复制字符串?这和包含 char *name 指针有什么区别?
  3. 结构体初始化有哪些方式?指定初始化 .field = value 比起位置初始化有什么优势?
  4. point_t 的成员类型为什么用 float 而不是 int?如果坐标是整数且距离也需要整数,如何处理?
  5. struct file_operations 的例子——为什么 Linux 内核用结构体来组织函数指针?这和面向对象编程有什么关系?
  6. 结构体成员的不同排列顺序会影响 sizeof 的结果吗?如何排列能最小化内存占用?

讨论答案

Q1: 传入的 p1 和函数内部的 p1 是不是同一个?

不是同一个变量,但初始值相同。 C 语言的参数传递是按值传递——实参的值被完整复制到了形参:

pass_by_value_demo.c
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):

deep_copy_array.c
c
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):

shallow_copy_ptr.c
c
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: 结构体初始化的方式和指定初始化的优势

三种初始化方式

init_comparison.c
c
// 方式 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};

指定初始化的优势

  1. 顺序无关——{.y = 200, .x = 100}{.x = 100, .y = 200} 完全等价
  2. 可部分初始化——{.x = 100} 会让 .y 自动为 0,不需要写 {100, 0}
  3. 自文档化——每个值都有标签,一眼看懂含义,不需要查结构体定义
  4. 可维护性——结构体增加新成员时,已有初始化不受影响(新成员自动为 0)
  5. 类型安全——如果成员类型改变,指定初始化不需要调整
  6. 与大型项目兼容——Linux 内核、FreeBSD 等大型 C 项目全部使用指定初始化

实际场景:当结构体有 20 个成员而你只需要初始化其中 3 个时,位置初始化需要写 17 个 0——指定初始化只需要写 3 个。这是代码质量的巨大差异。

Q4: 为什么用 float 而不是 int?如果坐标是整数怎么处理?

float 的原因

  • 距离公式中有平方根 sqrt——结果绝大多数情况下是浮点数
  • 即使坐标是整数,sqrt(3*3 + 4*4) = 5.0 结果也是浮点数
  • 坐标本身可能不是整数(如 (1.5, 2.3)),floatint 更通用

如果坐标始终是整数

  • 距离可能是无理数(如 sqrt(1² + 1²) ≈ 1.414...),无法用 int 精确表示
  • 如果只需要整数近似,可以用强制类型转换:
int_distance.c
c
int dx = p1.x - p2.x;
int dy = p1.y - p2.y;
int distance_int = (int)(sqrt(dx * dx + dy * dy) + 0.5);  // 四舍五入

但即使坐标是 int,距离仍建议用 floatdouble 计算后再根据需要转换——避免中间过程截断精度。

精度的进一步讨论float(32 位)提供约 7 位有效数字,double(64 位)提供约 15 位。对于坐标范围在数千以内、距离精度要求在小数点后两位的应用场景,float 完全够用。对于 GPS 坐标、科学计算等场景,应使用 double

Q5: file_operations 和面向对象编程的关联

struct file_operations 是 C 语言模拟面向对象编程的经典模式——vtable(虚函数表)模式:

vtable_oop.c
c
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 内核的设计智慧

  1. 零开销抽象:函数指针调用只有一次间接跳转,没有虚函数表的多次查找
  2. 显式而非隐式:所有「方法」都是可见的函数指针,没有隐藏的 this 指针调整
  3. 编译时检查:初始化不存在的成员会编译失败(指定初始化器的优势)
  4. 运行时灵活性:可以动态替换函数指针实现热升级

这证明了数据结构设计先于算法(Rob Pike 格言)——正确设计了 struct file_operations,所有驱动的接入方式就自然统一了。

Q6: 成员排列顺序如何影响 sizeof?如何优化?

是的,排列顺序显著影响 sizeof 的结果。

reorder_impact.c
c
// 顺序 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% 的内存)

优化原则:按对齐要求从大到小排列——

  1. double / long long / 指针(8 字节对齐)
  2. int / float / long(4 字节对齐)
  3. short(2 字节对齐)
  4. char / _Bool(1 字节对齐)

为什么默认不自动优化? 编译器不能随意重排成员——C 标准保证结构体成员的地址按声明顺序递增,且第一个成员的地址等于结构体本身的地址。这些保证被某些代码依赖(如将结构体指针转换为第一个成员的指针)。因此,优化排列顺序是程序员的责任。


课后练习

  1. 最远点查找:给定一个 point_t 结构体数组(如 10 个点),找出其中离原点 (0, 0) 距离最远的那个点并输出其坐标和距离。

    知识点提示:结构体数组 point_t arr[10]、遍历比较法、最大值更新模式

  2. 最远两点距离:在上一题的数组中,找出所有点对中距离最远的两个点。

    知识点提示:嵌套循环遍历所有点对 (i, j) where i < j、组合数 C(n,2)

  3. 学生成绩管理:定义 student 结构体(学号、姓名、三门课成绩),输入 5 个学生的数据,计算每位学生的总分和平均分,支持通过学号查询。

    知识点提示:结构体嵌套(学生包含课程成绩)、strcmp 字符串比较、strcpy 字符串复制

  4. 矩形与点:定义 rect_t 结构体(包含左上角坐标 point_t 和宽高 float),实现函数判断一个点是否在矩形内部。

    知识点提示:嵌套结构体、边界条件判断(点在边上算不算内部?)

  5. 结构体对齐实验:定义包含 charintshortdouble 的结构体,分别用不同顺序排列成员,用 sizeofoffsetof 验证内存布局,总结对齐规律。

    知识点提示sizeofoffsetof<stddef.h>)、__attribute__((packed))#pragma pack

练习1: 查找离原点最远的点
farthest_from_origin.c
c
#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_tpts[i].x 访问第 i 个点的 x 坐标。最大值更新模式:维护当前最大值,遇到更大的值就更新。sqrtf 直接计算到原点的距离(原点坐标是 (0,0),不需要减法)。

练习2: 寻找最远两点距离
farthest_pair.c
c
#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: 学生成绩管理
student_grade.c
c
#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: 矩形与点
rect_point.c
c
#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> 提供了 booltruefalse 类型(C99)。

练习5: 结构体对齐实验
alignment_experiment.c
c
#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 差异,分析每个成员的偏移量,理解填充字节的位置和数量。可以尝试更多类型组合(如 shortlong long、指针)来总结完整的对齐规律。


参考资料

"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

Released under the MIT License.