命令式、声明式、面向对象、函数式、控制反转之华山论剑(上)

4,216 阅读9分钟

命令式、声明式、面向对象、函数式、控制反转之华山论剑(上)

我接触编程比较晚,从自学java开始,面向对象的思想就已经深入骨髓。之前那些年,我的代码只有这一种编码风格。

这些年来,js发生了翻天覆地的变化,前端已经远不是那个dom横行,ajax调用接口的时代。数据驱动、函数式(声明式编程)、工程化、Node、状态管理等大量新兴的技术进入眼帘。我们亲眼见证前端代码从面向对象到函数式的转变,从抵制到接受,从学习到惊叹,惊叹一等对象的神奇,惊叹仅仅声明配置就可以完成功能,惊叹js居然有这样的高玩。

当然,我也见过太多的人对函数式嗤之以鼻,觉得函数式编程难以维护,在业务复杂的场景下容易形成维护噩梦(asserts hell)。今天,以我个人的立场(完全中立,不带任何面瘫色彩),就函数式编程与面向对象编程做简单的博弈,顺便介绍下从Java中spring框架就开始兴起的控制反转思想,这种思想的两个组成部分依赖注入依赖收集正式大名鼎鼎的angular的mvvm的实现原理。

命令式与声明式的区别

我们有两种编程方式:命令式和声明式。面向对象编程属于命令编程与声明式的结合。
  
我们可以像下面这样定义它们之间的不同:

  • 命令式编程:命令“机器”如何去做事情(how),这样不管你想要的是什么(what),它都会按照你的命令实现。
  • 声明式编程:告诉“机器”你想要的是什么(what),让机器想出如何去做(how)。

举个简单的例子,假设我们想让一个数组里的数值翻倍。

命令式:

var numbers = [1,2,3,4,5]
var doubled = []
for(var i = 0; i < numbers.length; i++) {
  var newNumber = numbers[i] * 2
  doubled.push (newNumber)
}
console.log (doubled) //=> [2,4,6,8,10]

声明式

var numbers = [1,2,3,4,5]
var doubled = numbers.map (function (n) {
  return n * 2
})
console.log (doubled) //=> [2,4,6,8,10]

map函数所做的事情是将直接遍历整个数组的过程归纳抽离出来,让我们专注于描述我们想要的是什么(what)。注意,我们传入map的是一个纯函数;它不具有任何副作用(不会改变外部状态),它只是接收一个数字,返回乘以二后的值。
  
在一些具有函数式编程特征的语言里,对于 list数据类型的操作,还有一些其他常用的声明式的函数方法。例如,求一个list里所有值的和,命令式编程会这样做:

var numbers = [1,2,3,4,5]
var total = 0 for(var i = 0; i < numbers.length; i++) {
  total += numbers[i]
}
console.log (total) //=> 15

而在声明式编程方式里,我们使用reduce函数:

var numbers = [1,2,3,4,5]
var total = numbers.reduce (function (sum, n) {
  return sum + n
});
console.log (total) //=> 15

reduce函数利用传入的函数把一个list运算成一个值。它以这个函数为参数,数组里的每个元素都要经过它的处理。每一次调用,第一个参数(这里是sum)都是这个函数处理前一个值时返回的结果,而第二个参数(n)就是当前元素。这样下来,每此处理的新元素都会合计到sum中,最终我们得到的是整个数组的和

同样,reduce函数归纳抽离了我们如何遍历数组和状态管理部分的实现,提供给我们一个通用的方式来把一个list合并成一个值。我们需要做的只是指明我们想要的是什么?

声明式编程为什么让某些人疑惑,不屑,甚至排斥?

从声明式编程诞生的那天起,对声明式编程与命令式编程的讨论就没有停止过。作为程序员,我们非常习惯去命令计算机去做某些事情。遍历列表,判断,赋值已经是我们逻辑中最常见的代码。

在很多情况中,命令式编程确实非常直观、简单并且编码运行效率最高,最重要的,维护的人也非常容易理解。加上大多数人并不理解函数的本质,只能把逻辑与数据封装到一个个对象中,以上的种种原因,导致声明式编程一直没有成为主流的编程模式。甚至有人觉得声明式编程是反人类思维模式的编程,只是为了写一些所谓高大上的“玩具”产生的模式。

如果我们花时间去学习声明式的可以归纳抽离的部分,它们能为我们的编程带来巨大的便捷。首先,我可以少写代码,这就是通往成功的捷径。其次,我们可以抽象出非常实用的工具类,对对象或者函数进行深度加工,嵌套,运算,直到得到想要的结果。最后,每当有需求变更时候,大多数情况下,我们无需改写框架(声明分析)代码,只需要修改声明的配置即可完成需求变更。

最重要的,它们能让我们站在更高的层面是思考,站在云端思考我们想要的是什么,什么是变化的,什么是不变的,找到变化,配置之,找到不变,封装之。最后你会发现,我们不关心变化,因为变化的通过配置来声明,我们只关心不变,也就是框架,用框架(不变)来处理声明(变化),正如道家的哲学,以不变(框架)应万变(声明)。而不是站在底层,思考事情该如何去做。

(通常来说,核心的架构师编写不变的框架,低P/T编写配置声明,不要以为配置仅仅是json等格式,在函数式编程里,配置往往是函数/类或者任何对象)

面向对象编程与函数式编程

面向对象

将现实世界的物体抽象成类,每个物体抽象成对象。用继承来维护物体的关系,用封装来描述物体的数据(属性)与行为(方法),通过封装技术,消息机制可以像搭积木的一样快速开发出一个全新的系统。既可以提高编程效率,又增强了代码的可扩展/维护等灵活性,是世界上运用最广泛的编程方法(个人观点:没有之一)。

面向对象语言是命令式编程的一种抽象。抽象包括两方面,数据抽象与过程抽象。在JS中,面向对象编程(也就是我们常说的基于对象,因为JS并不是面向对象的语言)把逻辑与数据封装到函数与原型中,通过函数的原型链拷贝实现继承,而代码的运行逻辑与数据依然封装在函数内,但是做了属性与方法的区分。优秀的面向对象编程显然可以做到声明式编程,也就是根据声明配置生成结果(也就是说,面向对象编程的逻辑是预设的,我们可以根据输入条件,判断走不同的逻辑)。

但是绝大多数的面向对象编程,不会根据声明配置去生成逻辑,逻辑的调用是封装在对象中,而不是动态生成。所以并没有做到真正的声明式,也就是数据与逻辑完全分离。这里所说的动态生成逻辑,是根据声明,自动完成逻辑的生成,这样就完全可以不用编写业务代码,而仅仅靠声明来完成逻辑的实现,而这部分处理,交给框架处理即可。

函数式编程

把逻辑完全视为函数的计算。把数据与逻辑封装到函数中,通过对函数的计算,加工,处理,来生成新的函数,最后拼装成一个个功能独立的函数。在运用这些函数,完成复杂逻辑的实现。

与现象对象不同的是,我们把数据和逻辑封装到函数中而不是类与对象中。每个函数完全独立,好的函数式设计,每个函数都是一个纯函数(pure function,即输入固定参数,即可得到相同输入的函数)。优点是:

  • 面向对象中的任何一个原型方法(prototype)都会获得this的数据,而且可以轻易获取闭包的数据。这样的非纯函数让我们非常难以提炼与抽象。
  • 纯函数由于输入与输出固定,所以变得非常容易单测。好的函数式中的函数设计,不会依赖于任何其他函数或者声明配置,只需要传递参数,既可以进行测试。而在面向对象语言中,我们往往需要启动整个工程,或者说所有依赖的类全部要加载,才能开始测试。
  • 对逻辑做抽象与提取,让我们避免在函数内做判断与循环,我们只需要把具体处理封装到函数中,而程序运行过程中的走向、判断与循环通常交给底层框架来处理。这让我们完全有能力动态生成逻辑。比如大名鼎鼎的d3和rx,逻辑与逻辑处理的代码完全分离,代码可读性非常高。

既然本文介绍的主要是函数式编程,所以主观评价了函数式的优点。当然面向对象的编程模式优点更加突出,各位客官已经非常熟悉封装、继承、多态给我们带来的优点,代码可读性与可维护性在所有模式中名列前茅,面向对象编程位列神坛已久,在此不必多言。