
上周帮同事过一份测试报告他画的“程序流图”里菱形套菱形判断框后面直接连着判断框圈复杂度算出来是 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 { rankdirTB; node [shapebox, fontnamemonospace]; B1 [labelB1: if (amount 100)]; B2 [labelB2: fee 5]; B3 [labelB3: if (amount 500)]; B1 - B2 [labelT]; B1 - B3 [labelF]; B2 - B3; }DOT 的语法很简单digraph声明有向图节点用节点名 [属性]定义边用A - B [label...]定义。属性里最常用的就是label显示文字和shape形状。控制流图的节点一般用shapebox判断相关的块可以加个颜色区分但别搞太多颜色黑白打印出来会一塌糊涂。2.3 动笔之前的三个决定画之前先把这三件事定了能省掉后面大量返工。粒度定在哪一层。这个框里放“计算小计”还是放“小计 单价 * 数量”如果这张图要给业务方讲放前者如果是给自己梳理逻辑或者给测试写用例放后者。我的经验是先在业务粒度上画一版给人看再降一级画一版给测试两版都留着互相对照。边界画到哪里。异常路径到底画不画画的话画到哪一层我的默认做法是输入校验、外部依赖失败、超时重试这三类必须画因为它们直接影响控制流而内存不足、磁盘写满这类底层异常除非是核心系统否则统一用一句注释带过。把边界提前说出来比画完之后被人追问“文件不存在怎么办”要体面得多。命名统一。图上出现“用户”“会员”“客户”三个词指同一个东西是流程图最掉价的地方。动笔前列一张术语表把所有实体的名字定死图上只用一个词。提示粒度、边界、命名这三件事本质上是同一件事——先定“契约”再画图。契约定好了图就只是一次翻译工作契约没定图画到一半就会开始纠结然后推翻重来。3. 面向人读的程序流程图七步画法3.1 七步法总览配一个完整例子我画流程图基本固定走这七步用久了会发现它其实是在逼你把问题拆干净。下面用一个“会员订单结算流程”当例子走一遍。第一步用一句话写清职责。格式固定为“输入 → 处理 → 输出”。这个例子里就是读入一批订单明细按会员等级计算折扣累加后输出结算金额遇到非法数据跳过并记录。第二步列出所有输入输出点。输入有订单明细单价、数量、会员等级配置表输出有结算总额、错误日志、处理统计。这一步容易漏的是“配置项”很多人只列业务数据忘了配置也是输入结果图画完了才发现折扣率不知道从哪来。第三步画主干。先把最顺利的一条路画出来开始 → 读取明细 → 遍历计算小计 → 累加 → 输出总额 → 结束。这一步刻意不画任何分支和循环目的是先确认整体骨架对不对。第四步补分支。明细是否合法数量大于 0、单价非负会员等级决定折扣率普通全价、银卡九折、金卡八折。每补一个判断框就立刻把两条出口都标上条件。第五步补循环。遍历明细是前测试循环菱形判断放在循环体上方判断“还有下一条明细吗”是则进入循环体否则跳到汇合点。循环体末尾用一条回边指回菱形。第六步补异常与边界。文件不存在时记日志并直接结束明细为空时输出 0结算金额超过人工审核阈值时走审核分支。最后这条是业务分支不是异常分支但特别容易漏因为需求文档里往往写在附注里。第七步走查三问。每条路径是不是都有出口每个判断框是不是都标了两条出口的条件有没有哪个框里塞了两件事这三问能干掉九成以上的低级错误。3.2 顺序、选择、循环三种结构的画法要点所有流程图拆到底都是这三种结构的组合。把它们画标准了整张图就稳了。顺序结构最没技术含量但有个细节箭头别拐弯抹角。两个处理框之间如果是直线可达就不要绕一个大弯。流程线交叉是所有流程图可读性的头号杀手能避免就避免不能避免就用连接点断开。选择结构的关键是“合并点要明确”。两条分支出去之后最终必须汇到同一个节点上这个汇合点的位置要画清楚。多路选择对应代码里的switch有两种画法串多个两路菱形或者用一条带多个出口的判断。前者符合标准符号定义后者在读图时更省事但严格来说已经偏离标准了。我自己在内部文档里用后者对外交付的文档用前者。循环结构要区分前测试和后测试。前测试循环对应while的菱形在循环体上方循环体可能一次都不执行后测试循环对应do-while的菱形在循环体下方循环体至少执行一次。这两种画错逻辑就完全变了。我见过有人把do-while画成前测试结果测试按图写了“一次都不执行”的用例跑出来和预期不符查了半天才发现是图的问题。下面用 DOT 描述三种结构中最容易被画错的一个——后测试循环方便对照。digraph While { rankdirTB; node [shapebox]; Start [shapeellipse, label开始]; Body [label循环体]; Cond [shapediamond, label条件成立?]; End [shapeellipse, label结束]; Start - Body; Body - Cond; Cond - Body [labelT]; Cond - End [labelF]; }注意这里Body - Cond的顺序先执行循环体再判断条件所以条件不成立时循环体至少执行过一次。这就是后测试循环。3.3 嵌套、提前返回和异常的收口技巧嵌套超过三层就抽子流程。这不是审美问题是认知负荷问题。人一次能同时跟踪的分支层数很有限超过三层之后读者就开始靠猜了。做法是把内层的两三步逻辑整体换成一个双边矩形的“预定义处理”框里面写子流程名然后在图的下方或者单独一页画出这个子流程。这样主图永远保持在三层以内。提前返回要统一收口。代码里一个函数有三个return很常见画到流程图里就会有三条线各自指向“结束”框。这是允许的但建议把这些线统一从图的右侧或者下方走不要穿来穿去。更好的做法是在图的底部放一个统一的“结束”框所有 return 都汇聚到它。break和continue的处理容易被忽略。break是从循环体跳到循环出口画一条从当前分支到循环出口汇合点的线continue是跳回循环条件的判断点画一条回边指向循环菱形。这两条线如果画得含糊读者会以为它们和正常结束是一条路那逻辑就完全错了。异常路径要不要画我的判断标准是这张图的用途是不是包含测试设计或者故障分析。如果是异常路径必须画而且建议用虚线区分视觉上和正常控制流分开。如果只是给业务方看的流程说明异常路径可以统一收进一个“异常处理”子流程框里。最怕的是画了一半——主干图上有两条异常线另外三条藏在正文里没画出来这种图比完全不画还容易误导人。提示try/finally这类结构在流程图里最难画。我的做法是把 finally 块画成一个单独的处理框然后用虚线从 try 块的正常出口和异常出口各连一条线过去两条线上都标注同样的进入条件。虽然不符合严格的标准符号但读过的人都能一次看懂比硬套符号强。4. 面向分析的控制流图从源码到基本块4.1 基本块怎么切块首判定规则控制流图和我们上面画的流程图在构造方式上完全不同。它不是“我觉得该分成几步就分几步”而是有一套确定性的划分规则。换句话说同一个函数你和别人各自画画出来的控制流图必须一模一样不存在风格差异。基本块的定义是一段顺序执行的语句序列只有一个入口和一个出口。入口就是块的第一条语句出口就是块的最后一条语句中间不能有跳转进来或者跳转出去。划分基本块先要找出所有“块首”也就是入口语句。经典教材给了三条规则程序的第一条语句是块首。任何跳转语句的目标语句是块首包括条件跳转和无条件跳转的目标。任何紧跟在跳转语句之后的语句是块首因为条件不成立时会落到这里。找到所有块首之后从每个块首往下扫描直到遇到下一个块首之前中间那段就是一个基本块。对写代码的人来说这套规则翻译成源码视角会更直观下面这张对照表我常用来给别人解释。源码结构产生的块首函数的第一条语句入口块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是块首 B7return语句是汇合点也是块首 B8。块号对应语句成为块首的原因B1if (amount 100)函数第一条语句B2fee 5真分支目标B3if (amount 500)假分支目标B4fee 3真分支目标B5fee 0假分支目标B6if (is_vip)分支汇合点B7fee fee - 1真分支目标B8return fee汇合点同时是唯一出口再看边。B1 真走 B2假走 B3B2 执行完要跳过整个 else-if 链直接到 B6B3 真走 B4假走 B5B4 和 B5 都汇到 B6B6 真走 B7假走 B8B7 执行完汇到 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节点数变成 912 - 9 2 5。注意这里从 4 变成了 5多出来的 1 不是逻辑变复杂了而是分支数变多了——fee fee - 1之后直接返回和跳到统一出口再返回在控制流上确实是两条不同的路。下面是这段逻辑的 DOT 描述可以直接渲染成图。digraph CFG { rankdirTB; node [shapebox, fontnamemonospace]; B1 [labelB1: if (amount 100)]; B2 [labelB2: fee 5]; B3 [labelB3: if (amount 500)]; B4 [labelB4: fee 3]; B5 [labelB5: fee 0]; B6 [labelB6: if (is_vip)]; B7 [labelB7: fee fee - 1]; B8 [labelB8: return fee]; B1 - B2 [labelT]; B1 - B3 [labelF]; B2 - B6; B3 - B4 [labelT]; B3 - B5 [labelF]; B4 - B6; B5 - B6; B6 - B7 [labelT]; B6 - B8 [labelF]; 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(fN{n} E{e} P{p} V(G){v})跑出来是N8 E10 P1 V(G)4和手算一致。这个脚本的价值不在于省事而在于当你手画的图和工具跑的图对不上时可以逐个块、逐条边地核对快速定位是哪一步切错了。5. 圈复杂度计算与测试路径设计5.1 三种算法与互相校验圈复杂度不止一种算法但它们的适用前提不一样乱用就会算错。下面三种是最常用的。方法公式适用前提本案例结果边点法E - N 2P图已统一出口P 为连通分量数4判定节点法判定节点数 1每个判定恰好两条出口4条件数法简单条件数 1所有复合条件已展开成子图4前两种方法在本案例里对上了这很好。但第三种方法有个大坑必须单独说。假设有这么一个判断if (a b) { x(); } else { y(); }。如果你把一个复合条件画成一个菱形那么判定节点数是 11 1 2。但图上的边是入口→菱形、菱形真→x、菱形假→y、x→汇合、y→汇合一共 5 条边 5 个节点5 - 5 2 2。这里两种算法是一致的。如果你按短路求值的语义把它展开成两个菱形——先判断 aa 为真再判断 b——那就多了一层。这时候判定节点数是 22 1 3边点法算出来也是 3。但如果你还用“一个菱形”的图画法却用“两个简单条件”去套条件数法就会得到 3和图上实际能走出来的路径数对不上。这就是前面说的粒度不匹配问题。结论很简单图上画了几个菱形就用判定节点法复合条件拆开了才用条件数法。至于圈复杂度的经验阈值行业里流传的一套参考是1 到 10 属于简单维护起来轻松11 到 20 属于中等测试要花点心思21 到 50 属于复杂重构信号超过 50 基本没法完整测试必须先拆。这套数字不是硬标准但拿来给团队定一个“超过多少就必须重构”的红线非常好用。注意圈复杂度只反映控制流的复杂程度不反映业务逻辑的复杂程度。一个圈复杂度只有 4 的函数可能因为业务规则刁钻而极难维护反过来一个圈复杂度 30 的函数也可能只是机械的一长串判断。别把这两个概念混为一谈。5.2 导出独立路径基线集圈复杂度算出来是 4含义是“至少需要 4 条独立路径才能覆盖所有边”。接下来就是把它们找出来。方法叫“基线路径法”先选一条最简单的路径作为基准然后每次翻转一个判定节点的出口得到一条新路径直到覆盖完所有边。拿 4.3 节的例子来。基准路径选最容易理解的那条金额很小、不是会员。P1B1 → B2 → B6 → B8。对应amount 100为真、is_vip为假走fee 5然后直接返回。P2B1 → B2 → B6 → B7 → B8。相对 P1只翻转了 B6 的出口把is_vip改成真多走一步fee fee - 1。P3B1 → B3 → B4 → B6 → B8。相对 P1翻转了 B1 的出口走 else-if 分支amount落在 100 到 500 之间。P4B1 → B3 → B5 → B6 → B8。相对 P3翻转了 B3 的出口走最后的 elseamount大于等于 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 里解释原因。这两件事落地之后图就不再是画完就扔的文档而是变成了代码质量的一道闸门。