w3ctech

[ReasonML] - Variant types - 可变类型


原文:http://2ality.com/2017/12/variants-reasonml.html

翻译:ppp

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


可变类型(简称:变型)是许多函数式编程语言支持的数据类型。 它们是ReasonML中的一个重要组成部分,但C语言(C,C ++,Java,C#等)并不支持。 本文将解释了它们是如何工作的。

1. 符号集(枚举)

可变类型允许自定义符号集。这样的用法与C语言中的枚举类似。例如,以下类型color定义了六种颜色的符号。

type color = Red | Orange | Yellow | Green | Blue | Purple;

这个类型定义中有两个要点: 类型的名称color,必须以小写字母开头。 构造函数的名称(Red,Orange...)必须以大写字母开头。当我们把可变类型当做数据结构使用时,为什么称之为构造函数就更容易理解了。 构造函数的名称在当前范围内必须是唯一的。这是为了让ReasonML能够轻松推导出它们的类型:

# Purple;
- : color = Purple

可变类型可以通过switch和模式匹配来进行处理:

let invert = (c: color) =>
  switch c {
  | Red => Green
  | Orange => Blue
  | Yellow => Purple
  | Green => Red
  | Blue => Orange
  | Purple => Yellow
  };

这里,构造函数既用作模式(=>的左边),也用于数值(=>的右边)。这是invert()的实际使用:

# invert(Red);
- : color = Green
# invert(Yellow);
- : color = Purple

1.1 Tips:用可变类型替换布尔变量

在ReasonML中,可变类型通常是比布尔变量更好的选择。举个例子,定义函数:(请记住,在ReasonML中,主要参数最终都会进行柯里化currying)

let stringOfContact(includeDetails: bool, c: contact) => ···;

这是stringOfContact的调用:

let str = stringOfContact(true, myContact);

现在还是不清楚这个布尔值有什么作用。你可以通过参数别名来做一些改进。

let stringOfContact(~includeDetails: bool, c: contact) => ···;
let str = stringOfContact(~includeDetails=true, myContact);

那么更具可读性的写法是为值引入一个可变类型~includeDetails:

type includeDetails = ShowEverything | HideDetails;
let stringOfContact(~levelOfDetail: includeDetails, c: contact) => ···;
let str = stringOfContact(~levelOfDetail=ShowEverything, myContact);

使用可变类型includeDetails有两个好处: 立即知道“不显示细节”的含义。 以后添加模块变得更容易。

1.2 将枚举值与数据关联

有时,你想使用枚举值作为数据的索引。可以通过将枚举值映射到数据的函数来实现:

type color = Red | Orange | Yellow | Green | Blue | Purple;
let stringOfColor(c: color) =>
  switch c {
  | Red => "Red"
  | Orange => "Orange"
  | Yellow => "Yellow"
  | Green => "Green"
  | Blue => "Blue"
  | Purple => "Purple"
  };

这样实现有一个缺点:它会导致代码的冗余,特别是如果你想要将多个数据片段与相同的变量值相关联时。我们将在未来的文章中探索备选方案。

2. 可变类型作为数据结构

每个构造函数都可以接收一个或多个值。这些值由位置标识。也就是说,个别的构造函数与元组相似。以下代码对这点做出了说明。

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

point类型是具有一个构造函数的可变类型。它拥有两个浮点数。shape是另一种可变类型。它可以表示: Rectangle由两个角坐标确定的矩形或, Circle由一个中心和半径确定的圆。 当构造函数有多个参数时,如果参数没有别名,将会遇到一个问题 - 我们必须在别处描述它们的含义。在这种情况下,我们可以使用Records(将在未来的章中进行说明)。 下面是如何使用构造函数:

# let bottomLeft = Point(-1.0, -2.0);
let bottomLeft: point = Point(-1., -2.);
# let topRight = Point(7.0, 6.0);
let topRight: point = Point(7., 6.);
# let circ = Circle(topRight, 5.0);
let circ: shape = Circle(Point(7., 6.), 5.);
# let rect = Rectangle(bottomLeft, topRight);
let rect: shape = Rectangle(Point(-1., -2.), Point(7., 6.));

由于每个构造函数名称都是唯一的,所以ReasonML可以轻松推断出这些类型。 如果要在构造函数中保存数据,则通过switch更方便,因为它还允许你访问该数据:

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)
  };

让我们用一下computeArea继续使用我们之前的rtop会话:

# computeArea(circ);
- : float = 78.5398163397448315
# computeArea(rect);
- : float = 64.

3. 基于可变类型的自递归数据结构

你也可以通过可变类型定义递归数据结构。例如,节点包含整数的二叉树:

type intTree =
  | Empty
  | Node(int, intTree, intTree);

intTree 值是这样构造的:

let myIntTree = Node(1,
  Node(2, Empty, Empty),
  Node(3,
    Node(4, Empty, Empty),
    Empty
  )
);

myIntTree看起来如下:1有两个子节点2和3. 2有两个空子节点。等等。

1
  2
    X
    X
  3
    4
      X
      X
    X

3.1 通过递归处理自递归数据结构

为了演示处理自递归数据结构,让我们实现函数computeSum,它计算存储在节点中的整数的总和。

let rec computeSum = (t: intTree) =>
  switch t {
  | Empty => 0
  | Node(i, leftTree, rightTree) =>
    i + computeSum(leftTree) + computeSum(rightTree)
  };

computeSum(myIntTree); /* 10 */

这种递归是可变类型类型的典型用法: 一组有限的构造函数用于创建数据。本例中:Empty和Node()。 使用相同的构造函数作为模式来处理数据。 只要它是类型的intTree,这就确保了我们能够正确处理传递给我们的任何数据。如果switch对intTree覆盖不完整,ReasonML会出现警告。这可以避免出现遗漏的情况。为了演示,让我们假设我们漏了Empty,omputeSum定义为:

let rec computeSum = (t: intTree) =>
  switch t {
  /* Missing: Empty */
  | Node(i, leftTree, rightTree) =>
    i + computeSum(leftTree) + computeSum(rightTree)
  };

然后我们得到以下警告。

Warning: this pattern-matching is not exhaustive.
Here is an example of a value that is not matched:
Empty

正如在函数那篇文章中所说,如果使用默认分支catch-all(default)意味着你不会得到这样的提示。这就是为什么你应该竟可能的避免这种情况。

4. 基于可变类型的互递归数据结构

回想一下,在涉及到递归的定义是时我们必须使用let rec: 通过let rec完成单一的自递归定义。 多个互递归的定义是通过let rec和and共同完成。 type是隐含的rec。这使我们能够完成自递归的定义,如intTree。对于互递归的定义,我们还需要配合and完成。下面的例子重新定义了intTree,但这次使用了一个单独的节点类型。

type intTree =
  | Empty
  | IntTreeNode(intNode)
and intNode =
  | IntNode(int, intTree, intTree);

intTree和intNode是互递归的,这就是为什么它们需要在相同的type声明中定义,再通过and连接。

5. 可变类型参数化

让我们回顾一下对intTree的原始定义:

type intTree =
  | Empty
  | Node(int, intTree, intTree);

我们如何将这个定义转化为树的一个通用定义,树的节点可以包含任何类型的值? 为此,我们必须为某个Node内容的类型引入一个变量。类型变量在ReasonML中以撇号作为前缀。例如:'a。因此,通用树的定义如下所示:

type tree('a) =
  | Empty
  | Node('a, tree('a), tree('a));

有两件事值得注意。首先,先前为int类型的节点变成'a类型。其次,类型变量'a变成了tree的参数。节点将该参数传递给其子节点。也就是说,我们可以为每棵树选择不同的节点值类型,但在同一棵树中,所有节点必须是相同的类型。 现在我们可以通过传入不同类型的参数tree来定义对应类型树的别名了:

type intTree = tree(int);

我们用tree来创建一个字符串树:

let myStrTree = Node("a",
  Node("b", Empty, Empty),
  Node("c",
    Node("d", Empty, Empty),
    Empty
  )
);

基于类型推测机制,你不需要提供具体的类型。 ReasonML会自动推断myStrTree具有tree(string)类型。下面的通用函数可以打印任何类型的树:

/**
 * @param ~indent How much to indent the current (sub)tree.
 * @param ~stringOfValue Converts node values to strings.
 * @param t The tree to convert to a string.
 */
let rec stringOfTree = (~indent=0, ~stringOfValue: 'a => string, t: tree('a)) => {
  let indentStr = String.make(indent*2, ' ');
  switch t {
  | Empty => indentStr ++ "X" ++ "\n"
  | Node(x, leftTree, rightTree) =>
    indentStr ++ stringOfValue(x) ++ "\n" ++
    stringOfTree(~indent=indent+1, ~stringOfValue, leftTree) ++
    stringOfTree(~indent=indent+1, ~stringOfValue, rightTree)
  };
};

该函数以递归的方式遍历t的所有节点。鉴于stringOfTree可以使用任意类型'a,我们需要一个类型确定函数来将'a类型的值转换为字符串。这就是~stringOfValue的作用。 这是我们如何打印我们先前定义的myStrTree:

# print_string(stringOfTree(~stringOfValue=x=>x, myStrTree));
a
  b
    X
    X
  c
    d
      X
      X
    X

6. 标准可变类型的用处

我将简要介绍两种常用的标准可变类型。未来的文章将给出使用它们的例子。

6.1 option('a) 类型的可选值

在许多面向对象的语言中,具有类型string的变量意味着该变量可以是null,也可以是字符串值。包含null的类型null称为可空类型。可空类型会有个问题就是,如果忘记处理它们的值很容易为null。如果意外的出现null ,将抛出臭名昭着的空指针异常。 在ReasonML中,类型不可空。相反,可能为空的值通过以下参数化可变类型处理:

type option('a) =
  | None
  | Some('a);

option迫使你总是考虑这种空的情况。 ReasonML是最小程度的支持option。这个可变类型的定义是语言的一部分,但是核心标准库还没有用于处理可选值的实用函数。在此之前,你可以使用BuckleScript's Js.Option。

6.2 result('a)类型的错误处理

result 是OCaml中错误处理的另一个标准可变类型:

type result('good, 'bad) =
  | Ok('good)
  | Error('bad);

在ReasonML的核心库支持它之前,你可以使用BuckleScript's Js.Result。

6.3 例子:整数表达式求值

使用树这种数据结构是ML风格语言的优势之一。这就是为什么它们经常用于涉及语法树(解释器,编译器等)的程序中。例如,Facebook的语法检查器Flow是用OCaml编写的。 因此,作为最后一个例子,我们实现一个简单整数表达式的求值器。 以下是整数表达式的数据结构。

type expression =
  | Plus(expression, expression)
  | Minus(expression, expression)
  | Times(expression, expression)
  | DividedBy(expression, expression)
  | Literal(int);

这是用这个可变类型编码的表达式:

/* (3 - (16 / (6 + 2)) */
let expr =
  Minus(
    Literal(3),
    DividedBy(
      Literal(16),
      Plus(
        Literal(6),
        Literal(2)
      )
    )
  );

最后,这是整型表达式求值的函数。

let rec eval(e: expression) =
  switch e {
  | Plus(e1, e2) => eval(e1) + eval(e2)
  | Minus(e1, e2) => eval(e1) - eval(e2)
  | Times(e1, e2) => eval(e1) * eval(e2)
  | DividedBy(e1, e2) => eval(e1) / eval(e2)
  | Literal(i) => i
  };

eval(expr); /* 1 */
w3ctech微信

扫码关注w3ctech微信公众号

共收到0条回复