函数式编程中如何处理副作用?

一.纯函数

纯函数是说没有副作用的函数(a function that has no side effects),有几个好处:

  • 引用透明(referential transparency)

  • 可推理(reason about your code)

P.S.关于引用透明,见基础语法_Haskell笔记1

零副作用(side effects)是关键,但有些副作用是不可避免且至关重要的,例如:

  • 输出:显示到Console、发送给打印机、写入数据库等

  • 输入:从输入设备取得用户输入、从网络请求信息等

那么,推崇纯函数的函数式编程如何应对这些场景?有2种解法:

  • 依赖注入

  • Effect Functor

二.依赖注入

We take any impurities in our code, and shove them into function parameters. Then we can treat them as some other function’s responsibility.

简言之,把不纯的部分剔出去作为参数

例如:

// logSomething :: String -> String
function logSomething(something) {
    const dt = new Date().toISOString();
    console.log(`${dt}: ${something}`);
    return something;
}

logSomething函数有两个不纯的因素,Dateconsole是偷偷取的外部状态,所以对于同样的输入(something),并不一定输出相同结果(log行为及输出内容都不确定)。所以,为了满足相同输入总是对应相同输出,采用这种欺骗性的手段

// logSomething: Date -> (String -> *) -> String -> *
function logSomething(d, log, something) {
    const dt = d.toISOString();
    return log(`${dt}: ${something}`);
}

如此这般,就能做到相同输入对应相同输出了:

const something = "Curiouser and curiouser!"
const d = new Date();
const log = console.log.bind(console);
logSomething(d, log, something);

看起来这可真蠢,独善其身似乎没什么意义。实际上,我们做了几件事情:

  • 把不纯的部分剥离出来

  • 把它们推开,远离核心代码(拿到了logSomething之外)

  • logSomething变纯了(行为可预测)

意义在于控制不确定性(unpredictability)

  • 缩小范围:把不确定性移到了更小的函数(log)里

  • 集中管理:如果反复缩小范围,并把不确定性推啊推推到边缘(如应用入口),就能让不确定性远离核心代码,从而保证核心代码的行为可预测

So we end up with a thin shell of impure code that wraps around a well-tested, predictable core.

P.S.这样做也有利于测试,只要把这层不纯的薄壳换掉就能让核心代码在模拟的测试环境中跑起来,而不需要模拟全套运行环境

但这种参数化的依赖注入方式并非完美,其缺点在于:

  • 方法签名长:例如app(document, console, fetch, store, config, ga, (new Date()), Math.random)

  • 传参链路长:例如React里从顶层组件一路接力传递props给某个叶子组件

长方法签名的好处在于标清楚了将要进行的调用依赖哪些不纯的东西,但逐层传递参数确实比较麻烦

三.惰性函数(Lazy Function)

另一种控制副作用的思路是,把产生副作用的部分保护起来(放到地铁站防爆球里),带着这层防护壳参与运算,直到需要结果时才打开壳取值

例如:

// fZero :: () -> Number
function fZero() {
    console.log('发射核弹');
    // Code to launch nuclear missiles goes here
    return 0;
}

显然,fZero不是个纯函数,存在极大的副作用(会发射核弹)。安全起见,把这个危险操作包进防爆球:

// returnZeroFunc :: () -> (() -> Number)
function returnZeroFunc() {
    return fZero;
}

接下来就可以随意操作这个球而不会引发核弹了:

const zeroFunc = returnZeroFunc();
roll(zeroFunc);
knock(zeroFunc);

returnZeroFunc仍然不是纯函数(依赖外部的fZero),不妨把依赖收进来:

// returnZeroFunc :: () -> (() -> Number)
function returnZeroFunc() {
    function fZero() {
        console.log('Launching nuclear missiles');
        // Code to launch nuclear missiles goes here
        return 0;
    }
    return fZero;
}

不直接返回结果,而是返回一个能够返回预期结果的函数(有点thunk的意思),以此类推:

// fIncrement :: (() -> Number) -> (() -> Number)
function fIncrement(f) {
    return () => f() + 1;
}

const fOne   = fIncrement(fZero);
const fTwo   = fIncrement(fOne);
const fThree = fIncrement(fTwo);
// And so on…

我们定义了一些特殊的数值,对这些数值进行任何操作(传递、计算等等)都是安全无副作用的:

// fMultiply :: (() -> Number) -> (() -> Number) -> (() -> Number)
function fMultiply(a, b) {
    return () => a() * b();
}

// fPow :: (() -> Number) -> (() -> Number) -> (() -> Number)
function fPow(a, b) {
    return () => Math.pow(a(), b());
}

// fSqrt :: (() -> Number) -> (() -> Number)
function fSqrt(x) {
    return () => Math.sqrt(x());
}

const fFour = fPow(fTwo, fTwo);
const fEight = fMultiply(fFour, fTwo);
const fTwentySeven = fPow(fThree, fThree);
const fNine = fSqrt(fTwentySeven);
// No console log or thermonuclear war. Jolly good show!

这些操作相当于公式变换,只有最终代数计算时才会真正产生副作用。就像是把副作用沉淀出来,而依赖注入的方案是让副作用漂起来,两种方式都能够达到分离副作用,控制不确定性的目的

但是,由于数值的定义变了(从数值变成了返回数值的函数),我们不得不重新定义加、减、乘、除……等一整套基于数值的算术运算,这可真蠢,有更好的办法吗?

四.Effect Functor

至此,我们把数值映射成返回数值的函数,并把数值运算映射成能够操作这种特殊数值的函数。等一下,映射、防爆球、包装、操作包起来的东西……想到了什么?

没错,是Functor

-- Haskell
class Functor f where
  fmap :: (a -> b) -> f a -> f b

fmap定义的行为恰恰是对容器里的内容(值)做映射,完了再装进容器

这不就是惰性函数方案中迫切想要的东西吗?

试着用JS实现,先造个容器类型(Effect):

// Effect :: Function -> Effect
function Effect(f) {
    return {
      get: () => f
    }
}

有了容器就可以进行装箱/拆箱操作:

// 含有副作用的方法
function fZero() {
  console.log('some side effects...');
  return 0;
}
// 装箱,把fZero包成Effect
const eZero = Effect(fZero);
// 拆箱,从Effect中取出fZero
eZero.get();

-- 对应Haskell中的
-- 装箱
let justZero = Just (\x -> 0)
-- 拆箱
let (Just fZero) = justZero in fZero

接下来实现fmap

// fmap :: ((a -> b), Effect a) -> Effect b
function fmap(g, ef) {
  let f = ef.get();
  return Effect(x => g(f(x)));
}

// test
let eOne = fmap(x => x + 1, Effect(fZero));
eOne.get()(); // 1

或者更地道(函数签名一致)的curried版本:

const curry = f => arg => f.length > 1 ? curry(f.bind(null, arg)) : f(arg);

// fmap :: (a -> b) -> Effect a -> Effect b
fmap = curry(fmap);

// test
let eOne = fmap(x => x + 1)(Effect(fZero));
eOne.get()(); // 1

让Effect跑起来的get()()看着有些啰嗦,简化一下,同时把fmap也收进来,让这一切更符合JS的味道:

// Effect :: Function -> Effect
function Effect(f) {
    return {
        get: () => f,
        run: x => f(x),
        map(g) {
            return Effect(x => g(f(x)));
        }
    }
}

试玩一下:

const increment = x => x + 1;
const double = x => x * 2;
const cube = x => x ** 3;

// (0 + 1) * 2 ^ 3
const calculations = Effect(fZero)
    .map(increment)
    .map(double)
    .map(cube)
calculations.run(); // 8

这一系列map运算都是不含副作用的,直到最后run()才会引发fZero的副作用,这正是惰性函数方案的意义:让副作用像沙子一样沉淀到最后,保证上层的水纯净透明

P.S.上面实现的Effect其实相当于函数Functor,作用于函数的映射操作实际上就是函数组合

-- Haskell
instance Functor ((->) r) where
  fmap = (.)

(.)    :: (b -> c) -> (a -> b) -> a -> c
(.) f g = \x -> f (g x)

// 即
map(g) {
    return Effect(x => g(f(x)));
}

所以关键点在于函数组合(compose):

// 特殊值
const fZero = x => 0;
// 普通函数
const double = x => x + x;
// 无法直接double fZero

// 引入Functor fmap概念
const compose = (f, g) => x => g(f(x));
// 不改变double,实现double fZero
compose(fZero, double)();
// (0 + 1) * 2 ^ 3
// compose(compose(compose(fZero, increment), double), cube)();

五.总结

无论依赖注入还是Effect Functor方案,处理副作用的原则都是将其带来的不确定性限制在一定范围内,让其它部分得以保持纯的特性

如果把四处混杂着副作用的应用看作一杯混着沙子的水,两种方案的区别在于让水变清的思路不同:

  • 依赖注入:让沙子漂起来浮在最上层,形成一层不纯的薄壳,保持下方的水纯净

  • Effect Functor:把沙子淀到杯底,让上方的水澄清透明

诚然,副作用还在,并没有被消除。但通过类似的方式能够让大部分代码保持纯的特性,享受纯函数带来的确定性好处(think less):

You can be confident that the only thing affecting their behaviour are the inputs passed to it. And this narrows down the number of things you need to consider. In other words, it allows you to think less.

参考资料

函数式编程中如何处理副作用?》上有2条评论

  1. JackZhouMine

    请教一个问题,react 里的副作用和函数式编程里的副作用是同一个概念吗? 如果不同,有什么区别?

    回复

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code