你其实是立法者
来看一行你写过一万遍的代码:
cpp
class Wall { ... };
就在敲下这行字的那一秒,你做了一件哲学家吵了两千四百年都没做成的事:你立法规定了一个世界里存在什么东西。
你规定了"墙"存在;规定了它有厚度、有材质、归属于某个楼层;规定了它不能凭空消失。然后你按下编译,一台机器会逐字逐句检查你的世界是否自洽------不自洽,拒绝运行。
这件事有个名字,叫本体论 (ontology)。而那份清单,在编程语言里有个更朴实的名字------类型。
你写下的类型,就是你的世界的边界。类型即世界。
没那么玄。它拆开就两样东西:
- 一份清单:这个世界里有哪些种类的东西?
- 一套规矩:这些东西必须满足什么?
就这些。全篇文章都是这两句话的展开。
一、规矩的作用不是描述,是排除
先说第二样,因为它最容易被误解。
你写下"一栋楼至少有一层",看起来是在描述事实。其实不是------你是在把"零层的楼"这个可能性从世界上删掉。
你写下"退款金额不能超过订单金额",你就删掉了"超额退款"这个可能。
建模的全部工作,就是不断加规矩,直到不想要的可能性都被删光。
这句话是全文的枢纽,后面会反复回来。记一下。
那 AI 为什么需要这东西
因为机器不知道世界有什么。
人走进一栋楼,不需要谁告诉他"这是墙、那是门"------他有几十年的常识。机器一个字都没有。你必须在喂数据之前,把"世界里有什么、它们之间什么关系"白纸黑字写下来,机器才能开始干活。
这份写下来的东西,AI 领域就管它叫 ontology 。1993 年 Tom Gruber 给了个标准定义,翻译成人话就是:把大家脑子里模模糊糊的那套理解,写成机器能读的规矩。
这里有个很妙的地方:
哲学的本体论想发现 世界到底有什么,吵了两千年没吵出结果。AI 干脆不吵了------我们不追求正确,我们追求够用且大家同意。
于是形而上学变成了接口契约。这大概是人类思想史上最实用的一次"投降"。你可能已经在公司里见过它的样子:领域模型评审、数据字典对齐、接口 schema 定版------这些全是本体论谈判。两个系统对接不上,九成不是技术问题,是两个团队的世界观不一样。
顺便说清楚"幻觉"
大模型凭空编论文、编法条,表面是"记错了",准确说法其实是另一回事:
它没有一个可靠的机制,去判断一个东西到底存不存在。
它只知道"在这个上下文里,接这句话的概率高不高"。概率高 ≠ 真的有。
所以给它配知识库、知识图谱(也就是 RAG 那一套)为什么管用?不是"给它加了知识",是给它的存在感装了一个外部裁判------查得到就有,查不到就没有,可验证、可追溯。
二、编程语言,也是一套本体论
现在是关键一步。
既然本体论 = 清单 + 规矩,那编程语言里,什么对应清单,什么对应规矩?
- 清单 = 类型系统。 你能写
Wall w;,你的程序世界里就有墙。如果你只有double,那"钱"在你的程序里就不存在------你只能用数字假装它是钱,然后所有人都会在某个深夜为这个假装付出代价。 - 规矩 = 约束 :
const、引用、private、static_assert、以及类型本身。
有个特别干净的判据,哲学家蒯因 1948 年给出的:
想知道一个人承认什么存在,别听他说什么,去看他的变量能取什么值。
这句话在编程语言里是字面成立的:一个变量能绑定什么,这门语言就承认什么存在。
所以选语言,本质上是在选世界观:
| 语言 | 它认为世界由什么构成 |
|---|---|
| C | 一大块内存,和一个个地址 |
| Java / C# | 对象(几乎一切都是对象) |
| Haskell | 不变的数学值;副作用被关进隔离区 |
| Python / JS | 运行时对象,"能不能这么用"比"是什么"更重要 |
| C++ | 你说了算 |
最后一行是重点,第三节整节都在讲它。
三、四层:世界、人、代码、机器
这是全文的主干。你写的软件,本质上是四层之间的翻译:

图 1 · 四层翻译。往下三次,每次都有损失;往上一次,是你的代码改变现实。
往下走,三次翻译,每次都有损失
第一次:世界 → 人。 那面真实的墙有无限的属性------温度、湿度、每一条微裂缝。你脑子里只留下五个:位置、厚度、材质、是否承重、归属楼层。
这一步的损失是必需的。不舍弃就没法思考。但它也是所有误差的起点。
第二次:人 → 代码。 你把概念写成类。这一步既丢又加:
- 丢:专家没说出口的经验("这种墙在南方要加保温层"------他脑子里有,没写进需求)
- 加:人根本没想过的东西(墙怎么复制?怎么销毁?异常发生时它怎么办?)
所以需求永远做不对。这不是沟通问题,是这两次翻译天生不可逆。 领域专家说不清,程序员做不准,都不怪人,怪翻译本身。
第三次:代码 → 机器。 编译。你的 Wall 在下降途中一路掉身份:
| 阶段 | 长什么样 | Wall 还在吗 |
|---|---|---|
| 源码 | class Wall { ... }; |
完整存在 |
| 中间代码 | 内存布局描述 | 降级成"怎么摆" |
| 汇编 | mov rax, [rdi+8] |
没了,只剩偏移量 |
| 机器码 | 48 8B 47 08 |
没了,只剩字节 |
到了最底层,你的墙根本不存在。内存里只有字节。 "Wall" 只是你和编译器之间的一个约定------编译一结束,运行中的机器里一面墙都没有。
这顺便解释了 C++ 那条著名的"零成本抽象"到底在说什么:抽象在编译期真实存在(编译器靠它检查你),在运行时彻底消失(一个字节都不给你留)。 所以 std::sort 能比 C 的 qsort 还快------它那个比较器被内联进了排序循环,在机器码层面根本不存在那个函数。
这套模型能解释两件你天天遇到的事
第一,抽象为什么会泄漏。 Joel Spolsky 那条著名定律(所有抽象都会泄漏),用这套话说就几乎是废话:底层的东西是物理存在的,你压不住它,它迟早顶穿你的抽象。
float 不是实数(所以 0.1+0.2 != 0.3)、int 不是整数(所以会溢出)、引用背后是地址(所以会悬垂)。每一次泄漏,都是底层在提醒你:我比你想的更真实。
第二,bug 到底是什么。
bug = 这四层之间对不上。
于是调试变成了一件有章可循的事------顺着症状往上反查:
| 症状 | 哪两层对不上 |
|---|---|
| 编译不过 | 代码层自己跟自己矛盾 |
| 崩溃 / UB | 代码层承诺了机器层根本没有的东西 |
| 能跑,但结果算错 | 代码正确实现了一个错误的模型 |
| 能跑,用户说不对 | 人脑子里的模型跟真实业务对不上 |
最后一行值得停一下:那不是 bug,是理解错了。改代码没用,得回去重新谈。
四、C++:把"存在"这件事拆成四个问题问你
现在讲 C++。
别的语言在"世界由什么构成"上给你做选择题,而且答案是唯一的:Java 说一切皆对象,Haskell 说一切皆值。
C++ 不替你做主。它把四个问题摆在你面前,让你一个一个自己答。
问题一:这东西什么时候算活着?
cpp
void process(const char* path) {
std::ifstream f(path); // 出生:文件打开
read_something(f);
// 哪怕这里抛异常......
} // 死亡:文件必然关闭
C++ 的规矩很硬:存在即持有。 一个对象活着,它就持有它的资源;它一死,资源必然释放。不是靠你记得写 finally,是语言焊死在类型上的命运。
这就是 RAII,也是 unique_ptr、lock_guard 背后同一条原则。C++ 标准里甚至专门有一章精确规定"对象从哪一刻开始算活着、到哪一刻算死了"。
对比一下:带 GC 的语言里,对象什么时候死是不确定的。C++ 里,一个对象的死亡时刻是编译期就定下来的事实。
问题二:两个东西,什么时候算"同一个"?
这个问题最容易被忽略,也最深。
cpp
// A 类东西:内容一样就是同一个
Point a{1, 2}, b{1, 2};
a == b; // 真。不存在"两个不同的点 (1,2)"这种说法,
// 就像不存在"两个不同的数字 3"
// B 类东西:地址一样才算同一个
Building x{1, "A座"}, y{1, "A座"};
&x != &y; // 真。属性一模一样也白搭,它们就是两栋不同的楼
前者叫值语义 (身份由内容决定),后者叫引用语义(身份由位置决定)。
关键在这里:在 C++ 里,这是你的选择。 Java 里你没得选(对象一律引用语义),Haskell 里你也没得选(一切皆值)。C++ 说:这个东西的本质是什么,你比我清楚。
代价是你必须想清楚。而一旦想清楚了,拷贝、移动、比较、析构这四件事该怎么写就全定了------这就是所谓的"三/五法则"。它不是要背的规则,是这一个问题推导出来的必然结果。
顺带说一个由此而来的、很有意思的设计:
cpp
std::unique_ptr<Building> a = std::make_unique<Building>();
Building* raw = a.get();
auto b = std::move(a); // 那栋楼没动,还在原地
// 动的是"谁持有它"这件事
std::move 什么都不移动。它只是说了一句:"这东西我让给你了,我不再持有它。" 存在者本身没变,变的是它跟世界的关系。
问题三:它是会变的,还是不变的?
cpp
void inspect(const Building& b); // 承诺:我看,但我不动它
const 就是"不变"的立法。而 mutable 是它留的一个精巧的口子:
cpp
class Cache {
public:
Value get(const Key& k) const { // 逻辑上:查询不改变我
std::lock_guard lk(mutex_); // 但物理上得加锁,可能还要写入缓存
return lookup(k);
}
private:
mutable std::mutex mutex_; // ← 变化中的不变者
};
mutable 说的是:这东西逻辑上没变,但它的比特必须允许动。 这是"本质不变、表象可变"这个老区分在语言里的样子。
问题四:什么叫"不可能"?
C++ 里有两处设计,把"可能性"这件事玩到了极致。
其一:走不通的路,不算错。
cpp
// 问:类型 T 到底有没有 .size()?
template<class T, class = void>
struct has_size : std::false_type {}; // 默认:没有
template<class T>
struct has_size<T, std::void_t<decltype(std::declval<T&>().size())>>
: std::true_type {}; // 如果这句写得出来:有
思路是:先把所有可能都摊开,再看哪些走得通;走不通的那个,不是"错误",只是"这条路不存在"。
这就是 SFINAE(替换失败不是错误)。它的分量在于:它承认有些可能性是打不开的,而打不开不构成矛盾------这正是人实际的思考方式。
if constexpr 更进一步:走不通的分支根本不会被生成。不是"不执行",是"从来没被创造出来"。
其二:未定义行为,那片没被立法的地方。
cpp
int f(int x) { return (x + 1) > x; }
有符号整数溢出在 C++ 里是"未定义"------标准没说会怎样。于是编译器推理:既然它没被定义,那它就"不可能发生";既然不可能发生,x+1 就一定大于 x。于是优化成 return true;。
未定义行为不是一个错误,是一处立法空白。 而空白不是"什么都没有",是"什么都可能"。程序员一旦真踏进去,整条推理链就塌了------这就是那些"跑了五年、某个周五下午突然炸掉"的事故的来源。
一张总表
| C++ 特性 | 它在回答哪个问题 |
|---|---|
| 构造函数 / 析构函数 / RAII | 什么时候算活着 |
| 值语义 vs 引用语义、三五法则 | 什么时候算同一个 |
const / mutable |
什么能变、什么不能变 |
模板 / SFINAE / if constexpr |
哪些可能性存在 |
| UB | 哪些地方我还没立法 |
std::array<T, N> |
把规矩直接焊进类型 |
最后一行单独说一句:std::array<int, 3> 和 std::array<int, 4> 是两个不同的类型。"长度是 3"这条规矩被写进了类型本身,于是在长度这件事上,可能性只剩一个。这是 C++ 里最漂亮的一个设计。
五、数学里的公理是怎么回事?(以及,它凭什么有用)
你可能会问:数学也讲公理,那是不是"一个数学概念就是一个本体"?
不是。而且答案比"不是"更有趣。
希尔伯特的玩笑
1891 年,数学家希尔伯特在火车站等车时跟同事说了句话,后来被反复引用:
在几何里,我们应该可以随时用"桌子、椅子和啤酒杯 "去替换"点、直线、平面"。
他不是开玩笑。他的意思是:
几何公理从来没说过"点是什么"。它只规定了点、线、面之间必须满足什么关系。
所以你把"点"换成啤酒杯,只要那些关系还成立,毕达哥拉斯定理就照样成立------对啤酒杯成立。
于是:"点"不是一样东西,是一个位置。 谁满足规矩,谁就能坐上去。
压缩成一句话:
规矩不定义"这是什么",规矩定义"它们之间必须怎样"。
跟你写的代码一模一样
cpp
template<class T>
concept Sortable = requires(T a, T b) {
{ a < b } -> std::convertible_to<bool>;
};
你在这里没有定义"可排序"是个什么东西 。你只给了一个条件:只要有 a < b,谁都能来当"可排序"这个角色。整数可以,字符串可以,你自己的 Student 也可以------它们毫无共同点,只是都满足条件。
这和数学里的"群"是一回事:整数加法是群,三角形旋转是群,洗牌动作也是群。没有任何共同本质,只有共同规矩。
那规矩到底在干什么?------在缩小"可能性"
现在把两件事接起来,这是本节的核心:
- 规矩 = 必须满足的条件
- 满足所有条件的 = 这个世界里"可能存在"的东西
于是:
每加一条规矩,可能的东西就少一批。
举个例子,数一下:
cpp
int floor_count; // 规矩只有一个:这是一个 32 位整数
// 它可能存在的值:约 43 亿个
// 你真正想要的:1 ~ 200
剩下的 42 亿多个值------负一层楼、21 亿层楼------全都"合法地存在"于你的程序里。它们满足你写下的每一条规矩,但全都不是你想要的东西。
这就是"建模不严"的本质。 而类型设计的全部工作,就是把 43 亿缩到 200:

图 2 · 每加一条规矩,可能性就少一批。类型设计的全部工作就是这张图。
但是,别加过头
这里有个反直觉的地方。
数学家希望自己的公理严到只剩一种可能 。但工程上恰恰不能这么干。
因为严到只剩一种可能 = 钉死 = 没有实现自由。
正确做法是分两层立法:
| 层次 | 该严还是该松 | 例子 |
|---|---|---|
| 对外的规矩(业务不变量) | 往死里严 | 退款额不能超过订单额 |
| 对内的实现(技术选择) | 故意放空 | std::vector 怎么扩容、用什么分配器 |
std::vector 就是范本:语义定得极死(连续存储、[] 是 O(1)、迭代器何时失效),实现什么都不说(扩容因子 1.5 还是 2,厂商自己定)。
太松 = 到处是 bug;太严 = 改不动。好的设计就是在这中间找那个点。
判断一个抽象好不好,就问一句:该钉死的钉死了吗?该留白的留白了吗?
最后一句实话
那能不能把所有不该存在的值全消灭掉?
不能,这在数学上就做不到。 有个结论叫 Rice 定理,说的就是:程序的大部分性质,你没法写程序去判定。所以"靠类型系统消灭所有 bug"是个不可能完成的目标------Rust 把这件事做到了工程上的极限,代价是限制你能写出哪些程序。
这不是让人丧气,是告诉你力气该花在哪:
类型的价值不是消灭所有错误,而是把错误压缩到------剩下那些,测试能兜住。
六、所以,这跟现在的 AI 编程有什么关系
三条,明天就能用。
1. 写类之前,先回答四个问题
- 这东西凭什么判断是不是同一个?(比内容,还是比地址)
- 它什么时候生、什么时候死?
- 什么东西永远不能变?
- 它可能的值有多少,其中多少是我想要的?
这四个答案,直接就是你的构造、析构、拷贝、移动的写法。先答再写,比背"三五法则"管用。
2. 让类型替你扛规矩,别用注释扛
| 靠注释(靠自觉) | 靠类型(靠机器) | 排除了什么可能性 |
|---|---|---|
double price |
class Money |
价格跟长度相加 |
// owner 不能为 null |
Floor& owner_ |
无所属的墙 |
// 记得加锁 |
mutable mutex + 封装 |
忘了加锁 |
void* data |
std::variant<A, B, C> |
data 是别的什么东西 |
每多一条类型约束,就少一个能藏 bug 的地方。
3. 给 AI 的提示词,最好给类型,而不是给描述
这条最反直觉,也最好用。
大多数人是这么问 AI 的:"帮我写个函数处理订单,订单有 ID、金额、状态......"------你是在用自然语言描述一个世界,而自然语言是模糊的,AI 只能靠"一般人怎么写"去猜你的意思。
更有效的做法是直接把类型和不变量贴给它:
cpp
enum class OrderStatus { Pending, Paid, Shipped, Refunded, Cancelled };
struct Order {
OrderId id; Money amount; Money refunded;
OrderStatus status; std::vector<OrderLine> lines;
};
// 不变量:refunded <= amount;状态为 Refunded 时,refunded == amount
// 请实现:apply_refund(Order&, Money) -> Result<void, RefundError>
你贴过去的不是代码,是你这个世界的法律。AI 一看就知道什么能做什么不能做。
类型就是提示词。 而且是密度比自然语言高一个数量级的提示词。
这也解释了为什么"代码库设计得好,AI 写的代码就明显更好"------不是它变聪明了,是你的类型替它扛住了约束。
结尾
回到开头那一行 class Wall。
两千四百年前,亚里士多德写了本《范畴篇》,想列清楚世界上到底有哪几类东西。今天每个程序员在每个头文件里都在写同一本书。区别只有一条:我们有编译器,它会逐字逐句检查这个世界是否自洽,检查完还顺手给你生成机器码。
而希尔伯特在火车站那句玩笑话------点线面可以换成桌子椅子啤酒杯------道破了底:规矩从来不规定"这是什么",只规定"它们之间必须怎样"。
所以下次设计一个类的时候,值得先停一下,别急着想"它有哪些字段"。先问四个问题:
在我的这个世界里,它凭什么算是它自己?它什么时候在、什么时候不在?什么东西永远不能变?哪些可能性,是我根本不该允许的?
想清楚这四个问题,代码会自己写出来。
一页速查
| 问题 | 答案 |
|---|---|
| 本体论是什么 | 一份清单 (有什么)+ 一套规矩(必须满足什么) |
| 规矩在干什么 | 排除可能性。每加一条,可能的世界就少一批 |
| AI 为什么需要它 | 机器不知道世界有什么,你得写下来它才能推理 |
| 幻觉是什么 | 它没法可靠判断"这东西到底存不存在";RAG = 装个外部裁判 |
| 编程语言也是本体论吗 | 是。类型系统 = 清单,约束 = 规矩;变量能绑定什么,就承认什么存在 |
| 四层映射 | 世界 → 人 → 代码 → 机器,往下三次翻译次次有损 |
| 泄漏抽象是什么 | 底层是物理存在的,压不住,迟早顶穿你的抽象 |
| Bug 是什么 | 四层之间对不上 |
| C++ 特殊在哪 | 它把"存在"拆成四个问题(生死 / 身份 / 变化 / 可能)让你自己答 |
| 数学公理的启示 | 规矩不定义"这是什么",只定义"必须满足什么";int 的 43 亿种可能就是建模不严 |
| 工程与数学的分歧 | 数学追求"只剩一种可能",工程要分两层------契约定死、实现留白 |
| 最实用的一条 | 类型即提示词:给 AI 上下文,schema 比自然语言描述有效得多 |