为什么说 JavaScript 是适合函数式的编程语言?
对高阶函数、闭包、数组字面量以及其他特性的支持,使得 JavaScript 成为一个应用函数式技术的理想平台。实际上,函数是 JavaScript 中的主要单元,这意味着它们不仅用于驱动应用程序的行为,也用于定义对象、创建模块以及处理事件。
另外,JavaScript 是一门积极演变与改进的语言。在 ECMAScript 的标准支持下,它的下一个主要版本 ES6 增加了更多的语言特性:箭头函数、常量、迭代器、Promise 以及其他能够很好地配合函数式编程的特性。
函数式与面向对象的程序设计
面向对象的应用程序大多是命令式的,因此在很大程度上依赖于使用基于对象的封装来保护其自身和继承的可变状态的完整性,再通过实例方法来暴露或修改这些状态。其结果是,对象的数据与其行为以一种内聚的包裹的形式紧耦合在一起。而这就是面向对象程序的目的,也正解释了为什么对象是抽象的核心。
个人理解就是就是面向对象的设计思路,数据和函数的耦合性高。
对于函数式编程,它不需要对调用者隐藏数据,通常使用一些更小且非常简单的数据类型。由于一切都是不可变的,对象是可以直接拿来使用的,而且是通过定义在对象作用域外的函数来实现的。换句话说,数据与行为是松耦合的。函数式的代码可以工作于多种数据类型之上的更加粗力度的操作,而不是一些细粒度的实例方法。在这种范式中,函数成为抽象的主要形式。
面向对象的程序设计通过特定的行为将许多数据类型逻辑地联结在一起,函数式编程则关注如何在这些数据类型之上通过组合来连接各种操作。因此存在一个两种编程范式都可以被有效利用的平衡点。
从本质上讲,面向对象的继承和函数式中的组合都是为了将新的行为应用于不同的数据类型当中。
| 函数式 | 面向对象 | |
|---|---|---|
| 组合单元 | 函数 | 对象(类) |
| 编程风格 | 声明式 | 命令式 |
| 数据和行为 | 独立且松耦合的纯函数 | 与方法紧耦合的类 |
| 状态管理 | 将对象视为不可变的值 | 主张通过实例方法改变对象 |
| 程序流控制 | 函数与递归 | 循环与条件 |
| 线程安全 | 可并发编程 | 难以实现 |
| 封装性 | 因为一切都是不可变的,所以没有必要 | 需要保护数据的完整性 |
不可变状态
JavaScript 的对象是高度动态的,其属性可以在任何时间被修改、增加或删除。但如此自由的操作,会让代码变得难以维护。
虽然 ES6 在类上添加了许多语法糖,但 JavaScript 的对象也只是可在任意时间添加、删除和更改的属性包而已。ES6 使用 const 关键字了来创建常量引用。但这样只能防止这个变量被重新赋值,而不能防止对象内部状态的改变。
封装是一个防止篡改的不错策略。对于一些简单的对象结构,一个好的方法是采用值对象模式。
以下代码就是值对象的模式,通过封装,可以使返回的对象表现出像原始类型一样没有可变方法的行为。
function zipCode(code, location) {
let _code = code;
let _location = location || '';
return {
code: function () {
return _code;
},
location: function () {
return _location;
},
fromString: function (str) {
let parts = str.split('-');
return zipCode(parts[0], parts[1]);
},
toString: function () {
return _code + '-' + _location;
}
}
}
const princetonZip = zipCode('08544', '3345');
princetonZip.toString(); // -> '08544-3345'
这种方式适用于对象层级只有一层的场景,不适合处理层次化数据。
深冻结可变数据
JavaScript 的 Object.freeze() 函数可以通过将对象的隐藏属性 writable 设置为 false 来阻止对象状态的改变。但 Object.freeze() 只能用于冻结浅层属性,不能被用于冻结嵌套属性。
var o = {type:1, infos:{title:'保存成功', content:'成功了保存20条数据'}}
var o1 = Object.freeze(o)
o1.type = 2
console.log(o.type) // 1
console.log(o1.type) // 1
o1.infos.title = '保存失败'
console.log(o.infos.title) // 保存失败
console.log(o1.infos.title) // 保存失败
Object.freeze() 是一种浅操作,对于嵌套对象,需要使用递归函数来深冻结对象。
Ramda.js
用于保持对象的不可变性,详细的资料可以查看阮一峰大佬的文章: www.ruanyifeng.com/blog/2017/0…
函数
函数是可通过()操作求值的表达式。先区分两个概念,表达式和语句。有返回值的函数是一个表达式,不返回值的函数是语句。命令式编程和过程式编程大多是由一系列有序的语句租组成。而函数式编程完全依赖于表达式。JavaSript 函数有两个重要特性:一等的和高阶的。
一等函数
一等函数,指的是在语言层面讲函数视为真实的对象。
高阶函数
由于函数的行为与普通对象类似,其理所当然地可以作为其他函数的参数进行传递,或是由其他函数返回。这些函数被称为高阶函数。例如:Array.sort() 的 comporator 函数。
// 栗子一:opt()函数可以作为参数传入其他函数中
function applyOperation(a, b, opt) {
return opt(a, b);
}
const mult = (a, b) => a * b;
applyOperation(3, 5, mult); // -> 6
// 栗子二:一个返回其他函数的函数
function add(a) {
return function(b) {
return a + b;
}
}
add(2)(3); // -> 5
因为函数的一等性和高阶性,JavaScript 函数具有值的行为,也就是说,函数就是一个基于输入的且尚未求值的不可变的值。
通过组合一些小的高阶函数来创建有意义的表达式,可以简化很多繁琐的程序。例如,需要打印住在美国的人员名单。
// 命令式代码
function printPeopleInTheUs(people) {
for (let i = 0; i < people.length; i++) {
const thisPerson = people[i];
if (thi.address.country === 'US') {
console.log(thisPerson);
}
}
}
printPeopleInTheUs([p1, p2, p3]);
// 函数式代码
function printPeople(people, selector, print) {
people.forEach(person => { // forEach 是函数式推荐的循环方式
if(selector(person)) {
print(person);
}
})
}
const inUs = person => person.address.country === 'US';
printPeople(people, inUs, console.log);
闭包和作用域
定义:闭包是一种能够在函数声明过程中,将环境信息与所属函数绑定在一起的数据结构。它是基于函数声明的文本位置的,因此也被称为围绕函数定义的静态作用域或词法作用域。
应用场景:闭包不仅应用于函数式编程的高阶函数中,也可应用于事件处理和回调、模拟私有成员变量。
从本质上讲,闭包就是函数继承而来的作用域,这类似于对象方法是如何访问其继承的实例变量的,它们都具有其父类型的引用。
函数的闭包包含以下内容:
- 函数的所有参数
- 外部作用域的所有变量,包括全局变量
全局作用域
全局作用域是最简单的作用域,但也是最差的。任何对象和在脚本最外层声明的变量,都是全局作用域的一部分,可被所有 JavaScript 代码访问。全局作用域容易导致全局命名空间被污染的问题。因此,在函数式编程时,我们应该尽量避免使用全局变量。
函数作用域
这是 JavaScript 主推的作用域机制。在函数中声明的任何变量都是局部且外部不可见的。
JavaScript 的作用域机制如下:
- 首先检查变量的函数作用域
- 如果不是在局部作用域内,那么逐层向外检查各词法作用域,搜索该变量的引用,直到全局作用域
- 如果无法找到变量引用,那么 JavaScript 将返回 undefined
伪块作用域
标准的 ES5 JavaScript 并不支持块级作用域,这些块包裹在括号{}中,隶属于各种控制结构,如 for, while, if 和 switch 语句。唯一例外的是传递到 catch 块的错误变量。语句 with 与块作用域类似,但它已经不被建议使用,并且在严格模式下被禁止。
闭包的实际应用
- 模拟私有变量 很多其他语言提供了一个内置的机制,通过设置访问修饰符(如 private)来定义对象的内部属性。JavaScript 并没有这样的关键字。但是利用闭包可以模仿这种行为。例如上面的 zipCode 函数。
闭包还可以用来管理全局命名空间,以免在全局范围内共享数据。一些库和模块还会使用闭包来隐藏整个模块的私有方法和数据。这被称为模块模式,它采用了立即调用函数表达式(IIFE),在封装内部变量的同时,允许对外公开必要的功能集合,从而有效减少全局引用。
// 模块框架的简单示例
var MyModule = (function MyModule(export) { // 给IIFE一个名字,方便栈追踪
let _myPrivateVar = ...; // 无法从外部访问到这个私有变量,但对内部的两个方法可见
export.method1 = function () {
// do work
}
export.method2 = function () {
// do work
}
})(MyModule || {}) // 一个单例对象,用来私有的封装所有的状态和方法。可以通过MyModule.method1()调用到method1()
- 异步服务端调用
getJson('/students', (student) => {
getJson('/students/grades',
grades => processGrades(grades), // 处理返回的结果
error => console.log(error.message) // 处理获取数据时发生的错误
);
}, (error) => {
console.log(error.message) // 处理获取学生时发生的错误
})
以上是一般的获取服务端数据的例子,其中 getJson 是一个高阶函数,它接受两个回调作为参数,一个处理成功的函数,一个处理错误的函数。
- 模拟块作用域变量 JavaScript 缺乏块作用域的语义,使用 let 确实可以缓解许多传统的循环机制的问题,然而使用一种基于 forEach 的函数式方法则可以对闭包以及 JavaScript 的函数作用域加以利用。
arr.forEach((elem, i) => {
... // 在循环体内包裹一个函数作用域来模拟块作用域
});
小结
- JavaScript 是一种用途广泛的、具有强大面向对象和函数式编程特性的语言。
- 使用不可变的实现方式可以使函数式与面向对象编程很好地结合在一起。
- 一等高阶函数使得 JavaScript 成为函数式编程的中坚力量。
- 闭包具有很多实际用途,如信息隐藏、模块化开发,并能够将参数化的行为跨数据类型地应用于粗力度的函数之上。