类型即世界:一个 C++ 程序员的本体论入门

你其实是立法者

来看一行你写过一万遍的代码:

cpp 复制代码
class Wall { ... };

就在敲下这行字的那一秒,你做了一件哲学家吵了两千四百年都没做成的事:你立法规定了一个世界里存在什么东西。

你规定了"墙"存在;规定了它有厚度、有材质、归属于某个楼层;规定了它不能凭空消失。然后你按下编译,一台机器会逐字逐句检查你的世界是否自洽------不自洽,拒绝运行。

这件事有个名字,叫本体论 (ontology)。而那份清单,在编程语言里有个更朴实的名字------类型

你写下的类型,就是你的世界的边界。类型即世界。

没那么玄。它拆开就两样东西:

  • 一份清单:这个世界里有哪些种类的东西?
  • 一套规矩:这些东西必须满足什么?

就这些。全篇文章都是这两句话的展开。


一、规矩的作用不是描述,是排除

先说第二样,因为它最容易被误解。

你写下"一栋楼至少有一层",看起来是在描述事实。其实不是------你是在把"零层的楼"这个可能性从世界上删掉。

你写下"退款金额不能超过订单金额",你就删掉了"超额退款"这个可能。

建模的全部工作,就是不断加规矩,直到不想要的可能性都被删光。

这句话是全文的枢纽,后面会反复回来。记一下。

那 AI 为什么需要这东西

因为机器不知道世界有什么。

人走进一栋楼,不需要谁告诉他"这是墙、那是门"------他有几十年的常识。机器一个字都没有。你必须在喂数据之前,把"世界里有什么、它们之间什么关系"白纸黑字写下来,机器才能开始干活。

这份写下来的东西,AI 领域就管它叫 ontology 。1993 年 Tom Gruber 给了个标准定义,翻译成人话就是:把大家脑子里模模糊糊的那套理解,写成机器能读的规矩。

这里有个很妙的地方:

哲学的本体论想发现 世界到底有什么,吵了两千年没吵出结果。AI 干脆不吵了------我们不追求正确,我们追求够用且大家同意。

于是形而上学变成了接口契约。这大概是人类思想史上最实用的一次"投降"。你可能已经在公司里见过它的样子:领域模型评审、数据字典对齐、接口 schema 定版------这些全是本体论谈判。两个系统对接不上,九成不是技术问题,是两个团队的世界观不一样。

顺便说清楚"幻觉"

大模型凭空编论文、编法条,表面是"记错了",准确说法其实是另一回事:

它没有一个可靠的机制,去判断一个东西到底存不存在。

它只知道"在这个上下文里,接这句话的概率高不高"。概率高 ≠ 真的有。

所以给它配知识库、知识图谱(也就是 RAG 那一套)为什么管用?不是"给它加了知识",是给它的存在感装了一个外部裁判------查得到就有,查不到就没有,可验证、可追溯。


二、编程语言,也是一套本体论

现在是关键一步。

既然本体论 = 清单 + 规矩,那编程语言里,什么对应清单,什么对应规矩?

  • 清单 = 类型系统。 你能写 Wall w;,你的程序世界里就有墙。如果你只有 double,那"钱"在你的程序里就不存在------你只能用数字假装它是钱,然后所有人都会在某个深夜为这个假装付出代价。
  • 规矩 = 约束const、引用、privatestatic_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_ptrlock_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 比自然语言描述有效得多
相关推荐
Rain的Java大神之路1 小时前
JavaWeb开发如何解决跨域问题
java·前端·后端·nginx·web安全·面试·运维开发
IT_陈寒4 小时前
Python的多线程居然是个假把式?搞清GIL让我少熬三天夜
前端·人工智能·后端
明月_清风4 小时前
位图与布隆过滤器:海量数据下的"存在性判断"艺术
前端·后端·算法
明月_清风4 小时前
Hash 表从入门到精通:Go 实战与工程细节
前端·后端·算法
考虑考虑4 小时前
ElasticSearch索引命令
运维·后端·elasticsearch
玉宇夕落5 小时前
背单词项目一:融会贯通:从零构建"单词大师"后台——Next.js + Supabase + Drizzle 全栈开发实战
后端
橙序员小站5 小时前
从 Demo 到生产:Agent Harness 如何把 AI 真正“接”进项目
后端·aigc·ai编程
她的男孩6 小时前
一个开源低代码框架的协作 SPI 是怎么设计的 ForgeAdmin 拆解 + 实战接入新平台
后端·算法·架构
蓝山6166 小时前
Python 字典(Dictionary)完全指南:从入门到实战
后端