聊聊编程范式

作者:张晓龙 ref

编程范式一词最早来自 Robert Floyd 在 1979 年图灵奖的颁奖演说,是程序员看待程序应该具有的观点,代表了程序设计者认为程序应该如何被构建和执行的看法,与软件建模方式和架构风格有紧密关系。

现在主流的编程范式有三种:

这几种编程范式之间的关系如下:

https://pic4.zhimg.com/80/v2-1c98d68cc3e3a15d46916aaad253b003_720w.jpg

如果你对上图中编程范式之间的关系已理解得非常透彻,那就没有必要再往下看了,否则建议耐心看完本文,在过程中可以跳过熟悉的章节。

众所周知,计算机运行在图灵机模型之上。最初,程序员通过纸带将指令和数据输入到计算机,计算机执行指令,完成计算。后来,程序员编写程序(包括指令和数据),将程序加载到计算机,计算机执行指令,完成计算。时至今日,软件已经非常复杂,规模也很大,人们通过软件来解决各个领域(Domain)的问题,比如通信,嵌入式,银行,保险,交通,社交,购物等。

https://pic1.zhimg.com/80/v2-835cf8e85fe5c0be60fa2f2b67fdad08_720w.jpg

人们把一个个具体的领域问题跑在图灵机模型上,然后做计算,而领域问题和图灵机模型之间有一个很大的 gap(What,How,Why),这是程序员主要发挥的场所。编程范式是程序员的思维底座,决定了设计元素和代码结构。程序员把领域问题映射到某个编程范式之上,然后通过编程语言来实现。显然,编程范式到图灵机模型的转化都由编译器来完成,同时这个思维底座越高,程序员做的就会越少。

你可能会有一个疑问:为什么会有多个编程范式?换句话说,就是程序员为什么需要多个思维底座,而不是一个?

思维底座取决于程序员看待世界的方式,和哲学及心理学都有关。程序员开发软件是把现实中的世界模拟到计算机中来运行,每个程序员在这个时候都相当于一个造物主,在计算机重新创造一个特定领域的世界,那么如何看待这个世界就有些哲学观的味道在里面。这个虚拟世界的最小构筑物是什么?每个构筑物之间的关系是什么?用什么方式把这个虚拟世界层累起来。随着科学技术的演进,人们看待世界的方式会发生变化,比如生物学已经演进到细胞,自然科学已经演进到原子,于是程序员模拟世界的思维底座也会发生变化。

程序员模拟的世界最终要跑在图灵机模型上,这就有经济学的要求,成本越小越好。资源在任何时候都是有限的,性能是有约束的,不同的编程范式有不同的优缺点,程序员在解决领域问题时需要有多个思维底座来进行权衡取舍,甚至融合。

为了能更深刻的理解编程范式,我们接下来一起回顾一下编程范式的简史。

编程范式简史

https://pic4.zhimg.com/80/v2-9cbd641ce23ab1e4464915dcb5d02067_720w.jpg

机器语言使用 0 和 1 组成的二进制序列来表达指令,非常晦涩难懂。汇编语言使用助记符来表达指令,虽然比机器语言进步了一些,但编写程序仍然是一件非常痛苦的事情。汇编语言可以通过汇编(编译)得到机器语言,机器语言可以通过反汇编得到汇编语言。汇编语言和机器语言一一对应,都是直接面向机器的低级语言,最贴近图灵机模型。

站在结构化编程的视角,机器语言和汇编语言也是有编程范式的,它们的编程范式就是非结构化编程。当时 goto 语句满天飞,程序及其难以维护。后来,大家对于 goto 语句是有害的达成了共识,就从编程语言设计上把 goto 语句拿掉了。

随着计算机技术的不断发展,人们开始寻求与机器无关且面向用户的高级语言。无论何种机型的计算机, 只要配备上相应高级语言的编译器,则用该高级语言编写的程序就可以运行。首先被广泛使用的高级语言是 Fortran,有效的降低了编程门槛,极大的提升了编程效率。后来 C 语言横空出世,它提供了对于计算机而言较为恰当的抽象,屏蔽了计算机硬件的诸多细节,是结构化编程语言典型代表。时至今日,C 语言依然被广泛使用。

当高级语言大行其道以后,人们开发的程序规模逐渐膨胀,这时如何组织程序变成了新的挑战。有一种语言搭着 C 语言的便车将面向对象的设计风格带入主流视野,这就是 C++,它完全兼容 C 语言。在很长一段时间内,C++ 风头十足,成为行业中最主流的编程语言。后来,计算机硬件的能力得到了大幅提升,Java 语言脱颖而出。Java 语言假设程序的代码空间是开放的,在 JVM 虚拟机上运行,一方面支持面向对象,另一方面支持 GC 功能。

不难看出,编程语言的发展就是一个逐步远离计算机硬件,向着待解决的领域问题靠近的过程。所以,编程语言后续的发展方向就是探索怎么更好的解决领域问题。

前面说的这些编程语言只是编程语言发展的主流路径,其实还有一条不那么主流的路径也一直在发展,那就是函数式编程语言,这方面的代表是 Lisp。首先,函数式编程的主要理论基础是 Lambda 演算,它是图灵完备的;其次,函数式编程是抽象代数思维,更加接近现代自然科学,使用一种形式化的方式来解释世界,通过公式来推导世界,极度抽象(比如 F=ma)。在这条路上,很多人都是偏学术风格的,他们关注解决方案是否优雅,如何一层层构建抽象。他们也探索更多的可能,垃圾回收机制就是从这里率先出来的。但函数式编程离图灵机模型太远了,在图灵机上的运行性能得不到直接的支撑,同时受限于当时硬件的性能,在很长一段时间内,这条路上的探索都只是学术圈玩得小众游戏,于是函数式编程在当时被认为是一个在工程上不成熟的编程范式。当硬件的性能不再成为阻碍,如何解决问题开始变得越来越重要时,函数式编程终于和编程语言发展的主流路径汇合了。促进函数式编程引起广泛重视还有一些其他因素,比如多核 CPU 和分布式计算。

编程范式是抽象的,编程语言是具体的。编程范式是编程语言背后的思想,要通过编程语言来体现。编程范式的世界观体现在编程语言的核心概念中,编程范式的方法论体现在编程语言的表达机制中,一种编程语言的语法和风格与其所支持的编程范式密切相关。虽然编程语言和编程范式是多对多的关系,但每一种编程语言都有自己的主流编程范式。比如,C 语言的主流编程范式是结构化编程,而 Java 语言的主流编程范式是面向对象编程。程序员可以打破“次元壁”,将不同编程范式中的优秀元素吸纳过来,比如在 linux 内核代码设计中,就将对象元素吸纳了过来。无论在以结构化编程为主的语言中引入面向对象编程,还是在以面向对象编程为主的语言中引入函数式编程,在一个程序中应用多范式已经成为一个越来越明显的趋势。不仅仅在设计中,越来越多的编程语言逐步将不同编程范式的内容融合起来。C++ 从 C++ 11 开始支持 Lambda 表达式,Java 从 Java 8 开始支持 Lambda 表达式,同时新诞生的语言一开始就支持多范式,比如 Scala,Go 和 Rust 等。

从结构化编程到面向对象编程,再到函数式编程,离图灵机模型越来越远,但抽象程度越来越高,与领域问题的距离越来越近。

结构化编程

结构化编程,也称作过程式编程,或面向过程编程。

基本设计

在使用低级语言编程的年代,程序员站在直接使用指令的角度去思考,习惯按照自己的逻辑去写,指令之间可能共享数据,这其中最方便的写法就是需要用到哪块逻辑就 goto 过去执行一段代码,然后再 goto 到另外一个地方。当代码规模比较大时,就难以维护了,这种编程方式便是非结构化编程。

https://pic4.zhimg.com/80/v2-cac2780a45eef6f4ae12c8394e03544f_720w.jpg

迪克斯特拉(E.W.dijkstra)在 1969 年提出结构化编程,摒弃了 goto 语句,而以模块化设计为中心,将待开发的软件系统划分为若干个相互独立的模块,这样使完成每一个模块的工作变得单纯而明确,为设计一些较大的软件打下了良好的基础。按照结构化编程的观点,任何算法功能都可以通过三种基本程序结构(顺序、选择和循环)的组合来实现。

结构化编程主要表现在以下三个方面:

结构化程序设计是用计算机的思维方式去处理问题,将数据结构和算法分离(程序 = 数据结构 + 算法)。数据结构描述待处理数据的组织形式,而算法描述具体的操作过程。我们用过程函数把这些算法一步一步的实现,使用的时候一个一个的依次调用就可以了。

在三种主流的编程范式中,结构化编程离图灵机模型最近。人们学习编程的时候,大多数都是从结构化编程开始。按照结构化编程在做设计时,也是按照指令和状态(数据)两个纬度来考虑。在指令方面,先分解过程 Procedure,然后通过 Procedure 之间的一系列关系来构建整个计算,对应算法(流程图)设计。在状态方面,将实例数据都以全局变量的形式放在模块的静态数据区,对应数据结构设计。

https://pic2.zhimg.com/80/v2-0670ce8ba797829ef0f507b5ffd09951_720w.jpg

架构风格

结构化编程一般偏底层,一般适用于追求确定性和性能的系统软件。这类软件偏静态规划,需求变化也不频繁,适合多人并行协作开发。将软件先分完层和模块,然后再确定模块间的 API,接着各组就可以同时启动开发。各组进行数据结构设计和算法流程设计,并在规定的时间内进行集成交付。分层模块化架构支撑了软件的大规模并行开发,且偏静态规划式开发交付。层与层之间限定了依赖方向,即层只能向下依赖,但同层内模块之间的依赖却无法约束,经常会出现模块之间互相依赖的情况,导致可裁剪性和可复用性过粗,响应变化能力较弱。

https://pic4.zhimg.com/80/v2-ff0e018db091b1331a0c601375c983d3_720w.jpg

 

结构化编程的优点:

结构化编程的缺点:

刚才在优点中提到,结构化编程贴近图灵机模型,可以充分调动硬件,控制性强。为什么我们需要这个控制性?你可能做过嵌入式系统的性能优化,你肯定知道控制性是多么重要。你可能要优化版本的二进制大小,也可能要优化版本的内存占用,还有可能要优化版本的运行时效率,这时你如果站在硬件怎么运行的最佳状态来思考优化方法,那么与图灵机模型的 gap 就非常小,则很容易找到较好的优化方法来实施较强的控制性,否则中间有很多抽象层,则很难找到较好的优化方法。

除过性能,确定性对于系统软件来说也很重要。对于 5G,系统要求端到端时延不超过 1ms,我们不能 80% 的情况时延是 0.5ms,而 20% 的情况时延却是 2ms。卖出一个硬件,给客户承诺可以支持 2000 用户,我们不能 80% 的情况可以支持 3000 用户,而 20% 的情况仅支持 1000 用户。静态规划性在某些系统软件中是极度追求的,这种确定性需要对底层的图灵机模型做很好的静态分解,然后把我们的程序从内存到指令和数据一点点映射下去。因为结构化编程离图灵机模型较近,所以映射的 gap 比较小,容易通过静态规划达成这种确定性。

面向对象编程

随着软件种类的不断增多,软件规模的不断膨胀,人们希望可以更小粒度的对软件进行复用和裁剪。

基本设计

将全局数据拆开,并将数据与其紧密耦合的方法放在一个逻辑边界内,这个逻辑边界就是对象。用户只能访问对象的 public 方法,而看不到对象内部的数据。对象将数据和方法天然的封装在一个逻辑边界内,可以整体直接复用而不用做任何裁剪或隐式关联。

https://pic3.zhimg.com/80/v2-85dd8d2e0a042b899e4fa4e8fdb9988e_720w.jpg

人们将领域问题又开始映射成实体及关系(程序 = 实体 + 关系),而不再是数据结构和算法(过程)了,这就是面向对象编程,核心特点是封装、继承和多态。

封装是面向对象的根基,它将紧密相关的信息放在一起,形成一个逻辑单元。我们要隐藏数据,基于行为进行封装,最小化接口,不要暴露实现细节。

继承分为两种,即实现继承和接口继承。实现继承是站在子类的视角看问题,而接口继承是站在父类的视角看问题。很多程序员把实现继承当作一种代码复用的方式,但这并不是一种好的代码复用方式,推荐使用组合。

对于面向对象而言,多态至关重要,接口继承是常见的一种多态的实现方式。正因为多态的存在,软件设计才有了更大的弹性,能够更好地适应未来的变化。只使用封装和继承的编程方式,我们称之为基于对象编程,而只有把多态加进来,才能称之为面向对象编程。可以这么说,面向对象设计的核心就是多态的设计。

面向对象建模

面向对象编程诞生后,程序员需要从领域问题映射到实体和关系这种模型,后续再映射到图灵机模型就交给面向对象编程语言的编译器来完成。于是问题来了,领域千差万别,如何能将领域问题高效简洁的映射到实体和关系?这时 UML(Unified Model Language,统一建模语言)应运而生,是由一整套图表组成的标准化建模语言。可见,面向对象极大的推进了软件建模的发展。

https://pic4.zhimg.com/80/v2-1b904ed06eb65b28c55ae5d54a9e793f_720w.jpg

现在有一些新的程序员对于 UML 不太熟悉,建议至少要掌握两个 UML 图,即类图和序列图:

软件设计一般从动态图开始,在动态交互中会把相对比较固定的模式下沉到静态视图里,然后形成类和结构。在看代码的时候,通过类和结构就知道一部分对象和交互的信息了,可以约束及校验对象和交互的关系。

面向对象建模一般分为四个步骤:

在 OOA 阶段,分析师产出分析模型。同理,在 OOD 阶段,设计师产出设计模型。

https://pic1.zhimg.com/80/v2-85e1252153cd83004d51ea34bc6a31c8_720w.jpg

分析模型和设计模型的分离,会导致分析师头脑中的业务模型和设计师头脑中的业务模型不一致,通常要映射一下。伴随着重构和 fix bug 的进行,设计模型不断演进,和分析模型的差异越来越大。有些时候,分析师站在分析模型的角度认为某个需求较容易实现,而设计师站在设计模型的角度认为该需求较难实现,那么双方都很难理解对方的模型。长此以往,在分析模型和设计模型之间就会存在致命的隔阂,从任何活动中获得的知识都无法提供给另一方。

Eric Evans 在 2004 年出版了 DDD(领域驱动设计, Domain-Driven Design)的开山之作《领域驱动设计——软件核心复杂性应对之道》,抛弃将分析模型与设计模型分离的做法,寻找单个模型来满足两方面的要求,这就是领域模型。许多系统的真正复杂之处不在于技术,而在于领域本身,在于业务用户及其执行的业务活动。如果在设计时没有获得对领域的深刻理解,没有将复杂的领域逻辑以模型的形式清晰地表达出来,那么无论我们使用多么先进多么流行的平台和基础设施,都难以保证项目的真正成功。

DDD 是对面向对象建模的演进,核心是建立正确的领域模型:

https://pic3.zhimg.com/80/v2-fd6fb65cfd7456b3d16be9770b5df1ca_720w.jpg

DDD 的精髓是对边界的划分和控制,共有四重边界:

设计原则与模式

设计原则很多,程序员最常使用的是 SOLID 原则,它是一套比较成体系的设计原则。它不仅可以指导我们设计模块(类),还可以被当作一把尺子,来衡量我们设计的有效性。

SOLID 原则是五个设计原则首字母的缩写,它们分别是:

前面我们提到,对于面向对象来说,核心是多态的设计,我们看看 SOLID 原则如何指导多态设计:

除过设计原则,我们还要掌握常用的设计模式。设计模式是针对一些普遍存在的问题给出的特定解决方案,使面向对象的设计更加灵活和优雅,从而复用性更好。学习设计模式不仅仅要学习代码怎么写,更重要的是要了解模式的应用场景。不论那种设计模式,其背后都隐藏着一些“永恒的真理”,这个真理就是设计原则。的确,还有什么比原则更重要呢?就像人的世界观和人生观一样,那才是支配你一切行为的根本。可以说,设计原则是设计模式的灵魂。

守破离是武术中一种渐进的学习方法:

设计模式的学习也是一个守破离的过程:

架构风格

面向对象设计大行其道以后,组件化或服务化架构风格开始流行起来。组件化或服务化架构风格参考了对象设计:对象有生命周期,是一个逻辑边界,对外提供 API;组件或服务也有生命周期,也是一个逻辑边界,也对外提供 API。在这种架构中,应用依赖导致原则,不论高层还是低层都依赖于抽象,好像整个分层架构被推平了,没有了上下层的关系。不同的客户通过“平等”的方式与系统交互,需要新的客户吗?不是问题,只需要添加一个新的适配器将客户输入转化成能被系统 API 所理解的参数就行。同时,对于每种特定的输出,都有一个新建的适配器负责完成相应的转化功能。

https://pic4.zhimg.com/80/v2-ebfeac9d2a36f571c2276483a5f215b3_720w.jpg

面向对象编程的优点:

面向对象编程的缺点:

函数式编程

与结构化编程与面向对象编程不同,函数式编程对很多人来说要陌生一些。你可能知道,C++ 和 Java 已经引入了 Lambda 表达式,目的就是为了支持函数式编程。函数式编程中的函数不是结构化编程中的函数,而是数学中的函数,结构化编程中的函数是一个过程(Procedure)。

基本设计

函数式编程的起源是数学家 Alonzo Church 发明的 Lambda 演算(Lambda calculus,也写作 λ-calculus)。所以,Lambda 这个词在函数式编程中经常出现,你可以把它简单地理解成匿名函数。

https://pic2.zhimg.com/80/v2-20ca1ac924fc2a0fc076b4943b929125_720w.jpg

函数式编程有很多特点:

函数式编程还有两个重要概念:高阶函数和闭包。所谓高阶函数,是指一种比较特殊的函数,它们可以接收函数作为输入,或者返回一个函数作为输出。闭包是由函数及其相关的引用环境组合而成的实体,即闭包 = 函数 + 引用环境。

闭包有独立生命周期,能捕获上下文(环境)。站在面向对象编程的角度,闭包就是只有一个接口(方法)的对象,即将单一职责原则做到了极致。可见,闭包的设计粒度更小,创建成本更低,很容易做组合式设计。在面向对象编程中,设计粒度是一个 Object,它可能还需要拆,但你可能已经没有意识再去拆,那么上帝类大对象就会存在了,创建成本高。在函数式编程中,闭包给你一个更精细化设计的能力,一次就可以设计出单一接口的有独立生命周期的可以捕获上下文的原子对象,天然就是易于组合易于重用的,并且是易于应对变化的。

有一句话说的很好:闭包是穷人的对象,对象是穷人的闭包。有的语言没有闭包,你没有办法,只能拿对象去模拟闭包。又有一些语言没有对象,但单一接口不能完整表达一个业务概念,你没有办法,只能将多个闭包组合在一起当作对象用。

对于函数式编程,数据是不可变的,所以一般只能通过模式匹配和递归来完成图灵计算。当程序员选择将函数式编程作为思维底座时,就需要解决如何将领域问题映射到数据和函数(程序 = 数据 + 函数)。

函数式设计的思路就是高阶函数与组合,背后是抽象代数那一套逻辑。下面这张图是关于高阶函数的,左边是将函数作为输入,右边是将函数作为输出:

https://pic4.zhimg.com/80/v2-3f6404152b966b299a9b2bd3af82294f_720w.jpg

对于将函数作为输入的高阶函数,就是面向对象的策略模式。对于将函数作为输出的高阶函数,就是面向对象的工厂模式。每个高阶函数都是职责单一的,所以函数式设计是以原子的方式通过策略模式和工厂模式来组合类似面向对象的一切。在这个过程中,到底哪些函数作为入参,哪些函数作为返回值,然后这些返回值函数再传给哪些函数,接着再返回哪些函数......,你发现你在套公式,通过公式的层层嵌套完成一个算法的描述,所以核心就是设计有哪些高阶函数以及它们的组合规则,这是函数式设计中最难的,就是抽象代数的部分。可见,函数式设计的基本方法为:借助闭包的单一接口的标准化和高阶函数的可组合性,通过规则串联设计,完成数据从源到结果的映射描述。这里的映射是通过多个高阶函数的形式化组合完成,描述就像写数学公式一样放在那,等源数据从一头传入,然后经过层层函数公式的处理,最后变成你想要的结果。数据在形式化转移的过程中,不仅仅包括数据本身,还包括规则的创建、返回和传递。

架构风格

前面我们讲过,函数式编程引起人们重视的因素包括硬件性能提升,多核 CPU 和分布式计算等。函数式编程的一些特点,使得并发程序更容易写了。一些架构风格,尤其是分布式系统的架构风格,借鉴了函数式的特点,使得系统的扩展性和弹性变得更容易。

函数式编程的建模方式是抽象代数,在上面层累出两类架构风格:

(1)Event Sourcing,Reative Achitecture

https://pic1.zhimg.com/80/v2-91d5257e5770c40e584a2146744fc060_720w.jpg

(2)Lambda Achitecture,FaaS,Serverless

https://pic2.zhimg.com/80/v2-0cbec1bdccb9230d846a42e55b5ffd2d_720w.jpg

借鉴函数式编程的理念,分布式系统的架构风格,在架构层面完成更高抽象力度的表达,在并发层面完成更好的弹性和可靠性。

函数式编程的优点:

函数式编程的缺点:

小结

作为一个程序员,我们应该清楚每种编程范式的适用场景,在特定的场景下选择合适的范式来恰当的解决问题。

多范式融合的设计建议:

最后,我们重新看看开始的那张编程范式之间的关系图:

https://pic4.zhimg.com/80/v2-1c98d68cc3e3a15d46916aaad253b003_720w.jpg

说明如下。

最早是非结构化编程,指令可以随便跳,数据可以随便引用。后来有了结构化编程,人们把 goto 语句去掉了,约束了指令的方向性,过程之间是单向的,但数据却是可以全局访问的。再到面向对象编程的时候,人们干脆将数据与其紧密耦合的方法放在一个逻辑边界内,约束了数据的作用域,靠关系来查找。最后到函数式编程的时候,人们约束了数据的可变性,通过一系列函数的组合来描述数据从源到目标的映射规则的编排,在中间它是无状态的。可见,从左边到右边,是一路约束的过程。

越往左边限制越少,越贴近图灵机模型,可以充分调动硬件,“直接”带来的可控性及广域适用性。对于可控性,因为离图灵机模型很近,可以按自己的想法来“直接”控制。对于广域适用性,因为约束越多,说明门槛越高,一旦右边搞不定,可以往回退一步,当你找到合理的对象模型或抽象代数模型时,可以再往前走一步。

越往右边限制越多,通过约束建立规则,通过规则描述系统,“抽象”带来的定域扩展性。对于定域,因为这种“抽象”一定是面向某一个狭窄的切面,找到的对象模型或抽象代数模型会有很强的扩展性和可理解性,但一旦超过这个范围,模型可能就无效了,所以 DDD 一直在强调分离子域、划分 BC 和分层架构。

参考资料

如何证明一个程序是正确的? | 结构化编程范式

杨锐

自从1945年,Alan Turing在真实的计算机上编写了第一个真正的计算机程序起,编程领域已经发生了许多次革命性的变革。其中,广为人知的是编程语言的演变。今天的主题则是另一个可能更重要的变革,也就是编程范式(Programming Paradigms)领域的变革。编程范式是与编程语言相对不相关的编程的方式,它告诉你该使用哪种结构来编程,以及何时该使用它们。迄今为止,已有三种编程范式,分别是结构化编程(Structured Programming)、面向对象编程(Object-Oriented Programming)、以及函数式编程(Functional Programming)。

首先,从结构化编程讲起。简单来说,结构化编程语言就是支持顺序、选择和迭代这三种控制结构的编程语言。结构化编程语言的起源可以追溯到上世纪中叶Dijkstra对程序正确性的证明(对,就是发明Dijkstra算法的那个Dijkstra)。

1 程序正确性的数学证明

Edsger Wybe Dijkstra于1930年出生在鹿特丹。1952年到1955年,Dijkstra在上大学之余,还在阿姆斯特丹的数学中心接受了一份程序员的工作。事实上,他是荷兰的第一个程序员。

Dijkstra发现,任何复杂的程序都包含了太多的细节,让人的大脑无法在没有帮助的情况下进行管理。忽略一个小细节就会导致一些看似有用的程序以惊人的方式失败。

对这个问题,Dijkstra的解决方案是数学证明。他的设想是建立一个包含假设,定理,推论,和引理的欧几里德公理体系。程序员可以像数学家一样使用经过这个公里体系验证的结构,并将它们与代码结合起来,然后证明自己是正确的。

当然,在构建公理体系的过程中,Dijkstra却发现证明简单程序的正确性已经是一件很有挑战性的任务。在他的调查中,Dijkstra发现goto语句的某些用法可以防止程序模块被递归地分解成更小的单元,从而不能使用分治法来证明程序的正确性。然而,并非所有的goto的语句都有这个问题。Dijkstra意识到这些“好的”使用了goto对应的简单选择和迭代控制结构,例如if/then/else和do/while。只使用那些类型的控制结构的模块可以被递归地细分为可证明的单元。

1966年,B?hm和Jacopini在《Comm. ACM》上发表了一篇论文,证明所有的程序都可以由顺序、选择和迭代这三个结构组成,也就是说,goto语句在逻辑上是多余的。

1968年,Dijkstra在《Comm. ACM》上发表了一篇名为《Go To Statement Considered Harmful》的文章,文中概述了他对这三种控制结构的证明。

在顺序结构中,Dijkstra通过简单的枚举从序列的输入追溯到序列的输出,从而证明程序是正确的。

在选择结构中,Dijkstra通过枚举每条路径的正确性来证明选择结构的正确性。如果这每条路径都最终产生了适当的数学结果,那就证明了选择结构是正确的。

迭代的证明有一点不同。为了证明迭代是正确的,Dijkstra必须使用归纳法。他首先通过枚举证明了第一次迭代的正确性,再通过枚举证明了如果第N次迭代是正确的,那么第N + 1次迭代是正确的。他还通过枚举证明了迭代的开始和结束标准。

这样的证明是费力而复杂的,但它们是形式化的数学证明。继续发展下去,可以用这些数学证明构建出一个欧几里得体系结构。

于是一个值得注意的情况出现了:所有的程序都可以由三个数学上可证明正确的控制结构构造而成,而逻辑上多余的goto语句则会破坏这种控制结构。

证明程序正确性的科学方法

但是欧几里德式的公理结构没有建立起来,程序员们也从来没有看到过通过艰苦的过程来形式化证明每一个小函数正确的好处。Dijkstra的梦想破灭了。今天的程序员很少相信形式化证明是生产高质量软件的合适方法。

当然,形式化的数学证明并不是证明某些东西正确的唯一策略,另一个非常成功的策略是科学方法。

科学与数学本质上是不同的,因为科学理论和定律不能被证明是正确的。我无法证明牛顿第二定律或万有引力定律是正确的。我可以向你们展示这些定律,我可以做一些测量来证明精确到很多小数位之后这些定律依然是正确的,但我不能用数学证明来证明它们。不管我做了多少实验,或者我收集了多少经验证据,总有可能存在一些实验会证明牛顿第二定律或万有引力定律是不正确的。然而,我们每天都把自己的生活押在这些定律上。每当你坐上一辆车,你就会认为 F = ma 是对世界运转方式的可靠描述。你每走一步,你就会把你的健康和安全赌注压在F=(Gm1m2)/r2上。

这就是科学方法的本质:它是可证伪的(falsifiable),但不可证实(provable)。

科学方法不能证明程序是正确的,只能证明程序是错误的。那些经过了许多次尝试也不能证明错误的程序,我们认为对我们来说是足够正确的。这种证明程序正确性科学方法在软件开发领域还有个更为熟知的名字——测试。

换句话说,一个程序可以通过测试被证明是不正确的,但是它不能被证明是正确的。而那些经过充分的测试的程序,可以被认为对我们来说是足够正确的。

这一事实有些令人震惊。尽管软件开发看似是一个操纵数学结构的过程,但是实际上软件开发更像一门科学。所谓的软件开发的科学方法,其实就是通过无法证明程序是错误的,来证明程序是正确的。

但是科学方法只能应用于可证实的程序。所以不管一个程序通过了多少个测试,如果它不受限制地使用goto对程序的控制进行转移,它就不能被认为是(足够)正确的,因为它是在科学上是不可证伪的。

编程语言的发展也证明了科学方法的有效性。随着计算机语言的不断发展,goto语句的声明一直在向底层移动,直到消失。大多数现代语言都没有goto语句——当然,LISP从来没有过。有些人可能会指出Java的命名中断或类似于goto的异常处理。事实上,这些结构并不是像Fortran或COBOL这样的旧语言的完全无限制的控制权转移。即使仍然支持goto关键字的语言,也经常将目标限制在当前函数的范围内。

结论

结构化编程的本质其实是对程序控制的直接转移施加约束(也就是对goto语句加以限制),以确保程序可被科学方法(也就是足够的测试)证明正确。这就是现代编程语言通常不支持无约束的goto语句的原因。

我们递归地将程序分解为一组小的可证伪的程序单元(函数),然后用尝试测试来证明那些小的可证伪的函数是不正确的。如果这些测试不能被证明不正确,那么我们认为这些函数对我们来说是正确的。

从最小的函数(function)到最大的组件(component),软件开发都是由可证伪性(falsifiability)驱动的。软件开发者努力定义易于证伪(可测试)的模块和组件。为了做到这一点,他们在一个更高的层面上采用了类似于结构化编程的限制性规则。

参考文献

[1] Dijkstra, Edsger W. "Letters to the editor: go to statement considered harmful." Communications of the ACM 11.3 (1968): 147-148.

[2] Martin, Robert C. Clean architecture: a craftsman's guide to software structure and design. Prentice Hall Press, 2017.

补充

观念范式是一个语言流派(如命令式语言)的共同信仰,是该流派程序员创造解域(软件)世界所需遵循的世界观和方法论。观念范式决定和影响了该范式的心理范式和规则范式。

例如,命令编程范式的核心观念:以机器的运作方式看待一切。从机器语言、汇编语言到高级语言如C,命令范式的程序观以CPU对指令的读取-执行循环(fetch-and-execute cycle)为基础、完全遵循计算机硬件按顺序执行指令这一事实,秉承鲜明的物理学风格的实证主义。因此,

命令编程范式的程序开发,被视为一个命令/指令序列的开发。程序是一个语句的序列,这些指令通过操作数据而改变机器的状态。按照图灵机(Turing machine),“状态变化”是命令编程范式的关键词汇,以至于SICP指出:"广泛采用赋值的程序设计被称为命令式程序设计"。

那么,面向对象编程范式的核心观念是什么呢?不同于基于图灵机的命令范式和基于λ运算的函数范式,面向对象编程范式没有直接的计算理论模型来源。它之所以被称为一种范式,仅仅是因为它采用了完全不同的看待程序的视角。

★面向对象编程范式是以人们熟悉的、习惯的现实世界和思维方式为隐喻(metaphor),以概念/类型或其实例化的对象为思考单元,进行程序组织的编程范式。

★面向对象编程范式的核心观念是:以概念/类型建模一切。

因此,面向对象编程范式的程序开发,被视为定义各种概念/类型。程序是相互服务的类型的集合,程序中的类型因为提供服务才具有存在价值。

很多人比较命令编程范式和面向对象编程范式的异同。按照托马斯?库恩的范式论,『当范式改变时,不但从事科学研究的人更换了,评价选择的标准、整个社会心理、研究传统以及世界图式都改变了,新旧范式是不可通约的、不可比较的』;另一方面,Java等诸多面向对象编程语言,事实上是命令式面向对象语言。这说明面向对象编程范式对于其他范式,并非取代,而是兼容。

★面向对象编程范式在形成概念/类型时,不拘泥于采用命令范式还是函数范式。

②心理范式(心理认知因素)。托马斯?库恩指出,科学作为人类的一种社会事业,还必须考虑到认识领域以外的社会问题和心理学问题。

每一种范式的科学共同体对其范式的核心观念和逻辑体系,有着“虔诚的狂热”。争论语言的好坏,常常会导致网络互喷大战,表现出编程的宗派。因为每一种范式的群体,力图把自然界“强迫纳入”该范式所规定的思想框架内,从而展开卓有成效的研究活动;而对立范式之间的争吵经久不衰,因为"范式之间的竞争不是那种可用证明解决的战斗",你无法说服不想被你说服的人。需要注意的是,编程范式间的冲突,通常不是新旧范式的矛盾,而是并行范式的相互鄙视。

心理范式在程序员学习语言以及在编程实践中,起着潜移默化的作用。例如一些人在首先学习了C语言,再学习面向对象编程时会遭受痛苦的“范式迁移”,即使学习C++和Java这种命令式面向对象语言也倍感困扰。

心理范式在面向对象普及中,起到重要作用。把人们熟悉的、习惯的现实世界作为思考和编程的背景知识(隐喻),成为面向对象普及的助推剂。

③规则范式(学科的逻辑体系)。从核心观念出发,将会演绎出、或者说为了支持核心观念而形成各种被公认的规范,如定律、理论、应用以及工具等等,组成了一个逻辑体系。

命令范式从“状态”出发,将变量、数据集合(数组、结构体)、函数均视为内存中的实体,可以直接和间接访问它们,于是指针成为其有力的工具。

对象技术以概念/类型建模一切,在[0.3PLP详解]中将对对象技术进行展开讨论。这里先提出一个问题:面向对象技术最大的优势是什么?庞大的类库。

正因为以概念/类型建模一切,所以人们可以按照不同的领域,并行地展开各自的研究,从而形成庞大的类库。例如Java平台(Java Platform)提供的庞大的标准类库,帮助程序员进行图形处理、数学计算、字符串处理、网络应用等方方面面的工作。全世界的Java程序员可以按照其研究领域,设计出各种各样的类型(Java类和接口)。而这些类型——作为标准类库和第三方库,可以作为其他程序员使用的“建筑砖块”。

如果不能够认识到这一点,不知道面向对象技术最大的优势,就不可能真正理解对象技术的逻辑体系,而对象技术的逻辑体系的基石是类层次和里氏替换原则(Liskov Substitution Principle、LSP)。