OCaml 编程:正确+高效+美丽

OCaml 函数编程和数据结构教材,强调语义和软件工程。这本书是康奈尔大学 CS 3110 数据结构和函数编程的教材。该书曾经有过一个标题叫“OCaml 函数编程”。

春季 2024 年版

视频。这个书中嵌入了超过 200 个 YouTube 视频。这些视频可以独立于阅读书籍观看。从这个 YouTube 播放列表开始。

作者们。这本书基于迈克尔·R·克拉克森、罗伯特·L·康斯塔布尔、内特·福斯特、迈克尔·D·乔治、丹·格罗斯曼、 جست因·休、丹尼尔·P·胡滕洛赫、德克斯特·科曾、葛雷格·莫里塞特、安德鲁·C·迈耶斯、拉杜·鲁吉娜和拉敏·扎比赫等人的课程讲义。他们一起创造了超过 20 年的课程笔记和智慧贡献。现在,确定谁做出了什么贡献已经不是一个容易的任务。该书的主要编译器和作者是迈克尔·R·克拉克森,他在 2021 年秋季版中是本书大约 40%的字词和代码 token 的作者。

版权所有 2021—2024 迈克尔·R·克拉克森。根据创作共享 4.0 国际许可证 Attribution-NonCommercial-NoDerivatives 发布。

关于本书

报告错误。如果您发现错误,请报告!或者如果您对如何重写书中的某部分有建议,请告诉我们。只需转到您想提出建议的书页,点击页面右上角附近的 GitHub 图标(看起来像只猫),然后点击“打开问题”或“建议编辑”。后者稍微复杂一些,因为它要求您使用 GitHub 分叉教材存储库。但对于将受到赞赏并导致建议更快被采纳的小修改。

背景。这本书在康奈尔大学用于第三学期的编程课程。大多数学生已经学过一学期的 Python 入门编程,然后是一学期的 Java 面向对象编程。因此,经常会将这两种语言进行比较。已经学过类似语言的读者应该没有任何难度跟上。这本书不假设读者有任何函数式编程的先前知识,但假设读者有在某种主流命令式语言中编程的经验。还假设读者具有标准第一学期计算机科学课程水平的离散数学知识。

视频。您将在本书中找到超过 200 个嵌入的 YouTube 视频。这些视频通常为材料提供介绍,然后教科书进行扩展。这些视频是在大流行期间制作的,当时使用这本教科书的康奈尔课程 CS 3110 必须是异步的。学生对它们的反应非常积极,因此它们现在作为教科书的一部分公开发布。但是请注意,它们并不是由专业的音视频团队制作的 - 只是一个在地下室里学习的人。

视频主要使用了 2020 年秋季流行的 OCaml 及其生态系统的版本。您目前使用的版本可能与视频中的版本有所不同,但不要惊慌:基本思想是相同的。最明显的区别可能是 OCaml 的 VS Code 插件。在 2020 年秋季,仍在使用过时的“OCaml 和 Reason IDE”插件。它已被“OCaml 平台”插件取代。

教科书和视频有时以不同的顺序涵盖主题。视频放置在最接近其涵盖主题的教科书中。要按照原始顺序观看视频,请从这个 YouTube 播放列表开始。

协作式注释。在每页的右边缘,您会发现由 hypothes.is 提供的注释功能。您可以在学习文本时使用它来突出显示和做私人笔记。您可以组建学习小组来分享您的注释,或者公开分享它们。查看这些有效注释的技巧。

可执行代码。本书的许多页面中嵌入了 OCaml 代码。该代码的输出已经显示在书中。以下是一个例子:

print_endline "Hello world!"
Hello world!
- : unit = ()

您也可以编辑并重新运行代码,以便进行实验并检查您的理解。在页面右上方附近寻找一个看起来像火箭船的图标。在下拉菜单中,您会找到两种与代码交互的方式:

  • Binder 将推出网站 mybinder.org,这是一个免费的基于云的服务,用于“在规模上实现可重现、交互式、可共享的科学环境”。所有计算都在他们的云服务器中进行,但用户界面通过您的浏览器提供。在 Binder 中打开教科书页面可能需要一点时间。一旦打开,您可以在 Jupyter 笔记本中编辑和运行代码。Jupyter 笔记本是文档(通常以 .ipynb 扩展名结尾),可以在 Web 浏览器中查看,并用于撰写叙述内容以及代码。它们在数据科学社区(特别是 Python、R 和 Julia)中变得流行,作为分享分析的一种方式。现在许多语言都可以在 Jupyter 笔记本中运行,包括 OCaml。在 Jupyter 笔记本中,代码和文本以单元格形式编写。查看其中的“Cell”菜单以获取运行单元格的命令。请注意,Shift-Enter 通常是运行具有焦点的单元格的快捷键。

  • 实时代码实际上会做大致相同的事情,只是不会离开当前的教材页面并将您带到 Binder,而是会修改页面上的代码单元格以便进行编辑。在幕后建立连接需要一些时间,在此期间您会看到“等待内核”的提示。连接建立后,您可以编辑页面上的所有代码单元格并重新运行它们。如果连接失败,那么首先启动 Binder 站点;这可能需要很长时间。在成功连接并将教材页面加载为 Jupyter 笔记本后,您可以关闭 Binder,重新加载教材页面,然后再次启动实时代码。这时连接应该会相对快速地成功。

尝试与上面的单元格进行交互,让它打印出您选择的字符串。比如: "Camels are bae."

当您编写“真实”的 OCaml 代码时,这不是您将使用的界面。您将在诸如 Visual Studio Code 或 Emacs 之类的编辑器中编写代码,并从终端编译它。Binder 和 Live Code 仅用于与教科书无缝交互。

可下载页面。本书的每一页都可以以多种格式下载。下载图标位于每一页的右上角。您总是可以找到页面的原始源代码,通常是 Markdown——更确切地说是 MyST Markdown,这是 Markdown 的技术写作扩展。每一页也可以单独作为 PDF 下载,只需从您的浏览器中打印即可。有关整本书的 PDF,请参见下面的段落。

包含 OCaml 代码单元格的页面也可以下载为 Jupyter 笔记本。要在本地计算机上运行这些笔记本(而不是在 Binder 云端运行),您需要安装 Jupyter。通常最简单的方法是安装 Anaconda。然后您需要安装 OCaml Jupyter,这需要您已经安装了 OCaml。需要明确的是,无需安装 Jupyter 或使用笔记本。这只是与本教材互动的另一种方式,超越阅读。

练习和解答。除第一章外,每章末尾都有一节练习。练习附有难度评级:

  • 一颗星[★]:简单的练习,只需一两分钟。
  • 两颗星[★★]:直接的练习,应该只需要几分钟。
  • 三星[★★★]:可能需要五到二十分钟左右的练习。
  • 四颗星或更多星:为那些希望深入学习材料的学生提供具有挑战性或耗时的练习。

有时候我们可能会误判问题的难度。如果您认为某个注释有误,请告诉我们。

请不要在任何地方发布您对练习的解决方案,尤其是不要在公共存储库中发布,以免被搜索引擎发现。大多数练习的解决方案都已提供。2022 年秋季是这些解决方案的首次公开发布。尽管这些解决方案已经提供给康奈尔大学的学生几年了,但更广泛的传播将揭示可以进行改进的地方。我们很乐意添加或更正解决方案。请通过 GitHub 进行贡献。

PDF。本书提供完整的 PDF 版本。它不包含 HTML 版本具有的嵌入视频、注释或其他功能。可能还存在排版错误。目前尚无平板电脑(ePub 等)版本,但大多数平板电脑可以导入 PDF 文件。

安装 OCaml

如果您只需要跟随本书中的代码示例,您实际上不需要安装 OCaml!每页上的代码在浏览器中可执行,如前面在这本前言中所述。

如果您想进一步,但还不准备花时间安装 OCaml 自己,我们提供了一个带有预安装的 OCaml 虚拟机,运行在 Linux 操作系统中。

但是如果你想自己做 OCaml 开发,你需要在机器上安装它。没有一种通用的“正确”方法来实现这个目标。下面提供的指南是 Cornell CS 3110 课程的一部分,这个课程有其自己的目标和需求,远远超出了只是 OCaml。但是,即使你不是这个课程的学生,你也可能会发现这些指南很有用。

我们将要安装:

  • Unix 开发环境
  • OPAM,OCaml 软件包管理器
  • 一个 OPAM 开关带有 OCaml 编译器和一些包
  • Visual Studio Code 编辑器,具有 OCaml 支持

安装过程将主要依赖于计算机的终端或文本界面。如果您不太熟悉它,您可能想学习一下终端教程

如果这是你第一次安装开发软件,那么值得注意的是,“关闭”不算:尝试继续执行错误通常只会导致更糟糕的错误,并且感到沮丧。这是因为我们正在安装一座软件塔,每层塔都基于前一个。如果你没有建立在坚实的基础上,这整个结构可能会塌陷。好消息是,如果你遇到错误,你可能不是唯一的人。快速搜索一下 Google 通常可以找到他人已经发现的解决方案。当然,也要对互联网上的随机陌生人的建议进行批判性思考。

让我们开始!

Unix 开发环境

首先,升级您的操作系统。如果您计划进行任何主要的操作系统升级,请现在做。否则,当您实际升级时,您可能需要重复部分或全部安装过程。事先处理更好。

Linux

如果您已经在运行 Linux 了,那么这个步骤就完成了。请继续到下面的安装 OPAM步骤。

Mac

以下面层面,macOS 实际上已经是一个基于 Unix 的操作系统。但是,您需要一些开发工具和一个 Unix 包管理器。有两个选择:HomebrewMacPorts。从本书籍和 CS 3110 的角度来说,这并不重要:

  • 如果你已经习惯使用某个编辑器,随时继续使用它。确保在继续这些指令之前运行其更新命令。
  • 否则,选择一个,然后按照其网站上的安装说明进行安装。Homebrew 的安装过程通常更简单、更快,这可能会使您朝这个方向发展。如果您选择 MacPorts,确保遵循其页面上的所有详细说明,包括 XCode 和 X11 服务器。不要同时安装 Homebrew 和 MacPorts;它们不能共存。如果您后来改变主意,请在安装另一个之前卸载其中的一个。

安装完成 Homebrew 或 MacPorts 更新后,请继续安装 OPAM,以下。

Windows

Unix 开发在 Windows 中是由 WindowsSubsystem for Linux(WSL)所可能的。如果您拥有 Windows 的最新版本(20262,2020 年 11 月发布或更新),那么 WSL 安装起来很容易。如果您没有那么新的版本,可以尝试运行 Windows 更新以获取它。

如果您在安装 WSL 时遇到关于“虚拟机”的错误,您可能需要在机器的 BIOS 中启用虚拟化。该操作步骤取决于您的机器制造商。尝试搜索“启用虚拟化 [制造商] [型号]”,将制造商和型号替换为您的机器信息。这一页可能也会有助于您。

使用最新版本的 Windows,并假设您从未安装过 WSL,以下是您需要做的事情:

  • 打开 Windows PowerShell 以管理员身份。要做到这一点,请点击“开始”,输入“PowerShell”,它应该显示为最佳匹配。点击“以管理员身份运行”,然后单击“允许更改”以允许更改。
  • 运行 wsl --install 。(如果您已经安装了 WSL,但之前没有安装 Ubuntu,那么请运行 wsl --install -d Ubuntu 。)当 Ubuntu 下载完成后,它可能会询问您是否需要重新启动。请重新启动。安装将在重新启动后自动恢复。
  • 你将被提示创建一个 Unix 用户名和密码。你可以使用任何用户名和密码,你愿意。它与你的 Windows 用户名和密码无关(虽然你可以重复使用它们)。不要在用户名中输入空格。不要忘记你的密码。你将需要它未来。

不要继续执行这些指令,如果您没有被提示创建 Unix 用户名和密码。可能 Ubuntu 安装未完成正确。尝试从 Windows 开始菜单中卸载 Ubuntu 并重新安装它。

现在跳到下面的“Ubuntu 设置”段落。

没有最新版本的 Windows,您需要遵循 Microsoft 的手动安装指南。WSL2 是 OCaml 所 prefer 的选择(WSL2 还提供了性能和功能改进),因此,如果可以,请安装 WSL2。

Ubuntu 安装。这些剩余的指令假设您安装了 Ubuntu(22.04)作为 Linux 发行版。这是 WSL 中的默认发行版。在原则上,其他发行版也应该可以工作,但是可能需要从现在开始使用不同的命令。

打开 Ubuntu 应用程序。(可能已经打开,如果你刚刚安装了 WSL。)你将在 Bash 提示符下,这个界面看起来像这样:

user@machine:~$

如果该提示看起来像 root@...# ,那么出了问题。您在上一步中为 Ubuntu 创建了 Unix 用户名和密码吗?如果是这样,那么这个提示中的用户名应该是您之前选择的那个,而不是 root 。如果您的提示看起来像 root@...# ,不要继续执行这些指令。可能需要卸载 Ubuntu 并重新安装它。

在 Windows 终端的当前版本中,Ctrl+Shift+C 将复制到终端,而 Ctrl+Shift+V 将粘贴到终端。请注意,您需要将 Shift 作为该快捷键的一部分。在 older 版本的终端中,您可能需要在终端设置中找到一个选项以启用这些键盘快捷键。

更新 APT 包管理器,这是安装 Unix 软件包的工具,可以使用以下命令:

sudo apt update

你将被提示输入你选择的 Unix 密码。前缀 sudo 表示以管理员身份运行命令,也就是所谓的“超级用户”。换言之,这个命令应该以超级用户身份执行,因此是“sudo”。

运行命令时使用 sudo 可能危险,应该谨慎。不要养成将 sudo 加在命令前面的习惯,也不要随意尝试没有原因的操作。

现在运行这个命令来升级所有 APT 软件包:

sudo apt upgrade -y

然后安装一些有用的包,我们将需要它们:

文件系统。WSL 拥有自己的文件系统,这与 Windows 文件系统不同,虽然存在访问每个的方法。

  • 当你启动 Ubuntu 并获得 $ 提示符时,你就在 WSL 文件系统中。你的家目录在那里被命名为 ~ ,这是对 /home/your_ubuntu_user_name 的内置别名。你可以运行 explorer.exe . (注意最后的点)以在 Windows 探索器中打开你的 Ubuntu 家目录。
  • 从 Ubuntu 中,您可以访问您的 Windows 主目录在路径 /mnt/c/Users/your_windows_user_name/
  • 从 Windows 资源管理器,您可以在左侧列表中找到 Linux 图标,访问 Ubuntu 主目录(靠近“这个计算机”和“网络”),然后导航到 Ubuntu → homeyour_ubuntu_user_name 。或者,您可以直接输入 Windows 资源管理器路径栏: \wsl$\Ubuntu\home\your_ubuntu_user_name 。

实践访问您的 Ubuntu 和 Windows 主目录现在,并确保您可以识别当前是哪一个。对于高级信息,请查看 Microsoft 关于 Windows 和 Linux 文件系统的指南

我们建议将 OCaml 开发工作存储在 Ubuntu 主目录中,而不是 Windows 主目录中。由此,微软也推荐这样做,如上链接中的指南所示。

安装 OPAM

Linux。遵循您的发行版说明

Mac。如果您使用 Homebrew,运行以下命令:

brew install opam

如果您使用 MacPorts,运行以下命令:

sudo port install opam

Windows。从 Ubuntu 运行以下命令:

sudo apt install opam

初始化 OPAM

不要在任何 opam 命令前面加上 sudo 。那样将会破坏您的 OCaml 安装。

Linux、Mac 和 WSL2。运行:

opam init --bare -a -y

不要担心如果您收到一条关于确保 .profile.bashrc 中“充分来源”的提示。您不需要对此进行任何操作。

WSL1。希望您正在运行 WSL2,而不是 WSL1。但是在 WSL1 中,请运行:

opam init --bare -a -y --disable-sandboxing

因为 OPAM 和 WSL1 存在问题,需要禁用沙盒

创建 OPAM 切换

A switch 是 OCaml 的一个命名安装,它具有特定的编译器版本和一组包。您可以拥有多个开关,并且可以在它们之间切换——因此得名。为本学期的 CS 3110 创建一个开关,运行以下命令:

opam switch create cs3110-2024sp ocaml-base-compiler.5.1.1

如果那个命令失败说 5.1.1 编译器找不到,你可能以前安装过 OPAM 现在需要更新它。使用 opam update 进行更新。

你可能会被提示运行下一个命令。无论你是否执行它,因为我们即将做的下一步操作(即,登出)都无关紧要。

现在我们需要确保您的 OCaml 环境正确配置。退出操作系统(或直接重启)。然后重新打开终端,运行以下命令:

opam switch list

你应该获得输出类似这样

#  switch         compiler                    description
→  cs3110-2024sp  ocaml-base-compiler.5.1.1   cs3110-2024sp

如果您之前有过 OCaml 开发经验,那可能会出现其他行。下面是需要检查的内容:

  • 你不能收到警告“环境不与当前开关同步。你应该运行 eval $(opam env) ”。如果以下两个问题也出现,你需要先解决这个问题。
  • 必须在 cs3110-2024sp 开关旁边的第一列中有一个正确的箭头。
  • 那个开关必须具有正确的名称和正确的编译器版本,5.1.1。

如果你收到关于 opam env 的警告,事情出了错。你的 shell 可能没有运行 opam init 所安装的 OPAM 配置命令。你可以尝试 opam init --reinit 来看是否能解决问题。另外,请确保你真的退出了操作系统(或重新启动)。

继续安装我们需要的 OPAM 包:

opam install -y utop odoc ounit2 qcheck bisect_ppx menhir ocaml-lsp-server ocamlformat

确保复制整个行。您将获得关于编辑器配置的输出,除非您打算使用 Emacs 或 Vim 进行 OCaml 开发,可以安全地忽略该输出。我们将在这些说明中使用 VS Code 作为编辑器,所以让我们忽略它。

你现在应该能够启动 utop,OCaml 通用终端。

utop

您应该看到一条消息“欢迎使用 utop 版本…(使用 OCaml 版本 5.1.1)!”如果 OCaml 版本不正确,那么你可能遇到环境问题。见上面的 opam env 命令提示。

输入 3110 后跟两个分号。按回车键。#是 utop 提示符;你自己不需要输入它。

# 3110;;
- : int = 3110

停止欣赏 3110 多么美丽。然后退出 utop。注意这次你必须在 quit 指令前添加额外的#符号。

# #quit;;

退出的更快方法是键入 Control+D。

双重检查 OCaml

如果您遇到安装问题,请遵循以下检查指南。其中一些重复了我们之前提供的提示,但我们将它们都放在一起,以帮助诊断任何问题。

首先,重新启动您的计算机。我们需要一个干净的白板来进行这个双重检查程序。

第二,运行 utop,并确保它工作正常。如果不工作,请查看以下常见问题:

  • 你在正确的 Unix 提示符中吗? 在 Mac 上,确保你在 Terminal 中的默认 Unix shell 中,不要手动运行 bash 或 zsh 或其他 shell。 在 Windows 上,确保你在 Ubuntu 应用程序中,而不是 PowerShell 或 Cmd。

  • OPAM 环境是否已经设置?如果 utop 不是 recognized 命令,运行 eval $(opam env) 然后再次尝试运行 utop。如果 utop 现在工作正常,那么你的登录 shell 可能没有正确地激活 OPAM 环境;你不需要手动使用 eval 命令激活环境。可能是之前执行了 opam init 命令时出了问题。要解决这个问题,请按照以下“redo”指令进行操作。

  • 您的开关列表了吗?运行 opam switch list ,确保有一个名为 cs3110-2024sp 的开关,它具有 5.1.1 编译器,并且是活动开关(以箭头旁边表示)。如果该开关存在但不是活动开关,请运行 opam switch cs3110-2024sp ,然后查看 utop 是否工作。如果该开关不存在,请遵循以下“redo”指令。

红色重做指令:使用 rm -r ~/.opam 删除 OPAM 目录。然后,返回上面的 OPAM 初始化步骤,并继续前进。特别注意在执行上述 OPAM 命令时不要遗漏任何部分,因为有时会出现错误。最后,重新启动并检查 utop 是否仍然可用。

你想让 utop 在重启后立即工作,不需要输入任何额外命令。

Visual Studio Code

Visual Studio Code 是一款适合 OCaml 代码编辑器的不错选择。(如果您已经是 Emacs 或 Vim 的高级用户,那么它们也很不错。)

首先,下载并安装 Visual Studio Code(以下简称 VS Code)。启动 VS Code。打开扩展面板,可以通过“视图”→“扩展”,或者单击左侧图标栏中的四个小方块图标,其中右上角的方块与其他三个方块分离。

在以下指令中,您将被要求“打开命令面板”。要做到这一点,请转到视图→命令面板。同时,也有操作系统特定的键盘快捷键,您将看到该快捷键的右侧,在该视图菜单中的“命令面板”字样下方。

第二步,如果您使用的是 Windows 或 Mac,请遵循以下一步骤:

  • Windows-only:安装“WSL”扩展。
  • Mac 仅:打开命令面板,输入“shell command”,找到“Shell Command: 安装‘code’命令到 PATH 中”命令。运行它。

第三,無論你使用的是哪种操作系统,请先关闭所有打开的终端窗口或直接注销或重启,以便让新的路径设置生效,然后你将能够从终端中启动 VS 代码。

第四,仅在 Windows 上,打开命令面板并运行“WSL:连接到 WSL”命令。(如果您使用 Mac,则跳过下一步。)第一次执行此操作时,它将安装一些额外的软件。完成后,您将看到 VS Code 窗口底部左侧的“WSL:Ubuntu”指示符。确保在继续下一步之前,您已经看到“WSL:Ubuntu”。如果您只看到一个 > < 图标,请点击它,然后从打开的命令面板中选择“连接到 WSL”。

五、再次打开 VS Code 扩展面板。搜索并安装来自 OCaml Labs 的“OCaml 平台”扩展。确保安装该扩展时名称与“OCaml 平台”完全匹配。

OCaml 名为“OCaml”或“OCaml 和 ReasonIDE”的扩展不是正确的。它们都是老旧的,已经不再由开发者维护了。

双重检查 VS Code

让我们确保 VS 代码的 OCaml 支持正在工作。

  • 重新启动你的计算机一次。(是的,这真的不应该必要。但现在它会检测许多潜在错误,因此这笔努力值得。)
  • 开启一个新的 Unix 壳。Windows:记住这是 Ubuntu,而不是 PowerShell 或 Cmd。Mac:记住你不应该手动切换到不同的 shell,输入 zshbash
  • 导航到您选择的目录,尽量是一个家目录的子目录。例如,您可能在家目录中创建一个用于您的 3110 工作的目录:
mkdir ~/3110
cd ~/3110

在那个目录中运行以打开 VS 代码:

code .

去到“文件”→“新建文件”。将文件以名称 test.ml 保存。VS Code 应该为其添加橙色骆驼图标。

  • 类型以下 OCaml 代码,然后按 Return/Enter 键:
let x : int = 3110

当你输入代码时,VS Code 应该将语法着色、建议一些完成项,并在代码行上添加一个小注释。尝试将你输入的 int 改为 string 。下面 3110 应该出现一条波浪线。鼠标悬浮在它上面可以看到错误信息。同时,在“视图”→“问题”中也可以看到它。将整数加上双引号,使其变成字符串,这个问题就解决了。

如果您不观察这些行为,安装出了问题。下面是解决方法:

  • 确保从您启动 VS Code 的同一个 Unix 提示符下,可以成功完成 OPAM 开关的双重检查指令:您可以运行 utop 吗?当前是否激活了正确的开关?如果不是,那么这是你需要解决的问题。然后返回到 VS Code 问题。如果现在已经修复了。
  • 如果您在 WSL 中运行 VS Code,但它并没有添加上述描述的语法高亮和 squiggles,那么请检查 VS Code 窗口底部左侧是否显示“WSL”指示符。如果您不见到,请确保安装了“WSL”扩展,并且从 Ubuntu 启动 VS Code,而不是从 PowerShell 或 Windows GUI 启动。如果您已经看到,请确保安装了“OCaml 平台”扩展。

如果您仍然遇到问题,请尝试卸载 VS Code,重新启动,然后从头开始执行上述安装指令。请注意任何警告或错误信息。

VS Code 中遇到任何问题时,不管您在线找到什么建议,请不要在 VS Code 设置文件中硬编码路径。这只是解决问题的 Band-Aid,而不是真正问题的根源。实际上,真正的问题可能是 OCaml 环境问题,您可以使用上述 OCaml 双重检查指令来调查。

VS Code 设置

我们建议调整一些编辑器设置。打开用户设置 JSON 文件,方法如下:(一)转到视图→命令面板,(二)输入“用户设置 json”,(三)选择打开用户设置(JSON)。将这些设置粘贴到窗口中:

{
    "editor.tabSize": 2,
    "editor.rulers": [ 80 ],
    "editor.formatOnSave": true
}

保存文件并关闭标签。

使用 VS 代码协作

VS Code 的 Live Share 扩展使得与其他人类一起编写代码变得轻松和有趣。您可以像在 Google 文档中协作一样编辑代码。它甚至支持共享语音通道,因此不需要单独启动 Zoom 会议。要安装和使用 Live Share,请遵循 Microsoft 的教程

如果您是康奈尔学生,请使用您的 Microsoft 账户登录,而不是 GitHub。输入您的康奈尔 NetID 电子邮件,例如 your_netid@cornell.edu 。这将带您到康奈尔的登录网站。使用与您的 NetID 相关联的密码。

通过 OCaml 更好地编程

你已经掌握了像 Python 或 Java 这样的主流语言的编程技能吗?很好。这本书适合你。是时候学习如何更好地编程了。是时候学习一种函数式语言,OCaml。

函数式编程提供了与您迄今为止所经历的编程不同的视角。适应这种视角需要放弃旧观念:赋值语句、循环、类和对象等。这并不容易。

南隠(南隐),日本明治时代(1868-1912 年)的一位大师,接待了一位前来探讨禅宗的大学教授。南隠给他倒茶。他将访客的杯子倒满,然后继续倒。教授看着溢出来,直到他再也忍受不住。“满了,再也倒不下去了!”“就像这个杯子一样,”南隠说,“你充满了自己的观点和猜测。除非你先倒空你的杯子,否则我怎么能向你展示禅呢?”

我相信学习 OCaml 会让你成为更好的程序员。以下是原因:

  • 您将体验到不可变性的自由,其中所谓的“变量”的值无法更改。再见,调试。
  • 您将在抽象化方面有所提高,这是通过提取共性来避免重复的实践。再见,臃肿的代码。
  • 你将接触到一种类型系统,起初你会讨厌它,因为它会拒绝你认为是正确的程序。但你会逐渐喜欢它,因为你会谦卑地意识到它是正确的,而你的程序是错误的。再见,失败的测试。
  • 您将接触到一些编程语言的理论和实现,帮助您理解在编写代码时向计算机传达的基础知识。告别神秘和魔法般的咒语。

所有这些想法都可以在其他背景和语言中学习。但 OCaml 提供了一个难得的机会将它们全部捆绑在一起。OCaml 将改变你对编程的思考方式。

“一种不影响你对编程思考方式的语言是不值得学习的。” —艾伦·J·佩里斯(1922-1990 年),图灵奖的首位获得者

此外,OCaml 是美丽的。OCaml 优雅、简洁、优美。美学很重要。代码不仅仅是为了让机器执行而编写的。它也是为了与人类沟通而编写的。优雅的代码更易阅读和维护。编写优雅的代码并不一定更容易。

你写的 OCaml 代码可以是时尚和有品位的。起初,这可能并不明显。毕竟,你正在学习一门新语言——你不会期望在初学梵文的第一天就欣赏梵文诗歌。事实上,当你努力用一门新语言表达自己时,你可能会感到沮丧一段时间。所以给自己一些时间。当你掌握了 OCaml 之后,当你回到其他你已经了解的语言时,你可能会惊讶地发现它们变得多么丑陋。

OCaml 的过去

从家族谱的角度来看,OCaml 来自编程语言的血统,其祖父是 Lisp,包括其他现代语言如 Clojure、F#、Haskell 和 Racket。

OCaml 起源于苏格兰爱丁堡计算机科学实验室的罗宾·米尔纳(Robin Milner)等人的工作。他们在 1970 年代末和 1980 年代初致力于定理证明器。传统上,定理证明器是用诸如 Lisp 之类的语言实现的。米尔纳一直遇到一个问题,即定理证明器有时会将不正确的“证明”(即非证明)组合在一起,并声称它们是有效的。因此,他尝试开发一种只允许您构建有效证明的语言。ML 代表“元语言”,是该工作的结果。ML 的类型系统经过精心构建,以便您只能在该语言中构建有效证明。然后,定理证明器被编写为一个构建证明的程序。最终,这种“经典 ML”演变成了一种成熟的编程语言。

在 80 年代初,机器学习领域出现了一场分裂,法国人站在一边,英国人和美国人站在另一边。法国人继续发展了 CAML,后来发展出了 Objective CAML(OCaml),而英国人和美国人则发展了 Standard ML。这两种方言非常相似。微软在 2005 年推出了自己的 OCaml 变体,称为 F#。。

Milner 在 1991 年因其在 ML 领域的工作而获得了图灵奖。[ACM]((https://amturing.acm.org/award_winners/milner_1569367.cfm) 为他的奖项所做的网站包括了这样的赞美:

ML 远远领先于其时代。它建立在清晰和明确的数学思想之上,这些思想被拆分开来,以便能够独立研究,并相对容易地重新混合和重复使用。ML 已经影响了许多实用语言,包括 Java、Scala 和微软的 F#。事实上,任何认真的语言设计者都不应忽视这个优秀设计的例子。

OCaml 的现在

OCaml 是一种函数式编程语言。函数式语言的关键语言抽象是数学函数。函数将一个输入映射到一个输出;对于相同的输入,它总是产生相同的输出。也就是说,数学函数是无状态的:它们不保留任何额外信息或在函数使用之间保持的状态。函数是第一类的:您可以将它们用作其他函数的输入,并将函数作为输出。用函数来表达一切使得编程模型更加统一和简单,比其他语言族中找到的过程和方法更容易推理。

命令式编程语言如 C 和 Java 涉及可变状态,在执行过程中会发生变化。命令指定如何通过破坏性地改变状态来计算。过程(或方法)可能具有会更新状态的副作用,除了产生返回值。

可变性的幻想是很容易推理的:机器做这个,然后这个,依此类推。

可变性的现实是,尽管机器擅长对状态进行复杂操作,但人类并不擅长理解它。这种情况为什么会发生的本质是,可变性破坏了引用透明性:即能够用其值替换表达式而不影响计算结果的能力。在数学中,如果 \( f(x) = y \),那么你可以在任何地方替换 \( y \) 为 \( f(x) \) 。在命令式语言中,你不能这样做:f 可能会有副作用,所以在时间 \( t \) 计算 \( f(x) \) 可能会得出与时间 \( t' \) 不同的值。

诱人的想法是相信机器操纵一个单一状态,并且机器一次只做一件事。计算机系统竭尽全力试图提供这种幻觉。但事实上,有许多状态分布在线程、核心、处理器和网络计算机之间。机器同时做许多事情。可变性使得对分布状态和并发执行的推理变得极其困难。

不可变性可以使程序员摆脱这些顾虑。它提供了构建正确和并发程序的强大方式。OCaml 主要是一种不可变语言,就像大多数函数式语言一样。它支持具有可变状态的命令式编程,但在本书的许多章节中我们不会使用这些特性——部分原因是因为我们根本不需要它们,另一部分原因是为了让你戒除你可能不知道自己有的依赖。这种摆脱可变性的自由是 OCaml 可以带给你的最大视角变化之一。

OCaml 的特点

OCaml 是一种静态类型和类型安全的编程语言。静态类型语言在编译时检测类型错误;如果检测到类型错误,语言将不允许程序执行。类型安全语言限制了可以对哪种类型的数据执行哪种操作。在实践中,这可以防止许多愚蠢的错误(例如,将整数视为函数)并且还可以防止许多安全问题:在计算机应急响应团队(CERT,负责网络安全的美国政府机构)报告的一半以上入侵事件是由于缓冲区溢出,这在类型安全的语言中是不可能发生的。

一些语言,比如 Python 和 Racket,是类型安全的但是动态类型的。也就是说,类型错误只在运行时捕获。其他语言,比如 C 和 C++,是静态类型的但不是类型安全的:它们检查一些类型错误,但不能保证所有类型错误的缺失。也就是说,不能保证类型错误不会在运行时发生。还有其他语言,比如 Java,使用静态和动态类型结合来实现类型安全。。

OCaml 支持许多高级功能,其中一些您可能以前遇到过,而另一些可能是新的:

  • 代数数据类型:您可以在 OCaml 中轻松构建复杂的数据结构,无需处理指针和内存管理。模式匹配是一项功能,我们很快将了解到,它使得检查数据结构的形状变得更加方便。
  • 类型推断:您不必在每个地方都写下类型信息。编译器会自动推断大多数类型。这可以使代码更易于阅读和维护。
  • 参数多态性:函数和数据结构可以针对类型进行参数化。这对于能够重用代码至关重要。
  • 垃圾回收:自动内存管理使您摆脱了内存分配和释放的负担,这是诸如 C 语言中常见的错误源。
  • 模块:OCaml 通过使用模块,使得构建大型系统变得简单。模块用于封装接口背后的实现。OCaml 通过提供可以操作模块的函数(称为 functors),在模块方面的功能远远超出大多数语言的范围。

工业中的 OCaml

OCaml 和其他函数式语言远不及 Python、C 或 Java 那样受欢迎。OCaml 真正的优势在于语言操作(即编译器、分析器、验证器、证明器等)。这并不令人意外,因为 OCaml 起源于定理证明领域。

这并不是说函数式语言没有在工业中使用。有许多行业项目使用 OCaml 和 Haskell 等语言。 Yaron Minsky(康奈尔大学博士 02)甚至写了一篇关于在金融行业使用 OCaml 的论文。它解释了 OCaml 的功能如何使其成为快速构建可用的复杂软件的良好选择。

Look to Your Future

通用语言来来去去。在你的生活中,你可能会学习几种。今天,是 Python 和 Java。昨天,是 Pascal 和 Cobol。再之前,是 Fortran 和 Lisp。谁知道明天会是什么呢?在这个快速变化的领域,你需要能够迅速适应。一个优秀的程序员必须学习超越任何具体语言的编程原则。没有比从功能角度来看待编程更好的方法了。从零开始学习一门新语言为你提供了反思编程和在某种语言中编程之间差异的机会。

如果您在学习 OCaml 之后想了解更多关于函数式编程的知识,您将会有很好的准备。OCaml 在澄清和简化函数式编程的本质方面做得非常出色,这是其他混合函数式和命令式编程(如 Scala)或将函数式编程推向极致(如 Haskell)的语言所不具备的。

即使您在学习 OCaml 后再也不编写代码,您仍然会为未来做好准备。函数式语言的高级特性有一种令人惊讶的趋势,可以预测更主流语言的新特性。Java 在 1995 年将垃圾回收引入主流;而 Lisp 在 1958 年就有了这一特性。Java 直到 2004 年的第 5 版才有泛型;而 ML 系列在 1990 年就有了。头等函数和类型推断已经被纳入主流语言,如 Java、C#和 C++,这是在函数式语言引入它们之后的 10 年内发生的。

Python 刚刚宣布计划在 2021 年 2 月开始支持模式匹配。

CS 3110 的简要历史

本书是康奈尔大学 CS 3110 课程的主要教材。该课程已经存在超过二十年,始终教授函数式编程,但并不是总使用 OCaml。

曾经有一门名为 MIT 的课程 6.001 结构和计算机程序解释(SICP)。它有同名教材,并使用 Scheme,一个函数式编程语言。蒂姆·泰特尔鲍姆在 1988 年秋季于康奈尔大学讲授了该课程的版本,紧跟书本的脚步,使用 Scheme。

CS 212。丹·胡特纳勒曾担任麻省理工学院的 6.001 课程助教;后来他在康奈尔大学成为一名教授。在 1989 年秋季,他开设了 CS 212 算法表达方式课程。基于 SICP,他对该课程材料进行了更加严格的处理。胡特纳勒继续开发 CS 212,直到 1990 年代中期,为此他使用了各种家 grown 的 Scheme 方言。

其他教师开始定期讲授这门课程。Ramin Zabih 在麻省理工学院(MIT)担任学生时曾修读过 6.001 课程。在 1994 年春季,他在康奈尔大学担任 faculty,教 CS 212 课程。Dexter Kozen(康奈尔大学博士 1977 级)于 1996 年春季首次讲授这门课程。该课程最早的在线记录似乎是 1998 年的春季,由 Greg Morrisett 在 Dylan 中讲授;该课程名称已经变为计算机程序结构与解释。

1999 年秋季,CS 212 已经有了自己的讲义。与 CS 3110 一样,那个 CS 212 的实例仍然涵盖函数式编程、替换和环境模型、某些数据结构和算法,以及编程语言实现。

CS312。当时,计算机科学(CS)课程中有两个入门编程课程,即 CS211《计算机和编程》和 CS212。学生们选择一个或另一个,就像今天的学生选择 CS2110 或 CS2112 一样。然后,他们学习了 CS410《数据结构》。CS410 最早的在线记录似乎来自 1998 年春季,它涵盖了许多数据结构和算法,包括平衡树和图,并使用 Java 作为编程语言。

根据学生所选的课程,CS211 或 212,他们在进入高级课程时拥有不同的技能集。经过充分讨论,教职员工决定将 CS211 设置为必修课,将 CS212 重新命名为 CS312 数据结构和函数式编程,并将 CS211 作为 CS312 的先决条件。在同一时间内,CS410 从学科中被删除其内容被分配给了 CS312 和 CS482 算法分析介绍课程。 Dexter Kozen 在 1999 年秋季学期中教导了 CS410 的最后一堂课。

格雷格·莫里塞特在 2001 年春季开设了新的 CS312 课程。他从 Scheme 转换到标准 ML。库兹恩首次讲授该课程是在 2001 年秋季,安德鲁·迈尔斯则是在 2002 年秋季。迈尔斯开始将《Java 编程开发:抽象、规范和面向对象设计》中的一些材料纳入课程教学,这本书是巴拉·利斯科夫和约翰·古塔格的著作。胡特伦洛赫尔首次讲授该课程是在 2006 年春季。

CS3110。2008 年秋季有两个大变化:语言转换为 OCaml,大学转换为四位数字课程号。CS312 变为 CS3110。Myers、Huttenlocher、Kozen 和 Zabih 首次在 2008 年秋季、2009 年春季、2009 年秋季和 2010 年秋季分别讲授修订课程。Nate Foster 首次在 2012 年春季讲授该课程;Bob Constable 和 Michael George 第一次共同讲授是在 2013 年秋季。

迈克尔·克拉克森(2010 年康奈尔大学博士)首次讲授该课程是在 2014 年秋季学期,作为博士生在 2008 年春季学期担任助教。他开始修订 OCaml 编程材料的展现方式,以便将丹·格罗斯曼(2003 年康奈尔大学博士)的关于以语法、动态和静态语义为基础的学习编程语言的方法融入其中。格罗斯曼在华盛顿大学计算机科学与工程系 CSE 341 编程语言课程中,以及他的人气 MOOC 《编程语言》中也使用该方法

2018 年秋季,这本教科书的编写开始了。它综合了康奈尔大学超过二十年的函数式编程教学成果。在康奈尔大学晚钟歌中说到:

是墙上的回响 我们自己的,美丽的康奈尔。

总结

本书旨在帮助读者成为更好的编程师。学习函数式编程将对此有所助益。我们面临的最大障碍是学习新语言的沮丧感,尤其是放弃可变状态。但是,这些好处将是巨大的:一种发现,即编程超越任何特定的语言或语言家族,一种对高级语言特性的暴露,以及对美丽的欣赏。

术语

  • dynamic typing 动态类型化
  • first-class functions 高级函数
  • 函数式编程语言
  • 不变性
  • Lisp
  • ML
  • OCaml
  • referential transparency 参考透明
  • state
  • static typing 静态类型化
  • type safety 类型安全

延伸阅读📖

  • 介绍 Objective Caml,第 1 章和第 2 章,这是一本免费的教材,我们建议学生阅读这本教材
  • OCaml 自始至终,第 1 章,这是一本非常温和的教材,对于这门课程来说是极佳推荐的。书籍的 PDFHTML 格式都是免费的。
  • OCaml 指南:《Real World OCaml》一书的第一章,这本书是康奈尔大学某些学生写作的,可能会让一些学生感到有趣阅读
  • Standard ML 的历史:虽然它主要关注 SML 是 ML 語言的变体,但对 OCaml 有关联。
  • 值的价值:Clojure(Lisp 的一种现代方言)的设计者关于命令式编程时代已经过去的讲座
  • 教自己编程十年:Google 研究院主任的一篇论文,旨在将成为有知识的程序员所需时间置于合适的角度

OCaml 的基础

本章将介绍 OCaml 的一些基本特性。但在我们深入学习 OCaml 之前,让我们先谈谈一个更大的概念:一般来说学习语言。

本课程的一个次要目标不仅是让您学会一门新的编程语言,还要提高您学习新语言的能力。

学习一门语言有五个基本组成部分:句法、语义、习语、库和工具。

语法。通过语法,我们指的是定义语言中何为文本上格式良好的程序的规则,包括关键字、对空格和格式的限制、标点符号、运算符等。学习一门新语言时最令人讨厌的一点可能是语法与你已经了解的语言相比感觉奇怪。但是你学习的语言越多,你就会越习惯接受语言的语法本身,而不是希望它不同。 (如果你想看看一些具有非常不寻常语法的语言,请看看需要自己的扩展键盘的 APL,以及由空格、制表符和换行符组成的 Whitespace。)你需要理解语法才能与计算机进行交流。

语义学。通过语义学,我们指的是定义程序行为的规则。换句话说,语义学关乎程序的含义——特定语法片段代表的计算。请注意,虽然“语义学”在形式上是复数,但我们将其视为单数。这类似于“数学”或“物理学”。

语义学有两个部分,即一种语言的动态语义和静态语义。动态语义定义了程序在执行或求值时的运行时行为。静态语义定义了在编译时进行的检查,以确保程序合法,超出任何语法要求。最重要的静态语义可能是类型检查:定义程序是否良好类型化的规则。学习一种新语言的语义通常是真正的挑战,尽管语法可能是您必须克服的第一个障碍。您需要理解语义以告诉计算机您的意思,并且需要表达您的意思,以便程序执行正确的计算。

习语。通过习语,我们指的是使用语言特征来表达计算的常见方法。鉴于您可能会在语言内部以多种方式表达一个计算,您会选择哪一种?有些方式比其他方式更自然。精通该语言的程序员会更倾向于某些表达方式。我们可以考虑在有效使用语言中的主导范式方面,无论是命令式的、函数式的、面向对象的等等。您需要理解成语,不仅要向计算机表达您的意思,还要向其他程序员表达。当您以成语方式编写代码时,其他程序员将更好地理解您的代码。

。库是已经为您编写的代码包,可以使您成为更高效的程序员,因为您不必自己编写代码。(有人说,对于程序员来说,懒惰是一种美德。)学习一门新语言的一部分是发现可用的库以及如何利用它们。一种语言通常提供一个标准库,让您访问一组核心功能,其中许多功能您自己无法在该语言中编写,比如文件 I/O。

工具。至少,任何语言实现都提供编译器或解释器作为与计算机交互的工具。但还有其他类型的工具:调试器;集成开发环境(IDE);以及用于性能、内存使用和正确性等方面的分析工具。学会使用与语言相关的工具也可以使您成为更有生产力的程序员。有时很容易混淆工具本身与语言;例如,如果您只使用过 Eclipse 和 Java,可能不会意识到 Eclipse 是一个可以与许多语言一起使用的 IDE,而 Java 也可以在没有 Eclipse 的情况下使用。

当涉及到在本书中学习 OCaml 时,我们的重点主要是语义和习语。当然,我们必须顺便学习语法,但这并不是我们研究的有趣部分。我们将接触到 OCaml 标准库和其他一些库,特别是 OUnit(类似于 JUnit、HUnit 等的单元测试框架)。除了 OCaml 编译器和构建系统,我们将使用的主要工具是 toplevel,它提供了与代码进行交互式实验的能力。

The OCaml Toplevel

toplevel类似于 OCaml 的计算器或命令行界面。它类似于 Java 的 JShell,或者交互式 Python 解释器。topleve 对于尝试小段代码而不必启动 OCaml 编译器很方便。但不要过分依赖它,因为创建、编译和测试大型程序需要更强大的工具。一些其他语言可能会将toplevel称为 REPL,即读取-求值-打印-循环:它读取程序员输入,求值它,打印结果,然后重复。

在终端窗口中,输入 utop 以启动 toplevel。按下 Control-D 退出 toplevel。您也可以输入 #quit;; 然后按回车。请注意,您必须在那里输入 # :这是除了您已经看到的 # 提示之外的内容。

类型和值

您可以在 OCaml toplevel输入表达式。用双分号 ;; 结束一个表达式,然后按回车键。OCaml 将求值表达式,告诉您结果值及其类型。例如:

# 42;;
- : int = 42

让我们解析 utop 的回应,从右到左阅读:

  • 42 是该值。
  • int 是该值的类型。
  • 该值没有被赋予名称,因此使用符号 -

那个 utop interaction 是作为这本书的一部分“硬编码”进去的。我们不得不输入所有的字符: #- 等等。但用来撰写这本书的基础设施实际上使我们能够编写代码,在将书翻译成 HTML 或 PDF 时由 OCaml 求值。从现在开始,这通常是我们要做的。它看起来像这样:

42
- : int = 42

第一个代码块中带有 42 的是我们要求 OCaml 运行的代码。如果您想将其输入到 utop 中,可以复制粘贴。在块的右上角有一个图标可以轻松完成这个操作。只需记得在末尾添加双分号。第二个代码块,稍微缩进,是 OCaml 在翻译书籍时的输出。

如果您在网络浏览器中查看此内容,请查找右上角的下载图标。选择 .md 选项,您将看到本书此页面的原始 MyST Markdown 源代码。您会发现,上面第二个示例的输出实际上并不存在于源代码中。这是好事!这意味着输出与我们用来构建本书的 OCaml 编译器的当前版本保持一致。这也意味着任何编译错误都可以作为构建本书的一部分来检测,而不是潜伏在那里等待您,亲爱的读者,去发现它们。

您可以通过 let 定义将值绑定到名称,如下所示:

let x = 42
val x : int = 42

再次,让我们解析这个回答,这次从左到右阅读:

  • 一个值被绑定到一个名称,因此使用 val 关键字。
  • x 是绑定的值的名称。
  • int 是该值的类型。
  • 42 是该值。

您可以将整个输出发音为“ x 的类型为 int ,等于 42 。”

Functions

可以使用以下类似的语法在 toplevel 定义一个函数:

let increment x = x + 1
val increment : int -> int = <fun>

让我们剖析一下这个回应:

  • increment 是与值绑定的标识符。
  • int -> int 是值的类型。这是接受 int 作为输入并产生 int 作为输出的函数类型。将箭头 -> 视为一种将一个值转换为另一个值的视觉隐喻,这正是函数所做的。
  • 值是一个函数,utop选择不打印它(因为它现在已经被编译,并且在内存中有一个不容易适合漂亮打印的表示)。相反,toplevel打印 <fun> ,这只是一个占位符。

<fun> 本身不是一个值。它只是表示一个不可打印的函数值。

您可以使用以下语法“调用”函数:

increment 0
- : int = 1
increment(21)
- : int = 22
increment (increment 5)
- : int = 7

但在 OCaml 中,通常的术语是我们“应用”函数,而不是“调用”它。

注意 OCaml 在写括号或不写括号,以及写空格或不写空格方面是灵活的。学习 OCaml 时的一个挑战之一是弄清楚何时实际上需要括号。因此,如果发现自己遇到语法错误问题,一种策略是尝试添加一些括号。然而,首选风格通常是在不需要时省略括号。因此, increment 21increment(21) 更好。

在toplevel加载代码

除了允许您定义函数外,toplevel还将接受指令,这些指令不是 OCaml 代码,而是告诉toplevel自身要执行某些操作。所有指令都以 # 字符开头。也许最常见的指令是 #use ,它会将文件中的所有代码加载到toplevel,就好像您在toplevel中键入了该文件中的代码一样。

例如,假设您创建了一个名为 mycode.ml 的文件。在该文件中放入以下代码:

let inc x = x + 1

启动toplevel。尝试输入以下表达式,并观察错误:

inc 3
File "[7]", line 1, characters 0-3:
1 | inc 3
    ^^^
Error: Unbound value inc
Hint: Did you mean incr?

错误发生是因为toplevel尚不知道名为 inc 的函数。现在向toplevel发出以下指令:

# #use "mycode.ml";;

请注意,上面的第 # 个字符表示给您的toplevel提示。第二个 # 字符是您键入的字符,用于告诉toplevel您正在发布指令。如果没有该字符,toplevel会认为您正在尝试应用名为 use 的函数。

现在再试一次:

inc 3
- : int = 4

toplevel的工作流程

使用存储在文件中的代码时,最佳工作流程是:

  • 编辑文件中的代码。
  • 在toplevel加载代码 #use
  • 交互式地测试代码。
  • 退出toplevel。警告:不要跳过这一步。

假设您想要修复代码中的一个错误。诱人的做法是不退出toplevel,编辑文件,并将 #use 指令重新发出到同一toplevel会话中。抵制这种诱惑。在同一会话中从早期 #use 指令加载的“陈旧代码”可能会导致一些令人惊讶的事情发生——尤其是在您初学该语言时。因此,在重新使用文件之前,始终退出toplevel。

编译 OCaml 程序

使用 OCaml 作为一种交互式计算器可能很有趣,但是我们无法通过这种方式编写大型程序。相反,我们需要将代码存储在文件中并进行编译。

将代码存储在文件中

打开终端,创建一个新目录,并在该目录中打开 VS Code。例如,您可以使用以下命令:

$ mkdir hello-world
$ cd hello-world

不要将 Unix 主目录的根目录用作存储文件的位置。我们即将使用的构建系统 dune 可能无法在您的主目录根目录中正常工作。相反,您需要使用主目录的子目录。

使用 VS Code 创建一个名为 hello.ml 的新文件。将以下代码输入到文件中:

let _ = print_endline "Hello, world!"

在那行代码的末尾没有双分号 ;; 。双分号是用于toplevel交互式会话的,这样toplevel就知道你已经输入完一段代码了。通常在 .ml 文件中不需要写双分号。

上面的 let _ = 表示我们不在乎给右侧代码命名(因此是“空白”或下划线)。 =

保存文件并返回到命令行。编译代码:

$ ocamlc -o hello.byte hello.ml

编译器的名称为 ocamlc-o hello.byte 选项表示将输出的可执行文件命名为 hello.byte 。可执行文件包含编译后的 OCaml 字节码。此外,还会生成另外两个文件, hello.cmihello.cmo 。目前我们不需要关注这些文件。运行可执行文件:

$ ./hello.byte

应该打印 Hello world! 并终止。

现在更改要打印的字符串为您选择的内容。保存文件,重新编译并重新运行。尝试使代码打印多行。

这种在编辑器和命令行之间的编辑-编译-运行循环,如果你习惯于在像 Eclipse 这样的集成开发环境中工作,可能会感到陌生。不用担心,很快你就会驾轻就熟。

现在让我们清理所有这些生成的文件:

$ rm hello.byte hello.cmi hello.cmo

main 是什么?

与 C 或 Java 不同,OCaml 程序不需要一个名为 main 的特殊函数来启动程序。通常的习惯是在文件中最后一个定义作为主函数,启动需要执行的任何计算。

Dune

在较大的项目中,我们不希望手动运行编译器或手动清理。相反,我们希望使用构建系统自动查找并链接库。OCaml 有一个名为 ocamlbuild 的传统构建系统,以及一个名为 Dune 的更新构建系统。类似的系统包括 make ,在 Unix 世界中长期以来一直用于 C 和其他语言;以及与 Java 一起使用的 Gradle、Maven 和 Ant。

一个 Dune 项目是一个包含您想要编译的 OCaml 代码的目录(及其子目录)。 项目的根目录是其层次结构中最高的目录。 一个项目可能依赖于提供已经编译的额外代码的外部包。 通常,包是使用 OPAM(OCaml 包管理器)安装的。

您项目中的每个目录都可以包含一个名为 dune 的文件。该文件向 Dune 描述了您希望该目录(及其子目录)中的代码如何编译。Dune 文件使用一种源自 LISP 的函数式编程语法,称为 s 表达式,其中括号用于显示形成树状结构的嵌套数据,类似于 HTML 标记的方式。Dune 文件的语法在 Dune 手册中有详细说明。

手动创建Dune Project

这里是如何使用 Dune 的一个小例子。在与 hello.ml 相同的目录中,创建一个名为 dune 的文件,并将以下内容放入其中:

(executable
 (name hello))

声明一个可执行文件(可以执行的程序),其主文件是 hello.ml

还要创建一个名为 dune-project 的文件,并将以下内容放入其中:

(lang dune 3.4)

这告诉 Dune,这个项目使用的是 Dune 版本 3.4,在这本教材版本发布时是最新的。每个你想要用 Dune 编译的源代码树的根目录中都需要这个项目文件。通常情况下,你会在源代码树的每个子目录中都有一个 dune 文件,但在根目录中只有一个 dune-project 文件。

然后从终端运行以下命令:

$ dune build hello.exe

请注意,Dune 不仅在 Windows 平台上使用 .exe 扩展。这导致 Dune 构建本机可执行文件,而不是字节码可执行文件。

Dune 将在 _build 中创建一个目录,并在其中编译我们的程序。这是构建系统相对于直接运行编译器的一个好处:不会在源目录中产生大量生成的文件,而是会干净地创建在一个单独的目录中。在 _build 中,Dune 会创建许多文件。我们的可执行文件被埋藏在几个层级之下:

$ _build/default/hello.exe
Hello world!

但 dune 提供了一个快捷方式,避免记住和输入所有这些内容。为了一步构建和执行程序,我们可以简单地运行:

$ dune exec ./hello.exe
Hello world!

最后,为了清理所有已编译的代码,我们只需运行:

$ dune clean

这将删除 _build 目录,仅保留您的源代码。

当 Dune 编译您的程序时,它会在 _build/default 中缓存您的源文件的副本。如果您不小心犯了一个错误导致源文件丢失,您可能可以从 _build 中恢复它。当然,使用像 git 这样的源代码控制也是明智的。

请勿编辑 _build 目录中的任何文件。如果您收到关于尝试保存只读文件的错误消息,您可能正在尝试编辑 _build 目录中的文件。

自动创建Dune Project

在终端中,切换到您想要存储工作的目录,例如,“~/work”。为您的项目选择一个名称,比如“calculator”。运行:

$ dune init project calculator
$ cd calculator
$ code .

您现在应该已经打开了 VS Code,并看到了 Dune 自动生成的项目文件。

calculator 目录中的终端运行:

$ dune exec bin/main.exe

它将打印 Hello, World!

如果您使用 ocamlformat 自动格式化源代码,请注意 Dune 不会自动向您的项目添加一个 .ocamlformat 文件。您可能希望在项目的toplevel目录(即根目录)中添加一个文件。这个目录中包含名为 dune-project 的文件。

连续运行Dune

当你运行 dune build 时,它会对你的项目进行一次编译。您可能希望每次保存项目中的文件时自动编译您的代码。要实现这一点,请运行以下命令:

$ dune build --watch

Dune 将会回应说它正在等待文件系统的更改。这意味着 Dune 现在正在持续运行,并且每次您在 VS Code 中保存文件时都会重新构建您的项目。要停止 Dune,请按 Control+C。

表达式

OCaml 语法的主要部分是表达式。就像命令式语言中的程序主要由命令构建而成一样,函数式语言中的程序主要由表达式构建而成。表达式的例子包括 2+2increment 21

OCaml 手册对语言中所有表达式进行了完整定义。尽管该页面以相当神秘的概述开始,但如果您向下滚动,您将看到一些英文解释。现在不必担心研究该页面;只需知道它可供参考。

函数式语言中计算的主要任务是将表达式求值为一个值。值是一个表达式,对于它而言没有剩余的计算需要执行。因此,所有的值都是表达式,但并非所有的表达式都是值。值的示例包括 2true"yay!"

OCaml 手册中也包含所有值的定义,尽管那一页主要用于参考而非学习。

有时候一个表达式可能无法求值为一个值。这种情况可能有两个原因:

  • 表达式的求值引发了异常。
  • 表达式的求值永远不会终止(例如,它进入了“无限循环”)。

原始类型和值

原始类型是内置和最基本的类型:整数、浮点数、字符、字符串和布尔值。它们将被识别为类似于其他编程语言中的原始类型。

类型 int :整数。OCaml 整数通常写作: 12 ,等等。常见运算符可用: +-*/ ,和 mod 。后两者为整数除法和取模:

65 / 60
- : int = 1
65 mod 60
- : int = 5
65 / 0
Exception: Division_by_zero.
Raised by primitive operation at unknown location
Called from Stdlib__Fun.protect in file "fun.ml", line 33, characters 8-15
Re-raised at Stdlib__Fun.protect in file "fun.ml", line 38, characters 6-52
Called from Topeval.load_lambda in file "toplevel/byte/topeval.ml", line 89, characters 4-150

OCaml 整数在现代平台上的范围从 -2^622^62-1 。它们使用 64 位机器字来实现,这是 64 位处理器上寄存器的大小。但其中一个比特被 OCaml 实现“窃取”,导致 63 位表示。该比特在运行时用于区分整数和指针。对于需要真正的 64 位整数的应用程序,在标准库中有一个 Int64 模块。对于需要任意精度整数的应用程序,有一个单独的 Zarith 库。但对于大多数情况,内置的 int 类型足够并提供最佳性能。

类型 float :浮点数。OCaml 浮点数是 IEEE 754 双精度浮点数。从语法上讲,它们必须始终包含一个点—例如, 3.143.0 或甚至 3. 。最后一个是 float ;如果你将其写成 3 ,那么它实际上是一个 int

3.
- : float = 3.
3
- : int = 3

OCaml 故意不支持运算符重载,浮点数的算术运算在其后加一个点来表示。例如,浮点数乘法写作 *. 而不是 *

3.14 *. 2.
- : float = 6.28
3.14 * 2.
File "[7]", line 1, characters 0-4:
1 | 3.14 * 2.
    ^^^^
Error: This expression has type float but an expression was expected of type
         int

OCaml 不会自动在 intfloat 之间转换。如果您想要转换,有两个内置函数可用于此目的: int_of_floatfloat_of_int

3.14 *. (float_of_int 2)
- : float = 6.28

与任何语言一样,浮点表示是近似的。这可能导致舍入误差。

0.1 +. 0.2
- : float = 0.300000000000000044

相同的行为也可以在 Python 和 Java 中观察到。如果您以前没有遇到过这种现象,这里有一个关于浮点表示的基本指南,您可能会喜欢阅读。

类型 bool :布尔值。布尔值用 truefalse 表示。通常的短路逻辑与 && 和或 || 运算符可用。

类型 char :字符。字符用单引号写成,如 'a''b''c' 。它们以字节形式表示——即 8 位整数——在 ISO 8859-1“Latin-1”编码中。该范围内的字符的前半部分是标准 ASCII 字符。您可以使用 char_of_intint_of_char 将字符转换为整数。

类型 string :字符串。字符串是字符序列。它们用双引号表示,例如 "abc" 。字符串连接运算符是 ^

"abc" ^ "def"
- : string = "abcdef"

面向对象的语言通常提供一个可重写的方法来将对象转换为字符串,比如 Java 中的 toString() 或 Python 中的 __str__() 。但大多数 OCaml 值不是对象,因此需要另一种方法来转换为字符串。对于三种原始类型,有内置函数: string_of_int , string_of_floatstring_of_bool 。奇怪的是,没有 string_of_char ,但可以使用库函数 String.make 来实现相同的目标。

string_of_int 42
- : string = "42"
String.make 1 'z'
- : string = "z"

同样,对于相同的三种基本类型,如果可能的话,有内置函数可以从字符串转换: int_of_stringfloat_of_stringbool_of_string

int_of_string "123"
- : int = 123
int_of_string "not an int"
Exception: Failure "int_of_string".
Raised by primitive operation at unknown location
Called from Stdlib__Fun.protect in file "fun.ml", line 33, characters 8-15
Re-raised at Stdlib__Fun.protect in file "fun.ml", line 38, characters 6-52
Called from Topeval.load_lambda in file "toplevel/byte/topeval.ml", line 89, characters 4-150

在字符串中没有 char_of_string ,但可以通过基于 0 的索引访问字符串的各个字符。索引运算符用点号和方括号表示:

"abc".[0]
- : char = 'a'
"abc".[1]
- : char = 'b'
"abc".[3]
Exception: Invalid_argument "index out of bounds".
Raised by primitive operation at unknown location
Called from Stdlib__Fun.protect in file "fun.ml", line 33, characters 8-15
Re-raised at Stdlib__Fun.protect in file "fun.ml", line 38, characters 6-52
Called from Topeval.load_lambda in file "toplevel/byte/topeval.ml", line 89, characters 4-150

更多运算符

我们已经讨论了大多数内置运算符,但还有一些您可以在 OCaml 手册中看到的。

OCaml 中有两个相等运算符 === ,对应的不相等运算符分别是 <>!= 。运算符 =<> 检查结构相等性,而 ==!= 检查物理相等性。 在我们学习了 OCaml 的命令式特性之前,很难解释它们之间的区别。如果你现在感到好奇,可以查看 Stdlib.(==)文档

立即开始训练自己使用 = ,而不使用 == 。如果您之前使用的是像 Java 这样的语言,其中 == 是通常的相等运算符,那么这可能会有些困难。

NOTE:

物理相等,只两个变量对应同一块内存,类似于两个指针指向一块内存

let a = [1; 2; 3] let b = [1; 2; 3]

(* 结构性相等,因为列表的结构相同 ) if a = b then "a" else "b" ( "a" *)

(* 物理性相等,因为a和b分别存储在不同的内存位置 ) if a == b then "a" else "b" ( "b" *)

断言

表达式 assert e 求值 e 。如果结果是 true ,则不会发生任何其他情况,并且整个表达式求值为一个称为单位的特殊值。单位值写为 () ,其类型为 unit 。但如果结果是 false ,则会引发异常。

测试函数 f 的一种方法是编写一系列类似以下的断言:

let () = assert (f input1 = output1)
let () = assert (f input2 = output2)
let () = assert (f input3 = output3)

那些声称 f input1 应该是 output1 等等的人。这些中的 let () = ... 部分用于处理每个断言返回的单位值。

If表达式

表达式 if e1 then e2 else e3 的值为 e2 ,如果 e1 的值为 true ,否则为 e3 。我们称 e1if 表达式的保护条件。

if 3 + 5 > 2 then "yay!" else "boo!"
- : string = "yay!"

与您可能在命令式语言中使用的 if-then-else 语句不同,OCaml 中的 if-then-else 表达式就像任何其他表达式一样;它们可以放在任何表达式可以放置的地方。这使它们类似于您可能在其他语言中使用的三元运算符 ? :

4 + (if 'a' = 'b' then 1 else 2)
- : int = 6

If 表达式可以以一种愉快的方式嵌套:

if e1 then e2
else if e3 then e4
else if e5 then e6
...
else en

您应该将最终的 else 视为强制性,无论您是在编写单个 if 表达式还是高度嵌套的 if 表达式。如果您省略它,您很可能会收到一个目前难以理解的错误消息:

if 2 > 3 then 5
File "[20]", line 1, characters 14-15:
1 | if 2 > 3 then 5
                  ^
Error: This expression has type int but an expression was expected of type
         unit
       because it is in the result of a conditional with no else branch

语法。一个 if 表达式的语法:

if e1 then e2 else e3

在这里,字母 e 用于代表任何其他 OCaml 表达式;它是一个句法变量,也称为元变量的示例,实际上并不是 OCaml 语言本身的变量,而是对某种句法结构的名称。字母 e 后面的数字用于区分它的三个不同出现。

动态语义学。一个 if 表达式的动态语义:

  • 如果 e1 求值为 true ,并且如果 e2 求值为 v ,那么 if e1 then e2 else e3 求值为 v
  • 如果 e1 求值为 false ,并且如果 e3 求值为 v ,那么 if e1 then e2 else e3 求值为 v

我们称这些为求值规则:它们定义了如何求值表达式。请注意,描述 if 表达式的求值需要两条规则,一条用于当条件为真时,另一条用于当条件为假时。这里使用字母 v 来代表任何 OCaml 值;这是另一个元变量的例子。稍后我们将会开发一种更数学化的动态语义表达方式,但现在我们将继续使用这种更不正式的解释风格。

静态语义。一个 if 表达式的静态语义:

  • 如果 e1 具有类型 boole2 具有类型 t , e3 具有类型 t ,那么 if e1 then e2 else e3 具有类型 t

我们称之为打字规则:它描述了如何对表达式进行类型检查。请注意,只需要一条规则来描述 if 表达式的类型检查。在编译时,当进行类型检查时,无论守卫是真还是假都没有影响;事实上,编译器无法知道守卫在运行时会有什么值。这里的字母 t 用于表示任何 OCaml 类型;OCaml 手册还定义了所有类型(有趣的是没有命名语言的基本类型,如 intbool )。

我们将经常写“具有类型”,因此让我们为其引入更紧凑的表示法。每当我们写“ e 具有类型 t ”时,让我们改为写 e : t 。冒号的发音是“具有类型”。这种冒号的用法与toplevel在求值您输入的表达式后的响应一致:

let x = 42
val x : int = 42

在上面的例子中,变量 x 的类型为 int ,这就是冒号所表示的。

Let 表达式

迄今为止,我们在顶层和 .ml 文件中对 let 一词进行了定义。例如,

let x = 42;;
val x : int = 42

x 定义为 42,之后我们可以在顶层的未来定义中使用 x 。我们将这种使用 let 的方式称为 let 定义。

let 的另一个用途是作为一个表达式:

let x = 42 in x + 1
- : int = 43

这里我们将一个值绑定到名称 x ,然后在另一个表达式 x+1 中使用该绑定。我们将这种 let 的用法称为 let 表达式。由于它是一个表达式,它会被求值为一个值。这与定义不同,定义本身不会被求值为任何值。如果您尝试将 let 定义放在期望表达式的位置,您会发现这一点:

(let x = 42) + 1
File "[24]", line 1, characters 11-12:
1 | (let x = 42) + 1
               ^
Error: Syntax error

从句法上讲, let 定义不允许出现在 + 运算符的左侧,因为那里需要一个值,而定义不会求值为值。另一方面, let 表达式可以正常工作:

(let x = 42 in x) + 1
- : int = 43

另一种理解顶层的 let 定义的方式是将它们视为 let 表达式,只是我们尚未提供主体表达式。隐含地,该主体表达式就是我们将来键入的任何其他内容。例如,

# let a = "big";;
# let b = "red";;
# let c = a ^ b;;
# ...

OCaml 以相同的方式理解

let a = "big" in
let b = "red" in
let c = a ^ b in
...

后一系列 let 绑定在习语上是指几个变量如何在给定的代码块内绑定。

句法

let x = e1 in e2

通常, x 是一个标识符。这些标识符必须以小写字母开头,而不是大写字母,并且按照惯例用 snake_case 而不是 camelCase 来书写。我们称 e1 为绑定表达式,因为它是绑定到 x 的内容;我们称 e2 为主体表达式,因为那是绑定将在其中范围内的代码主体。

动态语义

求值 let x = e1 in e2

  • 求值 e1 的值为 v1
  • x 替换为 v1e2 中,得到一个新表达式 e2'
  • 求值 e2' 的值为 v2
  • 求值 let 表达式的结果是 v2

这里有一个例子:

let x = 1 + 4 in x * 3
-->   (evaluate e1 to a value v1)
let x = 5 in x * 3
-->   (substitute v1 for x in e2, yielding e2')
5 * 3
-->   (evaluate e2' to v2)
15
  (result of evaluation is v2)

静态语义

  • 如果 e1 : t1 ,并且在假设 x : t1 的情况下成立 e2 : t2 ,那么 (let x = e1 in e2) : t2

我们仅仅是为了清晰起见而使用上述括号。通常情况下,编译器的类型推断器会确定变量的类型,或者程序员可以使用以下语法明确注释它:

let x : t = e1 in e2

范围

Let 绑定仅在其出现的代码块中生效。这正是您从几乎任何现代编程语言中熟悉的。例如:

let x = 42 in
  (* y is not meaningful here *)
  x + (let y = "3110" in
         (* y is meaningful here *)
         int_of_string y)

变量的作用域是其名称有意义的范围。变量 y 仅在绑定其的 let 表达式内部有效。

可以有相同名称的重叠绑定。例如:

let x = 5 in
  ((let x = 6 in x) + x)

但这太令人困惑了,因此,这种风格是强烈不推荐的——就像自然语言中不推荐使用模糊代词一样。尽管如此,让我们考虑一下那段代码的含义。

代码将求值为什么值?答案取决于每次出现时 x 被一个值替换。以下是一些替换的可能性:

(* possibility 1 *)
let x = 5 in
  ((let x = 6 in 6) + 5)

(* possibility 2 *)
let x = 5 in
  ((let x = 6 in 5) + 5)

(* possibility 3 *)
let x = 5 in
  ((let x = 6 in 6) + 6)

第一个是几乎任何合理的语言都会做的事情。很可能这就是你猜到的,但是,为什么呢?

答案是我们将称之为名称无关原则:变量的名称本质上不应该重要。你在数学中已经习惯了这一点。例如,以下两个函数是相同的

\[ f(x) = x^2 \] \[ f(y) = y^2 \]

无论我们将函数的参数称为 x 还是 y ,本质上都没有关系;无论如何,它仍然是平方函数。因此,在程序中,这两个函数应该是相同的:

let f x = x * x
let f y = y * y

这个原则更常被称为α等价性:这两个函数在变量重命名上是等价的,这也被称为α转换,出于历史原因,这里并不重要。

根据名称无关原则,这两个表达式应该是相同的:

let x = 6 in x
let y = 6 in y

因此,以下两个表达式中嵌入了上述表达式,也应该是相同的:

let x = 5 in (let x = 6 in x) + x
let x = 5 in (let y = 6 in y) + x

但是要使它们相同,我们必须选择上述三种可能性中的第一种。这是唯一一个使变量名称无关紧要的可能性。

有一个常用术语来描述这种现象:对变量的新绑定会掩盖任何旧的变量名称绑定。比喻地说,就好像新的绑定暂时将旧的绑定遮蔽起来。但最终,随着阴影消退,旧的绑定可能会重新出现。

阴影不是可变赋值。例如,以下两个表达式都会计算为 11

let x = 5 in ((let x = 6 in x) + x)
let x = 5 in (x + (let x = 6 in x))

同样,以下的utop transcript 并非可变赋值,尽管乍看起来可能是这样的:

# let x = 42;;
val x : int = 42
# let x = 22;;
val x : int = 22

请记住,顶层中的每个 let 定义实际上都是一个嵌套的 let 表达式。因此,上述实际上等同于以下内容:

let x = 42 in
  let x = 22 in
    ... (* whatever else is typed in the toplevel *)

正确的思考方式是,第二个 let 绑定了一个全新的变量,恰好与第一个 let 同名。

这是另一个值得研究的utop transcript:

# let x = 42;;
val x : int = 42
# let f y = x + y;;
val f : int -> int = <fun>
# f 0;;
: int = 42
# let x = 22;;
val x : int = 22
# f 0;;
- : int = 42  (* x did not mutate! *)

总结一下,每个 let 定义都绑定了一个全新的变量。如果这个新变量恰好与旧变量同名,那么新变量会暂时遮蔽旧变量。但是旧变量仍然存在,并且其值是不可变的:它永远不会改变。因此,即使 let 表达式在表面上看起来像命令式语言中的赋值语句,它们实际上是非常不同的。

类型标注

OCaml 自动推断每个表达式的类型,程序员无需手动编写。然而,有时手动指定表达式的期望类型可能会有用。类型注释可以实现这一点:

(5 : int)
- : int = 5

一个不正确的注释会产生编译时错误:

(5 : float)
File "[27]", line 1, characters 1-2:
1 | (5 : float)
     ^
Error: This expression has type int but an expression was expected of type
         float
  Hint: Did you mean `5.'?

这个例子说明了为什么在调试过程中可能会使用手动类型注释。也许你忘记了 5 不能被视为 float ,然后尝试编写:

5 +. 1.1

您可以尝试手动指定 5 应该是一个 float

(5 : float) +. 1.1
File "[28]", line 1, characters 1-2:
1 | (5 : float) +. 1.1
     ^
Error: This expression has type int but an expression was expected of type
         float
  Hint: Did you mean `5.'?

很明显,类型注释失败了。尽管对于这个小程序来说可能看起来有点傻,但随着程序变得更大,你可能会发现这种技术是有效的。

类型注释不是类型转换,例如在 C 或 Java 中可能找到的类型转换。它们不表示从一种类型转换为另一种类型。相反,它们表示检查表达式确实具有给定的类型。

语法。类型注释的语法:

(e : t)

请注意,括号是必需的。

动态语义。类型注释没有运行时含义。它在编译过程中消失,因为它表示编译时检查。没有运行时转换。因此,如果 (e : t) 成功编译,那么在运行时它就是简单的 e ,并且它的求值结果与 e 相同。

静态语义。如果 e 具有类型 t ,那么 (e : t) 具有类型 t

函数

由于 OCaml 是一种函数式语言,关于函数有很多内容需要涵盖。让我们开始吧。

方法和函数并不是相同的概念。方法是对象的一个组成部分,它隐含地具有一个接收者,通常可以通过类似 thisself 的关键字访问。OCaml 函数不是方法:它们不是对象的组成部分,也没有接收者。

有人可能会说所有的方法都是函数,但并非所有的函数都是方法。有些人甚至可能会对此提出异议,区分函数和过程。后者将是不返回任何有意义值的函数,比如在 Java 中的 void 返回类型或 Python 中的 None 返回值。

所以,如果您来自面向对象的背景,请注意术语。这里的一切严格来说都是一个函数,而不是一个方法。

函数定义

以下代码

let x = 42

在其中有一个表达式( 42 ),但它本身不是一个表达式。相反,它是一个定义。定义将值绑定到名称,本例中值 42 被绑定到名称 xOCaml 手册描述了定义(请参阅该页面上标题为“definition”的第三个主要分组),但该手册页面主要用于参考而非学习。定义不是表达式,表达式也不是定义——它们是不同的语法类别。

目前,让我们专注于一种特定类型的定义,即函数定义。非递归函数的定义如下:

let f x = ...

递归函数的定义如下:

let rec f x = ...

区别只是 rec 关键字。也许有点令人惊讶的是,您必须明确添加一个关键字才能使函数递归,因为大多数语言默认情况下都是这样假设的。不过,OCaml 并不做出这种假设。(Scheme 语言族也是如此。)

最著名的递归函数之一是阶乘函数。在 OCaml 中,它可以写成如下形式:

(** [fact n] is [n!].
    Requires: [n >= 0]. *)
let rec fact n = if n = 0 then 1 else n * fact (n - 1)
val fact : int -> int = <fun>

我们在函数上方提供了一个规范注释,用于记录函数的前置条件( Requires )和后置条件( is )。

请注意,与许多语言一样,OCaml 整数并非“数学”整数,而是限制在固定位数上。手册规定(有符号)整数至少为 31 位,但它们可能更宽。随着架构的发展,这个大小也在增加。在当前的实现中,OCaml 整数为 63 位。因此,如果您对足够大的输入进行测试,您可能会开始看到奇怪的结果。问题在于机器算术,而不是 OCaml。(对于感兴趣的读者:为什么是 3163 而不是 3264?OCaml 垃圾收集器需要区分整数和指针。因此,这些的运行时表示会窃取一位来标记一个字是整数还是指针。)

这是另一个递归函数:

(** [pow x y] is [x] to the power of [y].
     Requires: [y >= 0]. *)
let rec pow x y = if y = 0 then 1 else x * pow x (y - 1)
val pow : int -> int -> int = <fun>

注意我们在这两个函数中都没有写任何类型:OCaml 编译器会自动为我们推断类型。编译器通过算法解决了这个类型推断问题,但我们也可以自己做。这就像一个谜团,可以通过我们的推理能力来解决:

  • 由于 if 表达式可以在 then 分支中返回 1 ,根据 if 的类型规则,我们知道整个 if 表达式的类型为 int
  • 由于 if 表达式的类型为 int ,函数的返回类型必须为 int
  • 由于 y0 使用相等运算符进行比较, y 必须是一个 int
  • 由于 x 与另一个表达式使用 * 运算符相乘, x 必须是一个 int

如果我们出于某种原因想要写下这些类型,我们可以这样做:

let rec pow (x : int) (y : int) : int = ...

括号在我们为 xy 编写类型注释时是必需的。通常我们会省略这些注释,因为让编译器推断它们会更简单。还有其他时候,您可能希望明确写下类型。一个特别有用的时机是当您从编译器那里得到一个您不理解的类型错误时。显式注释类型可以帮助调试这样的错误消息。

语法。函数定义的语法:

let rec f x1 x2 ... xn = e

f 是一个元变量,表示正在用作函数名的标识符。这些标识符必须以小写字母开头。有关小写标识符的其余规则可以在手册中找到。名称 x1xn 是表示参数标识符的元变量。这些遵循与函数标识符相同的规则。如果 f 是递归函数,则必须使用关键字 rec ;否则可以省略。

请注意,函数定义的语法实际上比 OCaml 实际允许的要简化。在接下来的几周中,我们将学习更多关于函数定义的增强语法。但是现在,这个简化版本将帮助我们集中精力。

可以使用 and 关键字来定义相互递归的函数:

let rec f x1 ... xn = e1
and g y1 ... yn = e2

例如:

(** [even n] is whether [n] is even.
    Requires: [n >= 0]. *)
let rec even n =
  n = 0 || odd (n - 1)

(** [odd n] is whether [n] is odd.
    Requires: [n >= 0]. *)
and odd n =
  n <> 0 && even (n - 1);;
val even : int -> bool = <fun>
val odd : int -> bool = <fun>

函数类型的语法是:

t -> u
t1 -> t2 -> u
t1 -> ... -> tn -> u

tu 是表示类型的元变量。类型 t -> u 是一个接受类型为 t 的输入并返回类型为 u 的输出的函数的类型。我们可以将 t1 -> t2 -> u 看作是一个接受两个输入的函数的类型,第一个输入类型为 t1 ,第二个输入类型为 t2 ,并返回类型为 u 的输出。类似地,对于一个接受 n 个参数的函数。

动态语义。函数定义没有动态语义。没有什么需要被求值。OCaml 只是记录名称 f 绑定到具有给定参数 x1..xn 和给定主体 e 的函数。只有在稍后应用函数时,才会有一些需要求值的内容。

静态语义。函数定义的静态语义:

  • 对于非递归函数:如果假设 x1 : t1x2 : t2 和…和 xn : tn ,我们可以得出 e : u 的结论,那么 f : t1 -> t2 -> ... -> tn -> u
  • 对于递归函数:如果假设 x1 : t1x2 : t2 和…和 xn : tnf : t1 -> t2 -> ... -> tn -> u ,我们可以得出e : u ,那么 f : t1 -> t2 -> ... -> tn -> u

注意递归函数的类型检查规则是假设函数标识符 f 具有特定类型,然后检查函数体在该假设下是否是良型的。这是因为 f 在函数体内部是可见的(就像参数在作用域内一样)。

要理解 OCaml 中递归函数的类型检查规则,首先需要理解 类型推断环境 的概念。类型推断是指编译器在没有显式类型标注的情况下自动推导出表达式类型的过程。环境则是一个存储变量及其类型信息的映射表。

在 OCaml 中,递归函数的类型检查遵循以下规则:

  1. 假设函数标识符具有特定类型: 在检查递归函数的函数体时,OCaml 编译器会首先假设函数标识符 (例如, f) 具有程序员期望的类型。 这类似于在函数体内部,函数名 f 就如同一个已经定义了类型的变量,可以被函数体内部的代码使用。

  2. 检查函数体在假设下是否良型: 接下来,编译器会根据环境中已有的类型信息和函数标识符的假设类型,来检查函数体中所有表达式的类型是否一致。 如果类型检查过程中发现任何类型错误,编译器就会报错。

  3. 函数标识符在函数体内部可见: 递归函数的定义允许函数体内部的代码调用函数自身。 这意味着在函数体的环境中,函数标识符 f 是可见的,并且它的类型是之前假设的类型。 因此,编译器可以像检查普通变量一样检查递归函数对自身的调用。

总而言之,OCaml 中递归函数的类型检查规则可以概括为: 假设函数类型,检查函数体,函数名可见。 这种规则使得编译器能够在类型安全的前提下处理递归函数的定义和调用。

匿名函数

我们已经知道我们可以拥有不绑定到名称的值。例如,整数 42 可以在顶层输入而不给它一个名称:

42
- : int = 42

或者我们可以将其绑定到一个名称:

let x = 42
val x : int = 42

同样,OCaml 函数不必有名称;它们可以是匿名的。例如,这是一个将其输入递增的匿名函数: fun x -> x + 1 。这里, fun 是一个指示匿名函数的关键字, x 是参数, -> 将参数与函数体分隔开来。

我们现在有两种方式可以编写一个增量函数:

let inc x = x + 1
let inc = fun x -> x + 1
val inc : int -> int = <fun>
val inc : int -> int = <fun>

匿名函数也被称为 lambda 表达式,这个术语来自λ演算,它是一种计算模型,类似于图灵机是一种计算模型。在λ演算中, fun x -> e 会被写成 lambda x.e ,其中 lambda 是希腊字母 λ 的英文名。 λ 表示一个匿名函数。

现在或许有点神秘,为什么我们要使用没有名称的函数。别担心;在课程的后面,我们会看到它们的好处,特别是当我们学习所谓的“高阶编程”时。特别是,我们经常会创建匿名函数并将它们作为输入传递给其他函数。

句法

fun x1 ... xn -> e

静态语义

  • 如果假设 x1 : t1x2 : t2...xn : tn ,我们可以得出结论 e : u ,那么 fun x1 ... xn -> e : t1 -> t2 -> ... -> tn -> u

动态语义学。匿名函数已经是一个值。没有需要执行的计算。

函数应用

这里我们介绍了一种相对简化的函数应用语法,与 OCaml 实际允许的语法相比。

句法

e0 e1 e2 ... en

第一个表达式 e0 是函数,并且它被应用于参数 e1en 。请注意,不需要在参数周围加括号来表示函数应用,就像在 C 家族语言(包括 Java)中一样。

静态语义

  • 如果 e0 : t1 -> ... -> tn -> ue1 : t1...en : tn ,那么 e0 e1 ... en : u

动态语义

求值 e0 e1 ... en

  • e0 求值为一个函数。同时将参数表达式 e1en 求值为值 v1vn

    对于 e0 ,结果可能是一个匿名函数 fun x1 ... xn -> e 或一个名称 f 。在后一种情况下,我们需要找到 f 的定义,我们可以假设其形式为 let rec f x1 ... xn = e 。无论哪种方式,我们现在知道参数名称 x1xn 和主体 e

  • 将函数体 e 中的每个值 vi 替换为相应的参数名称 xi 。该替换会产生一个新表达式 e'

  • 求值 e' 的值为 v ,这是求值 e0 e1 ... en 的结果。

如果您将这些求值规则与 let 表达式的规则进行比较,您会注意到它们都涉及替换。这并非偶然。事实上,在程序中任何 let x = e1 in e2 出现的地方,我们都可以用 (fun x -> e2) e1 替换它。它们在语法上有所不同,但在语义上是等价的。实质上, let 表达式只是匿名函数应用的语法糖。

Pipeline

OCaml 中有一个内置的中缀运算符用于函数应用,称为管道运算符,写作 |> 。想象它表示一个指向右侧的三角形。这个比喻是值从左到右通过管道发送。例如,假设我们有上面的增量函数 inc 以及一个将其输入平方的函数 square

let square x = x * x
val square : int -> int = <fun>

这里有两种等效的方法来求 6 的平方:

- : int = 36
- : int = 36

后者使用管道运算符将 5 发送到 inc 函数,然后将该结果发送到 square 函数。这是在 OCaml 中表达计算的一种不错的成语方式。前者的方式可能不那么优雅:它涉及编写额外的括号,并要求读者的眼睛在各处跳动,而不是从左到右线性移动。后者的方式在应用的函数数量增加时可以很好地扩展,而前者的方式则需要越来越多的括号。

5 |> inc |> square |> inc |> inc |> square;;
square (inc (inc (square (inc 5))));;
- : int = 1444
- : int = 1444

一开始可能会感觉有点奇怪,但下次在编写一长串函数应用时,尝试在自己的代码中使用管道操作符。

由于 e1 |> e2 只是另一种写法 e2 e1 ,我们不需要说明 |> 的语义:它与函数应用相同。这两个程序是另一个例子,表达式在语法上不同但在语义上等效。

多态函数

id函数是一个简单地返回其输入的函数:

let id x = x
val id : 'a -> 'a = <fun>

或者等效地作为一个匿名函数:

let id = fun x -> x
val id : 'a -> 'a = <fun>

'a 是一种类型变量:它代表一个未知类型,就像常规变量代表一个未知值一样。类型变量总是以单引号开头。常用的类型变量包括 'a'b'c ,OCaml 程序员通常用希腊语发音:alphabetagamma

我们可以将恒等函数应用于任何类型的值:

id 42;;
id true;;
id "bigred";;
- : int = 42
- : bool = true
- : string = "bigred"

由于您可以将 id 应用于许多类型的值,它是一个多态函数:它可以应用于许多(多)形式(形态)。

通过手动类型注释,可以为多态函数提供比编译器自动推断的类型更为严格的类型。例如:

let id_int (x : int) : int = x
val id_int : int -> int = <fun>

这与 id 的功能相同,只是有两个手动类型的注释。因为这些原因,我们不能像对待 id 一样将 id_int 应用于 bool

id_int true
File "[14]", line 1, characters 7-11:
1 | id_int true
           ^^^^
Error: This expression has type bool but an expression was expected of type
         int

另一种写法 id_int 的方式是用 id 来表示:

let id_int' : int -> int = id
val id_int' : int -> int = <fun>

实际上,我们取了一个类型为 'a -> 'a 的值,并将其绑定到一个手动指定为 int -> int 的名称上。你可能会问,为什么会起作用呢?毕竟,它们并不是相同的类型。

一种思考这个问题的方式是从行为角度来看。 id_int 的类型指定了其行为的一个方面:给定一个 int 作为输入,它承诺会产生一个 int 作为输出。事实证明, id 也做出了同样的承诺:给定一个 int 作为输入,它也会返回一个 int 作为输出。现在 id 也做出了许多其他承诺,比如:给定一个 bool 作为输入,它将返回一个 bool 作为输出。因此,通过将 id 绑定到一个更为严格的类型 int -> int ,我们已经丢弃了所有这些额外的承诺,因为它们是无关紧要的。当然,这意味着丢失了信息,但至少不会违背承诺。当我们需要的是一个 int -> int 类型的函数时,使用一个 'a -> 'a 类型的函数总是安全的。

相反的情况并不成立。如果我们需要一个类型为 'a -> 'a 的函数,但却尝试使用一个类型为 int -> int 的函数,一旦有人传入另一种类型的输入,比如 bool ,我们就会陷入麻烦之中。为了避免这种麻烦,OCaml 对以下代码做了一些潜在的令人惊讶的处理:

let id' : 'a -> 'a = fun x -> x + 1
val id' : int -> int = <fun>

函数 id' 实际上是增量函数,而不是身份函数。因此,将其传递给 boolstring 或一些复杂的数据结构是不安全的;它可以安全操作的唯一数据是整数。因此,OCaml 将类型变量 'a 实例化为 int ,从而防止我们将 id' 应用于非整数:

id' true
File "[17]", line 1, characters 4-8:
1 | id' true
        ^^^^
Error: This expression has type bool but an expression was expected of type
         int

这将引导我们思考另一种更机械化的方式来考虑所有这些与应用相关的问题。我们指的是函数应用到参数的相同概念:在求值应用 id 5 时,参数 x 将被实例化为值 5 。同样, id 类型中的 'a 在该应用中将被实例化为类型 int 。因此,如果我们写下

let id_int' : int -> int = id
val id_int' : int -> int = <fun>

事实上,我们正在使用类型 id 中的 'a 实例化类型 int 。就像没有办法“取消应用”函数一样,例如,给定 5 ,我们无法向后计算到 id 5 ,我们也无法取消应用该类型实例化并将 int 更改回 'a

为了准确起见,假设我们有一个 let 定义[或表达]:

let x = e [in e']

OCaml 推断 x 具有类型 t ,其中包括一些类型变量 'a'b 等。然后我们可以实例化这些类型变量。我们可以通过将函数应用于揭示类型实例化应该是什么的参数(如 id 5 中所示)或通过类型注释(如 id_int' 中所示)等方式来实现。但我们必须在实例化时保持一致。例如,我们不能将类型为 'a -> 'b -> 'a 的函数实例化为类型 int -> 'b -> string ,因为 'a 的实例化在其出现的两个位置上不是相同的类型:

let first x y = x;;
let first_int : int -> 'b -> int = first;;
let bad_first : int -> 'b -> string = first;;
val first : 'a -> 'b -> 'a = <fun>
val first_int : int -> 'b -> int = <fun>
File "[19]", line 3, characters 38-43:
3 | let bad_first : int -> 'b -> string = first;;
                                          ^^^^^
Error: This expression has type int -> 'b -> int
       but an expression was expected of type int -> 'b -> string
       Type int is not compatible with type string

标记和可选参数

函数的类型和名称通常会让你对参数应该是什么有一个很好的想法。然而,对于有许多参数的函数(特别是相同类型的参数),给它们贴上标签可能会很有用。例如,你可能会猜到函数 String.sub 返回给定字符串的子字符串(你的猜测是正确的)。你可以输入 String.sub 来查找它的类型:

String.sub;;
- : string -> int -> int -> string = <fun>

但是从类型上并不清楚如何使用它,你被迫查阅文档。

OCaml 支持函数的标记参数。您可以使用以下语法声明这种类型的函数:

let f ~name1:arg1 ~name2:arg2 = arg1 + arg2;;
val f : name1:int -> name2:int -> int = <fun>

此函数可以通过以任意顺序传递带标签的参数来调用:

f ~name2:3 ~name1:4

参数的标签通常与它们的变量名称相同。OCaml 为这种情况提供了一种简写。以下两者是等价的:

let f ~name1:name1 ~name2:name2 = name1 + name2
let f ~name1 ~name2 = name1 + name2

使用带标签的参数在很大程度上是一种品味问题。它们传达了额外的信息,但也可能使类型变得混乱。

编写带有标签参数和显式类型注释的语法是:

let f ~name1:(arg1 : int) ~name2:(arg2 : int) = arg1 + arg2

也可以将一些参数设置为可选的。在没有可选参数的情况下调用时,将提供默认值。要声明这样一个函数,请使用以下语法:

let f ?name:(arg1=8) arg2 = arg1 + arg2
val f : ?name:int -> int -> int = <fun>

您可以选择带参数或不带参数地调用一个函数:

f ~name:2 7
- : int = 9
f 7
- : int = 15

偏应用

我们可以将加法函数定义如下:

let add x y = x + y
val add : int -> int -> int = <fun>

这是一个相当类似的函数:

let addx x = fun y -> x + y
val addx : int -> int -> int = <fun>

函数 addx 接受一个整数 x 作为输入,并返回一个类型为 int -> int 的函数,该函数将 x 添加到传递给它的任何内容

addx 的类型是 int -> int -> intadd 的类型也是 int -> int -> int 。因此从它们的类型的角度来看,它们是相同的函数。但 addx 的形式暗示了一些有趣的东西:我们可以将它应用于单个参数。

let add5 = addx 5
val add5 : int -> int = <fun>
add5 2
- : int = 7

原来 add 也可以这样做:

let add5 = add 5
val add5 : int -> int = <fun>
add5 2;;
- : int = 7

我们刚刚做的是偏应用:我们将函数 add 偏应用于一个参数,尽管你通常会认为它是一个多参数函数。这样做是因为以下三个函数在语法上不同但在语义上是等价的。也就是说,它们是表达相同计算的不同方式:

let add x y = x + y
let add x = fun y -> x + y
let add = fun x -> (fun y -> x + y)

所以 add 实际上是一个接受参数 x 并返回函数 (fun y -> x + y) 的函数。这让我们得出一个深刻的真理...

函数结合性

你准备好接受真相了吗?深呼吸。开始了…

每个 OCaml 函数都只接受一个参数。

为什么?考虑 add :虽然我们可以将其写成 let add x y = x + y ,但我们知道这在语义上等同于 let add = fun x -> (fun y -> x + y) 。而且一般来说,

let f x1 x2 ... xn = e

语义上等同于

let f =
  fun x1 ->
    (fun x2 ->
       (...
          (fun xn -> e)...))

所以,即使你认为 f 是一个接受 n 个参数的函数,实际上它是一个接受 1 个参数并返回一个函数的函数。

这种函数的类型

t1 -> t2 -> t3 -> t4

确实意味着与

t1 -> (t2 -> (t3 -> t4))

换句话说,函数类型是右结合的:函数类型周围有隐式括号,从右到左。这里的直觉是,函数接受一个参数并返回一个期望其余参数的新函数。

函数应用,另一方面,是左结合的:函数应用周围有隐式括号,从左到右。所以

e1 e2 e3 e4

确实意味着与

((e1 e2) e3) e4

这里的直觉是,最左边的表达式将右侧的下一个表达式作为其单个参数。

运算符作为函数

加法运算符 + 具有类型 int -> int -> int 。通常以中缀形式书写,例如, 3 + 4 。通过在其周围加括号,我们可以将其变为前缀运算符:

( + )
- : int -> int -> int = <fun>
( + ) 3 4;;
- : int = 7
let add3 = ( + ) 3
val add3 : int -> int = <fun>
add3 2
- : int = 5

相同的技术适用于任何内置运算符。

通常空格是不必要的。我们可以写 (+)( + ) ,但最好包括它们。要注意乘法,必须写成 ( * ) ,因为 (*) 会被解析为开始注释。

我们甚至可以定义自己的新中缀运算符,例如:

let ( ^^ ) x y = max x y

现在 2 ^^ 3 求值为 3

可以用标点符号创建中缀运算符的规则并不一定直观。这类运算符的解析优先级也不一定直观。因此,在使用时要小心。

尾递归

考虑以下看似无趣的函数,它从 1 数到 n

(** [count n] is [n], computed by adding 1 to itself [n] times.  That is,
    this function counts up from 1 to [n]. *)
let rec count n =
  if n = 0 then 0 else 1 + count (n - 1)
val count : int -> int = <fun>

数到 10 没有问题:

count 10
- : int = 10

计数到 100,000 也不是问题:

count 100_000
- : int = 100000

但是尝试数到 1,000,000,你会收到以下错误:

Stack overflow during evaluation (looping recursion?).

这里发生了什么事?

调用堆栈。问题在于调用堆栈具有有限的大小。您可能在入门编程课程中学到,大多数编程语言使用堆栈来实现函数调用。该堆栈包含每个已启动但尚未完成的函数调用的一个元素。每个元素存储诸如本地变量的值以及当前正在执行的函数中的指令等信息。当一个函数体的求值调用另一个函数时,调用堆栈上会推送一个新元素,并在被调用函数完成时弹出。

栈的大小通常受操作系统限制。因此,如果栈空间耗尽,就无法再进行另一个函数调用。通常情况下,这种情况不会发生,因为在返回之前没有理由连续进行那么多次函数调用。在发生这种情况的情况下,操作系统有充分理由让程序停止运行:可能正在占用整台计算机上所有可用内存,从而影响同一台计算机上运行的其他程序。 count 函数不太可能做到这一点,但这个函数可能会:

let rec count_forever n = 1 + count_forever n
val count_forever : 'a -> int = <fun>

因此,出于安全考虑,操作系统限制了调用堆栈的大小。这意味着最终 count 将在足够大的输入上耗尽堆栈空间。请注意,这个选择实际上与编程语言无关。因此,这个问题在除了 OCaml 之外的其他语言中也会出现,包括 Python 和 Java。你在那些语言中可能没有看到这个问题的表现,因为你可能从未在那些语言中编写过如此多的递归函数。

尾递归。关于这个问题,盖·斯蒂尔(Guy Steele)在 1977 年关于 LISP 的一篇论文中描述了一个解决方案。这个解决方案,即尾调用优化,需要程序员和编译器之间的一些合作。程序员对函数进行一些重写,编译器会注意到并应用优化。让我们看看它是如何工作的。

假设一个递归函数 f 调用自身然后返回该递归调用的结果。我们的 count 函数不这样做:

let rec count n =
  if n = 0 then 0 else 1 + count (n - 1)
val count : int -> int = <fun>

相反,在递归调用 count (n - 1) 之后,还有计算任务未完成:计算机仍然需要将 1 添加到该调用的结果中。

但是作为程序员,我们可以重写 count 函数,使其在递归调用之后不需要进行任何额外的计算。诀窍在于创建一个带有额外参数的辅助函数:

let rec count_aux n acc =
  if n = 0 then acc else count_aux (n - 1) (acc + 1)

let count_tr n = count_aux n 0
val count_aux : int -> int -> int = <fun>
val count_tr : int -> int = <fun>

函数 count_aux 与我们原来的 count 几乎相同,但它添加了一个名为 acc 的额外参数,这是惯用语,代表“累加器”。其思想是,我们希望从函数返回的值会随着每次递归调用而在其中逐渐累积。现在,“剩余计算”——加 1——现在发生在递归调用之前而不是之后。当递归的基本情况最终到来时,函数现在返回 acc ,其中答案已经被累积

但是原始的基本情况 0 仍然需要在代码中存在。而且确实存在,作为传递给 count_auxacc 的原始值。现在 count_tr (我们马上会解释为什么名称是“tr”)作为我们原始 count 的替代品。

到这一点,我们已经完成了程序员的责任,但很可能不清楚为什么要经历这样的努力。毕竟, count_aux 仍然会像 count 一样递归调用自身太多次,最终会导致堆栈溢出。

这就是编译器的责任所在。一个好的编译器(OCaml 编译器在这方面做得很好)可以注意到递归调用是否处于尾位置,这是一种技术性的说法,意思是“在它返回后没有更多的计算要做了”。对 count_aux 的递归调用处于尾位置;对 count 的递归调用则不是。以下是它们的对比:

let rec count n =
  if n = 0 then 0 else 1 + count (n - 1)

let rec count_aux n acc =
  if n = 0 then acc else count_aux (n - 1) (acc + 1)

这就是尾位置的重要性:尾位置的递归调用不需要新的堆栈帧。它可以直接重用现有的堆栈帧。这是因为在现有的堆栈帧中没有任何有用的东西了!没有剩余的计算需要进行,因此局部变量、下一条指令要执行的内容等都不再重要。再也不需要再次读取那些内存,因为该调用实际上已经完成。因此,编译器不会浪费空间来分配另一个堆栈帧,而是“回收”了先前帧使用的空间。

这是尾调用优化。即使在递归函数之外的情况下,如果调用函数的栈帧与被调用者适配,也可以应用尾调用优化。而且,这是一件大事。尾调用优化将栈空间需求从线性降低到常数。而 count 需要 O(n)个栈帧, count_aux 只需要 O(1),因为相同的栈帧一遍又一遍地被每次递归调用重复使用。这意味着 count_tr 实际上可以计数到1,000,000

count_tr 1_000_000
- : int = 1000000

最后,我们为什么将这个函数命名为 count_tr ?“tr”代表尾递归。尾递归函数是一个递归函数,其递归调用都处于尾位置。换句话说,这是一个函数(除非存在其他病态情况),不会耗尽堆栈。

尾递归的重要性。有时,初学函数式编程的人可能会过分关注它。如果你只关心编写函数的初稿,那么你可能不需要担心尾递归。如果需要的话,通过添加一个累加器参数,很容易将其改为尾递归。或者,也许你应该重新思考一下你设计函数的方式。以 count 为例:这有点愚蠢。但后来我们会看到一些不愚蠢的例子,比如遍历包含成千上万元素的列表。

编译器支持优化是很重要的。否则,作为程序员对代码所做的转换就毫无意义。事实上,大多数编译器都支持优化,至少作为一个选项。Java 是一个明显的例外。

尾递归的配方。简而言之,这是我们使一个函数成为尾递归的方法:

  • 将该函数改为辅助函数。添加一个额外的参数:累加器,通常命名为 acc
  • 编写一个调用辅助函数的新的“main”版本。它将原始基本情况的返回值作为累加器的初始值传递。
  • 将辅助函数更改为在基本情况下返回累加器。
  • 更改辅助函数的递归情况。现在它需要在递归调用之前对累加器参数进行额外的工作。这是唯一需要很多创造力的步骤。

一个例子:阶乘。让我们将这个阶乘函数转换为尾递归形式:

(** [fact n] is [n] factorial. *)
let rec fact n =
  if n = 0 then 1 else n * fact (n - 1)
val fact : int -> int = <fun>

首先,我们更改其名称并添加一个累加器参数:

let rec fact_aux n acc = ...

其次,我们编写一个新的“主”函数,该函数调用带有原始基本情况作为累加器的辅助函数:

let rec fact_tr n = fact_aux n 1

第三,我们将辅助函数更改为在基本情况下返回累加器:

if n = 0 then acc ...

最后,我们改变递归情况:

else fact_aux (n - 1) (n * acc)

将所有这些放在一起,我们有:

let rec fact_aux n acc =
  if n = 0 then acc else fact_aux (n - 1) (n * acc)

let fact_tr n = fact_aux n 1
val fact_aux : int -> int -> int = <fun>
val fact_tr : int -> int = <fun>

这是一个不错的练习,但也许并不值得。甚至在我们耗尽堆栈空间之前,计算就会因整数溢出而受到影响。

fact 50
- : int = -3258495067890909184

为了解决这个问题,我们转向 OCaml 的大整数库 Zarith。在这里,我们使用了一些 OCaml 的特性,这些特性超出了我们迄今为止见过的任何内容,但希望没有什么特别令人惊讶的地方。(如果您想跟随这段代码,首先在 OPAM 中安装 Zarith opam install zarith 。)

#require "zarith.top";;
let rec zfact_aux n acc =
  if Z.equal n Z.zero then acc else zfact_aux (Z.pred n) (Z.mul acc n);;

let zfact_tr n = zfact_aux n Z.one;;

zfact_tr (Z.of_int 50)
val zfact_aux : Z.t -> Z.t -> Z.t = <fun>
val zfact_tr : Z.t -> Z.t = <fun>
- : Z.t = 30414093201713378043612608166064768844377641568960512000000000000

如果你愿意,你可以使用该代码来计算 zfact_tr 1_000_000 ,而不会出现堆栈溢出或整数溢出的问题,尽管这可能需要几分钟的时间。

关于模块的章节将详细解释我们上面使用的 OCaml 功能,但现在:

  • #require 加载了库,该库提供了一个名为 Z 的模块。回想一下, 是数学中用来表示整数的符号。

  • Z.n 表示在 Z 内定义的名称 n

  • 类型 Z.t 是大整数类型的库名称。

  • 我们使用库值 Z.equal 进行相等比较, Z.zero 代表 0Z.pred 代表前驱(即减 1), Z.mul 代表乘法, Z.one 代表 1Z.of_int 用于将原始整数转换为大整数。

文档

OCaml 提供了一个名为 OCamldoc 的工具,其工作原理与 Java 的 Javadoc 工具非常相似:它从源代码中提取特殊格式的注释并将其呈现为 HTML,使程序员可以轻松阅读文档。

如何生成文档

以下是 OCamldoc 注释的一个示例:

(** [sum lst] is the sum of the elements of [lst]. *)
let rec sum lst = ...
  • 双星号使注释被识别为 OCamldoc 注释。
  • 注释部分周围的方括号表示这些部分应以打印字体而不是常规字体呈现在 HTML 中。

此外,与 Javadoc 一样,OCamldoc 支持文档标签,例如 @author@deprecated@param@return 等。例如,在大多数编程作业的第一行,我们要求您完成这样的注释:

(** @author Your Name (your netid) *)

有关 OCamldoc 注释中可能使用的所有标记,请参阅 OCamldoc 手册。但我们在此处介绍的内容足以满足您需要编写的大多数文档的需求。

文档应该包含什么

本书中我们喜欢的文档风格与 OCaml 标准库的风格类似:简洁且声明性强。作为示例,让我们重新回顾一下 sum 的文档:

(** [sum lst] is the sum of the elements of [lst]. *)
let rec sum lst = ...

该注释以 sum lst 开头,这是函数应用于参数的示例。注释以单词“is”继续,从而以声明方式描述应用程序的结果。(可以使用单词“returns”,但“is”强调函数的数学性质。)该描述使用参数的名称 lst 来解释结果。

请注意,无需添加标签来重复描述参数或返回值,而 Javadoc 中经常这样做。需要说的一切都已经说完了。我们强烈反对以下文档:

(** Sum a list.
    @param lst The list to be summed.
    @return The sum of the list. *)
let rec sum lst = ...

这份糟糕的文档用三行不必要的、难以阅读的文字来表达与清晰的一行版本相同的内容。

有一种方法可以改进我们目前的文档,即明确说明空列表会发生什么情况:

(** [sum lst] is the sum of the elements of [lst].
    The sum of an empty list is 0. *)
let rec sum lst = ...

先决条件和后置条件

以下是一些以我们喜欢的风格撰写的注释示例。

(** [lowercase_ascii c] is the lowercase ASCII equivalent of
    character [c]. *)

(** [index s c] is the index of the first occurrence of
    character [c] in string [s].  Raises: [Not_found]
    if [c] does not occur in [s]. *)

(** [random_int bound] is a random integer between 0 (inclusive)
    and [bound] (exclusive).  Requires: [bound] is greater than 0
    and less than 2^30. *)

index 的文档指定该函数会引发异常,以及该异常是什么以及引发该异常的条件。(我们将在下一章中更详细地介绍异常。)random_int 的文档指定该函数的参数必须满足条件。

在之前的课程中,您已经了解了先决条件和后置条件的概念。先决条件是某些代码段之前必须为真的条件;后置条件是之后必须为真的条件。

random_int 文档中的上述“Requires”子句是一种先决条件。它表示 random_int 函数的客户端负责保证 bound 的值。同样,同一文档的第一句话是一种后置条件。它保证函数返回的值。

index 文档中的“Raises”子句是另一种后置条件。它保证该函数会引发异常。请注意,该子句不是先决条件,即使它以输入的形式陈述了条件。

请注意,这些示例中均没有“Requires”子句来说明输入的类型。如果您来自动态类型语言(如 Python),这可能会让您感到惊讶。Python 程序员经常会记录有关函数输入类型的先决条件。然而,OCaml 程序员却不会这样做。这是因为编译器本身会进行类型检查,以确保您永远不会将错误类型的值传递给函数。再次考虑 lowercase_ascii:尽管英文注释有助于向读者识别 c 的类型,但注释并未像这样声明“Requires”子句:

(** [lowercase_ascii c] is the lowercase ASCII equivalent of [c].
    Requires: [c] is a character. *)

对于 OCaml 程序员来说,这样的注释非常不符合语言习惯,他们会读到这个注释并感到困惑,可能会想:“当然,c 是一个字符;编译器会保证这一点。写这个注释的人到底是什么意思?他们或我是否遗漏了什么?”

打印

OCaml 为一些内置基本类型提供了内置打印函数:print_charprint_stringprint_intprint_float。还有一个 print_endline 函数,它与 print_string 类似,但还会输出换行符。

print_endline "Camels are bae"
Camels are bae
- : unit = ()

Uint

让我们看一下其中几个函数的类型:

print_endline
- : string -> unit = <fun>
print_string
- : string -> unit = <fun>

它们都以字符串作为输入并返回 unit 类型的值,这是我们之前从未见过的。这种类型的值只有一个,写为 (),发音为“unit”。因此 unit 类似于 bool,只是 unit 类型的值比 bool 类型的值少一个。

当您需要接受参数或返回值,但没有要传递或返回的有趣值时,使用 Unit。它相当于 Java 中的 void,类似于 Python 中的 NoneUnit 通常在编写或使用具有副作用的代码时使用。打印就是副作用的一个例子:它改变了世界,并且无法撤消。

Semicolon

如果要逐个打印内容,可以使用嵌套 let 表达式对一些打印函数进行排序:

let _ = print_endline "Camels" in
let _ = print_endline "are" in
print_endline "bae"
Camels
are
bae
- : unit = ()

上面的 let _ = e 语法是一种计算 e 但不将其值绑定到任何名称的方法。事实上,我们知道每个 print_endline 函数将返回的值:它始终是 (),即 unit。因此没有充分的理由将其绑定到变量名。我们也可以写 let () = e 来表示我们知道它只是一个我们不关心的 unit

let () = print_endline "Camels" in
let () = print_endline "are" in
print_endline "bae"
Camels
are
bae
- : unit = ()

但无论哪种方式,编写所有 let..in 的样板代码都很烦人!因此,有一种特殊的语法可用于将多个返回 unit 的函数链接在一起。表达式 e1; e2 首先计算 e1,它应该计算为 (),然后丢弃该值,并计算 e2。因此,我们可以将上述代码重写为:

print_endline "Camels";
print_endline "are";
print_endline "bae"
Camels
are
bae
- : unit = ()

这是更加惯用的 OCaml 代码,并且对于命令式程序员来说看起来也更加自然。

⚠️警告

在该示例中,最后一个 print_endline 后面没有分号。一个常见的错误是在每个 print 语句后面都加一个分号。相反,分号严格位于语句之间。也就是说,分号是语句分隔符,而不是语句终止符。如果您在末尾添加分号,则可能会出现语法错误,具体取决于周围的代码。

Ignore

如果 e1 不是 unit 类型,则 e1; e2 将发出警告,因为您正在丢弃一个可能有用的值。如果这确实是您的意图,您可以调用内置函数 ignore : 'a -> unit 将任何值转换为 ()

(ignore 3); 5
- : int = 5

实际上 ignore 很容易自己实现:

let ignore x = ()
val ignore : 'a -> unit = <fun>

或者你甚至可以写下划线来表示函数接受一个值但不将该值绑定到名称。这意味着函数永远不能在其主体中使用该值。但没关系:我们想忽略它。

let ignore _ = ()
val ignore : 'a -> unit = <fun>

Printf

对于复杂的文本输出,使用内置函数进行原始类型打印很快就会变得乏味。例如,假设您想编写一个函数来打印统计数据:

(** [print_stat name num] prints [name: num]. *)
let print_stat name num =
  print_string name;
  print_string ": ";
  print_float num;
  print_newline ()
val print_stat : string -> float -> unit = <fun>
print_stat "mean" 84.39
mean: 84.39
- : unit = ()

我们如何缩短 print_stat?在 Java 中,您可以使用重载 + 运算符将所有对象转换为字符串:

void print_stat(String name, double num) {
   System.out.println(name + ": " + num);
}

但是 OCaml 值不是对象,它们没有从某个根 Object 类继承的 toString() 方法。OCaml 也不允许运算符重载。

不过很久以前,FORTRAN 发明了一种不同的解决方案,其他语言(如 C 和 Java 甚至 Python)也支持该解决方案。这个想法是使用格式说明符(顾名思义)来指定如何格式化输出。这个想法最著名的名字可能是“printf”,它指的是实现它的 C 库函数的名称。许多其他语言和库仍然使用该名称,包括 OCaml 的 Printf 模块。

以下是我们使用 printf 重新实现 print_stat 的方法:

let print_stat name num =
  Printf.printf "%s: %F\n%!" name num
val print_stat : string -> float -> unit = <fun>
print_stat "mean" 84.39
mean: 84.39
- : unit = ()

Printf.printf 函数的第一个参数是格式说明符。它看起来像一个字符串,但实际上它的作用不止于此。OCaml 编译器实际上对它的理解相当深刻。格式说明符内部有:

  • 纯字符,以及
  • 转换说明符,以 % 开头。

大约有二十几个转换说明符可用,您可以在 Printf 的文档中阅读。让我们以上面的格式说明符为例。

  • 它以 %s 开头,这是字符串的转换说明符。这意味着 printf 的下一个参数必须是 string,并且将输出该字符串的内容。
  • 接下来 : ,这只是纯字符。这些将插入到输出中。
  • 然后是另一个转换说明符 %F。这意味着 printf 的下一个参数必须是 float 类型,并且将以 OCaml 用于打印浮点的相同格式输出。
  • 之后的换行符 \n 是另一个纯字符序列。
  • 最后,转换说明符 %! 表示刷新输出缓冲区。正如您可能在之前的编程课程中学到的那样,输出通常是缓冲的,这意味着它不会一次或立即发生。刷新缓冲区可确保缓冲区中仍存在的任何内容立即得到输出。这个说明符的特殊之处在于它实际上不需要给 printf 传递另一个参数。

如果参数的类型与转换说明符不符,OCaml 会检测到。让我们添加类型注释以强制 numint,并查看浮点转换说明符 %F 会发生什么:

let print_stat name (num : int) =
  Printf.printf "%s: %F\n%!" name num
File "[14]", line 2, characters 34-37:
2 |   Printf.printf "%s: %F\n%!" name num
                                      ^^^
Error: This expression has type int but an expression was expected of type
         float

为了解决这个问题,我们可以将 int 的转换说明符改为 %i

let print_stat name num =
  Printf.printf "%s: %i\n%!" name num
val print_stat : string -> int -> unit = <fun>

printf 的另一个非常有用的变体是 sprintf,它以字符串形式收集输出而不是打印它:

let string_of_stat name num =
  Printf.sprintf "%s: %F" name num
val string_of_stat : string -> float -> string = <fun>
string_of_stat "mean" 84.39
- : string = "mean: 84.39"

调试

调试是当一切都失败时的最后手段。让我们退一步,考虑一下在调试之前发生的一切。

预防BUGs

根据罗布·米勒(Rob Miller)的说法,有四种防御措施可以防止错误

  • 对Bugs的第一道防线是让它们无法生存

    选择使用保证内存安全(即除了通过有效的指针(或引用)访问内存区域外,不能访问内存的任何部分)和类型安全(即不能以与其类型不一致的方式使用值)的编程语言,可以消除整类错误。例如,OCaml 类型系统可以防止程序发生缓冲区溢出和无意义操作(如将布尔值加到浮点数上),而 C 类型系统则不能。

  • 第二道预防Bugs的方法是使用能够发现它们的工具。

    有自动化源代码分析工具,如 FindBugs,可以在 Java 程序中找到许多常见类型的错误,以及用于在设备驱动程序中查找错误的 SLAM。计算机科学的一个子领域被称为形式方法,研究如何使用数学来指定和验证程序,即如何证明程序没有错误。我们将在本课程中稍后学习验证。

    社交方法,如代码审查和配对编程,也是发现错误的有用工具。IBM 在 1970 年代至 1990 年代的研究表明,代码审查可以非常有效。在一项研究中(Jones,1991),代码检查发现了 65%的已知编码错误和 25%的已知文档错误,而测试仅发现了 20%的编码错误和没有文档错误。

  • 第三个防御措施是使错误立即可见。

    出现的错误越早,诊断和修复就越容易。如果计算继续进行到错误点之后,那么进一步的计算可能会掩盖故障实际发生的位置。源代码中的断言使程序“快速失败”和“大声失败”,因此错误会立即出现,程序员会准确知道在源代码中应该查找的位置。

  • 第四个防御虫害的方法是进行广泛的测试。

    如何知道一段代码是否存在特定的错误?编写能够暴露错误的测试,然后确认您的代码不会在这些测试中失败。针对相对较小的代码片段,比如单个函数或模块,编写单元测试尤为重要,最好在开发代码的同时进行。这些测试的运行应该是自动化的,这样如果您不小心破坏了代码,您能尽快发现。(这实际上又是防御 3。)

在所有这些防御都失败之后,程序员被迫诉诸调试。

如何调试

那么你发现了一个漏洞。接下来呢?

  • 将错误提炼成一个小测试案例。调试是一项艰难的工作,但测试案例越小,你就越有可能将注意力集中在错误潜藏的代码部分上。因此,花在这种提炼上的时间可以节省时间,因为你不必重新阅读大量代码。在拥有一个小测试案例之前不要继续调试!

  • 运用科学方法。制定一个关于为什么出现错误的假设。你甚至可以把这个假设写在笔记本上,就好像你在化学实验室里一样,以便在自己的头脑中澄清它并跟踪你已经考虑过的假设。接下来,设计一个实验来证实或否定这个假设。运行你的实验并记录结果。根据你所学到的知识,重新制定你的假设。继续进行,直到你以理性、科学的方式确定了错误的原因。

  • 修复错误。修复可能只是简单地更正一个拼写错误。或者它可能揭示了一个导致您需要进行重大更改的设计缺陷。考虑是否需要将修复应用到代码库中的其他位置——例如,这是一个复制粘贴错误吗?如果是,您是否需要重构您的代码?

  • 将这个小测试用例永久地添加到您的测试套件中。您不希望错误再次潜入您的代码库。因此,通过将其作为单元测试的一部分来跟踪这个小测试用例。这样,每当您进行未来更改时,您将自动防范相同的错误。反复运行从先前错误中提炼出来的测试是回归测试的一部分。

OCaml 中的调试

以下是关于如何在 OCaml 中调试的一些建议,如果你被迫这样做的话。

  • 打印语句。插入一个打印语句来确定变量的值。假设您想知道以下函数中 x 的值是多少:

    let inc x = x + 1
    

    只需添加下面的代码行即可打印该值:

    let inc x =
      let () = print_int x in
      x + 1
    
  • 功能跟踪。假设您想要查看函数的递归调用和返回的跟踪。使用 #trace 指令:

# let rec fib x = if x <= 1 then 1 else fib (x - 1) + fib (x - 2);;
# #trace fib;;

如果您评估 fib 2 ,您现在将看到以下输出:

fib <-- 2
fib <-- 0
fib --> 1
fib <-- 1
fib --> 1
fib --> 2

要停止跟踪,请使用 #untrace 指令。

  • 调试器。OCaml 有一个调试工具 ocamldebug 。您可以在 OCaml 网站上找到教程。除非您将 Emacs 作为编辑器,否则您可能会发现这个工具比仅插入打印语句更难使用。

防御性编程

正如我们在调试部分讨论过的那样,防止错误的一种方法是使任何错误立即可见。这个想法与前提条件的想法相联系。

考虑这个 random_int 的规范:

(** [random_int bound] is a random integer between 0 (inclusive)
    and [bound] (exclusive).  Requires: [bound] is greater than 0
    and less than 2^30. *)

如果 random_int 的客户端传递了违反“Requires”条款的值,比如 -1 ,那么 random_int 的实现可以做任何事情。当客户端违反前置条件时,一切皆不确定。

但对 random_int 来说,最有帮助的事情是立即揭露违反前提条件的事实。毕竟,客户很可能并不是故意违反的。

因此, random_int 的实施者最好检查前置条件是否被违反,如果是,则引发异常。以下是这种防御性编程的三种可能性:

(* possibility 1 *)
let random_int bound =
  assert (bound > 0 && bound < 1 lsl 30);
  (* proceed with the implementation of the function *)

(* possibility 2 *)
let random_int bound =
  if not (bound > 0 && bound < 1 lsl 30)
  then invalid_arg "bound";
  (* proceed with the implementation of the function *)

(* possibility 3 *)
let random_int bound =
  if not (bound > 0 && bound < 1 lsl 30)
  then failwith "bound";
  (* proceed with the implementation of the function *)

第二种可能性可能对客户最具信息性,因为它使用内置函数 invalid_arg 来引发良好命名的异常 Invalid_argument 。事实上,这正是该函数的标准库实现所做的。

第一种可能性可能在您尝试调试自己的代码时最有用,而不是选择将失败的断言暴露给客户。

第三种可能性与第二种可能性仅在引发的异常名称( Failure )上有所不同。在前提涉及多个无效参数的情况下,这可能是有用的。

在这个例子中,检查前置条件在计算上是廉价的。在其他情况下,可能需要大量计算,因此函数的实现者可能更喜欢不检查前置条件,或者只检查一些廉价的近似值。

有时程序员会不必要地担心防御性编程会太昂贵——无论是在实施初始检查时花费的时间,还是在运行时检查断言时支付的成本。这些担忧往往是不合时宜的。社会为修复软件中的故障所花费的时间和金钱表明,我们都能负担得起让程序运行得慢一点的程序。

最后,实施者甚至可以选择消除前置条件,并将其重述为后置条件:

(** [random_int bound] is a random integer between 0 (inclusive)
    and [bound] (exclusive).  Raises: [Invalid_argument "bound"]
    unless [bound] is greater than 0 and less than 2^30. *)

现在,当 bound 太大或太小时,不能自由地做任何事情, random_int 必须引发异常。对于这个函数来说,这可能是最好的选择。

在这门课程中,我们不会强迫你进行防御性编程。但如果你很精明,你会开始(或继续)这样做。花费少量时间编写这些防御代码将为你节省大量调试时间,使你成为更高效的程序员。

总结

语法和语义是学习编程语言的强大范式。当我们学习 OCaml 的特性时,我们会仔细记录它们的语法和语义。我们已经看到,可以有多种语法来表达相同的语义概念,也就是说,相同的计算。

函数应用的语义是 OCaml 和函数式编程的核心,我们将在课程中多次回顾这一点,以加深我们的理解。

术语和概念

  • 匿名函数
  • 匿名函数
  • 绑定
  • 绑定表达式
  • body expression
  • 调试
  • 防御性编程
  • 定义
  • 文档化
  • 动态语义学
  • 评估
  • 表达
  • 功能应用
  • 函数定义
  • 标识符
  • 成语
  • if 表达式
  • lambda 表达式
  • let 定义
  • let 表达式
  • libraries
  • 元变量
  • 相互递归
  • pipeline operator
  • 后置条件
  • 前提条件
  • 打印
  • 递归
  • 语义学
  • 静态语义
  • substitution
  • 语法
  • 工具
  • 类型检查
  • 类型推断
  • values

进一步阅读

  • Objective Caml 简介,第 3 章
  • OCaml 从零开始,第 2 章
  • 现实世界中的 OCaml,第 2 章
  • 尾递归,音乐剧。在 JavaScript 的背景下解释尾调用优化,配以可爱的 8 位动画和迪士尼歌曲!

练习

大多数练习的解决方案都可以获得。2022 年秋季是这些解决方案的首次公开发布。尽管康奈尔大学的学生已经可以获得这些解决方案几年了,但更广泛的传播将揭示可以进行改进的地方是不可避免的。我们很乐意添加或更正解决方案。请通过 GitHub 进行贡献。

Exercise: values [★]

以下是每个 OCaml 表达式的类型和值是什么?

  • 7 * (1 + 2 + 3)

  • "CS " ^ string_of_int 3110

提示:将每个表达式输入到顶层,它会告诉你答案。注意: ^ 不是指数运算。

Exercise: operators [★★]

查看 OCaml 手册中所有运算符的表格(您需要向下滚动以在该页面上找到它)。

  • 写一个表达式,将 42 乘以 10
  • 编写一个表达式,将 3.14 除以 2.0 。提示:在 OCaml 中,整数和浮点运算符的写法不同
  • 编写一个表达式,计算 4.2 的七次方。注意:OCaml 中没有内置的整数指数运算符(顺便说一句,在 C 语言中也没有),部分原因是大多数 CPU 不提供这种操作。

Exercise: equality [★]

  • 编写一个表达式,使用结构相等性比较 4242
  • 编写一个表达式,使用结构相等性比较 "hi" 和 "hi" 。结果是什么?
  • 编写一个表达式,使用物理相等性比较 "hi" 和 "hi" 。结果是什么?

Exercise: assert [★]

  • assert true;; 输入到 utop 中,看看会发生什么。
  • assert false;; 输入到 utop 中,看看会发生什么。
  • 编写一个表达式,断言 2110 不等于 3110(在结构上)。

Exercise: if [★]

编写一个 if 表达式,如果 2 大于 1 ,则评估为 42 ,否则评估为 7` 。

Exercise: double fun [★]

利用上面的增量函数作为指导,定义一个函数 double ,它将其输入乘以 2。例如, double 7 将是 14 。通过将其应用于一些输入来测试您的函数。将这些测试用例转换为断言。

Exercise: more fun [★★]

  • 定义一个函数,计算浮点数的立方。通过将该函数应用于几个输入来测试它
  • 定义一个函数,计算整数的符号(10-1)。使用嵌套的 if 表达式。通过将其应用于几个输入来测试您的函数。
  • 定义一个函数,根据给定的半径计算圆的面积。使用 assert 测试您的函数

对于后者,请记住浮点运算并不精确。与其断言一个确切的值,你应该断言结果是“足够接近”,例如,误差在 1e-5 之内。如果这对你来说是陌生的,值得阅读关于浮点运算的相关内容

一个接受多个输入的函数可以通过在 let 定义的一部分提供这些输入的附加名称来定义。例如,以下函数计算三个参数的平均值:

let avg3 x y z = (x +. y +. z) /. 3.

Exercise: RMS [★★]

定义一个函数,计算两个数字的均方根,即 \( \sqrt{\frac{x^2 + y^2}{2}} \) 。用 assert 测试您的函数。

Exercise: date fun [★★★]

定义一个函数,该函数接受一个整数 d 和一个字符串 m 作为输入,并在 dm 形成有效日期时返回 true 。在这里,有效日期的月份必须是以下缩写之一:Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sept,Oct,Nov,Dec。日期必须是介于 1 和该月最少天数之间的数字,包括边界值。例如,如果月份是 Jan,则日期介于 131 之间,包括边界值;如果月份是 Feb,则日期介于 128 之间,包括边界值。

你的函数可以有多简洁(即代码行数少且短)?你肯定可以做到少于 12 行。

Exercise: fib [★★]

定义一个递归函数 fib : int -> int ,使得 fib n 是斐波那契数列中的第 n 个数字,该数列为 1, 1, 2, 3, 5, 8, 13, ... 即:

  • fib 1 = 1,
  • fib 2 = 1, and fib 2 = 1
  • fib n = fib (n-1) + fib (n-2) for any n > 2.

在顶层测试您的函数。

Exercise: fib fast [★★★]

您的 fib 实现计算第 50 个斐波那契数的速度有多快?如果计算几乎是瞬间完成的,那么恭喜!但大多数人最初想到的递归解决方案似乎会无限期地挂起。问题在于明显的解决方案会重复计算子问题。例如,计算 fib 5 需要同时计算 fib 3fib 4 ,如果这些分开计算,实际上会重复大量工作(指数级别的工作量)。

创建一个函数 fib_fast ,只需要线性数量的工作。提示:编写一个递归辅助函数 h : int -> int -> int -> int ,其中 h n pp p 定义如下:

  • h 1 pp p = p,
  • h n pp p = h (n-1) p (pp+p) 对于任何 n > 1

h 的概念是假设前两个斐波那契数是 ppp ,然后计算向前 n 个数字。因此, fib n = h n 0 1 对于任何 n > 0

n 的第一个值是多少,使得 fib_fast n 为负,表明整数溢出发生了?

Exercise: poly types [★★★]

以下每个函数的类型是什么?您可以要求顶层检查您的答案。

let f x = if x then x else x
let g x y = if y then x else x
let h x y z = if x then y else z
let i x y z = if x then y else y

Exercise: divide [★★]

编写一个函数 divide : numerator:float -> denominator:float -> float 。应用您的函数。

练习:结合律 [★★]

设我们已经定义了 let add x y = x + y 。以下哪个会产生一个整数,哪个会产生一个函数,哪个会产生一个错误?做出答案,然后在 toplevel 中检查你的答案。

  • add 5 1
  • add 5
  • (add 5) 1
  • add (5 1)

Exercise: average [★★]

定义一个中缀运算符 +/. 来计算两个浮点数的平均值。例如,

  • 1.0 +/. 2.0 = 1.5
  • 0. +/. 0. = 0.

Exercise: hello world [★]

utop 中键入以下内容:

  • print_endline "Hello world!";;
  • print_string "Hello world!";;

注意每个输出之间的差异。

数据和类型

在本章中,我们将研究 OCaml 的一些内置数据类型,包括列表、变体、记录、元组和选项。其中许多可能会让人感觉熟悉,因为它们在其他编程语言中也很常见。特别是,

  • 列表和元组,在 Python 中可能感觉相似;和
  • 记录和变体,可能与 C 或 Java 中的 structenum 类型感觉相似。

由于熟悉,我们称之为这些标准数据类型。我们将学习模式匹配,这是一个不太熟悉的功能。

几乎在我们学习列表之后立即,我们将暂停标准数据类型的学习,转而学习在 OCaml 中使用 OUnit 进行单元测试,OUnit 是一种类似于您可能在其他语言中使用过的单元测试框架。OUnit 依赖于列表,这就是为什么我们现在才开始介绍它。

在本章的后面,我们将学习一些 OCaml 数据类型,这些类型可能不像其他语言那么熟悉。它们包括:

  • Option,这些选项与 Java 中的 null 松散相关;
  • 关联列表是一种基于列表和元组的地图(又称字典)的惊人简单的实现方式;
  • 代数数据类型,可以说是 OCaml 中最重要的类型,实际上也是许多其他内置类型背后的动力;
  • 异常,它们是一种特殊类型的代数数据。

List

OCaml 列表是具有相同类型的值序列。它们以单链表的形式实现。这些列表在语言中拥有一流的地位:有特殊支持可以轻松创建和处理列表。这是 OCaml 与许多其他函数式语言共享的特征。主流的命令式语言,如 Python,现在也具有这样的支持。也许这是因为程序员发现直接与列表作为语言的一流部分一起工作如此愉快,而不必通过库(如 C 和 Java)来实现。

构建列表

句法。有三种句法形式用于构建列表:

[]
e1 :: e2
[e1; e2; ...; en]

空列表写作 [] ,发音为“nil”,这个名称来自于 Lisp。给定一个列表 lst 和元素 elt ,我们可以通过写 elt :: lstelt 添加到 lst 的前面。双冒号运算符发音为“cons”,这个名称来自于 Lisp 中用于在内存中构造对象的运算符。“Cons”也可以用作动词,比如“我将一个元素添加到列表中”。列表的第一个元素通常被称为头部,其余的元素(如果有的话)被称为尾部。

方括号语法方便但不必要。 任何列表 [e1; e2; ...; en] 都可以用更原始的 nilcons 语法来代替: e1 :: e2 :: ... :: en :: [] 。 当可以用更原始的语法在语言中定义出一种愉快的语法时,我们称这种愉快的语法为语法糖:它使语言变得“更甜”。 将甜美的语法转换为更原始的语法称为去糖化。

由于列表的元素可以是任意表达式,因此列表可以嵌套得很深,例如, [[[]]; [[1; 2; 3]]]

动态语义学

  • [] 已经是一个值。
  • 如果 e1 评估为 v1 ,并且如果 e2 评估为 v2 ,那么 e1 :: e2 评估为 v1 :: v2

由于这些规则以及如何对列表的方括号表示进行去糖化,我们得到了以下推导规则:

  • 如果 ei 对于 1..n 中的所有 i 都评估为 vi ,那么 [e1; ...; en] 将评估为 [v1; ...; vn]

开始在所有评估规则中写“评估为”变得乏味了。因此,让我们引入一个更简短的表示法。我们将写 e ==> v 来表示 e 评估为 v 。请注意, ==> 不是 OCaml 语法的一部分。相反,它是我们在语言描述中使用的一种表示法,有点像元变量。利用这种表示法,我们可以重写上述的后两条规则:

  • 如果 e1 ==> v1 ,并且如果 e2 ==> v2 ,那么 e1 :: e2 ==> v1 :: v2
  • 如果对于 1..n 中的所有 i 都有 ei ==> vi ,那么 [e1; ...; en] ==> [v1; ...; vn]

静态语义

列表的所有元素必须具有相同的类型。如果该元素类型是 t ,那么列表的类型是 t list 。您应该从右向左阅读这些类型: t listt 的列表, t list listt 的列表的列表,等等。这里的单词 list 本身不是一种类型:无法构建具有简单类型 list 的 OCaml 值。相反, list 是一种类型构造器:给定一个类型,它会产生一个新类型。例如,给定 int ,它会产生类型 int list 。您可以将类型构造器视为操作类型的函数,而不是操作值的函数。

类型检查规则:

  • [] : 'a list
  • 如果 e1 : te2 : t list ,那么 e1 :: e2 : t list 。如果冒号及其优先级令人困惑,后者意味着 (e1 :: e2) : t list

[] 的规则中,回想一下 'a 是一个类型变量:它代表一个未知类型。因此,空列表是一个元素具有未知类型的列表。如果我们将一个 int 连接到它上面,比如说 2 :: [] ,那么编译器会推断对于这个特定列表, 'a 必须是 int 。但是如果在另一个地方我们将一个 bool 连接到它上面,比如说 true :: [] ,那么编译器会推断对于这个特定列表, 'a 必须是 bool

访问列表

NOTE:

上面链接的视频也使用记录和元组作为示例。这些内容在本书的后面部分进行了讨论。

构建列表的方法实际上只有两种,即使用 nilcons。因此,如果我们想要将列表拆分为其组成部分,我们必须说明当列表为空时该怎么办,以及当列表非空时该怎么办(即将一个元素的 cons 连接到另一个列表上)。我们使用一种称为模式匹配的语言特性来实现这一点。

这里是使用模式匹配来计算列表总和的示例:

let rec sum lst =
  match lst with
  | [] -> 0
  | h :: t -> h + sum t
val sum : int list -> int = <fun>

该函数表示获取输入 lst ,并查看其形状是否与空列表相同。如果是,则返回 0。否则,如果其形状与列表 h :: t 相同,则让 h 成为 lst 的第一个元素,让 t 成为 lst 的其余元素,并返回 h + sum t 。这里选择的变量名意在暗示“头”和“尾”,这是一种常见习语,但如果需要的话我们也可以使用其他名称。另一个常见习语是:

let rec sum xs =
  match xs with
  | [] -> 0
  | x :: xs' -> x + sum xs'
val sum : int list -> int = <fun>

也就是说,输入列表是一个 xs(发音为 EX-uhs)的列表,头元素是 x,尾部是 xs'(发音为 EX-uhs prime)。

从句法上讲,没有必要使用这么多行来定义 sum 。我们可以在一行上完成所有操作:

let rec sum xs = match xs with | [] -> 0 | x :: xs' -> x + sum xs'
val sum : int list -> int = <fun>

或者,注意到在我们使用多少行之后,第 with 个 | 是可选的,我们也可以这样写:

let rec sum xs = match xs with [] -> 0 | x :: xs' -> x + sum xs'
val sum : int list -> int = <fun>

多行格式是我们在本书中通常使用的格式,因为它有助于人眼更好地理解语法。然而,OCaml 代码格式化工具正在朝着单行格式发展,每当代码足够短以适合一行时。

这里是另一个使用模式匹配来计算列表长度的示例:

let rec length lst =
  match lst with
  | [] -> 0
  | h :: t -> 1 + length t
val length : 'a list -> int = <fun>

注意一下,在模式匹配的右侧我们实际上并不需要变量 h 。当我们想要指示模式中某个值的存在,但又不想给它起名字时,可以写成 _ (下划线字符):

let rec length lst =
  match lst with
  | [] -> 0
  | _ :: t -> 1 + length t
val length : 'a list -> int = <fun>

该功能实际上作为 OCaml 标准库 List 模块的一部分内置。它在那里的名称是 List.length 。该“点”表示模块 List 内名为 length 的函数,类似于许多其他语言中使用的点表示法。

这里是第三个示例,将一个列表附加到另一个列表的开头:

let rec append lst1 lst2 =
  match lst1 with
  | [] -> lst2
  | h :: t -> h :: append t lst2
val append : 'a list -> 'a list -> 'a list = <fun>

例如, append [1; 2] [3; 4][1; 2; 3; 4] 。该函数实际上作为内置运算符 @ 可用,因此我们可以改为写成 [1; 2] @ [3; 4]

作为最后一个例子,我们可以编写一个函数来确定一个列表是否为空:

let empty lst =
  match lst with
  | [] -> true
  | h :: t -> false
val empty : 'a list -> bool = <fun>

但是有一种更好的方法可以编写相同的函数,而不需要模式匹配:

let empty lst =
  lst = []
val empty : 'a list -> bool = <fun>

注意以上所有递归函数与对自然数进行归纳证明的相似之处:每个自然数要么是 0,要么比另一个自然数n1,因此归纳证明有一个基本情况为 0 和一个归纳情况为 n + 1 。同样,我们所有的函数都有一个空列表的基本情况和一个比另一个列表多一个元素的递归情况。这种相似性并非偶然。归纳和递归之间存在着深刻的关系;我们将在本书后面更详细地探讨这种关系。

顺便提一下,有两个库函数 List.hdList.tl ,它们返回列表的头部和尾部。在 OCaml 中,直接将它们应用于列表是不好的习惯。问题在于,当应用于空列表时,它们会引发异常,而你必须记得处理该异常。相反,你应该使用模式匹配:这样你将被迫匹配空列表和非空列表(至少),这将防止异常被引发,从而使你的程序更加健壮。

不变异列表

列表是不可变的。无法将列表中的一个元素从一个值更改为另一个值。相反,OCaml 程序员会从旧列表中创建新列表。例如,假设我们想要编写一个函数,该函数返回与其输入列表相同的列表,但第一个元素(如果有的话)增加一。我们可以这样做:

let inc_first lst =
  match lst with
  | [] -> []
  | h :: t -> h + 1 :: t

现在您可能会担心我们是否在浪费空间。毕竟,编译器至少有两种方式可以实现上述代码:

  • 在与 cons 模式匹配时,当创建新列表时,复制整个尾列表 t ,使得内存使用量增加的数量与 t 的长度成比例增加。
  • 在旧列表和新列表之间共享尾部列表 t ,以确保内存使用量不会增加——除了存储 h + 1 所需的额外内存之外。

事实上,编译器执行后者。所以不需要担心。编译器实现共享是相当安全的原因在于列表元素是不可变的。如果它们是可变的,那么我们将开始担心我拥有的列表是否与你拥有的列表共享,以及我所做的更改是否会在你的列表中可见。因此,不可变性使得对代码进行推理更容易,并使编译器执行优化变得更安全。

使用列表进行模式匹配

我们已经看到如何使用模式匹配来访问列表。让我们更仔细地看看这个特性。

句法

match e with
| p1 -> e1
| p2 -> e2
| ...
| pn -> en

每个子句 pi -> ei 被称为模式匹配的一个分支或一个情况。整个模式匹配中的第一个竖线是可选的。

这里的 p 是一种称为模式的新句法形式。目前,模式可能是:

  • 变量名称,例如, x
  • 下划线字符 _ ,也被称为通配符
  • 空列表 []
  • p1 :: p2
  • [p1; ...; pn]

在模式中,变量名不得出现超过一次。例如,模式 x :: x 是非法的。通配符可以出现任意次数。

随着我们对 OCaml 中可用的数据结构了解更多,我们将扩展模式可能性。

动态语义学

模式匹配涉及两个相互关联的任务:确定模式是否与值匹配,以及确定值的哪些部分应与模式中的哪些变量名相关联。前一个任务直观地是关于确定模式和值是否具有相同的形状。后一个任务是关于确定模式引入的变量绑定。例如,考虑以下代码:

match 1 :: [] with
| [] -> false
| h :: t -> h >= 1 && List.length t = 0
- : bool = true

在评估第二分支的右侧时, h 绑定到 1t 绑定到 [] 。让我们写 h->1 表示变量绑定,即 h 的值为 1 ;这不是 OCaml 语法的一部分,而是我们用来推理语言的符号。因此,第二分支产生的变量绑定将是 h->1, t->[]

利用该符号表示法,以下是模式何时与值匹配以及匹配产生的绑定的定义:

  • 模式 x 匹配任何值 v 并生成变量绑定 x->v
  • 模式 _ 匹配任何值并且不生成任何绑定。
  • 模式 [] 匹配值 [] 并且不生成任何绑定。
  • 如果 p1 匹配 v1 并产生一组绑定b1,并且如果 p2 匹配 v2 并产生一组绑定 b1 U b2 ,那么 p1 :: p2 匹配 v1 :: v2 并产生一组绑定。请注意 v2 必须是一个列表(因为它在 :: 的右侧),并且可以具有任意长度:0 个元素,1 个元素或多个元素。请注意绑定的并集 b1 U b2 永远不会出现一个问题,即同一个变量在 b1b2 中分别绑定,因为语法限制要求模式中的变量名不得多次出现。
  • 如果对于 1..n 中的所有 i ,它成立 pi 匹配 vi 并产生绑定集 bi ,那么 [p1; ...; pn] 匹配 [v1; ...; vn] 并产生绑定集 Uibi。请注意,此模式指定列表必须具有的确切长度。

现在我们可以说如何评估 match e with p1 -> e1 | ... | pn -> en

  • 评估 e 的值为 v
  • 尝试将 vp1 匹配,然后与 p2 匹配,依此类推,按照它们在匹配表达式中出现的顺序进行匹配。
  • 如果 v 与任何模式都不匹配,则匹配表达式的评估会引发 Match_failure 异常。我们还没有讨论 OCaml 中的异常,但您肯定从其他语言中熟悉它们。在本章末尾,我们将回到异常讨论,之前我们会介绍 OCaml 中的一些其他内置数据结构。
  • 否则,让 pi 成为第一个匹配的模式,并让 b 成为通过将 vpi 匹配产生的变量绑定。
  • 替换那些在 ei 内部的绑定 b ,生成一个新表达式 e'
  • 评估 e' 的值为 v'
  • 整个匹配表达式的结果是 v'

例如,这是如何评估这个匹配表达式的方式:

match 1 :: [] with
| [] -> false
| h :: t -> h = 1 && t = []
- : bool = true
  • 1 :: [] 已经是一个值。
  • []1 :: [] 不匹配。
  • h :: t 匹配 1 :: [] 并生成变量绑定 { h->1 , t->[] },因为:
    • h 匹配 1 并生成变量绑定 h->1
    • t 匹配 [] 并生成变量绑定 t->[]
  • { h->1 , t->[] } 替换到 h = 1 && t = [] 中会产生一个新表达式 1 = 1 && [] = []
  • 评估 1 = 1 && [] = [] 的结果为 true 。我们在这里省略了这一事实的理由,但它是根据其他内置运算符和函数应用的评估规则得出的。
  • 因此,整个匹配表达式的结果是 true

静态语义

除了这种类型检查规则外,编译器还会对每个匹配表达式进行另外两项检查。

首先,穷尽性:编译器会检查以确保有足够的模式来保证至少有一个模式与表达式 e 匹配,无论该表达式在运行时的值是什么。这确保了程序员没有遗漏任何分支。例如,下面的函数将导致编译器发出警告:

let head lst = match lst with h :: _ -> h
File "[12]", line 1, characters 15-41:
1 | let head lst = match lst with h :: _ -> h
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^
Warning 8 [partial-match]: this pattern-matching is not exhaustive.
Here is an example of a case that is not matched:
[]
val head : 'a list -> 'a = <fun>

通过向程序员发出警告,编译器帮助程序员防范在运行时可能发生的 Match_failure 异常

NOTE:

抱歉,上面单元格的输出在 HTML 中被分成多行。这是目前使用来构建本书的 JupyterBook 框架中的一个未解决问题。

第二,未使用的分支:编译器会检查是否有任何分支永远不会匹配,因为之前的某个分支已经被保证会成功。例如,下面的函数将导致编译器发出警告:

let rec sum lst =
  match lst with
  | h :: t -> h + sum t
  | [ h ] -> h
  | [] -> 0
File "[13]", line 4, characters 4-9:
4 |   | [ h ] -> h
        ^^^^^
Warning 11 [redundant-case]: this match case is unused.
val sum : int list -> int = <fun>

第二分支未被使用,因为第一分支将匹配第二分支匹配的任何内容。

未使用的匹配情况通常表明程序员编写的内容与其本意不符。因此,通过提供该警告,编译器帮助程序员检测其代码中潜在的错误。

这里是一个导致未使用匹配情况警告的最常见错误的示例。理解这个错误也是检查您对匹配表达式的动态语义理解的好方法:

let length_is lst n =
  match List.length lst with
  | n -> true
  | _ -> false
File "[14]", line 4, characters 4-5:
4 |   | _ -> false
        ^
Warning 11 [redundant-case]: this match case is unused.
val length_is : 'a list -> 'b -> bool = <fun>

程序员认为如果 lst 的长度等于 n ,那么这个函数将返回 true ,否则将返回 false 。但实际上这个函数总是返回 true 。为什么?因为模式变量 n 与函数参数 n 是不同的。假设 lst 的长度为 5。那么模式匹配变成了: match 5 with n -> true | _ -> falsen 匹配 5 吗?是的,根据上述规则:变量模式匹配任何值,并在这里生成绑定 n->5 。然后评估将该绑定应用于 true ,用 5 替换 true 中的所有 n 。嗯,这里没有这样的出现。所以我们完成了,评估的结果就是 true

程序员真正想要写的是:

let length_is lst n =
  match List.length lst with
  | m -> m = n
val length_is : 'a list -> int -> bool = <fun>

或者更好的是:

let length_is lst n =
  List.length lst = n
val length_is : 'a list -> int -> bool = <fun>

深度模式匹配

模式可以嵌套。这样做可以让您的代码深入查看列表的结构。例如:

  • _ :: [] 匹配所有只有一个元素的列表
  • _ :: _ 匹配至少包含一个元素的所有列表
  • _ :: _ :: [] 匹配所有恰好有两个元素的列表
  • _ :: _ :: _ :: _ 匹配至少包含三个元素的所有列表

立即匹配

当您有一个立即对其最终参数进行模式匹配的函数时,有一个很好的语法糖可以帮助您避免编写额外的代码。这里有一个例子:而不是

let rec sum lst =
  match lst with
  | [] -> 0
  | h :: t -> h + sum t
val sum : int list -> int = <fun>

你可以写

let rec sum = function
  | [] -> 0
  | h :: t -> h + sum t
val sum : int list -> int = <fun>

function 是一个关键字。请注意,我们可以省略包含 match 的那一行,以及参数的名称,该名称在其他任何地方都没有被使用过。在这种情况下,尤其重要的是在函数的规范注释中记录该参数应该是什么,因为代码不再给出它的描述性名称。

OCamldoc 和列表语法

OCamldoc 是类似于 Javadoc 的文档生成器。它从源代码中提取注释并生成 HTML(以及其他输出格式)。例如,List 模块的标准库 Web 文档是由 OCamldoc 从该模块的标准库源代码生成的。

WARNING:

在 OCamldoc 中,有一个与列表有关的方括号的句法约定,可能会让人感到困惑。

在 OCamldoc 注释中,源代码被方括号括起来。该代码将以打字机字体呈现,并在输出的 HTML 中进行语法高亮显示。在这种情况下,方括号并不表示一个列表。

例如,在标准库源代码中,这是对 List.hd 的注释:

(** Return the first element of the given list. Raise
   [Failure "hd"] if the list is empty. *)

[Failure "hd"] 并不意味着包含异常 Failure "hd" 的列表。相反,它意味着将表达式 Failure "hd" 排版为源代码,正如您在这里所看到的

这在你想要讨论列表作为文档的一部分时可能会变得特别令人困惑。例如,这是我们可以重写该评论的一种方式:

(** [hd lst] returns the first element of [lst].
    Raises [Failure "hd"] if [lst = []]. *)

[lst = []] 中,外部方括号表示源代码作为注释的一部分,而内部方括号表示空列表。

列表推导

一些语言,包括 Python 和 Haskell,具有一种称为推导的语法,允许列表的编写方式有点类似数学中的集合推导。推导的最早例子似乎是函数式语言 NPL,它设计于 1977 年。

OCaml 没有内置的语法支持推导式。虽然一些扩展被开发出来,但似乎已经不再受支持。推导式所完成的主要任务(过滤掉一些元素,转换其他元素)实际上已经被高阶编程很好地支持,我们将在后面的章节中学习到,以及我们已经学习过的管道操作符。因此,推导式的额外语法实际上从未真正需要。

尾递归

请回忆一下,如果一个函数在递归调用自身后不执行任何计算,而是在递归调用返回后立即将递归调用的值返回给调用者,则该函数是尾递归的。考虑这两种列表求和的实现 sumsum_tr

let rec sum (l : int list) : int =
  match l with
  | [] -> 0
  | x :: xs -> x + (sum xs)

let rec sum_plus_acc (acc : int) (l : int list) : int =
  match l with
  | [] -> acc
  | x :: xs -> sum_plus_acc (acc + x) xs

let sum_tr : int list -> int =
  sum_plus_acc 0
val sum : int list -> int = <fun>
val sum_plus_acc : int -> int list -> int = <fun>
val sum_tr : int list -> int = <fun>

观察上述 sumsum_tr 函数之间的区别:在 sum 函数中,它不是尾递归的,在递归调用返回其值后,我们将 x 添加到其中。在尾递归 sum_tr 中,或者更确切地说,在 sum_plus_acc 中,在递归调用返回后,我们立即返回该值,而无需进一步计算。

如果你要在非常长的列表上编写函数,尾递归对性能非常重要。因此,当你在使用尾递归和非尾递归函数之间做选择时,最好在非常长的列表上使用尾递归函数以实现空间效率。出于这个原因,List 模块记录了哪些函数是尾递归的,哪些不是。

但这并不意味着尾递归实现就一定更好。例如,尾递归函数可能更难阅读。(考虑 sum_plus_acc 。)此外,有些情况下,实现尾递归函数需要进行预处理或后处理以反转列表。在小到中等大小的列表上,反转列表的开销(无论是时间还是为反转列表分配内存)可能会使尾递归版本的时间效率较低。这里的“小”和“大”是什么意思?很难说,但根据 List 模块的标准库文档,也许 10,000 是一个很好的估计。

(** [from i j l] is the list containing the integers from [i] to [j],
    inclusive, followed by the list [l].
    Example:  [from 1 3 [0] = [1; 2; 3; 0]] *)
let rec from i j l = if i > j then l else from i (j - 1) (j :: l)

(** [i -- j] is the list containing the integers from [i] to [j], inclusive. *)
let ( -- ) i j = from i j []

let long_list = 0 -- 1_000_000
val from : int -> int -> int list -> int list = <fun>
val ( -- ) : int -> int -> int list = <fun>
val long_list : int list =
  [0; 1; 2; 3; 4; 5; 6; 7; 8; 9; 10; 11; 12; 13; 14; 15; 16; 17; 18; 19; 20;
   21; 22; 23; 24; 25; 26; 27; 28; 29; 30; 31; 32; 33; 34; 35; 36; 37; 38;
   39; 40; 41; 42; 43; 44; 45; 46; 47; 48; 49; 50; 51; 52; 53; 54; 55; 56;
   57; 58; 59; 60; 61; 62; 63; 64; 65; 66; 67; 68; 69; 70; 71; 72; 73; 74;
   75; 76; 77; 78; 79; 80; 81; 82; 83; 84; 85; 86; 87; 88; 89; 90; 91; 92;
   93; 94; 95; 96; 97; 98; 99; 100; 101; 102; 103; 104; 105; 106; 107; 108;
   109; 110; 111; 112; 113; 114; 115; 116; 117; 118; 119; 120; 121; 122; 123;
   124; 125; 126; 127; 128; 129; 130; 131; 132; 133; 134; 135; 136; 137; 138;
   139; 140; 141; 142; 143; 144; 145; 146; 147; 148; 149; 150; 151; 152; 153;
   154; 155; 156; 157; 158; 159; 160; 161; 162; 163; 164; 165; 166; 167; 168;
   169; 170; 171; 172; 173; 174; 175; 176; 177; 178; 179; 180; 181; 182; 183;
   184; 185; 186; 187; 188; 189; 190; 191; 192; 193; 194; 195; 196; 197; 198;
   199; 200; 201; 202; 203; 204; 205; 206; 207; 208; 209; 210; 211; 212; 213;
   214; 215; 216; 217; 218; 219; 220; 221; 222; 223; 224; 225; 226; 227; 228;
   229; 230; 231; 232; 233; 234; 235; 236; 237; 238; 239; 240; 241; 242; 243;
   244; 245; 246; 247; 248; 249; 250; 251; 252; 253; 254; 255; 256; 257; 258;
   259; 260; 261; 262; 263; 264; 265; 266; 267; 268; 269; 270; 271; 272; 273;
   274; 275; 276; 277; 278; 279; 280; 281; 282; 283; 284; 285; 286; 287; 288;
   289; 290; 291; 292; 293; 294; 295; 296; 297; 298; ...]

值得研究 -- 的定义,以确保您理解

  • (i)它是如何工作的,以及
  • (ii)为什么它是尾递归的。

将来您可能会决定再次创建这样的列表。与其记住这个定义的位置,然后将其复制到您的代码中,不如使用内置库函数轻松创建相同的列表的方法:

List.init 1_000_000 Fun.id
- : int list =
[0; 1; 2; 3; 4; 5; 6; 7; 8; 9; 10; 11; 12; 13; 14; 15; 16; 17; 18; 19; 20;
 21; 22; 23; 24; 25; 26; 27; 28; 29; 30; 31; 32; 33; 34; 35; 36; 37; 38; 39;
 40; 41; 42; 43; 44; 45; 46; 47; 48; 49; 50; 51; 52; 53; 54; 55; 56; 57; 58;
 59; 60; 61; 62; 63; 64; 65; 66; 67; 68; 69; 70; 71; 72; 73; 74; 75; 76; 77;
 78; 79; 80; 81; 82; 83; 84; 85; 86; 87; 88; 89; 90; 91; 92; 93; 94; 95; 96;
 97; 98; 99; 100; 101; 102; 103; 104; 105; 106; 107; 108; 109; 110; 111; 112;
 113; 114; 115; 116; 117; 118; 119; 120; 121; 122; 123; 124; 125; 126; 127;
 128; 129; 130; 131; 132; 133; 134; 135; 136; 137; 138; 139; 140; 141; 142;
 143; 144; 145; 146; 147; 148; 149; 150; 151; 152; 153; 154; 155; 156; 157;
 158; 159; 160; 161; 162; 163; 164; 165; 166; 167; 168; 169; 170; 171; 172;
 173; 174; 175; 176; 177; 178; 179; 180; 181; 182; 183; 184; 185; 186; 187;
 188; 189; 190; 191; 192; 193; 194; 195; 196; 197; 198; 199; 200; 201; 202;
 203; 204; 205; 206; 207; 208; 209; 210; 211; 212; 213; 214; 215; 216; 217;
 218; 219; 220; 221; 222; 223; 224; 225; 226; 227; 228; 229; 230; 231; 232;
 233; 234; 235; 236; 237; 238; 239; 240; 241; 242; 243; 244; 245; 246; 247;
 248; 249; 250; 251; 252; 253; 254; 255; 256; 257; 258; 259; 260; 261; 262;
 263; 264; 265; 266; 267; 268; 269; 270; 271; 272; 273; 274; 275; 276; 277;
 278; 279; 280; 281; 282; 283; 284; 285; 286; 287; 288; 289; 290; 291; 292;
 293; 294; 295; 296; 297; 298; ...]

表达式 List.init len f 创建列表 [f 0; f 1; ...; f (len - 1)] ,如果 len 大于 10,000,则以尾递归方式执行。函数 Fun.id 简单地是标识函数 fun x -> x

Variants

Variants是表示值为多种可能性之一的数据类型。在最简单的情况下,变体类似于 C 或 Java 中的枚举:

type day = Sun | Mon | Tue | Wed | Thu | Fri | Sat
let d = Tue
type day = Sun | Mon | Tue | Wed | Thu | Fri | Sat

OCaml 中变体值的个体名称被称为构造函数。在上面的示例中,构造函数是 SunMon 等。这与 C++或 Java 中构造函数一词的用法略有不同。

对于 OCaml 中的每种数据类型,我们已经讨论了如何构建和访问它。对于变体,构建很容易:只需编写构造函数的名称。对于访问,我们使用模式匹配。例如:

let int_of_day d =
  match d with
  | Sun -> 1
  | Mon -> 2
  | Tue -> 3
  | Wed -> 4
  | Thu -> 5
  | Fri -> 6
  | Sat -> 7
val int_of_day : day -> int = <fun>

目前还没有一种自动将构造函数名称映射到 int 的方法,就像你可能期望从具有枚举的语言中看到的那样。

句法

定义变体类型:

type t = C1 | ... | Cn

构造函数的名称必须以大写字母开头。OCaml 使用这一点来区分构造函数和变量标识符。

编写构造函数值的语法只是它的名称,例如, C

动态语义学。

  • 构造函数已经是一个值。没有需要执行的计算。

静态语义。

  • 如果 t 被定义为 type t = ... | C | ... 类型,则 C : t

范围

假设有两种类型被定义为具有重叠的构造函数名称,例如,

type t1 = C | D
type t2 = D | E
let x = D
type t1 = C | D
type t2 = D | E
val x : t2 = D

当这些定义之后出现 D 时,它指的是哪种类型?也就是说,上面的 x 是什么类型?答案是后面定义的类型胜出。所以 x : t2 。这对程序员来说可能会有些意外,因此在任何给定的范围内(例如,一个文件或一个模块,尽管我们还没有涉及模块),每当可能出现重叠的构造函数名称时,习惯上会在它们前面加上一些区分字符。例如,假设我们正在定义用于表示宝可梦的类型:

type ptype =
  TNormal | TFire | TWater

type peff =
  ENormal | ENotVery | ESuper
type ptype = TNormal | TFire | TWater
type peff = ENormal | ENotVery | ESuper

由于“Normal”自然会成为宝可梦的类型和宝可梦攻击的有效性的构造函数名称,我们在每个构造函数名称前面添加一个额外的字符,以指示它是类型还是有效性。

模式匹配

每次我们引入一种新的数据类型,我们需要引入与之相关的新模式。对于变体来说,这很容易。我们将以下新模式形式添加到合法模式列表中:

  • 构造函数名称 C

我们将模式与值匹配并生成绑定的定义扩展如下:

  • 模式 C 匹配值 C 并且不生成任何绑定。

NOTE:

变体比我们在这里看到的要强大得多。我们很快会再次回到它们。

使用 OUnit 进行单元测试

NOTE:

这一部分有点偏离我们对数据类型的研究,但这是一个很好的地方来进行这个偏离:我们现在已经知道足够多,以理解在 OCaml 中如何进行单元测试,没有任何理由再等待更长时间来了解它。

使用 toplevel 来测试函数只适用于非常小的程序。更大的程序需要包含许多单元测试并且可以在每次更新代码库时重新运行的测试套件。单元测试是对程序中一个小功能的测试,比如一个单独的函数。

我们现在已经学习了足够多的 OCaml 特性,以便了解如何使用一个名为 OUnit 的库进行单元测试。它是类似于 Java 中的 JUnit、Haskell 中的 HUnit 等的单元测试框架。使用 OUnit 的基本工作流程如下:

  • 在文件 f.ml 中编写一个函数。该文件中可能还有许多其他函数。
  • 在单独的文件 test.ml 中为该函数编写单元测试。实际上,确切的名称并不是必要的。
  • 构建并运行 test 以执行单元测试。

OUnit 文档可在 GitHub 上找到。

OUnit 的示例

以下示例向您展示了如何创建一个 OUnit 测试套件。示例中有一些东西起初可能看起来很神秘;它们将在下一节中讨论。

创建一个新目录。在该目录中,创建一个名为 sum.ml 的文件,并将以下代码放入其中

let rec sum = function
  | [] -> 0
  | x :: xs -> x + sum xs

现在创建一个名为 test.ml 的第二个文件,并将以下代码放入其中:

open OUnit2
open Sum

let tests = "test suite for sum" >::: [
  "empty" >:: (fun _ -> assert_equal 0 (sum []));
  "singleton" >:: (fun _ -> assert_equal 1 (sum [1]));
  "two_elements" >:: (fun _ -> assert_equal 3 (sum [1; 2]));
]

let _ = run_test_tt_main tests

根据您的编辑器及其配置,您可能会看到关于 OUnit2Sum 的一些“未绑定模块”错误。不用担心;代码实际上是正确的。我们只需要设置好 dune 并告诉它链接 OUnit。创建一个 dune 文件并将以下内容放入其中:

(executable
 (name test)
 (libraries ounit2))

创建一个 dune-project 文件,像往常一样:

(lang dune 3.4)

现在构建测试套件:

$ dune build test.exe

返回编辑器,做任何会导致其重新访问 test.ml 的操作。您可以关闭并重新打开窗口,或者在文件中进行微小更改(例如,添加然后删除一个空格)。现在错误应该全部消失。

最后,您可以运行测试套件:

$ dune exec ./test.exe

您将会收到类似以下内容的回复:

...
Ran: 3 tests in: 0.12 seconds.
OK

现在假设我们通过将其中的代码更改为以下内容来修改 sum.ml 以引入一个错误:

let rec sum = function
  | [] -> 1 (* bug *)
  | x :: xs -> x + sum xs

如果重建并重新执行测试套件,现在所有测试用例都会失败。输出会告诉我们失败用例的名称。以下是输出的开头,我们已经用 ... 替换了一些依赖于您本地计算机的字符串:

FFF
==============================================================================
Error: test suite for sum:2:two_elements.

File ".../_build/oUnit-test suite for sum-...#01.log", line 9, characters 1-1:
Error: test suite for sum:2:two_elements (in the log).

Raised at OUnitAssert.assert_failure in file "src/lib/ounit2/advanced/oUnitAssert.ml", line 45, characters 2-27
Called from OUnitRunner.run_one_test.(fun) in file "src/lib/ounit2/advanced/oUnitRunner.ml", line 83, characters 13-26

not equal
------------------------------------------------------------------------------

输出的第一行

FFF

告诉我们,OUnit 运行了三个测试用例,全部三个都失败了。

下一个有趣的一行

Error: test suite for sum:2:two_elements.

告诉我们,在名为 test suite for sum 的测试套件中,索引为 2 的测试用例名为 two_elements 失败了。该测试用例的其余输出并不特别有趣;现在让我们暂时忽略它。

OUnit 示例的解释

让我们仔细研究一下我们在上一节中所做的事情。在测试文件中, open OUnit2OUnit2 中的许多定义引入范围,这是 OUnit 框架的第二版。而 open Sum 则将 sum.ml 中的定义引入范围。我们将在后面的章节中更多地了解作用域和 open 关键字。

然后我们创建了一个测试用例列表:

[
  "empty"  >:: (fun _ -> assert_equal 0 (sum []));
  "one"    >:: (fun _ -> assert_equal 1 (sum [1]));
  "onetwo" >:: (fun _ -> assert_equal 3 (sum [1; 2]));
]

每一行代码都是一个独立的测试用例。一个测试用例有一个字符串作为描述性名称,以及一个作为测试用例运行的函数。在名称和函数之间,我们写入 >:: ,这是由 OUnit 框架定义的自定义运算符。让我们看一下上面的第一个函数:

fun _ -> assert_equal 0 (sum [])

每个测试用例函数接收一个参数,OUnit 将其称为测试上下文。在这里(以及我们编写的许多测试用例中),我们实际上不需要担心上下文,因此我们使用下划线表示函数忽略其输入。然后函数调用 assert_equal ,这是由 OUnit 提供的函数,用于检查其两个参数是否相等。如果相等,则测试用例成功。如果不相等,则测试用例失败。

然后我们创建了一个测试套件:

let tests = "test suite for sum" >::: [
  "empty" >:: (fun _ -> assert_equal 0 (sum []));
  "singleton" >:: (fun _ -> assert_equal 1 (sum [1]));
  "two_elements" >:: (fun _ -> assert_equal 3 (sum [1; 2]));
]

::: 运算符是另一个自定义的 OUnit 运算符。它位于测试套件的名称和该套件中测试用例列表之间。

最后,我们运行测试套件:

let _ = run_test_tt_main tests

函数 run_test_tt_mainOUnit 提供。它运行一个测试套件,并将测试用例的通过与失败的结果打印到标准输出。这里使用 let _ = 表示我们不关心函数返回什么值;它只是被丢弃。

改进 OUnit 输出

在我们的示例中,使用有错误的 sum 实现,我们得到了以下输出:

==============================================================================
Error: test suite for sum:2:two_elements.
...
not equal
------------------------------------------------------------------------------

OUnit 输出中的 not equal 表示 assert_equal 发现在该测试用例中传递给它的两个值不相等。这并不是很具体:我们想知道它们为什么不相等。特别是,我们想知道 sum 为该测试用例产生的实际输出是什么。为了找出,我们需要向 assert_equal 传递一个额外的参数。该参数的标签是 printer ,应该是一个能够将输出转换为字符串的函数。在这种情况下,输出是整数,因此 Stdlib 模块中的 string_of_int 就足够了。我们修改测试套件如下:

let tests = "test suite for sum" >::: [
  "empty" >:: (fun _ -> assert_equal 0 (sum []) ~printer:string_of_int);
  "singleton" >:: (fun _ -> assert_equal 1 (sum [1]) ~printer:string_of_int);
  "two_elements" >:: (fun _ -> assert_equal 3 (sum [1; 2]) ~printer:string_of_int);
]

现在我们获得了更多信息性的输出:

==============================================================================
Error: test suite for sum:2:two_elements.
...
expected: 3 but got: 4
------------------------------------------------------------------------------

该输出意味着名为 two_elements 的测试断言 34 的相等性。预期输出为 3 ,因为那是 assert_equal 的第一个输入,并且该函数的规范规定在 assert_equal x y 中,您(作为测试人员)期望获得的输出应该是 x ,而被测试函数实际产生的输出应该是 y

请注意我们的测试套件积累了大量冗余代码。特别是,我们不得不在几行中添加 printer 参数。让我们通过提取一个构建测试用例的函数来改进该代码:

let make_sum_test name expected_output input =
  name >:: (fun _ -> assert_equal expected_output (sum input) ~printer:string_of_int)

let tests = "test suite for sum" >::: [
  make_sum_test "empty" 0 [];
  make_sum_test "singleton" 1 [1];
  make_sum_test "two_elements" 3 [1; 2];
]

对于比整数更复杂的输出类型,您最终需要编写自己的函数传递给 printer 。这类似于在 Java 中编写 toString() 方法:对于您自己发明的复杂类型,语言不知道如何将它们呈现为字符串。您必须提供执行此操作的代码。

异常测试

我们还需要学习一点 OCaml 知识,然后我们就可以看到如何测试异常情况了。如果你想现在知道,可以提前查看关于异常的部分。

测试驱动开发

测试不一定要严格在编写代码之后进行。在测试驱动开发(TDD)中,测试是第一位的!它强调代码的逐步开发:总是有东西可以被测试。测试不是在实现之后发生的事情;相反,连续测试被用来尽早捕捉错误。因此,在编写代码时立即开发单元测试非常重要。自动化测试套件至关重要,以便连续测试基本上不需要任何努力。

这里是 TDD 的一个示例。我们故意选择一个非常简单的函数来实现,以便过程清晰。假设我们正在处理一种表示天数的数据类型:

type day = Sunday | Monday | Tuesday | Wednesday | Thursday | Friday | Saturday

我们想要编写一个函数 next_weekday : day -> day ,它返回给定日期后的下一个工作日。我们首先编写这个函数的最基本、有缺陷的版本:

let next_weekday d = failwith "Unimplemented"

NOTE:

内置函数 failwith 会引发一个异常,同时传递给函数的错误消息。

然后我们编写我们能想象到的最简单的单元测试。例如,我们知道周一之后的下一个工作日是周二。因此,我们添加一个测试

let tests = "test suite for next_weekday" >::: [
  "tue_after_mon"  >:: (fun _ -> assert_equal Tuesday (next_weekday Monday));
]

然后我们运行 OUnit 测试套件。正如预期的那样,它失败了。这是好事!现在我们有一个具体的目标,那就是让该单元测试通过。我们修改 next_weekday 以实现这一目标:

let next_weekday d =
  match d with
  | Monday -> Tuesday
  | _ -> failwith "Unimplemented"

我们编译并运行测试;测试通过。是时候添加更多的测试了。剩下的最简单可能性是涉及工作日而不是周末的测试。所以让我们为工作日添加测试。

let tests = "test suite for next_weekday" >::: [
  "tue_after_mon"  >:: (fun _ -> assert_equal Tuesday (next_weekday Monday));
  "wed_after_tue"  >:: (fun _ -> assert_equal Wednesday (next_weekday Tuesday));
  "thu_after_wed"  >:: (fun _ -> assert_equal Thursday(next_weekday Wednesday));
  "fri_after_thu"  >:: (fun _ -> assert_equal Friday (next_weekday Thursday));
]

我们编译并运行测试;许多测试失败。这是好事!我们添加新功能:

let next_weekday d =
  match d with
  | Monday -> Tuesday
  | Tuesday -> Wednesday
  | Wednesday -> Thursday
  | Thursday -> Friday
  | _ -> failwith "Unimplemented"

我们编译并运行测试;测试通过。此时我们可以继续处理周末,但我们应该首先注意到我们编写的测试中有一些重复的代码。事实上,我们可能是通过复制粘贴第一个测试来编写它们的,然后为接下来的三个进行修改。这表明我们应该重构代码。(就像我们之前测试的 sum 函数一样。)

让我们抽象出一个函数,用于创建 next_weekday 的测试用例:

let make_next_weekday_test name expected_output input =
  name >:: (fun _ -> assert_equal expected_output (next_weekday input))

let tests = "test suite for next_weekday" >::: [
  make_next_weekday_test "tue_after_mon" Tuesday Monday;
  make_next_weekday_test "wed_after_tue" Wednesday Tuesday;
  make_next_weekday_test "thu_after_wed" Thursday Wednesday;
  make_next_weekday_test "fri_after_thu" Friday Thursday;
]

现在我们通过处理周末来完成测试和实施。首先,我们添加一些测试用例:

...
make_next_weekday_test "mon_after_fri" Monday Friday;
make_next_weekday_test "mon_after_sat" Monday Saturday;
make_next_weekday_test "mon_after_sun" Monday Sunday;
...

然后我们完成函数:

let next_weekday d =
  match d with
  | Monday -> Tuesday
  | Tuesday -> Wednesday
  | Wednesday -> Thursday
  | Thursday -> Friday
  | Friday -> Monday
  | Saturday -> Monday
  | Sunday -> Monday

当然,大多数人即使不使用 TDD 也能编写该函数而不出错。但我们很少实现如此简单的函数。

过程。让我们回顾一下 TDD 的过程:

  • 编写一个失败的单元测试用例。运行测试套件以证明测试用例失败。
  • 实现足够的功能使测试用例通过。运行测试套件以证明测试用例通过。
  • 根据需要改进代码。在上面的示例中,我们重构了测试套件,但通常我们需要重构正在实现的功能。
  • 重复直到您满意为止,测试套件提供了证据表明您的实现是正确的。

记录和元组

单链表是一种很好的数据结构,但如果您想要固定数量的元素,而不是无限数量呢?或者如果您希望元素具有不同的类型呢?或者如果您希望通过名称而不是数字访问元素呢?列表不会使这些可能性变得容易。相反,OCaml 程序员使用记录和元组。

记录

记录是其他类型数据的组合,每个数据都有名称。OCaml 记录类似于 C 语言中的结构体。这里是一个关于宝可梦的记录类型定义的示例,重用了变体部分的定义。

type ptype = TNormal | TFire | TWater
type mon = {name : string; hp : int; ptype : ptype}
type ptype = TNormal | TFire | TWater
type mon = { name : string; hp : int; ptype : ptype; }

这种类型定义了一个记录,其中包含三个字段,分别命名为 namehp (生命值)和 ptype 。每个字段的类型也已给出。请注意, ptype 可以同时用作类型名称和字段名称;在 OCaml 中,它们的命名空间是不同的。

要构建记录类型的值,我们编写一个记录表达式,看起来像这样:

{name = "Charmander"; hp = 39; ptype = TFire}
- : mon = {name = "Charmander"; hp = 39; ptype = TFire}

因此,在类型定义中,我们在字段的名称和类型之间使用冒号,但在表达式中我们使用等号。

要访问记录并从中获取字段,我们使用点符号表示法,这是您在许多其他语言中所期望的。例如:

let c = {name = "Charmander"; hp = 39; ptype = TFire};;
c.hp
val c : mon = {name = "Charmander"; hp = 39; ptype = TFire}
- : int = 39

还可以使用模式匹配来访问记录字段:

match c with {name = n; hp = h; ptype = t} -> h
- : int = 39

这里的 nht 是模式变量。如果您想要为字段和模式变量使用相同的名称,可以提供一种语法糖:

match c with {name; hp; ptype} -> hp
- : int = 39

这里,模式 {name; hp; ptype}{name = name; hp = hp; ptype = ptype} 的糖。在这些子表达式中,出现在等号左侧的标识符是字段名,出现在右侧的标识符是模式变量。

句法

记录表达式被写为:

{f1 = e1; ...; fn = en}

记录表达式中 fi=ei 的顺序是无关紧要的。例如, {f = e1; g = e2} 完全等同于 {g = e2; f = e1}

一个字段访问被写为:

e.f

其中 f 必须是字段名称的标识符,而不是表达式。这个限制与其他具有类似特性的任何语言相同——例如,Java 字段名称。如果您真的想计算要访问的标识符,那么实际上您需要一个不同的数据结构:一个映射(也被许多其他名称称为:字典或关联列表或哈希表等,尽管每个术语都隐含着微妙的差异。)

动态语义学

  • 如果对于 1..n 中的所有 i ,都成立 ei ==> vi ,那么 {f1 = e1; ...; fn = en} ==> {f1 = v1; ...; fn = vn}
  • 如果 e ==> {...; f = v; ...} ,那么 e.f ==> v

静态语义学

记录类型是这样写的:

{f1 : t1; ...; fn : tn}

记录类型中 fi:ti 的顺序是无关紧要的。例如, {f : t1; g : t2} 完全等同于 {g:t2;f:t1}

请注意,记录类型必须在使用之前定义。这使得 OCaml 能够进行比如果记录类型可以在没有定义的情况下使用时更好的类型推断。

类型检查规则如下:

  • 如果对于 1..n 中的所有 i ,都满足 ei : ti ,并且如果 t 被定义为 {f1 : t1; ...; fn : tn} ,那么 {f1 = e1; ...; fn = en} : t 。请注意,记录表达式中提供的字段集必须是作为记录类型的一部分定义的完整字段集(但请参见下文有关记录复制的内容)。

  • 如果 e : t1 ,并且如果 t1 被定义为 {...; f : t2; ...} ,那么 e.f : t2

记录复制

另外还提供了另一种语法来从旧记录构造新记录:

{e with f1 = e1; ...; fn = en}

这不会改变旧记录。相反,它会用新值构建一个新记录。在 with 之后提供的字段集合不必是作为记录类型的一部分定义的完整字段集合。在新复制的记录中,任何未作为 with 的一部分提供的字段都会从旧记录中复制过来。

记录副本是一种语法糖。它相当于编写

{ f1 = e1;   ...; fn = en;
  g1 = e.g1; ...; gn = e.gn }

其中 gi 的集合是记录类型的所有字段的集合减去 fi 的集合。

** 模式匹配。**

我们将以下新的模式形式添加到合法模式列表中:

  • {f1 = p1; ...; fn = pn}

我们将模式与值匹配并生成绑定的定义扩展如下:

  • 如果对于 1..n 中的所有 i ,满足 pi 匹配 vi 并生成绑定 bi,那么记录模式 {f1 = p1; ...; fn = pn} 匹配记录值 {f1 = v1; ...; fn = vn; ...} 并生成绑定集 Uibi。请注意,记录值可能比记录模式具有更多的字段。

作为一种语法糖,提供了另一种记录模式的形式: {f1; ...; fn} 。它被展开为 {f1 = f1; ...; fn = fn}

元组

与记录类似,元组是其他类型数据的组合。但是,元组不是通过名称来标识组件,而是通过位置来标识。以下是一些元组的示例:

(1, 2, 10)
(true, "Hello")
([1; 2; 3], (0.5, 'X'))

具有两个组件的元组称为对。具有三个组件的元组称为三元组。除此之外,通常我们只使用“元组”这个词,而不是继续基于数字的命名方案。

NOTE:

超过三个组件后,使用记录而不是元组可能更好,因为程序员很难记住哪个组件应该代表什么信息。

元组的构建很容易:只需像上面那样写出元组。再次访问涉及模式匹配,例如:

match (1, 2, 3) with (x, y, z) -> x + y + z
- : int = 6

语法

元组被写入

(e1, e2, ..., en)

括号并非完全必需的 - 通常情况下,您的代码可以成功解析而不使用它们 - 但通常被认为是良好的风格包含它们。

动态语义学

  • 如果对于 1..n 中的所有 i 都成立 ei ==> vi ,那么 (e1, ..., en) ==> (v1, ..., vn)

静态语义学

元组类型是使用一个新的类型构造函数 * 编写的,这与乘法运算符不同。类型 t1 * ... * tn 是第一个组件类型为 t1 ,...,第 n 个组件类型为 tn 的元组类型。

  • 如果对于 1..n 中的所有 i 都成立 ei : ti ,那么 (e1, ..., en) : t1 * ... * tn

模式匹配

我们将以下新的模式形式添加到合法模式列表中:

  • (p1, ..., pn)

我们将模式与值匹配并生成绑定的定义扩展如下:

  • 如果对于 1..n 中的所有 i ,都满足 pivi 匹配并产生绑定 bi,那么元组模式 (p1, ..., pn) 与元组值 (v1, ..., vn) 匹配并产生绑定集 Uibi。请注意,元组值的组件数量必须与元组模式的相同。

变体 vs. 元组和记录

NOTE:

上面的第二个视频使用了更高级的变体示例,这些将在后面的部分中学习。

变体与我们刚学过的类型(记录和元组)之间的重要区别在于变体类型的值是一组可能性中的一个,而元组或记录类型的值提供一组可能性中的每一个。回到我们的例子,类型 day 的值是 SunMon 或等等之一。但类型 mon 的值提供了 stringintptype 的每一个。请注意,在前两个句子中,单词“或”与变体类型相关联,而单词“和”与元组和记录类型相关联。这是一个很好的线索,如果你曾经试图决定是使用变体,还是元组或记录:如果你需要一个数据或另一个数据,你需要一个变体;如果你需要一个数据和另一个数据,你需要一个元组或记录。

一对类型更常被称为和类型,每对类型被称为积类型。这些名称来自集合论。变体类似于不相交并集,因为变体的每个值来自许多基础集合中的一个(因此迄今为止,这些集合中的每一个仅仅是一个构造器,因此基数为一)。不相交并集有时确实用求和运算符来表示。元组/记录类似于笛卡尔积,因为元组或记录的每个值包含来自许多基础集合中的一个值。笛卡尔积通常用乘法运算符 xPI来表示。

高级模式匹配

以下是一些有用的额外模式形式:

  • p1 | ... | pn :一个“或”模式;如果与任何一个单独模式 pi 匹配成功,则匹配成功,这些模式按从左到右的顺序尝试。所有模式必须绑定相同的变量。
  • (p : t) :带有显式类型注释的模式。
  • c :在这里, c 表示任何常量,如整数字面值、字符串字面值和布尔值。
  • 'ch1'..'ch2' :在这里, ch 表示一个字符字面量。例如, 'A'..'Z' 匹配任何大写字母。
  • p when e :仅当 e 评估为 true 时,才匹配 p

您可以在手册中阅读有关所有图案形式的信息。

使用 Let 进行模式匹配

到目前为止,我们一直在使用的 let 表达式的语法实际上是 OCaml 允许的完整语法的一个特例。该语法是:

let p = e1 in e2

也就是说,绑定的左侧实际上可能是一个模式,而不仅仅是一个标识符。当然,变量标识符在我们的有效模式列表中,这就是为什么我们迄今为止学习的语法只是一个特例。

鉴于这种语法,我们重新审视 let 表达式的语义。

动态语义学

评估 let p = e1 in e2

  • 评估 e1 的值为 v1
  • 匹配 v1 与模式 p 。如果不匹配,则引发异常 Match_failure 。否则,如果匹配,则产生一组绑定 。
  • e2 中的绑定 b 替换为新表达式 e2'
  • 评估 e2' 的值为 v2
  • 评估 let 表达式的结果是 v2

静态语义

  • 如果所有以下条件成立,则 (let p = e1 in e2) : t2
    • e1 : t1
    • p 中的模式变量为 x1..xn
    • 在假设对于 1..n 中的所有 i 都成立 xi : ti 的情况下, e2 : t2

让我们来看一下定义

与以往一样,let 定义可以被理解为一个 let 表达式,其主体尚未给出。因此,它们的语法可以泛化为

let p = e

它们的语义与之前的 let 表达式的语义一致。

使用函数进行模式匹配

到目前为止,我们一直在使用的函数语法也是 OCaml 允许的完整语法的一个特例。这种语法是:

let f p1 ... pn = e1 in e2   (* function as part of let expression *)
let f p1 ... pn = e          (* function definition at toplevel *)
fun p1 ... pn -> e           (* anonymous function *)

我们需要关心的真正原始的句法形式是 fun p -> e 。让我们重新审视匿名函数的语义及其在该形式下的应用;其他形式的变化源自以下内容:

静态语义

  • x1..xn 为出现在 p 中的模式变量。如果假设 x1 : t1x2 : t2 和…和 xn : tn ,我们可以得出 p : te :u 的结论,则 fun p -> e : t -> u
  • 应用程序的类型检查规则保持不变。

动态语义

  • 匿名函数的评估规则保持不变。
  • 评估 e0 e1
    • e0 评估为匿名函数 fun p -> e ,并将 e1 评估为值 v1
    • 匹配 v1 与模式 p 。如果不匹配,则引发异常 Match_failure 。否则,如果匹配,则产生一组绑定 b
    • e 中的绑定 b 替换为新表达式 e'
    • 评估 e' 的值为 v ,这是评估 e0 e1 的结果。

模式匹配示例

以下是获取宝可梦生命值的几种方法:

(* Pokemon types *)
type ptype = TNormal | TFire | TWater

(* A record to represent Pokemon *)
type mon = { name : string; hp : int; ptype : ptype }

(* OK *)
let get_hp m = match m with { name = n; hp = h; ptype = t } -> h

(* better *)
let get_hp m = match m with { name = _; hp = h; ptype = _ } -> h

(* better *)
let get_hp m = match m with { name; hp; ptype } -> hp

(* better *)
let get_hp m = match m with { hp } -> hp

(* best *)
let get_hp m = m.hp
type ptype = TNormal | TFire | TWater
type mon = { name : string; hp : int; ptype : ptype; }
val get_hp : mon -> int = <fun>
val get_hp : mon -> int = <fun>
val get_hp : mon -> int = <fun>
val get_hp : mon -> int = <fun>
val get_hp : mon -> int = <fun>

这是如何获取一对中的第一个和第二个组件:

let fst (x, _) = x

let snd (_, y) = y
val fst : 'a * 'b -> 'a = <fun>
val snd : 'a * 'b -> 'b = <fun>

fstsnd 实际上已经在标准库中为您定义好了。

最后,以下是获取三元组的第三个组件的几种方法:

(* OK *)
let thrd t = match t with x, y, z -> z

(* good *)
let thrd t =
  let x, y, z = t in
  z

(* better *)
let thrd t =
  let _, _, z = t in
  z

(* best *)
let thrd (_, _, z) = z
val thrd : 'a * 'b * 'c -> 'c = <fun>
val thrd : 'a * 'b * 'c -> 'c = <fun>
val thrd : 'a * 'b * 'c -> 'c = <fun>
val thrd : 'a * 'b * 'c -> 'c = <fun>

标准库没有为三元组、四元组等定义任何函数。

Type Synonyms

类型同义词是对已经存在的类型的新名称。例如,以下是一些在表示线性代数中的一些类型时可能有用的类型同义词:

type point = float * float
type vector = float list
type matrix = float list list
type point = float * float
type vector = float list
type matrix = float list list

无论何处需要 float * float ,您都可以使用 point ,反之亦然。这两者可以完全互换。在下面的代码中, get_x 不在乎您传递的是被注释为其中一个还是另一个的值:

let get_x = fun (x, _) -> x

let p1 : point = (1., 2.)
let p2 : float * float = (1., 3.)

let a = get_x p1
let b = get_x p2
val get_x : 'a * 'b -> 'a = <fun>
val p1 : point = (1., 2.)
val p2 : float * float = (1., 3.)
val a : float = 1.
val b : float = 1.

类型同义词很有用,因为它们让我们为复杂类型赋予描述性名称。它们是使代码更具自我说明性的一种方式。

Options

假设您想编写一个通常返回类型为 t 的值的函数,但有时不返回任何内容。例如,您可能想定义一个函数 list_max ,它返回列表中的最大值,但在空列表上没有明智的返回值:

let rec list_max = function
  | [] -> ???
  | h :: t -> max h (list_max t)

有几种可能性需要考虑:

  • 返回 min_int ? 但这样 list_max 只能用于整数— 而不是浮点数或其他类型。
  • 引发异常?但是函数的用户必须记得捕获异常。
  • 返回 null ? 这在 Java 中有效,但是按设计,OCaml 没有 null 值。这实际上是件好事:空指针错误不好调试。

NOTE:

何瑞爵士称他发明的 null 为“价值十亿美元的错误”。

除了这些可能性之外,OCaml 还提供了一种更好的东西,称为Option。(Haskellers 将把选项识别为 Maybe 单子。)

你可以把期权想象成一个封闭的盒子。也许盒子里面有东西,或者盒子是空的。在打开盒子之前,我们不知道里面是什么。如果当我们打开盒子时发现里面有东西,我们可以把那个东西拿出来并使用。因此,期权提供一种“或许类型”,最终是一种一种的类型:盒子处于两种状态之一,满或空。

在上面的 list_max 中,我们想要用隐喻的方式返回一个空箱子,如果列表为空,或者如果列表非空,则返回一个包含列表中最大元素的箱子。

这是我们如何创建一个选项,就像一个里面有 42 的盒子:

Some 42
- : int option = Some 42

这是我们如何创建一个类似空盒子的选项:

None
- : 'a option = None

Some 表示盒子里面有东西,而且是 42None 表示盒子里面没有东西。

list 一样,我们称 option 为类型构造器:给定一个类型,它会产生一个新类型;但是,它本身不是一个类型。因此对于任何类型 t ,我们可以将 t option 写成一个类型。但是 option 本身不能被用作一个类型。类型 t option 的值可能包含类型 t 的值,或者它们可能什么也不包含。 None 的类型是 'a option ,因为里面的东西的类型是不受限制的 — 因为里面什么也没有。

您可以使用模式匹配访问选项值 e 的内容。以下是一个从选项中提取 int (如果存在)并将其转换为字符串的函数:

let extract o =
  match o with
  | Some i -> string_of_int i
  | None -> "";;
val extract : int option -> string = <fun>

以下是该函数的几个示例用法:

extract (Some 42);;
extract None;;
- : string = "42"
- : string = ""

这是我们如何使用选项写 list_max 的方法:

let rec list_max = function
  | [] -> None
  | h :: t -> begin
      match list_max t with
        | None -> Some h
        | Some m -> Some (max h m)
      end
val list_max : 'a list -> 'a option = <fun>

NOTE:

上面嵌套模式匹配的 begin .. end 包装在这里并不是严格必需的,但也不是一个坏习惯,因为它将预防更复杂代码中的潜在语法错误。关键字 beginend 等同于 ( 和 ) 。

在 Java 中,每个对象引用都隐式地是一个选项。要么引用中有一个对象,要么引用中什么都没有。那个“什么都没有”由值 null 表示。Java 不强制程序员显式检查空值情况,这会导致空指针异常。OCaml 选项强制程序员在模式匹配中包含一个分支 None ,从而确保程序员在没有内容时考虑正确的操作。因此,我们可以将选项视为一种有原则的方法,从语言中消除 null 。使用选项通常被认为是比引发异常更好的编码实践,因为它强制调用者在 None 情况下执行一些明智的操作。

选项的语法和语义。

  • t option 是每种类型 t 的一种类型。
  • None 是一种 'a option 类型的值。
  • Some et option 类型的表达式,如果 e : t 。如果 e ==> v ,那么 Some e ==> Some v

关联列表

地图是一种将键映射到值的数据结构。地图也被称为字典。地图的一种简单实现是关联列表,它是一对列表。这里,例如,是一个将一些形状名称映射到它们拥有的边数的关联列表:

let d = [("rectangle", 4); ("nonagon", 9); ("icosagon", 20)]
val d : (string * int) list =
  [("rectangle", 4); ("nonagon", 9); ("icosagon", 20)]

请注意,关联列表在 OCaml 中并不是一种内置数据类型,而是两种其他类型的组合:列表和对。

这里有两个函数,用于在关联列表中实现插入和查找操作:

(** [insert k v lst] is an association list that binds key [k] to value [v]
    and otherwise is the same as [lst] *)
let insert k v lst = (k, v) :: lst

(** [lookup k lst] is [Some v] if association list [lst] binds key [k] to
    value [v]; and is [None] if [lst] does not bind [k]. *)
let rec lookup k = function
| [] -> None
| (k', v) :: t -> if k = k' then Some v else lookup k t
val insert : 'a -> 'b -> ('a * 'b) list -> ('a * 'b) list = <fun>
val lookup : 'a -> ('a * 'b) list -> 'b option = <fun>

insert 函数只是简单地在列表的前面添加一个从键到值的新映射。它不会去检查键是否已经在列表中。 lookup 函数从左到右遍历列表。因此,如果列表中恰好有多个给定键的映射,只会返回最近插入的那个。

插入在关联列表中是常数时间,查找是线性时间。虽然字典有更高效的实现方式,我们将在课程后面学习一些,但关联列表是一个非常简单和有用的实现方式,适用于不需要高性能的小型字典。OCaml 标准库在 List 模块中有关联列表的函数;在文档中查找 List.assoc 和其下面的函数。我们刚刚写的 lookup 实际上已经被定义为 List.assoc_opt 。标准库中没有预定义的 insert 函数,因为在上面简单地将一对元素连接起来是如此微不足道。

Algebraic Data Types

到目前为止,我们仅将变体视为列举一组常量值,例如:

type day = Sun | Mon | Tue | Wed | Thu | Fri | Sat

type ptype = TNormal | TFire | TWater

type peff = ENormal | ENotVery | Esuper

但变体远比这更强大。

携带数据的变体

作为一个运行示例,这里有一个变体类型 shape ,它不仅仅是枚举值:

type point = float * float
type shape =
  | Point of point
  | Circle of point * float (* center and radius *)
  | Rect of point * point (* lower-left and upper-right corners *)
type point = float * float
type shape = Point of point | Circle of point * float | Rect of point * point

这种类型, shape ,代表着一个点、一个圆或一个矩形的形状。一个点由一个构造函数 Point 表示,它携带一些额外的数据,即类型为 point 的值。一个圆由一个构造函数 Circle 表示,它携带两个数据片段:一个是类型为 point 的,另一个是类型为 float 的。这些数据代表圆的中心和半径。一个矩形由一个构造函数 Rect 表示,它携带另外两个点。

这里有几个使用 shape 类型的函数:

let area = function
  | Point _ -> 0.0
  | Circle (_, r) -> Float.pi *. (r ** 2.0)
  | Rect ((x1, y1), (x2, y2)) ->
      let w = x2 -. x1 in
      let h = y2 -. y1 in
      w *. h

let center = function
  | Point p -> p
  | Circle (p, _) -> p
  | Rect ((x1, y1), (x2, y2)) -> ((x2 +. x1) /. 2.0, (y2 +. y1) /. 2.0)
val area : shape -> float = <fun>
val center : shape -> point = <fun>

shape 变体类型与我们之前见过的类型相同,因为它是根据一组构造函数定义的。与以往不同的是,这些构造函数携带额外的数据。类型 shape 的每个值都是从这些构造函数中的一个精确形成的。有时我们称构造函数为标签,因为它标记携带的数据来自特定的构造函数。

变体类型有时被称为标记联合。该类型的每个值都来自构造函数携带的基础类型的所有值的并集。例如,对于 shape 类型,每个值都标记为 PointCircleRect ,并携带一个值。

  • 所有 point 值的集合,与并集
  • 所有 point * float 值的集合,与并集
  • 所有 point * point 值的集合。

这些变体类型的另一个名称是代数数据类型。这里的“代数”指的是变体类型包含前一讲中定义的和类型和积类型。和类型源自于一个变体的值是由其中一个构造器形成的事实。积类型源自于构造器可以携带元组或记录的事实,这些元组或记录的值分别来自于它们各自的组件类型。

使用变体,我们可以以一种类型安全的方式表示代表多个其他类型的联合类型。例如,这里是一个代表 stringint 的类型:

type string_or_int =
  | String of string
  | Int of int
type string_or_int = String of string | Int of int

如果我们愿意的话,我们可以使用这种类型来编写包含字符串或整数的列表(例如)。

type string_or_int_list = string_or_int list

let rec sum : string_or_int list -> int = function
  | [] -> 0
  | String s :: t -> int_of_string s + sum t
  | Int i :: t -> i + sum t

let lst_sum = sum [String "1"; Int 2]
type string_or_int_list = string_or_int list
val sum : string_or_int list -> int = <fun>
val lst_sum : int = 3

变体因此提供了一种类型安全的方式来做一些以前似乎不可能的事情。

变体还可以区分值是用哪个标签构造的,即使多个构造函数具有相同的类型。例如:

type t = Left of int | Right of int
let x = Left 1
let double_right = function
  | Left i -> i
  | Right i -> 2 * i
type t = Left of int | Right of int
val x : t = Left 1
val double_right : t -> int = <fun>

语法和语义

句法

定义变体类型:

type t = C1 [of t1] | ... | Cn [of tn]

上面的方括号表示 of ti 是可选的。每个构造函数可以单独不携带数据或携带数据。我们称不携带数据的构造函数为常量;携带数据的构造函数称为非常量。

编写一个变体表达式:

C e

或者:

C

取决于构造函数名称 C 是非常量还是常量。

动态语义学

  • 如果 e ==> v ,那么 C e ==> C v ,假设 C 是非常数的。
  • C 已经是一个数值,假设 C 是常数。

静态语义学

  • 如果 t = ... | C | ... ,那么 C : t 。
  • 如果 t = ... | C of t' | ... ,并且如果 e : t' ,那么 C e : t 。

模式匹配

我们将以下新的模式形式添加到合法模式列表中:

  • C p

我们将模式与值匹配并生成绑定的定义扩展如下:

  • 如果 p 匹配 v 并产生绑定 b ,那么 C p 匹配 C v 并产生绑定 b

全部情况

对抗变体进行模式匹配时要注意的一点是现实世界 OCaml 所称的“全捕获情况”。以下是一个可能出错的简单示例。假设您编写了以下变体和函数:

type color = Blue | Red

(* a thousand lines of code in between *)

let string_of_color = function
  | Blue -> "blue"
  | _ -> "red"
type color = Blue | Red
val string_of_color : color -> string = <fun>

看起来没问题,对吧?但有一天你意识到世界上有更多的颜色。你需要表示绿色。所以你回去,将绿色添加到你的变体中:

type color = Blue | Red | Green

(* a thousand lines of code in between *)

let string_of_color = function
  | Blue -> "blue"
  | _ -> "red"
type color = Blue | Red | Green
val string_of_color : color -> string = <fun>

但是由于中间有上千行代码,你忘记了 string_of_color 需要更新。现在,突然间,你变成了红绿色盲:

string_of_color Green
- : string = "red"

问题在于模式匹配中的通配情况 string_of_color :使用通配符模式匹配任何内容的最终情况。这样的代码对于将来对变体类型的更改不够健壮。

如果您最初将函数编码为以下内容,生活会更美好:

let string_of_color = function
  | Blue -> "blue"
  | Red  -> "red"
File "[9]", lines 1-3, characters 22-17:
1 | ......................function
2 |   | Blue -> "blue"
3 |   | Red  -> "red"
Warning 8 [partial-match]: this pattern-matching is not exhaustive.
Here is an example of a case that is not matched:
Green
val string_of_color : color -> string = <fun>

OCaml 类型检查器现在提醒您,您尚未更新 string_of_color 以考虑新构造函数。

故事的道德是:捕捉所有情况会导致错误的代码。避免使用它们。

递归变体

变体类型可能在其自身的内部提及自己的名称。例如,这里是一个变体类型,可以用来表示类似于 int list 的东西:

type intlist = Nil | Cons of int * intlist

let lst3 = Cons (3, Nil)  (* similar to 3 :: [] or [3] *)
let lst123 = Cons(1, Cons(2, lst3)) (* similar to [1; 2; 3] *)

let rec sum (l : intlist) : int =
  match l with
  | Nil -> 0
  | Cons (h, t) -> h + sum t

let rec length : intlist -> int = function
  | Nil -> 0
  | Cons (_, t) -> 1 + length t

let empty : intlist -> bool = function
  | Nil -> true
  | Cons _ -> false
type intlist = Nil | Cons of int * intlist
val lst3 : intlist = Cons (3, Nil)
val lst123 : intlist = Cons (1, Cons (2, Cons (3, Nil)))
val sum : intlist -> int = <fun>
val length : intlist -> int = <fun>
val empty : intlist -> bool = <fun>

请注意,在 intlist 的定义中,我们将 Cons 构造函数定义为携带包含 intlist 的值。这使得类型 intlist 成为递归的:它是根据自身定义的。

如果使用 and 关键字,类型可能会相互递归

type node = {value : int; next : mylist}
and mylist = Nil | Node of node
type node = { value : int; next : mylist; }
and mylist = Nil | Node of node

任何这种相互递归必须涉及至少一个变体或记录类型,该递归“经过”。例如,以下情况是不允许的:

type t = u and u = t
File "[12]", line 1, characters 0-10:
1 | type t = u and u = t
    ^^^^^^^^^^
Error: The definition of t contains a cycle:
       u

但这是:

type t = U of u and u = T of t
type t = U of u and u = T of t
type t = U of u
and u = T of t

记录类型也可以是递归的:

type node = {value : int; next : node}
type node = { value : int; next : node; }

但是普通的类型同义词可能不是:

type t = t * t
File "[15]", line 1, characters 0-14:
1 | type t = t * t
    ^^^^^^^^^^^^^^
Error: The type abbreviation t is cyclic

尽管 node 是一种合法的类型定义,但由于涉及循环性,无法构造该类型的值:要构造出存在的第一个 node 值,您已经需要一个类型为 node 的值存在。稍后,当我们涵盖命令式特性时,我们将看到类似的想法用于可变链表(但成功地)。

参数化变体

变体类型可以基于其他类型进行参数化。例如,上面的 intlist 类型可以泛化为提供列表(我们自己编码)的任何类型:

type 'a mylist = Nil | Cons of 'a * 'a mylist

let lst3 = Cons (3, Nil)  (* similar to [3] *)
let lst_hi = Cons ("hi", Nil)  (* similar to ["hi"] *)
type 'a mylist = Nil | Cons of 'a * 'a mylist
val lst3 : int mylist = Cons (3, Nil)
val lst_hi : string mylist = Cons ("hi", Nil)

在这里, mylist 是一种类型构造器而不是一种类型:没有办法编写类型为 mylist 的值。但是我们可以编写类型为 int mylist 的值(例如, lst3 )和 string mylist 的值(例如, lst_hi )。将类型构造器视为类似于函数,但是它将类型映射到类型,而不是将值映射到值。

这里是一些关于 'a mylist 的函数:

let rec length : 'a mylist -> int = function
  | Nil -> 0
  | Cons (_, t) -> 1 + length t

let empty : 'a mylist -> bool = function
  | Nil -> true
  | Cons _ -> false
val length : 'a mylist -> int = <fun>
val empty : 'a mylist -> bool = <fun>

请注意,每个函数的主体与其之前的定义 intlist 没有变化。我们改变的只是类型注释。甚至可以安全地省略这一点。

let rec length = function
  | Nil -> 0
  | Cons (_, t) -> 1 + length t

let empty = function
  | Nil -> true
  | Cons _ -> false
val length : 'a mylist -> int = <fun>
val empty : 'a mylist -> bool = <fun>

我们刚刚编写的函数是一种称为参数多态性的语言特性的示例。这些函数不关心 'a'a mylist 中是什么,因此它们可以很好地在 int myliststring mylist 或任何其他 (whatever) mylist 上工作。单词“多态性”基于希腊词根“poly”(许多)和“morph”(形式)。类型为 'a mylist 的值可能有许多形式,取决于实际类型 'a

一旦您对类型 'a 可能是什么加以限制,您就放弃了一些多态性。例如,

let rec sum = function
  | Nil -> 0
  | Cons (h, t) -> h + sum t

我们使用 ( + ) 运算符与列表的头部结合的事实限制了头部元素必须是 int ,因此所有元素必须是 int 。这意味着 sum 必须接受 int mylist ,而不是其他类型的 'a mylist

还可以为参数化类型设置多个类型参数,这种情况下需要使用括号:

type ('a, 'b) pair = {first : 'a; second : 'b}
let x = {first = 2; second = "hello"}
type ('a, 'b) pair = { first : 'a; second : 'b; }
val x : (int, string) pair = {first = 2; second = "hello"}

多态变体

到目前为止,每当您想要定义一个变体类型时,您必须给它一个名称,比如 dayshape'a mylist

type day = Sun | Mon | Tue | Wed | Thu | Fri | Sat

type shape =
  | Point of point
  | Circle of point * float
  | Rect of point * point

type 'a mylist = Nil | Cons of 'a * 'a mylist
type day = Sun | Mon | Tue | Wed | Thu | Fri | Sat
type shape = Point of point | Circle of point * float | Rect of point * point
type 'a mylist = Nil | Cons of 'a * 'a mylist

偶尔,您可能仅需要一种变体类型来表示单个函数的返回值。例如,这里有一个函数 f,可以返回 int ;您需要定义一个变体类型来表示该结果:

type fin_or_inf = Finite of int | Infinity

let f = function
  | 0 -> Infinity
  | 1 -> Finite 1
  | n -> Finite (-n)
type fin_or_inf = Finite of int | Infinity
val f : int -> fin_or_inf = <fun>

这个定义的缺点是,即使在程序的大部分部分中都不会使用 fin_or_inf ,你还是被迫定义它。

OCaml 中还有另一种支持这种编程的变体:多态变体。多态变体就像变体一样,只是:

  • 在使用它们之前,您不必声明它们的类型或构造函数。
  • 多态变体类型没有名称。(因此,这一特性的另一个名称可以是“匿名变体”。)
  • 多态变体的构造者以反引号字符开头。

使用多态变体,我们可以重写 f

let f = function
  | 0 -> `Infinity
  | 1 -> `Finite 1
  | n -> `Finite (-n)
val f : int -> [> `Finite of int | `Infinity ] = <fun>

这种类型表示 f 要么对某些 n : int 返回 Finite n` ,要么 Infinity。方括号不表示列表,而是一组可能的构造函数。 `>` 符号意味着针对该类型的值进行模式匹配的任何代码必须至少处理构造函数Finite 和 ``Infinity ,可能还有更多。例如,我们可以这样写:

match f 3 with
  | `NegInfinity -> "negative infinity"
  | `Finite n -> "finite"
  | `Infinity -> "infinite"

这将导致编译器发出警告,因为我们没有处理 `NegInfinity 构造函数。我们可以通过添加一个匹配来修复这个问题:

- : string = "finite"

模式匹配包括除 Finite 或 Infinity 之外的构造函数是完全可以的,因为 f 保证不会返回除此之外的任何构造函数。

在课程的后期我们会看到多态变体还有其他更具说服力的用途。它们在库中特别有用。目前,我们通常会建议您遏制对多态变体的广泛使用,因为它们的类型可能变得难以管理。

内置变体

OCaml 的内置列表数据类型实际上是一个递归的、参数化的变体。它的定义如下:

type 'a list = [] | ( :: ) of 'a * 'a list

所以 list 实际上只是一个类型构造器,具有值构造器 [] (我们发音为“nil”)和 :: (我们发音为“cons”)。

OCaml 的内置选项数据类型实际上也是一个参数化的变体。它的定义如下:

type 'a option = None | Some of 'a

所以 option 实际上只是一个类型构造器,带有值构造器 NoneSome

您可以在核心 OCaml 库中看到 listoption 都有定义

Exceptions

OCaml 具有类似于许多其他编程语言的异常机制。使用以下语法定义 OCaml 异常的新类型:

exception E of t

其中 E 是构造函数名称, t 是类型。 of t 是可选的。注意这与定义变体类型的构造函数类似。例如:

exception A
exception B
exception Code of int
exception Details of string
exception A
exception B
exception Code of int
exception Details of string

要创建一个异常值,使用创建变体值时相同的语法。例如,这里是一个异常值,其构造函数是 Failure ,携带了一个 string

Failure "something went wrong"
- : exn = Failure "something went wrong"

这个构造函数是在标准库中预定义的,是 OCaml 程序员经常使用的更常见的异常之一。

要引发异常值 e ,只需简单地编写

raise e

标准库中有一个方便的函数 failwith : string -> 'a ,它引发 Failure 。也就是说, failwith s 等同于 raise (Failure s)

要捕获异常,请使用以下语法:

try e with
| p1 -> e1
| ...
| pn -> en

表达式 e 可能会引发异常。如果没有引发异常,整个 try 表达式将评估为 e 的值。如果 e 引发异常值 v ,该值 v 将与提供的模式进行匹配,就像 match 表达式一样。

异常情况是可扩展的变体

所有异常值都具有类型 exn ,这是核心中定义的一种变体。不过,它是一种不寻常的变体,称为可扩展变体,允许在定义变体类型本身之后定义新的变体构造函数。如果您感兴趣,可以查看 OCaml 手册了解更多关于可扩展变体的信息。

异常语义

由于它们只是变体,异常的语法和语义已经被变体的语法和语义所覆盖——只有一个例外(故意的),即异常被引发和处理的动态语义。

动态语义。正如我们最初所说,每个 OCaml 表达式都是

  • 评估为一个值
  • 引发异常
  • 或者无法终止(即“无限循环”)。

到目前为止,我们只介绍了处理这三种情况中的第一种情况的动态语义部分。当我们添加异常时会发生什么?现在,表达式的评估要么产生一个值,要么产生一个异常数据包。数据包不是正常的 OCaml 值;只有语言中的 raisetry 才能识别它们。由(例如) Failure "oops" 产生的异常值是由 raise (Failure "oops") 产生的异常数据包的一部分,但数据包不仅包含异常值;例如,还可以有一个堆栈跟踪。

对于除 try 之外的任何表达式 e ,如果 e 的子表达式的评估产生异常数据包 P ,那么 e 的评估会产生数据包 P

但现在我们第一次遇到一个问题:子表达式的求值顺序是什么?有时,这个问题的答案可以通过我们已经开发的语义来提供。例如,对于 let 表达式,我们知道绑定表达式必须在主体表达式之前求值。因此,以下代码会引发 A

let _ = raise A in raise B;;
Exception: A.
Called from Stdlib__Fun.protect in file "fun.ml", line 33, characters 8-15
Re-raised at Stdlib__Fun.protect in file "fun.ml", line 38, characters 6-52
Called from Topeval.load_lambda in file "toplevel/byte/topeval.ml", line 89, characters 4-150

在 OCaml 中,对于函数,它并没有正式规定函数和参数的求值顺序,但当前的实现是在函数之前对参数进行求值。因此,以下代码也会引发 A ,除了产生一些编译器警告,即第一个表达式实际上永远不会被应用为函数的参数:

(raise B) (raise A)
File "[4]", line 1, characters 10-19:
1 | (raise B) (raise A)
              ^^^^^^^^^
Warning 10: this expression should have type unit.
File "[4]", line 1, characters 10-19:
1 | (raise B) (raise A)
              ^^^^^^^^^
Warning 20 [ignored-extra-argument]: this argument will not be used by the function.
Exception: A.
Called from Stdlib__Fun.protect in file "fun.ml", line 33, characters 8-15
Re-raised at Stdlib__Fun.protect in file "fun.ml", line 38, characters 6-52
Called from Topeval.load_lambda in file "toplevel/byte/topeval.ml", line 89, characters 4-150

这两段代码引发相同异常是合理的,因为我们知道 let x = e1 in e2(fun x -> e2) e1 的语法糖。

但是以下代码会引发什么异常?

(raise A, raise B)
Exception: B.
Called from Stdlib__Fun.protect in file "fun.ml", line 33, characters 8-15
Re-raised at Stdlib__Fun.protect in file "fun.ml", line 38, characters 6-52
Called from Topeval.load_lambda in file "toplevel/byte/topeval.ml", line 89, characters 4-150

答案是微妙的。语言规范并未规定对成对组件的评估顺序。我们的语义也没有确切确定顺序。(尽管如果你认为是从左到右也是可以原谅的。)因此,程序员实际上不能依赖于该顺序。事实证明,OCaml 的当前实现是从右向左评估的。因此,上面的代码实际上会引发 B 。如果你真的想要强制评估顺序,你需要使用 let 表达式:

let a = raise A in
let b = raise B in
(a, b)
Exception: A.
Called from Stdlib__Fun.protect in file "fun.ml", line 33, characters 8-15
Re-raised at Stdlib__Fun.protect in file "fun.ml", line 38, characters 6-52
Called from Topeval.load_lambda in file "toplevel/byte/topeval.ml", line 89, characters 4-150

该代码保证会引发 A 而不是 B

一个有趣的边界情况是当一个 raise 表达式本身具有一个引发异常的子表达式时会发生什么:

exception C of string;;
exception D of string;;
raise (C (raise (D "oops")))
exception C of string
exception D of string
Exception: D "oops".
Called from Stdlib__Fun.protect in file "fun.ml", line 33, characters 8-15
Re-raised at Stdlib__Fun.protect in file "fun.ml", line 38, characters 6-52
Called from Topeval.load_lambda in file "toplevel/byte/topeval.ml", line 89, characters 4-150

该代码最终会引发 D ,因为首先必须评估 C (raise (D "oops")) 的值。这需要评估 raise (D "oops") 的值。这会导致生成一个包含 D "oops" 的数据包,然后该数据包传播并成为评估 C (raise (D "oops")) 的结果,因此也是评估 raise (C (raise (D "oops"))) 的结果。

一旦表达式的评估产生异常数据包 P ,该数据包会传播直至到达一个 try 表达式:

try e with
| p1 -> e1
| ...
| pn -> en

P 内部的异常值与提供的模式进行匹配,使用通常的模式匹配评估规则——有一个例外(再次,双关语)。如果没有任何模式匹配成功,那么不会在新的异常数据包内生成 Match_failure ,而是原始异常数据包 P 会继续传播,直到达到下一个 try 表达式。

模式匹配

有一个用于异常情况的模式形式。以下是其用法示例:

match List.hd [] with
  | [] -> "empty"
  | _ :: _ -> "non-empty"
  | exception (Failure s) -> s

请注意,上面的代码只是一个标准的 match 表达式,而不是一个 try 表达式。它将 List.hd [] 的值与提供的三个模式进行匹配。正如我们所知, List.hd [] 将引发一个包含值 Failure "hd" 的异常。异常模式 exception (Failure s) 与该值匹配。因此,上述代码将求值为 "hd"

异常模式是一种语法糖。例如,考虑以下代码:

match e with
  | p1 -> e1
  | exception p2 -> e2
  | p3 -> e3
  | exception p4 -> e4

我们可以重写代码以消除异常模式:

try
  match e with
    | p1 -> e1
    | p3 -> e3
with
  | p2 -> e2
  | p4 -> e4

通常情况下,如果存在异常模式和非异常模式,则评估过程如下:尝试评估 e 。如果它产生异常数据包,则使用原始匹配表达式中的异常模式来处理该数据包。如果它不产生异常数据包,而是产生非异常值,则使用原始匹配表达式中的非异常模式来匹配该值。

异常和 OUnit

如果函数的规范要求它引发异常,您可能希望编写 `OUnit 测试来检查函数是否正确执行此操作。以下是如何做到这一点:

open OUnit2

let tests = "suite" >::: [
    "empty" >:: (fun _ -> assert_raises (Failure "hd") (fun () -> List.hd []));
  ]

let _ = run_test_tt_main tests

表达式 assert_raises exn (fun () -> e) 用于检查表达式 e 是否引发异常 exn 。如果是,则 OUnit 测试用例成功,否则失败。

请注意, assert_raises 的第二个参数是类型为 unit -> 'a 的函数,有时被称为“thunk”。写一个这种类型的函数可能看起来很奇怪——唯一可能的输入是 () ——但这是函数式语言中常见的模式,用于暂停或延迟程序的评估。在这种情况下,我们希望 assert_raises 在准备好时评估 List.hd [] 。如果我们立即评估 List.hd []assert_raises 将无法检查是否引发了正确的异常。我们将在后面的章节中更多地了解 thunks

WARNING:

常见的错误是忘记在 e 周围加上 (fun () -> ...) 。如果您犯了这个错误,程序可能仍然可以进行类型检查,但 OUnit 测试用例将失败:没有额外的匿名函数,异常会在 assert_raises 有机会处理它之前被引发。

Example: Tree

树是一种非常有用的数据结构。二叉树,正如你在 CS 2110 中所记得的那样,是一个包含值和两个子树的节点。二叉树也可以是一棵空树,我们也用它来表示没有子节点的情况。

用元组表示

这里是二叉树数据类型的定义:

type 'a tree =
| Leaf
| Node of 'a * 'a tree * 'a tree
type 'a tree = Leaf | Node of 'a * 'a tree * 'a tree

一个节点携带类型为 'a 的数据项,并具有左子树和右子树。叶子为空。将此定义与列表的定义进行比较,注意它们结构是多么相似:

type 'a tree =                        type 'a mylist =
  | Leaf                                | Nil
  | Node of 'a * 'a tree * 'a tree      | Cons of 'a * 'a mylist

唯一的基本区别在于 Cons 包含一个子列表,而 Node 包含两个子树。

这里是构建一个小树的代码:

(* the code below constructs this tree:
         4
       /   \
      2     5
     / \   / \
    1   3 6   7
*)
let t =
  Node(4,
    Node(2,
      Node(1, Leaf, Leaf),
      Node(3, Leaf, Leaf)
    ),
    Node(5,
      Node(6, Leaf, Leaf),
      Node(7, Leaf, Leaf)
    )
  )
val t : int tree =
  Node (4, Node (2, Node (1, Leaf, Leaf), Node (3, Leaf, Leaf)),
   Node (5, Node (6, Leaf, Leaf), Node (7, Leaf, Leaf)))

树的大小是指其中节点的数量(即 Node 个,而不是 Leaf 个)。例如,上面树 t 的大小为 7。这里是一个函数 size : 'a tree -> int ,用于返回树中节点的数量:

let rec size = function
  | Leaf -> 0
  | Node (_, l, r) -> 1 + size l + size r

记录表示

接下来,让我们修改我们的树类型,使用记录类型来表示树节点。在 OCaml 中,我们必须定义两个相互递归的类型,一个用于表示树节点,另一个用于表示(可能为空的)树:

type 'a tree =
  | Leaf
  | Node of 'a node

and 'a node = {
  value: 'a;
  left: 'a tree;
  right: 'a tree
}
type 'a tree = Leaf | Node of 'a node
and 'a node = { value : 'a; left : 'a tree; right : 'a tree; }

这是一个示例树:

(* represents
      2
     / \
    1   3  *)
let t =
  Node {
    value = 2;
    left = Node {value = 1; left = Leaf; right = Leaf};
    right = Node {value = 3; left = Leaf; right = Leaf}
  }
val t : int tree =
  Node
   {value = 2; left = Node {value = 1; left = Leaf; right = Leaf};
    right = Node {value = 3; left = Leaf; right = Leaf}}

我们可以使用模式匹配来编写通常用于递归遍历树的算法。例如,这里是对树进行递归搜索的一个示例:

(** [mem x t] is whether [x] is a value at some node in tree [t]. *)
let rec mem x = function
  | Leaf -> false
  | Node {value; left; right} -> value = x || mem x left || mem x right
val mem : 'a -> 'a tree -> bool = <fun>

函数名称 mem 是“成员”的缩写;标准库通常使用这个名称的函数来实现对集合数据结构的搜索,以确定某个元素是否是该集合的成员。

这是一个计算树的先序遍历的函数,其中每个节点在其任何子节点之前被访问,通过构建一个列表,其中值按照它们被访问的顺序出现:

let rec preorder = function
  | Leaf -> []
  | Node {value; left; right} -> [value] @ preorder left @ preorder right
val preorder : 'a tree -> 'a list = <fun>
preorder t
- : int list = [2; 1; 3]

尽管上面的代码中算法非常清晰,但由于 @ 运算符,在不平衡树上它需要二次时间。这个问题可以通过引入一个额外的参数 acc 来解决,在每个节点累积值,尽管这会使代码变得不太清晰。

let preorder_lin t =
  let rec pre_acc acc = function
    | Leaf -> acc
    | Node {value; left; right} -> value :: (pre_acc (pre_acc acc right) left)
  in pre_acc [] t
val preorder_lin : 'a tree -> 'a list = <fun>

上述版本在树中每个 Node 使用了恰好一个 :: 操作,使其具有线性时间。

Example: Natural Number

我们可以定义一个递归变体,它的行为类似于数字,从而证明我们实际上并不一定需要将数字内置到 OCaml 中!(不过出于效率考虑,将数字内置进去是件好事。)

自然数要么是零,要么是另一个自然数的后继。这是你在数理逻辑课程中可能会定义自然数的方式,它自然地导致以下 OCaml 类型 nat

type nat = Zero | Succ of nat
type nat = Zero | Succ of nat

我们已经定义了一个新类型 natZeroSucc 是这种类型值的构造函数。这使我们能够构建具有任意数量嵌套 Succ 构造函数的表达式。这样的值就像自然数:

let zero = Zero
let one = Succ zero
let two = Succ one
let three = Succ two
let four = Succ three
val zero : nat = Zero
val one : nat = Succ Zero
val two : nat = Succ (Succ Zero)
val three : nat = Succ (Succ (Succ Zero))
val four : nat = Succ (Succ (Succ (Succ Zero)))

现在我们可以编写函数来操作这种类型的值。在下面的代码中,我们将写很多类型注释,以帮助读者跟踪哪些值是 nat 而不是 int ;当然,编译器不需要我们的帮助。

let iszero = function
  | Zero -> true
  | Succ _ -> false

let pred = function
  | Zero -> failwith "pred Zero is undefined"
  | Succ m -> m
val iszero : nat -> bool = <fun>
val pred : nat -> nat = <fun>

同样地,我们可以定义一个函数来相加两个数字:

let rec add n1 n2 =
  match n1 with
  | Zero -> n2
  | Succ pred_n -> add pred_n (Succ n2)
val add : nat -> nat -> nat = <fun>

我们可以将 nat 值转换为类型 int ,反之亦然:

let rec int_of_nat = function
  | Zero -> 0
  | Succ m -> 1 + int_of_nat m

let rec nat_of_int = function
  | i when i = 0 -> Zero
  | i when i > 0 -> Succ (nat_of_int (i - 1))
  | _ -> failwith "nat_of_int is undefined on negative ints"
val int_of_nat : nat -> int = <fun>
val nat_of_int : int -> nat = <fun>

确定一个自然数是偶数还是奇数,我们可以编写一对相互递归的函数:

let rec even = function Zero -> true | Succ m -> odd m
and odd = function Zero -> false | Succ m -> even m
val even : nat -> bool = <fun>
val odd : nat -> bool = <fun>

Summary

列表是 OCaml 中非常有用的内置数据结构。该语言提供了一种轻量级的语法来构建它们,而不是要求您使用库。访问列表的部分利用了模式匹配,这是一个非常强大的特性(正如您可能从其相当冗长的语义中期待的那样)。随着课程的进行,我们将看到模式匹配的更多用途。

这些内置列表是作为单链表实现的。当您的需求超出小到中等大小的列表时,这一点很重要。在长列表上使用递归函数将占用大量堆栈空间,因此尾递归变得重要。如果您尝试处理非常庞大的列表,您可能根本不想使用链表,而是使用一种能更好地利用内存局部性的数据结构。

OCaml 提供了变体(一种类型)、元组和乘积(每种类型)以及选项(可能类型)的数据类型。模式匹配可用于访问这些数据类型的值。模式匹配可用于 let 表达式和函数中。

关联列表结合了列表和元组,创建了字典的轻量级实现。

变体是一种强大的语言特性。它们是在函数式语言中表示数据的主力军。OCaml 变体实际上将几个理论上独立的语言特性合并为一个:和类型、积类型、递归类型以及参数化(多态)类型。其结果是能够表达许多种类的数据,包括列表、选项、树甚至异常。

术语和概念

  • algebraic data type 代数数据类型
  • append 追加
  • association list 关联列表
  • binary trees as variants 二叉树作为变体
  • binding 绑定
  • branch 分支
  • carried data 传输数据
  • catch-all cases 全包式案例
  • cons 缺点
  • constant constructor 常量构造函数
  • constructor 构造函数
  • copying 复制
  • desugaring 解糖
  • each-of type 每个类型
  • exception 例外
  • exception as variants 异常作为变体
  • exception packet 异常数据包
  • exception pattern 异常模式
  • exception value 异常值
  • exhaustiveness 详尽性
  • field 领域
  • head 头部
  • induction 归纳
  • leaf 叶
  • list 清单
  • lists as variants 列为变体
  • maybe type 也许类型
  • mutually recursive functions 相互递归函数
  • natural numbers as variants 自然数作为变量
  • nil 空
  • node 节点
  • non-constant constructor 非常量构造函数
  • one-of type 独特类型
  • options 选项
  • options as variants 选项作为变体
  • order of evaluation 评估顺序
  • pair 对
  • parameterized variant 参数化变体
  • parametric polymorphism 参数多态性
  • pattern matching 模式匹配
  • prepend 添加
  • product type 产品类型
  • record 记录
  • recursion 递归
  • recursive variant 递归变体
  • sharing 分享
  • stack frame 栈帧
  • sum type 求和类型
  • syntactic sugar 语法糖
  • tag 标签
  • tail 尾部
  • tail call 尾调用
  • tail recursion 尾递归
  • test-driven development (TDD) 测试驱动开发(TDD)
  • triple 三重
  • tuple 元组
  • type constructor 类型构造函数
  • type synonym 类型同义词
  • variant 变体
  • wildcard 通配符

进一步阅读

  • Objective Caml 简介,章节 4,5.2,5.3,5.4,6,7,8.1
  • OCaml 从最初开始,第 3、4、5、7、8、10、11 章
  • 现实世界中的 OCaml,第 3、5、6、7 章

Exercises

大多数练习的解决方案都可以获得。2022 年秋季是这些解决方案的首次公开发布。尽管康奈尔大学的学生已经可以获得这些解决方案几年了,但更广泛的传播将揭示可以进行改进的地方是不可避免的。我们很乐意添加或更正解决方案。请通过 GitHub 进行贡献。

Exercise: list expressions [★]

  • 构建一个包含整数 1 到 5 的列表。使用方括号表示法表示列表。
  • 构建相同的列表,但不要使用方括号表示法。而是使用 :: 和 [] 。
  • 再次构建相同的列表。这次,您的答案中必须包含以下表达式: [2; 3; 4] 。使用 @ 运算符,不要使用 :: 。

Exercise: product [★★]

编写一个函数 product ,它返回列表中所有元素的乘积。空列表的所有元素的乘积是 1 。

Exercise: concat [★★]

编写一个函数,将列表中的所有字符串连接起来。空列表中所有字符串的连接是空字符串 "" 。

Exercise: product test [★★]

单元测试您在上面练习中编写的函数 product 。

Exercise: patterns [★★★]

使用模式匹配,编写三个函数,分别针对以下属性。如果输入列表具有该属性,则您的函数应返回 true ,否则返回 false 。

  • 列表的第一个元素是 "bigred"
  • 列表恰好有两个或四个元素;不要使用 length 函数
  • 列表的前两个元素相等

Exercise: library [★★★]

请参考 List 标准库来解决这些练习:

  • 编写一个函数,该函数接受一个 int list 并返回该列表的第五个元素(如果存在)。如果列表少于五个元素,则返回 0 。提示: List.length 和 List.nth 。
  • 编写一个函数,该函数接受一个 int list 并返回按降序排序的列表。提示:使用 List.sort 作为其第一个参数,以及 Stdlib.compare 。

Exercise: library test [★★★]

在前面的练习中,为您编写的每个函数编写一对 OUnit 单元测试。

Exercise: library puzzle [★★★]

  • 编写一个函数,该函数返回列表的最后一个元素。您的函数可以假定列表非空。提示:使用两个库函数,不要编写任何自己的模式匹配代码。
  • 编写一个函数 any_zeros : int list -> bool ,当输入列表至少包含一个 0 时返回 true 。提示:使用一个库函数,不要编写任何自己的模式匹配代码。

您的解决方案将只有一两行代码。

Exercise: take drop [★★★]

  • 编写一个函数 take : int -> 'a list -> 'a list ,使得 take n lst 返回 lst 的前 n 个元素。如果 lst 的元素少于 n 个,则返回所有元素。
  • 编写一个函数 drop : int -> 'a list -> 'a list ,使得 drop n lst 返回 lst 的除了前 n 个元素之外的所有元素。如果 lst 的元素少于 n 个,则返回空列表。

Exercise: take drop tail [★★★★]

修改您的解决方案 take 和 drop ,使其成为尾递归,如果它们还不是的话。在具有大值 n 的长列表上对它们进行测试,看看它们是否会耗尽堆栈空间。要构建长列表,请使用列表部分的 -- 运算符。

Exercise: unimodal [★★★]

编写一个函数 is_unimodal : int list -> bool ,该函数接受一个整数列表并返回该列表是否是单峰的。 单峰列表是一个列表,它单调增加到某个最大值,然后在该值之后单调减少。 两个段(增加或减少)中的一个或两个段可能为空。 常数列表是单峰的,空列表也是单峰的。

Exercise: powerset [★★★]

编写一个函数 powerset : int list -> int list list ,该函数接受一个以列表表示的集合 S,并返回 S 的所有子集。幂集中子集的顺序和子集中元素的顺序都不重要。

提示:考虑这个问题的递归结构。假设你已经有 p ,使得 p = powerset s 。你如何使用 p 来计算 powerset (x :: s) ?

Exercise: print int list rec [★★]

编写一个函数 print_int_list : int list -> unit ,它打印其输入列表,每行一个数字。例如, print_int_list [1; 2; 3] 应该产生以下输出:

1
2
3

这里是一些代码,让您可以开始:

let rec print_int_list = function
| [] -> ()
| h :: t -> (* fill in here *); print_int_list t

Exercise: print int list iter [★★]

编写一个函数 print_int_list' : int list -> unit ,其规范与 print_int_list 相同。在您的解决方案中不要使用关键字 rec ,而是使用 List 模块函数 List.iter 。以下是一些代码,可帮助您开始:

let print_int_list' lst =
  List.iter (fun x -> (* fill in here *)) lst

Exercise: student [★★]

假设以下类型定义:

type student = {first_name : string; last_name : string; gpa : float}

给出具有以下类型的 OCaml 表达式:

  • student
  • student -> string * string (提取学生姓名的函数)
  • string -> string -> float -> student (创建学生记录的函数)

Exercise: pokerecord [★★]

这里是代表几种宝可梦类型的一个变种:

type poketype = Normal | Fire | Water
  • 定义类型 pokemon 为一个记录,具有字段 name (字符串), hp (整数)和 ptype ( poketype )。
  • 创建一个名为 charizard 的记录,类型为 pokemon ,代表一只生命值为 78 的火属性宝可梦。
  • 创建一个名为 squirtle 的记录,类型为 pokemon ,代表一只生命值为 44 的水属性宝可梦。

Exercise: safe hd and tl [★★]

编写一个函数 safe_hd : 'a list -> 'a option ,如果输入列表的头部是 x ,则返回 Some x ,如果输入列表为空,则返回 None 。

另外编写一个函数 safe_tl : 'a list -> 'a list option ,该函数返回列表的尾部,如果列表为空则返回 None 。

Exercise: pokefun [★★★]

编写一个函数 max_hp : pokemon list -> pokemon option ,给定一个 pokemon 列表,找到生命值最高的宝可梦。

Exercise: date before [★★]

定义类似日期的三元组为类型为 int * int * int 的值。类似日期的三元组的示例包括 (2013, 2, 1) 和 (0, 0, 1000) 。日期是一个类似日期的三元组,其第一部分是正年份(即公元年份),第二部分是 1 到 12 之间的月份,第三部分是 1 到 31 之间的日期(根据月份和年份可能是 30、29 或 28)。 (2013, 2, 1) 是一个日期; (0, 0, 1000) 不是。

编写一个函数 is_before ,它接受两个日期作为输入,并计算为 true 或 false 。如果第一个参数是早于第二个参数的日期,则计算结果为 true 。(如果两个日期相同,则结果为 false 。)

您的函数只需要在日期上正确工作,而不是在任意类似日期的三元组上工作。然而,如果您考虑使其适用于任意类似日期的三元组,您可能会发现编写解决方案更容易。例如,如果您忘记输入是否真正是日期,而是简单地编写一个函数声称(例如)2013 年 1 月 100 日在 2013 年 2 月 34 日之前—因为任何一月的日期都在任何二月的日期之前,但一个函数声称 2013 年 1 月 100 日在 2013 年 2 月 34 日之后也是有效的。您可以忽略闰年。

Exercise: earliest date [★★★]

编写一个函数 earliest : (intintint) list -> (int * int * int) option 。如果输入列表为空,则计算结果为 None ,如果日期 d 是列表中最早的日期,则计算结果为 Some d 。提示:使用 is_before 。

与之前的练习一样,您的函数只需要针对日期正确工作,而不是任意类似日期的三元组。

Exercise: assoc list [★]

使用关联列表部分的函数 insert 和 lookup 构建一个将整数 1 映射到字符串“one”,2 映射到“two”,3 映射到“three”的关联列表。查找键 2。查找键 4。

Exercise: cards [★★]

  • 定义一个变体类型 suit ,代表标准 52 张牌中的四种花色,♣ ♦ ♥ ♠。您类型的所有构造函数应为常量。
  • 定义一个类型 rank ,表示一张卡牌可能的等级:2、3、…、10、J、Q、K 或 A。有许多可能的解决方案;您可以自由选择适合您的任何方法。其中一种方法是使 rank 成为 int 的同义词,并假定 J=11,Q=12,K=13,A=1 或 14。另一种方法是使用变体。
  • 定义一个类型 card ,表示一张牌的花色和等级。将其设为一个具有两个字段的记录。
  • 定义几个 card 类型的值:梅花 A、红心 Q、方块 2、黑桃 7。

Exercise: matching [★]

对于下面列表中的每个模式,请给出一个类型为 int option list 的值,该值不匹配该模式且不是空列表,或者解释为什么这是不可能的。

  • Some x :: tl
  • [Some 3110; None]
  • [Some x; _]
  • h1 :: h2 :: tl
  • h :: tl

Exercise: quadrant [★★]

完成下面的 quadrant 函数,根据右侧的图表(摘自维基百科),返回给定 x, y 点所在的象限。位于坐标轴上的点不属于任何一个象限。提示:(a)为整数的符号定义一个辅助函数,(b)与一对进行匹配。

type quad = I | II | III | IV
type sign = Neg | Zero | Pos

let sign (x:int) : sign =
  ...

let quadrant : int*int -> quad option = fun (x,y) ->
  match ... with
    | ... -> Some I
    | ... -> Some II
    | ... -> Some III
    | ... -> Some IV
    | ... -> None

Exercise: quadrant when [★★]

重写象限函数以使用 when 语法。您不需要之前的辅助函数。

let quadrant_when : int*int -> quad option = function
    | ... when ... -> Some I
    | ... when ... -> Some II
    | ... when ... -> Some III
    | ... when ... -> Some IV
    | ... -> None

Exercise: depth [★★]

编写一个函数 depth : 'a tree -> int ,该函数返回从根节点到叶子节点的任意最长路径中节点的数量。例如,空树的深度(简单地 Leaf )为 0 ,上面的树 t 的深度为 3 。提示:有一个库函数 max : 'a -> 'a -> 'a ,它返回相同类型的任意两个值中的最大值。

Exercise: shape [★★★]

编写一个函数 same_shape : 'a tree -> 'b tree -> bool ,用于确定两棵树是否具有相同的形状,而不考虑它们在每个节点携带的值是否相同。提示:使用具有三个分支的模式匹配,其中被匹配的表达式是一对树。

Exercise: list max exn [★★]

编写一个函数 list_max : int list -> int ,它返回列表中的最大整数,如果列表为空则引发 Failure "list_max" 。

Exercise: list max exn string [★★]

编写一个函数 list_max_string : int list -> string ,该函数返回一个包含列表中最大整数的字符串,或者如果列表为空,则返回字符串 "empty" (注意,不是异常 Failure "empty" ,而只是字符串 "empty" )。提示:标准库中的 string_of_int 将执行其名称所暗示的操作。

Exercise: list max exn ounit [★]

编写两个 OUnit 测试来确定您对上面的列表最大异常解决方案在其输入为空列表时是否正确引发异常,以及当该列表非空时是否正确返回输入列表的最大值。

Exercise: is_bst [★★★★]

编写一个函数 is_bst : ('a*'b) tree -> bool ,仅当给定树满足二叉搜索树不变式时返回 true 。编写一个有效的版本,该函数最多访问每个节点一次,有些棘手。提示:编写一个递归辅助函数,该函数接受一个树,要么给出树中的最小值和最大值,要么告诉您树为空,要么告诉您树不满足不变式。您的 is_bst 函数不会是递归的,但会调用您的辅助函数并对结果进行模式匹配。您需要为辅助函数的返回类型定义一个新的变体类型。

Exercise: quadrant poly [★★]

修改您对象限的定义,使用多态变体。您的函数的类型应该变成这样:

val sign : int -> [> `Neg | `Pos | `Zero ]
val quadrant : int * int -> [> `I | `II | `III | `IV ] option

高阶编程

函数在 OCaml 中就像任何其他值一样。这究竟意味着什么?这意味着我们可以将函数作为参数传递给其他函数,我们可以将函数存储在数据结构中,我们可以从其他函数中返回函数作为结果,等等。

高阶函数要么接受其他函数作为输入,要么返回其他函数作为输出(或两者兼有)。高阶函数也被称为函数式,因此使用它们进行编程可以被称为函数式编程——这表明像 OCaml 这样的语言中编程的核心是什么。

高阶函数是从函数式语言中最近引入主流语言的概念之一。Java 8 Streams 库和 Python 2.3 的 itertools 模块就是其中的例子;C++ 也至少从 2011 年开始增加了对高阶函数的支持。

NOTE:

C 语言的专家可能会反对采用这种方法并不是最近才有的。毕竟,C 语言长期以来一直通过函数指针具有进行高阶编程的能力。但是,这种能力还取决于通过传递额外的环境参数来传递函数中变量的值的编程模式,以便通过指针调用函数。正如我们将在后面关于解释器的章节中看到的那样,在函数式语言中(高阶)函数的本质是它们实际上是一种称为闭包的东西,这消除了对额外参数的需求。请记住,问题不在于一种语言中可能计算什么——毕竟一切最终都被编译成机器代码,所以我们可以专门写那种代码——而在于什么是愉快的计算。

在本章中,我们将看到所有的喧嚣是关于什么的。高阶函数使得编写美观、通用、可重用的代码成为可能。

高阶函数

考虑这些整数上的函数 double 和 square :

let double x = 2 * x
let square x = x * x
val double : int -> int = <fun>
val square : int -> int = <fun>

让我们使用这些函数来编写其他函数,使数字变为四倍并将数字提升到四次方:

let quad x = double (double x)
let fourth x = square (square x)
val quad : int -> int = <fun>
val fourth : int -> int = <fun>

这两个函数之间存在明显的相似性:它们的作用是将给定的函数两次应用于一个值。通过将函数作为参数传递给另一个函数 twice ,我们可以抽象出这种功能:

let twice f x = f (f x)
val twice : ('a -> 'a) -> 'a -> 'a = <fun>

函数 twice 是高阶的:它的输入 f 是一个函数。而且——回想一下,所有 OCaml 函数实际上只接受一个参数——它的输出在技术上是 fun x -> f (f x) ,所以 twice 返回一个函数,因此在这方面也是高阶的。

使用 twice ,我们可以以统一的方式实现 quad 和 fourth :

let quad x = twice double x
let fourth x = twice square x
val quad : int -> int = <fun>
val fourth : int -> int = <fun>

抽象原则

以上,我们利用了 quad 和 fourth 之间的结构相似性来节省工作量。诚然,在这个简单的例子中,看起来可能并不需要太多工作。但想象一下,如果 twice 实际上是一个更复杂的函数。那么,如果有人提出了一个更高效的版本,那么所有以它为基础编写的函数(如 quad 和 fourth )都可以从这种效率提升中受益,而无需重新编码。

成为一名优秀的程序员的一部分是识别这种相似之处,并通过创建函数(或其他代码单元)来将它们抽象化。Bruce MacLennan 在他的教材《函数式编程:理论与实践》(1990 年)中将此称为抽象原则。抽象原则建议避免要求某事重复陈述;而是将重复模式提取出来。高阶函数使得这种重构成为可能,因为它们允许我们提取函数并将函数参数化为其他函数。

除了 twice 之外,这里还有一些相对简单的例子,也受到麦克莱南的启发:

应用。我们可以编写一个函数,将其第一个输入应用于其第二个输入:

let apply f x = f x
val apply : ('a -> 'b) -> 'a -> 'b = <fun>

当然,写 apply f 比写 f 要多得多。

管道。我们之前见过的管道运算符是一个高阶函数

let pipeline x f = f x
let (|>) = pipeline
let x = 5 |> double
val pipeline : 'a -> ('a -> 'b) -> 'b = <fun>
val ( |> ) : 'a -> ('a -> 'b) -> 'b = <fun>
val x : int = 10

组合。我们可以编写一个函数来组合另外两个函数:

let compose f g x = f (g x)
val compose : ('a -> 'b) -> ('c -> 'a) -> 'c -> 'b = <fun>

这个函数可以让我们创建一个新的函数,可以多次应用,例如以下:

let square_then_double = compose double square
let x = square_then_double 1
let y = square_then_double 2
val square_then_double : int -> int = <fun>
val x : int = 2
val y : int = 8

两者。我们可以编写一个函数,将两个函数应用于相同的参数,并返回结果的一对:

let both f g x = (f x, g x)
let ds = both double square
let p = ds 3
val both : ('a -> 'b) -> ('a -> 'c) -> 'a -> 'b * 'c = <fun>
val ds : int -> int * int = <fun>
val p : int * int = (6, 9)

条件。我们可以编写一个函数,根据谓词有选择地选择应用哪个函数:

let cond p f g x =
  if p x then f x else g x
val cond : ('a -> bool) -> ('a -> 'b) -> ('a -> 'b) -> 'a -> 'b = <fun>

“Higher Order” 的含义

术语“高阶”在逻辑学和计算机科学中被广泛使用,尽管在所有情况下并不一定具有精确或一致的含义。

在逻辑学中,一阶量化主要涉及全称量词和存在量词。这些让你对某个感兴趣的领域进行量化,比如自然数。但对于任何给定的量化,比如说,被量化的变量代表该领域中的一个个体元素,比如自然数 42。

二阶量化使您能够做一些严格更强大的事情,即对域的属性进行量化。属性是关于个体元素的断言,例如,自然数是偶数,或者它是质数。在某些逻辑中,我们可以将属性与个体的集合等同起来,例如所有偶数自然数的集合。因此,二阶量化通常被认为是对集合的量化。您还可以将属性视为接受一个元素并返回一个布尔值指示元素是否满足属性的函数;这被称为属性的特征函数。

第三阶逻辑将允许对属性的属性进行量化,第四阶逻辑将允许对属性的属性的属性进行量化,依此类推。高阶逻辑指的是所有比一阶逻辑更强大的逻辑;尽管在这个领域的一个有趣结果是所有高阶逻辑都可以用二阶逻辑来表达。

在编程语言中,一阶函数类似地指的是操作单个数据元素(例如字符串、整数、记录、变体等)的函数。而高阶函数可以操作函数,就像高阶逻辑可以对属性进行量化(这些属性类似于函数)。

著名的高阶函数

在接下来的几节中,我们将深入探讨三个最著名的高阶函数:map、filter 和 fold。这些函数可以针对许多数据结构进行定义,包括列表和树。每个函数的基本思想是:

  • map transforms elements
  • filter eliminates elements, and
  • fold combines elements.

Map

这里有两个我们可能想要编写的函数:

(** [add1 lst] adds 1 to each element of [lst]. *)
let rec add1 = function
  | [] -> []
  | h :: t -> (h + 1) :: add1 t

let lst1 = add1 [1; 2; 3]
val add1 : int list -> int list = <fun>
val lst1 : int list = [2; 3; 4]
(** [concat_bang lst] concatenates "!" to each element of [lst]. *)
let rec concat_bang = function
  | [] -> []
  | h :: t -> (h ^ "!") :: concat_bang t

let lst2 = concat_bang ["sweet"; "salty"]
val concat_bang : string list -> string list = <fun>
val lst2 : string list = ["sweet!"; "salty!"]

这两个函数之间有很多相似之处:

  • 它们都与一个列表进行模式匹配。
  • 它们在空列表的基本情况下返回相同的值。
  • 它们在非空列表的情况下都对尾部进行递归。

事实上,它们之间唯一的区别(除了它们的名称)就是它们对于头元素的操作:添加与连接。让我们重写这两个函数,使这种区别更加明显:

(** [add1 lst] adds 1 to each element of [lst]. *)
let rec add1 = function
  | [] -> []
  | h :: t ->
    let f = fun x -> x + 1 in
    f h :: add1 t

(** [concat_bang lst] concatenates "!" to each element of [lst]. *)
let rec concat_bang = function
  | [] -> []
  | h :: t ->
    let f = fun x -> x ^ "!" in
    f h :: concat_bang t
val add1 : int list -> int list = <fun>
val concat_bang : string list -> string list = <fun>

现在这两个函数之间唯一的区别(除了它们的名称之外)就是辅助函数 f 的主体。在这两个函数之间存在如此微小的差异时,为什么要重复所有那些代码呢?我们不妨将一个辅助函数从每个主函数中抽象出来,并将其作为一个参数。

let rec add1' f = function
  | [] -> []
  | h :: t -> f h :: add1' f t

(** [add1 lst] adds 1 to each element of [lst]. *)
let add1 = add1' (fun x -> x + 1)

let rec concat_bang' f = function
  | [] -> []
  | h :: t -> f h :: concat_bang' f t

(** [concat_bang lst] concatenates "!" to each element of [lst]. *)
let concat_bang = concat_bang' (fun x -> x ^ "!")
val add1' : ('a -> 'b) -> 'a list -> 'b list = <fun>
val add1 : int list -> int list = <fun>
val concat_bang' : ('a -> 'b) -> 'a list -> 'b list = <fun>
val concat_bang : string list -> string list = <fun>

但现在 add1' 和 concat_bang' 之间实际上没有任何区别,除了它们的名称。它们是完全重复的代码。甚至它们的类型现在也是相同的,因为关于它们的任何内容都没有提到整数或字符串。我们可能会考虑只保留其中一个,并为其想一个好的新名称。一个可能的选择是 transform ,因为它们通过将函数应用于列表的每个元素来转换列表:

let rec transform f = function
  | [] -> []
  | h :: t -> f h :: transform f t

(** [add1 lst] adds 1 to each element of [lst]. *)
let add1 = transform (fun x -> x + 1)

(** [concat_bang lst] concatenates "!" to each element of [lst]. *)
let concat_bang = transform (fun x -> x ^ "!")
val transform : ('a -> 'b) -> 'a list -> 'b list = <fun>
val add1 : int list -> int list = <fun>
val concat_bang : string list -> string list = <fun>

NOTE:

相反 let add1 lst = transform (fun x -> x + 1) lst 以上我们写道 let add1 = transform (fun x -> x + 1)

这是另一种高阶的方式,但这是我们在部分应用的伪装下已经了解过的。后一种编写函数的方式部分应用 transform 到其两个参数中的一个,从而返回一个函数。该函数绑定到名称 add1 。

事实上,C++库确实调用等效函数 transform 。但是 OCaml 和许多其他语言(包括 Java 和 Python)使用更短的单词“map”,在数学上的意义是函数如何将输入映射到输出。因此,让我们对该名称进行最后一次更改:

let rec map f = function
  | [] -> []
  | h :: t -> f h :: map f t

(** [add1 lst] adds 1 to each element of [lst]. *)
let add1 = map (fun x -> x + 1)

(** [concat_bang lst] concatenates "!" to each element of [lst]. *)
let concat_bang = map (fun x -> x ^ "!")
val map : ('a -> 'b) -> 'a list -> 'b list = <fun>
val add1 : int list -> int list = <fun>
val concat_bang : string list -> string list = <fun>

我们现在已成功应用了抽象原则:通用结构已被分解出来。剩下的清晰地表达了计算,至少对于熟悉 map 的读者来说,这种表达方式不像原始版本那样迅速显而易见。

副作用

源文本:在 OCaml 的标准库中, map 函数已经存在,名称为 List.map ,但与我们上面发现的实现有一个小差异。首先,让我们看看我们自己实现可能存在的问题,然后再看标准库的实现。

在我们讨论异常时,我们已经看到 OCaml 语言规范通常不指定子表达式的评估顺序,并且当前的语言实现通常从右到左进行评估。因此,以下(相当牵强的)代码实际上导致列表元素以看似相反的顺序打印出来:

let p x = print_int x; print_newline(); x + 1

let lst = map p [1; 2]
val p : int -> int = <fun>
2
1
val lst : int list = [2; 3]

这就是为什么:

  • 表达式 map p [1; 2] 的值为 p 1 :: map p [2] 。
  • 该表达式的右侧然后被评估为 p 1 :: (p 2 :: map p []) 。 p 应用于 1 尚未发生。
  • :: 的右侧再次进行评估,得到 p 1 :: (p 2 :: []) 。
  • 然后 p 被应用到 2 ,最后应用到 1 。

这对于那些倾向于认为评估会从左到右进行的人可能会感到惊讶。解决方案是使用 let 表达式,以使函数应用的评估在递归调用之前发生:

let rec map f = function
  | [] -> []
  | h :: t -> let h' = f h in h' :: map f t

let lst2 = map p [1; 2]
val map : ('a -> 'b) -> 'a list -> 'b list = <fun>
1
2
val lst2 : int list = [2; 3]

这就是为什么这样做有效的原因:

  • 表达式 map p [1; 2] 的值为 let h' = p 1 in h' :: map p [2] 。
  • 绑定表达式 p 1 被评估,导致 1 被打印并且 h' 被绑定到 2 。
  • 身体表达 h' :: map p [2] 然后被评估,导致接下来打印 2 。

这就是标准库如何定义 List.map 的方式。我们应该从现在开始使用它,而不是重新定义这个函数。但很好的是,我们已经发现了这个函数的“从零开始”的方式,如果需要的话,我们可以快速地重新编码它。

这次讨论中更重要的教训是,当评估顺序很重要时,我们需要使用 let 来确保它。什么时候它很重要?只有在存在副作用时。打印和异常是我们目前看到的两种情况。稍后我们将添加可变性。

映射和尾递归

敏锐的读者会注意到 map 的实现不是尾递归的。在某种程度上,这是不可避免的。以下是一种诱人但糟糕的方法来创建它的尾递归版本:

let rec map_tr_aux f acc = function
  | [] -> acc
  | h :: t -> map_tr_aux f (acc @ [f h]) t

let map_tr f = map_tr_aux f []

let lst = map_tr (fun x -> x + 1) [1; 2; 3]
val map_tr_aux : ('a -> 'b) -> 'b list -> 'a list -> 'b list = <fun>
val map_tr : ('a -> 'b) -> 'a list -> 'b list = <fun>
val lst : int list = [2; 3; 4]

在某种程度上这是有效的:输出是正确的, map_tr_aux 是尾递归的。微妙的缺陷在于子表达式 acc @ [f h] 。回想一下,追加操作在单链表上是线性时间的。也就是说,如果有 个列表元素,那么追加操作需要时间 。因此,在每次递归调用时我们执行一个 操作。并且会有 次递归调用,每个元素都会有一次。这总共是 的工作量,这是 的。因此,我们实现了尾递归,但代价很高:原本应该是线性时间操作变成了二次时间。

为了解决这个问题,我们可以使用常数时间的 cons 操作,而不是线性时间的 append 操作:

let rec map_tr_aux f acc = function
  | [] -> acc
  | h :: t -> map_tr_aux f (f h :: acc) t

let map_tr f = map_tr_aux f []

let lst = map_tr (fun x -> x + 1) [1; 2; 3]
val map_tr_aux : ('a -> 'b) -> 'b list -> 'a list -> 'b list = <fun>
val map_tr : ('a -> 'b) -> 'a list -> 'b list = <fun>
val lst : int list = [4; 3; 2]

在某种程度上,这是有效的:它是尾递归和线性时间的。这一次不那么微妙的缺陷是输出是颠倒的。当我们从输入列表的前面取出每个元素时,我们将其放在输出列表的前面,但这会颠倒它们的顺序。

NOTE:

要理解为什么会发生逆转,或许可以将输入和输出列表想象成排队的人们:

  • 输入:Alice,Bob。
  • 输出:空。

然后我们将爱丽丝从输入中移除,并将她添加到输出中:

  • 输入:Bob。

  • 输出:爱丽丝。

  • 然后我们将 Bob 从输入中移除,并将他添加到输出中:

  • 输入:空。

  • 输出:Bob,爱丽丝。

问题在于,对于单链表,我们只能在列表的头部进行操作,并且仍然保持常数时间。我们无法将 Bob 移动到输出的末尾,而不让他经过 Alice 和其他可能站在输出中的人。

因此,标准库将此函数称为 List.rev_map ,即一个(尾递归)映射函数,以相反顺序返回其输出。

let rec rev_map_aux f acc = function
  | [] -> acc
  | h :: t -> rev_map_aux f (f h :: acc) t

let rev_map f = rev_map_aux f []

let lst = rev_map (fun x -> x + 1) [1; 2; 3]
val rev_map_aux : ('a -> 'b) -> 'b list -> 'a list -> 'b list = <fun>
val rev_map : ('a -> 'b) -> 'a list -> 'b list = <fun>
val lst : int list = [4; 3; 2]

如果您希望以“正确”的顺序输出,那很简单:只需将 List.rev 应用到它上:

let lst = List.rev (List.rev_map (fun x -> x + 1) [1; 2; 3])
val lst : int list = [2; 3; 4]

由于 List.rev 既是线性时间又是尾递归的,这导致了一个完整的解决方案。我们得到了一个线性时间和尾递归的映射计算。代价是需要通过列表进行两次遍历:一次进行转换,另一次进行反转。对于单链表,我们无法做得比这更高效。当然,还有其他实现列表的数据结构,我们最终会涉及到它们。与此同时,请记住,通常我们不必担心尾递归(也就是说,关于堆栈空间)直到列表有 10,000 个或更多元素。

为什么标准库没有提供这个一体化功能?也许如果有足够的理由,将来会提供。但你可能会发现在你自己的编程中,并不怎么需要它。在许多情况下,我们可以不使用尾递归,或者满足于一个反转的列表。

从这次讨论中可以得出的更重要的教训是,递归函数在时间和空间效率之间可能存在权衡。试图使函数更加空间高效(即尾递归)时,我们可能会意外地使其在渐近时间效率上变得不那么高效(即二次而不是线性),或者如果我们足够聪明,可以保持渐近时间效率不变(即线性),但代价是一个常数因子(即处理两次)。

Map in Other Languages

我们之前提到过,映射的概念存在于许多编程语言中。以下是 Python 的一个示例:

>>> print(list(map(lambda x: x + 1, [1, 2, 3])))
[2, 3, 4]

我们必须使用 list 函数将 map 的结果转换回列表,因为为了效率起见,Python 会根据需要生成 map 输出的每个元素。在这里,我们再次看到“何时进行评估?”这一主题再次出现。

在 Java 中,map 是 Java 8 中添加的 Stream 抽象的一部分。由于没有内置的 Java 语法用于列表或流,因此给出一个示例会更加冗长。在这里,我们使用一个工厂方法 Stream.of 来创建一个流:

jshell> Stream.of(1, 2, 3).map(x -> x + 1).collect(Collectors.toList())
$1 ==> [2, 3, 4]

与 Python 示例中一样,我们必须使用某种方法将流转换回列表。在这种情况下,使用的是 collect 方法。

Filter

假设我们想要从一个列表中筛选出偶数或奇数。以下是一些函数来实现这一目的:

(** [even n] is whether [n] is even. *)
let even n =
  n mod 2 = 0

(** [evens lst] is the sublist of [lst] containing only even numbers. *)
let rec evens = function
  | [] -> []
  | h :: t -> if even h then h :: evens t else evens t

let lst1 = evens [1; 2; 3; 4]
val even : int -> bool = <fun>
val evens : int list -> int list = <fun>
val lst1 : int list = [2; 4]
(** [odd n] is whether [n] is odd. *)
let odd n =
  n mod 2 <> 0

(** [odds lst] is the sublist of [lst] containing only odd numbers. *)
let rec odds = function
  | [] -> []
  | h :: t -> if odd h then h :: odds t else odds t

let lst2 = odds [1; 2; 3; 4]
val odd : int -> bool = <fun>
val odds : int list -> int list = <fun>
val lst2 : int list = [1; 3]

函数 evens 和 odds 几乎是相同的代码:唯一的关键区别是它们应用于头元素的测试。因此,就像我们在上一节中对 map 所做的那样,让我们将该测试提取为一个函数。让我们将该函数命名为 p ,缩写为“predicate”,这是一种测试某事是真还是假的花哨方式:

let rec filter p = function
  | [] -> []
  | h :: t -> if p h then h :: filter p t else filter p t
val filter : ('a -> bool) -> 'a list -> 'a list = <fun>

现在我们可以重新实现我们最初的两个函数:

let evens = filter even
let odds = filter odd
val evens : int list -> int list = <fun>
val odds : int list -> int list = <fun>

这些是多么简单!多么清晰!(至少对熟悉 filter 的读者来说。)

过滤器和尾递归

与 map 一样,我们可以创建 filter 的尾递归版本:

let rec filter_aux p acc = function
  | [] -> acc
  | h :: t -> if p h then filter_aux p (h :: acc) t else filter_aux p acc t

let filter p = filter_aux p []

let lst = filter even [1; 2; 3; 4]
val filter_aux : ('a -> bool) -> 'a list -> 'a list -> 'a list = <fun>
val filter : ('a -> bool) -> 'a list -> 'a list = <fun>
val lst : int list = [4; 2]

再次发现输出是反向的。在这里,标准库做出了与 map 不同的选择。它内置了反转到 List.filter ,实现方式如下:

let rec filter_aux p acc = function
  | [] -> List.rev acc (* note the built-in reversal *)
  | h :: t -> if p h then filter_aux p (h :: acc) t else filter_aux p acc t

let filter p = filter_aux p []
val filter_aux : ('a -> bool) -> 'a list -> 'a list -> 'a list = <fun>
val filter : ('a -> bool) -> 'a list -> 'a list = <fun>

为什么标准库在这一点上对 map 和 filter 有所不同?很好的问题。也许只是从来没有对一个 filter 函数有过时间效率是一个常数倍更好的需求。或者这只是历史意外。

其他语言中的过滤器

再次,过滤器的概念存在于许多编程语言中。在 Python 中是这样的:

>>> print(list(filter(lambda x: x % 2 == 0, [1, 2, 3, 4])))
[2, 4]

在 Java 中:

jshell> Stream.of(1, 2, 3, 4).filter(x -> x % 2 == 0).collect(Collectors.toList())
$1 ==> [2, 4]

Fold

map 函数为我们提供了一种逐个转换列表中每个元素的方式。filter 函数为我们提供了一种逐个决定是保留还是丢弃列表中每个元素的方式。但这两个函数实际上只是逐个查看单个元素。如果我们想要以某种方式结合列表的所有元素呢?这就是 fold 函数的作用。事实证明,它有两个版本,我们将在本节中学习。但首先,让我们看一个相关的函数——实际上不在标准库中——我们称之为 combine。

合并

再写两个函数:

(** [sum lst] is the sum of all the elements of [lst]. *)
let rec sum = function
  | [] -> 0
  | h :: t -> h + sum t

let s = sum [1; 2; 3]
val sum : int list -> int = <fun>
val s : int = 6
(** [concat lst] is the concatenation of all the elements of [lst]. *)
let rec concat = function
  | [] -> ""
  | h :: t -> h ^ concat t

let c = concat ["a"; "b"; "c"]
val concat : string list -> string = <fun>
val c : string = "abc"

与我们在使用map和filter进行类似练习时一样,这些函数共享许多共同的结构。这里的区别是:

  • 空列表的情况返回不同的初始值, 0 vs ""
  • 非空列表的情况使用不同的运算符将头元素与递归调用的结果结合起来, + vs ^ 。

那么我们可以再次应用抽象原则吗?当然可以!但这次我们需要分解出两个参数:一个用于这两个差异中的每一个。

首先,让我们只提取出初始值:

let rec sum' init = function
  | [] -> init
  | h :: t -> h + sum' init t

let sum = sum' 0

let rec concat' init = function
  | [] -> init
  | h :: t -> h ^ concat' init t

let concat = concat' ""
val sum' : int -> int list -> int = <fun>
val sum : int list -> int = <fun>
val concat' : string -> string list -> string = <fun>
val concat : string list -> string = <fun>

现在 sum' 和 concat' 之间唯一真正的区别是用于将头部与尾部的递归调用组合的运算符。该运算符也可以成为我们称之为 combine 的统一函数的参数:

let rec combine op init = function
  | [] -> init
  | h :: t -> op h (combine op init t)

let sum = combine ( + ) 0
let concat = combine ( ^ ) ""
val combine : ('a -> 'b -> 'b) -> 'b -> 'a list -> 'b = <fun>
val sum : int list -> int = <fun>
val concat : string list -> string = <fun>

一种思考 combine 的方式是:

  • 列表中的 [] 值被 init 替换,而
  • 每个 :: 构造函数都被 op 替换。

例如, [a; b; c] 只是 a :: (b :: (c :: [])) 的语法糖。因此,如果我们用 0 替换 [] ,用 (+) 替换 :: ,我们得到 a + (b + (c + 0)) 。这将是列表的总和。

再次,抽象原则让我们得到了一个非常简单而简洁的计算表达。

Fold Right

combine 函数是一个实际 OCaml 库函数的基本思想。为了实现这一点,我们需要对我们目前的实现进行一些更改。

首先,让我们重新命名一些参数:我们将把 op 更改为 f ,以强调我们实际上可以传入任何函数,而不仅仅是像 + 这样的内置运算符。我们将把 init 更改为 acc ,通常代表“累加器”。这样就得到了:

let rec combine f acc = function
  | [] -> acc
  | h :: t -> f h (combine f acc t)
val combine : ('a -> 'b -> 'b) -> 'b -> 'a list -> 'b = <fun>

其次,让我们做一个明显动机较弱的改变。我们将隐式列表参数与 init 参数进行交换:

let rec combine' f lst acc = match lst with
  | [] -> acc
  | h :: t -> f h (combine' f t acc)

let sum lst = combine' ( + ) lst 0
let concat lst = combine' ( ^ ) lst ""
val combine' : ('a -> 'b -> 'b) -> 'a list -> 'b -> 'b = <fun>
val sum : int list -> int = <fun>
val concat : string list -> string = <fun>

以这种方式编写函数会稍微不太方便,因为我们不再能够利用 function 关键字,也不能在定义 sum 和 concat 时利用部分应用。但算法上没有任何改变。

我们现在拥有的是标准库函数 List.fold_right 的实际实现。我们剩下要做的就是更改函数名称:

let rec fold_right f lst acc = match lst with
  | [] -> acc
  | h :: t -> f h (fold_right f t acc)
val fold_right : ('a -> 'b -> 'b) -> 'a list -> 'b -> 'b = <fun>

为什么这个函数被称为“fold right”?直觉是它的工作方式是从右到左“折叠”列表的元素,使用运算符结合每个新元素。例如, fold_right ( + ) [a; b; c] 0 导致表达式 a + (b + (c + 0)) 的评估。括号从最右边的子表达式向左关联。

尾递归和组合

既不是 fold_right 也不是 combine 是尾递归的:在递归调用返回后,仍然需要在应用函数参数 f 或 op 时进行工作。让我们回到 combine 并将其重写为尾递归。所需的只是更改 cons 分支:

let rec combine_tr f acc = function
  | [] -> acc
  | h :: t -> combine_tr f (f acc h) t  (* only real change *)
val combine_tr : ('a -> 'b -> 'a) -> 'a -> 'b list -> 'a = <fun>

细心的读者会注意到 combine_tr 的类型与 combine 的类型不同。我们很快会解决这个问题。

现在函数 f 应用于递归调用之前的头元素 h 和累加器 acc ,从而确保在调用返回后没有剩余的工作需要完成。如果这看起来有点神秘,这里是两个函数的重写,可能会有所帮助:

let rec combine f acc = function
  | [] -> acc
  | h :: t ->
    let acc' = combine f acc t in
    f h acc'

let rec combine_tr f acc = function
  | [] -> acc
  | h :: t ->
    let acc' = f acc h in
    combine_tr f acc' t
val combine : ('a -> 'b -> 'b) -> 'b -> 'a list -> 'b = <fun>
val combine_tr : ('a -> 'b -> 'a) -> 'a -> 'b list -> 'a = <fun>

请密切关注每个版本中 acc' ,即新的累加器的定义:

  • 在原始版本中,我们使用头元素 h 来拖延。首先,我们将所有剩余的尾元素组合在一起得到 acc' 。然后我们才使用 f 将头部合并进去。因此,作为 acc 的初始值传递的值最终对于 combine 的每次递归调用都是相同的:它一直传递到需要的地方,即列表的最右边元素,然后在那里被使用一次。

  • 但在尾递归版本中,我们通过立即将 h 与旧累加器 acc 折叠在一起来“预先处理”。然后我们将其与所有尾元素折叠在一起。因此,在每次递归调用时,作为参数传递的值 acc 可能是不同的。

结合的尾递归版本对求和(和连接,我们省略了)非常有效

let sum = combine_tr ( + ) 0
let s = sum [1; 2; 3]
val sum : int list -> int = <fun>
val s : int = 6

但是减法可能会发生一些令人惊讶的事情:

let sub = combine ( - ) 0
let s = sub [3; 2; 1]

let sub_tr = combine_tr ( - ) 0
let s' = sub_tr [3; 2; 1]
val sub : int list -> int = <fun>
val s : int = 2
val sub_tr : int list -> int = <fun>
val s' : int = -6

这两个结果是不同的!

  • 使用 combine 我们计算 3 - (2 - (1 - 0)) 。首先我们将 1 合并,然后 2 ,最后 3 。我们从右到左处理列表,将初始累加器放在最右边。
  • 但是使用 combine_tr 我们计算 (((0 - 3) - 2) - 1) 。我们从左到右处理列表,将初始累加器放在最左边。

在加法中,我们处理列表的顺序并不重要,因为加法是可交换和结合的。但是减法不是,所以两个方向会得出不同的答案。

实际上,如果我们回想一下我们将 map 设为尾递归时,这并不应该太令人惊讶。然后,我们发现尾递归会导致我们以与同一函数的非尾递归版本相反的顺序处理列表。这就是这里发生的事情。

Fold Left

我们的 combine_tr 函数也在标准库中以 List.fold_left 的名称存在:

let rec fold_left f acc = function
  | [] -> acc
  | h :: t -> fold_left f (f acc h) t

let sum = fold_left ( + ) 0
let concat = fold_left ( ^ ) ""
val fold_left : ('a -> 'b -> 'a) -> 'a -> 'b list -> 'a = <fun>
val sum : int list -> int = <fun>
val concat : string list -> string = <fun>

我们再次成功应用了抽象原则。

Fold Left vs. Fold Right

让我们回顾一下 fold_right 和 fold_left 之间的区别:

  • 它们根据它们的名称以相反的顺序组合列表元素。函数 fold_right 从右到左组合,而 fold_left 从左到右进行。
  • 函数 fold_left 是尾递归的,而 fold_right 不是。
  • 函数的类型是不同的。

关于最后一点,很难记住这些类型是什么!幸运的是,我们总是可以询问顶层:

List.fold_left;;
List.fold_right;;
- : ('a -> 'b -> 'a) -> 'a -> 'b list -> 'a = <fun>
- : ('a -> 'b -> 'b) -> 'a list -> 'b -> 'b = <fun>

要理解这些类型,请查找它们各自的列表参数。这会告诉你列表中值的类型。然后查找返回值的类型;这会告诉你累加器的类型。从那里,你可以推断出其他一切。

  • 在 fold_left 中,列表参数的类型为 'b list ,因此列表包含类型为 'b 的值。返回类型为 'a ,因此累加器的类型为 'a 。知道这一点,我们可以推断第二个参数是累加器的初始值(因为它的类型为 'a )。我们可以推断第一个参数,即组合运算符,将累加器值作为其自己的第一个参数(因为它的类型为 'a ),将列表元素作为其自己的第二个参数(因为它的类型为 'b ),并返回一个新的累加器值。

  • 在 fold_right 中,列表参数的类型为 'a list ,因此列表包含类型为 'a 的值。返回类型为 'b ,因此累加器的类型为 'b 。知道这一点,我们可以推断第三个参数是累加器的初始值(因为它的类型为 'b )。我们可以推断第一个参数,即组合运算符,将累加器值作为自己的第二个参数(因为它的类型为 'b ),将列表元素作为自己的第一个参数(因为它的类型为 'a ),并返回一个新的累加器值。

TIP:

你可能会想为什么这两个 fold 函数的参数顺序不同。很好的问题。事实上,其他库确实使用不同的参数顺序。在 OCaml 中记住这一点的一种方法是,在 fold_X 中,累加器参数放在列表参数的 X 位置。

如果您发现很难跟踪所有这些参数顺序,标准库中的 ListLabels 模块可以提供帮助。它使用带标签的参数为组合运算符(称为 f )和初始累加器值(称为 init )命名。在内部,实现实际上与 List 模块完全相同。

ListLabels.fold_left;;
ListLabels.fold_left ~f:(fun x y -> x - y) ~init:0 [1;2;3];;
- : f:('a -> 'b -> 'a) -> init:'a -> 'b list -> 'a = <fun>
- : int = -6
ListLabels.fold_right;;
ListLabels.fold_right ~f:(fun y x -> x - y) ~init:0 [1;2;3];;
- : f:('a -> 'b -> 'b) -> 'a list -> init:'b -> 'b = <fun>
- : int = -6

请注意,在上述两个 fold 的应用中,由于它们的标签,我们能够以统一的顺序编写参数。然而,我们仍然需要注意将列表元素与累加器值作为组合运算符的哪个参数。

A Digression on Labeled Arguments and Fold

我们可以编写自己版本的 fold 函数,该函数将为组合运算符的参数加上标签,因此我们甚至不必记住它们的顺序:

let rec fold_left ~op:(f: acc:'a -> elt:'b -> 'a) ~init:acc lst =
  match lst with
  | [] -> acc
  | h :: t -> fold_left ~op:f ~init:(f ~acc:acc ~elt:h) t

let rec fold_right ~op:(f: elt:'a -> acc:'b -> 'b) lst ~init:acc =
  match lst with
  | [] -> acc
  | h :: t -> f ~elt:h ~acc:(fold_right ~op:f t ~init:acc)
val fold_left : op:(acc:'a -> elt:'b -> 'a) -> init:'a -> 'b list -> 'a =
  <fun>
val fold_right : op:(elt:'a -> acc:'b -> 'b) -> 'a list -> init:'b -> 'b =
  <fun>

但这些功能并不像它们看起来那么有用:

let s = fold_left ~op:( + ) ~init:0 [1;2;3]
File "[17]", line 1, characters 22-27:
1 | let s = fold_left ~op:( + ) ~init:0 [1;2;3]
                          ^^^^^
Error: This expression has type int -> int -> int
       but an expression was expected of type acc:'a -> elt:'b -> 'a

问题在于内置的 + 运算符没有标记参数,因此我们无法将其作为组合运算符传递给我们的带标签函数。我们必须定义自己的带标签版本

let add ~acc ~elt = acc + elt
let s = fold_left ~op:add ~init:0 [1; 2; 3]

但现在我们必须记住, ~acc 参数将成为 add 的左手参数。这并没有比我们一开始必须记住的要好多少。

使用 Fold 实现其他函数

折叠是如此强大,以至于我们可以用 fold_left 或 fold_right 来编写许多其他列表函数。例如,

let length lst =
  List.fold_left (fun acc _ -> acc + 1) 0 lst

let rev lst =
  List.fold_left (fun acc x -> x :: acc) [] lst

let map f lst =
  List.fold_right (fun x acc -> f x :: acc) lst []

let filter f lst =
  List.fold_right (fun x acc -> if f x then x :: acc else acc) lst []
val length : 'a list -> int = <fun>
val rev : 'a list -> 'a list = <fun>
val map : ('a -> 'b) -> 'a list -> 'b list = <fun>
val filter : ('a -> bool) -> 'a list -> 'a list = <fun>

在这一点上,开始有争议,是更好地使用折叠来表达上面的计算,还是使用我们已经看到的方法。即使对于经验丰富的函数式程序员来说,理解折叠的作用可能比阅读天真的递归实现更耗时。如果您查阅标准库的源代码,您会发现 List 模块内部没有一个是使用折叠来实现的,这或许是对折叠可读性的一种评价。另一方面,使用折叠确保程序员不会意外地错误地编写递归遍历。对于比列表更复杂的数据结构,这种健壮性可能是一种胜利。

Fold vs. Recursive vs. Library

我们现在已经看到了三种不同的编写操作列表的函数的方法:

  • 直接作为一个递归函数,该函数模式匹配空列表和 cons
  • 使用 fold 函数
  • 使用其他库函数。

让我们尝试使用这些方法来解决问题,这样我们就能更好地欣赏它们。

考虑编写一个函数 lst_and: bool list -> bool ,使得 lst_and [a1; ...; an] 返回列表的所有元素是否都是 true 。也就是说,它的评估结果与 a1 && a2 && ... && an 相同。当应用于空列表时,它的评估结果为 true 。

以下是编写此类函数的三种可能方式。为了清晰起见,我们为每种方式赋予略有不同的函数名称。

let rec lst_and_rec = function
  | [] -> true
  | h :: t -> h && lst_and_rec t

let lst_and_fold =
	List.fold_left (fun acc elt -> acc && elt) true

let lst_and_lib =
	List.for_all (fun x -> x)
val lst_and_rec : bool list -> bool = <fun>
val lst_and_fold : bool list -> bool = <fun>
val lst_and_lib : bool list -> bool = <fun>

所有三个函数的最坏运行时间与列表的长度成线性关系。但是:

  • 第一个函数, lst_and_rec 的优点在于它不需要处理整个列表。一旦在列表中发现一个 false 元素,它将立即返回 false 。
  • 第二个函数, lst_and_fold ,将始终处理列表的每个元素。
  • 至于第三个功能 lst_and_lib ,根据 List.for_all 的文档,它返回 (p a1) && (p a2) && ... && (p an) 。因此,与 lst_and_rec 一样,它不需要处理每个元素。

Beyond Lists

像 map 和 fold 这样的功能并不局限于列表。它们对几乎任何类型的数据收集都是有意义的。例如,回想一下这棵树的表示:

type 'a tree =
  | Leaf
  | Node of 'a * 'a tree * 'a tree
type 'a tree = Leaf | Node of 'a * 'a tree * 'a tree

Map on Trees

这很容易。我们只需要在每个节点上将函数 f 应用于值 v 。

let rec map_tree f = function
  | Leaf -> Leaf
  | Node (v, l, r) -> Node (f v, map_tree f l, map_tree f r)
val map_tree : ('a -> 'b) -> 'a tree -> 'b tree = <fun>

Fold on Trees

这个稍微难一点。让我们为 'a tree 开发一个类似于我们在 'a list 上的 fold_right 的折叠功能。一种思考 List.fold_right 的方式是列表中的 [] 值被 acc 参数替换,每个 :: 构造函数被 f 参数的应用所替换。例如, [a; b; c] 是 a :: (b :: (c :: [])) 的语法糖。因此,如果我们用 0 替换 [] ,用 ( + ) 替换 :: ,我们得到 a + (b + (c + 0)) 。沿着这些思路,这是我们可以重写 fold_right 的一种方式,这将帮助我们更清晰地思考一下:

type 'a mylist =
  | Nil
  | Cons of 'a * 'a mylist

let rec fold_mylist f acc = function
  | Nil -> acc
  | Cons (h, t) -> f h (fold_mylist f acc t)
type 'a mylist = Nil | Cons of 'a * 'a mylist
val fold_mylist : ('a -> 'b -> 'b) -> 'b -> 'a mylist -> 'b = <fun>

算法是相同的。我们所做的只是改变了列表的定义,使用用字母字符编写的构造函数,而不是标点符号,并改变了 fold 函数的参数顺序。

对于树,我们希望将 acc 的初始值替换每个 Leaf 构造函数,就像它在列表中替换 [] 一样。我们希望每个 Node 构造函数被操作符替换。但现在操作符将需要是三元的而不是二元的 - 也就是说,它需要接受三个参数而不是两个 - 因为树节点有一个值,一个左子节点和一个右子节点,而列表的 cons 只有一个头部和一个尾部。

受到这些观察的启发,这里是树的折叠函数:

let rec fold_tree f acc = function
  | Leaf -> acc
  | Node (v, l, r) -> f v (fold_tree f acc l) (fold_tree f acc r)
val fold_tree : ('a -> 'b -> 'b -> 'b) -> 'b -> 'a tree -> 'b = <fun>

如果将该函数与 fold_mylist 进行比较,您会注意到它们几乎是相同的。第二个模式匹配分支中只多了一个递归调用,对应于在该类型定义中 'a tree 的一个额外出现。

我们可以使用 fold_tree 来实现我们之前看到的一些树函数:

let size t = fold_tree (fun _ l r -> 1 + l + r) 0 t
let depth t = fold_tree (fun _ l r -> 1 + max l r) 0 t
let preorder t = fold_tree (fun x l r -> [x] @ l @ r) [] t
val size : 'a tree -> int = <fun>
val depth : 'a tree -> int = <fun>
val preorder : 'a tree -> 'a list = <fun>

为什么我们选择 fold_right 而不是 fold_left 进行这个开发?因为 fold_left 是尾递归的,这是我们永远无法在二叉树上实现的。假设我们首先处理左分支;然后我们仍然必须在返回之前处理右分支。因此,在对一个分支进行递归调用后,总会有剩余的工作要做。因此,在树上, fold_right 的等价物是我们所能期望的最好的。

我们用来推导 fold_tree 的技术适用于任何 OCaml 变体类型 t :

  • 编写一个递归 fold 函数,该函数接受 t 的每个构造函数的一个参数。
  • 该 fold 函数与构造函数匹配,在遇到任何类型为 t 的值时会递归调用自身。
  • 使用 fold 的适当参数来组合所有递归调用的结果,以及在每个构造函数中不属于 t 类型的所有数据。

这种技术构建了一种称为 catamorphism 的东西,又称为广义折叠操作。要了解更多关于 catamorphisms 的信息,请参加范畴论课程。

Filter on Trees

这可能是最难设计的一个。问题是:如果我们决定过滤一个节点,那么我们应该怎么处理它的子节点?

  • 我们可以对子节点进行递归。如果在筛选它们后只剩下一个子节点,我们可以将其提升为其父节点的位置。但如果两个子节点都保留下来,或者一个都没有呢?那么我们就必须以某种方式重塑树形结构。如果不了解树应该如何使用——也就是说,它代表什么样的数据,我们就会陷入困境。
  • 相反,我们可以完全消除子节点。因此,过滤节点的决定意味着修剪以该节点为根的整个子树。

后者易于实施:

let rec filter_tree p = function
  | Leaf -> Leaf
  | Node (v, l, r) ->
    if p v then Node (v, filter_tree p l, filter_tree p r) else Leaf
val filter_tree : ('a -> bool) -> 'a tree -> 'a tree = <fun>

Pipelining

假设我们想要计算从 0 到 n 的数字的平方和。我们该如何做呢?当然(数学是最佳的优化形式),最有效的方式是使用一个封闭形式的公式: n(n+1)(2n+1) / 6

但是让我们想象一下,你忘记了那个公式。在一种命令式语言中,你可以使用一个 for 循环:

# Python
def sum_sq(n):
	sum = 0
	for i in range(0, n+1):
		sum += i * i
	return sum

在 OCaml 中,等价的(尾)递归代码将是:

let sum_sq n =
  let rec loop i sum =
    if i > n then sum
    else loop (i + 1) (sum + i * i)
  in loop 0 0
val sum_sq : int -> int = <fun>

在 OCaml 中产生相同结果的另一种更清晰的方法是使用高阶函数和管道操作符:

let rec ( -- ) i j = if i > j then [] else i :: i + 1 -- j
let square x = x * x
let sum = List.fold_left ( + ) 0

let sum_sq n =
  0 -- n              (* [0;1;2;...;n]   *)
  |> List.map square  (* [0;1;4;...;n*n] *)
  |> sum              (*  0+1+4+...+n*n  *)
val ( -- ) : int -> int -> int list = <fun>
val square : int -> int = <fun>
val sum : int list -> int = <fun>
val sum_sq : int -> int = <fun>

函数 sum_sq 首先构造一个包含所有数字 0..n 的列表。然后它使用管道运算符 |> 将该列表传递给 List.map square ,该操作对每个元素进行平方。然后将结果列表通过 sum 进行管道传递,该操作将所有元素相加。

您可能考虑的其他选择有些更加丑陋:

(* Maybe worse: a lot of extra [let..in] syntax and unnecessary names to
   for intermediate values we don't care about. *)
let sum_sq n =
  let l = 0 -- n in
  let sq_l = List.map square l in
  sum sq_l

(* Maybe worse: have to read the function applications from right to left
   rather than top to bottom, and extra parentheses. *)
let sum_sq n =
  sum (List.map square (0--n))
val sum_sq : int -> int = <fun>
val sum_sq : int -> int = <fun>

与原始的尾递归版本相比,所有这些方法的缺点是它们浪费空间——是线性的而不是常数的——并且需要更多的时间。因此,就像在编程中经常发生的情况一样,代码的清晰度和效率之间存在权衡。

请注意,低效并不是由于管道操作符本身,而是由于需要构建所有那些不必要的中间列表。因此不要认为流水线处理本质上是不好的。事实上,它可以非常有用。当我们到达关于模块的章节时,我们将经常在那里学习的一些数据结构中使用它。

Currying

我们已经看到,一个接受类型为 t1 和 t2 的两个参数并返回类型为 t3 的值的 OCaml 函数具有类型 t1 -> t2 -> t3 。在 let 表达式中函数名后面我们使用两个变量:

let add x y = x + y
val add : int -> int -> int = <fun>

定义一个接受两个参数的函数的另一种方法是编写一个接受元组的函数:

let add' t = fst t + snd t
val add' : int * int -> int = <fun>

不使用 fst 和 snd ,我们可以在函数定义中使用元组模式,从而导致第三种实现:

let add'' (x, y) = x + y
val add'' : int * int -> int = <fun>

使用第一种风格编写的函数(带有类型 t1 -> t2 -> t3 )称为柯里化函数,而使用第二种风格(带有类型 t1 * t2 -> t3 )的函数称为非柯里化函数。比喻地说,柯里化函数更“辛辣”,因为您可以部分应用它们(这是您无法对非柯里化函数做到的:您无法传入一对的一半)。实际上,“柯里”一词并不是指香料,而是指逻辑学家 Haskell Curry(他是极少数以名字和姓氏命名编程语言的人之一)。

有时候你会遇到一些库提供了一个非柯里化版本的函数,但你想要一个柯里化版本来在自己的代码中使用;或者反过来。因此,了解如何在这两种函数之间进行转换是很有用的,就像我们在上面使用 add 时所做的那样。

您甚至可以编写一对高阶函数来为您进行转换:

let curry f x y = f (x, y)
let uncurry f (x, y) = f x y
val curry : ('a * 'b -> 'c) -> 'a -> 'b -> 'c = <fun>
val uncurry : ('a -> 'b -> 'c) -> 'a * 'b -> 'c = <fun>
let uncurried_add = uncurry add
let curried_add = curry add''
val uncurried_add : int * int -> int = <fun>
val curried_add : int -> int -> int = <fun>

Summary

本章是本书中最重要的章节之一。它没有涵盖任何新的语言特性。相反,我们学习了如何以可能是新的、令人惊讶的或具有挑战性的方式使用一些现有特性。高阶编程和抽象原则是两个概念,将帮助您成为任何语言中更好的程序员,而不仅仅是 OCaml。当然,各种语言在支持这些概念的程度上有所不同,有些语言在编写高阶代码时提供的帮助明显较少,这也是我们在本课程中使用 OCaml 的原因之一。

地图、过滤器、折叠和其他函数式正逐渐被广泛认可为优秀的计算结构化方式。部分原因在于它们将对数据结构的迭代与对每个元素进行的计算分离开来。诸如 Python、Ruby 和 Java 8 等语言现在已经支持这种迭代方式。

术语和概念

  • Abstraction Principle 抽象原则
  • accumulator 累加器
  • apply 应用
  • associative 关联的
  • compose 组成
  • factor 因素
  • filter 过滤器
  • first-order function 一阶函数
  • fold 折叠
  • functional 功能性
  • generalized fold operation 广义折叠操作
  • higher-order function 高阶函数
  • map 地图
  • pipeline 管道
  • pipelining 流水线化

进一步阅读

  • Introduction to Objective Caml -- Objective Caml 简介,第 3.1.3 章,第 5.3 章
  • OCaml from the Very Beginning -- OCaml 从零开始,第 6 章
  • More OCaml: Algorithms, Methods, and Diversions --《更多 OCaml:算法、方法和娱乐》,第 1 章,约翰·惠廷顿著。这本书是《OCaml from the Very Beginning -- OCaml 从零开始》的续集。
  • Real World OCaml --- 现实世界的 OCaml,第 3 章(请注意,这本书的 Core 库与标准库的 List 模块不同, map 和 fold 的类型也与我们在这里看到的不同)
  • “Higher Order Functions”,《函数式编程:实践与理论》第 6 章。Bruce J. MacLennan,Addison-Wesley,1990 年。我们对高阶函数和抽象原则的讨论受到了这一章的启发。
  • “编程能否摆脱冯·诺依曼风格?一种函数式风格及其程序代数。”约翰·巴克斯(John Backus)1977 年图灵奖演讲的详细形式,作为一篇发表的文章
  • 斯坦福哲学百科全书》中的“二阶和高阶逻辑”。

Exercises

大多数练习的解决方案都可以获得。2022 年秋季是这些解决方案的首次公开发布。尽管康奈尔大学的学生已经可以获得这些解决方案几年了,但更广泛的传播将揭示可以进行改进的地方是不可避免的。我们很乐意添加或更正解决方案。请通过 GitHub 进行贡献。

Exercise: twice, no arguments [★]

考虑以下定义:

let double x = 2 * x
let square x = x * x
let twice f x = f (f x)
let quad = twice double
let fourth = twice square

使用 toplevel 确定 quad 和 fourth 的类型。解释为什么 quad 不是以语法形式编写为接受参数的函数,但其类型显示它实际上是一个函数。

Exercise: mystery operator 1 [★★]

以下运算符是做什么用的?

let ( $ ) f x = f x

提示:调查 square $ 2 + 2 与 square 2 + 2 之间的差异。

Exercise: mystery operator 2 [★★]

以下运算符是做什么用的?

let ( @@ ) f g x = x |> g |> f

提示:研究 String.length @@ string_of_int 应用于 1 , 10 , 100 等。

Exercise: repeat [★★]

将 twice 概括为一个函数 repeat ,使得 repeat f n x 将 f 应用于 x 共 n 次。也就是说,

  • repeat f 0 x yields x
  • repeat f 1 x yields f x
  • repeat f 2 x yields f (f x) (which is the same as twice f x)
  • repeat f 3 x yields f (f (f x))
  • and so on.

Exercise: product [★]

使用 fold_left 编写一个函数 product_left ,用于计算一组浮点数的乘积。空列表的乘积是 1.0 。提示:回想一下我们在讲座中如何用一行代码实现 sum 。

使用 fold_right 编写一个函数 product_right ,用于计算一个浮点数列表的乘积。相同的提示适用。

Exercise: terse product [★★]

你能将对产品练习的解决方案简化到多少?提示:每个解决方案只需要一行代码,且不需要使用 fun 关键字。对于 fold_left ,你的函数定义甚至不需要显式地接受一个列表参数。如果你使用 ListLabels ,对于 fold_right 也是同样的情况。

Exercise: sum_cube_odd [★★]

编写一个函数 sum_cube_odd n ,计算介于 0 和 n 之间(包括 0 和 n )所有奇数的立方和。不要编写任何新的递归函数。而是使用函数式编程中的 map、fold 和 filter,以及管道化讨论中定义的 ( -- ) 运算符。

Exercise: sum_cube_odd pipeline [★★]

重写函数 sum_cube_odd 以使用管道运算符 |> 。

Exercise: exists [★★]

考虑编写一个函数 exists: ('a -> bool) -> 'a list -> bool ,使得 exists p [a1; ...; an] 返回列表中至少有一个元素满足谓词 p 的情况。也就是说,它的评估结果与 (p a1) || (p a2) || ... || (p an) 相同。当应用于空列表时,它的评估结果为 false 。

写出三个解决这个问题的方法,就像我们之前所做的那样:

  • exists_rec 必须是一个递归函数,不能使用 List 模块
  • exists_fold ,使用 List.fold_left 或 List.fold_right ,但不使用任何其他 List 模块函数,也不使用 rec 关键字
  • exists_lib ,使用除 fold_left 或 fold_right 之外的任何 List 模块函数的组合,并且不使用 rec 关键字。

Exercise: account balance [★★★]

编写一个函数,给定一个代表借方的数字列表,从账户余额中扣除它们,最后返回余额中剩余的金额。编写三个版本: fold_left , fold_right ,以及一个直接的递归实现。

Exercise: library uncurried [★★]

这是 List.nth 的非柯里化版本:

let uncurried_nth (lst, n) = List.nth lst n

以类似的方式,编写这些库函数的非柯里化版本:

  • List.append
  • Char.compare
  • Stdlib.max

Exercise: map composition [★★★]

展示如何用一个调用 List.map 仅一次的等效表达式替换任何形式为 List.map f (List.map g lst) 的表达式。

Exercise: more list fun [★★★]

编写执行以下计算的函数。 您编写的每个函数都应该使用 List.fold , List.map 或 List.filter 中的一个。 要选择使用哪个,请考虑计算正在做什么:组合、转换或过滤元素。

  • 找出字符串列表中长度严格大于 3 的元素。
  • 将 1.0 添加到浮点数列表的每个元素。
  • 给定一个字符串列表 strs 和另一个字符串 sep ,生成包含 strs 的每个元素以 sep 分隔的字符串。例如,给定输入 ["hi";"bye"] 和 "," ,生成 "hi,bye" ,确保不会在结果字符串的开头或结尾产生额外的逗号。

Exercise: association list keys [★★★]

回想一下,关联列表是一种字典的实现,它是一对列表,我们将每对中的第一个组件视为键,第二个组件视为值。

编写一个函数 keys: ('a * 'b) list -> 'a list ,该函数返回一个关联列表中唯一键的列表。由于它们必须是唯一的,在输出列表中不应出现多次相同的值。输出值的顺序不重要。您能使解决方案多么紧凑和高效?您能在一行中以线性对数空间和时间完成吗?提示: List.sort_uniq 。

Exercise: valid matrix [★★★]

数学矩阵可以用列表表示。在行主表示中,这个矩阵

[ [1; 1; 1]; [9; 8; 7]]

将其表示为列表 [[1; 1; 1]; [9; 8; 7]] 。让我们将行向量表示为 int list 。例如, [9; 8; 7] 是一个行向量。

一个有效的矩阵是一个至少有一行、至少有一列,并且每列具有相同行数的 int list list 。有许多 int list list 类型的无效值,例如,

  • []
  • [[1; 2]; [3]]

Modular Programming

Module Systems

Modules

Modules and Toplevel

Encapsulation

Functional Data Structures

Module Type Constrains

Includes

Functors

Summary

Exercises

Correctness

specifications

Function Documentation

Module Doucumentation

Testing And Debugging

Black-box and Glass-box Testing

Randomized Testing with QCheck

Proving Correctness

Structural Induction

Algebraic Specification

Summary

Exercises

Mutability

Refs

Mutable Fields

Arrays And Loops

Summary

Exercises

Data Structures

Hash Tables

Amortized Analysis

Red-Black Trees

Sequences

Memoization

Promises

Monads

Summary

Exercises

Interpreters

Example: Calculator

Parsing

Substitution Model

Environment Model

Type Checking

Type Inference

Summary

Exercises

The Curry-Howard

Big-Oh Notation

Virtual Machine