learn-tech/专栏/JavaScript进阶实战课/01函数式vs.面向对象:响应未知和不确定.md
2024-10-16 06:37:41 +08:00

19 KiB
Raw Blame History

                        因收到Google相关通知网站将会择期关闭。相关通知内容
                        
                        
                        01 函数式vs.面向对象:响应未知和不确定
                        你好,我是石川。

编程模式programming paradigm可以说是编程语言的元认知。从编程模式的角度看JavaScript它是结构化的、事件驱动的动态语言且支持声明式和指令式两种模式。所以我们说JavaScript是一个多模式multi-paradigm的语言也是一门“丰富”的语言。

在JavaScript所支持的编程模式中用得最多的是面向对象OOP object oriented programming和函数式FP functional programming两种其中又以面向对象的普及率最高。现在介绍面向对象的书已经很多了函数式因为受众相对小一些支持的语言少一些所以被提及的也相对比较少。

我猜你也许已经对这两种编程模式有所了解甚至会比较熟悉但我之所以还是要在第一节课去强调这个话题是因为你在学习JavaScript时可能会面对以下至少 1个核心痛点

如果你已经学过传统的面向对象语言那么在学JavaScript的时候很可能对函数式的理解和运用不够深入 反之如果你一开始就学习JavaScript只是停留在开发一些简单应用上可以说你对它的面向对象的理解和运用也不会很深入。

这两种学习困扰,很多时候会导致我们刚知道了点概念,就碰上了千奇百怪的副作用,然后我们发现还需要去学习解决它的办法,最后往往很容易就放弃了。

补充:在开篇词里,我提到函数式+响应式编程可以对抗不确定性。这个概念不只是在编程中它也是一个跨学科的研究。比如在AI、机械和航空航天工程这些硬科技的领域以及很多知名的大学如伯克利、麻省理工和政府机构如NASA都对System Dynamics and Controls开展了很深入的研究。其核心就是研究在动态情况下如何做到系统控制其中很重要的一点就是处理波动和干扰。

而在函数式编程中我们通常会把各种干扰就叫做副作用Side effect

所以接下来我会先带你从“思维大厦”的顶层开始来了解JavaScript语言的核心思想然后再带你看看如何因地制宜地使用这两种编程模式来解决问题。这样一来你在日后面对已知和未知问题做复杂系统开发的时候也能找到一套行之有效的方法了。

函数式编程

首先我们一起来看看函数式编程了解下函数是什么、它是做什么用的、在编程中可能会有哪些副作用以及如何利用JavaScript的核心设计思想和工具解决这些副作用。

函数是什么、如何使用?

一个函数由输入、函数和输出组成,这和我们在初中就学过的函数一样,函数是数据集到目标的一种关系,它所做的就是把行为封装起来,从而达到目标。

举一个最简单的例子:我们要实现一个“计算消费税”的工具,目标是通过产品价格计算出消费税。

以下代码中的productPrice是输入的形参parameter产品价格100元是传入的数据实参argument而calculateGST这个功能就是封装算法的函数本身代码中输出的5就是返回值returned value也就是消费税5元。

function calculateGST( productPrice ) { return productPrice * 0.05; } calculateGST(100); // return 5

其实很多开发者常用的jQuery就是一个工具集。我们打开jQuery在GitHub的源代码可以看到里面有大大小小的工具助手。比如下面这个isArrayLike函数就是一个帮助我们判断一个对象是不是类似数组的功能。这个功能也可以独立于jQuery库存在这就是函数式编程最基本的使用。

function isArrayLike( obj ) { var length = !!obj && obj.length, type = toType( obj );

if ( typeof obj === "function" || isWindow( obj ) ) { return false; }

return type === "array" || length === 0 || typeof length === "number" && length > 0 && ( length - 1 ) in obj; }

所以通过isArrayLike可接受的参数可见函数的输入值不仅可以是一个基础类型数据primitive type比如前面例子中的数字或者字符串也可以是一个相对复杂些的对象类型数据object type包括对象本身和数组。甚至函数本身作为对象也可以是输入或输出值我们把这种函数就叫做高阶函数higher order functions

函数中都有哪些副作用?

前面我们说过,函数已经把算法封装了起来,那么函数里相对就是可控的,而比较不可控的是外部环境。这里,我们可以把不可控的外部环境分为三大类。

第一类函数中最常见的副作用就是全局变量global variable。比如下面的例子里我们首先定义了一个全局变量x之后每次在log它的值之前都执行了不同的函数但我们没法保证这些函数没有改变这个变量的值也没法保证每次输出的结果是1。所以从输入开始这种不确定性就存在了。

var x = 1; foo(); console.log( x ); bar(); console.log( x ); baz(); console.log( x );

除了全局变量以外,另一个比较明显的问题就是 IO影响IO effects。这里的IO说的不是前面函数里的参数和返回值而是类似前端浏览器中的用户行为比如鼠标和键盘的输入或者如果是服务器端的Node的话就是文件系统、网络连接以及stream的stdin标准输入和stdout标准输出

第三种比较常见的副作用是与网络请求HTTP request相关比如我们要针对一个用户下单的动作发起一个网络请求需要先获得用户ID再连着用户的ID一起发送。如果我们还没获取到用户ID就发起下单请求可能就会收到报错。

减少副作用:纯函数和不可变

那么我们要如何减少以上这些副作用呢在函数式编程中有两个核心概念纯函数pure function和不可变immutability

这是一个“双循环”,纯函数更多解决的是“内循环”;而不可变更多考虑的是“外循环”。

纯函数的意思是说一个函数的返回结果的变化只依赖其参数并且执行过程中没有副作用。也就是说打铁还需自身硬面对外界的复杂多变我们要先保证函数封装的部分本身是稳固的。比如前面消费税计算器的例子当输入的产品价格参数为100时输出的结果永远是5。无论有什么干扰它都不会返回一个不是5的数字除非你换一个参数。

我们再来看下面这个例子,当把税率从函数中抽离出来,放在函数外作为变量时,它就不是一个纯函数了,因为随着这个变量的变化,计算结果会有所不同。所以,纯函数就可以通过减少对外界不确定因素的依赖,来减少副作用。

var rate = 0.05; function calculateGST( productPrice ) { return productPrice * rate; } calculateGST(100); // return 5

除了纯函数函数式编程解决副作用的另一个核心思想就是不可变。这个如何理解呢我们可以通过JavaScript中自带的splice和slice来举例。

const beforeList = [1,2,3,4] console.log(beforeList.splice(0,2)) console.log(beforeList.splice(0,2)) //[ 1, 2 ] //[ 3, 4 ]

const beforeList = [1,2,3,4] console.log(beforeList.slice(0,2)) console.log(beforeList.slice(0,2)) //[ 1, 2 ] //[ 1, 2 ]

可以看到数组中的splice方法在对数据进行了处理后改变了全局中的beforeList的值所以是可变的。而slice在执行之后的结果没有影响全局中的beforeList的值所以它是不可变的。也是因为这样在开发中如果要保证不可变我们就不能用splice而用slice。

所以,不可变就是在减少程序被外界影响的同时,也减少对外界的影响。因为如果你把一个外部变量作为参数作为输入,在函数里做了改变,作为输出返回。那么这个过程中,你可能不知道这种变化会对整个系统造成什么样的结果。

而且在数组中你还可以看到更多类似splice和slice这种纯函数、非纯函数以及可变与不可变的例子。

另外,从纯函数和不可变的设计思想中,我们还可以抽象出一个概念。

因为“副作用”首先是一个作用effect而作用遵循的是一个因果cause and effect关系。那么从值的角度来看“纯函数”对值只影响一次而“不可变”完全不影响。

如何理解“纯函数”对值只影响一次呢这里有一个幂等idempotence的概念。如果你做过大型的交易类应用的话应该对这个概念不陌生。比如说有时用户对同一个订单多次重复发出更新请求这时返回的结果不应该有差别。

在数学中幂等的意思是不管我们把一个函数嵌套多少次来执行它的结果都应该是一样的。比如在这个Math.round四舍五入的例子里无论你嵌套执行几次结果都是一样的。

//数学幂等 Math.round(((0.5)))

在计算机中幂等的意思是一个程序执行多次结果是一样的。比如假设我们有一个adder函数3和4相加永远返回7。所以你其实可以把数学里的概念迁移过来。

//计算机幂等 adder (3, 4) // 返回 7 adder (3, 4) // 返回 7

好,然后我们再来看看如何理解“不可变”对值完全不影响。

通过前面array slice和splice的例子你应该能感觉到splice更像是一块橡皮泥一开始它可能是个方块儿你可以捏出腿和脑袋它就成了一个小人儿也就是说它本身发生了变化。而slice在处理完后是形成了一个新的数组但原始的数组完好无损它是把值当成乐高积木而不是橡皮泥。把这种思想用到状态管理里你就会记录状态的变化而不会篡改状态。

总之,我们可以看到函数式编程最核心的地方,就是输入输出和中间的算法,我们要解决的核心问题就是副作用。而为了解决副作用,我们需要掌握两个重要的概念,一个是纯函数,一个是不可变。纯函数强调的是自身的稳定性,对结果只影响一次;而不可变强调的是和外界的交互中,尽量减少相互间负面的影响。

面向对象编程

我们再来看看面向对象。如前面所说,如果我们用函数来做一个税率计算工具,判断一个数是不是类数组的对象,是没问题的,而且我们可以放心,如果希望它“纯粹”,那么它运行的结果就可以基于我们定义的法则,没有惊喜,没有意外。那这样不就足够了?为什么我们还需要对象?下面我们就来看看。

对象是什么、如何创建?

开篇词里我说过一个“摸着石头过河”的例子首先得有站在岸边的“你”这个“你”就是对象如果没有对象就算是有一个工具function比如有快艇在岸边它也只能停靠在那儿或者你有游泳这个方法method但它也只有在你身上才能发挥作用。

这其实就是对象的意义。我们在做业务系统开发的时候,会面对各种各样的业务对象,比如“表单”“购物车”“订单”,这些都可以看做是对象。所以我们说,工具和方法通常是服务于对象的。

举个例子假设我们有一个微件对象我们想定义一个属性是它的微件名称widgetName并给它定义一个identify的功能来识别自己的名称那么在JavaScript中其实就可以通过以下代码来实现

var widget = { widgetName : "微件", identify : function(){ return "这是" + this.widgetName; } };

console.log(widget.widgetName); // 返回 "微件" console.log(widget.identify()); // 返回 "这是微件"

为什么需要封装、重用和继承?

实际上,如果说函数加对象组成了生产力,那么封装、重用和继承则可以用来组成生产关系。

封装最常见的使用就是在我们做组件化设计的时候比如在一个旅行网站页面中我们看到的筛选器、日历、结果区域都可以看做是不同的模块module或组件 component这些组件是通过封装导入加载到页面的。

重用就是把可以重复使用的功能抽象到一个类里每次只是创建一个它的实例对象来使用。比如我们的页面上有很多按钮它们的功能大同小异这时我们就可以把它抽象出来成为一个类class每一个按钮都是一个按钮类中的实例instance

当然上面我们说的按钮可能虽然功能上大同小异但还是有具体差别。这时我们可以把通用功能放到抽象类而一些特定的行为或属性我们可以通过继承放到实现类中这样在继承了基础的父类parent class功能的基础上extend我们能够在子类child class中作一些改动。

但是如果一个程序中,父子的层级过于复杂,也会变得“官僚化”,如果父类有了问题,就会牵一发动全身,而且抽象的层级过多,也会让代码难以理解。

实际上在面向对象中也有组合的概念就是一个子类不是继承的某个父类而是通过组合多个类来形成一个类。这也很像我们如今的职场公司为了应付外界竞争压力内部会有一个个的敏捷团队通过每个成员自身价值和团队“组合”产生1+1>2的价值而不是强调依靠某种从属关系。

所以,在面向对象的编程中,也有“组合”优于“继承”的概念。不过在实际情况下,继承也不是完全不可取的,在开发中,我们使用哪种思想还是要根据情况具体分析。

什么是基于原型的继承?

好,既然说到了继承,那我们还需要明确一个问题,什么是基于原型的继承?

这里我们首先要搞清楚一点JavaScript中的类和其它面向对象的语言究竟有什么不同

对于传统的面向对象的编程语言来说比如Java一个对象是基于一个类的“蓝图”来创建的。但是在JavaScript中就没有这种类和对象的拷贝从属关系。实际上JS里的对象和“类”也就是构建函数之间是原型链接关系。

比如,在下图左边基于类的例子中,以一个类作为蓝图,可以创建两个实例。而右边基于原型的例子里,我们可以看到通过一个构建函数构建出的两个对象,是通过原型链和构建函数的原型相连接的,它们并不是基于一个蓝图的复制拷贝和从属关系。

虽然后来JavaScript在ES6之后也加入了类但实际上它只是运用了语法糖在底层逻辑中JavaScript使用的仍然是基于原型的面向对象。

在ES6+中class的语法糖用法基本和之前的类似只是把function变成了class

class Widget { constructor (){ // specify here } notice(){ console.log ("notice me"); }

display(){ console.log ("diaplay me"); } }

var widget1 = new Widget();

widget1.notice(); widget1.display();

我们再通过一个例子来实际观察下原型链。下面的代码中我们是通过函数自带的call()方法和对象自带的Object.create()方法让Notice作为子类继承了Widget父类的属性和方法然后我们创建了两个实例notice1和notice2。

而这时我们如果用getPrototypeOf来获取notice1和notice2的原型会发现它们是等于Notice原型。当我们用display方法调用这个方法时实际调用的是原型链里Notice的原型中的方法。

function Widget(widgetName) { this.widgetName= widgetName; }

Widget.prototype.identify = function() { return "这是" + this.widgetName; };

function Notice(widgetName) { Widget.call(this, widgetName); }

Notice.prototype = Object.create(Widget.prototype);

Notice.prototype.display= function() { console.log("你好, " + this.identify() + "."); };

var notice1 = new Notice("应用A"); var notice2 = new Notice("应用B");

Object.getPrototypeOf(notice1) === Notice.prototype //true Object.getPrototypeOf(notice2) === Notice.prototype //true

notice1.display(); // "你好这是应用A" notice2.display(); // "你好这是应用B"

而这就印证了前面所说的在传统的面向对象语言比如Java里当我们用到继承时一个类的属性和功能是可以被基于这个类创建的对象“拷贝”过去的。但是在JavaScript里虽然我们用Notice创建了notice1和notice2但是它俩并没有将其属性和功能拷贝过来而是默认通过原型链来寻找原型中的功能然后利用“链接”而不是“拷贝”来。

for (var method in Notice.prototype) { console.log("found: " + method); } // found: display // found: identify

所以我们通过上面的for in就可以找出所有原型链上的功能而如果我们想要看Notice函数的原型对象都有哪些功能的话可以看到返回的是display和identify。这就证明除了Notice自己的原型对象和自己的display功能之外它也链接了Widget里的identify功能。

现在我们知道了面向对象编程最核心的点就是服务业务对象最需要解决的问题就是封装、重用和继承。在JavaScript中面向对象的特殊性是基于原型链的继承这种继承更像是“授权”而不是传统意义的“父子”继承。而且为了解决继承的层级过多的情况在面向对象中也有组合优于继承的思想。

总结

这节课,我们主要是了解了函数式编程和面向对象编程的核心概念,它们一个是管理和解决副作用,一个是服务于业务对象。

而理解这部分内容对于我们接下来要深入了解的这两种编程模式以及后面会学习的各种数据结构和算法、JavaScript的各种设计模式等等都有很强的指导意义它能为我们学好并更好地应用JavaScript这门语言提供扎实的理论基础。

思考题

我们提到函数式编程的时候说到为了解决副作用因此有了不可变和纯函数的概念那么你觉得JavaScript中的常量constconstant算不算不可变呢

欢迎在留言区分享你的答案、交流学习心得或者提出问题,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。

延伸阅读

Beginning Functional JavaScript Function Light JS JavaScript世界的一等公民——函数 Mastering JavaScript Functional Programming You Dont Know JS: this & Object Prototypes JavaScript Patterns Learning JavaScript Design Patterns