☰
程序流图辨析:程序流程图、控制流图与圈复杂度指南
2026/9/30 1:33:57 网站建设 项目流程

上周帮同事过一份测试报告,他画的“程序流图”里菱形套菱形,判断框后面直接连着判断框,圈复杂度算出来是 7,可他自己数分支只数出 4 条,两边对不上。问题不在他手生,而在他把两种不同的图揉一张纸上了——面向人读的程序流程图,和面向机器分析的控制流图,符号体系、节点粒度、连边规则压根不是一套东西。程序流图这四个字本身就有歧义,搜出来的结果里既有课本上的矩形菱形图,也有编译原理课件里的基本块有向图,还有静态分析工具吐出来的那一大坨连线,画之前不把“给谁看、拿来干什么”想明白,画得再工整也是白费功夫。

这篇东西我想把这件事一次说透:先分清你要画的到底是哪一种图,再把标准符号、工具选型、三种基本结构的画法、基本块划分规则、圈复杂度计算和独立路径导出,一层层拆开。每一段我都会交代清楚“为什么这么做”以及“不这么做会出什么问题”,中间夹着我这些年踩过的坑。不管你是刚接触流程图的在校学生、要写测试用例的测试同学,还是做代码审计、静态分析、需要读控制流图的工程人员,看完都能直接上手,画出一张经得起别人追问的图。

1. 程序流图到底指哪一种图,先别急着动笔

1.1 三个经常被混为一谈的“流图”

“程序流图”这个说法在中文技术圈里是个模糊词。我见过太多人拿着 A 图去回答 B 问题,最后两边都别扭。你只要记住下面三类图的识别特征,基本就不会再搞混。

程序流程图,英文 Program Flowchart,也有人叫框图、流程图。它的节点是“处理步骤”,可能是“读取文件”“计算金额”“打印结果”这种业务动作,边是“执行的先后顺序”。这套东西有正式标准,ISO 5807 和我国的 GB 1526 都规定了符号画法。它天生是画给人看的,读者可能是产品经理、业务方、新入职的同事。识别特征很简单:图里有圆角矩形当起止、有菱形当判断、有平行四边形当输入输出。

控制流图,英文 Control Flow Graph,简称 CFG。它的节点不是“业务动作”,而是基本块——一段顺序执行、中间没有跳转的语句序列。边是跳转关系,边上会标真/假。它天生是画给工具和算法看的,编译器优化、静态分析、代码覆盖率统计、圈复杂度计算,用的都是它。识别特征:一个节点里往往塞着好几行代码,节点之间用有向边连,边上标 T 或 F。

数据流图,英文 Data Flow Diagram,简称 DFD。它描述的是数据在系统里怎么流动、在哪个环节被加工、落到哪个存储。它没有控制流的概念,所以它画不出循环,也画不出“先判断再执行”。识别特征:图里有外部实体、加工、数据存储、数据流四类元素,箭头代表数据流向而不是执行顺序。

1.2 用途决定画法:三种图对照着看

把这三类图放到同一张表里对比,差异会变得非常直观。

对比维度程序流程图控制流图 CFG数据流图 DFD
节点含义一个处理步骤或判断一个基本块(多条语句)一个加工或存储
边的含义执行先后顺序跳转关系数据流向
能否表达循环能,用回边能,用回边不能
主要读者人工具与算法业务与架构人员
典型用途需求沟通、操作说明、文档复杂度度量、测试设计、编译优化需求分析、数据建模
常用工具draw.io、Visio、ProcessOnGraphviz、staticfg、Soot、反汇编器Visio、PowerDesigner
粒度可粗可细,看读者由块首规则唯一确定按数据实体划分

判断自己该画哪种,问三个问题就够了。第一,这张图最终交给谁看?交给人的,大概率是程序流程图;交给工具或者要拿去做覆盖度统计的,是控制流图。第二,你要不要算圈复杂度、要不要导出独立路径?要,就必须用控制流图,程序流程图里那些业务动作算不出圈复杂度。第三,你关注的是“执行顺序”还是“数据去向”?关注数据去向的,别在流程图上硬凑,直接画数据流图。

注意:把程序流程图和控制流图混画,是最常见的翻车方式。混画的直接后果是圈复杂度算不准,因为程序流程图里的一个“判断框”可能对应控制流图里的好几个基本块和好几条边,两边根本对不上账。

我见过一个更隐蔽的例子:有人在流程图上把if (a && b)画成一个菱形,然后按“条件数 + 1”去算复杂度,算出来是 3;换成工具跑出来是 2。他去跟领导解释了半天,最后发现是图本身的粒度和算法不匹配。这类问题在 5.1 节我会专门展开。

2. 画图前的准备:符号、工具和三条底线

2.1 标准符号速查与几种高频误用

符号画错是最容易被人当场指出的问题,而且改起来成本极低,没理由不按标准来。下面这张表建议直接收藏。

符号名称形状含义常见误用
起止框圆角矩形或椭圆流程的开始与结束用直角矩形代替,读者分不清入口出口
处理框矩形赋值、计算、业务动作一个框里塞三段互不相关的逻辑
判断框菱形条件分支画成三条甚至四条出口
输入输出框平行四边形读文件、请求接口、打印和“处理框”混着用
预定义处理双边矩形调用已定义的子流程硬展开成完整子流程,图迅速爆炸
注释框开口方括号补充说明当成处理框,把逻辑写进注释里
连接点小圆圈跨页或跨区域接续滥用导致主线断裂,读图要靠猜

判断框有两件事必须做到。一是有且仅有两条出口,标准符号的菱形就是两出路,要表达多路选择就串几个菱形,或者用多路分支的画法。二是两条出口都要标条件,只标“是”不标“否”是最常见的小毛病,读者看到没标的那条边就得自己反推。我一般直接标T和F,或者标具体的判断结果比如amount < 100和amount >= 100,后者信息量更大,但图会显得挤,看情况选。

处理框的写法也有讲究。框里写“动词 + 宾语”,比如“读取订单明细”“计算小计”“累加总额”。不要写“处理数据”这种没信息量的词,更不要写“调用 process() 函数”,那是控制流图或者时序图该干的事。一张流程图如果每读一个框都要回去翻代码,那这张图就没有存在的价值。

提示:判断框的出口条件建议用“事实陈述”而不是“是/否”。“是/否”在人脑里需要额外做一次映射,尤其是图上有五六个判断框的时候,读者很容易绕晕。写成“金额 < 100”和“金额 >= 100”,一眼就能对上。

2.2 工具怎么选:从纸笔到代码自动生成

工具没有绝对的好坏,只有合不合适。我按使用场景分了几档。

白板或纸笔。讨论阶段最快的工具,没有之一。一群人围在白板前,边争论边擦改,五分钟能定下一版草图。缺点是没法存档,所以我一般会在讨论结束后立刻用纸拍一张照片,回去再数字化。

draw.io(现在叫 diagrams.net)。免费、跨平台、符号库齐全,做流程图和基础的控制流图都够用。它的优势是导出的.drawio是 XML 文本,能进 Git,虽然 diff 起来不太友好,但至少版本可追溯。

Visio、ProcessOn、亿图这些商业化工具,胜在模板和协作,适合要出正式文档的场合。缺点是要么收费,要么有水印,团队协作时还得考虑账号问题。

PlantUML 和 Graphviz DOT。这两类是文本即图,写的是代码,渲染出来是图。它们的真正价值在于能进版本库、能 diff、能随代码一起评审。我做过一个项目,控制流图直接由 CI 从源码生成,每次提交都能看到复杂度变化,比人工画图靠谱得多。

代码自动生成这一档,值得单独说。Python 可以用staticfg直接从源码出控制流图,Java 生态有 Soot 这类框架,GCC 编译时加-fdump-tree-cfg也能把中间表示的控制流图 dump 出来。逆向分析场景下,反汇编工具自带的图视图本质上也是控制流图。

下面是一个 Graphviz DOT 的最小示例,写进cfg.dot之后执行dot -Tpng cfg.dot -o cfg.png就能出图。

digraph CFG { rankdir=TB; node [shape=box, fontname="monospace"]; B1 [label="B1: if (amount < 100)"]; B2 [label="B2: fee = 5"]; B3 [label="B3: if (amount < 500)"]; B1 -> B2 [label="T"]; B1 -> B3 [label="F"]; B2 -> B3; }

DOT 的语法很简单:digraph声明有向图,节点用节点名 [属性]定义,边用A -> B [label="..."]定义。属性里最常用的就是label(显示文字)和shape(形状)。控制流图的节点一般用shape=box,判断相关的块可以加个颜色区分,但别搞太多颜色,黑白打印出来会一塌糊涂。

2.3 动笔之前的三个决定

画之前先把这三件事定了,能省掉后面大量返工。

粒度定在哪一层。这个框里放“计算小计”,还是放“小计 = 单价 * 数量”?如果这张图要给业务方讲,放前者;如果是给自己梳理逻辑或者给测试写用例,放后者。我的经验是,先在业务粒度上画一版给人看,再降一级画一版给测试,两版都留着,互相对照。

边界画到哪里。异常路径到底画不画?画的话画到哪一层?我的默认做法是:输入校验、外部依赖失败、超时重试这三类必须画,因为它们直接影响控制流;而内存不足、磁盘写满这类底层异常,除非是核心系统,否则统一用一句注释带过。把边界提前说出来,比画完之后被人追问“文件不存在怎么办”要体面得多。

命名统一。图上出现“用户”“会员”“客户”三个词指同一个东西,是流程图最掉价的地方。动笔前列一张术语表,把所有实体的名字定死,图上只用一个词。

提示:粒度、边界、命名这三件事,本质上是同一件事——先定“契约”再画图。契约定好了,图就只是一次翻译工作;契约没定,图画到一半就会开始纠结,然后推翻重来。

3. 面向人读的程序流程图:七步画法

3.1 七步法总览,配一个完整例子

我画流程图基本固定走这七步,用久了会发现它其实是在逼你把问题拆干净。下面用一个“会员订单结算流程”当例子走一遍。

第一步,用一句话写清职责。格式固定为“输入 → 处理 → 输出”。这个例子里就是:读入一批订单明细,按会员等级计算折扣,累加后输出结算金额,遇到非法数据跳过并记录。

第二步,列出所有输入输出点。输入有订单明细(单价、数量)、会员等级配置表;输出有结算总额、错误日志、处理统计。这一步容易漏的是“配置项”,很多人只列业务数据,忘了配置也是输入,结果图画完了才发现折扣率不知道从哪来。

第三步,画主干。先把最顺利的一条路画出来:开始 → 读取明细 → 遍历计算小计 → 累加 → 输出总额 → 结束。这一步刻意不画任何分支和循环,目的是先确认整体骨架对不对。

第四步,补分支。明细是否合法(数量大于 0、单价非负);会员等级决定折扣率(普通全价、银卡九折、金卡八折)。每补一个判断框,就立刻把两条出口都标上条件。

第五步,补循环。遍历明细是前测试循环,菱形判断放在循环体上方,判断“还有下一条明细吗”,是则进入循环体,否则跳到汇合点。循环体末尾用一条回边指回菱形。

第六步,补异常与边界。文件不存在时记日志并直接结束;明细为空时输出 0;结算金额超过人工审核阈值时走审核分支。最后这条是业务分支不是异常分支,但特别容易漏,因为需求文档里往往写在附注里。

第七步,走查三问。每条路径是不是都有出口?每个判断框是不是都标了两条出口的条件?有没有哪个框里塞了两件事?这三问能干掉九成以上的低级错误。

3.2 顺序、选择、循环三种结构的画法要点

所有流程图,拆到底都是这三种结构的组合。把它们画标准了,整张图就稳了。

顺序结构最没技术含量,但有个细节:箭头别拐弯抹角。两个处理框之间如果是直线可达,就不要绕一个大弯。流程线交叉是所有流程图可读性的头号杀手,能避免就避免,不能避免就用连接点断开。

选择结构的关键是“合并点要明确”。两条分支出去之后,最终必须汇到同一个节点上,这个汇合点的位置要画清楚。多路选择(对应代码里的switch)有两种画法:串多个两路菱形,或者用一条带多个出口的判断。前者符合标准符号定义,后者在读图时更省事,但严格来说已经偏离标准了。我自己在内部文档里用后者,对外交付的文档用前者。

循环结构要区分前测试和后测试。前测试循环(对应while)的菱形在循环体上方,循环体可能一次都不执行;后测试循环(对应do-while)的菱形在循环体下方,循环体至少执行一次。这两种画错,逻辑就完全变了。我见过有人把do-while画成前测试,结果测试按图写了“一次都不执行”的用例,跑出来和预期不符,查了半天才发现是图的问题。

下面用 DOT 描述三种结构中最容易被画错的一个——后测试循环,方便对照。

digraph While { rankdir=TB; node [shape=box]; Start [shape=ellipse, label="开始"]; Body [label="循环体"]; Cond [shape=diamond, label="条件成立?"]; End [shape=ellipse, label="结束"]; Start -> Body; Body -> Cond; Cond -> Body [label="T"]; Cond -> End [label="F"]; }

注意这里Body -> Cond的顺序:先执行循环体,再判断条件,所以条件不成立时循环体至少执行过一次。这就是后测试循环。

3.3 嵌套、提前返回和异常的收口技巧

嵌套超过三层就抽子流程。这不是审美问题,是认知负荷问题。人一次能同时跟踪的分支层数很有限,超过三层之后,读者就开始靠猜了。做法是把内层的两三步逻辑整体换成一个双边矩形的“预定义处理”框,里面写子流程名,然后在图的下方或者单独一页画出这个子流程。这样主图永远保持在三层以内。

提前返回要统一收口。代码里一个函数有三个return很常见,画到流程图里就会有三条线各自指向“结束”框。这是允许的,但建议把这些线统一从图的右侧或者下方走,不要穿来穿去。更好的做法是在图的底部放一个统一的“结束”框,所有 return 都汇聚到它。

break和continue的处理容易被忽略。break是从循环体跳到循环出口,画一条从当前分支到循环出口汇合点的线;continue是跳回循环条件的判断点,画一条回边指向循环菱形。这两条线如果画得含糊,读者会以为它们和正常结束是一条路,那逻辑就完全错了。

异常路径要不要画,我的判断标准是:这张图的用途是不是包含测试设计或者故障分析。如果是,异常路径必须画,而且建议用虚线区分,视觉上和正常控制流分开。如果只是给业务方看的流程说明,异常路径可以统一收进一个“异常处理”子流程框里。最怕的是画了一半——主干图上有两条异常线,另外三条藏在正文里没画出来,这种图比完全不画还容易误导人。

提示:try/finally这类结构在流程图里最难画。我的做法是把 finally 块画成一个单独的处理框,然后用虚线从 try 块的正常出口和异常出口各连一条线过去,两条线上都标注同样的进入条件。虽然不符合严格的标准符号,但读过的人都能一次看懂,比硬套符号强。

4. 面向分析的控制流图:从源码到基本块

4.1 基本块怎么切:块首判定规则

控制流图和我们上面画的流程图在构造方式上完全不同。它不是“我觉得该分成几步就分几步”,而是有一套确定性的划分规则。换句话说,同一个函数,你和别人各自画,画出来的控制流图必须一模一样,不存在风格差异。

基本块的定义是:一段顺序执行的语句序列,只有一个入口和一个出口。入口就是块的第一条语句,出口就是块的最后一条语句,中间不能有跳转进来或者跳转出去。

划分基本块,先要找出所有“块首”,也就是入口语句。经典教材给了三条规则:

  1. 程序的第一条语句是块首。
  2. 任何跳转语句的目标语句是块首,包括条件跳转和无条件跳转的目标。
  3. 任何紧跟在跳转语句之后的语句是块首,因为条件不成立时会落到这里。

找到所有块首之后,从每个块首往下扫描,直到遇到下一个块首之前,中间那段就是一个基本块。

对写代码的人来说,这套规则翻译成源码视角会更直观,下面这张对照表我常用来给别人解释。

源码结构产生的块首
函数的第一条语句入口块
if、elif、else每个分支的第一条语句分支块首
if-else链汇合后的第一条语句汇合块首
while、for的条件判断语句循环头块首
循环体的第一条语句循环体块首
循环结束后的第一条语句出口块首
return、break、continue之后仍有语句不可达块首,通常会被优化掉

举个例子,if-else链在编译之后其实是嵌套的条件跳转:条件不成立时跳到下一个判断,成立时执行分支体然后跳过后续判断。所以“每个分支的第一条语句”和“每个判断语句本身”都是块首。理解了这一点,就不会出现“我明明按规则切了,怎么和工具跑出来的不一样”这种困惑。

4.2 连边规则与入口出口节点的坑

块切好之后开始连边,规则有四条,都很直接。

顺序边:块 A 的最后一条语句执行完之后自然进入块 B,就画一条 A 到 B 的边,不标条件。这种边在图上通常表现为上下相邻的两个块之间的连线。

条件边:块末尾是条件判断,两个分支分别指向不同的块。两条边都要标条件,一般用T和F。如果有复合条件被展开成多个判断,每个判断各自带 T/F 边。

回边:循环体末尾指回循环头的那条边。这条边的存在是判断“这里有个循环”的唯一依据,别省。

过程调用不展开:foo()这样的调用在函数级控制流图里就当成一条普通语句,不把foo的内部逻辑展开进来。要做过程间分析是另一个话题,图会大得多。

接下来是真正容易踩的两个坑。

第一个坑:多个return必须加虚拟出口。一个函数如果有三个return,严格来说图上就有三个出口节点。这时候如果你直接套E - N + 2算圈复杂度,会算小。正确做法是加一个虚拟出口节点,把所有return、throw、exit都连到它上面,这样整张图才符合公式的适用前提。我在 4.3 节的例子里会演示一个带虚拟出口的完整推演。

第二个坑:不可达代码破坏连通性。如果函数里有永远执行不到的代码,画出来的图会出现独立的连通分量。这时候公式要写成V(G) = E - N + 2P,其中P是弱连通分量的个数。更省事的做法是把死代码直接删掉,然后在图旁标注一句“已省略不可达分支”。

异常边画不画,也是个需要提前决定的事。在测试覆盖率的语境下,try/catch产生的异常边应该画,因为它确实是一条可执行路径。但在编译优化或者纯逻辑分析的场景下,通常会忽略它,否则图会膨胀得很快。关键是全团队用同一套约定,不能你画我不画。

注意:控制流图里最忌讳“凭感觉切块”。块切得不一样,边数就不一样,圈复杂度就不一样,后面的测试用例覆盖率统计也就没法对齐。工具跑出来的图和你手画的图对不上,九成是块首规则没吃透。

4.3 完整推演:一个函数从代码到流图

光说规则太干,我们把一个函数从头到尾推一遍。用的是下面这段 C 风格伪代码,逻辑是“根据金额区间确定基础费用,会员再减一块钱”。

int settle(int amount, int is_vip) { int fee; if (amount < 100) { // 第 1 行 fee = 5; // 第 2 行 } else if (amount < 500) { // 第 3 行 fee = 3; // 第 4 行 } else { fee = 0; // 第 6 行 } if (is_vip) { // 第 8 行 fee = fee - 1; // 第 9 行 } return fee; // 第 11 行 }

先找块首。函数的第一个if是块首,记作 B1。两个分支体的第一条语句fee = 5和fee = 3是块首,记作 B2、B4。else分支的fee = 0是块首,记作 B5。第二个if是分支汇合后的第一条语句,是块首,记作 B6。它的分支体fee = fee - 1是块首 B7,return语句是汇合点也是块首 B8。

块号对应语句成为块首的原因
B1if (amount < 100)函数第一条语句
B2fee = 5真分支目标
B3if (amount < 500)假分支目标
B4fee = 3真分支目标
B5fee = 0假分支目标
B6if (is_vip)分支汇合点
B7fee = fee - 1真分支目标
B8return fee汇合点,同时是唯一出口

再看边。B1 真走 B2,假走 B3;B2 执行完要跳过整个 else-if 链直接到 B6;B3 真走 B4,假走 B5;B4 和 B5 都汇到 B6;B6 真走 B7,假走 B8;B7 执行完汇到 B8。

起点终点条件
B1B2amount < 100 为真
B1B3amount < 100 为假
B2B6顺序,跳过 else 链
B3B4amount < 500 为真
B3B5amount < 500 为假
B4B6顺序
B5B6顺序
B6B7is_vip 为真
B6B8is_vip 为假
B7B8顺序

节点数 N = 8,边数 E = 10,弱连通分量 P = 1。代入V(G) = E - N + 2P = 10 - 8 + 2 = 4。同时数一下判定节点,B1、B3、B6 一共 3 个,3 + 1 = 4。两种算法对上账了,说明图没画错。

这个例子里只有一个return,所以不需要虚拟出口。如果 B7 和 B8 都改成return xxx,就得在它们下面加一个虚拟出口节点 EXIT,边数变成 12,节点数变成 9,12 - 9 + 2 = 5。注意这里从 4 变成了 5,多出来的 1 不是逻辑变复杂了,而是分支数变多了——fee = fee - 1之后直接返回和跳到统一出口再返回,在控制流上确实是两条不同的路。

下面是这段逻辑的 DOT 描述,可以直接渲染成图。

digraph CFG { rankdir=TB; node [shape=box, fontname="monospace"]; B1 [label="B1: if (amount < 100)"]; B2 [label="B2: fee = 5"]; B3 [label="B3: if (amount < 500)"]; B4 [label="B4: fee = 3"]; B5 [label="B5: fee = 0"]; B6 [label="B6: if (is_vip)"]; B7 [label="B7: fee = fee - 1"]; B8 [label="B8: return fee"]; B1 -> B2 [label="T"]; B1 -> B3 [label="F"]; B2 -> B6; B3 -> B4 [label="T"]; B3 -> B5 [label="F"]; B4 -> B6; B5 -> B6; B6 -> B7 [label="T"]; B6 -> B8 [label="F"]; B7 -> B8; }

手算容易出错,我一般会用一个十几行的小脚本复核一遍。用networkx建图,直接算节点数、边数和弱连通分量。

import networkx as nx edges = [ ("B1", "B2"), ("B1", "B3"), ("B2", "B6"), ("B3", "B4"), ("B3", "B5"), ("B4", "B6"), ("B5", "B6"), ("B6", "B7"), ("B6", "B8"), ("B7", "B8"), ] g = nx.DiGraph() g.add_edges_from(edges) n = g.number_of_nodes() e = g.number_of_edges() p = nx.number_weakly_connected_components(g) v = e - n + 2 * p print(f"N={n} E={e} P={p} V(G)={v}")

跑出来是N=8 E=10 P=1 V(G)=4,和手算一致。这个脚本的价值不在于省事,而在于当你手画的图和工具跑的图对不上时,可以逐个块、逐条边地核对,快速定位是哪一步切错了。

5. 圈复杂度计算与测试路径设计

5.1 三种算法与互相校验

圈复杂度不止一种算法,但它们的适用前提不一样,乱用就会算错。下面三种是最常用的。

方法公式适用前提本案例结果
边点法E - N + 2P图已统一出口,P 为连通分量数4
判定节点法判定节点数 + 1每个判定恰好两条出口4
条件数法简单条件数 + 1所有复合条件已展开成子图4

前两种方法在本案例里对上了,这很好。但第三种方法有个大坑,必须单独说。

假设有这么一个判断:if (a && b) { x(); } else { y(); }。

如果你把一个复合条件画成一个菱形,那么判定节点数是 1,1 + 1 = 2。但图上的边是:入口→菱形、菱形真→x、菱形假→y、x→汇合、y→汇合,一共 5 条边 5 个节点,5 - 5 + 2 = 2。这里两种算法是一致的。

如果你按短路求值的语义把它展开成两个菱形——先判断 a,a 为真再判断 b——那就多了一层。这时候判定节点数是 2,2 + 1 = 3,边点法算出来也是 3。但如果你还用“一个菱形”的图画法,却用“两个简单条件”去套条件数法,就会得到 3,和图上实际能走出来的路径数对不上。

这就是前面说的粒度不匹配问题。结论很简单:图上画了几个菱形,就用判定节点法;复合条件拆开了,才用条件数法。

至于圈复杂度的经验阈值,行业里流传的一套参考是:1 到 10 属于简单,维护起来轻松;11 到 20 属于中等,测试要花点心思;21 到 50 属于复杂,重构信号;超过 50 基本没法完整测试,必须先拆。这套数字不是硬标准,但拿来给团队定一个“超过多少就必须重构”的红线,非常好用。

注意:圈复杂度只反映控制流的复杂程度,不反映业务逻辑的复杂程度。一个圈复杂度只有 4 的函数,可能因为业务规则刁钻而极难维护;反过来,一个圈复杂度 30 的函数,也可能只是机械的一长串判断。别把这两个概念混为一谈。

5.2 导出独立路径基线集

圈复杂度算出来是 4,含义是“至少需要 4 条独立路径才能覆盖所有边”。接下来就是把它们找出来。

方法叫“基线路径法”:先选一条最简单的路径作为基准,然后每次翻转一个判定节点的出口,得到一条新路径,直到覆盖完所有边。

拿 4.3 节的例子来。基准路径选最容易理解的那条:金额很小、不是会员。

P1:B1 → B2 → B6 → B8。对应amount < 100为真、is_vip为假,走fee = 5然后直接返回。

P2:B1 → B2 → B6 → B7 → B8。相对 P1,只翻转了 B6 的出口,把is_vip改成真,多走一步fee = fee - 1。

P3:B1 → B3 → B4 → B6 → B8。相对 P1,翻转了 B1 的出口,走 else-if 分支,amount落在 100 到 500 之间。

P4:B1 → B3 → B5 → B6 → B8。相对 P3,翻转了 B3 的出口,走最后的 else,amount大于等于 500。

四条路径,正好等于圈复杂度。检查一下边覆盖:B1→B2、B1→B3、B2→B6、B3→B4、B3→B5、B4→B6、B5→B6、B6→B7、B6→B8、B7→B8,十条边全被覆盖到了。这就是一组合格的基线集。

值得注意的是,基线集不唯一。上面这组里,P1 到 P3 需要同时翻转两个判定,改动幅度比较大。也可以选 P2 当基准,这样 P1 只翻转 B6,改动更小。选哪一组看你的目的:目的是给测试写用例,就选每条路径之间差异最小的那组,用例之间差异小,出问题时好定位;目的是做代码审查,就选覆盖业务场景最全的那组。

5.3 从路径到测试用例

路径本身不是用例,还得给它配上具体输入数据和预期输出。这一步的难点在于,同一条路径对应无穷多组输入,选哪组最有价值。

路径输入 amount输入 is_vip预期 fee说明
P15005小额、非会员
P25014小额、会员
P330003中额、非会员
P480000大额、非会员

这四组数据能保证每条边上至少走一次。但它们只验证了“路径能走通”,没验证“边界对不对”。边界值得单独补一轮。

边界场景输入 amount输入 is_vip预期 fee
临界值下侧9914
临界值上侧10012
第二临界下侧49903
第二临界上侧50000
极小值014
负值-10待确认

最后一行“负值”很有价值。它在控制流图上走的是和 P1 完全相同的路径,但业务上是不是合法输入?如果不合法,函数里应该有校验,那图就漏了一块。这类问题靠画图发现不了,得靠“路径 + 边界 + 业务规则”三条线交叉验证。

还有一个必须知道的现实:控制流图上能连通的路径,在数据上不一定走得通。看这段代码:

if (x > 10) { if (x < 5) { /* 永远执行不到 */ } }

图上这两条边都在,圈复杂度会把它算进去,但实际上内层的真分支是一条不可行路径。这是圈复杂度方法的一个固有局限,学术上叫“不可行路径问题”。所以独立路径集给出的是测试用例数量的上限,实际能写出来的测试用例可能更少。遇到这种情况,正确做法是在图上标注“不可行”,并在测试计划里说明原因,而不是硬凑一组数据去跑。

6. 踩坑记录与常见问题速查

6.1 常见问题速查表

画了几年图,问题翻来覆去就那么几类。下面这张表是我自己整理的,遇到状况直接对号入座。

现象根因处理方式
圈复杂度比预期小多个return没加虚拟出口加虚拟出口节点,所有出口连到它
判定节点法和条件数法结果不一致复合条件的粒度没统一要么全画成一个菱形,要么全展开
图上出现孤立节点死代码没删或异常边漏画删掉不可达块,或用2P公式
流程图绕成一团看不清嵌套超过三层抽子流程框,主图控制三层以内
分支只标了“是”没标“否”习惯问题两条出口都标条件
循环体至少执行一次的逻辑画错了前测试和后测试混淆while菱形在上,do-while菱形在下
工具生成的图密密麻麻没做函数级拆分按函数出图,另画调用关系图
图改一次要动半张纸用了纯图形工具换 DOT 或 PlantUML 这类文本化工具
独立路径有的一条都跑不通不可行路径在图上标注,测试计划里说明
多人协作时图对不上块首规则理解不一致先统一约定,写进团队规范

这十条里,我遇到最多的是第一条和第二条。第一条尤其隐蔽,因为圈复杂度少算 1 到 2 不会引起任何报错,只是覆盖率统计的数字对不上,往往等到上线前做质量报告时才被发现。

6.2 几个只有画过几十张图才会知道的技巧

从出口往入口倒着画,比顺着画快。顺着画的时候,你脑子里要同时装着“当前在哪”“还剩哪些分支没画”,很容易漏。倒着画的时候,每个return都是一个明确的起点,把它往回连到上一个汇合点,画完一个少一个,不容易乱。这个方法是我画一个三百多行的老函数时琢磨出来的,用了之后基本没再漏过分支。

先把异常出口统一堆在图纸右侧。正常控制流走中间,异常出口全部拉到右边排成一列,用虚线连过去。这样主线的可读性完全不受影响,而且一眼就能看出这个函数有几个异常出口。

节点标签带上源码行号。写B1 (L12-15)这样的格式,改代码的时候能快速定位到对应位置。我的习惯是块号放在左上角,行号范围放在标签第二行,这样图既是逻辑图,也是一份索引。

让一个不懂业务的人读你的图。这是最狠的自检方法。他读不通的地方,就是图有问题的地方,不是他理解能力有问题。我一般会找一个刚入职的同事,把图递过去,什么背景都不说,看他能不能在五分钟内把主干讲出来。

圈复杂度超过 15 就先重构再画图。一个函数圈复杂度到了 15 以上,你花两小时画出来的图,改完代码之后大概率要重画。不如先按职责把它拆成两三个小函数,每个的复杂度控制在 8 以内,再分别出图。这个顺序调换一下,能省掉大量返工。

给图配一张“块 - 源码行号”对照表。图本身不写行号的话,只适合讲逻辑;配上对照表,就变成了一份可以随代码演进的活文档。我现在的习惯是把这张表直接贴在图的下面,用 Markdown 表格维护,和代码放在同一个仓库里。

文本化工具是长期项目的唯一选择。我吃过一次亏:一个跑了两年多的项目,控制流图是用图形工具画的,二进制格式进了版本库,某次需要回溯“半年前的复杂度是多少”时,发现根本没法 diff,只能翻日志找旧版本截图。从那之后,所有需要长期维护的图我都用 DOT 或者 PlantUML 写。

最后分享一个我自己用下来最省事的组合:日常讨论用白板,快速定稿用 draw.io 出 PNG 给人看,需要长期维护的那部分用 DOT 写进仓库,每次代码评审时顺手更新。三套东西各管一段,不冲突。

关于这张图后续还能怎么用,我的经验是:控制流图算出来的独立路径集,可以直接接到单元测试的用例设计上,把它当成覆盖率的下界;圈复杂度则适合接到 CI 里,设一条红线,超过就让人在 PR 里解释原因。这两件事落地之后,图就不再是画完就扔的文档,而是变成了代码质量的一道闸门。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询