单函数的十条流

一切从一个函数开始。在所有复杂的软件系统之前,程序只是若干条指令顺序执行。当这些指令被组织成一个函数,十条流就已经同时存在了——只是有几条还很微弱,甚至尚未诞生。这一篇用一个真实的小函数,把十条流全部展开。


一、从一条指令到一个函数

最简单的程序:

1
printf("hello");

只有一条指令。没有分支,没有循环,没有函数调用。十条流在这里几乎全部退化成一个点——指令执行了,结果出现了,结束。

加一个判断:

1
2
3
4
if (argc > 1)
printf("hello %s", argv[1]);
else
printf("hello");

有了分支,程序不再只走一条路。控制流开始出现——程序在某个点要「选择」。

加一个循环:

1
2
for (int i = 0; i < argc; i++)
printf("arg[%d] = %s\n", i, argv[i]);

有了循环,同一段代码可以被执行多次。执行流不再是线性的。

把这些组织成一个函数:

1
2
3
4
5
6
7
8
void print_args(int argc, char **argv) {
for (int i = 0; i < argc; i++) {
if (argv[i] == NULL)
printf("arg[%d] = (null)\n", i);
else
printf("arg[%d] = %s\n", i, argv[i]);
}
}

函数是程序组织的最小单元。到了这一步,十条流已经全部就位——虽然有些还很薄,但它们都存在。


二、十条流的总览

先把十条流的定义列出来,后面逐条展开:

# 问的问题 函数级的形态
1 启动流 框架的启动编排 尚未形成(只有一个入口,被调用=执行开始)
2 执行流 谁调用了谁 函数内部调用了哪些其他函数
3 数据流 数据经过谁被转换 输入参数 → 处理 → 输出
4 状态流 状态怎么变化 局部变量的变化过程
5 用户流程 用户操作什么、得到什么 CLI:一类多条(每条命令一个实例)
6 业务流 业务要做哪些步骤 函数完成的业务语义
7 功能流 内部逻辑怎么跑 循环、判断、赋值的具体步骤
8 控制流 程序走向由谁决定 if/for/while/return 的分支结构
9 错误流 出错时怎么办 NULL 检查、错误返回
10 反馈流 发现问题后回退到哪 函数级几乎不存在

先注意最后两行:反馈流在函数级几乎不存在——函数内部出了错,只能返回错误码,没有办法「回到上一步重新执行」。反馈流要等到多文件、多层系统时才会真正出现。


三、启动流:尚未形成——被调用是执行流的开始

启动流只有有框架才有——框架(有生命周期、要 start/stop 的组件/子系统)才需要「启动编排」。单函数阶段只有一个入口,没有框架,启动流还没有形成。

函数被调用不是「启动」,而是执行流的开始

1
print_args(3, argv);
1
2
3
4
5
调用者传入 argc = 3, argv = {"cmd", "hello", "world"}

函数获得这两个参数

进入函数体 ← 这是执行流开始,不是启动流

「参数合法吗」这个问题属于执行流的起点(入口条件),不属于启动流:

1
2
3
4
void print_args(int argc, char **argv) {
// 入口条件没有检查:argc 是否为负数?argv 是否为 NULL?
for (int i = 0; i < argc; i++) { ... }
}

如果 argc = -1,循环不会执行,函数安静地返回。这不是「启动失败」,是执行流带着非法条件开始。

单函数阶段的结论:启动流尚未形成——只有一个入口,被调用 = 执行开始。启动流要等到多系统阶段(框架化子系统出现)才真正形成。


四、执行流:内部调用了谁

1
2
3
4
5
6
7
8
9
10
11
12
graph TD
A["print_args(argc, argv)"] -->|"argv[i] == NULL"| B["printf('(null)')"]
A -->|"argv[i] != NULL"| C["printf('%s', argv[i])"]
B --> D["write 系统调用"]
C --> D
D --> E["内核"]

style A fill:#4a9eff,color:#fff
style B fill:#ff9f4a,color:#fff
style C fill:#ff9f4a,color:#fff
style D fill:#888,color:#fff
style E fill:#555,color:#fff

print_args 内部调用了两个函数:

1
2
3
print_args(argc, argv)
├→ printf("arg[%d] = (null)\n", i) // 当 argv[i] == NULL
└→ printf("arg[%d] = %s\n", i, argv[i]) // 当 argv[i] != NULL

执行流是调用链——从当前函数出发,追踪它调用了谁、被谁调用。

在函数级,执行流通常是树状的:

1
2
3
4
5
print_args
└→ printf
└→ 内部的字符格式化
└→ write 系统调用
└→ 内核

函数级执行流的特点:我们只能看到「往下调了谁」,看不到「谁调了我」。调用者的信息在函数内部是不可见的——函数不知道自己是被 main 调用的还是被另一个函数调用的。


五、数据流:输入经过处理变成输出

1
2
3
4
5
6
7
8
9
10
11
graph LR
A["argc"] -->|"控制循环次数"| B{"循环 i=0..argc-1"}
B --> C["argv[i]"]
C -->|"NULL"| D["printf('(null)')"]
C -->|"非NULL"| E["printf('%s')"]
D --> F["屏幕文字"]
E --> F

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

print_args 的数据流:

1
2
3
4
5
6
7
8
argc (整数)
↓ 控制循环次数

argv[i] (字符串指针)
↓ NULL 检查
↓ printf 格式化
↓ write 系统调用
屏幕上的文字

函数级数据流的特点:输入是参数,输出是副作用(printf 写屏幕)或返回值

这个函数没有返回值——它的「输出」是通过 printf 直接写到 stdout 的副作用。这意味着调用者无法拿到函数的计算结果,只能通过观察屏幕来获取。

数据流的一个重要观察:同一个输入 argv[i] 走了两条不同的路径

1
2
3
argv[i]
├→ NULL → printf("(null)")
└→ 非 NULL → printf("%s", argv[i])

分支处数据分流,这是数据流和控制流重叠的地方。


六、状态流:局部变量怎么变化

1
2
3
4
5
6
7
8
9
10
11
12
13
stateDiagram-v2
[*] --> i_0: i = 0
i_0 --> i_1: i++ (第1次循环)
i_1 --> i_2: i++ (第2次循环)
i_2 --> i_3: i++ (第3次循环)
i_3 --> done: i >= argc, 退出循环
done --> [*]: 函数返回, i 销毁

state "i = 0" as i_0
state "i = 1" as i_1
state "i = 2" as i_2
state "i = 3" as i_3
state "函数返回" as done

print_args 的状态流——追踪局部变量的生命周期:

1
2
3
4
i = 0        ← 初始化
i = 1 ← 第一次循环后 i++
i = 2 ← 第二次循环后 i++
i = 3 ← 第三次循环后 i++,此时 i >= argc,退出循环

函数级状态流的特点:状态生命周期 = 函数调用周期。函数返回后,所有局部变量消失。

1
2
3
4
5
函数调用开始
↓ 创建局部状态(i)
↓ 状态在循环中变化(0→1→2→3)
函数返回
↓ 所有局部状态销毁

没有任何状态被持久化——下次调用 print_args 时,i 重新从 0 开始。

函数级状态流的核心特征:状态是临时的、函数返回即销毁。


七、用户流程:CLI 阶段是一类多条

用户流程(User Flow)= 用户的操作序列——用户操作什么、得到什么结果,不关心程序内部怎么执行。它的完整形态来自 GUI:用户逐个操作控件、每步验证符合预期。在 CLI 阶段它只是一类多条:程序是命令式的,用户输入一条命令就是一个实例,很多条命令只是同一个类型的不同实例。

单函数程序 = 一条命令:

1
2
3
4
5
6
用户打开终端
→ 输入 ./program hello world ← 一个用户流程实例
→ main 解析参数
→ 调用 print_args(3, argv)
→ 函数执行
→ 用户看到输出 ← 实例结束:结果符合预期

这个阶段用户流程的验证只有一次:输入命令 → 得到结果。函数内部怎么执行,用户不关心——用户流程和执行流在 main 处交界。

函数本身不知道「用户」是谁,它只知道有人传了参数进来。函数只是用户流程的一个片段,不是全部——多步操作、逐步验证的完整用户流程,要等 GUI 才成为一条真正的线。


八、业务流:这个函数做了什么

从业务角度看,print_args 做的事情是:

1
2
3
接收一组命令行参数
→ 逐个打印每个参数
→ 格式化为 "arg[序号] = 值" 的形式

业务流关注的是「做什么」(What),不关心内部实现细节。

1
2
业务流:打印命令行参数
执行流:for 循环 → NULL 判断 → printf

两者的区别:

1
2
业务流说:打印参数
执行流说:先判断 NULL,再 printf

业务流是设计时的视角(我想要什么),执行流是运行时的视角(代码实际怎么跑)。


九、功能流:函数内部的逻辑步骤

功能流是执行流的叶节点展开——执行流说「调用了 print_args」就停了,功能流把这个调用展开成内部的每一步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
print_args(3, {"cmd", "hello", "world"}):
i = 0
i < 3? → 是
argv[0] == NULL? → 否
printf("arg[0] = cmd\n")
i = 1
i < 3? → 是
argv[1] == NULL? → 否
printf("arg[1] = hello\n")
i = 2
i < 3? → 是
argv[2] == NULL? → 否
printf("arg[2] = world\n")
i = 3
i < 3? → 否 → 退出循环
函数返回

功能流的图是流程图——只关心函数内部的判断、循环、赋值。

什么时候需要画功能流:函数超过 30 行、逻辑复杂、反复出 Bug、需要向别人解释。


十、控制流:程序走向由谁决定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
flowchart TD
Start["for i = 0; i < argc; i++"] --> Cond{"i < argc?"}
Cond -->|"否"| End["函数返回"]
Cond -->|"是"| NullCheck{"argv[i] == NULL?"}
NullCheck -->|"是"| PrintNull["printf('(null)')"]
NullCheck -->|"否"| PrintVal["printf('%s')"]
PrintNull --> Inc["i++"]
PrintVal --> Inc
Inc --> Cond

style Start fill:#4a9eff,color:#fff
style End fill:#f44,color:#fff
style Cond fill:#ff9f4a,color:#fff
style NullCheck fill:#ff9f4a,color:#fff

控制流关注的是:在每个分支点,程序往哪走?谁决定的?

print_args 的控制流:

1
2
3
4
5
6
7
8
循环条件: i < argc
→ 由 argc 决定(调用者传入)

NULL 判断: argv[i] == NULL
→ 由数据决定(argv 的内容)

循环结束: i >= argc
→ 由 argc 和循环递增共同决定

控制流的核心观察:函数内部的控制流由两样东西决定——参数和数据。函数自身不「选择」走哪条路,是数据告诉它走哪条。

但更深层的问题是:谁决定了 argc 和 argv 的值? 函数自己不知道。控制权在函数外面——在调用者那里。

1
2
调用者决定 argc, argv    ← 控制权在外部
函数内部根据数据分支 ← 控制权在数据

函数级控制流的核心特征:函数内部的控制权由数据掌握,函数外部的控制权由调用者掌握。


十一、错误流:出错时怎么办

print_args 的错误处理:

1
2
3
4
if (argv[i] == NULL)
printf("arg[%d] = (null)\n", i); // 容忍 NULL,打印 (null)
else
printf("arg[%d] = %s\n", i, argv[i]);

它选择的策略是容忍——遇到 NULL 不崩溃,打印一个标记继续。

但这个函数有一个未处理的错误argc 为负数时的行为未定义(循环条件 i < argci = 0, argc = -1 时为假,直接跳过——碰巧不会崩溃,但不是有意设计)。

函数级错误流的特点:

1
2
3
4
5
6
7
错误发生在函数内部

函数可以选择:
1. 容忍(打印标记继续)
2. 返回错误码(本函数没有返回值,做不到)
3. 抛异常(C 语言没有)
4. 崩溃(最差选择)

函数级错误流的核心限制:函数能做的错误处理方式有限——最多返回错误码或打印日志,无法「回到上一步重试」。


十二、反馈流:几乎不存在

反馈流的定义:发现问题后,回到问题产生的那一步,重新执行后续流程。

在函数内部,这几乎不可能。print_args 发现 argv[i] 是 NULL 后,它能做的只是打印 (null) 然后继续——它无法回到调用者说「你传了一个 NULL,换一个再调我」。

1
2
3
4
5
6
7
函数内部发现错误

能做什么?
→ 返回错误码?(本函数没有返回值)
→ 崩溃?(最差选择)
→ 打印日志继续?(当前选择)
→ 回到上一步重试?(做不到——函数没有这个能力)

反馈流在函数级几乎不存在,是函数作为最小组织单元的结构性限制。 函数是一个「只能前进不能后退」的执行单元。

反馈流要等到多层系统出现时才会真正诞生——那时「上层」可以接住「下层」的错误,回到合适的步骤重试。


十三、十条流在函数级的全景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
                  函数级的十条流

┌──────────────────────────────────────────────────┐
│ 启动流 尚未形成(只有一个入口) │
│ 执行流 内部调用了 printf 等 │
│ 数据流 argc/argv → 处理 → 屏幕输出 │
│ 状态流 局部变量 i: 0→1→2→3→销毁 │
│ 用户流程 一类多条:输入命令 → 得到结果 │
│ 业务流 打印命令行参数 │
│ 功能流 循环+判断+打印的具体步骤 │
│ 控制流 数据决定分支方向,调用者决定参数 │
│ 错误流 NULL 容忍,argc 负数未处理 │
│ 反馈流 几乎不存在(函数无法回到上一步) │
└──────────────────────────────────────────────────┘

十条流的成熟度在函数级差异很大:

  • 成熟的:执行流、数据流、控制流、功能流——函数内部最容易观察到
  • 薄的:用户流程、状态流、业务流、错误流——存在但很简单(用户流程在 CLI 阶段只是一类多条)
  • 不存在的:反馈流——函数没有「回退重试」的能力

这正是函数作为最小组织单元的特点:它解决了一个具体的计算问题,但对自身的上下游、错误恢复、用户感知都一无所知。

当程序从一个函数增长到多个函数、多个文件、多个层级时,这些流会逐步「浮现」——有的变厚,有的从无到有。下一篇看它们在单文件多函数时长成了什么样。