w3ctech

[ReasonML] - Polymorphic variant types - 多态可变类型


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

翻译:ppp

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


在这篇文章中,龙虎大战坐庄龙虎大战坐庄我 们 看看多态变型,这是一个更灵活的常规变型版本。但这种灵活性也使得它们更加复杂。 [另外:这个文章很难写,并且参考了网络上的各种资源,欢迎提出更正与龙虎大战坐庄你 的建议!]

1. 什么是多态变型?

多态性变型与普通变型类似。最大的区别是构造函数不再与类型绑定; 他们独立存在。这使得多态可型更加通用,并且为类型系统带来了有趣的变化。虽然其中一些后果是负面的 - 让变型的使用变得更难。 让龙虎大战坐庄龙虎大战坐庄我 们 从下面的普通变型开始,然后把他转变成多态变型。

type rgb = Red | Green | Blue;

多态版本的rgb是这样定义的:

type rgb = [`Red | `Green | `Blue];

多态变型的构造函数必须用方括号括起来,并且它们的名称必须以反引号开头,后面跟着小写或大写字母:

# `red;
- : [> `red ] = `red
# `Red;
- : [> `Red ] = `Red

像往常一样,类型rgb必须以小写字母开始。

1.1 多态构造函数独立存在

如果是非多态的变型,则只能使用非多态的构造函数:

# Int(123);
Error: Unbound constructor Int

# type data = Int(int) | Str(string);
type data = Int(int) | Str(string);
# Int(123);
- : data = Int(123)

然而,龙虎大战坐庄你 可以在未定义情况下,使用多态的构造函数。

# `Int(123);
- : [> `Int(int) ] = `Int(123)

龙虎大战坐庄龙虎大战坐庄我 们 注意到`Int(123) 的类型很有趣: [> `Int(int) ]。稍后龙虎大战坐庄龙虎大战坐庄我 们 看看具体是什么意思。 多态构造函数独立存在,使得龙虎大战坐庄龙虎大战坐庄我 们 可以可以多次使用相同的构造函数:

type color = [`Red | `Orange | `Yellow | `Green | `Blue | `Purple];
type rgb = [`Red | `Green | `Blue];

相反,对于常规变型,您应该避免在同一个作用域中同时存在多个使用相同构造函数的变型。 但对于多态构造函数而言,就算参数个数和类型都不同,龙虎大战坐庄你 也可以用同一个多态构造函数。

# `Int("abc", true);
- : [> `Int((string, bool)) ] = `Int(("abc", true))
# `Int(1.0, 2.0, 3.0);
- : [> `Int((float, float, float)) ] = `Int((1., 2., 3.))

1.2 扩展多态变型

龙虎大战坐庄你 可以通过扩展一个以后的变型,来定义一个多态变型。在下例中,color扩展了rgb:

type rgb = [`Red | `Green | `Blue];
type color = [rgb | `Orange | `Yellow | `Purple];

如上所示,字母的大小写至关紧要:小写rgb表示它的构造函数将被插入。 扩展多个变型也是可以的:

type red = [ `Red ];
type green = [ `Green ];
type blue = [ `Blue ];
type rgb = [ red | green | blue ];

1.3 多态构造函数的名称空间是全局的

非多态构造函数的名称是它们作用域的一部分(例如它们的模块),而多态构造函数的名称空间是全局的。如下例所示:

module M = {
  type data = [`Int(int) | `Str(string)];
  let stringOfData = (x: data) =>
    switch x {
    | `Int(i) => string_of_int(i)
    | `Str(s) => s
    };
};
M.stringOfData(`Int(123));

在最后一行中,M.stringOfData()的参数是通过一个不在M这个作用域中的构造函数创建的。由于多态构造函数名称空间的全局性,参数与data类型是兼容的。

1.4 类型的兼容性

ReasonML如何确定实参数(函数调用时)的类型是否与形参数(函数定义是)的类型兼容?例子,龙虎大战坐庄龙虎大战坐庄我 们 首先创建一个类型和一个参数为该类型的一元函数。

# type rgb = [`Red | `Green | `Blue];
type rgb = [ `Blue | `Green | `Red ];
# let id = (x: rgb) => x;
let id: (rgb) => rgb = <fun>;

接下来,龙虎大战坐庄龙虎大战坐庄我 们 创建一个和rgb有相同的rgb2类型,调用id(),并传入rgb2类型的值:

# type rgb2 = [`Red | `Green | `Blue];
type rgb2 = [ `Blue | `Green | `Red ];
# let arg: rgb2 = `Blue;
let arg: rgb2 = `Blue;
# id(arg);
- : rgb = `Blue

通常,形参数的类型x和实际参数arg必须相同。但是对于多态变型,只要类型具有相同的构造函数就可以了。 但是,如果实参数的类型arg2的构造函数少了一部分,那么ReasonML就回报错:

# type rg = [`Red | `Green];
type rg = [ `Green | `Red ];
# let arg2: rg = `Red;
let arg2: rg = `Red;
# id(arg2);
Error: This expression has type rg but an expression was expected of type rgb
The first variant type does not allow tag(s) `Blue

其原因在于,id函数需要精准匹配:准确的构造函数`Red,`Green和`Blue。因此,这种类型rg是不够的。

从具体类型到类型约束

有趣的是,下面的写法没问题:

# type rgb = [`Red | `Green | `Blue];
type rgb = [ `Blue | `Green | `Red ];
# let id = (x: rgb) => x;
let id: (rgb) => rgb = <fun>;

# let arg3 = `Red;
let arg3: [> `Red ] = `Red;
# id(arg3);
- : rgb = `Red

为什么?由于ReasonML没有推断出固定类型arg3,因此推断出类型约束 [> `Red ]。这个约束是一个所谓的下限,意思是:“所有类型至少有构造函数`Red”。这个约束与rgb的类型x兼容。

参数的类型约束

龙虎大战坐庄龙虎大战坐庄我 们 也可以使用约束作为参数的类型。例如,下面的调用id()失败,因为参数的类型rgbw,具有太多的构造函数:

# type rgbw = [`Red | `Green | `Blue | `White ];
type rgbw = [ `Blue | `Green | `Red | `White ];
# id(`Red: rgbw);
Error: This expression has type rgbw but an expression was expected of type rgb
The second variant type does not allow tag(s) `White

这一次,龙虎大战坐庄龙虎大战坐庄我 们 直接调用id()并直接指定参数的类型为rgbw(没有中间let绑定)。龙虎大战坐庄龙虎大战坐庄我 们 只需要修改一下id(),就可以在不改变函数调用的情况下使函数正常工作:

# let id = (x: [> `Red | `Green | `Blue]) => x;
let id: (([> `Blue | `Green | `Red ] as 'a)) => 'a = <fun>;
# id(`Red: rgbw); /* same as before */
- : rgbw = `Red

现在x有一个下限并接受所有至少有给定了三个构造函数的类型。 您也可以通过引用变型来定义约束。例如,下面的定义基本上等同于上一个。

let id = (x: [> rgb]) => x;

稍后龙虎大战坐庄龙虎大战坐庄我 们 将深入研究类型的约束。

2. 编写具有多态变型的可扩展代码

多态变型的一个主要优点是使代码变得更具可扩展性。

2.1 龙虎大战坐庄龙虎大战坐庄我 们 想要扩展的类型和代码

以形状类型为例。它们是之前文章里示例的多态版本。

type point = [ `Point(float, float) ];
type shape = [
  | `Rectangle(point, point)
  | `Circle(point, float)
];

基于这些类型定义,龙虎大战坐庄龙虎大战坐庄我 们 可以编写一个函数来计算形状的面积:

let pi = 4.0 *. atan(1.0);
let computeArea = (s: shape) =>
  switch s {
  | `Rectangle(`Point(x1, y1), `Point(x2, y2)) =>
    let width = abs_float(x2 -. x1);
    let height = abs_float(y2 -. y1);
    width *. height;
  | `Circle(_, radius) => pi *. (radius ** 2.0)
  };

2.2 扩展shape:失败的第一次尝试

假设龙虎大战坐庄龙虎大战坐庄我 们 想要为shape扩展一个形状 - 三角形。龙虎大战坐庄龙虎大战坐庄我 们 怎么做呢?龙虎大战坐庄龙虎大战坐庄我 们 可以简单地定义一个新的类型shapePlus,重用现有的多态构造函数Rectangle和Circle,并添加构造函数Triangle:

type shapePlus = [
  | `Rectangle(point, point)
  | `Circle(point, float)
  | `Triangle(point, point, point)
];

现在龙虎大战坐庄龙虎大战坐庄我 们 还需要扩展computeArea()。下面的函数是龙虎大战坐庄龙虎大战坐庄我 们 对扩展的第一次尝试:

let shoelaceFormula = (`Point(x1, y1), `Point(x2, y2), `Point(x3, y3)) =>
  0.5 *. abs_float(x1*.y2 -. x3*.y2 +. x3*.y1 -. x1*.y3 +. x2*.y3 -. x2*.y1);
let computeAreaPlus = (sp: shapePlus) =>
  switch sp {
  | `Triangle(p1, p2, p3) => shoelaceFormula(p1, p2, p3)
  | `Rectangle(_, _) => computeArea(sp) /* A */
  | `Circle(_, _) => computeArea(sp) /* B */
  };

可惜的是,这个代码报错了:AB两行,sp的类型shapePlus与shape类型的computeArea的参数不兼容。龙虎大战坐庄龙虎大战坐庄我 们 得到下面的错误信息:

Error: This expression has type shapePlus
but an expression was expected of type shape
The second variant type does not allow tag(s) `Triangle

2.3 通过as修复computeAreaPlus

幸好,龙虎大战坐庄龙虎大战坐庄我 们 可以通过as来解决在最后两句代码里出现的问题:

let computeAreaPlus = (sp: shapePlus) =>
  switch sp {
  | `Triangle(p1, p2, p3) => shoelaceFormula(p1, p2, p3)
  | `Rectangle(_, _) as r => computeArea(r)
  | `Circle(_, _) as c => computeArea(c)
  };

as是怎么龙虎大战坐庄帮助 龙虎大战坐庄龙虎大战坐庄我 们 的呢?答案是通过多态变型,它可以选择最普通的类型。即是: r 有类型 [> Rectangle(point, point)] c 有类型 [>Circle(point, float)] 这两种类型都兼容shape的computeArea参数。

2.4 最终的解决方案

龙虎大战坐庄龙虎大战坐庄我 们 还可以基于以下变化,进一步改进:

type myvariant = [`C1(t1) | `C2(t2)];

那么以下两种模式是等价的:

#myvariant
(`C1(_: t1) | `C2(_: t2))

如果龙虎大战坐庄龙虎大战坐庄我 们 使用shape的哈希,龙虎大战坐庄龙虎大战坐庄我 们 得到:

let computeAreaPlus = (sp: shapePlus) =>
  switch sp {
  | `Triangle(p1, p2, p3) => shoelaceFormula(p1, p2, p3)
  | #shape as s => computeArea(s)
  };

让龙虎大战坐庄龙虎大战坐庄我 们 给computeAreaPlus()传入两个形状:

let top = `Point(3.0, 5.0);
let left = `Point(0.0, 0.0);
let right = `Point(3.0, 0.0);

let circ = `Circle(top, 3.0);
let tri = `Triangle(top, left, right);

computeAreaPlus(circ); /* 28.274333882308138 */
computeAreaPlus(tri); /* 7.5 */

因此,龙虎大战坐庄龙虎大战坐庄我 们 已经成功扩展了它的shape类型和computeArea()函数。

3. 最佳实践:普通变型与多态变型

正常情况下,您应该优先选择普通变型而不是多态变型,因为前者效率要稍高一些,并且可以实现更严格的类型检查。引用OCaml手册:

[...] polymorphic variants, while being type-safe, result in a weaker type discipline. That is, core language variants do actually much more than ensuring type-safety, they also check that you use only declared constructors, that all constructors present in a data-structure are compatible, and they enforce typing constraints to their parameters.

然而,多态变型也有一些明显的优势。在下面这些情况下,则应使用多态变型: 重用:构造函数(可能随代码处理)对于多个变型很有用。例如,颜色的构造函数就属于这类问题。 解耦:构造函数用于多个位置,但不希望这些位置依赖于定义构造函数的单个模块。相反,您可以随时使用构造函数而不用定义它。 可扩展性:您期望稍后扩展一个变型。类似于本文中的shapePlus的扩展shape。 简洁性:由于多态构造函数的名称空间是全局的,所以使用它们时,无需加前缀或open它们的模块(请参阅下一小节)。 使用没有事先定义的构造函数:您可以使用多态构造函数,而无需事先通过变型定义它们。这对于用一次就丢的类型很方便。 值得庆幸的是,需要的时候,把一个普通变型转换为多态变型相对比较容易。

3.1 简洁:普通变型与多态性变型

龙虎大战坐庄龙虎大战坐庄我 们 来对比普通变型和多态变型的简洁性。 对于正常的变型bwNormal,您需要在A行中限定(加前缀)(或open它的模块):

module MyModule = {
  type bwNormal = Black | White;

  let getNameNormal(bw: bwNormal) =
    switch bw {
    | Black => "Black"
    | White => "White"
    };
};

print_string(MyModule.getNameNormal(MyModule.Black)); /* A */
  /* "Black" */

对于多态变型bwPoly,`Black可以直接用:

module MyModule = {
  type bwPoly = [ `Black | `White ];

  let getNamePoly(bw: bwPoly) =
    switch bw {
    | `Black => "Black"
    | `White => "White"
    };
};

print_string(MyModule.getNamePoly(`Black)); /* A */
  /* "Black" */

在这个例子中,虽然加不加限定条件(前缀)并没有太大的区别,但是如果要多次使用构造函数的话还是有点区别的。

3.2 防止使用多态变型的拼写错误

多态变型的一个问题是,龙虎大战坐庄你 不会得到丰富的龙虎大战坐庄关于 拼写错误的警告,因为龙虎大战坐庄你 可以在不定义它们的情况下实例化。例如,在下面的代码A行中,`Green拼错成了`Gren:

type rgb = [`Red | `Green | `Blue];
let f = (x) =>
  switch x {
  | `Red => "Red"
  | `Gren => "Green" /* A */
  | `Blue => "Blue"
  };
/* let f: ([< `Blue | `Gren | `Red ]) => string = <fun>; */

如果为参数x声明类型,会发出警告:

let f = (x: rgb) =>
  switch x {
  | `Red => "Red"
  | `Gren => "Green"
  | `Blue => "Blue"
  };
/*
Error: This pattern matches values of type [? `Gren ]
but a pattern was expected which matches values of type rgb
The second variant type does not allow tag(s) `Gren
*/

如果您把多态变量作为函数的返回值,也可以指定该函数的返回类型。但是这也增加了龙虎大战坐庄你 的代码的重量,所以想清楚是不是真的有必要。如果输入所有的参数,问题基本都会被抛出。

4. (进阶)

以下所有部分为进阶主题。

5. 类型变量的类型约束

在龙虎大战坐庄龙虎大战坐庄我 们 仔细研究多态变型的约束之前,龙虎大战坐庄龙虎大战坐庄我 们 首先需要了解类型变量的一般约束(前者是一种特殊情况)。 在类型定义的结尾,可以有一个或多个类型约束。语法如下:

constraint «typeexpr» = «typeexpr»

这些约束用于改进前面类型定义中的类型变量。简单的例子:

type t('a) = 'a constraint 'a=int;

ReasonML如何处理约束?在龙虎大战坐庄龙虎大战坐庄我 们 研究这个之前,龙虎大战坐庄龙虎大战坐庄我 们 先来了解强一致以及它如何构建模式匹配。 模式匹配沿着一个方向进行:一个没有变量的词用于填充另一个词中的变量。在以下示例中,`Int(123)是没有变量的术语,`Int(x)是带变量的术语:

switch(`Int(123)) {
| `Int(x) => print_int(x)
}

强一致是可以在两个方向上工作的模式匹配:两个术语都可以有变量,并且双方的变量都被填充。例如:

# type t('a, 'b) = ('a, 'b) constraint ('a, int) = (bool, 'b);
type t('a, 'b) = ('a, 'b) constraint 'a = bool constraint 'b = int;

ReasonML尽可能做到了简化:将等号两边变量的原始复杂约束转换为只有左侧变量的两个简单约束。 下面这个例子展示了,简化后不需要约束的情形:

# type t('a, 'b) = 'c constraint 'c = ('a, 'b);
type t('a, 'b) = ('a, 'b);

6. 多态变型的类型约束

龙虎大战坐庄龙虎大战坐庄我 们 在前面看到的类型约束实际上只是针对多态变型的类型约束。 例如,以下两个表达式是等价的:

let x: [> `Red ] = `Red;
let x: [> `Red] as 'a = `Red;

在另一方面,下面的两个类型的表达式也等价(但不能把构造函数用于let赋值和参数定义):

[> `Red] as 'a
'a constraint 'a = [> `Red ]

也就是说,到目前为止,对于龙虎大战坐庄龙虎大战坐庄我 们 使用的所有多态变型约束,总是存在一个隐式(隐藏)类型变量。 龙虎大战坐庄龙虎大战坐庄我 们 可以看到,如果龙虎大战坐庄龙虎大战坐庄我 们 尝试使用这样的约束来定义一个类型t:

# type t = [> `Red ];
Error: A type variable is unbound in this type declaration.
In type [> `Red ] as 'a the variable 'a is unbound

龙虎大战坐庄龙虎大战坐庄我 们 用下面的龙虎大战坐庄方法 解决这个问题。 请注意由ReasonML计算​​出的最终类型。

# type t('a) = [> `Red] as 'a;
type t('a) = 'a constraint 'a = [> `Red ];

6.1 多态变型的上下界

对于本文的其余部分,龙虎大战坐庄龙虎大战坐庄我 们 将多态变型的类型约束简称为类型约束或约束。 类型约束包含以下任一个或两个: 下限:指示类型至少包含哪些元素。例如:[> `Red | `Green]接受所有包含构造函数`Red和`Green的类型。换句话说:作为一个集合,约束接受所有类型的超集。 上限:指示类型最多包含的元素。例如:[ `Red | `Green]通过使用它作为函数参数x的类型来看看下界是如何工作的:

let lower = (x: [> `Red | `Green]) => true;

类型的值[`Red | `Green | `Blue]和[`Red | `Green]都是合法的:

# lower(`Red: [`Red | `Green | `Blue]);
- : bool = true
# lower(`Red: [`Red | `Green]);
- : bool = true

但是,类型的值[`Red]不合法,因为该类型不包含两个构造函数的约束。

# lower(`Red: [`Red]);
Error: This expression has type [ `Red ]
but an expression was expected of type [> `Green | `Red ]
The first variant type does not allow tag(s) `Green

7.2 上限

下面的是对上限的演示[< `Red | `Green]:

# let upper = (x: [ true;
let upper: ([ bool = ;

# upper(`Red: [`Red | `Green]); /* OK */
- : bool = true
# upper(`Red: [`Red]); /* OK */
- : bool = true

# upper(`Red: [`Red | `Green | `Blue]);
Error: This expression has type [ `Blue | `Green | `Red ]
but an expression was expected of type [ `Int(int) ] = `Int(3)

`Int(3)经过推断后,类型为[> `Int(int) ]。与至少拥有`Int(int)的有类型都兼容。 使用元组,您可以得到两个单独的推断类型约束:

# (`Red, `Green);
- : ([> `Red ], [> `Green ]) = (`Red, `Green)

另一方面,列表中的元素必须都具有相同的类型,这就是合并两个推断约束的原因:

# [`Red, `Green];
- : list([> `Green | `Red ]) = [`Red, `Green]

只有预期类型是一个元素列表,并且至少包含`Red和`Green这两个构造函数,那么就是合法的。 如果您尝试使用具有不同类型参数的相同构造函数,则会出错误,因为ReasonML无法合并两个推断类型:

# [`Int(3), `Int("abc")];
Error: This expression has type string but
an expression was expected of type int

8.2 上限

到目前为止,龙虎大战坐庄龙虎大战坐庄我 们 只看到推断下界。在以下示例中,ReasonML推断参数x的上限:

let f = (x) =>
  switch x {
  | `Red => 1
  | `Green => 2
  };
/* let f: ([ int = ; */

由于switch表达式的原因,f最多可以处理两个构造函数`Red和`Green。 如果f返回其参数,则推断的类型变得更复杂:

let f = (x) =>
  switch x {
  | `Red => x
  | `Green => x
  };
/* let f: (([ 'a = ; */

类型参数'a用于表示参数的类型和结果的类型是相同的。 如果龙虎大战坐庄龙虎大战坐庄我 们 使用as又不一样了,因为它们将输入类型从输出类型中分离了出来:

let f = (x) =>
  switch x {
  | `Red as r => r
  | `Green as g => g
  };
/* let f: ([ [> `Green | `Red ] = ; */

8.3 ReasonML的类型系统的限制

有些事情超出了ReasonML的类型系统的功能:

let f = (x) =>
  switch x {
  | `Red => x
  | `Green => `Blue
  };
/*
Error: This expression has type [> `Blue ]
but an expression was expected of type [ x;
let f: (([ `Red ] as 'a)) => 'a = <fun>;
# let f = (x: [>`Red]): [> `Red | `Green] => x;
let f: (([> `Green | `Red ] as 'a)) => 'a = ;

统一后的类型保持多态 - 它包含类型变量'a。类型变量用于表示:“无论x最终类型是什么,结果都是相同的类型”。

9.2 一个单型,一个约束

如果两种类型之一是单型(确定的类型,没有类型变量),另一种是约束,那么约束仅用于检查单型。由于强一致,两种类型最终都是单形的。

# let f = (x: [>`Red]): [`Red | `Green] => x;
let f: ([ `Green | `Red ]) => [ `Green | `Red ] = <fun>;
# let f = (x: [`Red]): [ x;
let f: ([ `Red ]) => [ `Red ] = ;

9.3 两个单型

如果两种类型都是单型,那么它们必须具有相同的构造函数。

# let f = (x: [`Red]): [`Red | `Green] => x;
Error: This expression has type [ `Red ]
but an expression was expected of type [ `Green | `Red ]
The first variant type does not allow tag(s) `Green

# let f = (x: [`Red | `Green]): [`Red | `Green] => x;
let f: ([ `Green | `Red ]) => [ `Green | `Red ] = <fun>;

10. 单型与约束

单型和约束之间的区别的另一个证明。 下面两个功能:

type rgb = [`Red | `Green | `Blue];

let id1 = (x: rgb) => x;
  /* let id1: (rgb) => rgb = ; */

let id2 = (x:[>rgb]) => x;
  /* let id2: (([> rgb ] as 'a)) => 'a = ; */

龙虎大战坐庄龙虎大战坐庄我 们 来比较这两个函数: id1()有一个参数,其类型rgb是单型的(它没有类型变量)。 id2()有一个参数,其类型[>rgb]是一个约束。定义本身并非多态,但计算出来的类型确是 - 存在一个类型变量'a。 如果龙虎大战坐庄龙虎大战坐庄我 们 调用这些函数是,传入和rgb拥有相同构造函数的新多态变量类型作为参数,让龙虎大战坐庄龙虎大战坐庄我 们 看看会发生什么:

type c3 = [ `Blue | `Green | `Red ];

对于id1(),x的类型是固定的。即,它还是rgb:

# id1(`Red: c3);
- : rgb = `Red

龙虎大战坐庄龙虎大战坐庄我 们 调用id1(),因为rgb和c3是相同的。 相比之下,对于id2(),x的类型是多态的并且更加灵活。强一致时,'a必然会变成c3。函数调用的结果是类型 c3('a的值)。

# id2(`Red: c3);
- : c3 = `Red

11.参考

多态变型以及如何使用它们:

w3ctech微信

扫码关注w3ctech微信龙虎大战坐庄公众号

共收到0条回复