一份知识层的接口协议
📌 本部分为中文精读版作者的原创分析,原书没有对应章节,因此不提供英文对照。
一句话:本体就是“把一个领域里的名词、以及这些名词之间的关系,写成一本书面的、机器也读得懂的规矩册”。
先说个生活直觉。
你去一家新公司报道,人事给你一份《岗位说明书》。上面写着:公司有哪些部门、部门下面有哪些岗位、每个岗位管什么、岗位之间谁向谁汇报。有了这本册子,你不用问人就知道“我要找谁签字”。
本体就是某个领域的《岗位说明书》——只不过服务的对象不只是新员工,还有软件。
正式定义(Gruber, 1993,本书第 1 章引用的那个):
本体是对某个领域中术语及其相互关系的、显式的形式化规范。
(explicit formal specifications of the terms in the domain and relations among them)
拆开这四个词:
| 词 | 意思 |
|---|---|
| 术语 | 这个领域里有哪些“东西”。酒、葡萄品种、酒庄、产区…… |
| 关系 | 这些东西之间怎么挂钩。酒由酒庄生产、酒用葡萄酿、酒庄在某个产区…… |
| 显式(explicit) | 写下来,不是“大家心里都明白”。这一条是本体区别于“常识”的分水岭 |
| 形式化(formal) | 用机器能处理的语言写。这一条是本体区别于“一份 Word 文档”的分水岭 |
本书给的工作定义更细一点:本体 = 类 + 属性 + 约束;本体 + 一组实例 = 知识库。
这是本书作者自己强调的对照物,也是最容易搞混的一对——因为两边都在“设计类”,都在画类图,看起来一模一样。
原文的判词(第 1 章“About this guide”):
面向对象编程主要围绕类上的方法展开——程序员根据类的操作属性做设计决策;而本体设计者根据类的结构属性做这些决策。结果是:本体中类的结构以及类之间的关系,和一个相似领域的面向对象程序里的结构是不同的。
把这句话展开成两条质的不同:
| 面向对象设计 | 本体设计 | |
|---|---|---|
| 决定“要不要建这个类”的依据 | 这个类要执行什么操作、有什么方法 | 这个类有没有额外属性/约束/关系可说(见 4.4 节) |
| 典型提问 | “这个对象需要响应哪些消息?” | “关于它,我能说点什么关于它父类说不了的话?” |
举个具体例子。在 OO 里,你可能会建一个 Order 类,因为订单需要有 calculateTotal()、submit() 这些方法。而同一个业务领域在本体里,Order 是不是一个类,取决于有没有关于订单的、关于订单父类说不了的话——比如“订单有一个 hasLineItem 属性,最小值 1”。如果答案是没有,本体里就不建这个类。
OO 的继承是一种代码复用手段:SalaryEmployee extends Employee,图的是父类的字段和方法不用重写。OO 里用继承来复用实现,是常规操作,甚至有人批评说这会导致继承被滥用。
本体的继承是一条逻辑断言:
如果类 A 是类 B 的父类,那么 B 的每一个实例也都是 A 的实例。
这条断言必须客观上为真,而且它会驱动机器自动推理:知道某个对象是 PinotNoir,机器就能推出它必然是 RedWine,从而推出它必然有 tanninLevel 属性。不要“复用”这层意思,只有“必然如此”这层意思。
这条差异有一个非常锋利的可观察后果,就是原文 4.5 节的判据:
一个个体实例所属的类,不应该经常变。
在 OO 里,一个对象的类可以因为状态变化而换(状态模式就是这么干的);在本体里,类代表本质,实例频繁换类就说明这个“类”建错了——ChilledWine(冰镇酒)不能是类,因为一瓶酒可以今天冰着、明天不冰。
再补一个更近的对照:数据库 schema。
| 数据库 schema | 本体 | |
|---|---|---|
| 管理什么 | 数据存哪、什么类型、什么约束 | 概念是什么、概念之间什么关系 |
| 能不能表达“两个表其实是上下级分类” | 不能,只能靠外键模拟 | 能,就是类层次本身 |
| 换一个领域要改什么 | 重写整个 schema | 换一份本体,引擎不动(原文第 1 章“分离领域知识与操作知识”说的就是这件事) |
痛点的名字叫“语义断层”——数据能拿到,但拼不起来;规则写在代码里,但改不动。
本书第 1 章给了五条动机,每一条对应一类具体的、会出事故的失败场景。下面把“没有它会怎样”写具体:
场景:你想让程序去三个医疗网站抓信息,合并后回答“这个病人有没有用过某类药”。
没有本体会怎样:三个站点分别写“病人姓名 / 患者名 / name”,字段名对不上、层级对不上、枚举值对不上。程序拿到的是一堆语义无法对齐的字符串。结果不是“效率低”,而是——这个功能根本做不出来,或者退化成派人手工对照字段(每个新数据源都要重来一遍)。
有本体之后:三家共享同一套术语本体,聚合变成一行代码的事。
场景:风控规则、“满 300 减 50”、酒菜搭配规则。这些规则本来属于业务,但被硬编码(hard-code)进了程序。
没有本体会怎样:出事故的方式非常具体——
场景:你写了一个“按规格配置产品”的算法(给客户配一台定制 PC)。
没有本体会怎样:这个算法和“PC 部件”这个领域焊死了。哪天老板说“电梯也能这么配”(这是原文举的例子,出自 Rothenfluh et al., 1996),你的算法一行都用不上,得从头写一个配置电梯的。同一套逻辑,写 N 遍。
有本体之后:算法是通用引擎,把“电梯部件”的本体喂进去,同一个算法就能配电梯。写得对的话,一行代码都不用改。
场景:每个团队做项目,都要表示“时间”这个概念。
没有本体会怎样:N 个团队写出 N 套时间模型(有人用区间、有人用时间点、有人用相对度量),互相接不上。别人已经做细的时间本体完全白费。
场景:你凭直觉建了一个 500 个类的本体。
没有本体会怎样:里面的类层次有环、有等价类重复、有兄弟不在同一层级——这些你肉眼看不出来。这些错误会一路潜伏到应用里,表现为“推理结果莫名其妙”。
有本体(且是形式化的)之后:机器可以替你查——这正是第 7 章提到的 Chimaera 干的事。
三个系统说三种话,功能根本做不出来。
找不到、改不动、懂业务的人没有修改权。
配 PC 的算法配不了电梯——同一套逻辑写 N 遍。
N 个团队写 N 套「时间」模型,互相接不上。
500 个类里有环、有等价重复——肉眼看不出来。
本体处在“领域知识”和“应用逻辑”之间的中间层——这是它最本质的定位。
三个关键性质:
以下任一条件成立,就该建本体:
| 条件 | 说明 | 原文出处 |
|---|---|---|
| 多生产者/多消费者 | 同一领域的知识要被 ≥2 个系统或团队共享 | 第 1 章“共享共识” |
| 假设需要显式化 | 领域规则会变,且改的人不是程序员 | 第 1 章“显式假设” |
| 算法要跨领域复用 | 同一套逻辑要服务多个领域 | 第 1 章“分离领域与操作知识” |
| 需要被机器批量校验 | 数据量大到人眼盯不过来 | 第 1 章“分析领域知识” |
| 要与外部系统对接 | 对方已经绑定了某个本体或受控词表 | 第 3 章第 2 步 |
这一条最重要,本书没有集中讲,但散落在多处,这里汇总:
禁用条件一:只有一个生产者、一个消费者、而且短期内不会变。
此时本体是纯开销。原文第 1 章那句“建本体往往不是目标本身”,反面就是——如果本体不能给任何下游带来收益,它就不该存在。具体判据:如果没有第二个消费者,而且假设不会变,那么把规则写在代码里更划算。
禁用条件二:领域本身还在剧烈变动、甚至还没被理解。
第 4.1 节那个仙粉黛的例子就是警告:本体是对世界的一份静态承诺,世界一变就得返工。在领域还没定型的时候把它冻结成本体,等于给探索上了一把锁。判据:如果这个领域“上个月刚换了玩法”,先别建。先做原型,等玩法稳了再本体化。
禁用条件三:用一次就扔的探索性分析。
原文 4.7 节的原则反过来用:“你不需要比你应用所需更特化——两个方向各多一层就够了。” 如果这次分析只需要临时答案,为一个临时问题建长期本体,是拿长期资产成本干短期活。
禁用条件四:把本体当成“领域百科”来建。
原文 4.7 节明确禁止:
本体不应该包含这个领域所有可能的信息。
本体不应该包含层次中类之间所有可能的属性和区分。
原文给的实例:酒标用什么纸、虾怎么烧——不该进本体。favorite wine(最爱的酒)这种关系也不该进,哪怕“完整的表示所有互连”听起来很诱人。
同一领域知识要被 ≥2 个系统或团队共享
领域规则会变,且改规则的人不是程序员
同一套算法要跨领域复用
数据量大到人眼盯不过来,需要机器校验
只有一个生产者、一个消费者,短期内不会变——纯开销
领域还在剧烈变动——给探索上了一把锁
用一次就扔的探索性分析——拿长期资产干短期活
想建成「领域百科」——酒标用什么纸、虾怎么烧,都不该进本体
| 输入 | 来源 | 说明 |
|---|---|---|
| 领域范围 | 第 1 步的四个问题 | 覆盖什么领域、干什么用、答哪些问题、谁维护 |
| 能力问题清单 | 领域专家 | 本体的“考卷”。是判断“建到多细就够了”的唯一硬指标 |
| 术语清单 | 第 3 步头脑风暴 | 一份未分类的、包含所有候选词的表——先不分类 |
| 现成本体 | 本体库 / 外部系统 | 第 2 步的复用检查;同时它们也是“我必须兼容”的约束 |
| 领域专家的判断 | 人 | 用来裁决“兄弟类在同一层级吗”“这个区分重要吗”这类机器答不了的问题 |
第1步 定范围 → 产出:【边界】。决定了后面所有"要不要加"的取舍标准
↓
第2步 查现成 → 产出:【起点】。决定从零建还是扩展
↓
第3步 列术语 → 产出:【原料池】。只收集,不分类
↓
第4步 建类层次 → 从原料池挑出"独立存在的对象"→ 类
↓ 用"is-a / kind-of"把类串成树
第5步 定义属性 → 原料池剩下的词 → 大多是属性
↓ 属性挂在"能挂的最高层"
第6步 定义约束 → 给每个属性配:基数 / 值类型 / domain / range
↓
第7步 建实例 → 选类 → 建实例 → 填属性值
↓
【本体 = 类 + 属性 + 约束】
【知识库 = 本体 + 实例】
注意两条被原文反复强调的传导特性:
一个三元组加一个集合:
| 符号 | 名字 | 内容 | 例子 |
|---|---|---|---|
| C | 类集合 | 概念 + 类层次关系 | {Wine, RedWine, Beaujolais, ...} |
| S | 属性集合 | 属性位 + domain/range | maker: Wine → Winery |
| F | 约束集合 | 对属性取值的约束(基数、枚举、互斥) | body ∈ {FULL, MEDIUM, LIGHT},基数 1 |
| I | 实例集合 | 具体的个体 | Chateau-Morgon-Beaujolais |
这个输出的独特之处:它同时是“给机器看的”和“给人看的”。 机器拿它做推理和校验,人拿它做沟通和查证。
得到 A:把领域知识变成可共享、可复用、可分析、可机器处理的一等资产。
牺牲 B:
| 牺牲了什么 | 具体代价 | 什么场景下会真的疼 |
|---|---|---|
| 前期成本 | 建模本身不产生直接业务价值,是纯投入 | 项目工期紧时,本体最容易被砍——砍掉之后一切都写进代码里,第 1 章那四个痛点全数复发 |
| 变更刚度 | 本体是对世界的一份承诺。世界变了,本体必须跟着改,而本体常常已被多个下游依赖 | 仙粉黛场景(原文 4.1 节):“所有仙粉黛都是红酒”当年是真的,后来出现了白仙粉黛,整个类的定位就得拆。而此刻可能已经有 5 个下游在用 Zinfandel ⊂ RedWine 这个断言 |
| 表达力 ↔ 推理成本 | 语言表达力越强(能写更复杂的约束),机器推理越贵;全于一阶逻辑的推理甚至是不可判定的 | 本体大到几千个类、约束互相咬合时,推理机会跑不动或算不完 |
| 显式化会逼你现在就做决定 | 写下来就意味着“放弃模糊”。而领域专家往往靠“看情况”工作 | 遇到“这条规则通常是这样,但有例外”时,本体不接受“通常”。你被迫要么写死、要么把例外也建出来——两条路都有代价 |
最疼的那一刀,是“变更刚度”和“显式化逼你做决定”的叠加。 原文 4.7 节那个 Experimenter 的例子就是现场:
把Experimenter建成BiologicalOrganism的子类,逻辑上为真(实验者确实是人,确实是生物有机体)。但这么一建,所有实验者实例就被塞进了体重、年龄、物种这些与实验场景完全无关的属性。
更要命的是,这个决定会误导后来的复用者:他看到Experimenter不是Person的子类,会以为是漏了,然后“顺手补上”——把你当初的设计意图彻底搞乱。
所以原文给的处方是:该省的关系就省,但省下的原因必须写进文档。
| 上游 | 喂什么 | 原文出处 |
|---|---|---|
| 领域专家 | 能力问题清单、术语清单、对“这两个概念是不是一回事”的裁决 | 第 1 步、第 3 步 |
| 现成本体库 | 可复用的本体(Ontolingua、DAML 库、UNSPSC、RosettaNet、DMOZ) | 第 3 章第 2 步 |
| 外部系统的既有本体/受控词表 | 硬约束——不是可选项,是兼容性要求 | 第 3 章第 2 步 |
| 形式化语言 | 承载本体的语言本身:RDF、DAML、Ontolingua、CLASSIC 方言 | 第 1 章、第 3 章第 2 步 |
| 知识表示系统的能力 | 决定命名约定能不能这么定(名字空间、大小写敏感性、分隔符) | 第 6 章 |
| 下游 | 接过去干什么 | 原文出处 |
|---|---|---|
| 具体应用 | 酒菜搭配推荐、回答服务员和顾客的提问、分析酒窖库存并建议采购 | 第 1 章末 |
| 知识库 | 本体 + 实例 = 知识库,供问题求解程序消费 | 第 2 章 |
| 跨领域算法 | 通用配置引擎把本体当“可插拔知识包”——配 PC、配电梯,同一个算法 | 第 1 章 |
| 其他本体 | 被复用、被扩展。通用本体(如 UNSPSC)被扩展出具体领域版本 | 第 1 章、第 3 章第 2 步 |
| 分析工具 | Chimaera 这类工具对演进中的本体跑诊断:查逻辑正确性、查常见设计错误 | 第 7 章 |
| 智能体 | 多站点信息抽取与聚合,回答用户查询,或作为其他程序的输入数据 | 第 1 章 |
| 人 | 命名约定、文档、同义词表——供新用户理解领域术语 | 第 1 章、第 6 章 |
这个生态里有一个反馈环,是本书最看重的一件事:
本体 → 应用 → 用起来的效果 → 回头修本体
原文结论章说得很直白:“布丁好不好,吃了才知道”——我们只能通过在实际应用中使用本体,来评估它的质量。
用你脑子里已有的东西给本体找个位置。
你写过或者用过 API 吧?两个系统要对话,得先约定好:接口叫什么名、传什么参数、返回什么格式。这份约定就是协议。
本体就是“知识层的接口协议”。
这个比方的三个延伸都很准:
| 协议的性质 | 本体的对应性质 |
|---|---|
| 协议不关心你怎么实现 | 本体不关心应用怎么算 |
| 协议一旦发布就很难改(有兼容性负担) | 本体一旦被多个下游用上就很难改(4.1 节仙粉黛场景) |
| 协议版本升级需要“迁移” | 本体演化需要对下游做迁移(4.1 节) |
| 内部小项目不需要协议 | 禁用条件一:只有一个生产者一个消费者时不建本体 |
TP311 是“程序设计”)TP3 是“计算机技术”,TP311 在它下面)这个比方能帮你理解 4.6 节那个产区例子:“阿尔萨斯”不是“法国”的一种,但“阿尔萨斯产区”是“法国产区”的一种。 就像 TP311 是 TP3 的一种分类,而不是“巴黎是法国的一种”。
你会用 Excel 记账,但你也知道 Excel 管不住“这一列只能填红/白/桃红”。数据库能管(加约束)。
那本体比数据库多了什么?多了“分类”这件事本身。
数据库里,“红酒”和“白葡萄酒”只是某一列的两个值,数据库不知道“红酒”和“白酒”是互斥的、“红酒”和“甜酒”可以重叠。本体知道——这就是 4.8 节的互斥声明,和 4.3 节的多重继承。有了这两样,机器才能替你查错。
本体就是知识层的接口协议:两边独立开发,只要协议不变就永远能接上。
类 = 分类号,层次 = 上下级,实例 = 具体一本书,属性 = 书脊字段。
数据库不知道「红与白互斥」。本体知道——多了「分类」这件事本身。
下面用一个能手工验算的小本体,把八步走一遍。案例来自本书的酒本体,但只取 10 个类、7 个属性——小到可以口算,大到能体现机制。
领域范围(第 1 步):酒与食物搭配推荐。能力问题(第 1 步):5 道题(下面 9.5 会用来算覆盖率)。
类与直接父类(第 4 步的产出):
| 类 | 直接父类 |
|---|---|
Wine | (无,顶层类) |
WhiteWine | Wine |
RedWine | Wine |
DessertWine | Wine |
Beaujolais | RedWine |
Bordeaux | RedWine |
Medoc | Bordeaux |
Pauillac | Medoc |
Margaux | Medoc |
Port | RedWine, DessertWine ← 多重继承(4.3 节) |
属性(第 5、6 步的产出):
| 属性 | domain(挂在哪个类上) | range / 约束 |
|---|---|---|
color | Wine | {RED, WHITE, ROSE},单基数 |
body | Wine | {FULL, MEDIUM, LIGHT},单基数 |
maker | Wine | Instance,允许类 Winery,单基数 |
grape | Wine | Instance,允许类 WineGrape,最小基数 1 |
tanninLevel | RedWine | {LOW, MODERATE, HIGH},单基数 |
sugar | DessertWine | 定值 SWEET |
produces | Winery | Instance,待定的 range ← 9.4 要化简它 |
公式 1:祖先的传递闭包(对应第 4 步“层次是可传递的”)
公式 2:继承的属性(对应第 5 步“子类继承父类的属性”)
注意公式 2 用 ∪(并集)而不是加法——因为多重继承时,两个父类可能有同名的属性(如Port从RedWine和DessertWine都继承到color),并集保证只算一次。
公式 3:子类数量守卫(对应第 4.2 节的两条准则)
输出 1 表示需要人工复核(不是“必定错了”,是“去看一眼”)。
公式 4:domain / range 化简(对应第 6 步的三条规则,规则一的形式化)
读法:把所有“祖上也在这个集合里”的元素删掉——只留最一般的那个。
公式 5:能力问题覆盖率(对应第 1 步的“考卷”)
取值 [0,1],越高越好。这个数字直接指导“本体还需要补什么”。
① 祖先闭包(公式 1)
| 类 | Anc | 个数 |
|---|---|---|
Wine | ∅ | 0 |
WhiteWine | {Wine} | 1 |
RedWine | {Wine} | 1 |
Beaujolais | {RedWine, Wine} | 2 |
Bordeaux | {RedWine, Wine} | 2 |
Medoc | {Bordeaux, RedWine, Wine} | 3 |
Pauillac | {Medoc, Bordeaux, RedWine, Wine} | 4 |
DessertWine | {Wine} | 1 |
Port | {RedWine, DessertWine, Wine} | 3 |
口算验算:Pauillac往上数——Medoc(1) →Bordeaux(2) →RedWine(3) →Wine(4)。四层,对上了。
多重继承的验算:Port有两个父类,但 Anc 只有 3 个而不是 4 个——因为Wine被两条路都走到,集合自动去重。这就是公式 1 用 ∪ 不用 + 的原因。
② 继承的属性(公式 2)
Local:Wine 加 {color, body, maker, grape};RedWine 加 {tanninLevel};DessertWine 加 {sugar};其余类不加。
| 类 | 计算 | |Slots| |
|---|---|---|
Wine | {color, body, maker, grape} | 4 |
RedWine | 上面 ∪ {tanninLevel} | 5 |
DessertWine | 上面 ∪ {sugar} | 5 |
Pauillac | 同 Medoc 同 Bordeaux 同 RedWine | 5 |
Port | Slots(RedWine) ∪ Slots(DessertWine) | 6 |
验算多重继承:5 + 5 = 10,但结果只有 6——因为 4 个属性(color、body、maker、grape)被两条父类路径共享,所以 10 − 4 = 6。“是 5+5 还是 6”,这就是 ∪ 和 + 的区别。
③ 子类数量守卫(公式 3,阈值:=1 或 >12 报警)
| 类 | DirectSub | 个数 | Alert |
|---|---|---|---|
Wine | {WhiteWine, RedWine, DessertWine} | 3 | 0 |
RedWine | {Beaujolais, Bordeaux, Port} | 3 | 0 |
DessertWine | {Port} | 1 | 1 ⚠️ |
Bordeaux | {Medoc} | 1 | 1 ⚠️ |
Medoc | {Pauillac, Margaux} | 2 | 0 |
⚠️ 两处命中原文 4.2 节的警告(跑代码时第二处才浮出来,手工列表里差点漏掉——这正好说明为什么需要机器守卫)。这不是“必定错了”,而是两个需要复核的信号,而且两条的复核结论方向相反:Bordeaux(1 个子类:Medoc)——两种可能:(1) 如果Medoc和Bordeaux其实等价 → 删掉(原文图 6a 的情形);(2) 波尔多底下本还该有别的子类(如St.Emilion、Graves)→ 本体不完整,继续补。DessertWine(1 个子类:Port)——这个信号几乎是纯的“本体不完整”。现实中的甜酒远不止波特一种(原文图 7b 里就列了Sauterne、Sweet Riesling、Ice Wine)。
两个警告同形不同因,这就是为什么这条守卫只能报警、不能下判决。 它把你拽回来做复核——复核的结论由人给,不由规则给。
④ range 化简(公式 4)
produces 属性的值域,初版由某个开发者写成 D = {Wine, RedWine, WhiteWine}。逐个检查是否有“祖上也在 D 里”:
| x | Anc(x) | 祖上在 D 里吗? | 判决 |
|---|---|---|---|
Wine | ∅ | 否 | 保留 |
RedWine | {Wine} | 是 | 删除 |
WhiteWine | {Wine} | 是 | 删除 |
这 2 条被删掉的条目,就是原文 4.6 节吐属性的“重复劳动”。 更重要的不是省了 2 行,而是——没有这两行,以后就不会有人改漏。 假设某天要给RoseWine也加进来:D={Wine} 的版本不用改(RoseWine自动被覆盖),而 D={Wine, RedWine, WhiteWine} 的版本得记得再加一行。化简不是美观问题,是维护成本问题。
⑤ 能力问题覆盖率(公式 5)
| # | 能力问题 | 答得出来吗? | 依据 |
|---|---|---|---|
| q1 | 波尔多是红酒还是白酒? | ✅ 1 | Bordeaux 继承 color 属性 |
| q2 | 赤霞珠配海鲜吗? | ❌ 0 | 本体里没有 pairing 关系——缺一块 |
| q3 | 甜酒的糖分是什么? | ✅ 1 | DessertWine.sugar = SWEET(定值) |
| q4 | 哪家酒庄生产 Sterling Merlot? | ✅ 1 | maker / produces 逆属性对(5.1 节) |
| q5 | 纳帕仙粉黛哪几年份好? | ❌ 0 | 本体里没有 vintage 属性——缺一块 |
这个 0.6 就是下一步的行动清单。 q2 和 q5 答不出来,明确告诉我们两件事:要么补充本体(加pairing和vintage),要么接受“这个本体不服务这类问题”,并在文档里写清楚(原文 4.7 节的要求)。
不做这一步,你永远不知道自己建到什么程度了。
下面这段代码把上面的公式全部实现,可以直接编译运行(Java 17+)。
import java.util.*;
/**
* 本体审计器:实现本报告 9.2 节的五条公式
* 对应知识解剖八步:公式1/2 属第五步(白盒化),公式3/4/5 属第四步(流程定位)
*/
public class OntologyAudit {
// ---------- 输入:类 -> 直接父类(第四步 建类层次的产出)----------
static final Map<String, List<String>> SUPER = new LinkedHashMap<>();
static {
SUPER.put("Wine", List.of()); // 顶层类
SUPER.put("WhiteWine", List.of("Wine"));
SUPER.put("RedWine", List.of("Wine"));
SUPER.put("DessertWine", List.of("Wine"));
SUPER.put("Beaujolais", List.of("RedWine"));
SUPER.put("Bordeaux", List.of("RedWine"));
SUPER.put("Medoc", List.of("Bordeaux"));
SUPER.put("Pauillac", List.of("Medoc"));
SUPER.put("Margaux", List.of("Medoc"));
SUPER.put("Port", List.of("RedWine", "DessertWine")); // 多重继承(4.3 节)
}
// ---------- 公式1:祖先的传递闭包 ----------
// Anc(c) = Super(c) ∪ ⋃_{p ∈ Super(c)} Anc(p)
// path 用来检测类环(4.1 节"避免类环")——有环就会无限递归,这里让它显式报错
static final Map<String, Set<String>> ANC = new HashMap<>();
static Set<String> ancestors(String c, Deque<String> path) {
if (path.contains(c)) { // 出现环 = 本体已损坏
throw new IllegalStateException("类层次里有环: " + path + " -> " + c);
}
Set<String> cached = ANC.get(c);
if (cached != null) return cached; // 记忆化:同一个类只算一次
path.push(c); // 进入本层
Set<String> acc = new LinkedHashSet<>();
for (String p : SUPER.getOrDefault(c, List.of())) { // 遍历直接父类
acc.add(p); // 收下直接父类
acc.addAll(ancestors(p, path)); // 递归收下父类的父类
}
path.pop(); // 离开本层
ANC.put(c, acc);
return acc;
}
static Set<String> ancestors(String c) { return ancestors(c, new ArrayDeque<>()); }
// ---------- 公式2:继承的属性 ----------
// Slots(c) = Local(c) ∪ ⋃_{p ∈ Super(c)} Slots(p) —— 注意是并集,多重继承不会重复计数
static final Map<String, Set<String>> LOCAL = new LinkedHashMap<>();
static {
LOCAL.put("Wine", new LinkedHashSet<>(List.of("color", "body", "maker", "grape")));
LOCAL.put("RedWine", new LinkedHashSet<>(List.of("tanninLevel")));
LOCAL.put("DessertWine", new LinkedHashSet<>(List.of("sugar")));
}
static Set<String> slots(String c) {
Set<String> acc = new LinkedHashSet<>(LOCAL.getOrDefault(c, Set.of()));
for (String p : SUPER.getOrDefault(c, List.of())) {
acc.addAll(slots(p)); // 父类的属性往下传
}
return acc;
}
// ---------- 公式3:子类数量守卫 ----------
// Alert(c) = [|DirectSub(c)| = 1] ∨ [|DirectSub(c)| > 12]
static List<String> directSubs(String c) {
List<String> out = new ArrayList<>();
for (var e : SUPER.entrySet()) {
if (e.getValue().contains(c)) out.add(e.getKey());
}
return out;
}
static boolean alert(String c) {
int n = directSubs(c).size();
return n == 1 || n > 12; // 只报警,不下判决——需要人复核
}
// ---------- 公式4:domain / range 化简 ----------
// D' = { x ∈ D | ¬∃y ∈ D, y≠x, y ∈ Anc(x) }
static Set<String> simplify(Set<String> d) {
Set<String> out = new LinkedHashSet<>();
for (String x : d) {
boolean ancestorAlsoInD = ancestors(x).stream()
.anyMatch(y -> !y.equals(x) && d.contains(y));
if (!ancestorAlsoInD) out.add(x); // 祖上也在集合里 → 冗余 → 删
}
return out;
}
// ---------- 公式5:能力问题覆盖率 ----------
static double coverage(List<String> questions, Set<String> answerable) {
long hit = questions.stream().filter(answerable::contains).count();
return (double) hit / questions.size();
}
public static void main(String[] args) {
System.out.println("=== 公式1:祖先闭包 ===");
for (String c : SUPER.keySet()) {
System.out.printf(" Anc(%-12s) = %-45s |Anc|=%d%n",
c, ancestors(c), ancestors(c).size());
}
System.out.println("\n=== 公式2:继承的属性(含多重继承)===");
for (String c : List.of("Wine", "RedWine", "Pauillac", "Port")) {
System.out.printf(" Slots(%-10s) = %-58s |Slots|=%d%n",
c, slots(c), slots(c).size());
}
System.out.println("\n=== 公式3:子类数量守卫(=1 或 >12 报警)===");
for (String c : SUPER.keySet()) {
if (alert(c)) System.out.printf(" ⚠️ %s 只有/超过异常数量的直接子类: %s%n", c, directSubs(c));
}
System.out.println("\n=== 公式4:range 化简 ===");
Set<String> raw = new LinkedHashSet<>(List.of("Wine", "RedWine", "WhiteWine"));
System.out.println(" 原始 range : " + raw);
System.out.println(" 化简后 : " + simplify(raw));
System.out.println("\n=== 公式5:能力问题覆盖率 ===");
List<String> qs = List.of("q1:波尔多是红酒还是白酒", "q2:赤霞珠配海鲜吗",
"q3:甜酒的糖分是什么", "q4:哪家酒庄生产SterlingMerlot",
"q5:纳帕仙粉黛哪几年份好");
Set<String> answerable = Set.of(qs.get(0), qs.get(2), qs.get(3));
System.out.printf(" Cov(O) = %.2f (答不出:%s)%n", coverage(qs, answerable),
qs.stream().filter(q -> !answerable.contains(q)).toList());
}
}
运行结果(已在本机 OpenJDK 17 上实际执行,与 9.3 手工验算一致):
=== 公式1:祖先闭包 ===
Anc(Wine ) = [] |Anc|=0
Anc(WhiteWine ) = [Wine] |Anc|=1
Anc(RedWine ) = [Wine] |Anc|=1
Anc(DessertWine ) = [Wine] |Anc|=1
Anc(Beaujolais ) = [RedWine, Wine] |Anc|=2
Anc(Bordeaux ) = [RedWine, Wine] |Anc|=2
Anc(Medoc ) = [Bordeaux, RedWine, Wine] |Anc|=3
Anc(Pauillac ) = [Medoc, Bordeaux, RedWine, Wine] |Anc|=4
Anc(Margaux ) = [Medoc, Bordeaux, RedWine, Wine] |Anc|=4
Anc(Port ) = [RedWine, Wine, DessertWine] |Anc|=3
=== 公式2:继承的属性(含多重继承)===
Slots(Wine ) = [color, body, maker, grape] |Slots|=4
Slots(RedWine ) = [tanninLevel, color, body, maker, grape] |Slots|=5
Slots(Pauillac ) = [tanninLevel, color, body, maker, grape] |Slots|=5
Slots(Port ) = [tanninLevel, color, body, maker, grape, sugar] |Slots|=6
=== 公式3:子类数量守卫(=1 或 >12 报警)===
⚠️ DessertWine 只有/超过异常数量的直接子类: [Port]
⚠️ Bordeaux 只有/超过异常数量的直接子类: [Medoc]
=== 公式4:range 化简 ===
原始 range : [Wine, RedWine, WhiteWine]
化简后 : [Wine]
=== 公式5:能力问题覆盖率 ===
Cov(O) = 0.60 (答不出:[q2:赤霞珠配海鲜吗, q5:纳帕仙粉黛哪几年份好])
| 代码块 | 对应哪一步 | 为什么 |
|---|---|---|
SUPER 表 | 第五步·输入 | 这是第 4 步“建类层次”的产出,喂给审计器 |
ancestors() 的递归 + 记忆化 | 第五步·传导 | 实现公式 1。记忆化对应“本体是大图,同一个祖先会被重复问到” |
ancestors() 里的环检测抛异常 | 第四步·禁用条件 | 显式对应 4.1 节“避免类环”——有环在数学上就无解,必须硬失败 |
LOCAL 表只给 RedWine 加 tanninLevel | 第六步·代价权衡 | “挂在能挂的最高层”的落地。挂 Wine 会污染白酒 |
slots() 用 addAll 而非累加 | 第五步·传导 | ∪ 而非 +,保证多重继承不重复计数。Port 是 6 而不是 10 |
alert() 的 n == 1 || n > 12 | 第四步·启动条件 | 4.2 节两条守卫规则的直接编码。注意它只报警不下判决 |
simplify() 的 anyMatch | 第六步·代价权衡 | 公式 4 落地。对应原文“不要在 range 里同时列父类和子类” |
coverage() | 第一步·本质 + 第四步·流程定位 | 用能力问题反查本体完整度——“本体够不够用”的唯一客观指标 |
| 两条 ⚠️ 告警 | 第八步·旧知缝合 | 把“项目符号列表不该只有一个条目”这个排版直觉,变成了可执行检查 |