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 怎么算:把方法和字段当节点

算法四步:

  1. 节点 = 类的全部方法 + 全部字段。
  2. 方法 m 访问了字段 f → m—f 连边。
  3. 方法 m 调用了方法 n → m—n 连边。
  4. 连通分量数 = 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 延迟与故障率同时上升

最强的是发布耦合:两个服务必须一起发版,不管代码怎么分层都是一个部署单元。

度量的正确用法:三句话

  1. 找热点:排序取前 10。第 11 名以后没有行动价值。
  2. 定阈值:进 CI 做“不得更差”的门禁,LCOM4 与 CBO 取基线,只降不升(53.8 覆盖率与质量门禁 里同一套棘轮)。
  3. 看趋势:季度环比。连续三个季度上升才是真腐化(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 不知道拆哪,指标变成指责工具

关联

自检

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 只是噪声。