40.2 耦合与内聚度量
一句话
耦合是“改一处要动几处”,内聚是“一个模块里的东西是不是为同一件事服务”;两者都能算成数字,但数字只用来找热点、定阈值、看趋势,不用来给团队打分。
先给三条结论
- 耦合量边界上的边,内聚量边界内的团。 同一张依赖图,切法一换两个数一起动,必须一起看。
- 能落地的只有四个数:LCOM4(类内)、CBO/RFC(类外)、I/A/D(包与组件)、变更耦合(历史)。
- 阈值都是起点。 产出是“排序后的前 10 个热点”,不是“合格 / 不合格”。
三个最贵的误区
| 误区 | 现实 | 反例 |
|---|---|---|
| 低耦合 = 没有耦合 | 没有耦合就没有功能。耦合只在“数量 × 对面不稳定度 × 隐蔽度”上有害 | main 依赖 30 个包没问题——没人依赖它,改它不牵连谁 |
| 耦合 = 依赖个数 | 依赖 10 个稳定接口 < 依赖 1 个天天改的内部实现 | 依赖 10 次 io.Reader 是低耦合;依赖一次每周改的 internal/util 是高耦合 |
| 内聚 = 文件里东西少 | 内聚是“是否服务同一件事”,与体积无关 | 200 行的 Order 聚合根内聚很高;40 行的 util.go 装 5 个不相干函数,内聚最低 |
第三条要展开一句:小而杂的文件比大而专的更难维护,没有主题,谁都不敢删、也不知道往哪加;把 util.go 拆成 stringsx.go / timex.go 只是换文件名。
耦合七级:从“改一处动十处”到“互不相干”
Constantine 的经典分级,强度从高到低。级别越高,改动越容易外溢,也越难写出快测试——内容耦合与公共耦合的代码只能整段跑,替身无从下手(40.1 可测试性)。
| 级别 | 判据 | 强度 | 可接受场景 |
|---|---|---|---|
| 内容耦合 | 直接读写对方内部状态,绕过不变量 | 最强 | 几乎没有 |
| 公共耦合 | 共享全局可变状态 | 很强 | 只读配置、日志句柄 |
| 外部耦合 | 共享外部格式、协议、设备约定 | 强 | 不可避免,靠契约测试兜 |
| 控制耦合 | 传标志位让对方走不同分支 | 中 | 布尔参数少且稳定时 |
| 标记耦合 | 传整个结构体,实际只用两个字段 | 弱 | 同领域 DTO |
| 数据耦合 | 只传必要的原始值 | 很弱 | 理想状态,最多做到这一级 |
| 无耦合 | 只依赖参数与标准库 | 无 | 纯函数库 |
七级长什么样,各给一段:
// ① 内容耦合:把内部切片交出去,外部一 append 就破坏不变量
func (c *Cart) Items() []Item { return c.items }
// 调用方 cart.Items()[0].Price = 0 → total 与 items 立刻不一致
// ② 公共耦合:包级可变单例,任何包都能把它换掉
var DefaultDB *sql.DB
func Save(o Order) error { _, err := DefaultDB.Exec(saveSQL, o.ID, o.Amount); return err }
// 两个测试并行跑,一个换成了内存库,另一个的断言随机失败
// ③ 外部耦合:两个系统都依赖"金额用分、时间用 RFC3339"这个约定
func Decode(b []byte) (Order, error) {
var o Order
return o, json.Unmarshal(b, &o) // 约定没有版本号,对方加字段就炸
}
// ④ 控制耦合:调用方必须知道对方内部有几种分支
func Export(orders []Order, format string) ([]byte, error) {
switch format { // 每加一种格式,改这里 + 改所有调用方 + 重测
case "csv":
return toCSV(orders)
}
return toJSON(orders)
}
// ⑤ 标记耦合:只要一个字段,却要求整个聚合
func Total(o Order) int64 { return o.Amount }
// Order 加字段 → Total 的所有调用点重编译;值传递还多一次整结构拷贝
// ⑥ 数据耦合:签名只含真正需要的值,加字段不牵连
func Total(amounts []int64) int64 {
var s int64
for _, a := range amounts {
s += a
}
return s
}
// ⑦ 无耦合:输入输出全是值,测试不需要任何替身
func NormalizePhone(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "+86") }
一个反直觉的排序:④ 控制耦合被当成“只是个 bool”,它的坏处不在强度,而在改动半径——每加一个分支,所有调用方都要重新理解那个参数。② 公共耦合的坏处则在隐蔽:它不出现在函数签名里,只有测试互相污染时才暴露。
内聚七级:从“全塞一个文件”到“只为一件事服务”
| 级别 | 真实场景(Go 里的长相) | 判据 |
|---|---|---|
| 偶然 | util.go 里 MD5()、SendMail()、ParseDate() 并列 | 共同点只是“不知道放哪” |
| 逻辑 | Notify(kind string, ...) 里 switch 短信 / 邮件 / 站内信 | 由参数选一段,一次只走一条 |
| 时间 | Init() 里连数据库、建 Redis 池、读配置 | 只因“启动时都要做” |
| 过程 | ImportCSV() 里 读文件 → 校验 → 写库 | 顺序不能换,三步不共享数据 |
| 通信 | Pay()、Refund()、Close() 都读写同一个 status | 共享数据,操作不同 |
| 顺序 | Pay() 的输出正好是 Ship() 的输入 | 前一步的产出直接喂给下一步 |
| 功能 | Withdraw() 只改 balance / frozen,只维护一条不变量 | 所有元素为单一目标服务 |
逻辑内聚最常见,也最容易被误认为“设计过了”:
// 逻辑内聚:加一个渠道,要改 switch、改参数校验、改测试
func Notify(kind, to, msg string) error {
switch kind {
case "sms":
return sms.Send(to, msg)
case "mail":
return mail.Send(to, msg)
}
return nil
}
// 功能内聚:选择与发送分离,加渠道只加一个实现,调用方不动
type Channel interface{ Send(to, msg string) error }
func Notify(c Channel, to, msg string) error { return c.Send(to, msg) }
能算的数字:八个量一张表
| 度量 | 公式 | 物理含义 | 好的方向 | 起点阈值 | 怎么取数 |
|---|---|---|---|---|---|
| CBO | 引用的其他类数(字段类型 + 参数 + 局部变量 + 调用目标) | 向外伸了几只手 | 小 | >10 排查 | go/ast |
| RFC | 自身方法数 + 这些方法调到的方法数 | 能触发的行为面 | 小 | >50 | go/ast + 调用图 |
| LCOM4 | 方法 + 字段图的连通分量数 | 同时在服务几个目标 | =1 | ≥2 | 抓字段访问 |
| Ca | 依赖我的类/包数(入度) | 改动半径 | 与职责匹配 | 无绝对阈值 | 依赖图入度 |
| Ce | 我依赖的数量(出度) | 要理解多少外部 | 小 | 无绝对阈值 | 出度 |
| I | Ce / (Ca + Ce) | 不稳定度,0 最稳 | 与 A 配对看 | — | 上两个数 |
| A | 抽象类型数 / 总类型数 | 抽象度 | 越稳定越高 | — | 数 interface |
| D | abs(A + I − 1) | 离主序列的距离 | 小 | >0.5 检查 | 计算 |
六个独立量(CBO、RFC、LCOM4、Ca/Ce、A)+ 两个派生量(I、D)。包一级的公式与可运行代码在 26.6 模块化与包设计。补两个读法:Ca = 0 时 I 恒为 1,这个 1 没有含义,先过滤无入度的包;A 按类型个数计,400 行的 interface 与空 interface 同权,只能当趋势。
每个度量都有固定的误判姿势:
| 度量 | 典型误判 | 破法 |
|---|---|---|
| CBO | 依赖 10 个 io.Reader 被算成高耦合 | 用加权版 CBO_w = Σ I(依赖方),稳定依赖几乎不计分 |
| RFC | 生成代码(*.pb.go)方法数天然上百 | 统计前排除生成目录 |
| LCOM4 | getter/setter 计进方法(DTO 算成 1 个分量);db / logger 被所有方法碰到,把上帝类焊成 1 | 排除平凡 getter/setter;环境字段单列,不参与连边 |
| Ca / Ce | 只算静态 import,反射与 HTTP 调用看不见 | 补运行时调用图 |
| I / D | 对 Ca = 0 的叶子包算 I,得出“极不稳定” | 先过滤无入度包 |
| 变更耦合 | 批量提交、改名提交把无关文件焊在一起 | 剔除 >10 文件的提交 |
LCOM4 怎么算:把方法和字段当节点
算法四步:
- 节点 = 类的全部方法 + 全部字段。
- 方法
m访问了字段f→m—f连边。 - 方法
m调用了方法n→m—n连边。 - 连通分量数 = LCOM4。
=1说明所有方法通过字段或调用串成一团;≥2说明这个类在同时做几件事。
5 方法 3 字段的 Account,访问矩阵与连边:
balance frozen lastSeen
Deposit ●
Withdraw ● ●
Freeze ●
Touch ●
IsDormant ●
连边:Deposit—balance—Withdraw—frozen—Freeze (Withdraw 桥接了两个字段)
Touch—lastSeen—IsDormant
分量:{Deposit, Withdraw, Freeze}、{Touch, IsDormant} → LCOM4 = 2
该拆成哪两个类,分量直接给出答案:
// 类一:资金账。三个方法围着 balance / frozen 两个字段,维护"余额 + 冻结 ≤ 总额"这条不变量
type Account struct {
balance int64
frozen int64
}
func (a *Account) Deposit(amt int64) { a.balance += amt }
func (a *Account) Withdraw(amt int64) error { /* 校验 balance-frozen */ return nil }
func (a *Account) Freeze(amt int64) { a.frozen += amt }
// 类二:活跃度。它的变化原因(登录、休眠策略)和资金完全无关
type Activity struct {
lastSeen time.Time
}
func (a *Activity) Touch(now time.Time) { a.lastSeen = now }
func (a *Activity) IsDormant(now time.Time) bool { return now.Sub(a.lastSeen) > 90*24*time.Hour }
拆的判据不是“行数少了”,是变化原因分开了:休眠策略从 90 天改成 180 天,不再碰资金代码;资金加一条“冻结上限”,也不再碰活跃度。
下面是可跑的实现,用并查集,三个类一起算,并对比“关掉方法调用边”的结果:
package main
import (
"fmt"
"sort"
)
// method 是一个方法的静态信息:读了哪些字段、调用了哪些同类方法。
// 只要能从 AST 抽到这两样,LCOM4 就能算,不需要运行。
type method struct {
name string
fields []string
calls []string
}
type class struct {
name string
fields []string
methods []method
}
// lcom4:方法与字段都当节点,方法访问字段连边,方法调用方法连边,
// 连通分量数即 LCOM4。并查集,复杂度近似 O(N)。
func lcom4(c class, useCalls bool) [][]string {
n := len(c.methods) + len(c.fields)
parent := make([]int, n)
for i := range parent {
parent[i] = i
}
var find func(int) int
find = func(x int) int {
for parent[x] != x {
parent[x] = parent[parent[x]] // 路径减半
x = parent[x]
}
return x
}
union := func(a, b int) {
if ra, rb := find(a), find(b); ra != rb {
parent[rb] = ra
}
}
// 统一编号:字段占 [0, F),方法占 [F, F+M)
idx := make(map[string]int, n)
for i, f := range c.fields {
idx["f:"+f] = i
}
for j, m := range c.methods {
idx["m:"+m.name] = len(c.fields) + j
}
for _, m := range c.methods {
for _, f := range m.fields {
union(idx["m:"+m.name], idx["f:"+f]) // 方法—字段边
}
if useCalls {
for _, callee := range m.calls {
union(idx["m:"+m.name], idx["m:"+callee]) // 方法—方法边
}
}
}
// 按根分组,只报告方法集合(字段不出现,便于直接照抄拆分)
groups := map[int][]string{}
for j, m := range c.methods {
r := find(len(c.fields) + j)
groups[r] = append(groups[r], m.name)
}
out := make([][]string, 0, len(groups))
for _, g := range groups {
sort.Strings(g)
out = append(out, g)
}
sort.Slice(out, func(i, j int) bool {
if len(out[i]) != len(out[j]) {
return len(out[i]) > len(out[j])
}
return out[i][0] < out[j][0]
})
return out
}
func report(c class, useCalls bool) {
comps := lcom4(c, useCalls)
fmt.Printf("%-14s %d 方法 %d 字段 LCOM4=%d\n", c.name, len(c.methods), len(c.fields), len(comps))
for i, g := range comps {
fmt.Printf(" 分量%d(%d 个方法): %v\n", i+1, len(g), g)
}
if len(comps) >= 2 {
fmt.Printf(" → 一个类在做 %d 件事,按分量拆\n", len(comps))
} else {
fmt.Printf(" → 单职责\n")
}
}
func main() {
// 案例 A:记账类。balance/frozen 一组,lastSeen 一组,应为 2。
account := class{
name: "Account",
fields: []string{"balance", "frozen", "lastSeen"},
methods: []method{
{"Deposit", []string{"balance"}, nil},
{"Withdraw", []string{"balance", "frozen"}, nil},
{"Freeze", []string{"frozen"}, nil},
{"Touch", []string{"lastSeen"}, nil},
{"IsDormant", []string{"lastSeen"}, nil},
},
}
// 案例 B:订单服务。Pay 内部调用 checkLimit,把 limit 那一簇拽进主分量。
order := class{
name: "OrderService",
fields: []string{"amount", "status", "audit", "limit"},
methods: []method{
{"Pay", []string{"amount", "status"}, []string{"checkLimit"}},
{"Refund", []string{"amount", "status"}, nil},
{"Close", []string{"status"}, nil},
{"Describe", []string{"status"}, nil},
{"checkLimit", []string{"limit"}, nil},
{"ExportAudit", []string{"audit"}, nil}, // 由运维脚本调用,同类里没人调它
},
}
// 案例 C:高内聚对照。三个方法都围着同两个字段转。
retry := class{
name: "RetryPolicy",
fields: []string{"max", "backoff"},
methods: []method{
{"Allow", []string{"max"}, nil},
{"Delay", []string{"backoff"}, nil},
{"Step", []string{"max", "backoff"}, []string{"Delay"}},
},
}
for _, c := range []class{account, order, retry} {
report(c, true)
if c.name == "OrderService" {
fmt.Printf(" (关掉方法调用边再算一次:LCOM4=%d,差 1 就是 checkLimit 被调用边焊住的结果)\n",
len(lcom4(c, false)))
}
fmt.Println()
}
}
GO111MODULE=off go run main.go 的真实输出:
Account 5 方法 3 字段 LCOM4=2
分量1(3 个方法): [Deposit Freeze Withdraw]
分量2(2 个方法): [IsDormant Touch]
→ 一个类在做 2 件事,按分量拆
OrderService 6 方法 4 字段 LCOM4=2
分量1(5 个方法): [Close Describe Pay Refund checkLimit]
分量2(1 个方法): [ExportAudit]
→ 一个类在做 2 件事,按分量拆
(关掉方法调用边再算一次:LCOM4=3,差 1 就是 checkLimit 被调用边焊住的结果)
RetryPolicy 3 方法 2 字段 LCOM4=1
分量1(3 个方法): [Allow Delay Step]
→ 单职责
逐条读:
Account的LCOM4=2与手算一致:{Deposit, Freeze, Withdraw}是资金账,{IsDormant, Touch}是活跃度,分量可直接当拆分方案抄。OrderService算不算方法调用边,差 1:算上,checkLimit被Pay拽进主分量(2);不算,它是孤立的第 3 个分量(3)。报 LCOM4 必须声明算不算调用边,否则两队的数不可比。ExportAudit单独成一分量:审计导出和下单生命周期无关,拆出去后 LCOM4=1。RetryPolicy的Step同时碰max与backoff,把三者串成一团,LCOM4=1——高内聚在数字上的样子不是方法少,是没有方法落在别的团里。
架构级:I / A / D 怎么读
每个包按 (I, A) 打在平面上,主序列 A + I = 1:稳定的该抽象,不稳定的可以具体。
A 抽象度
1.0 ┤ ①抽象+稳定 ②无用抽象
│ I≈0, A≈1 I≈1, A≈1
│ ╲
0.5 ┤ ╲ 主序列 A+I=1
│ ╲
│ ╲
0.0 ┤ ③稳定+具体 ╲ ④适配器
│ I≈0, A≈0 ╲ I≈1, A≈0
└──────┬───────────┬──────────┬──→ I 不稳定度
0 0.5 1.0
斜线从左上(①稳定且抽象)拉到右下(④不稳定且具体),落在带上的都健康;② 与 ③ 是离带最远的两个角。
四个角的读法:
- ③ 稳定具体(I=0 且 A=0)最贵。 被一堆包依赖(改它牵连所有人),自身全是具体实现(没有接口可替换),这类包常叫
common/util。 - ② 无用抽象(I=1 且 A=1)是纯负债。 定义了端口却没有第二个实现,
D好看,只增加理解成本。 - ① 与 ④ 在主序列上,都健康。 ① 是被依赖的接口层;④ 是不稳定的适配器(
adapter/mysql),它就该随外部变化。 D只在有入度时有意义。Ca = 0的包I恒为 1,D只是噪声。
变更耦合:从 git 历史里挖真耦合
静态依赖看得见 import,看不见“这两个文件总是一起改”。后者才是维护成本的直接来源,也最容易算。
| 量 | 公式 | 读法 |
|---|---|---|
| 支持度 support | 同改次数 / 总 commit 数 | 这对文件在全仓库的“热度” |
| 置信度 confidence | conf(A→B) = 同改次数 / A 被改的 commit 数 | A 改了,B 有多大概率跟着改 |
| 判定 | conf > 0.6 且 同改 ≥ 5 | 一对隐藏耦合 |
置信度有方向:conf(A→B) = 0.9 而 conf(B→A) = 0.2,说明 A 是主、B 是从,该修的地方不同。
取数只要一条管道(--name-only 按 commit 分组):
git -c core.quotepath=false log --format='@@%H' --name-only -- src \
| awk '/^@@/{if(n>1) print prev; prev=""; n=0; next} NF{prev=prev"\t"$0; n++} END{if(n>1) print prev}' \
> commits.tsv # 每行一个 commit,制表符分隔文件列表
再用十来行代码数对:
import collections, itertools
pair, cnt = collections.Counter(), collections.Counter()
# 每行一个 commit,制表符分隔文件列表
for line in open("commits.tsv"):
files = sorted(set(line.rstrip("\n").split("\t")))
for f in files:
cnt[f] += 1
for a, b in itertools.combinations(files, 2):
pair[(a, b)] += 1
# support = n / 总 commit 数;confidence(A→B) = n / cnt[A]
一组跑通公式的算例(20 个文件、300 次提交;数字是构造的,只为把公式走一遍):
| 文件对 | 同改 n | A 改动数 | B 改动数 | support | conf A→B | conf B→A | 判定 |
|---|---|---|---|---|---|---|---|
order.go ↔ order_dto.go | 18 | 24 | 27 | 0.060 | 0.75 | 0.67 | ✅ 双向强耦合 |
price.go ↔ tax.go | 11 | 15 | 13 | 0.037 | 0.73 | 0.85 | ✅ 双向强耦合 |
config.go ↔ main.go | 7 | 9 | 12 | 0.023 | 0.78 | 0.58 | ⚠️ 单向,改 config 必带 main |
log.go ↔ log_test.go | 4 | 30 | 4 | 0.013 | 0.13 | 1.00 | ❌ 样本不足,且测试跟着改是应该的 |
只有这 3 对越线——这是正常的,隐藏耦合本就是少数派。
本机实测(这个仓库 v2/,184 次提交里 39 次碰过它):16 次单次改动超 10 个文件,剔除后只剩 21 次有效提交;滤波后最高一对 n=3(48.1-配置中心.md ↔ 48.2-服务发现.md,conf 0.75 / 1.00),滤波后 365 对里 n≥5 的一个都没有。所以 n≥5 必须按仓库规模重新标定。
💡 两条只有跑过才知道的坑:① 批量提交毁掉信号,一次 git add . 能让 50 个文件两两互连,必须先剔除。② 同目录同主题的高耦合是合理耦合,那对配置中心与服务发现本就该一起改;要查的是跨目录、跨层的对(41.3-缓存穿透-击穿-雪崩.md ↔ 44.14-分库分表.md 是 n=2、conf 1.00 / 0.67,量级不够但方向值得看)。多仓库场景下这些对会跨仓断裂,要靠 PR 号关联,见 51.4 单体仓库vs多仓库。
层切对了、类太大:用三件套量
分层切对之后最常见的是“层内上帝类”:UserService 2000 行、40 个方法,层隔离在这里失效(26.1 分层架构 列为第二形态)。这件事不能靠行数判断,要靠三个数:
| 信号 | 阈值 | 说明 |
|---|---|---|
| LCOM4 | ≥2 | 类内已经裂成几团,程序直接给出拆分方案 |
| 方法数 | >30(或字段 >10) | 职责面过大,配合 LCOM4 更准 |
| 变更频率 | 90 天内进仓库前 10%(约 ≥40 次) | 改得最勤,耦合成本被放大最多次 |
判定规则:三项中两项越线就拆。只有 LCOM4 ≥2 而另两项都低,往往是两个小类恰好放一起,等下次改到再顺手动;只有变更频率高而 LCOM4=1,说明它是热点但内聚尚可,该做的是拆方法粒度或加缓存,不是拆类。
⚠️ 先排除环境字段,再看 LCOM4——db 被所有方法访问时,整个类必然焊成一个分量。
微服务级:耦合信号 → 度量 → 阈值
服务拆开后,耦合从编译期搬到运行期,量法全变。参考 30.2 服务粒度 的粒度判据,把信号逐个落成数字:
| 耦合信号 | 度量 | 阈值 | 说明 |
|---|---|---|---|
| 同步调用 | 同步依赖的下游服务数 | >5 警戒 | 每多一跳,P99 叠加一次 |
| 共享库 | 跨服务共用的内部包数 | >0 要解释 | 共用 DTO 可以,共用业务规则就是分布式单体 |
| 共享数据库 | 写同一 schema 的服务数 | ≥2 即反模式 | 见 30.3 分布式单体反模式 |
| 共享同一张表 | 写同一张表的服务数 | ≥2 | 改表结构要协调 N 个团队 |
| 发布耦合 | 近 20 次发布中“必须一起发”的服务对 | ≥3 次 | 无法独立发布 = 事实上一个服务 |
| 调用链深度 | 一次请求跨服务跳数 | >3 | 延迟与故障率同时上升 |
最强的是发布耦合:两个服务必须一起发版,不管代码怎么分层都是一个部署单元。
度量的正确用法:三句话
- 找热点:排序取前 10。第 11 名以后没有行动价值。
- 定阈值:进 CI 做“不得更差”的门禁,
LCOM4与CBO取基线,只降不升(53.8 覆盖率与质量门禁 里同一套棘轮)。 - 看趋势:季度环比。连续三个季度上升才是真腐化(58.2 技术债管理 的债台账可直接吃这份排序)。
禁止:指标排名次(团队会优化指标而不是结构)、绝对值一刀切(模块间合理基线差 5 倍以上)、为好看而拆类(LCOM4 从 2 变 1,却多出两个 30 行、互相调用的类,耦合只换了地方)。
反模式
| 反模式 | 后果 |
|---|---|
| 用 LCOM 考核工程师 | 抢着拆小类,类数量翻倍、调用链变长,耦合更高 |
| 把 getter/setter 计进方法数 | DTO 被算成 LCOM4=1,上帝类被平均掉 |
| 只算静态依赖,不算变更耦合 | 看不见“改 A 必改 B”,而它是维护成本主体 |
追求 I = 0.5 的“完美” | 主序列是允许区,为凑 0.5 造无用 interface |
为降低 Ce 把代码复制一遍 | 出度降了,逻辑两处维护,缺陷率翻倍 |
| 拿 CBO 当 KPI 互相甩锅 | 依赖改成反射 / 事件总线,耦合变隐蔽 |
| 度量一次就再也不看 | 基线过期,门禁变成“永远通过” |
| 只报数字不报拆分方案 | 知道 LCOM4=3 不知道拆哪,指标变成指责工具 |
关联
- 前置:26.1 分层架构、26.6 模块化与包设计
- 并列:40.1 可测试性
- 下游:53.8 覆盖率与质量门禁、58.2 技术债管理、51.4 单体仓库vs多仓库
- 并列:30.2 服务粒度、30.3 分布式单体反模式
自检
Q1 一个类 LCOM4 = 1、1200 行、60 个方法、上季度改了 41 次。能说它高内聚吗?该动哪一刀?
▸ 答案:不能。LCOM4 只测方法是否通过字段成团;只要有一个被所有方法访问的 db,整个类就被焊成一个分量,数字好看而职责没少。另两项已越线:60 个方法(>30)、41 次变更(热点)。第一刀是排除环境字段重算 LCOM4,按分量拆。
Q2 团队把 CBO 做成季度 KPI,一季下来均值从 8 降到 3,交付速度反而变慢。为什么?
▸ 答案:CBO 只统计静态可见的引用,“做低”它几乎零成本:改反射或全局注册表、包一层 facade 让计数只落在它身上、复制一份代码。三种都让数字下降而耦合不变。指标一挂钩考核,团队优化的就是指标本身;正确位置是排序找热点 + CI 棘轮。
Q3 变更耦合里挖出一对 conf = 1.00、n = 40 的跨目录文件,一定是设计问题吗?
▸ 答案:不一定,先过三个问题:① 是否同一代码生成器的产物(.pb.go 与 proto)——那是正确的耦合;② 是否由一次批量提交造成(先剔除 >10 文件的提交);③ 谁是“主”(conf A→B 高而反向低,说明 A 是主)。都排除后仍是跨目录、跨层的双向高置信度,才是设计问题:通常缺一个中间抽象,两边都在直接改同一份数据结构。
Q4 有人把 A = 0.5、I = 0.5、D = 0 的包当成“最健康”,这个理解错在哪?
▸ 答案:错在把主序列当成最优点。A + I = 1 是允许区,表达“稳定就别具体、不稳定可以具体”的方向约束,沿线任何一点都合规。被 30 个包依赖的核心包正解是 I≈0、A≈0.6,不是硬凑 0.5 造抽象;只被一个适配器依赖的包,I≈1、A=0 完全健康。且 Ca = 0 的叶子包 I 恒为 1,D 只是噪声。