编程方法论
编程的本质:复合
函数式程序员在洞察问题方面会遵循一个奇特的路线。他们首先会问一些似有禅机的问题。例如,在设计一个交互式程序时,他们会问:什么是交互?在实现基于元胞自动机的生命游戏时,他们可能又去沉思生命的意义。
秉持这种精神,我将要问:什么是编程?
在最基本的层面,编程就是告诉计算机去做什么,例如"从内存地址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 完成的。
数字电路系统
用数字信号完成对数字量进行算术运算和逻辑运算的电路.
数字电路定义
用数字信号完成对数字量进行算术运算和逻辑运算的电路称为数字电路,或数字系统。由于它具有逻辑运算和逻辑处理功能,所以又称数字逻辑电路。
数字逻辑电路分类(按功能分)
- 组合逻辑电路 简称组合电路,它由最基本的的逻辑门电路组合而成。特点是:输出值只与当时的输入值有关,即输出惟一地由当时的输入值决定。电路没有记忆功能,输出状态随着输入状态的变化而变化,类似于电阻性电路,如加法器、译码器、编码器、数据选择器等都属于此类。
- 时序逻辑电路 简称时序电路,它是由最基本的逻辑门电路加上反馈逻辑回路(输出到输入)或器件组合而成的电路,与组合电路最本质的区别在于时序电路具有记忆功能。时序电路的特点是:输出不仅取决于当时的输入值,而且还与电路过去的状态有关。它类似于含储能元件的电感或电容的电路,如触发器、锁存器、计数器、移位寄存器、储存器等电路都是时序电路的典型器件。
数字电路的特点
同时具有算术运算和逻辑运算功能 数字电路是以二进制逻辑代数为数学基础,使用二进制数字信号,既能进行算术运算又能方便地进行逻辑运算(与、或、非、判断、比较、处理等),因此极其适合于运算、比较、存储、传输、控制、决策等应用。
实现简单,系统可靠 以二进制作为基础的数字逻辑电路,简单可靠,准确性高。
集成度高,功能实现容易
集成度高,体积小,功耗低是数字电路突出的优点之一。电路的设计、维修、维护灵活方便,随着集成电路技术的高速发展,数字逻辑电路的集成度越来越高,集成电路块的功能随着小规模集成电路(SSI)、中规模集成电路(MSI)、大规模集成电路(LSI)、超大规模集成电路(VLSI)的发展也从元件级、器件级、部件级、板卡级上升到系统级。电路的设计组成只需采用一些标准的集成电路块单元连接而成。对于非标准的特殊电路还可以使用可编程序逻辑阵列电路,通过编程的方法实现任意的逻辑功能。
数字电路的应用
数字电路与数字电子技术广泛的应用于电视、雷达、通信、电子计算机、自动控制、航天等科学技术各个领域。
计算机程序的本质
计算机程序的本质就是计算.其背后的逻辑基础就是数字电路逻辑体系,物理基础就是半导体电路.也就是电流信号在时间和空间上的运动.(东海陈光剑 2016.6.22)
当然,我们通常说, 程序就是一系列有序执行的指令集合。
如何将指令集合组织成可靠可用可信赖的软件(美妙的逻辑之塔),这是个问题。
程序 = 逻辑 + 控制。 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程序的六条准则:
- 在方法前面编写简明扼要的注释: 方法用途, 接收参数, 返回值, 注意事项, 作者, 时间。
- 契约式编程: 在方法入口处编写前置条件校验,在方法出口处编写后置结果校验 ;
- 防御式编程: 编程时严格校验参数和前置条件; 仔细考虑各种错误与异常的定位和处理;
- 编写和保持短小逻辑块, 易于为人的脑容量一次性处理, 容易测试;
- 复用经过严格测试的可靠的公共库; 如果库没有经过很好的测试,但有很好的用处, 帮助其添加测试;
- 对所编写的代码, 如果不是逻辑元, 都要进行严格测试。
面向对象编程
1967年挪威计算中心的Kisten Nygaard和Ole Johan Dahl开发了Simula67语言,它提供了比子程序更高一级的抽象和封装,引入了数据抽象和类的概念,它被认为是第一个面向对象语言。
20世纪70年代初,Palo Alto研究中心的Alan Kay所在的研究小组开发出Smalltalk语言,之后又开发出Smalltalk-80,Smalltalk-80被认为是最纯正的面向对象语言,它对后来出现的面向对象语言,如Object-C,C++,Self,Eiffl都产生了深远的影响。
随着面向对象语言的出现,面向对象程序设计也就应运而生且得到迅速发展。
之后,面向对象不断向其他阶段渗透,1980年Grady Booch提出了面向对象设计的概念,之后面向对象分析开始。
面向对象编程(Object Oriented Programming,OOP,面向对象程序设计)是一种计算机编程架构。OOP 的一条基本原则是计算机程序是由单个能够起到子程序作用的单元或对象组合而成。OOP 达到了软件工程的三个主要目标:重用性、灵活性和扩展性。为了实现整体运算,每个对象都能够接收信息、处理数据和向其它对象发送信息。
过去的几十年中,程序设计语言对抽象机制的支持程度不断提高:
从机器语言到汇编语言,到高级语言,直到面向对象语言。
汇编语言出现后,程序员就避免了直接使用0-1,而是利用符号来表示机器指令,从而更方便地编写程序;
当程序规模继续增长的时候,出现了Fortran、C、Pascal等高级语言,这些高级语言使得编写复杂的程序变得容易,程序员们可以更好地对付日益增加的复杂性。
但是,如果软件系统达到一定规模,即使应用结构化程序设计方法,局势仍将变得不可控制。作为一种降低复杂性的工具,面向对象语言产生了,面向对象程序设计也随之产生。
OOP的思想
面向对象程序设计中的概念主要包括:
- 对象
- 类
- 数据抽象
- 继承
- 动态绑定
- 数据封装
- 多态性
- 消息传递
通过这些概念面向对象的思想得到了具体的体现。
对象(Object)
可以对其做事情的一些东西。一个对象有状态、行为和标识三种属性。
类(class)
一个共享相同结构和行为的对象的集合。 类(Class)定义了一件事物的抽象特点。通常来说,类定义了事物的属性和它可以做到的(它的行为)。类可以为程序提供模版和结构。一个类的方法和属性被称为“成员”。
封装(encapsulation)
第一层意思:将数据和操作捆绑在一起,创造出一个新的类型的过程。 第二层意思:将接口与实现分离的过程。
继承
类之间的关系,在这种关系中,一个类共享了一个或多个其他类定义的结构和行为。继承描述了类之间的“是一种”关系。子类可以对基类的行为进行扩展、覆盖、重定义。
组合
既是类之间的关系也是对象之间的关系。在这种关系中一个对象或者类包含了其他的对象和类。 组合描述了“有”关系。
多态
类型理论中的一个概念,一个名称可以表示很多不同类的对象,这些类和一个共同超类有关。因此,这个名称表示的任何对象可以以不同的方式响应一些共同的操作集合。
动态绑定
也称动态类型,指的是一个对象或者表达式的类型直到运行时才确定。通常由编译器插入特殊代码来实现。与之对立的是静态类型。
静态绑定
也称静态类型,指的是一个对象或者表达式的类型在编译时确定。
消息传递
指的是一个对象调用了另一个对象的方法(或者称为成员函数)。
方法
也称为成员函数,是指对象上的操作,作为类声明的一部分来定义。方法定义了可以对一个对象执行那些操作。
一种语言要称为面向对象语言,必须支持面向对象几个主要的概念。根据支持程度的不同,通常所说的面向对象语言可以分成两类:基于对象的语言和面向对象的语言。
基于对象的语言
基于对象的语言仅支持类和对象,举例来说,Ada就是一个典型的基于对象的语言,因为它不支持继承、多态,此外其他基于对象的语言还有Alphard、CLU、Euclid、Modula。
面向对象的语言
面向对象的语言支持的概念包括:类与对象、继承、多态。
面向对象的语言中一部分是新发明的语言,如Smalltalk、Java,这些语言本身往往吸取了其他语言的精华,而又尽量剔除他们的不足,因此面向对象的特征特别明显,充满了蓬勃的生机;
另外一些则是对现有的语言进行改造,增加面向对象的特征演化而来的。
如由Pascal发展而来的Object Pascal,由C发展而来的Objective-C,C++ ,由Ada发展而来的Ada 95等,这些语言保留着对原有语言的兼容,并不是纯粹的面向对象语言,但由于其前身往往是有一定影响的语言,因此这些语言依然宝刀不老,在程序设计语言中占有十分重要的地位。
为什么不少编程语言设计「一切皆是对象」
“万物皆对象(Everything is Object)”是OOP中的一个重要思想,
编程语言的最终目的都是提供一种“抽象”方法。而抽象的本质,就是对现实问题的模型映射. 一切皆是映射.
汇编语言是对基础机器的运行指令的映射。后来的许多“命令式”语言(如FORTRAN,BASIC 和 C)是对汇编语言的一种更加偏人类语言化的映射。与汇编语言相比,这 些语言已有了长足的进步,但它们的映射原理依然要求我们着重考虑计算机的结构,而非考虑问题本身的结 构。
在机器模型(位于“方案空间”)与实际解决的问题模型(位于“问题空间”)之间,程序员必须建立 起一种映射(联系)。
LISP把所有问题都映射为"列表"
APL把所有问题都映射为"算法"
PROLOG 则将所有 问题都映射为决策链。
面向对象的程序设计把表达问题空间内的元素都映射为对象。
Alan Kay 总结了 Smalltalk 的五大基本特征。这是第一种成功的面向对象程序设计语言,也是Java 的基础 语言。通过这些特征,我们可理解“纯粹”的面向对象程序设计方法是什么样的:
所有东西都是对象。可将对象想象成一种新型变量;它保存着数据,但可要求它对自身进行操作。理论上讲,可从要解决的问题身上提出所有概念性的组件,然后在程序中将其表达为一个对象。
程序是一大堆对象的组合;通过消息传递,各对象知道自己该做些什么。为了向对象发出请求,需向那个对象“发送一条消息”。更具体地讲,可将消息想象为一个调用请求,它调用的是从属于目标对象的一个 子例程或函数。
每个对象都有自己的存储空间,可容纳其他对象。或者说,通过封装现有对象,可制作出新型对象。所 以,尽管对象的概念非常简单,但在程序中却可达到任意高的复杂程度。
每个对象都有一种类型。根据语法,每个对象都是某个“类”的一个“实例”。其中,“类”(Class) 是“类型”(Type)的同义词。一个类最重要的特征就是“能将什么消息发给它?”。
同一类所有对象都能接收相同的消息。
函数式编程(Functional Programming)
In computer science, functional programming is a programming paradigm—a style of building the structure and elements of computer programs—that treats computation as the evaluation of mathematical functions and avoids changing-state and mutable data. It is a declarative programming paradigm, which means programming is done with expressions or declarations instead of statements.
编程的老祖宗是有限状态机,核心就是状态和状态之间的变化,状态衍生出数据结构(变量结构),而状态的变化衍生出算法(函数),所以有了
程序=数据结构+算法
问题越复杂,状态机的状态越多变化也越多,过程式编程语言hold不住了。
面向对象编程解决方法是拆分,把一个状态机拆成多个命名成对象,对象封装自己的状态与状态变化,整体上看复杂度是上升了,但是局部复杂度是下降了,因为考虑问题时仅需要使用对象这一层抽象,而对象实现时只需要考虑有限几个关联的对象。
函数式编程的解决方法是嵌套,去掉中间状态,整个状态机就是一个大算法,算法之间层层嵌套,每一个算法只需要考虑输入和输出,这样就分解了问题,降低了局部复杂度。而且可以自下而上,通过简单算法组合出复杂的算法.
面向对象主要用的归纳法,有关联的状态与状态变化归纳到同一个对象,而函数式编程就是演绎法了,写函数就像写证明样,同样的前提条件总会得到相同的证明结果,一个证明又可以当作另一个证明的前提条件,所以函数可以传递给函数,而有时候证明结果不一定是一个完整的结果,也有可能只是一个半成品,加点前提条件就能得到完整的结果,所以有了高阶函数。
函数式编程的feature
- 用递归替换循环。
- 难以尾递归的时候考虑使用延续函数(continuation)。
- 高阶函数、部分应用、Lambda演算。
- 用泛型、接口、可区别联合类型替换类继承。
- 用二叉树替换普通链表后可以支持高并发计算。
函数式语言其实就是模仿人的数学思维而发明的朴素,后来因为离机器太远,不容易优化而被诟病。但科技发展到今天,编译器的优化能力已经很强,软件系统越来越复杂,人的分工越来越细,函数式语言离数学更近,离机器更远,反而成为一种优势,有助于人把问题清晰化。从这个层面看,函数式编程是一种什么思维,就是推离机器的数学思维。这里没有内存、寄存器的想法,在 a=1之后,a 就不可能再等于2,当然你可以在 let a = 1 之后,再 let a = 2,但是这个a 就已经不是那个a,在停留在有内存概念的编程世界里,a 一直是 a,它是装东西的桶或者盒子,只是每次里面装的东西不同。
那么,总的来说,是先有朴素的函数式语言,然后才有今天发现函数式编程的好处, 启用了函数式语言的某些 feature,目的是为了把问题解构成更小的粒度。所以这些feature背后没什么逻辑,就好像问这石头为什么长这样一样。我只能打句偈语:本来就这样。
函数式编程是什么
函数式编程(Functional Programming)其实相对于计算机的历史而言是一个非常古老的概念,甚至早于第一台计算机的诞生。函数式编程的基础模型来源于 λ 演算,而 λ 演算并非设计于在计算机上执行,它是由 Alonzo Church 和 Stephen Cole Kleene 在 20 世纪三十年代引入的一套用于研究函数定义、函数应用和递归的形式系统。
函数式编程拥有强大的抽象能力,也正是因为抽象能力强,函数式编程的模型才拥有巨大的潜力。 函数式编程是程序设计范式的一种,就像面向对象编程一样,它是我们解决问题可以选择的模式和思路,它和命令式编程(面向过程、面向对象)之间并不意味着非此即彼的选择,而是可以并存。所以,关键问题不在于函数式编程实不实用,而在于学习一种新的思考模式,这种思考模式能够帮助我们更深入理解程序设计原理和本质,深入了解函数式编程的优点和缺点,从而写出更通用抽象能力更强质量更好的代码。
编程实例
Python环境
$ python
Python 2.7.10 (default, Aug 22 2015, 20:33:39)
[GCC 4.2.1 Compatible Apple LLVM 7.0.0 (clang-700.0.59.1)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
计算0-9各自的平方
>>> sq = map(lambda x: x*x, range(10))
>>> sq
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
[1,2,3,4,5]求和
>>> sum = reduce(lambda x,y: x+y, [1,2,3,4,5])
>>> sum
15
计算数组中正数的平均值
命令式风格:
num =[2, -5, 9, 7, -2, 5, 3, 1, 0, -3, 8]
positive_num_cnt = 0
positive_num_sum = 0
for i in range(len(num)):
if num[i] > 0:
positive_num_cnt += 1
positive_num_sum += num[i]
if positive_num_cnt > 0:
average = positive_num_sum / positive_num_cnt
print average
如果用函数式编程风格:
>>> positive_num=filter(lambda x: x>0, num)
>>> positive_num
[2, 9, 7, 5, 3, 1, 8]
>>> avg=reduce(lambda x,y: x+y, positive_num) / len(positive_num)
>>> avg
5
map, filter, reduce 等等东西写代码处理集合真是非常舒爽, 一眼就能看懂干什么, 又不用担心循环中的边界条件.
当然, 本质上这两种方式没啥区别, 我们要是扒开filter跟reduce函数里面的实现代码,我们可以发现就是一串命令式的实现.
map函数
var map = function (mappingFunction, list) {
var result = [];
forEach(list, function (item) {
result.push(mappingFunction(item));
});
return result;
};
下面是reduce函数的javascript实现
reduce函数
function reduce(actionFunction, list, initial){
var accumulate;
var temp;
if(initial){
accumulate = initial;
}
else{
accumulate = list.shfit();
}
temp = list.shift();
while(temp){
accumulate = actionFunction(accumulate,temp);
temp = list.shift();
}
return accumulate;
};
所以说, 源代码中无新鲜事.都逃不过01010101.
函数式编程的三大特性:
immutable data 不可变数据:
像Clojure一样,默认上变量是不可变的,如果你要改变变量,你需要把变量copy出去修改。这样一来,可以让你的程序少很多Bug。因为,程序中的状态不好维护,在并发的时候更不好维护。(你可以试想一下如果你的程序有个复杂的状态,当以后别人改你代码的时候,是很容易出bug的,在并行中这样的问题就更多了)当然, 事物都是两面性的.immutability 至今表现还不太好的地方是哈希表, 例如 Haskell 的哈希表实现就一片混乱... 如果要实现一个高效的 immutable 并且保持插入顺序的哈希表... 你会比较蛋疼. 现在做得比较好的是 HAMT 或者 RRB tree, 但也没有 mutable 的哈希表快.
first class functions:
这个技术可以让你的函数就像变量一样来使用。也就是说,你的函数可以像变量一样被创建,修改,并当成变量一样传递,返回或是在函数中嵌套函数。
尾递归优化:
我们知道递归的害处,那就是如果递归很深的话,stack受不了,并会导致性能大幅度下降。所以,我们使用尾递归优化技术——每次递归时都会重用stack,这样一来能够提升性能,当然,这需要语言或编译器的支持。
函数式编程的几个技术
map & reduce :
这个技术不用多说了,函数式编程最常见的技术就是对一个集合做Map和Reduce操作。
pipeline:
这个技术的意思是,把函数实例成一个一个的action,然后,把一组action放到一个数组或是列表中,然后把数据传给这个action list,数据就像一个pipeline一样顺序地被各个函数所操作,最终得到我们想要的结果。
recursing 递归 :
递归最大的好处就简化代码,他可以把一个复杂的问题用很简单的代码描述出来。注意:递归的精髓是描述问题,而这正是函数式编程的精髓。
currying:
把一个函数的多个参数分解成多个函数, 然后把函数多层封装起来,每层函数都返回一个函数去接收下一个参数这样,可以简化函数的多个参数。
higher order function 高阶函数:
所谓高阶函数就是函数当参数,把传入的函数做一个封装,然后返回这个封装函数。现象上就是函数传进传出,就像面向对象对象满天飞一样。
parallelization 并行:
所谓并行的意思就是在并行环境下,各个线程之间不需要同步或互斥。
lazy evaluation 惰性求值:
这个需要编译器的支持。表达式不在它被绑定到变量之后就立即求值,而是在该值被取用的时候求值,也就是说,语句如x:=expression; (把一个表达式的结果赋值给一个变量)明显的调用这个表达式被计算并把结果放置到 x 中,但是先不管实际在 x 中的是什么,直到通过后面的表达式中到 x 的引用而有了对它的值的需求的时候,而后面表达式自身的求值也可以被延迟,最终为了生成让外界看到的某个符号而计算这个快速增长的依赖树。
determinism 确定性:
所谓确定性的意思就是像数学那样 f(x) = y ,这个函数无论在什么场景下,都会得到同样的结果,这个我们称之为函数的确定性。而不是像程序中的很多函数那样,同一个参数,却会在不同的场景下计算出不同的结果。所谓不同的场景的意思就是我们的函数会根据一些运行中的状态信息的不同而发生变化。
函数式语言中的另一个利器是 pattern match, 很多费脑子的复杂操作一下就搞定了... 为什么 Thinking in Java 这么大这么厚一本书, 学完了你基本干不了啥事情? 因为在 Java 的思维世界里, 你要和概念的海洋作斗争, 而命名恰恰就是编程的两大难题之一. 如果有 pattern match, 哪用想这么多没意义的中间变量名?
函数式编程是一种编程思想或者说编程泛型,函数式编程强调函数计算本身,而不是如同经典的命令式编程模型那样强调指令执行。当然, 函数式编程最终代码落地还是CPU指令.
如同面向对象和面向过程不可分割一样,函数式、面向对象和面向过程的编程泛型常常混合于我们的代码中,如果不去刻意区分它们,很难将它们彻底独立开来。
简言之,不论是面向对象编程还是函数式编程,如果你走了极端,那都是错误的。面向对象编程的极端是一切都是对象(纯面向对象)。函数式编程的极端是纯函数式编程语言。
面向对象的问题在于它对“对象”的定义,它试图将所有事情就纳入到这个概念里。这种做法极端化后,你就得出来一个一切皆为对象思想。
相似的,函数式编程走向极端、成为一种纯函数式编程语言后,也是有问题的。有些东西不是纯的。副作用是真实存在的。所谓纯函数,基本上就是忽略了物质基础(硅片、晶体等)表现的特性。纯函数式的编程语言试图通过函数——在函数中传入传出整个宇宙——来重新实现整个宇宙。但物理的和模拟的是有区别的。“副作用”是物理的。它们真实的存在于自然界中,对计算机的效用的实现起着不可或缺的作用。利用纯函数来模拟它们是注定低效的、复杂的、甚至是丑陋的。
还有,纯函数编程语言会带来巨大的认知成本。如果你深入观察它们,你会看到monads使程序变得复杂,难于编写,而且monad的变体都是拙劣的修改。monads跟Java的“设计模式”具有相同的精神本质。
函数式编程和面向对象编程不是一个相对应层次的概念。与函数式编程对应的应该是指令式编程。而面向对象编程更多的是一种设计思路。
理论上,函数式编程的理论基础是Lambda演算,指令式编程基于图灵机;
从程序员角度来看,函数式编程不支持赋值操作,一个函数的执行只会返回一个值,不会有任何副作用,所以看上去,一个函数就是一个大的表达式。
由于现在机器的指令集都是基于图灵机的,函数式语言的执行效率不高,可能是优化比较难。
函数式编程是一种编程范式,我们常见的编程范式有命令式编程(Imperative programming),函数式编程,逻辑式编程,常见的面向对象编程是也是一种命令式编程。
命令式编程是面向计算机硬件的抽象,有变量(对应着存储单元),赋值语句(获取,存储指令),表达式(内存引用和算术运算)和控制语句(跳转指令),一句话,命令式程序就是一个冯诺依曼机的指令序列。
而函数式编程是面向数学的抽象,将计算描述为一种表达式求值,一句话,函数式程序就是一个表达式。
函数式编程的本质
函数式编程中的函数这个术语不是指计算机中的函数(实际上是Subroutine),而是指数学中的函数,即自变量的映射。也就是说一个函数的值仅决定于函数参数的值,不依赖其他状态。比如sqrt(x)函数计算x的平方根,只要x不变,不论什么时候调用,调用几次,值都是不变的。
在函数式语言中,函数作为一等公民,可以在任何地方定义,在函数内或函数外,可以作为函数的参数和返回值,可以对函数进行组合。
纯函数式编程语言中的变量也不是命令式编程语言中的变量,即存储状态的单元,而是代数中的变量,即一个值的名称。变量的值是不可变的(immutable),也就是说不允许像命令式编程语言中那样多次给一个变量赋值。比如说在命令式编程语言我们写“x = x + 1”,这依赖可变状态的事实,拿给程序员看说是对的,但拿给数学家看,却被认为这个等式为假。
函数式语言的如条件语句,循环语句也不是命令式编程语言中的控制语句,而是函数的语法糖,比如在Scala语言中,if else不是语句而是三元运算符,是有返回值的。
严格意义上的函数式编程意味着不使用可变的变量,赋值,循环和其他命令式控制结构进行编程。
从理论上说,函数式语言也不是通过冯诺伊曼体系结构的机器上运行的,而是通过λ演算来运行的,就是通过变量替换的方式进行,变量替换为其值或表达式,函数也替换为其表达式,并根据运算符进行计算。λ演算是图灵完全(Turing completeness)的,但是大多数情况,函数式程序还是被编译成(冯诺依曼机的)机器语言的指令执行的。
函数式编程的好处
由于命令式编程语言也可以通过类似函数指针的方式来实现高阶函数,函数式的最主要的好处主要是不可变性带来的。没有可变的状态,函数就是引用透明(Referential transparency)的和没有副作用(No Side Effect)。
一个好处是,函数即不依赖外部的状态也不修改外部的状态,函数调用的结果不依赖调用的时间和位置,这样写的代码容易进行推理,不容易出错。这使得单元测试和调试都更容易。
不变性带来的另一个好处是:由于(多个线程之间)不共享状态,不会造成资源争用(Race condition),也就不需要用锁来保护可变状态,也就不会出现死锁,这样可以更好地并发起来,尤其是在对称多处理器(SMP)架构下能够更好地利用多个处理器(核)提供的并行处理能力。