康奈尔-CSS10-OCaml-编程笔记-全-

康奈尔 CSS10 OCaml 编程笔记(全)

001:引言

在本节课中,我们将要学习OCaml函数式编程的入门知识,了解本系列课程的目标、背景以及如何开始学习。

大家好,我是Michael Clarkson博士。欢迎来到OCaml函数式编程课程。

我的主要工作是教授大学生如何编写正确高效优美的程序。

在过去的几年里,我在康奈尔大学的CS3110课程(数据结构与函数式编程)中,已经向大约3700名学生教授了OCaml。

您可能通过多种方式接触到这个视频。或许您目前就是CS3110课程的学生。或许您并非康奈尔的学生,但希望学习一些函数式编程的知识。或许您是通过嵌入了此视频的在线教科书找到这里的,又或者您是先发现了这个视频。如果是后者,请在下方评论区查找教科书的最新链接。

我编写这本教科书已有数年时间,它基于我和其他教职员工在过去二十多年里撰写的课程讲义。

除了教授函数式编程,我们的长期目标一直是教导学生成为优秀的程序员,为他们提供超越常规计算机科学入门课程基础知识所需的知识、技能和习惯。

然而,这些视频是全新的。它们的出现源于疫情。

在2020至2021学年,我以异步方式教授了我的课程,这意味着这些视频必须取代通常的面对面讲座。

因此,我最终制作了超过200个短视频,大部分时长在五到六分钟左右。

学生们的反应非常积极。

所以现在,我将这些视频公开,供任何希望学习OCaml、函数式编程以及想成为更好程序员的人使用。我希望您觉得它们有用,甚至有趣。这些视频是我写给所有在Zoom时代坚持下来的学生们的一封情书。我们一起做到了。现在,随着我们传播函数式编程的光芒,世界或许能变得更好一点。

本节课中我们一起学习了本系列课程的背景、目标以及获取学习资源的途径。在接下来的课程中,我们将深入探索OCaml和函数式编程的核心概念。

002:函数式编程基础 🧮

在本节课中,我们将要学习函数式编程的核心概念,特别是它与我们熟悉的命令式编程(如Java、Python)有何不同。我们将重点理解状态可变性以及函数式编程如何通过不可变性来简化我们的思维。

什么是函数式语言?🤔

函数式语言并非一个精确定义,而更像一个连续谱系上的概念。目前,我们可以这样理解:函数式语言是一种将计算定义为数学函数,并避免可变状态的语言

那么,什么是“状态”呢?状态指的是计算过程中所维护的信息。

理解状态与可变性

上一节我们介绍了状态的概念,本节中我们来看看什么是可变状态。

状态是计算所维护的信息。例如,在Java中构建一个链表类时,它会维护一些信息,比如链表中有多少个元素,或者链表的首尾节点是哪些。这些就是被维护的状态。

或者,在实现迪杰斯特拉算法时,你需要探索以找到最近的节点及其最短路径。该算法会维护一个“边界集”作为其状态。

可变状态是指可以被改变的状态。在计算的一个瞬间,状态是一种样子;在另一个瞬间,状态已经演变并发生了改变。这种改变就是可变性的本质。可变的反义词是不可变

因此,函数式语言避免可变状态,它们拥抱不可变性,并通过将计算表达为数学函数来实现这一点。

可变性的幻想与现实

可变性是你可能早已习以为常的概念,并且它非常具有诱惑力。

可变性的幻想在于它似乎易于推理。你会想,机器先做这个,然后做那个,如此这般,只是一个线性发生的动作序列。

然而,可变性的现实是双重的。

首先,机器确实非常擅长对状态进行复杂的操作,但我们人类的大脑并不擅长。机器为此而生,其内部的处理器使之成为可能(你将在CS 3410课程中深入学习)。

其次,可变性真正的问题在于,用一个专业术语来说,它破坏了引用透明性

引用透明性与命令式编程

引用透明性指的是能够用一个表达式的值来替换该表达式,而不改变计算的结果。引用透明性使人类更容易推理计算,因为我们可以简单地将一个东西替换为另一个。

可变状态破坏了这一点,因为一个变量的值可以从一个时刻变化到另一个时刻。而这正是命令式编程中发生的情况。

命令式编程可以说是函数式编程在谱系上的另一端。命令式编程是你通过Python或Java课程所熟悉和喜爱的编程范式。

在命令式程序中,我们有命令。这些命令通过破坏性地改变状态来指定如何计算。

以下是三个此类命令的例子:

  • x = x + 1
  • 数组更新,如 a[i] = 42
  • 指针更改,如 p.next = p.next.next

没有一个自尊的数学家会写出 x = x + 1 这样的等式,因为它不可能成立,但程序员却经常这样写。这是对状态的一种破坏性改变。我们实际上是在取内存中的一些位,并用其他位替换它们,从而改变了名称X的含义。

从计算的一个瞬间到另一个瞬间,这些命令都在改变状态。它们是在指示计算机对我已命名的内存中的某些位进行更新。

副作用

命令式编程中出现的另一个现象是所谓的副作用。关于副作用有很多定义,这里我们采用一个简单的。

以下是一个你可以视为函数或方法(暂时将其视为方法)的例子,它具有副作用:

int increment(int x) {
    x = x + 1;
    return x;
}

通过修改在其外部声明的变量(这里参数x可以视为外部传入的引用或值,取决于语言,但概念是修改了输入或外部状态),当你调用这个方法increment(x)时,它会给x加1并返回。除了返回值之外,它还改变了状态中的某些东西。这种改变就是其副作用。

函数式编程:表达式与不可变性

在函数式程序中,我们拥有的不是命令,更多的是表达式

表达式不说明如何计算,而是说明要计算什么

因此,在函数式程序中:

  • 变量永不改变其值。这使得称它们为“变量”本身就值得怀疑,但这是你从其他编程课程中熟悉和喜爱的词,所以此刻我不打算改变它。也许我们更应称它们为“标识符”。总之,在函数式语言中,变量一旦拥有一个值,就不会改变。
  • 函数永远不会产生副作用

不可变性的现实在于它解放了作为程序员的你。它让你的工作更轻松,因为你不再需要思考状态及其如何变化。如果你曾经不得不调试一个涉及指针的程序,你就明白我所说的痛苦。在函数式世界中,这种痛苦不复存在。

这为你提供了非常强大的方式来编写和构建正确的程序。正因如此,函数式编程的概念已经在编程世界的某些领域牢牢扎根,这些领域你可能没想到会看到它们,例如Web编程,或构建在拥有多个处理器的机器上运行的并发程序。

总结

本节课中,我们一起学习了函数式编程的基础。我们理解了状态可变性的区别,看到了命令式编程如何通过命令副作用来操作可变状态,以及这如何破坏了引用透明性。相反,函数式编程强调不可变性,使用表达式来描述计算,避免了状态变化和副作用,从而使程序更易于推理和构建,特别是在复杂或并发的场景下。

003:学习函数式编程的理由(第一部分)🎯

在本节课中,我们将探讨学习函数式编程的几个重要理由。理解这些理由有助于我们认识函数式编程的价值,并激发学习兴趣。

拓宽编程视野 🌍

第一个理由是,函数式语言让你认识到编程超越了特定语言的范畴。

如果你之前只使用过命令式语言,那么探索编程世界的另一面,了解其运作方式,对你而言是必要的。同样,如果一开始就学习函数式编程,我也会建议你去学习一门命令式语言。两者都至关重要。

让我用一个学习外语的类比来说明。

学习外语除了掌握语言本身,还有许多益处。你可以了解另一种文化,或许会发现值得欣赏和热爱的新事物,并将其融入自己的生活。它可以帮助你审视自己的先入之见,通过更好地理解他人来减少偏见。你甚至可以通过学习外语来更好地理解自己的母语。

我在高中学习西班牙语时就经历了这种情况。在那之前,我从未理解如何在英语中使用虚拟语气。直到我必须在西班牙语中学习它,我才在英语中更好地掌握了它。

让我引用艾伦·佩利斯的一句话。他是我所知道的最多被引用的计算机科学家之一。他说:“一门不影响你编程思维方式的语言,不值得学习。

我向你保证,学习一门函数式语言将影响你的编程思维方式。希望你会发现它值得学习。顺便提一下,佩利斯是图灵奖的第一位获得者,该奖项被认为是计算机科学领域的诺贝尔奖。他因其在高级编程技术和编译器构建领域的影响力而获奖。因此,第一位图灵奖得主致力于编程语言,我认为这非常酷。

预测未来趋势 🔮

学习函数式编程的第二个理由是,函数式语言能够预测未来。

我的意思是,许多特性在函数式语言中出现的时间,远早于它们在命令式语言中出现的时间。

一个例子是垃圾回收。垃圾回收是指计算机自动为你管理内存。例如,在Java中创建许多对象时,理论上计算机可能会耗尽内存。然而,对象有生命周期,你使用它们一段时间后,最终会完成使用,并且程序中将不再触及它们。垃圾回收器是语言运行时的一部分,它回收那些永远不会再被使用的内存,从而可以循环利用这些内存用于其他目的。

Java的第一个版本大约在1995年推出,它拥有垃圾回收。但你知道吗?大约在40到50年前,函数式语言Lisp就已经拥有了它。Lisp是所有函数式语言的鼻祖,其语法特点是包含大量括号。因此,有个笑话称Lisp代表“大量恼人的愚蠢括号”。你可以找一天看看Lisp程序,看看你是否同意。Lisp之所以如此早地拥有垃圾回收,是因为这是实现该编程语言本身的唯一合理方式,因为幕后需要大量的内存管理。因此,在当时发明和设计它是必要的。

另一个函数式语言预测未来的例子是泛型。Java最初没有泛型,它们是在Java 5中添加的。我所说的泛型,是指Java中可以在类后写入类型参数的语言特性,例如,你可以写 List<T>,其中 T 是泛型参数,这就是参数化多态的一个例子。

Java语言设计的其他部分变得复杂和丑陋,因为泛型是在语言发布很久之后才被强行添加的。设计者不想破坏与旧代码和工具的向后兼容性,这使得Java泛型在某些方面不如其他语言(如微软的C#)有用。然而,在函数式语言社区中,泛型的参数化多态的能力和表达性早已广为人知,它在1990年就已经存在于一种名为ML的语言中。

更准确地说,ML不是一个单一的语言,而是一个语言家族,我们稍后会详细讨论。我们将在本课程中学习的语言就属于这个家族。

我还可以举出许多其他例子,但不会详细展开。简而言之,高阶函数是在C#和Java设计后期才添加的,而Lisp早在1958年就有了。类型推断在2011年添加到C++和Java 7中,而ML在1990年就有了。顺便问一下,为什么他们要将类型推断添加到C++中?因为对于程序员来说,为现代C++版本中所谓的模板编程写下非常复杂的类型变得太困难了。所以,当人类做起来太困难时,你就让计算机来做。

展望未来特性 🚀

那么,接下来会是什么?我没有水晶球,无法告诉你未来会怎样。但如果我必须猜测,我会说函数式语言提供的最引人注目的特性之一,但尚未在许多命令式语言中被采用的,将是模式匹配。

最近有一种名为Rust的语言添加了它。模式匹配是你将在本课程中熟悉并喜爱的一个特性,我认为它非常强大和有用。顺便说一句,别误会,我并不是说函数式语言是唯一发明新特性的地方,绝对不是。例如,面向对象的特性非常出色和酷炫,使我们能够构建非常庞大的软件,对于设计现在所有计算机都使用的GUI库非常有用。它们并非来自函数式语言社区。事实上,函数式语言也开始引入面向对象的特性,你开始看到这些特性的融合,Scala就是一个很好的例子。

所以,并不是一个语言家族比另一个更好。但函数式语言在预测未来方面确实有相当好的记录。这使得学习它们很有用。因为你本学期学到的特性,可能有一天会出现在下一个主流的命令式语言中。

总结 📝

本节课我们一起学习了学习函数式编程的两个核心理由。首先,函数式编程能帮助我们拓宽编程视野,理解编程的本质超越特定语言,并通过类比外语学习说明了其文化和技术价值。其次,函数式语言在历史上多次预测了编程语言的发展趋势,如垃圾回收、泛型、高阶函数和类型推断等特性都率先出现在函数式语言中,学习它们有助于我们把握未来的技术方向。下一节我们将继续探讨学习函数式编程的其他理由。

004:函数式编程的意义(第二部分)🎯

在本节课中,我们将继续探讨学习函数式编程的原因,特别是其在工业界的应用、教育价值以及代码美学方面的意义。

上一节我们讨论了函数式编程在概念和效率上的优势,本节中我们来看看其他几个同样重要的原因。

工业应用与现实考量 🏢

学习函数式编程的第三个原因,实际上更像是一个“非原因”。函数式语言确实在工业界有所应用,但这里需要澄清一个观点。

学习这门课程中的函数式语言,并不意味着你立刻就能以此为基础找到工作。事实并非如此。人们被雇佣的原因多种多样,你在学校学到的特定语言可能只是其中之一。

在现实世界中,确实存在使用函数式语言的地方。我将Java 8放在列表顶部可能有些取巧,因为Java 8增加了一些函数式特性。这或许是一种在现实世界中观察其影响的方式。事实上,随着时间的推移,函数式语言中的一些小特性正逐渐融入命令式语言,从而得到广泛应用。

例如,你在Java中学到的Lambda表达式(匿名函数)就是一个例证。

随着我们看向列表下方,我们会遇到越来越多真正的函数式语言,它们被世界各地的公司所使用。我们将要学习的OCaml语言就被许多公司使用过。你可以在OCaml.org网站上查看部分列表。

例如,Facebook创建了一种名为Reason的语言,它基于OCaml,实际上可以编译成OCaml。Reason用于前端Web开发,解决了JavaScript缺乏编译时类型检查的一些混乱问题。

再举一个例子,Jane Street是一家位于纽约的量化交易公司,它尽可能在所有业务上使用OCaml。其技术负责人Yaron Minsky博士,并非巧合,正是康奈尔大学的博士毕业生。

但归根结底,我们在3110课程中学习函数式编程的原因,康奈尔大学计算机科学系(美国前十)要求其主修学生学习函数式编程的原因,是为了你们的教育。因为它能让你成为一名受过良好教育的计算机科学家。其本身目的并非是为了帮你找到下一份实习。

教育的持久价值 🎓


阿尔伯特·爱因斯坦曾说:“教育就是当一个人把在学校所学全部忘光之后剩下的东西。”

实际上,我不完全确定这是否是爱因斯坦说的,他有点像教育名言界的亚伯拉罕·林肯,但总之有人说过这句话。

我认为这句话用在这里很合适。总有一天,你会忘记在学校学到的许多东西。

朋友们,我曾经上过微生物学,现在什么都不记得了。我也上过线性代数,现在别问我特征向量。你会忘记学过的一些东西,包括这门课的内容,我对此有清醒的认识。

但我希望剩下的东西对你仍然有用。我希望剩下的东西是我和这里的其他教员训练你思考计算机科学的方式。我希望在本课程的背景下,这能使你成为一名高效的程序员。


代码的优雅与美学 ✨

第四,函数式语言是优雅的。这是我目前提出的最主观的主张,我无法用数据来证实,所以也许我在这里不是一个好的科学家。

我所说的“优雅”,是指函数式语言是优美、庄重、时尚、雅致的,或者许多其他同义词。但对我来说,最有分量的是:

美丽。你是否曾认为代码可以是美丽的?我有过。我见过非常丑陋的代码,也见过让我坐下来感叹“哇,这真是一段漂亮的代码”的代码。编写它的人投入了大量精力使其变得优秀。

所以,恭喜你,你可能不知道,在注册3110课程的同时,你也注册了一门艺术鉴赏课。我希望在这个学期里,你能学会欣赏OCaml程序的美。不过,起初这会很困难,因为它们非常陌生,你不能期望一开始就欣赏不熟悉的语法。

但现在我已经数不清有多少学生在完成这门课程,然后继续用另一种语言(可能是在这里的另一门课,也可能是在工业界)编码后,回来对我说:“是的,这些其他语言,我现在用它们写的程序,在我看来很丑陋。”也许这也会发生在你身上。如果没有,那也没关系。

你可能会问:这些真的重要吗?美学在编程中重要吗?😡

答案是:是的,它们重要。除了主观部分,想想谁阅读和编写代码。机器和人类都阅读代码。对机器来说,你的代码是否美丽并不重要,它不会欣赏这一点。也许人工智能领域的人正在研究这个,但优雅的代码,即使对计算机来说还不重要,对人类却是重要的。

优雅的代码更容易阅读,因此也更容易维护。随着时间的推移,你会发现程序员最终花在编写代码上的时间会变少,而花在维护代码上的时间会变多。维护意味着调整、改进、添加新功能。而且这并不总是你最初编写的代码。😡

所以,阅读他人的代码可能有一天会成为你工作的一部分。到那时,你才会开始欣赏人们写的是丑陋的代码还是美丽的代码。

优雅的代码不一定更容易编写。😡 事实上,编写它可能需要更多时间,通常你首先写出丑陋的版本,然后回头改进它。我并不是说你一开始就能快速写出非常漂亮的代码,因此,这需要努力和实践。但完善这种能力是值得的。

总结 📝

以上就是我认为学习函数式编程至关重要的几个原因。😡

本节课中我们一起学习了函数式编程在工业界的实际应用、其超越职业技能的持久教育价值,以及代码优雅性对可读性和可维护性的重要意义。理解这些原因,有助于我们以更全面的视角投入到后续OCaml语言和函数式编程范式的学习中去。

005:OCaml语言简介 🐫

在本节课中,我们将要学习CS3110课程所使用的编程语言——OCaml。我们将了解它的名字来源、所属的语言家族,并探讨它作为学习工具的优势与局限性。


OCaml的名字含义

上一节我们介绍了课程背景,本节中我们来看看我们将要使用的语言本身。

我们将在CS3110课程中学习的函数式语言叫做OCaml。我认为OCaml是一门非常适合编写优美程序的编程语言。

人们总是想知道这个名字的含义。我会告诉你,但我也要提醒你,知道这个含义并没有太大帮助。

  • “O” 代表 Objective。这是因为在原始语言的基础上添加了一个面向对象的层。
  • “Caml” 是特别没有帮助的部分。它是一个缩写,代表 Categorical Abstract Machine Language

看,我告诉过你这没什么帮助。不过,这个名字的好处是,它给了我很多机会在本课程中使用骆驼的剪贴画,我会充分利用这一点。


OCaml的语言家族

OCaml属于ML语言家族。需要澄清的是,我在这里说的“ML”不是指机器学习,尽管你们可能都热衷于学习一些机器学习知识。

ML语言家族起源于一种元语言,这就是“M”的由来。它是一种工具的元语言,实际上是一个定理证明器的元语言,用于编写证明。当然,如今我们不再那样使用它,我们用它来编写通用程序。


为什么选择OCaml?

我认为OCaml有很多优点。你可以在课程教材中读到这些,也可以在课程大纲链接的另一本名为《Real World OCaml》的书中读到。但我现在不会详细讨论这些,因为现在讲会太抽象。

相反,我想做的是在学期末回来详细讨论。所以我现在向你们承诺,我希望在几个月后兑现这个承诺:我们最终会讨论OCaml在整个学期中对我们来说非常棒的那些原因。


没有完美的语言

但没有任何语言是完美的。在我们开始学习OCaml时,牢记这一点很重要。

语言是工具。你应该为正确的工作选择正确的工具。这让我想起一次,我的岳父需要修理电脑里的调制解调器卡,他没有使用应该用的螺丝刀,而是决定用锤子。结果并不好。

这也让我想起了中世纪的长柄武器。我喜欢玩《龙与地下城》,我有点书呆子气,我有一个持续多年的家庭战役,每周五晚上都会主持。在D&D中,你掷骰子,假装用武器进行战斗。你可以使用很多武器,其中一些就是长柄武器——这些长杆末端装有某种刀刃。在历史上,它们被发明出来都有特定的目的,在战斗中有不同的用途。

但它们是工具。你要为正确的工作使用正确的工具。

没有普遍完美的工具。看看有多少种长柄武器被开发出来就知道了。同样,也没有普遍完美的编程语言。所以请理解,尽管我喜欢用OCaml编程,但我并不是在试图告诉你它是完美的。


OCaml在本课程中的适用性

OCaml恰好很适合这门课程,因为它很好地混合了函数式和命令式的特性。

我们将从能够使用纯函数式语言开始,然后在学期后期,我们能够以隔离的方式融入一些命令式特性。

在OCaml中,推理程序的含义相对容易,特别是因为你可以主要将其用作函数式语言,而且我们本学期将对程序进行证明。

但OCaml并不完美。没有完美的工具,也没有完美的语言。

我们需要克服一些障碍,因为你会怀念过去使用过的任何语言中的某些特性。基于你对语言应该如何工作的期望,也会产生一些烦恼。

因此,作为一个指导过许多人学习OCaml和函数式编程的人,我请求你尝试将这些烦恼放在一边。有这些感觉是完全可以的,只是试着去识别它们。承认那种挫败感,然后把它放在一边。最终,它会过去的。我保证你会适应的。

在此过程中,我只要求你尝试保持开放的心态并享受乐趣。因为我认为学习函数式编程和学习OCaml有很多乐趣。


本节课中我们一起学习了OCaml语言的名称由来、它所属的ML家族,并客观地探讨了其作为教学语言的优缺点。重要的是要记住,语言是工具,OCaml是我们为学习特定编程范式而选择的合适工具。

006:学习编程语言的五个方面 🧠

在本节课中,我们将要学习掌握一门新编程语言所涉及的五个核心方面。理解这些方面能帮助你更系统、更高效地学习OCaml,乃至任何其他编程语言。

当我们共同踏上学习一门新编程语言的旅程时,有必要退一步思考:学习一门新编程语言究竟包含哪些内容?这至少是你过去学习的第三门,甚至可能是第四或第五门语言。那么,学习编程语言有哪些方面呢?

五个核心方面

以下是学习一门编程语言时需要关注的五个不同方面。

1. 语法

这是最基础的层面。它涉及如何书写语言结构、有哪些关键字、如何使用标点符号、需要哪种括号以及它们的位置等。例如,在OCaml中,函数定义和调用的语法就与Java或Python不同。

2. 语义

这是更有趣的方面,它关乎程序的含义。当你写下一段代码时,计算机将如何理解它?这包含两个贯穿我们学习OCaml乃至整个课程始终的重要部分:类型检查求值规则

  • 类型检查:帮助决定哪些程序具有意义。例如,OCaml的静态类型系统会在编译时检查类型错误。
  • 求值规则:告诉我们程序的具体含义,即代码将如何一步步执行并产生结果。

我们将在本学期深入探讨这两点。

3. 惯用法

这是指该语言中典型的模式。就像任何学习非母语的人所知,仅仅能用一种语言表达,并不意味着你能以母语者直觉且立刻理解的方式表达。编程语言也是如此。你可以用非常“Java风格”的方式在OCaml中表达,但OCaml的熟练使用者理解起来会有些困难。因此,学习用符合语言习惯的方式编程非常重要。

4. 库

学习编程语言还需要了解可用的库。语言本身或第三方项目提供了哪些标准设施或可下载的附加库?例如访问文件系统、使用提供的数据结构等。在Java中,你可能学过使用HashMap这样的库;在OCaml中,你也需要学习使用特定的库。

5. 工具

最后是可用工具。不同语言提供的工具丰富程度取决于其生态系统。在Java中,你可能习惯了使用Eclipse IDE;在Python中,你可能更多在命令行和代码编辑器中工作。其他语言可能提供调试器或类似“顶层环境”的工具(如Java的JShell或Python解释器),这是一个可以交互式输入命令或表达式并立即得到结果的区域,与将代码写入文件再编辑的方式不同。当然,后者是构建大型软件所必需的,而前者在我们尝试探索语言结构或弄清某个命令/表达式的含义时非常有用。

以上就是学习编程语言的五个不同方面,它们都是我们在本学期学习OCaml过程中需要掌握的挑战。

重点与优先级

然而,这些方面的重要性并不均等。如果将你的语言学习过程分解为这些方面,可以帮助你聚焦于当前面临的挑战以及需要改进的地方。

  • 语法是初期的挑战,但我们真正希望快速掌握的是语义,即语言和程序的含义。
  • 惯用法的学习实际上尤其重要。
  • 工具在某种程度上重要性较低。你将在整个职业生涯中持续学习新的库和工具。

因此,我们的重点是语义惯用法

语义像是一种元工具。一旦你理解了编程语言的语义——不仅仅针对某一门特定语言,而是理解语义如何被描述和在人类之间交流——这将帮助你学习新的语言,为你的未来提供助力。

惯用法则能帮助你成为更好的程序员。一旦你意识到在命令式语言与函数式语言中如何用符合习惯的方式编程,你将在这些语言中表现得更好。

至于库和工具,它们是次要的。在你的职业生涯中,你可能每年甚至每周都需要在工作中学习新的库和工具。因此,在本课程中我们不会过多聚焦于此。

而语法基本上总是枯燥的。它只是需要记忆的事实,就像“康奈尔大学成立于1865年”一样——这是一个有趣的事实,但就我们的目的而言,仅此而已。人们常常过于纠结于自己对语法的主观偏好。让我们不要陷入这个陷阱。语法是枯燥的,人们总会抱怨它。如果你想抱怨,那没关系,讨厌者总会讨厌,这会发生。

但我要在这里制定一条课堂规则:我们不抱怨语法。现在,如果你的职业生涯发展到你开发了一门被许多大学使用、每年有数万人学习或使用的编程语言,那么你就有资格抱怨语法,我也不会反对。但现在,在这门课上,我们不会抱怨OCaml的语法。我相信你会很快习惯它。

总结

本节课中,我们一起学习了掌握一门编程语言需要关注的五个方面:语法语义惯用法工具。我们明确了本课程将重点关注语义(特别是类型检查和求值规则)和惯用法,因为它们能带来更深层次的理解和编程能力的提升。同时,我们建立了不纠结于枯燥语法、聚焦于核心概念的共识,为后续的OCaml学习之旅奠定了良好的基础。

007:表达式 🧩

在本节课中,我们将学习OCaml程序的基本构建块——表达式。我们将了解表达式的语法、语义(包括静态语义和动态语义),并通过一些简单的例子来观察它们如何被求值为值。

概述

在函数式编程中,任何程序的基本构建单元都是表达式。表达式类似于命令式语言中的语句或命令,是我们用来构建程序的东西。每个表达式都有两个重要的方面:语法和语义。

语法与语义

上一节我们介绍了表达式的基本概念,本节中我们来看看表达式的两个核心方面:语法和语义。

  • 语法 是我们使用的关键词和标点符号。
  • 语义 是表达式的含义。

语义又可以分为两个部分:

  1. 静态语义,或称类型检查规则。这类似于Java中的类型检查。在OCaml中,编译器根据一组类型检查规则,为每个表达式推断出一个类型,或者在类型不合法时报错。如果类型检查失败,程序将不允许运行。之所以称为“静态”,是因为这是在程序运行之前、处于“静止”状态时进行的检查。
  2. 动态语义,或称求值规则。这描述了当程序实际运行时,表达式如何被计算。求值规则将一个表达式规约(reduce)为语言中的一个值。

除了产生一个值,求值过程还可能发生另外两种情况:一是可能引发一个异常;二是程序可能永不终止,即进入所谓的“无限循环”。

因此,对于每个表达式,我们主要关注三个方面:它的语法、它的类型检查规则和它的求值规则。这也是我们学习OCaml中各种结构时将采用的方法。

表达式与值的关系

现在,我们来理解表达式和值之间的关系。目前,你可以这样理解:值是一种不需要进一步求值的表达式。它已经被规约到了最简形式,没有更多计算需要执行。

用维恩图来表示,所有值都是表达式,但并非所有表达式都是值。需要说明的是,这种说法在课程后期会稍有修正,但目前这是正确的理解方式。

值 ⊆ 表达式

实践:在Utop中观察表达式

让我们启动OCaml的交互式环境Utop,通过一些简单的例子来观察表达式。

以下是启动Utop后可以尝试的一些基本表达式:

  • 整数:输入 3110;;。Utop会返回 - : int = 3110。这表示表达式 3110 求值得到 int 类型的值 3110。双分号 ;; 是告诉Utop我们已完成输入并希望它开始求值。
  • 布尔值:输入 true;;false;;。它们的类型是 bool
  • 比较表达式:输入 3110 > 2110;;。这会得到一个布尔值 true
  • 字符串:输入 "big";;,类型为 string。字符串连接使用 ^ 操作符,例如 "big" ^ "red";; 得到 "bigred"
  • 浮点数:OCaml有浮点数类型 float。但需要注意,OCaml为整数和浮点数设置了不同的算术操作符。例如,2.0 * 3.14;; 会导致类型错误,因为 * 操作符要求两边的操作数都是整数。对于浮点数乘法,应使用 *.,即 2.0 *. 3.14;; 会得到 6.28

类型推断与类型注解

OCaml具有强大的类型推断能力。这意味着在大多数情况下,你不需要像在Java中那样显式地为变量或参数声明类型。编译器会自动推断出表达式的类型。

OCaml在这方面介于Java和Python之间:像Java一样,它在编译时进行类型检查;又像Python一样,你通常不需要在代码中显式写出类型。然而,类型信息在编译时是存在的,如果编译器无法推断出使程序正确的类型,编译就会失败并报错,就像我们刚才看到的 * 操作符的例子。

如果你希望或需要显式指定类型,可以使用类型注解。语法是将表达式用括号括起来,后跟冒号和类型名,例如 (3110 : int)。类型注解不是类型转换,而是你要求类型检查器为你验证的一个额外约束。如果注解的类型与实际推断的类型不符,编译器会报错。

总结

本节课中我们一起学习了OCaml表达式的核心概念。我们了解到表达式是程序的基础,每个表达式都有其语法、静态语义(类型规则)和动态语义(求值规则)。值是不需要进一步求值的表达式。通过Utop的实践,我们观察了整数、布尔值、字符串和浮点数等基本表达式的求值过程,并特别注意了OCaml中整数和浮点数操作符的区别。最后,我们介绍了OCaml强大的类型推断特性以及如何使用类型注解进行显式类型声明。理解这些是编写正确、高效的OCaml程序的第一步。

008:If表达式 🧠

在本节中,我们将学习OCaml中的if表达式。if表达式允许我们根据条件,在两个表达式之间选择执行哪一个。这是实现程序分支逻辑的基础。

概述

if表达式是控制程序流程的核心结构之一。它通过评估一个布尔条件(称为“守卫”),来决定执行两个分支中的哪一个。我们将详细探讨其语法、语义和类型规则。

语法与基本用法

我们可以使用关键字 ifthenelse 来编写一个if表达式。其基本形式如下:

if condition then expression1 else expression2

例如,我们可以写:

if "Batman" > "Superman" then "Yay" else "Boo"

这个表达式将求值为 "Boo"。原因是OCaml中的字符串按字典序(即字母顺序)进行比较,而 "Batman" 在字典序上小于 "Superman"

表达式求值规则

if表达式的求值遵循明确的规则。位于 ifthen 之间的部分称为“守卫”,它必须是一个布尔表达式。

以下是if表达式的求值规则:

  • 如果守卫求值为 true,则执行 then 分支的表达式,跳过 else 分支。
  • 如果守卫求值为 false,则跳过 then 分支,执行 else 分支的表达式。

类型检查规则

OCaml对if表达式有严格的类型要求,这确保了程序的正确性。

以下是类型检查的关键规则:

  1. 守卫必须是布尔类型:守卫表达式必须具有 bool 类型。不能将整数等其他类型当作布尔值使用。
  2. 分支类型必须一致thenelse 两个分支的表达式必须具有相同的类型。整个if表达式的类型就是这个共同的类型。

例如,if true then "Yay" else 1 会导致类型错误,因为字符串和整数类型不匹配。

关于省略Else分支的说明

初学者应避免省略 else 分支。虽然语法上可能允许,但会导致类型错误或引入 unit 类型,目前我们暂不涉及。现阶段,请始终提供完整的 if-then-else 结构。

形式化定义与符号

为了更精确、简洁地描述求值和类型规则,我们引入一些数学符号。

  • 求值符号:使用 ==> 表示“求值为”。例如,e ==> v 表示表达式 e 求值为值 v
  • 类型符号:使用 : 表示“具有类型”。例如,e : t 表示表达式 e 具有类型 t

使用这些符号,我们可以将if表达式的规则形式化地写出来:

求值规则:

  1. 如果 e1 ==> truee2 ==> v,那么 (if e1 then e2 else e3) ==> v
  2. 如果 e1 ==> falsee3 ==> v,那么 (if e1 then e2 else e3) ==> v

类型规则:
如果 e1 : boole2 : t,且 e3 : t,那么 (if e1 then e2 else e3) : t

这些形式化规则准确地捕捉了if表达式的核心行为。

总结

本节课我们一起学习了OCaml中的if表达式。我们了解了它的基本语法 if...then...else,掌握了其根据布尔守卫条件选择分支进行求值的工作方式。更重要的是,我们明确了其严格的类型规则:守卫必须是布尔值,且两个分支必须类型一致。最后,我们引入了 ==>: 符号来形式化地描述这些规则,为后续学习更复杂的语言结构打下了基础。

009:Let定义 🧩

在本节课中,我们将要学习OCaml程序的第二种基本构建块:定义。到目前为止,我们一直将表达式作为程序的基础。现在,让我们来看看如何通过定义来为值命名,并理解其语法和语义。

定义简介

定义允许我们为值赋予一个名称。这本质上类似于其他编程语言中的变量概念,但关键区别在于,这个“变量”的值一旦绑定,就不可更改。

例如,我们可以输入 let x = 42。系统会给出回应,现在 x 就代表 42

让我们仔细看看这个回应。从右向左阅读:let x = 42 的结果是产生了一个值为 42、类型为 int 的结果,并且它被绑定到了名称 X 上(回应中的 val x 即表示此意)。因此,从左向右看,我们得到了一个名为 X、类型为 int、值等于 42 的值。

当我在下一个提示符中直接求值 x 本身时,我得到的是 42,它就是一个 int42 是变量 x 的值,x 是名称。

我可以定义其他值,例如 let y : int = 3110。这里我添加了类型标注 : int。实际上,在这个类型标注中,括号是可选的,编译器可以推断出类型,因此并非必需。在这个特定的 let 定义语法中,由于语法足够明确,编译器即使没有括号也能理解我的意图。

现在 y 的值是 3110。有了这两个定义,我就可以进行运算,例如 x + y,结果是 3152

深入理解定义

上一节我们直观地了解了 let 定义,本节中我们来看看如何更严谨地审视它们。

定义是为值命名的一种方式。但定义本身不是表达式,表达式也不是定义。在OCaml的语法中,它们是两个完全独立的类别。你不能把一个定义当作表达式来使用,反之亦然。

尽管如此,定义在语法上确实包含表达式。在我们之前的例子中,作为定义一部分写下的整数值,其本身就是表达式。

一个 let 定义的语法是 let x = e,其中 x 代表一个标识符。OCaml有形成标识符(可以理解为变量名)的规则,与其他语言非常相似。对于这些 let 定义,标识符必须以小写字母开头(后续我们会看到以大写字母开头的标识符例子)。OCaml对此有严格要求,如果你尝试以大写字母开头,将会收到错误信息。

这里的 e 可以是任何表达式,遵循我们已经见过或将要见到的任何表达式规则。

要评估一个 let 定义(即动态语义),步骤如下:

  1. 首先,将表达式 e 求值为一个值 v
  2. 然后,将值 v 绑定到名称 x 上。“绑定”意味着将值 v 与名称 x 关联起来。此后,x 的求值结果永远是 v,它是不可变的。

如果你更喜欢从硬件层面思考,这里实际发生的是:计算机会分配一个新的内存位置,用名称 x 来指代它,并将值 v 存储在其中。

由于 let 定义本身不算作表达式,因此你不能将 let x = e 用作其他表达式的一部分。例如,你不能写 let z = 1 + 2 并期望 let z = 1 这部分先被求值。let z = 1 是一个定义,它绑定一个值到一个名称,但它自身没有值。你不能在期望一个值的上下文中(即作为表达式)使用它。如果你尝试编译这样的代码,将会得到一个语法错误,提示期望一个运算符,因为它无法将用作表达式的 let 定义解析出来。

定义与表达式的结合

如果你确实希望能够在表达式上下文中使用 let 定义,有一种方法可以实现,我们将在下一节中看到。


本节课中我们一起学习了OCaml中的 let 定义。我们了解到定义是为值赋予不可变名称的方式,其语法为 let <标识符> = <表达式>。我们明确了定义与表达式是语法上不同的类别,因此定义不能直接用作表达式的一部分。最后,我们提到了存在一种将定义融入表达式的方法,为后续学习留下了引子。

010:Let表达式 🧩

在本节课中,我们将要学习OCaml中的Let表达式。Let表达式与之前学过的Let定义非常相似,但它在语法上是一个表达式,这意味着你可以将它们嵌套在更大的表达式中。

Let表达式与Let定义的区别

上一节我们介绍了Let定义,本节中我们来看看Let表达式。它们的关键区别在于语法结构。Let表达式包含一个in关键字,后面跟着另一个表达式。

例如,我们可以写:

let a = 0 in a

这与我们之前看到的定义不同。之前做定义时,我们没有in关键字以及后面跟着的语法片段。正是这个in关键字使得它成为一个Let表达式,而不是Let定义。

所以,let a = 0 in a这个表达式会求值为0

Let表达式的求值过程

理解Let表达式的一种方式是:OCaml将值绑定到名称上,然后继续执行in后面的部分。

例如:

let b = 1 in 2 * b

我们期望得到2,实际也确实如此。这里发生了什么?1被绑定到名称b,然后求值继续,计算2 * b。由于b当前绑定为1,所以2 * 1等于2

你可以将此视为一种替换。如果我们把1绑定到b,那么之后无论在哪里看到b,我们都可以用1替换这个名称b,然后继续求值。

作用域的概念

现在,这些Let表达式不是定义。让我向你证明这一点。a目前没有绑定到任何值。即使我执行了第一个Let表达式let a = 0 in a,那个in实际上在试图说明一些重要的事情:a在随后的子表达式中等于0,但在其他地方则不是。这为我们提供了作用域的概念,你可能在其他语言中遇到过。a在外部没有绑定,b在外部也没有绑定,依此类推。

嵌套Let表达式

我们可以任意嵌套这些表达式。例如:

let c = 3 in let d = 4 in c + d

让我们停下来思考一下这里会发生什么。c将被绑定到3d将被绑定到4,然后我们将计算c + d。这将是3 + 4,所以整个表达式应该求值为7,实际情况也确实如此。

理解变量遮蔽

让我们尝试另一件事:

let e = 5 in let e = 6 in e

现在,你认为这里会发生什么?实际上,仅凭直觉可能有点难以预测。结果是6,并且我们还会得到一个警告。这里看起来可能发生了某种变量突变,但我告诉过你那不会发生。对于这里发生的事情,有一个非常合乎逻辑的解释,但我们需要先研究语法和语义才能理解它。

本节课中我们一起学习了Let表达式,理解了它与Let定义的区别、其求值过程、作用域的概念以及嵌套和变量遮蔽的行为。掌握Let表达式是编写模块化和清晰OCaml代码的关键一步。

011:变量表达式与作用域 🧠

在本节课中,我们将要学习OCaml中变量表达式的求值规则,以及一个至关重要的概念——作用域。我们将理解变量名如何通过替换获得意义,以及作用域如何界定一个名字在何处是有效的。


变量求值与替换

上一节我们介绍了let表达式的基本结构。本节中我们来看看,当我们单独求值一个变量名时,它意味着什么。

我们之前已经看到,可以单独求值一个名字。例如,我可以输入 let x = 42,然后求值 x。那么,x本身在顶层环境中是如何获得意义的呢?

理解方式是:在顶层环境中,每一个let定义都隐式地是一个巨大嵌套let表达式的开始。当我输入 let x = e 时,其真正含义是:在后续所有将要输入的内容中,x 都等于 e

如果你有一连串的let定义,例如:

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

这实际上被理解为一系列嵌套的let表达式。

基于此,我们现在可以将变量名的求值理解为替换。当我输入 let x = 42 后,在后续任何地方出现的 x 都将被理解为一次替换,就像在let表达式中一样,x 会被替换为 42。因此,本质上,它只是在一个巨大的嵌套let表达式内部进行替换。


作用域的概念

现在,让我们回到作用域的概念。作用域是指一个名字有意义的地方。😡

在大型嵌套的let表达式中,名字在某些地方有意义,在另一些地方则没有。如果我们写 let x = 42 in ...,那么在那个主体表达式中,x 是有意义的,但在其外部则没有。

考虑一个嵌套的let表达式:

let x = 42 in
    let y = 3110 in
        ...

y 在内层let表达式的作用域内是有意义的,但在外层let表达式的作用域内则没有意义。

因此,let表达式为OCaml程序提供了作用域的界定。


重叠的作用域

容易让人困惑的是重叠的作用域。例如,下面这个程序与我之前提到的一个例子很相似,其中名字 x 的作用域发生了重叠:

let x = 5 in
    let x = 6 in
        x + x

要弄清楚它的含义有点费脑筋。虽然它确实有明确的定义,但首先我必须说,这非常令人困惑。😡 因此,当我们编写供他人理解的程序时,应尽量避免这种难以思考的代码。

不过,既然我们已经有了语义学,特别是let表达式的求值规则,我们就可以弄清楚这个程序以及其他类似程序的含义了。它们遵循一个我称之为名字无关性原则的原则。


名字无关性原则

据我所知,我是唯一使用这个术语的人,但我认为它相当贴切。对我来说,名字无关性原则是指:一个变量的名字本身不应该具有内在的重要性。😡

让我用数学来类比。如果你打开一本数学教科书,看到两个函数:

  • f(x) = x + 1
  • f(y) = y + 1

我想大多数人都会同意这是同一个函数。它们做同样的事情:给参数加1。因此,参数的名字实际上与函数的真正含义无关。

为了在程序中确保名字无关性,我们在进行替换时必须非常小心。我们需要在遇到相同名字的绑定停止替换。在本学期稍后,我会给出一个非常严谨的定义,我们甚至会将其作为程序的一部分来实现。但现在,这个解释足以让我们进行一些示例分析。


示例分析

以下是几个示例,帮助我们理解重叠作用域下的替换规则。

示例一:简单重叠

假设我们有程序:

let x = 5 in
    let x = 6 in
        x

它的实际含义是什么?我们已经掌握了足够的知识来推导它。

  1. 求值 5 得到值 5
  2. 5 替换到 x 出现的地方。这意味着对 let x = 6 in x 这个表达式进行替换,将 5 替换给 x
  3. 根据我们刚刚同意的规则:当遇到相同名字的绑定时,停止替换。😡
  4. 我们确实遇到了一个对相同名字 x 的绑定,因此我们停止任何替换,不触及该主体表达式的其余部分。😡

所以,let x = 5 in let x = 6 in x 意味着我们现在需要求值 let x = 6 in x,我们知道这求值结果为 6。这就是整个let表达式的结果。

在UTop中,我们会看到一个“未使用变量”的警告。这现在更有意义了。OCaml实际上是在说:第一个将 x 绑定到 5 的操作,你从未使用它。它被丢弃了,因为替换在遇到绑定相同名字 x 的内层let表达式时就停止了。

示例二:使用外层绑定

这并不是说我们不能使用外层的绑定。例如:

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

现在,我们有一次替换需要执行:将 5 替换给 x。但随后停止,不要进一步深入到那个嵌套的let表达式中,因为它绑定了一个我们正在替换的相同名字。

这将得到 5 + 6,整个表达式的结果是 11

让我们在UTop中验证一下。确实,我们得到了 11,并且这次没有收到“未使用变量”的警告,因为我们在求值整个表达式时确实使用了将 x 绑定到 5 的操作。


总结

本节课中我们一起学习了:

  1. 变量求值:变量名通过在其作用域内进行替换来获得意义。
  2. 作用域:由let表达式界定,指一个名字有效的区域。
  3. 重叠作用域:当内层作用域重新定义了与外层相同的名字时,内层绑定会“遮蔽”外层绑定。替换规则是:遇到相同名字的绑定时停止替换
  4. 名字无关性原则:变量的名字本身不影响程序的逻辑含义,这通过上述替换规则来保证。

理解这些概念对于编写清晰、可预测的OCaml代码至关重要。在下一节中,我们将探讨更复杂的数据结构和模式匹配。

012:作用域与顶层环境 🧠

在本节课中,我们将学习OCaml中作用域的一个关键特性,并澄清一个关于变量“可变性”的常见误解。我们将通过分析顶层环境中的连续定义,来理解OCaml变量实际上是不可变的这一核心原则。

上一节我们介绍了嵌套作用域和let表达式的求值规则。本节中我们来看看一个在顶层环境中看似“改变”变量值的例子,并解释其背后的原理。

一个看似矛盾的例子

在OCaml的顶层环境中,可以写出如下代码:

let x = 1;;
let x = 2;;

此时,x的求值结果是2。这看起来很像变量x的值被“改变”了,似乎与OCaml变量不可变的说法相矛盾。

深入理解:嵌套作用域视角

实际上,上述操作并未发生任何“改变”。我们可以将顶层环境中的这一系列定义,理解为嵌套的let表达式和作用域。

上述代码在语义上等价于:

let x = 1 in
  let x = 2 in
    x

根据我们已知的let表达式求值规则,整个表达式的求值结果将是2

内存模型解释

从底层内存的角度来思考:

  1. 第一个let表达式分配了一块内存,用于永久存储值1
  2. 第二个let表达式分配了另一块新的内存,用于永久存储值2
  3. 当最内层的let表达式对其主体(即x)进行求值时,我们需要查找x所对应的值。

根据作用域规则,查找名称时会使用最内层的作用域定义。因此,x指向的是存储2的那块新内存,而不是存储1的那块旧内存。

这与其他编程语言的经验一致:名称的含义通常由最内层的作用域决定。

结论与要点

现在可以清楚地看到,这里的变量并没有发生任何改变。我们只是用相同的名称x去引用了一块新分配的内存

以下是关于顶层环境作用域的关键点:

  • 在顶层环境中,无法“回退”去引用被新定义所遮蔽的旧绑定(例如,绑定到1的那个x)。
  • 在实际编程中,代码大多写在文件里而非顶层环境。在文件中,我们可以通过嵌套作用域来清晰地管理内层和外层的作用域。

本节课中我们一起学习了如何正确理解OCaml顶层环境中的连续定义。我们认识到,这并非变量可变,而是作用域嵌套和名称遮蔽的结果,再次印证了OCaml变量不可变的核心特性。

013:匿名函数 🧩

在本节课中,我们将深入学习OCaml中最基本的构建块——函数。我们将重点探讨匿名函数,了解其定义、类型、应用方法以及多参数函数的处理方式。

概述

上一节我们介绍了函数作为函数式程序的基础。本节中,我们将深入探讨一种特殊的函数形式:匿名函数。匿名函数是没有绑定到标识符的函数,可以直接定义和使用。

匿名函数基础

以下是一个匿名函数的例子,它接受一个参数 x 并返回 x + 1,即对其输入进行递增操作。

fun x -> x + 1

从OCaml的响应中,我们可以看到一些信息,但这与我们之前看到的输出略有不同。输出的右侧显示 fun 在尖括号 < > 中。这些尖括号表示OCaml有一个“不可打印”的值。在这种情况下,它只能告诉我们这是一个函数,但无法透露更多信息,例如其源代码或程序员如何编写它。

原因是当OCaml准备打印此输出时,该函数已被编译,现在以内存中的位形式存在,不再是其原始形式,因此变得“不可打印”。

函数类型

现在,看向输出的左侧,我们看到 int -> int。这是函数的类型。int -> int 表示该函数接受一个整数作为输入,并返回一个整数作为输出。最左侧的 - 表示它是匿名的。这与我们上周看到的情况类似。例如,当我们输入 3110 时,那是一个匿名整数,我们尚未将其绑定到名称,其类型是 int,值是 3110。匿名函数的区别在于它仍然是匿名的,并且有一个类型,但我们实际上看不到其值。

未来我们还会看到其他类型的值,OCaml也会用尖括号打印出来,但目前函数是唯一的情况。

函数应用

定义函数时,我并非必须使用那些括号。实际上,我可以不写它们,它们不是强制要求的。但是,当您想要应用匿名函数以便OCaml正确解析语法时,将匿名函数用括号括起来很重要。

假设我们实际上想要增加一个整数,例如我们想增加 3110。您从其他语言习惯的语法可能是在调用函数时,将参数放在括号中。在OCaml中,这确实有点不同。

在OCaml中,函数应用只是将函数写在其参数旁边,不需要额外的语法,除非您需要强制OCaml以正确的方式解析某些语法。

因此,在这种情况下,我需要做的是将匿名函数本身用括号括起来,以表示“将此解析为一个单元”,并将其应用于参数 3110。这样我们得到 3111

这里发生的情况是,括号内的左侧被解析为要应用的函数,右侧被解析为函数应用的参数,然后函数应用就发生了。

如果您习惯在参数周围加括号,也可以在这里添加额外的括号,但这并非必需,并且实际上应该省略,因为添加额外的括号只会使代码复杂化。在您适应OCaml及其语法时,可能需要尝试添加额外的括号来弄清楚解析方式,这没关系,但请尝试使用尽可能少的括号以保持代码更清晰。

多参数函数

以上是一个函数及其应用方式的简单示例,该函数接受单个参数。您也可以拥有多参数函数。

以下是一个接受两个参数 xy 的匿名函数。它做什么呢?让我们将这两个参数相加。为了多样化,我们将其设为浮点数加法。更有趣的是,让我们将其设为平均值函数,因此我们将两者相加后除以 2.。当然,根据算术运算的正常规则,我需要在 x +. y 周围加上括号。

fun x y -> (x +. y) /. 2.

现在,我有了一个可以计算两个参数平均值的匿名函数。当然,我需要在 2 后面加上点以使其成为浮点数,顺便说一下,您也可以输入 2.0,但不必在那里加点零。

现在,我可以将该函数应用于两个不同的参数。假设我计算 21.103110. 作为浮点数的平均值,结果返回 2610.5

(fun x y -> (x +. y) /. 2.) 21.10 3110.

总结

本节课中,我们一起学习了OCaml中的匿名函数。我们了解了如何定义匿名函数,其类型的表示方式,以及如何正确地应用它们,包括处理单参数和多参数的情况。记住,在函数应用时,清晰的语法和尽可能少的括号有助于保持代码的简洁和可读性。匿名函数是函数式编程中强大且灵活的工具,为构建更复杂的程序奠定了基础。

OCaml编程:2.9:匿名函数与Lambda表达式 🧮

在本节中,我们将深入探讨匿名函数的语法和语义,以及函数作为值的概念。

上一节我们介绍了函数的基本概念,本节中我们来看看匿名函数的具体语法和计算规则。

匿名函数表达式的语法以关键字 fun 开头。在OCaml中,fun 是一个关键字。其后是函数的参数,即参数名 x1xn。这些参数名可以在函数体中被引用。参数名之后是一个箭头符号 ->,它表示从输入到输出的转换。最后是函数体 E,它可以是任何表达式。

以下是匿名函数的语法结构:

fun x1 ... xn -> E

如何对函数进行求值?实际上,匿名函数本身就是一个值,就像整数、字符串或布尔值一样。在定义时,函数体 E 并不会被立即求值。根据OCaml的求值规则,函数体只有在函数被调用(应用)时才会被求值。因此,匿名函数在定义时就已经是最终值,无需进一步计算。

匿名函数在编程语言领域还有一个广为人知的名称:Lambda表达式。这个名称来源于数学中的Lambda演算记法 λx. E,其含义与OCaml中的匿名函数语法完全相同。这里的 λ 就相当于OCaml中的 fun 关键字。

Lambda表达式在各种编程语言和社区中无处不在:

  • Python使用 lambda 关键字创建Lambda表达式。
  • Java也支持Lambda表达式。
  • 有一个非常流行的编程语言理论博客名为“Lambda the Ultimate”。
  • 甚至还有以“Lambda Style”为主题的趣味文化作品。

既然函数是值,我们就可以像使用其他值一样使用函数。这意味着函数可以作为参数传递给其他函数,也可以作为其他函数的结果被返回。这是函数式编程的一个非常强大且显著的特征,它将带来深远的影响。

本节课中我们一起学习了匿名函数(Lambda表达式)的语法和语义,理解了函数在定义时即为值,且函数体在调用时才被求值的规则。更重要的是,我们认识了“函数作为值”这一核心概念,它是实现高阶函数和函数式编程强大表达能力的基础。

015:函数应用 🧮

在本节课中,我们将要学习OCaml中函数应用的核心概念和具体执行过程。函数应用是调用函数并传递参数的基本方式。

函数应用的语法

函数应用的语法非常简单,只需将表达式并排放置即可。

如果你想将一个函数应用到N个不同的参数上,你可以这样写:E0 E1 ... EN。其中E0是代表函数的表达式,E1EN是其他表达式。

除非你需要强制特定的求值顺序,例如在算术运算中,或者确保OCaml按照你期望的方式解析代码,否则不需要括号或标点符号。

请改掉在函数参数周围写括号的习惯。你不需要它们,事实上,如果你添加了括号,OCaml甚至可能无法理解你的意图。

特别需要注意的是,不要在函数参数之间添加逗号。这里没有逗号,添加逗号实际上是错误的。

函数应用的求值过程

函数应用的求值过程遵循以下步骤。

首先,将所有子表达式求值为值。也就是说,将E0求值为值v0,将E1求值为值v1,依此类推,直到EN

完成这一步后,E0必须求值为一个函数值v0。我们可以将其写成一个匿名函数,例如 fun x1 ... xn -> e

现在我们有了这个函数,我们可以将参数的值代入函数体中的那些名称。

因此,将v1代入x1,将v2代入x2,依此类推。在函数体表达式e内部进行这些替换。

这将得到一个新的表达式e',它可能因为我们在内部进行了一些替换而发生了变化。

最后,将e'求值为值v。这个值v就是整个函数应用表达式的结果。

示例一:单参数函数应用

让我们通过一个例子来具体说明这个过程。

假设我们应用一个将其参数加一的匿名函数到表达式2 + 3上。当然,如果我们对2 + 3的结果加一,我们应该得到6。

以下是求值规则的具体应用。

首先,将E0求值为一个值。这一步已经完成,因为fun x -> x + 1是一个匿名函数,因此已经是一个值,无需进一步操作。

接着,将参数求值为值。这意味着求值2 + 3,这当然会得到5

接下来,我们尝试将fun x -> x + 1应用到5上。

函数应用的下一步是,将参数值代入参数的名称。

因此,我想在匿名函数体内部将5代入x。这给了我5 + 1

这就是求值规则中提到的表达式e'

最后,将e'求值为一个值。5 + 1求值为值6,这就是整个函数应用表达式的结果。

示例二:多参数函数应用

让我们尝试第二个例子。

假设我们应用一个将其第二个参数从第一个参数中减去的匿名函数到另外两个表达式上。

以下是函数应用规则的具体步骤。

首先,求值所有子表达式。第一个子表达式已经完成,因为它已经是一个值(那个匿名函数已经是一个值)。

求值第二个子表达式和第三个子表达式。3 * 1求值为33 - 1求值为2

现在我们可以将这些值分别代入函数体中的名称。函数体是x - y,我们将3代入x,将2代入y。这就是替换后得到的子表达式e'

最后,将e'求值为一个值。3 - 2求值为值1


本节课中我们一起学习了OCaml函数应用的语法和求值过程。我们了解到函数应用只需将表达式并置,无需括号或逗号。求值过程分为三步:先求值所有子表达式为值,然后将参数值代入函数体,最后求值替换后的表达式得到最终结果。通过两个具体示例,我们清晰地看到了这个过程是如何一步步执行的。

016:命名函数 🏷️

在本节课中,我们将要学习如何为函数命名。虽然匿名函数很有用,但在编程实践中,为函数赋予名称是组织代码和提升可读性的关键。

概述

上一节我们介绍了匿名函数,本节中我们来看看如何为函数命名。实际上,这与为其他值命名并无不同,因为函数本身也是值。

为函数命名

我们可以像绑定其他值一样,将一个匿名函数绑定到一个名称上。

例如,我们之前有一个递增函数:

fun x -> x + 1

我们可以为它命名:

let inc = fun x -> x + 1

现在,我们有了一个名为 inc 的函数,其类型为 int -> int。它可以对传递给它的任何参数进行递增操作。

简化的函数定义语法

总是书写 fun ... -> ... 语法会显得冗长。OCaml 提供了一种更简洁的方式来定义命名函数。

我们可以这样写:

let inc x = x + 1

这行代码与上面使用 fun 关键字定义的 inc 函数在语义上完全等价。它通过将参数直接放在等号左侧,省略了 fun 关键字和箭头,使语法更简洁。

让我们用这个语法重写求平均值的函数:

let average x y = (x +. y) /. 2.0

现在,我们可以计算两个数的平均值。当然,我们也可以用匿名函数语法来写,但大多数 OCaml 程序员会选择这种更便捷的形式。

语法糖

我们现在看到了两段语法不同但语义等价的代码。

  • let inc = fun x -> x + 1
  • let inc x = x + 1

它们含义相同,求值方式也相同。这种现象被称为语法糖。当一门语言中有两种不同的语法形式表达相同含义,但其中一种更易于使用时,后者就是前者的语法糖。

第一种带有 fun 关键字的形式是更基础的形式。第二种将参数置于左侧的形式则更“甜”,写起来更方便。没有它我们也能编程,但语言提供了它,让编程体验更佳。

OCaml 的设计使得许多复杂的表达式可以简化为更简单的形式,语法糖就是这一理念的体现。

以下是另一个例子:

(fun x -> x + 1) 2

在语法上不同于下一行:

let x = 2 in x + 1

但它们在语义上是等价的。根据各自的求值规则,两者最终都会进行相同的替换(用 2 替换 x)和计算,得到结果 3。因此,let 表达式在这里也是语法糖,它比使用匿名函数应用更易读。

深入理解求值过程

让我们更仔细地分析一个之前见过的例子。假设在顶层定义了函数:

let f x y = x - y

然后应用它:

f 3 2

我们知道,在顶层书写的 let 定义,实际上被理解为嵌套的 let 表达式。因此,这两行代码共同作用的方式如下:

let f = (fun x y -> x - y) in f 3 2

根据 let 表达式的求值规则,这意味着将 f 替换为那个匿名函数。于是我们得到:

(fun x y -> x - y) 3 2

再根据匿名函数应用的求值规则,我们取函数体 x - y,将 x 替换为 3y 替换为 2,最终得到结果 1

所以,当你在交互环境中键入 let f x y = x - y 然后应用 f 3 2 时,背后正是这些求值规则在运作,最终产生了值 1

总结

本节课中我们一起学习了如何为函数命名。我们了解到:

  1. 函数是值,可以像其他值一样被绑定到名称上。
  2. OCaml 提供了 let func_name arg = ... 这种简洁的语法来定义命名函数,它是 let func_name = fun arg -> ... 的语法糖。
  3. 语法糖是指语法不同但语义等价的表达方式,它使代码更易于编写和阅读。
  4. 通过分析 let 表达式和函数应用的求值规则,我们更深入地理解了代码执行背后的逻辑。

掌握命名函数是构建复杂程序的基础,它帮助我们更好地组织和复用代码。

017:递归函数 🌀

在本节中,我们将学习OCaml中递归函数的定义方法,包括必须使用的rec关键字、递归函数的基本结构,以及编写递归函数时需要注意的常见语法问题。


语法要求:rec关键字

在OCaml中,定义递归函数时必须在let和函数名之间显式添加rec关键字。这是OCaml语言的设计选择,其他函数式语言可能有不同的设计。虽然支持这种设计的论点并不特别重要,但最重要的是记住添加这个关键字。

代码示例:

let rec function_name parameters = 
  (* 函数体 *)


编写阶乘函数

上一节我们介绍了rec关键字的基本语法,本节中我们来看看如何用它编写一个实际的递归函数——阶乘函数。

假设要编写阶乘函数fact(n)。阶乘的定义是:如果n为0,则0! = 1;否则,n! = n * (n-1)!。此外,应该注明前置条件:n >= 0,因为如果用户传入负数,函数行为将不可预测。

阶乘函数定义:

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

常见错误与注意事项

在编写递归函数时,有几个常见的语法错误需要注意。

以下是两个关键注意事项:

  1. 忘记rec关键字:如果忘记添加rec关键字,OCaml编译器会提示“未绑定的值”错误,因为函数在递归调用自身时尚未定义。

  2. 参数括号的使用:在递归调用fact (n - 1)中,括号是必需的。如果写成fact n - 1,OCaml会将其解析为(fact n) - 1,这将导致无限递归和栈溢出。

错误示例导致的栈溢出:

let rec fact n =
  if n = 0 then 1
  else n * fact n - 1  (* 错误:缺少括号 *)

执行此代码会导致栈溢出,因为函数会无限调用自身,直到调用栈耗尽。


正确实现与测试

为了正确实现阶乘函数,必须确保括号放在正确的位置,强制OCaml将n - 1整体作为参数解析,而不是仅将n作为参数。

修正后的阶乘函数:

let rec fact n =
  if n = 0 then 1
  else n * fact (n - 1)  (* 正确:使用括号 *)

现在,我们可以正确计算阶乘,例如fact 10将返回3628800


总结

本节课中我们一起学习了OCaml中递归函数的定义方法。关键点包括:必须使用rec关键字显式声明递归函数;编写递归函数时要正确处理基准情况和递归步骤;特别注意参数传递时的括号使用,避免因解析错误导致的无限递归。掌握这些基础后,您就能开始编写自己的递归函数了。

018:函数类型 🧮

在本节课中,我们将要学习OCaml中函数类型的静态语义,即类型检查规则。我们将探讨如何表示函数类型,以及如何对匿名函数表达式和函数应用进行类型检查。


函数类型表示

上一节我们介绍了函数的语法和动态语义(求值规则),本节中我们来看看函数的静态语义,即它们的类型检查规则。

一个函数类型使用箭头类型 T -> U 表示。这是一个函数的类型,它接受一个类型为 T 的输入,并返回一个类型为 U 的输出。

同样地,如果一个函数接受两个参数,其类型 T1 -> T2 -> U 表示该函数接受一个类型为 T1 的输入,再接受一个类型为 T2 的输入,并返回一个类型为 U 的输出。

因此,所有箭头链末尾的最后一个类型始终是函数的输出类型,而该链中在此之前的所有类型都是输入类型。通过这种方式,可以表示接受任意数量输入的函数。

注意,语言中的箭头语法在这里有双重用途。它既用于表示函数类型,也用于表示函数值(当我们使用匿名函数关键字 fun 时)。这很好,因为它表明这两者密切相关,它们都是一种将输入(或多个输入)转换为输出的变换概念。


匿名函数表达式的类型检查

匿名函数表达式的类型检查规则本质上表明,函数的类型就是其参数类型 -> 其输出类型。

用数学方式表示,如果每个参数都能被赋予类型(即 x1 能被赋予类型 T1,一直到 xn 被赋予类型 Tn),并且匿名函数体 e 具有类型 U,那么匿名函数表达式 fun x1 ... xn -> e 的类型就是 T1 -> ... -> Tn -> U

让我们看几个例子,使其更具体。

示例1:单参数函数

假设我们有匿名函数 fun x -> x + 1。我们知道,程序员本可以在 x 上写一个类型注解,说明 x 必须是整数。OCaml通过类型推断来弄清楚这一点。

暂时抛开类型推断的问题。我们人类当然可以通过观察函数表达式的右侧来说:如果 x1 通过 + 运算符相加,那么 x 必须是整数。OCaml也大致是这样推断的。

因此,如果我们知道 x 的类型是 int,那么函数体 x + 1 的类型也是 int(因为它是将 + 运算符应用于两个整数的结果)。所以,整个匿名函数表达式 fun x -> x + 1 的类型是 int -> int(参数类型 -> 输出类型)。

示例2:多参数函数

假设我们有 fun x y -> x + y。如果 x 的类型是 inty 的类型是 int(同样,这是由OCaml的类型推断部分提供的假设,我们人类也能通过思考得出:+ 运算符的类型是 int -> int -> int,因此 xy 必须是整数才能使整个表达式成立)。

那么,函数体 x + y 的类型是什么?它必须是 int(同样因为 + 运算符)。因此,整个匿名函数表达式 fun x y -> x + y 的类型是 int -> int -> int

我们通过假设参数类型必须是什么,找出函数体的类型,然后将它们组合起来形成匿名函数表达式的类型。


函数应用的类型检查

函数应用的类型检查规则非常相似。

如果被应用的函数(当然可以是一个表达式 e0)具有类型 T1 -> ... -> Tn -> U(即它具有类型为 T1Tn 的参数,以及输出类型 U),并且如果每个参数表达式都具有与函数类型相匹配的正确类型,那么整个函数应用的结果类型将是函数的输出类型 U


总结

本节课中我们一起学习了OCaml中函数类型的静态语义。我们了解了如何使用箭头语法 T -> U 表示函数类型,以及如何对匿名函数表达式(如 fun x -> x + 1)和函数应用进行类型检查。核心规则是:函数的类型由其参数类型和返回类型决定,而函数应用的结果类型就是被调用函数的返回类型。理解这些规则是掌握OCaml强大类型系统的基础。

OCaml编程:2.14:部分应用

在本节中,我们将学习OCaml中一个称为“部分应用”的强大特性。这个特性可能与你之前在其他编程语言中见过的概念有很大不同,它允许我们只向函数提供部分参数,从而创建一个新的函数。

部分应用是函数式编程的标志性特性之一,它极大地增强了代码的表达能力和灵活性。


部分应用示例

让我们从一个简单的函数开始。以下是一个将两个整数相加的函数:

let add x y = x + y

这个函数接收两个参数 xy,并返回它们的和。我们可以像往常一样调用它:

add 2 3 (* 结果为 5 *)

然而,在OCaml中,我们也可以只提供第一个参数:

add 2

这个表达式的结果不是一个整数,而是一个函数。Utop(交互式环境)的输出会显示这是一个类型为 int -> int 的函数。这意味着 add 2 返回了一个新函数,这个新函数接收一个整数,并返回该整数与2的和。

因此,我们可以这样做:

(add 2) 3 (* 结果为 5 *)

这里的括号将 add2 组合在一起,先进行部分应用,生成一个新函数,然后再将这个新函数应用到参数 3 上。

为了更清晰地理解,我们可以分步进行:

let add2 = add 2

现在,add2 是一个函数,它将给任何输入值加上2。

add2 0 (* 结果为 2 *)
add2 10 (* 结果为 12 *)

这解释了为什么之前 (add 2) 3 的结果是5。


部分应用的原理

上一节我们通过示例看到了部分应用的效果,本节中我们来看看它为何能工作。这需要揭示一个关于OCaml函数的重要事实。

我必须坦白:我之前说OCaml有多参数函数,这并不完全准确。实际上,OCaml没有真正的多参数函数。

所有看起来像多参数的函数,都只是“语法糖”。它们实际上是一系列嵌套的单参数函数

例如,我们写的 add 函数:

let add x y = x + y

实际上是以下代码的简写:

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

或者更明确地:

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

这意味着 add 是一个接收参数 x 的函数,它返回另一个函数。这个返回的函数接收参数 y,并最终计算 x + y

这种设计之所以可能,完全得益于我们之前的核心决策:函数是值。函数可以像其他值一样被传递、返回和使用。部分应用正是“返回一个函数作为结果”这一能力的直接体现。

当我们写下 add 2 时,我们提供了第一个参数 x,于是得到了内部函数 fun y -> 2 + y。这就是部分应用。


核心概念总结

以下是部分应用与函数定义的核心要点:

  1. 函数定义的本质:形如 fun x y -> e 的表达式是 fun x -> (fun y -> e) 的语法糖。
  2. 部分应用:当向一个“多参数”函数提供少于其定义的参数时,会返回一个新的函数,这个新函数接收剩余的参数。
  3. 类型推导:函数 add 的类型是 int -> int -> int。这可以理解为 int -> (int -> int)。应用第一个 int 后,结果类型就是 int -> int

为何强大

部分应用是一个极其强大的语言特性。它允许我们轻松地从通用函数创建出特定的函数,促进了代码的复用和组合。这是函数式编程范式的基石之一,能够帮助你编写出更简洁、更模块化的代码。


本节课中我们一起学习了OCaml的部分应用。我们了解到,OCaml中看似多参数的函数实质上是柯里化的单参数函数链。部分应用允许我们固定函数的部分参数来创建新函数,这是实现函数组合和高阶抽象的关键工具。掌握这个概念,将为你打开函数式编程思维的大门。

020:多态函数 🧬

在本节课中,我们将要学习OCaml中的一个核心概念——多态函数。我们将了解什么是类型变量,以及如何编写能够处理多种不同类型参数的函数。

概述

多态性是编程语言中一个强大的特性,它允许我们编写能够适用于多种数据类型的代码。在OCaml中,这主要通过类型变量参数化多态来实现。本节我们将从一个简单的函数开始,深入探讨其工作原理。

恒等函数

让我们从一个重要的小函数开始——恒等函数。

以下是恒等函数的定义:

let id x = x

这个函数被称为“恒等函数”,因为它会原封不动地返回你传递给它的任何值。

例如:

  • 如果传递 5id,会得到 5
  • 如果传递 "hello"id,会得到 "hello"

类型变量

现在,请注意 id 函数的类型。它的类型与我们目前见过的所有类型都不同。

id 函数的类型是:

'a -> 'a

这个类型中包含一个有趣的语法:单引号a'a)。这实际上是OCaml中类型变量的语法。

我们一直使用的普通变量可以看作是值变量。而类型变量代表一个未知的类型,其方式与常规变量(值变量)代表一个未知的值类似。

你在Java中可能见过类似的功能。Java中 List<T> 的尖括号 T 就是一种在 List 类中对类型进行参数化的方式,其中名称 T 代表一个未知的类型。

在OCaml语法中,我们将类型变量写作任何其他类型的标识符,但在其前面加上一个单引号。因此,有时人们会说“单引号a”。为了简洁,我经常说“tick”,比如“tick A”。

当然,你也可以使用比 a 更长的变量名。例如,你可能有 'key'value,尤其是在处理字典时。但大多数情况下,我们编写的最简单的类型变量就是 'a

对于这种形式的类型变量,OCaml程序员通常使用希腊语发音:

  • 'a 读作 alpha
  • 'b 读作 beta
  • 'c 读作 gamma

在一个给定的表达式中,我们很少会使用超过三个类型变量。

多态性

这是一种多态性。“Poly”在这里意为“多”,“morph”意为“形式”。这是一种编写函数的方式,使其能够适用于多种类型的参数。

我们在 id 函数中看到了这一点。无论你传递整数、字符串还是布尔值给它,它都能工作。例如,id true 也会返回 true

这种多态性与你在Java中使用泛型时所见到的密切相关。它也与C++的模板实例化有些关联。这被称为参数化多态。它是一种让一段代码能够以多种方式运行的方法,具体取决于正在使用的参数类型。

总结

本节课中我们一起学习了OCaml中的多态函数。我们介绍了恒等函数作为起点,探讨了类型变量(如 'a)的语法和含义,并理解了参数化多态如何使函数能够灵活地处理多种数据类型。掌握这些概念是编写通用、可重用OCaml代码的关键。

021:运算符即函数 🧮

在本节课中,我们将深入探讨OCaml中的运算符,了解它们如何作为函数工作,以及如何定义和使用自定义的中缀运算符。

上一节我们介绍了函数和多态性,本节中我们来看看二元运算符的更多细节。

运算符作为函数

我们已经知道,可以使用加号运算符将两个数字相加,它被写作中缀形式,位于两个参数之间。

1 + 2

如果将二元运算符包裹在括号中,它实际上会变成一个函数。由于它是一个函数,我们将其写作前缀应用形式,即函数出现在其参数之前。

(+) 1 2

这种方法适用于任何二元运算符,但需要注意乘法运算符。

( * ) 3 4

括号加星号(*在OCaml中会开启一个注释,因此解析器会认为其右侧的所有字符都是注释的一部分,并等待注释关闭。因此,将运算符作为函数处理时,最好在括号和运算符符号之间留一个空格。

多态比较运算符

相等运算符=也是一个函数。它的类型是'a -> 'a -> bool,这是一个多态比较,允许我比较任意两个类型相同的第一个参数。

1 = 2

我可以比较12,因为它们都是整数。但我不能比较1false,因为它们类型不同。无论我将其写作前缀形式还是中缀形式,这一点都成立。

1 = false (* 这将无法通过类型检查 *)

不等式运算符也是多态的。小于运算符<的类型是'a -> 'a -> bool,你可以用它比较任意两个值。

但对此需要稍加注意。使用这些多态比较运算符来比较原始值是完全没问题的,但当处理更复杂的数据结构时,它们可能不是最合适的比较方式。对于更大的数据结构,你可能需要编写自己更有意义的比较运算符。

定义自定义中缀运算符

我可以根据需要定义自己的中缀二元运算符。例如,假设我们正在使用一个max函数。实际上,max已经内置在OCaml的标准库中。你给它任意两个相同类型'a的值,它会根据比较运算符返回较大的那个。

max 1 2 (* 结果是 2 *)

如果你想为max定义一个中缀运算符,你可以做到。我们需要选择一些标点符号来定义它。我决定使用^作为最大值的运算符,因为它看起来像是一个向上的箭头。

let ( ^ ) x y = max x y

但这还不能完全编译,我有一个语法错误。为了定义一个中缀二元运算符,我实际上必须将其包裹在括号中。

let ( ^ ) x y = max x y

现在,我有了一个新的中缀运算符。实际上,OCaml做了我应该做的事情,也在它周围加上了空格。让我回去这样做,以便更容易阅读。

let ( ^ ) x y = max x y

现在我可以使用这个^运算符来计算最大值。

1 ^ 2 (* 结果是 2 *)

请注意,当我将其写作中缀形式时,周围没有括号;当我将其写作前缀形式时,就像我在这里的定义一样,周围有括号。

关于如何构成中缀标点运算符的规则有点复杂,它们在OCaml手册中有说明。允许使用某些类型的标点符号,而有些则不允许。

总结

本节课中我们一起学习了OCaml中运算符作为函数的工作原理。我们了解到,任何中缀运算符都可以通过包裹在括号中转换为前缀函数形式,并需要注意乘法等特殊运算符的语法。我们还探讨了多态比较运算符=<的使用及其局限性。最后,我们学习了如何通过let (op) x y = ...的语法来定义自己的自定义中缀运算符,这为编写更具表达力的代码提供了灵活性。

022:应用运算符 🚀

在本节课中,我们将学习OCaml标准库中两个与函数应用相关的二元运算符:应用运算符和反向应用运算符。我们将了解它们的语法、用途以及如何利用它们编写更清晰、更优雅的代码。

应用运算符

上一节我们介绍了函数的基本应用方式。本节中,我们来看看一个特殊的运算符,它能让函数应用的书写在某些情况下更简洁。

OCaml标准库内置了两个与函数应用相关的二元运算符。第一个是应用运算符,第二个是反向应用运算符

应用运算符写作两个@符号(@@)。它的功能非常简单:它接收一个函数F和一个参数X,然后将F应用于X。其行为可以用以下公式描述:

F @@ X 等价于 F(X)

这个运算符如此简单,以至于你可能会觉得根本不需要它。我们马上会看到一个例子,说明它如何能派上用场。

为何需要应用运算符?

为了理解应用运算符的用途,让我们考虑一个具体的例子。

我们之前多次编写过increment函数,即将其参数加一的函数。实际上,这个函数已内置在OCaml标准库中,名为succ(后继函数)。

那么,succ 2的结果是什么?是3。如果你想计算succ 2 * 10的结果呢?

OCaml解析表达式的方式是,函数应用的优先级高于乘法等二元运算符。因此,OCaml会将其理解为(succ 2) * 10,结果是30

但也许你真正的意图是计算succ (2 * 10),即succ 20,结果是21。为了实现这个意图,你必须在2 * 10周围加上括号,写成succ (2 * 10)。这当然可以。

应用运算符可以改变这种解析方式。如果你在函数和表达式之间使用应用运算符,OCaml会以不同的方式解析它。它改变了优先级规则,使得OCaml认为:左边的语法片段是一个函数,右边的语法片段是一个需要单独求值的完整表达式。

因此,如果你在中间使用应用运算符,OCaml会先计算2 * 10,然后取其succ。代码如下:

succ @@ 2 * 10

所以,应用运算符可以帮助你避免书写括号。当应用运算符右侧的表达式很长时,不使用括号可能会让代码看起来更清晰。

反向应用运算符

接下来,我们看看反向应用运算符,它也被称为管道运算符

反向应用运算符做的是相反的事情:它接收一个参数X,然后是一个函数F。注意,它们的顺序与第一个运算符相反。然后它将F应用于X。其行为可以用以下公式描述:

X |> F 等价于 F(X)

反向应用运算符写作竖线后接大于号(|>),意在看起来像一个指向右边的三角形。其概念是让某个值从左到右通过一个管道。

为何需要反向应用运算符?

让我们通过一个例子来理解为什么反向应用运算符有意义且有用。

假设我们定义另一个函数:

let square x = x * x

想象一下,你想取数字5,先递增它,然后对结果进行平方。按照我们目前所学的写法,应该是:

square (succ 5)

这需要在succ 5周围加上括号,以强制OCaml以正确的方式解析。当然,如果我们省略这些括号,OCaml会将其解释为试图将square函数应用于succ函数,这在类型上是没有意义的。

如果你想要一个更复杂的版本,比如在平方之后再次平方,你就必须添加更多的括号:

square (square (succ 5))

然后,如果你还想对那个结果取后继,可能还需要添加更多括号。这样代码会变得有些臃肿。

反向应用运算符是解决这个问题的一个方案。使用它,你可以先写出参数,然后让它依次通过后续的函数。

假设你想取5,对其取后继,然后平方,然后再平方,最后再取后继。使用管道运算符,你可以这样写:

5 |> succ |> square |> square |> succ

这能得到相同的结果,而无需书写所有那些嵌套的括号,并且它产生了一种从左到右的阅读顺序。如果你习惯于使用从左到右阅读的语言,这是一种很自然的阅读方式:取5,通过succ函数,通过square函数,再通过square函数,最后通过succ函数。这就是它被称为管道运算符的原因:就像取一个值并让它通过一个管道。

这种写法在OCaml中是非常地道的。一旦你习惯了它,就能创造出非常优美的代码。

总结

本节课中我们一起学习了OCaml中的两个应用运算符:

  • 应用运算符 (@@)F @@ X 将函数F应用于参数X,可以帮助避免在复杂表达式右侧使用括号。
  • 反向应用/管道运算符 (|>)X |> F 将参数X传递给函数F,允许以从左到右的管道式风格组合多个函数调用,使代码更清晰、更易读。

掌握这两个运算符能帮助你编写出更简洁、更符合OCaml社区习惯的代码。

023:列表基础 📝

在本节课中,我们将要学习OCaml中一个核心的数据结构——列表。我们将了解列表的语法、类型、特性以及其适用的场景。

上一节我们深入探讨了OCaml的函数,现在我们将回到数据部分。

列表的语法与类型

OCaml中的列表写在方括号内。

空列表写作 []。空列表不包含任何元素,其类型是 'a list。你可以从右向左阅读这个类型,它是一个元素类型为 'a 的列表。之所以是 'a,是因为列表目前是空的,在元素类型上是参数化的。它不包含任何内容,因此不需要特定的元素类型。

一旦我们向列表中加入元素,例如整数 1,我们就得到了一个 int list。从右向左读,它是一个整数列表。

我们使用分号来分隔列表中的元素,例如 [1; 2; 3]。当然,列表中的元素必须是相同类型的。我们可以有浮点数列表 float list,布尔值列表 bool list,甚至可以有列表的列表。

以下是一个嵌套列表的例子:[[1; 2]; [3; 4]; [5; 6]]。这是一个 int list list,即一个“整数列表的列表”。

列表的构造操作符

列表还有另一种语法。如果你已经有一个列表,例如 [2; 3],并且想在它的前面添加一个新元素,你可以使用双冒号操作符 ::

1 :: [2; 3] 会得到列表 [1; 2; 3]。它将元素 1 添加到了列表的前端。事实上,我们可以仅使用 :: 和空列表来构造整个列表。

例如:1 :: 2 :: 3 :: [] 就是列表 [1; 2; 3]。OCaml会将其简化为方括号形式显示。

列表的特性

OCaml列表是不可变的,就像我们目前见过的其他OCaml数据一样。一旦创建了一个列表,你就不能删除其中的某个元素,或者将某个元素更改为其他值。你所能做的,就是基于旧列表构造新列表。我们刚才使用 :: 操作符所做的正是这件事。

OCaml列表是单向链表。单向链表只是表示列表的一种数据结构。例如,你可能还学习过数组列表,那是另一种实现。OCaml内置的列表是单向链表,因为它们在这种语言中运作得非常好。

但这并不意味着它们总是完全符合你手头工作的需求。数据结构就像语言或工具一样,只是工具。没有完美的数据结构。

列表的适用场景

OCaml的单向链表适用于以下场景:

以下是OCaml列表擅长处理的典型情况:

  • 数据处理:当你需要对数据进行顺序访问时。
  • 短到中等长度的列表:很难给出一个精确的数字,但大约在最多10000个元素以内,这些列表会工作得很好。

如果这不是你想要的,也没关系。将来如果需要,你可以寻找其他库的实现。但你会发现,当你习惯使用它们之后,这些列表对于你需要做的大部分事情都相当好用。

总结

本节课中我们一起学习了OCaml列表的基础知识。我们了解了列表使用方括号 [] 和分号 ; 的语法,以及使用 :: 操作符在列表前端添加元素的方法。我们明确了列表是不可变的,并且其内置实现是单向链表。最后,我们讨论了这种数据结构适用于顺序访问和处理短到中等长度数据的场景。下周,我们将深入研究OCaml中列表的实现。

024:列表语法与语义 📝

在本节课中,我们将要学习OCaml中列表的语法和语义。我们将了解如何创建列表、列表的类型规则以及其求值方式。


列表的语法

列表在语法上可以通过两种方式构建。

第一种方式是使用空列表,写作方括号 []。这被称为 nil,这个名字来源于Lisp语言,它是所有函数式语言的鼻祖。

第二种构建列表的方式是使用双冒号操作符 ::。其形式为 E1 :: E2,其中 E1E2 是表达式。这个操作符将元素 E1 添加到列表 E2 的开头。这个操作符的名字 cons 同样来源于Lisp。你可以将其理解为构造一个新列表:取一个元素,并将其放在另一个列表的开头,从而构造出一个新列表。

需要注意的是,列表是不可变的。使用 cons 操作符并不会改变原有的列表,它只是创建了一个新的列表。

我们最初看到的、使用方括号和分号的列表语法,实际上只是使用 nilcons 的语法糖。例如,你写的 [E1; E2] 这个列表,实际上是 E1 :: E2 :: [] 的简写。更长的列表也是如此,你只需不断地将元素 cons 到列表的开头。


列表的求值规则

列表的求值规则相当简单。

空列表 [] 本身就是一个值。

要计算 E1 :: E2,你需要分别计算 cons 操作符两边的表达式。即,计算 E1 得到值 V1,计算 E2 得到值 V2。当然,V2 必须是一个列表,因为我们正在将一个元素添加到它的开头。最终返回 V1 :: V2。一个由值构成的列表本身也是一个值。

作为这些规则的结果,我们现在知道了如何求值使用方括号和分号的语法糖。如果你想计算列表 [E1; E2],它意味着分别计算 E1E2 得到值,然后返回由这些值组成的列表。


列表的类型

列表类型涉及一个新的关键字 list。对于任何类型 T,类型 T list 描述了那些所有元素都是 T 类型的列表。没错,列表中的所有元素必须具有相同的类型。这实际上并不是一个限制,我们稍后会看到如何拥有混合类型的列表。

例如:

  • [1; 2; 3] 是一个 int list
  • [true] 是一个 bool list
  • 我们也可以拥有嵌套在其他列表中的列表,等等。

关于类型检查:

  • 空列表 [] 具有类型 'a list。另一种理解方式是,无论你想向一个空列表中添加什么类型的元素,你都可以做到,因为它目前是空的。因此,你可以认为这个列表可以具有你想要的任何元素类型。
  • 对于 cons 操作符,如果你思考它在做什么,你总能记住它的类型必须是什么。因为你正在将一个元素添加到另一个列表的前面,所以如果那个列表 E2 具有类型 T list,那么 E1 必须具有类型 T,因为它是要放入该列表的新元素,其类型必须与列表中已有元素的类型一致。最终,我们得到的列表只是在开头多了一个新元素,因此列表的类型不会改变。所以 E1 :: E2 将具有与 E2 相同的类型,即 T list

如果你觉得阅读其中的冒号有点困难,这里用括号来使其更清晰一些。当然,冒号在这些表达式中具有最低的优先级。所以 E2 具有类型 T list,而不是 E2 : T 具有类型 list 之类的。并且 E1 :: E2 具有类型 T list


本节课中我们一起学习了OCaml列表的两种构建语法([]::),理解了列表的求值规则(分别求值并组合),并掌握了列表的类型系统(T list 和类型检查规则)。列表是OCaml中基础且强大的数据结构。

025:记录与元组 📚

在本节课中,我们将要学习OCaml中的两种基本复合数据类型:记录(Records)和元组(Tuples)。它们都用于将多个数据项聚合在一起,但在结构和用法上有所不同。

记录(Records) 📝

记录是OCaml内置的一种基本数据类型,它允许我们将数据聚合在一起。与元组不同,记录的每个组成部分都有一个明确的名称,称为字段(field)。

定义记录类型

让我们从一个新文件开始编写代码。在VS Code中,我们创建一个名为 records.ml 的文件。

在文件中,我们可以定义一个表示学生的记录类型。例如,学生可能有一个姓名(字符串类型)和一个毕业年份(整数类型)。我们使用 type 关键字来定义记录类型。

(* 学生记录类型 *)
type student = {
  name : string;
  graduation_year : int;
}

创建记录值

定义类型后,我们可以创建该类型的值。例如,我们可以创建著名的康奈尔校友Ruth Bader Ginsburg的记录。

let rbg = {
  name = "Ruth Bader";
  graduation_year = 1954;
}

我们可以为变量 rbg 添加类型注解 : student,但OCaml的类型推断通常能自动识别出来。在交互式环境(utop)中,我们可以使用 #use "records.ml" 指令来加载文件中的代码,然后就可以操作这些定义了。

访问记录字段

要访问记录中的某个字段,我们使用点号(.)后跟字段名。

(* 获取rbg的姓名 *)
rbg.name

在utop中执行上述代码会返回字符串 "Ruth Bader"

重要提示:在utop中加载文件后,如果返回源代码修改了定义(例如更改毕业年份),utop中的值不会自动更新。你需要退出utop并重新加载文件,或者使用 #use 指令再次加载,以确保使用最新的定义。这是一种良好的使用习惯,可以避免旧定义造成混淆。


上一节我们介绍了如何使用记录来聚合具有命名字段的数据。本节中,我们来看看另一种聚合数据的方式:元组。

元组(Tuples) 🔢

元组是OCaml中另一种内置的聚合数据类型。与记录不同,元组的组成部分是匿名的,它们通过位置来区分。

定义与创建元组

让我们创建另一个文件 tuples.ml 来探索元组。

元组使用逗号将值分隔,并通常用括号括起来。例如,下面是一个包含三个组件的元组,表示时间(小时、分钟、上午/下午)。

let class_time = (10, 10, "am")

我们可以为这种“时间”元组定义一个类型别名,使其更易读。

type time = int * int * string

现在,class_time 的类型会被推断为 int * int * string,这与 time 类型是等价的。我们可以显式地添加类型注解。

let t : time = (10, 10, "am")

同样,我们可以为二维平面上的点定义一个元组类型。

type point = float * float
let p : point = (5.0, 3.5)

访问元组组件

对于二元组(即只有两个组件的元组,也称为“对”),OCaml标准库提供了两个内置函数来访问其组件:

  • fst:获取第一个组件。
  • snd:获取第二个组件。

在utop中加载 tuples.ml 文件后,我们可以进行测试:

(* 获取点p的第一个分量(x坐标) *)
fst p (* 返回 5.0 *)

(* 获取点p的第二个分量(y坐标) *)
snd p (* 返回 3.5 *)

请注意fstsnd 函数仅适用于二元组。对于具有三个或更多组件的元组(如我们的 time 类型),不能使用这两个函数。我们将在后续课程中学习如何使用模式匹配来访问任意长度元组的组件。


本节课中我们一起学习了OCaml中两种重要的复合数据类型:记录和元组。

  • 记录使用命名字段来聚合数据,通过 record.field 语法访问,提高了代码的可读性和可维护性。
  • 元组通过位置聚合匿名数据,结构紧凑。对于二元组,可以使用 fstsnd 函数进行访问。

理解这两种数据结构的区别和适用场景,是构建更复杂OCaml程序的基础。在接下来的课程中,我们将学习如何更灵活地处理和分解这些数据。

OCaml编程:3.4:数据类型的比较 🆚

在本节中,我们将学习如何在OCaml的三种基本数据类型——列表、元组和记录——之间做出选择。我们将通过分析数据的长度和访问方式来指导决策。

当您尝试决定在OCaml内置的三种基本数据类型(列表、元组或记录)中使用哪一种时,可以遵循以下思考过程。

首先,考虑数据的长度。如果数据的长度是无界的,那么您可能想要使用列表,因为列表的长度是无界的。😡 但是,元组和记录具有有界长度,它们有特定数量的组件或字段。

因此,如果您要表示具有有界长度的内容,例如我们之前的骆驼例子中只需要表示两个数字,您可能更倾向于使用元组或记录。

接下来,关于如何在元组和记录之间做出决定。

您将如何访问数据是一个重要问题。您希望通过位置访问还是通过名称访问?

通过位置访问意味着您想要像元组的第一组件或第二组件那样获取数据。对于点坐标这样的数据,这通常是有意义的。我们通常认为点坐标是按位置排序的,例如先有X坐标,然后是Y坐标,可能还有Z坐标。

而记录则通过名称访问。您有字段名,并且可以基于该字段名提取记录的一部分。在我们骆驼的例子中,字段名是驼峰数或骑手数。

以下是选择数据类型时需要考虑的关键因素:

  • 数据长度:无界长度选择列表;有界长度考虑元组或记录。
  • 访问方式:按固定位置访问选择元组;按语义名称访问选择记录。

本节课中,我们一起学习了如何根据数据的长度(有界/无界)和访问方式(按位置/按名称)来选择合适的OCaml数据类型(列表、元组或记录)。理解这些基本原则将帮助您为程序中的数据构建更清晰、更有效的表示形式。

027:记录语法与语义 📝

在本节课中,我们将要学习OCaml中的两种新数据类型:记录(Records)和元组(Tuples)。我们将重点研究它们的语法和语义。

概述

上一节我们介绍了数据类型的基本概念。本节中,我们来看看两种复合数据类型:记录和元组。与它们一同引入的,还有一种新的定义方式——类型定义。我们之前见过使用 let 语法定义值,现在我们将使用 type 关键字来定义类型。

记录类型与元组类型

我们有两种新的类型:记录类型和元组类型。本节我们先聚焦于记录类型。

记录语法

记录写在花括号 {} 内。字段名之间用分号 ; 分隔。在字段名和要存储在该字段中的表达式之间,我们使用等号 =

所以,{f1 = e1; f2 = e2} 就是一个包含字段名 f1f2 的记录。

记录中字段的书写顺序无关紧要。我们可以按任意顺序书写它们,OCaml不会根据字段顺序来区分不同的记录。

一个记录中可以包含任意数量的字段,从一个到大约四百万个。当然,你永远不会有一个那么大的记录。实际上,你可能会有三、四、五个,甚至八个字段。

字段访问

我们使用点号 . 来访问记录中的字段。因此,e.f 访问的是记录表达式 e 中名为 f 的字段。

重要的是要记住,这里的 f 是一个字段名,在语法上必须是一个标识符。它不能是一个需要计算得出的表达式。如果你想要后者,那么你实际上想的是字典(dictionary),我们将在后面学习字典。

记录求值

如何对记录进行求值?虽然用很多符号、文字和数学来描述,但概念其实相当简单。

只需将所有表达式求值为值。如果你有一组表达式 e1en,假设每个 ei 都求值为 vi,那么包含所有这些 e 的记录表达式就会求值为一个内部只包含这些值的记录值。

至于字段访问,如果 e 求值为一个记录值,并且该记录值有一个名为 f 的字段绑定到值 v,那么 e.f 就求值为 v。我们所做的就是从记录中取出该字段的值。

类型检查

在类型检查方面,这里有一个小特点:记录类型必须在被使用之前定义。这并不奇怪,就像在Java中,你不能在定义类之前使用类。同样,在OCaml中,你不能在定义特定的记录类型之前使用它。这是为了让OCaml知道该记录类型将有哪些字段名。

理论上可以设计记录类型和类型推断,使得你不必先定义它们,但那样就无法获得同样好的类型推断效果。

以下是记录的类型检查规则:

  • 如果你构造一个记录表达式,那么根据记录的定义,组成它的所有子表达式都必须具有正确的类型。如果一个字段名为 gpa,类型是 float,你就不能把 int 放进去。
  • 如果你使用点符号访问记录的一部分,那么你将获得相应类型的字段。

记录复制

另一个我们尚未见过的记录语法是记录复制。

如果你写 {e with f1 = e1},这将创建一个记录 e 的副本,并为字段 f1 赋予新值 e1

让我们快速尝试一个例子。

回到之前的代码,我们有记录和 rbg。让我们把那段代码加载到交互式环境(utop)中。

# use "records.ml";;

我们可以创建一个 rbg 的副本。比如写 {rbg with name = "Ruth Bader Ginsburg"}。现在我们得到了一个新记录,其中的名字已被更改。

这并没有回去改变原始记录,它仍然是不可变的,原始记录的名字仍然是 "Ruth Bader"。所以记录复制不是突变操作,它保持原始记录不变。

你还可以通过用分号链接多个 字段名 = 表达式,在同一时间替换记录中的多个值。

实际上,记录复制只是语法糖的另一个例子。它本质上是将整个记录用所有字段名重写的语法糖,在 with 子句中使用所有这些字段名,然后对于你没有在 with 子句中提到的字段名,则使用原始记录中的值。

但是,如果记录有很多字段,而你只想更新其中一个字段,使用记录复制语法会方便得多。

注意,你不能使用这个语法来添加新字段。那会改变记录的类型,这是不允许的。

总结

本节课中,我们一起学习了OCaml中记录类型的语法和语义。我们了解了如何定义和构造记录,如何使用点符号访问字段,以及记录求值和类型检查的基本规则。我们还介绍了记录复制语法,它是一种方便地创建部分字段被修改的新记录副本的方法,同时强调了记录的不可变性。理解记录是构建更复杂数据结构的重要一步。

028:元组语法与语义 📦

在本节课中,我们将要学习OCaml中元组的语法和语义。元组是一种将多个值组合成一个复合值的数据结构,其使用比记录更为简单。

元组语法

元组的语法比记录更简单。在圆括号内使用逗号分隔的表达式即可构成一个元组。

以下是元组语法的基本形式:

  • (E1, E2) 是一个元组。
  • 同样,(E1, E2, E3) 也是一个元组。
  • 你可以在此处使用任意多个由逗号分隔的子表达式。

我们通常根据元组中组件的数量来称呼它们:

  • 包含两个组件的元组称为
  • 包含三个组件的元组称为 三元组
  • 依此类推,可以有四元组、五元组等。

但在实际编程中,我们通常不会在元组中使用超过两到三个组件。

元组语义

上一节我们介绍了元组的语法,本节中我们来看看其语义,即元组的含义和使用规则。

在元组中,组件的顺序至关重要。这是因为元组是通过位置来访问的,而不是像记录那样通过名称访问。因此,位置信息是关键。

求值规则:对元组中的每个子表达式进行求值,得到一个值,最终结果就是一个对值、三元组值或元组值。

类型检查规则:元组的每个组件自身都需要有一个类型。整个元组的类型使用星号 * 来书写,用于分隔各组件的类型。

让我们回到示例代码中查看这一点。

当我为时间元组编写类型时,我写了 int * int * string。你可以将这里的星号视为分隔元组中每个组件的类型。

请注意,这里的星号 * 不是乘法。我们在语言的语法中使用了星号的几种不同方式,但你可以区分清楚,因为这里的星号是作为类型的一部分出现的,而不是作为值的一部分。在值中,星号 * 表示乘法。

从某种意义上说,这确实是一种“乘法”。如果你学过离散数学(CS2800),可以将元组类型中的星号视为代表一种笛卡尔积



本节课中我们一起学习了OCaml中元组的语法和语义。我们了解到元组通过圆括号和逗号定义,通过位置访问,其类型由组件类型通过星号 * 连接表示。元组提供了一种轻量级的方式来组合固定数量的相关值。

029:模式匹配 🧩

在本节课中,我们将学习OCaml中一个非常强大的特性:模式匹配。模式匹配允许我们根据数据的结构来分解和检查数据。我们将从基础用法开始,逐步深入到更复杂的应用。

概述

模式匹配是OCaml的核心特性之一,它让我们能够以一种声明式的方式检查和解构数据。无论是简单的布尔值、整数,还是复杂的列表、元组和记录,模式匹配都能优雅地处理。

基础模式匹配

上一节我们介绍了OCaml的基本数据类型,本节中我们来看看如何使用模式匹配来分解这些数据。

一个最简单的模式匹配示例如下:

let x = match not true with
  | true -> "nope"
  | false -> "yep"

这段代码匹配 not true(即 false)的值。由于表达式结果为 false,所以整个匹配表达式的结果是 "yep"

模式匹配的语法结构如下:

  • 关键字 match 后跟要匹配的表达式。
  • 关键字 with 后跟一个或多个分支。
  • 每个分支由竖线 |、一个模式、箭头 -> 和一个结果表达式组成。

匹配变量与通配符

除了匹配具体的值,我们还可以使用变量来捕获匹配到的值。

let y = match 42 with
  | a -> a

在这个例子中,变量 a 会捕获整数值 42,并在右侧的表达式(这里就是 a 本身)中使用。

有时我们并不关心某些值,这时可以使用通配符 _

let z = match "foo" with
  | "bar" -> 0
  | _ -> 1

因为字符串 "foo" 不等于 "bar",所以匹配到通配符分支,结果为 1。通配符分支通常放在最后,作为默认情况。

解构复合数据

模式匹配真正强大的地方在于解构列表、元组和记录等复合数据类型。

解构列表

以下是检查列表是否为空的示例:

let a = match [] with
  | [] -> "empty"
  | _ -> "not empty"

我们还可以解构非空列表,获取其头部和尾部。

let b = match ["Taylor"; "Swift"] with
  | [] -> "folklore"
  | h :: t -> h

在这个匹配中:

  • 列表 ["Taylor"; "Swift"] 不是空列表。
  • 它匹配模式 h :: t,其中 h 被绑定为 "Taylor"t 被绑定为列表 ["Swift"]
  • 因此,整个表达式的结果是 "Taylor"

解构元组

假设我们想获取三元组的第一个元素,而标准库的 fst 函数只适用于二元组。

let fst3 triple = match triple with
  | (a, b, c) -> a

现在,调用 fst3 (1, 2, 3) 将返回 1

解构记录

对于记录类型,我们也可以通过模式匹配按字段名解构。

type student = { name : string; year : int }

let name_with_year s = match s with
  | { name; year } -> name ^ " '" ^ (string_of_int (year mod 100))

这个函数接收一个学生记录,并返回类似 "Ruth Bader '54" 的字符串。在匹配分支中,字段 nameyear 被自动绑定到记录 s 的对应值上。

总结

本节课中我们一起学习了OCaml的模式匹配。我们了解到:

  1. 模式匹配使用 match ... with 语法。
  2. 可以匹配常量(如 true, 42, "bar")。
  3. 可以使用变量(如 a)来捕获值。
  4. 可以使用通配符 _ 匹配任意值,通常作为默认分支。
  5. 模式匹配能优雅地解构列表(使用 []::)、元组和记录。
    模式匹配是编写简洁、安全OCaml代码的基石,它强制你考虑所有可能的数据情况,从而减少错误。

030:列表的模式匹配 🧩

在本节课中,我们将要学习如何对列表进行模式匹配。模式匹配允许我们同时完成两件事:匹配数据的形状,并从中提取出部分数据。列表只有两种可能的形态:空列表(nil)或一个元素与另一个列表的组合(cons)。我们将通过编写几个函数来深入理解这个概念。

列表的两种形态

上一节我们介绍了模式匹配的基本概念,本节中我们来看看如何将其应用于列表。列表只有两种构成方式:

  • 空列表:写作 []
  • 非空列表:写作 h :: t,其中 h 是列表的第一个元素(头部),t 是剩余元素组成的列表(尾部)。

判断列表是否为空

以下是判断列表是否为空的函数实现方式。

let empty lst =
  match lst with
  | [] -> true
  | _ :: _ -> false
  • [] -> true:如果列表为空,则返回 true
  • _ :: _ -> false:如果列表非空(即头部和尾部存在,但我们不关心具体值),则返回 false。这里的下划线 _ 表示我们不绑定变量名。

计算列表元素之和

接下来,我们看看如何递归地计算一个整数列表中所有元素的和。

let rec sum lst =
  match lst with
  | [] -> 0
  | h :: t -> h + sum t

  • [] -> 0:空列表的和定义为 0
  • h :: t -> h + sum t:非空列表的和等于其头部元素 h 加上其尾部列表 t 的和。这里 sum t 是对函数自身的递归调用。

我们可以使用 trace 指令来观察递归调用的过程:

#trace sum;;
sum [1; 2; 3];;

这将展示函数 sum 如何一步步递归处理列表 [1; 2; 3],最终返回结果 6。完成后,可以使用 #untrace sum;; 停止追踪。

计算列表长度

现在,我们编写一个函数来计算任意列表的长度。

let rec length lst =
  match lst with
  | [] -> 0
  | _ :: t -> 1 + length t
  • [] -> 0:空列表的长度为 0
  • _ :: t -> 1 + length t:非空列表的长度等于 1(头部元素)加上尾部列表 t 的长度。同样,这里 length t 是递归调用。

连接两个列表

最后,我们实现一个将两个列表连接起来的函数。这个函数会返回一个新列表,其内容为第一个列表的所有元素后接第二个列表的所有元素。

let rec append lst1 lst2 =
  match lst1 with
  | [] -> lst2
  | h :: t -> h :: append t lst2
  • [] -> lst2:如果第一个列表为空,则结果就是第二个列表 lst2
  • h :: t -> h :: append t lst2:如果第一个列表非空,则结果由其头部元素 h 和“尾部列表 tlst2 连接的结果”共同构成。append t lst2 是递归调用。

在OCaml标准库中,append 函数已内置,并且可以使用中缀运算符 @ 来调用,例如 [1; 2; 3] @ [4; 5; 6]


本节课中我们一起学习了如何对列表进行模式匹配。我们了解到列表只有空和非空两种形态,并利用这一特性编写了判断空列表、求和、计算长度以及连接列表的递归函数。通过 trace 指令,我们可以直观地观察递归函数的执行过程。掌握列表的模式匹配是编写高效、清晰OCaml代码的基础。

031:function关键字 🎯

在本节课中,我们将要学习OCaml中的一个便捷语法——function关键字。它用于简化那些需要立即对最后一个参数进行模式匹配的函数定义,可以减少代码中的样板内容。


立即模式匹配的常见模式

上一节我们介绍了列表相关的函数。你可能会注意到,在empty函数以及我们编写的许多其他列表函数中,我们都会立即对函数的参数进行模式匹配。

例如,listempty函数的参数,我们在这里立即对它进行了模式匹配。

这种立即对参数进行模式匹配的做法非常符合OCaml的惯用风格,并且十分常见。因此,OCaml为这种模式提供了一种语法形式。


function关键字的语法

这同样是一种语法糖。语言本身并不一定需要它,但它能让代码更简洁。

假设你有一个函数,它有一些参数(这里我写了xyz,但参数数量可以是任意的),并且你想要立即对最后一个参数进行模式匹配。

这正是empty函数的工作方式,因为它只有一个参数。

在这种情况下,你可以省略最后一个参数,省略match表达式的开头部分,并用function关键字替换所有这些内容。

以下是具体变化:

  • 参数z消失了。
  • match z with这一行也消失了。
  • 我们在上面写上了function关键字。

这带来了更简洁的函数定义,无需重复编写那么多样板代码。


应用示例与限制

我可以回过头来用这种方式清理我们目前编写的函数。

以下是具体示例:

  • 对于empty函数,我可以将其替换为= function。这消除了整行代码。
  • 对于some函数,我可以做同样的事情:= function
  • 对于length函数,我也可以这样做,因为我针对最后一个参数进行了模式匹配:= function

然而,对于append函数,我无法使用function关键字。因为function关键字意味着立即对最后一个参数进行模式匹配,而append函数的实现方式并非如此——它是对其第一个参数进行模式匹配。因此,function关键字在这里帮不上忙。

不过,确实也不需要为匹配其他任意参数再设计另一种语法。能够方便地对最后一个参数进行匹配已经足够有用了。


总结

本节课中我们一起学习了function关键字。它是一种语法糖,用于简化那些需要立即对函数最后一个参数进行模式匹配的函数定义,从而减少代码中的重复样板。我们通过emptysomelength函数演示了其用法,同时也指出了它在append这类匹配第一个参数的函数中并不适用。掌握这个关键字有助于你写出更简洁、更地道的OCaml代码。

032:Cons与Append操作符详解 📚

在本节课中,我们将详细探讨OCaml中两个核心的列表操作符:cons::)和append@)。我们将分析它们的类型、功能以及性能差异,这对于编写高效的程序至关重要。

操作符概述

上一节我们介绍了模式匹配,现在让我们暂时离开那个话题,来仔细看看这两个列表操作符。

Cons操作符 (::)

Cons操作符写作双冒号 ::。它的作用是将一个元素添加到列表的头部。你可以将其理解为“前置”一个元素。

它的类型反映了这一功能:

'a -> 'a list -> 'a list

你提供一个类型为 'a 的元素和一个包含 'a 类型元素的列表,它会返回一个新的包含 'a 类型元素的列表。

我们将在下周看到它的实现。但现在可以告诉你,它的时间复杂度是常数时间,速度非常快。如果你还记得单链表是如何实现的,这应该不足为奇。你需要做的只是分配一个节点并设置一个指针。

Append操作符 (@)

Append操作符写作 @ 符号。它的作用是将两个列表连接在一起。

它的类型同样反映了其功能:

'a list -> 'a list -> 'a list

它的两个参数类型都是 'a list。因此完整的类型是 'a list -> 'a list -> 'a list

比较这两个操作符的类型,你会发现它们的第一个参数不同。对于Cons操作符,第一个参数是 'a(一个元素);对于Append操作符,第一个参数是 'a list(一个列表)。

性能差异分析

我们刚刚看到了Append操作符的实现,你可以思考一下。它的运行时间与第一个列表的长度成线性关系

以下是该实现的再次展示:

let rec append lst1 lst2 =
  match lst1 with
  | [] -> lst2
  | h :: t -> h :: (append t lst2)

我们通过模式匹配遍历第一个列表,直到检查完其中的每一个元素,并将每个元素通过Cons操作添加到新列表的前面。因此,无论第一个列表中有多少个元素,这都将是该函数的运行时间。

核心区别总结

所以,Cons和Append的核心区别在于:

  • :: (Cons): 在列表头部添加一个元素。时间复杂度为 O(1)(常数时间)。
  • @ (Append): 将两个列表连接在一起。时间复杂度为 O(n)(线性时间),其中 n 是第一个列表的长度。


本节课中,我们一起学习了OCaml中cons::)和append@)这两个关键列表操作符。我们明确了它们的功能、类型签名,并重点理解了它们在性能上的根本差异:::是高效的常数时间操作,而@的性能则取决于其第一个输入列表的长度。在编写程序时,根据需求选择合适的操作符对效率至关重要。

033:模式匹配语法与语义 🧩

在本节课中,我们将要系统地学习OCaml中模式匹配的语法和语义。我们已经见过许多模式匹配的例子,现在让我们退一步,深入理解其背后的规则。

概述

模式匹配是OCaml中一个强大且核心的特性,它允许我们根据数据的结构来解构和检查值。本节将详细介绍其语法、求值规则和类型检查机制。

语法

模式匹配的语法使用两个关键字:matchwith

match E with
| P1 -> E1
| P2 -> E2
...
| Pn -> En

我们使用一个表达式 E 与一系列模式 P 进行匹配。对于每一个模式,都有一个对应的表达式 E 可能在匹配成功后被求值。

模式匹配中的每一行被称为一个分支。一个模式匹配可以包含多个分支,甚至只有一个分支,正如我们之前所见。

这里的 P 代表了一个新的语法类别——模式表达式。这些模式表达式看起来或多或少像OCaml中的其他表达式,这也是为什么我能在不专门讨论语法的情况下就引入它们。到目前为止,唯一真正的新语法是下划线 _

模式还有其他一些语法形式,你可以在教材中找到它们。

求值语义

对于一个 match 表达式的求值,遵循以下步骤:

  1. 首先,我们需要将表达式 E 求值为一个值 V
  2. 然后,从上到下遍历模式匹配的各个分支。
  3. 尝试将值 V 与第一个模式 P1 进行匹配。
    • 如果匹配成功,我们就找到了正确的分支,不再查看后续分支。
    • 接着,我们对该分支右侧的表达式 E1 进行求值。E1 求值后得到的值 V1 就是整个 match 表达式的返回值。
  4. 如果 P1 不匹配,则继续尝试 P2。如果 P2 也不匹配,则继续尝试 P3,依此类推,直到找到一个匹配的模式。
  5. 如果没有找到任何匹配的模式,则会引发一个名为 Match_failure 的异常。

我们不希望程序中出现异常。因此,到目前为止我与你一起编写的所有代码都避免了任何可能导致此类匹配失败的匹配模式。我们很快会看到一个例子。

类型检查

match 表达式的类型检查,主要由我们刚刚讨论的求值语义驱动。

以下是类型检查的核心规则:

  • 所有模式 P 以及被匹配的表达式 E,都必须具有相同的类型。这是合理的,因为我们正在用这些模式来匹配该表达式的值。
  • 同样,整个 match 表达式的类型,必须与所有可能从任何分支返回的结果的类型相同。因此,所有子表达式 E1En 都必须共享相同的类型,这个类型也将是整个 match 表达式的类型。

更严谨地说,如果 E 和所有模式 P 具有类型 T,并且所有 Ei 具有(可能不同的)类型 T',那么整个 match 表达式就具有类型 T'

模式语法与匹配规则

关于模式语法,其最基本的组成部分是标识符、下划线和任何你喜欢的常量。

  • 标识符 是模式变量。
  • 下划线 是通配符。
  • 常量 是像 42、字符串 "Hello" 或布尔值 true 这样的语法。

匹配本身是如何工作的呢?

  • 一个常量匹配它自身,所以 42 匹配 42
  • 当你有一个标识符(即模式变量)时,它会匹配任何值,并且会在该分支的作用域内绑定它所匹配的值。我们在列表函数的例子中已经见过很多这样的例子。
  • 下划线(即通配符)匹配任何值,但它不进行绑定。我们也见过这样的例子。

对于更复杂的模式语法,每种数据类型也有其自己的模式形式。

  • 我们已经见过空列表 []:: 操作符作为模式的一部分,因为它们是构建列表的两种方式。
  • 注意,@ 不是一个模式,它只是我们编写的一个函数。
  • 你也可以使用方括号语法作为模式。
  • 对于元组和记录,你也可以将它们写成模式,并且这些模式内部可以嵌套其他模式。

所有这些模式都将匹配相应的数据类型值,并可以组合它们的各个部分。关于如何嵌套这些模式、进行深度模式匹配,以及使用表示“一个模式或另一个模式”的其他类型模式,其细节非常丰富,你可以在教材中找到所有相关内容。

总结

本节课中我们一起学习了OCaml模式匹配的核心知识。我们了解了其基本语法结构,包括 match...with 关键字和分支的写法。我们深入探讨了它的求值语义:表达式先被求值,然后按顺序与模式进行匹配,匹配成功则执行对应分支,否则可能引发异常。我们还学习了类型检查规则,要求被匹配表达式、所有模式以及所有分支的返回值类型必须一致。最后,我们回顾了基础的匹配规则,如常量匹配自身、变量绑定值、通配符匹配但不绑定等,并提及了列表、元组等数据类型的模式写法。掌握这些是编写正确、高效OCaml代码的基础。

OCaml编程:3.12:模式匹配的静态检查 🧐

在本节课中,我们将学习OCaml编译器如何通过静态检查来帮助我们避免模式匹配中的常见错误。我们将看到编译器不仅能进行类型检查,还能检查模式匹配的完整性和冗余性,从而在代码运行前就发现潜在的问题。


我们已经多次讨论过静态语义。我之前提到过,静态语义通常涉及类型检查。

但静态语义的内容可以不止于类型检查。在模式匹配方面,OCaml所做的就不仅仅是类型检查。让我展示一些有趣的例子。

检查模式匹配的完整性

以下是一段用于判断列表是否为空的代码。我将其命名为 bad_empty。实际上,OCaml会给我一个警告。那个波浪下划线处,当我悬停时,会显示“警告:此模式匹配不完整。这里有一个未被匹配的例子:_::_”。(Merlin是帮助OCaml显示此信息的IDE插件。)

所以,对于被匹配的表达式 lst(一个列表),存在一种情况我没有覆盖到。我对数据可能形态的覆盖是不完整的。更简单地说,我遗漏了一个分支。match lst with [] -> true,但我忘记了处理非空列表的分支。

为什么这是个问题?这里的代码存在缺陷。事实上,如果我不小心,这段代码会引发异常。

让我们在终端中看看这种情况。使用那段匹配代码。你可以再次看到警告,但我将忽略它并尝试使用这段代码。

bad_empty 在空列表上返回什么?true,它是空的。但在包含一个元素的列表上呢?我得到了一个异常,一个匹配失败异常。我们不希望在运行时出现异常,它们会导致程序崩溃,让用户不满意。

因此,我们需要关注编译器给出的警告并修复它。这里,我应该意识到,哦,是的,对于任何其他列表,我应该返回 false。现在我得到了该函数的一个良好实现。

检查冗余的模式分支

让我们看下一个例子。这里我试图对列表元素求和,但我又犯了一个错误。OCaml会就模式匹配给我一个警告:“此匹配分支未被使用”。

OCaml的意思是,它已经推断出,无论被匹配的表达式 lst 的值是什么,这个特定的分支都永远不会被匹配到。

那么,为什么这里永远不会用到它呢?让我们仔细看看。

如果列表非空(即 head::tail),我们将把 head 加到剩余列表的和中。这看起来没问题。如果列表只有一个元素,那么单元素列表的和就是 x,这看起来也对。如果列表为空,其和应为 0,这也对。那为什么这个分支是未使用的?为什么它是冗余的?

请记住,列表的方括号表示法实际上只是语法糖。我本可以将其写为 x::[]。实际上,作为一个模式,它仍然是冗余的。事实上,那个匹配分支未被使用。但现在可能更清楚为什么它是冗余的了。

想想上面第一个分支会发生什么。那个模式将匹配任何具有头部元素和尾部的列表,无论尾部有多少个元素(10个、1个,甚至是0个)。而这正是第二个分支所考虑的情况:一个具有头部元素(无论我们称它为 x 还是 h)和一个空尾部的列表。

因此,当你尝试匹配只有一个元素的列表时,第一个分支总是会被触发,我们总是会匹配到它,并用 bad_sum 进行递归调用。

在这个例子中,这并不意味着代码有特别严重的错误。如果我们运行它:空列表的 bad_sum0,列表 [1;2;3]bad_sum6。代码仍然产生了正确的结果。

但从代码质量的角度看,它并不好,因为这里有一些永远不会被执行的代码。它不应该存在于我们的代码库中,我们不需要维护它。通常,这意味着我们没有足够仔细地考虑某些情况,需要重新思考我们的代码。在这种情况下,我们可以直接删除整行来简化函数。

避免使用 hdtl 函数

作为第三个例子,让我们看一段甚至没有模式匹配的代码。这是 sum 的另一个实现。让我们尝试运行它。我们有 bad_sum_prime

如果我尝试对列表 [1;2;3] 求和,我得到了一个失败,一个异常。这是怎么回事?

这个版本的 sum 实现使用了两个库函数:List.hdList.tl。正如你可能猜到的,它们代表头部(head)和尾部(tail),旨在给我们列表的头部和尾部。

所以,你可以取列表 [1;2;3] 的头部,得到 1。你可以取列表 [1;2;3] 的尾部,得到 [2;3]

那么,当这些函数应用于空列表时会发生什么?对于空列表,你无法返回其头部;对于空列表,你也无法返回其尾部。因此,标准库在这两种情况下都会引发一个名为 Failure 的异常(我们将在下周更详细地学习异常)。

但这里 bad_sum_prime 的实现使用了 hdtl,而不是模式匹配。它说:取列表的头部,并将其与对列表尾部进行递归调用的结果相加。

本质上,这正是 bad_sum 在这里所做的:试图将列表的头部与对尾部递归调用的结果相加。但通过不使用模式匹配,而使用 hdtl,编写这段代码的人实际上遗漏了列表可能的一种情况:他们忘记了它可能是空的。

这有点类似于我们第一个例子中的相反情况,那里的程序员忘记了列表可能是非空的。这就是使用 hdtl 的危险之处:它们可能导致在空列表上引发异常。

因此,作为一种良好的实践,使用模式匹配来访问列表元素比使用 hdtl 函数更好。OCaml通过模式匹配提供的静态检查因此是一个优势,因为它有助于防止有缺陷的代码。虽然偶尔可能会有你想使用标准库中的 hdtl 函数的情况,但这里的情况并非如此。


总结

本节课中,我们一起学习了OCaml编译器如何对模式匹配进行静态检查。我们看到了编译器如何检查模式匹配的完整性(避免遗漏分支)和冗余性(避免无用的分支)。我们还了解到,相比于直接使用 List.hdList.tl 函数,使用模式匹配来解构列表是更安全、更受编译器保护的做法,因为它能在编译时强制我们处理所有可能的情况,从而避免运行时异常。

请务必不要忽略这些警告。编译器正在为你发现错误,并给你修复它们的机会。请充分利用这一点。

035:变体类型 🧩

在本节课中,我们将学习OCaml中的变体类型。这是一种强大的方式,用于创建自定义的数据类型,它允许我们定义一组可能的值,每个值可以携带额外的数据。

上一周我们学习了记录和元组,这是OCaml中创建自定义数据类型的两种内置方式。本节中,我们来看看变体类型,这是一种构建数据类型的令人兴奋的新方法。

简单的变体:枚举

变体类型初看起来可能很熟悉。在其他语言中,你可能见过允许枚举一个类型中不同常量的类型,有时被称为枚举。OCaml的变体也有类似的功能。

我们可以使用 type 关键字定义一个新类型,并列出其可能的值。

type primary_color = Red | Green | Blue

现在我们可以拥有该类型的值。

let r : primary_color = Red

请注意,即使不添加类型注解,OCaml也能推断出 rprimary_color 类型。这是最简单的变体类型。

携带数据的变体

但还有更有趣的变体。让我们尝试为形状创建一个变体类型。我们将在笛卡尔平面中表示形状。

首先,回忆一下我们用于点的类型。

type point = float * float

这是一个浮点数元组,代表笛卡尔平面中具有X和Y坐标的点。

现在,让我们为形状创建一个类型。世界上有很多形状,我们创建圆形和矩形。

type shape =
  | Circle of { center : point; radius : float }
  | Rectangle of { lower_left : point; upper_right : point }

圆形和矩形除了形状本身,还需要在平面中定位。因此,我们需要在变体构造器名称之外携带额外的信息。

我们可以通过在构造器名称后使用 of 关键字来实现。这表示它不仅仅是一个“圆形”,还包含了与“它是圆形”这一事实相关的其他数据。

对于圆形,我们需要一个中心点和一个半径。我们使用记录类型来组织这些数据,包含名为 center(类型为 point)和 radius(类型为 float)的字段。

现在我们可以创建这样的圆形。

let c1 = Circle { center = (0.0, 0.0); radius = 1.0 }

c1 现在是 shape 类型。

对于矩形,我们决定用两个点来表示:其左下角坐标和右上角坐标。同样,我们使用记录类型来清晰地标记这些字段,使代码更易于理解。

以下是创建矩形的方法。

let r1 = Rectangle { lower_left = (-1.0, -1.0); upper_right = (1.0, 1.0) }

使用带有命名字段的记录,而不是简单的元组(如 point * point),可以使代码更具自描述性,避免混淆哪个点是左下角,哪个是右上角。

总结

本节课中我们一起学习了OCaml的变体类型。我们首先介绍了最简单的枚举形式,然后学习了如何定义可以携带额外数据的变体构造器,并使用记录类型来组织这些数据。变体类型是构建复杂、灵活数据结构的强大工具。

036:变体类型与模式匹配(第一部分)🎯

在本节课中,我们将学习如何使用模式匹配来处理变体类型。我们将通过一个计算几何图形中心点的例子,来理解如何根据不同的变体构造器执行不同的代码逻辑。


概述

假设我们有一个表示几何图形的变体类型 shape,它可以是圆形或矩形。我们的目标是编写一个函数,根据传入的图形类型,计算并返回其中心点坐标。

变体类型定义

首先,我们定义 shape 类型。它使用两个构造器:CircleRectangle

type point = float * float
type shape =
  | Circle of point * float
  | Rectangle of point * point
  • Circle 构造器接受一个点(圆心)和一个浮点数(半径)。
  • Rectangle 构造器接受两个点(左下角和右上角)。

编写中心点计算函数

现在,我们开始编写 center 函数。其类型签名为 shape -> point

let center s =
  match s with
  | Circle (center_point, _) -> center_point
  | Rectangle (lower_left, upper_right) ->
      (* 计算矩形中心点的逻辑将放在这里 *)
      failwith "to do"

函数的核心是 match 表达式。它检查输入 sCircle 还是 Rectangle

  • 如果匹配到 Circle,我们直接提取并返回其自带的 center_point
  • 如果匹配到 Rectangle,我们提取其两个角点 lower_leftupper_right。目前我们先用 failwith "to do" 占位,以保证代码能编译。

完善矩形中心点计算

上一节我们介绍了函数的基本结构,本节中我们来看看如何计算矩形的中心点。矩形的中心是其对角线的中点,即两个角点坐标的平均值。

首先,我们编写一个辅助函数来计算两个数的平均值。

let avg a b = (a +. b) /. 2.

接下来,在 Rectangle 分支中,我们需要从两个点中提取坐标,并计算平均值。

以下是计算矩形中心点的完整步骤:

  1. 使用模式匹配从 lower_left 点提取 x_lly_ll 坐标。
  2. 使用模式匹配从 upper_right 点提取 x_ury_ur 坐标。
  3. 计算X坐标的平均值 avg x_ll x_ur
  4. 计算Y坐标的平均值 avg y_ll y_ur
  5. 返回由这两个平均值构成的新点 (x_avg, y_avg)
let center s =
  match s with
  | Circle (center_point, _) -> center_point
  | Rectangle (lower_left, upper_right) ->
      let (x_ll, y_ll) = lower_left in
      let (x_ur, y_ur) = upper_right in
      let x_avg = avg x_ll x_ur in
      let y_avg = avg y_ll y_ur in
      (x_avg, y_avg)

注意:在模式匹配元组时,虽然可以省略括号,但根据 OCaml 社区的风格指南,建议保留括号以使代码更清晰。

测试函数

让我们在交互式环境(utop)中测试这个函数。

(* 定义图形 *)
let my_circle = Circle ((0., 0.), 5.)
let my_rect = Rectangle ((-1., -1.), (1., 1.))

(* 计算中心点 *)
center my_circle (* 应返回 (0., 0.) *)
center my_rect   (* 应返回 (0., 0.) *)

测试结果符合预期:圆心在原点,矩形的中心点也在原点。


总结

本节课中我们一起学习了变体类型模式匹配的核心应用。

  1. 我们使用 match 表达式根据变体构造器(CircleRectangle)进行分支判断。
  2. 在匹配分支中,我们可以直接解构出构造器所携带的数据(如点的坐标、半径)。
  3. 我们通过编写辅助函数和逐步计算,完成了矩形中心点的逻辑。
  4. 模式匹配是处理变体类型、列表、元组等复合数据结构的强大且安全的工具,它能确保所有可能的情况都被考虑。

037:变体模式匹配(第二部分)🎬

在本节课中,我们将继续学习OCaml中的模式匹配,特别是如何通过嵌套模式来简化代码,以及当为变体类型添加新的构造函数时,如何更新模式匹配表达式以保证其完整性。

上一节我们介绍了如何使用模式匹配来获取图形的中心点。本节中,我们来看看如何通过嵌套模式来优化代码,并探索如何为图形类型添加新的形状。

优化矩形中心的模式匹配

在之前的代码中,为了确定矩形的中心,我们首先需要模式匹配以判断它是否为矩形。如果是,则从记录中提取左下角和右上角的坐标,然后进一步对这些点进行模式匹配以获取各自的X和Y坐标。

实际上,我们可以编写更复杂的模式。就像表达式可以嵌套一样,模式也可以嵌套在其他模式内部。

因此,对于左下角点 lower_left,我们可以直接在此处进行模式匹配,立即提取出该点的X和Y坐标。对于右上角点 upper_right 也可以进行同样的操作。

以下是优化后的代码示例:

match s with
| Circle {center; radius} -> center
| Rect {lower_left = {x = llx; y = lly}; upper_right = {x = urx; y = ury}} ->
    {x = (llx +. urx) /. 2.0; y = (lly +. ury) /. 2.0}

通过这种深层模式匹配(即将模式嵌套在其他模式中),我们不再需要中间的额外提取步骤。这有助于显著简化代码。

为图形类型添加新形状

当然,圆形和矩形并非世界上唯一的图形。让我们添加一种非常简单的形状:一个点。它甚至不是二维图形,而是一维的。这个构造函数将携带该点的坐标信息。

以下是添加新构造函数后的类型定义:

type point = {x: float; y: float}
type shape =
  | Circle of {center: point; radius: float}
  | Rect of {lower_left: point; upper_right: point}
  | Point of point  (* 新增的构造函数 *)

现在,我们可以创建一个点:

let p = Point {x=3.0; y=1.0}

当我们回到 center 函数时,编译器会立即提示问题。

处理非穷尽的模式匹配

编译器会警告:“此模式匹配不完整。这里有一个未被匹配的例子:Point _。”

这意味着模式匹配中缺少了一个构造函数。你还没有说明当传入函数 center 的形状 s 是一个点时应该做什么。这个点会携带一些额外的信息,即该点所在的坐标(小写的 point 类型)。下划线 _ 指代的就是这个被携带的数据,它表示:“嘿,这是一个可以匹配到此处的模式示例,Point 构造函数携带了一些其他数据。” 因此,我们需要将其添加进去。

那么,对于点应该返回什么呢?点的中心就是它本身。我们可以通过模式匹配提取坐标后返回,但正如我们刚才所见,如果我们愿意,也可以直接在那里提取X和Y坐标并返回。

以下是更新后的 center 函数,包含了对 Point 的处理:

let center s =
  match s with
  | Circle {center; radius} -> center
  | Rect {lower_left = {x = llx; y = lly}; upper_right = {x = urx; y = ury}} ->
      {x = (llx +. urx) /. 2.0; y = (lly +. ury) /. 2.0}
  | Point p -> p  (* 点的中心就是它本身 *)

我们也可以写成 Point {x; y} -> {x; y},但直接返回 p 更简洁。

需要注意的一点是,我们不能在这里使用下划线 _ 来匹配 Point,因为那样会匹配并丢弃 Point 构造函数内部的数据。这样我们将不知道返回什么,最好的情况也只能返回一个无意义的值或导致失败。同样,你也不能省略该构造函数应该携带的数据而返回其他东西(例如一个虚拟值 p1),否则会得到错误:“此构造函数 Point 期望一个参数,但此处应用了零个参数。” 因此,我们确实需要说明与大写 Point 构造函数一起携带的那个点是什么。

本节课中我们一起学习了如何通过嵌套模式匹配来编写更简洁的代码,以及如何在扩展变体类型时,通过添加相应的分支来保持模式匹配的完整性,确保函数能够处理所有可能的输入情况。

038:变体类型的语法与语义 🧩

在本节课中,我们将学习OCaml中变体类型的语法和语义。我们将了解如何定义变体类型、如何使用构造器创建变体值,以及如何通过模式匹配来处理它们。

定义变体类型

上一节我们介绍了变体类型的概念,本节中我们来看看如何具体定义它。

定义变体类型的语法使用 type 关键字,这与我们之前定义记录类型时相同。但这里的类型定义不使用花括号,而是使用一种看起来很像模式匹配的语法,用竖线 | 来分隔变体类型的每个构造器。

以下是变体类型定义的核心语法:

type t = C1 | C2 | ... | CN

这些构造器,我们可以称之为C1到CN,数量可以任意多或少。所有构造器的名称必须以大写字母开头。这与我们之前所说的OCaml标识符通常以小写字母开头不同,构造器名称是一个例外。

构造器可以选择携带数据,这是可选的。我们见过像表示原色那样不携带数据的构造器,也见过像表示形状那样携带数据的构造器。我们说这些可选数据由构造器“携带”,这个概念意味着数据与构造器一同存在。

“构造器”这个词有时有一个同义词,人们有时称之为“标签”。这就像你携带了所有这些附加数据,然后被“标记”以说明它是哪种数据。例如,与Circle构造器一起携带的记录,就被标记为它是一个圆形。

变体表达式

有两种涉及变体的表达式,取决于构造器是常量还是非常量。区别在于:如果一个构造器不携带任何数据,我们就说它是常量;如果它携带一些数据,它就是非常量。希望这能讲得通,说它“非常量”是因为与之携带的数据可能会变化。

非常量变体表达式

非常量变体表达式的语法是构造器名称后跟一个表达式。

要计算一个非常量变体表达式,需要计算其内部的表达式E。然后,整个表达式的结果就是构造器名称后跟那个计算出的值。

对于类型检查:如果一个非常量变体表达式具有类型T,那么必须存在该变体类型T的定义,其中包含该构造器名称C,并且该构造器被声明为携带类型为T'的数据。同时,写在C旁边的表达式E必须具有类型T'。

常量变体表达式

常量变体表达式实际上是我们刚才看到的所有内容的简化。

常量变体表达式的语法就是构造器名称本身。对于求值,它已经是一个值,这类似于OCaml中其他类型的常量(如42)本身已经是值。对于类型检查,如果一个构造器名称具有类型T,那么必须存在变体类型T的定义,其中包含该构造器名称。

模式匹配

我们说过,每次在OCaml中引入一种新的数据类型时,都会有相应的模式匹配语法。这里我们有一种新的模式形式。

一个构造器名称后跟一个模式,其本身就是一个模式。最后,单独的C也是一个模式。


本节课总结

本节课中我们一起学习了OCaml变体类型的核心语法和语义。我们掌握了如何使用type关键字和竖线|来定义变体类型,理解了常量与非常量构造器的区别及其表达式求值规则,并认识了用于匹配变体值的新模式形式。这些是构建和操作复杂数据结构的基石。

OCaml编程:第3章:代数数据类型 🧮

在本节课中,我们将深入学习代数数据类型。这是函数式编程的核心概念之一,它帮助我们以一种结构化的方式对现实世界的数据进行建模。


概述:记录与变体

我们已经接触过代数数据类型,现在让我们更仔细地审视它们。我们将通过尝试对现实世界中的数据进行建模来理解其本质。思考在以下每种情况下,你会选择使用记录还是变体。


硬币建模:使用变体

假设你想为硬币建模,并且这些硬币是美国货币:一分、五分、一角或二十五分。对于这种情况,使用记录还是变体更有意义?

我们正在尝试对可能是几种事物之一的东西进行建模。一枚硬币要么是一分、五分、一角,要么是二十五分,而变体允许我们做到这一点。它让我们拥有这四个不同的构造器,并且该类型的任何值都恰好是其中之一。

以下是硬币类型的定义:

type coin = Penny | Nickel | Dime | Quarter

学生建模:使用记录

接下来考虑学生。一个学生可能有一个名字和一个学号。对于这种情况,你会使用记录还是变体?

我会使用记录。因为我知道在为学生建模时,我希望拥有该学生的两个数据片段。我可以创建一个包含这两个字段的记录:一个用于学生姓名,另一个用于学生学号。当然,如果我尝试使用变体,并为姓名和学号各设一个构造器,那么我将永远无法同时拥有这两个数据片段。我需要同时拥有两者,这促使我选择记录。

以下是学生记录的定义:

type student = { name : string; id : int }


甜点建模:再次使用记录

现在考虑甜点。我曾经在康奈尔大学上过酒店管理学院的烹饪课,从中我了解到,一个合格的甜点需要包含酱汁、奶油成分和酥脆成分。那么,我会使用记录还是变体来为甜点建模?

我会使用记录。因为我知道它需要包含所有这三个成分,而不仅仅是恰好一个。这里需要注意的一个关键词是描述数据时使用的连词。


连词的关键作用:“或”与“和”

当我们使用连词 “或” 时,我们倾向于寻找变体。当我们使用连词 “和” 时,我们倾向于使用记录。

我们可以将其视为 “其中之一”类型“每一个”类型 之间的区别。

  • 记录和元组是“每一个”类型的例子,因为记录或元组的每个值都包含其他组件的每一个值。例如,代表学生的记录包含姓名和学号。这些也被称为积类型,其概念源于笛卡尔积。例如,你可以有浮点数与浮点数的笛卡尔积,这可以表示笛卡尔平面上的点。或者,你可以取任意两个类型(如字符串和整数)的笛卡尔积,这样你就得到了一个字符串和一个整数——这里又出现了“和”这个词。
  • 变体被称为“其中之一”类型,因为变体的任何值都是一组构造器中的其中一个。例如,一个形状必须是圆形、矩形或点中的恰好一个。有时这些也被称为和类型

变体作为标记联合

从数学上讲,它们被称为和类型的原因可能不如笛卡尔积那么为人熟知。其概念是我们在取两个集合的并集,而在笛卡尔积中,我们取的是两个集合的乘积

这里有一个例子。我们可以定义一个变体类型,表示一个值要么是字符串,要么是整数。它有两个构造器:StringInt,每个构造器都携带一个适当类型的值。

type string_or_int = String of string | Int of int

现在,string_or_int 类型的任何值都将是字符串或整数中的恰好一个。因此,在某种意义上,我们取了这两个集合(这两种类型)的并集。

但这其中还有更多内容。它不仅仅是一个并集,因为构造器名称(或标签)告诉我们该值来自哪个集合。在我们正在查看的这个类型中,你大致可以分辨出它来自哪个集合,因为有两个不同的集合(字符串和整数是不同的)。但假设我们想创建一个要么是“蓝色整数”要么是“粉色整数”的东西。现在,我们取的是所有整数集合的两个副本,并将它们合并在一起。我们需要跟踪值来自哪个副本——是来自蓝色整数集合还是粉色整数集合——这就是标签告诉我们的信息。

这在数学上被称为标记联合,它被写成一个通常的集合并集,但在其中有一个小加号。这就是变体的本质:它们是标记联合,因为它们确切地告诉我们值来自哪个集合。这就是为什么它们有时被称为和类型,因为“联合”很像“求和”。


代数数据类型:和与积的结合

当你同时拥有“和”与“积”时,可能会让你联想到代数。确实,变体也被称为代数数据类型,因为它们允许和与积的组合,允许“每一个”类型和“其中之一”类型的组合。

代数数据类型的缩写是 ADT,这有点不幸,因为它与抽象数据类型的缩写发生了名称冲突。因此,从现在开始,ADT可能指代这两者中的任何一个,你需要根据上下文来判断。


总结

本节课中,我们一起学习了代数数据类型的核心概念。我们了解到:

  • 使用连词 “或” 描述数据时,应选择变体(和类型/“其中之一”类型)。
  • 使用连词 “和” 描述数据时,应选择记录或元组(积类型/“每一个”类型)。
  • 变体本质上是标记联合,它明确标识了值所属的特定子集。
  • 代数数据类型是函数式编程中结合了“和”与“积”的强大建模工具。

通过理解这些基本区别,你可以更准确、更有效地为各种数据场景选择合适的数据结构。

040:为宝可梦设计代数数据类型 🐉

在本节课中,我们将学习如何使用OCaml的代数数据类型来建模一个简单的宝可梦战斗系统。我们将定义宝可梦的类型、攻击效果,并编写函数来计算伤害倍率。

概述

代数数据类型是OCaml中一种强大的数据建模工具。为了展示其应用,我们以宝可梦为例进行说明。宝可梦之间会进行战斗,并且每个宝可梦都有属性,例如火属性、水属性、虫属性等。为简化模型,我们假设每个宝可梦只拥有一种属性。当宝可梦相互攻击时,属性相克关系会决定攻击是“效果绝佳”、“效果一般”还是“效果不理想”。

定义数据类型

首先,我们需要为宝可梦的属性定义一个类型。在OCaml中,type是保留关键字,因此我们将其命名为ptype

type ptype = TNormal | TFire | TWater

接下来,我们定义攻击效果的类型。

type effectiveness = ENormal | ENotVery | ESuper

这里需要注意命名冲突。如果我们定义了两个都包含Normal构造子的类型,后定义的会“遮蔽”先定义的。一种惯用的解决方法是给构造子名称添加前缀以作区分,例如为ptype的构造子添加T,为effectiveness的构造子添加E。下周学习模块时,我们会看到更好的处理方式。

计算伤害倍率

现在,我们编写一个函数,根据攻击效果返回对应的伤害倍率。在OCaml中,将一种类型转换为另一种类型的函数通常命名为xxx_of_yyy的形式。

以下是计算倍率的函数:

let multiplier_of_effectiveness = function
  | ENormal -> 1.0
  | ENotVery -> 0.5
  | ESuper -> 2.0

此函数使用function关键字进行模式匹配,它接收一个effectiveness类型的参数并返回一个浮点数。

编码属性相克表

上一节我们定义了数据类型和伤害计算函数,本节我们将根据游戏规则编码属性相克表。这个函数接收攻击方和防御方的属性,并返回攻击效果。

let effectiveness_of_attack (attacker, defender) =
  match (attacker, defender) with
  | (TFire, TWater) | (TWater, TFire) -> ESuper
  | (TFire, TFire) | (TWater, TWater) -> ENotVery
  | _ -> ENormal

这个函数接收一个属性对(元组)作为参数。我们也可以将其写成接收两个独立参数的形式,这在功能上是等价的,选择哪种形式取决于你在代码其他部分的调用便利性。

let effectiveness_of_attack2 attacker defender =
  match (attacker, defender) with
  | (TFire, TWater) | (TWater, TFire) -> ESuper
  | (TFire, TFire) | (TWater, TWater) -> ENotVery
  | _ -> ENormal

创建宝可梦记录类型

最后,我们引入记录类型来完整地表示一只宝可梦。一个宝可梦记录可以包含名字、属性和生命值等信息。

type pokemon = { name : string; ptype : ptype; hp : int }

现在,我们可以用这个类型来创建具体的宝可梦实例:

let charmander = { name = "Charmander"; ptype = TFire; hp = 39 }

总结

本节课我们一起学习了如何使用OCaml的代数数据类型和记录类型来建模一个领域。我们定义了宝可梦属性(ptype)和攻击效果(effectiveness)的类型,编写了计算伤害倍率的函数,并实现了属性相克逻辑。最后,我们创建了一个记录类型来完整描述一只宝可梦。通过这些步骤,你将代数数据类型的核心概念应用到了一个具体、有趣的例子中。

041:递归与参数化变体 🧩

概述

在本节课中,我们将要学习变体类型的两个更强大的特性:递归参数化。我们将通过构建自己的列表类型来理解这些概念,并最终了解OCaml标准库中内置列表的实现原理。


递归变体

上一节我们介绍了变体的基本概念,本节中我们来看看如何定义递归变体。递归变体允许在类型的定义中引用类型自身,就像递归函数一样。

让我们编写一个自己的类型来表示整数列表。

type int_list =
  | Nil
  | Cons of int * int_list

这里,我们创建了一个名为 int_list 的类型,它有两个构造器:NilCons

  • Nil 是一个常量构造器,不携带任何数据。
  • Cons 是一个非常量构造器,它携带一个元组 (int * int_list)。这个元组的第一个分量是一个整数,第二个分量是一个 int_list

int_list 类型是递归的,因为它在自己的定义中提到了自己的名字。我们可以将 Cons 构造器中的 int 部分视为列表的头部,而 int_list 部分视为列表的尾部

现在,我们可以像操作OCaml内置列表一样,为 int_list 编写函数。例如,计算长度的函数:

let rec length = function
  | Nil -> 0
  | Cons (_, t) -> 1 + length t

我们可以创建一个 int_list 并计算其长度:

let my_list = Cons (1, Cons (2, Nil))
let result = length my_list (* result 为 2 *)

参数化变体

如果我们第二天发现还需要字符串列表,一种方法是复制所有代码并修改类型。但这会导致代码重复和维护困难。优秀的程序员不会大量复制粘贴代码,而是会抽象出代码的共性,编写参数化的代码。

我们应该将列表变体参数化,使其不硬编码元素的类型。在OCaml中,我们可以这样定义一个参数化变体:

type 'a my_list =
  | Nil
  | Cons of 'a * 'a my_list

这里的 'a 是一个类型变量。它位于类型名称的左侧。你可以将 my_list 看作一个类型级别的函数:它接收一个类型 'a,并返回一个由该变体定义的新类型。在定义体中,我们可以使用 'a 来指代这个类型。

现在,我们可以编写操作于任何 'a my_list 的函数,而无需为每种元素类型重复定义。例如,之前的 length 函数现在可以通用于所有类型的列表:

let rec length = function
  | Nil -> 0
  | Cons (_, t) -> 1 + length t

let len_int = length (Cons (1, Nil)) (* 长度为 1 *)
let len_bool = length (Cons (true, Nil)) (* 长度也为 1 *)

length 函数现在是参数化多态的,与列表元素的类型无关。


改进语法与标准库实现

我们可以通过使用操作符或标点符号来替换构造器名称,从而获得更优雅的语法,使其更接近内置列表。

type 'a my_list =
  | []
  | (::) of 'a * 'a my_list

这样,我们就可以使用方括号 [] 表示空列表,使用双冒号 :: 作为 Cons 构造器。

事实上,这正是OCaml标准库实现列表的方式。标准库中列表的定义本质上就是:

type 'a list = [] | (::) of 'a * 'a list

OCaml内置的单链表本质上就是递归的参数化变体list 在这里是一个类型构造器(可以看作类型级别的函数),它由类型变量 'a 参数化。而用标点符号 []:: 表示的 NilCons 只是这个变体的构造器。


总结

本节课中我们一起学习了变体类型的两个高级特性:

  1. 递归变体:允许类型在其定义中引用自身,用于定义像链表这样的递归数据结构。
  2. 参数化变体:通过引入类型变量,使变体定义能够抽象出所包含数据的类型,从而实现多态和代码复用。

我们通过从定义具体的整数列表开始,逐步抽象到参数化的通用列表类型,最终揭示了OCaml内置列表类型的实现本质——它就是一个递归的参数化变体。理解这些概念是掌握OCaml强大类型系统的关键一步。

042:Options 🧰

在本节课中,我们将要学习OCaml标准库中的另一个内置变体类型:Options。它为解决某些编程问题提供了一种优雅的方案。

概述

上一节我们介绍了变体类型的基本概念,本节中我们来看看一个非常实用的内置变体类型:option。它用于表示一个可能不存在的值,是处理“空值”问题的核心工具。

Options的定义与概念

option 类型的定义非常简单。其类型 'a option 要么是 None,要么是 Some of 'a

你可以将 option 想象成一个盒子。这个盒子要么是空的,要么里面装着东西。None 构造器代表空盒子,Some 构造器代表装有东西的盒子,而 Some 所携带的类型 'a 就是盒子里的内容。

例如:

  • None 的类型是 'a option,类似于空列表的类型是 'a list
  • Some 1 的类型是 int option,可以理解为“可选地,这是一个整数”。可选意味着这里可能有一个整数,也可能什么都没有(用 None 表示)。
  • 你也可以放入更复杂的数据,例如 Some [1; 2; 3] 的类型是 int list option,即“可选地,这是一个整数列表”。

从Option中取值

Options是变体类型,因此我们可以使用模式匹配来从中取值。让我们编写一个函数来尝试从 option 中取出值。

let get_val o =
  match o with
  | Some x -> x
  | None -> ???

我们编写了函数 get_val,其类型应为 'a option -> 'a。它尝试从 'a option 中取出 'a。当盒子中有东西(Some)时,get_val 可以返回那个值。但是,当盒子是空的(None)时,我们该返回什么呢?我们不知道 'a 具体是什么类型,因此无法返回一个合理的默认值。

一个更好的方法是让调用此函数的程序员来指定当盒子为空时返回的默认值。

let get_val default o =
  match o with
  | Some x -> x
  | None -> default

现在,get_val 的类型是 'a -> 'a option -> 'a。它总是能返回一个 'a 类型的值。当然,这里也可以使用 function 关键字来简化代码。

Options的典型用途

仅仅有盒子和从中取值的操作并不十分有趣,也不是最地道的用法。Options真正有用的地方是作为那些可能没有有意义返回值的计算的返回值。

让我给你一个例子。假设你想求一个列表的最大值。max_of_list [1; 2; 3] 应该返回 3。但是,max_of_list [](空列表的最大值)应该返回什么呢?你可能会决定返回某个极值(如整数的最小值),或者抛出一个异常。但一个更有原则的处理方式是返回一个 option:对空列表返回 None,对列表 [1; 2; 3] 返回 Some 3

因此,我们真正要写的是一个函数 list_max,它接收一个列表,并返回一个 'a option。它可选地返回列表中的一个元素作为最大值。

以下是实现这个函数的代码:

let rec list_max = function
  | [] -> None
  | h :: t ->
    match list_max t with
    | None -> Some h
    | Some m -> Some (max h m)

如果列表为空,我返回 None(空盒子)。如果列表有头元素 h 和尾列表 t,那么我返回一个盒子(Some 构造器)。我放入盒子的是两个值中的较大者:要么是列表的头元素 h,要么是其尾列表 t 的最大值。

但是,这里有一个编译错误。因为递归调用 list_max t 返回的是 'a option 类型,而头元素 h'a 类型。我试图比较一个 'a 和一个 'a option,多态比较操作符会报错,因为这两者的类型不同。

因此,我需要先从递归调用 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

现在,代码做了什么?我匹配对尾列表 t 递归调用 list_max 的结果,它返回一个 'a option。这个 option 可能有内容,也可能没有(因为尾列表可能为空)。如果尾列表为空,递归调用返回 None。在这种情况下,我直接返回 Some h,因为头元素是列表中唯一的元素,所以它必然是最大值。另一方面,如果递归调用确实返回了一个包含最大值元素的 Some,那么我想返回一个 option(以 Some 开头),里面放入当前头元素 h 和尾列表最大值 m 中较大的那个。

关于代码风格的重要说明

在风格上,需要指出的一点是,你应该在这样的嵌套模式匹配周围写上 beginend。这样做有充分的理由:它帮助OCaml和人类确保正确解析代码。beginend 实际上与括号作用相同,但在风格上,我们倾向于在嵌套模式匹配周围使用 beginend,因为它们更醒目,能帮助我们更好地看清代码分组。

为什么需要它们?假设我们没有写 beginend。当我保存文件并让Merlin(代码工具)重新格式化时,它可能会将外层模式匹配的第一个分支移动到内层 match 表达式中,因为OCaml会将其解析为离它最近的 match 表达式的一部分。这会导致解析错误,进而引发类型错误。正确使用 beginend 可以确保OCaml正确解析代码,从而消除这些类型错误。

Options与空指针异常

我之前提到,Options提供了一种解决Java难以处理的问题的方法。这个问题就是空指针异常。这不是我说的,是Tony Hoare说的。他在1980年因对编程语言定义和设计的基础性贡献而获得图灵奖,也是快速排序算法的发明者。他写道,他称空引用(null)为他“十亿美元的错误”。在设计面向对象语言的类型系统时,他没能抵制住加入空引用的诱惑。这在过去40年里导致了无数的错误、安全漏洞和系统崩溃,可能造成了数十亿美元的损失和损害。

Options是空值(null)的一个有原则的替代品。Java的类型系统本可以内置 option 类型,而不是让每个引用都可能是 null 或一个指针。这样,每个引用都可以是一个 option,程序员必须通过模式匹配来检查它是 None 还是 Some。强制程序员进行这种模式匹配,可以确保他们在每次解引用时都考虑到空指针错误的可能性。

所以,这个故事的寓意是:避免空指针异常,转而使用模式匹配来检查 None

总结

本节课中我们一起学习了OCaml中的 option 类型。我们了解了它如何像一个盒子一样,表示一个可能存在也可能不存在的值。我们学习了如何通过模式匹配从 option 中安全地取值,并看到了它在处理像“求列表最大值”这类可能无结果的计算时的典型应用。最后,我们探讨了 option 类型作为一种更安全、更有原则的机制,如何帮助我们避免像Java中空指针异常那样的常见错误。通过使用 option,编译器可以强制我们在代码中显式地处理“值缺失”的情况,从而编写出更健壮的程序。

043:异常处理 🚨

在本节课中,我们将要学习OCaml中的异常处理机制。异常是处理程序中错误或意外情况的一种方式。理解了变体类型后,你会发现异常本质上就是一种特殊的变体。

异常的本质:一种特殊的变体

上一节我们介绍了变体类型,本节中我们来看看异常。OCaml中有一个内置的类型叫做 exn,这是异常的类型。所有的异常实际上都是这个类型的值。

你可以通过 exception 关键字定义自己的异常,后面跟上异常的名称。这相当于定义了一个 exn 类型的构造器。既然是构造器,它可以是常量,也可以携带数据。

exception BadThing
exception OhNo of string

这里的 exn 类型很特殊,它是一个内置的、可扩展的变体。通常我们定义变体时,必须在定义中列出所有的构造器。但 exn 允许我们稍后通过 exception 关键字来添加构造器。

抛出与处理异常

定义好异常后,如果想抛出它,可以使用 raise 关键字,就像Python一样。

raise (OhNo "oops")

执行上述代码,OCaml会报告一个异常被抛出:Exception: OhNo "oops"。这类似于除以零会抛出预定义的 Division_by_zero 异常。

我们创建的异常可以不携带任何数据。

exception ABadThing
raise ABadThing

OCaml程序员比某些Java程序员更倾向于创建自己的新异常,因为在OCaml中创建异常非常简单,不需要像Java那样创建一个新的类文件并定义构造器。

内置函数 raise 的类型是 exn -> 'a。它接受一个异常值并将其抛出。由于它会抛出异常,包含 raise 的表达式本身永远不会产生一个真正的值,它不会返回任何值,因此其返回类型被标记为 'a(任意类型),类型系统允许这样做。

let x : int = raise ABadThing (* 这是类型检查通过的 *)

标准库中的常用异常

标准库中预定义了许多异常,其中两个有用的异常是 FailureInvalid_argument。它们都携带一个字符串,以便提供有用的错误信息。

以下是它们的简要说明:

  • Failure:由库函数引发,表示它们在给定参数上未定义。
  • Invalid_argument:由库函数引发,表示给定的参数没有意义。

实际上,它们的功能很相似,但标准库同时提供了两者。

为了方便,标准库还提供了两个内置函数来抛出这些异常:

  • failwith:抛出 Failure 异常。
  • invalid_arg:抛出 Invalid_argument 异常。

因此,你可以选择麻烦一点的方式:

raise (Failure "my error message")

或者更方便的方式:

failwith "my error message"

failwith 函数本质上就是用你提供的消息调用了 raise

总结

本节课中我们一起学习了OCaml的异常处理。我们了解到异常是 exn 类型的值,这是一种可扩展的变体。我们学会了如何使用 exception 关键字定义自己的异常,以及如何使用 raise 关键字抛出异常。最后,我们还介绍了标准库中预定义的 FailureInvalid_argument 异常,以及便捷函数 failwithinvalid_arg 的用法。

044:异常处理 🚨

在本节课中,我们将要学习如何在OCaml中处理被抛出的异常。异常处理的核心是模式匹配,因为异常本身就是一种变体类型。我们将通过一个安全的除法函数示例来理解其语法和语义。

处理被抛出的异常

处理一个被抛出的异常,本质上就是进行模式匹配。

因为异常是变体类型。例如,假设你试图处理一个除零错误。

通常,如果你除以零,你会得到一个异常。

假设你想要一个安全的除法函数,如果尝试除以零,则返回其他值,比如定义除零的结果为零。

我们可以这样定义:let safe_div x y =。我们将尝试计算 x / y。如果成功,x / y 的值将被返回。但如果计算 x / y 时抛出了异常,我们可以继续并对该异常进行模式匹配。这就像 match ... with,但现在用的是 try ... with,并且我们只在异常被抛出时进行模式匹配。

因此,如果抛出除零异常,那么我们决定返回零。

let safe_div x y =
  try x / y with
  | Division_by_zero -> 0

现在,当我们尝试计算 safe_div 4 0 时,我们得到零而不是一个异常。try 表达式的语法和语义与 match 表达式非常相似。

try 表达式的语法与语义

语法是 try E with,然后是一些模式分支。你可以有多个分支,也可以只有一个。

要计算一个 try 表达式,首先计算表达式 E

如果 E 确实成功,即 E 能够在不引发异常的情况下产生一个值 V,那么整个 try 表达式就计算为 V。此时我们甚至不会去看那些模式。

但如果 E 引发了一个异常,我们称之为 X。那么就像正常的模式匹配一样,将 X 与每个模式进行匹配。

如果有一个模式匹配成功,则计算右侧的表达式并返回其结果。

否则,如果我们检查完所有模式都没有匹配项,此时,异常 X 会被重新抛出。这与 match 表达式的正常语义略有不同,因为在 match 表达式的末尾,如果没有找到匹配项,会抛出 Match_failure 异常,而这里我们重新抛出原来的异常 X

类型检查规则

整个 try 表达式具有类型 T

表达式 E 必须具有类型 T,因为如果没有引发异常,E 的结果将被返回。

所有分支右侧的表达式 E_i 也必须具有相同的类型 T,因为其中一个可能在发生异常时成为返回的值。

当然,所有模式本身必须具有类型 exn,因为我们要对可能引发的任何异常进行模式匹配。


本节课中我们一起学习了OCaml中的异常处理机制。我们了解到,使用 try ... with 结构可以捕获并处理表达式执行过程中抛出的异常,其核心是通过模式匹配来识别不同的异常类型。我们掌握了其语法、计算语义以及类型检查规则,并通过一个“安全除法”函数的例子进行了实践。记住,如果没有任何模式匹配到被抛出的异常,该异常会被重新抛出。

045:🌳 二叉树

在本节中,我们将学习OCaml中变体类型的另一个重要应用:二叉树。我们将通过对比单链表来理解二叉树的定义、创建和基本操作,例如计算树的大小和元素总和。


二叉树的定义

上一节我们介绍了变体类型,本节中我们来看看如何用变体类型定义二叉树。二叉树与单链表非常相似。单链表的节点中,每个元素只有一个后继节点。而在二叉树中,每个节点可以有两个后继节点,即两个子节点。

以下是 alpha mylist(单链表)和 alpha tree(二叉树)的代码定义,我们可以边看边比较:

type 'a mylist = Nil | Cons of 'a * 'a mylist

type 'a tree = Leaf | Node of 'a * 'a tree * 'a tree

alpha mylist 要么是 Nil(空列表),要么是 Cons(包含一个元素和另一个列表的节点)。alpha tree 要么是 Leaf(空树,不包含任何内容),要么是 Node(包含一个类型为 alpha 的值,以及另外两个 alpha tree 作为左右子节点)。除了将标识符重命名外,其定义与 alpha mylist 基本相同,只是在 Node 构造器中多了一个子节点,而链表节点只有一个子节点。


创建二叉树

要创建这种类型的值,可以像下面这样定义一个树 t

let t = Node (2,
              Node (1, Leaf, Leaf),
              Node (3, Leaf, Leaf))

下图展示了我们创建的这棵树的结构:

这棵树的根节点是 2,其左子节点是 1,右子节点是 3。节点 13 各自又有两个叶子节点作为子节点。在绘制树形图时,我们通常不会画出叶子节点,因为它们不包含数据,也没有实际作用。


计算树的大小

如果你想计算树的大小,即树中节点的数量,代码会与计算列表长度的代码非常相似。

以下是计算树大小的函数:

let rec size = function
  | Leaf -> 0
  | Node (_, left, right) -> 1 + size left + size right

对于空的情况(Leaf 构造器或列表的 Nil 构造器),我们返回 0。对于非空的情况(列表的 Cons 或树的 Node),我们返回 1 加上对子节点递归调用的结果。列表只有一个子节点,而树有两个子节点。


计算树中元素的总和

如果你想计算列表或树中所有元素的总和,方法也类似。

以下是计算树中元素总和的函数:

let rec sum = function
  | Leaf -> 0
  | Node (value, left, right) -> value + sum left + sum right

对于列表,如果列表非空,则返回头元素加上尾部元素的总和。对于树,则返回节点处的值加上对两个子节点递归调用的结果之和。同样,代码结构非常相似。


总结

本节课中我们一起学习了如何在OCaml中使用变体类型定义和操作二叉树。我们了解到,二叉树的实现与单链表非常相似,主要区别在于节点可以拥有两个子节点。通过计算树的大小和元素总和这两个例子,我们看到了递归在处理树结构时的强大和简洁。在OCaml中实现二叉树并不困难,这正体现了函数式编程的精妙之处。

046:高阶函数 🧠

在本节课中,我们将要学习OCaml中一个核心且强大的概念:高阶函数。我们将探讨函数如何像其他数据一样被传递和返回,并通过具体例子理解其应用。


我们已经在OCaml的数据和类型上花费了不少时间。现在,让我们回到函数的概念上,甚至可以将函数本身视为一种数据。

我们之前提到过,函数是值,我们可以在任何使用值的地方使用它们。这意味着函数可以作为参数传递给其他函数,也可以作为其他函数的结果被返回。在OCaml中,这种特性使得函数成为“高阶”的。所谓“高阶”,就是指函数可以像其他类型的值一样被传递和使用。

让我们从编写几个函数开始。

let double x = 2 * x

double函数将其参数加倍。

let square x = x * x

square函数计算其参数的平方。

如果我们想将一个数字翻四倍(quadruple)呢?一种写法是:

let quad x = 4 * x

但我们也可以用另一种方式编写,即使用两次double函数:

let quad_prime x = double (double x)

我们之前学过管道运算符(|>),也可以用它来写quad

let quad_double_prime x = x |> double |> double

以上是编写quad函数的几种不同方式。

现在,假设我们想计算一个数字的四次方(fourth power)。我们可以这样写:

let fourth x = x * x * x * x

这与quad的写法有些类似。我们也可以用类似重写quad的方式来重写它:

let fourth_prime x = square (square x)

或者使用管道运算符:

let fourth_double_prime x = x |> square |> square

这为我们提供了编写同一函数的多种方式。


这些函数本身可能并不那么有趣,但让我们看看在构建quadfourth时的相似之处。为了构建quad,我们应用了两次double函数。为了构建fourth,我们应用了两次square函数。这里存在一个“应用函数两次”的概念。

在代码中,每当你注意到这种重复的操作时,一个好的问题是:我能否将其抽象出来?能否将这个功能提取出来,使其成为独立的代码片段,从而避免重复编写?

因此,让我们编写一个函数,它能够将另一个函数应用两次。

let twice f x = f (f x)

twice函数接受一个函数f和一个参数x,然后将f应用到这个参数上两次。

让我们看看这个函数的类型。twice接受一个类型为'a -> 'a的函数(即输入和输出类型相同),然后接受一个该输入类型的值,并返回一个相同类型的值。

现在,我可以使用twice来重新编写之前的一些函数。例如:

let quad_triple_prime x = twice double x

这里,我取double函数,并将其对x应用两次。这就是高阶函数的应用:我们实际上在使用twice这个函数,它接受另一个函数作为参数。

我可以用同样的方式处理fourth

let fourth_triple_prime x = twice square x

当然,我也可以使用管道运算符来编写twice

let twice_pipe f x = x |> f |> f

这同样有效。


我还可以为quadfourth做另一件事:省略参数。

我可以将quad重写为:

let quad_four_primes = twice double

注意,这里的quad_four_primes没有接收参数。它等于twice double。那么twice double是什么呢?从上面twice的类型可以看出,它接受一个函数和一个参数,并返回一个输出。但如果我们只将twice应用于它的第一个参数(函数f),就像这里的twice double,那么它返回的是一个函数。具体来说,是一个类型为'a -> 'a的函数(在这里,由于double是基于整数定义的,所以是int -> int类型)。

这再次体现了高阶性。这里的twice是一个高阶函数,因为当我们只给它第一个输入(函数)时,它返回一个函数作为输出。我们之前在部分应用(partial application)中见过这种特性。

我同样可以对fourth做类似处理:

let fourth_four_primes = twice square

我们之所以详细查看了所有这些不同的写法,是为了深入理解“高阶”的含义。它意味着我们可以像使用语言中其他类型的值一样使用函数:将它们传递给其他函数,甚至将函数作为结果返回。

本节课中我们一起学习了高阶函数的概念,包括如何定义接受函数作为参数的函数,以及如何通过部分应用返回新的函数。这是函数式编程中实现代码抽象和复用的强大工具。

OCaml编程:4.2:Map函数详解 🗺️

在本节课中,我们将学习函数式编程中最著名的高阶函数之一:map函数。我们将了解它的概念、用途以及如何正确高效地使用它。


也许最著名的高阶函数就是mapreduce。谷歌通过一个名为MapReduce的框架,使它们在大规模数据并行计算中变得极其流行。Apache有一个名为Hadoop的开源实现。在介绍MapReduce的论文中,谷歌写道:“这个抽象概念受到了Lisp和许多其他函数式语言中存在的mapreduce原语的启发。”

上一节我们介绍了高阶函数的概念,本节中我们来看看map函数的具体应用。在查看任何代码之前,我们先看一个例子。

假设你想将某个流行电视剧(这里我选择了《星际迷航》)中的角色映射到他们的衬衫颜色。假设你有三个人物:Kirk、Spock和Uhura。他们分别对应金色衬衫、蓝色衬衫和红色衬衫。

map函数的核心思想是:你传入一个函数和一个列表,它会将该函数应用到列表的每一个元素上,并返回结果列表。如果你传入一个能根据角色返回衬衫颜色的函数,那么map就会返回一个包含他们衬衫颜色的列表:[“金色”, “蓝色”, “红色”]

以下是map函数的一个概念性描述:

map (fun x -> f x) [a; b; c]  =>  [f a; f b; f c]

回到我们的示例代码,即使它只是伪代码,也有可以改进的地方。我们传递给map作为参数的函数可以写得更好。

我们传入了一个匿名函数,它接受参数x并对x应用shirt_color函数。这里有一些不必要的括号,但更糟糕的是,有一种更简单的写法。我们本可以直接传递shirt_color这个函数名。

这是一个初学者在学习函数式编程时常犯的错误:他们本可以直接将一个函数名作为参数传递给高阶函数,却错误地用一个匿名函数包裹了这个函数名。如果你发现自己这样做,请尝试改掉这个习惯。


本节课中我们一起学习了map函数。我们了解了它的基本思想:对一个列表中的每个元素应用某个函数,并收集结果。我们还强调了在传递函数参数时应避免不必要的匿名函数包装,直接使用函数名可以使代码更简洁清晰。

048:实现Map函数 🗺️

在本节课中,我们将要学习如何实现OCaml中的map函数。我们将通过分析两个具体的列表转换函数,提取其共同模式,最终推导出map函数的通用实现。理解这个过程将帮助你深刻掌握高阶函数和代码抽象的核心思想。

列表转换的核心思想

我们之前了解到,map函数的核心思想是转换列表中的元素

为了深入理解map是如何编写的,让我们先看看其他一些转换列表元素的函数,从中获取灵感。

分析具体的转换函数

以下是两个具体的列表转换函数示例。

示例一:为整数列表的每个元素加一
这个函数非常简单,希望你此刻能够不假思索地写出来。

let rec add1 = function
  | [] -> []
  | h :: t -> (h + 1) :: add1 t

示例二:为字符串列表的每个元素拼接“3110”
这是另一个转换列表元素的例子。

let rec concat3110 = function
  | [] -> []
  | h :: t -> ("3110" ^ h) :: concat3110 t

现在,让我们仔细观察这两个转换列表的函数之间的相似与不同之处。

识别代码模式

观察这两个函数,我们可以发现一个清晰的模式。

首先,看每个函数的第一行,除了函数名不同,结构完全一样。

其次,看每个函数的第二行,都是处理空列表并返回空列表。

最后,看每个函数的第三行,模式匹配h :: t。在::的右侧,都是对列表尾部t的递归调用。而在::的左侧,都执行了一个操作来转换头部元素h

  • add1函数中,这个操作是加一 (h + 1)。
  • concat3110函数中,这个操作是拼接字符串“3110” ("3110" ^ h)。

抽象公共代码

现在,让我们像之前抽象twice函数那样,将这段重复的代码提取出来。

我已经提取了大部分代码,剩下的关键问题是:在::的左侧,我需要对元素h做什么?我需要以某种方式转换它。

然而,在这两个函数中,转换h的方式是不同的。那么,何不将一个函数作为参数传入,让它来告诉我如何进行转换呢?

我可以传入一个函数f,并在这里将它应用到h上。现在,f需要作为transform函数的输入。因此,在递归调用的地方,我也需要继续将f作为参数传递给transform

let rec transform f = function
  | [] -> []
  | h :: t -> (f h) :: transform f t

现在,transform函数可以成为我们重新实现add1concat3110的基础,从而消除大量重复代码。

add1可以这样实现:调用transform,并传入一个函数,该函数接收列表元素(称为x)并返回x + 1

let add1 lst = transform (fun x -> x + 1) lst

对于concat3110,我可以这样实现:调用transform并应用一个函数,该函数接收参数x,并将“3110”拼接到这个参数上。

let concat3110 lst = transform (fun x -> "3110" ^ x) lst

揭秘:Transform 就是 Map

我们成功提取了所有重复的代码,并用transform函数实现了add1concat3110

猜猜怎么着?这个transform函数实际上就是Map。朋友们,那就是map函数。我们刚刚实现了它。

我相信,如果你研究这个过程,练习几次将公共代码提取到一个名为map的函数体中,你将永远记住并理解map的实现。

探究 Map 的类型

上一节我们实现了map函数,本节中我们来看看它的类型签名,这能帮助我们理解其通用性。

map函数接收一个类型为 'a -> 'b 的函数作为参数。这个函数将用于转换列表中的每个元素。

注意,这个函数的输入和输出类型可以不同。实际上,它可以将元素转换成不同类型的不同值。

例如,如果你想将一个整数列表的每个元素转换为对应的字符串表示:

let string_list_of_int_list lst = map string_of_int lst

(这里可以写出更好的代码风格:string_of_int本身就是一个接收整数返回字符串的函数,因此可以直接传入,无需匿名函数包装。)

这就是一个输入输出类型不同的函数,但我们仍然可以把它传给map

map接着会接收一个 'a list,这是未经转换的原始元素列表。

它会应用传入的函数来转换其中的每一个元素,因此返回一个 'b list。因为该函数已将原始列表中的每个'a类型元素转换成了一个'b类型元素,并返回了结果列表。

map的类型签名完整表述为:

('a -> 'b) -> 'a list -> 'b list

抽象原则的应用

我们通过map函数应用了所谓的抽象原则

抽象原则指出:应提取反复出现的代码模式,而不是重复它们。这种需要转换元素(不仅是列表,更普遍的是数据结构中的元素)的共性,在计算中一次又一次地出现。

因此,当你提供一种对数据结构进行映射的方法(就像我们刚刚为列表所做的那样)时,你就将“遍历结构”与“转换每个元素”这两个关注点分离开了。这样你就能得到更加优雅且易于维护的代码。


本节课中我们一起学习了如何从具体案例出发,通过识别模式、提取公共逻辑、引入函数参数,最终推导并实现了通用的map函数。我们还分析了map的类型签名,理解了其处理不同类型转换的能力,并认识到这是抽象原则的一个经典应用。掌握这一过程,对于理解函数式编程中的高阶函数和代码复用至关重要。

049:列表元素的组合与折叠函数 🧩

在本节课中,我们将要学习如何将列表中的多个元素组合成一个单一的结果,并探索OCaml中一个强大的高阶函数——fold

上一节我们介绍了如何独立地转换列表中的每个元素。本节中我们来看看如何将列表中的元素组合在一起。

假设你不仅想独立地看待列表元素,而是希望将它们组合起来。以下是几个例子:你可能想对列表中的所有元素求和,这就是一种组合方式。你可能想将列表中的所有元素连接起来,这是另一种组合方式。这两个简单的函数你现在应该能很快写出来。

让我们来实现它们。

let rec sum lst =
  match lst with
  | [] -> 0
  | h :: t -> h + sum t

let rec concat lst =
  match lst with
  | [] -> ""
  | h :: t -> h ^ concat t

你可能会注意到这两个函数之间存在许多共同点。

它们的结构非常相似。第一行,唯一不同的是函数名。第二行,唯一不同的是它们在基础情况下返回的值。第三行,唯一不同的是它们在表头元素和递归调用结果之间应用的运算符。

因此,抽象原则告诉我们,应该将重复的代码提取出来。

让我们将“组合一系列列表元素”这个想法提取到一个函数中。

let rec combine init op lst =
  match lst with
  | [] -> init
  | h :: t -> op h (combine init op t)

这个模式匹配列表。如果是空列表,我们需要返回某种基础值或初始值。因此,我们让combine函数接受这个初始值作为参数init

在列表有表头h和表尾t的情况下,我们需要做什么?我们需要将表头h与对表尾进行递归组合的结果结合起来。那么,我们如何进行这种组合呢?我们再次引入一个函数来告诉我们如何操作。因此,我们接受一个操作函数op。这个操作函数将用于将表头元素h与其他内容(即对表尾进行递归组合的结果)结合起来。

现在,我们有了combine函数,可以用它来重写sumconcat

let sum' lst = combine 0 ( + ) lst
let concat' lst = combine "" ( ^ ) lst

sum'函数通过combine实现,初始值为0,组合运算符是加法( + )concat'函数同样使用combine,初始值为空字符串"",组合运算符是连接运算符( ^ )

这样,我们就提取出了组合列表元素的通用代码。现在,实现这两个函数只剩下识别它们各自特有的部分:基础值和组合运算符。

我们刚刚编写的combine代码,其核心思想正是OCaml标准库中一个函数(实际上是几个函数)的基础,这个函数被称为foldfoldreduce的近亲。

fold函数是函数式编程中一个极其强大和通用的概念,它能够捕获许多列表处理模式的本质。

本节课中我们一起学习了如何将列表元素组合成单一结果,并引入了fold函数的核心思想。我们通过抽象出sumconcat函数的共同模式,创建了一个通用的combine函数,这为理解和使用OCaml内置的fold函数奠定了坚实的基础。

050:折叠函数 🧮

在本节课中,我们将要学习OCaml中两个强大的高阶函数:fold_rightfold_left。它们用于将列表中的所有元素通过一个给定的操作“折叠”或“累积”成一个单一的值。理解这两个函数的工作原理及其区别,对于编写简洁高效的函数式代码至关重要。

从右向左折叠:fold_right 的概念

上一节我们介绍了列表处理的基本模式,本节中我们来看看如何通过折叠操作来累积结果。fold_right 的核心思想是从列表的最右侧元素开始,向左逐个将元素“折叠”或“合并”到一个初始值中,从而累积出一个最终答案。

这个过程可以看作是反复应用一个函数 f,将列表中的每个元素与当前累积的结果结合起来。它从最右边的元素 C 开始,将其与初始值合并,然后向左移动到元素 B,最后是元素 A

实现 fold_right

我们可以自己编写代码来实现 fold_right 的概念。它需要对列表进行模式匹配。

如果列表是空的,它需要返回那个初始值。我们现在开始称这个初始值为累加器,它代表了在折叠过程中不断累积的结果。fold_right 需要将这个初始累加器作为一个参数。

在匹配空列表之后,我们可以匹配非空列表。我们需要一个函数来组合元素,这个函数就是我们之前提到的操作符 op。应用这个函数到列表的头部元素,并继续在列表的尾部进行从右向左的折叠。

以下是 fold_right 的一个实现示例。需要注意的是,标准库中 fold_right 的列表参数位置有所不同,因此我们的模式匹配方式需要调整。

let rec fold_right f lst acc =
  match lst with
  | [] -> acc
  | hd :: tl -> f hd (fold_right f tl acc)

为什么这是从右向左折叠?因为我们直到对列表右侧所有元素的递归调用完成后,才将左侧的头部元素 hd 通过函数 f 合并进来。这使得折叠操作从列表的末端开始。

从左向右折叠:fold_left 的概念

正如你可能猜到的,列表库中还有另一个函数:fold_left。它的想法是从左向右折叠。

fold_left 首先将列表最左侧的元素合并到初始值中,作为当前累积答案的一部分。因此,我们先用函数 f 合并初始值和 A,然后是 B,最后是 C,沿着列表从头部开始向右移动。

实现 fold_left

让我们自己编写 fold_left 的代码。这里,标准库的参数顺序是:先接受累加器,然后是列表。有一个助记符可以帮助记忆:累加器位于列表参数的哪一侧。对于 fold_right,累加器在列表的右侧;对于 fold_left,累加器在列表的左侧

我们可以对列表进行模式匹配。如果列表为空,我们返回累加器。如果列表非空,我们需要从左开始累积。这意味着我们将使用组合函数,将当前的累加器与头部元素结合。这样就得到了一个新的累加器值 acc'。然后,我们需要用这个新的累加器和列表的尾部继续进行折叠。

以下是 fold_left 的实现:

let rec fold_left f acc lst =
  match lst with
  | [] -> acc
  | hd :: tl -> fold_left f (f acc hd) tl

为什么这是从左向右折叠?因为每次我们应用执行累积操作的函数 f 时,都是先将其应用于头部元素和当前累积值,然后才使用这个结果继续折叠剩余的尾部。

fold_leftfold_right 的区别

你可能会想,为什么需要 fold_leftfold_right 两个函数?它们真的有区别吗?毕竟,考虑用初始值 0 和操作 + 来折叠列表 [1; 2; 3],无论从左到右还是从右到左,结果都是 6

然而,并非所有操作符都像加法那样。如果你尝试用初始值 0 和操作符 - 来折叠列表 [1; 2; 3]

  • 从左向右折叠会得到 -6
  • 从右向左折叠会得到 2

因此,一般来说,当操作符不满足结合律交换律时,从左折叠和从右折叠会得到不同的结果。根据你想要实现的计算逻辑,有时可能需要从左开始,有时可能需要从右开始。

fold_leftfold_right 之间还有另一个重要区别:尾递归性。你能看出哪个是尾递归的吗?

fold_left 的实现中,递归调用之后没有剩余的工作需要做,函数直接返回递归调用的结果。这使其成为尾递归函数,因此它只需要常数级别的栈空间。

另一方面,fold_right 不是尾递归的。在递归调用之后,还有工作要做:需要应用函数 f。因此,fold_right 所需的栈空间与列表参数的大小成线性关系。

这个区别也可能影响你对使用哪个函数的选择。

如果你想要一个从右折叠的尾递归版本,通常可以先反转列表,然后进行从左折叠。是的,这需要额外的遍历时间,但通常不会增加算法的渐近复杂度,并且能提供一个不会导致栈溢出的实现。

总结

本节课中我们一起学习了OCaml中的两个核心折叠函数:

  1. fold_right:从列表的右端开始向左折叠,其实现不是尾递归的。
  2. fold_left:从列表的左端开始向右折叠,其实现是尾递归的,效率更高。

理解它们的方向差异、对非结合性操作符的影响以及尾递归特性,对于在适当场景下选择正确的工具至关重要。折叠函数是函数式编程中用于迭代和累积的基石,掌握它们能极大地提升你处理列表数据的能力。

051:过滤函数 🧹

概述

在本节课中,我们将学习过滤(Filter)这一高阶函数。它是抽象原则的第三个重要例子。我们将了解如何从列表中筛选出满足特定条件的元素,并探讨如何实现其尾递归版本。


从具体需求到抽象

上一节我们介绍了mapfold。本节中我们来看看另一种常见操作:从列表中保留一部分元素,同时丢弃另一部分。

例如,你可能想从一个列表中提取所有偶数元素,或者所有奇数元素。这与map不同,因为map会处理并保留每一个元素。这也不是fold,因为fold通常会将元素以某种方式组合起来。我们这里的目标是有选择地保留元素。

首先,我们直接编写实现具体需求的函数。

let rec evens = function
  | [] -> []
  | h::t -> if h mod 2 = 0 then h :: evens t else evens t

let rec odds = function
  | [] -> []
  | h::t -> if h mod 2 = 1 then h :: odds t else odds t

编写odds函数时,我直接复制粘贴了evens的代码并稍作修改。这是一个强烈的信号,表明我们应该将其中共同的功能抽象出来


抽象出过滤函数

我们真正在做的是根据某个条件“过滤”列表中的元素。让我们将这个函数命名为filter。提示一下,这也是OCaml标准库中这个函数的名字。

filter函数需要决定是否保留列表的头部元素。因此,我们让它接收一个谓词(predicate)p。这个谓词是一个函数,它接收一个元素,并返回一个布尔值(bool),告诉我们是否应该保留该元素。

let rec filter p = function
  | [] -> []
  | h::t -> if p h then h :: filter p t else filter p t

现在,我们可以用filter来简洁地重新实现evensodds函数。首先,最好为判断奇偶性定义单独的函数。

let even x = x mod 2 = 0
let odd x = x mod 2 = 1

let evens lst = filter even lst
let odds lst = filter odd lst

这样,我们就得到了简洁、易读的一行实现,利用了高阶函数filter


实现尾递归版本

上面的filter函数不是尾递归的。你可以看到,在递归调用之后,还有额外的工作(h :: ...)需要完成,然后才能返回结果。

如果你想实现一个尾递归版本的filter,也是可以的。构建尾递归函数的通用过程如下:

  1. 为函数添加一个累加器(accumulator)参数。累加器用来存放结果,这样递归调用后就不需要做额外工作了。
  2. 当到达基本情况(如空列表)时,返回累加器。

让我们尝试一起构建它。首先,我们添加一个累加器参数,并创建一个辅助函数来完成主要工作。

let filter p lst =
  let rec filter_aux p lst acc = match lst with
    | [] -> acc
    | h::t -> filter_aux p t (if p h then h :: acc else acc)
  in
  filter_aux p lst []

我们几乎完成了。但这里有一个关键问题需要检查。


顺序问题与修复

让我们运行一下这个尾递归版本的代码,看看还有什么问题。

(* 测试:filter even [1; 2; 3; 4] *)
(* 期望得到: [2; 4] *)
(* 实际得到: [4; 2] *)

我们得到了[4; 2],顺序反了!这是因为我们是从左到右处理列表的。当我们发现第一个偶数2时,把它放到了累加器acc(初始为[])的前面,得到[2]。然后发现4,又把它放到[2]的前面,最终得到[4; 2]

解决方案是:在最终返回结果之前,将累加器反转。我们可以使用标准库中的List.rev函数。

let filter p lst =
  let rec filter_aux p lst acc = match lst with
    | [] -> List.rev acc (* 关键修复:反转结果 *)
    | h::t -> filter_aux p t (if p h then h :: acc else acc)
  in
  filter_aux p lst []

这是第二次我们讨论尾递归时提到反转操作。上一次(在讨论fold时)提到,你可能需要在输入列表前先将其反转。而这里的情况相反:在完成尾递归计算后,你可能需要反转结果列表,以得到期望的顺序。

再次强调,这个反转操作不会增加函数的时间复杂度(仍然是O(n))。但它改善了空间复杂度,因为尾递归版本只使用常数级的栈空间,而不是与列表长度成线性关系。


总结

本节课中我们一起学习了:

  1. 过滤filter)函数的概念:根据谓词条件筛选列表元素。
  2. 如何从具体函数(evens, odds)中抽象出通用的filter高阶函数。
  3. 如何实现filter的基础递归版本。
  4. 如何将filter转化为尾递归版本,以优化栈空间使用。
  5. 在构建尾递归filter时遇到的结果顺序问题,以及通过List.rev反转列表来解决它。

filter与之前学过的mapfold一样,是函数式编程中用于处理集合的核心高阶函数,体现了强大的抽象能力。

052:🌳 树上的Map与Fold

在本节课中,我们将学习如何将高阶函数mapfold的概念应用到二叉树结构上。你将看到,这些强大的抽象不仅限于列表,也能让树的操作变得简洁而优雅。

概述

上一节我们介绍了列表上的mapfold操作。本节中,我们来看看如何将这些概念推广到更复杂的数据结构——二叉树上。我们将定义树上的mapfold函数,并通过实例理解其工作原理。

树的类型定义

首先,回顾一下我们之前定义的二叉树类型。这种树由叶子(空树)和节点(包含值、左子树和右子树)构成。

type 'a tree =
  | Leaf
  | Node of 'a * 'a tree * 'a tree

在树上实现Map

map操作的目标是将一个函数应用到树中的每一个元素上,并生成一棵结构相同的新树。

以下是实现树map函数的逻辑:

  • 如果树是Leaf(空树),则无需任何操作,直接返回Leaf
  • 如果树是一个Node,包含值v、左子树l和右子树r,那么我们需要:
    1. 将函数f应用到当前节点的值v上,得到新值。
    2. 递归地对左子树l应用map f
    3. 递归地对右子树r应用map f
    4. 用新的值、新的左子树和新的右子树构建一个新的Node并返回。

其代码实现如下:

let rec tree_map f = function
  | Leaf -> Leaf
  | Node (v, l, r) -> Node (f v, tree_map f l, tree_map f r)

可以看到,一旦理解了map的核心思想——对结构中的每个元素应用变换——你就能很自然地将它应用到各种数据结构上,二叉树只是其中之一。你同样可以对队列、栈甚至图进行map操作。

例如,如果你想将树中每个元素的值加一,现在可以轻松地用tree_map实现:

let add_one_tree t = tree_map (fun x -> x + 1) t

当你习惯了这种高阶编程风格后,实现功能会变得非常迅速。

在树上实现Fold

接下来,我们探讨如何在树上实现fold操作。fold的目标是遍历整棵树,并使用一个累积器和一个组合函数,将树中的所有值归约为一个单一结果。

树上的fold函数需要三个参数:一个组合函数f、一个初始累积器acc和一棵树t

其实现逻辑如下:

  • 如果树是Leaf,直接返回当前的累积器acc
  • 如果树是一个Node,包含值v、左子树l和右子树r,那么我们需要:
    1. 用当前的累积器acc递归地对左子树进行折叠,得到左子树的折叠结果acc_left
    2. acc_left作为新的累积器,递归地对右子树进行折叠,得到右子树的折叠结果acc_right
    3. 最后,使用组合函数f,将当前节点的值v、左子树的结果acc_left和右子树的结果acc_right三者组合起来,得到最终结果。

这个过程可以用以下公式描述:
fold f acc (Node(v, l, r)) = f v (fold f acc l) (fold f acc_right r)
其中 acc_right = fold f acc l

代码实现如下:

let rec tree_fold f acc = function
  | Leaf -> acc
  | Node (v, l, r) ->
      let acc_left = tree_fold f acc l in
      let acc_right = tree_fold f acc_left r in
      f v acc_left acc_right

这里稍微复杂的地方在于,组合函数f现在需要接收三个参数:当前节点的值、左子树的折叠结果和右子树的折叠结果。

例如,我们可以用这个fold来求一棵树中所有值的和,就像我们对列表求和一样:

let sum_tree t = tree_fold (fun v left_sum right_sum -> v + left_sum + right_sum) 0 t

实例演示

假设我们有一棵示例树t,其结构如下(根节点值为2):

  2
 / \
1   3

我们可以进行以下操作:

  1. 对树中每个值加一tree_map (fun x -> x + 1) t 会生成一棵新树 (3, (2, Leaf, Leaf), (4, Leaf, Leaf))
  2. 计算原树所有值的和sum_tree t 将返回结果 6
  3. 先加一再求和sum_tree (tree_map (fun x -> x + 1) t) 将返回结果 9(因为每个节点都增加了1)。

能够以这种高阶的方式进行编码是如此愉快和美妙,希望你也能很快爱上它。

总结

本节课中,我们一起学习了如何将mapfold操作扩展到二叉树数据结构。

  • 我们实现了tree_map函数,它能将函数应用到树的每个节点,并保持树的结构不变。
  • 我们实现了tree_fold函数,它能通过遍历将整棵树归约(折叠)为一个单一值。
  • 通过实例,我们看到了使用这些高阶函数能让代码变得非常简洁和富有表达力。

理解这些模式后,你可以将它们应用到许多其他数据结构中,这是函数式编程强大抽象能力的体现。

053:模块化编程入门 🧩

在本节课中,我们将要学习模块化编程的基本概念,了解为何在构建大型软件时模块化至关重要,并初步认识OCaml为实现模块化所提供的语言特性。

概述:软件规模与模块化需求

让我们谈谈软件的规模。我的A1 Enigma作业解决方案大约有100行代码。对于初学者而言,这可能感觉很多,但就现实世界的软件而言,这并不算大。

OCaml编译器本身大约有20万行代码。Unreal Engine 4,即《堡垒之夜》和《最终幻想7重制版》等优秀游戏背后的游戏引擎,代码超过200万行。而如今已过时的Windows Vista,其代码超过5000万行。我们虽不清楚Windows 10的确切行数,但肯定非常庞大。

有一个网页以可视化方式展示了软件的规模,我邀请你稍后自行查看。其范围从简单的iPhone游戏应用,一直扩展到Windows NT、Microsoft Office、大型强子对撞机,甚至以某种方式用代码行数表示的小鼠基因组,最终以谷歌所有互联网服务的20亿行代码为顶峰。这是2015年的信息,如今规模只会变得更大。

因此,大型软件包含大量代码。这不可能由一个人完成。没有任何程序员能理解Windows操作系统的所有细节,甚至可能连OCaml编译器都难以完全理解。正因如此,仅凭我们目前所学的OCaml构造来构建这类软件代码库,对一个人来说过于复杂。你需要更多的语言支持。

模块化编程的核心概念

为了构建这类大型软件,特别是,你需要模块化编程的支持。

模块,在软件开发中,通常指一组相关的代码。这些代码可以由程序员团队分开开发,并能被独立理解。

模块化编程的主要好处是程序员能够进行局部推理,而非全局推理。他们只需要理解代码库的一部分,并处理某些代码行,而非所有代码行。这对于保持开发者自身的理智,以及减少必须参加的会议数量都尤为重要。你接触的代码行越多,需要成为专家的软件部分就越多,与开发团队其他成员沟通的时间也就越多。这会占用时间,影响你本可用于自身代码开发的时间。

主流语言的模块化特性

任何主流编程语言都提供了许多模块化特性。在Java中,你已经学习了很多相关内容,例如类、包、接口等。

以下是几个重要的模块化特性:

  • 命名空间:指的是你可能在第一个编程语言中就已习惯的点符号表示法。例如在OCaml中,List.sort。这是一种分层的命名空间,使得像sort这样的短名称在不同上下文中可以表示不同含义而不会冲突。Java通过类和包来提供分层命名空间。当然,类和包还有其他功能,但这是它们的重要好处之一。OCaml也通过结构提供分层命名空间,我们很快会学到。
  • 接口:这里我通用地使用这个术语,而非特指Java的interface。它关乎相关的名称组以及这些名称的规范。这些规范可能是类型规范,也可能是通过注释描述的行为规范。Java通过其语言机制interface提供接口,你在CS2110中已经见过。OCaml提供了自己版本的接口,称为签名,我们很快会学习。
  • 封装:正如你在CS2110中学到的,这是模块化编程非常重要的一部分。正是它使得局部推理(而非全局推理)成为可能。Java通过publicprivate等可见性修饰符部分地提供封装。OCaml通过称为抽象类型的东西提供类似功能。
  • 代码复用:这是模块化编程的重要组成部分。我们希望能够复用已创建的代码模块,而不是每次想构建新软件时都从头开始重建一切。Java通过子类型化和继承(主要是你在CS2110中学到的extends关键字)提供代码复用机制。OCaml提供了相当不同的代码复用机制,称为函子包含,我们将在本周视频的最后学习这些内容。

总结

本节课中,我们一起学习了模块化编程的重要性,它源于现代软件庞大的代码规模。我们探讨了模块化带来的核心好处——局部推理,并简要对比了Java和OCaml为实现模块化所提供的不同语言特性,包括命名空间、接口、封装和代码复用。在接下来的课程中,我们将深入探索OCaml具体的模块系统。

054:模块与结构体 🧩

在本节课中,我们将学习OCaml中的模块(Modules)和结构体(Structures)。它们是组织代码、创建层次化命名空间的重要工具,能有效解决命名冲突问题。

概述:命名冲突问题

上一节我们介绍了自定义类型和函数。本节中我们来看看当代码规模增长时可能遇到的一个常见问题:命名冲突。

结构体为我们在OCaml中提供了层次化的命名空间。让我们回顾一下上周看到的一些代码。

我们有一个列表类型和一个树类型,以及分别作用于它们的两个map函数。这次,我将这两个函数都命名为map

type 'a mylist = Nil | Cons of 'a * 'a mylist
let rec map f = function
  | Nil -> Nil
  | Cons (h, t) -> Cons (f h, map f t)

type 'a tree = Leaf | Node of 'a tree * 'a * 'a tree
let rec map f = function
  | Leaf -> Leaf
  | Node (l, v, r) -> Node (map f l, f v, map f r)

let list = Cons (1, Cons (2, Cons (3, Nil)))
let result = map (fun x -> x + 1) list

当我尝试对mylist进行map操作时,这会产生一个问题。因为这里的map一词指向其最近的定义,即作用于树的map函数。它遮蔽了之前定义的用于列表的map函数。实际上,没有办法恢复那个早期的定义,除非我们使用结构体。

解决方案:使用模块和结构体

为了解决上述问题,让我们将mylist的定义封装在OCaml称为模块的结构中。

我们将该模块命名为MyList。它是一个以关键字struct开始,以关键字end结束的结构体。我将对tree做同样的事情,模块Tree也是一个结构体。

module MyList = struct
  type 'a mylist = Nil | Cons of 'a * 'a mylist
  let rec map f = function
    | Nil -> Nil
    | Cons (h, t) -> Cons (f h, map f t)
end

module Tree = struct
  type 'a tree = Leaf | Node of 'a tree * 'a * 'a tree
  let rec map f = function
    | Leaf -> Leaf
    | Node (l, v, r) -> Node (map f l, f v, map f r)
end

现在,map这个词是未绑定的,因为它不再像之前那样定义在顶层作用域中。相反,我现在必须说MyList.map来获取其MyList版本,这就是我在这里想要的。或者说Tree.map,这当然不是我想要的,它会返回我们之前看到的原始错误。

因此,我们创建了一个名为MyList的模块,并将其绑定到这个结构体。该结构体内部包含我们一直编写的所有代码。这基本上为我们提供了一种创建具有层次化名称的代码嵌套级别的方法。

结构体与类型名称

关于结构体和模块名称,一个可能令人惊讶的地方是,当涉及类型时,名称本身位于何处。

如果我们查看list的类型,可以看到它的类型是int MyList.mylistMyList是我们在模块MyList内部定义的类型构造器。该类型构造器以类型变量'a为参数。因此,list的类型是int MyList.mylist。因为名称mylist定义在模块MyList内部,所以模块名加点号放在类型名前面。

模块名不会放在类型变量前面。可以把这个类型变量想象成传递给某种类型级别函数的参数。所以在这里,int被传递给了MyList.mylist

我们也可以查看tree并看到相同的现象。

解决模块内构造器的引用

现在你会看到我们有一个错误,因为OCaml不知道这里的Node是什么意思。它不知道我们指的是Tree模块内部的Node。我们可以通过几种不同的方式向OCaml表明我们想要那个。

一种方式是在这里使用Tree.Node。一旦OCaml知道了这一点,它就不会强迫我们在其余这些地方写Tree.Leaf,类型推断引擎会解决这个问题。

let t = Tree.Node (Tree.Leaf, 5, Tree.Leaf)

另一种帮助类型推断的方法是手动注解t的类型。

let t : int Tree.tree = Node (Leaf, 5, Leaf)

现在我们不必在任何构造器前面加上Tree.了。

关键概念总结

以下是使用模块和结构体的核心要点:

  • 模块定义:使用 module 模块名 = struct ... end 语法。
  • 访问成员:使用 模块名.成员名(如 MyList.map)来访问模块内部的类型、函数或值。
  • 作用域:模块内的定义具有自己的命名空间,不会污染顶层作用域。
  • 类型注解:在引用模块内定义的类型时,格式为 模块名.类型名(如 int MyList.mylist)。
  • 类型推断辅助:提供显式的类型注解(如 : int Tree.tree)可以帮助编译器理解代码意图,从而简化构造器的使用。

本节课总结

本节课中我们一起学习了OCaml的模块和结构体。我们看到了如何利用它们将相关的代码组织在一起,创建清晰的层次化命名空间,从而避免不同部分代码之间的命名冲突。通过模块名.成员名的访问方式,我们可以明确地引用特定模块中的定义,这使得大型程序的构建和维护变得更加清晰和可靠。

055:函数式栈 🥞

在本节课中,我们将要学习如何使用OCaml来实现一个经典的数据结构。我们将从定义栈的类型开始,逐步实现其核心操作,并将其与OCaml内置的列表进行对比,最后探讨其与面向对象语言(如Java)中栈实现的异同。

栈数据结构简介

还记得栈这种数据结构吗?它就像自助餐厅里的一叠托盘。数据项被压入栈顶,并从栈顶弹出。让我们在OCaml中编写栈的代码。

定义栈类型

首先,我创建了一个名为MyStack的模块。之所以命名为MyStack,是因为标准库中已经有一个Stack模块,为了避免混淆和错误信息,我使用了不同的名字。

在这个模块内部,我们定义了一个类型'a stack。一个'a stack要么是Empty,要么是一个EntryEntry是一个二元组:该元组的第一个分量是栈顶的实际值,第二个分量是栈的其余部分。

module MyStack = struct
  type 'a stack =
    | Empty
    | Entry of 'a * 'a stack
end

实现栈操作

现在,我们可以编写所有常见的栈操作了。

以下是这些操作的实现细节:

  • empty:我在MyStack模块中创建了一个名为empty的值,用于生成空栈。这目前是为了方便。显然,我也可以直接写Empty这个构造器。但考虑到我们将要编写许多数据结构,记住数据结构的空值就叫empty会很有帮助,无论其内部实现如何。
  • push:要将一个值x压入栈s,我创建一个新的Entry,其中x在栈顶,s在其下方。
  • peekpoppeek的作用是告诉我栈顶的值是什么。pop的作用是移除栈顶的值。有些栈数据结构会将这两个操作合并为一个,返回栈顶元素并移除它。我们暂时将它们解耦。因此,这里的peek接收一个栈并返回其栈顶值。从类型签名'a stack -> 'a可以看出。pop接收一个'a stack并返回另一个'a stack。它不返回栈中的值,而是返回另一个栈。

peekpop的实现非常相似,它们都只是进行模式匹配。如果栈是空的,它们会抛出一个异常,提示栈为空。如果栈不为空,则返回Entry内部元组的相应分量。

let empty = Empty

let push x s = Entry (x, s)

let peek = function
  | Empty -> failwith "Empty"
  | Entry (x, _) -> x

let pop = function
  | Empty -> failwith "Empty"
  | Entry (_, s) -> s

栈与列表的关联

如果你一直在看这个实现,并且觉得'a stack类型看起来非常眼熟,那么你是对的。它基本上和列表的定义相同。我完全可以把stack写成list,把Empty写成Nil,把Entry写成Cons,这样我们就得到了之前见过的my_list类型。

那么,为什么不直接用列表来实现栈呢?让我们试试。

module ListStack = struct
  type 'a stack = 'a list
  let empty = []
  let push x s = x :: s
  let peek = function
    | [] -> failwith "Empty"
    | x :: _ -> x
  let pop = function
    | [] -> failwith "Empty"
    | _ :: s -> s
end

在这里,类型'a stack只是'a list的一个同义词。在这段代码体内,它们含义相同。empty就是空列表[]push就是一个::(cons)操作。peekpop则对应着通过模式匹配从栈中获取正确值的操作。

使用栈

要使用栈,我可以创建一个空栈,将值压入栈,并查看栈顶的值。让我们在utop中看看效果。

let s = ListStack.empty          (* s = [] *)
let s' = ListStack.push 1 s      (* s' = [1] *)
let top = ListStack.peek s'      (* top = 1 *)

可以看到,我创建了一个空栈s,另一个栈s'包含了值1,当我查看s'的栈顶时,我得到了值1。注意,查看栈顶并不会改变栈的内容。s'仍然是[1]。同时注意,push操作创建了一个新栈s',但原有的栈s保持不变。我甚至可以弹出s',这会返回空栈,而s'本身保持不变。

与Java语法的对比

让我们比较一下Java和OCaml中一些类似栈操作的语法。

在Java中,你可能会有一个Stack类,你可以实例化它来获得一个新对象s,然后你可以将1压入那个栈。

Stack<Integer> s = new Stack<>();
s.push(1);

在OCaml中,类似的一系列操作是:创建一个栈(可能是MyStack.emptyListStack.empty),将这个空栈值绑定到变量s,然后如果你想压栈,可以说将1压入它。

let s = ListStack.empty
let s' = ListStack.push 1 s

请注意,在创建栈时,我们不使用new关键字,而是直接引用模块内部定义的那个empty值。当你要压栈时,不是写stack.push,而是写push,而栈本身变成了push函数的一个参数。

与Java相比,在OCaml中,这可能会让人觉得有点“反”了,因为点的位置和参数的位置不同。事实证明,如果你学习编译器课程,了解面向对象语言是如何实现的,或者即使你学过Python并记得方法的self参数,Java实际上会将s.push(1)编译成一个版本,其中s作为参数提供给push方法。因此,在某种意义上,将对象视为方法的另一个参数,是思考面向对象语言的正确方式。所以,当深入到Java的实现层面时,它与OCaml真的没有太大不同。

总结

本节课中,我们一起学习了如何在OCaml中实现函数式的栈数据结构。我们从自定义的代数数据类型开始,实现了emptypushpeekpop等核心操作。接着,我们发现栈的本质与列表高度相似,并实现了基于列表的、更简洁的栈模块。通过实际使用示例,我们理解了函数式栈的不可变性特性——操作总是返回新栈,原栈保持不变。最后,我们对比了OCaml与Java在栈操作语法上的差异,并理解了其背后“将对象作为参数”的共通思想。

056:函数式数据结构 🧱

在本节课中,我们将要学习函数式数据结构的概念,理解其与命令式数据结构的关键区别,并探讨其持久性特性带来的优势与效率考量。

函数式数据结构简介

上一节我们介绍了栈的实现,本节中我们来看看这种栈所属的类别——函数式数据结构。

函数式数据结构是一种没有可变更新的数据结构。每个操作都接收一个数据结构的“旧值”,并返回一个“新值”,同时保持旧值不变。

操作的不变性

你可以通过查看我们栈实现中操作的签名来理解这一点。

以下是栈操作的签名:

val push : 'a -> 'a list -> 'a list
val pop : 'a list -> 'a list

push 接收一个值和一个栈(此处为 'a'a list),并返回一个新的 'a list。它实际上并不改变底层的栈;那个旧栈始终是不可变的。相反,push 返回给我们一个新的栈。

pop 也是如此。它接收一个 'a list 并返回一个 'a list

持久性与短暂性

我们说函数式数据结构是持久性的,而非短暂性的。这意味着数据结构的所有值在时间进程中持续存在。

我们在栈 SS' 的例子中看到了这一点。S 在对其执行 push 1 后保持不变,而 S' 在对其执行 pop 后也保持不变。那些旧值仍然存在,并且仍然可供使用。它们持续存在,并未消失。

效率考量

现在你可能会问,持久性数据结构的效率如何?与短暂性数据结构相比,它是否更难编写、更复杂?

可能是这样。事实证明,OCaml 编译器在效率方面非常出色,它能确保数据结构的表示在内存中共享相同的空间,这样你就不会因为保留所有不同版本而浪费内存。当然,OCaml 中也有垃圾回收机制,就像 Java 和其他面向对象语言一样,垃圾回收器可以回收不再使用的内存。

至于时间效率,存在一种现象:有时函数式数据结构(或者说持久性数据结构)确实需要更多一点的时间复杂度来实现。一个经验法则是,实现一个函数式数据结构可能比实现一个命令式数据结构多花费对数级别的时间。这并不是一个巨大的开销。

持久性的优势

为此,你获得了许多好处:既有持久性带来的好处,也有因为缺少命令式特性和可变更新而能够更轻松地推理代码中发生的情况的好处。


本节课中我们一起学习了函数式数据结构的核心概念。我们了解到函数式数据结构通过不可变操作实现持久性,旧值在操作后保持不变。虽然实现上可能带来轻微的时间开销,但其在内存共享、易于推理以及通过垃圾回收管理内存方面具有显著优势。理解这些特性是掌握函数式编程范式的关键一步。

057:模块与结构的语法与语义 📚

在本节课中,我们将深入学习OCaml模块和结构(Structure)的语法与语义。我们将从定义语法开始,探讨其与普通let定义的区别,并解释模块值的独特性质。


上一节我们介绍了模块的一些示例,现在让我们退一步,系统地审视它们的语法和语义。

模块定义语法 📝

模块定义的语法与let定义的语法相似,但它使用关键字module而非let

其基本形式如下:

module ModuleName = struct
  (* 定义内容 *)
end

我们首先书写module关键字,然后是模块名称,最后将这个名称绑定到一个模块值。构造模块值的一种方式是使用结构(structure),即以struct关键字开头,后跟一系列定义,并以end关键字结束。

命名规则与风格 🏷️

模块名称必须大写首字母,这与我们目前所见到的其他标识符(如let绑定)不同,后者必须小写首字母。

习惯上,模块名称采用驼峰命名法(CamelCase),而非蛇形命名法(snake_case),这是它与let定义的另一个区别。

结构内部定义 📦

以下是结构内部可以包含的定义类型:

  • let定义:用于绑定值或函数。
  • 类型定义:用于定义新的数据类型。
  • 异常定义:用于定义新的异常。
  • 嵌套模块:你甚至可以在模块内部嵌套其他模块,从而构建深层次的命名空间。

结构内部的每个定义都可以用双分号;;来终止,就像在交互式环境(如Utop)中书写一样。事实上,支持双分号主要是为了与Utop兼容。

但在现代OCaml代码中,在模块内部使用双分号已不被视为惯用做法。虽然你有时仍会看到一些代码库这样做,但如今我们倾向于省略它们。

结构求值语义 🔍

结构求值的语义非常简单。要评估一个结构,只需从上到下按顺序评估其中的每个定义。评估的结果是一个模块值(通常简称为“模块”)。一个模块会将其内部定义的名字绑定到相应的值上。

要评估一个模块定义,只需将结构评估为一个模块,然后将该模块绑定到指定的名称上。

模块值的独特性 ⚡

模块值与其他类型的值不同,它们在OCaml类型系统中是独立的一类

以下是模块值的一些限制:

  • 你不能使用let将一个模块绑定到一个名称。
  • 你不能将模块作为参数传递给函数。
  • 你不能将模块作为输出从函数中返回。

因此,在OCaml中,常规值(如果你愿意这么称呼)与模块值是分层的。如果你考虑一下其他语言,这并不奇怪。例如,在面向对象语言中,类定义通常也不能像其他定义那样使用——在Java中,你不能将一个类传递给一个方法(你可以传递一个类对象,但那是对象,而不是类本身)。


本节课中,我们一起学习了OCaml模块与结构的核心语法和语义。我们了解了如何定义模块、其内部的命名规则、可以包含的定义类型,以及模块值在语言中的独特地位。理解这些基础是有效使用OCaml模块系统进行代码组织和封装的必要前提。

058:作用域与模块打开 🧩

在本节中,我们将学习如何更简洁地使用模块中的函数,避免重复书写模块名。我们将探讨几种不同的方法,包括局部打开和全局打开,并了解各自的优缺点。


当我们想要使用模块中的许多函数时,有时代码会显得冗长。

这里我创建了一个栈:先创建一个空栈,然后压入42,最后查看栈顶元素。

这导致我不得不写了三次 ListStack。这种重复并不好。

有多种方法可以消除这种重复。以下是其中一种。

我写了 ListStack.( 然后括号。在这些括号内部,来自 ListStack 的所有名称都将在作用域内。这意味着我不必在每个名称前重复书写 ListStack.

顺便提一下,另一种编写此代码的好方法是使用管道运算符。

你能看到那里的管道吗?我取空栈,然后压入42,最后查看栈顶。编写相同代码的另一种方法是使用局部打开。

这是一种使用 let 表达式语法的特殊方式。

当你写 let open 然后跟一个模块名时,它会使该模块中的所有名称在该表达式的主体内部都处于作用域内。

所以 ListStack 中的所有内容在第42行都处于作用域内。

当你有一个很长的 let 表达式主体时,这很有用。你希望为该主体内部的所有行打开一个模块,而不仅仅是一行,这就是我们上面用括号语法所做的。

最后,编写此代码的另一种方法是进行非局部的、更全局的打开。

所以当我在顶部写 open ListStack 时,从此在这个文件的其余部分,来自 ListStack 的所有定义都将在作用域内。因此这里的 empty 指的是 ListStack 模块内部的 empty

这种方法的问题以及不鼓励使用它的原因是,如果你打开了两个恰好定义了相同名称的模块,一个名称会遮蔽另一个。事实上,在这一点上,如果我想使用我的 MyStack

上面的代码中,我遇到了一点麻烦。因为这里的 empty 现在将始终意味着 ListStack.empty。而如果我打开了 MyStack,那么 empty 将意味着 MyStack.empty

所以我又回到了引入结构之前所处的境地。我遇到了名称遮蔽的问题。

因此,最好在OCaml文件中非常谨慎地使用这种全局打开。

我们定期这样做的少数情况之一是在测试文件顶部打开 OUnit2,因为我们知道我们需要它的OUnit运算符和测试函数的定义。

在你自己的代码库中,你可能会创建一些你知道可以安全打开的模块,那也没问题。但是全局打开数据结构模块通常会有问题,因为它们通常都会定义 mapfold,可能还有 empty 以及其他类似的名称。


总结

本节课中,我们一起学习了在OCaml中管理模块作用域的几种方法。我们了解了使用括号语法进行局部打开、使用 let open 进行表达式内打开,以及使用全局 open 语句。关键是要理解全局打开可能导致名称冲突和遮蔽,因此应谨慎使用,优先考虑局部打开或显式使用模块名来保持代码的清晰性和可维护性。

059:函数式队列 🧑‍💻

在本节课中,我们将学习如何使用OCaml实现函数式队列。我们将从简单的列表实现开始,分析其效率问题,然后引入一种更高效的双列表实现方法。

概述:从简单列表开始

首先,我们尝试用列表来实现队列。列表的第一个元素代表队列的头部,最后一个元素代表队列的尾部。

type 'a queue = 'a list

空队列就是空列表。入队一个元素时,我们使用@操作符将该元素追加到列表的末尾。

let enqueue (x: 'a) (q: 'a queue) : 'a queue = q @ [x]

然而,这个操作的时间复杂度是线性的(O(n)),因为需要遍历整个列表才能到达末尾。这对于队列操作来说并不理想。

出队和查看队首元素的操作与我们之前实现的栈类似。

let peek (q: 'a queue) : 'a option =
  match q with
  | [] -> None
  | h :: _ -> Some h

let dequeue (q: 'a queue) : 'a option =
  match q with
  | [] -> None
  | _ :: t -> Some t

这里我们选择返回option类型来处理空队列的情况,而不是抛出异常。

引入高效的双列表实现

为了解决线性时间入队的问题,我们引入一种更高效的表示方法。这种方法是Gries教授的一位博士生在1981年发明的。

核心思想是用两个列表来表示一个队列:一个front列表和一个back列表。front列表按正确顺序存放队列前半部分的人,back列表则按相反顺序存放队列后半部分的人。

type 'a queue = {
  front: 'a list;
  back: 'a list;
}

例如,如果front = [A; B]back = [E; D; C],那么这个队列代表的顺序是A, B, C, D, E。A是队首,E是队尾。

为了使实现简化,我们引入一个重要的不变式:如果front列表为空,那么back列表也必须为空。这个约定保证了队首元素永远只可能在front字段中,简化了后续操作。

实现队列操作

基于上述设计,我们来实现各个操作。

查看队首(Peek)

查看队首元素的操作变得非常简单,只需查看front列表的第一个元素。

let peek (q: 'a queue) : 'a option =
  match q.front with
  | [] -> None
  | h :: _ -> Some h

入队(Enqueue)

入队操作需要考虑当前队列的状态。

以下是入队操作的逻辑:

  1. 如果队列为空(即front为空,根据不变式back也为空),新元素应放入front列表。
  2. 如果队列非空,新元素作为最新的成员,应放在队列末尾。由于back列表是反向存储的,我们可以将其添加到back列表的头部
let enqueue (x: 'a) (q: 'a queue) : 'a queue =
  if List.length q.front = 0 then
    { front = [x]; back = [] }
  else
    { q with back = x :: q.back }

这个操作是常数时间(O(1))的,因为我们只是将元素添加到一个列表的头部。

出队(Dequeue)

出队操作是本节课最微妙的部分。

以下是出队操作的逻辑:

  1. 如果队列为空,返回None
  2. 如果队列非空,通常只需移除front列表的头部元素。
  3. 关键情况:当移除队首元素后,front列表可能变空。这违反了我们的不变式(front空则back必须空)。此时,我们需要将back列表反转,并将其作为新的front列表,同时将back置为空。
let dequeue (q: 'a queue) : 'a option =
  match q.front with
  | [] -> None
  | [h] -> 
      (* 移除最后一个front元素,需要处理back *)
      Some { front = List.rev q.back; back = [] }
  | h :: t ->
      Some { q with front = t }

这个操作在大多数情况下是常数时间。只有在front列表耗尽、需要反转back列表的罕见情况下,才是线性时间。通过本学期后续会学到的摊还分析,我们可以证明该实现的平均时间复杂度确实是常数级的。

总结

本节课我们一起学习了函数式队列的两种实现方式。

  1. 我们首先用单列表实现了简单的队列,但发现了入队操作效率低下的问题。
  2. 接着,我们引入了一种巧妙的双列表结构,将队列分为frontback两部分,其中back列表反向存储。
  3. 通过维护“front空则back必空”的不变式,我们实现了高效的常数时间入队操作,以及平均常数时间的出队操作。

这种实现既优雅又高效,是函数式数据结构中的一个经典例子。

060:异常与选项及更多应用运算符 🚀

在本节课中,我们将学习如何使用列表实现栈和队列,并探讨在接口设计中异常(Exceptions)与选项(Options)的差异。我们还将介绍如何通过自定义运算符来解决选项类型在管道操作中带来的问题,使代码更加简洁和高效。


栈与队列的列表实现

我们已使用列表实现了栈和队列。以下是两种实现的并排展示,便于比较。

(* 栈的实现 *)
type 'a stack = 'a list

let empty_stack = []
let push x s = x :: s
let pop = function
  | [] -> failwith "Empty stack"
  | hd :: tl -> (hd, tl)

(* 队列的实现 *)
type 'a queue = 'a list

let empty_queue = []
let enqueue x q = q @ [x]
let dequeue = function
  | [] -> None
  | hd :: tl -> Some (hd, tl)

这两种实现非常相似。您可能注意到的主要区别在于异常与选项的使用。这并非数据结构本身的核心特性,而更多取决于接口设计的选择。


异常与选项在管道操作中的差异

异常使操作能够轻松地串联在一起。我可以不断添加新操作。选项则使这一点变得稍微复杂。

(* 使用异常的管道操作 *)
let result = empty_queue |> enqueue 1 |> enqueue 2 |> dequeue

(* 使用选项的管道操作(会导致类型错误) *)
let result = empty_queue |> enqueue 1 |> enqueue 2 |> dequeue

上述代码中,使用选项的版本会导致类型检查错误。因为 dequeue 返回一个 'a list option,而 enqueue 期望接收一个 'a list。选项类型在此阻碍了管道操作。


解决选项管道问题:Option.map 运算符

为了解决这个问题,我们可以编写一个新的管道运算符来处理额外的选项类型。以下是 Option.map 运算符的定义:

let (|>?) x f =
  match x with
  | None -> None
  | Some v -> Some (f v)

这个运算符接收一个选项和一个函数。如果选项是 None,则返回 None;如果是 Some x,则将函数 f 应用于 x。这类似于在列表中映射函数,但针对的是选项类型。

在标准库中,这个运算符以 Option.map 的名称提供。现在,我们可以使用它来修复之前的错误:

let result = empty_queue |> enqueue 1 |> enqueue 2 |>? dequeue

需要注意的是,整个管道操作的返回值现在被包装在选项中,因为 dequeue 可能返回 None


进一步优化:Bind 运算符

如果我们想在管道中继续调用 dequeue,会遇到新的问题:

let result = empty_queue |> enqueue 1 |> enqueue 2 |>? dequeue |>? dequeue

上述代码会导致类型错误,因为 dequeue 期望一个队列,而不是一个选项队列。为了解决这个问题,我们需要另一个运算符,它能够处理选项参数,但不会在结果外额外包装选项。

以下是 bind 运算符的定义:

let (>>=) x f =
  match x with
  | None -> None
  | Some v -> f v

这个运算符类似于 Option.map,但它不会在结果外添加额外的选项包装。在标准库中,这个运算符以 bind 的名称提供。使用它,代码可以正常类型检查:

let result = empty_queue |> enqueue 1 |> enqueue 2 >>= dequeue >>= dequeue

代码格式化建议

当管道操作包含多个元素时,建议将每个元素放在单独的行上,以提高代码的可读性:

let result =
  empty_queue
  |> enqueue 1
  |> enqueue 2
  >>= dequeue
  >>= dequeue

这样,每一行代码都清晰地表示管道中的一个步骤,便于阅读和理解。


总结

在本节课中,我们一起学习了如何使用列表实现栈和队列,并探讨了异常与选项在接口设计中的差异。通过引入 Option.mapbind 运算符,我们解决了选项类型在管道操作中带来的问题,使代码更加简洁和高效。最后,我们还介绍了如何通过格式化代码来提高管道操作的可读性。掌握这些技巧将帮助您编写更优雅、更易维护的OCaml代码。

061:模块类型与签名 🧩

在本节课中,我们将要学习OCaml中的模块类型与签名。它们是定义模块接口的强大工具,用于规定模块必须提供哪些功能,同时隐藏实现细节。


接口与签名

接口是一系列名称及其规范的集合。这些名称通常以某种方式相互关联,使得整个接口作为一个代码单元具有意义。

在Java中,你已经习惯了接口的概念。OCaml有一个类似的功能,称为签名


编写一个签名

让我们为那些包含阶乘函数的模块编写一个签名。

module type Fact = sig
  (** [fact n] is [n] factorial. *)
  val fact : int -> int
end

你立刻会发现,这看起来很像一个模块的定义,但有一些变化。

首先,我们使用 module type 关键字,因为这里我们指定的是模块的类型,而不是模块的具体值。定义该模块类型的代码块就是一个签名,它以关键字 sig 开始,以 end 结束。

在签名内部,你需要提供所有值的名称、类型和行为的规范,这些值将出现在任何属于此类型的模块中。

在这里,我声明必须有一个名为 fact 的值,其类型为 int -> int。并且我为它提供了一个规范注释:fact nn 的阶乘。

请注意这里 val 关键字的使用。你在UTop中已经见过它:每当我们创建一个值,UTop会回应说有一个名为 x 的值,其类型是 int,以及它的具体值是什么。这里使用 val 关键字是同样的含义,它表示存在一个名为 fact 的值,其类型是 int -> int。只是在我们定义模块类型时,我们并不说明这个值的具体实现是什么。


实现签名的模块

我可以提供具有此类型的模块。

module RecursiveFact : Fact = struct
  let rec fact n =
    if n <= 1 then 1 else n * fact (n - 1)
end

这个名为 RecursiveFact 的模块,拥有模块类型 Fact。它通过提供一个具有正确类型的 fact 定义来实现该模块。当然,类型检查器会验证类型是否正确。例如,我不能在这里返回一个字符串。

OCaml编译器无法检查这个函数是否真的是阶乘函数。关于代码正确性相对于文档注释的部分,是不会被检查的。

请注意,注释是放在模块类型中的,而不是模块里。模块类型是我们为外界记录所有关于函数应如何工作的规范的地方。这是客户端阅读类型文档的地方。因此,在OCaml中,文档被分解在接口和实现之间:接口中的文档是面向公众的,说明函数应如何行为。

模块类型要求必须有一个名为 fact 的函数,其他函数不行。

module WrongFact : Fact = struct
  let ink n = n + 1
end

我会得到一个类型检查错误:😡 签名不匹配,模块不匹配。类型为 int -> intval ink 未被包含。😡 实际上,需要值 fact 但未提供。

这个相当冗长的错误信息是说,这里有一个名为 fact 的值被这个模块类型注解所要求,但声称要实现该接口的结构并未提供它。


隐藏内部实现

我可以有其他实现相同接口的模块。

module TailRecursiveFact : Fact = struct
  let fact_aux acc n =
    if n <= 1 then acc else fact_aux (acc * n) (n - 1)
  let fact n = fact_aux 1 n
end

这个尾递归模块实现了阶乘接口,确实提供了一个阶乘函数。我可以从模块外部使用这个阶乘函数。

但是,有一件事我做不到,那就是使用辅助函数 fact_aux

(* 这会导致错误:Unbound value TailRecursiveFact.fact_aux *)

当我们对这个模块加上模块类型注解时,我们不仅是在说它必须提供该接口中的所有名称,我们还在说这些将是暴露给外界的唯一名称。因为 Fact 签名只提到了函数 fact,而没有提到函数 fact_aux,所以 fact_aux 被隐藏在 TailRecursiveFact 内部。

如果我去掉模块类型注解,那么这个编译错误就会消失,因为 fact_aux 不再被隐藏在那个接口后面。


总结

本节课中,我们一起学习了OCaml的模块类型与签名。我们了解到,签名(使用 module typesig ... end 定义)是模块的接口规范,它规定了模块必须公开哪些值及其类型。实现签名的模块必须提供所有指定的值,并且只有签名中列出的值才会对外暴露。这实现了信息隐藏,将公共接口与私有实现分离开来,是构建模块化、可维护代码的重要机制。

062:栈与队列的模块类型 📚

在本节课中,我们将学习如何为之前实现的栈和队列数据结构定义通用的模块类型(签名)。我们将看到如何通过签名来抽象数据结构的实现细节,并确保不同的实现满足相同的接口规范。

概述

上一节我们讨论了模块的基本概念。本节中,我们将重点学习如何为栈和队列定义模块类型,并使用这些类型来约束和验证不同的实现。

为栈定义签名

首先,我们尝试为之前用列表实现的栈编写一个签名。

以下是栈签名的定义过程:

module type ListStackSig = sig
  type 'a stack = 'a list
  val empty : 'a stack
  val push : 'a -> 'a stack -> 'a stack
  val pop : 'a stack -> ('a * 'a stack) option
end

现在,我可以让OCaml检查列表栈的实现是否满足这个签名。确实,列表栈的实现符合该签名。

通用栈签名的挑战

然而,对于之前实现的另一个栈版本(我们称之为MyStack),情况则不同。

以下是MyStack的实现:

module MyStack = struct
  type 'a stack = Empty | Node of 'a * 'a stack
  let empty = Empty
  let push x s = Node (x, s)
  let pop = function
    | Empty -> None
    | Node (x, s) -> Some (x, s)
end

当我尝试声明MyStack具有ListStackSig类型时,会出现编译错误。错误指出值不匹配:empty的类型不符合。具体来说,签名要求empty的类型是'a list,但在MyStack中,empty的类型是'a stack(这是在模块内部定义的类型)。

问题的根源在于,我为栈编写的初始签名不够通用。它只描述了使用列表实现的特定栈,而无法描述其他相关的栈实现。

通用化栈签名

为了解决这个问题,我们需要一个更通用的签名,不提及具体的实现类型(如列表)。

以下是通用栈签名的定义:

module type StackSig = sig
  type 'a stack
  val empty : 'a stack
  val push : 'a -> 'a stack -> 'a stack
  val pop : 'a stack -> ('a * 'a stack) option
end

现在,ListStackMyStack都可以满足这个通用的StackSig签名,因为它们都提供了自己的'a stack类型以及所需的值和函数。

为队列定义签名

接下来,让我们为队列创建一个类似的通用签名。

以下是队列签名的定义过程:

module type QueueSig = sig
  type 'a queue
  val empty : 'a queue
  val enqueue : 'a -> 'a queue -> 'a queue
  val dequeue : 'a queue -> ('a * 'a queue) option
end

定义这个规范需要一些思考,我需要确定如何处理可选值(option)并写下每个函数的行为。

应用队列签名

定义了队列签名后,我可以将其应用于不同的队列实现。

以下是两种队列实现应用签名的示例:

(* 第一种列表队列实现 *)
module ListQueueImpl = struct
  type 'a queue = 'a list
  let empty = []
  let enqueue x q = q @ [x]
  let dequeue = function
    | [] -> None
    | x :: xs -> Some (x, xs)
end

(* 第二种双列表队列实现 *)
module TwoListQueueImpl = struct
  type 'a queue = 'a list * 'a list
  let empty = ([], [])
  let enqueue x (front, back) = (front, x :: back)
  let dequeue (front, back) = match front with
    | [] -> (match List.rev back with
             | [] -> None
             | x :: xs -> Some (x, (xs, [])))
    | x :: xs -> Some (x, (xs, back))
end

现在,我可以使用签名来约束这些实现模块:

module ListQueue : QueueSig = ListQueueImpl
module TwoListQueue : QueueSig = TwoListQueueImpl

这里,等号右边不是一个结构体,而是一个之前定义好的模块值。我将其绑定到新名称(如ListQueue)的同时,也指定了它的模块类型。OCaml会满意地确认这两个模块都具有正确的模块类型。

总结

本节课中,我们一起学习了如何为栈和队列数据结构定义通用的模块类型(签名)。我们看到了一个具体的签名如何因为绑定到特定实现类型(如列表)而无法通用,并通过抽象类型'a stack'a queue解决了这个问题。最后,我们学会了如何使用签名来约束不同的模块实现,确保它们提供一致的接口。这是构建模块化、可互换组件的重要基础。

063:模块类型语法与语义 📘

在本节课中,我们将深入学习模块类型的语法和语义。我们将了解如何定义模块类型,以及类型检查器如何确保模块与其声明的类型相匹配。


概述

模块类型用于描述模块的结构。它定义了模块必须提供的组件,如值、类型和异常。通过为模块指定类型,我们可以确保模块的实现符合预期,并限制外部对模块内部细节的访问。


模块类型语法

模块类型的语法以关键字 module type 开头,后跟一个名称、等号、关键字 sig、一些规范说明,最后以关键字 end 结束。

module type ModuleTypeName = sig
  (* 规范说明 *)
end

值得注意的是,模块类型的名称不必像模块名称那样首字母大写,但为了保持一致性,通常还是会将其首字母大写。有一种较旧的命名习惯是全部使用大写字母和下划线,但这种写法现在已不常用。


规范说明的内容

模块类型中的规范说明可以包含多种组件:

  • :指定模块必须提供的函数或变量。
  • 类型:声明模块必须定义的类型。
  • 异常:列出模块可能抛出的异常。
  • 嵌套模块类型:在模块类型内部还可以包含其他模块类型。

模块定义语法的更新

上一节我们介绍了模块类型,现在我们可以回过头来更新模块的定义语法。在定义模块时,可以使用冒号为其指定一个模块类型。

module ModuleName : ModuleTypeName = struct
  (* 实现 *)
end

此外,你也可以将模块类型标注直接放在 struct 前面,这看起来更像我们一直使用的标准类型标注。

module ModuleName = (struct
  (* 实现 *)
end : ModuleTypeName)

模块类型语义

由于模块类型本身只是类型,因此没有求值语义,不需要对它们进行计算。但是,存在针对模块类型的类型检查语义。

当你为一个模块指定了类型后,类型检查器会执行以下两项主要任务。

1. 签名匹配

类型检查器会进行签名匹配。它确保在模块类型 sig 中指定的每一项,都在模块 mod 中有定义。并且,这些项的定义必须具有正确的类型。

2. 确保封装性

类型检查器会确保封装性。只有那些在 SIG(模块类型)中指定的内容,才能从模块 M 外部被访问。我们说模块在该签名下被“密封”了。

“密封”意味着你无法从模块中获取签名未列出的任何其他内容,只有那些在签名中被标识的名称才是可访问的。


签名匹配的规则

以下是签名匹配的具体规则:

  • SIG 中指定的每个名称都必须在 MOD 中有定义。
  • MOD 中定义的类型必须与 SIG 中声明的类型相同。

或者,MOD 中的类型可以比 SIG 中声明的更通用。

假设我们有一个模块类型,它指定必须有一个函数 f,其类型为 int -> int

module type INT_FUNC = sig
  val f : int -> int
end

我们可以用一个确实具有该类型函数的模块来实现它。

module M1 : INT_FUNC = struct
  let f x = x + 1
end

我们也可以用一个具有更通用类型的函数来实现它。

module M2 : INT_FUNC = struct
  let f x = x (* f 的类型是 'a -> 'a *)
end

这里,我使用恒等函数 ident 来实现模块 IDF。恒等函数通常可以处理任何类型的值。它适用于布尔值、整数和浮点数。在这里,我可以用它来实现 IntF

之所以允许这样做,是因为使用一个更通用的函数类型永远不会导致类型错误。这个恒等函数完全能够接受一个整数并返回一个整数,正如 INT_FUNC 所要求的那样。至于它是否也能处理其他类型的值,在这里并不重要。只要它能接受 int 并返回 int 就足够了。

因此,你可以看到,这里函数 f 的类型实际上是 'a -> 'a,但这足以实现一个类型为 int -> int 的函数,因为我们可以用 int 来实例化类型变量 'a


总结

本节课中,我们一起学习了模块类型的语法和语义。我们了解了如何定义模块类型,以及如何用它来注解模块定义。更重要的是,我们探讨了类型检查器如何通过签名匹配来确保模块实现的正确性,并通过密封模块来保证封装性。记住,模块中实现项的类型可以比签名中声明的更通用,这为代码复用提供了灵活性。

064:抽象类型 🧱

在本节课中,我们将学习OCaml中一种强大的封装机制——抽象类型。抽象类型允许我们隐藏模块内部数据类型的实现细节,从而提升代码的模块化和安全性。

签名与封装

上一节我们介绍了模块签名(Signatures)的基本概念。签名提供了一种比我们目前所见更强大的封装机制。

如果签名中不提及任何名称,那么当模块被该签名密封(sealed)时,这些名称就不会暴露给外部世界。这一点同样适用于类型。

抽象类型的概念

在我们的栈(stack)签名中,我们声明了一个类型 alpha stack,但没有指定这个类型具体是什么。List_stack 模块提供了它自己的实现,My_stack 模块则提供了另一种不同的实现。

因为签名没有说明具体的实现是什么,所以当模块被该签名密封时,外部世界就无法知道这个类型的具体定义。这就是抽象类型

例如,以下使用 List_stack 的代码可以正常工作:

let s = List_stack.push 42 List_stack.empty

但以下代码则会被类型检查器拒绝:

let s = [42]

错误信息类似于:This expression has type alpha list, but an expression was expected of type int List_stack.stack

发生这种情况的原因是,栈实际上是一个列表这一事实被隐藏在了接口之后。当我们声明 List_stack 的模块类型是 StackSig 时,类型定义的这一部分就对外部世界隐藏了,因为签名没有揭示这个事实。

抽象类型的优势

正如我们之前所见,签名不揭示具体实现是件好事,因为它允许我们为栈接口的两种实现使用相同的签名。

以下是抽象类型带来的关键好处:

  • 实现自由:作为数据结构的实现者,你可以自由地升级其内部实现,而不会影响客户端代码。
  • 信息隐藏:栈或队列的客户端不需要知道它是如何实现的(例如,是列表还是其他自定义变体)。
  • 防止滥用:如果你暴露了实现细节(例如,队列是用列表实现的),那么客户端可能会直接使用列表操作,这会破坏封装性,使得未来升级实现变得困难。

这种封装对于模块化编程来说是一个真正的益处。

抽象类型的惯用写法

在OCaml中,为数据结构定义抽象类型有一个常见的惯用写法:将类型简单地命名为 t

让我们看看这会是什么样子。现在,我们在 StackSig 内部将表示类型称为 t,而不是 stack

module type StackSig = sig
  type 'a t
  val empty : 'a t
  val push : 'a -> 'a t -> 'a t
  val pop : 'a t -> 'a t option
  val peek : 'a t -> 'a option
end

这种写法更简短,并且OCaml程序员通常期望 type t 就是该模块中主要的数据结构类型。在代码中,你需要将之前的 stack 替换为 t

module ListStack : StackSig = struct
  type 'a t = 'a list
  let empty = []
  let push x s = x :: s
  let pop = function
    | [] -> None
    | _ :: xs -> Some xs
  let peek = function
    | [] -> None
    | x :: _ -> Some x
end

使用 t 不仅节省了字符和输入,而且一旦你习惯了这种阅读方式,代码会变得更容易理解。在脑海中,你可以忽略 .t,直接将其读作 int ListStack(一个整数栈)。

总结

本节课中,我们一起学习了OCaml中的抽象类型。我们了解到,通过在模块签名中声明但不定义类型,可以创建抽象类型,从而有效地隐藏数据结构的内部表示。这种机制增强了代码的封装性、安全性和模块化,允许实现者自由更改内部实现,同时确保客户端代码的稳定。我们还学习了使用 type t 作为抽象类型的惯用命名约定,这使代码更加简洁和符合OCaml社区的惯例。

OCaml编程:5.13:编译单元 📦

在本节课中,我们将学习如何将模块的接口和实现分离到两个独立的文件中,这种结构被称为“编译单元”。我们将了解 .mli.ml 文件的作用,以及OCaml编译器如何将它们视为一个模块。


概述

到目前为止,我们一直在 .ml 文件中使用 modulemodule type 关键字来定义模块和模块签名。本节将介绍另一种更标准、更模块化的方法:将代码分解到两个独立的文件中。一个文件(.mli)存放模块的接口(签名),另一个文件(`.ml``)存放模块的实现


编译单元的结构

编译单元由一对具有相同基础名称的文件组成,它们位于同一目录下:

  • .mli 文件:包含模块的接口。它声明了模块对外公开的类型、值和函数及其行为规范。
  • .ml 文件:包含模块的实现。它提供了接口中声明的所有内容的实际代码。

例如,对于一个栈模块,我们可以创建 stack.mlistack.ml

stack.mli 中,我们定义接口:

(** 栈的抽象类型 *)
type 'a t

(** 创建一个空栈 *)
val empty : 'a t

(** 判断栈是否为空 *)
val is_empty : 'a t -> bool

(** 将元素 [x] 压入栈 [s] *)
val push : 'a -> 'a t -> 'a t

(** 弹出栈 [s] 的栈顶元素。
    如果栈为空则引发 [Failure] 异常。*)
val pop : 'a t -> 'a t

(** 返回栈 [s] 的栈顶元素。
    如果栈为空则引发 [Failure] 异常。*)
val peek : 'a t -> 'a

stack.ml 中,我们提供实现(这里用列表实现):

type 'a t = 'a list

let empty = []

let is_empty s = s = []

let push x s = x :: s

let pop = function
  | [] -> failwith "Empty stack"
  | _ :: s -> s

let peek = function
  | [] -> failwith "Empty stack"
  | x :: _ -> x

注意,公共的行为规范(给使用者看的)只写在 .mli 文件中。而在 .ml 文件中,可以添加面向代码维护者的注释,例如说明“栈是用列表实现的,栈顶是列表的头部”。


编译器如何处理编译单元

OCaml编译器会将一个编译单元视作一个模块定义。具体来说,对于文件 myfile.mlmyfile.mli,编译器会像处理以下代码一样处理它们:

module Myfile : sig
  (* myfile.mli 文件中的所有声明 *)
  ...
end = struct
  (* myfile.ml 文件中的所有定义 *)
  ...
end

请注意,模块名 Myfile 是文件名首字母大写后的形式。.mli 文件的内容成为了一个匿名签名,约束着模块的实现。

这种机制实现了完美的封装:任何在 .mli 接口中未声明的值(例如实现中使用的辅助函数),对外部世界都是完全隐藏和不可访问的。


标准库中的实例

OCaml标准库广泛使用了编译单元。如果你好奇某个标准库模块是如何实现的,可以轻松查看。

例如,我们经常使用的 List 模块:

  • 其接口定义在 list.mli 文件中。该文件为每个函数(如 val length : 'a list -> int)声明了类型,并在注释中提供了行为规范。
  • 其实现代码在 list.ml 文件中。例如,你可以看到 length 函数内部使用了一个尾递归的辅助函数 length_aux。由于 length_aux 没有在 list.mli 中声明,因此它对模块使用者是完全隐藏的。

总结

本节课我们一起学习了OCaml中的编译单元。我们了解到,通过将代码分离到 .mli(接口)和 .ml(实现)两个文件中,可以更好地组织代码、明确公开的API、并利用签名来隐藏实现细节。这种结构是OCaml模块化编程的基础,也是标准库和大型项目的标准组织方式。从下一节开始,我们将在编程实践中应用这一概念。

066:Utop与模块的交互 🧩

在本节课中,我们将学习如何在Utop交互式环境中与模块进行交互。我们将了解useloadrequire命令的区别,以及它们如何影响模块的封装性和代码的可用性。

概述

在OCaml编程中,模块是组织代码的重要方式。Utop作为交互式环境,提供了几种不同的方式来加载和使用模块。理解这些方式的细微差别,对于正确测试和使用模块至关重要。本节将详细解析useloadrequire命令的行为,并通过栈模块的实例进行演示。

use命令:直接导入文件内容

当你在Utop中使用use命令时,其效果等同于将该文件中的所有命令直接键入到Utop环境中。

例如,假设我们有一个文件stack.ml,其中包含了ListStack模块的实现。使用use命令后:

# use "stack.ml";;

此时,文件中的所有定义(如签名STACK_SIG、实现ListImpl、模块ListStack以及示例栈s1s2)都会如同直接在Utop中键入一样,变得可用。

然而,这里有一个关键点需要注意。当我们打印s1的值时,Utop会显示类似<abstr>的内容。这是因为s1的类型在STACK_SIG签名中被定义为抽象类型,并且ListStack模块被该签名密封。因此,OCaml系统知道s1ListStack.t类型,但无法得知其内部的具体表示。

相比之下,s2来自未被密封的ListImpl模块,因此其内部列表表示对Utop是可见的,可以被完整打印出来。

load命令:模拟真实编译环境

在实际的项目开发中,尤其是在完成编程作业时,我们更常使用ocamlbuild工具来编译代码。这与在Utop中直接键入代码有所不同。

为了模拟这种真实的编译和使用场景,我们需要回到之前创建的栈编译单元。假设我们已经通过ocamlbuild编译了stack.mlistack.ml,生成了stack.cmi(编译后的接口文件)和stack.cmo(编译后的模块文件)。

在Utop中,我们不能直接use源文件来获得封装效果。相反,我们需要:

  1. 告诉Utop从哪个目录加载编译后的代码(默认是当前目录,但ocamlbuild通常将文件输出到_build目录)。
  2. 使用load命令加载编译后的模块。

操作过程如下:

(* 首先添加包含编译文件的目录到搜索路径 *)
# #directory "_build";;

(* 然后加载编译后的栈模块 *)
# #load "stack.cmo";;

现在,Stack模块变得可用。但此时,我们必须使用模块名作为前缀来访问其内容,例如Stack.empty。更重要的是,Stack.empty的值现在是抽象的(显示为<abstr>),因为接口文件stack.mli已经密封了模块,对外隐藏了其内部列表表示。

load命令虽然比use更复杂,但它更准确地模拟了你在其他代码库中使用该模块的真实情况:通过接口进行封装,并通过模块名进行访问。

自动化配置:.ocamlinit文件

如果你需要频繁地在Utop中测试某个模块,反复键入#directory#load命令会非常繁琐。一个高效的解决方案是利用Utop的初始化文件。

所有位于.ocamlinit文件中的命令,都会在每次启动Utop时自动运行。因此,你可以将加载模块所需的命令写入这个文件:

(* 在 .ocamlinit 文件中 *)
#directory "_build";;
#load "stack.cmo";;

这样,每次打开Utop,Stack模块就已经准备就绪,可以直接使用。在未来的编程作业中,我们提供的代码包可能会采用这种方式进行预配置。

require命令:加载第三方库

最后,当你需要使用的不是自己编写的模块,也不是标准库中的模块,而是第三方库时,就需要使用require命令。

例如,要加载OUnit测试库,你可以这样做:

# #require "ounit2";;

执行后,OUnit库的所有定义就变得可用了。require命令会处理库的依赖和路径,是引入外部依赖的推荐方式。

总结

本节课我们一起学习了在Utop交互式环境中与模块交互的三种主要方式:

  1. use:用于快速测试,直接将源文件内容导入Utop,但会破坏模块的封装性。
  2. load:用于加载本地编译后的模块(.cmo文件),模拟真实的项目环境,能保持接口定义的封装性。
  3. require:用于便捷地加载和安装第三方库。

理解这些命令的适用场景和区别,能帮助你在开发过程中更有效地编写、测试和使用OCaml模块。记住,在追求正确性高效性优雅性的代码中,恰当地使用模块和接口进行封装是关键的一步。

067:模块包含(Includes)功能 🧩

在本节课中,我们将学习OCaml模块系统的一个强大功能:include。这个功能允许我们复用代码,避免重复编写相同的模块定义,从而使代码更易于维护。

概述

在之前的课程中,我们了解到OCaml为整数和浮点数算术使用了不同的运算符。有时,我们希望创建一个模块来抽象这些操作,使它们看起来一致。include关键字正是实现这一目标的工具。

环(Ring)签名

首先,我们定义一个名为“环”的数学结构的签名。环是一种具有加法和乘法运算的代数结构。

以下是环(ring)签名的定义:

module type ring = sig
  type T
  val zero : T
  val one : T
  val ( + ) : T -> T -> T
  val ( * ) : T -> T -> T
  val ( ~- ) : T -> T
  val to_string : T -> string
end
  • type T:表示底层数学类型的抽象类型。
  • val zero : Tval one : T:表示加法单位元(0)和乘法单位元(1)的抽象值。
  • val ( + )val ( * )val ( ~- ):分别表示加法、乘法和一元取反运算符。
  • val to_string:一个将值转换为字符串以便显示的函數。

实现整数环

接下来,我们根据这个签名实现一个整数环模块。

module IntRingRep : ring = struct
  type T = int
  let zero = 0
  let one = 1
  let ( + ) = Stdlib.( + )
  let ( * ) = Stdlib.( * )
  let ( ~- ) = Stdlib.( ~- )
  let to_string = string_of_int
end
  • 我们将抽象类型 T 具体化为 int
  • zeroone 分别定义为整数 01
  • 运算符直接使用OCaml标准库中对应的整数运算符。
  • to_string 函数使用 string_of_int

现在,我们可以创建一个密封在ring签名下的模块:

module IntRing = (IntRingRep : ring)

通过密封,IntRing模块的内部实现(即使用int类型)被隐藏起来。外部代码只能通过ring签名定义的接口来使用它。例如,IntRing.zero的值是抽象的,但我们可以使用IntRing.to_string IntRing.zero来查看其字符串表示形式。

实现浮点数环

同理,我们可以为浮点数实现一个环模块。

module FloatRingRep : ring = struct
  type T = float
  let zero = 0.
  let one = 1.
  let ( + ) = Stdlib.( +. )
  let ( * ) = Stdlib.( *. )
  let ( ~- ) = Stdlib.( ~-. )
  let to_string = string_of_float
end

module FloatRing = (FloatRingRep : ring)

这个模块与IntRing结构相同,但内部使用浮点数和对应的运算符(如+.*.)。关键优势在于,使用这些模块的客户端代码无需关心底层是int还是float,它们通过统一的接口(+*)进行操作。

引入域(Field)和代码重复问题

环结构只有加法和乘法。域(Field)是一种更丰富的结构,它在环的基础上增加了除法运算。

一种笨拙的实现方法是复制整个环的代码,然后添加除法。以下是不推荐的做法:

(* 糟糕的示例:代码重复 *)
module IntFieldRep : sig
  include ring
  val ( / ) : T -> T -> T
end = struct
  type T = int
  let zero = 0
  let one = 1
  let ( + ) = Stdlib.( + )
  let ( * ) = Stdlib.( * )
  let ( ~- ) = Stdlib.( ~- )
  let to_string = string_of_int
  (* 新增的除法 *)
  let ( / ) = Stdlib.( / )
end

这种方法导致了严重的代码重复。如果未来需要修改环的实现(例如,优化加法运算),我们必须在IntFieldRep中做同样的修改,这非常容易出错且难以维护。

使用 include 避免重复

OCaml的include功能可以优雅地解决这个问题。它允许我们将一个已有模块(或签名)的全部内容包含到当前定义中。

以下是使用include的正确定义域签名和模块的方法:

(* 1. 定义域(field)签名:包含环,并添加除法 *)
module type field = sig
  include ring (* 包含ring签名的所有声明 *)
  val ( / ) : T -> T -> T
end

(* 2. 实现整数域模块 *)
module IntFieldRep : field = struct
  include IntRingRep (* 包含IntRingRep模块的所有定义 *)
  let ( / ) = Stdlib.( / ) (* 只需额外定义除法 *)
end
  • 在签名中,include ring表示field签名包含了ring签名的所有声明(type Tzero(+)等),并额外添加了(/)
  • 在模块实现中,include IntRingRep表示IntFieldRep模块继承了IntRingRep模块中的所有定义(type T = intzero = 0等)。我们只需要补充定义除法操作(/)即可。

现在,对IntRingRep的任何修改都会自动反映到IntFieldRep中,完全消除了代码重复。我们可以用同样的方式轻松定义浮点数域:

module FloatFieldRep : field = struct
  include FloatRingRep
  let ( / ) = Stdlib.( /. )
end

一个重要注意事项:包含未密封的模块

在使用include时,有一个关键细节需要注意:我们通常需要包含未密封的模块实现(如IntRingRep),而不是密封后的模块(如IntRing)。

为什么?因为密封会隐藏类型的具体信息。如果我们尝试包含IntRing

module BadIntFieldRep : field = struct
  include IntRing (* 错误:包含的是已密封的模块 *)
  let ( / ) = Stdlib.( / )
end

编译器会报错。在BadIntFieldRep的实现内部,从IntRing包含进来的类型T是一个抽象类型,编译器不知道它实际上就是int。因此,我们补充定义的除法函数let ( / ) = Stdlib.( / )的类型是int -> int -> int,这与签名要求的T -> T -> T不匹配,导致类型错误。

正确的做法是包含未密封的IntRingRep,这样在定义新模块时,编译器知道T就是int,类型检查就能顺利通过。

总结

本节课我们一起学习了OCaml模块系统中的include功能。

  1. 作用include用于复用已有的模块或签名定义,避免代码重复,提升代码的可维护性。
  2. 使用方法:在签名或模块结构体中使用include ModuleName来引入其全部内容。
  3. 核心场景:在构建层次化的模块抽象(如从ring构建field)时特别有用。
  4. 关键细节:在模块实现中include时,通常需要包含具体的、未密封的实现模块,以确保类型信息得以保留,从而能正确补充新的定义。

通过include,我们可以构建出既清晰又高效的模块化代码结构。

068:Include与Open的区别 🧩

在本节课中,我们将学习OCaml中两个容易混淆的概念:includeopen。我们将通过具体的例子来理解它们之间的核心区别,以及各自的使用场景。

概述

includeopen在初次接触时看起来非常相似,它们确实有一些共同点,但也存在关键差异。本节我们将深入探讨这两个指令,帮助你清晰地理解它们各自的行为。

核心概念解析

为了理解includeopen,我们首先定义三个模块。

以下是模块M的定义,它包含一个值x

module M = struct
  let x = 1
end

接下来,我们定义两个新模块NO,它们分别使用includeopen来引入模块M

模块N使用include M

module N = struct
  include M
  let y = x + 1
end

模块O使用open M

module O = struct
  open M
  let y = x + 1
end

关键区别

现在,让我们来看看includeopen的核心区别。

上一节我们定义了三个模块,本节中我们来看看它们在交互环境(如UTop)中的实际表现。加载这些模块后,我们可以很快发现差异。

模块N最终包含两个值:xy。这是因为include指令将模块M中的所有定义都包含到了模块N的内部。由于M定义了值xN也因此拥有了x

模块O最终只包含一个值:y。这是因为open指令只是将模块M中的名称打开到当前作用域,使其在模块O内部可用,但并不会将这些名称重新导出到外部世界。

以下是两种指令行为的总结:

  • include:导入模块M的定义,使其在本地可用,并且将这些定义导出到外部世界。
  • open:导入模块M的定义,使其在本地可用,但不会将这些定义导出到外部世界。

总结

本节课中我们一起学习了OCaml中includeopen指令的区别。include会将一个模块的全部内容复制并合并到当前模块中,并对外暴露;而open仅是将另一个模块的命名空间在当前作用域内打开,方便内部使用,但不会改变当前模块对外提供的接口。理解这一区别对于构建清晰、模块化的代码结构至关重要。

069:函子(Functors)🚀

在本节课中,我们将要学习OCaml中一个强大的模块级概念——函子(Functors)。函子允许我们编写以模块为输入并返回新模块的“函数”,从而极大地提升了代码的复用性和抽象能力。

概述

之前我们提到,在OCaml中,模块值和普通值是严格区分的,不能混用,因此不能有直接作用于模块的函数。虽然从技术上讲这是正确的,但OCaml提供了一种非常类似函数的功能,称为函子。函子本质上就是一个模块级别的函数,它可以接收一个模块作为输入,并产生一个新的模块作为输出。

函子的基本语法

与OCaml模块系统的其他部分一样,函子的语法与语言中的其他值略有不同。让我们从一个非常简单的例子开始,来理解函子的基本结构。

首先,我们定义一个简单的模块类型和一个模块:

module type X = sig
  val x : int
end

module A : X = struct
  let x = 0
end

现在我们有一个名为 A 的模块,它内部有一个值 x,其类型由模块类型 X 指定。

创建第一个函子

假设我们想创建另一个模块,它与模块 A 完全相同,只是其内部的 x 值要大1。我们可以通过函子来实现这个功能。

以下是函子的定义方式:

module Increment = functor (M : X) -> struct
  let x = M.x + 1
end

让我们来分解一下上面代码的各个部分:

  • module 关键字用于开始一个模块定义,就像 let 关键字用于创建和绑定普通值一样。
  • functor 关键字表明我们正在创建一个函子,一个从模块值到模块值的“函数”。
  • (M : X) 指定了函子的输入:一个名为 M 的局部模块参数,其模块类型必须为 X
  • -> struct ... end 定义了函子返回的模块结构。在这个结构中,我们创建了一个名为 x 的值,并将其绑定为输入模块 Mx 的值加1。

本质上,这是在模块级别上实现了一个类似于“递增”的功能。普通的递增函数接收一个参数 x 并返回 x + 1。而这个函子接收一个包含标识符 x 的模块,并返回另一个包含标识符 x 的模块,只不过输出模块中的 x 值比输入模块中的大1。

应用函子

让我们尝试在交互式环境(utop)中应用这个函子。注意,我们不能直接在utop提示符下应用函子,就像不能直接在那里写匿名结构一样。

我们需要使用模块定义语法将函子应用的结果绑定到一个模块名:

module B = Increment(A)

现在,我们有了一个名为 B 的模块,其内部的 x 值是 Ax 值加1的结果(即1)。我们可以继续这个过程:

module C = Increment(B)

这样,C 中的 x 值就是2。你可以看到,我们可以像调用函数一样将这个函子应用到其他模块上,并得到新的模块作为结果。

函子的语法糖

就像我们学习函数时知道有关键字 fun 的语法糖一样,函子也有其语法糖。

虽然可以使用上面演示的匿名函子形式来编写,但你也可以将输入模块写在等号的左边:

module IncrementSugar (M : X) = struct
  let x = M.x + 1
end

这正好与匿名函数及其语法糖的对应关系相平行。然而,有一个关键区别:对于函子,必须始终指定输入模块的类型。你不能省略 : X。这是为了OCaml类型推断引擎的良好运行,它要求你明确指定函子的输入类型。

模块类型与密封

关于输入模块的类型,有一个重要的细节:我们传递给函子的模块 A 必须具有模块类型 X。但事实证明,只要该模块可以被赋予该模块类型,我们不一定需要将 A 密封(seal)在类型 X 下。

例如,即使我们在定义 A 时省略了模块类型注解,只要其结构符合 X 的要求,下面的代码也能完美编译:

module A = struct
  let x = 0
end

module B = Increment(A) (* 这仍然有效 *)

A 只需要匹配模块类型 X,而不必被密封为该类型。

总结

本节课中我们一起学习了OCaml中的函子。函子的语法是模块定义语法的直接扩展,你只需要在等号左边或右边使用 functor 关键字来编写输入模块及其类型即可。通过函子,我们可以实现高度的代码抽象和复用,构建出更加灵活和强大的模块化程序。

070:标准库Map模块 🗺️

在本节课中,我们将要学习OCaml标准库中的Map模块。Map是一种将键映射到值的数据结构,类似于Java中的TreeMap。我们将了解如何使用函子(Functors)来创建自定义的Map,并探索其核心操作。

概述

Map模块是函子在OCaml中应用的一个绝佳范例。这里的“Map”与Java中的TreeMap概念几乎相同,其核心抽象都是将键映射到值。Java的HashMap基于哈希表实现,而TreeMap则基于平衡二叉树。OCaml的Map模块同样采用平衡二叉树作为后端实现。

创建Map:使用函子

Map模块内部包含一个名为Make的函子,它用于基于输入结构创建一个Map数据结构。这个输入结构必须是一个“有序类型”(Ordered Type)模块。

因此,要创建一个Map,你必须向Make函子传递一个包含以下两部分的模块:

  1. 键的类型。
  2. 键的比较函数。

Map模块需要比较函数的原因在于其平衡二叉树的实现。它需要能够比较树中每个节点上的键。所以,你的任务就是告诉Map模块键的类型以及如何比较它们。

Make函子的输出类型是S。这个输出签名包含了字典(dictionary)中所有常见的函数。

以下是创建Map所需的关键组件:

  • 键类型:例如 type key = ...
  • 比较函数compare : key -> key -> int,该函数在键相等时返回0,第一个键较小时返回严格负数,第二个键较小时返回严格正数

Map的核心操作

Make函子输出的模块提供了字典的标准操作。你可以使用mem函数测试字典中是否存在某个键的绑定,使用add函数添加从键到值的绑定,使用remove函数移除绑定,等等。该模块甚至提供了mapfold函数。

以下是部分核心函数:

  • mem : key -> 'a t -> bool:检查键是否存在于Map中。
  • add : key -> 'a -> 'a t -> 'a t:添加或更新键值对,返回新的Map。
  • find : key -> 'a t -> 'a:查找键对应的值,若键不存在则引发Not_found异常。
  • bindings : 'a t -> (key * 'a) list:返回一个由键值对组成的关联列表。

实践示例:创建星期Map

让我们创建一个以星期几为键的Map。首先,我们定义一个表示星期的类型和一个将星期映射为整数的函数。

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

let day_to_int d = match d with
  | Mon -> 1
  | Tue -> 2
  | Wed -> 3
  | Thu -> 4
  | Fri -> 5
  | Sat -> 6
  | Sun -> 7

现在,假设我想创建一个键为day类型的Map。我需要创建一个模块来指定键类型和比较方法。

module DayOrdered = struct
  type t = day
  let compare d1 d2 = compare (day_to_int d1) (day_to_int d2)
end

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/crnl-cs3110-ocaml/img/098348ad41cf4b736a062c38cb028634_3.png)

module DayMap = Map.Make(DayOrdered)

DayMap现在必须使用day类型作为键,但它允许将这些键映射到我喜欢的任何值类型。例如,我可以将星期几映射到表示它们的字符串。

let m = DayMap.(empty |> add Mon "Monday" |> add Tue "Tuesday")

现在,Map mMonday映射到字符串"Monday",将Tuesday映射到字符串"Tuesday"

使用Map操作

注意,m是抽象的,我们无法直接看到其内部的树形实现。但我们可以使用DayMap模块提供的操作来检查它。

为了方便演示,我们可以打开模块:

open DayMap

let test1 = mem Mon m  (* 返回 true *)
let test2 = mem Sun m  (* 返回 false,因为我们没有放入Sunday *)

let val1 = find Mon m  (* 返回 "Monday" *)
let val2 = find Tue m  (* 返回 "Tuesday" *)
(* let val3 = find Sun m *) (* 会引发 Not_found 异常 *)

find函数的类型是key -> 'a t -> 'a。这里的'a t是什么?在模块顶部,'a t表示从key类型到'a类型的映射类型。键的类型在我们应用函子时就已经固定,因为我们需要确定如何对树节点进行排序比较。但值类型可以是整数、字符串、列表等任何我们想要的类型,这个值类型就是这里的'a

函数式特性与关联列表

最后,这些Map是函数式数据结构,它们不是通过命令式方式更新的。

let m_prime = add Sun "Sunday" m

我向m添加了一个将Sunday绑定到字符串"Sunday"的绑定,并将结果新Map绑定到m_prime。但m本身并未改变,它仍然只包含MondayTuesday的绑定。m_prime则包含新的Sunday绑定。

bindings是Map模块中的另一个函数,它可以给你一个由键值对组成的列表。每个对的第一个元素是键,第二个元素是值。这被称为关联列表。标准库的List模块中也提供了处理关联列表的函数。

你可以在作业中使用Map,它们是函数式的,不受命令式特性禁令的限制。

总结

本节课我们一起学习了OCaml标准库中的Map模块。我们了解到Map是基于平衡二叉树实现的函数式数据结构,需要通过Map.Make函子并提供一个包含键类型和比较函数的模块来创建。我们探索了Map的核心操作,如addfindmembindings,并通过创建星期Map的实践示例巩固了这些概念。记住,Map是纯函数式的,任何更新操作都会返回一个新的Map,而不会改变原有结构。

071:抽象与规范 📚

在本节课中,我们将要学习计算机科学中的一个核心概念:抽象,以及如何通过规范来清晰地定义抽象的行为。


什么是抽象?🤔

有人说,计算机科学是关于抽象的科学,或者说是关于高效抽象的科学。

那么,什么是抽象?作为一个动词,“抽象”意味着遗忘信息。其目的是能够将相关的事物视为相同。也就是说,你忽略掉不重要的细节,专注于重要的、共性的部分。

作为一个名词,抽象指的是这个过程中产生的制品。这些制品可能是函数、模块、类,或者你的编程语言用来组织代码的任何结构。

当我们创建这些函数时,抽象的一个重要环节就是编写规范


规范的作用 📝

规范作为一个名词,指的是抽象的预期行为。我们通过它向其他人传达该行为应该是什么样子。作为一个动词,“指定”就是创建这样一个制品的行为。

规范面向两种不同的受众。我们为这两种受众编写文档,但文档内容并不相同,因为他们的需求不同。

以下是两种受众:

  • 客户端:将要使用规范的人。例如,你可能是一个标准库的客户端,因为你正在阅读标准库的文档。
  • 实现者:将要维护代码或首次编写代码的人。这些人需要理解比客户端可能更底层、更详细的规范内容。

规范的双重角色 ⚖️

对于客户端而言,规范告诉他们必须保证什么(这里我特别以函数为例)。这就是前置条件。客户端必须确保在调用函数时,已经满足了该函数的前置条件。

同时,规范也告诉客户端可以假设什么,即后置条件。他们可以根据文档知道函数的输出是什么。

对于实现者而言,情况则有些相反。

规范告诉实现者可以假设什么作为前置条件。因为客户端必须满足它,所以在函数边界处,实现者可以说:“好的,我确信前置条件成立,因为如果它不成立,客户端就无法调用我。”当然,客户端可能违反,但这不是实现者的错,而是客户端的错。

规范也告诉实现者必须保证什么作为后置条件。现在,确保某些条件成立是实现者的责任,而不是客户端的责任。

因此,在客户端与实现者之间、前置条件与后置条件之间、假设与保证之间,存在着一种二元性


规范带来的好处 ✨

规范为模块化程序带来了许多好处。

它帮助我们实现局部性。当规范向客户端提供足够的信息时,我们可以在不阅读其实现的情况下理解一个抽象。

规范也有助于可修改性。通过明确代码应该做什么,实现者在后续演进代码时,可以更改其实现,而不会破坏客户端代码,因为客户端可以依赖什么是清晰的。

最后,规范提供了责任归属。它们明确了当出现问题时应该归咎于谁。是客户端因为违反了前置条件而受责,还是实现者因为违反了后置条件而受责?


实现与规范的关系 🔗

我们说,如果一个实现提供了所描述的行为,那么它就满足一个规范。

当然,对于一个给定的规范,可能有许多不同的实现能够满足它。

这里再次体现了客户端与实现者之间的二元性。客户端不能对实现者选择了哪个具体实现做出任何特定假设。但实现者最终确实有权选择他们想要的那个。

这并不意味着实现者应该故意刁难或恶意行事。双方都应本着诚信行事,以就规范的含义达成一致。

但歧义可能会出现。让我们从实现者的角度来思考这个问题。

以下是可能产生歧义的一些因素:

  • 客户端可能并未真正理解他们希望你实现什么。
  • 客户端可能确实不在乎,他们留下了一些未指定的部分,因为他们对你提供的任何方案都满意,只要它满足规范的其他部分。
  • 或者,你作为实现者可能并不理解规范中某些部分的含义。

所有这些都可能导致规范中存在感知上或实际上的歧义。


处理歧义规范 💡

那么,当你遇到有歧义的规范时,未来可以遵循哪些策略?

首先,要认识到歧义是生活中的一个事实。但同时,也要本着诚信行事。作为实现者,我们应该尝试做最合理的事情。

诚然,当前置条件被违反时,你可以做任何你想做的事情,比如让电脑着火。但让电脑着火很可能不是最合理的做法。

所以,让我们问:谁写的规范?如果你是编写规范的人,并且发现了歧义,你可以寻求改进它,将规范细化为更清晰的内容。

如果是客户端提供的规范,那么你可以向客户寻求澄清。但对此需要小心一点。请求几次澄清和请求500次澄清是有区别的。如果你做了后者,客户可能不会再雇佣你,因为你无法自己解决低层次的歧义,而只把真正重要的问题抛给他们。

这是一种平衡行为,需要培养一些良好的常识。正如伏尔泰所写:常识并不那么常见。


总结 📋

本节课中,我们一起学习了抽象与规范的核心概念。我们了解到,抽象是通过忽略细节来关注共性,而规范则是定义抽象行为的契约。规范在客户端(使用者)和实现者(编写者)之间建立了清晰的权责边界,通过前置条件和后置条件来划分假设与保证。良好的规范能提升代码的局部性、可修改性和责任明确性。最后,我们还探讨了如何处理规范中可能出现的歧义,强调了沟通与常识的重要性。

072:函数规范详解 🧾

在本节课中,我们将学习如何为OCaml函数编写清晰、完整的规范。函数规范是连接函数实现者与使用者之间的重要契约,它精确描述了函数的行为、前提条件和预期结果。

规范模板的起源 📜

上一节我们介绍了函数规范的重要性,本节中我们来看看一个被广泛采用的规范模板。这个模板源自Barbara Liskov和John Guttag的著作《Program Development in Java: Abstraction, Specification, and Object-Oriented Design》。在OCaml中,我们使用的规范模板包含四个核心部分。

以下是该模板的四个组成部分:

  • 首行摘要:用一行文字概括函数的行为,描述输出与输入之间的关系。
  • 示例:提供如何使用该函数的具体例子。
  • requires 子句:即前置条件,规定了调用函数前输入必须满足的条件。
  • raises 子句:这是后置条件的一部分,说明了函数在何种情况下会抛出异常。

接下来,我们将详细探讨每一个部分。

规范与类型声明 📝

规范通常写在.mli接口文件中,位于函数名称的声明之上。该声明同时提供了函数的类型签名。

(* 规范写在类型声明之上 *)
val function_name : input_type -> output_type

因此,类型信息本身不需要成为规范的一部分,它们作为源代码的一部分被单独书写。

关于Barbara Liskov 👩‍🔬

顺便一提,Barbara Liskov是图灵奖得主,她于2008年因对编程语言和系统设计的实践与理论基础,特别是数据抽象(我们现在正在学习)、容错和分布式计算方面的贡献而获奖。她也是我的“祖师爷”——即我博士导师的博士导师。她更是最早获得计算机科学博士学位的女性之一。

她的一项主要成就是设计了CLU语言。CLU是现代许多面向对象语言的前身,它首创了一些如今广为人知的理念,例如迭代器异常处理。此外,CLU也拥有类型安全特性,甚至早于ML语言家族。

实例解析:List.sort函数 🔍

让我们看一个来自OCaml标准库的规范实例,这是针对List.sort函数的。

(** 根据给定的比较函数对列表进行排序。
    比较函数必须返回一个整数:
    如果第一个参数小于第二个,则返回负数;
    如果相等,则返回0;
    如果大于,则返回正数。
    结果列表按递增顺序排列。
    当前实现以常数堆空间和对数栈空间运行。*)
val sort : ('a -> 'a -> int) -> 'a list -> 'a list

观察这个规范,你可以看到它包含了模板中的所有部分:

  1. 首行摘要:第一行概括了行为——“根据给定的比较函数对列表进行排序”。对于排序这种通用概念,有时无需用复杂的数学语言描述,直接说明其目的更为清晰。
  2. 前置条件:第二行虽然没有以“requires:”开头,但它是一个前置条件。它规定了传递给sort函数的比较函数compare必须满足的行为:必须返回一个整数,其符号表示两个参数的大小关系。
  3. 后置条件:第三行给出了后置条件(可以想象前面有“returns:”)。它承诺“结果列表按递增顺序排列”。
  4. 效率保证:最后一句是规范的另一个部分,关乎函数的效率。它承诺当前的排序实现将在常数堆空间对数栈空间内运行。

因此,效率可以作为函数规范的一部分,虽然不总是必要,但完全可以包含。

总结 📚

本节课中,我们一起学习了如何为OCaml函数编写规范的四个核心部分:首行摘要、使用示例、前置条件(requires)和异常说明(raises)。我们通过List.sort函数的实例分析了这些部分在实际中如何呈现,并了解到规范甚至可以包含对算法效率的承诺。编写良好的规范是构建可靠、可维护软件的关键步骤。

073:函数规范的组成部分 🧩

在本节课中,我们将详细学习函数规范的各个组成部分。我们将从 requires 子句开始,逐步了解如何编写清晰、完整的函数规范。

规范模板的组成部分

上一节我们介绍了函数规范的基本模板,本节中我们来看看模板中每个部分的具体含义和作用。

requires 子句:前置条件

requires 子句定义了函数的前置条件。事实上,如果你愿意,完全可以用 precondition: 来代替 requires:

以下是标准库中 List.hd 函数的一个假设性规范,该函数用于获取列表的第一个元素(如果存在的话)。

我们可以这样写前置条件:要求列表非空。

这个前置条件明确了当列表为空时,责任方是谁——是客户端的错。因为规范明确指出不允许传入空列表。

如果客户端确实传入了空列表,那么实现者可以自由地执行任何操作。

这里有一点需要注意,特别是Python程序员应该留意。

(* 错误的做法:在OCaml中,类型已经是函数声明的一部分,无需在前置条件中重复声明 *)
(* requires: lst is a list *)

这与Python不同,因为两种语言处理类型的方式不同。Python没有静态类型系统,因此你被迫在文档中写明这类前置条件,别无选择。

在OCaml中,类型检查在编译时完成,不可能将错误类型的值传递给函数。这就是为什么在静态类型语言(无论是OCaml还是Java)中,我们不在前置条件中编写这类子句。

关于断言前置条件的常见问题

你或许在CS 1110或其他入门编程课程中学过:是的,你必须总是断言前置条件。然而,正如我们在CS 2110中告诉你的,现在在CS 3110中我再次重申:训练轮已经卸下。

你并不总是必须断言前置条件。这是一个微妙的问题,让我详细解释一下。

在程序正确性的数学理论中,在函数被调用之前,前置条件必须得到满足。如果前置条件被违反,则没有任何保证。如果你想了解更多,可以选修像CS 4110或CS 4160这样的高级编程语言课程。

但数学理论与良好的编程实践之间存在差异。断言前置条件是一种极佳的防御性编程技术。抛开“把电脑点着”的笑话不谈,如果能在前置条件失败的那一刻立即引发一个断言错误,而不是导致更具破坏性的问题,这对所有人都有帮助。

问题在于,并非所有代码都像CS 1110的代码那么简单。在现实中,前置条件有时会变得非常复杂,以至于仅仅检查它们就会使你的代码效率低到无法接受。

例如,检查一个关于数据结构的前置条件可能需要查看该数据结构中包含的每一个值。这样一来,一个本应是常数时间复杂度的操作,因为断言了前置条件,就变成了线性时间复杂度。

因此,我们常常会省略检查前置条件的所有部分。也许你只检查其中计算成本较低的部分,或者只检查那些能在常数时间内完成检查的部分,而不检查其余部分。

又或者,你可以声明:如果客户端违反了前置条件,责任由他们承担。

returns 子句:后置条件

接下来,我们看看 returns 子句。在我们的OCaml代码中,我们通常省略 returns:,而将返回子句作为规范的第一句话来写。但正如我们在 List.sort 中看到的,也有其他处理方式。

以下是一个用于整数列表的排序函数的假设性规范:

returns: a list that contains the same elements as lst, but sorted in ascending order.

这陈述了一个后置条件,它告诉我们如果输出结果不正确,责任方是谁——在这个例子中是实现者。除非,当然,存在前置条件且客户端违反了它(例如,客户端传入了一个错误的比较函数)。但这里没有前置条件,所以责任全在实现者。

examples 子句:示例

规范的另一个组成部分可以是 examples 子句。它提供了输入和输出的示例,以便阅读规范的人在抽象的规范描述不够清晰时,有一个具体的概念可以参考。

这对于澄清边界情况、极端输入等特别有帮助。

对于排序函数,我写了两个示例:一个列表包含重复元素,以展示重复元素会被保留;另一个是空列表,展示空列表排序后仍是空列表。这对于我们人类大脑理解规范非常有帮助,即使规范本身在数学上是精确和清晰的,示例仍然至关重要。

raises 子句:异常

raises 子句告诉我们OCaml函数可能引发的异常。

回到我们为 head 函数假设的规范,我们可以添加一个 raises 子句,说明当客户端违反前置条件时会发生什么:

raises: Failure "hd" if lst is empty.

这正是标准库函数实际所做的。这使得异常实际上成为了后置条件的一部分,因为它陈述了函数的一种行为,并且由实现者来确保该行为确实发生。如果当输入列表为空时,head 未能引发此异常,那就是实现者的错。事实上,客户端可能依赖这种行为,因此实现者必须确保它发生。


本节课中我们一起学习了函数规范的四个核心部分:requires(前置条件)、returns(后置条件)、examples(示例)和 raises(异常)。理解并正确使用这些部分,是编写健壮、可维护且文档清晰的OCaml代码的关键。

074:数据抽象与数据结构 🧱

在本节课中,我们将要学习编程中除函数之外的另一个核心概念:数据抽象。我们将探讨数据抽象的定义、它与数据结构的关系,并通过一个“集合”的实例来具体理解如何在OCaml中实现它们。

编程不仅仅是定义函数,数据抽象同样重要。

这里所说的数据抽象,指的是一组值及其操作的规范。这类似于我们在其他课程中提到的抽象数据类型。例如,我们之前学习过栈。我们知道栈有压入、弹出和查看栈顶等操作。我们知道这些操作的规范应该是什么,但如果我们用一个签名中的抽象类型来隐藏栈的具体值,我们就不知道栈的具体值是什么了。

因此,在OCaml中,一个数据抽象自然地对应于包含抽象类型的签名。

数据结构是使用特定表示方法对数据抽象的实现。例如,我们实现了列表栈。我们使用 'a list 作为表示栈的类型。然后,我们可以利用列表的其他操作来实现这些栈操作本身。

所以,在OCaml中,一个 .ml 文件或一个结构体自然地对应于一个数据结构。

让我们尝试为“集合”构建一个数据抽象和数据结构。


集合数据抽象规范 📋

以下是一个名为 Set 的数据抽象的规范。这里的集合是计算机科学意义上的集合。我为该集合的值定义了一个表示类型 'a t。因此,Set.t 是集合的类型构造器,它由一个类型变量 'a 参数化。这个类型变量 'a 是集合中元素的类型,所以我可以有整数集合、布尔值集合、字符串集合等。

  • empty 表示空集。
  • 我定义了三个操作:sizeaddmem
  • 我为每一个操作都编写了规范。

以下是这些操作的详细说明:

  • size s:返回集合 s 中元素的数量。作为规范的一部分,我特别说明空集的大小为零。
  • add x s:返回一个包含集合 s 中所有元素以及新元素 x 的新集合。
  • mem x s:当且仅当 x 是集合 s 中的元素时返回 true。我选择 mem 这个名字是为了与OCaml标准库中一些使用类似名称的数据抽象保持一致。

add 的类型可以看出,这是一个函数式数据结构。它接收一个旧的集合值,并返回一个新的集合值,而不是破坏性地更新原集合。


总结 ✨

本节课中我们一起学习了数据抽象与数据结构的核心概念。我们明确了数据抽象是一组操作的规范,而数据结构是这种规范的具体实现。在OCaml中,签名定义了数据抽象,而结构体则实现了具体的数据结构。我们以“集合”为例,详细分析了其抽象规范,包括空集、计算大小、添加元素和判断成员关系等操作,并特别指出了函数式数据结构不可变的特性。理解这两者的区别与联系,是设计和实现健壮、模块化程序的关键。

OCaml编程:6.5:使用列表实现集合 📚

在本节课中,我们将学习如何使用列表这种数据结构来实现集合(Set)的抽象数据类型。我们将探讨其表示方法、关键操作的实现,并分析其效率。


上一节我们介绍了集合的抽象概念,本节中我们来看看如何用具体的列表数据结构来实现它。

我们首先需要明确如何用列表来表示一个集合。有多种选择,最简单的一种是直接让列表代表集合。

我在类型上方写了一个注释,来说明列表如何表示集合。这个注释目前还不完美,但正在完善中。列表 [a1; ...; an] 表示集合 {a1, ..., an}。空列表 [] 表示空集 {}

然而,这里尚未说明如果列表包含重复元素该怎么办。毕竟,这是列表抽象和集合抽象之间的一个关键区别:列表可以有重复元素,而集合不应该有重复元素。

让我们记住这一点,继续前进,看看它如何影响我们要实现的操作。


在实现 size(求集合大小)操作时,重复元素的问题变得相关。如果我想计算集合的大小,可以取代表它的列表的长度。但这仅在列表不包含重复元素时才有效。如果列表有重复元素,使用这个实现就会得到错误的答案。

现在,这可能是实现 size 的好方法,也可能不是。让我们暂时采用它,并回到我们对表示类型的文档说明,为其添加一个限制:列表不能包含重复元素。这确保了我的 size 实现是正确的。


让我们再次暂停。我最初将 add(添加元素)操作简单地实现为 cons,即将元素添加到列表的开头。这是一个简单的实现,但它是不正确的。因为如果 x 已经是列表中的一个元素呢?那么我就违反了上面那个“列表必须不包含重复项”的不变量。

因此,sizeadd 这两个实现不能很好地共存。我们只能选择其中之一,而不能两者兼得。目前,让我们保留 size 的实现,并修改 add 的实现以使其正确。

现在,我在添加元素前会检查该元素是否已经是列表的成员。如果是,我就不添加它。这样,我就不会创建任何重复项。如果它还不是列表的元素,那么我就可以使用 cons 添加它。


让我们思考一下这两个操作的效率。

size 的效率如何?它是线性时间 O(n) 的,因为 List.length 需要与列表长度成线性关系的时间。

add 呢?现在,每次我想向列表中添加一个元素时,实际上必须检查整个列表以查看它是否已经存在。这意味着 add 的实现也是线性时间 O(n) 的。

所以,我在这里还没有一个特别高效的集合实现,不过目前我们可以接受。


为了验证我们的实现,我们甚至可以在模块类型标注中放入代码,让 OCaml 检查 ListSet 是否真的匹配 Set 模块的签名。如果确实匹配,我就不会在那里得到任何编译错误。

实际上,相当一部分代码可以被清理和简化。我们不需要实际接收参数并应用函数,我们可以直接声明 sizeList.length,而 memList.mem


本节课中我们一起学习了如何使用列表来实现集合。我们明确了列表表示集合时需保证元素不重复这一关键不变量,并据此实现了 sizeadd 等基本操作。我们认识到,这种简单实现的 sizeadd 操作都是线性时间复杂度 O(n) 的。最后,我们还看到了如何通过直接引用标准库函数来简化代码实现。虽然效率不高,但这种实现清晰地展示了从抽象接口到具体数据结构的映射过程。

076:集合的另一种实现 🧮

在本节课中,我们将探讨集合(Set)数据结构的另一种实现方式,重点关注其操作的效率权衡,并学习如何通过调整不变式(invariant)来优化某些操作。


概述

之前我们实现集合时,要求其底层列表不能包含重复元素。这确保了 size 操作的简单性,但增加了 add 操作的负担。本节我们将尝试另一种设计:允许列表包含重复元素,并分析这对其他操作效率的影响。


重新审视不变式

上一节我们介绍了基于无重复列表的集合实现。本节中,我们来看看如果我们放宽这个限制,允许列表包含重复元素,会发生什么。

显然,如果我们不必检查元素是否已存在于列表中,add 操作可以实现为常数时间。但这违反了之前为表示类型(representation type)设定的不变式。

让我们尝试做出不同的决定,允许列表包含重复元素。


操作的效率权衡

当然,现在 add 操作变成了常数时间。但 size 操作变得不正确了。

由于列表可能包含重复元素,它可能会多算集合中的元素数量,因此我们需要修复这个实现。

为了计算列表的大小,我们必须检查它的每个元素是否已经出现在其尾部(tail)中。这效率并不高。实际上,我们将需要多次遍历尾部。这最终导致 size 操作的实现是二次方时间的,比我们之前线性的实现要差得多。


寻求改进

我们能做得更好吗?如果我们能移除列表中的所有重复元素呢?那样的话,size 操作就又可以简化为取列表长度了。让我们试试这个方案。

我使用了一个名为 sort_uniq 的库函数。这个函数除了对列表排序外,还会同时移除重复元素。该函数的文档说明其运行时间为 O(n log n),这比二次方时间好得多,但仍然不如线性时间。

顺便提一下,我也可以清理一下 add 函数的代码。


添加并集操作

假设我想添加一个 union 操作来计算两个集合的并集。

现在,由于我在签名(signature)中添加了 union,我得到了一个类型检查错误,因为我的模块实际上还没有提供这个操作,所以我需要将它添加到两个模块中。

让我们在这里暂停一下。很自然地会想用列表追加(list append)来实现 union,因为我们可以直接将两个列表拼接在一起,并说这就是结果集合。但这违反了我们在文档中设定的不变式——列表不能包含重复元素。

因此,我们需要做更多的工作来使这个实现正确。sort_uniq 函数在这里可能再次派上用场。


另一种视角的实现

当然,对于这种允许重复的列表集合实现,使用 append 作为 union 操作是完全没问题的,因为重复元素的存在无关紧要,size 操作稍后会为我们处理它们。事实上,我们甚至可以稍微清理一下这段代码。


总结

本节课中,我们一起学习了两种使用列表来实现集合数据抽象的数据结构。这两种结构之间存在一些相似之处和差异,这些差异最明显地体现在我们编写的文档注释中,特别是我们为每种表示形式选择的不变式。

通过比较这两种实现,我们理解了在设计数据结构时,对不变式的选择会直接影响核心操作的效率,需要在不同操作之间进行权衡。

077:抽象函数 🧠

在本节课中,我们将学习数据抽象实现中的两个核心概念:抽象函数表示不变量。我们将探讨它们如何帮助我们清晰地定义数据结构的内部表示与外部抽象视图之间的关系。


抽象函数与表示不变量

在实现集合数据抽象时,我们为每个实现方案都需要回答两个关键问题:

  1. 如何将表示类型解释为数据抽象。
  2. 如何确定表示类型中哪些值是有意义的。

这两个问题有专门的技术术语。第一个称为抽象函数,它告诉我们如何解释表示类型。第二个称为表示不变量,它告诉我们哪些值是有意义的。

回顾我们的代码,可以看到我们已经对这两者进行了文档化。对于“无重复列表集合”,抽象函数是文档的第一部分,说明了如何将列表解释为集合。表示不变量则告诉我们哪些表示值实际上是有意义的:列表不能包含重复项,因此任何包含重复项的列表在此表示中都是无意义的。

对于“允许重复列表集合”,抽象函数同样是文档的第一部分。但回顾那个抽象函数,我并不完全满意,因为它没有很好地处理重复项的概念。它说列表 [a1, ..., an] 表示集合 {a1, ..., an}。那么如果我们有列表 [1, 1, 2, 3] 呢?它表示集合 {1, 1, 2, 3} 吗?这看起来不是一个很好的集合,实际上它可能更像一个“包”。因此,让我们改进这个抽象函数,以消除关于如何解释该表示类型值的任何不明确之处。

现在,我明确了如何处理重复项:列表 [a1, ..., an] 表示集合 {b1, ..., bm},其中列表 [b1, ..., bm][a1, ..., an] 相同,但移除了所有重复项。这是一个更好的抽象函数。

至于表示不变量,当然是列表可能包含重复项。由于在 OCaml 中,列表通常总是可以包含重复项,我们甚至不一定需要记录这个表示不变量。虽然记录它有帮助,但从技术上讲,我们可以直接消除它,并说没有表示不变量。或者完全删除那条注释。但后者可能会让人怀疑我们是否忘记了记录表示不变量,所以也许最好的选择实际上是保留原始的注释,但我们可以澄清它确实没有效果:没有表示不变量。


抽象函数的重要性

抽象函数如此重要的原因之一是,它告诉我们如何从客户端的角度理解数据抽象。

回想一下,从客户端的角度看我们的集合,它们就是集合。客户端不知道底层列表实现的任何信息。因此,在客户端看来,它们只是像集合 {1, 2} 或集合 {7} 这样的东西。但从实现者的角度来看,这些是列表。并且有许多可能的列表可以表示这些集合中的每一个。

例如,集合 {1, 2} 可以由列表 [1, 2] 或列表 [2, 1] 来表示。

因此,这里存在客户端理解的抽象值与实现者理解的具体值之间的差异。抽象函数(即这里的黑色箭头)告诉我们如何将具体的值解释为抽象的值。它们从列表表示映射到集合表示。

当然,当我在这里使用“函数”这个词时,我是在数学意义上使用它,而不是在 OCaml 的意义上。我们还没有编写任何接受列表并返回集合的 OCaml 函数。这是一个先有鸡还是先有蛋的问题:我们实际上还没有一个集合抽象,所以当我们正在编写 list_set_dupeslist_set_no_dupes 时,我们无法编写一个从列表映射到集合的函数。因此,抽象函数主要是存在于我们脑海中的东西。

它是一个多对一函数,你可以在这里看到,因为它可能将多个列表映射到一个集合。它也可以是一个部分函数,也就是说,可能存在具体类型的某些值,它们作为抽象类型的值是没有意义的。这由表示不变量来决定,我们很快就会讲到。


抽象屏障

在客户端的视图和实现者的视图之间存在一个边界,我们称之为抽象屏障。可以把它想象成一条警戒线,上面写着“不要越过犯罪现场”。客户端不能越过这个屏障,他们不能查看数据抽象的内部实现。

当然,我这里是假设性地说的,也许客户端可以获取源代码并查看它。但在他们的思维中,他们不应该考虑具体的值。向客户端保证的只是规范所揭示的内容。除此之外的任何信息都是危险的知识,通常不应被客户端利用。这就是为什么我们在语言中内置了诸如抽象类型和密封之类的机制,以防止这类信息泄露给客户端。换句话说,抽象屏障是封装的一部分。

因此,我们说抽象函数将有效的具体值映射到抽象值,这里的“有效的”一词很重要,因为可能有一些具体值我们无法有意义地映射,就像在我们不允许重复的实现中那样。


如何记录抽象函数

为了记录抽象函数,我们将其写在表示类型上方的注释中,正如你已经看到我所做的那样。在开始实现操作之前,首先编写抽象函数非常重要。我是随着我们的进展逐步制定了一些抽象函数,并与你一起改进了它们,但如果你能第一次就写对,那么当你发现可能搞错了抽象函数的某些方面时,需要回头重新实现某些操作的可能性就越小。

从技术上讲,你不必在前面写 AF:,如果你想更详细,也可以写 Abstraction Function:,有时你可能根本不写 AF,但重要的是这个决定要记录在表示类型旁边。

为什么首先要做这件事?因为这是在实现数据抽象时必须做出的首要决定。它赋予了表示以意义。如果它是一个记录,它还决定了需要哪些字段;如果它是一个变体,则决定了需要哪些构造函数。


总结

本节课中,我们一起学习了数据抽象实现的两个基石:抽象函数表示不变量。抽象函数定义了如何将内部的具体表示映射到外部的抽象概念,而表示不变量则规定了哪些内部状态是合法有效的。理解并明确记录这两者,是构建健壮、清晰且易于维护的数据抽象的关键。我们还探讨了抽象屏障的概念,它强调了封装的重要性,确保客户端代码依赖于接口规范而非内部实现细节。

078:实现抽象函数 🧠

在本节课中,我们将要学习抽象函数的概念,并探讨如何在实践中通过实现字符串表示函数来近似地体现它。我们将以集合数据抽象为例,一步步构建其字符串表示功能。


概述

抽象函数是连接具体数据表示和抽象数学概念之间的桥梁。虽然我们通常不会在代码中显式地实现一个数学意义上的抽象函数,但为其创建字符串表示函数是一个常见的实践。这能帮助开发者理解数据结构的抽象含义。本节我们将为两种集合实现(允许重复列表和去重列表)编写 to_string 函数。

设计字符串表示函数

首先,我们为集合模块定义一个 to_string 函数。其规范暂时不限定字符串的具体格式,只要求它能提供集合的某种可读表示。

val to_string : (’a -> string) -> ’a set -> string

函数接受一个将元素转换为字符串的函数,以及一个集合,最终返回该集合的字符串表示。

重构代码以消除重复

在实现过程中,我们发现两个集合实现都需要一个将列表转换为字符串的辅助函数。为了避免代码重复,我们将其提取为一个独立的函数 list_to_string

此外,对于“允许重复列表”的实现,在进行集合操作前需要先对元素去重。我们也将这个去重逻辑提取为一个独立的辅助函数 dedup

let dedup lst = … (* 去重逻辑 *)

let list_to_string to_str lst = … (* 将列表转换为字符串的逻辑 *)

通过提取 deduplist_to_string,我们简化了主函数的实现,并提高了代码的可维护性。dedup 函数被放在模块外部,以展示其可见性;若想隐藏它,可将其移至独立的编译单元。

实现列表到字符串的转换

现在,我们来具体实现 list_to_string 函数。我们希望输出的字符串格式为 {元素1, 元素2, …}

核心挑战在于,我们需要一个将任意类型 ’a 的元素转换为字符串的函数。因此,我们将这个函数 to_str 作为参数传入。

  1. 首先,使用 List.map 和传入的 to_str 函数,将列表中的每个元素转换为字符串,得到一个字符串列表。
  2. 接着,使用 List.fold 操作将这个字符串列表折叠(连接)成一个单独的字符串,并在元素间插入逗号分隔符。

初步实现后,测试发现字符串开头会多出一个逗号。为了解决这个问题,我们对空列表和非空列表进行特殊处理:空列表返回空集表示;非空列表则单独处理第一个元素,避免在其前面添加逗号。

let list_to_string to_str lst =
  match lst with
  | [] -> “{}”
  | hd::tl ->
      let hd_str = to_str hd in
      let tl_str = List.fold_left (fun acc x -> acc ^ “, “ ^ (to_str x)) “” tl in
      “{“ ^ hd_str ^ tl_str ^ “}”

测试与最终效果

使用 string_of_int 作为元素转换函数进行测试,现在我们的 to_string 函数可以正确输出格式良好的集合字符串,例如 {42, 43}

对于“去重列表”实现,由于 dedup 函数可能对元素进行了排序,输出的字符串中的元素顺序可能与输入不同。但这符合集合的无序特性,且我们的规范并未对输出顺序做出承诺。

抽象函数的其他实现形式

字符串表示只是实现抽象函数思想的一种方式。根据不同的抽象,我们可能有其他实现:

  • 对于映射(Map)抽象,可以编写一个函数将其转换为关联列表。
  • 对于树形结构,甚至可以生成基于底层数据结构的图形化图示。

总结

本节课我们一起学习了如何通过实现 to_string 函数来近似体现抽象函数的概念。我们经历了从设计函数签名、重构代码以消除重复、到具体实现并处理边界条件(如多余的逗号)的完整过程。关键在于,这个字符串函数旨在向开发者揭示数据结构的抽象数学视图,而具体的输出格式可以根据需要灵活定义。

079:表示不变量 🛡️

在本节课中,我们将要学习表示不变量的概念。表示不变量是抽象数据类型实现中的一个关键设计原则,它定义了具体表示类型的哪些值是“合法”的,从而确保抽象类型的正确性。

概述

上一节我们讨论了抽象函数,本节中我们来看看与之紧密相关的表示不变量。它帮助我们区分哪些具体类型的值是有效的抽象值,哪些不是。

具体类型的某些值,作为抽象类型的值是没有意义的。也就是说,抽象函数在应用于这些值时,其结果是无意义的。

在我们禁止重复元素的集合实现中,我们绝不希望该模块的任何操作接收一个包含重复元素的列表。否则,操作会得到错误的结果。例如,我们的 size 函数,如果列表包含重复元素,它将返回一个偏大的数字。

表示不变量的任务,就是识别具体类型的哪些值是允许的,哪些是不允许的。

理解表示不变量

你可以将表示不变量想象成具体值集合中的一条粗红线,它将有意义的具体值与无意义的具体值分隔开来。

  • 有效具体值是那些满足表示不变量的值。
  • 无效具体值是那些不满足表示不变量的值。

因此,表示不变量是区分有效具体值与无效具体值的标准,从而标识出我们实际希望在计算中使用哪些具体值,以及我们希望避免哪些具体值。

如何记录表示不变量

正如我们所见,在文件中表示类型的上方,通过注释来记录表示不变量。

以下是几种常见的写法:

  • RI:
  • invariant:
  • representation invariant: (如果你想更详细)

你使用哪一种并不重要,重要的是你明确地标识了它是什么。

再次强调,这应该是你在实现操作之前首先写下的内容。当然,这是因为这迫使你仔细思考表示类型的设计,并从一开始就确保其正确性。当然,有时你可能最终需要重新审视它。

表示不变量与操作契约

表示不变量隐含地构成了抽象中每个前置条件和后置条件的一部分。

因此,ListSetNoDups 中的每个操作都隐含地具有这个表示不变量:列表不能包含重复元素,这既是其前置条件的一部分,也是其后置条件的一部分。

不允许任何人传入一个包含重复元素的值,并且此数据结构的任何操作也绝不允许返回一个包含重复元素的列表。

这个不变量对客户端是隐藏的,我们不会在面向客户端的地方记录它。例如,我们不会在模块类型 SET 中写下它。😡

隐含地,如果客户端是程序员,他们应该注意到,如果存在表示不变量,它是在那个抽象屏障之后被维护的。😡

表示不变量的暂时违反

与循环不变量类似,表示不变量是在某些地方成立,但在其他地方不一定成立的东西。

在一个操作的执行体内部,作为朝着完成该操作目标前进过程的一部分,表示不变量可能会被暂时违反,但可能尚未完全达到目标。😡

这就像在循环体中,循环不变量可能暂时不成立,但最终在循环体结束时会被恢复。

我们可以从具体操作的输入和输出来思考这个问题:有一个具体输入和一个具体输出。我们保证表示不变量对于具体输入成立,并且对于具体输出也成立。这就是表示不变量作为前置条件和后置条件的本质。

但是,在该具体操作的执行过程中,表示不变量可能会被暂时违反。

一个例子:union 操作

一个很好的例子出现在我们 ListSetNoDupsunion 操作的实现里。

这里的表示不变量规定列表不能包含重复元素。但是,当我们执行 s1 @ s2 时,我们确实暂时构造了一个可能包含重复元素的列表。

不过,我们在从函数返回之前,通过去重操作恢复了那个不变量。

总结

本节课中我们一起学习了表示不变量。我们了解到,表示不变量定义了具体实现中哪些值是“合法”的,它是确保抽象数据类型内部一致性和正确性的关键。它像一份内部契约,所有操作都必须维护它(至少在输入和输出时),尽管在操作执行过程中可能会暂时违反它。正确地定义和维护表示不变量,是构建健壮、可靠的抽象数据类型的基础。

080:实现表示不变式

在本节课中,我们将学习一种称为“表示不变式”的防御性编程技术。具体来说,我们将探讨如何编写一个专门的函数来检查数据结构是否满足其内部表示所要求的约束条件,并在代码的关键位置调用此函数,以确保程序的正确性。


你是否曾经实现过表示不变式?实际上,这是一种非常有用的技术。

你可以编写一个函数,我们称之为 rep_ok,用于检查表示不变式是否成立。

然后,在每一个函数的输入处调用该函数,以测试输入值是否满足表示不变式。

同样,在函数的输出处也调用该函数,以检查输出的值是否满足表示不变式。

该函数会检查表示不变式,如果成立,则直接返回该类型的值,本质上只是将值“传递”过去。否则,函数会失败,表明存在违反表示不变式的情况。

这是防御性编程的另一个例子。可以将其视为对函数输入和输出属性的检查。


实现 rep_ok 函数

上一节我们介绍了表示不变式的概念,本节中我们来看看如何为“无重复元素的列表集合”这一具体实现来编写 rep_ok 函数。

首先,我实现了 rep_ok 函数。它的类型是 'a list -> 'a list,因此它本质上应该是具体类型值上的恒等函数,只有一个例外——字面意义上的异常:如果表示不变式不成立,则引发失败。

以下是 rep_ok 函数的一种实现方式:

let rep_ok lst =
  let deduped = List.sort_uniq compare lst in
  if List.length lst = List.length deduped then
    lst
  else
    failwith "Representation invariant violated: list contains duplicates"

通过计算列表长度,并与去重后的列表长度进行比较来检查表示不变式,这是一个昂贵的操作。以这种方式检查表示不变式实际上会增加此数据结构所有操作的时间复杂度。

我们暂时先不担心性能问题,稍后会再讨论。


在操作中集成检查

现在,让我们继续添加所有必要的 rep_ok 调用,以检查具体类型值的输入和输出是否满足表示不变式。

我已经添加了所有对 rep_ok 的调用。

empty 操作中,我检查 rep_ok 是否对输出值成立。

sizemem 操作中,我检查 rep_ok 是否对输入值成立。

add 操作中,我必须检查 rep_ok 是否同时对输入和输出成立。我检查它是否对输入集合 s 成立,以及是否对将元素 x 添加到 s 后构造的新集合输出成立。

union 操作中,我必须检查 rep_ok 是否对输入的两个集合 s1s2 都成立,然后检查它是否也对输出集合成立。

最后,对于 to_string 操作,我检查 rep_ok 是否对输入集合成立。


关于成本的讨论

这是一种昂贵的编码方式吗?是的。我们增加了更多字符、更多输入工作、更多需要思考的细节,并且每个操作的实现都变得更复杂。正如我们为集合实现的,检查表示不变式是昂贵的。在生产代码中,你可能不希望这样做。

但并非所有数据结构的实现都像我们的列表集合这样。有时,表示不变式中存在可以廉价检查的部分,可能只需要常数时间。

或者,作为一种通用做法,你可能不希望一直检查表示不变式,但你希望代码中包含可以随时开启或关闭的检查机制。


可切换的检查机制

让我解释一下我的意思。假设我之前已经实现了所有功能,但在 rep_ok 函数中,我注释掉了所有检查代码,并将其替换为:

let rep_ok lst = lst

现在,它完全就是一个恒等函数。所有代码都将正常工作,没有任何性能损失,编译器很可能会优化掉所有对 rep_ok 的调用。

但是,如果我遇到错误需要快速定位问题,我可以恢复 rep_ok 的完整实现。是的,检查很昂贵,但没关系,我正在尝试查找错误,我愿意暂时承受这个代价。

以这种方式预先编写代码的优势在于,当你在生产环境中需要进行紧急修复时,可以立即启用检查,而不必暂停工作,再去到处添加 rep_ok 调用。

因此,首先将这些检查作为实现的一部分构建进去,这将成为一种非常强大的防御性编程形式,你可以在以后需要时随时启用它。


本节课中我们一起学习了如何实现和集成表示不变式检查。我们看到了如何编写 rep_ok 函数,如何将其插入到数据结构的各个操作中,以及如何通过一个简单的恒等函数实现来灵活地开启或关闭这项昂贵的检查,从而在开发调试和生产运行之间取得平衡。这是一种提升代码健壮性和可调试性的有效技术。

081:抽象函数与交换图 🧩

在本节课中,我们将学习抽象函数的另一个重要益处:它为我们提供了一种思考数据抽象操作正确性的绝佳方式。我们将通过“交换图”这一数学概念来形象化地理解这一点。

交换图:两种路径,同一终点

上一节我们介绍了抽象函数如何连接具体值与抽象值。本节中,我们来看看如何利用它来验证操作的正确性

在数学中,有一种称为“交换图”的工具。它以一种巧妙的方式说明,存在两条路径可以到达同一个终点。

想象你从这张图的左下角开始。你有一个具体值

你想对这个值进行一个操作。

思考这个问题有两种方式:

  1. 你可以考虑执行已实现的具体操作
  2. 或者,你可以考虑使用抽象函数将那个具体值转换为抽象值,然后使用该操作的抽象版本

一个集合的实例

为了更清晰地理解,让我们看一个例子。假设我们正在处理用列表表示的集合,并且允许列表中有重复元素。

  • 具体值 1:列表 [1, 2]
    • 应用抽象函数后,我们将其视为集合 {1, 2}
  • 具体值 2:列表 [2, 3](允许重复,所以 [1, 2, 2, 3] 也是有效的具体值)
    • 应用抽象函数后,我们得到集合 {2, 3}

现在,考虑一个操作:求并集。让我们将集合 {1, 2}{2, 3} 取并集。

  • 抽象操作{1, 2} ∪ {2, 3} = {1, 2, 3}
  • 具体操作(对于列表实现):这可能是追加操作。将列表 [2, 3] 追加到列表 [1, 2] 后,我们得到 [1, 2, 2, 3]

注意当我们完成这个图时发生了什么。

我可以选择图中的两条路径之一:

  1. 先应用抽象函数,再进行抽象操作(AF 然后 抽象Op)。
  2. 先进行具体操作,再应用抽象函数(具体Op 然后 AF)。

无论选择哪条路径,最终都到达了同一个点:抽象值 {1, 2, 3}(对应具体值 [1, 2, 2, 3] 经过抽象函数后的结果)。我们说这个图是交换的。

操作正确性的定义

这引出了数据抽象操作正确性的一个核心概念。

以下是判断操作实现是否正确的标准:

一个数据抽象操作的实现是正确的,当且仅当抽象函数与该操作可交换

用公式表示,对于任何具体值 c 和操作 op

AF(concrete_op(c)) == abstract_op(AF(c))

或者用路径描述:

  • 路径 A:对具体值 c 应用抽象函数 AF,然后应用抽象操作 abstract_op
  • 路径 B:对具体值 c 应用具体操作 concrete_op,然后应用抽象函数 AF

如果对于所有合法的输入,路径 A 和路径 B 产生的结果都相同,那么具体操作的实现就是正确的。

注意抽象函数是如何与操作“交换”的——它可以出现在操作的任意一侧。当抽象函数以这种方式可交换时,具体操作的实现就是正确的。

总结

本节课中,我们一起学习了如何利用抽象函数交换图来形式化地定义和验证数据抽象操作的正确性。我们了解到,一个操作实现正确的关键在于,无论我们是先抽象再计算(在抽象世界),还是先计算再抽象(在具体世界),最终得到的抽象结果都应该是一致的。这种方法为模块化设计和代码验证提供了强大而清晰的理论工具。

082:测试与验证 🧪

在本节课中,我们将要学习软件工程中一个至关重要的环节:验证,特别是其中的测试方法。我们将探讨验证的范畴、测试与形式化验证的区别,以及如何正确看待测试的目的。

验证的范畴

上一节我们介绍了验证是确保程序行为符合预期的过程。本节中我们来看看实现验证的多种方法。

验证包含一系列方法,它们的形式化程度各不相同。以下是一些主要的验证方法:

  • 代码审查:开发者之间互相检查代码。
  • 结对编程:两位开发者共同在一台计算机上编写代码。
  • 测试:运行程序以检查其行为。
  • 模型检查:系统地探索程序所有可能的状态。
  • 形式化验证:使用数学方法证明程序的正确性。

这个列表从上到下,方法的形式化程度逐渐增加,发现或消除问题的确定性也逐渐增强。形式化程度较低的方法(如代码审查)可能会遗漏一些问题,但也能发现另一些问题。形式化程度较高的方法(如形式化验证)则能以更高的确定性消除尽可能多的问题。

需要明确的是,并非谱系的一端绝对优于另一端。在实际的软件开发中,应根据项目情况综合运用多种方法。即使是形式化验证也可能存在漏洞,例如,即使数学上证明了程序的正确性,仍需非正式地审视“我证明的是正确的事情吗?”这个问题。

测试 vs. 形式化验证

了解了验证的多种途径后,我们重点对比其中两种核心方法:测试与形式化验证。

测试是大家从编程课中已经熟悉的一种性价比很高的验证方法。程序员为函数编写单元测试、将其加入自动化测试套件并频繁运行,这些成本相对较低。

然而,测试只能保证程序在已测试的输入已测试的环境下是正确的。它无法对未测试的输入或其他运行环境(如不同的操作系统、云端与本地的差异)做出任何保证。用公式可以这样理解测试的局限性:

程序通过所有测试 ≠ 程序在所有情况下都正确

相比之下,形式化验证的成本非常高昂。它要求人员接受高级数学和计算机科学技术培训,并且通常需要使用专门的验证工具。

其优势在于,它能够保证程序在所有输入所有环境下都是正确的。因此,形式化验证传统上用于高风险的软件开发,例如航天飞机、放射治疗仪或飞机自动导航系统,在这些领域,可靠性和安全性至关重要,值得投入巨大成本来确保绝对正确。

在本课程中,我们将主要学习测试,并在后续课程中简要了解形式化验证。

测试的目的:发现错误

现在,让我们深入探讨测试的本质目的。对此有深刻见解的是埃德斯加·迪杰斯特拉(Edsger Dijkstra),他以迪杰斯特拉算法而闻名,并在1972年因“雄辩地坚持并实际证明了程序应被正确构建,而非仅靠调试来修正”而获得图灵奖。

他有一句名言:

“程序测试充其量只能证明错误的存在,而永远无法证明其不存在。”

“证明错误的存在”是理解测试过程的重要视角。测试的目的不是为了证明你的程序是正确的,而是为了找出它不正确的地方。你应该编写那些能发现程序中错误的测试用例,以便修复它们。

但是,仅仅因为你的整个测试套件都通过了,并不意味着你的程序中就没有错误了。事实上,可能仍然有一些错误潜伏着,等待被发现。

总结

本节课中我们一起学习了软件验证的广阔图景。我们了解到验证包含从非正式的代码审查到高度形式化的数学证明等一系列方法。我们重点对比了测试形式化验证,认识到测试是一种高性价比但覆盖有限的方法,而形式化验证能提供全面保证但成本极高。最后,我们明确了测试的核心目的是发现错误,而非证明无误。理解这些概念将帮助我们更有效地运用测试来提升代码质量。

083:软件缺陷的术语与概念 🔍

在本节课中,我们将学习软件工程中关于“缺陷”的术语,理解“错误”、“故障”和“失效”之间的区别与联系,并探讨其背后的因果关系。

概述

“缺陷”这个术语在历史上是一个不太准确的表述。它可能暗示着有什么东西“溜进”了程序。例如,格蕾丝·霍珀发现的第一个计算机“Bug”,是一只飞入继电器并被卡住的真实飞蛾。这发生在计算机还是房间大小的时代。

但事实是,缺陷并非自行“溜进”我们的程序,而是我们将其置于其中。因此,我们需要一套更准确的术语来描述相关问题。

核心概念:错误、故障与失效

上一节我们提到了“缺陷”一词的历史渊源,本节中我们来看看更精确的术语体系。这套术语来源于ITEE标准729。

以下是三个核心概念的定义:

  • 错误:软件中人为失误的结果。最常见的例子是编码错误。其他可能的情况包括:实现与软件系统设计不匹配,或者设计与用户对软件系统的需求不匹配。
  • 故障:错误在代码中的具体体现。故障可能显现,也可能保持潜伏和隐藏状态,永远不会被最终用户发现。例如,某行代码存在故障,但从未被执行到,因此该故障未被触发。
  • 失效:对需求的违反。即最终用户所期望的系统行为方式未能实现。当失效发生时,最终用户会察觉到出了问题。

事件链与总结

请注意这里的事件链:人为错误 导致了 代码故障,而故障最终可能引发 系统失效。这种看待软件系统问题的方式,比笼统地使用“Bug”一词更为精确。

尽管如此,“Bug”已成为一个广泛确立的术语,我们可能仍会继续使用它。但了解这些更精确的术语仍然很有益处。

在本节课中,我们一起学习了软件缺陷的精确术语体系,明确了“错误”、“故障”和“失效”的定义及其因果关系,这有助于我们更清晰地思考和讨论软件开发中的问题。

084:测试方法 🧪

在本节课中,我们将要学习软件测试的不同方法。测试的目标是发现系统中存在的缺陷(即人为错误),以便修复它们,从而防止它们导致系统故障。

测试的目标

测试的核心目标是暴露系统中存在的缺陷。这些缺陷是人为引入的错误。通过测试发现它们,我们就能进行修复,防止它们演变为实际的系统故障。

测试的类型

有多种方法可以进行测试。以下是几种主要的测试类型:

上一节我们介绍了测试的总体目标,本节中我们来看看具体的测试类型。

以下是几种主要的测试类型:

  • 单元测试:为程序的单元编写小型测试。在OCaml中,我们通常在函数级别进行单元测试。
  • 集成测试:测试各个单元如何协同工作。例如,两个类或两个OCaml模块是否能正确交互。这比单元测试更复杂。
  • 系统测试:在更高层次上测试整个软件系统,验证其所有组成部分是否能提供正确的功能和足够的性能。
  • 验收测试:确定用户是否认为系统满足了他们的需求。
  • 安装测试:测试系统是否能在其需要运行的环境中成功安装。

在本课程中,我们主要关注单元测试。如果你将来在其他大学攻读软件工程硕士学位,你会发现有专门的课程来深入探讨测试,这是一个丰富而深奥的领域。

回归测试

回归测试是测试的另一个重要组成部分。

“回归”在这里指的是一个先前已修复的缺陷,由于某种原因被重新引入了代码中。换句话说,你今天修复了一个错误,但第二天这个错误又出现了,可能是因为代码合并出错,或者其他程序员没有意识到这个修复而被覆盖了。

所以,回归测试是针对软件新版本运行测试的过程,以确保没有发生回归。

目前,实现回归测试的最佳方法是:任何时候你编写了一个能暴露缺陷的单元测试,就把这个测试放入测试套件中,并自动化其运行过程。在本课程中,你们已经在使用OUnit测试套件或多或少地实践这一点。你把一个测试放入套件,它现在和将来都会运行。

这就是为什么将所有测试都放入套件中非常重要,而不是仅仅在头脑中发现错误并在代码中修复它们。当你将从头脑中获得的知识转化为测试套件中的测试时,你就能保证在未来如果你再次犯错,你会立即发现,而不是让缺陷潜伏在系统中。

一个有趣的事实

你认为未检测到缺陷的概率目前已检测到的缺陷数量之间是什么关系?

假设一个系统中已经发现了许多缺陷。你认为这使得系统中仍然存在待发现缺陷的可能性是更大还是更小?

认为可能性更小的理由是:系统中的缺陷总数是有限的,如果我已经发现了大部分,那么任务可能就快完成了,剩下的工作可能很少。

但事实并非如此。实际上,根据20世纪70年代末和21世纪初重复进行的研究,未检测到缺陷的概率会随着已检测到缺陷数量的增加而增加。也就是说,一个系统迄今为止发现的错误越多,该系统可能仍然存在的错误也越多。

造成这种现象的根本原因尚不完全清楚。一种可能性是,编写有缺陷代码的程序员会持续编写有缺陷的代码。因此,如果你已经发现了许多缺陷,以后很可能还会发现更多。这可能不仅仅是因为他们编写了有缺陷的代码,也可能是因为需求定义不清、程序员理解有误,或者在我们修复错误时,倾向于在修复一个错误的同时引入新的错误。

所有这些潜在现象都可以解释这个统计事实:如果你已经发现了一些错误,那么总会有更多的错误等待被发现

这个故事的寓意是:首先编写正确的代码,不要先编写有缺陷的代码然后再去修复。

何时停止测试?

那么,你应该在什么时候停止测试呢?以下是一些不好的答案:

上一节我们了解了缺陷的统计规律,本节中我们来看看何时应该停止测试。

以下是一些不好的停止测试的理由:

  • 时间到了:你只有有限的时间,一小时后就必须停止。
  • 所有测试都通过了:不,这只是意味着你还没有编写足够的测试,仍有更多测试需要编写。
  • 课程评分系统说你可以停止了:例如,当CS3110的测试评分系统说你已经获得了所有分数时。测试不是为了分数,而是为了确保质量。

那么,什么是好的答案呢?

以下是一些好的停止测试的理由:

  • 你遵循的测试方法论已经完成:我们很快会讨论两种不同类型的测试方法来了解更多。
  • (未来的目标)基于统计估计:我们希望未来软件工程能有足够的研究,使我们能够进行统计估计。我们想知道未检测到缺陷的概率确实足够低。虽然我们目前还不知道如何做到这一点,但人们正在努力研究。也许20年后我再次教授这门课程时,就能引用你们中某位的研究成果,因为你们已经为我们解决了这个问题。


本节课中我们一起学习了软件测试的多种方法,包括单元测试、集成测试、系统测试等不同类型,并重点探讨了回归测试的重要性及其最佳实践。我们还了解了一个关于缺陷概率的统计事实,并讨论了何时应该停止测试。记住,测试的目标是编写正确、可靠的代码,而不仅仅是完成一项任务。

OCaml编程:6.15:黑盒测试与白盒测试 🧪

在本节课中,我们将学习软件测试中两种核心的方法论:黑盒测试与白盒测试。理解它们的区别对于设计全面有效的测试至关重要。


概述

测试方法中最重要的区别之一,就是黑盒测试与白盒测试。

黑盒测试

在黑盒测试中,编写测试的人员对被测试功能的内部实现一无所知。这就像系统是一个不透明的黑盒,无法看透。输入进入,输出产生。在进行黑盒测试时,我们完全不知道输入是如何被转换为输出的。

白盒测试

白盒测试则与之相反,有时也被称为透明盒测试。在白盒测试中,测试人员确实了解被测试功能的内部实现。区分这两种测试的一种方式可能是:在黑盒测试中,测试者无法访问源代码;而在白盒测试中,测试者可以访问。

因此,在白盒测试中,测试者实际上知道输入是如何被转换为输出的。

举例说明

为了更直观地理解,让我们来看一个例子。

上一节我们介绍了两种测试的基本概念,本节中我们来看看一个具体的比喻。

假设你正在尝试对BB8进行单元测试。

以下是两种测试方法下的不同视角:

  • 使用黑盒测试:你完全不知道BB8的内部是如何工作的。
  • 使用白盒测试:你或许能看到球内部有一只猫在跑来跑去。

总结

本节课中我们一起学习了黑盒测试与白盒测试的核心区别。黑盒测试关注外部行为,不关心内部实现;而白盒测试则利用对内部结构的了解来设计测试用例。在实际测试中,结合使用这两种方法往往能取得最佳效果。

086:黑盒测试 🧪

在本节课中,我们将学习黑盒测试的概念及其主要方法。黑盒测试是一种仅依据函数规范来编写测试用例的方法,不依赖于具体的实现代码。

概述

黑盒测试的编写完全基于函数的规格说明。这种方式具有多个优点。由于测试者只查看规范,而不查看实现,因此他们不会因为阅读了实现代码而产生任何先入为主的假设。由于实现对他们来说是未知的,他们不会受其影响。这意味着创建的测试对于实现的变化具有鲁棒性,因为这些测试并非基于对实现的任何了解。此外,任何评审者或质量保证团队的成员都可以阅读和评估这些测试,他们只需判断这些测试对于该规范是否良好,而无需额外查看实现代码,因为测试本身并非基于实现。

黑盒测试的主要类型

黑盒测试主要有四种类型。规范中提供的示例输入,我们之前讨论过如何记录这些,它们应该始终成为黑盒测试的一部分。另外三种类型,我们现在将逐一详细讨论,它们是:典型输入、边界情况以及规范路径。

典型输入

典型输入是指一个类型中常见、简单的值。以下是针对不同数据类型的典型输入示例:

  • 整数:测试一个接收 int 作为输入的函数时,使用一个在1到10范围内的小整数。
  • 字符:使用简单的字母或数字。
  • 字符串:一个典型值可能是长度较小(即短字符串)且所有字符本身都是典型的字符串。
  • 列表:与字符串类似,可以测试一个元素数量较少,且每个元素都是其类型典型值的列表。
  • 记录和元组:每个字段或组件都应具有典型值。
  • 变体:测试任何典型的构造函数(如果这对于我们讨论的变体有意义的话)。

以上都是类型的常见简单值,如果你正在对函数的规范进行黑盒测试,可以使用它们。

边界情况

边界情况指的是像这条推文中所展示的值(这是我最喜欢的推文之一):“一个质量工程师走进酒吧,点了一杯啤酒,点了零杯啤酒,点了9999杯啤酒,点了一只蜥蜴,点了负一杯啤酒,点了一个盘子。这就是良好的边界值测试的样子。测试那些看起来可能有点荒谬的东西。”边界情况是指那些非典型或极端的类型值,以及附近的值,所以有时这些也被称为角落情况或边缘情况。以下是一些边界情况的例子:

  • 整数:一些好的边界情况是零以及它附近的一些值,如 1-1。最小值和最大值也是很好的边界用例。
  • 字符:一个非典型字符可能是零字节字符或全为1的字符,也许是空格字符或删除字符,这些字符确实可能扰乱某些系统。
  • 字符串:可以测试空字符串、单字符字符串,或者一个不合理的长字符串。
  • 列表:一些边界值可能是空列表、单元素列表,或者元素数量足以在非尾递归函数上触发栈溢出的列表。顺便说一下,无论是OCaml还是面向对象语言,程序员都可能编写会导致栈溢出的函数。
  • 记录和元组:可以查看非典型值的组合。
  • 变体:可以查看其所有构造函数,看看是否有符合边界条件的。

现在,你马上会注意到,1 是一个小整数,那么它既是边界情况又是典型情况吗?是的。我并不是说这三种不同类型的黑盒测试是正交的,它们不是。这些只是生成良好黑盒测试的好方法,你最终可能会得到一些属于多个类别的测试,这没关系。

规范路径

第三种黑盒测试是规范路径测试。这可能有点不太熟悉。我所说的规范路径实际上可以有几种不同的形式,其中一种形式是针对输出类别的代表性输入

这里有一个函数 is_prime 的规范:is_prime n 为真,当且仅当 n 是质数。现在,你可能会想到各种值来测试这个函数。但这个函数只有两类输出:truefalse。因此,触发 true 输出的一个代表性输入将是一个质数,例如 13。触发 false 输出的一个代表性输入将是一个非质数,例如 42。所以这是两个很好的黑盒测试,因为它们都旨在产生不同类别的输出。

其他例子包括列表标准库中的 compare 函数,它有三类输出。因此,如果你正在测试一个 compare 函数,可以设计三个不同的黑盒测试来测试该规范的路径。任何返回变体类型值的函数都将有多个输出类别,因此可以生成触发每个可能构造函数的黑盒测试。

我们可以用黑盒方式测试的另一种规范路径是满足前提条件的所有不同方式。当然,可能有许多不同的输入满足一个前提条件。让我们看看这个规范,它是一个平方根函数:它计算 x 的平方根,精度为 n 位有效数字。这里的前提条件是 x >= 0n >= 1。同样,我们可以向它传递许多不同的输入。但实际上有四种代表性的方式来满足这个前提条件,这与“大于或等于”有关。要么 x 等于 0,要么大于 0;要么 n 等于 1,要么大于 1。因此,我们可以为满足前提条件的每种方式编写四个不同的测试用例。这为我们提供了一组通过该前提条件及其所有满足方式的黑盒测试。

测试规范路径的另一种方式与异常有关。你可以为每种引发或不引发异常的方式创建代表性输入的黑盒测试。这里有一个函数 pos 的规范:这是列表中第一个等于 x 的元素的从零开始的位置,如果 x 不在列表中,则引发 Not_found 异常。同样,你可以想象在这里测试许多不同的输入。但是,对于引发或不引发 Not_found 异常的代表性方式,我们可以只创建两个黑盒测试:一个用于当 X 将在列表中找到时,一个用于当它找不到时。请记住,通过在此处声明 raises 子句,规范制定者正在承诺此函数应如何行为——如果 x 不在列表中,它必须引发异常,这是后置条件的一部分。

最后,测试数据抽象会引出另一种发明黑盒测试的方法。如果你考虑数据抽象中的操作,有些函数会产生该类型的值。回想一下我们的集合抽象,empty 产生一个集合值。其他一些函数则消费该抽象的值。例如,sizemem 接收集合作为输入,但它们不产生集合作为输出。然后还有一些函数两者都做,它们既产生又消费该抽象的值。add 函数就是这样一个例子,它接收一个集合并返回一个集合。如果我们添加一个 remove 函数,它也会做同样的事情。因此,在为数据抽象发明黑盒测试时,可以测试生产者和消费者所有可能的交互,类似于取它们的笛卡尔积,并为该积中的每个元素创建一个黑盒测试。例如,你可以测试空集的大小为零,以及向空集插入元素后的大小为一。这测试了消费者 size 应用于两个不同的生产者 emptyinsert

总结

本节课我们一起学习了黑盒测试的核心思想及其四种主要方法:利用规范示例、选择典型输入、设计边界情况以及探索规范路径。通过仅依赖函数规范来设计测试,我们可以创建出不受实现细节影响、更具鲁棒性的测试用例,这对于保证代码质量和应对未来变更至关重要。

087:白盒测试 🧪

在本节课中,我们将要学习白盒测试。这是一种测试方法,测试者可以看到被测试功能的内部实现。我们将探讨其优势、核心的覆盖率概念,并通过具体例子来理解如何应用。

概述

白盒测试允许测试者查看被测试功能的内部实现。与黑盒测试相比,这具有一些优势。测试者可以判断一个新的测试用例是否真的能提供关于实现正确性的额外信息,或者它是否只是与已设计的其他测试用例冗余。当查看源代码时,白盒测试还能处理那些从规格说明中不明显但可能出现的错误。例如,你可能意识到实现者正在使用一个他们可能误用的函数,因此可以据此设计一些测试。

然而,白盒测试是对黑盒测试的补充,并不能替代对规格说明的检查。如果可能,两种方法都应该使用。

白盒测试的目标与挑战

在白盒测试中,我们的目标是用测试用例覆盖整个程序。即确保测试能够执行到源代码的所有部分。

使这一目标具有挑战性的是分支。分支是任何允许你条件性地执行一段代码而非另一段代码的语言结构。if表达式、match表达式、布尔运算符、抛出异常、循环或递归函数,所有这些都涉及分支,使得实现良好的覆盖率变得困难。

覆盖率的类型

覆盖率的精确定义是灵活的。以下是三种主要类型:

  • 语句覆盖:这个名称是在命令式语言的背景下发明的。我们的目标是确保程序中的每一条语句至少被执行一次。在函数式语言的语境中,这意味着拥有足够的测试,以确保程序中的每一个表达式至少被求值一次。我们继续称之为语句覆盖。
  • 条件覆盖:我们试图通过使程序中的每一个布尔表达式(或在函数式语言中,每一个模式匹配)求值为每一种可能的值,来获得更细粒度的程序覆盖。仅仅确保if语句的条件至少被求值一次是不够的。你至少需要两个测试:一个使条件求值为true,另一个使其求值为false。对于模式匹配,你需要确保有足够的测试来最终执行该模式匹配的每一个可能分支。
  • 路径覆盖:这是粒度更细的覆盖。在路径覆盖中,你希望让程序中每一条可能的执行路径都出现。仅仅让每个可能的分支至少被求值一次是不够的。对于其中的每一个子分支,你也希望探索通过它的所有路径。因此,路径覆盖相当难以实现。

具体示例分析

让我们通过一个求三个数最大值的具体例子来使其更具体。

函数 max_of_three x y z 返回这三个数中的最大值。你可以使用我们已经探讨过的方法为此编写各种黑盒测试,这也是一个好做法。

但如果你查看这里的特定实现,你会发现这个函数实际上只有四种可能的返回路径。

以下是实现示意图:

if x > y then
    if x > z then x else z
else
    if y > z then y else z

因此,为了实现良好的白盒覆盖,我们实际上只需要编写四个测试用例。每一个测试用例用于探索通过if表达式的每一条路径。

这里的优势是我们得以编写一个小得多的测试套件。劣势是我们没有真正针对规格说明进行测试,并且如果有人来更改了实现,我们的测试就会过时。所以我们同时也应该进行黑盒测试。

实现良好覆盖的要点

为了实现良好的覆盖,我们应该为每个嵌套if表达式的每个分支以及每个嵌套模式匹配的每个分支都包含测试用例。

需要特别注意以下几点:

  • 递归函数的基本情况
  • 确保触发每个递归函数的递归调用
  • 确保为每个可能抛出异常的地方编写测试。

案例分析:闰年判断函数

假设你正在尝试对这个leap_year函数进行白盒覆盖测试,该函数用于判断年份y在公历中是否为闰年。

实现基于布尔运算符。假设你已经有一个测试套件,测试了输入 2000、2010 和 2020。

这个测试套件实现了哪种覆盖?是语句覆盖还是条件覆盖?

让我们来分析一下。我创建了一个表格。行是测试用例,列是每个布尔表达式。我将填写这个表格。如果布尔表达式在特定输入下求值为truefalse,我将填入truefalse;如果某些输入不会导致所有表达式都被求值,则标记为“未求值”。

以下是测试覆盖分析表:

测试输入 y mod 4 == 0 y mod 100 != 0 y mod 400 == 0
2000 true false true
2010 false 未求值 未求值
2020 true true 未求值

输入 2000 导致所有三个表达式都被求值。但 2010 和 2020 使其中一些未被求值。

因此,这个测试套件确实实现了语句覆盖,因为程序中的每个表达式在整个测试套件中的某个地方都被求值了。事实上,仅 2000 这一个输入就足以实现语句覆盖。

但是,条件覆盖并未实现。如果你查看 y mod 400 == 0 这一列,没有任何地方该表达式求值为false。你设法得到了true,但没有false

为了实现条件覆盖,我们需要添加另一个测试用例。输入 2100 就足够了,它可以使最后一个布尔表达式求值为false,从而获得条件覆盖。

更新后的覆盖分析表如下:

测试输入 y mod 4 == 0 y mod 100 != 0 y mod 400 == 0
2000 true false true
2010 false 未求值 未求值
2020 true true 未求值
2100 true false false

总结

本节课中我们一起学习了白盒测试。我们了解到白盒测试通过查看代码内部实现来设计测试,可以有效补充黑盒测试。我们探讨了三种主要的覆盖率类型:语句覆盖条件覆盖路径覆盖,并通过具体函数示例分析了如何评估和达到这些覆盖标准。记住,结合使用白盒和黑盒测试,并针对代码中的分支、递归和异常点进行重点测试,是构建健壮测试套件的关键。

088:使用Bisect进行白盒测试 🧪

在本节课中,我们将学习如何使用OCaml的工具Bisect进行白盒测试。白盒测试是一种计算机可以辅助完成的测试方法,旨在检查程序中的每一条语句或表达式是否都被执行过。

概述

白盒测试的核心在于验证测试用例是否覆盖了程序中的所有代码路径。许多编程语言都提供了相应的支持,例如Java。在OCaml中,这个功能由一个名为Bisect的工具实现。接下来,我们将通过一个简单的演示来了解如何使用Bisect。

演示:测试闰年函数

假设我们有一个名为leap_year.ml的文件,其中包含一个判断闰年的函数。我们为该函数编写了一些单元测试。

为了演示,我们首先注释掉其中两个单元测试,然后运行测试套件。测试通过后,我们使用一个特定的Makefile目标(例如bisect)来生成代码覆盖率报告。

运行make bisect后,Bisect会生成一份报告,并保存在coverage目录中。在浏览器中打开该报告,我们可以看到文件leap_year.ml的覆盖率仅为25%。

进一步查看文件详情,会发现函数体的第一行被高亮显示,表示有单元测试执行到了该表达式,但其余部分则没有。

报告中的这些带有高亮背景的小字符是Bisect插入的“程序点”。在编译测试代码时,Bisect被链接进来,并在运行时注入了一些代码,用于记录程序执行是否经过了这些点。这就是它判断是否覆盖了程序中所有表达式的方法。

提高覆盖率

目前,我们只启用了一个单元测试(测试年份2010,它不是闰年),因此只覆盖了第一行代码。

接下来,我们取消注释下一个单元测试,覆盖率会有所提升。现在覆盖率达到了75%,并且第六行代码也被覆盖了,因为该单元测试检查了一个能被4整除但不能被100整除的年份。

最后,为了达到100%的语句覆盖率,我们取消注释最后一个单元测试,并重新运行Bisect。再次查看结果,现在覆盖率达到了100%。

需要注意的是,这里达到的是语句覆盖率,而非路径覆盖率。例如,我们不需要额外添加一个单元测试来让条件 y mod 400 = 0 评估为 false

配置Bisect

要启用Bisect,需要进行一些配置。以下是关键的步骤:

  1. 更新_tags文件:指定需要覆盖的源代码文件,并链接bisect_ppx包。
  2. 创建myocamlbuild.ml文件:这是必要的构建配置文件。
  3. 更新Makefile:添加新的构建目标(如bisect),并在其中指定加载bisect_ppx插件,以及在测试源代码和生成报告时执行额外的命令。

对于Merlin配置文件(.merlin),通常不需要进行特殊修改。

以上所有步骤的详细说明都可以在Bisect的官方文档中找到。

总结

本节课我们一起学习了如何使用OCaml的Bisect工具进行白盒测试。我们了解了如何生成和查看代码覆盖率报告,并通过逐步添加测试用例将覆盖率从25%提升到100%。同时,我们也简要介绍了配置Bisect所需的关键步骤。利用Bisect,开发者可以客观地评估测试的完备性,并针对性地补充测试用例。

089:随机化测试与QCheck 🎲

在本节课中,我们将要学习一种重要的测试方法:随机化测试。我们将了解它的起源、核心思想,并学习如何在OCaml中使用QCheck库进行基于属性的随机化测试。

除了黑盒测试和白盒测试,另一种非常重要的测试方法是随机化测试。

这种测试方法的发现源于一个真实的故事。故事发生在一个风雨交加的夜晚。一位研究员正在使用拨号调制解调器连接其大学的计算机集群。当时正有一场雷暴,闪电的电磁干扰在电话线中引入了噪声,导致研究员输入的内容中插入了随机字符。这些随机字符导致研究员正在使用的远程计算机上的一些工具程序崩溃。

受此启发,这位研究员决定测试,如果将随机字符作为输入提供给程序,会导致多少崩溃。这就是随机化测试背后的思想:生成随机输入,并将其提供给程序,观察会发生什么。

程序可能会崩溃,也可能会挂起或进入无限循环,还可能正常终止,但产生正确或错误的输出。

当这种方法在1989年首次被应用时,在测试的大约90个工具程序中,有大约四分之一到三分之一会在输入各种形式的随机字符时崩溃。崩溃是一件坏事,它意味着程序中可能存在缓冲区溢出漏洞,攻击者可能利用此漏洞来控制计算机系统。

此后,这些结果在X Window、Windows、Mac OS等系统上被重新测试。你猜结果是变好了还是变差了?在图形用户界面程序上,结果持续变差,但在命令行工具上结果在变好。因此,我们总体上正在修复命令行工具的漏洞,但测试图形用户界面程序是一个非常困难的问题,所以它们的情况变差并不令人意外。

从那时起,这种通常被称为“模糊测试”的测试方法,就像给程序喂“模糊”输入一样,已成为安全测试中的标准实践。它在我们测试自己的代码时也非常有用。事实上,在后续的一些编程作业中,课程团队会例行使用这种方法来测试学生对数据结构的实现。没有比向一个平衡二叉树实现中插入一千个随机元素,然后再进行一千次随机删除,并观察最终得到的树是否正确更好的测试方法了。OCaml同样支持随机化测试。

上一节我们介绍了随机化测试的基本概念,本节中我们来看看如何在OCaml中具体实现它。

让我们回到我们的闰年判断函数。

我保留了之前常用的三个测试,但现在我添加了一些使用名为QCheck(代表Quick Check)的库构建的新测试。这是一个用于对函数属性进行快速随机化测试的库。

让我们看看第一个测试在做什么。它测试的是随机的“非4的倍数”。这个测试的名称是“非四的倍数不可能是闰年”。让我们停下来思考一下。是的,像2001这样不是4的倍数的年份,永远不可能是闰年。

这个测试所做的是进行1000次不同的随机测试。每次测试都生成一个在1到3000范围内的随机输入,然后检查对于每个输入,我在最后一行列出的函数是否返回true。我们正在检查该输入的一个属性。我们正在检查的属性在这里定义,即函数non_mult4_non_leapyear。我们检查该年份是否是4的倍数,如果是,它可能是也可能不是闰年,但我们不会进一步检查。但如果它不是4的倍数,那么它最好不是闰年。这就是年份的一个属性。

那么,为什么我们检查的是属性而不是确切的正确输出(即判断闰年函数返回truefalse)呢?这是因为,对于一个随机数,我无法提前预测它是否是闰年。如果我已有能正确判断的代码,我就可以使用它,但问题在于,那正是我试图在这里测试的代码,这行不通。

因此,随机化测试通常不是测试对于某个输入你是否得到了完全正确的输出,而是基于属性的测试,测试输入和输出之间的某些属性是否成立。这里我们测试的属性是:如果年份不是4的倍数,则它不是闰年。

接下来,我有另一个随机化测试。这个测试检查的是世纪年份。除非是400的倍数,否则世纪年份不能是闰年。

我在这里编写的属性是:检查年份是否是400的倍数,如果是,那没问题,我不再进一步处理。但如果它不是400的倍数,我将检查它是否是闰年。只要我只传入100的倍数,这应该总是返回true。我所做的是使用QCheck生成1到30范围内的随机数,然后通过一个将其乘以100的函数映射每个数。这样我就得到了从100到3000的随机数,并检查该属性。

QCheck中有一个函数可以将这些测试转换为OUnit测试。我将该函数应用于每个测试,现在我可以将它们与所有其他OUnit测试一起放入我的OUnit测试套件中。

现在你在这里只看到五个测试点,因为每个Quick Check测试,尽管各自检查了1000个随机元素,在输出中只算作一个小点。如果那1000个元素中的任何一个在测试中失败,我们就会在这里得到一个失败提示,并且Quick Check会告诉我们触发该失败的随机输入是什么。

要让Quick Check与你的源代码一起工作非常简单,你只需要将其作为一个包链接进来。因此,在tags文件中添加包qcheck,在Merlin文件中也将其添加为另一个包。

以下是使用QCheck生成随机数据的一些主要方式。

该库的文档包含了所有类型的生成器。我们在这里使用了int范围生成器,但还有许多其他随机生成器,例如整数的边界情况、小整数、小的自然数,甚至还有许多其他类型的生成器,如浮点数、随机打乱的列表,以及用于操作这些生成器的高阶函数,甚至还有生成变体类型随机元素的功能。

本节课中我们一起学习了随机化测试的起源和核心思想,即通过生成随机输入来测试程序的健壮性。我们重点介绍了如何在OCaml中使用QCheck库进行基于属性的测试,这包括定义待测试的属性、使用生成器创建随机输入,并将这些测试集成到OUnit测试框架中。这种方法对于发现边界情况和潜在的安全漏洞特别有效。

090:调试的艺术 🐛

在本节课中,我们将学习如何有效地调试程序。调试是编程中不可或缺的一部分,它帮助我们定位并修复代码中的错误。我们将探讨调试的科学方法、实用技巧以及如何通过防御性编程来减少未来的调试工作。

当测试失败时,你就需要调试程序。

调试就像在一部犯罪电影中扮演侦探,而你自己就是凶手。😡

是你自己造成了问题,现在需要找出问题所在。

因此,测试揭示了程序中的故障,而调试则揭示了该故障的根源。😡

调试通常比编程本身花费更多时间。所以,尽量在第一次就写对代码,以节省所有用于调试的时间。

但如果你发现自己不得不进行调试,在开始调试之前,请先尝试理解你认为代码应该能正常工作的确切原因。

前期花时间思考可以为你后期节省大量时间。

以下是一些成功调试的建议。

遵循科学方法。首先提出一个可证伪的假设。

然后设计一个可以反驳该假设的实验。

通常,这意味着找到能引发故障的最简单的输入。

你运行那个实验。然后,保持记录。因为很多调试任务会变得相当复杂。

所以写下你正在做的事情、你观察到的故障、你尝试作为实验的输入。

以及你得到的结果。通过做笔记,而不是试图把所有信息都记在脑子里,你会在那些深夜调试、感到困惑的时段里变得更有效率。

很可能,这意味着错误并不在你认为的地方。所以问问自己,它可能在哪里?😡

然后也开始调查那些地方。另一个很好的策略是寻求他人的帮助。

一个绝妙的方法是使用一只橡皮鸭。

我实际上在盖茨楼的办公室架子上放了一只橡皮鸭,也许有一天你可以亲自来看看。它是一些以前的学生送给我的。

大声地讲出来。对象不一定非得是橡皮鸭。可以跟一个想象中的朋友、一个毛绒玩具或枕头交谈。但要让你的大脑运转起来,就像你在向另一个人解释问题一样。这种方法的效果常常令人惊讶。

如果所有方法都失败了,质疑一下自己对当前情况的判断。

你是否真的使用了正确版本的编译器?😡

你是否真的在处理正确版本的源代码?或者你是否在另一个并行目录下工作而没有意识到?

如果你发现自己变得愤怒或疲惫,停下来。调试非常困难,当你情绪化时只会更难。所以休息一下。

当你感觉更理智时,再精神焕发地回来。

仔细思考你做的任何修复。一个常见的现象是,修复一个错误实际上会引入新的错误,所以在打补丁时要非常小心。

防御性编程是加速调试的好方法,但你在开始调试之前就提前做了,所以你可以把它看作一种主动的调试。

其核心思想是让检测故障变得更容易。通过在实现其余代码的同时编写故障检测代码。

你已经熟悉了一些相关技术,让我来提醒你一下。

断言前置条件。虽然规范不要求,但这是一个很好的防御性编程技术。😡

断言不变量,例如表示不变量。

编写穷举的条件分支。确保每个 match 表达式都有穷尽的模式匹配,并且编译器没有给出任何警告。

有人可能会问,防御性编程代价高吗?😡

答案是,它只是看起来如此。对于实现者来说,防御性代码实际上是有回报的,因为它能及早捕获故障,而不是等到你处理来自用户的错误报告时。

在性能方面,有时防御性编程可能有点昂贵,但你在生产环境中捕获的故障为公司节省的钱,可能比运行时检查的成本要多。

本节课中我们一起学习了调试的核心思想与实用技巧。我们了解到调试是一个需要遵循科学方法、保持耐心和记录的过程。通过使用橡皮鸭调试法、质疑基本假设以及实施防御性编程,我们可以更高效地定位和修复错误,并减少未来调试的负担。记住,良好的编程习惯和前期思考是减少调试时间的最佳途径。

091:形式化验证

概述

在本节课中,我们将要学习形式化验证的基本概念。形式化验证是一种数学方法,用于确保软件系统按照预期工作,尤其适用于对安全性要求极高的系统。我们将探讨其重要性、发展历程,并了解在CS3110课程中我们将如何应用它来证明小型纯函数式程序的正确性。


软件可靠性的重要性

如果你的工作是构建下一个运行现实世界关键部件的大型软件系统,会怎样?

例如,一架飞机的自动驾驶飞行控制器。

或者一辆自动驾驶汽车。又或者另一种完全不同的交通工具,比如航天飞机。

航天飞机必须进入太空并搭载人类返回。或者是电网、DNA测序仪。

以及许多其他类型的机器。这些系统有什么共同点?安全性至关重要。

人类可能因这些设备中的任何一个而受伤或死亡。

因此,我们需要运行它们的软件比普通的手机应用(比如TikTok)可靠得多。

😡,这就是本课程下一部分要讨论的内容。

你需要使用哪些技术来构建如此可靠的软件?更早的时候。

我们在讨论测试时见过这张幻灯片,现在是时候回顾它了。

确保软件按预期工作的验证过程有许多方法。

😡,我们之前讨论过从社会性方法一直到数学性方法的各种方法。

在数学方法这一端,你现在对OCaml的类型系统已经有了很多经验。

它比其他语言更严格。也许你已经体会到了这一点。

也许你甚至已经亲身感受到,在编码过程中能够意识到,哇,一旦我的代码编译通过,它就能按我的意愿工作。

这在其他语言中不一定成立。

因为它们的类型系统表达能力不够强。另一方面。

当然,有时它确实会让代码编译变得更麻烦。那么。

让我们再向前迈进一步,超越类型系统,进入所谓的“形式化验证”。

这里的“形式化”指的是数学意义上的形式化,即对你试图证明的内容进行严谨的数学描述。

因此,你应该从CS 2800课程中熟悉这种思想。

形式化验证的历史与发展

形式化验证的历史可以追溯到大约60或70年代。那时。

事实证明,我们康奈尔大学自己的教授Gs是这一领域的领军人物之一。

大约在70年代。形式化验证,即证明程序的正确性。

当时只能扩展到大约几十行代码的规模。

它需要大量的人工工作,大量的纸上证明。

而且做起来并不总是那么愉快。它曾经一度。

可能有点被认为是没有前途的东西。

时至今日,你可能仍会偶尔遇到持这种观点的人。顺便说一下。

机器学习也曾经历过同样的事情,看看我们现在在机器学习领域取得了什么成就。

那么,也看看我们现在在形式化验证领域取得了什么成就。😡。

现在的研究项目已经扩展到真实的软件规模。😡,例如。

有一个名为CompCert的经过验证的C编译器,它已被证明是正确的,并且有研究表明它比其他C编译器少得多。

少得多的错误。现在你可能会想,既然我们证明了它是正确的,它怎么还会有错误?这是因为并非编译器的所有部分都被证明了正确性,有一些部分(比如解析器的一部分)最初没有被证明正确,实际上他们现在已经完成了那些部分的验证工作。

😡。

其他例子包括SEL4,这是一个经过验证的微内核操作系统。

还有Verdi,它提供了一个库,在此基础上构建了一个经过验证的数据库管理系统,这项工作部分由我们自己的Greg Morrisett教授完成。

他现在是康奈尔理工学院的院长兼副教务长。

更数学化的成果包括四色定理的计算机辅助证明。

你可能在CS 2110中记得它,该定理指出给平面地图着色最多只需要四种颜色。

还有其他更激动人心的进行中的工作,比如Project Everest。

由微软研究院部分完成,这是一个经过验证的HTTPS协议栈。

还有许多其他有趣的项目正在进行。所以在大约40年的时间里。

我们从只能处理一些玩具示例,发展到了能够处理真实软件。

这些都是研究努力的成果,其中一些项目耗费了数年的人力。

SEL4在这方面尤其引人注目。但在接下来的40年里,我们可能会达到什么程度?嗯。

到那时,我的职业生涯将接近尾声,但你们将正值事业的黄金时期。

因此,我对你们在40年后将看到的形式化验证能力,以及它在提高软件正确性方面所能做的事情感到兴奋。

😡。

CS3110课程中的形式化验证

我们在CS3110中要做什么?嗯,不会是构建一个完整的经过验证的微内核操作系统。

抱歉。我们将要做以下事情。我们将编写一些小型、纯函数式的程序。

所谓纯函数式,我的意思当然是指没有副作用。

没有可变性,没有输入/输出,并且它们总是会终止。

当然,有办法绕过所有这些限制。

但随着你构建更复杂的程序,技术难度会增加。

所以我们将从简单的内容开始。小型且纯粹。

我们将对涉及整数、列表、选项、树。

以及其他几种数据结构的程序进行证明。我们将证明这些程序的正确性定理。

所以这回到了你在CS 2800中学到或正在学习的内容,为此你需要了解一些逻辑知识。

在这个过程中,你肯定会得到更多关于归纳法的练习。😡。

我们在所有这些中的目标不是做到100%完全形式化。我们将保持严谨。

我们将对我们提出的主张、使用的证明技术以及给出的理由保持谨慎。

但我们不会试图做到事无巨细、面面俱到。那可以做到。

只是需要多得多的工作量。如果你对此感兴趣。

我确实有一门CS4160课程,专门讲形式化验证,在那里我们会更深入地探讨所有这些内容。


总结

本节课中我们一起学习了形式化验证的基本概念。我们了解到,形式化验证是一种用于确保关键软件系统正确性的数学方法,其应用范围已从几十行代码的示例扩展到真实的操作系统、编译器等项目。在CS3110课程中,我们将通过编写和证明小型纯函数式程序的正确性,来实践这一思想的核心部分,为构建更可靠的软件打下基础。

092:表达式相等性 🧮

在本节课中,我们将学习如何证明程序的正确性,其核心在于理解“表达式相等性”这一概念。我们将探讨如何判断两段代码是否“相等”,这不仅是程序规范的基础,也是后续进行正确性证明的关键。

程序正确性与规范

证明程序正确性意味着什么?如何做到这一点?

我们从一个规范的立场出发。假设这里有一个计算阶乘的程序。实际上,我还没有给出函数的具体实现,但更重要的是,我还没有给出它的规范

这个规范中包含一个后置条件。正如本学期我所教授的,我倾向于将这类后置条件写成等式的形式。这个等式位于函数输出和它的另一种描述之间,后者通常是英语和数学的混合体,用于描述输出如何与输入相关联。

我们的正确性证明将基于表达式之间的相等性,就像我们的规范一直基于这种相等性概念一样。

代码相等性的挑战

然而,推理代码和自然语言描述之间的相等性非常困难。你如何知道一段OCaml代码正确地实现了某个英语句子?这是一个非常棘手的问题,我们暂不解决。

更简单的方法是推理两段代码之间的相等性。因此,在学习过程中,我们会看到如何将一段代码视为规范,而将另一段代码视为实现。

但这立刻意味着,我们需要一种方法来思考表达式之间、代码片段之间的相等性。这里我指的不是OCaml的布尔相等运算符(=),而是更根本的:两段代码何时是相等的?

相等性的不同层面:语法与语义

以下是一个帮助我们思考的例子:

问题: 代码 41 + 1 是否等于代码 42

对于这个问题,至少可以给出两种不同的答案。

1. 语法层面的答案:
从语法上讲,这两段代码并不相同。你可以将它们表示为树结构(正如你在CS 2110中将Java表达式表示为树所学到的)。包含一个“+”节点及其子节点“41”和“1”的树,显然与表示“42”的树不同。因此,语法上,它们不相等

2. 语义层面的答案:
从语义上讲,它们都求值相同的值。这正是我在此处所考虑的代码相等性概念:如果两段代码求值到相同的值,那么它们就是相等的

函数的相等性

上面的例子是针对相当简单的表达式。让我们看一个稍微复杂一点的:函数

问题: 恒等函数的两个版本 fun x -> xfun y -> y 是否相等?

  • 语法上,答案必须是否定的,因为这里使用了两个不同的变量名 xy
  • 语义上,这个问题回答起来有点困难,因为“函数求值到相同的东西”意味着什么?我们目前还没有一个清晰的答案。

实际上,答案是:如果两个函数在接收到相同的输入时,总是求值到相同的值,那么它们就是相等的。换句话说,对于所有值 v

  • (fun x -> x) 应用于 v,会得到 v
  • (fun y -> y) 应用于 v,也会得到 v

因此,对于所有输入,这两个函数产生相同的输出。这种函数相等性的概念被称为外延相等性。外延性在数理逻辑中广为人知,你可能以前接触过,也可能没有。这将是我们对函数使用的相等性概念。

定义与前提条件

综上所述,我们可以给出定义:两个表达式 ee' 是相等的,当且仅当它们求值到相同的值

这里需要补充一些重要的前提说明(但我不想在此过分强调):

  1. 这两个表达式必须是良类型的。我们不考虑类型错误的表达式。
  2. 它们必须是的,即我之前讨论过的没有副作用。
  3. 它们必须是可终止的,也就是说,它们必须能正常结束,而不会引发异常或陷入无限循环。

本节课总结:
我们一起学习了程序正确性证明的基础——表达式相等性。我们区分了语法相等和语义(求值结果)相等,并明确了在程序推理中我们关注的是后者。对于函数,我们引入了外延相等性的概念,即两个函数相等当且仅当它们对所有输入都产生相同输出。理解这些概念是后续进行严谨程序推导和证明的第一步。

093:等式推理 🧮

在本节课中,我们将学习一种名为“等式推理”的编程推理方法。这是一种通过表达式相等的概念,来论证两个程序是否等价的技术。我们将通过高阶函数的例子来理解其核心思想,并学习一种结构化的证明格式。

高阶函数与等式推理

上一节我们介绍了高阶函数的概念,本节中我们来看看如何运用等式推理来分析它们。

我们曾学习过函数 twice,它将其参数函数 f 应用到输入 x 上两次。我们也学习了函数组合,例如,组合两个函数 fg 可以定义为:先对 x 应用 g,再对结果应用 f

这里我们可以注意到一个现象。对于某个函数 h 和输入 xtwice h x 会求值成什么?根据OCaml的求值语义,我们将 h 代入 f,因此得到 h (h x)。假设你将 h 同时作为 compose 的第一个和第二个参数(即同时作为 fg)传入,那么 compose h h x 也会求值为 h (h x)

因此,涉及 twicecompose 的这两段代码最终会求值成相同的中间表达式。所以,twice h x 等于 compose h h x。这是因为通过传递性,从 twiceh (h x) 再到 compose,所有这些表达式都将求值为相同的值,所以它们相等。

结构化证明格式

以下是一种结构化表述上述结论的方式,它是验证领域一种广为人知的证明格式,归功于Vim Fayan。

twice h x
= { 根据 `twice` 的定义求值 }
h (h x)
= { 根据 `compose` 的定义求值 }
compose h h x

我们从顶部的一段代码开始,以底部的另一段代码结束。在它们之间是一个具有传递性的等式链。等式是凸出的,而代码是缩进的。在每一行写有“=”的地方,我们不写代码,而是写下该证明步骤的理由(用花括号括起)。

请你在学习这部分内容时使用这种证明格式,它是该领域“词汇”的一部分。

函数组合的结合律证明

让我们更详细地看看函数组合。我们已经有了 compose 函数,让我引入一个二元运算符 << 来表示它(尽管我仍将其读作“组合”)。这个助记符暗示我们从右向左运行:如果你有 f << g,那么将其应用于参数 x 时,会先通过 g 运行 x,然后再通过 f

我想证明的定理是:组合操作满足结合律。即 (f << g) << h 等于 f << (g << h)

要证明这一点,我们需要使用外延性原理,因为我们试图证明两个函数的相等性。根据外延性,如果两个函数在应用于相同输入时产生相同的输出,那么它们相等。因此,我们需要证明对于所有 x,左边的表达式 ((f << g) << h) x 等于右边的表达式 (f << (g << h)) x

以下是证明过程。我在顶部写下了我们想要证明的命题,并提醒了我们组合的定义。

定义: (f << g) x = f (g x)

命题: ((f << g) << h) x = (f << (g << h)) x

进行此类证明的一种策略是采用类似两列的格式。我从左边的表达式开始写一列,右边的表达式写另一列。我的目标是让两列最终到达同一个地方,即在每一列中采取一系列等式变换步骤,最终在底部得到相同的表达式。

以下是证明步骤:

左边列:                     | 右边列:
((f << g) << h) x           | (f << (g << h)) x
= { 根据组合定义求值 }       |
(f << g) (h x)              | = { 根据组合定义求值 }
= { 再次根据组合定义求值 }   | f ((g << h) x)
f (g (h x))                 | = { 根据组合定义求值 }
                            | f (g (h x))

现在,两列都结束于相同的地方 f (g (h x))。因此,我证明了最初左右两边的表达式相等。证明完毕(QED)。

我鼓励你接下来合上这份教程,仅写下我们想要证明相等的表达式和组合的定义,然后在不回看的情况下自己完成证明。这将有助于在你的大脑中巩固进行此类证明的思维过程。

总结

本节课中我们一起学习了“等式推理”。我们了解到,这是一种通过表达式求值来论证程序等价性的方法。我们通过 twicecompose 的例子直观理解了其思想,并学习了一种结构化的证明格式。最后,我们详细证明了函数组合操作满足结合律,巩固了使用等式推理和外延性原理进行严谨证明的步骤。掌握这种方法,有助于我们更深入地理解程序的行为并验证其正确性。

094:递归函数的归纳证明 📚

在本节课中,我们将学习如何证明使用递归的、更有趣的函数的正确性。我们将以证明一个判断自然数是否为偶数的函数为例,并详细介绍归纳证明的步骤。

概述

我们将证明一个递归函数 even 的正确性。具体来说,我们将证明对于所有自然数 neven (2 * n) 的求值结果总是 true。这个证明将使用数学归纳法。

归纳证明的结构

上一节我们介绍了等式推理,本节中我们来看看如何对递归函数进行归纳证明。

归纳证明用于证明一个性质 P 对所有自然数 n 都成立。证明过程分为两个部分:基础步骤和归纳步骤。

以下是归纳证明的两个核心步骤:

  1. 基础步骤:证明性质 P 对最小的自然数(通常是 0)成立。
  2. 归纳步骤:假设性质 P 对一个任意的自然数 k 成立(这称为归纳假设),然后证明在此假设下,性质 Pk+1 也成立。

为了清晰地完成证明,我们建议遵循以下格式:

  • 明确陈述要证明的性质 P
  • 明确陈述基础步骤。
  • 明确陈述归纳步骤,包括:
    • 如何将 n 表示为 k+1
    • 归纳假设的具体内容。
    • 在归纳步骤中需要证明的具体目标。

证明示例:even 函数

现在,让我们应用归纳法来证明关于 even 函数的断言。

我们定义函数 even 如下:

let rec even n =
  match n with
  | 0 -> true
  | 1 -> false
  | n -> even (n - 2)

我们要证明的性质 P(n) 是:对于任意自然数 neven (2 * n) 的求值结果为 true

基础步骤

基础步骤是证明 P(0) 成立,即 even (2 * 0) = true

我们进行等式推理:

  • even (2 * 0) 求值为 even 0
  • 根据 even 函数的定义,当输入为 0 时,匹配第一个分支并返回 true
    因此,even (2 * 0) 求值为 true。基础步骤得证。

归纳步骤

在归纳步骤中,我们设 n = k + 1,其中 k 是一个自然数。

归纳假设是 P(k) 成立,即 even (2 * k) = true

我们需要证明的目标是 P(k+1) 成立,即 even (2 * (k + 1)) = true

我们再次从等式推理开始:

  • even (2 * (k + 1)) 根据代数运算,等于 even ((2 * k) + 2)
  • 由于 k 是自然数,(2 * k) + 2 至少为 2。根据 even 函数的定义,对于不小于 2 的输入,它会递归调用 even (n - 2)。因此,even ((2 * k) + 2) 求值为 even (2 * k)
  • 现在,我们得到了 even (2 * k)。根据我们的归纳假设,even (2 * k) = true

因此,我们证明了 even (2 * (k + 1)) 也求值为 true。归纳步骤得证。

总结

本节课中我们一起学习了如何对递归函数进行归纳证明。我们以 even 函数为例,详细演示了证明的完整过程:首先明确要证明的性质 P,然后分别完成基础步骤和归纳步骤。通过这个例子,我们掌握了使用归纳法验证递归函数性质的基本方法。

095:求和

在本节课中,我们将学习如何对一个递归程序进行归纳证明。我们将通过一个具体的例子——证明一个求和函数的递归实现与其已知的闭合形式公式等价——来演示归纳证明的完整过程。

概述

我们将要证明一个递归函数 sum2(n),它计算从0到n的所有整数之和,其计算结果与数学公式 n * (n + 1) / 2 完全一致。这个证明将巩固我们对递归函数行为和数学归纳法的理解。

证明目标

我们定义函数 sum2 如下:

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

我们希望证明对于所有自然数 n,以下命题 P(n) 成立:
sum2(n) = n * (n + 1) / 2

证明过程:数学归纳法

我们将采用数学归纳法来完成证明。归纳法包含两个步骤:证明基础情况成立,以及证明归纳步骤成立。

基础情况 (Base Case)

首先,我们证明当 n = 0 时,命题 P(0) 成立。

根据 sum2 函数的定义,当输入为0时,函数直接返回0。

sum2(0) = 0

同时,根据代数运算,公式的右侧在 n=0 时也为0。

0 * (0 + 1) / 2 = 0

因此,sum2(0) = 0 * (0 + 1) / 2 成立。基础情况得证。

归纳步骤 (Inductive Step)

上一节我们证明了基础情况,本节中我们来看看归纳步骤。这是证明的关键,也是容易出错的地方。

我们假设对于某个自然数 k,归纳假设 P(k) 成立,即:
sum2(k) = k * (k + 1) / 2
我们需要证明在此假设下,P(k+1) 也成立,即:
sum2(k + 1) = (k + 1) * ((k + 1) + 1) / 2

以下是我们的推导过程:

  1. 计算 sum2(k+1)
    由于 k >= 0,所以 k+1 >= 1,不等于0。因此,函数会进入 else 分支。

    sum2(k + 1) = (k + 1) + sum2((k + 1) - 1)
                = (k + 1) + sum2(k)
    
  2. 应用归纳假设
    根据我们的归纳假设 P(k),我们知道 sum2(k) = k * (k + 1) / 2。将其代入上式:

    sum2(k + 1) = (k + 1) + [k * (k + 1) / 2]
    
  3. 代数化简
    对上式进行代数运算,目标是将其化为 (k+1)*((k+1)+1)/2 的形式。

    sum2(k + 1) = (k + 1) + [k * (k + 1) / 2]
                = [2*(k+1) / 2] + [k*(k+1) / 2]
                = (2*(k+1) + k*(k+1)) / 2
                = (k+1)(2 + k) / 2
                = (k+1)(k+2) / 2
                = (k+1) * ((k+1) + 1) / 2
    

    推导结果正是 P(k+1) 的右侧。因此,归纳步骤得证。

总结

本节课中我们一起学习了如何对一个OCaml递归函数进行归纳证明。

我们首先定义了待证明的命题 P(n),然后通过数学归纳法完成了证明:

  1. 基础情况:证明了 P(0) 成立。
  2. 归纳步骤:假设 P(k) 成立,成功推导出 P(k+1) 也成立。

由此,我们证明了对于所有自然数 n,递归函数 sum2(n) 的计算结果确实等于其闭合形式公式 n * (n + 1) / 2。这个证明过程将我们在数学课(如CS2800)中学到的公式与在OCaml中的具体实现联系了起来,并验证了代码的正确性。证明完毕。

096:迭代阶乘的证明示例 🧮

在本节课中,我们将学习如何通过归纳法证明一个递归程序的正确性。具体来说,我们将证明一个“尾递归”版本的阶乘函数与标准的递归版本是等价的。通过这个例子,我们将展示如何通过强化归纳假设来克服证明中的困难。

迭代阶乘函数

首先,我们来看两个阶乘函数的实现。一个是标准的递归版本 fact,另一个是更高效的尾递归版本 fact_ii 代表迭代)。

以下是 fact 函数的定义:

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

以下是尾递归辅助函数 fact_i 的定义:

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

为了理解为什么称其为“迭代”,我们可以将其与Java等命令式语言中的迭代解决方案进行比较。两者都使用一个从1开始的累加器,检查输入的数字 n 是否为零,将累加器乘以 n,递减 n,最后返回累加器的值。

证明目标

我们的目标是证明以下声明的正确性:

fact n = fact_i 1 n

也就是说,对于任意自然数 n,标准的 fact n 与以 1 作为初始累加器调用的 fact_i 1 n 结果相同。

我们将通过对 n 进行归纳来证明这一点。

初始尝试与遇到的困难

我们首先尝试证明属性 P(n)fact n = fact_i 1 n

基础情况:当 n = 0 时,fact 0 计算结果为 1。同时,fact_i 1 0 也返回累加器 1。因此,基础情况成立。

归纳步骤:假设对于某个自然数 kP(k) 成立,即 fact k = fact_i 1 k。我们需要证明 P(k+1) 也成立。

我们从 fact (k+1) 开始:

fact (k+1) = (k+1) * fact k          (根据 `fact` 的定义,因为 k+1 > 0)
           = (k+1) * (fact_i 1 k)    (应用归纳假设)

现在,我们需要证明 (k+1) * (fact_i 1 k) 等于 fact_i 1 (k+1)

然而,我们在这里遇到了障碍。我们无法直接对 fact_i 1 (k+1) 进行求值,因为我们不知道 k 的具体值(它可能是0,也可能是其他数),因此无法确定是进入 ifthen 分支还是 else 分支。我们卡住了。

强化归纳假设

当我们遇到这种僵局时,退一步审视情况会有所帮助。问题在于归纳假设过于具体——它总是将 1 作为 fact_i 的第一个参数。我们真正需要的是将 (k+1) 这个因子乘到累加器里去。

因此,我们需要强化我们想要证明的属性 P。我们将它推广为:

对于所有的 p,都有:p * fact n = fact_i p n

这个新属性 P'(n) 的含义是:你可以将任意值 p 作为累加器的初始值传入,fact_i p n 的结果就等于 p 乘以 fact n

使用强化假设完成证明

现在,我们使用强化后的属性 P'(n) 来重新进行证明。

基础情况:对于任意 pp * fact 0 = p * 1 = p。同时,fact_i p 0 返回累加器 p。因此,基础情况成立。

归纳步骤:假设对于某个自然数 kP'(k) 成立,即对于所有 p,都有 p * fact k = fact_i p k。我们需要证明 P'(k+1) 也成立,即对于所有 p,都有 p * fact (k+1) = fact_i p (k+1)

我们从左边开始:

p * fact (k+1) = p * ((k+1) * fact k)    (根据 `fact` 的定义)
               = (p * (k+1)) * fact k    (乘法结合律)

现在,我们有了 (p * (k+1)) * fact k 这个形式。请注意,我们的归纳假设 P'(k) 是“对于所有 p...”。这意味着我们可以选择将归纳假设中的 p 实例化为 p * (k+1)

应用归纳假设(其中 p 取值为 p * (k+1)):

(p * (k+1)) * fact k = fact_i (p * (k+1)) k

接下来,我们处理右边 fact_i p (k+1)。由于 k+1 > 0,我们进入 else 分支:

fact_i p (k+1) = fact_i (p * (k+1)) k    (根据 `fact_i` 的定义)

现在,我们得到:

p * fact (k+1) = (p * (k+1)) * fact k
               = fact_i (p * (k+1)) k    (应用归纳假设)
               = fact_i p (k+1)          (根据 `fact_i` 的定义)

因此,我们证明了对于所有 pp * fact (k+1) = fact_i p (k+1)。归纳步骤完成。

得出最终结论

通过归纳法,我们证明了引理:对于所有自然数 n 和所有整数 pp * fact n = fact_i p n

我们最初的目标 fact n = fact_i 1 n 是这个引理的一个直接推论(只需令 p = 1 即可)。

最后,如果我们有一个尾递归的阶乘函数 fact_tr,它简单地调用 fact_i 1 n

let fact_tr n = fact_i 1 n

那么其正确性证明就非常直接:fact_tr n = fact_i 1 n = fact n

总结

本节课中,我们一起学习了如何证明迭代(尾递归)阶乘函数的正确性。

  1. 我们首先定义了标准递归阶乘 fact 和尾递归辅助函数 fact_i
  2. 最初的证明尝试遇到了障碍,因为归纳假设不够强大。
  3. 我们通过将属性推广为 p * fact n = fact_i p n 来强化了归纳假设。 这允许累加器携带一个任意的乘数 p
  4. 使用强化后的假设,我们成功地通过归纳法完成了证明。
  5. 最终,我们最初的结论作为引理的一个特例(p=1)得以证明。

这个例子展示了一个非常重要的程序验证技术:编写一个简单、显然正确的“慢”版本,再编写一个高效但不那么显然正确的“快”版本,然后证明两者结果一致。 这是一种建立对高效算法信心的强大方法。

097:ADT上的归纳法

概述

在本节课中,我们将学习如何对代数数据类型进行归纳证明。我们将从一个熟悉的领域开始,即使用变体来表示自然数,并探讨我们之前使用的归纳证明技术如何应用到变体上。

自然数的ADT表示

到目前为止,我们一直在对自然数进行归纳。这使我们能够对OCaml内置整数上的递归程序进行一些推理。接下来,我们将尝试进行更复杂的操作,即对涉及变体或代数数据类型的程序进行证明。

我们将以一种熟悉的方式开始,即通过编码一个变体来表示自然数。这将使我们能够看到我们一直在使用的归纳证明技术如何应用到变体上。

以下是我为自然数定义的类型,我将用一元表示法来表示它们。

type nat = Z | S of nat

这里有两个构造器:ZSZ 代表 0,S 代表后继。它携带另一个自然数,表示比该数大一的数。这里的抽象函数是:S 的数量就是被一元表示的自然数。因此,Z 是 0,S Z 是 1,S (S Z) 是 2,依此类推。

现在,我们可以编写递归函数来实现标准的数学运算,只不过它们需要用一元表示法来完成。

实现加法运算

以下是在我们的 nat 类型上实现加法运算的示例。

let rec plus a b =
  match a with
  | Z -> b
  | S k -> S (plus k b)

要将两个自然数 ab 相加,我们可以通过模式匹配来实现。你可以选择在这里对哪个参数进行模式匹配,我将对 a 进行匹配。如果 aZ,那么我们试图将 0 加到另一个数上,这应该保持另一个数不变,所以我们直接返回 b。如果 a 是某个其他自然数 k 的后继,那么我们将递归计算 plus k b 的结果,然后加一,即取返回值的后继。这是一个正确但缓慢的加法实现。

实现乘法运算

你也可以实现所有其他运算,其中一些实现起来比其他运算更复杂。以下是实现乘法的示例,我不会详细讲解,但指出它基本上是在重复加法,就像 plus 重复加 1 一样,mult 重复加 b

let rec mult a b =
  match a with
  | Z -> Z
  | S k -> plus b (mult k b)

在变体上进行归纳

如果我们想对这种变体类型的值进行归纳,我们该怎么做?你已经熟悉了如何对数字形式的自然数进行归纳,但当它们以这种代码形式表示时,你该如何做呢?其实没有什么不同。

以下是关于 nat 的归纳证明格式。

  • 基本情况:我们从 n 等于 Z 开始,这当然代表零。这与我们之前处理自然数为 0 的基本情况相同。我们试图证明性质 PZ 成立。
  • 归纳情况:再次像之前一样,我们考虑一个比另一个自然数大一自然数。在这里,我们表达为 n 等于 S k。归纳假设是 Pk 上实例化成立,我们想要证明 Pk 的后继(即比 k 大一)上实例化也成立。

因此,证明格式完全没有改变。我们只是使用了不同的概念来表示基本值以及“比另一个数大一”的含义。

证明一个定理

让我们证明一个关于 plus 的定理:如果 plus 的第二个参数是 Z,那么它直接返回其第一个参数 n

Theorem: for all n:nat, plus n Z = n.

我为什么选择这种方式?为什么选择第二个参数是 Z?因为如果第一个参数是 Z,证明是平凡的,通过求值我们自动返回第二个参数。但这个更有趣,因为我们不能在这里对 plus n Z 进行求值步骤,因为我们不知道 n 是什么。回顾这个实现,第一个参数是我们立即进行模式匹配的参数,我们不知道它将是零还是大于零。因此,我们需要一个归纳证明。

我们将通过归纳法来证明这一点。

基本情况

基本情况非常简单。我们将性质 P 实例化在 Z 上,因此我们试图证明 plus Z Z = Z。我们只需对 plus Z Z 进行一步求值,它当然会对其第一个参数进行模式匹配并返回其第二个参数,所以我们知道它求值为 Z,这个情况就完成了。

归纳情况

我已经在这里设置了归纳假设和我想证明的内容。让我们开始吧。

我们知道在这种情况下可以对 plus 进行一些求值,因为我们确实知道第一个参数不是 Z,它不可能是 Z,它实际上是 S k,所以我们知道它将求值为递归调用的后继。

现在,我们不能再次对 plus k Z 进行求值步骤,因为现在我们遇到了一个自然数 k,我们不知道它是 Z 还是 S。但归纳假设适用,因此我们可以将 plus k Z 重写为 k。这就是我们想要证明的,因此我们完成了,证毕。

总结

本节课中,我们一起学习了如何对代数数据类型进行归纳证明。关键要点是,我们可以以与在离散数学课程中对自然数进行归纳非常相似的方式,对代数数据类型进行归纳。我们通过定义一个表示自然数的变体类型,并对其上的加法函数进行归纳证明,展示了这一过程。

098:列表归纳法 🧾

在本节课中,我们将学习如何对列表(List)这种数据类型进行归纳证明。上一节我们介绍了对自定义自然数类型(NAP)的归纳法,本节中我们来看看如何将同样的逻辑应用到列表上。

列表归纳法的结构与之前所学的非常相似。

证明结构

归纳证明的基础情况是空列表。这类似于自然数归纳法中的零(Z构造器)或数学自然数中的数字0。我们需要证明性质 P 在基础情况(即空列表)上成立。

归纳情况则考虑如何构造一个“更大”的列表。在自然数中,“更大”意味着加一;在列表中,“更大”意味着在列表头部添加一个新元素。因此,在归纳情况下,列表的形式是 h :: t,其中 t 是原类型中更小的元素(即尾部)。归纳假设是性质 P 在这个更小的值 t 上成立。我们需要证明性质 P 对整个列表 h :: t 也成立。

实践:证明一个性质

让我们运用这个结构来证明一个性质:将一个列表与空列表进行 append 操作,会返回原列表本身。

以下是 append 操作的源代码(以中缀运算符形式表示)以及我们要证明的命题:

let rec ( @ ) lst1 lst2 =
  match lst1 with
  | [] -> lst2
  | h :: t -> h :: (t @ lst2)

(* 要证明的性质:对于所有列表 lst, lst @ [] = lst *)

我们将通过归纳法来证明此性质。

基础情况

基础情况针对空列表 []。我们需要证明 [] @ [] = []

这很容易证明,只需一步求值:append 运算符会对其左参数进行模式匹配,发现它是空列表,于是直接返回其右参数(在这里也是 [])。因此等式成立。

归纳情况

现在考虑归纳情况。列表形式为 h :: t,其中 t 是一个更小的列表。我们需要证明 (h :: t) @ [] = h :: t

以下是证明步骤:

  1. 根据 append 的定义,当左参数是 h :: t 时,结果为 h :: (t @ [])。因此,(h :: t) @ [] 求值得到 h :: (t @ [])
  2. 此时,我们无法直接对 t @ [] 进一步求值,因为不知道 t 的具体形式。
  3. 这时,我们的归纳假设就派上用场了。归纳假设是性质 P 对更小的列表 t 成立,即 t @ [] = t
  4. 将归纳假设应用到表达式 h :: (t @ []) 中,用 t 替换 t @ [],我们得到 h :: t

这正是我们在归纳情况下想要证明的结果。证明完成。

与自然数归纳法的对比

接下来,我们可以做一个有趣的对比:将刚才的列表归纳证明,与之前对自然数证明 n + 0 = n 的过程进行比较。

以下是两个证明的并排对比:

自然数证明 (n + 0 = n) 列表证明 (lst @ [] = lst)
运算符 + 对第一个参数进行模式匹配。 运算符 @ 对第一个参数进行模式匹配。
基础情况(Zero):返回第二个参数。 基础情况([]):返回第二个参数。
递归情况(Succ n):调用 n + m 递归情况(h::t):调用 t @ lst2
证明:n + 0 = n 证明:lst @ [] = lst
证明结构:归纳法。 证明结构:归纳法。
证明步骤(归纳情况):求值后应用归纳假设。 证明步骤(归纳情况):求值后应用归纳假设。

可以看到,两个证明的结构完全一致,甚至归纳情况中的证明步骤(先求值,再应用归纳假设)也完全相同。这本质上是在两种不同的数据类型(一次是自然数 Nat,另一次是列表 list)上进行的同一个证明。

总结

本节课中我们一起学习了如何对列表进行归纳证明。我们首先回顾了归纳证明的一般结构(基础情况和归纳情况),然后将其具体应用到列表上,证明了 lst @ [] = lst 这一性质。最后,通过对比列表归纳与自然数归纳的证明过程,我们发现其核心逻辑是相通的,这体现了归纳法作为证明技巧的强大通用性。掌握这种对递归数据结构的归纳推理方法,对于理解和验证函数式程序的正确性至关重要。

099:列表长度与追加的归纳证明示例 🧮

在本节课中,我们将学习如何对列表进行归纳证明。具体来说,我们将证明一个关于列表长度和列表追加操作的定理:两个列表追加后的长度,等于这两个列表各自长度的和。

概述

我们将要证明的定理是:对于任意两个列表 xsyslength (xs @ ys) = length xs + length ys 成立。这可以理解为长度操作在特定方式下对追加操作具有分配律。

为了证明这个定理,我们将采用归纳法。但首先需要决定对哪个变量进行归纳。一个关键的线索是观察 @(追加)操作符的模式匹配方式:它对其左参数进行模式匹配。这通常意味着,在进行关于追加操作的归纳证明时,我们希望对作为该操作符左参数的变量进行归纳。在本例中,左参数是 xs,因此我们将对 xs 进行归纳。

定义性质 P

随着证明的深入,明确我们要证明的命题以及归纳性质 P 变得愈发重要。由于我们对 xs 进行归纳,性质 P 应该是关于 xs 的一个命题,即原命题中剩余的部分:对于所有的 yslength (xs @ ys) = length xs + length ys 成立。

因此,我们将证明:∀ ys, length (xs @ ys) = length xs + length ys

基础步骤

首先,我们处理基础情况,即 xs 为空列表 [] 的情况。

我们需要证明:∀ ys, length ([] @ ys) = length [] + length ys

以下是证明步骤:

  1. 根据 @ 操作符的定义,当左参数为空列表时,[] @ ys 直接返回 ys。因此,等式左边简化为 length ys
  2. 根据 length 函数的定义,length [] 返回 0。因此,等式右边简化为 0 + length ys
  3. 根据加法运算,0 + length ys 等于 length ys

至此,我们证明了在基础情况下,等式左右两边相等,均为 length ys。基础步骤完成。

归纳步骤

现在,我们进入归纳步骤。我们假设归纳假设(IH)对某个列表 t 成立,即:
∀ ys, length (t @ ys) = length t + length ys

我们需要证明,对于由头元素 h 和尾列表 t 构成的列表 h :: t,性质 P 也成立。即,我们需要证明:
∀ ys, length ((h :: t) @ ys) = length (h :: t) + length ys

以下是证明过程:

  1. 处理等式左边:根据 @ 操作符的定义,(h :: t) @ ys 会求值为 h :: (t @ ys)。因此,等式左边变为 length (h :: (t @ ys))
  2. 根据 length 函数的定义,length (h :: (t @ ys)) 会求值为 1 + length (t @ ys)
  3. 此时,表达式 length (t @ ys) 出现了。这正是我们的归纳假设(IH)中左边部分的形式。根据归纳假设,我们可以将其替换为 length t + length ys
  4. 经过替换,等式左边最终简化为 1 + (length t + length ys)

  1. 处理等式右边:根据 length 函数的定义,length (h :: t) 求值为 1 + length t。因此,等式右边变为 (1 + length t) + length ys
  2. 根据加法的结合律,(1 + length t) + length ys 等于 1 + (length t + length ys)

现在,等式左边和右边都化简为相同的表达式 1 + (length t + length ys)。因此,我们证明了在归纳步骤中,等式也成立。

总结

本节课中,我们一起学习了如何对列表进行归纳证明。我们通过一个具体的例子,证明了列表长度函数对追加操作满足分配律:length (xs @ ys) = length xs + length ys

证明的关键步骤包括:

  • 选择归纳变量:根据操作符的模式匹配特性,选择对 @ 的左参数 xs 进行归纳。
  • 明确定义性质 P:将定理表述为关于归纳变量 xs 的性质:∀ ys, length (xs @ ys) = length xs + length ys
  • 完成基础步骤:证明当 xs 为空列表 [] 时,性质成立。
  • 完成归纳步骤
    • 假设性质对较小的列表 t 成立(归纳假设)。
    • 证明对于列表 h :: t,性质也成立。这通常涉及根据函数定义进行求值化简,并巧妙地应用归纳假设。

这个证明展示了归纳法在验证递归函数性质时的强大能力,是理解和构建正确程序的重要工具。QED(证明完毕)。

100:树上的归纳法 🌳

在本节课中,我们将要学习如何对树这种更复杂的数据结构进行归纳证明。我们将看到,虽然树的结构比列表或自然数更复杂,但归纳法的核心思想是相通的。

概述

上一节我们介绍了对列表和自然数的归纳法。本节中,我们来看看如何将归纳法应用于二叉树。我们将学习归纳证明的格式,并通过一个具体的例子——证明树中叶子节点的数量等于节点数量加一——来巩固理解。

树的结构与归纳法格式

我们之前已经见过如何对自然数和列表进行归纳。那么对于更复杂的变体呢?当然,你也可以对那些结构进行归纳。

让我们看看如何对树进行归纳。请记住,我们在这里定义的二叉树与列表并没有太大不同。它只是多携带了一个部分:列表携带一个子列表,而二叉树携带两个子树,这就是全部的区别。

因此,对树进行归纳证明的格式与对列表或自然数的归纳证明非常相似。

假设你想要证明一个性质 P 对所有树 t 都成立。并且你想通过对 t 进行归纳来证明。

首先,存在一个基础情况,即 t 是最小的可能树。在这个例子中,就是 Leaf,因为它在这里代表空树。就像对于列表,我们有最小的可能列表 Nil;对于自然数,我们有最小的自然数 0。所以我们需要证明性质 P 在这个最小值(即 Leaf)上成立。

我们还有一个归纳情况。在这种情况下,树更大。实际上,它比之前的某个东西大一个单位。就像我们有一个自然数的后继,或者一个在开头 cons 了某物的列表一样,这里我们有一个由两个较小的树构成的树。在这个例子中,必须是两个,因为我们讨论的是二叉树。所以,我们有一个 Node,它有一个左子树、一个右子树以及一个存储在该节点的值。

我们将尝试证明性质 P 对于用 Node 构造的这个更大的树成立。与之前不同的是,我们得到了两个归纳假设。我们实际上可以将性质 P 应用于该类型的两个较小的值。这在以前从未发生过,因为我们之前只有一个较小的值(一个自然数,或者列表的尾部那个较小的列表)。这里我们有一个左子树和一个右子树,因此我们可以假设性质 P 对左子树和右子树都成立。

一个例子:计算树中的叶子和节点数量

让我们通过计算树中叶子和节点的数量来做一个例子。

树中节点的数量将是我们遍历树时遇到 Node 构造函数的次数。树中叶子的数量将是我们找到 Leaf 构造函数的次数。所以叶子都在底部,节点都在中间和顶部。

我想要证明的命题是:树中叶子的数量等于节点数量加一。事实上,如果你想暂停一下,画几棵树,你可能会在一些例子中确信这一点成立。但我们如何严格地证明它呢?

以下是证明的步骤:

  1. 定义函数:首先,我们定义两个递归函数来计算叶子数和节点数。

    let rec leaves = function
      | Leaf -> 1
      | Node (_, l, r) -> leaves l + leaves r
    
    let rec nodes = function
      | Leaf -> 0
      | Node (_, l, r) -> 1 + nodes l + nodes r
    
  2. 陈述命题:对于任意树 t,证明 leaves t = 1 + nodes t。我们将通过对 t 的结构归纳来证明。

基础情况:t = Leaf

我们需要证明 leaves Leaf = 1 + nodes Leaf

根据 leaves 函数的定义,当模式匹配到 Leaf 时返回 1。所以 leaves Leaf 求值为 1

根据 nodes 函数的定义,当模式匹配到 Leaf 时返回 0。所以 nodes Leaf 求值为 0

因此,右边 1 + nodes Leaf 等于 1 + 0,即 1

左边 leaves Leaf 也等于 1。两边相等,基础情况得证。

归纳情况:t = Node (v, l, r)

归纳假设:假设性质对左子树 l 和右子树 r 成立。即:

  • leaves l = 1 + nodes l (归纳假设 L)
  • leaves r = 1 + nodes r (归纳假设 R)

我们需要证明:leaves (Node (v, l, r)) = 1 + nodes (Node (v, l, r))

证明过程:

从左边开始:
leaves (Node (v, l, r))
根据 leaves 函数的定义,匹配到 Node 时,返回 leaves l + leaves r
所以,左边等于 leaves l + leaves r

根据归纳假设 L 和 R,我们可以将其替换为:
(1 + nodes l) + (1 + nodes r)

现在处理右边:
1 + nodes (Node (v, l, r))
根据 nodes 函数的定义,匹配到 Node 时,返回 1 + nodes l + nodes r
所以,右边等于 1 + (1 + nodes l + nodes r),即 2 + nodes l + nodes r

现在比较左右两边:
左边:(1 + nodes l) + (1 + nodes r) = 2 + nodes l + nodes r
右边:2 + nodes l + nodes r

左右两边完全相等。因此,在归纳情况下,命题也成立。

总结

本节课中我们一起学习了如何对二叉树进行归纳证明。我们了解到,其基本格式与列表归纳法相似,但关键区别在于归纳情况中,由于一个 Node 包含两个子树,因此我们获得两个归纳假设。我们通过一个具体的命题——树中叶节点数等于内部节点数加一——完整演示了基础情况和归纳情况的证明步骤,巩固了对树归纳法的理解。掌握这种方法对于证明关于递归树形数据结构的性质至关重要。

101:树的前序遍历长度证明示例 🌳

在本节课中,我们将学习如何对树结构进行归纳证明。具体来说,我们将证明一个关于二叉树前序遍历的重要性质:前序遍历结果列表的长度等于树中节点的数量

概述

我们将通过一个具体的例子来演示归纳法在树结构上的应用。证明的目标是:对于任意二叉树 t,其前序遍历列表的长度 length (preorder t) 等于树的大小 size t。这里的 size 函数计算树中的节点总数。

证明目标与定义

首先,我们明确要证明的命题 P(t)

P(t): length (preorder t) = size t

其中,preorder 函数执行前序遍历:先访问节点的值,然后递归遍历左子树,最后递归遍历右子树。size 函数计算树中的节点数。

以下是相关的OCaml代码定义:

type 'a tree = Leaf | Node of 'a * 'a tree * 'a tree

let rec preorder = function
  | Leaf -> []
  | Node (v, l, r) -> v :: (preorder l @ preorder r)

let rec size = function
  | Leaf -> 0
  | Node (_, l, r) -> 1 + size l + size r

证明过程:归纳法

我们将对树的结构进行归纳证明。

基础情况:t = Leaf

当树是叶子节点时,我们需要证明 P(Leaf) 成立,即 length (preorder Leaf) = size Leaf

  1. 计算左边 length (preorder Leaf)
    • 根据 preorder 定义,preorder Leaf 求值为 []
    • length [] 求值为 0
  2. 计算右边 size Leaf
    • 根据 size 定义,size Leaf 求值为 0

因此,左边等于右边(0 = 0),基础情况得证。

归纳情况:t = Node (v, l, r)

现在,我们进入归纳步骤。假设对于任意子树 lr,归纳假设 P(l)P(r) 成立:

  • IH_l: length (preorder l) = size l
  • IH_r: length (preorder r) = size r

我们需要证明 P(Node (v, l, r)) 也成立,即:
length (preorder (Node (v, l, r))) = size (Node (v, l, r))

证明左边表达式:

  1. 根据 preorder 定义,展开一步:
    length (preorder (Node (v, l, r))) = length (v :: (preorder l @ preorder r))

  2. 此时,我们需要一个关键的引理(已在之前的课程中证明过):length 函数在列表连接操作上是可分配的。即对于任意列表 xsys,有:
    length (xs @ ys) = length xs + length ys

  3. v :: (preorder l @ preorder r) 视为 [v] @ (preorder l @ preorder r),并应用上述引理:
    length ([v] @ (preorder l @ preorder r)) = length [v] + length (preorder l @ preorder r)

  4. length [v] 很容易计算,它等于 1

  1. 再次对 length (preorder l @ preorder r) 应用分配引理:
    length (preorder l @ preorder r) = length (preorder l) + length (preorder r)

  2. 现在,我们可以应用归纳假设 IH_lIH_r
    length (preorder l) + length (preorder r) = size l + size r

  3. 综合以上步骤,左边表达式最终简化为:
    1 + size l + size r

证明右边表达式:

根据 size 函数的定义,直接计算:
size (Node (v, l, r)) = 1 + size l + size r

得出结论:

左边表达式 1 + size l + size r 与右边表达式 1 + size l + size r 完全相同。因此,在归纳假设成立的前提下,归纳情况 P(Node (v, l, r)) 也成立。

总结

本节课中,我们一起学习了如何对二叉树进行归纳证明。我们证明了 length (preorder t) = size t 这一性质。证明过程清晰地展示了:

  1. 基础情况的处理:直接根据函数定义求值。
  2. 归纳步骤的构建:假设子性质成立,证明当前性质。
  3. 关键引理的应用:利用 length 对列表连接的分配律来简化表达式。
  4. 归纳假设的使用:将复杂表达式替换为已知的等价形式,最终使等式两边相等。

这个证明是函数式编程中利用结构归纳法验证程序性质的典型范例。通过这样的练习,我们可以更深入地理解递归数据结构和递归函数的正确性。

102:归纳与递归

在本节课中,我们将探讨归纳法为何有效,理解其背后的原理,并揭示归纳证明与递归程序之间的深刻联系。

归纳原理

上一节我们介绍了如何进行归纳证明,本节中我们来看看这些证明为何有效。每个我们定义的数据类型都附带一个归纳原理。归纳原理告诉我们如何为该类型构建归纳证明,即需要展示哪些基本情况、归纳情况,以及在归纳情况中可以假设什么(即归纳假设)。

以下是自然数的归纳原理公式:

对于任意性质 P,如果 P(0) 成立,并且对于任意 kP(k) 蕴含 P(k+1),那么可以得出结论:对于所有自然数 nP(n) 都成立。

为何归纳法有效

为什么这是一个有效的推理方式?一个直观的比喻是多米诺骨牌

  • P(0) 成立意味着你能推倒第一块骨牌。
  • P(k) ⇒ P(k+1) 意味着如果第 k 块骨牌倒下,它会导致第 k+1 块骨牌也倒下。

因此,如果你能推倒第一块骨牌,并且确保每块倒下的骨牌都会推倒下一块,那么你就能推倒所有的骨牌。这就是归纳法有效的核心思想。

不同数据类型的归纳原理

以下是其他常见数据类型的归纳原理。

自定义自然数类型 nat

对于自定义的自然数类型(例如 ZS of nat),其归纳原理与标准自然数几乎相同:

如果 P(Z) 成立,并且对于任意 kP(k) 蕴含 P(S(k)),那么 P 对所有 nat 类型值成立。

列表类型

列表的归纳原理如下:

如果 P([]) 成立,并且对于任意头部元素 x 和尾部列表 tP(t) 蕴含 P(x :: t),那么 P 对所有列表成立。

这可以理解为:从空列表这个“最小”情况开始,如果我们能证明性质对任何列表 t 成立意味着它对添加了一个元素的列表 x :: t 也成立,那么性质就对所有列表成立。

二叉树类型

树的归纳原理稍复杂,但本质相同:

如果对于任意值 vP(Leaf v) 成立,并且对于任意节点值 a、左子树 l 和右子树 rP(l)P(r) 同时成立能蕴含 P(Node (a, l, r)) 成立,那么 P 对所有树成立。

多米诺骨牌的比喻在此依然适用,只是需要想象树叶是后方的骨牌,而任何两个倒下的子树(分支)会共同推倒它们上层的父节点,最终推倒整棵树的根节点。

归纳与递归的关系

最后,我们来探讨归纳证明与递归程序之间一个有趣且重要的关系。我认为归纳证明就像递归程序。事实上,它们之间存在紧密的对应关系。

让我们比较一下证明和程序,并思考我们定义的数据类型。

证明案例与程序分支

在证明中,我们通常为数据类型的每个构造函数准备一个证明案例。

  • 对于列表,我们有 Nil 的基本情况和 Cons 的归纳情况。
  • 对于树,我们有 Leaf 的基本情况和 Node 的归纳情况。

类似地,在编写操作这些数据类型的函数时,我们通常在该函数中为每个构造函数准备一个模式匹配分支。

  • 计算列表长度时,有 []x :: xs 的分支。
  • 计算树的大小时,有 LeafNode 的分支。

归纳假设与递归调用

在证明中,我们为所涉及的每个“更小”的值获得一个归纳假设。

  • 对于列表,我们获得一个关于尾部 t 的归纳假设。
  • 对于树,我们获得两个归纳假设,分别关于左子树 l 和右子树 r

同样,在程序中编写操作这些数据类型的函数时,我们通常为每个更小的值进行一次递归调用。

  • 计算列表长度时,对尾部 xs 进行一次递归调用。
  • 计算树的大小时,对左子树 l 和右子树 r 各进行一次递归调用。

因此,归纳证明和递归程序在结构上具有高度的相似性。

总结

本节课中我们一起学习了:

  1. 归纳原理是每个数据类型自带的推理规则,规定了归纳证明的格式。
  2. 归纳法的有效性可以通过多米诺骨牌的比喻来直观理解。
  3. 我们查看了自然数列表等不同数据类型的归纳原理。
  4. 我们揭示了归纳证明递归程序之间深刻的对应关系:证明案例对应程序分支,归纳假设对应递归调用。理解这种关系有助于我们同时掌握如何证明程序的正确性和如何编写正确的程序。

103:完全正确性 🎯

在本节课中,我们将要学习程序正确性证明的最后一个关键概念:终止性。我们将探讨部分正确性与完全正确性的区别,并学习如何证明递归函数会终止。

概述

到目前为止,我们讨论的都是如何证明程序的正确性。然而,我们之前所讨论的实际上被称为部分正确性。部分正确性意味着:如果一个程序终止,那么它的输出是正确的。

还存在一个完全正确性的概念。完全正确性意味着程序必定会终止,并且它的输出是正确的。因此,完全正确性实际上是部分正确性加上终止性。

终止性证明的困难

证明程序的终止性非常困难。这也是为什么我们之前实际上只专注于部分正确性。事实上,艾伦·图灵在1936年就证明了:不存在一个通用的算法,能够判定其他所有算法是否会终止。

作为人类,我们或许能够通过巧妙的数学证明来论证程序会终止,但计算机无法在所有情况下独立完成这件事。如果你想了解更多,可以搜索“停机问题”或学习相关的计算理论课程。

一个有用的启发式方法

尽管存在困难,但仍有一些启发式方法可以提供帮助。以下是一个在我们目前所做的证明类型中非常有用的方法:

一个递归函数会终止,如果以下两个条件同时成立:

  1. 它进行的所有递归调用都是在一个“更小”的输入上进行的。
  2. 所有的基本情况都保证会终止。

这里需要仔细定义“更小”和“基本情况”的含义。让我们先看一个例子。

阶乘函数示例

我们之前多次看过阶乘函数。我们如何保证它会终止?

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

以下是分析:

  • 递归调用:函数在 n - 1 上调用自身,这比原始的输入 n 更小。
  • 基本情况:当 n = 0 时,函数直接返回 1,这是一个终止的基本情况。

因此,这个递归函数对所有自然数输入都会终止。因为当我们传入一个自然数 N 时,我们要么在下一个更小的自然数 n-1 上递归,要么最终到达最小的自然数 0

然而,对于所有整数,这个结论并不成立。假设你用 -1 调用 fact 函数,那么程序将不断在 -2-3 等值上递归调用,因为代码中没有处理 n 为负数的可能性。实际上,如果我们为这个函数编写规范,n >= 0 应该是一个前置条件

定义“更小”与良基关系

现在,让我们更严谨地定义“更小的输入”和“基本情况”。

我们假设在函数的输入类型上存在一个小于关系,用来刻画我们所说的“更小”。这个关系不一定非得是整数或自然数上的“小于”关系,但如果输入类型恰好是这些,那它可以是。对于列表,一个“更小的”列表可能包含更少的元素;对于树,一个“更小的”树可能拥有更少的节点或子树。

我们要求,根据这个顺序关系,不存在无限递减链。无限递减链指的是该类型元素的一个序列 x0, x1, x2, x3, ...,其中第一个比第二个大,第二个比第三个大,依此类推,形成一个无限向下的链条。

如果不存在无限递减链,那么根据这个关系,你最终总会到达某个“底部”,你无法无限地变得更小。在数学上,这使得我们所考虑的“小于”关系成为一种良基关系。直观理解“良基”就是:你总是能够到达底部。

  • 自然数集是良基的:从任何自然数开始,通过每次减一不断变小,最终你总会到达 0
  • 数学上的整数集不是良基的:因为你可以无限地向负方向递减(... -3, -2, -1)。

良基关系蕴含终止性

在上述我们讨论的启发式方法条件下,良基关系蕴含着终止性

如果你在递归操作的类型上有一个良基关系,那么只要你从任何输入 n 开始,并且能在代码中保证每一次递归调用都是在某个更小的值 m 上进行的(即 m < n),那么最终你必定会到达一个基本情况

原因正是良基关系的定义:不存在无限递减链。递归调用序列构成了一个递减链,由于链不能无限长,所以过程必然终止。

总结

本节课中,我们一起学习了:

  1. 部分正确性完全正确性的区别:后者要求程序必须终止。
  2. 证明终止性的一般困难性,以及图灵关于停机问题的结论。
  3. 一个用于证明递归函数终止的实用启发式方法:确保递归调用作用于“更小”的输入,并且所有基本情况都能终止。
  4. “更小”的精确定义依赖于良基关系,该关系要求类型上不存在无限递减链。
  5. 如果递归调用的参数在某个良基关系下不断减小,那么函数必定会终止。

理解终止性是确保程序完全正确性的关键一环,它结合了我们之前学习的逻辑推理,为程序验证画上了一个完整的句号。

104:栈的等式规范 🧮

在本节课中,我们将学习一种称为等式规范代数规范的接口描述方法。我们将以栈接口为例,通过一系列等式来精确地定义其操作行为,并了解如何利用这些等式进行推理和证明。

代数规范入门

上一节我们介绍了接口规范的一般概念,本节中我们来看看一种更形式化的规范方法——等式规范。让我们从熟悉的数据结构——栈开始。

以下是栈接口的等式规范。请注意,这些规范中没有使用 option 类型,这意味着操作可能会引发异常。规范中的等式描述了不同操作之间如何相互作用。

以下是定义栈行为的四个核心等式:

  1. is_empty empty = true
    这个等式表明,对空栈应用 is_empty 操作会返回 true

  2. is_empty (push x s) = false
    这个等式表明,对一个至少包含一个元素 x 的栈(通过 push 操作得到)应用 is_empty 操作会返回 false

  3. peek (push x s) = x
    这个等式表明,如果刚刚将元素 x 压入栈 s,那么对结果栈应用 peek 操作将返回 x

  4. pop (push x s) = s
    这个等式表明,pop 操作会移除刚刚通过 push 操作压入栈顶的元素 x,并返回原始的栈 s

这四个等式共同刻画了栈操作的交互方式。值得注意的是,每个等式的右边都比左边更简单,因此这些等式不仅定义了行为,也指明了如何简化表达式。

利用等式规范进行证明

我们可以利用这种等式规范,基于我们之前见过的等式推理来进行证明。

例如,假设我们想证明下面这个复杂的表达式最终会简化为 2

peek (pop (push 1 (push 2 empty)))

证明过程如下:

  1. 首先,注意到表达式中间有 pop 紧邻着 push 1
  2. 根据等式4,poppush 可以相互抵消,留下原始栈。因此,表达式简化为:
    peek (push 2 empty)
    
  3. 接着,根据等式3,peek 会抵消掉 push,直接返回被压入的元素。因此,最终结果为:
    2
    

这是一个非常简单的证明。我们不需要了解底层的实现细节,也不需要知道栈在数学或抽象意义上的含义,只需使用等式规范即可完成推理。

为何称为“代数”规范?

之所以称为代数规范,是因为它类似于代数课程(无论是高中数学还是大学的抽象代数)中描述数学运算行为的等式。

你可能见过描述加法、减法、乘法等运算的等式。要正确指定整数、多项式或矩阵等数学对象的运算行为,它们必须遵循特定的等式。这里的原理是相同的:我们只是写下了一系列等式,来刻画一个“正确的”栈应该具备的行为,而不是正确的算术运算。

验证实现是否符合规范

给定一个具体的实现,我们现在可以尝试证明该实现是正确的,即它满足其规范。此时,我们使用的规范就是栈的等式规范。

以下是一个使用列表实现的、非常简单的栈:

type 'a stack = 'a list

let empty = []
let is_empty s = s = []
let push x s = x :: s
let peek = function
  | [] -> failwith "Empty stack"
  | x :: _ -> x
let pop = function
  | [] -> failwith "Empty stack"
  | _ :: s -> s

根据上述等式规范,所有这些实现都是正确的。如何证明呢?我们需要针对每个等式,展示在使用这些具体函数作为实现时,等式的左边确实会简化为右边。

对于这个实现,证明非常简单,因为所有规范中的等式都直接通过该实现的求值过程得以满足。

让我们简要地看一个例子:第四个等式,即 poppush 相互抵消。
在这个实现中,push 对应 cons 操作(::),pop 对应 tail 操作(取列表尾部)。当我们对 push x s(即 x :: s)应用 pop(即取尾部)时,根据 tail 的定义,我们恰好得到原始的栈 s

请注意,此实现允许引发异常(之前我们排除了这种情况),但同样请注意,只要栈中至少有一个元素(这正是 push 操作所保证的),这段代码就永远不会引发异常。

总结

本节课中,我们一起学习了等式规范(或代数规范)的概念。我们以栈接口为例,通过四个核心等式精确地定义了 emptyis_emptypushpeekpop 操作的行为。我们了解到,这种规范不仅描述了操作如何交互,还提供了一种简化表达式和进行形式化证明的清晰路径。最后,我们看到了如何利用这种规范来验证一个具体的列表实现是否符合栈的预期行为。等式规范是一种强大而优雅的工具,它将数据结构的抽象行为以简洁、可推理的数学形式呈现出来。

105:队列的等式规范 📚

在本节课中,我们将学习如何为队列数据结构编写等式规范。我们将看到,与栈相比,队列的规范更为复杂,这主要是因为队列操作的行为取决于队列是否为空。我们将通过一个生动的“路易斯午餐车”排队例子来理解这些规范,并探讨如何用双列表结构实现队列,以及如何证明其正确性。


上一节我们介绍了栈的等式规范,本节中我们来看看如何为队列编写类似的规范。

队列的等式规范与栈的规范在类型上看起来相似,但核心区别在于操作的行为。仅仅改变模块和操作的名称是不够的,我们需要更详细的等式来描述队列的独特行为。

以下是队列操作的核心等式规范:

  1. is_empty empty = true
    空队列的 is_empty 操作返回真。

  2. is_empty (enq x q) = false
    对一个元素执行入队操作后,队列不再为空。

  3. front (enq x q) = if is_empty q then x else front q
    这个等式描述了 frontenq 的交互。它说明,新元素入队后,队首元素取决于原队列是否为空。

  4. deq (enq x q) = if is_empty q then empty else enq x (deq q)
    这个等式描述了 deqenq 的交互。它说明了入队和出队操作的顺序可以交换,但结果取决于队列的初始状态。

让我们深入理解第三和第四个等式。

理解 frontenq 的交互 🍔

假设你正要去路易斯的午餐车买饭。第三个等式告诉我们关于排队的情况。

  • 如果队列原本是空的(没人排队),那么你入队后,你就直接站在了队伍的最前面(队首)。即 front (enq x empty) = x
  • 如果队列原本不空(前面已经有人排队),那么你入队后,会站在队伍末尾。队首仍然是原来那个人,不会改变。即 front (enq x q) = front q(当 q 不空时)。

理解 deqenq 的交互 🔄

现在来看第四个等式,它描述了出队和入队操作的交互。

  • 场景一:先入队,再出队

    1. 你到达时队伍为空。
    2. 你入队(enq)。
    3. 然后你出队(deq)——因为你前面没人,你立刻得到服务。
    4. 结果队列变空。这对应等式 deq (enq x empty) = empty
  • 场景二:队伍不空时的交互
    假设初始队列 q 中有三个人:[A; B; C](A在队首)。

    • 等式左边deq (enq you q)
      1. 你先入队(enq),队伍变为 [A; B; C; you]
      2. 然后队首A出队(deq),队伍变为 [B; C; you]
    • 等式右边enq you (deq q)
      1. 队首A先出队(deq),队伍变为 [B; C]
      2. 然后你入队(enq),队伍变为 [B; C; you]

    可以看到,无论先入队再出队,还是先出队再入队,最终得到的队列 [B; C; you] 是一样的。这就是第四个等式的含义。


队列的实现:双列表队列 📝

我们可以用非常简单的列表来实现队列,但更高效和经典的是双列表队列实现。

这种实现使用两个列表 (f, b) 来表示一个队列。整个队列的元素顺序是:前列表 f,后接后列表 b 的逆序。即队列 = f @ (List.rev b)

它有一个关键的表示不变式只有当 f 为空时,b 才必须为空。这保证了操作的效率。

以下是核心操作的OCaml代码实现:

type ‘a queue = ‘a list * ‘a list (* (front, back) *)

let empty = ([], [])

let is_empty (f, _) = f = []

let enq x (f, b) =
  if f = [] then ([x], []) else (f, x :: b)

let front (f, _) = List.hd f (* 可能抛出异常 *)

let deq (f, b) =
  match f with
  | [] -> failwith “deq: empty queue” (* 可能抛出异常 *)
  | [_] -> (List.tl f, List.rev b) (* f只剩一个元素,出队后需将b反转作为新f *)
  | _::fs -> (fs, b) (* f有多个元素,直接出队 *)

关于异常的重要说明:我们的等式规范没有定义对空队列进行 frontdeq 操作的行为。因此,在实现中,我们可以选择抛出异常,而这在基于该规范的证明中无需考虑。


证明与抽象函数 🔬

证明双列表队列实现符合等式规范需要大量工作,其中两个概念至关重要:

  1. 表示不变式:这是每个数据抽象操作的前置条件。例如,在 enq 的实现中,我们假设传入的队列 (f, b) 满足不变式(若 f 为空,则 b 为空)。这个假设对于证明 enq 的正确性必不可少。

  1. 抽象函数:这是连接具体表示(双列表)和抽象概念(队列)的桥梁。它定义为:
    abs (f, b) = f @ (List.rev b)

在证明过程中,我们可能会遇到两个具体的双列表对 (f1, b1)(f2, b2) 在OCaml代码层面不相等,但它们代表同一个抽象队列的情况。例如,我们需要证明:
(f, [x])(x::f, []) 在某种意义上是“相等”的。

此时,我们引入基于抽象函数的新等价关系
如果 abs(r1) = abs(r2),那么在等式证明中,我们可以认为具体表示 r1r2 是相等的。

这使我们能够断言,即使具体代码不同,只要它们通过抽象函数映射到同一个抽象队列,那么对于队列操作来说,它们就是等价的。这正是证明双列表队列实现正确性的关键。


本节课中我们一起学习了如何为队列编写等式规范,理解了其与栈规范的区别主要在于对“空状态”的依赖。我们通过午餐车排队的例子直观理解了 front/enqdeq/enq 的交互等式。最后,我们探讨了双列表队列的实现,并指出了在证明其正确性时,表示不变式抽象函数所扮演的核心角色。

106:方程设计方法论 🧮

在本节课中,我们将学习如何为数据结构设计方程规范。我们将探讨一种系统性的方法,通过区分不同类型的操作来生成方程,并理解如何确保方程能有效地简化表达式。


上一节我们介绍了方程规范的概念,本节中我们来看看如何系统地设计这些方程。这涉及到“规范形式”的概念。

“规范”一词意味着符合某种规则。这里的规则是:表达式应该只通过构建数据结构的方式来创建。

让我展示一个例子。假设你有一个只包含元素 2 的栈。构建这个栈的规范方式是简单地将 2 推入空栈。然而,存在许多非规范的方式来构建这个只包含 2 的栈,例如,先推入 2,再推入 1,然后将 1 弹出。你可以想象出无限多种这样的方式。

数据结构的每个值都可以仅通过创建这些规范形式的操作来生成。让我们为这类操作命名。

  • 生成器:指创建规范形式的操作。
  • 操纵器:指创建非规范形式的操作。
  • 查询:指创建与数据结构本身不同类型值的操作。

让我们用栈的签名来看一些例子,这也能解释为何我一直用颜色来编码这些操作。

以下是栈操作分类:

  • emptypush生成器(蓝色)。它们创建规范形式。empty 本身就是规范的,这是创建空栈的最简单方式。如果你有一个规范形式的栈,再推入一个元素,这是得到新栈的最简单方式,它仍然是规范的。
  • is_emptypeek查询(绿色)。它们不返回栈值:is_empty 返回布尔值,peek 返回一个 alpha 类型的值。
  • pop 是一个操纵器(红色)。它返回一个栈,但这并非构造该栈的最简单方式。因为如果你要弹出某个元素,你本可以一开始就不把它推入栈中。

那么,我们如何设计方程呢?我们取查询和操纵器生成器的笛卡尔积。

  1. 从左侧(查询/操纵器)选取一个操作,例如 is_empty
  2. 从右侧(生成器)选取一个操作,例如 empty
  3. 将这两个操作组合在一起,并巧妙地思考它们的结果应该是什么。

例如,is_empty(empty) 应该是什么?应该是 true。我们将其写为第一个方程。

让我们再做另一个例子。假设从绿色(查询)中选取 is_empty,从蓝色(生成器)中选取 push。现在,这个方程的左侧是 is_empty(push(x, s))。我们需要考虑它的结果。当然,这应该是 false。我们将其写为第二个方程。

让我们继续。选取另一个绿色查询,例如 peek,以及另一个蓝色生成器 push 与之配对。那么 peek(push(x, s)) 的结果应该是什么?我们知道它应该是 x。对于 pop 也可以进行类似的操作。

根据这个方法,仍然缺少几个方程,这些方程对应于可能引发错误的情况。我尚未说明对 empty 应用 peekpop 时会发生什么。这是有意为之的,因为我不想具体规定错误必须是什么。我们可以开发一个更丰富的规范,用方程详细说明这些情况,但这需要引入错误的概念,例如使用 option 类型。


让我们用另一个数据结构——集合——来举例说明。

这是一个集合的规范(或更准确地说,是签名)。我们有 emptyis_emptyaddmemremove。我可以将它们按生成器、操纵器和查询进行颜色编码。

以下是集合操作分类:

  • emptyis_empty 我们之前见过。
  • add 是一个生成器。如果你给我一个规范形式的集合,它可以返回一个集合。
  • mem 是另一个查询,因为它告诉我一个元素是否在集合中,但不返回集合。
  • remove 是一个操纵器,因为任何用 remove 构造集合的方式,你都可以不用它来构造。

我们如何在这里设计方程?我们取这个笛卡尔积:is_emptymem 是我们的查询,remove 是我们的操纵器,emptyadd 是我们的生成器。我们只需开始从这个笛卡尔积中选取配对,并思考该配对的方程应该是什么。

让我们开始操作。与 empty 配对的方程是简单的,这些对应于我们在栈和队列中已经见过的情况。

  • mem(x, empty) = falsex 不可能是空集的成员,所以这必须是 false
  • is_empty(empty) = true

对于 addmem 的配对,正如我们之前在队列中看到的,有时我们需要根据某个布尔条件是否成立来拥有两种不同版本的方程。这里也将是这种情况,它将基于我们检查成员资格的元素(这里是 y)是否与要添加到集合中的元素 x 相同。

以下是 memadd 的方程:

  • 如果 xy 是相同的元素,那么 y 是该集合的成员,因为我们刚刚将其作为 x 添加了。所以 mem(y, add(x, s)) = true
  • 如果它们不是相同的值,那么我们尚不知道 y 是否是 s 的成员。我们必须通过说“现在检查 y 是否实际在 s 中”来简化。我们不能在这里说 truefalse,我们必须以某种方式递归。所以 mem(y, add(x, s)) = mem(y, s)

对于 removeempty,我规定从空集中移除元素就是空集本身。它不会改变空集。我不会将其设为错误。

对于从至少包含一个元素 x 的集合中移除元素 y,再次,这将取决于 xy 是否相等。

以下是 removeadd 的方程:

  • 如果它们相等,我们可以去掉 add x,但我们仍然必须递归,因为 y 可能稍后也出现在 s 中(例如,之前也在某处添加过),所以我们必须继续从集合中移除它。所以 remove(y, add(x, s)) = remove(y, s)
  • 如果 xy 不相同,那么我们需要保留 add x,因为即使在我们移除 y 之后,它仍然需要是集合的一个元素,但我们必须向下递归。所以 remove(y, add(x, s)) = add(x, remove(y, s))


让我们回到这些方程应该如何真正简化表达式这一概念。也就是说,我们确实希望方程的右侧比左侧更简单。对于这里的最后一个方程,你可能会争辩说并非如此。我们有 removeadd,它们在这里有点交换了位置,但所有相同的项都还在。那么我到底简化了什么?

我简化的意义在于:我在右侧将非生成器(操纵器)应用到了一个比左侧更小的输入上。在左侧,我将 remove 应用到了一个庞大且涉及 add 的表达式上。在右侧,我将 remove 应用到了比那个表达式更小的东西上,即 add 内部的 s。这就是我简化的方式:我将那个非生成器推到了内部,希望如果我继续对更大的表达式这样做,最终将能够将其消除。

现在我有了集合的方程规范,我可以用它来验证一个实现,可能是一个基于列表的非常简单实现,也可能是一个基于红黑树的非常复杂的实现。


本节课中我们一起学习了如何通过区分生成器、操纵器和查询来系统地为数据结构设计方程规范。我们理解了规范形式的概念,并通过栈和集合的例子实践了如何生成简化表达式的方程。这种方法为验证不同实现的正确性提供了坚实的基础。

107:引用(Refs)入门 🧠

在本节课中,我们将要学习OCaml中的引用。引用是OCaml中实现可变状态的一种方式,它允许我们创建可以修改其内容的内存单元。我们将从基本概念开始,逐步了解如何创建、访问和修改引用。


什么是引用?

OCaml中的引用指向内存中一个类型化的位置。

有时它们被简称为 refsref cell

现在,我们开始接触可变性。😡 变量名与指针的绑定关系仍然是不可变的,但指针所指向的内存位置的内容,通过引用变得可变


回顾不可变值

让我们先回忆一下OCaml中不可变值的情况。

例如,我们可以有整数。3110 是一个 int 类型。它现在是匿名的,没有名字。

我们可以将它绑定到一个名字:

let x = 3110

现在 x 是一个值为 3110 的整数。我们知道不能改变 x 的值,尽管我们可以在作用域内创建同名变量来遮蔽它。


创建引用

引用通过标准库函数 ref 创建。你给它一个值,它就会创建一个指向内存中类型化位置的指针,其内容被初始化为你传递给 ref 的值。

ref 3110

现在我们有了一个 int ref,即一个指向整数的引用。它将始终指向一个整数,类型不能改变,但该内存位置的内容可以改变。

为了演示,我们需要将这个引用绑定到一个名字。

let y = ref 3110

现在我们有了一个值 y,它是一个指向整数的引用,其当前内容是 3110,但我们可以根据需要改变这些内容。


访问引用内容

首先,让我们看看 y 里面有什么。它是一个 int ref,内容是 3110

如果你想取出内容,需要使用解引用操作符,写作 !,通常读作 “bang”。

!y

这会从那个内存位置返回内容本身,而不是位置。注意这里的类型变化:当我们解引用 y 时,我们得到的是一个 int,而不是 int ref

这一点很重要,因为如果你想对该整数进行算术运算,必须先解引用它。x + y 无法通过类型检查,因为 xint,而 yint ref

正确的写法是:

x + !y

这允许我们将这两个整数相加。


修改引用内容

我们可以使用赋值操作符 := 来改变内存位置的内容。

y := 2110

注意,赋值操作符返回的是 unit 类型。unit 类型只有一个值 ()。本质上,OCaml 是在说:“是的,赋值已完成,内存位置已被修改。”

现在,如果我们查看 y,它仍然是一个 int ref。你看不出来,但它仍然指向最初的那个内存位置,只是该内存位置的内容已经改变,现在变成了 2110

所以,现在如果计算 x + !y,我们得到 5220,而不是之前的 6220


核心概念回顾

上一节我们介绍了引用的创建和修改,本节我们来总结一下核心机制。

当我们创建一个引用时,我们创建了一个指向内存中某个位置的指针。

该内存位置是类型化的。如果你创建了一个 int ref,你将永远无法向其中放入一个字符串。

当我们将该位置绑定到一个名字时,这个绑定关系(像往常一样)是不可变的,变得可变的只是内存位置的内容

所以,当我们写下 let x = ref 0 时,标准库函数 ref 会去创建一个新的内存位置来存储整数,并将 0 作为其初始内容放入其中。

  • 如果你解引用 x,你会得到内容。
  • 如果你给 x 赋值,你会得到 unit 值,仅表示赋值已经发生。
  • 如果你在那之后再次解引用 x,内容已经改变,现在是 1

x 本身将始终指向内存中的同一个位置(除非我们像往常一样进行遮蔽)。所以,改变的不是指针本身,而是指针所指向的内容


总结

本节课中,我们一起学习了OCaml中的引用。我们了解到:

  1. 引用(ref)是创建可变内存单元的方式。
  2. 使用 ! 操作符可以解引用,获取其当前值。
  3. 使用 := 操作符可以赋值,修改引用的内容。
  4. 变量名与引用本身的绑定是不可变的,但引用指向的内容是可变的。

理解引用是掌握OCaml中命令式编程可变状态的关键第一步。

108:引用的语法与语义 🔍

在本节课中,我们将要学习OCaml中引用的语法和语义。引用是实现可变状态的核心机制,理解其工作原理对于编写高效的程序至关重要。

引用的创建

让我们首先仔细看看引用的语法和语义。引用通过 ref 函数创建,ref 是标准库中用于创建引用的函数。

公式ref E

为了求值 ref E,OCaml首先将表达式 E 求值为一个值 V

然后,它在内存中分配一个新的位置,我们称这个位置为 loc。值 V 将被存储在这个位置。

OCaml将这个内存位置作为调用 ref 函数的结果返回。

对于类型检查,我们现在有了一个新的类型构造器,它同样写作 ref。请注意不要混淆这两者:ref 既用于表示类型,也用于表示创建引用的函数。

T ref 是一个适用于任何类型 T 的类型。因此,你可以有 int refstring ref 或对变体类型的引用等。

公式:如果 E 具有类型 T,那么 ref E 具有类型 T ref

位置与值的关系

位置是值,但它们不是表达式。你不能直接在OCaml源文件中写下一个位置,也不能直接写下内存地址或在内存地址上进行算术运算。

正如我在第一周或第二周展示这张图时所警告的那样,这个维恩图并没有完全反映真相。在我们之前以及课程第一个月所看到的那些值中,值都是表达式。

但现在我们有了不是表达式的值。位置值不是我们可以在程序中直接写下的表达式。

因此,真相是维恩图应该看起来像这样:其中有些值是表达式,有些值则不是。

赋值操作符

赋值操作符写作 E1 := E2

公式E1 := E2

为了求值这样的赋值,首先将 E2 求值为值 V2

然后求值 E1。它必须产生一个位置 loc。接着将 V2 存储在该位置。

作为赋值的结果,返回 unit

为了对赋值进行类型检查,如果 E2 具有类型 T,并且 E1 具有类型 T ref,那么 E1 := E2 具有类型 unit

请注意,类型检查维护了存储在该引用处的类型。正如我们所说,存储在内存位置的值的类型不能改变。因此,将引用视为指向内存中类型化位置的指针是合适的。

关于Unit类型的更多讨论

让我们再谈谈 unit 类型。我们之前已经见过它几次。

unit 是一个类型,它唯一的值是写作括号 () 的 unit 值。由于它是其类型中唯一的值,因此没有有趣的操作可以对它进行。

如果你愿意,可以通过与布尔类型类比来理解 unitbool 是一个类型,它有两个值:truefalseunit 也是一个类型,它恰好比布尔类型少一个值,它只有一个值,那就是 unit

在你以前使用过的其他语言中,最接近的类比可能是 void。当你在Java或C中有一个没有有趣返回值的过程时,它的返回类型是 void。你可以将其视为一个单一的、无法进行任何有趣操作的值。

这与OCaml中的 printassert 类似。例如,在OCaml中执行 print_string,这是一个接收字符串并返回 unit 的函数;在其他语言中,它可能只是一个 void 返回类型。同样,如果你断言一个布尔表达式,其类型是 unit,除了断言失败时会引发异常外,不会发生任何有趣的事情。

解引用操作符

解引用操作符写作 !E,其中 E 是一个表达式。

公式!E

请注意,这不是逻辑非。在许多其他语言中,感叹号 ! 表示布尔非。但在这里不是,它表示解引用。

为了求值一个解引用,首先求值 E,它必须产生一个位置 loc,然后直接返回该位置的内容。

进行类型检查时,如果 E 具有类型 T ref,那么 !E 具有类型 T。感叹号操作符本质上是去掉了那个 ref


本节课中我们一起学习了OCaml引用的核心语法与语义。我们了解了如何通过 ref 创建引用,如何使用 := 进行赋值,以及如何使用 ! 进行解引用。我们还探讨了 unit 类型的含义,并理解了内存位置作为值(但非表达式)的特殊性。掌握这些概念是理解和使用OCaml中可变状态的基础。

109:分号操作符 🧩

在本节课中,我们将要学习OCaml中的分号操作符,也称为序列操作符。我们将了解它的语法、求值规则、类型检查以及它与let表达式的等价关系。掌握了引用之后,分号操作符将变得更加有用。

概述

分号操作符用于按顺序执行多个表达式,通常是为了利用它们的副作用(例如打印输出或修改引用)。整个序列的值是最后一个表达式的值,而前面表达式的值会被丢弃。

语法与求值规则

分号操作符的语法是 E1; E2,并且可以链接更多表达式,例如 E1; E2; E3; E4

以下是其求值过程:

  1. 首先,对表达式 E1 进行求值,得到值 V1
  2. 然后,丢弃 V1。我们之所以编写这样的表达式序列,通常只是为了利用它们的副作用,并不关心 E1 的值。
  3. 接着,对表达式 E2 进行求值,得到值 V2
  4. 最后,返回 V2 作为整个序列的值。

E2 也可能有副作用,我们同样关心这些副作用。但只有序列中最后一个表达式的值是有意义的。

类型检查

对于类型检查,如果 E1 的类型是 unit,并且 E2 的类型是 T,那么 E1; E2 的类型就是 T

因此,我们丢弃 E1 的值是合理的,因为类型检查要求它必须是 unit 类型。unit 类型只有一个值 (),所以这个值本身并不包含有趣的信息。

let 表达式的等价关系

从某种意义上说,分号操作符几乎只是一种语法糖。

表达式 E1; E2 的含义几乎等同于以下 let 表达式:

let () = E1 in E2

这个 let 表达式的求值过程是:先求值绑定表达式 E1 得到一个值,然后用 unit 模式 () 去匹配这个值(这本身没有实际意义),最后继续求值主体表达式 E2。这正是 E1; E2 所做的事情。

两者的区别在于当 E1 的类型不是 unit 时会发生什么。

  • 对于 let () = E1 in E2 语法,如果 E1 的类型不是 unit,你会得到一个类型错误,因为你试图用 unit 模式去匹配一个非 unit 类型的值。
  • 对于分号语法 E1; E2,如果 E1 的类型不是 unit,你不会得到错误,但会得到一个类型警告。类型检查器会警告你 E1 的类型不是 unit,因此你可能犯了一个错误,因为你可能并不想在序列中丢弃那个值。

忽略非 unit

如果你确实想丢弃一个非 unit 类型的值,标准库中内置了一个名为 ignore 的函数。它接受一个任意类型 'a 的值,并返回 unit。因此,如果你只关心某个表达式的副作用,而不关心其返回值,可以使用它来忽略该表达式产生的值。

函数签名如下:

val ignore : 'a -> unit

示例分析

让我们看一个使用分号的例子。假设我想编写一个有点奇怪的函数,它把两个整数相加,同时也打印出结果。

let print_and_add x y =
  print_int (x + y);
  x + y
  • print_and_add 的类型是 int -> int -> int。这个函数接受两个整数,然后返回一个整数。它碰巧也有副作用:会打印两个整数的和。
  • print_int 的类型是 int -> unit。它接受一个 int 并返回 unit。这就是为什么我能够成功地用分号将它作为操作序列的一部分链接起来。

但是,如果我想用分号链接 print_and_add 和另一个操作,可能会收到警告:

print_and_add 0 1; print_newline ()
(* 警告:这个表达式应该具有 unit 类型 *)

OCaml 抱怨的是:print_and_add 0 1 产生的值(即 1)仅仅因为它是分号链接的表达式序列的一部分而被丢弃了。

如果我确实想丢弃那个值,可以使用标准库函数 ignore

ignore (print_and_add 0 1); print_newline ()
(* 现在没有警告了 *)

总结

本节课中我们一起学习了OCaml中的分号操作符。我们了解到:

  1. 分号 ; 用于顺序执行表达式,主要目的是利用副作用。
  2. 序列的值是最后一个表达式的值,前面表达式的值被丢弃。
  3. 类型上,分号前的表达式通常应为 unit 类型,否则编译器会发出警告。
  4. 分号操作 E1; E2 在功能上近似于 let () = E1 in E2
  5. 可以使用 ignore 函数来显式丢弃非 unit 类型的值,以避免编译器警告。

理解分号操作符是编写具有副作用的OCaml程序(如执行输入/输出或修改可变状态)的关键一步。

110:别名与引用相等性 📚

在本节课程中,我们将学习OCaml中引用的一个重要概念:别名。我们将了解什么是别名,以及如何区分两个引用是否指向内存中的同一位置。理解这一点对于编写正确且可预测的程序至关重要。

概述

在上一节中,我们学习了如何使用引用(ref)来创建可变状态。本节中,我们将探讨当多个引用指向内存中同一位置时会发生什么,这种现象被称为“别名”。我们还将介绍OCaml中两种不同的相等性检查:结构相等性和物理相等性。

什么是别名? 🔗

别名是指两个或更多个引用指向内存中同一位置的情况。

例如,我们可以创建两个引用,初始都存储内容42

let x = ref 42
let y = ref 42

xy被绑定到两个不同的内存位置。

如果我们执行let z = x,那么zx现在就是别名。它们都指向内存中的同一位置。

因此,如果我使用赋值操作将x的内容更新为43

x := 43

这个操作会同时将z的内容更新为43,因为它们指向内存中的同一位置。

所以,当我解引用yz并将它们相加时,我不会得到84,而是会得到85

!y + !z (* 结果为 85 *)

如何检测别名? 🧐

你可能会问,如何判断两个引用是否是彼此的别名?OCaml的相等运算符可以帮助你解决这个问题。这引出了OCaml中结构相等性和物理相等性的区别。

假设你有两个引用r1r2,初始都包含相同的内容。

let r1 = ref 3110
let r2 = ref 3110

以下是两种相等性的区别:

  • ==(双等号)是物理相等性。它检查两个引用是否指向内存中“物理上”的同一位置。

    • r1 == r1 结果为 true,因为它与自身是同一位置。
    • r1 == r2 结果为 false,因为它们不是内存中的同一位置(即使内容相同)。
    • 物理不相等的写法是 !=
  • =(单等号)是结构相等性。可以将其理解为查看位置的内容或结构,而不是位置本身。

    • r1 = r1 结果为 true
    • r1 = r2 结果也为 true,因为它们当前包含相同的内容(3110)。
    • ref 3110 = ref 2110 结果为 false,因为这两个位置包含不同的内容。
    • 结构不相等的写法是 <>

大多数情况下,你真正需要的是结构相等性(=)。只有在你真正关心两个引用是否互为别名时,才使用物理相等性(==)。

总结

本节课中,我们一起学习了OCaml中引用的别名概念。我们了解到,当多个引用指向同一内存位置时,通过其中一个引用修改内容会影响所有别名。我们还区分了物理相等性==,检查是否为同一内存地址)和结构相等性=,检查内容是否相同)。理解这些概念对于管理程序中的可变状态和避免意外错误至关重要。在大多数比较场景中,应优先使用结构相等性。

OCaml编程:7.5:实现计数器 🧮

在本节课中,我们将学习如何利用OCaml中的引用和副作用,来实现一个每次调用都会返回递增数字的计数器函数。我们将探讨函数定义的不同方式,并理解变量作用域如何影响程序的行为。


假设你需要创建一个函数,每次调用它时,返回的值都比上一次调用时多一。

仅凭我们目前学到的OCaml特性,无法实现这个功能。因为到目前为止,我们看到的每个函数都是其输入的确定性函数。函数的输出完全由输入决定。

现在,我们有了引用副作用,就可以编写不完全由其输入决定的函数了。让我们来创建这样一个计数器函数,每次调用 next 函数时,都会得到比上一次调用时多一的值。

我以一种稍显特别的方式编写了这个计数器函数,主要是为了确保使用匿名函数语法,以便解释每个部分。

let counter = ref 0 in
let next = fun () ->
  counter := !counter + 1;
  !counter
in
next

next 是一个绑定到匿名函数的名称。该匿名函数接受一个输入,这个输入必须是 unit 类型。你不能向它传递 intbool,只能传递 unit

当你向该函数传递 unit 时,它会执行函数体。函数体首先通过获取 counter 的内容、加一,然后将结果存回 counter 来递增计数器。

接着,函数返回更新后的 counter 的内容。

让我们来使用它。我们有一个 counter 变量,其内容初始为 0。next 是一个函数。

如果我只写 next 本身,我只是在引用这个函数,还没有调用它。唯一能做的就是使用 unit 调用这个函数。第一次调用返回 1。再次调用 next,得到 2,再次调用得到 3,依此类推。

因此,每次调用 next,我都在计数并更新那个计数器的内容。最近一次,它的内容是 3。


上一节我们看到了 counternext 如何工作,现在让我们稍微简化一下这个函数。

首先,我不必使用匿名函数语法,可以使用语法糖。

let counter = ref 0 in
let next () =
  counter := !counter + 1;
  !counter
in
next

此外,标准库中内置了一个便捷函数,如果你只想递增一个 int ref,有一个函数 incr 可以做到。

val incr : int ref -> unit

incr 函数会递增给定引用中包含的整数。顺便提一下,如果需要,还有一个类似的函数 decr 用于递减整数引用。


为了更好理解引用和作用域,我们来看看可以放置 counterlet 绑定的另外两个位置。

我可以将 counter 的绑定嵌套在 next 内部,像这样:

let next () =
  let counter = ref 0 in
  counter := !counter + 1;
  !counter

我可能想这样做,因为我不想将 counter 引用暴露给外部世界(当然,我也可以使用模块和签名来实现这个目的)。

当我尝试运行这段代码时会发生什么?看起来我的 next 实现有问题,每次调用 next 都返回相同的值,它不再为我递增计数了。

这是怎么回事?让我们回顾一下代码。当 next 被求值时,它做了什么?

它接受 unit 输入,然后继续执行函数体。函数体中的第一件事是一个 let 绑定。

我们知道如何求值 let 绑定。首先,求值绑定表达式 ref 0 到一个值,这会在内存中创建一个位置并将 0 放入该位置。

该内存位置被绑定到名称 counter。函数的其余部分递增该 counter 的内容并返回它,所以我们得到 1。

那么,下一次调用 next 时会发生什么?

我们会重新执行所有步骤。ref 0 被再次求值,这会在内存中创建一个的位置,并将其绑定到名称 counter

这是一个的位置。每次我们调用这个函数,ref 0 都会被求值,产生一个新的位置,在其中存储 0,然后将那个 0 递增到 1。

但由于每次都是一个新的位置,我们永远无法递增超过 1。我们只是在内存中创建了很多存储数字 1 的位置。


好的,这是我们可以放置 counterlet 绑定的另一个位置。

我重新引入了匿名函数语法。next 被绑定到一个匿名函数,该函数创建一个 counter,在其中存储 0 并递增它,然后立即返回。

我希望你能看出,这和之前是同样的问题。我所做的只是去掉了语法糖。如你所见,每次我求值 next,我仍然得到 1。


但现在让我们尝试一点更巧妙的方法。这需要你仔细思考 OCaml 的求值语义。

let next =
  let counter = ref 0 in
  fun () ->
    counter := !counter + 1;
    !counter

当我们把第 2 到第 5 行的整个函数体绑定到 next 时,会发生什么?

首先,我们求值 let 的绑定表达式,也就是 ref 0。这意味着在内存中创建一个位置,将其内容初始化为 0,并将该位置绑定到名称 counter

然后,我们求值主体表达式。主体表达式是什么?它是一个匿名函数。这意味着此时不会进行进一步的求值。记住,匿名函数本身已经是值了。

因此,next 被绑定到那个匿名函数。该函数等待接受一个 unit 类型的值,然后才会求值其函数体。

但在其函数体内部,就好像我们已经用 ref 0 返回的位置替换了名称 counter。因此,它始终是同一个位置ref 0 的调用只被求值一次,而不是每次。

所以这段代码实际上创建了一个正常工作的计数器。你会看到,每次我调用 next,它都会在上一次的基础上递增。


这只是一个非常细微的差别。如果我将 counterlet 绑定放在匿名函数之前,它只会被求值一次。如果我把它放在匿名函数的函数体内部,它会在每次函数被调用时都被求值。

这就造成了是每次都创建一个单独的内存位置,还是创建一个内存位置并在每次调用时递增它的区别。


本节课中,我们一起学习了如何利用OCaml的引用和副作用来实现一个状态可变的计数器。我们探讨了函数定义的位置如何影响变量的作用域和生命周期,理解了将引用初始化放在函数外部与内部的关键区别。通过这个例子,我们看到了副作用如何使函数的行为不再仅仅由其输入决定,从而实现了更灵活的程序逻辑。

112:可变字段 🎬

在本节课中,我们将学习OCaml中记录(record)的可变字段。我们将了解如何声明可变字段、如何更新它们,并揭示OCaml中引用(ref)类型的内部实现原理。

概述

记录是OCaml中一种重要的数据结构,用于将多个相关值组合在一起。默认情况下,记录字段是不可变的。然而,我们可以通过mutable关键字将特定字段声明为可变的,从而允许在创建记录后修改其值。

声明可变字段

在OCaml中,记录的字段可以被声明为可变。让我们创建一个表示点的类型。

type point = {
  x : int;
  y : int;
  mutable c : string; (* 颜色字段被声明为可变 *)
}

在上述代码中,我们定义了一个point类型,它包含两个不可变的整数字段xy,以及一个可变的字符串字段c。请注意,mutable关键字位于字段名称之前,它并不是字段类型的一部分。这意味着c字段的类型是string,而不是mutable string

创建与更新可变字段

现在,我们可以创建一个点并更新其可变字段。

let p = { x = 0; y = 0; c = "red" } (* 创建一个点 *)
p.c <- "white" (* 更新可变字段c的值 *)

执行更新操作后,点p的颜色字段c的值已从“red”变为“white”。需要注意的是,对于可变字段的赋值操作符是<-,它形似一个左箭头,表示将右侧的值放入左侧的字段中。

可变字段与引用的区别

上一节我们介绍了如何操作可变字段,本节中我们来看看它与引用(ref)操作的区别。

以下是可变字段与引用在操作上的关键区别:

  • 赋值操作符不同:对于引用,我们使用:=操作符进行赋值(例如,r := 5)。对于记录的可变字段,我们使用<-操作符(例如,p.c <- "white")。
  • 访问方式不同:引用使用!操作符来解引用获取其内容(例如,!r)。记录字段则使用点号.来访问(例如,p.c)。

引用的内部实现

一个有趣的事实是,OCaml中的引用类型本质上是通过包含一个可变字段的记录来实现的。

(* 引用类型 'a ref 在概念上等同于以下记录 *)
type 'a ref = {
  mutable contents : 'a;
}

标准库中的ref函数、!操作符和:=操作符,在底层分别对应着创建记录、访问contents字段和更新该可变字段的操作。这种设计体现了OCaml语言设计的简洁与优雅,核心的引用概念可以通过更基础的可变记录机制来实现。

总结

本节课中我们一起学习了OCaml记录的可变字段。我们掌握了如何使用mutable关键字声明可变字段,以及如何使用<-操作符更新它们。我们还探讨了可变字段与引用在语法上的区别,并深入了解了引用类型在OCaml内部是如何基于可变记录字段实现的。理解这些概念有助于我们更灵活地处理程序中需要变化的状态。

113:可变单向链表(第一部分)🎯

在本节课中,我们将学习如何在OCaml中实现可变单向链表。我们将从定义链表节点的类型开始,然后定义链表本身的类型,并编写创建链表的函数。

定义节点类型

上一节我们介绍了链表的基本概念,本节中我们来看看如何在OCaml中表示链表节点。

在Java中实现链表时,链表通常包含节点。这些节点存储值,并且每个节点都有一个指向链表中下一个节点的指针。

让我们为节点创建一个OCaml表示类型。我们在此处暂停一下。

一个 'a node 是一个包含类型为 'a 的值的节点。

因此,我创建了一个记录类型,其中包含一个名为 value 的字段。该字段存储节点中的值。

我还创建了一个名为 next 的字段。该字段将是指向链表中下一个节点的指针。

现在,我希望能够更改链表中的下一个节点是什么,我希望能够改变它。

实现这一点的一种方法是使 next 字段可变。现在,当我想更新链表中的下一个节点时,就可以做到。

当然,另一种方法是使用 ref

我也可以将 next 设为 'a node ref 并改变该引用。

我们现在知道,引用和可变字段本质上是相同的,因为你可以用可变字段来实现引用。因此,我将坚持使用可变字段的实现,以使类型稍微简单一些。

现在,如果我们位于链表的最后一个节点怎么办?之后没有其他节点了。问题是,我们的 next 字段将迫使我们放置另一个节点,然后又一个节点,我们将永远无法结束链表。

我们需要类似Java中的 null 来实现这一点。如你所知,OCaml有 option 类型,它为我们提供了一种规范的方法来表示可能为空的值。

因此,我将使 next 字段成为一个 option 类型。从右向左读,这是一个包含 'a 的可选节点。要么它是 None,表示我们位于链表末尾;要么它是 Some,表示我们能够跟随到链表中的下一个节点。

我应该用抽象函数来记录其中的一些内容。很好,现在,当有人稍后来阅读我的代码时,他们会理解我使用这个类型的意图。

(** ['a node] 表示可变单向链表中的一个节点。 *)
type 'a node = {
  value : 'a;
  mutable next : 'a node option; (** 指向下一个节点的可选引用。 *)
}

定义链表类型

在实现链表时,通常还有一个单独的类型来表示链表本身,而不仅仅是一个节点。这个单独的类型有一个指向链表第一个节点的指针,并且可能包含一些额外的信息,比如链表的大小。

让我们为链表创建一个OCaml类型。因此,一个 'a mlist 将是一个可变单向链表,它包含类型为 'a 的值。我将其创建为记录类型。

该记录有一个名为 first 的字段,它是指向链表中第一个节点的指针。当然,如果我想要链表中的第一个节点也是可变的,以便我可以更改此链表的第一个节点(例如通过在链表前添加一个节点),我将需要该字段是可变的。

如果我想要链表可能为空,即其中根本没有节点,我将需要使该 first 字段也是可选的。

最后,我应该为此表示类型记录一个抽象函数。

(** ['a mlist] 表示一个可变单向链表。 *)
type 'a mlist = {
  mutable first : 'a node option; (** 指向第一个节点的可选引用。 *)
}

创建链表的函数

现在我们已经定义了类型,让我们编写一些代码来创建链表。首先,让我们创建单例链表,我们只需传入一个值并创建一个仅包含该值的链表。

因此,我在这里写了两个函数。singleton 创建一个 mlist,其 first 字段被初始化为某个新节点。

而那个新节点,我提取出了一个辅助函数 node 来帮助创建。它创建一个不指向任何其他节点且仅包含值 v 的节点。

我绝对应该为这些函数中的每一个都编写一些文档注释。

以下是创建节点的辅助函数:

(** [node v] 返回一个值为 [v] 且没有后续节点的新节点。 *)
let node v = { value = v; next = None }

以下是创建单例链表的函数:

(** [singleton v] 返回一个仅包含值 [v] 的新链表。 *)
let singleton v = { first = Some (node v) }

使用示例

让我们尝试使用这些函数来创建一个链表。现在我有了一个新的单向链表,它的第一个节点包含值 3110,并且没有任何指向其他节点的链接。

let my_list = singleton 3110

总结

本节课中我们一起学习了如何在OCaml中实现可变单向链表的基础部分。我们首先定义了表示链表节点的记录类型 'a node,它包含一个值字段和一个可变的、指向下一个节点的可选字段。接着,我们定义了表示链表本身的记录类型 'a mlist,它包含一个可变的、指向第一个节点的可选字段。最后,我们编写了创建新节点和单例链表的辅助函数,为后续的链表操作打下了基础。在下一节中,我们将继续探索如何向链表中添加元素、遍历链表等操作。

114:可变单向链表(第二部分)🚀

在本节课中,我们将学习如何为可变单向链表实现一个在链表头部插入新节点的函数。我们将通过绘制示意图来理解指针操作,并最终编写出正确的代码。此外,我们还将探讨如何正确地创建一个“空链表”函数,以避免常见的陷阱。


上一节我们介绍了可变单向链表的基本结构。本节中,我们来看看如何实现一个向链表头部插入节点的函数。

实现 insert_first 函数

我们的目标是编写一个函数,将一个包含特定值的新节点插入到链表的开头。

以下是实现该函数的关键步骤:

  1. 处理链表为空的情况:如果链表的 first 字段为 None,我们需要将其指向一个新创建的节点。
  2. 处理链表非空的情况:如果链表已有头节点,我们需要创建一个新节点,使其指向原来的头节点,然后将链表的 first 字段更新为这个新节点。

通过绘制示意图,我们可以更清晰地理解这两种情况下的指针操作逻辑。

代码实现

基于上述分析,我们可以编写 insert_first 函数。我们使用模式匹配来区分链表为空和不为空两种情况。

let insert_first (mlist : 'a mlist) (v : 'a) : unit =
  match !mlist.first with
  | None ->
      (* 情况1:链表为空 *)
      mlist.first := Some (create_node v None)
  | Some old_first ->
      (* 情况2:链表非空 *)
      let new_first = create_node v (Some old_first) in
      mlist.first := Some new_first

让我们测试一下这个函数。插入操作成功后,链表 l 的头部将变为包含值 2110 的新节点,而原来的节点(包含值 30)则紧随其后。


创建“空链表”的注意事项

最后,如果我们想创建一个便捷的函数来生成空链表(而不是单节点链表),需要注意一个关键点。

以下是创建空链表函数的正确方式:

  1. 错误方法:直接将一个记录字面量绑定到 empty。这会导致所有对 empty 的引用都指向同一个链表记录。
  2. 正确方法:将 empty 定义为一个返回新记录的函数。这样,每次调用该函数都会创建一个全新的、独立的空链表。

代码实现

错误的实现会导致多个变量共享同一个链表,从而产生意外的修改。

(* 错误示例:所有 empty 都指向同一个列表 *)
let empty : 'a mlist = { first = ref None } (* 不要这样做 *)

正确的实现是使用一个函数,每次调用都生成一个新的空链表记录。

(* 正确示例:每次调用都返回一个新的空列表 *)
let empty () : 'a mlist = { first = ref None }

现在,当我们调用 empty () 时,每次都会获得一个不同的、全新的空链表。因此,向这些不同的链表中插入元素时,它们会保持独立,互不影响。


本节课中我们一起学习了如何为可变单向链表实现头部插入功能,并通过绘图理清了指针操作的逻辑。我们还深入探讨了如何正确创建“空链表”生成函数,避免了因共享引用而导致的常见错误。理解这些对于安全、高效地操作可变数据结构至关重要。

115:数组(第一部分)📚

在本节课中,我们将要学习OCaml中的数组。数组是一种可变的数据结构,与你在其他编程语言中遇到的数组非常相似。我们将学习如何创建、访问和修改数组元素,并探索一些用于处理数组的便捷函数。


数组基础

OCaml拥有数组,它们与其他语言中的数组非常相似。语法略有不同,看起来像是OCaml列表语法和可变字段语法的混合体。

我们可以通过编写一个看起来几乎像列表的表达式来创建数组。

以下是创建一个包含数字1的列表的语法:

[1]

但我们在两边加上了一组额外的竖线:

[|1|]

现在,这就变成了一个包含数字1的整数数组。

我们同样使用分号来分隔其中的值。这是一个包含1到3的数组:

[|1; 2; 3|]

要访问数组的元素,我们使用点语法。我们可以将数组绑定到一个名称 a,然后使用 a.(0) 来访问它的第一个元素。这将得到 1

因此,与大多数其他语言一样,OCaml中的数组也是从0开始索引的。

如果你尝试索引超出数组的末尾,将会得到一个越界异常。

要修改数组的元素,你可以使用左箭头语法,就像修改字段一样:

a.(0) <- 5

现在 a.(0) 是5,而不是1。


使用数组建模向量

上一节我们介绍了数组的基本操作,本节中我们来看看如何用数组编写更复杂的代码。让我们来模拟物理学或线性代数中的向量。

我们可以为向量定义一个类型,例如,一个浮点数数组:

type vector = float array

我们首先从打印一个向量开始。如果你习惯于在命令式语言中做这件事,你首先想到的可能是编写一个循环来遍历数组的所有元素并打印每一个。

我们可以在OCaml中做到这一点。现在,我写了一个函数来打印向量。它循环遍历该数组中的每个索引,并打印出在该索引处找到的浮点数。

let print_vector v =
  for i = 0 to Array.length v - 1 do
    print_float v.(i);
    print_newline ()
  done

假设我们有向量 v,其第一个分量为1.0,第二个分量为0.0。我们打印它,每个分量会单独显示在一行。

请注意,当我们循环遍历数组中的所有元素时,可以使用 Array.length 来获取数组的长度。如果长度是2,那么有效的索引是0和1。因此,通常我们需要从长度中减去1来循环遍历每个索引。


使用高阶函数迭代数组

不过,还有更巧妙的方法来实现我们的向量打印函数,利用函数式编程和高阶编程的思想。让我们来看看它们。

标准库的数组模块中有一个名为 iter 的函数。该函数接收一个类型为 'a -> unit 的函数和一个 'a array 数组。它将这个函数应用到数组的每个元素上,类似于依次执行 f a.(0); f a.(1); ... 直到数组末尾。

通过使用 iter,我们可以避免编写循环。这也意味着我们不会在循环索引上犯错,比如很容易忘记那里的 -1,那样会导致索引越界异常。

使用 Array.iter,我们甚至不需要编写循环。我们只需传入一个打印数组每个元素的函数。

let print_vector_iter v =
  let print_element x = print_float x; print_newline () in
  Array.iter print_element v

这里我们看到了很好的关注点分离:我们有一个单独的函数 print_element 来处理数组中每个元素应该发生的事情;而 Array.iter 则处理遍历数组元素的所有迭代工作,这样我们就不必编写循环,也就不会在循环中出错。

这两个函数打印出的内容相同。


使用 Printf 模块进一步简化代码

为了进一步缩短这段代码,我们能否编写一个函数来打印数组元素,但只用一行代码完成呢?标准库中的 Printf 模块为我们提供了一种方法。

如果你熟悉C语言或其他命令式语言中的 printf 函数,这会让你感到非常熟悉。它的用法很简单。

printf 函数接收一个所谓的格式说明符,这是一个说明如何打印参数的字符串。

let print_vector_printf v =
  Array.iter (Printf.printf "%f\n") v

这里的 %f 表示打印一个浮点数,\n 表示打印一个换行符。

我们得到的输出与之前略有不同,你会看到打印出了所有额外的小数位。这是因为标准的 %f 以那种风格打印浮点数。如果你想以标准的OCaml风格打印它们(省略所有多余的零),你也可以做到。

let print_vector_printf_short v =
  Array.iter (Printf.printf "%F\n") v

注意我们使用了大写的 F。现在我们得到了与之前相同的输出。

但我们是用一个非常简短的函数实现的。这不需要我们实现大量代码,从而避免了在代码中可能犯的许多错误。


总结

本节课中我们一起学习了OCaml数组的基础知识。我们了解了如何创建数组、访问和修改其元素。我们还探索了使用 Array.iter 高阶函数来遍历数组,这比手动编写循环更安全、更简洁。最后,我们使用了 Printf.printf 函数来格式化输出,进一步简化了代码。数组是OCaml中处理可变序列数据的重要工具。

116:数组(第二部分)🚀

在本节课中,我们将学习如何实现向量加法,并探索从基础的命令式循环到使用高阶函数进行简化的不同方法。我们将看到,即使在使用数组这种可变数据结构时,函数式编程的思想依然能帮助我们编写出更简洁、更优雅的代码。


上一节我们介绍了数组的基本操作。本节中,我们来看看如何实现两个向量的加法。

首先,我们尝试将两个向量相加。我通过编写文档说明和提供使用示例来开始实现 vec_add 函数,以明确我们执行的是向量各分量的逐元素加法。

现在,作为一名命令式程序员,你首先想到的实现方法可能是使用循环。在 OCaml 中,我们当然可以这样做。

let vec_add v1 v2 =
  (* 函数体待实现 *)

所以,我在这里开始编写循环的主体部分。目前还有一些地方需要填充。首先,我需要确定循环的长度。如果传入的两个向量长度不匹配,可能会出现问题。

因此,或许我需要设定一个前提条件:两个向量必须具有相同的长度。这样,我就可以安全地使用其中任意一个向量的长度。当然,如果我想在编程中更谨慎一些,我可以检查一下长度。

好的,我现在更进一步了。如果两个向量长度不同,我将抛出一个异常。否则,我可以使用 len1len2 作为循环边界。当然,我必须记得将其减一,否则会遇到数组越界异常。

现在剩下的就是创建一个新向量,并在其每个分量中存储传入的两个向量对应分量的和。让我们在这里暂停一下。

我使用了标准库函数 Array.make。该函数返回一个长度为 n 的新数组,并将其所有值初始化为 x。因此,我创建了一个长度为 len1 的新数组,并将其所有值初始化为 0.0

现在,我可以使用这个新数组来存储逐分量的和。最后,我创建了数组 v3,但尚未返回它。实际上,此时 vec_add 的返回类型仍然是 unit

我需要将该向量作为最终值返回。现在,vec_add 的类型是 float array -> float array -> float array。让我们来测试一下。

let v1 = [|1.0; 2.0; 3.0|] in
let v2 = [|4.0; 5.0; 6.0|] in
vec_add v1 v2
(* 预期结果: [|5.0; 7.0; 9.0|] *)

看起来我们为那个使用示例得到了正确的答案,就像之前打印的那样。

我们可以利用高阶编程的思想来简化这段代码。

一种简化方法是使用另一个名为 Array.init 的库函数。Array.init n f 为我们提供了一种初始化新数组每个分量的方法。它返回一个长度为 n 的新数组,其中编号为 i 的元素被初始化为函数 f i 的结果。因此,我们使用每个新分量索引的函数来决定该数组分量应存储的值。

现在,我使用 Array.init 来创建一个长度为 len1 的新数组。我使用这里编写的元素函数 elt 来决定索引 i 处的分量应该是什么,而这就是传入的两个向量在索引 i 处的和。所以这段代码更好,我不需要显式地编写循环,所有这些都隐藏在 Array.init 内部。

但在处理这些长度方面,这段代码仍然有些繁琐。高阶编程给了我一个更好的方法来实现这个功能。

标准库中内置了一个名为 Array.map2 的函数。你向它传递一个函数 f 和两个数组 ab。然后,map2 会将函数 f 应用于 ab 的所有元素,并构建结果。因此,我们将 f 应用于 a.(0)b.(0),并将结果存储在新数组的第 0 个分量中,依此类推。

那么,对于初始两个数组中的每一对分量,我们想做什么呢?我们只想将它们相加。所以我需要做的就是将浮点数加法函数应用于输入数组中的每一对元素。

let vec_add v1 v2 = Array.map2 (+.) v1 v2

这一行代码就实现了整个向量加法函数。

我的三个实现都得到了相同的答案。但我希望你会同意,它们的代码质量并不相同。我用了很多行代码和一个循环来完成的事情,使用高阶函数只需一行就能做到。

因此,即使我们在编写命令式代码而不是纯函数式代码时,到目前为止我们从函数式编程中学到的思想仍然非常有用。


本节课中,我们一起学习了实现向量加法的三种方法:从基础的命令式循环,到使用 Array.init 进行封装,再到使用高阶函数 Array.map2 实现最简洁的一行代码。这展示了函数式编程思想如何提升命令式代码的简洁性和表达力。

117:Map ADT - 插入、查找、删除 🗺️

在本节课中,我们将学习映射(Map)这一抽象数据类型(ADT)。映射是编程中最重要的数据结构之一,它用于将键(Key)与值(Value)关联起来。

概述

映射抽象数据类型是编程中最重要的工具之一。映射将键与值进行绑定。你可能在其他语言中见过它,但名称可能不同:有人称之为关联数组,有人称之为字典,还有人称之为符号表。在Java中,你学过哈希映射(HashMap)。在OCaml中,也有一个哈希表的实现,我们稍后会讲到。但首先,让我们从抽象数据类型本身开始,即关注这种类型上有意义的操作。

映射的抽象表示

抽象地,我们将使用一种表示法来书写映射,这种表示法借鉴自Python。我们用花括号表示映射,其中每个绑定用“键: 值”的形式表示。所以,K1: V1 表示一个从键 K1 到值 V1 的绑定。

{ K1: V1, K2: V2, ... }

这只是一个抽象的表示法,并非OCaml语法,只是我们用来抽象地表达映射含义的一种方式。

以下是几个例子:

  • 我们可能有一个映射,将键 3110 绑定到字符串 "fun",将键 2110 绑定到字符串 "OO"
  • 或者,我们可能有一个映射,将键 "Harvard"(一个字符串)绑定到 1636"Princeton" 绑定到 1746"Penn" 绑定到 1740"Cornell" 绑定到 1865,这些数字是各大学的建校年份。

定义Map ADT的签名

接下来,让我们为Map ADT实现一个OCaml签名。

我们将从为映射编写一个模块类型开始。在这里,我们需要为映射创建我们想要使用的表示类型。此时,我们需要说明键和值及其类型。因为在映射中,所有的键都具有相同的类型,所有的值也具有相同的类型。

所以,我们的类型 t 实际上需要是一个类型构造器,它由一些类型变量参数化:一个用于键的类型变量,一个用于值的类型变量。当有两个类型变量时,语法与我们目前所见略有不同,需要用括号括起来。

因此,t 现在是一个类型构造器,由类型变量 'k'v 参数化。这些类型变量分别代表键和值的类型。

module type Map = sig
  type ('k, 'v) t
  ...
end

映射的操作与规范

现在,让我们为映射编写一些操作及其规范。

以下是插入(insert)操作的规范:

  • insert k v m:返回的映射与 m 相同,只做了一件事:增加了一个从 kv 的额外绑定。
  • 问题:如果映射中已经包含了该键的绑定,该怎么办?这里,我选择的做法是替换该绑定。所以,如果 k 已经在 m 中被绑定,那么在新的映射中,旧的绑定将被新的到 v 的绑定所取代。
  • insert 的类型可以看出,这是一个函数式数据结构,实际上是一个持久化数据结构。我们并没有改变原映射,而是传入一个旧映射,并返回一个包含了更改的新映射。

查找(find)操作用于在映射中查找键的绑定。

  • find k m:如果键 k 在映射 m 中绑定到值 v,则返回 Some v;如果该键在映射中没有绑定,则返回 None
  • 当然,我也可以有其他选择,例如引发异常而不是返回一个选项类型。对于这个特定的ADT,我决定:有时人们想要查找可能尚未绑定在映射中的键,如果这是一种常见操作,那么返回选项类型比引发异常更好。

删除(remove)操作移除键到值的绑定。

  • remove k m:返回的映射与 m 相同,但移除了键 k 的任何绑定。
  • 问题:如果键 k 原本就不在映射中呢?我再次决定不将其视为需要引发异常的异常情况。相反,我认为这可能是正常发生的情况。在这种情况下,如果 k 未在 m 中绑定,则返回的映射将保持不变。

插入、查找和删除是映射的三个主要操作。

总结

本节课我们一起学习了映射(Map)这一抽象数据类型。我们了解了映射的基本概念,即键值对的集合。我们定义了Map ADT的OCaml签名,其中类型 ('k, 'v) t 表示一个键类型为 'k、值类型为 'v 的映射。接着,我们详细探讨了三个核心操作:insert(插入或替换键值对)、find(查找键对应的值,返回选项类型)和 remove(移除指定键的绑定)。这些操作共同定义了映射的抽象行为,为后续的具体实现奠定了基础。

118:Map ADT 绑定与列表

在本节课中,我们将学习如何构造映射(Map)抽象数据类型(ADT),并探讨其核心操作。我们将重点关注如何从空映射和关联列表创建映射,以及如何将映射转换回关联列表。此外,我们还将讨论实现细节和效率考量。


构造映射的方法

我们需要提供构造映射的方法。以下是两种简单的构造方式。

空映射

首先,一个空映射。值 empty 就是一个空的映射。

从关联列表创建映射

另一种创建映射的便捷方式是从关联列表(association list)生成。我们最初学习列表时见过关联列表,它们就是包含键值对的列表。每个对中的第一个元素是键,第二个元素是值。

函数 ofList 将接收一个包含适当键值类型的关联列表,然后返回一个包含相同绑定的映射。

现在的问题是,如果该列表包含重复的键绑定,应该如何处理?例如,假设列表包含一个从 31"fun" 的绑定,以及另一个从 31"FP" 的绑定。这就是键 31 的重复绑定。ofList 函数必须对此做出决定。

在这里,我设定一个前置条件来排除这种情况,这样 ofList 的实现就不必担心重复键的问题。这对ADT的使用者来说可能不那么友好,但对ADT的实现者来说更友好。


映射的表示函数

最后,提供一个便利函数来获取映射的表示形式会很有用。无论底层类型 T 是什么,都可以将其转换为关联列表。在某种程度上,你可以将其视为抽象函数的一种实现。通过将底层表示 T 转换为关联列表,我们可以获得对映射中绑定的另一种抽象理解。

因此,函数 bindings 接收一个映射 m,返回一个包含与 m 相同绑定的关联列表。我保证 bindings 返回的关联列表中没有重复的键。


语法细节说明

你可能注意到我刚刚完成时有一个小的语法错误,值得一看。我最初在 KV 之间加了一个逗号。当我们为一个类型构造器编写两个类型变量参数时,它们位于类型构造器的左侧,并且中间有一个逗号,就好像那里有一个“对”一样。但是,当我们有一个参数化类型的列表时,我们需要实际给出一个类型,而两个带逗号的类型变量本身不是一个类型。

然而,元组类型——其中第一个分量的类型是 K,第二个分量的类型是 V——是一个有效的类型。因此,这两种用法在语法上存在差异。这种差异存在的主要原因是这两个类型构造器的定义不同:类型构造器 T 由两个类型变量参数化(如 type ('k, 'v) t),而类型构造器 list 只由一个类型变量参数化(如 type 'a list)。


总结与展望

这完成了我们关于映射ADT的讨论。当然,我们还可以向其中添加许多其他函数,但目前这些足以让我们研究如何实现这个ADT。

接下来,我们将探讨三种不同的数据结构来实现映射ADT。对于每种实现,我们将重点关注以下四个问题:

  1. 表示类型是什么?
  2. 抽象函数是什么?
  3. 表示不变式是什么?(我们将作为表示类型 T 注释的一部分来记录这些。)
  4. 每个操作的效率如何?(这是我们本周新增的问题。)

本节课中,我们一起学习了映射ADT的构造方法、从关联列表创建映射、将映射转换回关联列表的便利函数,以及相关的语法细节。我们还为后续研究不同数据结构的实现设定了框架和核心问题。

119:关联列表表示类型

在本节课中,我们将学习如何使用关联列表来实现映射(Map)抽象数据类型(ADT)。我们将从定义表示类型开始,并逐步实现核心操作。

概述

我们将基于关联列表构建一个映射ADT。关联列表是一个键值对列表,它提供了一种简单直观的方式来表示映射关系。本节将重点定义其表示类型,并明确其抽象函数和潜在的不变量。

选择表示类型

上一节我们介绍了映射ADT的基本概念,本节中我们来看看如何用关联列表来实现它。首先,我们需要定义模块的内部表示类型。

module AssociationListMap : Map = struct
  type ('k, 'v) t = ('k * 'v) list
  ...
end

这里,类型构造器 t 被定义为一个包含键 'k 和值 'v 的元组列表。这构成了我们映射的底层数据结构。

定义抽象函数

定义了表示类型后,我们需要明确它如何对应到抽象的映射概念。这通过抽象函数来完成。

抽象函数将具体的关联列表映射到抽象的映射。对于一个包含 (K1, V1), (K2, V2), ..., (Kn, Vn) 的列表,它表示将键 K1 绑定到值 V1,键 K2 绑定到值 V2,依此类推的映射。

关于重复键,我们需要做出设计决策。以下是两种主要选择:

  • 允许列表中存在重复键。在这种情况下,抽象映射中键所对应的值由列表中最左侧的绑定决定。例如,列表 [(K, V1); (K, V2)] 表示将 K 映射到 V1 的映射,(K, V2) 被忽略。
  • 定义一个表示不变量,规定列表永远不包含重复键。这将简化某些操作,但会增加插入新元素时的复杂度。

为了实现的简便性,我们选择第一种方案:允许重复键,并以最左侧的绑定为准。同时,空列表 [] 自然地表示空映射。

实现空映射

根据我们选择的表示类型,空映射的实现变得非常简单。

let empty = []

empty 值直接定义为空列表,这符合我们的抽象函数定义。

搭建测试环境

为了采用测试驱动开发的方式,我们已经准备了一个OUnit测试套件和一个Makefile。这允许我们通过运行 make test 来持续验证实现是否正确。在开发过程中,保持测试窗口打开并频繁运行测试是一个好习惯。

总结

本节课中我们一起学习了映射ADT基于关联列表的初步实现。我们定义了表示类型 ('k, 'v) t 为键值对列表,明确了抽象函数的含义,特别是处理重复键的规则(以最左侧为准),并实现了 empty 操作。这为后续实现插入、查找和删除等操作奠定了基础。下一节,我们将开始实现这些核心功能。

120:关联列表与绑定 🗺️

在本节课中,我们将学习如何为关联列表数据结构实现一个名为 bindings 的操作。我们将遵循测试驱动开发的方法,从编写一个失败的测试用例开始,然后逐步实现功能,最终通过测试。

概述

我们将实现一个函数,用于从关联列表中提取所有唯一的键值对(绑定)。关联列表本质上是一个 (key * value) 对的列表。bindings 函数的目标是返回一个列表,其中每个键只出现一次,并与其对应的值配对。

从测试开始

上一节我们介绍了测试驱动开发的基本概念。本节中,我们来看看如何为一个具体的操作编写测试。

测试驱动开发的第一步是选择一个涉及某个操作的测试用例,并为其实现一个会失败的测试。我们已经有了一个空列表的值。这是一个简单的起点。我们知道空关联列表的绑定结果应该为空。

以下是我们的第一个测试用例:

let test_empty_bindings () =
  assert (bindings empty = [])

我们实现了第一个测试用例,断言空关联列表映射的 bindings 结果就是空列表。运行测试以确认它失败。确实,测试失败了。这意味着我们现在有需要修复的问题。当然,问题在于我们尚未实现 bindings 函数。

初步实现

现在,我们如何获取一个列表的绑定呢?一个非常简单的做法是直接返回列表本身。

let bindings m = m

我不确定这是否完全符合规范,但它可以编译,并且通过了测试。这就是测试驱动开发:我们不一定要第一次就做对,但我们不断取得进展,持续编译并添加测试。

深入思考规范

现在,如果我思考 bindings 函数应该做什么的规范,我知道会有一个问题。bindings 要求列表中没有重复的键。但我们没有在关联列表的实际表示类型中定义任何机制来防止重复键。这意味着我们需要在 bindings 函数中做一些工作来消除任何重复项。

我们的算法思路是:

  1. 获取所有键的列表。
  2. 从该键列表中去除任何重复项。
  3. 将那个无重复的键列表转换为其对应的绑定。

我已经取得了一些进展。我实现了那个算法思路:获取映射中的所有键,并生成一个无重复的键列表(这将是 keys 函数要做的),然后我将每个键转换为代表该键绑定的键值对(这就是对函数 binding m 应用 List.map 会做的事情)。

let bindings m =
  let keys_list = keys m in
  List.map (binding m) keys_list

在这里,我将 binding 部分应用到了映射 m 上。这意味着从输入列表传入的每个键,现在将被映射到该键的绑定上。

此时,我可以再次运行测试以确保它仍然失败,并且重要的是仍然能够编译。它仍然失败,但出现了不同的错误:现在我需要实现这些我尚未完成的函数,而不是“未实现”的错误。

实现辅助函数

让我们先实现 keys 函数。我在这里用到了老朋友 List.sort_unique 来去除任何重复项。具体做法是:获取关联列表中的所有键值对,通过 fst 函数映射它们(该函数只从每个对中提取出键部分),然后通过 List.sort_unique 使所有这些键变得唯一。

let keys m =
  m |> List.map fst |> List.sort_unique

让我们暂停一下,思考这个函数的效率。使用 fst 函数映射一个列表需要线性时间,所以这部分是 O(n),其中 n 是列表中的元素数量。但是,正如你从库文档中会记得的,List.sort_unique 需要 O(n log n) 的时间。因此,keys 函数的效率是 O(n log n)

那么 binding 函数呢?我如何从列表中获取一个绑定?这实际上很容易,我甚至可以用一个标准库函数来实现,该函数已经存在,用于查找关联列表中某个键最左侧的绑定。

let binding m k = List.assoc k m

就这样完成了。现在让我们尝试运行我们的测试套件。太好了,成功了!我们能够正确确定空映射中的绑定是什么。

总结

本节课中,我们一起学习了如何为关联列表实现 bindings 操作。我们遵循了测试驱动开发的流程:首先为一个简单情况(空列表)编写并运行一个失败的测试,然后实现了一个初步的、能通过测试但可能不完全符合最终规范的版本。接着,我们深入分析了规范,设计了一个能处理重复键的算法,并逐步实现了 keysbinding 这两个辅助函数,最终完成了 bindings 函数并通过了测试。我们还简要分析了 keys 函数的算法效率。

121:关联列表

在本节课中,我们将学习如何使用关联列表来实现映射(Map)数据结构。我们将探讨其创建方式、操作的效率,以及如何通过测试来验证实现的正确性。


创建非空映射的两种方式

我们有两种创建非空映射的方法:一种是插入键值对,另一种是从列表中构造。

上一节我们介绍了映射的基本概念,本节中我们来看看如何从列表构造映射。

让我们先尝试 of_list 函数。我写了一个测试用例,但目前对这个测试用例还有一些不满意的地方。

但首先,让我们确保测试会失败。很好,它确实失败了。

现在,让我们回顾这个测试用例,并清理其中的一些问题。

首先,我通过使用 let 绑定消除了重复的表达式。但现在,使用 assert_equal 的测试代码和调用 bindings 的代码又出现了重复。

我应该为此提取一个函数。这样测试代码会更简洁。

我消除了之前草稿中存在的大量重复。草稿初期存在一些重复是可以的,关键是要在后续阶段消除它们。

让我们再次运行测试套件。它失败了,很好,目前它应该失败。

现在,我们将让这个测试用例通过。目前,我打算直接使用传入的列表来实现 of_list

这能行吗?

我应该阅读规范并思考一下,但让我们先运行测试套件,看看会发生什么。

太好了,测试通过了。现在让我们回过头再思考一下那些规范。

of_list 要求列表不能包含任何重复的键。如果有人传入了一个包含重复键的列表,那是他们的问题,我们的实现不对此负责。

我们需要保证的是,返回的映射包含与该关联列表相同的绑定。我们的抽象函数将保证这一点。

如果你传入一个没有重复项的列表,那么我们可以直接将该列表用作映射的表示。

所以,这实际上是 of_list 函数的一个良好实现。


各操作的效率分析

完成那个测试用例后,让我们继续思考每个操作的效率。

empty 的效率是常数时间,这里我们实际上不需要做任何工作。

binding 的效率呢?List.assoc 可能需要遍历整个关联列表 m,在最坏情况下,以找到键 k 的绑定。

因此,其效率与列表长度成线性关系,即 O(n)

那么 bindings 的效率呢?首先,它会调用 keys m,这将获取 m 的所有键。我们已经知道其效率是 O(n log n)

它还会部分应用函数 bindingm,这只是一个常数时间操作,我们传入一个参数并得到一个函数。

然后我们映射一个列表。在最坏情况下,该列表的长度将是原始映射中的键的数量,即 O(n)

那么,我们为列表中的每个元素做了多少工作呢?我们正在查找每个键的绑定,这意味着对于每个键,我们可能还需要做额外的 O(n) 工作。

当然,我们可以简化这个分析。因为那里的 O(n²) 项实际上主导了 O(n log n),二次复杂度比线性对数更差。

所以我们没有一个非常高效的 bindings 函数。

如果我们记录了一个表示不变量,防止列表中出现重复项,我们本可以使它更高效。


总结

本节课中我们一起学习了如何使用关联列表实现映射。我们探讨了从列表构造映射的方法,并通过测试验证了实现的正确性。我们还分析了 emptybindingbindings 等核心操作的效率,认识到基于简单列表的实现在某些操作上(如查找和生成所有绑定)可能效率不高,为后续学习更高效的数据结构(如平衡二叉搜索树)奠定了基础。

122:关联列表、测试改进与测试驱动开发 🧪

在本节课中,我们将学习如何改进测试用例,并深入理解测试驱动开发(TDD)的实践过程。我们将通过一个具体的例子,探讨如何为关联列表(Association Lists)编写更全面的测试,以及如何处理测试中出现的意外问题。

测试完成了吗?回顾测试套件

上一节我们实现了关联列表的基本功能。现在,让我们回顾一下当前的测试套件。

从黑盒测试的角度思考,我们目前已经有了针对零个绑定和一个绑定的测试用例。

扩展测试用例:覆盖“多个”情况

以下是扩展测试范围的考虑:

  • 为两个绑定添加测试是有意义的,因为这为我们提供了传入列表长度的三个简单小值:0、1和2。
  • 正如我的同事怀特教授喜欢开玩笑说的,世界上只有三个数字:0、1和“许多”。我们已经有了0和1的测试,现在让我们为“许多”的情况添加一个测试。

当我尝试加入第二个测试时,遇到了一个语法错误。

错误:此表达式应为 unit 类型。

这可能不太明显,但实际情况是,当OCaml解析这段代码时,它把整个表达式理解为了嵌套在上层 let 表达式主体中。

解决这个问题的一种方法是添加括号。

let%test _ = ... in
(let%test _ = ... )

然后,我需要为后续所有以此风格编写的测试用例继续这样做。

另一种可能性是将那些列表的定义提升到我当前编写位置的外部。

let test_list = [ ... ] in
let%test _ = ... test_list ...
let%test _ = ... test_list ...

现在,我的代码更紧凑了一些,希望也更易读。

运行测试并分析失败

让我们运行那个测试用例,看看发生了什么。哦,不,它失败了。怎么回事?

很难判断,因为它只显示“不相等”。我确实应该为这些测试用例添加一个打印器,以便轻松查看输出内容。

我使用了A2发布代码中提供的两个辅助函数来帮助解决这个问题:一个用于漂亮地打印列表,另一个用于漂亮地打印字符串。当然,到这里我需要弄清楚如何漂亮地打印一个键值对,而不仅仅是字符串或整数,所以我需要编写另一个辅助函数。

现在,我为关联列表准备了一个打印器。使用它,我将能够弄清楚测试输出到底出了什么问题。😡

很明显,我从那里没有得到很好的输出,因为我并没有真正看到绑定被正确打印出来。让我们回去修复它。

这样好多了。现在我看到了,我期望得到一个列表,但实际得到了另一个,而这两个列表之间唯一的区别是它们的顺序。

诊断问题:排序改变了顺序

现在,如果我想想我的 bindings 函数发生了什么,我就知道是怎么回事了。当我调用 List.sort_uniq 时,它在去除重复项的同时改变了列表中绑定的顺序。

因此,这里的一种可能性是使用我记得在A2中出现过的辅助函数,它可以比较类似集合的列表(其中顺序无关紧要)。

我从A2发布代码中找到了这个函数 compare_set_like_lists。它将检查确保列表不包含重复项,并且包含相同的元素,尽管顺序不一定相同。后者正是我在这里真正关心的。

那个关于重复项的问题会成为麻烦吗?也许会,也许不会。我必须在后续过程中留意这一点。但我的想法是,在我的 bindings 函数规范中,它声明列表中没有重复的键。所以,如果我使用该函数配合 compare_set_like_lists,应该不会遇到麻烦。

让我们把它作为比较器放进去。

let%test _ = compare_set_like_lists (bindings ...) (expected_list ...)

现在再次尝试运行测试用例。是的,它成功了。

测试驱动开发(TDD)的深入洞察

希望这让你对测试驱动开发的过程有了更深入的了解。

我们持续编写测试,并在此过程中不断改进测试和改进代码,但我们不强迫自己一开始就做到100%正确。出现异常是可以接受的。我们在过程中发现新问题也是可以的。

我们编写测试,测试失败,我们改进代码以通过测试,然后我们清除代码中的任何不良之处,例如重复代码或糟糕的风格。

总结

本节课中,我们一起学习了如何通过添加更多测试用例(包括“多个”绑定的情况)来完善测试套件。我们遇到了测试语法错误,并通过提升变量定义或使用括号来解决。当测试因输出顺序问题而失败时,我们引入了打印器来辅助调试,并使用了 compare_set_like_lists 这种不依赖顺序的比较函数来验证结果。这个过程生动地展示了测试驱动开发(TDD)中“红-绿-重构”的循环:编写测试(红),使测试通过(绿),然后优化代码(重构)。

123:关联列表的插入、查找与删除 🗺️

在本节课中,我们将学习如何使用关联列表来实现映射(Map)抽象数据类型(ADT)的三种核心操作:插入、查找和删除。我们将分析每种操作的效率,并总结关联列表作为映射实现方式的优缺点。

上一节我们介绍了关联列表的基本概念,本节中我们来看看如何基于它实现具体的映射操作。

插入操作

插入一个键值对到映射中,只需将这对绑定添加到列表的最前端。

let insert k v map = (k, v) :: map

此操作的效率是常数时间(O(1)),因为它只涉及创建一个键值对并将其添加到列表头部。需要注意的是,此实现允许重复键的存在。

查找操作

查找操作旨在根据给定的键,在关联列表中找出其对应的值。OCaml标准库中已经提供了相应的函数。

let find k map = List.assoc_opt k map

以下是List.assoc_opt函数的工作原理:

  • 它接收一个键和一个关联列表。
  • 它遍历列表,寻找第一个键与给定键匹配的键值对。
  • 如果找到,则返回Some value;否则返回None

此操作的效率在最坏情况下是线性时间(O(n)),因为它可能需要遍历整个列表才能找到目标键。

删除操作

删除操作需要移除列表中所有与给定键匹配的绑定。标准库中的List.remove_assoc函数只移除第一个匹配项,因此我们需要自己实现移除所有匹配项的功能。

let remove k map = List.filter (fun (key, _) -> key <> k) map

以下是删除操作的实现逻辑:

  • 使用List.filter函数遍历列表。
  • 保留所有键不等于目标键k的键值对。
  • 过滤掉所有键等于k的键值对。

此操作的效率同样是线性时间(O(n)),因为filter函数需要检查列表中的每一个元素。

效率总结

现在,让我们总结一下使用关联列表实现映射ADT时,三种核心操作的效率。

操作 时间复杂度 说明
插入 O(1) 直接将新元素添加到列表头部。
查找 O(n) 在最坏情况下需要遍历整个列表。
删除 O(n) 需要遍历列表以找到并移除所有匹配项。

关联列表的优点是插入操作非常快速,且实现简单。然而,其查找和删除操作的效率较低,尤其是在列表很长或目标键位于列表末尾时。这是因为关联列表本质上是一种线性数据结构。

本节课中我们一起学习了如何使用关联列表实现映射的插入、查找和删除操作,并分析了它们的时间复杂度。关联列表提供了简单的实现方式,但牺牲了查找和删除的效率。在后续章节中,我们将探索其他数据结构(如二叉搜索树),以期获得更均衡的操作性能。

124:直接地址映射ADT 🗺️

在本节课中,我们将学习实现映射抽象数据类型(ADT)的第二种数据结构——直接地址表。我们将了解其工作原理、实现方式以及与之前学过的关联列表的区别。

概述

上一节我们介绍了使用关联列表来实现映射ADT。本节中,我们来看看另一种实现方式:直接地址表。这种实现方式同样简单,但底层使用了数组而非列表。

直接地址表的核心概念

直接地址表的核心思想是使用数组作为底层存储结构。这意味着存在一个重要的限制条件:键必须是整数。我们通过键值本身作为索引,直接访问数组中的对应位置。

公式map[key] = value

这种设计意味着映射的灵活性有所降低。例如,你可以用它来映射办公室门牌号(整数)到其占用者。然而,对于字符串或记录等其他类型的键,这种表示方法将无法工作。但这仍然是一种实现映射的有效方式。

接口与类型定义

让我们开始查看其实现。首先,我们需要更新映射的接口定义,以适配直接地址表。由于键必须是整数,类型T不能再参数化一个键类型,而只参数化值类型。

代码

module type DirectAddressMap = sig
  type 'v t
  val insert : 'v t -> int -> 'v -> unit
  val find : 'v t -> int -> 'v option
  val remove : 'v t -> int -> unit
  val create : int -> 'v t
end

这里,'v t是映射类型,它将int类型的键绑定到'v类型的值。

可变性与操作语义

由于直接地址表基于数组,因此它是可变的数据结构。当我们更新一个映射时,实际上是在原地改变底层的数组。这意味着它不再是一个函数式或持久化的数据结构,而是一个命令式的、短暂的数据结构。

因此,insert操作不再返回一个新的数据结构,而是返回unit,表示操作已完成并对原映射进行了修改。remove操作也需要相应更新其规范,以反映数据结构的可变性。

实现细节

以下是实现直接地址表的关键步骤:

  1. 创建映射create函数接受一个整数参数(表示预期的最大键值),并初始化一个相应大小的数组。
  2. 插入操作insert函数接受一个映射、一个整数键和一个值。它直接将值存储到数组中以键为索引的位置。
  3. 查找操作find函数接受一个映射和一个整数键。它返回数组中对应索引处的值(包装在option中,因为该位置可能没有值)。
  4. 删除操作remove函数接受一个映射和一个整数键。它将数组中对应索引处的值移除或标记为无效。

总结

本节课中,我们一起学习了如何使用直接地址表来实现映射ADT。我们了解到,这种实现方式要求键必须是整数,并通过数组实现高效的直接访问。同时,由于底层使用数组,该数据结构是可变的,其插入和删除操作会直接修改原映射。尽管灵活性不如关联列表,但直接地址表在键为整数的场景下提供了高效的性能。

125:数组映射的表示类型与创建 🗺️

在本节课中,我们将学习如何为基于数组的映射数据结构定义表示类型,并实现其创建函数。我们将探讨如何将抽象的“映射”概念转化为具体的OCaml数组实现,并处理其中的关键细节。

概述

上一节我们讨论了映射数据结构的接口设计。本节中,我们来看看如何具体实现一个基于数组的映射,特别是如何定义其内部表示类型并创建初始的映射实例。

表示类型的选择

首先,我需要为映射创建一个表示类型。最直接的想法是使用一个类型为 V 的数组。

type 'v t = 'v array

然而,这会导致编译错误,因为我需要实现 empty 函数。实际上,empty 甚至不是一个函数,而是一个值。我可以将其设置为空数组。

但这里存在一个问题。当我们实现可变数据结构时,其创建函数必须是一个真正的函数,而不能仅仅是一个值。否则,整个程序中只会存在该数据结构的一个实例。

数组的固定大小特性

数组是不可调整大小的。一旦创建,数组就具有固定的大小。如果 empty 返回一个零元素的数组,那么这个映射将永远无法存储超过零个元素。

基于以上两点原因——创建函数必须是函数,以及我们需要知道数组的初始容量——我决定修改数据结构中的这个操作。我需要能够创建一个具有特定容量的映射,而不是一个后续可以随意添加元素的空值。

修改接口规范

因此,我回到接口定义,修改了规范,甚至更改了函数的名称和类型。

现在,我将拥有一个 create 函数,它创建一个容量为 c 的映射,这个容量将是底层数组的大小。这对键(key)产生了影响。

如果一个数组的容量是 c,例如有10个元素,那么该数组唯一有效的键将是0到9。我需要在规范中说明哪些键对于映射是有效的。

引入边界概念

我引入了一个“键在边界内”的概念。现在,接口中的其他一些函数也需要将“键必须在边界内”作为前置条件。

对于 of_list 函数,我遇到了一点困难。我知道需要说明传入列表中键的边界,但此时还没有映射实例可供参考。我有两个选择:

  • 扫描列表,找出其中的最大键值,然后创建相应大小的数组。
  • 要求调用此函数的客户端明确告知返回映射的容量。

以下是修改后的接口函数规范,它们都考虑了容量和键的边界问题。

实现创建函数

在更新了所有函数以考虑容量和键边界之后,让我们回到数据结构的实现。

我已经部分实现了 create 函数。我需要创建一个容量为 c 的数组,可以使用 Array.make 来完成。

let create c = Array.make c ...

但我还需要向 Array.make 传递一个值,用于初始化数组的每个元素。我不知道该用什么值,因为我不知道客户端将用什么类型来实例化类型构造器 t,它可以是任何类型。

实际上,还有一个更严重的问题。我创建的初始映射不应该绑定任何键值对。如果我只是创建了一个“空”映射,那么键0不应该绑定任何值,键1也不应该绑定任何值,依此类推。

解决方案:使用可选类型

同时解决这两个问题的方法是,让这个数组成为可选值(option)的数组。

这意味着:

  • 如果某个位置的选项是 None,则该键尚未绑定到映射中。
  • 如果选项是 Some v,则该键已绑定,并且绑定到该选项内部包含的值 v

我刚刚做出了关于如何表示此数据结构值的决定,因此我应该在这里记录我的抽象函数和任何表示不变式。

定义抽象函数

我已经记录了一个抽象函数:如果我有一个数组,其元素为 Some v0Some v1 等,那么它实际上表示将键0绑定到值 v0、键1绑定到值 v1 等的映射。如果数组的任何元素 iNone,则键 i 未绑定在映射中。

完成创建函数的实现

现在我有办法完成 create 的实现。我可以简单地传入 None 来初始化数组的每个元素为 None,这意味着所有键都尚未绑定。

let create c = Array.make c None

这个操作的效率如何?一个大小为 n 的数组需要被分配内存,并且它的所有元素都需要被设置为 None。这将是一个与传入的数组容量成线性关系的时间操作。

总结

本节课中,我们一起学习了如何为基于数组的映射定义表示类型。我们了解到,由于数组的固定大小特性,需要用一个 create 函数来指定初始容量。我们选择使用可选类型的数组来表示映射,其中 None 表示键未绑定,Some v 表示键绑定到值 v。最后,我们实现了创建函数,并分析了其时间复杂度为线性。下一节,我们将基于这个表示类型来实现映射的其他操作。

126:数组映射剩余操作

在本节课中,我们将继续实现基于数组的映射数据结构。我们将完成插入、查找和删除操作,并实现从关联列表创建映射以及从映射获取所有绑定的功能。这些操作都力求在常数时间或线性时间内完成。

实现插入、查找和删除

上一节我们介绍了映射的数组表示和核心结构。本节中我们来看看如何实现基础的键值对操作。

插入、查找和删除操作实际上都相当容易实现。为了强调操作对象是数组,我们将参数名定为 A 而非 M

以下是这些操作的实现:

(* 插入键值对 *)
let insert (A : 'v t) (k : key) (v : 'v) : unit =
  A.(data).(k) <- Some v

(* 查找键对应的值 *)
let find (A : 'v t) (k : key) : 'v option =
  A.(data).(k)

(* 删除键 *)
let remove (A : 'v t) (k : key) : unit =
  A.(data).(k) <- None

插入操作直接将数组索引 k 处的值修改为 Some v查找操作通过索引返回数组中存储的 option 值。删除操作则将指定数组位置的值设为 None,表示该键不再绑定任何值。所有这些操作都是常数时间。

从关联列表创建映射

接下来,我们需要实现从关联列表创建映射的函数。以下是实现步骤:

首先,创建一个具有指定容量的数组。然后,遍历输入的关联列表,对于列表中的每一个键值对,调用之前实现的 insert 函数将其插入数组中。

let of_list (lst : (key * 'v) list) (c : capacity) : 'v t =
  let A = create c in
  List.iter (fun (k, v) -> insert A k v) lst;
  A

该操作的效率分析如下:创建一个容量为 C 的数组需要 O(C) 时间。遍历列表时,假设列表有 N 个元素,每个元素的插入操作是常数时间,因此这部分是 O(N)。根据 of_list 的规范,列表不能包含重复键,且所有键必须在有效范围内,因此在最坏情况下 N 等于 C。因此,整个操作的总效率是 O(C)

获取映射中的所有绑定

最后,我们来实现获取映射中所有绑定的函数。一个直接的方法是使用循环遍历数组。

以下是实现此功能的一种方式:

let bindings (A : 'v t) : (key * 'v) list =
  let b = ref [] in
  for k = 0 to A.capacity - 1 do
    match A.(data).(k) with
    | None -> ()
    | Some v -> b := (k, v) :: !b
  done;
  !b

我们创建一个指向空列表的引用 b。在循环中,我们检查数组的每个位置。如果该位置是 None,则不进行任何操作。如果是 Some v,则将键值对 (k, v) 添加到列表 b 的开头。循环结束后,返回引用 b 中累积的列表。

该操作的效率分析:循环会执行 容量 次迭代。在每次迭代中,索引访问、创建新的 cons 节点和模式匹配都是常数时间操作。因此,整个循环的时间复杂度是 O(容量)

总结

本节课中我们一起学习了如何实现基于数组的映射数据结构的剩余操作。我们实现了常数时间的 insertfindremove 函数。我们还实现了 of_list 函数,它能在 O(容量) 时间内从关联列表构建映射,以及 bindings 函数,它能在 O(容量) 时间内提取映射中的所有键值对。这些操作共同构成了一个高效、基础的映射实现。

127:关联列表与数组的对比 🆚

在本节课中,我们将比较目前已经学过的两种映射(Map)实现:关联列表和直接寻址表(数组)。我们将分析它们在时间和空间效率上的差异,并思考如何结合两者的优点。


上一节我们介绍了直接寻址表(数组)的实现。本节中,我们来对比关联列表与直接寻址表在性能上的优劣。

首先,让我们回顾一下插入操作的效率。对于关联列表,我们实现了常数时间的插入操作。对于直接寻址表,我们也获得了常数时间的插入操作。

然而,在查找和删除操作上,直接寻址表的效率更高。

直接寻址表提供了常数时间的操作,因为这本质上只是数组的索引和更新。相比之下,关联列表可能需要扫描整个列表,因此这些操作是线性时间的。


当然,这并非完全公平的比较。直接寻址表附带了一个额外的限制:键必须是整数。

正是由于这个限制,你才获得了更高效的实现。😡 这是在时间效率方面。但在空间效率方面,你可能需要付出更多代价。

以下是原因:如果你因为想使用像1000这样的大数字作为键而创建了一个容量非常大的映射,但你实际上并不关心像0、1、2、100、200、300这样的小数字,你仍然需要分配一个巨大的数组来实现这一点。

因此,如果实际绑定的键数量不多,关联列表在空间上可能更节省。

不过,关联列表在时间效率上较慢。

它们具有这些线性时间的操作。

如果我们能设法结合两者的优点,那将非常理想。



本节课中我们一起学习了关联列表与直接寻址表(数组)在实现映射时的核心差异。我们了解到,关联列表在空间利用上可能更灵活,但查找和删除操作是线性的;而直接寻址表虽然要求整数键且可能浪费空间,但提供了常数时间的操作。这引出了我们对更优数据结构的需求。

128:哈希表表示类型 v1 🗂️

在本节课中,我们将要学习如何结合直接寻址表和关联列表的优点,来构建哈希表这种数据结构。我们将探讨其核心思想、处理冲突的策略,并最终定义出哈希表的表示类型。

概述

我们希望结合直接寻址表和关联列表两者的优点。直接寻址表因其数组实现,能提供常数时间的操作。关联列表则允许我们使用任意类型的键。结合这两种数据结构的基本思路,是能够将键转换为整数。

核心思想:哈希函数

我们假设存在一个转换函数,可以将任意类型的键映射为一个整数。这就是所谓的哈希函数。其思想是,无论键是什么类型,都将其“打散”以生成一个整数输出。

我们希望实现插入操作。其方法是:通过哈希函数将键转换为一个整数,该整数需在数组的边界范围内,然后将键值对存储在该索引位置。这解决了键必须是整数的问题,因为我们假设存在一种转换方法。

现在,这个转换过程需要快速,因为我们当前关注的是效率。理想情况下,我们希望这个转换是一个常数时间操作。因为如果它是,那么插入、查找和删除操作也有可能成为常数时间操作。

面临的问题:冲突

但真正的问题是:如果两个键哈希到同一个索引怎么办?在直接寻址表中,每个条目只能存储一个值。此时我们会遇到麻烦。

用CS 2800的术语来说,这个问题是关于单射性的问题。一个单射函数或一对一函数,是一个没有冲突的函数。冲突是指定义域中的两个不同元素,映射到了陪域中的同一个元素。换句话说,该函数无法被唯一地逆推。例如,右边的函数,如果我们知道输出是1,我们无法确定输入是B还是D。而左边的函数,如果我们知道输出是1,那么我们知道输入必定是A。

非单射性正是这里的问题。

解决方案:接受冲突并使用桶

你可能会认为解决方案是保证我们的哈希函数是单射的,但事实上恰恰相反,我们将接受非单射的哈希函数。

哈希函数的整数输出被称为一个。之所以称为桶,是因为在数组的那个位置,我们将存储一个大的绑定集合,而不仅仅是一个单一的绑定。

因此,在那个桶里,可能会有多个键发生冲突,但这没关系,我们只需将所有冲突的键值对都保存在那里。

处理冲突的两种策略

以下是处理桶内冲突的两种通用策略。

一种策略称为探测。这意味着在表中的其他地方找到一个空桶。典型的策略包括线性扫描整个表,或者以某种二次间隔跳过一些元素。这种方法传统上在硬件实现中更常见,因为在硬件中无法在桶内存储一大堆数据结构,空间有限,所以必须向前寻找可用的空位。

另一种可能性称为链地址法,这意味着在桶中,以一个列表的形式存储多个键值对。它甚至可能不是一个列表,你可能会将其放入某种树形数据结构或更高级的结构中。但基本思想是从该桶开始链接,存储越来越多的绑定。这在硬件中难以实现,但在软件中很容易做到。

OCaml的哈希表模块(实际上它叫Hashtbl)就采用了链地址法。我们在这里也将实现这种方法。

顺便提一下,探测和链地址法还有其他令人困惑的名称,如闭散列和开地址法,或开散列和闭地址法。我不使用这些术语,也不要求你们掌握,探测和链地址法就足够了。

定义哈希表的表示类型

现在,让我们通过结合之前已有的两种类型,来开始定义哈希表的表示类型。这就是为什么我之前详细介绍了这两种类型,因为要理解哈希表,你需要分别理解它们。

所以,最顶层的表示类型将是一个数组,就像一个直接寻址表。对于每个哈希后的键,我们存储一些绑定。现在,在我们存储哈希键的那个桶里,会有一个关联列表。在那个关联列表中,会有多个键值绑定,它也可能是空的。并且,这些键不一定都相同,因为不同的键可能会在同一个桶中发生冲突。

抽象函数

以下是我为这种表示类型设想的抽象函数。如果我有一个数组,并且该数组在每个索引处都有一个键值绑定的关联列表,那么它就代表了一个映射。这个映射所做的,只是将所有那些绑定单独放入映射中。

因此,如果数组中有一个从K11到V11的绑定,那么映射中就有一个从K11到V11的绑定,依此类推。这就像我们忽略了数组索引和关联列表条目的所有不同层级,只是将所有内容折叠成一个绑定集合。

表示不变式

我们需要为此类型定义一些表示不变式。

首先,我将强制规定一个不变式:任何键在数组中最多出现一次。换句话说,我绝不允许同一个键有重复的绑定。不仅在同一桶中不允许,在其他桶中也不允许。所以,每个键最多只能出现一次。

第二个要强制执行的表示不变式是:所有键必须位于正确的桶中。因此,如果键K在桶B中,那么必须满足哈希函数hash(K)的结果等于B。

总结

本节课中,我们一起学习了哈希表的核心思想。我们了解到,哈希表通过哈希函数将任意类型的键映射为数组索引,从而结合了直接寻址表的效率和关联列表的灵活性。我们探讨了哈希冲突的必然性,并介绍了处理冲突的两种主要策略:探测和链地址法。最后,我们定义了哈希表的表示类型,它本质上是一个数组,数组的每个元素(桶)是一个关联列表,并为此类型建立了抽象函数和表示不变式,为后续实现具体的操作奠定了基础。

129:哈希表表示类型 v2 🧮

在本节中,我们将学习哈希表核心操作的算法,并探讨如何通过控制负载因子来保证操作的效率。我们将看到,一个设计良好的哈希表可以实现常数时间的插入、查找和删除操作。


核心操作算法

以下是哈希表主要操作的算法。

插入操作

要插入一个键值对到哈希表中,我们首先对键进行哈希,以确定它应该进入哪个桶。接着,我们需要在该桶中搜索,以删除键 K 的任何先前绑定。这是为了维护我们的表示不变量:任何键都不能被绑定超过一次。最后,我们将改变该桶,添加 KV 的绑定。

查找操作

查找操作与插入类似。我们对键 K 进行哈希,以确定它应该在哪个桶中。然后,我们线性搜索该桶,以找到该键的绑定。

删除操作

删除操作与其他两个操作类似:对键进行哈希以找到桶,然后搜索该桶以删除该键的任何绑定。当然,一旦我们找到一个绑定,我们就完成了,因为根据我们的表示不变量,不可能有第二个绑定。


效率分析与负载因子

上一节我们介绍了哈希表的基本操作,本节中我们来看看这些操作的效率如何保证。

您可能立刻注意到,上述每一个操作都需要我们搜索一个桶。这有点令人担忧。我们选择这种表示类型是为了获得常数时间的操作。但现在,我们突然需要在桶中进行搜索。如果我们不小心,这可能会变成线性时间操作,而不是常数时间。

因此,我们的效率将取决于桶的长度。如果桶的长度最终与添加到哈希表中的绑定数量成函数关系,我们就有麻烦了。但如果桶的长度能保持为一个常数,那么我们就没问题,因为当我们需要搜索每个桶时,只需要做常数量的工作。

桶的长度将取决于哈希函数。例如,这里有一个糟糕的哈希函数:假设键 K 的哈希值,无论键是什么,都只是 42。这是一个常数函数。这意味着所有键都会发生冲突,并被存储到同一个桶中。本质上,我们有一个巨大的数组,除了那个桶之外,其余部分都是空的。而在那个桶里,它只是一个关联列表。因此,这退化成了我们之前用关联列表实现的映射。在这种情况下,插入、查找和删除都将是线性时间操作。

所以,让我们假设哈希函数的一个属性:假设它能将键随机地分布到各个桶中。这里的“随机”是指键均匀地分布在各个桶上,它们以相等的概率可能出现在任何桶中。这种随机均匀分布意味着所有桶的长度大致相同,因为平均而言,每个桶最终会有大约相同数量的键。

让我们称期望的桶长度为 L。在这种情况下,插入、查找和删除的期望运行时间都是 O(L)。如果期望的桶长度是 5,那么它们都有期望运行时间 5,这只是一个常数。O(5) 就是 O(1)。所以,这意味着我们得到了常数运行时间。这正是我们需要的。

如果我们的哈希函数能提供这个属性,我们就能用任意键类型实现常数时间的插入、查找和删除操作。

从表中绑定数量与整个数组中桶数量的关系来思考这个问题。如果哈希函数将键均匀地分布到所有桶中,那么检查的桶长度将是绑定数量除以桶数量。

因此,如果你有 10 个绑定和 10 个桶,那么平均而言,任何桶的期望长度将只是 1。你不需要搜索超过一个元素,这很好,是常数时间。如果有 20 个绑定在 10 个桶中,那么期望长度将是 2,这仍然没问题,仍然是常数。或者,如果绑定数量是 5,桶数量是 10,期望长度将是 0.5,这甚至更好,一半的时间我们甚至不需要搜索任何元素。

无论哈希函数将键分布到桶中的情况如何,还有另一个重要的量,称为负载因子。这正是我们上一张幻灯片所探讨的。

负载因子是绑定数量除以桶数量。负载因子有效地告诉你哈希函数在随机分布键方面做得有多好。因为这是哈希表性能的一个如此重要的特征,现实世界的标准库实现都提供了查询负载因子的功能。例如,OCaml 的哈希表和 Java 的 HashMap 实际上都提供了功能,让你可以询问哈希表其当前的负载因子,以查看你是否面临任何性能问题,或者你的哈希函数表现是好是坏。

关于负载因子的关键点是,虽然绑定数量不在实现者的控制之下,但桶的数量是。是客户端将绑定放入哈希表,我们可能不应该编写限制可添加绑定数量的哈希表。这实际上是我们直接地址表实现的一个缺点,因为你必须提前声明容量,并且它是固定的。但是桶的数量,这是数组中元素的数量。如果需要,我们可以分配新数组,并增加或减少哈希表中的桶数量。

所以,这里有一个关键思想:当负载因子变得太大时(即超过某个常数,重要的是它是一个常数),哈希表需要将数组变大。在大多数数组实现中,数组大小不可调整,这意味着需要分配一个新数组并将一些元素复制过去。当数组变大时,桶的数量增加,因此负载因子下降。这样做需要额外的工作,我们必须将这些键重新分布到更大的数组中,但通过这样做,我们将负载因子限制在一个常数范围内。


新的表示类型

以下是我们的新表示类型。此时我把它放在一个记录中,因为我想让数组可变。我也可以使用 ref,差别不大,但稍后使用记录会更方便。

type ('k, 'v) t = {
  mutable buckets : ('k * 'v) list array;
  mutable size : int; (* 当前绑定数量 *)
}

记录中的 buckets 字段是可变的。这意味着我可以在需要增加数组大小时改变该字段。所以请注意这里有两层可变性:数组本身的元素是可变的,而记录有一个 buckets 字段也是可变的。我们改变数组元素以执行插入或删除操作。当我们需要调整数组大小以将负载因子限制在常数范围内时,我们改变 buckets 字段。


总结

本节课中我们一起学习了哈希表核心操作的算法,并深入理解了保证其效率的关键——负载因子。我们了解到,通过设计良好的哈希函数将键均匀分布,并动态调整数组大小以控制负载因子在一个常数范围内,哈希表可以实现期望的常数时间插入、查找和删除操作。我们还介绍了新的可变记录表示类型,它支持数组的动态扩容。

130:哈希表扩容与性能分析 🔄

在本节课中,我们将学习哈希表实现中的一个关键优化技术——扩容(Rehashing)。我们将探讨扩容的触发条件、执行过程,以及它如何影响哈希表的空间和时间效率。最后,我们会将哈希表与之前学过的其他映射(Map)实现进行对比。


扩容机制

上一节我们介绍了链式哈希表的基本结构。本节中我们来看看当哈希表变得过于拥挤时,如何通过扩容来维持其性能。

如果负载因子(Load Factor)超过一个特定阈值(例如2),哈希表的实现会暂停当前操作,将底层数组的大小加倍,并将所有现有元素复制到新数组中。在此过程中,每个元素会根据新的数组大小被重新哈希,并可能被放入新的桶中。

这个过程会将负载因子减半。因为绑定(键值对)的数量保持不变,但桶的数量翻倍了。例如,负载因子可能从2降至1。

以下是扩容过程的伪代码描述:

if load_factor > threshold then
    let new_size = old_size * 2 in
    let new_buckets = create_empty_array new_size in
    for each binding in old_buckets do
        let new_index = hash(binding.key) mod new_size in
        insert binding into new_buckets.(new_index)
    done
    replace old_buckets with new_buckets

不同实现的扩容策略

以下是不同编程语言标准库中哈希表扩容策略的对比:

  • OCaml 的哈希表在负载因子达到 2 时进行扩容,将其降低回 1
  • JavaHashMap 在负载因子达到 0.75 时进行扩容。

更高的扩容阈值(如Java的0.75)会降低空间需求,但增加时间需求。因为当每个桶中预期有2个元素需要查找时,所花费的时间会比每个桶只有0.75个预期元素时要略多一些。然而,当负载因子为2时,元素在表中排列得更紧密,因此不会在空桶上浪费太多空间。

操作复杂度分析

经过扩容优化后,find(查找)和 remove(删除)操作的效率现在是期望常数时间 O(1)。因为从期望值来看,在每个数组索引位置只需要搜索常数数量的元素。

但是,insert(插入)操作仍然是个问题。它仍然是最坏情况线性时间 O(n) 的操作。因为如果你刚插入一个元素就导致负载因子超过了阈值(无论阈值是多少),现在你就必须对哈希表中的所有绑定进行重新哈希,这是一个线性时间的操作。

缩容的考量

顺便提一下,如果负载因子低于某个常数(例如 0.5),也可以进行对称的缩容操作。当负载因子过低,意味着存在大量浪费的空间时,可以分配一个容量减半的新数组,并将所有元素重新哈希到新数组的桶中。这会使负载因子翻倍,例如回升到1。

这是一个在许多教科书中都能找到的想法。但现实世界的库似乎不这么做。OCaml不这么做,Java也不这么做。我曾与一位Java HashMap 的实现者交谈,他说这是因为他们对哈希表的真实工作负载进行了研究,发现缩容并不是一件有用的事情。实际情况是,虽然有时哈希表可能会变得有点小,但最终用户通常会向其中添加更多元素,因此尝试进行这种缩容重新哈希只是浪费精力。


映射ADT实现对比

以下是目前我们学过的三种映射抽象数据类型(ADT)的实现对比:

  1. 关联列表(Association Lists)
  2. 直接地址表(Direct Address Tables)
  3. 链式哈希表(Hash Tables with Chaining)

链式哈希表几乎集所有优点于一身。现在我们可以允许任何类型的键。只要我们有一个好的哈希函数,就能获得快速的 findremove 操作。

我们还有一个看似较慢的 insert 操作。在后续关于摊还分析(Amortized Analysis) 的视频中,我们将探讨如何解决这个问题。

目前,让我们先继续使用这个实现。



总结

本节课中我们一起学习了哈希表的扩容(Rehashing) 机制。我们了解到,当负载因子超过阈值时,通过将数组容量加倍并重新哈希所有元素,可以将负载因子减半,从而维持 findremove 操作的期望常数时间复杂度。我们还比较了OCaml和Java不同的扩容阈值策略及其在空间和时间上的权衡,并简要讨论了缩容在实践中的必要性。最后,我们将链式哈希表与关联列表、直接地址表进行了对比,认识到链式哈希表在支持任意键类型的同时,提供了优异的查找和删除性能,尽管插入操作在最坏情况下仍是线性的。

131:哈希表接口 🗂️

在本节课中,我们将学习如何为哈希表编写一个接口。这个接口将融合我们之前基于关联列表和基于数组的映射接口的特点。

接口概述

我们将定义一个名为 TableMap 的模块签名。它包含一个参数化的表示类型和一系列操作。

以下是 TableMap 接口的核心组成部分:

表示类型

type ('k, 'v) t

类型 ('k, 'v) t 是可变哈希表的类型,它将类型为 'k 的键与类型为 'v 的值关联起来。这与我们为关联列表定义的表示类型构造器类似,同时对键和值进行参数化。我们重新对键进行参数化,因为我们不再像直接寻址表那样限制键必须是整数。

核心操作

  • 创建 (create)

    val create : capacity:int -> hash:('k -> int) -> ('k, 'v) t
    

    create 操作基于直接寻址表中的相同操作。我们发现,由于可变性,我们需要 create 接收一个容量参数,而不仅仅是一个空值。这里我们新增了一个概念:create 还需要接收一个哈希函数作为参数。这是因为我们的哈希表实现本身并不知道如何将这个任意类型 'k 的值哈希为整数,因此我们要求哈希表的客户端将这个哈希函数传递给我们。当然,我们需要它是一个良好的哈希函数,能够将键均匀地分布在整数范围内。

  • 插入 (insert)

    val insert : ('k, 'v) t -> 'k -> 'v -> unit
    

    insert 操作的类型及其规范可以看出,它会改变数据结构。insert k v m 会改变映射 m,将键 k 绑定到值 v。根据我们目前的实现,如果 k 已经绑定在 m 中,则该绑定将被新的绑定替换。

  • 查找 (find)

    val find : ('k, 'v) t -> 'k -> 'v option
    

    find 操作的规范与关联列表中的相同。

  • 移除 (remove)

    val remove : ('k, 'v) t -> 'k -> unit
    

    remove 操作看起来像是关联列表和直接寻址表操作的结合。它返回 unit 类型,因为它是可变操作,但它也对键类型进行了参数化。

总结

本节课中,我们一起学习了如何为哈希表定义一个接口。我们定义了一个参数化的表示类型 ('k, 'v) t,并设计了四个核心操作:createinsertfindremove。这个接口的关键在于,create 函数需要客户端提供一个哈希函数,使得哈希表能够处理任意类型的键。同时,insertremove 操作会改变哈希表本身,体现了其可变数据结构的特性。

132:哈希表插入实现 🧩

在本节课中,我们将学习如何使用哈希表模块来实现映射接口。我们将从定义表示类型开始,逐步实现创建和插入操作,并分析其效率。


表示类型与不变式

我们已经定义了表示类型、抽象函数和表示不变式。现在,我们将在表示类型中添加两个新字段。

首先,我们将存储哈希函数,因为在模块的实现过程中需要它来计算键的哈希值。

其次,我们将记录哈希表的大小。虽然这可以通过扫描所有桶来重新计算,但在记录中专门设置一个字段来存储它会更加方便。

哈希表的大小指的是其中绑定的数量。我们使这个字段可变,以便在插入和删除绑定时可以更新它。

以下是表示类型的定义:

type ('k, 'v) t = {
  hash : 'k -> int;
  mutable size : int;
  buckets : ('k * 'v) list array;
}


创建哈希表 🏗️

上一节我们介绍了表示类型,本节中我们来看看如何创建一个哈希表。创建操作可能是这些操作中最简单的一个。

我们只需要存储哈希函数,将大小初始化为0(因为目前没有任何绑定),并创建一个具有正确长度的空桶数组。

在这里,我们使用 capacity 作为初始的桶数量。如果我们以负载因子1为目标(这也是OCaml哈希表实现以及我们将要采用的方式),那么 capacity 就相当于哈希表的目标绑定数量。

以下是 create 函数的实现:

let create hash capacity =
  {
    hash;
    size = 0;
    buckets = Array.make capacity [];
  }

create 函数的效率与容量成线性关系,因为我们需要分配并初始化相应数量的数组元素。


实现插入操作 📥

接下来,让我们实现插入操作。从算法研究中我们知道,插入操作包含两个部分:实际执行插入,以及必要时进行扩容。

因此,我们将这个函数的实现分解为两个辅助函数。

let insert_no_resize tbl k v =
  (* 待实现 *)

let insert tbl k v =
  insert_no_resize tbl k v;
  (* 检查是否需要扩容并执行 *)

现在,我们需要实现这两个辅助函数。第一个函数上的警告是因为OCaml检测到目前该函数没有返回任何合理的值。

让我们先实现 insert_no_resize

首先,我们需要确定新键应该放在哪个桶中。为此,我们需要计算键的哈希值。

let bucket_index tbl k =
  (tbl.hash k) mod (Array.length tbl.buckets)

我们假设哈希函数能将键均匀地分布在整数上。但并非所有整数都适合作为数组的索引。数组有一个特定的容量,可能远小于哈希函数输出的范围。因此,我们需要调整哈希函数的输出,使其落在当前可用桶数量的范围内。

我们编写一个辅助函数来计算键在桶数组中的索引。哈希值可以对哈希表的桶数量取模,从而得到正确的范围。

但这里有一个问题:取模函数在接收负数时会返回负数。例如,-42 mod 10 的结果是 -2,这不是一个有效的数组索引。为了避免这个问题,我们添加一个额外的限制:哈希函数必须返回正值。

现在,我们知道了键应该放入哪个桶,接下来只需要将绑定放入该桶中。

let insert_no_resize tbl k v =
  let i = bucket_index tbl k in
  let old_bucket = tbl.buckets.(i) in
  tbl.buckets.(i) <- (k, v) :: (List.remove_assoc k old_bucket);
  if not (List.mem_assoc k old_bucket) then
    tbl.size <- tbl.size + 1

在此过程中,我们保存了旧的桶(即键值对列表)。现在,我们将新的键值绑定添加到该桶的前面,并更新数组中的桶以包含这个新绑定。

但我们需要考虑一个问题:我们可能无意中违反了表示不变式。表示不变式规定,数组中任何键都不能出现超过一次,特别是在关联列表中不能有重复的键。如果我们不小心,可能刚刚向关联列表中添加了一个重复的键,因为旧的桶可能已经绑定了键 k。我们需要先移除它。

库函数 List.remove_assoc 会移除它找到的第一个键 k 的绑定。由于表示不变式已经保证最多只有一个绑定存在,所以我们不需要像在映射的关联列表实现中那样继续移除更多绑定。

此外,我们还需要维护表示类型中哈希表的大小字段。如果我们添加了一个新绑定,大小可能会增加一,因此我们需要考虑这一点。只有当键 k 在旧桶中未被绑定时,表的大小才增加一。


效率分析 ⚡

现在,让我们思考这部分操作的效率。这里发生了很多事情。

bucket_index 的效率如何?它只是对键应用哈希函数,然后对容量取模。容量可以通过查看数组长度获得,在OCaml的数组实现中,这是一个常数时间操作。因此,在哈希函数也是常数时间的假设下,bucket_index 也是常数时间。

insert_no_resize 中,我们索引数组并访问记录字段,这些都是常数时间操作。接下来,我们构造新的值并更新数组元素,这也是常数时间。

但我们调用了 List.remove_assoc。它的运行时间是多少?它与桶的长度成线性关系。我们所做的所有工作都是为了确保桶的预期长度是常数。因此,这一行的预期效率将基于桶的长度 L。我们以2作为最大负载因子的目标,这是一个常数。所以,预期的大O表示是 O(L),即预期常数时间。

if 表达式中,我们再次可能进行线性时间操作,但它同样受限于预期的桶长度,因此也是预期常数时间。

综上所述,整个 insert_no_resize 操作的效率,正如我们之前计划的那样,是预期常数时间。


总结 📚

本节课中,我们一起学习了如何实现哈希表的创建和插入操作。我们首先扩展了表示类型以包含哈希函数和大小字段。然后,我们实现了 create 函数来初始化哈希表。接着,我们重点实现了 insert 操作,将其分解为 insert_no_resize 和潜在的扩容步骤。我们详细讨论了如何计算键的索引、处理重复键以及更新表的大小。最后,我们分析了插入操作的效率,确认其在预期情况下是常数时间。在下一节中,我们将探讨哈希表的查找和删除操作。

133:哈希表扩容实现 🛠️

在本节课中,我们将学习如何为哈希表实现动态扩容功能。我们将重点关注如何计算负载因子,以及如何根据负载因子的大小来调整哈希表的容量,确保其操作效率。

上一节我们介绍了哈希表的基本插入操作,本节中我们来看看如何实现自动扩容,以维持哈希表的性能。

负载因子计算

首先,我们需要计算哈希表的负载因子。负载因子是衡量哈希表“满”的程度的关键指标,其计算公式为:

负载因子 = 表中元素数量 / 桶数组容量

在我们的实现中,我们选择将负载因子维持在 0.52 之间。当负载因子超出这个范围时,无论是过大还是过小,我们都会触发扩容操作。

以下是计算负载因子的辅助函数实现:

let load_factor tab =
  let size_float = float_of_int tab.size in
  let capacity_float = float_of_int (Array.length tab.buckets) in
  size_float /. capacity_float

这个函数将整数类型的元素数量和容量转换为浮点数,然后进行除法运算,得到一个浮点数结果作为负载因子。

触发扩容判断

有了负载因子后,我们可以在插入操作中判断是否需要扩容。以下是 resize_if_needed 函数的逻辑框架:

let resize_if_needed tab =
  let lf = load_factor tab in
  if lf > 2.0 then
    rehash tab (tab.capacity * 2)  (* 负载因子过高,容量翻倍 *)
  else if lf < 0.5 then
    rehash tab (tab.capacity / 2)  (* 负载因子过低,容量减半 *)
  else
    ()  (* 负载因子正常,无需操作 *)

这个函数检查当前负载因子,并根据其值决定是扩容(翻倍)还是缩容(减半)。

核心:重哈希(Rehash)操作

当决定调整容量后,核心工作是 rehash 函数。它的作用是:用一个新的、指定容量的桶数组替换旧的桶数组,并将原哈希表中的所有键值对重新插入到新数组中。

以下是 rehash 函数的规格说明和实现步骤:

(* 规格说明:
   将 tab 的桶数组替换为一个大小为 new_capacity 的新数组,
   并将 tab 中的所有绑定重新插入到这个新数组中。
*)
let rehash tab new_capacity =
  let old_buckets = tab.buckets in          (* 保存旧数组 *)
  tab.buckets <- Array.make new_capacity []; (* 创建新的空数组 *)
  tab.size <- 0;                             (* 重置大小计数器 *)

  (* 遍历旧数组中的所有桶 *)
  Array.iter (fun bucket ->
    rehash_bucket tab bucket                (* 重新哈希每个桶 *)
  ) old_buckets

在重新插入时,每个键都会被重新计算哈希值(即 hash key mod new_capacity),因此它很可能会被放入与之前不同的新桶中。

辅助函数:重哈希桶与绑定

rehash 函数依赖于两个辅助函数来完成具体工作。

以下是 rehash_bucket 函数,它负责处理一个桶内的所有元素:

let rehash_bucket tab bucket =
  List.iter (fun (k, v) ->
    rehash_binding tab k v                  (* 重新哈希桶内的每个绑定 *)
  ) bucket

rehash_binding 函数则负责处理单个键值对:

let rehash_binding tab key value =
  insert_no_resize tab key value            (* 使用已有的无扩容插入函数 *)

这里巧妙地复用了之前编写的 insert_no_resize 函数。因为它不包含扩容逻辑,所以不会在重哈希过程中引发递归扩容。同时,insert_no_resize 会负责递增 tab.size 字段,我们无需在 rehash 函数中手动管理。

效率分析

最后,我们来分析一下 rehash 操作的效率。

  1. 创建新数组Array.make new_capacity [] 的时间复杂度为 O(N),其中 N 是新的容量。在扩容时,我们的目标是将容量调整到与当前元素数量 n 相当,因此这部分是 O(n)
  2. 重新插入所有元素rehash_binding 会对表中每一个元素(共 n 个)调用一次 insert_no_resize
  3. 单次插入成本insert_no_resize 的期望时间复杂度是 O(L),即常数时间。

因此,整个 rehash 操作的总期望时间复杂度是:

O(n) * O(1) = O(n)

其中 n 是哈希表中元素的数量。这是一个线性的时间复杂度。


本节课中我们一起学习了哈希表动态扩容的完整实现。我们掌握了如何通过负载因子判断扩容时机,并实现了将表中所有元素重新哈希到新数组的核心 rehash 函数。通过将扩容逻辑与插入逻辑分离,并复用基础插入函数,我们构建了一个高效且清晰的实现。至此,我们完成了哈希表 insert 操作的所有关键部分。

134:哈希表的查找与删除实现

在本节课中,我们将学习如何为哈希表实现查找和删除操作。我们将看到查找操作相对简单,而删除操作则与插入操作有许多相似之处,但需要处理一些不同的逻辑。最后,我们会分析这些操作的效率。


实现查找操作

上一节我们讨论了哈希表的插入操作,本节中我们来看看如何实现查找功能。查找操作的实现实际上要简单得多。

我们需要做的是根据键的哈希值索引到正确的桶,然后在该桶的关联列表中查找对应的键。

以下是查找操作的实现步骤:

  1. 计算键的哈希值,并取模得到桶的索引。
  2. 获取该索引对应的桶(即关联列表)。
  3. 在关联列表中查找给定的键,并返回其关联的值(如果存在)。

查找操作的代码可以简洁地表示如下:

let find key table =
  let index = (hash key) mod (Array.length table.buckets) in
  List.assoc_opt key table.buckets.(index)

这个操作的时间复杂度在平均情况下是常数级的,因为它只涉及一次哈希计算和一次列表查找。


实现删除操作

接下来,我们实现删除操作。让我们像实现插入时那样开始,将操作分解为两个部分:实际执行删除,以及根据需要进行调整大小。

我们已经为插入操作编写了调整大小的函数,这里可以直接复用。因此,我们只需要专注于实现删除逻辑本身。

删除操作的实现与“无需调整大小的插入”几乎相同。我们仍然需要找到正确的桶索引并获取旧的桶内容。不同之处在于,这次我们是从关联列表中移除一个键值对,而不是插入。同时,我们需要在条件表达式中使用相反的逻辑判断,并且减少size计数器,而不是增加。

以下是删除操作的核心步骤:

  1. 计算键的哈希值以确定桶索引。
  2. 从该桶的关联列表中移除指定的键值对。
  3. 如果成功移除,则更新哈希表的size字段。
  4. 最后,检查是否需要收缩哈希表(即调用调整大小函数)。

我们可能会想,能否提取出插入和删除操作中的公共代码?理论上可以,但在当前阶段尝试提取公共实现可能并不会让代码更清晰。尽管两者有一部分代码看起来相似,但它们的目的并不完全相同。因此,保持各自的实现是合理的。

关于删除操作的效率,其分析与之前对插入操作的分析完全相同。在平均情况下,删除操作的时间复杂度也是期望常数级。在最坏情况下(例如触发调整大小操作),时间复杂度为线性。


总结

本节课中我们一起学习了哈希表查找和删除操作的实现。查找操作通过哈希索引和列表查找直接完成,较为简单。删除操作则借鉴了插入操作的框架,但核心逻辑变为从列表中移除元素并更新大小。这两个操作在平均情况下都具有高效的性能。

至此,我们完成了哈希表核心功能的实现。你还可以为其添加如bindings(获取所有键值对)和of_list(从列表构造哈希表)等辅助函数,相关代码可以在课程资料库和教材中找到。

135:哈希表与其他映射数据结构的对比 🆚

在本节课中,我们将回顾并比较哈希表与其他实现映射抽象数据类型(Map ADT)的数据结构在性能上的差异。

上一节我们完成了哈希表的实现,本节中我们来看看它与其他数据结构相比表现如何。

性能概览 📊

哈希表(采用链地址法)相较于关联列表的主要优势在于其查找操作效率更高。

关联列表的查找操作时间复杂度为线性时间 O(n),而哈希表的查找操作在期望情况下是常数时间 O(1)

这里必须强调“期望”一词,因为其性能取决于所使用的哈希函数。一个好的哈希函数会将键均匀地分布到各个桶中,从而将每个桶的期望长度保持在常数级别。

插入与删除操作的考量 ⚖️

然而,对于插入和删除操作,哈希表在最坏情况下的时间复杂度是线性的 O(n)

在之前的一些视频中,我曾错误地将其描述为“期望”常数时间,这是一个失误。对于采用链地址法的哈希表,其插入和删除操作在最坏情况下确实是线性时间。这使得它们在某些方面比关联列表和直接寻址表更差。

以下是几种数据结构操作的时间复杂度对比:

  • 关联列表:所有操作在最坏情况下均为 O(n)
  • 直接寻址表:所有操作在最坏情况下均为 O(1),但要求键是整数且范围较小。
  • 哈希表(链地址法)
    • 查找:期望 O(1)
    • 插入:最坏情况 O(n)
    • 删除:最坏情况 O(n)

展望:摊还分析 🔮

我们将在下周学习一种名为“摊还分析”的技术。通过这种分析,我们可以得出结论:哈希表的插入和删除操作实际上是常数时间的,而非线性时间。这值得我们期待,我们很快就能学到。


本节课中我们一起学习了哈希表与其他映射数据结构(主要是关联列表和直接寻址表)的性能对比。我们了解到哈希表在查找操作上具有期望常数时间的优势,但其插入和删除操作在最坏情况下是线性的。最后,我们预告了通过摊还分析可以更全面地评估哈希表的平均性能。

136:哈希函数 🗝️

在本节课中,我们将学习哈希函数的工作原理及其在哈希表实现中的关键作用。哈希表的高效性很大程度上依赖于一个优秀的哈希函数。

概述

哈希表的性能取决于哈希函数。我们需要一个好的哈希函数来实现高效的哈希表。一个返回常数的哈希函数会很糟糕,因为所有键都会在同一个桶中发生冲突。我们需要哈希函数将键均匀、随机地分布到所有桶中。那么如何实现这一点呢?

从键到桶索引的三个步骤

将键转换为桶索引的过程可以分为三个步骤。

以下是这三个步骤的分解:

  1. 序列化:将键转换为某种字节流。序列化不仅出现在哈希函数中,当你想将内存中的值表示形式转换并存储到磁盘时,也必须将其序列化为字节流。序列化应该是单射的。如果你要从磁盘加载数据,你希望恢复最初写入的相同内容,因此你不希望无法反转哈希函数的这一部分。
  2. 扩散:将这些字节扩散成一个单独的大整数。这就像获取所有字节并将它们组合成某种大整数。这一步我们希望引入一种随机性。键的微小变化应该导致其扩散成的整数发生巨大且不可预测的变化。在这一步,我们可能会失去单射性,这取决于我们扩散成的整数的大小。如果是int64甚至int63,只要我们的输入不是极其庞大,它很可能仍然是单射的。
  3. 压缩:将该大整数压缩到我们正在使用的特定哈希表的桶索引范围内。如果表中有M个不同的桶,那么我们需要将键映射到0M-1。在这一步,我们肯定会失去单射性,因为我们是从一个非常大的空间映射到一个小的空间。

这三个步骤的责任通常由哈希表的客户端和哈希表的实现者分担。

不同实现中的责任划分

上一节我们介绍了哈希函数的三步流程,本节中我们来看看不同编程语言或库是如何划分这些责任的。

以下是几种实现方式:

  • OCaml的Hashtbl模块:它有一个函数Hashtbl.hash,可以将任何类型的值转换为int。它负责序列化和扩散,即通过一些基于知名、经过充分研究的哈希算法的原生C代码,将数据转换为字节,再将字节转换为整数。在Hashtable实现内部,另一个名为key_index的函数负责压缩,将其转换为少量桶的索引。在这种实现中,实现者负责所有步骤。客户端甚至不需要传入哈希函数。这对于客户端来说很方便,直到你需要一种宽松的键相等性概念。例如,你的键可能不仅仅是字符串,而是希望它们是不区分大小写的字符串。此时,哈希和相等性之间的一致性就会丢失。
  • OCaml的Hashtbl.Make函子:OCaml实际上提供了第二种实现。它仍然在Hashtbl模块中,但它是嵌套在其中的一个名为Make的函子。它的输入签名是Hashtbl.HashedTypeHashedType要求你提供一个哈希函数(与我们目前看到的类似)以及一个equal函数,用于判断两个值作为键是否相等。在这里,客户端负责提供equalhash函数来完成序列化和扩散,因为它们需要将输入类型T的值通过字节流转换为int。客户端有责任保证如果两个键相等,它们必须具有相同的哈希值。在这里,实现者只负责压缩。这与我之前实现的类似,只是我忽略了相等性问题。
  • Java的java.util.HashMap:它依赖于Object类中一个名为hashCode的方法。hashCode负责进行序列化和扩散。典型的默认实现是让hashCode返回对象地址作为整数。这里没有太多的扩散,因为在任何给定的运行虚拟机中,对象的地址彼此之间差异不大,它们往往都在一个相当相似的堆空间内。作为客户端,你可以重写hashCode。但你必须保证如果两个键相等,它们具有相同的哈希值。在java.util.HashMap的实现内部,还有另一个名为hash的方法。它实际上在hashCode的基础上做了进一步的扩散。这是为了在你意外提供了一个糟糕的hashCode函数时保护你,它试图进行一些额外的扩散,以确保哈希表性能不会下降。基本上,实现者在这里不信任客户端,这可能是好事,因为我知道我们都写过糟糕的Java hashCode实现。最后一个名为indexFor的方法负责压缩,以确定给定键应该位于哪个桶的索引。在这里,实现者以一种非常有趣的方式与客户端分担了哈希函数的责任

设计哈希函数的要点

如果你要设计自己的哈希函数,这是一项棘手的任务。

以下是设计时需要考虑的几个方面:

  • 压缩:Java和OCaml最终都使桶的数量为2的幂,并通过取模该桶数来进行压缩。
  • 序列化:Java和OCaml都支持序列化,因此你可以获得将任意类型转换为字节串的预实现版本。在OCaml中,它在标准库的Marshal模块中。
  • 扩散:这是真正困难的部分,有各种技术,这里不展开讨论。有时在CS 2800课程中会涉及,具体取决于当学期的授课老师。因此,如果你在当前学期的笔记中找不到,可以回顾过去的2800课程,找到关于模哈希、乘法哈希、通用哈希、加密哈希等技术的笔记。

总结

本节课中我们一起学习了哈希函数的核心概念及其重要性。

哈希很难。如果你没有实现良好的扩散,就会失去常数时间性能,因为键无法均匀分布在各个桶中。如果你的哈希函数本身不是常数时间,你也会失去常数时间性能,因为这是我们在分析中需要做出的假设。正如你从过去的课程中所知,如果你不遵守equalshashCode必须一致的不变量(无论是在Java还是OCaml中),你就会失去正确性。

137:哈希表扩容效率分析 🧮

在本节课中,我们将分析哈希表在扩容(Rehashing)操作时的效率。我们将看到,虽然单次扩容操作在最坏情况下是线性时间,但通过均摊分析,我们仍然可以实现平均的常数时间性能。

哈希表链式实现回顾

上一节我们介绍了使用链式法实现的哈希表。其表示类型包含一个桶数组,每个桶中存储一个关联列表。

每个桶中关联列表的长度可能各不相同。但我们假设哈希函数能将键均匀地分布到各个桶中,从而将关联列表的期望长度保持为一个常数。

因此,查找操作是高效的。但插入操作偶尔需要进行扩容,以将平均桶长度限制在一个常数范围内。

扩容操作的成本分析

在哈希表扩容时,假设其容量为 C,即数组中有 C 个桶。我们约定,会将桶数组的容量加倍,创建一个容量为 2C 的新数组。OCaml 的标准库实现也采用了同样的策略。

以下是扩容操作的主要步骤及其成本:

  1. 重新分配数组:仅分配新数组的成本就是 O(C),因为新数组的长度为 2C
  2. 重新哈希和重新插入:在分配新数组后,我们还需要将原始数组中的每个元素重新哈希并插入到新数组中。这是因为哈希函数现在需要根据新的数组长度(2C)重新计算每个键应归属的桶。这不仅仅是简单的复制。

假设在扩容时,绑定(键值对)的数量 n = 2C(因为我们在绑定数量是桶数量的两倍时触发扩容)。那么:

  • 重新哈希 n 个键(假设哈希函数是常数时间)的成本是 O(n)
  • n 个绑定插入新桶数组的成本也是 O(n)。即使所有键都冲突到同一个桶(最坏情况),我们仍然需要进行 n 次插入。

因此,重新哈希和重新插入的总成本在最坏情况下也是线性时间,即 O(n)

这意味着单次扩容操作的总成本在最坏情况下是线性时间,即 O(n)

深入思考常数因子

根据大 O 记法的定义,它隐藏了一个常数因子。当我们说某个操作是 O(n) 时间,意味着它被某个常数乘以 n 所限定。这个常数可能是 1、2 或 5000。

为了帮助我们思考,让我们暂时考虑这个隐藏的常数。假设扩容成本 O(n) 中隐藏的常数是 R。我们可以进一步将 R 分解为几个部分,每个部分对应扩容中每个绑定(键值对)的平均成本:

  • X 为分配一个桶的成本(我们需要分配 n 个新桶)。
  • Y 为哈希一个键的成本(我们需要哈希 n 个键)。
  • Z 为插入一个绑定的成本(我们需要插入 n 个绑定)。

那么,R 可以表示为 X + Y + Z,它代表了执行重新分配、重新哈希和重新插入时,每个绑定所花费的平均成本

我们实现哈希表的初衷是为了获得常数时间的性能,但单次扩容的线性成本似乎破坏了这个目标。

核心问题与目标

这就引出了核心问题:我们能否将所有这些扩容的成本降低到 O(1)?我们能否以某种方式将其降低到常数成本?

如果我们能做到这一点,那么我们就实现了最初的目标:在映射的关联列表表示和直接寻址表表示之间取得最佳平衡,同时拥有高效的查找和平均高效的插入操作。

总结

本节课中,我们一起学习了哈希表扩容操作的效率分析。我们发现,虽然单次 insert 操作在最坏情况下(触发扩容时)是 O(n) 的,但通过下一节将要介绍的均摊分析,我们可以证明,在一系列操作中,每个操作的平均成本仍然是 O(1)。这使我们能够实现哈希表所承诺的高效性能。

138:哈希表的摊还分析 🏦

在本节课中,我们将要学习一种名为“摊还分析”的技术,它可以帮助我们理解哈希表插入操作的平均成本。我们将通过一个存钱罐的比喻,来解释为什么即使最坏情况下的扩容操作是线性的,插入操作的平均时间仍然可以被视为常数时间。

上一节我们介绍了哈希表扩容操作在最坏情况下是线性的。本节中我们来看看,如何通过摊还分析来论证插入操作的平均时间复杂度是常数。

摊还分析的核心思想 💡

摊还分析的核心思想是:我们分析的不是单个操作的效率,而是一系列操作的效率。通过为每个廉价操作(如普通插入)预留少量“信用”,我们可以为那些罕见但昂贵的操作(如扩容)提前“存钱”。

存钱罐比喻 🐷

让我们想象一个存钱罐。每次执行插入操作时,我们向存钱罐里存入一些钱。当哈希表的负载因子变得过高,需要进行扩容操作时,我们就“砸开”存钱罐,用里面存的钱来支付扩容的成本。

具体来说,如果此时表中有 n 个绑定,我们就花费 n 美元来支付这次扩容操作。

一个具体例子 📊

假设我们有一个哈希表,其容量为 16(即有16个桶),并且已经存有 16 个绑定。此时负载因子 α = 1,我们的“银行账户”余额为 0

现在,我们插入 16 个新的绑定。绑定总数变为 32,负载因子达到 2,此时需要触发扩容。

以下是关键的计算步骤:

  1. 如果我们每次插入只存入 R 美元,那么账户里总共有 16 * R 美元。
  2. 扩容和重哈希操作需要为哈希表中的每个绑定支付 R 美元,总成本为 32 * R 美元。
  3. 16R 中扣除 32R,我们得到了 -16R 美元的负余额,破产了。

显然,我们存的钱不够。

调整策略:加倍存款 💰

如果我们调整策略,每次插入时存入 2R 美元,情况会如何?

再次从相同起点开始(16个绑定,余额为0):

  1. 插入 16 个新绑定后,绑定总数变为 32
  2. 此时我们的账户余额为 16 * 2R = 32R 美元。
  3. 扩容操作的成本依然是 32 * R 美元。
  4. 我们恰好有足够的钱支付这次扩容,账户余额归零。

这个过程可以持续下去。例如,再插入 32 个绑定(总数达64)时,我们需要支付 64R 美元进行扩容。而我们在此期间通过每次插入存入 2R 美元,总共存入了 32 * 2R = 64R 美元,再次刚好够用。

我们找到了一种方法,通过提前存储信用(存钱),来为将来需要的昂贵操作(扩容)支付费用。

生活中的类比:预算与享受 🍣

这就像一个预算问题。当我还是研究生时,我喜欢寿司,但寿司很贵。所以,周一到周四,我可能吃很多便宜的拉面,这样到周五时,我就存够了钱,可以和朋友们一起去享受一顿美味的寿司大餐。

这与哈希表的操作是同样的道理:在频繁的廉价操作(插入/吃拉面)中,我们存下一点额外的钱,以便在最后(需要扩容/想吃寿司时),我们有足够的资金来支付那顿昂贵的大餐。

摊还分析的结论 ✅

通过这种分析,哈希表的查找操作仍然是期望常数时间。而插入操作,则可以被称为摊还常数时间

“摊还”是一个金融术语,它基本上指的就是我们在这里所做的预算模型:通过为每个廉价操作预留一点资金,来支付那些昂贵的操作。

本节课中我们一起学习了摊还分析的概念。我们通过存钱罐的比喻,理解了如何通过分析一系列操作的平均成本,来论证哈希表插入操作具有摊还常数时间复杂度。关键在于,为每个普通插入操作支付一点“额外费用”,从而为将来不可避免的昂贵扩容操作提前做好准备。

139:平摊分析的基本思想 🧮

在本节课中,我们将学习一种名为“平摊分析”的算法效率分析技术。我们将了解其核心思想、发明背景以及它如何帮助我们更准确地评估数据结构的性能。

平摊分析的定义 📖

字典中,“平摊”的定义是:定期留出资金,用于逐步偿还债务

在效率分析中的应用 ⚙️

在效率分析中使用平摊分析时,我们所做的正是上述定义描述的事情。

我们为某些操作支付一些额外的“成本”(这里我将其比喻为“钱”)。这实际上是一种对时间成本的“会计”方法,用于平衡我们将在后续操作与先前操作上花费的时间。

我们利用预留的这种“信用”(类似于时间信用),来支付那些后续操作中可能出现的更高成本。

技术的起源与背景 🧑‍🔬

这种分析技术由SlaterTarjan于1985年发明。

Bob Tarjan与康奈尔大学有一段渊源。他于1986年与我们自己的教授John Hopcroft(最近刚刚退休)共同获得了图灵奖。他们因在算法和数据结构设计与分析方面的基础性成就而获奖。

Bob Tarjan仅在1972年至1973年间担任了一年的康奈尔大学计算机科学系教员。

我曾问过Hopcroft教授,为何Tarjan只在这里待了一年。他告诉我的故事是:Bob本质上是个“加州男孩”,无法忍受伊萨卡冬天的天气,因此在一年后就离开了。



本节课中,我们一起学习了平摊分析的基本思想。我们了解到,平摊分析是一种通过将高成本操作的开销“分摊”到一系列操作中,从而更公平地评估算法平均性能的技术。它由Tarjan等人发明,其核心在于一种时间成本的“信用”会计系统,帮助我们理解数据结构的整体效率,而非孤立地看待单次操作。

140:双列表队列的摊还分析 📊

在本节课中,我们将运用摊还分析的方法,再次审视双列表队列的效率。我们将看到,尽管出队操作在最坏情况下是线性的,但通过巧妙的“记账”方式,我们可以证明其摊还时间复杂度是常数级的。

双列表队列回顾

上一节我们介绍了双列表队列的数据结构。让我们快速回顾一下其核心概念。

双列表队列在抽象上是一个有序的元素序列。但在具体实现中,它被分解为两个列表:一个前列表和一个后列表

  • 前列表:按队列顺序存储元素。
  • 后列表:按逆序存储新入队的元素。

例如,一个包含元素 1, 2, 3, 4, 5, 6, 7, 8 的队列,其具体表示可能如下:

  • 前列表:[1; 2; 3]
  • 后列表:[8; 7; 6; 5; 4]

我们定义了一个表示不变式来保证正确性:如果前列表为空,则后列表也必须为空。这确保了空队列表示的唯一性,并指明了出队操作的位置——我们总是从前列表出队。当前列表变空时,我们将后列表反转,并将其作为新的前列表。

操作效率分析

现在,我们来分析双列表队列中各个操作的效率。

常数时间操作

以下是时间复杂度为常数 O(1) 的操作:

  • peek(查看队首):只需查看前列表的头部元素。
    let peek = function
      | {front = hd::_; _} -> Some hd
      | _ -> None
    
  • enqueue(入队):通常通过 cons 操作将新元素添加到后列表的头部。
    let enqueue x {front; back} = {front; back = x::back}
    
    (注:当队列为空时,有一个特殊情况需要将元素加入前列表以维持不变式,但这仍然是常数时间。)

出队操作的复杂性

出队操作 dequeue 的效率则有些复杂。

  • 通常情况:从前列表的尾部取出元素,这是常数时间操作。
    (* 常规出队 *)
    let dequeue {front = _::tl; back} = {front = tl; back}
    
  • 特殊情况:当前列表变为空时,我们必须执行一个昂贵的操作——将整个后列表反转并设为新的前列表。如果此时队列中有 n 个元素,这个反转操作的成本就是 O(n)

因此,dequeue最坏情况时间复杂度是线性的

摊还分析:平摊成本

虽然 dequeue 有时很昂贵,但我们可以通过摊还分析来证明,如果将成本平摊到一系列操作中,每个操作的平均成本仍然是常数。

想象一个“存钱罐”模型:

  1. 每次执行一次廉价的 enqueue 操作(向后列表添加元素)时,我们就在存钱罐里存入 1 美元
  2. 当遇到那个昂贵的 dequeue 操作(需要反转后列表)时,我们就打开存钱罐,用里面存的钱来支付这个 n 美元的成本。

以下是一个具体示例:

操作序列 队列状态 (前列表, 后列表) 存钱罐余额 说明
初始 ([], []) $0 空队列。
enqueue 1 ([1], []) $0 因前列表空,元素加在前列表,不存钱。
enqueue 2..10 ([1], [10;9;...;2]) $9 执行9次 enqueue,每次存$1。
dequeue ([10;9;...;2], []) $0 前列表空,需反转后列表(成本$9),正好用光存款。
dequeue x 9 ([], []) $0 连续9次出队都从前列表取,成本低,不涉及存钱罐。

通过这种记账方式,昂贵操作的成本被之前多次廉价操作“预付”了。因此,虽然单次 dequeue 的最坏情况是 O(n),但其摊还时间复杂度是 O(1)

总结

本节课我们一起学习了如何对双列表队列进行摊还分析。

  • 我们回顾了双列表队列的结构和表示不变式。
  • 我们分析了 peekenqueue 是严格的常数时间操作,而 dequeue 的最坏情况是线性时间。
  • 通过引入“存钱罐”的比喻和摊还分析,我们证明了 dequeue 操作的摊还成本是常数。这意味着在一系列操作中,每个操作的平均时间开销很小且恒定。

这种分析揭示了数据结构和算法设计中的一个重要思想:即使单个操作可能很昂贵,只要它能被足够多的廉价操作“摊销”,整体性能仍然可以非常高效。

141:摊还分析理论 🧮

在本节课中,我们将学习摊还分析背后的理论。我们已经非正式地看到了两个使用摊还分析的例子,分别是哈希表和双列表队列。现在,让我们更深入地探讨其理论基础。

摊还分析的核心概念

摊还分析的核心在于,我们仍然分析每个操作的实际运行时间。这与你在其他课程中学到的效率分析没有区别。我们仍然关心每个操作的实际运行时间。

但在此基础上,我们引入了一个记账技巧的概念。我们为每个操作定义一个摊还成本。这个成本源于我们自己的设想,但我们必须确保它是正确的。具体来说,我们需要证明,对于任何操作序列,其总摊还成本是总实际成本的一个保守上界。

这意味着,对于一系列操作,我们希望证明:
总实际成本 ≤ 总摊还成本

摊还成本总是比实际成本“更差”(即更高),这正是我们要为整个序列证明的。重要的是,我们是在高估操作的成本,而不是低估,这样才能保证最坏情况下的分析是可靠的。

回到双列表队列的例子

让我们回到双列表队列的例子。通过分析代码,我们可以得到:

  • enqueue 操作的实际成本是 1(因为需要将一个元素 cons 到列表上)。
  • dequeue 操作的实际成本可能是 1(当只需取列表的 tail 时),也可能是 1 + length(back)(当 front 为空,需要反转 back 列表时)。

现在,基于我们的记账设想,我们定义:

  • enqueue 的摊还成本为 2
  • dequeue 的摊还成本为 1

我们在这里规定,dequeue 操作永远只支付1个单位的成本,而任何 enqueue 操作则需要支付双倍的价格。

接下来,我们需要为任何操作序列证明:总摊还成本大于或等于总实际成本。

证明的挑战与方法

为所有可能的操作序列证明这一点是困难的。我们可以为一个具体的序列(例如,四个 enqueue 后跟四个 dequeue)进行计算验证,但这远远不够。我们需要对所有可能的序列都成立。

逐个分析所有可能的序列在数学上并不方便。更简单的方法是能够独立地分析每个操作,而不必考虑整个序列。

摊还分析提供了两种技术来实现这一点,它们被称为:

  1. 银行家方法
  2. 物理学家方法

(注:本视频主要介绍了理论框架和证明目标,后续内容会详细讲解这两种具体方法。)

总结

本节课我们一起学习了摊还分析的理论基础。我们了解到,摊还分析是在实际成本分析之上,通过定义“摊还成本”来进行的一种记账式分析。其核心目标是证明对于任何操作序列,总摊还成本都是总实际成本的一个保守上界。我们还看到了将这一理论应用于双列表队列的具体例子,并引出了两种简化分析的技术:银行家方法和物理学家方法。

142:银行家与物理学家方法 🏦⚛️

在本节课中,我们将学习两种进行摊还分析的重要方法:银行家方法和物理学家方法。这两种方法为分析数据结构的平均性能提供了强大的工具,它们本质上是等价的,只是使用了不同的比喻。

银行家方法 🏦

银行家方法的核心思想是,在数据结构的每个位置“存储”信用(credits)。

例如,对于一个链表,银行家方法会说链表中的每个节点都有自己的“银行账户”。对于一个队列,可以是队列中的每个元素都有自己的信用。对于一个使用链式法的哈希表,可以是哈希表中表示为键值对的每个绑定都有自己的银行账户。

然后,我们定义一个操作的摊还成本为:
摊还成本 = 实际成本 + 存储的信用 - 花费的信用

通常,一个操作要么存储信用,要么花费信用,但也可以混合进行。实际成本会因存储额外信用而增加,或因花费信用而减少。接下来,只需证明没有任何操作会使账户余额变为负数。因为通过这样做,就能保证在整个操作序列中,摊还成本总是至少与实际成本一样大,但我们可以按操作来思考。

以下是队列和哈希表的分析示例:

对于队列,我们可以定义每个操作存储或花费的信用数量。假设 enqueue 操作在入队的元素上存储1个信用。而 dequeue 操作在需要将元素从后端移动到前端时,会花费该信用。这些账户余额永远不会变为负数,它们总是0或1。当一个元素从后端移动到前端时,其信用恰好降为0,并且这种情况最多发生一次。每个元素最多被移动到前端一次,这是一个不变式。

对于哈希表,假设 insert 操作在每个绑定上存储2个信用。在插入时,2个信用存入该绑定。将来某一天,该绑定可能需要被重新哈希和重新插入。那时,我们会花费这两个信用,并且以一种巧妙的方式进行:花费其中一个信用来重新哈希和重新插入该绑定本身,而另一个信用则充当“捐助者”,帮助那些缺少信用的其他绑定。为什么会有缺少信用的绑定呢?因为如果已经发生过重新哈希,那么来自旧周期的任何绑定其账户余额都将为0,此时它需要朋友的帮助。这个朋友就来自自上次重新哈希以来新插入的绑定。此时,新插入的绑定数量将与重新哈希前的旧绑定数量一样多,因此总绑定数是原来的两倍。存储2个信用正好是我们需要的数量,以便成为一个“好朋友”,帮助那些当时账户里没有“钱”的朋友。

物理学家方法 ⚛️

物理学家方法与银行家方法略有不同,但并非完全不同。它的比喻是,将整个数据结构(而不仅仅是特定元素)视为具有势能。可以想象为分析弹簧、弓弦的张力,或一个孩子在秋千顶端即将荡回时的势能等物理比喻。

在物理学家方法中,我们定义操作的摊还成本为:
摊还成本 = 实际成本 + 势能的变化

势能的变化可能为正(增加)也可能为负(减少)。这里我们需要证明势能永远不会为负。同样,这确保了摊还成本始终是实际成本的一个保守上界。

以下是两个示例:

对于双列表队列,我们可以将整个数据结构的势能定义为后端列表的长度。根据定义,这永远不会为负。任何时候,当我们执行导致后端列表反转的实际操作时,我们都可以用存储的势能来支付其成本,因为势能就是后端的长度,而这正是我们反转后端所需存储的量。

对于哈希表,我们可以将势能定义为自上次重新哈希以来插入的绑定数量的两倍。同样,基于与银行家方法相同的原因,这将是足够的。

两种方法的等价性与灵活性 🔄

你可能已经注意到,在上述队列和哈希表的例子中,银行家方法和物理学家方法的分析非常相似。这并非偶然,它们在能力上实际上是等价的,只是理解同一事物的不同比喻。

如果你想将银行家方法的分析转化为物理学家方法,只需将势能定义为数据结构中所有元素存储的信用总数。反之,如果你想将物理学家的分析转化为银行家的分析,只需在数据结构中指定一个位置(例如,选择某个特定元素),将所有信用只存储在那一个位置,并确保那里存储的就是势能。

在我最初分析哈希表和双列表队列时,实际上混合使用了这两种方法:我使用了银行家方法的术语(用“美元”而非“势能”),但又像物理学家方法那样,没有具体说明这些“美元”存储在哪里,只是想象有一个中央“储蓄罐”。

因此,请注意,你并不需要严格遵循任何一种比喻,你只需要能够定义一个操作的摊还成本,并证明你总是能够支付该操作的成本而不会变为负数。换句话说,在进行摊还分析时,始终保持“正数”。

总结 📝

本节课中,我们一起学习了两种进行摊还分析的核心方法:

  1. 银行家方法:为数据结构中的元素分配“信用”,通过管理信用的存储与花费来分析摊还成本。
  2. 物理学家方法:将整个数据结构视为具有“势能”,通过势能的变化来分析摊还成本。

这两种方法在数学上是等价的,选择哪一种取决于哪种比喻更便于理解和分析特定问题。关键在于能够定义出合理的信用或势能函数,并证明其在整个操作序列中始终保持非负,从而保证摊还成本是实际成本的有效上界。

143:函数式映射与集合 🗺️

在本节课中,我们将学习函数式映射(Map)和集合(Set)的实现方式,并比较不同数据结构的性能差异。我们将从已学的三种数据结构(关联列表、直接地址表、哈希表)出发,探讨它们各自的优缺点,并最终引入函数式集合的概念。

已实现的映射抽象数据类型

上一节我们介绍了映射(Map)的抽象数据类型(ADT)。本节中,我们来看看我们已使用三种不同的数据结构实现了它。

以下是三种实现方式及其特点:

  • 关联列表:这是一种函数式数据结构,易于编码实现。但其插入、查找和删除操作的时间复杂度均为线性时间(O(n)),性能较慢。
  • 直接地址表(数组):其插入、查找和删除操作均为常数时间(O(1)),速度很快。但它有两个主要限制:键必须是整数,并且它是可变的数据结构。
  • 链式哈希表:它结合了前两者的思想。查找操作在拥有良好哈希函数的情况下,期望性能是常数时间。插入和删除操作在最坏情况下是线性时间,但通过摊还分析,我们可以认为其具有摊还常数时间的性能。哈希表同样是可变的数据结构。

迈向函数式数据结构

既然哈希表性能已经很好,我们能否用函数式数据结构达到同等或更好的性能呢?我们可能无法超越常数时间的性能,但函数式映射的效率能达到多高?这是我们接下来要探索的问题。😡

不过,我们将在集合(Set)的背景下进行探讨。让我们先简要思考一下映射与集合的关系。

从抽象角度看,一个映射本质上就是一组绑定的集合。事实上,我们一直使用的抽象表示法就是集合表示法。一个映射可能将键K1绑定到值V1,将键K2绑定到值V2,等等。如果你忽略值,就得到了一个集合——一个仅包含键的集合。因此,映射和集合是紧密相关的。实际上,用于实现它们的数据结构在很大程度上是可以互换的。

为了简化接下来的讨论,我将首先开发函数式集合。😡 在最后,只需做一个非常快速的调整,就能将其转变为函数式映射。😡

函数式集合接口

我们将从一个非常简单的集合接口开始。

module type Set = sig
  type 'a t
  val empty : 'a t
  val insert : 'a -> 'a t -> 'a t
  val mem : 'a -> 'a t -> bool
end

其中,'a t 是元素类型为 'a 的集合类型。empty 是空集合。insert x s 将一个元素 x 添加到集合 s 中,返回包含 x 以及 s 中所有元素的新集合。从类型签名可以看出,这是一个函数式数据结构——它接收一个旧集合并返回一个新集合,而不是返回 unit。最后,mem x s 是成员查询操作,用于判断 x 是否是集合 s 的成员。

列表实现示例

以下是一个将集合表示为不包含重复元素的列表的简单模块实现:

module ListSet : Set = struct
  type 'a t = 'a list
  let empty = []
  let mem = List.mem
  let insert x s = if mem x s then s else x :: s
end

列表 [x1; ...; xn] 精确地表示集合 {x1, ..., xn}。空列表表示空集。成员查询可以直接用列表模块的 mem 函数实现。插入操作唯一需要注意的地方是,我们必须检查元素是否已经是集合的成员;如果是,则不改变集合,否则将其添加到列表前端。

由于在插入和查找时都需要遍历当前集合中的所有元素以检查是否存在,因此这两个操作的时间效率都与集合的大小成线性关系(O(n))。

总结

本节课中,我们一起回顾了用关联列表、直接地址表和哈希表实现映射ADT的性能特点。我们认识到,虽然哈希表性能优异,但它是可变的。接着,我们探讨了映射与集合的紧密联系,并为了引入函数式数据结构,定义了一个简单的函数式集合接口。最后,我们看到了一个基于列表的简单实现,但其核心操作(insertmem)仍然是线性时间复杂度。这引出了一个问题:是否存在更高效的函数式集合/映射实现?我们将在后续课程中探索答案。

144:二叉搜索树实现集合

在本节课中,我们将学习如何使用二叉搜索树来实现一个比线性时间更高效的集合数据结构。我们将探讨BST的核心概念,并实现其成员检查和插入操作。

概述

上一节我们讨论了基于列表的集合实现,其时间复杂度为线性。本节中我们来看看如何利用二叉搜索树将时间复杂度降低到对数级别。

二叉搜索树简介

回想一下数组的线性搜索与二分搜索,你可能会猜到答案是肯定的,我们可以做得更好。线性搜索需要扫描整个数组,其运行时间为线性,即 O(n),其中 n 是数组长度。但二分搜索不需要扫描整个数组,而是反复缩小搜索空间。当然,这要求数组满足一个不变式:它必须是有序的。但这将搜索算法的运行时间降低到了 O(log n)。我们不断将待搜索的数组大小减半,这对应于对数操作。

从早期的编程课程中,你会记得一个相关的概念:二叉搜索树,它使得搜索元素更加高效。在二叉树中,每个节点都有两个子树。但二叉搜索树不变式,或称BST不变式,规定:如果一个节点包含值 v,那么其左子树中的所有值都必须小于 v,其右子树中的所有值都必须大于 v

我们可以使用二叉搜索树来实现一个更高效的集合。

实现BST集合

以下是使用模块 BSTSet 实现集合接口的起始代码。表示类型使用了我们熟悉的树类型。

type 'a tree =
  | Leaf
  | Node of 'a tree * 'a * 'a tree

这里的抽象函数是:Leaf 表示空集。如果一个节点包含值 v 以及子树 lr,那么它表示包含 v 的集合,并与 lr 所表示的集合取并集。

我的表示不变式就是BST不变式:对于树中的每个节点,其左子树中的所有值都必须小于节点值 v,其右子树中的所有值都必须大于 v

空集就是空树,即一个叶子节点。我需要实现两个操作:meminsert

实现成员检查(Mem)

让我在实现 mem 时以BST不变式为指导。简单的情况是,如果我查看的是一棵空树,那么任何元素都不可能是该集合的成员,因此在这种情况下我返回 false

如果我查看的树中有一个节点,那么我想将我要查找的值 x 与该节点中的值 v 进行比较。如果 x 小于 v,那么我想递归地在左子树中查找。如果 x 大于 v,那么我想递归地在右子树中查找。否则,x 必须等于 v,那么我就找到了集合中的这个元素,可以返回 true

实现插入(Insert)

insert 的算法与 mem 的算法几乎相同。在这两种情况下,我们都需要使用BST不变式来确定节点在树中的位置。区别在于,对于 mem,我们在那里查找它,然后根据是否找到返回 truefalse;而对于 insert,一旦我们到达正确的位置,我们就把元素放在它应该在的地方。

因此,insert 的代码看起来几乎和 mem 的代码一样。让我们在这里暂停一下,我有一个未详尽处理的模式匹配需要解释。

如果我们有一棵空树,那么我们通过创建一个具有空子树的新节点来插入新元素 x

如果我们有一棵包含节点的非空树,那么我们使用BST不变式来比较要插入的元素 x 与节点中已有的值 v。如果 x 小于 v,我们递归地在左子树中插入。如果 x 大于 v,我们递归地在右子树中插入。否则,我们正好处于该元素应该在的位置,因此我们可以直接返回已经存在的节点,因为 x 必须等于 v

关于这个实现有几点说明。我们已经将 Node(l, v, r) 作为模式匹配的一部分。现在我们正在创建一个新节点。我们可以通过使用 as 模式给上面匹配到的节点起一个名字,然后在这里直接返回 n 来进行微小的优化。这样稍微好一点。

此外,这里一个常见的错误是只递归处理子树,但忘记在其周围实际重建树的其余部分。你可能会在这里递归处理左子树并将 x 插入到适当的位置,但现在你完全忘记了 v(它应该仍在集合中)以及右子树中所有应该在集合中的元素。所以这很容易出错,但不要忘记用 vr 重建那个节点。

总结

本节课中,我们一起学习了如何使用二叉搜索树来实现一个高效的集合数据结构。我们理解了BST不变式的核心概念,并实现了基于此的成员检查和插入操作,从而将时间复杂度从线性降低到了对数级别。

145:二叉搜索树效率分析 🧮

在本节课中,我们将要学习二叉搜索树操作的效率。我们将探讨插入和查找操作的性能,分析其在不同数据输入顺序下的表现,并理解为什么二叉搜索树并不总是能保证对数级的性能。


二叉搜索树操作的效率

上一节我们介绍了二叉搜索树的基本操作。本节中我们来看看这些操作的效率。

插入和查找操作的效率并不一定是对数级的,尽管这是我们最初设计二叉搜索树的初衷。

在我展示的这棵树上,我们确实获得了良好的对数级操作,能够高效地查找树中的元素或进行插入。这是因为这棵树非常密集和紧凑,所有节点都紧密地聚集在一起,树中没有很长的路径。

但是,树可以呈现其他形状,同时仍然表示相同的集合。事实上,我在这里画的第二棵树仍然满足二叉搜索树的不变性。但如果你想找出7是否是这棵树中的一个元素,你必须遍历树中的所有节点,而不是仅仅沿着一条短路径向下查找。

实际上,这棵树已经退化成了本质上只是一个链表。我们知道基于链表的集合实现的效率。我们刚刚创建了它。如果你想在其中插入或查找一个元素,那是线性时间,而不是对数时间。

树形依赖于插入顺序

假设你要插入元素1到4,并且你恰好以这个随机顺序插入它们:3,2,4,1。你会从3开始,然后插入2,根据BST不变性,它必须放在左边。然后插入4,根据BST不变性,它必须放在右边。要插入1,根据BST不变性,它必须放在2的左边。所有这些都是完全确定的。不变性准确地告诉你每个节点必须插入的位置。只有插入顺序控制着树的形状。

然而,假设你要插入相同的元素,但是以线性顺序1,2,3,4。只有一种方式可以进行这种插入。你会从1开始。然后根据BST不变性,2必须放在它的右边。3必须放在2的右边。4必须放在3的右边。因此,当你以这种递增顺序插入元素时,最终得到的树就退化成了一个列表。我们称这样的树为不平衡的。它向一个方向倾斜。在这种情况下,它向右倾斜,因为我们是以升序插入的。如果我们碰巧以降序插入,我们会得到对称的情况,即一棵向左倾斜的树。

这造成了很大的差异。插入顺序确实对基于二叉搜索树的集合的性能至关重要。

性能测试数据

让我用一些具体的数字来说明这一点。我将向你展示我在这个二叉搜索树实现上运行的一些性能测试数据。

在我即将展示的数字中,有两个工作负载。

  • 工作负载1是升序插入元素。依次插入1,然后2,然后3,然后4,依此类推。实际上,我将以升序插入50,000个元素。在所有插入完成后,我将调用mem函数100,000次,其中一半是针对集合中的元素,另一半是针对不在集合中的元素。
  • 工作负载2是随机顺序。我将以完全随机的顺序插入50,000个不同的元素。然后我再次进行100,000次成员资格测试,其中一半针对集合中的元素,另一半针对不在集合中的元素。

你准备好看这些数字了吗?我们开始吧。

首先,对于升序工作负载,我们的链表集合实现。插入所有50,000个元素花费了22秒,然后mem检查100,000个不同元素又花费了大约一分钟。这不是很好的性能,但我们知道链表并不是特别高效的实现。

对于二叉搜索树,我们希望它比链表集合更快,但看看那些运行时间。它们很糟糕。插入操作花费了91秒,mem操作花费了87秒。

为什么会这样?我们之前说过,如果你以升序插入,BST会退化为一个链表。实际上,情况甚至更糟,因为如果你查看代码,insertmem实际上都是很好的实现,尽管它们必须读取列表的所有元素,但它们对列表的唯一修改是在最前面连接一个元素。另一方面,BST集合中的insert操作之所以如此糟糕,是因为它们不仅仅对树进行一次修改。

事实上,如果你插入一个比目前所有其他节点都大的节点,你将沿着树的右脊柱向下走,并在返回时重建一棵新树。所以你实际上是在创建新的树,这需要时间和空间。从渐近角度看,时间性能仍然是相同的。但这里的常数因子变得大得多。这就是为什么对于升序工作负载,我们最终得到如此低效的BST集合实现。

对于随机工作负载,我们得到了一个更漂亮的图景。链表集合的性能大致相同,但对于BST集合,请注意我们现在让它们运行得有多快。我们得到了一个插入操作,所有50,000次插入在0.05秒内完成,而mem操作,所有100,000次成员资格测试在0.04秒内完成。这些正是我们寻找的那种数字。

我们之所以获得更好的性能,是因为在随机工作负载下,树的形状更好。这棵树看起来更像一棵内部有短路径的树,而不是一棵有很长路径的树。

效率总结

因此,对于二叉搜索树,插入和查找操作的效率在最坏情况下是线性的。这是程序员有时会犯的一个常见错误。他们假设二叉搜索树能提供对数性能,但事实并非如此。这真的取决于工作负载。

但是,如果你能找到一种方法来保证树总是有短路径而不是长路径,你就可以做得更好,你就可以获得那种对数性能。我们如何才能获得更短的路径?我们需要平衡这些树,这样即使插入顺序很糟糕,它们也不会倾斜。


本节课中我们一起学习了二叉搜索树操作的效率。我们了解到,在最坏情况下(如按顺序插入数据),二叉搜索树会退化成链表,导致插入和查找操作的时间复杂度为O(n)。而在理想情况下(如随机插入),其性能可以达到O(log n)。关键在于保持树的平衡,以避免性能退化。

146:平衡二叉树 🧑‍💻

在本节课中,我们将要学习平衡二叉树的概念及其重要性。我们将探讨几种著名的平衡二叉搜索树数据结构,了解它们如何通过不同的“不变量”来维持树的平衡,从而保证对数级别的时间复杂度。


理想情况:完美二叉树 🌳

上一节我们介绍了二叉搜索树的基本操作。本节中我们来看看如何让这些操作更高效。最理想的情况是树的结构像下图所示。

这是一棵完美二叉树,其中所有从根节点到叶子节点的路径长度都相同。在这种树中,查找、插入和删除操作的时间复杂度是对数级别的,即 O(log n),其中 n 是树中节点的数量。

即使我们无法让树达到完美的状态,我们也希望其形状能近似于这种完美二叉树。


实现平衡的策略 ⚖️

为了实现平衡,我们通常采取的策略是:强化二叉搜索树的“不变量”要求,使其包含平衡条件;并修改插入和删除操作,确保这个新的不变量始终成立。

以下是几种著名的平衡二叉搜索树数据结构:

  • 2-3树:由约翰·霍普克罗夫特教授发明。在2-3树中,所有路径的长度都相同。这是通过允许每个节点存储两个或三个子节点来实现的。这种结构保证了严格的平衡,但也使数据表示和算法变得稍微复杂一些。
  • AVL树:其不变量比2-3树稍宽松一些。它要求对于树中的任何节点,其左子树和右子树的高度差最多为1。这意味着路径长度不需要完全相同,但差异被严格控制在一个很小的范围内。
  • OCaml标准库的变体:实际上,OCaml标准库在其树实现中使用了一种AVL树的变体,它允许最短路径和最长路径的长度最多相差2,这是一个更为宽松的不变量。
  • 红黑树:拥有一个更为宽松的不变量。它要求从任何节点到其子孙叶子节点的所有路径中,最长路径的长度不超过最短路径长度的两倍。虽然这听起来差异较大,但事实证明这足以实现平衡,并为我们提供对数时间复杂度的操作。


总结 📚

本节课中我们一起学习了平衡二叉树的核心思想。我们了解到,通过为树的结构引入并维护一个平衡的“不变量”(如高度差限制或路径长度比例限制),可以确保树不会退化成低效的链表形态。无论是严格的2-3树、高度平衡的AVL树,还是通过颜色规则维持平衡的红黑树,它们的目标都是一致的:将操作的时间复杂度保持在 O(log n),从而构建出既正确、高效又优雅的数据结构。

147:红黑树 🎄

在本节课中,我们将要学习一种极其有用、实用且常见的数据结构——红黑树。我们将了解其定义、核心不变式,以及它如何保证对数级别的性能。

红黑树是一种非常有用、实用且常见的数据结构。事实上,你可以在Linux内核、C++标准模板库以及Java的TreeMap实现中找到它们。在CS3110课程中,我们非常喜爱红黑树,它甚至是课程徽章的一部分。徽章中的树包含一些红色和黑色的节点。当然,徽章的另外两部分是列表和将输入转换为输出的函数。

红黑树发明于1978年。我将要教你们的是由Okasaki在1998年发明的一个函数式版本。

红黑树定义

首先,红黑树是一棵二叉搜索树。但除此之外,树中的每个节点都被着色,要么是红色,要么是黑色。为什么选择这两种颜色?这让我很困扰,因为我恰好是红绿色盲,几乎分辨不出红色,它在我眼里大多是灰色的。据说最初选择这两种颜色是因为当时他们有一台彩色打印机,而这两种颜色是那台打印机上能打印出的最好的颜色。如今我们只能沿用这个传统。

按照惯例,红黑树的根节点和叶子节点总是黑色的。因此,只有中间的一些节点可能被染成红色。

核心不变式

红黑树的不变式是理解它的最重要的事情之一。

首先,不变式要求这棵树是一棵BST,因此BST不变式是红黑树不变式的一部分。除此之外还有另外两个部分。

第一个,我称之为局部不变式。红黑树的局部不变式规定:任何红色节点都不能有红色的子节点。所以你永远不会看到两个连续的红色节点。我称之为“局部”是因为你可以在每个节点处本地检查它,只需查看一个节点及其子节点。当然,你需要对所有节点都这样做,但这仍然是一种可以在节点本地进行的检查。

然而,全局不变式则不那么容易检查。全局不变式规定:从根节点到任何叶子节点的每条路径上,黑色节点的数量必须相同。如果从根到某片叶子的路径上有五个黑色节点,那么所有路径都必须有五个黑色节点。这并不意味着所有路径的长度相同,有些路径中间可能穿插着一些红色节点,但它们都必须有相同数量的黑色节点。

顺便提一下,这是一个很好的例子,说明了我们为什么不想在函数入口处检查整个前置条件或断言前置条件。因为每个红黑树操作都隐式地将这个不变式作为前置条件,而检查这些不变式实际上需要查看树中的每个节点。因此,如果我们断言前置条件,这将自动导致每个操作的时间复杂度至少变为线性,甚至更糟。

示例分析

让我们看一些例子。

这里有一个可能是红黑树的候选,但它违反了不变式,因为全局不变式被破坏了。沿着树的左侧路径向下,我们有两个黑色节点(以及该黑色节点下方隐含的一些黑色叶子,但我们通常不考虑叶子,甚至不画出来)。而在树的右侧路径上,我们只有一个黑色节点。因此,这些路径的“黑色长度”不同,这违反了全局不变式。




这是另一棵树。这棵树违反了局部不变式,因为它有两个连续的红色节点。节点2和1都是红色的,这在红黑树中是不允许的。


这是一棵有效的红黑树。所有路径的黑色长度都相同,每条路径上都有两个黑色节点(如果算上叶子,则是三个)。并且我们没有两个连续的红色节点。现在,子路径的长度不同是可以接受的,这棵树中有些路径长度为2,有些为1,这没问题。每条路径上红色节点的数量不同也没问题,这不是任何不变式的一部分。路径上连续出现几个黑色节点也是可以的。

性能保证

关于这些路径长度,有一个引理可以证明:红黑树中最长路径的长度至多是最短路径长度的两倍。你可以通过思考一条只有黑色节点的路径来获得一些直觉。假设树中有一条路径有四个黑色节点,并且该路径上只有这些节点。那么,根据全局不变式,该树中的每条其他路径都必须有四个黑色节点。那么,如何使某些路径变得更长呢?你可以在某些路径上插入一些红色节点,但可能不是所有路径。你可以在哪里插入这些红色节点呢?我们说过,按照惯例根节点是黑色的,所以不能在根节点处插入。我们还说过,永远不能有两个连续的红色节点,这是局部不变式。因此,你能插入的最大数量是在一条路径上每对黑色节点之间插入一个红色节点。这样,一条在四个黑色节点之间长度为3的路径,就可以扩展到长度为6。


这就是红黑树实现对数性能的方式。事实上,有一个定理:大小为 n 的红黑树中,节点的最大深度至多为 2 * ⌊log₂(n) + 1⌋(这里log以2为底)。由于大O表示法允许我们忽略常数因子,我们也可以忽略那个2,这并不重要。这意味着我们所有的操作都将在树大小的对数时间内运行。

这难道不棒吗?我们通过强制执行红黑不变式获得了对数性能。这意味着红黑树是平衡的,并且所有操作通常都在对数时间内运行。

总结

本节课中,我们一起学习了红黑树的基本概念。我们了解到红黑树是一种平衡的二叉搜索树,通过为节点着色(红或黑)并强制执行两个核心不变式——局部不变式(无连续红节点)和全局不变式(所有根到叶路径的黑色节点数相同)——来保证其操作(如查找、插入、删除)的时间复杂度为 O(log n)。这种数据结构因其高效和实用,被广泛应用于各种系统和编程语言的标准库中。

148:红黑树实现 - 表示类型、空集与成员判断

在本节课中,我们将学习如何使用红黑树来实现集合抽象数据类型。我们将从定义表示类型开始,然后实现空集和成员判断操作。

概述

我们将使用红黑树来实现集合ADT。首先,我们会定义一个变体类型来表示红色和黑色。我们的集合表示类型将基于之前用于二叉搜索树的二叉树,但会增加一个额外的字段来存储颜色。

表示类型定义

我们使用一个变体类型来表示红色和黑色两种颜色。

type color = Red | Black

集合的表示类型将是我们之前用于二叉搜索树的二叉树,但有一个重要的补充:我们将在节点元组中包含颜色信息,以及左子树、右子树和节点存储的值。

type 'a rbtree =
  | Leaf
  | Node of color * 'a * 'a rbtree * 'a rbtree

抽象函数与表示不变式

对于抽象函数,叶子节点代表空集。一个节点则代表包含该节点值以及左、右子树所表示集合中所有元素的集合。请注意,颜色与集合本身无关,它不影响集合中包含哪些元素。

对于表示不变式,我们使用二叉搜索树不变式以及红黑树的局部和全局不变式。

空集实现

空集的实现非常简单,直接使用叶子节点构造器即可。

let empty = Leaf

成员判断操作

成员判断操作与我们为二叉搜索树实现的成员操作几乎完全相同。

以下是成员判断操作的实现:

let rec mem x = function
  | Leaf -> false
  | Node (_, v, left, right) ->
      if x < v then mem x left
      else if x > v then mem x right
      else true

唯一的区别在于模式匹配中,我们使用通配符 _ 来忽略颜色字段,除此之外,代码与二叉搜索树的成员判断完全相同。

总结

本节课中,我们一起学习了红黑树实现集合ADT的基础部分。我们定义了表示颜色的类型和红黑树的表示类型,理解了抽象函数和表示不变式的概念,并实现了空集和成员判断操作。下一节中,我们将探讨更具挑战性的插入操作实现。

149:冈崎插入算法 🧮

在本节课中,我们将要学习如何为红黑树实现插入操作。我们将从一个简单的二叉搜索树插入代码开始,然后探讨如何为新增节点选择颜色,并最终介绍冈崎提出的一种优雅算法,该算法通过临时违反局部不变量,再通过旋转操作进行修复,从而高效地完成插入。

从二叉搜索树插入开始

上一节我们介绍了红黑树的基本概念和不变性。本节中我们来看看如何实现插入操作。我们可以复用二叉搜索树的插入代码作为起点。

如果从一棵空树(即一个叶子节点)开始,并希望插入一个元素 X,我们将创建一个新节点来存储 X,并为其分配空的子树。

type color = Red | Black
type 'a tree = Leaf | Node of color * 'a * 'a tree * 'a tree

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/crnl-cs3110-ocaml/img/e1faa0cbdd5397363a5814abed84a1f8_1.png)

let insert_bst x t = ... (* 标准BST插入逻辑 *)

但由于这是红黑树,我们现在必须为这个新节点选择一个颜色。

新节点的颜色选择难题

那么,我们可以安全地选择什么颜色呢?让我们通过一个例子来思考。

假设我们有一棵红黑树,并希望插入元素 4。根据二叉搜索树的不变性,它只能被放在节点 5 的左侧。

如果我们将其着为红色

这将违反局部不变量(红节点的子节点必须是黑的)。因此,总是将新节点着为红色并不安全。

如果我们将其着为黑色呢?这违反了全局不变量(从根到每个叶子的路径必须包含相同数量的黑节点)。因为在此之前,每条路径有一个黑节点,现在有些路径有一个,有些路径有两个。所以,总是将新节点着为黑色也不安全。



冈崎算法的核心思想

那么该如何解决这个问题呢?冈崎在1998年为函数式红黑树提出了一种特别优雅的算法。

在展示这个算法之前,我需要提醒你:如果你查看在线的红黑树模拟器,你很可能会发现它们实现的算法与冈崎算法不同。因此,不要完全相信你在其他网站上找到的内容。😡

冈崎算法的核心原则基于以下几点:

  1. 我们将始终维护二叉搜索树不变性全局不变量
  2. 但我们愿意暂时牺牲局部不变量

具体操作步骤如下:

  • 插入并着色:我们总是将新节点着为红色,即使这可能导致局部不变量被违反。
  • 递归修复:在插入这个红色新节点后,我们递归地向上回溯。
  • 检测与旋转:在回溯过程中,我们持续检查当前节点下方的两个直接子节点。😡 如果我们检测到连续出现两个红色节点的违规情况,我们就对节点进行旋转操作。
  • 恢复与平衡:旋转的目的是使局部不变量再次成立,同时在这个过程中平衡树的结构,使其形状更好。

算法步骤概述

以下是该算法关键阶段的一个简要总结:

  • 阶段一:红色插入:始终以红色插入新节点,接受可能产生的临时“红-红”冲突。
  • 阶段二:向上回溯:从插入点开始,沿路径向根节点回溯。
  • 阶段三:模式匹配与旋转:在回溯的每个节点处,检查其子节点和孙节点的颜色模式。当匹配到特定的“红-红”违规模式时,应用预定义的旋转和重新着色操作来消除冲突,并将“红色”向上推送。
  • 阶段四:根节点处理:最终,回溯到根节点。如果根节点被染成了红色(这是旋转过程中红色上推可能导致的),我们将其重新着为黑色。这会使所有路径的黑高增加1,但全局不变量(所有路径黑高相等)依然保持。

总结

本节课中我们一起学习了冈崎的红黑树插入算法。我们从简单的BST插入出发,发现了直接为新节点选择颜色的困难。冈崎算法通过一个巧妙的策略解决了这个问题:总是插入红色节点,允许暂时的局部违规,然后在递归回溯的过程中,通过旋转和重新着色来修复这些违规。这种方法在保持二叉搜索树性质和全局黑高不变量的前提下,高效地维护了红黑树的结构。理解这个“先破坏,后修复”的范式,是掌握函数式红黑树操作的关键。

150:旋转操作

在本节课中,我们将学习红黑树插入操作后,如何通过旋转来修复可能违反的局部不变式(即一个红节点不能有红子节点)。我们将详细分析四种可能的违规情况,并学习如何通过旋转和重新着色来恢复平衡。

上一节我们介绍了红黑树插入后可能违反的局部不变式。本节中我们来看看如何通过旋转操作来修复这些违规。

插入一个红节点后,只有四种可能的方式会违反局部不变式。所有情况都涉及一个黑父节点、一个红子节点,以及该红子节点下又出现了一个红节点(即违规的红节点)。父节点必须是黑色,因为原树满足局部不变式。之所以有四种情况,是因为在树的每个位置,你可以选择向左或向右走,然后再向左或向右走。

以下是四种违规情况的图示,我们将其命名为情况1到情况4。



情况1分析

让我们先看第一种情况。图中节点标记为X、Y、Z,代表节点存储的值。从X、Y、Z延伸出的子树标记为A、B、C、D。我们暂时不关心这些子树的具体内容。

这里的违规点是X作为第二个红节点出现。我们来看看如何旋转这棵树以获得更多平衡,并恢复局部不变式。

首先,我们将选择Y作为新构建子树的根节点。选择Y并非随机,因为它是违规的红节点中,在值上最接近Z的节点。根据二叉搜索树(BST)不变式,X < Y < Z,因此Y比X更接近Z。

想象一下,我们物理上“提起”Y节点,其他节点因其“重量”在重力作用下围绕Y重新排列。以下是它们落下的位置:

  • Z落在Y的右侧。根据BST不变式,Z的值大于Y,所以它必须在Y的右边。
  • 子树C和D也落在Y的右侧。在原树中,C是Y的右子树,所以C中的所有值都大于Y。同时,Y < Z,且D是Z的右子树,所以D中的所有值也大于Y。
  • 具体来说,D成为Z的右子树,因为D中的所有值都大于Z。
  • C成为Z的左子树,因为原树中C就在Z的左侧,意味着C中的所有值都小于Z。

以上所有安排都遵循BST不变式。

接下来,我们需要处理违规节点X。它将成为Y的左子节点(根据BST不变式)。我们将X的颜色改为黑色,这修复了X和Y之间的局部不变式违规。

子树A和B成为X的子节点,位置与原树相同。它们的值没有变化,BST不变式对此没有影响。

现在考虑我们刚刚创建的黑色节点X。我们需要确认在操作过程中全局不变式(从根到任何叶子的路径上黑色节点数量相同)得以维持。在原树中,我们展示的所有路径上都有一个黑色节点。子树A、B、C、D中可能还有自己的黑色节点,但由于原树满足全局不变式,这些子树中的黑色节点数量是相等的。在新树中,我们展示的所有路径上仍然有一个黑色节点(即X)。A、B、C、D中的黑色节点数量保持不变,因此全局不变式得以维持。

至此,我们成功维持了BST不变式和全局不变式,并修复了X和Y之间的局部不变式违规。

但出现了一个新问题:Z在原树中可能不是整个红黑树的根节点,它上面可能还有其他节点。当我们返回这个以红色节点Y为根的新子树时,可能会创造一个新的违规。因为Z在原树中的父节点可能是红色的,那么Y就会成为它下面的第二个红节点。因此,我们需要继续向上递归,处理Z的原父节点,重新平衡,并一直进行到整个红黑树的根节点。

其他情况与Okasaki算法

第二种旋转操作与第一种类似。我们再次选择Y作为新根,因为它在值上最接近Z(X < Z,但Y > X,所以Y仍然比X更接近Z)。我们提起Y,让其他节点落在正确位置,并将X重新着色为黑色。

第三和第四种旋转操作与我们刚刚看到的操作基本对称,因此不在此详细展开。

但请注意这些幻灯片中每一页的右侧结果树。无论输入是哪种违规情况,输出树的结构都是相同的。这就是Okasaki算法的精妙之处。这意味着可以用模式匹配非常优雅地实现这个算法。

以下是实现代码:

let balance = function
  | Black, z, Node (Red, y, Node (Red, x, a, b), c), d
  | Black, z, Node (Red, x, a, Node (Red, y, b, c)), d
  | Black, x, a, Node (Red, z, Node (Red, y, b, c), d)
  | Black, x, a, Node (Red, y, b, Node (Red, z, c, d)) ->
      Node (Red, y, Node (Black, x, a, b), Node (Black, z, c, d))
  | color, value, left, right -> Node (color, value, left, right)

代码中的四个模式分别对应四种旋转情况。但只有一个右侧分支,我们使用|(或)模式将它们组合在一起。所有那些用图示展示的复杂逻辑,实际上可以用这几行模式匹配代码实现。

这并不是说你能直接从这段代码中获得算法如何运作的直觉。事实上,在这方面图示比代码更容易理解。但当你查看命令式语言(如Java、C++)中的红黑树实现时,你会发现它们远没有这么简洁。

算法收尾

让我们完成Okasaki算法的描述。我们先将新节点着为红色,然后递归向上回溯,在回溯过程中进行平衡操作。当我们到达最顶部时,算法的最后一步是将根节点着为黑色。因为有可能在重新平衡的过程中,我们将一个红节点推到了根的位置。如果根节点是红色,我们在此最后一步将其重新着为黑色,这将使树的黑高(任何路径上的黑色节点数量)增加1。这是整个算法中唯一允许黑高增加的地方。

本节课中我们一起学习了红黑树插入后的四种旋转操作,理解了如何通过选择特定节点作为新根、重新排列子树以及重新着色来修复局部不变式违规,同时保持BST和全局不变式。我们还看到了Okasaki算法如何用简洁的模式匹配统一处理这四种情况,并在最后通过将根节点着为黑色来保证全局性质。

151:红黑树插入实现 🧮

在本节中,我们将学习红黑树插入操作的具体代码实现。我们将分析平衡操作和插入函数的逻辑,理解它们如何协同工作以维护红黑树的性质。

概述

红黑树是一种自平衡的二叉搜索树。它通过一组颜色规则和旋转操作来确保树的高度大致平衡,从而保证插入、删除和查找等操作的时间复杂度为 O(log n)。本节我们将深入探讨其插入操作的实现细节。

平衡操作

首先,我们来看平衡操作的代码。这个函数负责在插入后检查和修复可能违反的红黑树性质。

let balance = function
  | Black, z, Node (Red, y, Node (Red, x, a, b), c), d
  | Black, z, Node (Red, x, a, Node (Red, y, b, c)), d
  | Black, x, a, Node (Red, z, Node (Red, y, b, c), d)
  | Black, x, a, Node (Red, y, b, Node (Red, z, c, d)) ->
      Node (Red, y, Node (Black, x, a, b), Node (Black, z, c, d))
  | color, value, left, right -> Node (color, value, left, right)

这个函数是常数时间复杂度的,它不进行递归。它的核心工作是模式匹配树的前两层结构,检查是否存在连续的红色节点。如果发现违反规则的情况(即出现“红-红”冲突),它就执行相应的旋转操作并构造一棵新的树。否则,它直接返回原树。

辅助插入函数

接下来,我们分析辅助插入函数 insert_aux。这个函数是插入操作的核心,其逻辑与普通二叉搜索树的插入非常相似。

let rec insert_aux x = function
  | Leaf -> Node (Red, x, Leaf, Leaf)
  | Node (color, v, left, right) as node ->
      if x < v then
        balance (color, v, insert_aux x left, right)
      else if x > v then
        balance (color, v, left, insert_aux x right)
      else
        node

以下是 insert_aux 函数的关键步骤:

  1. 插入到叶子节点:如果当前节点是 Leaf,则创建一个新的节点。新节点的值为 x,左右子树均为 Leaf,并且始终将这个新节点着色为红色。这正是 Okasaki 算法的要求:新插入的节点总是红色,即使这可能暂时违反局部的不变性规则。
  2. 递归插入:如果当前不是叶子节点,函数会比较待插入值 x 与当前节点值 v 的大小。
    • 如果 x < v,则递归地在左子树中插入 x
    • 如果 x > v,则递归地在右子树中插入 x
    • 如果 x = v,说明值已存在,直接返回原节点。
  3. 调用平衡:与普通BST插入的主要区别在于,在每次递归调用返回后,insert_aux 会立即将结果(新的左子树或右子树)与当前节点的其他部分一起传递给 balance 函数。这样,在递归回溯的过程中,每一层都会尝试修复可能因插入红色节点而破坏的平衡性。

主插入函数

最后,我们来看主插入函数 insert。它是对外提供的接口,内部使用 insert_aux 完成大部分工作。

let insert x t =
  match insert_aux x t with
  | Leaf -> failwith "insert: impossible"
  | Node (_, v, left, right) -> Node (Black, v, left, right)

insert 函数的工作流程如下:

  1. 它首先调用 insert_aux x t 在树 t 中插入值 x
  2. 然后对 insert_aux 的返回结果进行模式匹配。
    • 第一个分支 Leaf -> ... 理论上永远不会发生,因为向非空树插入元素不会返回空树。这里包含它只是为了实现详尽模式匹配,并通过 failwith 处理意外情况。
    • 第二个分支是主要逻辑。无论 insert_aux 返回的根节点是什么颜色,insert 函数都会将其重新着色为黑色。这是完成插入操作的最终步骤,确保了红黑树的根节点始终是黑色这一全局性质。

时间复杂度

insert 函数的时间复杂度是对数级的 O(log n)。因为它调用的 insert_aux 函数沿着树向下递归一次(O(log n)),然后在回溯的每一层调用常数时间的 balance 函数。这个对数复杂度的保证,源于我们之前讨论过的关于红黑树大小和路径长度的引理和定理。

总结

本节课我们一起学习了红黑树插入操作的完整实现。我们首先分析了常数时间的 balance 函数,它通过模式匹配和旋转来修复局部冲突。然后,我们深入探讨了递归的 insert_aux 函数,它像普通BST一样插入节点(但总是着红色),并在回溯时调用 balance 维持平衡。最后,主 insert 函数确保根节点为黑色,完成了整个插入过程。整个算法精巧地保证了树在插入后仍能维持红黑树的所有性质,从而确保操作的高效性。

OCaml编程:8.36:红黑树集合性能分析 🎯

在本节中,我们将回顾红黑树操作的效率,并分析其性能表现。

上一节我们介绍了红黑树的操作实现,本节中我们来看看这些操作的性能如何。

红黑树操作的效率分析如下:

以下是mem操作的效率分析:

  • mem操作在对数时间内运行。
  • 其最坏情况是必须遍历树中最长的路径。
  • 由于红黑不变式的保证,该路径的长度至多为 O(log n)

以下是insert操作的效率分析:

  • 对于insert操作,最坏情况是沿着最长路径向下遍历,然后在递归返回时再向上回溯。
  • 这同样需要 O(log n) 的时间。
  • 在向上回溯路径的过程中,我们会进行旋转操作,但每次旋转仅需常数时间,因此不会增加渐近复杂度。

通过使用这些更高效的红黑树操作,我们获得了期望的性能表现,无论是在升序工作负载还是随机工作负载下。

因此,这个红黑树集合实现的insertmem性能,与二叉搜索树集合在随机工作负载下的性能完全可比。

😊 无论是对于红黑树集合执行升序工作负载还是随机工作负载,其性能大致相同。我们在此看到的数据存在微小差异,这只是测量中的实验误差。为了获得更精确的信息,本应进行大量重复实验并计算置信区间,但希望当前的演示足以证明,通过使用平衡二叉树的实现,我们能够获得非常理想的性能,而不会因某些插入顺序导致性能急剧下降的极端情况。

本节课中我们一起学习了红黑树meminsert操作的时间复杂度均为O(log n),并通过性能对比图表验证了其在升序和随机插入序列下都能保持稳定高效的性能,成功避免了普通二叉搜索树可能出现的性能退化问题。

153:用红黑树实现映射表 🗺️

在本节课中,我们将学习如何利用红黑树来实现映射表(Map)这一抽象数据类型。我们将看到,只需对之前用于实现集合(Set)的红黑树进行简单的修改,就能得到一个高效、持久化的映射表实现。

从集合到映射表

上一节我们介绍了如何用红黑树实现集合。本节中,我们来看看如何将其扩展为映射表。映射表与集合的主要区别在于,每个节点不仅存储一个键(Key),还存储一个与该键关联的值(Value)。

为了实现映射表,我们只需在每个节点存储一个键值对。这个键用于维持二叉搜索树的排序不变式。例如,如果键是整数,那么树将按照这些整数进行排序。值本身不影响树的排序结构,它只是作为附加信息存储在节点中。

当我们在树中查找一个绑定时,可以通过键在对数时间内找到它,而它绑定的值就存储在同一个节点中。

以下是映射表抽象数据类型(ADT)的最终实现核心思想:

type ('k, 'v) tree =
  | Leaf
  | Node of color * ('k * 'v) * ('k, 'v) tree * ('k, 'v) tree

红黑树映射表的性能

红黑树的所有操作——插入(insert)、查找(find)和删除(remove)——其时间复杂度都是O(log n)。这意味着在最坏情况下,它们也是对数时间复杂度的。

这种渐近效率介于哈希表和关联列表之间。但对数时间远优于线性时间。因此,随着输入规模变得非常大,红黑树的性能将更接近哈希表,而不是关联列表。

我们通过这种函数式的红黑树,得到了一个几乎和哈希表一样快的数据结构,但它还具有持久化的特性。这意味着如果你需要,可以保留数据结构的历史版本。这是哈希表无法直接提供的(至少在不显式创建副本的情况下)。

持久化数据结构通常比同等的非持久化(临时性)数据结构需要付出对数级的额外时间复杂度,这是我们为持久化特性所付出的微小性能代价。

不同实现的性能权衡

顺便提一下,我完成了我们的集合实现性能对比表。通过OCaml的Hashable模块创建哈希集合(Hash Set),在升序和随机工作负载下,哈希集合的性能确实非常快,实际上比红黑树集合快大约一个数量级。

因此,如果你追求最佳性能,可变性(Mutability)实际上是必不可少的。我经常开玩笑说可变性是万恶之源,但它确实能带来好处——它为我们提供了我们所看的四种实现中最快的映射表实现。

关于优化的思考

大家应该都记得托尼·霍尔爵士(Sir Tony Hoare),他因对编程语言定义和设计的基础性贡献而获得了1980年的图灵奖。他曾说过:“我们应该忘记那些小的效率问题,大约97%的时间都是如此。过早优化是万恶之源。”

作为程序员,我们很容易过早地开始思考如何优化某段代码。我认为托尼爵士想说的是,在大多数时候,我们应该放下这种想法。不要在编写每一行代码时都试图完全优化它,那样你走不远。

另一方面,优化过早优化是有区别的。你在本课程和其他课程中学到的渐近复杂度理论,其目的是作为一个指南,让你提前知道哪些操作会非常昂贵,哪些则很廉价。

如果你知道需要一个非常高效的映射表数据结构,那么现在你就知道应该使用哈希表或红黑树,很可能不应该使用普通的二叉搜索树,绝对不应该使用关联列表。

另一方面,如果你知道自己不需要一个超级高效的映射表实现,只需要一个快速且易于实现的东西,那么关联列表就是你的选择。如果你只有非常小的映射表,关联列表就足够了,因为线性遍历的操作量不会太大。

因此,思考数据结构操作的渐近效率是很重要的。然后,再担心数据结构内部或你使用它们的代码中,具体每一行代码的详细优化问题。

总结

本节课中,我们一起学习了如何用红黑树实现映射表。我们看到,通过在节点中存储键值对,可以轻松地将红黑树集合扩展为映射表,并获得O(log n)时间复杂度的插入、查找和删除操作。我们比较了红黑树、哈希表、普通二叉搜索树和关联列表的性能与特性,认识到红黑树在提供高效操作的同时,还具备持久化的优势。最后,我们讨论了在编程中平衡性能需求与实现复杂度的重要性,避免过早优化,但也要根据渐近复杂度理论明智地选择数据结构。

154:编译器和解释器

在本节课中,我们将要学习编程语言实现的两个核心概念:编译器和解释器。我们将探讨它们的基本定义、工作原理、主要区别以及现代语言实现中常见的混合技术。

编译器 🛠️

编译器是一种对程序进行操作的软件。当我们讨论编译器时,我们实际上是在将代码视为数据。

编译器的输入是一个源程序

编译器的输出是一个目标程序

编译器完成工作后便会退出。运行目标程序时不再需要编译器。操作系统会帮助加载和启动程序,但编译器本身无需驻留。你甚至可以在目标程序运行时将其从系统中完全删除。

目标程序自身会接受输入并产生输出。例如,目标程序可能是一个恩尼格玛密码机模拟器,它接收字符串和密钥作为输入,并产生加密或解密的结果作为输出。

因此,编译器的主要工作是进行翻译。这种翻译通常意味着从像Java或OCaml这样的高级语言,向下翻译到低级语言,甚至是像x86这样的机器语言。

正因为如此,编译器通常能提供更好的性能,因为它们会进行优化以改进代码,使其在机器上运行得非常快。

解释器 🧑‍💻

另一方面,解释器也以源程序作为输入。

但解释器不会产生一个目标程序作为输出。

相反,解释器会持续运行。它接收程序本应接收的任何输入,并直接产生输出。

解释器与源程序协作,共同将输入转换为输出。

因此,解释器的主要工作是执行。它只想运行程序。

通常,解释器比编译器更容易实现。

有时,使用解释器还能提供更好的错误信息或调试体验。

现代实现技术:混合方法 🔄

如今,一种非常常见的实现技术是混合方法。

你首先将源程序通过一个编译器,得到一个中间程序,这个中间程序通常使用某种字节码语言。例如,你可能听说过Java字节码,也见过OCaml程序的.byte扩展名,这实际上就是OCaml字节码。

这个字节码由一个虚拟机执行。你可以将这里的虚拟机视为一种解释器。它接收程序,接收程序要操作的实际输入,然后与程序协作产生输出。

实际上,这种嵌套可以更深。例如,在Java虚拟机内部,它们实际上嵌入了一个编译器。如果某些代码被频繁执行,虚拟机可以即时将该部分代码编译成机器语言,以获得更好的性能。

因此,实现编程语言的技术是一个广泛的谱系,从纯编译到纯解释,再到各种混合策略。

总结 📝

本节课中,我们一起学习了编译器和解释器的核心概念。编译器将源代码翻译成目标代码后便退出,由目标程序独立运行,通常性能更优。解释器则持续运行,直接读取并执行源代码,通常更易于实现和调试。现代编程语言实现常常采用混合方法,例如先编译成字节码,再由虚拟机解释执行,甚至结合即时编译技术来提升性能。理解这些基本概念是深入学习编程语言设计和实现的重要基础。

155:编译器架构 🏗️

在本节课中,我们将要学习编译器或解释器的基本架构。我们将了解其核心组成部分,特别是前端如何将源代码逐步转换为更结构化的形式,为后续的编译或解释执行做好准备。

编译器架构概览

当我们观察一个编译器或解释器时,其架构通常包含两个主要部分。

我们称之为编译的各个阶段。编译器的前端负责将该语言的源代码,翻译成一种称为抽象语法树(Abstract Syntax Tree, 简称 AST)的数据结构。

通常,前端会继续对这个AST进行一些转换。实际上,它可能会将程序重写多次,甚至很多次,逐步生成越来越简单的程序表示形式。

这些被称为中间表示(Intermediate Representations, 简称 IRs)。

编译器的后端则负责将某个中间表示翻译成机器码。

将编译器分解为这两个阶段的好处在于,你可以拥有一个定义明确的中间表示,作为两者之间的边界。

这意味着你可以将许多不同的源语言编译到那一个IR。然后,让后端根据那一个IR,面向许多不同的汇编语言(例如MIPS、X86等)生成目标代码。

解释器通常没有后端,因为它不试图翻译成机器码,它只是试图执行程序。

事实上,解释器通常甚至没有任何有意义的IR。

尽管如此,编译器和解释器的前端在很大程度上是相同的。

前端的三部分架构

前端架构可以细分为三个部分:词法分析器、语法分析器,以及一个没有确切名称的部分。词法分析器的工作是进行词法分析,语法分析器的工作是进行语法分析,最后一部分则是语义分析

让我们更详细地看看每一部分的含义。

词法分析:从字符流到单词

假设你从一个源程序开始,它看起来像这样,这可能是OCaml中阶乘函数实现的一部分。

if x = 0 then 1 else fact (x - 1)

我们可以将其视为人类程序员输入的字符流,即他们在键盘上输入的单个字符。我们将其通过词法分析器。

词法分析器的工作是将该字符流分割成所谓的词法单元流

因此,可以将这里的词法单元视为源语言中有意义的符号单位。

所以,词法分析,我喜欢将其理解为将字符流分割成单词。打个比方,这些就是源语言的“单词”。

以下是词法分析器可能生成的词法单元序列示例:

  • IF
  • ID("x")
  • EQ
  • INT(0)
  • THEN
  • INT(1)
  • ELSE
  • ID("fact")
  • LPAREN
  • ID("x")
  • MINUS
  • INT(1)
  • RPAREN

语法分析:从单词到结构树

前端的下一个步骤是获取词法单元流,并通过语法分析器进行处理。

语法分析器的工作是将词法单元流转换为抽象语法树(AST)。

这为数据提供了更多结构。它是一种不同的数据结构。我们实际上是在用语法分析器从列表数据结构中生成树形数据结构。

这棵树展示了程序的层次结构。

这个程序是一个if-then-else表达式,因此我们在树的顶部有一个代表if-then-else的节点。它有三个子树:一个用于条件判断部分,一个用于then分支,一个用于else分支。

      If-Then-Else
      /     |     \
     /      |      \
    /       |       \
Guard    Then      Else
(Binop)  (Int)   (Apply)
 =         1      /    \
 / \            fact  (Binop)
x   0                   -
                       / \
                      x   1

每个子树也有自己的结构。在条件判断部分,我们使用一个二元操作符来比较两个子表达式。在else分支,我们使用一个函数应用。它有一个特定的函数fact被应用于一个子表达式。该子表达式涉及减法运算。

现在注意语法分析过程中发生的一件非常有趣的事情:我们摆脱了括号。它们在词法单元流中是为了精确指示我们希望代码如何被解析。我们希望fact应用于整个子表达式x - 1,而不仅仅是x,这就是为什么我们必须写括号。

而这棵树通过让x - 1作为apply的一个子树,而不仅仅是x,隐式地表示了这种分组。因此,语法分析的部分工作是识别程序员希望如何对表达式进行分组,然后将它们适当地放入树中。

语义分析:检查程序含义

现在我们有了抽象语法树,前端的下一个阶段是语义分析

广义上说,这项任务是确定程序在语义上是否有意义。

这里的语义正是我们一直以来讨论的静态语义,其中包括类型检查。因此,作为语义分析的一部分,前端通常会创建一个所谓的符号表,这只是一个将标识符映射到其类型的字典。根据编程语言的不同,符号表可能包含更多信息,但这是它的主要工作。

语义分析可能会继续进行,并生成一个新的抽象语法树,为每个AST节点用其类型进行装饰。语义分析还包括更多内容,例如在OCaml中,我们不仅进行类型检查,还会对模式匹配进行分析,以查看它是否详尽,或者是否有任何分支永远无法到达。Java也进行额外的语义分析,比如字段的初始化。所以,广义上这些都是编译这一部分的内容。

后续步骤:编译与解释

接下来会发生什么?这在很大程度上取决于我们是在看编译器还是解释器,也取决于语言和架构本身。

我们可能会将AST翻译成中间表示。然后,解释器可以执行那个IR,或者它可能直接执行AST。

然而,编译器将开始将AST翻译成越来越像机器的表示形式。关于这具体意味着什么,你需要学习计算机体系结构或系统编程课程(如CS 3410),我们不会深入探讨。

函数式语言的优势

函数式语言非常适合实现编译器和解释器。使用变体类型在函数式语言中表示这些树形数据结构非常容易。并且,通过对树进行模式匹配,可以轻松定义这些结构的编译和执行过程。

所以我们接下来将学习如何做到这一点。

总结

本节课中,我们一起学习了编译器与解释器的基本架构。我们了解到前端负责将源代码字符流转换为结构化的抽象语法树,这个过程分为词法分析、语法分析和语义分析三个阶段。后端则负责将中间表示转换为目标机器码。函数式语言因其强大的模式匹配和递归能力,是实现此类树形结构处理的理想选择。

156:计算器语言解释器实现

在本节课中,我们将从零开始,共同构建一个非常简单的计算器。我们将为这个计算器语言实现一个解释器。我们的语言将只包含整数、加法、乘法、括号和空白字符,因此我们将能够编写非常简单的算术表达式。我的目标是接收一个这样的算术表达式字符串,将其求值为最终的整数形式,并返回一个表示该整数的字符串。

我已经开始为我们的计算器语言实现解释器。让我简要介绍一下目前已有的文件。

项目文件结构概述

以下是当前项目中的主要文件:

  • ast.ml:这是最重要的起始文件。我们将在这里定义类型,用以表示源语言的抽象语法树。目前,我只定义了一个类型 expr(表达式的缩写),它代表我们计算器语言中的一个表达式。目前我仅用 unit 类型作为占位符,稍后我们会完善它。
  • lexer.mlparser.ml:这是词法分析器和语法分析器的存根文件。我们稍后会详细讨论每个文件,现在暂时跳过它们。
  • main.ml:这个文件已经包含了两个函数。一个用于将字符串解析为AST,另一个用于解释该字符串并生成一个新的字符串输出。目前大部分功能尚未实现。
  • test.ml:在这个文件中,我已经编写了一些单元测试,我希望最终能让这些测试通过。

初始运行与当前状态

现在,让我先运行一下这个解释器的当前版本。目前我有零个通过的测试。

但我已经设置好,如果我运行 make 命令,它会进入Utah环境,我也可以在那里进行解释。目前,这个解释器唯一能够处理的源代码是空字符串,对于空字符串,它只会返回“未实现”的失败信息。

不过,我编写了一个解析函数,你可能之前在 main 文件中看到过。这个 parse 函数接收一个字符串并返回一个AST类型的值。虽然目前它只是返回 unit,但请注意,我现在可以解析空字符串,并且它会返回 unit 值。


总结:本节课我们一起学习了计算器语言解释器的项目初始结构。我们介绍了核心的AST定义文件、词法分析器与语法分析器存根、主程序文件以及测试文件,并运行了当前仅能处理空字符串的解释器原型。在接下来的章节中,我们将逐步完善AST定义,并实现词法分析与语法分析功能。

157:解析整数 🧮

在本节课中,我们将学习如何扩展我们的计算器程序,使其能够解析和表示整数。我们将从定义抽象语法树(AST)开始,然后逐步构建词法分析器和解析器来处理整数。


概述

我们将构建一个简单的计算器,它能够理解像 22 这样的整数。为此,我们需要:

  1. 定义一个AST来表示整数。
  2. 扩展解析器以识别整数标记。
  3. 扩展词法分析器以将字符序列转换为整数标记。
  4. 将所有部分连接起来,实现一个能解析整数的完整流程。

设计抽象语法树(AST)

首先,我们需要一种数据结构来表示源代码中的表达式。目前,我们只关心整数。

我们使用一个变体类型来定义AST。其中一个构造函数是 Int,它携带一个OCaml整数,用于表示源代码中的整数。

type expr = Int of int

这个类型目前只表示整数。我们稍后会添加加法、乘法和括号等表达式。


扩展解析器以处理整数

上一节我们定义了AST,本节中我们来看看如何让解析器识别整数。解析器与词法分析器通过标记进行交互。词法分析器生成一个标记流,解析器则消费这个流。

首先,我们需要在解析器中定义整数标记。目前我们只有一个 EOF(文件结束)标记。

%token EOF

现在,我们添加一个名为 INT 的新标记。这个标记需要携带一个整数值信息。

%token <int> INT

这段代码指示OCaml解析器生成器,在内部的标记变体类型中添加一个名为 INT 的构造函数,该构造函数携带一个 int 类型的值。

接下来,我们需要修改解析规则。目前,解析规则 program 只能解析 EOF。我们希望它能解析一个表达式。

program:
  | e = expr; EOF { e }

这个规则表示:一个程序由一个表达式 e 后跟 EOF 组成。解析成功后,返回表达式 e 对应的AST节点。

现在,我们需要定义 expr 规则。目前,我们只解析整数表达式。

expr:
  | i = INT { Int i }

当解析器遇到一个 INT 标记(例如,值为22)时,它会返回AST节点 Int 22


扩展词法分析器以生成整数标记

解析器现在期望接收 INT 标记,因此我们需要让词法分析器能够将字符序列(如“22”)转换为这种标记。

首先,我们需要定义什么是“整数”。一个整数由一个可选的负号和一个或多个数字组成。

以下是词法分析器中的定义:

  • digit 匹配单个数字字符(0-9)。
  • int 匹配一个可选的负号(-?)后跟一个或多个数字(digit+)。
let digit = ['0'-'9']
let int = '-'? digit+

定义了模式后,我们需要指定当匹配到 int 模式时该做什么。我们需要返回一个 INT 标记,并附上具体的整数值。

词法分析器提供了一个特殊的 lexbuf 变量,代表当前的字符缓冲区。函数 Lexing.lexeme lexbuf 可以获取当前匹配到的字符串。

因此,规则如下:

  1. 匹配 int 模式。
  2. 使用 Lexing.lexeme lexbuf 获取匹配到的字符串。
  3. 使用 int_of_string 函数将字符串转换为OCaml的 int 类型。
  4. 返回标记 INT 和这个整数值。
rule read = parse
  | int { INT (int_of_string (Lexing.lexeme lexbuf)) }
  | eof { EOF }

连接所有部分

现在,让我们回到主程序,看看 parse 函数如何将词法分析器和解析器连接起来。

parse 函数接收一个字符串,并返回一个 expr 类型的AST。

以下是它的工作流程:

  1. 创建词法缓冲区:使用 Lexing.from_string 将输入字符串转换为一个词法缓冲区 (lexbuf)。
  2. 调用解析器:调用解析器模块生成的 Parser.program 函数。这个函数需要两个参数:
    • 一个从词法缓冲区读取标记的函数(即 Lexer.read)。
    • 词法缓冲区本身 (lexbuf)。
  3. 生成ASTParser.program 使用 Lexer.readlexbuf 中获取标记,并根据我们定义的语法规则构建AST,最终返回结果。
let parse (s : string) : expr =
  let lexbuf = Lexing.from_string s in
  Parser.program Lexer.read lexbuf

至此,我们的程序已经可以解析整数了。例如,输入字符串 "22" 会被成功解析为AST节点 Int 22


总结

本节课中我们一起学习了如何为计算器实现整数解析功能。我们:

  1. 设计了AST,使用 Int of int 来表示整数表达式。
  2. 扩展了解析器,定义了 INT 标记和相应的语法规则来构建AST节点。
  3. 扩展了词法分析器,定义了整数的字符模式,并将其转换为携带值的 INT 标记。
  4. 整合了流程,通过 parse 函数将词法分析和语法分析步骤串联起来。

现在,我们的程序已经能够理解并表示像 22 这样的基本整数了。在接下来的课程中,我们将在此基础上添加更多的运算符和表达式类型。

158:整数计算器求值

在本节课中,我们将学习如何为一个简单的整数计算器实现求值功能。我们将把字符串解释过程分解为解析、求值和转换回字符串三个步骤,并重点介绍“单步计算”的核心思想。

概述

为了解释一个字符串,我们需要完成三个步骤:解析它、求值它,然后将其转换回字符串。因此,我们将把解释函数分解为对应的几个部分。

值到字符串的转换

首先,我们实现一个辅助函数 string_of_val,用于将一个表达式 E 转换为字符串。作为前提条件,我们要求 E 必须是一个“值”,即已完成计算,没有更多求值步骤需要执行,这与我们在OCaml中的概念一致。

以下是 string_of_val 的实现:

let string_of_val e =
  match e with
  | Int n -> string_of_int n

由于目前我们的抽象语法树节点只有整数类型,转换非常简单。整数本身就是值,无需进一步计算,我们只需调用标准库函数 string_of_int 处理节点中的数据即可。

表达式的求值

接下来,我们看看求值部分。这需要更多的工作,并非因为整数本身难以规约为值(事实上它们已经是值),而是因为我们将使用“单步计算”的思想来构建AST的求值过程。我们希望每次只进行一小步计算,并持续进行,直到最终得到一个值。

以下是 eval 函数的基本框架实现:

let rec eval e =
  if is_value e then e
  else eval (step e)

eval 函数首先检查表达式 e 是否已经是一个值,这里我们写了一个辅助函数 is_value 来完成这个判断。目前,由于我们还没有像加法或乘法这样的复杂表达式,AST中的所有节点都是值。

如果 e 已经是值,则直接返回它。否则,我们对 e 执行一小步计算,然后递归地对结果调用 eval 函数。因此,eval 本质上会不断递归调用自身,直到得到一个值为止。

单步计算函数

那么,step 函数具体做什么呢?对于一个整数节点,没有计算需要执行。实际上,你可以认为它的计算已经完成。

因此,虽然这起初可能看起来有点违反直觉,但我们将规定:当遇到一个整数时,无法执行任何计算步骤,并在此抛出一个异常 Does_not_step

exception Does_not_step

let step e =
  match e with
  | Int n -> raise Does_not_step

请注意,如果代码其他部分工作正常,我们永远不应该触发这个异常。因为当我们对一个整数调用 eval 时,它已经是一个值,is_value 会返回 true,所以我们永远不会在整数上调用 step 函数。

测试验证

现在,我们应该能够让第一个测试用例通过了。回想一下,我们的第一个测试用例是检查 22 是否求值得出 22。它确实通过了。

我们可以解析 22,现在也可以解释它了。

总结

本节课中,我们一起学习了如何为整数计算器实现求值器。我们引入了“值”的概念和“单步计算”的求值策略,并成功实现了将整数表达式求值为最终结果的功能。虽然目前只处理整数,但这个框架为后续添加更复杂的运算操作奠定了基础。

159:实现加法运算 🧮

在本节课中,我们将学习如何为我们的计算器语言添加加法运算功能。我们将从定义抽象语法树(AST)开始,逐步实现词法分析、语法分析和求值步骤,最终让 11 + 11 这样的表达式能够被正确计算。


上一节我们实现了整数字面量的求值,本节中我们来看看如何支持加法运算。

首先,我们需要在抽象语法树(AST)中表示加法这样的二元运算符。

type binop = Add | Mult

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/crnl-cs3110-ocaml/img/27ded28b91303152d0f34b0bdbce9d2f_7.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/crnl-cs3110-ocaml/img/27ded28b91303152d0f34b0bdbce9d2f_8.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/crnl-cs3110-ocaml/img/27ded28b91303152d0f34b0bdbce9d2f_9.png)

type expr =
  | Int of int
  | Binop of binop * expr * expr

我们定义了一个新的类型 binop 来表示二元运算符,目前只包含 Add(加法)。expr 类型新增了一个 Binop 构造器,它包含一个运算符和左右两个子表达式。


接下来,我们需要让词法分析器(Lexer)能够识别加号 + 字符。

以下是需要在词法分析规则中添加的内容:

rule token = parse
  ...
  | '+' { PLUS }
  ...

我们在词法分析规则中添加了一个分支,当遇到 + 字符时,返回一个名为 PLUS 的标记(Token)。


然后,我们需要在语法分析器(Parser)中声明这个新的 PLUS 标记,并定义如何将 表达式 + 表达式 的结构解析成 AST。

以下是需要在语法分析器中添加的声明和规则:

%token PLUS
...
expr:
  | e1=expr PLUS e2=expr { Binop (Add, e1, e2) }
  | i=INT { Int i }

我们声明了 PLUS 标记,并添加了一条新的语法规则:一个表达式可以是由 PLUS 连接的两个子表达式,解析后会生成一个 Binop (Add, ...) 节点。


此时,语法分析器应该已经可以解析加法表达式了。我们可以进行测试:

parse "1 + 1"
(* 应返回:Binop (Add, Int 1, Int 1) *)

然而,我们还不能对这个表达式进行求值,因为求值函数 step 还没有处理 Binop 节点。编译器会提示模式匹配不完整,这正是我们接下来需要修复的。


我们需要更新几个函数来完善对 Binop 节点的处理。

首先,更新 string_of_val 函数。该函数只应接收一个值(Int),如果收到 Binop 则违反前提条件。

let string_of_val = function
  | Int i -> string_of_int i
  | Binop _ -> failwith “precondition violated”

其次,更新 is_val 函数。任何 Binop 节点都不是最终值。

let is_val = function
  | Int _ -> true
  | Binop _ -> false

最后,也是最关键的,我们需要在 step 函数中实现加法运算的求值步骤。我们采用最左优先的规约策略。

let rec step = function
  | Binop (op, Int n1, Int n2) -> step_binop op (Int n1) (Int n2)
  | Binop (op, v1, e2) when is_val v1 -> Binop (op, v1, step e2)
  | Binop (op, e1, e2) -> Binop (op, step e1, e2)
  | _ -> failwith “Not a reducible expression”

规则如下:

  1. 如果左右两边都是值(Int),则调用 step_binop 进行计算。
  2. 如果左边已经是值,但右边不是,则对右边表达式求一步。
  3. 如果左边还不是值,则对左边表达式求一步。

现在,我们需要实现 step_binop 这个辅助函数,它负责在操作数都是值的时候执行实际的运算。

let step_binop op v1 v2 =
  match op, v1, v2 with
  | Add, Int n1, Int n2 -> Int (n1 + n2)
  | _ -> failwith “precondition violated”

对于加法,我们直接将两个整数相加,结果包装在 Int 构造器中。这里,我们将计算器语言中的加法“编译”或“规约”到了 OCaml 语言的加法运算上。


完成以上所有步骤后,我们的测试用例 11 + 11 应该就能通过求值,最终得到结果 22

本节课中我们一起学习了如何为解释器添加一个新的二元运算符(加法)。我们扩展了 AST 定义,更新了词法分析和语法分析规则,并实现了最左优先的求值策略,最终成功将加法表达式规约到 OCaml 的原生运算上。这个过程清晰地展示了如何逐步构建一个语言解释器的核心功能。

160:实现乘法运算 🧮

在本节课中,我们将学习如何为我们的计算器程序添加乘法运算功能。我们将遵循与实现加法运算相同的步骤,但会进行得更快一些。

上一节我们成功实现了加法运算,本节中我们来看看如何实现乘法运算。

实现步骤

以下是实现乘法运算需要完成的几个关键步骤。

  1. 扩展二元运算符类型:首先,我们需要在定义二元运算符的类型中添加乘法运算符。

    type binop = Plus | Minus | Times | Div
    
  2. 更新词法分析器:接着,修改词法分析器(lexer),使其在遇到星号字符 * 时能识别并返回对应的乘法记号(TIMES)。

    | '*' -> TIMES
    
  3. 更新语法分析规则:然后,在语法分析器(parser)的专家级规则中,添加处理 TIMES 记号的部分,使其返回 Multiplication 节点。

    | e1 = parse_expr; TIMES; e2 = parse_expr { Binop (Multiplication, e1, e2) }
    
  4. 扩展求值函数:最后,在求值函数(eval)的模式匹配中添加针对 Multiplication 的分支,以执行实际的乘法计算。

    | Binop (Multiplication, e1, e2) -> eval e1 * eval e2
    

测试与验证

在实现过程中,我们可以通过一个测试用例来验证乘法功能当前是否有效。运行测试后,确认它确实会失败,这符合我们的预期。

完成上述所有修改后,我们再次运行测试。这次,乘法测试应该能够成功通过了。

这里有一个需要注意的细节:在最初的求值函数中,我们使用了一个“捕获所有情况”的模式(如 | _ -> ...)。这意味着即使我们没有显式添加 Multiplication 的分支,编译器也不会发出警告或提示未完成。这有时是危险的,因为它可能掩盖未实现的功能。

本节课中我们一起学习了如何为OCaml计算器添加乘法运算。我们回顾了从扩展类型定义、更新词法分析和语法分析规则,到最终在求值函数中实现运算的完整流程。这个过程与实现加法运算高度一致,体现了代码的模块化和可扩展性。

161:计算器 - 运算符优先级与结合性 🧮

在本节课中,我们将学习如何为我们的计算器解析器定义运算符的优先级和结合性,以确保表达式能够按照数学规则被正确解析和求值。


概述

在上一节中,我们构建了一个能够解析简单算术表达式的计算器。然而,当我们尝试解析包含多个不同运算符的表达式时,例如 2 * 2 + 10,解析器可能会因为不知道运算顺序而产生错误的结果。本节我们将解决这个问题,通过为解析器指定运算符的优先级和结合性,来确保表达式被正确解析。


发现问题:运算顺序冲突

为了测试我们的解析器,我们尝试解析表达式 2 * 2 + 10。根据数学规则,乘法优先级高于加法,因此这个表达式应该被计算为 (2 * 2) + 10 = 14

然而,当我们运行测试时,解析器却将其计算为 2 * (2 + 10) = 24。这表明解析器没有遵循正确的运算顺序。

(* 错误的解析树结构 *)
Times (Int 2, Plus (Int 2, Int 10))

OCaml的解析器生成工具(ocamlyaccmenhir)在编译时也给出了警告,提示存在“冲突”。这个冲突正是源于解析器不确定应该先解析加法(+)还是先解析乘法(*)。


解决方案:定义优先级与结合性

为了解决运算顺序问题,我们需要在解析器定义文件(.mly)中明确声明运算符的优先级和结合性。

以下是定义的核心概念:

  • 优先级:决定哪个运算符先被计算。例如,乘法(*)的优先级高于加法(+)。
  • 结合性:当连续出现多个相同优先级的运算符时,决定计算顺序。例如,加法通常是左结合的,意味着 1 + 2 + 3 被计算为 (1 + 2) + 3

在OCaml的解析器语法中,我们使用 %left%right%nonassoc 声明来同时指定结合性和建立优先级顺序。

%left PLUS     /* 左结合,优先级较低 */
%left TIMES    /* 左结合,优先级较高 */

规则:声明在列表越下方的运算符,其优先级越高。因此,TIMES 的优先级高于 PLUS


应用与验证

在解析器文件中添加上述声明后,我们重新编译项目。之前的冲突警告消失了。

现在,当我们再次测试表达式 2 * 2 + 10 时,解析器生成了正确的语法树,并计算出结果 14

(* 正确的解析树结构 *)
Plus (Times (Int 2, Int 2), Int 10)

结合性的作用

我们之前定义的 %left PLUS 确保了加法是左结合的。这意味着表达式 1 + 2 + 3 会被解析为 (1 + 2) + 3

我们可以通过修改声明来观察结合性的影响:

  • 如果将 PLUS 声明为 %right(右结合),那么 1 + 2 + 3 将被解析为 1 + (2 + 3)。虽然对于加法结果相同,但解析树的结构改变了。
  • 同样,我们可以故意错误地定义优先级,例如让 PLUS 的优先级高于 TIMES。这时,2 + 2 * 10 会被错误地计算为 (2 + 2) * 10 = 40。这演示了优先级声明如何直接影响解析结果。


(图示:修正优先级后,表达式被正确解析)


总结

本节课中,我们一起学习了如何为算术表达式解析器定义运算符的优先级和结合性。我们了解到:

  1. 未明确定义优先级会导致解析冲突和错误的计算结果。
  2. 在OCaml解析器规范中,使用 %left%right 等声明可以同时设定运算符的结合性和优先级顺序。
  3. 声明的位置决定了优先级高低,下方的运算符优先级更高。
  4. 正确的优先级(乘除高于加减)和结合性(算术运算符通常左结合)是保证计算器行为符合数学直觉的关键。

通过这一机制,我们的计算器现在能够正确地处理复杂的混合运算表达式了。

162:空白字符与括号 🧮

在本节课中,我们将学习如何扩展我们的小型计算器语言,使其能够处理表达式中的空白字符(如空格和制表符)以及括号。这将使我们的语言更具可读性,并允许用户显式地指定运算的优先级。

上一节我们介绍了如何解析和求值基本的算术表达式。本节中我们来看看如何让解析器忽略无关的空白字符,并识别括号来强制改变运算顺序。


扩展词法分析器

首先,我们需要修改词法分析器(Lexer),使其能够识别并处理两种新的输入元素:括号和空白字符。

以下是需要添加到词法分析器中的新规则:

  • 左括号与右括号:我们添加两条新规则来生成对应的词法单元(Token)。
    • "(" 将生成 LPAREN 词法单元。
    • ")" 将生成 RPAREN 词法单元。
  • 空白字符:我们添加一条规则来匹配并跳过空白字符。
    • 我们定义空白字符为一个或多个空格( )或制表符(\t)的混合。在正则表达式中,+ 表示“一个或多个”。
    • 当词法分析器遇到空白字符时,我们不返回任何词法单元,而是递归地调用自身以继续读取输入缓冲区,从而有效地跳过所有空白字符。

在OCaml的ocamllex语法中,跳过空白字符的规则看起来像这样:

rule read = parse
  | [' ' '\t']+ { read lexbuf }  (* 跳过空白字符 *)
  | ...

扩展语法分析器

接下来,我们需要更新语法分析器(Parser),使其能够理解这些新词法单元并构建正确的抽象语法树(AST)。

以下是语法分析器的关键修改:

  • 声明新词法单元:首先,我们需要在语法分析器的头部声明 LPARENRPAREN 这两个新词法单元。
  • 添加新的表达式形式:我们为表达式添加一个新的解析规则。当语法分析器看到一个左括号 LPAREN,后跟一个表达式,再后跟一个右括号 RPAREN 时,它只需返回括号内表达式的解析结果。
    • 这意味着括号本身不会在AST中显式表示,它们的作用通过改变AST的结构(即子表达式的嵌套关系)来隐式体现。

在OCaml的menhir语法中,这条新规则可以写作:

expr:
  | LPAREN expr RPAREN { $2 }  (* 返回括号内表达式的值 *)

测试与验证

完成以上修改后,我们现在可以测试像 (10 + 1) 或带有空格的 10 + 1 这样的表达式。

测试结果显示,我们的计算器现在能够成功解析并求值这些包含空白字符和括号的表达式。

至此,我们已经为这个小型计算器语言实现了一个完整的解释器,它能够处理数字、加法、减法、乘法、除法、空白字符和括号。


总结

本节课中我们一起学习了如何增强计算器语言的词法分析器和语法分析器。

  • 我们通过添加规则让词法分析器能够跳过空白字符,使输入格式更灵活。
  • 我们通过添加对括号的识别和处理,让用户能够显式控制运算的优先级和分组。
  • 这些修改使得我们的语言解释器更加实用和健壮,为理解更复杂的语言特性打下了基础。

163:词法单元与抽象语法树

在本节课中,我们将详细探讨解释器实现中的两个核心组件:词法单元和抽象语法树。我们将了解它们在编译流程中的角色、相互关系以及具体实现方式。

上一节我们快速浏览了实现一个解释器的整体流程,本节中我们将深入分析其中的关键部分。

数据视角:词法单元与AST节点

从数据流动的视角来看,编译过程主要涉及两种数据结构。

词法单元

词法单元是词法分析器的输出,也是语法分析器的输入。在我们的计算器语言中,词法单元在语法分析器文件中声明,例如使用 %token INT。词法分析器的职责就是读取源代码字符流,并将其转换为一系列词法单元。

AST节点

AST节点是抽象语法树的构成元素,由语法分析器根据词法单元序列构建而成。它们定义在独立的AST模块中,并成为语法分析器与后续编译阶段(特别是求值器)之间的接口。

因此,词法单元是词法分析器与语法分析器之间的接口,而AST节点则是语法分析器与编译流程中其他所有部分之间的接口。

工具视角:各模块的职责

从实现工具和模块分工的视角,我们可以这样理解:

以下是各个组件的核心职责:

  • 词法分析器:负责生成词法单元。
  • 语法分析器:负责生成AST节点。同时,它还有一个额外的责任,即声明所有词法单元的类型。
  • 求值器:负责对AST节点进行计算。

词法分析器、语法分析器和抽象语法树三者紧密耦合。这种高度关联性使得按线性顺序讲解它们颇具挑战,这也是我们先进行高层概述,再深入细节的原因。

核心数据结构:AST类型定义

贯穿整个解释器实现(词法分析、语法分析、求值)的一个共同核心,是一个名为抽象语法树的数据结构。

这个用于表示语言语法的类型定义,被所有模块共享,因此我们将其提取到独立的文件中。

对于我们简单的计算器语言,其AST定义可能如下所示:

type binop = Add | Mult
type expr =
  | Int of int
  | Binop of binop * expr * expr

这个定义包含二元运算符(加法和乘法)和表达式。表达式要么是整数常量,要么是由运算符和两个子表达式构成的二元运算。

当然,为了表示更复杂的语言,你需要定义更多种类的表达式和运算符。

词法分析器、语法分析器和抽象语法树之间的这种组织方式,很大程度上是OCaml相关工具设计方式的产物。OCaml在这方面继承了来自C语言等更早一代工具的工作模式。

总结

本节课中,我们一起学习了编译过程中的两个基础概念:词法单元抽象语法树节点。我们了解了它们分别作为词法分析器与语法分析器、以及语法分析器与后续阶段之间的数据接口。同时,我们也明确了词法分析器、语法分析器和求值器各自的职责,并看到了如何用OCaml类型来定义简单的AST。理解这些组件及其相互关系,是构建语言解释器或编译器的关键第一步。

164:Menhir与Ocamllex详解 🧩

在本节课中,我们将深入学习如何为OCaml语言实现一个解析器。具体来说,我们将探讨如何使用解析器生成器(Menhir)和词法分析器生成器(Ocamllex)来将令牌流转换为抽象语法树,从而避免手动编写大量繁琐的代码。

解析器生成器概述

上一节我们介绍了词法分析的基本概念,本节中我们来看看解析器的具体实现。解析器的核心任务是将词法分析器生成的令牌流转换为抽象语法树(AST)。在OCaml中,我们通常不直接手写解析器,而是使用一个名为Menhir的解析器生成器。

如果你查看我们之前创建的解析器,会发现它是一个 .mly 文件。这并非标准的OCaml代码文件。实际上,我们运行一个随OCaml分发的工具,该工具读取这个 .mly 文件,并从中生成一个 .ml 文件。在构建目录中,我们可以找到这个生成的文件,例如 parser.ml

该文件包含了由OCaml解析器生成器自动生成的大量代码,这些代码基于我们编写的那个更简洁的定义文件来完成解析工作。这使我们无需手动编写所有复杂的解析逻辑。

历史背景与工具演变

接下来,我们来解释一下为什么使用这些工具。在C语言中,有一个古老的解析器生成器叫做 YACC(Yet Another Compiler Compiler)。其理念是,你输入一个定义语言的文件,然后编译它以生成能够编译该语言的程序。OcamlYacc 是YACC的OCaml版本。

后来,出现了一个更现代的版本,名为 Menhir。我们目前使用的正是Menhir解析器生成器来为简单的计算器语言构建解析器。文件扩展名 .mly 中的 “y” 源于YACC,是对这段历史的致敬。

解析器定义文件(.mly)详解

在我们的 parser.mly 文件中,我们声明了一系列令牌。其中只有一个令牌(INT)携带了额外的数据(一个OCaml整数),我们需要指定其类型。所有这些都是词法分析器将产生的令牌。EOF 是一个特殊的文件结束令牌,表示不再产生更多令牌。

我们还声明了关于运算符优先级和结合性的规则。我们尝试过为加法定义左结合与右结合。左结合意味着括号向左分组,右结合则意味着向右分组。你也可以使用 nonassoc,这会使解析器认为 x + y + z 是歧义的,从而强制程序员使用括号来澄清意图。

在结合性列表中,位置越靠下的令牌,其优先级越高。因此,由于 TIMES 出现在 PLUS 下方,表达式 1 + 2 * 3 会被解析为 1 + (2 * 3),而不是 (1 + 2) * 3

词法分析器生成器(Ocamllex)

在我们的实现中,我们使用了词法分析器生成器,而非手动编写词法分析器。我们编写了一个 .mll 文件,OCaml随后使用一个工具来生成词法分析器。

OCaml实际上将这个文件编译成了 lexer.ml 文件。同样,我们可以在构建目录中找到它。这里包含了所有我们无需自己实现的词法分析器代码,包括一些看起来非常奇怪的代码。

这使我们免于手动实现这些复杂逻辑,非常方便。这个词法分析器生成器将 .mll 文件转换为 .ml 文件。在C语言中,有一个古老的词法分析器生成器叫做 Lex。我们使用的是 Ocamllex,它是Lex的OCaml等效工具。因此,.mll 文件末尾的额外 “l” 代表 “lex”。

词法分析器定义文件(.mll)详解

在我们的 lexer.mll 文件中,我们有一个头部,其中包含了 open Parser。这实际上是会被复制到生成文件中的OCaml代码。这里的 open 是为了方便,使得解析器的令牌定义在词法分析器中可用。

在词法分析器中,我们为一些令牌类编写了标识符:white(空白字符)、digit(数字)和 int(整数)。我们实际上使用了正则表达式来完成这项工作。正则表达式是计算机科学中一个非常有用的工具,如果你还没接触过,将来在相关课程中一定会学到。在这里,我用它们来帮助定义令牌的语法。

以下是定义这些正则表达式的规则:

  • white 被定义为任何空格字符或制表符。在方括号中列出它们,是OCaml正则表达式语法,表示匹配其中任意一个字符。后面的加号 + 表示一个或多个。
  • digit 被定义为字符 ‘0’ 到 ‘9’。在方括号中使用连字符 - 表示ASCII码中从 ‘0’ 到 ‘9’ 的任意字符。
  • int 被定义为一个或多个数字(digit+),前面可以有一个可选的负号(-?)。问号 ? 表示可选。

然后,我们有一个产生式规则,该规则描述了如何从字符流中产生令牌。我将该规则命名为 read,但 ruleparse 是关键字。在花括号 {} 内的一切内容都是返回值。因此,read 最终成为一个可用于从令牌流中读取令牌的函数。

parse 是一个关键字。当我写下 | white { read lexbuf } 时,其含义是:如果字符流中的下一个字符匹配正则表达式 white,则递归地调用 read 函数处理流中剩余的字符,从而跳过空白字符。这里的 lexbuf 是词法分析器已知的一个变量,它实际上就是字符流。

如果字符流中的下一个字符匹配任何这些字符串(如 "+"),那么我们将返回相应的令牌。如果字符流中的字符匹配 int 正则表达式,那么我们将返回一个 INT 令牌,并使用一些内置函数将匹配到的字符串转换为OCaml整数。Lexing.lexeme lexbuf 就是匹配到的整数字符串。最后,当字符流为空时,我们返回文件结束令牌 EOF

总结

本节课中,我们一起学习了如何使用Menhir和Ocamllex为OCaml程序构建解析器和词法分析器。我们了解到,通过编写简洁的 .mly.mll 定义文件,可以利用工具自动生成复杂且正确的解析代码,这大大提高了开发效率并减少了错误。核心在于定义令牌、优先级、结合性以及使用正则表达式描述词法规则。掌握这些工具是构建编译器或解释器的重要一步。

165:语法与BNF 🧩

在本节课中,我们将学习如何用语法和巴科斯-诺尔范式(BNF)来描述编程语言的语法结构。我们将看到语法规则如何与抽象语法树(AST)紧密对应,并了解如何在解析器生成器(如ocamlyacc)中实现这些规则。

概述

我们之前已经能够解析包含空格、括号和运算符优先级的表达式。所有这些元素最终都被转换成一棵树,用以表示各个标记之间的关系。在树中,括号变得无关紧要,它们通过树的结构来体现;运算符优先级则通过树的构建方式来表示,即哪些子树成为其他树的一部分。

我们可以使用一种称为“语法”的数学符号来完整地描述这一切。

语法与巴科斯-诺尔范式(BNF)

语法是一种数学符号。一个表达式可以是以下三种情况之一。

以下是表达式的BNF定义:

E ::= I
    | E1 BINOP E2
    | ( E )

在这个定义中:

  • E 是一个元变量,代表表达式。
  • I 代表整数。
  • BINOP 代表二元运算符(如 +*)。
  • ::=|元语法的一部分,用于描述语言本身的语法,它们并不出现在源语言(如我们的计算器语言)中。

当然,如果你要描述的语言本身就包含 ::=| 符号,就需要更仔细地区分语法和元语法。但对于我们的计算器语言,这不是问题。

我们还需要定义语言中的其他部分:

BINOP ::= +
        | *

I ::= (整数标记的集合)

以这种方式书写语法被称为巴科斯-诺尔范式。巴科斯和诺尔是两位图灵奖得主,他们在1960年代设计ALGOL语言时共同发明了BNF。你会在许多地方看到BNF,它被用来描述各种语言的语法。

BNF与抽象语法树(AST)的对应关系

BNF和AST之间通常存在非常紧密的对应关系。

在BNF中,表达式可以是整数、二元运算或带括号的表达式。在AST类型定义中,我们有对应的构造器来处理前两者(整数和二元运算符)。我们不再表示第三种情况(括号),因为当我们抽象为树时,这些具体的语法就不再需要了。

同样,在BNF中,二元运算符可以是加号或乘号,而在AST中,我们也有相应的类型来表示加法和乘法。

因此,你为语言设计的语法(BNF)和AST之间通常会有这种紧密的对应。

在解析器生成器中的实现

回到我们之前创建的.mly文件。我们在其中声明了解析的起始点是名为 prog 的规则,并为其注解了返回的OCaml类型。之后,我们有一些产生式规则,它们或多或少地对应着我刚才展示的语法规则。

每个规则都具有以下形式:名称:,然后是一些由竖线 | 分隔的产生式,最后是花括号 {} 中的一个动作(有时称为语义动作)。

这与BNF非常相似:BNF有一个元变量名、::= 以及一些由竖线分隔的产生式。主要区别在于,在.mly文件中,我们实际上添加了那些花括号中的动作,用以说明返回什么作为OCaml结果,而不仅仅是说明什么构成了语言中的合法表达式。

让我们看其中一个规则。我们有一个用于解析表达式的规则,它大致对应一个BNF规则:E ::= I | E1 * E2 | E1 + E2 | (E)

以下是该规则在.mly文件中的实现:

expr:
  | i = INT { Int i }
  | e1 = expr; TIMES; e2 = expr { Binop (Mult, e1, e2) }
  | e1 = expr; PLUS; e2 = expr { Binop (Add, e1, e2) }
  | LPAREN; e = expr; RPAREN { e }

为什么这里没有为二元运算符设置一个单独的规则?这涉及到解析器生成器如何工作的技术细节。像这样内联它们通常效果更好。如果你想深入了解,可以选修CS4120课程。

现在,我们来分解这个产生式:

  • 第一个产生式针对整数:它解析一个携带整数值 iINT 标记,并返回一个同样携带该整数的AST节点 Int i
  • 下一个产生式针对乘法:它解析一个表达式,后跟一个 TIMES 标记,再跟另一个表达式。我们将解析第一个表达式的结果绑定到OCaml变量 e1,将解析第二个表达式的结果绑定到 e2。然后,我们构造一个AST节点 Binop (Mult, e1, e2) 作为动作返回。
  • 加法的产生式同理。
  • 对于括号,我们基本上丢弃了括号。我们不再需要具体语法,现在只表示抽象语法,因此直接返回内部的表达式 e

最后,最高层级的规则是解析整个程序:我们解析一个完整的表达式,然后必须是文件或标记流的结尾。

prog:
  | e = expr; EOF { e }

总结

本节课中,我们一起学习了如何使用BNF形式化地描述语言的语法。我们看到BNF规则与抽象语法树(AST)的数据构造器之间存在直接的映射关系。最后,我们探讨了如何在ocamlyacc.mly文件中实现这些语法规则,其中每个产生式都附带一个“动作”来构建对应的AST节点。理解语法、BNF和AST之间的关系,是构建编译器或解释器的关键基础。

166:小步求值模型 🧮

在本节课中,我们将学习解释器实现中的一个核心概念:小步求值模型。我们将探讨如何通过一系列简单的“步骤”来简化抽象语法树,最终将其规约到一个代表最终值的节点。

概述

我们的解释器实现的最后一部分,我暂时称之为“求值器”。需要说明的是,我们在此处跳过了类型检查的环节,这部分内容将在本课程单元的末尾进行讨论。

对于我们的简单计算器语言,其抽象语法树的求值过程是:我们从一个复杂的树结构开始,通过一系列步骤,最终将其简化为一个仅代表该语言值的整数节点。

小步求值策略

我们的求值策略是逐步进行的。具体方法是:取一个表达式,通过简化它的某个子表达式,使其向前“迈出一步”,变成一个新的表达式 E'。然后我们不断重复这个“迈步”过程,直到得到一个“值”。值本身不会再进行任何进一步的步骤。

以下是一个包含嵌套加法的树结构示例:

我们首先对它的左侧进行求值,使得左下角的加法步骤规约为 11

接着,我们处理下一个加法步骤,它也变成了 11

最后,我们处理最高层的加法步骤,它最终变成了 22

后续内容预告

在接下来的系列视频中,我们将更深入地探讨这种小步求值模型

总结

本节课我们一起学习了小步求值模型的基本思想。我们了解到,解释器可以通过定义一系列简单的规约规则(即“步骤”),将复杂的表达式逐步化简为最终的值。这个过程是理解编程语言运行时行为的基础。在后续课程中,我们将应用此模型来形式化地定义更复杂语言的求值过程。

167:求值关系 🧮

在本节课中,我们将学习如何用数学符号来描述编程语言的动态语义。我们将从简单的单步求值关系开始,逐步扩展到多步求值和大步求值关系,并理解它们之间的联系。

概述

随着我们构建比小型计算器语言更复杂的语言,我们需要一些数学符号来描述语言的动态语义。本节将基于单步求值来建立这些概念。

单步求值关系

我们使用符号 E → E' 来表示表达式 E 经过一次求值小步后变为 E'。这对应于我们在解释器中实现的 step 函数。

例如,表达式 5 + 2 + 0 会经过一步求值变为 7 + 0,然后再经过一步求值变为 7

为了更形式化地描述二元运算符的语义,我们使用以下规则:

以下是二元运算符的求值规则:

  1. 左子表达式求值:如果 E1 可以单步求值为 E1',那么 E1 + E2 可以单步求值为 E1' + E2
    • 公式:E1 → E1'(E1 + E2) → (E1' + E2)
  2. 右子表达式求值:如果 E2 可以单步求值为 E2',并且 V1 已经是一个值,那么 V1 + E2 可以单步求值为 V1 + E2'
    • 公式:E2 → E2'(V1 + E2) → (V1 + E2')
  3. 执行运算:如果 V1V2 都是值,那么 V1 + V2 可以单步求值为它们执行底层平台加法运算的结果 I
    • 公式:(V1 + V2) → I,其中 IV1 + V2 的计算结果。

注意:值本身不会进行求值步。例如,数字 7 不会进一步求值。单步关系要求必须恰好执行一步,不允许执行零步。

多步求值关系

上一节我们介绍了单步求值,本节中我们来看看如何描述零步或多步求值。

我们使用符号 →* 来表示多步求值关系,其中星号 * 表示自反传递闭包,即零步或多步单步求值。

以下是多步求值关系的示例:

  • 5 + 2 + 0 →* 5 + 2 + 0 (零步)
  • 5 + 2 + 0 →* 7 + 0 (一步)
  • 5 + 2 + 0 →* 7 (两步)

所有这些都包含在多步求值关系中,尽管只有后两个包含在单步求值关系中。

大步求值关系

既然我们的最终目标是求值得到一个值,我们引入一个关系来表达表达式可以一直求值到一个值。这称为大步求值关系,与我们之前的小步单步关系互补。它对应于我们在解释器中已经实现的 eval 函数。

我们使用符号 E ⇓ V 来表示表达式 E 大步求值到值 V

例如,5 + 2 + 0 ⇓ 7。它不会大步求值到自身,因为它还不是一个值。

关系之间的联系

你可能会期望这两种求值模型之间存在联系。确实如此。

假设有一个表达式 E,它经过一系列单步求值 E → E1 → E2 → ... → V 最终到达一个值 V。如果我们忽略所有中间步骤,只考虑 E 如何到达 V,这就是大步求值关系。它只是那串小步求值链的起点和终点。

更重要的是,我们希望这两种关系是一致的。无论你如何定义它们,对于所有表达式 E 和值 V,都应满足:
E ⇓ V 当且仅当 E →* V

这表达了大步求值关系与小步求值关系的一致性。

总结

本节课中我们一起学习了三种描述表达式求值的数学关系:

  1. 单步关系 ():恰好执行一步求值。对应于解释器中的 step 函数,是当前我们用来形式化描述语言动态语义的主要工具。
  2. 多步关系 (→*):执行零步或多步单步求值。
  3. 大步关系 ():执行所有求值步骤直到得到一个值。对应于解释器中的 eval 函数。

它们之间的关系是:表达式 E 大步求值到值 V,当且仅当 E 可以通过多步求值到达 V

168:Let语义

在本节课中,我们将学习如何为计算器语言添加变量和let表达式,并深入探讨其形式化语义。我们将重点关注替换操作的定义,这是理解let表达式求值过程的核心。


变量与Let表达式

上一节我们介绍了计算器语言的基础运算。本节中,我们来看看如何为其添加变量和let表达式,使其功能更加强大。

你可以想象一个带有存储功能的计算器,它允许你将数值存入不同的存储单元中。let表达式与此类似,它允许我们将值绑定到一个变量名上,并在后续的计算中使用这个变量名。

因此,我们为计算器语言添加以下两种新语法:

  • 变量:用元变量X表示,代表标识符。我们在此不严格规定标识符的具体语法类,这将在解析器和词法分析器中实现。
  • Let表达式:其语法与OCaml中的let表达式一致,形式为 let X = E1 in E2

Let表达式的单步求值语义

现在,我们使用单步求值关系来形式化定义let表达式的语义。

我们可以将let表达式的求值规则写作:

let x = E1 in E2  -->  let x = E1' in E2

条件:当且仅当 E1 --> E1'

这条规则的含义与我们本学期初所阐述的一致:为了求值一个let表达式,你首先需要求值其绑定表达式E1。因此,我们在此处进行一步求值(并持续进行,直到E1得到一个值)。

let表达式达到 let x = v1 in E2 的形式时(其中v1是一个值),它将进行一步求值,变为将v1替换掉E2中所有x之后的结果。同样,这也符合我们一直以来的说法。


替换操作的精确定义

然而,到目前为止,我们对“替换”的定义有些宽松。现在,让我们为替换操作引入一个精确的记法。

我将记作 E{V/x},其含义是:在表达式E中,用值V替换所有出现的变量x

使用这个记法,我就可以更精确地写出第二条求值规则,而无需依赖文字描述:

let x = v1 in E2  -->  E2{v1/x}

但现在,我需要真正给出E{V/x}这个替换操作的定义。

以下是替换操作的形式化定义:

  • 变量
    • x{V/x} = V
    • y{V/x} = y (当 y ≠ x 时)
  • 整数常量n{V/x} = n
  • 加法(E1 + E2){V/x} = (E1{V/x}) + (E2{V/x})
  • Let表达式(let y = E1 in E2){V/x} = let y = (E1{V/x}) in (E2{V/x})

注意:在最后一条关于let表达式的规则中,我们假设y是一个与x不同的新变量名,以避免变量捕获问题。这是实现替换时需要仔细处理的关键细节。


本节课中,我们一起学习了如何扩展计算器语言以支持变量和let绑定。我们形式化地定义了let表达式的单步求值语义,并引入了替换操作E{V/x}的精确定义,这是理解变量如何被其绑定值所替换的核心机制。

169:替换示例 🧩

在本节课中,我们将通过几个具体的例子,来逐步建立对“替换”这一核心概念的理解。我们将看到在OCaml的求值过程中,如何正确地用值替换变量,并理解替换在绑定表达式和主体表达式中的不同行为。


概述

我们将分析三个逐步复杂的表达式求值示例。通过这些例子,我们将直观地理解替换的定义,并总结出替换操作的关键规则。


第一个示例:基础替换

首先,我们来看一个简单的例子,以理解替换的基本过程。

假设我们有表达式:

let x = 2 + 2 in x + x

这个表达式的求值过程如下:

  1. 绑定表达式 2 + 2 单步规约为 4
  2. 接着,我们取主体表达式 x + x,并将其中所有的 x 替换为 4。请注意,这里的替换本身不是一个求值步骤,它只是将两个表达式视为等价。
  3. 替换后,我们得到 4 + 4
  4. 4 + 4 最终单步规约为 8

在这个例子中,替换直观地将主体表达式中所有出现的变量 x 都替换成了值 4


第二个示例:变量遮蔽与作用域

上一个例子展示了基础替换。现在,我们来看一个更复杂的例子,它涉及同一个变量名的多次绑定,这能帮助我们理解变量遮蔽和作用域。

考虑以下表达式:

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

为了清晰区分两个 x,我们用颜色标记:外层的 x 是蓝色,内层的 x 是橙色。

以下是求值过程:

  1. 首先进行单步规约。绑定表达式 5 已经是一个值,我们用它替换掉主体表达式中所有蓝色的 x
  2. 替换后,我们得到 (let x = 6 in x)请注意,我们没有在内层 let 表达式(它绑定了相同的变量名 x)的内部进行替换。这是因为我们希望变量遮蔽和作用域能按照我们之前学习的方式正常工作。
  3. 接下来,我们求值这个内层的 let 表达式,将 6 替换到其主体表达式 x 中。
  4. 这意味着 x 被替换为 6
  5. 因此,整个表达式最终规约为 11

从这个例子中,我们学到了关于何时停止替换的重要一点:当进入一个重新绑定同名变量的新作用域时,不应替换该作用域内部的同名变量。


第三个示例:绑定表达式内的替换

前两个例子帮助我们理解了主体表达式中的替换规则。现在,我们来看第三个更复杂的例子,它将揭示在绑定表达式内部进行替换的特殊情况。

考虑这个表达式:

let x = 1 in let x = x + x in x + x

这里,我们绑定了 x 两次。在第二次绑定(橙色 x)时,其绑定表达式 x + x 中使用了第一次绑定的 x(蓝色 x)。

让我们逐步分析:

  1. 我们想进行单步规约。这意味着我们将去掉最外层的 let 表达式(它将 x 绑定到 1),并规约到内层的 let 表达式。在这个过程中,我们需要在内层 let 表达式中进行一些替换。
  2. 我们必须非常小心地进行替换。一方面,正如我们在上一个例子中学到的,我们想在主体表达式 x + x 内部进行替换,因为这里重新绑定了相同的变量名。我们希望变量遮蔽和作用域正确工作。因此,在 x + x 内部,我们不会用 1 替换 x
  3. 但是,与上一个例子不同的是,在绑定表达式 x + x 中,我们确实需要进行替换,因为那里的 x + x 确实指的是将 x 绑定到 1 的那个作用域。
  4. 因此,这个替换会发生,并将 x + x 规约为 1 + 1

这里的新情况是:即使绑定表达式使用了相同的变量名,我们也会在绑定表达式内部进行替换。

因此,在绑定表达式和主体表达式中进行替换的规则是不同的。

根据我们目前所做的一切,剩下的部分很容易推导:

  • let x = 1 + 1 in x + x 会规约到 let x = 2 in x + x
  • 这又会规约到将 2 替换 x 后的 x + x,即 2 + 2
  • 最后,2 + 2 规约为 4

总结

在本节课中,我们一起学习了三个关于替换的示例,逐步建立了对替换定义和规则的理解。

我们学到的重要内容包括:

  • 替换是用值替换表达式中变量的过程,它本身不直接构成求值步骤,但用于建立表达式之间的等价关系。
  • 当遇到重新绑定同名变量的新作用域(如内层 let)时,不应替换该新作用域主体内的同名变量,这是变量遮蔽规则的要求。
  • 关键区别:替换在绑定表达式主体表达式中的行为不同。即使在绑定表达式中出现了与即将绑定的变量同名的标识符,只要它引用的是外层作用域的变量,就需要进行替换。

这些规则共同确保了OCaml的词法作用域和变量遮蔽行为符合我们的预期。

OCaml编程:9.17:替换的定义 🧩

在本节课中,我们将学习编程语言理论中的一个核心概念——替换。我们将详细探讨替换在整数、二元运算符、变量和let表达式中的具体定义和行为,并通过公式和例子来阐明其规则。


替换的定义

以下是替换的定义。

对于整数,实际上无需进行任何操作。如果你有整数42,并且要替换x0,这无关紧要,因为整数内部不包含变量x,所以整数保持不变。

当遇到二元运算符时,只需将替换操作递归地应用到运算符的两个子表达式E1E2中。具体来说,取用值v替换变量x的规则,递归地应用于E1内部,再递归地应用于E2内部。

变量是替换操作真正发生作用的地方。其行为取决于变量名是否与被替换的目标变量名相同。

如果你看到变量x,并且要用值V替换x,那么就将这个x替换为V

但是,如果你看到的是另一个不同的变量名y,并且正在进行替换操作,那么这无关紧要。因为它是不同的变量,应保持原样不动。所以,用v替换x时,y保持不变,仍然是y


let表达式中的替换

现在来看let表达式,这正是我们之前用例子说明的情况。

如果你有一个表达式 let y = E1 in E2,并且要在其中用值v替换变量x

由于y与变量x不是同一个变量名,你需要在两个地方进行替换:在绑定表达式E1中进行替换,同时在主体表达式E2中进行替换。


但是,如果你遇到一个地方,相同的变量名被重新绑定了,那么我们必须对替换操作格外小心。

我们确实会继续在绑定表达式内部进行替换。然而,我们在主体中停止替换,不再执行替换操作。

如果你觉得这已经很巧妙了,那么在引入函数之后,情况会变得更加复杂。不过我们目前还未涉及函数,所以暂时就讨论到这里。


总结

本节课中,我们一起学习了替换操作的精确定义。我们明确了替换在整数、二元运算符和变量上的基础规则,并重点剖析了在let表达式中,当遇到变量重绑定情况时,替换操作需要进行的特殊处理。理解这些规则是掌握表达式求值和后续更复杂语言特性的基础。

171:在计算器中实现Let表达式 🧮

在本节课中,我们将学习如何为我们的计算器解释器实现 let 表达式。我们将扩展抽象语法树(AST)以支持变量和 let 绑定,并实现相应的求值和替换逻辑。


扩展AST以支持变量和Let

上一节我们介绍了计算器的基本结构,本节中我们来看看如何扩展它。首先,我们需要修改AST类型定义,增加两个新的构造器来表示变量和 let 表达式。

type expr =
  | Int of int
  | Binop of binop * expr * expr
  | Var of string          (* 新增:变量,携带一个字符串标识符 *)
  | Let of string * expr * expr  (* 新增:let表达式,三元组(变量名,绑定表达式,主体表达式) *)
  • Var of string:表示一个变量,其标识符用OCaml的字符串表示。
  • Let of string * expr * expr:表示一个 let 表达式。它包含一个三元组:被绑定的变量名(字符串)、用于绑定的表达式以及主体表达式。

词法分析器和语法分析器也需要相应扩展以识别新的语法结构,这部分代码已提供。


更新解释器与求值函数

由于我们向AST添加了新的节点,原有的模式匹配将变得不完整,编译器会产生警告。因此,我们需要更新解释器中的相关函数,特别是 is_valuestep 函数。

判断是否为值

对于 is_value 函数,只有整数是值。变量和 let 表达式本身都不是值。

let is_value = function
  | Int _ -> true
  | Var _ | Let _ | Binop _ -> false

单步求值函数

接下来,我们实现 step 函数来处理变量和 let 表达式。

1. 处理变量

当求值过程到达一个变量名时,意味着这个变量从未被绑定过(例如,在顶层直接输入 x)。此时,解释器应报错。

let unbound_var_err = "Unbound variable"
let step = function
  | Var x -> failwith unbound_var_err
  (* ... 其他模式 ... *)

我们将错误信息定义为一个常量 unbound_var_err。这样做的好处是,在后续编写测试用例时,可以直接引用这个常量,避免在代码中重复硬编码字符串,减少重复。

2. 处理Let表达式

let 表达式的求值规则我们已经推导过:

  • 如果绑定表达式 e1 还不是一个值,则先对 e1 进行一步求值。
  • 如果绑定表达式 e1 已经是一个值,则执行一步求值:将主体表达式 e2 中的变量 x 替换为值 e1

以下是代码实现:

let step = function
  | Let (x, e1, e2) when is_value e1 -> subs e2 e1 x
  | Let (x, e1, e2) -> Let (x, step e1, e2)
  (* ... 其他模式 ... *)

这里用到了替换函数 subs e v x,它的含义是“在表达式 e 中,将变量 x 替换为值 v”。我们接下来就实现它。


实现替换函数

替换函数 subslet 表达式求值的核心。它的实现需要对表达式 e 进行结构递归,并根据其语法形式采取不同操作。

以下是替换函数的实现逻辑,对应四种AST节点:

let rec subs e v x = match e with
  | Int _ -> e                          (* 整数:无需替换,直接返回 *)
  | Binop (op, e1, e2) ->               (* 二元操作符:递归替换两个子表达式 *)
      Binop (op, subs e1 v x, subs e2 v x)
  | Var y ->                            (* 变量:检查标识符 *)
      if x = y then v else e            (* 相同则替换,不同则保留 *)
  | Let (y, e1, e2) ->                  (* Let表达式:需要处理变量作用域 *)
      let e1' = subs e1 v x in          (* 总是替换绑定表达式e1 *)
      if x = y then
        Let (y, e1', e2)                (* 变量名相同:停止对e2的替换 *)
      else
        Let (y, e1', subs e2 v x)       (* 变量名不同:继续替换e2 *)

关键点在于处理 Let 表达式时的变量作用域规则:

  1. 总是对绑定表达式 e1 进行替换。
  2. 然后检查 let 绑定的变量名 y 是否与要被替换的变量名 x 相同。
    • 如果相同(x = y),意味着这个 let 表达式重新绑定了变量 x,形成了新的作用域。因此,我们不应对主体表达式 e2 进行替换(替换在此停止)。
    • 如果不同(x != y),则继续对主体表达式 e2 进行替换。

测试与总结

至此,我们已经为计算器语言成功实现了 let 表达式。可以运行配套的测试文件来验证实现的正确性,所有测试都应通过。

本节课中我们一起学习了:

  1. 扩展AST:增加了 VarLet 节点来支持变量和绑定。
  2. 更新求值器:在 step 函数中处理了未绑定变量错误和 let 表达式的分步求值规则。
  3. 实现替换:定义了关键的 subs 函数,它通过结构递归和变量作用域检查,正确地实现了表达式中的变量替换。

通过实现 let,我们为计算器语言增加了定义和重用中间结果的能力,使其表达能力大大增强。

172:SimPL语言小步语义

在本节课中,我们将学习如何为SimPL语言添加if表达式和布尔值。我们将扩展之前实现的带let表达式的计算器解释器,使其支持条件判断,从而使其成为一个更接近真实编程语言的简单片段。

概述:扩展语言功能

上一节我们实现了带let表达式的计算器解释器。本节中,我们将为同一语言添加if表达式。由于引入了if表达式,我们也将同时添加布尔值。这个扩展后的语言我们称之为SimPL(Simple Programming Language),因为它现在已超越了简单的计算器范畴,拥有了条件判断能力,构成了OCaml的一个简单片段。

我们将添加以下新元素:

  • 布尔常量(truefalse)。
  • if表达式,其语法为:if E1 then E2 else E3
  • 扩展二元运算符,包含小于等于比较(<=),该操作符接收两个整数并返回一个布尔值。
  • 布尔值本身也是值,当求值到布尔值时,计算即告完成。

小步语义规则

以下是if表达式的小步求值规则:

  1. 求值条件表达式
    规则:if E1 then E2 else E3if E1' then E2 else E3
    条件:E1E1'
    这条规则表示,要计算一个if表达式,首先需要对其条件部分E1进行求值。

  2. 条件为真
    规则:if true then E2 else E3E2
    当条件部分求值为布尔值true时,整个if表达式一步求值为then分支E2

  1. 条件为假
    规则:if false then E2 else E3E3
    当条件部分求值为布尔值false时,整个if表达式一步求值为else分支E3

扩展抽象语法树

为了支持SimPL中的所有新表达式语法,我们需要扩展抽象语法树(AST)的定义。

以下是AST类型的扩展部分:

type binop = ...
          | Leq  (* 新增:小于等于运算符 *)

type expr = ...
          | Boo of bool          (* 新增:布尔常量节点,携带一个OCaml布尔值 *)
          | If of expr * expr * expr (* 新增:if节点,携带三个表达式:条件、then分支、else分支 *)

我们为二元运算符类型binop添加了Leq构造子。
我们添加了Boo构造子,它携带一个OCaml的bool值,用于表示SimPL中的布尔常量。
我们添加了If节点,它携带三个表达式:条件守卫、then分支和else分支。

实现求值函数

在重建代码后,由于新增了AST节点,我们会在许多模式匹配中得到“非穷尽匹配”的警告。接下来我们开始修复这些问题。

判断是否为值

我们需要更新is_value函数:

  • 布尔节点是值,因此遇到Boo _时返回true
  • if表达式不是值,因此遇到If _时返回false

实现替换

我们需要更新subst函数:

  • 当对布尔常量进行替换时,由于其中不可能包含变量名,因此无需做任何操作,直接返回原表达式e
  • 当在if表达式中进行替换v for x时,只需递归地将该替换操作应用到if表达式的每一个子表达式(条件、then分支、else分支)中。

实现单步求值

现在,我们需要在step函数中处理布尔值和if表达式。

  • 布尔值:由于它们已经是值,因此不进行单步求值。
  • if表达式:我们需要小心确保首先对正确的部分进行求值,就像处理二元运算符时一样。
    • 如果条件e1还不是一个值,那么我们就先对e1进行单步求值。
    • 如果条件e1已经是一个值,那么我们将进入一个专门处理if表达式的新辅助函数step_if

接下来,我们编写step_if辅助函数。该函数的核心逻辑如下:

  • 如果条件值v1true,则if表达式单步求值为then分支e2
  • 如果条件值v1false,则if表达式单步求值为else分支e3
  • 如果条件值v1是其他类型的值(目前只有整数),则我们需要处理可能出现的运行时类型错误。我们引入一个错误消息:"guard of if must have type bool"(if的条件守卫必须是布尔类型)。

注意:对于最后一种情况(v1是整数)的兜底处理,目前稍显不安,因为如果未来我们为语言添加更多类型的值,类型检查器不会发现我们遗漏了对这些新类型的处理。后续当我们实现一个将值类型与表达式类型分开的解释器时,将解决这个问题。

处理新增的二元运算符

我们还需要修复其他非穷尽模式匹配,其中之一是二元运算符的求值。我们添加了新的二元运算符Leq(小于等于),因此也需要处理它。

  • 实现Leq:如果它接收两个整数值,则将其简化为这两个值的OCaml比较结果,并用Boo构造子包装。其他情况(如操作数不是整数)则属于二元运算符错误。

完成字符串转换

最后,我们需要更新将表达式转换为字符串的函数,使其能够处理布尔值。

测试与运行

完成上述所有实现后,我们应该能够运行所有的测试用例。我们现在有了针对if表达式的新测试用例。

虽然这还不是一个工业级的测试套件,可以进一步扩展,但它足以演示我们目前完成的工作。

运行测试,所有测试用例均通过。至此,我们成功地在SimPL这个小语言中实现了if表达式。

总结

本节课中,我们一起学习了如何为SimPL语言添加if表达式和布尔值。我们定义了新的小步语义规则,扩展了抽象语法树,并逐步实现了is_valuesubststep等核心函数以支持新的语言特性。通过这个过程,我们看到了如何系统地为一个编程语言添加新的语法结构并实现其动态语义。

173:SimPL大步语义解释器实现

在本节课中,我们将为SimPL语言实现一个“大步语义”解释器。通过这个过程,我们将理解大步语义与小步语义这两种不同风格的形式语义和求值关系之间的区别。

大步语义规则概述

上一节我们介绍了小步语义,本节中我们来看看SimPL语言的大步语义规则。大步语义直接描述一个表达式如何一步求值到最终结果(值)。

  • :一个值(整数或布尔值)通过大步语义直接求值为自身。
  • 二元操作符:表达式 e1 + e2 大步求值为值 v,当且仅当满足以下三个条件:
    1. e1 大步求值为值 v1
    2. e2 大步求值为值 v2
    3. v 是执行原始操作 v1 + v2 的结果。
  • 变量:单独的变量名无法通过大步语义求值,这表示遇到了未绑定的变量。
  • let表达式:表达式 let x = e1 in e2 大步求值为值 v2,当且仅当满足以下两个条件:
    1. e1 大步求值为值 v1
    2. e2 中的 x 替换为 v1 后得到的新表达式大步求值为 v2
  • if表达式if 表达式的语义与上述规则类似。整个 if 表达式大步求值为对应分支的值,具体取决于条件守卫表达式大步求值的结果。

解释器实现

现在,让我们开始实现SimPL的大步语义解释器。抽象语法树(AST)、词法分析器和语法分析器都与小步语义解释器中的实现完全相同。

唯一需要修改的是 main.ml 文件中的部分实现。错误信息和替换函数的实现保持不变。主要变化是:我们移除了 step 函数,现在 eval 函数将负责完成所有求值工作。

eval 函数接收一个表达式,并返回一个新表达式,这个返回的表达式就是输入表达式通过大步语义求值得到的最终值。

以下是 eval 函数的核心实现步骤:

  1. 处理值和变量

    • 整数或布尔值已经是值,直接返回自身。
    • 尝试对变量求值将返回未绑定变量错误。
  2. 处理let表达式

    • 首先,求值 e1 得到 v1
    • 接着,将 e2 中的 x 替换为 v1,得到 e2'
    • 最后,求值 e2' 得到最终结果 v2
    • 这种多行实现通常会被提取为一个辅助函数,这是一种常见的实现模式。
  3. 处理二元操作符

    • 求值 e1e2
    • 同时模式匹配求值结果和操作符类型。
    • 检查操作数是整数,然后执行对应的运算(加、乘、小于等于)并返回结果。
    • 其他情况(如类型错误)会引发错误。
  4. 处理if表达式

    • 求值条件守卫表达式。
    • 如果结果为 true,则求值 e2 分支。
    • 如果结果为 false,则求值 e3 分支。
    • 如果守卫求值结果不是布尔值(例如是整数),则引发错误。

测试与总结

完成实现后,我们可以运行测试套件来验证解释器的正确性。这个测试套件与小步语义解释器所使用的完全相同。如果所有测试都通过,则表明我们的大步语义解释器实现成功。

本节课中我们一起学习了如何为SimPL语言实现一个大步语义解释器。我们理解了大步语义的规则,它直接描述了表达式到最终值的求值过程,并与之前学习的小步语义(描述表达式如何通过一系列小步骤变换)形成了对比。通过具体的代码实现,我们掌握了在函数式编程中构建此类解释器的常见模式和技巧。

174:核心OCaml小步语义

在本节课中,我们将学习如何将之前为Simple语言定义的语义扩展到OCaml的一个更大片段,我们称之为“核心OCaml”。这个片段包含了OCaml作为函数式编程语言几乎所有核心特性。

上一节我们为Simple语言实现了一个解释器。本节中,我们来看看如何构建一个更强大的语言片段。

🧩 核心OCaml的扩展特性

核心OCaml在Simple语言的基础上增加了以下关键特性:

  • 函数与应用:这是Simple语言中缺失的部分。
  • 序对:包括序对的构造以及获取其第一和第二分量。
  • 模式匹配:我们目前只硬编码了两个构造器(LeftRight),并可以针对它们进行模式匹配。

对于值,我们现在有:

  • 函数本身是值。
  • 由值构成的序对本身是值。
  • 应用于一个值的LeftRight构造器也是值。

🔄 核心OCaml的小步语义

接下来,我们介绍核心OCaml的小步语义规则。

函数应用

以下是函数应用的求值规则:

  1. 如果表达式E1可以单步求值为E1',那么应用E1 E2可以单步求值为E1' E2
    • 用公式描述为:E1 -> E1' 蕴含 E1 E2 -> E1' E2
  2. 如果E1已经是一个值V1,而E2可以单步求值为E2',那么应用V1 E2可以单步求值为V1 E2'
    • 用公式描述为:E2 -> E2' 蕴含 V1 E2 -> V1 E2'
  3. 当应用的两个部分都是值(即V1 V2)时,第一个值V1必须是一个函数(例如fun x -> E)。此时,该应用单步求值为函数体E,其中函数的形参x被实参值V2替换。
    • 用公式描述为:(fun x -> E) V2 -> E[V2 / x]

序对

序对的求值规则比较直观:

  1. 如果E1可以单步求值为E1',那么序对(E1, E2)可以单步求值为(E1', E2)
    • 用公式描述为:E1 -> E1' 蕴含 (E1, E2) -> (E1', E2)
  2. 一旦序对的第一个分量是值V1,就可以开始求值第二个分量。如果E2可以单步求值为E2',那么(V1, E2)可以单步求值为(V1, E2')
    • 用公式描述为:E2 -> E2' 蕴含 (V1, E2) -> (V1, E2')
  3. 函数fst应用于一个由两个值构成的序对(V1, V2)时,求值结果为第一个值V1
    • 用代码描述为:fst (V1, V2) -> V1
  4. 函数snd应用于一个由两个值构成的序对(V1, V2)时,求值结果为第二个值V2
    • 用代码描述为:snd (V1, V2) -> V2

构造器与模式匹配

对于构造器(如LeftRight),我们只需对构造器内部的表达式进行求值。

  1. 如果E可以单步求值为E',那么Left E可以单步求值为Left E'Right E同理。
    • 用公式描述为:E -> E' 蕴含 C(E) -> C(E'),其中C代表LeftRight

模式匹配的规则稍复杂一些。考虑表达式 match E with Left x1 -> E1 | Right x2 -> E2

以下是其求值步骤:

  1. 首先,对匹配对象E进行求值。如果E可以单步求值为E',那么整个match表达式单步求值为 match E' with ...
    • 用公式描述为:E -> E' 蕴含 (match E with ...) -> (match E' with ...)
  2. E最终求值为一个值,例如Left V时,整个match表达式将单步求值到与Left构造器对应的分支(即E1),并且将匹配到的值V替换该分支中的模式变量x1
    • 用公式描述为:match (Left V) with Left x1 -> E1 | ... -> E1[V / x1]
  3. 对称地,如果E求值为Right V,则求值到E2分支,并用V替换x2
    • 用公式描述为:match (Right V) with ... | Right x2 -> E2 -> E2[V / x2]

💡 关于替换的说明

我们在函数应用和模式匹配的规则中多次提到了“替换”(例如E[V / x])。为了精确定义这些语义,我们需要为核心OCaml给出一个替换操作的形式化定义。这将是下一节的主要内容。

本节课中,我们一起学习了如何将小步语义扩展到包含函数、序对和模式匹配的核心OCaml片段。我们定义了各类表达式如何单步求值,并看到替换操作在函数应用和模式匹配中的核心作用。

175:函数中的替换 🧩

在本节课中,我们将学习如何为OCaml核心语言定义替换操作。我们将重点关注函数定义中的替换,这是整个过程中最微妙和复杂的部分。

概述

替换是编程语言语义中的一个核心概念,它描述了如何将一个表达式中的变量替换为另一个值。对于大多数语言结构,替换操作是直观的。然而,当涉及到匿名函数(lambda表达式)时,情况就变得复杂了,因为我们必须仔细处理变量的作用域,避免发生“变量捕获”的问题。

上一节我们介绍了替换的基本概念,本节中我们来看看在函数定义中应用替换时会遇到的特殊挑战。

函数替换的初步尝试

首先,让我们尝试为匿名函数定义替换。假设我们有一个函数 fun x -> E',我们想在其中用值 V 替换变量 x

一个初步的想法是:如果函数的参数名(x)恰好就是我们要替换的变量名,那么我们应该停止替换。这符合之前学习替换定义时的直觉:在绑定同名变量的地方停止替换。

公式表示:
subst(V, x, fun x -> E') = fun x -> E'

如果函数绑定的是另一个变量名,例如 fun y -> E',而我们要替换的是 x,那么 xy 不同,似乎我们应该继续在函数体 E' 中执行替换。

公式表示:
subst(V, x, fun y -> E') = fun y -> subst(V, x, E') (当 x ≠ y 时)

一个揭示问题的例子

让我们通过两个简单的函数来检验这个定义是否有效。假设 x 在外部作用域中已有定义。

考虑以下两个函数:

  1. fun y -> x (蓝色函数)
  2. fun z -> x (橙色函数)

直观上,这两个函数都应该忽略其参数,并返回外部定义的 x 的值。

现在,假设我们执行一个 let 绑定:let x = z in (fun y -> x)。根据 let 的语义,它应该求值为将 z 替换到 (fun y -> x)x 的位置后的结果。

根据我们初步的替换定义:

  • 对于蓝色函数 fun y -> x,由于 yx 不同,我们会在函数体内进行替换,得到 fun y -> z。这个函数忽略其参数并返回 z,这正是我们期望的结果。
  • 对于橙色函数 fun z -> x,由于 z 是函数绑定的参数名,与我们想要替换的变量 x 不同,我们本应继续替换。但是,根据“在绑定同名变量处停止”的规则,z 在这里被绑定了,所以我们停止替换,得到的结果是 fun z -> x

问题出现了:fun z -> x 是一个恒等函数(返回其参数 z),而不是我们期望的“忽略参数并返回外部 z”的函数。这两个函数的行为不再相同。

变量捕获问题

让我们仔细分析第二个例子。这里存在两个 z

  • 橙色 z:来自外部作用域,是我们要替换进去的值。
  • 红色 z:是函数 fun z -> x 绑定的形式参数。

当我们尝试在 fun z -> x 内部用外部的橙色 z 替换 x 时,外部的 z 被函数内部的参数 z 捕获了。这破坏了作用域的隔离性:外部的变量意外地变成了函数内部的局部变量。

核心问题:我们初步的替换定义导致了变量捕获

避免捕获的替换

我们需要一个改进的定义,即避免捕获的替换

其定义如下:当我们在函数 fun y -> E 内部,用值 V 替换变量 x 时:

  1. 如果 x == y,则停止替换:subst(V, x, fun x -> E) = fun x -> E
  2. 如果 x != y,则仅当 y 不是 V 中的自由变量时,我们才在函数体 E 中继续替换:subst(V, x, fun y -> E) = fun y -> subst(V, x, E) (当 x ≠ yy ∉ FV(V) 时)

代码描述(伪代码):

let rec subst (v : exp) (x : string) (e : exp) : exp =
  match e with
  | Fun (y, body) ->
      if x = y then
        Fun (y, body) // 情况1:停止替换
      else if not (free_in y v) then
        Fun (y, subst v x body) // 情况2:安全则继续替换
      else
        // 情况3:可能发生捕获,需要特殊处理
        ...

这里引入了一个新概念:自由变量。一个变量是自由的,如果它在表达式中出现,但未被任何 let、模式匹配或函数绑定到当前作用域。FV(V) 表示值 V 中所有自由变量的集合。

这个定义使得替换在某种意义上成为一个“部分函数”:当替换可能导致捕获(即 y ∈ FV(V))时,上述规则无法直接应用。

阿尔法转换(重命名)

当面临变量捕获的风险时,我们并非无计可施。我们可以使用阿尔法转换,即为函数的形式参数换一个全新的、在上下文中未使用过的名字。

回顾之前的例子:对于 fun z -> x,用外部的 z 替换 x 会导致捕获。

  1. 我们识别到 z(函数参数)是 V(外部 z)中的自由变量,满足捕获条件。
  2. 我们对函数进行阿尔法转换,将参数 z 重命名为一个新鲜的名字,例如 z1。得到 fun z1 -> x
  3. 现在,z1 不在外部 z 的自由变量集中,满足安全替换的条件。
  4. 安全地在函数体内进行替换,得到 fun z1 -> z

这个新函数 fun z1 -> z 会忽略其参数 z1 并返回外部的 z,行为与 fun y -> z 一致,问题得以解决。

公式表示(捕获时):
subst(z, x, fun z -> x) =>α=> subst(z, x, fun z1 -> x) = fun z1 -> z

总结

本节课我们一起学习了函数定义中的替换操作。我们了解到,简单的替换规则在处理函数时会导致变量捕获,从而破坏程序语义。为了解决这个问题,我们引入了避免捕获的替换,其核心是检查替换值中是否包含会被捕获的自由变量。当捕获可能发生时,我们通过阿尔法转换重命名函数参数,使用一个新鲜的名字来避免冲突。

替换的定义虽然基础,但极其微妙且容易出错。历史上,许多数学家都曾给出过有缺陷的替换定义,直到20世纪50年代,哈斯凯尔·柯里才提出了真正正确的形式。理解并正确实现避免捕获的替换,是深入理解编程语言,特别是函数式编程语言求值语义的关键一步。

176:SimPL的环境模型 🧠

在本节课中,我们将学习编程语言动态语义的另一种模型——环境模型。我们将看到它如何比替换模型更贴近真实机器的执行方式,并学习如何使用它来定义语言的语义。

概述

替换模型是理解语言动态语义的优秀思维模型,它以一种简单的方式思考计算过程。然而,替换模型并非对真实机器的现实模拟。在真实机器中,代码和数据通常是分开存储的。我们有一个单独的内存区域用于存储代码,另一个单独的区域用于存储数据。在运行时,机器不会通过编辑代码来执行变量替换,而是将数据保存在内存中,并在需要时进行查找。

环境模型的核心概念

上一节我们提到了替换模型的局限性,本节中我们来看看更贴近机器实现的环境模型

在环境模型中,我们引入了一个称为动态环境的概念。动态环境是一个映射,它将变量名关联到其在该作用域内的当前值。这可以看作是一种“惰性替换”:变量名最终可能会被其值替换,但我们并不立即执行替换,而是在需要时查找该值。

为了对这种求值过程进行建模,我们将引入一个新的大步关系。与之前替换模型的大步关系不同,这是环境模型的大步关系

它的形式如下:

<env, e> ⇓ v

其中:

  • env 是动态环境。
  • e 是要求值的表达式。
  • v 是求值结果。

箭头左侧用尖括号括起来的部分称为机器配置。你可以将其视为程序执行时机器的当前状态。其中第一个组件(env)类似于内存,第二个组件(e)则是正在求值的程序。

我们将环境抽象地视为映射。我们使用花括号 {} 表示空环境。例如,{x: 42, y: “red”} 表示将变量名 x 映射到值 42,将变量名 y 映射到字符串 “red” 的环境。环境是偏函数,记作 env(x) 表示在环境中查找变量 x。如果查找一个未绑定的变量,则会发生未绑定变量错误。

SimPL语言的环境模型语义

现在,我们可以使用这个环境模型的大步关系来定义 SimPL 语言的动态语义。

变量求值

对于变量,我们只需在环境中查找它。这正体现了惰性替换的发生。

<env, x> ⇓ env(x)

请注意这与替换模型的不同之处。在替换模型中,我们会说这是一个未绑定变量。在这里,它可能绑定也可能未绑定。如果 x 在环境中没有映射,则是一个未绑定变量错误;如果已绑定,则这正是执行了最终惰性替换的结果。

Let 表达式求值

对于在环境 env 中求值 let x = e1 in e2 表达式:

  1. 首先,在相同的动态环境 env 中求值 e1,得到值 v1
    <env, e1> ⇓ v1
    
  2. 然后,我们不是将 v1 替换到 e2 中,而是记录 x 应该是什么。我们取环境 env,并在其中将 x 绑定到 v1。我们引入一个新的符号 env[x -> v1] 来表示扩展环境以绑定(或重新绑定,如果变量已存在)一个值。
  3. 接着,我们在那个记录了 xv1 的惰性替换的扩展环境中求值 e2,得到值 v2
    <env[x -> v1], e2> ⇓ v2
    
  4. 整个 let 表达式的求值结果就是 v2

其他表达式求值

以下是其他类型表达式的求值规则:

  • :像在替换模型中一样,值直接求值为自身。
    <env, v> ⇓ v
    

  • 二元操作符:其工作方式与替换模型几乎相同,我们只需要在子表达式求值时传入相同的环境。
    <env, e1> ⇓ v1    <env, e2> ⇓ v2    v = v1 op v2
    ————————————————————————————————————————————————
              <env, e1 op e2> ⇓ v
    

  • If 表达式:同样,我们只是将动态环境向下传递到语义中。
    <env, e1> ⇓ true    <env, e2> ⇓ v
    ———————————————————————————————————
        <env, if e1 then e2 else e3> ⇓ v
    
    <env, e1> ⇓ false    <env, e3> ⇓ v
    ————————————————————————————————————
        <env, if e1 then e2 else e3> ⇓ v
    

总结

本节课中我们一起学习了 SimPL 语言的环境模型。我们了解到,与替换模型不同,环境模型通过维护一个动态环境(即变量名到其值的映射)来模拟求值过程,这更贴近真实计算机在内存中存储和查找数据的机制。我们定义了基于环境模型的大步求值关系 <env, e> ⇓ v,并详细说明了变量、let 表达式、值、二元操作符和 if 表达式在该模型下的求值规则。环境模型本质上实现了一种惰性替换,只在需要时才查找变量的值,从而提供了对程序运行时行为更现实的描述。

177:环境模型示例 🌳

在本节课中,我们将学习如何使用大步(Big Step)环境模型来评估一段OCaml代码。我们将通过一个具体的例子,理解如何将计算过程构建为树形结构,并比较它与小步(Small Step)模型的不同之处。


从列表到树:计算结构的转变

上一节我们介绍了小步模型,它将计算过程建模为一个列表。表达式 E 逐步演变为 E1E2,最终求值为结果。

在本节中,我们来看看大步环境模型。它将计算过程结构化为树,而不是列表。以下是该树形结构的示例,通过嵌套每个求值关系(evaluation relation)的使用来展示其如何支持更高层级的求值。

构建求值树:从叶子到根

以下是构建求值树的步骤,我们从最内层(叶子节点)开始。

首先,在空环境 {} 中,我们两次使用值规则(Value Rule):

  • 1 求值为 1
  • 2 求值为 2

接着,在其之上使用二元操作符规则(Binary Operator Rule),得出结论:1 + 2 求值为 3。这是因为我们需要分别求值子表达式 E1E2,然后执行原始操作。

对于表达式 x + x,我们同样需要分别求值。这里我们两次使用变量规则(Variable Rule),在动态环境中查找 x 并找到其值 3。然后使用二元操作符规则将 33 相加,得到 6

最后,在树的最高层级,我们使用 let 规则(Let Rule)来求值绑定表达式,将其值记录为动态环境中 x 的值,然后求值主体表达式。

树的图形化表示

除了上一张幻灯片中使用的缩进文本表示法,我们还可以将树绘制为图形。

在这种表示中,节点是大步关系或原始操作事实的实例,边则展示了它们作为大步语义定义中规则的一部分是如何被使用的。

实际上,你可以将此树视为解释器运行时 eval 函数递归调用结构的展示。

首先,对 let 表达式调用 eval,这将调用对绑定表达式和主体表达式中二元操作符的 eval。而这些调用又会进一步调用对值(如 12)或变量(如 x)的 eval,并执行原始操作。

证明树表示法

另一种书写证明树的方式(如果你上过数理逻辑课可能见过)如下所示。

这里我们移除了节点和边,改用线条。每条线代表我们语义中一个规则的使用(无论是变量规则、二元操作符规则还是其他规则)。规则名称写在线的旁边。在每次规则使用时,结论在线下方,前提在线上方。

因此,我们介绍了三种表示这些树的方法:

  • 带缩进的文本表示法(适用于幻灯片)。
  • 图形化的节点-边树(适用于可视化理解)。
  • 证明树线条表示法(在纸笔演算时更图形化、易读)。


总结

本节课中,我们一起学习了如何使用大步环境模型来评估OCaml代码。我们通过一个例子,演示了如何将计算过程构建为树形结构,从叶子节点的基本值求值开始,逐步向上应用变量规则、二元操作符规则和 let 规则,最终得到整个表达式的结果。我们还比较了文本、图形和证明树三种不同的树形结构表示方法,帮助你更深入地理解求值过程的递归本质。

178:SimPL环境模型解释器实现 🧠

在本节课中,我们将学习如何基于大步骤环境模型,为SimPL语言实现一个解释器。我们将看到,与之前基于表达式的解释器相比,环境模型通过引入“值”类型和动态环境,使实现更加清晰和直接。

概述

我们将实现一个解释器,其核心变化在于求值部分。解析器、抽象语法树(AST)等组件与之前保持一致。解释器将引入一个动态环境来存储变量绑定,并定义一个专门的“值”类型来表示求值结果。

实现详解

定义动态环境与值类型

首先,我们需要定义动态环境。我们使用OCaml标准库的Map模块来创建一个从变量名(字符串)映射到“值”的数据结构。

module Env = Map.Make(String)
type env = value Env.t

这里的env类型是一个映射表,其键是字符串,值是value类型。value是我们为SimPL语言定义的新类型,用于明确区分“表达式”和“求值结果”。

SimPL语言中只有两种值:整数和布尔值。为了避免与AST中同名的表达式构造器冲突,我们在构造器名称前加上V

type value =
  | VInt of int
  | VBool of bool

求值函数

求值函数eval现在接受两个参数:一个表达式expr和一个动态环境env。它返回一个value类型的结果,而不是表达式。

let rec eval (env : env) (e : expr) : value =
  match e with
  ...

以下是针对不同语法结构的求值规则实现。

字面量

整数和布尔字面量直接转换为对应的值类型。

  • 整数Int i 求值为 VInt i
  • 布尔值Bool b 求值为 VBool b

变量

对于变量Var x,我们需要在动态环境env中查找其绑定的值。我们使用一个辅助函数lookup来实现。

| Var x -> lookup env x

如果变量未在环境中找到,lookup函数将引发一个“未绑定变量”错误。

二元操作符

对于二元操作符表达式Binop (op, e1, e2),我们首先在当前环境中分别求值e1e2,得到两个值v1v2。然后,根据操作符op的类型,对这两个值进行相应的运算(例如加法、逻辑与等),并返回结果值。这确保了操作符的语义正确性。

Let表达式

Let表达式Let (x, e1, e2)的求值步骤如下:

  1. 在当前环境env中求值e1,得到值v1
  2. 将绑定(x, v1)添加到当前环境中,创建一个新的环境env'
  3. 在这个新环境env'中求值e2,其结果即为整个Let表达式的值。

If表达式

对于条件表达式If (e1, e2, e3)

  1. 首先求值条件守卫e1,得到一个值v1
  2. 匹配v1
    • 如果是VBool true,则求值e2并返回其值。
    • 如果是VBool false,则求值e3并返回其值。
    • 如果是其他值(例如一个整数),则引发类型错误,因为守卫必须是布尔值。

辅助函数更新

最后,我们需要更新将值转换为字符串的函数。由于现在有了明确的value类型,我们可以安全地进行模式匹配,而不再需要通用的“捕获所有”分支。

let string_of_value = function
  | VInt i -> string_of_int i
  | VBool b -> string_of_bool b

总结

本节课中,我们一起学习了如何为SimPL语言实现一个基于环境模型的解释器。关键点在于:

  1. 引入了动态环境env)来管理运行时的变量绑定。
  2. 定义了专门的值类型value)来清晰地区分表达式和求值结果。
  3. 求值函数eval现在接收环境作为参数,并返回一个值。
  4. 针对每种语法结构,我们严格遵循了环境模型的语义规则进行实现,使得代码结构清晰,逻辑直接。

这种实现方式避免了早期将所有东西都视为表达式所带来的模式匹配复杂性,使解释器的设计与语言的形式化语义更加贴合。

179:环境模型中的函数语义 🧠

在本节课中,我们将学习如何为包含匿名函数和函数应用的扩展语言定义大步环境模型语义。我们将探讨两种不同的变量作用域规则,并理解OCaml所采用的词法作用域规则。


扩展语言与初步语义

上一节我们介绍了简单语言的环境模型。本节中,我们来看看如何通过添加匿名函数和函数应用来扩展这个语言。

我们如何为这个扩展语言定义大步环境模型语义?由于函数是值,我们可能会认为,在环境 e 中求值匿名函数 fun x -> e 会直接返回这个函数值,就像求值一个整数值会返回该整数一样。

这个想法很诱人,简单,但却是错误的。稍后我将展示原因。

对于函数应用 e1 e2,我们可能会这样定义:首先求值 e1。如果类型检查通过,它最终会返回一个函数,假设是 fun x -> e。接着,我们求值参数 e2 得到值 v2。然后,类似于之前处理 let 表达式的方法,我们扩展动态环境,将 x 绑定到 v2,并在这个新环境中求值函数体 e,最终得到结果值 v

这个想法同样很诱人,简单,但也是错误的。


问题示例与分析

那么问题出在哪里呢?下面这段代码可以帮助我们看清问题。

假设我们执行以下代码:

let x = 1 in
let f = fun y -> x in
let x = 2 in
f 0

这段代码应该求值为什么?关键在于应该使用 x 的哪个绑定:是绑定到 1 的那个,还是绑定到 2 的那个?

在OCaml中,这段代码求值结果为 1。也就是说,最终使用的是 x 绑定到 1 的那个定义。

但是,根据我们刚才给出的错误语义,会发生什么呢?以下是每行代码执行时动态环境的状态:

  1. 执行 let x = 1 后,环境绑定 x1
  2. 执行 let f = fun y -> x 后,环境扩展为绑定 f 到函数 fun y -> x
  3. 执行 let x = 2 后,环境将 x 重新绑定2
  4. 最后,在当前环境中求值 f 0

根据我们的错误语义:

  • 首先,求值 f 得到函数值 fun y -> x
  • 其次,求值参数 0 得到值 0
  • 第三,扩展环境,将函数形参 y 绑定到实参值 0
  • 第四,在当前环境中求值函数体 x。此时查找 x,得到的是 2

因此,根据错误语义,求值结果将是 2。这与OCaml实际返回的 1 不符。


动态作用域 vs. 词法作用域

为什么我们得到了两个不同的答案?这是因为我们正在探讨两种定义变量作用域的方式。

我们的错误语义使用的是动态作用域规则。动态作用域规则规定:函数体在函数被调用时的当前环境中进行求值,而不是在函数定义时的旧环境中。

OCaml的正确语义使用的是词法作用域(也称为静态作用域)规则。词法作用域规则规定:函数体在函数被定义时的旧环境中进行求值,而不是在函数被调用时的当前环境中。

正是这个“使用哪个环境来求值函数体”的差异,导致了不同的结果:

  • 动态作用域使用了函数调用时最新的绑定(x = 2),因此返回 2
  • 词法作用域使用了函数定义时存在的旧绑定(x = 1),因此返回 1

实现词法作用域:环境“时间旅行”

OCaml的语义要求实现词法作用域。这意味着在函数被调用时,我们不能继续使用新的环境,而必须“回到过去”,使用函数定义时的那个旧环境。

从实现角度看,这要求函数值不能仅仅是一个代码块(如 fun x -> e),还必须携带闭合其定义时的环境。这种将函数代码与其定义环境打包在一起的结构,称为闭包

因此,正确的语义中,求值匿名函数 fun x -> e 时,产生的值不是一个简单的函数标记,而是一个闭包,记作:(x, e, env)。其中 env 就是函数定义时的环境。

相应地,函数应用 e1 e2 的求值规则修正为:

  1. 求值 e1 得到一个闭包 (x, e_body, env_def)
  2. 求值 e2 得到实参值 v_arg
  3. 在环境 env_def 的基础上,扩展绑定 xv_arg,形成新环境 env_new
  4. 在环境 env_new 中求值函数体 e_body,得到最终结果 v

本节课总结

本节课中我们一起学习了:

  1. 为包含函数的语言定义环境模型语义的初步尝试及其缺陷。
  2. 通过一个具体示例,揭示了动态作用域与词法作用域的核心区别:函数体求值时所依据的环境是定义时的还是调用时的。
  3. OCaml采用词法作用域规则。
  4. 为了实现词法作用域,函数值必须是一个携带了其定义环境的闭包 (参数, 函数体, 定义时环境)
  5. 修正后的函数应用语义需要在闭包保存的旧环境中扩展形参绑定,再进行求值。

理解词法作用域和闭包是掌握函数式编程语言执行模型的关键一步。

180:闭包 🧠

在本节课中,我们将学习OCaml函数语义的核心概念——闭包。我们将了解闭包如何通过保存定义时的环境来实现“时间旅行”,从而确保函数在调用时能访问到正确的变量绑定。


闭包的定义

上一节我们介绍了函数的基本概念,本节中我们来看看闭包的具体实现。

闭包是一个包含两部分内容的数据结构:代码定义环境。它并非一个普通的OCaml对,而是语言实现层面的一个不可分割、无法直接通过语法访问的实体。

一个匿名函数 fun x -> e 在环境 env 中被求值时,会大步语义地规约到一个闭包。这个闭包保存了函数代码 fun x -> e 以及函数被定义时的环境 env

我们可以将其记作一个“对”:

闭包 = (代码, 定义环境)

更形式化地,对于函数 fun x -> e 在环境 env_def 中被定义,其闭包表示为:

closure = (fun x -> e, env_def)


函数应用的求值规则

理解了闭包的定义后,我们来看看如何应用闭包进行函数调用。

为了在环境 env 中对表达式 e1 e2(即 e1 应用于 e2)进行求值,需要遵循以下步骤:

以下是具体的求值步骤:

  1. 首先,在当前环境 env 中对 e1 进行求值。由于程序已通过类型检查,e1 必须是一个函数,因此求值结果应为一个闭包。假设该闭包为 (fun x -> e0, env_def),其中 env_def 是该函数的定义环境。
  2. 接着,仍在当前环境 env 中对参数表达式 e2 进行求值,得到一个值 v2
  3. 最后,进行关键的“时间旅行”:在定义环境 env_def 的基础上,将形参 x 绑定到实参值 v2,从而扩展出一个新环境 env_def[x -> v2]。然后在这个新环境中对函数体 e0 进行求值,得到的结果 v 就是整个函数应用表达式的最终值。

这个过程可以用以下推导式表示:

env ⊢ e1 ⇓ (fun x -> e0, env_def)    env ⊢ e2 ⇓ v2    env_def[x -> v2] ⊢ e0 ⇓ v
———————————————————————————————————————————————————————————————————————————————
                     env ⊢ e1 e2 ⇓ v


示例分析

让我们通过几个具体例子来巩固对闭包求值过程的理解。

示例一:基础闭包

考虑以下代码:

let x = 0 in
(fun y -> x + y) 1

以下是其求值过程的逐步分析:

  1. 首先,在环境中将 x 绑定为 0
  2. 接着,对函数应用 (fun y -> x + y) 1 进行求值:
    • 求值函数部分 fun y -> x + y:这会创建一个闭包,其代码是 fun y -> x + y,定义环境是 {x: 0}
    • 求值参数部分 1:得到值 1
    • 应用函数:使用闭包中的定义环境 {x: 0},将形参 y 绑定为实参值 1,得到新环境 {x: 0, y: 1}。在此环境中求值函数体 x + y,即 0 + 1,最终得到结果 1

示例二:环境捕获

现在,让我们回顾之前可能引发困惑的代码,看看闭包如何解决作用域问题:

let x = 1 in        (* 行 1 *)
let f = fun y -> x in (* 行 2: f 的闭包捕获了 {x: 1} *)
let x = 2 in        (* 行 3 *)
f 0                  (* 行 4 *)

以下是关键步骤分析:

  1. 执行到第4行时,动态环境包含:x -> 2(来自第3行)和 f -> closure(fun y -> x, {x: 1})(来自第2行)。
  2. f 0 求值:
    • f 求值为其闭包 (fun y -> x, {x: 1})
    • 参数 0 求值为 0
    • 应用函数:在闭包保存的定义环境 {x: 1} 中,将 y 绑定为 0,得到环境 {x: 1, y: 0}。在此环境中求值函数体 x,查找到 x 的值为 1
  3. 因此,f 0 的最终结果是 1,正确反映了函数 f 定义时 x 的绑定值(1),而非调用时 x 的值(2)。这正是静态作用域词法作用域的行为。

总结

本节课中我们一起学习了OCaml中闭包的核心机制。

闭包是一个包含函数代码及其定义时环境的数据结构,它是实现函数式语言中“词法作用域”或“静态作用域”的基石。在函数应用时,闭包机制通过“回到”函数定义时的环境进行求值,确保了变量引用的正确性,无论函数在何处被调用。这使得函数成为真正的“一等公民”,可以携带其定义上下文自由传递。

OCaml编程:9.28:词法作用域与动态作用域 📚

在本节课中,我们将学习编程语言中两个核心概念:词法作用域动态作用域。我们将探讨它们的区别、各自的优缺点,以及为什么现代语言设计普遍选择词法作用域。


概述

我们已经了解了词法作用域和动态作用域。如果你学习过Python、Java或几乎所有其他编程语言,那么你一直以来被教授的都是词法作用域。

经过数十年的编程语言设计实践,业界共识是词法作用域是正确的选择

其主要原因在于它不会导致意外的结果。

词法作用域的优势:避免意外

程序员可以更改局部变量的名称,而不会改变函数的意义。

回想一下我们之前的例子,程序的意义高度依赖于我们如何处理变量 x 的名称。

在动态作用域下,如果我们将 x 的第二个绑定改为 let z = ... 或其他变量名,那么它将改变这个程序的意义,改变其求值结果。

但在词法作用域下,我们可以自由地将变量 x 的第二个绑定更改为我们想要的任何其他变量名,而这不会影响计算的结果。

这就是我所说的“词法作用域导致更少意外”的含义。你可以更改变量名,而不会遇到那种意外情况。

动态作用域的现状与例外

然而,动态作用域在某些情况下仍然有用。

因此,一些语言默认使用它或提供某种形式的支持,例如LaTeX、Perl、Racket等。不过,如今大多数语言都不再使用动态作用域。

有一个重大的例外,有趣的是,它被称为异常。如果你思考一下异常,它们在某种程度上类似于动态作用域。

抛出(raise或throw)一个异常实际上会将控制权转移到最近可以捕获该异常的地方。

这有点类似于动态作用域如何使用变量的最近绑定。

尽管如此,我发现思考动态作用域非常困难,因为它不是大多数语言的工作方式。

实践与探索

在教材中,你会找到一个解释器,你可以用它来试验表达式,并在动态作用域和词法作用域之间切换,以观察它们如何求值。


总结

本节课中,我们一起学习了:

  1. 词法作用域是静态的,根据代码的书写结构确定变量绑定,是现代语言的主流选择。
  2. 动态作用域是动态的,根据运行时调用栈确定变量绑定,容易导致意外行为。
  3. 词法作用域的主要优势在于可预测性可维护性,允许安全地重命名变量。
  4. 动态作用域在某些特定领域(如配置、异常处理机制)仍有其价值,但已非主流。
  5. 异常处理机制在控制流转移方式上与动态作用域有相似之处。

理解这两种作用域模型,有助于你更深入地把握程序的行为和不同编程语言的设计哲学。

182:剩余核心OCaml环境模型

在本节课中,我们将学习如何将大步环境模型扩展到完整的核心OCaml语言,具体方法是引入模式匹配。我们将看到,现在的值包含了函数闭包,而闭包本身又包含了环境。


扩展环境模型以包含对

上一节我们介绍了环境模型的基本概念。本节中,我们来看看如何将模型扩展到处理

我们的值现在包含了函数闭包,这些闭包本身又包含了环境。这类似于之前处理引用和位置时的情况,即存在一种无法用语言语法直接写出的值。这再次说明,并非所有值本身都是表达式。

对(Pairs)的语义非常直接。下图展示了其求值规则:

图中黑色部分与使用环境模型之前相同,新增的橙色部分表示:使用当前的动态环境来求值对中的每个分量

对于 firstsecond 操作,规则是类似的,同样是利用动态环境来求值。


扩展环境模型以包含构造器和模式匹配

理解了对的求值后,我们继续探讨构造器和模式匹配的规则。

对于构造器(例如 LeftRight),规则与对类似,我们同样将动态环境传递下去,用于求值构造器内的表达式。

模式匹配的规则与之前几乎完全相同,但有一个关键区别。以下是其求值规则:

当求值一个分支及其表达式(E1E2)时,我们需要扩展动态环境,将模式变量映射到被匹配表达式的值。

具体来说,如果我们匹配表达式 E,且 E 求值为值 Left V,那么在求值对应的分支 E1 时,我们将扩展环境,将模式变量 X1 映射到值 V。如果匹配的是 Right 模式,规则是对称的。


实现环境模型解释器

您可能会好奇,核心OCaml的大步环境模型解释器代码在哪里。

答案是:将由您来编写

在过去的至少五年(甚至二十年)里,CS3110课程始终包含一个解释器作业。在该作业中,学生需要实现OCaml(或一个密切相关的函数式语言)的一个实质性片段。预计今年也不例外。


总结

本节课中,我们一起学习了如何将大步环境模型扩展到完整的核心OCaml。我们引入了模式匹配的求值规则,并理解了函数闭包作为值的一部分如何携带环境。最后,我们了解到实现这样一个解释器是课程实践环节的重要组成部分。

OCaml编程:9.30:所有模型回顾 🧠

在本节课中,我们将回顾之前学习过的多种程序求值模型,梳理它们之间的关系与区别,帮助初学者建立一个清晰的理解框架。


模型概览

我们目前已经学习了许多求值关系和模型。为了清晰地掌握它们,让我们进行一个系统的回顾。

小步与大步求值模型

上一节我们介绍了求值的基本概念,本节中我们来看看两种主要的求值方式:小步模型和大步模型。

在小步求值模型中,我们定义了一个单步求值关系,记作 e -> e'。这表示表达式 e 经过恰好一步求值,变为表达式 e',不多也不少。

我们还定义了一个多步求值关系,记作 e ->* e'。这是单步求值关系的自反传递闭包,意味着表达式 e 经过零步或多步求值后,变为表达式 e'。我们的最终目标是求值到一个值 v,之后无法再进行任何求值步骤。

为了表示多步求值的起点和终点,我们引入了大步求值关系,记作 e => v。这个关系表示表达式 e 直接求值为值 v,而不展示中间的求值步骤。

需要记住的是,这两种模型在本质上是一致的。具体来说,e => v 当且仅当 e ->* v

替换模型与环境模型

在理解了求值的步进方式后,我们来看看实现变量绑定的两种不同模型:替换模型和环境模型。

在替换模型中,我们通过替换操作来实现变量。这个模型的难点在于正确定义替换,特别是避免捕获的替换

在环境模型中,我们通过一个动态环境来实现变量。这可以看作一种“惰性”的替换。这个模型的难点在于理解闭包

有趣的是,在这两种模型中,最复杂的部分都是如何实现函数,无论是如何在函数体内部进行替换,还是如何使用正确的环境来求值函数体。

模型的组合与正交性

这些模型是正交的。这意味着你可以自由组合它们。

以下是可能的组合方式:

  • 你可以拥有一个小步替换模型。
  • 你也可以拥有一个大步替换模型。
  • 同样,你也可以拥有一个大步环境模型。

课程中,我首先展示了小步替换模型,然后是大步替换模型,最后是大步环境模型。至于小步环境模型,其中并无特别需要展示的新颖之处,因此略过。

不同语言中的应用

我们已经将这些模型应用到了不同的语言中。

我们研究了 Simple 语言,它像一个带有 let 表达式和 if 表达式的计算器。对于这个语言,我提供了替换模型的数学定义和代码,以及环境模型的数学定义。

我们还研究了 Core OCaml 语言。对于它,我同样提供了上述所有三种模型的数学定义。

在教材中,你还会看到对另一种语言的讨论,称为 Lambda 演算。Lambda 演算只包含函数、应用和变量这三种语法形式。教材提供了其环境模型的数学定义和代码,包括一个允许你在词法作用域和动态作用域之间切换的解释器。我希望你有机会尝试使用那个解释器。

顺便一提,Lambda 演算非常强大,你可以用它来模拟所有的计算过程。不过,这属于另一门课程的范畴了。


总结

本节课中我们一起学习了程序求值模型的回顾。我们梳理了小步与大步求值模型的区别与联系,对比了替换模型与环境模型在实现变量绑定上的不同机制,并理解了这些模型是正交的,可以应用于不同的编程语言。掌握这些模型有助于深入理解编程语言的运行原理。

184:类型检查基础 🧠

在本节课中,我们将要学习类型检查的基本概念。类型检查是一种在程序运行前分析代码、发现潜在类型错误的技术。通过这种方式,我们可以避免许多运行时错误,提升程序的健壮性。

上一节我们介绍了解释器中的运行时错误,本节中我们来看看如何通过类型检查来预防这些错误。

类型检查的目标 🎯

当我们为简单的语言构建大步替换模型解释器时,有三个地方可能发生运行时错误。

  1. 一个单独的变量无法求值,因为它应该已经被替换掉了。
  2. 如果一个二元操作符存在类型错误,则无法求值。例如,+ 的一边是整数 5,另一边是布尔值 false
  3. 一个 if 表达式要求其条件守卫是布尔值,而不是整数。

在解释器代码中,这些情况都对应着我们在运行时抛出的错误。

  • 当我们遇到一个未被替换的变量并试图求值时,会抛出“未绑定变量”错误。
  • 当我们遇到一个二元操作符,其两边的参数类型不正确时(例如 add 需要两个整数,multless than equal to 同理),会抛出错误。
  • 对于 if 表达式,如果守卫条件最终求值结果不是 truefalse,我们也会抛出异常。

类型检查的目标就是预防此类错误。我们希望程序永远不要发生这类运行时错误。毕竟,这些错误不仅会困扰程序员,还可能最终出现在我们的终端用户或软件客户面前。我们不希望他们看到这类错误,我们更愿意从一开始就预防它们。😡 这就是类型检查能做的。类型检查可以帮助我们预防所有这类在编译时真正可检测到的错误,使它们永远不会在运行时发生。

因此,类型检查器会分析一个程序。😡 如果程序包含任何可检测的类型错误,类型检查器将拒绝该程序。如果这些错误在编译时被检测到,它将绝不允许该程序运行。😡

静态环境 📚

为了实现这个目标,类型检查器需要一种我们之前未曾见过的新环境。我之前提到过,我们已经有了动态环境,它将标识符映射到值。

现在,为了进行类型检查,我们将拥有一个静态环境。这将是一个从标识符到类型的映射。所以,如果 x 在运行时将是 42,那么在编译时,我们可能知道 x 的类型是 int

你可以将静态环境视为最终动态环境的一种近似,或者更准确地说,是一种抽象。😡 我们抽象掉实际的值 42,只是说我们知道它将是某个 int,只是不知道是哪一个。

静态环境也像动态环境一样具有作用域。例如,在将 42 绑定到 x 之后的内层作用域中,在编译时我们知道在那个作用域中,x 将具有 int 类型。然后,在 y 被绑定到 3110 的内层作用域中,我们知道 y 也将具有 int 类型。

因此,静态环境(也称为类型上下文)为我们提供了在编译时变量类型的作用域概念。😡

类型判断关系 📐

为了精确地数学化表达类型检查,我们需要一个新的关系。😡 我们之前有过很多关系,但大多是关于求值的:我们有大步求值关系、小步求值关系、替换和环境模型关系。😊

现在,我们将有一个类型检查关系,也称为类型判断,即对表达式类型做出判断。

它是一个三元关系,写作:Γ ⊢ e : t

  • Γ 代表静态环境。
  • 符号(读作“推出”或“证明”)是数理逻辑中的符号。
  • e : t 表示表达式 e 具有类型 t

所以,e : t 这部分是完全正常的,你在 OCaml 中已经习惯了。但静态环境通常对我们隐藏,在 OCaml 中我们通常看不到它,也没有办法真正打印出静态环境是什么。然而在这里,我们将把它写下来,因为我们有这个描述类型判断的数学关系。

以下是该类型关系的几个例子:

  1. 假设我们试图对 x + 2 进行类型检查,并且我们在一个已经知道 x 具有 int 类型的静态环境中进行。那么,在该环境中,表达式 x + 2 具有 int 类型是成立的。你和我都知道我们可以通过观察得出这个结论,因为我们很了解 OCaml。很快,我们将写下实际的数学规则,使任何人都能推导出这一点。

  2. 假设我们在一个不同的环境中,如这里的第二个例子。如果 x 具有 bool 类型,那并不能推出 x + 2 具有 int 类型。事实上,在 OCaml 中尝试将一个布尔值和一个整数相加是毫无意义的。

  3. 同样,如果 x 具有 int 类型,那么 x + 2 不具有 bool 类型,这也是无意义的。

  4. 作为最后一个例子,在空的静态环境中(没有标识符被绑定到任何类型),不能推出 x 具有 int 类型。事实上,它根本不能推出 x 有任何类型,因为 x 没有在静态环境中绑定。

总结 📝

本节课中我们一起学习了类型检查的基础知识。我们了解到类型检查的目标是在编译时预防运行时类型错误,从而提升程序可靠性。为了实现这一目标,我们引入了静态环境(或类型上下文)的概念,它是一个从变量名到类型的映射,用于在编译时跟踪变量的类型信息。最后,我们介绍了用于形式化描述类型检查过程的类型判断关系 Γ ⊢ e : t,它构成了类型系统推理的数学基础。在接下来的课程中,我们将深入探讨构成这些判断的具体规则。

185:SimPL类型系统 🧠

在本节课中,我们将学习一种名为“类型化SimPL”的语言,并深入探讨其类型系统的规则。我们将看到,通过为变量和表达式指定类型,编译器可以在程序运行前发现潜在的错误,从而确保程序的正确性。


语言概述

我们将在一个名为“类型化SimPL”的语言背景下进行讨论。这个语言几乎与原始的SimPL语言完全相同,唯一的区别是我在其中加入了一些类型。

因此,我现在要求程序员在let绑定中写下冒号和类型T。这里没有类型推断,你必须明确写出类型。我们稍后会再讨论类型推断。这个语言中只有两种类型:int(整数)和bool(布尔值)。

值与变量的类型检查

值和变量的类型检查相当简单。无论静态环境(我们称之为Γ)是什么,它总是能表明一个整数常量具有int类型,而一个布尔常量具有bool类型。

此外,变量名的类型将是它在那个环境中被绑定的类型。当然,如果变量名在环境中没有被绑定,那么就无法查找它,因此如果变量不在静态环境中,我们就永远无法推断出该变量具有某个类型。

二元运算符的类型检查

所有二元运算符的类型检查规则看起来都大致相同。对于每一个运算符,我们都会递归地检查其子表达式E1E2

我们确定在静态环境中,每个子表达式都具有int类型。基于此,我们能够为每个二元运算符推断出适当的类型。

以下是具体规则:

  • 对于加法(+)和乘法(*),整个表达式的类型是int
  • 对于小于等于(<=),整个表达式的类型是bool

请注意,我们每次递归检查这些子表达式时,都使用相同的静态环境,它并没有改变。原因是,无论对+运算符左侧的E1进行何种求值,都不会改变当我们去求值右侧子表达式E2时在作用域内的变量。从语法上讲,变量就是以这种方式被限定在作用域内的。

条件表达式的类型检查

对于一个if表达式,我们需要检查三个前提条件,即这条规则的三个前提。

我们需要检查:

  1. E1具有bool类型。
  2. E2具有某个类型T
  3. E3具有相同的类型T

因此,整个if表达式就具有类型T,因为这是两个分支的类型。

Let表达式的类型检查

最后,let表达式是最复杂的,因为它们的语法结构中实际出现了类型。

如果我们有 let x : T1 = E1 in E2,那么我们需要做的第一件事是在相同的静态环境中对E1进行类型检查,以确保它确实具有类型T1。我们在这里进行双重检查,以确认程序员写下的类型注解是正确的。

然后,我们递归地对E2进行类型检查。但是,是在一个新的静态环境中进行。这是我们在所有这些规则中第一次改变环境。

在这里,我们将x在静态环境中绑定到程序员为其指定的类型T1。在这个扩展后的环境中,我们将继续对E2进行类型检查。如果它具有类型T2,那么这就是整个let表达式的类型。

请注意,let的类型检查规则与在大步环境语义中let的求值规则之间存在非常强的相似性。在两者中,我们都扩展了环境以记录关于标识符的信息。在类型检查中,我们只记录类型;在求值中,我们记录实际的值。当然,那个类型将是值的一个近似或抽象。


总结

本节课中,我们一起学习了“类型化SimPL”语言的基本类型系统。我们了解了如何对常量、变量、二元运算符、条件表达式和let绑定进行类型检查。核心在于,类型系统通过静态环境(Γ)跟踪变量的类型,并利用一套规则递归地验证整个程序的类型正确性,从而在运行前保障程序的安全性。let表达式的规则尤其体现了类型环境扩展与值环境扩展之间的对应关系。

OCaml编程|CS3110:OCaml Programming: Correct + Efficient + Beautiful:P186:为SimPL解释器添加类型系统

在本节课程中,我们将学习如何为SimPL语言实现一个类型检查器。我们将从修改抽象语法树开始,逐步添加对类型注解的支持,并更新解析器、词法分析器以及解释器的相关部分。


修改抽象语法树

首先,我们需要在OCaml中定义一个类型来表示SimPL的类型。为了避免与OCaml的关键字type冲突,我们使用typ作为类型名。

type typ = TInt | TBool

这里,TIntTBool是构造器,分别代表整数类型和布尔类型。我们使用T前缀是为了与表达式中已有的IntBool构造器区分开。

接下来,我们需要修改let表达式的结构,使其包含一个类型注解。let表达式现在将携带一个额外的类型组件。

type expr =
  | Let of string * typ * expr * expr
  | ... (* 其他表达式构造器 *)

这样,每个let绑定都明确指定了其变量的类型。


更新词法分析器与解析器

为了在源代码中解析类型注解,我们需要扩展词法分析器和解析器。

在词法分析器中,我们需要添加三个新的词法单元:

  • COLON:用于表示类型注解前的冒号(:)。
  • INT_TYPE:对应关键字int
  • BOOL_TYPE:对应关键字bool

在解析器中,我们需要修改let表达式的产生式规则,使其包含冒号和类型声明。同时,添加一个typ产生式,用于解析intbool类型,并返回对应的AST节点(TIntTBool)。

完成这些修改后,我们可以尝试编译代码。此时,编译器可能会报错,提示let表达式缺少类型组件。这正是我们接下来需要修复的问题。


调整解释器

现在,我们需要更新解释器的两个核心函数:替换函数和求值函数,以处理新增的类型注解。

在替换函数中,类型本身不包含需要替换的变量,因此我们只需原样传递类型T即可。

let rec subst e x v = match e with
  | Let (y, t, e1, e2) ->
      Let (y, t, subst e1 x v, if x = y then e2 else subst e2 x v)
  | ... (* 其他分支 *)

在求值函数中,运行时我们不再关心类型注解,因为类型检查的目的在求值前已经完成。因此,我们可以使用下划线_来忽略let表达式中的类型组件。

let rec eval = function
  | Let (x, _, e1, e2) ->
      let v1 = eval e1 in
      eval (subst e2 x v1)
  | ... (* 其他分支 *)

通过这种方式,我们确保了类型信息在编译时被检查,但在运行时被安全地忽略。


总结

在本节课中,我们一起学习了如何为SimPL解释器添加一个简单的类型系统。我们首先定义了表示类型的OCaml变体typ,然后修改了AST,使let表达式能够携带类型注解。接着,我们更新了词法分析器和解析器以识别新的语法。最后,我们调整了替换和求值函数,确保类型信息在运行时被正确处理(即被忽略)。这些步骤构成了为动态语言添加静态类型检查的基础。

187:SimPL类型检查器(第一部分) 🧠

在本节课中,我们将学习如何为SimPL语言实现一个类型检查器。我们将看到类型检查如何作为解析和求值之间的关键环节,确保程序在运行前是类型安全的。

概述

类型检查发生在解析和求值之间。解析后我们得到抽象语法树(AST),在求值之前,我们需要对这个AST进行类型检查。因此,我们将在解释器的执行流程中添加类型检查步骤。

实现类型检查函数

上一节我们讨论了类型检查在流程中的位置,本节中我们来看看如何具体实现类型检查函数。

首先,我们定义类型检查函数 type_check 的规范。如果表达式 e 类型检查成功,该函数将返回 e 本身。这意味着在空的静态环境中,必须存在一个类型 t,使得 e 具有类型 t。如果 e 无法通过类型检查,函数将引发一个失败异常。

为了实现 type_check,我们需要实现一个名为 type_of 的函数,它定义了类型关系。

type_of 函数的规范是:type_of env e 返回在环境 env 中表达式 e 的类型 t。你可以将函数的输入视为类型判断中冒号左侧的所有内容,输出视为冒号右侧的所有内容。

type_check 中,我们需要在空环境中调用 type_of 函数。对于环境的实现,我们选择使用关联列表,因为它简单直接。空列表即代表空环境。

根据规范,type_check 应该返回表达式 e 本身,而不是其类型。因此,我们计算表达式的类型,如果成功,则忽略返回的类型值并返回原表达式。如果 type_of 无法找到类型,它也会引发异常。从 type_check 的角度看,我们只关心 type_of 是否成功执行而不引发异常。

实现 type_of 函数

现在,让我们开始实现 type_of 函数。我们将逐步实现所有的类型检查规则。

首先处理布尔常量。如果表达式 e 是一个布尔常量(truefalse``),那么它的类型必须是 TBool`。这是由我们的类型规则规定的。

match e with
| Bool _ -> TBool

接下来,我们需要实现其他类型规则。让我们一次处理几个。

我们知道整数常量的类型是 TInt。当我们遇到一个变量名时,我们应该在静态环境中查找它,并返回找到的类型。如果环境中没有该变量的绑定,则出现未绑定变量错误,这属于类型检查错误。

我们将查找变量的逻辑提取到一个辅助函数中。我们使用标准库中的函数来实现这个查找函数,如果变量名未绑定,则引发未绑定变量错误。

在解释器中,现在有两个地方可能引发未绑定变量错误:一个是在类型检查期间,另一个是在求值期间。类型检查将拒绝任何可能出现未绑定变量错误的程序。然而,在求值器的模式匹配中,我们仍然需要保留处理 Var 构造函数的分支,以满足OCaml的模式匹配穷尽性检查。我们知道由于类型检查的存在,运行时永远不会到达这个分支,但它必须存在。

为了区分运行时错误和编译时(类型检查)错误,我引入了两个新的异常类型以及两个方便的函数来分别引发它们。这样可以使错误信息更清晰。

处理二元操作符

现在,让我们回到实现 type_of 函数。我知道实现二元操作符的类型检查规则需要不止一行代码,因此我提前为其提取了一个相互递归的辅助函数,就像之前实现求值器时做的那样。

为了类型检查一个二元操作符,我们需要匹配操作符本身,并递归地类型检查子表达式 e1e2,以判断该操作符的应用是否正确。

目前只有三个二元操作符:AddMultLeq。每个操作符都必须接受两个整数作为参数,并返回适当的类型:AddMult 返回整数,Leq 返回布尔值。如果类型不匹配,我们将引发一个预定义好的类型错误。

这个错误可能在类型检查时被捕获,确保程序不会进入求值阶段。同样,在求值器的实现中,理论上类型检查器会阻止此类错误,但为了满足OCaml的模式匹配穷尽性检查,我们仍然需要一个分支来处理,并在那里引发一个运行时错误。

以下是实现二元操作符类型检查的核心逻辑结构:

let rec type_of env e =
  match e with
  | Binop (op, e1, e2) ->
      let t1 = type_of env e1 in
      let t2 = type_of env e2 in
      (match op with
       | Add | Mult ->
           if t1 = TInt && t2 = TInt then TInt
           else raise (TypeError "operator and type mismatch")
       | Leq ->
           if t1 = TInt && t2 = TInt then TBool
           else raise (TypeError "operator and type mismatch"))
  (* ... 其他分支 ... *)

总结

本节课中我们一起学习了如何为SimPL语言构建类型检查器的第一部分。我们定义了 type_checktype_of 函数的规范,并开始实现基础类型(布尔、整数)和变量的类型检查。我们还引入了辅助函数来查找环境中的变量,并创建了特定的异常类型来区分编译时类型错误和运行时错误。最后,我们讨论了二元操作符类型检查的实现框架。在下一节中,我们将继续实现其他表达式结构的类型检查规则。

188:SimPL类型检查器实现(第二部分) 🧠

在本节课中,我们将继续学习如何为SimPL语言实现类型检查器。我们将重点实现let表达式和if表达式的类型检查规则,并最终完成整个类型检查器的构建。

上一节我们介绍了类型检查的基本框架和部分表达式的检查。本节中,我们来看看如何实现let表达式和if表达式的类型检查。


实现let表达式的类型检查

接下来,我们实现let表达式的类型检查。我们将在此处暂停,开始积累一系列辅助函数。此时,我可能需要为每个辅助函数编写规范。

如何实现let表达式的类型检查?我们之前为let类型检查设计的规则明确指出了方法:我需要检查表达式E1,确保其类型与程序员指定的类型T一致,然后将其绑定扩展到静态环境中,以便检查E2

我们将实现这个规则。好的,我们在此处暂停。我们已经检查了E1,得到了它的类型T'。然后我们比较TT',看它们是否相同。

如果相同,说明程序员的类型标注正确,代码中的类型无误。我们可以继续检查E2。我在这里引入了一个新的辅助函数,用于将名称绑定到新类型,从而扩展静态环境。

在扩展后的静态环境中,我将检查E2的类型并返回该类型。我们稍后会编写这个辅助函数。现在,让我先完成let的类型检查:如果TT'不相同,则出现类型检查错误。

这是一个新的错误类型,在之前的运行时从未出现过,因为我们之前没有类型系统。因此,我需要引入一个新的字符串来表示类型标注错误。

现在,我的类型标注错误消息已准备就绪。let表达式的类型检查几乎完成,我只需要编写那个扩展函数。当然,由于这只是一个关联列表,我可以轻松实现这个扩展函数。

我所做的只是将新的绑定添加到列表前端。我需要担心重复绑定吗?语言的语义涉及遮蔽:内部作用域中的变量绑定会遮蔽外部作用域中的同名绑定。

因此,通过将新绑定放在列表前端,我确保了新绑定会遮蔽任何先前的绑定。这样,我恰好得到了正确的语义,甚至无需担心遍历列表以移除重复绑定。

至此,let表达式的类型检查完成。现在我可以继续添加下一个语法形式。


实现if表达式的类型检查

好的,我们在此处暂停。我已经为if表达式的类型检查引入了新的辅助函数,现在只需要实现类型检查规则:即检查条件表达式的类型是否为bool,并检查两个分支的类型是否相同。

我们再次在此处暂停。

我已经实现了对条件表达式类型的检查,确保它是bool类型。如果不是bool类型,我将引发类型错误。这是另一个可能成为运行时错误或类型错误的例子。我们将通过类型检查在运行时防止此错误,但在代码下方仍需保留分支。

现在我们可以实现分支检查。

我递归地检查两个子表达式,获取它们的类型T2T3。如果这两个类型不相同,我将引发类型错误;否则,由于它们相等,我可以返回其中任意一个,这里我选择返回T2

现在我遇到了一种以前从未存在过的类型错误。这是因为在运行时,我从未同时评估thenelse分支并比较它们的类型——评估语义只评估其中一个分支。

然而,在类型检查中,我们必须同时检查两个分支。这意味着我们遇到了一种永远不会对应任何运行时错误的类型错误。好的,我现在已经添加了错误消息。至此,if表达式的类型检查实现完成。


完成类型检查器

此外,整个type_of函数的实现也已完成。这意味着我的整个解释器现在已经构建完毕。我已经为其添加了所有类型检查功能。

现在我可以运行测试套件了。当然,在实现过程中,我应该一直使用测试驱动开发来运行这些测试用例。我相信你理解如何有效地使用它。有些时候你需要使用它,有些时候可能不需要。在这里,我对自己所做的事情有信心,所以觉得没有必要使用它,但如果需要,我随时可以返回使用。

我这里有一个测试套件,基本上是我们一直用于简单解释器的相同测试套件,但现在我为其添加了一些类型检查。我的let表达式都带有类型标注,并且我在这里有一些案例来检查类型检查功能,包括:

  • 操作数类型不匹配的二元运算符
  • 具有各种类型错误的if表达式
  • 未绑定的变量

让我们运行这个测试套件。它成功了,太好了!我们已经完成了为SimPL添加类型检查的工作。😡


总结

本节课中我们一起学习了如何为SimPL语言实现完整的类型检查器。我们详细实现了let表达式和if表达式的类型检查规则,理解了类型标注、静态环境扩展以及分支类型一致性检查等核心概念。通过将类型检查集成到解释器中,我们能够在程序运行前捕获类型错误,从而构建出更健壮、更安全的程序。

189:类型安全 🛡️

在本节课中,我们将要学习类型安全的核心概念。类型检查的目标是确保运行时错误不会发生。我们将探讨如何通过“进展”和“保持”这两个性质来形式化地证明类型系统能够防止程序在求值过程中“卡住”。

概述

我们曾多次提到,类型检查的目标是确保运行时错误不会发生。尽管如此,在实现带有类型检查的解释器时,我们仍需为运行时错误提供处理分支。类型系统能够防止这类错误,这一结论是我们通过理论证明而非让OCaml编译器信服来确立的。

我们希望预防的求值错误,可以描述为求值过程“卡住”的情况。

求值“卡住”的定义

如果一个表达式 E 不是一个值,并且 E 无法执行单步求值,那么我们就说表达式 E 的求值过程“卡住”了。这里我们回到了单步求值模型来给出这个定义。

这意味着它“卡住”是因为尚未达到一个值,计算尚未完成,但却无法从当前状态继续向前推进。更精确地说,类型系统的目标是保证在求值过程中,没有任何表达式会“卡住”。

类型安全:进展与保持

这个概念有一个名称,叫做类型安全。类型安全意味着永远不会“卡住”。实际上,类型安全可以分解为两个部分,分别称为进展保持

  • 进展 性质指出,一个表达式总是可以执行一步求值,除非它已经是一个值。
  • 保持 性质指出,执行一步求值永远不会改变表达式的类型。

让我们更仔细地阐述这两个性质。

保持性质

保持性质是说:如果一个环境表明一个表达式具有类型 T,并且该表达式经过单步求值得到一个新表达式 E',那么在同一个环境中,E' 仍然具有相同的类型 T。因此,求值步骤保持了类型。

以下是保持性质的一个例子:
假设有复杂表达式 10 + 1 + 5 + 6。我们知道它可以执行一步求值,将 10 + 1 规约为 11。确实,11 + 5 + 6 继续具有 int 类型。

进展性质

进展性质是说:如果一个表达式具有类型 T,那么以下两种情况之一必然成立:

  1. E 已经是一个值。
  2. 存在另一个表达式 E',使得 E 可以单步求值为 E'

例如,10 + 1 + 5 + 6 具有 int 类型,并且确实存在一个它可以单步求值到的 E',正如我们在上一张幻灯片中看到的。

进展性质对类型错误的表达式不做任何断言。例如,在空环境中,x 不能被赋予 int 类型。但这没关系,进展性质并没有说 x 是一个值或者说它必须能执行一步求值,因为它没有正确的类型。事实上,这是一个会产生运行时错误的表达式,因此我们无法为其赋予类型是一件好事。

最后,请注意进展性质陈述中的“空环境”实际上很重要,我们不能将其扩展到任意环境。

例如,如果我们从一个确实将 x 绑定到 int 的环境开始,那么我们就能断定 x 是良类型的,因为 x 在那个环境中确实具有 int 类型。但是 x 不是一个值,并且 x 无法执行求值步骤。因此,对于进展性质而言,要求是空环境这一点至关重要。

证明类型安全

为了证明类型安全,我们可以将进展和保持性质结合起来使用。

以下是此类证明的概要:
我们的主张是:良类型的程序不会“卡住”,这就是类型安全的含义。

  1. 我们首先假设程序是良类型的。
  2. 然后我们根据程序到达一个值所需的步数进行归纳证明。
    • 基本情况:剩余步数为零。这意味着我们已经得到了一个值。既然它已经是一个值,它就不是“卡住”的,因为“卡住”意味着不是一个值且无法执行一步求值。因此,证明的这一部分完成。
    • 归纳步骤:假设剩余一步或多步求值。根据进展性质,如果一个程序是良类型的,那么它总是可以执行一步求值。因此,我们可以执行下一步。
      现在可能剩余零步或多步,但我们至少执行了一步。根据保持性质,当我们执行那一步时,得到的新表达式仍然是良类型的。
      这意味着归纳假设可以应用,因为我们有一个良类型的程序,但它距离到达一个值所需的步数少了一步。因此,根据归纳假设,这个新的良类型程序不会“卡住”。证明完成。

这不是一门我期望你们在本课程中能够掌握的证明,我只是想让你们了解此类证明的结构。如果你对了解更多感兴趣,可以学习 CS4110 课程。但这就是目标,这就是我们拥有类型系统的原因:它们确保我们的良类型程序在求值过程中不会“卡住”。

总结

本节课中我们一起学习了类型安全的核心概念。我们定义了求值“卡住”的状态,并引入了进展保持这两个关键性质来形式化类型安全。通过结合这两个性质,我们可以从理论上证明良类型的程序在求值过程中永远不会陷入“卡住”的境地,从而避免了特定的运行时错误。理解这些原理有助于我们深入认识静态类型系统为程序可靠性提供的保障。

190:Hindley-Milner类型推断 🧠

在本节课中,我们将要学习Hindley-Milner类型推断算法。我们已经学会了如何为OCaml程序进行类型检查,但那种情况需要程序员写下类型注解,然后由编译器或解释器进行检查。本节中我们来看看,如果程序员像在OCaml中允许的那样省略类型注解,我们如何推断或重建这些缺失的类型。

OCaml及相关语言使用一种由Hindley和Milner在20世纪60年代和70年代开发的类型推断算法。他们实际上是各自独立发现的,因此该算法通常以两人的名字共同命名,称为Hindley-Milner类型推断,或简称HM。HM在某种程度上更像是一个相关算法家族,而非单一特定算法,但它们都有一个共同点:它们永远不会为程序推断出错误的类型。😡

它们几乎不需要程序员的帮助来寻找类型。事实上,在足够小的语言片段中,它们完全不需要程序员的帮助。在整个庞大的OCaml语言中,存在一些角落,HM无法完美地进行类型推断,确实需要你的帮助。😡

此外,该算法通常以线性时间运行。你可能从未需要等待很长时间让OCaml推断你程序的类型,这是因为对于大多数程序来说,它确实非常高效。😡

Robin Milner因其在类型推断算法上的贡献,部分地获得了图灵奖。他于1991年因ML语言获奖,ML是第一个包含多态类型推断的语言。

那么,你如何在脑海中推断类型呢?在你学习OCaml和其语法的过程中,你也潜移默化地学会了推断类型。😡

在Java甚至Python中也是如此。作为程序员,你学会了查看程序并弄清楚类型是什么,因为没有语言会强制你写下其中每个子表达式的类型。😡

那么,你将如何推断这个程序的类型:let g x = 5 + x?也许可以暂停一下。我相信你已经知道它的类型了,但想一想,反思一下你是如何弄清楚的。

当我查看这个程序时,我首先注意到的是加号。😡 在OCaml中,这个加号将接受两个int作为输入。因此,我了解到关于其两侧操作数5x的一些信息。5显然是一个int。但我从x和加号出现的位置得知,x也必须是一个int。所以,x相对于加号出现的位置对x的类型施加了一个约束,即它必须是int

现在,整个表达式5 + x的类型呢?当我查看它时,我知道加法操作保证会输出一个int,所以现在我了解了等号右侧表达式的一个约束或事实。😡 这个函数g必须输出一个int

现在我知道了函数的输入和输出类型,因为我推断出x必须是int,并且推断出5 + x必须是int。因此,g必须是一个接受int并返回int的函数。

用这么多话来描述你的大脑现在可以快速完成的事情。接下来的挑战是,我们要将其转化为计算机可以执行的算法。

那么,Hindley-Milner如何推断类型呢?以下是其概览。

它按顺序处理每个顶层定义。😡 所以,如果你在Utop中工作,可以理解为:你输入的每个以双分号结尾的短语,在进入下一个之前都会完全完成类型推断。😡 如果你在.ml文件中工作,则意味着该文件中的每个定义,特别是每个let定义,都会按顺序处理。😡

这就是OCaml不允许你在较早的定义中使用较晚的定义的主要原因之一,除非你让它们相互递归,这意味着它们将同时进行类型推断。

对于每个定义,HM将收集一个约束系统。可以将其想象成你在高中数学中学到的代数方程组。不过,这些方程不是关于数字的,而是关于类型的,是类型之间必须成立的等式。

在收集完整个方程组(即约束集)之后,HM将求解它。类似于你可能学过的用高斯消元法求解代数方程组,HM类型推断将求解该方程组,从而推断出正在定义的表达式的类型。

我们需要一种小语言来演示HM类型推断。Simple语言实际上太简单,不适合作为示例语言,因为在Simple中,你实际上可以推断出所有类型,甚至不需要使用像HM类型推断这样高级的东西;你基本上可以猜测并检查类型必须是什么。😊

因此,为了做一些更复杂的事情,让我们在Simple的基础上加入Lambda演算。同时,我将从语言中移除let表达式。事实证明,它们是类型推断中最棘手的部分。所以,我们现在暂时省略它们,稍后再添加回来。这样我们就得到了以下语言:

e ::= n
    | i
    | b
    | if e1 then e2 else e3
    | fun x -> e
    | e1 e2

一个表达式可以是名称。😡 这是一个新的语法类别,我引入它是为了概括标识符和二元运算符。😡 所以,名称n可以是变量标识符x,也可以是二元运算符,但现在我将把它们写成前缀函数而不是中缀运算符。

n ::= x
    | (+)
    | (-)
    | (*)
    | (/)
    | (&&)
    | (||)
    | (=)
    | (<=)
    | ...

我在这里所做的,实际上是对OCaml允许我们使用中缀运算符的功能进行去语法糖化,以便在这种语言中统一处理运算符和函数。😡

名称之后,我们有整数常量、布尔常量和if表达式,这些都与Simple中相同。

i ::= ... -2 | -1 | 0 | 1 | 2 ...
b ::= true | false

我们还有Lambda演算:匿名函数和函数应用,这些和往常一样。

fun x -> e
e1 e2

这里的类型语言是:一个类型可以是类型变量,所以我将写一个单引号,然后是一个变量标识符如x来表示一个类型变量。😡

t ::= 'x
    | int
    | bool
    | t1 -> t2

或者,类型可以是intbool,或者对于任意两个类型t1t2,可以是函数类型t1 -> t2

这种小语言足以探索HM类型推断中大多数有趣的问题,而不会一开始就让人不知所措。😡

本节课中我们一起学习了Hindley-Milner类型推断算法的基本概念、其重要性以及它如何通过收集和求解类型约束方程组来工作。我们还定义了一种用于演示的简化语言,为后续深入算法细节奠定了基础。

191:类型推断关系 🧩

在本节课中,我们将学习类型推断的核心概念——类型约束和类型推断关系。我们将了解如何通过一组方程来推断表达式的类型,并理解OCaml类型检查器内部的工作原理。

类型约束

上一节我们介绍了类型推断的基本概念,本节中我们来看看什么是类型约束。

一个类型约束是任意两个类型 T1T2 之间的一个等式 T1 = T2

以下是两个类型约束的例子。

  • 在第一个例子中,我们有一个约束 α = int
  • 第二个约束是 α -> β = int -> bool -> int。这个约束稍微复杂一些。

如果我们观察第二个约束的各个部分,或许能进一步推断出所涉及的类型变量的信息。我们知道函数类型是右结合的,因此这里 α 必须等于 int,而 β 必须等于 bool -> int

用公式可以表示为:

α -> β = int -> (bool -> int)
=> α = int 且 β = bool -> int

类型推断关系

了解了类型约束后,我们将把之前学过的类型检查关系扩展为类型推断关系

我们通过在末尾添加一个新的部分来实现这一点,这个部分写作 ⊢ C,其中 C 是一个约束集合,即一组关于类型的等式。

用公式描述这个关系:

Γ ⊢ e : t ⊢ C

这个关系的第一部分看起来和我们之前见过的类型检查关系完全一样:在一个静态环境 Γ 中,表达式 e 具有类型 t。中间部分 Γ ⊢ e : t 正是 utop 展示给我们的内容:你输入一个表达式,它会返回推断出的类型。

然而,在 符号两侧的部分,是 utop 不会显示给你的内容。左侧有一个静态环境 Γ,OCaml 在内部维护它但不会打印出来。右侧有一个约束集合 C,你同样看不到它们。

将关系视为算法

接下来,我们将这个关系视为一个算法来思考。

它是一个接收输入并产生输出的算法。

  • 输入:算法接收一个静态环境 Γ 和一个表达式 e。我们想要在静态环境 Γ 中推断表达式 e 的类型。
  • 输出:算法的输出是推断出的类型 t,以及关于该类型的一组约束 C

当然,我们最终需要求解这组约束,才能完全重构出具体的类型。但目前,我们暂时先不进行求解,只停留在此处。


本节课中我们一起学习了类型约束的定义和示例,理解了类型推断关系 Γ ⊢ e : t ⊢ C 的构成,并学会了如何将类型推断过程视为一个接收环境与表达式、输出类型与约束集合的算法。这是理解OCaml自动类型推断机制的重要一步。

192:常量与名称的类型推断 🧠

在本节课中,我们将学习类型推断关系中最基础的两个部分:常量(如整数和布尔值)和名称(如变量和运算符)的类型推断规则。我们将通过简单的例子来理解这些规则如何工作。


上一节我们介绍了类型推断的基本概念,本节中我们来看看如何为常量和名称进行类型推断。

常量的类型推断

常量的类型推断规则非常简单。以下是具体规则:

  • 整数常量:一个整数常量 i 总是具有 int 类型,并且不产生任何类型约束。
  • 布尔常量:一个布尔常量 b 总是具有 bool 类型,并且不产生任何类型约束。

让我们来看几个例子:

在空环境 Γ = {} 中:

  • 整数常量 3110 具有类型 int
  • 它产生的约束集合为空集 C = {}

(这里用空花括号 {} 同时表示空环境和空约束集合。环境是一组绑定的集合,约束是一组类型等式的集合。)

同理,在空环境 Γ = {} 中:

  • 布尔常量 true 具有类型 bool
  • 它产生的约束集合为空集 C = {}

名称的类型推断

名称的类型推断规则同样直接。以下是具体规则:

在静态环境 Γ 中,一个名称 n 的类型就是环境 Γ 为它指定的类型,并且不产生任何约束。

当然,如果该名称 n 在环境 Γ 中没有绑定,那么类型推断在此处就会失败,因为无法为 n 找到对应的类型。

让我们通过例子来理解:

例子 1:名称在环境中
假设我们有一个静态环境 Γ,它将 x 绑定到类型 int
在这个环境中,推断名称 x 的类型:

  • 结果是类型 int
  • 产生的约束集合为空集 C = {}

例子 2:名称不在环境中
假设我们在空环境 Γ = {} 中尝试推断 x 的类型。
由于 x 不在环境中,推断失败。我们无法推断出任何类型。

例子 3:内置运算符
我们如何推断 + 运算符的类型?
我们编写的每个程序都应该从一个初始的静态环境开始,这个环境包含了内置布尔运算符的类型。
因此,初始环境 Γ 中应该始终包含:+ 的类型是 int -> int -> int
同样,* 也应有相同的类型,<= 应有类似的类型。

有了这个环境,名称推断规则就能告诉我们如何推断 + 的类型:直接在环境中查找。
所以,+ 必须具有类型 int -> int -> int,并且不产生任何约束。


初始环境中的内置绑定

以下是进行类型推断时,每个初始静态环境中都应包含的三个核心绑定:

Γ = {
    (+) : int -> int -> int,
    ( * ) : int -> int -> int,
    (<=) : int -> int -> bool
}

这些绑定确保了语言中的基本算术和比较运算符具有正确的类型。


本节课中我们一起学习了类型推断的基础规则。我们了解到:

  1. 常量(整数和布尔值)具有固定的、预定义的类型(intbool),且不产生约束。
  2. 名称(变量、运算符)的类型通过查询当前静态环境 Γ 获得。如果名称未在环境中定义,则类型推断失败。
  3. 程序的初始环境必须包含内置运算符(如 +, *, <=)的类型绑定,这是进行后续复杂表达式推断的基石。

理解这些简单案例是掌握更复杂的函数应用、let 表达式和条件表达式类型推断规则的关键第一步。

193:if表达式的类型推断 🧠

概述

在本节中,我们将学习OCaml中if表达式的类型推断过程。这是类型推断开始变得复杂的第一个地方,因为它涉及多个子表达式以及它们之间的类型约束。

if表达式的类型推断规则

上一节我们介绍了类型推断的基本概念,本节中我们来看看如何将其应用于if表达式。

我们将在静态环境M中推断if E1 then E2 else E3的类型。

我们将推断出一个类型τ(读作“tau”),并且会涉及一系列约束。这是第一次真正生成约束。

让我们逐步分析这个过程。

第一步:创建新的类型变量

首先,τ应该是一个新鲜的类型变量。所谓“新鲜”,意味着它在程序的类型推断过程中从未被使用过,因此是全新的。

if表达式的类型将被推断为这个新的类型变量τ。这是因为仅从语法上看,在不深入分析E1E2E3的情况下,我们还不知道这个if表达式的类型应该是什么。

当然,你我都知道,如果深入分析E2E3,我们就能推断出来,因为if表达式的类型就是其then分支和else分支的类型。但从算法角度,我们还不能这样做。

第二步:推断子表达式的类型

接下来,我们将使用静态环境M,对三个子表达式分别进行类型推断。

我们将推断E1的类型,它将是某个类型T1。我们无法控制这个类型会是什么。类型推断算法可能返回bool,可能返回一个类型变量,甚至可能返回更复杂的东西。我们只知道它是一个类型,所以将其记为T1

E2E3也是如此。它们各自都会独立地生成自己的一组约束,因为其内部可能嵌套了代码。

让这些约束分别为C1C2C3

第三步:整合约束

现在,为了整合所有信息,我们知道这里出现的类型之间必须满足一些关系。这些关系被记录在额外的约束集C中。

集合C将包含:

  • T1 = bool,因为我们需要将守卫(guard)的类型约束为布尔型。
  • τ = T2τ = T3,因为在if表达式中,then分支和else分支的类型必须相同。

因此,类型推断返回τ作为if表达式的类型,同时返回所有来自子表达式的约束C1C2C3,并额外加上对守卫类型、then分支类型和else分支类型的这三个新约束。

用公式表示,推断结果如下:

type: τ
constraints: C1 ∪ C2 ∪ C3 ∪ {T1 = bool, τ = T2, τ = T3}

示例分析

让我们尝试一个推断if表达式类型的例子。

假设我们要推断 if true then 0 else 1 的类型。

第一步:创建类型变量

首先,我需要创建一个新鲜的类型变量。例如,我使用α作为之前未在此处使用过的类型变量。

因此,我将返回α作为这个if表达式的类型。当然,还会附带一些约束,我需要找出这些约束是什么。

第二步:推断子表达式类型

为此,我需要继续推断守卫的类型。如何推断呢?守卫是一个布尔常量,所以我们使用之前介绍的常量规则,该规则给出类型bool且不生成任何约束。

我还需要推断then分支和else分支的类型。

  • then分支是常量0,其类型为int,不生成约束。
  • else分支是常量1,其类型也为int,不生成约束。

第三步:生成并整合约束

最后,我需要形成由if表达式本身添加在之前所有约束之上的约束集。

我的约束集将是:

  • bool = bool(来自守卫类型T1必须等于bool的规则)。
  • α = int(来自τ必须等于then分支类型T2的规则)。
  • α = int(来自τ必须等于else分支类型T3的规则)。

现在,我知道了这个整体表达式需要满足的约束集。我需要包含所有来自子表达式的约束,但那些都是空集,所以不必再写。然后我有上面定义的集合C,即{bool = bool, α = int, α = int}

当然,我可以简化它。但真正有趣的是α = int。如果我想要在纸上稍微简化一下,可以将其写为我的约束集。

在算法实现中,你可能会也可能不会注意到这里有重复项,或者出现了平凡的恒等式。你可以编写额外的代码来稍微简化这些内容。

请注意,在完成if表达式的推断后,我得到了一个类型α和关于该类型的约束。你和我可以看着它说:“哦,这意味着整个if表达式的类型是int。”

但请记住,在将其实现为算法时,我们最终可能会得到一个需要解决的大型约束集,可能因为数量太多而人脑难以解决。我们将在后面讨论如何解决这个约束集,并将其与推断出的类型α结合起来。

总结

本节课中,我们一起学习了if表达式的类型推断过程。关键点在于:为整个表达式引入一个新鲜的类型变量τ,分别推断其三个子表达式的类型和约束,然后添加三个核心约束——守卫表达式类型必须为bool,且thenelse分支的类型都必须与τ相等。这个过程是构建OCaml强大类型系统的基础步骤之一。

194:匿名函数的类型推断 🧠

在本节中,我们将学习如何为匿名函数进行类型推断。上一节我们介绍了if表达式的类型推断,本节中我们来看看如何处理fun x -> E这样的匿名函数。

概述

匿名函数的类型推断过程,核心在于为函数的参数引入一个新的类型变量,然后在扩展了参数类型绑定的环境中,推断函数体E的类型。最终,函数的类型是参数类型 -> 函数体类型,并附带从函数体推断中产生的所有约束。

推断过程详解

以下是匿名函数类型推断的算法步骤:

  1. 为参数引入新类型变量:为匿名函数的参数x引入一个全新的类型变量τ1。这代表我们尚不知道x的具体类型。
  2. 在扩展环境中推断函数体:在原有环境Γ的基础上,将x绑定到类型τ1,形成新环境Γ, x:τ1。然后在这个新环境中推断函数体E的类型T2,并得到一组约束C
  3. 确定函数类型:整个匿名函数的类型即为τ1 -> T2
  4. 收集约束:函数推断过程返回的约束集,就是推断函数体时产生的约束集C

用伪代码描述如下:

infer(Γ, fun x -> E) =
    let τ1 = fresh()          // 为参数x生成新类型变量
    let (T2, C) = infer((Γ, x:τ1), E) // 在扩展环境中推断E
    in (τ1 -> T2, C)          // 返回函数类型及约束

示例分析

让我们通过一个具体例子来理解这个过程。考虑匿名函数:

fun x -> if x then 42 else 0

其推断步骤如下:

  1. 步骤一:为参数x引入新类型变量α
  2. 步骤二:在环境[x: α]中推断函数体if x then 42 else 0的类型。
    • 推断条件表达式x的类型:从环境中查找x,得到类型α。无新约束。
    • 推断then分支42的类型:int
    • 推断else分支0的类型:int
    • 为整个if表达式引入新类型变量β,并生成约束:
      • 条件x必须是bool类型:α = bool
      • 两个分支类型必须一致:β = int (来自then分支) 且 β = int (来自else分支)
    • 因此,函数体类型T2 = β,约束集C = {α = bool, β = int}
  3. 步骤三:因此,整个匿名函数的类型是α -> β
  4. 步骤四:附带约束集C = {α = bool, β = int}

通过求解约束集,我们可以得出α必须是boolβ必须是int。因此,该函数的最终类型是bool -> int

总结

本节课中我们一起学习了匿名函数的类型推断。其核心思想是:为未知的参数类型引入变量,在记录其使用方式产生的约束后,最终确定函数类型。这个过程与之前学习的表达式推断一脉相承,通过生成并后续求解约束,OCaml的类型系统能够自动推导出fun x -> E这样匿名函数的精确类型。

195:函数应用的类型推断 🧠

在本节课中,我们将要学习如何为函数应用(Function Application)表达式进行类型推断。函数应用是编程中最常见的操作之一,理解其类型推断规则对于掌握OCaml的类型系统至关重要。

上一节我们介绍了变量和常量的类型推断,本节中我们来看看如何推断 E1 E2 这种形式的表达式类型。

函数应用的类型推断规则

为了推断表达式 E1 应用到 E2 的类型,我们需要引入一个新的类型变量 τ,并生成一组约束 CC1C2

以下是这些约束的来源:

  • τ 必须是一个全新的类型变量。在算法层面,我们尚未深入分析 E1E2,因此尚不清楚整个应用表达式的最终类型。我们引入一个新变量,然后通过约束来确定其具体类型。
  • 接下来,我们需要为两个子表达式进行类型推断。我们将为 E1 推断出类型 T1 和约束 C1,为 E2 推断出类型 T2 和约束 C2
  • 最后,我们通过添加一个关于 T1T2τ 的约束来整合所有信息。这个约束是:T1 必须是一个函数类型。

为什么 T1 必须是函数类型?因为我们在函数应用的左侧使用了它。接下来,这个函数类型的输入必须是 T2,因为我们是将函数 E1 应用于类型为 T2 的表达式 E2。最后,函数的输出类型未知,这正是我们引入的类型变量 τ 所代表的意义。

因此,我们记录下约束:T1 必须等于类型 T2 -> τ。我们将这个约束与从 E1E2 推断出的所有其他约束合并。

一个具体例子

让我们通过一个例子来实践这个规则:推断 plus 部分应用于 1 的表达式类型(即 plus 1)。

为了推断这个函数应用的类型,我们将引入一个新的类型变量,并推断其子表达式的类型。

以下是推断步骤:

  • 推断左侧表达式(即名称 plus)的类型。我们在初始静态环境中查找它,得知其类型为 int -> int -> int。名称推断不产生新约束。
  • 推断参数(即整数常量 1)的类型,这很简单,就是 int
  • 现在我们需要添加一个新约束。我们引入了一个类型变量 α 来代表整个函数应用的类型。

根据规则,函数 plus 的类型(即 int -> int -> int)必须等于 T2 -> τ。这里 T2 是参数 1 的类型,即 int,而 τ 是我们引入的变量 α。因此,我们得到约束:int -> int -> int 必须等于 int -> α

观察这个约束,我们看到箭头左侧都是 int,可以将其消去,从而得出 int -> int 必须等于 α。因此,我们能够推断出 plus 1 的类型是 int -> int。这是正确的,当我们将 plus 运算符部分应用于一个参数后,会得到一个等待接收第二个参数并返回结果的函数。

到目前为止,我们已经多次运用人脑来求解这些约束并完成类型推断。我们接下来的任务是构建一个算法,让计算机也能完成这项工作。

本节课中我们一起学习了函数应用的类型推断规则。我们了解到,推断过程需要引入新的类型变量,并为函数及其参数的类型建立等式约束,最终通过求解这些约束来确定整个表达式的类型。

196:合一算法直觉 🧩

在本节课中,我们将学习如何将解决代数方程组的直觉,应用到解决类型约束系统上。我们将通过一个简单的例子,理解“合一”算法的核心思想。


概述

我们已经在脑海中多次解决过类型约束系统。如何用算法来解决它呢?让我们回到高中时代的代数方程组,从中获取一些启发。

从代数方程组到类型约束

以下是一个方程组。你会如何求解其中的 x 和 y?花点时间思考一下,并反思你的解题过程。

你可能立刻注意到,第二个方程为我们提供了一个简单的方法来分离 x 或 y。例如,我们可以将第二个方程重写为 x = y - 1

既然我们这样分离了 x,就可以将其从系统中消除。然后继续简化方程。现在,我们可以将结果代回我们已有的 x 方程中。现在我们得到了 X 和 Y 的值。

如果我将这些值代入原始方程,每个方程的两边将变得相同。我代入了我们发现的 x 和 y 的值,x 是 1,y 是 2。现在,如果我简化这些方程,就变成了 9 等于 9,以及 -1 等于 -1。

通过应用我们为 X 和 Y 发现的这个替换,我实际上可以使方程的两边变得相同,它们变得一致,或者另一个词是它们“合一”了。

合一的概念

那么,我们如何解决一组方程呢?在这里,我们消除了一个变量。我们用它来找到另一个变量的值。这使我们能够回过头来找到第一个原始变量的值。结果,我们得到了一个可以看作“替换”的解决方案,它统一了这组方程。

这正是我们解决类型约束系统时要做的。

不过,这里的替换最终可能比简单的单个替换(例如用 1 替换 x 或用 int 替换 alpha)更复杂。实际上,我们将得到一个替换序列作为解决方案,其中每一个都是对变量的一个小替换。但我们将按顺序执行它们,作为该序列的一部分。

我们将说,如果一个替换应用于等式两边后,最终使两边变得相同,那么这个替换就统一了一个约束 T1 = T2。然后,如果一个替换统一了集合中的所有约束,那么它就统一了整个约束集合。

类型约束合一示例

让我们尝试一个统一类型约束系统的例子。

这里有两个类型约束。我可以从其中任何一个开始处理。第二个看起来更容易一些,因为它已经在等式的一边分离出了 Y。

假设我创建一个替换:用 x -> x 替换 y。然后,我可以在第一个方程中用 x -> x 替换 y。让我这样做。我取出第一个方程,将 y 替换为 x -> x

现在我有一个包含一些部分的方程,其中一些部分是函数箭头。我知道函数箭头是右结合的。所以如果我在这里加上缺失的括号,它看起来会像那样。如果我进一步分解会怎样?

我知道这里两边都有一个带输入和输出的函数。这边的输入是 x,那边的输入是 int。为了使这两个函数类型相同,它们的输入类型必须相同。

所以从这个方程中,我实际上可以提取更多信息。我可以进一步分解它,说 x 必须等于 int,这是箭头两边的输入类型。并且输出类型也必须相同。所以 x -> int 必须与 x -> x 相同。我从这里和那里得到了这个信息。

现在我有了这两个方程来处理。第一个方程立即给了我另一个被分离出来的变量,我可以尝试从其他所有地方消除它。所以现在让我添加一个替换:我将用 int 替换 x

如果我取这个替换并将其应用于剩下的方程,我得到 int -> int = int -> int。当然,看这个,我们立刻可以看到等式两边是相同的类型。如果我们想更算法化一点,我们可以再次说我们有一个函数类型,我们可以分解它:箭头的左侧必须等于两边箭头的左侧,所以是 int 等于 int,箭头右侧也是如此。

最后,我们得到了这个替换。首先我们用 x -> x 替换 y,然后用 int 替换 x

处理顺序的影响

这只是这个方程组的一个可能解。实际上,如果我们选择以不同的顺序消除变量,可能会得到不同的解。让我们进行第二次尝试。

第一次我们选择通过处理第二个方程先消除 y。这次我们首先处理第一个方程。所以我们有这个方程。从中,我们可以提取关于输入类型和输出类型的信息。我所做的只是说箭头的左侧和右侧都必须相同。

现在,如果我想,我可以消除 x,因为我知道 x 是 int。我将在第二个方程中用 int 替换 x。现在我知道 y 实际上是 int。

所以那次我最终得到的最终替换将不同。它仍然是一个统一这组方程的替换,但它与我第一次得出的替换不同。

因此,根据我们处理所有方程和消除变量的顺序,我们可能会得到不同的解。



总结

本节课中,我们一起学习了如何将解决代数方程组的直觉迁移到类型约束系统。我们理解了“合一”的核心思想:通过应用一系列替换,使约束等式两边变得相同。关键在于消除变量顺序处理,不同的处理顺序可能导致不同的(但都有效的)替换序列。这为后续学习具体的类型推断算法奠定了重要的概念基础。

197:合一算法 🧩

在本节课中,我们将学习类型推断中的核心算法——合一算法。该算法用于求解一组类型等式约束,并找到能使所有等式成立的最一般类型替换。


算法概述

合一算法处理一个约束集合。当约束集合不为空时,算法将持续执行以下步骤。

首先,我们从集合中任意选取一个约束 t1 = t2,并将其从集合中移除。选择哪个约束可能影响求解过程,但算法最终会找到解。

接着,我们尝试简化这个约束。简化规则将在下一节详细介绍,其核心思想是将复杂的约束转化为更简单的形式。

完成简化后,将发生以下三种情况之一:

  1. 我们可以用一个新的替换来更新当前解,即将其添加到已发现的替换序列末尾。
  2. 我们可能需要向约束集合中添加一些新的、更简单的约束。
  3. 算法可能失败,因为发现了不一致的等式(例如 int = bool),这意味着该约束系统无解。

值得注意的是,这个算法由雪城大学的约翰·艾伦·罗宾逊教授发明。


约束简化规则

约束的简化取决于其具体形式。以下是几种主要的简化情况。

第一种情况是处理平凡约束。例如 int = intbool = bool 或类型变量 'x = 'x。这类等式不提供新信息,因此可以直接丢弃,继续处理剩余约束。

第二种情况是处理函数类型相等。当遇到 T1 -> T2 = T3 -> T4 这样的约束时,我们将其从集合中移除,并添加两个更简单的约束:T1 = T3T2 = T4。这表示两个函数的输入类型必须相等,输出类型也必须相等。虽然约束数量增加了,但每个新约束的结构都更简单,这确保了算法能向终止推进。

第三种情况是处理类型变量等于某个类型。当我们得到形如 'x = T 的约束时,我们有机会消除这个类型变量 'x

但这里有一个重要前提:类型变量 'x 不能出现在类型 T 中。如果 'x 出现在 T 里(例如 'x = 'x -> int),那么替换 'xT 将无法真正消除 'x,反而可能创建循环定义,导致算法无法取得进展。

当发现一个满足条件的 'x = T 约束时,我们将执行两个操作:

  1. 将剩余约束集合中所有出现的 'x 都替换为 T,从而从约束集中消除 'x
  2. 将替换 'x -> T 追加到我们的解中,记录这一发现。

如果遇到的约束不属于以上任何一种情况,则算法失败。这表明我们发现了一组无法求解的不一致方程。


算法的最优性

合一算法在一个特定意义下是最优的:它保证输出的解是给定约束集的最一般合一子

这意味着,任何其他能统一该约束集的替换,都将是算法输出替换的某种特例。换句话说,算法的解包含了所有必要的信息,但没有引入任何多余的、过于具体的限制。

例如,假设约束为 alpha = beta -> beta。合一算法将输出用 beta -> beta 替换 alpha 的解。它不会输出更具体的解,比如用 int -> int 替换 alpha 并用 int 替换 beta。虽然这个更具体的替换也能使等式成立(两边都变成 int -> int),但它不必要地硬编码了类型必须是 int 的信息,而原始约束并未要求这一点。

我们可以形式化地表述这一性质:
假设对约束集 C 运行合一算法,得到替换 S
如果存在任何其他也能统一 C 的替换 S1,那么 S1 实际上等于先应用 S,再应用一些额外的替换 S2。即 S1 = S ∘ S2。因此,S1S 更具体,它在 S 的基础上增加了额外的限制,使得最终类型可能变得更特化。


总结

本节课我们一起学习了合一算法。我们了解了算法的基本流程:循环选取并简化约束,根据简化结果更新解、添加新约束或报告失败。我们详细探讨了三种主要的约束简化规则:丢弃平凡约束、分解函数类型以及用具体类型替换类型变量。最后,我们认识到该算法能够输出最一般合一子,这是其核心优势,确保了类型推断结果的通用性。掌握合一算法是理解OCaml等语言类型推断机制的关键一步。

198:类型推断实例分析 🧠

在本节课中,我们将通过一个具体的例子,将类型推断关系和统一算法结合起来,完整地走一遍类型推断的流程。我们将学习如何为一个给定的表达式推导出其类型。

概述与流程

上一节我们介绍了类型推断关系和统一算法。本节中,我们来看看如何将它们结合起来完成类型推断。

我们从一个表达式 E 开始,它处于某个初始静态环境 I 中。我们使用类型推断关系来收集该表达式的一些约束条件和一个类型 T。换句话说,我们将这个推断关系作为一种算法来运行。输入是 IE,输出是推断出的类型 T 和一些约束 C

此时,类型 T 并不一定告诉我们所有需要知道的信息,因为我们还需要解决那组约束并获得一个代换 S。接下来,我们对该约束集运行统一算法,得到代换 S,并将其应用回 T。因此,表达式 E 的最终推断类型将是 T 应用了代换 S 后的结果。

最终流程可以总结为:

推断关系 (I, E) -> (T, C)
统一算法 (C) -> S
最终类型 = S(T)

实例分析:一个函数的类型推断

让我们通过一个稍大一点的表达式来实践类型推断。以下是我们将要进行类型推断的函数:

fun f -> fun x -> f ((plus x) 1)

该函数接受一个参数 f 和另一个参数 x,然后将 f 应用于一个涉及 x 和加法的表达式。

我们将在我们的小语言的标准静态环境中开始推断,该环境绑定了 plustimesminuslessorequal 等运算符。

步骤一:处理外层匿名函数

我们从最外层的匿名函数开始。这意味着我们获取初始静态环境 I,并向其中添加该函数的参数 f,同时将其绑定到一个新的类型变量,我们称之为 α

然后,我们使用这个环境来推断函数体的类型,而函数体本身是另一个匿名函数。

步骤二:处理内层匿名函数

为了深入内层函数,我们重复相同的过程。我们从静态环境 I'(现在包含 f: α)开始,这里有一个新参数 x。我们为其发明一个新的类型变量 β,然后继续推断函数体 f ((plus x) 1) 的类型。

此时,我们遇到了第一个函数应用 f ((plus x) 1)。其左侧子表达式是 f

以下是处理子表达式的步骤:

  1. 推断 f 的类型f 是一个名称,我们在环境中查找它。它存在,类型为 α。这不会生成任何约束。
  2. 推断参数 (plus x) 1 的类型:我们需要推断 plus 应用于 x 再应用于 1 的表达式类型。根据左结合性,这实际上是 (plus x) 应用于 1。因此,我们遇到了另一个函数应用。

步骤三:深入 (plus x) 1

我们首先处理 (plus x) 这个子表达式。这又是一个函数应用。

  1. 推断 plus 的类型plus 是一个名称,在初始环境中绑定为 int -> int -> int。查找名称不生成约束。
  2. 推断 x 的类型x 是一个名称,在环境中绑定为 β。这返回类型 β,无新约束。
  3. 完成 (plus x) 的应用:对于一个函数应用,我们为整个表达式的类型发明一个新的类型变量 γ。这会生成一个约束:函数的类型(int -> int -> int)必须等于参数的类型(β)指向返回类型(γ)。因此,我们得到约束:int -> int -> int = β -> γ

现在,我们回到 (plus x) 1 这个应用。我们刚刚处理了函数部分 (plus x),其类型为 γ。现在需要处理参数 1

  1. 推断 1 的类型1 是一个常量,类型为 int,不生成约束。
  2. 完成 ((plus x) 1) 的应用:我们为这个应用的结果发明一个新的类型变量 δ。这会生成约束:函数 (plus x) 的类型 γ 必须等于参数 1 的类型 int 指向返回类型 δ。因此,我们得到约束:γ = int -> δ

步骤四:完成顶层应用 f ((plus x) 1)

现在,我们回到最外层的函数应用 f ((plus x) 1)。我们已经处理了函数 f(类型 α)和参数 ((plus x) 1)(类型 δ)。

  1. 完成 f ((plus x) 1) 的应用:我们为这个应用的结果发明一个新的类型变量 ε。这会生成约束:函数 f 的类型 α 必须等于参数 ((plus x) 1) 的类型 δ 指向返回类型 ε。因此,我们得到约束:α = δ -> ε

步骤五:组装匿名函数的类型

现在,我们向上回溯,组装匿名函数的类型。

  1. 内层函数 fun x -> ... 的类型:其参数类型为 β,其函数体(即 f ((plus x) 1))的类型为 ε。因此,该函数的类型是 β -> ε。我们需要携带从函数体推断中收集的所有约束。
  2. 外层函数 fun f -> ... 的类型:其参数类型为 α,其函数体(即内层函数)的类型为 β -> ε。因此,该函数的类型是 α -> (β -> ε)。我们同样需要携带所有累积的约束。

至此,我们通过推断关系为这个程序得到了一个推断类型 α -> β -> ε 以及一组约束集合 C

约束求解与统一

我们得到的约束集合 C 如下:

  1. int -> int -> int = β -> γ
  2. γ = int -> δ
  3. α = δ -> ε

现在,我们运行统一算法来求解这些约束。

以下是求解步骤:

  1. 从约束 α = δ -> ε 开始,我们可以将 α 代换为 δ -> ε。将此代换加入解集 S,并从约束集中移除该等式。
  2. 接下来处理 γ = int -> δ。将 γ 代换为 int -> δ 加入 S,并将此代换应用到剩余约束中(即约束1变为 int -> int -> int = β -> (int -> δ)),然后移除该等式。
  3. 现在处理 int -> int -> int = β -> (int -> δ)。这是一个函数类型的等式,我们将其分解为两个等式:
    • int = β (对应输入类型)
    • int -> int = int -> δ (对应输出类型)
  4. int = β 可得,将 β 代换为 int 加入 S,并应用到剩余约束(目前无影响),移除该等式。
  5. 处理 int -> int = int -> δ。再次分解:
    • int = int (恒成立,可丢弃)
    • int = δ
  6. int = δ 可得,将 δ 代换为 int 加入 S,移除该等式。

现在约束集为空,统一完成。我们得到的代换解 S 为:

  • α -> int -> ε
  • β -> int
  • γ -> int -> int
  • δ -> int

应用代换得到最终类型

我们最初推断出的类型是 α -> β -> ε

现在,我们将代换解 S 应用到这个类型上:

  1. α 替换为 int -> ε:得到 (int -> ε) -> β -> ε
  2. β 替换为 int:得到 (int -> ε) -> int -> ε
  3. γδ 在最终类型中未出现,无需替换。

因此,该函数的最终推断类型是 (int -> ε) -> int -> ε

在OCaml的交互环境(如utop)中,类型变量通常会按顺序重命名(例如,ε 可能被显示为 'a)。所以,这个类型等价于 (int -> 'a) -> int -> 'a

让我们在utop中验证一下:

(fun f -> fun x -> f (( + ) x 1));;

输出结果应为:(int -> 'a) -> int -> 'a,这与我们的推导结果一致。

总结

本节课中,我们一起学习了如何将类型推断关系与统一算法结合,完成对一个具体OCaml表达式的完整类型推断。我们逐步分析了表达式 fun f -> fun x -> f ((plus x) 1),通过推断关系收集约束,利用统一算法求解约束并获得代换,最后应用代换得到最终的多态类型 (int -> 'a) -> int -> 'a。这个过程展示了OCaml类型系统如何自动且精确地推导出函数类型,即使对于嵌套的函数应用也是如此。

199:完成类型推断

在本节课中,我们将要学习类型推断算法的最后一部分,即其保证的“主类型”属性,并探讨在类型推断过程中如何处理错误。

主类型属性 🎯

上一节我们介绍了类型推断算法的核心步骤。本节中我们来看看该算法的一个重要保证:它推断出的类型被称为表达式的主类型

与合一算法能给出最优解类似,这个HM类型推断算法也是如此。推断出的类型被保证是表达式的主类型。

“主类型”是一个专业术语,其含义是:任何其他可能的类型都会比它更具体。例如,考虑恒等函数。它的推断类型应该是 α -> α。当然,你也可以赋予它其他类型,比如 int -> intbool -> bool 等,但这些类型都比 α -> α 更具体。α -> α 这个类型就是该表达式的主类型。

以下是主类型的正式定义:
假设你推断表达式 E 的类型,得到答案 T。同时假设在初始的静态环境中,你也可以合法地检查并得出 E 有另一个类型 T'。那么我们可以保证,对于某个替换 ST' 实际上等于 T 应用了额外的替换 S 后的结果。换句话说,T' 必须是比 T 更具体的类型,因为它涉及进行了一些额外的替换。

类型推断中的错误处理 ⚠️

我们已经了解了类型推断如何产生主类型。现在,我们来探讨类型推断中被我略过的第三个部分:错误处理。对于那些我们无法推断类型的程序,我们应该如何处理错误?

错误可能在几个地方出现。它们可能在约束生成阶段出现。我们已经见过的一个地方是使用名称规则时,在静态环境中找不到该名称。此时,停止类型推断并告诉程序员“这里有一个未绑定的名称”是相对容易的。

然而,当错误是因为合一失败而引起时,处理起来就更困难了。你可能正在求解一个庞大的方程组,然后发现了一个不一致性。此时,合一算法应该做什么?它需要一些我们到目前为止尚未追踪的额外信息。

它需要记录关于约束是在何处以及为何被添加的信息,以便能够回溯并告诉程序员:“问题实际上发生在你源代码的这一行、这一列。”

顺便提一下,约束被处理的顺序可能导致不同的错误。事实上,我们已经看到,你可以得到不同的替换结果,所以这也许并不令人惊讶。

如何在类型推断过程中因这种合一失败而给程序员提供真正有用的错误信息,这是一个持续的研究领域。事实上,最近安德鲁·迈耶教授的研究小组就在这里针对这类问题做了一些工作。

总结 📝

本节课中我们一起学习了类型推断算法的两个重要方面。首先,我们了解了HM类型推断算法保证会为表达式推断出其主类型,即最通用、最不具体的类型。其次,我们探讨了在类型推断过程中处理错误的挑战,特别是在合一失败时,需要额外的信息来生成有用的错误信息,这是一个活跃的研究领域。

200:Let表达式与多态性类型推断

在本节课中,我们将学习如何为OCaml语言添加Let表达式,并深入探讨其类型推断机制,特别是如何处理多态性。我们将看到,看似简单的Let表达式在类型推断中会带来一些微妙的挑战,并学习Hindley-Milner类型系统如何通过类型方案(type scheme)和值限制(value restriction)来解决这些问题。


Let表达式的初始类型推断规则

上一节我们介绍了不含Let表达式的语言。本节中,我们来看看如何添加Let表达式。

一个看似合理的初始规则是:要推断一个Let表达式的类型,首先推断其绑定表达式E1的类型,得到类型T1和约束C1。然后将变量X以类型T1添加到静态环境中。接着推断主体表达式E2的类型,得到类型T2和约束C2。最后,整个Let表达式的类型就是T2,并包含这两组约束。

let x = E1 in E2 : T2, C1 ∪ C2

这个规则在许多情况下是正确的。例如,推断 let x = 42 in x 的类型:

  1. 绑定表达式 42 的类型是 int,无约束。
  2. x: int 添加到环境。
  3. 主体表达式 x 的类型是 int,无约束。
  4. 因此,整个表达式的类型是 int,无约束。

这是一个简单的成功案例。


多态性带来的问题

问题出现在处理多态性时。假设我们使用恒等函数(identity function),并对其应用两次:一次是整数,一次是布尔值。

let id = fun x -> x in
(id 0, id true)

我们希望多态的恒等函数类型为 'a -> 'a,而不是特定的 int -> intbool -> bool。然而,我们刚才给出的朴素Let规则不允许恒等函数以这种方式多态化。

以下是推断过程:

  1. id 以类型 'a -> 'a 放入环境。
  2. id 应用于整数 0 时,会产生约束:函数类型必须为 int -> something,这导致约束 'a = int
  3. id 应用于布尔值 true 时,会产生另一个约束:函数类型必须为 bool -> something,这导致约束 'a = bool
  4. 现在我们有了一个矛盾:'a 需要同时等于 intbool,这是不可能的。

问题出在哪里?问题在于我们放入环境中的类型。我们说 id 的类型是 'a -> 'a,这意味着存在一个单一的未知类型 'a。统一(unification)会尝试为这个单一类型求解。但实际上,我们想要的是不同的东西:我们希望存在许多未知类型,id 的每次应用都可以为该类型使用不同的值。


解决方案:类型方案(Type Scheme)

这个解决方案的灵感来源于逻辑中的全称量化(universal quantification)。在类型推断中,我们引入类型方案,记作 'a. T(在教科书中可能写作 ∀'a. T)。这里的 'a 是一个在该类型 T 范围内有效的类型变量。语法上可以扩展到多个类型变量:'a1 'a2 ... 'an. T

类型方案一直是OCaml的一部分,只是你之前没有看到它。例如,List.length 的类型是 'a list -> int。实际上,我可以给它一个显式的类型方案注解:

let my_length : 'a. 'a list -> int = List.length

OCaml接受这个类型注解。当OCaml输出类似 'a list -> int 的类型时,任何出现在那里的类型变量实际上都是作为类型方案的一部分被量化的。OCaml通常不打印它,因为这是冗余信息,大多数程序员不需要知道。但你可以手动输入它。

另一个例子:一个接收两个参数并返回第一个参数的函数。

let first_of_two : 'a 'b. 'a -> 'b -> 'a = fun x y -> x

Hindley-Milner类型推断如何使用类型方案

当遇到像 id 这样的多态函数(类型为 'a -> 'a)时,类型推断算法会将该类型泛化为一个类型方案:'a. 'a -> 'a。可以将其理解为全称量化:对于所有类型 'a,该函数都具有该类型。

然后,在函数的每次使用(每次应用)时,类型推断会用一个新的类型变量来实例化该类型方案。这就像用更具体的东西来填充那个全称量化。

  • id 应用于 0 时,我们可能将其实例化为 'beta -> 'beta(假设 'beta 是一个新的类型变量)。
  • 后来,在 id 应用于 true 时,它会用另一个不同的类型变量实例化,例如 'gamma -> 'gamma

现在,函数的每次使用都独立于其他使用,因此每次使用最终都可以有自己的类型(int -> intbool -> bool)。

为了利用泛化和实例化,我们只需要稍微更新两个规则。

  1. 更新Let规则:我们给出的朴素Let规则几乎是正确的,只是需要在将绑定表达式E1的类型T1放入环境时,将其泛化以创建类型方案。这里的一个小复杂之处是,我们需要一些额外信息才能正确地进行泛化:需要知道生成的约束、环境和变量名。
  2. 更新名称规则:当我们使用变量名时,需要实例化发现的任何类型方案。

实例化的细节:

  • 如果对一个类型(严格来说,不是类型方案)应用实例化,它不会改变,只是保持不变。
  • 当实例化一个类型方案时,你将其变回一个类型:去掉前面的 'a1, 'a2, ... 'an.,并为每个量化的类型变量替换一个新的类型变量。例如,恒等函数 'a. 'a -> 'a 会变成 'beta -> 'beta'beta 是新的)。

泛化是两者中较难的一个。它接受约束集、静态环境、变量名以及为该变量初步找到的类型(我们现在可能想要泛化它)作为输入。

泛化的工作流程:

  1. 完全完成对该绑定表达式的推断:取约束C1并进行统一,得到一个代换(substitution)。
  2. 将该代换应用于环境,也应用于类型T1。这得到一个新环境(称为N1)和一个新类型(称为U1)。
  3. 然后泛化U1。我们完全完成了对它的推断,然后查看其中是否有任何类型变量可以泛化并转化为类型方案。
    • 注意:并非所有类型变量都应该被泛化。不能泛化那些仍然出现在静态环境中的类型变量。原因是它们来自外部代码(当前正在处理的Let表达式之外嵌套的代码)。外部环境已经对这些类型变量有一些假设,可能在其他地方使用它们或产生约束,因此不允许泛化它们。
  4. 泛化返回应用了代换的环境,以及将X绑定到这个泛化后的类型方案S1的绑定。

这就是多态类型推断的工作原理。从某种意义上说,这是Hindley-Milner类型推断的本质:它在Let绑定处进行泛化以生成类型方案。因此,这种类型推断方式有时被称为Let多态


可变性与值限制

在结束类型推断和多态性的话题之前,我们还需要讨论另一个复杂问题,它毫不意外地与可变性有关。

可变性总是让事情变得更复杂。考虑以下表达式:

  • ref None (类型不是 'a option ref,而是 '_weak1 option ref
  • ref [] (类型是 '_weak2 list ref
  • ref (fun x -> x) (类型是 '_weak3 -> '_weak3 ref

它们的共同点是都涉及弱类型变量(weak type variables),类型变量名中出现了 _weak 字样。接下来,我将解释什么是弱类型变量,但首先你需要理解它们要解决的问题。

问题示例

let id = fun x -> x in
let r = ref id in
r := succ;
!r true

让我们关注第二行。引用 r 的类型应该是什么?你最初可能认为它应该是 ('a -> 'a) ref。根据类型推断,我们会将其泛化为一个类型方案:r 的类型方案是 'a. ('a -> 'a) ref。泛化后,每次使用它时都会实例化。

  1. 当我们将 r 更新为后继函数 succ(一个整数加1的函数)时,我们会将 r 的类型方案实例化为 (int -> int) ref
  2. 在最后一行,当我们解引用 r 并将其应用于 true 时,在类型推断中会再次实例化该类型方案,并发现它需要是 (bool -> bool) ref

然而,这里发生了糟糕的事情:如果后继函数存储在 r 中,我们解引用 r 得到该函数,它是一个期望整数输入的函数,但我们刚刚将其应用于布尔值 true。这将导致类型不安全,程序会崩溃。OCaml 绝不应该允许我们这样做。事实上,OCaml 不允许这段代码运行。

OCaml 用来防止这种将 succ 应用于布尔值所导致的爆炸性错误的解决方案,被称为值限制

值限制出现在许多语言中。它规定:一个可变的多态值永远不能持有超过一种类型。你只能在其中放入一种类型(intboolint -> int 函数等)。

OCaml 目前使用弱类型变量来实现值限制。弱类型变量代表一个单一的未知类型(这正是在我们为多态性引入泛化之前,类型变量所做的)。最终,弱类型变量会被实例化为实际类型,从此就不再是类型变量了,因为它被一次性永久实例化了。

回顾我们的代码,更详细地看看类型发生了什么:

  1. 当我们将 r 绑定为 id 的引用时,它获得了一个涉及弱类型变量的类型:'_weak2 -> '_weak2 ref。这是一个对某个函数的引用,该函数接受相同类型的输入并产生相同类型的输出。但这个函数不是多态的;这里只是一个尚未求解的未知类型。OCaml 还不知道我将在其中放入 int -> int 函数还是 bool -> bool 函数。
  2. 后来,我确实放入了这样一个函数。我可以将后继函数 succint -> int)存储在该引用中。
  3. 现在看 r 的类型发生了什么变化:它不再涉及弱类型变量。那个弱类型变量已被永久实例化为 int。从现在开始,我们永远不允许在其中放入不同类型的函数(例如 bool -> bool 函数)。

看起来当进行这种突变时,r 的类型似乎在改变。在某种意义上确实如此:在弱类型变量被实例化之前,存在一个未知类型;当我们进行突变将 succ 存储到 r 中时,该弱类型变量被实例化,最终确定下来,并且从此以后我们就使用这个确定的类型。这就是值限制。

你可能在你自己的代码中见过它出现。现在我们已经学习了类型推断,你可以理解它为什么存在了。OCaml 目前使用弱类型变量实现值限制,但其他语言尝试过不同的强制执行方式,并且语言设计者仍在探索放宽它的可能性,因为有时可以不完全要求它。


值限制不仅限于OCaml

顺便说一下,值限制不仅仅是OCaml的事情,甚至不仅仅是函数式编程的事情。即使是Java也必须处理值限制。

考虑在Java中创建一个表示动物的类,以及另外两种动物类型:大象和兔子。然后创建一个兔子数组。该数组的类型被声明为 Animal[],但我允许在其中存储一个 Rabbit[],因为 RabbitAnimal 的子类。这是Java中子类型多态性的作用。

如果我尝试将一头大象放入这个数组会怎样?根据数组的类型,第一个元素应该是一个 Animal。我之前碰巧放了一只兔子进去。那么我现在可以放一头大象进去吗?不行。这段代码可以通过编译(没有类型错误),但它会通过类型检查器,在运行时抛出 ArrayStoreException

ArrayStoreException 在你尝试将错误类型的对象存储到数组中时抛出。在这个例子中,该数组是一个兔子数组(这是它创建时的类型),因此它只能保存 Rabbit 类的对象(或 Rabbit 的子类)。你不能将非兔子的东西放入该数组,即使你持有对该数组的引用是 Animal[] 类型。Java 禁止这样做,因为如果你编写一个遍历该数组的循环,并假设数组的每个元素都是兔子,如果允许放入大象,这个假设就会被违反。

所以Java通过数组存储异常来防止这种情况。这实际上只是值限制的另一种表现形式。我们有一个可变的多态值(这里的可变性来自数组,多态性来自子类型多态性,即一个类扩展另一个类)。但这又是值限制,因为你不允许改变那个可变多态值的类型:一旦它成为兔子数组,它就永远只能是兔子数组,你不能放入大象。


总结

本节课中,我们一起学习了:

  1. Let表达式的类型推断:从朴素的规则开始,并识别其在处理多态函数时的问题。
  2. 类型方案与泛化/实例化:作为解决方案,引入了类型方案('a. T)的概念。Hindley-Milner类型系统在Let绑定处对类型进行泛化(生成类型方案),在变量使用处进行实例化(用新的类型变量替换量化的变量),从而实现了Let多态。
  3. 可变性的挑战与值限制:当多态性与可变性(如引用 ref)结合时,需要值限制来保证类型安全。它规定可变的多态值只能持有一种具体类型。OCaml使用弱类型变量来实现这一限制。
  4. 概念的普遍性:值限制的思想不仅适用于OCaml的函数式编程和参数多态性,也适用于像Java这样的命令式语言及其子类型多态性(体现在数组协变和 ArrayStoreException 上)。

通过学习类型推断、多态性和可变性,希望你不仅能更好地理解OCaml,也能更好地理解Java以及许多其他语言。

posted @ 2026-03-29 09:40  布客飞龙I  阅读(1)  评论(0)    收藏  举报