组合软件:3. 函数式程序员的 JavaScript 介绍

https://www.zcfy.cc/article/a-functional-programmer-s-introduction-to-javascript-composing-software-2695.html

原文链接: medium.com

对于不熟悉 JavaScript 或者 ES6+ 的人来说,本文的目的是做一个简单介绍。不管你是初学者,还是有经验的 JavaScript 开发者,都可以学到一些新东西。如下的内容只是蜻蜓点水,让你兴奋起来。如果想了解更多,就必须更深层次探索了。后面还有更多东西。

学会写代码的最佳方式就是开始写代码。推荐用一个交互式的 JavaScript 编程环境来跟着写,比如 CodePen 或者 Babel REPL。当然,就用 Node 或者浏览器控制台 REPL 也能搞定。

表达式和值

表达式是要计算为一个值的代码段。

如下都是 JavaScript 中有效的表达式:

7;

7 + 1; // 8

7 * 2; // 14

'Hello'; // Hello

可以给表达式的值一个名称。这样做时,是先对表达式求值,然后将结果值赋值给该名称。为此,我们会用 const 关键字。虽然这个关键字并非变量声明的唯一方式,不过会是用得最多的。所以,我们从现在开始就坚持用 const

const hello = 'Hello';
hello; // Hello

var、let 和 const

const 外,JavaScript 还支持有两个变量声明关键字:varlet。我喜欢从选择顺序上去思考它们。默认情况下,我会选用最严格的声明:const。用 const 关键字声明的变量是不能被重新赋值的。变量必须在声明时就赋了最终值。这听起来可能很死板,但是严格是件好事。这是一个信号,用来告诉你:赋给这个名称的值是不打算改变的。它能帮助我们马上完全理解变量名的含义,而不需要去读整个函数或者块作用域。

有时对变量重新赋值是佷用的。例如,如果是在用手动的、命令式的迭代,而不是更函数式的方式,那么我们就可以迭代一个用 let 赋值的计数器。

因为 var 告诉我们有关变量的信息是最少的,所以它是最弱的信号。从我开始用 ES6 开始,我就不再故意在真实软件项目中使用 var 声明变量。

请注意,一旦变量是用 letconst 声明的,任何企图再次声明它的做法都会导致出错。如果你喜欢在 REPL(Read,Eval, Print Loop)中更实验性的灵活性,可能会用 var 而不是 const 来声明变量。重新声明用 var 声明过的变量是允许的。

本文会使用 const,从而让你习惯默认在实际程序中使用 const,但是为交互性实验的目的,可以随意用 var 替代。

类型

迄今为止我们已经看到了两种类型:数字和字符串。JavaScript 还有布尔类型(truefalse)、数组、对象等等类型。稍后我们会接触其它类型。

数组是一个有序的值列表。将它当作一个可以装很多东西的盒子。如下是数组字面量表示法:

[1, 2, 3];

当然,也可以给表达式一个名称:

const arr = [1, 2, 3];

JavaScript 中的对象是键值对的集合。它也有一个字面量表示法:

{
  key: 'value'
}

当然,也可以把对象赋值给一个名称:

const foo = {
  bar: 'bar'
}

如果想把已有的变量赋值给同名的对象属性键,这里有一个快捷方式,只键入变量名即可,不用同时提供键和值:

const a = 'a';
const oldA = { a: a }; // 长而冗余的方式
const oA = { a }; // 短而酷的方式!

只是为了好玩,下面我们再来一次:

const b = 'b';
const oB = { b };

对象可以很容易组合在一起成为一个新对象:

const c = {...oA, ...oB}; // { a: 'a', b: 'b' }

这些点就是对象剩余(Rest)运算符。它遍历 oA 中的属性,将其赋值给新对象,然后对 oB 做同样的动作,覆盖新对象上已有的所有键。到本文编写时,对象剩余运算符还只是一个新的试验性特性,还没有被所有流行浏览器所支持。不过如果它不起作用的话,这里还有替代品:Object.assign():

const d = Object.assign({}, oA, oB); // { a: 'a', b: 'b' }

Object.assign() 例子中只多打了几个字,并且如果是在组合很多对象的话,这样做甚至会省掉更多键入。不过要注意的是,在使用 Object.assign() 时,必须将目标对象传递为第一个参数。属性将会被复制到该对象。如果你忘记了,漏掉了目标对象,那么传递为第一个参数的对象就会被修改。

根据我的经验,修改一个已有的对象,而不是创建一个新对象,通常会是一个 bug,起码也是很容易出错的。用 Object.assign() 时候一定要注意这点。

解构

对象和数组都支持解构,也就是说我们可以从对象和数组中抽取值,并将其赋值给命名的变量:

const [t, u] = ['a', 'b'];
t; // 'a'
u; // 'b'

const blep = {
  blop: 'blop'
};

// 如下语句等于:const blop = blep.blop;
const { blop } = blep;
blop; // 'blop'

如同上例中的数组一样,我们可以一次解构为多个赋值。如下是在很多 Redux 项目中会看到的一行:

const { type, payload } = action;

如下是在 reducer 上下文中使用它的示例(随后会讲解更多有关该主题的知识):

const myReducer = (state = {}, action = {}) => {
  const { type, payload } = action;
  switch (type) {
    case 'FOO': return Object.assign({}, state, payload);
    default: return state;
  }
};

如果我们不想对新绑定用不同的名称,可以把它赋值给一个新名称:

const { blop: bloop } = blep;
bloop; // 'blop'

读作:将 blep.blop 赋值为 bloop

比较运算符和三元运算符

可以用严格相等运算符来比较值:

3 + 1 === 4; // true

还有一个非严格相等运算符。其正式叫法是等于运算符,非正式叫法是双等号。双等号有一到两个合法的例子,但是用默认的 === 运算符替换通常会更好一些。

其它比较运算符包括:

  • > 大于
  • < 小于
  • >= 大于等于
  • <= 小于等于
  • != 不等于
  • !== 不严格相等
  • && 逻辑与
  • || 逻辑或

三元表达式是先用比较运算符问一个问题,然后根据表达式是否为真,来求值为一个不同的答案:

14 - 7 === 7 ? 'Yep!' : 'Nope.'; // Yep!

函数

JavaScript 有函数表达式,函数表达式可以赋值给一个名称:

const double = x => x * 2;

这跟数学函数 f(x)=2x 是一样的。大声说出来,该函数读为 xf 等于 2x。这个函数只有在将它应用到一个特定的 x 值时,才有意思。要在其它等式中使用该函数,得写 f(2),它与 4 是一个意思。

换句话说,f(2) = 4。你可以把数学函数当作是从输入到输出的映射。本例中的 f(x)x 的输入值映射到相应的输出值,等于输入值和 2 的积。

在 JavaScript 中,函数表达式的值就是函数本身:

double; // [Function: double]

可以用 .toString() 方法看看函数定义:

double.toString(); // 'x => x * 2'

如果想把一个函数应用到某些参数上,必须用一个函数调用来调用它。函数调用将一个函数应用到其参数上,计算结果为一个返回值。

可以用 <functionName>(argument1, argument2, ...rest) 调用一个函数。例如,要调用 double 函数,只需要添加括号,然后给 double 传递进一个值:

double(2); // 4

与其它函数式语言有所不同,JavaScript 中这些括号是有意义的。如果没有括号,函数就不会被调用:

double 4; // SyntaxError: Unexpected number

函数签名

函数有签名,签名由如下部分组成:

  1. 一个可选的函数名。
  2. 在括号中的一个形式参数类型列表。形式参数可以有名称。
  3. 返回值的类型。

在 JavaScript 中类型签名不需要指定。JavaScript 引擎在运行时会算出类型。如果提供足够的线索,签名还可以被开发者工具推断出来,比如使用数据流分析的 IDE 和 Tern.js

JavaScript 自身没有函数签名表示法,所以有几个相互竞争的标准:从历史上看,JSDoc 曾经很流行过,不过它超级啰嗦,没人劳神费心去让文档注释保持与代码同步,所以很多 JS 开发者已经停止使用它了。

TypeScript 和 Flow 目前是最大的竞争者。我不确定如何用二者中任何一个表示我所需要的一切,所以我只用 Rtype 来生成文档。有些人转过去求助于 Haskell 的 Hindley–Milner 类型推导系统。如果只是为文档化的用途,我倒是很想看到针对 JavaScript 标准化的一个好的表示法体系,不过我认为当今没有任何目前的解决方案能胜任这一任务。眼前,只能睁一眼闭一只眼,尽力适应这种可能与任何你所用过的看起来有点不同的古怪的类型签名。

functionName(param1: Type, param2: Type) => Type

double 函数的签名是:

double(x: n) => n

尽管 JavaScript 不需要用签名来做批注,不过知道签名是什么以及签名的含义,依然对搞清楚函数如何使用以及如何组成是很重要的。大部分可重用的函数组合实用程序需要我们传递共享相同类型签名的函数。

默认的形参值

JavaScript 支持默认的形参值。如下的函数就像一个恒等函数(一个返回值与传递进来的值相同的函数),除非你用 undefined 做参数调用它,或者就不传进参数(此时它会返回零):

const orZero = (n = 0) => n;

要设置一个默认的形参值,只需要在函数签名中用 = 将默认的值赋值给形参,比如上例中 n = 0。如果按这种方式给形参设置默认值,像 Tern.js、Flow 或者 TypeScript 这类类型推断工具就可以自动推断出函数的类型签名,即使没有显式声明类型注解也是如此。

于是,如果在编辑器或者 IDE 中装好了插件,就能在键入函数调用同时看到函数签名显示在行内,看一眼函数调用签名,马上就理解了如何用该函数。很显然,使用默认赋值可以帮助我们编写更能自我说明的代码。

注意:带默认值的形参不会计入到函数的 .length 属性中,这会打乱像自动柯里化这种依赖于 .length 值的实用程序。如果遇到这种情况,有些柯里化实用程序(比如 loadh/curry)允许传递一个自定义的参数元数来规避这种限制。

命名参数

JavaScript 函数可以将对象字面量作为实参,并在形参签名中使用解构赋值,从而实现命名参数一样的效果。注意,还可以用默认形参特性,将默认值赋值给形参:

const createUser = ({
  name = 'Anonymous',
  avatarThumbnail = '/avatars/anonymous.png'
}) => ({
  name,
  avatarThumbnail
});

const george = createUser({
  name: 'George',
  avatarThumbnail: 'avatars/shades-emoji.png'
});

George;
/*
{
  name: 'George',
  avatarThumbnail: 'avatars/shades-emoji.png'
}
*/

剩余运算符和展开运算符

JavaScript 中函数的一个基本特征是能在函数签名中用剩余运算符 ... 将一组剩余的实参聚集在一起。

例如,如下的函数只是丢掉第一个实参,并将剩余的返回为一个数组:

const aTail = (head, ...tail) => tail;
aTail(1, 2, 3); // [2, 3]

剩余运算符将单个元素聚集在一起,成为一个数组。扩展运算符则相反:它将数组中的元素展开为一个一个元素。考虑如下的示例:

const shiftToLast = (head, ...tail) => [...tail, head];
shiftToLast(1, 2, 3); // [2, 3, 1]

在使用扩展运算符时,JavaScript 的数组会有一个迭代器被调用。对于数组中的每个元素,迭代器会提供一个值。在表达式 [...tail, head] 中,迭代器从 tail 数组中按顺序将每个元素复制到一个由周围文本符号(即 shiftToLast)创建的新数组中。因为 head 已经是一个单个元素,我们只需要将它放到数字末尾就搞定了。

柯里化

通过返回另一个函数,可以启用柯里化和偏函数应用:

const highpass = cutoff => n => n >= cutoff;
const gt4 = highpass(4); // highpass() 返回一个新函数

这里其实不必用箭头函数。JavaScript 还有一个 function 关键字。我们之所以用箭头函数是因为 function 关键字敲的字要多一些。上面的 highPass()定义等于:

const highpass = function highpass(cutoff) {
  return function (n) {
    return n >= cutoff;
  };
};

JavaScript 中的箭头大致就是 function 的意思。根据所用的函数类型,函数行为有一些重大的区别(=> 没有自己的 this,不能被用作为构造器),之后我们讲到这里的时候会讲清楚这些区别。目前,当你看到 x=>x 时,就把它当作“以 x 为参数,并返回 x 的函数”。所以我们可以把 const highpass = cutoff => n => n >= cutoff; 读为:"highpass 是一个以 cutoff 为参数的函数,它返回一个以 n 为参数,返回值为 n >= cutoff 的结果的函数"。

既然 highpass() 返回一个函数,那么我们就可以用它来创建更专用的函数:

const gt4 = highpass(4);

gt4(6); // true
gt4(3); // false

自动柯里化让我们可以自动地柯里化函数,以达到最大的灵活性。假如有一个函数 add3()

const add3 = curry((a, b, c) => a + b + c);

有了自动柯里化,我们就可以以几种不同的方式来用它,并且它会根据我们传递进多少个实参,来返回正确的事:

add3(1, 2, 3); // 6
add3(1, 2)(3); // 6
add3(1)(2, 3); // 6
add3(1)(2)(3); // 6

不过,对不起,Haskell 粉丝,JavaScript 缺乏内置的自动柯里化机制。但是我们可以从 Lodash 中导入一个:

$ npm install --save lodash

然后在你的模块中导入:

import curry from 'lodash/curry';

或者,你可以使用如下的魔咒:

// 很小的、递归的自动柯里化
const curry = (
  f, arr = []
) => (...args) => (
  a => a.length === f.length ?
    f(...a) :
    curry(f, a)
)([...arr, ...args]);

函数组合

我们当然还可以组合函数。函数组合是将一个函数的返回值传递为另一个函数的实参。用数学符号表示就是:

f . g

在 JavaScript 中翻译为:

f(g(x))

它是从内向外求值:

  1. x 被求值
  2. g() 应用到 x
  3. f() 应用到 g(x) 的返回值

例如:

const inc = n => n + 1;
inc(double(2)); // 5

2 被传递进 double(),生成 44 被传递进 inc(),得到 5

我们可以将任何表达式作为实参传递给函数。表达式会在函数应用之前就被求值:

inc(double(2) * double(2)); // 17

既然 double(2) 的计算结果为 4,我们就可以将上面的代码读为 inc(4 * 4),其结果为 inc(16),最终其结果为 17

函数组合是函数式编程的核心。后续文章中我们会对此有更进一步的讲解。

数组

数组有内置的方法。方法是与对象关联的函数:通常是被关联对象的一个属性:

const arr = [1, 2, 3];
arr.map(double); // [2, 4, 6]

本例中,arr 是对象,.map() 是该对象的一个属性,.map() 的参数是一个用于一个值的函数。当调用它时,函数就被应用到实参以及一个特殊的形参 this 上,this 是在方法被调用时自动设置的。.map() 是通过 this 值访问数组内容。

注意这里我们是将 double 函数作为一个值传递进 map,而不是调用它。这是因为 map 是用一个函数作为实参,并将其应用到数组中的每个元素上。它返回一个新数组,这个数组包含了被 double() 返回的值。

注意原始 arr 值没有改变:

arr; // [1, 2, 3]

方法链

我们还可以把方法调用链起来。方法链是直接在一个函数的返回值上调用一个方法的过程,而不需要通过名称来引用返回值:

const arr = [1, 2, 3];
arr.map(double).map(double); // [4, 8, 12]

断言(predicate)是一个返回布尔值(true 或者 false)的函数。.filter() 方法以一个断言为参数,并返回一个新列表,只有通过断言(返回 true)的条目才会被包含在新列表中:

[2, 4, 6].filter(gt4); // [4, 6]

我们经常会想从一个列表中选择一些条目,然后将这些条目映射到一个新的列表:

[2, 4, 6].filter(gt4).map(double); [8, 12]

注意:在本系列稍后的文章中,会看到一种更高效的方式,即使用一种称为 transducer 的方式同时选择和映射。但是我们还有其它事情需要先探讨。

总结

如果现在你已经头晕了,不要着急。我们只是触及了很多应该有更多探索和思考的事情的表面。我们不久会再绕回来,更深入探讨这些主题。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章