首先,谢谢你能陪我一直读到这里,。
现在,你已经对bevy有了更详细的认知,凭借之前的知识,现在你应该可以自己写出一些简单的游戏了。不过如果你细心,那你可能察觉到我漏掉了关于物理引擎的这一部分,我们在本书中不会详细介绍,因为这不属于bevy的组成部分(或许以后我可能会专门再写一本书研究如何从0写一个物理引擎>-<),读者可以查看别的相关资料,我们这里就不再赘述了。
在这里我们正式进入第二部分,即bevy最复杂最核心的反射与ecs系统,我将会花几个章节的篇幅先把反射讲清楚,然后揭开bevy_reflect与bevy_ecs的核心秘密。
让我们把话题拉到更宏大历史进程中,来进一步认识我们平时的工作吧!Bevy 的反射与 ECS 极其复杂,是出了名的难懂,这是因为它在试图解决『图灵机的并发安全困境』与『静态语言缺乏运行时灵活度』这两个大难题。
而解答这两个难题的钥匙,就是函数式编程与元编程。(以下话题大多数为闲聊话题,非代码部分,如果不想看也可以不看,不过看了之后会帮助你对于编程有一个全新的理解)。
10.1 函数式编程
如果你仔细审视当今技术栈的顶峰,你会发现函数式编程无处不在,它已经成为了诸多框架和语言刻在基因里的概念——不可变数据与闭包。
你可能把这句话都已经听烂了,可变数据会导致状态污染、控制流混乱、导致发生“我这个数据怎么莫名其妙变了”这种问题,听烂了闭包的n种好处。但是抛开这种教条,到底为什么我们需要函数式编程?这中间到底发生过什么?让我们从古到今来复盘一下。
顺着族谱往上找,函数式编程其实根本不是什么现代前端或者Rust的新发明,它的理论根基在1930年代就被阿隆佐·邱奇(Alonzo Church)在普林斯顿大学的黑板上写下来了。那时候连真正意义上的电子计算机都还没诞生。
10.1.1 Lambda 演算
要聊 λ 演算的诞生,我们得把时钟拨回1920-1930年代。那时,整个数学界正处于一场数学危机中,数学家们正在疯狂地寻找数学的终极根基。
起因在于,大数学家大卫·希尔伯特(David Hilbert)在1900年的国际数学家大会上,一口气提出了 23 个著名的数学问题。这 23个问题直接指引了整个20世纪数学家们的奋斗方向,诞生了无数的数学定理,甚至连编程本身,也是从中诞生。
希尔伯特是一个极端的数学理性主义者,他有一句名言刻在了他的墓碑上:“我们必须知道,我们必将知道”。他无法容忍数学体系里存在任何模糊、二义性或者无法证明的东西。于是,1920年代,他进一步提出的一个宏大宏愿——“希尔伯特计划”。提出了一个宏大的计划,想要一劳永逸地为整个数学建立一座坚不可摧的理论体系。他要求这个数学体系必须满足三个条件:
- 完备性: 任何一个数学命题,要么能证明它是真的,要么能证明它是假的。绝对不存在无法确定的中间地带。
- 一致性: 数学体系必须是自洽的,绝对不能推导自相矛盾的结论(比如不能推导出 )。
- 可判定性: 存在一个纯粹机械化的步骤(算法),只要把任何数学命题丢进去,这个机器就能在有限时间内死板地计算出它是真还是假。这就是大名鼎鼎的判定问题。
如果这个计划成功,数学将变成一门完美的艺术:所有的定理都不再需要天才的灵光一现,只需要交给一个机械工人去推导符号就可以了。
这三个条件,直接导致了三个伟大的发现。
希尔伯特正带着全世界的数学家试图证明这个体系时,1931年,25岁的库尔特·哥德尔(Kurt Gödel)发表了著名的哥德尔不完备定理。
简单来说,哥德尔不完备定理直接证明了完备性的破产,哥德尔证明了。
任何一个自洽的数学体系,一定存在着一些绝对正确,但永远无法被这个体系证明的数学命题。
这给希尔伯特的梦想狠狠地砸了第一锤:数学是不可能完备的。
虽然完备性碎了,但希尔伯特和数学家们想:好吧,就算有些问题无法证明,那我们至少能不能发明一个“算法”,来判定某一个特定命题到底是不是“无法证明”的呢?这对应了第三个条件——可判定性。
为了解决这个判定问题,数学家们必须先搞懂一件事:到底什么叫“算法”? 当时根本没有计算机,大家对计算的理解都是模糊的。
于是,为了彻底打趴下希尔伯特的幻想,有两个天才在1936年不约而同地登场了,他们各自为计算下了一个严格的数学定义:
- 阿隆佐·邱奇 发明了** 演算**,认为计算就是函数的代入与展开。
- 艾伦·图灵 发明了图灵机,认为计算就是读写头在纸带上的擦擦写写。
后者,是今天一切计算机的老祖宗,而前者,则是函数式编程的老祖宗。
那么,什么是 演算?要一句话说清 演算,就是:“世界上没有实体,只有动作,万物皆是函数。”
在传统的物理世界或图灵机世界里,我们习惯了先有“东西(数据)”,再有“对东西的操作(算法)”。但邱奇的 演算做出了一个极其反直觉但是很数学的假设:我们不需要数据,只需要映射关系本身,就足以表达宇宙间的一切规律。
- 没有数字: “2” 不是一个实体,而是“把某个动作连续做两次”的抽象概念。
- 没有状态: 函数是一个绝对纯净的映射通道。你把东西丢进去,吐出新的东西。在这个过程中,输入的参数绝对不会在原位被污染或篡改。不可变性不是它的特性,而是它的核心假设。
邱奇用他的基础假设和Lambda演算,证明了只要有以下三种语法组件,就能组合出宇宙间所有的计算逻辑:
- 变量: (一个代号,用于指代输入)。
- 函数抽象: (定义一个匿名函数,输入是 ,逻辑体是 。现代前端管它叫箭头函数:x => M)。
- 函数应用: (把函数 作用在参数 上。也就是函数调用:M(N))。
它的计算机制只有一条:-归约(-reduction)。
说白了就是九个字——变量代入与函数展开。把输入参数代入到函数体里,再像弹簧一样把函数表达式展开、化简,直到不能再简为止。这就是整个 演算的全部运转方式。
邱奇在这个连数字、if-else、循环语句都没有的荒凉世界里,推导出了两个终极结论:
- 结论一:它和图灵机完全等价(邱奇-图灵论题)
通过纯粹的函数组合(比如用函数表达布尔值、用著名的“Y组合子”在没有循环语句的情况下强行实现递归),邱奇证明了:这个只用符号代入的逻辑系统,其计算能力与那个拥有无限纸带、读写头的、精密的物理图灵机完全等价。 这就为后来的函数式编程(Lisp、Haskell)奠定了绝对合法的正统地位——图灵机能干的, 演算全都能干。
- 结论二:机械化的终极判定是不可能的
邱奇利用 演算证明了,你无法写出一个通用的 表达式,去判定任意另一个 表达式是否能被化简到终点。这和图灵的停机问题殊途同归,彻底干掉了希尔伯特想用机械化判定一切数学命题的宏愿(希尔伯特又被无情鞭打)。
10.1.2 图灵机
说到图灵机,每个计算机人都很熟悉,在这里我们再重新复习一下图灵机的三个物理组件。
-
无限长的纸带: 这就是内存。纸带被划分为一个个格子,每个格子存储一个符号(数据)。
-
读写头: 这就是 CPU 的指针和寄存器。它可以在纸带有左右移动,更重要的是,它能擦除当前格子的符号,并写入一个新符号。
-
状态寄存器: 记录机器当前处于什么状态。
在图灵的假设里,计算就是读写头在纸带上的擦擦写写。想进行计算,必须去内存的某个地址上,把原来的值擦掉,改写成新的值。这就是地地道道的“可变变量”。我们今天在面向对象或命令式编程里写的 x = x + 1、obj.age = 18,全都是图灵机在纸带上擦擦写写的现代翻版。
OOP的出现,其实也绝对不是偶然,它就是图灵机模型在工程复杂度爆炸时的必然产物。 甚至可以说,OOP是图灵机在软件工程世界里演进出来的终极完全体。图灵机的核心就是那条纸带和那个擦擦写写的读写头,就是命令式编程的根源。世界上最早的高级语言(如 Fortran、C 语言)全都是地地道道的图灵机翻版。
为了拯救图灵机模型,不让它在复杂的现实业务里崩溃,人类必须想个办法限制读写头的瞎擦乱写。于是,阿兰·凯(Alan Kay,OOP之父)、芭芭拉·利斯科夫(Barbara Liskov)这帮天才提出了面向对象的思想。仔细审视OOP的核心三大特性(封装、继承、多态),它们其实全都是为了给图灵机的可变纸带打补丁。
10.1.3 为什么计算机使用图灵机
历史务实的地方就在于,冯·诺依曼在造物理计算机时,由于当年的硬件极其昂贵,物理学家们用屁股决定也会毫不犹豫背叛邱奇,选择更贴合硬件物理结构的图灵机模型(内存就是纸带,CPU就是读写头)。
于是,原位修改、命令式代码,这些东西统治了工业界半个世纪。而演算则带着它天生不可变,被软件工程打入冷宫,只能在Lisp语言里自娱自乐,甚至被工业界嘲笑是学术玩具。
这主要是因为,在那个世纪的硬件条件下,如果你非要用图灵机的硬件去跑纯演算,因为不能原地改数据,你每次改个属性都得在内存里重新开辟空间、复制一遍数据,当年只有几KB内存的机器根本无法运行任何复杂程序。
10.1.4 函数式编程复兴原因
函数式编程的复兴主要是由于两个变化导致的。
第一个变化是半导体技术达到了上限。在21世纪初人类终于撞上了物理极限,摩尔定律锁死了,单核 CPU 的主频撞到了物理极限。为了让计算机更快,物理学家和工程师们只能开始往一个芯片里塞更多的核心——多核并发时代来临。
这时候,图灵机模型的致命缺陷暴露了。多个核心同时在同一块内存上疯狂擦擦写写,世界瞬间乱套了。为了防止数据竞争,人类不得不发明了锁、互斥量、死锁检测。为了保护那块可变内存,程序员的心智负担直接爆炸。
人类在图灵机的沙盘里修修补补快崩溃时,一回头,突然发现了在函数式编程里躺了半个世纪的 演算。因为 演算天然不可变,数据只能读取不能修改,所以一千个核心同时去读它也是绝对安全的,天然对多核并发免疫。
另一个变化是系统的复杂度变了。早期的程序很简单,内存很贵,原位修改能榨干每一比特的硬件性能。但现代的系统太庞大了。数据在无数个组件、中间件、异步回调里流转。这时候,可变数据就变成了:“我这个状态怎么莫名其妙变了?是谁在哪个旮旯角里悄悄改了我的属性?”debug 变成了玄学。
兜兜转转大半个世纪,软件工程发现,解决复杂度的终极武器,居然是90年前数学家在黑板上写下的、那个连变量和数字都没有的纯粹世界。不管是现代前端的函数式编程、不可变数据、Lambda函数,其归根到底,老祖宗都是90年前的 演算。说到这里顺便插一嘴,其实纯粹的Lambda演算的函数,不能包含内部状态和触发副作用的,在现在叫做“纯函数”。而现在编程语言里的更务实的,能够捕获外部变量和存有自己状态的,有个更响亮的名字——闭包。现在你知道为什么叫Lambda函数而不是gamma或者什么omega函数了吧。
10.1.5 现代函数式编程
现代硬件底层依然是硬件高效的图灵机,但在软件的上层,现代编程语言都正在用演算的核心概念,让程序员能够编写出确定与优雅的程序。由于根上还是图灵机,实现最纯粹的其演算实是很困难的,而且我们也没有必要完全按照最初的演算来编写程序,不然那就太痛苦了。
现代函数式编程语言,从演算里偷来了最核心的几个武器——不可变数据、闭包、代数数据类型、高阶函数复合。变成了你天天看到的这些概念。
- **闭包:**本质上是演算在图灵机上的妥协产物。演算没有状态,而闭包通过捕获自由变量,把环境和动作打包在了一起。
- **不可变数据类型:**本质上是演算的核心假设。在现代主要通过两种方式实现,一种是编译器强制不可变关键字(例如
const)甚至在Rust里,只要不声明可变,就默认不可变;一种是通过结构共享,在可变数据类型上强行实现不可变数据,这就是React的核心,没变的部分用指针指向老数据,变了的部分创建新节点。 - **代数数据类型:**这是现代函数式编程极其重要、甚至可以说是
Rust、TypeScript等语言灵魂的一点,其标志性的体现就是enum。传统OOP依赖开放多态(通过子类继承虚表来分发行为),而函数式编程带来了闭合多态,结合模式匹配,编译器能强迫你必须处理完所有情况才能编译通过。这种闭合带来了类型安全和运行效率。 - **高阶函数复合:**传统OOP要实现功能的组合通常要让对象持有另一个对象的引用。而函数式编程的组合,是数学意义上的函数复合()。一个函数能够接收函数作为参数,或者能够实现一个,返回函数的函数
因此,你在设计模式中看到的,很多由于现代语言的改进而消灭的设计模式,其实更本质上,是现代函数式编程和演算对于图灵机的胜利。
10.1.6 不存在银弹
软件开发中没有银弹。说完了函数式编程与OOP的爱恨情仇。赞扬了函数编程的优点,那么,函数式编程的缺点是什么?
前面我们说到,演算的核心就是变量代入与函数展开,而一个事物的核心假设与方法,是优点来源的同时,往往也是最大的缺点来源。
缺点一:闭包地狱
在React里有一个被无数人喷过的被叫做“闭包地狱”的大雷,由于闭包的特性,某个异步回调函数或副作用捕获了“过去”的状态,导致它在执行时拿到的依然是旧数据,而不是最新的值。为了引入函数式组件的副作用与缓存,React推出了 useEffect、useCallback 等等一堆API。然而搞笑的是,它们反而转接了代价,变成了闭包地狱最频繁的爆发区。
其本质,是闭包的状态捕获与异步并发之间的冲突。
而在更底层的语言里,这个问题变得更加致命。单单是所有权机制和异步就已经开始产生了排异反应。
由于Rust 经常会发生所有权转移,把一个Future传给另一个线程、或者放进一个Box里。为了解决异步并发导致的内存移动产生空指针,Rust 引入了 Pin。Pin<P> 的本质是在内存中把这个对象“钉住”。一旦一个 Future 被 Pin 住了,Rust 就保证它再也不能在内存中被移动。这样,它内部的自引用指针就永远是安全的。
Rust闭包与其他语言也不同,在Rust中闭包也有一个move关键字。在没有所有权的语言中,闭包对外部变量的捕获一般是全自动且悄无声息的,一律通过引用计数挂在堆上。但Rust的闭包非常节约,能用借用就绝不用所有权。但如果你的闭包生命周期比它捕获的变量还要长,借用就会失效,导致编译报错,这时候你就必须在闭包前加上 move。
而当Rust里的异步和闭包撞上,就会发生巨大的核反应。
如果捕获的变量已经在外面的函数里被销毁(Drop)了,但异步任务还在另一个线程里哼哧哼哧地跑,这时候去访问那个变量,不就成了野指针吗?而Rust设计的目标就是根除空指针的存在,这可怎么办?
为了让异步任务能安全运行,你必须使用move关键字。
#![allow(unused)]
fn main() {
let local_data = String::from("重要数据");
// 加上 move!把 local_data 的所有权彻底剥夺,转移进异步任务内部
tokio::spawn(async move {
// 此时这个数据完全属于这个异步任务了,它爱活多久活多久
println!("{}", local_data);
});
}
回到React闭包地狱的本质,其根源是同一个变量,外面在变,闭包里拿的是旧的,从而产生了冲突,React创造了useEffect。而Rust的move机制直接从物理上消灭了这种冲突的可能性。
但代价就是,一旦被捕获,外面的世界就彻底失去它了。因此如果你既想把数据传给异步任务,又想让外面的代码继续用,该怎么办?只能拷贝。
这就是第一条代价:拷贝地狱。
更进一步,如果数据不仅要被多个异步任务共享,而且这些任务都想修改它,那你必须保证线程安全。
这就是第二条代价:Arc<Mutex<T>>地狱。
缺点二:丧失的多态拓展性
代数数据类型与模式匹配很好的解决了绝大部分OOP继承需要的场景。唯独只有一条没有解决——扩展性。这在计算机科学中被称为“表达式问题”。
以最典型的UI树为例。利用开放多态(继承),可以很好的描述一个UI的树结构,这已经在OOP中得到了大量的应用,我们不再赘述。
在现代的闭合多态语言中,利用代数数据类型与接口,我们也可以实现同样的功能,因此可以写出这样的代码来描述一个树形结构。
#![allow(unused)]
fn main() {
// 用代数类型穷尽节点的所有可能性(闭合多态)
#[derive(Debug)]
pub enum UIWidget {
Button { text: String },
Text { content: String },
Panel { children: Vec<UIWidget> },
}
impl UIWidget {
// 统一的行为方法,内部通过模式匹配(Pattern Matching)分发逻辑
pub fn draw(&self) {
match self {
UIWidget::Button { text } => {
println!(" [渲染按钮]: {}", text);
}
UIWidget::Text { content } => {
println!(" [渲染文本]: {}", content);
}
UIWidget::Panel { children } => {
println!("[开始渲染面板] ->");
// 一视同仁地向下递归遍历
for child in children {
child.draw();
}
println!("<- [面板渲染结束]");
}
}
}
}
}
但是——这里的关键是代数类型是一种封闭的多态,如果要添加新的UI组件,必须更改整个代数类型,这意味着,如果你在写一个通用 UI 框架,麻烦就大了。如果用户想自定义一个自己的组件并塞进框架的容器里,他们根本无法做到!因为他们没有权限去修改框架源码的enum类型,而且增加一个新的类型,意味着要大动干戈地修改原有的枚举定义,并且所有 match 它的地方全都要改一遍。这对框架设计来说是毁灭性的。
有很多框架采用了一种讨巧的方式,比如SwiftUI或者Slint等,将修改结构后需要更改源代码的问题,交给了编译器来自动化工作,让你写起来感觉有开放多态的好处,但是实际上运行时却没有OOP的开销和问题。但这往往导致编译器报错出来的类型会嵌套几十层如同天书,同时也无法良好的支持热重载。
而OOP是开放多态,从不假设数量。这就导致了一个天然的结果——OOP天然就是适合用来组建真正的UI树。很多UI框架早以证实了这一点,Flutter、早期的React(实际上React现在也是,虽然代码里不是,但是JS天生就是弱类型多态的,这只是一种不需要开放多态的幻觉,如果用纯粹、严格的函数式静态语言去写,就会立刻撞上这堵南墙),源代码里都是OOP。
或许你有很多的方法仍然可以绕着来实现这样的开放多态,但是绝对不会优雅,最终你会发现自己绝对又结合类型擦除,重新实现了OOP中的虚表。
或许有朝一日,如果我们能够用另一种继承之外的,更好的方式来实现开放多态,这个问题就能够解决了。
缺点三:复合地狱
最后一点则是函数式编程里臭名昭著的套娃地狱。例如Rust的迭代器不仅链条长,最致命的是Rust编译器的类型推导。这一长串下来,中间任何一步的借用或所有权搞错,编译器就会抛出一个长达两页纸的、夹杂着类似 Filter<Map<Filter<Iter<...>>>> 的超长泛型报错,直接把人看瞎。
#![allow(unused)]
fn main() {
let top_users: Vec<String> = users
.into_iter()
.filter(|u| u.age >= 18)
.map(|u| u.name)
.filter(|name| !name.is_empty())
// 为了去重,得先收集到 HashSet,再转回迭代器(因为迭代器本身没有 dedup)
.collect::<std::collections::HashSet<_>>()
.into_iter()
.take(3)
.collect();
// 哦等等,还要排序?迭代器没有 sort(),又得 collect 出来排,再处理……
}
正如人月神话里说的,软件开发中没有银弹。其实这句话放到编程语言里也是相同的。没有一种编程风格能够解决所有的设计问题。很多人误以为函数式编程比OOP更加优美,是因为他们只看到了高阶函数在字面上的简练。真正的函数式编程之所以敢如此自由传递和嵌套函数,是因为它强制要求函数是“纯”的。但我们在前端、在Rust、在Go里用的,大多是多范式语言而不是真正的纯粹的函数式语言。因此,硬币的另一面就是:自由的代价往往就是混乱。
10.1.7 补充
Lisp是第一门函数式编程语言。Lisp是世界上第二古老的高级语言(仅次于 Fortran),由人工智能之父约翰·麦卡锡(John McCarthy在1958年发明。他的特点是万物皆是列表,万物皆是函数。Lisp是现代所有动态语言(包括 JavaScript)的老祖宗。它是第一个引入闭包(Closure)、高阶函数、垃圾回收(GC)机制的语言。
Haskhell是一门把函数式编程发展到极致的编程语言。是为了实现“绝对纯粹的邱奇理想国”而打造的语言。它是以逻辑学家哈斯凯尔·柯里(Haskell Curry,柯里化Curry思想的提出者)的名字命名的。他的特点是,极端的纯粹与不可变,在 Haskell 里,“可变变量”这个概念是被彻底物理消灭的,它把 = 理解为数学上的等价定义,而不是图灵机的赋值。Haskell 引入了极其强大的静态强类型系统。之前在设计模式里感受到的代数数据类型和模式匹配,其现代工业界的终极源头就是Haskell。
10.2 元编程
要讲元编程,就必须先弄明白什么不是元编程,“元”到底指的是什么。
让我们把思维从日常的写代码里抽离出来,站到一个更高的抽象维度去重新审视,那个你一直在写但从未考虑过的问题:“代码本身到底是什么?”
10.2.1 代码的本质
每天,程序员坐在电脑前敲下 if (user.isAdmin)、list.map(x => x * 2),或者封装一个用户类、写一个数据库连接池,这些通通不是元编程,这些应该叫做,业务编程或者一阶编程。
在这种模式下,代码本身决定的是具体的数据结构与控制流程,而用户输入的数据是被推上流水线的原材料。代码像一个加工厂,日复一日地吞进去数据,吐出来新的数据。
在计算机科学的终极圣殿里,有一条定理,叫做“柯里-霍华德同构”。
它用严密的数学逻辑证明了一件事**:代码的本质,就是数学证明。**这个定理表明,类型本质上就是数学里的命题,函数本质上就是对这个命题的数学证明。
当你写下一个函数 function add(a: number, b: number): number 并让它顺利通过编译时,其实不是在写一段机械的指令,而是在向计算机推导一个数学定理:“已知存在两个数和,我可以通过以下步骤,证明必然能通过函数体内的流程来构造出第三个数。”只要代码能跑通,就意味着你的证明成立,命题就为真。
10.2.2 元编程的本质
还记得我们之前说,希尔伯特的判定问题指的是:存在一个纯粹机械化的算法,只要把任何数学命题丢进去,这个机器就能在有限时间内死板地计算出它是真还是假。而这个机器,其实就是我们天天在用的计算机。
“元”一词在希腊语里有“超越”、“在……之后”的意思,这个词还广泛用在其他领域,例如心理学上有“元认知”,是一个人对自己的认知能力;数据科学里有“元数据”,是用来描述数据的数据,比如描述一副图像的各种参数等等。
现在我们可以指出,我们之前写的业务代码,就是在编写一个命题,写代码的过程就是去证明我们的逻辑和结论为真的过程。而元编程,是去写出一个“能够证明一个证明为真”的程序。在这种情况下,一个证明本身就成为了一个命题。
这么说可能有些拗口,“证明一个证明”是什么意思呢?既然我们说写代码是证明,那么其实简单替换一下就可以得到更容易理解的概念:元编程,是写一段程序,让这个程序输出另一个可运行的、正确的程序的过程。
希尔伯特的判定问题,以及图灵的图灵机停机问题,如果用现代软件工程的语言来翻译,就是“试图寻找一个万能的、全知全能的元编程程序”的问题。
图灵提出,是否存在一个万能的判定程序 ,当你把任意一个程序 和它的输入 传给 时, 能够百分之百准确地告诉你,程序 是会运行结束(停机),还是会陷入死循环?
这个 ,在定义上就是一个地地道道的、最顶级的“元编程程序”,。如果它存在,它就拥有了超越一阶代码的“上帝视角”,能够看穿所有世间代码的宿命。它要吃进去的原材料不是别的,正是另一段程序的源代码。它的加工逻辑是去分析这段源代码的控制流,它的输出是一个布尔值(true 或 false)。
元编程是极其强大的武器,能够轻而易举做到普通编程根本无法做到的事情。在现代语言里,语言本身主要使用反射与宏,来实现元编程。
10.2.3 运行时元编程—反射
反射是很多程序员进阶路上最大的一只拦路虎,甚至是编程生涯的终点。
很多人天天都在用它( JSON 序列化工具、用 Spring 的注解、或者在前端用Vue3/MobX),但如果你问:“到底什么是反射?它反射了什么?”,绝大多数人都会卡壳,往往只觉得它很高级、很神秘,能搞出一些魔法,但底层的逻辑却是一片浆糊。
要弄懂反射,必须先明白这个词是怎么来的。它其实是一个非常精妙的物理隐喻。
在物理世界里,反射是一束光打在镜子上,折射回你的眼睛,让你看清了你自己的模样。
在编程语言里,反射是代码在运行的时候,突然停下来,转过头去对着镜子晃一下,审视自己、观察自己、看清自己的代码结构。这就叫做运行时的“自省”。
所以,反射,就是程序在运行期“照镜子”自审的能力。 它反射的不是别的东西,正是一个程序自身。
说到这里可能还是很抽象,一个程序看自身是什么意思?让我们看的更本质一些。
为了明白为什么需要反射,我们必须回头看看你写下的代码,是怎么编译的。
当你写下这些定义:
class User {
public name: string;
private age: number;
public deleteDatabase() { ... }
}
对于人类来说,这个类结构清晰,有属性、有方法、有私有共有权限。这些定义一目了然。
但是,当编译器把这段代码编译成机器码或者字节码、放到内存里去运行的时候,计算机只认识内存地址和偏移量。在物理内存里,这个 User 对象最后可能被简化成了仅仅是一块 16个字节的连续内存空间。前8个字节存 name 的指针。后8个字节存 age 的数字,你在代码里所有的访问和调用,全都变成了指针和地址。
这时候,如果你突然产生了一个需求:“我想写一个万能的工具,用户传进来任意一个对象,我都能自动把它变成JSON字符串吐出来。”如果没有反射你根本无法做到。因为程序在运行的时候,根本不知道前8个字节的名字叫做什么,后8个字节的名字叫做什么。
为了解决这个痛点,现代支持反射的语言(Java、Go、C#、JavaScript)的编译器,做了一件额外的事情。它们在编译和加载代码时,没有把这些类型和字段数据删除,而是偷偷藏了一份每个函数和对象的字段和参数记录。
以TS为例,当你启用编译器的反射功能,并且引入反射库。
{
"compilerOptions": {
"experimentalDecorators": true, // 开启装饰器
"emitDecoratorMetadata": true // 核心:允许编译器在运行时吐出类型元数据
}
}
import "reflect-metadata";
假如你拥有一个巨大的Map,其中key是类型,而value是对应的数据。
// 假设这是我们的大 Map,里面存着系统运行所需的各种基础服务
const container = new Map<any, any>();
// 往大Map里塞点原材料
class ApiService {
fetchData() { return "从服务器捞出的核心数据"; }
}
class LoggerService {
log(msg: string) { console.log(`[日志]: ${msg}`); }
}
container.set(ApiService, new ApiService());
container.set(LoggerService, new LoggerService());
TS有个设定,只有被装饰器修饰的类或方法,编译器才会配合地保存这个类或方法的参数和类型信息。所以我们先写一个最简单的装饰器,哪怕它什么都不干。
// 这是一个空壳装饰器,单纯为了告诉 TS 编译器
function Injectable(): ClassDecorator {
return (target) => {
};
}
现在,我们要写一个万能的自动实例化函数 resolve。它会去照镜子(反射),看看目标类需要什么参数,然后从大 Map 里捞出来塞进去。这里的关键是,由于在之前引入了import "reflect-metadata";,这个库会在全局的Reflect对象上添加一个getMetadata方法,其本质其实是一个巨大的WeakMap。利用这个方法,你可以获得一个含有当前对象元数据的数组。
function resolve<T>(target: new (...args: any[]) => T): T {
// 核心反射:向 Reflect 索要这个类的构造函数参数类型图纸
// "design:paramtypes" 是TS内置的key,用来获取参数的类型数组
const paramTypes: any[] = Reflect.getMetadata("design:paramtypes", target) || [];
console.log(`[反射自审]: 发现类 ${target.name} 的构造函数需要 ${paramTypes.length} 个参数。`);
// 打印出来的 paramTypes 看起来像这样:[ [class ApiService], [class LoggerService] ]
paramTypes.forEach((type, index) => {
console.log(` 参数 ${index + 1} 的类型是: ${type.name}`);
});
// 现在你可以根据类型参数,从刚才的大Map里捞出对应的依赖
const dependencies = paramTypes.map(type => {
const dependency = container.get(type);
if (!dependency) {
throw new Error(`找不到依赖的类型: ${type.name},请先塞进大 Map 中!`);
}
return dependency;
});
// 把捞出来的参数作为构造函数参数塞进去,返回新实例
return new target(...dependencies);
}
现在我们写一个业务类 UserController,它假装自己不知道参数怎么来,只管伸手要:
@Injectable() // 打上标记,让TS编译器保存参数
class UserController {
// 声明我需要 ApiService 和 LoggerService,参数数量:2
constructor(
private api: ApiService,
private logger: LoggerService
) {}
public handleRequest() {
this.logger.log("开始处理用户请求...");
const data = this.api.fetchData();
console.log(`[控制器响应]: ${data}`);
}
}
// 我们不需要手动 new UserController(new ApiService(), new LoggerService()) 了
// 直接交给 resolve 函数,它会通过反射自动组装
const controller = resolve(UserController);
controller.handleRequest();
这个例子,本质上就是我们在创建型模式里讨论的依赖注入的最简单实现。在其他语言里,比如Java,Go,同样也需要用到反射,才能够实现依赖注入。这是因为依赖注入必须有一个前提,必须能够让程序知道“这个类或者函数接受几个参数,每个参数的类型是什么”。要知道这些事情,就必须使用反射。
但是,你发现他的代价了吗?
第一,它是不纯粹的。JS语言本身根本不支持这种类型的反射,我们必须在语言外层加一套特殊的编译器开关(emitDecoratorMetadata)并引入一个reflect-metadata库。
第二,它必须依赖装饰器(@Injectable)这个语法。如果程序员忘记在类名上加这行注解,反射机制当场就会变成瞎子,什么参数也捞不出来,在运行时直接导致程序崩溃。
这种在运行时“编译时留额外信息、运行时再翻来翻去查找”的方案,正是元编程在运行时流派的工程妥协。这也正是为什么,更硬核的现代语言,会抛弃运行时的反射,直接转入编译期的阵营。
10.2.4 编译时元编程—宏
宏的发展史是一个极其精彩的屌丝逆袭故事,它一开始根本不是什么高大上的元编程神器,而是一个纯粹的打字机语法糖。
宏最早可以追溯到汇编语言时代和C语言时代(#define)。那时候,由于硬件内存极小,连函数的调用开销(压栈、出栈、保护现场)程序员都斤斤计较。于是,大家想:能不能发明一个东西,让我少写点重复代码,但在编译前自动帮我把代码复制粘贴过去。
这就是文本替换宏。在这个阶段,宏根本没有智商,它不认识什么是类型,不认识什么是语法,它就是一个纯粹的字符串替换工具。因为这种呆板设计,早期的宏制造了无数难以调试的Bug,被现代面向对象语言(Java、C#)视作毒瘤,直接在语言设计中被彻底开除了。
然而,当程序员陷入了“运行时的无限套娃”和“反射的沉重代价”中感到毫无办法时,宏最终又被拾起来重新发现,并且在系统级语言里完成了屌丝逆袭。
现代的宏(以 Rust 的过程宏 为代表)彻底进行了全面升级,它不再是盲目的文本替换了,而是直接跟编译器深度合体,直接获得了接触抽象语法树 的最高权限,变成了指挥编译器的工具。
在动态类型和Java或者C#这种字节码语言里主要依靠反射,而宏主要在真正的硬核编译型语言里大行其道,并且分为好几个流派。
流派 A:Rust的“编译魔法”——过程宏
这个流派的观点非常直白粗暴简单有效,既然元编程是改写代码,那就干脆在编译器内部开一个后门。
当你写下#[derive(Serialize)] 时,编译器在编译你的核心代码之前,会先去跑一段由你编写的元编程程序。这段程序吃进去的是你当前结构体的Token流,在内存里重组语法树,然后吐出全新的Rust代码。
这种流派是纯粹的语言内解法,非常安全,类型系统和生命周期检查全部无缝覆盖。但代价是编译速度极慢,因为你编译一个程序,实际上等于先编译并运行了十几个“生成代码的程序”。
流派 B:UE5(虚幻引擎)的“重工业魔改”——外部编译器
C++天生是没有现代反射和元数据的,但在游戏开发中,对象的序列化、GC、蓝图与C++的动态绑定又必须依赖反射。Epic官方一看,指望C++标准委员会扯皮完(C++标准委员会是这样的),黄花菜都凉了,于是他们采取了最暴力的重工业手段:不和委员会玩了,自己给 C++ 编译器写个外挂壳子。
这就是UHT(虚幻请求头工具)。当你在UE5里写下 UCLASS()、UPROPERTY() 时,原生的C++编译器根本不认识这些宏。在真正的C++编译器启动之前,UE的UHT会先跑一遍,把你的整个 C++ 源码文件肉眼扫描一遍,强行解析出你的类结构。UHT会在幕后自动生成一堆巨庞大的、极其恶心的 .generated.h 和 .gen.cpp 文件,帮你在C++里强行手写出了一套元数据。最后,再把这些生成的代码和你的源代码一起喂给标准的C++编译器。
这种流派直接降维打击,在不支持反射的语言里,硬生生用重型工程手段造出了全套反射基础设施。但是极度依赖虚幻自己的构建系统,编译链条极长,一旦代码写错,引发的报错信息简直是天书。
流派 C:Zig语言的“超级杀招”——comptime关键字
Zig 语言走了一条让所有人惊掉下巴的纯粹路线。不发明宏也不发明外部工具,而是在编译期直接运行正儿八经的Zig代码。这就是Zig被无数人赞扬的超级大招——comptime 关键字。
在Zig里,你想写一个万能的、支持任何类型的泛型容器,不需要写复杂的泛型语法或宏,你直接在编译期写常规代码。直接把类型当作变量给当作函数的输出给返回出去。
// 这是一个函数,输入是一个类型,输出是一个结构体新类型!
fn List(comptime T: type) type {
return struct {
items: []T,
len: usize,
};
}
// 使用时:
const IntList = List(i32); // 在编译期把 i32 类型传进去,直接生成一个新的结构体!
在Zig语言里,类型天生就是编译期的一等公民。你可以像操作普通的数字、字符串一样,在编译期用 if-else、for 循环去组装这些类型。它不需要任何特殊的宏语法,日常怎么写业务逻辑,就怎么写元编程。编译器在编译时,会把标有 comptime 的代码直接在内嵌的解释器里跑完,把结果固化成极其干净、零开销的机器码吐出来。
流派 D:现代语言的“隐藏元编程”——泛型
泛型在计算机科学的严谨定义下,其实也是“元编程”最基础、最优雅的一种形态。
当你编写fn swap<T>(a: &mut T, b: &mut T)这样的函数时,你并没有编写一段具体的机器码。你是在编写“编写代码的逻辑”。所以,泛型本质上就是一个“代码生成器”。编译器替你完成了繁琐的“代码复制粘贴”,这就是最高效、最安全、最易用的元编程。Zig语言其实就是把这种“泛型即元编程”的本质给彻底释放出来,并且把泛型的地位进一步提升了。
10.2.5 成也萧何,败也萧何
在前面我们说到了zig语言最神奇的地方,把类型当作可以运算的变量来在编译器期间运行生成结果,看似一切都很美,那么代价是什么?
让我们来抛开现象看本质,zig语言的comptime到底是什么?
在话题函数式编程中我们长篇铺叙了图灵机与 演算的历史,不知道你是否能发现一些端倪。
当我们写下泛型HsahMap<String,Vec<Vec<usize>>>时,其实这就是最纯正的 演算,类型就是我们的输入,泛型的嵌套就是函数的嵌套。
因此Rust社区才能利用泛型构造出泛型常量,当你在nalgebra库中使用U2,U3这样的泛型常量时,其实就是在用 演算去模拟真正的次数计算。
那么,zig语言的comptime是什么?
显然,他就是一个完整的用来计算类型的图灵机,具备了图灵机便于使用简单直观的优点的同时,也背负了图灵机所有的缺点。
- 图灵停机问题:图灵证明了,对于一个程序,不存在任何一个程序只需要看源代码,就能够推导出这个程序是否会在有限时间内终止。这里的程序,就是Zig编译器本身。换句话说,对于
comptime中的内容,Zig编译器永远也不能希望靠纯粹的代码检查就能判断comptime中的内容会不会卡死编译器,这对Zig语言的语言服务器提出了巨大的挑战。 - 复杂度爆炸:自由是混乱的源泉。相比 演算,图灵机的灵活性实在是太大了,一旦不加任何对于
comptime的限制,comptime中的内容最终必将会成长为谁也看不懂的混沌巨兽。 - 并行:当
comptime碰上并行,所有之前在图灵机中的出现的竟态问题,都会重新以更复杂的状态出现在comptime中。
解决方法也很简单,必须对自由的comptime加上严格的函数式限制,例如不可变量、纯函数式编程….
comptime的特性使得Zig的编译器和语言服务器复杂度暴增,Zig必须在正式编译之前,把所有的comptime运行一遍才能知道真正的结果,这就意味着Zig编译器必须内置一个微型解释器来运行这些代码。同时,这也意味着这对于类型检查、代码补全、代码跳转等功能是毁灭性打击。
成也萧何,败也萧何。Zig语言的未来还长路漫漫。
10.2.6 最大的元编程程序—编译器
其实某种意义上来说,编译器,就是最大的元编程程序。
在前面我们说,元编程,是写一段程序,让这个程序输出另一个可运行的、正确的程序的过程。那么按照这个标准来看,我们前面讨论的方法,都局限在这两个程序都是一种语言上。但是只要我们允许这两个程序可以不是一个语言,那么我们实际上就可以顺理成章得到一个结论——编译器,就是最大的元编程程序。
我们在前端创造了各种DSL语言,JSX也好Vue也罢, JSX和Svelte的编译器,本质上就是一个“语法翻译层面的元编程”。当你写下 <div/> 时,你不是在写JS,你是在写一种元编程语言。编译器这个最大的元编程程序,在构建阶段接管了你的代码,它把你的语义描述通过AST重写,转换成了指令式的、低层级的DOM操作,让你能够一行代码顶原来的十行代码。
一个编译器的里程碑事件,就是让这个语言的编译器,也是用这个编程语言写出来的。在计算机里这叫做自举。这就是元编程的闭环。在这个闭环里,编程语言变成了一种能够自我复制、自我进化、自我增殖的生命体。
但是,这种进化是永无休止的吗?
显然,图灵和邱奇已经证明了,编译器不是万能的,不管如何进化,也不可能进化出理想的终点——能够解释万物终极真理判别器。那么,如果我们彻底超越编程与代码的边界,去看看那个真正孕育了计算机的母体呢?
10.3 篇外:超越编程
让我们把话题推向逻辑和理性终点,在编程之外,超越编程之外的世界是什么样的?
让我们把视野拉的更高一些,当程序消失,当计算机消失,甚至连物理世界也消失,我们还剩下什么?
10.3.1 符号主义
早在 17 世纪,戈特弗里德·威廉·莱布尼茨(Gottfried Wilhelm Leibniz)就提出了一个想法:能不能发明一种通用特性语言,把人类世界所有的思想、争论、逻辑,全部简化为纯粹的符号和算术?这样未来如果两个人发生争论,他们不需要面红耳赤地吵架,只需要拿出纸和笔,说一句:“让我们来算一算!”,通过符号推导就能得出谁是对的。
这就是符号主义创世起点。莱布尼茨在三百年前,就已经在脑海中构思了“逻辑代数”的蓝图。在他眼里,思考就是计算,理性就是符号的重组。
顺着莱布尼茨的火种,历史走到了 20 世纪初。逻辑学家们不满足于仅仅把符号当成工具,他们发起了一场更加激进的圣战:证明整个数学,本质上都只是逻辑学的一个分支。
罗素和怀特海为了实现莱布尼茨的梦想,硬生生写出了三大卷、重达数公斤的宏篇巨著——《数学原理》。这本书枯燥、严密、纯粹到了非人的地步。在这套完全由符号构筑的冰冷世界里,他们花了整整 362页的符号推导,才终于在数学上严格地证明了一个在我们看来是废话的命题:。罗素定义了什么是0,什么是1,什么是加法,然后用极其严密的逻辑符号去证明和定义了2。
为什么当年那些大哲学家要搞这么复杂来证明看似这么简单的问题?
因为越是简单,反而越是困难。
罗素搞得这么复杂,就是为了向全世界证明:不需要依赖任何关于数字的直觉,我只用纯粹的逻辑概念(集合、属于、并集),就能凭空推导整个数学。
那么,到底什么是符号?什么又是符号主义?人类为什么非要用符号不可?
在普通人眼里,符号只是键盘上的 + - * / 或者代码里的字母。但在哲学和数学的底层,符号是人类用来驯服无限宇宙的唯一武器。
在逻辑哲学里,符号的本质,是人类理性对现实世界进行的一种“极高层次的抽象”。
现实世界是极其沉重、复杂且充满杂质的。比如,你眼前有“两头健壮的牛”,有“两个红苹果”,还有“两颗正在坍缩的恒星”。物理世界里的牛会生病,苹果会腐烂,恒星会熄灭,它们太重了,人类的大脑根本无法直接把它们塞进思维里去运算。于是,人类发明了符号。 人类把牛、苹果、恒星里那个共同的、抽象的本质抽出来,脱掉所有的水分和实体,变成了一个毫无具体意义的符号——2。
有了符号,就诞生了符号主义,它的底层核心假设只有一句话:“人类的认知本质上就是计算,而计算本质上就是符号的操纵。”
符号主义坚信,宇宙的底层逻辑是一套巨大的、由公理驱动的形式系统。在这个系统里,不管是数学(如皮亚诺的后继符号)、逻辑(如真与假),还是人类的思想、语言,都可以被彻底拆解为离散的符号。你不需要去物理世界里看牛和苹果。你只需要制定一套严格的符号规则。只要你按照规则,把符号 变成符号 ,再变成符号 ,只要推导过程不违背规则,你在纸面上演算出来的结论,就必定是物理世界的终极真理。
我们为什么要用符号?因为符号是人类跨越心智带宽限制来认识和改造世界的工程手段。
自然语言是充满二义性和模糊性的。如果用自然语言去推导真理,逻辑链条稍微拉长,就会因为语义的妥协而彻底崩塌。罗素的著作《逻辑与知识》,就是在研究如何用一套严密的符号去创造一门精确的语言。
一旦现实世界被符号化,真理的推导就变成了一套刻板的、机械的符号替换游戏。这时候,思考这个动作,就被彻底降维降格成了体力劳动。 既然是体力劳动,人类就可以把这个开销转嫁出去——在三百年前转嫁给纸笔,在今天转嫁给图灵机,也就是计算机。
人类的寿命和肉眼是有限的,但数学和宇宙是无限的。你怎么去证明“所有自然数都满足某个规律”?你不可能把无限个数挨个试一遍。 但符号主义可以通过数学归纳法(皮亚诺第五公理),用区区几行符号的推导,把横跨到宇宙终点的无限个数字,一网打尽。
可以说,不管是数学家写下的每一行公式和证明,还是程序员写下的每一行代码,每天面对的,就是这样的一个由各种符号组成的世界。
10.3.2 逻辑哲学
既然有了符号,我们开始玩起了符号的游戏,那么在这场游戏里,我们天天挂在嘴边的逻辑,本质到底是什么?
在这里,我们直接请出 20 世纪最伟大的天才哲学家——路德维希·维特根斯坦(Ludwig Wittgenstein)。
很多人(包括很多程序员)觉得逻辑是人类大脑里“正确思考的指南手册”。
但维特根斯坦在全书的开篇,就直接颠覆了这种唯心主义的幻觉。
-
世界是所有发生的事情。
-
世界是事实的总和,而不是事物的总和。
什么叫“世界不是事物的总和,而是事实的总和”?在计算机和现实世界里,根本没有孤立存在的“苹果”、“内存”或“用户”(这些只是事物)。世界是由“苹果在桌子上”、“指针指向了0x001”、“变量 x 等于 1”这些事实构成的。
那么,逻辑是什么?维特根斯坦给出了如同神谕一般的定义:
- 我们在语言中不能表达任何违反逻辑的东西,就像在几何学中不能用坐标来表现一个违反空间定律的图形,或者给出一个不可能的点的坐标一样。
- 逻辑充满了世界:世界的边界也就是逻辑的边界。
逻辑,本质上根本不是什么高深的思想工具。
逻辑就是允许事实能够发生、能够被组合、被陈列的“底层网格”,是世界的“脚手架”。如果没有逻辑这个底层脚手架,世界上的事实根本无处挂载,就像没有内存的网格和语法规则,代码里的变量和数据流根本无法存在一样。
既然逻辑是世界的脚手架,那么逻辑命题的底层真相是什么?
-
逻辑的命题都是同义反复。
-
因此逻辑的命题什么也没有说。
什么叫“同义反复”、“什么也没有说”?
举个例子:
- 命题 A:“外面正在下雨。”(这句话传达了新信息,它可能是真,可能是假。)
- 命题 B:“外面要么在下雨,要么没在下雨。”(这句话在逻辑上永远正确,是个真理。)
但维特根斯坦说:命题 B 作为一句绝对正确的逻辑真理,它告诉你任何关于天气的新知识了吗?没有。它在物理上一文不值。
这就是我们写代码的真相。
我们在代码的世界里写下了成千上万行严密的逻辑推导,我们用泛型和控制流卡死了每一种可能。编译通过的那一刻,我们欢呼雀跃,觉得自己创造了真理。但维特根斯坦告诉我们:**你的代码什么也没有说,它只是一场宏大的、在编译期完成的“同义反复”。**喂给编译器的输入,和编译器吐出来的输出,在逻辑的物理天平上,重量是完全相等的。逻辑没有创造任何新东西,它只是用一种绝对严密的符号游戏,保证了“输入”到“输出”的等价交换。逻辑的本质,就是一场绝对不会出错的、巨大的符号变换。
程序员们每天打交道的代码(if-else、变量名、类名),本质上就是这套玩了三百年的“符号脱水游戏”。用符号把沉重的现实业务脱水成轻量级的代码,在计算机里疯狂运算。
10.3.4 超越编程
从莱布尼茨的通用语言,到罗素362页的符号圣战,再到维特根斯坦给世界布下的逻辑网格,人类用三百年的时间,把现实世界脱水成了纯粹的符号与逻辑。
现代程序员,其实正是这门最古老学派的事实缔造者。我们用代码在物理的硅片上,用符号和逻辑,筑起了人类历史上最庞大、最精密的逻辑巴别塔。
但维特根斯坦在理性的终点拉住了我们。他告诉我们:
-
对语言的边界,就是世界的边界。
-
对于不可言说之物,必须保持沉默
符号和逻辑能捕获的,仅仅是那个被“脱水”的事实世界。而超越编程之外的——那些关于生命的痛苦、美、爱、以及存在本身的意义,它们无法被写进 if-else,无法被封装进类与函数,它们属于那片“不可言说的领域”。
沃尔玛你程序员们用尽一生的逻辑去编写程序,去穷尽这个符号世界的所有可能。而当我们终于合上电脑、抬起头,望向窗外真实、充满不确定性的复杂世界时,才会发现超越编程之外的,是那些代码无法触及的生命色彩,对于这些我们能做的,只有带着最深的敬畏,保持沉默。