likes
comments
collection
share

函数式编程 - fp-ts 初探

作者站长头像
站长
· 阅读数 12

Introduce

在学习 fp-ts 这个库之前,如果有兴趣,可以阅读:

正如官网介绍的那样:Typed functional programming in TypeScript Fp-ts实现了函数式编程的概念,函数引用透明性、函数组合、函子等

It is difficult to get started. I have just started learning it.

Why use it

  • 首先,它是帮我们定义并解决类型安全问题
  • 其次,它符秉承了函数式编程的思想,将帮助我们将复杂的逻辑,简单化、流程化,可读性强,提升代码质量,便于维护

口说无凭,那么我们先看一个例子:

// 模拟请求操作
const makeUrl = (x: string) => `htpp://fp-ts.com?name=${x}`;
const requestSync = (url: string): IResponse => {
   // deal with url
   return { name: 'flow', next: 'pipe' };
}
const handleResponse = ({ name, next }: IResponse) => `${name} >> ${next}`;
  • 命令式写法:我们常用的方式
const func01 = (name: string): string => {
  const url = makeUrl(name);
  const res = requestSync(url);
  return handleResponse(res);
}
console.log(func01('api')); // flow >> pipe
  • 洋葱式写法
const func02 = (name: string): string => handleResponse(requestSync(makeUrl(name)));
console.log(func02('api')); // flow >> pipe
  • 使用 fp-ts 的 flow 函数
    • flow类似pipe,flow会将第一个函数的执行结果传递给下一个函数,并作为该函数的参数
import { flow } from 'fp-ts/Function';

const func03 = flow(makeUrl, requestSync, handleResponse);
console.log(func03('api')); // flow >> pipe

再看一个例子,在handleResponse处理完后,我们还要结合业务对结果再次处理,总不能再写一个函数来处理吧

const func04 = (name: string): string => {
  const url = makeUrl(name);
  const res = requestSync(url);
  const result = handleResponse(res);
  return `${url} : ${result}`;
}
console.log(func04('api')); // htpp://fp-ts.com?name=api : flow >> pipe
  • fp-ts:flow,可读性太差了
import { flow } from 'fp-ts/Function';

const func05 = flow(
  makeUrl, // 返回 url,并作为下一个函数的参数
  url => ({ url, response: requestSync(url) }), // 返回 {url, response};
  ({ response, ...others }) => ({ ...others, result: handleResponse(response) }), // 返回 { url, result }
  ({ url, result }) => `${url} : ${result}`
);
console.log(func05('api')); // htpp://fp-ts.com?name=api : flow >> pipe
  • fp-ts:优化 flow 版本,flow + pipe,pipe是调用版的flow
import { flow, pipe } from 'fp-ts/Function';

const func06 = flow(
  makeUrl ,
  url => ({ url, result: pipe(url, requestSync, handleResponse) }),
  ({ url, result }) => `${url} : ${result}`
);
console.log(func06('api')); // htpp://fp-ts.com?name=api : flow >> pipe
  • fp-ts: flow + Identity 方案,这个方案有点难!!!
    • bindTo 相当于于给对象增加一个属性
import { flow } from 'fp-ts/Function';
import * as ID from 'fp-ts/Identity'

const func07 = flow(
  makeUrl,
  ID.bindTo('url'), // 第一次给对象增加url属性,
  // 第二次将makeUrl的结果传递给x => x.url,并给url属性赋值
  ID.bind('result', flow(
    x => x.url, requestSync, handleResponse
  )),// 返回 { url, result: xxx },bindTo 增加 rusult: value 键值对
  // 将收到的参数原样返回
  ID.map(({ url, result }) => `${url} : ${result}`)
);
console.log(func07('api')); // htpp://fp-ts.com?name=api : flow >> pipe

Let's hit the road

pipe(管道)

  • pipe 允许你从从左到右连接接一系列函数。 pipe 的类型定义采用任意数量的参数。第一个参数可以是任意值,后续参数必须是要处理这个参数的函数,管道中前一个函数的返回类型必须与后一个函数的输入类型相匹配
  • pipe(f1的参数, f1, f2, ..., fn)
import { pipe } from 'fp-ts/Function'

function add1(num: number): number {
  return num + 1
}

function multiply2(num: number): number {
  return num * 2
}

pipe(1, add1, multiply2) // 4
// 第一个参数1为add1的参数,
// add1的执行结果做为 multiply2 的参数
  • 有了这个基础,我们后面还可以连接更多的操作,比如
function toString(num: number): string {
  return `${num}`
}

pipe(1, add1, multiply2, toString) // '4'
  • 注意:管道中前一个函数的返回类型必须与后一个函数的输入类型相匹配
pipe(1, add1, toString, multiply2);
// Error,类型检查错误,“(num: number) => string”类型的参数不可分配给“(b: number) => number”类型的参数。类型“字符串”不可分配给类型“数字”
// toString的结果,作为multiply2的参数时,ts检查报错

总结:使用pipe运算符可以使用一系列函数来转换任何值,A -> (A->B) -> (B->C) -> (C->D)

Flow(流)

  • flow 运算符几乎类似于 pipe 运算符。不同之处在于第一个参数必须是一个函数,而不是任何任意值
  • const flowFn = flow(f1, f2, ..., fn) 然后 flow(f1的参数)
flow(add1, multiply2, toString)(1)
// 等同于
pipe(1, flow(add1, multiply2, toString));

(A->B) -> (B->C) -> (C->D) -> (D->E)

  • When or where use pipe or flow
    • 当您想避免使用匿名函数时
function concat(
  a: number,
  transformer: (a: number) => string,
): [number, string] {
  return [a, transformer(a)]
}

// 我们必须将 `n` 声明为匿名函数的一部分才能将其与 `pipe` 运算符一起使用。应该避免这种情况,因为它会使我们面临隐藏外部范围中的变量的风险。它也更冗长
concat(1, (n) => pipe(n, add1, multiply2, toString)) // [1, '4']

// 应该使用flow
concat(1, flow(add1, multiply2, toString)) // [1, '4']

Option、Map、Flatten、Chain

Option

  • Option可以包装 undefined 或 null 值的容器,如果该值存在,Option是some类型,如果值为 undefined 或 null,我们说它具有 None类型
  • 这样,Option就为我们实现了类型保护
type Opiton<A> = None | Some<A>
  • 为什么我们首选Option类型? 因为它可以用于显式表示值可以不存在
import * as O from "fp-ts/Option";

function safeHead<T>(arr: T[]): O.Option<T> {
  return arr.length === 0 ? O.none : O.some(arr[0]);
}

Map

  • map运算符允许将一个值转换成另一个值,就是 Container的map呀
const foo = {
  bar: 'hello',
}

pipe(foo, (f) => f.bar) // hello

foo 映射到 foo.bar

interface Foo {
  bar: string;
}

const foo = {
  bar: 'hello'
} as Foo | undefined;

// pipe(foo, (f) => f?.bar) // hello
pipe(foo, ({ bar }) => bar) // Property 'bar' does not exist on type 'Foo | undefined'
  • 这就使用到Option和map,用 Option 模块中的 map 函数替换匿名函数后,编译器不再报错
import * as O from "fp-ts/Option";

pipe(foo, O.fromNullable, O.map(({ bar }) => bar)) // { _tag: 'Some', value: 'hello' }

pipe(undefined, O.fromNullable, O.map(({ bar }) => bar)) {_tag: 'None'}
  • O.map通过对 _tag 属性进行比较来工作。如果 _tag 是 Some ,它会使用传递给 map 的函数转换值。在本例中,我们使用 ({ bar }) => bar 对其进行了转换。但是,如果 _tag 是 None ,则不执行任何操作。容器保持在 None 状态

Flatten

  • 我们将如何处理对象具有顺序嵌套的可空属性的情况?让我们扩展上面的例子
interface Fizz {
  buzz: string
}

interface Foo {
  bar?: Fizz
}

const foo = { bar: undefined } as Foo | undefined

pipe(foo, (f) => f?.bar?.buzz) // undefined
  • 为了使它与可选链接一起工作,我们只需要添加另一个问号。使用 Option 类型会怎样
pipe(
  foo,
  O.fromNullable,
  O.map(({ bar: { buzz } }) => buzz),
)
// Property 'buzz' does not exist on type 'Fizz | undefined'
  • 可悲的是,我们遇到了以前遇到的同样问题。也就是说,对象解构不能用于可能是 undefined 的类型。我们可以做的是使用 O.fromNullable 两次将 foo 和 bar 提升为Option 类型
pipe(
  foo,
  O.fromNullable,
  O.map(({ bar }) =>
    pipe(
      bar,
      O.fromNullable,
      O.map(({ buzz }) => buzz),
    ),
  ),
) // { _tag: 'Some', value: { _tag: 'None' } }
    • 但是现在我们产生了两个新问题。首先,它非常冗长。其次,我们有一个嵌套的选项。查看外部和内部选项的 _tag 。第一个是 Some ,这是我们期望的,因为定义了 foo.bar 。第二个是 None 因为 foo.bar.buzz 是 undefined 。如果你只关心最后一个 Option 的结果,那么每次都需要遍历 Option 的嵌套标签列表
  • 我们只关心最终的 Option,那么我们将这个嵌套的Option扁平化?

pipe(
  foo,
  O.fromNullable,
  O.map(({ bar }) =>
    pipe(
      bar,
      O.fromNullable,
      O.map(({ buzz }) => buzz),
    ),
  ),
  O.flatten,
) // { _tag: 'None' }

但是,我们仍然有冗长的问题。如果我们可以使用单个运算符同时映射和展平嵌套选项,那将是有益的。直观上,这通常称为平面图运算符

Chain(Flatmap)

  • 在 fp-ts 中,平面图运算符称为 chain 。我们可以将上面的代码重构为下面的代码
pipe(
  foo,
  O.fromNullable,
  O.map(({ bar }) => bar),
  O.chain(
    flow(
      O.fromNullable,
      O.map(({ buzz }) => buzz),
    ),
  ),
) // { _tag: 'None' }

Array

  • 我们先看一个例子
const foo = [1, 2, 3, 4, 5]

const sum = foo
  .map((x) => x - 1)
  .filter((x) => x % 2 === 0)
  .reduce((prev, next) => prev + next, 0)
console.log(sum) // 6

Basic

fp-ts 中,提供了用不同的语法做同样的事情

import * as A from 'fp-ts/lib/Array'
import { pipe } from 'fp-ts/lib/function'

const foo = [1, 2, 3, 4, 5]

const sum = pipe(
  // 注意:array已经弃用,官方建议使用函子Functor来表示数组
  // A.array.map(foo, (x) => x - 1),
  A.Functor.map(foo, x => x - 1),
  A.filter((x) => x % 2 === 0),
  A.reduce(0, (prev, next) => prev + next),
)
console.log(sum) // 6

这段代码与前面的例子,没啥区别呀,如果只是使用数组的一些操作方法,只是新的语法糖,为啥多此一举,还要引入fp-ts,我直接使用数组原型链上的方法或lodash不香么???

fp-ts确实香,因为它不但提供了一些数组的其他操作方法并且能保障数据类型安全,下面简要介绍几种方法

zip

import * as A from 'fp-ts/lib/Array'
import { pipe } from 'fp-ts/lib/function'

const foo = [1, 2, 3]
const bar = ['a', 'b', 'c']

const zipped = pipe(foo, A.zip(bar))
console.log(zipped) // [[1, 'a‘], [2, 'b’], [3, 'c']]

It's wonderful! 它保留了数组中元素的类型安全性。 zipped 的类型是 Array<[number, string]> 而不是 Array<Array<number | string>> 。换句话说, zipped 是一个元组数组

unzip

  • zip的反向操作
import * as A from 'fp-ts/lib/Array'

// const unzip: <A, B>(as: [A, B][]) => [A[], B[]]

A.unzip([[1, 'a'], [2, 'b'], [3, 'c']]) // [[1, 2, 3], ['a', 'b', 'c']]
// [number[], string[]]

partition

  • 根据条件过滤出结果集,该集合包含 left 和 right 值
  • left: 不符合条件的数组
  • right: 符合条件的数组
import * as A from 'fp-ts/lib/Array';
import { isString } from 'fp-ts/lib/string';

A.partition(isString)(['a', 1, {}, 'b', 5]); // { left: [1, {}, 5], right: ['a', 'b'] }

A.partition((x: number) => x > 0)([-3, 1, -2, 5]); // { left: [-3, -2], right: [1, 5] }

flatten

  • 将维数组平铺
import * as A from 'fp-ts/lib/Array';
A.flatten([['a'], ['b', 'c'], ['d', 'e', 'f']]);// 'a', 'b', 'c', 'd', 'e', 'f']

Array Type Safety

  • 数组类型安全问题
  • ts中不安全之一就是数组访问越界和突变,这可能给我们带来可预知的灾难
    • 在js和ts中,不像java那样拥有IndexOutOfBounds这种异常行为检查,而是越界时返回undefined
    • 当在其边界之外改变数组时,您还可以创建未定义的行为
const foo = [1, 2, 3];

const x: number = foo[4];// no error
foo[5] = 2; // no error
console.log(foo); // [1, 2, 3, undefined, undefined, 2]

Lookup

  • 数组的查找函数,它有两个参数,一个索引和一个数组,并返回一个 Option 类型
  • 如果它返回的Options为Node的情况,我们就必须得处理这种情况
import * as A from 'fp-ts/lib/Array'
import { pipe } from 'fp-ts/lib/function'

pipe([1, 2, 3], A.lookup(1)) // { _tag: 'Some', value: 2 }
pipe([1, 2, 3], A.lookup(3)) // { _tag: 'None' }

InsertAt 、UpdateAt、DeleteAt

  • insertAt(index, item)(array)
    • index 数组下标,item将要插入的元素
  • updateAt(index, item)(array)
    • index 数组下标,item将要替换的元素
  • DeleteAt(index)(array)
    • index 数组下标

这三个API相当于splice,相较之,类型安全

insertAt(2, 5)([1, 2, 3, 4]); // { _tag: 'Some', value: [ 1, 2, 5, 3, 4 ] }
insertAt(2, 5)([]); // { _tag: 'None' }

updateAt(1, 1)([1, 2, 3]); // { _tag: 'Some', value: [ 1, 1, 3 ] }
updateAt(2, 5)([]); // { _tag: 'None' }

deleteAt(0)([1, 2, 3]); // some([2, 3])
deleteAt(1)([]);// { _tag: 'None' }

NonEmptyArray

表示非空数组,先看一个例子

const foo = [1, 2, 3]
if (foo.length > 0) {
  // We don't want an Option since we know it will always be some
  const firstElement = A.head(foo) // { _tag: 'Some', value: 1 }
}

我们想要的是value值,这时可以使用 NonEmptyArray

import * as A from 'fp-ts/lib/Array'
import * as NEA from 'fp-ts/lib/NonEmptyArray'

const foo = [1, 2, 3]
if (A.isNonEmpty(foo)) {
  const firstElement = NEA.head(foo) // 1
}

Task、Either、TaskEither

Task 异步任务

interface Task<A> {
  (): Promise<A>
}

type Task<A> = () => Promise<A>

所有异步函数都将有一个返回 Promise<T> 的类型定义。有些功能可能永远不会失败,但出于必要是异步的。 Promise 不提供有关函数是否会失败的指示。因此,在命令式模型中,您被迫使用 try-catch-finally 块来处理这些错误

通过使用 Task<T> ,我们减轻了客户端处理不存在的错误的负担

async function someTask(id: string) {
  if (id.length > 36) {
    throw new Error('id must have length less than or equal to 36')
  }

  // do async work here
}

const id = 'abc'
const task: T.Task<void> = () => someTask(id)

一个更真实的示例是当您有一个可能会失败的操作时,但是通过将成功和失败结果都减少为单一类型来处理。由于错误已得到处理,该函数虽然是异步的,但始终会返回已完成的 Promise

async function boolTask(): Promise<boolean> {
  try {
    await asyncFunction()
    return true
  } catch (err) {
    return false
  }
}

// 转为Task

const boolTask: Task<boolean> = async () => {
  try {
    await asyncFunction()
    return true
  } catch (err) {
    return false
  }
}

未完待续