【译】函数式编程-柯里化和函数合成

904 阅读12分钟

原文:medium.com/javascript-…
作者:Eric Elliott
翻译:前端小白

注意:这篇文章是 "Composing Software" 学习函数式编程 这个系列(现在已出版为一本书)里面的一部分。

什么是函数柯里化

一个被柯里化的函数是一个原本接受多个参数,转变成接受单一参数的函数。给定一个有3个参数的函数,将他柯里化后,它将接受一个参数并返回一个函数,这个返回的函数又接受下一个参数,接下来又返回一个函数接受第三个参数,最后一个函数返回接受所有参数后函数运行的结果。

你可以使用数量不定的参数来实现柯里化,比如给定一个函数接受两个参数 ab,将函数柯里化后返回两个参数的和

// add = a => b => Number
const add = a => b => a + b;

我们需要调用这两个函数来使用 add 函数,调用函数的语法就是在函数引用后面加上 (),当一个函数返回另一个函数时,可以通过添加一组额外的括号立即调用返回的函数:

const result = add(2)(3); // => 5

首先,这个函数接受 a 参数,然后返回一个新函数,新函数接受 b 参数,然后返回 ab的和,每次都只接受一个参数。如果函数有更多的参数,那就简单地继续返回新函数,直到所有的参数都挨个被接收,然后程序运行完成。

add 函数接受一个参数,然后返回了它自身的一部分,这部分中有个变量 afixed 在该函数的闭包中,闭包就是某个函数有个和它捆绑在一起的一个词法作用域,闭包是在函数创建期间的运行时建立的,fixed 意味着闭包里面的变量只会在闭包环境中被赋值。

上面例子中的括号表示函数被调用:add 函数被调用,同时传入了参数 2,返回一个部分应用的函数,这时 a 的值被固定为 2。这时候返回值不会被赋值给某个变量或者以其他方式去使用,我们马上又会将 3 传入,并调用这个返回的函数,现在程序结束,返回结果 5

什么是部分应用(偏函数)

部分应用是一个应用了一个或者多个参数的函数,没有接受全部参数。换句话说,部分应用就是一个函数,这个函数有一些参数被固化在它的闭包作用域范围内。一个函数的某些参数是固定的,我们称之为部分应用

区别

部分应用可以根据需要一次使用任意多或任意少的参数,而柯里化的函数总是返回一个一元函数,即只接受一个参数的函数。

所有的柯里化函数都返回部分应用,但不是所有的部分应用都是柯里化函数的结果。

柯里化函数的一元需求是一个很重要的特性。

什么是 point-free style

point-free style 是一种编程风格,函数在定义时,不要对函数的参数进行引用,我们看看下面的函数定义:

function foo (/* 这里声明参数*/) {
  // ...
}
const foo = (/* 这里声明参数 */) => // ...
const foo = function (/* 这里声明参数 */) {
  // ...
}

如何在不引用所需参数的情况下用JavaScript定义函数?我们不能使用 function 关键字,也不能使用箭头函数(=>),因为这些都要求正式的参数声明(会引用该参数)。所以我们要做的是调用一个会返回函数的函数。

使用 point-free style 创建一个函数,该函数会将你传入的任何数字加1,记住,我们已经有了一个名为 add 的函数,它接受一个数字并返回一个部分应用的函数,其第一个参数被固化为传入的任何值。我们可以使用它来创建一个 inc()

// inc = n => Number
// Adds 1 to any number.
const inc = add(1);
inc(3); // => 4

作为一种普遍化和专门化机制,这很有趣,返回的函数与 add 这种通用版比较,相当于一种专门的定制版,我们可以使用 add 来创造更多不同的版本

const inc10 = add(10);
const inc20 = add(20);
inc10(3); // => 13
inc20(3); // => 23

当然,他们都有自己的闭包作用域范围(闭包是在函数创建时产生的,当add() 函数被创建时),所以原始的 inc() 可以继续工作

inc(3) // 4

当我们调用函数 add(1) 时就创建了 inc() 函数,在这个被返回的函数中,add() 函数里面的 a参数值被固定为1,被返回的函数赋值给 inc,当我们调用 inc(3) 时,add() 函数里面的 b 参数就被实参 3 替代,整个程序结束,返回 13 的和。

所有柯里化函数都是高阶函数的一种形式,它允许为手头的特定用例创建一个原始函数的特定版本。

为什么需要柯里化

函数柯里化在函数组合上下文中特别有用

在代数中,给定两个函数 gf

g: a -> b
f: b -> c

你可以将他们组合成一个新函数,从 a 直接到 ch 函数

// 代数定义,借助 Haskell 中的 `.` 组合运算符
h: a -> c
h = f . g = f(g(x))

在Javascript中:

const g = n => n + 1;
const f = n => n * 2;
const h = x => f(g(x));
h(20); //=> 42

代数定义中:

f . g = f(g(x))

转换为Javascript语言:

const compose = (f, g) => x => f(g(x));

但是这一次只能组合两个函数。在代数中,可以这样写:

f . g . h

我们可以编写一个函数来组合任意多个函数,换句话说,compose() 创建一个函数管道,其中一个函数的输出会连接到下一个函数的输入。

我通常喜欢这么写:

const compose = (...fns) => x => fns.reduceRight((y, f) => f(y), x);

这种写法接受任意数量的函数并返回一个接受初始值的函数,然后使用 reduceRight()fns 中的 f 从右到左的迭代,并调用,得到最后累积的值,在这个函数里面,我们用累加器积累的 ycompose() 这个函数返回的函数的返回值。

现在我们可以这样组合:

const g = n => n + 1;
const f = n => n * 2;
// 将 `x => f(g(x))` 用 `compose(f, g)` 替换
const h = compose(f, g);
h(20); //=> 42

追踪

函数组合使用了 point-free style 使代码非常简洁、可读。但是也为代码调试带来了不便,如果你想监测两个函数之间的值,该怎么做?trace() 是一个十分方便的工具,可以让你做到这一点。 它采用了函数柯里化的形式:

const trace = label => value => {
  console.log(`${ label }: ${ value }`);
  return value;
};

现在我们能监测函数管道:

const compose = (...fns) => x => fns.reduceRight((y, f) => f(y), x);
const trace = label => value => {
  console.log(`${ label }: ${ value }`);
  return value;
};
const g = n => n + 1;
const f = n => n * 2;
/*
注意函数调用顺序是从下至上
*/
const h = compose(
  trace('after f'),
  f,
  trace('after g'),
  g
);
h(20);
/*
after g: 21
after f: 42
*/

compose() 是一个不错的工具,但是当我们需要组合超过两个函数时,如果我们能按从上到下的顺序阅读,有时会很方便。我们可以通过反转函数的调用顺序来做到这一点,还有一个名为 pipe() 的工具函数,它以相反的顺序组合我们的函数。

const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);

现在上面的代码可以这样写:

const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);
const trace = label => value => {
  console.log(`${ label }: ${ value }`);
  return value;
};
const g = n => n + 1;
const f = n => n * 2;
/*
现在函数调用顺序时是从上到下
*/
const h = pipe(
  g,
  trace('after g'),
  f,
  trace('after f'),
);
h(20);
/*
after g: 21
after f: 42
*/

柯里化和函数组合结合

即使没有与函数组合结合起来,柯里化也是一个实用的抽象功能,我们可以使用它来专门化一个函数。例如,柯里化版本的 map() 函数可以专门用于做许多不同的事情:

const map = fn => mappable => mappable.map(fn);
const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);
const log = (...args) => console.log(...args);
const arr = [1, 2, 3, 4];
const isEven = n => n % 2 === 0;
const stripe = n => isEven(n) ? 'dark' : 'light';
const stripeAll = map(stripe);
const striped = stripeAll(arr); 
log(striped);
// => ["light", "dark", "light", "dark"]
const double = n => n * 2;
const doubleAll = map(double);
const doubled = doubleAll(arr);
log(doubled);
// => [2, 4, 6, 8]

但是柯里化真正的优势是它简化了函数组合,一个函数可以接受任意数量的输入,但只有一个输出,为了使函数可组合,输出类型必须与预期的输入类型一致:

f: a => b
g:      b => c
h: a    =>   c

如果上面的 g 函数希望接受两个参数,f 函数的输出就与 g 函数的输入不一致了:

f: a => b
g:     (x, b) => c
h: a    =>   c

在这种情况下,我们怎样可以将 x 传给 g,答案就是将 g 柯里化

记住,柯里化函数的定义就是一个函数,它一次接受多个参数,取第一个参数并返回一系列函数,每个函数取下一个参数,直到收集到所有参数。

定义中的关键字词是 一次接受一个,柯里化函数对于函数组合来说非常方便,原因就是它们将期望接受多个参数的函数转换为可以接受单个参数的函数,使他们可以应用在函数组合管道中

以前面的 trace() 函数为例

const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);
const trace = label => value => {
  console.log(`${ label }: ${ value }`);
  return value;
};
const g = n => n + 1;
const f = n => n * 2;
const h = pipe(
  g,
  trace('after g'),
  f,
  trace('after f'),
);
h(20);
/*
after g: 21
after f: 42
*/

trace() 定义了两个参数,但是一次只能接受一个,这样我们可以在函数体里面来专门化 trace() 函数,如果它没哟被柯里化,我们就不能这样使用,我们的整个函数组合管道看起来像这样

const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);
const trace = (label, value) => {
  console.log(`${ label }: ${ value }`);
  return value;
};
const g = n => n + 1;
const f = n => n * 2;
const h = pipe(
  g,
  // the trace() calls are no longer point-free,
  // introducing the intermediary variable, `x`.
  x => trace('after g', x),
  f,
  x => trace('after f', x),
);
h(20);

但是简单的柯里化一个函数是不够的。还需要将这个函数专门化并确保函数期望的参数的顺序,如果我们再次将 trace() 柯里化,但是翻转参数顺序,会发生什么。

const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);
const trace = value => label => {
  console.log(`${ label }: ${ value }`);
  return value;
};
const g = n => n + 1;
const f = n => n * 2;
const h = pipe(
  g,
  // the trace() calls can't be point-free,
  // because arguments are expected in the wrong order.
  x => trace(x)('after g'),
  f,
  x => trace(x)('after f'),
);
h(20);

如果你觉得很难理解,您可以使用一个 flip() 的函数来解决这个问题,该函数只翻转两个参数的顺序

const flip = fn => a => b => fn(b)(a);

现在我们来创建一个 flippedTrace() 函数

const flippedTrace = flip(trace);

像这样使用

const flip = fn => a => b => fn(b)(a);
const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);
const trace = value => label => {
  console.log(`${ label }: ${ value }`);
  return value;
};
const flippedTrace = flip(trace);
const g = n => n + 1;
const f = n => n * 2;
const h = pipe(
  g,
  flippedTrace('after g'),
  f,
  flippedTrace('after f'),
);
h(20);

但更好的方式是先正确编写函数,这种风格有人称为 data last,意思就是应该将专门化的参数放在前面,然后将函数使用到的数据放在最后,以函数的原始形式

const trace = label => value => {
  console.log(`${ label }: ${ value }`);
  return value;
};

每个 trace() 对应一个 label,这是一个用于函数管道里面的专门化的 trace() 函数,label 的值在 trace() 返回的偏函数中就已经被确定了

const trace = label => value => {
  console.log(`${ label }: ${ value }`);
  return value;
};
const traceAfterG = trace('after g');

上面的代码和以下等同:

const traceAfterG = value => {
  const label = 'after g';
  console.log(`${ label }: ${ value }`);
  return value;
};

如果我们将 trace('after g')traceAfterG 替换,意思是一样的:

const pipe = (...fns) => x => fns.reduce((y, f) => f(y), x);
const trace = label => value => {
  console.log(`${ label }: ${ value }`);
  return value;
};
// The curried version of trace()
// saves us from writing all this code...
const traceAfterG = value => {
  const label = 'after g';
  console.log(`${ label }: ${ value }`);
  return value;
};
const g = n => n + 1;
const f = n => n * 2;
const h = pipe(
  g,
  traceAfterG,
  f,
  trace('after f'),
);
h(20);

结论

柯里化函数是一个原本接受多个参数,转变成接受单一参数的函数,通过第一个参数,并返回一系列函数,接着又取下一个参数,直到所有的参数被用尽,程序结束,返回结果。

部分应用(偏函数是指一个函数,只接受了部分,不是全部参数,那些已经被接受了的参数称为固定参数

Point-free style 是一种编程风格,函数在定义时,不要对函数的参数进行引用,通常我们通过调用一个返回值为函数的函数来创建一个 point-free 函数,比如柯里化函数。

柯里化函数对于函数组合非常有用,因为它允许你轻松地将一个n元函数转换为函数组合管道所需的一元函数形式:函数管道中的函数必须只接受一个参数。

Data last 函数对于函数组合来说很方便,他们可以很容易的使用 point-free style 形式