函数式编程 - 抽象

348 阅读10分钟

Functional Programming

函数式提倡使用函数的方式对数据进行处理,函数可以进行组合,转换,从而得到新的函数。

如下 compose2 函数可以将 f:X⇒ Y 和 g: Y⇒ Z 进行组合,从而得到 一个 X ⇒ Z 的运算。

但是实际开发中,并不是所有函数的输出,都是另一个函数的输入。所以我们通常需要对函数进行一些转化,常用对技巧包括 curry,partial,unary,reduce

前端的函数库有 lodash 和 Ramda

在同步编程当中我们很容以对数据采用函数对方式进行处理,并进行函数推导

但是在实际对项目当中,我们会有很多具有副作用的操作,比如 IO,异常等。对于这类副作用操作如果需要进行 函数推倒,需要对副作用进行抽象(abstract) ,使其具有可运行性

我们以异步编程为例,看看函数式编程是如何对异步IO 进行抽象,从而使其具有可运行性

同步获取数据

findUserByUserNamefindArticlesByAuthor 均采用 同步的获取数据

export type Optional<T> = T | undefined | null;

export interface User {
  name: string;
}

export interface Article {
  title: string;
  author: string;
}

function findUserByUserName(name: string): Optional<User> {
  return users.find((_) => _.name === name);
}

function findArticlesByAuthor(author: string): Article[] {
  return articles.filter((_) => _.author === author);
}

fetchArticlesSync 可以根据用户的名字来检索其相关的文章。由于其 findUserByUserName 和 findArticlesByAuthor 均为纯函数,所有我们可以直接对其进行 compose

function fetchArticlesSync(userName: string): Article[] {
  const user = findUserByUserName(userName);
  if (user) {
    return findArticlesByAuthor(user.name);
  }
  return [];
}

function fetchArticlesSyncCompose(userName: string): Article[] {
  return compose2((user) => {
    if (user) {
      return findArticlesByAuthor(user.name);
    }
    return [];
  }, findUserByUserName)(userName);
}

Continuation

A continuation is an abstract representation of the control state of a computer program.

可以简单的理解 Continuation 就是 ‘程序剩下的部分’

比如对于如下的表达式 1 + 2 + 3 , 对于 1 来说,continuation为 +2 + 3, 当程序执行完 1 + 2 时,此时 continuation 为 + 3

如下例子中,const user = findUserByUserName(userName) 的 continuation 为蓝色部分的代码。

Continuation 实现了程序控制状态,它是个代表了给定计算过程点上的数据数据机构,有的语言提供了访问这个数据结构能力,有的则隐藏了这种能力。

Continuation 是 First Class 的,这意味着它可以被当作参数,以及返回值进行传递,当 continuation 被调用的时候,它会跳转到某一个执行位置。这为这我们对程序运行的 control flow 可以有更加灵活的控制。但是大多数语言并未提供如灵活的改变控制流的接口。我们常用的控制流方法有 for, while, if 等, Javascript 还提供了 goto 关键词来进行跳转

start:
alert("RINSE");
alert("LATHER");
repeat: goto start

Continuation 可以实现其他的控制机制,比如 异常(Exceptions)、生成器(Generator)、协程(Coroutine) 等。

我们可以用函数去粗力度表示一个 Continuation

Continuation Pass Style

我们可以将

Continuation Passing Style (CPS) is a style of programming in which control(continuation) is passed .

这里将 Continuation 称为 _return 的意图是,它本事和 函数的意图已有,当我们调用 另外一个 continuation 的时候 当前函数的 continuation 应该立马结束。

我们可以将任何函数转成 CPS 的写法。

function plus(v1, v2) { return v1 + v2 }
function multi(v1, v2) { return v1 * v2 }

function plusCPS(v1, v2, _return) {
	return _return(v1 + v2)
}

function multiCPS(v1, v2, _return) {
	return _return(v1 * v2)
}

const result = multi(plus(1, 2), plus(3, 4))
const resultCps = plusCPS(1, 2, r1 => plusCPS(3, 4, r2 => multiCPS(r1, r2, r3 => r3)))

如上模式在 javascript 异步编程中会经常遇到,resultCps 是一种人肉的进行递归 cps 调用,这种结果可能会导致 callback hell,并且其代码可读性也会存在问题。

不过我们可以对 cps 进行 compose 从而避免 callback hell

High Order Function - Curried CPS

对 CPS 进行 curry 得到如下 HOF

从函数签名中,我们可以看出 continuation 返回的是一个函数 (_return: Continuation) => void, 当我们调用这个函数后,可以从上一次执行的必包中获取到 R0R0, 这里我们可以将整个过程抽象为一个 生成 Container 的过程

对应的 HOF 版的函数如下

function findUserByUserNameHOF(name: string) {
  return (_return: Continuation<Optional<User>>) => {
    findUserByUserNameCPS(name, _return);
  };
}

function findArticlesByAuthorHOF(author: string) {
  return (_return: Continuation<Article[]>) => {
    findArticlesByAuthorCPS(author, _return);
  };
}

Curried CPS 的返回值是个函数,因为函数是 First Class ,所以 Container 可以当错参数被传递

function fetchArticlesHOF(name: string) {
  return (_return: Continuation<Article[]>) => {
    findUserByUserNameHOF(name)((optUser) => {
      if (optUser) {
        findArticlesByAuthorHOF(optUser?.name)(_return);
      }
      _return([]);
    });
  };
}

现在我们已经可以将异步的操作进行封装,并且可以通过调用 Container 的方式 获取到异步的值,下面我们 对 HOF 进行一些改造,使其更加直观一些

ExecObject

我们将 (_return: Continuation) => void 包装成一个 ExecObject

对应的 ExecObject 版的实现如下

function findUserByUserNameExec(name: string) {
  return createObj((_return: Continuation<Optional<User>>) => {
    findUserByUserNameCPS(name, _return);
  });
}

function findArticlesByAuthorExec(author: string) {
  return createObj((_return: Continuation<Article[]>) => {
    findArticlesByAuthorCPS(author, _return);
  });
}

function fetchArticleExec(name: string) {
  return createObj((_return: Continuation<Article[]>) => {
    findUserByUserNameExec(name).exec((userOpt) => {
      if (userOpt) {
        return findArticlesByAuthorExec(userOpt.name).exec(_return);
      }
      return _return([]);
    });
  });
}

将 exec 改为 then,就是我们熟悉的 Promise !

function fetchArticleExec(name: string) {
  return createObj((_return: Continuation<Article[]>) => {
    findUserByUserNameExec(name).then((userOpt) => {
      if (userOpt) {
        return findArticlesByAuthorExec(userOpt.name).then(_return);
      }
      return _return([]);
    });
  });
}

Monad

在同步编程时,我们只需要声明 一个 A ⇒ B 的函数,当 A 是异步获取的时候,我们用 HOF 将 A 抽象成了 Container,从而将 A ⇒ B 转换为 Container ⇒ Conatiner, 在 ExecObject 的方式下,我们将 A 抽象成为 ExecObject,将 A ⇒ B 转换为 ExecObject ⇒ ExecObject。我们这里对 Container 和 ExecObject 统称为 Monad。

通过调用函数,传递f的方式来处理 A ⇒ B

如图所示:不管是 Container 还是 ExecObj,都有两个部分组成

我们将 A ⇒ Monad 的过程称为 apply(也有的库叫 of,无论叫什么,只要可以构建一个 Moand对象即可, 比如 Promise.resolve() 就是构建一个 Promise的)

Monad ⇒ Monad 的过程称为 map

如果 A,B 都是简单类型 那么我们上面的是个结构为 Functor

当 f 返回的 一个 Functor 的时候,此时 map 出的结果便是 一个 Functor<Functor>, 这通常不是我们想要的结果,所以我们需要加入第三个函数来处理这种特殊的情况

ExecObject 是从 Container 转换而来的,而Container 本事是一个Curried CPS,对于函数式编程中所有的操作基本都是用同样的抽象方式来转换的,所以在范畴论当中,对于 Container 通常又叫 Continuation Monad.

Promise Monad

按照 Monad 的定义接口,我们可以按照如下定义 PromiseMonad

因为 PromiseMonad 的值是异步生成的, 这里我们添加了一个 onComplete 来帮助我们获取PromiseMonad 封装的值。

通过调用 resolve 我们可以获取到异步的值,然后将其封装进 PromiseMoand 当中,调用 onComple 可以获取这个值。

function createPromise<A>(
  resolve: (_return: Continuation<A>) => any
): PromiseMonad<A> {
  let value: A;
  let state = "pending";
  const pendingFns: Array<(_: A) => void> = [];

  resolve((a) => {
    value = a;
    state = "resolve";
    pendingFns.forEach((fn) => fn(value));
  });

  function onComplete(fn: (a: A) => void) {
    if (state === "penging") {
      fn(value);
    } else {
      pendingFns.push(fn);
    }
  }
}

flatMap 直接调用 onComplete 来获取 A,当经过 fn 的处理后,封装成一个新的 PromiseMonad

function flatMap<B>(fn: (a: A) => PromiseMonad<B>): PromiseMonad<B> {
    return createPromise<B>((resolveB) => {
      onComplete((a) => {
        fn(a).onComplete(resolveB);
      });
    });
  }

map 可以由 flatMap 去实现,事实上所有的 Monad 的 map 都可以由 flatMap 实现。因为我们由 apply函数可以将 A 转化为 Monad, 所以我们可以将 (

: A) ⇒ B 转化为 (

: A) ⇒ Monad 交由 flatMap 处理

function apply<B>(b: B): PromiseMonad<B> {
	return createPromise((_cc) => _cc(b));
}

function map<B>(fn: (a: A) => B): PromiseMonad<B> {
	return flatMap((a) => apply(fn(a)));
}

下来实现 Promise 独有的方法 then,因为 then 可以处理 (a: A) => PromiseMonad | B 的转化,所以我们可以调用 flatMap 来处理,当是 (a: A) => PromiseMonad 的情况是,不做任何处理,当为 (a: A) => B 的时候,用 apply 将 B 转成 PromiseMonad 即可。

function then<B>(fn: (a: A) => PromiseMonad<B> | B) {
    return flatMap((a: A) => {
      const result = fn(a);
      if ((result as any) && (result as any).map) {
        return result as PromiseMonad<B>;
      }
      return apply(result as B);
    });
  }

这样一个简易版的 Promise Monad 就出来了, Promise 版的实例实现如下,不过为了简单,我们并没有实现错误捕获和处理。

function findUserByUserNamePromise(name: string) {
  return createPromise<Optional<User>>((_return) => {
    findUserByUserNameCPS(name, _return);
  });
}

function findArticlesByAuthorPromise(author: string) {
  return createPromise((_return: Continuation<Article[]>) => {
    findArticlesByAuthorCPS(author, _return);
  });
}

function fetchArticlePromise(name: string) {
  return findUserByUserNamePromise(name).then((userOpt) => {
    if (userOpt) {
      return findArticlesByAuthorPromise(userOpt.name);
    }
    return [];
  });
}

实际上不止只有异步操作可以如上的抽象转为 Monad,只要你想任何值都可以进行转换,只需要提供一个合适的Monad 构造方式将 A 抽象成 Monad

Option Monad

上面我们反复出现如下模式, 在进行编写的时候,每次遇到 Option 类型的值都需要进行判断。

if (userOpt) {
	return findArticlesByAuthorPromise(userOpt.name);
}
return [];

整个过程是一个 (userName: string) ⇒ Promise<Article[]> 的过程,整个推到过程如下

String => Promise<Optional> => Promise<Article[]>

这里我们手动处理对空值对情况,在实际对开发情况下,我们整个函数调用练中可能存在多次对判断。比如我们有如下函数推倒

findUser: String => Optional<User>
findUserAccount: User => Optional<BankAccount>
availableBalance: BankAccount => number

const optUser = findUser("mark")
if (optUser) {
	const optAccount = findUserAccount(optUser)
	if (optAccount) {
    const balance = availableBalance(account)
  }
}

整个过程为 String ⇒ Optonal, 为了包装程序的健壮性,我们每次都需要对Optional进行处理。

这里我们需要了解 Optional 的本质, 他是 Some 和 None 两个类型组成的 Union,我们把这种类型叫做 Some 和 None 的 积(Product)

为了减少程序的复杂度,我们只对 Some 进行处理,对 None 进行的任何操作应该都返回 None

我们可以把 None 必做 0,对 0 乘以 任何书 都是 0,把 Some 必做 1,1 乘以任何数都是 它本事。

**String => Some<User> => Some<BankAccount> => number**
			 => None       => None              => None

我们定义如下 Option 类型

interface Option<T> {}
class None extends Option<any> {
	map<B>(fn: (_: A) => B)) {
		return None
  }
}
class Some<A> extends Option<A> {
	private value: A
  constructor(elem: A) {
		this.value = elem
  }
	map<B>(fn: (_: A) => B) {
		return new Some(fn(this.value))
  }
}

可以看出 对于 Option 做 map 操作,如果是 Some 就会生成新的 值, 如果是None则会立即返回 None。上面的对获取 可用余额的实现,我们就可以按照如下编写,我们值关心 如何做 map,而不用去处理空值的情况。

findUser("mark").map(findUserAccount).map(availableBalance)

Either Monad

和 Option 类似,异常(Exception)通常也需要调用者手动去处理,对于可以抛出异常的函数,我们都可以将其看作是返回两个值。要么结果是正确的 (Right),要么是错误的 (Left),我们通常只关心 Right 的情况。所以我们可以定义如下类型

Either<A, B> = Left<A> | Right<B>

所以我们可以实现如下的 Either Monad

interface<A, B> {}
class Left<A, B> {
	private value: A
  constructor(elem: A) {
		this.value = elem
  }
	map<C>(fn: (_: B) => C): Left<A, C> {
		return this
  }
}
class Right<A, B> {
	private value: B
  constructor(elem: B) {
		this.value = elem
  }
	map<C>(fn: (_: B) => C): Left<A, C> {
		return new Right<A, C>(fn(this.value))
  }
}

当查询不到用户信息的时候,我们可以返回找不到的原因,也可能是用户真不存在,也可能是用户被禁用了

type Error = string
findUser: String => Either<Error, User>
findUserAccount: User => Either<Error, BankAccount>
availableBalance: BankAccount => number

findUser("mark").map(findUserAccount).map(availableBalance)

我们依旧可以只用两个map进行数据的获取,而不用每次在每次调用事都检测异常。

findUser("mark").map(findUserAccount).map(availableBalance)

延展

  • Monad 内不仅可以封装一个值,然后去应用一个函数。也可以封装一个函数去应用一个值,我们把这种 Monad 叫做 Application。

  • Monad 也可以嵌套,比如 Option<Promise>, 对于这种的嵌套类型,可以进行转换成, 这种转换叫做 traverse

    Option<Promise> ⇒ Promise<Option>

    Array<Promise> ⇒ Promise<Array>

  • T 可以是个 Product,可以只 map Product 的左边部分或者右边,这种叫做 BiFunctor。

  • Javascript 中不包含对 continuation 的操作,但是我们可以用 generator 来实现一个 call/cc, 然后来实现各种控制语句。

  • PromiseMonad 的链式调用可以改为同步写法,主要做法是用 generator 来控制 continuation 的运行。

总结

函数式编程的这种数据抽象的思维,基本都来源于“范畴论”,范畴论主要研究的是对象之前的关心,范畴之间的关系。在整个数学领域中,范畴论在最顶端,通常在某一个范畴内有无法解决的问题时,我们可以通过关系(态射)将其转到另外一个范畴去解决,从而也就解决了当前领域的问题。范畴论为这种关系的映射 提供的数学上的证明。

在编程当中也有大量的思想,比如 React,将我们的页面抽象为 ReactElement,Recoil 将数据抽象为 RecoilAtom,我们在开发的过程中只关心局部的正确性,而整体的正确性由框架来确定。

Source Code