类型驱动开发学习指南-全-

类型驱动开发学习指南(全)

原文:zh.annas-archive.org/md5/3f893a61c8c853d83d7f1763db1e22fe

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

类型驱动开发是一种使用静态类型系统来实现期望属性(如安全性和效率)的编程方法。本书将涵盖使用 ReasonML 语言进行类型驱动开发,并解释如何使用其类型系统来检查代码的逻辑一致性。我们使用类型在代码中直接表达关系和其他假设,这些假设在代码运行之前由 ReasonML 编译器强制执行。

本书面向对象

如果你曾经遇到过这个问题:“未定义不是一个函数”,那么这本书可能适合你。如果你在寻找一种编写更少防御性代码、更少的琐碎测试,并且在你尝试重构时不用担心破坏代码的方法,那么你可能对类型驱动开发的概念感兴趣。

如果你是一名程序员(无论是什么类型的程序员),并且对编写安全、高效的代码感兴趣,这本书就是为你准备的。当然,有许多技术和流程可以实现这一点,但类型驱动开发是最主流和易于接触的方法之一。

话虽如此,这本书使用了一种相对不熟悉的语言来展示类型驱动开发。我尽量写得尽可能简单明了;但为了跟上,你仍然需要学习强大类型系统的规则和逻辑。你将达到一个你的思维与编译器协同工作的点,但这需要时间和耐心。回报将是编译器成为你在编写更安全、更正确代码时的朋友。

如果你愿意踏上这段旅程并学习类型系统的规则,那么这本书就是为你准备的。

本书涵盖内容

第一章,开始类型驱动开发,介绍了类型驱动开发及其主要优势。它让你开始使用 ReasonML 语言及其工具,设置了一个我们将在这本书中使用的教程项目,并展示了如何获取进一步的帮助和资源。

第二章,使用类型和值进行编程,建立了一个快速开发的编辑-编译工作流程,并介绍了一些基础概念,例如类型和值。它还涵盖了静态类型编译器的工作原理以及它与动态类型的不同之处。

第三章,打包类型和值,展示了如何编写模块化代码,并利用 Reason 的一等模块支持来实现抽象、信息隐藏和 API 文档。

第四章,在类型中将值分组,涵盖了可能同时包含多种不同类型值的类型,构建这些乘积类型的各种方法,以及如何最好地使用它们。

第五章,在类型中放置替代值,涵盖了可能只包含一个值(在许多不同类型中)的类型。我们介绍了这些求和类型与积类型的不同之处,以及何时以及如何使用它们。

第六章,创建可以插入任何其他类型的类型,涵盖了泛型、类型参数,如何使求和和积类型泛型化,以及与泛型一起工作的限制。

第七章,创建表示操作的类型,涵盖了函数及其在类型驱动开发中的理想属性,如何创建函数以及它们的工作方式,以及如何使用它们实现依赖注入和控制反转等技术。

第八章,使用多种不同类型重用代码,涵盖了参数多态性,这是一种在类型驱动开发中编写可扩展和重用代码的强大技术,而无需旧代码和新代码了解彼此的实现细节。

第九章,通过新行为扩展类型,涵盖了可以用来提高代码重用性和扩展实现部分的技术*。

第十章,将所有内容整合在一起,解释了在尝试以类型驱动的方式解决问题时,如何将许多不同的技术结合起来探索实现。

为了充分利用这本书

您应该至少有使用一种编程语言的经验。它不必是 JavaScript 或 ReasonML,尽管我们在本书中将这些语言作为动态和静态类型语言的示例。

您应该有一台装有 ReasonML 和相关软件的 macOS、Linux 或 Windows 计算机。

您应该在每一章中输入并编译(在某些情况下,运行)显示的示例。这些示例已被设计来展示涵盖的思想,并且故意尝试一些章节中显示的负面示例将非常有教育意义。

下载示例代码文件

您可以从www.packt.com的账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packt.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. www.packt.com登录或注册。

  2. 选择 SUPPORT 标签。

  3. 点击代码下载与勘误表。

  4. 在搜索框中输入书籍名称,并遵循屏幕上的说明。

文件下载完成后,请确保使用最新版本解压缩或提取文件夹:

  • Windows 版本的 WinRAR/7-Zip

  • Mac 版本的 Zipeg/iZip/UnRarX

  • Linux 版本的 7-Zip/PeaZip

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Learn-Type-Driven-Development。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。去看看吧!

使用的约定

本书使用了多种文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg磁盘映像文件作为系统中的另一个磁盘挂载”。

代码块设置如下:

let *sumOfSquares*(*x*: int, *y*: int) = { /* (1) */
 let *xSq* = *x* * *x*;
 let *ySq* = *y* * *y*;
 *xSq* + *ySq*
};

代码如下突出显示:

  • 类型被加粗

  • 值是斜体的

  • 语言保留词(关键字)被下划线标注。

还请注意,编号注释被添加到需要进一步解释的代码中的某些点,例如:

  1. 这条注释解释了带有注释/* (1) */的代码。

任何命令行输入或输出都按以下方式编写:

$ mkdir css
$ cd css

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中如下所示。以下是一个示例:“从管理面板中选择系统信息”。

警告或重要提示看起来像这样。

技巧和窍门看起来像这样。

联系我们

我们读者的反馈始终受到欢迎。

一般反馈:请通过customercare@packtpub.com发送电子邮件,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过customercare@packtpub.com发送电子邮件给我们。

勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

评论

请留下评论。一旦您阅读并使用了这本书,为什么不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 公司可以了解您对我们产品的看法,我们的作者可以查看他们对书籍的反馈。谢谢!

如需了解 Packt 的更多信息,请访问 packt.com.

第一章:开始类型驱动开发

在这本书中,我们正在探索类型驱动开发中可用的技术和惯例。有些人也将类型驱动开发称为类型级编程。静态类型提供了几个好处,包括:

  • 阻止错误代码有机会运行

  • 记录当前的代码库

  • 通过指出你可能遗漏的代码部分来帮助正确重构代码库

  • 提供更丰富的 IDE 支持,例如自动完成

  • 当编译器知道类型并相应地优化代码时,性能更好

类型驱动开发是使用静态类型来限制代码可以做什么的实践。通常,你的编程语言给你足够的权力来表示任何计算。通过类型驱动开发,你本质上是在尝试让你的代码不可能做你不希望做的事情。

在本章中,我们将对一段代码进行一些基本的批判性分析,并查看它可能包含的错误。我们还将介绍 ReasonML,这是我们用来学习类型驱动开发的语言,并将其与 JavaScript 进行比较。我们将从一个基本的 Reason 项目开始,然后介绍 Reason 以及其相关的社区和生态系统。

在本章中,我们将涵盖以下主题:

  • 类型驱动开发的主要思想和好处

  • 动态类型代码与其静态类型 ReasonML 等效物

  • Reason 语言、生态系统和相关项目

  • 如何设置一个基本的 Reason 项目,我们将在此书中使用它

  • 在线尝试 Reason 游戏场

分析代码中的隐藏错误

假设你有以下 JavaScript 代码:

// src/Ch01/Ch01_Demo.js
function *makePerson*(*id*, *name*) { return {*id*, *name*}; }

前面的代码可能会出现很多问题;它们如下所示:

  • 调用者可以将 null 或 undefined 值作为参数传递

  • 调用者可以传递非预期的参数类型

  • 调用者可以随意操作返回的 person 对象,例如,他们可以添加或删除属性

换句话说,这段代码并不能阻止许多潜在的错误。在 JavaScript 中,我们有像 ESLint (eslint.org/) 这样的 linter,它可以检查许多可能出现的错误,但你必须记得去找到它们,启用它们,然后解决它们的限制。linter 可以以各种其他方式提供帮助,例如指出编码风格中的推荐最佳实践。然而,JavaScript 中的 linter 往往被重新用于执行静态类型检查任务;因为它们提供了很大的灵活性并且需要配置(实际上,人们通常上传他们为不同编程风格首选的配置集),因此在不同代码库之间可能存在很大的差异。

添加类型

使用静态类型系统,我们可以以多种方式限制我们的 makePerson 函数。以下是一个使用 ReasonML 的例子,这是我们在这本书中用来学习类型驱动开发的语言:

/* src/Ch01/Ch01_Demo.re */
type person = {*id*: int, *name*: string};
let *makePerson*(*id*, *name*) = {*id*, *name*};

在这里,我们定义一个新的数据类型person和一个根据所需参数创建该类型值的函数。与前面的 JavaScript 代码相比,我们多了一行代码,但作为交换,我们得到了以下保证:

  • 函数的调用者不能传入 null 或 undefined 参数

  • 函数的调用者不能传入错误的参数类型

  • 函数的调用者不能修改函数的结果值

注意在先前的例子中,我们不需要为makePerson函数声明参数或类型。这是因为 ReasonML 具有出色的类型推断,可以自动理解intstringperson必须是函数这些部分允许的唯一可能类型。

ReasonML 将前面的代码编译成以下 JavaScript:

// src/Ch01/Ch01_Demo.bs.js
function *makePerson*(*id*, *name*) { return [*id*, *name*]; }

如您所见,前面的代码几乎与之前我们编写的 JavaScript 完全一样——主要区别在于 Reason 的 JavaScript 编译器将记录(我们将在后面探讨)转换为 JavaScript 数组,以利用它们的速度。

这只是静态类型对代码库能做的事情的一个缩影。在接下来的章节中,我们将探讨更多实际的应用。

ReasonML

我们将探索使用 ReasonML(reasonml.github.io/)进行类型驱动开发。Reason 是一种类似于 JavaScript 的语法,同时也是一套用于 OCaml(ocaml.org/)的工具。OCaml 是一种成熟的静态类型函数式编程语言,它对面向对象和模块化编程提供了出色的支持。

我们将使用 BuckleScript 编译器(bucklescript.github.io/)编写 Reason 代码并将其编译成 JavaScript。BuckleScript 从 Reason 代码中获取输入,并输出基本上是 ES5 的一个简单子集(即没有 ES2015 风格的类,没有箭头函数等)。这将使我们能够编写强静态类型代码,并看到所有类型被剥离后的输出 JavaScript 的样子。

BuckleScript 默认输出扩展名为.bs.js的 JavaScript 文件,以区分您的其他 JS 文件。您可以在示例输出文件中看到这一点,src/Ch01/Ch01_Demo.bs.js

Reason 工具包目前包括:

  • 代码格式化和语法转换工具,refmt

  • 交互式代码评估环境,rtop

  • 用于原生编译项目的构建管理器(我们在这本书中不需要这个),rebuild

  • 为编辑器提供智能感知能力的工具,ocamlmerlin-reason

这些工具协同工作,提供了一种最小化但强大的开发体验。与一个好的编辑器(我们推荐 Visual Studio Code)一起,它们覆盖了您日常开发的大部分需求。

为什么选择 ReasonML?

那么,我们为什么选择 ReasonML 而不是其他语言呢?例如,TypeScript 和 Flow 是目前针对 JavaScript(以及其他许多语言)的流行语言,但我们选择 Reason 是因为:

  • 它有一个强大而优雅的类型系统,它巧妙地结合了许多类型驱动开发概念

  • 它的 JavaScript 编译器(BuckleScript)具有极快的编译、优化和高质量的死代码消除;如果你正在进行类型驱动开发,快速编译是非常有用的,而在任何系统中,性能良好的代码都是非常受欢迎的

  • 它有一个非常有帮助且热情的社区,非常易于接触

  • 它为你提供了访问成熟的 OCaml 社区和其聚合的知识库

我们将利用两种语言之间的差异来理解静态类型 Reason 代码是如何转换为动态类型 JavaScript 代码,同时仍然通过设计安全地运行。

ReasonML 入门

Reason 网站还有一个很好的快速入门指南以及设置编辑器支持的教程。首先,安装 NodeJS 以获取 node 包管理器npm)。然后,运行以下命令:

npm install -g bs-platform
cd <your-projects-folder>
bsb –init learning-tydd-reason –theme basic-reason
cd learning-tydd-reason

现在,我们可以使用以下命令进行初始编译:

bsb -make-world

上述命令递归地构建你的整个项目和其依赖项。它将几乎是瞬间的。

值得注意的是,我们实际上推荐运行前面的 shell 命令(当然,替换为你的实际项目文件夹),因为在这本书中,我们将把代码示例组织成单个项目,learning-tydd-reason,而你输入到各种给定文件名中的代码示例将组合在一起形成该项目。

你几乎肯定想在 Reason 中设置编辑器支持,以便你可以获得诸如自动完成和转到定义等功能。ReasonML 网站上可用的指南(reasonml.github.io/docs/en/global-installation.html)对此非常有帮助。目前,Visual Studio Code (code.visualstudio.com/) 是支持最好的编辑器;你可能会从使用它中获得最佳结果。

如果你正在尝试决定安装方法,我们个人推荐 OPAM 方法(OPAMOCaml 包管理器的缩写)。

使用 Try Reason

Reason 为学习者提供了一个极好的资源:一个在线 Reason 到 JavaScript 编译器和评估器。要访问它,请访问 Reason 网站,并在顶部导航栏中点击 Try。你可以用它来快速尝试不同的想法。

让我们通过一个快速示例来使用 Try Reason 了解我们的方向。将 src/Ch01/Ch01_Demo.re 中的示例代码输入到 Try Reason 网页应用中的 Reason 部分。现在在那之后添加以下行:

let *bob* = *makePerson*(1, "Bob");

现在如果你检查输出的 JS,你应该能看到以下更改:

  • 类型已被移除

  • 记录已被转换为没有字段名称的数组(记录大致类似于 C 结构体或 JavaScript 对象)

  • 每个声明的值都明确导出(公开)

注意,我们在这章中故意引入了非常少的实际 Reason 语法。如果你对探索语法(其核心与 JavaScript 非常相似)感兴趣,最好的方式是探索出色的 Reason 网站文档。由于本书的重点是类型驱动开发,在接下来的章节中,我们将介绍我们将需要的所有语法,并讨论它对我们理解代码的影响。

深入学习

ReasonML 社区是一个有帮助的、快速发展的社区。如果你需要任何帮助,不要害怕提问。你只会初学者一次,一旦你感到舒适,你就能帮助其他初学者。请访问社区页面 reasonml.github.io/docs/en/community.html,并在 discord 聊天中作为首次接触点。

摘要

在本章中,我们介绍了类型驱动开发的初步概念,并批判性地分析了一块动态类型代码,以探索其潜在的错误条件,这些错误条件可以通过添加静态类型来防止。我们还介绍了 ReasonML 语言及其生态系统,设置了我们的 Reason 项目,并瞥见了它如何将静态类型代码编译成 JavaScript。

下一章将非常重要——我们将更深入地探讨类型、值和在 Reason 中的工作方式。那里见!

第二章:使用类型和值进行编程

在上一章中,我们探讨了使用 ReasonML 进行类型驱动开发,但类型究竟是什么?它们如何与程序的其余部分交互?它们如何帮助您在日常工作中,使用强静态类型系统和类型推断是什么样的体验?

在本章中,我们将涵盖以下主题:

  • 设置编辑器工作流程

  • 类型与值

  • 不变值和内存

  • 静态类型与动态类型

  • 类型擦除

  • 语法错误

  • 类型错误与推断

  • 合并

工作流程

为了充分利用本章,我们将设置一个舒适的编辑-编译工作流程。我们建议在您的编辑器中将两个窗口并排放置。VSCode 通过“视图”|“分割编辑器”命令支持此功能。在一侧,加载一个 Reason 源文件;在另一侧,加载 JavaScript 输出文件(一旦最初编译)。然后在终端中,运行以下命令:

bsb -w

前面的命令以 监视模式 开始构建,这会自动重新编译项目中的任何受影响的源代码部分,每当您更改任何源代码时。实际上,监视模式足够智能,当相应的 Reason 源文件被删除时,也会自动删除过时的 JavaScript 输出文件。当您保存 Reason 源文件时,编辑器也会自动重新加载编译后的 JavaScript 文件。

在 VSCode 中,您还可以使用“视图”|“集成终端”命令直接在文件下方打开一个终端会话,并运行 bsb -w 以获得整个工作流程的单个集成视图。这样,当有编译错误时,您不必切换窗口就能看到它们。当然,您可能更喜欢使用两个显示器,并将终端和编译器保持在另一个屏幕上,这样您就不必切换窗口——这也是可行的。

类型与值

让我们通过讨论类型和值来为本书的其余部分设定场景。在核心上,类型是一组值。想想看,类型 bool,这是 Reason 所称为的正常布尔类型。一个 bool 值可以是两种不同的事物之一:truefalse。我们说这些值 占据(存在于)这个类型中。其他任何东西都是错误。

这引发了一个有趣的问题:在这个上下文中,“说任何其他东西都是错误”是什么意思?实际上,我们为什么要关心类型呢?

为了回答这些问题,让我们思考一下如果我们尝试执行操作 "Bob" / 5 会发生什么。将字符串 Bob 除以数字 5 的意义是什么?

如果你无法想出一个好的答案,那么其他人也无法想出。这其实是一个没有意义的问题。就像问,“绿色的味道是怎样的?”(尽管这可能对通感者来说是一个有意义的问题。)

总之,这是我们关心类型的最简单答案——为了避免不得不处理无意义的问题。为了排除无意义操作,我们只需在执行代码的程序中将它们标记为类型错误。换句话说,我们让我们的编译器和解释器将所有值放入不同的类型中,或者如果有任何操作对于给定的值类型无法有意义地执行,则标记为错误。

静态类型

类型错误可能在两个可能的时间发生:编译时和运行时。这是静态类型系统和动态类型系统之间关键的区别:静态类型系统之所以被称为静态,是因为它们会静态分析程序并尝试找到类型错误,而动态类型系统之所以被称为动态,是因为它们在程序运行时抛出类型错误。

动态类型系统将肯定会在你的程序中找到所有的类型错误,前提是它实际上运行了程序中的所有执行路径。任何未执行的路径可能包含隐藏的类型错误。

静态类型系统会尝试在运行程序之前找到尽可能多的错误。通常,这并不能保证你在运行时之前会捕获所有类型错误。一些错误可能通过类型检查器漏网,并在运行时仍然影响你。此外,类型系统可能会使表达一个你知道是正确的程序变得困难,因为它认为它不是。尽管如此,当这种情况发生时,你仍然需要密切关注,因为要么类型检查器是正确的,要么你的设计将受益于以不同、被接受的方式表达程序。

你能得到什么?

关于前面提到的注意事项,静态类型系统实际上能给你带来什么?

  • 一个好的类型系统会在运行时之前为你捕获几乎所有的类型错误

  • 它接受所有或几乎所有不包含类型错误的程序

在运行时之前能够捕获类型错误是一个非常棒的能力。这将帮助你避免可能的中断、费用、失去的业务等等。请注意,我们提到了好的类型系统。我们应该努力争取我们能够得到的最好的类型系统。由于 Reason 是 OCaml,它自动获得了 OCaml 强大、安全和表达性强的类型系统。

静态和动态环境

让我们为具有类型和值的程序发生的事情建立一个心理模型。在其核心,一个程序由一系列类型和值定义组成。例如:

/* src/Ch02/Ch02_Demo.re */
type person = {*id*: int, *name*: string};
type company = {*id*: int, *name*: string, *employees*: list(person)};

let *bob* = {*id*: 1, *name*: "Bob"};
let *acmeCo* = {*id*: 1, *name*: "Acme Co.", *employees*: [*bob*]};

在这里,我们定义了personcompany``类型,然后分配一个个人(bob)和他工作的公司(acmeCo)。

不必过于担心语法(我们将在第四章,在类型中将值分组在一起)中,让我们思考编程环境如何看待这个程序。

在静态类型编程语言中,类型检查器和运行时环境共同构成了静态动态环境。这些是在类型检查期间存储类型定义的区域,以及在程序执行(运行时)期间存储值定义的区域。我们可以将这些视为仅在编译和运行时不同阶段相关的两个不同区域。在编译之后,所有类型信息都被擦除(类型擦除),但在运行时,动态环境在内存中变得活跃(即,栈和堆)。

下面是如何为前面的代码展示静态和动态环境的:

静态环境 动态环境
type person;
type company; (指代 person)
let bob;
let acmeCo; (指代 bob)

静态和动态环境示例(自上而下评估)

在静态和动态环境的每个环境中,每个定义都可以引用它之前的定义。这是一种至关重要的抽象技术——这是我们如何在类型和值级别从较小的程序构建较大程序的方法。

静态环境和动态环境之间没有引用——值在编译时不存在,类型在运行时不存在。这可能会让人感到惊讶,因为我们确实在一个地方混合了它们:源代码。

在其他方面,这种严格的分离平衡了安全和效率的需求。请注意,这与动态类型形成鲜明对比,在动态类型中,类型在运行时也存在,并且必须在每次操作之前进行检查。

价值观

理解 Reason 中的值的工作方式很重要。我们已经看到它们在运行时发挥作用并存在于内存中,但了解默认情况下所有值都是不可变的——实际上,是常量也很重要。有一些例外,我们将在后面介绍,但通常我们将以一种不尝试更改值,而是从旧值中创建新值的方式工作。这种风格在 Reason 中得到很好的支持,并且是函数式编程的基础。

存在一种语法,可以将值绑定到名称,如下所示:

let *PATTERN* = *VALUE*;

前面的语法将右侧的值放入左侧描述的形状中,只要它们的形状匹配。这个概念的一般名称是模式匹配,我们将在本书中看到很多。

到目前为止,我们看到的(=左侧的)模式只是简单的名称,例如:

let *x* = 1;

前面的模式使我们能够将整个值捕获在名称中并在以后重用它。它是这样工作的:Reason 检查值(1)是否可以适合模式(x)。在这种情况下,模式中没有阻止值适合的内容。我们称这为不可反驳的模式。在后续章节中,我们将看到可反驳的模式的示例以及它们的运行方式。

无论何时你看到关键字 let,你应该理解它可能正在分配内存,如果:

  • 绑定值是一个字面量(例如,"Bob"),或者

  • 绑定值是函数或运算符调用的结果,并且函数或运算符调用在内存中分配了一个新值

其他情况主要是绑定到现有值或绑定到不分配内存的函数调用。

在这本书中,我们不会过多地担心分配和内存使用,但我们会探讨一些在必要时如何减少它们的技术,这些技术可以在尝试提高性能时派上用场。

作用域和遮蔽

无论何时我们定义值,它们(在动态环境中)都存在于一个作用域中,其中所有之前定义的名称都是可用的,但只到作用域的结束。作用域嵌套在彼此内部,从顶层作用域(文件级别的定义)开始,并在大括号 {...} 内嵌套作用域。例如:

/* src/Ch02/Ch02_Scope.re */
let *x* = 1;

let *y* = *x* + 1;

let z = {
  let *result* = 0;
  *result* + *x* + *y*
};

在这里,xy 在顶层作用域中,其中 y 可以通过名称访问 x,因为 xy 之前定义;z 可以出于同样的原因访问两者。然而,请注意在大括号引入的嵌套作用域中 result 的定义。名称 result 只从定义的点开始可用,直到闭合括号;在此作用域之外,引用 result 将导致编译错误(具体来说,是一个 名称错误,我们将在本章后面讨论)。

因为 Reason 将所有定义放在特定的作用域中,我们可以在同一个作用域或嵌套作用域中多次定义同一个名称。这被称为遮蔽,因为新的定义隐藏了旧的定义,直到新的定义超出作用域。当然,如果旧的和新的名称同时超出作用域(即,它们在同一个作用域中),旧名称将永久性地被隐藏。下面的代码块是这种情况的一个例子:

/* src/Ch02/Ch02_Shadowing.re */
let *name* = "Bob";
let *age* = "33";

let *greeting* = {
  let *age* = "34";
  "Hello, " ++ *name* ++ " aged " ++ *age*;
};

let *name* = "Jim";
let *greeting2* = "Hello, " ++ *name* ++ " aged " ++ *age*;

现在我们来看看以下输出 JavaScript:

// src/Ch02/Ch02_Shadowing.bs.js
var *age* = "33";
var *greeting* = "Hello, Bob aged 34";
var *name* = "Jim";
var *greeting2* = "Hello, Jim aged 33";

注意 Bob 的年龄在 greeting  中是 34 – 在 greeting 作用域中,age 遮蔽了顶层作用域中的 age。然而,一旦该作用域结束(通过闭合括号),原始的 age 再次变得可见,并在 Jim 的 greeting2 中使用。

然而,第二个 name 绑定("Jim")永久性地遮蔽了第一个,因为它们都在顶层作用域中。实际上,由于第一个 name 和内部的 age 将永远不会再次可见,BuckleScript 编译器甚至懒得输出它们,而是直接内联它们的值。

理解类型擦除

为了具体理解静态/动态分离的效果,让我们看看类型擦除,这是当我们将前面的代码编译成 JavaScript 时发生的事情。以下是在移除所有冗余注释后的输出:

// src/Ch02/Ch02_Demo.bs.js
var *bob* = [1, "Bob"];
var *acmeCo_002* = [*bob*, 0];
var *acmeCo* = [1, "Acme Co.", *acmeCo_002*];

如我们之前提到的,BuckleScript 将 Reason 记录类型编译成具有相应元素数量的 JavaScript 数组。实际上,BuckleScript 为你执行了许多优化。其中一些来自其底层 OCaml 编译器技术,该技术自 1990 年代以来一直在发展,但其他一些在语言到 JavaScript 编译器的世界中相当独特。

注意到 BuckleScript 已经删除了类型定义,并且只输出了实际运行时所需的最小数量的值。这里要理解的重要一点是,所有输出值都遵循其对应类型引入的规则;例如,类型为 personBob 值只能是一个包含两个元素(一个数字和一个字符串,对应于人员记录中的两个字段)的数组,而 acmeCo 值只能是一个包含正确类型的三元素数组。其他任何东西都是不可能的——以数学上的确定性——即使在输出 JavaScript 代码中,因为不通过类型规则的代码(即不通过类型检查)甚至无法编译。

错误

我们之前提到,如果编译器无法理解它遇到的代码片段,它将引发错误。存在几种不同的编译器错误,如下所述

  • 语法错误

  • 类型错误

  • 名称错误

  • 过时的接口错误(我们将在下一章中介绍)

  • 编译器错误(这些很少见,但不应被忽视)

我们将要处理的两种最常见的错误类型是语法错误和类型错误。名称错误相对简单避免:始终以小写字母开始类型名称,并确保你在代码中引用的名称在你引用它们之前已经定义。 (Reason 支持 循环引用 但不支持 向前引用;我们将在稍后讨论循环引用。)

语法错误

语法错误是一种基本错误,发生在编译器字面意义上无法理解源代码时,例如:

type person = {*id*: int; *name*: string};

你能否在前面代码中找到错误?如果你将其与 src/Ch02/Ch02_Demo.re 中的 person 定义进行比较,你应该能够做到。无论如何,编译器会告诉你(通常相当准确)在哪里查找。唯一的问题是,你必须学会筛选编译器输出以找到确切的错误,如下所示:

(Output from bsb -w)
>>>> Start compiling
Rebuilding since [ [ 'change', 'Ch02_Demo.re' ] ]
ninja: Entering directory `lib/bs'
[1/2] Building src/Ch02/Ch02_Demo.mlast
FAILED: src/Ch02/Ch02_Demo.mlast
/usr/local/lib/node_modules/bs-platform/lib/bsc.exe -pp "/usr/local/lib/node_modules/bs-platform/lib/refmt3.exe –print binary"    -w -30-40+6+7+27+32..39+44+45+101 -warn-error +3 -bs-suffix -nostdlib -I '/Users/yawar/src/learning-tydd-reason/node_modules/bs-platform/lib/ocaml' -no-alias-deps -color always -c -o src/Ch02/Ch02_Demo.mlast -bs-syntax-only -bs-binary-ast -impl /Users/yawar/src/learning-tydd-reason/src/Ch02/Ch02_Demo.re
File "/Users/yawar/src/learning-tydd-reason/src/Ch02/Ch02_Demo.re", line 2, characters 23-24:
Error: 438: <UNKNOWN SYNTAX ERROR>
File "/Users/yawar/src/learning-tydd-reason/src/Ch02/Ch02_Demo.re", line 1, characters 0-0:
Error: Error while running external preprocessor
Command line: /usr/local/lib/node_modules/bs-platform/lib/refmt3.exe –print binary '/Users/yawar/src/learning-tydd-reason/src/Ch02/Ch02_Demo.re' > /var/folders/xg/6jbw_1bj5h35b4lt7rygs12w0000gn/T/ocamlppf72c18

ninja: error: rebuilding 'build.ninja': subcommand failed
>>>> Finish compiling(exit: 1)

语法错误以文本 File "/path/to/file", line L, characters C1-C2: 开头(其中 LC1C2 是实际的行和字符编号)。错误消息 <UNKNOWN SYNTAX ERROR> 并不是很有帮助,但行和字符位置相当准确地指出了位置。令人困惑的是,还有另一个以相同方式开始的错误消息,但这次是 line 1 and characters 0-0: Error while running external preprocessor。这是 Reason 以冗余的方式表示它无法理解代码,并且希望很快就会消失!

在我们的例子中,错误指向第 23 和第 24 个字符,在那里你看到一个分号和一个空格;如果你与正确的代码版本进行比较,你会看到它应该是一个逗号和一个空格。

当你刚开始使用 Reason 时,你应该期待看到更多的这些语法错误,并且需要花一些时间来弄清楚它们为什么会发生。随着你对语法的了解,你可以期待仅通过查看代码就能判断出某段代码是否包含正确的语法。正确的语法可以在 Reason 的优秀参考文档中找到。

类型错误和推断

你还会看到另一种主要的编译器错误,那就是类型错误.* 类型错误是在一个类型(或类型的值)以不符合类型定义的方式被使用时产生的错误。

这些错误更有趣,因为你很可能在整个编程生涯中都会遇到它们,在这个过程中,你应该期待继续从类型错误强制更好的设计思维和错误减少中获得大量的生产力和代码质量效益。

类型错误也与 Reason 的类型推断引擎紧密相关,该引擎通过排除法的过程确定代码中每一部分的类型。让我们看看几个简单的类型错误和将触发它们的代码。我们还将解释导致错误的类型推断规则。

首先,让我们尝试我们之前发布的除法问题(在 Reason 的错误信息中,粗体部分被涂成红色):

(Output from bsb -w)
  We've found a bug for you!
  /Users/yawar/src/learning-tydd-reason/src/Ch02/Ch02_Demo.re 8:14-18

  6 │ /* ... elided ... */
  7 │
  8 │ let result = "Bob" / 5;

  This has type:
    string
  But somewhere wanted:
    int

让我们看看 Reason 如何通过排除法得出类型错误的处理过程:

  • 逐个将类型分配给表达式的最小部分

  • 尝试将所有类型像拼图一样拼在一起

    • 如果它们匹配,则通过类型检查器

    • 如果它们不匹配,则引发类型错误

下面的图示显示了类型推断和检查过程(从左到右阅读):

类型错误源于"Bob"是一个字符串(双引号内的任何内容都被推断为字符串),而除法运算符((/))根据定义需要两个int类型的变量作为输入。然而,Reason 仍然可以推断resultint类型,因为它知道除法运算符的输出是int

现在,让我们尝试一个稍微有趣一点的类型错误,即未正确创建记录的情况,如下所示:

(Output from bsb -w)
 We've found a bug for you!
 /Users/yawar/src/learning-tydd-reason/src/Ch02/Ch02_Demo.re 6:51-53

 4 │
 5 │ let bob = {id: 1, name: "Bob"};
 6 │ let acmeCo = {id: 1, name: "Acme Co.", employees: bob};

  This has type:
 person
 But somewhere wanted:
 list(person)

下面的图示显示了记录的类型检查过程:

在这里,类型错误产生是因为记录的一个组成部分没有正确的类型。你可以将错误信息中的代码与源代码进行比较以精确

你可能好奇为什么除法类型错误是以这种方式报告的,当从左到右工作并产生一个像“字符串不支持整数的除法”这样的错误可能更自然时。这是因为类型检查器在程序的抽象语法树上工作——也就是说,在解析(并验证为无语法错误)之后程序本身的内部表示。正如你可能猜到的,AST 是结构化的,作为一个树,在树中,操作和函数调用是它们参数的父节点。因此,操作首先被分配类型,然后是它们的参数。所以,你看到"Bob"是导致类型不匹配的东西,而不是(/)

从理论上讲,类型检查可以朝两个方向进行——从 AST 的根节点到其叶节点,或者反过来,就像正常情况一样。你可能经常听到将类型组合在一起的过程被称为统一,这指的是同一个概念。如果第一个操作数不是"Bob",而是例如10(类型为int),Reason 就能够统一它们的类型(intint),从而通过类型检查。

摘要

在本章中,我们涵盖了大量的内容,包括设置编辑工作流程,了解类型和值,静态和动态类型,Reason 在编译时间和运行时间之间的分离以及其类型擦除,语法,类型错误,以及推理和统一。

在未来的章节中,我们将在此基础上构建,并介绍更多静态类型技术以及使用它们可能看到的潜在类型错误。

第三章:将类型和值打包在一起

ReasonML 对将程序划分为小型、模块化组件的软件工程实践提供了出色的支持,这些组件可以相互替换。

在本章中,我们将涵盖以下内容:

  • 模块及其如何用于打包类型和值

  • 文件模块和语法模块之间的区别

  • 模块签名(文件和语法)

  • 使用签名实现信息隐藏

  • 使用签名实现类型抽象

  • 实现零成本抽象

模块 是一组类型和值,可以在单个名称下访问。当你想要将一些类型和操作关联起来以使它们更容易查找和使用时,这可以非常有用。它们在其他语言中类似于 命名空间,但更强大,因为它们可以以各种方式组合。

让我们看看如何创建一些模块。

文件模块

事实上,我们已经有了一些模块!Reason 将 .re 源文件视为模块,因此我们的 src/Ch01/Ch01_Demo.resrc/Ch02/Ch02_Demo.re 文件分别自动作为模块可用,名称分别为 Ch01_DemoCh02_Demo。在 Reason 世界中,这些被称为 实现文件。我们将非正式地称它们为 文件模块

Reason 仅根据文件名命名文件模块,忽略它们的目录嵌套。这使得每个模块都可以自动从其他模块中访问,无论它们在项目中的物理位置如何。这就是为什么我们小心翼翼地为模块命名时加上章节前缀;否则,来自不同章节但具有相同名称的文件会混淆编译器。

让我们利用 Reason 的自动模块解析功能,通过创建一个新的(文件)模块来引用现有模块中的内容:

/* src/Ch03/Ch03_Greet.re */
let *greet*(*person*: *Ch02_Demo*.person) = /* (1), (2), (3) */
  "Hello, " ++ /* (4), (5) */
  *person.name* ++
  " with ID " ++
  *string_of_int*(*person.id*) ++ /* (6) */
  "!";

在这里,我们定义了一个知道如何用姓名和 ID 欢迎人们的函数。在这个例子中发生了一些事情(用编号注释标记):

  1. 我们通过在函数参数后附加冒号并跟类型来为 person 函数参数分配一个类型。你可以读作 "person has type c h 0 2 demo dot person"。我们可以在 Reason 中为任何函数参数分配类型;不过,由于类型推断,它们几乎总是可选的。在这种情况下,我们想要明确,因为存在一个微妙的问题:Ch02_Demo 模块中实际上有两种不同的记录类型(personcompany),它们都有 nameid 字段,我们需要区分它们。

  2. 函数定义有一个由单个表达式组成的主体;这也可以是一个由括号分隔的复合表达式(我们将在后面看到示例)。

  3. 我们可以有一个 person 值和一个 person 类型——它们不会冲突,因为 Reason 将它们分别存储在静态和动态环境中。

  4. Reason 对空白字符不敏感;你可以以任何你想要的方式布局你的代码,只要用分号分隔绑定即可。对于大多数代码库,你实际上会使用 Reason 格式化工具 refmt,它会自动处理所有格式化。

  5. Reason 中的 ++ 操作符将两个字符串(以及没有其他内容!)连接在一起。

  6. person.id 是一个 int,所以我们不能将其与其周围的字符串连接在一起——除非我们使用内置的 string_of_int 函数将其转换为字符串。Reason 具有严格的强类型,不会在类型之间隐式转换(甚至不在 intfloat 变量之间)。

为了了解 Reason 为我们做了什么,让我们看看输出 JavaScript 的相关部分:

// src/Ch03/Ch03_Greet.bs.js
function *greet*(*person*) {
  return "Hello, "
    + *person*[1]
    + " with ID "
    + *String*(*person*[0])
    + "!";
}

我对 JavaScript 输出进行了一些清理和重新排列,但没有改变其含义。

如同往常,我们看到类型被完全删除,输出只关注值。然而,基于类型,Reason 编译器知道在数组索引 1 处访问人的姓名,在索引 0 处访问 ID。它也知道通过使用 JavaScript 字符串构造函数将 person[0] 转换为字符串。

在 JavaScript 世界中,我们会说这样的转换是不必要的。但在静态类型 Reason 世界中,编译器会跟踪所有值的类型,并确保它们只根据其类型的规则进行交互。因此,我们确保一个数字不会意外地添加到一系列字符串中。

在更大规模上,请注意,Reason 文件是模块的事实在 JavaScript 输出代码中并不直接可见——除了 Reason 文件是直接编译的,与 JavaScript 模块有一对一的关系。

语法模块

让我们看看在 Reason 中创建模块的另一种方式:语法模块。这些是使用 Reason 的模块语法定义的模块。以下是一个示例:

/* src/Ch03/Ch03_Domain.re */
module *Person* = {
  type t = {*id*: int, *name*: string};
  let *make*(*id*, *name*) = {*id*, *name*};
};

module *Company* = {
  type t = {*id*: int, *name*: string, *employees*: list(*Person*.t)};
};

在这里,我们定义一个 Domain 文件模块来包含两个嵌套模块:PersonCompany。这些嵌套模块实际上包含与我们之前在 src/Ch02/Ch02_Demo.re 中定义的类似类型,但这次两种类型都命名为 t

让我们稍微偏离一下主题,谈谈类型名称 t。这是 Reason 生态系统中的标准命名约定,表示模块中的主类型。通常,你会指代一个模块及其主类型,例如,Person.tCompany.t,这样就可以清楚地知道你指的是哪种类型。

语法模块具有以下形式:module Name = {...bindings...}; 并且所有绑定都对外部消费者以模块名称的方式可用,例如,Name.binding1,等等。

之前,我们说模块将类型和值打包在一起。但在前面的例子中,你可以看到 Ch03_Domain 文件模块本身包含两个模块,PersonCompany。我之前过于简化了。模块可以递归地包含其他模块!这是一种很好的代码组织和命名空间策略。

让我们看看(相关部分的)JavaScript 输出,以了解这个域模块的运行时效果:

// src/Ch03/Ch03_Domain.bs.js
function *make*(*id*, *name*) { return [*id*, *name*]; }

var *Person* = [*make*];
var *Company* = [];
*exports.Person*  = *Person*;
*exports.Company* = *Company*;

PersonCompany模块被表示为 JavaScript 数组,它们的t类型被完全删除,使得数组几乎为空。数组中只包含文件级别模块 JavaScript 输出会包含的内容:值。实际上,这正是 Reason 在编译成字节码或原生二进制形式时表示模块的方式。

然而,这并不是你可能会期望的嵌套模块在惯用 JavaScript 中的样子。实际上,BuckleScript 编译器并不总是生成完全惯用的 JavaScript 输出。其中一些情况可以修复(实际上,一些已经修复了);其他则是编译器为了有效地将 Reason 代码转换为 JavaScript 而需要做出的妥协。

使用语法模块

如您所见,模块非常便宜。它们几乎没有任何运行时效果。让我们看看将我们的类型组织到它们自己的嵌套模块中的回报。我们将再次尝试问候一个人:

/* src/Ch03/Ch03_GreetAgain.re */
let *greetAgain*(*person*) = /* (1) */
  "Hello again, " ++
  *person.Ch03_Domain.Person.name* ++ /* (2) */
  " with ID " ++
  *string_of_int*(*person.id*) ++
  "!";

src/Ch03/Ch03_Greet.re有什么不同?

  1. 我们不需要显式注释person的类型

  2. 我们需要告诉 Reason name字段来自哪个模块,因为为了防止具有相同字段名称的记录类型之间发生名称冲突,Reason 不会自动打开模块以查找类型细节

你可能会问,这真的有回报吗?我们只是将一种注释(显式类型签名)换成了另一种(字段名模块前缀)。虽然这是真的,但在实现代码中,尽可能少地使用类型注释并让编译器尽可能多地推断是惯例。

类型注释确实还有另一个用途,那就是记录类型。在 Reason 中,我们有一个明确的地方可以放置类型注释,以记录我们的模块类型并服务于其他一些有用的目的。

模块签名

模块签名,也称为接口,是一个放置类型注释的明确位置。但它们实际上有几个用途:

  • 导出模块的公共 API

  • 记录模块公共 API 的类型

  • 提供放置模块文档的位置

  • 隐藏模块的非公共元素

  • 隐藏类型的实现细节

考虑到前面提到的要点,你会在什么情况下不希望为你的模块使用签名?这并不是一成不变的,但我的经验法则是,当我的 API 是实验性的并且仍在演变(在语义版本控制术语中,小于版本 1.0.0)时,或者当模块纯粹是应用程序模块,并不打算作为库发布给其他人使用时(尽管这两者之间的界限有些模糊)不要使用签名。

签名有两种形式——接口文件语法签名对应于实现。接口文件是包含签名而没有其他内容的 Reason 源文件。语法签名是使用 Reason 对签名的语法支持定义的签名。

我们将通过使用接口文件主要探索先前提到的点,但也会根据需要展示语法签名的示例。

导出和记录公共 API

让我们看看导出和记录模块公共 API 的一个例子。这是一个接口文件:

/** src/Ch03/Ch03_GreetAgain.rei (1), (2)
    Contains a way to greet a person. */

/** Greet someone with a name and ID, again. (3) */
let *greetAgain*: *Ch03_Domain.Person*.t => string; /* (4) */

在这里有几个有趣的事情正在进行:

  1. 接口文件必须具有 .rei(Reason 接口)文件扩展名,文件名与实现文件名相对应。

  2. 我们使用一种新的注释类型,称为文档注释doc comment),来编写将随 API 一起公开导出的文档。文档注释以 /** 开始,以 */ 结束。Reason 生态系统中有工具可以理解文档注释并为读者格式化它们。请注意,我们通常不在实现文件(.re)中使用文档注释,因为这通常没有意义将特定于实现的文档暴露给用户。

  3. 模块中的任何项目都可以使用文档注释进行记录。

  4. 我们使用值声明来告诉 Reason 从模块中导出一个具有给定类型的值。在这种情况下,值的类型是函数类型——这个类型读作 "ch 0 3 domain person t arrow string",意味着 以第三章域 person 类型作为输入并返回一个字符串作为输出。我们将在未来的章节中更详细地介绍函数。

那么,这个接口文件编译成什么?实际上:什么也没有。接口文件完全是编译时构造;它们在运行时根本不存在。事实上,它们就像类型一样被擦除,因为它们是类型。模块接口是类型。你作为接口编写的 .rei 文件实际上指定了其对应的 .re 实现文件的类型。

语法模块签名

模块签名也被称为模块类型。*就像其他类型一样,模块类型指定了你可以对它们的类型值做什么,不能做什么;换句话说,它们指定了模块的表面区域。

这里是一个语法模块类型及其用法的例子:

/* src/Ch03/Ch03_ModuleType.re */
module type PersonType = {
  type t = {*id*: int, *name*: string};
  let *make*: (int, string) => t; /* (1) */
};

module *Person*: PersonType = { /* (2) */
  type t = {*id*: int, *name*: string};

  let *massage*(*name*) = *String.trim*(*String.capitalize*(*name*)); /* (3) */
  let *make*(*id*, *name*) = {*id*, *name*: *massage*(*name*)}; /* (4) */
};

语法模块类型具有 module type Type = {...declarations...}; 形式,并精确指定符合该类型的模块将导出什么。在先前的例子中,需要注意的一些关键点:

  1. 我们使用 let funName: (param1Type, ..., paramNType) => returnType; 语法声明一个函数的类型(make)。

  2. 我们通过在模块类型后附加一个冒号来声明模块符合模块类型。

  3. 我们定义一个 massage 函数来正确地大小写和修剪输入名称,但这个函数在模块类型中没有声明,所以它从未被导出(也就是说,使用此模块的用户将无法访问 massage)。

  4. 我们通过使用 name: expression 语法给记录字段一个特定的表达式作为其值。我们将在后面的章节中更全面地介绍记录类型语法。

让我们来看看(相关部分的)JavaScript 输出:

// src/Ch03/Ch03_ModuleType.bs.js
var *$$String* = *require*("bs-platform/lib/js/string.js"); // (1)
function *make*(*id*, *name*) {
  return [*id*, *$$String*.*trim*(*$$String*.*capitalize*(*name*))]; // (2)
}
var *Person* = [*make*];
*exports*.*Person* = *Person*;
  1. Reason 的 String 模块名称在输出中略有损坏(带有 $$ 前缀)以避免与现有的 JavaScript String 构造函数发生名称冲突。

  2. 返回的人员数组值没有调用 massage 函数。实际上,BuckleScript 甚至没有发出 massage 函数,因为它已经确定可以内联。

模块错误

模块具有类型的事实自然导致它们也可以抛出类型错误。让我们看看与模块相关的几个可能的类型错误。

签名不匹配

当我们尝试将模块签名分配给没有正确实现该接口的模块时会发生什么?查看以下内容:

(Output from bsb -w)
  We've found a bug for you!
  /Users/yawar/src/learning-tydd-reason/src/Ch03/Ch03_ModuleType.re 8:29-11:1

   6 │ };
   7 │ 
   8 │ module Person: PersonType = {
   9 │   type t = {id: int, name: string};
  10 │   let massage(name) = String.trim(String.capitalize(name));
  11 │ };

  Signature mismatch:
  Modules do not match:
    { type t = { id: int, name: string, }; let massage: (string) => string; }
  is not included in
    PersonType
  The value `make' is required but not provided
  File "/Users/yawar/src/learning-tydd-reason/src/Ch03/Ch03_ModuleType.re", line 5, characters 3-31:
    Expected declaration

这条信息意味着我们忘记在我们的实现中包含 make 函数。信息有点奇怪,但如果你知道 Reason 如何检查模块类型,它就有意义了:

  • 它通过检查实际模块的结构来推断它认为应该是的模块类型

  • 它将推断的类型与注解的模块类型 PersonType 进行比较

  • 它不关心实际模块中出现的但未在 PersonType 中声明的项(它只是将这些项隐藏在外部世界之外)

  • 它确实显示了在 PersonType 中声明但实际模块中缺失的项的错误

简而言之,你不能过度承诺而不足够交付。了解这一点后,你可以解释错误信息:推断的模块类型在最上面,注解的模块类型在下面,如下面的截图所示:

模块类型不匹配错误图

在 Reason 中,模块是结构化类型化的:它们的类型由它们的结构组成,也就是说,通过以与模块本身类似的语法形式组合其包含的绑定类型。这就是我们能够编写 module type Foo = {...declarations...}; 的原因 – {...declarations...} 结构类型本身就是一个一等类型;我们只是将其绑定到一个名称上。这一具体结果就是你可以直接定义一个模块并用类型注解它,例如,module Foo: {...declarations...} = {...bindings...};。在后面的章节中,我们将进一步探讨结构化类型。

一个重要的事情是要理解,你不能通过以与它们的签名不同的顺序实现类型和值来得到签名不匹配。你仍然在使用它们之前声明或定义它们,但编译器会理解一个模块符合签名,即使它们的声明和定义在顺序上不完全匹配。为了看到这个的小例子,请查看即将到来的类型抽象部分中的代码示例。getter 函数的顺序在接口和实现之间略有不同。

这种顺序灵活性在尝试以易于理解的方式安排模块签名时可能是一个好处,也就是说,首先展示最重要的项目。有时,你可能不需要这种级别的灵活性,但如果你发现你需要,你总是可以利用它。

找不到值

当我们尝试在一个模块中使用不存在的东西时会发生什么?查看以下内容:

(Output from bsb -w)
  We've found a bug for you!
  /Users/yawar/src/learning-tydd-reason/src/Ch03/Ch03_ModuleType.re 10:23-40

   8 │   type t = {id: int, name: string};
   9 │ 
  10 │   let massage(name) = Ch03_Greet.process(name);
  11 │   let make(id, name) = {id, name: massage(name)};
  12 │ };

  The value process can't be found in Ch03_Greet

Reason 在编译时检查模块的类型(Ch03_Greet)是否导出了命名的值(process),如果没有,则构建失败。无法使用不存在的值。

类型抽象

在 Reason 中,你可以隐藏类型的实现并精确地展示你选择的内容。这种类型抽象是保持代码库中不变量(应该遵守的规则)的最佳技术之一。类型抽象还允许模块彼此解耦,只处理它们从导出接口中获取的信息。

例如,看看 Ch03_ModuleType.Person 模块。它导出 t 类型来表示有关人员的信息,以及一个 make 函数来正确创建 t 类型的值。make 函数确保我们正确地修剪并大写我们给出的名称。我们希望强制执行规则,即名称应该有正确的格式化,并且不应该有周围的空白字符。

问题是我们可以这样做:

let *bob* = {*Ch03_ModuleType.Person.id*: 1, *name*: " bob    "};

因为 Ch03.ModuleType.Person.t 的定义是导出的,我们可以绕过 make 函数并直接创建 t 值,打破我们想要应用于名称的规则。

我们可以通过将 t 定义为一个抽象类型来解决此问题(注意,我们必须定义接口和实现文件,如下所示):

/** src/Ch03/Ch03_AbstractPerson.rei */

type id = int;
type name = string;
type t; /* (1) */

let *make*: (id, name) => t;
let *id*: t => id;
let *name*: t => name;

/* src/Ch03/Ch03_AbstractPerson.re */
type id = int; /* (2) */
type name = string;
type t = {*id*, *name*}; /* (3) */

let *id*(*t*) = *t.id*;
let *name*(*t*) = *t.name*;
let *massage*(*name*) = *String.trim*(*String.capitalize*(*name*));
let *make*(*id*, *name*) = {*id*, *name*: *massage*(*name*)};
  1. 现在,Ch03_AbstractPerson.t 在内部是一个记录类型,就像其他类型一样,但它被导出为一个纯抽象类型,没有实现细节除了我们在接口中提供的操作。这些操作使我们能够正确创建 t 值并从 t 值中提取人员的 ID 和姓名。

  2. 我们在这里引入了两种新的类型:idname,使用type typeName = otherType;语法。将新的类型名称直接绑定到现有的类型名称称为类型别名(有时也称为类型缩写)。类型别名对类型检查没有影响,但它们是记录我们的意图和有时缩短较长的类型名称的有用方式。

  3. 由于我们别名为idname,我们可以使用 Reason 提供的快捷方式,当记录类型定义中的字段和类型名称相同时,称为打趣

由于我们希望导出类型别名以及原始类型和操作,我们需要在接口和实现中都重复相同的别名绑定。这种重复允许 Reason 双重检查其推断是否符合我们的意图。

零分配类型抽象

有时我们需要对已存在的类型强制执行规则。例如,在Ch03_AbstractPerson中,我们有一个name类型,它只是一个string,以及一个massage函数,该函数接受一个字符串并应用一些规则将其转换为“良好行为”的名称。我们以相当随意的的方式将这个类型和函数放在另一个模块中,因为我们当时专注于“人”的概念及其操作,而不是那么关注“名称”及其操作。

然而,我们并不想为名称创建一个新的类型,该类型将在已分配的名称字符串之上分配值。我们希望在保持命名规则(大小写和修剪)的同时,还要节省内存使用。

让我们将name类型别名及其智能构造函数(一个应用我们想要强制执行的规则的make函数)提取到一个专用模块中(再次注意,我们必须定义模块的接口和实现文件,以便模块完整):

/* src/Ch03/Ch03_Person.rei */

module *Name*: { /* (1) */
  type t;
  let *make*: string => t;
  let *toString*: t => string;
};

type id = int; /* (2) */
type t = {*id*, *name*: *Name*.t}; /* (3) */

let *make*: (id, *Name*.t) => t; /* (4) */
let *id*: t => id;
let *name*: t => *Name*.t;

/* src/Ch03/Ch03_Person.re */
module *Name* = {
  type t = string; /* (5) */
  let *make*(*string*) = *String*.(*capitalize*(*trim*(*string*))); /* (6) */
  let *toString*(*t*) = *t*; /* (7) */
};

type id = int;
type t = {*id*, *name*: *Name*.t};

let *make*(*id*, *name*) = {*id*, *name*};
let *id*(*t*) = *t.id*;
let *name*(*t*) = *t.name*;

这里的新特性:

  1. 我们声明一个嵌套的Name模块,并指定其模块类型。我们告诉 Reason:Ch03_Person 包含一个名为 Name 的模块,该模块导出这些项目。请注意,Name模块的t类型是抽象的。

  2. 我们没有将id类型声明为抽象的,因为我们不需要强制执行任何关于其创建的规则(我们可能在将来会这么做)。

  3. 由于我们已经将名称类型和创建逻辑移动到Name中,我们不再需要将Ch03_Person.t类型声明为抽象的。因为任何人都无法创建错误的Ch03_Person.t值,因为他们必须仍然通过Name.make来获取名称。

  4. 现在我们让Ch03_Person模块函数使用Name.t类型。

  5. Name.t类型被实现为一个string。它不会在运行时分配任何内容。

  6. make 智能构造函数将自动强制执行我们的命名规则。此外,我们在这里使用 ModuleName.(expression) 语法临时“打开” String 模块,使其包含的所有值在当前表达式的作用域内可见。在小的作用域内临时打开模块可以非常方便地节省一些打字——但打开较大的作用域可能会因为潜在的名字冲突而变得有风险。

  7. 因为 Name.t 已经是 string 类型,所以在 toString 中将其转换回 string 只需返回输入值。

这种结构达到了良好的平衡:

  • 它暴露了 person 记录类型定义,以便用户可以轻松地检查和使用它们的值

  • 它控制了 person 数据的关键部分:姓名

防止类型混淆

之前,我提到我们不需要使 id 类型抽象,因为我们目前没有为其制定任何规则。但有一个很好的理由使简单类型抽象:防止表示不同逻辑概念的相同物理类型(从实现的角度来看)之间的混淆。

例如,假设你有一个以下函数和用法:

let *payBill*(*personId*, *businessId*) = ...;
*payBill*(*acmeCo.id*, *bob.id*);

Ch02_Demo 中,我们为 personcompany 的 ID 类型都指定了 int。如果我们不小心按错误的顺序传递函数参数,并且我们的系统试图从公司向个人支付账单,这可能会产生反效果。

我们可以使用与前面类似的技术来防止这种混淆:使个人 ID 和公司 ID 类型在逻辑上区分开来,同时在内部物理上仅用 int 表示。以下是一个示例:

/* src/Ch03/Ch03_Id.re */
module type Id = { /* (1) */
  type t;
  let *make*: int => t;
  let *toInt*: t => int;
};

module *IntId* = { /* (2) */
  type t = int;
  let *make*(*int*) = *int*;
  let *toInt*(*t*) = *t*;
};

module *PersonId*: Id = *IntId*; /* (3) */
module *CompanyId*: Id = *IntId*;

let *bobId* = *PersonId.make*(1); /* (4) */
let *acmeCoId* = *CompanyId.make*(1);
/*
let result = bobId == acmeCoId; /* (5) */
*/

在前面的代码中,有一些非常有趣的事情正在发生:

  1. 我们定义了一个 Id 模块签名,它声明了一个抽象的 t 类型,以及 t 值的构造函数和提取函数

  2. 我们定义了一个没有明确签名的 IntId 模块,它暴露了一个等于 intt 类型,以及与 Id 签名中相同的构造函数和提取函数

  3. 我们定义了两个模块别名 PersonIdCompanyId,将它们指定为 IntId,并赋予它们显式的 Id 签名

  4. 我们定义了 PersonId.tCompanyId.t

  5. 如果我们尝试比较这些值,我们会得到一个类型错误:

(Output from bsb -w)
  We've found a bug for you!
  /Users/yawar/src/learning-tydd-reason/src/Ch03/Ch03_Id.re 19:23-30

  17 │ let bobId = PersonId.make(1);
  18 │ let acmeCoId = CompanyId.make(1);
  19 │ let result = bobId == acmeCoId;

  This has type:
    CompanyId.t
  But somewhere wanted:
    PersonId.t

因此,Reason 能够区分这两种类型,尽管它们在物理上是相同的类型,由相同的模块(IntId)支持,只是因为它们被显式地注解为模块类型(Id),这阻止了 Reason “看到”底层类型。由于签名和抽象的 t 类型,Reason 不能证明 CompanyId.tPersonId.t 是相同的,因此尝试比较它们会导致类型错误。

注意,即使有明确的签名,创建这些模块在分配上也是非常便宜的:

// src/Ch03/Ch03_Id.bs.js
function *make*(*$$int*) { return *$$int*; }
function *toInt*(*t*) { return *t*; }

var *IntId* = [*make*, *toInt*];
var *PersonId* = *IntId*;
var *CompanyId* = *IntId*;

var *bobId* = 1;
var *acmeCoId* = 1;

原因是重用了相同的 IntId 模块,并在编译时纯粹区分它们的类型。因此,我们可以优雅地分离我们的关注点:

  • Id 签名只是说明存在一个可以转换为 int 并从 int 转换回的 t 类型

  • IntId实现了一个与Id签名兼容但未显式注解的模块;从而表明基于整数的 ID 模块是Id签名的一种可能的实现方式。

  • PersonIdCompanyId模块利用IdIntId的组合,通过告诉编译器它不能假设 ID 类型相同(尽管我们知道它们是相同的)来实现类型安全。

如你所见,在 Reason 中,我们有一个细粒度的强大功能,利用编译器实现非常轻量级的代码。我们将在未来的章节中看到更多类似的技术。

摘要

在本章中,我们介绍了如何将类型和值与模块一起打包,如何使用签名精确指定我们希望从模块中公开的表面区域,以及如何通过模块和签名的组合来严格控制我们的数据类型——甚至可以控制模块中数据的内存分配。在 Reason 中,你会注意到这种模式很多——你设计类型以确保某些规则得到执行,在很多情况下,它们将不会产生运行时成本。

所以,请保持关注——在下一章中,我们将介绍我们在类型驱动开发中每天都会使用的一些最重要的类型:将值组合在一起以便于访问的产品类型。

第四章:在类型中组合值

在上一章中,我们看到了一种将类型和值组合在一起的方法,以便它们可以在单个命名空间下访问,我们还看到了这些命名空间(模块)本身也有类型。然而,模块在运行时传递值时并不方便。我们需要一种轻量级的方法来从更简单的类型构建更结构化的类型,以模拟现实世界问题。

在本章中,我们将介绍这些结构化类型,特别是:

  • 记录类型

  • 元组类型

  • 对象类型

  • JavaScript 对象类型

总的来说,这些类型被称为乘积类型,因为乘积类型可以包含的可能值的数量是其每个组成部分类型可以包含的可能值的数量的乘积。这是类型理论中的一个有趣的结果,它给我们一个暗示,即类型遵循某些代数定律。我将在本书稍后提供更多阅读材料,在我们对类型知识有更多了解之后。

记录类型

我们在书中几个地方使用了记录类型,主要是用来构建一个具有 ID 和名称的person类型。让我们更仔细地检查这个简单的记录类型,并分析创建它时究竟发生了什么:

type person = {*id*: int, *name*: string};

整个类型定义创建了一个新的名义类型person,它有两个命名字段:idname,具有特定的intstring类型。名义类型是类型检查器仅通过名称与其他类型区分的类型。

这与结构类型相对,结构类型在类型检查器中被认为是其构成类型的等价。例如,我们在上一章中看到模块是结构类型。我们将在本章和下一章中看到更多示例。

无论如何,名义类型不能互换使用,即使它们有完全相同的定义。例如:

(Output from bsb -w)
  We've found a bug for you!
  /Users/yawar/src/learning-tydd-reason/src/Ch03/Ch03_Greet.re 10:20-22

   8 │ type person = {id: int, name: string};
   9 │ let bob = {id: 1, name: "Bob"};
  10 │ let result = greet(bob);

  This has type:
    person
  But somewhere wanted:
    LearningTyddReason.Ch02_Demo.person (defined as
      LearningTyddReason.Ch02_Demo.person)

在前面的错误信息中,请注意我们在Ch03_Greet中定义了一个新的记录类型person,创建了这个类型的值,并尝试用这个值调用greet函数。但是greet函数只接受Ch02_Demo.person类型的值,并在我们的具有相同结构但名称不同的person值上出错(在这个目的上,模块路径被认为是名称的一部分):

图片

具有相同定义的名义类型之间的类型不匹配

记录字面量

我们可以使用记录字面量语法创建记录值:

/* src/Ch04/Ch04_RecordLiterals.re */
type person = {*id*: int, *name*: string};

let *bob* = {*name*: "Bob", *id*: 1}; /* (1) */

let *jim* = { /* (2) */
  let *id* = 2;
  let *name* = "Jim";
  {*id*, *name*} /* (3) */
};

let *tomId* = 3;
let *tom* = {*id*: *tomId*, *name*: "Tom"}; /* (4) */

这些是记录字面量的典型变体:

  1. 记录类型的标准记录字面量语法为具有field1fieldN字段的记录:{field1: expression1, ..., fieldN: expressionN}。注意记录的一个最显著特点:字段顺序不重要。在这里,我们定义了一个记录字面量,其字段名称的顺序与记录类型定义相反。

  2. 我们可以使用括号来开始一个封闭的作用域,这样内部声明的名称(idname)将不会对外部可见,并且封闭作用域中的最后一个表达式({id, name})将是作用域的结果值。请注意,作用域分隔括号与记录分隔括号是分开的。

  3. 当作用域中存在与字段名称相同的名称时,我们可以使用记录字段重命名语法来编写记录字面量。现代 JavaScript 也获得了这个重命名功能。

  4. 如果作用域中没有与字段名称相同的名称,我们可以使用标准记录字面量语法来使用任何表达式。

访问字段和处理错误

记录字段访问语法在其他语言中看起来很相似:recordValue.fieldName

为了正确推断记录字面量的类型,Reason 依赖于将字段名称与它所知道的记录类型匹配。您可能会看到的一个常见错误是当 Reason 找不到合适的记录类型时;例如,如果我们从Ch04_RecordLiterals中删除person类型:

(Output from bsb -w)
  We've found a bug for you!
  /Users/yawar/src/learning-tydd-reason/src/Ch04/Ch04_RecordLiterals.re 2:12-13

  1 │ /* src/Ch04/Ch04_RecordLiterals.re */
  2 │ let bob = {name: "Bob", id: 1}; /* (1) */
  3 │ 
  4 │ let jim = { /* (2) */

  The record field name can't be found.

  If it's defined in another module or file, bring it into scope by:
  - Annotating it with said module name: let baby = {MyModule.age: 3}
  - Or specifying its type: let baby: MyModule.person = {age: 3}

这通常发生在记录类型定义在另一个模块中时。我们之前提到,Reason 不会自动搜索其他模块以查找记录类型,因为不同的模块可能包含具有相同字段名称的记录类型;这会不清楚您指的是哪一个。相反,Reason 选择让您明确指定模块,例如:

let *bob* = {*Ch02_Demo.id*: 1, *name*: "Bob"};

现在,Reason 可以告诉我们:

  • 您所提到的id字段位于Ch02_Demo

  • 记录字面量只有idname字段

  • 因此,这个记录可能只能是Ch02_Demo.person类型(它不能是Ch02_Demo.company,因为该记录类型也有employees字段)

更改记录值

记录默认是不可变的:一旦创建,就无法更改。这还意味着所有的 Reason 值默认都是不可重新分配的(这意味着像let x = 1; x = 2;这样的代码会导致编译错误)。这种刚性有很好的理由:如果您知道值不会改变,那么单独考虑程序中的每个部分并将它们放在一起时,您会更容易地确信这些部分的行为将是一致的。这是函数式编程的核心原则之一,Reason 的静态类型技术就是建立在这一点上的。

但完全不可更改的值并不很有用。通常,您需要在运行时更改值来模拟您感兴趣的行为。因此,Reason 提供了两种更改记录值的方法:不可变更新和可变字段。

不可变更新

这是我们将最常使用的方法。使用不可变更新,我们实际上使用旧值创建新的记录值。Reason 为此提供了一种特殊的语法,当记录类型有两个或更多字段时,它非常有用。但无论字段有多少,我们都可以仅使用正常的记录字面量语法来构建记录值。以下是一些示例:

/* src/Ch04/Ch04_RecordUpdates.re */
let *bob* = *Ch04_RecordLiterals.bob*;
let *bobLongForm1* = {...*bob*, *name*: "Robert"}; /* (1) */
let *bobLongForm2* = {*Ch04_RecordLiterals.id*: *bob.id*, *name*: "Robert"}; /* (2) */

在这里,我们将鲍勃的名字更新为其完整形式。请注意以下几点:

  1. 当我们使用不可变更新语法(在 Reason 中也称为记录展开语法)时,Reason 会创建一个新的记录值,它与旧值(bob)完全相同,除了我们覆盖的字段。尽管记录类型定义在另一个模块中,但我们不需要为任何字段使用模块前缀,因为不可变更新强制 Reason 查找字段是什么,因此当我们更新它时,它知道name字段。

  2. 当我们使用正常的记录字面量语法时,我们需要定义所有记录字段。如果我们忘记了一个,我们将会得到一种相当有趣的类型错误:

(Output from bsb -w)
  We've found a bug for you!
  /Users/yawar/src/learning-tydd-reason/src/Ch04/Ch04_RecordUpdates.re 4:20-51

  2 │ let bob = Ch04_RecordLiterals.bob;
  3 │ let bobLongForm1 = {...bob, name: "Robert"};
  4 │ let bobLongForm2 = {Ch04_RecordLiterals.id: bob.id};

  Some record fields are undefined: name

由于 Reason 不允许 null 或 undefined 值,省略记录字段会导致类型错误:

缺少记录字段类型错误

使用不可变更新,我们可以重用相同的名称,并且不必担心为各种转换值想出新名称,这要归功于阴影:

let *bob* = *Ch04_RecordLiterals.bob*;
let *bob* = {...*bob*, *name*: "Robert"};
let *bob* = {...*bob*, *id*: *bob.id* + 1}

我们经常使用阴影对某个值执行一系列更新,以传达我们正在构建或转换同一个值以用于最终目的的意图。

可变记录字段

这些用得比较少,因为它们给程序引入了不安全因素,并迫使开发者更加谨慎地编写代码。你在一个点上绑定了一个特定的值,而在另一个点上它可以是不同的值,如果代码库的其他部分基于原始值以某种方式行为,并且它们再次检查并发现新值,那么它们将会遇到一个大的惊喜。

尽管如此,如果需要从某些记录中获得一些额外的性能,并且将突变包含在单个函数的作用域内,它们可能非常有用。以下是一个示例:

/* src/Ch04/Ch04_MutableFields.re */
type summaryStats = {
  mutable *sum*: float, /* (1) */
  *count*: int,
  mutable *mean*: float
};

let *summarise*(*array*) = {
  let *result* = {*sum*: 0., *count*: *Array.length*(*array*), *mean*: 0.}; /* (2) */

  for (*i* in 0 to *result.count* - 1) { /* (3) */
    *result.sum* = *result.sum* +. *Array.unsafe_get*(*array*, *i*); /* (4), (5) */
  };

  *result.mean* = *result.sum* /. *float_of_int*(*result.count*); /* (6), (7) */
  *result*
};

这里发生了很多事情:

  1. 我们使用mutable关键字在字段名称之前使记录字段可变。在这种情况下,我们只使三个字段中的两个可变,因为对于给定的数组,计数永远不会改变。

  2. 我们使用常规的文本语法创建一个新记录类型;对于浮点字面量,我们可以使用0.作为0.0的简写,并且Array.length函数返回 Reason 数组的长度。

  3. 我们使用for循环遍历我们想要统计的输入数组。Reason 完全支持命令式编程语法,如forwhile循环;如果你想要以任何给定风格编程,你很可能能在 Reason 中做到。

  4. 我们通过迭代地修改sum字段来累加数组。修改(可变的)记录字段使用简单的recordVal.field = value语法。

  5. 我们使用Array.unsafe_get获取给定数组索引处的元素,它获取元素前会跳过边界检查。在这个例子中,我们不需要边界检查,因为我们已经在循环开始时确保不会超出数组的末尾。但如果我们犯了错误,比如for (i in 0 to result.count) { ... },我们会得到一个运行时错误。所以对不安全函数要非常小心。数组元素访问有一个安全的、带边界检查的语法:array[i]

  6. 在我们遍历整个数组完成后,我们将mean字段变异为最终的均值。

  7. 我们不能将浮点数sum除以整数count,因此我们需要将整数count转换为浮点数。请注意,我们不会反过来操作,即不将浮点数sum转换为整数,因为这会导致有损转换!

注意我们如何始终将变异操作限制在单个函数的范围内。尽管如此,仅仅将包含两个可变字段的summaryStats值返回给调用者,这种变异安全性多少受到了影响。在未来的变异示例中,我们将展示更安全的用法模式。

我们在浮点数算术中使用略微不同的运算符:在 Reason 中,整数和浮点数算术是完全分开的,它们不混合。浮点数算术运算符与整数算术运算符相同,只是在它们后面附加了一个"."。Reason 的哲学非常强调显式优于隐式

重复一遍,对于大多数目的,不可变记录确实非常有效。在某些有限的情况下,可变记录字段很有用,并且当我们使用它们时,我们需要采取额外的预防措施以防止意外的变异。

记录模式

在第二章,使用类型和值编程中,我们看到了值绑定的通用语法:

let *PATTERN* = *VALUE*;

这种语法也适用于记录,因为记录字面量也充当模式。在记录模式的情况下,我们称之为解构模式匹配,因为我们通过匹配记录值的结构并将字段拉出来来绑定名称。以下是一些示例:

/* src/Ch04/Ch04_RecordPatterns.re */
open *Ch04_RecordLiterals*; /* (1) */

let {*id*: *bobId*, *name*: *bobName*} = *bob*; /* (2) */
let {*id*, *name*: *jimName*} = *jim*; /* (3) */
let {*id*: *tomId*, *name*: _} = *tom*; /* (4) */
let {*name*, _} = *tom*; /* (5) */
let {*name*: *tomName*} = *tom*; /* (6) */

这些是记录模式的可能变体:

  1. 我们全局打开了记录字面量模块,以便方便地访问那里定义的类型和值,而无需每次都进行限定。我在上一章中没有展示这一点,因为我故意想淡化全局打开的使用,因为它们通常风险更高。但在这种受控情况下,它们是可以接受的。

  2. 记录模式看起来就像记录字面量一样,除了那些原本会是字段值的现在也是名称(bobIdbobName)并且这些名称被绑定到记录中的实际值。

  3. 如果我们想绑定与字段名相同的名称,可以使用快捷方式:只需在右侧省略冒号和字段值。我们甚至可以混合两种风格。

  4. 我们只能将记录的一些字段绑定到值上,并且通过将它们绑定到下划线符号来显式地不绑定其他字段,这意味着忽略。

  5. 我们可以将一些字段绑定到名称上,并使用下划线符号无保留地忽略其余部分。

  6. 我们可以通过完全省略它们来绑定一些字段并忽略其余部分。Reason 将使用它看到的字段名称来推断模式的类型,但如果定义了具有相同字段的多个记录类型,它可能会感到困惑。

正如你所见,记录模式语法非常复杂和详细,这是随着时间的推移,作为 Reason 的基础语言 OCaml 在积极工业应用中发展起来的。要习惯所有这些模式需要一点时间;从简单开始,并在需要时使用更简洁的模式。

关于记录模式,还有最后一件事要提:它们也是不可反驳的模式,就像简单的值绑定一样,例如 let x = 1;。不可反驳意味着一旦模式被编译,在运行时它无法失败地与右侧的值匹配。根据这个标准,记录模式通过绑定它们只是将它们的字段绑定到名称上,而简单的名称绑定也是不可反驳的。但我们在下一章中会看到可反驳模式的例子,所以请留意它们。

元组类型

元组 是轻量级的结构化类型。更准确地说,它们是由其他类型组成的类型,以特定的顺序在括号内连接,用逗号分隔,并且没有字段名。元组值语法非常简单——开括号,逗号分隔的值列表,闭括号。事实上,值语法与元组本身的类型非常相似。

元组 的发音因人而异,但我通常把它读作与 couple 发音相似。

为什么我们要使用元组,而不是具有字段名的记录类型呢?有时,我们不想为了将一些值组合在一起而启动一个新的类型,并为其定义。元组是一种低仪式的方式来做到这一点。但是,在代码库的更大部分中使用它们的危险是,它们不像记录类型那样具有自描述性。以下是一个例子:

/* src/Ch04/Ch04_Tuples.re */

/* ID, name */
let *bob* = (1, "Bob");

/* Name, ID */
let *jim* = ("Jim", 2);

/*let bobEqualsJim = bob == jim;*/

我在这里只用了两个值来构成元组,但 Reason 支持任何大小的元组。

注意最后一行,它被注释掉了。如果我们取消注释那行代码,我们会得到以下错误:

(Output from bsb -w)
  We've found a bug for you!
  /Users/yawar/src/learning-tydd-reason/src/Ch04/Ch04_Tuples.re 4:27-29

  2 │ let bob = (1, "Bob");
  3 │ let jim = ("Jim", 2);
  4 │ let bobEqualsJim = bob == jim;

  This has type:
    (string, int)
  But somewhere wanted:
    (int, string)

  The incompatible parts:
    string
    vs
    int

我移除了描述性注释,以便将所有代码放入错误信息中。

类型错误告诉我们两个元组的类型,我们看到它们纯粹是通过其组件类型的顺序不同:

图片

元组类型不匹配错误

在前面的错误中,我们还可以看到 Reason 一旦发现元组类型结构中的错误,就不会费心检查整个类型。十有八九,当我们调查这个错误信息时,我们会立即看到问题——值被交换了,类型也是如此——并且一次性修复它。另一种情况是,我们在修复第一个错误之后会遇到另一个类型错误,并修复它。我喜欢将类型驱动开发视为真正的类型错误驱动开发,因为遇到类型错误是一个非常好的场景,因为它意味着又多了一个潜在的错误,生产代码永远不会看到。

访问元组值

我们可以通过两种主要方式访问元组内的值:解构模式匹配和访问器函数。

解构模式匹配

我们可以使用解构将名称绑定到元组中的值,类似于记录:

let (*bobId*, *bobName*) = *bob*;
let (*jimName*, *_*) = *jim*;

对于 N-元组,一般模式是 (field1, field2, ..., field*N*)。像往常一样,特殊 _ 模式允许我们忽略我们不关心的字段。然而,与记录不同,我们不能完全省略它们——我们必须列出所有字段,并用逗号分隔。因为元组是根据其结构进行类型化的,省略元组字段会改变我们将要匹配的类型,这将是一个类型错误:

(Output from bsb -w)
  We've found a bug for you!
  /Users/yawar/src/learning-tydd-reason/src/Ch04/Ch04_Tuples.re 12:18-20

  10 │ 
  11 │ let tom = (3, "Tom", 45); /* ID, name, age */
  12 │ let (tomId, _) = tom;

  This has type:
    (int, string, int)
  But somewhere wanted:
    ('a, 'b)

元组解构类型错误

我们在这里看到了两种有趣的数据类型:'a'b(通常发音为 alphabeta)。我们将在未来的章节中深入探讨它们的意义,但就目前而言,我们可以将它们视为 Reason 表示我还没有弄清楚这些类型的方式。无论如何,请注意,它们不会触发实际的类型错误——因为 Reason 不了解它们是什么,它不能假设它们是类型不匹配。真正导致错误的是元组中缺失的第三个元素。实际上,我们正在尝试将一个 3 元组与一个 2 元组匹配,这触发了类型错误。

访问器函数

Reason 提供了两个便利函数,fstsnd,用于从元组中获取第一个和第二个位置值。以下是一个示例:

let *bobId* = *fst*(*bob*);
let *jimId* = *snd*(*jim*);

这些函数在我们试图管理大量元组并需要告诉其他函数如何操作它们时非常有用。我们将在即将到来的关于函数的章节中看到一个这种受控行为的示例。

对象类型

对象是 OCaml 对面向对象编程OOP)的全面支持的一部分。它们结合了记录和元组的一些最佳特性:它们是结构化的,因此我们可以以临时方式创建它们(无需定义它们的类型),并且我们可以提供字段名称(在面向对象术语中,方法)作为描述性名称。以下是一个示例:

/* src/Ch04/Ch04_Objects.re */
let *bob* = {as _; pub *id* = 1; pub *name* = "Bob"}; /* (1), (2) */

let *greet*(*person*) =
  "Hello, " ++
  *person#name* ++
  " with ID " ++
  *string_of_int*(*person#id*); /* (3) */

let *jim* = {
  pub *id* = 2;
  pub *name* = "Jim";
  pub *sayHi* = "Hi, my name is " ++ *this#name* /* (4) */
};

*Js.log*(*greet*(*jim*)); /* (5) */
/*Js.log(greet({as _; pub name = "Tom"}));*/

这些展示了 Reason 对象的一些基本用法:

  1. 对象由括号界定,并且可以在括号内使用 this 关键字来引用自身。然而,如果我们不使用 this,Reason 将会警告我们关于未使用值。因此,我们可以在对象的开始处使用 as _ 来选择性地忽略此警告,以抑制此警告。

  2. 我们可以通过指定它们的公共方法(即字段)来以 adhoc 的方式创建对象,使用 pub 关键字。Reason 将从其公共方法推断对象的类型。

  3. 我们可以使用 # 符号来访问对象字段(即调用它们的公共方法)。此外,在这种情况下,我们需要将 int ID 转换为字符串,以便将其与其他字符串连接起来。

  4. 我们可以通过使用 this 来调用当前对象的公共方法。

  5. 如果我们在 NodeJS 中加载输出 JavaScript 模块 src/Ch04/Ch04_Objects.bs.js,Jim 的问候语将被打印出来。

如果你查看 JavaScript 输出,你会发现它相当复杂。Reason 对象确实相当重,因为它们可以包含很多封装在其中的功能。通常,我们不需要这种级别的功能,因为我们有其他方式来建模数据和行为。我们将在后续章节中介绍更多抽象技术,这些技术可以减少对 Reason 对象的需求。

继承和子类型

你可能想知道 Reason 对象是否支持继承。实际上,它们完全支持使用运行时派发的继承。我们不会深入探讨继承,因为面向对象编程风格不是本书的重点;你可以在 Reason 和 OCaml 文档中找到大量关于面向对象编程的资源(它们具有等效的功能,只是语法不同)。

我们需要强调的一点是,Reason 对象可以通过一种特定的方式使用称为 行多态 的类型技术进行扩展。这是一个高级主题,我们将在未来的章节中介绍不同类型的 多态(提供不同类型的共同行为)。然而,基本思想是,支持所有 t 对象方法超集的 u 对象被认为具有 U 类型,它是 t 对象的 T 类型的子类型。因此,u 对象可以安全地 向上转换T 类型(即我们可以安全地说 u 具有该 T 类型),因为这不会导致数据丢失。换句话说,对象通过其方法实现支持 继承

让我们来看一个具体的例子:前一个示例代码中的 greet 函数。我们使用 jim 对象来调用它。该函数在其输入对象上调用两个方法:idname。Reason 实际上推断输入对象类型为 {.. id: int, name: string}。我们称这种类型为开放对象类型。开放性 由开括号后的两个点标记。这意味着我们可以使用此类型的子类型来调用 greet

实际上,我们在标记为(5)的行中就是这样做的:我们用jim调用greetjim是一个具有idnamesayHi方法的对象。换句话说,它是一个greet期望的类型子类型。greet接受它,因为它的输入参数类型允许子类型。如果您将调用改为使用bob,它也会成功,因为bob也符合具有idname方法的预期类型。

然而,如果您取消注释最后一行,您将得到以下类型错误:

(Output from bsb -w)
  We've found a bug for you!
  /Users/yawar/src/learning-tydd-reason/src/Ch04/Ch04_Objects.re 17:14-37

  15 │ 
  16 │ Js.log(greet(jim)); /* (5) */
  17 │ Js.log(greet({as _; pub name = "Tom"}));

  This has type:
    {. name : string }
  But somewhere wanted:
    {.. id : int, name : string }
  The first object type has no method id

这个错误表明我们传递的输入没有满足greet函数的最小要求。确实,直观地我们可以看到输入只有一个name方法,而greet函数需要idname方法。

如果您这么想,Reason 对象在行为上非常类似于 JavaScript、Python 或 Ruby 对象,因为我们可以将它们视为值和方法包。唯一的区别是 Reason 在编译时而不是在运行时为我们检查方法。

JavaScript 对象类型

在 Reason 中解释 JavaScript 对象的第一件事是,它们实际上不是 JavaScript 对象,它们是 Reason 语法结构,将编译成 JavaScript 对象。我们使用后一个术语作为前一个术语的快捷方式。

通过意识到 Reason 对象与 JavaScript 对象非常相似,我们可以理解 Reason 对 JavaScript 对象的支持是如何工作的。这种支持是内置的,但仅当使用 BuckleScript 针对 JavaScript 时。在这个项目中,我们就是这样做的,以下是一个例子:

/* src/Ch04/Ch04_JsObjects.re */
let *bob* = {"id": 1, "name": "Bob"}; /* (1) */

let *greet*(*person*) =
  "Hello, " ++
  *person##name* ++
  " with ID " ++
  *string_of_int*(*person##id*); /* (2) */

let *jim* = {"id": 2, "name": "Jim", "age": 29}; /* (3) */

*Js.log*(*greet*(*jim*));

这个例子展示了典型的 JavaScript 对象用法:

  1. 注意 JavaScript 对象字面量与实际 JavaScript 的相似性。它是明确这样设计的——用括号括起来,字段名用双引号,值在冒号后面。

  2. JavaScript 对象字段访问看起来几乎与 Reason 对象方法调用完全相同,只是将#替换为##

  3. 可以给 JavaScript 对象实际的方法,使其引用this JavaScript 值,但这是一个非常高级的话题,所以我们在这里使用age字段。

注意到结尾处,就像 Reason 对象示例一样,我们传递了一个对象(jim),它暴露了greet函数所需字段的超集。这就是为什么我们在 Reason 中使用这种特殊语法来模拟 JavaScript 对象:它们在支持通过行多态进行子类型化的意义上类似于 Reason 对象。有了这种子类型化,我们可以轻松地模拟许多 JavaScript 惯用法,而这用简单的记录类型是做不到的。前面使用greet函数的例子就是其中之一,实际上在 JavaScript 世界中,经常看到在只使用它们属性的一些子集的函数之间传递对象。

如果你查看 JavaScript 输出,请注意它相对非常简单——它实际上只是简单的 JavaScript 对象操作。多亏了 BuckleScript 使用基本 OCaml 对象来模拟 JavaScript 对象的能力,以及 Reason 优雅的对象语法,我们得到了两者的最佳结合。

我们不会在本书中常规关注 JavaScript 对象类型,但在现实世界的 Reason 使用中,了解它们是如何工作的非常有益。

摘要

在本章中,我们全面探讨了 Reason 提供的一些最重要的类型:产品类型,例如记录、元组、对象和 JavaScript 对象。通过了解它们是什么以及何时何地使用它们,我们现在可以模拟我们大部分数据处理需求。

在下一章中,我们将介绍求和类型,这是产品类型的另一面,它允许我们直接在我们的数据中模拟替代方案。

第五章:将替代值放入类型中

在上一章中,我们看到了如何构建捕获多种类型值的值,以及构建这些类型的不同方式。这使得我们可以说,只有当我们有它们组成类型的 所有 值时,我们才有复合(乘积)类型的值。有时,我们需要的值必须是几种类型中 仅一种 类型。

在本章中,我们将介绍这些 仅一种 类型,即:

  • 变体类型

  • 多态变体类型

  • 广义代数数据类型

这些类型统称为 求和类型,因为求和类型可以包含的可能值的数量是它每个组成部分类型可能值的数量的 总和。我们将在本章中看到这一点是如何成立的!

变体类型

变体类型是 Reason 的简单、惯用的求和类型。你可以把它们想象成类似于其他语言的枚举(一组声明为形成类型的有限值),但更强大,因为每个 变体情况(可能的替代值)都可以选择性地携带一个负载。以下是一个例子:

/* src/Ch05/Ch05_Variants.re */
type education = *School* | *College* | *Postgrad* | *Other*; /* (1) */

type poNumber = string;
type paymentMethod = *Cash* | *PurchaseOrder*(poNumber); /* (2) */

let *bobEducation* = *College*; /* (3) */
let *bobPaymentMethod* = *Cash*;
let *jimEducation* = *Other*;
let *jimPaymentMethod* = *PurchaseOrder*("PO-1234"); /* (4) */

此模块定义了一些类型,用于存储某人的教育水平和现金或采购订单的支付方式:

  1. 变体类型定义以 type typeName = 开头,就像任何其他类型的定义一样,并在等号右侧有一个或多个 变体构造函数(也称为 数据构造函数)。这些构造函数具有完全相同的类型,可以用作字面值。

  2. 变体构造函数必须以大写字母开头,并且可以携带任意数量的负载,正如括号内定义的那样。这可以是一个类型列表,用逗号分隔,这些类型将组成负载。在这里,我们定义了 poNumber 类型,而不是直接使用 string,以便使代码更具自描述性。

  3. 我们可以直接使用变体情况作为字面值。现在,bobEducation 的类型是 education

  4. 对于携带负载的变体构造函数,我们可以将它们作为逗号分隔的列表传递,括号内的语法反映了它们的定义。

第一个变体类型 education 是一个简单的类型,我们可以在很多语言中找到它。它只是定义了一个人教育允许的值集。当然,在所有场景中这可能并不现实,但有时我们只需要类型足够现实,以模拟我们的问题。

下一个变体类型 paymentMethod 是真正有趣的一个。它表示有效的支付方式是现金或带有 PO 号的采购订单。注意,对于现金我们不需要任何额外信息,但对于采购订单,我们需要其号码;不可能用相应的号码来描述 PO。

让我们稍微思考一下。在其他语言中表示支付方式,你可能做如下操作:

// JavaScript
const *bobPaymentMethod* = {*type*: 'PaymentMethod.Cash'};
const *jimPaymentMethod* = 
{
  *type*: 'PaymentMethod.PurchaseOrder',
  *poNumber*: 'PO-1234'
};

注意到问题了吗?没有任何东西阻止我们创建具有type: 'PaymentMethod.Cash'poNumber属性的对象,或者更糟糕的是,type: 'PaymentMethod.PurchaseOrder'但没有poNumber属性的type: 'PaymentMethod.PurchaseOrder'。我们无法保证采购订单总是有一个相关的 ID。变体类型给我们提供了这个静态保证。

模式匹配

我们可以轻松地构造变体值——只需输入变体构造函数并给出它们所需的数据。但当我们处理它们的值时,变体才真正发光。

例如,假设你想编写一个函数,该函数返回任何给定支付的感谢信息。信息的一部分将取决于支付方式:

/* src/Ch05/Ch05_PatternMatching.re */
type paymentMethod = *Ch05_Variants*.paymentMethod = /* (1) */
| *Cash*
| *PurchaseOrder*(*Ch05_Variants*.poNumber);

let *paymentMethodThanks*(*paymentMethod*) = switch (*paymentMethod*) { /* (2) */
| *Cash* => "Thank you for your cash payment"
| *PurchaseOrder*(*poNumber*) =>
  "Thank you for your purchase order # " ++ *poNumber*
};

我们将在这里引入一些新的语法:

  1. 我们使用类型方程来告诉编译器,在这个模块中定义的这个变体类型与在Ch05_Variants中定义的其他变体类型相同,并且关键的是它的构造函数也是完全相同的。

  2. 我们使用 Reason 的switch表达式模式匹配给定的支付方式,该表达式可以解构可以与模式匹配的数据。

我们可以在两个模块中简单地重新定义变体类型;但在 Reason 中,变体类型是命名(即,即使相同的变体类型定义在不同的模块中,也会被视为不同的类型),除非我们使用类型方程。在这个简单的例子中,编译器将两个类型等同起来并不是关键,但有时在一个 Reason 代码库中,你可能希望为了方便访问而引入另一个模块中的变体构造函数。否则,你可能需要打开另一个模块(风险较大)或者在构造函数前加上模块名(冗长),例如,Ch05_Variants.Cash

switch表达式的力量

在上一节中,我们看到了如何使用switch表达式。但那只是触及了switch能做的事情的表面。switch表达式可以匹配任意模式,并评估与第一个匹配模式对应的分支。

这里是switch表达式的稍微正式一点的语法:

switch (*expr*) {
| *pat1* => *res1*
| *pat2* => *res2*
...
| *patN* => *resN*
}

整个语法形式评估为单个值。每个以竖线字符(|)开始的表达式子部分被称为分支

评估表达式的步骤如下:

  1. 评估expr

  2. expr的值与pat1匹配;如果匹配,将整个表达式评估为res1并忽略所有其他分支。

  3. 否则,继续将值依次与每个模式匹配,并评估为与匹配模式对应的结果。

  4. 如果没有模式匹配,则抛出一个运行时错误(称为异常),Match_failure

表达式的每一分支都必须具有相同的返回类型,才能编译成功。请注意,分支的顺序可能重要也可能不重要,这取决于我们正在匹配哪种模式。如果我们正在匹配一个变体类型的精确情况,顺序并不重要,因为变体情况自然没有任何顺序的概念。也就是说,即使我们在paymentMethod类型定义中将Cash定义在PurchaseOrder之前,这并不意味着Cash在本质上小于PurchaseOrder。这里没有优先级。

然而,模式不必是变体情况。它们可以是任何有效的名称和字面值的组合。在这种情况下,一个名称是任何有效的 Reason 标识符,例如age_123。字面值包括变体情况,但也包括基本类型如charstringintfloat等的值;以及元组和记录值。如果一个模式与输入表达式匹配,它包含的任何名称都会被绑定到表达式的相关部分,并在=>右侧的结果表达式的范围内可用。

通过名称绑定、字面值和顺序,模式可以变得相当复杂;让我们看看几个例子:

/* src/Ch05/Ch05_PatternMatchOrder.re */
type person = *Ch04_RecordLiterals*.person = {*id*: int, *name*: string};

let *classifyId*(*id*) = switch (*id*) {
| 1 | 2 | 3 | 4 | 5 => "Low" /* (1) */
| 6 | 7 | 8 | 9 | 10 => "Medium"
| _ => "High"
};

let *greet1*(*person*) = switch (*person.id*, *person.name*) { /* (2) */
| (_, "Dave") => "I'm sorry, Dave, I can't let you do that."
| (1, _) => "Hello, boss."
| (*id*, *name*) => "Hi, " ++ *name* ++ " with ID " ++ *string_of_int*(*id*) ++ "!"
};

let *greet2*(*person*) = switch (*person*) {
| {*name*: "Dave"} => "I'm sorry, Dave, I can't let you do that." /* (3) */
| {*id*: 1} => "Hello, boss."
| {*id*, *name*} => "Hi, " ++ *name* ++ " with ID " ++ *string_of_int*(*id*) ++ "!"
};

这里有几个有趣的地方:

  1. 我们正在根据一个int ID 进行条件分支,在一行上列出多个替代模式,用竖线分隔。实际上,模式是递归定义的;这意味着模式可以包含更多的模式!换句话说,我们可以用竖线将几个单独的模式,如12等,组合起来,以表示这些模式中的任何一个都应该匹配。这被称为或模式。请注意,模式的顺序意味着在运行时,输入 ID 将首先与数字 1 到 5 进行比较,然后才会与其他模式进行比较。在这个模式匹配中,下划线符号(_)表示任何东西,我不关心,也不绑定值

  2. 给定一个person值,我们根据其名称和 ID 进行条件分支。实际上,我们是在对一个单一的表达式进行条件分支;表达式(person.id, person.name)是一个即时创建的元组,我们立即对其进行匹配。这里真正有趣的是模式的顺序。我们表达的是逻辑,即我们总是为 Dave 显示特殊消息,如果不是 Dave,如果是 ID 为 1 的人,我们将其问候为老板,只有当不是 Dave 或老板时,我们才按名称和 ID 问候这个人。

  3. greet2中,我们直接使用记录字面模式对person进行条件分支,并表达与之前相同的有序逻辑,只是这次我们不需要构建一个临时元组,因为我们知道我们可以直接对记录进行模式匹配。这并不一定有性能上的好处,但你可能会觉得代码稍微整洁一些——这是主观的。

模式匹配可以处理相当复杂的数据结构,因为模式具有特殊的组合属性。然而,有了这种强大的力量,也带来了两个警告:首先,我们需要理解我们列出 switch 表达式分支的顺序,以免得到意外结果;其次,我们需要避免使用可反驳模式可能引起的潜在运行时错误。

可反驳模式

重要的是要理解,switch 表达式是 Reason 中三种模式匹配中的一种:

  • Switch 表达式

  • 令绑定(见第二章,使用类型和值编程

  • 函数参数(我们将在未来的章节中介绍函数)

在所有三种模式匹配形式中,如果我们错误地使用可反驳模式,我们就会面临运行时错误的风险。可反驳模式 是可以类型检查,但可能在运行时可能失败的模式。这里有一些简单的例子:

let 3 = 3;
let *getPoNumber*(*paymentMethod*) = 
{
  let *PurchaseOrder*(*poNumber*) = *paymentMethod*;
  *poNumber*
};

let 3 = 3 感到惊讶吗?记住,等号左边的可以是任何模式——甚至是一个单一的文本值!此外,PurchaseOrder(poNumber) = ... 是一个变体情况的解构,而不是函数定义。区别在于第一个字母的大小写,P。记住,Reason 模块和数据构造函数以大写字母开头,而类型和值以小写字母开头。

如果你尝试第一个绑定,你会看到以下警告:

(Output from bsb -w)
  Warning number 8
  /Users/yawar/src/learning-tydd-reason/src/Ch05/Ch05_PatternMatchOrder.re 22:5

  20 │ };
  21 │ 
  22 │ let 3 = 3;

  You forgot to handle a possible value here, for example: 
0

问题其实并不明显:被绑定的值实际上是 3,它永远不会是 0,所以我们怎么能忘记处理它呢?要意识到的是,编译器只看类型——它看到我们使用了类型为 int 的可反驳模式,并警告我们关于它知道我们没有处理过的最简单的 int0

如果你尝试第二个:

(Output from bsb -w)
  Warning number 8
  /Users/yawar/src/learning-tydd-reason/src/Ch05/Ch05_VariantPatternMatching.re 13:7-29

  11 │ 
  12 │ let getPoNumber(paymentMethod) = {
  13 │   let PurchaseOrder(poNumber) = paymentMethod;
  14 │   poNumber
  15 │ };

  You forgot to handle a possible value here, for example: 
Cash

这次,很明显:在运行时,getPoNumber 函数可能会被带有 Cash 值的参数调用(因为那通过了类型检查!)但它不知道如何处理它。它除了抛出运行时错误外,别无他法。编译器就像以前一样,通过查看 paymentMethod 类型并寻找它知道我们没有处理过的类型值来解决这个问题。

这种检查我们是否处理了给定类型的所有可能模式的功能被称为完备性检查,并且是 Reason 类型系统中最强大和最有用的功能之一。它也存在于一些从与 Reason 相同的 ML(元语言)遗产中衍生出来的语言,或者从中汲取了灵感。无论什么语言,如果你有完备性检查可用,尽量尽可能多地使用它,因为它是一个很好的安全网。

当子句和一般分支

不仅开关表达式可以在它们的输入上进行模式匹配,而且它们还可以在每个模式的末尾添加一个称为 when 子句 的一般测试条件。这让你可以在每个分支中检查完全通用的条件以匹配该分支。请注意,使用 when 子句确实会放弃详尽性检查,所以在伸手去拿它之前,考虑一下你是否可以不这么做。有时候,你真的不能。例如:

/* src/Ch05/Ch05_Branching.re */
type education = *Ch05_Variants*.education = /* (1) */
| *School*
| *College*
| *Postgrad*
| *Other*;

type paymentMethod = *Ch05_Variants*.paymentMethod =
| *Cash*
| *PurchaseOrder*(*Ch05_Variants*.poNumber);

/** Returns purchase order IDs that start with 'ACME', otherwise
    nothing. */
let *getAcmeOrder*(*paymentMethod*) = switch (*paymentMethod*) {
| *PurchaseOrder*(*poNumber*)
  /* (2),                                      (3) */
  when *String.sub*(*poNumber*, 0, 4) == "ACME" => *Some*(*poNumber*)
| _ => *None* /* (4) */
};

let *decidePaymentMethod*(*orderTotal*, *orderId*) =
 if (*orderTotal* <= 50.0) *Cash* /* (5) */
 else *PurchaseOrder*("PO-" ++ *orderId*);

这个代码示例展示了使用 when 子句的场景:

  1. 我们重新定义了我们需要的类型并将它们等价起来以实现互操作性。严格来说,在这种情况下,我们不需要这么做。但在大多数现实世界的代码中,你确实需要这么做。

  2. 我们在 PurchaseOrder(poNumber) 基本模式中添加了一个 when 子句来检查 PO 号码是否以单词 ACME 开头。这是我们不能通过模式匹配来完成的事情,因为我们不能匹配字符串的部分。

  3. 我们还将分支评估为 Some(poNumber)Some 是一个内置的数据构造函数,它表达了这里有一个值(与没有值相对)的概念。它实际上接受任何类型,而不仅仅是 string,我们将在下一章中看到它是如何工作的。

  4. 当 PO 号码不以单词 ACME 开头时,其结果为 None,这与 Some(whatever) 的类型相同,但它表达了这里没有值的概念。Some(whatever)None 的类型是一种变体类型 option(whateverType),它对于安全地传递可能逻辑上不存在的值非常有用。在这种情况下,它对我们函数 getAcmeOrder 很有用,因为,在运行时给定任何支付方式,它可能实际上不包含 ACME 购买订单,因此我们需要一种方式来说明这不是一个 ACME PO,而 None 就提供了这种可能性。

  5. Reason 还有一个传统的 if-else 语法,它也是一个表达式,并返回一个值。ifelse 分支都必须返回相同类型的值;如果我们省略了 else 分支,它将被假定为返回类型为 unit()。在 Reason 中,()——发音为 unit——大致意味着与 C、C++ 等语言中的 void 相同的意思,只不过它是一个实际可赋值的值,有时可能会派上用场。在 if 表达式的情况下,具体来说,这意味着我们应该包含一个 else 子句,或者确保 if 子句返回类型为 unit。通常,这发生在执行操作但不返回有用值的函数中,例如,Js.log("Hello")

到目前为止,在这本书中,我们看到了一些看起来像 typeName(typeParam) 的类型,但我们还没有深入研究它们是什么或它们是如何工作的。在下一章中,我们将学习参数化类型以及它们如何帮助我们编写安全、可重用的代码。

严格的安全检查

值得稍微偏离一下,谈谈编译器的详尽性检查警告。正如你将记得的,它看起来如下:

(Output from bsb -w)
  Warning number 8
  /Users/yawar/src/learning-tydd-reason/src/Ch05/Ch05_Branching.re 25:5

  23 │   else PurchaseOrder("PO-" ++ orderId);
  24 │ 
  25 │ let 3 = 3;

  You forgot to handle a possible value here, for example: 
0

编译警告的问题在于它不会导致编译失败。我们最终可能会在生产环境中运行经过类型检查但实际不安全的代码,因为它忘记处理一些边缘情况。如果编译器在发现任何非穷举模式时能够停止编译,那就真的理想了,这样我们就可以排除将它们部署到生产环境中。

幸运的是,我们可以告诉编译器将警告提升为错误。在我们的情况下,因为我们使用的是 BuckleScript 项目,我们可以稍微编辑一下bsconfig.json文件:

// bsconfig.json
{
  // ... rest of the file ...
  "warnings": 
  {
    "error": "+8"
  }
}

编辑完文件后,我们可以重新启动bsb -w以使新设置生效。"warnings"属性包含一个对象,可能包含几个属性,其中之一是具有对应字符串值的"error"属性,该字符串值列出了我们想要提升为错误的警告编号,使用"+NUM"语法。我们可以从编译器警告消息中获取确切的编号(见前面的片段)。

现在,这个相同的警告将导致编译失败:

(Output from bsb -w)
  Warning number 8
  /Users/yawar/src/learning-tydd-reason/src/Ch05/Ch05_Branching.re 25:5

  23 │   else PurchaseOrder("PO-" ++ orderId);
  24 │ 
  25 │ let 3 = 3;

  You forgot to handle a possible value here, for example: 
0

  We've found a bug for you!
  /Users/yawar/src/learning-tydd-reason/src/Ch05/Ch05_Branching.re

  Some fatal warnings were triggered (1 occurrences)

现在编译器在遇到致命警告时会停止编译,我们可以享受更安全的模式匹配。

多态变体类型

虽然 Reason 的变体类型在概念上很简单,但它们的大部分功能来自于能够与模式匹配和穷举检查一起使用。Reason 还提供了一个更强大但更复杂的求和类型,称为多态变体。正如其名所示,这些变体比常规变体更灵活。以下是一些我们可以使用多态变体做,但不能使用常规变体的事情:

  • 在定义类型之前创建值,让编译器推断类型

  • 将多组变体情况组合在一起

  • 定义处理至少一组变体情况作为输入的函数

  • 定义输出最多一组变体情况的函数

在某种意义上,我们可以将多态变体视为与常规变体相关,就像对象与记录相关一样。实际上,它们是结构化类型,就像对象和模块一样,因为编译器通过检查它们的结构来推断值的类型。

创建和类型化值

让我们看看几个例子,以了解多态变体是如何工作的:

/* src/Ch05/Ch05_PolymorphicVariantBasics.re */
let *colour* = *`Red*; /* (1) */
let *angle* = *`degrees*(45.0); /* (2) */

type event = [ /* (3) */
| *`clickTap*(int, int) /* x, y */
| *`keypress*(char)
| *`pointerMove*(int, int, int, int) /* x1, y1, x2, y2 */
];

type mobileEvent = [
| event /* (4) */
| *`deviceShake*(int) /* how many times */
| *`accel*(float) /* m/s² */
];

let *pressA*: mobileEvent = *`keypress*('a'); /* (5) */
/* let *shakeThrice*: event = *`deviceShake*(3); /* (6) */ */

这里有很多新的语法,但希望它们看起来与常规变体语法有些相似:

  1. 我们可以定义一个专用的多态变体值,并让编译器确定其类型。在这种情况下,编辑器支持应该显示给我们一个类型[> Red]。这意味着至少包含我看到的 Red ``数据构造函数的多态变体类型,可能还有更多。类型周围的括号([...]`)在语法上将其与常规变体区分开来,并暗示我们应该将其视为类似有界集的东西。

  2. 多态变体构造函数可以包含有效载荷,就像常规变体构造函数一样。此外,与常规构造函数不同,多态构造函数可以以小写字母开头。尽管如此,这仍然是不明确的,因为多态变体构造函数必须始终以反引号字符作为前缀。

  3. 我们可以使用显示的语法显式定义多态变体类型。请注意,编译器不会自动推断使用定义的构造函数作为后续值的定义类型。我将在第五点中对此进行更多解释。

  4. 多态变体类型可以组合其他多态变体类型,并成为一个更广泛的案例集。这让我们可以模拟那些可以被认为是集合(类型)一部分的案例,但也可以单独考虑,例如设备输入事件。

  5. 我们必须显式地用多态变体类型注释一个值,以告诉编译器它必须是正好这个类型。没有这个注释,在模块内部编译器将根据数据构造函数的结构推断类型,即在这种情况下是[> keypress(char)]`。请注意,在模块签名中声明多态变体值的精确类型将有效,但仅从其他模块的角度来看,而不是在模块内部。通常,这已经足够了!

  6. 如果该类型没有首先声明构造函数,我们无法声明一个多态变体构造函数具有某种类型。以下是错误可能的样子:

(Output from bsb -w)
  We've found a bug for you!
  /Users/yawar/src/learning-tydd-reason/src/Ch05/Ch05_PolymorphicVariantBasics.re 18:26-40

  16 │ 
  17 │ let pressA: mobileEvent = `keypress('a'); /* (5) */
  18 │ let shakeThrice: event = `deviceShake(3); /* (6) */

  This has type:
    [> `deviceShake(int) ]
  But somewhere wanted:
    event
  The second variant type does not allow tag(s) `deviceShake

我们可以这样可视化错误:

图片

多态变体类型不匹配

向函数中输入值

现在我们已经可以创建多态变体值,让我们用它们做一些有用的事情。我们如何处理在运行时得到的值?结果证明,我们可以像处理正常变体一样处理它:模式匹配:

/* src/Ch05/Ch05_PolymorphicVariantInputs.re */
let *eventToString*(*event*) = switch (*event*) {
| *`clickTap*(*x*, *y*) => {j|`clickTap($*x*, $*y*)|j} /* (1), (2) */
| *`keypress*(*char*) => {j|`keypress($*char*)|j}
| *`pointerMove*(*x1*, *y1*, *x2*, *y2*) => {j|`pointerMove($*x1*, $*y1*, $*x2*, $*y2*)|j}
| *`deviceShake*(*times*) => {j|`deviceShake($*times*)|j}
| *`accel*(*mssq*) => {j|`accel($*mssq*)|j}
};

/* (3) */
let *pressAString* = *eventToString*(*Ch05_PolymorphicVariantBasics.pressA*);

/* (4) */
let *`degrees*(*angleVal*) = *Ch05_PolymorphicVariantBasics.angle*;
  1. 我们可以对输入的多态变体数据构造函数进行模式匹配,就像我们对常规构造函数进行匹配一样:通过将构造函数作为模式写出来,并绑定我们想要使用的任何包含的数据。

  2. 我们在这里使用 BuckleScript 特定的字符串插值语法以提高便利性:{j| ... $name ... |j}name必须在作用域内,并且它只能是一个名称,不能是像1 + 2这样的任意表达式。{j||j作为字符串分隔符,也可以用于多行和 Unicode 字符串字面量。

  3. 我们可以用符合其推断输入类型的任何值来调用该函数,pressA就是这样做的,因为我们确保了它的类型和我们的新函数具有相同的多态变体情况。

  4. 我们可以像往常一样直接使用let绑定进行模式匹配,但请注意,这里没有完全性警告。编译器现在不知道可能存在其他情况,因此它不会警告我们。因此,最好避免对多态变体值进行let绑定模式匹配。

理解多态变体推断

如果你已经在你的编辑器中启用了类型提示,它应该告诉你 eventToString 的类型如下:

[<
| *`accel*('a)
| *`clickTap*('b, 'c)
| *`deviceShake*('d)
| *`keypress*('e)
| *`pointerMove*('f, 'g, 'h, 'i)
] =>
string

编译器在打印变体情况时按字母顺序排列它们,但就像常规变体一样,情况没有任何内在的顺序。

这个函数类型分为两个主要部分:箭头左边的输入,和箭头右边的输出。输入是一个多态变体类型,它可以包含列出的任何情况或更少的情况,并且这些情况可以在给定位置有负载。注意,所有负载的类型看起来像 'x',即以撇号字符开头。正如我们之前提到的,这个字符意味着这个类型将在以后填充。具体来说,它被称为类型参数。

关于这个推断函数类型最重要的理解是,输入的多态变体被赋予一个上界(<),这意味着这个函数可以处理具有这些情况的变体多态变体类型,以及具有这些情况的子集的多态变体类型,但它肯定不能处理 更多 的情况。

现在,让我们探讨为什么所有负载类型都将稍后填充的原因。我们已经知道它们只是 int 和 float。为什么编译器不能推断出来?

结果表明,当我们使用 BuckleScript 的特殊字符串插值语法糖中的负载时,编译器从未被告知它们的确切类型。字符串插值只是让我们可以在 JavaScript 字符串中放入任何东西,而不需要像我们通常做的那样对编译器具体化,例如,使用字符串连接如 "Hello" ++ " world"。这对于生成输出非常方便,但可能导致陷阱,因为编译器无法推断类型;因此,了解这一点是好的。

总体而言,请注意我们实现的重要安全属性,这与对象的类型安全非常相似:我们可以在一定程度上安全地使用 adhoc 变体构造函数,并借助编译器的帮助。

从函数中输出值

如果我们想反方向操作呢?也就是说,从函数中输出多态变体值:

/* src/Ch05/Ch05_PolymorphicVariantOutputs.re */
let *stringToColour*(*string*) = switch (*string*) {
| "red" => *`red*
| "green" => *`green*
| "blue" => *`blue*
| _ => *`unknown* /* we need to handle the edge case */
};

这次,发生了两件有趣的事情:

  • 编译器对输入进行穷举检查,输入是一个字符串,因此我们需要处理 所有 可能的字符串输入(最后一个是一个通配符)

  • 编译器交换多态变体类型的界限,使得函数的类型(为):

string => [> *`blue* | *`green* | *`red* | *`unknown*]

这次,编译器为多态变体返回类型推断出一个 下界,因为它似乎函数返回这些情况,并且它可能还会返回更多,但它肯定永远不会返回 更少 的情况。

多态变体类型是 Reason 的一个深奥而强大的部分,我们将在接下来的章节中更深入地探讨它们。

泛化代数数据类型

泛化代数数据类型GADTs)是语言中另一个深奥而强大的领域。与多态变体类型一样,它们的大部分真正力量是在使用类型参数时展现出来的。但我们可以理解它们的表面语法以及它们与常规变体类型的关系,至少目前是这样。

GADTs,正如其名称所暗示的,是代数数据类型的一种泛化形式。这个名称有一点误导性,因为它们实际上是常规变体类型泛化形式的一种。代数数据类型是一个总称,包括产品类型和求和类型。在语法上,GADTs 看起来如下:

/* src/Ch05/Ch05_GADTs.re */
type poNumber = string;

type paymentMethod =
| *Cash*: paymentMethod
| *PurchaseOrder*(poNumber): paymentMethod; /* (1) */

let *paymentCash* = *Cash*; /* (2) */

let *paymentMessage*(*paymentMethod*) = switch (*paymentMethod*) {
| *Cash* => "Paid in cash" /* (3) */
| *PurchaseOrder*(*poNumber*) => {j|Paid with PO#$*poNumber*|j}
};
  1. GADT 定义语法看起来与常规变体定义语法非常相似,不同之处在于数据构造函数在末尾明确声明了它们的类型。对于单态类型(即没有类型参数的类型),这看起来不太有用。在下一章中,我们将看到为什么它如此强大。

  2. 值构造看起来与常规变体相同。

  3. 模式匹配看起来与常规变体相同。

使用类型进行正确性设计

现在我们已经看到了产品类型和求和类型在实际中的应用,让我们退一步思考它们如何协同工作。直观上,产品类型让我们可以将值组合在一起,而求和类型让我们可以从一个受限的值集中选择一个。结合起来,它们可以表达广泛的数据建模场景。让我们看看几个例子。

产品和求和类型一起

首先,一个简单的例子来热身。假设我们被要求为一个客户关系管理软件跟踪以下人员的属性:

  • 身份证号:一个字符串

  • 姓名:一个字符串

  • 教育水平:学校、学院、研究生或其他之一

一个有效的人员记录必须具有所有这些属性。注意我们如何使用特定的措辞“之一”和“所有”。这些给我们提供了关于如何建模数据的线索:“之一”意味着求和类型,“所有”意味着产品类型!在我们将数据结构分解为其组成部分时,以这些术语来考虑需求是非常有用的。

此后的主要选择是具体使用哪种产品类型和求和类型的实现。通常,我们会选择最简单的可能实现:记录和变体。因此,在这种情况下:

/* src/Ch05/Ch05_CrmPerson.re */
type education = *Ch05_Variants*.education =
| *School*
| *College*
| *Postgrad*
| *Other*;

type t = {*id*: int, *name*: string, education}; /* (1) */

/* (2) */
let *bob* = {*id*: 1, *name*: "Bob", *education*: *College*};
let *jim* = {*id*: 2, *name*: "Jim", *education*: *Other*};
  1. 人员记录类型 Ch05_CrmPerson.t 是由三种其他类型组成的记录类型:一个 int、一个 string 和一个 educationeducation 类型是一个变体类型,接受四种合法教育值中的任何一个。

  2. 有效的 t 值是通过将所有必需的组件值组合在一起构建的,包括 education 值。

注意值是如何通过遵守类型所规定的业务逻辑来构建的:对于一个人员记录,我们需要一个 ID、一个姓名和一个教育背景;对于一个教育值,我们只需要允许的值之一。

递归类型

现在,让我们看看一个更有趣的例子。假设我们需要模拟的不是一个人,而是一个人记录列表。假设我们还需要能够逐个遍历这个列表,并对每个记录的数据执行一些操作。这种情况下,一个自然的数据结构是单链表。正如你可能知道的,单链表是一系列节点,每个节点都指向列表中的下一个节点,或者在没有下一个节点的情况下指向空值(即列表的最后一个节点)。

在 Reason 中,模拟这个列表的自然方式是使用递归变体类型。递归类型是包含其自身类型值的类型。这个巧妙的方法之所以有效,是因为 Reason 内部将值的存储与其类型分开,因此它们不会最终占用无限存储。以下是我们的人记录列表场景的一个示例实现:

/* src/Ch05/Ch05_PersonList.re */
type t = *Node*(*Ch05_CrmPerson*.t, t) | *Empty*; /* (1) */

let *people* = *Ch05_CrmPerson*.(*Node*(*bob*, *Node*(*jim*, *Empty*))); /* (2) */

let rec *greet*(*t*) = switch (*t*) { /* (3) */
| *Node*(*person*, *list*) => { /* (4) */
    *print_endline*("Hello, " ++ *person.name* ++ "!");
    *greet*(*list*)
  }
| *Empty* => () /* (5) */
};

使用递归类型可以揭示一些新颖且有趣的语法:

  1. 递归类型的类型定义看起来与我们之前看到的没有不同,只不过它包含对其自身类型的引用。在 Reason 中,我们不需要做任何特别的事情,因为所有类型定义默认都是递归的。事实上,如果我们想使类型定义非递归(在某些情况下我们确实这样做),我们需要使用一个额外的关键字:type nonrec t = ...

  2. 我们可以像定义普通类型一样定义递归类型的值,只不过它可以包含其自身类型的其他值。我们局部打开Ch05_CrmPerson模块以获取对那里定义的人的值的访问权限。

  3. 要遍历递归类型的所有值,我们通常需要一个递归函数(即调用自己的函数)。与类型不同,函数默认不是递归的,因为传统上递归函数在运行时性能方面稍微昂贵一些。因此,当我们需要递归时,我们使用let rec ...语法来指定它。

  4. 我们可以像处理非递归值一样解构递归值,只需确保我们将它的递归部分list传递给递归的greet调用。

  5. 为了处理Ch05_PersonList.tEmpty)的非递归部分,我们只需不做任何事情,因此我们返回 unit,即空值

我们的递归greet函数有一个有趣的属性:它是尾递归的。尾递归是递归函数具有的属性,如果它在尾位置调用自己,即如果它在任何评估分支的最后一个(尾)操作中调用自己。例如,greet在其第一个分支中作为最后一个操作调用自己,处理Node变体情况。

我们关注这个尾递归属性,因为 BuckleScript 编译器具有将尾递归转换为输出 JavaScript 中的简单、高效循环的特殊能力。如果你检查输出,你会看到循环看起来几乎像是手写的。

摘要

在本章中,我们介绍了被称为求和类型(sum types)的总称:变体和模式匹配、多态变体以及 GADTs。这些是 Reason 中一些基本的类型技术,当正确组合使用时,它们能够实现强大的数据建模技术。

然而,越来越明显的是,还有很多类型的力量有待探索。在下一章中,我们将全面介绍到目前为止我们只是瞥见过的类型参数(泛型),以及使用不同类型的技巧。

第六章:创建可以插入任何其他类型的类型

在上一章中,我们看到了如何表达具有在运行时可能成为几种不同事物之一的潜在值的类型。在上一章的某些时候,以及到目前为止整本书中,我们遇到了 Reason 标记为稍后填充的类型。在本章中,我们将更详细地介绍这些类型,特别是以下主题:

  • Reason 的泛型类型推断

  • 什么是类型参数?

  • 常见的参数化类型,如列表、选项和数组

  • 向求和和乘积类型添加参数

  • 参数化可变类型的类型推断限制

类型推断和泛型类型

让我们看看 Reason 的类型推断的一些有趣示例以及它是如何决定需要稍后填充哪些类型的,如下面的代码片段所示:

/* src/Ch06/Ch06_GenericInference.re */
let *triple*(*x*) = (*x*, *x*, *x*); /* (1) */
let *wrap*(*x*) = `*wrap*(*x*); /* (2) */
let *makeObj*(*x*) = {as _; pub *x* = *x*}; /* (3) */
let *greet*(*x*) = *print_endline*({j|Hello, $*x*!|j}); /* (4) */

这些例子都有一个共同点:编译器并没有足够的信息来推断它们的具体类型。相反,它推断出它们的一般形状,但将一些部分留为泛型。我们可以观察到以下内容:

  1. 在(1)中,类型被推断为 `'a => ('a, 'a, 'a)'。

  2. 在(2)中,类型被推断为 'a => [> `wrap('a)]

  3. 在(3)中,类型被推断为'a => {. x: 'a}

  4. 在(4)中,类型被推断为 `'a => unit'。

在这些情况下,编译器推断出一些类型为 'a',换句话说,*我还不知道*。让我们看看第一个案例,triple`函数,以尝试理解原因。

triple中,函数参数是x,函数主体是(x, x, x)。考虑到这两个事实,编译器试图通过从函数参数和主体的每一部分向上工作来推断(即缩小)triple的类型。让我们看看我们可以从triple的每一部分推断出什么:

  • x参数:没有,因此我们将其类型标记为(一个泛型类型)'a

  • 从主体(x, x, x):我们已经将x的类型标记为'a,因此我们将主体的类型标记为('a, 'a, 'a),即由三个相同类型的元素组成的元组类型

  • 从整体函数:我们将参数标记为类型'a',并将主体标记为类型('a, 'a, 'a),因此我们推断整个函数的类型为'a => ('a, 'a, 'a)`。

注意,这似乎更像是一个排除过程,或者解决数独谜题的过程。我们尽可能多地根据我们所知道的信息缩小类型,直到我们不能再缩小为止。这正是我们从之前的章节中熟悉的推断和统一过程,但在泛型类型中,我们看到类型系统的新维度。

Reason 的类型推断过程是著名的,称为 Hindley-Milner (H-M) 类型推断 它是一种数学特定的方法,用于检查任何给定的表达式,以尝试推断最适合该表达式的最一般类型。我们不会深入理论,但我们将涵盖使用 H-M 类型推断的实际操作。

通过检查 triple 的类型推断过程,我们可以看到 wrapmakeObjgreet 的推断是如何工作的。理解的关键点是这些函数中的每一个都有一个不使用其输入参数任何特定属性的体表达式;相反,体表达式在一个更大的表达式中组合输入参数,以通用的方式使用值,而不暴露任何关于类型本身的信息。例如,想象一下被给了一个某些对象(你不知道是什么),然后立即把它放在一个盒子里;你仍然不知道这个对象是什么,你只是知道你现在有一个包含某个对象的盒子。这就是结构化类型通用类型推断的工作方式。

特殊情况插值

在我们提到的函数中,greet 稍微特殊,因为类型推断并不完全像其他函数那样工作。对于前三个函数,由于参数在结构化类型表达式中使用,推断达到了一个通用类型。然而,对于 greetx 参数被插入到一个字符串中;然而,字符串并不是结构化类型!正在发生的事情是,字符串插值是 BuckleScript 编译器提供的一个特殊逃生门,允许你将任何值转换为字符串,但仅当针对 JavaScript 时。

这里关键点是任何值转换为字符串。我们可以将这种行为视为一个函数,'a => string。由于我们随后使用 print_endline 打印字符串,最终的结果类型是 unit;因此,整体类型是 'a => unit

类型参数

我们已经看到类型检查通过一个算法过程来确定任何给定表达式的最一般类型,当表达式是结构化类型,例如 let pair(x) = (x, x),它可以推断出参数化类型(例如 'a => ('a, 'a)),因为它不知道,或者不需要知道它们的确切内容。

类型参数 是一个尚未指定的类型,将在稍后使用时指定。Reason 支持在所有类型上使用类型参数,包括命名类型。这为记录和变体类型提供了新的数据建模维度(字面上)。现在让我们探索一些最基本但也很重要的参数化数据类型。

列表——建模多个

我们已经在第五章中看到了如何建模人员记录的列表,在类型中放置替代值,但那个数据结构仅限于存储人员记录的值。理想情况下,我们希望有一个可以存储任何类型值的结构,这样我们就不必为每个可能的元素类型重新实现类型及其操作。我们可以通过将列表类型参数化为元素类型来实现这一点,如下所示:

/* src/Ch06/Ch06_List.re */
type list('a) = *Cons*('a, list('a)) | *Empty*; /* (1) */

/* (2) */
let *people* = *Ch04_RecordLiterals*.(*Cons*(*bob*, *Cons*(*jim*, *Cons*(*tom*, *Empty*))));

/* (3) */
let *greetOne*({*Ch04_RecordLiterals.id*, *name*}) = *print_endline*(
 {j|Hello, $*name* with ID $*id*!|j});

let rec *greetAll*(*people*) = switch (*people*) {
| *Cons*(*person*, *people*) => { /* (4) */
 *greetOne*(*person*);
 *greetAll*(*people*)
 }
| *Empty* => () /* (5) */
};

之前的例子展示了如何泛型地持有任何给定类型的对象,以及如何对它们进行特定操作。解释如下:

  1. 在这里,我们使用显式类型参数'a(发音为'alpha')对命名类型(确切地说,是一个变体类型)list进行参数化。这里我们只有一个,但类型可以有多个类型参数。声明类型参数的语法是type typeName('param1, 'param2, ..., 'paramN)。请注意,类型参数必须始终以撇号(')字符开头,以区分常规类型。类型参数也被称为类型变量。

  2. 我们构建一个由我们在之前模块中定义的person记录组成的列表。编译器可以推断其类型为list(Ch04_RecordLiterals.person),因为我们正好在声明类型参数'a的地方插入了人员类型。

  3. 我们定义了如何问候一个单独的人。这个操作在某种程度上没有使用类型参数,但它是一个构建后续操作的基础。

  4. greetAll内部,我们对people进行了一个有趣的模式匹配。通过Cons(person, people)Empty分支,编译器推断出people的类型为list('a),通过greetOne(person),它推断出'a = person;总体上,该函数的类型为list(person) => unit

  5. 当我们到达列表的末尾时,我们不想做任何事情,所以我们只返回()

现在我们已经看到了如何构建和操作多态数据类型,让我们看看 Reason 内置的list类型的实现。内置实现的工作方式与前面提到的类似,但 Reason 提供了一些语法糖,使得与列表一起工作更加容易。让我们看看下面的代码片段:

/* src/Ch06/Ch06_ReasonList.re */
let *people* = *Ch04_RecordLiterals*.[*bob*, *jim*, *tom*]; /* (1) */

let rec *greetAll*(*people*) = switch (*people*) {
| [*person*, ...*people*] => { /* (2) */
    *Ch06_List.greetOne*(*person*); /* (3) */
    *greetAll*(*people*)
  }
| [] => () /* (4) */
};

首先,请注意,我们已经去掉了类型声明,因为 Reason 的list('a)类型已经内置并且可以从每个模块中访问。更准确地说,它在Pervasives模块中定义,其内容默认可以从每个模块中访问。

  1. 我们使用列表构造的语法糖,即[elem1, elem2, ... elemN],来构造一个本质上类似于我们之前的列表Cons(elem1, Cons(elem2, ... Cons(elemN, Empty) ... ))。括号和逗号语法更简洁,更容易理解。

  2. 在列表上进行模式匹配现在看起来像[elem, ...restElems],以绑定第一个元素和剩余元素列表。...被称为扩展运算符,它被设计成类似于 JavaScript 的数组扩展功能,其工作方式类似。

  3. 我们重用了已经定义的greetOne函数,因为它不依赖于特定的列表类型——它只是问候一个人。

  4. 空列表模式现在看起来像[]而不是Empty

注意列表语法的简洁性。它是为日常使用设计的,因为列表是 Reason 中最重要的一种数据类型,在函数式编程中也是如此。

选项 - 模拟无或一个

list('a')类似,Pervasives模块也提供了一个数据类型,option('a')。这次让我们看看它的实际定义,因为它没有语法糖,如下所示:

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

在某些方面,这比列表是一个更简单的数据类型。它的真正效用来自于我们对变体情况赋予的意义:

  • Some('a'): 表示一个存在且已知的值

  • None: 表示一个不存在且未知的值

在 Reason 和一些其他语言中,没有 null 值的概念,所以这种选项类型用来表示一个值是存在还是不存在。当我们使用 null 时,我们可以使用选项,其好处是可选性(某个值可能存在或不存在)被捕获在类型系统中,而不是在幕后。由于option类型是一个变体,编译器帮助我们通过穷尽性检查来处理可能缺失的值。没有忘记处理 null 值并在运行时崩溃的风险。

存在和不存在可能仍然是一些模糊的概念,所以下面的代码是一个更具体的例子,其中我们尝试在列表中找到一个匹配的值:

/* src/Ch06/Ch06_Option.re */
let rec *tryFind*(*needle*, *haystack*) = switch (*haystack*) { /* (1) */
| [*item*, ...*_items*] when *needle*(*item*) => *Some*(*item*) /* (2) */
| [*_item*, ...*items*] => *tryFind*(*needle*, *items*) /* (3) */
| [] => *None* /* (4) */
};

let *optionallyGreet*(*person*) = switch (*person*) { /* (5) */
| *Some*(*person*) => *Ch06_List.greetOne*(*person*)
| *None* => *print_endline*("No such person!")
};

let *idEq1*({*Ch04_RecordLiterals.id*}) = *id* == 1; /* (6) */
let *idEq4*({*Ch04_RecordLiterals.id*}) = *id* == 4;

*optionallyGreet*(*tryFind*(*idEq1*, *Ch06_ReasonList.people*)); /* (7) */
*optionallyGreet*(*tryFind*(*idEq4*, *Ch06_ReasonList.people*));

在这个例子中,我们定义了如何在列表中查找一个项目,并安全地处理缺失项的情况:

  1. 我们传递一个列表去搜索(称为haystack)和一个测试函数(称为needle),该函数告诉我们是否找到了我们正在寻找的值。由于haystack是一个列表,我们可以在它上面进行模式匹配。

  2. 第一个模式检查列表的第一个元素是否是我们想要的,这是由needle决定的。我们不想绑定其余的元素,所以我们在_items名称前加上下划线来告诉 Reason 忽略它。注意,我们在这里使用的是when子句,正如在第五章中介绍的,在类型中放置替代值。这相当于在分支体中使用if表达式,但更简洁。如果第一个元素与针匹配,我们将其放入Some构造函数中并对其进行评估。

  3. 在第二种模式中,我们匹配忽略第一个项目后的剩余项目列表,并递归地尝试在该列表中找到我们想要的元素。但只有当第一个分支不匹配时,才会到达这个分支,这意味着元素不是列表中的第一个项目。这个分支本身评估为 tryFind 的结果,意味着 Some(person)None

  4. 在最终分支中,我们必须处理 haystack 列表的另一种可能状态:为空。如果是这样,我们可能开始时就是一个空列表,或者通过递归最终变成了一个空列表。在任何情况下,我们都没有在列表中找到我们想要的元素,所以我们返回 None,在这种情况下意味着未找到*。

  5. 这里,我们描述了如何问候可能或可能不在那里的人。编译器强制处理 Some(person)None 两种情况——我们不能忘记处理缺失的值。

  6. 我们定义了两个不同的 needle 函数,它们测试给定的人的记录是否有 ID 1 或 4。

  7. 这里是回报:我们可以选择性地问候一个人,但仅在我们找到了具有给定 ID 的人在我们的 people 列表中。我们可以运行以下输出脚本来查看会发生什么:

$ node src/Ch06/Ch06_Option.bs.js 
Hello, Bob with ID 1!
No such person!

如预期的那样,我们找到了并问候了 ID 1 的 (Bob) 人,但没有问候 ID 4 的人,因为没有这样的人。

在输出 JavaScript 中,BuckleScript 再次将尾递归的 tryFind 函数转换为一个简单的命令式循环。

这里,我们看到使用 option 类型的两个方面:我们可能需要在操作过程中表示值的出现或缺失,如果我们使用像 option 这样的变体类型,我们就能得到其详尽性检查的好处。

可变参数化类型 – ref 和 array

Reason 还提供了两种重要的参数化类型,允许它们的值在原地被修改。这种可变性为某些类型的算法带来了效率提升,但通常需要谨慎使用,因为,正如我们将看到的,它可能是一个错误来源。

管理对值的引用

我们已经在第四章的 Mutable record fields 部分,Grouping Values Together in Types 中看到了一个可变性的例子。有时,我们只需要管理一个或两个可变值,我们可能不想通过声明一个新的具有可变字段的记录类型来进行仪式。在这些情况下,我们可以利用内置的 ref 类型。ref 类型本质上给我们一个盒子,一个 ref,它让我们可以交换值。值本身不需要是可变的,只需要盒子本身:

type ref('a) = {mutable *contents*: 'a}; /* (1) */
let *ref*: 'a => ref('a); /* (2) */
let (:=): (ref('a), 'a) => unit; /* (3) */
let (^): ref('a) => 'a; /* (4) */
let *incr*: ref(int) => unit; /* (5) */
let *decr*: ref(int) => unit;

之前的代码示例显示了 ref 类型的完整 API,解释如下:

  1. 它被实现为一个只有一个可变记录字段的记录类型,但这个字段由类型参数 'a' 参数化,让我们可以为其任何类型重用它。

  2. 我们可以使用 ref 函数将任何类型的值装箱,并将其放入一个 ref 中:let count = ref(0);

  3. 赋值运算符 (:=) 实现为一个函数,并且可以用作中缀位置:count := 1;

  4. 取消引用运算符 (^) 也实现为一个函数,但 Reason 允许我们在后缀位置使用它:let countVal = count^;

  5. incrdecr 是用于增加和减少整数的便利函数,因为我们经常需要更新计数。

使用 ref 类型来管理突变的好处是可变性在类型级别上被捕获。我们可以从查看任何包含 ref(something) 的类型签名中看出,something 类型的某个东西正在改变。相比之下,当我们直接使用可变记录字段时,我们没有可变性的类型级别指示,只有记录定义本身。

让我们看看使用 ref 的一个示例:重新定义 tryGreet 函数以使用更命令式的风格遍历其输入列表并尝试找到所需的项目。我们需要三个 ref:列表的剩余部分、是否应该停止搜索,以及一个可选的找到的项目。只要我们还没有找到项目,我们就会继续搜索,但一旦找到,我们就会返回它:

/* src/Ch06/Ch06_Ref.re */
let *tryFind*(*needle*, *haystack*) = {
  let *currHaystack* = *ref*(*haystack*);
  let *stop* = *ref*(*false*);
  let *currItem* = *ref*(*None*);

  while (!(*stop*^)) { /* (1) */
    switch (*currHaystack*^) { /* (2) */
    | [*item*, ...*_items*] when *needle*(*item*) => { /* (3) */
        *stop* := *true*;
        *currItem* := *Some*(*item*)
      }
    | [*_item*, ...*items*] => *currHaystack* := *items* /* (4) */
    | [] => *stop* := *true* /* (5) */
    };
  };

  *currItem*^ /* (6) */
};

在上一个示例中,我们使用了 Reason 的命令式特性(突变和循环),如下所述:

  1. 我们会持续循环,直到我们明确表示应该停止。注意,命令式的 while 循环看起来就像我们从其他命令式语言(如 JavaScript)中期望的那样。

  2. 在输入 haystack list 上的模式匹配仍然是遍历它的最方便的方式,所以我们在这里继续这样做。

  3. 如果我们找到了我们正在寻找的项目,我们需要确保我们将停止指示符设置为 true,并且设置找到的项目,以便我们稍后可以返回它。

  4. 如果我们还没有找到它,但列表不为空,将当前 haystack 设置为列表的剩余部分。

  5. 如果列表为空,显然我们没有找到项目,因此我们需要停止。

  6. 无论我们迄今为止找到了什么(或者没有找到什么),我们最终都需要取消引用并返回它。

这个命令式版本首先要注意的是它更冗长。跟踪可变状态在代码层面上涉及一些仪式。这并不是说 Reason 的命令式风格特别繁琐;在任何命令式语言中看起来都差不多。只是函数式风格,特别是递归风格,通常更简洁,因为递归调用实际上跟踪了当前状态,所以我们不需要。

第二个要注意的是,我们保留了相同的函数签名(('a => bool, list('a)) => option('a))来实现这个版本,只是没有使用递归。如果我们需要,我们可以用递归实现替换这个实现,而我们的客户端代码不需要重新编译就可以使用它。这是许多静态类型系统的特性:如果我们保留类型签名,我们可以自由地替换实现。特别是 Reason 的类型系统使用签名来决定整个模块是否兼容。这有助于我们在构建时早期定位兼容性问题,而不是在运行代码时发现不兼容性。

管理值数组

有时,我们需要高效地管理和更改相同类型的大量值。Reason 提供了 array 类型来帮助处理这个问题。我们可以将数组想象成一条由相同大小的盒子组成的连续线,每个盒子都可以存储相同类型的值。形式上,它是一个多态类型,如下所示:

type array('a);

array 数据结构在特征上与其他语言中可能看到的结构相似,其中:

  • 它允许随机访问其元素。

  • 它不允许像 list 使用展开操作([item, ...items])那样递归遍历元素。

然而,它确实允许对其元素进行基本的模式匹配。以下是其使用的一个示例:

/* src/Ch06/Ch06_Array.re */
let *empty* = [||]; /* (1) */
let *singleton* = [|1|];
let *multi* = [|*false*, *true*, *true*|];

*multi*[1] = *false*; /* (2) */
Js.log(*multi*[1]); /* (3) */

在前面的例子中,我们看到以下简单的数组使用:

  1. 我们可以创建一个空数组,以及包含一个或多个元素的数组。数组分隔符是 [||],并且用于区分它们与在 Reason 中更频繁使用的列表。

  2. 我们可以分配到数组中的任何有效索引(分配到越界索引会导致运行时异常)。

  3. 我们可以读取任何有效索引处的值(从越界索引读取也会导致运行时异常)。

对于数组,索引的概念很重要,正如我们在这里所看到的。基于索引的随机访问是常数时间的,但作为交换,我们必须注意只访问有效的索引。

为了更好地理解数组的有用性,让我们尝试使用一个数组和几个更新和检查棋盘的函数来实现井字棋棋盘,如下所示:

/* src/Ch06/Ch06_TicTacToe.re */

/* Each slot on the board can be taken by X or O, or it can be empty. */
type slot = *X* | *O* | *Empty*;

let *newBoard*() = *Array.make*(9, *Empty*); /* (1) */

/* Coords are as follows on the board:
   1 2 3
   4 5 6
   7 8 9 */
let *play*(*player*, *coord*, *board*) = *board*[*coord* - 1] = *player*; /* (2) */

let *xWon*(*board*) = switch (*board*) {
| [|*X*, *X*, *X*, /* (3) */
    _, _, _,
    _, _, _|]
| [|_, _, _, /* (4) */
    *X*, *X*, *X*,
    _, _, _|]
| [|_, _, _,
    _, _, _,
    *X*, *X*, *X*|]
| [|*X*, _, _,
    *X*, _, _,
    *X*, _, _|]
| [|_, *X*, _,
    _, *X*, _,
    _, *X*, _|]
| [|_, _, *X*,
    _, _, *X*,
    _, _, *X*|]
| [|*X*, _, _,
    _, *X*, _,
    _, _, *X*|]
| [|_, _, *X*,
    _, *X*, _,
    *X*, _, _|] => *true* /* (5) */
| _ => *false* /* (6) */
};

这是一个设计数组的有趣示例,以便我们可以对其进行字面意义上的模式匹配,如下所述:

  1. 我们可以通过创建一个新的数组并使用 Array.make 库函数填充 Empty 槽来创建一个新的游戏棋盘。这个函数为我们提供了一个所需长度的数组,并且所有索引都填充了单个值。

  2. 由于我们接受一个一维的棋盘坐标,我们只需简单地减去一将其转换为零索引的数组索引。

  3. 我们可以针对数组的精确结构进行模式匹配。在这种情况下,我们的九元素数组如果将其分成三行纯粹是为了展示,可以看起来就像一个井字棋棋盘。

  4. 我们可以使用 or 模式来捕获玩家 X 获胜的所有情况。

  5. 对于所有这些返回 true

  6. 否则,我们返回false

在这种情况下,当玩家移动时,我们可以轻松地设置棋盘上的任何位置,这符合数组的随机访问能力。相比之下,在列表中设置随机位置将是一个低效的操作,因为唯一的方法是遍历列表的每个元素,然后执行多次列表拆分和连接操作,直到创建最终的输出列表。

通常,当我们需要对多个元素进行频繁更新时,数组非常有用。常见场景是像素缓冲区和手动管理的内存区域。幸运的是,Reason 使这些场景相对容易实现。

变异和类型推断限制

通常,编译器会为我们推断各种类型,但它确实有一些限制。有时,我们需要给它一点提示,以帮助它得到正确的推断结果,例如。我们需要注意的主要情况被称为值限制。值限制基本上意味着可变值不能是泛型的,编译器必须完全知道它们的类型。以下是在文件src/Ch06/Ch06_ValueRestrictionError.re中取消注释代码时你会得到的错误示例:

(Output from bsb -w)
  We've found a bug for you!
  src/Ch06/Ch06_ValueRestrictionError.re 2:17-24

  1 │ /* src/Ch06/Ch06_ValueRestrictionError.re */
  2 │ let optionArr = [|None|];
  3 │ let optionRef = ref(None);

  This expression's type contains type variables that can't be generalized:
  array(option('_a))

  This happens when the type system senses there's a mutation/side-effect, in combination with a polymorphic value.
  Using or annotating that value usually solves it. More info:
  https://realworldocaml.org/v1/en/html/imperative-programming-1.html#side-effects-and-weak-polymorphism

注意optionArr: array(option('_a'))的类型推断。类型变量名前的下划线前缀是编译器表示遇到了值限制的方式。名为'_a'的类型变量被称为弱类型变量(在这个意义上,它的实际类型可能会以后改变)。

类型在编译时推断后可能会改变,这是一个坏主意。例如,让我们考虑如果编译器推断类型为array(option('a'))会发生什么。在代码的后续部分,我们可以将该索引设置为Some(1),然后稍后将其设置为Some(false)。这将完全破坏类型系统,并使我们无法在任何代码点确定确切的类型。编译器设计者决定防止这种情况发生。这只是他们多年来为防止运行时类型错误渗入程序而做出的类型安全性决策之一。

作为有效代码,我们可以使用array(option(string))精确类型,正如你在替代代码文件(src/Ch06/Ch06_ValueRestrictionErrorFixed.re)中看到的那样,该文件可以正确编译。其代码如下:

/* src/Ch06/Ch06_ValueRestrictionErrorFixed.re */
let optionArr: array(option(string)) = [|None|];
let optionRef: ref(option(string)) = ref(None);

让我们看看另一个值限制错误。再次,取消注释文件src/Ch06/Ch06_ValueRestrictionOtherError.re中的代码,你将得到以下编译错误:

(Output from bsb -w)
We've found a bug for you!
  src/Ch06/Ch06_ValueRestrictionOtherError.re 5:15-28

  1 │ /* src/Ch06/Ch06_ValueRestrictionOtherError.re */
  2 │ let pair(x) = (x, x);
  3 │ let pairAll = List.map(pair);

  This expression's type contains type variables that can't be generalized:
  list('_a) => list(('_a, '_a))

  This happens when the type system senses there's a mutation/side-effect, in combination with a polymorphic value.
  Using or annotating that value usually solves it. More info:
  https://realworldocaml.org/v1/en/html/imperative-programming-1.html#side-effects-and-weak-polymorphism

这个问题稍微有点棘手,因为没有明显的突变。pairAll 函数的目的是将项目列表转换为这些项目的对(二元组)列表。问题是 pair 函数是通用的;编译器无法确定它是否可能会修改任何内容。如果我们有一个单态(即非通用)函数,比如 let pair(x) = (x + 1, x - 1);,那么编译器就能确定输入和输出只是 int,并且没有涉及任何突变。

然而,还有另一种解决这个特定错误的方法;记住,它被称为值限制。换句话说,只有值受到这种限制。如果我们将 pairAll 扩展为一个显式的函数,那么错误就会消失,如下所示:

let *pairAll*(*list*) = *List.map*(*pair*, *list*);

我们固定的代码可以在 src/Ch06/Ch06_ValueRestrictionErrorFixed.re 中找到。

这个有趣的方法让编译器相信,是的,这确实是一个函数,因此值限制不适用。

强制与幻影类型的不同

因为我们可以声明可以插入任何类型参数的类型,包括类型实际上没有使用的类型参数。这些被称为幻影类型参数,或者更非正式地称为幻影类型

幻影类型的一个常见用途是在一种类型安全的构建器模式中。(构建器模式是一段代码,帮助我们根据特定规则构建对象。)例如,我们可能想要构建语法上有效的 SQL 语句。做到这一点的一种方法是有这样一个验证函数,它接受一个输入 SQL 语句,并在运行时决定它是否遵循 SQL 语法规则。这个函数可能会尝试解析输入语句并构建一个表达式树。如果树可以构建,则语句有效。否则,它无效。

另一种方法是提供一组函数,这些函数静态地强制只创建语法上有效的语句。这个方法的神奇之处在于,我们可以在类型体实际上没有使用类型参数时,告诉编译器类型参数应该是什么。类型定义中没有与我们的说法相矛盾的内容,因此编译器必须接受它。

以下是一个简化的示例:

/* src/Ch06/Ch06_PhantomTypes.re */

module *Sql*: {
  type column = string; /* (1) */
  type table = string;
  type t('a); /* (2) */

  let *select*: list(column) => t([`*select*]); /* (3) */
  let *from*: (table, t([`*select*])) => t([`*ok*]);
  let *print*: t([`*ok*]) => string;
} = {
  type column = string;
  type table = string;
  type t('a) = string;

  let *select*(*columns*) = { /* (4) */
    let *commalist* = *String.concat*(", ", *columns*);
    {j|select $*commalist*|j}
  };

  let *from*(*table*, *t*) = {j|$*t* from $*table*|j};
  let *print*(*t*) = *t*; /* (5) */
};

let *sql* = *Sql*.(*select*(["name"]) |> *from*("employees") |> *print*); /* (6) */
Js.log(*sql*);

为了简化,我们在这个 SQL 构建模块中只处理 selectfrom 子句,具体解释如下:

  1. 我们将几个类型别名为文档用途。

  2. 这个类型具有幻影类型参数;对于模块消费者来说,它看起来像是一个正常的参数化类型。内部,它不包含或以其他方式使用其参数类型的任何值。

  3. 这个函数是构建的入口点:它接受一个列列表,并返回一个部分构建的 SQL 语句。我们无法对这个返回值做任何事情,除了将其输入到下一个函数 from 中。注意,类型参数实际上是适当命名的多态变体的类型;它们只是作为标签。

  4. selectfrom 的实现非常简单:它们只是构建形式为语法有效 SQL 语句的正常字符串。最有趣的是,它们的类型被强制接受参数,这样它们就只能以特定的顺序调用:selectfromprint

  5. print 函数非常简单,它只是返回构建的字符串。我们可以检查它,然后将其传递给 SQL 引擎运行。

  6. 我们通过按正确的顺序调用函数,并由类型系统强制执行,构建一个语法有效的 SQL 语句,然后将它们输出到终端。请注意,|> 操作符被称为 pipe-forward,它用于将一个函数的输出作为下一个函数的输入。我们将在下一章中介绍常见的操作符。

以下代码是如果我们尝试打印一个无效的 SQL 语句时可能会得到的错误:

(Output from bsb -w)
We've found a bug for you!
  src/Ch06/Ch06_PhantomTypes.re 25:36-40

  23 │ };
  24 │ 
  25 │ let sql = Sql.(select(["name"]) |> print); /* (6) */
  26 │ Js.log(sql);

  This has type:
    Sql.t([ `ok ]) => string
  But somewhere wanted:
    Sql.t([ `select ]) => 'a
  These two variant types have no intersection

这种类型错误表示 print 函数期望一个完整的 ok SQL 语句,但只收到了一个 select 子句。类型参数与模块的函数一起工作,确保 SQL 以正确的方式构建。

摘要

在本章中,我们深入探讨了 Reason 的参数化类型,学习了类型参数以及它们如何扩展类型以成为泛型,我们在 Reason 中使用的常见参数化类型,编译器对使用参数化类型和突变的同时的限制,以及如何使用幻影类型参数强制相同的底层类型对编译器看起来不同。

我们还看到了一些将函数作为参数传递给其他函数的例子,例如 tryFindList.map。在下一章中,我们将彻底介绍函数以及 Reason 如何让我们将它们作为一等对象处理,这样我们就可以在代码中传递它们,使代码的行为更加灵活。

第七章:构建表示操作的类型

在前面的章节中,我们看到了如何构建类型来模拟多种类型的数据。在所有这些章节中,我们都依赖于函数。函数封装计算和操作以便于重用,因此它们是任何编程语言中最常用的功能之一。因此,通过利用 Reason 的类型系统和函数式编程技术,我们可以设计出最有效的函数。

在本章中,我们将涵盖以下主题:

  • 有意义的函数类型和有用的属性

  • 柯里化和部分应用

  • 高阶函数

  • 使用函数来控制依赖顺序和程序流程

  • 常用函数和运算符

但首先,什么是函数?在类型理论和数学中,函数有一个正式的定义,但我们可以将其视为给定输入计算输出的公式。在 Reason 和其他静态类型函数式编程语言中,函数总是有输出的,即使它们实际上没有进行任何计算。我们将研究如何表达这些输入和输出,但首先我们需要对函数类型和属性有一个基本了解。

函数类型和其他有用的属性

在 Reason 中,函数具有非常具体的类型,并且就像其他值一样,不同类型的函数不能相互替换。

每个 Reason 函数的基本类型如下:

*a* => b

将其读作a -> b

如您所见,输入a和输出b可以是任何类型(甚至是相同的类型)。这种基本函数类型,具有单个输入和单个输出,导致了 Reason 中所有其他函数类型。我们将在稍后讨论这是如何发生的,但首先让我们谈谈几个在类型驱动世界中非常重要的有用函数式编程概念。

引用透明性

第一个属性被称为引用透明性(或RT),这意味着对于给定的输入a,函数将始终产生相同的输出b,无论我们如何、何时或多少次调用它。这意味着函数不能表现出不可预测的行为;我们必须能够预测每个输入的输出,就像一个数学公式一样。

例如,以下是一个非引用透明函数:

/* [xDaysAgo(x)] returns the time [x] days before now, in ms since Unix
    epoch. */
let *xDaysAgo*(*x*) =
 * Js.Date.now*() -. *float_of_int*(*x*) *. 24\. *. 60\. *. 60\. *. 1_000.;

在 Reason 中,浮点算术运算符与整数运算符不同(它们以点作为后缀)。Reason 试图在算术和转换方面尽可能明确,以便我们可以避免意外结果。

我们无法预测任何给定输入x的输出,因为这取决于函数被调用的日期和时间。问题在于对当前日期或时间的隐藏依赖。一个解决方案是通过将依赖项作为函数参数传递来消除依赖,如下所示:

/* [xDaysAgo(now, x)] returns the time [x] days before [now], in ms
    since Unix epoch. */
let *xDaysAgo*(*now*, *x*) =
  *now* -. *float_of_int*(*x*) *. 24\. *. 60\. *. 60\. *. 1_000.;

立即的好处是函数更容易测试,但更大的好处是,代码库中这样的函数使得推理更容易*。

关于代码的推理(也称为等式推理)意味着能够用实际值替换函数参数,就像一个数学方程式一样,通过简化来评估结果。这听起来像是一个微不足道的优势,但当在一个代码库中使用时,它可以是一个确保透明性的强大技术。

实际上讲,我们无法使整个代码库具有引用透明性(除非我们使用诸如效果类型等高级技术)。然而,我们可以将非 RT 操作推到程序的边缘。例如,我们可以用Js.Date.now()的调用结果或从其他地方传入的日期值调用(第二个)xDaysAgo函数。这是一种简单但有效的依赖注入形式(向程序传递值而不是让程序自己尝试获取值)。我们将在本章的后面部分更详细地介绍依赖注入。

函数纯净性

我们试图实现的第二个重要属性是纯净性。这个概念意味着,对于调用者(即调用它的代码)和外部世界来说,一个函数除了评估其结果外没有其他影响。我们说函数没有任何可观察的效果。可观察性在这里是关键;可能确实有在函数内部发生和包含的效果(如突变),但调用者不知道,也不能知道它们。以下是一个纯函数的例子,它在内部发生突变但不可观察:

let *sum*(*numbers*) = {
  let *result* = *ref*(0);
  for (*i* in 0 to *Array.length*(*numbers*) - 1) {
    *result* := *result*^ + *numbers*[*i*];
  };
  *result*^
};

如果我们在for循环体中添加一个Js.log(result^),函数就会变得不纯,因为它的效果会变得可观察。人们有时对“可观察”的确切含义存在分歧,尤其是在记录其他方面纯净函数的操作时,但出于谨慎,我们可以接受任何可观察的效果都是函数中的杂质(而且这没关系,因为有时我们确实需要那些可观察的效果)。

完全性

我们希望函数拥有的最后一个重要属性是完全性。这意味着函数应该处理它们接受的类型的每个可能值,这实际上比看起来要复杂得多!例如,再次看看xDaysAgo函数。如果x是负数怎么办?或者非常大或非常小?我们是否考虑了整数溢出?尤其是在处理数字时,我们需要了解它们在我们运行的平台的属性。

在我们的情况下,我们运行在 JavaScript 平台(如 Node.js)上,所以所有数字都是内部表示为 IEEE 浮点数(这就是 JavaScript 的工作方式),我们可以在需要担心溢出之前走得很远。但考虑以下一个简单的函数:

let *sendMoney*(*from*: string, *to_*: string, *amount*: float) = Js.log(
  {j|Send \$$*amount* from $*from* to $*to_*|j});

第一个美元符号需要转义,否则编译器会尝试将其视为开始一个插值的标志。

在这里,我们只是打印出我们想要发生的事情。在实际应用中,我们可能想要进行货币转账。假设我们通过 HTTP 服务调用公开了这个函数。如果有人用负浮点数调用服务会发生什么?最好的情况是错误会在其他地方被捕获;最坏的情况是人们可能调用服务从别人的账户中吸走资金。

解决这个问题的方法之一是在函数的开始处验证我们的参数,如下所示:

let *sendMoney*(*from*, *to_*, *amount*) = {
  assert(*from* != "");
  assert(*to_* != "");
  assert(*amount* > 0.);
  *Js.log*({j|Send \$$*amount* from $*from* to $*to_*|j});
};

为了确保万无一失,在这个片段中,我们对发送者和接收者字符串进行了一些基本的验证。我们还能够去除类型注解,因为断言将导致它们被正确推断。

assert是一个内置关键字,尽管它看起来和像一个函数工作。

从函数的角度来看,内部现在是一个完整的函数,因为它明确地错误处理了它不想处理的案例,但处理了剩余的愉快路径。然而,对于外界来说,函数仍然接受原始字符串和浮点数,并且未能处理大多数情况。一个更好的解决方案是使用更约束的类型来精确描述函数可以接受的内容,如下所示:

/* src/Ch07/Ch07_DomainTypes.re */
module *NonEmptyString*: { /* (1) */
 type t = pri string; /* (2) */
 let *makeExn*: string => t;
} = {
 type t = string;
 let *makeExn*(*string*) = { assert(*string* != ""); *string* };
};

module *PositiveFloat*: { /* (3) */
 type t = pri float;
 let *makeExn*: float => t;
} = {
 type t = float;
 let *makeExn*(*float*) = { assert(*float* > 0.); *float* };
 let *toFloat*(*t*) = *t*;
};

let *sendMoney*( /* (4) */
 *from*: *NonEmptyString*.t,
 *to_*: *NonEmptyString*.t,
 *amount*: *PositiveFloat*.t) = {

 let *from* = (*from* :> string); /* (5) */
 let *to_* = (*to_* :> string);
 let *amount* = (*amount* :> float);
 *Js.log*({j|Send \$$*amount* from $*from* to $*to_*|j});
};

*sendMoney*( /* (6) */
 *NonEmptyString.makeExn*("Alice"),
 *NonEmptyString.makeExn*("Bob"),
 *PositiveFloat.makeExn*(32.));

我们不得不使用to_作为参数名而不是to,因为在 Reason 中to是一个保留关键字。如果我们想将其用作名称,通常会在关键字前添加一个下划线。

这个片段看起来更冗长,但从长远来看,这是一个更好的解决方案,因为我们可以在隔离的情况下为包装类型及其模块编写测试,确保类型确实执行了我们的规则,并且可以重用这些类型,而不是在整个代码库中添加检查。以下是它是如何工作的:

  1. 我们设置了一个只能是非空字符串的类型。如果调用者尝试构造一个空字符串的类型,这将引发异常。

  2. 类型声明表明这是一个private类型,这意味着我们暴露了其内部表示,但不允许用户构造该类型的值。当我们想半透明地取一个现有的类型并对其进行某种限制时,这是一种有用的技术。我们很快就会看到如何做到这一点。

  3. 类似地,我们设置了一个只能有正浮点数值的类型。

  4. sendMoney函数中,我们通过只接受这些约束类型而不是它们的原始变体来利用这些类型的好处。现在函数是完整的,因为它根据(类型)定义只接受它处理的精确值。

  5. 我们仍然需要解包约束值以获取原始值,因为我们最终想要打印原始值。尽管类型被声明为 private,但我们可以将它们强制回更通用的版本。强制意味着将约束类型(如 NonEmptyString.t)的值强制转换为更通用的类型(如 string)。强制是完全静态的;如果我们不能强制转换某个值,我们将得到编译错误。请注意,强制转换的语法需要相当精确,并且需要包括括号。

  6. 在将值传递给函数之前,我们还需要包装这些值。这是可能失败的地方,因此我们将它移出了我们的函数实现。

在这里,我们使用了在可能抛出异常的函数名称中添加 Exn 的约定。有些人更喜欢返回可选值而不是抛出异常。这个约定是惯用的且类型安全的,但最终只是报告错误的一种方法。关键点是,任何可能的失败都已被移出我们的总 sendMoney 函数以及其他使用约束类型的函数。

函数类型意味着什么

在类型驱动开发的背景下,为什么引用透明性、纯洁性和完备性等函数式编程概念很重要?原因是函数的类型有一个被充分理解的数学意义,违反这些规则会模糊这个意义。

类似于 a => b 的函数类型意味着这种类型的函数将接受类型为 a 的输入并评估为类型为 b 的结果,并且不会做其他任何事情(例如,打印日志、启动咖啡机或发射导弹)。我们非常喜欢这种保证,就像我们喜欢知道 int 只是一个 int,而不是导弹发射后跟一个 int 一样。

事实是,Reason 允许副作用是一个伟大的实用决策,但我们仍然可以努力将副作用推到我们程序的边缘,并保持其核心纯粹是函数式的。在函数式意义上,纯洁性对于函数类型的准确性是必要的。如果我们程序中的类型是准确的,我们就可以更有信心地进行类型驱动开发。

多个参数和柯里化

我们已经提到,Reason 函数始终接受单个参数并返回单个值,但我们一直愉快地使用看起来接受多个参数的函数,例如 xDaysAgo(now, x)。这是如何可能的?

在 Reason 中,具有多个参数的函数会自动柯里化。这意味着它们实际上是接受单个参数并返回一个新的函数,该函数接受下一个参数,依此类推,形成一个单参数函数的链,最终返回一个结果。这看起来可能效率不高,但在实践中,编译器几乎总能优化调用链为单个高效的调用。让我们看看以下具体的例子,定义xDaysAgo

let *xDaysAgo*(*now*, *x*) = ...;

此语法等同于以下(这由 Reason 代码格式化工具支持,因此通常在野外看到):

let *xDaysAgo* = (*now*, *x*) => ...;

接下来,我们有以下内容:

let *xDaysAgo* = *now* => *x* => ...;

类似地,我们可以调用以下函数:

let *result* = *xDaysAgo*(*now*, *x*);

这等同于以下语法:

let *result* = *xDaysAgo*(*now*)(*x*);

编译器理解这个语法是一个完全应用的函数调用,并相应地进行优化。

有时候,一个调用不是完全应用的。换句话说,它是部分应用的。这意味着它只使用了它接受的某些参数。一个部分应用的函数只是接受一个或多个参数,但根据定义,比原始函数少的参数。让我们看看以下简单的例子:

let *xDaysBeforeNow* = *xDaysAgo*(*Js.Date.now*());
let *result* = *xDaysBeforeNow*(10);

此示例通过调用适当的 JavaScript 日期函数并随后将其注入到xDaysAgo函数中,以获取一个预先加载了当前时间的新的函数来捕捉当前时刻。然后,这个新函数被绑定到名称xDaysBeforeNow并调用以获取结果。结果将是确定的;换句话说,对于给定的输入,我们总是会得到相同的输出。原因是非确定性的数据已经输入到函数中,并作为静态值捕获在函数内部。换句话说,xDaysBeforeNow也是引用透明的。

根据规则,我们从引用透明函数的应用中获得的函数(如xDaysAgo)也是引用透明的。这个规则同样适用于其他函数特性:纯净性和完备性。这在我们从更一般的函数构建专用函数时非常有帮助,因为我们可以从自信开始,并在每一步保持这种自信。

现在,让我们看看另一个既有趣又展示了其有用性的部分应用示例。

在以下示例中,我们定义了一个函数,该函数返回或打印用于邮寄信件的信封标签,使用收件人的姓名、邮政地址等。此函数可以定义为如下:

let printEnveloppeLabel = (~firstname: string, ~lastname: string, ~address: string, ~country: string)
  : unit => {
    print_newline();
    print_endline(firstname ++ " " ++ lastname);
    print_endline(address);
    print_endline(country);
};

我们可以像下面这样以正常方式调用该函数,使用所有参数:

printEnveloppeLabel("John", "Doe", "Some address in the US", "USA");

我们还可以定义另一个用于部分应用的函数,为国家参数传递一个值,如下所示:

let printEnveloppeLabelUS = printEnveloppeLabel(~country="USA");
printEnveloppeLabelUS("John", "Doe", "Some address in the US");

我们还可以为lastname参数传递一个值,为打印同一家庭成员标签的函数,如下所示:

let printEnveloppeLabelDoeFamily = printEnveloppeLabelUS(~lastname="Doe", ~address="Some address in the US");
printEnveloppeLabelDoeFamily(~firstname="Jane");

此示例(src/Ch07/Ch07_Currying.re)编译后生成的 JS 执行结果如下:

John Doe
Some address in the US
USA

John Doe
Some address in the US
USA

Jane Doe
Some address in the US
USA

函数作为值

在上一节中,我们介绍了如何通过将函数定义语法上 去糖化(即使用稍微繁琐的语法)成一系列链式函数值。让我们来探讨函数在 Reason 中实际上是第一类值这一观点,就像数字、字符串、记录等一样。

函数字面量语法

Reason 为所谓的 函数字面量 提供了强大的支持,也称为 lambda闭包。这意味着,就像在 JavaScript 和其他各种语言中一样,我们可以在任何可以写下任何值的地方直接写下函数值。以下是一个函数字面量的基本语法:

PATTERN => body

以下是为编写柯里化函数的语法:

PATTERN1 => PATTERN2 => ... => PATTERNn => body

Reason 为编写柯里化函数提供了一个看起来熟悉的语法糖,如下所示:

(PATTERN1, PATTERN2, ..., PATTERNn) => body

注意到与绑定一样,故意使用了 PATTERN

let *PATTERN* = VALUE;

事实上,Reason 函数(无论是字面量还是正常函数绑定)都可以直接对其参数进行模式匹配。然而,与任何模式匹配一样,我们必须小心在函数参数中匹配可反驳的模式,因为这些风险在运行时失败。以下代码片段包括函数字面量的示例:

/* src/Ch07/Ch07_FunctionLiterals.re */
let addV1(int1, int2) = int1 + int2; /* (1) */
let addV2 = (int1, int2) => int1 + int2; /* (2) */
let addV3 = int1 => int2 => int1 + int2; /* (3) */

/** A way to convert values of type ['a] to and from floats. */
module FloatConverter = {
 /* (4) */
 type t('a) = {encodeExn: 'a => float, decodeExn: float => 'a};

 /* (5) */
 let float = {encodeExn: float => float, decodeExn: float => float};
 let int = {encodeExn: float_of_int, decodeExn: int_of_float}; /* (6) */
};

let greet = ({Ch03_Domain.Person.id, name}) => /* (7) */
 {j|Hello, $name with ID $id!|j};

此文件展示了相当多的事物,如下所述:

  1. 我们使用本书中一直使用的语法定义一个函数,以进行比较。

  2. 如何使用稍微去糖化的 Reason 语法定义并立即绑定一个函数字面量。

  3. 如何使用完全去糖化的柯里化语法定义并立即绑定一个函数字面量。这里需要意识到的是,这三个函数在类型和行为上完全相同,并且可以以完全相同的方式调用:addV*n*(1, 2)

  4. 如何定义一个可以包含两个函数的类型:一个将给定的类型 'a' 转换为 float,另一个将 float 转换回相同的 'a'。请注意,我们使用命名约定来表明这两个函数都可能抛出异常,因为我们不能事先保证每个类型 'a' 都可以实际转换为 float 并返回。

  5. 如何定义一个 FloatConverter.t(float),它(简单地)知道如何将 float 转换为 float。函数实现为 float => float,在这个上下文中意味着返回与输入相同的 float

  6. 如何定义一个 FloatConverter.t(int),它知道如何使用 Reason 标准库中的函数在 intfloat 之间进行转换。

  7. 最后,我们看到了如何通过创建一个函数字面量,使用参数的解构模式匹配并将函数绑定到名称 greet 来定义另一个问候函数。

Eta 抽象

注意,在前面的第六点中,我们直接使用了两个标准库提供的函数作为值。我们也可以将它们包裹在第一类函数中,如下所示:

let *int* = {
  *encodeExn*: *int* => *float_of_int*(*int*),
  *decodeExn*: *float* => *int_of_float*(*float*)
};

通常情况下,将某个东西包裹在函数内部被称为eta 抽象。它是一种抽象,因为它增加了一层间接性,而不是直接返回一个值。换句话说,我们首先需要传入一个参数,该参数在函数体内部被替换,然后才能返回计算结果。

在某些情况下,eta 抽象是必要的。例如,我们之前的浮点转换器需要一个将 float 转换为 float 的方法,以便适应我们设置的类型。为此,eta 抽象 float => float 是完美的。然而,当它直接包裹单个函数调用时,eta 抽象是多余的,例如 int => float_of_int(int)output => Js.log(output)。这是因为那个单个函数调用本身就是一个等价的 eta 抽象;它已经接受相同的参数并计算相同的结果。多亏了 Reason 函数是值,我们总是可以直接传递它们。

通常,当我们专注于编写所需的函数时,很容易忽略这些冗余。幸运的是,我们可以在稍后简化代码库时移除冗余的 eta 抽象,而不会改变代码的含义。

摘要

函数是 Reason 的重要组成部分,并且被广泛使用。本章重点介绍了它们的本质属性:引用透明性、纯净性和完备性。我们还讨论了与 Reason 函数相关的具体技术,例如柯里化和部分应用。

在下一章中,我们将探讨 Reason 支持的更多方法和技术,这些方法和技术有助于代码重用和通用编程。

第八章:使用多种不同类型重用代码

在前面的章节中,我们介绍了很多特定的技术。其中,我们看到了如何使用模块来封装定义类型和值的代码。我们还看到了函数和函数类型,包括它们的用法技术,如柯里化和部分应用。

在本章中,我们将基于我们迄今为止所看到的,并涵盖以下主题:

  • Reason 中的多态技术

  • 使用模块和函子编写泛型代码

Reason 中的多态性

多态性,是许多编程语言中使用的技巧类别,允许编写适用于不同类型或对象的代码(例如,在 C++或 Java 等语言中)。精确地看待事物可以显示存在几种多态技术或类型。

我们将在这里讨论两种实现多态性的方法:

  • 参数多态性

  • 特设多态性

具有参数多态性的通用函数

参数多态性允许以通用方式编写函数或数据类型,这意味着它可以以相同的方式处理不同类型的值。这既有趣又强大,因为它意味着使用参数多态性编写的函数可以在不同的数据类型上工作。

在 C++中,参数多态性通常被称为泛型编程编译时多态性

我们可以在 ReasonML 中使用类型进行参数多态性。我们有一个特殊的功能来实现这一点,类型变量,这在第七章中已经遇到过,在表示操作的类型的例子中,与函数字面量相关。我们不是使用intstring等具体类型作为参数或结果,而是使用类型变量。因此,使用类型变量作为参数的类型将确保接受任何类型的值。

使用类型变量的函数被称为通用函数

让我们用一个例子来解释。恒等函数是解释通用函数是什么的简单例子。恒等函数(让我们称它为id())只是返回其输入参数。它定义如下:

let *id* = x => x;

根据这个定义,ReasonML 使用'a来推断输入参数的类型,这意味着它使用类型变量来表示接受任何类型的值。函数的返回类型也是以相同的方式推断的,即其参数的类型,即'a类型变量。这就是通用函数的行为。

每当我们遇到以'开头的类型名时,例如'a(表示任何类型),这定义了一个类型变量。

这里,是此类函数的另一个示例。我们可以考虑一个返回列表最后一个元素的函数(lastElem)。关键是列表的元素可以是任何类型。此外,由于我们必须考虑空列表的情况,我们将使用带有类型变量的选项类型。

我们可以为该函数编写一个接口(参见src/Ch08/Ch08_GenericFunctionLastElementOfList.rei文件),如下所示:

let *lastElem*: *list('a)* => option('a);

此外,基于 Reason 中列表的工作方式,我们可以定义一个递归函数(使用rec关键字),如下所示:

let *rec* *lastElem* = aList =>
  switch aList {
  | [] => None
  | [x] => Some(x)
  | [_, ...l] => lastElem(l)
};

让我们用以下Js.log调用进行测试:

Js.log(*lastElem*([1, 3, 2, 5, 4]));
Js.log(*lastElem*(["a", "b", "c", "d"]));

如预期的那样,输出(参见src/Ch08/Ch08_GenericFunctionLastElementOfList.re文件)显示结果值为4d

特设多态或重载

特设多态是我们接下来要讨论的另一种技术。它也被称为重载,它为相同的操作提供不同的实现,例如+操作。为了继续这个例子,我们可能会在某些编程语言中,例如 Python,发现+操作有用于数字加法的实现,另一个用于字符串连接,还有一个用于列表或数组连接。

几乎所有编程语言都支持内置操作的多态,例如+-*

ReasonML 目前不支持特设多态。例如,我们有用于整数加法的不同+操作符,用于浮点数加法的+.操作符,以及用于字符串连接的++操作符。如果需要,我们必须在应用给定操作符之前手动将值转换为正确的类型。

ReasonML 最终可能通过目前正在开发的模块隐式来实现特设多态。

使用函数式编程的通用代码

正如我们所见,模块在 OCaml 和 ReasonML 中非常重要,有助于将代码组织成具有指定接口的单位。除此之外,我们现在将看到它们可以用所谓的函数式来构建通用代码。

什么是函数式?

函数式是一种函数,其参数是模块,其结果是另一个模块。

函数式允许我们通过新功能扩展现有模块,而无需为不同类型编写大量重复代码。

函数式编程的语法如下所示:

module *F* = (M1: I1, ···): ResultI => {
   ...
 };

基于这些特性,请注意以下内容:

  • F函数式编程的参数是一个或多个M1模块等

  • 每个参数模块必须通过接口进行类型化(I1用于M1等)

  • 结果类型(ResultI)的接口是可选的

一些示例将帮助我们理解。

示例 1 – 在标准库中查找

最好的例子是标准库中的Set模块。

Set类型有一个排序(例如,在整数集合中,1 < 22 > 1),并且元素是唯一的。注意,在其他语言,如 Python 中,也有这种情况。

要在 ReasonML 和 OCaml 中使用集合,首先必须创建一个。你可以通过调用Set.Make来实现,这是一个函数式编程,它接受作为输入的另一个模块,该模块必须在其内部实现一个compare()函数,并返回我们的Set类型模块。

例如,我们可以为整数集编写如下代码:

module *IntSet* =
   *Set.Make*(
     {
       let *compare* = Pervasives.compare;
       type *t* = int;
     }
 );

我们得到一个新的模块,它提供了方便地处理整数集合的函数,例如 IntSet.of_list()

let *myIntSet* = IntSet.of_list([1,2,3]);

让我们在控制台中显示结果:

Js.log(myIntSet)

当我们使用 node 命令运行编译该程序文件(src/Ch08/Ch08_FunctorsExample1.re)生成的 JS 文件时,我们得到输出,显示了创建的 IntSet

[ 0, 1, [ 0, 2, [ 0, 3, 0, 1 ], 2 ], 3 ]

通过这个第一个示例,我们了解到了函子的作用。

示例 2

这里还有一个从 ReasonML 文档中摘取的另一个示例,也是关于集合的,我们将对其进行讨论。我们将展示一个 MakeSet 函子,它接受一个 Comparable 类型的模块并返回一个可以包含此类可比较项的新集合。

我们首先定义 Comparable 类型,如下所示:

module type *Comparable* = {
   type *t*;
   let *equal*: (t, t) => bool;
 };

现在,我们定义函子,如下所示:

module *MakeSet* = (Item: Comparable) => {
   /* 1 */
   type backingType = list(Item.t);
   let *empty* = [];
   let *add* = (currentSet: backingType, newItem: Item.t) : backingType =>
     if (*List.exists*((*x*)=> *Item.equal*(*x, newItem*)*, currentSet*)) {
       currentSet /* 2 */
     } else {
       [
         *newItem*,
         ...currentSet /* 3 */
       ]
     };
 };

下面是这个代码块的解释:

(1) 我们使用列表作为我们的数据结构。

(2) 如果项目存在,则返回相同的集合。

(3) 否则,将元素添加到集合的前面并返回它。

现在,让我们记住我们想要创建一个集合,其元素是整数对。我们创建输入模块 IntPair,它遵循 MakeSet 所需的 Comparable 签名,如下所示:

module *IntPair* = {
   type *t* = (int, int);
   let *equal* = ((x1, y1), (x2, y2)) => x1 == x2 && y1 == y2;
   let *create* = (x, y) => (x, y);
 };

这意味着我们可以使用函子编写以下内容:

module *SetOfIntPairs* = *MakeSet*(IntPair);

最后,让我们添加一些代码来使用生成的模块:

let *aSetOfPairItems*: SetOfIntPairs.backingType = SetOfIntPairs.empty;
Js.log(aSetOfPairItems);
let *otherSetOfPairItems* = SetOfIntPairs.add(aSetOfPairItems, (1, 2));
Js.log(*otherSetOfPairItems*);
let *thirdSetOfPairItems* = SetOfIntPairs.add(otherSetOfPairItems, (2, 3));
Js.log(thirdSetOfPairItems);

这应该足以得到一些有趣的结果。

当我们使用 node 命令运行由编译我们的 Reason 代码文件(src/Ch08/Ch08_FunctorsExample2.re)生成的 JS 文件时,我们得到以下输出:

0
[ [ 1, 2 ], 0 ]
[ [ 2, 3 ], [ [ 1, 2 ], 0 ] ]

我们做了什么?使用函子,我们能够从一个模块创建一个新的模块 SetOfIntPairs。新模块具有 add 函数等特性。使用该模块,我们可以创建一个空集(输出中的 0),我们可以根据需要向其中添加 int 实例的成对(使用之前提到的 add 函数)。

示例 3

现在,我们将使用来自 Axel Rauschmayer 的一个示例,该示例可以在他的仓库中找到,网址为 github.com/rauschma/reasonml-demo-functors

为了更精确,让我们使用带有微小调整的可打印对函子示例,以帮助我们更容易地理解这如何有用。

在定义函子之前,我们必须定义其参数的接口。在这里,我们将有一个单个参数,即 PrintablePair 模块。为此,我们将定义一个第一类型 PrintableType,它将被 PrintablePair 使用。我们定义它如下:

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

现在,我们添加 PrintablePair 类型的定义,如下所示:

module type *PrintablePair* = (First: PrintableType, Second: PrintableType) => {
   type *t*;
   let *make*: (First.t, Second.t) => t;
   let *print*: (t) => string;
 };

然后,我们可以定义函子,如下所示:

module *Make*: *PrintablePair* = (First: PrintableType, Second: PrintableType) => {
 type *t* = (First.t, Second.t);
 let *make* = (f: First.t, s: Second.t) => (f, s);
 let *print* = ((f, s): t) =>
   "(" ++ First.print(f) ++ ", " ++ Second.print(s) ++ ")";
 };

现在,我们有代码,我们将使用函子,从定义 PrintableStringPrintableInt 模块开始。

我们定义 PrintableString 如下:

module *PrintableString* = {
   type *t*=string;
   let *print* = (s: t) => s;
 };

然后,我们定义 PrintableInt 如下:

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

现在,我们添加其余的代码,如下所示:

module *PrintableSI* = Make(PrintableString, PrintableInt);
let () = *PrintableSI*.({
   let *pair* = *make*("Jane", 53);
   let *str* = *print*(pair);
   *print_string*(str);
 });

当我们使用 node 命令运行由 Reason 文件(src/Ch08/Ch08_FunctorsExample3.re)生成的 JS 代码时,我们得到以下输出:

(Jane, 53)

我们完成了!

摘要

我们已经看到,ReasonML 支持使用 类型变量 进行参数多态性,这是语言特性之一。当使用 类型变量 作为函数参数的类型时,接受任何类型的值作为该参数。这种技术允许编写我们所说的泛型函数,并在 ReasonML 中的代码复用中扮演着重要的角色。

相比之下,在流行的编程语言中支持的另一种多态性——临时多态性,在 ReasonML 中尚不存在。但是,正在努力在未来版本中纠正这一不足。

模块在代码复用中也扮演着重要的角色。但,这并非全部。除了它们自身允许的功能之外,ReasonML 还有一个强大的特性,可以增强我们对模块的使用能力:函子。它们就像特殊的函数,接受一个或多个模块作为输入,并返回一个模块。这为编程泛型开辟了一些可能性。

在下一章中,我们将探讨 ReasonML 的技术,用于扩展类型以添加行为。

第九章:使用新行为扩展类型

在上一章中,我们看到了 Reason 提供的工具和技术,这些工具和技术为通用编程开辟了可能性。一个是使用类型变量的参数多态性。另一个是函子,它可以接受一个或多个模块作为参数并返回一个模块。

在本章中,我们将探讨扩展类型本身以添加行为的方法。Reason 中可用的一种技术称为 子类型化。其思想是类型之间存在层次关系,具体类型是更通用类型的子类型。例如,一只 可以是 哺乳动物 的子类型,而 哺乳动物 本身又是 脊椎动物 的子类型。

我们可以在 OCaml 文档中找到一个关于子类型化方法的定义:

子类型化控制着一个类型 A 的对象能否被用于期望另一个类型 B 的对象的表达式中。当这是真的时,我们说 A 是 B 的子类型。更具体地说,子类型化限制了何时可以应用转换操作符 e :> t。这种转换仅在 e 的类型是 t 的子类型时才有效。

除了子类型化之外,还有一些类似于我们在面向对象编程(OOP)风格的继承中所做的技术,这些技术可能很有用。

根据具体情况和使用案例,你可以利用这些技术中的一个或多个来改进你的代码结构,使其易于扩展功能,同时保持代码的类型安全性。

在本章中,我们将涵盖以下主题:

  • 使用多态变体进行子类型化

  • 使用 OOP 风格的继承进行代码复用

使用多态变体进行子类型化

正如我们在 第五章 中所看到的,在类型中放置替代值,Reason 有变体类型的概念,这可以在模式匹配和完备性检查中发挥作用。变体类型有它们更复杂和强大的版本,称为 多态变体*。它们比常规变体提供了更多的灵活性。例如,它们使用特殊的语法定义,如 type color = [Red | Orange | Yellow | Green | Blue ];,并且它们的构造函数是独立存在的。

让我们看看它们如何通过子类型化来扩展类型行为。

重复使用不同类型的构造函数

由于构造函数是独立存在的,我们可以多次使用同一个构造函数。因此,我们可以定义一个名为 rgb 的类型,它使用与 color 类型定义一起提供的 RedGreenBlue 构造函数。

在这里,我们首先定义了类型,然后是几个绑定来使用这些类型(onegreenothergreen):

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

/* Bindings using the variants we defined */
let *onegreen*: color = `Green;
let *othergreen*: rgb = `Green;

让我们再添加一些代码来展示值:

/* Console log */
Js.log(onegreen);
Js.log(othergreen);

在这一点上,如果你尝试执行编译此 Reason 代码生成的 JavaScript,你会得到类似以下输出的结果:

756711075
756711075

如我们所见,打印的标识符对两个变量都是相同的,这我们可能已经有所预感,因为我们使用了相同的构造函数( Green ``)。

让我们通过添加将值转换为字符串的函数来使这个例子更直观。

我们添加一个函数,为每个颜色提供一个字符串值,如下所示:

let *stringOfColor* = (c: color) : string => {
 switch (c) {
 | *`Red* => "red"
 | *`Orange* => "orange"
 | *`Yellow* => "yellow"
 | *`Green* => "green"
 | *`Blue* => "blue"
 }
};

我们添加一个函数,为每个 RGB 颜色 提供一个字符串值,如下所示:

let *stringOfRgb* = (c: rgb) : string => {
 switch (c) {
 | *`Red* => "RGB red"
 | *`Green* => "RGB green"
 | *`Blue* => "RGB blue"
 }
};

我们还添加了相同的代码,用于记录值,如下所示:

Js.log(stringOfColor(onegreen));
Js.log(stringOfRgb(othergreen));

那么,这个新实验的结果是什么?完整版本的代码(在 src/Ch09/Ch09_Example1.re 文件中)编译后生成一个 JS 代码文件,使用 node 命令执行时,输出结果类似于以下内容:

756711075
756711075
green
RGB green

如你所料,使用两个不同的函数,我们能够根据输入类型(以及涉及的构造函数,例如多态构造函数 Green)来区分输出。

作为留给读者的练习,将 Js.log(stringOfRgb(onegreen)); 添加到 ReasonML 代码中,并查看该调用输出的结果。

多态变体类型扩展的示例

代码复用的一个更有趣的技巧是,可以将现有的多态变体类型扩展以创建一个新的类型。让我们通过另一个例子来看一下。

假设我们有一个网站类型定义,我们想将其扩展到所有网络应用(包括个人生产力工具、社交网络,甚至 API)。我们首先定义了特定于女性的类别,如下所示:

type onlyWomanShoe = [`Slingbacks | `HighHeels];

我们可以通过重用该类型定义并扩展它来为所有(女性和男性)定义多态变体,如下所示:

type shoe = [onlyWomanShoe | `Moccasins | `Boots | `Sneakers | `Wingtips];

我们可以通过代码文件 src/Ch09/Ch09_Example2.re 来查看这些类型的结果。像往常一样,我们添加一些控制台日志(使用 Js.log())使事情更有趣。完整的代码如下:

type onlyWomanShoe = [`Slingbacks | `HighHeels];
type shoe = [onlyWomanShoe | `Moccasins | `Boots | `Sneakers | `Wingtips];

let *johndoe_shoe*: shoe = `Moccasins;
let *janedoe_shoe*: shoe = `Slingbacks;

Js.log(johndoe_shoe);
Js.log(janedoe_shoe);

let *infoAboutShoe* = (s: shoe) : string => {
 switch (s) {
 | *`Slingbacks* => "Slingbacks - Specific woman shoe"
 | *`HighHeels* => "High Heels - Specific woman shoe"
 | *`Moccasins* => "Moccasins"
 | *`Boots* => "Boots"
 | *`Sneakers* => "Sneakers"
 | *`Wingtips* => "Wingtips"
 }
};

Js.log(infoAboutShoe(johndoe_shoe));
Js.log(infoAboutShoe(janedoe_shoe)); 

执行编译结果生成的 JS 代码会输出类似于以下内容:

265261402
-594895036
Moccasins
Slingbacks - Specific woman shoe

如果你在将构造函数绑定到 janedoe_shoe 变量时出错,例如,写下以下内容,编译时会发生什么很有趣:

let *janedoe_shoe*: onlyWomanShoe = `Slingbacks;

尝试一下,在重新编译期间,你会立即看到类似于以下错误的错误:

  This has type:
    onlyWomanShoe
  But somewhere wanted:
    shoe
  The first variant type does not allow tag(s)
  `Boots, `Moccasins, `Sneakers, `Wingtips

现在,回到我们的正常代码!我们能否在这里改进代码以减少代码量?我们能否在 infoAboutShoe() 函数中利用变体类型 onlyWomanShoe,以避免 switch 块中的 SlingbacksHighHeels 的两行代码,并尝试使其真正强大?

有一个技巧可以在函数的 switch 块中使用语法糖来实现这一点:#onlyWomanShoe

我们将在稍后详细解释这一点,但基本上我们可以编写几乎等效的代码(src/Ch09/Ch09_Example2bis.re),我们只更改 infoAboutShoe 函数的主体,如下所示:

let *infoAboutShoe* = (s: shoe) : string => {
    switch (s) {
        | *#onlyWomanShoe* => "Woman shoe such as Sandals or High Heels"
        | *`Moccasins* => "Moccasins"
        | *`Boots* => "Boots"
        | *`Sneakers* => "Sneakers"
        | *`Wingtips* => "Wingtips"
    }
};

执行编译生成的 JS 代码会输出如下内容:

265261402
-594895036
Moccasins
Woman shoe such as Slingbacks or High Heels

更多关于扩展多态变体类型的内容

假设我们有一个网站类型定义,我们想将其扩展到所有网络应用(包括个人生产力工具、社交网络,甚至 API)。我们首先定义了描述网站的主要特征,如下所示:

  • 域名

  • 是否通过登录进行访问(对于主要内容),即私有或公共访问

要开始,我们首先定义两个实用类型,domainaccessType,如下所示:

type domain = [ `Domain(string) ];
type accessType = [`Private | `Public];

然后,我们添加一个辅助函数来将accessType值转换为字符串:

let *accessTypeName* = (a: accessType) : string => {
 switch (a) {
 | *`Private* => "private"
 | *`Public* => "public"
 }
 };

现在,让我们定义网站的变体类型:

type website = [
 | *`CorporateSite*(domain)
 | *`CommerceSite*(domain, accessType)
 | *`Blog*(domain, accessType)
 ];

然后,我们添加一个函数,该函数将返回包含每个网站信息的简短摘要文本:

let *siteSummary* = (app: website) : string => {
 switch (app) {
 | *`CorporateSite*(`Domain(s)) => s ++ " - corporate site (public)"
 | *`CommerceSite*(`Domain(s), a) => s ++ " - commerce site (" ++ accessTypeName(a) ++ ")"
 | *`Blog*(`Domain(s), a) => {
 switch (a) {
 | *`Private* => s ++ " - " ++ "corporate blog (" ++ accessTypeName(a) ++ ")"
 | *`Public* => s ++ " - blog (public - login-based access for authors)"
 }
 }
 }
};

如同往常,为了测试目的,我们添加了一些绑定,并使用Js.log()在控制台显示结果:

let *mysite* = `CorporateSite(`Domain("www.acme.com"))
Js.log(siteSummary(mysite))

let *myblog* = `Blog(`Domain("www.contentgardening.com"), `Public)
Js.log(siteSummary(myblog))

let *corpinternalblog* = `Blog(`Domain("internalblog.acme.com"), `Private)
Js.log(siteSummary(corpinternalblog))

我们可以看到这部分正在工作。但是,我们还没有完成。我们还想将这段代码扩展到任何 webapp。因此,我们定义了一个新的类型,用于web appswebapp,正如你所猜到的),目的是作为websites类型的扩展。类型定义如下:

type webapp = [
 | *`CorporateSite*(domain)
 | *`CommerceSite*(domain, accessType)
 | *`Blog*(domain, accessType)
 | *`SocialApp*(domain)
 ];

之后,我们添加了一个新的函数(appSummary()),它将扩展之前的函数(siteSummary())。我们在这里使用的一个技术是as关键字,它允许我们在函数的switch部分将结果与website变体类型的构造函数相匹配。例如,我们可以写`` CorporateSite(Domain(s)) as ws ```。

我们可以定义函数如下:

let *appSummary* = (app: webapp) : string => {
 switch (app) {
 | *`CorporateSite*(`Domain(s)) as ws => siteSummary(ws)
 | *`CommerceSite*(`Domain(s), a) as ws => siteSummary(ws)
 | *`Blog*(`Domain(s), a) as ws => siteSummary(ws)
 | *`SocialApp*(`Domain(s)) => s ++ " - social app"
 }
};

注意,我们实际上可以稍微改进一下;为了避免编译器对switch块的前三条线中的sa变量未使用而抱怨,我们可以用_替换这些变量。改进后的函数如下:

/* 1) the extended function **/
let *appSummary* = (app: webapp) : string => {
 switch (app) {
 | *`CorporateSite*(`Domain(_)) as ws => siteSummary(ws)
 | *`CommerceSite*(`Domain(_), _) as ws => siteSummary(ws)
 | *`Blog*(`Domain(_), _) as ws => siteSummary(ws)
 | *`SocialApp*(`Domain(s)) => s ++ " - social app"
 }
};

我们可以通过添加一些测试代码来完成,如下所示:

Js.log("---")
let *fb* = `SocialApp(`Domain("facebook.com"))
Js.log(appSummary(fb))

但是等等,还有更多的改进空间!

另一种技术是通过#website as ws语法重用第一个类型,这允许我们完全替换与website变体类型构造函数相关的模式匹配的行。

函数的最终版本如下:

/* 2) the extended function improved! **/
let *appSummaryImproved* = (app: webapp) : string => {
 switch (app) {
 | #website as ws => siteSummary(ws)
 | *`SocialApp*(`Domain(s)) => s ++ " - social app"
 }
};

然后,在这个更新之后,为了使事情更有趣,以下是最终的测试和值显示代码:

Js.log("------")
Js.log(appSummaryImproved(mysite))
Js.log(appSummaryImproved(myblog))
Js.log(appSummaryImproved(corpinternalblog))
Js.log(appSummaryImproved(fb))

我们可以看到,执行编译生成的 JavaScript 代码会产生以下输出:

www.acme.com - corporate site (public)
www.contentgardening.com - blog (public - login-based access for authors)
internalblog.acme.com - corporate blog (private)
---
facebook.com - social app
------
www.acme.com - corporate site (public)
www.contentgardening.com - blog (public - login-based access for authors)
internalblog.acme.com - corporate blog (private)
facebook.com - social app

我们刚刚看到了 Reason 支持的一种很好的类型扩展技术,即多态类型变体。

使用面向对象风格的继承进行代码复用

继承,就像我们在面向对象编程语言中看到的那样,在 Reason 中并不常用。然而,我们可以找到一些类似于 OOP 风格继承的技术示例。

打开一个模块

在模块中不断引用某个东西会给开发者带来很多编写工作。这就是open派上用场的地方。

没有这个功能,假设你有一个ColorExample模块(基于我们在本章第一个示例中已经使用过的代码),定义如下:

/* A module */
module ColorExample = { 
  type color = [`Red | `Orange | `Yellow | `Green | `Blue ];
  type rgb = [`Red | `Green | `Blue];

  let *onegreen*: color = `Green;
  let *othergreen*: rgb = `Green;

  let *stringOfColor* = (c: color) : string => {
    switch (c) {
        | *`Red* => "red"
        | *`Orange* => "orange"
        | *`Yellow* => "yellow"
        | *`Green* => "green"
        | *`Blue* => "blue"
    }
  };

  let *stringOfRgb* = (c: rgb) : string => {
    switch (c) {
        | *`Red* => "RGB red"
        | *`Green* => "RGB green"
        | *`Blue* => "RGB blue"
    }
  };
}

你可以通过使用ColorExample.stringOfColor引用来使用该函数,对于值也是类似。所以,一些值显示代码可能如下所示(如实际在src/Ch09/Ch09_Open_module.re文件中看到的那样):

/* Use the module the default way */
Js.log("1/ Use function and values inside the module...");
Js.log(ColorExample.stringOfColor(ColorExample.onegreen));
Js.log(ColorExample.stringOfRgb(ColorExample.othergreen));

但是,使用在作用域内打开模块的 open 解决方案,我们可以编写更紧凑的代码,如下(在同一个 src/Ch09/Ch09_Open_module.re 文件中):

/* Open the module and use its content */
Js.log("2/ Use function and values from the module after opening it...");
let colorString = {
  open ColorExample;
  let oneblue: color = `Blue;
  Js.log("String value of another color: " ++ stringOfColor(oneblue));
};

事情按预期进行!执行代码文件时给出以下输出:

1/ Use function and values inside the module...
green
RGB green
2/ Use function and values from the module after opening it...
String value of another color: blue

发生的事情是,模块的所有内容都可以神奇地访问,无需添加通常在访问包含的类型、函数和变量时需要添加的前缀(在这种情况下是 ColorExample. 部分)。它将模块的内容引入当前作用域。

我们可以以另一种方式做事:将模块放在自己的文件中以更好地分离代码。在某些情况下,如果你认为这样做更适合你的代码组织方式,你可能想这样做。所以基本上,让我们将模块的代码移动到 src/Ch09/Ch09_OpenModulebisPart1.re 文件中,例如,如下所示:

module ColorExample = { 
  type color = [`Red | `Orange | `Yellow | `Green | `Blue ];
  type rgb = [`Red | `Green | `Blue];

  let *onegreen*: color = `Green;
  let *othergreen*: rgb = `Green;

  let *stringOfColor* = (c: color) : string => {
    *switch* (c) {
        | *`Red* => "red"
        | *`Orange* => "orange"
        | *`Yellow* => "yellow"
        | *`Green* => "green"
        | *`Blue* => "blue"
    }
  };

  let *stringOfRgb* = (c: rgb) : string => {
    switch (c) {
        | *`Red* => "RGB red"
        | *`Green* => "RGB green"
        | *`Blue* => "RGB blue"
    }
  };
}

然后,在另一个代码文件(例如 src/Ch09/Ch09_OpenModulebisPart2.re),我们可以打开模块并访问其函数和值,如下所示:

open Ch09_OpenModulebisPart1.ColorExample;

Js.log(stringOfColor(onegreen));
Js.log(stringOfRgb(othergreen));

let *colorString* = {
  let *oneblue*: color = `Blue;
 Js.log("String value of another color: " ++ stringOfColor(oneblue));
};

就这样!我们已经看到了如何在 Reason 中利用 open 打开模块。并且,在你可以找到的其他开发者的代码中,你可以发现它被大量使用。

包含一个模块

Reason 还提供了一种重用已定义的模块并像在面向对象中一样扩展它的方法:include 关键字。

关于这个特性,文档实际上是这样说的:

在模块中使用 include,可以将模块的内容静态地转移到新的模块中。因此,通常可以满足继承或混合的角色。

假设我们有一个基础模块如下:

module *Site* = {
 let *siteEnvMarker* = "TESTING";
 let *protocol* = (~secured) => secured ? "https" : "http";
 let *getInfo* = domainName => protocol(~secured=false) ++ "://" ++ domainName ++ " (" ++ siteEnvMarker ++ ")";
};

现在,我们将在以下模块中使用它,使用 include 技术,如下所示:

module ProductionSite = {
 include Site;
 let *siteEnvMarker* = "production!";
 let *getPublicInfo* = domainName => {
 let *additionalText* = " (" ++ String.uppercase(siteEnvMarker) ++ ")";
 let *result* = protocol(~secured=true) ++ "://" ++ domainName ++ additionalText;
 Js.log(result);
 }
};

然后,我们可以使用每个模块中的函数来显示信息,以便比较行为:

Js.log(Site.getInfo("dev-acme.com"));
print_newline();
ProductionSite.getPublicInfo("acme.com");

执行编译我们的代码(在 src/Ch09/Ch09_Include_module.re 文件中)产生的 JS 代码给出以下输出:

http://dev-acme.com (TESTING)

https://acme.com (PRODUCTION!)

有趣。你可能已经对这里发生的事情有了直觉,但 ReasonML 关于 include 的文档规定,这种技术会静态地复制模块的定义,然后也执行 open

摘要

我们现在已经看到了如何利用两种技术,使用多态变体进行子类型化和使用模块的面向对象继承风格,来改进代码结构并使其易于向类型添加行为。

使用多态变体类型,我们可以因为它们的设计而重用不同类型的构造函数。此外,还可以扩展多态变体类型以创建新的类型。

使用模块,我们可以打开一个现有的模块来使用其中定义的函数和绑定,并且我们可以将其包含在新的模块中以扩展其行为,类似于面向对象的继承风格。

在下一章和最后一章中,我们将通过一个最终示例来回顾我们迄今为止学到的所有主要技术。

第十章:将一切整合在一起

在前面的章节中,我们探讨了在 ReasonML 中进行类型驱动开发的不同工具和技术。

在本章中,通过一个最终示例,我们将培养一种对何时使用每种类型驱动技术来解决问题的感觉。让我们看看我们至少可以部分地创建处理输入(在一个小的 JavaScript 应用中)的社交、生产力和商业应用代码。更精确地说,我们这里指的是由互联网或平台公司推出的成功应用,如 Gmail、Facebook、Twitter、Skype、Airbnb 或 Uber。

在本章中,我们将介绍以下主题:

  • 从变体类型开始(版本 1)

  • 使用更多模式匹配(版本 2)

  • 转向多态变体类型(版本 3)

  • 使用记录(版本 4)

  • 使用模块进行代码结构(版本 5)

  • 代码结构的另一种结构(版本 6)

  • 改进:使用列表作为输出(版本 7)

  • 另一项改进:使用可变记录(版本 8)

  • 单元测试我们的代码(最终版本)

从变体类型开始(版本 1)

首先,我们需要一个 type 来表示公司,这些公司是互联网应用。基于这个 type,我们可以考虑编写函数,以帮助我们以类型安全的方式构建逻辑。让我们看看结果如何。

作为第一次尝试,我们从小处着手,定义一个我们感兴趣的互联网公司的变体类型。正如我们在前面的章节中看到的,我们将使用模式匹配来展示这些公司向用户提供的应用列表。

我们如下定义互联网公司类型:

type internetCompany =
   | Facebook
   | Google
   | Twitter;

现在,我们定义一个显示应用的函数,如下所示:

let *apps* = (company: internetCompany) : string => {
   switch (company) {
     | Facebook => "facebook, messenger, ads"
     | Google => "gmail, google+, maps, ads"
     | Twitter => "twitter"
   }
 };

以下代码将展示一些来自 Google 的应用:

let *googleApps* = apps(Google);
Js.log(googleApps);

这里是整个代码的输出(由 src/Ch10/Ch10_PlatformCompany_V1.re 生成):

gmail, google+, maps, ads

这是一个良好的开端。

由于涉及不同的应用类别(社交、商业、通信、娱乐等),我们可以通过使用更多的模式匹配代码来区分应用列表,从而丰富我们的类型驱动逻辑。现在让我们这样做。

使用更多模式匹配(版本 2)

如果我们将变体的构造函数更改为传递一个字符串以表示每个可能的类别(例如 Facebook(string),它可以给出 Facebook("social")Facebook("business")),我们就可以做到这一点。额外的模式匹配可能如下所示:

Facebook(str) => switch str {            
        | "social" => "facebook, messenger, instagram"
        | "business" => "facebook ads"

那么,让我们从定义互联网公司变体类型开始,如下所示:

type internetCompany =
   | Facebook(string)
   | Google(string)
   | Twitter(string);

如我们计划的那样,我们的函数的模式匹配代码可以演变,如下所示:

let apps = (company: internetCompany) : string => {
   switch (company) {
     | Facebook(str) => switch str {            
         | "social" => "facebook, messenger, instagram"
         | "business" => "facebook ads"
     }
     | Google(str) => switch str {            
         | "social" => "google+, gmail"
         | "business" => "google ads, google adsense, gmail for business"
     }
     | Twitter(str) => switch str {            
         | "social" => "twitter"
         | "business" => "twitter ads"
     }   
   }
 };

现在,以下代码将展示一些结果数据,这样我们就可以看到事情是否按预期进行:

Js.log(Js.String.toUpperCase("facebook"))
Js.log("Business: " ++ apps(Facebook("business")));
Js.log("Social: " ++ apps(Facebook("social")));

Js.log(Js.String.toUpperCase("google"))
Js.log("Business: " ++ apps(Google("business")));
Js.log("Social: " ++ apps(Google("social")));

这里是执行此版本时我们得到的结果(src/Ch10/Ch10_PlatformCompany_V2.bs.js,由 src/Ch10/Ch10_PlatformCompany_V2.re 生成):

FACEBOOK
Business: facebook ads
Social: facebook, messenger, instagram
GOOGLE
Business: google ads, google adsense, gmail for business
Social: google+, gmail

注意,我们的类型只考虑了互联网公司,但我们的逻辑也应该适用于其他使用技术(Web 移动、数据库、AI)以可扩展的方式构建和提供服务的现代公司。我们可以称它们为平台公司。因此,我们也可以为平台公司添加一个类型。

我们甚至可以认为一些互联网公司(至少是那些大公司)也是平台公司或者已经推出了平台业务。因此,我们可以从普通变体类型切换到多态变体类型,以利用它们的类型重用能力。

让我们从这些新想法开始第三个版本。

切换到多态变体类型(版本 3)

在这个新版本中,我们定义了我们构建逻辑的两个类型,在第二个类型中重用第一个类型,如下所示:

type internetCompany = [ `Facebook(string) | `Google(string) | `Twitter(string) ];
type platformCompany = [ internetCompany | `Amazon(string) | `Uber(string) ]

基于此,我们使我们的函数如下进化(使用platformCompany允许所有变体都能接受company参数):

let *apps* = (company: platformCompany) : string => {
  switch (company) {
    | `Facebook(str) => switch str {            
      | "social" => "facebook, messenger, instagram"
      | "business" => "facebook ads"
    }
    | `Google(str) => switch str {            
      | "social" => "google+, gmail"
      | "business" => "google ads, google adsense, gmail for business"
    }
    | `Twitter(str) => switch str {            
      | "social" => "twitter"
      | "business" => "twitter ads"
    }   
    | `Amazon(str) => switch str {            
      | "social" => ""
      | "business" => "amazon, AWS"
    } 
    | `Uber(str) => switch str {            
      | "social" => ""
      | "business" => "uber"
    } 
  }
};

现在,让我们添加通常的快速数据显示代码,如下所示:

Js.log(Js.String.toUpperCase("facebook"))
Js.log("Business: " ++ apps(`Facebook("business")));
Js.log("Social: " ++ apps(`Facebook("social")));
Js.log("")

Js.log(Js.String.toUpperCase("google"))
Js.log("Business: " ++ apps(`Google("business")));
Js.log("Social: " ++ apps(`Google("social")));
Js.log("")

Js.log(Js.String.toUpperCase("uber"))
Js.log("Business: " ++ apps(`Uber("business")));

这看起来是一个很好的改进,但让我们看看它是否有效。基于src/Ch10/Ch10_PlatformCompany_V3.re生成的 JavaScript 代码的测试给出了以下输出:

FACEBOOK
Business: facebook ads
Social: facebook, messenger, instagram

GOOGLE
Business: google ads, google adsense, gmail for business
Social: google+, gmail

UBER
Business: uber

很好!我们还能添加什么?

虽然这个实现很棒,但你很快就能看出它缺乏处理更复杂数据结构的能力。基本上,我们希望用一个包含所有必要信息的app来表示,例如名称URL(至少对于 Web 应用)。为此,Reason 有一个方便的工具我们可以使用:记录

让我们看看使用记录处理应用的下个版本会怎样。

使用记录(版本 4)

与我们的第一个变体类型相比,没有变化,但我们将添加webapp记录类型定义,如下所示:

type internetCompany = [ `Facebook(string) | `Google(string) | `Twitter(string) ];
type platformCompany = [ internetCompany | `Amazon(string) | `Uber(string) ];
type webapp = {
  *name*: string,
  *url*: string,
};

然后,我们可以使用该记录类型输入一些数据给程序的其余部分,如下所示:

/* some data */
let *facebook*: webapp = {
  *name*: "facebook",
  *url*: "https://facebook.com",
}
let *facebookads*: webapp = {
  *name*: "facebook ads",
  *url*: "https://www.facebook.com/business",
}
let *messenger*: webapp = {
  *name*: "messenger",
  *url*: "https://www.facebook.com/messenger",
}
let *instagram*: webapp = {
  *name*: "instagram",
  *url*: "https://www.instagram.com",
}

注意,我们只为最小测试定义了这些值中的几个。真正的生产就绪代码应该包括所有需要的记录值。

最小的apps函数将如下所示:

let *apps* = (company: platformCompany) : string => {
  switch (company) {
    | `Facebook(str) => switch str {            
        | "social" => facebook.name ++ ", " ++ messenger.name ++ ", " ++ instagram.name
        | "business" => facebookads.name
    }
  }
};

让我们添加一些输出显示代码,如下所示:

Js.log(Js.String.toUpperCase("facebook"));
Js.log("Business: " ++ apps(`Facebook("business")));
Js.log("Social: " ++ apps(`Facebook("social")));

我们得到了这个最小测试用例的以下输出(来自src/Ch10/Ch10_PlatformCompany_V4.re文件的代码):

FACEBOOK
Business: facebook ads
Social: facebook, messenger, instagram

在下一个版本中,我们还将使用每个应用的 URL 并在输出中显示它。

我们还可以通过将一些类型和函数打包到模块中来改进全局代码结构。

使用模块进行代码结构(版本 5)

我们可以做的第一个改进是创建一个模块来包含 Web 应用的记录以及一个返回它们字符串表示的函数。让我们称这个模块为WebApp。它的定义如下:

module *WebApp* = {
  type t = {
    *name*: string,
    *url*: string,
  };

  let *toString* = (app: t) => app.name ++ " (" ++ app.url ++ ")" ;
}

然后,就像上一个版本一样,我们有我们的示例 Web 应用值。唯一改变的是,类型注解使用WebApp.t完成。这部分代码如下:

let *facebook*: WebApp.t = {
  *name*: "facebook",
  *url*: "https://facebook.com",
}
let *facebookads*: WebApp.t = {
  *name*: "facebook ads",
  *url*: "https://www.facebook.com/business",
}
let *messenger*: WebApp.t = {
  *name*: "messenger",
  *url*: "https://www.facebook.com/messenger",
}
let *instagram*: WebApp.t = {
  *name*: "instagram",
  *url*: "https://www.instagram.com",
}

然后,我们创建一个名为 Platform 的模块,用于其余的逻辑。它可能包含公司类型的定义和 apps 函数。为了简化,让我们选择一个包含所有公司的唯一多态变体类型。在模块内部,我们可以称它为 t

我们创建模块如下:

module *Platform* = {  
  type t = [ `Facebook(string) 
             | `Google(string) 
             | `Twitter(string) 
             | `Amazon(string) 
             | `Uber(string) 
           ];

  let *apps* = (company: t) : string => {
    switch (company) {
      | `Facebook(str) => switch str {            
          | "social" => WebApp.toString(facebook) ++ ", " ++ WebApp.toString(messenger) ++ ", " ++ WebApp.toString(instagram)
          | "business" => WebApp.toString(facebookads)
      }
    }
  };
}

我们可以添加类似的代码来展示一些可能的输出,以及代码执行(由 src/Ch10/Ch10_PlatformCompany_V5.re 文件生成的 JS 代码)给出的输出类似于以下内容:

FACEBOOK
Business: facebook ads (https://www.facebook.com/business)
Social: facebook (https://facebook.com), messenger (https://www.facebook.com/messenger), instagram (https://www.instagram.com)

我们的结果令人鼓舞。可能会有不同的可能性,所以让我们继续通过尝试替代的代码结构并进行后续改进来实验。

替代代码结构(版本 6)

到目前为止,实际上可以在我们的代码文件中使用更少的模块来编写全面的代码。让我们保留平台模块,但将我们在 使用模块进行代码结构(版本 5) 中拥有的 WebApp 模块的内容移出,从而消除该模块。同时,我们将调整一些名称和定义。

当我们有更少的模块时,我们可以通过引入一个接口文件(src/Ch10/Ch10_PlatformCompany_V6.rei)来改进基于类型的代码,该文件用于存储 .re 文件(src/Ch10/Ch10_PlatformCompany_V6.re)的类型信息,如下所示:

type webapp;
type pfcompany;

let *appToString*: webapp => string;

接下来,我们定义 webapppfcompany 类型,并相应地调整 appToString 函数(在 src/Ch10/Ch10_PlatformCompany_V6.re 中),如下所示:

/* Basic types and functions we need (see .rei file) */

type webapp = {
    *name*: string,
    *url*: string,
};

type pfcompany = [ `Facebook(string) 
                    | `Google(string) 
                    | `Twitter(string) 
                    | `Amazon(string) 
                    | `Uber(string) 
                    ];

let *appToString* = (app: webapp) => app.name ++ " (" ++ app.url ++ ")" ;

然后,在执行 let 绑定 以获取 web 应用输入数据的部分没有算法上的变化,因此为了可读性,我们不会在这里重复那段代码。您可以在 src/Ch10/Ch10_PlatformCompany_V6.re 文件中看到完整的记录值集合,您会注意到我们添加了一些 Google 应用的输入,如下所示:

/* Data */

/* ... 
   Extract from src/Ch10/Ch10_PlatformCompany_V6.re */

let *google*: webapp = {
  *name*: "google",
  *url*: "https://google.com",
}
let *gmail*: webapp = {
  *name*: "gmail",
  *url*: "https://google.com/gmail",
}
let *googleads*: webapp = {
  *name*: "google ads",
  *url*: "https://ads.google.com",
}
let googleplus: webapp = {
  *name*: "google+",
  *url*: "https://plus.google.com",
}

接下来,我们改进 Platform 模块代码:

  • 我们有 Platform 模块,它前面是它的签名 PlatformType

  • 我们现在使用 pfcompany 作为 company 值的类型。

  • 我们在 apps 函数的模式匹配代码中添加了 Google(及其应用)的情况。

平台模块代码的改进部分如下:

/* Platform module, signature followed by implementation */

module type PlatformType = {
  let apps: pfcompany => string;
};

module *Platform*: PlatformType = {  
  let *apps* = (company: pfcompany) : string => {
    switch (company) {
      | `Facebook(str) => switch str {            
          | "social" => appToString(facebook) ++ ", " ++ appToString(messenger) ++ ", " ++ appToString(instagram)
          | "business" => appToString(facebookads)
      }

      | `Google(str) => switch str {            
          | "social" => appToString(googleplus)
          | "business" => appToString(googleads)
      }
    }
  };
}

为了使这个实现版本的测试变得容易,我们添加了常用的输入和输出打印代码,如下所示:

Js.log("Facebook")
Js.log("Business: " ++ Platform.apps(`Facebook("business")));
Js.log("Social: " ++ Platform.apps(`Facebook("social")));
print_newline();
Js.log("Google")
Js.log("Business: " ++ Platform.apps(`Google("business")));
Js.log("Social: " ++ Platform.apps(`Google("social")));

总结一下,我们通过使用 .rei 文件(有助于代码文档)改进了类型声明,为平台添加了一个模块签名(PlatformType),并通过添加 Google 情况来扩展了输入的覆盖范围。

鼓励读者添加输入数据(用于变体类型中的其他公司,如 Twitter、Amazon 和 Uber)。

一种改进——使用列表作为输出(版本 7)

您注意到,在我们的输出中,我们只是使用字符串(通过连接)。从一开始,我们就可以返回每个公司真实的应用列表。没问题,让我们现在更改代码来实现这一点。

改变仅限于Platform模块。在签名中,对于apps函数,我们将输出类型从string更改为list(string)。在函数的模式匹配部分,我们相应地更改实现,例如,通过返回[appToString(facebook), appToString(messenger), appToString(instagram)]列表来表示 Facebook 应用。

新版本的主要部分如下:

/* Platform module, signature followed by implementation */

 module type PlatformType = {
   let *apps*: pfcompany => list(string);
 };

 module *Platform*: PlatformType = {  
   let *apps* = (company: pfcompany) : list(string) => {
     switch (company) {
       | `Facebook(str) => switch str {            
           | "social" => [appToString(facebook), appToString(messenger), appToString(instagram)]
           | "business" => [appToString(facebookads),]
       }

       | `Google(str) => switch str {            
           | "social" => [appToString(googleplus),]
           | "business" => [appToString(googleads),]
       }
     }
   };
 }

由于我们现在输出列表,所以使用Array.of_list来打印它们是个好主意,因为它是一个既好又快的解决方案。我们用以下代码更改代码的最后部分:

 Js.log("Facebook")
 print_string("Business: ")
 Js.log(Array.of_list(Platform.apps(`Facebook("business"))));
 print_string("Social: ")
 Js.log(Array.of_list(Platform.apps(`Facebook("social"))));
 print_newline();

 Js.log("Google")
 print_string("Business: ")
 Js.log(Array.of_list(Platform.apps(`Google("business"))));
 print_string("Social: ")
 Js.log(Array.of_list(Platform.apps(`Google("social"))));

使用我的输入数据执行生成的代码,得到以下输出:

Facebook
Business: [ 'facebook ads (https://www.facebook.com/business)' ]
Social: [ 'facebook (https://facebook.com)',
  'messenger (https://www.facebook.com/messenger)',
  'instagram (https://www.instagram.com)' ]

Google
Business: [ 'google ads (https://ads.google.com)' ]
Social: [ 'google+ (https://plus.google.com)' ]

太棒了!这是一个有趣的改进。让我们继续添加。

另一个改进——使用可变记录(版本 8)

现在,我们可以为webapp类型使用可变记录,这样我们就可以用它来处理不断更新的有趣应用数据。这样一个数据点是创建的账户数量。另一个可能是对应移动应用的下载次数。

在这个例子中,让我们看看如何通过向记录中添加账户数量参数来改进我们的实现。这是通过使用mutable numberOfAccounts: int作为记录定义中该参数的条目来完成的。

因此,现在只有这一个更改,但让我们回顾一下webapp类型、pfcompany类型和appToString函数的定义,以便更好地阅读,如下所示:

type webapp = {
    *name*: string,
    *url*: string,
    mutable *numberOfAccounts*: int,
};

type pfcompany = [ `Facebook(string) 
                    | `Google(string) 
                    | `Twitter(string) 
                    | `Amazon(string) 
                    | `Uber(string) 
                    ];

let *appToString* = (app: webapp) => app.name ++ " (" ++ app.url ++ ")" ;

之后,让我们添加一个函数,每次有新的注册时,都会在记录中对应网页应用的numberofAccounts值上增加。这个函数可能看起来如下所示:

let *newSignUp* = (app: webapp) : unit => {
  app.*numberOfAccounts* = app.*numberOfAccounts* + 1;
  ()
};

然后,像之前一样,我们有输入数据部分。但现在我们有了新的参数newSignUp,我们必须不断更新它。为了简化问题,让我们假设所有这些应用目前都有相同的账户数量,我们选择 10,000 作为一个任意数。因此,现在的记录定义如下:

/* Data */

let *facebook*: webapp = {
 * name*: "facebook",
 * url*: "https://facebook.com",
  *numberOfAccounts*: 10000,
}
let *facebookads*: webapp = {
  *name*: "facebook ads",
  *url*: "https://www.facebook.com/business",
  *numberOfAccounts*: 10000,
}
let *messenger*: webapp = {
  *name*: "messenger",
  *url*: "https://www.facebook.com/messenger",
  *numberOfAccounts*: 10000,
}
let *instagram*: webapp = {
  *name*: "instagram",
  *url*: "https://www.instagram.com",
  *numberOfAccounts*: 10000,
}
let *google*: webapp = {
  *name*: "google",
  *url*: "https://google.com",
  *numberOfAccounts*: 10000,
}
let *gmail*: webapp = {
  *name*: "gmail",
  *url*: "https://google.com/gmail",
  *numberOfAccounts*: 10000,
}
let *googleads*: webapp = {
  *name*: "google ads",
  *url*: "https://ads.google.com",
  *numberOfAccounts*: 10000,
}
let *googleplus*: webapp = {
  *name*: "google+",
  *url*: "https://plus.google.com",
  *numberOfAccounts*: 10000,
}

Platform模块部分没有变化,所以让我们转到下一部分,也就是测试代码的部分:

Js.log("Facebook")
print_string("Business: ")
Js.log(Array.of_list(Platform.apps(`Facebook("business"))));
print_string("Social: ")
Js.log(Array.of_list(Platform.apps(`Facebook("social"))));
print_newline();

Js.log("New sign-up on Instagram")
*newSignUp*(instagram);
Js.log("New sign-up on Instagram")
*newSignUp*(instagram);
Js.log(instagram.numberOfAccounts)

src/Ch10/Ch10_PlatformCompany_V5.re文件编译的代码执行给出以下输出:

Facebook
Business: [ 'facebook ads (https://www.facebook.com/business)' ]
Social: [ 'facebook (https://facebook.com)',
  'messenger (https://www.facebook.com/messenger)',
  'instagram (https://www.instagram.com)' ]

New sign-up on Instagram
New sign-up on Instagram
10002

因此,我们只选取了一个有趣的用例,其中可以使用可变记录,并看到了添加该功能是多么简单。

单元测试我们的代码(最终版本)

现在是时候给我们的代码添加测试了!为了完整演示,让我们创建一个新的包,我们将在这个包中设置使用Jest框架编写单元测试所必需的设置。

在 Facebook 使用的另一种网络技术,Jest是一个用于编写 JavaScript 代码测试的框架,它还与编译到 JavaScript 的语言(如 TypeScript 或 ReasonML)一起工作。对于 Reason,我们还需要安装bs-jest包,它为 BuckleScript 提供了 Jest 的绑定。

创建我们的最终包并设置测试

为了快速启动工作,我们创建了一个名为 Ch10-final 的文件夹,其中包含以下文件结构:

bsconfig.json
package.json
src
__tests__

我们通过复制 ReasonML 或 BuckleScript 代码生成器生成的我们正在使用的 package.json 文件,并对其进行调整来获取 package.json 文件。第一个版本如下:

{
  "name": "Ch10-final",
  "version": "0.1.0",
  "scripts": {
    "build": "bsb -make-world",
    "start": "bsb -make-world -w",
    "clean": "bsb -clean-world"
  },
  "keywords": [
    "BuckleScript"
  ],
  "author": "",
  "license": "MIT",
  "devDependencies": {
    "bs-platform": "⁴.0.7",
  },
  "dependencies": {}
}

然后,我们可以使用 npmJestbs-jest(确切地说是 glennsl/bs-jest 中引用的)添加到我们的包中作为开发依赖项。

要安装 bs-jest,请运行 npm install @glennsl/bs-jest --save-dev 命令。

要安装 Jest,请运行 npm install jest --save-dev 命令。

使用这些安装,所需的文件被安装到常规的 node_modules 子目录中,并且我们的 package.json 文件更改以引用已安装的两个依赖项的版本。package.json 文件中的更新 devDependencies 显示了两个新增项,如下所示:

"devDependencies": {
    "@glennsl/bs-jest": "⁰.4.5",
    "bs-platform": "⁴.0.7",
    "jest": "²³.6.0"
  },

bsconfig.json 文件也是从我们之前使用的代码设置中复制(并调整)的(这允许 bsb -w 命令工作,并且我们的 .re 文件可以即时编译)。我们将 sources 列表调整为引用 src 目录的常规代码和 __tests__ 目录的 测试代码(注意 "type": "dev" 部分),如下所示:

"sources": [        
    {
      "dir" : "src",
    },  
    {
      "dir": "__tests__",
      "type": "dev",
    },
  ],

我们最后需要添加的是将 @glennsl/bs-jest 添加到 bs-dev-dependencies 参数中。我们将在下一分钟看到原因。

我们 ch10final 包的 bsconfig.json 文件如下:

{
  "name": "ch10final",
  "version": "0.1.0",
  "sources": [        
    {
      "dir" : "src",
    },  
    {
      "dir": "__tests__",
      "type": "dev",
    },
  ],
  "package-specs": {
    "module": "commonjs",
    "in-source": true
  },
  "suffix": ".bs.js",
  "bs-dependencies": [
  ],
  "bs-dev-dependencies": ["@glennsl/bs-jest"],
  "warnings": {
    "error" : "+101"
  },
  "namespace": true,
  "refmt": 3
}

src 子目录中,我们可以添加我们的最终 Reason 代码文件(src/Ch10-final/src/Ch10_PlatformCompany.re)。我们的 ReasonML 代码与上一个版本(src/Ch10/Ch10_PlatformCompany_V8.re 文件)完全相同,除了我们通过删除最后部分(打印一些输出的代码片段)来简化它。

现在,让我们编写测试。

编写我们的第一个测试

要使用 Jest 测试代码,我们必须首先打开模块:

open Jest;

然后,我们定义一个 describe 函数来封装测试套件。我们需要打开 Expect 模块,它是 Jest 的一部分,它提供了 expect 函数以及其他一些我们需要检查某些值是否满足特定条件的地方。我们还打开模块文件,其中包含我们的实现代码。到目前为止,我们的测试函数包含以下内容:

describe("Platform", () => {
  open Expect;
  open Ch10_PlatformCompany;

});

现在,我们可以添加第一个测试来验证 Platform 模块中 apps 函数返回的数据。测试套件代码如下:

describe("Platform", () => {
  open Expect;
  open Ch10_PlatformCompany;

  test("list facebook business app", () => {
    let *facebook_biz* = Platform.apps(`Facebook("business"));
    expect(facebook_biz) |> toEqual([ "facebook ads (https://www.facebook.com/business)" ]);
  });
});

我们不要停下来,添加第二个测试来验证在调用 newSignUp 函数后,账户数量是否是上一个数量加 1

完整的测试套件代码如下(在 src\Ch10-final\__tests__\Platform_test.re 文件中):

open Jest;

describe("Platform", () => {
  open Expect;
  open Ch10_PlatformCompany;

  test("list facebook business app", () => {
    let *facebook_biz* = Platform.apps(`Facebook("business"));
    expect(facebook_biz) |> toEqual([ "facebook ads (https://www.facebook.com/business)" ]);
  });

  test("instagram number of accounts", () => {
    let *nb* = instagram.numberOfAccounts;
    *newSignUp*(instagram);
    expect(instagram.numberOfAccounts) |> toEqual(nb + 1);
  });
});

运行测试

在我们执行测试之前,我们需要使用bsb -make-world命令来构建代码。这个命令会找到测试代码并编译它。如果一切顺利,这个过程会将生成的文件复制到包结构的lib部分(位于src\Ch10-final\lib\bs\__tests__)。我们现在可以开始运行测试了。

要运行测试,我们使用Jest命令。在我的情况下,在 Windows 上运行,可执行文件实际上位于node_modules\.bin\jest,但根据你的情况,你只需输入jest即可。

当我们执行Jest测试运行器命令时,我们会得到以下输出:

 PASS  __tests__/Platform_test.bs.js
  Platform
    √ list facebook business app (16ms)
    √ instagram number of accounts (2ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        8.433s
Ran all test suites.

到目前为止,我们已经有一个良好的结构,包括一个测试套件,我们可以在此基础上进行扩展。我们可以扩展代码的输入部分,以考虑所有平台公司,并添加更多功能。我们还可以在过程中添加更多测试。这留作读者的练习。

摘要

在本章中,我们使用 Reason 的核心特性构建了一些类型安全的代码,这些代码也相对容易维护和扩展。我们可以进一步使用高级技术,如函子,来创建更通用的代码,但这对于这个小示例来说并不是必要的。

这是我们最后一章。我们通过类型驱动的过程解决了编码问题。在这个过程中,我们提高了对 ReasonML 的特性和技术,特别是变体类型、函数、模块和记录的理解。我们还探讨了如何使用Jest框架直接测试 ReasonML 代码。

希望这本书作为 ML 语言世界的入门之书是有用的,并且它将帮助你进一步学习 ReasonML 的技术和工具,如果你是一个具有额外技能的 Web 开发者,甚至可能包括 React。

posted @ 2025-10-27 08:49  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报