Algebraic Effects

Algebraic Effects

十一月 16, 2019

看到 Dan Abramov 的一篇博客 Algebraic Effects for the Rest of Us 提到了一个叫 Algebraic Effects 的概念。并举出了一个虚构的语法作为例子。它和之前提到的 Suspense 也有很多相似的地方。

function delay<T>(value: T, time = 1000) {
  return new Promise<T>(resolve => {
    setTimeout(() => resolve(value), time);
  });
}

function process<T>(x: Promise<T> | T) {
  console.log(x); // 这里可以正确输出 'value'
}

process(delay('value'));

使用普通的 JavaScript 特性,没有办法在不将 process 变为异步的同时正确输出 'value'。假设有这样一种语法

function delay<T>(value: T, time = 1000) {
  return new Promise<T>(resolve => {
    setTimeout(() => resolve(value), time);
  });
}

function isPromise<T>(x: Promise<T> | T): x is Promise<T> {
  return typeof x.then === 'function';
}

function process<T>(x: Promise<T> | T) {
  if (isPromise(x)) {
    x = perform x;
  }
  console.log(x); // 这里可以正确输出 'value'
}

try {
  process(delay('value'));
} handle (effect) {
  if (isPromise(effect)) {
    effect.then(value => {
      resume with value;
    });
  }
}

这里有三个和普通 JavaScript 不同的地方,perform, try-handleresume。它的作用和 try-catch 类似,在执行 perform 时会中断当前操作,跳转到最近一层的 try-handle 语句,并把 perform 后面的值传递给 handle 代码块。在运行到 resume 语句时,代码的运行会回到 perform 的地方,并返回 resume 的值。

具体到上面这个例子,代码会在运行到

function process<T>(x: Promise<T> | T) {
  if (isPromise(x)) {
    x = perform x;
  }
  console.log(x); // 这里可以正确输出 'value'
}

时中断,并且跳转出去寻找最近的一个 try-handle 语句

try {
  process(delay('value'));
} handle (effect) {
  if (isPromise(effect)) {
    effect.then(value => {
      resume with value;
    });
  }
}

此时 effect 的值就是 perform 传递过来的 x,也就是 delay('value')

try {
  process(delay('value'));
} handle (effect) {
  if (isPromise(effect)) {
    effect.then(value => {
      resume with value;
    });
  }
}

然后在这个 Promise resolve 之后,运行 resume,并把获取到的值 "value" 返回给原来执行的地方。

function process<T>(x: Promise<T> | T) {
  if (isPromise(x)) {
    x = perform x;
  }
  console.log(x); // 这里可以正确输出 'value'
}

此时 x 被赋值为 "value",最终正确输出。

「像写同步代码一样写异步代码」,这听起来很像 async-await 语法的描述,但是 async-await 有一个很不方便的场景:当一个函数从普通函数修改为 async 函数时,所有调用它的函数也需要修改为 async 函数。这条修改的链路可能很长,会给重构工作带来巨大的麻烦。而使用 Algebraic Effect,则只需要修改一个函数即可。

Algebraic Effect 的形式看起来和 上一篇文章 的 Suspense 实现有很多相似之处。但是 ALgebraic Effect 的写法显然简洁了很多。

// 需要自己提取 Monad 实现状态的流转
export const schedule = (m: Monad) => (thunk: () => T) => {
  const ctx: Context = {
    effect: null as Monad_<T>,
    trace: [] as T[],
    pos: 0,
  };
  return step();

  // 执行流程在 `getValue` 和 `schedule#step` 里面反复横跳
  // 执行的逻辑难以阅读
  function step(value?: T): Monad_<T> {
    const savedContext = context;
    ctx.pos = 0;
    try {
      context = ctx; // 用到了全局变量,数据变化不清楚
      return m.of(thunk());
    } catch (e) {
      const { pos } = ctx;
      return m.chain(ctx.effect, (value: T) => {
        ctx.trace.length = pos;
        ctx.trace[pos] = value;
        ctx.pos = pos + 1;
        return step(value);
      });
    } finally {
      context = savedContext;
    }
  }
};

schedule(PromiseM)(() => {
  const value = getValue(delay("value"));
  const value2 = getValue(delay("value2"));
  console.log(value, value2);
});

看到这里大概就能理解,为什么异步加载等功能明明可以让用户自己实现,并且还有 react-query 等库,React 还要自己实现一个 Suspense 了。有了 Suspense 以后,用户完全不需要关心异步操作,以及随之而来的各种状态处理,真的可以做到像写同步代码一样写异步代码。