五、高效 ReasonML

到目前为止,我们已经学习了理性的基础知识。我们已经看到了拥有一个健全的类型系统是如何让重构变得更安全、压力更小的。当更改实现细节时,类型系统会提醒我们需要更新的代码库的其他区域。在本章中,我们将学习如何隐藏实现细节,以使重构更加容易。通过隐藏实现细节,我们保证更改它们不会影响代码库的其他区域。

我们还将学习类型系统如何帮助我们在应用中实施业务规则。隐藏实现细节也为我们提供了一个很好的方法,通过保证模块不会被用户滥用来强制执行业务规则。在本章的大部分内容中,我们将使用包含在本书的 GitHub 存储库中的简单代码示例来说明这一点。

跟随,从Chapter05/app-start开始。这些例子是从我们一直在构建的应用中分离出来的。

您可以通过以下方式访问本书的 GitHub 存储库:

git clone https://github.com/PacktPublishing/ReasonML-Quick-Start-Guide.git
cd ReasonML-Quick-Start-Guide
cd Chapter05/app-start
npm install

请记住,所有模块都是全局的,默认情况下,模块的所有类型和绑定都是公开的。正如我们将很快看到的,模块签名可以用来隐藏模块的类型和/或绑定,不让其他模块知道。在本章中,我们还将了解高级类型系统功能,包括以下内容:

  • 抽象类型
  • 幻影类型
  • 多态变体

模块签名

模块签名约束模块的方式类似于面向对象编程中接口约束类的方式。模块签名可以要求模块实现某些类型和绑定,也可以用来隐藏实现细节。假设我们在Foo.re中定义了一个名为Foo的模块。其签名可在Foo.rei定义。模块签名中列出的任何类型或绑定都会暴露给其他模块。如果模块签名存在,并且该类型或绑定不在模块签名中,则模块中列出的任何类型或绑定都将被隐藏。在Foo.re中给定一个绑定let foo = "foo";,通过在Foo.rei中包含let foo: string;,该绑定既可以是必需的,也可以是由其模块签名公开的:

/* Foo.re */
let foo = "foo";

/* Foo.rei */
let foo: string;

/* Bar.re */
Js.log(Foo.foo);

这里,Foo.rei要求Foo.re有一个名为string类型的let绑定。

如果模块的.rei文件存在并且为空,那么模块内的所有内容都被隐藏,如下面的代码所示:

/* Foo.rei */
/* this is intentionally empty */

/* Bar.re */
Js.log(Foo.foo); /* Compilation error: The value foo can't be found in Foo */

模块的签名要求模块包含签名中列出的任何类型和/或绑定,如以下代码所示:

/* Foo.re */
let foo = "foo";

/* Foo.rei */
let foo: string;
let bar: string;

这将导致以下编译错误,因为模块签名需要未在模块中定义的string类型的bar绑定:

The implementation src/Foo.re does not match the interface src/Foo.rei:
The value `bar' is required but not provided

模块类型

也可以使用module type关键字定义模块签名,而不是使用单独的.rei文件。模块类型必须以大写字母开头。一旦定义,模块可以使用module <Name> : <Type>语法由模块类型约束,如下所示:

module type FooT {
  let foo: (~a: int, ~b: int) => int;
};

module Foo: FooT {
  let foo = (~a, ~b) => a + b;
};

相同的模块类型可用于多个模块,如下所示:

module Bar: FooT {
  let bar = (~a, ~b) => a - b;
};

我们可以将模块签名视为面向对象意义上的接口。接口定义了模块必须定义的属性和方法。然而,在推理中,模块签名也隐藏绑定和类型。但是也许模块签名最有用的特性之一是公开抽象类型的能力。

抽象类型

抽象类型是没有定义的类型声明。让我们探索一下为什么这是有用的。除了绑定,模块签名还可以包括类型。在下面的代码中,您会注意到Foo的模块签名包含一个person类型,现在Foo必须包含这个type声明:

/* Foo.re */
type person = {
  firstName: string,
  lastName: string
};

/* Foo.rei */
type person = {
  firstName: string,
  lastName: string
};

person类型的暴露方式与没有定义模块签名的方式相同。正如您所期望的那样,如果定义了签名并且没有列出类型,那么该类型就不会暴露给其他模块。还有一个选项是让类型保持抽象。我们只保留等号剩下的部分。让我们看看下面的代码:

/* Foo.rei */
type person;

现在,person类型暴露给其他模块,但是没有其他模块可以直接创建或操作person类型的值。person类型需要在Foo中定义,但是可以有任何定义。这意味着person类型可以随着时间的推移而改变,并且Foo之外的任何模块都不会知道区别。

让我们在下一节中进一步探讨抽象类型。

使用模块签名

假设我们正在构建一个发票管理系统,我们有一个Invoice模块,它定义了一个invoice类型以及一个其他模块可以用来创建该类型值的函数。下面的代码显示了这种安排:

/* Invoice.re */
type t = {
  name: string,
  email: string,
  date: Js.Date.t,
  total: float
};

let make = (~name, ~email, ~date, ~total) => {
  name,
  email,
  date,
  total
};

我们还假设我们有另一个模块负责向客户发送电子邮件,如以下代码所示:

/* Email.re */
let send = invoice: Invoice.t => ...
let invoice =
  Invoice.make(
    ~name="Raphael",
    ~email="persianturtle@gmail.com",
    ~date=Js.Date.make(),
    ~total=15.0,
  );
send(invoice);

由于Invoice.t类型是暴露的,发票可以通过Email进行操作,如下代码所示:

/* Email.re */
let invoice =
  Invoice.make(
    ~name="Raphael",
    ~email="persianturtle@gmail.com",
    ~date=Js.Date.make(),
    ~total=15.0,
  );
let invoice = {...invoice, total: invoice.total *. 0.8};
Js.log(invoice);

尽管Invoice.t类型是不可变的,但是没有什么可以阻止Email用一些改变的字段隐藏发票绑定。然而,如果我们将Invoice.t类型抽象化,这是不可能的,因为Email无法操纵抽象类型。Email模块可以访问的任何功能都不适用于Invoice.t类型。

/* Invoice.rei */
type t;
let make:
 (~name: string, ~email: string, ~date: Js.Date.t, ~total: float) => t;

现在,编译给了我们以下错误:

8  let invoice = {...invoice, total: invoice.total *. 0.8};
9  Js.log(invoice);

The record field total can't be found.

如果我们决定允许其他模块向发票添加折扣,我们需要创建一个函数,并将其包含在Invoice的模块签名中。假设我们希望每张发票只允许一次折扣,并且将折扣金额限制在百分之十、十五或二十。我们可以通过以下方式实现:

/* Invoice.re */
type t = {
 name: string,
 email: string,
 date: Js.Date.t,
 total: float,
 isDiscounted: bool,
};

type discount =
 | Ten
 | Fifteen
 | Twenty;

let make = (~name, ~email, ~date, ~total) => {
 name,
 email,
 date,
 total,
 isDiscounted: false,
};

let discount = (~invoice, ~discount) =>
 if (invoice.isDiscounted) {
 invoice;
 } else {
 {
 ...invoice,
 isDiscounted: true,
 total:
 invoice.total
 *. (
 switch (discount) {
 | Ten => 0.9
 | Fifteen => 0.85
 | Twenty => 0.8
 }
 ),
 };
 };

/* Invoice.rei */
type t;

type discount =
 | Ten
 | Fifteen
 | Twenty;

let make:
 (~name: string, ~email: string, ~date: Js.Date.t, ~total: float) => t;

let discount: (~invoice: t, ~discount: discount) => t;

/* Email.re */
let invoice =
 Invoice.make(
 ~name="Raphael",
 ~email="persianturtle@gmail.com",
 ~date=Js.Date.make(),
 ~total=15.0,
 );
Js.log(invoice);

现在,只要Invoice模块的公共 API(或模块签名)不变,我们就可以随意重构Invoice模块,而无需担心破坏其他模块中的代码。为了证明这一点,让我们将Invoice.t重构为元组而不是记录,如下面的代码所示。只要我们不改变模块签名,Email模块就完全不需要改变:

/* Invoice.re */
type t = (string, string, Js.Date.t, float, bool);

type discount =
  | Ten
  | Fifteen
  | Twenty;

let make = (~name, ~email, ~date, ~total) => (
  name,
  email,
  date,
  total,
  false,
);

let discount = (~invoice, ~discount) => {
  let (name, email, date, total, isDiscounted) = invoice;
  if (isDiscounted) {
    invoice;
  } else {
    (
      name,
      email,
      date,
      total
      *. (
        switch (discount) {
        | Ten => 0.9
        | Fifteen => 0.85
        | Twenty => 0.8
        }
      ),
      true,
    );
  };
};

/* Invoice.rei */
type t;

type discount =
  | Ten
  | Fifteen
  | Twenty;

let make:
  (~name: string, ~email: string, ~date: Js.Date.t, ~total: float) => t;

let discount: (~invoice: t, ~discount: discount) => t;

/* Email.re */
let invoice =
  Invoice.make(
    ~name="Raphael",
    ~email="persianturtle@gmail.com",
    ~date=Js.Date.make(),
    ~total=15.0,
  );
let invoice = Invoice.(discount(~invoice, ~discount=Ten));
Js.log(invoice);

另外,由于Invoice.t抽象类型,我们保证一张发票只能打折一次,并且只能按规定的百分比打折。我们可以通过要求记录对发票的所有更改来进一步了解这个例子。传统上,这种需求可以通过在数据库事务后添加一个副作用来解决,因为在 JavaScript 中,我们不能确定我们是否会记录对发票的所有更改。有了模块签名,我们可以选择在应用层解决这类需求。

幻影类型

看看我们以前的实现,如果我们不必在运行时检查发票是否已经打折,那就太好了。有没有一种方法可以让我们在编译时检查发票是否打折?有了幻影类型,我们可以。

幻像类型是具有类型变量的类型,但是在其定义中没有使用该类型变量。为了更好的理解,我们再来看看option类型,如下代码所示:

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

option类型有一个类型变量'a,该类型变量在其定义中使用。正如我们已经知道的,option是一个多态类型,因为它有一个类型变量。另一方面,幻影类型在其定义中不使用类型变量。让我们看看这对我们的发票管理示例有什么用。

让我们将Invoice模块的签名更改为使用幻影类型,如下所示:

/* Invoice.rei */
type t('a);

type discounted;
type undiscounted;

type discount =
  | Ten
  | Fifteen
  | Twenty;

let make:
  (~name: string, ~email: string, ~date: Js.Date.t, ~total: float) =>
  t(undiscounted);

let discount:
  (~invoice: t(undiscounted), ~discount: discount) => t(discounted);

抽象的type t现在是type t('a)。我们还有两个更抽象的类型,如下面的代码所示:

type discounted;
type undiscounted;

另外,注意make功能现在返回t(undiscounted)(而不仅仅是t),而discount功能现在接受t(undiscounted)并返回t(discounted)。请记住,抽象的t('a)接受一个type变量,而type变量恰好是discounted类型或undiscounted类型。

在实现中,我们现在可以摆脱之前的运行时检查,如以下代码所示:

if (isDiscounted) {
  ...
} else {
  ...
}

现在,由于discount函数只接受undiscounted发票,所以在编译时进行检查,如下代码所示:

/* Invoice.re */
type t('a) = {
  name: string,
  email: string,
  date: Js.Date.t,
  total: float,
};

type discount =
  | Ten
  | Fifteen
  | Twenty;

let make = (~name, ~email, ~date, ~total) => {name, email, date, total};

let discount = (~invoice, ~discount) => {
  ...invoice,
  total:
    invoice.total
    *. (
      switch (discount) {
      | Ten => 0.9
      | Fifteen => 0.85
      | Twenty => 0.8
      }
    ),
};

这只是类型系统帮助我们更多地关注逻辑而不是错误处理的又一种方式。以前,尝试对发票进行两次折扣只会将原始发票原封不动地退回。现在让我们尝试使用以下代码在Email.re中对发票进行两次折扣:

/* Email.re */
let invoice =
  Invoice.make(
    ~name="Raphael",
    ~email="persianturtle@gmail.com",
    ~date=Js.Date.make(),
    ~total=15.0,
  );
let invoice = Invoice.(discount(~invoice, ~discount=Ten));
let invoice = Invoice.(discount(~invoice, ~discount=Ten)); /* discounted twice */
Js.log(invoice);

现在,尝试对发票贴现两次将导致一个可爱的编译时错误,如下所示:

We've found a bug for you!

   7  );
   8  let invoice = Invoice.(discount(~invoice, ~discount=Ten));
   9  let invoice = Invoice.(discount(~invoice, ~discount=Ten));
  10  Js.log(invoice);

  This has type:
    Invoice.t(Invoice.discounted)
  But somewhere wanted:
    Invoice.t(Invoice.undiscounted)

这绝对是美丽的。但是,假设您希望能够通过电子邮件发送任何发票,无论是否打折。我们使用幻影类型会导致问题吗?我们如何编写一个接受任何发票类型的函数?我们将,记住我们的发票类型是Invoice.t('a),如果我们想要接受任何发票,我们保留类型参数,如下面的代码所示:

/* Email.re */
let invoice =
  Invoice.make(
    ~name="Raphael",
    ~email="persianturtle@gmail.com",
    ~date=Js.Date.make(),
    ~total=15.0,
  );

let send: Invoice.t('a) => unit = invoice => {
 /* send invoice email */
 Js.log(invoice);
};

send(invoice);

这样我们就可以吃蛋糕了。

多态变体

在上一章中,我们已经简单介绍了多态变体。概括地说,当我们使用[@bs.unwrap]装饰器绑定到一些现有的 JavaScript 时,我们了解到了它们。这个想法是[@bs.unwrap]可以用来绑定到一个现有的 JavaScript 函数,它的参数可以是不同的类型。例如,假设我们想要绑定到以下函数:

function dynamic(a) {
  switch (typeof a) {
    case "string":
      return "String: " + a;
    case "number":
      return "Number: " + a;
  }
}

假设这个函数应该只接受string类型或int类型的参数,而不接受其他参数。我们可以如下绑定到这个示例函数:

[@bs.val] external dynamic : 'a => string = "";

然而,我们的绑定将允许无效的参数类型(例如bool)。如果我们的编译器能够通过防止无效的参数类型来帮助我们,那就更好了。一种方法是将[@bs.unwrap]用于多态变体。我们的绑定将如下所示:

[@bs.val] external dynamic : ([@bs.unwrap] [
  | `Str(string)
  | `Int(int)
]) => string = "";

我们会像这样使用绑定:

dynamic(`Int(42));
dynamic(`Str("foo"));

现在,如果我们试图传递一个无效的参数类型,编译器会让我们知道,如下面的代码所示:

dynamic(42);

/*
We've found a bug for you!

This has type:
  int
But somewhere wanted:
  [ `Int of int | `Str of string ]
*/

这里的折衷是,我们需要通过将参数包装在多态变量构造函数中来传递它们,而不是直接传递。

马上,你会注意到正常变体和多态变体之间的以下两个区别:

  1. 我们不需要显式声明多态变体的类型
  2. 多态变体以倒勾字符(```js)开始

每当您看到一个前缀为 backtick 字符的构造函数时,您就知道它是一个多态变体构造函数。可能有也可能没有与多态变量构造函数相关联的类型声明。

这对正常变体有效吗?

让我们试着用正常变体来做这件事,看看会发生什么:

type validArgs = 
  | Int(int)
  | Str(string);

[@bs.val] external dynamic : validArgs => string = "";

dynamic(Int(1));

前面实现的问题是Int(1)没有编译成一个 JavaScript 号。正常变量被编译成一个array,我们的dynamic函数返回undefined而不是"Number: 42"。该函数返回undefined,因为 switch 语句中没有匹配的案例。

有了多态变体,BuckleScript 将dynamic(Int(42))编译为dynamic(42)`,函数工作正常。

高级类型系统功能

Reason 的类型系统功能非常全面,在过去的几十年里已经得到了完善。到目前为止,我们看到的只是对理性类型系统的介绍。在我看来,您应该先熟悉基础知识,然后再继续学习更高级的 type 系统功能。如果没有经历过声音类型系统本可以避免的错误,很难理解类型安全之类的东西。很难欣赏高级类型的系统特性而不对你在这本书里学到的东西感到沮丧。过多详细地讨论高级类型系统特性超出了本书的范围,但是我想确保那些正在评估 Reason 作为选项的人知道它的类型系统还有很多。

除了幻影类型和多态变体,Reason 还有广义代数数据类型 ( GADTs )。可以使用函子动态创建模块(也就是说,在编译时和运行时之间的某个地方运行的模块函数)。理性也有类和对象 OCaml 中的 O 代表客观。OCaml 的前身是一种叫做 Caml 的语言,最早出现在 20 世纪 80 年代中期。到目前为止,我们在本书中学到的内容在典型的 React 应用中特别有用。就我个人而言,我喜欢《理智》这种语言,它能让我在高效的同时成长。

如果你发现自己对打字系统感到沮丧,那么联系不和谐频道的专家,有人可能会帮助你解决你的问题。我一直对社区的帮助感到惊讶。别忘了,如果你只是想继续前进,如果你需要的话,你可以随时进入原始的 JavaScript,当你准备好的时候再回到问题上来。

You can find the Reason Discord channel here:

https://discord.gg/reasonml

不使用理性类型系统的更高级特性也是完全正确的。到目前为止,我们所学到的为我们的 React 应用增加类型安全性提供了很多价值。

摘要

到目前为止,我们已经看到了理性如何在类型系统的帮助下帮助我们构建更安全、更可维护的代码库。变体允许我们使无效状态不具有代表性。类型系统有助于使重构成为一个不那么可怕、不那么痛苦的过程。模块签名可以帮助我们在应用中实施业务规则。模块签名还作为基本文档,列出模块公开的内容,并根据公开的函数名及其参数类型以及公开的类型,为您提供如何使用模块的基本概念。

第 6 章JS 中的 CSS(in Reason)中,我们将了解如何使用 Reason 的类型系统,使用包装了 Emotion(https://Emotion . sh)的 CSS in Reason 库(称为bs-css)来强制执行有效的 CSS。