多文件的十条流

当代码从一个文件拆成多个文件时,最关键的变化不是「文件变多了」,而是接口出现了。头文件定义了函数签名,实现了「能做什么」和「怎么做」的分离。十条流在这个阶段发生了质变——控制流第一次有了明确的「边界」,反馈流终于可以跨文件回退(启动流仍未形成,链接顺序是编译期问题,见第二节)。


一、从一个文件到多个文件

上一篇的 args.cmainprint_argsformat_arg 写在一个文件里。现在拆开:

1
2
3
4
5
6
7
8
9
10
11
// format.h
void format_arg(char *buf, size_t size, int index, const char *value);

// format.c
#include "format.h"
void format_arg(char *buf, size_t size, int index, const char *value) {
if (value == NULL)
snprintf(buf, size, "arg[%d] = (null)", index);
else
snprintf(buf, size, "arg[%d] = %s", index, value);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// print.h
void print_args(int argc, char **argv);

// print.c
#include "print.h"
#include "format.h"
void print_args(int argc, char **argv) {
char buf[256];
for (int i = 0; i < argc; i++) {
format_arg(buf, sizeof(buf), i, argv[i]);
printf("%s\n", buf);
}
}
1
2
3
4
5
6
// main.c
#include "print.h"
int main(int argc, char **argv) {
print_args(argc - 1, argv + 1);
return 0;
}

三个文件,三个编译单元。main.c 通过 print.h 知道 print_args 的存在,通过 print.c 间接使用 format.h文件之间只通过头文件(接口)通信,不通过实现细节通信。

这个变化让十条流全部重新洗牌。


二、启动流:尚未形成——链接顺序是编译期问题

启动流只有有框架才有。多文件阶段仍然只有一个入口 main,没有框架,启动流还没有形成。

这个阶段看起来和「启动」有关的新东西是链接顺序——但它是编译期问题,不是运行期启动:

1
2
3
4
5
编译时:
gcc -c format.c → format.o
gcc -c print.c → print.o
gcc -c main.c → main.o
gcc main.o print.o format.o → args ← 链接顺序(编译期,与运行无关)

链接顺序决定符号解析的顺序,头文件包含关系决定编译时的可见性:

1
2
main.c 能看到的:print_args(通过 print.h)
main.c 看不到的:format_arg(因为没有包含 format.h)

文件之间的可见性由头文件决定——看不到 = 不能直接调用。这是「封装」的物理基础,但属于接口/控制流的范畴,不是启动流。

启动流要等到多系统阶段——框架化子系统出现——才真正形成。


三、执行流:从调用图到跨文件调用链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
graph TD
subgraph "main.c"
A["main()"]
end
subgraph "print.c"
B["print_args()"]
end
subgraph "format.c"
C["format_arg()"]
end

A -->|"print.h"| B
B -->|"format.h"| C
C --> D["snprintf()"]

style A fill:#4a9eff,color:#fff
style B fill:#ff9f4a,color:#fff
style C fill:#4caf50,color:#fff

执行流现在跨文件了:

1
2
3
4
5
6
7
8
main.c: main()
→ [通过 print.h 调用]
print.c: print_args()
→ [通过 format.h 调用]
format.c: format_arg()
→ snprintf()
← 返回到 print.c
← 返回到 main.c

执行流的跨文件追踪变得困难——在 main.c 的调试器里,你看到 print_args 被调用了,但看不到 format_arg(因为 main.c 不包含 format.h)。要看完整调用链,需要切换到 print.c 的上下文。

多文件执行流的核心变化:调用链跨文件边界时,调试和追踪的难度陡增。


四、数据流:跨文件的数据传递

数据现在跨文件流动:

1
2
3
4
5
6
7
8
9
main.c: argc, argv
↓ 传参
print.c: argc, argv, buf[256]
↓ 传参
format.c: buf, size, index, value
↓ snprintf
format.c: buf 被写入
↓ 返回(buf 通过指针被 print.c 共享)
print.c: buf 的内容被 printf 输出

数据流跨文件的关键观察:**bufprint.c 中分配,通过指针传给 format.c 修改,再回到 print.c 使用**。两个不同的编译单元通过指针共享了同一块内存。

这是多文件数据流的核心模式:通过指针(或引用)跨文件传递可变数据。如果没有指针,format.c 就无法修改 print.cbuf——数据流就会断裂。

多文件数据流的核心约束:跨文件数据传递依赖指针/引用,类型信息依赖头文件。


五、状态流:全局状态和静态状态出现

多文件引入了两种新的状态:

1
2
3
4
5
6
7
// print.c
static int print_count = 0; // 文件内全局状态(其他文件看不到)

void print_args(int argc, char **argv) {
print_count++; // 每次调用都累加
// ...
}
1
2
3
4
5
6
7
8
// format.c
static int format_errors = 0; // 文件内全局状态

void format_arg(...) {
if (/* 格式化失败 */)
format_errors++; // 记录错误次数
// ...
}

print_countformat_errors文件级全局状态——它们的生命周期是整个程序运行期,但只有各自文件内的函数能访问。

更进一步,如果需要跨文件共享状态:

1
2
3
4
5
// shared.h
extern int total_args_printed; // 声明

// shared.c
int total_args_printed = 0; // 定义

多文件状态流的核心变化:出现了三种状态——函数局部(栈上)、文件全局(static)、跨文件全局(extern)。它们的生命周期和可见性完全不同。


六、用户流程:一类多条,止步于 main

多文件阶段用户流程仍然是一类多条:每条命令一个实例,验证 = 输入命令 → 结果符合预期。

1
2
3
4
5
6
7
用户
→ ./args hello world (用户流程:一个实例)
→ main.c: argc=3 (用户流程和执行流的交界)
→ print.c: print_args() (纯执行流)
→ format.c: format_arg() (纯执行流)
→ printf 输出 (执行流的终点)
→ 用户看到结果 (用户流程的终点:符合预期)

用户流程和执行流的交界点始终在 main——main 以下的所有文件都是纯执行流。用户不关心文件怎么拆分、函数怎么调用,只关心命令的结果。

多文件没有给用户流程带来任何新东西——它仍然是同一类(命令行)的多条实例。多步操作、逐步验证的完整用户流程从 TUI 的屏幕导航开始萌芽,到 GUI 才成为真正的线。


七、业务流:从业务步骤到模块职责

单文件级的业务流是「几个函数协作做什么」。多文件级的业务流变成了:每个文件承担一个业务职责

1
2
3
format.c → 职责:把数据格式化成字符串
print.c → 职责:遍历参数并打印
main.c → 职责:程序入口,调用 print

业务流和文件的对应关系:

1
2
3
4
业务步骤 1:接收参数        → main.c
业务步骤 2:逐个处理 → print.c
业务步骤 3:格式化单个参数 → format.c
业务步骤 4:输出结果 → print.c

多文件业务流的核心变化:业务步骤开始和文件(模块)一一对应。 这就是「按职责拆文件」的来源——每个文件负责业务流中的一个步骤。


八、功能流:仍然在函数内部

功能流仍然是单个函数内部的逻辑步骤,不随文件数量变化。format_arg 的功能流和单文件时完全相同。

功能流是唯一不随组织规模变化的流——它始终是函数级别的。


九、控制流:接口成为控制流的边界

1
2
3
4
5
6
7
8
9
10
11
12
13
14
graph LR
subgraph "main.c 能看到的"
A["print_args"]
end
subgraph "main.c 看不到的"
B["format_arg"]
C["snprintf"]
end
A -->|"通过 format.h"| B
B --> C

style A fill:#4a9eff,color:#fff
style B fill:#ddd,color:#333
style C fill:#ddd,color:#333

这是多文件阶段最重要的变化之一。

单文件时,控制流是「调用者通过参数间接控制被调用者」。多文件时,控制流增加了一个新维度:接口决定了控制流的可见范围

1
2
main.c 能控制的:print_args(因为 print.h 暴露了它)
main.c 不能控制的:format_arg(因为 format.h 没有被 main.c 包含)

头文件 = 控制流的边界main.c 通过 print.h 只能看到 print_args——它不知道 print_args 内部调用了 format_arg。这意味着:

1
2
3
main.c 的控制权范围:
→ 可以决定 print_args 的参数
→ 不能决定 format_arg 的参数(间接被 print_args 决定)

控制流的分层

1
2
3
main.c: 控制 print_args(直接控制)
print.c: 控制 format_arg(直接控制)
format.c: 控制 snprintf(直接控制)

每一层只控制自己的直接下级,不知道更深层发生了什么。这就是「封装」在控制流上的体现——每一层只暴露给上层自己愿意暴露的部分。


十、错误流:跨文件的错误传播

1
2
3
4
5
6
7
graph TD
A["format.c: snprintf 返回 -1"] -->|"返回错误码"| B["print.c: 检查返回值"]
B -->|"打印错误, 跳过"| C["print.c: 继续循环"]
B2["print.c: 不返回错误码"] -->|"main 不知道"| D["main.c: 无法感知错误"]

style A fill:#f44,color:#fff
style D fill:#ff9f4a,color:#fff

多文件的错误流有了真正的传播路径:

1
2
3
4
5
format_arg 遇到格式化错误
→ 返回错误码给 print_args
→ print_args 决定:跳过这个参数?打印错误信息?终止?
→ 返回错误码给 main
→ main 决定:退出?忽略?重试?

错误传播的方向:从底层到上层,逐级传递

1
2
3
4
5
format.c: snprintf 返回 -1(格式化失败)
↓ 返回 -1 给 print.c
print.c: 检查返回值,决定打印错误信息并跳过
↓ print_args 继续循环
main.c: 无法知道 print_args 内部发生了错误(因为 print_args 没有返回错误码)

多文件错误流的核心变化:错误传播路径变得清晰,但每一层可以选择吞掉错误。 如果 print_args 不返回错误码,main.c 就完全不知道发生过格式化错误。


十一、反馈流:第一次可以跨文件回退

反馈流在多文件级终于有了实质内容。

假设 format_arg 改为返回错误码:

1
int format_arg(char *buf, size_t size, int index, const char *value);  // 返回 0 成功,-1 失败

print_args 可以根据返回值决定:

1
2
3
4
5
6
7
8
9
10
11
void print_args(int argc, char **argv) {
char buf[256];
for (int i = 0; i < argc; i++) {
int ret = format_arg(buf, sizeof(buf), i, argv[i]);
if (ret != 0) {
printf("error: could not format arg %d\n", i);
continue; // ← 跳过,继续处理下一个
}
printf("%s\n", buf);
}
}

这是线性回退——出错了跳过当前,继续下一个。更进一步,可以实现重试

1
2
3
4
5
int ret = format_arg(buf, sizeof(buf), i, argv[i]);
if (ret != 0) {
// 用备用格式重试
snprintf(buf, sizeof(buf), "arg[%d] = <error>", i);
}

但仍然有一个根本限制:**print_args 无法让 format_arg 回到某个中间状态重试**——函数调用是单向的,返回后不能「回到」函数内部的某个点。

多文件反馈流的核心进展:调用者可以根据返回值决定跳过/降级/重试。但仍然是「跳过重来」而非「精确回退」。

反馈流的真正力量——「回到问题产生的那一步,从那里重新执行」——要等到多层系统出现时才会实现。那时有了「层」的概念,上层可以接住下层的失败,从合适的步骤重新开始。


十二、十条流在多文件级的全景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
                  多文件级的十条流

┌──────────────────────────────────────────────────────────┐
│ 启动流 尚未形成(链接是编译期问题) │
│ 执行流 跨文件调用链(调试难度陡增) │
│ 数据流 通过指针跨文件传递可变数据 │
│ 状态流 三种状态:局部 / 文件全局(static) / 跨文件全局(extern) │
│ 用户流程 一类多条:止步于 main │
│ 业务流 每个文件对应一个业务步骤 │
│ 功能流 仍在函数内部(不随规模变化) │
│ 控制流 头文件 = 控制流边界(封装的物理基础) │
│ 错误流 跨文件传播,每层可选择吞掉 │
│ 反馈流 调用者可跳过/降级(线性回退) │
└──────────────────────────────────────────────────────────┘

相比文件级的关键变化:

文件级 多文件级 质变点
启动流 尚未形成 尚未形成(链接是编译期问题) 无——启动流到多系统才形成
执行流 文件内调用图 跨文件调用链 调试需要跨文件上下文
数据流 文件内传递 指针跨文件传递 指针成为跨文件数据流的关键
状态流 局部 + 可能的全局 三种生命周期 static/extern 区分了可见性
控制流 调用者间接控制 头文件 = 控制边界 封装的物理基础
错误流 底层吞掉 跨文件传播 传播路径变清晰
反馈流 不存在 线性回退(跳过/降级) 第一次有实质内容

多文件级的核心概念:接口。 头文件定义了「能做什么」,源文件实现了「怎么做」。接口是控制流的边界、数据流的通道、错误流的传播路径。

当文件进一步增加,开始出现「分层」——有些文件负责入口,有些文件负责业务,有些文件负责底层能力——流的形态会发生更大的变化。下一篇看多层系统。