js中的面向对象编程和函数式编程

2,929 阅读12分钟

本文会结合两种常见的编程范式,对js语言层面的一些特性进行复习,并对Composing Software中的观点加以理解。


编程范式

前文我们讨论了编程范式,这里稍微回顾一下:

在我们编程中每解决一个问题对应concept,每种paradigm是多个concepts的组合,而每种编程语言实现了一种或多种paradigms。

常见concept组合

  • [record] 当只有记录(record)概念,被称为描述式声明编程(descriptive declarative programming),比如xml
  • [record,procedure] 当添加上过程(procedure)被称为一等函数式编程(first-order functional programming)
  • [record,procedure,cell(state)] 当添加上使用cell传递数据,则被称为命令式编程(imperative programming),比如C语言,cell在这里代表一种类似细胞之间的信息传递方式,state表示状态。
  • [record,procedure,cell(state),closure] 当添加上闭包(closure)就可以称为顺序面向对象编程(sequential object-oriented programming) 或有状态的函数式编程(stateful functional programming),比如java,当加上thread后就有了并发的能力,这里暂且不提。
  • [record,procedure,closure] 当OOP基础上减去状态,就成了函数式编程(functional programming),比如scheme,js就是参考了scheme中很多特性

面向对象和函数式

OOP和FP是两种比较常见的编程范式,也被js同时支持,相关讨论有很多,这里只代表其中一种看法,更详细的会在后面章节介绍。

核心

按照前文说法,两种范式的区别就是FP比OOP少了一个state concept,即

面向对象侧重于对数据的各种处理,数据是对象的内部状态, C++ Primer Plus 对对象的本质表述为用来设计并扩展自己的数据结构:

The essence of object-oriented programming (OOP) is designing and extending your own data types

函数式侧重于数据流的流动,数据是外界的输入,并最终将对应结果返回,A Brief Intro to Functional Programming 对于函数式编程概括为一种数据流,而不是控制流

The core of Functional Programming is thinking about data-flow rather than control-flow

适用场景

以下结论参考Functional programming vs Object Oriented programming [closed]

  • 当对事物有固定操作时选择面向对象,主要用来添加新的事物,可以通过添加新类实现现存的方法来完成,存在的问题是当需要添加新操作时需要编辑很多类。 在项目中的例子比如对下载器的封装,每种下载器都有开始、暂停、恢复等固定动作。
  • 当事物本身固定时选择函数式编程,主要是对现有事物添加操作,可以通过添加函数处理当前数据类型,存在的问题是当需要添加新事物时,需要编辑很多参与处理的函数。最直接的例子就是编译器,输入源代码,通过一系列操作,输出目标代码;再比如webpack中的loader。

面向对象专家 Michael Feathers 也说过:

OO makes code understandable by encapsulating moving parts. FP makes code understandable by minimizing moving parts.

面向对象的可读性是通过封装各部分,函数式的可读性是通过最小化各部分,即前者将数据和数据的动作进行封装,后者将各种操作拆分以最小化。

在日常开发怎么选择

每一个应用都是由各种组件组合而成,只不过组件的形式不固定,比如functions,data structures,classes等。
不同编程语言倾向于使用不同的原子元素组成组件,比如java使用对象,haskell使用function等。而在js中,因为天然支持面向对象和函数式,因此在项目中往往混合使用,我们可以使用对象组合来为函数式编程生成数据类型,使用函数式编程为面向对象编程生成对象,不管怎么写,软件开发的本质就是组合。

The essence of software development is composition

我们的工作就是根据各种情况使用各种编程范式,像盖房子一样将各种组件组合起来,在具体讨论两种范式之前我们先多了解一下js本身,

js中的相关特性

我们需要先对js这种语言中的各种特性有所了解,才能利用范式这种工具更好的对我们开发的软件进行组合。 这一部分主要会参考ecma262

js语言的设计

Brendan Eich开发了js。他在这篇文章提到了js设计过程中的一些问题。
Netscape需要在浏览器中内置一种脚本语言,根据领导要求,首先需要像java(Look like Java,因此很多语法和java类似),而作者本人偏向于scheme,因此最后在新的语言中选择了和Scheme一样的一等函数以及和Self一样的prototype作为主要组成。受java影响,有了原始类型和对象的区分,比如string和String。

以上设计加上后来的发展就成了现在的js。

js特性概述

在js中的数据结构分为两种,原始类型和对象,js的对象创建并不是基于class的,而是有很多方式,比如字面量或者构造函数,每个构造函数都有一个prototype属性用于实现基于原型(prototype-based)的继承。一个构造函数的prototype还有一个constructor引用指向构造函数本身,当实现继承时,这个属性可能会改,按照惯例需要修正,但不是必须的(关于constructor参考这里)。

说起构造函数,这里补充一点相关概念,函数是一种特殊的对象,含有internal method [[Call]],因此可以通过函数调用来执行相关代码,而构造函数又是一种特殊的函数,含有internal method [[Construct]],可以通过newsuper调用创建对象。

js中的对象

原型链

每一个通过构造函数创建的对象都有一个指向构造函数prototype属性的隐式引用(可以用__proto__访问但不推荐),而这个prototype本身可能也有一个非null的引用指向它的prototype,等等,这就被称为原型链。当访问对象的一个属性时,会首先从该对象本身查找,如果找不到就会沿着原型链依次查找,直到找到或者找到尽头发现没有,原型链上的属性可以被覆盖。

相较基于class继承的语言,通常来说,状态被实例拥有,方法被class拥有,继承的只有结构和行为(behavior),而js这一切都是可以继承的。这里的行为是方法整体决定的,如果没有方法,一个class将只有结构

原型链的尽头为null,要明确一个对象的原型链到底包括什么,这里可以大概分为以下

  • 如果是个new Object()或字面量{},其原型链为
var obj={}
//原型链 obj=>Object.prototype=>null
  • 如果是new调用了其他构造函数,包括自定义的或者内置的(比如Array,内置构造函数不一定需要new调用,比如也可以通过字面量或者不使用new,比如[]Date()),这里以Array为例
var arr =[]
//arr=>Array.prototype=>Object.prototype=>null
//如果是自定义构造函数也一样
var P=function(){}
var p=new P()
//p=>P.prototype=>Object.prototype=>null
  • 如果在上一种情况下,延长原型链,其实就是怎么样实现继承
//1. 使用Object.create()
var q=Object.create(p)
// q=>p

//2. 直接修改构造函数的prototype
function Q(){}
Q.prototype=p
var q=new Q()
//q=>p
//3. 通过Object.setPrototypeOf(obj, prototype)设置__proto__属性,可以直接修改原型链,这个操作很浪费性能,少用
function P(){
    this.b=1
}
function Q(){
    this.a=2
}
var q=new Q()
Object.setPrototypeOf(q,P.prototype)
// q=>P.prototype
//4. 使用call和apply借用构造函数时,和原型无关
var P=function(v){
    this.a=v
}
function Q(v){
    P.call(this,v)
}
var q=new Q(2)
// q=>Q.prototype=>Object.prototype=>null

我们可以通过object instanceof constructor 判断一个构造函数的prototype是否在指定对象的原型链中

function Q(){
    this.a=2
}
var q=new Q()
console.log(q instanceof Q)
console.log(q instanceof Object)

可以通过Object.getPrototypeOf(object)获得对象的__proto__属性

Object

Object.prototype上有一些属性和方法被其他所有对象继承,在特定对象继承过程中可能会对某些字段重写。
另外Object上还有很多静态方法用于处理关于对象的各种操作,具体请参考mdn

js中的函数

在js中所有函数都是Function的实例,包括Object和Function本身,乃至各种内置构造函数(比如Array),因此有

Function.__proto__===Function.prototype//true
Function.prototype.__proto__===Object.prototype//true,即Function instanceof Object
//原型链 Function=>Function.prototype=>Object.prototype,以下类似

Object.__proto__===Function.prototype//true,即Object instanceof Function
Array.__proto__===Function.prototype //true

function a(){}
a.__proto__===Function.prototype//true

可见,所有函数的原型链到达Object.prototype之前需要先经过Function.prototype,一个函数是一个对象,更是一个函数。

Function

Funciton.prototype上有一些方法值得我们关注

  • func.apply(thisArg, [argsArray])
  • function.call(thisArg, arg1, arg2, ...)
  • function.bind(thisArg[, arg1[, arg2[, ...]]])

其中前两个在一个对象的上下文应用另一个对象的方法,第三个用于修改上下文,其余参数会在返回的函数调用时使用

js的函数式编程

在具体的了解函数式编程之前,这里先了解一些概念,参考Composing Software。

概念

Pure Function

一个纯函数是一个函数,符合以下特点

  • 相同的输入总是返回相同输出
  • 没有副作用

纯函数在函数式编程中很重要,但是实际的开发中,函数或多或少会有一些副作用,比如数据获取和操作dom。

Function Composition

函数复合是将两个或多个函数按照顺序生成一个函数或者执行操作。

Shared State

共享数据可以是变量、对象或内存空间。使用共享数据的一个问题是为了了解一个函数的副作用,需要知道每个共享数据的操作历史,比如对一个用户信息在不同终端的修改会发生冲突,因此在flux中要使用单向流。
另一个问题是对共享数据的操作顺序也会造成不同结果,比如四则运算。

Immutability

一个不可变对象是创建后就不能改变,但是js在语言层面只提供了原始类型的不可变性,对对象并不提供这种特性,即使使用Object.freeze()等方法也只能冻结某个层级的对象修改,要想使用不可变数据,可以使用第三方库,比如Immer
不可变对象是函数式编程的核心概念,没有不可变性,程序中的数据流就会不可控,应该使用原数据生成新数据,而不应该修改原来的数据。

在实际的操作中,对于一个特定的数据,不可变性和不同享,至少要满足一个。

Side Effects

副作用指的是除了对输出结果操作以外其他的操作,比如打印日志或修改dom,副作用在函数式编程中应该避免,即将副作用和数据流处理分开。

Reusability Through Higher Order Functions

高阶函数是任何以函数作为参数或返回函数的函数,经常用于

  • 使用回调函数、promise或monads对动作、副作用或异步数据流进行抽象或隔离。
  • 为操作各种类型的变量创建工具函数
  • 为了复用或函数组合而创建偏函数或柯里化
  • 将一系列输入的函数串联返回一个函数组合

Containers, Functors, Lists, and Streams

这里包括上面提到的monads,可以参考Functors, Applicatives, And Monads In Pictures

一个functor数据结构可以用于映射数据,比如 [1,2,3].map(x => x *2),换句话说,它是一个容器,会为内部的数据应用一个函数,当看到这个词时应该想到mappable
在这里被映射的是一个数组,只要提供map api,其他数据结构应该也可以,一个按顺序处理的list可以看作是一个stream。

Declarative vs Imperative

函数式编程是一种声明式范式,声明式编程会将流控制过程抽象,而不是用一行行代码描述怎么做,对应的是命令式。 比如函数doubleMap命令式的写法

const doubleMap = numbers => {
const doubled = [];
for (let i = 0; i < numbers.length; i++) {
	doubled.push(numbers[i] * 2);
   }
 return doubled;
 };
 console.log(doubleMap([2, 3, 4])); // [4, 6, 8]

声明式的写法

const doubleMap = numbers => numbers.map(n => n * 2);

 console.log(doubleMap([2, 3, 4])); // [4, 6, 8]

总结

一个函数式编程应该有以下特征

  • 纯函数,而不是共享数据和副作用
  • 不可变,而不是可变数据
  • 函数组合,而不是命令式流控制
  • 泛型工具而不是对某些数据的特定方法
  • 声明式,而不是命令式
  • 表达式,而不是语句

在js中的函数式应用可以参考A GENTLE INTRODUCTION TO FUNCTIONAL JAVASCRIPT系列,最终目的就是将应用中的整个逻辑切分到不同的函数中,然后将函数组合,完成最终的任务。
在具体处理过程中注意函数式编程的各种特征。

常见的函数组合方式包括

  • compose,又称为pipe
 const compose = (...functions) => flowIn => functions.reduceRight( ( acc,f ) => f(acc), flowIn )
  • curry,这里实现一个具体的柯里化
const add = a => b => a + b;
add(1)(2)

js的面向对象编程

js的以原型为基础的继承不太适合实现面向对象的封装、继承和多态,而es6在语言层面实现了class语法,可以很方便的采用其他语言实践总结而来的设计模式和设计原则,这里建议采用ts,具体可参考ts实现的23种设计模式和设计原则

无论采用何种范式,最终都是要将各个模块组合成我们的软件。


完结撒花