函数式编程与面向对象编程[5]:编程的本质
之剑 2016.5.6 01:26:31
<div id="category"></div>
编程的本质
读到两篇文章,写的不错, 综合摘录一下
复合是编程的本质
函数式程序员在洞察问题方面会遵循一个奇特的路线。他们首先会问一些似有禅机的问题。例如,在设计一个交互式程序时,他们会问:什么是交互?在实现 基于元胞自动机的生命游戏时,他们可能又去沉思生命的意义。秉持这种精神,我将要问:什么是编程?在最基本的层面,编程就是告诉计算机去做什么,例如『从 内存地址 x 处获取内容,然后将它与寄存器 EAX 中的内容相加』。但是即使我们使用汇编语言去编程,我们向计算机提供的指令也是某种有意义的表达式。假设我们正在解一个难题(如果它不难,就没必要用计算 机了),那么我们是如何求解问题的?我们把大问题分解为更小的问题。如果更小的问题还是还是很大,我们再继续进行分解,以此类推。最后,我们写出求解这些 小问题的代码,然后就出现了编程的本质:我么将这些代码片段复合起来,从而产生大问题的解。如果我们不能将代码片段整合起来并还原回去,那么问题的分解就 毫无意义。
层次化分解与重新复合的过程
这个思维过程, 并非是受计算机的限制而产生,它反映的是人类思维的局限性。我们的大脑一次只能处理很少的概念。生物学中被广为引用的 一篇论文指出我们我们的大脑中只能保存 7± 2 个信息块。我们对人类短期记忆的认识可能会有变化,但是可以肯定的是它是有限的。底线就是我们不能处理一大堆乱糟糟的对象或像兰州拉面似的代码。我们需要 结构化并非是因为结构化的程序看上去有多么美好,而是我们的大脑无法有效的处理非结构化的东西。我们经常说一些代码片段是优雅的或美观的,实际上那只意味 着它们更容易被人类有限的思维所处理。优雅的代码创造出尺度合理的代码块,它正好与我们的『心智消化系统』能够吸收的数量相符。
那么,对于程序的复合而言,正确的代码块是怎样的?它们的表面积必须要比它们的体积增长的更为缓慢。我喜欢这个比喻,因为几何对象的表面积是以尺寸 的平方的速度增长的,而体积是以尺寸的立方的速度增长的,因此表面积的增长速度小于体积。代码块的表面积是是我们复合代码块时所需要的信息。代码块的体积 是我们为了实现它们所需要的信息。一旦代码块的实现过程结束,我们就可以忘掉它的实现细节,只关心它与其他代码块的相互影响。在面向对象编程中,类或接口 的声明就是表面。在函数式编程中,函数的声明就是表面。我把事情简化了一些,但是要点就是这些。
范畴论
在积极阻碍我们探视对象的内部方面,范畴论具有非凡的意义。范畴论中的一个对象,像一个星云。对于它,你所知的只是它与其他对象之间的关系,亦即它 与其他对象相连接的箭头。这就是 Internet 搜索引擎对网站进行排名时所用的策略,它只分析输入与输出的链接(除非它受欺骗)。在面向对象编程中,一个理想的对象应该是只暴露它的抽象接口(纯表面, 无体积),其方法则扮演箭头的角色。如果为了理解一个对象如何与其他对象进行复合,当你发现不得不深入挖掘对象的实现之时,此时你所用的编程范式的原本优 势就荡然无存了。
让我们暂时撇开平台、框架、技术、设计模式、对象思想、敏捷开发论等。 追问程序本质。
布尔代数的逻辑体系
布尔代数起源于数学领域,是一个用于集合运算和逻辑运算的公式:〈B,∨,∧,¬ 〉。其中B为一个非空集合,∨,∧为定义在B上的两个二元运算,¬为定义在B上的一个一元运算。
通过布尔代数进行集合运算可以获取到不同集合之间的交集、并集或补集,进行逻辑运算可以对不同集合进行与、或、非。
在布尔代数上的运算被称为AND(与)、OR(或)和NOT(非)。代数结构要是布尔代数,这些运算的行为就必须和两元素的布尔代数一样(这两个元素是TRUE(真)和FALSE(假))。亦称逻辑代数.布尔(Boole,G.)为研究思维规律(逻辑学)于1847年提出的数学工具.布尔代数是指代数系统B=〈B,+,·,′〉
它包含集合B连同在其上定义的两个二元运算+,·和一个一元运算′,布尔代数具有下列性质:对B中任意元素a,b,c,有:
1.a+b=b+a, a·b=b·a.
2.a·(b+c)=a·b+a·c,
a+(b·c)=(a+b)·(a+c).
3.a+0=a, a·1=a.
4.a+a′=1, a·a′=0.
公理化
在 1933 年,美国数学家 Edward Vermilye Huntington (1874-1952) 展示了对布尔代数的如下公理化:
交换律: x + y = y + x。
结合律: (x + y) + z = x + (y + z)。
Huntington等式: n(n(x) + y) + n(n(x) + n(y)) = x。
一元函数符号 n 可以读做'补'。
Herbert Robbins 接着摆出下列问题: Huntington等式能否缩短为下述的等式,并且这个新等式与结合律和交换律一起成为布尔代数的基础? 通过一组叫做 Robbins 代数的公理,问题就变成了: 是否所有的 Robbins 代数都是布尔代数?
Robbins 代数的公理化:
交换律: x + y = y + x
结合律: (x + y) + z = x + (y + z)
Robbins等式: n(n(x + y') + n(x + n(y))) = x
这个问题自从 1930 年代一直是公开的,并成为 Alfred Tarski 和他的学生最喜好的问题。
在 1996 年,William McCune 在 Argonne 国家实验室,建造在 Larry Wos、Steve Winker 和 Bob Veroff 的工作之上,肯定的回答了这个长期存在的问题: 所有的 Robbins 代数都是布尔代数。这项工作是使用 McCune 的自动推理程序 EQP 完成的。
计算机程序的本质
从本质上来说, 程序就是一系列有序执行的指令集合。 如何将指令集合组织成可靠可用可信赖的软件(美妙的逻辑之塔), 这是个问题。
程序 = 逻辑 + 控制。 what to do + when to do.
从编程角度来说, 开发者应对的就是逻辑, 逻辑的表达、组织和维护。 逻辑是事物自此及彼的合乎事物发展规律的序列。指令是逻辑的具体实现形式。
逻辑成立的先决条件是合乎事物发展规律。 程序只能处理数值, 却传入了字符串, 就只能报错而无法继续; 当处理海量数据时, 若内存不足, 就会导致程序崩溃; 若程序存在内存泄露, 随着时间的推移而耗尽内存, 也会导致程序崩溃。 多个线程同时修改一个共享变量, 若不加控制, 就会因为不同线程执行修改变量的时序的不确定导致该变量最终值的不确定。 这些就是程序执行的发展规律。 要编写程序, 必定要先通悉这些规律。
规律的表现形式是:如果条件 (C1, C2, ..., Cn) 是产生结果 (R1, R2, ... , Rn) 的充分必要条件, 那么当 C1, C2, ..., Cn 任一不满足条件时, 都不可能产生结果 (R1, R2, ..., Rn) ; 反之, 若结果 (R1, R2, ..., Rn) 没有出现, 则必定是 C1, C2, ..., Cn 某一条件不满足导致。 错误和异常即是 C1, C2, ..., Cn 任一不满足条件的表现。规律的性质是必然的, 不存在可能之说; 只存在人们探索的是否足够精确。编程开发首先应当懂得程序执行的规律, 然后才是实际的开发; 否则就会被程序的结果折腾得死去活来。
在通悉程序执行规律之后, 程序需要解决如下问题:
- 要表达什么逻辑
- 如何表达该逻辑;
- 如何维护该逻辑。
软件的复杂性表现在如何表达和维护交互复杂的大型逻辑上
暂时先回到软件的起点, 回顾一下这一切是如何发生的。
最初, 人们使用物理的或逻辑的二进制机器指令来编写程序, 尝试着表达思想中的逻辑, 控制硬件计算和显示, 发现是可行的;
接着, 创造了助记符 —— 汇编语言, 比机器指令更容易记忆;
再接着, 创造了编译器、解释器和计算机高级语言, 能够以人类友好自然的方式去编写程序, 在牺牲少量性能的情况下, 获得比汇编语言更强且更容易使用的语句控制能力:条件、分支、循环, 以及更多的语言特性: 指针、结构体、联合体、枚举等, 还创造了函数, 能够将一系列指令封装成一个独立的逻辑块反复使用;
逐渐地,产生了面向过程的编程方法;
后来, 人们发现将数据和逻辑封装成对象, 更接近于现实世界, 且更容易维护大型软件, 又出现了面向对象的编程语言和编程方法学, 增加了新的语言特性: 继承、 多态、 模板、 异常错误。
为了不必重复开发常见工具和任务, 人们创造和封装了容器及算法、SDK, 垃圾回收器, 甚至是并发库;
为了让计算机语言更有力更有效率地表达各种现实逻辑, 消解软件开发中遇到的冲突, 还在语言中支持了元编程、 高阶函数, 闭包 等有用特性。
为了更高效率地开发可靠的软件和应用程序, 人们逐渐构建了代码编辑器、 IDE、 代码版本管理工具、公共库、应用框架、 可复用组件、系统规范、网络协议、 语言标准等, 针对遇到的问题提出了许多不同的思路和解决方案, 并总结提炼成特定的技术和设计模式, 还探讨和形成了不少软件开发过程, 用来保证最终发布的软件质量。 尽管编写的这些软件和工具还存在不少 BUG ,但是它们都“奇迹般地存活”, 并共同构建了今天蔚为壮观的软件世界。
此外, 软件还经历了“单机程序 => 多机程序 => 分布式程序” 的过程 , 多机联网程序因为多个子系统的交互变得更加复杂。 这里不再赘述。
但请注意, 无论软件发展到多么复杂的程度, 总有一群人, 在试图从程序的本质中探究软件开发的基本问题, 他们试图论证和确保程序的正确性、提炼软件的基本属性并进行衡量; 程序的正确性本质是逻辑学来保证的。 没有逻辑学, 程序根本就无法立足, 更不可能有今天的大规模应用。
软件开发工具让我们更有效率地创造逻辑、 远离语法错误的困扰;
公共库将常用的通用逻辑块封装成可反复使用的组件, 避免不必要的重复劳动;
设计模式体现的是如何可扩展地解决常见的逻辑交互问题;
应用框架解决的是应用的通用逻辑流的控制的问题,让开发者更多地聚焦具体业务逻辑上;
开发技术是在具体的应用情境下按照既定总体思路去探究具体问题解决的方法。
表达和维护大型逻辑
我们要解决的是更通用的问题: 如何以更不易出错的方式去表达和维护大型逻辑 ?
表达和维护大型逻辑的终极诀窍就是: 将大型逻辑切分为容易消化的一小块一小块, “不急不忙地吃掉”。
在该方法的实践中, 可以充分利用现有的开发工具、公共库、设计模式、应用框架、开发技术。
独立无交互的大型逻辑或接口实现
独立无交互的逻辑通常体现为公共库, 可以解决常用或公共的日常任务, 对其他逻辑无任何依赖和交互, 即自足逻辑。
应对独立无交互的大型逻辑的首要方法是分解为若干的容易实现、测试和复用的小块逻辑, 编写和严格测试。
其次是运用成熟的编程模式去表达逻辑, 尽可能复用经过严格测试的可靠的库。
独立无交互的大型逻辑通过合理的逻辑块切分、严格的单元测试可以获得充分的测试和可靠度。
独立无交互的耗时长的逻辑或接口实现
快速响应的问题: “用户要求等待时间短” 与 “请求处理耗时长” 之间的矛盾导致的。
解决独立无交互的耗时长的逻辑依然可以采用切分逻辑块、严格的单元测试的做法使之更容易处理;
此外, 有两种设计思路可以考虑: 并发 与 异步。
-
并发思路是将切分的相互独立的逻辑块分配给不同的控制线程中执行, 从而降低请求处理时长; 并发方案获得的性能提升取决于串行操作在总操作中的时间占比。
-
异步思路是“先响应, 后处理, 终通知” 的"先奏后斩"方案。
将一步分离成了三步, 为了让用户首先获得初步的承诺, 再去履行承诺。 这样做能让用户暂时地放心, 却增加了新的问题: 消息中间件组件的开发与部署、异步消息发送与接收、编程模型的变化和适应。如果整个过程运作良好, 将会达到很好的体验,容易为用户接受。如果其中一步发生差错, 就会导致各种问题, 比如数据不一致, 消息堆积、 请求无法被处理。最终用户等待时间并没有降低, 反而使体验更加糟糕。 当然, 如果成功率为 95%, 也是“可以接受”的, 这样用户可能会怪自己“运气不太好”, 而不会过多怪责系统的不完善。毕竟没有任何事情能够做到完美的地步。
并发与异步方案的调试难度和排查问题都比同步方案增加不少。 每一种新的设计方案都会有其优点, 同时也会有其缺点。 权衡优缺点, 择善而从之 。值得注意的是, 并发方案是针对服务端实际处理请求逻辑而言, 而异步方案是针对请求处理之前是否立即回复的方式。 并发与顺序、 异步与同步两两组合, 可得到四种方式:
顺序同步: 最初的编程模型
- 优点是简单、安全、 容易维护和调试;
- 缺点是性能较低, 响应时间和吞吐量都不高; 若请求处理时长非常短, 采用顺序同步的方案佳;
并发同步: 改进的编程模型
- 优点是通过并发提高服务端的处理速度和吞吐量, 但若请求处理耗时较长, 响应时间仍然不高, 影响客户端体验;
- 若通过并发方案处理请求的时长非常短, 或客户端体验要求不高, 可以采用并发同步的方案;
顺序异步: 改善客户端体验的编程模型
- 优点是提高了响应时间和客户端体验, 由于其逻辑处理仍然采用顺序方式, 请求处理时长并未有改善, 因此吞吐量并没有改善。 是一种较好的折衷方案;
- 若请求处理耗时较长, 影响客户端体验, 且请求处理逻辑复杂, 采用并发方案容易出错或难以并发, 可采用顺序异步方案;
并发异步: 同时改善客户端体验和服务端处理速度
- 优点是提高了响应时间、客户端体验和处理速度、吞吐量。
- 缺点是容易出错, 且不易调试;
- 若客户端对响应体验要求较高, 请求处理逻辑简单(比如简单的数据拉取和汇总), 采用并发方式可有效提升处理速度, 可以采用并发异步方案;
逻辑块之间的交互耦合与可扩展性
软件的复杂性真正体现在逻辑块的持续长久的交互耦合和可扩展上。这是软件开发与维护中极具挑战性的部分。
逻辑块之间的交互耦合通常体现在三种情境:
a. 操作顺序的依赖。 比如资源更新操作必须在指定资源已经创建的情况下进行。
b. 对共享有限资源的并发申请。 比如打印机只有两台, 却有多个应用程序连接上去请求打印文档;
c. 对共享可变状态的并发访问。 比如两个操作同时要更新数据库中的同一条记录;
三种情境的复杂性均是由并发引起的。 假设所有操作都是串行进行的, 逻辑块的交互无非是“你方唱罢我登场”的次序控制, 而资源对单个请求通常是足够的; 一旦采用了并发方案, 就难以控制逻辑块的执行次序和资源分配的具体情况了, 容易导致某资源对单个请求不足的情况, 从而阻塞多个请求的处理甚至死锁。并发提升了应用的性能, 却增加了出错的风险和几率。并发控制是大型逻辑交互的本质性难点。并发控制的难点在于时序的合理控制和有效资源的合理分配。
对于 a 情境, 通常采用添加前置条件来求解, 在操作之前校验相关资源是否满足、实体状态是否合理, 实体之间的关联是否正确; 若前置条件不满足, 则直接返回错误提示, 或者暂时挂起以备后续继续执行;
对于 b 情境, 需要创建一个可靠适用的资源分配算法 和资源分配模块 , 应用程序不再“自行”去拉取资源, 而是向资源分配模块申请资源, 由资源分配模块根据实际申请的整体情况及申请条件来决定如何分配资源;
对于 c 情境, 需要进行安全的互斥访问, 谨慎地控制。
逻辑块之间的交互耦合应该交给交互解耦模块去完成, 而不是在自己的接口里实现。
也就是说, 只有交互解耦模块知道所有接口之间的交互, 而接口只做自己知道的事情就可以了。否则, 接口 A 与接口 B 必须知道彼此究竟做了什么, 才能正确地做自己的事情。 假设 接口 A 和接口 B 都修改某个资源的状态。 接口 A 在做某项操作执行必须执行 IF (ConditionX) do something ; DoMyOwnThing ; 接 口 B 也要根据 A 的逻辑相应地执行 if (ConditionY) do anotherThing;DoMyOwnThing. 而程序员在维护和修改接口 A 的逻辑时, 不一定知道接口 B 的逻辑与之相关, 于是修改不可避免地破坏了接口 B 的逻辑。 耦合的接口数量越多, 或者耦合接口之间的耦合资源越多, 对后期维护和扩展将是一个难以应对的噩梦。
对于逻辑块之间的交互解耦, 或者通俗地说, 模块解耦.
实现逻辑时的容错考虑
程序中的逻辑主要是三类:
-
获取值: 从数据库、网络或对象中获取值。 如果数据库或网络访问足够稳定的话, 可以看成是简单的获取值, 数据库访问和网络访问对获取值是透明的;
-
检测值: 检测值是否合法, 通常是前置条件校验、 中间状态校验和后置结果校验, 根据检测结果执行“获取值”或“设置值”的逻辑;
-
设置(拷贝)值: 设置数据库、对象中的值; 或者发送数据和指令给网络。如果数据库或网络访问足够稳定的话, 可以看成是简单的设置值, 数据库访问和网络访问对设置值是透明的;
这三类逻辑可以称为逻辑元。 具体业务逻辑就是基于物理的或逻辑的资源限制, 将逻辑元的组合封装成逻辑块, 有效控制逻辑块的时序交互和资源分配。 时序控制不合理和资源缺乏导致错误和异常。两个程序同时更新一个共享变量, 如果时序不控制, 就会导致错误的结果; 网络通信错误, 是因为网络带宽资源是有限的。
如何应对错误和异常 ?
防御性编程
预防错误的方法就是进行防御性编程, 进行容错考虑。 多思考: 如果这一步发生错误, 会导致什么问题? 该如何做才能预防这个错误? 如果难以预防, 该如何描述, 才能在出现错误时更好地定位出这样的错误? 在出现错误时, 如何才能恢复到正常合法的状态 ? 如果无法程序自动恢复, 怎样做才能让手工处理更加简单 ?
要健壮地表达和维护大型逻辑, 首先系统整体架构必须足够稳固可靠, 在开发和维护过程中持续加固。 假设一栋建筑整体设计有问题, 那么, 无论里面的房间装饰得多么漂亮优雅, 都会随着建筑的坍塌而消亡。 这需要深入去探究所使用的应用框架, 挖出可能的不可靠风险, 并加以预防和控制。
在已确定的设计方案和业务逻辑的情况下, 如何编写BUG更少的代码:
简明扼要的注释 + 契约式/防御式编程 + 更短小的逻辑块 + 复用公共库 + 严格测试
编写更少BUG程序的六条准则:
1. 在方法前面编写简明扼要的注释: 方法用途, 接收参数, 返回值, 注意事项, 作者, 时间。
2. 契约式编程: 在方法入口处编写前置条件校验,在方法出口处编写后置结果校验 ;
3. 防御式编程: 编程时严格校验参数和前置条件; 仔细考虑各种错误与异常的定位和处理;
4. 编写和保持短小逻辑块, 易于为人的脑容量一次性处理, 容易测试;
5. 复用经过严格测试的可靠的公共库; 如果库没有经过很好的测试,但有很好的用处, 帮助其添加测试;
6. 对所编写的代码, 如果不是逻辑元, 都要进行严格测试。
关于作者: 陈光剑,江苏东海人, 号行走江湖一剑客,字之剑。程序员,诗人, 作家
<link rel="stylesheet" href="http://yandex.st/highlightjs/6.2/styles/googlecode.min.css">
<script src="http://code.jquery.com/jquery-1.7.2.min.js"></script>
<script src="http://yandex.st/highlightjs/6.2/highlight.min.js"></script>
<script>hljs.initHighlightingOnLoad();</script>
<script type="text/javascript">
$(document).ready(function(){
$("h2,h3,h4,h5,h6").each(function(i,item){
var tag = $(item).get(0).localName;
$(item).attr("id","wow"+i);
$("#category").append('<a class="new'+tag+'" href="#wow'+i+'">'+$(this).text()+'</a></br>');
$(".newh2").css("margin-left",0);
$(".newh3").css("margin-left",20);
$(".newh4").css("margin-left",40);
$(".newh5").css("margin-left",60);
$(".newh6").css("margin-left",80);
});
});
</script>
网友评论
就这句话提个疑问:这个交互解耦模块放到哪里比较合适?
我尝试用OO举个例子(桥接模式):
我有毛笔和颜色两个接口;
如果我不表达毛笔依赖颜色这样的单向依赖,形成毛笔和颜色之间聚合关系;
那么我该通过使用不同的毛笔写不同颜色的字?
上述问题中:
1) 如果肯定这样的依赖关系,那么*交互解耦模块*就已经进入毛笔接口了,是否也是耦合?
2) 如果否定这样的依赖关系,独立的*交互解耦模块*若想要用它,就需要知道很多毛笔和颜色的细节,才能进行调色、写字。这样又是否打破了内聚之说?
最后,这样的*交互解耦模块*胶合代码如果越来越多,是否获得益处还不如维护它们自身带来的坏处?
可以告诉我是哪两篇文章吗?