程序与证明

程序与证明

来源: <http://www.tuicool.com/articles/YRZvem>

 

{-
   Turing complete, or not Turing complete: that is the question:
   Whether 'tis nobler in the machine to suffer
   The undecidability of a paradoxical recursion,
   Or to take proofs against a sea of calculi,
   And by opposing halt them?

   -- soimort's soliloquy
-}
  • §0 前言
  • §1 从计算到程序
  • §2 语言的巴别塔
  • §3 程序即证明
  • §4 程序非证明
  • §5 结语

§0 前言:关于一部传记文学,以及未来的计划

大约七个多月以前,我在 Blogger 上的随笔里设想过一个宏大的计划:用七个月的时间,写作一部从亚里士多德时代的传统逻辑延续到今天的 数学基础 研究、从若干次数学危机中发展起来的公理化系统到现代科学的形式化、从欧氏原本中最初以几何形式为载体的数学证明到借力于现代计算机工具的 Q.E.D. 宣言 和 Univalent Foundations 工程 ,横跨数学、逻辑学、形式语言学和程序语言等诸多领域的计算科学传记史(从尚未完成的第一章来看,这似乎更接近于一部野史,灵感大概来源于很久之前看过的一个流传颇广的讲各种数学家八卦的帖子《 Heroes in my heart 》——但绝对不再是什么伪史)。当时我这样写道:

 

我想在这个系列的文章里探讨一个最基本的问题:什么是程序。最初,我为它所设想的标题叫“程序语言是什么”,或“一个没有图灵机的世界”,阐释图灵机的计算模型为何适用于描述我们的现实世界;为何又不适合于描述逻辑的世界;阐释为何 Coq、Agda 这样的函数式语言从设计上与图灵构想的方式背道而驰,甚至并非是图灵完全的,却仍可以称之为程序语言,而且更加接近我理想中纯粹的语言;阐述证明论与类型论之间的关系,为何从形式化逻辑出发方能构建出可验证的终极程序或软件工程。最后我发现,这已经不仅仅是程序语言的本质问题了;它实则是关乎计算本质的终极问题。

 

七个月之后回过头再看,这项计划果然不出所料地搁浅了。但我还是想最后写一些关于程序、计算和证明的内容——它既非完全的科普,也算不得纯文学体裁的散文;只是为了弥补自己从未写过有关这方面的文章的缺憾,也作为这个博客的一个收尾(可能)。

 

* * *

 

对于我自己来说,这只是一切的开始之开始。在任何一个领域,学习到的越多,越是感到自己认知的肤浅和局限,而原地踏步时唯有陷入自我膨胀的恶循环;无论是曾经无从所求选择的地球物理学也罢,心猿意马的系统生物学也罢,还是这三年亡羊补牢的计算机编程也罢。我以为,人类的知识体系累积到今日,计算科学的基石已同其他学科领域别无二致,亦绝非匹夫之力可以撼动。在这个没有英雄的时代,即便是天才也无法不通过努力学习而获得成果,更何况我这样智商尚处中游的普通人。只有傻子和民科才会画地为牢,把自己的认知局限想当然地看作整个宇宙的中心,还自以为发现了一切事物的终极真理;而现实的知识如同芝诺的圆圈,有的人知道得少一些,有的人知道得多一些,更有人在努力开拓圆圈的边界——然而圆圈以外的区域,对于任何人而言却是无限未知。(我想,这大概就是 Ph.D. 的意义罢:“The world looks different to you now. Don't forget the bigger picture. Keep pushing.”)即使人生是个无解的死循环,现在起步去追求一些有价值的东西,应该还不算太晚。

 

* * *

 

然而,三年的时间足以改变许多,于我自身。我越来越感受到,网络这种廉价的媒介虽带来了信息的便利,却也充斥着谬误和浮躁,适合于万人追捧的偶像或者小丑,而不再适合知识的沉淀与思考的深度。那种逡巡于图书馆书架间“吹尽狂沙始到金”的喜悦,是再也找不到了。前人尚有“尽信书,则不如无书”的教诲,今天的人却会不假思索地转发和点赞网络上一些或慷慨激昂、或煽情肤浅而毫无逻辑理性可言的文字,实在是一件可笑的事情。这也是我除了写博客之外,越来越无心在别处谈论技术或表达观点的原因之一。知识的快餐化和随之而来的功利化,乃是我们这个时代的悲剧。

而知识的拓荒者并不需要成为摇滚明星,他们中的大部分人总是选择默默无闻,除非在身后因成就而被追随者捧到聚光灯下;因为在生前被普通大众关注和需要,是一件足以让人从自己所投入的事业中分心的事情。黄金也许会被时间的流沙埋没,也许要到数十年甚至百年后才会被人重新发现;来自不同时代的开拓者,也许十之八九会被人遗忘,后人在享受他们含辛茹苦创造的智慧结晶时,不曾记起他们的名字。但对于人类历史的长河来说,这一切都将不足挂齿;那些浮华一时、甚嚣尘上的,纵然一呼百应,终究不过是无足轻重的沙砾而已。倘若我们每个人都因为害怕孤独,害怕自己苦心孤诣淘出黄金又被世人遗忘,而去和时代的烟尘同化、无所作为地度过余生,那么我们什么也不会给后人留下。

在此,且允许我借用多年前对我影响颇深的李兴春的科幻小说《我是龙生第九子》里的一段话:

 

我是个狂生,我有家学渊源,但我漠视天元术(宋元代数学),讨厌那种市侩的徽商算术(明代商业数学),而偏爱墨家及欧氏几何。我选择了使名理与数理相合的“名数学”,将名实之理,仿作象、数的演算。我知道每一名都有阴阳虚实,而同一名可以只在大、小故(即充分、必要条件)同一,在其余的关系中一阴一阳、一虚一实,由此建立起来的一整套造术算法,是奇特而又美妙的,它使得楚人所卖的无不陷之矛和不可陷之盾可以同世而立,自相矛盾不再矛盾。这是我积十数年心血研究的结果,绝对可靠和完善,它好比我最疼爱的并对其未来前程寄予厚望的独子,今天我将看着它夭折。

“数百年后,必有用我此说者!”我在心里悲怆地喊出,回答我的是无边的暗夜和沉默。

 

 

* * *

 

如是,且告诫自己不可为身外之物所左右,无论此生能否学有所成,希望自己可以善始善终。

§1 从计算到程序:图灵完备的神话

 

……数学,与众多理性科学一样,以一门不严谨的计算技巧之名而诞生。在经历了重重危机之后,人们终于认识到使其公理化的必要性,作为终极的自然科学——物理学理论的基础、一门“元科学”的数学,就这样终于成为建立于形式化逻辑之上的一个严密理论体系。比起现世先验知识的总结,它更加体现出人类智慧的结晶;愈是暴露出人类直觉的模糊与不可靠性(如同非欧几何、ZFC公理体系显示出的与人类感官直觉背道而驰的表象),愈是能彰显出逻辑学的价值。

反对逻辑的人们,必将在招致谬误的表象中迷失了自身,把对事物的认知世俗化成了纯粹人类感官化的思维活动,无法形式化地精确表达,更无法被机器——这种比人脑具有更强大计算力的思维辅助工具无二义性地理解。能够被机器可靠地感知、处理、和转述的那部分,便是有资格被称之为“计算科学”的学科,或者,更加具体化的,“计算机科学”。

——某未完成的计算科学史原稿

 

如果说可计算性( computability )是计算科学的基本问题,那么图灵( Alan Turing )的图灵机( Turing machine )无疑是第一个被广为接受的通用计算模型。图灵第一次以抽象机器的方式定义了“计算是什么”,这已经足以让他成为计算机科学的先驱了;而和他同时代的邱奇( Alonzo Church )提出了与图灵机计算能力等价的λ演算( λ-calculus ),则更多地被看作是为计算科学奠定基础的逻辑学家。

通过邱奇-图灵论题( Church–Turing thesis ),他们默许了不可判定的停机问题( halting problem )在计算机器里的存在,从而回答了宇宙间一切计算机“终极的计算能力是什么”这个问题,却也因此让计算的机器成为了一个逻辑上不完备的形式系统。 一台完备的具有最大限度可计算能力的计算机器,与一个完备而自恰的逻辑系统实际上是水火不相容的 ;我们在后面还将反复提到这一点。

而反映到具体的计算机中,作为一切程序的灵魂——算法和数据结构表达载体的程序语言,就经常被这样使用“图灵完备( Turing complete )”的限定词来提及,甚至于被 错误地 用作是衡量一种计算机语言 是否程序语言 的标准:

 

A:FlooP 是一种编程语言吗?

B:是的,因为它是图灵完备的。

A:我可以在 FlooP 语言里实现 X 功能吗?

B:当然可以,它是图灵完备的,理论上你可以用它实现任何功能!

A:BlooP 是一种编程语言吗?

B:不是,因为它不是图灵完备的,它无法实现任意计算。

 

不,一门语言并不需要与图灵机完全等价的计算能力才能使自己成为程序语言;只要你能够用它来编写程序并让计算机完成某种计算,它就已经够资格称得上程序语言了。事实上,如果你深入地去探究关于 可计算性 的定义,就会发现,所谓图灵完备的程序语言比起非完备语言的唯一理论优势,就是可以让你写出“无论人或机器自身,都无法确定停机与否”的程序。而这种程序的存在,本身就意味着哲学上的悖论和实用范畴内的无意义。关于这点,我将留待稍后再作解释。

 

* * *

 

在上面的对话中,你可以把假想的 BlooP 和 FlooP 代入任何一种你所熟悉的现实计算机语言名称,从而得出“ 图灵完备就是程序语言,程序语言一定是图灵完备的 ”这句话的确凿证据——比如你大概已经听说过, Brainfuck 语言 是图灵完备的,它总共包含8条指令,可以看作基于命令式的图灵机模型的一个变种;你可能还知道,仅含1条指令的抽象机也可以是图灵完备的,这就是 OISC ; Unlambda 语言 是图灵完备的,因为它的函数式计算模型源自于λ演算,从而使得它的计算能力等价于图灵机;撇开这些纯粹作为理论玩具的 Turing tarpit 不谈,具有最小核心的“实用”语言大概就数各种 Lisp 方言了:Scheme,PicoLisp,Shen,它们无一不是图灵完备的,尽管它们的设计思想与图灵机模型可说是大异其趣;高兴的话,你还可以在 TIOBE 的列表上找到所有被广泛应用于软件业界的主流语言,它们几乎无一例外地总是图灵完备的——这其中甚至包括了一些不能够称之为是“通用语言”的领域特定语言,比如 Transact-SQL ,它是数据库查询语言标准 SQL 的一个扩展超集。当然,最初设计用作排版目的的 TeX和 PostScript 语言,也早已具有了图灵完备性。另外有一条并不新鲜的 新闻 是,有人用 CSS3 编码出了 Rule 110 元胞自动机,如果你学过自动机理论的话,你可能知道它的计算能力等价于图灵机——于是,现在连 HTML+CSS 也都变成图灵完备的了。当然,我们也都承认 HTML 本身并不是一种编程语言,因为至少我在写这篇文章、你在读这篇文章的时候,都只是在用文字传递思想而不是计算。

既然任何一种计算机语言是否图灵完备已经是板上钉钉的事实,理论上说也都可以无一例外地被证明或否证(如果有人愿意做的话),那么,复述这些又有何意义呢?

从定义上来讲,所谓“图灵完备性”,只是用于衡量某 计算模型 的 可计算能力 ,亦即表达计算的能力 的一个标准。换言之,如果一个计算模型具有和图灵机同等的计算能力,那么它就可以称作是图灵完备的。而在谈论“编程语言”这个名词时,我们关注的更多地是它的 可编程能力 ,亦即 表达计算机程序的能力 。一个程序,描述的可以是计算本身,也可以是对计算的模拟,也可以是对计算的模拟之模拟,如此直至无穷。此外,为了使任何一个程序具有实用价值——因为程序最终要在实际的 计算机 上运行,势必要考虑到与外部世界的交互(即,读取和输出数据),又或者是操控状态(即,改变计算机自身状态、或外部世界状态)的能力;而纯粹的 计算 这件事本身,并不涉及数据的输入输出——你可以在纸上用辗转相除法求两个数的最大公约数,在你施行计算这个过程之前,数据就已经在那里了;改变外部世界的状态,对于计算来说也不是必需的——你可以像普通人般心算辗转相除法,也可以像霍金般在脑内推演黑洞的形成,整个过程并不需要纸和笔的介入;甚至连改变计算机器的自身状态,从结果上讲也不是必然的——你可能会在计算过程中记忆一些中间量,但在计算完成之后,你可以选择忘记所有关于该计算的事情:譬如,我无法记起自己曾经求解过哪些一元二次方程,又在我的脑海中留下过什么;我只是知道,计算这件事情曾经发生过许多次。一句话, 纯粹的计算应该是无副作用的 。

而我们知道,编程这件事情一定是充满副作用的。它既需要 与物理世界交互 ,同时又 受到物理世界的制约 。虽然程序语言专家倾向于拿几个简单的原子类型来编码出关于整个宇宙的理论,但现实的机器并不具备无限的运算能力,因此为了足够的执行效率,我们仍需要机器的 ALU 和 FPU 来帮助我们的程序语言执行整数和浮点数这些原始数据类型的运算;需要机器的内存及缓存来提供足够的空间用于存储计算的中间变量。这些为了达成计算而对现实机器所做的妥协,并不在可计算性理论所讨论的范畴内。至于算法和数据结构的设计,时间和空间的优化,计算复杂度的分析,则是人们为了克服实际计算机效率限制所做出的努力——若是宇宙间存在这样的理想计算机,它拥有无限的时钟频率,那么讨论 P 和 NP 这样的 计算复杂性类 将不再有意义。

为了更加直观地感受“ 可计算能力不等于可编程能力 ”这一点,不妨考察一些简单的代码在不同程序语言中的实现方式。这里要举的一个例子便是出了名的“最不实用”的语言之一:Brainfuck。以我去年做过的Google Code Jam 2013 QR 第一题 为例,检查第一行字符串是否为“ OOOO ”,实际上写出来的代码是这样的:(因为读取数据之后做了一些预处理,所以不看 完整代码 的话恐怕很难理解这里的移位方式)

# Row 1

> [-] >> [-] > [ <+ <<+ >>>- ] <<< [ >>>+ <<<- ]
>> ----- ----- ----- ----- ----- ----- --- #O
<< [-] > [-] > [ <<+ >+ >- ] << [ >>+ <<- ] +
> [ <- > [-] ]
< [ >>>
    > [-] >> [-] > [ <+ <<+ >>>- ] <<< [ >>>+ <<<- ]
    >> ----- ----- ----- ----- ----- ----- --- #O
    << [-] > [-] > [ <<+ >+ >- ] << [ >>+ <<- ] +
    > [ <- > [-] ]
    < [ >>>
        > [-] >> [-] > [ <+ <<+ >>>- ] <<< [ >>>+ <<<- ]
        >> ----- ----- ----- ----- ----- ----- --- #O
        << [-] > [-] > [ <<+ >+ >- ] << [ >>+ <<- ] +
        > [ <- > [-] ]
        < [ >>>
            > [-] >> [-] > [<+<<+>>>-]<<<[>>>+<<<-]>>--------------------------------- #O
<<[-]>[-]>[<<+>+>-]<<[>>+<<-]+>[<->[-]]<[>>><<<<<<<<<<<<<<<<[-]+>>>>>>>>>>>>>>>><<<-]<<<<-]<<<<-]<<<<-]<

注意井号和随后的注释“ O ”——Brainfuck 自动忽略除了其指令集8个命令之外的字符。Brainfuck 中既没有变量,也没有类型的概念:所谓的字符类型、整数类型、布尔类型均不存在,因而所有的程序逻辑都是通过单元格中的数值编码出来的。(p.s. 为了学习用 Brainfuck 写出一些真正有意义的程序——超出 hello world 程度以上的程序,这里的 Brainfuck 算法 是一个很好的起点。)

而在常规的程序语言如 Python 或 Ruby 中,要实现相同的判断,只需把一个值赋给一个变量,然后一个简单的布尔表达式解决问题:

x == "OOOO"

同样具有图灵机等价的计算能力,这就是可编程能力的差距——一个正常人恐怕很难适应用 Brainfuck 来实现计算的方式,哪怕是最司空见惯的数据操作、条件判断和循环,也会使一个有二十年编程经验的程序员不明觉厉;从另一方面来看,哪怕是毫无编程功底的普通人,也可以比较快地学会一些 Python 或 Ruby 的基础,抓一些网页,提取一些文本,或者执行其他类似的非常实用的任务。

写网页爬虫,当然也是一个“计算”的过程:无论是解析一段复杂的 XML 文本,或是简单地判断一个字符串是否“ OOOO ”,从 计算 的角度来看并不存在着天壤之别;如果你能用 Brainfuck 来判断两个字符串的值是否相等,你当然也能够用 Brainfuck 来写一个完整的 XML parser,这只不过是时间和毅力的问题。但关键是,你能够用 Brainfuck 来实现一个爬虫吗?你不能,因为 Brainfuck 缺乏与外部世界的接口,它不能够发送 TCP 请求,也不具备从 socket 读取数据的能力;事实上,它的语言指令集只提供了两个用于和外部接口的命令:“ , ”和“ . ”,分别用于标准输入和标准输出。如果你把这两条输入输出指令从它的指令集中移除,Brainfuck 依然是图灵完备的语言——但那样的话,它将变得毫无用处,因为它失去了对标准输入/输出或文件读取和写入操作的能力,从而一切的输入数据都必须 hard-coded 进程序内,而单元格里的最终计算结果对于任何人来说都不可知;就任何程度的实际编程来说,这是不能接受的。

虽然对我来说,Brainfuck 仍然可以算作是比较实用的图灵完备语言,某种程度上——至少我用实际行动证明了能够用它来解决 Code Jam 的算法问题。那么,我在这里还可以举出许多完美的、“图灵完备无用”的例子。比如,用 CSS3 实现的 Rule 110 自动机 。元胞自动机大概是计算机科学专业的入门课程中所能接触到的最有意思的课题之一,即使是在非 CS 专业中有时也被用于编程入门的教学——我们 Java 编程课有一个小作业就是模拟 Conway 的生命游戏( Game of Life )。有趣的是,生命游戏加之以一些辅助的规则,可以实现 与图灵机等价 的计算能力。所以,如果你做过类似的模拟自动机的练习小程序,那么你也许已经在不知不觉中实现过一个所谓 编程语言的内核 了!(如果说“图灵完备”可以作为程序语言的唯一衡量标准的话)——尽管你可能想都没想过用它来编程。

图灵完备时常会在意想不到的地方被实现,当然还有另外一些 更加极端的例子 :比如 C++ 的模板是 图灵完备 的,比如 Intel x86 MMU 的页错误处理机制是 图灵完备 的,比如 Minecraft 里面的红石电路是 图灵完备 的,比如就连 口袋妖怪黄 里的一个 bug 也是 图灵完备 的。

当然,绝大多数人并不会赞成把 CSS 或生命游戏称作“程序语言”这种说法;图灵完备的计算模型不意味着人们可以(或者说应该)用它们来书写程序。那些非图灵完备的语言(严格说来:语言本身不足以实现和图灵机同等计算能力的语言),用途可能比你想象得要大得多;而许多图灵完备的计算模型,可能很难有什么除了理论研究之外的用处(当然,它们同时也可以是一种模板语言,或另一种语言的预处理器,甚至一款电子游戏——并非作为独立的用于编程的程序语言而被实现出来的图灵完备模型,它们的主要用途是什么,就随你怎么想了)。

至于某些非图灵完备语言的“有用性”,一个反复被人提到的例子便是 SQL,当今所有关系数据库的标准查询语言(这里指的是非图灵完备的标准 SQL,而不是它的图灵完备的扩展)。很多人不喜欢 SQL,我也不例外,当初在 Coursera 上做练习的时候,每次看到一个复杂的查询逻辑,总是心想:给我一个通用语言,我什么都能做!然而我不仅要学习 SQL 语言本身,还要学会怎样把它们正确地集成到一种能够用于实际软件开发的编程语言,比如 Java 当中。当然,如果要真正地在项目里使用 SQL,需要考量的东西就更多了;我自己就偷偷地在见不得人的代码里拼接过 SQL 字符串。以至于后来在学校课程的数据库项目中,我选择了 H2 里的 JaQu ,规避了 SQL 的繁琐和易于出错。

站在今日程序语言的高度上来看,SQL 可能只是一个历史的累赘:C#,F#,Scala,每一种语言都具备比 SQL 更丰富的语法糖、更强大的类型系统和安全性,使得对于这些语言的开发者来说,学会怎样书写 SQL 并不是必需的;用过 LINQ 的话:

var results = from c in SomeCollection
              where c.SomeProperty < 100
              select new {c.SomeProperty, c.OtherProperty};

你当然也可以通过拼接 "select " + something + " from " somewhere 这样的 SQL 语句来实现对等功能的查询,但因为你无法知道用户给的字符串是什么,这里需要更多的代码来验证、以确保它不被注入。当然,我们可以说,SQL 的缺陷并不是它本身的错误——要知道,SQL 是与 C 乃至 ALGOL 同时代的语言。在那个时候,没有人确切地知道,在一个通用语言中进行数据查询应该是怎样的;况且以当时工业界占统治地位的 C 和 ALGOL 系语言的表达能力和类型系统,根本不足以实现可以和现在的 LINQ 相提并论的语言特性;因此,历史的重任就落到了 SQL 的身上。作为可能是计算机历史上最为广泛应用的非图灵完备程序语言,SQL 自有它存在的意义。

综上所述,像 SQL 这样描述性的领域专有语言实际上反映了一个 Rule of least power 的原则,简单来讲就是:选择正确的工具解决正确的问题。描述性质的 SQL 能够用来解决简单的查询问题;而涉及到具体算法级别的工作,包括查询的优化,自然应该以更加通用、更加过程化的语言来解决。这对于许多所谓“图灵完备”的计算机语言来说都是成立的——你可以用 SQL 或者 XSLT 去实现 Dijkstra 算法,但是,这么做除了炫耀自己的编程技巧以外,几乎没有实际意义,因为解决这件事情有更好的工具;同样,有的人拿 TeX 去实现 BASIC 解释器 ,有的人喜欢拿 Brainfuck 或 Whitespace来做算法竞赛;这一切,不过是 Just for fun 罢了。领域专有语言如 SQL,以及作为其替代的 LINQ 和现在的诸多 NoSQL 查询语言,并不因为其非图灵完备而失去其实用价值;而如 Brainfuck 和 Rule 110 这样的“编程语言”,却不会因其图灵完备而增加哪怕一点点实用价值。计算机的发明,终极目的固然是为了解决通用计算的问题;但所谓的“通用计算”问题,乃是因一个个特定领域的问题而存在。追求能够完成一切通用计算的机器这个看似宏伟的目标,实际上早在计算机发明多年前就已经达成了:只要有纸和笔,人人都是一台假想的通用图灵机。

所以我认为,未来语言的发展方向,应当是让通用程序语言更适于描述领域专有的声明式编程,如 LINQ 之于 C#,或 Hiccup 之于 Clojure;而非凭空造出许多分裂的领域特定标准,然后试图让每一个都去完成“图灵完备”的通用计算。

除了 特定领域的语义描述 以外,非图灵完备语言实际上还有一个更本质、但常常容易被人忽略的作用: 表示数据 ,尤其是结构化的数据。例如,数据库存储的内容实际上也是一种计算机语言,尽管它无法直接通过 ASCII 文本的方式呈现出来。XML 是最常见的也是极其繁琐的一种描述结构化文本数据的规范化语言,它脱胎于 SGML。HTML 则是一种主要用于描述网页的标记语言,有的标准委员会制定了 XHTML,试图让 HTML 变得更像 XML、更加规范化;Google 这样的公司却试图让 HTML5 变得不那么繁琐和不那么 XML 化,其导致的结果就是今天要写一个通用的 HTML parser 非常困难。JSON 是一种现在常常被用在 RESTful API 上的短小、丑陋却异常严格的数据标记语言,以致于多一个逗号都被看成是语法错误。YAML 是一种更友好的数据描述语言,但并不是每个人都喜欢缩进,也并不是每个人都能够容忍它那冗长的规范。当然,单纯从语法一致性上讲,最好的语言不过S-表达式( S-expression ):括号的嵌套可以用来表示一个语言的抽象语法树( abstract syntax tree ),而 语法是一切形式语言的共性 。只有将一切语言解析成抽象语法树,相互之间的对比和转换才有意义(而这正是实现一个程序语言解释器、编译器或 Pandoc 这样的标记语言文本转换器的基本之基本)。

 

* * *

 

在这一章里,我们从计算机器的“可计算性”概念出发,浮光掠影地探讨了一些实际程序中的例子,展示了图灵完备不一定是有用的、而图灵非完备不一定是无用的。若是把“数据”视作被计算的对象,那么用于表述计算的程序本身,自然也可看作是一种数据的存在,因而亦可成为计算的对象;如此一来,引入了下面将要讨论的话题:以语言处理语言,或称之为“元计算”的过程。我们将会看到,图灵非完备语言将不再单单是一门用于特定领域或数据描述的语言、必须依存于图灵完备语言方可完成计算;事实上,它的语言表达能力足以独立地表达计算这件事情,以至于它完全可以在通用计算的领域内独当一面。我们还将看到,虽然依据计算机科学中最基本的定义, 非图灵完备的计算模型不能够用来模拟图灵完备的计算模型 ,但 非图灵完备的语言却能够用来描述和实现图灵完备的语言 ,从而印证了: 程序语言不能够等同于单纯的计算模型 ,深刻地揭示了 计算模型和形式语言的不对等性质 ,以及 计算和程序间的不等价性 。而当我们把所谓计算模型的完全“可计算性”从程序语言中抹去,也就使停机问题所带来的逻辑悖论为之消弭,拒图灵完备必然带来的逻辑不自恰于体系之外,为一切数学和计算机程序的形式化验证提供了可能。这正是后面一章所要介绍的“程序即证明”的要领,也是 Coq 和 Agda 这样的程序语言之所以被用作形式化证明辅助工具的理论保障。

 

* * *

 

参考链接

§2 语言的巴别塔:图灵完备的终结?

 

耶和华说:「看哪,他们成为一样的人民,都是一样的言语,如今既作起这事来,以后他们所要作的事,就没有不成就的了。我们下去,在那里变乱他们的口音,使他们的言语,彼此不通。」于是耶和华使他们从那里分散在全地上。他们就停工、不造那城了。

——《创世记》

 

我们知道, 自然语言 是人类间表述思维、交流思想的工具; 程序语言 则是人对机器、或机器间描述算法的工具。而人脑的思维过程也好,计算机程序也好,其核心价值,乃是“对数据执行计算”。语言本不是从虚空中冒出来的神的创造,它本身也是实实在在的数据。我们需要学会第一门母语,然后以它作为工具去学习更多的外语;我们需要为第一个程序语言 bootstrap 起一个编译器,方可为实现更多的程序语言提供可能性。故而作为数据存在的语言本身,亦应该能够作为计算的对象被语言处理。

在此,我想提出几个问题:

1. 从一种语言出发,可以得到另一种语言吗?

这个问题的答案是肯定的:只要语言的表达能力对等,理论上你可以在任意语言之间相互转换。在自然语言的范畴内,这个过程叫做 翻译(translate) 。我们在中译英时若遇到不会的英文词汇,在无法借助字典或网络的情况下,自然会根据意思造出一些类似的表达,加之以简单的解释;或是对中文特有的词汇直接采用汉语拼音。这是在人类翻译的情形下。对于机器翻译,Google Translate 也不会在你从一种语言翻译到另一种语言时报错;有的时候你会看到待翻译的原词,或其他一些牛头不对马嘴的表达。由于它采用非理性的、类似于现代星相学、颅相学或风水的基于统计的学习方法,它不能像合乎逻辑的数学演算一样给出值得我们信赖的结果。对于同一个句子,它的翻译结果甚至可能是不确定的:尝试翻译一段长句,过一段时间再去翻译,你也许会得到完全不同的结果,甚至于有时让你觉得:机器翻译的品质变差了。

在计算机语言的范畴内,我们当然无法如此随意地处理语言间的转换。在自然语言中追求的语义的准确性,到了计算机这里需要绝对的无二义性;你不能告诉计算机:你看,我这里有许多 C 语言程序,还有它们对应的二进制程序,我来训练你如何把 C 语言翻译成机器码吧!

由于我们不能把自然语言的模糊和不确定性引入计算机(比如“3加7乘4”这个句子,它是一个有效的自然语言语句,但其表述的计算却可能存在两种不同的方式),从语法的表达到语义的转换必须被精确地定义,这在计算机中的过程叫做 编译(compile) 。任何一种程序语言的编译器也好,Pandoc 这样的文本转换器也好,做的都是这件事情。当然对于程序语言来说,编译的 目标语言具备不低于源语言的计算表达能力是一个先决条件。大部分时候,源语言和目标语言均是图灵完备的,所以一般的编译器实现者根本不需要考虑到这个问题。但是, 如果你试图直接把一种图灵完备的语言编译到任意一种非图灵完备的语言,总有那么一些计算是你无法表达的 。

举个最容易理解的例子。比如你用 Scheme 实现了一个 GUI 的算术计算器,你可以把某次计算的按键顺序:

3 + 7 =

直接翻译(或者说,解释)成 Scheme 的算术表达式,通过 Scheme 语言本身的算术运算能力来执行此次计算:

(+ 3 7)

或编译到 C 语言的中间码(实际应用中可能是 LLVM 的 IR,CLR 的 IL,JVM 的字节码……),假如你对这个 Scheme 解释器本身的运算执行效率有所顾虑的话:(事实上,现在许多解释型语言的实现都采用了类似的“运行时编译”的优化策略,以绕过直接由解释器完成计算所造成的 overhead)

3 + 7

甚至于编译到本地机器码:

mov eax, 3
add eax, 7

但是,你却不能把任意一段 Scheme、或者任意 C 代码、任意机器码反编译成计算器的按键顺序:

(define ego (lambda () (ego)))

因为计算器本身所能实现的操作是相当有限的,它甚至无法定义循环来执行一次永不停机的计算,故而作为目标语言来说,它远未达到图灵完备的程度。(除非你的计算器是可以用 BASIC 编程的图形计算器!)

有趣的是,从纯计算的角度来看,BASIC 这样的命令式语言中的死循环和支持递归的语言中的无限递归完全是一回事;但从计算机程序的构造来讲,两者却大相径庭:作为一个不终止的进程,死循环可以最大程度地占用你的 CPU 资源,但由于现代操作系统的调度机制保证了其他进程不会被饿死,你仍可以轻松地用 Ctrl-C 信号来终止一个陷入死循环的程序;而递归的工作方式是不停地堆砌假想的函数调用栈,由于现代操作系统总认为机器的内存是无限的,所以一个不恰当实现的递归会去无限 thrash 用于虚拟内存的硬盘,而让你的前台进程变得难以响应(除非你为每个进程可占用的资源设定了上限)。你可以尝试一下这两段 AWK 程序,看到无限循环和无限递归实际作用于机器的差别:

$ awk 'BEGIN{for(;;);}'
$ awk 'function _(){_()}BEGIN{_()}'

比起 C、AWK 和 Perl 这些钻木取火时代的裸机语言,一门设计周全的现代编程语言应该去阻止过多的函数栈调用,防止你写出这种光堆砌内存不干活的垃圾程序;Java、C#、Python 和 Ruby 都是这样设计的(当然,部分原因也是它们语言实现本身囿于虚拟机的限制;Go 语言可没有虚拟机这一说)。无论如何,在任何一种命令式编程语言中,你应当尽可能地避免对递归的滥用。

尽管如此,命令式的循环并不是一切纯计算的终极解决方案,否则今天编程语言的发展也就止步于 ALGOL 的水准了。有一些计算适合于用注重过程的 命令式 语言描述;对需要进行计算的数据加以分类,给每种“东西”标上它所适合的特定过程,这造就了面向对象 的命令式编程。但世间仍然有一些计算是循环的方式所不适合表达的,它们从构造上更接近数学;比如早期人工智能和计算语言学的研究中,对于形式语言的语法分析,若是在过程式编程中,要用栈来模拟实现层层嵌套的调用关系,十分地繁琐和不自然。因此,人们才发明了 函数式 编程,试图以递归的方式来完全取代循环达成计算的目的。

前面已经说过,比起循环,递归在命令式语言中通常无可救药地低效和不实用。故而几乎所有的函数式语言都实现了对 尾递归调用 ( tail call )的优化,以期让递归的执行不必通过堆砌栈,而以类似循环的方式来进行。例如前面举的那个 Scheme 无穷递归的例程,它的实际执行效果类似于 AWK 的死循环(仅占用 CPU),而不会像用 AWK 写出一个无穷递归那样去不停地吞噬内存!

递归函数所能实现的计算能力与循环是等价的。因此,理论上讲,只要你总是用尾递归的方式在函数式语言中书写程序,而编译器可以通过优化将对调用栈的依赖消除为普通的指令循环,函数式编程的执行效率可以无限接近于命令式编程(诸如 OCaml 和 MLton 这样的全局优化编译器生成的机器码效率甚至不输给 GCC)。现实来讲,比起命令式语言,由于在函数式语言的实现中你不得不考虑到 垃圾收集 的问题,它们的效率几乎总是比 C 略低一些。标准是否要求尾递归优化,也可以成为判断一个语言是否为函数式语言的重要特征;比如 Ruby 和 Rust 作为支持函数式编程的命令式语言,而 Lisp 和 ML 作为支持命令式编程的函数式语言,其决定性因素就是前两者不针对尾递归做特殊优化,导致实际计算中不可能完全摈弃循环的能力,自然也就不能够用于纯粹的“函数式”编程,只可算是支持函数式的多范式语言;而对于大部分函数式语言(特别是比起 Common Lisp 和 Clojure 更加“纯洁”的一种 Lisp:Scheme)来说,程序中没有循环结构是一件理所当然的事情。

当然,所谓命令式和函数式、循环和递归的高效互转,不过是具体语言实现上的问题。因为循环和递归所表达的事情从效果上说是等价的,只需要记住 目标语言的计算能力不可低于源语言 这条原则,我们便可以为所欲为。对于程序语言的互编译来说,另一个重要的限制就是程序 仅涉及纯计算 。你可以把一个 C++ 的游戏引擎编译到任意桌面机的 x86,编译到游戏机的 ARM 体系,甚至编译到 JavaScript 嵌入在网页里运行,但是你却没法把它编译到 Brainfuck 或理想的图灵机上,因为它们虽从计算上来说同样图灵完备,但是后两者却没有对实际图形驱动的接口;换言之,我们所讨论的“语言互转换性”并不涵盖计算以外的副作用。

 

* * *

 

2. 语言可以作为另一种语言的载体吗?

毫无疑问,这个世界上各个领域的语言是相互依存的。纯粹用于数据描述的语言并不具备表示计算的语义,但它所表示的数据可以有包含具体计算的语义:比如,XSLT 这样一种图灵完备的语言就是以 XML 这样的数据描述语言为载体的;无独有偶,各种 Lisp 方言皆以S-表达式的形式为载体,贯彻了代码皆数据的哲学。

一段数据所含何种语言,是因探讨的层次不同而大异其趣的问题。Clojure 开发者用 Hiccup 写一个页面,看到了表达 HTML 模板的特定领域语言;非 Clojure 的 Lisp 程序员会说,“这是用来表示数据的S-表达式语言”;不懂 Lisp 的程序员看到它,会说“这么多括号,反正肯定是种 Lisp!”;而对于不擅编程的数学家或哲学家来说,有一个笼统的名词可以概括他们所看到的,那就叫做“符号语言”。正因为如此的多样化,用一个层面的语言去描述另一个层面的语言,乃至作为其载体,变成了一件寻常可见的事情。

自然语言和数学的符号语言已经为人所熟知,而在这里,我们当然更关心另一种符号语言——程序语言的存在。最初,为了描述程序语言,人们采用自然语言来作为程序语言规范的载体,由此产生了一些冗长且复杂如 C++ 的规范 (当然,C++ 语言本身的过度设计也是导致这个问题的原因之一)。此外,由于自然语言充满着模糊、二义性和不确定性,这些性质与定义程序语言本身所要求的精确性是格格不入的;那么,为什么不用同样形式化的符号语言来描述程序语言呢?由此而诞生了著名的 Standard ML 规范 ,首次对一种计算机程序语言的形式化描述。我认为,对于任何一门计算机语言(不单单是程序语言)的设计者来说,Standard ML 其实是一个可取的模范,而试图拿自然语言来“规范”计算机语言的设计则十分不可取(这里还有一个 很坏的榜样 )。

说完如何描述程序语言,接下来要说的是程序语言可以用来描述什么。显然,它可以被用于描述数学和逻辑的符号语言——这正是程序语言之所以被发明出来的原因:为了 实现计算 ,作为 计算的载体 。图灵机也好,邱奇的λ演算也好,从作为计算模型被提出之日起就以符号化的数学语言的形式存在。早在19世纪末,弗雷格( Gottlob Frege )在现代逻辑学的开山作《概念文字( Begriffsschrift )》中首次形式化了数理逻辑的符号,为逻辑引入了一门符号化的语言;而在20世纪后半叶,计算与逻辑证明之间的联系通过程序语言中的理论被联系起来之后,程序语言,作为一种符号语言的存在,与其他符号语言(如逻辑语言和数学语言)之间的联系,变得愈加根深蒂固了。有一些用于研究的学术型编程语言如 FP 和 Agda,形式上已经非常接近于数学和逻辑学符号;当然还有一些语言虽高度符号化,如 APL,但却不太接近人类已有的数学语言,反而类似于火星文字。

程序语言既然是为了用于描述符号语言的目的而创造,当然也可以用来处理自然语言。如果你形式化自然语言的构造,那么作为语法生成树的数据结构可以被任何一种程序语言所表示:无论是具有严密而强大的类型系统的 Haskell 或 ML,还是一切皆数据的动态语言 Lisp;甚至亦可通过最简单的无类型λ演算编码出来,如同其他任何形式的符号语言一样。不过,现代自然语言处理(NLP)研究的大潮流已经放弃了理性主义的、基于逻辑和形式化的努力,转向了大数据统计和学习的歧路。虽然近年来已有一些将程序语言中的直觉类型论用于自然语言形式化的尝试,但是自蒙塔古(Montague)以来,该领域方向的努力并没有取得应有的关注度,实际中发挥的作用也远不能和机器学习等实用化方法相提并论。关于这个话题的讨论过于艰深,就此打住。

 

* * *

 

3. 程序语言可以实现另一种程序语言吗?

说了这么多,终于就要进入本文的正题了。对于前两个问题,我的回答是肯定的:语言可以翻译成任意一种语言 ,只要目标语言不存在表达能力上的障碍;对于图灵完备的程序语言来说,则可以在纯计算的范畴内实现任意互相编译。 语言可以作为任意一种语言的载体 ,虽然绝大多数时候我们并没有形式化地、正确地把这件事情做好(除了自然语言的 Lojban 和程序语言的 Standard ML 以外)。

乍一看,这个问题的答案似乎是显而易见的:假想我们设计了一种本来不存在的通用语言或领域特定语言,称之为 S;我们想要把它书写的程序编译成已经可以被机器所执行的目标语言,称之为 M;而我们用于实现“编译”——这个计算过程的程序语言,称之为 I。现在,既然我们已经知道 S 语言一定可以被翻译成 M 语言(根据第一个问题的回答:因为目标语言 M 等效于图灵完备的计算模型,从而有足够的能力表达任何计算,无论源语言 S 是否图灵完备),而我们又知道 S 和 M 的语义一定可以在被用于实现编译器的 I 语言中精确地描述和定义(由第二个问题的回答可知),那么,用 I 来实现这样一个从 S 到 M 的编译器,难道不是件手到擒来的事情么?

现在我要问,用于实现新程序语言 S 的 I 语言,它必须是图灵完备的么?换言之,能否用图灵非完备的语言,去实现一个图灵完备的语言 ?

 

源 S  --> 实现 I --> 目标 M
T型图

 

诸位聪明的读者可能一下子就想到了:源码到源码级别的编译不是最简单的么?你可以把 Whitespace 语言 (注:此语言的创造者是 Edwin Brady,此君同时还设计了 Idris 语言 )里的空白字符做个简单有效的替换,从而得到 草泥马语 。虽然我在 这里给出的从草泥马语到 Whitespace 的 source-to-source 编译器 用的是 Haskell,但其实你完全可以用正则表达式来实现整个简单而直接的替换过程。如果你觉得这种 Turing tarpit 不够实用的话,我们还有 万能的汇编语言 JavaScript;若为 JavaScript 设计一套类似 Python 严格基于 layout 的代码缩进格式,其余语义不变,你也可以通过正则或正则的扩展语义来实现从基于缩进的 JavaScript 变种到正常 JavaScript 的编译过程。而我们知道,正则语言并不是图灵完备的;故而 图灵非完备的语言可以用来实现图灵完备语言的编译器 。

这当然是一种极端投机取巧的做法。尽管我们仍可称这种源码到源码的翻译程序为编译器,但由于它们并不对实际语法做分析,或许称之为预处理器更加合适。更何况在这种情况下,被实现的 S 语言并不比已有的 M 语言增加任何新的语义。

鉴于此,更富于挑战性的课题被提出了:你能否用图灵非完备的语言 I,完整地实现新的图灵完备语言 S 的语义呢?编译的方式是被禁止的,因此必须 用图灵非完备的语言去实现一个图灵完备语言的解释器 。

对于稍拥有一点计算理论常识的人来说,这件事情可能是明显荒谬的:一个计算模型如果能够完整地模拟图灵机或其他任何一种图灵完备的计算模型,那么它就具有和图灵机同等的计算能力,意即图灵等价(Turing equivalence),这表明它本身必然也是图灵完备的。从而这样的解释器 似乎 不可能存在。然而,对这个问题的探讨可能远比你想象的要复杂得多,它的答案也有可能完全出乎你的意料;我想先暂时保留对它的解释,把细节留到后面。

 

* * *

 

好的,且撇开 解释器 的事情不谈,假设你设计了一种类型系统足够强大外加支持十八种编程范式的通用程序语言 S,而且已经用 I 语言实现了这样的一个 编译器 ,现在我要问另一个问题: 你确定你的编译器真的能 完成 对 S 语言源码的编译么?

一个连“对于符合规范的任何正确程序,能否总是完成编译任务”都不确定的编译器,你大概会觉得这很荒谬。这甚至无关实现上的问题,它其实根本就是一道理论上的坎。“本公司开发了该软件用于执行某项功能,但由于停机问题的不可判定性,我们无法保证它总是完成该功能……”你可以试着在用户文档里这样写,把那些没有计算机科学学位的项目经理搞崩溃。

事实上,这个世界上至少存在着两种程序语言你永远写不出理论上“对于正确的程序绝对完成编译”的编译器:一种是类型系统足够强大却不具备 totality 性质的语言;还有一种叫做 C++。

断章取义只会让人曲解,所以这里是对两者更详细一点的解释:“无法绝对正确地完成编译”意味着它们的某些语言特性造成了 编译期间 不必要的图灵完备性,这体现在某些语言中,是编译前的类型检查(type checking)或类型推断(type inference);体现在 C++ 中,是模板(templates)的初始化。而图灵完备意味着编译器无法可靠地判断一次编译能否完成(如果它能够确切地判断出一次编译不能停机,那么显然它就会放弃编译,并认为这是一个不正确的程序)。而不可判定性带来了编译器行为的不确定性。大多数情况下,编译器并不会真的不终止,它只是简单地放弃对于某些正确程序的编译;或者因为资源溢出而报错。

考察一个像 Idris 这样具有依赖类型系统的静类型语言。所谓依赖类型即是依赖于值的类型系统,这意味着在编译期间 type checker 执行类型检查时,可能需要对程序中具体的函数进行求值方可完成它的类型检查;而 Idris 语言本身并不如其他依赖类型语言一样强制要求所有函数的 totality,这就意味着某个为了类型检查而在编译时求值的函数一旦不能终止,整个编译过程也不能终止。为了解决这个问题,Idris 编译器对所有这样(需要在类型检查中确保停机)的函数首先执行 termination check,如果它无法判断其终止性,那么它会报出一个错误。我们知道,没有任何 termination check 是完美的,这就意味着总有这么一些函数,虽然实际上一定可以终止,但因为 termination checker 不具备判断它的能力,故而编译器将拒绝编译这些无法通过 termination check 的程序,即使它们可能是正确的。由此,Idris 的类型检查并不是图灵完备的:一旦它不能判断可终止性,它就认为是一个错误。

而对于许多具备类型推断的静类型语言来说,编译期间类型级别(type level)的图灵完备性大概是更加广为人知的一件事情。比如我们知道,Haskell 和 Scala 的类型检查都能够实现图灵完备(完全在 Haskell 的类型级别上实现的 λ演算 和 SK 组合子演算 ;无独有偶,Scala 编译期间 的 SKI 演算实现 )。在一次不停机的类型检查中,编译器要么不终止;要么发生溢出。这意味着它们可能无法完成最终的编译。

C++ 则是另外一个世界。它的模板元编程特性无限制地剥削了编译期间的预处理能力:(这个例子来自于 Stack Overflow)

template <int I>
struct Infinite {
    enum { value = (I & 0x1)? Infinite<I+1>::value : Infinite<I-1>::value };
};
int main() {
    int i = Infinite<1>::value;
}

在我的 GCC 4.7 上,它提示一个模板实例化深度的溢出(默认允许的最大值为900次)。不得不说这是一个很有 C++ 程序员风格的处理方式:简单地执行,粗暴地退出……

error: template instantiation depth exceeds maximum of 900
(use -ftemplate-depth= to increase the maximum)
instantiating ‘struct Infinite<901>’

依据基本的算术公理,你可以构造出一个模板实例化深度901但是无法被编译的正确程序。等你把编译器预处理的最大深度调整到901,你可以构造出一个实例化深度902但是无法被编译的正确程序。如此直至无穷。你永远也不能构造出这样的编译器,它能够完成对宇宙间一切正确程序的编译。因为 C++ 模板的图灵完备性带来了停机问题的不可判定,理论上编译器无从得知你的程序究竟是要实例化901次,还是压根就不打算停机;虽然对于人类程序员而言,这是一件很显然的事情。当然,C++ 是 实用的 ,“实用”意味着即使你知道它的模板是图灵完备的,你也不太可能去无限制地剥削它的元编程能力。

强大的类型系统演算也好,被滥用的模板元编程也好,你可以称其中一个为 silver bullet,另一个为 bullshit;你也可以认为两个都是不必要的 over-engineering。这完全取决于一个程序员的个人品味:是倾向于用更加严密一致的数学理论来抽象程序逻辑,还是更热衷于软件开发中的各种模式技巧。然而,不能否认的是它们都是现实存在的编程语言: 一个走在程序语言类型理论研究的最前端;一个则象征着现代软件工程日趋复杂化和过度设计的巅峰。

(你可能听说过 Haskell 的类型系统与 C++ 模板存在着相似性 这种说法。既然两者同样都实现了图灵等价的计算模型,那么这件事也就不难理解了。)

任何编译器的本质包含了两部分实现:一、从源代码 S 生成目标码 M 的算法,因为编译器的实现者便是写算法的人(假定编译器用语言 I 实现),因此我们可以从理论上证明这个用 I 实现“编译”的计算过程一定停机;二、在进入具体的编译之前,对源代码执行预处理的过程,这包括了模板的初始化,静态类型检查等过程。对于大部分语言(不包括我们前面提到过的几种)的编译器来说,这个预处理过程同样是确定停机的;然而如果在设计某个 S 语言的特性时,把模板元编程做成一个图灵完备的独立计算模型(姑且称之为 T),抑或让类型系统达到图灵完备,这就意味着把编译后目标语言 M 在运行时才会存在的停机之不可判定性,以 T 语言(诸如 C++ 模板)或 S 语言(诸如 Haskell 或 Scala)类型系统的形式转嫁到了编译器的身上。而我们知道,T 或 S 所描述的算法完全是由用户所写的程序决定的,而非用 I 语言实现 S 语言编译器的编译器实现者所能控制,这就带来了编译过程的不可判定性或不正确性:因为你不可能在编译器里实现这样一个通用的算法,来判断用户的 T 模板定义、或对 S 执行的静类型检查会否停机。

好,现在结论有了:你要么在编译期间允许由用户定义的、图灵完备的 S 或 T 代码存在,然后承认你的编译器不够完美;要么规避掉编译期间 S 和 T 的图灵完备性,比如这个世界上存在的大多数程序语言,它们并没有一个图灵完备的模板语言 T 在编译时去预处理,它们也没有一个足够强大的类型系统以至于让类型检查停机这件事情变得无法确定。

在粗浅地考察了编译器程序的可停机性与实用性之间的权衡之后,我们对于实现一种语言可能性的探讨也就此告一段落:你的语言不必具有编译期间的图灵完备性质;但若是要设计一种强大如拥有 C++ 的模板特性或 Haskell、Scala 的静类型系统、而使编译过程本身达到图灵完备的语言,基于实际的考量,也可以写出一个虽非完美、但绝对 实用 的编译器。

接下来要说的,将会是“设计怎样的语言”。没有人确切地知道世界上曾经存在过多少种语言;彼得·兰丁( Peter Landin )在发表他那篇著名的《The Next 700 Programming Languages》时,估计过当时世界上已经出现过700种程序语言;那是1966年。如果说今天被创造出来的通用编程语言有100种的话,其中大概有不超过1种是刻意被设计成非图灵完备的。而我总是觉得,这个世界上,所谓图灵完备的程序语言早已泛滥成灾,其中95%以上注定不能创造任何实用或研究价值,乃至于变成烂尾项目;这个世界需要更多一些“不那么图灵完备”的通用编程语言。

 

* * *

 

设计一个图灵非完备的编程语言 。这件事情在外行人看来一点也不酷。就好比如果有人声称自己要做个“解决宇宙间一切计算问题的终极语言”,不管做得出来做不出来,总会有人把他奉若神明;也许很少有人意识到,只要有纸和笔,人人都是一台能解决任何计算问题的通用图灵机。自然,程序语言的可编程能力是纸带所无所比拟的:对于计算的规范化表述,高阶的数据抽象能力,丰富的类型系统,高度模块化及可重用性,等等。但,如果你不知道在纸上计算矩阵乘法的算法,而你的程序语言的库(注意——库不被看作是语言本身的一部分,即使是标准库)未曾实现这个功能,那么,再图灵完备、抽象能力再强的语言,也终究无法帮助你实现一个最简单的矩阵乘法。由此看来,设计一门语言并不能解决任何实际的问题;真正解决具体问题的,只能是设计算法的人。程序语言只是工具: 表达思想的工具 , 表述计算过程的载体 ,无他。没有作为思想的算法灵魂,语言只是徒有其表的空壳。这就是为什么说 程序语言的作用,常常被过分高估了 的理由。

当然,如果有谁一上来就把“我想做个程序语言,它不是图灵完备的,基本上不可能解决所有的计算问题……”作为广告词,恐怕看见的人也只会哑然失笑。对于稍通一点计算理论的人来说,计算机具有和图灵机理论等价的计算能力是常识中的常识;而给某种计算机语言加上“图灵非完备”的限定,似乎等于是放弃了表达一切通用计算的能力,转而成为一个领域专有语言。

我要说的是,作为理论模型计算能力的图灵完备性质,与作为一种计算机语言的实际可编程能力之间是没有太多直接联系的。即使是理论上可以被证明是图灵非完备的计算机语言,仍然可以做到很多事情,从而足够资格被称作通用计算语言:

1、它可以用来实现一切可形式化验证的正确算法,但无法实现你自己都不知道是否停机的算法;

2、它可以用来实现永不终止但持续执行有效计算的软件,如一个操作系统、一个网站;但它无法实现空的死循环、爆栈而不产出有效数据输出的递归,或其他一切任何你认为“无用却不停机”的计算。

换言之,图灵机(或任何一种图灵完备语言)能实现而它所不能实现的计算,从实际的角度来看,基本上可说是没有意义的。譬如:你试图写一个“寻找正整数集中最大的素数”的算法,那么你在运行这个程序之前应该先从逻辑上理清这么做的意义:一旦程序找到了一个极大的素数,因为无法确定它是否为最大,它必须继续找下去。因此,不管这个最大的素数是否存在,程序为了输出正确的结果,将永远不会停机,你也就永远无法知道确切的答案。这样的计算除了让机器发热之外,意义何在?又譬如,你的程序若是要“寻找尽可能大的素数”,那么它总得时不时输出点什么,来确保它于你是有用的。一种图灵非完备的程序语言应当拒绝执行前一种 不停机不产出 的无效计算,从而使得自身失去和图灵机等效的计算能力;但它应当允许后一种 不停机持续产出的有效计算,从而确保用它写出的程序永远是“有用的”。这些,构成了一种语言虽非图灵完备、其“有用”程度却丝毫不输于任何图灵完备语言的关键要素。

这样的图灵非完备程序语言实际存在着,但它们大多是因程序语言类型系统的研究用途而生,作为软件形式化验证的辅助工具而存在,并未有实际作为通用语言直接用于软件开发的尝试:如 Coq、Epigram 和 Agda。由于标准库中缺乏相应的支持,前两者甚至难以完成最简单的标准输入输出;而因为缺乏原生数据类型的实现,实际编程中司空见惯的整数、浮点数和字符串运算,到了这些语言中仍然极端繁琐。

作为证明辅助工具的它们,就纯函数式编程而言,其用途相当有限,不会超出为了对应 Curry–Howard 同构( Curry–Howard correspondence )所需的范围。从它们的设计目的上很容易知道,这些语言本身不可能是图灵完备的;因为图灵完备意味着你可以写出没有终止的递归,这就破坏了类型所对应于逻辑的自恰和完备性,正如要回答“全能的上帝能造出一块他自己都搬不动的石头吗?”这个问题般,让机器的类型检查陷入无尽的逻辑循环推演;而我们知道, 一次永不停机的证明是一次无效的证明 ,否则我们也就能够轻易地写出一个不停机的程序去验证哥德巴赫猜想对于一切数的情形都成立了。至于“纯函数计算是没有副作用的”,这一点更是老生常谈了,一个东西一旦被定义,那么它的值绝不能被改变;因为对于依赖于值的类型而言,一个值若可以在运行时改变,那么类型系统(编译时的类型检查即为证明)将失去它的意义,故而依赖类型的语言几乎总是纯函数式的 ,从而使得它们无法直接完成对 状态量 的操作。

看起来,这种语言似乎没有什么实用价值。然而一旦接受了“计算过程本身也可以是计算处理的对象,对于计算之计算”这种设定,将对计算的描述本身借助于 Monad 归一化到它们的类型系统中,那么正如 Haskell 能够容纳副作用(状态、输入输出)于函数式计算的体系中一般,它本身虽为纯函数范式的数学语言,却可以用来实现非纯的外部物理过程;更进一步,一旦通过 codata 来描述无限的数据结构,它本身虽非图灵完备的语言,却可以用来模拟任何不停机的计算。

现在,既然我们已经提到了这种图灵非完备语言的存在性与实用性,你可能不禁想起了我们之前提出的那个问题: 能否用这种图灵非完备的语言,去实现一个图灵完备语言的解释器呢?

 

* * *

 

这是一个非常巧妙的问题:既然我已经鼓吹图灵非完备语言可以和图灵完备语言一样实用,足够完成任何有实际价值的计算,那么我必须用它写出一个图灵完备语言的解释器来证明这一点,因为“图灵完备语言的解释器”显然也属于所有“有实际价值”的程序范畴内;而一旦当我这样做了,似乎就等于是用一个计算模型模拟了一个图灵机,从而证明我原来的计算模型不可能是图灵非完备的。

这看似自相矛盾而又无比自恰,看似痴人说梦却又实在可及,其招致误解的根源在于人们总是习惯性地把“程序语言”和“计算模型”等同起来:Brainfuck、C、Pascal 是命令式的语言,它们等价于图灵机;Unlambda、Scheme、Haskell 是函数式的语言,它们等价于λ演算。故而这些语言都是图灵完备的,因为它们可以实现与图灵机等效的计算过程。这种说法看似没有什么问题,程序语言本来就是为了描述计算而生的实用工具,而抽象的计算模型才是真正描述计算的方法学。如果说图灵完备可以成为衡量计算模型计算能力的尺度,那么体现这些具体计算模型的程序语言,自然也就存在着相应计算能力的说法。我还可以在这里举出很多例子:正则表达式不是图灵完备的,所以像嵌入在通用程序语言里的 PCRE 库这类正则实现不是图灵完备的;谓词逻辑是图灵完备的,所以嵌入在通用程序语言里的 miniKanren 是图灵完备的;递归函数是图灵完备的,所以 C++ 里的模板是图灵完备的;项重写系统是图灵完备的,所以 Maude 是图灵完备的;马尔可夫算法是图灵完备的,所以 Refal 是图灵完备的;如此种种,数不胜数。

但是,今天的一些程序语言,已然超越了单一计算模型的范畴;单一的计算过程,已经不再适于描述所有程序语言的工作方式——不,我甚至不是在讨论副作用这件事情。就程序本身而言,有些程序语言能做的事情,图灵机是做不到的。这其中的一个典型例子,就是元编程。

给图灵机一连串指令,它能够执行任何计算,也可以模拟纯数学的λ演算;给λ演算一系列定义,它能够编码出世间万物,也可以模拟出基于顺序执行的机器。而它们都无法做到的,是改变自身被分配的指令或定义,也就是它们所注定的计算本身。然而,对于某些具有元编程能力的计算机语言来说,你可以在程序里嵌入一套规则,让这套规则具有操纵“计算”本身的能力。

以许多人都熟悉的 C++ 为例:你可以把 C++ 里的任意循环、递归函数的能力拿掉,从而成功地把它阉割成为一个必定停机的图灵非完备语言(事实上你不必通过强制所有程序停机来使得一个语言图灵非完备;但这么做可以简单粗暴地达到我们的目的)。在这种情形下,你将不能用 C++ 自身的语言来模拟一个图灵机,因为它已经不具备通用计算的能力;但是,没有什么能阻止你用 C++ 模板来实现这一切。换言之,C++ 的语言规范至少实现了两个相互独立的计算模型:C++ 自身的语言,和其嵌入的模板语言。由此来看,仅仅用“图灵完备”来形容 C++ 的计算能力是不够的,因为 C++ 作为一种程序语言,并不是 一个 图灵完备的计算模型,而体现着 两个 相互独立的图灵完备计算模型。

既然理解了“ 程序语言不必等同于计算模型 ”这件事情,前面的矛盾也就迎刃而解了:你实际上 可以在图灵非完备的程序语言里内嵌一个图灵完备的计算模型 ;因为这个图灵完备的计算模型可以用来实现任何计算,因此,也就间接为使用该(图灵非完备)语言来实现一个图灵完备语言的解释器提供了无限的可能性。

这样容纳了另一个计算过程于自身内的程序语言,其计算潜力是无法用单一模型(如图灵机)的计算能力来完整表达的,正如你无法把 C++ (连同模板在内)的计算方式转化成单一的图灵机指令序列一样。C++ 模板元编程在这里也许并不是一个很好的例子;实际上,你可以把任何图灵完备的计算过程容纳到一门语言的类型系统里,从而让一门程序语言自身的计算模型虽非图灵完备,却可以借纯函数式的类型系统之手完整地描述图灵完备的计算模型。

图灵非完备语言的“有用性”,将会随着今后对于程序语言和类型论的研究愈加深刻地体现出来。

 

* * *

 

是否具有和图灵机同等的计算能力,这点适用于描述计算模型,却将不再是用于衡量程序语言能力的金科玉律。正如在此前冗长的陈述里所提到的:图灵完备的语言可能在实际编程中毫无用武之地;图灵非完备的语言,亦可通过广纳百川的类型系统吸纳图灵完备的计算模型于其内,从而间接地达到和图灵机同等的能力;从此,程序语言将不再是单一的计算模型,而可作为计算模型之计算模型存在。

看到这里,你可能不禁要问两个问题:一、为何一定要在图灵非完备的语言里达成图灵完备的计算能力呢?既然我们可以轻易地去设计和实现一个图灵完备的程序语言,它绝对实用,而且绝对能实现所有的计算,做这种事情不是自讨苦吃么?二、这一切,与所谓的类型系统又有何关系呢?难道程序语言中的类型不就是数归数,符归符,值和类型相匹配么,它又何以如此神奇,甚至于能够用来表述“图灵完备的计算”呢?

言归正传。我们对程序语言所创造的美妙新世界的探索,从这里才刚刚开始。

 

* * *

 

参考链接

§3 程序即证明

 

凡是狐猴,都会死。

Mort 是狐猴。

所以,Mort 会死。

——某有效但不一定可靠的一阶逻辑推理

 

在这一小节里,我将试图厘清当下程序语言前沿的发展脉络,尽可能用非学术化的语言阐释几个基本的问题:静类型的作用,纯函数式编程的真正意义所在,Currying 的重要性,计算机程序与逻辑证明之间千丝万缕的联系,什么是依赖类型和它的表达能力,Totality 与所谓完全函数式编程( total functional programming )的概念,乃至程序语言理论与数学基础之间的紧密关联;于此,大概可以揭示当前程序语言理论、尤其是类型论及程序形式化验证的愿景;以及澄清学术界一些最纯粹的理论研究,是怎样被普罗大众和一些半吊子“专家”曲解的。

从历史发展的规律来看,程序语言理论研究的目的并非是要设计一种能解决一切问题的“终极语言”,也非制造什么软件工程的“银弹”;若是抱着类似这样浮躁的功利心而来,必将一无所得,失望而归。它的真正意义在于追随数学公理化的道路,借助20世纪以来逻辑学为了解决数学基础中的重重危机、而形式化一切理性科学的成果,试图以数学为圭臬,探索编程的无穷可能性,直至通往构造可信软件的道路;若非如此,今日的编程将和昔日古中国和古埃及的算术代数一样,仅仅是一门缺乏严密逻辑、不成体系的技艺,虽能蓬勃一时,却无从传诸后世,最终被建立在逻辑形式化基础上的西洋数学所取代。正如康托尔的朴素集合论被 ZFC 所发展和延伸一样,一门程序语言理论会成为另一门自恰性更强的理论的基础,也势必被它所淘汰;终极的理论是不存在的,一个终极的程序语言设计也是不存在的。好的程序语言从来都不是无中生有、一夜成名,它的优秀设计可能来源于一篇不起眼的 paper,或若干年前的玩具语言里的特性。我们所能做的,就是放眼程序语言的大图景,一步步完善整个理论体系的拼图,填补各种“可能性”,为将来构建更自恰的理论和更可信的软件或铺设基石、或添砖加瓦罢了。如果你不认同这些话,那么接下来的程序语言之旅恐怕你将空手而返。

本章接下来的内容假设读者已经掌握一定程度的编程基础(能有耐心把这篇文章看到这里的,这点大概不是问题),但对于函数式编程未必一定了解,对于程序语言类型系统的概念并不清晰,也未曾接触过依赖类型及计算机辅助证明的领域。此外,一些数理逻辑的基础也是非常必要的,后面将不会赘述任何关于命题和谓词逻辑的常识性概念。我将尽可能地避开所有关于λ演算、类型论和逻辑的过于符号化和数学化的内容,因为这主要是一篇 写给程序员看的数学证明启蒙小品 ,而不是一部严肃的程序语言或逻辑入门教程。

除特别说明外,所有的程序示例均采用 Idris ;由于它设计上与 Haskell 和 ML 的极大相似性(尤其是语法与 Haskell 的接近程度),了解这些静类型函数式语言的读者很容易将最初的几个例子改写成他们所熟悉的语言。

 

* * *

 

首先,为了理解依赖类型的概念,我们先来回顾一下程序语言中所谓的“ 类型 (type)”。我们知道,函数式编程的理论基础是美国逻辑学家邱奇( Alonzo Church )在可计算性的研究中提出的 无类型λ演算 (untyped lambda calculus),由英国计算机科学家兰丁( Peter Landin )首次介入程序语言的设计;这是一种与图灵机等效的计算模型。它的“ 无类型 (untyped)”意味着真正的无数据类型:尽管理论上讲你可以用Church encoding 编码出实际编程所需要的任何数据结构,但是,λ演算本身毫不关心具体的数据和它们的类型是什么;类似地,在模拟图灵机设计的 Brainfuck 语言里,你可以用逐个单元格的组合来编码出任何形式的数据,但是其正确与否、何种操作能够在何种数据上执行,全靠程序员自己把握。

现实的电子计算机工作方式不像λ演算,而比较接近图灵机的模型(所以尽管邱奇和图灵解决的是同一个问题,但一般认为图灵才是现代计算机科学的开山祖);但机器的语言比起 Brainfuck 这种来说要稍稍好一些。在汇编里,你可以写出无符号整型、有符号整型、浮点数,8位的、16位的、32位的……然后通过机器内置的指令来完成算术或浮点数的运算操作,用不着什么数据都得自己编码。

对于今天的许多程序员来说,完全不存在数据类型的语言是难以想象的;但类型系统极弱的语言至今仍然大有市场,两种广为人知的 弱类型 (weakly typed)语言分别是 C 和 JavaScript。

一个东西的强与弱当然是相对的,程序语言的强类型弱类型之分也可以有不同定义。考虑到指针在 C 语言中用途之广,其数据操作的随意程度对安全性破坏之巨大,带来的坑 众多 ,称之为“弱类型语言”不足为过。

JavaScript 的类型之“弱”则主要以另一种方式体现:隐含类型转换的不可预知性。

1 + "2"

当程序复杂度足够高时,这种被默许的操作同样会带来难以发现的 bug。可以说 JavaScript 从 Scheme 那里学到了一些函数式编程的精髓,却没有从 ML 那里学来任何关于类型系统的经验教训。当然,对于这样一个基于 prototype 面向 DOM 的语言来讲,可能类型系统这种保障软件正确性的奢侈品对于它的设计初衷而言(嵌入在 Netscape 自家的浏览器里跑一些网页小脚本)真的不重要。谁知道呢。

虽然到今天为止,这两种语言的拥护者还是会给它们那些糟糕的特性诸如弱类型洗地,但不容否认的是,这个世界上每一种新兴并开始流行起来的语言,都已经摈弃了很多被认为是弱类型的、允许不同类型数据之间暧昧不清的设计了。

如今绝大多数得到广泛应用的语言都是 强类型 (strongly typed)的。强类型意味着不同类型被严格地定义和分离,彼此间的互操作受到限制。隐式类型转换是有害的,对于不熟悉语言特性的程序员来说,有时很难预知某个操作将会产生何种结果,造成软件难以维护;你也不能再像 C 一样把一个指针传过来传过去,而忽视内存的边界和数据的确切语义;混淆类型同样是有害的,把一个用户生成的任意字符串不经检查地插到 SQL 查询语句里是一个过于教科书式的软件错误。由于这些在软件业中几乎达成了共识,弱类型的设计注定将逐渐被强类型的设计所淘汰。

动态类型和静态类型是另外两个相对的概念。动态类型的概念常常被人错误地和弱类型相混淆;但 动态类型 (dynamically typed)指的是一个变量值的类型可以在运行时动态地确定和动态地变化,并非指你可以随意地执行数据的任意操作、而无视类型的限定规则。例如,无论是 Python、Ruby 或者 Lisp,都不会允许你给 1 加上 "2",因为它们尽管是动态类型,却是强类型的语言。而只有 JavaScript,才是动态类型+弱类型的语言。由于一些历史的原因,Lisp 在某些文献中曾经被称作“无类型(untyped)”语言,但以现代的术语来讲,它们其实是动态类型的,而不是真正的无类型。

相对地, 静态类型 (statically typed)则是指值的类型在运行时已被“静态地确定”了的语言。这意味着在编译期间,编译器就能够预知所有计算的类型,从而及早发现类型的错误;或者针对特定类型的计算做高效的编译器优化。迄今为止最为高效的高级语言到机器码的编译器,几乎无一例外全部是针对静态类型语言的:Fortran,C/C++,Go,Ada,Standard ML 和 OCaml。当然,那些静态类型+强类型的语言,自然也就被认为是最适合工业级别或企业级的软件开发、代码可维护性最高、最安全的程序语言集合,这其中应用最广的包括了 Java、C# 及 Ada 等。

那么,动态类型的意义何在呢?显然,在一种动态类型的语言中,你不需要在使用前对每个变量做明确的类型定义(尽管有些语言确实允许你这么做),因为即使这么做了也未必有意义,它完全可以在下一步就变成一个别的类型的值;你不必在实现一个函数时考虑“返回值是什么类型”,比如你要写一个能够求解任意数值方程或符号方程的“万能函数”,它可以在某些条件下返回一个数值,在某些条件下返回一个数值的列表,在另一些条件下返回一个符号的列表,在无解时则返回空值。

这些动态类型的好处,又有哪些是静态类型语言所不能做到的呢?答案是: 没有什么是静态类型做不到的 。还可以再多补充一句:静态类型所做不到的,对于写出正确的程序来说都是不必要甚至有害的。借助类型推断,静态类型一样可以让你免去声明变量类型的繁琐;而在运行时随意变换变量的类型,只会招致逻辑的混乱和难以维护的代码;静态类型可以通过抽象数据类型,让一个返回值可能是数值、数值列表或符号列表中的一种,多态的实现更是不在话下;唯一比起动态类型繁琐的是你必须预先定义所有需要的数据类型——但问题是,在动态语言中,如果你在实现函数之前都没有想好它的行为、它的返回数据的可能构造,而在实现完之后又不写文档,以致于后来者除了阅读这个函数的源码本身,没有办法可以知道它所返回的东西是什么,这对于软件工程来说,究竟是好还是坏?类型签名,就是不言自明的文档。

Robert Harper 说:最近若干年世界上每一种新出现的程序语言,都在变得更像 ML。我想,不单是 Scala、F#、Rust 和 Swift 这样的新兴静态语言,即便是最传统的动态语言如 Lisp,也早就在向“静类型检查”和“针对类型编译优化”这两个方向发展:从 Common Lisp,Typed Racket,直到近年的 Typed Clojure;至于 Python 之父向来推崇 Haskell,最近又提出要在 Python 中吸收 mypy 类型检查的记号语法,大概已经 广为人知 了。

 

* * *

 

有人说:既然谈到了静态类型语言,为什么你总是把 ML 啊 Haskell 啊之类的非主流函数式语言挂在嘴边呢?难道 C++ 和 Java 不也是通用的静态类型语言么?

这里就要讲到 函数式编程 (functional programming)的意义所在了。

我们知道,在传统的基于类型的程序语言中,类型是伴随着值与生俱来的属性;值是数据的直观表达,而作为数据抽象的类型则无法脱离具体的值而单独存在。不同的命令式语言重复发明了称之为“函数”“过程”或者“子程序”的概念,但这些概念无法被一致地容纳到纯粹的值/类型的体系当中。举个简单的例子:如何在 C 语言里优雅地实现一个通用的快速排序函数,它可以调用任意的比较函数:无论是大数在前小数在后,小数在前大数在后,或根据结构中某个特定的字符串来比较,都能由使用者自由决定?当然,对于问题的抽象,预示着我们必须把一个比较函数作为参数传给排序函数——但在 C 语言里,函数不被当作值,你无法直接把“函数”这个东西本身传递给函数;因此 C 语言用到了指向函数的 指针 这种方式作为对这类问题抽象的 hack(或对应于其他一些语言中“ 引用 ”的概念)。但指针或引用,在类型安全性上是相当脆弱的;因为它们并不是函数本身!显然,你在设计一门语言的时候可以让它尽可能准确无误地反映函数本身——不过既然要尽善尽美地用一个值来表示一个函数,又何必“发明”一个新的不必要的概念来让我们能把函数当作值那样来使用呢?直接让程序语言把函数也当成值来处理岂不是更加直接么?

一旦把“函数”这个概念本身也视作值,拥有和其他类型数据平等的权力,我们对于这类问题的抽象描述能力也就达到了最大程度的解放。函数的输入参数值具有类型;函数的返回值具有类型;而从定义域到值域的映射,这个“映射”的过程本身也被看作一个值,同样具有类型。

考察纯函数式语言中的两个 变量 (variable) x 和 y ,其 类型 (type)分别为整型和字符串型:

x : Int

y : String

注意:我们在这里使用了“变量”这个词来表示函数式语言中“对值的定义(或绑定)”,它的概念等同于数学上的变量:一个变量可以代表任何值,而一旦它拥有了具体值的语义,则不能够在同一语境下被改变。所有的命令式语言都错误地借用了“变量”这个词,来表示可以“被赋值的量”——但 数学上的变量是只可被定义,不可被赋值的 !这也是为什么 x = x + 1 这样的式子常常会让编程初学者感到困惑的原因;程序语言从数学那里偷走了一些术语,错误地使用了它们,导致了计算机科学和数学某些基本概念上的不一致性。Robert Harper 尖锐地指出了 这个问题 ;因此,我今后将沿用他在《Practical Foundations for Programming Languages》中所采纳的说法,将函数式编程中的“ 可定义的量 ”称作变量;而将命令式编程中的“ 可赋值的量 ”称作 可赋值量(assignable)。

我们已经知道,在函数式语言中,函数也被当作值,自然也有自己的类型。那么若要定义一个从整数映射到整数的函数,它的类型是什么呢?

f : Int -> Int

无论是人类还是编译器,只要看到这个定义,便知道了 f 是一个函数;它的输入是一个 Int 类型的值,返回一个 Int 类型的值。

由此可见,普通的数值也好,从值映射到值的函数也好,在函数式语言中的处理形式其实是别无二致的。在静类型的函数式语言中,它们的类型定义又被叫做 类型签名(type signature):签名体现一个人的个性;同样,看到一个值(可能是函数)的类型签名,你可以知道很多事情,包括它本身、所有输入和返回值的 严格的 数据类型。

需要强调的是,对于前面所有数据或函数的类型定义,我们并没有实际地为 x 和y 绑定具体的值、或者去实现 f 这个函数本身。在一门传统的静类型语言中,值是值,类型是类型, 你为一个名字定义了怎样的类型,它便拥有了怎样的类型 ,这件事情本身是无所谓对错的:你说它是什么,它就是什么;而在类型依赖于值的这样一些程序语言中,由于类型与值之间已无绝对的界限,在类型定义的阶段就可能会出现逻辑上的不一致性。例如,逻辑上,我们可以有效地定义:

--定义
狐猴 : 居住于某个[非洲国家]的动物
马达加斯加 : 非洲国家
Mort : 居住于[马达加斯加]的[狐猴]

但如下的类型定义则无效:

--定义
狐猴 : 居住于某个[非洲国家]的动物
火星 : 行星
Mort : 居住于[火星]的狐猴

咎其原因,是因为“狐猴”这个类型的定义本身依赖了“非洲国家”的具体值——而当我们在定义“Mort”这个狐猴类型时所使用的值(“火星”)事实上绝不可能具有“非洲国家”的类型(“火星”的类型定义为原始类型“行星”),这个类型定义显然已经是无效的了。依赖类型的定义可以对应到谓词逻辑,那么就必然可能出现一些逻辑上无效的命题;换言之,你必须首先有“一个东西是什么”(即所谓的“谓词”),方可构成一个有效的类型定义。而一般静类型语言的类型定义,则不具备这么强的逻辑性质。故而在依赖类型语言的编译器实现中, 类型检查器 (type checker)扮演了一个极其重要的角色;甚至可以说,在类型理论的研究中,type checker 才是程序语言实现的灵魂,而具体编译阶段的代码生成(code generation)则无足轻重。

光有类型定义当然是不够的;在实际编程中,我们比较关心具体的值。依据前面对f 和 x 的类型定义,若要实际计算 z=f(x) 的结果,只需对 f 以参数 x 加以应用即可:

z : Int
z = f x

如果你写:

z : String
z = f x

则类型检查不会通过,因为编译器已经从前面的类型签名知道 f(x) 的结果是一个Int ,而非 String 。

同样:

z : Int
z = f y

类型检查也不会通过,因为编译器之前已经知道 y 是一个 String 类型的值,它不同于 f 的定义域 Int ;类型不匹配报错。

如果你已经习惯了 Haskell 编程的惰性求值( lazy evaluation )方式的话,这里需要多加注意:在 Idris 中,对 f 、 x 和 y 的声明必须在 z 之前进行。因为 Idris 和 ML 一样默认严格求值(strict evaluation or eager evaluation ),值的定义顺序是被严格要求的。

注意到,虽然我们在上面的代码里写出了 z 的具体实现,但在此前,我们甚至没有给出 x 、 y 的具体值,也并没有定义这个函数 f 究竟是什么,所以你如果实际想要去编译这个程序,它还是什么都做不了!但是,Idris 允许你不加实现地执行 类型检查(type checking),而非直接编译成可执行程序。这样做有一个很大的好处:即使你的程序没有完成,所有的代码尚未具体实现,但编译器已经可以在此阶段帮助你及早发现定义中存在的类型错误了。

一个函数常常不止一个输入参数,这个时候我们可以用 值对 (pair or product type )这种复合数据类型( composite data type )来把两个参数“捆”成一个值。值对是函数式语言中一个最最基本的复合数据结构,在 Lisp 中你可以用它来构造出常用的列表(list)。

f : (Int, String) -> Int

z : Int
z = f (x, y)

注意到现在这个函数的应用方式: f(x,y) ,这与 C、Python 这类过程式语言中的写法何其相似!但是记住,我们通常不用“该函数有两个参数”这样的说法;这个函数只有 一个 参数,其数据类型是一个由 Int 和 String 构成的值对 (Int, String) 。

虽然你可以用值对来模拟函数的任意多个输入参数,但实际上这种写法在静类型的函数式编程(尤其是 Haskell 系的语言)中比较少见。为什么呢?不要忘了在函数式语言中,函数的返回值也可以是函数,故而我们可以先给定作为第一个参数的 Int 值作为约束变量,让主函数返回一个子函数:这个返回的函数接收我们给定的第二个参数,也就是一个 String ,而它所最终返回的值,才是我们真正想要的结果:

f : Int -> (String -> Int)

z : Int
z = (f x) y

这种技巧便是函数式语言中所谓的 柯里化 ( currying )。由于它非常有用,我们约定函数的类型签名从右至左结合,而函数的求值应用从左至右结合;故上述写法可以省略所有的括号而写成:

f : Int -> String -> Int

z : Int
z = f x y

很明显,Haskell 程序员要远比 Lisp 程序员更加喜欢 currying——单纯从语法上看,f x y 自然是要比 f(x,y) 更加简单,而且更加符合λ演算的本质:我们要完成的计算需要相继传递两个值, x 和 y ;而 f(x,y) 这样的写法暗示着值对 (x,y) 才是函数的参数——但这个值对并不是本来就存在的,它是我们人工“构造”出来的一个复合类型!Lisp 程序员不喜欢 currying 也是可以理解的,显然,每一次调用都要多嵌上一层括号,而 LISP(Lost in Stupid Parentheses)本身的括号已经足够多了,这实在是一件吃力不讨好的事情。

在后面你很快将看到,Currying 的作用远远不止于语法上的甜头,或者仅仅是高阶函数层层调用的抽象游戏;它所表示的类型推演与逻辑证明息息相关,甚至可以毫不夸张地说,对于整个程序语言类型系统的研究,就是建立在 currying 这种看似无用的抽象之上的。

初学者需要特别注意的是,这两种写法并不等价:

f : Int -> String -> Int
g : (Int -> String) -> Int

前者是一个从右至左的 currying;而后者是一个单一输入的函数,这个函数的输入参数本身是一个类型为 Int -> String 的函数,而其输出类型为 Int 。

注意到高阶函数也可以作为 currying 的一部分存在。比如下面这个函数:(通过观察它的签名,你或许会猜测它的功能可能是将输入的第二个参数 Int 应用于输入的第一个参数即函数 Int -> String ,从而得到最后的计算结果 String )

f : (Int -> String) -> Int -> String

最后一点,我们已经知道函数的输入参数只能是一个单一的值;而通过把多个值构造成值对,或是通过 currying 的方式,可以做到让函数模拟输入多个参数。那么,能否让函数输出多个值呢?

从理论上讲,每应用一次函数,相当于给出了一个输入参数值,得到了作为中间结果的输出函数值,但这个中间函数对你来说通常没有意义——无论如何,Currying 的作用是要得到最终的结果,而非过程中的中间函数。函数输出的值是单一的,但你仍然可以轻松地用值对把多个值“捆”在一起:

f : Int -> String -> (Int, String)
f x y = (x, y)

你可以看到,我们在这里具体地实现了这个函数:它的作用是把 currying 的两次参数构造成一个值对并返回。

很容易写一个函数,把这个值对中的第一个值( Int 类型)取出来:

f : (Int, String) -> Int
f (x, y) = x

【练习1】 实现如下函数,取出值对中的第二个值:

f : (Int, String) -> String

 

* * *

 

好了,关于函数式语言中静类型和高阶函数 currying 的基础概念我们就介绍这么多;如果你已经熟知 Haskell 或 ML 这样的语言,上面这些不过是老生常谈。虽然前面除了值对以外并未涉及到更复杂的复合数据类型,不过这些已经足以帮助我们理解后面的内容了:关于一个更强大、表达力更丰富的类型系统,让程序如同证明般精确——更确切地说, 让程序变成证明 (proofs-as-programs),从而使得我们的程序一旦通过了编译器的类型检查,便可成为自带证明的代码(proof-carrying code)。

从静态类型到看似遥不可及的“ 程序即证明 ”这一目标,实际上不过一步之遥。观察我们前面定义过的这个函数签名:

f : (Int -> String) -> Int -> String

之前我们猜测过,它的功能可能是将输入的第二个参数 Int 直接应用于输入的第一个函数 Int -> String ,得到最终的计算结果。尽管也许不是,因为我们总有无限种可能去实现某个函数;但还是不妨尝试去完成这个最简单的实现:

f : (Int -> String) -> Int -> String
f x y = x y

这不存在任何难度——它的功能是将 Int 类型的值作为参数应用于 Int -> String 类型的函数,仅限于此。

不难想象,在实际编程中我们可能会需要用到许许多多相同构造的函数,虽然它们在功能上完全具有相同的构造,实现也完全等同,却可能具有千奇百怪的类型签名:

f2 : (Int -> Bool) -> Int -> Bool
f2 x y = x y

f3 : (String -> Int) -> String -> Int
f3 x y = x y

f4 : (String -> String) -> String -> String
f4 x y = x y

我们希望有一种通用的形式来描述和实现所有这类函数,从而不必逐一枚举出所有可能的类型组合。换言之,我们希望 类型签名 也能够如函数的形参一般被 参数化 ;事实上,许多现代的静类型语言都提供了类似功能的特性:诸如 C++ 的 模板 (templates);如果你熟悉 Java、C# 或 Swift,这种功能被称作 泛型 (generics);在函数式语言中,它的术语叫 参数多态化 (parametric polymorphism)——虽然这个名词听起来远不如“模板”“泛型”那么酷,但我认为唯有它道出了问题的实质:我们需要一种形式来把某些东西的 类型定义 普遍化,而这个形式,便是用 参数 而非具体的类型名字来描述类型。这就是我们得到的新的更具普遍意义的函数实现:

f : {p, q : Type} -> (p -> q) -> p -> q
f x y = x y

对于 Haskell 或 Idris 这样的语言来说,编译器知道这里出现的 p 和 q 可表示任何类型,因此前面的限定量词 {p, q : Type} 通常可以省略。但我们在这里还是倾向于把它们写出来。

因为这个函数是如此稀松寻常(获取一个函数和一个值,然后将值应用于函数得到计算结果),它在 Idris 的标准库中已经被实现了,叫做 apply :

apply : (a -> b) -> a -> b
apply f a = f a

另一个具有普遍意义的函数是把任意类型的值映射到它自身,像这样:

f : {p : Type} p -> p

很容易想象它在 Idris 标准库里的实现。它叫做 id (identity,恒等变换):

id : a -> a
id x = x

现在问题来了,是否有可能实现这样一个通用的函数,它可将宇宙间任意一个类型的值映射到另一个类型的值?

f : {p, q : Type} p -> q

我们知道,这件事情必然是荒谬的,因为类型在这里只是一个参数,你在实现的时候并不知道它代表具体哪个类型——无论是程序语言已有的原生类型,还是用户自己定义的抽象数据类型。因而,你无从构造出任意一个“未知类型”的值,除非你知道关于类型的更加详尽的信息:例如,至少把 q 限定在某些特定类型的集合内,而不是 全体类型的集合 Type ;然而那样做,就失去了我们所要实现的这个函数对于 {p, q : Type} 均适用的普世价值了。

【思考1】 能否实现这样一个通用的函数?

f : {p, q : Type} -> (p -> q) -> q -> p

 

* * *

 

回顾一下前面所给出的几种类型签名形式: p -> p , p -> q , (p -> q) -> p -> q , (p -> q) -> q -> p ,以及相应的函数实现。我们可以说: p -> p类型的通用函数是可以被实现的,因而证明了从 p 推得出 p ; p -> q 类型的通用函数是不可实现的,因而我们无法证明 p 总是推得出 q ; (p -> q) -> p -> q可以被实现,表示着从 p -> q 和 p 出发,可推出 q ;然而, (p -> q) -> q -> p 不能被实现,即从 p -> q 和 q 推不出 p 。

如此一来,上述类型推演的深刻意义便可以揭示于众了: (p -> q) -> p -> q即对应着命题逻辑中的 肯定前件 ( modus ponens )。用自然语言表述,便是:

 

若 p 则 q ,且 p ,可以推出 q 。

 

用逻辑语言的相继式(sequent)表述,是:

 

$P \to Q, P \vdash Q$

 

而在大量吸收了 Unicode 用于表示数学符号的 Agda 语言中,则是:

modus-ponens : {a b} {P : Set a} {Q : Set b} (P → Q) → P → Q

当然,用对程序员而言更友好的 Idris 或类 Haskell 的语法来写,就是这样:

modus_ponens : {p, q : Type} -> (p -> q) -> p -> q

对其的证明即是其函数实现。换言之,每个程序员都是数学家;每完成一个函数的实现,便是完成了一个有效的数学证明。这便是类型系统的精妙之处所在:

modus_ponens : {p, q : Type} -> (p -> q) -> p -> q
modus_ponens x y = x y

如同在数学中一样,一个命题可以存在多种不同的证明;对于一个函数的实现方式也绝非唯一。注意到 Idris 标准库函数 apply 具有和 modus_ponens 完全相同的类型构造,故而我们可以直接用它来完成对其的证明:

modus_ponens = apply

前面在讲到函数的 currying 时已经指出,类型签名的结合性是从右至左的;这意味着 modus_ponens 的类型可看作是 (p -> q) -> (p -> q) :没错,从 currying 的第一层嵌套来看,它就是一个把 p -> q 类型函数映射到 p -> q 类型函数的高阶函数!因此,一种更巧妙的证明是直接将其自身映射到自身:

modus_ponens x = x

或借助于库函数中的恒等函数 id :

modus_ponens = id

以上便是对 modus ponens 最简洁有效的证明。

说了这么多,我们还没有跳出 Haskell 的 parametric polymorphism 能力范围之外,但这已经足够让我们去尝试实现一些最基本的经典逻辑证明了。

【练习2】 证明肯定前件的另一种书写形式:

modus_ponens' : {p, q : Type} -> p -> (p -> q) -> q

【练习3】 证明 假言三段论 ( hypothetical syllogism ):

 

$(P \to Q), (Q \to R) \vdash (P \to R)$

 

hypo_syllogism : {p, q, r : Type} -> (p -> q) -> (q -> r) -> p -> r

 

* * *

 

没有联接词的逻辑自然不能称之为是有用的逻辑。前面已经提到过,函数式语言中有一种叫做 值对 (pair)的复合数据类型,可以把两个值“捆绑”成一个值,如:

f : Int -> String -> (Int, String)

将其类型参数化:

f : {p, q : Type} -> p -> q -> (p, q)

我们便得到了逻辑上的 合取 ( conjunction )式:

 

$P, Q \vdash (P \land Q)$

 

而在命题逻辑中证明合取的过程,便是在计算机程序中直接构造出一个值对的过程:

conjunction : {p, q : Type} -> p -> q -> (p, q)
conjunction x y = (x, y)

因为值对在逻辑上可以用来编码合取式,或反映到具体应用数学的布尔代数中的“与(logical AND)”;从“类型是值的集合”这个角度来看,它可看作是两个集合(set)的笛卡尔积 (Cartesian product);而用范畴论的语言来说,它是在两个类型的范畴(category)上的  (product),所以值对这种数据类型,有另一个名字叫做 乘积类型( product type )。

需要指出的是,Idris 中这种用 (p, q) 来定义一个值对的写法纯属语法糖(syntactic sugar);作为一个抽象数据类型,值对的标准构造语法是 Pair p q ,例如:

conjunction : {p, q : Type} -> p -> q -> Pair p q

【练习4】 证明 合取消去 ( simplification ):

 

$(P \land Q) \vdash P$

 

simplification : {p, q : Type} (p, q) -> p

 

* * *

 

到目前为止,我们看到了程序语言类型中的多态参数 p 可以等同于逻辑证明中的命题;写出“ p ”则表示着命题为真。那么,如何表示命题为假的情况呢?例如,我们有熟知的 双重否定消去 ( double negation elimination )和 双重否定介入 (double negation introduction),用简单的话讲就是: 否定之否定为肯定 , 肯定为否定之否定 。

 

$\neg \neg P \vdash P$

 

$P \vdash \neg \neg P$

显然,为了能够表示出上述命题,我们必须首先通过类型表示出否定( negation )的项:

 

$\neg P$

 

在逻辑中,表示否定的语义,便是“该命题无法被证明为真”的语义。借鉴前面通过构造值对 Pair p q 来表示“合取”的方式,聪明的读者可能一下子就想到了:只要再构造一个 Not p 的抽象数据类型,然后为它赋予“命题 p 不可被证明为真”的语义,不就达到我们的目的了吗?

遗憾的是,根据 哥德尔不完备定理 ( Gödel's incompleteness theorems )的要求, 一个自恰的逻辑体系必不完备 ,也就是说总有这么一些命题,我们虽不能证明它,却也无法否证它;因而形式上讲是不应该存在这样一个通用的操作,能够用于表示“对命题 p 的证明不存在”这个语义的。我们必须另辟蹊径,玩弄一个小小的文字游戏:我们不说“ 对 p 的证明不存在 ”,而说“ 对 p 的证明将导致完全荒谬(absurdity)的结果 ”。

在类型论中,我们引入一种称之为 底类型 ( bottom type )的理想数据类型,它仅仅表示“什么也没有”, 这个类型不存在任何与它对应的值 ,或者说,“这个类型的值如果存在,那将是一件荒谬的事情”。Scala 程序员应该比较熟悉这个东西,它对应的类型叫 Nothing ,用于实现参数多态化(注意,Scala 里的此 bottom type Nothing与 Haskell 或 Idris 中 Maybe Monad 的 option type Nothing 表示的是不同的概念!);Haskell 和 ML 的语言标准里没有底类型,因为实际编程中它不是必需的;而在 Idris 中,底类型的实现是 _|_ 。至于为何要选择这个怪异的符号,我们很快将会解释到。(注意:它在 Idris 0.9.15 以后的版本中为了语法的一致性 被改成了 Void 。)

一个返回底类型的函数不能够返回任何值,一旦你试图对这样的函数求值,那么程序显然会出现未定义的行为。形式上,你可以在 Idris 里构造出这样的类型:

f :  p -> _|_

但你不可能真的去实现这样一个函数,因为它无法返回任何值。

一个不能被实现的东西,定义出来又有何意义?如果只是从实际编程的意义出发,它当然没有意义;但在领会了前面关于程序类型与逻辑证明的相似性之后,我们会很自然地想到:它可以用来表示一个已知为假的命题,即你写得出它的定义,却永远也实现不了(或者说:证明不了);因为对于它的输出数据类型 _|_ 来说,没有任何一个值可以用来被返回。(现在你大概明白了:Idris 中表示底类型的 _|_ 实际上是 Unicode 中 falsum 字符  的转写;既然  表示命题为真,True,或 Top;那么将其倒转为  ,自然就表示命题永假,False,或 Bottom。)

于是,上面的示例所代表的逻辑语义便是:对 p 的证明将导致一个荒谬的结果;或者说,命题 p 永假。

 

$P \to \bot$

 

即,$\neg P$

一旦接受了这种设定,我们便可以构造出:

f : {p : Type} -> p -> (p -> _|_) -> _|_

形式上,它与之前【练习2】中肯定前件的另一种形式是吻合的,无非把表示任意类型的 q 替换成底类型 _|_ 罢了。虽然 p -> _|_ 这样的函数不可能被实现(即命题无法被证明为真),但 承认它的存在 ,并 把它的类型 currying 到一个高阶函数中 (作为另一个更大命题的组成部分),乃是实现证明之必须;因为很显然,这个大命题是可以被证明成立(即,此类型的函数可以被构造出来)的!

f : {p : Type} -> p -> (p -> _|_) -> _|_
f x y = y x

Idris 标准库中为了证明的易读性,定义了 Not 函数,它将任意一个类型 a 映射到函数类型 a -> _|_ (即命题 a 为假)后返回:

Not : Type -> Type
Not a = a -> _|_

有了它,上述函数可以简单地改写成:(注意它的类型签名更加清晰地表达了其逻辑语义)

double_neg_intro : {p : Type} -> p -> Not (Not p)
double_neg_intro x y = y x

至此,我们完成了对经典逻辑中双重否定介入(double negation introduction)

 

$P \vdash \neg \neg P$

 

的证明。

【练习5】 证明 否定后件 ( modus tollens ):

 

$P\to Q, \neg Q \vdash \neg P$

 

modus_tollens : {p, q : Type} -> (p -> q) -> Not q -> Not p

提示:注意到 Not 在类型推演中起到了“返回一个类型”的作用,你可以把它改写成如下等效的形式:

modus_tollens : {p, q : Type} -> (p -> q) -> (q -> _|_) -> p -> _|_

如此一来,就很容易根据函数的类型定义猜测出它的证明了。

顺便说一句,这种用 函数的调用 来表示逻辑证明中的 推导 、用 值对类型 来表示逻辑上的 合取 、用 返回值为底类型的函数 来表示逻辑上的 否定 ,完全借助于函数式演算的元素来阐释逻辑之语义的方式,确实是非常精妙的。事实上,你还可以用类似的方式来表示逻辑上的析取,甚至于全称量词和存在量词。这种技巧并非函数式编程的创造,它甚至在计算机被发明出来之前就已经被数学家引入研究了;它有一个充满科幻色彩的名字,叫做“ BHK 释义 ( Brouwer–Heyting–Kolmogorov interpretation)”。(因为它是由三位数学家独立提出的,最后那个如雷贯耳的名字就是神一般的柯尔莫哥洛夫)

【思考2】 能否像上面一样,完成对于双重否定消去(double negation elimination)的证明?(无论是直接用 Idris 来实现下面的函数也好,改用等价的 Agda 或 Haskell 也好;不妨先尝试一下。)

 

$\neg \neg P \vdash P$(双重否定消去)

 

double_neg_elim : {p : Type} -> Not (Not p) -> p
-- Expands to:
-- double_neg_elim : ((p -> _|_) -> _|_) -> p

 

* * *

 

你不应该在这个问题上花费太多时间。这里有一个对此问题的 简短解释 。简单地讲,虽然 Curry-Howard 同构揭示了程序语言类型与逻辑证明之间的紧密联系,但,我们在日常的数学书中所接触到的、数学课上被广泛用于解证明题的 经典逻辑 (classical logic),和单纯基于 构造性证明 ( constructive proof )的 构造逻辑 (constructive logic)之间是不等价的!换言之,并非所有经典逻辑的命题都能够以如上构造逻辑的方式证明。双重否定消去只是其中的一个例子。

由此,人们提出了比经典逻辑更加弱化的 直觉主义逻辑 ( intuitionistic logic ),寄希望以直觉主义逻辑作为构造逻辑的基础,取代经典逻辑的方式来重建数学体系,从而绕过经典逻辑“不可完全通过构造的形式表达”的坑,让全部的数学体系被现代的计算机所接纳、亦可以通过机器计算来达成证明。例如:经典逻辑中稀松平常、在数学中常常被我们用来做证明的排中律(law of excluded middle)和双重否定消去律(double negation elimination),被构造逻辑统统拒之于门外,因为它们违背了“可计算性”的准则;而对于计算机来说,不能从计算的方式构造出来意味着不可被证明,自然也就破坏了“将一切数学定理在计算机中形式化”的宏伟目标。

历史上,像直觉逻辑这样的数学构造主义曾经饱受质疑。就连希尔伯特(David Hilbert)都曾经说过:“把排中律从数学家那里拿走,就像把望远镜从天文学家那里拿走,或是从拳击手那里把拳头拿走一样!”当然,他在说这句话的时候,可以用来做证明的“计算机器”还不存在,自然更不可能预见到看似无用蹩脚的构造性理论,对于今天的计算机辅助证明的重要性。

而直觉主义逻辑体现到今天程序语言理论对于类型论的研究中,就成了 直觉主义类型论 ( intuitionistic type theory )或所谓的 Martin-Löf 类型论,因该理论首先由瑞典数学家佩尔·马丁-洛夫( Per Martin-Löf )引入对类型论的研究。而体现到具体程序语言类型系统的实现中,就变成了所谓的“ 依赖类型 ( dependent type )”(事实上我认为“依赖类型”的定义过于笼统,它也不能完全反映直觉类型论中的所有东西,所以它现在基本上是个 buzzword)。因此,计算机科学乃至程序语言理论的前沿研究,首先是为了以计算的方式定义可构造的数学基础、而非为了“如何设计一门程序语言”“如何编程”这些世俗的目的而服务的;而数学基础则不单单是数学、也是一切科学和计算机工程学的公理化基石。

 

* * *

 

说到现在,我们还没有真正接触到依赖类型这种更为强大的类型系统的核心,因此前面的程序即证明同样可以在任何一门静类型函数式语言,如 Haskell、ML 或 Scala 中实现。但证明这件事情远不止于此!命题逻辑当然是有用的,但没有实际的语义,它们不过是些抽象的废话而已;我们希望能够证明一些更有用的东西,比如:实际的数学定理 。

一切数学的基本之基本,当然是建立在 Peano 公理 上的算术系统。在依赖类型的语言和证明辅助工具中,经常被研究的一种基本数据类型是自然数( natural number )的类型(或集合论中所说的自然数集) Nat (Agda 中写作  )。它满足 Peano 公理的若干性质,在 Idris 中可定义为:

data Nat : Type where
  Z : Nat
  S : Nat -> Nat

如果你熟悉 Haskell 中的抽象数据类型(GADT),这种写法其实很容易理解: Nat 是一个抽象数据类型,我们有两种方式构造出它的值: Z ,即构造一个作为基底的自然数(当然也就是 ); S n ,即构造前一个自然数的后续值。通过这种方式,我们定义出了包含全体自然数集的抽象类型。

我们把这种类型称之为 归纳类型 ( inductive type ),显然,这个叫法来自于它所使用的归纳法(induction)。Idris 标准库中的定义则写法更紧凑一些:

data Nat = Z | S Nat

初学者必须注意,在定义自然数集时,我们不可以用到任何程序语言本身提供的 原生数据类型 (primitive data type)——Haskell 或 Idris 实用编程中常见的 Int 整型,或高精度 Integer 类型,在这里被绝对地禁止。为什么要把原生数据类型排除在我们的讨论范围之外呢?很简单,我们在这里要做的是数学体系的公理化,而非实质的计算;这意味着你必须从零开始,依据 Peano 算术的原理一步步构造出任何自然数。例如,为了表示自然数7,你需要这样写:

S (S (S (S (S (S (S Z))))))

可以想见,这种数的表示方式,做起实际计算来将是极其低效的。Coq 和 Agda 这样的语言从一开始就是为了证明而设计的,所以虽然每个人都会拿它们定义诸如自然数这样的基本类型,但却鲜有人直接拿它们所定义的自然数来做实际的计算。Idris 作为一门实用的语言,提供了诸如 Int 和 Integer 这样的原生数据类型;当你在程序中实际执行 Nat 的计算时,它的编译器足够聪明到把 Nat 转化成相应的 Integer来对运算做优化。但在单纯做证明时,我们应当严格区分两者,并且避开这些原生数据类型。

自然数加法的定义如下:

plus : (n, m : Nat) -> Nat
plus Z right        = right               -- 定义1
plus (S left) right = S (plus left right) -- 定义2

用语言叙述便是:0加任意自然数得到后者;一个非0的自然数加另一个自然数,得到“第一个自然数的前驱加第二个自然数”的后继自然数。看似拗口,但对于熟悉数学归纳法或递归方法的读者来说,这已足够完成对任意两个自然数的加法运算。

例如,计算 3+2 的过程:

$plus\ (S\ (S\ (S\ Z)))\ (S\ (S\ Z))$

$=S\ (plus\ (S\ (S\ Z))\ (S\ (S\ Z)))$(根据定义2)

$=S\ (S\ (plus\ (S\ Z)\ (S\ (S\ Z))))$(根据定义2)

$=S\ (S\ (S\ (plus\ Z\ (S\ (S\ Z)))))$(根据定义2)

$=S\ (S\ (S\ (S\ (S\ Z))))$(根据定义1)

有了这些,可以开始我们的第一个证明了。对于任意自然数 n ,试证: 0+n=n 。即:

 

$\forall{n}{\in}\mathbf{N}, 0+n=n$

 

或以依赖类型的符号表示:

 

$\Pi_{(n:{\mathbb N})}\ plus\ Z\ n=n$

 

改写成 Idris 的形式:

plusReduces : (n : Nat) -> (plus Z n = n)

这个函数类型签名可能乍一看有点陌生。类型签名开头的量词 (n : Nat) 表明它是一个依赖类型的函数,只要类型后半部分的定义中出现了对于 n 的计算,就表示该对类型的定义是依存于 n 的具体值而存在的;而 plus Z n = n 则表示一个“证明”的类型,形式上它等同于 0+n=n 这样的数学式子,而将该等式本身作为一个单独类型的含义是:你在具体地实现该函数时,必须在此参数位置提供一个属于该“证明类型”的值,来使我满足;否则,我就不让类型检查通过。而我们知道,一旦你提供了这个 plus Z n = n 的证明作为函数的参数,就意味着程序类型检查通过,也就象征着你正确地完成了该定理的证明。这个所谓的“证明类型”形式上是一个等式,因此我们把它称作为 等式类型 (equality type)。

从程序语言的角度来看,因为 (n : Nat) 的存在,我们的函数定义是一个 依赖类型函数 (dependent function),其类型依赖于 n 的值;而从谓词逻辑的角度来看(n : Nat) 实际上是一个 全称量词 (universal quantifier),它的意义是“对于 Nat类型的任意值 n ,有……”。之所以在前面做命题逻辑证明时没有特别强调 (p : Type) 这个全称量词也是依赖类型这一点,是因为它们的具体类型签名并没有实质涉及p 的具体值;故之前的类型推演同样可以在支持参数化多态的非依赖类型语言,如 Haskell 中完成。而现在,通过允许在类型定义中使用 n 的具体值,甚至把 plus Z n = n 这样的等式也作为类型的一部分,便是依赖类型系统为构造数学证明提供的不二法宝。

容易发现,这个定理实际上是不证自明的,因为依据我们前面对加法函数 plus 的实现, plus Z n 从定义上说就是 n ;我们只需要告诉机器:这个等式的左边根据定义,已经等于右边了,你看吧!这种证明的技巧,叫做“trivial”;大致上相当于数学书中经常出现的“易证”、“显而易见”或“由定义可得”。在 Idris 里,我们使用 refl 来指出这一点,从而构造出这个异常简单的证明:

plusReduces : (n : Nat) -> (plus Z n = n)
plusReduces n = refl

我无法在这篇科普小品中叙述更多关于计算机辅助证明的内容,那将是一个过于庞大的课题。在这方面有一些很好的书籍可供参考,如《Software Foundations》和《Certified Programming with Dependent Types》(对于感兴趣的读者,这里是一个 或许有用的列表 )。需要指出,从写出实际的代码到机械化地执行类型检查、完成对数学公式的证明,这通常是一个比较繁琐的过程,远不是像我们上面这个例子中一个 refl 那样简单;所以在实际应用中,证明几乎总是通过交互式的方式逐步递进构造出来的。因为本文后面要探讨的内容将会逐渐脱离 形式化“证明” 的目的,从而转向 实用化的“计算” ,我们就不在此赘述更多关于证明的技巧了。

 

* * *

 

从上面浮光掠影地对类型和证明之间的同构性作了初探之后,我们可以说,依赖于值的类型具备了足以构造谓词逻辑的表达能力。那么,这是否意味着对于计算机程序来说,能够通过类型检查的证明就一定是完美无误的呢?

就我们用到的 Idris 语言来讲,它的实际情形比你所想的要复杂一些。比如,我在这里构造了一个“1小于等于一切自然数”(即 ∀n∈ℕ, 1≤n )的证明,它可以被 Idris 类型检查成功而不报错:( lte 函数判断两个自然数的大小,若前者小于等于后者,则返回真)

wtf : (n : Nat) -> lte (S Z) n = True
wtf (S k) = refl

它的错误出在哪里呢?显然,我们在函数的实现中漏掉了 wtf Z ,也就是针对该自然数为0的情况;对于 n = S k ,也就是可以通过一个自然数加1构造得到的自然数而言,1小于等于它,即 lte (S Z) n = True 这件事情是必然的。所以,类型检查在这里不会报错。而我们知道,一旦考量了0的情况,就会发现1并不小于等于它。由此可见,对于构造一个正确的证明来说,光通过类型检查还是不够的。我们必须确保函数的实现对每一个可能的值都有定义 ;否则只要漏过一个反例,对于我们的证明来说都是致命的缺陷。

正是基于这个原因,像 Coq 和 Agda 这样的证明辅助工具,不允许程序中出现不完全的函数定义。

当然,你也可以说,是否检查函数定义的完全性,只是一个程序语言设计上的选择而已——毕竟要检查一个函数是否有 missing case,实现起来还是挺简单的,不是吗。就拿 Idris 来说,可以通过指定编译器选项 --total 来检查函数定义完全性。上面那个错误的证明,将不会通过类型检查。问题解决。

如果你觉得事情就这样结束了,反正类型即逻辑,程序即证明;该证明的一定能证明,该类型检查的一定会被实现;将全部数学定理通过计算机形式化,也不过是时间的问题而已,那你也未免“ too simple,sometimes naive ”了!

 

* * *

 

Naive 这件事最初说的是朴素集合论(naive set theory)。我们都听说过那个著名的理发师悖论:

 

一个理发师只给他的镇上所有不自己刮脸的人刮脸。

 

它背后的数学形式便是罗素悖论( Russell's paradox ):

 

$\text{Let } R = \{ x \mid x \not \in x \} \text{, then } R \in R \iff R \not \in R$

 

提出罗素悖论的罗素( Bertrand Russell )自己并没有在今天被我们称为公理化集合论的这条路上走下去。他说,这件事其实很简单;之所以我们的体系中会出现矛盾,是因为我们在谈论“集合”这件事的时候引用了它自身,即逻辑上的“自指”( self-reference ),而这种集合概念的自指本身是邪恶的(即“恶性循环原则”, vicious circle principle )。完全采用 直谓性 (predicative)的定义就可以避免这种悖论,由此,他提出和发展了 分支类型论 (ramified theory of types):任何集合必须从属于确定的类型,而同一类型的集合存在谓词的层级。譬如,对于著名的说谎者悖论:“我说的这句话是假的”,即谓词逻辑上的“我断言命题 p :它是假的”,若 p 是一阶命题,那么这句话本身是一个关于 p 的二阶命题,不可被它代入,从而消除了这个矛盾。换言之, 非直谓性 ( impredicative )的自指定义是不被允许的。

但分支类型论带来了实际应用中一些不必要的麻烦;依据分支类型论,实数的划分也存在着“阶”的概念:关于一阶实数的定义,不能够出现“所有实数”这类谓词;关于二阶实数的定义,只可使用“一阶实数”这类谓词。英国逻辑学家拉姆齐( Frank Ramsey )指出: 非直谓性未必是有害的 ,譬如“房间里最高的人”,它的定义依赖于“房间里的人”这个全体,但这种定义不存在任何矛盾。拉姆齐把所有悖论分成两种:语义悖论和逻辑悖论。前一种悖论,如说谎者悖论,是由于其语义本身存在矛盾而造成的,它不可以从逻辑上消除;而后一种悖论,如罗素悖论,可以通过 简单类型论 (simple type theory)来消除:禁止混同不同的类型,如个体构成的类与“由类构成的类”。这样一来,分支类型论所引入的“层级”的繁琐,就变成不必要的了,如此得到的类型论更具有实用价值。为了显示这一点,他还将罗素和怀特海《数学原理(Principia Mathematica)》中用到的分支类型论改换成简单类型论。后来几乎所有的逻辑学家,包括罗素自己,都接受了拉姆齐的说法,而摒弃了饱受争议的分支类型论。1940年,美国逻辑学家邱奇(Alonzo Church)则依据简单类型论介入了 简单类型λ演算 ( simply typed lambda calculus ),成为了现代程序语言类型论的基础。

说到拉姆齐这个人,也是个英国科学界的传奇人物。他的弟弟是坎特伯雷大主教,他自己却是个坚定的无神论者;在剑桥,他是 剑桥使徒会 的成员之一;18岁的时候他拜读到维特根斯坦(Wittgenstein)的《逻辑哲学论》,为之欣喜若狂,将其翻译成英文,并去找维特根斯坦热烈讨论,后来成为其至交,并将其引荐给当时在剑桥三一学院的罗素;他自己则在21岁那年成为剑桥国王学院的研究员。比较遗憾的是,他只活了26岁;1930年,他因为一次手术的并发症而英年早逝。他短暂的一生中广泛涉猎哲学、逻辑学、数学、语言学、文学甚至于经济学。(经济学里那个著名的拉姆齐模型和拉姆齐法则,还有组合数学里的 Ramsey 定理、Ramsey 数,都是以他的名字命名的。)

类型论没能取代公理化集合论成为数学的基础。但罗素至少通过他那鸿篇巨制《数学原理》证明了一点:凭借着公理、推理规则和符号逻辑,我们具备了以逻辑作为数学基础、而构建出整个数学体系的可能性。简单类型论则通过“简单地”区分个体的类和类组成的类,回避了罗素悖论的问题。

然而,对于致力于数学构造主义的直觉类型论而言,类似于罗素悖论的情形仍然悬而未决。基于逻辑证明和类型之间的同构性,马丁-洛夫提出了直觉主义类型论的最初版本,即允许对于任意谓词的量化;反映到具体类型系统的设计中,它的更一般化形式便是纯类型系统( pure type system );然而,一个“任意谓词都被允许”的最强形式的类型系统,同时也必将导致对非直谓性(即类型的自指)的允许,而这意味着你可以说出“对于 一个包含所有类型的类型 (the types form a type)”,这就产生了所谓的 吉拉德悖论 (Girard's paradox)。

吉拉德悖论会带来什么问题呢?

我们知道,已知的较弱的类型系统均具有 强规范化性质 ( strongly normalizing);从计算的角度来看,它们确保一定会停机,即这些类型系统都 不是图灵完备 的。这些类型系统涵盖了最弱形式的 简单类型λ演算 (simply typed lambda calculus),System F ,直至最强形式的 构造演算 ( Calculus of Constructions )。

而允许非直谓性的最一般化形式的这种类型系统,从计算上讲它是图灵完备的,这意味着它的类型检查可能不会停机!

上面的这些话也许过于抽象,其实你可以更加直观地理解这件事情:

 

我证明了我现在正在说的这句话,因此,我证明了 p 。

 

这是一个典型的非直谓性命题。从构造主义逻辑的角度来讲,为了使其证明有效,则必须首先构造出“我正在说的这句话”的一个证明;因此,假如你写了这样一个证明,类型检查器会不停地检查下去。根据我们前面对于证明和类型同构性的介绍,很容易想象,一旦你的类型系统允许你定义“包括一切类型的类型”,你就具备了能力去写出这样一个永不停机的证明。

而我们当然知道, 一次不停机的证明是一个无效的证明 。否则就有人会说:“本人致力于证明哥德巴赫猜想,但是不知道证得对不对,因为关于这个证明的推导是无限的,根本停不下来;在此之前请先把 Fields 奖发给我……”

为了使我们的构造逻辑体系自恰,避开吉拉德悖论导致的矛盾,我们必须重新审视直觉主义类型论的基础。考察 Martin-Löf 类型论中提出的三个关键点:

1) 罗素对“ 类型 (type)”这一概念的原始定义: 使命题函数具有意义的范围 。

例如,

f : {p, q : Type} -> (p -> q) -> p -> q

若要使单独的项 p 具有意义,它的范围必须是 Type ,故而我们说项 p 的类型Type ;而若要使更具体的项 (p -> q) 具有意义,它的范围必须是能够使命题 (p -> q) 成立的类型,即其类型是一个函数 p -> q 的类型。

2) 我们有时会需要针对 所有 命题量化。(即允许 非直谓性 的存在)

3) 命题和类型的同一性。或者用通俗的话说,命题即类型,两者可以完全等同。

从 1) 和 2),我们可以得到简单类型λ演算,也就是不完全具备“类型即证明”能力的非依赖类型系统;若是加上 3),我们就具备了定义“一个包含所有类型的类型”的能力,这又是逻辑体系的自恰性所不容许的。由此可见,吉拉德悖论揭示出:我们不可能同时拥有这三件事情。1) 是类型论的概念基础,因此我们必须在 2) 和 3) 之间作出取舍;这其实是一件鱼和熊掌不可兼得的事情。

T. Coquand 的选择是取 2) 而舍 3)。换言之, 命题和类型不可以完全等同 。他提出了用“小类型”和“大类型”的概念对类型加以区分,这条路引出了所谓的构造演算( Calculus of Constructions ),其中包含五个基本概念:

  1. 证明 :类型为命题的项。
  2. 命题 :又称作小类型。
  3. 谓词 :返回命题的函数。
  4. 大类型 :谓词的类型。
  5. 大类型的类型 T。

构造演算是今天被广泛用于辅助证明和形式化验证的 Coq 语言的理论基础(没错,虽然 Coq 这个名字的意思表面上看是法语的“公鸡”,但实际上它暗示着 Coquand 的姓氏……)。Coq 的核心语言被称作 Gallina,它实际上是一个具有强规范化性质的类型系统,这意味着它必然停机;如你所想,它在具备了“类型即证明”能力的同时,亦克服了吉拉德悖论所招致的逻辑不自恰性,从而能够被可靠地用在实际数学证明中。

马丁-洛夫则作出了另一种选择:取 3) 而舍 2)。换言之, 不允许非直谓性的存在 。这一解决方式和罗素最初提出的分支类型论有着相同的根源,而它导致了另一条和构造演算大异其趣的道路,从而衍生的语言有 Agda、Epigram 和 Idris。我们定义一种新的类型,称之为 全类类型 (universe type):它是所有 其他类型 的类型。如此一来,类型这件事情就变成完全直谓性的了。但在某些时候,我们仍然需要提及“类型之类型”这样的说法,于是,我们约定一个类型的层级(hierarchy of types),或者说全类(universes,这个词也许翻译成“宇宙”更加贴切):当前宇宙( Type ,或者说0阶宇宙)的类型为一阶类型( Type 1 ),一阶类型宇宙的类型为二阶类型( Type 2 ),二阶类型宇宙的类型为三阶类型( Type 3 )……如此直至无穷。这种层级的递增关系具有 可累积性 (cumulativity):当 n<m 时,我们约定n阶类型的值同时也具备m阶类型,即低阶类型可以具有比它本身更高阶的类型;而其他情况下则不允许。这就有效地避开了吉拉德悖论带来的矛盾。

在 Idris 的 REPL 中,你可以看到, Type 的类型并非 Type ,而是比它高一阶的Type 1 :

*universe> :t Type
Type : Type 1

虽然现在很多实用的编程语言简单地把“类型的类型”视作其自身,如 Python 里 type(type) 返回 <type 'type'> ,Ruby 里 Class.class 返回 Class ,现在你知道,从“类型即逻辑”的角度来看,这种设计并不是正确的。

我们知道, Type 已经是类型层级中最低的阶,依据可累积性的原则,无法找到一个类型具有比它更低的阶;因此,在 Idris 中,这样的函数声明是不被允许的,它将导致一个编译器错误“Universe inconsistency”:(这个例子来自于 Programming in Idris: A Tutorial )

myid : (a : Type) -> a -> a
myid _ x = x

idid : (a : Type) -> a -> a
idid = myid _ myid

 

* * *

 

如果你既非数学家也非逻辑学家,上面这个简短的对于类型论的历史回顾也许枯燥乏味,无法对你有所触动。我想表达的意思究竟是什么呢?一、一个逻辑体系的自恰性,在任何时候都是重要的;我们不能掩耳盗铃地说“集合论中的罗素悖论是一个人造的问题,它原来根本不存在,不需要我们去解决它。”同样,我们也不能忽视计算机背后数理逻辑体系的一致与自恰性,而仅仅满足于能够“实际编程”这件事本身。二、就我们在此讨论的主题——对于一门具备依赖类型、作为证明辅助工具被设计出来的程序语言来说,其类型系统看似强大的图灵完备能力实际上是有害的——因为无法确定类型检查是否停机,意味着无法知道你的证明是否能完成!这就破坏了证明这件严肃的事情的 可靠性 。

综上所述,为了将数学的公理化体系容纳于计算机之中,乃至于构造出更多的定理,我们需要在扩充程序语言的类型系统、使其表达力更为丰富的同时,阉割另一些方面的能力,使其本身不如通用语言般“图灵完备”。唯有这样,才能达成我们的目的。由此,引入了 totality,和所谓 完全函数式编程 ( total functional programming )的概念:

  1. 所有的函数必须被完整地实现,即针对每一个可能的值都有定义;
  2. 递归必终止(对应到命令式语言中,则循环必终止);编译器有权拒绝一切它无法判断是否停机的计算。

你已经看到,这两点对于一个计算机证明工具来说是至关重要的。你还将看到,实际软件开发中那些永不停机的程序,是如何被自恰地容纳到这个体系当中的。

正如 Haskell 引入 purely functional 的概念,消除了副作用对于实际计算的干扰,却借 Monad 之力仍可实现非纯操作、而不失任何实际编程能力一般;完全函数式编程引入 total functional 的概念,消除了图灵完备所招致的停机不可判定问题,却借 Monad 之力仍可达到图灵完备,而不失任何实际用于软件开发的可能性。

 

* * *

 

参考链接

§4 程序非证明

 

想象这样的一个世界:逻辑、编程和数学得到了完全的统一,每一个证明对应着一个程序,每一个程序对应着一个函数映射,而每一个函数映射对应着一个证明!想象这样的一个世界,代码即是数学,推理过程和程序执行之间毫无隔阂,而数学的语言和计算的语言亦别无二致。

——Robert Harper《圣三位一体》

 

ML 程序员常常会对 Haskell 默认情况下不检查函数定义完整性这件事感到 无法理解 。即使是在现实的编程中,确保所有函数被完全地定义也是一件重要的事情:和类型安全同样的道理,尽可能地把缺陷扼杀在编译时;一个 missing case 可能会导致运行时出现无法预料的错误。

Idris 是种深受 Haskell 影响的语言。但正如许多人所见,一种能够被用于形式化证明的语言,默认行为不是 total 应该是不科学的,它导致我们足以完成上一节提到的那个关于 ∀n∈ℕ, 1≤n 的错误证明。至于它为何这样设计,这里有一个 冗长的解释 ,在此略过。在 Idris 里,我们可以直接调用编译器选项--total ,或使用开关

%default total

或直接声明某个单独的函数定义为 total:

total
wtf : (n : Nat) -> lte (S Z) n = True
wtf (S k) = refl

来达到让编译器执行 totality 检查的目的,从而让 Idris 的类型检查具有和 Agda 相仿的行为:只允许 total 函数的存在。当然,上面这个函数将不再通过类型检查,因为它是一个实现不完全的函数,从而无法完成我们要的证明。

检查函数定义针对所有的值都万无一失,这通过类型系统很容易做到。但我们前面提到过,这只是 totality 的第一个要求;第二个要求,是确保递归函数一定终止。

我们已经清楚地知道,不存在一种通用的算法能够判定一个图灵机是否停机;因此,没有人能够为他们的编译器写出这样的 termination checker:它总是准确无误地判断任意递归函数终止与否,然后选择接受或拒绝它们。

为了维持最小权限原则(principle of least privilege),现有的 termination checker 应当只允许那些“可以被确定”会终止的程序运行;而拒绝一切它无法判定是否终止的程序。这样带来的结果就是,一些完全可计算的( computable )递归函数( recursive function ),可能将无法作为 total 函数被实现。一个具有代表性的例子是 McCarthy 91函数 :

 

M ( n ) = { n − 10 , i f n > 100 M ( M ( n + 11 ) ) , i f n ≤ 100

 

它用 Idris 语言可以写成:

total
f91 : (n : Nat) -> Nat
f91 n with (n > 100)
    | True = n - 10
    | False = f91 (f91 (n + 11))

试图将它作为 total 函数去实现,将无法通过终止性检查:

  Main.f91 is possibly not total due to: with block in Main.f91

当然,如果明确地标记该函数为 partial(这是 Idris 的默认行为),程序就可以编译通过,并像一种正常的程序语言一样执行对该函数的计算了(这是 Idris 比起其它同类语言如 Agda 和 Coq 的便利之处所在:要它 total ,它就 total;要它 partial ,它就 partial)。

(注意,在这里,我们仅仅用 “partial” 这个词来表达“non-total”的含义:它和程序语言中其它语境下所说的 partial,诸如“partial application”“partial evaluation”并无直接关联。)

partial
f91 : (n : Nat) -> Nat
f91 n with (n > 100)
    | True = n - 10
    | False = f91 (f91 (n + 11))

这件事揭示了什么道理呢?一方面,由于我们醉心于“程序即证明”的美好愿景,不愿放弃让一切程序皆具有 totality 的努力,从而寄希望于 Coq 或 Agda 这种近乎完美的纯理论语言。另一方面,我们又必须承认 Idris 在函数定义中允许 partial 是非常实用的,因为即使是在纯计算范畴内,也存在无限的不可预知的可计算函数(诸如 McCarthy 91),以图灵非完备语言的能力不足以判断它的可计算性,更是无从计算;更毋论在实用编程范畴内,那些注定不会停机的程序。甚至于,你想过没有,如果我们写不了一个无限执行不停机的程序,我们甚至都没有方法去读取计算机的标准输入、读写一个文件,因为没有人会在写程序的时候确切地知道,你的程序所要处理的数据有多少,有限还是无限。看起来,“图灵非完备”与“实用编程”这两个目标是相互矛盾、格格不入的:实际的纯计算也好,以副作用为主的软件也好,无不充斥着“无法判定停机与否”的例子。追求“一切可证明”,看似抹杀了计算机完成许多有用计算的能力。

真正精彩的地方,这里才正要开始。

从现在起,我们将把 Idris 当作一门完全 total 的语言来使用,从而保持“程序如证明”的纯洁性(因为你一旦在语言引入了 partial 的函数定义,可能就会破坏这个目标);我们将看到,一种图灵非完备的 total functional 语言是怎样实现非终止性的递归、完成一次不停机的计算、乃至于模拟一个图灵完备的计算模型的。

注意,后文将要阐述的概念大部分来自于 Haskell、Coq、Agda 和其它一些静类型语言研究者的发明;而改用 Idris 实现,纯粹是为了对比展示 partial 编程和 total 编程的差别。实际上,我认为用 Agda 实现看起来会更酷,因为众所周知,Agda 是一个图灵非完备的语言,它只允许以 total functional 的方式编程;用它来模拟一个图灵完备的机器,无论如何都是一件很有成就感的事情。那么,这个练习就留给感兴趣的读者。

 

* * *

 

最初,程序语言专家把范畴论中的 Monad 这种较为费解的概念引入了计算机科学,得到了 Monad 这种抽象的数据构造,用它可描述一切计算:甚至包括那些对于纯计算来说具有副作用的操作,如 I/O,状态,在 Monad 中均可以轻易模拟得到。Haskell 语言首先实现了这个类型,并用它来完成一切带副作用的操作;形式上,它可以被简单地规约到纯函数式的类型推演之中,故而 Haskell 自称为是一门“纯函数式”的语言。尽管 Monad 可以在任何一种支持函数式编程的语言中实现,但 ML 和 Lisp 程序员通常对它不屑一顾。

不难想象,如果没有 do notation 语法糖等类似语言特性的支持,用 Monad 书写的程序会是非常丑陋的。所以,程序语言基本上可以分成两种:要么从一开始就模仿 Haskell,提供对 Monad 的原生支持,然后自称为纯函数式语言;要么从一开始就做成非纯语言,后来虽然有人给它实现了 Monad,但却无人问津。

关于 Monad,一种常见的误解是“Monad 就是副作用”。这句话只是部分的事实:诚然,Haskell 中副作用的实现是依 Monad 而存在的,但这只是表象;Monad 的意义远不止于此。

如果你是 Haskell 程序员,你平时接触得最多的可能会是 IO、State、Maybe、List 这些 Monad,因为它们最有用;如果你是 ML 程序员,你可能会在看过这篇文章 后尝试去用 ML 实现一个 Monad,然后把它扔在一边;因为这些 Monad 对于 ML 语言来说都不是有用的,ML 本身的语言特性已经足以完成和它们对等的功能。

现在即将引入的这种 Monad,对于 Haskell 语言来说不是有用的;但对于一门图灵非完备的 total functional 语言来说却是极其有用的,它叫做 Partial Monad。顾名思义,用它可以来模拟无法被 total 函数实现的 partial 计算。显而易见,Haskell 和 Idris 并不需要它,因为它们本身已经是图灵完备的语言;但从理论验证的角度来看,通过把 Idris 的语言能力限制在 total 计算以内,然后介入 Partial Monad,这便成了一次有趣的尝试,也可以为将来设计一种“实用的” total functional 编程语言提供实践基础。

简而言之,一个 Partial Monad 是一个无限的抽象数据类型。在 Idris 中,可以用 codata 这种 共归纳类型 (coinductive type)来表示这样无限的数据结构,它的定义如下:

codata Partial a = Later (Partial a) | Now a

Idris 中,一个 codata 是一个被惰性求值的数据结构。对于 Partial Monad 而言,还需要一些最基本的定义:

instance Functor Partial where
  map f (Now x) = Now (f x)
  map f (Later xs) = Later (map f xs)

instance Applicative Partial where
  pure = Now

  f <$> (Later xs) = Later (f <$> xs)
  (Now f) <$> (Now x) = Now (f x)
  (Later fs) <$> x = Later (fs <$> x)

instance Monad Partial where
  (Now a) >>= k = k a
  (Later d) >>= k = Later (d >>= k)

这个 Partial Monad 的意义何在呢?用简单的话讲,就是:每执行一次计算,我要么证明它是有生产率(productive)的( Later ),要么就让它终止计算得到最终结果( Now )。

“生产率(productivity)”的概念对于 total functional 编程来说非常重要。这么做意味着你可以读取和输出无限的 stream(比如文本文件),对每一个数据执行可验证的计算,而不必担心“图灵完备”这件事情破坏了计算的可靠性:因为你知道,你的真实计算(或证明)本身是图灵非完备的,它必然停机给出结果;而把每一次这样的计算放到一个 Partial Monad 中,你又具有了不停机持续完成计算的能力。同理,你可以实现一个操作系统、一个 Web server,它们的每一步纯计算都是 proof-carrying 的可验证代码;然而作为一个保持生产率的整体,它总是源源不断地向外界贡献它的计算能力。

当然,意义这件事情本是人为赋予的。一个 Web server 挂在那里运行,即使什么计算也不做也不停机,它分分钟都是有贡献的(我想没有人能否认这一点)。同样,你可以认为一个递归函数每当完成一步计算,就算是为科学界贡献了一点东西,比如又把圆周率的计算推进了若干位;你也可以认为把一个不知道是否停机的证明持续验证下去是一件毫无生产率的事情。假设我不关心前面所说的 McCarthy 91 函数是否停机,我认为它每做了一步递归,就是有贡献的,至少它成功地让我的机器发热了;那么,把计算放到 Partial Monad 里实现,我们就得到了 total 版本的 McCarthy 91 函数实现:

total
f91' : Partial Nat -> Partial Nat
f91' (Later p) = Later (f91' p)
f91' (Now n) with (n > 100)
     | True = Now (n - 10)
     | False = Later (f91' (Later (f91' (Now (n + 11)))))

注意它作为一个整体的实现,仍然是 total 的:你不能让它任意地去调用另一个 partial 函数,或者直接递归调用自身而不通过 Partial Monad 来保障其生产率;类型系统将会阻止你这么去做。于是,我们所使用的语言特性被限制在了 total functional 的范围内,却实现了通常只有图灵完备语言才能实现的计算。理解这一点是一步质的飞跃:从“ 我不确定是否停机 ”的、处于混沌状态的图灵完备编程,到“ 我确信它一定会停机 ”的计算和“ 我知道什么是有用的不停机 ”的计算严格区分的图灵非完备编程。

所以,图灵完备计算有哪些是图灵非完备计算所做不到的?这就是我的答案: 你既不能确定它是否停机、又不能确定它有没有用的。

Total functional 是对“程序如证明”这件事情正确性的保证:你无法写出一个不终止的递归函数,也就无法写出任何逻辑上不自恰的证明。Partial Monad 严格地将需要被保障正确性的程序逻辑和“有用的”不停机的计算过程分隔开,你需要显式地告诉类型系统:这段程序虽然我无法知道它会否停机,但我保证它一定是有用的!它在不停地计算下一位圆周率,或者在跑一个网站,或者甚至就是我服务器上运行的操作系统,直到宕机之前我都不打算让它停下来;如此种种。

 

* * *

 

现在,我们终于可以回到之前在第2章里提出的问题上了:

 

能否以图灵非完备的编程方式,去实现一个图灵完备语言的解释器呢?

 

在理解了“程序即证明”和 total functional 编程的意义之后,你会发现:“用图灵非完备语言来实现图灵完备语言”这件事本身,已经不足以成为我们的目的了。因为以图灵非完备的能力,我们仍可实现任何已知的有用的程序;更重要的是,它可以通过类型和逻辑的同构性,借助类型检查确实保证程序实现的正确性——假如我们认真严肃地对待“软件功能性规范”这件事的话。当然,若是能用它来实现一个图灵完备的计算模型,也许更容易让人信服它的“有用性”。

接下来,我们将以 total functional 的方式在 Idris 中实现一个基本的 无类型λ演算 解释器,而它是图灵完备的。它的核心实现不过13行,加之以数十行辅助的类型定义。比起废话连篇,实际的编程总是要简单许多!

 

* * *

 

Idris 设计的初衷之一是用于构建可验证的嵌入式领域专有语言, 这里 介绍了一个对简单类型λ演算 (simply typed lambda calculus)的解释器实现( 视频 )。注意到简单类型λ演算并不是图灵完备的,它的主要作用体现在函数式语言的类型系统实现上。而我们在这里将要实现的是 无类型λ演算 (untyped lambda calculus),它可以作为一个图灵完备语言的基础。

本文探讨的主要话题是“程序和证明”,故其篇幅不足以容纳一篇解释器的入门教程。关于怎样实现无类型λ演算的基础知识,请参见 Matthew Might 的 博客 或王垠的 博客;注意这两篇文章里的实现用的是 Scheme 或 Racket。实际上无类型λ演算解释器的基本构造都是大同小异的,因为它实在太简单了。

这个在 Literate Idris 里用 total functional 方式实现的无类型λ演算解释器如下:

导入标准库里的 Data.SortedMap 数据类型和之前定义的 Partial Monad
> import Data.SortedMap
> import Partial

默认使用 total
> %default total

λ演算里表示名字的符号类型
> data Symbol = Symbol_ String

表达式的定义:一个变量,一个λ,或一个函数应用
> data Expr = Var Symbol
>           | Lambda Symbol Expr
>           | App Expr Expr

函数的定义:一个表达式
> data Function = Def Expr

Show instance
> instance Show Symbol where
>          show (Symbol_ str) = str

> instance Show Expr where
>          show (Var sym) = show sym
>          show (Lambda sym expr) = "\\" ++ show sym ++ "." ++ show expr
>          show (App expr1 expr2) = show expr1 ++ " " ++ show expr2

> instance Show Function where
>          show (Def expr) = show expr

> mutual

环境的定义:一个将 String 映射到 Value 的记忆体(使用 SortedMap 实现)
>   data Environment = Memory (SortedMap String Value)

值的定义:一个包含函数和环境的闭包,或一个异常(错误)
>   data Value = Closure Function Environment
>              | Exception String

Show instance
> instance Show Value where
>          show (Closure fun env) = show fun
>          show (Exception str) = "Exception: " ++ str

> mutual

在环境中解释表达式(即执行求值)
>   ||| Evaluate an expresssion in an environment.
>   ||| @ expr the expresssion
>   ||| @ env  the environment
>   total
>   interp : (expr : Expr) -> (env : Environment) -> Partial Value
对于一个变量,直接从记忆体中返回其名字对应的值;若不存在,则返回一个异常
>   interp (Var sym) (Memory m) with (lookup (show sym) m)
>          | Just val = Now val
>          | Nothing  = Now (Exception "undefined symbol")
对于一个λ,返回一个由其代表的函数和当前环境组成的闭包
>   interp (Lambda sym expr) env = Now (Closure (Def (Lambda sym expr)) env)
对于一个函数应用,对前表达式和后表达式在当前环境下进行求值,并执行应用(有生产率的递归)
>   interp (App expr1 expr2) env = Later (app (interp expr1 env) (interp expr2 env))

将一个值应用到另一个值(即函数应用)
>   ||| Apply a function to its argument.
>   ||| @ f the function
>   ||| @ a the argument
>   total
>   app : (f : Partial Value) -> (a : Partial Value) -> Partial Value
应用被延迟(有生产率的递归)
>   app (Later f') a = Later (app f' a)
将值代入记忆体,并解释表达式
>   app (Now (Closure (Def (Lambda sym expr)) (Memory m))) a = interp expr (Memory (insert (show sym) !a m))
返回一个异常
>   app (Now (Exception str)) a = Now (Exception str)
其它情况下不合法(构造错误的闭包),返回一个异常
>   app (Now _)a = Now (Exception "malformed closure")

初始环境
> ||| Initial environment.
> env0 : Environment
> env0 = Memory empty

至此,我们已经完成了这个无类型λ演算解释器的实现,可以用它来完成一些简单的示例了。

【例1】 归约:

 

$λx.x=λx.x$

 

定义:

expr1 : Expr
expr1 = Lambda 'x (Var 'x)

求值:

*UntypedLambdaTotal> interp expr1 env0
Now (Closure (Def (Lambda (Symbol_ "x") (Var (Symbol_ "x"))))
             (Memory Empty)) : Partial Value
*UntypedLambdaTotal> show $ interp expr1 env0
"\\x.x" : String

【例2】 归约:

 

$λx.x\ λa.a=λa.a$

 

定义:

expr2 : Expr
expr2 = App (Lambda 'x (Var 'x)) (Lambda 'a (Var 'a))

求值:

*UntypedLambdaTotal> show $ interp expr2 env0
"\\a.a" : String

【例3】 归约:

 

$((λf.λx.f\ x)\ λa.a)\ λb.b=λb.b$

 

定义:

expr3 : Expr
expr3 = App (App (Lambda 'f (Lambda 'x (App (Var 'f) (Var 'x))))
                 (Lambda 'a (Var 'a)))
            (Lambda 'b (Var 'b))

求值:

*UntypedLambdaTotal> show $ interp expr3 env0
"\\b.b" : String

【思考3】 能否归约下式?为什么?

 

$λx.(x\ x)\ λx.(x\ x)$

 

定义:

expr4 : Expr
expr4 = App (Lambda 'x (App (Var 'x) (Var 'x)))
            (Lambda 'x (App (Var 'x) (Var 'x)))

 

* * *

 

无类型λ演算,使用 Partial Monad 的完整版本代码:

Partial 版本的实现:(对比)

(以上代码在 Idris 0.9.14.2 中编译通过)

 

* * *

 

参考链接

§5 结语:未完成的 Q.E.D.

在本文的第1章 里,我们从对可计算性的讨论出发,浅析了程序语言实际的“编程能力”与计算模型理论上的“计算能力”之间的差别,进而展示了图灵完备不一定是有用的、图灵非完备不一定是无用的。

在第2章 里,我们则从对形式语言的讨论出发,探寻了语言彼此间存在的无限可能性:以语言翻译到语言,以语言来表达语言,乃至于以一个程序语言去实现另一个程序语言,并在最后提出了一个未决的问题:能否以图灵非完备的语言去实现一个图灵完备的语言,从而证明“图灵非完备”的有用性?

第3章 是关于“程序即证明”的一个初步而不完全的简介。通过对函数式编程类型系统与构造逻辑之间关系的窥探,我们浅显地展示了 Curry-Howard 同构性的一隅,回顾了类型论在罗素悖论带来的数学危机中临危受命而发展起来的历史、以及构造主义的直觉类型论所遭致的吉拉德悖论,并在最后指出:描述计算的图灵完备性与一个自恰逻辑体系的完备性之间互不相容,为了达成“程序即证明”的目的,我们需要尽可能地避开图灵完备性招致的停机问题不可判定所造成的逻辑怪圈;从而引出了后面 totality 的概念。

第4章 的主题,则从抽象的逻辑和数学证明回到了实用主义的编程。通过明确“停机的计算”和“有用的计算”,我们在图灵非完备的 total functional programming 中介入了 Partial Monad,并利用它实现了一个最简单的图灵完备解释器——来展示以如前所述的方式来完成“有用的”软件开发的无限可能性。

关于 total functional programming 这个刚刚开始起步的新兴领域,并没有太多前人的工作可供参考,而它在软件工程中的实用性还有待进一步考察。然而,它的本源——软件的形式化验证,却深深地根植于数学基础的研究,而以构造逻辑和直觉类型论为理论根基。这些数学和逻辑中的基础工作在经历了重重危机之后,本身也在不断地对自恰性和完备性的探求之中自我完善;它们必然将为可信的计算机软件开发提供新的思路,而同时亦与程序语言理论的研究相辅相成,拥有不可估量的前景。虽然它的可行性已经被初步证明,但其有用性、乃至于实用的方法学,尚有待于未来的人们来回答。

我不想把这篇文章积压得太久——过分追求完美主义的结果就是因拖延而无法完成任何事情,就像那些自己曾经雄心壮志地规划要做却永远也无法完成的项目一样。无论好坏,能做成一件事,至少也算是了却了一桩心愿;文中的纰漏或许在所难免,但无论文字也好、代码也好,唯有不断地练习方可日臻完善,若是什么都不去做,什么都不会往好的方向改变。那么,写到这里,既然已经弥补了自己从未写过有关“程序与证明”这方面内容的缺憾,我作本文的目的,也就达到了。

Q.E.D.


====================== End

 

 

 

 

 

 


来源: <http://www.tuicool.com/articles/YRZvem>

posted @ 2021-05-03 13:08  lsgxeva  阅读(539)  评论(0编辑  收藏  举报