# 462-柯里化2

# 题目描述

Currying (opens new window) 是一种将带有多个参数的函数转换为每个带有一个参数的函数序列的技术。

但是在前端的日常开发中,柯里化函数参数个数动态化却是非常常见的,例如 Function.bind(this, [...params])

const func = (a: number, b: number, c: number) => {
  return a + b + c;
};

const bindFunc = func(null, 1, 2);

const result = bindFunc(3); // result: 6

因此,在 柯里化 的基础上,我们更需要的是 动态参数化的柯里化函数

const add = (a: number, b: number, c: number) => a + b + c;
const three = add(1, 1, 1);

const curriedAdd = DynamicParamsCurrying(add);
const six = curriedAdd(1, 2, 3);
const seven = curriedAdd(1, 2)(4);
const eight = curriedAdd(2)(3)(4);

传递给 DynamicParamsCurrying 的函数可能有多个参数,您需要实现它的类型。

在此挑战中,curriedAdd 函数每次可接受最少一个参数,但是所有参数个数总和及类型与原函数相同。分配完所有参数后,它应返回其结果。

# 分析

这个题目,饶是以已经刷过绝大部分体操题的我,还是陷入了沉思。

分析题目意思:入参是一个函数,而函数的返回值,是有非常多组合的可能,以 3 个入参 string, boolean, number, 返回值为 number 为例:

可能的返回值有:

type r1 = (a: string) => (b: boolean) => (c: number) => number;

type r2 = (a: string) => (b: boolean, c: number) => number;

type r3 = (a: string, b: boolean) => (c: number) => number;

type r4 = (a: string, b: boolean, c: number) => number;

有点类似排列组合,但是又不是。

这里先提供一种思路:第一个参数和第二个参数之间,要不要拆分?第二个和第三个参数之间,要不要拆分,故一共有 4 种情况。

而对于 4 个入参,则存在 3 次 拆与不拆分情况,也就是有 8 种可能。

# 思路一

所以这个题目的一种思路便是:生成所有的可能返回情况,并将可能情况进行交叉。即可得到最终的结果。

这里函数交叉的结果,为什么能够作为最终的结果呢?

可以看下例子:

type Test = ((a: string) => string) & ((a: number) => number);

declare const t: Test;

// number
const c1 = t(1);

// string
const c2 = t('1');

实践出真知,对于函数交叉后,就有点类似函数的重载,新的函数类型可以处理交叉中的每个函数的功能。

如此,本题就转换成了如何生成拆分与不拆分的排列组合情况。

type Curry3<
  // 剩余参数
  A extends any[],
  // 返回值
  R,
  // 暂存 未拆分的参数的类型
  D extends any[] = [],
> =
  // 拆出第一个入参
  A extends [infer F, ...infer Rest]
    ? // 如果是最后一个入参
      Rest extends []
      ? // 直接返回拼接了当前未拆分参数之后的新函数
        (...args: [...D, F]) => R
      : // 情况一:这个参数选择拆分,此时需要清空暂存的参数,并将其放在入参中
        ((...args: [...D, F]) => Curry3<Rest, R>) &
          // 情况二:这个参数不选择拆分,此时需要给 D 中暂存 F 这个参数
          Curry3<Rest, R, [...D, F]>
    : // 没有入参,直接返回函数
      () => R;

declare function DynamicParamsCurrying<A extends any[], R>(
  fn: (...args: A) => R,
): Curry3<A, R>;

这个解法思路还是比较清晰的,遍历入参,每次取出一个参数,根据要不要把这个参数和后面的参数拆分,分成两种情况:

  1. 拆分,那么需要将之前暂存的参数和当前参数一起形成新的入参,而返回值则是递归剩余参数,同时需要清空暂存的参数,对应 ((...args: [...D, F]) => Curry3<Rest, R>) 这段代码

  2. 不拆分,那么就直接递归,并将当前参数暂存起来,对应 Curry3<Rest, R, [...D, F]> 这段代码

这里要注意的是 ((...args: [...D, F]) => Curry3<Rest, R>) & Curry3<Rest, R, [...D, F]> 中的第一个括号,这里涉及到了 ts 的优先级, & 是大于函数定义的优先级的,如果不加括号:(...args: [...D, F]) => Curry3<Rest, R> & Curry3<Rest, R, [...D, F]>,就会生成一个函数而非两者的交叉,需要关注一下。

# 思路二

类似于思路一,不过不是从是否拆分参数入手,而是从可能的入参组合入手,假设三个入参: [string, boolean, number] 的情况,那么对于第一个函数的入参,只有可能是:[string] | [string, boolean] | [string, boolean, number],此时针对第一种情况,那么 剩余待分配的参数有 boolean, number,此时第二个函数的入参,只能是 [boolean] | [boolean, number],依次类推可得其他情况的入参。

可以先写一个生成入参的辅助类:

type Zuhe<T extends any[]> = T extends [infer F, ...infer R]
  ? // 当前元素必须选择
    [F] | [F, ...Zuhe<R>]
  : [];

// FirstArgs: [string] | [string, boolean] | [string, boolean, number]
type FirstArgs = Zuhe<[string, boolean, number]>;

从而得到目标函数的第一层柯里化的入参值,只有可能是上述几种情况。

之后递归得到剩余参数即可。同时由于最终需要输出交叉后的函数类型,所以还需要编写辅助的 联合转交叉的工具类型,同 联合转交叉

最终代码如下:

// 生成当前可能的入参
type Zuhe<T extends any[]> = T extends [infer F, ...infer R]
  ? [F] | [F, ...Zuhe<R>]
  : [];

// 联合转交叉
type UnionToIntersection<U> = (
  U extends any ? (arg: U) => any : never
) extends (arg: infer P) => any
  ? P
  : never;

// 参数组合并拼接成最终的答案
type Map<
  T extends any[],
  // 分发后,用于存储原始的全部入参,需要据此匹配剩余的入参
  A extends any[],
  R,
> =
  // 分发特性触发入参分发
  T extends any
    ? A extends T
      ? (...args: T) => R
      : (...args: T) => // 匹配剩余入参
        A extends [...T, ...infer Rest]
          ? // 对剩余入参,进行同样的操作
            UnionToIntersection<Map<Zuhe<Rest>, Rest, R>>
          : never
    : never;

type Curry2<A extends any[], R> = UnionToIntersection<Map<Zuhe<A>, A, R>>;

declare function DynamicParamsCurrying<A extends any[], R>(
  fn: (...args: A) => R,
): Curry2<A, R>;

# 思路三

这个思路最为简单,但是其实有很多的思考。他本身并没有生成所有的可能,而是在运行的过程中,不断借助 ts 本身的隐式类型推导,来免除了生成所有可能的工作。

这里直接配合代码进行描述:

declare function DynamicParamsCurrying<A extends any[], R>(
  fn: (...args: A) => R,
): A extends []
  ? R
  : // 返回一个函数,其入参 P 通过在运行时进行推导
    <P extends any[]>(
      ...args: P
    ) => A extends [...P, ...infer Rest]
      ? Rest extends []
        ? R
        : // 对剩余的元素,递归处理,借助了 typeof, ReturnType 的内置类型
          ReturnType<typeof DynamicParamsCurrying<Rest, R>>
      : never;

# 题解

本题还是比较复杂的,但是如果能够灵活的借助 ts 自带的类型推断,能够省去很大的工作量。解法三便是此思路。

declare function DynamicParamsCurrying<A extends any[], R>(
  fn: (...args: A) => R,
): A extends []
  ? R
  : // 返回一个函数,其入参 P 通过在运行时进行推导
    <P extends any[]>(
      ...args: P
    ) => A extends [...P, ...infer Rest]
      ? Rest extends []
        ? R
        : // 对剩余的元素,递归处理,借助了 typeof, ReturnType 的内置类型
          ReturnType<typeof DynamicParamsCurrying<Rest, R>>
      : never;

# 知识点

  1. 交叉后的函数类型,基本等于函数的重载,可以根据入参类型自行推断返回值,可用来实现函数重载
  2. ts 的类型推断,除了大家常见的 a = 1 会将 a 推断为 number,在函数的入参中进行推断也非常实用但不容易被意识到
  3. 联合转交叉
Last Updated: 2023/5/23 05:42:46