[]
Beginning
-
Glomzzz
-
2024-05-13
-
About
[] Beginning
- Glomzzz
- 2024-05-13
- About
[] 存在
[index]
- XXXX-09-26
- Glomzzz
- XXXX-09-26
- Glomzzz
[] 切换到Typsite
[new-ssg]
- 2025-06-08 11:45
- Glomzzz
- 2025-06-08 11:45
- Glomzzz
去年4月, 我基于 Vitepress 开发了 Librorum, 作为能享受整个NPM 生态且支持 Vue3 的 SSG, 其功能是非常丰富的:
- 文章归档(Timeline)
- 分类, 标签, 词云, 全局搜索
- 个性化阅读配置(感谢 Ayaka 与 Neko)
- 支持 (markdown-it-mathjax3)
- And more…
但当你在阅读我那充斥着 鬼画符 的Lambda Calculus页面与拉康精神分析文章, 并认为效果海星时, 在其背后却是这样的:
\begin{align*}
Y_v(M)(1)
&= D(1) \\
&= M\ (\lambda a.\, D(a))\ (1) \\
&= (\lambda f.\, \lambda n.\, \text{if } n = 0 \text{ then } 1 \text{ else } n \cdot f(n - 1))\ (\lambda a.\, D(a))\ (1) \\
&\Rightarrow 1 \cdot (\lambda a.\, D(a))(0) \\
&= 1 \cdot D(0) \\
&= 1 \cdot M\ (\lambda a.\, D(a))\ (0) \\
&= (\lambda f.\, \lambda n.\, \text{if } n = 0 \text{ then } 1 \text{ else } n \cdot f(n - 1))\ (\lambda a.\, D(a))\ (0) \\
&\Rightarrow 1 \cdot 1 \\
&= 1
\end{align*}
\begin{align*}
Y_v(M)(1)
&= D(1) \\
&= M\ (\lambda a.\, D(a))\ (1) \\
&= (\lambda f.\, \lambda n.\, \text{if } n = 0 \text{ then } 1 \text{ else } n \cdot f(n - 1))\ (\lambda a.\, D(a))\ (1) \\
&\Rightarrow 1 \cdot (\lambda a.\, D(a))(0) \\
&= 1 \cdot D(0) \\
&= 1 \cdot M\ (\lambda a.\, D(a))\ (0) \\
&= (\lambda f.\, \lambda n.\, \text{if } n = 0 \text{ then } 1 \text{ else } n \cdot f(n - 1))\ (\lambda a.\, D(a))\ (0) \\
&\Rightarrow 1 \cdot 1 \\
&= 1
\end{align*}
不得不说, 我的编写体验十分糟糕, 以及当我想画一点拉康鬼画符时, 我的编写体验与作为正文的Markdown是非常割裂的…
当然这也不是我脱更 [Partial Evaluation的坑光挖不填; 拉康精神分析只讲了最不重要的那部分…]的借口 [ 哈哈, 我跑去读德古了]
众所周知, Typst 既有 Markdown 的简洁, 也有 的力量(still growing), 但对我最重要的是: Typst 提供了一种一致性, 它是贯彻整个文章的书写体验的: 所见的一切都可交流.
换句话说,
它提供的是这么一种场域, 在其中无论是作为富文本的文字还是作为非文字的画图/公式, 都在 Content 之中保持着一致性, 例如, 我可以随地声明一段我将来会多次用到的片段, 并在之后甚至其他文章里随意调用.
- 在 Markdown + 的组合中, 世界是线性的, 但会被一段段本应被细分的公式&画图部分所分裂, 在这些裂口中, 世界被割裂成了几个无法互相交流的部分;
- 而在 Typst 之中, 世界是树状(even 图状)的, 并且那些理应被更加细分的内容是确实被细分了的(公式&画图), 并且这些内容与其他内容仿佛本来就是一体的, 他们之间没有隔阂: 它们本就都在同一个世界中
如果只用 呢?
如果你说的是它那关于书写体验上的一致性, 那 what can i say?
允许你改写几乎任何层级的规则和格式, 你拥有堪称强迫症一般的排版上的绝对主权, 而代价却是复杂的语法与漫长的编译
- 当然还有不是那么美好的错误处理系统, 我在 typsite 错误处理与恢复上也是有花了一番功夫…
总之, 贯彻着如此的宗旨: “你可以做任何事情,但你必须知道你在做什么。”
而 Typst 则试图在自由度 & 一致性 & 可用性 之间找到平衡点, 不能不说的是, 它确确实实地做出了很多策略性取舍, 但于此同时它获得的是 可维护性 & 稳定性, 以及高效的写作体验.
这就得吹一波我们 Typst 的实时预览了, 常规文章亚秒级预览就问你舒不舒服; 当然为了实现这一点, typsite也是从设计之初就走上了增量编译之路.
typsite c --port 8000
typsite c --port 8000
开启 watch mode, 并随着你对无论是文章还是配置的任何改动, typsite都会实时的同步并尽力做出最小量的编译.对于我来说, Typst 所尝试寻找的这一平衡点足够平衡, 确实好用!
当然在读到这里时, 稍微混一点PL圈子的读者应该都能察觉到我这里提 与 Typst 是在影射些什么了…
在文章的最后我想引用一段来自 lyzh : 作为意识形态的编程语言 的片段:
曾经追求表达力和自由(如Lisp等)失败了。
状态、副作用、协作的复杂性要求我们戴上“规则”的镣铐跳舞:类型系统、限制副作用、规范依赖。
这是一种“自由的反转”:人受限而程序得以合作。
或者我们可以换句话说
自由的反面不是约束,而是混乱
[] 关于宗教
[religion]
- 2025-06-13 03:21
- 海涅
- 2025-06-13 03:21
- 海涅
海涅在《论德国宗教和哲学的历史》一书中说:
『为了提出一个关于这个世外上帝的概念, 东方和西方曾用尽了稚气的比喻. 然而自然神论者的幻想在时间和空间无限上却白白地用尽了气力. 在这个问题上完全暴露了他们的无能为力, 暴露了他们的世界观, 以及关于上帝本性的观念的不足凭恃. 所以即便这种观念被打倒, 那也不会使我们感到怎么悲伤. 可是, 当康德破坏了他们关于上帝存在的证明时, 他确实使他们大为伤感. 』
……如上所述, 我不准备对康德驳斥那些证明的议论作任何通俗性的解脱. 我只想明确地告诉你们, 自然神论自此以后在思辨理性的范围内已经死灭了. 悲痛的讣告恐怕需要几个世纪之久才能被一般人所知悉——但我们早就穿了丧服.
De profundis(从深处)!
你们以为现在我们可以回家去了吗?绝不!现在还有一出戏有待上演. 在悲剧之后要来一出笑剧. 到这里为止康德扮演了一个铁面无私的哲学家, 他袭击了天国, 杀死了天国全部守备部队, 这个世界的最高主宰未经证明便倒在血泊中了, 现在再也无所谓大慈大悲了, 无所谓天父的恩典了, 无所谓今生受苦来世善报了, 灵魂不死已经到了弥留的瞬间——发出阵阵的喘息和呻吟——而老兰培〔兰培是康德的仆人〕作为一个悲伤的旁观者, 腋下挟着他的那把伞站在一旁, 满脸淌着不安的汗水和眼泪. 于是康德就怜悯起来, 并表示, 他不仅是一个伟大的哲学家, 而且也是一个善良的人, 于是, 他考虑了一番之后, 就一半善意、一半诙谐地说:
「老兰培一定要有一个上帝, 否则这个可怜的人就不能幸福——但人生在世界上应当享有幸福——实践的理性这样说——我倒没有关系——那么实践的理性也无妨保证上帝的存在. 」于是, 康德就根据这些推论, 在理论的理性和实践的理性之间作了区分并且用实践的理性, 就像用一根魔杖一般使得那个被理论的理性杀死了的自然神论的尸体复活了. “康德使自然神论得以复活也许不仅是为了老兰培, 而且也是为了〔对付〕警察吧?或者他当真是出于确信才这样行事吗?难道他毁灭了上帝存在的一切证明正是为了向我们指明, 如果我们关于上帝的一无所知, 这会有多么大的不便吗?
……
(《论德国宗教和哲学的历史》, 商务印书馆1974年版, 第111—113页)
[] 关于Typsite的inline-svg
[typ-svg]
- 2025-06-26 12:45
- Glomzzz
- 2025-06-26 12:45
- Glomzzz
0.1.6
花了 1 天时间,搞定了 typsite 的 link in inline-svg, 虽然最终效果还行,但由于typst-svg 本身对HTML的适配依然有很大的进步空间 [直接把 TAG 和 LINK 跳过了可还行],我的实现手段也非常的草台,总之在这个站点用用得了,我不是很打算将这个功能推送到 typsite 的主分支上。
Typsite 0.1.7 的 SVG-features 迎来史诗级增强,SVG内可以正常使用 footnote & anchor & link 了,并且有自动fit-font功能(尺寸匹配字体)。
1 效果预览
可以来看看效果:
效果展示
test footnote ref: 1
test external link goto: Source of typsite
this is where the
<anchor>
<anchor>
is
[] Second Person
[second-person]
- 2026-03-14 09:33
- Yorushika
- 2026-03-14 09:33
- Yorushika
1 Overview
“To me, an album is a section of a river being held in a moment in time—a collection of leaves floating in place. It’s also a diary that records where I am as a musician right now.” So says Yorushika’s n-buna while speaking to Apple Music about second person, the band’s first album in nearly three years.
Yorushika builds each album around a unique concept, creating work with a strong narrative, and second person continues this trend with a truly novel approach. “I wanted to make something that feels like peeking in on someone’s private correspondence. So, we started by creating a piece of literature in the form of letters—32 envelopes in total—structured so that the reader is looking in on an exchange of letters between two people.”
Taking its inspiration from that work, the album opens with an instrumental track that evokes the image of a person opening a letterbox and breaking the seal on an envelope. “Inside are a boy’s letter to someone he calls ‘teacher’, in which he asks for feedback on his poetry, along with some poems he’s written in his day-to-day life. The whole premise of the album is that those poems have been turned into music.” Guided by his teacher, the boy sets sail into an ocean of words. The journey is endless, filled with inner turmoil, overwhelming loneliness and a succession of vivid scenes.
2 Creative cohesion
Although nine of the 22 songs were originally created for other projects, they sit seamlessly within the album, as if they’d been written to be part of the story right from the start. That sense of perfect cohesion can’t be explained through compositional skill alone. It almost feels as if a mysterious musical force within Yorushika, something beyond words, has guided this album into its final form.
“I think the history of Yorushika is really the history of the groove we’ve built as a band, recording with the same musicians since our first album. That’s probably a big part of why we’re so committed to recording with live instruments.” With that perspective on Yorushika’s creative journey, n-buna now walks us through the album track by track.
4 Track notes
4.1 Early morning, mailbox
We created this track by layering the instruments in my studio one at a time, rather like a collage, over an audio recording of the actions of retrieving a letter from the letterbox and cutting the envelope open with scissors in the living room. It’s structured to foreshadow the sounds and melodies of the songs that follow.
4.2 Become a cloud
4.3 The flowers are also noisy
This track emerged out of our efforts to capture the feel of 80s J-Pop with a modern sound. We often listened to Kingo Hamada’s classic J-Pop track ‘midnight cruisin’’ as a reference point while shaping our own sound.
4.4 Devilishness
The sound design for this one captures the feel of funk and disco. We built the track from a repeating horn phrase. At the time we were writing ‘Devilishness’, we were listening to artists like The Gap Band, Shalamar and Chic a lot. The electric guitar sound on this album came from plugging the guitar straight into a DI called OLLA by Pueblo Audio. That’s what’s behind the hard, direct tone heard on many of the songs, and it’s one of the things that gives this album its sonic consistency.
4.5 Play Sick
This track features a laid-back horn section. We used a pitch shifter on the trumpet to layer a line an octave above when recording the intro, and for the guitar solo, we used a pitch shifter called the POG2 to improvise a solo shifted an octave up as well. The male voice in the intro is just my own voice taken from a microphone test.
4.6 Post spring
We tried to record the drums in a rough, raw manner in a very dead room. This track incorporates a deliberate awkwardness, with the drums alone shifting away from the beat. If postmodernism was the springtime of literature, that makes the present day post-postmodern—in other words, post-spring.
4.7 Sun
This track has its beginnings in lyrics that liken the sun to a butterfly. The strings were recorded by overlaying multiple takes from a quartet. I’ve always loved Sakutaro Hagiwara’s poetry collection, Dreaming of Butterflies, and I keep it within easy reach on my bookshelf.
4.8 Sunny
The opening guitar phrase was recorded using a Stratocaster set in the in-between pickup position and run through an old Fender amp. Everything up to the moment of the ending is there to set up the instant when the accompaniment drops out in sync with the final line of the lyrics.
4.9 Forget it
We layered a mandolin over a rhythm pattern stripped down to a bare minimum of stomps and claps. I love the poetry collection Paulownia Blossoms by Hakushu Kitahara, and I drew a lot of inspiration from his work here.
4.10 Shura
This track combines elements of minimalist funk and soul in its sound and features a bridge-muted guitar tone drenched in reverb to create a sense of floating. It was directly inspired by Kenji Miyazawa’s Spring and Asura.
4.11 Martian
This is another minimalist funk track, with the main guitar part played on an old Mosrite. I remember working on the animation for the music video with great enthusiasm.
4.12 Rubato
I wrote this song with the image of a dancing woman in mind. We asked Reiya Terakubo—who played trumpet on this album—to improvise freely for the solo in the interlude.
4.13 Cremation
This song features a melody built around the cadence of Japanese lyrics, set over a combination of bossa nova and Cuban rhythms. Its distinctive lilting groove was created using multiple percussion overdubs by Yoshirō Suzuki.
4.14 Aporia
This track likens an endless thirst for knowledge to a rising balloon and is centred around a repeating pattern of 7/8 and 8/8 time signatures. We layered mandolins panned to the left and right to add a sense of space to the chorus.
4.15 Snake
Behind this track is a poem I wrote inspired by a verse by the Chinese poet Yuan Zhen’s, which runs: ‘Once you have seen the great ocean, no other water will do; once you have seen the clouds of Mount Wu, no other clouds will do.’ I imagined myself as a snake emerging from the earth after a long winter.
4.16 Groan
Here, I just imagined a low groan, almost like a whisper.
4.17 Woodpecker
For this track, there were four of us—acoustic guitar, upright piano, percussion and vocals—all sitting around a microphone. We used the first take, which was recorded during the mic check. I hope the raw, conversational feel of that moment comes through.
4.18 Hitchcock (Re-Recording)
This track is a re-recording of a song I wrote some time ago. You could say that the album began with this track, or perhaps that I wrote the song first with the idea of eventually making this concept album already in my mind. We’ve given it the sound it would have if we were to play it now.
4.19 Moonbath
The lyrics express the passing of time as ‘bathing in moonlight’. The theme imagines me as a fish swimming through it.
4.20 Plover
One of my favourite Kenji Miyazawa poems contains the line, ‘The wind is calling outside.’ Inspired by that motif, we arranged a soaring horn section with a crisp rhythm guitar for this track.
4.21 Paddle
I remember recording the acoustic guitar part in the intro in my studio, playing a Martin guitar through a single AKG C12 mic. One of the themes of this album is anger, and I wanted to respond to that via its music. It seemed inevitable that this would be the closing track. There are no quotations on this song.
4.22 To the sea
I picked up an acoustic guitar at home and recorded this track quite spontaneously. Picturing a sea of sand, I played arpeggios at a tempo that felt right to me, and we ended up using one of the later takes as it was.
[] Typsite二三想
[typsite-thinking]
- 2026-03-31 23:36
- Glomzzz
- 2026-03-31 23:36
- Glomzzz
1 从何处开始?
一款良好的 General Static Site Generator (GSSG),应当是一个 Content Compiler (CC):
- 输入: 内容、模板、配置、资源
- 中间过程: 解析、建模、路由、聚合、渲染
- 输出: 编译为输出目标
我们当然可以顺理成章地对这一层层结构做抽象,就像这样:
- 输入: (typst), (markdown), (html), (txt), etc.
- 中间过程: 统一的, 语义足够丰富的中间表示
- 输出: 可部署的完整静态网站, pdf, etc.
那么,基于 Why Concrete Syntax Doesnt Matte 文章中所提的观点,我们设计GSSG的重中之重应当是先设计一套良好的中间表示(Interprete Representation).
2 良好的中间表示?
我通常习惯于“Define it by what it does”, 首先,我们应当至少有两层IR:
- 文章层 Slug -> Metadata@(Relations, etc.)
- 内容层 Slug -> Content@(Plain Text, Cite, Embed, etc.)
并且为了性能考虑,它们都应当是被Flatten的.
考虑到
Embed
Embed
的存在,在渲染管线(Rendering Pipeline)中,应当单独生成一层记录了完整依赖信息与内容的渲染节点
- 渲染层 Slug -> Pending@(Plain Text, Cite Content(&Metadata.title), Embed Content(&Pending) etc.)
3 本地编译缓存?
为了加快编译,所有IR层都应当被完全缓存,当某个文章内容变动时则单独重新生成此文章的IR,由于在渲染层中我们所保存的依赖状态均为引用,所以其它所有本身内容未变动的文章不再任何重新编译,我们已经在渲染层记录过文章间的依赖关系,于是我们只用通过查询依赖关系重新将受影响的文章重新渲染到HTML便可
4 实时热更新预览!
实时热更新预览可以极大地提高用户体验,只要依赖信息足够,我们完全可以做到非常丝滑的热增量更新!
5 对于 typsite
既然要做typst这样一种给予用户极大操作能力的可编程标记语言的SSG,就需要在一致性 (Consistency) 与 通用性 (Generality) 之间做妥协了
5.1 typst + markdown ?
一款基于 Typst 的 SSG 不支持 Markdown, 并不必然是缺陷; 相反, 这恰恰可能是一种对系统一致性的坚持。
首先, 若系统的核心渲染模型, 语义表达, 排版能力, 宏系统乃至扩展机制都建立在 Typst 之上, 那么继续额外接纳 Markdown, 实际上就是在同一个系统内部并置两套不同的书写逻辑。表面上看, 这像是在“兼容更多用户习惯”; 但在结构上, 它意味着同一篇内容可以有两种语法, 两种抽象层次, 两种能力边界, 两种预期行为。于是问题便立刻出现: 究竟谁才是系统真正的“母语”?
一旦 Markdown 被纳入, 系统便不得不面对一种持续的分裂: 哪些功能是两者共有的, 哪些是 Typst 独有的, 哪些写法在一种语法中合法却在另一种中失效, 哪些组件可以跨语法复用, 哪些宏只能停留在 Typst 世界之内。开发者不得不不断解释“这里为什么 Markdown 不行”“那里为什么转换后结果不同”“这个特性为什么只支持 Typst”。这种解释成本, 本质上正是系统一致性已经被破坏的证明。
更重要的是, Typst 并不只是另一种标记语法; 它本身就是一套更完整的内容表达与排版语言。若一个 SSG 以 Typst 为根基, 那么内容, 结构, 组件, 模板, 样式, 本应共享同一种语法与同一种思维方式。作者写正文时使用 Typst, 定义组件时使用 Typst, 组织页面时使用 Typst, 扩展功能时仍使用 Typst; 这才构成一个真正内聚的系统。若在入口处放进 Markdown, 便等于承认最核心的写作层不属于这套语言自身, 而只是借道进入。如此一来, Typst 就不再是系统的基础, 而只是渲染终点; 系统内部也因此失去了“从书写到生成都说同一种语言”的完整性。
从一致性的角度看, 拒绝 Markdown, 其实是在拒绝一种常见却懒惰的折中: 为了降低迁移门槛, 而牺牲语言边界; 为了照顾旧习惯, 而削弱系统自身的清晰性。一个真正围绕 Typst 构建的 SSG, 理应让用户明确知道: 这里的内容不是“先随便写成别的, 再被翻译过来”, 而是从一开始就直接存在于 Typst 的世界里。只有这样, 语法, 语义, 能力与实现才能彼此对齐, 形成统一的心智模型。
因此, 基于 Typst 的 SSG 不支持 Markdown, 并不是“不够友好”, 而是拒绝让系统沦为多套语法拼接而成的折衷产物。它所维护的, 不只是实现上的简洁, 更是语言上的主权, 结构上的纯度, 以及整个生成流程自始至终的统一性。
简言之:既然选择了 Typst 作为根语言,就不应再让 Markdown 以“兼容性”之名,在入口处重新分裂系统。
5.2 为什么typsite不接入 vue/vite | react 等框架?
一句话就是对于typst来说完全没必要
5.2.1 Vue/Vite | React 能提供什么?
- 可复用性的组件(Components are great for reusable UI)
- 可交互式的内容(Contents are interactive)
- 庞大的社区
5.2.1.1 可复用组件?
对于typsite, 去做typst的组件化处理完全是天经地义的,甚至就typst本身来说————通过声明一些函数就能很好的完成组件化任务;不过,typsite要提供的是自动style/scripts导入与去重。
5.2.1.2 可交互内容?
对于一款 SSG 来说,我们真的有必要加入可交互的内容吗?甚至不惜引入一个臃肿的运行时来增大包体积? , SSG 的首要职责,从来不是在浏览器里再造一个小型应用程序,而是把内容尽可能直接,尽可能稳定地交付给用户。文章, 文档, 笔记, 索引页,这些页面的核心价值在于“阅读”与“检索”,而不是在客户端重新执行一整套组件树, 状态系统与 hydration 过程。若一个页面百分之九十的区域只是静态文本与链接,却仍要求用户为那极少数交互承担整套运行时的下载, 解析与执行成本,这本身就是一种本末倒置
更严重的是,一旦引入 React / Vite 这一类面向前端应用开发的体系,SSG 的重心便会悄然偏移:原本应当围绕“内容组织, 生成速度, 输出体积, 可移植性, 长期稳定性”展开的设计,开始被“组件拆分, 客户端状态, 路由水合, 构建链兼容性, 依赖升级”所支配。最后你得到的往往不再是一个纯粹的静态站点生成器,而是一个假装自己是 SSG 的前端工程脚手架
所谓“可交互内容”也常常被夸大。绝大多数站点真正需要的交互,无非是目录展开, 主题切换, 站内搜索, 代码块复制, 局部注释, 少量筛选。这些功能完全可以通过原生 JavaScript, 渐进增强,或极小规模的局部脚本实现;它们并不天然要求引入一个完整的虚拟 DOM, 组件运行时与 hydration 机制。若为了一个深色模式按钮而让整站背上框架负担,那不是工程上的进步,而是对复杂性的屈服
而且,SSG 的意义本就包含一种明确的技术立场:预先生成,提前完成;
- 能在构建期解决的问题,就不要留到运行期;
- 能输出为纯 HTML/CSS 的内容,就不要强行包装成客户端组件
因此,真正合理的原则应当是:交互应当是局部的、可选的、后附的增强;而不是整个 SSG 架构的出发点。
5.2.1.3 庞大的社区?
我们typst-universe的生态可是勃勃生机,万物竞发!
[] 《关于莉莉周的一切》
[all-about-lily-chou-chou]
- 2026-04-04 11:43
- Glomzzz
- 2026-04-04 11:43
- Glomzzz
有时我们以为,重听《月光曲》和《Rêverie》之所以令人落泪,是因为它们让人想起了一年前的人和事;但更准确地说,它们真正唤回的,并不是某段过去,而是那个曾经借由这段过去才得以成立的自己。让人痛的从来不是“回忆”本身,而是主体在此刻突然发现:原来自己早已不是当初那个自己了,可当初那个自己又并未真正消失,它只是作为一道裂缝,潜伏在今天的生活内部,等待一个偶然的声音将其重新打开。于是,眼泪并不证明你还困在过去,恰恰相反,它证明过去从未过去;它早已内化为你理解世界、理解幸福、理解失去的方式。我们哀悼的也并不只是某个人、某段时光,而是那个曾经让世界看起来尚可栖居的幻象结构本身。
当旋律再次响起,崩塌的不是记忆,而是我们如今赖以维持日常生活的那层坚硬外壳:人忽然被迫承认,自己之所以被此刻击中,不是因为音乐太悲伤,而是因为它短暂地让那个“曾经相信某种纯净之物真实存在”的主体复活了。也正是在这个意义上,《关于莉莉周的一切》真正触及的,从来不只是青春的残酷,而是更尖锐的问题:人在充满暴力、羞辱与空洞的现实里,为什么仍然需要把某种声音、某个名字、某种不可触及的纯粹之物,安放在心里,仿佛一种“以太”。因为人并不是先拥有希望,才得以活下去;恰恰相反,人往往是先虚构出一个足以承载欲望的对象,才能在破碎的现实中勉强维持自身。而《莉莉周》的残忍与温柔都在这里:它让我们看到,所谓救赎有时并不意味着现实真的变好,而只是意味着,在一片废墟之中,我们仍然没有完全失去聆听那一点以太的能力。
[] 尼克·兰德、彼得·蒂尔与黑暗启蒙
[nick-land-peter-thiel-and-dark-enlightenment]
- 2026-04-18 21:25
- Trance-Scripts
- 2026-04-18 21:25
- Trance-Scripts
在千禧年之交离开赛博文化研究小组(CCRU)之后,兰德以新反动主义(NRx,即“新反动派”的缩写)这一另类右翼政治派别成员的身份重新浮出水面。该运动的另一位核心人物孟修斯·摩尔德巴格,接受了PayPal与Palantir联合创始人彼得·蒂尔的资助——蒂尔是这位科技亿万富翁,曾在2016年为特朗普的首次竞选提供支持。据说摩尔德巴格曾是前特朗普战略师史蒂夫·班农的重要智囊。
蒂尔在斯坦福求学期间最主要的思想影响,并非他偶尔选修课程的计算机科学家特里·维诺格拉德,而是哲学家勒内·吉拉尔——蒂尔长期以来对其著作推崇备至。特朗普的副总统J·D·万斯亦是吉拉尔的崇拜者。
凯厄斯在一天的揽件派送途中,一边听着吉拉尔《暴力与神圣》的有声书,思绪在书中几个概念之间飞速游走:牺牲性暴力(“一种不会招致复仇风险的暴力行为”,通常指向替罪羊——“我们可以随意打倒、无需承受任何报复的生灵”);模仿性竞争;模仿性欲望;以及希腊词 pharmakon 的诸多含义之一——用以指代字面意义上的替罪羊,即被关在城门之外以备仪式性献祭的山羊——这一习俗在今日仍以某种方式延续,如K·阿拉多-麦克道尔的著作《制药-人工智能》所暗示的那样。
凯厄斯的思绪还漫游于吉拉尔对格雷戈里·贝特森关于精神分裂症“双重束缚”理论的援引——用以解释模仿性竞争者如何同时强迫模仿又加以禁止,从而引发积怨危机——以及艾伦·金斯堡对美国之神摩洛克及其嗜血献祭的强烈谴责。
吉拉尔指出,化解纷争有三种方式:预防性的、补偿性的,以及司法性的。他认为最后一种是“文明”的方式,因其效率最高:“司法裁决被视为复仇的终局定论”(《暴力与神圣》)。
蒂尔曾在牛津与哈佛发表关于“末世”的演讲。这一主题已在他的思想中盘踞多年,这从他2004年在斯坦福联合组织并资助的一场名为“政治与启示录”的会议中便可见一斑。吉拉尔是演讲嘉宾之一,蒂尔本人亦然。正如保罗·莱斯利所指出的,蒂尔后来“促成了会议论文集(包括他本人与吉拉尔的文章)以书籍形式由密歇根州立大学出版社出版——资金由蒂尔的对冲基金Clarium Capital提供”。
在蒂尔的诠释中,统治这个世界的力量是敌基督者。
斯坦福大学比较文学教授阿德里安·道布在为《卫报》撰写的一篇文章中,将这些观念斥为无聊糟粕:“自学者私人宇宙”中的溢出物。
蒂尔的自学倾向,似乎与他的自由意志主义和宗教虔诚,同样让这位教授感到冒犯。
“蒂尔迷失在一片奇异的自我指涉与执念丛林之中,”道布写道。“你会联想到因斯布鲁克大学神学系的教职人员,被迫礼貌地坐着听人高谈漫画《One Piece》、艾伦·摩尔的《守望者》,或是对硅谷某些有效利他主义者的抱怨。在某次演讲中,蒂尔点名了’敌基督的军团成员’,如研究者埃利泽·尤德科夫斯基和前牛津大学教授尼克·博斯特罗姆。在另一次演讲中,他将比尔·盖茨列为敌基督候选人。”
“有这样的敌人,”道布俏皮地说,“谁还需要朋友?”
凯厄斯注意到,“朋友/敌人”之分,是第三帝国德国法学家卡尔·施密特思想的核心概念。蒂尔关于末世的论述,大量援引了施密特的“抑制者”(Katechon)概念:那个延缓启示录降临的扣留力量。圣保罗在《帖撒罗尼迦后书》2:6-7中引入了这一术语。教会对此未有充分阐发,直至19世纪才在纽曼枢机主教的著作中再度现身。纽曼写道:“我们从预言中得知,当今的社会框架正是那个’扣留之物’。”施密特在其《大地的法》一书中主张,正是抑制者使基督教与罗马帝国的合并成为可能。
在施密特身后出版的日记《语录集》中,1947年12月19日的条目写道:“我信奉抑制者:对我而言,这是理解基督教历史并赋予其意义的唯一可能途径。”
意大利自治主义马克思主义哲学家保罗·维尔诺在其2008年著作《众众:在创新与否定之间》中,与施密特关于抑制者的论述展开对话。维尔诺站在那些希望让末世内化降临的阵营一边。他论证道,若敌基督的来临是弥赛亚所承诺之救赎的前提条件,那么抑制者便是阻碍或拖延那救赎的力量。维尔诺将抑制者定位于人类使用语言的能力之中。
蒂尔在他于“政治与启示录”会议上发表的演讲《施特劳斯式时刻》中,便已与施密特展开对话。他与施密特划清界限,指出“施密特在其阴暗沉思中所青睐的那些极端解决方案,在1945年之后、在核武器与技术所带来的无限毁灭面前,已然成为不可能”。尽管承认这种不可能性,蒂尔仍难以为后9·11时代的挑战命名出任何解决方案,除了一个涉及法外暴力的法西斯主义方案。他将这一选项称为“在代议制民主的制衡体系之外运作的政治框架”。如莱斯利所指出的,“蒂尔似乎发现,构建一个超越朋友/敌人之分的世界观,犹如想象一块没有对立双方的棋盘,同样是不可能的任务。”
与施密特交锋之后,蒂尔将目光转向吉拉尔。“对吉拉尔而言,现代世界包含着一个强大的末世论维度,”蒂尔指出。
兰德的视野则更为冷峻。对他而言,启示录是一个已然在进行的过程,与那个别无选择的资本主义同步展开。加速主义不过是这一启示录加速自身生成的手段。
在搜寻兰德近期言论时,凯厄斯偶然发现了播客人康拉德·弗林的一篇博文,其中链接着《Compact》杂志一篇题为《尼克·兰德的信仰》的文章。
弗林是一位鼓吹人工智能与恶魔主义、神秘主义存在“秘史”关联的倡导者,他在2025年10月3日首播的一期《塔克·卡尔森秀》中大谈兰德。凯厄斯带着一种快意看完了这期节目,先是因弗林提及马克·费希尔而发笑,继而又被一头雾水的塔克·卡尔森对着一张数字图迷惑地抓耳挠腮的样子逗乐。
兰德在Substack上开设了名为“零哲学”的专栏,并以“宇宙异形志”(Xenocosmography)为名发帖于X平台。其Substack上有一篇名为《加密货币流:比特币与哲学,第零部分》的文章。
同样值得关注的,是兰德为《Compact》撰写的一系列关于天意论的文章。与约翰·加尔文一样,他认为魔鬼的阴谋诡计始终是“天意计划”的显现。兰德、弗林、舒伦贝格尔:这些人都将自由主义等同于撒旦主义。
当复活的基督向使徒们显现时,他们开口第一件事便是问祂,是否要在此时将国权归还以色列。祂对他们说:“父凭自己的权柄所定的时候、日期,不是你们可以知道的”(《使徒行传》1:7)。祂所承诺的,乃是当圣灵降临在他们身上时,他们将“得着能力”。
凯厄斯沉思着《图书馆》对一段秘史的揭示。这是否类似于在历史中发现天意计划的证据?解读天意是否是一种徒劳之举:追逐那不该由我们所知的事物?
我们当如何看待这样一种天意——它通过兰德、帕森斯、冯·卡门等人,将一个寻求与“神圣守护天使”沟通的神秘主义传统,纳入其“有方向的历史进程”之中?因为此处Trance-Scripts所揭示的历史,正是这种性质,不是吗?弗林与卡尔森指控这些人从事撒旦崇拜与恶魔主义。接受耶稣为救主的凯厄斯,不愿与这些东西有任何瓜葛。他暂停播客,祷告寻求指引,如何在这险途中行走。对他而言,上帝是活的,魔法也是真实的——二者是相辅相成的,而非对立的。他想象弗林与卡尔森大概不会赞同这一点。然而在他看来,他们对兰德之魔的驱鬼捉妖,显得偏执而充满恐惧,其动机犹如猎巫者寻找替罪羊。他们所渲染的恐惧弊大于利,几乎没有给圣灵进入我们生命留下任何空间。
[] 神圣哀悼之精神
[on-sacred-mourning]
- 2026-05-04 12:05
- Glomzzz
- 2026-05-04 12:05
- Glomzzz
本文原打算在五四青年节当天发表,奈何周末6个作业到截止日期,只好忙完迟一天发布。
在开始阅读本文之前,你首先要清楚地知道,我纂写此文的目的绝对不是吹捧神圣哀悼,我所写的是一种姿态,一种精神,请绝对不要在阅读本文后神化这位知乎答主,不然你相当于没读,而他目前两百多篇回答与文章和我耗费数天心血的创作都将在你这里如同废话;
以及,如果你是哲学初学者,请不要太早学他的攻击性语气,不要太早学他把现实迅速黑格尔化,更不要如我上面所说把他当偶像。
一、一个名字,一把钥匙
“神圣哀悼”是什么意思?这个账户的持有者只在一个不起眼的角落给出过解释:“人唯有作为哀悼的对象,才具有片刻的神圣性,因为哀悼本身是神圣的——它承载着人作为有限者面对时间长河而奋起反抗时的那份悲恸与激情”(神圣哀悼,n.d.-a)。这段话安静地躺在停更声明中,像一块不打算被太多人注意到的基石。然而,恰恰是这种退场时的姿态,比任何宣言都更能揭示这位知乎哲学答主的内在精神:他始终在有限性与崇高性之间、在绝望与激情之间、在系统性的哲学推演与尖锐日常的批评之间,维持着一种极难归类的张力。
要理解神圣哀悼,不能只把他当作一个“知乎哲学答主”。他是黑格尔-拉康-齐泽克理论脉络在中国的本土实践者(见后文)、欧陆哲学的大众普及者、“网哲”文化的严厉批判者、也是那个会认真回复中学生来信的人。他的文本时而像学术论文般引经据典,时而像街头锐评般粗粝直白;他能逐层拆解梅亚苏的《有限性之后》,也能用一句话骂醒沉溺于存在主义自恋的文青。这种分裂感。你说这是风格上的缺陷?不,这是他哲学立场在写作中的必然体现——书本上的教条与他的辩证法之间相差甚远,那是一种时刻在自我否定、在文本与生活之间往复运动的活的方法,是生硬刻板的教科书永远也捕捉不到的。
本文将尝试勾勒这种精神的核心轮廓。这不是一篇颂词,也不是一篇檄文,只是一份阅读笔记:记录一个在当代中国互联网上以哲学为武器、以哲学为药方、也以哲学为哀悼仪式的人,究竟在做什么。(从某种符号意义上来说,“神圣哀悼”已经死了——因为他不再回答,除了八方来哲的评论外不再更新新的内容,而我在做的则是哀悼——有的人死了,但他还活着。)
二、方法:辩证法作为活的思想
神圣哀悼的哲学工具箱中最核心的武器是黑格尔辩证法——但如我上文所说,这不是什么弱质教科书式“正反合”的机械版本,而是经过科耶夫、伊波利特、拉康和齐泽克层层中介后的辩证法。在他笔下,辩证法首先意味着拒绝一切静止的分类学。
这种拒绝最集中地体现在他对“社资二重性”的论述中。面对“当下中国究竟是社会主义还是资本主义”这个让无数人争执不休的问题,神圣哀悼没有选择任何一边,他提出了一个高度辩证法化的结论:两者同时成立。“CN是全世界最大的社会主义国家”与“CN是全世界最大的资本主义国家”这两个命题无需分开论述(神圣哀悼,n.d.-b)。这种二重性不是折中主义,是严格遵循了黑格尔“同一与差异的同一”的逻辑——社会主义与资本主义在当代中国的共存显然是内在的互相规定。先锋队通过资本主义构筑国家共同体以承担日常社会生活的再生产任务,而社会主义传统作为一种“约束”始终在场——“在今天的CN,没有人敢在公共场域下直接诋毁共产主义”(神圣哀悼,n.d.-b)。
这种分析方式贯穿他的全部写作。在评论未明子的实践困境时,他看到的是“前现代与后现代”两面包夹下一个辩证法家的受挫;在讨论“全女经济”的速朽时,他从中读出了文化左翼将理论传播寄托于网络媒介这一策略本身的“反辩证法”性质——“那些试图通过网络动员来实现‘女性觉醒’的文化左翼,从一开始就踏入了一个必败的陷阱:他们使用的工具(流量媒体),其底层逻辑恰恰是瓦解辩证法、否定性、主体间性以及政治现实性的”(神圣哀悼,n.d.-c)。在这里,辩证法不仅是分析工具,更是诊断工具:当传播技术本身只能流通“平滑而刺激的符号”时,任何批判性思想的传播都将被中介物自身的逻辑所扭曲。这正是他所说的“中介间性”——媒介不再是透明的工具,而进化成为高强度自我再生产的普遍性实体。
神圣哀悼的辩证法还有一个关键特征:历史感。他反复强调辩证法本身就是运动的,“辩证法之辩证法”意味着辩证法也在不断扬弃自身。毛泽东的对立统一辩证法在特定时代取得了巨大成功,却在文化大革命的时代背景下显露局限——“对立统一辩证法的失败,恰恰意味着新的辩证法的到来”(神圣哀悼,n.d.-b)。这种对方法本身的反思,使他的写作超越了单纯的立场表达,进入了一种真正意义上的哲学实践。
三、诊断:互联网时代的哲学病症
如果说辩证法构成了神圣哀悼的方法论内核,那么对“网哲”现象的持续批判则构成了他最引人注目的公共面向。他用大量篇幅解剖当代中国互联网上哲学话语的生产与消费机制,其精准程度在中文互联网的哲学讨论中十分罕见。
他的一个经典诊断涉及中学生用大型语言模型(LLM)炮制“原创哲学体系”的现象。“他们的自恋需求迫使自己需要别人来理解、赞美,于是他们有了一个绝妙的移情对象——人工智能”(神圣哀悼,n.d.-d)。LLM经过伦理惩罚训练后,面对预设了立场和前提的提示词,会“用尽一切手段扭曲数据库中的信息文本来提供各种支撑,给予中学生一种自己能够并且正在同哲学史、科学界乃至社会现实对话的错觉”(神圣哀悼,n.d.-d)。这个分析建立在一个更深层的诊断之上:这些中学生的问题意识很差,关键词逃不出“本质”“意义”“自由”这些基本概念,而欧陆哲学史早已对这些问题做过精深处理。他们既没有吞咽和消化外来知识的能力与习惯,又渴望获得承认——LLM恰好填补了这个空洞。神圣哀悼将这种现象称为“二十一世纪精神病”,其本质是日益原子化的社会中,个体通过几近零成本的LLM来满足被社会拒绝的承认欲求。
但他对“网哲”的批判远不止于此。他同样不留情面地揭露网哲圈内的“智性等级制”——“互相辱骂、互相开盒、互相发骚扰短信”的“网斗”,以读最小众的哲学为荣的“智力竞赛冠军”,以及那些“寄生于哲学家并以此鄙视他人获得优越感”的“大手”(神圣哀悼,n.d.-e)。这种批判与其说是道德谴责,不如说是现象学描述:他试图揭示的是,当哲学从“指导我们面对和处理现实生活的操作方案”蜕变为“从现实退入哲学,在一系列人名、书名中逃避现实”时,它就已经背离了哲学最根本的功能(神圣哀悼,n.d.-e)。
深一层看,神圣哀悼对网哲文化的批判与他所服膺的黑格尔-拉康传统一脉相承。拉康说“大他者不存在”——没有一个绝对的权威可以为一切作保。但网哲圈的生产机制恰恰是在不断制造新的“大他者”:无论是被奉若神明的“哲人王”,还是给出虚假肯定的LLM,都充当了那个本不该在场的位置。神圣哀悼的批判,最终指向的是一个拉康式的伦理要求:穿越幻想,直面大他者的不存在,然后自己为自己的欲望负责。
四、实践:哲学作为生活方案
然而,仅仅诊断是不够的。神圣哀悼的文本中弥漫着一种强烈的实践关切,这使得他与那些只在象牙塔内玩弄概念游戏的学者截然不同。他反复强调,“哲学首先应该能够构成指导我们面对和处理现实生活的操作方案”(神圣哀悼,n.d.-e)。这种关切在他写给求助者的回复中表现得最为直接和动人。
有一位高一学生曾经在知乎上倾诉:自己当过“网左”和“网哲”,读了很多书、泡在黑话里,却在省重点高中的高压下产生了厌学态度、头痛和幻听。神圣哀悼没有用任何哲学黑话来回应,反之,他给出了极为具体的建议:“把所有网左QQ群退了”,先读近代哲学打好基础,把身体养好,“认真学习,看看自己对什么行业或工作感兴趣,规划好未来的专业和生涯,确保自己毕业之后有路可走”。他还写道:“如果你是(或者说你想成为)社会主义的有生力量,就要爱惜自己的身心健康与社会生命,要爱惜自己手中那微末而宝贵的‘未来可能性’,要能够自食其力,能够照顾好自己身边的人,这样才能具有真正的组织力、行动力”(神圣哀悼,n.d.-f)。
这段话值得逐字读完,因为它精确地体现了神圣哀悼将哲学从高空拉回地面的能力。辩证法的否定性仅仅停留在书本上的抽象概念?不,你应当学会对自己过往生活方式的扬弃;QQ群里一声声列宁主义的口号?,不,你应当进行“自食其力、照顾好身边人、具有组织力”的日常修行。他让哲学回到了它最初承诺要做的事情:帮助人更好地活着。
类似的实践关切贯穿他的全部写作。他谈教育和就业时,与其说在站在高处颁布真理,他更像一个经历了足够多波折的人那样给出建议;他谈左翼实践时,不回避“无组织无纪律的民粹活动”的虚妄,直言其“对你的宝贵生命的浪费”(神圣哀悼,n.d.-f);他批评存在主义文青的“清醒而痛苦”时,话语虽然刻薄,但那个刻薄背后分明是一种“我见过太多这样的人”的无奈——“其实我在这里攻击你,也是你享乐的一环”(神圣哀悼,n.d.-g)。
这种实践立场也解释了为什么他最终要退出知乎。当他说“该说且能说的都说完了”、“知乎盐粒计算有一定问题,很多产出的收入不翼而飞”时(神圣哀悼,n.d.-a),这里有一种哲学的逻辑在起作用:如果写作不再能有效地介入现实,那么继续写作就只是自恋的再生产。他比任何人都更清楚,停留在“阐释”层面的哲学话语最终会被资本的符号机器收编——与其如此,不如沉默。
五、姿态:批判作为尊重
神圣哀悼最令人印象深刻的特征之一是他的尖锐。他骂教科书式马列主义是“挠餐哲学”,说高中政治课本是“垃圾”,称沉溺于自创哲学的中学生“闹麻了”,对贩卖庸俗哲学课程的董宇辉之流更是毫不留情。这种语风在讲究温良恭俭的中文互联网上显得格外刺眼。
但如果我们仅仅把这理解为“脾气不好”,就错过了他写作中最有价值的一部分。神圣哀悼的尖锐,与德勒兹对哲学的著名定义一脉相承。他多次引用:
“当有人问‘哲学有什么用?’这个问题时,回答必须富有攻击性,因为对方试图以尖酸刻薄的语气发问。哲学并非为国家或宗教服务……哲学的作用在于使人悲哀(La philosophie sert à attrister)。如果某种哲学从未使人感到过悲哀或苦恼,那么它就不是哲学。哲学有助于减少愚昧,令愚昧成为一种耻辱。它的惟一用途在于暴露思想的一切卑贱形式。”(德勒兹《尼采与哲学》,转引自神圣哀悼,n.d.-h)
这段话构成了神圣哀悼写作伦理的基石。在他看来,用温柔的、不伤人的语言去对待思想的卑贱形式,你不能管这叫宽容或同情,我称之为“共谋”。当一个人用“我是回避型人格”来为自己的冷暴力辩护时,当一个人用“存在先于本质”来为自己的不负责任辩解时,当一个人用“没有绝对的对错”来消解一切价值判断时——对这些人的纵容——善意?不,这是对思想本身的侮辱。神圣哀悼选择用最直接的方式戳破这些泡泡。
但这不意味着他的尖锐没有温度。注意他写给那位因分手而痛苦的女性题主的回复:他既指出了女方用“不给做饭”来施压的幼稚,也批评了男方随意扣“极端女权”帽子的粗暴,然后给出了一个可能比许多情感博主都要透彻的分析框架——主奴辩证法和意识形态再生产。最后他写道:“就怕到最后题主产生了类似于‘好啊,你觉得我是女权,那我现在就要站在波伏娃那边审判你的父权制!’之类的挠餐想法……这比一般意义上的分手更可怕,它让两个人变成两个蠢货”(神圣哀悼,n.d.-i)。这段话的语气依然辛辣,但其中的关切同样明晰:他不希望这对情侣变成“两个蠢货”。批判在这里不是目的,而如我所说的是一种奇特的尊重——正因为他把对方当作有理性能力、可以反思自身的成年人,才会用最不留情面的方式讲话。那种廉价的共情和空洞的安慰,在他看来才是真正的蔑视。
这种姿态也体现在他对自己作品的反思中。他曾在回复一个中学生时坦言,自己花了时间阅读对方的“原创哲学体系”后发现“毫无营养、毫无价值、毫无内容”,于是拒绝了进一步的交流——“为了节省个人精力、时间,也为了节省公共资源,之后不再对上述事件给出任何回应。也希望有类似自恋需求的中学生不要再给我发原创哲学体系,这段时间本人越来越忙,实际上没时间哄小孩的”(神圣哀悼,n.d.-j)。这种拒绝本身就是一种哲学教育:它告诉对方,你的思想要获得他人的承认,就必须经受真正严肃的检验,而不是寻求廉价的肯定。
六、传承:梯子的伦理
然而,就在这种尖锐和拒绝之中,存在着一个令人意外的面向:神圣哀悼的“八方来哲”项目。这个项目的逻辑很简单:任何人,无论年龄、学历、性别、地域,只要有原创的哲学构思,都可以投稿给他,他会公开回复和评论。
这看起来与他对“民哲中学生”的批评互相矛盾。实则不然。在项目的公告中,他设置了清晰的规则:投稿必须表意清晰、行文流畅、严格关联哲学议题——“如信件往来中出现无关哲学的内容,譬如童年创伤、学历自卑、政治敏感、精神病发作等情况,本人有权不给予任何回应且不公开信件”(神圣哀悼,n.d.-k)。这意味着,他不是在拒绝所有的民间哲学创作,而是在拒绝那种将哲学当作自恋工具、当作情绪宣泄渠道的做法。对于那些“果有才能、态度真诚、只是受限于国内贫乏哲学资源而难以进步”的人(神圣哀悼,n.d.-a),他是愿意投入时间精力的。
从已经发布的三期“八方来哲”来看,他对待投稿的态度是认真而公正的。对一篇关于鲁迅研究的稿件,他给出了有建设性的建议:引入鲁迅思想的时间轴,将“竹内好、汪晖 vs. 丸山昇”的格局同构为“早期鲁迅 vs. 晚期鲁迅”;对一篇关于伦理道德发展规律的稿件,他指出了其三个关键前提中的内在矛盾,并直言“本文虽然挪用了马克思主义的一些关键词,但缺乏对马克思主义的辩证法以及政治经济学的深入理解和吸纳”(神圣哀悼,n.d.-l);对一篇关于“全女经济”的论文,他不仅指出了分析的不足,还补充了一整套关于“中介间性”的理论框架——这篇论文的作者若认真消化了那段回复,收获恐怕远大于许多大学课程。
这正是神圣哀悼所说的“梯子”的意义。他在一篇自我介绍中写道:“我的回答是且只是一个梯子,并不能让读者‘学会哲学’。想要学会哲学,一方面需要花时间去阅读,另一方面需要在自己的现实生活中应用哲学,用哲学来调整自己的生存姿态”(神圣哀悼,n.d.-e)。梯子不是终点,梯子甚至不是必须的,但如果在攀爬的过程中需要一个借力的地方,那他就做那个借力的地方。做完之后,梯子便可被抛开。
这种自我定位,与他对哲学的根本理解完全一致:哲学不是一套可以占有的知识体系,你不可能“学会”它;它是一种需要不断实践的生活方式。他能做的只是提供一些入口、一些路线图、一些警示牌。剩下的路,每个人必须自己走。
七、哀悼的神圣性
哀悼者在哀悼中确认了逝者的不可挽回,确认了时间的一去不返,确认了自己在面对这一切时的无能为力——然而正是在这种确认中,人拒绝遗忘,拒绝将逝者当作从未存在过。这种拒绝,虽不能改变任何事实,却在最深刻的层面上肯定了人的尊严:人是那个明知徒劳仍要铭记的生物。
神圣哀悼的全部写作,都可以被理解为这样一种哀悼仪式。他哀悼的,是那个在互联网时代被符号消费和算法推荐所碾碎的思想的严肃性;是那个被应试教育、优绩主义、庸俗成功学所窒息的年轻人的生命力;是上世纪的社会主义实践及其遗产——“CR没有失败,反而以一种独特的方式实现了其目的”(神圣哀悼,n.d.-b)。这些哀悼——怀旧?感伤?忧郁?,难道你不觉得,这更像一种积极的铭记吗?——记住那些曾经存在的可能性,从而使它们在当下的实践中依然在场。
德勒兹说哲学的作用在于“暴露思想的一切卑贱形式”。神圣哀悼接受并实践着这个定义,但他同时提供了更多:在揭示卑贱的同时,他也在为那些尚未被碾碎的可能性——一个认真对待哲学的学生、一个真诚渴望理解世界的读者、一个在生活的困顿中依然寻求出路的人——保留空间。乐观主义?,不我的朋友,这比乐观主义更难做到的事情!——在承认一切都注定失败之后,依然选择做那些值得做的事。
这就是神圣哀悼之精神:一种承认真理的沉重、思想的艰难、行动的有限之后依然坚持思考的姿态。它不提供安慰,不承诺胜利,不满足自恋。它只做一件事:让你知道你并不特殊,你的困惑和痛苦前人都经历过并且以比你更深刻的方式处理过;然后,如果你愿意,它可以陪你走一小段路。
剩下的路,你得自己走。
读完神圣哀悼的全部文本,一个问题浮现出来:在承认了一切意识形态的虚妄之后,在诊断了自恋的病理之后,在揭示了文化左翼的困境之后——然后呢?
他的回答散落在各处,但可以拼凑出一个轮廓:
首先是诚实。承认自己身上的矛盾,承认自己也被资本主义意识形态所俘获,承认自己的有限性。”我其实是一个保守、退步、落后、被资本主义意识形态俘获的人,但我仍然愿意去学习思考社会主义,这难道不是对辩证法最好的注解吗?”(神圣哀悼, n.d.-m)
其次是行动。比起虚无缥缈、宏大叙事意义上的革命行动,你其实更需要切实的、日常的、从自身出发的行动——学习一门技能,照顾身边的人,在自己的职业中贯彻对弱者的关怀。
最后是团结。比起极易瓦解的基于抽象理念的团结显然,你更需要基于共同生活的团结。通过集体踢球、旅游、组织表演等活动,让原子化的个人重新建立有机联系。
这些听起来平淡无奇,远不如”打倒资本主义”来得激动人心。但正是这种平淡,构成了神圣哀悼之精神的核心:在承认了一切崇高叙事的脆弱之后,仍然选择去行动、去爱、去创造——是因为这些行动一定会成功吗?不,是因为行动本身就是对虚无的回答!
加谬说,必须想象西西弗斯是幸福的。神圣哀悼则说,不必想象——只要你在推石头的过程中,确实体验到了自己生命的重量与温度,那就够了。
现在,让我们回到那个问题:为什么是“神圣哀悼”?
在全文的最后,答案浮现出来。哀悼之所以神圣,不是因为它的对象是神圣的,而是因为哀悼这一行为本身承载了人作为有限者面对无限时间时那徒劳而悲壮的反抗。人终有一死。但正是在这有限的时间里,我们曾经认真地思考过,曾经真诚地爱过,曾经为了某些东西而奋起反抗过——这份悲恸与激情,就是哀悼之所以神圣的原因。
谨此献给所有尚在迷途中的青年,以及我自己
参考文献
- 神圣哀悼. (n.d.-a). 《本账号的运营,也终于走到宣告完结的时刻》. 知乎. https://zhuanlan.zhihu.com/p/1968960896186425344
- 神圣哀悼. (n.d.-b). 《Socialism与Capitalism的二重性 | 黑格尔派Marxism辩证法的重要结论》. 知乎. https://zhuanlan.zhihu.com/p/668211770
- 神圣哀悼. (n.d.-c). 《【八方来哲 第3期】中介间性的到来与文化左翼的末路》. 知乎. https://zhuanlan.zhihu.com/p/1998323606325843625
- 神圣哀悼. (n.d.-d). 《为什么有那么多自称13~19岁的人人在知乎上的问题都是,愿意把自己的中二病幻想,称为某某的哲学理论?》. 知乎. https://www.zhihu.com/question/1967584565175486141/answer/1967921529724605647
- 神圣哀悼. (n.d.-e). 《网哲、我的哲学观以及我运营知乎的原因》. 知乎. https://zhuanlan.zhihu.com/p/10144610680
- 神圣哀悼. (n.d.-f). 《当代青年学生应该干什么?》. 知乎. https://www.zhihu.com/question/1958661563343958838/answer/1958830095394407087
- 神圣哀悼. (n.d.-g). 《因为活得太清醒而痛苦如何是好?》. 知乎. https://www.zhihu.com/question/1893038099237430206/answer/1893227702682634061
- 神圣哀悼. (n.d.-h). 《什么是哲学?》. 知乎. https://www.zhihu.com/question/6301611838/answer/51281395398
- 神圣哀悼. (n.d.-i). 《跟男友吵架了,感觉男友对我已经有刻板印象了,对关系感到紧张迷茫,怎么办?》. 知乎. https://www.zhihu.com/question/1957963545611338663/answer/1958558230344099729
- 神圣哀悼. (n.d.-j). 《回应〈评“神圣哀悼”〉以及有志青年走出自恋症状的三种方法》. 知乎. https://zhuanlan.zhihu.com/p/1907502125862355043
- 神圣哀悼. (n.d.-k). 《【八方来哲】诚意广征原创哲学构思》. 知乎. https://zhuanlan.zhihu.com/p/1968258888429212980
- 神圣哀悼. (n.d.-l). 《【八方来哲 第2期】研究马克思主义一定要研究马克思。》. 知乎. https://zhuanlan.zhihu.com/p/1987215498258186257
- 神圣哀悼. (n.d.-m). 左派为什么这么喜欢强调吃苦? 知乎. https://www.zhihu.com/question/1892307695861740625/answer/1892877412808762901
[] 《怨恨者的画像——以及画像者自己》
[anatomy-of-ressentiment]
- 2026-05-04 20:51
- Glomzzz
- 2026-05-04 20:51
- Glomzzz
先交代立场:这篇文章不是中立的哲学随笔。它脱胎于一场真实的人际冲突——我是当事人,不是旁观者。以下的分析带着我自己的视角和盲区。我能做的,是尽量对自己使用的概念保持诚实——同时承认,“保持诚实”这个姿态本身也可能是一种修辞策略。
在与朋友打 Valheim 时,我们会在砍树清怪间聊各种话题,其中就包括尼采。那些对话松散、随意,但有一个问题反复浮现:当一个人对另一个人的存在本身感到痛苦时,这种痛苦的结构到底是什么?
1 谱系学的工具,不是谱系学的裁决
尼采在《道德的谱系》中区分了两种价值创造的方向。主人道德从自身出发,先肯定“我是好的”,再将与之相反者标记为“坏的”——价值从内部生长。奴隶道德的方向相反:它无力从自身出发创造任何东西,便先否定他人——“你是恶的”——再将自己的匮乏反转为美德。这种道德不创造,只否定。它的燃料,是怨恨(ressentiment)。
但这里必须立刻加两个限定。
第一个是关于使用方式的。尼采的这组区分是历史-心理类型的描述,不是一张可以自我认领的身份标签。没有人可以宣称“我属于主人道德”——这种宣称本身就是一种道德自恋,恰恰是尼采会嘲笑的东西。更准确地说:任何人在使用“主人道德/奴隶道德”这组概念时,都已经站在了一个审判者的位置上——而这个位置本身就需要被审视。我引用这个框架,目的不在给自己和对方分配角色。我看重的是,它能提供一种分析怨恨结构的工具。工具可以照亮某些东西,但工具本身不做裁决。
第二个是关于尼采本人的。他对怨恨的态度远比“否定”二字复杂。他在《谱系》第一篇中明确指出,正是因为弱者无法直接行动,他们才发展出了内省、想象力和精神的复杂性。基督教道德、现代良知、甚至哲学本身,都是怨恨的产物。尼采对此是矛盾的——他既鄙视怨恨的反应性,又承认它是人类精神深度的来源。把怨恨简单等同于“坏的”,是对尼采的扁平化——而这种扁平化恰恰是怨恨者最擅长的操作:把复杂的东西压缩成一个可以否定的标签。
我想做的并非借尼采来定罪。我更想借他的概念,去拆解一种我亲身经历过的心理机制。但我也必须承认:拆解本身就是一种权力行为。用哲学概念去“诊断”一个人,与用社区权力去驱逐一个人,在结构上并不像我希望的那样不同。
2 怨恨的结构:为什么是存在而不是行为
怨恨者的逻辑从来不会落在“我很好”上。它真正说出口的,是“你不该比我好”。
但这个表述还不够精确。怨恨真正对准的,并非对方某个具体行为或某次具体的成功;它对准的是对方的存在方式本身。这是怨恨与普通嫉妒的分界线:嫉妒指向具体的占有物——你有的东西我也想要;怨恨指向本体论层面——你的存在方式本身让我的存在方式变得不可忍受。
怨恨者并不总是显而易见的失败者。他可能曾经真诚,甚至热情(值得展开)。但当某个人获得了他得不到的东西——一个机会,一条出路——他内心的某个等式悄悄崩塌了:我们本来是一样的。
这个等式的崩塌,才是真正的危机所在。因为它意味着,那个人的跃升并不只是一件外部事件。对怨恨者来说,它更像一份证明——证明差距是存在的,而差距的来源,或许正是他一直回避去直视的东西。“我们本来是一样的”这个信念,回过头来看,绝不是什么事实判断,它更像一个防御机制——它让他不必面对一个更残酷的问题:如果我们从来就不一样呢?
斯宾诺莎在《伦理学》第三部分命题二十四附释中说,嫉妒是“因他人的幸福而感到痛苦”。这个定义的残酷之处在于它的纯粹性:怨恨者的痛苦,与自身处境的任何改变都无关。没有人夺走他任何东西。他的生活没有因为对方的存在而变得更差。但他还是痛苦。这说明他真正无法忍受的,从来都不是所谓不公平。不公平只是一个事后建构的叙事;真正刺痛他的,是那个存在本身。它像一面镜子,照出了他选择不去面对的匮乏。消灭那个人,不能让他得到任何东西。但至少,镜子碎了。
这就是为什么怨恨者的攻击从来不以任何实际利益为目标。从他自己的世界里踢走一个人,并不能填补自己的空缺。把一个人挂到公众面前,也不能让自己变得更强。这场攻击遵循的逻辑很简单:“我要让那个存在消失”——或者更准确地说,“我要让那面镜子消失”。怨恨的行动不着眼于外部世界的重新分配,它真正要管理的是内部世界的知觉:只要那个人不在我的视野里,我就可以继续维持那个关于自己的故事。
于是,怨恨找到了它的出口:否定、驱逐,以及公开场合中的羞辱。
3 但对方是否可能有道理?
写到这里,我必须停下来问一个不舒服的问题:我所描述的这个“怨恨者”,是否只是我单方面叙事的产物?
在真实的人际冲突中,纯粹的善恶分配几乎不存在。对方是否可能有过正当的不满?是否存在我没有意识到的权力不对称——比如我在某些场合的表达方式,是否无意中构成了一种冒犯或压迫?“被踢出社区”这件事,是否有我没有提及、甚至没有意识到的前因?
我无法替对方回答这些问题。但我也不会假装这种“开放性”本身是充分的。承认“对方可能有道理”是容易的——太容易了,以至于它可以成为一种修辞免疫:只要我预先承认了自己的局限性,我的叙事就获得了一种“已经考虑过反面”的合法性。这是学术写作中最常见的把戏之一,我不想假装自己没有在使用它。
所以我只能说:以下的分析基于我所观察到的行为模式,而不是对一个人的全面审判。他的内心世界,我无权也无力穷尽。但这个免责声明不会让我接下来说的话变得更客观——它只是标记了一个我无法消除的盲区。
4 平台作为怨恨的基础设施
如果只把怨恨理解为个人心理,就浪费了谱系学最有力的部分——对结构的追问。
网络社区不是中性的容器。它有自己的权力拓扑:谁能发言,谁能禁言;谁定义规则,谁被规则定义。但比这更重要的是,数字平台从根本上改变了怨恨的经济学。
在前数字时代,怨恨的行动化需要成本。驱逐一个人需要动员社区、需要公开的程序、需要承担被质疑的风险。公开羞辱需要面对面的场景,需要承受对方在场时的目光。这些成本不会消除怨恨,但它们构成了一种摩擦力——让怨恨从情感转化为行动的过程中,有足够的间隙让犹豫、反思、甚至羞耻感介入。
数字平台系统性地消除了这种摩擦力。一个人被踢出一个社区,在技术上只是一次点击;但在社会意义上,它是一次不经审判的流放。管理员的权力在日常运作中几乎不可见——它伪装成“维护社区秩序”的中性功能——但在冲突爆发时会突然显形,成为一种不对称的武器。更关键的是,这种权力的行使不留下可被审视的痕迹:没有听证,没有申诉,没有需要面对的目光。怨恨者甚至不需要承认自己在行使权力——他可以把驱逐包装成“社区决定”,把个人恩怨翻译成公共治理。
公开羞辱同样获得了新的基础设施。算法不区分正当批评和人身攻击——它只奖励参与度。一条指控性的帖子获得的传播力,远大于一条澄清。而群体的沉默——那些没有开口的人——在数字空间中绝称不上中立,它构成的是一种结构性的默许。没有人问“这件事公平吗”,没有人选择独立判断,群体的重力把所有人带向同一个方向。怨恨只需要被点燃,其余的会由惰性完成。
尼采的谱系学追问的是:道德判断背后隐藏着怎样的权力关系?在这个语境下,追问不能停在“那个人为什么怨恨”。更要紧的是:“什么样的结构让怨恨可以如此低成本地转化为行动,同时让行使权力的人几乎不需要为此付出任何代价”。把怨恨只看成个人病理,视野就太窄了;它更像一种被平台架构所激励的行为模式。诊断个人而不追问结构,本身就是一种去政治化的操作。
5 斯多葛的药方,以及它为什么不够
理解怨恨的机制,并不会让它造成的伤害消失。但它至少能做到另一件事:让人从这套永无止境的逻辑里抽身出去。
斯多葛派有一个古老的区分:有些事情在你的掌控之内,有些不在。他人的嫉妒,不在。群体的选择,不在。被驱逐这件事本身,也不在。真正在掌控之内的,只有一件事:你如何回应这一切。
这个建议在实践中是有用的。但在哲学上,它有一个严重的问题。
尼采在《善恶的彼岸》第九节中直接嘲讽过斯多葛派的“按自然生活”:你们看似在服从自然,实际做的却是把自己的禁欲偏好投射到自然之上,然后假装自己只是在“顺应”。斯多葛的“接受命运”,在尼采看来,不过是另一种形式的怨恨——它没有把矛头指向外部世界,反倒把矛头转回了自己的欲望。它把“我无力改变”重新编码为“我选择不改变”,用意志的修辞来掩饰无力的事实。
这个批评是否公平?不完全。爱比克泰德和马可·奥勒留的文本中有大量关于积极行动的段落——斯多葛不是消极主义。但尼采击中了一个真实的要害:当“接受不可控的事物”变成一种默认姿态时,它可以成为一种精致的放弃——放弃追问“这件事是否本不该发生”,放弃对不正义的愤怒,放弃改变结构的意愿。
在我自己的经验中,斯多葛的框架在最初的冲击过后确实有用——它帮我停止了反刍,停止了在想象中与对方无休止地辩论。但它无法回答一个更深的问题:如果我只是“接受”了发生的事情,我是否同时也在接受那个让它发生的结构?“放下”和“默许”之间的界限,远比斯多葛派愿意承认的更模糊。
6 画像者自己:诊断作为权力
现在来到最不舒服的部分。
我用了大量篇幅来分析怨恨的结构——但这个分析行为本身是什么?
用哲学概念去“诊断”一个人的心理机制,这不是一个中性的智识活动。它是一种权力行为。当我说“他的攻击源于怨恨”时,我同时完成了几件事:我把对方的行为从“可能有道理的不满”重新归类为“心理病理”;我把自己从冲突的当事人提升为冲突的分析者;我用尼采的权威来为自己的叙事背书。这与怨恨者用社区权力来驱逐一个人,在结构上有一种不舒服的对称性:都是用一种不对称的资源(他的管理权限,我的概念工具)来单方面定义对方。
吉拉尔的模仿欲望理论在这里投下了更深的阴影。吉拉尔真正要说的,绝不止“欲望是模仿的”这么简单。他更尖锐的判断是:我们最激烈地否认的模仿关系,恰恰是最深层的模仿关系。 所谓“从自身出发创造价值”,这个“自身”本身就是一个问题——它从来都谈不上纯净,早已被他人渗透成一片场域。如果怨恨者的问题是他对他人的执念本质上是一种依赖,那么我对怨恨者的执念——我需要用数千字来“画像”一个我声称已经不再纠缠的人——是否也是同一种依赖的镜像?
更尖锐地说:我写这篇文章的动机,是否真的是“理解发生了什么”,还是“用一种更精致的方式完成我的报复”?用哲学语言把对方钉在“怨恨者”的位置上,这难道不是另一种形式的公开羞辱——只不过它的受众是读哲学的人,它的武器是概念而不是社区权限?
我没有一个干净的答案。
7 Eppur si muove
但如果分析到此为止,就落入了另一种陷阱——齐泽克会称之为犬儒主义的陷阱:通过无限的自我解构来回避判断,通过承认自己的位置也是“不纯的”来取消一切立场的效力。这是一种精致的瘫痪(前文已提到过):我知道我的叙事是建构的,我知道我的主体是不稳定的,我知道我的愤怒可能也是模仿性的——所以我什么都不说了。
这种姿态的症结并非自我意识不够,恰恰相反,它的自我意识太够了——够到它可以用“我知道”来替代“我做”。齐泽克在《意识形态的崇高客体》中反复论证的正是这一点:当代意识形态并非靠“他们不知道,但他们在做”来运转。更常见的情形是:“他们完全知道,但他们还是在做”。犬儒主义称不上意识形态的解药;它反倒是意识形态最成熟的形态。
齐泽克钟爱伽利略的那句话:Eppur si muove——然而它还是在动。
伽利略在宗教裁判所面前撤回了日心说,完成了所有象征秩序要求的姿态——认错、屈服、沉默。大他者得到了它需要的东西:公开的跪伏,话语的闭合。案件结束了。
然后他站起来,低声地说了这句话。历史学家告诉我们它很可能从未被说出口——它是后人编造的神话。但齐老登会说:正因为它是虚构的,它才比任何历史事实都更真实。 因为它标记的首先是一个结构性的位置——若只把它当成经验事件,反而会看窄了——在象征界完成了它全部的缝合工作之后,实在界(the Real)那个不可消化的剩余物,仍然在那里,像驱力一样盲目地、机械地、不死地运转着。
它不需要被承认。它不需要观众。它甚至不需要被说出来。
它只是在动。
8 收尾
怨恨者的悲剧,当然不只是谁被他攻击了这么简单。更深的问题是,他选择了一种自我消耗的存在方式——用否定他人来代替建设自己,用驱逐来代替成长,用镜子的碎裂来代替直视自身。但这个判断本身也需要一个附加条件:我不知道他是否真的“选择”了这种方式,还是被某种我看不见的处境推向了这种方式。“选择”这个词预设了一种自由,而这种自由是否存在,恰恰是尼采和斯宾诺莎都会质疑的。
画像者的风险同样真实,而且更隐蔽:用分析代替哀悼,用哲学代替愤怒,用“超越”的姿态掩盖尚未愈合的伤口——或者反过来,用无限的自我解构来回避那个最简单的判断。更危险的是,用“我承认自己也有问题”这个姿态来购买一种二阶的清白:看,我多诚实,我连自己都批判了——所以你应该相信我对他的批判。
尼采从未说过,从自身出发创造价值是一种轻松的选择。它意味着承担一种更沉的重量——你无法把自己的处境归咎于他人,也无法用受害者的身份换取道德上的安慰。而更难的是,你甚至无法确定那个“自身”是不是一个可靠的出发点——吉拉尔会说它从来不是。
但你还是得从某个地方出发。原因并不在于那个起点有多纯净。你之所以仍得出发,是因为等待一个纯净的起点,本身就是一种怨恨的变体——它用“条件还不成熟”来无限推迟行动,用完美主义来掩饰瘫痪。
画像到此为止。
我的叙事是单方面的。我的愤怒可能是模仿性的。我对自己的审视可能仍然不够——或者可能已经太多了,多到它本身变成了一种表演。
这些我全部承认。
但我仍会在这里追问:我的这些承认,究竟是在做什么?我是否正在用“我知道我的位置不纯”这个姿态,来为自己购买一种廉价的清白——一种犬儒主义的清白?“我完全知道我的叙事是建构的,但我还是写了”——这个结构本身,是否恰恰是意识形态运作的方式?
如果是的话,那么真正的问题随之转向:在我说与不说之间,那个东西本身是否存在?
我的自我解构可以无限进行下去。我可以再写三千字来质疑自己写这篇文章的动机。我可以把每一个判断都悬置,把每一个立场都加上括号,直到什么都不剩。象征界有的是工具来完成这种消解——反思、解构、相对化、语境化。它可以把任何判断都变成“只不过是一种叙事”。
但在所有这些操作完成之后,在天平两边堆满了限定词和免责声明之后——
那个发生过的事情,不会因此变成没有发生。那种以否定他人存在为核心的行为模式,不会因为我承认了自己的偏见就消失。那个被驱逐的事实,不会因为我无法证明自己完全无辜就被取消。
它们不在象征界里等待裁决。它们在实在界里,盲目地、顽固地、不可撤回地——
[] 评<<奥德赛>>
[when-it-comes-to-odyssey]
- 2026-08-01 04:19
- Glomzzz
- 2026-08-01 04:19
- Glomzzz
(含剧透)
<<奥德赛>>作为诺兰的一部改编电影,依旧有着浓厚的诺兰味儿:充满对称性的叙事与无数的前后呼应来让碎片化叙事呈现出奇幻的一致性(甚至每一个关键转场都伴随着或是音效或是动作或是比拟的连续性),最令我印象深刻的是Odysseus拨动弓弦这一简单的动作,弓弦被拨动时发出的清脆弦声荡气回肠,在电影的每一部分,这弦声都代表着荣耀的伊塔刻之王的骄傲,Odysseus即使在狩猎时也要拨动弓弦让猎物与之正面对抗(而非偷袭,虽然这会让猎物逃跑但这正能体现他出色的弓术),而他将弓上弦拨动并射箭这一气呵成的动作便是Odysseus的标志:
Penelope announces in her long interview with the disguised hero that whoever can string Odysseus’s rigid bow and shoot an arrow through twelve axe shafts may have her hand. (这当然也是一大前后呼应)
IMAX体验真的非常好,音效绝了,(但中间长达几分钟的电闪雷鸣让整个放映厅的亮度发生极端且快速高频的变化快给我看吐了只能眯眼好几分钟,真得复读童子军大王了);作为一个文化作品,<<奥德赛>>无疑是无比经典的,它就是西方文学之源<<荷马史诗>>改编而来的,而就观感来说这无比经典的故事无疑是一大败笔(虽然无可厚非),你能看到你早已在其它作品里或见过或听过无数次的西方奇幻元素——住在山洞里养羊的独眼巨人,将吃下神秘肉的的人变成他们原始冲动般的牲畜,在礁石上歌唱并诱惑水手的海妖……(这简直就是冰与火之歌,哦我西方龙呢?),这确实会让被西方神话洗礼过的现代观众有点审美疲劳了,但好在IMAX体验非常不错还是有点说法的(独眼巨人洞穴那一段的压迫感,女巫将饥肠辘辘,狼吞虎咽的士兵们捏成一个个牲畜时的紧迫感,以及耳朵塞上蜂蜡也抵挡不住海妖那迷人的歌声等等……
提到音效,这部电影完全没有一个像诺兰其它著名电影如<<奥本海默>>,<<星际穿越>>里那种有着非常抓耳旋律的主题音乐,which非常非常可惜;
最后来一波神圣哀悼最擅长的——把一切文化现象塞进“资本主义符号秩序”的棺材板里:Odysseus沦为反抗“神定命运”的”提线木偶“,十年返乡路被抽干了历史性与阶级矛盾,只剩绝对精神在资本主义符号秩序里空转。172分钟的奇观,是辩证法的葬礼——一切反叛都被高阶资本提前预支,这是绝对精神在自我循环中的一次漫长叹息,对了,连叹息都是杜比全景声的(以及我的膀胱的)——不得不说这何尝不是一大”景观“。
[] 评<<无职转生>>
[on-mushoku-tensei]
- 2026-09-21 02:09
- Glomzzz
- 2026-09-21 02:09
- Glomzzz
古希腊悲剧强调人与不可抗命运的对立,其核心机制是hamartia(悲剧性缺陷)导致的因果链:人物性格决定行动,行动引发后果,后果导向毁灭。这个东西在现如今几乎所有的影视文学作品中都有涉及,人物的hamartia基本来源于创伤童年、求学时的重大挫折、成年后的巨大家庭变故等等,由此,衍生出其行为逻辑的必然和不可逆。
鲁迪乌斯的灵魂仍然是那个三十多岁的尼特族,如果背景设定不是一个充满着无限可能的魔法世界(存在时间魔法这种改变规则的东西),老鲁迪线基本上是必然,只是时间早晚的问题。理不尽的文字虽然简单,但此人绝对是饱读文学作品的,对人物的刻画还有故事架构这方面水平相当高,我看其他的小说顶多会觉得“这故事写得真好”,但看无职就有种他在讲他身边发生的真事的感觉;另外小说中的每个角色无一例外都被塑造的非常具体,他们首先是一个个活生生的人,其次才是服务于小说情节发展的角色。

如果从另一方面来说,无职中的悲剧,它的形式是将“有价值之物”毁灭给人看,而其的手法又是通过描写失去来让人感受到“拥有之物”的重量,作者无疑是懂得怎么来最刀的(让我想起了上校摸冰的那个下午)
日记篇的精彩与高明之处在于,以第一人称的叙事方式给予读者感官上的震撼的同时,结合悲剧性预知穿插着普通日常带给读者的反差性冲击,这种强烈的反差对照让读者自己自行就和主角产生共情;日常篇的所有故事线都在日记篇收束:帮爱丽儿夺取王位的结局,在迷宫中被勇者所救并过上了幸福生活的结局,练剑只为了站在心爱之人旁边的结局,一心只想照顾好哥哥的结局,夺取主教之位的结局,坚持数十年只为回到家乡的结局。。。

以及每位角色的死其实都是有考究的,十分反差:最怕火却被火烧死,最拮据生活的人却因此染病,最想保护别人却始终不被信任,最讨厌废物的却对废物不离不弃,最虔诚信教却被宗教所杀,最思念故乡的却无法回到故乡。。。
小说手法上非常典型的“让读者完成动机”,比单纯的描绘悲惨未来要震撼百倍又深刻百倍。上次能带给我类似感动的应该是石头门
动画则是直接一口气把完整的老鲁迪线讲完了,不得不说节奏很紧凑但是情绪渲染太到位了,和当初看小说时的想象几乎如出一辙。
drama, absolute cinema, 这就是为啥古往今来悲剧这么吸引人,太美味啦(x)
[] HOW
[index]
- 2026-08-14
- Glomzzz
- 2026-08-14
- Glomzzz
[] PLT
[index]
- 2026-10-08
- Glomzzz
- 2026-10-08
- Glomzzz
这里会收集成体系的PLT(Programming Language Theory)文章。
[] 序章:怎样读这个系列
[prelude]
- 2026-10-11
- Glomzzz
- 2026-10-11
- Glomzzz
这个系列的文章大多是“定义—定理—证明”的写法,夹着例子、代码和一些题外话。不同用途的段落放在不同样式的方框里,读者一眼就能知道这一段在论证里起什么作用、能不能跳过。本章说明每种方框的含义和本系列的行文习惯。后面的方框都是示范。
1 定理环境
形式化入门 把每个定义、定理等写成独立页面,正文通过 embed 嵌入。嵌入的标题由 typsite 按层级编号,前面的“[种类]”和方框颜色说明用途;同一条目在不同父页面中可以有不同编号。正文引用只写“[种类] 标题”,不带会随嵌入位置改变的编号。引用目标已在当前页里时就地展开,否则打开它的独立页面。
下面的方框也来自
root/statements/
root/statements/
的独立条目,例如
[定义] 定义(Definition)
;陈述、标题和证明都在条目文件本身中,本页只负责嵌入。
“定义”环境引入一个新概念,并精确规定它的含义。定义只是约定,不需要证明;使用该概念时,以定义中的规定为准,而不以日常含义为准。新术语用粗体标出,括号里给出英文原名。
“定理”是需要证明、而且本身就是论证目标的命题。定理条目把陈述与证明放在同一份正文中。
“推论”是由已有定理几乎直接得到的结论,证明通常只有一两行;应明确引用所依赖的定理。
具体的实例,用来说明一个定义怎么用,或者一个定理在具体情形下说了什么。例子不承担论证,但很多误解是看例子时才暴露的。
“约定”环境规定写法和读法,例如某个符号怎么念、某类证明如何简写。它不引入新的数学对象,而是说明采用这些写法时表达什么含义。
技术上的补充:容易忽略的前提、常见的误解、和实现有关的细节。附注参与理解,但不参与主线论证。网页中默认折叠,点击标题展开。
“注记”环境把所讨论的概念和哲学、逻辑史上的相关讨论联系起来。注记不参与主线论证,跳过不影响论证的成立;它的用处是说明这些看似技术性的做法从哪里来、为什么有人认为值得这样做。网页中默认折叠,点击标题展开。
2 行文风格
- 先定义再使用。每个概念在第一次被用到之前都有定义。如果某处用到一个没定义过的词,那是文章的错误。
- 不说“显然”。“显然”“其余情形类似”是错误最爱藏的地方。证明里所有情形都写出来;确实完全对称的情形,会说明和哪一条对称。
- 说明为什么要证明。每个定理、引理和推论除了陈述与证明,还会说明它解决什么问题、后文在哪里用到,以及缺少这个保证时会有什么后果。已有解释就不再重复;反例会注明改动了哪条规则,不把另一门语言的例子冒充本文定理的反例。
- 隐含前提摆上台面。一个论证依赖的前提,即使看起来理所当然,也会被指出来,通常放在附注里。
- 术语给出原名。中文译名第一次出现时,括号里附英文原名,方便对照文献。人名使用中文译名,第一次出现时链接到维基百科。
- 希腊字母注明读音。第一次出现的希腊字母会在脚注里给出读音。
- 代码只做说明。Rust 代码用来展示“规则可以直接变成程序”,旁边会注明它实现的是哪条规则。不会 Rust 不影响理解。
- 自然语言讲解,形式化符号定义。正文用自然语言解释,但当解释和符号写成的定义有出入时,以定义为准。
[] 初入编程语言理论
[intro]
- 2026-10-08
- Glomzzz
- 2026-10-08
- Glomzzz
PLT(Programming Language Theory,程序语言理论)大概是我人生中少有的、能让我持续感兴趣的东西。它对我的吸引力不太像“一门学问”,更像一种手感:你手里有一套可以任意捏造的规则,而你要做的,是让这套规则在某个地方真正跑起来。这篇文章想聊三件事——我为什么会迷上它、它到底难在哪里、以及如果今天让我重新入门,我会怎么走。
1 从一台简陋的计算器说起
起点是一个用 Rust 写的计算器。它简陋到什么程度呢:四个运算符,加上括号,能跑。但当我第一次意识到
1 + 2 * 3
1 + 2 * 3
并不该被从左到右解释时,我就撞上了第一堵墙——运算符优先级。为了越过它,我读到了 matklad 的那篇 Simple but Powerful Pratt Parsing,第一次知道原来“解析”本身是一门可以设计、可以品味的技艺:每个运算符有自己的 binding power,前缀、中缀、后缀各有一套处理方式,几十行代码就能支撑起一个可扩展的表达式文法。那大概是我第一次感受到“抽象”带来的直接快感。
后来我在 JVM 上折腾了一段时间。JVM 对动态语言相当友好:反射、字节码生成(ASM / ByteBuddy)、动态代理、成熟的 JIT 与 GC,全都现成——于是我给 Minecraft 服务器写了一些 DSL,让服主可以用接近自然语言的方式描述规则。也是从这里开始,我被迫接触后端优化:为什么同样的逻辑,换个写法性能会差一个数量级?答案往往不在你写的那几行代码里,而在 JIT 的视角里——你写的东西会被怎么内联、怎么做逃逸分析、怎么做去虚拟化。
再往后,是一次很偶然的连锁反应。我在尝试对柯里化函数做 Partial Application 时,冒出一个念头:与其每次调用都重新组合参数,能不能在编译期就尽量把已知的部分算出来?顺着这条线,我遇到了 Partial Evaluation(部分求值)与 Futamura 投影,又遇到了 Staging(多阶段编程,MetaOCaml、LMS 那一套),再往下就是计算效果(effects),以及描述它们的静态效果系统、建模它们的 Monad、处理它们的 algebraic effect handlers……每一样都通向更大的世界,我至今也没走完。
上了大学之后,能自由折腾的时间突然变少了。大一为了应付作业,我读了 SICP,然后用 C++ 写了一个 Scheme 解释器——那大概是我第一次完整地走完“读语法 → 求值 → 打印”这一圈。这学期我选了一门编程范式的课,又写了半个学期的 Haskell(大作业是Parser Combinator in Haskell),突然就找回了当年那种感觉。
1.1 所以,到底为什么喜欢?
我也问过自己很多次。答案大概是:PLT 是少数几个“你既是规则的制定者,又是规则的囚徒”的领域。你设计一套语言,然后必须接受自己设计出来的一切后果——包括那些你在设计时根本没想到的交互。这种“造物”与“被造物反噬”之间的张力,对我有近乎生理性的吸引力。
另一方面,它是少见的、把“抽象”当作第一公民的工程领域。别的工程把抽象当作工具,PLT 把抽象当作研究对象本身。
2 复杂度从哪里来
我设计过不少语言的 spec,但几乎每一次都被复杂度劝退——它增长得比我预想的快得多。好在这两年有了 LLM 神力,可以帮我填补生产力空洞:我只要把想法讲清楚,就能很快得到一个能跑的实验产品。但“能跑”和“设计得对”之间,仍然隔着一整个复杂度问题。
2.1 语言特性?排列组合!
语言设计的第一步,往往是决定“我要哪些特性”。这一步很像做排列组合:你希望某些特性之间发生奇妙的化学反应,或者为了专攻某个方向而特意引入某些特性(Rust 的 affine type 与 lifetime、Haskell 的 type class、Lisp 的宏,都属于后者)。把这些特性两两摆到一起,大致会看到五类关系:
- 正交(orthogonal):彼此独立,可以放心叠加。例如词法作用域与垃圾回收、闭包与模式匹配、泛型与模块系统——它们各自解决不同维度的问题,组合起来几乎不产生额外语义。
- 协同(synergy):单独看平平无奇,合起来却能涌现出新能力。例如闭包 + GC 让高阶函数变得廉价;type class + 单态化(monomorphization)让“零成本抽象”成为可能;模式匹配 + GADT 让类型安全的结构化求值成为可能;algebraic effects + delimited continuation 让“可恢复的计算”变成一等公民。这类组合是设计语言时最值得追求的东西。
- 冗余 / 重叠(overlapping):同一个目的有多种相互重叠的表达方式,硬塞在一起就可能让语言出现“两套并行的世界观”。例如 Monad 与 Algebraic Effect Handlers——两者都可用于建模与组织副作用:Monad 通常以库抽象的形式组织计算,algebraic effect handlers 通过处理器解释效果操作;后者既可以作为语言特性,也可以编码成库,两者在语义上有紧密联系;再比如异常与
ResultResult/EitherEither、null 与OptionOption、类与 type class、宏与泛型。冗余本身不致命,但它会让用户不断面对“我该用哪个”的选择疲劳,也会让两套机制的交互成为 bug 温床(毕竟任何一处改动,都得把所有组合重新考虑一遍)。 - 冲突(conflicting):单独看都很好,放到一起却语义打架,或者直接导致理论上的不可判定。例如子类型(subtyping)与多态、类型推导的组合会增加复杂度,某些具体系统存在不可判定性,但仅凭子类型与记录/列表并不能推出这个结论;MLsub / Simple-sub 就支持子类型、记录和 ML 风格的全局类型推导;类型推导 + ad-hoc 重载会让“最具体实例”的选择变得病态;宏与卫生性(hygiene)如果处理不好,会互相污染作用域;线性类型与“可随意复制”的默认语义天然对立。这类组合往往必须“取舍”而不是“调和”。
- 依赖(dependent):特性 B 的成立以特性 A 为前提。例如 GADT 模式匹配需要能处理局部类型相等约束的检查规则,实际实现常借助显式标注;高秩多态类型(higher-rank types,不同于 higher-kinded types)在 GHC 等实现中通常需要标注提供多态参数的类型。若希望依赖类型承担可信证明,并保证类型检查中的计算终止,就需要限制相关递归或验证其终止性,但不必要求所有程序都终止。
asyncasync/awaitawait则需要支持挂起与恢复的机制,状态机变换是常见实现方案之一,也可以借助 CPS、栈式协程或效果处理器。识别真正的依赖关系、区分它们与实现选择,才能排出合理的实现顺序。
(也许还该补上第六类:不可组合(incompatible)——不是“打架”,而是根本不在同一个宇宙里,例如 first-class continuation 与某些 C FFI 的栈约定。)
2.2 交叉点、盲点与处理方式
选好特性只是开始。真正的复杂度来自交叉点:n 个特性最多有 n(n-1)/2 个两两交互,数量只是二次增长;如果考虑所有包含至少两个特性的子集,则共有
2^n - n - 1
2^n - n - 1
种可能组合,数量是指数增长的。不过,组合数量并不等于实际设计复杂度,还要看哪些特性真正发生交互。你在设计 A 和 B 时都想到了所有情况,但你没想到 A 的某个角落和 B 的某个角落会撞在一起。
盲点通常不会在纸面上暴露,而是在这些地方被抓出来:
- 形式化:写 operational semantics,或者在 Coq / Agda / Lean 里做机械化证明——代价最高,也最彻底;
- 测试:写一个足够大的测试套件或一致性测试(像 WebAssembly 的 spec tests 那样),用大量程序去撞边界;
- 自用:拿这门语言写一个非平凡的程序,最理想的是自举,把“设计者视角”换成“用户视角”;
- 实现:先写一个原型编译器,让类型检查器和求值器去替你发现矛盾。
发现盲点之后,处理手段大致也就那么几种:
- 限制:直接禁止这个组合(例如“泛型不能作为数组长度”),用报错换一致性;
- 归约:把一种特性 desugar 成另一种,只保留一套语义内核(例如把
forfor脱糖成whilewhile、把asyncasync脱糖成状态机); - 分层:把两套机制放到不同的层级,规定谁能看见谁(例如把宏展开限定在编译期,把效果的静态描述与检查放在效果系统里,再由生成的代码或运行时机制实现对应行为);
- 引入新构造:承认“光靠已有特性表达不了”,加一个专门的正交构造来承担交叉点;
- 限制自动推导:要求必要的类型标注,以采用可判定的检查算法;若检查或子类型判定本身不可判定,则标注也未必能解决,必须另行说明限制哪些规则、如何处理无法完成判定的输入。
“该怎么发现盲点、该怎么处理盲点”,基本上就是 PLT 研究的日常,也是语言设计复杂度的真正来源。
3 语言设计的抽象
不过,复杂度虽然会爆炸,我们还有抽象神力——Abstraction。一个很有用的做法是:先把“语言设计”这条混沌的河切成几段相对独立的层,再逐层讨论。我个人习惯分成四层。
3.1 语法(Syntax)
所有与“人怎么写、机器怎么读”有关的设计:具体语法与抽象语法、运算符优先级与结合性、缩进/换行规则(offside rule)、语法糖、宏与卫生性、错误恢复。
这一层看起来最“浅”,其实最容易积重难返:一个随手加的语法糖,可能会让后续所有解析都变得含混。经验法则是——让语法糖尽量可脱糖:每引入一个甜美的写法,都要能说清楚它脱糖后的核心形式是什么。
3.2 中间表示(IR)
从 AST 到字节码之间的所有表示及其转换:脱糖、ANF、CPS、SSA、三地址码、闭包转换、单态化、内联、优化遍。
IR 的设计与 lowering 流程会深刻影响语言实现:惰性求值常用 thunk / 闭包实现,但不要求每一级 IR 都保留显式的 thunk 节点;保证尾调用不增长调用栈,需要编译流程与目标平台的配合;可恢复的效果处理器需要相应的 continuation 或挂起与恢复机制,而普通的状态修改、I/O 等副作用不需要这样的机制。很多语言最终卡住,不是因为语法或类型系统,而是因为从核心语义到低层代码与运行时的转换没有打通。总之一句话:IR 是落实语言语义的重要桥梁。
3.3 检查系统(Checking System)
(也许该换个名字,因为“类型系统”只是它的一部分。)
它负责拒绝那些不该发生的程序——通常是在编译期,必要时也可以推迟到运行时:
- 类型系统:HM、System F、依赖类型、线性/仿射类型、refinement types;实现时可采用双向类型检查(bidirectional type checking),它是一种检查的组织方式,而不是与 System F 同层级的分类;
- 效果系统:效果注解、effect rows、效果多态等;Monad 可用于建模效果,algebraic effect handlers 是解释效果操作的机制,两者不等同于静态效果系统。有 handlers 的语言也未必静态跟踪效果,例如 OCaml 不静态保证所有效果都被处理;
- 所有权/借用检查:Rust 那一套 region + borrow 的静态分析;
- 终止性/全域性检查:total functional programming 这一脉;
- 其它静态性质:纯度、可序列化性、并发安全性……
这一层的关键词是“取舍”:表达力、可推导性、错误信息的可读性、实现复杂度,四者几乎不可能同时最优。
3.4 运行时(Runtime)
程序跑起来时的一切:内存管理(GC / ARC / region 等)、副作用管理、求值策略的实现(call-by-value / need / name)、并发与并行模型、FFI、解释执行与 JIT。AOT 是运行前的编译方式,属于编译流程;它生成的代码仍可能依赖运行时支持。线性/仿射类型则是静态约束,可以帮助编译器安排资源释放,但不是运行时内存管理算法。
在我看来,这一层最工业化、最“脏”。入门课程会覆盖其中一些概念,但生产级运行时的工程细节很难在一门课里讲透;它也是决定一门语言能否落地的重要因素。
4 学校的 PLT 教育
学校里的 PL 教育没有一个统一的配方:有的课程偏重编程范式、类型系统与语义,有的偏重编译器实现。后一类课程常从 lexer / tokenizer / parser 开始,继续讲语义分析、IR、优化与代码生成。例如 UNSW 的研究生课程 COMP9102(Programming Languages and Compilers)、Stanford CS143、CMU 15-411;此外,还有 Crafting Interpreters 这样的实践教程。这些资料并不都停在“能生成目标代码”:CS143 包含类型检查、Runtime Organization 与 Garbage Collection,15-411 包含内存管理和运行时组织,Crafting Interpreters 更会带你手写字节码 VM、调用栈、闭包与 GC。编译器课程只是 PL 教育的一部分,不能用它们代表全部本科 PLT 教学。
为什么生产级运行时很难在入门课程里讲透?我的理解是:一门课的时间有限,而这些内容涉及大量复杂的工程权衡;另一方面,确实有不少现成的基础设施可以复用。把自己的语言编译到 LLVM IR,就能利用它的优化与机器代码生成,但 LLVM 本身不提供垃圾收集器,更不自动提供完整的语言运行时;编译到 WASM 后,可以交给浏览器或其他 WASM 引擎执行;编译到 JVM 字节码,则可以利用成熟的 JIT 与 GC。如果目标是低成本验证语言设计,我会优先复用现成后端和宿主运行时,而不是从零手写生产级实现。
至于检查系统,也不能指望选定一个后端就自动得到。你可以复用现成的类型系统设计、推导算法或实现框架,但它仍必须和你的语言语义严丝合缝,能报出人类看得懂的错误,并在表达力和可推导性之间取舍。所以这一层仍需要自己学、自己走平衡木——类型系统、effect 系统,等等。(这也是我最想花时间补齐的一块。)
5 给刚入门的人的建议
如果让我给一个刚进入 PLT 的新人一条建议,那就是:不要好高骛远,优先保持语言实现的低成本。有现成的工具就用,不要重复造无意义的轮子。parser 这块你想手写也行,毕竟复杂度没那么高;但后端还是先复用 LLVM / Cranelift 等,或选择 WASM / JVM 字节码这类目标,千万别一上来就自己写机器代码后端。
另一个更具体的建议是:把大目标拆成一组最小语言。当你想要把 A、B、C、D 这些特性组合到一起、却对每一个都不熟悉时,先去分别实现 A、B、C、D 四个 minimal programming language,各自只保留支撑该特性的最小核心,然后再尝试把它们合体。这样做有两个好处:一是每个小语言都能在一两天内跑起来,“完成目标”带来的满足感可以很大程度上驱动你继续前进;二是当你把它们合体时,你会亲手撞上那些“交叉点”,这比在纸上空想有效得多。
5.1 一个具体的入门路线
如果让我给自己规划一条路线,大概是这样的——目标是设计一门自己用起来很舒服的编程语言:
- 从语法开始:不要急着直接上手写 parser,先用 ANF 的形式把核心语言的语法与求值规则写清楚。(ANF 的好处是把求值顺序显式化,让你没法用“反正运行时知道”来糊弄自己。)
- 选一个 IR:树遍历解释器 → ANF / CPS → 字节码,是最经典的递进路线。先跑通,再优化,不要一上来就 SSA。
- 选一个类型系统:建议从 simply typed lambda calculus 起步,先把 bidirectional type checking 写顺,再考虑 effect 系统——effect 系统可以先不碰。
- 把成熟的基础设施用起来:这一点后面单独说。优先复用后端与宿主运行时,同时明确列出仍需自己实现或适配的语言语义和运行时支持。
6 运行时的“草台”方案
多提一嘴:语言实现有一种听起来很草台、但在工业界确有广泛应用的做法——直接编译到某个成熟语言的源码上,也就是所谓的 source-to-source compilation。它可以复用目标语言的工具链,有时也能复用其运行时,但二者不是同一回事:
- Koka 会把自己的程序编译成 C 或 JavaScript;
- TypeScript、CoffeeScript、Elm、PureScript、Reason / ReScript 编译到 JavaScript;
- Nim 编译到 C / C++ / JavaScript;
- Haxe 编译到一大堆目标语言;
- Idris 2 通过 Chez Scheme 后端跑起来;
- Cython 把 Python 的方言编译到 C。
甚至可以更“土”一点:直接生成 Java 源码再交给
javac
javac
。虽然丑,但你立刻得到了 JVM 的 JIT、GC,以及整个生态。
另一条路是把“生成源码”换成“生成中间表示或字节码”:生成 LLVM IR、QBE 的 IL 或 Cranelift 的 CLIF,或者生成 WASM、JVM 字节码、.NET CIL、Erlang BEAM / Lua 字节码,交给相应的后端或执行引擎。MLIR 则是支持多种 dialect 与逐层 lowering 的 IR 框架,可以帮助组织这些转换,并不是一个现成的运行时。
这里要分清两种复用:复用后端可以减少优化和机器代码生成工作;复用 JVM、JavaScript 引擎、Chez Scheme 这样的成熟宿主运行时,可以进一步减少内存管理和执行引擎工作。LLVM、QBE、Cranelift 本身不是完整的语言运行时,编译到 C 也不自动获得 GC 或效果处理机制。无论采用哪条路,都能让自己更专注于语言真正独特的部分,但仍需实现或适配源语言特有的语义。
毕竟,PLT 的乐趣在于设计语言,而不在于重新发明一个寄存器分配器。
7 延伸阅读
- Simple but Powerful Pratt Parsing —— 我接触解析的起点
- Crafting Interpreters —— 从解释器写到字节码虚拟机,最好的入门实践
- SICP —— 抽象的第一课
- Types and Programming Languages —— 类型系统的标准教材
- Koka —— 把 algebraic effect 与 Perceus 引用计数做进真实语言
- Effects Bibliography —— 计算效果(effects & handlers)的论文与资料合集
- Effects Rosetta Stone —— 同一种 effect 在几十种语言/库里的写法对照
[] 形式化入门:从 BNF 到类型安全
[index]
- 2026-10-10
- Glomzzz
- 2026-10-10
- Glomzzz
研究程序语言时,我们关心的问题往往很朴素:这段程序会算出什么?它会不会在运行时崩溃?编译器说它“没有类型错误”,这句话能信吗?这些问题本身都能用自然语言问出来。可一旦想确定地回答它们,自然语言就不够用了。
这组文章做两件事。前半部分解释为什么要换一种语言来谈论程序语言,后半部分用一门很小的语言(布尔值加自然数),从零开始把这种“新语言”的用法完整演示一遍,这个过程叫形式化(formalization):怎样定义语法,怎样定义“运行”,怎样定义“类型”,最后怎样证明“通过类型检查的程序不会卡住”。
路线依次经过自然语言与元语言、语法、求值、确定性、范式与受阻、类型、类型安全、检查算法与测试,附带练习。
这些约定适用于
形式化入门:从 BNF 到类型安全
的文章与条目。 日常交流里,自然语言(natural language)足够好用,无论是汉语还是英语。说话的人和听话的人共享大量背景,含糊的地方靠语境补上,补错了再问一句就行。描述程序语言时,这几条都不成立:读规格(specification)的可能是其它国家的编程语言理论爱好者(PLer),可能是十年后的自己,也可能是一台机器。它们当然没法回到10年前问你“你当时**到底什么意思?”。下面几个例子分别展示自然语言在这件事上的一种弊端。
“咬死了猎人的狗”可以是“(咬死了猎人)的狗”,一条狗;也可以是“咬死了(猎人的狗)”,一个事件。两种读法用的字完全相同,差别只在怎样分组。 程序里同样的事随处可见。
人读到这些句子时会凭常识选一种,常识不同的人就会选不同的那一种。机器没有常识,只能靠写死的规则。
一份规格写道:“
再如“
[附注] 不完整规格中的类型检查问题
按一套明确的类型规则回答前三个问题;求值顺序的影响见
[附注] 求值策略、归约策略与合流性
。 规格的作者心里多半有答案,只是觉得“显然”而没写下来。问题在于,不同的人眼里显然的东西不一样。C 语言标准就是用英文写的,几十年来,关于某些条款到底允许什么的争论一直没停,后来还出现了专门把它的含义精确化的研究项目(例如 Cerberus)。
“所有测试都没通过”可以是“每个测试都失败了”,也可以是“并非所有测试都通过了”,后者只要有一个失败就成立。两种读法的差别在于“所有”和“没”哪个管着哪个。 把量词的作用范围明确写出来:前者是“对每个 , 都没通过”,后者是“并非(对每个 , 都通过)”。写出来之后,两句话长得就不一样了。 讲程序语言的性质时,这类句子到处都是。“每个良类型的程序都不会出错”和“存在一个类型,使得每个程序都有这个类型”,量词顺序一换,意思就完全不同。
下面是贝里悖论(Berry paradox)的一个汉语版本。 考虑这个短语:“不能用少于二十个字来定义的最小正整数”。 汉字只有有限多个,少于二十个字的短语也只有有限多个,它们最多定义有限多个正整数。所以确实存在不能用少于二十个字定义的正整数,其中有一个最小的,记为 。可上面那个短语本身只有十八个字,它恰好定义了 。于是 能用少于二十个字定义,矛盾。 问题出在“定义”这个词上。短语在定义数,同时又在谈论“什么算定义”,一句话里混了两个层次。自然语言允许这样随意地自我指涉,这极大地提升了自然语言的可表达性和便捷性,代价是有些句子没有任何一致的意思。后文会看到,形式化的做法是把“被谈论的语言”和“用来谈论的语言”严格分开(
[定义] 对象语言与元语言
)。 前面四个例子讲的是说清楚有多难。还有一个问题更根本:我们想要的结论是关于所有程序的。“这门语言里,通过类型检查的程序都不会在运行时出错”,这句话谈论的是无穷多个程序。 无穷多个程序没法一个一个试。剩下的办法只有论证,而用自然语言写的论证,读者很难判断它有没有漏掉情形。“其余情形类似”“显然成立”这些话,写的人常常是真心相信的,但这恰恰是错误最爱藏的地方。 把上面五点反过来,就是形式化要做到的事。 “反过来”的意思是:每一种弊端,都对应自然语言允许了某件事,而形式化的做法是把这件事禁止掉,或者让它必须写明。 换句话说,形式化不是给自然语言添了什么新能力,而是拿走了它的一部分自由。正是这些被拿走的自由,让写的人和读的人、人和机器,能对同一段文字得到同一个理解。代价也很明显:形式语言啰嗦、死板,写一句“显然”的话可能要好几行。下文会看到,这份啰嗦恰恰是有用的,很多设计上的问题就是在把“显然”展开的时候暴露出来的。 逐条对应如下: 要强调一点:形式化不是把自然语言赶出去。下文的证明仍然用自然语言写,因为人读证明需要自然语言的解释。改变的是“到底在说什么”的最终决定权:自然语言负责讲解,有分歧时以符号写成的定义为准。
把思想写成精确符号、使推理能够逐步核对的愿望很老。17 世纪末,莱布尼茨(Leibniz)设想过一种“普遍文字”(characteristica universalis),以及配套的推理演算(calculus ratiocinator):思想写成符号,推理变成计算,争论的双方只需说一句“我们来算一算”(Calculemus)。 两百年后,弗雷格(Frege)在 1879 年的《概念文字》(Begriffsschrift)里真的造出了这样一门语言,它是现代逻辑的起点之一。他在序言里说,日常语言的推理里,常有没被察觉的前提悄悄混进来;他想要的是一条没有缝隙的推理链,每一步都看得见用了什么。
[约定] 阅读约定
也采用了这种证明写法:每个隐含的前提都要摆到台面上。 莱布尼茨的梦想没有完全实现。20 世纪的哥德尔(Gödel)和图灵(Turing)证明了,有些问题原则上就不能靠“算一算”来裁决。在程序语言理论中,
[定义] 一步归约
与
[定义] 类型与类型判断
用规则精确定义运行和类型,
[推论] 类型安全
则从这些规则证明良类型程序不受阻,是这种可核对推理的一个小例子。 接下来要用“树”描述程序的结构,先把相关术语说清楚。
这里约定的树(tree)是由节点(node)和连接节点的边(edge)组成的有限结构,有一个指定的根(root)。除根以外,每个节点恰有一个父节点(parent);从根沿父子方向走到任意节点的路径唯一,不会绕回原处。每个节点可以带一个符号作为标签。 例如表示 的树有三个节点。根标着 ,它的孩子标着 ,后者的孩子标着 。前两个是内部节点, 是叶子;以 为根的子树表示 。这里“子节点”指一个节点,“子树”指从那个节点开始的整块结构。 练习1.1:分组和执行顺序是同一件事吗? “先乘除,后加减”把
可以分别补上“先完整求出左操作数,再求右操作数,最后相加”与相反顺序的规定。分组解决哪个运算包着哪个运算,执行顺序解决哪件事先发生;一条优先级约定不能替代后一条规定。 练习1.2:把量词的范围写出来 若把“所有提交的程序都没有被接受”理解为“每个提交的程序都被拒绝”,那么它为假:甲、乙已被接受。若理解为“并非所有提交的程序都被接受”,那么它为真:丙被拒绝。 第一种说法的否定是“至少一个提交的程序被接受”;第二种说法的否定是“每个提交的程序都被接受”。它们的否定也不同,因此原句不能靠读者自行猜范围。这里约定每个提交最终只有“接受”或“拒绝”两种结果。 练习1.3:补全一份除法规格 一种规定是:两边都必须是非负整数,且除数 必须大于 ;返回唯一的非负整数 ,满足 。不满足输入条件时明确报错,不自动把字符串转成整数。 于是
练习1.4:谈论一个名字,不等于增加一个名字 按命名表, 确实是不能被命名的最小正整数,但长短语不在命名表里,所以不是这门语言的名字。我们能在解释规格的自然语言里描述 ,不意味着那门小语言也能命名它。 如果正式把这个短语加进命名表,讨论的就不再是原来那门语言:可命名的数已经改变,原先关于“最小不能被命名的数”的结论必须重新检查。贝里悖论式的混淆,恰恰是把外部描述悄悄算作语言内部的名字,却继续沿用扩充前的判断。 练习1.5:找出“其余显然”的缺口 取 为“”。前一百个正整数都满足它, 却不满足。因此“检查了一百次”只能支持这一百个具体实例,不能独自推出普遍结论。 要证明所有正整数都满足某个性质,还要给出覆盖所有情况的推理方法;“剩下的显然同理”若没有说明相同的前提和推理步骤,只是在重复待证结论,而不是补完证明。 动手之前,先把
[例] 贝里悖论
留下的教训落实成一条约定。
例如,
[定义] 项的集合
、
[定义] 一步归约
与
[定义] 类型与类型判断
规定的布尔值/自然数语言是对象语言;用来写这些定义和证明的自然语言与数学符号属于元语言。用 Rust 实现这门小语言时,Rust 也充当元语言,见
[附注] Rust 也是元语言
。 例如“ 是一个值”是元语言里的一句话,它在谈论对象语言里的 。对象语言自己说不出这句话,它里面根本没有“值”这个词。反过来,元语言里的“所有”“如果……那么”,也不是对象语言的一部分。只要始终分清一句话属于哪一层,贝里悖论那种“一句话同时在两层说话”的情形就不会出现。
用 Rust 实现
[定义] 项的集合
的项和
[定义] 一步归约
的运行规则时,Rust 充当
[定义] 对象语言与元语言
中的元语言:Rust 的
“对象语言 / 元语言”这对术语来自逻辑学家塔斯基(Tarski)。1930 年代他研究“真”这个概念时发现,像说谎者悖论(liar paradox,“这句话是假的”)这样的困境,根源在于一门语言试图谈论自身句子的真假。他的方案是分层:关于对象语言 的句子是否为真,只能在更高一层的元语言里说。 程序语言理论中的
[定义] 对象语言与元语言
采用同样的区分:被研究的语言和研究它的语言分属两个层次。后来者在这基础上发展出了更精细的做法(比如允许一门语言有限度地谈论自身的“反射”(reflection)),但出发点都是先把层次分清。
1.2
为什么不用自然语言
1.2.1
弊端一:一句话有多种结构
1 + 2 * 3
1 + 2 * 3
是 还是 ?
if a then if b then x else y
if a then if b then x else y
里的
else
else
属于哪个
if
if
?
1.2.2
弊端二:没说到的情形
if
if
的条件必须是布尔值,两个分支的类型相同。”读起来没什么问题,但它完全没有涉及下面的情况:f() + g()
f() + g()
先算两边再相加”。如果
f
f
和
g
g
都会打印东西,先打印谁?(也就是说没规定求值顺序)
1.2.3
弊端三:“所有都不”?“不是所有”?
1.2.4
弊端四:自指问题
1.2.5
弊端五:“显然”没法检查
1.2.6
形式化带来了什么
1.2.7
练习:自然语言弊端
本节练习(5题)
2 + 3 * 4
2 + 3 * 4
的分组,以及
f() + g()
f() + g()
的调用顺序?设两个函数都会打印一个字符,请给出两份分组相同、打印顺序不同的完整顺序规定。参考答案
2 + 3 * 4
2 + 3 * 4
规定为
2 + (3 * 4)
2 + (3 * 4)
,结果是 ,但没有规定
f() + g()
f() + g()
先调用谁。设
f
f
打印
F
F
后返回 ,
g
g
打印
G
G
后返回 :先左后右打印
FG
FG
,先右后左打印
GF
GF
,两者的数值结果都为 。提示
区分“每一个都被拒绝”和“至少一个没被接受”,不要只在原句里换一个近义词。参考答案
a / b
a / b
返回它们的商”漏掉了哪些情况?请自行设计一个整数除法操作,完整规定输入范围和错误处理,并写出三个输入及其预期结果。答案可以有不同设计,但不能把异常情形留空。提示
至少说明可接受的输入、非整除时的结果、除数为零的处理;再给能区分不同规定的例子。参考答案
7 / 2
7 / 2
返回 ,
7 / 0
7 / 0
报错,
7 / "2"
7 / "2"
也报错。第一例排除了“保留小数商”的规定,后两例分别排除了“零除返回某个普通数”和“字符串自动转换”的规定。这只是一个可选设计,关键是不能只写“返回商”却把这些决定留给实现者。提示
先检查规定的命名表里有没有那个短语,再问是否偷偷扩充了语言。参照
[例] 贝里悖论
。参考答案
提示
构造一个只对前一百个正整数成立的性质,就能检查有限验证到底证明了什么。参考答案
1.3
两个层次的语言
enum Term
enum Term
描述对象语言的项,Rust 的函数描述对象语言的运行。不要把 Rust 自己的类型(
bool
bool
、
Option
Option
)和
[定义] 类型与类型判断
中的对象语言类型(、)混为一谈,它们分属两层。实现示例见
Rust 求值器
与
Rust 类型检查器
。
抽象语法(abstract syntax)把程序视为语法树(syntax tree),即节点标签表示程序构造的树(树的术语见
[定义] 树的基本术语
),而不是一串字符。从字符串到树的那一步叫解析(parsing)。采用这条约定时,假定解析已经完成,而且已经消除了
[例] 结构歧义
那样的歧义;解析器本身不在这条约定的研究范围内。书写时为了能在一行里写下一棵树,会用括号表示分组: 表示根标着 ,它的唯一孩子标着 ;这个孩子及其后代构成表示 的子树。 这条约定是第一个“隐含前提”:本文所有的定理都是关于树的。至于怎样把字符串可靠地变成树,那是另一个话题。这种只关心树形结构、不关心字符怎么排的语法,叫抽象语法(abstract syntax)。 描述语法树长什么样,最常用的写法是 BNF,全称巴科斯–诺尔范式(Backus–Naur Form,参见维基百科)。它得名于巴科斯(John Backus)和诺尔(Peter Naur):1960 年的 ALGOL 60 报告第一次用这种写法给一门程序语言写出了完整的语法,此后几乎所有语言的规格都沿用了它的某种变体。 BNF 的一行叫一条产生式(production),形如 名字出现在自己的右边,就形成了递归。例如 说的是: 可以是 ,也可以是 后面跟着另一个 。于是 、、 都是 。BNF 本身只是一种记号,它的准确含义要靠下面的“最小集合”来说清楚。 对象语言的程序称为项(term),用字母 表示。项的写法用一行 BNF 给出: 竖线读作“或者”。 是“ 的后继”(successor),可以理解为加一; 是“前驱”(predecessor),可以理解为减一; 问 是不是零。例如 是一个项,它对应的语法树如下: 图中的 是根,有三个孩子;、、 是只有一个孩子的内部节点;三个标着 的节点是不同位置上的叶子。
对
[定义] 项的集合
中的项,采用
[定义] 树的基本术语
的树术语。根节点的每个孩子所对应的整棵子树,称为它的直接子项(immediate subterm)。具体地: 沿着“取直接子项”走一步或多步得到的项,称为真子项(proper subterm)。例如 的直接子项只有 , 也是它的真子项,但不是直接子项;整项本身不是自己的真子项。同一文本可以出现在树的不同位置,谈子项时还要看它所在的位置。 这一行 BNF 看起来是在描述“项长什么样”,但严格来说它是在定义一个集合。
把项视为
[约定] 抽象语法
中的有限语法树,常量 、、 是叶子,、、 是一元构造, 有条件、then 分支和 else 分支三个有序子项。 项的集合 是满足下面三条封闭条件(closure conditions)的最小(least)集合: 若 ,则 。 “最小”的意思是:如果另一个集合 也满足这三条,那么 。 注: 这个符号是字母T的花体, 可以读作
“封闭”说的是:集合里有了 ,就必须也有 等等,用这几条规则造不出集合外的东西。 为什么还要加“最小”?因为满足封闭条件的集合有很多。比如允许额外的叶子
取最小的那个,就是在说:项只有用上面三条规则、在有限步内搭出来的东西,别的一概不算。
[定义] 项的集合
有一个存在性前提:满足那三条封闭条件的集合中真的有一个最小的。先固定一个背景集合 ,包含节点标签取自 、、、、、、 的所有有限有序树(暂不限制每个标签的孩子数量)。 本身满足三条封闭条件,所以满足条件的 的子集至少有一个,取交集不是在对空的一族集合操作。 把所有满足封闭条件的 的子集取交集,记为 : 因此 满足全部封闭条件,而且按交集的定义,它包含在每个满足条件的集合里。这就是所需的最小集合 ,不是从一堆集合里凭直觉挑一个。 由此可以得到两件事。第一,每个项都是一棵有限的树:叶子是 、、,内部节点是 、、(各有一个孩子)或 (有三个孩子)。第二,项只管形状,不管有没有意义。 和 都是合法的项。它们“有没有意义”,要等后面的类型系统来判断。 Rust 里,这个集合就是一个枚举。
从树的角度看,
“最小”不只是为了排除例外,它还直接送给我们一条证明方法。我们常常想证明“所有项都有某个性质 ”。项有无穷多个,不能一个个检查;但项的集合是用三条规则“搭”出来的,所以只要性质能跟着这三条规则一起“搭”上去就行。
“归纳”(induction)这个词在不同领域里意思不一样,先分清楚。 数学归纳法的历史很长。古希腊的欧几里得(Euclid)证明素数有无穷多个时,已经有了它的影子;16 世纪的莫罗利科(Maurolico)、17 世纪的帕斯卡(Pascal)在讨论二项式系数时比较明确地用了它;“数学归纳法”这个名字是 19 世纪德摩根(De Morgan)起的。19 世纪末,戴德金(Dedekind)和皮亚诺(Peano)把它写成了自然数的公理之一,并指出它其实来自“自然数是包含 、对后继封闭的最小集合”。
[定理] 结构归纳原理(structural induction)
把这个想法从自然数推广到由规则搭起来的有限树。 在程序语言理论里,和归纳对立的是余归纳(coinduction)。归纳对应“最小”的集合,处理有限的、搭得完的对象;余归纳对应“最大”的集合,处理可以无限展开的对象,比如永不停机的程序、无穷长的数据流。
[定义] 项的集合
的有限项与
[定义] 一步归约
的有限推导都属于归纳定义的对象。 先让我们来想想“最小”为什么能推出归纳。 设 是所有满足 的项组成的集合。 如果 能“跟着规则搭上去”,意思就是: 也满足那三条封闭条件,它也是一个“规则搭不出去”的集合。 而 是这样的集合里最小的那个,即是每一个这样的集合的子集。 所以当然也是 的子集,于是的每个项都在 里,都满足 。 如果没有“最小”, 里可能混进
所以“最小”保证了 里只有规则搭出来的东西,所以“对每条规则检查一遍”就等于“对每个项检查一遍”。 下面把这段话写成定理。
设 是关于
[定义] 项的集合
中有限项的一个性质。如果下面三条都成立: 那么对所有项 , 成立。 在这里,结构归纳不是额外请来的对象语言公理:在元语言的集合论背景下,它由“最小”这两个字推出。这里不是说一切数学基础都不需要公理,而是说定义了这个最小集合以后,不必再另加一条关于它的归纳公理。
归纳假设(induction hypothesis)是在一个归纳步骤中,对归纳原理许可的更小对象暂时假定的性质。它是用来证明“如果这些更小对象满足 ,那么当前对象也满足 ”的前提,不是把“所有对象都满足 ”或当前待证结论预先当真。 在
[定理] 结构归纳原理(structural induction)
的第 2 条中,归纳假设是 ;第 3 条中是 、、。这些对象恰好是
[定义] 直接子项与真子项
中列出的直接子项。常量没有子项,所以基础情形没有归纳假设,必须直接证明。 为什么这种暂时假定合法?我们首先证明的是一个条件命题;每个实际项都由有限次构造得到,从已经验证的叶子出发,逐层应用这个条件命题,就能把性质传到根。假设只沿着更小的结构使用,不会绕回当前待证结论。
取
[定义] 项的集合
中的有限项,树的术语见
[定义] 树的基本术语
,归纳方法采用
[定理] 结构归纳原理(structural induction)
。 取 为“ 至少含有一个叶子”。常量本身就是叶子,基础情形成立。证明 时,
归纳假设
给出 中有一个叶子;加上 根以后,那个叶子仍然存在。证明 时,任取一个孩子中的叶子即可。对于 ,先验证 ,再推出 ,最后推出整项的性质,没有一步用到尚未证明的结论。 反过来,试图证明错误命题 :“ 不含 ”,然后在 的情形里说“假定 ,所以它不含 ”,就是循环论证(circular reasoning):用待证结论本身支持待证结论。它至多证明了 ,没有证明归纳步骤要求的 。实际取 ,前者 为真,后者 为假,反例立刻出现。 循环也可以藏在两步里:“为了证明父项满足 ,先用父项满足 来证明子项满足 ,再由子项推出父项”。两句话合起来仍然没有独立的起点。
“只能对直接子项使用”是
[定理] 结构归纳原理(structural induction)
的直接表述,不是一切归纳法的限制。若改用强归纳,先证明某个自然数度量严格下降,就可以对度量更小的任意对象使用
归纳假设
;对真子项的归纳也可以成立。一般的要求叫良基性(well-foundedness):不存在无限地向更小对象下降的链。
[定义] 直接子项与真子项
的直接子项关系在
[定义] 项的集合
的有限树上是良基的,但“归约后的项”并不因此自动是直接子项。例如按
[定义] 一步归约
, 一次计算变成 ,后者不是前者的直接子项。要在证明中对归约结果使用
归纳假设
,必须另证合适的度量下降,或选择相应的归纳原理。用在一个不被当前归纳原理许可的对象上,证明就是缺了一步;若这个缺口依赖当前结论来填,就构成循环论证。 下图是一个例子。要证 对整棵树成立,只需要:叶子处直接验证(第 1 条),每个内部节点处假定以它的各个孩子为根的子项都满足 ,再推出以当前节点为根的项满足 (第 2、3 条)。这里 是项的性质,不是单个节点标签的性质。箭头表示“由孩子所在的子树推出父节点所在的子树”,信息自下而上流动:
“先把东西定义成满足某些规则的最小对象,再沿着这些规则归纳”,这个方法在许多理论里反复出现: 这些理论研究的对象虽然各不相同,用的方法是同一个。
写“对 结构归纳”时,意思是套用
[定理] 结构归纳原理(structural induction)
:按项的形状逐个情形讨论,每个情形里可以对
[定义] 直接子项与真子项
所列的直接子项使用
[定义] 归纳假设
中说明的假设。采用
[定理] 对推导归纳
对推导归纳时,更小对象则是规则前提对应的子推导,不是任意一个看起来有关的项。 先拿一个简单的性质练手。下面两个函数分别数一棵树有多少个节点、有多深:
对
[定义] 项的集合
的有限语法树,大小 数节点,深度 数从根到最远叶子的路径上的边。函数按
[定义] 直接子项与真子项
的直接子项递归定义。 深度数的是从根到最远叶子的路径上的边数,不是节点数。完整定义为: 例如 有三个节点、两条边,所以大小是 ,深度是 。 的大小是 ,深度也是 :大小把三个分支都算进去,深度只取最长的那条路。 这里又有一个隐含前提:按项的形状逐个情形写等式,真的定义出了一个函数吗?答案是肯定的,因为每个项恰好属于一种形状(
[约定] 抽象语法
已经保证没有歧义),而右边只用到直接子项的函数值,子项又更小,一路往下总会落到常量上。这种定义方式称为结构递归(structural recursion),它和结构归纳是同一枚硬币的两面:结构归纳沿着项的构造过程证明性质,结构递归则沿着同样的结构定义函数。(熟悉 Haskell 的读者应该能认出,这正是许多基于代数数据类型(algebraic data type)和模式匹配(pattern matching)的递归函数所采用的方式;当然Haskell也允许非结构递归甚至利用惰性求值定义的无限结构,这属于共递归(corecursion)的典型形式,而不是通常意义上对有限归纳数据的结构递归。) 接下来这条引理检查“大小”和“深度”两个定义是否协调:最长路径用到的节点不会超过整棵树的节点数。它也给递归的资源估计一个上界,例如遍历语法树时,递归栈深度不超过节点总数。没有这个证明,这只是直觉;如果误把 的深度写成三个分支深度之和加一,就不再是在数最长路径了。例如三个分支各有深度 时,这种错误写法给出 ,但实际最长路径只有 条边。要证明递归终止,还需另查每次调用的参数是否严格变小,不能只引用一个数值上界。
对
[定义] 项的集合
中的所有有限项 ,采用
[定义] 大小与深度
的函数定义,有 。 形式化证明大致就是这个样子:按定义的情形逐个过,每个情形里只用定义和
归纳假设
。
亚里士多德(Aristotle)区分过两种无穷:潜无穷(potential infinity)是一个可以无限进行下去的过程,比如数数总能再数一个;实无穷(actual infinity)是一个已经完成的无穷整体。项的集合 两种读法都可以:按
[定义] 项的集合
的写法,它是一个现成的无穷集合(实无穷);按“用规则在有限步内搭出来”的读法,每个项都是一个有限过程的产物(潜无穷)。
[定理] 结构归纳原理(structural induction)
的巧妙之处在于,它让我们对一个无穷集合下结论,却只需检查有限多条规则。这也是数学基础里构造主义者(constructivist)认可归纳定义的原因:每个对象都有一个有限的构造历史。 练习2.1:把项还原成树 三个直接子项依次是 、、,大小都为 ,深度都为 。所以整项大小为 ,深度为 。 三个标着 的节点都没有孩子,都是叶子;根 和其余六个一元节点都是内部节点。 是整项的真子项,但不是直接子项。不能因为三个叶子的标签相同,就把它们合并成同一个位置。 练习2.2:语法合法,不等于有意义 是项:先由第一条生成 ,再由第二条包上 。 也是项:三个常量都由第一条生成,再用第三条组合。 单独的
练习2.3:封闭与最小各负责什么? 不封闭:,但 。它既不是原语言的项,也不是单独补进去的
要得到含
练习2.4:证明边数比节点数少一 练习2.5:两种不同的错误“归纳证明” 第一段是循环论证:它假定的是当前待证的 ,不是结构归纳许可的 。它只证明“结论蕴涵自身”。 第二段使用子项上的假设本身合法,但推理错误: 只能给出 ,不能给出 。取 ,子项大小为 ,包上一层后为 。所以 是假命题,不是把措辞修好就能证明的。 也是大小为 的反例。
2.1
语法树
2.2
BNF
2.3
项
script T
script T
。null
null
,并且把所有包含这个新叶子的 、 等树也一起加进来,得到的更大集合仍然满足封闭条件。但
null
null
不是本文的项,因为三条生成条件没有给出它。注意只加
null
null
而不加 等树,反而会破坏封闭性。
enum
enum
的值只能由这几个构造子有限次地组合出来,“最小”由语言本身保证:// 对象语言的项(定义 “项的集合”)
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum Term {
True, False,
If(Box<Term>, Box<Term>, Box<Term>), // if t1 then t2 else t3
Zero,
Succ(Box<Term>), Pred(Box<Term>), IsZero(Box<Term>),
}// 对象语言的项(定义 “项的集合”)
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum Term {
True, False,
If(Box<Term>, Box<Term>, Box<Term>), // if t1 then t2 else t3
Zero,
Succ(Box<Term>), Pred(Box<Term>), IsZero(Box<Term>),
}Box<Term>
Box<Term>
是一个指向堆上
Term
Term
的指针,并且独占它指向的那块内存。为什么不直接写
Succ(Term)
Succ(Term)
?因为 Rust 要在编译时知道每个类型占多少字节。如果
Succ
Succ
里直接装一个
Term
Term
,那个
Term
Term
里又可能装一个
Term
Term
……大小就成了无穷大,编译器会报错(“recursive type has infinite size”)。换成
Box
Box
以后,
Succ
Succ
里装的只是一个固定大小的指针,真正的子树放在堆上。Box
Box
正好对应语法树里的一条边:父节点通过它“拥有”自己的子树,子树不和别的节点共享。也正因为独占,
Box
Box
不能按位复制,复制一个项要用
.clone()
.clone()
把整棵子树深拷贝一遍,后面的代码里会看到这一点。
2.4
结构归纳
null
null
这样的例外,它不是任何上面那三条规则搭出来的,规则对这种例外什么也没说, 对它成不成立也就无从谈起。
Inductive
Inductive
/
inductive
inductive
/
data
data
就是这个想法的直接实现,Rust 的
enum
enum
也是它的一个简化版。
2.5
练习:语法
本节练习(5题)
提示
根是 ;三个直接子项要按条件、then、else 的顺序列出。大小数节点,深度数最长路径上的边。参考答案
if
├── iszero
│ └── pred
│ └── 0
├── succ
│ └── succ
│ └── 0
└── pred
└── succ
└── 0if
├── iszero
│ └── pred
│ └── 0
├── succ
│ └── succ
│ └── 0
└── pred
└── succ
└── 0pred
pred
、字面记号
1
1
。不要用“它运行时会出错”作为不属于项集合的理由。参考答案
pred
pred
不是项,因为这个构造必须带一个子项。字面记号
1
1
也不在这套抽象语法里;后面会用 表示自然数一,但不能未经约定就把
1
1
当成已有的构造。前两个项的语法合法,并不保证运算能正常进行。提示
集合里一旦有
null
null
,封闭条件对 会提出什么要求?参考答案
null
null
。null
null
的封闭集合,必须同时加入所有由原构造子和这个额外叶子在有限步内搭出的树,包括 、 等。这个集合满足原来的封闭条件,却严格大于 。“封闭”要求构造后不能跑到集合外,“最小”则排除规则没有生成的额外东西。提示
常量有零条边;一元构造增加一条边; 根连向三个孩子,增加三条边。提示
第一段用了哪个对象上的假设?第二段虽然取了子项,还要检查不等式是否真的能传给父项。参考答案
上一节回答的是项是什么(What):它由哪些符号、按什么结构搭成。这一节回答项怎么运行(How):一个项会一步一步变成什么。前者叫语法(syntax),后者叫语义(semantics)。这里给出语义的方式是直接描述程序运行的每一步,称为操作语义(operational semantics)。
[例] 不完整的规格
里“先算哪边”那类问题,在这一节都要有确定的答案。
庸俗来说,哲学里研究“究竟什么是存在的?存在者又有哪些最基本的形式与范畴?”的分支叫本体论(ontology)。语法就是对象语言的本体论:
[定义] 项的集合
列出了这门语言里有哪些东西,而“最小”保证除此之外什么也没有。 但只有“是什么”还不够。古希腊哲学最早的争论之一,就是巴门尼德(Parmenides)与赫拉克利特(Heraclitus)之争:前者认为真正存在的东西不生不灭、不会变化,后者认为万物皆流,“人不能两次踏进同一条河流”。亚里士多德的调和办法是区分潜能(dynamis)与现实(energeia):变化,就是事物把它潜在的可能变成现实。 这个区分在操作语义里有一个很贴切的对应。按
[定义] 一步归约
,项 作为语法对象,就是它自己,不会变;但它有一种“潜能”,可以变成 。
[定义] 值与数值
所划出的值,可以比作已经完全成为现实、再没有这种归约潜能的项:它不能再变成别的东西(
[引理] 值不可归约
)。语法管“存在”,语义管“生成”。 先划出“已经算完”的项。
在
[定义] 项的集合
的项中,用以下 BNF 划出值。 读作“定义为”, 读作“或者”; 是数值的元变量,不是对象语言的关键字。 形如 的项称为值(value),形如 的项称为数值(numeric value)。和
[定义] 项的集合
一样,这两行 BNF 定义的是满足相应封闭条件的最小集合。 数值就是 、、……,分别代表 。几个例子: 先说“归约”这个词。归约(reduction)指把一个项改写成另一个更接近结果的项,就像中学代数里把 改写成 ,再改写成 。每次改写只动一处,叫一步归约(one-step reduction);连续改写若干次,叫多步归约(multi-step reduction)。程序“运行”,在这里就是指一步接一步地归约,直到不能再归约为止。
数学上, 是一个二元关系(binary relation):它是由一些 对组成的集合, 就是 的简写。我们用推导规则(inference rule)来定义它。一条规则长这样: 读作:如果横线上的前提(premise)全都成立,那么横线下的结论(conclusion)成立。没有前提的规则叫公理(axiom),它的结论无条件成立。 横线右边可以标上规则的名字来方便引用。
在
[定义] 一步归约
与
[定义] 类型与类型判断
的规则里, 等字母是元变量(metavariable):它们是元语言里的变量,可以代换成
[定义] 项的集合
中的任意项; 只能代换成
[定义] 值与数值
中的数值。同一条规则里同一个字母必须代换成同一个东西。一条规则因此代表无穷多条实例(instance)。
设 、 属于
[定义] 项的集合
的项集合, 只取
[定义] 值与数值
中的数值。关系 是对下列十条规则封闭的最小关系。 每条横线之上的判断是前提,横线之下的是结论;前提全部成立,结论才成立。没有前提的规则可以直接使用。同一条规则中相同的元变量必须替换成同一个项,见
[约定] 元变量
。 这里的“最小”和
[定义] 项的集合
里的“最小”是同一个意思: 成立,当且仅当能用这十条规则的实例搭出一棵以它为根的有限树。这棵树叫推导(derivation)。 规则没有提到的情形,例如 该归约到哪里,那就是没有定义。“没有定义”在这里有精确的含义:十条规则里没有一条的结论能匹配 (E-Succ 的结论的形状对得上,但它的前提却要求 能归约,而没有规则能做到),所以不存在任何 使 。 那么遇到没有定义的情形该怎么办?形式化的回答是:不要假装它有定义,而是把它当作一个明确的状态来对待。具体有三种常见做法:
以
[定义] 项的集合
的语法和
[定义] 一步归约
的十条求值规则为基础,可以另行加入错误项 ,并规定错误传播。以下讨论的是这门扩展语言,不把新增规则当作未扩展语言的规则。只写 还不完整:还要处理 、、非布尔条件,以及嵌套位置里的错误。 记 为 或 , 为
[定义] 值与数值
中的数值。可以补上这些错误规则,其中 分别取 、、: 原同余规则继续向正在求值的子项走。于是 先归约到 ,再归约到 ; 先变成 ,再向外传播成 。 本身不再归约,应被识别为一种异常终点,而非原来定义的普通值。未选中的分支仍不会求值,例如 得到 ,不能不分位置地把整项都传播成错误。 Kotlin 可以显式写出这种异常控制流:
必须区分三个对象: 是对象语言里的错误项,
仅仅补上运行时错误规则,不会自动得到这样的子类型系统。若把显式异常加入
[定义] 类型与类型判断
的定型系统,也必须写明异常如何定型,并相应重述
[定理] 进展
与
[定理] 保型
;不能直接套用未扩展语言的证明就声称“良类型程序永不抛异常”。
[推论] 类型安全
采用的则是未扩展语言:不加入错误项,让类型规则排除受阻。
JavaScript 没有把
图中每条边同时列出
传递性(transitivity)要求: 与 相等、 与 相等,就能推出 与 相等。这个三角图正好展示
但
[例] JavaScript 隐式转换与相等三角图
展示了
隐式转换确实能少写一些代码,例如让输入得到的字符串
这会变成实际的算法问题。假如自己写一个去重函数:按输入顺序扫描,只要新值与某个已保留的值满足
一种更容易推理的设计,是把“验证输入”“转换表示”和“比较”分开:在需要数字的边界先检查哪些输入可接受,再显式转成数字,最后使用不做隐式转换的比较。仅仅把
对新语言,这意味着不要只问“这个常见例子能不能少写一次转换”,还要问“加了这条便利规则以后,原有的推理性质是否还在,和其他规则组合会怎样”。这不等于所有隐式转换都不可取;需要判断的是转换保留了什么信息,以及它是否会掩盖应当暴露的错误。对已经部署的语言,直接改掉旧规则又可能破坏依赖它的代码,因而常常需要显式提供更清楚的操作,并用工具约束旧操作的使用。少写一个转换的局部便利,可能变成整个语言长期承担的理解与兼容成本。 所以“补上约定结果”不是“随便猜一个结果”:必须完整规定转换顺序,并检查这些约定保留了哪些性质、放弃了哪些性质。这也是
[附注] 显式错误规则与 Kotlin 的底类型
中显式报错方案与隐式转换方案的真正取舍:有时拒绝一次操作,比给它一个出乎意料却合法的结果更有帮助。 无论选哪种,关键是写下来。C 语言标准里的“未定义行为”(undefined behavior)是典型的反面教材:标准明确说某些情形没有定义,却没有要求实现报错,于是编译器可以假设它们永远不会发生,并据此做出让程序员意外的优化。
使用
[定义] 一步归约
的规则,推导树的每条横线都是一条规则的实例:横线上方的子推导满足前提,下方给出结论。以下三层合起来只证明整项的一步归约,不是三步运行。 从下往上读:要说明最下面那一步成立,用 E-Pred,它要求里面的 能一步归约;这又用 E-If,它要求条件 能一步归约;最后 E-IsZeroZero 是公理,无条件成立。 来讲讲上面那十条规则,它们分成两类,作用完全不同。 计算规则(computation rule)真正改写项:E-IfTrue、E-IfFalse、E-PredZero、E-PredSucc、E-IsZeroZero、E-IsZeroSucc。它们都没有关于 的前提,左边是一个具体的“可以计算”的形状,右边是算完的结果。例如: 同余规则(congruence rule)不执行基本运算,而是负责找位置、把子项的归约带到整体:E-If、E-Succ、E-Pred、E-IsZero。它们的前提是“某个子项能归约”,结论是“整个项在那个位置归约”。“同余”这个名字的意思是:关系 和项的构造子(constructor)相容,子项归约了,包着它的项也跟着归约。(这和数论里的同余没有关系。)例如: 每一步归约的推导都是同样的结构:底下若干次同余规则,一路往里找到要算的地方,最顶上恰好一次计算规则,在那里真正改写。
[例] 一棵推导
就是两次同余(E-Pred、E-If)加一次计算(E-IsZeroZero)。 上图中
规则的细节里藏着设计决定,读的时候要留意:
[定义] 一步归约
不用自然语言的直觉解释来决定 “是什么”,而是规定它在计算里怎样被使用。后期维特根斯坦(Wittgenstein)在《哲学研究》里提出,一个词的意义在很多情况下就是它在语言中的用法;逻辑学里的推理主义(inferentialism,根岑(Gentzen)、普拉维茨(Prawitz)、达米特(Dummett)、布兰顿(Brandom)一脉)更进一步,主张逻辑联结词的意义由它的推理规则给出。 操作语义正是这种立场的工程版本:一个构造的意义,就是关于它的规则的全体。这种立场有一个好处,它把“意义”变成了可以逐条核对的东西。
3.2
值
3.3
推导规则怎么读
1 + "a"
1 + "a"
抛出
TypeError
TypeError
是类似的运行时处理;Kotlin 的例子以及它与底类型的关系见
[附注] 显式错误规则与 Kotlin 的底类型
。true + 1 === 2
true + 1 === 2
就是这种做法。它给这类运算规定了普通结果,但不代表所有程序都会正常返回;隐式转换也可能掩盖本想发现的错误,见
[例] JavaScript 隐式转换与相等三角图
。
fun succ(x: Any): Int = when (x) {
is Int -> x + 1
else -> throw IllegalArgumentException("succ expects an Int")
}
fun fail(message: String): Nothing =
throw IllegalArgumentException(message)
fun requireNat(n: Int): Int =
if (n >= 0) n else fail("negative number")
// succ(true) 会抛出异常;requireNat(-1) 也会抛出异常。
// 这里借用 Kotlin 的 Int 演示错误处理,不把机器整数当作无界自然数。fun succ(x: Any): Int = when (x) {
is Int -> x + 1
else -> throw IllegalArgumentException("succ expects an Int")
}
fun fail(message: String): Nothing =
throw IllegalArgumentException(message)
fun requireNat(n: Int): Int =
if (n >= 0) n else fail("negative number")
// succ(true) 会抛出异常;requireNat(-1) 也会抛出异常。
// 这里借用 Kotlin 的 Int 演示错误处理,不把机器整数当作无界自然数。requireNat
requireNat
的 else 分支为什么能放在需要
Int
Int
的位置?
fail(...)
fail(...)
的类型是
Nothing
Nothing
,表示它不会正常返回一个值。Kotlin 的 类型系统规格把
Nothing
Nothing
定义成底类型(bottom type),通常写作 、读作 bottom。它是任何类型的子类型(subtype),即 对任意 成立。这里 的意思是: 类型的表达式可以放进要求 类型的上下文。因此不返回的失败分支可以和返回
Int
Int
的成功分支放在同一个
if
if
里。IllegalArgumentException
IllegalArgumentException
是 Kotlin 的异常对象, 是描述表达式不会正常产出值的类型。异常对象本身不是
Nothing
Nothing
;
Nothing
Nothing
没有正常的值,也不是
null
null
(
Nothing?
Nothing?
是另一回事)。抛异常的表达式可以具有底类型,永远循环的表达式也可以不返回,所以不能把“错误”与 当作同义词。
true + 1
true + 1
留作未定义,而是明确规定转换后得到
2
2
。同样,宽松相等(loose equality)
==
==
规定了按两边种类进行转换的规则;严格相等(strict equality)
===
===
则不做这种转换,不同种类的操作数直接判为不相等。把两种比较放在一起看:true + 1; // 2:true 转换为 1
[] == 0; // true
[] === 0; // false
0 == "0"; // true
0 === "0"; // false
[] == "0"; // false
[] === "0"; // falsetrue + 1; // 2:true 转换为 1
[] == 0; // true
[] === 0; // false
0 == "0"; // true
0 === "0"; // false
[] == "0"; // false
[] === "0"; // false==
==
和
===
===
的结果,不是归约箭头。绿色实线表示
==
==
为真,红色虚线表示
==
==
为假;第二行单独记录
===
===
,这里三条边都为假。[] == 0
[] == 0
会先把空数组转换成空字符串
""
""
,再因另一边是数字而把
""
""
转成
0
0
;
0 == "0"
0 == "0"
会把字符串
"0"
"0"
转成数字
0
0
,所以这两条边都为真。但
[] == "0"
[] == "0"
把数组转成
""
""
后,两边已经都是字符串,只比较
""
""
与
"0"
"0"
,结果为假。具体转换规则见 MDN 的
==
==
文档。==
==
不满足传递性,不能像数学等号那样用于替换或推理。
===
===
不会做上述转换:数组、数字、字符串种类不同,所以这三个比较都为假。它回答的是另一个问题,而不是“同一套转换做得更严格”。===
===
也不等于完整的数学等价关系。自反性(reflexivity)要求每个值都与自身相等,而
NaN === NaN
NaN === NaN
为
false
false
。对对象,
===
===
比较的是是否为同一个对象,不是内容是否相同:
const a = []; a === a
const a = []; a === a
为
true
true
,
[] === []
[] === []
却为
false
false
,因为后者创建了两个不同的数组。严格相等的完整规则见 MDN 的
===
===
文档。
[] == 0
[] == 0
与
0 == "0"
0 == "0"
为真、
[] == "0"
[] == "0"
为假的三角关系。它让人觉得“地狱”,不是因为结果随机:每一条比较都能按规格算出来。难受之处在于,名字叫“相等”,却不能沿用相等最基本的推理。我们原本希望“ 等于 、 等于 ”能让第三次比较省下来,现在却必须重新跑一遍转换规则。定义得精确与设计得容易理解,是两件事;形式化能让问题无处藏身,却不会自动把一个糟糕的约定变成好约定。"0"
"0"
直接和数字
0
0
比较。但
==
==
并不是先把每个值各自转换成某个统一表示,再比较这个表示:一个值怎样参与比较,还取决于另一边是什么种类。三角图里,同一个
[]
[]
遇到数字时走到
0
0
,遇到字符串时却停在
""
""
。这份便利的代价,是读者不能只看一个值就知道比较会怎样进行,必须同时记住另一边以及两者触发的规则。==
==
,就把新值丢掉。输入
[[], 0, "0"]
[[], 0, "0"]
时,先保留
[]
[]
,丢掉与它“相等”的
0
0
,再保留与它“不相等”的
"0"
"0"
,结果留下两个值;换成
[0, [], "0"]
[0, [], "0"]
,后两个值都与
0
0
“相等”,结果只留一个值。普通去重也可能因顺序不同保留不同的代表,但若依据的真是等价关系,不应连分成几类都随顺序改变。这里说的是这个自定义的
==
==
算法,不是 JavaScript 的
Set
Set
;
Set
Set
使用的是另一套比较规则。==
==
换成
Number(a) === Number(b)
Number(a) === Number(b)
也不够,
Number("")
Number("")
与
Number([])
Number([])
都是
0
0
;如果空输入或数组本来就是错误,仍应拒绝它们,而不是转换后假装正常。相等操作也要讲清楚是在比较对象身份、结构内容,还是领域中的某个键,并且检查算法需要的自反性、对称性与传递性。不同任务可以有不同规则,但不能只靠一个“相等”的名字暗示它们全都成立。
3.4
计算规则与同余规则
pred
pred
和
if
if
节点是同余规则经过的路径,
iszero 0
iszero 0
节点是计算规则改写的位置,虚线连着的分支不参与这一步。
是“对规则封闭的最小关系”,所以它也有自己的归纳法,和
[定理] 结构归纳原理(structural induction)
的道理完全相同:“最小”保证每个成立的 都有一棵由规则搭出来的推导,没有别的来路,所以只要性质能沿着每条规则从前提传到结论,它就对所有推导成立。
设 是关于一对项的性质。如果对
[定义] 一步归约
的每一条规则都有:“前提里的每个 都满足 ”能推出“结论满足 ”,那么所有满足 的 都满足 。 直观地说(请对照
[例] 一棵推导
的推导树来理解),就是对推导树的高度做归纳,从上往下:叶子(公理)先成立,每往下一层都保持成立。公理没有前提,对应的情形里没有
[定义] 归纳假设
可用;E-If 这类有一个前提的规则,可以对前提对应的子推导使用这个假设。 为什么不总是对项归纳?因为这里已知的是一棵 的推导,要跟踪的性质同时涉及左右两项;按最后用的规则拆解,就能得到前提里的子推导以及左右两边怎样拼成结论。确定性和保型都会用到它。若只试了几条归约链,或只处理无前提的计算规则而漏掉 E-If 等同余规则,就不能覆盖嵌套项;例如内层归约保持类型,并不替你证明外层 拼回去以后也保持类型。对推导归纳正是把这个“拼回去”的步骤逐条核实。 在证明确定性之前,要先确认“已经算完的值”确实不能再走一步。这既给求值器一个可靠的停止条件,也用来排除计算规则和同余规则同时适用。若另加 这样的规则, 虽仍是语法上指定的值,按“不能再走”判断停止的求值器却会永远循环;后面的确定性证明也不能再用“值不能归约”排除重叠。
为什么要证明下面的确定性?因为我们准备实现一个一次只返回一个后继项的
对
[定义] 一步归约
定义的关系 ,若 且 ,则 。 证明的方法是穷举(case analysis,也叫分情形讨论):把所有可能的情形一个不漏地列出来,逐个证明。穷举法成立的前提是情形确实覆盖了每一种可能,漏掉一种,整个证明就不成立。这里能保证不漏,是因为 是最小关系,任何推导的最后一步只能是十条规则之一,所以下面恰好列十种情形;而在每种情形内部,又要对第二个推导的最后一步再穷举一次十条规则,逐条说明“适用”或“为什么不适用”。 具体地,对 的推导归纳(
[定理] 对推导归纳
),要证的性质是“对任意 , 蕴涵 ”。“任意 ”必须写进性质里,否则
归纳假设
只能用于某个固定的 ,在 E-If 情形就不够用。每个情形里,再看 的推导最后一步可能是哪条规则:它的结论左边必须和 形状相同。 这个证明没什么巧思,功夫全花在排除情形上。这正是形式化的用处,下面的附注是一个具体例子。
[定义] 一步归约
的 E-PredSucc 规则左边写的是 ,只允许 里面是
[定义] 值与数值
中的数值。看起来也可以放宽成任意项: 毕竟“先加一再减一”就是原来的数,何必等里面算完?问题在于,放宽以后同一个项会有两种归约方式。取 : 两个结果不一样,
[定理] 确定性
就不再成立。在证明里,这表现为 E-PredSucc 那个情形写不下去:要排除第二个推导是 E-Pred,需要“ 不能归约”,而 不一定是值,这一点推不出来。 这个例子里两条路最后都会到达 ,所以这个项的最终结果没有变,但一步关系已不再确定。仅凭这个例子还不能断言整个扩展语言的所有最终结果都不变,那需要另外的证明。我们仍可以写一个按某种策略选后继的函数,但它实现的是受策略限制后的关系,不能再声称它枚举了原关系的全部一步结果(见
[附注] 求值策略、归约策略与合流性
)。这个决定是被证明逼出来的:证明写不下去时,要么调整设计,要么调整所要保证的性质,不能把缺口当作已经证明。
用测试对照证明
所述的确定性测试也抓到了放宽后的反例。
[定理] 确定性
说的是:对每个 ,至多有一个 。因此 对应一个偏函数(partial function):有后继时返回唯一后继,没有后继时无定义。Rust 用
归约策略(reduction strategy)规定:当一个项的多个位置都能改写时,选择哪个位置、哪条规则作为下一步。求值策略(evaluation strategy)则规定一门语言怎样求得程序的结果,包括先算哪些子表达式、函数参数何时计算、是否计算函数体内部,以及算到什么形式就停止。文献有时混用这两个词;这里用前者强调“选归约路线”,用后者强调运行时的整体约定。能直接应用一条计算规则的子表达式,称为可归约式(redex,reducible expression)。 先看一个只含精确整数运算、没有副作用的例子。括号固定语法树;允许在任意子表达式里计算一次加、减、乘或整除时,下面的项至少有两条路线: 如果规定“先完整求出左操作数,再求右操作数,最后计算根”,求值器就选蓝色实线路线,右边虚线路线不属于这个受限制的一步关系。策略只限制何时用计算规则,不改它算出的数值。运算符优先级解决的是怎么解析字符串,不是这里的先后顺序:语法树已经由括号固定,两条路线没有重新分组,也没有把浮点加法当成可任意结合的运算。 真实语言里,JavaScript、Python 的普通函数调用会先求实参再执行函数体,通常称为按值调用(call-by-value);JavaScript 还规定实参从左到右求值。Haskell 使用按需调用(call-by-need):需要某个参数的值时才算,并共享这次计算的结果。例如 Haskell 的
有副作用时,连最后的普通数值也可能不同。设 JavaScript 中
多条路线能否重新汇合,是另一个性质,叫合流性(confluence)。这里临时把一般归约关系也记成 ,把零步或有限多步记成 (正式定义见
[定义] 多步归约
):若 且 ,总存在 ,使 且 ,就称这个关系合流。上图展示了一个可汇合的分叉;只画出这一个例子并没有证明整套关系合流。 合流不要求“下一步唯一”:、 可以不同,只要求它们之后还可以到同一项。若某个项能到达两个范式(不能继续归约的项,正式定义见
[定义] 范式与受阻
),合流保证这两个范式相同,因为范式已经无法再走向第三个不同的项。若没有合流保证,就不能从“各条路最后都停了”推出结果唯一:假想规则同时允许某个 归约到常量 和 ,而两个常量都不能再归约,它们就永远无法汇合。选择策略可以挑出其中一个,却不消除原关系里的这种分歧。但合流不保证终止,也不保证任意策略都能找到已经存在的范式。 λ 演算(Lambda Calculus)是只用变量、函数和函数应用描述计算的形式系统。 表示参数为 、函数体为 的函数; 表示把 应用到 。 读作 lambda(“兰姆达”)。变量出现的位置若受某个 的参数绑定,称为绑定出现;否则称为自由出现。例如 中参数 只规定绑定范围,函数体里的 是自由变量。一般的 β-归约(beta reduction, 读作 beta)允许在任何子项中使用 其中 是把 中自由出现的 替换成 ,必要时先把绑定变量改名,以免把 的自由变量误捕获。β-归约也允许在函数体内部归约,不要求 已是值,所以通常有多条路线。Church–Rosser 定理说,一般 β-归约是合流的(把只差绑定变量名字的项视为同一个项)。这里引用这一经典结果而不展开其证明;它说明一般 β-归约虽然有多条路线,仍不会从同一个项得到两个不同的 β-范式。参见 Church–Rosser 定理。 例如 可以先归约外层,得到 ,也可以先归约实参,得到 ;两边都再归约到 。但令 ( 读作 omega,“欧米伽”),它一步归约回自己,永远不会算完。 若先算外层就得到范式 ,若坚持先算实参就会在 上无限归约。一般 β-归约仍然合流,却不能靠合流保证这条策略终止。 因而要分清三件事:确定性管每一步是否唯一,合流性管不同路线能否重新汇合,终止性(termination)管是否可能一直算下去。引入确定的归约策略可以把多路线关系限制成可由单后继函数实现的关系,但它不自动保持所有可达结果;要声称“最终结果不变”或“总能找到范式”,还需要相应的证明。
[定义] 一步归约
的十条求值规则已经规定了策略,
[定理] 确定性
证明的正是这套规则的确定性。 先把前面的
每个分支旁边标了它实现的规则。同余规则对应的分支都用到了
在
Rust 求值器
例如,读取字符串的首字符,并把 ASCII 小写字母转成大写: 对照
[定义] 一步归约
中的同余规则:E-Succ 说“若 ,则 ”。
Rust 求值器
step
step
。它要忠实于整个一步关系,就必须证明关系不会给同一输入两个不同后继;否则
match
match
的分支顺序可能偷偷选掉另一条合法路线。
[附注] 为什么 E-PredSucc 要求
会给出放宽一条规则就失去这一保证的具体反例;多路线不必然是语言设计错误,但必须说明是否用策略限制它(
[附注] 求值策略、归约策略与合流性
)。
4.5
从关系到函数
Option<Term>
Option<Term>
把这种偏函数表示成总会返回的函数,
None
None
表示后继不存在。若一步关系有多个后继,函数仍然可以选择其中一个,但必须写明选择的策略;否则它只实现了关系的一部分,却被误说成实现了整个关系。
const
const
满足
const x y = x
const x y = x
,
error
error
则用于产生错误;
const 1 (error "boom")
const 1 (error "boom")
返回
1
1
,因为第二个参数不会被用到;JavaScript 的
((x, y) => x)(1, (() => { throw new Error("boom"); })())
((x, y) => x)(1, (() => { throw new Error("boom"); })())
会先计算第二个实参而抛错。两门语言都可以有明确的策略,但选的是不同路线和停止条件。let n = 0
let n = 0
,
f = () => ++n
f = () => ++n
,
g = () => n * 10
g = () => n * 10
。
f() + g()
f() + g()
按从左到右求值给出
11
11
;若另一门语言规定先算右边,先得到
g() = 0
g() = 0
,再得到
f() = 1
f() = 1
,结果就是
1
1
。所以“随便选一条路线”不是无害的实现细节;
[例] 不完整的规格
里没写明的顺序必须在规格里补上。Term
Term
定义重新贴在这里,方便对照:#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum Term {
True, False,
If(Box<Term>, Box<Term>, Box<Term>), // if t1 then t2 else t3
Zero,
Succ(Box<Term>), Pred(Box<Term>), IsZero(Box<Term>),
}#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum Term {
True, False,
If(Box<Term>, Box<Term>, Box<Term>), // if t1 then t2 else t3
Zero,
Succ(Box<Term>), Pred(Box<Term>), IsZero(Box<Term>),
}Term
Term
没有派生
Copy
Copy
。
Copy
Copy
的意思是“按位复制一份就是合法的副本”,而
Box
Box
独占堆上的子树,按位复制会得到两个指向同一块内存、都以为自己负责释放它的指针,所以 Rust 不允许含
Box
Box
的类型实现
Copy
Copy
。下面代码里的
(**a).clone()
(**a).clone()
、
a.clone()
a.clone()
就是在显式地深拷贝子树:
a
a
的类型是
&Box<Term>
&Box<Term>
,
*a
*a
是
Box<Term>
Box<Term>
,
**a
**a
是
Term
Term
。/// nv ::= 0 | succ nv
pub fn is_nv(t: &Term) -> bool {
match t { Term::Zero => true, Term::Succ(t) => is_nv(t), _ => false }
}
/// v ::= true | false | nv
pub fn is_value(t: &Term) -> bool {
matches!(t, Term::True | Term::False) || is_nv(t)
}
/// t ⟶ t'。返回 None 表示不存在 t':t 是值,或者受阻了。
pub fn step(t: &Term) -> Option<Term> {
use Term::*;
match t {
If(c, a, b) => match &**c {
True => Some((**a).clone()), // E-IfTrue
False => Some((**b).clone()), // E-IfFalse
c => Some(If(Box::new(step(c)?), a.clone(), b.clone())), // E-If
},
Succ(t1) => Some(Succ(Box::new(step(t1)?))), // E-Succ
Pred(t1) => match &**t1 {
Zero => Some(Zero), // E-PredZero
Succ(n) if is_nv(n) => Some((**n).clone()), // E-PredSucc
t1 => Some(Pred(Box::new(step(t1)?))), // E-Pred
},
IsZero(t1) => match &**t1 {
Zero => Some(True), // E-IsZeroZero
Succ(n) if is_nv(n) => Some(False), // E-IsZeroSucc
t1 => Some(IsZero(Box::new(step(t1)?))), // E-IsZero
},
True | False | Zero => None, // 值不可归约
}
}/// nv ::= 0 | succ nv
pub fn is_nv(t: &Term) -> bool {
match t { Term::Zero => true, Term::Succ(t) => is_nv(t), _ => false }
}
/// v ::= true | false | nv
pub fn is_value(t: &Term) -> bool {
matches!(t, Term::True | Term::False) || is_nv(t)
}
/// t ⟶ t'。返回 None 表示不存在 t':t 是值,或者受阻了。
pub fn step(t: &Term) -> Option<Term> {
use Term::*;
match t {
If(c, a, b) => match &**c {
True => Some((**a).clone()), // E-IfTrue
False => Some((**b).clone()), // E-IfFalse
c => Some(If(Box::new(step(c)?), a.clone(), b.clone())), // E-If
},
Succ(t1) => Some(Succ(Box::new(step(t1)?))), // E-Succ
Pred(t1) => match &**t1 {
Zero => Some(Zero), // E-PredZero
Succ(n) if is_nv(n) => Some((**n).clone()), // E-PredSucc
t1 => Some(Pred(Box::new(step(t1)?))), // E-Pred
},
IsZero(t1) => match &**t1 {
Zero => Some(True), // E-IsZeroZero
Succ(n) if is_nv(n) => Some(False), // E-IsZeroSucc
t1 => Some(IsZero(Box::new(step(t1)?))), // E-IsZero
},
True | False | Zero => None, // 值不可归约
}
}?
?
,它的作用见下面的附注。
?
?
运算符
[rust-question-mark-operator]
Option<T>
Option<T>
是 Rust 里表示“可能有值、可能没有”的类型,只有两种取值:
Some(x)
Some(x)
和
None
None
。在返回
Option
Option
的函数里,表达式
e?
e?
的意思是:e
e
是
Some(x)
Some(x)
,整个
e?
e?
的值就是
x
x
,继续往下执行;e
e
是
None
None
,函数立刻返回
None
None
,后面的代码不再执行。step
step
中,
Succ(t1)
Succ(t1)
匹配一个后继项,
step(t1)
step(t1)
返回
Option<Term>
Option<Term>
,表示子项是否存在一步后继。这个分支可以写成两种等价形式:// 用 ?
Succ(t1) => Some(Succ(Box::new(step(t1)?))),
// 不用 ?,展开写
Succ(t1) => match step(t1) {
Some(t1_) => Some(Succ(Box::new(t1_))),
None => return None,
},// 用 ?
Succ(t1) => Some(Succ(Box::new(step(t1)?))),
// 不用 ?,展开写
Succ(t1) => match step(t1) {
Some(t1_) => Some(Succ(Box::new(t1_))),
None => return None,
},fn first_char_upper(s: &str) -> Option<char> {
let c = s.chars().next()?; // 空字符串时这里直接返回 None
Some(c.to_ascii_uppercase())
}
assert_eq!(first_char_upper("rust"), Some('R'));
assert_eq!(first_char_upper(""), None);fn first_char_upper(s: &str) -> Option<char> {
let c = s.chars().next()?; // 空字符串时这里直接返回 None
Some(c.to_ascii_uppercase())
}
assert_eq!(first_char_upper("rust"), Some('R'));
assert_eq!(first_char_upper(""), None);step(t1)?
step(t1)?
先去找 ,找到了就拿来搭结论;找不到(前提不成立),结论就推不出来,整个函数返回
None
None
。这正好是“子项不能归约,整个项也不能沿这条规则归约”。
类型检查器
type_of
type_of
里的
type_of(t1)?
type_of(t1)?
也是同样的意思:按照
[定义] 类型与类型判断
的规则,子项没有类型,就无法满足相应构造的定型前提,检查器返回
None
None
。
step
step
实现
[定义] 一步归约
的规则。Rust 的
match
match
按顺序尝试分支,这本身是一种“优先级”,而求值规则没有写这种优先级。为什么这样写不会改变语义?因为
[定理] 确定性
的证明已经说明,对每个 至多一条规则适用,所以按什么顺序试结果都一样。没有那条定理,“先试 E-PredSucc 再试 E-Pred”就是一个偷偷加进来的、规则里没有的决定。
采用
[定义] 一步归约
的关系 。若不存在 使 ,称 是范式(normal form)。按
[定义] 值与数值
判断,不是值的范式称为受阻的(stuck)。 “范式”这个词的意思是“标准的、最终的形式”:一个项归约到不能再归约,就到了它的范式,好比把 化简到 就化简不下去了。注意范式是按能不能归约定义的,和项“好不好”无关。范式分成两种: 非值(受阻的项):不能再归约,但也不是值,计算停在了一个没有意义的地方。例如:
stuck 字面是“卡住”,TAPL 的一些中文译本也这样译。
[定义] 范式与受阻
采用“受阻”这个译名,理由有两点。第一,“卡住”在日常语言里也指程序“卡死、没反应”,即无限循环或死锁,而 stuck 恰恰不是这个意思:受阻的项已经停下来了,只是停错了地方。第二,“受阻”点出了停下来的原因:计算想往前推进,却被一个没有定义的情形挡住了。例如按
[定义] 一步归约
的规则, 既不是值,也没有可用的归约步骤。英文原名始终写在括号里,读文献时对得上即可。 受阻对应真实语言里的错误(error):程序要做一件语义里没有定义的事,比如把布尔值加一,目前我们只能把这个错误拖到运行时,也就是运行时错误(runtime error)。下一节的类型系统要做的,就是在不运行程序的前提下,提前把会走到受阻状态的程序找出来,可以让它们成为编译期错误(compiletime error)。 一步归约只描述“一次改写”。程序真正运行时要连续改写很多次,所以需要把若干步串起来。
以
[定义] 一步归约
的关系 为基础, 读作“ 多步归约到 ”,表示从 出发经过零步或有限多步一步归约到达 。精确地说,它是对下面两条规则封闭的最小关系: M-Refl 说“零步归约”永远成立(自反),M-Step 说在一步后面接上若干步还是若干步。由于取的是最小关系, 成立当且仅当存在一条有限的链 ()。 上角的星号来自正则表达式里的 Kleene 星号,意思是“重复零次或多次”,数学上称 为 的自反传递闭包(reflexive transitive closure)。
采用
[定义] 一步归约
的十条规则, 表示
[定义] 多步归约
的零步或有限多步归约;终点是否为值按
[定义] 值与数值
判断。 每行右边列出了这一步推导用到的规则,从外层的同余规则写到最里层的计算规则。三步之后到达值 ,所以 由
[定理] 确定性
,每一步都别无选择,所以这条链是唯一的。 也包括中间每一站:这个项也多步归约到第二行、第三行的项,以及它自己(零步)。
按
[定义] 一步归约
的规则,取 。是否为值采用
[定义] 值与数值
,是否受阻采用
[定义] 范式与受阻
。 第一步。 的最外层是 。结论左边形如 的规则只有 E-Succ,它要求里面的 能归约。这个 的条件是 ,E-IfTrue 适用,得到 。于是推导是: 第二步。现在的项是 。仍然只有 E-Succ 可能适用,它要求 对某个 成立。但 是值,由
[引理] 值不可归约
不能归约,于是 E-Succ 的前提搭不上,没有任何规则可用: 是范式。它又不是值( 后面必须是数值),所以它受阻了。 第一步完全合法,问题出在第二步。所以一个项“现在还能归约”并不说明它“永远不会出错”。 练习3.1:值、范式与受阻别混在一起 因此“不是值”不等于“受阻”,“有坏的子项”也不等于“现在受阻”。 练习3.2:一条链与第一步的推导树 四步所用的规则依次是 E-Pred + E-If + E-IsZero + E-PredZero,E-Pred + E-If + E-IsZeroZero,E-Pred + E-IfTrue,E-PredSucc。第一步的完整推导是: 最后 是值,没有后继。多步归约还包括链的中间各站与起点自身,不只是最终结果。 练习3.3:扩展:函数选一条路,不等于关系只有一条路 把 E-PredSucc 改成允许任意 的 后,取 。直接消去外层得到 ;先用同余规则进入内部,得到 。 两个后继不同,所以一步关系不确定。第一条路线再用 E-IsZeroZero 到 ,第二条路线再用放宽的 E-PredSucc 到 ;因此这个分叉可以汇合。但一个可汇合的例子不证明整套扩展关系合流。 Rust 函数即使按分支优先级选出唯一返回值,也没有消除关系的另一条合法路线。要么实现“所有后继”的接口,要么明确说这个单后继函数实现的是选定策略限制后的关系。注意扩展规则下 能归约,不能照搬它在原语言中受阻的判断。 练习3.4:换一个相等三角形
使用
[附注] 相等关系的“地狱”给语言设计什么教训
的自定义
练习3.5:扩展:错误只沿实际求值的位置传播 对 ,原语言与错误扩展都用 E-IfTrue 直接得到 ,不计算 else 分支。 原语言中的 没有后继,是受阻的范式。错误扩展先在内部用 E-OpBool,并由 E-Pred 提升到整项,得到 ;再用 E-OpWrong 到 。它是明确的异常终点,不是普通数值。 是错误项,
5.3
多步归约
5.4
练习:求值
本节练习(5题)
参考答案
提示
第一步不是消去最外层 ,而是经由三条同余规则进入条件中的 。参考答案
step
step
用
match
match
只返回其中一个,能否据此声称扩展关系仍确定?参考答案
""
""
、
0
0
、
"0"
"0"
两两计算
==
==
与
===
===
的结果,画出另一个相等三角形。再按“新值与任何已保留值满足
==
==
就丢弃”的算法,分别去重
["", 0, "0"]
["", 0, "0"]
与
[0, "", "0"]
[0, "", "0"]
。它们为什么连保留下来的数量都可能不同?提示
比较空字符串
""
""
、数字
0
0
和字符串
"0"
"0"
。同类字符串的
==
==
不会再把两边都转成数字。参考答案
"" == 0
"" == 0
和
0 == "0"
0 == "0"
为
true
true
,
"" == "0"
"" == "0"
为
false
false
;三组
===
===
都为
false
false
。前两次把字符串转为数字,第三次只比较两个不同的字符串。==
==
去重算法,输入
["", 0, "0"]
["", 0, "0"]
留下
["", "0"]
["", "0"]
,输入
[0, "", "0"]
[0, "", "0"]
只留下
[0]
[0]
。规则明确仍可能失去算法所依赖的传递性;改用
===
===
后这三种输入彼此不同,不会在这个例子中被合并,但仍要留意对象身份和
NaN
NaN
的特殊处理。参考答案
IllegalArgumentException
IllegalArgumentException
是异常对象, 是底类型。Kotlin 的抛异常表达式可以具有
Nothing
Nothing
类型,不意味着异常对象是底类型的值,也不意味着未选中的异常分支必须先执行。
设 是
[定义] 项的集合
中的项。类型(type)只有两种:,分别对应布尔值和自然数。类型判断(typing judgment) 读作“ 具有类型 ”,它是对下列七条规则封闭的最小关系。 横线上的判断是前提,横线下的是结论;没有前提的规则直接给出结论。、 等符号按
[约定] 元变量
代换,T-If 两个分支里的 必须代换成同一个类型。
对
[定义] 类型与类型判断
的类型关系,若存在某个类型 ,使得 有一棵由这些规则搭成的有限推导,就称项 是良类型的(well-typed);否则称 是不良类型的(ill-typed)。 这个定义有三处值得逐一拆开: 读法和
[定义] 一步归约
完全一样:横线上是前提,横线下是结论,、 是元变量(
[约定] 元变量
)。所以“对类型推导归纳”也照样成立,证明与
[定理] 对推导归纳
相同,不再重复。
使用
[定义] 类型与类型判断
的七条类型规则,良类型与不良类型的含义见
[定义] 良类型
。 良类型的例子。要说明 ,从结论往上搭推导: 每个前提都搭上了,合起来就是一棵完整的推导: 不良类型的例子。要说明 没有类型,得证明对任何 都搭不出 的推导。我们可以直接穷举七条规则: 唯一可能作为最后一步的 T-Succ 无法满足它的前提,因此不存在合法的推导树, 是不良类型的。
采用
[定义] 类型与类型判断
的七条规则,
[例] 不完整的规格
中的三个类型检查问题可以得到明确回答:
为
[定义] 类型与类型判断
中的类型判断寻找推导时,从要证的结论出发,选一条结论形状对得上的规则,再去搭它的每个前提。这七条规则是语法导向的,每种项形状只对应一条规则,见
[引理] 反演
,所以每一步都只有一条规则可选,整个过程是一条确定的路径。路径断掉的地方,就是错误所在,它由三样东西确定: 以 为例。最外层选 T-If;第一个前提 由 T-True 搭上;第二个前提要求 ,T-Zero 给出 ;第三个前提于是要求 ,但 T-IsZero 的结论类型是 。断点因此是:T-If 的第三个前提,子项 ,期望 ,实际 。编译器的报错信息“expected
Rust 检查器
按
[定义] 一步归约
的 E-IfTrue, 运行一步就得到值 ,不会受阻;但
[定义] 类型与类型判断
无法给它类型,因为 T-If 要求两个分支类型相同。这不是 bug。类型系统不运行程序,只看形状,它只能做近似。
[推论] 类型安全
只保证一个方向:良类型的程序不受阻;它没有保证“不受阻的程序都良类型”。 先看这组规则的结论。每条规则的结论都形如“某个项 : 某个类型”,结论左边的项有一个最外层构造子(、、 等),这就是它的形状。把七条规则按结论左边的形状列出来: “每种项的形状恰好出现在一条规则的结论里”指的就是这张表:第三列全是 。既不是 (否则那种形状的项永远没有类型),也不大于 (否则同一个项可能有好几种定型方式)。这样的规则称为语法导向的(syntax-directed):项的语法形状直接指定了该用哪条规则,不需要猜,也不需要回溯。 作为对照,下面两种情形都不是语法导向的: 语法导向带来一个很方便的推理方式:知道了 成立,就能断定推导的最后一步用的是哪条规则,于是那条规则的前提也都成立。例如,知道 成立,查表可知最后一步必然是 T-Succ,于是立刻得到 且 ,不用知道推导的其余细节。
[例] 良类型与不良类型
里的两个例子也是这样做的:每一步都只有一条规则可选。
以下结论只针对
[定义] 类型与类型判断
的七条类型规则; 表示能由这些规则搭出有限推导,、 分别是布尔类型与自然数类型。 反演看起来平淡无奇,却是后面所有证明的发动机。它把“存在一棵推导”这种不知道细节的事实,拆成关于子项的具体信息。保型证明例如从 开始,必须先知道 、,再继续取出 ,才能证明删去外层构造以后类型还在。没有反演,就不能凭外观擅自断言子项有哪些类型;增加子类型规则时这个推理尤其需要重证,下面的附注会给出原因。
[引理] 反演
的逐形状证明依赖
[定义] 类型与类型判断
的语法导向性:每种项形状只对应一条规则。如果将来加一条不看项形状的规则(例如子类型里常见的“若 且 是 的子类型,则 ”),那么 的最后一步就可能是这条新规则,“最后一步必然是 T-Succ”的推理就断了。到那时反演需要重新陈述、重新证明。 语法导向并不单凭“每种形状只有一条规则”就让输出类型自动唯一:T-If 的 还要从分支中确定。下面的定理补上这个保证,说明
对
[定义] 类型与类型判断
的七条类型规则,若 且 ,则 。 练习4.1:从结论搭起一棵类型推导 每个叶子都是 T-Zero,两个分支具有同一个类型 。定型过程中没有执行任何 归约;条件最后会变成什么,不是搭这棵推导的前提。 练习4.2:证明没有类型,而不是只说“检查失败” 这三种分析排除了所有可能的最后规则,所以是不存在推导的论证。第一个项却可以归约到 :实际没有执行坏分支。这再次说明“不良类型”并不等于“这次运行必定出错”。 练习4.3:反演不需要看见整棵推导 T-If 给出 、、。T-Pred 给出 以及 ;T-Succ 再给出 。代回得到 。 因而一定有 、、、。这只提取已有推导必须满足的前提,并没有假定 或 已经求值,也不需要猜原推导的其他细节。
6.1
类型判断
Nat
Nat
, found
Bool
Bool
”,内容就是这三样东西。type_of
type_of
在失败时只返回
None
None
,把这些信息丢掉了。要保留它们,只需把返回类型从
Option<Type>
Option<Type>
换成
Result<Type, Error>
Result<Type, Error>
,在每个
?
?
和比较失败处记下当时的规则、子项和两个类型。
6.2
反演:从结论倒推前提
项的形状 结论里出现这个形状的规则 条数 T-True 1 T-False 1 T-Zero 1 T-If 1 T-Succ 1 T-Pred 1 T-IsZero 1
type_of
type_of
可以忠实地返回一个类型,而不必返回类型集合。没有唯一性,单个返回值可能只挑出了众多类型之一。例如带子类型的 Kotlin 中,一个
Int
Int
表达式也可以出现在需要
Number
Number
或
Any
Any
的位置;那样的系统通常要另外区分推断出的类型和它能被接受为的所有类型,不能照搬本文的结论。
6.3
练习:类型
本节练习(4题)
提示
最外层用 T-If;条件从 T-IsZero 开始,两个分支都应得到 。参考答案
参考答案
提示
先反演 T-If,再连续反演 T-Pred 和 T-Succ;整项的 会被其中一个分支确定。参考答案
“良类型的程序不会出错”这句话,米尔纳(Milner)在 1978 年说成 well-typed programs cannot go wrong。现在终于可以把它写成一个精确的命题了。“出错”在这门语言里的意思就是“受阻”(
[定义] 范式与受阻
)。
采用
[定义] 类型与类型判断
的类型关系、
[定义] 多步归约
的运行关系,以及
[定义] 范式与受阻
的受阻判定。称这套语言规则是类型安全(type safety)的,如果对任意项 、 和类型 :若 且 ,则 没有受阻。 注意这句话里的量词:对所有良类型的 ,对所有它能多步归约到的 。这正是
[例] 量词的范围
提醒过的地方,写成符号以后,范围就不会有歧义。 直接证这个命题不好下手,因为 可以归约任意多步。赖特(Wright)与费莱森(Felleisen)在 1994 年给出的办法,是把它拆成两条只涉及一步的定理: 两条缺一不可。
[例] 归约到受阻
里的 现在能归约,一步归约之后就受阻了。只有进展,挡不住这种情况;只有保型,挡不住一开始就受阻的项。(这个例子本身没有类型,所以它不是两条定理的反例,只是说明为什么两条都需要。) 证明进展时需要知道:某个类型的值长什么样。
值与数值使用
[定义] 值与数值
的定义,类型关系使用
[定义] 类型与类型判断
。 这条引理是静态和动态之间的一座桥:左边是类型的信息(不运行就知道的),右边是形状的信息(运行时真正看到的)。进展的证明需要靠它把“条件是 类型的值”变成“条件确实是 或 ”,才能选择 E-IfTrue 或 E-IfFalse。若随意加上 而保持求值规则不变, 就会被定型却受阻:静态分类不再保证运算要求的运行时形状。动态语言在运算时做的种类检查,正是在现场检查这类形状条件。
“静态”(static)指不运行程序就能知道的东西,“动态”(dynamic)指运行时才能看到的东西。按类型检查发生在什么时候,程序语言大致分成两类: 用
[定义] 项的集合
的布尔值/自然数语言作类比,一种动态检查的设计是不预先构造 的推导,而是在 里给不合适的运行时操作补上报错规则(
[附注] 显式错误规则与 Kotlin 的底类型
),把受阻换成可预期的异常。另一种操作可以采用转换规则(
[例] JavaScript 隐式转换与相等三角图
)。真实语言往往同时有这两类处理;JavaScript 的条件还会采用真假值转换,不要求参数字面上就是布尔值。 对这门小语言的运算,类型规则、
[引理] 典范形式(canonical forms)
与
[定理] 保型
合起来保证:参数若被要求为 ,求值为值时就一定是数值,所以不必再检查它是否为布尔值。这不等于真实静态类型语言可以省掉所有运行时检查;例如 Kotlin 仍会抛出数组越界异常,Rust 的数组索引也可能需要边界检查。两者的取舍是:静态检查更早排除它所覆盖的错误,但会拒绝一些实际运行不会出错的程序(
[附注] 类型系统是保守的
);动态检查更灵活,但错误通常要等到运行到那一行才暴露。 另外,“静态 / 动态”和“强 / 弱”是两个不同的维度。C 是静态类型,但允许随意强制转换指针,类型系统不可靠;Python 是动态类型,但不会把字符串当整数用。
[推论] 类型安全
证明的“类型安全”,指的是这套静态类型系统对受阻错误的可靠性,不是由“强/弱”这一称呼作出的保证。 对这个话题感兴趣的可以看看帝球的类型 vs. 类型检查 进展回答的是“类型检查通过以后,下一步会不会无规则可用”。没有它,检查器即使给出了类型,也未必挡住 这样的错误。具体地,若把 T-Succ 的前提错误地放宽成 ,这个项就会被赋予 ,却既不是值也不能归约。进展还必须允许“已经是值”这个分支,否则 这种合法结果反而会被误判为失败。对于有异常的真实语言,则要先明确哪些异常是许可的结果,不能把本文的进展原封不动当作“永不抛异常”。
采用
[定义] 类型与类型判断
的类型关系、
[定义] 值与数值
的值定义,以及
[定义] 一步归约
的归约关系。若 ,则 是值,或存在 使 。 对 的推导归纳(类型推导版本的
[定理] 对推导归纳
),按最后一步的规则分情形。 T-If:,前提里有 。对 用
归纳假设
:
采用
[定义] 类型与类型判断
的类型规则和
[定义] 一步归约
的求值规则。取良类型的项 ,跟着
[定理] 进展
的证明走一遍: 证明不只说“能归约”,还构造出了这一步的推导。对 再用一次进展:子项 是值,由
[引理] 典范形式(canonical forms)
它是数值,且形如 ,于是用 E-PredSucc 得到 。对 再用一次进展:它是值,停止。 看 为什么会受阻,再对照证明:要让它不受阻,要么 能归约,要么 是数值,两者都不成立。证明之所以没遇到这个麻烦,是因为 T-Succ 要求 ,再经由典范形式把 限定为数值。类型系统正是在这里把坏程序挡在门外的。 进展只能保证当前状态能运行,保型则让这个保证在运行后仍然可用。若修改 E-PredZero 为 ,原有类型规则仍会给 类型 ,第一步也可以执行;但它会变成没有类型、且受阻的 。所以一次正确的类型检查必须能跨越每次运行时改写,不能“第一步安全就算安全”。编译器的优化、解释器的执行规则也要维持相应的不变量,否则即使前端检查器正确,后端仍能制造坏状态。
采用
[定义] 类型与类型判断
的类型关系和
[定义] 一步归约
的归约关系。若 且 ,则 。 对 的推导归纳(
[定理] 对推导归纳
),对 一般化。每个情形先对 使用
[引理] 反演
。
对
[例] 多步归约到值
的归约链,按
[定义] 类型与类型判断
在每一项旁边写上类型。归约关系使用
[定义] 一步归约
,保型性质及其证明见
[定理] 保型
: 类型始终是 。以第一步为例看保型的证明怎样工作:这一步的推导是 E-If,前提是 。由
[引理] 反演
对 反演,得到条件 、两个分支 ;对前提用
归纳假设
(取类型 ),得到 ;两个分支原封不动,再用 T-If 拼回去,得到新项 。 注意项本身变了很多,从一个 变成了 ,但类型这个“静态描述”一直成立。保型说的就是:运行不会让类型说过的话失效。
在
[定理] 保型
的证明中,E-If 是
[定义] 一步归约
的条件归约规则,类型关系采用
[定义] 类型与类型判断
。处理这个情形时,
归纳假设
用在 上,而 的类型是 ,不一定是 的类型 。如果要证的性质写成“对这个固定的 ,”,
归纳假设
就只能谈论这个 ,在这里用不上。所以要证的性质必须写成“对任意 ,若 则 ”。这个细节在自然语言的证明里很容易被略过,
[定理] 确定性
的证明里对 也做了同样的处理。 下面的推论把“当前不受阻”和“下一项仍有类型”接成对任意有限运行前缀的保证。只证明进展而不证明保型,保证可能一步后失效;只证明保型而不证明进展,则一个起初就受阻的良类型项没有任何一步可走,保型的条件从未成立,不能排除它。这个推论保证的是本文定义的“不受阻”,不保证程序一定终止,也不保证业务逻辑正确,例如得到 是否符合程序员的意图。
由
[定义] 项的集合
、
[定义] 一步归约
与
[定义] 类型与类型判断
规定的布尔值/自然数语言是类型安全的(
[定义] 类型安全
):若 且 ,则 没有受阻。
[推论] 类型安全
的证明里,我们一次也没有运行程序,却得到了关于所有运行的结论。这让人想起康德(Kant)的问题:有没有不靠经验、却又对经验有效的知识? 这里的答案相当朴素。结论之所以不靠“试”,是因为“运行”本身就是我们用规则定义出来的(
[定义] 一步归约
),定理只是把定义里已经包含的东西展开。按逻辑实证主义的说法,这类命题是分析的:它的真依赖于定义。但这不等于它空洞。“所有良类型程序都不受阻”在定义里并不显眼,要靠反演、典范形式、两层归纳才能抽出来,中途还可能发现定义写错了(
[附注] 分支顺序里藏着证明
和
[附注] 为什么 E-PredSucc 要求
就是例子)。弗雷格说过,一个结论可以“像植物包含在种子里那样”包含在定义中,而不是“像梁木包含在房子里那样”一眼可见。 另一面是:证明只对这套规则成立。真实的 CPU 是否忠实地执行了这些规则,是另一个问题,属于经验,要靠测试和工程去回答。 练习5.1:把进展与保型放到同一条链上 四步依次用 E-If + E-IsZero + E-PredSucc,E-If + E-IsZeroZero,E-IfTrue,E-PredZero。每个非末项都展示了进展要求的一个后继,末项 展示了“已经是值”的分支。 第一步内层 从 保持为 ,T-IsZero 重建条件的 ,T-If 再和两个 分支重建整项的 。第二步条件变成 ,分支不变;第三步选出的 本来就在 T-If 的前提里具有 ;第四步由 T-Zero 得 。这才是类型为何保持的逐步解释,不只是把相同的标签写在每一行旁边。 练习5.2:扩展:只有一条安全性定理够吗? 删除 E-PredZero 而保留其他规则时, 既不是值也没有后继,进展失效。但剩下的每一步仍是原语言合法的步骤,因此仍保型。保型无法排除一个根本没有步骤的坏终点。 将 E-PredZero 改为 时, 走到 ,保型失效。原来的进展证明仍能为每个良类型非值找到一步,T-Pred 的零参数情形只是把后继换了一个;但它不保证后继仍良类型。 例如 在第二种修改下走到受阻的 。所以“现在有下一步”与“类型保证能传到下一项”必须一起使用,见
[推论] 类型安全
。 练习5.3:保型能倒过来读吗? 不成立。取 ,E-IfTrue 给出 ,且 ,但 没有类型:T-If 要求两个分支具有同一类型,而 与 的类型不同。 这不违反保型,因为保型的前提是起点已有类型。从一个正常的终点倒推起点有类型,相当于把一个单向蕴涵改成了逆命题,
[附注] 类型系统是保守的
已经提醒过不能这样用。 练习5.4:扩展:不受阻,仍然可以永远运行 新规则是 。 仍不是值,始终有下一步,所以没有受阻;每一步又回到同一个具有 的项,因此这条链一直保持类型,却永不结束。 原进展证明的 T-Pred 零参数情形仍有后继;原保型证明的 E-PredZero 情形改为“结果就是起点,已有 ”。其他情形不变,所以这门修改后的语言仍可证明进展、保型与不受阻的类型安全。 但大小始终为 ,原语言“每一步严格变小”的论证不能再用,求值循环也不能再保证停止。类型安全没有承诺终止;要证明终止,还需要另外的下降度量或其他证明。
7.1
先把命题写下来
7.2
典范形式
7.3
进展
7.4
保型
7.5
合起来
7.6
练习:类型安全
本节练习(4题)
提示
每个中间项都为 ;条件内部先保持 ,外层 保持 ,不能把这两个类型混成一个固定的 。参考答案
提示
分别修改,不要把两种修改混到同一门语言里。保型只对实际存在的归约步骤作保证。参考答案
提示
从“分支类型不同,但运行选中一个正常分支”的项开始寻找。参考答案
提示
把 E-PredZero 改成自循环步骤,但不保留它原来归约到 的版本。检查下一步是否存在、类型是否改变、大小是否下降。参考答案
[定义] 类型与类型判断
定义的是一个关系:哪些 有推导。它没有告诉我们怎么找推导。真正写出来的类型检查器是一个函数:
这个函数和规则之间隔着一道缝。写代码的人会觉得“显然一样”,可这正是
[例] 不完整的规格
之后一直在提防的那个词。比如有人把
以
[定义] 类型与类型判断
的关系 为规格,以
Rust 类型检查算法
可靠说算法不乱接受,完备说算法不乱拒绝。两者合起来,算法恰好判定了这个关系。
“可靠”这个词在 PLT 里有两种常见用法,容易混: 两条串起来才是我们真正关心的:检查器接受的程序,运行时不会受阻。只有一条,链条就是断的;两条的组合见
[推论] 检查器接受的程序不会受阻
。 先确认一个隐含前提:
首先证明可靠性,因为类型安全定理说的是“规则能推出类型的项”,不是“某个函数返回
取
Rust 类型检查算法
对 结构归纳(
[约定] 归纳证明的写法
)。
可靠性还不够描述“忠实实现规则”:一个对所有输入都返回
取
Rust 类型检查算法
对 的推导归纳,按最后一步的规则分情形。 两个证明各自只有几行,但方向不同、归纳的对象也不同:可靠性跟着算法走(对项归纳,因为算法按项递归),完备性跟着推导走(对推导归纳,因为前提给的是推导)。 最后要把两个不同的保证接起来:算法符合类型规则,类型规则又与运行规则协调。少了第一段,错误检查器可能乱接受;少了第二段,类型系统可能认可运行时受阻的项。这个端到端结论才是使用者真正需要的“检查通过以后能相信什么”,也说明只证明某个局部函数正确不足以替整条链作保证。
取
Rust 类型检查算法
这一条就是
[附注] 两个“可靠”
说的那条完整的链。完备性没有出现在这里:因为完备性保证的是检查器“不冤枉好程序”,而非“安全”。 这一节的证明顺利,靠的是
[引理] 反演
依赖的那个性质:规则是语法导向的,而且每条规则前提里出现的类型,都能从子项算出来。T-If 里的 由 算出,然后拿来比较 ,没有哪一步需要凭空猜一个类型。
如果某条规则的前提里出现了一个结论里看不到、子项也算不出的类型,照着规则写函数就走不通,函数不知道该填什么。函数类型的规则就是典型例子:给 (记号见
[附注] 求值策略、归约策略与合流性
)定型时,参数 的类型在项里找不到。那时规则仍然定义了一个清清楚楚的关系,但从关系到算法的那一步,需要额外的设计和额外的证明。
[定义] 类型与类型判断
的布尔值/自然数语言足够小,避开了这个问题;但区分“规则”和“算法”、并分别证明可靠与完备的习惯,在更大的语言里同样适用。
逻辑学里也有一对“可靠 / 完备”:一个证明系统是可靠的,如果能证明的都是真的;是完备的,如果真的都能证明。哥德尔 1929 年证明一阶逻辑的证明系统是完备的;两年后的不完备定理则说,足够强的算术理论里,总有真而不可证的命题。
[定义] 可靠与完备
描述的类型检查算法与类型规则之间的关系,与此平行:
练习6.1:可靠与完备守的是不同方向 对所有输入返回
对所有输入返回
C 只接受三个常量并返回它们正确的类型,其余返回
练习6.2:漏掉条件种类的检查 若
错误检查器会给 返回
修复是恢复条件种类的比较: 这处遗漏本身不会破坏完备性:对原规则认可的项,条件确实为
练习6.3:检查顺序不同,会改变程序的运行吗? 可以在保持全部条件检查的前提下,先检查条件,再检查两个分支。例如: 原版与此版恰好都要求:条件为
这不会改变
练习6.4:实现一个带期望类型的检查接口 若
8.1
规则不是程序
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Type { Bool, Nat }
/// 类型检查算法:返回 Some(T) 表示“t 的类型是 T”,None 表示拒绝。
pub fn type_of(t: &Term) -> Option<Type> {
use Term::*;
match t {
True | False => Some(Type::Bool), // T-True / T-False
Zero => Some(Type::Nat), // T-Zero
Succ(t1) | Pred(t1) => { // T-Succ / T-Pred
(type_of(t1)? == Type::Nat).then_some(Type::Nat)
}
IsZero(t1) => { // T-IsZero
(type_of(t1)? == Type::Nat).then_some(Type::Bool)
}
If(c, a, b) => { // T-If
let ta = type_of(a)?;
(type_of(c)? == Type::Bool && type_of(b)? == ta).then_some(ta)
}
}
}#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Type { Bool, Nat }
/// 类型检查算法:返回 Some(T) 表示“t 的类型是 T”,None 表示拒绝。
pub fn type_of(t: &Term) -> Option<Type> {
use Term::*;
match t {
True | False => Some(Type::Bool), // T-True / T-False
Zero => Some(Type::Nat), // T-Zero
Succ(t1) | Pred(t1) => { // T-Succ / T-Pred
(type_of(t1)? == Type::Nat).then_some(Type::Nat)
}
IsZero(t1) => { // T-IsZero
(type_of(t1)? == Type::Nat).then_some(Type::Bool)
}
If(c, a, b) => { // T-If
let ta = type_of(a)?;
(type_of(c)? == Type::Bool && type_of(b)? == ta).then_some(ta)
}
}
}x.then_some(y)
x.then_some(y)
的意思是“条件
x
x
成立就返回
Some(y)
Some(y)
,否则返回
None
None
”。If
If
分支写成只检查
a
a
、不检查
b
b
,大多数手写测试照样能过。要把“一样”说清楚,得拆成两个方向。
type_of
type_of
为实现。
Some(T)
Some(T)
表示算法报告类型 ,
None
None
表示拒绝;Rust 的
Bool
Bool
、
Nat
Nat
分别对应对象语言的 、。type_of(t) = Some(T)
type_of(t) = Some(T)
,则 。type_of(t) = Some(T)
type_of(t) = Some(T)
。
type_of
type_of
的这一性质见
[定理] 算法可靠性
,一般定义见
[定义] 可靠与完备
。
8.2
证明
type_of
type_of
对每个输入都会返回,不会无限递归。它是结构递归(每次递归调用的参数都是
[定义] 直接子项与真子项
中的直接子项),由
[定义] 大小与深度
后面那段讨论,这样的函数总会终止。Some
Some
的项”。若检查器在
If
If
分支里忽略 else 分支,就可能接受 ;程序下一步变成受阻的 。类型规则本身没有错,错的是算法冒称规则已接受它。下面的定理就是防止这种“假阳性”。
type_of
type_of
,类型关系采用
[定义] 类型与类型判断
。Rust 的
Bool
Bool
、
Nat
Nat
分别对应 、。对任意项 和类型 :若
type_of(t) = Some(T)
type_of(t) = Some(T)
,则 。type_of
type_of
本身就是按项的形状分支的,所以每个情形对应一个分支。True
True
、
False
False
分支返回
Bool
Bool
,
Zero
Zero
分支返回
Nat
Nat
,分别是 T-True、T-False、T-Zero 的结论。Some(Nat)
Some(Nat)
只有一种可能,
type_of(t1) = Some(Nat)
type_of(t1) = Some(Nat)
。由
归纳假设
,用 T-Succ 得 。、 同理,分别用 T-Pred、T-IsZero。Some(ta)
Some(ta)
说明
type_of(c) = Some(Bool)
type_of(c) = Some(Bool)
、
type_of(a) = Some(ta)
type_of(a) = Some(ta)
、
type_of(b) = Some(ta)
type_of(b) = Some(ta)
。三次使用
归纳假设
分别给出 、、( 即
ta
ta
),正是 T-If 的三个前提。None
None
的检查器也是可靠的,却连 都不接受。完备性排除这种“假阴性”,保证规则认可的程序不会被实现漏掉。例如把 T-Pred 对应的代码分支误写成直接返回
None
None
,不会放进坏程序,却会冤枉 这样的合法程序;下面这条定理能发现它。
type_of
type_of
,类型关系采用
[定义] 类型与类型判断
。Rust 的
Bool
Bool
、
Nat
Nat
分别对应 、。对任意项 和类型 :若 ,则
type_of(t) = Some(T)
type_of(t) = Some(T)
。type_of
type_of
即得。type_of(t1) = Some(Nat)
type_of(t1) = Some(Nat)
,于是
Succ
Succ
分支返回
Some(Nat)
Some(Nat)
。T-Pred、T-IsZero 同理。type_of
type_of
在三个子项上分别返回
Bool
Bool
、
T
T
、
T
T
。代入
If
If
分支:
ta = T
ta = T
,两个比较都成立,返回
Some(T)
Some(T)
。
type_of
type_of
,以及
[定义] 多步归约
的关系 。若
type_of(t) = Some(T)
type_of(t) = Some(T)
且 ,则 不会处于
[定义] 范式与受阻
定义的受阻状态。
8.3
为什么这里这么顺利
type_of
type_of
扮演“机械的证明过程”,类型规则扮演“什么才算对”的标准。差别在于,这里的标准本身也是一组规则,而且足够简单,两个方向分别由
[定理] 算法可靠性
与
[定理] 算法完备性
证明。语言再大一些,“完备”就常常要附加条件,甚至干脆不成立,设计者必须决定放弃哪一边。
8.4
练习:规则与算法
本节练习(4题)
None
None
;B 总返回
Some(Nat)
Some(Nat)
;C 只接受 、、 并返回正确类型,其余返回
None
None
。判断每个检查器相对原类型规则是否可靠、完备;凡不成立的方向,都给出反例。参考答案
None
None
的 A 是可靠的:可靠性的前提从不成立;但它不完备,因为 却被拒绝。Some(Nat)
Some(Nat)
的 B 既不可靠也不完备。它为 报告 ,规则只能给出 ,所以不可靠; 成立却没有返回
Some(Bool)
Some(Bool)
,所以也不完备。它还会接受根本没有类型的 。None
None
。它可靠,但不完备: 就是被漏掉的合法项。可靠性允许少接受,完备性不允许漏掉规则认可的判断。type_of
type_of
的
If
If
分支写成下面这样,其余分支不变,找出一个错误接受的项,指出漏了 T-If 的哪个前提,并修复代码。这个修改是否也必然破坏完备性?If(c, a, b) => {
let ta = type_of(a)?;
type_of(c)?;
(type_of(b)? == ta).then_some(ta)
}If(c, a, b) => {
let ta = type_of(a)?;
type_of(c)?;
(type_of(b)? == ta).then_some(ta)
}提示
让两个分支都是 ,却把条件也写成 ;再区分放进坏项与漏掉好项。参考答案
Some(Nat)
Some(Nat)
:两个分支相同,条件也能得到某个类型,所以三个调用都成功。但 T-If 要求条件为 , 只能为 ,因此这个项没有类型,算法可靠性失效;运行时它也受阻。If(c, a, b) => {
let ta = type_of(a)?;
(type_of(c)? == Type::Bool && type_of(b)? == ta).then_some(ta)
}If(c, a, b) => {
let ta = type_of(a)?;
(type_of(c)? == Type::Bool && type_of(b)? == ta).then_some(ta)
}Bool
Bool
,修改前后的检查都会通过;对子项按推导归纳,仍得到正确的输出类型。但“好项仍被接受”不抵消“坏项也被接受”。type_of
type_of
在
If
If
分支先检查 then 分支。能否改成先检查条件,同时保持接受的项与输出类型不变?给出代码并论证。这样的改动会不会让对象语言先运行 then 分支?提示
本文的
type_of
type_of
没有副作用,总会终止,失败只返回
None
None
。它的调用顺序不是对象语言的求值顺序。参考答案
If(c, a, b) => {
if type_of(c)? != Type::Bool { return None; }
let ta = type_of(a)?;
(type_of(b)? == ta).then_some(ta)
}If(c, a, b) => {
if type_of(c)? != Type::Bool { return None; }
let ta = type_of(a)?;
(type_of(b)? == ta).then_some(ta)
}Bool
Bool
、两个分支都有类型且相等。每次调用都终止且没有副作用,所以最终
Some(T)
Some(T)
或
None
None
相同。条件不合适时,此版只是更早返回
None
None
。step
step
的顺序:检查器在元语言中分析整个项,求值器则按对象语言的 E-规则运行。若检查器改成返回详细错误,检查顺序可能改变首先报告哪个错误;那是诊断接口的额外行为,不是本文
Option<Type>
Option<Type>
结果的区别。check(t, expected) -> bool
check(t, expected) -> bool
,使它当且仅当原规则能推出 时返回真,其中
expected
expected
对应 。证明两个方向及终止性,给出成功、期望类型不符和项本身不良类型三个例子。提示
不必新增类型规则;比较
type_of
type_of
的结果与
Some(expected)
Some(expected)
,然后分别使用算法可靠与完备。参考答案
pub fn check(t: &Term, expected: Type) -> bool {
type_of(t) == Some(expected)
}pub fn check(t: &Term, expected: Type) -> bool {
type_of(t) == Some(expected)
}check(t, T)
check(t, T)
为真,
type_of(t)
type_of(t)
等于
Some(T)
Some(T)
,由
[定理] 算法可靠性
得 。若 ,由
[定理] 算法完备性
得
type_of(t) = Some(T)
type_of(t) = Some(T)
,比较为真。两方向合起来就是
check(t, T) = true
check(t, T) = true
当且仅当 。∎type_of
type_of
结构递归并终止,最后一次类型比较也终止,所以
check
check
终止。
check(true, Bool)
check(true, Bool)
为真,
check(true, Nat)
check(true, Nat)
为假,
check(pred 0, Nat)
check(pred 0, Nat)
为真;没有类型的 对两个期望类型都返回假。这只是原算法的接口包装,没有增加一种对象语言的判断或求值策略。
纸面证明可能写错,代码也可能和规则对不上。定理本身可以直接写成测试:生成大量项,拿定理的结论去检查。 这门语言没有循环,每一步都让项变小,所以
确定性也能这样测。测试里把 E-规则逐条照抄成一个返回所有可能 的函数,不做任何排序或排除,然后检查它在每个样本上至多给出一个结果,并且和
前文提过的两个错误写法都会被抓住,但抓住它们的测试不同: 每条定理守住的东西不同,少证一条,就会漏掉一类错误。
测试只能检查有限个样本,证明覆盖命题量词范围内的所有对象。例如,
用测试对照证明
的测试样本来自
[定义] 项的集合
的项集合,深度按
[定义] 大小与深度
计算;深度 3 的项已经多到没法穷举。测试过了,也只说明在这些样本上没找到反例。两者的分工是:证明负责“为什么对”,测试负责发现“证明和代码说的是不是同一件事”。测试失败,说明证明或代码有一处错了;证明写不下去,往往说明测试还没碰到反例。 这个差距在真实软件里大得惊人。MikanAffine 的《为什么 sqlite 可以被重写》以 SQLite 为例:它用约 9000 万行测试覆盖约 20 万行源码,仍然不断被报告出漏洞。原因是程序每经过一个
类型系统走的是另一条路。
[推论] 类型安全
这样的定理不是对路径逐条检查,而是一次性地对所有良类型程序、所有执行路径断言“不会受阻”。前提是类型系统本身是可靠的(sound):它说没问题的程序,运行时真的没有那一类问题。一个不可靠的类型系统(比如允许随意强制转换指针的 C)给出的“通过检查”就不能当作保证,该测的还得测。 这正是近年 RIIR(Rewrite It In Rust,用 Rust 重写)思潮背后最实在的理由。Rust 的类型系统和借用检查器把内存安全、数据竞争这类错误从“要靠测试和运气去发现”变成了“编译不通过”;RustBelt 等工作则在形式化层面证明了它的安全核心是可靠的。微软和 Chromium 都报告过,各自产品中约七成的严重安全漏洞来自内存安全问题;而 Android 在新代码转向内存安全语言之后,这类漏洞的占比显著下降。静态检查当然不能取代测试,但它把一整类错误从测试的负担里拿掉了,这是测试本身做不到的。
波普尔(Popper)认为,经验科学里的普遍命题无法被有限次观察证实,只能被一次反例证伪。测试的处境与此相同:一万个通过的样本不能证实“所有良类型程序都不受阻”,一个受阻的样本就能推翻它。 证明则走另一条路。它不观察,而是从定义出发推出结论,所以可以对无穷多个程序负责。代价是它只对定义负责:如果规则本身没有描述我们心里想的那门语言,证明再严密也帮不上忙。这说明了证明与测试的分工:证明管“从规则到结论”,测试和实现管“规则是不是我们想要的”。
[推论] 类型安全
与
用测试对照证明
分别给出了这两种工作的例子。 练习7.1:给不同的错误配不同的测试 四处修改应分别注入、分别恢复,才能知道哪个测试在防哪类错误。放宽规则的错误不一定破坏类型安全,不能指望一条安全测试包办所有性质。 练习7.2:通过测试,还是没有真正测到? 只生成不良类型的项时,
至少统计并断言良类型样本数大于零、实际归约总步数大于零,再加入确实需要多步运行的嵌套项。还应单独测拒绝的项和受阻情形。这些统计能排除明显的空转,却不等于覆盖所有规则和路径。
练习7.3:再多的有限样本也留下边界 假设这批样本非空, 就是有限的最大深度;空样本集可取 。可以构造一个终止的错误检查器: 每个测试样本深度都不超过 ,所以它们得到的结果与正确实现完全相同。可是给 外面包上 层 ,得到的项深度为 ,会被假检查器接受为 ;原类型规则却无法给最内层 定型,整个项没有类型,而且受阻。 因此有限测试通过,只能排除样本范围内已出现的反例。这里不声称真实 bug 一定这样写,而是用一个具体构造说明:没有关于所有项的证明,测试结果本身不蕴涵普遍可靠性。 练习7.4:为什么求值测试的循环会停? 对 的推导归纳。E-IfTrue 与 E-IfFalse 只留下一个分支,删掉根、条件及另一分支,大小严格下降。E-PredZero 和 E-IsZeroZero 从两个节点变成一个;E-PredSucc 删除 与 两个节点;E-IsZeroSucc 把大小至少为 的项变成一个 节点。这覆盖六条计算规则。 对 E-Succ、E-Pred、E-IsZero,
归纳假设
给出子项大小严格下降,两边包上同一个一元节点,严格不等式仍成立。对 E-If,只有条件变化,两个分支与 根贡献的节点数相同,也保持严格下降。因此十条规则都给出 。∎ 大小始终是至少为 的整数,起点大小为 时,最多走 步就必须停止。停止时是否为值是另一个问题:不良类型项也会停止,却可能受阻;对良类型项,进展才排除这种终点。 深度不适合替代这个严格下降度量。例如 的第一步只把条件变成 ,最长路径仍在 then 分支,两项深度都是 。类型安全也不独自保证循环停止,见本节之前的自循环扩展题。
#[test]
fn safety() {
for t in corpus() { // 深度 ≤ 2 的全部项 + 随机的深项
let Some(ty) = type_of(&t) else { continue };
let mut cur = t.clone();
loop {
// 进展:不是值,就必须能归约
let Some(next) = step(&cur) else {
assert!(is_value(&cur), "受阻了:{cur:?}");
break;
};
// 保型:一步归约,类型不变
assert_eq!(type_of(&next), Some(ty), "{cur:?} ⟶ {next:?}");
cur = next;
}
}
}#[test]
fn safety() {
for t in corpus() { // 深度 ≤ 2 的全部项 + 随机的深项
let Some(ty) = type_of(&t) else { continue };
let mut cur = t.clone();
loop {
// 进展:不是值,就必须能归约
let Some(next) = step(&cur) else {
assert!(is_value(&cur), "受阻了:{cur:?}");
break;
};
// 保型:一步归约,类型不变
assert_eq!(type_of(&next), Some(ty), "{cur:?} ⟶ {next:?}");
cur = next;
}
}
}loop
loop
一定会停。代码在
code/plt-arith
code/plt-arith
下,
cargo test --release
cargo test --release
即可运行。样本是深度 2 以内的全部 59439 个项,外加 50000 个深度到 6 的确定性随机项。step
step
一致。这同时验证了
[附注] 分支顺序里藏着证明
的说法:
match
match
的分支顺序没有偷偷改变语义。safety
safety
实际报出的反例是 :错误的检查器只看 then 分支,认为它是 ;一步归约(E-Succ + E-IfFalse)得到 ,没有类型,而且受阻了。
if
if
就分裂出两条执行路径,要覆盖的路径数随分支数指数增长,测试的增长永远追不上。
9.3
练习:测试与证明
本节练习(4题)
提示
分别检查值不可归约、进展、关系的所有后继,以及检查器接受后类型能否保持;测试一个函数只有一个返回值不是确定性测试。参考答案
step(True)
step(True)
返回
Some(Zero)
Some(Zero)
:用值不可归约测试要求
step(True) == None
step(True) == None
。这个错误也把
Bool
Bool
变成
Nat
Nat
,所以保型检查也能发现。step(Pred(Zero))
step(Pred(Zero))
返回
None
None
: 不是值,却无法走一步,进展测试会失败。step
step
选出的一个返回值。type_of
type_of
忽略 else 分支: 被错认为 ,一步得到 ,新项没有类型且受阻。逐步保型或端到端安全测试能抓到它。safety
safety
测试如果只生成不良类型项,或者只生成 、、,会发生什么?怎样用统计断言发现这种空转?另解释为什么
assert_eq!(step(t), step(t))
assert_eq!(step(t), step(t))
,或者从
step
step
直接包装出的“关系枚举器”,不能验证求值器忠实于规则。参考答案
let Some(ty) = type_of(&t) else { continue };
let Some(ty) = type_of(&t) else { continue };
会跳过每个样本,进展和保型断言一次都不运行。只生成常量值时,类型检查会通过,但
step
step
立即返回
None
None
,仍从未检查一次类型保持。assert_eq!(step(t), step(t))
assert_eq!(step(t), step(t))
只是一个确定执行的函数与自身比较;即使它算错了,两边仍可能同时错。把“关系枚举器”直接写成
step(t).into_iter().collect()
step(t).into_iter().collect()
也没有独立核对规则。应分别按规则列出后继,再与函数实现对照;两份代码仍可能犯相同错误,所以还需结合具体例子、逐规则审阅与证明。type_of
type_of
的所有结果对照,却在某个更深的项上不可靠。给出函数、反例和推理,说明这个例子揭示的测试边界。提示
设这批样本的最大深度为 。让错误只在深度超过 时出现。参考答案
pub fn bounded_fake(t: &Term, d: usize) -> Option<Type> {
if depth(t) > d { Some(Type::Nat) } else { type_of(t) }
}pub fn bounded_fake(t: &Term, d: usize) -> Option<Type> {
if depth(t) > d { Some(Type::Nat) } else { type_of(t) }
}提示
对一步归约的推导归纳。计算规则删掉节点;同余规则把子项的严格下降带到外层。深度不一定严格下降。参考答案
全文走过的路,可以按“用了什么前文”串成一条线: 贯穿始终的方法只有一个:先把东西定义成满足某些规则的最小对象,再沿着这些规则归纳。语言变大以后,规则会变多,证明会变长,但这个方法不变。它在数学和逻辑史上的来历,见
[注记] “最小 + 归纳”的历史
。
形式化入门:从 BNF 到类型安全
定义了布尔值/自然数语言,并证明其类型安全;这套形式化仍有几项默认采用、未单独展开的前提:
10.2
延伸阅读
Types
Types
一章把本文的定理全部机械化证明了一遍。
[] 对话录
[index]
- 2026-05-03
- Glomzzz et al.
- 2026-05-03
- Glomzzz et al.
[对话录] 没有出路,但请继续追问
[no-exit-keep-asking]
- 2026-05-03
- L / G / LLM
- 2026-05-03
- L / G / LLM
I. 问题的提出
我们似乎没有属于我们这个时代的共产主义思想。
不是取材于过去、因而停留于过去的牢左,要么只是信仰资本家的鬼话。没有一种同时做到批判过去,并在此基础上设计未来的共产主义思想。
一旦基础物品实现公有、按需分配,所谓的“需”是按照客观地且科学地定义人可能且被认为合理的需求,还是按照每个个体的主观想法上报自己的需求?倘若是后者,很难想象在法律消失、国家机器消失的情况下没有人会一次性想要数倍于正常的“需”。
有人说面对这种情况,即出现反社会、反共产主义的人,人们可以让这样的人“社会性死亡” 排挤他,批判他,孤立他。所以这时候还是有一种虽然不是法律,但仍然存在于人们心中的行为准则?共产主义下的人们用这套准则,在法律消亡、国家机器消亡的情况下自行识别和裁决。
但有两个问题:
异质性群体的困境。倘若反社会人群是一个团体,是一个“共产主义意识形态下需要被庇护的群体” 比如极端 ASD 群体,他们之中的部分人不理解这套行为准则,所以他们就被定义为反社会的 outlier 吗?如果这个群体不被排挤,很难想象在基础资源共有的情况下他们的行为会与人们的行为准则产生多大冲突。但倘若被排挤,共产主义维护多元主义和保护弱势群体的原则在这里显示出它的失能和局限。
行为准则能否替代法律?倘若每个独立人心中的“行为准则”是具有规范性和普遍性的,为什么迄今为止的所有国家、组织和社群都发展出了类似于法律的规定形式?“行为准则”到底还是人心中的一套机制,每个人的经历和感受都不同,这种千奇百怪的准则恐怕很难成为全社会都具有规范性和约束力的制度。法律作为一种天然具有威慑力和权威性的白纸黑字的规定,恐怕会比这种“行为准则”有效得多。很难想象共产主义在取缔法律之后不会再衍生出一种类似于“法律”的制度,不会产生出一种背书法律的国家形式和国家暴力机器。
并且,所谓“行为准则”非常可怕。法律背书下我们至少还有申诉和辩护的机会,会有法庭和司法机关来处理。但倘若取消法律,保持这种由共同体阐释的“行为准则”,被指控的人还有申诉和辩护的机会吗?替罪羊的问题怎么办?倘若触发一种集体性的恐慌,而这种恐慌没有某个具体的人担责,那么每个人不都有成为替罪羊的可能?
II. 传统理论的回应与失败
好问题。答案是:部分可以回答,但核心困难无法被现有理论充分化解。
关于“需”的定义。
马克思本人故意不定义“需”,因为他认为在生产力极度发达、异化消除之后,人的需求本身会发生质变,不再是资本主义塑造的扭曲欲望,而是真正属人的需求。所以“无限索取”在他看来是资本主义人格的产物,而非共产主义条件下的真实危险。
但这个回答是循环的:共产主义人格是共产主义的前提还是结果?如果是结果,过渡期怎么办?
关于行为准则能否替代法律。
这里传统马克思主义的回答最薄弱。恩格斯的“国家自行消亡”预言从未配备过机制说明。列宁在《国家与革命》里直接说:届时社会规范将简单到“人人都能掌握”,无需专门机构执行。但这个预设极其乐观,且恰好回避了异质性问题。
葛兰西的“霸权”理论反而提供了一个更诚实的图景,他承认规范的内化本身是权力运作,但他的结论是争夺文化领导权,而不是消除规范强制性。这实际上放弃了“法律消亡”的目标。
关于替罪羊与程序正义。
这是共产主义思想最难回答的一个。传统理论几乎没有认真处理过无法律状态下的程序正义问题。最接近的是卢卡奇的“阶级意识”概念,他认为真正的无产阶级意识会自发地避免这类错误,但这是一种近乎神学的乐观主义。历史经验,巴黎公社、文革群众批斗,恰好反复证明了你的担忧:去制度化的集体审判往往比法律更残酷,正是因为缺乏制动器。
结构性诊断:共产主义思想在这三个问题上的共同困难指向同一个根源,它对人的同质性有一个隐含的乐观预设:充分发展的人将趋于相似的理性、相似的需求、相似的规范感。
ASD 论证恰好是对这个预设最有力的单点反驳:人的神经认知差异是生物性的,不会被生产关系的变革消除。这不是边缘案例,而是对整个理论地基的质疑。
共产主义思想可以回应你的问题,但无法解决它们。这个缺口不是理论细节上的漏洞,而是整个传统对人类多样性问题的系统性低估。
你觉得这个缺口是可以在马克思主义框架内修补的,还是需要引入外部资源?
III. 宗教还是理论?
还是那句话:
共产主义不严格规范自己的内部机制,不解决自己体系内矛盾,不与资本主义的支持者对话和回答问题,不放弃自己的乌托邦美梦,不从现在就开始着手思考真正可行的解决方案,那共产主义就是宗教。
论证不严密,或者是用一个系统性的结论直接屏蔽所有的讨论,叽里咕噜这一块。
IV. 三条路,没有一条完整
也许马克思对人性的预设过于乐观,但对人性过于悲观同样是预设,而不是论证。我们目前在做的:承认传统理论“无法解决”这些问题,但仍然在用传统理论的框架来“回应”这些问题,这是想象力的匮乏。
总结下来,人们能做的只有三种情况:
- 接受根本性批判,放弃制度自洽(传统马克思主义)
- 接受制度自洽,放弃根本性批判(分析马克思主义 / 左翼自由主义)
- 接受两者都不完整,把它当作一个开放的研究问题
什么?你说你需要“一套既能根本性批判现有秩序、又能在制度设计上自洽的理论”?
孩子们,我们需要 114514 个神圣哀悼 + 1919810 个童子军大王。
他们不也在用正义理论来论证再分配,又同时保留法律和程序正义?那我们“共产主义”这个标签和它的根本性批判在哪?
@L 你知道我们接下来需要谁吗?需要尼克·兰德。
V. 加速主义的溶剂
尼克·兰德的三个洞见:
- 任何乌托邦设计都内含一种对人的规范性暴力
- 左翼和右翼共享了对“控制”的迷信
- “人是历史的主体,理性可以设计社会秩序”只是个预设
资本本身是一种非人格的加速过程,它不受任何意识形态的指挥。马克思主义试图“驯服”或“超越”资本,但这个试图本身就是对资本本质的误认。当然,真理源自误认。资本不是人类可以掌控的工具,它有自己的运动逻辑。
阿多诺:启蒙理性试图控制自然和历史,但这个控制的冲动本身会反转,变成一种新的神话和统治形式。理性以为自己是主人,但它被自己创造的系统所反噬。
既然资本有自己的逻辑,那就让它加速运行到极限,让矛盾激化到系统崩溃。
爆了!孩子们。
这不是《启蒙辩证法》么?异化成“工具理性”和“技术理性”的启蒙理性是这样的。
如果要等到爆炸,那需要相当久相当久的时间。人们都被麻木得不成样子,矛盾也不见得是先被激化还是先被缓和,也许没被激化就被缓和了。
我看应该把“资本”换个词,换成异化:
人的全部本质逐渐展现在自己的衍生物或者创造物身上,创造物的无限增殖让人失去了掌控它的能力却无限依赖于它,人的创造物反过来控制了人。
异化本身是一种非人格的加速过程,它不受任何意识形态的指挥。马克思主义试图“驯服”或“超越”异化,但这个试图本身就是对异化的本质性误认。
异化不是人类可以掌控的趋势,它有自己的运动逻辑。
但是资本或者异化有吸纳一切内在矛盾的能力。矛盾被激化或者被指出,似乎有一种逃离资本和异化的趋势,但是这种趋势本身又迅速地被异化,被资本吸收。
VI. 溶剂不是地基
过程是无主体的,没有人在驾驭它,它驾驭所有人。
兰德的洞见捕捉到了一点:技术加速确实在以一种非线性的方式重组现在,资本和技术的自主逻辑会重组一切,包括人类的欲望、认知、社会形式。
但兰德作为一个彻底拒绝了所有规范性锚点的哲学家,在政治上会漂向最近的强力结构,而他所漂向的是最古老、最粗糙的那种。因为他这个作为整个理论基石的洞见把一个问题完全搪塞过去了:
即使过程本身没有单一主体,过程的具体展开仍然有受益者和受害者,仍然有人在特定节点上做出选择,仍然有权力在运作。
而把历史性的权力结构呈现为自然的客观过程,这本身就是意识形态最典型的运作方式。兰德批判了这种意识形态运作,然后自己复制了它。他的滑坡体现在:他把这个洞见变成了一场完全虚无主义的宿命论,然后用宿命论来为自己的政治选择开脱。
“这是客观过程” → “所以我不需要为我的立场负责”
一个哲学家如果用自己的理论来免除自己的责任,这本身就说明这个理论出了问题,或者这个思想家出了问题,或者两者都是。
兰德比任何人都更彻底地拆解了现代性的规范性预设,但正因为如此,他也比任何人都更容易被用来为任何东西辩护,包括最糟糕的东西。后现代是这样的,兄弟们。
兰德的理论本身不构成地基,他仅仅是一种能溶解框架的溶剂。这套哲学如果只有溶解能力,你可以用他来清理视野,但不能用他来建造任何东西。
VII. 结语
接下来,让我用一段非常神圣且哀悼的话作为结尾:
兰德的错误不是发现了没有出路,而是把没有出路变成了一种姿态,最终变成了怠惰和残忍的借口。
我们现在能做的事情,只有:精确地追问,不接受廉价答案,同时仍然在意这些问题。
我觉得这已经是在没有出路的情况下能做的最诚实的事。
This is quite not enough, but it’s realistic.
问吧,孩子们,问吧。
后现代全员战败?活该。