w3ctech

[ReasonML] - Functors - 函子


原文:http://2ality.com/2018/01/functors-reasonml.html

翻译:ppp

系列文章目录详见: “什么是ReasonML?


函子是从模块到模块的映射。这篇博文解释了他们如何工作以及他们为什么有用。

这些示例的代码可以在GitHub上的仓库repository reasonml-demo-functors中找到。

注: 函子(Functors)是一个高阶主题。刚开始可能并不知道如何编写它们。在此我只对它们的一些基本用法做一些说明以便在必要时用到。

1. 什么是函子?

函子是范畴论中用以表达范畴之间映射关系的一个概念。在ReasonML中,有两种理解函子的方式:

  • 一个函数,其参数是模块,其结果是一个模块。
  • 通过模块(参数)参数化(配置)的模块(结果)。

你只能通过子模块来定义一个函子(它不能是文件中的顶层元素)。但这不是问题,因为你通常都在模块内部定义函子,让它更靠近接口,使参数和返回值(可选)更清晰。

函子的语法如下所示。

module «F» = («ParamM1»: «ParamI1», ···): «ResultI» => {
  «body of functor»
};

函子F将一个或多个模块ParamM1作为参数。这些模块必须通过接口ParamI1等确定类型。返回值类型ResultI(另一个接口)是可选的。

函子的主体具有与普通模块相同的语法,只是具体实现和参数根据实际情况而定。

2. 第一个函子

2.1 定义函子 Repetition.Make

作为我们的第一个例子,我们定义了一个函子Repetition.Make,该函子导出repeat()函数:功能为复制其参数(字符串)。函子的参数配置字符串重复的频率。

在我们定义函子之前,我们需要为其参数定义一个接口Repetition.Count:

/* Repetition.re */

module type Count = {
  let count: int;
};

这就是函子:

/* Repetition.re */

module Make = (RC: Count) => {
  let rec repeat = (~times=RC.count, str: string) =>
    if (times <= 0) {
      "";
    } else {
      str ++ repeat(~times=times-1, str);
    };
};

Make是一个函子:它的参数RC是一个模块,其返回值(在花括号中)也是一个模块。

repeat()是一个相对无关紧要的ReasonML函数。唯一新颖的一点是~times的默认值来自于参数RC模块。

2.2 Repetition.Make返回值得接口

Repetition.Make目前没有定义其返回值的类型。这意味着ReasonML会推断出这种类型。如果我们想结果更可控,我们可以为返回值定义一个接口S:

/* Repetition.re */

module type S = {
  let repeat: (~times: int=?, string) => string;
};

之后,我们只需要把S添加到Make的定义中:

module Make = (RC: Count): S => ···

2.3 使用Repetition.Make

接下来,我们在一个单独的文件RepetitionMain.re中使用Repetition.Make。首先,我们为这个函子的参数定义一个模块:

/* RepetitionMain.re */

module CountThree: Repetition.Count = {
  let count=3;
};

只要CountThree和Repetition.Count具有相同的解构,就不在需要Repetition.Count了。CountThree的意义更明确。

现在我们已经准备好使用函子Repetition.Make为我们创建一个模块了:

/* RepetitionMain.re */

module RepetitionThree = Repetition.Make(CountThree);

以下是RepetitionThree.repeat的调用,以及如预期的返回:

/* RepetitionMain.re */

let () = print_string(RepetitionThree.repeat("abc\n"));

/* Output:
abc
abc
abc
*/

我们也可以用内联的方式来定义CountThree:

module RepetitionThree = Repetition.Make({
  let count=3;
});

2.4 模块Repetition的结构

我们创建模块Repetition的方式函子的一个常见用法。它有以下几部分:

  • Make:函子。
  • 一个或多个Make参数的接口(我们例子中的Count)。
  • S:Make返回值的接口。

也就是说,Repetition打包了函子Make和它所需要的一切。

3. 函子与数据结构

函子的一个常见用法是实现数据结构:

  • 函子的参数指定了由通过数据结构来管理的元素。由于参数是模块,你不仅可以指定元素的类型,还可以指定数据结构管理它们可能需要的辅助函数。
  • 函子的返回值是为特定元素量身定做的模块。

例如,数据结构Set(我们将在后面详细介绍)必须能够比较它的元素。因此,集合的函子具有带以下接口的参数:

module type OrderedType = {
  type t;
  let compare: (t, t) => int;
};

OrderedType.t是集合中元素的类型,OrderedType.compare用于比较这些元素。OrderedType.t类似于多态类型list('a)中的类型变量'a。

3.1 PrintablePair1:可打印pairs的函子第一版

让我们实现一个非常简单的数据结构:由任意元件组成且可以打印(转换为字符串)的pair。为此,我们必须首先能够打印他们的元件。这就是为什么组件必须通过以下接口指定的原因。

/* PrintablePair1.re */

module type PrintableType = {
  type t;
  let print: t => string;
};

我们继续使用Make为函子命名,通过它创建了一个实际数据类型的模块:

/* PrintablePair1.re */

module Make = (Fst: PrintableType, Snd: PrintableType) => {
  type t = (Fst.t, Snd.t);
  let make = (f: Fst.t, s: Snd.t) => (f, s);
  let print = ((f, s): t) =>
    "(" ++ Fst.print(f) ++ ", " ++ Snd.print(s) ++ ")";
};

这个函子有两个参数:Fst指定可打印对的第一个元件,Snd指定第二个元件。

PrintablePair1.Make返回的模块包含以下部分:

  • t是这个函子支持的数据结构的类型。注意它是如何引用函子参数Fst和Snd。
  • make是用于创建t类型值的函数。
  • print用于打印pair。它将可打印的pair转换为string。

使用函子

让我们使用函子PrintablePair1.Make来创建一个可打印的pair,其第一个元件是一个string,其第二个元件是一个int。

首先,我们需要为函子定义参数:

/* PrintablePair1Main.re */

module PrintableString = {
  type t=string;
  let print = (s: t) => s;
};
module PrintableInt = {
  type t=int;
  let print = (i: t) => string_of_int(i);
};

接下来,我们使用函子来创建一个模块PrintableSI:

/* PrintablePair1Main.re */

module PrintableSI = PrintablePair1.Make(PrintableString, PrintableInt);

最后,我们创建并打印一个pair:

/* PrintablePair1Main.re */

let () = PrintableSI.({
  let pair = make("Jane", 53);
  let str = print(pair);
  print_string(str);
});

改进封装

目前的实现有一个缺陷,我们不通过PrintableSI.make()也能创建类型为t的元素:

let pair = ("Jane", 53);
let str = print(pair);

为了防止这种情况,我们需要通过一个接口来对Make.t进行类型抽象:

/* PrintablePair1Main.re */

module type S = (Fst: PrintableType, Snd: PrintableType) => {
  type t;
  let make: (Fst.t, Snd.t) => t;
  let print: (t) => string;
};

这是我们如何定义Make的类型S:

module Make: S = ···

请注意,这S是整个函子的类型。

3.2 PrintablePair2:仅用于结果的接口

定义一个仅用于函子返回值的接口更常见(而不是像之前那样完整的函子)。这样,你可以将该接口用于其他的情况。紧接上例,接口定义如下。

/* PrintablePair2.re */

module type S = {
  type fst;
  type snd;
  type t;
  let make: (fst, snd) => t;
  let print: (t) => string;
};

现在我们不能再引用参数Fst和Snd了。因此,我们需要引入两个新的类型fst和snd,用于定义make()。之前的函数的类型是这样:

let make: (Fst.t, Snd.t) => t;

那么我们如何把fst,snd与Fst.t,Snd.t连接起来呢?我们通过所谓的共享约束来修改接口。用法如下:

/* PrintablePair2.re */

module Make = (Fst: PrintableType, Snd: PrintableType)
: (S with type fst = Fst.t and type snd = Snd.t) => {
  type fst = Fst.t;
  type snd = Snd.t;
  type t = (fst, snd);
  let make = (f: fst, s: snd) => (f, s);
  let print = ((f, s): t) =>
    "(" ++ Fst.print(f) ++ ", " ++ Snd.print(s) ++ ")";
};

以下两个等式是共享约束条件:

S with type fst = Fst.t and type snd = Snd.t

S with 给出了共享约束改变接口S的提示:

{
  type fst = Fst.t;
  type snd = Snd.t;
  type t;
  let make: (fst, snd) => t;
  let print: (t) => string;
}

还有一件事我们可以改进:fst和snd是多余的。如果返回值的接口能直接引用Fst.t和Snd.t会更好(就像我们定义一个完整的函子一样)。我们可以通过破坏性替换来实现。

3.3 PrintablePair3:破坏性替代

破坏性替代与共享约束非常相似。然而:

  • 共享约束S type t = u 为S.t提供了更多信息。
  • 破坏性替代 type t := u 用u替换所有出现在S内部的t。

这是用破坏性替代后的Make:

module Make = (Fst: PrintableType, Snd: PrintableType)
: (S with type fst := Fst.t and type snd := Snd.t) => {
  type t = (Fst.t, Snd.t);
  let make = (fst: Fst.t, snd: Snd.t) => (fst, snd);
  let print = ((fst, snd): t) =>
    "(" ++ Fst.print(fst) ++ ", " ++ Snd.print(snd) ++ ")";
};

破坏性替换从S中删除了fst和snd。因此,我们不需要将它们定义在Make主体中,并且可以始终直接引用Fst.t和Snd.t。归功于破坏性替换,内部定义Make与接口要求的内容相匹配。现在Make的返回值类型签名为:

{
  type t;
  let make: (Fst.t, Snd.t) => t;
  let print: (t) => string;
}

3.4 例子:使用函子Set.Make

处理set的标准模块Set遵循我已经解释过的约定:

  • Set.Make 是生成处理集合的实际模块的函子。
  • Set.OrderedType是Make参数的接口:
      module type OrderedType = {
      type t;
      let compare: (t, t) => int;
      };
    
  • Set.S是Make返回值的接口。

这是你如何创建并使用一个string sets:

module StringSet = Set.Make({
  type t = string;
  let compare = Pervasives.compare;
});

let set1 = StringSet.(empty |> add("a") |> add("b") |> add("c"));
let set2 = StringSet.of_list(["b", "c", "d"]);

/* StringSet.elements(s) converts set s to list */
StringSet.(elements(diff(set1, set2)));
  /* list(string) = ["a"] */

方便的是,ReasonML的标准库带的String可以作为模块Set.Make的参数,因为它兼有String.t和String.compare。因此,我们也可以写下:

module StringSet = Set.Make(String);

4.用于扩展模块的函子

函子也可以用来扩展现有模块的功能。这样的用法类似于多重继承和混合(抽象子类)。

让我们以扩展一个现有模块为例,该模块只能向数据结构中添加单个元素,我们为他扩展一个可以把给定的一个数据结构中所有元素全部添加进去的函数。AddAll就是这样的一个函子:

module AddAll = (A: AddSingle) => {
  let addAll = (~from: A.t, into: A.t) => {
    A.fold((x, result) => A.add(x, result), from, into);
  };
};

该函数addAll()用fold()遍历~from的元素并将它们添加到into中,每次一个。result总是保存运算的结果(开始为into,然后是第一个x加into,以此类推)。

在这种情况下,我们让ReasonML推断出AddAll返回值的类型,并没有直接为它指定一个接口。如果我们指定了的话,参数会是类型S,返回值会是抽象类型t。用法会变成这样:

module AddAll = (A: AddSingle)
: (S with type t := A.t) => {
  ···
};

通过源码,我们可以推断出接口addAll需要什么并归纳出接口AddSingle:

module type AddSingle = {
  type elt;
  type t; /* type of data structure */

  let empty: t;
  let fold: ((elt, 'r) => 'r, t, 'r) => 'r;
  let add: (elt, t) => t;
};

4.1 字符串集合的AddAll

我们假定StringSet在之前已经定义好,现在用它来创建StringSetPlus:

module StringSetPlus = {
  include StringSet;
  include AddAll(StringSet);
};

新模块StringSetPlus包含模块StringSet,以及将函子AddAll应用到模块StringSet得到的结果。我们正在模块之间进行多重继承。

这是StringSetPlus的实际用例:

let s1 = StringSetPlus.of_list(["a", "b", "c"]);
let s2 = StringSetPlus.of_list(["b", "c", "d"]);
StringSetPlus.(elements(addAll(~from=s2, s1)));
  /* list(string) = ["a", "b", "c", "d"] */

4.2 我们可以简化AddAll吗?

目前,我们需要将基础模块StringSet与扩展AddAll(StringSet)结合起来,来创建StringSetPlus:

module StringSetPlus = {
  include StringSet; /* base */
  include AddAll(StringSet); /* extension */
};

如果我们可以按如下方式创建它会怎样?

module StringSetPlus = AddAll(StringSet);

我们不这样做的原因有两个。

首先,我们要保留AddAll的参数和StringSetPlus的隔离。当我们对列表使用AddAll时,我们需要这个隔离。

其次,没办法实现AddAll,以便于它扩展参数。理论上,这看起来像这样:

module AddAll = (A: AddSingle) => {
  include A;
  ···
};

但在实践中,include A 仅仅是导入了AddSingle的接口定义。这通常是不够的。

4.3 数据结构:多态数据类型 vs 函子

ReasonML标准库提供了两种数据结构:

  • list 由多态数据类型实现,其元素类型是通过类型变量指定的。
  • Set 则通过函子来实现。元素类型通过模块指定。

4.4 AddAll对于字符串列表

遗憾的是,AddAll最适合于通过函子实现的数据结构。如果我们想将它用于列表,我们必须将list('a)的类型变量绑定到具体类型(在这种情况下string)。这使得需要添加如下参数:

module AddSingleStringList
: AddSingle with type t = list(string) = {
  type elt = string;
  type t = list(elt);
  let empty = [];
  let fold = List.fold_right;
  let add = (x: elt, coll: t) => [x, ...coll];
};

[这是我能想到的最简单的解决方案 - 欢迎提供改进建议。]

然后,下面就是我们如何创建和使用支持所有列表操作的模块以及addAll():

module StringListPlus = {
  include List;
  include AddAll(AddSingleStringList);
};

StringListPlus.addAll(~from=["a", "b"], ["c", "d"]);
  /* list(string) = ["a", "b", "c", "d"] */

5. 材料

w3ctech微信

扫码关注w3ctech微信公众号

共收到0条回复