华盛顿大学-CSE341-编程语言-ABC-笔记-全-

华盛顿大学 CSE341 编程语言 ABC 笔记(全)

001:欢迎与课程机制 🎬

在本节课中,我们将学习课程的基本介绍和结构安排,帮助你顺利开始学习。

欢迎来到编程语言课程。我是 Dan Grossman。很高兴你选择参与这门课程。作为课程的第一个视频,我将欢迎你,并提供课程组织和后续内容的基本信息。

课程概述

这门课程旨在学习编程语言的基本概念。学习方式将使你成为任何编程语言中更优秀的程序员,甚至包括课程中未使用的语言。核心思想是学习构建所有编程语言的基本理念,并精确理解编程时使用的不同思想,以及这些思想如何在多种编程语言中表达。

人们常认为这是学习 ML、Racket 和 Ruby 的课程,因为我们将使用这三种语言贯穿整个编程语言课程。但这并非重点。重点是超越语言间的表面差异,深入核心思想。希望这能激发你的兴趣,并带来挑战与收获。

我们将很快回到实际的编程语言内容,但像任何课程一样,首先需要讨论一些机制和结构,确保你能找到所需的一切。本视频将介绍部分内容,后续还有六个介绍性视频。

课程准备事项

以下是开始课程前应完成的事项列表。

  • 熟悉课程网页,浏览并查找内容。
  • 观看视频,阅读所有公告和消息,避免因假设与其他课程相同而错过信息。
  • 完成 Part A 作业所需的软件安装,以便在进入课程第一个正式部分时能跟上进度并尝试程序。
  • 准备文本编辑器。视频中使用并推荐 Emacs,但也可使用其他适用于 ML 程序的编辑器。
  • 完成可选的 homework0,以熟悉本课程作业提交流程。

课程材料说明

我们提供了大量材料,以下是它们如何配合使用的说明。

  • 视频讲座:主要学习形式,包含大量代码编写。
  • 阅读笔记:涵盖视频中所有材料的书面解释,更详尽、精确,是重要的辅助资源。
  • 讲座代码:提供视频中出现的所有代码文件,无需重新键入。
  • 作业:包含自动评分和同伴互评,用于测试正确性、检查代码风格,并相互学习。
  • 考试:Part A 和 Part C 结束时各有一次,用于以非代码编写的形式更好地评估某些主题。

作业指南

作业基本上每个主要课程部分有一个。这不同于某些在线课程分散布置习题的方式,更接近传统大学课程,每周完成一组问题后一并提交。这种方式让你能综合多个主题,形成整体理解。缺点是过程中无法获得太多反馈。

做作业时,人们往往直接编写要求的代码并提交。但更好的方法是:首先理解问题与课程的哪个部分相关;然后编写代码;接着进行测试,尝试破坏你认为正确的解决方案,验证其是否按预期出错;尝试各种变体;当你确信理解问题后,才算完成。这通常不会比急于完成花费更多时间。

作业中有时会有挑战性问题,它们会被明确标识且分值不高,是可选的深入练习机会。为确保他人学习效果,请不要将你的解决方案公开发布。

最后,作业描述力求精确简洁,需要仔细阅读全文,而非浏览。如有困惑,欢迎在讨论区提问。

总结与欢迎

本节课我们一起了解了课程的基本机制和结构安排。

最后,再次欢迎你。这样的在线课程,尤其是内容如此丰富的课程,对你我而言都是一次伟大的冒险。这是我在专业领域做过最令人兴奋的事情之一:分享我最喜爱的计算材料——编程语言,并阐述我对如何思考编程语言的看法。编程语言的组合方式蕴含着深刻的艺术性和优雅性。希望这门课程能拓展你的思维,为你提供审视软件和编程的新鲜视角。我们可能会让你感到不适,以陌生方式行事,但希望课程结束时,一切都能与你已有的编程知识联系起来,并通过学习编程语言获得的独特视角,让你更加热爱编程。

欢迎加入。😊

002:可选章节 - 讲师介绍与致谢 🎬

在本节可选视频中,我们将了解本课程的讲师背景,并向所有在幕后为课程制作付出努力的人员致谢。

讲师背景介绍 👨‍🏫

上一节我们开始了课程,本节中我们来看看我是谁。我一生都生活在美国。我在密苏里州圣路易斯郊区长大。18岁时,我进入德克萨斯州休斯顿的莱斯大学攻读本科。我于1997年离开德州,至今怀念那里的墨西哥卷饼。

随后,我在纽约州北部的康奈尔大学攻读研究生。对于不熟悉美国东北部地理的同学,需要说明纽约州很大,我所在的地方距离纽约市约有四个半小时车程。2003年,我搬到西海岸的华盛顿大学,并一直在计算机科学与工程系任教至今。这里是我最喜欢的生活和工作的地方。

以下是我的研究方向简述:

  • 当我不教授课程或在线课程时,我是编程语言研究社区的成员。
  • 我深信编程语言形式化方面的基础优雅性,例如函数式语言、类型系统、程序证明,以及形式语义学,特别是操作语义学。
  • 但在该背景下,我的研究更偏向应用,即利用这些理论基础解决复杂的现实计算问题。例如:如何用C等语言编写系统程序;如何解决并发编程的难题(在多核处理器时代变得尤为重要);以及近期,尝试在硬件性能受限于功耗和能源问题的时代,继续提升计算性能。
  • 我一直觉得,从编程语言理论的基础概念视角出发,与他人合作解决重要问题,是非常愉快和充实的。

近年来,我也更加关注计算机科学教育。这门慕课(MOOC)无疑是其中的一部分,但更广泛地说,是整个计算机科学课程体系的问题。这是我非常热衷的领域。

个人生活点滴 🚴‍♂️

我的生活并非全部围绕计算机科学。以下是我的一些兴趣爱好:

  • 我曾去蒙大拿州徒步旅行(大约在2012年夏天)。
  • 我每周打一次冰球(图中戴头盔持球杆的是我)。
  • 我喜欢在有机会时滑冰。
  • 我喜欢长途骑行。图中是我在太平洋西北地区完成一次200英里(约320公里)骑行后的照片。
  • 我还养了一只狗,名叫Red Dog,它很棒。每当你完成一次作业,它都会“出现”并给你一条消息。

我也旅行过不少地方,虽然希望能去更多。以下是我曾为游玩到访过的国家列表(按面积从小到大排列),大约有25个。在美国的50个州中,我肯定没去过阿拉斯加州,不确定小时候是否去过特拉华州,但其他各州都曾到访。

但过去几年我的生活发生了很大变化。事实上,自从我首次录制本慕课的大部分材料以来,我在2013年12月迎来了第一个孩子,一个可爱的男孩;并在2015年9月迎来了第二个可爱的男孩。我无法想象在有孩子之后如何还能创建一门在线课程,所以我很庆幸大部分工作是在那之前完成的。我也不知道如今是如何在睡眠如此少的情况下坚持下来的,但拥有孩子是最美妙的事情,这是我生活中非常重要的一部分,虽然课程中不会体现,但我想向大家展示一下,这也是如今我身份的重要组成部分。

幕后团队致谢 🙏

让我切换到本视频的另一个主题:感谢所有在镜头背后付出的人们。这门课程的诞生是一段奇妙的旅程,离不开大量的帮助。

早在2012年夏天,当慕课刚刚兴起、仅有少数几门时,我们召集了一组学生(Cody, Rachel, Sha, Claire, Eric, Max)。我们当时真的不知道自己在做什么,但我们把它做出来了。这些伙伴们首次摸索出了许多方法,这段经历对他们而言永远是特别而难忘的。

随着课程的持续发展和改进,后来有一批助教让课程变得更好,修复了许多问题,他们是重要的第二批成员。

最近,随着我们迁移到一个允许人们更频繁、更有规律地学习课程的平台,又有一组人员不得不重做大量的自动评分基础设施,我对此深表感谢。

在整个课程过程中,一直有志愿者担任社区助教和社区导师。他们提供了大量反馈(当解释不清楚时)、许多额外的练习题,并对讨论论坛做出了惊人的贡献。未来还会有更多人加入,但在录制本视频时,以下是我要分享的名单。

感谢帮助搭建录制工作室的人们,让我能通过这些视频与大家分享信息。

感谢近四年来Coursera的团队成员,他们总是及时回应问题并提供出色反馈。没有他们,这门课程肯定不会存在。

还有一些特别的人,他们可能没有直接参与课程,但当我真正需要自己无法完成的事情时,一群朋友和同事提供了特殊的脚本、更简易的安装说明,并在关键时刻给予了帮助。

有一组人员深度参与了我校园内与这门在线课程最相似的课程教学,他们多年来改进材料、贡献内容,这门课程无疑因他们的所有工作而变得更好。

当然,还要感谢成千上万参与课程的学生,他们发现了拼写错误、提供了反馈、指出了哪些地方做得好、哪些地方有待改进。

最终,这是一个涉及众多人员的惊人团队努力成果。虽然屏幕上始终是我的头像,扬声器中传出的是我的声音,但我希望在课程开始时明确表示,我深深感激所有人为使这门课程成为今天这个样子所付出的巨大努力。

总结 📝

本节课中,我们一起了解了课程讲师的背景与生活,并衷心感谢了所有在幕后为本课程的成功制作与运行付出辛勤劳动的团队成员、志愿者和学生。正是集体的智慧与努力,才使得这门课程得以呈现。

003:CSE341 Coursera 课程概述与初始动机 🎯

在本节课中,我们将探讨这门课程的核心内容——编程语言,以及学习此类材料的意义。我们将学习构成所有(或几乎所有)编程语言的基本概念,以及这些概念如何相互关联。课程分为三个部分:A部分使用标准ML语言,B部分使用Racket,C部分使用Ruby。选择这些语言是因为它们能突出我们想要研究的核心概念,让我们专注于关键思想,并在真实语言中更容易学习和聚焦。通过使用多种语言,我们可以看到相同的概念在不同语言中的表现形式,有时它们略有不同,更多时候它们非常相似,只是在不同语言中有一些基本的语法差异。

为什么选择这些语言?🤔

你可能会问,为什么我们不使用Java、C#、Python、Scala或JavaScript?原因在于,在许多方面,我们使用的语言更简单。当我们试图研究核心概念时,简洁是一种美德。

强调函数式编程 🧮

需要强调的是,尽管课程标题中没有明确提及,但我们将在学习的大部分材料中强调函数式编程。这意味着我们将避免使用赋值语句,或者尽量减少对内存内容的修改或更新。如果你以前从未以这种风格编程过,不必担心,我们将进行大量练习。这也意味着我们将使用一等函数和函数闭包,这是一个非常重要的主题,但无法在简短的视频中详细解释。如果你以前从未听说过这些术语,也不必担心。当然,我们还将学习更多内容。

课程动机的延迟呈现 ⏳

通常,在课程开始时,我们会花一些时间来激发学习课程主要主题的动力。但根据我的经验,在我们建立起一些共同经验和共享术语,并且你完成了一两个(实际上是三个)作业,特别是理解了函数式编程和一般编程语言的基本思想之前,很难做到这一点。因此,我制作了一系列激发课程动力的视频,这些视频将延迟发布,并在课程的第3节之后出现。

高层次理念:新的思维方式 🧠

我想说的是一个我深信不疑的高层次理念:学习这些材料将为你提供一种思考软件的新方式,即使你回到已经熟悉的环境、程序和语言中,也能使你成为更好的程序员。此外,它还将为你提供终身自信地学习新语言和思想所需的心智工具和经验,并能够仔细、精确、正确地推理你正在编写的软件。

初学阶段的挑战与类比 🥋

我完全承认,当你进入本课程的前几个小时并完成第一个作业时,如果你以前没有做过这种函数式编程,编写程序可能会感觉与你以前所做的完全不同。我能想到的最好的类比是电影《空手道小子》。在这部电影中,一个想学习空手道的孩子被要求花费数天时间擦窗户、洗车和做其他琐碎的任务。事实证明,他(或她,在翻拍版中)正在建立成功进行空手道所需的心智和肌肉记忆。虽然我对空手道一无所知,但我对编程语言和编写软件有所了解。我相信,我们在这门课程中,尤其是在早期阶段所做的,正是建立那些基本的肌肉技能和基本思想,以便能够快速构建,以组合的方式理解更复杂的软件、算法和程序如何有效地结合在一起。也许这个类比会引起你们中一些人的共鸣。在课程早期,当你做一些感觉非常不舒服或不熟悉的事情时,这无疑是一种激励自己的好方法。

适应新环境与工具 🛠️

确实,我想强调的是,在课程的A部分,我们将使用标准ML语言,可能你们中很少有人使用过。我们可能使用你不熟悉的文本编辑器(使用哪种编辑器是可选的),但你可能没有用于其他语言的熟悉开发环境。我们将使用称为“读取-求值-打印循环”的东西来评估我们的程序,这会感觉与你更熟悉的正常编译和运行周期不同。你将需要安装你不熟悉的软件,并且必须在真正开始作业一的内容之前让这些东西运行起来。我理解这是一个额外的负担,但我认为这也是计算领域中相当常见的事情。当你参加不同的课程、从事新的工作或尝试新事物时,你总是在陌生新环境中工作并安装新工具。虽然这可能并不有趣,而且起初常常会引起麻烦并令人恼火,但随着时间的推移,你会变得更加适应。像许多课程一样,本课程要求你在开始时做一点这样的事情,以便能够顺利开始。

语言是手段,而非目的 🎯

让我再次强调,就像在第一个视频中所做的那样,这门课程不是关于ML的。当你进入B部分时,它不是关于Racket的;C部分也不是关于Ruby的。这些是实现其他目的的手段。选择这些语言是因为它们特别适合我们想要呈现的主题。你可以用其他语言教授这些概念,但我选择这些是因为我认为它们是我们想要完成的任务的特别好的工具。让我在这里介绍课程时明确指出,特别是标准ML,并不是当今用于真实软件的流行语言。这没关系,因为在本课程的作业中,我们并不是试图构建真实的软件,而是试图为你未来在其他语言中构建更好的真实软件打下基础。因此,有一些密切相关的语言非常活跃、有效,并且在今天仍然更常用。从与标准ML的相似度递减来看,你有OCaml、F#、Scala和Haskell。在某种程度上,它们都可以作为本课程A部分的合适选择。我认为标准ML是稍好的选择。事实上,我可以(并在此简要地)论证,选择一种没有很多现代库、在现代软件生态系统中不做很多事情的语言,是一个特点而不是缺陷。这样,我们就可以只专注于核心思想。我们不会被尝试做任何花哨的事情所分心,也不会遇到现代软件开发中常见的许多复杂情况。我们可以专注于标准ML,它在许多方面,即使与这些其他优秀的语言相比,也是干净、组合和优雅的,我们将在本课程的第一周就开始看到这一点。

课程形式:实践为主 📝

我还要强调,这些介绍性视频有很多PowerPoint上的文字,我只是在告诉你一些东西。这不是大多数课程的工作方式。我认为你通过编写代码和尝试来学习软件,你会看到我们在很多视频中都是这样做的。视频中有关于内容的快速测验问题。简而言之,我实际上认为本课程的大多数视频都比介绍性视频更好,但介绍性内容只是你需要完成的事情,我们很快就会进入精彩的部分。

总结 📚

本节课中,我们一起学习了这门编程语言课程的核心目标与动机。课程旨在揭示几乎所有编程语言共有的基本概念,并使用标准ML、Racket和Ruby三种语言作为教学工具,以突出这些概念。我们强调了函数式编程的重要性,并解释了课程动机内容将延迟呈现的原因。课程初期可能会面临新语言、新工具带来的挑战,但这有助于建立扎实的基础。最后,我们明确了所学的语言是手段而非目的,真正的目标是掌握普适的编程语言概念,以提升在任何环境下的编程能力。

004:课程背景要求 🎯

在本节课中,我们将讨论学习本课程所需的背景知识。这是一个非常重要但难以一概而论的话题,因为每位学习者的背景各不相同。我们将明确课程定位,帮助你判断自己是否适合学习。

课程定位:承上启下

上一节我们介绍了课程的整体情况,本节中我们来看看具体需要哪些预备知识。本课程并非编程入门课,课程设计假设你已有其他语言的编程经验。然而,这也不是一门高级编程语言课程。它通常面向大学二年级学生,既不需要多年行业经验,也非高年级本科课程。因此,你需要有编程基础,但不必是专家。

必备的编程概念 📚

以下是学习本课程前你应该接触过的一些核心编程概念。这些概念是理解后续内容的基础。

  • 变量:用于存储数据。
  • 函数/方法:接收参数并返回结果的可执行代码块。
  • 条件分支:例如 if 语句,用于在不同情况下执行不同操作。
  • 循环:用于重复执行某段代码。
  • 数组:一种基础的数据集合。
  • 递归:通过函数调用自身来定义计算或数据结构。本课程将大量使用递归,如果你从未接触过,初期可能会感到吃力。
  • 接口与实现分离:代码的使用者只需了解高级功能,而不应依赖具体的实现细节。这常被称为抽象模块化
  • 基础数据结构:如链表和二叉树。
  • 动态分发/方法重写/子类化:这些面向对象概念在课程后期才会用到,届时会进行复习。

如果你对以上大部分概念都有所了解,那么你的背景是合适的。

编程语言经验无关紧要 🌐

你可能会注意到,我并未强调需要哪种特定的编程语言经验,因为这并不重要。课程材料有时会以Java或C#背景为例进行对比,但这并非必须。如果你只熟悉Python或JavaScript,也完全没问题。关键在于理解上一节列出的概念。

课程中偶尔会有与Java或C(一种底层语言)对比的可选视频,但这些始终是可选内容。我们之所以能从零开始,特别是课程早期,恰恰是因为我们将使用ML语言,它的风格与你熟悉的语言可能截然不同。这种“陌生感”是好事,意味着你之前的经验具体是什么并不重要。但如果你完全没有编程经验,则可能难以跟上进度。

代码示例:感受所需水平 💻

现在,我想通过一个例子来展示我校学生在学习这门MOOC对应课程前所能编写或理解的代码水平。请注意,如果你不熟悉Java,可能无法完全理解代码细节,但你应该能感受到其编程模式。我在讲解时会边写代码边解释概念,即使语法陌生,你所做的事情应该看起来像编程。

以下是一个Java示例,它定义了一个整数二叉树数据结构,并包含一个求所有节点之和的递归方法。

class Tree {
    int i;
    Tree left;
    Tree right;

    // 构造函数
    Tree(int i, Tree left, Tree right) {
        this.i = i;
        this.left = left;
        this.right = right;
    }

    // 递归求和方法
    int sumAll() {
        int ans = i; // 包含当前节点的值
        if (left != null) {
            ans += left.sumAll(); // 加上左子树的和
        }
        if (right != null) {
            ans += right.sumAll(); // 加上右子树的和
        }
        return ans;
    }
}

此外,这里还有一个在数组中查找最大值的方法示例:

// 假设数组arr至少有一个元素
static int maxArray(int[] arr) {
    int ans = arr[0]; // 从第一个元素开始
    for (int i = 1; i < arr.length; i++) {
        if (arr[i] > ans) {
            ans = arr[i]; // 更新找到的最大值
        }
    }
    return ans;
}

这只是我在课堂上编码风格的一个例子。当我们正式开始学习ML语言时,我会放慢速度,详细解释每一个步骤。课程的讲授方式将与此类似,但会对每一部分进行逐一拆解和说明。

总结

本节课中我们一起学习了学习本课程所需的背景知识。我们明确了课程需要你具备基础编程概念(如变量、函数、递归、抽象等),但不要求特定的编程语言经验。课程将从ML语言的基础开始,即使你感觉陌生,只要有编程基础,就能跟上节奏。我们通过Java代码示例展示了预期的理解水平。准备好开始一段探索新编程范式的旅程了吗?

005:课程分为A/B/C三部分的原因

在本节课中,我们将探讨华盛顿大学CSE341课程在Coursera平台上被分为Part A、Part B和Part C三个独立部分的原因。我们将了解这个决策背后的考量,以及这三个部分如何共同构成一个完整的课程体系。

课程分拆的主要原因

将课程分为三个部分的最主要原因是课程内容体量庞大,涵盖了大量材料。完成全部三个部分(Part A、Part B和Part C)的学习者通常需要投入100到200小时的学习时间。具体时长因人而异,取决于个人学习进度。在当前大多数在线课程的背景下,将如此大量的内容整合为一门课程显得过于庞大。

这门课程对应着我校学生在10到11周内完成的课程内容,期间他们虽然也同时修读其他几门课程,但主要精力集中于此。

分拆的挑战与课程的整体性

对我而言,将课程分拆并非易事。这好比写完一部小说后,有人表示只想阅读前几章。课程内容本是一个有机整体,各部分相互关联。只有学习到课程尾声,一些贯穿始终的主题才能完全显现其意义。Part B和Part C中的内容会巧妙地联系起来,一些对比也需要在课程后期才能进行。

我投入了大量精力来设计课程内容,同样重要的是决定哪些内容不纳入,以确保整个课程从始至终讲述一个连贯的故事。我强烈希望所有学习者都能坚持学习,直至完成Part C。

分拆的积极理由

分拆的目的并非为了创造更多收入,或让学习者误以为完成了三倍的工作量。通常我提及“这门课程”时,指的是全部内容。以下是我们决定分拆的几个积极理由:

  1. 认可学习成就:课程内容更多、更具挑战性。完成相当于三门课程的学习量,理应获得相应的认可。分拆点设在了合理的中间里程碑处,学习者到达这些节点时应感到自豪。即使选择不再继续,也已有所收获。这些是合理的暂停点。

  2. 提供灵活节奏:分拆让学习者有机会在Part A之后稍作休息。虽然学习Part A时最好保持连贯性,以便掌握所有内容,但若想在开始Part B前休息几周或一个月,分拆结构使之成为可能。这允许学习者以更灵活的节奏完成整个课程。

各部分的构成与关系

需要说明的是,课程的每个部分使用不同的编程语言进行教学。这似乎强化了“各部分是关于特定语言”的误解,而我一直在强调课程的核心并非语言本身。但现实情况确实如此划分。

另外,课程并非均分为三份。我们根据合理的停顿点进行划分,结果是Part B无疑是三个部分中体量最大的。我们是否可以将Part A再拆分为两个更小的部分?或许可以,但我没有找到自然的拆分点。

以下是具体的构成:

  • Part A:包含三次作业,以及第四个没有作业但包含考试的重要单元。因此,完成Part A相当于学完了课程的四个主要单元。
  • Part B:仅包含两个单元,约为Part A的一半大小。其中第二个单元的作业通常被认为是课程中最具挑战性也最有收获的。Part B没有单独的考试。
  • Part C:我们在此学习面向对象编程,并将其与Part A和Part B中的函数式编程进行对比。它包含另外两次作业和一次期末考试。这次考试主要关注Part B和Part C的内容,但也会少量涉及与Part A的对比,因为在课程结束时进行这样的对比是恰当的。

总而言之,这是一门分为三个部分的完整课程。有一点或许显而易见,但必须指出:无法在不学习Part A的情况下学习Part B,也无法在不学习Part A和Part B的情况下学习Part C,因为后续部分经常需要回顾之前的内容。

这就是我们将课程分拆的来龙去脉。我们希望你能以最适合自己的方式,充分利用这些学习材料。

本节课中,我们一起学习了CSE341课程分为A、B、C三部分的原因。我们了解到这主要是由于课程体量庞大,分拆既能合理认可学习者的阶段性成就,也提供了更灵活的学习节奏。同时,我们明确了三个部分在内容上的构成与紧密的递进关系,它们共同构成了一个从函数式编程到面向对象编程,并进行对比的完整知识体系。

006:评分政策详解 📝

在本节课中,我们将详细介绍本课程的评分机制。了解评分政策有助于你规划学习进度,并明确作业与考试的提交要求。

概述

课程评分主要基于作业的自动评分、同伴互评以及两次考试。核心目标是鼓励你深入理解材料,而非仅仅通过测试。以下我们将逐一解析各项政策。

自动评分政策

上一节我们概述了课程结构,本节中我们来看看作业的自动评分机制。

对于每次作业,你首先需要将其提交给一个自动评分器。这是一个计算机程序,它会检查你的作业,在测试用例上运行你的代码,分析你使用的语言特性,并给出一个分数。我们的评分标准比许多慕课平台更为严格。

基本要求是,你需要获得80%的分数才能通过。这是一个合理的门槛。虽然我们鼓励你努力达到100%,但80%是及格线。此外,我们规定每天最多只能提交一次作业。

这个政策意味着你需要一次性完成整个作业。我们不鼓励你逐个问题地提交并查看反馈。这样设计有教学上的考量,旨在模拟传统大学校园的作业模式:你在周末完成作业,尽最大努力做好所有部分,然后一次性提交。

这个政策最不便之处在于,如果你的代码因微小错误无法编译,或者上传了错误的文件,你将不得不等到第二天才能重新提交。这是该政策的代价,我们为此提前致歉,但目前没有完美的解决方案。不过,你只需等待一天,这比传统大学课程的限制要宽松得多。

你可以每天提交一次,但我们强烈建议你:先完成作业,确保自己对其正确性有信心,然后再提交。之后如果你想尝试获得更高分数,当然也有机会。需要指出的是,本慕课过去曾有更严格的提交次数限制(仅限两次),但现在的模式允许你持续学习直至掌握内容,因此每天一次的提交政策实际上相当宽松。

政策背后的理念

了解了自动评分的基本规则后,我们来探讨一下制定这些规则背后的原因。

与许多在线课程不同,本课程不希望你将自动评分器作为替代自己理解和测试的工具。我们不希望你反复微调代码,依赖评分器来发现错误。

我们相信,学习这门材料的最佳方式是专注于作业本身,独立思考各部分如何组合,并判断一切是否正确。作业中提供了大量帮助,例如函数应有的类型和示例测试用例,以便你自己进行推理。自动评分器应仅在你完成后用作最终检查。

如果我们允许你随意使用自动评分器,那么获得满分会容易得多——你只需逐个修正错误,直到通过所有测试用例。但这并非本课程所倡导的学习方式。

当然,没有完美的评分政策。当前政策是在权衡各种因素后,为所有学员选择的最佳折中方案。

其他评分细节

除了自动评分,课程中还有其他几个重要的评分组成部分。

以下是关于挑战题和同伴互评的详细信息:

  • 挑战题:作业中包含一些挑战性问题。它们占分很少,仅作为少量加分项。理想情况下,我们希望为完成所有常规题和挑战题的同学给出超过100%的分数(例如104%)。但截至目前,Coursera平台的基础设施限制我们无法在官方成绩记录中给出超过100%的分数。你会在文本反馈中看到相关评价,并应为此感到自豪,但官方课程分数上限为100%。

  • 同伴互评:这是本课程非常重要的一部分。在你通过作业的自动评分(达到80%)后,你需要再次提交作业,以供课程中的其他学员审阅你的代码风格并提供反馈。同时,你也需要为其他三位学员的提交提供风格反馈。
    你可以在Coursera平台上随时参与同伴互评,但必须在你通过自动评分后才能正确进行。这是因为包含详细互评标准的文件同时也包含参考答案,该文件只会在你达到自动评分门槛后才对你开放,以避免提前看到答案。
    同伴互评本身对最终成绩影响不大,仅占整体课程成绩的约10%。任何在互评中获得的分数都足以让你继续课程。

我们引入同伴互评是因为仅通过自动评分无法全面体现课程重点。测试用例并非全部,编写清晰、优雅并能证明你理解课程概念的代码同样重要。我们观察到,学员通过查看他人的解决方案以及获得同伴的反馈,能学到很多。因此,互评的目的是创造一个建设性的互助机会,而非苛刻的评分系统。我们已尽力将其设计得既有意义,又不会过于耗时。

考试政策

最后,我们来了解一下课程的考试安排。

课程共有两次考试:一次在A部分结束时,另一次在C部分全部结束时。与编程作业类似,考试每天最多只能参加一次,且需要获得至少80%的分数才能通过。

考试是限时的。尽管在录制本视频时,Coursera平台可能不强制限时,但作为诚信体系的一部分,需要你自觉遵守。我们希望你先复习,然后参加考试。如果因为复习方向有误而成绩不理想,你还有机会再次参加。我们不希望你打开考试后,再逐个查找答案并花费数小时完成,因为我们认为为考试而复习的过程,比你仅仅在考试中答题能学到更多。

关于考试的具体形式,我们将在后续章节提供模拟考试,让你很好地了解题型。这是一个以不同于编写程序和参与自动评分/同伴互评的形式来深入参与课程材料的机会。

总结

本节课中我们一起学习了本课程的完整评分政策,包括:

  1. 作业自动评分需达到80%,且每天限提交一次
  2. 政策旨在鼓励独立思考和深入理解,而非依赖评分器。
  3. 挑战题为加分项,但总分上限为100%。
  4. 同伴互评在通过自动评分后开放,侧重于代码风格,对总成绩影响较小。
  5. 两次考试同样要求80%及格,且为限时考试,旨在检验学习成果。

虽然详细讲解这些政策有些繁琐,但我们坚信,完成作业和考试(包括作业的同伴互评)是学习这门材料的绝佳方式。通过动手实践,你所学到的将远超过仅仅观看视频。希望你能享受这些富有挑战性的练习,它们将拓展你的思维并助你收获颇丰。

007:课程高阶概述 🗺️

在本节课中,我们将对课程的整体结构进行一个高阶概述。这有助于你了解我们将要学习的内容,并为你接下来的学习之旅提供一个清晰的路线图。

概述

在正式开始课程之前,我想先简要介绍我们即将踏上的旅程。本节内容是可选的,因为你可以直接开始学习,在实践中了解课程内容。同时,由于我们尚未学习相关术语,现在深入讨论课程细节可能会让你感到困惑。本课程的目的正是为了让你逐步学习这些概念。因此,如果你现在不理解某些术语,请不要担心,这正是你继续学习的理由。

接下来,让我们从宏观层面了解一下课程结构。本课程分为A、B、C三个部分,每个部分都包含多个单元,我们将循序渐进地学习编程语言的核心概念。

第一部分:A部分

上一节我们介绍了课程概述,本节中我们来看看A部分的具体内容。A部分将专注于在静态类型语言ML中学习函数式编程的基础知识。

以下是A部分的核心单元:

  • 单元1:基础:我们将从基础开始,学习变量、作用域、数字和加法等概念。虽然你可能在其他编程语言中接触过这些,但我们将以一种更精确的方式学习。我们将逐步构建函数(类似于面向对象中的方法,但没有对象)、递归(这是我们编写迭代计算和其他计算的主要方式),以及使用元组和列表构建更大的数据结构。考虑到这是在一个新环境中学习,我们为这个单元安排了更多的时间。

  • 单元2:数据类型与模式匹配:这是一个大多数同学可能从未见过的新概念。我们将学习一种新的方式来表示和访问复杂的数据结构,为不同类型的数据建模。模式匹配是遍历复合数据结构的绝佳方式。此外,我们还将学习尾递归,这是在现代语言中高效使用递归的关键。

  • 单元3:一等函数:许多人认为这是函数式编程最重要的特性。我们将学习如何将函数作为值来传递、从函数中返回,甚至将其放入数据结构中。这是一个庞大且意义深远的主题。

  • 单元4:类型推断与模块:在A部分的最后一个单元,我们将学习类型推断——编译器如何为我们推断类型,使得程序员无需手动标注。同时,我们还将学习ML的模块系统,它支持将类型、值和相关函数组织在一起,强制客户端无法误用抽象,这体现了接口与实现分离的思想。

总的来说,A部分将通过几个关键模块,为你提供一次精确、循序渐进的函数式编程入门。

第二部分:B部分

在掌握了静态类型函数式编程的基础后,B部分将带我们进入一个不同的领域。

以下是B部分的核心单元:

  • 单元5:动态类型语言:我们首先会在动态类型语言Racket中,重新实现A部分的大部分内容。我们将探讨当程序在运行前很少被拒绝,而是依赖运行时失败时,编程方式会发生什么变化,以及其中的权衡取舍。

  • 单元6:语言实现与类型对比:在这个单元,我们将通过实现一个解释器来实际构建一个编程语言。这将很好地展示编程语言及其实现的含义,并让你亲身体验实现一个支持一等函数的语言的过程。此外,我们还将对比静态类型与动态类型,在分别体验了ML和Racket之后,我们可以就这一重要的设计维度展开深入的讨论。

第三部分:C部分

最后,C部分将探讨面向对象编程,并与之前学到的函数式范式进行比较。

以下是C部分的核心单元:

  • 单元7:Ruby与面向对象基础:我们将使用Ruby语言学习面向对象编程的基础。作为我们的第三门语言,你会发现我们可以学得很快。我们将重点关注Ruby如何比Racket更具动态性,以及它如何比Java、C#等语言更加“纯粹”地面向对象(在Ruby中,一切皆为对象,包括数字)。

  • 单元8:函数式与面向对象对比:在课程的最后一个单元,我们将比较函数式编程与面向对象编程。我们会探讨一些更高级的面向对象主题,如混入和双重分派。这使得课程的最后一个作业颇具挑战性,因为我们将使用一些非平凡的面向对象惯用法来比较这些思想。

我强烈建议你完成C部分。有些人可能认为自己已经了解面向对象编程,只是为了学习函数式内容而来,因此想跳过C部分。但我鼓励你继续学习,正是为了进行这种比较和对照。需要指出的是,本课程会有意强调函数式视角,因为这是大家相对不熟悉的范式。课程的目标不是宣称某种方法总是优于另一种,而是为你提供大量的思考素材和新的视角。

总结

本节课中,我们一起学习了CSE341课程的高阶概述。我们了解到课程分为A(静态类型函数式)、B(动态类型与语言实现)、C(面向对象与范式对比)三个部分,每个部分都旨在循序渐进地拓宽我们对编程语言设计的理解。现在,概述结束,让我们在下一个视频中正式开始第一单元的学习。

008:软件安装介绍 🛠️

在本节课中,我们将学习如何为编程语言课程安装必要的软件。虽然本节视频是可选的,但我们准备了详细的安装指南和演示视频,以帮助那些可能遇到困难的学员顺利完成环境配置。

概述

本课程开始前,您需要安装一些特定的软件。我们为不同的操作系统提供了详细的安装说明。尽管安装过程通常比较简单直接,但为了解答可能出现的疑问,我们录制了演示视频,展示在Windows系统上的完整安装步骤。请注意,对于Mac或Linux系统,安装步骤可能略有不同,但说明文档已涵盖这些内容。如果您在安装过程中遇到问题,课程讨论区是寻求帮助的好地方。

软件安装说明

以下是获取和遵循软件安装指南的步骤。

  1. 访问课程网页:首先,请访问课程官方网站。
  2. 查找安装页面:在网页上,找到并点击关于“软件安装”的页面。
  3. 下载指南文件:在该页面中,您会找到一个文件的链接。该文件包含了针对不同操作系统和软件版本的详细安装说明。

关于演示视频

上一节我们介绍了如何获取书面安装指南,本节中我们来看看配套的演示视频。

我们制作这些视频的目的是提供直观的安装演示。讲师将在镜头前,一步步完成软件的安装过程。

  • 视频内容:这些视频将展示在Windows操作系统上的安装过程。
  • 适用性说明:虽然演示基于Windows,但安装说明也适用于Mac和Linux系统。不同系统间的步骤差异已在书面指南中注明。
  • 寻求帮助:如果在安装过程中有任何疑问,欢迎在课程讨论区提问,其他学员和助教很乐意提供帮助。

软件安装范围

请注意,本系列视频中安装的软件将覆盖课程最初几周的学习需求。在后续的课程周次中,我们还需要安装一些额外的软件,不过那些软件的安装过程通常会更为简单。

总结

本节课中我们一起学习了如何为编程语言课程准备软件环境。我们介绍了获取详细安装指南的途径,并说明了配套演示视频的作用和适用范围。请务必根据您的操作系统完成软件安装,以便顺利开始后续的编程学习。

009:Windows系统安装指南

在本节课中,我们将学习如何在Windows操作系统上安装Emacs文本编辑器。整个过程包括下载安装包、解压文件、运行安装程序以及首次启动验证。

下载Emacs安装包

首先,我们需要获取Emacs的Windows版本安装文件。以下是具体步骤:

  1. 打开包含安装说明的PDF文档,找到Emacs安装章节。
  2. 复制或点击提供的下载链接。如果直接点击无效,请手动将链接地址复制到浏览器地址栏中。
  3. 浏览器将开始下载一个约50MB的.zip压缩文件。请等待下载完成。

解压安装文件

下载完成后,我们需要找到并解压该文件。以下是操作流程:

  1. 根据浏览器的设置,找到下载文件的位置(例如桌面)。
  2. 右键点击下载的.zip文件。
  3. 从右键菜单中选择“全部提取”或使用你喜欢的解压软件。
  4. 在弹出的窗口中点击“提取”。解压过程可能需要几分钟时间。

准备安装目录

解压完成后,你会看到一个名为Eax 242 bin I 386的文件夹。接下来,我们需要将其放置到最终的安装位置。

  1. 打开解压后生成的文件夹。
  2. 将其中的主文件夹剪切出来。
  3. 粘贴到你希望永久安装Emacs的目录(例如C:\Program Files或桌面)。请确保选定后不再移动此文件夹。

运行安装程序

现在,我们可以运行安装程序来完成设置了。

  1. 进入你上一步放置的文件夹,打开其中的bin子文件夹。
  2. 找到并运行名为addd PMm的程序(可能是addpm.exe或类似名称)。
  3. 当系统询问是否要安装Emax时,选择“是”。
  4. 安装程序会识别你已选择的目录,并快速完成安装。

首次启动与验证

安装完成后,让我们首次启动Emacs以验证安装成功。

  1. 点击Windows“开始”菜单。
  2. 在搜索框中输入“emax”。在Windows 7系统中,可以直接选择顶部出现的选项。
  3. 或者,依次进入“所有程序” -> “Ganew Eax”文件夹,点击其中的Emacs程序。
  4. 首次运行时,系统可能会询问是否运行此程序。这是安全的,可以取消勾选警告框,然后选择“运行”。

当你看到Emacs的编辑界面成功显示时,说明Emacs已经安装完成。


本节课中,我们一起学习了在Windows系统上安装Emacs的完整流程:从下载安装包、解压文件、配置安装目录到运行安装程序并最终成功启动。现在,你可以开始使用这款强大的文本编辑器了。

010:SML安装指南 🛠️

在本节课中,我们将学习如何安装Standard ML(SML),特别是其编译器“Standard ML of New Jersey”。本教程将引导您完成在Windows操作系统上的安装步骤,并验证安装是否成功。

下载安装文件

首先,我们需要访问SML的官方网站以下载安装文件。无论您使用何种操作系统,都可以通过以下URL访问下载页面。如果您无法直接点击链接,请将其复制到浏览器地址栏中。

https://www.smlnj.org/dist/working/110.75/index.html

在网页的Windows部分,您会找到一个.msi文件。点击该文件,浏览器会提示您下载。请保存该文件到您的计算机。

运行安装程序

下载完成后,找到下载的.msi文件并双击运行。如果出现安全提示,请点击“运行”以继续。

安装程序将启动一个安装向导。所有默认设置都适用于大多数用户,因此您可以一直点击“下一步”直到安装完成。

验证安装

安装完成后,我们需要验证SML是否正确安装。为此,我们将打开命令提示符并运行SML。

您可以通过以下方式打开命令提示符:

  • 在开始菜单中搜索“cmd”并打开。
  • 或者,依次进入“所有程序” > “附件” > “命令提示符”。

打开命令提示符后,输入以下命令并按回车键:

sml

如果安装成功,您将看到类似以下的提示信息:

Standard ML of New Jersey Version 110.75

这表明SML已成功安装并可以运行。

测试SML

为了进一步确认SML工作正常,我们可以在SML交互环境中执行一个简单的算术运算。在SML提示符下,输入:

1 + 1;

按回车后,您应该看到输出:

val it = 2 : int

这表示SML能够正确执行代码。

退出SML

完成测试后,您可以通过以下方式退出SML交互环境:

  • 在Windows上,按Ctrl + Z,然后按回车键。
  • 或者,输入OS.Process.exit(OS.Process.success)并按回车。

总结

本节课中,我们一起学习了如何在Windows系统上安装Standard ML of New Jersey编译器。我们完成了从下载安装文件、运行安装程序到验证安装的完整步骤。现在,您已经成功安装了SML,并可以开始使用它进行编程学习。

011:SML模式安装指南 🛠️

在本节课中,我们将学习如何在Emacs编辑器中安装并配置SML模式。这将使Emacs成为一个方便编写和运行SML程序的工具。

概述

我们已经安装了Emacs和SML编译器。现在,我们需要配置Emacs,使其能够高效地编辑和运行SML代码。本节将指导你完成SML模式的安装与基本使用。


启动Emacs并安装SML模式

首先,我们需要启动Emacs编辑器。在终端中输入以下命令:

emacs

启动后,你可能会看到与我演示中不同的颜色或界面布局,这取决于你的个人设置。例如,你的背景可能是白色,这完全正常。

现在,我们将在Emacs内部安装SML模式。以下是具体步骤。

列出可用包

在Emacs中,我们需要运行一个命令来列出可安装的包。对于Windows键盘,按下 Alt + X,然后输入:

list-packages

或者直接输入 list-packages 并按下回车。如果你的Emacs版本是24或更高,并且已连接到互联网,这个命令将显示一个易于安装的包列表。

安装SML模式

在包列表中,使用鼠标或键盘向下滚动,找到名为 sml-mode 的包。点击它,Emacs屏幕将分成两个缓冲区。在其中一个缓冲区中,点击“安装”按钮。系统会询问你是否确认安装,选择“是”。大约一分钟后,安装完成。

重启Emacs

安装完成后,退出Emacs。使用快捷键 Ctrl + X,然后 Ctrl + C。之后,重新打开Emacs,以确保所有更改生效。


验证SML模式安装

现在,我们需要验证SML模式是否已正确安装并配置。以下是验证步骤。

创建SML文件

首先,创建一个SML文件。你可以使用Emacs的快捷键 Ctrl + X Ctrl + F 并输入文件名,或者直接在桌面上创建一个文件。例如,创建一个名为 test.sml 的文件。确保文件扩展名为 .sml,这样Emacs才能识别它为SML文件。

编辑SML文件

将文件拖入Emacs中打开。在Emacs底部的模式行中,你应该看到类似“SML”的提示,表示当前正在编辑SML文件。现在,你可以编写SML代码。例如:

val x = 3 + 4;
val y = x * 5;

在编辑时,你会注意到代码有语法高亮显示。按下Tab键可以自动缩进,使代码更易读。保存文件时,使用快捷键 Ctrl + X Ctrl + S

运行SML代码

要在Emacs中运行SML代码,确保你正在编辑一个 .sml 文件。然后,按下 Ctrl + C Ctrl + S。Emacs会提示你输入SML命令,输入 sml 并按下回车。此时,Emacs屏幕将再次分成两个缓冲区,底部会出现SML的交互式环境(REPL)。

在REPL中,你可以输入SML代码并立即看到结果。例如,输入 1 + 1; 将返回 2。此外,你还可以加载之前创建的SML文件。输入以下命令:

use "test.sml";

如果一切正常,系统将输出 xy 的值,分别为 735。这表明SML模式已成功安装并可以正常工作。


总结

在本节课中,我们一起学习了如何在Emacs中安装和配置SML模式。通过安装SML模式,我们使Emacs成为一个强大的SML编程环境,具备代码高亮、自动缩进和直接运行SML程序的功能。安装完成后,我们验证了配置的正确性,确保可以顺利编写和运行SML代码。现在,你已经准备好使用Emacs进行SML编程了!

012:变量绑定与表达式 🧠

在本节课中,我们将学习 ML 语言的基础:如何通过变量绑定和表达式来编写程序。我们将从创建一个简单的程序开始,理解其语法和语义,并学习如何运行它。


概述

我们将从零开始,学习 ML 编程的核心概念。首先,我们会创建一个包含变量绑定的程序文件,然后使用 ML 的交互式环境(REPL)来运行它。我们将重点理解程序的语法(如何书写)和语义(其含义),后者又分为类型检查(程序运行前的静态分析)和求值(程序运行时的动态计算)。


创建第一个程序

上一节我们介绍了本课程的目标,本节中我们来看看如何实际创建一个 ML 程序。

首先,我们打开一个文本编辑器(例如 Emacs),创建一个新文件,文件扩展名必须是 .sml

(* 这是一个注释,也是我们的第一个程序 *)
val x = 34;
val y = 17;

这段程序做了两件事:

  1. 使用关键字 val 声明一个名为 x 的变量,并将其绑定到整数值 34
  2. 使用关键字 val 声明一个名为 y 的变量,并将其绑定到整数值 17

在 ML 中,val 是引入变量的关键字,= 是声明语法的一部分,分号 ; 表示一个绑定的结束。3417表达式中最简单的一种:整型常量。


运行程序

现在我们已经有了一个程序(一系列变量绑定),接下来看看如何运行它。

我们使用 ML 的读取-求值-打印循环(REPL)来运行程序。在 REPL 中,我们可以使用 use 命令来加载并执行我们编写的 .sml 文件。

use "first.sml";

执行后,REPL 会输出:

  • 它创建了值 x,其内容为 34,类型为 int(整数)。
  • 它创建了值 y,其内容为 17,类型为 int

之后,我们可以在 REPL 提示符下直接使用这些变量,例如输入 x; 会返回 34,输入 x + y; 会返回 51


理解绑定序列的含义

上一节我们运行了一个简单的程序,本节中我们来深入理解程序中多个绑定的执行顺序和含义。

我们可以在后续的绑定中使用之前已定义的变量。例如:

val x = 34;
val y = 17;
val z = x + y + y + 2;

以下是程序执行时,动态环境(程序运行时的变量状态)的变化过程:

  1. 初始环境为空。
  2. 执行 val x = 34; 后,环境变为 {x -> 34}
  3. 执行 val y = 17; 后,环境变为 {x -> 34, y -> 17}
  4. 执行 val z = ...; 时,在当前环境下对表达式 x + y + y + 2 进行求值。查找 x34,查找 y17,计算 34 + 17 + 17 + 2 得到 70。因此,环境最终变为 {x -> 34, y -> 17, z -> 70}

重要规则:在绑定序列中,只能使用之前已定义的绑定,不能使用之后的绑定。这保证了程序含义的清晰和可预测性。


类型检查:程序运行前的保障

在程序实际运行(求值)之前,ML 编译器会先进行类型检查。这是一个静态分析过程,用于确保程序没有类型错误(例如,尝试对非数字进行加法运算,或使用未定义的变量)。

检查过程依赖于静态环境,它记录每个变量的类型。对于上面的程序:

  1. 34int,所以 x 的类型是 int
  2. 17int,所以 y 的类型是 int
  3. 检查 z 的表达式 x + y + y + 2:加法要求两边表达式都是 int 类型。查找静态环境,xy 都是 int2 也是 int,因此整个表达式类型为 intz 的类型也是 int

只有所有绑定都通过类型检查,程序才会进入求值阶段。这能提前捕获许多错误。


更多表达式类型:条件表达式

上一节我们介绍了整数和加法表达式,本节中我们来看看另一种重要的表达式:条件表达式(if-then-else)。

val abs_of_z = if z < 0 then 0 - z else z;

这个绑定计算变量 z 的绝对值。

  • 求值过程:首先对 ifthen 之间的条件 z < 0 进行求值。在当前动态环境中,z7070 < 0 的结果是 false。因此,程序会跳过 then 分支 (0 - z),直接求值 else 分支 (z),得到结果 70。所以 abs_of_z 被绑定到 70
  • 类型检查过程:条件 z < 0 必须产生一个布尔类型 (bool) 的值。< 操作符在接收两个 int 参数时返回 boolthenelse 两个分支的表达式可以是任意类型,但它们的类型必须相同。这里两个分支都是 int 类型,因此整个 if 表达式的类型是 int

ML 也内置了绝对值函数 abs,我们可以直接写 val abs_of_z = abs z;,括号是可选的。


核心概念总结

本节课中我们一起学习了 ML 编程的基石:变量绑定和表达式。

  1. 变量绑定语法

    val <变量名> = <表达式>;
    
  2. 程序语义分为两个阶段:

    • 类型检查(静态语义):在程序运行前,使用静态环境分析表达式类型,确保一致性。
    • 求值(动态语义):在程序运行时,使用动态环境计算表达式的值。
  3. 表达式求值规则

    • 常量(如 34):值就是其本身。
    • 变量(如 x):在当前动态环境中查找其值。
    • 加法(如 e1 + e2):先分别求值 e1e2,再将结果相加。
    • 条件表达式(如 if e1 then e2 else e3):先求值 e1,若为 true 则求值 e2,否则求值 e3

理解每个表达式的语法类型检查规则求值规则是掌握 ML 的关键。在接下来的课程中,我们将继续学习更多类型的表达式和它们的规则。

013:表达式规则详解 🧮

在本节课中,我们将深入学习编程语言中表达式的语法、类型检查和求值规则。我们将通过一个具体程序示例,详细解析变量、加法、条件表达式等核心概念,并确保为每种表达式提供精确的语义定义。

概述

上一节我们介绍了程序的基本结构,本节中我们将仔细分析程序中出现的各类表达式,并为其定义精确的语法和语义规则。通过这种方法,我们能为后续学习更复杂的语言特性打下坚实基础。

程序示例

我们使用与上一节相同的程序作为分析对象。该程序包含多种表达式类型,例如字面量(如34)、变量(如x)、加法运算等。此外,程序执行过程中还会产生布尔常量true和false,这些是条件表达式求值的结果。

在REPL环境中,我们可以验证这些表达式的行为。例如,3 < 0求值为布尔值false。如果我们在环境中绑定了变量,也可以在比较表达式中使用它们,例如x < y

表达式的递归性质

表达式的一个重要特性是它们可以任意嵌套组合。例如,加法表达式可以包含另一个加法表达式,条件表达式可以包含加法运算,反之亦然。这种嵌套可以无限深入,从而构建出任意复杂的表达式。

因此,在定义表达式的语法和语义时,我们必须采用递归定义,因为大表达式的定义依赖于其子表达式的定义。

核心分析方法:三个关键问题 🎯

对于每一种表达式,我们都将遵循相同的方法论,回答以下三个核心问题:

  1. 语法:如何书写这种表达式?
  2. 类型检查规则:表达式具有什么类型?什么情况下会导致类型检查失败并报错?
  3. 求值规则:假设类型检查通过,表达式如何进行计算以产生结果(我们称这个结果为“值”)?

需要注意的是,表达式求值可能不会产生结果,也可能在运行时引发异常或进入无限循环。

变量表达式

让我们从最简单的表达式——变量开始。

  • 语法:变量是由字母、数字或下划线组成的序列,但首字符不能是数字。这与许多编程语言的变量命名规则类似。
  • 类型检查规则:当在表达式中使用一个变量(而非在变量绑定中定义它)时,我们会在静态环境中查找该变量。如果找到,则该表达式的类型就是环境中该变量的类型。如果未找到,则类型检查失败并报错。
  • 求值规则:与类型检查类似,但我们在动态环境中查找变量,并使用在那里找到的值。在ML语言中,我们只运行通过类型检查的程序,因此可以确保变量一定存在于动态环境中。

加法表达式

加法表达式是我们遇到的第一个包含子表达式的例子。因此,所有问题的答案都必须依赖于其子表达式的相应答案。

  • 语法:加法表达式由两个子表达式(E1和E2)以及中间的加号(+)组成。
  • 类型检查规则:必须对两个子表达式E1和E2分别进行类型检查。如果它们都具有int类型,那么整个加法表达式也具有int类型。如果其中任何一个类型检查失败或类型不是int,则整个表达式类型检查失败。
  • 求值规则:首先对两个子表达式E1和E2进行求值,得到值V1和V2。然后,整个表达式的结果就是V1和V2的和。得益于类型检查,我们知道V1和V2一定是整数,因此求和操作总是可行的。

值与字面量

求值的结果称为“值”。每个值本身也是一个表达式,但并非每个表达式都是值。

值是一类特殊的表达式,它们总是求值为自身。例如,42求值为42true求值为true。在REPL中使用use命令甚至会产生一个类型为unit的值()

对于每种类型,都存在一组特定的值,它们就是该类型表达式求值后得到的答案。

以整数34为例:

  • 语法:一个数字序列。
  • 类型检查规则:它具有int类型。
  • 求值规则:它是一个值,因此求值结果就是它自身。

条件表达式(if表达式) ⚖️

条件表达式更为复杂。其语法、类型检查和求值规则如下:

  • 语法if E1 then E2 else E3,其中ifthenelse是关键字,E1、E2、E3是子表达式。
  • 类型检查规则
    1. 首先,E1必须具有bool类型(即其求值结果必须是truefalse)。
    2. E2和E3可以是任意类型,但它们必须具有相同的类型(我们称之为类型T)。因为整个表达式的结果可能是E2,也可能是E3,所以它们类型必须一致。
    3. 整个条件表达式的类型也是T。
  • 求值规则
    1. 首先对E1求值,得到值V1。由于类型检查确保E1为bool类型,因此V1必定是truefalse
    2. 如果V1是true,则对E2求值,其结果即为整个表达式的结果。
    3. 如果V1是false,则对E3求值,其结果即为整个表达式的结果。

以上就是关于if表达式的语法、类型检查和求值规则的全部内容。

练习:小于比较表达式

作为练习,请尝试自行推导小于比较表达式(如x < y)的语法、类型检查和求值规则。如果遇到困难,可以参考课程相关的阅读笔记。

请记住,对于所有这些规则,细节本身不如我们始终遵循的“三问”方法论重要:如何书写(语法)?类型检查规则是什么?求值规则是什么?

总结

本节课中,我们一起学习了如何为编程语言中的表达式定义精确的语义。我们掌握了分析任何表达式的通用方法,即回答关于其语法、类型检查和求值规则的三个核心问题。我们具体分析了变量、加法运算、字面量以及条件表达式,理解了简单表达式如何组合成复杂的嵌套结构。这种方法论是我们后续学习更高级语言特性的基石。

014:REPL与错误处理 🐞

在本节课中,我们将学习两个非常实用的主题:如何有效地使用ML的REPL(读取-求值-打印循环),以及如何处理和理解ML中的错误信息。我们将通过具体示例,帮助你掌握调试程序的基本技能。

概述

上一节我们介绍了ML的一些核心概念。本节中,我们来看看如何在实际编程环境中运行和测试我们的代码,并学习如何解读和修复常见的错误。

使用REPL运行程序

REPL代表“读取-求值-打印循环”。它的工作流程是:读取你输入的代码,对其进行求值(如果类型检查通过),打印结果,然后循环回到提示符等待下一次输入。

在ML中,我们通常使用 use 表达式来运行程序文件。你可以将 use 理解为:它读取指定文件的内容,并像你手动在REPL中逐行输入这些绑定一样执行它们。

例如,对于一个包含多个变量绑定的文件 first.sml

val x = 34
val y = 17

你可以手动在REPL中输入每一行,也可以使用以下命令一次性加载:

use "first.sml";

use 命令会批量执行文件中的所有绑定,并显示每个绑定的类型和求值结果。这是一种非常方便的运行程序的方式。

高效使用REPL的技巧

在编程过程中,你可能会反复测试某些代码。为了提高效率,建议将你的测试用例整理到第二个文件中。然后,你可以先使用 use 加载主程序文件,再使用 use 加载测试文件。

需要注意的是,不建议在同一个REPL会话中重复使用 use 加载同一个文件。这可能会导致环境状态混乱,难以理解。正确的做法是:

  1. 结束当前REPL会话(例如,使用 Ctrl+D)。
  2. 重新启动REPL。
  3. 再次使用 use 命令加载文件。

这样做可以确保每次都在一个干净的环境中开始测试。

理解与调试错误

编程中难免出错。错误通常分为几类:

  • 语法错误:代码不符合ML的语法规则。
  • 类型错误:代码语法正确,但违反了类型系统的规则(例如,将整数用于需要布尔值的条件判断)。
  • 运行时错误:代码通过了类型检查,但在执行时出现问题(例如,除以零、无限循环或产生非预期的结果)。

调试的关键在于定位错误的根源。ML的错误信息有时可能不够直观,尤其是类型错误信息。这需要一些练习来掌握解读技巧。

以下是调试的基本步骤:

  1. 查看错误信息:注意错误发生的行号。
  2. 检查相关代码:错误有时发生在报告行号的前一行或附近
  3. 理解错误类型:根据信息判断是语法、类型还是运行时错误。
  4. 逐步修复:一次只修复一个错误,然后重新测试。

常见错误示例分析

让我们通过一个包含多个错误的示例文件 errors.sml 来实践一下。我们将逐一修复其中的问题。

错误1:if 表达式缺少 else 分支

  • 错误信息syntax error: inserting ELSE ID
  • 问题代码
    if true then 42 (* 缺少 else *)
    
  • 修复:ML的 if-then-else 是一个表达式,必须同时包含 thenelse 两部分。
    if true then 42 else 0
    

错误2:使用关键字作为变量名

  • 错误信息replacing FUN with WILD
  • 问题代码
    val fun = 10
    
  • 修复fun 是ML中用于定义函数的关键字,不能用作变量名。需要更换变量名。
    val funny = 10
    

错误3:绑定语句缺少 val 关键字

  • 错误信息unbound variable or constructor: x
  • 问题代码
    val x = 5
    y = x + 1 (* 这里本意是新开一个绑定,但缺少了 `val` *)
    
  • 修复:每个新的变量绑定都需要以 val 开头。
    val x = 5
    val y = x + 1
    

错误4:if 的条件表达式类型错误

  • 错误信息test expression in if is not of type bool
  • 问题代码
    if y then ... (* 假设 y 是整数 *)
    
  • 修复if 的条件部分必须是布尔(bool)类型。
    if y > 0 then ...
    

错误5:if 表达式两个分支类型不一致

  • 错误信息types of if branches do not agree
  • 问题代码
    if true then 34 else x < 4 (* then分支是int,else分支是bool *)
    
  • 修复thenelse 分支必须具有相同的类型。
    if true then 34 else 0
    

错误6:负数的错误语法

  • 错误信息expression or pattern begins with infix identifier: -
  • 问题代码
    val a = -5
    
  • 修复:在ML中,负号 - 是二元操作符。表示负数应使用波浪号 ~
    val a = ~5
    

错误7:整数除法的错误操作符

  • 错误信息operator and operand don‘t agree [real * real] * int
  • 问题代码
    val result = x / w (* 假设 x, w 是整数 *)
    
  • 修复/ 用于实数(real)除法。整数除法应使用 div
    val result = x div w
    

错误8:运行时错误——除零异常

  • 错误信息uncaught exception Div [divide by zero]
  • 问题代码
    val w = 0
    val result = x div w (* 当 w 为 0 时 *)
    
  • 修复:确保除数不为零,或在代码中进行检查。
    val result = if w <> 0 then x div w else 0 (* 示例处理 *)
    

错误9:逻辑错误——结果不符合预期

  • 现象:程序能通过编译和运行,但最终的计算结果不是你想要的值。
  • 问题代码
    val fourteen = 17 - 7 (* 程序员本意是 7 + 7 *)
    
  • 修复:仔细检查你的算法和逻辑。类型检查器无法发现意图错误,需要你通过测试来验证。
    val fourteen = 7 + 7
    

总结

本节课中我们一起学习了如何高效地使用ML的REPL环境来运行和测试程序,并深入探讨了各种类型的编程错误及其调试方法。

记住以下几点:

  1. 使用 use “filename.sml”; 来加载和运行文件。
  2. 为保持环境清晰,建议在重新加载文件前重启REPL。
  3. 遇到错误时,不要慌张。仔细阅读错误信息,定位到具体行号,并检查附近的代码。
  4. 错误信息是编译器的“最佳猜测”,最终需要你根据编程知识来判断真正的错误原因。
  5. 即使程序通过了类型检查并成功运行,也必须验证输出结果是否符合你的预期。

调试是一项通过不断实践才能熟练掌握的核心技能。不要害怕犯错,每一次修复错误的过程都是你进步的机会。

015:变量遮蔽

在本节课中,我们将要学习变量遮蔽的概念。变量遮蔽是指在一个环境中添加一个变量,而该变量在添加之前已经存在于该环境中。我们将通过这个概念来深入理解环境和变量绑定的工作原理,为后续学习更复杂的语言特性打下坚实基础。

概述

变量遮蔽是理解静态作用域和变量生命周期的重要概念。它不涉及新的语法或求值规则,而是帮助我们巩固对现有规则的理解。通过分析遮蔽现象,我们可以清晰地看到变量绑定如何在不同环境中被创建和覆盖。

变量遮蔽的基本原理

上一节我们介绍了环境和绑定的基本概念,本节中我们来看看当同一个变量名被多次绑定时会发生什么。

首先,我们在环境中添加一个绑定 a = 10。此时,在静态环境中,a 的类型是 int;在动态环境中,a 被绑定到值 10

val a = 10

如果我们在文件中继续写下 val b = a * 2,那么 b 的值将是 20

val b = a * 2 (* b 的值为 20 *)

遮蔽的发生

现在,有趣的事情发生了。如果我们写下 val a = 5

val a = 5

需要重点强调的是,b 仍然被绑定到 20。实际上,如果我接下来写下 val c = b,即使在此刻的环境中 a 映射到 5b 映射到 20,我们最终会得到一个环境,其中 a 映射到 5b 映射到 20c 也映射到 20。这是因为我们求值表达式 b 时,会在动态环境中查找它并得到 20,然后扩展动态环境,使 c 映射到这个结果。

b 之前是通过求值 a * 2 创建的,但这已不再相关。那是在 a 映射到 10 的环境中发生的,我们得到了 20,然后扩展了环境,使得 a 映射到 10b 映射到 20,故事到此为止。a * 2 这个事实已不再相关。

同样需要强调的是,val a = 5 不是一个赋值语句。在 ML 中,绝对没有办法改变或突变之前环境中 a 映射到 10 的事实。我们得到的是在后续环境中,a 现在被遮蔽了。我们在一个不同的环境中有了一个对 a 的不同映射,现在 a5

更多示例

让我们做更多示例来确保理解。如果我现在说 val d = a,我最终会得到一个包含我之前所有绑定的环境,并且现在 d 映射到 5,因为在此刻的环境中 a 映射到 5

val d = a (* d 的值为 5 *)

如果我现在说 val a = a + 1 呢?再次强调,这不是赋值。变量绑定的工作方式是,我们在当前的动态环境中求值表达式。所以 a 映射到 5,我们加 1 得到 6,然后我们创建一个新的动态环境,其中 a 映射到 6,同时包含我们之前拥有的一切。但现在我们遮蔽了 a 映射到 5 的较早环境,以及 a 映射到 10 的更早环境。

val a = a + 1 (* 新的 a 值为 6 *)

确实,如果我们现在说 val f = a * 2f 将映射到 12

val f = a * 2 (* f 的值为 12 *)

无法进行前向引用

同样需要强调的是,你不能做的一件事是前向引用,原因相同。所以如果我这里有这段代码,它将无法通过类型检查。原因是当我必须去检查表达式 f - 3 时,f 还不在环境中。所以当我在静态环境中查找它时,它不在那里,我会得到一个类型错误信息。

(* 错误示例:前向引用 *)
val f = f - 3 (* 类型检查错误:f 未定义 *)

交互环境中的注意事项

这也是为什么我坚持要求你不要对同一个文件多次使用 use 命令的原因。因为如果你对同一个文件多次使用 use,你将重复该文件第一次使用 use 时存在的所有绑定,并且这些绑定在你第二次对文件使用 use 时仍然存在。虽然在 ML 中允许这种遮蔽和重新引入绑定,但这可能会非常令人困惑。也许你删除了一个绑定,但由于之前的 use 它仍然存在;或者也许你有一些奇怪的遮蔽,使得你的代码看起来是正确的,而实际上由于多次 use 语句导致的遮蔽工作方式,它是错误的。

总结

本节课中我们一起学习了变量遮蔽。我们了解到,变量遮蔽是向已包含某变量的环境中再次添加同名绑定的行为。关键点在于:

  1. val 绑定不是赋值,它创建新的绑定并可能遮蔽旧的,但绝不改变旧的绑定。
  2. 表达式的求值总是基于当前的动态环境
  3. 被遮蔽的变量在其作用域内不再可访问
  4. 前向引用是不允许的,因为绑定在定义后才进入环境。
  5. 在交互环境中,应避免对同一文件多次使用 use,以防止由意外遮蔽引起的混淆。

理解遮蔽对于掌握静态作用域和构建对程序状态变化的准确心智模型至关重要。

016:函数初步介绍 🧮

在本节课中,我们将开始学习函数。函数是一种新的绑定形式,因此我们将更新程序的定义,使其不仅包含变量绑定序列,还允许包含变量和函数。如果你之前没有听说过“函数”这个术语,它非常类似于面向对象语言中的方法。函数接收参数,计算结果并返回该结果。这就是它的全部功能。因此,在许多方面,函数比方法更简单。我们将始终称这些为函数。

第一个函数示例

上一节我们介绍了函数的基本概念,本节中我们来看看一个具体的例子。我将切换到 Emacs 环境,展示一个关于指数运算(求幂)的简单函数。

以下是定义该函数所需的大部分代码:

fun pow (x : int, y : int) =
    if y = 0
    then 1
    else x * pow(x, y-1)

这里,fun 是关键字,pow 是定义的函数名。参数 xy 用逗号分隔,并通过冒号指定其类型为 int。等号后面是函数体。

函数体可以是任何我们想要的表达式。调用函数时,我们将计算这个表达式,其结果就是函数的返回值。对于指数运算,我使用了一个条件表达式:如果 y 等于 0,则结果为 1;否则,结果为 x 乘以 pow(x, y-1) 的调用结果。只要 y 大于或等于 0,这个函数就能正常工作。这里不处理 y 为负数的情况,这只是一个示例。

在程序中使用函数

这是一个完整的程序,可以包含在绑定序列中。我可以在它之前有一个变量绑定,之后也可以有另一个函数绑定。

例如,定义一个计算参数立方的函数:

fun cube (x : int) = pow(x, 3)

cube 的函数体本身是一个函数调用,它调用 pow 函数,参数是 cube 的参数 x 和常数 3。

我可以这样使用这些函数:

val sixty_four = cube(4)

实际上括号不是必须的,但加上它们看起来更像其他语言的风格。

或者,可以使用更复杂的嵌套表达式:

val result = pow(2, 2+2)

这里会先计算 2+2 得到 4,然后将 4 作为第二个参数传递给 pow。你甚至可以进行嵌套调用,例如 pow(2, pow(2, 2))

在 REPL 中测试

想要测试时,可以像往常一样转到 REPL。输入 use "functions.sml"; 可以加载文件并看到所有的绑定。

你会注意到,powcube 的打印输出与变量绑定不同,它们只显示“这是一个函数”。REPL 不会打印函数体,只会显示它是一个函数及其类型。

对于 pow,其类型显示为 int * int -> int。在 ML 中,函数类型的写法是:参数类型用 * 分隔(int * int),然后是箭头 ->,最后是结果类型(int)。我们不需要显式写出结果类型,ML 通过查看函数体(例如上面的条件表达式)推断出:如果 xyint 类型,那么条件表达式的结果也必须是 int 类型,因此该函数在接收两个 int 参数时会返回一个 int

类似地,cube 的类型是 int -> intsixty_fourresult 则像往常一样显示值。

现在可以在 REPL 中尝试调用函数,例如输入 cube 7; 会得到结果 343

关于函数的要点

以上是函数的非正式概念。由于这是我们正在学习的新内容,让我用幻灯片强调几个要点。

首先,pow 的函数体内部可以调用 pow 自身。这就是我们实现递归算法的方式,正如示例中所做的那样。这表明在函数体内部,可以调用函数本身。

开始编写代码时,需要注意一些潜在的陷阱,我们会遇到新的错误消息来源。特别是,如果忘记在变量名和类型之间写冒号,或者像在其他语言中那样尝试写 int x 而不是 x : int,都会导致语法错误。

另外需要指出的是,我们在 REPL 中看到的函数类型 int * int -> int 中的 * 与乘法运算符 * 不同。这只是字符的重用:在表达式中,* 表示乘法;在类型中(至少目前看来),它只是用来分隔多个参数的类型。

最后,就像变量绑定一样,函数绑定可以使用文件中更早的绑定,但不能使用文件中更晚的绑定。这是 ML 的规则。因此,如果你想用一个函数(如 pow)来定义另一个函数(如 cube),那么必须把 cube 放在 pow 之后。

这引出了一个有趣的问题:如果有两个或三个函数需要相互调用,就没有一个合适的顺序来放置它们。在未来的课程中,我会展示 ML 为这种相互递归的情况提供的特殊支持。

递归的说明

如果你对递归还不熟悉,希望至少之前见过它。你很快会在第一次作业中接触到它,几乎你写的每个函数都将是递归的,我们将看到更多例子。所以,如果 pow 的算法看起来有点神奇,请不要惊慌,它其实一点也不神奇。

让我切换回来,再次展示这个 pow 函数。我们之所以能在 pow 的定义中使用 pow,这并不循环。我们所做的是,用“求某数的 y-1 次幂”来定义“求某数的 y 次幂”。这是一个完全合理的定义:递归调用是在解决一个更简单的问题。

如果这个更简单的问题是 y 等于 0,那么我们根本不使用递归,直接返回答案 1。随着学习的深入,我们会非常熟悉这个概念。

在 ML 中,我们将始终使用递归来处理这类事情。如果你习惯用 while 循环或 for 循环来编写像求幂这样的功能,我们不会使用它们。循环常常掩盖了更简单、更优雅的算法,而递归更强大。虽然循环在许多编程语言中更方便或更高效,但我保证,任何能用循环完成的事情,都能用递归完成。在 ML 以及本课程的大部分编程中,我们将专注于递归方法。

总结

本节课中,我们一起学习了函数的基本概念。我们了解到函数是一种接收参数并返回结果的绑定形式。通过 fun 关键字定义函数,可以指定参数名和类型。函数体是一个表达式,其计算结果即为返回值。函数可以递归调用自身,这是实现许多算法的基础。我们还在 REPL 中测试了函数,并理解了 ML 如何推断函数类型。最后,我们讨论了递归的重要性,并指出在 ML 编程中将主要依赖递归而非循环。

017:函数的形式化定义 🧮

在本节课中,我们将要学习如何从形式化的角度理解函数定义与调用。我们将深入探讨函数绑定的语法、类型检查规则和求值规则,以及函数调用的相应规则。通过精确理解这些编程语言构造的含义,你将能更扎实地掌握函数的工作原理。


函数绑定:定义函数

上一节我们学习了如何定义像 PAcube 这样的函数,以及如何调用它们。本节中,我们将从更形式化的角度来审视函数定义。

语法规则

函数绑定的通用语法如下:

fun 函数名 (参数1: 类型1, 参数2: 类型2, ..., 参数N: 类型N) = 表达式

其中:

  • fun 是关键字。
  • 函数名 是一个变量,作为函数的名称。
  • (参数1: 类型1, ...) 是参数列表,每个参数由变量名、冒号和类型组成,用逗号分隔,并放在括号内。
  • = 是等号。
  • 表达式 是函数体,可以是任意复杂、嵌套的表达式。

求值规则

函数的求值规则非常简单:函数本身就是一个值。当我们遇到一个函数绑定时,我们只需将这个函数名(例如 x0)及其对应的函数值添加到动态环境中,以便后续的表达式可以调用它。此时,我们不会对函数体进行求值。函数体的求值只发生在函数被调用时。

类型检查规则

类型检查相对复杂一些。函数绑定的最终结果,是为函数名 x0 在静态环境中添加一个类型。这个类型的形式是:(T1 * T2 * ... * Tn) -> T。其中,T1Tn 是各个参数的类型,T 是返回值的类型。

以下是类型检查函数体的过程:

  1. 我们使用当前已有的静态环境来检查函数体 E,因为 E 可以使用任何之前定义的绑定。
  2. 同时,我们知道参数 x1 具有类型 T1x2 具有类型 T2,依此类推,这些信息也被加入检查 E 时的静态环境。
  3. 由于 ML 语言允许递归,函数体 E 中也可以使用函数名 x0 自身。此时,x0 在静态环境中,其类型就是整个函数的类型 (T1 * T2 * ... * Tn) -> T

需要特别注意的是:

  • 参数 x1, x2 等变量名在函数体 E 的静态环境中有效,不会添加到这个函数定义之后的代码的静态环境中。这与 Java、Python 等语言中方法参数的作用域规则一致。
  • 函数调用 x0 的求值结果就是 E 的结果,因此函数返回类型 T 就是表达式 E 的类型。类型检查器会自动推断出 E 的类型,并将其作为函数的返回类型 T,这个过程我们将在课程后续详细讨论。

函数调用:使用函数

在定义了函数之后,我们自然需要调用它。函数调用是语言中的另一个构造,同样有其语法、类型检查和求值规则。

语法规则

函数调用的通用语法是:

函数表达式 (参数表达式1, 参数表达式2, ..., 参数表达式N)

如果只有一个参数,括号可以省略;但对于零个、两个或更多参数,括号是必需的。

类型检查规则

类型检查一个函数调用需要遵循以下规则:

  1. 首先,函数表达式 (E0) 必须具有一个函数类型,即其类型形式应为 (T1 * T2 * ... * Tn) -> T
  2. 然后,我们检查所有的参数表达式 (E1 到 En)。参数的数量必须与函数定义中的参数数量一致。
  3. 接着,每个参数表达式 Ei 的类型必须与函数定义中对应参数的类型 Ti 相匹配。
  4. 如果以上都满足,那么整个函数调用的结果类型就是函数返回类型 T

例如,在递归函数中调用 PA(x, y-1) 时:

  1. 查找 PA,其类型为 (int * int) -> int
  2. 检查参数数量为 2,正确。
  3. 检查 x 的类型(在函数体静态环境中为 int)和 y-1 的类型(减法运算结果也是 int),均匹配。
  4. 因此,该函数调用的结果类型为 int

求值规则

函数调用的求值分为三个步骤:

  1. 求值函数表达式:首先对 E0 进行求值,以确定要调用哪个函数。这通常是在动态环境中查找函数名,找到对应的函数绑定。
  2. 求值所有参数:然后,从左到右(在 ML 中是严格求值)对所有的参数表达式 E1En 进行求值,得到具体的值。例如,调用 PA(2, 2+2) 时,会先得到 24 这两个值,函数体只会看到 4,而不知道它来自 2+2 这个加法运算。
  3. 求值函数体:最后,在函数定义时的动态环境基础上进行扩展,将参数名 (x1, x2, ..., xn) 分别绑定到本次调用求值出的参数值 (v1, v2, ..., vn) 上,然后在这个扩展后的动态环境中对函数体 E 进行求值。对于递归函数,函数体中的递归调用也指向函数自身。

例如,调用 PA(2, 4) 时,我们会在一个动态环境中求值函数体,其中 x 绑定为 2y 绑定为 4PA 绑定为函数自身。


总结 📝

本节课中,我们一起学习了函数的形式化定义。我们深入探讨了:

  • 函数绑定的语法、求值规则(函数即值)和类型检查规则(推断参数与返回类型)。
  • 函数调用的语法、类型检查规则(检查函数类型与参数匹配)和求值规则(三步走:求函数、求参数、在扩展环境中求函数体)。

正如我们为每个语言概念所做的那样,无论其简单或复杂,我们都可以通过精确的语法、类型检查规则和求值规则来完全理解它——函数也不例外。掌握这些形式化规则,将为你理解和构建更复杂的程序打下坚实的基础。

018:元组与复合数据

在本节课中,我们将学习如何构建由多个部分组成的复合数据。我们将从最简单的形式——对(pairs)开始,然后扩展到更通用的元组(tuples)。你将学会如何创建和访问这些数据结构,并理解它们在类型系统中的作用。

构建与访问对(Pairs)

上一节我们介绍了基本数据类型,本节中我们来看看如何将多个数据组合成一个整体。对(pair)是包含两个部分的数据结构,每个部分可以是不同类型。

构建对

构建对的语法是:将两个表达式用逗号分隔,并放在括号内。例如,(e1, e2)

求值规则:首先求值 e1 得到值 v1,然后求值 e2 得到值 v2,最终结果是一个包含 v1v2 的对值。

类型规则:如果 e1 的类型是 tae2 的类型是 tb,那么整个表达式的类型是 ta * tb

访问对

访问对的组成部分使用 #1#2 操作符。

求值规则:首先求值表达式 e 得到一个对值,然后 #1 e 返回其第一部分,#2 e 返回其第二部分。

类型规则:表达式 e 必须具有对类型 ta * tb。那么 #1 e 的类型是 ta#2 e 的类型是 tb

编写使用对的函数

理解规则的最佳方式是实践。以下是几个使用对的函数示例。

交换函数

此函数接受一个 int * bool 类型的对,并返回一个交换了顺序的 bool * int 对。

fun swap (pr : int * bool) =
    (#2 pr, #1 pr)

求和函数

此函数接受两个 int * int 类型的对,返回四个整数的和。

fun sum_two_pairs (pr1 : int * int, pr2 : int * int) =
    (#1 pr1) + (#2 pr1) + (#1 pr2) + (#2 pr2)
(* 类型: (int * int) * (int * int) -> int *)

商与余数函数

此函数接受两个整数,返回一个包含商和余数的对。

fun div_mod (x : int, y : int) =
    (x div y, x mod y)
(* 类型: int * int -> int * int *)

排序对函数

此函数接受一个 int * int 对,返回一个按升序排列的对。

fun sort_pair (pr : int * int) =
    if (#1 pr) < (#2 pr)
    then pr
    else (#2 pr, #1 pr)
(* 类型: int * int -> int * int *)

从对到元组(Tuples)

对是元组的一种特例。元组可以包含任意数量的部分,称为 n元组

构建元组

构建元组的语法与对类似,只需用逗号分隔多个表达式:(e1, e2, ..., en)

类型规则:如果每个表达式 ei 的类型是 ti,那么整个元组的类型是 t1 * t2 * ... * tn

访问元组

访问元组使用 #1, #2, #3, ... 操作符,分别对应第一、第二、第三...部分。

嵌套元组

元组可以嵌套,形成更复杂的数据结构。以下是一些嵌套元组的例子:

val x1 = (7, (true, 9))
(* 类型: int * (bool * int) *)

val x2 = #1 (#2 x1)
(* x2 求值为 true *)

val x3 = #2 x1
(* x3 求值为 (true, 9),类型为 bool * int *)

val complex_tuple = ((1,2), ((3,4), (5,6)))
(* 类型: (int * int) * ((int * int) * (int * int)) *)

本节课中我们一起学习了如何创建和使用元组这两种复合数据类型。我们掌握了构建和访问它们的语法与规则,并通过编写函数进行了实践。我们还了解了元组可以嵌套,从而构建出复杂的数据结构。这些概念是后续学习列表和其他数据结构的重要基础。

019:列表简介 📚

在本节课中,我们将开始学习列表。列表是一种可以容纳任意数量元素的复合数据类型,与元组不同,其大小在程序运行时决定。我们将首先学习列表的基本规则,包括如何构建和使用列表。

构建列表 🛠️

列表的构建方式主要有两种:直接书写和使用 :: 操作符(称为 cons)。

直接书写列表

最简单的列表是空列表,用 [] 表示。它本身就是一个值,其类型为 'a list,意味着它可以被当作任何类型的列表使用。

[]

要创建包含多个元素的列表,只需在方括号内列出这些元素,并用逗号分隔。

[3, 4, 5]
[true, false, true]

列表中的所有元素必须是相同类型。例如,[3, 4+5, true] 会导致类型错误,因为整数和布尔值类型不同。

使用 :: 操作符

:: 操作符用于在列表前端添加一个新元素。其语法为 e1 :: e2,其中 e2 必须是一个列表。

5 :: [7, 8, 9] (* 结果为 [5, 7, 8, 9] *)
6 :: 5 :: [7, 8, 9] (* 结果为 [6, 5, 7, 8, 9] *)

需要注意的是,你不能将一个列表直接添加到另一个列表的前端,除非外层列表的元素类型就是列表。例如,[6] :: [7,8,9] 是错误的,但 [6] :: [[7,5], [5,2]] 是正确的。

使用列表 🔍

要使用列表,我们需要能够判断列表是否为空,并访问其元素。

判断列表是否为空

ML 语言提供了 null 函数,它接受一个列表作为参数,如果列表为空则返回 true,否则返回 false

null([]) (* true *)
null([7,8,9]) (* false *)

访问列表元素

一旦确定列表非空,就可以使用 hd 函数获取列表的第一个元素(头部),或使用 tl 函数获取除第一个元素外的剩余部分(尾部)。

val x = [7,8,9]
hd(x) (* 7 *)
tl(x) (* [8,9] *)
hd(tl(x)) (* 8 *)
tl(tl(x)) (* [9] *)
tl(tl(tl(x))) (* [] *)

尝试对空列表使用 hdtl 函数会导致运行时错误。

列表类型 📝

就像元组有 int * int 这样的类型一样,列表也有自己的类型表示法。对于任何类型 TT list 表示一个元素类型为 T 的列表。

  • int list: 整数列表
  • bool list: 布尔值列表
  • (int * int) list: 整数对列表

列表类型可以嵌套,例如 int list list 表示一个列表的列表。

操作的类型

理解构建和访问列表操作的类型非常重要。

  • 空列表 []: 类型为 'a list。这里的 'a(读作 alpha)是一个类型变量,表示空列表可以当作任何类型的列表使用。
  • :: 操作符: 如果 e2 的类型是 T list,那么 e1 的类型必须是 T。整个表达式 e1 :: e2 的类型也是 T list
  • null 函数: 类型为 'a list -> bool。它可以接受任何类型的列表,并返回一个布尔值。
  • hd 函数: 类型为 'a list -> 'a。它接受一个列表,并返回该列表元素类型的值。
  • tl 函数: 类型为 'a list -> 'a list。它接受一个列表,并返回一个相同元素类型的列表。

这些带有 'a 的类型签名表明这些函数是多态的,可以用于处理多种类型的列表。

总结 🎯

本节课中,我们一起学习了 ML 语言中列表的基础知识。

我们首先学习了两种构建列表的方法:直接书写元素和使用 :: 操作符在列表前端添加元素。我们了解到列表中的所有元素必须具有相同的类型。

接着,我们学习了如何使用列表。通过 null 函数可以安全地检查列表是否为空。对于非空列表,可以使用 hd 函数获取其第一个元素,使用 tl 函数获取剩余部分。

最后,我们探讨了列表的类型系统。列表的类型表示为 T list。我们了解了空列表的特殊类型 'a list,以及 nullhdtl 等函数的多态类型签名,这使得它们能够灵活地处理各种类型的列表。

掌握了这些构建和访问列表的基本操作后,在下一节中,我们将学习如何编写接收列表作为参数或返回列表作为结果的函数,这是函数式编程中非常强大和常见的模式。

020:列表函数 📝

在本节课中,我们将学习如何编写一系列函数,这些函数要么以列表作为参数,要么返回列表作为结果。我们不会引入新的语言结构,而是将递归函数的工作原理与列表的工作方式相结合,用极少的代码编写出许多实用的函数和程序。

概述 📋

我们将通过多个示例,展示如何递归地处理列表。每个示例都将遵循相同的模式:首先处理空列表的情况,然后处理非空列表的情况。通过这种方式,我们可以简洁地实现各种列表操作。

示例1:列表求和 ➕

首先,我们编写一个函数,用于计算列表中所有整数的和。该函数接收一个 int list 类型的参数。

以下是实现步骤:

  1. 如果列表为空,则和为0。
  2. 如果列表非空,则将列表的第一个元素(头部)与剩余部分(尾部)的和相加。

以下是该函数的代码实现:

fun sum_list (xs: int list) =
    case xs of
        [] => 0
      | x::xs' => x + sum_list(xs')

该函数的类型为 int list -> int。例如,调用 sum_list([3,4,5]) 将返回 12

示例2:倒计时列表 ⏳

接下来,我们编写一个函数,它接收一个整数 x,并返回一个从 x 递减到 1 的整数列表。

以下是实现步骤:

  1. 如果 x 为0,则返回空列表。
  2. 否则,将 x 与对 x-1 进行递归调用的结果连接起来。

以下是该函数的代码实现:

fun countdown (x: int) =
    if x=0
    then []
    else x::countdown(x-1)

该函数的类型为 int -> int list。例如,调用 countdown(7) 将返回 [7,6,5,4,3,2,1]

示例3:列表拼接 🔗

现在,我们编写一个函数,用于将两个整数列表拼接在一起。该函数接收两个 int list 类型的参数。

以下是实现步骤:

  1. 如果第一个列表为空,则直接返回第二个列表。
  2. 否则,将第一个列表的头部,与第一个列表的尾部拼接第二个列表的结果连接起来。

以下是该函数的代码实现:

fun append (xs: int list, ys: int list) =
    case xs of
        [] => ys
      | x::xs' => x::append(xs', ys)

该函数的类型为 int list * int list -> int list。这个函数的实现逻辑也是本课程Logo前半部分所展示的。

示例4:处理元组列表 📦

在实际编程中,我们经常需要处理包含更复杂元素的列表,例如元组。以下是一些处理 (int * int) list 类型列表的函数。

求和所有元素

这个函数计算列表中所有整数对中两个分量的总和。

以下是实现步骤:

  1. 如果列表为空,则返回0。
  2. 否则,取出第一个元组,将其第一个分量和第二个分量相加,然后加上对列表尾部递归求和的结果。

以下是该函数的代码实现:

fun sum_pair_list (xs: (int * int) list) =
    case xs of
        [] => 0
      | x::xs' => (#1 x) + (#2 x) + sum_pair_list(xs')

例如,调用 sum_pair_list([(3,4), (5,6)]) 将返回 18

提取所有第一个分量

这个函数返回一个新列表,包含原列表中每个元组的第一个分量。

以下是实现步骤:

  1. 如果列表为空,则返回空列表。
  2. 否则,取出第一个元组的第一个分量,将其与对列表尾部递归提取的结果连接起来。

以下是该函数的代码实现:

fun firsts (xs: (int * int) list) =
    case xs of
        [] => []
      | x::xs' => (#1 x)::firsts(xs')

例如,调用 firsts([(3,4), (5,6)]) 将返回 [3,5]

提取所有第二个分量

类似地,我们可以编写提取所有第二个分量的函数。

以下是该函数的代码实现:

fun seconds (xs: (int * int) list) =
    case xs of
        [] => []
      | x::xs' => (#2 x)::seconds(xs')

例如,调用 seconds([(3,4), (5,6)]) 将返回 [4,6]

示例5:利用已有函数重构 🛠️

在编程中,一个很好的实践是复用已有的函数来构建新功能。我们可以用之前编写的 sum_listfirstsseconds 函数来更简洁地实现 sum_pair_list 的功能。

以下是重构后的代码实现:

fun sum_pair_list2 (xs: (int * int) list) =
    (sum_list (firsts xs)) + (sum_list (seconds xs))

这个版本首先分别提取所有第一个分量和第二个分量,形成两个新列表,然后分别对这两个列表求和,最后将两个和相加。例如,调用 sum_pair_list2([(3,4), (5,6), (9,~3)]) 将返回 24

递归的核心思想 💡

通过以上示例,我们可以看到处理列表的函数几乎总是递归的。其核心思想非常简单:

  • 当给定一个列表时,要访问所有元素,必须递归地处理列表的尾部。
  • 你只需要问自己两个问题:
    1. 对于空列表,答案应该是什么?(这是递归的基准情况)
    2. 对于非空列表,如何利用列表尾部的(递归)结果来计算当前列表的答案?

同样,如果要生成一个长度依赖于输入参数的列表(如 countdown),也需要递归来从更小的列表构建出最终结果。

总结 🎯

本节课我们一起学习了如何编写递归函数来处理列表。我们掌握了以下内容:

  1. 列表求和:通过递归将头部与尾部的和相加。
  2. 生成列表:如 countdown,通过递归构建递减序列。
  3. 列表操作:如 append,通过递归将两个列表拼接。
  4. 处理复杂结构:对元组列表进行求和、提取分量等操作。
  5. 代码复用:利用已有函数组合出新功能,使代码更简洁。

关键在于始终遵循递归模式:明确基准情况(空列表),并定义如何从更小问题的解构建出当前问题的解。在接下来的作业中,你将获得更多练习来巩固这些概念。

021:let表达式 🧩

在本节课中,我们将要学习M语言编程基础中的最后一个重要语言特性:let表达式。let表达式允许我们在函数内部定义局部变量,这些变量仅在该函数内部可见,这有助于提升代码风格和便利性。我们将从语法、求值规则和类型检查三个方面详细介绍let表达式,并通过示例代码帮助初学者理解其用法。


回顾已学内容

上一节我们介绍了函数、元组和列表的构建与使用方法。我们已经学习了如何定义和使用整数、布尔值、元组和列表,以及如何通过函数参数和返回值进行编程。我们还了解了环境在顶层、函数绑定、元组和列表中的作用机制。

然而,我们尚未掌握如何在函数内部定义局部变量。局部变量可以仅在特定函数内部使用,这对于代码组织和风格非常重要。本节中,我们将学习如何使用let表达式实现这一功能。


let表达式的基本语法

let表达式使用三个关键字:letinend。在letin之间,我们可以放置任意数量的绑定;在inend之间,我们放置一个表达式,称为let表达式的主体

以下是let表达式的语法结构:

let
    binding1
    binding2
    ...
in
    body_expression
end

求值规则

let表达式的求值规则如下:

  1. 按顺序求值每个绑定,就像它们在程序顶层一样。
  2. 每个绑定可以在后续绑定中使用,但不能在之前的绑定中使用。
  3. 所有绑定都可以在主体表达式中使用。
  4. 主体表达式的求值结果即为整个let表达式的结果。
  5. let表达式中的绑定不会影响外部环境。

类型检查规则

let表达式的类型检查规则与求值规则类似:

  1. 按顺序对每个绑定进行类型检查。
  2. 使用新的静态环境对后续绑定进行类型检查。
  3. 所有绑定都可以在主体表达式的类型检查中使用。
  4. 主体表达式的类型即为整个let表达式的类型。

示例代码

以下是使用let表达式的一些示例代码,帮助理解其用法:

示例1:基本let表达式

fun silly1 z =
    let
        val x = if z > 0 then z else 34
        val y = x + z + 9
    in
        x + y
    end

在这个示例中:

  • 绑定x的值取决于z是否大于0。
  • 绑定y的值依赖于xz
  • 主体表达式使用xy进行计算。

整个函数的类型为int -> int,因为主体表达式的类型为int


示例2:嵌套let表达式

fun silly2 () =
    let
        val x = 1
    in
        (let val x = 2 in x + 1 end) +
        (let val y = x + 2 in y + 1 end)
    end

在这个示例中:

  • 第一个let表达式中的x绑定为2,遮蔽了外层的x绑定。
  • 第二个let表达式中的x引用外层的x绑定(值为1)。
  • 两个let表达式的结果相加,得到最终结果。

运行silly2 ()将返回7。


作用域的概念

let表达式引入了作用域的概念,即绑定在程序中的可见范围。对于顶层绑定,其作用域为整个文件(除非被遮蔽)。而对于let表达式中的绑定,其作用域仅限于该表达式的后续绑定和主体部分,不会影响外部环境。

这种局部作用域机制使得我们可以灵活地定义和使用局部变量,提升代码的模块化和可读性。


总结

本节课中,我们一起学习了let表达式的基本语法、求值规则和类型检查方法。通过示例代码,我们了解了如何在函数内部定义局部变量,并掌握了作用域的概念。let表达式是M语言中实现局部变量的关键工具,为后续学习嵌套函数和算法优化奠定了基础。

在接下来的课程中,我们将进一步探讨如何利用let表达式实现函数嵌套,并学习其在提升算法效率方面的应用。

022:嵌套函数 🧩

在本节课中,我们将学习如何在其他函数内部定义函数,探讨何时这样做是良好的编程风格,以及如何以符合良好风格的方式实现。我们将看到,实现嵌套函数并不需要学习新的语言结构,只需利用我们已经掌握的 let 表达式即可。


概述

嵌套函数是指在一个函数内部定义的另一个函数。在 ML 语言中,这可以通过 let 表达式轻松实现,因为函数本身就是一种绑定。这样做的好处是可以将辅助函数的作用域限制在需要它的主函数内部,从而提高代码的封装性和可维护性。

从独立辅助函数开始

为了更好地理解嵌套函数,我们先来看一个不使用嵌套函数的例子。假设我们需要一个函数 count_up_from_one,它接收一个整数 x 并返回从 1 到 x 的整数列表。

为了实现这个功能,我们首先定义一个独立的辅助函数 count,它接收两个参数 fromto,并返回它们之间(包含两端)的整数列表。

以下是 count 函数的代码:

fun count (from, to) =
    if from = to
    then [to]
    else from :: count(from+1, to)

接着,我们利用这个辅助函数来定义主函数 count_up_from_one

fun count_up_from_one x = count(1, x)

这样,当我们调用 count_up_from_one 7 时,就会得到列表 [1,2,3,4,5,6,7]

使用 let 表达式实现嵌套

上一节我们介绍了独立的辅助函数。然而,如果 count 函数只被 count_up_from_one 使用,那么将其暴露在全局作用域中并不是最佳实践。本节中,我们来看看如何使用 let 表达式将 count 函数嵌套在 count_up_from_one 内部,使其成为私有函数。

修改后的代码如下:

fun count_up_from_one x =
    let
        fun count (from, to) =
            if from = to
            then [to]
            else from :: count(from+1, to)
    in
        count(1, x)
    end

在这个版本中,count 函数被定义在 let 表达式内部,因此它只在 count_up_from_one 的函数体内可见。在外部环境中尝试调用 count 函数会导致错误,这正符合我们的预期。

优化:利用外部作用域的变量

我们注意到,在嵌套的 count 函数中,参数 to 的值始终等于外部函数 count_up_from_one 的参数 x。既然 x 已经在作用域内,我们就没有必要再将 to 作为参数传递给 count 函数。

以下是优化后的版本,我们移除了 count 函数中多余的 to 参数,并直接使用外部变量 x

fun count_up_from_one x =
    let
        fun count from =
            if from = x
            then [x]
            else from :: count(from+1)
    in
        count 1
    end

这个版本更加简洁,因为它避免了传递不必要的参数。count 函数通过闭包机制,可以直接访问其定义时所在作用域中的变量 x

嵌套函数的风格指南

何时应该使用嵌套函数?这涉及到软件设计中的一个基本权衡。

以下是使用嵌套函数的主要优点:

  • 限制作用域:确保辅助函数只在需要它的地方被使用,防止误用。
  • 提高封装性:隐藏实现细节,使主函数的接口更清晰。
  • 便于维护:修改嵌套函数时,只需检查其有限的调用点,降低了代码维护的复杂度。

然而,嵌套函数并非总是最佳选择:

  • 降低可复用性:如果一个函数可能在程序的其他部分也有用,那么将其定义在更广的作用域(如模块级别)会更合适。
  • 设计权衡:目标是在“限制作用域以保证安全”和“扩大作用域以促进复用”之间找到平衡。

总结

本节课中我们一起学习了 ML 语言中嵌套函数的定义和使用。关键点在于,我们可以利用已有的 let 表达式在函数内部定义其他函数。我们探讨了如何通过这种方式将辅助函数私有化,以及如何通过闭包直接使用外部作用域的变量来简化函数签名。最后,我们讨论了在什么情况下使用嵌套函数是良好的编程风格,即当辅助函数用途单一且不需要被外部代码复用时。掌握这一技巧有助于你编写出更模块化、更易维护的代码。

023:let 表达式与递归效率 🚀

在本节课中,我们将学习如何避免在递归函数中重复计算,并理解 let 表达式如何帮助我们解决这个问题。我们将通过一个具体的例子来展示低效递归的后果,并演示如何使用 let 表达式来优化代码。

概述

递归是函数式编程的核心概念之一,但如果不小心处理,递归可能导致严重的性能问题。本节中,我们将分析一个名为 bad_max 的低效递归函数,并展示如何通过 let 表达式将其优化为 good_max 函数。


低效递归示例:bad_max 函数

首先,我们来看一个低效的递归函数 bad_max。该函数的目标是找出整数列表中的最大值。虽然函数逻辑正确,但其递归方式导致了严重的性能问题。

fun bad_max (xs : int list) =
    if null xs
    then 0
    else if null (tl xs)
    then hd xs
    else if hd xs > bad_max(tl xs)
    then hd xs
    else bad_max(tl xs)

函数逻辑分析

以下是 bad_max 函数的逻辑步骤:

  1. 如果列表为空,返回 0(这是一个临时处理,实际应抛出异常)。
  2. 如果列表只有一个元素,返回该元素。
  3. 否则,比较列表头部与尾部列表的最大值,返回较大者。

性能问题

尽管 bad_max 函数逻辑正确,但其在递归过程中重复计算了 bad_max(tl xs),这导致了指数级的计算量增长。具体来说,每次递归调用都会产生两个新的递归调用,从而形成二叉树状的调用结构。


性能对比:countdown 与 countup

为了更直观地展示性能问题,我们使用两个辅助函数 countdowncountup 来生成列表。

countdown 列表

当列表元素按降序排列时(如 [50, 49, 48, ...]),bad_max 的性能尚可接受,因为最大值在列表开头,递归调用次数约为列表长度。

countup 列表

当列表元素按升序排列时(如 [1, 2, 3, ...]),bad_max 的性能急剧下降。因为最大值在列表末尾,每次递归都需要重复计算尾部列表的最大值,导致调用次数呈指数增长。

例如,对于一个长度为 50 的列表,bad_max 的调用次数约为 2^50 次,这是一个天文数字,即使对于现代计算机也需要数年时间才能完成。


优化方案:使用 let 表达式

为了避免重复计算,我们可以使用 let 表达式将递归结果存储在变量中。这样,每次递归只需计算一次尾部列表的最大值,从而将指数级复杂度降为线性复杂度。

优化后的函数:good_max

以下是使用 let 表达式优化后的 good_max 函数:

fun good_max (xs : int list) =
    if null xs
    then 0
    else if null (tl xs)
    then hd xs
    else
        let val tail_ans = good_max(tl xs)
        in
            if hd xs > tail_ans
            then hd xs
            else tail_ans
        end

优化原理

good_max 函数中,我们使用 let 表达式将 good_max(tl xs) 的结果存储在变量 tail_ans 中。这样,在比较头部元素与尾部最大值时,我们无需重复计算 good_max(tl xs),从而大幅提升了性能。


性能对比总结

通过对比 bad_maxgood_max 的性能,我们可以得出以下结论:

  1. bad_max:在升序列表等情况下,递归调用次数呈指数增长,导致性能极差。
  2. good_max:通过 let 表达式避免重复计算,递归调用次数与列表长度成线性关系,性能显著提升。

实际测试结果

  • 对于长度为 30 的升序列表,bad_max 需要数秒才能完成,而 good_max 几乎瞬间完成。
  • 对于更长的列表(如 3000 个元素),bad_max 可能永远无法完成,而 good_max 仍然高效运行。

总结

在本节课中,我们一起学习了如何避免递归函数中的重复计算问题。通过分析 bad_max 函数的低效递归,我们认识到重复计算会导致指数级的性能下降。接着,我们使用 let 表达式优化了递归函数,将其转化为高效的 good_max 函数。

关键点总结:

  1. 避免重复计算:在递归函数中,重复计算同一子问题会导致严重的性能问题。
  2. 使用 let 表达式:通过 let 表达式将递归结果存储在变量中,可以避免重复计算,将指数级复杂度降为线性复杂度。
  3. 性能对比:优化后的递归函数在处理大规模数据时,性能显著提升。

通过本节课的学习,你应该能够识别并优化递归函数中的重复计算问题,从而编写出更高效的代码。

024:Option类型 🧩

在本节课中,我们将学习ML语言中的最后一个主要语言结构——Option类型。我们首先会回顾一个旧版本的求列表最大值的函数,分析其设计缺陷,然后引入Option类型作为更优雅的解决方案。我们将学习如何构建和访问Option值,并最终用它重写一个更健壮的max函数。

从旧版max函数说起

上一节我们介绍了递归和列表处理。本节中我们来看看一个具体的例子:计算整数列表的最大值。我们之前见过一个max函数,它接收一个int list并返回一个int。这个函数在递归和效率方面都很好,但它处理空列表的方式存在问题:空列表没有最大值,但旧函数却返回了0。这是一个笨拙的变通方法。返回一个极小的负数也不是理想的解决方案。我们更希望表达“不应该计算空列表的最大值”这一意图。

那么,我们该怎么办呢?有几种可能的尝试:

  • 我们可以将其视为运行时异常并抛出,就像除以零或取空列表的头部一样。我们会在后续课程学习异常。
  • 另一种方法是,利用我们已有的结构,修改max函数,让它不返回int,而是返回int list。如果传入空列表,就返回空列表;如果传入非空列表,就返回一个包含最大值的单元素列表。这种方法可行,但风格不佳。

返回“零个或一个”结果在编程中非常常见,因此ML专门为此设计了一种不同的类型和一组语言结构。使用这种结构是更好的风格,能让函数的调用者清晰地理解其行为。而列表可以包含任意数量的元素,不适合表达“零或一”的场景。这时,你应该使用Option类型。

理解Option类型 📦

理解Option类型很简单。我喜欢通过类比列表来解释它。

就像我们可以为任何类型TT list一样,我们现在也可以为任何类型TT option。这是两种不同的类型,Option不是列表,列表也不是Option,这只是为了便于教学而作的类比。

与列表类似,我们有构建Option和访问其内容的方法。

以下是构建Option的两种方式:

  • 你可以写NONE(全大写),这会构建一个不包含任何值的Option,类似于[]构建一个空列表。
  • 你可以写SOME(全大写)后跟一个表达式E。求值方式是:先对E求值得到一个值,如果该值的类型是T,那么结果就是一个T option,类似于创建一个单元素列表。

例如,SOME 3就是一个int option。你不能把它当作int来使用,因为它不是int,而是一个“包含int的东西”。

当你有一个Option值时,你需要使用以下两个内置函数(实际上是库函数)来访问它:

  • 第一个函数叫isSome。它接收一个Option,如果它是SOME则返回true,如果是NONE则返回false。这很像列表的null函数,但逻辑是反的:我们在非空(即单元素)情况下返回true
  • 第二个结构是valOf。它接收一个Option,并取出SOME里面包裹的值。如果传给valOf的参数是NONE,它会引发一个异常;如果参数是SOME,你就会得到里面包裹的值。

以上就是Option的全部内容。

使用Option实现更好的max函数 🔧

现在,让我们用Option来实现一个更好的max函数版本。我将展示两种实现方式。

版本一:max1

这是第一种方式,我称之为max1。这个函数接收一个int list并返回一个int option

fun max1 (xs: int list) =
    if null xs
    then NONE
    else
        let val tail_ans = max1(tl xs)
        in if isSome tail_ans andalso valOf tail_ans > hd xs
           then tail_ans
           else SOME (hd xs)
        end

代码解释:

  1. 如果参数列表xs为空,则函数求值为NONE
  2. 否则,递归地计算尾部的最大值,并将结果(一个int option)存储在tail_ans中。
  3. 由于tail_ansint option,我们只能用isSomevalOf来访问它。
  4. 我们检查:如果tail_ansSOME并且它的值大于列表的头部,那么尾部最大值就是整体最大值,直接返回tail_ans
  5. 否则(要么tail_ansNONE——因为tl xs是空列表,要么列表头部大于尾部最大值),我们都希望返回一个由列表头部构建的Option,即SOME (hd xs)

顺便说一下,代码中的andalso是首次出现,它是一个计算两个布尔值“逻辑与”的操作符,我们将在下一节详细解释。

这个max1函数运行良好。如果我们调用max1 [3,7,5],会得到SOME 7。注意,你不能直接对这个结果加1,因为它不是int,而是int option。你必须先使用valOf (max1 [3,7,5])取出里面的值,然后才能加1。如果调用max1 [],会得到NONE,此时尝试valOf会引发运行时异常,就像对空列表取hd会引发异常一样。

版本二:max2

虽然max1没问题,但我不太喜欢的一点是:每次递归返回后,我都要检查结果是SOME还是NONE。而NONE的情况只发生在最初的空列表上。在后续递归调用中,结果总是SOME,但我们仍在反复检查。我们可以避免这一点。

以下是第二个版本max2,实现上更清晰一些:

fun max2 (xs: int list) =
    if null xs
    then NONE
    else
        let fun max_nonempty (xs: int list) =
                if null (tl xs)
                then hd xs
                else
                    let val tail_ans = max_nonempty(tl xs)
                    in if hd xs > tail_ans
                       then hd xs
                       else tail_ans
                    end
        in SOME (max_nonempty xs)
        end

代码解释:

  1. 如果传入空列表,直接返回NONE
  2. 否则,我们定义一个递归辅助函数max_nonempty,它专门处理非空列表,并返回一个int
  3. max_nonempty函数假设其参数非空。如果列表只有一个元素(即tl xs为空),则返回该元素。否则,递归计算尾部的最大值,然后与头部比较,返回较大的那个。
  4. 由于我们只在确定xs非空后才调用max_nonempty(在else分支里,以及递归调用中),并且max_nonempty在单元素列表时直接返回而不进行递归,因此它永远不会对空列表调用,也就不会引发异常。
  5. 最后,max2函数使用SOMEmax_nonempty xs得到的int包装起来,返回一个int option

我们可以验证max2 [3,7,5]返回SOME 7,而max2 []返回NONEmax1max2都能工作,我稍微更偏爱max2,但两者都是良好的风格。

总结

本节课中,我们一起学习了ML中的Option类型。我们首先分析了旧版max函数在处理空列表时的缺陷,进而引入了用于表示“可能有值,可能无值”的Option类型。我们学习了如何使用NONESOME构建Option值,以及如何使用isSomevalOf来安全地访问其中的值。最后,我们运用这些知识,实现了两个使用Option类型的、更健壮的max函数版本。Option类型与列表类似,但它专门用于表示“零个或一个”元素的情况,使得代码意图更清晰,设计更优雅。

025:布尔运算与比较运算符

在本节课中,我们将学习如何组合布尔表达式以及如何比较数字。我们将介绍 andalsoorelsenot 运算符,并详细讲解数字的比较运算符。这些内容是编程中的基础,对于编写条件判断和逻辑运算至关重要。

布尔运算

上一节我们介绍了基本的表达式和类型,本节中我们来看看如何组合布尔表达式。ML 语言使用特定的关键字进行逻辑运算,这与许多现代编程语言不同。

以下是 ML 中的三个主要布尔运算符:

  • E1 andalso E2:这是逻辑“与”运算。其求值规则是:先求值 E1,如果结果为 false,则整个表达式的结果为 false,且不会求值 E2;如果 E1true,则继续求值 E2,其结果即为整个表达式的结果。
  • E1 orelse E2:这是逻辑“或”运算。其求值规则是:先求值 E1,如果结果为 true,则整个表达式的结果为 true,且不会求值 E2;如果 E1false,则继续求值 E2,其结果即为整个表达式的结果。
  • not E1:这是逻辑“非”运算。其求值规则是:求值 E1,如果结果为 true,则返回 false;如果结果为 false,则返回 true

需要注意的是,andalsoorelse关键字,而不是函数。因为它们具有“短路求值”的特性(即不一定求值所有子表达式),所以不能像函数那样被单独调用。而 not 是一个函数,因为它总是需要求值其参数。

(* 示例 *)
true andalso false; (* 结果为 false *)
true andalso true;  (* 结果为 true *)
not true;           (* 结果为 false *)
not false;          (* 结果为 true *)
andalso;            (* 语法错误,因为它是关键字,需要左右操作数 *)
not;                (* 有效,因为它是一个函数 *)

与条件表达式的等价关系

从语言功能的角度看,if-then-else 表达式已经足够强大,可以表达所有逻辑运算。以下是它们的等价写法:

  • E1 andalso E2 等价于 if E1 then E2 else false
  • E1 orelse E2 等价于 if E1 then true else E2
  • not E1 等价于 if E1 then false else true

然而,直接使用 andalsoorelsenot 是更好的编程风格,因为它们更清晰地表达了代码的意图。

此外,请避免编写 if E then true else false 这样的冗余代码。这个表达式的含义就是 E 本身,因此直接写 E 即可。

比较运算符

接下来,我们看看如何比较数字。ML 提供了标准的比较运算符,但在类型系统上有一些特别之处。

以下是 ML 中的比较运算符:

  • =:等于(用于整数、字符串等可比较相等性的类型)
  • <>:不等于(ML 中使用 <> 而非 !=
  • >:大于(可用于 intreal 类型)
  • <:小于(可用于 intreal 类型)
  • >=:大于等于(可用于 intreal 类型)
  • <=:小于等于(可用于 intreal 类型)

有两个重要的注意事项:

  1. 类型必须一致:比较运算符要求两边的操作数类型相同。你不能直接比较一个 int 和一个 real。如果需要比较,必须先将 int 转换为 real,可以使用库函数 Real.fromInt
    3 > 2;                 (* 正确:比较两个 int *)
    3.0 > 2.0;             (* 正确:比较两个 real *)
    3 > 2.0;               (* 类型错误! *)
    Real.fromInt(3) > 2.0; (* 正确:先将 3 转为 real *)
    
  2. 实数(real)的相等性比较:在 ML 中,不能使用 =<> 来比较两个 real 类型的值。这是因为浮点数存在精度误差,直接判断相等在数学和编程上通常都是不正确的做法。正确的做法是检查两个浮点数之差的绝对值是否小于一个很小的阈值(epsilon)。

本节课中我们一起学习了 ML 语言中的布尔运算和比较运算符。我们了解了 andalsoorelsenot 的用法及其短路求值特性,认识了它们与 if-then-else 的等价关系。同时,我们也掌握了数字比较运算符的使用,并特别注意了 ML 类型系统对操作数类型一致性的要求,以及实数不能直接进行相等性比较这一重要规则。掌握这些基础知识是进行后续复杂逻辑编程的关键。

026:不可变性的优势 🛡️

在本节课中,我们将探讨 ML 语言中一个核心且极具价值的特性:数据的不可变性。我们将了解为什么大多数数据(如元组、列表、变量)一旦创建,其内容就无法被改变,以及这种“缺失”的特性如何成为 ML 语言的一大优势。

概述

在之前的课程中,我们已经学习了完成作业所需的所有功能。本节我们将聚焦于 ML 语言所“没有”的一个特性——数据可变性。你可能会认为,为程序员提供更多可用的工具总是好的,他们可以自行决定是否使用。然而,当一种语言明确缺少某个特性时,编写代码的人就能确信,使用其代码的人永远不会用到这个特性,因为它根本不存在。这使得编写正确的代码和理解代码的运行结果变得更加容易。

事实上,函数式编程之所以成为函数式编程,一个主要特征就是:当你创建了一些数据(如一个数对或一个列表)后,就无法再改变这些数据的内容。你必须创建一个包含不同值的新数据。

接下来,让我们看看为什么这可以成为一个宝贵的特性。

一个简单的例子:排序数对

让我们从一个相对简单的例子开始。之前我展示过一个这样的函数,这是一个排序数对的函数。它接收一个 int * int 类型的参数,并返回一个 int * int。如果你用 (3, 4) 调用它,会返回 (3, 4);如果用 (4, 3) 调用,则会返回 (3, 4),因为它总是对元组中的两个元素进行排序。

以下是这个函数的两个版本:

(* 版本一 *)
fun sort_pair (x, y) = if x < y then (x, y) else (y, x)

(* 版本二 *)
fun sort_pair (x, y) = (Int.min(x, y), Int.max(x, y))

在第一个版本中,当数对的第一个分量小于第二个分量时,我们直接返回原数对,因为这已经是正确答案。而在第二个版本中,我们实际上是创建了原数对的一个副本,返回一个包含原数对第一个分量和第二个分量的新数对。

假设你最初使用的是第二个版本,但出于简化或提高效率等考虑,你想将其修改为第一个版本。那么,是否存在某些使用你函数的客户端代码,会因为你的这个改动而出现问题呢?

在 ML 中,答案是否定的。任何使用这些函数的代码都无法区分这两个版本。你可以认为第一个版本风格更好,但不能说它们对代码使用者有任何区别。

但是,如果你的语言允许你改变(即更新)数对的内容,情况就不同了。

可变性带来的别名问题

假设我们将数对 (3, 4) 绑定到变量 x,然后将排序这个数对的结果绑定到变量 y

val x = (3, 4)
val y = sort_pair x

现在有两种可能性(如果我们不知道 sort_pair 是如何实现的):

  1. 如右图所示,y 引用了传递给 x 的同一个数对。此时 xy 在大多数编程语言中通常被称为“别名”。
  2. xy 不是别名,y 指向另一个不同的数对 (3, 4)

在 ML 中,这无关紧要。但如果在 ML 中存在某种方式可以改变(例如)x 的第一个分量,将其从 3 改为 5,那么我们就面临一个棘手的问题:这个从 35 的改变会影响 y 所引用的内容吗?这取决于 xy 是否是别名。

如果没有可变性,你就无法判断两个事物是别名关系,还是具有相同值的两个副本。这使得实现 sort_pair 函数、使用它以及推理其结果都变得更加容易,甚至可以使实现像 ML 这样的语言本身也变得更容易。

更复杂的例子:列表追加

现在让我们看一个更有趣的例子,从数对转向列表,并使用本课程中我最喜欢的函数之一:append

这是一个优雅的递归函数,它接收两个列表 xsys,并返回一个新列表,该列表是 xs 的所有内容与 ys 的所有内容连接起来的结果。

fun append ([], ys) = ys
  | append (x::xs', ys) = x :: append (xs', ys)

现在我们可以问自己:如果我有一个列表 [2, 4] 和一个列表 [5, 3, 0],并将它们追加在一起,那么我得到的结果中是否存在别名呢?

有两种可能的情况:

  1. 如顶部图片所示,x 持有列表 [2, 4]y 持有列表 [5, 3, 0],而 z 持有的列表 [2, 4, 5, 3, 0] 中,有一部分是 y 的别名。
  2. append 函数实际上为 z 创建了一个全新的列表。

同样地,客户端代码无法区分这两种情况。因此,你可以用任何一种方式实现 append,它在你的程序中的行为都是相同的,尽管底部版本(创建全新列表)会占用稍多一点的空间。

实际上,上面的代码实现的是第一种情况(顶部图片)。因为当 xs 为空时,我们直接返回 ys,并没有复制 ys,只是返回了一个将成为 ys 别名的引用。这样我们实际上节省了空间。

在允许更新列表元素的语言中,这通常是一个非常糟糕的主意,因为如果有人对列表 z 进行某些修改,最终可能会影响到列表 y,而这很可能不是你想要的。因此,再次强调,正是没有可变性这一特性,帮助我们无需担心所有这些复杂问题。

ML 中的别名与效率

在 ML 中,我们实际上一直在创建别名,甚至都没有意识到这一点,但这没关系,因为你永远无法判断两个事物是别名关系,还是两个具有相同值的副本。它们是指向同一个 (3, 4) 数对,还是指向两个 (3, 4) 的副本?

事实上,列表的 tail 操作可能就是一个很好的例子。它是一个非常快速的操作,因为它只是返回传递给它的参数(列表)的尾部的别名。如果 tail 函数复制了整个列表(除了第一个元素),ML 程序的效率将会低得多。

因此,在编写函数式程序时,你无需担心这些别名问题,只需专注于你的算法,因为你知道,没有可变性。

对比:拥有可变数据的语言

在拥有可变数据的语言中(这几乎是所有非函数式语言,例如 Java),程序员必须绝对关注对象的“身份”:我在这里是创建了一个副本吗?我创建了一个别名吗?这两个东西是引用相等,还是仅仅在 equals 方法上返回 true

我并不是在批评他们对此过于执着。事实上,我认为他们必须执着,因为任何时候你有了别名,赋值语句就会影响到所有别名;而如果你没有别名,赋值语句就只影响其中一个。你必须理解这一点,才能理解程序的行为以及你的代码是否正确。

在下一节中,我实际上将展示一个 Java 中相当棘手的例子(由于本课程不要求 Java,这部分完全是可选的),它将向你展示推理别名和赋值语句是多么困难。而在 ML 中,我们避免不得不这样做的办法就是:直接摒弃赋值语句。

总结

本节课中,我们一起学习了 ML 语言中数据不可变性的核心优势。我们了解到,不可变性消除了由别名和可变状态带来的复杂性和不确定性,使得代码更易于编写、理解和推理。它允许实现者进行空间优化(如 tail 操作返回别名),而不会影响程序的可观测行为。通过对比拥有可变数据的语言(如 Java)中必须面对的别名问题,我们更加清晰地认识到,放弃“改变数据”这一能力,反而换来了在构建可靠、可维护软件方面更强大的保证。这正是函数式编程思想的精髓之一。

027:Java 可变性安全漏洞示例 🛡️

在本节中,我们将通过一个 Java 示例,继续探讨不可变数据带来的好处。本节内容为可选,但能帮助我们更深刻地理解为何在某些情况下避免数据突变至关重要。

概述

上一节我们讨论了在 ML 等语言中创建别名(aliases)是安全的,因为数据不可变,一个别名的更新不会影响其他别名。然而,在像 Java 这样允许数据突变的语言中,我们必须非常小心地区分何时创建别名,何时需要创建副本。本节将通过一个源自真实 Java 库安全漏洞的简化示例,展示因不当处理别名而可能引发的严重安全问题。

问题场景:一个“安全”的资源访问类

假设我们有一个包含私有资源的类。我们不希望所有人都能访问这个资源,只允许特定的用户列表中的用户访问。允许访问的用户名单并非秘密,但资源本身的内容是保密的。

以下是该类的核心结构:

public class ResourceHolder {
    private SomeResource theResource; // 私有资源
    private String[] allowedUsers; // 允许访问的用户数组

    // 返回允许用户列表的方法
    public String[] getAllowedUsers() {
        return allowedUsers; // 注意:这里直接返回了数组引用
    }

    // 使用资源的方法
    public void useResource() {
        String currentUser = getCurrentUser(); // 获取当前用户
        for (int i = 0; i < allowedUsers.length; i++) {
            if (currentUser.equals(allowedUsers[i])) {
                // 用户在白名单中,允许访问资源
                accessResource(theResource);
                return;
            }
        }
        // 用户不在白名单中,抛出异常
        throw new SecurityException("Access denied");
    }

    // 其他辅助方法...
    private void accessResource(SomeResource r) { /* ... */ }
    private String getCurrentUser() { /* ... */ }
}

useResource 方法的逻辑看起来是合理的:它遍历 allowedUsers 数组,检查当前用户是否在列表中。如果在,则允许访问;如果遍历完整个数组都没找到,则拒绝访问。

安全隐患:别名的滥用

问题并不出在 useResource 方法上,而出在 getAllowedUsers() 方法。

以下是关键问题所在:

getAllowedUsers() 方法直接返回了私有字段 allowedUsers 数组的引用(即一个别名)。在 Java 中,数组是可变的。这意味着,任何获得此数组引用的客户端代码都可以修改数组的内容。

一个恶意的用户可以这样做:

ResourceHolder holder = new ResourceHolder(...);
String[] usersAlias = holder.getAllowedUsers(); // 获得内部数组的别名
usersAlias[0] = "maliciousUser"; // 修改数组的第一个元素为当前恶意用户名
holder.useResource(); // 现在调用会成功,因为“maliciousUser”在数组里了

通过这种方式,恶意用户将自己添加到了允许访问的列表中,从而绕过了安全检查。这正是历史上一些 Java 标准库(如类加载器相关部分)中出现过的真实安全漏洞的简化原理。

解决方案:返回副本而非别名

在允许突变的语言中,修复此类问题的责任完全落在了程序员肩上。语言本身或类型系统(除非正确使用 final 或特定的不可变集合库)通常不会提供帮助。

修复方法是修改 getAllowedUsers(),使其返回 allowedUsers 数组的一个副本,而不是原始引用:

public String[] getAllowedUsers() {
    // 创建并返回数组的一个全新副本
    return Arrays.copyOf(allowedUsers, allowedUsers.length);
}

这样,客户端获得的只是一个数据的快照。他们可以读取这个副本,但对其进行的任何修改都只会影响这个副本,而完全不会触及 ResourceHolder 类内部私有的 allowedUsers 数组。因此,useResource 方法所依赖的内部状态得到了保护。

总结

本节课我们一起学习了可变数据可能带来的安全隐患。我们通过一个具体的 Java 示例看到,一个看似无害的、用于“只读”目的的方法(getAllowedUsers),由于返回了内部可变数据的别名,可能导致严重的安全漏洞,使得未授权访问成为可能。解决之道在于,当需要向外部暴露内部状态时,如果该状态是可变的,则应返回其副本而非别名。这个例子也强化了之前的观点:如果程序的大部分数据都是不可变的,那么我们根本无需进行此类复杂的推理,从而从根本上避免这类错误。

028:语言的组成部分 🧩

在本节课中,我们将退一步,从宏观视角审视学习一门编程语言究竟意味着什么。我们已经学习了许多ML语言的知识,现在需要思考:这些是核心内容吗?这是学习编程语言的正确方式吗?我们将探讨学习编程语言需要掌握哪些方面,并明确本课程的重点所在。

概述:学习编程语言的五个方面

学习一门编程语言并成为一名更优秀的程序员,需要掌握五个不同的方面。理解这些方面有助于我们明确学习目标,并认识到本课程的核心价值。

以下是构成编程语言知识的五个关键部分:

  1. 语法:如何书写语言结构。例如,在ML中,函数定义的基本语法是 fun 函数名 参数 = 表达式。如果不了解语法,就无法编写程序。
  2. 语义:类型检查规则和求值规则。例如,理解 if e1 then e2 else e3 的语义是:先对 e1 求值,若结果为 true 则对 e2 求值,否则对 e3 求值。不了解语义,就无法正确推理程序行为。
  3. 编程惯用法:识别特定语言特性的典型使用模式。这不仅仅是知道某个结构存在,而是知道何时以及如何使用它。例如,使用嵌套的 let 表达式来创建私有辅助函数,就是一种惯用法。
  4. :语言提供的、用于完成特定任务(如文件读写、数据结构)的设施。有些库(如文件操作)是必需的,因为用户无法自行实现;有些(如列表、树)则可以在语言基础上自行构建。
  5. 工具:语言实现或第三方提供的、用于简化开发工作的程序,例如交互式环境(REPL)、调试器、代码格式化工具等。需要明确,工具并非语言本身的一部分。

在实际编程中,要成为一名高效的程序员,需要精通所有这五个方面。同时,清晰地区分这些概念非常重要。例如,喜欢ML语言因为它有REPL,这是一种常见的混淆——REPL是工具,而非语言本身的特性。

本课程的重点:语义与惯用法

上一节我们介绍了学习编程语言的五个方面,本节中我们来看看本课程的核心焦点。

本课程将大量精力集中在第二和第三点:语义编程惯用法。以下是如此安排的理由:

  • 关于语法:虽然必须掌握语法,但它通常并非最有趣或最具启发性的部分。就像学习美国历史必须知道内战结束于1865年,但真正的学问在于背后的理论和思想。在我们的语境中,这个“思想”就是语义。此外,语法讨论容易陷入主观偏好之争,而本课程旨在传授客观的、可推理的语义知识。
  • 关于库和工具:它们确实至关重要,但程序员总是需要学习新的库和工具。本课程更希望教会你如何思考任何编程语言。如果你精通了理解和分析语言语义及惯用法的思维技能,那么学习新库也会变得更容易——你会自然地思考:“这个库提供的函数语义是什么?它们通常如何被使用?”

这种聚焦带来的一个结果是,我们编写的程序有时看起来可能过于简单(例如连接列表或求最大值)。请理解,这是为了剥离复杂性,从而专注于核心的语言机制。请不要用课程中的教学示例来评判一门语言本身的能力——如果用同样的教学法来教Java、Python或JavaScript,那些程序看起来也会很“傻”。我们选择ML等语言,正是因为它们是探讨语义和惯用法的最佳载体。这些语言完全有能力用于构建真实的Web应用、桌面软件等,但那不是本课程的重点。

总结

本节课中,我们一起学习了构成编程语言知识的五个关键方面:语法、语义、编程惯用法、库和工具。我们明确了本课程的核心在于深入理解语义和掌握编程惯用法,因为这两者是培养扎实编程思维和快速学习新语言、新库能力的基础。掌握了这些,你将成为一个更优秀的软件工程师和计算机科学家。

029:构建复合类型

在本节课中,我们将学习如何构建新的数据类型。我们将从一个更通用的视角出发,理解构建复合类型的三种基本方式。这不仅有助于我们理解 ML 语言中的现有类型,也为后续学习记录(records)和自定义数据类型(datatypes)打下基础。

概述:基础类型与复合类型

在编程语言中,所有类型可以分为两大类:基础类型和复合类型。

  • 基础类型 描述了语言中最基本的数值,例如 intboolunitcharreal
  • 复合类型 则是通过组合其他类型来构建新类型的方式。例如,元组(tuples)、列表(lists)和选项(options)都是复合类型。

在接下来的小节中,我们将学习创建自定义复合类型的新方法,包括记录和数据类型。但在深入学习之前,我们需要退一步,了解构建复合类型的三种通用方式。

构建复合类型的三种通用方式

尽管这不是标准的命名,我将这三种方式称为:“每个”类型(each of)“之一”类型(one of)自引用类型(self-reference)

以下是这三种方式的定义:

  1. “每个”类型(Each-of):要构建一个“每个”类型 T,意味着类型 T 的每个值都同时包含一组其他类型的值。例如,一个包含 T1T2T3 的三元组就是一个“每个”类型,因为它的值同时拥有一个 T1、一个 T2 和一个 T3
  2. “之一”类型(One-of):要构建一个“之一”类型 T,意味着类型 T 的每个值一组其他类型中某一个类型的值。例如,一个值可以是 τ1τ2τ3(这里用希腊字母 τ 泛指类型)。
  3. 自引用类型(Self-reference):这种类型允许在定义中引用自身,这对于描述递归结构(如列表、树)至关重要。例如,一个整数列表要么是空列表,要么是一个整数和另一个(更小的)整数列表的组合。

值得注意的是,一旦一门编程语言支持了某种方式来实现“每个”、“之一”和自引用,它就拥有了描述大量有趣数据的强大能力。这解释了为什么几乎所有编程语言都有构建这类复合类型的方法。

已学类型的分析

现在,让我们用这三种构建块来分析我们已经学过的 ML 类型:

  • 元组(Tuples) 直接体现了 “每个” 的概念。例如,int * bool 类型包含一个 int 一个 bool
  • 选项(Options)“之一” 类型的一个例子,尽管它有点特殊。一个 int option 类型的值要么包含一个 int要么不包含任何数据(即 NONE)。这里涉及的是“或”的关系,没有“与”。
  • 列表(Lists) 则同时使用了所有三种构建块。一个 int list 可以描述为:它要么是一个整数另一个整数列表(这是“每个”和自引用),要么是空列表(这是“之一”)。这些概念可以任意嵌套,让我们能够描述复杂的数据形状。

例如,考虑一个更复杂的类型:它的值要么没有数据,要么包含一些数据;而这些数据本身要么是一个整数对,要么是一个整数列表的列表。这个类型同样可以用“每个”、“之一”和自引用的概念来描述。

后续学习路径

基于这个框架,我们的学习路径将非常清晰:

上一节我们介绍了构建复合类型的通用概念,本节中我们来看看 ML 语言中具体的实现方式。

首先,我们将学习另一种构建 “每个”类型 的方法:记录(Records)。记录很像元组,但它使用命名字段而不是第一、第二位置来访问数据。我们会看到,记录和元组如此相似,以至于可以用“语法糖”这个概念,用记录来描述元组。

接着,我们将学习如何构建自己的 “之一”类型。目前,我们只有选项和列表作为“之一”类型的例子。ML 提供了一种强大的方式来定义自己的类型,例如一个值可以是 intstring。定义这样的类型后,我们需要一种方法来访问其中的数据,这将引入一个可能对你来说非常新颖的概念:模式匹配(Pattern Matching)。如果你只学过 Java、C 或 Python,模式匹配会显得与众不同,但它极其强大,我们会逐渐熟悉并使用它。

在本课程更靠后的部分,我们将会接触面向对象编程(OOP)。你可能会想,在 Java 或 C++ 中从未见过“之一”类型。实际上,你见过,但面向对象编程通过子类(subclasses)子类型(subtypes) 以一种完全不同的、非常优雅的方式来实现它。这与 ML 这类语言的做法恰恰相反。一旦我们了解了这两种范式,就能对它们进行对比,这将是本课程中最有趣、最具普遍意义的收获之一。

总结

本节课中,我们一起学习了构建复合类型的三种通用方式:“每个”类型、“之一”类型和自引用类型。我们分析了 ML 中已有类型(元组、选项、列表)如何对应这些概念,并概述了后续将学习的记录、自定义数据类型和模式匹配。理解这些基础概念,为我们掌握更复杂的数据结构设计和理解不同的编程范式(如函数式与面向对象)奠定了坚实的基础。

030:记录类型

在本节课中,我们将要学习一种新的复合数据类型——记录。记录与之前学过的元组类似,都用于将多个值组合在一起,但记录使用字段名而非位置来访问其组成部分。我们将通过示例学习如何创建和使用记录,并比较记录与元组的异同。

创建记录

上一节我们介绍了记录的基本概念,本节中我们来看看如何创建记录。记录使用花括号 {} 定义,内部包含一系列字段名和对应的表达式。

以下是创建记录的语法和示例:

val x = {bar = true andalso true, foo = (3,4), baz = (false,9)}

执行上述代码后,变量 x 被绑定到一个记录值。该记录包含三个字段:barfoobaz。REPL 会以字母顺序显示字段,但这不影响记录的实际结构。

记录的类型

记录拥有独特的类型,其类型由字段名和每个字段的类型共同决定。类型推断系统会自动推导出记录的类型。

以下是记录类型的表示方法:

{bar: bool, baz: bool * int, foo: int}

此类型表示一个记录,其中 bar 字段为布尔类型,baz 字段为 bool * int 元组类型,foo 字段为整数类型。

访问记录字段

要访问记录中的特定字段,我们使用 #字段名 语法。这与使用 #1#2 访问元组元素的方式类似,但使用的是有意义的名称而非数字索引。

以下是访问记录字段的示例:

#name myNiece

此表达式将返回 myNiece 记录中 name 字段的值。

记录与元组的比较

现在我们已经了解了记录的基本操作,本节中我们来比较记录和元组这两种构造复合类型的方式。两者都用于将多个值组合成一个值,但访问方式不同。

以下是记录与元组的主要区别:

  • 访问方式:元组通过位置(如 #1#2)访问,记录通过字段名(如 #name)访问。
  • 可读性与维护性:当字段数量较多时,使用有意义的字段名(记录)比记住位置顺序(元组)更易于理解和维护代码。
  • 语法简洁性:创建元组通常比创建记录更简洁。

选择使用记录还是元组,取决于具体场景。一般来说,如果组合的各个部分有明确的语义名称,或者字段数量较多,记录是更好的选择。如果只是临时组合少量值,且顺序自然明确,则可以使用元组。

语言设计中的命名与位置

记录和元组的区别引出了一个更广泛的语言设计问题:如何访问复合数据中的各个部分——是通过名称还是通过位置?

函数参数就是一个有趣的混合例子。在调用函数时,我们通过位置传递参数(第一、第二、第三个实参)。但在函数定义内部,我们通过形参名来访问这些值。有些语言允许通过名称传递参数,也有些语言在函数内部通过位置访问参数。理解这种设计选择,有助于我们更深入地掌握编程语言的概念。

总结

本节课中我们一起学习了记录类型。我们了解了如何使用花括号和字段名创建记录,如何通过 #字段名 语法访问记录中的值,以及记录类型的表示方法。通过将记录与元组进行比较,我们认识到根据数据是否具有明确的名称语义来选择不同的复合类型。最后,我们探讨了在语言设计中,通过名称还是位置来访问数据组成部分这一普遍的设计选择。在接下来的课程中,我们将看到记录和元组之间更深层次的联系。

031:元组作为语法糖 🍬

在本节课中,我们将探讨元组(tuples)在编程语言中的一个有趣特性,并借此引入一个非常重要的概念——语法糖。我们将通过实例演示,揭示元组在本质上只是记录(records)的一种特殊写法,从而简化我们对语言特性的理解。


概述

我们已经学习了如何创建和使用元组与记录。本节将展示,元组实际上可以被视为一种“语法糖”,即一种更简洁、更易读的写法,其底层实现完全基于记录。理解这一点,不仅能帮助我们更深入地掌握语言设计,还能简化语言实现。


元组与记录的相似性

上一节我们介绍了元组和记录的基本用法。本节中,我们来看看它们之间更深层次的联系。

在 REPL 环境中,我们可以轻松创建元组和记录。例如,创建一个元组 (3+1, 4+2) 会得到结果 (4, 6),类型为 int * int。同样,创建一个记录 {first = 3+1, second = 4+2} 会得到 {first=4, second=6},类型为 {first:int, second:int}。这看起来是两种不同的数据结构。

然而,如果我们尝试一些特殊的写法,情况会变得有趣。


一个有趣的实验

假设我们创建一个记录,但使用数字作为字段名,例如 {1 = 3+1, 2 = 4+2}。在 REPL 中执行后,输出显示为 (4, 6),并且类型被报告为 int * int。这看起来像是记录被“转换”成了元组。

进一步实验,使用 {3 = “hi”, 1 = true, 2 = 3+2} 创建一个记录。输出为 (true, 5, “hi”),类型为 bool * int * string。这再次表明,使用数字字段名的记录在表现形式和类型上与元组完全相同。


核心概念:语法糖

上述现象引出了本节的核心概念:元组本质上只是记录的一种语法糖

这意味着,在语言定义层面,并不存在独立的“元组”概念。元组的所有语法、类型检查规则和求值规则,都可以完全通过记录来定义和实现。

具体来说:

  • 元组的构建语法 (E1, …, En) 等价于记录 {1 = E1, …, n = En}
  • 元组的类型 T1 * … * Tn 等价于记录类型 {1:T1, …, n:Tn}

因此,当我们编写或求值一个元组时,语言处理器(如编译器或解释器)会将其转换为对应的记录形式进行处理。REPL 在显示结果时,如果遇到字段名为连续数字(从1开始)的记录,则会选择更简洁的元组语法进行输出。


为什么语法糖很重要

语法糖是一个在编程语言设计中非常普遍且重要的概念。

“语法” 指的是这种特性只涉及书写形式的转换,不改变语言的核心语义。元组的语义完全由记录的语义所定义。

“糖” 则形象地比喻了它的作用:让语言变得更“甜美”,即更简洁、更符合直觉、更易于读写。使用 (x, y) 显然比 {1=x, 2=y} 更清晰。

以下是语法糖带来的主要好处:

  • 简化语言理解:一旦理解了元组是记录的语法糖,你只需要掌握记录这一种数据结构的规则,就自然理解了元组的所有行为。
  • 简化语言实现:语言的实现者无需为元组和记录分别编写重复的类型检查、求值代码。只需要实现记录的功能,然后在解析阶段将元组语法转换为对应的记录形式即可。

其他语法糖的例子

元组并非唯一的语法糖例子。实际上,我们已经接触过其他例子。

例如,逻辑运算符 andalso 也可以被视为语法糖。表达式 e1 andalso e2 在语义上完全等价于 if e1 then e2 else falseandalso 提供了更符合直觉的逻辑表达方式,但其底层逻辑可以通过已有的 if-then-else 结构来定义。


总结

本节课中,我们一起学习了语法糖这一核心概念。我们通过实验发现,元组在 ML 语言中只是字段名为连续数字的记录的一种简写形式。语法糖通过提供更优雅的语法来“包装”已有的语言特性,使得程序更易读写,同时简化了语言的理解与实现。理解这一点,是我们深入洞察编程语言设计思想的重要一步。在后续课程中,我们还会遇到更多语法糖的例子。

032:数据类型绑定

在本节课中,我们将开始学习数据类型。这是 ML 语言中用于创建自定义“多选一”类型(one-of types)的核心概念。这是编程中一个非常重要的特性,但 ML 的实现方式与你可能见过的其他语言有很大不同,因此我们需要一些时间来逐步构建所需的概念。

我们将介绍第三种绑定形式。到目前为止,我们已经学习了使用 val 的变量绑定和使用 fun 的函数绑定。现在,我们将学习以关键字 datatype 开头的数据类型绑定。这种绑定在一个声明中包含了更强大的功能,因此我们需要详细解释使用数据类型绑定时发生的不同事情。

数据类型绑定的语法

以下是数据类型绑定的基本结构。你首先写一个任意的类型名称(例如 mytype),然后是等号,接着是一系列用竖线 | 分隔的可能性。你可以将竖线理解为“或”。

datatype mytype = TwoInts of int * int
                | Str of string
                | Pizza

这段代码定义了一个新类型 mytype。创建 mytype 类型值的方式有三种:要么携带一个 int * int 对,要么携带一个 string,要么什么都不携带。因此,它是一个“多选一”类型。每个 mytype 类型的值要么是一个整数对,要么是一个字符串,要么是空值。我们可以定义任意数量的可能性。

构造函数

现在,我们来解释代码中那些大写字母开头的标识符:TwoIntsStrPizza。我选择 Pizza 是为了强调这些名称可以是任何你想要的。按照惯例,它们通常大写,有些人甚至全部使用大写字母。我们称这些为构造函数

当你引入一个数据类型绑定时,实际上是在向静态环境和动态环境中添加多个新内容:新的类型名称(mytype)以及这些构造函数。

构造函数有几个用途,但目前我们可以将它们理解为函数:给定正确类型的参数,它们会返回一个 mytype 类型的值。

  • TwoInts 现在是一个类型为 int * int -> mytype 的函数。给它一个整数对,它会返回一个 mytype
  • Str 是一个类型为 string -> mytype 的函数。
  • Pizza 不是一个函数,因为它不需要任何参数。它本身就是一个 mytype 类型的值。

在 REPL 中实践

让我们在 REPL 中尝试这个例子。我们定义了上述数据类型,然后使用构造函数以各种方式创建变量绑定。

datatype mytype = TwoInts of int * int | Str of string | Pizza;

val a = Str "hi";
val b = Str;
val c = Pizza;
val d = TwoInts (3+4, 5+6);
val e = a;

REPL 首先会评估数据类型绑定,并打印出整个定义,因为它与后续程序相关。现在我们有了类型 mytype 及其构造函数。

  • val a = Str "hi"Str 是一个从 stringmytype 的构造函数。用字符串 "hi" 调用它,我们得到一个 mytype 类型的值 Str "hi"
  • val b = Str:这里 Str 后面没有参数,所以 b 被绑定为函数 Str 本身,其类型是 string -> mytype。这是一个常见的编程错误,你可能本意是想调用 Str,但这在类型检查上是合法的。
  • val c = PizzaPizza 本身就是一个 mytype 类型的值,所以 c 被绑定为 Pizza
  • val d = TwoInts (3+4, 5+6):首先计算 (7, 11),然后将其传递给构造函数 TwoInts,得到值 TwoInts (7,11)
  • val e = a:这很简单,e 也被绑定到值 Str "hi"

值的内部结构

任何 mytype 类型的值都是由其中一个构造函数创建的,这就是它被称为“多选一”类型的原因。返回的值实际上包含两部分:

  1. 标签部分:记录是哪个构造函数创建了这个值。
  2. 数据部分:存储构造函数携带的相应数据。

例如,表达式 TwoInts (3+4, 5+4) 会求值为一个标签为 TwoInts、数据部分为 (7,9) 的值。表达式 Str (if true then "hi" else "bye") 会求值为标签为 Str、数据部分为 "hi" 的值。

访问数据:我们已有的方式

现在我们知道如何构建数据类型的值了。但是,每当在语言中引入一个新类型时,我们既需要构建它们的方法,也需要访问它们的方法。

对于像 mytype 这样的“多选一”类型,访问值有两个方面:

  1. 我们需要某种方式来检查我们拥有的是哪种变体,即,是哪个构造函数创建了它(标签是什么)。
  2. 我们还需要一种方式来获取底层的数据(如果有的话)。对于 Pizza 没有数据,但对于 TwoIntsStr 则有。

值得回顾一下我们见过的其他“多选一”类型:列表(list)和选项(option)。它们也有这两种访问方式:

  • null(检查列表是否为空)和 isSome(检查选项是 SOME 还是 NONE)是变体检查函数。它们只告诉你标签是什么。
  • hdtlvalOf 函数则是数据提取函数hd 返回列表的一部分,tl 返回列表的另一部分,valOf 返回 SOME 包裹的值。注意,如果你对错误的变体应用这些函数(例如对空列表应用 hd,或对 NONE 应用 valOf),它们会引发异常。

ML 的选择

既然我们有了数据类型绑定,ML 本可以这样做:当你引入一个像 datatype mytype = ... 这样的绑定时,除了获得构造函数(TwoIntsStrPizza)之外,还会自动获得用于检查变体和提取数据的函数。

例如,它可能会向环境中添加 isStr : mytype -> bool(如果是 Str 创建的值则返回 true)和 getStrData : mytype -> string(如果是 Str 则获取底层字符串,否则引发异常)这样的函数。这将是 null/hd 工作方式的直接类比。

这会是完全合理的语言设计,可能现在教起来更容易。但是,ML 选择了一种更优的方式,我们将在下一节开始学习它。

总结

本节课我们一起学习了 ML 中数据类型绑定的基础。我们了解了如何使用 datatype 关键字定义自定义的“多选一”类型,以及如何使用构造函数创建该类型的值。每个值都包含一个标识其变体的标签和相应的数据部分。我们还回顾了访问这类值通常需要的两种操作:检查变体和提取数据,并以列表和选项类型为例进行了说明。最后,我们提到 ML 没有为自定义数据类型自动生成类似 null/hd 的检查器和提取器,而是采用了一种更强大的机制,这将是后续课程的重点。

033:Case 表达式 🧩

在本节课中,我们将开始学习 Case 表达式。这是 ML 语言中用于访问由数据类型绑定(data type bindings)所创建值的语言结构。我们将结合上一节学到的两个概念:需要一种方法来识别“或”类型(one of type)的具体变体,以及需要一种方法来提取其内部数据。我们将看到 ML 将这两个功能结合到了一个单一的语言结构中。在后续章节中,我们会发现它实际上比这里展示的更强大,但本节我们将介绍其基础用法。

语法与基本用法

上一节我们介绍了如何定义数据类型,本节我们来看看如何使用 Case 表达式来访问这些类型的值。

以下是一个示例,定义了一个名为 mytype 的数据类型,它由三个构造器(constructor)组成:TwoIntsStrPizza

datatype mytype = TwoInts of int * int
                | Str of string
                | Pizza

现在,我们定义一个函数 f,它接收一个 mytype 类型的参数,并返回一个整数。函数体使用 Case 表达式来处理不同的情况。

fun f x =
    case x of
        Pizza => 3
      | Str s => 8
      | TwoInts(i1, i2) => i1 + i2

以下是 Case 表达式的执行过程:

  1. 求值匹配对象:首先,计算 caseof 之间的表达式(此处是 x),得到一个值。
  2. 模式匹配:从上到下依次检查每个分支的模式(PizzaStr sTwoInts(i1, i2))。
  3. 绑定变量并求值:找到第一个匹配的模式后,将模式中的变量(如 si1i2)绑定到对应数据部分,然后在扩展的环境中计算 => 右侧的表达式,其结果即为整个 Case 表达式的结果。

例如:

  • f Pizza 计算结果为 3
  • f (Str “hi”) 计算结果为 8
  • f (TwoInts(7,9)) 计算结果为 16

Case 表达式的优势

为什么 Case 表达式比提供类似 isTwoIntsgetFirstInt 这样的函数更好?以下是几个关键原因:

  1. 完备性检查:编译器可以检查你是否覆盖了所有可能的情况。如果你漏掉某个构造器(例如忘记处理 Str),编译器会发出“非穷尽匹配”的警告。
  2. 冗余检查:编译器能检测并报错冗余的、永远不会被执行到的分支。
  3. 安全性:通过模式匹配直接提取数据,可以避免运行时错误。例如,在 TwoInts(i1, i2) 分支中,i1i2 已经被安全地绑定为整数,不会出现试图从 Pizza 中提取整数的情况。
  4. 表达力与简洁性:模式匹配本身是一种强大而优雅的抽象,未来我们会看到更复杂的模式,它能让我们写出比使用一堆检查函数更简洁、清晰的代码。

总结

本节课中我们一起学习了 Case 表达式的基础知识。我们了解了其语法结构,即 case e0 of p1 => e1 | p2 => e2 | ...,并掌握了其通过模式匹配进行求值的规则。我们看到了如何使用它来安全、完备地处理自定义数据类型的各个变体,并理解了它相比传统检查函数在安全性、编译器辅助和代码优雅性方面的优势。这是处理 ML 中复合数据的核心工具。

034:实用数据类型示例 🎯

在本节课中,我们将学习数据类型的实际应用。我们将通过几个具体示例,展示如何使用数据类型绑定来建模现实世界中的概念,并编写操作这些数据的函数。


枚举类型示例 ♠️

上一节我们介绍了数据类型的基本概念,本节中我们来看看它的一个简单应用:枚举。

假设我们需要表示扑克牌的花色。在英语中,每张扑克牌的花色是梅花、方块、红心或黑桃之一。使用数据类型可以完美地表示这个概念:

datatype suit = Club | Diamond | Heart | Spade

以下是使用枚举的优势:

  • 避免使用魔术数字(如用1代表梅花,2代表方块),代码更易读。
  • 获得编译器的类型检查支持,减少错误。
  • 处理所有可能情况时,可以使用case表达式,为每个构造器提供一个分支。

如果不使用枚举,代码将难以维护和理解。


带数据的构造器示例 🔢

现在,我们来看一个构造器可以携带数据的例子:扑克牌的牌面大小。

牌面可以是数字(2-10),也可以是J、Q、K、A。我们可以这样定义:

datatype rank = Num of int | Jack | Queen | King | Ace

这里,Num构造器携带一个int类型的数据。虽然这个定义没有限制int必须在2到10之间,但它提供了一种简洁、可读性高的方式来表示牌面。之后,我们可以创建一个记录类型,将suitrank组合起来,表示一张完整的扑克牌。


“多选一”与“全都要”的对比 ⚖️

理解何时使用“多选一”类型(datatype)与“全都要”类型(记录/元组)至关重要。

假设我们要表示学生的身份标识。有些学生有学号,而另一些旁听生只有姓名。这是一个典型的“多选一”场景:

datatype id = StudentNum of int
            | Name of {first:string, middle:string option, last:string}

然而,学生有时会错误地使用记录类型来模拟这种情况,例如定义一个包含student_numfirstmiddlelast所有字段的记录,并约定当student_num为-1时使用姓名字段。这种做法很差,因为它:

  • 放弃了语言对“多选一”的强制保障。
  • 依赖注释和特殊值(如-1),容易出错且不清晰。

相反,如果我们要建模的场景是“每个学生都有姓名,并且可能有一个学号”,那么这就是一个“全都要”但包含可选字段的场景,应使用记录加option类型:

type student_info = { student_num : int option,
                      first : string,
                      middle : string option,
                      last : string }

设计软件时,关键在于理解数据的内在关系,并选择正确的复合类型来直接体现这种关系。


递归数据类型与函数示例 🌳

最后,我们探讨一个更强大的概念:递归数据类型。这对于表示像程序语言本身这样的嵌套结构非常有用。

我们定义一个表示简单算术表达式的数据类型:

datatype exp = Constant of int
             | Negate of exp
             | Add of exp * exp
             | Multiply of exp * exp

这个定义是递归的,因为NegateAddMultiply这些构造器内部又包含了exp类型的数据。它定义了一棵树:叶子节点是带整数的Constant,内部节点是Negate(一个子节点)、AddMultiply(两个子节点)。

例如,表达式 (10+9) + (-4) 可以构建为:

Add(Constant(19), Negate(Constant(4)))

其对应的树形结构如下:

     Add
    /   \
  19    Negate
          |
          4

定义了数据类型后,我们可以编写递归函数来处理它。最明显的函数是求值器eval

fun eval (e : exp) =
    case e of
        Constant i => i
      | Negate e2 => ~ (eval e2)
      | Add(e1, e2) => (eval e1) + (eval e2)
      | Multiply(e1, e2) => (eval e1) * (eval e2)

函数通过case表达式分析exp的具体形式。对于Constant,直接返回值。对于NegateAddMultiply,则递归地对子表达式调用eval,然后将结果组合。处理递归数据类型的函数本身也常常是递归的。

我们还可以编写其他函数,例如计算表达式中加法运算的数量:

fun number_of_adds (e : exp) =
    case e of
        Constant i => 0
      | Negate e2 => number_of_adds e2
      | Add(e1, e2) => 1 + (number_of_adds e1) + (number_of_adds e2)
      | Multiply(e1, e2) => (number_of_adds e1) + (number_of_adds e2)

总结 📚

本节课中我们一起学习了数据类型的多种实用模式:

  1. 枚举:用于表示固定集合的值,使代码清晰、安全。
  2. 带数据的构造器:为类型变体关联具体信息。
  3. “多选一”建模:正确使用datatype来精确表示互斥的可能性,并与记录类型进行对比。
  4. 递归数据类型:用于定义树状等嵌套结构,并编写相应的递归函数来处理它们。

数据类型是强大工具,能帮助我们精确建模问题域,并编写出结构清晰、易于维护的代码。

035:模式匹配回顾与基础

在本节课中,我们将系统回顾 ML 语言中数据类型绑定和 case 表达式这两个核心构造。我们将精确理解它们的语法、类型检查规则和求值规则,为后续学习更强大的模式匹配功能打下坚实基础。

数据类型绑定 📝

上一节我们介绍了函数定义,本节中我们来看看如何定义自己的数据类型。数据类型绑定是 ML 中用于创建新类型和其值构造器的语法。

其基本语法如下:

datatype T = C1 of t1 | C2 of t2 | ... | Cn of tn
  • 引入新类型T 是一个在程序中此前不存在的新类型名称。
  • 添加构造器C1C2 等被称为值构造器。它们有两个作用:
    • 作为创建 T 类型值的函数。例如,C1 的类型是 t1 -> TC2 的类型是 t2 -> T
    • 作为值本身的“标签”。
  • 创建值:将一个构造器应用于一个值,其结果本身就是一个值。例如,C1 e 是一个类型为 T 的值,它包含了构造器标签 C1 和其下承载的值。
  • 无数据构造器:如果构造器不承载任何数据,则省略 of 和类型部分。此时,构造器本身就是一个类型为 T 的值,而非函数。例如:datatype Color = Red | Green | Blue,其中 Red 就是一个 Color 类型的值。

Case 表达式 🔍

现在我们知道如何创建自定义数据类型的值了,接下来看看如何访问和使用这些值内部的组成部分。这需要通过 case 表达式来实现。

case 表达式的语法结构如下:

case e of
    p1 => e1
  | p2 => e2
  ...
  | pn => en
  • 整体是一个表达式:整个 case 结构本身就是一个表达式,因此可以出现在任何允许表达式出现的地方。虽然常见于函数体,但并非必须。
  • 组成部分
    • e:位于 caseof 之间的任意表达式。
    • 多个分支:每个分支由模式 p、箭头 => 和表达式 e 组成。

其求值规则定义如下:

  1. 求值 e:首先对表达式 e 进行求值,得到一个值 v
  2. 顺序匹配:将值 v 按顺序与每个分支的模式 p1p2…… pn 进行匹配。
  3. 执行匹配分支:找到第一个匹配的模式 pi 后,对其对应的表达式 ei 进行求值。该求值结果即为整个 case 表达式的值。只会执行一个分支
  4. 模式匹配与变量绑定
    • 目前,我们的模式形如 C(x1, x2, ...),其中 C 是构造器名,x1x2 等是变量。
    • 当值 v 的形式为 C(v1, v2, ...) 时,模式匹配成功。
    • 匹配成功后,在求值表达式 ei 时,会扩展当前动态环境,将变量 x1x2 等分别绑定到对应的值 v1v2 等。这就是我们提取底层数据的方式。
  5. 无数据构造器的匹配:对于不承载数据的构造器(如 Red),模式中不包含括号和变量。匹配成功后,环境无需扩展,直接求值对应分支的表达式即可。

总结与展望 🚀

本节课中我们一起学习了 ML 语言中数据类型绑定和 case 表达式的基础知识。我们明确了:

  • 如何使用 datatype 关键字定义新的数据类型和构造器。
  • 如何使用 case 表达式对不同类型的值进行分情况处理,并通过模式匹配提取其内部数据。
  • 理解了从语法到求值规则的完整过程。

目前我们所见的模式匹配功能已经非常实用。在接下来的章节中,我们将扩展模式匹配的概念,使其变得更通用、更强大。届时,我们今天所学的所有规则依然成立,我们只是在此基础上增加一些同样成立的、有用的新规则。

036:另一个表达式示例教程 🧮

在本节课中,我们将学习如何编写一个函数来查找算术表达式中的最大常量。我们将使用一个已定义的数据类型,并通过多个步骤逐步优化我们的解决方案,确保代码既正确又高效。


概述

本节课的目标是演示如何编写一个函数,该函数能够在一个算术表达式中找到最大的常量。我们将使用一个数据类型绑定,该数据类型定义了常量、取反、加法和乘法等构造器。我们将从基础实现开始,逐步优化,最终得到一个简洁高效的解决方案。


数据类型定义

首先,我们定义了一个数据类型 exp,用于表示算术表达式。它包含以下构造器:

  • Constant:表示一个整数常量。
  • Negate:表示对一个表达式的取反操作。
  • Add:表示两个表达式的加法操作。
  • Multiply:表示两个表达式的乘法操作。

以下是数据类型的定义:

datatype exp = Constant of int
             | Negate of exp
             | Add of exp * exp
             | Multiply of exp * exp

函数目标

我们需要编写一个名为 max_constant 的函数,其类型为 exp -> int。该函数的功能是查找表达式中所有常量中的最大值。


测试用例

在编写函数之前,我们先创建一个测试用例,以确保函数的行为符合预期。以下是测试用例:

val test_exp = Add (Constant 19, Negate (Constant 4))
val test_result = max_constant test_exp

测试表达式的最大常量应为 19


初始实现

首先,我们使用 case 表达式对输入表达式进行模式匹配。以下是初始实现:

fun max_constant e =
    case e of
        Constant i => i
      | Negate e2 => max_constant e2
      | Add (e1, e2) =>
          if max_constant e1 > max_constant e2
          then max_constant e1
          else max_constant e2
      | Multiply (e1, e2) =>
          if max_constant e1 > max_constant e2
          then max_constant e1
          else max_constant e2

这个实现虽然正确,但存在效率问题,因为它会多次递归计算相同子表达式的最大常量。


优化:使用 let 绑定

为了避免重复计算,我们使用 let 绑定来存储递归调用的结果。以下是优化后的实现:

fun max_constant e =
    case e of
        Constant i => i
      | Negate e2 => max_constant e2
      | Add (e1, e2) =>
          let
              val m1 = max_constant e1
              val m2 = max_constant e2
          in
              if m1 > m2 then m1 else m2
          end
      | Multiply (e1, e2) =>
          let
              val m1 = max_constant e1
              val m2 = max_constant e2
          in
              if m1 > m2 then m1 else m2
          end

这个版本避免了重复计算,提高了效率。


优化:提取辅助函数

注意到 AddMultiply 分支的代码非常相似,我们可以提取一个辅助函数来避免代码重复。以下是优化后的实现:

fun max_constant e =
    let
        fun max_of_two (e1, e2) =
            let
                val m1 = max_constant e1
                val m2 = max_constant e2
            in
                if m1 > m2 then m1 else m2
            end
    in
        case e of
            Constant i => i
          | Negate e2 => max_constant e2
          | Add (e1, e2) => max_of_two (e1, e2)
          | Multiply (e1, e2) => max_of_two (e1, e2)
    end

这个版本通过辅助函数 max_of_two 减少了代码重复。


优化:使用内置函数

SML 标准库提供了一个内置函数 Int.max,用于计算两个整数的最大值。我们可以直接使用它来简化代码。以下是优化后的实现:

fun max_constant e =
    case e of
        Constant i => i
      | Negate e2 => max_constant e2
      | Add (e1, e2) => Int.max (max_constant e1, max_constant e2)
      | Multiply (e1, e2) => Int.max (max_constant e1, max_constant e2)

这个版本更加简洁,并且仍然保持了高效性。


最终实现

经过多次优化,我们得到了一个简洁且高效的最终实现:

fun max_constant e =
    case e of
        Constant i => i
      | Negate e2 => max_constant e2
      | Add (e1, e2) => Int.max (max_constant e1, max_constant e2)
      | Multiply (e1, e2) => Int.max (max_constant e1, max_constant e2)

这个实现通过模式匹配和内置函数 Int.max 高效地计算了表达式的最大常量。


总结

在本节课中,我们一起学习了如何编写一个函数来查找算术表达式中的最大常量。我们从基础实现开始,逐步优化,最终得到了一个简洁高效的解决方案。通过这个过程,我们复习了以下概念:

  • 使用 case 表达式进行模式匹配。
  • 使用 let 绑定避免重复计算。
  • 提取辅助函数减少代码重复。
  • 利用内置函数简化代码。

希望这个示例能够帮助你更好地理解如何编写高效且优雅的 SML 函数!

037:类型别名 📝

在本节课中,我们将要学习类型别名。这是一种与之前学过的数据类型绑定不同的新概念。虽然它有点偏离数据类型绑定和case表达式的主线,但对完成接下来的作业非常有帮助,并且与数据类型绑定形成了很好的对比。

概述

数据类型绑定会引入一个全新的类型名称,这个新类型与其他所有类型都不同。创建该类型实例的唯一方法,就是使用该数据类型绑定中定义的构造函数。

类型别名则是一个全新的结构。它并不创建新类型,只是为已有的类型创建一个新的名称。接下来,我们将详细探讨它的语法、用途以及与数据类型绑定的区别。

类型别名的语法与含义

类型别名的语法非常简单。你只需要使用关键字 type(注意,不是 datatype),后面跟上你为类型起的新名字,然后是等号 =,最后是已有的类型。

type card = suit * rank

这里的英文单词“synonym”(同义词)非常贴切。它只是为已经存在的类型(例如 suit * rank)创建了另一个名字(card)。现在,我们可以在任何使用 suit * rank 的地方使用 card,反之亦然。它们在所有方面都是可以互换的。

你甚至不必担心交互环境(REPL)会打印出哪一个名字。类型 card 和类型 suit * rank 完全可以互换使用。

代码示例:扑克牌

为了更好地理解,让我们看一些ML代码。首先,我们有两个之前见过的数据类型绑定,用于定义扑克牌的花色点数

datatype suit = Clubs | Diamonds | Hearts | Spades
datatype rank = Jack | Queen | King | Ace | Num of int

现在,我们引入一个类型别名。这个别名提醒我们,并且允许我们在程序中,将“花色和点数的配对”视为一个名为 card 的类型。

type card = suit * rank

现在,card 这个新类型名称就存在于我们的环境中了。它意味着 suit * rank 这个配对类型。因此,写 suit * rank 或写 card 都是可以的。

类型别名的常见用法

类型别名还有一个常见的用法,就是为记录类型命名。直接写出完整的记录类型定义非常麻烦,也不便于记忆和引用。

例如,与其写注释说明“这个记录类型用于描述我班上学生的姓名和学号”,不如直接在程序中给这个类型起个名字。

type student = {name: string, id: int}

然后,你就可以在程序中任何需要的地方使用 student 这个类型名了。

类型的可互换性

现在,让我们重点强调这种可互换性。我写了一个简短的函数 is_queen_of_spades,它接受一个类型为 card 的参数 c

fun is_queen_of_spades (c: card) =
    (#1 c) = Spades andalso (#2 c) = Queen

注意,我可以在函数签名中使用上面定义的 card 这个类型名。

接下来,我们看三个变量绑定:c1, c2, c3

val c1 : card = (Spades, Ace)
val c2 : suit * rank = (Spades, Ace)
val c3 = (Spades, Ace)

c3 是我们最常用的变量绑定写法,我直接创建了一个 (Spades, Ace) 对。你可能会问,它的类型是什么?是 suit * rank 还是 card

答案是:两者都是,因为这两个类型是相同的。实际上,c1c2 都是完全合理的变量声明。在ML中,你可以选择性地使用冒号 : 为变量标注类型,类型检查器会确保右侧的表达式确实具有该类型。

在REPL中运行,我们会发现 c1c2c3 都能顺利通过类型检查。REPL可能会打印出 c1 的类型是 card,而 c2c3 的类型是 suit * rank。但请放心,这完全没有问题,因为 cardsuit * rank 是同一个类型。

为何使用类型别名?

这引出了一个有趣的问题:既然类型别名没有增加新功能,为什么语言要支持它?

首先,方便性本身就是有价值的。如果你想写 card 而不是又长又具体的 suit * rank,语言提供这种便利是很好的。

唯一的潜在困惑是,如果你写了一个函数,其类型标注为 card -> bool,而REPL可能显示为 suit * rank -> bool。你必须认识到,这是等价的类型,完全没有问题。

目前,类型别名确实没有让我们能做任何新的事情。但在本课程后续关于ML模块系统的学习中,我们将在此基础上构建更强大的功能,那时类型别名将是必不可少的工具。

总结

本节课我们一起学习了类型别名

  • 数据类型绑定datatype)会创建一个全新的、不同的类型
  • 类型别名type)只是为已存在的类型创建一个新的名称,两者在所有上下文中都可以互换使用。
  • 类型别名的主要作用是提高代码的可读性和便利性,例如为复杂的配对类型或记录类型赋予一个有意义的名称。
  • 理解类型别名与其底层类型的完全等价性至关重要,这能帮助你正确理解类型检查器的输出。

掌握类型别名,将为后续学习更高级的编程概念打下坚实的基础。

038:列表与选项作为数据类型

在本节课中,我们将回顾之前学过的列表和选项类型,并揭示它们的本质:它们实际上是数据类型绑定。我们将学习使用case表达式来操作列表和选项,这是一种更优的方式。

概述

之前我们学习了如何使用nullheadtail来操作列表,以及使用isSomevalOf来操作选项。本节课程将揭示,列表和选项本质上是通过数据类型绑定定义的,我们可以使用更安全、更优雅的case表达式和模式匹配来替代之前的函数。

自定义列表类型

为了理解内置列表的工作原理,我们先看看如何用数据类型绑定定义自己的列表类型。

我们可以定义一个递归的整数列表类型my_int_list,它有两种情况:空列表(Empty)或由“构造器”(Cons)构建的非空列表。Cons构造器接受一个整数和另一个my_int_list作为参数。

以下是定义:

datatype my_int_list = Empty | Cons of int * my_int_list

使用这个类型,我们可以创建一个列表变量。例如,表示列表[4, 23, 2008]的代码如下:

val x = Cons(4, Cons(23, Cons(2008, Empty)))

这段代码从内向外构建列表:Cons(2008, Empty) 构成一个单元素列表,然后作为第二个参数传递给外层的Cons构造器,依此类推。

接着,我们可以为这个自定义列表类型编写函数,例如append函数。我们会使用case表达式进行模式匹配,其算法与内置列表的append相同。

以下是append函数的实现:

fun append_my_list (xs, ys) =
    case xs of
        Empty => ys
      | Cons(x, xs') => Cons(x, append_my_list(xs', ys))

如果xsEmpty,则直接返回ys。否则,将xs匹配为Cons(x, xs'),然后递归地将x添加到append_my_list(xs', ys)的结果前面。

需要明确的是,在实际编程中应使用ML内置的列表,这里只是为了演示原理。

使用Case表达式处理选项

上一节我们介绍了自定义数据类型,本节我们来看看如何将case表达式应用于内置的选项类型。

我之前介绍过,可以使用NONESOME创建选项,用isSome检查是否为SOME,用valOf提取值。现在我将告诉你一个事实:选项类型也是通过数据类型绑定定义的NONESOME就是构造器。

因此,我们可以直接在case表达式的模式中使用NONESOME

例如,以下函数接收一个int option,如果是NONE则返回0,如果是SOME i则返回i+1

fun inc_or_zero (x : int option) =
    case x of
        NONE => 0
      | SOME i => i + 1

模式NONE不携带数据,而SOME i会将i绑定到所包含的值上。

使用case表达式比isSomevalOf更好,原因包括:不会遗漏分支、代码更易读、避免了对NONE应用valOf导致的运行时错误。因此,在作业2及以后的ML编程中,推荐使用这种风格。

使用Case表达式处理列表

理解了选项的处理方式后,对于列表我们也可以采用同样的思路。我们将不再使用nullheadtail,而是使用case表达式。

对于内置列表,模式匹配的语法稍有不同:

  • 空列表的模式是 []
  • 非空列表的模式是 x::xs'。这里::是构造器,x匹配列表的第一个元素(头),xs'匹配剩余部分(尾)。

以下是两个示例函数。

第一个是sum_list,用于计算列表所有元素的和:

fun sum_list (xs : int list) =
    case xs of
        [] => 0
      | x::xs' => x + sum_list(xs')

如果列表为空,和为0。否则,将头元素x与剩余列表xs'的和相加。

第二个是append函数,用于连接两个列表:

fun append (xs : int list, ys : int list) =
    case xs of
        [] => ys
      | x::xs' => x :: append(xs', ys)

如果xs为空,结果就是ys。否则,将xappend(xs', ys)的结果用::连接起来。

使用case表达式的优点与选项相同:避免遗漏情况、防止对空列表调用tail等错误。为了让大家熟练掌握,在第二次作业中,将要求必须使用case表达式进行模式匹配。

你可能会问,既然case表达式更好,为什么ML还要提供nullheadtailisSomevalOf这些函数呢?原因有几个:

  1. 它们有时可以作为参数传递给其他函数(我们将在后续高阶函数章节看到)。
  2. 在某些简单场景下,它们写起来更便捷。
  3. 它们很容易定义,ML提供它们是为了让所有人使用统一的函数名。

总结

本节课中我们一起学习了列表和选项类型的本质。我们看到,ML语言的核心其实更精简:列表和选项并非语言的特殊新增部分,它们只是通过通用的数据类型绑定机制预定义的、非常有用的类型。我们使用case表达式模式匹配来操作它们,这种方式更安全、更清晰。在接下来的课程中,我们将探索为什么我们无法完全像定义my_int_list那样自己定义完全一样的内置列表,这引出了下一个有趣的话题。

039:多态数据类型 🧬

在本节课中,我们将学习如何定义自己的多态数据类型。我们将看到,ML 语言中的列表(list)和选项(option)类型并非特殊的内置结构,而是通过一种通用的多态数据类型定义机制实现的。通过理解这种机制,你将能够创建自己的、可以处理多种类型数据的自定义类型。

概述

之前我们提到,ML 中的列表和选项类型并不特殊,它们只是普通的数据类型绑定。但有一个关键区别:列表和选项是类型构造器,它们接受类型参数来生成具体的类型(例如 int list)。本节课将展示如何在 ML 中定义自己的多态数据类型,从而理解列表和选项的实现原理。

多态数据类型的定义语法

上一节我们介绍了普通的数据类型绑定。本节中我们来看看如何为其添加类型参数,使其成为多态类型。

定义多态数据类型的语法是在 datatype 关键字后、新类型名称前,声明一个或多个类型参数。这些参数通常用希腊字母表示,如 'a(读作“alpha”)。

以下是几个关键示例:

  • 选项类型:ML 内置的 option 类型可以这样定义:

    datatype 'a option = NONE | SOME of 'a
    

    这里,'a 是一个类型参数。int optionstring option 才是具体的类型。SOME 构造器携带一个类型为 'a 的值。

  • 列表类型(忽略其特殊语法):列表的核心结构可以这样定义:

    datatype 'a mylist = Empty | Cons of 'a * ('a mylist)
    

    注意,递归部分 ('a mylist) 必须使用相同的类型参数 'a,这保证了列表中的所有元素类型一致。

  • 多参数类型:我们还可以定义具有多个类型参数的类型。例如,一个二叉树,其内部节点和叶子节点可以存储不同类型的数据:

    datatype ('a, 'b) tree = Leaf of 'b
                           | Node of 'a * ('a, 'b) tree * ('a, 'b) tree
    

    这里,'a 是内部节点数据的类型,'b 是叶子节点数据的类型。它们可以相同,也可以不同。

使用多态数据类型编写函数

定义了多态数据类型后,我们可以像使用普通类型一样编写函数。函数的类型会根据其如何使用数据而自动推导。

以下是使用内置列表类型和自定义树类型编写的几个函数示例:

处理列表的函数

首先,我们回顾两个处理列表的函数,以观察类型推导的差异。

  • sum_list 函数:对整数列表求和。

    fun sum_list xs =
        case xs of
            [] => 0
          | x::xs' => x + sum_list xs'
    

    类型推导器会发现 x 参与了加法运算 +,且基准情况返回 0int 类型)。因此,它推断出 sum_list 的类型必须是 int list -> int

  • append 函数:连接两个列表。

    fun append (xs, ys) =
        case xs of
            [] => ys
          | x::xs' => x :: append(xs', ys)
    

    类型推导器发现,函数实现不依赖于列表元素的特定类型,但要求两个输入列表 xsys 的元素类型必须相同(因为我们将 xappend 的结果拼接)。因此,它推断出 append 的类型是 ('a list * 'a list) -> 'a list,这是一个多态类型。

处理自定义树的函数

现在,我们使用之前定义的 ('a, 'b) tree 类型来编写函数,看看类型推导如何工作。

以下是三个函数的定义:

  1. sum_tree 函数:计算树中所有节点(包括内部节点和叶子)值的总和。

    fun sum_tree tr =
        case tr of
            Leaf i => i
          | Node (i, left, right) => i + sum_tree left + sum_tree right
    

    由于函数对叶子节点值 i 和内部节点值 i 都进行加法操作,类型推导器要求 'a'b 都必须是 int。因此,sum_tree 的类型是 (int, int) tree -> int

  2. sum_leaves 函数:仅计算树中所有叶子节点值的总和。

    fun sum_leaves tr =
        case tr of
            Leaf i => i
          | Node (_, left, right) => sum_leaves left + sum_leaves right
    

    这个函数只使用了叶子节点的值 i(进行加法),而忽略了内部节点的数据(用 _ 匹配)。因此,类型推导器只要求叶子类型 'bint,而内部节点类型 'a 可以是任意类型。sum_leaves 的类型是 ('a, int) tree -> int,它是一个多态函数。

  3. num_leaves 函数:统计树中叶子节点的数量。

    fun num_leaves tr =
        case tr of
            Leaf _ => 1
          | Node (_, left, right) => num_leaves left + num_leaves right
    

    这个函数完全不关心节点中存储的具体数据(叶子节点和内部节点的值都用 _ 忽略)。因此,它对类型参数 'a'b 都没有限制。num_leaves 的类型是 ('a, 'b) tree -> int,这是一个完全多态的函数。

总结

本节课中我们一起学习了 ML 中多态数据类型的核心概念。

  • 定义:使用 datatype ‘a mytype = ... 语法可以定义自己的多态类型,其中 ‘a 是类型参数。
  • 本质:列表和选项类型正是利用此机制定义的,并非语言魔法。
  • 类型推导:函数的类型会根据其如何使用数据成员而自动、精确地推导出来。
    • 如果函数对数据进行了特定类型的操作(如整数加法),则对应的类型参数会被具体化(如 int)。
    • 如果函数未使用某些数据,则对应的类型参数可以保持多态(如 ‘a)。
  • 一致性:核心原则不变——在一个具体的数据结构实例中,所有对应相同类型参数的位置,其类型必须一致。

通过掌握多态数据类型的定义和使用,你便拥有了构建灵活、通用数据结构的强大工具,这正是 ML 类型系统优雅而强大的体现。

040:模式匹配的真相与函数的本质

在本节课中,我们将深入探讨模式匹配的扩展应用,并揭示一个关于ML函数的重要真相。我们将学习如何对“each of”类型(如元组和记录)使用模式匹配,并最终理解ML中所有函数都只接受一个参数这一核心概念。

模式匹配的扩展应用

上一节我们介绍了如何对“one of”类型(即自定义数据类型)使用模式匹配。本节中,我们来看看模式匹配如何同样适用于“each of”类型,即元组和记录。

元组模式匹配

元组模式匹配允许我们直接提取元组中的各个分量。其语法类似于元组表达式,但逗号之间放置的是变量。

公式(x1, x2, ..., xn) 匹配一个n元组,并将第i个分量绑定到变量xi

类型检查器会确保模式中的变量数量与元组的分量数量一致。

记录模式匹配

记录模式匹配允许我们按字段名提取记录中的值。

公式{f1=x1, f2=x2, ..., fn=xn} 匹配一个具有字段f1fn的记录,并将对应字段的值绑定到变量x1xn

字段的顺序无关紧要。

从案例表达式到更优雅的风格

以下是使用模式匹配的不同风格示例,我们将看到如何从基础的案例表达式过渡到更简洁的写法。

初始风格:使用单分支案例表达式

这种风格有助于理解原理,但并非最佳实践。

(* 对三元组求和 *)
fun sum_triple triple =
    case triple of
        (x, y, z) => x + y + z

(* 拼接全名 *)
fun full_name r =
    case r of
        {first=x, middle=y, last=z} => x ^ " " ^ y ^ " " ^ z

改进风格:使用let绑定进行模式匹配

我们可以用let绑定来替代单分支的case表达式,这更符合习惯。

(* 对三元组求和 *)
fun sum_triple triple =
    let val (x, y, z) = triple
    in x + y + z
    end

(* 拼接全名 *)
fun full_name r =
    let val {first=x, middle=y, last=z} = r
    in x ^ " " ^ y ^ " " ^ z
    end

最佳风格:直接在函数参数中使用模式匹配

ML允许函数参数本身就是一个模式,这是最简洁优雅的方式。

(* 对三元组求和 *)
fun sum_triple (x, y, z) = x + y + z

(* 拼接全名 *)
fun full_name {first=x, middle=y, last=z} = x ^ " " ^ y ^ " " ^ z

使用这种风格,类型检查器能自动推断出参数的类型,我们通常不再需要显式标注类型。

函数的真相:每个函数只接受一个参数

现在,让我们揭示ML中关于函数的核心真相。观察以下两个函数定义:

(* 版本A:我们刚刚学到的“接受一个元组”的函数 *)
fun sum_triple (x, y, z) = x + y + z

(* 版本B:我们一直以来认为的“接受三个参数”的函数 *)
fun sum_three x y z = x + y + z

真相是:在ML中,每个函数都只接受恰好一个参数

我们一直所称的“多参数函数”,实际上只是一个接受单个元组作为参数的函数。函数定义中的(x, y, z)就是一个元组模式,它将该元组的三个分量分别绑定到变量xyz上。

因此,函数调用sum_triple (3, 4, 5)实际上是传递了一个三元组(3, 4, 5)给这个只接受一个参数的函数。

这种设计的好处

这种统一性带来了极大的灵活性和优雅性。由于每个函数都只接受和返回一个值,组合函数变得非常容易。

例如,我们可以定义一个旋转元组的函数:

fun rotate_left (x, y, z) = (y, z, x)

然后轻松地将一个函数的输出直接作为另一个函数的输入:

val result1 = rotate_left (3, 4, 5)          (* 得到 (4, 5, 3) *)
val result2 = sum_triple (rotate_left (3, 4, 5)) (* 得到 12 *)
val result3 = sum_triple (rotate_left (rotate_left (3, 4, 5))) (* 同样得到 12 *)

我们甚至可以利用这一点定义新函数:

(* 右旋转可以通过两次左旋转实现 *)
fun rotate_right t = rotate_left (rotate_left t)

这种“单参数”哲学使得数据流(一个函数的输出成为另一个函数的输入)在代码中表达得非常清晰和直接。

总结

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

  1. 模式匹配可以扩展到元组记录(“each of”类型),用于优雅地提取其内部值。
  2. 使用模式匹配的最佳实践是直接在函数参数中使用元组或记录模式。
  3. ML中一个根本性的设计是:每个函数都只接受一个参数。所谓的“多参数函数”只是接受一个元组并使用模式匹配来提取其分量的语法糖。
  4. 这一设计使得函数组合和数据传递变得异常简洁和强大。

理解这一点,你就能以更统一、更本质的视角来看待ML中的函数定义和调用。

041:类型推断初探 👨‍🏫

在本节课中,我们将要学习类型推断的基本概念。我们将通过具体的代码示例,了解ML语言如何推断函数类型,并解释为何有时推断出的类型可能比我们预期的更通用。理解这一点对于完成后续作业至关重要。

类型推断与模式匹配的关系

上一节我们介绍了模式匹配的基本用法。本节中我们来看看类型推断如何与模式匹配协同工作。

当我们使用模式匹配来解构元组或记录时,类型检查器通常能够推断出函数参数的类型。这是因为模式本身提供了关于数据结构形状的信息,而函数体中对变量的使用则揭示了其具体类型。

以下是两个我们之前见过的函数示例:

fun sum_triple1 (x, y, z) = x + y + z
fun full_name1 {first=x, middle=y, last=z} = x ^ " " ^ y ^ " " ^ z

对于 sum_triple1,类型检查器可以推断出其类型为 int * int * int -> int。模式 (x, y, z) 表明它是一个三元组,而 x + y + z 表明这三个元素必须是整数。

对于 full_name1,类型检查器可以推断出其类型为 {first:string, middle:string, last:string} -> string。模式表明参数是一个包含 firstmiddlelast 字段的记录,而字符串连接操作 ^ 表明这些字段的内容必须是字符串。

使用 # 字符的局限性

如果使用旧的 # 字符方式来访问元组或记录的字段,类型检查器获得的信息就会减少,有时甚至无法推断出完整类型。

以下是使用 # 字符的版本:

fun sum_triple2 triple = #1 triple + #2 triple + #3 triple
fun full_name2 r = #first r ^ " " ^ #middle r ^ " " ^ #last r

虽然这些函数可以工作,但如果我们尝试省略参数的类型标注,sum_triple2 就会引发编译错误:“Unresolved flex record”。这是因为类型检查器无法仅从 #1#2#3 确定元组的确切宽度(它可能有三项、四项或更多)。ML的类型系统要求函数的参数类型是明确的,不能同时处理三元组和四元组。

这就是为什么在作业中,一旦我们学会了模式匹配,就不再鼓励使用 # 字符的原因之一。

未使用全部参数导致的“意外多态”

在编写函数时,如果你没有使用通过模式匹配绑定的所有变量,可能会得到一个比预期更通用的类型。这有时被称为“意外多态”。

考虑以下两个函数:

fun partial_sum (x, y, z) = x + z
fun name {first=x, middle=y, last=z} = x ^ " " ^ z

函数 partial_sum 只使用了元组的第一和第三个元素 xz,没有使用 y。函数 name 只使用了记录的 firstlast 字段,没有使用 middle

如果我们加载这些函数,ML 推断出的类型是:

  • partial_sum : int * 'a * int -> int
  • name : {first:string, middle:'a, last:string} -> string

你可能会期望 partial_sum 的类型是 int * int * int -> int,但 ML 给出了 int * 'a * int -> int。这是因为函数体根本没有对第二个位置的值 y 进行任何操作(没有对其做加法、比较等),因此这个位置可以是任何类型(用类型变量 'a 表示)。这个函数因此是多态的。

以下是关于这种多态性的关键点:

  • 这完全没问题。函数 partial_sum (3, 4, 5) 返回 8,正确工作。
  • 它甚至可以用在其他类型上,例如 partial_sum (3, "hello", 5) 也能通过类型检查并返回 8。字符串 "hello" 被简单地忽略了。
  • 但是,partial_sum (3, 4, "hello") 会类型检查失败,因为第三个位置 z 参与了加法运算,必须是整数。

只要函数的行为对于要求的类型是正确的,并且推断出的类型比要求的更通用(即,所有要求的输入都能被新类型覆盖),那么就没有问题。这实际上使函数更可重用。

总结

本节课中我们一起学习了类型推断的几个关键方面:

  1. 模式匹配是强大的:它能为类型检查器提供丰富的信息,使其能够推断出函数参数的类型,从而通常无需显式编写类型标注。
  2. 避免使用 # 字符:在新代码中使用模式匹配而非 # 字符,可以避免一些类型推断的局限性,并让代码更清晰。
  3. 理解“意外多态”:当函数没有使用其所有参数时,可能会推断出带有类型变量(如 'a)的更通用的多态类型。只要函数对所需类型正确工作,这就不是一个错误,反而是函数灵活性的体现。

在下一节中,我们将更深入地探讨“一个类型比另一个类型更通用”这一概念的精确规则,让你能准确判断和比较类型的通用性。

042:多态类型与相等类型

在本节课中,我们将要学习ML语言中多态类型如何允许某些类型比其他类型更通用,并指出ML中一个你可能在作业中偶然遇到的特殊特性——相等类型。理解这些概念将帮助你更好地理解类型系统,并在编写代码时避免困惑。

多态类型的通用性

上一节我们介绍了多态函数的基本概念,本节中我们来看看如何判断一个多态类型是否比另一个类型更通用。

假设你需要编写一个函数,将两个字符串列表连接起来。你可能会写出如下代码:

fun append (xs, ys) = xs @ ys

你期望它的类型是 string list * string list -> string list。但实际上,这段代码具有更通用的多态类型:'a list * 'a list -> 'a list。这意味着函数可以处理任何类型的列表,而不仅仅是字符串列表。

类型通用性的核心规则是:一个类型 T1 比另一个类型 T2 更通用,当且仅当你可以通过一致地替换 T1 中的类型变量(例如,将所有 'a 替换为同一种类型)来得到 T2

以下是判断类型通用性的关键点:

  • 类型变量必须被一致地替换。例如,在 'a list * 'a list -> 'a list 中,所有 'a 必须被替换为同一种类型(如 intstring)。
  • 因此,'a list * 'a list -> 'a listint list * int list -> int list 更通用。
  • 'a list * 'a list -> 'a list 并不比 int list * string list -> int list 更通用,因为这里的 'a 没有被一致地替换。

结合其他类型规则

现在,让我们将类型通用性规则与之前学过的其他类型规则结合起来。

记住两点:类型同义词(type 定义)在类型检查时会被展开;记录类型(record)的字段顺序不影响类型等价。

考虑以下示例:

type foo = int * int

假设你编写的代码推断出的类型是:
{ quux : 'b, bar : int * 'a, baz : 'b }

而作业要求你实现的类型是:
{ bar : foo, baz : string, quux : string }

第一个类型是否比第二个更通用?答案是肯定的。判断过程如下:

  • 两个记录类型拥有相同的字段(quuxbarbaz),字段顺序不同不影响。
  • 将第一个类型中的类型变量进行一致替换:
    • 将所有 'b 替换为 string
    • 'a 替换为 int,那么 int * 'a 就变成了 int * int
  • 由于 fooint * int 的同义词,因此 int * intfoo 等价。
  • 经过替换后,第一个类型就变成了第二个类型,因此它更通用。

在作业中,ML类型检查器可能会推断出一个与你预期不同的类型,你需要运用这些规则来判断它是否满足要求。

理解相等类型

接下来,我们看看ML中一个比较特殊的特性:相等类型。你可能会看到带有两个引号的类型变量,例如 ''a,而不仅仅是 'a

例如,一个函数的类型可能是 ''a list * ''a -> bool。这表示一个多态函数,但这里的 ''a 是一个相等类型变量。这意味着你只能用支持 = 操作符的类型来实例化 ''a

= 操作符在ML中并非对所有类型都有效。以下是关键规则:

  • = 可以用于比较 intstring、元组(只要其所有组成部分也是相等类型)等。
  • = 不能用于比较 real(浮点数),因为浮点数的相等比较通常是不精确的,ML为了强制良好编程风格而禁止此操作。
  • = 不能用于比较函数类型。

因此,对于 ''a list * ''a -> bool 这样的类型,类型通用性的规则依然适用(必须一致替换 ''a),但替换的选择受到了限制(只能替换为相等类型)。

相等类型的实例

这里有两个简单的例子来说明相等类型如何出现:

例1:显式使用 =

fun isSame (x, y) = if x = y then "yes" else "no"

这个函数的类型会被推断为 ''a * ''a -> string。ML可能会给出一个关于“多态相等”的警告,通常可以忽略。

例2:与特定值比较

fun checkThree x = if x = 3 then "yes" else "no"

在这个函数中,x 与整数 3 比较。类型检查器知道 = 要求两边类型相同,因此它推断出 x 必须是 int 类型。整个函数的类型是 int -> string,这里没有出现奇怪的相等类型变量。

总结

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

  1. 类型通用性:一个多态类型 T1T2 更通用,当 T2 可通过一致替换 T1 中的类型变量得到。公式表示为:T1 > T2 当且仅当存在一致的替换 σ 使得 σ(T1) = T2
  2. 规则结合:在判断类型通用性时,需结合类型同义词展开和记录类型字段顺序无关的规则。
  3. 相等类型:ML为支持 = 操作符的类型引入了特殊的 ''a 类型变量。理解这一点有助于你理解类型检查器的某些推断结果,并知道哪些类型可以用于相等比较。

掌握这些概念后,你在面对作业中多态类型相关的问题时将更加从容,也能更好地理解ML类型系统的精妙之处。

043:嵌套模式匹配 🧩

在本节课中,我们将要学习模式匹配的一个强大特性:嵌套模式。通过允许我们将模式放入其他模式中,我们可以编写出更简洁、更易读的代码,避免复杂的嵌套 case 表达式。

概述

上一节我们介绍了基础的模式匹配。本节中,我们来看看如何通过嵌套模式来泛化我们之前所学的知识。嵌套模式的核心思想是:在任何可以放置变量或简单模式的地方,我们都可以放置另一个模式。这类似于在构建表达式时,我们可以在任何位置嵌套子表达式。通过递归定义,模式匹配将检查一个值是否具有模式所描述的“形状”,并在匹配时,将变量绑定到值的相应部分。

为了更好地理解,让我们先看一个具体的例子。

示例:ZIP与UNZIP函数

一个展示嵌套模式强大之处的经典例子是实现 zip3unzip3 函数。

  • zip3:接收三个列表,返回一个元组列表,其中每个元组包含三个输入列表中对应位置的元素。其作用类似于拉链,将独立的部件并排组合。
  • unzip3:是 zip3 的逆操作。接收一个元组列表,返回三个列表,分别包含所有元组的第一个、第二个和第三个元素。

不使用模式匹配的实现

首先,我们看看如果不使用模式匹配,zip3 函数会多么繁琐且容易出错:

(* 旧式实现,未使用模式匹配 *)
fun old_zip3 (L1, L2, L3) =
    if null L1 andalso null L2 andalso null L3
    then []
    else if null L1 orelse null L2 orelse null L3
    then raise Empty (* 假设已声明异常 *)
    else (hd L1, hd L2, hd L3) :: old_zip3(tl L1, tl L2, tl L3)

这种写法需要手动检查列表是否为空,代码冗长且类型检查器无法提供太多帮助。

使用基础模式匹配的实现

即使使用我们目前学过的基础模式匹配,代码也会因为需要枚举所有空/非空组合而显得混乱:

(* 使用基础模式匹配,代码冗长 *)
fun clumsy_zip3 (L1, L2, L3) =
    case L1 of
        [] => (case L2 of
                  [] => (case L3 of
                            [] => []
                          | _ => raise Empty)
                | _ => raise Empty)
      | x::xs => (case L2 of
                     [] => raise Empty
                   | y::ys => (case L3 of
                                  [] => raise Empty
                                | z::zs => (x,y,z) :: clumsy_zip3(xs, ys, zs)))

使用嵌套模式匹配的实现

现在,让我们看看如何使用嵌套模式写出优雅的解决方案。以下是 zip3 的实现:

(* 使用嵌套模式匹配 *)
fun zip3 list_triple =
    case list_triple of
        ([], [], []) => []                         (* 模式1:三个空列表 *)
      | (x::xs, y::ys, z::zs) =>                   (* 模式2:三个非空列表 *)
          (x, y, z) :: zip3 (xs, ys, zs)
      | _ => raise Empty                           (* 模式3:其他所有情况 *)

让我们详细分析这段代码:

  • 函数定义fun zip3 list_triple =。函数接收一个参数 list_triple,它是一个三元组 (L1, L2, L3)
  • 模式1 ([], [], []):这是一个嵌套模式。它匹配一个三元组,且该三元组的三个分量都是空列表。匹配时,直接返回空列表 []
  • 模式2 (x::xs, y::ys, z::zs):这是关键的嵌套模式。它匹配一个三元组,且三个分量都是非空列表(即 head::tail 结构)。匹配成功后,变量 x, y, z 被绑定到三个列表的头部元素,xs, ys, zs 被绑定到尾部。然后,递归地对尾部进行 zip3 操作。
  • 模式3 _:下划线模式匹配所有未被前两个模式匹配的情况(即列表长度不一致),并抛出异常。

类似地,我们可以用嵌套模式优雅地实现 unzip3

fun unzip3 lst =
    case lst of
        [] => ([], [], [])                         (* 空列表情况 *)
      | (a, b, c)::tl =>                           (* 非空列表,且头部是三元组 *)
          let val (l1, l2, l3) = unzip3 tl         (* 递归解压缩尾部 *)
          in (a::l1, b::l2, c::l3)                 (* 将头部元素分别cons到结果列表 *)
          end

unzip3 的第二个模式 (a, b, c)::tl 中,我们再次看到了嵌套:它匹配一个非空列表,并且同时匹配其头部元素必须是一个三元组 (a, b, c)。这在一个模式中完成了两层数据的解构。

总结

本节课中我们一起学习了嵌套模式匹配。通过允许模式中包含其他模式,我们能够:

  1. 用更少的代码行数表达复杂的条件判断。
  2. 写出结构清晰、更易阅读和维护的程序。
  3. 避免深层嵌套的 case 表达式。

zip3unzip3 的例子展示了如何利用嵌套模式同时解构元组和列表。在接下来的课程中,我们将看到更多嵌套模式的常见用法和习惯用语。掌握这一特性,是编写优雅函数式代码的关键一步。

044:更多嵌套模式匹配示例 🧩

在本节课中,我们将通过几个具体示例,深入学习如何使用嵌套模式匹配来编写更简洁、更优雅的代码。我们将重点关注如何避免复杂的嵌套 case 表达式,并利用嵌套模式直接处理数据结构。


概述

嵌套模式匹配允许我们在一个模式中同时匹配数据的多个层次,从而减少代码嵌套,使逻辑更清晰。本节将通过三个函数示例来展示其应用:判断列表是否非递减、计算两个整数乘积的符号、以及计算列表长度。


示例一:判断非递减列表 📈

首先,我们编写一个函数 nondecreasing,它接收一个整数列表,并判断列表是否是非递减的(即每个元素都不小于其前一个元素)。

如果使用简单的模式匹配和嵌套 case 表达式,代码可能会显得笨拙。以下是传统方式的思路:

fun nondecreasing xs =
    case xs of
        [] => true
      | x::xs' => case xs' of
                      [] => true
                    | y::ys' => x <= y andalso nondecreasing (y::ys')

然而,使用嵌套模式匹配,我们可以更直接地处理不同长度的列表:

fun nondecreasing xs =
    case xs of
        [] => true
      | [_] => true
      | head::neck::rest => head <= neck andalso nondecreasing (neck::rest)

代码解析:

  • [] 模式匹配空列表,结果为 true
  • [_] 模式匹配单元素列表(_ 是通配符,表示我们不关心具体值),结果也为 true
  • head::neck::rest 模式匹配至少有两个元素的列表。它检查前两个元素(headneck)是否满足 head <= neck,然后递归检查剩余部分 neck::rest

这种写法更简洁,并且类型检查器能确保模式是完备的,覆盖了空列表、单元素列表和多元素列表所有情况。


上一节我们介绍了如何用嵌套模式简化列表条件判断,本节中我们来看看如何将其应用于自定义数据类型。

示例二:计算乘积的符号 🔢

接下来,我们定义一个表示数字符号的数据类型,并编写函数 mult_sign,它接收两个整数,返回它们乘积的符号,但不实际计算乘积

首先定义数据类型:

datatype sign = P | N | Z

函数 mult_sign 的逻辑需要考虑两个参数符号的所有9种组合(3x3)。使用嵌套 case 表达式会非常冗长。更好的方法是直接对 (sign(x1), sign(x2)) 这个元组进行模式匹配。

fun mult_sign (x1, x2) =
    let
        fun sign x = if x = 0 then Z else if x > 0 then P else N
    in
        case (sign x1, sign x2) of
            (Z, _) => Z
          | (_, Z) => Z
          | (P, P) => P
          | (N, N) => P
          | _ => N
    end

逻辑解析:
以下是匹配顺序和逻辑:

  1. (Z, _):如果第一个数是0,乘积为0,符号为 Z
  2. (_, Z):如果第二个数是0(且第一个数不是0,否则已被上一条匹配),乘积为0,符号为 Z
  3. (P, P):两数同为正,乘积为正,符号为 P
  4. (N, N):两数同为负,乘积为正,符号为 P
  5. _:通配符匹配所有剩余情况(即 (P, N)(N, P)),此时两数符号相反,乘积为负,符号为 N

这种写法将逻辑以表格形式清晰呈现。需要注意的是,使用最后的通配符 _ 虽然简洁,但可能会掩盖未明确列出的情况(例如,如果我们误删了 (N, N) => P 这一行,编译器不会警告)。另一种更安全的风格是明确列出所有9种或剩余的组合。


从处理成对的数据过渡到更基础的列表操作,我们来看最后一个简单但重要的例子。

示例三:计算列表长度 📏

最后,我们重温计算列表长度的经典例子。虽然这里没有复杂的嵌套结构,但它展示了良好的代码风格。

fun length xs =
    case xs of
        [] => 0
      | _::xs' => 1 + length xs'

代码风格提示:
_::xs' 模式中,我们使用通配符 _ 来匹配列表头部,这明确地向代码阅读者传达了一个信息:我们不需要使用头部元素的值,只需要知道列表非空,并获取其尾部 xs' 以进行递归调用。这比使用一个变量名(如 x::xs')但后续不使用 x 更具表达力。


总结与风格指南 ✅

本节课中我们一起学习了嵌套模式匹配的几种实用技巧:

  1. 避免不必要的嵌套 case 表达式:当逻辑需要多层判断时,尝试用单个 case 配合嵌套模式来扁平化代码结构,如 nondecreasing 函数所示。
  2. 对元组进行模式匹配:当需要同时处理多个相关值时,将它们放入元组并一次性匹配,这比分别匹配每个值更清晰,如 mult_sign 函数所示。
  3. 善用通配符 _:当某个被匹配的值在分支中不会被使用时,使用通配符 _ 代替变量名。这不会影响匹配功能,但能使代码意图更明确,是一种更好的编程风格。

嵌套模式匹配是 ML 类语言中编写简洁、安全且易读代码的强大工具。下一节,我们将退后一步,更精确地探讨 ML 中嵌套模式匹配是如何定义的。

045:嵌套模式匹配的精确语义

在本节课中,我们将为模式匹配提供一个精确的定义,以说明一个模式何时与一个值匹配。由于我们引入了嵌套模式,这个定义会稍微复杂一些,但它会引出一个非常自然且优雅的递归定义。

需要明确的是,这里描述的是模式匹配的语义和求值规则。具体来说,就是当你有一个模式和一个值时,如何判断它们是否匹配。例如,这个值通常是 caseof 之间表达式的结果,而模式则是 case 表达式中按顺序排列的各个分支。这些规则同样适用于函数绑定或 val 绑定中的模式。核心问题是:给定一个模式和一个值,它们是否匹配?如果匹配,会引入哪些变量,这些变量被绑定到什么值?

递归定义的基础

在嵌套模式存在的情况下,这是一个递归定义。定义中会为每一种书写模式的方式提供一个分支。虽然幻灯片上没有列出所有情况,但通过以下示例,你将理解其核心思想,并体会到模式匹配在语言定义中本身就是一个递归过程。

让我们从递归的基础情况开始,即模式是变量或通配符(下划线 _)的情况。

  • 变量模式:如果模式是一个变量,那么无论值 V 是什么,匹配总是成功。该变量将被绑定到整个值 V
  • 通配符模式:如果模式是下划线 _,匹配也总是成功,并且不会绑定任何变量。

递归情况的定义

其他情况都是递归的,它们由更小的嵌套模式构成。这些嵌套模式本身可能是变量这样的基础情况,也可能是更复杂的嵌套模式,此时递归定义将继续适用。

以下是两种主要的递归情况:

1. 元组模式

假设我们有一个元组模式,形式为 (P1, P2, ..., Pn)。这个模式只会匹配那些同样是包含 n 个值的元组。并且,只有当 P1 匹配 V1P2 匹配 V2……直到 Pn 匹配 Vn 时,整个元组模式才匹配。这里的递归匹配正是引用了同一个模式匹配的递归定义。如果所有这些子模式都匹配,它们会各自引入一系列变量到值的绑定,而整个模式的绑定就是所有这些绑定的并集。模式匹配有一个额外规则:不允许在同一个模式中多次使用同一个变量名,编译器会拒绝这样的代码。

2. 构造器模式

假设 C 是某个已定义数据类型的构造器。我们可以写出形如 C(P1) 的模式,其中 P1 是一个嵌套模式(通常是一个元组,但也可以是任何模式)。如果一个值是由同一个构造器 C 构建的,并且其内部值 V1 与嵌套模式 P1 匹配,那么这个构造器模式就与该值匹配。然后,由 P1 匹配 V1 这一递归匹配所产生的变量绑定,就是整个 C(P1) 模式匹配 C(V1) 值所产生的绑定。

示例解析

通过上述递归定义,我们可以更清晰地理解嵌套模式如何与特定形状的值进行匹配。以下是三个例子:

  • 示例一:a::b::c::d
    这个模式将匹配任何长度大于等于3的列表。它会递归地将 a 匹配到第一个元素,b 匹配到第二个,c 匹配到第三个,而 d 则匹配列表的剩余部分。如果列表太短,最终会尝试用空列表值去匹配 :: 构造器模式,这将导致匹配失败,程序会转向 case 表达式的下一个分支。

  • 示例二:a::b::c::[]
    这个模式将匹配所有恰好有三个元素的列表,将 abc 分别绑定到第一、二、三个元素。如果列表过短或过长,都会在递归匹配过程中遇到构造器不匹配的情况(例如用 [] 模式匹配非空列表,或用 :: 模式匹配空列表),从而导致匹配失败。

  • 示例三:((w,x), (y,z))::e
    这个模式包含更多嵌套。它只会匹配一个非空列表,并且要求该列表的第一个元素是一个由两个二元组构成的二元组。匹配成功后,将引入 wxyze 这五个变量的绑定。

总结

本节课中,我们一起学习了模式匹配的精确递归定义。我们从变量和通配符这两个基础情况开始,然后探讨了如何处理元组模式和构造器模式这两种递归情况。通过具体的列表模式示例,我们看到了这个定义如何清晰地解释嵌套模式的行为。希望这能让你认识到,模式匹配并非一个模糊的概念,而是一个具有精确定义的机制,它能更完整地解释我们在之前课程中看到的各类示例。

046:函数模式匹配的另一种写法 🧩

在本节可选课程中,我们将学习一种编写函数模式匹配的替代语法。这种风格并非必需,但了解它有助于你阅读他人代码。我们将通过算术表达式求值和列表拼接两个例子来演示这种写法。

概述

之前我们使用 case 表达式进行模式匹配。本节介绍一种将模式直接写在函数定义中的语法糖。这种写法更简洁,风格更数学化。

算术表达式求值示例

首先回顾我们定义的数据类型,用于表示算术表达式:

datatype exp = Constant of int
             | Negate of exp
             | Add of exp * exp
             | Multiply of exp * exp

最自然的操作是为该数据类型编写一个求值函数,类型为 exp -> int。我们之前使用 case 表达式实现:

fun eval e =
    case e of
        Constant i => i
      | Negate e2 => ~ (eval e2)
      | Add(e1,e2) => (eval e1) + (eval e2)
      | Multiply(e1,e2) => (eval e1) * (eval e2)

现在,我们可以将模式匹配直接移至函数定义中:

fun eval (Constant i) = i
  | eval (Negate e2) = ~ (eval e2)
  | eval (Add(e1,e2)) = (eval e1) + (eval e2)
  | eval (Multiply(e1,e2)) = (eval e1) * (eval e2)

这种写法更短。人们喜欢它是因为它更数学化:我们定义了函数 eval,它有多个分支。eval 应用于 Constant 得到这个结果,应用于 Negate 得到那个结果,依此类推。但这只是 case 表达式的语法糖。

列表拼接示例

为了进一步说明,让我们看另一个例子。以下是使用 case 表达式实现的列表拼接函数 append

fun append (xs,ys) =
    case xs of
        [] => ys
      | x::xs' => x :: append(xs',ys)

使用新的语法,我们可以利用嵌套模式一次性完成:

fun append ([], ys) = ys
  | append (x::xs', ys) = x :: append(xs', ys)

这里,如果两个参数匹配第一个模式(即第一个列表为空,第二个列表是任意列表 ys),则结果为 ys。否则,如果匹配第二个模式,则返回 xappend(xs', ys) 结果的拼接。

语法概括

总的来说,如果你有一个函数绑定,其函数体就是一个 case 表达式:

fun f x =
    case x of
        p1 => e1
      | p2 => e2
      | ...

你可以将其重写为多个模式,重复函数名并用竖线 | 分隔:

fun f p1 = e1
  | f p2 = e2
  | ...

需要注意的一个细节是,这种写法通常假设你不需要在分支中使用原始变量 x。你通常在 case 表达式中对其进行模式匹配,然后只使用在分支中绑定的变量。

总结

本节课我们一起学习了一种可选的函数模式匹配语法。通过将模式直接写在函数定义中并用 | 分隔,我们可以写出更简洁、风格更数学化的代码。这种写法是 case 表达式的语法糖,你可以根据个人喜好选择是否使用。

047:异常处理 🚨

在本节课中,我们将要学习异常处理。异常用于处理运行时出现的错误情况。我们将介绍如何创建异常、如何抛出异常(在许多语言中称为“throwing”),以及如何捕获和处理异常(在许多语言中称为“catching”)。在课程的这个阶段介绍异常是合适的,因为 ML 语言处理异常的方式与其处理数据类型绑定的方式非常相似,尽管异常是一个独立的概念。我们将通过代码示例来详细解释,然后用几张幻灯片进行总结。

概述

首先,让我们看看 head 函数是如何实现的。head 函数接收一个列表,如果列表非空,则返回第一个元素;如果列表为空,则抛出一个预定义的异常 List.Empty

以下是 head 函数的预期实现代码:

fun head xs =
    case xs of
        [] => raise List.Empty
      | x::_ => x

在非空列表的情况下,函数返回第一个元素 x;对于空列表,则使用 raise 关键字抛出 List.Empty 异常。如果使用空列表调用 head 函数,它将永远不会返回,因为 raise 会导致异常发生。

自定义异常

除了使用预定义的异常,我们还可以定义自己的异常类型。定义异常时使用 exception 关键字,后跟你选择的异常名称。按照惯例,异常名称通常以大写字母开头。

以下是一个自定义异常的例子:

exception MyUndesirableCondition

定义异常后,你可以使用 raise 关键字抛出它:

raise MyUndesirableCondition

你还可以创建携带数据的异常,语法与数据类型的构造函数类似:

exception MyOtherException of int * int

这样,你可以抛出携带数据的异常,例如:

raise MyOtherException (3, 4)

这允许你将数据传递给可能处理该异常的代码。

抛出异常

现在,让我们看一个函数示例,该函数在特定条件下抛出异常。假设我们有一个函数 myDiv,它接收两个整数,如果分母为零,则抛出我们自定义的异常 MyUndesirableCondition,而不是 ML 语言通常抛出的异常。

以下是 myDiv 函数的实现:

fun myDiv (x, y) =
    if y = 0 then raise MyUndesirableCondition else x div y

这样,当分母为零时,函数会抛出我们自定义的异常。

异常值与抛出异常的区别

需要注意的是,创建异常值与抛出异常是不同的。异常值只是类型为 exn 的值,而抛出异常会导致程序控制流的改变。

考虑以下函数 maxList,它接收一个整数列表和一个异常值。如果列表为空,则抛出传入的异常;否则,返回列表中的最大元素。

以下是 maxList 函数的实现:

fun maxList (xs, ex) =
    case xs of
        [] => raise ex
      | [x] => x
      | x::xs' => Int.max (x, maxList (xs', ex))

maxList 函数的类型为 int list * exn -> int,因为它可能返回一个整数,也可能抛出异常。

现在,如果我们调用 maxList 并传入一个非空列表和一个异常值,不会触发异常,函数会正常返回最大值:

val w = maxList ([3,4,5], MyUndesirableCondition)  (* w 被绑定为 5 *)

这里,MyUndesirableCondition 只是一个异常值,并没有被抛出,因此 w 被绑定为 5

处理异常

处理异常是异常机制的关键部分。在许多语言中,这被称为“捕获”异常。在 ML 中,我们使用 handle 关键字来处理异常。

handle 表达式的语法如下:

E1 handle Pattern => E2

如果 E1 正常求值,则忽略 handle 部分;如果 E1 抛出一个与 Pattern 匹配的异常,则执行 E2;如果不匹配,则异常继续向上传播。

以下是一个简单的示例:

val x = (5 handle MyUndesirableCondition => 42)  (* x 被绑定为 5 *)

因为 5 不会抛出异常,所以 x 被绑定为 5

另一方面,如果我们调用 maxList 并传入空列表,则会抛出异常,然后被 handle 捕获:

val z = maxList ([], MyUndesirableCondition) handle MyUndesirableCondition => 42  (* z 被绑定为 42 *)

这里,maxList 抛出 MyUndesirableCondition 异常,然后被 handle 捕获,z 被绑定为 42

总结

在本节课中,我们一起学习了异常处理的基本概念和操作。我们介绍了如何通过 exception 关键字定义新的异常类型,如何使用 raise 关键字抛出异常,以及如何使用 handle 表达式捕获和处理异常。我们还了解了异常值与抛出异常的区别,并通过代码示例加深了理解。

异常处理是编程中处理错误和异常情况的重要机制,掌握它可以帮助你编写更健壮和可靠的代码。在后续的作业中,你将有机会使用异常处理来确保程序的正确性和稳定性。

048:尾递归 🚀

在本节课中,我们将开始讨论尾递归。这是一个与评估 ML 及其他函数式语言中递归函数效率相关的新主题。

概述

目前我们已经编写了许多递归函数。希望你已经确信,编写递归函数并不比编写循环困难。事实上,我们甚至没有讨论过循环。可以说,递归虽然从不比循环更难,但在处理树状结构(如计算算术表达式)时,通常比循环更容易。像列表拼接这样的例子,当我们以递归方式思考时,也变得简单得多。这些递归函数避免了任何对可变状态的需求,即使是局部变量。如果你使用类似 for 循环的结构,总是需要递增索引 i,而我们正试图远离可变变量。

然而,我们尚未讨论递归是否高效,或者在何种情况下能产生快速代码。很多人认为递归效率低下,但这并非必然。即使在某些情况下确实如此,也往往无关紧要。接下来,我们将探讨“尾递归”这一概念的重要性,并在后续章节中学习如何使用常见模式(如累加器)来实现尾递归。需要明确的是,这里不会介绍任何新的语言特性,只是新的编程模式和评估我们已编写代码效率的新方法。

调用栈的工作原理

要理解递归和尾递归,需要了解函数调用是如何实现的。你只需理解调用栈的高层概念。

程序运行时,存在一个调用栈,其中包含所有已调用但尚未完成的函数。当你调用函数 F 时,会将一个实例压入栈中。这个实例会一直保留在栈上,直到对 F 的调用完成。当 F 的调用结束时,会将其从栈中弹出。因此,在任何给定时间,栈中都包含所有已开始但未完成的调用。由于一个函数可以调用另一个函数,栈中可能有很多调用。

这些栈元素(称为栈帧)存储诸如局部变量绑定值等信息,还存储在该函数调用的任何其他函数完成求值后,该函数仍需完成的工作信息。

注意,如果我们有一个递归函数 F 调用 F,再调用 F,依此类推,栈上将有多个栈帧,它们都对应同一段代码(函数 F)。这完全合理,也是递归的真正含义。

让我们看一个例子。假设有一个阶乘函数 fact,它接收参数 n,返回 n * (n-1) * (n-2) * ... * 1。它仅对非负数正确工作。假设我们调用 fact(3),最终将得到答案 6(3 * 2 * 1)。

初始调用 fact(3) 时,栈上只有一个元素。当 fact(3) 执行时,它会调用 fact(2)(因为 3-1=2)。因此,栈上会添加一个 fact(2) 的调用。fact(3) 在其栈帧中记住,当它收到递归调用的结果后,需要将该结果乘以 3,这才是它的最终答案。即 3 * fact(2) 的结果。

类似地,fact(2) 会调用 fact(1),栈变得更大。fact(2) 等待调用结果,并准备将其乘以 2。fact(1) 最终会调用 fact(0)。此时,栈上有四个元素。当调用 fact(0) 时,它求值 if 表达式并返回 1,没有额外的调用压入栈。然后返回 1,并从调用栈中弹出。

现在栈变小了,fact(1) 获得了它的递归结果。它将 1 乘以 1,得到 1,然后弹出。接着 fact(2) 将 2 乘以 1,得到 2,然后弹出。最后 fact(3) 将 3 乘以 2,得到 6,然后返回。这就是调用栈的工作方式,也是我们求值 fact 时期望发生的情况。

一个更高效的阶乘版本

现在展示第二个更复杂的阶乘版本。稍后将说明它实际上更高效,但目前还看不出来。

首先理解这个阶乘版本的工作原理。它所做的就是调用一个局部定义的辅助函数(这里命名为 aux,代表辅助函数)。这个辅助函数接收一个数字和一个累加器(acc)。它的逻辑是:如果 n 等于 0,则直接返回累加器的值;否则,递归调用 aux,参数为 n-1acc * n

我将展示这实际上也能正确计算阶乘。与简单的递归函数不同,我们现在在递归过程中将结果累积到第二个参数(累加器)中。当 n 为 0 时,我们直接返回累加器。aux 仍然是递归的,但更复杂。关键在于,当 aux 调用 aux 时,递归调用的结果就是调用者的结果,调用者没有额外的工作要做。而在第一个阶乘版本中,递归调用返回后,调用者还有工作要做(必须乘以 n)。这就是关键区别。

在展示效率差异之前,先看看基于目前理解的调用栈情况。

fact(3) 开始。fact(3) 将调用 aux(3, 1)(根据代码,这是累加器的初始值)。然后 aux(3, 1) 会调用 aux(2, 3)(因为 3-1=2,3*1=3)。注意,此时栈上的这些调用者只是在等待结果返回并立即将其返回。

接着 aux(2, 3) 调用 aux(1, 6)aux(1, 6) 调用 aux(0, 6)。此时,栈比之前版本的实际栈还要大。然后 aux(0, 6) 返回 6。aux(1, 6) 返回那个 6,aux(2, 3) 返回那个 6,aux(3, 1) 返回那个 6,最后 fact(3) 返回那个 6。程序继续直到结束。

尾调用优化

现在进入关键概念:函数式语言执行的一项重要优化。如果一个栈帧的唯一作用就是接收被调用者的结果并立即返回,那么保留这个栈帧是完全不必要的。

这种情况称为尾调用(原因我从未完全理解,但大家都这么叫)。编译器(语言的实现)能识别这些函数调用并以不同方式处理它们。

具体做法是:在发起调用之前,移除调用者的栈帧,使得被调用者直接复用调用者使用的相同栈空间。结合其他一些未展示的优化,这使得使用尾调用的递归函数与其他语言中的循环一样高效。因此,可以合理地假设 ML 语言保证了尾调用的这种效率。

所以,之前展示的更复杂阶乘版本的调用栈情况实际上并不会发生。

我们确实从 fact(3) 的栈开始。但在 fact(3) 调用 aux(n, 1) 的地方,这是一个尾调用。fact 在调用 aux 后没有其他工作要做,只是返回其结果。因此,我们将重用栈空间,用 aux(3, 1) 的栈帧替换 fact(3) 的栈帧。

现在我们在求值 aux(3, 1)。当执行到递归调用时,之后也没有更多工作要做,所以这也是一个尾调用。因此,我们将为 aux(2, 3) 的递归调用重用这个栈空间。同样的情况会发生在下一个递归调用,以及再下一个递归调用上。因此,我们从未构建起一个大的调用栈。当 aux(0, 6) 返回 6 时,我们立即得到了答案。

这就是为什么这个更复杂的阶乘版本实际上更高效。如果你计划在非常大的数字上使用阶乘函数并且关心这种效率,这将是一种更好的编写方式。另一方面,如果你只是计算 fact(3),更简单的解决方案可能更可取,因为它更直观。

总结

本节课我们一起学习了尾递归的概念。我们了解了函数调用栈的基本工作原理,比较了普通递归和尾递归在实现阶乘函数时的差异。关键在于,尾递归调用后没有额外工作,因此编译器可以进行优化,复用栈帧,避免栈空间随着递归深度线性增长,从而提升效率。这使得在函数式语言中,尾递归可以像命令式语言中的循环一样高效。在后续课程中,我们将学习如何使用累加器等常见模式来编写尾递归函数。

049:尾递归与累加器模式 🚀

在本节课中,我们将继续讨论尾递归,并展示如何通过累加器模式创建尾递归函数。我们将通过几个示例来理解这一过程,并分析其性能优势。

概述

尾递归是一种特殊的递归形式,其中所有递归调用都是尾调用。这意味着递归调用完成后,调用者无需执行任何额外操作。尾递归函数通常更高效,因为它们可以被编译器优化为循环,从而避免栈溢出并提升性能。

上一节我们介绍了尾递归的基本概念,本节中我们来看看如何通过累加器模式将普通递归函数转换为尾递归函数。

尾递归的优势

尾递归函数具有显著的性能优势。如果代码简洁且性能至关重要,将函数重写为尾递归形式是值得的。尾递归函数的核心特征是所有递归调用都是尾调用,即递归调用后没有其他工作。

累加器模式

将普通递归转换为尾递归的常用方法是使用累加器模式。这涉及创建一个辅助函数,该函数接受一个额外的参数——累加器。累加器用于在递归过程中累积结果。

以下是累加器模式的一般步骤:

  1. 创建一个辅助函数,接受原始参数和一个累加器。
  2. 将原递归的基准情况作为累加器的初始值。
  3. 在递归过程中,更新累加器以累积结果。
  4. 递归结束时,返回累加器作为最终结果。

这种方法适用于任何可以按任意顺序组合结果的操作。

示例一:阶乘函数

让我们以阶乘函数为例。传统的递归阶乘函数如下:

(define (factorial n)
  (if (= n 0)
      1
      (* n (factorial (- n 1)))))

这不是尾递归,因为递归调用后还需要执行乘法操作。

使用累加器模式,我们可以将其转换为尾递归版本:

(define (factorial n)
  (define (helper n acc)
    (if (= n 0)
        acc
        (helper (- n 1) (* n acc))))
  (helper n 1))

在这个版本中,helper 函数是尾递归的。累加器 acc 初始值为 1(原基准情况),并在每次递归调用中更新。

示例二:列表求和

接下来,我们看一个列表求和的例子。传统的递归求和函数如下:

(define (sum xs)
  (if (null? xs)
      0
      (+ (car xs) (sum (cdr xs)))))

这也不是尾递归,因为递归调用后需要执行加法操作。

使用累加器模式,我们可以将其转换为尾递归版本:

(define (sum xs)
  (define (helper xs acc)
    (if (null? xs)
        acc
        (helper (cdr xs) (+ (car xs) acc))))
  (helper xs 0))

在这个版本中,helper 函数是尾递归的。累加器 acc 初始值为 0(原基准情况),并在每次递归调用中更新。

示例三:列表反转

现在,我们来看一个更复杂的例子:列表反转。传统的递归反转函数如下:

(define (reverse xs)
  (if (null? xs)
      '()
      (append (reverse (cdr xs)) (list (car xs)))))

这个版本不仅不是尾递归,而且效率低下,因为它使用了 append 操作,而 append 需要复制整个列表。

使用累加器模式,我们可以将其转换为高效的尾递归版本:

(define (reverse xs)
  (define (helper xs acc)
    (if (null? xs)
        acc
        (helper (cdr xs) (cons (car xs) acc))))
  (helper xs '()))

在这个版本中,helper 函数是尾递归的。累加器 acc 初始值为空列表,并在每次递归调用中通过 cons 操作更新。

性能分析

传统反转函数的效率问题在于 append 操作。每次 append 都需要复制第一个列表,导致总工作量与列表长度的平方成正比(即 O(n²))。而尾递归版本仅使用 cons 操作,总工作量与列表长度成正比(即 O(n))。

因此,尾递归版本不仅避免了栈溢出问题,还显著提升了性能。

总结

本节课中我们一起学习了尾递归与累加器模式。我们了解到:

  • 尾递归函数可以通过编译器优化为循环,提升性能。
  • 累加器模式是一种将普通递归转换为尾递归的有效方法。
  • 通过阶乘、列表求和和列表反转的示例,我们掌握了累加器模式的应用。
  • 避免使用低效操作(如 append)可以进一步提升尾递归函数的性能。

希望这些知识能帮助你在编程中写出更高效、更优雅的递归函数。

050:尾递归的深入视角 🧠

在本节课中,我们将深入探讨尾递归的重要性,并学习如何精确定义何为“尾调用”。我们将从实际应用的角度出发,分析尾递归的适用场景,并提供一个形式化的定义来准确理解尾调用的概念。

尾递归的重要性与局限性 🔍

上一节我们讨论了尾递归的基本概念,本节中我们来看看它在实际编程中的重要性及其局限性。

首先,许多初学者在了解尾递归后,会急于将所有递归函数都改写为尾递归形式。但需要指出,在某些情况下,这实际上无法做到,或者即使能做到,也需要构建额外的数据结构(如列表),其占用的空间可能与调用栈本身一样多。

最明显的例子是处理树形结构的函数。在进行树的递归遍历时,你需要记录已经访问和尚未访问的部分,而调用栈是完成此任务的绝佳方式。因此,处理树的函数通常不会以尾递归形式结束。你或许可以使用辅助函数让其中一个递归调用(例如对左子树的调用)成为尾调用,但你仍然需要以某种方式记录对右子树所需进行的操作。你可以尝试,但最终会发现总有一个递归调用无法成为尾调用。

此外,程序员常常过于急切地优化代码。对于我们编写的许多程序而言,代码的直观性可读性易于验证正确性更为重要。很多时候,一个更简短、更自然的递归函数已经足够好。如果性能变得至关重要,你随时可以回头将其重构为尾递归函数。

尾调用的精确定义 📐

接下来,我们为“尾调用”提供一个更精确的定义。目前我们有一个很好的非正式定义:即调用者在进行此次调用后,不再有任何后续工作。例如,函数 F 调用 X,而 X 的结果直接成为 F 的结果。

然而,如何精确判断一个调用位置是否满足“无后续工作”的条件呢?与编程语言中的许多概念一样,我们可以通过一个优雅的、递归式的定义来描述函数体中何为“尾位置”。一个尾调用,就是任何发生在尾位置的函数调用。

以下是关于“尾位置”定义的一些关键情况。这是一个自上而下的递归定义:

  • 首先,如果一个表达式在尾位置,那么它的任何子表达式也在尾位置。一旦某处有后续工作,其组成部分必然也有后续工作。
  • 对于一个函数定义 fun f p = e,其函数体 e 本身处于尾位置。我们总是从整个函数体开始判断。
  • 对于条件表达式,如 if e1 then e2 else e3(或类似的 case 表达式):
    • 条件测试部分 e1ifthen 之间的部分)在尾位置,因为之后我们还需要根据结果执行 e2e3
    • 但是,如果整个条件表达式本身处于尾位置(即它是函数最后要做的事),那么 e2e3 这两个分支也各自处于尾位置。因为执行完其中一个分支后,整个函数就结束了。
  • 对于局部绑定表达式 let b1 b2 ... bn in e end
    • 如果这个 let 表达式本身处于尾位置,那么其主体 e 也处于尾位置。因为执行完 e 后函数就结束了。
    • 然而,所有绑定部分(b1, b2, ..., bn)中的表达式都在尾位置,因为在完成它们之后,我们还需要执行主体 e
  • 对于函数调用 e1 e2 的各个部分:
    • 即使整个函数调用 e1 e2 是一个尾调用(处于尾位置),其子表达式 e1e2在尾位置。因为我们必须先计算 e1(得到函数),再计算 e2(得到参数),最后才能进行调用。
    • 这意味着,对于嵌套调用如 f (g x)f 的调用可能是一个尾调用,但 g x 的调用绝不会是尾调用,因为在调用 g 之后,调用者还必须进行调用 f 的操作。

综上所述,我们通常用“无后续工作”的直觉来理解尾调用,而上述定义则根据编程语言的递归结构,使这一概念更加精确。这足以让我们准确理解尾位置、尾调用和尾递归。

总结 ✨

本节课中我们一起学习了尾递归的深入视角。我们认识到,虽然尾递归能优化空间效率,但它并非适用于所有场景(如树遍历),且代码的清晰性常常比过早优化更重要。同时,我们通过递归式定义精确地理解了“尾位置”和“尾调用”的概念,明确了如何根据代码的结构来判断一个调用是否为尾调用。掌握这些知识,能帮助你在实践中更明智地运用递归。

051:一等函数入门 🚀

在本节课中,我们将要学习一等函数的概念。我们将介绍相关术语,并回顾什么是函数式编程,为后续深入学习打下基础。

什么是函数式编程? 🤔

上一节我们介绍了课程主题,本节中我们来看看函数式编程的具体含义。函数式编程对我而言主要包含两个概念,它们因历史和实际原因被结合在一起。

  • 避免状态突变:不使用赋值语句,数据不存储在可改变的位置。我们已经学习并实践了很多这方面的内容。
  • 将函数作为值使用:这是本节课程的核心,即函数可以像其他值一样被传递和使用。

人们通常还会联想到函数式编程的其他特点:

  • 大量使用递归和递归数据结构(如列表、树)。
  • 编程风格或语法更接近数学表达。
  • 一些语言(如Haskell)具备惰性求值特性。
  • 应避免将“非面向对象”或“非C语言”简单等同于函数式编程的模糊定义。

什么是函数式语言? 💻

一旦理解了函数式编程,那么什么是函数式语言呢?我认为这并非一个简单的“是或否”问题。

函数式语言是指能够方便地进行函数式编程的语言。在几乎所有语言中都可以实践函数式编程,只是便利程度和默认范式不同。对我而言,函数式语言意味着函数式编程是其中简单、自然、常规的方式,其库通常也以函数式风格编写。

什么是一等函数? ⭐

既然函数式编程的一个重要部分是将函数作为值使用,那么是时候正式开始这部分内容了。

一等函数是指函数可以出现在其他值(如数字、列表、字符串)能够出现的任何地方。具体来说:

以下是函数可以作为一等公民被使用的几种情况:

  • 作为其他函数的参数。
  • 作为函数的返回结果。
  • 作为元组的一部分。
  • 可以绑定到变量。
  • 可以放入数据类型构造器中。

代码示例 📝

在这个介绍性章节中,我们不会编写大量代码,但会通过一个简单示例展示在ML中如何实现一等函数,并且使用的都是我们已经学过的特性。

我们定义两个简单的函数:

fun double x = x * 2
fun increment x = x + 1

现在,让我们创建一个元组:

val a_tuple = (double, increment, double(increment 7))
  • 在元组的第一部分,我们放入了函数double本身(注意,我们没有调用它,只是引用它)。
  • 第二部分放入了函数increment
  • 第三部分,为了对比函数本身和函数调用结果的区别,我们计算了double(increment 7)的值,即16

然后,我们可以从元组中取出函数并使用它:

val eighteen = (#1 a_tuple) 9

这里,#1 a_tuple取出了元组中的第一个元素,即double函数,然后用参数9调用它,得到结果18

这是一个一等函数的例子,因为我们将函数放入了元组,之后又取出并使用了它们。

高阶函数与函数闭包 📚

最后,让我再介绍两个术语。

一等函数最常见的用法并非放入或取出元组,而是将函数作为参数传递给另一个函数,或作为另一个函数的返回结果。执行这种操作的函数被称为高阶函数

高阶函数是一种强大的编程范式,用于提取公共计算模式,我们将在后续章节中看到例子。

另一个我们将在本节后面遇到的术语是函数闭包。函数闭包是指使用了函数定义外部绑定的函数。一旦拥有一等函数(函数可以被传递和返回),环境与函数的交互方式就变得更加复杂、有趣和强大。我们稍后会详细讨论函数闭包。

对我而言:

  • 一等函数意味着函数可以被传递并放置在任何你需要的地方。
  • 函数闭包意味着函数可以使用环境中的绑定(而不仅仅是参数和局部变量)。

这两个概念在技术上是独立的,但由于函数式语言总是同时支持两者,并且它们经常一起使用,所以这两个术语经常被混淆。尽管概念区分很重要,但鉴于它们常被误用,且实际影响不大,我不会过分强调术语定义。

总结 🎯

本节课中我们一起学习了函数式编程的核心概念,重点介绍了一等函数。我们了解到一等函数允许函数像其他数据值一样被传递、返回和存储。我们还通过代码示例直观感受了其用法,并初步接触了高阶函数和函数闭包这两个重要术语。希望你对一等函数的概念感到兴奋,我们将在下一节开始学习如何将它们用于编写真正有趣的代码。

052:函数作为参数 🧩

在本节课中,我们将学习一等函数最常见的用法:将一个函数作为参数传递给另一个函数。我们将通过一个具体例子来展示这种技术如何帮助我们减少代码重复,并编写出更通用、更优雅的程序。


上一节我们介绍了一等函数的基本概念,本节中我们来看看如何将函数作为参数传递给其他函数。这并非新的语言特性,只是我们之前未曾尝试过这种用法。在 ML 语言中,我们可以定义一个函数绑定,例如 F,它接受另一个函数 G 作为参数。在 F 的函数体内,G 是一个变量,当我们查找它时,会得到一个函数,然后我们可以用一些参数来调用这个函数。

因此,我们可以在一个地方用 H1 调用 F,在另一个地方用 H2 调用 F。这使得 F 更加有用、更具可配置性,因为 F 的不同调用可以传入不同的函数。这将是一种非常优雅的策略,使我们能够提取出代码中的公共部分,这是软件设计中最好的实践之一。

提取公共部分的方法是:与其编写 N 个非常相似的函数,不如编写一个包含所有公共部分的函数,然后传入 N 个较短的函数,这些短函数仅在其函数体中描述不同的计算部分。

接下来,我们将通过一个简单的例子来具体说明这是如何工作的。


首先,这里已经编写了三个普通的非高阶函数(我们称之为一阶函数)。它们都非常相似,我们来看看具体内容。这些函数可能看起来有点简单,但请耐心理解。

第一个函数接受两个参数 nx,并将 x 递增 n 次。如果 n 是 0,它直接返回 x;否则,它返回 1 加上将 x 递增 n-1 次的结果。这本质上是一个加法函数,只是将 n 加到 x 上。

第二个例子稍微实用一些。它接受一个数字 n 和另一个数字 x,并将 x 翻倍 n 次。换句话说,它是计算 2^n * x。其实现方式是:如果 n 是 0,则返回 x;否则,将递归调用(对 n-1x)的结果乘以 2。

最后一个例子不一定处理数字,它处理列表。它接受一个数字 n 和一个列表 xs,并取列表的 n 次尾。例如,如果传入 3 和列表 [4, 8, 12, 16],将返回列表 [16],因为对输入列表取三次尾后,只剩下包含 16 的列表。其实现方式是:如果 n 是 0,则返回整个列表;否则,取 n-1 次尾的结果的尾。

以下是三个简单的函数,我们本可以自己编写。但看到这些函数之间有如此多的相似之处,可能会让人感到不适。它们都接受两个参数;如果第一个参数是 0,则返回第二个参数;否则,对递归调用(参数为 xxs 以及 n-1)的结果进行某种操作。

因此,我们希望以某种方式将这些公共部分提取出来,这样就不必重复编写三次。通常,这些函数可能更大、更复杂。在没有一等函数的情况下,我们只能使用一些丑陋的条件判断(例如,通过标志位区分是递增、翻倍还是取尾),但这种方法不可扩展。如果将来出现第四个类似的函数怎么办?而一等函数可以非常优雅地解决这个问题。

接下来,我将编写一个函数 n_times,它除了接受 nx 之外,还接受另一个参数 f。这个 f 将用于捕获上述三个函数之间的差异。

在所有情况下,我都想说:如果 n 等于 0,则直接返回第二个参数。否则,我肯定想用相同的函数 fn-1x 递归调用 n_times。然后,我想对这个递归结果做什么?这取决于具体情况,取决于我是想翻倍、递增还是取尾。但在所有情况下,调用者只需传入一个能完成所需操作的函数 f 即可。

这就是我们实用的高阶函数。现在,让我们看看如何使用它来实现递增、翻倍和取尾操作。

首先,让我定义几个非常简单且简短的辅助函数。

fun increment x = x + 1
fun double x = x * 2

现在,我可以用多种方式使用 n_times。例如,如果我想将 7 翻倍 4 次,我可以这样做:

val x1 = n_times (double, 4, 7)

如果我想将 7 递增 4 次,我可以进行完全相同的调用,但传入 increment 函数:

val x2 = n_times (increment, 4, 7)

类似地,如果我想对一个列表(例如 [4, 8, 12, 16])取两次尾:

val x3 = n_times (tl, 2, [4, 8, 12, 16])

运行这些代码后,我们会看到 x1 是 112,x2 是 11,x3[12, 16]。这样,我们实现了大量的代码复用。我们只是使用了 n_times 以及非常简短的 incrementdouble 函数,然后以不同的方式使用它。

你可能会想,这很好,但调用者或代码用户不应该需要做这些额外的工作。当然,他们不必这样做。如果我想编写自己的递增函数,它接受 nx(我最好改个名字,因为我们知道只要 n 大于等于 0,这实际上就是加法),我可以这样定义:

fun add (n, x) = n_times (increment, n, x)

类似地,double_n_times 可以更好地写成:

fun double_n_times (n, x) = n_times (double, n, x)

最后,n_tail 可以写成:

fun n_tail (n, xs) = n_times (tl, n, xs)

一如既往,这些函数的调用者不需要知道,也不需要关心我们在底层使用了相同的公共代码 n_times 来实现它们。

最后,假设我们完成了这些工作,对自己非常满意。现在,为了简洁起见,我们可以注释掉所有重复的代码。所以,我们现在只剩下 n_times 和这三个辅助函数。

也许在之后的某个时间,你会发现那个模式、那个可重用的代码、那个高阶函数 n_times 甚至更有用。例如,你可能意识到需要将一个数字翻三倍 n 次。你会想,我只需调用 n_times,并传入一个我将编写的小辅助函数 triple

当然,我需要在这里定义 triple

fun triple x = 3 * x
fun triple_n_times (n, x) = n_times (triple, n, x)

这一切都很棒。让我们回顾一下,确保一切正确。现在,我们有了类型为 int * int -> int 的函数 adddouble_n_times,类型为 int * 'a list -> 'a list 的函数 n_tail,以及类型为 int * int -> int 的函数 triple_n_times

n_times 本质上接受一个函数、一个 n 和一个 x,并最终返回将 f 应用于 x n 次的结果(即 f(f(...f(x)...)))。它是一个通用、可重用、优美的高阶函数,我们可以通过为参数 f 传入不同的函数来复用它。

这似乎是一个很好的例子。唯一可能让你有点困惑的是 n_times 的类型,它在多态类型中看起来有点复杂。因此,我们将在下一节中更详细地讨论这个问题。


本节课中,我们一起学习了如何将函数作为参数传递给其他函数。我们通过一个具体的例子 n_times 展示了这种技术的强大之处:它允许我们提取代码中的公共模式,减少重复,并通过传入不同的函数来实现多样化的行为。这使得我们的代码更加模块化、可扩展且易于维护。理解并掌握这一概念是迈向函数式编程高阶技巧的重要一步。

053:多态类型与函数作为参数 🧩

在本节课中,我们将要学习高阶函数 n_times 的类型,并深入探讨多态类型与函数作为参数之间的关系。我们将通过具体示例,帮助初学者理解这些概念。

概述

上一节我们介绍了高阶函数 n_times,它接受一个函数 f、一个整数 n 和一个初始值 x,并返回将 f 应用于 xn 次的结果。本节中,我们来看看 n_times 的类型,并探讨多态类型与高阶函数之间的区别与联系。

n_times 的类型分析

n_times 函数的类型为:

('a -> 'a) -> int -> 'a -> 'a

这个类型表示:

  • 第一个参数是一个函数,它接受一个类型为 'a 的值,并返回一个类型为 'a 的值。
  • 第二个参数是一个整数 int
  • 第三个参数是一个类型为 'a 的值。
  • 返回值也是一个类型为 'a 的值。

类型变量 'a(读作 alpha)表明这是一个多态函数。它可以在不同的调用中被实例化为不同的具体类型(如 intint list),只要满足类型约束即可。

为了理解这个类型,我们可以先考虑一个更简单的特化版本。如果我们限制所有 'a 都为 int,那么类型就变为:

(int -> int) -> int -> int -> int

这个类型更容易理解:它接受一个 int -> int 的函数、一个 int 和一个 int,最终返回一个 int。查看 n_times 的代码逻辑也能印证这一点:

  • n 必须是 int,因为我们要与 0 比较。
  • 有时函数直接返回 x,因此 x 的类型必须与整个函数的返回类型相同。
  • 递归调用 n_times 的结果会作为参数传递给 f,因此 f 的参数类型必须与 n_times 的返回类型匹配。
  • f 的返回类型又必须与 n_times 的返回类型匹配,因为 f 的结果被直接返回。

综上所述,f 的参数类型、返回类型、x 的类型以及 n_times 的返回类型,这四者必须完全相同。n_times 的编写者并不关心这个具体的类型是什么,这正是多态类型所表达的:所有这些 'a 位置必须被替换为同一个类型,但这个函数适用于任何这样的类型。

n_times 的类型实例化

在实际使用 n_times 时,我们确实用不同的具体类型实例化了类型变量 'a

以下是 n_times 的不同用法示例:

  • 用于整数运算:在调用 n_times double 4 7 时,'a 被实例化为 intdoubleint -> int 函数,47int,结果也是 int
  • 用于列表操作:在调用 n_times tl 2 [1,2,3,4] 时,'a 被实例化为 int listtl(即 tail 函数)本身是多态的,对于 int list 类型,它是 int list -> int list 函数,符合第一个参数的类型要求。初始值 [1,2,3,4]int list,最终结果也是 int list

多态性极大地增强了代码的复用性。如果没有多态,我们就需要为 int 类型写一个 n_times 版本,再为 int list 类型写另一个版本,这会削弱一等函数和代码复用理念的价值。

多态与高阶函数的区别

多态类型和函数作为参数(高阶函数)是两个独立的概念。并非所有高阶函数都是多态的,也并非所有多态函数都是高阶的。

示例1:非多态的高阶函数

考虑以下函数 times_until_zero

fun times_until_zero f x =
    if x = 0
    then 0
    else 1 + times_until_zero f (f x)

这个函数计算需要对 x 重复应用函数 f 多少次,结果才能变为 0

它的类型是 (int -> int) -> int -> int。这是一个高阶函数(它以函数 f 为参数),但它不是多态的。原因如下:

  • x 需要与 0 比较,所以 x 必须是 int
  • f 被以 x 为参数调用,所以 f 必须接受 int
  • f(x) 的结果又作为参数传递给 times_until_zero,所以 f 必须返回 int
    因此,这个函数仅适用于整数,但它仍然是一个有用的、可复用的高阶函数。

示例2:非高阶的多态函数

考虑我们熟悉的 length 函数:

fun length xs =
    case xs of
        [] => 0
      | _::xs' => 1 + length xs'

它的类型是 'a list -> int。这是一个多态函数(它可以处理任何类型 'a 的列表),但它不是高阶函数,因为它内部没有以其他函数作为参数。它只是接受一个列表,返回其长度,并不关心列表元素的类型。

总结

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

  1. 分析了高阶函数 n_times 的多态类型 ('a -> 'a) -> int -> 'a -> 'a,并理解了其类型约束的来源。
  2. 通过实例,看到了多态类型如何使同一个函数能灵活应用于不同类型(如 intint list)的数据上。
  3. 明确了多态(与类型变量相关)和高阶函数(以函数为参数或返回值)是两个不同的概念,并通过 times_until_zerolength 两个例子进行了对比:
    • 存在非多态的高阶函数。
    • 也存在非高阶的多态函数。
      理解函数的类型,尤其是高阶函数的类型,对于编写正确、通用且可复用的代码至关重要。

054:匿名函数 🎯

在本节课中,我们将学习 ML 语言中的匿名函数。匿名函数是一种无需使用 fun 绑定即可定义的函数。我们将通过一个逐步演进的例子来理解匿名函数的用途、语法及其与普通函数绑定的区别。

概述

匿名函数在 ML 中是一种强大的语言构造,特别适用于高阶函数的使用场景。它们允许我们在需要函数的地方直接定义函数,而无需为其命名。本节将通过一个具体的例子,展示从普通函数绑定到匿名函数的演进过程,并解释匿名函数的语法、优势及限制。

演进过程

初始版本:顶层辅助函数

我们从一个熟悉的函数 n_times 开始,它接受一个函数 f、一个整数 n 和一个参数 x,并返回将 f 应用于 x n 次的结果。接着,我们定义了一个函数 triple_n_times,它调用 n_times 并传递一个辅助函数 triple。在这个版本中,triple 被定义为顶层函数。

fun triple x = 3 * x;
fun triple_n_times n x = n_times triple n x;

然而,triple 仅在 triple_n_times 中使用,将其定义为顶层函数并不符合良好的编程风格。

改进版本:局部辅助函数

为了改进,我们将 triple 定义为 triple_n_times 内的局部函数。这样,triple 的作用域被限制在 triple_n_times 内部,避免了不必要的全局暴露。

fun triple_n_times n x =
    let
        fun triple y = 3 * y
    in
        n_times triple n x
    end;

这个版本比初始版本更好,但我们可以进一步缩小 triple 的作用域。

进一步改进:最小作用域

我们注意到 triple 仅在传递给 n_times 时使用一次。因此,我们可以将 let 表达式直接放在 n_times 的第一个参数位置。这样,triple 的定义仅出现在需要它的地方。

fun triple_n_times n x =
    n_times (let fun triple y = 3 * y in triple end) n x;

虽然这个版本能正常工作,但它看起来有些笨拙。ML 提供了更好的构造来处理这种情况。

最终版本:匿名函数

ML 的匿名函数允许我们直接定义一个没有名称的函数。语法使用 fn 关键字,后跟参数、=> 符号和函数体。这样,我们可以在需要函数的地方直接定义它,而无需使用 let 表达式。

fun triple_n_times n x =
    n_times (fn y => 3 * y) n x;

这个版本最简洁,清晰地表达了我们的意图:定义一个匿名函数,它接受参数 y 并返回 3 * y,然后将其传递给 n_times

匿名函数详解

语法

匿名函数的语法如下:

  • 使用 fn 关键字。
  • 后跟参数(可以是模式)。
  • 使用 => 符号分隔参数和函数体。
  • 函数体是一个表达式。

例如,fn x => 3 * x 定义了一个匿名函数,它接受一个参数 x 并返回 3 * x

优势

匿名函数的主要优势在于:

  • 简洁性:无需为仅使用一次的函数命名。
  • 局部性:函数定义紧挨着使用它的地方,提高代码可读性。
  • 便利性:特别适合与高阶函数配合使用。

限制

匿名函数有一个重要限制:无法定义递归函数。因为递归函数需要调用自身,而匿名函数没有名称,无法进行递归调用。如果需要递归,必须使用 fun 绑定。

与函数绑定的关系

对于非递归函数,fun 绑定可以看作是匿名函数的语法糖。例如:

  • fun triple x = 3 * x 等价于 val triple = fn x => 3 * x

然而,对于递归函数,这种等价关系不成立,因为匿名函数无法实现递归。

总结

在本节课中,我们一起学习了 ML 中的匿名函数。我们通过一个例子,从顶层辅助函数逐步演进到使用匿名函数,理解了匿名函数的语法、优势及限制。匿名函数特别适用于高阶函数场景,能够使代码更简洁、更清晰。需要注意的是,匿名函数不支持递归,递归函数仍需使用 fun 绑定。掌握匿名函数的使用,将有助于你编写更优雅、更高效的 ML 代码。

055:避免不必要的函数包装 🚫

在本节课中,我们将学习一个常见的编程风格问题:不必要的函数包装。通过理解这个概念,你可以写出更简洁、更高效的代码。

概述

上一节我们介绍了匿名函数,本节我们将探讨一种过度使用匿名函数的编程模式。这种模式被称为“不必要的函数包装”,它会导致代码冗余和性能损失。

不必要的函数包装示例

让我们使用之前学过的 n_times 高阶函数来演示这个问题。假设我们需要一个函数,用于获取列表的 n 次尾部。

fun n_tail (n, xs) = n_times (fn y => tl y, n, xs)

这段代码可以正常工作,但它存在风格问题。我们定义了一个匿名函数 fn y => tl y,它接收一个参数并返回该参数的尾部。

更简洁的写法

实际上,我们可以直接使用 tl 函数,因为它已经是一个接收列表并返回尾部的函数。

fun n_tail (n, xs) = n_times (tl, n, xs)

这种写法更简洁,因为:

  • tl 本身就是一个函数,无需额外包装。
  • 代码更易读,直接表达了“使用 tl 函数”的意图。
  • 性能稍好,因为减少了一次不必要的函数调用。

不必要的函数包装模式

以下是常见的“不必要的函数包装”模式:

  • fn x => f x:这种匿名函数只是将参数传递给另一个函数 f。在这种情况下,直接使用 f 即可。
  • if x then true else false:这个表达式等价于 x 本身。

为函数创建别名

有时,我们可能想为库函数创建一个更短或更易记的别名。例如,为标准库中的 List.rev 函数创建一个别名 rev

不必要的包装写法:

fun rev xs = List.rev xs

更优的写法:

val rev = List.rev

在更优的写法中,rev 被直接绑定到 List.rev 这个函数值上。这利用了函数是一等公民的特性,既简洁又高效。

总结

本节课我们一起学习了“不必要的函数包装”这一概念。关键要点是:当匿名函数仅仅是将参数传递给另一个现有函数时,应直接使用那个现有函数。这能使代码更简洁、更清晰,并带来微小的性能提升。记住,好的编程风格在于用最直接的方式表达意图。

056:map与filter高阶函数 🧠

在本节课中,我们将学习并实现两个著名的高阶函数:mapfilter。这两个函数在计算机科学和软件开发中极为常见,是处理列表等数据结构的强大工具。我们将通过简单的例子来理解它们的工作原理和类型签名。

概述 📋

map 函数接受一个函数和一个列表,返回一个新列表,其中每个元素都是原列表对应元素经过给定函数处理后的结果。filter 函数也接受一个函数和一个列表,但该函数返回布尔值,filter 会返回原列表中所有使该函数返回 true 的元素组成的新列表。

实现 map 函数

上一节我们介绍了高阶函数的概念,本节中我们来看看如何实现 map 函数。map 函数的核心思想是将一个函数 f 应用到列表的每个元素上。

以下是 map 函数的定义:

fun map f xs =
    case xs of
        [] => []
      | x::xs' => (f x) :: (map f xs')

这个函数的逻辑是:

  • 如果输入列表 xs 为空,则结果也是一个空列表。
  • 如果列表非空(由头部 x 和尾部 xs' 组成),则结果列表的头部是 f x,尾部是递归地对 xs' 应用 map f 的结果。

map 函数的类型签名非常通用:(‘a -> ‘b) -> ‘a list -> ‘b list。这意味着:

  • 第一个参数 f 是一个从任意类型 ‘a 到任意类型 ‘b 的函数。
  • 第二个参数是一个 ‘a 类型的列表。
  • 返回值是一个 ‘b 类型的列表。

使用 map 函数

理解了 map 的实现后,我们来看看如何使用它。

以下是两个使用 map 的例子:

  1. 对整数列表中的每个元素加一:
    val x1 = map (fn x => x + 1) [4, 8, 12, 16]
    (* 结果为 [5, 9, 13, 17] *)
    
  2. 获取一个列表的列表(int list list)中每个子列表的第一个元素:
    val x2 = map (fn lst => hd lst) [[1,2,3], [3,4], [5]]
    (* 结果为 [1, 3, 5] *)
    

使用 map 是一种良好的编程风格,它能清晰地告诉代码的阅读者:这里正在对集合中的每个元素进行相同的操作以生成一个新的集合。

实现 filter 函数

接下来,我们学习第二个高阶函数 filter。与 map 不同,filter 用于根据条件筛选列表中的元素。

以下是 filter 函数的定义:

fun filter f xs =
    case xs of
        [] => []
      | x::xs' => if f x
                  then x :: (filter f xs')
                  else filter f xs'

这个函数的逻辑是:

  • 如果输入列表为空,则结果为空列表。
  • 如果列表非空,则检查函数 f 应用于头部元素 x 的结果。
    • 如果 f xtrue,则将 x 包含在结果中,并递归处理剩余部分。
    • 如果 f xfalse,则跳过 x,直接递归处理剩余部分。

filter 函数的类型签名是:(‘a -> bool) -> ‘a list -> ‘a list。这意味着:

  • 第一个参数 f 是一个从任意类型 ‘a 到布尔值 bool 的函数。
  • 第二个参数和返回值都是 ‘a 类型的列表,因为 filter 返回的是原列表的一个子集。

使用 filter 函数

现在,我们通过一些例子来看看 filter 的实际应用。

以下是两个使用 filter 的例子:

  1. 定义一个函数,从整数列表中筛选出所有偶数:
    fun is_even x = (x mod 2 = 0)
    fun all_even xs = filter is_even xs
    
    (* 测试 *)
    val result = all_even [3, 4, 6, 0, 13]
    (* 结果为 [4, 6, 0] *)
    
  2. 定义一个函数,从一个(任意类型,整数)对的列表中,筛选出第二个元素是偶数的对:
    fun all_even_second xs =
        filter (fn (_, y) => y mod 2 = 0) xs
    

总结 🎯

本节课中我们一起学习了两个极其重要的高阶函数:mapfilter

  • map 函数用于将同一个操作应用于列表的每个元素,从而生成一个结构相同但内容被转换的新列表。其类型为 (‘a -> ‘b) -> ‘a list -> ‘b list
  • filter 函数用于根据一个判断条件(返回布尔值的函数)从列表中筛选出符合条件的元素,生成一个子集列表。其类型为 (‘a -> bool) -> ‘a list -> ‘a list

掌握这两个函数是理解函数式编程思想的关键一步,它们也是许多编程语言标准库中的核心工具。

057:泛化先前主题 🧠

在本节课中,我们将学习一等函数(First-Class Functions)的通用性。我们将看到函数不仅可以作为参数传递,还可以作为结果返回,并且可以用于处理各种递归数据结构,而不仅仅是列表。通过具体示例,我们将理解如何编写高阶函数来抽象通用计算模式。


一等函数的通用性

上一节我们介绍了mapfilter等一等函数的例子。本节中,我们将探讨一等函数的更广泛用途。

目前我们看到的例子(如n_timesmapfilter)都只涉及一个函数作为参数,并递归处理数字或列表。实际上,我们可以在任何可以使用表达式的地方使用函数。

以下是我们可以用一等函数实现但尚未见到的功能:

  • 传递多个函数作为参数:就像我们传递一个函数F来抽象计算一样,如果你有两个不同的计算需要抽象,完全可以让调用者传递两个不同的函数。
  • 将函数放入数据结构:可以将函数存储在元组、列表或记录中。本课程后续部分会展示一个使用此技巧的惯用法。

本节我们将重点演示两个新功能:

  1. 函数返回其他函数作为结果。
  2. 为自定义数据类型(而非列表)编写高阶函数。

函数返回函数 🔄

首先,我们来看一个函数如何返回另一个函数。以下是一个示例代码:

fun double_or_triple f =
    if f 7
    then (fn x => 2 * x)
    else (fn x => 3 * x)

这个函数接收一个参数f并调用f 7。因此,f的类型必须是int -> bool

then分支和else分支中,它都返回一个匿名函数(任何计算结果为函数的表达式都可以)。由于thenelse的类型必须相同,这两个分支返回的是相同类型的函数。实际上,这个函数总是返回int -> int类型。

因此,double_or_triple函数绑定的类型是:接收一个int -> bool类型的参数,返回一个int -> int类型的函数。

让我们看看如何使用它:

val double = double_or_triple (fn x => x - 3 = 4)

double现在是一个函数,因为double_or_triple返回一个函数。我们可以调用它:

double 4 (* 返回 8 *)

我们也可以直接使用返回的表达式:

(double_or_triple (fn x => x = 42)) 3 (* 返回 9 *)

在REPL中尝试,double_or_triple的类型显示为(int -> bool) -> int -> int。REPL省略了结果类型周围的括号,因为它们是可选的。

类型括号规则
当看到像T1 -> T2 -> T3 -> T4这样的类型时,隐式括号总是向右结合。这意味着这是一个接收T1并返回一个函数(该函数接收T2并返回另一个函数...最终返回T4)的函数。初次接触时,你需要在脑中加上这些括号,但很快你就会习惯并喜欢这种简洁的表示法。


为自定义数据类型编写高阶函数 🌳

高阶函数不仅适用于数字和列表,它们也是处理任何递归数据结构的绝佳方式,你可以在其中用多种不同的计算执行相同类型的递归遍历。

例如,回顾我们熟悉的算术表达式数据类型:

datatype exp = Constant of int
             | Negate of exp
             | Add of exp * exp
             | Multiply of exp * exp

假设有一个作业问题:“给定一个表达式,其中的每个常量都是偶数吗?”你可以为此编写递归函数。但如果有另一个问题:“每个常量都小于10吗?”代码会非常相似。

如果你有许多这种形式的遍历(“所有常量都满足某个条件吗?”),那么将这种数据遍历和处理抽象成一个高阶函数将是一个好主意。

以下是如何实现:

fun true_of_all_constants (f, e) =
    case e of
        Constant i => f i
      | Negate e1 => true_of_all_constants(f, e1)
      | Add(e1, e2) => true_of_all_constants(f, e1) andalso true_of_all_constants(f, e2)
      | Multiply(e1, e2) => true_of_all_constants(f, e1) andalso true_of_all_constants(f, e2)

这个函数接收一个函数f和一个表达式e,检查f是否对e中的每个常量都返回true。它类似于filter,但最终返回一个布尔值。

现在,要判断“所有常量是否为偶数”,我们只需调用这个高阶函数:

fun all_even e = true_of_all_constants((fn x => x mod 2 = 0), e)

true_of_all_constants的类型是((int -> bool) * exp) -> bool。它接收一个int -> bool函数和一个exp,返回一个布尔值。这种返回布尔值、处理某些数据的函数,可以称为谓词(Predicate)。而true_of_all_constants是一个高阶谓词,它抽象了具体的计算(int -> bool),并返回一个使用该计算的谓词。


总结 📝

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

  1. 一等函数的通用性:函数可以作为参数、返回值,并存入数据结构。
  2. 函数返回函数:通过double_or_triple示例,我们了解了如何定义和调用返回函数的函数,并理解了多箭头类型的结合规则。
  3. 为自定义数据类型编写高阶函数:通过true_of_all_constants示例,我们看到了如何将递归数据结构的通用遍历模式抽象成高阶函数,从而避免重复代码,提高代码的复用性和清晰度。

高阶函数是抽象和复用代码逻辑的强大工具,掌握它们能让你更优雅地处理复杂的编程任务。

058:词法作用域 🧠

在本节课中,我们将深入探讨词法作用域这一核心概念。词法作用域是理解和使用一等函数的关键基础。我们将通过具体示例和代码,详细解释词法作用域的工作原理,并介绍闭包的概念。

概述

词法作用域并非新概念,但在引入高阶函数后,我们需要重新审视并准确理解它。简单来说,词法作用域规定:函数体在执行时,使用的是函数定义时的环境,而非调用时的环境。这一规则是大多数现代编程语言(包括ML)的默认行为。

词法作用域的核心规则

上一节我们提到了函数可以访问其定义时环境中的变量。本节中,我们来看看这个规则的具体含义。

词法作用域的核心规则是:函数在查找变量时,依据的是它被定义时的作用域(环境),而不是它被调用时的作用域。

以下是一个关键示例,展示了这一规则:

val x = 1; (* 第1行:环境1中,x 映射到 1 *)
val f = fn y => x + y; (* 第2行:定义函数f。此时环境1中 x=1,因此 f 是一个将参数加1的函数 *)
val x = 2; (* 第3行:遮蔽(shadow)x。新环境2中,x 映射到 2 *)
val y = 3; (* 第4行:环境2中,y 映射到 3 *)
val z = f (x + y); (* 第5行:调用 f *)

让我们逐步分析执行过程:

  1. 第1行后,环境为 {x -> 1}
  2. 第2行,将函数 f 添加到环境。f 映射到一个函数,该函数体为 x + y。根据词法作用域,这个函数体将始终在定义它的环境(即 {x -> 1})中查找 x 的值。因此,f 是一个“参数加1”的函数。
  3. 第3行,x 被遮蔽,新环境变为 {x -> 2, f -> (函数)}。这不影响 f 函数内部所关联的旧环境。
  4. 第4行,环境变为 {x -> 2, y -> 3, f -> (函数)}
  5. 第5行,调用 f(x + y)
    • 首先,在当前调用环境中计算实参 x + y,得到 2 + 3 = 5
    • 然后,调用函数 f。根据词法作用域,执行函数体 x + y 时,x 来自函数定义时的环境(值为1),y 来自本次调用的参数(值为5)。
    • 因此,计算结果为 1 + 5 = 6z 被绑定为6。

关键点:如果认为 z 的结果是7(即使用调用时的 x=2 进行计算),那么你使用的是动态作用域规则,这不是ML及大多数现代语言的工作方式。

闭包:实现词法作用域的机制

你可能会疑惑,函数如何能“记住”一个已经不再存在的旧环境?这并非魔法,而是通过闭包实现的。

为了正确实现词法作用域,语言的实现(如ML解释器或编译器)必须将函数定义时的环境保存下来。

因此,一个函数值(一等公民)实际上包含两个部分:

  1. 代码部分:函数的可执行体。
  2. 环境部分:函数定义时的环境。

这个(代码,环境) 对就被称为闭包。当我们传递或调用一个函数时,实际上是在操作这个闭包。

让我们用闭包的概念重新分析上面的例子:

  • 在第2行 val f = fn y => x + y; 执行时,我们创建了一个闭包
    • 代码部分:fn y => x + y
    • 环境部分:{x -> 1}
  • 在第5行调用 f(5) 时:
    1. 我们向这个闭包传递参数 5
    2. 执行时,使用闭包中保存的环境部分{x -> 1})并为其添加本次调用的绑定 {y -> 5},形成一个用于执行函数体的新环境 {x -> 1, y -> 5}
    3. 在这个新环境中执行闭包的代码部分 x + y,得到结果 6

所以,闭包是词法作用域的具体实现机制

课程总结

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

  1. 词法作用域的核心规则:函数使用其定义时的环境,而非调用时的环境。
  2. 通过一个详细的代码示例,演示了词法作用域与变量遮蔽的交互。
  3. 引入了闭包的概念,解释了函数值如何通过(代码,环境)对来“记住”定义时的作用域,从而实现词法作用域。

理解词法作用域和闭包是掌握高阶函数编程范式的基石。在接下来的课程中,我们将利用这一知识,探索接收函数或返回函数的更强大用法,对比动态作用域,并学习一系列依赖于词法作用域的、强大的函数式编程技巧。

059:词法作用域与高阶函数 🧠

在本节课中,我们将要学习词法作用域与高阶函数。我们将通过两个例子来理解,当函数可以接收其他函数作为参数,或者返回其他函数时,词法作用域规则如何保持不变。这为编写更强大、更灵活的代码奠定了基础。

词法作用域规则回顾 📚

上一节我们介绍了词法作用域的基本概念。本节中我们来看看当函数变得更加复杂时,规则如何应用。

核心规则是:函数体总是在函数被定义时所处的环境中进行求值。当我们创建一个函数时,会生成一个包含当时环境的闭包。之后调用该函数时,就使用这个闭包中的环境。

规则本身没有改变,但环境的内容会因为嵌套的 let 表达式、函数参数和返回值而变得更加有趣。

示例一:返回函数的函数 ➡️🧩

以下是第一个示例,我们将逐行分析。

val x = 1
fun f y =
    let
        val x = y + 1
    in
        fn z => x + y + z
    end
val x = 3
val g = f 4
val z = g 6

首先,我们有一个环境,其中 x 绑定为 1。但随后我们定义了一个函数 f

  • fun f y = ...:函数 f 接收一个参数 y
  • let val x = y + 1 in ... end:在 f 的函数体内,我们创建了一个局部变量 x,其值为 y + 1。这个 x 遮蔽了外层的 x
  • fn z => x + y + zf 返回一个匿名函数,该函数接收参数 z,并返回 x + y + z 的和。

根据词法作用域,这个匿名函数被定义时,其环境包含了 f 被调用时传入的 y 值,以及局部变量 x(值为 y + 1)。因此,这个匿名函数的行为是固定的:它将 (y + 1) + y + z 的结果返回,即 2*y + 1 + z

现在来看调用过程:

  • val g = f 4:调用 f(4)。此时,在返回的匿名函数的闭包环境中,y4x5。因此,g 是一个“给参数加 9”的函数(因为 2*4 + 1 = 9)。
  • val z = g 6:调用 g(6)。使用闭包中的环境:x=5, y=4,加上参数 z=6,计算 5 + 4 + 6,得到结果 15

外层的 val x = 3 是无关的,因为它被 f 内部的 let 表达式遮蔽了。

过渡到示例二 🔄

在第一个例子中,我们看到了函数如何返回另一个函数。接下来,我们看看函数如何接收另一个函数作为参数。

示例二:接收函数作为参数的函数 🧩➡️

以下是第二个示例:

fun f g =
    let
        val x = 3
    in
        g 2
    end
val x = 4
fun h y = x + y
val z = f h

首先分析函数 f

  • fun f g = ...:函数 f 接收一个参数 gg 预期是一个函数。
  • let val x = 3 in ... end:在 f 内部定义了一个局部变量 x,值为 3
  • g 2f 的函数体是调用传入的函数 g,并传入参数 2

关键点:由于词法作用域,我们可以独立理解 ff 的行为很简单:“用参数 2 调用你给我的函数 g”。局部变量 x 在函数体中从未被使用,因此它的存在与否(或值是多少)完全不影响 f 的行为。我们可以安全地删除 let...in 部分,将 f 简化为 fun f g = g 2,其功能完全不变。

现在来看调用过程:

  • val x = 4:在顶层环境中,x 绑定为 4
  • fun h y = x + y:定义函数 h。根据词法作用域,h 被创建时,其闭包捕获了当前环境,其中 x4。因此,h 是一个“给参数加 4”的函数
  • val z = f h:调用 f(h)
    1. 查找 f,得到我们刚分析过的函数。
    2. h(“加4函数”)作为参数 g 传入。
    3. f 的函数体内,执行 g 2,即 h(2)
    4. 调用 h(2)此时使用 h 被定义时的环境,其中 x=4。因此计算 4 + 2,得到结果 6

结果 z6,而不是 7。如果动态作用域,在 f 内部调用 g(2) 时可能会使用 f 内部的局部变量 x=3,从而得到 3+2=5,或者错误地结合其他环境。但词法作用域保证了我们总是使用函数定义时的环境,因此结果是确定且可预测的 6

总结 🎯

本节课中我们一起学习了词法作用域在高阶函数中的应用。通过两个例子,我们验证了核心规则:

函数体总是在其定义时的环境中求值,与调用时的环境无关。

  • 返回函数的例子中,返回的函数其行为由它被创建时的参数值决定。
  • 接收函数作为参数的例子中,传入的函数其行为由它自身定义时的环境决定,而非由调用者(f)的内部环境决定。

词法作用域的这种特性使得代码模块化、推理和维护变得更加简单。你可以独立地分析每个函数,而不必担心其他部分的代码如何调用它。在接下来的课程中,我们将探索如何利用这种特性来构建强大且优雅的程序。

060:为什么需要词法作用域 🧠

在本节课中,我们将探讨编程语言中作用域规则的核心概念,特别是为什么现代语言普遍采用词法作用域而非动态作用域。我们将通过对比分析,理解词法作用域在代码可读性、可维护性和功能强大性方面的优势。

概述

词法作用域,也称为静态作用域,其核心规则是:函数体中变量的值,取决于该函数被定义时所处的环境。与之相对的另一种规则是动态作用域,即变量的值取决于函数被调用时所处的环境。本节课我们将深入探讨为什么词法作用域是更优的选择。

词法作用域的优势

上一节我们介绍了词法作用域的基本概念,本节中我们来看看它带来的三个主要优势。这些优势不仅仅是风格偏好,更是技术上的必然选择。

1. 函数含义不依赖于变量名 🏷️

词法作用域允许我们安全地重命名局部变量,而不会影响函数的行为。这是良好软件工程和模块化的基础。

以下是具体示例:

(* 函数 F1 *)
fun f1 y =
    let val x = 15
    in
        fn z => x + y + z
    end

(* 函数 F2,仅将局部变量 x 重命名为 q *)
fun f2 y =
    let val q = 15
    in
        fn z => q + y + z
    end

val a1 = (f1 7) 4 (* 结果为 19 *)
val a2 = (f2 7) 4 (* 结果同样为 19 *)

在词法作用域下,f1f2 返回的函数行为完全一致,都将其参数 z 加上 157(即 y 的值)。调用者无法区分两者。若采用动态作用域,f2 的函数体在调用时会寻找名为 q 的变量,若调用环境中没有 q,则会导致错误或使用错误的值。

2. 函数可在定义处进行类型检查和推理 🔍

在词法作用域下,我们可以在函数定义时就确定其类型和行为,无需考虑它将来在何处被调用。这使得编译时类型检查成为可能,极大地增强了程序的可靠性。

让我们分析同一个例子:

fun f y =
    let val x = 15
    in
        fn z => x + y + z
    end

(* 我们可以独立推断 f 的类型为: int -> (int -> int) *)
(* 即,它接受一个 int,返回一个接受 int 并返回 int 的函数 *)

val result = (f 7) 4 (* 结果为 19 *)

我们能够确定 f 返回的函数总是执行 15 + y + z 的加法运算。如果采用动态作用域,在调用 (f 7) 返回的函数时,解释器会到调用环境中去寻找 xyz 的值。如果调用环境是这样的:

val x = “hello” (* x 是字符串 *)
(* y 和 z 可能未定义 *)

那么程序会在运行时因类型错误或未定义变量而崩溃,完全破坏了静态类型检查的意义。

3. 闭包变得更加强大 💪

这是词法作用域最激动人心的优势。它允许闭包“记住”其定义时的环境,从而存储任意所需的数据。这在处理像 mapfilter 这样的高阶函数时尤其强大。

以下是两个利用词法作用域增强 filter 函数功能的例子:

示例一:创建通用的“大于比较器”

fun greater_than x = fn y => y > x
(* greater_than 类型: int -> (int -> bool) *)

val non_neg = filter (greater_than (~1)) [~2, ~1, 0, 1, 2]
(* 结果为 [0, 1, 2] *)

当调用 greater_than (~1) 时,它返回一个闭包。这个闭包的函数体是 fn y => y > x,并且它的环境记录了 x = ~1。当 filter 将这个闭包应用于列表的每个元素时,它总是在自己的环境中查找 x,得到 ~1,从而正确判断 y > ~1 是否成立。如果采用动态作用域,闭包会在 filter 的函数体内查找 x,而 filter 内部可能有一个完全不同的 x 变量,导致逻辑错误。

示例二:使用匿名函数

fun all_greater (xs, n) =
    filter (fn x => x > n) xs

val result = all_greater([5, 10, 15, 20], 12)
(* 结果为 [15, 20] *)

传递给 filter 的匿名函数 fn x => x > n 是在 all_greater 函数体内定义的。因此,当在 filter 内部执行这个函数时,它查找变量 n 会追溯到定义它的环境,即 all_greater 的参数 n。词法作用域保证了这一点。

动态作用域还有用吗?

既然动态作用域有这么多缺点,为什么它还会存在?因为它偶尔在特定场景下能提供便利。例如:

  • 一些语言(如 Racket)提供了特殊的动态作用域变量作为补充机制。
  • 它可能用于控制输出重定向、配置参数传递等需要“全局”但临时覆盖值的场景。

此外,如果我们仔细思考,异常处理机制在行为上类似于动态作用域。当 raise 一个异常时,系统并不是在代码的静态结构(定义处)寻找最近的 handle,而是在动态的调用栈上寻找。这种“最近”是基于运行时的调用顺序,而非代码的书写位置。实践证明,对于异常处理,这种动态查找的方式更加方便和合理。

总结

本节课中我们一起学习了词法作用域的核心价值。我们通过三个关键原因论证了其优越性:

  1. 保证局部变量名的无关性,支持安全的代码重构。
  2. 支持在定义处进行可靠的类型检查和推理,这是静态类型系统的基石。
  3. 赋予闭包强大的“记忆”能力,使得高阶函数能灵活携带所需数据,极大地提升了代码的表达力和模块化能力。

虽然动态作用域在特定边缘案例或像异常处理这样的特殊机制中仍有其身影,但词法作用域无疑是现代编程语言变量查找规则的正确默认选择。理解这一点,对于编写可靠、可维护和富有表现力的代码至关重要。

061:闭包与避免重复计算 🚀

在本节课中,我们将学习如何利用对词法作用域的理解,在使用闭包时避免不必要的重复计算。我们将通过一个具体示例,展示如何通过简单的代码调整来提升程序性能,并加深对函数闭包工作机制的理解。

概述

我们将首先回顾函数求值、变量绑定和闭包的基本概念。随后,通过一个过滤字符串列表的示例,展示一个存在不必要重复计算的初始版本代码。最后,我们将利用闭包和局部变量对该代码进行优化,消除重复计算,并通过添加打印语句直观地展示优化效果。

核心概念回顾

在深入示例之前,我们先明确三个关键点:

  1. 函数体求值时机:函数体中的代码直到函数被调用时才会执行。这与词法作用域无关,是大多数编程语言中函数的通用行为。

    • 代码示例fun f(x) = ... (* 函数体 *) 中的 ... 部分在 f(5) 调用时才计算。
  2. 函数体的重复求值:每次调用函数时,其函数体都会使用当次调用的参数重新求值一次。

  3. 变量绑定求值时机:变量绑定(如 let 绑定)中的表达式仅在绑定被执行时求值一次,而不是在每次使用该变量时都重新求值。

    • 代码示例let val size_s = String.size(s) in ... end 中的 String.size(s) 只计算一次。

理解以上三点后,我们可以将它们结合起来。利用闭包,我们可以将函数体中那些不依赖于函数参数的计算结果“提取”出来,通过局部变量绑定只计算一次,从而避免在每次函数调用时重复计算。这能提升性能,虽然可能使代码稍长,但有助于清晰展示函数闭包的语义。

示例:过滤短字符串

让我们通过一个具体例子来演示。首先,我们使用高阶函数 filter。其定义没有变化。

现在,我们定义一个函数 allShorterThan1。它接收一个字符串列表 xs 和一个字符串 s,返回一个新的字符串列表。其类型为 string list * string -> string list

它的功能是过滤出所有长度严格小于字符串 s 长度的字符串。

以下是实现方式:调用 filter,传入列表 xs 和一个合理的匿名函数。该匿名函数接收列表中的一个元素 x,并判断 String.size(x) < String.size(s) 是否成立。如果 x 严格更短,则保留在结果中;否则过滤掉。

这个实现本身没有错误,能得到正确结果。但问题是,对于列表 xs 中的每一个元素,我们都会重新计算一次 String.size(s)。因为 filter 会为每个列表元素调用一次匿名函数,而每次调用都会计算 s 的长度。这是一个不必要的重复计算,因为 s 的长度并不会改变。

优化:使用闭包避免重复计算

修复方法如下,我们来看第二个例子 allShorterThan2

我们创建一个局部变量 i 来保存 String.size(s) 的结果。然后在匿名函数中,判断条件改为 String.size(x) < i

这将产生完全相同的答案。调用者无法区分我们使用的是第一个版本还是第二个版本,除非注意到当列表非常长或 s 非常长时,两者存在性能差异。

这正是我想展示的技巧。请注意,这里我们需要闭包。如果没有词法作用域,没有在闭包中存储函数定义时的环境,我们将无法在匿名函数内部使用这个局部变量 i。这再次证明了闭包和词法作用域是非常自然且必要的概念。

可视化优化效果

为了直观展示两者的区别,我们可以在代码中添加一些打印语句,以便观察计算发生的位置。

ML语言中有一个 print 函数,它接收一个字符串并将其打印出来。此外,还有一个分号 ; 操作符。通常,表达式 E1; E2 会先执行 E1 并丢弃其结果,然后执行 E2,整个表达式的结果是 E2 的结果。在函数式编程中,我们很少需要这种顺序执行,因为执行一个没有赋值或副作用、结果又被丢弃的计算没有意义。但打印是一个很好的具有副作用的例子,而这正是我们想要的效果。

类似地,我们也可以在调用 String.size(s) 之前添加打印语句。我的想法是,每次计算 String.size(s) 时,打印一个感叹号 !。这样修改后,我们得到两个函数 allShorterThan1allShorterThan2

下面是一些测试代码。它会先调用 allShorterThan1,然后在一个长度为4的列表上调用 allShorterThan2(使用相同的字符串参数),接着再次调用 allShorterThan2

运行这段代码(文件名为 use-closures-and-recomputation.sml),在修复了括号问题后,我们得到以下输出:

我们可以看到:

  • 对于没有使用局部变量的 allShorterThan1 版本,在处理长度为4的列表时,我们得到了4个感叹号,这意味着 String.size(s) 被重复计算了4次。
  • 而对于调用了 allShorterThan2 的版本,let 绑定(计算 String.size(s))只执行了一次。尽管匿名函数被调用了4次,我们查找了变量 i 4次,但查找变量只是返回值(例如数字3,即列表长度),这个过程没有重复计算。

总结

本节课中,我们一起学习了如何利用闭包和词法作用域来优化代码,避免不必要的重复计算。我们回顾了函数求值、变量绑定和闭包的核心概念,并通过一个过滤字符串的示例,演示了如何将不依赖于函数参数的昂贵计算移出函数体,通过局部变量绑定只计算一次。这种技巧虽然可能使代码稍长,但能有效提升性能,并且清晰地展示了闭包如何“记住”其定义时的环境。最后,我们通过添加打印语句,直观地验证了优化前后计算次数的差异。

062:Fold 与更多闭包示例

在本节课中,我们将学习一个名为 fold 的著名高阶函数,它用于递归遍历数据结构。我们将通过多个示例来展示如何使用闭包,并深入理解这类迭代式高阶函数的本质。

概述

Fold(有时也称为 reduceinject)是一个用于遍历递归数据结构(如列表)并产生单一结果的高阶函数。它的核心思想是,通过一个初始值(累加器)和一个二元函数,依次处理列表中的每个元素,最终得到一个累积结果。

Fold 的工作原理

上一节我们介绍了高阶函数的概念,本节中我们来看看 fold 的具体实现。其工作方式如下:

我们定义一个函数 fold,它接收三个参数:一个函数 F、一个初始累加器值 acc 和一个列表 xs。其过程是:

  1. 将函数 F 应用于累加器 acc 和列表的第一个元素。
  2. 将上一步的结果作为新的累加器,与列表的第二个元素一起传递给 F
  3. 重复此过程,直到遍历完整个列表,最终的结果就是最后的累加器值。

你可以想象列表从左到右展开,然后从初始累加器开始,依次“折叠”每个元素。

在像 ML 这样支持高阶函数和便捷语法的语言中,我们可以用大约三行代码实现 fold

fun fold (f, acc, xs) =
    case xs of
        [] => acc
      | x::xs' => fold (f, f(acc, x), xs')

这是一个从左向右折叠的版本(foldl)。如果列表元素从左到右排列,我们也可以编写一个从右向左折叠的版本(foldr),但后者通常无法很好地实现尾递归。除非函数 F 对顺序敏感,否则方向通常不重要,因此标准库中通常会区分左折叠和右折叠。

Fold 的类型签名

fold 的类型签名清晰地揭示了它的功能。在 REPL 中,其类型可能显示为:

(‘a * ‘b -> ‘a) -> ‘a -> ‘b list -> ‘a

这告诉我们:

  • 函数 F 接受两个类型分别为 ‘a‘b 的参数(它们可以相同也可以不同),并返回一个 ‘a 类型的值。
  • 累加器 acc 的类型必须是 ‘a
  • 列表 xs 的元素类型必须是 ‘b
  • 整个 fold 函数的最终结果类型也是 ‘a

因此,你从一个 ‘b list 和一个初始答案 ‘a 开始,使用 F 遍历列表,在每个位置产生一个新的 ‘a 类型答案,最终得到最后一个 ‘a 作为结果。

为何 Fold 如此重要

这类迭代器函数并非内置于语言中,它们只是一种编程模式。我们刚才在幻灯片上用三行 ML 代码就写出了 fold。许多语言确实为遍历数据结构和计算结果(如 fold 所做之事)提供了内置支持,这并无不妥,因为这些操作非常普遍。

但拥有 fold 的美妙之处在于,我们可以直接用语言本身编写它。它是一个概念。我们为列表编写了 fold,如果面对数组、树、图或集合等不同的数据结构,我们同样可以为它们编写 fold。然后,不同的使用者可以传入各种各样的函数 F 来使用 fold

这实现了关注点分离:一组人可以专注于为复杂的数据结构编写 fold,而另一组人可以专注于为特定结果编写计算逻辑。如果你开始使用列表以外的数据结构,可以复用你的计算函数,只需一个新的 fold 实现即可。反之,一旦为列表编写了 fold,许多人可以将其用于不同的计算。这一切仅仅通过使用高阶函数和传递函数来实现。

Fold 使用示例

理论阐述已经足够,现在让我们通过代码来展示一系列 fold 的示例用法,以帮助你更好地掌握其工作方式。

以下是 fold 函数的定义,我们将基于它进行演示:

fun fold (f, acc, xs) =
    case xs of
        [] => acc
      | x::xs' => fold (f, f(acc, x), xs')

示例 1:列表求和

第一个示例展示了如何使用 fold 对列表元素求和。

val f1 = fn xs => fold ((fn (x, y) => x + y), 0, xs)

在这个调用中,我们传入列表 xs,初始累加器为 0。匿名函数的第一个参数 x 是当前的累加器,第二个参数 y 是列表的下一个元素。每一步,我们都将累加器与下一个列表元素相加,结果作为新的累加器。因此,这行代码是使用 fold 对列表元素求和的一行式解决方案。

示例 2:检查所有元素是否非负

第二个示例检查列表中的所有元素是否都非负(大于等于0)。

val f2 = fn xs => fold ((fn (x, y) => x andalso y >= 0), true, xs)

这里,初始累加器是 true。在每一步,我们检查当前累加器 x 是否为真,并且下一个元素 y 是否 >= 0。如果 y 是负数,整个表达式将为假;如果 x 已经是假,结果也将是假。x 会随着列表的每个元素更新。最终,这个函数判断列表中的所有元素是否都非负。

示例 3:计算范围内元素个数(使用闭包)

接下来的示例开始展示闭包的真正威力,即函数可以使用其定义环境中的其他数据。

val f3 = fn (xs, low, high) =>
    fold ((fn (x, y) => x + (if y >= low andalso y <= high then 1 else 0)), 0, xs)

我们传入列表 xs,初始累加器为 0。在匿名函数中,x 是当前累加器,y 是下一个元素。我们检查 y 是否在 [low, high] 这个闭区间内,如果是则加1,否则加0。注意,这里使用了私有数据 lowhigh,它们只在定义此匿名函数的作用域内有效。闭包使得这成为可能。这个函数实际上是在计算列表中处于 lowhigh 之间(包含两端)的元素个数。

示例 4:检查所有字符串长度小于给定字符串

这个示例类似于上一节的内容,它接收一个字符串列表和一个字符串 s,检查列表中所有字符串的长度是否都严格小于 s 的长度。

val f4 = fn (xs, s) =>
    let val i = String.size s
    in
        fold ((fn (x, y) => x andalso String.size y < i), true, xs)
    end

我们调用 fold,初始累加器为 true。闭包中的逻辑是:当前累加器 x 为真,并且下一个字符串 y 的长度严格小于预先计算好的 i(即 s 的长度)。通过 let 绑定避免了对 String.size s 的重复计算。这个函数最终判断列表中的所有元素(字符串)的长度是否都严格小于字符串 s 的长度。

示例 5:通用的“全部满足”函数

最后一个示例定义了一个更通用的高阶函数。

val f5 = fn (g, xs) => fold ((fn (x, y) => x andalso g y), true, xs)

函数 f5 接收一个函数 g 和一个列表 xs。它调用 fold,初始累加器为 true,并在每一步计算 x andalso g(y)。这意味着,f5 使用 fold 来检查列表中的所有元素在传递给函数 g 时是否都返回 true。这是一个可复用的函数,用于判断列表中的所有元素是否都满足某个条件(由 g 定义)。

基于这个通用的 f5,我们可以重新定义之前的 f4,而无需直接使用 fold

val f4_v2 = fn (xs, s) =>
    let val i = String.size s
    in
        f5 ((fn y => String.size y < i), xs)
    end

这里,我们只需调用 f5,并传入一个简单的匿名函数(检查字符串长度是否小于 i)以及列表 xs。这是使用高阶函数的另一种方式。注意,在 f5 的定义中,函数 g 是在其定义处捕获的,这也是闭包的应用。

总结

本节课中,我们一起学习了 fold 这个强大的高阶函数。我们首先了解了它的工作原理和类型签名,然后通过多个示例演示了它的用法,包括列表求和、条件检查,以及利用闭包引入私有数据进行更复杂的计算(如计数和通用判断)。

这些示例表明,mapfilterfold 这三个最重要的高阶函数,在闭包和词法作用域的加持下变得更为强大。我们可以通过作用域传入函数 F 所需的任何私有数据,而迭代器 fold 本身无需知道这些数据是什么,甚至无需知道其类型。无论是像 f1f2 那样不使用私有数据,还是像 f3f4f5 那样使用,fold 都以相同的方式工作:为列表中的每个元素调用一次函数 F,而 F 可以使用其环境中的任何信息。

我们已经见识了 mapfilterfold,当然还存在许多其他有用的高阶函数。事实上,我们刚刚定义的 f5 本身就是一个非常有用且比 fold 更简单易用的高阶函数,而我们正是用 fold 定义了它。这标志着我们对 fold 学习的结束,接下来可以继续探索使用闭包的其他重要模式和惯用法。

063:闭包惯用法之组合函数

在本节课中,我们将学习如何利用已掌握的函数闭包知识,探索更多强大的编程惯用法。我们已经了解了词法作用域和函数闭包的语义,并实践了将函数传递给迭代器这一关键惯用法。现在,我们将继续学习其他几种惯用法。本节课将重点介绍组合函数,例如函数组合。

函数组合

上一节我们介绍了闭包的基本概念,本节中我们来看看如何组合函数。函数组合是数学和计算机科学中的一个基本思想。我们首先编写一个名为 compose 的函数,它接收两个函数 FG,并返回一个新函数。当调用这个新函数时,它会计算 F(G(x))

fun compose (f, g) = fn x => f (g x)

调用 compose 并传入两个函数后,返回的函数完全利用了闭包的语义。当调用它时,它可以在定义该函数时存在的环境中查找 FG

我喜欢将其类型视为:接收两个参数,一个是 alpha -> beta(即 G),另一个是 beta -> gamma(即 F),并返回一个 alpha -> gamma 的函数。一旦你看到这样的类型,函数的功能就几乎一目了然:它必须在其参数 A 上调用 F(G(A)),最终返回类型为 C 的结果。

在 ML 中,函数组合已经作为一个中缀运算符提供。它使用小写字母 o(不是数字0)表示。因此,你可以写 f o g,这完全等同于我们上面编写的函数。

以下是使用示例。首先是不使用组合的常规方式:

fun sqrt_of_abs i = Math.sqrt(Real.fromInt(abs i))

现在,我们使用函数组合来编写一个版本,以更清晰地表达代码意图:

fun sqrt_of_abs i = (Math.sqrt o Real.fromInt o abs) i

由于这符合“不必要的函数包装”这一标准模式,我们可以更直接地表达:

val sqrt_of_abs = Math.sqrt o Real.fromInt o abs

这就是函数组合的用法。我们可以看到,要使这一切正确工作,我们需要闭包。

管道运算符

尽管函数组合在数学中顺序自然,但在代码中我们不得不从右向左阅读:先取绝对值,转换为实数,再取平方根。这有点反向,尤其是对于习惯从左向右阅读代码的程序员。

近年来,另一个运算符变得更受欢迎,在 F#(ML 的一种方言)中大量使用,许多函数式程序员也喜欢使用它。这个运算符通常写作 |>,但在当前使用的 SML 模式中,我使用 !> 来避免冲突。

我们可以定义自己的中缀运算符:

infix !>
fun x !> f = f x

这个函数看起来非常简单:它接收一个参数 x 和一个函数 f,然后调用 f(x)。语义上并不复杂,只是调用了函数和参数。但通过将参数放在左边,它允许我们以一种许多程序员认为易于阅读和愉快的方式编写 sqrt_of_abs

fun sqrt_of_abs i = i !> abs !> Real.fromInt !> Math.sqrt

我们称之为管道运算符,因为它看起来像是建立了一个管道:从数字 i 开始,将其通过一系列函数传递,最终得到我们想要的答案。这很巧妙,它只是一个编程惯用法,定义了一个非常简单的高阶函数。

其他组合示例

我已经展示了函数组合和管道。当然,你还可以用函数组合做更多有趣的事情。以下是其他一些可能有用的组合示例。

首先,定义一个“后备”函数。假设你接收函数 FG,你想运行 F,但如果 F 的结果不合适,则返回 G 的结果。我们组合函数,返回一个新函数,该函数接收 x,并尝试计算 F(x)。按照以下写法,F 返回一个选项类型。如果该选项是 NONE,则调用 G(x);否则,如果它是 SOME y,则返回 y

fun backup1 (f, g) = fn x =>
    case f x of
        NONE => g x
      | SOME y => y

从类型可以看出,F 必须是 alpha -> beta option,因为我们需要用 NONESOME 进行模式匹配。第二个参数 G 必须接收与 F 相同的输入,因为我们可能用调用 F 时相同的 x 来调用 G。结果类型不能带有选项,因为这是我们整个函数的结果,就像在 case 表达式的另一个分支中返回 beta 一样。

你可能更喜欢一个使用异常而不是选项的版本:

fun backup2 (f, g) = fn x => f x handle _ => g x

在这个版本中,FG 都是 alpha -> beta 类型。如果 F 在调用 x 时引发任何异常(通过 handle 表达式中的模式匹配),则改为调用 G(x)

总结

本节课中我们一起学习了组合函数的几种闭包惯用法。我们介绍了函数组合,它允许我们将多个函数串联起来;探讨了管道运算符,它提供了一种从左向右阅读的函数应用方式;还看了一些其他组合函数的示例,如后备函数。这些都是在掌握了将函数传递给迭代器之后,进一步利用闭包语义的强大工具。在下一节中,我们将继续学习下一个闭包惯用法。

064:柯里化闭包惯用法 🧮

在本节课中,我们将开始学习下一个闭包惯用法:柯里化。这是函数式编程中我最喜欢做的事情之一,它是一种从概念上处理多参数函数的新方法。

多参数函数的传统处理方式

你可能记得,在 ML 语言中,每个函数都只接受一个参数。我们之前通过将 N 个参数作为单个元组的 N 个不同部分来传递,从而解决了这个问题。

柯里化的基本概念

另一种处理多参数函数的方法被称为柯里化。它的核心思想是:函数接受一个参数,然后返回一个接受下一个参数的函数。返回的函数仍然能够使用第一个参数,因为该参数会保存在其闭包环境中。这种方法以一位名叫 Curry 的人命名。作为一个有趣的旁注,在我知道这个名字的由来之前,我实际上花了好几年时间试图弄清楚它为什么叫“柯里化”,当然我从未成功。

通过代码示例理解柯里化

让我们通过一个简单的代码示例来展示柯里化是如何工作的。在本节中,我们将坚持使用一个非常简单的例子:一个在概念上接受三个参数 x、y 和 z 的函数,并检查它们是否按顺序排列。

元组方式

首先,我们看看使用元组的传统方式。我们定义一个函数,它接受一个三元组,通过模式匹配解构它,然后根据 z >= yy >= x 的条件返回 true 或 false。

fun sorted_tuple (x, y, z) = z >= y andalso y >= x

我们可以这样调用这个函数:sorted_tuple (7, 9, 11)

柯里化方式

现在,我们来看一种新的方式。起初它可能看起来很复杂,但稍后我会展示一些语法糖,让它变得甚至比元组版本更简洁、更令人愉快。

以下是柯里化版本的实现:

val sorted3 = fn x => fn y => fn z => z >= y andalso y >= x

或者,使用 fun 关键字,我们可以写成:

fun sorted3 x = fn y => fn z => z >= y andalso y >= x

这两种写法是完全等价的。val 版本在这里可能更容易理解。

现在,我们可以这样使用它:首先调用 sorted3 7,这会返回一个函数。然后我们调用这个返回的函数 9,这又会返回另一个函数。最后,我们调用这个函数 11。整个过程如下:

val t2 = ((sorted3 7) 9) 11

这绝对可以工作,并且应该给出正确的答案。在我们的代码中,sorted_tuple 接受一个整数元组并返回布尔值,t1 为 true。sorted3 接受一个整数,返回一个接受整数并返回另一个接受整数并最终返回布尔值的函数。我们正确地使用了它,所以 t2 也为 true。

深入理解柯里化的语义

让我们更仔细地看看发生了什么,因为这看起来非常复杂。其实,它使用的都是我们已经了解的闭包语义。

  1. 当我们调用 sorted3 7 时,我们得到了一个闭包。闭包有两部分:代码和环境。
    • 代码就是我们调用的函数体:fn y => fn z => z >= y andalso y >= x
    • 环境是 x 映射到 7
  2. 当我们用 9 调用这个闭包时,我们得到另一个闭包。
    • 代码现在是 fn z => z >= y andalso y >= x
    • 环境是 x 映射到 7y 映射到 9
  3. 当我们用 11 调用这个闭包时,我们计算 andalso 表达式并返回 true

这就是全部。虽然语义可能看起来很复杂,但它只是我们已经习惯的闭包。由于柯里化是一种非常常见的模式、一种常见的惯用法,我们不需要每次都这样深入思考。我们只需要想:“哦,我在使用柯里化,所以它就像一个多参数函数。”

使用语法糖简化柯里化

现在,让我们通过指出 ML 中一些现成的语法糖,让柯里化的使用变得更加愉快。

简化调用

首先,我们可以清理这个调用。通常,你不需要所有这些括号。如果你只是用空格分隔参数,括号会自动从左到右组织调用。所以,如果你写 sorted3 7 9 11,它等价于调用 sorted3 并传入 7,得到一个函数,然后用 9 调用它,再得到一个函数,最后用 11 调用它。

因此,我们不需要那些括号。我们可以回到代码中,直接写:

val t3 = sorted3 7 9 11

现在,如果你将其与调用元组函数进行比较,它实际上字符更少(虽然空格更多,但屏幕上的混乱更少)。这实际上很不错。

这是一个很好的时机来指出:如果你有一个像 sorted3 这样的柯里化函数,你可以用这两种方式中的任何一种来调用它。但如果你有一个元组函数,你只能以元组的方式调用它,不能混用。例如,你不能写 sorted3 (7, 9, 11),因为 sorted3 不期望一个元组,它会立即产生类型错误。同样,你也不能用空格分隔的方式调用一个期望元组的函数。

简化定义

定义柯里化函数也比我之前建议的要容易。你不需要写所有这些返回其他匿名函数的匿名函数。ML 的函数绑定语法内置了对柯里化的支持。

以下是 sorted3 的另一种定义,我称之为 sorted3_nicer

fun sorted3_nicer x y z = z >= y andalso y >= x

如果你只是在等号前用空格分隔多个模式(这里只是变量),这就意味着这是一个柯里化函数。

与我们之前的版本相比,这两者完全相同。这是对之前版本的语法糖。虽然因为我们使用了 fun,我们也可以使用递归,但这里不需要递归。

有了这个,这是一种编写函数更优雅的方式,并且我们可以继续使用我们的语法糖来调用它。

这是一种思考多参数函数的非常好的方式:调用者只需用空格分隔参数,定义者也只需用空格分隔参数。但在语义上发生的是闭包:函数返回其他函数。其余的都只是语法糖。

我想指出,因为这仅仅是语法,你也可以用所有这些括号来调用 sorted3_nicer,因为这一行与上面的行意思完全相同。

柯里化在 fold 函数中的应用

这就是我想向你展示的大部分内容。在 SML 的 REPL 中,这些元组函数会像这样打印它们的参数。sorted3_nicer 会有完全相同的类型。记住,括号在这里是向右结合的。所以 int -> int -> int -> bool 表示:我是一个函数,接受一个 int 并返回一个 int -> int -> bool 的函数;你向那个函数传入一个 int,你会得到一个 int -> bool 的函数;你再向那个函数传入一个 int,你会得到一个 bool

在 REPL 的输出中,我还有一件事想向你展示,那就是柯里化形式的 fold 函数。

我们之前已经见过 fold。这里是一个使用我们语法糖、接受三个柯里化参数的版本:

fun fold f acc xs =
    case xs of
        [] => acc
      | x::xs' => fold f (f(acc, x)) xs'

我在这里高亮显示的第一行,意思完全等同于:我是一个名为 fold 的函数,它接受 f 并返回一个函数,该函数接受累加器 acc;如果你调用它,它返回一个函数,该函数接受列表 xs,而这是它的函数体。你会注意到,在递归调用中,由于我有一个柯里化函数,我需要用空格分隔这些参数,我在这里已经这样做了:第一个参数是 f,第二个参数是 (f(acc, x)),第三个参数是 xs'。你需要这些括号,以便知道第二个参数在哪里结束,第三个参数从哪里开始。

这是一个非常完美的柯里化函数。以下是它的一个用法,一个通过对列表调用柯里化的 fold 来求和的函数:

fun sum_list xs = fold (fn (x,y) => x+y) 0 xs

调用方式:fold 是柯里化的,这里是第一个参数(空格)第二个参数(空格)第三个参数。与 sorted3 的语义完全相同,只是更有用。

总结

回到 sorted3,我们的最终版本确实非常优雅。它实际上比使用元组的方法的非空格字符更少。我们只需知道它是底部版本的语法糖,这更容易理解发生了什么。但一旦你理解了,一旦你明白“哦,这就是柯里化在做的”,那么你就可以把它看作一个三参数函数,就像我们把元组看作多参数函数一样。

对于 fold,我们一开始就这样写它,它同样优雅。我们把 fold 看作一个三参数函数,并用三个参数调用它。

本节课中,我们一起学习了柯里化这一强大的闭包惯用法。我们了解了它的基本概念、通过闭包实现的语义、以及 ML 中简化其定义和调用的语法糖。柯里化提供了一种处理多参数函数的优雅方式,是函数式编程工具箱中的重要工具。

065:部分应用

在本节课中,我们将学习如何对柯里化函数使用部分应用,并探讨这一技术的实用价值。

概述

上一节我们介绍了如何使用柯里化来模拟多参数函数。本节中,我们将看到,如果调用柯里化函数时传入的参数少于其定义所需的参数,会发生什么。这被称为“部分应用”,它是一种强大且优雅的编程技巧。

部分应用的概念

部分应用允许我们固定一个函数的部分参数,从而创建一个新的、参数更少的函数。这并非新的语言特性,而是柯里化函数调用方式的自然结果。你可以随时调用一个返回函数的函数,并将返回的函数保存起来,以便后续使用。

部分应用示例

以下是几个部分应用的具体例子,帮助我们理解其工作原理。

示例一:判断非负数

我们有一个柯里化的三参数函数 sorted3,用于比较三个数。如果我们只传入两个参数,例如 sorted3 0 0,我们将得到一个等待第三个参数 z 的函数。这个新函数的作用是判断 z 是否非负。

(* 定义柯里化的 sorted3 函数 *)
fun sorted3 x y z = x <= y andalso y <= z

(* 部分应用:固定前两个参数为 0 *)
val isNonNeg = sorted3 0 0
(* isNonNeg 现在是一个接受一个参数 z 的函数,判断 z 是否 >= 0 *)

示例二:列表求和

我们有一个柯里化的 fold 函数。通过部分应用,我们可以创建一个专门用于求和的函数。

(* 定义柯里化的 fold 函数 *)
fun fold f acc xs = ... (* 折叠操作的实现 *)

(* 部分应用:固定函数 f 为加法,初始累加器 acc 为 0 *)
val sumAll = fold (fn (x, y) => x + y) 0
(* sumAll 现在是一个接受一个列表 xs 的函数,返回列表中所有元素的和 *)

避免不必要的函数包装

在学习了部分应用之后,我们可以写出更简洁的代码。例如,之前我们可能会这样写:

val isNonNeg = fn z => sorted3 0 0 z
val sumAll = fn xs => fold (fn (x, y) => x + y) 0 xs

现在,我们可以直接利用部分应用,省略掉外层的匿名函数包装:

val isNonNeg = sorted3 0 0
val sumAll = fold (fn (x, y) => x + y) 0

这两种写法在功能上是完全等价的,但后者更简洁,并且直接体现了对柯里化和部分应用的理解。

更多实用示例

部分应用在函数式编程中非常常见,尤其是在使用高阶函数时。

示例三:生成数字序列

假设我们有一个生成数字区间的柯里化函数 range

fun range i j = ... (* 生成从 i 到 j 的列表 *)

(* 部分应用:固定起始数字为 1 *)
val countUp = range 1
(* countUp 6 将返回列表 [1,2,3,4,5,6] *)

示例四:列表谓词检查

标准库中的高阶函数通常是柯里化的,这使部分应用变得非常方便。

(* 检查列表中是否存在满足谓词的元素 *)
fun exists predicate xs = ...

(* 部分应用:创建一个检查列表中是否存在 0 的函数 *)
val hasZero = exists (fn x => x = 0)

(* 使用 List.map 的部分应用 *)
val addOneToList = List.map (fn x => x + 1)

(* 使用 List.filter 的部分应用 *)
val removeZeros = List.filter (fn x => x <> 0)

值限制及其应对方法

当你对多态柯里化函数使用部分应用时,可能会遇到 值限制 问题。ML 的类型系统引入此限制有充分理由,但它有时会导致意外的类型推断警告。

例如,以下代码可能会产生警告:

val pairWithOne = List.map (fn x => (x, 1))
(* 警告:类型变量未被泛化 *)

此时,得到的 pairWithOne 函数可能无法被正常使用。

以下是两种常见的解决方法:

方法一:放弃部分应用,使用显式的函数包装。

val pairWithOne = fn xs => List.map (fn x => (x, 1)) xs

方法二:为结果函数添加一个具体的类型注解,放弃其多态性。

val pairWithOne : string list -> (string * int) list = List.map (fn x => (x, 1))

请注意,在课程作业中,你可能不太会遇到这个问题,因为它通常只发生在结果函数仍然是多态的情况下。例如,对 int list 进行操作的函数通常不会触发此警告。

总结

本节课中,我们一起学习了 部分应用 这一核心概念。我们了解到,通过向柯里化函数传入少于其定义的参数,可以方便地创建新的、功能特定的函数。这使我们能够编写出更简洁、更富表达力的代码。同时,我们也简要介绍了在使用多态函数进行部分应用时可能遇到的 值限制 问题及其解决方案。掌握部分应用是深入理解函数式编程风格的重要一步。

066:柯里化总结 🧩

在本节课中,我们将学习柯里化的最后几个高级主题,包括如何在不同函数形式间转换、参数顺序的调整,以及柯里化的性能考量。我们将通过具体的代码示例来理解这些概念。


概述

本节将探讨两个核心主题。首先,我们将学习如何编写通用函数,在“元组形式”和“柯里化形式”的函数之间进行转换。其次,我们将讨论柯里化的性能问题,了解不同编程语言实现中的差异。


函数形式转换

上一节我们介绍了柯里化的基本概念,本节中我们来看看如何在不同形式的函数之间进行转换。有时,我们拥有的函数形式可能不符合调用需求。例如,我们可能有一个接收元组的函数,但希望以柯里化形式使用它进行部分应用;或者情况正好相反。

元组函数转换为柯里化函数

假设我们有一个函数 range,它接收一个包含两个数字的元组 (i, j),并返回从 ij 的列表。这是一个元组形式的函数。

(* 元组形式的 range 函数 *)
fun range (i, j) = if i > j then [] else i :: range(i+1, j)

如果我们想用它创建一个辅助函数 countUp,该函数接收一个数字 n 并返回从 1 到 n 的列表,我们很自然地会想到使用部分应用:

val countUp = range 1 (* 错误:range 期望一个元组,而不是两个单独的参数 *)

这段代码无法工作,因为 range 是元组形式的,不能直接进行柯里化部分应用。我们可以编写一个通用的 curry 函数来解决这个问题。

以下是 curry 函数的实现:

fun curry f x y = f (x, y)

这个函数接收一个元组函数 f,并返回一个新的柯里化函数。这个新函数依次接收两个参数 xy,然后将它们组合成元组 (x, y) 传递给原始的 f

现在,我们可以这样创建 countUp

val countUp = (curry range) 1
(* 等价于:val countUp = fn y => range (1, y) *)

这样,countUp 就成为了一个接收单个参数 y 并返回 range(1, y) 结果的函数。

柯里化函数转换为元组函数

同样,我们也可以进行反向操作。如果我们有一个柯里化函数,但需要接收一个元组参数(例如在函数组合链中),我们可以编写一个 uncurry 函数。

以下是 uncurry 函数的实现:

fun uncurry f (x, y) = f x y

这个函数接收一个柯里化函数 f,并返回一个新的元组函数。这个新函数接收一个元组 (x, y),然后将 xy 作为两个单独的参数传递给原始的 f

函数类型的深层联系

curryuncurry 的函数类型非常通用,揭示了柯里化和元组形式之间的深刻数学联系。

  • curry 的类型(’a * ’b -> ’c) -> (’a -> ’b -> ’c)
  • uncurry 的类型(’a -> ’b -> ’c) -> (’a * ’b -> ’c)

在逻辑学中,如果将 * 视为逻辑“与”,将 -> 视为逻辑“蕴含”,那么这两个类型签名实际上是等价的逻辑公式。这暗示了函数式编程中柯里化与元组参数之间存在着根本的、数学上的对称性。


调整参数顺序

有时,我们可能希望对一个柯里化函数进行部分应用,但不是应用于前几个参数,而是应用于后面的参数。由于柯里化函数必须按顺序接收参数,我们无法直接做到这一点。但我们可以通过创建一个新函数来交换参数的顺序。

以下是一个交换柯里化函数参数顺序的通用函数 otherCurry

fun otherCurry f x y = f y x

这个函数接收一个双参数柯里化函数 f,并返回一个新函数。新函数接收参数 xy,但会以 f y x 的顺序调用原始函数,即交换了参数顺序。

利用之前学过的语法糖,我们可以更简洁地定义它:

val otherCurry = fn f => fn x => fn y => f y x

通过组合这些基础的高阶函数,函数式程序员可以优雅地调整和组合函数,使代码结构清晰且符合需求。


柯里化的性能考量

人们常常担心柯里化的性能:创建大量闭包来调用函数会不会很慢?实际上,这取决于具体的编程语言实现。

通用原则

无论是调用元组函数还是通过柯里化调用多参数函数,在现代实现中通常都是常数时间操作,速度足够快。编程时应优先考虑代码的优雅、简洁和正确性

性能关键代码

对于程序中那些真正影响性能的微小部分(例如被调用数百万次的内层循环),我们可能需要了解哪种形式更快。这一点并非语言规范的一部分,完全取决于语言的具体实现

  • 在我们使用的 SML/NJ 编译器 中,元组形式的函数调用通常比柯里化形式稍快一些。
  • 然而,在当今大多数其他函数式语言实现中(如 OCaml、F#、Haskell),情况恰恰相反。这些语言的运行时对柯里化进行了高度优化,使得柯里化成为默认且高效的选择。在这些语言中,通常建议使用柯里化形式,仅在元组形式更方便的特殊情况下才使用它。

这就像课程开始时,我们暂时将元组函数说成是“三参数函数”以便理解,后来才揭示其本质。在这些以柯里化为默认形式的语言中,教学时也会采用类似的方式。


总结

本节课中我们一起学习了柯里化的高级应用。我们掌握了如何使用 curryuncurry 函数在元组形式和柯里化形式之间进行转换,理解了其类型签名背后深刻的逻辑对称性。我们还学习了如何通过 otherCurry 函数调整柯里化函数的参数顺序,以满足部分应用的不同需求。最后,我们探讨了柯里化的性能问题,认识到其效率取决于语言实现,因此在大多数情况下应专注于编写清晰的代码,仅在性能关键路径上根据具体环境进行选择。通过这些工具,我们可以更灵活、更优雅地组织和组合函数。

067:可变引用

在本节课程中,我们将暂时离开闭包的话题,首次在课程中展示可变的内容,即赋值语句。本课程中,我始终强调你不需要可变数据结构,因为它们会引发许多问题。避免使用它们有诸多好处,不可变性是一个极佳的默认选择。但我并不认为总是需要避免使用可变性。在某些情况下,你编写的程序、计算所模拟的事物本身具有固有的更新需求。世界上存在某些状态,当以自然方式思考你的问题时,需要让所有能访问该状态的人看到状态的更新。在这种情况下,使用可变性是有意义的。

大多数编程语言的问题在于它们让所有东西都可变。因此,即使在你对计算应如何进行的模型中,没有理由改变某些事物时,你也不得不担心它们会被改变。ML 在支持可变性方面做得很好,但并非针对变量、元组或列表。对于你希望可变的事物,你必须使用一种独立的语言结构,称为引用。并且只有引用的内容可以被更新。

我现在展示这个内容,是因为接下来要展示的闭包惯用法将在示例中使用它,这就是如此安排顺序的原因。在你的作业中,仍然不允许使用可变性。这是你需要大量练习的重要部分,即无可变性编程。我们要求你编写的程序,没有一个会因使用引用而受益,或者在你被允许使用引用时会变得更容易。因此,你不被允许使用。

引用类型与基本操作

上一节我们介绍了引入可变引用的背景和原因。本节中,我们来看看关于这些可变引用你需要知道的一切。

首先,有一种新的类型 T ref,其中 T 是任何类型。就像 T list 是任何类型 T 的列表类型一样,T ref 也是。所以,一个内容为 T 的引用的类型就是 T ref。例如,一个 int ref 的内容是整数。

语言中新增了三个用于使用引用的基本函数(原语):

  • ref e:创建一个新引用。e 是一个表达式。其工作方式是:计算表达式 e 得到一个值,然后创建一个新引用,结果是一个指向该引用的“指针”。这个“东西”的内容初始化为 e 计算出的值。现在,这些内容可以改变,因为这是可变操作。
  • e1 := e2:改变引用的内容。这是我们的赋值语句 :=。其工作方式是:计算 e1 得到一个引用,计算 e2 得到某个值,然后将该引用的内容更新为那个值。因此,e1 所指向的“东西”的内容被更改为 e2 的结果。
  • !e:读取引用的内容。我们使用感叹号 ! 来检索引用的内容。其工作方式是:计算 e 得到一个值,它最好是某个 T ref 类型,然后对该值应用 ! 操作,检索出引用中的 T 类型值。

在类型检查方面,e1 := e2 要求 e1 必须是某个类型 TT ref,而 e2 必须是 T 类型。我们不允许引用内容的类型发生改变。类型不能变。我们必须用一个整数替换另一个整数,或者用一个字符串替换另一个字符串。

引用示例与核心概念

了解了基本操作后,让我们看一个例子。这是一个简单的例子,但它强调了这些事物的不同之处。

val x = ref 42
val y = ref 42
val z = x
x := 43
!y + !z

以下是代码执行的步骤:

  1. val x = ref 42:创建一个新的、内容可变的存储位置,将其内容初始化为 42,并返回一个指向该“盒子”的引用(图中左侧的箭头)。此时,变量 x 绑定到这个箭头,该箭头指向一个存有 42 的盒子。
  2. val y = ref 42:创建一个新的盒子,将其内容初始化为 42,结果是指向它的箭头。变量 y 现在指向那个东西。
  3. val z = x:计算 x,我们在动态环境中查找 x,得到它指向的箭头。因此,zx 现在指向同一个引用,它们是别名
  4. x := 43:将 x 指向的盒子(也就是 z 指向的同一个盒子)的内容更新为 43
  5. !y + !z!y 检索 y 指向盒子的内容,得到 42!z 检索 z 指向盒子的内容(即被更新后的盒子),得到 4342 + 43 结果是 85

请注意,xyz 都具有 int ref 类型。它们引用一个持有整数的可变位置。你不能对 int ref 直接应用加法,这没有意义。对引用能做的唯一操作就是用 := 赋值和用 ! 解引用。所以你不能写 x + 1,而必须写 !x + 1。这很好地将可变位置的概念与整数的概念分开了。

事实上,我想在此强调:变量 xyz 本身是不可变。它们总是绑定到创建时所指的那个引用(箭头)。是箭头所指向的那个“东西”的内容可以改变。

因此,一旦你使用可变性,就像在任何其他编程语言中一样,你必须处理潜在的别名问题。这就是为什么当我们对 x 赋值后,此后 !z 得到的结果也改变了。如果你使用可变性,你可能需要考虑这类事情。如果你不想考虑这类事情,就不要使用可变性。

引用的本质与类比

你应该真正将这些引用视为一等公民值。你可以传递它们,可以将引用传递给函数,也可以从函数返回一个引用。

对于那些更习惯用其他语言(如 Java、C、C++ 等)编程的人,你可以将一个引用想象成一个小的可变对象,它只有一个字段。因为它只有一个字段,所以 := 更新该字段,! 读取该字段。我们不需要给这个字段命名,因为引用只有一个字段。

如果你想要多个字段,你可以让引用持有一个元组,然后你可以更新它指向一个不同的元组。可变的是引用的内容,而不是元组本身

总结

本节课中,我们一起学习了 ML 中的可变引用。我们了解到,ML 通过引入独立的 ref 类型及其操作(ref:=!)来支持可控的可变性,而不是默认所有东西都可变。我们通过示例看到了如何创建引用、如何通过赋值更新其内容、如何读取内容,并理解了变量绑定不可变引用内容可变之间的关键区别,以及由此产生的别名效应。最后,我们将引用类比为单字段的可变对象,并指出其作为一等公民值可以灵活传递的特性。虽然本课程后续不会大量使用可变性,但理解这些概念对于全面掌握编程语言特性至关重要。

068:闭包惯用法 - 回调函数

在本节课中,我们将学习闭包的第二个重要惯用法:回调函数。回调式编程在现代软件开发中非常普遍。我们将了解其工作原理,并通过一个简单的键盘事件库示例来演示如何实现和使用回调。

概述

回调函数是一种编程模式,库或框架允许客户端传入一段代码(函数),以便在特定事件发生时被调用。这种模式在处理用户输入、网络数据到达或游戏逻辑等异步事件时非常有用。闭包在此模式中至关重要,因为它允许回调函数访问其定义环境中的私有数据。

回调函数的工作原理

上一节我们介绍了闭包的基本概念,本节中我们来看看它在回调模式中的具体应用。

回调模式的核心是:库的编写者提供一个接口,允许客户端注册一些函数。当库内部监听的特定事件(如键盘按键、鼠标点击、网络数据包到达)发生时,库会自动调用所有已注册的函数。

为了实现这一点,我们需要一等函数(first-class functions)作为参数传递。但更重要的是,这些函数通常是闭包,这样每个客户端在传递回调时,都能利用其函数定义时的环境中的绑定,来访问执行回调时所需的私有数据。库的实现者无需知道这些数据的类型,这使得设计非常灵活。

面向对象语言通常使用对象来处理回调,而函数式语言使用闭包。两者各有千秋,但闭包方案同样优雅。由于本课程聚焦函数式语言,我们将采用闭包的方式。

实现一个简单的回调库

我们将实现一个模拟键盘事件处理的简单库。这个库将内部维护一个可变的状态,用于记录所有已注册的回调函数。

库的唯一公共接口是一个名为 onKeyEvent 的函数。它的类型是 (int -> unit) -> unit。客户端传入一个接收整数(代表按键编码)并返回 unit 的函数。库在后续事件发生时,会调用这个函数。

以下是库的实现代码:

val cbs : (int -> unit) list ref = ref []

fun onKeyEvent f = cbs := f :: (!cbs)

fun onEvent i =
    let fun loop fs =
        case fs of
            [] => ()
          | f::fs' => (f i; loop fs')
    in loop (!cbs) end

代码解释:

  1. val cbs : (int -> unit) list ref = ref []: 声明一个可变的引用 cbs,其内容是一个函数列表,初始化为空列表 []。这个列表用于存储所有注册的回调。
  2. fun onKeyEvent f = cbs := f :: (!cbs): 函数 onKeyEvent 接收一个回调函数 f,并通过 := 操作符更新 cbs,将新的回调 f 添加到列表的头部。
  3. fun onEvent i = ...: 这是一个模拟事件触发的函数。当调用 onEvent i 时(i 是模拟的按键编码),它会遍历 cbs 列表中的所有回调函数,并用参数 i 依次调用它们。

客户端如何使用回调库

了解了库的实现后,我们来看看客户端如何注册回调函数。客户端通过调用 onKeyEvent 并传入一个闭包来注册回调。这个闭包可以捕获并使用其定义环境中的变量。

以下是两个客户端示例:

示例一:按键计数器
这个客户端注册一个回调,用于统计按键事件发生的总次数。

val timesPressed = ref 0
val _ = onKeyEvent (fn _ => timesPressed := !timesPressed + 1)

代码解释:timesPressed 是一个整数引用。注册的回调是一个忽略其整数参数、只对 timesPressed 进行加一操作的函数。

示例二:特定按键打印器
这个客户端定义了一个函数 printIfPressed,它注册一个回调,仅在按下特定按键时打印消息。

fun printIfPressed i =
    onKeyEvent (fn j => if i = j
                        then print ("You pressed " ^ Int.toString i ^ "\n")
                        else ())

代码解释:函数 printIfPressed i 注册一个闭包。这个闭包将接收到的按键编码 j 与捕获的 i 进行比较,如果相等则打印信息。

我们可以用这个函数注册多个关心不同按键的回调:

val _ = printIfPressed 4
val _ = printIfPressed 11
val _ = printIfPressed 23
val _ = printIfPressed 4

运行示例

将所有代码加载后,初始时 cbs 列表中包含了5个回调函数(1个计数器 + 4个打印器),timesPressed 的值为0。

当我们通过 onEvent 函数模拟事件发生时:

  • 调用 onEvent 11: 会打印 “You pressed 11”,并且 timesPressed 变为1。
  • 调用 onEvent 79: 没有回调关心79,所以不打印任何内容,但 timesPressed 会递增到2。
  • 调用 onEvent 4: 由于我们注册了两个关心按键4的回调,所以会打印两次 “You pressed 4”,timesPressed 递增到3。
  • 调用 onEvent 23: 打印 “You pressed 23”,timesPressed 变为4。

这个过程清晰地展示了回调库的工作流程:事件触发时,所有注册的闭包被依次调用,每个闭包都能根据其捕获的环境数据执行不同的操作。

总结

本节课中我们一起学习了闭包的第二个核心惯用法:回调函数。我们了解了回调模式在事件驱动编程中的重要性,并动手实现了一个简单的键盘事件回调库。关键在于,闭包允许回调函数“记住”其定义时的环境,从而在事件发生时能访问所需的私有数据,而库的实现完全无需关心这些数据的细节。这种将代码与数据打包传递的能力,是函数式编程中闭包强大威力的又一体现。

069:标准库文档指南 🗂️

在本节课中,我们将学习如何查找和使用 ML 标准库中的函数。这是完成作业三所需的关键技能,能帮助你更高效地编写代码。

概述

标准库是任何编程语言实现中都会提供的代码集合。它包含两类内容:一是程序运行所必需的基础功能(如文件操作、网络访问),二是为了方便和统一而提供的常用功能(如列表处理函数)。学会查阅标准库文档是掌握新编程语言的重要环节。

标准库的作用

上一节我们介绍了高阶函数,本节中我们来看看如何在实际编程中利用标准库。标准库的存在有两个主要目的。

首先,它提供了无法通过语言基本元素(如列表、数字、字符串)自行构建的核心功能。例如,如果没有打开文件的功能,程序就无法与计算机的文件系统交互。

其次,它为标准化的常用操作提供了统一实现。例如,map 函数在 ML 编程中非常普遍,将其定义在标准库中,可以确保所有程序员使用相同的函数名、参数顺序和语义,从而提高代码的可读性和复用性。

如何查阅 ML 标准库文档

ML 标准库的文档组织在称为“结构”(structures)和“签名”(signatures)的模块中。虽然我们将在下一节详细学习模块系统,但现在我们完全可以先使用库中的函数。

文档的 URL 是:http://sml-family.org/Basis/manpages.html

在作业三中,你主要需要查阅与字符串(String)、字符(Char)、列表(List)以及列表对(ListPair)相关的结构。

使用库函数的方法与我们之前使用库函数的方式完全一致。你需要写出结构名,后跟一个点,然后是函数名。

代码示例:

List.map
String.isSubstring

使用 REPL 作为辅助工具

除了查阅完整的在线文档,你还可以利用 REPL(交互式环境)来快速获取函数信息。虽然 REPL 不能替代完整的文档,但它可以方便地提醒你函数的名称和类型。

例如,在 REPL 中直接输入 List.map,它会显示该函数的类型签名,帮助你确认参数顺序和柯里化情况。

代码示例:

(* 在 REPL 中输入 *)
List.map;
(* 输出可能为:('a -> 'b) -> 'a list -> 'b list *)

你甚至可以尝试猜测函数名。例如,输入 List.last 来查看是否存在这个函数。如果猜错了,REPL 会提示“未绑定变量”。这时,你就应该去查阅正式文档了。

此外,还有一个技巧可以列出某个结构中的所有绑定。虽然其原理将在学习模块系统后更清晰,但现在你可以将其作为一个实用工具。

代码示例:

(* 列出 List 结构中的所有内容 *)
structure X = List;
signature SIG = sig end;

执行上述代码后,REPL 会打印出 List 结构中所有函数和值的列表。请注意,这不会提供详细的语义文档,但可以作为快速参考。

总结

本节课中我们一起学习了如何查找和使用 ML 标准库。我们了解了标准库的两大作用,掌握了通过在线文档和 REPL 工具来探索库函数的方法。记住,熟练查阅文档是独立编程的关键技能,能帮助你在不依赖他人指导的情况下,有效地利用语言提供的强大工具来完成作业三及其他编程任务。

070:使用闭包实现可选抽象数据类型 🧩

在本节可选课程中,我们将学习一个更高级的闭包用法。这个用法结合了之前学过的多个高级特性,但没有引入新的语言结构。我们将实现一个抽象数据类型,其使用体验与面向对象编程中的对象非常相似。

具体来说,我们将实现一个整数集合,以保持示例的简洁性。集合将由一个记录表示,该记录的字段全部是函数。使用我们抽象数据类型的客户端只能调用这些函数。由于这些函数是闭包,它们可以访问私有数据。我们将进行设置,使记录中的所有函数都能访问相同的私有数据,这样它们就能协同工作,实现包含多个操作(如向集合插入元素、检查元素是否在集合中)的抽象。

我们可以选择将私有数据设为可变或不可变。实际上,如果设为可变会更简单一些,但为了鼓励更函数式的编程风格,我们将采用不可变的方式。因此,向整数集合中插入元素不会改变原集合,而是返回一个包含新元素的新集合(如果该元素原本不在集合中)。

这个示例旨在让你初步感受到面向对象编程与函数式编程之间深刻的相似性。即使你不熟悉面向对象编程,这仍然是一个很好的闭包示例。虽然实现有些复杂和巧妙,但它很好地结合了我们学过的词法作用域、数据类型、记录和闭包。一旦你学会如何使用,从客户端使用这个抽象并不困难。

类型定义与客户端示例

首先,我们需要定义一个类型。我们希望定义一个类型同义词,表示集合是一个记录类型,其字段是函数。例如,insert 字段应持有类型为 int -> set 的闭包(给定一个整数,返回一个新的可能不同的集合),member 字段的类型应为 int -> bool(检查整数是否在集合中),size 字段的类型应为 unit -> int(返回集合中的元素数量)。

然而,ML 中的类型同义词不能递归定义,因此直接使用 set 在定义中引用自身是行不通的。为此,我们使用数据类型的绑定。我们定义一个只有一个构造器的数据类型,目的就是为了能在 set 的定义中提及 set 本身,就像在列表定义中提及 list 一样。

以下是类型定义和客户端使用示例:

(* 数据类型定义 *)
datatype set = S of { insert : int -> set,
                      member : int -> bool,
                      size   : unit -> int }

(* 客户端使用示例 *)
fun client () =
    let
        val S s1 = emptySet
        val S s2 = (#insert s1) 34
        val S s3 = (#insert s2) 34  (* 不会重复添加 *)
        val S s4 = (#insert s3) 19
    in
        if (#member s4) 42 then 99
        else if (#member s4) 19 then 17 + (#size s3) ()
        else 0
    end

在上面的客户端代码中,我们从一个空集合 emptySet 开始。通过模式匹配去掉构造器 S 后,我们得到一个记录 s1,其中包含三个函数字段。我们可以调用 #insert s1 并传入 34 来获得一个新集合 s2,依此类推。使用 membersize 函数的方式也类似。这个示例展示了客户端如何在不了解内部实现的情况下,仅通过提供的函数来操作集合。

实现抽象数据类型

现在,让我们看看如何实现这个抽象数据类型,重点是实现 emptySet 和内部的 makeSet 辅助函数。

实现的核心是 makeSet 函数,它接收一个没有重复元素的整数列表 xs(这是一个内部维护的不变量),并返回一个包装在 S 构造器中的记录。该记录的三个字段都是闭包,它们可以访问并操作私有数据 xs

以下是具体的实现代码:

(* 空集合的定义和内部实现 *)
val emptySet =
    let
        fun makeSet xs =
            let
                fun contains i = List.exists (fn j => i = j) xs
            in
                S {
                    insert = fn i =>
                               if contains i
                               then makeSet xs          (* 元素已存在,返回原集合 *)
                               else makeSet (i::xs),    (* 添加新元素 *)
                    member = contains,                  (* 检查成员 *)
                    size   = fn () => length xs         (* 返回大小 *)
                }
            end
    in
        makeSet []  (* 空集合由空列表生成 *)
    end

我们来分解一下 makeSet 的实现:

  • size 字段:这是一个匿名函数,返回列表 xs 的长度。它直接使用了私有数据 xs
  • member 字段:我们使用了一个内部辅助函数 containscontains 函数检查给定的整数 i 是否存在于列表 xs 中,它利用了 List.exists 这个库函数。member 字段直接绑定到这个 contains 函数。
  • insert 字段:这是最有趣的部分。它也是一个匿名函数,接收一个整数 i。首先,它使用 contains 检查 i 是否已在 xs 中。如果存在,则通过递归调用 makeSet xs 返回一个表示相同集合的新记录(虽然内容相同,但是一个新创建的值)。如果 i 不存在,则递归调用 makeSet (i::xs),创建一个包含新元素的新集合。这正是函数式编程中“不可变”的体现——修改操作返回一个新的值。

整个结构通过 makeSet 的递归调用维系。emptySet 就是通过调用 makeSet [] 生成的。当客户端调用 insert 时,会触发新的 makeSet 调用,从而生成一个包含新私有列表(可能增加了新元素)的新记录闭包。

总结

在本节课中,我们一起学习了一个使用闭包实现抽象数据类型的高级技巧。我们实现了一个不可变的整数集合,其外部接口是一个包含三个函数(insertmembersize)的记录。关键在于,这些函数都是闭包,它们共同访问并操作同一个私有数据(一个无重复的整数列表)。通过递归函数 makeSet,我们能够创建从空集开始、经过一系列插入操作后得到的任何集合。

这个示例巧妙地融合了词法作用域、数据类型(用于支持递归类型定义)、记录和闭包这几个核心概念。它展示了函数式编程如何封装数据和行为,从而实现了与面向对象编程中对象类似的抽象机制。虽然内部实现需要仔细设计以保证正确性,但客户端代码却可以非常直观和简洁地使用这个抽象。

071:无闭包环境下的闭包惯用法移植 🧩

在本节课中,我们将学习如何将依赖闭包的高阶函数编程惯用法(如 mapfilter),移植到本身不支持闭包特性的编程语言中。我们将通过对比 ML、Java 和 C 语言的实现,来理解不同编程范式之间的联系与差异。

接下来的几个小节是可选的,它们相互关联,核心内容是探讨如何将我们熟悉的闭包惯用法,移植到那些实际上没有闭包特性的语言中。

高阶函数编程(例如使用 mapfilter)非常强大。当你的语言支持闭包时,这种方式尤其简单,因为你可以轻松创建带有私有数据的函数并传递它们,函数类型正好符合我们的需求。但是,如果你的语言没有闭包,你仍然可以用这种风格编程,只是过程通常会繁琐得多。你通常需要手动创建概念上的“闭包环境”。

通过展示如何在您可能熟悉的语言中实现这一点,我们可以看到不同编程风格之间的一些联系。其中一个小节将使用面向对象的风格在 Java 中实现,其关键思想是使用只有一个方法的接口,这类似于闭包中的“可调用代码”部分。另一个小节则在 C 语言中实现,C 语言有函数指针但没有闭包,我们需要将环境作为额外的参数显式传递。

这些是可选的章节,因为你需要对 Java 有一定了解才能理解 Java 部分,对 C 语言有一定了解才能理解 C 部分。即使你懂一些 Java 或 C,也可能觉得这部分内容有些深奥,这没关系。我提供这些内容,是希望将来如果你在使用这些语言并希望以函数式风格编程时,可以回头参考这些章节,它们或许能为你指明正确的方向。

这展示了不同语言和特性之间的联系,有助于你理解闭包和对象的本质。最终,这些实现方式会显得有些笨拙,这可能会让你更希望有更多的编程语言真正支持闭包,这样我们就不必去模拟它们,也不必用更繁琐的方式编码相同的惯用法。

本节将展示我们随后要移植到 Java 或 C 的 ML 代码,这通常被称为“移植”。它只是一个小型的列表库。我不会使用 ML 内置的列表,因为我想展示所有代码,并且希望提供一个更全面的比较,因为我们将在所有三种语言中自己编写所有内容。接下来的章节是独立的,你可以观看其中一个、两个、或者都不看,它们将展示如何在其他语言中编写相同的代码。

以下是 ML 代码的实现:

(* 定义自定义多态列表类型 *)
datatype 'a mylist = Cons of 'a * 'a mylist | Empty

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/uwash-cse341-prog-abc/img/aabf3798d6aa4ae8384bcbbd84eb0f47_1.png)

(* map 函数 *)
fun map f lst =
    case lst of
        Empty => Empty
      | Cons(x, xs) => Cons(f x, map f xs)

(* filter 函数 *)
fun filter f lst =
    case lst of
        Empty => Empty
      | Cons(x, xs) => if f x
                       then Cons(x, filter f xs)
                       else filter f xs

(* length 函数 *)
fun length lst =
    case lst of
        Empty => 0
      | Cons(_, xs) => 1 + length xs

然后是两个使用这些函数定义更具体功能的客户端代码示例:

(* 示例1:将整数列表中的所有元素加倍 *)
val doubleAll = map (fn x => x * 2)

(* 示例2:计算列表中等于 n 的元素个数 *)
fun countNs lst n = length (filter (fn x => x = n) lst)

第一个函数 doubleAll 接收一个整数列表,并将所有元素加倍。我们通过部分应用 map 函数并传入一个匿名函数 (fn x => x * 2) 来实现。

第二个函数 countNs 接收一个列表和一个整数 n,返回列表中等于 n 的元素个数。一种实现方式是先使用 filter 函数筛选出所有等于 n 的元素,然后计算这个结果列表的长度。虽然这不是计算此结果最高效的方式,但它是可行的。

在接下来的章节中,我们将用 Java 和 C 语言以这种风格编写相同的代码。

本节课中,我们一起学习了在没有原生闭包支持的语言中模拟函数式编程惯用法的基本思路。我们首先在 ML 中定义了一个自定义列表类型以及 mapfilterlength 等核心高阶函数,并看到了它们简洁的实现。然后,我们探讨了将这些概念移植到 Java(通过单方法接口模拟函数对象)和 C(通过显式传递环境参数给函数指针)的总体策略。理解这些不同实现方式之间的对比,有助于我们更深刻地认识到闭包这一语言特性的价值与便利性。

072:在Java中模拟闭包 🧙‍♂️

在本节可选课程中,我们将学习如何在像Java这样的语言中使用对象来模拟闭包。虽然这种方法不够优雅,但我们将能够成功地将上一节中的ML代码移植到Java中。本节的核心是理解如何利用Java的类和对象机制,编写出类似于ML风格的函数式代码。

概述:模拟闭包的核心思想

上一节我们介绍了ML中的闭包概念。本节中我们来看看,在没有原生闭包支持的Java中,如何通过对象来模拟这一功能。

核心技巧是定义只有一个方法的接口。因为一个函数很像一个只有一个方法的对象,你只需要调用这个方法即可。

以下是两个核心接口的定义:

interface Fun<A, B> {
    B m(A a);
}

interface Pred<A> {
    boolean m(A a);
}
  • Fun<A, B> 接口模拟一个从类型 A 到类型 B 的函数。
  • Pred<A> 接口模拟一个判断类型 A 的值是否满足条件的谓词函数。

我们将通过创建实现了这些接口的对象来模拟闭包,并在对象中使用字段来保存闭包所需的环境数据。

构建链表库 📚

有了上述背景,我们现在开始构建我们的链表库。我们将从一个基本的面向对象风格的链表定义开始。

class List<T> {
    T head;
    List<T> tail;
    List(T head, List<T> tail) {
        this.head = head;
        this.tail = tail;
    }
}

这里我们做了一个稍后会带来麻烦的选择:使用 null 来表示空链表。这是Java中一种常见的做法,但空链表将不是一个对象,也没有对应的类。

实现 map、filter 和 length 函数

现在,我们来实现ML库中的三个核心函数:mapfilterlength。由于我们使用了 null 表示空链表,我将它们实现为静态方法。

实现 map 函数

map 函数接收一个链表和一个函数,将函数应用到链表的每个元素上,并返回一个新链表。

static <A, B> List<B> map(List<A> xs, Fun<A, B> f) {
    if (xs == null) {
        return null;
    }
    return new List<B>(f.m(xs.head), map(xs.tail, f));
}

这段代码几乎和ML版本一样:如果链表为空(null),则返回空链表;否则,构建一个新链表,其头节点是函数 f 应用于原链表头节点的结果,尾节点是递归调用 map 处理原链表剩余部分的结果。

实现 filter 函数

filter 函数接收一个链表和一个谓词,返回一个只包含满足谓词条件的元素的新链表。

static <A> List<A> filter(List<A> xs, Pred<A> p) {
    if (xs == null) {
        return null;
    }
    if (p.m(xs.head)) {
        return new List<A>(xs.head, filter(xs.tail, p));
    } else {
        return filter(xs.tail, p);
    }
}

逻辑与ML代码一致:检查当前头元素是否满足谓词 p,满足则将其包含在结果中,否则跳过。

实现 length 函数

length 函数计算链表的长度。这里我选择使用循环而非递归来实现,因为循环版本在这种情况下更简洁。

static <A> int length(List<A> xs) {
    int ans = 0;
    while (xs != null) {
        ans++;
        xs = xs.tail;
    }
    return ans;
}

关于方法设计的讨论 🤔

你可能会问,为什么将这些函数设计为静态方法,而不是链表类的实例方法(如 xs.map(f))?

原因与我们使用 null 表示空链表有关。如果 xsnull,调用 xs.map(f) 会抛出空指针异常。这迫使每个客户端代码在使用前都必须检查 null。而我们的静态方法 List.map(xs, f) 在内部处理了 null 的情况,对客户端更友好。

更纯粹的面向对象设计应该避免使用 null,而是为链表定义两个子类:一个表示空链表,一个表示非空链表。这样,实例方法就能正常工作,空链表子类可以简单地实现 mapfilter(返回新的空链表)以及 length(返回0)。这实际上是更好的设计。

客户端代码示例 👨‍💻

最后,让我们看看如何使用这个库来编写客户端代码,就像我们在ML中做的那样。以下是两个例子:一个将所有元素翻倍,另一个计算链表中等于某个值 n 的元素个数。

// 1. 将所有元素翻倍
static List<Integer> doubleAll(List<Integer> xs) {
    return List.map(xs, new Fun<Integer, Integer>() {
        public Integer m(Integer x) {
            return x * 2;
        }
    });
}

// 2. 计算链表中等于 n 的元素个数
static int countNs(List<Integer> xs, final int n) {
    return List.length(List.filter(xs, new Pred<Integer>() {
        public boolean m(Integer x) {
            return x.equals(n);
        }
    }));
}
  • doubleAll 中,我们创建了一个实现了 Fun<Integer, Integer> 接口的匿名内部类对象。它的 m 方法接收一个整数 x 并返回 x * 2。这个对象被传递给 map 函数。
  • countNs 中,我们创建了一个实现了 Pred<Integer> 接口的匿名内部类对象。它的 m 方法检查输入 x 是否等于外部的 n。注意,为了在内部类中访问局部变量 n,必须将其声明为 final

总结 🎯

本节课中我们一起学习了在Java中模拟闭包的技术。我们了解到,通过定义单方法接口(如 Fun<A, B>Pred<A>)并创建实现这些接口的对象,可以模拟函数的行为。这些对象利用字段来保存环境数据,从而实现了闭包的功能。

我们构建了一个简单的链表库,实现了 mapfilterlength 函数,并讨论了由于使用 null 表示空链表而采用静态方法设计的原因。最后,我们通过客户端代码示例,展示了如何使用匿名内部类来创建这些“函数对象”,并完成具体的计算任务。

虽然这种模拟方式代码略显冗长,但它清晰地展示了函数式编程思想在面向对象语言中的一种实现路径。随着Java 8及更高版本引入了Lambda表达式和Stream API,这种模式现在可以写得更加简洁。

073:在没有闭包的情况下使用 C 语言

在本节课中,我们将学习如何将 ML 语言中的列表库移植到 C 语言中。C 语言本身不支持对象或闭包,因此我们需要采用一些高级的 C 语言技巧来实现类似的功能。我们将重点介绍如何使用函数指针和额外的参数来模拟闭包的行为,并处理 C 语言中缺乏泛型的问题。

移植列表库到 C 语言

上一节我们介绍了课程的目标。本节中,我们来看看如何在 C 语言中定义基本的列表类型和辅助函数。

首先,我们定义一个通用的列表类型。由于 C 语言没有泛型,我们使用 void* 类型来存储列表元素。

typedef struct list {
    void* head;
    struct list* tail;
} list;

list* cons(void* head, list* tail) {
    list* ans = (list*)malloc(sizeof(list));
    ans->head = head;
    ans->tail = tail;
    return ans;
}

在这个定义中,list 结构体包含一个指向任意类型数据的 head 指针和一个指向下一个列表节点的 tail 指针。cons 函数用于构造新的列表节点,它分配内存并初始化字段。我们使用 NULL 来表示空列表。

实现 Map、Filter 和 Length 函数

现在我们已经定义了列表结构,接下来需要实现 mapfilterlength 这三个核心函数。由于 C 语言没有闭包,我们需要调整函数签名。

以下是 map 函数的实现。关键点在于,传递给 map 的函数指针需要接收一个额外的“环境”参数。

list* map(void* (*f)(void*, void*), void* env, list* x) {
    if (x == NULL) {
        return NULL;
    } else {
        return cons(f(env, x->head), map(f, env, x->tail));
    }
}

map 函数接收一个函数指针 f、一个环境参数 env 和一个列表 x。函数 f 的类型是 void* (*)(void*, void*),这意味着它接受两个 void* 参数(环境和列表元素)并返回一个 void*。递归地,map 对列表的每个元素应用函数 f,并使用 cons 构建新列表。

filter 函数的实现遵循类似的模式,但函数指针返回一个布尔值(或整型)来决定是否保留元素。

list* filter(int (*f)(void*, void*), void* env, list* x) {
    if (x == NULL) {
        return NULL;
    } else if (f(env, x->head)) {
        return cons(x->head, filter(f, env, x->tail));
    } else {
        return filter(f, env, x->tail);
    }
}

filter 函数接收一个谓词函数 f、环境 env 和列表 x。如果 f 对当前元素返回真(非零),则该元素被包含在新列表中。

对于 length 函数,由于不需要函数指针,我们可以使用更传统的迭代方法。

int length(list* x) {
    int ans = 0;
    while (x != NULL) {
        ans++;
        x = x->tail;
    }
    return ans;
}

这个函数使用一个 while 循环遍历列表,计数节点数量。

函数指针与额外参数的重要性

上一节我们实现了库函数。本节中,我们来深入理解为什么函数指针需要额外参数,以及这在 C 语言编程中为何是一种通用模式。

在 C 语言中,函数指针本身只包含代码,不包含定义时的环境(即闭包的数据部分)。因此,为了模拟闭包,我们必须手动传递环境数据。通用的做法是让所有作为参数传递的函数都接受一个额外的 void* 参数,用于接收这个环境。调用方(如 mapfilter)负责在每次调用时将这个环境传递回去。

这种做法虽然需要大量的类型转换,但它是实现可复用回调函数和模拟高阶函数功能的基石。即使在某些不需要环境数据的简单情况下(例如一个简单的加倍函数),我们也应该遵循这个模式以保持接口一致。

客户端代码示例

理解了库的实现原理后,现在我们来看看如何使用这个库。客户端代码需要处理大量的类型转换。

首先是一个使用 map 函数将列表中所有整数加倍的例子。

void* double_int(void* ignore, void* i) {
    // 将 void* 转换为 int*,解引用,计算,再转换回 void*
    int val = *((int*)i);
    int* ans = (int*)malloc(sizeof(int));
    *ans = val * 2;
    return (void*)ans;
}

list* double_all(list* lst) {
    // 调用 map,对于不需要环境的函数,环境参数传递 NULL
    return map(double_int, NULL, lst);
}

double_int 函数忽略其环境参数 ignore。它首先将元素指针 i 转换为 int* 以获取整数值,计算加倍后的值,分配新内存存储结果,最后将结果指针转换回 void* 返回。

接下来是一个更复杂的例子,它使用 filterlength 来计算列表中等于某个值 n 的元素数量,这模拟了 ML 中需要从闭包环境捕获 n 的情况。

int is_n(void* env, void* i) {
    // 将环境(要查找的值 n)和列表元素都转换为 int 进行比较
    int n = *((int*)env);
    int val = *((int*)i);
    return n == val;
}

int count_n(int n, list* lst) {
    // 为环境参数分配内存并存储 n 的值
    int* env = (int*)malloc(sizeof(int));
    *env = n;
    // 调用 filter,传递环境指针
    list* filtered = filter(is_n, (void*)env, lst);
    int ans = length(filtered);
    // 释放环境内存
    free(env);
    // 注意:这里简化了,实际还需要释放 filtered 列表占用的内存
    return ans;
}

count_n 函数中,我们动态分配了一个整数内存来存储要查找的值 n,并将其作为环境指针传递给 filter。在 is_n 函数内部,我们将环境和列表元素指针都转换回 int* 进行比较。使用完毕后,需要记得释放为环境分配的内存。

总结与最佳实践

本节课中我们一起学习了如何在缺乏闭包支持的 C 语言中模拟高阶函数的功能。

我们实现了一个简单的通用列表库,包含 mapfilterlength 函数。核心技巧是让所有作为参数传递的函数指针都接受一个额外的 void* 类型环境参数,并由调用方负责传递该环境。这允许函数访问其所需的“私有”数据,从而模拟了闭包的行为。同时,由于 C 语言没有泛型,我们不得不大量使用 void* 类型和显式的类型转换。

这种模式在 C 语言的回调接口设计中非常常见。记住以下最佳实践:

  • 设计库时:如果您的接口接受函数指针作为回调,请总是为其添加一个 void* 环境参数。
  • 使用库时:如果看到回调函数有 void* 环境参数,请利用它来传递您回调函数所需的数据。
  • 类型安全:频繁的 void* 转换会绕过编译器的类型检查,因此需要格外小心以确保类型的正确性。

虽然这个过程有些繁琐,但它清晰地展示了拥有自动管理闭包的语言(如 ML、Java、C# 等)所带来的便利性。通过手动管理环境和类型,我们在 C 语言中实现了类似的功能。

074:课程动机与介绍

在本节中,我们将探讨本课程的设计动机。我们将了解为何要学习跨越所有编程语言的基本概念,为何使用与主流语言不同的语言,以及为何特别关注函数式编程。最后,我们会解释选择ML、Racket和Ruby这三种语言作为教学工具的原因。

课程动机概述

本节内容为可选部分,旨在深入阐述本课程的设计理念和学习目标。与通常在课程开始时介绍动机不同,我认为在学习了几周课程内容后,再来讨论课程的设计思路会更有意义,也更容易理解。因此,我在课程开始时仅做了简单预告,现在则可以更深入地探讨。

你甚至可以在整个课程结束后再次观看本节内容,以获得更深刻的理解。

本节所有内容均为可选,不会进行测试。但我希望它能帮助你理解学习本课程材料的目的。

在接下来的视频中,我将重点探讨四个问题,以充分阐明课程动机:

  1. 为何要学习那些跨越所有编程语言的基本概念?
  2. 为何要使用与Java、Python、C或C++等主流语言差异较大的语言?
  3. 为何在“编程语言”这门课程中要特别强调函数式编程?
  4. 为何选择ML、Racket和Ruby作为教学语言?

在开始本节之前,我需要明确几点:我将主要阐述我个人的观点,并以本课程学生能理解的方式进行说明。与课程大部分内容不同,这里我会允许自己表达更多主观看法,但这并不意味着我的观点绝对正确。

我的阐述将是不完整的列表,不会涵盖学习编程语言的所有可能原因。这并不意味着其他原因不重要。

我将以本课程学生能够理解和欣赏的方式进行解释。如果你已经是编程语言专家或研究者,可能会有不同的讨论角度,但我会确保所有观看者都能跟上节奏。

需要指出的是,在本节中,我绝不会说一种语言比另一种语言更好。如果我遗漏了你最喜欢的语言,我提前表示歉意。我只是试图用一些语言来描绘编程语言的现状,并展示从不同视角学习能带来的收获。我特意避免暗示提到的语言就是“好”语言的代表,未提及的语言则不然,我绝无此意。

如果你没有时间观看所有视频,以下是本节的核心要点,我将在后续内容中逐一论证:

  1. 不存在“最好”的编程语言,将来也不会有。不同的编程语言适合不同的视角和任务。从深层的理论层面看,几乎所有编程语言都是等价的——你能用一种语言做的事,用另一种语言也能做到。
  2. 基本概念的教学效果因语言而异。某些概念在一种语言中比在另一种语言中更容易教授。要理解一个概念的普遍性,最好的方法莫过于在多种编程语言中看到它的身影,这正是我们在课程中使用三种语言而非一种的原因。
  3. 编程语言本身就是一个接口。你可以将整个语言的定义和语义本身视为一个接口。要使用一个编程语言,就像使用任何接口一样,精确理解其含义是无可替代的。这一点至关重要:你必须了解语言结构,才能知道如何正确使用它们。
  4. 函数式语言处于编程语言发展的前沿。数十年来,函数式语言的思想一直引领着编程语言的进步。这些思想虽然需要很长时间才能渗透到更流行、更广为人知的主流语言中,但历史证明它们确实做到了。我们所强调的一等函数避免数据可变性这两个概念,在软件系统日益复杂和并行化的趋势下,正变得越来越重要。
  5. 多视角学习提升编程能力。即使我所展示的一些构造和思维方式在你使用的C、Java或PHP中并非最佳支持,但学会从不同方向、不同视角审视你的软件,将使你在使用任何语言时都成为更优秀的程序员。
  6. ML、Racket和Ruby的选择具有互补性。当然存在许多优秀的替代语言,我并非说这三种就是最好的。我选择它们各有原因:每种语言都有我想用来呈现课程内容的独特之处;同时,这三者在一起形成了很好的互补——它们在处理某些事情时方式相同,而在处理另一些事情时则截然相反,这非常有利于观察和理解其中的差异。

接下来,我们将进入更多细节,逐一论证上述观点。希望在此之后,大家能对本课程的动机有清晰的认识。

在本节课中,我们一起学习了本课程的设计动机。我们探讨了学习通用编程概念的重要性,理解了使用多样化语言和函数式范式的价值,并了解了选择ML、Racket和Ruby这三种特定语言的原因。核心在于认识到,掌握多种编程范式和视角,能让我们成为更全面、适应性更强的程序员。

075:为何学习通用编程语言概念

在本节课中,我们将探讨学习通用编程语言概念的价值,而不局限于任何特定的编程语言或细节。我们将通过类比和核心概念分析,理解这种抽象学习如何提升我们对编程本质的认识。

概述

学习编程语言,不仅仅是掌握一种工具的语法。更重要的是理解其背后的通用概念、语义和设计思想。这能让我们在面对不同编程任务时,做出更明智的工具选择,并编写出更优雅、更健壮的代码。

核心价值:类比汽车与鞋子

上一节我们概述了学习通用概念的重要性,本节中我们来看看一个生动的类比。

编程语言在某种意义上就像汽车或鞋子。人们常问我最喜欢的编程语言是什么,我的回答是:你最喜欢哪种汽车或哪双鞋子?

你可以花一秒钟想象一下世界上最好的汽车。无论你想到什么,它可能确实是一辆好车,比许多其他车都好。但它不可能在所有用途上都是最好的。汽车用于许多不同的事情:赢得时速300公里的比赛,或者载一群孩子去踢足球。任何一辆车都能参加比赛,也能载人去目的地,但很难有一辆车能同时把这两件事都做得非常出色

设计总是在不同方面有所取舍。专注于某些设计方面,就会牺牲其他方面。鞋子也是如此。有人喜欢搭配正装的精致皮鞋,有人喜欢运动鞋。我可以用我最精致的皮鞋打篮球,但这会很痛苦。

这个类比很清晰:编程语言就像一种鞋子或一种汽车。一旦你学会了通用的驾驶技能,换车并不难。但你还是会根据当天的具体任务,选择最合适的工具。

深入类比:从使用者到设计者

理解了通用概念对使用者的价值后,我们进一步看看它对“机械师”和“设计者”的意义。

对于修理汽车的机械师来说,专攻某个品牌或年代的汽车是合理的。程序员也可以更擅长某些语言。但机械师通常对汽车的工作原理有普遍的理解,即使面对非其专长的汽车,也能提供帮助。他们很清楚什么重要,什么不重要。我从未遇到过机械师因为我的汽车座椅是蓝色(而他只喜欢棕色座椅)就拒绝修理发动机异响。

这里的类比是:那些非本质的细节就是语法。语法对人们确实有影响,就像买车时人们真的喜欢某些颜色或后视镜款式。但这对于汽车如何运行、如何实现其目标来说,并非本质。

那么,对于真正设计汽车的人(无论是机械工程师还是其他人)呢?想要改进或理解“汽车”本质的人,必须懂得如何在不同设计约束中取得平衡。如果你试图让某个特性更好,可能就会让另一个特性更难做到同样好。某些特性是相互冲突的。

这就是为什么我没有最喜欢的编程语言,也没有最喜欢的汽车。我觉得这个问题有点傻。这完全取决于我想要做什么。

学习方法:从简单模型入手

既然理解了学习通用概念的价值,我们来看看如何有效地学习它们。

如果你想了解更多关于汽车如何运行、如何修理、如何设计得更好的知识,一种方法是研究许多真正优秀、昂贵、先进的汽车,分析它们为何如此出色,人们为何愿意花大价钱购买。

但我认为这常常适得其反。这些汽车非常复杂,是超过100年工程和改进的结果。有时,最好的学习方式是回到一个更简单、更古老的模型。比如ML语言,它已有超过25年的历史。因为它更简单,没有处理所有现代的高级计算机特性,所以更容易理解。

理解一辆老车更容易,你可以打开引擎盖看到所有部件。在掌握了这些基础知识之后,再去理解现代最先进的技术、更前沿的东西,实际上会更容易。在某些方面,你甚至能理解现代设计在某些方面其实不那么优雅,因为随着时间的推移,由于历史性的改进,事情变得更加复杂。

此外,我们知道有时汽车非常流行,即使它们不是最好的汽车。流行是一个很难理解的现象。这并不意味着流行的东西不好,但我们知道流行的东西不一定就是好的。流行本身并不是“最好”的证据。

课程焦点:语义与惯用法

在介绍了学习方法后,本课程将聚焦于编程语言的两个关键部分。

在本课程中,我们不仅仅学习编程语言。我们聚焦于它们的两个关键部分:语义惯用法

  • 语义:编程语言结构的意义。
  • 惯用法:使用这些语言结构以优雅方式执行常见任务的方法。

我聚焦于此的原因首先是语义。如果你想正确推理程序的行为、判断语言实现是否正确、或者你写的库有人抱怨无法正常工作(到底是你错了还是他们错了),你必须理解语言语义。在软件开发中,没有“我觉得条件表达式应该那样工作”的余地。不,编程语言中的条件表达式有它自己的工作方式,你要么正确使用它,要么没有。

这不是关于你喜欢花括号还是圆括号,或者用“and also”还是连续两个“&”字符的问题。这真正关乎概念的含义。软件开发的很大一部分是设计精确的接口然后使用它,而编程语言可能是这方面最好的例子,其定义必须如此精确,以至于全世界的人都能使用这门语言,并对预期发生什么达成一致。

至于惯用法,我认为它们能让你成为更好的程序员。如果你在多种环境中(包括那些使用起来非常方便的语言中)看到某种模式,那么你就能在任何地方使用它。一旦你理解了数据类型绑定和case表达式,它就会教你用“多选一”类型和所有不同可能性来思考。即使你的语言不直接支持以那种方式编写算法,这仍然是一种极好的思考方式。你会发现你的代码突然布局得更加优美,恰好覆盖了你需要处理的所有情况。

这就是为什么我常说,很多上这门课的学生以前都学过Java,但我们在本课程中几乎不涉及Java。然而,我坚信这门课程会让你成为更好的Java程序员。

终极类比:哈姆雷特与深层真理

最后,在谈论编程语言的美与通用性以及为何学习它有用时,让我谈谈威廉·莎士比亚的戏剧《哈姆雷特》。

如果你从未看过《哈姆雷特》,我强烈推荐。它是一件美丽的艺术品。它教导关于人类境况的深刻、永恒的真理:家庭关系、复仇、嫉妒、谋杀,以及让情绪支配我们。即使在现代,它也是许多我们用来理解生活的表达和说法的源泉。

如果你曾听人说“生活中最重要的事是对自己真诚”,在《哈姆雷特》中,原句实际上是(如果我没记错的话):“最重要的是:你必须对自己忠实”。我们研究这些东西,是因为它让我们成为更好的人。

编程语言概念也是如此。这里确实存在着深刻、永恒的真理。例如,布尔值和条件表达式不过是带有两个构造器(称为true和false)的数据类型绑定的语法糖,这是一个关于逻辑、事物如何组合以及我们如何表达两种可能性(是/否,真/假)的深刻真理。值得我们在尽可能纯粹的环境中研究这些东西,并在多种环境中观察它们,就像《哈姆雷特》的教训在电影、戏剧和小说中反复出现一样。

我们应该学习这些东西,即使语法真的很烦人。我不太喜欢读莎士比亚,因为它很难懂,而英语是我的母语。但如果我能超越语法,理解其中发生的深刻真理,它就能让我成为更好的人。我不必太担心学习这个(无论是增进我对人类境况理解的东西,还是对编程语言软件理解的东西)之后,三周内是否能申请到新工作。那会来的,我以后能掌握那些技能。但与此同时,我可以打下正确的基础,获得审视整个领域的正确视角。

总结

本节课中我们一起学习了为何要超越具体语法,去研究通用的编程语言概念。我们通过汽车和鞋子的类比,理解了工具选择取决于任务。我们探讨了从简单模型(如ML语言)入手的学习方法,明确了本课程将聚焦于语义(语言结构的含义)和惯用法(优雅解决问题的模式)这两个核心。最后,我们借由《哈姆雷特》的类比,认识到学习这些通用概念是为了掌握软件设计中深刻、永恒的真理,从而为整个编程生涯奠定坚实而正确的思想基础。

076:所有编程语言都一样吗?🚗💻

在本节课中,我们将探讨一个核心问题:所有编程语言本质上是否相同?我们将从类比入手,逐步深入到计算机科学的技术层面,分析编程语言的共性与差异,并理解学习多种语言的价值。

概述

本节课程旨在分析编程语言的相似性与独特性。我们将首先通过一个汽车类比来直观理解,然后从理论(丘奇-图灵论题)和实践(语言特性与设计)两个层面进行探讨。最后,我们会总结为何在承认语言普遍能力的同时,仍需重视其多样性。

从汽车类比谈起 🚗

上一节我们引入了编程语言的基本概念,本节中我们来看看一个生动的类比。

当你去租一辆车或开朋友从未驾驶过的汽车时,如果你会开车,你基本上能操作它。这是因为全球在方向盘功能、刹车位置、车窗开关方式以及车头灯设置等方面存在某种标准化。这种标准化是有益的,它使得即使不熟悉所有细节,阅读另一种语言的代码也变得更加容易。

然而,这种标准化也可能带来麻烦。如果某些东西位置不对,或者你不真正理解某个特定语言结构(或汽车功能)的工作原理,就会感到非常不适。随着你学习标准通用概念并获得驾驶不同汽车(或使用不同编程语言)的经验,你的舒适度会逐渐提高。

编程语言之间的差异可能比汽车之间的差异更大。也许它们更像汽车、卡车或船只的区别。虽然不确定如何精确衡量,但这个类比相当贴切。

你还可以论证,标准化的所有好处实际上可能阻碍进步。如果有人设计汽车的方式好得多,但为了实现它,油门和刹车踏板必须放在人们不习惯的位置,那么这个想法将很难被采纳,因为人们使用那辆车会非常困难,并认为它非常危险。

理论层面的同一性:丘奇-图灵论题 💡

现在,让我们进入更计算机科学和技术性的内容。如果你要学习编程语言,这一点你真的应该知道。

在非常技术性的层面上,所有编程语言在以下意义上实际上是相同的:任何你能用语言X编写的程序,你也能用语言Y编写。这里“程序”指的是,给定一些参数,它返回什么结果?如果它接收一些输入并返回输出,并且有办法在Java中实现它,那么在ML、Python、PHP中也都有办法实现它。事实上,甚至有一种编程语言,其中你只有一个while循环和三个可以存储无限大数字的变量,也能实现它。

这是一个事实。这本质上就是著名的丘奇-图灵论题。我们发现这是正确的:对于每一种我们认为具有足够能力的编程语言(你需要学习另一门课程来精确研究这种能力是什么,这里不深入探讨),都存在从一种语言的任何程序到另一种语言程序的翻译。

因此,从这个意义上说,所有编程语言都是同等强大的。

实践层面的相似性:共享的核心概念 🔧

从另一个意义上说,在实践中,我们在世界各地实际用于开发软件的所有语言都具有相同的基础。

以下是它们共享的一些核心概念:

  • 变量:都有某种变量的概念。
  • 封装:都有某种方式将代码的一部分对其他部分隐藏起来。
  • 类型:都有某种类型的观念。在课程后面,我们将看到面向对象编程如何处理类型。
  • 递归:都支持某种形式的递归定义。

所以,所有语言都是相似的,它们只是以不同的方式组合这些相同的特性。

强调差异性的理由:文化的多样性 🌍

现在,让我从另一个角度论证:仅仅因为所有语言都有这些相似性,具有相同的表达能力,并为许多相同的概念而构建,并不意味着它们完全相同。

我喜欢做的类比是:我相信在很多层面上,人就是人。无论你生活在哪个国家,说什么语言,处于什么社会,总有一些事情让我们快乐、悲伤,在智力方面总有一些事情对我们来说困难或容易。有很多相似之处。

然而,没有人会否认世界各地存在的巨大文化差异。这就是为什么我喜欢旅行,为什么我喜欢有来自世界各地的朋友,因为这些差异也令人兴奋。

事实上,去世界另一个地方旅行或学习另一种语言的最佳理由之一,是因为你能更欣赏你来自哪里,以及你的语言是如何运作的。编程语言也是如此。

实践中的差异:便利性与“表达方式陷阱” ⚠️

在软件方面,编程语言中经常出现的情况是:一种语言中的原语或默认功能在另一种语言中也能实现,但会非常笨拙。

例如,在另一种语言中可以实现类似case表达式的编程,但如果没有对case表达式的原生支持,代码会冗长得多,你必须绕很多弯子,添加额外的变量。

反之,如果你想在ML中实现类似对象的功能,坦白说,体验并不愉快。需要做大量额外工作,必须正确处理许多细节,而且得不到方便的错误信息,因为编译器不理解你正在使用的惯用法。

这没关系。通常,理解一种语言结构的最佳方式,就是理解如何在另一种语言中用其他语言结构来编码实现它。我们将在本课程中看到这样的例子。

幻灯片上的最后一行是:谨防“图灵焦油坑”。这是一个著名的表述。它指的是:我们知道你的编程语言可以实现所有需要实现的东西,丘奇-图灵论题保证了这一点。但“焦油坑”在于,你并没有使用方便的特性,而是说:“好吧,我有办法做到。这对我来说很优美,对我来说足够好,我能完成这个任务。”但你最终使用了笨拙、易错、复杂、效率较低或不够直接的功能来完成工作。

因此,编程语言课程的一部分,就是提炼出优雅的思维方式,而不是总是用其他概念来编码实现。

总结 📝

本节课中我们一起学习了编程语言的普遍相似性,但如果说“哦,它们都一样”来否定编程语言的多样性,那将是夸大其词且适得其反的夸大。

编程语言在理论能力上是等价的,并共享许多核心概念。然而,它们在设计哲学、语法、特性支持、惯用法和社区文化上存在显著差异。这些差异使得学习多种语言不仅能拓宽视野,加深对编程本质的理解,还能让你在解决特定问题时选择更合适的工具,并最终成为任何编程语言中更好的程序员。

077:为什么选择函数式语言? 🧠

在本节课中,我们将探讨为什么在编程语言课程中花费大量时间学习函数式语言及其核心思想是合理的。我们将从历史趋势和当前发展两个角度来论证函数式编程的价值。


课程概述 📋

本课程大约60%到80%的内容都围绕函数式语言展开。这些语言通常不鼓励使用可变状态,推崇高阶函数,并广泛使用代数数据类型等特性。那么,为什么要这样做呢?首要原因是,这些语言特性和软件构建方法对于编写正确、优雅且高效的软件至关重要。它们提供了一种优秀的计算思维方式。

在之前的课程中,我们已经重点强调了这一点。现在,在这个更具课程背景和动机的环节,我将提出另外两个截然不同的论点。


论点一:引领潮流的历史先锋 🚀

函数式语言数十年来一直走在时代前列。因此,学习它们是洞察软件未来发展方向的一个绝佳途径。当前的一些趋势表明,函数式语言非常适合应对不远的将来(而非更遥远的未来)的挑战。

事实证明,几十年来,人们在编程语言课程中通过函数式语言学习到的许多概念,最初都被认为是“美好的想法,但不适用于现实语言”,理由包括速度太慢、不符合习惯、看起来太奇怪等。以下是一些具体的例子:

  • 垃圾回收:即你可以随意创建数据,语言实现会在数据不再使用时自动回收内存。就在20年前,这还被认为对许多实际软件不切实际。如今,大多数现代流行语言都欣然接受了垃圾回收。
  • 泛型:即我们在ML中见过的类型变量(如 'a 或 α)。这个概念在主流语言中普及比垃圾回收更晚,大约在最近10到15年。然而,标准ML(SML)这类语言自20世纪80年代就已具备这些思想。
  • XML:作为一种通用的数据表示格式,互联网上的所有HTML和随处可见的XML工具,其基本思想是我们可以用文本写出易于转换为树形结构的内容。例如,1 + 2 * 3 可能存在歧义(是 (1+2)*3 还是 1+(2*3)?),但在XML的完整结构中绝无歧义。而这正是Racket(及其前身Scheme和更早的Lisp,可追溯到1958年)中我们将看到的语法思想:通过足够的括号(在XML中是尖括号)来消除歧义,并易于以结构化的递归方式处理文本。
  • 高阶函数:这是本课程的重点。我们现在看到大多数语言都包含了它们,例如Ruby、JavaScript和C#。Java也计划很快添加。我们花了很长时间才走到这一步,这大概是最近5到10年人们才普遍认识到它们应该存在于通用编程语言中。
  • 类型推断:即拥有静态类型语言而无需承担写下所有类型的负担。这个概念现在在C#中有有限支持,在Scala中支持更广泛。同样,在函数式语言中,这可以追溯到30年前。
  • 递归支持:即允许函数调用自身。虽然大多数语言早已支持,但在20世纪60年代,语言是否应该支持递归还是一个非常有争议的话题。更近一些,人们常批评旧版本的Fortran无法方便地实现递归。

思想的融合与传播

这张幻灯片的内容有些推测性,我无法给出证明。但在我看来,函数式语言及其设计者常常是正确的。世界逐渐认识到他们所倡导的语言结构和方法是有用的,无论是垃圾回收还是高阶函数,只是这通常需要几十年的时间。

我喜欢这样理解:函数式语言从未成功地征服世界或取代之前的语言。但它们的思想被同化、吸收并融入了其他语言。这就是我所说的函数式语言走在时代前列,成为领导者,然后其思想在其他地方被采纳。

我认为这很好。社会进步往往需要时间,有时变革所需的时间之长会令人沮丧。你不能简单地将功劳归于某处。我并不是说C#有类型推断是因为ML有类型推断,故事要复杂得多。但确实存在影响,并且存在一条合理的历史路径。

推测一下,也许下一个被广泛采纳的,就是你在本课程中学到的东西,无论是模式匹配、柯里化,还是我们在学习Racket时会接触一点的宏。这些都是优秀、优雅的概念,我希望看到它们更受欢迎。我不会预测哪一个或列表之外的某个概念会是下一个,但学习这些具有前瞻性历史的语言,其益处之一就在于此。


论点二:契合当下的现实发展 💡

上一节我们回顾了函数式语言作为思想先驱的历史。那么,最近发生了什么?在我看来,尽管我可能有偏见,但过去几年确实出现了一股对函数式语言的真正兴趣和流行热潮,许多真实的公司、项目和系统都在使用它们。

以下是一些活跃且用户社区仍在增长的语言列表(如果遗漏了某些语言,我表示歉意):

  • Clojure
  • Erlang
  • F#
  • Haskell
  • OCaml
  • Racket
  • Scala

这些毫无疑问都是函数式语言(对于Scala或F#,你可能会说它们同时也是面向对象语言)。但在我看来,它们带来的新东西真正在于函数式方面,而且人们真的很喜欢这些语言。在下一节中,我会讨论为什么本课程不使用这些语言,这有其利弊。

对于其中几种语言,我碰巧知道一些列出了许多公司(不是一两家,而是十家、二十家甚至五十家)真正使用它们进行项目开发的网址。如果你知道其他语言的相关信息,我鼓励你在讨论板上分享。总的来说,你可能会对最后一个网址 cfp.org(商业函数式编程用户组织的网站)感兴趣,他们每年都有会议等活动。

我不想给大家留下函数式语言没有实际用途的印象。它们有,当然这不是本课程的重点。


函数式思想的广泛渗透

我想指出,即使上述这些语言都不存在,你仍然可以有力地论证,函数式语言的思想比以往任何时候都更受欢迎、更重要。

我们看到其他语言也在采纳这些思想:

  • C#及其在.NET框架上的LINQ支持:这本质上是从一个面向对象的语言出发,然后添加了函数闭包、类型推断等功能。他们认为这为语言带来了很多好处。
  • Java计划在不久的将来添加闭包:可能在你观看这个视频时已经实现了。
  • MapReduce或开源变体Hadoop:这是一种在容错分布式集群上进行大规模数据计算的思想,其底层基础设施实现复杂,但在其上,你只需编写本质上是 mapfoldreducefold 本质上是同义词)的函数,许多事情就为你处理好了。如果你回头去看介绍MapReduce思想的原始研究论文,开篇几句话就明确指出,他们借鉴了函数式编程语言中长期使用的 mapreduce 函数的思想。

近期兴起的可能原因

那么,为什么会出现这种近期的热潮(如果确实存在的话)?我在这里真的是在猜测,但我觉得给出一些我认为可能的原因会很好。

当然,第一个原因是这些函数式语言思想简洁、优雅,能提高生产力。但这始终如此,所以并不能完全解释最近的现象,除非你说人们终于明白了这些思想的好处。

我认为一些功劳实际上要归于像JavaScript、Python、Ruby这样的语言,它们让人们认识到,真正的系统和你想做的事情不一定非要用C、C++、Java或C#等语言来编写。拥有一个多样化、异构的编程语言生态,比试图找到一个所有人都将使用的单一语言或系统更有帮助。

我还认为,特别避免可变状态(这是函数式语言提供的关键特性之一)是使并发和并行编程变得更简单的最简单方法。在一个充满并行处理的世界里(无论是你手机、笔记本电脑、台式机中的多核,还是数据中心),我们需要让并行编程更容易。而在线程间共享可能被其他线程更新的数据是很困难的,因此这是函数式语言应该大放异彩的领域。

总的来说,我们的软件已经变得如此复杂,很难跟踪系统中所有可能访问特定数据的地方。如果该数据不可变,那么你通常就无需过多担心谁还拥有它的别名。我们在课程早期讨论过不可变性的好处(尽管只是一个小例子)。

最后,函数式编程在整个软件生态中可能仍然是一个非常小的利基领域。我不知道如何衡量软件产业的规模或正在编写的软件数量,但当今世界的软件如此之多,足以容纳所有人:ML程序员、MATLAB程序员、JavaScript程序员、C程序员、汇编程序员、Java程序员等等。有大量的软件需要编写,我们需要为需要解决的每个问题配备我们所能提供的最佳工具。


课程总结 🎯

在本节课中,我们一起探讨了在编程语言课程中深入学习函数式语言的合理性。我们从两个主要角度进行了论证:

  1. 历史视角:函数式语言长期以来一直是软件设计思想的先驱,其核心概念如垃圾回收、泛型、高阶函数、类型推断等,往往在几十年后才被主流语言广泛采纳。学习它们有助于我们预见未来的技术趋势。
  2. 现实发展:近年来,函数式语言在工业界的应用显著增长,同时其核心思想(如不可变性、高阶抽象)也正被主流语言吸收,以应对现代软件开发中并发、复杂性和开发效率的挑战。

总而言之,学习函数式语言不仅是掌握一种强大的编程范式,更是理解那些持续塑造着整个软件行业发展的核心计算思想。

078:为何选择ML、Racket与Ruby 🎯

在本节中,我们将总结本课程的动机,探讨为何选择ML、Racket和Ruby这三种语言来呈现课程内容。我们将分析每种语言的特点及其在教学中的独特价值,并简要讨论其他可能的替代语言。

语言选择的二维框架 📊

首先,ML、Racket和Ruby为我们提供了一个非常有用的组合。我们可以用一个二维网格来理解这种选择:一些语言是动态类型的,另一些是静态类型的;一些语言更推崇函数式风格,而另一些更推崇面向对象风格。虽然这张图还可以添加其他行(并非所有语言都严格属于函数式或面向对象),但对我们课程而言,Racket、Ruby和SML恰好对应了四个象限中的三个。

我们没有时间引入第四种语言,而且许多学生在学习本课程之前,可能已经通过Java、C#或Scala等语言接触过第四个象限(静态类型、面向对象)。Python虽然是动态类型,但其面向对象特性不如Ruby纯粹。因此,选择这三种语言有助于表明:函数式与面向对象是独立于动态类型与静态类型的正交问题。

为何选择这三种语言? 🤔

上一节我们介绍了语言选择的整体框架,本节中我们来看看每种语言被选中的具体原因。

选择SML(一种ML家族语言)的原因

对于ML家族的语言(包括SML),我特别喜欢它的多态类型系统。通过使用类似'a的类型,我们可以利用类型系统来理解代码如何实现复用。它还具有模式匹配功能,我认为这是分解数据的绝佳方式。此外,我非常欣赏它的模块系统,我们将通过学习它来了解如何使用抽象类型来强制保持不变量,确保库的使用者无法违反这些约束。

选择Racket的原因

我需要一种动态类型的语言。Racket拥有非常出色的宏系统,适合用来简要介绍宏的概念。我喜欢它极简的语法:通过大量使用括号,所有结构都具有非常规则、易于理解的语法,尽管很多人可能不太习惯。它还具有eval功能,这是一个特定的概念,我们可以将其与其他概念联系起来讨论。我真的很喜欢Racket这门语言,因此选择了它。

选择Ruby的原因

Ruby是一门面向对象的语言,它使用类,但没有静态类型系统。这使其与许多其他面向对象的编程语言(如Java、C#)形成鲜明对比。Ruby是彻头彻尾的面向对象语言,其中的所有数据都是对象,而不仅仅是大部分数据。它有一些我认为对教学有用的小特性,我也会尝试纳入“混入”(mixins)这个特定主题,这可以通过Ruby的模块来实现。当然还有其他许多小原因,但这足以说明我选择这些语言并非仅仅出于个人喜好或语法偏爱。

其他语言的考量与替代方案 ⚖️

在解释了选择ML、Racket和Ruby的原因后,我们来看看其他合理的替代选项,以及我为何没有选择它们。本节内容是可选的,但可能满足一些同学的好奇心。

替代SML的语言

我们本可以轻松地使用其他类似的、更现代的函数式语言,例如OCaml、F#、Haskell或Scala。这些语言更符合现代审美,工具支持也更完善。我选择SML有一些充分的理由,也有一些偶然因素。

最接近的选择可能是OCaml,它具备我们所需的一切功能。但我需要移植所有的课程材料,这可能会引入很多错误。此外,课程中使用的少数几个特性在SML中实现得更为优雅。例如,在SML中(而非OCaml),数据类型绑定中的构造器实际上是可像普通函数一样传递的函数。在研究模块系统时,SML的签名匹配方式也让我能指出一些在OCaml中不那么优雅的细节。OCaml和F#都是优秀的语言。

F#本质上是运行在微软.NET平台上的OCaml方言。我稍微避开它是因为它更复杂,并且需要在非Windows平台上安装.NET实现,这虽然并非不可能,但略显困难。与OCaml相比,F#在一些小细节上也不那么完美,例如无法将多态函数在签名中导出为非多态函数。这些都是次要问题,完全可以用F#来教授本课程,但这些因素足以让我坚持使用SML。

Haskell是一门出色的语言,但我教授语义学和评估规则的方式与Haskell的惰性求值语义不兼容。在Haskell中,求值顺序与ML及大多数其他编程语言不同,我觉得在存在惰性求值的情况下,很难清晰准确地呈现计算过程。虽然也有人用Haskell作为入门语言,但我认为这对教学来说过于困难。因此,SML虽然不如其他语言现代,但它仍然是教授函数式编程的坚实基础。学会SML后,学习其他类似语言将容易得多。

替代Racket的语言

我们可以使用Scheme、Lisp或Clojure,它们与Racket有很多相似之处。对我而言,Racket是一门现代语言,它持续演进,这既是优点也是挑战。它在许多方面对这些变体进行了改进,而在无关紧要的方面则保持原样。我特别喜欢它的模块系统、对结构体(类似于其他语言中的记录)的支持以及契约系统(尽管我们不会深入探讨)。拥有一门可以自学更多内容的语言是很好的。Racket拥有庞大的用户群,虽然以在计算机科学教育中的作用而闻名,但其用途远不止于此。它附带的DrRacket IDE使教学更加容易,因为该开发环境始终设计得对教学友好。在Racket中,你可以非常轻松地设计自己的语言,这是DrRacket系统和Racket基础设施的一个非常酷的特性。虽然为课程定制一门语言很诱人,但我认为使用更通用、已被他人使用的语言是更好的选择。

替代Ruby的语言

Ruby被称为脚本语言,那么为何不使用Python、Perl或JavaScript呢?这些语言也是动态类型的。像Python和JavaScript这样的语言也具有许多面向对象的特性,但它们不如Ruby那样完全地面向对象。Ruby在面向对象编程方面做出了更彻底的承诺。Ruby还拥有完整的闭包,而Python则没有。JavaScript是一门非常面向对象的语言,但它没有类,而是基于原型的继承(本课程不会涉及这个话题)。我并非对某种方式有特别的偏好,但我更倾向于从动态类型、基于类的面向对象语言开始,因为我觉得这能更容易地与静态类型、基于类的面向对象语言(如Java、C#和Scala)形成对比。

另一种与Ruby具有我所提及的所有特性的语言其实是Smalltalk。我以前确实用过Smalltalk。它是一门古老得多的语言,许多人觉得其语法非常奇怪(当然这对我毫无影响,因为语法只是表面)。我不太喜欢的地方是它不那么常见,了解它的人更少,现代工具也更少。而且Smalltalk的实现传统上将语言与环境以一种我认为有些奇怪的方式融合在一起,这种方式使我更难梳理出实际的语言结构。喜欢Smalltalk的人认为这是一个特性,而我则觉得它更令人困惑。

课程中的语言使用与现实考量 🛠️

让我提醒一下,我想我在课程开始时说过:我们在本课程中使用这些语言的方式可能会让它们显得简单。我们编写小型函数,逐个测试独立的概念。我完全清楚,真正的编程语言需要很多东西:测试框架、错误跟踪系统、项目管理工具等工具;用于文件I/O、网络访问等的库;浮点运算、字符串操作和多线程等结构。许多优雅的语言都具备这些,Racket和Ruby实际上也拥有所有这些功能,但我们在一门编程语言课程中不会以那种方式使用它们。因此,我们也可以用Java等其他语言以这种“简单”的方式来教授语言结构。在本节中,我只是试图解释为何我认为ML、Racket和Ruby能更好地服务于我们的教学目的。

课程结束后的语言应用 🚀

最后,让我们以“学完这门课后,你还会再次使用这些语言吗?”这个问题来结束动机部分的讨论。你很有可能会使用它们。我鼓励你去使用。你可能会使用与这些语言足够相似的语言,感觉就像在使用它们一样。但你也可能不会。你的整个职业生涯可能都在用C++编程,这是因为现实世界的考量:你需要特定的库,需要使用老板要求的语言,需要能够雇佣具备所需技能背景的团队成员,可能存在行业标准,你需要与未学习过本课程的人交流想法等等。我理解所有这些。像这样的课程,其设计初衷就是将课程与行业实践和现实世界区分开来:我们不必处理所有这些问题,可以只专注于编程语言本身。这是一门关于编程语言的课程,而非更广泛的软件工程和软件开发问题。这使我们有机会、也有好处将这些合理的问题暂时搁置一旁。

这实际上是关于采取更长远的视角。技术领导者(我希望你们中的许多人将成为技术领导者)能够影响诸如“什么是行业标准?”、“我应该雇佣什么样的人?”、“我们应该如何教育软件开发人员?”等问题的答案。因此,我们不应过度受限于当今世界的运作方式。我们应该规划未来,洞察底层设计的优雅之处,发现普遍存在的特性。我喜欢使用ML、Racket和Ruby来实现这一目标。

总结 📝

本节课中,我们一起学习了选择ML、Racket和Ruby作为本课程教学语言的深层原因。我们通过一个二维框架理解了它们在类型系统和编程范式上的代表性,并逐一分析了每种语言在支持多态类型、模式匹配、动态类型、宏、极简语法、纯面向对象和闭包等方面的独特教学价值。我们还简要探讨了其他可能的替代语言及其未被选中的考量。最后,我们讨论了课程中简化使用语言的方式及其与现实开发的差异,并展望了学习这些语言对长远技术视野的益处。

079:课程第四部分介绍 🎯

在本节课中,我们将要学习课程第四部分的内容。这是以M语言为主要编程语言的最后一个部分。本节将介绍类型推断、相互递归、模块系统以及函数等价性等核心概念,为课程前半部分画上句号。

课程回顾与展望 📍

上一节我们深入探讨了M语言的高级特性。本节开始前,简要回顾我们的学习历程并展望后续内容是有益的。

我们已经完成了M语言大部分核心内容的学习,包括:

  • 模式匹配
  • 高阶函数
  • 闭包

以上是主要的重点主题。但有四个主题被推迟讲解,原因是我们之前的作业尚未需要它们。我认为这些主题将完善我们对课程前半部分的正式介绍。

本节核心内容 📚

本节内容比其他部分稍短,我们将学习以下四个主题:

以下是本节将涵盖的具体主题列表:

  1. 类型推断:M语言如何推断出我们所有函数和绑定的类型。
  2. 相互递归:如何编写多个相互调用的递归函数。
  3. 模块系统:如何编写可以隐藏信息、对程序其他部分保密的模块。M语言为此提供了一个非常优雅的模块系统,我们将学习其基础知识。
  4. 函数等价性:这是一个更概念性的主题,探讨在像M这样的语言中,两个函数何时等价,以及何时可以相互替换。

课程安排说明 📅

本节没有相关的作业任务。本课程时间安排上共有七次作业,但会有八个或可能九个部分。这为我们提供了更多时间为期中考试做准备,而期中考试恰好安排在本节结束之后。

期中考试将包含简答题,覆盖前四个部分的所有内容,包括本节材料以及之前章节的问题。

期中考试之后,我们将继续学习更多概念,并将编程语言切换到Racket,因为它更适合我们接下来要涵盖的概念。

总结 🎓

本节课中,我们一起回顾了课程进度,并介绍了第四部分将要学习的四个关键主题:类型推断、相互递归、模块系统和函数等价性。同时,我们也了解了本节之后的课程安排,包括期中考试和后续向Racket语言的过渡。

080:什么是类型推断 🧠

在本节课中,我们将学习类型推断的概念,了解它试图解决什么问题,以及它在静态类型语言(如ML)中的作用。我们将通过对比静态类型检查和动态类型检查来阐明类型推断的必要性。

静态类型检查与动态类型检查

上一节我们介绍了类型推断的总体目标,本节中我们来看看类型检查的两种主要方式。

当你在编译时进行类型检查(称为静态类型检查)时,它允许我们在程序运行之前就拒绝某些程序。我们这样做是为了防止潜在的错误。因此,静态类型编程语言(或具有此功能的语言)中,某些函数可能无法通过类型检查。例如,一个可能尝试将数字当作函数调用的函数,我们实际上不需要执行该函数就能得到错误,我们在运行程序之前就会得到该错误。

相反,动态类型语言很少或根本不进行这种检查。因此,你必须实际调用函数,并且在变量绑定到正确值的情况下,对特定的问题表达式求值后,才能看到潜在的错误。

静态和动态类型检查的相对优势是本课程的一个主要概念,我们将在使用动态类型编程语言(如Racket)一段时间后再深入研究。目前,我们只见过ML。像Java、C、Scala等语言一样,ML是静态类型的。它具有这样的特性:某些代码在运行前就无法通过类型检查。

所有这些静态类型语言的共同点是,我们引入的每个变量都被赋予一个类型。每个绑定都有某种类型,并且在其可用的作用域内,它保持该类型,并且只能持有该类型的值。因此,我们将字符串、布尔值、函数和元组区分开来。

ML的隐式与静态类型

现在强调这一点是因为,尽管ML是静态类型的,但自第2节左右开始,它一直是隐式类型的。我们从未为任何变量写下类型,对于val绑定从未写过,只在课程开始时使用fun定义函数时,为函数的参数写过类型。

所以,仅仅因为ML是隐式类型的,有时可能会让人混淆,忘记它实际上是静态类型的。例如,在第一个函数f中,两个变量fx都有类型:f的类型是int -> intx的类型是int。它们和Java或C语言一样是静态类型的,在Java或C中我们必须在引入变量时写下所有变量的类型。

这就是为什么当你有一个像g这样的函数时,我们会得到一个类型错误。这里类型错误的原因是,ML的类型检查规则之一是:条件表达式的then分支和else分支必须具有相同的类型,这样整个if表达式才能具有该类型,从而也决定了g的返回类型。当这两个分支类型不匹配时(例如这里是bool,那里是int),我们就会得到一个类型错误。这正是动态类型语言中通常被允许的情况,根据g的参数,你可能返回一个bool或一个int。因此,在静态类型检查的意义上,ML更像Java或C,而不像JavaScript或Python。

什么是类型推断问题?

那么,类型推断问题到底是什么呢?

类型推断问题是:给定一个程序(就像我们在上一张幻灯片中看到的那样),尝试为该程序中的每个变量、绑定和表达式赋予一个类型,使得如果你写下所有这些类型,类型检查就会成功。我们需要推断出所有类型,以便程序能够通过类型检查。

如果我们无法做到这一点,即不可能给出这样的类型(就像第二个例子中,truex * 2根本没有相同的类型),那么类型推断的作用就是失败,并可能给出某种错误信息。

原则上,你可以像这样设置:可以有一个类型推断过程为所有东西写下类型,然后有一个类型检查器在实践中检查这些类型。但在像SML这样的语言实现中,我们通常不会如此清晰地将两者分开。我们通常让类型推断器和类型检查器是同一个东西,你的程序要么通过类型检查,要么不通过。

类型推断的复杂性与优雅性

在进入下一节讨论ML的类型推断之前,我想强调的最后一点是:本小节实际上是关于类型推断的一般概念。类型推断可能容易、困难或不可能,这取决于你试图为之推断类型的类型系统。

以下是两个极端的观点:

  • 如果每个程序都能通过类型检查,那么推断起来非常容易,直接说“是”即可。
  • 如果没有程序能通过类型检查,推断起来也很容易,直接说“否”即可。

因此,类型推断的难易程度并不一定取决于类型系统是接受更多程序还是接受更少程序。要弄清楚这一点并不简单。这是语言设计难度的一部分,如果你想要类型推断的话。

但我们之所以要研究ML的类型推断,是因为它虽然有些微妙,但也极其优雅。在课程的现阶段,它可能看起来像魔术。我们已经看到这些多态类型似乎是为我们推断出来的。但通过一系列例子,我想让你相信,它实际上是一个相当优雅且直接的过程。


本节课中我们一起学习了:类型推断的定义及其目标,静态类型检查与动态类型检查的区别,ML作为隐式但静态类型语言的特性,以及类型推断问题的本质——为程序中的所有元素推断出使其通过类型检查的类型。我们还了解到类型推断的难度取决于具体的类型系统,而ML的类型推断机制以其优雅性著称。

081:类型推断简介

在本节课中,我们将要学习ML语言如何进行类型推断,即如何自动确定程序中所有绑定的类型。我们将通过一系列示例来理解其核心步骤和原理。

概述

类型推断是ML语言的核心特性之一,它允许程序员在不显式标注类型的情况下编写代码,编译器会自动推导出表达式的类型。这个过程遵循一套系统化的步骤。

上一节我们介绍了类型推断的基本概念,本节中我们来看看ML类型推断的具体工作流程。

类型推断的关键步骤

以下是ML类型推断算法在处理每个示例时会遵循的关键步骤:

  1. 按顺序确定绑定类型
    除非遇到相互递归的情况(这需要同时处理所有相互调用的定义),否则ML会按代码顺序处理每个绑定。先推断第一个绑定的类型,将其加入环境后,再处理下一个绑定。这就是为什么辅助函数必须在使用它的函数之前定义。

  2. 分析定义以收集必要事实
    对于每个valfun绑定,分析其定义体,收集所有对类型构成约束的事实。例如,如果函数体内有表达式x >= 0,那么参数x必须具有int类型。

  3. 推导约束蕴含的类型关系
    收集所有约束后,推导这些约束共同蕴含的类型。有时约束会相互矛盾(例如,要求同一个x既是int又是string),这时就会产生类型错误。

  4. 处理未约束的类型变量
    如果推导后没有矛盾,但某些类型成分仍未被任何事实约束(即它们可以是任何类型),那么就会推断出一个多态类型,使用类型变量(如'a'b)来表示这些部分。

  5. 应用值限制
    最后一步是应用“值限制”规则。我们将在后续专门讨论值限制的章节中详细解释这一点,本节暂不涉及。

示例解析

在后续章节中,我们将更细致地、一步步地解析示例,就像实际算法所做的那样。但为了理解这些约束如何工作,我们先从一个较高的层次,以更接近人类思维的方式来看一个例子。

我们有两个按顺序处理的绑定:

val x = 42
fun f (y, z, w) = if y then z + x else 0
  • 第一个绑定val x = 42。数字42的类型是int,因此x的类型是int
  • 第二个绑定:函数f。我们需要推断其参数和返回值的类型。
    • 查看函数体if y then ...if的条件部分必须是bool类型,因此y必须是bool类型。
    • 查看then分支z + x+运算符要求两边的操作数都是int类型。已知xint,因此z也必须是int类型。
    • 同时需要检查:else分支的0int,与then分支z+x的结果类型int一致。
    • 参数w在函数体中从未被使用,因此没有任何事实约束它的类型。w可以是任何类型。
    • 整个函数体的结果类型是int,因此f的返回类型是int

综合以上分析,函数f接受一个bool、一个int和一个任意类型(记为类型变量'a)的参数,并返回一个int。因此,f的推断类型为:

bool * int * 'a -> int

这与ML类型检查器给出的结果一致。

多态性与类型推断的关系

ML类型推断的一个重要特性是,只要可能,它就会生成带有类型变量的多态类型。这有利于代码重用和理解函数行为。例如,上述例子明确告诉我们参数w从未被使用,所以它可以具有更通用的类型。

但需要强调的是,类型推断带有类型变量的多态性是两个完全独立的概念,这一点常被混淆:

  • 可以存在具有类型推断但不支持类型变量(即多态)的语言。这会使推断上述例子中的类型变得困难,但概念上是可行的。
  • 反之,也可以存在支持类型变量(多态)但要求程序员显式写出所有类型、不进行类型推断的语言。Java(在大多数情况下)就是一个很好的例子:即使你想要一个多态方法,通常也需要显式写出类型参数。

总结

本节课中我们一起学习了ML类型推断的基本流程。我们了解到,类型推断按顺序处理绑定,通过分析代码收集类型约束,解决这些约束以确定具体类型,并将未受约束的部分泛化为多态类型变量。我们还澄清了类型推断与多态性是两个独立但在此处协同工作的语言特性。通过手动演练示例,我们能够理解ML类型检查器背后的工作原理。

082:类型推断示例详解 🧠

在本节课中,我们将通过几个完整的例子来展示类型推断的过程。我们将从简单的例子开始,逐步深入到更复杂的情况,最后还会探讨一个无法通过类型检查的例子,看看类型推断如何识别错误。

概述 📋

类型推断的核心思想是收集所有类型检查所需的事实,并用这些事实来约束函数的类型。我们将通过两个例子来演示这一过程,这两个例子都不涉及多态性。之后,我们会修改其中一个例子,使其无法通过类型检查,并观察类型推断如何发现这个错误。

示例一:简单整数运算函数 ➕

首先,我们来看一个简单的函数,它接收一个整数对,进行绝对值运算后相加。类型推断并不关心代码的具体功能,它只收集类型检查所需的事实。

函数 F 的定义如下:

fun f x =
    let val (y, z) = x
    in abs y + z
    end

类型推断步骤

  1. 确定函数类型:函数 F 必须具有类型 T1 -> T2,因为它是一个接收一个参数的函数。参数 x 的类型为 T1

  2. 分析函数体:在函数体中,我们遇到了 let 绑定和模式匹配。

    • val (y, z) = x 这个模式匹配表明,x 的类型 T1 必须是一个对类型,即 T1 = T3 * T4,其中 y 的类型为 T3z 的类型为 T4
  3. 收集表达式约束

    • abs y:已知 abs 的类型为 int -> int。因此,y 的类型 T3 必须等于 int
    • abs y + z+ 运算符要求两个操作数都是 int 类型。由于 abs y 的结果是 int,所以 z 的类型 T4 也必须等于 int
  4. 推导最终类型

    • 根据 T1 = T3 * T4T3 = intT4 = int,我们得到 T1 = int * int
    • 函数体 abs y + z 的类型是 int,因此返回类型 T2 = int
    • 最终,函数 F 的类型被推断为 (int * int) -> int

通过这个例子,我们看到了如何通过收集和传播约束来推断函数的类型。

示例二:列表求和函数 📊

接下来,我们看一个更有用的函数:计算列表中所有元素的和。这个例子将展示如何处理递归和列表类型。

函数 sum 的定义如下:

fun sum xs =
    case xs of
        [] => 0
      | x::xs' => x + sum xs'

类型推断步骤

  1. 确定函数类型:函数 sum 必须具有类型 T1 -> T2,参数 xs 的类型为 T1

  2. 分析模式匹配case 表达式对 xs 进行模式匹配。

    • 模式 x::xs' 表明 xs 必须是一个列表。因此,T1 = T3 list,其中 x 的类型为 T3xs' 的类型为 T3 list
  3. 收集分支约束

    • 第一个分支 [] => 00 的类型是 int,因此整个 case 表达式的返回类型,也就是 T2,必须为 int
    • 第二个分支 x::xs' => x + sum xs'
      • x + sum xs'+ 运算符要求 xint 类型,因此 T3 = int
      • 递归调用 sum xs'xs' 的类型是 T3 list,即 int list。这与 sum 的参数类型 T1(即 int list)一致。递归调用的返回类型是 T2,即 int,这与 + 运算符的期望相符。
  4. 推导最终类型

    • T1 = T3 listT3 = int,得 T1 = int list
    • T2 = int,得函数 sum 的类型为 int list -> int

这个例子展示了类型推断如何优雅地处理递归函数和复合数据类型。

示例三:引入错误以观察类型检查失败 ❌

现在,我们修改第二个例子,故意引入一个错误,看看类型推断如何报告问题。

我们将错误地尝试对列表的头部(一个整数)进行递归求和:

fun sum xs =
    case xs of
        [] => 0
      | x::xs' => x + sum x  (* 错误:应对 xs' 递归,而不是 x *)

类型推断与错误分析

  1. 初始约束与之前相同

    • sum 的类型为 T1 -> T2
    • xs 的类型为 T1
    • 由模式 x::xs'T1 = T3 listx 的类型为 T3xs' 的类型为 T3 list
    • 由第一个分支得 T2 = int
    • x + ...T3 = int
  2. 矛盾出现

    • 现在,我们看递归调用 sum x
    • 我们知道 x 的类型是 T3,即 int
    • 但函数 sum 期望的参数类型是 T1,即 int list(由 T1 = T3 listT3 = int 推导得出)。
    • 因此,我们得到了一个无法满足的约束:需要将 int 类型传递给期望 int list 类型的函数。这等价于要求 int = int list,这显然是不可能的。
  3. 错误报告:类型检查器在遇到这种矛盾时会立即报告错误。具体的错误信息可能因检查器收集约束的顺序而异。它可能会指出“sum 需要 int list,但找到了 int”,也可能在其他地方(比如模式匹配处)报告类型不匹配。但无论如何,核心结论都是相同的:这段代码存在类型错误,无法通过类型检查。

这个例子说明了类型推断不仅能够推断出正确的类型,还能有效地检测出代码中的类型错误。

总结 🎯

本节课我们一起学习了类型推断的实际应用。我们通过三个例子演示了如何逐步收集类型约束并推导函数类型:

  1. 简单整数运算函数中,我们看到了对基本类型和元组类型的推断。
  2. 列表求和函数中,我们学习了如何处理递归函数和列表类型。
  3. 引入错误的例子中,我们观察了类型推断如何识别并报告类型不匹配的错误。

类型推断是静态类型语言中一个强大的特性,它允许我们在不显式标注所有类型的情况下编写安全的代码。理解其工作原理有助于我们编写更清晰、更健壮的程序。在接下来的课程中,我们将看到类型推断如何与多态性结合,产生更通用的类型。

083:多态类型推断示例 🧩

在本节课中,我们将继续学习类型推断的示例。本节的所有示例都将生成具有多态类型的函数。核心概念与之前相同:我们仍将收集类型检查所需的所有事实,并利用这些事实来约束函数的类型。唯一区别在于,最终约束条件极少,导致某些参数或结果类型可以保持任意类型。我们将利用这一信息推导出多态类型,其中某些参数或结果可能需要与其他部分类型相同或不同。最终,我们将推断出多态函数。尽管这在概念上可能更具挑战性,但由于需要处理的约束更少,实际操作反而更简单。

示例一:列表长度函数 📏

上一节我们介绍了整数列表求和的类型推断,本节我们来看一个类似的例子:计算列表的长度。我们知道,length 函数应适用于任何类型的列表,因此其类型应为 'a list -> int

我们首先假设 length 的类型为 T1 -> T2,因为它是一个函数,其参数类型必须匹配。

接下来,我们注意到模式匹配中使用了 x::xs。这意味着存在某个类型 T3,使得 x 的类型为 T3,而 xs 的类型为 T3 list。因此,参数 xs 的类型 T1 必须等于 T3 list

从函数返回 0 可知,结果类型 T2 必须等于 int

递归调用 length xs 时,我们传入类型为 T3 listxs,而 T1 已等于 T3 list,因此没有额外约束。所有类型检查均通过。

综合所有约束,我们得到 T3 list -> int。由于对 T3(列表元素的类型)没有任何约束(我们从未使用元素 x),我们可以用类型变量 'a 一致地替换 T3。最终推断出的类型为 'a list -> int。这表示对于任意类型 'a,该函数接受一个 'a list 并返回 int

示例二:三元组交换函数 🔄

现在,我们来看一个更复杂的例子:函数 f 接受三个参数 xyz,并根据条件返回 (x, y, z)(y, x, z)。虽然条件始终为真,但类型检查器会遵循规则,考虑两个分支都可能返回。

我们假设 f 的类型为 T1 * T2 * T3 -> T4,其中 xyz 的类型分别为 T1T2T3

条件表达式要求两个分支类型一致。因此,T4 必须同时等于 T1 * T2 * T3T2 * T1 * T3。这只有在 T1 = T2 时才可能成立。

综合约束后,f 的类型为 T1 * T1 * T3 -> T1 * T1 * T3。这表明 xy 必须类型相同,而 z 可以是不同类型。

由于 T1T3 没有其他约束,我们用类型变量 'a'b 一致地替换它们,得到最终类型 'a * 'a * 'b -> 'a * 'a * 'b。这意味着 xy 必须类型相同,z 可以相同或不同。

示例三:函数组合 ⚙️

最后,我们分析函数组合的例子:compose 接受两个函数参数 fg,返回一个新函数,该函数接受输入 x 并返回 f (g x)

假设 compose 的类型为 T1 * T2 -> T3,其中 fg 的类型分别为 T1T2

函数体是一个匿名函数 fn x => f (g x),其类型为 T4 -> T5。因此,T3 必须等于 T4 -> T5

g x 可知,g 必须是一个函数,即 T2 = T4 -> T6T6g 的返回类型)。

f (g x) 可知,f 也必须是一个函数,且其参数类型必须匹配 g 的返回类型,即 T1 = T6 -> T7T7f 的返回类型)。

此外,匿名函数体的结果类型 T5 必须等于 f 的返回类型 T7,因此 T5 = T7

综合所有约束:

  • T1 = T6 -> T5
  • T2 = T4 -> T6
  • T3 = T4 -> T5

因此,compose 的类型为 (T6 -> T5) * (T4 -> T6) -> (T4 -> T5)

由于 T4T5T6 无额外约束,我们用类型变量一致地替换它们:

  • T6 替换为 'a
  • T5 替换为 'b
  • T4 替换为 'c

最终得到类型 ('a -> 'b) * ('c -> 'a) -> ('c -> 'b)。这表示 compose 接受一个从 'a'b 的函数和一个从 'c'a 的函数,返回一个从 'c'b 的函数。

总结 📝

本节课中,我们一起学习了多态类型推断的三个示例。我们了解到,类型推断过程包括收集事实、约束类型,并在无额外约束时用类型变量一致地替换未约束的类型变量。通过这种方法,我们可以推断出函数的通用多态类型,使其能够灵活处理多种数据类型。

084:值限制与其他类型推断挑战 🧩

在本节课中,我们将要学习类型推断的收尾内容,包括一个重要的修正——值限制,以及探讨类型系统设计如何影响推断的难易程度。我们会看到,为了使ML的类型系统保持健全,必须引入一些额外的规则。


概述

到目前为止,我们介绍的ML类型推断规则过于宽松,会允许一些本应类型检查失败的程序通过。这会导致运行时出现类型错误,使类型系统变得不健全。本节将揭示这个问题,并介绍ML如何通过值限制来修复它。此外,我们还将简要讨论,如果ML的类型系统变得更严格更宽松,类型推断会面临哪些挑战。


类型系统为何会不健全?🤔

上一节我们介绍了多态类型推断的基本原理。本节中我们来看看,当多态性与可变引用结合时,会出现什么问题。

问题源于多态类型变量和可变状态的组合。以下是一个能展示该问题的最简示例:

val r = ref NONE
r := SOME "hi"
1 + (valOf (!r))

按照目前介绍的规则,第一行中r会被推断为类型 'a option ref。这是一个多态类型,意味着这个引用可以存放任何类型的option

然而,这带来了麻烦:

  1. 第二行,我们可以将r当作string option ref类型,并存入SOME "hi"
  2. 第三行,我们又将r当作int option ref类型,解引用后通过valOf取出其值(实际是字符串),并尝试与整数1相加。

这显然会导致运行时错误,但类型检查器却认为程序是合法的。这就是类型系统的不健全之处。


解决方案:值限制 ✅

为了恢复类型系统的健全性,我们需要一个更严格的规则,确保上述三行代码不能同时通过类型检查。这个规则就是值限制

值限制的规则如下:

在一个val绑定中,只有当右侧的表达式是一个(或一个变量)时,被绑定的变量才能获得一个多态类型。如果右侧是一个需要计算的表达式(如函数调用),则其类型必须被单态化

这个规则看似奇怪,但它巧妙地解决了问题。它使得我们之前的问题代码无法通过类型检查。


值限制如何解决问题

让我们用值规则分析之前的例子:

val r = ref NONE  (* ref 是一个函数调用,不是值。因此 r 不能获得多态类型 *)

根据值限制,r不能获得类型'a option ref。标准ML编译器(如SML/NJ)会给出警告,并赋予r一个类似?.X1 option ref单态占位类型。这个类型是无效的,使得后续对r的赋值和读取操作都无法通过类型检查,从而阻止了错误的发生。


值限制的副作用与应对

值限制有时会“误伤”一些本应安全的代码。以下是一个常见的例子:

val pairWithOne = List.map (fn x => (x, 1))

我们期望pairWithOne的类型是'a list -> ('a * int) list。但List.map (fn x => (x, 1))是一个函数调用的结果,不是值,因此违反了值限制。

解决方法是进行函数包装

val pairWithOne = (fn xs => List.map (fn x => (x, 1)) xs)

或者使用更简洁的语法糖:

fun pairWithOne xs = List.map (fn x => (x, 1)) xs

现在,右侧是一个函数定义(这是一个值),因此pairWithOne可以合法地获得多态类型。


类型系统设计与推断难度 ⚖️

现在,让我们跳出值限制,从更宏观的视角看看ML类型推断所处的“甜蜜点”。

如果ML没有多态性,类型系统会更简单、更严格,但类型推断反而可能更笨拙。例如,对于list length函数,推断器将不得不为它选择一个具体的列表元素类型(如int list -> int),这大大降低了函数的通用性。

如果ML拥有更宽松的类型系统(如子类型),允许一个表达式有更多可能的类型,类型推断也会变得更困难。例如,模式匹配val (y, z) = x原本能明确推断x是二元组T1 * T2。但如果存在子类型,x可能是一个三元组或更长的元组,这增加了推断的复杂性。

ML的设计在表达力、安全性和推断能力之间取得了良好的平衡,使得其类型推断既强大又相对易于理解。


总结

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

  1. 问题:ML原有的多态类型推断与可变引用结合会导致类型系统不健全
  2. 解决方案:引入了值限制规则,规定只有val绑定右侧是值或变量时,变量才能获得多态类型。
  3. 影响与应对:值限制可能导致某些合法代码无法通过推断,但可以通过函数包装等技术解决。
  4. 设计权衡:ML的类型推断之所以优雅实用,是因为其类型系统设计处于一个“甜蜜点”。增加或减少类型系统的限制,都可能让类型推断变得更复杂。

至此,我们关于ML类型推断的讨论就告一段落了。ML展示了如何在静态类型语言中实现强大而实用的类型推断,这一思想也影响了许多其他现代编程语言。

085:相互递归 🌀

在本节课中,我们将学习相互递归(Mutual Recursion)的概念。相互递归指的是两个或多个函数相互调用的情况。虽然这是一个小主题,但在某些编程模式和习惯用法中非常有用。我们将通过实现一个简单的状态机来展示其应用,并介绍两种实现相互递归的方法:ML语言的内置支持以及使用高阶函数的变通方法。

内置支持:funand 关键字

在ML语言中,我们可以使用 funand 关键字来定义相互递归的函数。具体做法是,在第一个函数定义后使用 and 关键字连接后续的函数定义。这样,这些函数就可以相互调用,它们被视为一个整体添加到环境中,并进行类型检查和求值。

例如,定义两个相互递归的函数 FG

fun F x = ... (* F 的定义,可以调用 G *)
and G y = ... (* G 的定义,可以调用 F *)

同样,我们也可以定义相互递归的数据类型:

datatype T1 = Foo of int | Bar of T2
and T2 = Baz of string | Qux of T1

状态机示例:处理整数列表

相互递归的一个常见应用是实现有限状态机(Finite State Machine)。状态机用于处理未知长度的输入列表,根据当前状态和输入元素决定下一个状态。最终,某些状态表示接受输入,某些状态表示拒绝。

以下是一个简单的状态机示例,它只接受形如 [1, 2, 1, 2, ...] 的整数列表,且必须以 2 结尾:

fun match xs =
    let
        fun need_one [] = true
          | need_one (1::xs') = need_two xs'
          | need_one _ = false
        and need_two [] = false
          | need_two (2::xs') = need_one xs'
          | need_two _ = false
    in
        need_one xs
    end

在这个例子中,need_oneneed_two 是两个相互递归的函数,分别代表两个状态。need_one 状态期望下一个元素是 1,而 need_two 状态期望下一个元素是 2。通过相互调用,它们实现了状态之间的切换。

相互递归数据类型示例

假设我们有两个相互递归的数据类型 T1T2

datatype T1 = Foo of int | Bar of T2
and T2 = Baz of string | Qux of T1

我们希望编写两个函数,分别检查 T1T2 类型的值中是否包含 0 或空字符串。由于这两个数据类型相互引用,我们需要使用相互递归的函数来实现:

fun no_bad_T1 (Foo i) = i <> 0
  | no_bad_T1 (Bar y) = no_bad_T2 y
and no_bad_T2 (Baz s) = size s > 0
  | no_bad_T2 (Qux x) = no_bad_T1 x

高阶函数变通方法

如果我们无法使用内置的相互递归支持,可以通过高阶函数来实现相同的功能。具体做法是将一个函数作为参数传递给另一个函数。例如,我们可以重写上面的示例:

fun no_bad_T1 (f, Foo i) = i <> 0
  | no_bad_T1 (f, Bar y) = f y

fun no_bad_T2 (Baz s) = size s > 0
  | no_bad_T2 (Qux x) = no_bad_T1 (no_bad_T2, x)

在这个版本中,no_bad_T1 接受一个函数 f 作为额外参数,这个函数就是 no_bad_T2。通过这种方式,我们实现了相互递归的效果,而不需要依赖语言的内置支持。

总结

本节课中,我们一起学习了相互递归的概念及其在ML语言中的实现方法。我们介绍了如何使用 funand 关键字定义相互递归的函数和数据类型,并通过状态机的例子展示了相互递归的实际应用。此外,我们还探讨了使用高阶函数实现相互递归的变通方法。相互递归虽然是一个小主题,但在处理复杂的状态切换和相互引用的数据结构时非常有用。

086:模块系统入门 🧩

在本节课中,我们将开始学习ML的模块系统。这是一个相当庞大的主题,我们将通过多个章节来探讨,尽管我们只会触及表面,了解模块系统的基本概念和功能。

概述

到目前为止,我们编写的所有程序都只是一个顶层的绑定序列。我们可能在函数内部有辅助函数或局部绑定,但整体上只有一个序列。对于编写大型程序来说,这种组织方式并不理想。ML语言意识到了这一点,并且像许多编程语言一样,提供了更多支持来组织大型程序。

我们将主要关注ML的结构。结构定义了一个模块,你可以在其中拥有一系列绑定,这些绑定与其他模块及其绑定是分开的。在本节中,我们将介绍语法和基础知识,然后在此基础上继续深入。

结构绑定语法

本节我们将介绍的主要结构是结构绑定。其语法如下:

structure 模块名 = struct
    绑定1
    绑定2
    ...
end

按照惯例,模块名通常以大写字母开头,但这并非强制要求。在structend之间,你可以放置任何类型的绑定:数据类型、异常、变量、函数等。

在模块内部,这一系列绑定的求值方式与我们之前在顶层看到的一样。按顺序求值每个绑定,后面的绑定可以使用前面的绑定。定义模块后,在模块外部你可以使用这些绑定,但不能直接通过语法使用它们。如果模块内有一个名为binding_name的绑定,你需要写成模块名.绑定名

这应该不会让我们感到意外,因为我们已经使用了标准库中的一些模块。例如,当我们使用String.toUpper函数时,它就是定义在String模块中的toUpper函数。因此,我们现在学习的是如何定义自己的模块。

示例:自定义数学库

下面是一个简单的示例,我们定义了一个名为MyMathLib的结构:

structure MyMathLib = struct
    fun fact x = if x=0 then 1 else x * fact(x-1)
    val half_pi = Math.pi / 2.0
    fun doubler x = x * 2
end

val pi = MyMathLib.half_pi + MyMathLib.half_pi
val twenty_eight = MyMathLib.doubler 14

我们可以像运行任何其他程序一样求值这个程序。REPL会告诉我们确实存在一个结构MyMathLib,其中包含facthalf_pidoubler绑定。在顶层,我们得到pi的值为3.14159,twenty_eight的值为28。

需要强调的是,在我们的环境中,我们有诸如MyMathLib.fact这样的东西,它是一个从intint的函数。但在顶层环境中,我们并没有fact这个绑定,它根本没有被绑定。同样,我们也没有一个名为MyMathLib的变量。结构名不是变量,它们是ML中不同的东西。我们有这些模块,但必须使用其中的绑定,不能一次性使用整个绑定。例如,MyMathLib本身并不是一个可用的值,这只是模块系统的一部分,与语言的其他部分略有不同。

当然,我们可以这样使用:MyMathLib.half_pi + 1.0,这完全可以正常工作。

命名空间管理

到目前为止我们所做的工作,我称之为命名空间管理。通过使用模块,我们可以将不同绑定的名称分开。这使得当你的程序中有大量函数和变量时,管理起来要容易得多。

ML甚至支持在其他模块内部定义模块,形成一个完整的层次结构。这非常棒,因为一个列表库可以有一个map函数,一个树库也可以有一个map函数,它们不必担心将其命名为List.mapTree.map,每个模块都可以有自己的map

这对于大型程序非常重要,但就其本身而言并不十分有趣。本节内容很短,关于命名空间管理,我要说的基本就是这些。是的,你应该使用命名空间,我们很高兴语言提供了这种功能。

可选特性:open关键字

为了结束本节,介绍一个可选特性,因为人们总是会问到它。有时人们想知道,是否有办法说“我想使用模块中的所有内容,而不必键入模块名”?在ML中,你可以通过open特性来实现。

你可以在模块内部使用它,也可以在REPL中使用。我个人不太喜欢它,因为它倾向于将所有那些原本很好地放在命名空间中的东西“打开”,而模块中通常包含的不仅仅是你想要的东西。但尽管如此,让我展示一下它是如何工作的,以及它实际上并不是非常必要。

例如,如果我打算经常调用half_pi,我可以直接写:

val hp = MyMathLib.half_pi

然后我就可以说hp + 1.0,这完全没问题。

如果你真的想要模块中的所有内容,那么你可以说:

open MyMathLib

现在,我在顶层环境中就有了facthalf_pidoubler的绑定。我认为这在REPL中测试模块时非常有用,但除此之外,我并不觉得它特别有用。尤其是如果你在想“我真的很想经常使用fact”,却忘记了模块中还有一个doubler绑定,当你使用open时,实际上会遮蔽环境中可能已经存在的任何doubler绑定。

尽管如此,如果你想使用open,你可以。它在ML中存在是有原因的,有些人觉得它很方便。我将其视为一个可选主题。

总结

本节课中,我们一起学习了ML模块系统的基础知识,以及如何将其用于命名空间管理。我们介绍了结构绑定的语法,通过示例创建了自己的模块,并理解了如何通过模块名.绑定名的方式访问模块内部的成员。我们还简要探讨了可选的open关键字,它允许直接使用模块内的绑定而无需前缀,但使用时需要注意潜在的命名冲突问题。模块系统是组织大型ML程序、避免命名冲突和实现代码封装的强大工具。

087:签名与信息隐藏 🧱

在本节课中,我们将学习如何为模块赋予类型,这些类型被称为签名。我们将从基本概念开始,然后展示签名最重要的用途:隐藏模块内部的信息,使其不被模块外部的代码访问。

签名基础

上一节我们介绍了模块结构,本节中我们来看看如何为模块定义类型,即签名。

你可以将签名视为模块的类型。就像我们称模块为“结构”一样,我们称其类型为“签名”。签名指明了模块中定义了哪些绑定,以及它们的类型是什么。

我们可以独立于任何特定模块来定义一个签名,然后声明某个模块具有该签名。以下是定义签名的方式:

signature MATHLIB =
sig
  val fact : int -> int
  val half : int -> int
  val double : int -> int
end

我们使用关键字 signature 来定义签名。按照惯例,签名名称通常使用全大写字母,但这不是强制要求。在 sigend 之间,我们列出绑定及其类型,就像 REPL 环境打印出的信息一样。

为结构赋予签名

现在,我们可以为之前定义的结构 MyMathLib 赋予 MATHLIB 签名。以下是具体做法:

structure MyMathLib :> MATHLIB =
struct
  fun fact x = ...
  fun half x = x div 2
  fun double x = x * 2
  val pi = 3.14
  fun doubler x = x + x
end

在结构名称和等号之间,我们使用 :> 符号后接签名名称。这样,只有当结构提供了签名中要求的所有内容且类型正确时,类型检查才会通过。如果结构缺少签名要求的绑定,或提供的绑定类型不匹配,类型检查器会给出明确的错误信息。

签名的核心用途:信息隐藏

签名的真正价值不在于记录模块中的所有内容,而在于隐藏实现细节。这是编写正确、健壮、可复用软件的最重要策略:向外部世界声明,我不希望你使用模块中定义的所有内容,我希望控制哪些是公开的,哪些是私有的。

在许多编程语言中,我们通过标记函数为 privatepublic 来实现这一点。在 ML 中,我们采用一种不同的、更优雅的方式:我们按照自己的意愿编写模块,然后在签名中只列出我们希望公开的部分。任何未包含在签名中的绑定,都无法在模块外部使用,但它们仍然可以在模块内部使用。

以下是实现信息隐藏的方法:

  1. 在签名中只列出希望公开的绑定。
  2. 结构可以包含比签名中更多的绑定(私有实现)。
  3. 外部代码只能访问签名中列出的绑定。

例如,如果我们从 MATHLIB 签名中移除 doubler,那么外部代码就无法通过 MyMathLib.doubler 来访问它,但 MyMathLib 结构内部的其他函数仍然可以调用 doubler

总结

本节课中我们一起学习了模块签名的概念与应用。我们了解到:

  • 签名是模块的类型,定义了模块必须提供的公开接口。
  • 使用 structure ModuleName :> SIGNATURE_NAME = ... 可以为结构赋予签名。
  • 签名的核心作用是信息隐藏:通过只在签名中列出公开的绑定,我们可以将实现细节封装在模块内部,从而创建更清晰、更安全的抽象,并提高代码的模块化和可维护性。

通过签名,我们能够有效地构建软件抽象,将“接口”与“实现”分离,这是模块化编程的基石。在接下来的课程中,我们将看到签名更强大的功能。

088:一个模块示例 🧮

在本节课中,我们将学习一个模块示例。这个示例将贯穿我们后续对模块系统的学习,因此理解它非常重要。在开始之前,我们先浏览一下代码。

这个示例将实现一个抽象数据类型。它只是一个模块,导出一个新的数据类型及其相关操作。为了简单起见,我们实现一个有理数的小型库。有理数包含分子和分母,两者都是整数。我们将支持的操作包括:创建分数、将两个分数相加,以及将它们转换为字符串。例如,数字“二分之三”在转换为字符串时会打印出“3/2”。

接下来,我将展示代码。首先,让我给出一个高层次概述。我将定义一个名为Rational1的结构(因为在后面的章节中,我会定义Rational2Rational3)。我将为有理数定义一个小型数据类型,它包含两个构造器:Whole用于整数(携带一个int)和Frac用于分数(携带两个int)。我定义了一个异常BadFrac,以防有人尝试创建分母为零的分数,因为这是未定义的,需要引发异常。我定义了函数make_frac用于创建分数,add用于将两个有理数相加,以及to_string用于将有数转换为字符串。

模块的实现将包含一些局部辅助函数,稍后我会展示它们的作用。在下一节中,当我们为这个结构体添加签名时,我们将选择隐藏这些辅助函数,这样外部世界就不需要依赖它们。

接下来,让我们详细查看代码。代码在这里。

它包含了我之前提到的数据类型定义和异常定义。稍后我们会详细讨论,这个模块的实现将维护几个不变量,因为它承诺了一些事情。我还没有告诉你的是,这个模块将确保我们总是返回最简形式的字符串。例如,我们永远不会返回字符串“9/6”,而是返回“3/2”;我们也不会返回“8/2”,而是返回“4”。

我们将通过几个辅助函数来实现这一点。关键的辅助函数是reducereduce函数接收一个有理数并返回一个有理数。它的作用是返回一个最简形式的有理数。如果输入是一个整数,就直接返回该整数,因为整数总是最简形式。否则,如果它是一个分数Frac(x, y),并且分子x为0,那么整个值就是0。0除以任何数都应该是0。请记住,我们不允许分母为零。

否则,我们需要计算xy的最大公约数。但我的gcd函数假设xy是正数。所以我会取x的绝对值。我的分母将始终是正数,这是模块要强制执行的另一个不变量。然后,如果最大公约数d能整除y,那么结果应该是一个整数x div d。否则,结果应该是分数(x div d) / (y div d)

我将让你选择相信我或检查我的算术,只要gcd实现正确,这个函数确实能将分数化为最简形式。gcd函数在这里。我不会说服你相信这个算法的正确性。但如果xy大于0,它会做正确的事情。信不信由你,这是一个有2000多年历史的递归算法,可以追溯到古希腊。因此,人类相当确信这个算法是正确的。这很酷。

这些只是辅助函数。现在,让我们讨论客户端将要使用的函数。我有make_frac函数,它接收xy。如果y为0,即尝试创建分母为0的分数,我将引发异常。如果y小于0,我不希望分母为负,这是模块要维护的不变量之一。因此,我将返回分数(-x, -y),这样y就变为正数,而x的符号将与传入时相反。然后我需要化简它,因为可能你调用make_frac时传入的是(9, 6)(9, -6),我希望返回3/2-3/2。否则,如果y为正数,我们就直接化简分数(x, y)。这样,我们创建的就是最简形式的分数。

如果我们想把两个分数相加,我们假设它们已经是最简形式,并确保结果也是最简形式。这是一个嵌套模式匹配的好例子。如果你有两个整数,就返回这两个整数的和。如果你有一个整数和一个分数,并且该分数已经是最简形式,那么加上一个整数后的结果也将是最简形式,我们可以直接返回(j + k*i) / k。如果你有两个分数,我们可以计算新分数为(a*d + b*c) / (b*d),但然后我们需要化简结果。同样,这是小学算术,但我知道我有时会忘记这些细节。如果你对具体的算术感到有点惊讶,这没关系,因为学习模块系统并不是为了这个。

最后,我们有这个打印字符串的函数。这非常有趣,因为我们保持所有有理数都是最简形式,所以我们可以直接打印它。如果它是一个整数,就把它转换成字符串。请注意,我们这里并不是真的在打印,我们只是在转换为字符串,然后REPL会打印出我们的结果,所以我一直说“打印”。如果是分数,就把a转换成字符串,连接一个斜杠,再把b转换成字符串。

让我展示一个使用所有这些功能的例子,然后我们再讨论一下我们模块的结构。

好了,我已经定义了我的整个模块。现在我可以写val x = Rational1.make_frac(9, 6)。然后写val y = Rational1.make_frac(-8, -2)。我在这里拼错了Rational1make_frac

好了。现在我可以写Rational1.add(x, y)。然后我可以立即写Rational1.to_string。我得到的结果是“11/2”,这实际上是“3/2”加上“4”的结果,而“4”是“-8/-2”的最简形式。这就是我作为客户端使用模块的方式。

这就是基本思想。我们看到了这里的结构:我们定义了数据类型、异常和这些公共函数make_fracaddto_string。现在我想更多地讨论抽象数据类型通常是如何用模块实现的,并专注于库在属性方面的规范以及它在不变量方面的实现。

我所说的属性,是指外部可见的保证,是库作者向库的任何客户端承诺的事情。我刚才展示的模块承诺了以下几点:

  1. 我们将禁止任何分母为0的情况。如果你试图创建这样的分数,我们将引发异常。你永远不会看到一个分母为0的有理数。
  2. 我们返回的所有字符串都是最简形式。例如,你会看到“4”,而不是“4/1”;你会看到“3/2”,而不是“9/6”。
  3. 除了禁止分母为0外,任何时候你调用函数,它都会终止,不会引发异常,并且总是产生正确答案。

这些是额外的属性。这不是一个完整的规范,但这些都是我想强调的、我们的库必须做的事情,即使它不在我们提供的函数(如addto_string)的类型中。

这些是外部世界应该关心的全部。在内部,我们的实现维护着一些重要的不变量。这意味着所有函数都必须保证这些额外的事情,否则其他函数可能会出错。外部世界不应该关心这些,这些是实现细节,但它们是贯穿模块的东西,以便一个函数可以依赖另一个函数正确地执行。

因此,我们要求的第一个内部不变量是:所有分母都是正数。外部世界可以用(-8, -2)创建一个分数,但我们会返回一个没有负分母的分数。我们所有的函数都可以假设这一点。

第二个内部不变量是:所有有理数值都已经化简。所以我们永远不会创建“9/6”,而是立即创建“3/2”。

让我强调一下,我们的函数在多个地方维护并依赖这些不变量。如果你回头看代码,你会看到几处体现。

例如,在make_frac中,当我们开始构建有理数时,它明确禁止分母为零,它有一个特殊情况来处理负分母(同时取反分子和分母),并且它立即对结果调用reduce,以确保我们创建的是最简形式的有理数。

然后,我们的add函数假设两个参数都是最简形式,并且实际上利用这一点,在一些不需要的情况下避免调用reduce。但在其他情况下,为了谨慎并确保它保持不变量,它确实调用了reduce

我们在其他几个地方也依赖这些不变量。gcd函数对于负参数是不正确的,而我们只在某些地方调用gcd,并且我们知道这些参数是非负的,因为我们的整个模块都维护着这个不变量。同样,在to_string中,我们向外部世界承诺总是返回最简形式,但to_string并没有检查这一点,也没有调用reduce,因为它依赖于模块其余部分维护的不变量。

这就是我们的示例。现在,我们想要做的是使用签名来确保客户端无法违反这些属性和不变量。

在本节课中,我们一起学习了一个有理数模块的完整示例。我们看到了如何定义数据类型、异常和公共函数,以及如何使用辅助函数来维护内部不变量(如分母为正和分数最简)。我们还讨论了模块对外承诺的属性,并理解了内部不变量对于实现正确性的重要性。在下一节中,我们将探讨如何通过签名来封装这个模块,隐藏实现细节并强制执行这些不变量。

089:为示例设计签名 🧩

在本节课中,我们将学习如何为上一节中创建的模块设计一个合适的签名。签名是模块的接口,它决定了外部代码可以看到和使用模块中的哪些部分。设计良好的签名对于封装实现细节、保护数据不变性至关重要。

概述

我们将从一个简单的签名开始,逐步改进它,最终展示如何使用抽象类型来完全隐藏数据的具体表示,从而强制外部代码只能通过我们提供的安全函数来创建和操作数据。这个过程将帮助我们理解如何利用签名来构建健壮的抽象数据类型。

初始签名及其问题

上一节我们介绍了一个有理数模块。根据目前的知识,一个自然的签名是隐藏我们不希望外部世界知道的两个辅助函数 gcdreduce

以下是一个可能的初始签名:

signature RATIONAL_A =
sig
  type rational
  exception BadFrac
  val make_frac : int * int -> rational
  val add : rational * rational -> rational
  val to_string : rational -> string
end

这个签名会通过类型检查。外部世界将无法直接使用 gcdreduce 函数。这看起来是个不错的开始。

但是,我们犯了一个关键错误。通过暴露数据类型的定义(即 type rational 的具体实现),客户端代码可以违反我们设定的所有数据不变性。他们能够以我们不希望的方式使用库,导致错误的结果和行为。

我们可以在注释中要求客户端永远不要直接构建 rational,而总是调用 make_frac,因为 make_frac 会检查条件并建立不变性。然而,经验表明,客户端并不总是遵守这些规则。如果我们的编程语言有办法强制这些规则,那会好得多。幸运的是,ML 语言确实有这样的机制。

暴露具体类型带来的问题

RATIONAL_A 签名下,关键问题是客户端能够直接调用 frac 构造函数。他们可以创建 frac (1, 0)frac (3, ~2)frac (9, 6) 这样的值。这些值的形式是我们的库函数假设不存在的。一旦它们存在,各种问题就会接踵而至。

让我们看几个例子。假设我们有一个结构 Rational1,它拥有上面所示的签名。

如果客户端正确使用 make_frac,例如 make_frac (1, 0),会得到异常 BadFrac,这是正确的行为。

但如果客户端不遵守规则,直接创建 frac (1, 0),程序会进入无限循环(可能与 gcd 函数有关)。我们不想深究原因,我们只想阻止客户端这样做。

另一个例子:如果客户端创建了分母为负数的 frac,算术运算可能会溢出,因为模块中的代码假设分母不会是负数。

最后一个更简单的例子:我们向客户端承诺,to_string 函数总是以最简形式打印分数。但如果他们直接对 frac (9, 6) 调用 to_string,结果将是 "9/6",因为我们的 to_string 代码假设其参数已经被约分。这同样是客户端能够违反的规则。

解决方案:抽象类型

我们想要防止上述问题。其核心理念是:一个抽象数据类型应该隐藏类型的具体表示。这样,客户端将永远无法在不通过我们的函数(如 make_frac)的情况下创建该类型的值。这样我们就能建立并维护那些不变性。

你可能会想,只需从签名中移除数据类型的定义即可。但这样不行,因为类型检查器看到 rational 类型时会说“我从未听说过这种类型”,并报错。

我们需要做的是告诉类型检查器:对于这个签名,rational 确实是一个类型,但我不希望客户端知道关于它的任何更多信息。这就是抽象类型的关键思想:你知道这个类型存在,但不知道它的定义。

在 ML 中,我们这样实现:在签名中,你可以只写 type 和类型名,后面没有等号和更多信息。这表示“类型存在,但外部世界不知道它是什么”。

以下是我非常喜欢的一个签名,我称之为 RATIONAL_B

signature RATIONAL_B =
sig
  type rational
  exception BadFrac
  val make_frac : int * int -> rational
  val add : rational * rational -> rational
  val to_string : rational -> string
end

这个签名告诉客户端关于 rational 他们可以知道什么:存在一个 rational 类型;存在一个 BadFrac 异常;make_frac 接收两个整数返回一个 rationaladd 接收两个 rational 返回一个 rationalto_string 接收一个 rational 返回一个字符串。

如果我们给结构 Rational1 加上这个签名,所有正确使用 make_frac 的例子仍然可以工作。但外部世界不再知道存在一个 FRAC 构造函数,因此他们将无法创建任何违反我们不变性的值。

这是一个非常重要的进步。现在,客户端没有任何办法可以违反我们的不变性。我们可以仔细研究上一节中的结构,结合这个签名,确信我们所有的属性都将始终成立。

论证的直觉如下:客户端如何创建一个 rational?他们必须从 make_frac 开始,因为这是他们创建 rational 的唯一途径。在拥有 rational 之前,他们无法调用 addto_string。我们可以研究 make_frac 的代码,确信它正确地建立了所有不变性(无零分母、无负分母、分数已约分)。之后,客户端只能对 rational 进行相加和转换为字符串的操作,而这些函数我们也确信是正确实现的。

对于外部世界,他们可以随意处理 rational 值:放入列表、传递给函数、放入元组。但他们能执行的、访问数据内部结构的唯一操作,就是我们库中提供的那些。

签名隐藏信息的两种方式

现在,我们拥有了两种使用签名向客户端隐藏信息的强大方法。

第一种是通过省略来隐藏:如果你在签名中省略了 val 绑定、函数绑定、构造函数等,那么对客户端来说,它们就根本不存在。

第二种是更复杂、更令人兴奋的通过抽象类型来隐藏:获取一个类型定义,告诉外部世界“是的,我定义了这个类型,但我不告诉你我是如何定义的”。这很重要,因为它允许我们说 make_frac 返回一个 rationaladd 接收两个 rational 并返回一个 rational,而无需揭示 rational 实际上是什么。

在后续关于模块的章节中,我们还会看到签名可以隐藏的其他内容。但这两点是希望大家始终记住的。

一个有趣的补充:部分暴露构造函数

在结束本节之前,我想展示代码文件中提供的第三个签名,这有点趣味性。

回顾我们模块的数据类型绑定:

datatype rational = Whole of int | Frac of int * int

导出 Frac 构造函数对我们的不变性是个问题,我已经展示了许多相关例子。但导出 Whole 构造函数是可以的,我们的库不介意任何整数通过 Whole 传入。根据我们特定的属性和不变性,你不会因此陷入麻烦。

实际上,我们可以导出它,并使用以下签名:

signature RATIONAL_C =
sig
  type rational
  val Whole : int -> rational
  exception BadFrac
  val make_frac : int * int -> rational
  val add : rational * rational -> rational
  val to_string : rational -> string
end

如果你用这个签名来检查我们已经定义的结构,ML 是允许的。这或许有点令人惊讶,原因是当 ML 看到这个数据类型绑定时,它会记得我们最初学习数据类型时就知道:这个绑定定义了一系列东西。它定义了一个类型 rational,也定义了一个类型为 int -> rational 的函数 Whole,以及一个类型为 int * int -> rational 的函数 Frac,同时允许 WholeFrac 在模式匹配中使用。

签名让我们可以隐藏一些东西,同时揭示另一些东西。在这个特定例子中,ML 允许我们暴露存在一个类型为 int -> rational 的函数 Whole,存在一个类型 rational,但同时隐藏数据类型绑定给我们的所有其他东西。这是 ML 语言的一个特点。我认为这不是语言中最重要的特性,但我觉得它很有趣。它强调了签名可以暴露一些内容并隐藏其他内容,并且我们有一种特定的方式可以让客户端多做一点事情:他们可以直接调用 Whole,而不必调用 make_frac 并传入第二个整数参数。

总结

本节课中我们一起学习了如何为模块设计签名。我们从暴露具体类型签名的问题出发,认识到客户端可能因此违反数据不变性。接着,我们引入了抽象类型的概念,通过在签名中只声明 type rational 而不提供其定义,彻底隐藏了数据的具体表示。这强制客户端只能通过我们提供的安全接口(如 make_frac)来创建和操作数据,从而保证了模块内部不变性的始终成立。最后,我们还看到了签名可以灵活地选择暴露部分构造函数(如 Whole),进一步展示了接口设计的灵活性。掌握这些技巧,是构建健壮、可维护的抽象数据类型的关键。

090:签名匹配规则详解 🧩

在本节课中,我们将学习模块系统中“签名匹配”的精确规则。签名匹配是类型检查器判断一个结构体是否满足某个签名要求的过程。我们将详细探讨其定义和各种情况。

概述

上一节我们介绍了抽象类型的概念。本节中,我们将更精确地定义“签名匹配”的规则。当一个结构体被赋予某个签名时,我们需要确保结构体以适当的方式提供了签名中声明的所有内容。

以下是签名匹配的核心规则,我们将逐一详细解释。

签名匹配的详细规则

签名匹配的过程是:给定一个结构体 foo 和一个签名 bar,我们需要检查 bar 中声明的每一项,foo 是否都以恰当的方式提供了它。

1. 完整类型定义

如果签名 bar 中包含一个完整的类型定义(例如数据类型的绑定 datatype 或类型别名 type),那么结构体 foo 必须提供完全相同的定义。

  • 结构体必须包含该数据类型的绑定。
  • 结构体必须包含该类型的所有构造函数。

2. 抽象类型

如果签名 bar 中只声明了一个抽象类型(例如 type rational),那么我们只需要检查结构体 foo 确实定义了这样一个类型。

  • 结构体可以通过类型别名(type)来定义它。
  • 结构体也可以通过数据类型绑定(datatype)来定义它。

3. 值绑定

如果签名 bar 中声明了一个值绑定(例如 val x : t),那么结构体 foo 必须提供这个值绑定。

  • 结构体可以通过 val 绑定来提供它。
  • 结构体也可以通过函数绑定来提供它。具体方式不重要,但必须提供。
  • 该绑定必须具有合适的类型。

这里有一个关键点:该绑定在模块内部的类型,不必与在签名中声明的类型完全相同,但两者必须存在合理的关联。

  • 更通用的类型: 在模块内部,一个值可以拥有比签名声明中更通用的类型。例如,在模块内部,一个函数可能具有类型 ‘a -> ‘a list,而在签名中,我们可以声明它为 string -> string list。由于 string -> string list‘a -> ‘a list 的一个特例(更不通用),这是允许的。
  • 抽象类型的影响: 模块内部的类型可能涉及具体的类型定义(例如知道 rational 的具体实现),但在签名中,这些类型可能被声明为抽象的。模块内部的具体类型必须与签名中声明的抽象类型兼容。

本质上,我们需要比较签名所声明的类型与模块内部经过类型检查和推断后得到的实际类型,判断两者之间的关系是否可接受。类型不必完全相同,但签名中暴露的类型必须是模块内部类型的一个合理、可能更具限制性的视图,以确保客户端能够安全地使用它。

4. 异常声明

如果签名 bar 中声明了一个异常(例如 exception BadRational),那么结构体 foo 必须声明这个异常。

5. 未提及的绑定

非常重要的一点是,结构体 foo 可以拥有签名 bar 中完全没有提及的额外绑定。上述规则只要求检查 bar 中声明的每一项,foo 额外定义的内容是完全允许的,并且会被隐藏起来。

总结

本节课中,我们一起学习了签名匹配的精确规则。我们了解到,判断一个结构体是否匹配一个签名,需要逐一检查签名中的类型定义(完整或抽象)、值绑定和异常声明,确保结构体以兼容的方式提供了它们。特别需要注意的是,结构体内部值的类型可以比签名声明的类型更通用,并且结构体可以包含签名未提及的额外内容。这些规则共同构成了模块系统类型安全的基础。

091:等价结构 🏗️

在本节课中,我们将学习抽象、签名和抽象类型的重要性,并通过展示与之前有理数结构具有相同签名的其他结构来加深理解。

概述

抽象、签名和抽象数据类型的一个核心目的是,允许同一功能的不同实现是等价的。所谓“等价”,是指任何客户端都无法区分你正在使用的是哪一个实现。如果能通过抽象保证客户端无法区分,那么你就可以用一个实现替换另一个。新实现可能添加了不改变旧功能的新功能,可能速度更快,或者你只是希望将选择哪个实现的决定推迟到以后,并保证在更改时客户端不会出错。

签名与等价性

上一节我们介绍了抽象和签名的概念,本节中我们来看看如何通过不同的结构实现等价性。

更容易实现的是,两个结构在揭示信息较少的签名下比在揭示信息较多的签名下更可能是等价的。我将通过两个例子来说明这一点,本段展示第一个例子,下一个例子将在后续课程中介绍。

本段将展示一个结构,它像我们之前的有理数结构一样,可以拥有我们之前见过的所有三种签名(Rational A、Rational B 或 Rational C)。在 Rational B 或 Rational C 签名下,它将与之前的结构等价;但在 Rational A 签名下,它不会等价。

新结构:rational2

让我说明这个结构有何不同。这是一个使用有理数的有趣例子,我稍后会展示代码。

struct rational2struct rational1 的不同之处在于,它将有理数保持为最简形式。它允许分子和分母是像 9 和 6 这样的值,但 toString 函数在返回字符串之前总是会进行约分。

以下是 rational2 的代码:

structure Rational2 =
struct
  exception BadFrac
  datatype rational = Whole of int | Frac of int * int

  fun make_frac (x, y) =
      if y = 0
      then raise BadFrac
      else Frac (x, y)

  fun add (Whole i, Whole j) = Whole (i + j)
    | add (Whole i, Frac (j, k)) = Frac (j + i*k, k)
    | add (Frac (j, k), Whole i) = Frac (j + i*k, k)
    | add (Frac (a, b), Frac (c, d)) = Frac (a*d + b*c, b*d)

  fun toString (Whole i) = Int.toString i
    | toString (Frac (x, y)) =
        let
          fun gcd (x, y) =
              if x = y
              then x
              else if x < y
              then gcd (x, y-x)
              else gcd (y, x)
          fun reduce (Frac (x, y)) =
              let val d = gcd (abs x, abs y)
              in Frac (x div d, y div d) end
        in
          case reduce (Frac (x, y)) of
              Whole i => Int.toString i
            | Frac (x, y) => (Int.toString x) ^ "/" ^ (Int.toString y)
        end
end

关键点在于:

  • make_fracadd 函数进行约分。例如,make_frac(4, 2) 返回 Frac(4, 2)add(Frac(2,3), Frac(1,3)) 返回 Frac(9, 9)
  • toString唯一使用 gcdreduce 辅助函数进行约分的函数。它在将有理数转换为字符串之前,先将其约分为最简形式。

根据我们的规范属性,这个实现仍然是正确的:它不允许分母为零,并且返回的字符串总是最简形式。

签名兼容性

这个新结构可以兼容我们之前见过的所有三种签名。

以下是三种签名的定义:

(* 签名 RationalA *)
signature RATIONAL_A =
sig
  datatype rational = Whole of int | Frac of int * int
  exception BadFrac
  val make_frac : int * int -> rational
  val add : rational * rational -> rational
  val toString : rational -> string
end

(* 签名 RationalB *)
signature RATIONAL_B =
sig
  type rational (* 抽象类型 *)
  exception BadFrac
  val make_frac : int * int -> rational
  val add : rational * rational -> rational
  val toString : rational -> string
end

(* 签名 RationalC *)
signature RATIONAL_C =
sig
  type rational (* 抽象类型 *)
  exception BadFrac
  val whole : int -> rational
  val make_frac : int * int -> rational
  val add : rational * rational -> rational
  val toString : rational -> string
end

Rational2 结构可以提供所有这些签名中要求的正确类型的值。对于 RationalBRationalC,类型 rational 是抽象的。

等价性分析

现在,让我们思考一个关键问题:如果有一个程序正在使用 Rational1,而我进入该程序,将所有对 Rational1 的使用替换为 Rational2,程序的行为会有所不同吗?

答案取决于我们给这些结构赋予哪个签名。

情况一:使用签名 RationalA

如果两个结构都使用签名 RationalA,那么客户端的行为可能不同

这是因为 RationalA 允许过多操作,它允许客户端直接使用 Frac 构造函数构建自己的分数。考虑以下客户端代码:

Rational1.toString (Rational1.Frac (9, 6))
  • 对于 Rational1(内部保持最简形式),直接返回 "9/6"(因为它不重新约分)。
  • 对于 Rational2toString 会先约分 Frac(9,6)Frac(3,2),然后返回 "3/2"

由于两个模块对相同的参数给出了不同的答案,它们不是等价的。在替换时需要非常小心。

情况二:使用签名 RationalBRationalC

如果使用签名 RationalBRationalC,那么没有任何对模块的使用会导致两个模块产生不同的结果。

这基本上源于 rational 类型是抽象的。给定类型是抽象的,两个模块都强制执行相同的属性(例如,通过 make_frac 创建,不允许零分母),并且对于相同的参数总是返回相同的结果(因为客户端只能通过 make_fracadd 创建有理数,而这些操作的结果在 toString 时都会被约分)。

因此,当你暴露的信息更少(特别是使类型抽象)时,用一个模块替换另一个模块变得更容易。我们现在已经看到了一个这样的例子。

一个重要注意事项

作为最后一点,你可能会想,在 Rational2 下,是否可以直接暴露 Frac 构造函数,因为 toString 会将事物约分为最简形式?答案是不可以

这仍然会允许负分母或零分母,因此,如果你暴露 Frac 构造函数,Rational2 对于大多数 Rational1 不正确的原因来说,也是不正确的。Rational2 只是由于其实现方式的偶然性,现在碰巧正确处理了其中的几个原因(例如,toString 输出总是最简的),但根本性的抽象违规问题依然存在。

总结

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

  1. 抽象的目的:允许功能的不同实现在客户端看来是等价的,从而实现灵活替换、性能优化或延迟决策。
  2. Rational2 结构:一个不内部保持最简形式,仅在 toString 时约分的有理数实现。
  3. 签名的影响:在暴露具体类型的签名(如 RationalA)下,不同实现可能不等价;而在使用抽象类型的签名(如 RationalB/RationalC)下,可以保证实现的等价性,使替换更加安全可靠。
  4. 核心结论:通过使用抽象类型和限制性更强的签名,我们可以隐藏实现细节,从而更容易地创建可互换的模块,这是构建健壮、可维护软件系统的关键。

092:另一种等价结构

在本节中,我们将学习有理数的第三种实现方式。只要保持类型抽象,这种实现将与之前介绍的两种实现完全等价。我们将通过改变有理数类型的实现来强调一个观点:当类型是抽象的时候,等价的结构可以自由地改变类型的实现方式。

给定一个包含抽象类型的签名,不同的结构可以实现该签名,同时以不同的方式实现该类型。这些结构可能等价,也可能不等价。接下来,我将展示一个等价的例子,即第三种实现 rational3。在 rational3 中,有理数类型将实现为 int * int 的类型同义词。

让我们开始详细讲解。

结构定义与签名匹配

以下是 rational3 的结构定义。请注意,这里使用了类型同义词 type rational = int * int。这意味着在该模块内部,rationalint * int 是相同的类型。然而,外部世界无需知道这一点。

在展示具体函数之前,我们先回顾一下三种签名的要求。

  • 签名 rational A:要求模块将 rational 实现为一个特定的数据类型。由于我们的 rational3 模块没有使用这种数据类型,如果尝试赋予它这个签名,类型检查器会拒绝。
  • 签名 rational B:要求模块定义一个 rational 类型,并提供 make_fracaddto_string 函数。我们的 rational3 符合这个签名,因为它将 rational 定义为 int * int,并提供了相应类型的函数。外部世界不会知道 rational 就是 int * int
  • 签名 rational C:除了包含 rational B 的所有内容外,还要求一个类型为 int -> rational 的函数 whole。我们将在最后展示如何实现它。

现在,让我们看看 rational3 是如何实现的,以及它如何能够拥有 rational B 签名。

核心函数实现

rational3 中,所有有理数都只是整数对。以下是关键函数的实现细节。

以下是具体函数的实现步骤:

  1. make_frac 函数

    • 如果分母为 0,则引发异常。
    • 如果分母 y 小于 0,则返回 (~x, ~y)
    • 否则,返回 (x, y)
    • 注意:这里没有像之前那样特殊处理整数(即分母为 1 的情况),我们直接返回 (x, 1)
  2. add 函数

    • 该函数接收两个 rational 类型(即 int * int)的参数。
    • 我们使用模式匹配 (a, b)(c, d) 来提取分子和分母。
    • 计算结果为 (a*d + c*b, b*d)
    • rational2 类似,我们选择暂不约分,等到 to_string 时再处理。
  3. to_string 函数

    • 如果分子 x0,则返回字符串 "0"
    • 否则,需要利用最大公约数(GCD)进行约分。
    • 使用辅助函数 gcd 计算 abs xabs y 的最大公约数。
    • 令约分后的分子 num = x div gcd,分母 den = y div gcd
    • 返回的字符串格式为:Int.toString(num) 连接上(如果 den=1 则为空字符串,否则为 "/" ^ Int.toString(den))。

综上所述,对于签名 rational B,我们提供了所有正确类型的绑定:make_frac 返回 int * intadd 接收两个 int * int 并返回 int * intto_string 接收 int * int 并返回 string。外部世界并不知道 rational 就是 int * int

实现扩展签名 rational C

如果我们希望 rational3 也能实现签名 rational C,我们还需要一个类型为 int -> rational 的函数 whole

在之前使用数据类型的结构中,数据类型绑定本身隐式提供了这个构造函数。但在新的结构中,我们没有数据类型绑定,因此没有自动生成的函数。不过,我们仍然可以通过显式定义来实现这个签名。

以下是如何定义 whole 函数:

fun whole i = (i, 1) : rational

这个函数接收一个整数 i,并返回一个有理数 (i, 1)。它完全满足签名 rational C 的要求。

签名匹配的深入探讨

当我们将 rational3 结构与 rational Brational C 签名进行匹配时,有几个有趣的点值得强调。

首先,关于 make_frac 的类型。在模块内部,它的类型是 int * int -> int * int。这可以匹配签名中声明的 int * int -> rational 类型,因为 rational 在模块内就是 int * int。客户端永远无法察觉我们返回的类型与参数类型在内部是相同的,因为客户端不知道它们是同一种类型。

一个有趣的现象是,我们理论上可以(但极其不明智地)在签名 rational B 中将 make_frac 的类型声明为 rational -> rational。类型检查器会接受这个签名匹配,但这样的结构将变得毫无用处。因为外部世界只知道这些绑定,却永远无法获得第一个 rational 来调用 make_fracaddto_string 函数。这说明了存在一些能通过类型检查但毫无用处的程序。

其次,关于 whole 函数的类型推断与匹配。根据类型推断规则,我们定义的 fun whole i = (i, 1) 在模块内部会具有多态类型 'a -> 'a * int。然而,在签名 rational C 中,我们要求它的类型是 int -> rational

类型检查器在签名匹配时,能够将多态类型实例化为一个具体的、灵活性较低的类型。它首先推断出 'a 可以是 int,因此该函数也可以具有 int -> int * int 这个类型。然后,由于模块内的 rational 就是 int * int,它就能像往常一样,将这个内部类型与签名中的 int -> rational 类型进行匹配。ML 语言能为我们完成这个匹配,这非常巧妙。

需要注意的是,whole 函数并不具有像 'a -> int * intint -> 'a * int 这样的类型。当你将一个泛型(多态)类型变得不那么通用时,必须将所有类型变量一致地替换为另一个类型。在模块内部,我们实际上可以用字符串调用 whole(返回 string * int),但在模块外部,由于签名的限制,我们只能用它接收整数,因此它返回的将是一个 rational

总结

本节课中,我们一起学习了有理数的第三种实现 rational3。其高层次的核心观点是:在 rational Brational C 签名下,尽管 rational3 以完全不同的方式(使用类型同义词 int * int)实现了 rational 类型,但它与之前两种实现是等价的。这充分展示了抽象类型如何隐藏实现细节,允许不同的模块在满足相同接口的前提下,采用各自不同的内部数据表示方式。我们还深入探讨了签名匹配过程中关于类型推断和多态类型实例化的有趣细节。

093:不同模块定义不同类型 🧩

在本节课中,我们将完成对模块系统的学习,重点澄清一个关于多个结构共享同一签名时的常见误解。

上一节我们探讨了签名和结构的基本关系。本节中,我们来看看当多个结构实现同一个包含抽象类型的签名时,会发生什么。

核心概念

多个结构可以实现同一个签名,即使该签名中包含抽象类型。然而,每个结构的具体实现都会引入一个全新的、彼此不同的类型

代码示例与分析

以下是演示这一概念的代码。我们定义了三个结构(Rational1Rational2Rational3),它们都实现了同一个签名 RATIONAL_C

(* 签名定义 *)
signature RATIONAL_C =
sig
  type rational
  val make_frac : int * int -> rational
  val to_string : rational -> string
end

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/uwash-cse341-prog-abc/img/406d449ff4f890abc90140c4ac48e91b_2.png)

(* 结构1:使用约分实现 *)
structure Rational1 : RATIONAL_C =
struct
  type rational = int * int
  fun make_frac (x, y) = ... (* 约分逻辑 *)
  fun to_string (x, y) = Int.toString x ^ "/" ^ Int.toString y
end

(* 结构2:不约分实现 *)
structure Rational2 : RATIONAL_C =
struct
  type rational = int * int
  fun make_frac (x, y) = (x, y) (* 不约分 *)
  fun to_string (x, y) = Int.toString x ^ "/" ^ Int.toString y
end

(* 结构3:可能使用不同内部表示 *)
structure Rational3 : RATIONAL_C =
struct
  datatype rational = Frac of int * int
  fun make_frac (x, y) = Frac (x, y)
  fun to_string (Frac (x, y)) = Int.toString x ^ "/" ^ Int.toString y
end

每个结构都可以独立使用,并正常工作:

Rational1.to_string (Rational1.make_frac (9, ~6)); (* 返回 "-3/2" *)
Rational3.to_string (Rational3.make_frac (9, ~6)); (* 返回 "-3/2" *)

关键限制:禁止混合使用

以下是核心限制,初学者务必理解:

你不能在不同结构之间混合使用其值或函数。 尝试这样做将无法通过类型检查。

例如,以下代码是错误的:

Rational3.to_string (Rational1.make_frac (9, ~6)); (* 类型错误! *)
Rational1.to_string (Rational2.make_frac (9, ~6)); (* 类型错误! *)

原因解析

为什么不允许混合使用?原因如下:

  1. 类型不同:尽管签名相同,但每个结构定义的 rational 类型都是独立的抽象类型。例如:

    • Rational1.to_string 的类型是 Rational1.rational -> string
    • Rational2.to_string 的类型是 Rational2.rational -> string
      编译器视 Rational1.rationalRational2.rational 为完全不同的类型,就像 intstring 不同一样。
  2. 维护抽象与不变性:这是最重要的原因。每个结构可能维护着不同的内部不变性。例如,Rational1 保证分数总是约分的,而 Rational2 则不保证。如果允许将 Rational2 创建的未约分分数传给 Rational1 的函数,就可能破坏 Rational1 所依赖的内部假设,导致输出错误结果(如期望得到“-3/2”,却打印出“-9/6”)。

总结

本节课中我们一起学习了模块系统的一个关键特性:

  • 多个结构(模块)可以实现同一个签名。
  • 每个结构都会定义自己独有的抽象类型,即使它们签名相同。
  • 禁止在不同结构之间混合使用其值和函数,这是类型系统强制执行的规则。
  • 这一规则至关重要,它确保了:
    • 抽象边界不被破坏。
    • 每个库内部维护的不变性和属性得到遵守。
    • 类型安全得以保障,就像防止将整数误用作字符串一样。

理解这一点,对于构建可靠、模块化的ML程序至关重要。

094:函数等价性 🔄

在本节中,我们将探讨最后一个重要主题:如何更深入、更精确地理解两个函数何时是相同的,或者说它们是等价的。

仔细审视这个概念非常重要。本节不会介绍新的编码习惯、巧妙技巧或语言结构,但它是软件工程中的一个基本思想,值得重点关注。

我们将看到,如果你非常小心,并且知道两个事物等价意味着什么,那么你可以用一个函数替换另一个函数。我们还会发现,当你使用更多抽象、产生更少副作用时,函数更可能等价。如果你能假设其他计算不会执行诸如改变引用或打印输出等操作,那么更多事物将是等价的。

让我解释一下为什么要探讨这个主题。我相信开发者们在编程时总是在思考等价性。

当你维护代码并认为“我有一个更优雅的方式来表达这个”时,你实际上是在说表达相同或等价的事物。没有人能察觉到你进行了代码清理。

在考虑向后兼容性时,你也在思考这个问题:我能否在不改变软件对任何旧功能行为的情况下添加新功能?对于所有旧的可能性,它的行为仍然等价。

代码优化,无论是手动编写代码时还是实现语言时,都关乎等价性:我能否在不改变其在任何输入上行为的情况下加速这段代码?

最后,在研究模块系统时,我们也涉及了这一点:外部客户端能否察觉到我用另一个实现替换了这个实现?😊

现在,我们在这里要探讨的并不一定与模块或抽象类型相关。相反,我们将提出:这里有两个函数,对于所有可能的调用,它们是否等价?

例如,我可能正在实现一个库。😡 我不知道库的所有客户端。我可能将这段代码发布到互联网上供人们使用。我需要能够思考:我能否用另一个函数替换这个函数,而任何对这些函数的调用都无法察觉?这就是等价性的核心。

现在我们需要定义两个函数行为相同意味着什么。我会说,如果给定等价的参数,它们具有相同的可观察行为,即满足幻灯片上列出的所有要点。

显然,它们需要始终返回相同的答案。如果一个函数输入3返回7,另一个输入3返回8,这就不行。但这还不够。

它们还必须具有相同的非终止行为。如果一个函数在输入9时不终止,另一个也必须在输入9时不终止。同样,它们必须在所有相同的参数上终止。

它们对可变引用的任何影响(程序其他部分可见的)必须相同。如果一个函数将引用更新为与另一个函数不同的值,那么在调用完成后,程序中的其他代码可能能够察觉你替换了第一个函数。

它们必须具有相同的输入输出行为。我们不能让一个函数打印某些内容,而另一个不打印相同的内容。

它们必须引发相同的异常。我们不能让一个函数在某种情况下选择引发异常,而另一个函数不引发。

我可能还遗漏了一些内容,但这是一个相当全面的列表。

现在请注意,如果这些函数的用户不能使用太多参数,那么两个函数更容易等价。例如,如果你有一个强大的类型系统,它将确保这些函数只接受 string * string 作为参数,我们就不必考虑如果有人传递一个 int 会发生什么,因为不会有这样的调用触及它。

我们还将看到,如果我们的语言是函数式语言,产生副作用的方式更少,那么两个函数更容易等价。有时人们甚至假设你不会产生副作用,即使语言允许。我马上会给你展示一个例子。

好的,让我们用几个例子来结束这一部分,然后我们将继续讨论函数等价性的一些更普遍的概念。

在顶部这里,我有两个等价的函数 F。

左边的函数接受一个参数 x,返回 x + x。右边的函数接受一个参数 x,返回 y * x,这是在 y 绑定为 2 的环境中定义的。这两个函数总是将其参数加倍。它们没有其他副作用,总是终止,在任何方面都是等价的。使用左边函数的任何程序都无法察觉你是否将其替换为右边的函数,反之亦然。这是一个很好的例子。

这里有一个例子,你可能会惊讶地发现这两个函数并不等价。我们对假设不够小心。

左边的函数 G 接受一个函数 F 和一个参数 x,返回 F(x) + F(x)。右边的函数将 F(x) 乘以 y,而 y 绑定为 2

假设 F 是一个在给定相同参数时总是返回相同结果的函数,那么它们将始终返回相同的答案。但左边的代码调用了 F 两次,而右边的代码只调用了一次 F。如果 F 可能有副作用,这就是一个问题。

假设它每次调用都递增一个引用。那么左边的代码将使该引用增加2,而右边的代码只增加1。一个更简单的例子是,如果 F 总是打印输出,比如像这里的这个函数:如果你传递这个函数作为 F,它会打印“hi”然后返回其参数。左边的代码会打印两次“hi”,右边的代码会打印一次“hi”。

因此,当你在函数式编程语言中时,我们通常有不做这类事情的函数。如果你假设这些事情不会发生,那么这些函数就是等价的。像 Haskell 这样的语言是一个很好的例子,它强制纯函数式编程的概念。语言中的大多数函数(那些你可以像这样传递给其他函数的函数)不能执行打印输出等操作。因此,在像 Haskell 这样的语言中,左边和右边的相应代码是等价的。所以,这可以说是避免代码中副作用的另一个优势。

让我们再看一个例子,这里有几行代码,实际上非常简单。

左边的函数 F 假设在我们的环境中有一些函数 GH。它用 x 调用 G,用 x 调用 H,并返回结果对。

右边的代码完全相同,只是它以相反的顺序调用 HG。因此,与前面的例子不同,这里没有调用次数的问题。两者都调用 G 一次,调用 H 一次。那么它们等价吗?

同样,如果 GH 是纯函数,没有副作用,它们只是计算并返回结果,那么是的,它们是等价的。

但如果 GH 可能有副作用,那么不一定等价。

假设 G 打印一些内容,H 也打印一些内容。那么左边的代码将按一种顺序打印这些输出,右边的代码将按相反的顺序打印。

这是另一个例子。假设 G 设置某个可变引用,而 H 读取同一个可变引用。那么在左边的代码中,H 将看到 G 写入后的新值;而在右边的代码中,H 将在 G 执行写入之前看到值,因为 HG 之前执行。

因此,一旦你有了突变、副作用和打印,我们突然就需要担心执行顺序,不同的顺序会导致函数不等价。但如果我们坚持函数式风格,不编写有副作用的函数,那么我们就不必担心顺序,我们可以以任意顺序执行这些函数。

最后一个需要注意的地方是:如果 G(x)H(x) 都引发异常,并且引发不同的异常,那么顺序可能再次变得重要。左边的代码会引发一个异常,而右边的代码会引发另一个异常。

在本节课中,我们一起学习了函数等价性的概念。我们了解到,判断两个函数是否等价,需要检查它们是否在相同输入下返回相同结果、具有相同的终止行为、对可变状态产生相同影响、具有相同的输入输出行为以及引发相同的异常。我们还看到,在函数式编程中,由于副作用更少,函数更容易被证明是等价的,这为代码优化、重构和维护提供了更大的灵活性。理解等价性有助于我们编写更可靠、更易于推理的代码。

095:标准等价性 🧩

在本节课中,我们将学习编程语言中一些广为人知的函数等价性。这些是编程语言设计者深入研究并充分理解的概念,涉及两个函数何时等价,以及在确保遵循所有假设时需要注意的事项。我们将通过几个例子来探讨这些等价性,并指出其中的微妙之处。

概述

本节将介绍几种常见的函数等价情况,包括语法糖的定义、变量重命名、辅助函数的使用、不必要的函数包装以及 let 表达式与函数的关系。我们将逐一分析这些情况,并指出在特定条件下可能出现的例外。

语法糖等价性 🍬

首先,我们来看语法糖的等价性。根据定义,如果某个结构是另一种结构的语法糖,那么它们总是等价的。例如,表达式 E1 andalso E2if E1 then E2 else false 的语法糖。

以下是一个例子:左边的函数接受参数 x 并计算 x andalso g x(假设 g 已在环境中定义)。这个函数总是可以替换为右边的形式:if x then g x else false

(* 左边:使用 andalso *)
fun f x = x andalso g x

(* 右边:使用 if-then-else *)
fun f x = if x then g x else false

虽然右边的写法风格不佳,但语言实现可能会这样做,以便只需实现 if-then-else 结构,而无需重复实现 andalso 的代码。反之亦然,如果你看到右边的形式,用左边的形式替换是更好的风格。

这里需要注意求值顺序。andalso 只有在先求值 x,且仅在 xfalse 时才求值 g x 的情况下才是语法糖。因此,左边的代码与这里看到的右边版本并不等价,因为右边的版本总是调用 g,而左边的版本仅在 xfalse 时才调用 g。这是我们的第一个例子,它实际上是基于定义的。

变量重命名 🔄

接下来,我们来看变量重命名的等价性。这是编程语言设计者深入理解并努力确保成立的三个等价性之一,通过正确处理变量等方式实现。其核心思想是,你应该能够重命名函数中的变量而不引起问题。

左边的代码接受参数 x,返回 x + y + x,即 2*x + y。右边的代码与左边完全相同,只是将函数参数名从 x 改为了 z,因此返回 z + y + z。我们确实希望这两者是等价的,因为语言不应该让调用者能够察觉到参数名的改变,比如你在清理别人的代码时决定换一个更好的变量名。

(* 左边:参数名为 x *)
fun f x = x + y + x

(* 右边:参数名重命名为 z *)
fun f z = z + y + z

然而,有时人们会粗心地认为可以将参数重命名为任何名称,并且总是有效。以下是两个微妙的错误情况,实际上不能这样做。

首先,假设我们取左边的代码(与上面相同),但将 x 替换为 y。如果天真地这样做,你会得到函数 f 接受参数 y 并返回 y + y + y。这不是同一个函数:右边的函数将其参数乘以3,而左边的函数返回其参数的两倍加上 y

(* 错误示例:将参数重命名为已存在的变量名 *)
fun f x = x + y + x
(* 错误重命名为: *)
fun f y = y + y + y (* 这改变了函数含义! *)

这里的错误在于,如果你将参数重命名为函数体中已用于引用外部变量的名称,就会引入之前不存在的变量遮蔽,导致函数不等价。

另一个相关的微妙之处是,也许 y 不是环境中已有的变量,而是一个局部定义的变量。例如,左边的函数定义了一个局部变量 y 为3,然后返回 x + y。我们知道这个函数只是返回其参数加3。右边的代码总是返回6,这显然不同。尽管我做的只是将每个 x 替换为 y,但这里的 x + y 中的 x 现在指向了被遮蔽的 y(即3),而不是参数。因此,你必须小心处理这类问题,不要重用已用于其他目的的变量名。

(* 左边:使用局部变量 y *)
fun g x =
    let val y = 3
    in x + y end

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/uwash-cse341-prog-abc/img/48830623c420b152d6825c1dc3c815a8_5.png)

(* 错误重命名后: *)
fun g y =
    let val y = 3
    in y + y end (* 含义改变! *)

辅助函数的使用 🛠️

现在,我们来看关于是否使用辅助函数的等价性。事实证明,你应该总是能够选择是否使用辅助函数,而调用者无法察觉。

左边的代码返回其参数的三倍加上14。右边的代码也总是返回其参数的三倍加上14,但它通过使用辅助函数 f 来实现。右边的代码调用 f 作为辅助函数,而左边的代码不使用 f。如果我将部分函数体移出并放入一个正确使用的辅助函数中,这应该无关紧要。

(* 左边:不使用辅助函数 *)
fun g x = 3*x + 14

(* 右边:使用辅助函数 *)
fun f z = z + z + z
fun g x = f x + 14

这一切都很好,但你必须小心,当你这样做时,所有变量仍然指向它们之前所指的内容。在这个稍有不同的例子中,左边的代码实际上返回其参数的三倍加上7,因为存在被遮蔽的 y。右边的代码使用了一个辅助函数 f,这个辅助函数 f 与上面的代码完全相同。但现在它的含义不同了,因为 f 中的 yg 中的 y 不是同一个 y。因此,如果我将 z + y + z 替换为 f z,我最终会使用14,而本应使用7。你可以通过输入 REPL 来验证,左边的函数 g 和右边的函数 g 根本不相同。

(* 左边:y 被遮蔽为 7 *)
fun g x =
    let val y = 7
    in x + x + x + y end

(* 右边:尝试使用辅助函数,但 y 的引用出错 *)
fun f z = z + z + z
fun g x =
    let val y = 7
    in f x + y end (* 这里 f 中的 y 是外部环境的 y,不是 7 *)

不必要的函数包装 📦

最后,我们讨论不必要的函数包装。我们实际上已经多次提到过这一点,现在在讨论等价性时再次提及是很好的。

左边的函数 g 接受一个 y 并返回 f y。正如我已经强调过几次的,定义 g 的一个更简单的方法是直接说 val g = f。它们都是接受一个参数并返回 f 主体结果的函数,在这个例子中是使参数加倍。

(* 左边:不必要的包装 *)
fun g y = f y

(* 右边:直接绑定 *)
val g = f

这里的微妙之处在于需要小心。当被调用的函数只是一个变量时,这是没问题的。但如果相反,我们有一个需要求值才能得到函数的表达式,那么是否使用函数包装会影响我们执行某些操作的次数。如果你有副作用,比如打印或可变引用,这可能会产生影响。

让我展示两个不等价的例子。在左边,f 只是使其参数加倍。h 是一个零参数函数,接受 unit,当你调用它时,打印出“hi”,然后返回函数 f。所以这个函数 g y,每次你调用 g,它都会调用 h,打印“hi”,然后取结果并用 y 调用它。因此,函数 g 每次被调用时都会打印“hi”,然后使其参数加倍。

右边的代码以另一种方式定义 g,直接说 val g = h ()。这将做的是:我们会调用一次 h,打印“hi”,然后返回 f。因此,val g 绑定到 fgf 是同一个函数。所以右边的代码会在你调用 g 之前打印一次“hi”,并且再也不会打印。

让我再说一遍:右边的代码会打印一次“hi”,并且再也不会打印。它们都使参数加倍,左边的代码在你调用 g 之前根本不打印,但每次调用 g 时都会打印。因此,如果 h 可能有这样的副作用,它就会产生影响,它们不相同。

(* 左边:每次调用都打印 *)
fun f x = x * 2
fun h () = (print "hi"; f)
fun g y = (h ()) y

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/uwash-cse341-prog-abc/img/48830623c420b152d6825c1dc3c815a8_9.png)

(* 右边:只打印一次 *)
fun f x = x * 2
fun h () = (print "hi"; f)
val g = h ()

let 表达式与函数的关系 🔗

最后一个例子,我有点喜欢它,因为它有助于解释 let 表达式和函数。我们之前也提到过一次,但现在讨论等价性时重复一下是很好的。

我声称左边的代码和右边的代码总是会做同样的事情。原因如下:左边的代码按以下方式求值:将 e1 求值为一个值,扩展环境使 x 映射到该值,然后求值 e2,这就是你的答案。

右边的代码呢?嗯,这已经是一个值在左边,所以将 e1 求值为一个值。然后在 x 绑定到 e1 结果的环境中求值 e2,这就是我们整个的答案。左边和右边执行完全相同的步骤序列。它们都先求值 e1,然后在相同的扩展环境中求值 e2,然后返回 e2 的结果。所以它们不可能不等价。

你确实可以认为这些是相同的表达式,就好像其中一个是另一个的语法糖。在 ML 中,有一个类型系统差异,即左边的代码可以给出多态类型,右边的代码永远不会给出多态类型。因此,有些程序左边的版本类型检查通过,而右边的版本不通过。但这是 ML 类型系统的一个细节,对于任何确实都通过类型检查的表达式,它们是等价的,并且总是产生相同的结果。

(* 左边:let 表达式 *)
let val x = e1
in e2 end

(* 右边:立即执行的匿名函数 *)
(fn x => e2) e1

总结

在本节课中,我们一起学习了五种常见情况下函数等价性的例子,以及一些需要考虑变量遮蔽或副作用等微妙之处的场景。如果你理解了这些微妙的区别,那么你应该在非常基础的层面上理解了变量的作用和函数的含义。这些概念并非 ML 语言特有,而是在任何使用变量和函数进行编程时都应该成立的原理。掌握这些等价性有助于编写更清晰、更可靠的代码,并深入理解编程语言的设计。

096:函数等价性与性能 🚀

在本节课中,我们将要学习函数等价性定义中一个重要的方面:性能。我们将探讨为什么在编程语言的定义中忽略性能,以及这种做法的优点与局限性。同时,我们还将介绍计算机科学中其他几种衡量“等价”或“优劣”的方式,帮助你建立更全面的分析视角。


上一节我们介绍了函数等价性的基本定义,本节中我们来看看性能因素在其中扮演的角色。

需要明确的是,性能差异可能非常巨大。例如,在之前学习列表表达式效率时,我们曾见过一个计算列表最大值的例子。虽然当时没有使用模式匹配,但核心思想相同。左侧的代码在某些情况下可能非常慢,对于长度为50的特定列表,其计算时间甚至可能长达数个世纪。而右侧的代码总是很快,其运行时间与列表长度成正比。

然而,我们的函数等价性定义只关注:相同的副作用、相同的终止行为,以及对任何参数都产生相同的结果。从技术上讲,左侧的代码只要愿意等待足够长的时间,最终也会终止。因此,根据我们的定义,这两段代码是等价的。

我承认在现实世界中,它们并不等价。其中一个在很多情况下是有用的,而另一个则不是。但我们的定义说它们是等价的。那么,为什么这是一个优点,同时又是一种局限呢?


我喜欢这样理解:在计算机科学中,存在许多关于“等价”的定义。具体来说,有三种定义我经常使用。只要在合适的场景下使用,拥有三种定义是完全可以接受的。当我需要在特定层面思考时,我会选择相应的定义。

以下是三种主要的等价性定义:

  1. 编程语言等价性:这是我们一直在学习的定义。它关注的是给定相同输入时,是否产生相同的输出和副作用。这是一个优点,因为它允许我们将之前幻灯片上那个糟糕的 max 函数版本替换为好的版本,并声称没有破坏任何客户端代码,因为我们做了一个“等价”的替换,并且现在性能更好了。性能本身并不属于“两者相同”这一定义的一部分。当然,它也有坏处,因为它也允许你将好的版本替换为坏的版本。因此,只要恰当地使用这一定义来证明好的改动,而不是坏的改动,它就是完全合理的。

  2. 渐近等价性:如果你学习数据结构或算法课程,会研究这种不同的等价性。它关注算法,分析其运行时间或空间等性质与输入规模的关系,并且忽略小规模输入,只关心当输入变得非常大时,运行时间如何随输入变化而成比例地反应。这对于研究算法、理解为什么一个 max 版本比另一个更好非常有用。它是一种非常强大的、用于比较事物优劣(优劣本身也是一种等价关系)的定义。你同样应该熟悉这个定义来研究算法。它仍然有局限性,因为它认为如果一个程序的版本总是比另一个快四倍或九倍,那么这些程序是“相同”的。虽然我们可能更喜欢快九倍的那个,但这个定义说它们相同。这种局限性与编程语言定义的局限性类似,只是程度较轻。

  3. 系统/实践等价性:第三种定义则认为,实际考虑也很重要。我们应该考虑这些因素。当我对系统进行性能调优,或者关心它在现实世界典型工作负载下的表现时,我应该注意确保不会换入一个表现有根本性差异的实现。也许10%的偏差是可以接受的——对某些输入快一点,对另一些输入慢一点——但我期望系统行为大体相同。因为我处理的是一个正在运行的系统之类的东西,我应该只进行那些不会对性能产生巨大影响(无论是好是坏)的代码更改。这种更细粒度的等价性概念的局限性在于,它往往侧重于你已经研究过的输入。它不研究通用算法(可能针对你尚未见过的输入),也不研究通用的等价概念(可能针对你从未见过的代码库客户端)。例如,如果你只关注列表长度不超过20时的系统等价性,那么这两个 max 版本可能看起来相同。而你没有意识到它们对于你尚未见过的输入实际上表现迥异,这就是你思维的一个局限。


我欣赏所有这些定义。我的观点是,软件开发和计算机科学家几乎每天都会用到所有这些定义。当你思考正在处理的问题时,你是在与抽象打交道,而这些抽象允许不同的实现——这正是编程语言等价性的核心。你在思考算法,以及某些东西在所有情况下是否通用且高效——这关乎中间那种定义。然后你还在思考你的软件在实践中将如何表现,是否存在需要调优和改进的输入——这更偏向于系统视角。没有哪一种定义比另一种更好,它们都是心智工具、智力工具,是我们当今世界用来推理复杂软件所必需的。


总结:本节课中我们一起学习了函数等价性定义中忽略性能的原因及其双重性。我们探讨了编程语言等价性、算法中的渐近等价性以及实践中的系统等价性这三种不同的视角。理解这些不同层面的“等价”概念,能帮助我们在软件开发和系统分析中做出更明智的决策。

097:A部分总结与B、C部分预览 🎉

在本节课中,我们将总结编程语言课程A部分的核心内容,并预览即将在B部分和C部分中学习的激动人心的主题。

我们已经完成了编程语言课程A部分的学习。祝贺大家坚持到这里。接下来,我们将简要总结和回顾已完成的内容,并解释这些内容如何与即将在B部分和C部分中出现的精彩内容相结合。

A部分内容回顾 📚

我们确实完成了大量学习,相信大家为此付出了巨大努力。A部分的内容可以划分为四个主要章节。

以下是各章节的核心内容概述:

  • 第一部分:基础与核心概念。这部分主要奠定了编程的基础,包括函数、递归,以及每个语言结构都包含语法求值语义类型检查规则这三个核心概念。
  • 第二部分:数据类型与模式匹配。这部分重点介绍了数据类型的定义和模式匹配。同时,我们也学习了尾递归的重要性。
  • 第三部分:一等函数与闭包。这部分全面探讨了一等函数和闭包。这为我们提供了必要的背景,让我们能够退一步解释本课程的动机,以及我们为何以这种方式来讲解编程语言材料。
  • 第四部分:类型推断与模块。我们刚刚完成了关于类型推断的学习,以揭示其原理而非魔法。这让我们能够将“是否显式书写类型”与“语言是否具有类型检查规则”这两个概念分离开来。接着,我们学习了模块,以及更广义的概念:只要客户端无法区分,两种实现就可以被认为是等价的。

将所有这些内容整合起来,我们得到的是一个关于函数式编程的精确介绍。它涉及许多独立的组成部分,无论是case表达式、签名还是尾递归的概念,它们协同作用,产生了巨大的合力。这些编程语言中众多小而精的特性,组合在一起使我们能够完成相当多的编程任务。

在本课程中,我们主要进行小规模编程,不涉及大型项目。但我希望大家相信,这些思想——无论是“不改变数据”这样的概念性思想,还是“根据模式匹配的不同分支来组织代码”这样的具体实践——确实能够扩展,并让我们以一种令人惊讶的愉快方式构建健壮、优雅的软件。

B部分与C部分预览 🚀

我相信,随着我们继续学习课程的B部分和C部分,大家将能看到我们反复运用在此处学到的所有知识。我们将不仅仅是在其他几种编程语言中重复相同的信息,而是以此为基础进行构建和补充,在我们尚未学习的所有内容中,继续运用高阶函数、模式匹配和数据类型等思想。

为了鼓励大家继续学习B部分,让我简要介绍一下在完成A部分后,B部分将要学习的内容。

B部分包含两个主要章节,因此只有两次作业。然而,第二次作业通常是大家觉得最具挑战性和最有收获的。在这一部分,我们将使用Racket语言。

以下是B部分的核心内容:

  • 第一章节:动态类型语言中的函数式思想。我们将看到许多在ML中学到的思想,在一个动态类型语言中重新实现。语法会非常不同,但语法只是语法。最大的区别在于我们将没有类型系统,不会有类型来指导我们编写的函数。尽管如此,我们仍会看到许多相同的东西。我们不会仅仅重复所有内容,那将不太有趣。我们还将重点关注延迟求值的编程习惯,基本上是学习使用零参数函数的重要性,以及这如何成为一种非常强大的编程模式,即使你没有向被调用者传递任何数据。这部分内容我们几乎可以在ML中完成,但我想切换到动态类型语言,以让我们在那里获得更多经验。
  • 第二章节:语言实现与类型系统对比。我们将学习两个主题。第一个是该章节作业的核心内容:实现你自己的小型编程语言。学习实现一门语言意味着什么,如何编写所谓的解释器,以及如何实现一门自身就拥有高阶函数和闭包的语言。这将通过让你在Racket语言内部实现自己的语言,来为用户提供闭包,从而真正帮助巩固我们在A部分所做的高阶编程。我们还将研究静态类型与动态类型的对比。这是编程语言设计中考虑的主要标准之一。对此有很多观点,在我们对两种类型的语言都有使用经验后,也可以研究一些关于静态类型优缺点的基础事实。

当我们进入C部分时,请记住,那时我们将转向面向对象编程,并且同样会在一个动态类型语言中进行。

以下是C部分的核心内容:

  • 第一章节:Ruby与OOP基础。我们将首先学习Ruby的基础知识和面向对象编程的基础。即使你以前做过OOP,我认为在像Ruby这样格式上近乎纯粹的面向对象语言中进行学习,是巩固你已有OOP知识的绝佳方式。事实上,也能够将其与我们到那时为止所学的函数式编程进行对比和补充。
  • 第二章节:高级OOP概念。我们将在课程的最后一部分更明确地进行这种对比,深入一些更高级的OOP概念,并研究泛型与子类型化

在C部分,我们同样会有两次作业。第一次作业相对简单,第二次更具挑战性。然后,就像在A部分结束时一样,会有一场综合性的考试,将所有内容整合在一起。

总结与鼓励 ✨

我真的希望大家能继续参与。你们已经在A部分完成了大量工作,并且能够从中获益良多。你们在A部分投入了巨大的精力,而我认为在B部分和C部分还有更多精彩内容。因此,我真的将这视为我们学习的中点,也许是半程点,具体取决于你觉得A部分的长短和挑战性。我希望你能继续与我同行,报名参加编程语言B部分,以便我们能一起学习更多知识。

无论如何,祝贺大家完成A部分的学习。我热爱这些材料,并希望其中一些热情已经感染了你,也希望你到目前为止享受这门课程。

编程语言A/B/C:CSE341:欢迎来到B部分 🎉

在本节课中,我们将介绍编程语言课程B部分的内容概览,并回顾A部分的核心概念,为后续学习做好准备。


欢迎来到编程语言课程的B部分。本节将讨论课程安排,确保每位学员都处于正确的学习阶段,并指导如何深入课程内容。

这是编程语言A部分的延续。无论您是刚刚完成A部分,还是已经有一段时间,都欢迎回来。相信您将从本课程中获益良多。另一方面,如果您尚未学习A部分的材料,请注意本课程是A部分的延续。更多细节将在网站上的阅读材料中讨论。

现在,让我们回顾A部分开始时提到的几个要点。编程语言课程整体是一个学习编程语言基本概念的挑战性机会。随着您经验的积累,学习全新的程序视角需要相当的耐心和开放心态。课程重点并非特定语言本身。在A部分中,我们使用了ML语言,而在B部分中,我们将使用Racket语言。

Racket同样是一种函数式编程语言。这为我们提供了一个绝佳的机会,来观察在ML中学到的许多理念。


本节课中,我们一起回顾了A部分的核心概念,并介绍了B部分将使用的Racket语言。下一节我们将开始深入探讨Racket的基础语法和函数式编程思想。

099:概念概述 🧭

在本节课中,我们将简要概述B部分将要学习的核心内容,了解它如何与A部分的知识衔接并在此基础上进行拓展。

从A部分到B部分

上一节我们介绍了A部分的内容,它主要涵盖了函数式编程的基础知识。我们学习了函数、递归、环境以及元组和列表等简单数据结构。随后,我们深入探讨了模式匹配、数据类型、一等函数和闭包。在A部分的最后,我们研究了类型推断、模块系统,并讨论了表达式的等价性及其在编程语言中的含义。总而言之,A部分提供了一个精确的编程入门,使我们能够优雅地组合代码并完成许多任务。这一切都是在ML这个静态类型的函数式编程语言中逐步构建完成的。

现在,我们将把这些思想转移到Racket语言中,并在此基础上进行扩展。

B部分内容概览

以下是B部分我们将要学习的主要内容:

1. 动态类型语言与核心概念

在B部分的第5节(可视作A部分的延续),我们将快速地在动态类型语言中重做许多在ML中完成的工作。所谓动态类型,是指没有类型系统,也没有类型推断。我们将被允许尝试执行一些ML在程序运行前就会拒绝的操作。Racket的语法(大量使用括号)会有所不同,但我们将看到许多相似的概念,重点仍然是列表、闭包、函数等。

2. 延迟求值与高级编程范式

在掌握了基础之后,我们将聚焦于延迟求值。我们将学习一系列强大的编程范式,这些范式涉及使用零参数函数。你可能会疑惑,不传递任何信息的函数有何意义?答案是:函数体在调用之前不会被求值。这个特性正是实现许多高级范式(例如创建行为上无限大的数据结构,即)所需的核心思想。这将是本节作业的重点。

3. 宏系统简介

最后,我将简要介绍。宏的大部分内容将被设为可选,因为它不是作业的主要焦点,但放在课程的这个阶段非常合适。原因有二:首先,Racket拥有一个设计精良的宏系统,是学习宏的良好环境;其次,在下一节中,我们需要宏的基本概念来完成一些工作。宏本质上是程序员扩展编程语言语法的一种方式,它允许在不改变底层语言实现的情况下,通过引入新结构来“生长”一门语言。

4. 语言实现与解释器

说到语言实现,这是B部分第二个模块的重点。为此,我们首先需要学习Racket如何实现ML中通过数据类型完成的功能。在动态类型语言中,类似ML的数据类型绑定意义不大,但存在类似的机制。学习这些之后,我们将用它来实现我们自己的编程语言。我们将讨论编程语言实现的一般原理,如何将程序表示为结构化的树而非字符序列,并学习如何实现加法、对、变量乃至函数闭包等基本特性。事实上,本部分的作业将是实现一个小型编程语言的解释器,这个语言虽然小,但功能足够强大,支持一等函数,足以让程序员实现自己的mapfilter等功能。

5. 静态与动态类型系统对比

这部分内容虽不直接体现在作业中,但却是课程的重要组成部分。既然我们已经使用了ML和Racket,现在让我们聚焦于它们之间最大的区别:是否拥有静态类型系统。我们将退后一步,定义静态检查的含义,学习类型系统“健全性”和“完备性”等重要术语,并理解为何通常无法同时拥有两者。然后,我们将讨论静态类型和动态类型的优缺点。不出意外,我不会给出哪种更好的明确结论,而是聚焦于它们之间的权衡以及那些无可争议的事实。最终,将由你根据自身项目、编程习惯和偏好来权衡这些事实,决定是更喜欢ML更严格的静态方法,还是Racket更宽松的动态方法。

总结

本节课中,我们一起学习了B部分的核心内容概览。B部分将引导我们从静态类型的ML过渡到动态类型的Racket,重温核心编程概念,并深入探索延迟求值、宏以及编程语言实现等高级主题。最后,我们将系统性地对比静态与动态类型系统,理解它们各自的权衡。这构成了编程语言课程B部分的全部内容。

100:课程结构 📚

在本节课中,我们将要学习编程语言课程B部分的结构安排。我们已经欢迎了大家加入课程,并对内容进行了简要概述。现在,我们将快速回顾B部分的结构。完成A部分后,B部分的结构会显得非常熟悉。

课程开始阶段 🚀

在课程开始时,我们不会专门用一整周时间进行软件安装和学习如何提交作业。那样安排会使这一周的内容过于轻松。幸运的是,安装我们将用于B部分的DrRacket环境非常简单。我们假设大家可以按照提供的说明快速完成安装,以便在第一周就能深入到一个相当复杂且内容丰富的章节。

作业与评估 📝

本周和下周,我们将布置编程作业,并进行同伴互评等评估活动。这与我们在A部分的三次作业模式非常相似。在B部分,我们还将有两次类似性质的作业。

最后一周的安排 🧠

在B部分的最后一周,安排会有所不同。这一周将包含更多概念性材料,这些内容不太适合通过编程作业来巩固。因此,我们将进行一次测验,该测验约占成绩的10%。这一周的安排将仅针对该章节的内容,而不像A部分结束时那样,有一次涵盖课程所有内容的考试。

各周内容分布 ⚖️

需要补充的是,虽然第一周有很多视频材料且内容相当深入,但第二周和第三周的内容原本是合并在一起的。我意识到它们可以很容易地分开,因此我提前将内容稍微分散了一下。所以,你会看到这两周的视频数量比通常一周的要少。

尽管如此,课程第二周的作业5被许多人认为是最具挑战性,同时也是最有收获的作业。综合来看,第二周的内容在难度上达到了平衡。我认为你会发现第三周是一个很好的总结,并且以一种更概念化的方式,退一步来比较和对比我们在课程中迄今为止接触到的不同编程方法。

考试安排 📋

最后我想补充的是,在B部分结束时没有累积性考试。我们只有两次编程作业和一次测验。然而,在C部分结束时,将有一次考试。这次考试不仅关注C部分的内容,还会重点比较和对比C部分与B部分的内容。因此,在C部分结束时,你将有机会回顾B部分的材料,并在C部分最后综合所有概念。

总而言之,以上就是在接下来三周里我们的课程结构安排。接下来应该是软件安装,然后我们将深入实际的课程内容。祝大家学习愉快!


本节课中我们一起学习了编程语言课程B部分的结构安排,包括课程开始阶段的设置、作业与评估模式、最后一周的测验安排、各周内容的分布特点,以及B部分与C部分在考试上的衔接。

101:Racket 语言入门 🚀

在本节课中,我们将开始学习 Racket 语言。我们将从熟悉这个新的编程环境开始,了解其基本概念和使用方法。

课程概述

本节是课程第一部分的开始,我们将使用 Racket 语言。因此,我们需要先熟悉这个新的编程语言。我们将在此处完成这一过程。

我们将使用 Racket 语言,而不是 ML。我们推荐使用 Dr. Racket 编程环境,而不是其他编辑器。因此,在本部分课程中,我们不会使用 Emacs。课程网站上提供了安装和使用说明。您会发现安装过程非常简单直接,只需花几分钟时间即可完成。

Racket 与 ML 的相似之处

与 ML 类似,Racket 主要是一种函数式语言。因此,我们在 ML 中学到的许多概念,将在新的环境中再次见到。我们仍然会使用匿名函数、一等函数闭包,并且一切都是表达式,因此我们不需要像 return 语句这样的东西。

我们不会使用 Racket 的某种 case 表达式形式。它确实支持一些此类功能,但我们将以不同的方式访问我们的类型,这对于我们的目的来说已经足够。

Racket 与 ML 的关键区别

然而,Racket 与 ML 有两个关键区别,这也是我希望在本部分课程中使用 Racket 的原因。

首先,Racket 不那么依赖静态类型系统。它接受更多的程序,只是将 ML 中会出现的类型错误推迟到运行时发生。例如,如果您尝试将一个数字和一个字符串相加,在 ML 中会立即报错,但在 Racket 中,直到实际执行该表达式时才会出现运行时错误。

其次,您将在后续章节中看到,当我们开始编写一些实际代码时,Racket 的语法非常简约。它大量使用括号来分组事物,而不是像大多数其他编程语言那样拥有复杂的语法规则。

此外,Racket 拥有许多高级功能。我们没有时间涵盖大部分内容,但如果有时间,我至少希望讨论一下模块系统。我还想讨论宏等概念。

课程安排与目标

总体而言,由于 Racket 与 ML 的相似性,接下来的作业不仅仅是熟悉 Racket 的作业。前一两道题会是,但之后,我想讨论一些新概念,一些我们本可以在 ML 中完成但在 Racket 中实现得更清晰的新事物。

因此,我们将用几个章节来熟悉 Racket 语言,然后继续前进,因为本课程不仅仅是在不同语言中尝试相同的事情,而是学习新概念。

关于 Scheme 的说明

我应该提一下,有一个相关的编程语言叫做 Scheme。Racket 本质上是从 Scheme 演化而来的,并在大约 2010 年左右持续发展。Racket 的设计者决定停止使用 Scheme 这个名称,以便他们可以更自由地进行创新,而不必受其束缚。

我有时可能会口误说成 Scheme,因为我仍然习惯称这类语言为 Scheme。我会尽量避免,但如果我说了,请不要感到困惑。如果您以前用 Scheme 编程过,它是一种非常流行的语言,特别是在入门编程和实际应用中。Racket 做出了一些不兼容的更改,您最可能注意到的是与列表使用方式相关的更改,特别是与 ML 列表类似,Racket 列表的元素是不可变的。如果您以前见过 Scheme,您需要适应这一点。如果没有,我们将把 Racket 作为一个独立的、出色的现代编程语言来介绍。

Racket 的应用与发展

我应该提一下,作为一种现代语言,Racket 已被用于构建一些真实的系统,并且继续在这方面发挥作用。它在教育领域也被广泛使用,这可能是它最出名的地方。该语言持续发展,因此可能有些变化。我正在努力保持所有内容都是最新的,它的变化速度并不快,您可以随时查阅在线文档。特别是 Racket 指南,这是一份免费的用户指南,涵盖了该语言的重要概念,内容远超过本课程所需,但您可以根据需要轻松查找信息。

Dr. Racket 环境介绍

现在开始,我马上向您展示 Dr. Racket。它将有一个所谓的“定义窗口”和一个“交互窗口”。这对我们来说会非常熟悉:在 Emacs 中,我们有编写代码的缓冲区(buffer)和运行代码的 REPL。Dr. Racket 的工作方式相同。我认为您会发现它比我们使用 ML 的方式更用户友好,并且您会发现它相当容易自学。如果您有任何问题,可以在讨论论坛上提问,当然,讲座演示也会展示我使用该工具的过程。

正如我提到的,有很棒的文档。通用的 Racket 网站是您下载 Dr. Racket 的地方,那里还有 Racket 指南和许多教程,信息非常丰富。

Dr. Racket 界面演示

那么,我现在切换到这里。这就是 Dr. Racket,我向您展示的当前缓冲区只是一个您可以编写代码的缓冲区。当我想运行它时,只需点击右上角的这个“运行”按钮。现在我得到了这个分割视图,下方是 REPL,上方是代码。您可以在它们之间切换,菜单选项有只显示一个或同时显示两者的选项。我认为按 Ctrl+E 可以在只显示定义和同时显示定义与 REPL 之间切换。我在这里按 Ctrl+E 来回切换。

除了我为了在录制中看起来更好而增大了字体大小并更改了字体外,Dr. Racket 在您那里的外观基本就是这样。

代码结构解析

现在让我们看看实际的代码。您看到的这种棕色的文本是注释。Racket 中的注释以分号 ; 开始,直到行尾。支持多行注释,但我不太常用。通常的做法是让注释的每一行都以分号开头。您也可以在同一行代码的右侧添加注释。所以底部这里,是另一个注释。

文件开头的必要设置

在我们的文件中,总是要做几项簿记工作。

首先,始终让文件的第一个非注释行恰好是这一行。我再为您输入一遍,尽管您应该只写一次:

#lang racket

这行代码告诉 Dr. Racket 这个文件中的代码是 Racket 代码。Dr. Racket 实际上支持定义自己的语言,用许多不同的语言运行代码,所以我们必须说明我们的代码是哪种语言,我们用这第一行来说明。

第二行 (provide (all-defined-out)) 是一个变通方法,目的是为了让我们的事情保持简单。默认情况下,Racket 有一个模块系统,就像我们在 ML 中学到的那样。但在 Racket 的模块系统中,每个文件都是一个模块,默认情况下,其中的所有内容都是私有的,您必须说明您希望向其他文件公开什么。

使用我们本课程中将测试放在第二个文件中的方法,这有点麻烦。所以这一行代码(您可以复制它,或者我们会在作业中提供给您)的意思是:更改默认设置,使所有内容公开。这使得您的代码更容易测试。

定义变量与运行代码

处理完这两件事后,您就可以在文件的其余部分定义一堆定义、变量和其他内容了。对于本讲座,我只做了一个定义,我们将在下一节中做更多。

我定义了变量 s 为字符串常量 "hello"。当您定义一个变量时,语法是:开括号、关键字 define、变量名、值 "hello"、闭括号。

(define s "hello")

这就像 ML 中的 val s = "hello"

如果我运行这个(点击运行按钮),在 REPL 中,它确实运行了所有代码,因此它为 s 创建了那个定义。与 ML 不同,它不会告诉我们任何信息。如果有什么问题,它会给我们一个错误。但由于一切正常,它只是给我们提示符。我可以说 (+ 2 2),我会得到 4。这就是您在 Racket 中做加法的方式:括号、运算符 +、参数、另一个括号。我也可以输入 s,得到字符串 "hello"。如果我使用一个不同的变量,比如 t,我会得到一个错误消息,提示引用了一个未绑定的标识符。

总结

本节课中,我们一起学习了 Racket 语言的入门知识。我们了解了 Dr. Racket 编程环境的基本界面和操作方法,学习了 Racket 文件的基本结构,包括必要的 #lang racket 声明和 (provide (all-defined-out)) 语句。我们还通过一个简单的变量定义示例,体验了在 Racket 中编写和运行代码的过程。

Dr. Racket 将继续在接下来的几节中使用,我们将快速介绍许多在 ML 中已经见过的相同概念。在那之后,我们将转向新的概念和新的材料。

102:定义、函数与条件语句 🚀

在本节课中,我们将学习Racket编程语言的基础知识,包括如何定义变量、编写函数以及使用条件语句。这些概念与我们在ML语言中学过的内容相似,但语法有所不同。我们将通过编写代码来逐步掌握它们。

变量定义

首先,我们来看如何在Racket中定义变量。定义变量的语法是使用define关键字,后跟变量名和一个表达式。这类似于ML中的val x = 3

以下是定义变量的示例:

(define x 3)

定义变量后,我们可以在其他定义中使用它。例如,我们可以定义变量y,其值为x加2的结果。在Racket中,函数调用需要将函数名放在括号内,后跟参数。

以下是使用变量x定义变量y的示例:

(define y (+ x 2))

这里,+是一个函数,我们调用它并传入x2作为参数。函数调用的通用语法是:(函数名 参数1 参数2 ...)

函数定义

接下来,我们学习如何定义自己的函数。我们将编写几个计算立方值的函数版本。

第一个版本使用lambda关键字来定义匿名函数。lambda后跟参数列表和函数体。

以下是使用lambda定义立方函数的示例:

(define cube1
  (lambda (x)
    (* x x x)))

在这个例子中,cube1被绑定到一个匿名函数,该函数接受一个参数x,并返回x * x * x的结果。

然而,Racket中的乘法函数*可以接受任意数量的参数。因此,我们可以简化函数体。

以下是简化后的立方函数定义:

(define cube2
  (lambda (x)
    (* x x x)))

此外,Racket提供了定义函数的语法糖,允许我们省略lambda关键字,直接使用define后跟参数列表和函数体。

以下是使用语法糖定义立方函数的示例:

(define (cube3 x)
  (* x x x))

这三种方式定义的函数在功能上是完全相同的。

递归函数

与ML不同,在Racket中定义递归函数不需要额外的关键字。我们可以直接使用lambda或语法糖来编写递归函数。

现在,让我们编写一个计算幂的函数。我们将以两种方式实现。

第一种方式使用if条件语句。if语句的语法是(if 测试表达式 真分支 假分支)

以下是计算幂的递归函数定义:

(define (power1 x y)
  (if (= y 0)
      1
      (* x (power1 x (- y 1)))))

在这个函数中,如果y等于0,返回1;否则,返回x乘以power1递归调用的结果,其中y减1。

我们可以测试这个函数:

(power1 3 2) ; 返回 9
(power1 3 0) ; 返回 1

柯里化函数

柯里化是一种将多参数函数转换为一系列单参数函数的技术。在Racket中,虽然内置支持多参数函数,但我们仍然可以实现柯里化。

以下是柯里化版本的幂函数定义:

(define (power2 x)
  (lambda (y)
    (power1 x y)))

这里,power2是一个接受参数x的函数,它返回另一个接受参数y的函数,该函数调用power1计算xy次幂。

使用柯里化函数时,我们需要分别调用每个函数:

((power2 4) 2) ; 返回 16

首先调用(power2 4)返回一个函数,然后调用该函数并传入2,最终得到结果16。

函数调用语法

在Racket中,函数调用的语法始终是(函数表达式 参数1 参数2 ...)。其中,函数表达式可以是一个变量名,也可以是其他求值为函数的表达式。

例如:

(define add +)
(add 2 3) ; 返回 5

这里,add被绑定到+函数,因此调用(add 2 3)等价于调用(+ 2 3)

需要注意的是,如果表达式是ifdefinelambda等关键字,它们不是函数调用,而是Racket中的特殊构造。

总结

本节课中,我们一起学习了Racket的基础知识。我们掌握了如何定义变量、编写函数以及使用条件语句。通过示例代码,我们了解了Racket的语法特点,如函数调用的括号规则和递归函数的实现方式。此外,我们还简要介绍了柯里化函数的概念及其在Racket中的应用。这些基础知识将为我们后续学习更复杂的Racket概念打下坚实的基础。

103:Racket列表 🧮

在本节课中,我们将学习Racket语言中的列表。由于Racket列表的工作方式与ML语言中的列表非常相似,特别是我们之前在第1节中使用函数访问列表各部分的方式,因此本节内容会进展得很快。

概述

我们将介绍Racket列表的基本操作原语,并通过编写几个简单的函数来加深理解。这些概念与ML语言中的列表操作一脉相承,相信你在学习时会感到熟悉和安心。

列表基本操作

Racket列表的操作原语与ML类似,但语法和函数名有所不同。

以下是Racket中用于处理列表的核心函数:

  • 空列表:使用 null 表示。在ML中我们写作 []
  • 构造列表:使用 cons 函数。它接受一个元素和一个列表,返回一个在原列表头部添加了新元素的新列表。在ML中,我们使用中缀运算符 ::
  • 获取头部:使用 car 函数获取列表的第一个元素。
  • 获取尾部:使用 cdr 函数获取列表中除第一个元素外的剩余部分。
  • 判断空列表:使用 null? 函数检查一个列表是否为空。

需要说明的是,carcdr 这两个函数名的来源是一个历史遗留问题,源于几十年前这种语言最初实现的机器架构。虽然这些名字在今天看来并不直观,但我们已经习惯使用它们。

此外,Racket还提供了 list 函数,它可以接受任意数量的参数,并创建一个包含这些参数的列表。这比连续使用多个 cons 调用更为方便。

编写列表函数示例

上一节我们介绍了列表的基本操作,本节中我们来看看如何利用这些操作来编写实际的函数。

示例一:列表求和

首先,我们编写一个函数,用于计算列表中所有数字的总和。

(define sum
  (lambda (xs)
    (if (null? xs)
        0
        (+ (car xs) (sum (cdr xs))))))

这个函数使用递归:如果列表 xs 为空,则返回0;否则,将列表的头部 (car xs) 与对列表尾部 (cdr xs) 递归求和的结果相加。

我们可以测试这个函数,例如计算列表 (list 3 4 5 6) 的和,结果应为18。

需要注意的是,Racket是动态类型语言。如果我们错误地将一个字符串传递给 sum 函数(例如列表中包含 "hi"),程序会在运行时尝试对字符串执行加法操作,从而引发错误。

示例二:列表连接

接下来,我们实现一个自己的列表连接函数 my-append。Racket本身内置了 append 函数,这里我们重新实现它以作练习。

(define my-append
  (lambda (xs ys)
    (if (null? xs)
        ys
        (cons (car xs) (my-append (cdr xs) ys)))))

这个函数同样采用递归:如果第一个列表 xs 为空,则直接返回第二个列表 ys;否则,将 xs 的头部与 xs 的尾部同 ys 连接的结果再次连接起来。

示例三:映射函数

最后,我们实现一个高阶函数 my-map,它将一个函数应用到列表的每个元素上。这也是Racket内置的函数。

(define my-map
  (lambda (f xs)
    (if (null? xs)
        null
        (cons (f (car xs)) (my-map f (cdr xs))))))

函数逻辑是:如果列表 xs 为空,则返回空列表;否则,将函数 f 应用于列表头部 (car xs),并将结果与对列表尾部递归调用 my-map 的结果连接起来。

我们可以这样使用它:

(define fo (my-map (lambda (x) (+ x 1)) (cons 3 (cons 4 (cons 5 null)))))

执行后,变量 fo 的值将是列表 '(4 5 6)。这里我们使用了匿名函数 (lambda (x) (+ x 1)) 来对每个元素加1。

总结

本节课中我们一起学习了Racket中的列表。我们看到,Racket列表的操作方式与ML非常相似,同样方便和强大。我们介绍了列表的基本构造和访问原语(null, cons, car, cdr, null?),并通过编写 summy-appendmy-map 三个递归函数,实践了如何使用这些原语来处理列表数据。递归在Racket中与在ML中一样,是处理列表问题的核心工具。

104:语法与括号

在本节课中,我们将要学习Racket语言中大量使用括号的原因,并理解为何这在许多方面是一个优点。我们将探讨Racket极其简单的语法规则,并学习如何正确看待和调试括号的使用。

概述

到目前为止,在学习Racket的过程中,你可能已经注意到代码中包含了大量的括号。本节将解释为什么这在许多方面实际上是一件好事。下一节中,我们将学习如何仔细思考括号的使用,并在出现括号错误时进行调试。

Racket的简单语法

忽略一些我们尚未遇到且不会重点关注的复杂特性,Racket拥有极其简单的语法。我们编写程序的方式没有很多复杂的规则。事实上,我几乎可以把所有规则都放在这张幻灯片上。这非常了不起。

以下是编写Racket程序的基本规则。

在Racket语言中,你写的任何内容——Racket人士称之为“项”——要么是原子,要么是序列。

  • 原子:指不可分割的基本单位。例如:
    • 布尔值:#t#f
    • 数字:例如 4.0
    • 字符串
    • 变量名
  • 特殊形式:某些原子被定义为特殊形式,例如 lambdaifdefine。当我们下一节学习宏时,将能够定义自己的特殊形式,这本质上就是宏的功能。
  • 序列:将一系列项放在括号中。项是一个递归定义,序列中的每个项本身可以是一个序列、一个原子或一个特殊形式。

语义直接来自这个语法,规则如下:

  1. 如果 t1 是一个特殊形式,那么每个特殊形式都有其特定的语义(例如 iflambdadefine 各有含义)。
  2. 否则,如果 t1 不是这些特殊单词之一,那么 (t1 t2 ... tn) 就是一个函数调用。

让我们看几个例子。

示例1:函数调用

(+ 3 (car xs))

这是一个包含三个项 +3(car xs) 的序列。+ 不是特殊形式,因此这是一个函数调用,正如我们所期望的。

示例2:特殊形式

(lambda (x) (if #t x 0))

这是一个包含三个项 lambda(x)(if #t x 0) 的序列。由于 lambda 是特殊形式,它会影响其余部分的含义。因此,(x) 不是函数调用,而是参数列表,其后的 (if #t x 0) 是函数体,将被作为表达式进行求值。

这就是Racket语法的全部内容。正是因为所有这些括号,语法才如此简单。

关于括号样式的一个小说明

既然我们在讨论语法,需要说明的是,在任何使用圆括号 () 的地方,你也可以使用方括号 []。这只是一种风格问题,使用方括号还是圆括号从不影响任何含义。我们不会对此过于挑剔。

但我会展示一些习惯上使用方括号的地方。DrRacket在这方面实际上很友好:如果你写了一大堆闭合括号,不用担心你闭合的是哪种括号。如果你在键盘上敲了圆括号 ),但有一个匹配的开口方括号 [,DrRacket会自动为你将其改为闭合方括号 ]。这只是DrRacket一个方便的键盘功能。

为什么括号是好的

括号有很多优点,但我想强调的一点是:将程序文本转换为表示程序的树结构变得完全微不足道

让我们直接看幻灯片中间的这个例子。这是我们几节前编写的 cube 函数的一个版本。

代码文本:

(define cube (lambda (x) (* x x x)))

对应的语法树:

      define
      /    \
   cube   lambda
          /    \
        (x)    (*)
               /|\
              x x x

任何想要分析你的程序(为了编译、运行或在你按Tab键时自动缩进)的工具,都希望将你的程序视为这样一棵树。当所有括号都存在时,从左边的代码到右边的树的过程极其简单。

这在编程语言中称为解析,即将程序字符串转换为正确的树结构。当我们拥有所有这些括号时,程序员在理解这些内容如何组织方面永远不会产生任何混淆。我们不会遇到其他语言中的那些问题,例如 x + y * z 这棵树是顶部为 +、子树为乘法,还是顶部为 *、子树为加法。

在其他语言中,我们必须为此制定规则(在学校里,你必须学习数学约定:乘法比加法结合得更紧密)。但在像Racket这样的语法中,只需使用额外的括号,将所有内容置于前缀表示法中,将运算符放在其参数之前,就永远不会产生歧义。没有一堆特殊的规则。

对括号的辩护

事实证明,人们往往不喜欢这样。我不介意,有些人喜欢,但很多人不喜欢所有这些括号。让我用一页幻灯片来为括号辩护。

首先,我从未听到任何人抱怨HTML。如果你访问一个网页并查看其源代码,它采用了完全相同的方法。它不使用像开括号 ( 这样的东西,而是使用尖括号 <foo>,甚至更长。然后,它不使用闭括号 ),而是写一个斜杠并重复对应的开始标签 </foo>,这要长得多。在HTML中,你不允许省略闭合标签。我们没有一堆规则让你可以在中间写 +,而其他东西必须放在开头。然而,似乎没有人抱怨这一点。

我同意,起初这有点难以阅读。拥有一个能为内容着色并为你缩进的环境是很好的。但Racket和HTML都拥有所有这些功能。

然而,追溯到Lisp、Scheme以及所有像Racket这样为所有东西使用括号的语言,人们似乎对它们有一种我甚至称之为非理性的厌恶。我可以有非理性的喜欢,你也可以有非理性的厌恶,因为这仅仅是语法。本课程不是教你哪种语法好或哪种语法坏,所以我们可以有不同的意见。我可以喜欢括号,你可以不喜欢括号,这没关系。

我认为不合适的是,仅仅因为你不喜欢括号就否定整个Racket语言。它有许多有趣的结构、视角和语义值得我们学习。即使你不喜欢括号,我也请你超越它们看待问题。我在本幻灯片上的类比是:这就像某人是一位历史学家,想学习欧洲历史,想了解所有不同的国家、它们如何形成、战争、社会变迁等等。但有一个特定的国家,他们不喜欢那里人们的口音,觉得那种口音难以理解,希望人们不那么说话。结果,他们从未研究过那个国家的历史。我认为这会让你成为一个糟糕的历史学家。同样,仅仅因为你不喜欢在屏幕上看到一堆括号就否定整个Racket,会让你成为一个有点糟糕的计算机科学家。

总结

在本节课中,我们一起学习了Racket语言中括号的核心作用。我们了解到,Racket的语法极其简单,主要基于原子、特殊形式和括号序列。这种设计使得代码解析为语法树的过程变得非常简单和明确,避免了其他语言中因运算符优先级等规则带来的歧义。虽然括号的密集使用可能初看起来令人生畏,但正如HTML的例子所示,这只是一种需要习惯的语法风格。重要的是超越语法形式,关注语言提供的强大功能和语义,这些才是计算机科学学习的核心价值。在下一节中,我们将学习如何仔细处理括号并调试相关的错误。

105:括号的重要性与调试实践 🧩

在本节课中,我们将深入探讨 Racket 语言中括号的重要性。括号在 Racket 中并非可有可无,它们具有明确的语法含义。我们将通过一系列示例,特别是阶乘函数的实现,来理解括号的正确使用方式,并学习如何避免和调试因括号使用不当而引发的错误。

括号的基本含义

上一节我们介绍了 Racket 的基本语法,本节中我们来看看括号的核心作用。在 Racket 中,括号并非用于增强可读性的装饰,而是具有严格的语法意义。

在大多数情况下,将表达式写在括号中意味着:先对表达式 E 求值,然后将结果作为一个零参数函数进行调用。这是因为在 Racket 中,函数调用的语法是 (函数名 参数...)。如果没有参数,就是零参数调用。

例如,((E)) 的含义是:

  1. E 求值。
  2. 将第一步的结果作为零参数函数调用。
  3. 将第二步的结果再次作为零参数函数调用。

这完全符合语法,但如果你并非此意,就会导致错误。由于 Racket 是动态类型语言,这类错误在保存或点击“运行”时可能不会立即报错,但当代码实际执行时(例如在某个函数体内),就会产生“尝试将非函数对象作为函数调用”的错误,这种错误有时难以诊断。

正确的阶乘函数示例

在开始分析错误之前,我们先来看一个正确的阶乘函数实现。以下是实现步骤:

以下是 fact 函数的定义:

(define (fact n)
  (if (= n 0)
      1
      (* n (fact (- n 1)))))
  • (define (fact n) ...):定义一个名为 fact 的单参数函数。
  • (if (= n 0) ...)if 表达式需要三个参数。第一个是条件 (= n 0)
  • 1:第二个参数,当条件为真(n 等于 0)时返回的结果。
  • (* n (fact (- n 1))):第三个参数,当条件为假时执行的表达式。它计算 n 乘以 (fact (- n 1)) 的结果。注意,调用 fact- 函数时,前面都需要括号。

运行 (fact 5) 会得到正确结果 120

常见的括号错误及分析

现在,让我们通过几个错误的版本来练习调试,理解括号误用导致的各类问题。

错误示例 1:多余的括号导致函数调用

在基础情况(n 为 0)的返回值 1 外加了括号。

(define (fact1 n)
  (if (= n 0)
      (1) ; 错误:尝试将数字 1 作为零参数函数调用
      (* n (fact1 (- n 1)))))

运行 (fact1 5) 时,在递归到基础情况后会报错:procedure application: expected procedure, given: 1; arguments were: ()。修复方法是删除 1 周围的括号。

错误示例 2:错误递归调用掩盖问题

这个版本犯了和 fact1 类似的错误,但递归调用时错误地调用了之前正确的 fact 函数,而非 fact1b 自身。

(define (fact1b n)
  (if (= n 0)
      (1) ; 错误
      (* n (fact (- n 1))))) ; 注意:这里调用的是 `fact`,不是 `fact1b`

调用 (fact1b 5) 看似能工作,因为它实际调用了正确的 fact 函数。但调用 (fact1b 0) 会立即触发与 fact1 相同的错误。这提醒我们,递归调用自身时必须确保函数名正确。

错误示例 3:if 表达式结构错误

忘记了将条件表达式 (= n 0) 用括号括起来,导致 if 后面跟了 5 个部分,不符合语法。

(define (fact2 n)
  (if = n 0 ; 错误:`if` 后面跟了 `=`, `n`, `0`, `1`, `(* n ...)` 共5部分
      1
      (* n (fact2 (- n 1)))))

这会导致语法错误,无法运行。Racket 会提示:if: bad syntax (has 5 parts after keyword)。修复方法是为条件加上括号:(if (= n 0) ...)

错误示例 4:函数定义语法错误

在定义函数时,括号的位置放错了。括号应该放在函数名之前,而不是参数列表之前。

(define fact3 (n) ; 错误:括号位置错误
  (if (= n 0)
      1
      (* n (fact3 (- n 1)))))

这会导致语法错误:define: bad syntax (multiple expressions after identifier)。正确的定义方式是 (define (fact3 n) ...)

错误示例 5:函数调用缺少括号

在递归调用 fact4 时,忘记在其前面加括号进行调用,导致 * 函数收到了三个参数:n、函数对象 fact4(- n 1)

(define (fact4 n)
  (if (= n 0)
      1
      (* n fact4 (- n 1)))) ; 错误:`*` 收到了 n, fact4, (- n 1) 三个参数

运行 (fact4 5) 会报错:*: expects type <number> as 2nd argument, given: #<procedure:fact4>。修复方法是在 fact4 前后加上括号,使其成为一个函数调用:(* n (fact4 (- n 1)))

错误示例 6:函数调用括号过多

在递归调用时,给 fact5 加了多余的括号,导致尝试用零个参数调用它。

(define (fact5 n)
  (if (= n 0)
      1
      (* n ((fact5) (- n 1))))) ; 错误:`(fact5)` 尝试用零参数调用函数

运行 (fact5 5) 会报错:procedure fact5: expects 1 argument, given 0。修复方法是使用正确的调用形式:(fact5 (- n 1))

错误示例 7:算术表达式写法错误

使用了中缀表达式的习惯来写乘法,这在 Racket 中是不合法的。

(define (fact6 n)
  (if (= n 0)
      1
      (n * (fact6 (- n 1))))) ; 错误:将 n 放在了操作符 * 的位置

运行 (fact6 5) 会报错:procedure application: expected procedure, given: 5; arguments were: * #<procedure:fact6> ...。错误在于它试图将数字 n(例如 5)作为函数来调用,参数是 *(fact6 (- n 1)) 的结果。必须使用前缀表达式:(* n (fact6 (- n 1)))

总结与调试建议

本节课中我们一起学习了 Racket 语言中括号的关键作用。核心要点是:括号在 Racket 中始终具有语法含义,主要用于组织表达式和进行函数调用,不能随意添加或删除。

当遇到令人困惑的错误时,请遵循以下调试建议:

  1. 放慢速度:不要盲目增删括号。
  2. 仔细思考:理解你写的每个括号的意图。
  3. 重新开始:如果感到混乱,删除有问题的括号,根据语法规则重新添加。
  4. 良好缩进:正确的代码缩进能极大地帮助你识别结构错误。
  5. 理解错误信息:Racket 的错误信息通常会明确指出问题所在,例如期望的过程类型、给定的参数数量等。

通过反复练习和对语法的深入理解,你将能够熟练而准确地使用括号,从而编写出正确、高效的 Racket 程序。

106:动态类型 🧩

在本节课中,我们将首次探讨 Racket 作为一门动态类型语言的特点,并重点说明它如何让我们能够自由构建数据结构,而无需受类型系统或类型检查器的限制。

动态类型是后续课程中的重要主题,待我们更熟悉 Racket 后,会将其与静态类型进行对比,并讨论各自的优缺点。目前,我们只需理解 Racket 不会将许多情况视为类型错误,并了解这为我们带来了哪些可能性。

你可能会因为没有类型检查器而感到不便,毕竟类型错误信息虽然烦人,但总比没有要好。例如,在 Racket 代码中,如果你误写了 n times X 而不是 times N X,Racket 会允许你运行这段代码而不报错。只有当你执行到该表达式,并在环境中查找 n(假设未找到期望两个参数、且第一个参数为 times 的函数)时,才会看到错误信息。因此,对这种错误的测试实际上变得更加重要。

然而,动态类型的优势在于,它让我们能够构建非常灵活的数据结构,而无需为了向类型检查器解释我们的意图而做大量额外工作。

本节中,我们将编写一个 sum 函数,用于对列表中的所有数字求和。但这不是一个简单的数字列表。我们的列表可以包含数字,也可以包含其他数字列表,而这些列表内部又可以包含更多列表或数字。我们的目标是允许任意深度的列表和数字嵌套,并仍然能对出现在任何层级的所有数字求和。

在 ML 中,如果不创建数据类型绑定和构造函数,就无法将数字和列表放入同一个列表中,因为类型检查器不允许这样做。但在 Racket 中,没有类型检查器,我们可以直接实现这个功能。

定义示例列表

首先,我们定义几个示例列表,以便明确我们要处理的数据结构。

(define xs (list 4 5 6))
(define ys (list (list 4 5) 6 7 (list 8) 9 2 3 (list 0 1)))

xs 是一个普通的数字列表 (4 5 6)ys 则是一个更复杂的嵌套列表,其元素依次是:列表 (4 5)、数字 6、数字 7、列表 (8)、数字 9、数字 2、数字 3 以及列表 (0 1)。我们还可以进行更深层的嵌套,例如在 (4 5) 中再嵌套一个列表 (5 0)。我们的函数需要能正确处理这种任意深度的嵌套结构。

实现求和函数:第一版

现在,让我们开始定义求和函数。本节我们将定义两个版本,先从 sum1 开始。

(define (sum1 xs)
  (if (null? xs)
      0
      (if (number? (car xs))
          (+ (car xs) (sum1 (cdr xs)))
          (+ (sum1 (car xs)) (sum1 (cdr xs))))))

这个函数的逻辑如下:

  • 如果参数 xs 是空列表 null,则和为 0
  • 否则,检查列表的第一个元素 (car xs) 是否为数字(使用 number? 函数)。在静态类型语言中,类型检查器会告诉你值的类型,但在 Racket 中,我们可以在运行时使用这类函数进行判断。
  • 如果 (car xs) 是数字,则将其与递归地对剩余列表 (cdr xs) 求和的结果相加。
  • 如果 (car xs) 不是数字(根据我们的设计,此时它应该是一个列表),则递归地对 (car xs) 求和,再递归地对 (cdr xs) 求和,最后将两个结果相加。

让我们测试一下这个函数:

(sum1 xs) ; 返回 15
(sum1 ys) ; 返回 45
(sum1 (list (list (list 4)) 5 (list 7 2))) ; 返回 18

函数对正确的输入工作正常。但是,如果列表深层中包含既非数字也非列表的元素(例如字符串),它就会出错。因为当函数递归到该位置时,会假设非数字的元素就是列表,并尝试对其取 car,从而导致运行时错误。

(sum1 (list (list 4) "hi" 5)) ; 会报错

实现求和函数:第二版

接下来,我们实现一个更健壮的版本 sum2。如果遇到既非数字也非列表的元素(如字符串),我们选择忽略它,将其视为 0 处理。

(define (sum2 xs)
  (if (null? xs)
      0
      (cond
        [(number? (car xs)) (+ (car xs) (sum2 (cdr xs)))]
        [(list? (car xs))   (+ (sum2 (car xs)) (sum2 (cdr xs)))]
        [else               (sum2 (cdr xs))])))

这个版本的逻辑更清晰:

  • 空列表和为 0
  • 使用 cond 进行多条件判断:
    • 如果 (car xs) 是数字,将其加入总和。
    • 如果 (car xs) 是列表,递归计算其和并与剩余部分的和相加。
    • 否则(既非数字也非列表),直接忽略该元素,仅递归计算剩余部分 (cdr xs) 的和。

现在测试这个更宽容的版本:

(sum2 (list (list 4) "hi" 5))     ; 返回 9,忽略了 "hi"
(sum2 (list (list 4) "hi" 5 #f))  ; 返回 9,同时忽略了 "hi" 和 #f

sum2 仍然假设初始参数是一个列表。如果你直接用 "hi" 调用它,它会在尝试检查 (car "hi") 时出错。如果你想让它对任何输入都返回一个值(例如,对非列表输入返回 0),那将是第三个版本的练习,留给你自己完成。

动态类型的便利与挑战

通过上面的例子,我们看到,无需创建 ML 风格的数据类型绑定,我们就实现了能正确处理数字和嵌套列表求和的函数。在 Racket 中,我们没有任何类型声明,就可以自由地定义列表。

事实上,一个列表可以包含任何类型的元素:

(define zs (list #f "hi" 14))
(car zs)   ; 返回 #f
(cdr zs)   ; 返回 '("hi" 14)

Racket 对此完全没有问题。这种灵活性为我们带来了便利,但也使得测试函数和记录它们期望的参数类型变得稍微困难一些。

总结

本节课中,我们一起学习了 Racket 动态类型的基本概念。我们了解到,动态类型语言不会在代码运行前进行严格的类型检查,这允许我们构建和操作非常灵活的数据结构,例如包含任意嵌套数字和列表的混合列表。我们通过编写两个版本的 sum 函数实践了这一理念:第一个版本 sum1 对符合预期的数据结构有效,第二个版本 sum2 则更加健壮,能优雅地处理意外类型的元素。同时,我们也认识到,这种灵活性要求开发者承担更多责任,需要通过充分的测试来确保代码的正确性。

107:条件表达式 cond 的使用 🧩

在本节课中,我们将学习Racket语言中的 cond 结构。cond 是处理多条件分支的更好方式,可以替代嵌套的 if-then-else 表达式,使代码更清晰、更具可读性。

概述

cond 可以被视为嵌套 if-then-else 表达式的语法糖。它允许我们以更结构化的方式编写多个条件分支。本节将介绍 cond 的基本语法、使用场景以及一些重要的风格约定。

cond 的基本语法

cond 是一个特殊形式,其基本结构如下:

(cond
  [test1 result1]
  [test2 result2]
  ...
  [else default-result])

每个分支由一对表达式组成:第一个是测试条件,第二个是当该条件为真时要执行的操作。cond 会按顺序评估每个测试条件,并执行第一个为真的条件对应的结果表达式。

使用 cond 重写求和函数

上一节我们介绍了处理嵌套列表的求和函数。本节中,我们来看看如何使用 cond 来重写这些函数,使其结构更清晰。

以下是使用 cond 重写的 sum3 函数,它计算一个可能包含嵌套列表的数字列表的总和:

(define (sum3 xs)
  (cond
    [(null? xs) 0]
    [(number? (car xs)) (+ (car xs) (sum3 (cdr xs)))]
    [else (+ (sum3 (car xs)) (sum3 (cdr xs)))]))

这个 cond 表达式清晰地列出了三种情况:

  1. 如果列表为空,返回0。
  2. 如果列表的第一个元素是数字,则将其与剩余列表的递归和相加。
  3. 否则(即第一个元素是列表),则递归计算第一个子列表和剩余列表的和,然后相加。

类似地,我们可以重写 sum4 函数,该函数在遇到非列表且非数字的元素时会跳过它:

(define (sum4 xs)
  (cond
    [(null? xs) 0]
    [(number? (car xs)) (+ (car xs) (sum4 (cdr xs)))]
    [(list? (car xs)) (+ (sum4 (car xs)) (sum4 (cdr xs)))]
    [else (sum4 (cdr xs))]))

sum3 相比,sum4 增加了一个显式检查 (list? (car xs)) 的分支,并在最后的 else 分支中跳过无效元素。

关于条件测试的重要说明

在Racket中,无论是 if 还是 cond,其测试表达式的结果都不必须是严格的布尔值 #t#f

其语义是:任何不是 #f 的值都被视为真(#t。只有 #f 本身被视为假。

例如:

  • (if 34 14 15) 会返回 14,因为 34 不是 #f
  • (if '() 14 15) 会返回 14,因为空列表 '() 不是 #f
  • (if #f 14 15) 才会返回 15

这种特性在动态类型语言中比较常见。虽然有些程序员认为这很方便,但也有人认为这会影响代码清晰度。你可以根据情况决定是否使用。

以下是一个利用此特性的例子,函数 count-falses 用于计算一个列表中 #f 出现的次数:

(define (count-falses xs)
  (cond
    [(null? xs) 0]
    [(car xs) (count-falses (cdr xs))] ; 如果(car xs)不是#f,则为真,跳过
    [else (+ 1 (count-falses (cdr xs)))])) ; 否则(car xs)是#f,计数加1

在这个函数中,测试 (car xs) 利用了“非 #f 即真”的规则。如果 (car xs) 不是 #f,则条件为真,递归处理列表其余部分;如果它是 #f,则进入 else 分支,计数加1。

总结

本节课中我们一起学习了Racket中 cond 结构的使用。cond 通过提供清晰的多分支结构,改善了嵌套 if 表达式的代码风格。我们还了解了Racket条件判断中“非 #f 即真”的独特规则。现在,你可以在后续的Racket编程作业中,使用 cond 来编写更优雅、更易读的条件判断代码了。

108:Racket 中的局部绑定 🧩

在本节课中,我们将学习 Racket 语言中的局部绑定机制。我们将探讨几种不同的 let 表达式形式,理解它们各自独特的语义和适用场景,并通过具体示例来掌握其用法。

概述

Racket 提供了多种定义局部变量的方式,包括 letlet*letrec 和局部 define。这些形式在变量作用域和绑定时机上存在差异,理解这些差异对于编写正确且清晰的 Racket 代码至关重要。

let 表达式基础

首先,我们来看一个使用 let 表达式的简单示例:计算一个数字列表中的最大值。

(define (max-of-list xs)
  (cond
    [(null? xs) (error "列表为空")]
    [(null? (cdr xs)) (car xs)]
    [else
     (let ([tail-ans (max-of-list (cdr xs))])
       (if (> tail-ans (car xs))
           tail-ans
           (car xs)))]))

在上面的代码中,我们使用 let 创建了一个局部变量 tail-ans,它保存了列表尾部(cdr)的最大值。这样我们就避免了在递归中对同一列表进行多次计算,防止了性能上的指数级爆炸。

let 表达式的基本语法结构如下:

(let ([var1 exp1]
      [var2 exp2]
      ...)
  body)

其中,[var1 exp1] 等是变量绑定,body 是表达式主体。所有初始化表达式(exp1, exp2...)都在 let 表达式之前的环境中求值,它们不能相互引用。

三种 let 表达式的语义对比

Racket 拥有 letlet*letrec 三种形式,它们的主要区别在于变量绑定创建的作用域不同。

1. let:并行绑定

let 表达式中,所有绑定是“并行”创建的。每个初始化表达式都在外层环境中求值,彼此之间看不到对方。

(define (silly-double x)
  (let ([x (+ x 3)]
        [y (+ x 2)])
    (+ x y -5)))

在这个例子中,两个绑定 [x (+ x 3)][y (+ x 2)] 中的 x 都指向函数参数 x,而不是新绑定的 x。因此,如果传入参数 5,计算过程为:x 绑定为 8(5+3),y 绑定为 7(5+2),最终结果为 8 + 7 - 5 = 10,即参数的两倍。

let 的这种语义在需要交换变量值时非常方便:

(let ([x y]  ; 这里的 y 是外层的 y
      [y x]) ; 这里的 x 是外层的 x
  ...)       ; 在主体中,x 和 y 的值完成了交换

2. let*:顺序绑定

let* 的语义与 ML 语言中的 let 相同,绑定是“顺序”创建的。每个初始化表达式都可以使用前面已经绑定的变量。

(define (silly-double x)
  (let* ([x (+ x 3)]
         [y (+ x 2)])
    (+ x y -8)))

此时,第一个绑定 [x (+ x 3)] 中的 x 是函数参数。第二个绑定 [y (+ x 2)] 中的 x 则是新绑定的 x(即参数加3)。因此,对于参数 5x 绑定为 8y 绑定为 10(8+2),最终结果为 8 + 10 - 8 = 10,同样是参数的两倍。

当后续绑定需要依赖前面绑定的值时,应使用 let*

3. letrec:递归绑定

letrec 允许所有绑定(包括后面的)在初始化表达式的环境中都可见。这主要用于定义相互递归的函数

(define (triple x)
  (letrec ([y (+ x 2)]
           [f (lambda (z) (+ x y z w))]
           [w (+ x 7)])
    (f -9)))

在这个复杂的例子中,函数 f 的定义(第二个绑定)中使用了后面才绑定的变量 w。这是允许的,因为 f 是一个函数,其函数体在定义时并不会立即求值,只有在调用时才会。当调用 (f -9) 时,所有变量(包括 w)都已完成初始化。

letrec 的一个典型应用是定义互递归函数来判断奇偶性:

(define (mod2 x)
  (letrec ([even? (lambda (n) (if (= n 0) 0 (odd? (- n 1))))]
           [odd?  (lambda (n) (if (= n 0) 1 (even? (- n 1))))])
    (even? x)))

重要警告:虽然 letrec 允许向前引用,但初始化表达式仍然是按顺序求值的。如果在一个变量的初始化表达式中直接(而非通过函数间接)使用了后面尚未求值的变量,会导致未定义行为或错误。因此,letrec 应谨慎使用,通常仅用于互递归函数定义。

局部 define

除了 let 系列表达式,Racket 还允许在函数体内部使用 define 来创建局部绑定,其语义与 letrec 完全相同。

(define (mod2 x)
  (define even? (lambda (n) (if (= n 0) 0 (odd? (- n 1)))))
  (define odd?  (lambda (n) (if (= n 0) 1 (even? (- n 1)))))
  (even? x))

目前 Racket 社区更推崇使用局部 define 的语法风格。你可以根据喜好和代码清晰度来选择使用 let/let*/letrec 或是局部 define

总结

本节课我们一起学习了 Racket 中四种定义局部绑定的方式:

  1. let:并行绑定。初始化表达式在外层环境求值,互不可见。适用于交换变量值或无需相互引用的简单绑定。
  2. let*:顺序绑定。每个初始化表达式可以看见前面绑定的变量。语义与 ML 的 let 相同,是最常用的一种。
  3. letrec:递归绑定。允许所有绑定相互可见,主要用于定义相互递归的函数。需注意避免在初始化表达式中直接向前引用。
  4. 局部 define:在函数体内使用 define,语义同 letrec,是 Racket 社区推荐的新风格。

理解这些绑定形式的差异,不仅能帮助你正确编写 Racket 代码,更能加深你对编程语言中作用域和环境概念的理解。在实际编码时,请根据需求选择最清晰、最合适的绑定形式。

109:顶层绑定语义详解 🧠

在本节课中,我们将要学习顶层绑定的语义规则。我们已经讨论了局部绑定的工作原理,现在来看看在文件中的 define 语句(即顶层绑定)是如何工作的。理解这一点至关重要,否则知识体系将不完整。

核心语义:类 letrec 风格

顶层绑定的简短版本是:它们的工作方式类似于 letrec,或者说类似于局部的 define。这意味着绑定可以按顺序求值,并且可以引用后续的绑定,这与 ML 语言不同。

具体来说:

  • 可以引用先前的绑定(与 ML 和 let* 相同)。
  • 可以引用后续的绑定(这是与 ML 的关键区别)。

然而,由于是按顺序求值,你必须谨慎引用后续绑定。以下是关键规则:

  • 规则:你只应在函数体中引用后续绑定。
  • 规则:你必须确保,在引用的绑定完成其表达式求值之前,那些函数不会被调用

如果违反此规则(例如在非函数体的表达式中直接引用后续定义),在 Racket 中会导致错误,这与函数内部可能产生未定义结果的情况不同。

示例与规则详解

以下是几个代码示例,具体说明了顶层绑定的工作方式、允许的操作以及会导致错误的操作。

示例1:在函数体中正确引用后续绑定

(define (f x) (+ x b)) ; 函数 f 在其体中引用了后续定义的 b
(define b 3)           ; b 在此处定义
; 调用 (f 10) 将得到 13,因为调用发生在 b 被绑定为 3 之后。

这个例子是可行的,因为函数 f 的定义虽然引用了尚未定义的 b,但 f函数体b 被求值并绑定为 3 之前不会被执行。任何对 f 的调用都发生在 b 完成定义之后。

示例2:引用先前的绑定

(define b 3)
(define c (+ b 4)) ; c 被求值为 7

这就像在 ML 或 let* 中一样,按顺序求值,当计算 c 时,b 已经是 3,因此没有问题。

错误示例1:在非函数体中引用后续绑定

(define d (+ e 5)) ; 错误!在 e 定义之前就试图使用它。
(define e 10)

如果取消这行注释并运行,Racket 会报错:reference to an identifier before its definition。这正是我们强调的错误。

错误示例2:在同一文件中重复定义(遮蔽)

(define (f x) (* x 2))
(define f 17) ; 错误!在同一文件中不能有两个同名的绑定。

尝试这样做会导致错误:duplicate definition for identifier。在同一个文件(模块)内,你不能对同一个变量名进行多次定义或遮蔽。

交互环境(REPL)的特殊性

上一节我们介绍了文件中的顶层绑定规则,本节中我们来看看交互环境(REPL)的特殊情况。REPL 的行为并不完全像 letrec,也不完全像 let*,事实上,它的行为并不总是完全“正确”。

在正常使用中,REPL 通常会如你所愿地工作,就像我们在 ML 和 Racket 中使用的那样。但是,存在一些边界情况,特别是当你试图遮蔽(即使是标准库中)已经定义的内容,并且定义递归函数时,事情可能会出错。

因此,一个简单的解决方案是:

  • 建议:在 REPL 中,不要定义你自己的递归函数
  • 建议:调用由你运行的文件所定义的递归函数是没问题的。
  • 建议:将你自己的递归函数放在文件中定义,然后运行该文件,这样可以避免此问题。

模块系统的前瞻(可选)

最后,作为一个可选的知识点,我想提一下 Racket 的模块系统。从技术上讲,我之前称其为“顶层绑定”并不完全准确。在 Racket 中,每个文件都隐式地处于其自己的模块中。

不过,该模块内部仍然具有 letrec 风格的语义,因此我展示的所有代码示例都是正确的。关键在于,跨文件时,并不是一个大的 letrec,而是每个文件有自己独立的 letrec 环境。

你可以从一个文件(模块)引入内容到另一个文件,并且你可以在自己的模块中遮蔽从其他模块引入的定义。例如,+ 只是一个在标准库其他模块中定义的函数,你甚至可以在自己的文件中遮蔽 + 函数,但每个文件内部仍然只能定义一次。当然,这种遮蔽通常是不好的风格,但它有助于解释 Racket 的模块系统,这将是课程后期的一个可选主题。

总结

本节课中我们一起学习了 Racket 中顶层绑定的语义。核心要点是:文件中的 define 序列具有类 letrec 的语义,允许引用后续绑定,但必须仅在函数体中进行此类引用,并确保函数在依赖项定义后才被调用。同时,我们了解到在同一文件内不能重复定义(遮蔽)变量名,并注意到了 REPL 环境中的特殊注意事项。最后,我们前瞻了每个文件都是一个独立模块的概念,这为理解更复杂的代码组织方式奠定了基础。

110:使用 set! 进行变量修改 🛠️

在本节中,我们将学习 Racket 语言中的变量修改功能。具体来说,我们将探讨 set! 语句的工作原理、它可能引发的问题,以及如何避免这些问题。通过本节的学习,你将理解为什么在函数式编程中应谨慎使用变量修改。


概述 📋

在本节中,我们将要学习 Racket 语言中的变量修改功能。虽然 Racket 主要是一种函数式编程语言,但它确实提供了 set! 语句来实现变量的赋值和修改。我们将通过具体示例来展示 set! 的使用方法,并讨论其潜在的问题和解决方案。


Racket 中的赋值语句:set! 🔄

上一节我们介绍了 Racket 的基本语法和函数定义,本节中我们来看看 Racket 中的赋值语句 set!set! 是 Racket 中用于修改变量值的语句,其语法如下:

(set! x e)

其中,x 是一个已经存在于环境中的变量,e 是一个表达式。set! 会计算表达式 e 的值,并将变量 x 绑定到该值。此后,任何在环境中使用 x 的地方都会看到更新后的值。

需要注意的是,如果某个代码在 set! 执行之前已经查找过 x 的值,那么它看到的是修改前的值。只有在 set! 执行之后查找 x 的代码才会看到更新后的值。


顺序执行:begin 表达式 📝

一旦引入了像 set! 这样的副作用操作,有时我们需要按顺序执行一系列操作。虽然本节不会使用 begin 表达式,但有必要介绍一下它的用法。begin 表达式是 Racket 中的顺序执行操作符,其语法如下:

(begin
  e1
  e2
  ...
  en)

begin 会按顺序执行每个表达式 e1e2、...、en,并返回最后一个表达式 en 的值。前面的表达式通常用于执行副作用操作,例如修改变量或打印输出。


示例:set! 的使用与问题 ⚠️

让我们通过一个具体示例来展示 set! 的使用及其可能引发的问题。以下是示例代码:

(define b 3)
(define f (lambda (x) (* 1 (+ x b))))
(define c (f 4))
(set! b 5)
(define z (f 4))
(define w c)

以下是代码的逐步解释:

  1. 第一行定义变量 b 并绑定到值 3
  2. 第二行定义函数 f,它接受参数 x 并返回 (* 1 (+ x b))。在函数式编程中,由于闭包的存在,f 应该始终将 b 的值(即 3)加到参数 x 上。
  3. 第三行调用 f 并传入 4,此时 b 的值为 3,因此 c 被绑定到 7
  4. 第四行使用 set!b 的值修改为 5
  5. 第五行再次调用 f 并传入 4,此时 b 的值为 5,因此 z 被绑定到 9
  6. 第六行将 c 的值绑定到 w,此时 c 的值仍然是 7

通过这个示例,我们可以看到 set! 修改了 b 的值,导致函数 f 的行为发生了变化。这可能会引发意想不到的问题,尤其是在大型软件项目中。


如何避免 set! 引发的问题 🛡️

如果我们希望函数 f 始终使用 b 在定义时的值(即 3),而不是在调用时的更新值,我们可以通过创建局部副本来实现。以下是具体的实现方法:

(define f
  (let ([local-b b])
    (lambda (x) (* 1 (+ x local-b)))))

在这个实现中,我们使用 let 表达式创建了一个局部变量 local-b,并将其初始化为 b 的当前值。这样,函数 f 在调用时会使用 local-b 的值,而不会受到外部 b 值修改的影响。

然而,这种方法仍然存在潜在问题。例如,如果 +* 这些函数被修改,函数 f 的行为仍然可能发生变化。为了避免这种情况,我们可能需要为所有依赖的变量创建局部副本:

(define f
  (let ([local-b b]
        [local-plus +]
        [local-times *])
    (lambda (x) (local-times 1 (local-plus x local-b)))))

尽管这种方法在语义上是正确的,但在实际编程中并不常用,因为它会增加代码的复杂性。


Racket 的设计选择 🧩

Racket 的设计者意识到,如果允许修改像 + 这样的内置函数,可能会导致大量代码出现问题。因此,Racket 采取了一种折中方案:如果一个文件在定义某个变量时没有使用 set! 修改它,那么其他文件也不能修改它。由于 +* 等内置函数在定义时没有使用 set!,因此我们无需担心它们被修改。

这种设计选择简化了编程模型,使得开发者可以更安全地使用内置函数和变量。


总结 📚

本节课中我们一起学习了 Racket 中的变量修改功能。我们介绍了 set! 语句的基本用法,展示了它可能引发的问题,并探讨了如何通过创建局部副本来避免这些问题。此外,我们还了解了 Racket 的设计选择,即限制对内置函数的修改,以确保代码的稳定性和可预测性。

在实际编程中,应谨慎使用 set!,尤其是在修改全局变量时。通过避免不必要的变量修改,我们可以编写出更简洁、更可靠的代码。

111:关于cons的真相 🔍

在本节课中,我们将继续学习Racket作为动态类型语言的特点,通过揭示我们一直用来构建列表的cons原语的真相。我们将了解cons不仅可以构建列表,还可以构建“对”(pair),并探讨列表与对之间的区别与联系。

概述

本节我们将学习cons函数的本质。我们会发现,在Racket中,列表实际上是由一系列嵌套的“对”构成的,而cons正是构建这些“对”的基础。我们将通过代码示例来理解如何创建和操作“对”与列表,并学习相关的内置函数和编程风格建议。


列表的本质:嵌套的对

上一节我们介绍了Racket的动态类型特性,本节中我们来看看cons函数的完整功能。cons接受两个参数并创建一个“对”。在Racket这类动态类型语言中,一个列表本质上就是一系列以空列表null结尾的嵌套“对”。

以下是一个代码示例,展示了如何用cons创建一个“对”:

(define pr (cons 1 (cons #t "hi")))

这段代码定义了一个变量pr。它不是一个列表,而是一个“对”。其第一个位置是1,第二个位置是另一个包含#t"hi"的“对”。在ML语言中,这类似于(1, #t, "hi"),但在Racket中我们使用cons来构建。

在REPL中打印pr,会看到如下输出:

(1 #t . "hi")

输出中"hi"前的点.表明这不是一个列表,而是一个“对”。

现在,让我们将其与一个真正的列表进行对比:

(define lst (cons 1 (cons #t (cons "hi" null))))

这里同样使用了cons,但最后一个cons的第二个参数是null(空列表)。这就构成了一个列表,因为列表的定义就是一系列cons,其中每个“对”的第二个位置是下一个cons,直到遇到null为止。

因此,列表是嵌套“对”的一种特定形式。


访问对中的元素:car与cdr

理解了“对”的结构后,我们来看看如何访问其中的元素。访问“对”的组成部分使用carcdr函数。

  • car:获取“对”的第一个元素,类似于ML中的#1
  • cdr:获取“对”的第二个元素,类似于ML中的#2

以下是使用示例:

给定之前的“对”pr

(cdr (cdr pr)) ; 返回 "hi"

这获取了第二个“对”的第二个元素。

给定列表lst

(cdr (cdr lst)) ; 返回 ("hi"),这是一个单元素列表
(car (cdr (cdr lst))) ; 返回 "hi"

要获取列表中的实际元素"hi",需要对结果再使用car

为了方便,Racket提供了一些组合函数。例如,caddr函数等价于(car (cdr (cdr x))),它是标准库中的内置函数。


区分列表与对

我们可能需要判断一个值是列表还是“对”。Racket提供了相应的内置谓词函数。

以下是相关的判断函数:

  • list?:判断一个值是否为真列表(proper list),即以null结尾的嵌套“对”。
  • pair?:判断一个值是否是由cons构建的“对”。所有真列表(除了空列表null本身)也是“对”。
  • null?:判断一个值是否为空列表。

示例:

(list? pr) ; 返回 #f,因为 pr 不是以 null 结尾
(pair? pr) ; 返回 #t,因为 pr 是由 cons 构建的
(list? lst) ; 返回 #t
(and (list? lst) (pair? lst)) ; 返回 #t,列表也是“对”(除了空列表)

需要注意的是,像length这样的列表函数只对真列表有效。如果对非列表(即使是由cons构建的)使用length,会引发错误。这种不以null结尾的、由cons构建的结构,有时被称为非真列表(improper list)。


为何如此设计及编程风格

为什么Racket要这样设计,允许cons同时用于构建“对”和列表?

在动态类型语言中,没有类型检查器来严格区分列表和“对”的类型。因此,与其像ML那样使用逗号,构建元组、使用::构建列表,不如统一使用cons来构建这两种结构。程序员需要自己留意哪些值是列表,哪些是“对”。

关于编程风格,有以下建议:

  1. 处理未知大小的集合时,应使用真列表。这是约定俗成的做法,记得在末尾使用null
  2. 当只需要一个简单的二元组或三元组时,使用“对”是完全可以的。Racket没有内置的三元组,但可以通过嵌套cons来实现。
    ; 一种构建三元组的方式
    (define triple1 (cons 1 (cons #t "hi"))) ; 打印为 (1 #t . "hi")
    ; 另一种结构不同的方式
    (define triple2 (cons (cons 1 #t) "hi")) ; 打印为 ((1 . #t) . "hi")
    ; 访问元素
    (cdr (car triple2)) ; 返回 #t
    
  3. 更好的风格是定义自己的数据类型(我们将在后续章节看到)。这比单纯使用cons更易于管理和理解代码的组织结构。

内置谓词的行为总结如下:

  • list? 对真列表(包括空列表)返回#t,对非真列表返回#f
  • pair? 对所有由cons构建的值返回#t(这包括所有非空的真列表和所有的“对”),对空列表返回#f

总结

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

  1. cons函数在Racket中的本质是构建一个“对”(pair)。
  2. 列表是由一系列嵌套的“对”构成,并以空列表null结尾的特殊结构。
  3. 使用carcdr函数可以访问“对”中的元素,并且有像caddr这样的组合函数。
  4. 可以使用list?pair?等谓词来区分列表和“对”。
  5. 在动态类型语言中,统一使用cons简化了语法,但程序员需注意值是否为真列表。
  6. 在编程风格上,处理集合用列表,简单固定结构可用“对”,但定义自定义类型通常是更佳选择。

理解“对”与列表的关系,是掌握Racket及其家族语言(如Scheme)中数据结构的核心。

112:可变对与 MCons 单元 🧩

在本节课中,我们将学习 Racket 中的可变数据结构。我们将了解为什么标准的 cons 单元是不可变的,以及如何通过 mcons 单元来创建可以修改其内容的数据结构。理解这两种类型的区别对于编写高效且意图清晰的程序至关重要。

标准 Cons 单元的不可变性

上一节我们介绍了 cons 单元,它用于构建对和列表。本节中我们来看看 cons 单元的一个重要特性:不可变性

在 Racket 中,一旦一个 cons 单元被创建,其 carcdr 字段的内容就无法被改变。这与 Scheme 等 Racket 的前身语言不同。Racket 做出这一设计选择,是为了让使用 cons 构建的对和列表像 ML 语言中的元组和列表一样,具有不可变性。

不可变性的最大优势是消除了别名(aliasing)的困扰。因为数据无法被修改,所以一个列表的一部分是否是另一个列表的别名变得无关紧要,程序的行为不会因此产生差异。我们在课程的第一部分曾重点讨论过这一点。此外,在像 Racket 这样的动态类型语言中,不可变性还带来了一个额外的好处:像 list? 这样的过程可以实现得更高效。因为在创建 cons 时,我们就能立即知道它是否构成一个列表,并且这个状态永远不会改变,因此无需在每次调用 list? 时遍历整个列表来检查其结构。

可变的需求与 set! 的局限

但是,假设我们确实需要修改数据结构的内容,该怎么办呢?了解什么可行、什么不可行非常重要,因为在某些特定的编程模式中,有控制地使用可变性会非常有用。

首先需要明确的是,set! 操作符并不能修改 cons 单元的内容。它只能改变一个变量(标识符)所绑定的值。

以下是一个示例:

(define x (cons 14 null)) ; x 指向一个包含 14 的 cons 单元
(define y x)              ; y 也指向同一个 cons 单元
(set! x (cons 42 null))   ; set! 改变了 x,让它指向一个新的 cons 单元

执行后,x 现在指向新的列表 (42),但 y 仍然指向原来的列表 (14)set! 改变了变量 x 的引用,但没有改变原先那个 car 为 14 的 cons 单元本身。

如果我们希望 xy 共享的同一个数据结构内部能被修改,这在标准的 cons 单元上是无法实现的。Racket 的前身 Scheme 提供了 set-car!set-cdr! 来做到这一点,但 Racket 为了保持不可变性的优势,没有提供这些操作。

引入 MCons 单元

那么,在 Racket 中如何实现可变的对呢?答案是使用一个独立的内置数据类型:MCons 单元

创建 MCons 单元的函数是 mcons,它类似于 cons,但创建的是可变单元。与 MCons 单元交互需要使用一套专门的函数:

以下是相关操作:

  • (mcons a b): 创建一个 caracdrb 的可变对。
  • (mcar mpair): 获取可变对 mpaircar 部分。
  • (mcdr mpair): 获取可变对 mpaircdr 部分。
  • (set-mcar! mpair val): 将可变对 mpaircar 部分修改为 val
  • (set-mcdr! mpair val): 将可变对 mpaircdr 部分修改为 val
  • (mpair? obj): 判断 obj 是否是一个可变对。

让我们看一个例子:

(define mpr (mcons 1 (mcons #t "hi")))
; mpr 指向一个 MCons,其 car 是 1,cdr 是另一个 MCons (其 car 是 #t, cdr 是 "hi")

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/uwash-cse341-prog-abc/img/7d6ca30fbc3682cea34f7dc79dc2f00a_7.png)

(mcar mpr)               ; => 1
(mcdr mpr)               ; => (mcons #t "hi")
(mcar (mcdr mpr))        ; => #t

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/uwash-cse341-prog-abc/img/7d6ca30fbc3682cea34f7dc79dc2f00a_8.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/uwash-cse341-prog-abc/img/7d6ca30fbc3682cea34f7dc79dc2f00a_9.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/uwash-cse341-prog-abc/img/7d6ca30fbc3682cea34f7dc79dc2f00a_10.png)

(set-mcdr! mpr 47)       ; 修改 mpr 的 cdr 部分
mpr                      ; => (mcons 1 47)

(set-mcar! (mcdr mpr) 14) ; 错误!因为此时 (mcdr mpr) 是 47,不是一个 MCons 单元。

不可混合使用

需要特别注意,cons 单元(不可变列表)和 mcons 单元(可变结构)的操作是不能混用的。

以下是关键区别:

  • lengthmaplist? 这样的列表操作只能用于由 cons 构成的标准不可变列表。对 mcons 结构使用它们会导致错误。
  • mcarset-mcar! 这样的操作只能用于 mcons 单元。对标准的 cons 单元使用它们也会导致错误。

这种分离强制程序员明确其数据结构的意图:是需要不可变的、可安全共享的数据,还是需要内部状态可变的实体。

总结

本节课中我们一起学习了 Racket 中可变数据结构的实现。我们了解到:

  1. 标准的 cons 单元是不可变的,这带来了别名无关性和潜在的性能优化等优势。
  2. set! 只能修改变量的绑定,不能修改 cons 单元的内容。
  3. 当确实需要可变对时,应使用 mcons 来创建 MCons 单元,并配套使用 mcarmcdrset-mcar!set-mcdr! 等操作。
  4. 不可变的 cons 列表和可变的 mcons 结构在 Racket 中是严格区分的,其操作函数不能交叉使用。

根据需求选择不可变或可变的数据结构,是编写清晰、健壮 Racket 程序的重要一环。

113:延迟求值与 Thunk

在本节课中,我们将重点学习编程语言语义中的一个核心概念:表达式的求值时机。理解这个概念对于掌握后续课程中将要介绍的程序设计范式至关重要。

概述

在编程语言中,每种语言结构的语义都必须明确规定其子表达式是否求值以及何时求值。本节我们将以函数调用和条件表达式这两个关键结构为例,深入探讨求值时机的重要性,并介绍一种名为“Thunk”的延迟求值技术。

函数调用与条件表达式的求值时机

在 ML、Racket 等语言中,函数调用和条件表达式的求值规则截然不同。

  • 函数调用:在函数体执行之前,所有参数会先被立即求值。每个参数只求值一次,然后函数体通过变量(即函数参数)来引用这些已经计算好的结果。
  • 条件表达式(如 if):它包含三个子表达式:测试条件、真分支和假分支。我们不会立即求值所有三个表达式。我们只求值第一个(测试条件),然后根据其结果决定求值另外两个中的哪一个,未被选中的分支则永不求值

这种语义差异对于编写正确的程序至关重要。下面我们通过代码示例来具体说明。

代码示例:求值时机的影响

首先,我们来看一个普通的阶乘函数实现。

(define (factorial-normal x)
  (if (= x 0)
      1
      (* x (factorial-normal (- x 1)))))

调用 (factorial-normal 5) 会得到 120。即使计算 (factorial-normal 500),Racket 也能正确计算出结果。这是因为 if 表达式在 x 不为 0 时,只会求值假分支(即乘法运算和递归调用),而不会求值真分支(即 1)。

接下来,我们尝试用函数包装 if,看看会发生什么。

(define (my-if-bad e1 e2 e3)
  (if e1 e2 e3))

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/uwash-cse341-prog-abc/img/94013c7f5706f1905723185ab29861f8_18.png)

(define (factorial-bad x)
  (my-if-bad (= x 0)
             1
             (* x (factorial-bad (- x 1)))))

这个版本的 factorial-badmy-if-bad 替代了 if但是,factorial-bad 对于任何输入都将永不终止。原因在于,在调用函数 my-if-bad 之前,Racket 会先立即求值它的所有三个参数。这意味着即使 x 为 0,它也会去求值第三个参数 (* x (factorial-bad (- x 1))),从而触发无限递归。

这个例子清楚地表明,if 作为语言原语的特殊性在于它不会立即求值所有分支。那么,如果我们确实需要一个行为类似 if 的函数,该怎么办呢?这就需要引入延迟求值的技术。

引入 Thunk 实现延迟求值

我们可以通过让函数接受“零参数函数”(即 Thunk)作为参数来实现延迟求值。Thunk 是一个封装了表达式的函数,只有调用它时,内部的表达式才会被求值。

下面是一个可以正常工作的 my-if 函数版本:

(define (my-if-strange-but-works e1 e2 e3)
  (if e1 (e2) (e3)))

注意,这里的 e2e3 预期是零参数函数。在 Racket 中,调用零参数函数 e2 的写法是 (e2)。这个版本的 my-if 会先求值 e1,然后根据结果调用 e2e3 中的一个函数,从而获得最终结果。未被调用的那个函数,其内部的表达式永远不会被执行。

相应地,使用这个函数的阶乘版本需要将分支表达式包装成 Thunk:

(define (factorial-ok x)
  (my-if-strange-but-works (= x 0)
                           (lambda () 1)
                           (lambda () (* x (factorial-ok (- x 1))))))

现在,调用 (factorial-ok 500) 就能正确计算出结果了。因为传递给 my-if-strange-but-works 的后两个参数已经是函数(Thunk)本身,在函数调用前不会被求值,只有在 my-if-strange-but-works 内部,根据条件选择了其中一个 Thunk 并调用它时,相应的计算才会发生。

核心概念辨析

在 Racket 中,理解以下三者的区别非常重要:

  1. 表达式 E:例如 (+ 3 4)。它会被立即求值,得到结果 7
  2. Thunk (lambda () E):例如 (lambda () (+ 3 4))。这是一个函数,它不会立即求值 E。只有当你调用这个 Thunk 时,才会求值 E 并得到结果。
  3. 调用 Thunk (e):假设 e 是一个求值后得到 Thunk 的表达式。(e) 表示调用这个 Thunk,从而触发其内部表达式 E 的求值。

总结

本节课我们一起学习了编程语言中表达式求值时机的重要性。我们通过对比函数调用(立即求值)和条件表达式(惰性求值)的语义,理解了错误地强制立即求值可能导致的问题(如无限递归)。为了解决这个问题,我们引入了 Thunk 的概念,即通过将表达式包装成零参数函数来实现延迟求值。这种技术允许我们控制计算发生的时机,是后续学习更强大编程范式(如流和惰性求值数据结构)的基础。记住关键点:要延迟一个表达式的求值,就把它放进一个函数里;需要结果时,再调用这个函数。

114:避免不必要的计算 🚀

在本节课中,我们将学习如何使用 thunk 来延迟可能不需要的计算。我们将探讨这种方法的优点与缺点,并通过具体示例理解其工作原理。最后,我们将介绍如何通过“惰性求值”结合 thunk 与记忆化技术,实现最佳性能。


使用 Thunk 延迟计算

上一节我们介绍了 thunk 的基本概念。本节中,我们来看看如何使用 thunk 来避免执行不必要的昂贵计算。

Thunk 允许你在不需要结果时跳过昂贵的计算。这在以下代码框架中非常有用:

(define (some-function thunk)
  (if (some-condition)
      (do-something-without-thunk)
      (thunk))) ; 仅在需要时调用 thunk

如果你不使用 thunk,而是直接传入昂贵计算的结果,那么无论是否需要,计算都会提前执行,造成浪费。


Thunk 的局限性

然而,thunk 并非在所有情况下都是最佳选择。考虑以下场景:

(if condition1 (thunk) ...)
(if condition2 (thunk) ...)
(if condition3 (thunk) ...)

以下是这种情况的分析:

  • 如果所有条件都为假,thunk 从未被调用,避免了计算,这是理想情况。
  • 但如果多个条件为真,thunk 会被重复计算多次,这比提前计算一次结果更浪费性能。

这就是我们需要权衡的地方。接下来,我们将通过一个具体示例来演示这个问题。


具体示例分析

为了让问题更直观,我们创建一个执行缓慢的加法函数:

(define (slow-add x y)
  (sleep 1) ; 模拟耗时操作
  (+ x y))

slow-add 3 4 需要大约一秒才能返回结果 7。

现在,我们定义一个使用 thunk 的乘法函数 my-mult

(define (my-mult x y-thunk)
  (cond [(= x 0) 0]
        [(= x 1) (y-thunk)]
        [else (+ (y-thunk) (my-mult (- x 1) y-thunk))]))

这个函数的工作原理如下:

  • 如果 x 为 0,直接返回 0,不调用 thunk。
  • 如果 x 为 1,调用一次 thunk 并返回结果。
  • 如果 x 大于 1,则递归调用自身,每次递归都会调用一次 thunk。

让我们测试不同情况:

  • (my-mult 0 (lambda () (slow-add 3 4))) 立即返回 0,性能最佳。
  • (my-mult 1 (lambda () (slow-add 3 4))) 耗时约一秒,这是必要的计算。
  • (my-mult 2 (lambda () (slow-add 3 4))) 耗时约两秒,因为 thunk 被计算了两次。
  • (my-mult 20 ...) 将耗时约二十秒,性能急剧下降。

相比之下,如果提前计算 slow-add 的结果:

(let ([precomputed (slow-add 3 4)])
  (my-mult 20 (lambda () precomputed)))

那么无论 x 是多少,slow-add 都只计算一次,后续调用直接使用缓存值。但这样做的代价是,即使 x 为 0,我们也不得不先执行那耗时一秒的计算。


寻求最佳方案:惰性求值

那么,我们能否两全其美呢?答案是肯定的,通过惰性求值实现。

惰性求值的核心思想是:

  1. 延迟计算:直到真正需要结果时才执行计算。
  2. 记忆化:一旦计算结果,就将其存储起来。后续任何对同一结果的请求都直接返回存储的值,避免重复计算。

支持这种求值策略的语言称为惰性语言,Haskell 是其中最著名的例子。

虽然 Racket 本身不是惰性语言(函数参数在调用点会立即求值),但我们可以利用已学的知识——thunk 和可变对(mcons)——自己实现它。

在下一节中,我们将动手实现一个支持记忆化的惰性求值器,并重新审视上面的乘法示例,展示如何同时获得“零参数快速”和“多次调用高效”的优势。


本节课中,我们一起学习了使用 thunk 避免不必要计算的基本方法,分析了其优缺点,并通过示例看到了重复计算可能带来的性能问题。最后,我们引出了“惰性求值”作为结合延迟计算与记忆化的最佳解决方案,为下一节的实现做好了准备。

115:延迟与强制

在本节课中,我们将学习如何通过“延迟”与“强制”的概念来实现惰性求值,从而避免不必要的计算。我们的目标是:对于昂贵的计算,仅在需要时才执行;如果需要多次使用结果,则存储第一次计算的结果以供后续使用。

上一节我们讨论了通过函数封装来延迟计算。本节中,我们将看看如何结合一点“突变”技术,来实现一个能记住结果的延迟计算机制。

实现延迟与强制

我们将定义两个函数:my-delaymy-force(为避免与Racket标准库中的同名函数冲突)。以下是它们的工作原理。

延迟计算:my-delay

my-delay 接收一个thunk(一个无参数的函数)。它不会立即调用这个thunk,而是快速返回一个可变的序对(pair)。

代码实现如下:

(define (my-delay thk)
  (mcons #f thk))

这个序对的第一个元素(car)初始化为 #f(假),表示thunk尚未被求值。第二个元素(cdr)存储着待执行的thunk本身。这个过程非常快,没有进行任何实际计算。

强制求值:my-force

my-force 接收由 my-delay 返回的“承诺”(promise)。它的职责是:当第一次被调用时,执行thunk并存储结果;之后再次调用时,直接返回存储的结果。

代码实现如下:

(define (my-force p)
  (if (mcar p)
      (mcdr p)
      (begin
        (set-mcar! p #t)
        (set-mcdr! p ((mcdr p)))
        (mcdr p))))

其逻辑是:

  1. 检查 car 是否为 #t(真)。如果是,说明thunk已被求值过,直接返回 cdr 中存储的结果。
  2. 如果 car#f(假),则执行 begin 块中的三个步骤:
    • (set-mcar! p #t):将 car 设为 #t,标记此承诺已被求值。
    • (set-mcdr! p ((mcdr p))):取出 cdr 中的thunk并执行它(((mcdr p))),然后将结果存回 cdr
    • (mcdr p):返回 cdr 中存储的结果(即刚计算出的值)。

本质上,我们利用了一个可变序对作为简单的记忆单元:car 作为“是否已求值”的标志位,cdr 则要么存放thunk,要么存放计算结果。

如何使用延迟与强制

使用方式很简单:在任何你原本需要传入一个thunk的地方,改为传入 (my-delay thk) 返回的承诺。而在任何你需要获取计算结果的地方,则调用 (my-force promise)

以下是具体的使用示例。

示例回顾与改进

回顾上一节的乘法函数 my-mult,它接受一个数字 x 和一个返回数字的thunk。我们曾面临一个困境:当 x > 1 时,thunk会被调用多次,如果thunk本身计算很慢(例如模拟的 slow-add),效率就会很低。

现在,我们可以用 my-delaymy-force 来优化它。思路是:将昂贵的计算包装成一个承诺,并传递给一个会“强制”该承诺的thunk

优化后的调用方式如下:

; 1. 创建一个承诺,延迟执行 (slow-add 3 4)
(define p (my-delay (lambda () (slow-add 3 4))))

; 2. 传递给 my-mult 的thunk会强制这个承诺
(my-mult 100 (lambda () (my-force p)))

在这个例子中:

  • (lambda () (my-force p)) 是一个thunk。每当它被调用,就会去强制承诺 p
  • my-mult 内部第一次需要这个thunk的值时,会调用它,从而触发 (my-force p)。这是 slow-add 第一次也是唯一一次被执行,结果被存入 p
  • my-mult 内部后续再次需要这个thunk的值(例如循环中)时,调用 (lambda () (my-force p)) 会再次触发 (my-force p)。但此时 pcar 已是 #t,所以直接返回之前存储的结果,速度极快。

效果对比

让我们对比三种策略在调用 (my-mult x thunk) 时的表现:

  1. 原始thunk(纯延迟)

    • x = 0:最佳。thunk从未被调用,slow-add 未执行。
    • x = 1:良好。thunk被调用一次,slow-add 执行一次。
    • x > 1(如100):糟糕。thunk被调用多次(100次),slow-add 也执行了多次(100次)。
  2. 预计算thunk(提前求值)

    • x = 0:一般。尽管 my-mult 未使用thunk,但 slow-add 在创建thunk前已执行了一次。
    • x = 1:良好。slow-add 只执行了一次(预计算时)。
    • x > 1:良好。slow-add 只执行了一次(预计算时),thunk只是返回值。
  3. 使用承诺(延迟+记忆)

    • x = 0:最佳。承诺从未被强制,slow-add 未执行。
    • x = 1:良好。承诺被强制一次,slow-add 执行一次。
    • x > 1:最佳。承诺仅在第一次被强制时执行 slow-add,后续99次都是直接返回值。

可以看到,使用承诺的方案结合了前两种方案的优点:它既能在不需要时完全避免计算(如 x=0 的情况),又能在需要多次计算时确保只计算一次并记住结果(如 x=100 的情况)。

总结

本节课中,我们一起学习了如何利用“延迟”和“强制”的概念,并结合可变数据(突变)来实现惰性求值与记忆化。

  • 我们定义了 my-delay 来创建一个承诺,它包装了一个计算但暂不执行。
  • 我们定义了 my-force强制一个承诺:首次强制时执行计算并存储结果;后续强制时直接返回存储的结果。
  • 通过将承诺与一个会调用 my-force 的thunk结合使用,我们可以在复杂的逻辑(如 my-mult 函数)中实现高效的计算:仅在绝对需要时才计算,且绝不重复计算

这种模式在函数式编程中非常有用,它允许我们声明式地表达计算依赖,同时由运行时系统智能地管理计算过程,从而提升程序效率。

116:流的使用 🌀

在本节及下一节中,我们将讨论流。这是一种不同的编程范式,它也需要某种延迟求值的概念。其实现将利用 thunk 来达成这一目标。

什么是流?

在计算机科学中,我们使用“流”这个词来表示一个无限的值序列。它可以持续产生你所需的值,表现得像一个无限大的事物。然而,关于无限大的事物,你实际上无法真正创建它们。我们需要一种方式来表示可以永远持续下去的东西,而我们将使用的核心理念是:使用一个函数来延迟序列大部分内容的求值,只生成其他计算所需的那部分前缀序列

我们不需要任何新的语言结构。这只是使用 thunk 等概念进行编程,但它是一种强大的概念,可以在许多不同的软件系统中以有效的方式分配工作。

其理念是:生成流的程序部分知道如何创建你需要的任意数量的值,但不知道你需要多少。而流的消费者可以在处理过程中请求这些值,而无需了解生成它们的任何过程。

事实证明,这在软件系统中经常出现。如果你不熟悉以下任何例子也没关系,但我还是想提一下,以便了解的人能明白。一种情况是,如果你需要实现代码来响应用户事件(如鼠标点击、键盘按下等),我们在课程早期看到过可以用回调函数处理,但另一种方式可以将其视为一个事件流。我们会根据需要请求每个事件,然后根据目前收集到的事件计算结果,而其他人则会在事件发生时生成它们。

如果你曾经在 Unix shell 系统中使用管道编程,你会发现第二个命令会根据需要从第一个命令中拉取数据,因此它将第一个命令视为一个流,而第一个命令的输出正在生成这个流。此外,这与电气工程和电路也有很好的联系:如果你考虑一个带有反馈的时序电路,你可以将其在不同输出线上发送的输出值视为形成一个无限长的序列,然后读取这些值的电路可以读取它们感兴趣的部分。总之,这些只是展示这是一个通用概念的示例,即使你觉得它有点抽象,你也会在与此材料相关的作业中看到一些更简单、更有趣的例子。

如何表示流?

我们想用一种不实际生成无限长列表的方式来代表流。以下是我们将采用的方法:我们将流表示为一个 thunk

因此,一个流就是一个 thunk,但不是任意类型的 thunk。它是一个当你调用时,会返回一个序对的 thunk。其中,car 部分是序列中的下一个(第一个)元素,而 cdr 部分是一个代表从第二个元素到无穷的流。所以,它是一个流,当你使用它时,你会得到下一个值。

在本节中,我将向你展示如何使用这些东西。然后在下一节中,我们将看到如何定义自己的流。通常,先使用它们有助于解释它们是什么,并在我们尝试创建之前获得更好的理解。

使用流:一个例子

我已经加载了一个文件,其中包含我将在下一节展示的流。其中一个流是 2 的幂的无限序列。这个流返回的第一个元素是 2(我设置它以 2 开始),然后是 4、8、16、32,以此类推,因为我们不知道需要多少个 2 的幂。

当我提到 powers-of-two 时,正如你在这里看到的,我得到的只是一个过程,因为我们的流是 thunk,当你调用它们时,会返回一个序对。

那么如何调用一个 thunk 呢?你把它放在括号里:(powers-of-two)。看,我得到了一个序对,其第一个分量是 2,第二个分量是另一个过程(实际上也是一个 thunk)。

如果我想要序列中的第一个元素,我可以说 (car ((powers-of-two)))。如果我想要第二个元素,我需要调用 cdr 来获取另一个流。流是一个 thunk,所以我需要调用它,然后取它的 car(car ((cdr ((powers-of-two))))),这得到了 4。

如果我想要序列中的下一个元素呢?这个值是 4,cdr 是另一个流(一个 thunk)。所以你调用它,返回一个序对,然后取它的 car,就会得到 8。

当然,我们不会一直这样编程来获取 16 或 32。其理念是,我们会使用某种递归函数,将这个“下一个流”传递给递归调用。然后我们应用那个流来获取一个序对,取 car 得到下一个元素。因此,如果你想计算前 100 个 2 的幂的和,你只需要一个小的递归函数,在处理过程中使用这个流。

与其展示那个,我想展示一些更通用的东西。让我们定义一个递归函数,我称之为 number-until

它将接收一个流和一个函数(我称之为 tester)。这个函数的作用是:计算在处理流元素时,需要处理多少个元素,直到 tester 第一次返回真值。如果 tester 从不返回真值,我们将进入无限循环。否则,我们将在第一次得到真值时停止,并返回我们已经处理过的元素数量。

我将用一个尾递归辅助函数来实现。这里有一个 letrec。我将接收当前的流(包含所有尚未处理的元素)、累加器(目前的结果),然后在这里放一些东西。然后,我将用初始的流和累加器(初始为 1)调用 F

现在,F 的主体需要做的是:首先,调用那个流(我们知道流是一个 thunk)。如果我调用它,我应该得到这个序对:第一个元素和剩余元素的流。现在有了这个序对,让我们在 car 上调用 tester。如果返回真值,我就完成了,返回 ans。否则,用序对的 cdr(这是我的新流)和 ans+1 再次调用 F。注意,这里传递的是 cdr(一个 thunk),而不是调用它得到的序对,因为 F 期望一个流,然后 F 本身会调用那个 thunk 来获取序对。

(define (number-until stream tester)
  (letrec ([F (lambda (stream ans)
                (let ([pr (stream)]) ; 调用流 thunk 获取序对
                  (if (tester (car pr))
                      ans
                      (F (cdr pr) (+ ans 1)))))])
    (F stream 1)))

现在,如果我调用 number-until,传入流 powers-of-two 和一个函数,该函数接收一个数字并判断它是否等于 16:

我得到 4。这意味着我遍历了流 4 次,直到得到等于 16 的数。

让我们再玩一下。继续直到我得到一个大于 1000 的数:

2 的幂的好处是它们增长得非常快,所以这尝试了 339 次。如果我提出一个 10 倍大的数,可能需要 343 次尝试;再大 10 倍,可能只需要 346 次尝试,因为 2 的幂增长得非常快。

编程注意事项

需要指出的是,在编程流时,你很容易在括号上犯很多错误。要仔细思考:我是要传入一个流,还是要传入调用 thunk 后得到的序对?如果我搞错了,在这里加了括号,向 number-until 传入了一个序对,那么在这里,你会在屏幕顶部看到,当你尝试将一个序对当作函数调用时,会得到一个很大的错误消息,比如“过程应用:期望过程,但得到了序对 (2 . #procedure:...)”。因此,你必须非常仔细地思考 thunk 和序对之间的区别。如果你能做到这一点,你就可以通过使用流和漂亮的递归函数来进行一些优美的编程,并获得有趣的结果。

总结

在本节课中,我们一起学习了流的概念。流是一种表示无限序列的强大方式,它通过 thunk 延迟求值,使得我们能够按需生成序列元素。我们了解了如何将流表示为一个返回序对的 thunk,并学习了如何使用递归函数(如 number-until)来消费和处理流。我们还讨论了在编程流时需要注意的常见错误,特别是区分 thunk 和调用 thunk 后得到的序对。掌握这些概念后,你将能够利用流进行更高效和灵活的编程。

117:定义流

在本节课中,我们将学习如何定义自己的流。之前我们已经了解了如何使用流,现在我们将深入探讨如何创建它们。理解流的定义是有效使用它们的前提。

概述

流是一种延迟计算的数据结构,它代表一个潜在的无限序列。在Racket等语言中,流被实现为一个thunk(无参函数)。当调用这个thunk时,它会返回一个序对(pair)。这个序对的car部分是流的第一个元素,而cdr部分则是另一个thunk,调用这个新的thunk会返回代表剩余元素的序对,如此递归下去。

定义第一个流:无限个1的序列

让我们从最简单的例子开始:定义一个生成无限个1的流。

(define ones
  (lambda ()
    (cons 1 ones)))

解析

  • ones 被定义为一个thunk(lambda () ...))。
  • 当调用 ones 时,它返回一个序对 (cons 1 ones)
  • 序对的第一个元素是 1
  • 序对的第二个元素是 ones 本身,即同一个thunk。这正是递归的关键:流在定义中引用了自身,从而能够无限地生成后续元素。

工作原理

  1. (car (ones)) 返回 1
  2. (car ((cdr (ones)))) 会先获取 ones 返回序对的 cdr(即 ones 本身),然后调用这个thunk得到新的序对,再取其 car,返回下一个 1。这个过程可以无限继续。

定义更复杂的流:自然数序列

上一节我们定义了一个简单的常量流,本节中我们来看看如何定义一个生成递增序列的流,例如自然数序列。

以下是定义自然数流的一种方法:

(define nats
  (letrec ([f (lambda (x)
                (cons x (lambda () (f (+ x 1)))))])
    (lambda () (f 1))))

解析

  • 我们使用 letrec 定义一个局部辅助函数 f,它接受一个起始值 x
  • f 返回一个序对:(cons x (lambda () (f (+ x 1))))
  • 序对的 car 是当前值 x
  • 序对的 cdr 是一个新的thunk,当它被调用时,会递归调用 f 并传入 x+1
  • 最外层的 (lambda () (f 1)) 定义了流的起点为 1

更清晰的写法是使用嵌套的 lambda 来明确体现 thunk 的结构:

(define nats
  (lambda ()
    (letrec ([f (lambda (x)
                  (cons x (lambda () (f (+ x 1)))))])
      (f 1))))

定义2的幂次方流

理解了自然数流的定义后,我们可以轻松地修改它来创建其他序列,例如2的幂次方流。

(define powers-of-two
  (lambda ()
    (letrec ([f (lambda (x)
                  (cons x (lambda () (f (* x 2)))))])
      (f 2))))

解析
这个定义与 nats 几乎完全相同,唯一的区别在于递归步骤中,我们将参数 x 乘以2(* x 2))而不是加1,并且起始值设为 2

常见的错误定义方式

在定义流时,初学者常会犯一些错误。理解这些错误有助于加深对流机制的理解。

以下是两种错误的 ones 流定义:

错误示例1:忘记使用 thunk

(define ones-bad1
  (cons 1 ones-bad1)) ; 错误!
  • 问题:这不是一个 thunk,而是一个立即求值的序对。在求值这个定义时,解释器需要查找 ones-bad1 的值,而此时它还未定义完成,导致循环引用错误。这在采用严格求值(eager evaluation)策略的语言(如Racket)中是不允许的。

错误示例2:过早调用 thunk

(define ones-bad2
  (lambda ()
    (cons 1 (ones-bad2)))) ; 错误!
  • 问题:这是一个 thunk,但在其函数体内,(ones-bad2) 会立即被调用。这导致无限递归,因为每次调用都会立即触发下一次调用,试图在内存中构建一个无限长的列表,而不是延迟计算。这与流的“按需计算”本质相悖。

正确的定义必须确保:

  1. 流本身是一个 thunk
  2. 在序对的 cdr 部分放置的是另一个 thunk,而不是调用 thunk 的结果。

总结

本节课中我们一起学习了如何定义自己的流。我们掌握了流的核心概念:流是一个返回 (当前值, 下一个流的thunk) 的 thunk。我们通过定义无限1序列、自然数序列和2的幂序列实践了这一模式。最后,我们分析了两种常见的错误定义,强调了延迟计算和避免立即递归调用的重要性。理解这些后,你将能够创建并使用这种强大的编程结构来表示和处理潜在的无限数据序列。

118:记忆化技术

在本节课中,我们将学习一种称为“记忆化”的编程技巧。这种技巧可以帮助我们避免重复计算,从而显著提升程序效率,尤其适用于计算成本高昂的函数。我们将通过经典的斐波那契数列实现来具体演示这一技术。

核心概念

记忆化的核心思想是:如果一个函数没有副作用,并且不读取任何可能变化的外部数据,那么对于相同的参数,多次计算其结果是毫无意义的。我们可以通过维护一个缓存(或称为记忆表)来存储之前的计算结果。

这个想法与我们之前学过的“承诺”(promises,通过 forcedelay 实现)非常相似。第一次计算时存储结果,后续调用直接使用存储的值。不同之处在于,记忆化需要处理带参数的函数,因此需要一个根据参数进行查找的表结构。

低效的递归实现

我们首先来看一个经典的、但效率低下的斐波那契函数实现。斐波那契数列的数学定义是:fib(1) = 1fib(2) = 1,对于 n > 2fib(n) = fib(n-1) + fib(n-2)

以下是用 Racket 语言实现的代码:

(define (fibonacci x)
  (cond [(or (= x 1) (= x 2)) 1]
        [else (+ (fibonacci (- x 1))
                 (fibonacci (- x 2)))]))

这个实现虽然忠实于数学定义,但效率极低。计算 fib(40) 所需的时间大约是计算 fib(30) 的1000倍,因为它进行了大量重复的递归调用。

记忆化实现详解

接下来,我们将展示如何通过记忆化技术来改造这个函数,使其变得高效。以下是实现记忆化斐波那契函数的完整代码,我们将逐行解析。

(define fibonacci
  (let ([memo null]) ; 初始化一个空的记忆表
    (lambda (x)
      (let ([ans (assoc x memo)]) ; 在表中查找参数 x
        (if ans
            (cdr ans) ; 如果找到,直接返回缓存的结果
            (let ([new-ans
                   (if (or (= x 1) (= x 2))
                       1
                       (+ (fibonacci (- x 1))
                          (fibonacci (- x 2))))])
              (begin
                (set! memo (cons (cons x new-ans) memo)) ; 将新结果存入表
                new-ans))))))) ; 返回计算结果

代码结构解析

上一节我们看到了低效的递归实现,本节中我们来看看如何通过引入记忆表来优化它。

1. 外层 let 绑定记忆表

  • 代码 (let ([memo null]) ...) 创建了一个局部变量 memo,并将其初始化为空表 null。这个表对于函数是私有的,不会暴露给外部。
  • memo 放在函数定义之外、let 表达式之内是关键。这确保了记忆表在多次函数调用之间是持久存在的,而不是每次调用都新建一个。

2. 核心函数逻辑

  • 函数主体是一个 lambda 表达式,它接受参数 x
  • 首先,使用 (assoc x memo) 在记忆表 memo 中查找参数 xassoc 函数会在一个由点对(pair)组成的列表中,查找其 car(第一个元素)与给定键(这里是 x)匹配的项。如果找到,则返回整个点对;否则返回 #f(假)。
  • 记忆表中存储的点对结构是 (参数 . 结果)

3. 条件分支处理
以下是函数执行的两种路径:

  • 缓存命中:如果 (assoc x memo) 返回了一个点对(即 ans 为真),则通过 (cdr ans) 直接取出该点对的 cdr(第二个元素),也就是之前计算好的结果,并立即返回。这样就完全避免了重复计算。
  • 缓存未命中:如果未在表中找到 x(即 ans 为假),则进入 else 分支进行计算。

4. 计算与存储新结果

  • else 分支中,使用一个内部的 let 变量 new-ans 来计算斐波那契值。计算逻辑与原始的低效版本相同:如果 x 是1或2,结果为1;否则,递归调用 (fibonacci (- x 1))(fibonacci (- x 2)) 并将结果相加。
  • 关键点在于,这里的递归调用 fibonacci 是已经被记忆化包装后的版本。因此,即使是递归调用,也会先查询记忆表。
  • 计算得到 new-ans 后,使用 (set! memo (cons (cons x new-ans) memo)) 更新记忆表。这行代码将新的点对 (x . new-ans) 添加到表的最前面,并赋值回 memo
  • 最后,返回 new-ans

为何效率大幅提升

记忆化技术之所以能带来指数级的效率提升,关键在于它彻底改变了递归调用的性质。

在原始的低效版本中,计算 fib(n) 会产生一棵巨大的递归树,许多子问题(如 fib(n-2)fib(n-3) 等)被重复计算了无数次,导致时间复杂度约为 O(2^n)

在记忆化版本中:

  1. 首次计算某个 fib(k) 时,结果会被存入表中。
  2. 之后任何需要 fib(k) 的调用(无论是来自顶层还是来自其他递归分支)都会直接在表中找到结果,时间复杂度为 O(1)(或近似 O(n) 的列表查找时间)。
  3. 因此,每个 fib(k) 值在整个程序运行过程中只会被计算一次。总体的计算过程从树形递归退化为了类似自底向上的动态规划,时间复杂度降低为 O(n)

例如,计算 fib(1000) 在原始版本中是完全不可能的,而在记忆化版本中可以瞬间完成。

总结

本节课中我们一起学习了记忆化这一重要的编程技术。我们了解到:

  • 记忆化的核心是用空间换时间,通过缓存纯函数的计算结果来避免重复计算。
  • 其通用模式是:在函数开始时检查缓存;若命中则直接返回;若未命中则进行计算,并在返回前将结果存入缓存。
  • 我们通过将斐波那契函数从 O(2^n) 优化到 O(n) 的实例,深刻体会了这项技术如何带来巨大的性能提升。
  • 记忆化是一种可以“机械式”应用的通用优化手段,适用于任何输入输出映射固定、计算成本较高的纯函数场景。

119:宏的核心要点 🧩

在本节课中,我们将学习宏的基本概念。宏是一种允许程序员扩展编程语言语法的强大工具。我们将了解宏是什么、它们如何工作,以及为什么在某些情况下它们比函数更合适。课程内容将保持简单直白,确保初学者能够理解。


什么是宏? 🤔

上一节我们介绍了宏的基本概念,本节中我们来看看宏的具体定义。

宏定义描述了如何将某种新语法转换为源语言中的不同语法。你可以将宏定义视为向语言中添加更多语法糖。如果程序员可以定义自己的宏,他们就能通过引入新的语法糖来扩展语言的语法。

例如,我们可以创建一个宏,在 Racket 中添加类似 andalso 的关键字,这个宏会展开为我们已知的条件表达式,就像 ML 中的 andalso 是语法糖一样。

宏系统就是提供给程序员用于定义宏的一种语言。定义宏之后,其他人就可以像使用函数一样使用这个宏。当宏被使用时,会发生宏展开:根据宏定义中的规则,将使用处的语法转换为去糖后的版本。

宏系统工作的关键点在于,展开过程发生在我们课程中讨论过的所有其他步骤之前。在静态类型语言中,宏展开发生在类型检查之前;在任何求值之前,宏展开就已经完成。宏展开在函数体、条件分支等各处进行,它是在执行任何其他操作之前的一个预处理步骤。


Racket 中的宏示例 📝

以下是几个宏的使用示例,后续的可选章节将学习如何定义它们。

示例 1:自定义 if 语句
假设有人不喜欢 Racket 的 if 语法,因为不清楚哪个部分是“then”,哪个是“else”。他们可以定义一个宏 myif,其语法为 (myif e1 then e2 else e3)。宏展开会将其转换为 (if e1 e2 e3)

示例 2:注释掉代码
可以定义一个宏 comment-out,它接受两个任意表达式 e1e2。宏展开会“丢弃” e1,只保留 e2。由于宏展开在所有求值之前进行,e1 永远不会被求值。

示例 3:创建延迟求值(Promise)
我们可以定义一个宏 my-delay,它接受一个表达式,并通过宏展开将其包装在一个 lambda 中,确保该表达式不会立即被求值。这是无法通过定义普通函数 my-delay 来实现的。


宏的实际演示 🎬

让我们通过一些代码示例来具体看看这些宏是如何工作的。

; 使用自定义的 myif 宏
(myif #t then 7 else 8) ; 展开为 (if #t 7 8),结果为 7

; 使用 comment-out 宏
(comment-out (car null) #f) ; 展开为 #f,(car null) 不会引发错误

; 使用 my-delay 宏
(define p (my-delay (begin (print "hi") (* 3 4))))
; 由于宏展开,表达式被包装在 lambda 中,不会立即打印 "hi"
(force p) ; 此时会打印 "hi" 并返回 12
(force p) ; 再次 force,返回 12 但不会再次打印 "hi"

这些示例展示了宏的用法。本质上,宏就像为我们的语言添加了新的关键字。


宏的声誉与使用建议 ⚠️

宏在计算机科学和软件开发中名声不佳,坦白说,这通常是有道理的。宏经常被过度使用,或者在那些使用函数在风格上更为合适的场景中被误用。

因此,这里要传达一个重要的信息:当你不确定宏是否有用时,很可能你不应该定义或使用它。但是,如果你喜欢上面 my-delay 的版本,用户只需写表达式 e,而无需写 (lambda () e),那么你真的需要一个宏,因为世界上没有任何函数可以拥有一个不被求值的参数。

既然宏可以被很好地使用,我希望接下来的可选章节能帮助大家理解为什么宏难以用好,并提供一些正确使用的指导。


后续可选内容展望 🔮

在后续的可选内容中,我们将讨论宏系统如何处理括号和变量等基本语义。我们将学习如何定义本节中使用的那些宏。我们将了解到,在定义宏时,必须格外注意哪些表达式在何处求值以及求值多少次。最后,我们将看到 Racket 比大多数宏系统做得更好的关键一点:当宏定义局部变量或使用宏定义作用域内的变量时,Racket 拥有合理的语义。这是大多数语言处理得不太理想(或者说采用了不同语义)的地方,我们将展示为什么在几乎所有情况下,Racket 的语义是更优的。


总结 📚

本节课中我们一起学习了宏的核心要点。宏是一种在程序求值、类型检查等所有步骤之前进行语法转换的机制,它允许程序员扩展语言的语法。我们看到了宏的定义、工作原理以及几个简单的使用示例。同时,我们也了解了宏可能被误用,因此需要谨慎使用。最后,我们预览了后续将深入探讨的宏定义细节和 Racket 宏系统的优势。

120:宏展开的三个核心问题

在本节课中,我们将要学习宏系统在展开代码时,必须妥善处理的三个基本问题:词法单元化括号处理以及作用域与局部变量。理解这些问题对于安全、正确地定义和使用宏至关重要。

上一节我们介绍了宏的基本概念,本节中我们来看看宏系统在具体实现时必须解决的三个关键挑战。

词法单元化:如何识别宏的使用?🔍

第一个问题是宏系统如何识别代码中哪些地方使用了宏,需要进行展开。几乎所有宏系统都在词法单元的层面工作,而不是字符层面。

词法单元是编程语言中的基本“单词”,例如变量名、关键字、算术运算符等。宏系统需要理解语言的语法,以正确区分这些单元。

以下是一个例子来说明这一点。假设在Racket中,你不喜欢用car来获取列表的第一个元素,于是你定义了一个宏,将你写的head替换为car

; 宏定义:将 head 替换为 car
(define-syntax head
  (syntax-rules ()
    [(_ lst) (car lst)]))

宏系统应该做的是将(head some-list)展开为(car some-list)。但它不应该做的是将变量名headT错误地替换为carT,也不应该将head-door(在Racket中这是一个合法的变量名)替换为car-door

在C语言中,情况则不同。C语言的宏预处理器会将head-door视为head减去door,因为-是一个独立的运算符词法单元。因此,宏系统必须精确理解语言的语法规则,知道一个词法单元在哪里结束,下一个在哪里开始。

我们假设所有合理的宏系统都能做到这一点。

括号处理:如何保证运算顺序?📐

第二个问题是宏展开后,如何保证表达式的运算顺序符合预期,这主要涉及括号的隐式添加问题。

这里可以用C/C++的宏系统作为一个反面例子。假设你定义了一个宏:

#define ADD(x, y) x + y

然后你这样使用它:

ADD(1, 2 / 3) * 4

你可能会直觉地认为它计算的是 (1 + (2 / 3)) * 4。然而,C/C++的宏只是进行简单的文本替换,上述代码会展开为:

1 + 2 / 3 * 4

根据C语言的运算符优先级,这实际上计算的是 1 + ((2 / 3) * 4),得到了一个完全不同的结果。为了避免这种问题,在C/C++中定义宏时,必须为参数和整个表达式加上大量的括号:

#define ADD(x, y) ((x) + (y))

Racket则没有这个问题。在Racket中,宏的使用总是紧跟在左括号之后,就像一个特殊的语法形式。宏展开后,其结果会占据原表达式的位置。如果展开结果本身包含括号,那么运算顺序自然会被正确界定;如果展开结果是一个数字或变量,也不存在括号错位的问题。这使得Racket的宏更加安全和直观。

作用域与局部变量:如何避免冲突?🛡️

第三个,也是最微妙的问题,是宏展开如何与代码中可能存在的局部变量交互,特别是当局部变量名与宏名或宏展开内容中的标识符冲突时。

让我们回到head宏的例子。我们定义了一个将head替换为car的宏。现在考虑下面这段代码,它定义了自己的局部变量headcar

(let ([head 0]
      [car 1])
  head) ; 期望返回 0

如果宏系统“天真地”将所有head词法单元都替换为car,那么展开后的代码会变成:

(let ([car 0]  ; 变量名被错误替换
      [car 1]) ; 重复定义,导致错误(在let中不允许)
  car)         ; 这里引用的是内部的car,值为1

这会导致两个问题:

  1. let中,会产生重复定义变量的语法错误。
  2. 即使用允许遮蔽的let*,结果也会从预期的0变成1,因为宏干扰了局部变量的意图。

好消息是,Racket的宏系统具有卫生性,能够优雅地处理这个问题。在Racket中,局部变量head遮蔽同名的宏定义。因此,在上面let表达式的作用域内,head指的就是局部变量0,而不会触发宏展开。代码会按预期计算出结果0。宏系统能够正确理解词法作用域,确保宏不会意外干扰那些根本不知道宏存在的代码。

本节课中我们一起学习了宏系统设计的三个核心挑战:基于词法单元进行识别正确处理括号以保证语义、以及遵守词法作用域避免与局部变量冲突。理解这些问题有助于我们认识到宏展开并非简单的文本替换,而是一个需要语言精心设计和定义的复杂过程。接下来,我们就可以在此基础上,开始学习如何在Racket中定义自己的宏了。

121:使用 define-syntax 定义 Racket 宏 🧩

在本节课中,我们将学习如何在 Racket 中定义自己的宏。我们将通过三个具体的例子,逐步讲解 define-syntaxsyntax-rules 的使用方法,并理解宏与函数的区别。

概述 📋

宏是一种强大的元编程工具,它允许我们在代码被求值之前,根据自定义的语法规则对其进行转换。与函数不同,宏的参数不会被立即求值,这使得宏能够实现函数无法完成的任务,例如延迟求值或创建新的控制结构。我们将通过定义三个宏来掌握其基本用法。

定义第一个宏:my-if 🔄

首先,我们定义一个名为 my-if 的宏,其语法类似于 Racket 内置的 if,但要求使用 thenelse 关键字。用户将能够这样编写代码:(my-if e1 then e2 else e3)

以下是定义 my-if 宏的代码:

(define-syntax my-if
  (syntax-rules (then else)
    [(my-if e1 then e2 else e3)
     (if e1 e2 e3)]))

代码解析:

  • define-syntax 用于定义宏,而不是变量或函数。
  • my-if 是宏的名称。
  • syntax-rules 后面跟着一个列表 (then else),这列出了宏中除了宏名 my-if 之外的特殊关键字。
  • 方括号 [...] 内定义了一个转换规则。它匹配形如 (my-if e1 then e2 else e3) 的语法模式,并将其转换为 (if e1 e2 e3)。这里的 e1, e2, e3 是模式变量,代表任意表达式。

使用示例:

(my-if true then (+ 3 4) else 72) ; 展开为 (if true (+ 3 4) 72),结果为 7

如果用户忘记写 thenelse,Racket 会报错,因为提供的语法不匹配宏定义的任何规则。

上一节我们介绍了如何定义一个简单的条件判断宏。接下来,我们看一个更简单的例子,它用于“注释掉”代码。

定义第二个宏:comment-out 🗑️

comment-out 宏的目的是完全忽略第一个表达式,只求值第二个表达式。例如,(comment-out (car null) (+ 3 4)) 应该只计算 (+ 3 4) 并返回 7,而不会触发 (car null) 的错误。

以下是 comment-out 宏的定义:

(define-syntax comment-out
  (syntax-rules ()
    [(comment-out e1 e2)
     e2]))

代码解析:

  • 这个宏没有额外的关键字,因此 syntax-rules 后的列表是空的 ()
  • 规则匹配 (comment-out e1 e2) 模式,并直接将其替换为第二个表达式 e2。注意,e2 外面没有括号,这意味着它直接是表达式本身,而不是一个函数调用。

使用示例:

(comment-out (car null) (+ 3 4)) ; 展开为 (+ 3 4),结果为 7。 (car null) 被安全地丢弃。

我们已经看到了两个在语法层面进行替换的宏。现在,让我们探讨一个更强大的应用:重新实现延迟求值。

定义第三个宏:my-delay

在之前学习“承诺”(promises)时,我们使用函数实现了 my-delaymy-force 来模拟惰性求值。my-delay 函数接收一个无参函数(thunk),并返回一个包含该 thunk 的特定数据结构。

然而,使用函数时,调用者必须显式地创建一个 thunk:(my-delay (lambda () e))。我们可以定义一个宏,让调用者只需写 (my-delay e),宏会自动将其包装在 thunk 中。

以下是 my-delay 宏的定义:

(define-syntax my-delay
  (syntax-rules ()
    [(my-delay e)
     (mcons #f (lambda () e))]))

代码解析:

  • 宏匹配 (my-delay e)
  • 它将其展开为 (mcons #f (lambda () e))。这里,表达式 e 被放入一个 (lambda () ...) 中,从而延迟了它的求值。
  • 关键点:这无法用函数实现。因为 Racket 的函数在调用前总会先对所有参数进行求值。如果 my-delay 是函数,那么 e 在传入时就会被计算,失去了延迟的意义。宏在语法扩展阶段就将 e 放入了 thunk,因此解决了这个问题。

注意:定义了此宏后,调用方式从 (my-delay (lambda () e)) 变为 (my-delay e)。如果程序员错误地写了前者,将会产生一个“双重 thunk”,导致需要调用两次才能得到结果。

那么,my-force 也应该定义为宏吗?

为什么 my-force 应该保持为函数 ❌

my-force 的功能是:接收一个 promise(即 my-delay 返回的 mcons),检查其是否已被求值,若未求值则强制执行其中的 thunk 并缓存结果。

我们可以尝试将其定义为宏:

; 版本一:有问题的宏
(define-syntax my-force
  (syntax-rules ()
    [(my-force e)
     (if (mcar e)
         (mcdr e)
         (begin (set-mcar! e #t)
                (set-mcdr! e ((mcdr e)))
                (mcdr e)))]))

问题:如果调用是 (my-force (begin (print “hi”) p)),那么 (begin (print “hi”) p) 这个表达式会在宏展开时,被复制到上述 if 语句的多个位置(e 出现的地方),导致副作用(打印 “hi”)发生多次,这违背了“表达式只应求值一次”的原则。

我们可以修复这个问题,引入一个局部变量来保存求值结果:

; 版本二:修复多次求值的宏
(define-syntax my-force
  (syntax-rules ()
    [(my-force e)
     (let ([x e]) ; 先对 e 求值一次,结果绑定到 x
       (if (mcar x)
           (mcdr x)
           (begin (set-mcar! x #t)
                  (set-mcdr! x ((mcdr x)))
                  (mcdr x))))]))

这个版本行为正确了。但是,这并没有带来任何额外的好处。它的行为和对应的函数实现完全一样。既然函数已经能完美、清晰地表达这个逻辑,就没有必要使用更复杂、更容易出错的宏来实现。因此,my-force 应保持为函数。

总结 🎯

本节课我们一起学习了 Racket 宏的基础知识:

  1. 我们使用 define-syntaxsyntax-rules 来定义宏。
  2. 宏在语法层面进行模式匹配和替换,其参数不会预先求值。
  3. 我们通过 my-ifcomment-outmy-delay 三个例子实践了宏的定义。
  4. 我们认识到宏虽然强大,但需要谨慎设计,特别是要注意表达式可能被求值的次数(如第一个有问题的 my-force 宏所示)。
  5. 判断是否使用宏的一个重要原则是:如果函数能够实现相同的语义,通常应优先使用函数,因为其行为更易于推理。

宏是创造领域特定语言和强大抽象的工具,但同时也要求程序员对求值顺序有清晰的理解。在接下来的课程中,我们将继续深入探讨宏的更多细节和注意事项。

122:宏与变量、卫生宏

在本节课中,我们将学习宏如何与编程语言中的变量处理交互。我们将看到宏的语义与之前展示的简单宏展开不同。这种差异是积极的,它在变量可能遮蔽宏内内容的情况下表现更好。这种更优的语义被称为卫生宏。本节将展示相关内容。

宏与变量求值次数

上一节我们介绍了宏的基本概念,本节中我们来看看宏展开时如何处理变量求值。一个关键问题是宏参数可能被求值多次,这可能导致意外的副作用。

以下是两个实现“加倍”功能的宏版本,它们并不等价:

(define-syntax double1
  (syntax-rules ()
    [(_ X) (+ X X)]))

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/uwash-cse341-prog-abc/img/4c6affbc865f571d64ba57b6e7447a6d_3.png)

(define-syntax double2
  (syntax-rules ()
    [(_ X) (* 2 X)]))

如果使用 double1 宏并传入一个会打印信息的表达式,例如 (begin (print "hi") 42),该表达式会被求值两次,导致信息被打印两次。而 double2 宏只会求值一次。

为了避免这种多次求值,可以使用 let 表达式将参数绑定到局部变量。以下是推荐的技术:

(define-syntax double3
  (syntax-rules ()
    [(_ X) (let ([y X]) (+ y y))]))

这个宏先将表达式 X 的结果绑定到局部变量 y,然后使用 y 进行计算,从而确保 X 只被求值一次。

宏与变量求值顺序

宏展开还可能影响子表达式的求值顺序,这可能与用户的预期不符。

考虑以下宏定义:

(define-syntax take-from
  (syntax-rules ()
    [(_ E1 E2) (- E2 E1)]))

用户可能预期参数 E1E2 按照他们书写的顺序求值。然而,在 Racket 中,函数参数从左到右求值。因此,在宏展开后,E2 会先于 E1 被求值。

同样,可以使用 let 表达式来控制求值顺序,确保符合用户预期:

(define-syntax take-from-fixed
  (syntax-rules ()
    [(_ E1 E2) (let ([v1 E1]
                     [v2 E2])
                 (- v2 v1))]))

卫生宏的必要性 🧼

在 C 或 C++ 等语言的宏系统中,在宏内使用局部变量是危险的,因为宏展开时可能发生变量捕获,导致意外行为。Racket 的卫生宏系统解决了这个问题。

考虑以下有问题的宏定义:

(define-syntax double-bad
  (syntax-rules ()
    [(_ X) (let ([y 1]) (* 2 X y))]))

如果用户在已经定义了变量 y 的上下文中使用这个宏,例如 (let ([y 7]) (double-bad y)),在非卫生的宏系统中,宏内的 y 会捕获外部的 y,导致错误结果。Racket 的卫生宏通过重命名宏定义中的局部变量来避免这种冲突,确保得到正确结果 14

宏与词法作用域

卫生宏的另一个重要特性是遵循词法作用域。宏定义中引用的自由变量(如 *)是在宏定义时的环境中查找,而不是在宏调用时的环境中查找。

考虑以下宏:

(define-syntax double-lexical
  (syntax-rules ()
    [(_ X) (* 2 X)]))

即使用户在调用宏的上下文中重新定义了 *(例如将其绑定为 +),宏展开时仍然会使用定义时的乘法操作,得到正确结果 84,而不是错误结果 44。这保证了宏行为的可靠性和可预测性。

卫生宏的实现原理

卫生宏系统通过两种机制工作:

  1. 重命名局部变量:宏展开时,宏定义内的局部变量会被自动重命名为唯一的标识符,防止与调用处的变量发生冲突。
  2. 维护词法环境:宏定义中引用的自由变量会在宏定义时的词法环境中进行解析,而不是在调用时的环境中。

这些机制使得在 Racket 中,开发者可以安全地在宏中使用局部变量,而无需担心命名冲突。虽然实现比简单的文本替换复杂,但它带来了巨大的便利性和可靠性。

需要注意的是,卫生规则虽然适用于绝大多数情况,但 Racket 也提供了在特定情况下绕过这些规则的机制,以满足更高级的元编程需求。

总结

本节课中我们一起学习了宏与变量交互时的关键问题。我们了解到宏参数可能被多次求值或按意外顺序求值,可以通过 let 表达式来控制。更重要的是,我们探讨了卫生宏的概念,它通过自动重命名局部变量和遵循词法作用域,避免了变量捕获和动态作用域带来的问题,使得在 Racket 中使用宏更加安全和直观。

123:更多宏示例 🧩

在本节课中,我们将通过几个更复杂的宏示例来结束对宏的学习。这些示例将展示一些新特性,并将我们之前见过的概念组合起来。我们将学习如何创建接受多个参数、具有多个匹配分支,甚至能够递归调用自身的宏。


循环宏示例 🔄

上一节我们介绍了宏的基本概念,本节中我们来看看一个自定义的循环宏 for。这个宏的目的是展示如何处理多个参数,并控制其中哪些需要被重新求值,哪些不需要。

以下是 for 宏的使用示例:

(for 7 11 do (print "hi"))

这段代码会打印五次 “hi”,因为 11 - 7 = 5。我们也可以在参数位置使用任意表达式。

假设我们有三个函数:

(define (f x) (print "A") x)
(define (g x) (print "B") x)
(define (h x) (print "C") x)

现在执行:

(for (f 7) (g 11) do (h 9))

它会按顺序打印一次 “A”、一次 “B” 和五次 “C”。这是用户期望的 for 循环行为。

为了实现正确的求值顺序和次数,我们需要在定义宏时格外小心。for 宏的展开形式如下:

(define-syntax for
  (syntax-rules (do)
    ((for low high do body)
     (let ((l low)
           (h high))
       (letrec ((loop (lambda (i)
                        (if (< i h)
                            (begin
                              body
                              (loop (+ i 1)))
                            #f))))
         (loop l))))))

这个宏的核心思想是:

  1. 使用 let 表达式按顺序对 lowhigh 求值一次,并将结果绑定到变量 lh 上,避免重复求值。
  2. 定义一个递归辅助函数 loop,在循环中执行 body 部分,执行次数为 high - low

如果起始值大于结束值,例如 (for 11 7 do (print “hi”)),循环体将一次也不执行,这也是符合预期的行为。


多分支宏示例 🧱

接下来,我们看一个名为 let2 的宏,它是 let 的一个变体。这个示例将展示如何让一个宏拥有多个匹配分支(即多个 syntax-rules 情况)。

let2 的用法是减少定义变量时所需的括号。比较一下:

  • 标准 let: (let ((x 1) (y 2)) (+ x y))
  • 使用 let2: (let2 x 1 y 2 (+ x y))

let2 也支持定义更少或零个变量:

(let2 x 1 (+ x 5)) ; 定义一个变量
(let2 3)           ; 定义零个变量,直接返回3

let2 宏的定义如下,它包含了三个不同的匹配分支:

(define-syntax let2
  (syntax-rules ()
    ; 情况1:零个绑定
    ((let2 () body) body)
    ; 情况2:一个绑定
    ((let2 (var1 val1) body)
     (let ((var1 val1)) body))
    ; 情况3:两个绑定
    ((let2 (var1 val1) (var2 val2) body)
     (let ((var1 val1))
       (let ((var2 val2))
         body)))))

每个分支匹配不同数量的参数,并将其展开为对应的标准 let 表达式。

如果用户错误地使用了宏,例如提供了三个绑定 (let2 x 1 y 2 z 3 (+ x y z)),由于没有匹配的分支,Racket 会报告一个“语法错误:let2 匹配失败”的信息。

一个有趣的情况是,如果用户提供了格式错误的参数,例如 (let2 3 4 5 (+ 3 4))。这个输入匹配第三个分支(它被解析为 var1=3, val1=4, var2=5, val2=(+ 3 4)),但在展开后,会产生一个无效的 let 表达式 (let ((3 4)) …)。此时,错误信息将是关于 let 的语法错误(例如“期望一个标识符,但得到 3”),而不是关于 let2 的。这是使用宏时的一个常见问题:错误可能发生在展开后的代码中。


递归宏示例 ♾️

最后,我们来看一个最复杂的例子:递归宏。我们将实现一个自己的 let*(假设 Racket 本身没有提供)。

递归宏允许宏在其展开式中调用自身。我们定义的宏叫 my-let*

my-let* 的用法应与标准 let* 一致:

(my-let* ((x 1) (y (+ x 1))) (+ x y)) ; 应返回 3

其定义如下:

(define-syntax my-let*
  (syntax-rules ()
    ; 基本情况:绑定列表为空
    ((my-let* () body) body)
    ; 递归情况:至少有一个绑定
    ((my-let* ((var0 val0) . rest) body)
     (let ((var0 val0))
       (my-let* rest body)))))

这个宏有两个分支:

  1. 基本情况:当绑定列表为空时,直接执行 body
  2. 递归情况:使用模式 ((var0 val0) . rest) 匹配第一个绑定和剩余绑定列表( 是 Racket 宏中用于匹配“一个或多个”的特殊语法)。展开时,它创建一个 let 绑定第一个变量,然后在它的主体中递归调用 my-let* 来处理剩余的绑定。

通过这种递归展开,我们就能用基本的 let 构造出 let* 的语义。

需要注意的是,递归宏非常强大,但如果编写不当(例如形成无限递归展开),程序在运行前就会在宏展开阶段失败。因此必须谨慎使用。


总结 📝

本节课中我们一起学习了三个高级宏的示例:

  1. for 循环宏:演示了如何控制多个参数的求值顺序和次数,确保用户代码按预期执行。
  2. let2 多分支宏:展示了如何为一个宏定义多个 syntax-rules 分支,以处理不同数量的参数,并讨论了宏使用错误时可能出现的错误信息问题。
  3. my-let* 递归宏:介绍了最强大的递归宏概念,通过宏自身调用自身,实现了 let* 的功能,体现了宏在扩展语言语法方面的强大能力。

通过这些例子,我们看到了宏如何能够创造新的控制结构、简化语法,并在编译时生成复杂的代码。这标志着我们对 Racket 宏系统的探索告一段落。

124:不使用结构体在 Racket 中进行数据类型编程 🧩

在本节课中,我们将学习如何在 Racket 这种动态类型语言中,不使用类似 ML 中的数据类型绑定,来实现类似的功能。我们将探索两种主要方法:处理混合类型列表,以及使用列表和符号来构建递归数据结构。

处理混合类型列表

在 ML 中,列表的所有元素必须是同一类型。为了处理混合类型,我们需要定义新的数据类型。然而,在 Racket 中,我们可以直接混合不同类型的数据,并使用内置谓词(如 number?string?)来检查值的类型。

以下是实现一个函数 funny-sum 的示例,该函数对列表中的数字求和,对字符串则取其长度:

(define (funny-sum xs)
  (cond
    [(null? xs) 0]
    [(number? (car xs)) (+ (car xs) (funny-sum (cdr xs)))]
    [(string? (car xs)) (+ (string-length (car xs)) (funny-sum (cdr xs)))]))

这个函数直接利用了 Racket 的动态类型特性,无需预先定义任何新的数据类型。

构建递归数据结构

上一节我们介绍了如何处理简单的混合类型。本节中,我们来看看如何为更复杂的递归数据结构(如算术表达式树)编写解释器。

在 ML 中,我们会定义一个数据类型来表示表达式,并编写一个求值函数。在 Racket 中,我们可以使用列表来模拟这种结构。列表的第一个元素将作为一个“标签”,用来标识表达式的类型。

首先,我们需要一些辅助函数来“构造”表达式:

; 构造一个常量表达式
(define (constant i) (list 'constant i))

; 构造一个取负表达式
(define (negate e) (list 'negate e))

; 构造一个加法表达式
(define (add e1 e2) (list 'add e1 e2))

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/uwash-cse341-prog-abc/img/1d4d0bf014fcb12571e1a45960f7a801_3.png)

; 构造一个乘法表达式
(define (multiply e1 e2) (list 'multiply e1 e2))

接下来,我们需要辅助函数来“检查”和“提取”表达式:

; 检查是否为常量表达式
(define (constant? e) (eq? (car e) 'constant))

; 检查是否为取负表达式
(define (negate? e) (eq? (car e) 'negate))

; 检查是否为加法表达式
(define (add? e) (eq? (car e) 'add))

; 检查是否为乘法表达式
(define (multiply? e) (eq? (car e) 'multiply))

; 从常量表达式中提取整数值
(define (constant-int e) (car (cdr e)))

; 从取负表达式中提取子表达式
(define (negate-e e) (car (cdr e)))

; 从加法表达式中提取第一个子表达式
(define (add-e1 e) (car (cdr e)))

; 从加法表达式中提取第二个子表达式
(define (add-e2 e) (car (cdr (cdr e))))

; 从乘法表达式中提取第一个子表达式
(define (multiply-e1 e) (car (cdr e)))

; 从乘法表达式中提取第二个子表达式
(define (multiply-e2 e) (car (cdr (cdr e))))

现在,我们可以使用这些辅助函数来编写我们的解释器 eval-exp。这个函数遵循一个优雅的模式:递归求值子表达式,获取底层数据,执行操作,然后构造一个新的表达式作为结果。

(define (eval-exp e)
  (cond
    [(constant? e) e] ; 基础情况:直接返回常量表达式
    [(negate? e)
     (let ([v (eval-exp (negate-e e))]) ; 递归求值子表达式
       (constant (- (constant-int v))))] ; 取负并包装成常量
    [(add? e)
     (let ([v1 (constant-int (eval-exp (add-e1 e)))] ; 求值第一个子表达式
           [v2 (constant-int (eval-exp (add-e2 e)))]) ; 求值第二个子表达式
       (constant (+ v1 v2)))] ; 相加并包装
    [(multiply? e)
     (let ([v1 (constant-int (eval-exp (multiply-e1 e)))]
           [v2 (constant-int (eval-exp (multiply-e2 e)))])
       (constant (* v1 v2)))]))

关于符号的说明

在代码中,我们使用了符号(Symbol),例如 'constant。符号类似于字符串,但比较速度更快(使用 eq?)。我们也可以使用字符串(如 "constant")来实现,但使用符号是 Racket 中的常见做法。

总结

本节课中我们一起学习了在 Racket 中不使用正式数据类型绑定的编程方法。

  • 对于混合类型数据,我们可以直接利用动态类型和内置类型谓词。
  • 对于递归数据结构,我们可以使用列表和符号来模拟数据类型的构造、检查和提取操作。
  • 通过定义清晰的辅助函数,我们可以编写出结构清晰、易于理解的代码,其递归逻辑与 ML 中的模式匹配版本相对应。

这种方法的核心在于,我们手动管理了数据的“标签”和结构,而将类型检查的责任从编译时转移到了运行时和程序员自身。

125:Racket 中使用结构体进行数据类型编程 🏗️

在本节课中,我们将学习 Racket 中的 struct 构造,展示其工作原理,并使用它来实现我们的小型算术表达式语言。下一节中,我们将讨论为何这是一种更好的方法。

概述 📋

本节将介绍 Racket 的 struct 特性,它类似于记录类型,但功能更强大。我们将通过定义结构体来重新实现算术表达式求值器,从而替代之前使用列表的方式。这种方法能提供更清晰、更安全的代码结构。

结构体定义与使用 🛠️

struct 是一种特殊形式,用于定义新的数据类型。其基本语法如下:

(struct 结构体名称 (字段1 字段2 ...) #:transparent)

例如,定义一个名为 foo 的结构体,它包含三个字段 barbazqux

(struct foo (bar baz qux) #:transparent)

当您定义这样一个结构体时,Racket 会自动创建一系列辅助函数。

以下是自动生成的函数及其作用:

  • 构造函数foo,一个接收三个参数(对应三个字段)并返回新 foo 结构体的函数。
  • 谓词函数foo?,接收任意表达式,仅当该表达式是由 foo 构造函数创建的结果时返回 #t
  • 字段访问器foo-barfoo-bazfoo-qux,每个函数接收一个值,如果该值是 foo 结构体,则返回对应字段的内容;否则会引发运行时错误。

应用于表达式求值器 ✨

上一节我们介绍了结构体的基本概念,本节中我们来看看如何将其应用于我们的算术表达式求值器。

我们将定义四个结构体来分别表示四种表达式:常量、取负、加法和乘法。

(struct const (int) #:transparent)
(struct negate (e) #:transparent)
(struct add (e1 e2) #:transparent)
(struct multiply (e1 e2) #:transparent)

这些定义类似于 ML 中的构造函数定义。每个定义都提供了一个构造函数、一个类型判断谓词和字段访问器。例如,对于 const,我们会得到:

  • const(构造函数)
  • const?(判断谓词)
  • const-int(字段访问器)

需要注意的是,在动态类型的 Racket 中,我们无需(也无法)在语言层面声明这些是所有可能的表达式类型,也无需声明字段的类型。这需要由程序员自己来确保,例如 constint 字段应存放数字,而 adde1e2 字段应存放表达式。

实现求值函数 🧮

基于这些结构体定义,我们现在可以编写 eval-exp 函数。其逻辑与我们之前使用列表和 ML 的实现相同。

(define (eval-exp e)
  (cond
    [(const? e) e] ; 常量表达式直接返回
    [(negate? e) ; 处理取负表达式
     (const (- (const-int (eval-exp (negate-e e)))))]
    [(add? e) ; 处理加法表达式
     (let ([v1 (const-int (eval-exp (add-e1 e)))]
           [v2 (const-int (eval-exp (add-e2 e)))])
       (const (+ v1 v2)))]
    [(multiply? e) ; 处理乘法表达式
     (let ([v1 (const-int (eval-exp (multiply-e1 e)))]
           [v2 (const-int (eval-exp (multiply-e2 e)))])
       (const (* v1 v2)))]
    [#t (error "eval-exp expected an expression")]))

代码解析:

  1. 如果 econst,直接返回它。
  2. 如果 enegate,则使用 negate-e 访问器获取其子表达式,递归求值后,用 const-int 取出数字,取负,再用 const 构造函数包装结果。
  3. 如果 eadd,则分别使用 add-e1add-e2 访问器获取两个子表达式,递归求值并取出数字后相加,最后用 const 包装结果。
  4. multiply 的处理与 add 类似,只是将加法换成乘法。

运行示例:

(define x (add (const 3) (const 4)))
(eval-exp x) ; 输出 (const 7)

这种方法比使用列表更加方便和清晰,并且正如我们将在下一节强调的,由这些构造函数创建的值并不是列表,它们是不同的实体。

结构体属性说明 🔧

最后,我们来解释一下定义中的 #:transparent 属性。

这个属性不是必须的,没有它,之前所讲的所有关于结构体的功能依然成立。但是,如果没有 #:transparent,Racket 的交互环境(REPL)在打印结构体实例时,不会显示其内部字段值,而只会显示为一个抽象表示(如 #<foo>)。添加此属性后,打印时会显示具体内容,便于调试。

此外,结构体还支持 #:mutable 属性。如果使用它,Racket 会为每个字段额外生成一个修改器函数(例如 set-foo-bar!),允许你改变字段的值。这引入了可变状态,我们在课程中讨论过可变数据的优缺点。在本节后续内容中,我们需要的所有结构体定义都可以更好地在不使用可变性的情况下完成,因此我们不会使用这个属性。

总结 🎯

本节课中我们一起学习了 Racket 中 struct 的关键用法。我们了解了如何定义结构体,以及如何利用自动生成的构造函数、谓词函数和访问器函数来构建和操作数据。通过将其应用于算术表达式求值器的例子,我们看到了这种方法如何提供比原始列表更清晰、更结构化的代码组织方式。我们还简要介绍了 #:transparent#:mutable 这两个属性及其作用。在接下来的课程中,我们将继续使用结构体来构建更复杂的程序。

126:结构体的优势 🏗️

在本节课中,我们将学习在定义自定义数据类型时,为何使用结构体(struct)的方法比使用列表(list)的方法更具优势。我们将通过对比两种实现简单算术表达式求值器的方法,来详细阐述结构体在类型安全、错误检查以及代码组织方面的好处。

概述

我们已经看到了两种定义简单算术语言并编写表达式求值函数的方法。本节将重点强调为何我认为使用结构体的方法是更优的选择,并解释为何编程语言支持此类特性是重要的。核心在于对比两种方法:一种是使用结构体定义,为每种表达式生成一组用于测试、访问和构建的函数;另一种是早期我们自行使用列表实现的方法,即用列表的第一个元素标识表达式类型(如加法),后续元素存放子表达式。需要强调的是,结构体定义并非列表方法的语法糖。

结构体与列表的本质区别

上一节我们介绍了使用结构体定义表达式类型。本节中我们来看看结构体方法与列表方法的关键区别。

结构体方法的核心优势在于,当你调用结构体构造函数(如 add)来构建一个新的加法表达式时,你得到的不是一个列表

让我们通过代码来验证这一点。以下是上一节中使用结构体的同一个文件:

(struct const (int) #:transparent)
(struct add (e1 e2) #:transparent)
(struct multiply (e1 e2) #:transparent)

如果我定义 x(add (const 3) (const 4))

(define x (add (const 3) (const 4)))

打印 x 会显示类似 (add (const 3) (const 4)) 的内容。它不是一个序对(pair),不是一个列表,也不是一个乘法表达式。它是一个 add 类型的数据。

这就是关键区别。实际上,每个结构体定义都像是在 Racket 中引入了一种新的原始数据类型。就像 cons 创建序对、数字就是数字一样,add 构造函数创建的是 add 类型的东西。

当然,Racket 本身并不知道这些字段(如 e1, e2)应该是什么类型。我们甚至可以创建一个 (add 7 19),虽然这不符合我们对 add 表达式的预期用法,但就 add 作为一种 Racket 允许创建的新数据类型而言,这是合法的。

结构体提供的类型安全

这种方法的好处在于,如果你尝试对某个数据使用错误的访问器函数,你会得到一个错误,这可以防止你悄无声息地做错事。

再次以 x 为例,它只是一个 add 表达式。如果我错误地尝试将其视为 multiply 表达式并获取其 e1 字段:

(multiply-e1 x) ; 这会报错

我会得到一个错误。通过使用结构体定义,它帮助我将 addmultiply 以及其他所有类型区分开来。我也不能对 x 使用 cdr 函数。

我认为这一点已经阐述得足够清楚了。

列表方法的缺陷

列表方法则不具备上述任何优势。因为使用 add 函数(它只是一个返回三元素列表的普通 Racket 函数)创建的所有东西都是列表,它们是由 cons 单元构成的。

因此,你可能会在无意中犯下各种错误。以下是一些在列表方法中可能出现的错误操作示例:

现在切换到两个小节前我们为所有操作定义辅助函数的方法(这里使用大写字母以作区分):

(define (Add e1 e2) (list `add e1 e2))
(define (Add-e1 exp) (car (cdr exp)))
(define (Add-e2 exp) (car (cdr (cdr exp))))

我可以定义 x(Add (Const 3) (Const 4))

(define x (Add (Const 3) (Const 4))) ; 假设有对应的 Const 函数

x 是一个列表。如果我询问它是否是列表,答案是肯定的。这没问题。

但假设我应该用 Add-e1 函数来获取 (Const 3)。我可能会错误地写成 (car (cdr x)),并且没有任何东西能阻止我这样做。

更糟糕的是,如果我搞混了,以为要取 (car (cdr x)) 却写成了 (car (car x)) 或其他什么,程序不会报错。我不应该思考 cons 单元,而应该思考 addmultiply 表达式。

最糟糕的是,你可能会认为对 x 调用 Multiply-e1 函数会是一个错误,但它并不会。你只会得到 (Const 3)。这是因为如果你查看上面的代码,Add-e1 函数和 Multiply-e1 函数(如果定义了的话)做的完全是同一件事:它们都对其参数执行 (car (cdr ...))。因此,你可以随意混用它们,用这种方式编程时,你得到的及时错误检查会少得多。

结构体方法的优势总结

以下是结构体方法的主要优势:

  • 更好的代码风格与更简洁:这可能是你首先想到的。你只需一行结构体定义,就能免费获得五个函数(构造函数、类型判断谓词、字段访问器)。
  • 同等的使用便利性:在正确使用数据类型时,即使使用列表方法,一旦你定义了所有需要的函数,其便利性与结构体方法大致相当。
  • 更及时的错误检查:当程序员误用这些函数时(程序员总会犯错),结构体方法能提供更快的错误信息,让问题更早暴露,这通常使得调试更加容易。

结构体的额外优势

借助 Racket 的一些我们不会重点强调的特性,结构体甚至更加强大:

  1. 模块系统与数据抽象:Racket 拥有一个非常好的模块系统,就像 ML 一样。在 ML 中,我们使用抽象类型来隐藏信息。Racket 是动态类型语言,但通过结构体,你同样可以实现类型抽象。如果我们将一个结构体放在模块中,并且只向该模块的客户端提供访问器函数,而不提供构造函数,那么客户端就无法绕过我们那些强制执行不变式并确保操作正确的函数来随意创建数据。而如果你的数据用列表表示,你无法阻止程序中的任何部分构建任何他们想要的列表,他们可以简单地创建一个第一个元素是符号 'add 的列表,即使它可能不满足正确的不变式,它看起来也像一个加法表达式。

  2. 合约系统:Racket 有一个合约系统,你可以在函数和像结构体这样的数据类型上附加必须满足的属性,从而获得更早的错误提示。这些属性可以是用户自定义的,例如“加法表达式中的子表达式本身也必须是表达式”。虽然这里没有展示如何做到这一点,但这是结构体可以支持的。对于列表来说,这没有意义,因为你不会对程序中所有的列表都附加这样的合约,你只想为你特别定义的这种新数据类型附加合约。

结构体的本质

最后需要强调一点,结构体确实是我们添加到 Racket 中的一个新事物,它不是你可以用其他特性编码实现的东西。

  • Racket 中的一个函数无法像我们刚才看到的那样引入多个绑定。
  • 即使是之前展示过的宏,也无法创建一种新的数据类型。

使结构体真正特殊的是这样一个事实:当你用结构体创建某个东西时,它对于程序中所有其他类型的数据的谓词检查(如 number?, pair?)都返回 false。它不是一个数字,不是一个序对,也不是任何其他已有的东西。这是编程语言可以赋予你的一个关键特性,但只能通过内置的构造来实现。如果你只用语言中已有的数据类型来构建新数据,那么像 number?pair? 这样的检查可能会返回 true,因为你是用语言中已存在的某种数据构建的。

总结

本节课中,我们一起学习了结构体在定义自定义数据类型时的优势。我们对比了结构体方法与列表方法,了解到结构体通过引入真正的新数据类型,提供了更强的类型安全性、更及时的错误检查以及更清晰的代码抽象。此外,结构体还能与 Racket 的模块系统和合约系统更好地结合,实现数据抽象和不变式约束。这些特性使得结构体成为像 Racket 这样的语言中一个极有价值的补充。

127:概述与基本概念

在本节课中,我们将学习如何实现一个编程语言。我们将从一般性的实现流程开始,然后聚焦于一种简化的、在作业中会使用的实用方法。

编程语言的典型实现流程可以概括为几个阶段。首先,程序员的代码文本(称为具体语法)会被读取为一个字符串。这个字符串随后被送入解析器。解析器的职责是检查语法错误,例如括号位置错误或关键字使用不当。如果没有语法错误,解析器会输出一个抽象语法树。AST 以一种结构化的树形格式,清晰地展示了程序中使用的语言结构及其关系。

两种核心实现方法

上一节我们介绍了从源代码到抽象语法树的流程,本节中我们来看看如何执行这个 AST 程序。实现一个编程语言(我们称之为语言 B)主要有两种基本方法。

以下是两种核心方法:

  1. 解释器:使用另一种语言 A 编写一个程序(解释器),该程序接收语言 B 的 AST 并直接计算出运行结果。解释器有时也被称为求值器或执行器。
  2. 编译器:同样使用语言 A 编写一个程序(编译器),它将语言 B 的程序翻译成一个等价的、用第三种语言 C 编写的程序。然后,我们运行这个 C 语言程序。这依赖于语言 C 已有现成的实现(例如,如果 C 是机器码,则依赖计算机硬件来运行)。

我们通常将用于实现其他语言的语言 A 称为元语言。在本课程和作业中,关键是要在头脑中分清什么是元语言 A(我们用来实现的工具),什么是目标语言 B(我们要实现的语言)。

现实中的混合实现

需要指出的是,现实中的语言实现通常更为复杂,并非纯粹使用解释器或编译器。现代语言实现往往结合了这两种思想。例如,许多 Java 实现首先将代码编译成一种中间语言(字节码),然后用一个解释器来执行字节码。为了提高性能,这个解释器内部可能还会包含一个即时编译器,将频繁执行的字节码片段编译成本地机器码。Racket 语言的实现也采用了类似的混合策略。因此,核心思想是解释和编译,它们可以以各种有趣的方式组合使用。

另外,一个语言的定义是由其语义规则(即各种语言结构的意义)书面确定的。它是通过编译器、解释器还是两者结合来实现,这只是一个实现细节。理解了这一点,就应该明白并不存在所谓的“编译型语言”或“解释型语言”,只有使用编译器实现的语言或使用解释器实现的语言。将语言本身归类为编译型或解释型是一种常见的误解。

简化实现:跳过解析步骤

现在让我们回到实现工作流。在你们的作业中,我们将不编写解析器和类型检查器,只专注于实现一个解释器。我想强调,如果设置得当,我们实际上可以跳过解析步骤。

假设我们要用语言 A 来实现语言 B。如果我们不想写解析器,可以让编写 B 语言程序的程序员直接在语言 A 中写出 AST。也就是说,他们不写需要被转换成 AST 的字符串,而是直接写出 AST 树本身。如果我们的元语言 A 是 Racket,并且我们为 B 语言程序员提供了合适的结构体,那么这并不难做到:他们只是在编写恰好能表示目标语言程序的 Racket 数据结构(即 AST)。

例如,我们可以定义一些 Racket 结构体,如 callfunctionvar。当程序员使用这些构造函数时,他们本质上就是在直接构建程序树。这正是我们在之前算术表达式示例中已经做过的事情。我们将那些由 constantnegateaddmultiply 构建的表达式视为一个小的编程语言(算术语言)。Racket 是我们的元语言 A,算术语言是我们的目标语言 B。编写算术语言程序的人只需使用 Racket 构造函数来写下 AST。而我们语言的实现就是 eval-expr 函数——这就是一个解释器。它接收一个用这些树形结构写成的 Racket 数据(即程序),并产生该语言下的计算结果。我们已经实现了一个包含常量、取反、加法和乘法操作的语言。

本节课中我们一起学习了编程语言实现的基本流程,区分了解释器和编译器两种核心方法,并介绍了一种通过让程序员直接编写 AST 来跳过解析步骤的简化实现方式。这为我们后续实现更复杂的语言特性奠定了基础。

128:解释器的假设与检查 📚

在本节课中,我们将探讨编写解释器时的一个重要问题:如何处理目标语言 B 中的错误。我们将明确解释器可以假设什么,以及必须检查什么。这有助于区分我们正在实现的语言 B 和用于实现解释器的语言 A,对于理解作业中需要检查的错误类型也至关重要。

解释器的职责与假设 🎯

上一节我们介绍了如何为语言 B 定义抽象语法并编写解释器。本节中,我们来看看解释器在运行时可以做出哪些假设,以及必须执行哪些检查。

解释器可以假设其接收到的抽象语法树(AST)是合法的 B 语言程序。这意味着 AST 的结构符合语法定义。如果 AST 不合法,解释器可以直接崩溃并给出奇怪的错误信息,这没有问题。

然而,解释器必须检查程序中使用的数据类型是否正确。具体来说,当递归求值一个子表达式时,它需要检查返回结果的类型。

什么是合法的 AST?🌳

合法的 AST 是解释器必须处理的所有树结构的集合,它是 Racket 允许我们创建的所有可能树的一个子集。Racket 是动态类型语言,当我们为 constnegateaddmultiply 定义结构体时,除了注释和文档,没有任何东西强制 int 字段必须持有数字,e 字段必须持有另一个合法的 AST。

解释器可以假设我们被给予了一个合法的 AST。如果不是,它可以直接崩溃,这是可以接受的。

以下是一些非法 AST 的例子:

  • (multiply (const 2) (add (const 3) "hello"))add 的第二个参数是一个字符串,这没有意义。
  • (negate -7)negate 内部应该是一个表达式,正确的写法是 (negate (const -7))

对于这类非法 AST,解释器无需优雅地处理,直接崩溃给出错误信息即可。

解释器的返回值:值(Value)💎

解释器确实会返回表达式,但并非任意表达式。解释器在任何递归调用中求值一个表达式后,返回的结果总是一种我们称为 值(Value) 的东西。值是一种求值结果为自身的表达式。

在我们的简单语言中,解释器总是返回像 (const 17) 这样的常量。当语言包含多种类型的值时,情况会变得更有趣。例如,在作业中,语言将包含值对、布尔值、字符串和闭包。只有当一对的两个分量本身都是值时,这个对才是一个值。

因此,当我们递归求值一个子表达式时,可能会得到多种类型的值中的一种。任何已完成求值的表达式都是值,而不仅仅是数字。

类型错误与运行时检查 ⚠️

现在,我们扩展语言,使其包含布尔值、数值比较和条件判断。以下是新增的语法结构:

  • (bool b)b 字段应为 truefalse
  • (eq? e1 e2)e1e2 应为求值结果为数字的表达式,用于比较它们是否相等。
  • (if e1 e2 e3):条件判断表达式。

即使程序是合法的 AST(例如,b 是布尔值,e1e2e3 是合法的 AST),在运行时也可能出现类型错误。例如,尝试将常量 (const 17) 与布尔值 (bool false) 相加。

解释器必须检测此类错误,并给出恰当的错误信息,解释发生了什么问题(例如“尝试对非数字进行加法操作”),而不是暴露解释器的内部实现细节。

代码示例:正确与错误的解释器 💻

让我们通过代码来具体理解。我们有两个测试程序:

  1. test1:一个合法的、无类型错误的程序,例如 (multiply (negate (add (const 2) (const 2))) (add (const 7) (const 0))),应正确计算并返回 (const -28)
  2. test2:一个合法但会导致运行时类型错误的 AST,例如在某个分支中最终尝试计算 (multiply (const -4) (bool true))

错误的解释器版本(缺少类型检查)

这个版本能正确处理 test1,但对 test2 会产生糟糕的错误信息。问题在于它错误地假设了递归求值结果的类型。

以下是关键部分的伪代码逻辑:

(define (eval-exp-bad e)
  (cond
    ...
    [(negate? e)
     (let ([v (eval-exp-bad (negate-e e))])
       ; 错误假设:假设 v 一定是 (const ...)
       (const (- (const-int v))))]
    [(add? e)
     (let ([v1 (eval-exp-bad (add-e1 e))]
           [v2 (eval-exp-bad (add-e2 e))])
       ; 错误假设:假设 v1 和 v2 都是 (const ...)
       (const (+ (const-int v1) (const-int v2))))]
    [(if? e)
     (let ([v (eval-exp-bad (if-e1 e))])
       ; 错误假设:假设 v 一定是 (bool ...)
       (if (bool-b v)
           (eval-exp-bad (if-e2 e))
           (eval-exp-bad (if-e3 e))))]
    ...))

这个解释器在遇到类型不匹配时,会因为在错误的位置尝试访问字段(如对非 const 值调用 const-int)而崩溃,并产生难以理解的底层错误信息。

正确的解释器版本(包含类型检查)

这个版本在递归求值后,会检查返回值的类型是否匹配当前操作的要求。

以下是关键部分的伪代码逻辑:

(define (eval-exp-good e)
  (cond
    ...
    [(negate? e)
     (let ([v (eval-exp-good (negate-e e))])
       (if (const? v) ; 正确:检查 v 的类型
           (const (- (const-int v)))
           (error "negate applied to non-number")))]
    [(add? e)
     (let ([v1 (eval-exp-good (add-e1 e))]
           [v2 (eval-exp-good (add-e2 e))])
       (if (and (const? v1) (const? v2)) ; 正确:检查两个操作数的类型
           (const (+ (const-int v1) (const-int v2)))
           (error "add applied to non-number")))]
    [(if? e)
     (let ([v (eval-exp-good (if-e1 e))])
       (if (bool? v) ; 正确:检查条件表达式的类型
           (if (bool-b v)
               (eval-exp-good (if-e2 e))
               (eval-exp-good (if-e3 e)))
           (error "if condition not a boolean")))]
    ...))

对于 test1,它能正确计算。对于 test2,它能给出清晰的错误信息,如 "multiply applied to non-number"。对于非法的 AST,它仍然可以允许底层崩溃。

总结 📝

本节课中,我们一起学习了编写解释器时关于错误处理的核心原则:

  1. 可以假设:解释器可以假设输入的抽象语法树(AST)在结构上是合法的。如果 AST 非法,解释器崩溃是可以接受的。
  2. 必须检查:解释器必须检查运行时值的类型。在递归求值子表达式后,不能假设结果的类型,而必须根据当前操作(如加法、条件判断)的要求进行验证,并在类型不匹配时给出友好的错误信息。

这归结为一点:当解释器递归得到一个值作为结果时,通常需要检查你得到的是哪种类型的值。掌握这一区别对于实现健壮、用户友好的解释器至关重要。

129:实现变量与环境 🧠

在本节课中,我们将学习如何为包含变量的编程语言实现一个解释器。这非常重要,原因有二:首先,真实的编程语言都包含变量;其次,你作业中需要解释的语言也包含变量。到目前为止,我们编写的解释器还没有处理过变量、let表达式或带参数的函数等概念。一旦引入这些概念,我们就需要修改 eval 函数的工作方式,使其在求值程序时能够查找变量。

上一节我们介绍了实现变量解释器的必要性,本节中我们来看看具体如何实现。我不会在本节展示具体代码,因为这正是你的作业任务,但我将用文字详细描述解释器应如何工作。你的任务就是在作业中将其编码实现。

幸运的是,变量和环境的工作方式与我们从课程一开始学习ML时就教授的概念完全一致。还有什么比为一个像你作业中那样的小型编程语言实现其语义,更能确保我们理解这些概念呢?

核心概念:环境与求值

以下是实现的核心思路。

当解释器对一个表达式进行求值时,它需要一个当前环境。环境的作用是将变量映射到值。在作业中,我们可以用一个列表来表示环境,列表中的每个元素是一个序对。每个序对的第一个元素是一个字符串(Racket字符串),表示变量名;第二个元素是语言中被解释的值,例如一个常量、闭包、布尔值等,即表达式求值后可能返回的结果之一。

我们将当前环境作为参数传递给解释器。当遇到一个变量表达式时,解释器需要做的就是在环境中查找该变量。这并不复杂。

递归求值与环境传递

有趣的部分在于递归求值子表达式时。对于许多表达式,递归求值子表达式时使用的环境与当前环境相同。

例如,对于一个加法表达式 (add e1 e2),你需要使用相同的环境递归求值两个子表达式 e1e2。如果 e1e2 是变量 xy,那么解释器会在相同的环境中查找 xy。如果 xy 不在环境中,变量求值分支作为其查找过程的一部分,会给出一个错误信息,提示未找到该变量。

然而,有时在求值子表达式时,需要使用一个不同的环境。最明显的例子是 let 表达式。在求值 let 表达式的主体时,你需要一个更大的环境,即一个包含了由 let 表达式定义的新变量绑定的环境,这样主体部分才能使用这个新定义的变量。

这就是处理变量和 let 表达式的全部核心内容。

解释器结构设计

现在,让我们更详细地描述一下解释器的结构设置。

在你的解释器中,应该定义一个递归辅助函数,我们可以称之为 eval-under-env(在特定环境下求值)。它接受两个参数:一个表达式 e 和一个环境 env。这个函数内部是一个大的 cond 语句,为每种表达式类型设置一个分支。变量分支需要使用环境;某些分支在进行递归调用时只传递相同的环境;而另一些分支则会传递一个稍有不同的环境。

所有核心逻辑都在这个 eval-under-env 函数中。但是,你的程序入口函数 eval(对整个程序求值)会调用 eval-under-env。唯一的问题是:初始环境是什么?答案是空环境,即一个不包含任何字符串与值序对的列表,因为我们开始时环境中没有任何变量。

环境的具体表示

我还没有告诉你的唯一细节是环境的具体表示方法。在作业中,环境的表示就是一个Racket列表。你可以将其定义为包含Racket序对的列表,每个序对的形式是 (变量名字符串, MPL值)。作业中被解释的语言称为MPL(虚构编程语言),其值可以是像常量 17 这样的东西(在作业中写作 (int 17))。

一个重要的风格与评分细节

我想以一个评分细节和风格问题来结束。从风格上看,eval-under-env 本质上是 eval 的一个辅助函数。eval 用空环境调用 eval-under-env。通常你不应该直接调用 eval-under-env,这没有太大意义。因此,你可能会想将 eval-under-env 定义为 eval 内部的一个局部辅助函数。

请不要这样做。 原因是,为了评分脚本能够测试你的解释器,并判断你哪些部分做对了、哪些部分有困难,我们需要能够直接使用特定的环境调用 eval-under-env。这样,即使你在处理环境的某个细节上出了点小错,也不会导致所有测试都失败。所以,我们需要你将 eval-under-env 函数定义在文件的顶层,尽管从一般编程风格角度,将其作为局部辅助函数可能更好。

总结

本节课中,我们一起学习了如何为包含变量的编程语言实现解释器。我们理解了环境作为变量到值映射的核心概念,其实现可以是一个 (变量名, 值) 的列表。我们探讨了在递归求值子表达式时,如何根据表达式类型决定传递相同环境还是扩展后的新环境(例如处理 let 表达式时)。最后,我们明确了解释器的结构应包含一个顶层的 eval-under-env 辅助函数,并从空环境开始程序的求值。这就是实现编程语言中变量和环境所需了解的全部内容。

130:实现闭包 🧠

在本节课中,我们将学习如何在解释器中实现闭包。闭包是函数式编程中的核心概念,它允许函数“记住”其定义时的环境。我们将详细讲解闭包的实现原理,并通过具体步骤展示如何在解释器中处理函数定义和函数调用。


概述

上一节我们介绍了环境的基本概念,但实现闭包是编程语言中最有趣且最具挑战性的部分之一。闭包使得函数可以携带其定义时的环境,从而实现词法作用域。本节将详细讲解如何实现闭包,包括如何存储环境、如何处理函数调用以及如何支持递归。


闭包的实现原理

闭包的核心在于存储函数定义时的环境。当函数被调用时,我们使用这个存储的环境来解析函数体中的变量,而不是使用当前的运行环境。

闭包的结构

闭包是一个包含两部分的结构:

  1. 代码部分:包括函数的参数和函数体。
  2. 环境部分:函数定义时的环境。

在代码中,闭包可以表示为以下结构:

(struct closure (fun env))

其中,fun 是函数定义,env 是函数定义时的环境。


函数定义的实现

当解释器遇到函数定义时,它不会直接返回函数本身,而是创建一个闭包。闭包将当前环境存储起来,以便在函数调用时使用。

以下是处理函数定义的步骤:

  1. 获取当前环境。
  2. 创建一个闭包,包含函数定义和当前环境。
  3. 返回这个闭包作为值。

例如,对于函数定义 (lambda (x) (+ x y)),解释器会创建一个闭包,其中包含函数定义和定义时的环境(包括变量 y 的值)。


函数调用的实现

函数调用时,我们需要使用闭包中存储的环境来解析函数体中的变量。以下是处理函数调用的步骤:

假设函数调用形式为 (e1 e2),其中 e1 应求值为一个闭包,e2 是参数。

  1. 使用当前环境对 e1 求值,得到一个闭包。如果 e1 不是闭包,则报错。
  2. 使用当前环境对 e2 求值,得到参数值。
  3. 获取闭包中存储的环境,并扩展该环境,将函数的参数名映射到参数值。
  4. 如果函数支持递归,进一步扩展环境,将函数名映射到整个闭包(而不仅仅是函数定义)。
  5. 使用扩展后的环境对函数体求值。

例如,对于闭包调用 (closure-fun arg),解释器会使用闭包中存储的环境来解析函数体中的变量,并正确处理递归调用。


递归的支持

为了支持递归,我们需要在环境中将函数名映射到整个闭包。这样,在函数体中递归调用自身时,可以正确引用闭包及其环境。

例如,对于递归函数定义:

(define (factorial n)
  (if (= n 0)
      1
      (* n (factorial (- n 1)))))

在创建闭包时,环境会包含 factorial 到闭包的映射,从而支持递归调用。


总结

本节课我们一起学习了如何在解释器中实现闭包。闭包通过存储函数定义时的环境,实现了词法作用域,使得函数可以访问其定义时的变量。我们还详细讲解了函数定义和函数调用的处理步骤,以及如何支持递归。通过实现闭包,你的解释器将能够支持高阶函数和函数式编程的所有强大功能。


通过本节的学习,你应该能够理解闭包的实现原理,并将其应用到自己的解释器中。闭包是函数式编程的基石,掌握它将为你打开编程语言设计的新世界。

131:闭包的高效实现 🚀

在本节可选课程中,我们将深入探讨闭包的实际高效实现方法。上一节我们介绍了闭包的基本实现方式,本节中我们来看看如何优化闭包的空间效率,并简要讨论编译环境下的闭包处理策略。

概述

闭包实现的核心挑战在于空间效率。我们将学习如何通过识别自由变量来减少环境存储,并了解在实际编译器中如何优化闭包机制。

闭包实现的效率分析

上一节我们介绍了闭包的简单实现方式,本节中我们来看看这种实现方式的实际效率表现。

创建闭包时,我们只是调用构造函数并初始化两个字段。调用函数时,我们只需执行少量操作将正确环境传递给解释器函数。因此时间开销实际上很小。

我们实现闭包的主要问题在于空间效率:闭包存储了函数定义时的完整环境。虽然不同环境通过列表表示可以共享部分数据,但这种列表结构在实际高效实现中并不理想,因为查找变量需要遍历列表。

更严重的问题是存储了不需要的数据:为未来可能使用的闭包存储完整环境会导致程序保留大量不再需要的数据。

自由变量优化策略

以下是优化闭包存储的关键思路:

在实际支持闭包的语言中,创建闭包时不需要存储完整环境,只需存储较小的环境——仅包含函数代码可能使用的变量。

这些变量称为自由变量。自由变量不是指“免费”,而是指“未绑定”——在函数体内使用但未在函数内定义(包括函数参数和局部变量)的变量。

自由变量的精妙之处在于:只需在闭包环境中存储所有自由变量的绑定,因为函数调用时不可能使用其他变量。

自由变量计算示例

以下是自由变量计算的几个示例,通过简单的递归过程即可分析函数体得到:

; 示例1:零参数函数,自由变量为X、Y、Z
(lambda () (+ x y z)) ; 自由变量: x, y, z

; 示例2:x为参数,自由变量为Y、Z
(lambda (x) (+ x y z)) ; 自由变量: y, z

; 示例3:条件分支,自由变量为Y、Z
(lambda (x) (if x y z)) ; 自由变量: y, z

; 示例4:y为局部变量,自由变量仅为Z
(lambda (x) (let ([y 5]) (+ x y z))) ; 自由变量: z

; 示例5:无自由变量
(lambda (x y) (+ x y)) ; 自由变量: 无

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/uwash-cse341-prog-abc/img/500d6607627cbd0d7b22a10fb68851f4_6.png)

; 示例6:考虑变量遮蔽
(lambda (x) 
  (+ y (let ([y 10]) (+ x y)) z)) ; 自由变量: y, z

在最后一个示例中,x不是自由变量(它是参数),y是自由变量(因为第一个y的使用在局部变量y定义之前),z也是自由变量。

实现优化细节

解释器不会在每次创建闭包时分析函数体寻找自由变量,那样效率太低。实际做法是在评估开始前进行预处理(可视为编译步骤的一部分):

  1. 分析程序中的每个函数
  2. 一次性计算每个函数的自由变量
  3. 将信息存储在方便的位置(如函数本身或专门的表中)

这样当执行到函数时,可以直接获取自由变量信息(如“自由变量是X和Y”),然后从当前环境中查找X和Y,创建较小的环境存储在闭包中。

这种方法比存储完整环境需要更多时间,但节省的空间使得这种代价是值得的。

环境查找优化

另外需要注意的是,像我们作业中那样用列表存储环境无法高效查找变量。实际实现中应使用支持快速查找的数据结构,如平衡树或哈希表。这只需更换环境表示的数据结构即可实现。

编译环境下的闭包处理

最后我们简要讨论编译环境下的闭包处理策略。如果没有解释器,而是将支持闭包的语言编译成不支持闭包的语言,该如何处理?

此时不再有接收环境作为参数的解释器。需要做的是:

  1. 编译程序中的每个函数,使其在目标语言中接受一个额外参数
  2. 原本的n参数函数变为n+1参数函数
  3. 将环境作为那个额外参数传递

通过这种方式,程序本身维护环境,在需要时将环境传递到每个函数的每个调用点。这是翻译步骤的副产品,使得程序自身跟踪环境。

同时需要改变变量的翻译方式:函数内对变量x或y的使用不能直接查找,而必须从额外的环境参数中查找。编译代码时需要识别“这原本是自由变量的使用,现在需要从翻译步骤添加的额外参数中查找该变量”。

闭包创建部分仍然相同:创建函数值时,仍然需要构建闭包,获取当前环境(现在是传递给当前执行过程的参数)和函数体,然后构建闭包。

总结

本节课中我们一起学习了闭包的高效实现策略。我们了解到通过自由变量分析可以显著减少闭包存储的环境大小,讨论了预处理优化和数据结构选择的重要性,并简要了解了编译环境下的闭包处理机制。虽然我们的实现方式已经足够,但实际应用中还有这些优化空间,确保闭包实现不会低效。

132:作为解释型语言宏的Racket函数

在本节课中,我们将学习如何将Racket函数用作宏,以扩展我们正在实现的解释型编程语言的功能。我们将结合之前学习的解释器和宏的知识,展示一种在Racket中实现语言B的编程模式。

上一节我们介绍了如何为解释型语言编写解释器。本节中,我们来看看如何利用Racket函数来模拟宏的行为,从而在编写语言B的程序时获得语法扩展的能力。

核心概念回顾

我们目前采用以下方法在语言A(Racket)中实现语言B:

  • 我们跳过解析和具体语法,直接使用语言A的构造器编写语言B的程序。
  • 我们的解释器用语言A编写,通过递归求值子表达式来计算更大表达式的结果。

关于宏,我们知道:

  • 宏用于扩展语言的语法。
  • 宏定义后,在使用宏的地方,程序运行前会先根据宏定义展开宏调用,重写程序语法。

现在,我们将看到如何通过编写Racket函数来模拟宏,这些函数接收语言B的语法并生成语言B的语法。

实现“宏”作为Racket函数

其工作原理如下:我们将编写一些Racket函数,它们接收语言B的语法作为输入,并输出语言B的语法。当我们在编写语言B程序时使用这些函数,这些函数的输出结果会被放入我们的程序中,然后才交给解释器处理。我们无需修改解释器或添加任何结构体定义。

让我们通过代码来具体理解。首先,我们有一个之前定义的小型语言及其解释器 eval-exp

以下是该语言支持的部分表达式定义:

(struct const (int) #:transparent)
(struct negate (e) #:transparent)
(struct add (e1 e2) #:transparent)
(struct multiply (e1 e2) #:transparent)
(struct bool (b) #:transparent)
(struct eq-num (e1 e2) #:transparent)
(struct if-then-else (e1 e2 e3) #:transparent)

以下是解释器 eval-exp 的核心结构:

(define (eval-exp e)
  (cond [(const? e) e]
        [(negate? e) (const (- (const-int (eval-exp (negate-e e)))))]
        [(add? e) (let ([v1 (const-int (eval-exp (add-e1 e)))]
                        [v2 (const-int (eval-exp (add-e2 e)))])
                    (const (+ v1 v2)))]
        ... ; 其他表达式类型的处理
        [#t (error "eval-exp expected an exp")]))

现在,让我们定义一些Racket辅助函数,它们将充当我们语言的“宏”。

首先,定义一个实现逻辑“与”操作的函数 and-also

(define (and-also e1 e2)
  (if-then-else e1 e2 (bool #f)))

这个函数接收两个表达式 e1e2,并返回一个 if-then-else 表达式。它接收语法,返回语法,这正是宏所做的。

我们可以这样使用它:

(define y (and-also (bool #t) (eq-num (const 3) (const 4))))
(eval-exp y) ; 结果为 (bool #f)

调用 and-also 时,我们传入语法,得到展开后的程序 y,然后将 y 交给解释器求值。

接下来,定义一个“加倍”操作的函数 double

(define (double e)
  (multiply e (const 2)))

再定义一个更复杂的例子,一个接收Racket列表并返回连乘程序的函数 list-product

(define (list-product es)
  (if (null? es)
      (const 1)
      (multiply (car es) (list-product (cdr es)))))

现在,让我们看一个使用了所有上述“宏”的更大程序示例:

(define test
  (and-also (eq-num (double (const 4)) (const 8))
            (eq-num (list-product (list (const 2) (const 2) (const 1) (const 2)))
                    (const 8))
            (bool #t)))

变量 test 中保存的是完全展开后的语法。我们可以将其交给解释器求值:

(eval-exp test) ; 结果为 (bool #t)

重要注意事项

需要强调的是,这种方法虽然便捷,但并非完美的宏系统。特别是,如果实现的语言包含变量,这种方法无法像Racket自身的宏系统那样妥善处理变量遮蔽(hygiene)问题。在定义此类“宏函数”时,可能需要使用特殊的变量名来避免在变量遮蔽时出现意外的语义。

尽管如此,其核心思想是成立的:我们可以将一个接收并生成语言B语法的Racket函数,视为该语言的一个宏。

总结

本节课中,我们一起学习了如何在实现解释型语言时,利用Racket函数来模拟宏的行为。我们了解到,通过编写接收和返回目标语言语法的函数,我们可以在程序被解释器求值之前对语法进行转换和扩展。这种方法为我们在Racket中嵌入实现的语言提供了强大的元编程能力,虽然它在处理变量时存在局限性,但为理解宏的基本概念和实现方式提供了清晰的视角。

133:ML与Racket对比

在本节课中,我们将比较和对比Racket与ML这两种编程语言,分析它们各自的优势和劣势。我们将重点关注两者最核心的区别:ML拥有一个在程序运行前就拒绝大量程序的静态类型系统,而Racket则选择更宽松,允许更广泛的程序,但代价是运行时可能出现更多错误。


🧠 从Racket程序员的视角看ML

上一节我们介绍了对比的背景,本节中我们来看看,一个熟悉Racket的程序员会如何看待ML的类型系统。

最自然的想法是:ML就像是Racket的一个子集。它接收所有Racket程序,然后通过类型系统剔除掉其中一部分,剩下的就是ML程序。这种视角有其优点。

以下是Racket程序员可能会欣赏ML类型系统的原因:

  • 捕获错误:许多被ML拒绝的程序本身就有错误。例如,一个函数同时将参数传给+(加法)和car(取列表头)操作,这在Racket中必然导致运行时错误。ML能在你运行前就发现这个错误。
  • 消除类型检查:在通过类型检查的ML程序中,你总是能静态地知道每个值的类型。因此,你永远不需要在运行时使用像number?这样的内置谓词来检查类型,这非常简洁。

然而,Racket程序员也可能对这个“子集”感到不满,因为它也排除了许多在Racket中完全正确、没有错误的程序。

以下是Racket中合法但ML不允许的例子:

  • 一个函数在if表达式的不同分支中,有时返回布尔值,有时返回列表。
  • 构建一个包含不同类型数据(如数字和字符串)的列表。
  • 调用上述多态函数并得到正确结果。

Racket程序员会认为这些代码运行良好(例如返回true),但ML却不允许。


🔄 从ML程序员的视角看Racket

上一节我们从Racket的视角看了ML,本节中我们反过来,看看ML程序员如何理解Racket。

一种观点是Racket是ML的“超集”,但还有一个更有趣的视角:可以将每个Racket程序都看作是一种特殊风格的ML程序

其核心思想是:Racket并非没有类型,而是所有东西都属于同一个巨大的数据类型。我们称这个类型为the-type

在ML中,这个类型可以这样定义:

datatype the-type = Int of int
                  | String of string
                  | Pair of the-type * the-type
                  | Bool of bool
                  | Symbol of symbol
                  | Procedure of ... (* 表示函数 *)
                  (* ... 为每种内置数据添加构造器 ... *)

从这个ML视角看世界:

  • Racket表达式求值后得到的都是the-type类型的值。
  • Racket解释器会自动为你添加“构造器标签”。例如,你写42,实际上像是写了Int(42)
  • 每个传递的值都带有标签,标明其类型。
  • carpair?+这样的函数,在底层实现中,都在对the-type值进行模式匹配,检查标签并执行相应操作或抛出错误。

因此,Racket中的所有操作都可以看作是在使用这个统一的the-type类型。

这种视角几乎涵盖了Racket的所有特性,除了struct。在ML程序员看来,Racket的struct定义是在程序动态执行时,向the-type数据类型添加新的构造器。这在ML中通常是不允许的(因为它会破坏模式匹配的穷尽性检查等),但在概念上是合理的。实际上,ML中的异常类型exn就是以类似方式工作的。


📝 总结

本节课中我们一起学习了从两种不同视角理解Racket和ML:

  1. Racket视角看ML:ML像是Racket的一个安全子集。它的类型系统能提前捕获许多错误,但代价是排除了一些在Racket中灵活但正确的编程模式。
  2. ML视角看Racket:Racket可以被视为一种特殊的ML编程风格,其中所有值都属于一个巨大的、带标签的联合数据类型the-type)。Racket的动态特性,如运行时定义struct,相当于动态扩展这个数据类型。

这两种视角帮助我们更好地理解静态类型(ML)和动态类型(Racket)设计哲学的根本差异,为我们后续深入探讨类型检查、静态分析等概念奠定了基础。

134:静态检查是什么?🔍

在本节课中,我们将要学习静态检查的概念。在讨论静态检查的优缺点之前,我们必须先理解它究竟是什么。毕竟,在不了解某事物本质的情况下争论其好坏是毫无意义的。

静态检查的定义

静态检查是指在程序成功解析后、开始运行前,拒绝某些程序集合的任何操作。解析完成后,我们得到抽象语法树。此时,语言会判定:尽管程序已解析,但由于某些原因,该程序被拒绝。这个拒绝过程就是静态检查。哪些程序被拒绝、哪些被允许运行,这绝对是编程语言定义的一部分。它是定义的核心部分,以便开发者了解哪些是合法的程序。

当然,除了编程语言定义本身,你还可以使用额外的工具进行静态检查。这些工具可以检测错误并发出警告,这种做法非常流行且值得提倡。但在这里,我们只关注作为编程语言定义一部分的静态检查。

类型系统:方法与目的

在编程语言定义中,实现静态检查最常见的方式是通过类型系统。对于类型系统,我们需要区分其采用的方法与其目的。

大多数类型系统采用的方法是:每个变量都有一个类型。我们利用这一点对每个表达式进行类型检查,函数可能具有我们感兴趣的类型签名等。这都很好。然后,一些程序通过类型检查,一些则不能。但这样做的目的是什么?我们必须清楚特定类型系统试图防止什么。

例如,类型系统可能试图阻止你将字符串传递给除法等算术运算。这是类型系统防止的一种情况。它还能防止违反模块的抽象性,比如 ML 的模块系统。它避免了像 Racket 中的 number? 原语那样的运行时检查。它防止使用未定义的变量等。

动态类型语言

动态类型语言就是不进行这类静态检查的语言。但这并非绝对界限。例如,Racket 在你点击运行按钮时实际上会检查几项内容,比如不允许存在未定义的变量。但它静态检查的内容非常少,因此我们仍倾向于称其为动态类型语言。

ML 类型检查器的目的

为了理解类型系统目的这一概念,让我们更具体地看看 ML。以下是 ML 类型检查器旨在防止的一系列错误。我们知道,如果一个 ML 程序能够运行,它将永远不会出现以下任何错误:

  • 永远不会对错误类型的值使用原语操作。例如,永远不会出现 E1 应用于 E2E1 未求值为某个闭包的情况。
  • 永远不会出现 if-then-else 表达式中 ifthen 之间的表达式求值结果不是布尔值的情况。
  • 永远不会在解释器在环境中查找时,使用环境中未定义的变量。
  • 永远不会出现模式匹配中存在冗余模式等情况。

这些错误中有许多是类型系统的标准防范内容,但它们确实是每种编程语言定义的一部分。不同的语言可以使用其类型系统来防止不同的问题。一种类型系统防止的问题,另一种可能不防止,这很正常。

类型系统的局限性

同样,没有类型系统能防止所有错误。以下是 ML 类型系统无法防止的一些错误和缺陷:

  • 无法防止使用空列表调用 head 函数。
  • 无法防止数组越界错误。ML 有数组,它确保数组下标总是用整数访问,但不确保该整数足够小以指向数组中实际存在的元素。
  • ML 的类型系统无法防止除以零等错误。

存在许多类似的错误,你可能想象类型系统可以防止它们,但当今大多数语言中的类型系统并不这样做。总的来说,即使不考虑这类问题,你也永远不能指望类型系统找到所有错误,因为它不知道你的程序试图做什么。

除非静态检查器获得了关于你的程序在所有输入上应如何行为的完整规范,否则它不可能读懂你的心思,了解程序应该做什么。因此,如果你有一个完全正确的 if-then-else 表达式,只是 then 分支和 else 分支放反了,类型检查器如何知道你不应该这样做?同样,如果你有两个类型为 int -> int 的函数 FG,但你本意想调用一个却调用了另一个,这也不是类型系统设计来防止的问题。

因此,除非你有关于程序应做什么的完整规范,否则类型系统和静态检查器几乎不能替代我们期望的程序测试和动态评估。

类型系统的核心观点

这里我强调一个关于类型系统的特定观点,这在技术上是准确的,也是一个重要的视角:类型系统的目的是防止某些行为在运行时发生。这与类型检查器如何根据表达式具有类型来定义,以及如何通过在你的程序上运行的递归过程来实现类型推断等,是分开的问题。

语言设计包括两个问题:首先,你要检查什么,即你的类型系统的目的是什么;其次,你打算如何强制执行这些规则,如何确保永远不会将字符串传递给加法运算符。设计类型系统的难点在于确保它们在实现目的的同时,保持实用、灵活,并且是人们想要使用的语言类型。我们将在下一节研究正确性的精确定义以及如何思考这个问题。

静态与动态的连续谱

但在继续之前,我想指出,拥有静态检查或不拥有静态检查并不是唯一的选择。实际上,这里存在一个连续的选择谱。从根本上说,尽早捕获错误(例如在运行相关代码之前给出类型错误)总是与“也许我不应该称之为错误,因为它永远不会产生影响”之间存在内在的张力。程序员不需要知道有某种原因导致该错误不会发生等等。

因此,像你习惯的类型系统那样的静态检查,以及在运行时遇到错误操作时的动态检查,只是这个连续谱上的两个点。为了强调这一点,让我举一个奇怪的例子,这样你就不会觉得熟悉。

假设我们想要防止的坏事是除以零。因此,你永远不会让程序到达试图用三除以零的地步。让我列举一系列可能防止这种情况的方法:

  • 极端的静态端:甚至在正常的编译时之前,就像我们一直在讨论的,它会做的是:每次你尝试输入一小段程序,甚至在屏幕上显示这些字符之前,它都会运行一些分析,以确保当前构建的程序永远不会除以零。你看,这比我们习惯的更早地捕获了错误。我们习惯的是在输入程序时,然后选择编译,它会给出错误信息。
  • 比我们习惯的类型系统更不急切:也有一些在运行时之前进行的检查,但不如我们习惯的类型系统那么急切。例如,它可能允许每个单独的文件通过类型检查,但随后等待,就在你准备运行程序之前,你有一个特定的 main 函数和一组特定的参数,也许即使在那时它也会进行一些检查并说:“我认为你可能会除以零,所以我不让程序运行。”这将比我们习惯的运行时更急切。
  • 我们习惯的运行时:这有点像 Racket 的做法,如果你写 (/ 3 0),你会得到某种错误。你可能会认为这是最动态的方式了,你无法超越除以零这一步,我们在学校学过不允许除以零,这是一个非法操作。
  • 比运行时更动态:然而,我们可以做得比这更“动态”。我们可以以某种方式返回一个表示我们除以零的答案,将其返回给调用者,并让他们处理它。这可能听起来很奇怪,但事实证明,这在几乎所有编程语言中的浮点数运算中正是如此。因为进行科学计算的人认为除以 0.0 实际上是有用的,因为如果你返回无穷大,调用者可能不需要它。可能的情况是,在分母为 0.0 的情况下,这个计算最终会在其他地方被抵消,或者你会采取某种条件判断,导致你最终不使用这个变量。因此,事实证明编程语言通常这样做。如果它们愿意,它们也可以对整数 0 做同样的事情。

因此,何时检测错误以及如何将其转化为错误,这是一个语言设计选择,你有一个完整的连续谱。只是根据我们多年来设计语言的经验,编译时和运行时是表示错误的该连续谱上最常见的两个点。

总结

本节课中,我们一起学习了静态检查的概念。我们明确了静态检查是在程序解析后、运行前拒绝某些程序的过程,它是编程语言定义的核心部分。我们重点探讨了类型系统作为实现静态检查的主要方法,区分了其方法(如变量类型、表达式检查)与目的(防止特定运行时错误,如类型不匹配、未定义变量使用等)。我们还认识到,类型系统有其局限性,无法防止所有错误(如逻辑错误、某些运行时错误),并且静态检查与动态检查存在于一个连续谱上,语言设计者需要在错误检测的时机和严格性之间做出权衡。理解静态检查的本质是评估其利弊的基础。

135:可靠性与完备性 📚

在本节中,我们将精确地定义类型系统“正确”的含义。实际上,我们通常不直接定义“正确性”,而是定义两个相对的概念:可靠性完备性

概述

我们将学习类型系统如何通过“可靠性”来确保阻止某些不良行为,以及“完备性”如何描述其精确性。同时,我们会探讨为何完美的静态检查在理论上是不可能的,并了解当类型系统不可靠时可能采取的措施。

可靠性的定义 🛡️

我们已经知道,类型系统的目的是阻止某些事情发生。假设存在一个属性 X,类型系统旨在阻止它。例如,X 可以是不将字符串传递给加法运算,或者不查找未定义的变量。它可以是单一属性,也可以是一组属性。

在给定 X 的前提下,我们说一个类型系统是可靠的,如果它永远不会接受一个在运行时(给定某些输入)会执行 X 的程序。可靠性意味着它确实阻止了它本应阻止的事情。

一个相关的术语是“没有假阴性”。这借鉴自医学和统计学。类型检查器就像一次医学检测,程序是病人,我们检测的是它是否会执行 X。如果检测结果说“不会执行 X”(阴性),但实际上它会,这就是一个假阴性。可靠的类型系统,根据定义,没有假阴性。

完备性的定义 🎯

一个类型系统是完备的,如果它永远不会拒绝一个不会执行 X 的程序。这是关于“没有假阳性”。如果我们的类型检查器是医学检测,程序是病人,我们说“你患有 X 疾病”(阳性),但实际上它没有,这就是一个假阳性。完备的类型系统永远不会报告假阳性。

实践中的权衡 ⚖️

上一节我们介绍了可靠性与完备性的定义。本节中我们来看看实践中类型系统的设计选择。

我们为编程语言定义的类型系统通常被设计为可靠的。语言设计者和理论家花费大量时间来检查、确保并写下证明,希望它们确实是可靠的。这样,你就可以信赖一个事实:如果你的程序通过了类型检查,它就不会患上本应被阻止的“疾病”。

而我们所有更高级的类型系统特性,如泛型,通常旨在减少假阳性的数量。我们的类型系统确实存在假阳性,它们会拒绝那些实际上不会执行试图阻止的 X 行为的程序。我们让类型系统变得更高级,以便程序员能尽可能避免假阳性。

以下是 ML 语言中被拒绝但实际上不会执行不良行为的例子(即假阳性)。ML 的类型系统在阻止“用字符串进行除法运算”方面是可靠的,因此没有假阴性的例子。

  • 未调用的函数:假设有函数 fun f1 x = 4 div "hi"。即使 f1 从未被调用,调用它会用字符串做除法这件事本身无关紧要,但 ML 不会如此宽容,它会报告整个程序有“用字符串做除法”的问题。
  • 不可达代码:考虑函数 fun f2 x = if true then 0 else 4 div "hi"4 div "hi" 位于一个永远无法执行的分支中(因为条件是 true),但类型系统仍然会报告这个除法错误。
  • 依赖运行时值的分支fun f3 x = if x then 0 else 4 div "hi"f3 可能执行 else 分支,这取决于参数 x。但假设在整个程序中,f3 只被以 true 调用,那么这段代码永远不会执行。然而,我们预期类型检查器会给出这类错误信息。
  • 基于数学事实的不可达代码fun f4 (x:int) = if x <= abs x then 0 else 4 div "hi"。实际上,对于任何整数 xx <= abs x 总是成立,因此 else 分支永远不可达。但我们能期望类型检查器推理出这个数学事实来避免假阳性吗?
  • 条件表达式中的类型fun f5 x = 1 div (if true then 1 else "hi")。同样,类型检查器需要推理出 if-then-else 的条件总是 true,才能知道分母永远是数字,从而避免错误。

这些只是例子。从实用角度,我们理解并不期望类型检查器避免所有这些假阳性,这似乎是可以接受的。但这背后还有一个深刻的理论原因。

不可判定性的限制 🧠

上一节我们看到了一些假阳性的具体例子。本节中,我们将探讨其根本原因——不可判定性。

事实证明,对于几乎所有你想静态阻止的属性 X,你都无法在不产生假阳性的情况下阻止它。这就是不可判定性的概念,它是计算机科学的核心思想。

对于编程语言中非常多、几乎任何非平凡的属性 X,一个静态检查器无法同时做到以下三件事:

  1. 总是终止。
  2. 是可靠的(无假阴性)。
  3. 是完备的(无假阳性)。

因此,如果你想要一个总是终止的静态检查器(程序员确实希望他们的类型检查器能终止),那么你就必须接受假阳性或假阴性,或两者兼有。

在大多数编程语言中,我们做出了设计选择:我们将不会有假阴性(即保证可靠性)。但由于这个适用于任何功能完备的编程语言的数学定理,为了确保类型检查器总是终止,我们将不得不接受一些假阳性。

无论你想阻止什么——想阻止不终止的函数、想阻止将字符串用作函数、想阻止除以零——这些都无法通过一个总是终止、且没有假阳性、也没有假阴性的检查器来实现。

不可判定性是计算机科学的核心。我鼓励每个人都在计算理论课程中学习它。在本课程中,我们不会学习为什么这是真的。但以我作为一个编程语言研究者的偏见观点来看,静态检查固有的近似性——即我们所有的静态检查器要么有假阳性,要么有假阴性,因此我们选择让它们有假阳性——是这个定理对软件开发最重要的影响。这是每天影响我们所有软件开发的事情,而这个数学定理永远不会改变。

如果类型系统不可靠会怎样?⚠️

那么,如果我们确实想要假阴性呢?如果我们确实有一个不可靠的类型系统,它声称能阻止 X,但类型检查器接受了一些程序,而这些程序在运行时 X 确实可能发生,那会怎样?编程语言可以怎么做?

首先,也许这只是语言设计中的一个错误,我们应该更新语言,设计一个限制性更强、能拒绝更多程序的类型系统,以消除所有这些假阴性。

如果你不打算这样做,那么即使你“通常”阻止 X 或做出了很好的尝试来阻止 X,也许你应该作为一个后备选项,继续对 X 进行动态检查,就像 Racket 所做的那种动态运行时检查。即使你有一个试图发现某些错误但并不总能发现所有错误的不可靠类型系统,你仍然可以拥有动态检查。

比这更糟的是,显著更糟的是直接允许 X 发生。也就是说,即使这是一件没有意义的坏事,我没有静态阻止它,我也不打算动态检查它,我就让它发生。也许你正在从环境中读取一个变量,而它不在环境中。一个可以接受的做法可能是返回某个默认值,比如 0。脚本语言倾向于这样做。我们可能在 Ruby 中看到类似的情况,尽管 Ruby 通常不会走到那一步。

最糟糕的事情是:好吧,我试图阻止 X,但我并不总能做到。如果 X 真的发生了,我就让程序做任何事情。我让程序表现得像世界上的任何程序一样,甚至可能是一个删除你电脑上所有文件或在互联网上发送病毒的程序。这正是 C/C++ 对其类型系统所采取的方法,我将在下一节中更详细地讨论这一点。

总结

本节课中,我们一起学习了类型系统的两个核心属性:

  1. 可靠性:确保被接受的程序绝不会执行被禁止的操作 X(无假阴性)。这是类型系统设计的首要目标。
  2. 完备性:确保不会拒绝那些实际上不会执行 X 的程序(无假阳性)。这在实践中难以完全实现。

由于不可判定性这一根本的计算理论限制,一个总是终止的静态类型检查器无法同时做到完全可靠和完全完备。因此,实际的语言设计通常在保证可靠性的前提下,接受一定程度的假阳性。我们还探讨了当类型系统不可靠时可能带来的风险及不同的处理策略。理解这些概念有助于我们更好地理解和使用类型系统,并认识其能力的边界。

136:弱类型与相关概念辨析 🧩

在本节中,我们将简要探讨弱类型的概念。这是一个与静态类型和动态类型不同的独立话题,但由于它常与后者混淆,因此有必要在此澄清。

概述

我们将首先定义弱类型,并将其与静态/动态类型进行区分。接着,我们会探讨弱类型存在的原因及其利弊。最后,我们会澄清一些常与弱类型混淆的其他语言特性。

弱类型的定义 🔥

上一节我们介绍了静态与动态类型,本节中我们来看看弱类型。

弱类型,通常以C和C++的类型系统为代表,可以描述如下:存在一些程序,根据语言定义,它们能通过静态检查。但当程序运行时,它们被允许执行任何操作。形象地说,它们被允许“点燃计算机”,即允许程序崩溃、损坏数据、成为病毒或删除所有文件等。

核心思想是:存在一些执行无意义操作的程序,而本应在灾难性后果发生前捕获这些错误的动态检查是可选的。在C/C++的实际实现中,这些检查通常不被执行。

弱类型存在的原因 ⚙️

如果你只使用过Racket、Python、ML或Java编程,这听起来可能很疯狂。为什么允许语言实现不进行这些检查?原因有很多。

以下是使用C/C++在某些场景下的合理理由:

  1. 易于实现:这使得语言更容易在各种计算平台上实现。检查工作留给了程序员。
  2. 性能:如果不进行动态检查,不仅节省了执行检查的时间,还无需在程序中存储用于动态检查的各种标签和大小信息,从而节省空间。
  3. 低级控制:如果需要一个让程序员能控制数据表示等细节的语言,就不能有需要程序员无法控制的额外字段的动态检查。

需要强调的是,“弱类型”这个名字并不贴切。它实际上与类型系统关系不大,而关乎于:你试图防止某些不良属性X,但既不进行静态检查,也不进行动态检查。如果X发生,计算机被允许“着火”。

当你意识到C/C++程序出现无法解释的行为(或许不包括“点燃计算机”)的最大原因之一是数组越界访问时,就能明白这其实不完全是类型问题。数组越界在这些语言中通常不被检查,但大多数人并不认为数组边界与类型系统相关。

观念的演变 🤔

几十年前,有些人曾是弱类型的支持者,当然也有很多人反对。支持者有一句名言:“强类型是为弱智准备的”。其观点是,只有当你认为计算机比人类更聪明时,你才会想要强类型(弱类型的反面)。但我们知道计算机并非更聪明,静态检查也永远不会完美,因此总需要聪明的人类想出一些绕过检查的方法。他们认为,人类应该能够说“相信我,我说这是对的”,如果错了,计算机可以做任何事。

然而,传统的观念已经发生了很大变化。现实中,我们认识到人类非常不擅长避免错误,我们需要一切可能的帮助。如果我们能编写一个计算机程序来为我们进行一些检查,这是一种很好的责任划分:编写静态或动态检查器的人可以专注于让检查正确无误,编写应用程序的人可以专注于应用程序的逻辑,并依赖强类型编程语言提供的自动检查。

公平地说,传统观念的改变也因为我们的类型系统变得好得多。它们更灵活,拥有多态性、子类型等特性,使得在强类型系统中编程更容易,且不会感到束手束脚。

在我看来,弱类型的论点在现代软件的规模和复杂性面前站不住脚。如今的操作系统包含数千万行C代码。如果你写的是200或1000行C代码,我理解“人类可以通过仔细检查来消除大部分甚至所有错误”的论点。但当你面对3000万行代码,并且其中任何一行都可能导致整个应用程序执行任意行为时,我认为期望不需要保证某些错误和可预防的属性在软件中不可能发生,是荒谬的。

澄清:Racket不是弱类型 ✅

我想强调的是,Racket不是弱类型的。这只是一个定义问题。Racket是动态类型的,它在运行时检查许多事情(例如不将数字当作过程使用),但这与不进行检查有本质区别。

在Racket中,语言定义规定这些错误将在特定时刻被检测到,并引发一个错误。在语言实现中,如果实现能通过某些分析证明某些检查永远不会失败,那么这些检查可以被移除。语言定义要求这些检查必须存在。如果检查失败,必须通过异常或程序错误来指示。这与C/C++的“着火”语义完全不同。

易混淆的概念:操作语义的灵活性 🔄

最后,让我谈谈另一个话题,它既不是弱类型,也不是动态类型,但常与它们混淆。这就是基本操作能做什么的问题。

在某些语言中,如果你尝试用+操作字符串,会得到一个类型错误(在ML中是运行时错误,在Racket中也是)。但在其他语言中,+可能意味着字符串连接。也许在那些语言中,尝试将字符串和数字相加是一个错误。还有一些语言中,这不是错误,它会将数字转换为字符串,最终得到一个像“F003”这样的四字符字符串。

还有其他一些我习惯认为是错误的情况,在某些语言中却不认为是错误:

  • 如果我访问一个只有5个元素的数组的第10个元素,在某些语言中,这不是错误,你只会得到null或空列表之类的东西。
  • 如果你调用一个函数时传入错误数量的参数,在Racket中这是一个错误。但不同的语言可以做出不同的选择:它可以说,如果你传递了太多参数,我们就忽略多余的;如果你传递的参数太少,我们可能为所有缺失的参数传入默认值(如79)。

你可以通过这种方式定义你的语言,使其更灵活,并决定更少的事情是错误。但这实际上并不是静态检查与动态检查的问题。我们有时认为在这些事情上更宽容的语言“更动态”,但从技术上讲,它们并非如此。这里发生的一切只是我们改变了语言中基本操作的求值规则。

如果你改变语言的求值规则以允许更多的参数、更多类型的东西,你是在增加灵活性,你是在以牺牲及时发现错误为代价来增加合法程序的数量。相反,即使那不太可能是程序员的原意,程序也会默默地继续执行。

静态检查(更早发现错误)和动态检查(更晚发现错误)之间的一些权衡在这里也适用。在这里,我们甚至更加“动态”,在宣布似乎出了问题的事情上更加延迟。但这根本不是静态检查或动态检查,这只是说我改变了语言的求值规则。

总结

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

  1. 弱类型的定义:允许通过静态检查的程序在运行时执行任意(可能有害的)操作。
  2. 弱类型存在的原因,包括性能、低级控制和历史因素。
  3. 随着软件复杂度的增加和类型系统的进步,支持强类型(进行充分检查)的观点已成为主流。
  4. 明确了Racket等语言是动态类型(进行运行时检查),而非弱类型。
  5. 区分了与弱类型易混淆的另一个概念:语言操作语义的灵活性(改变基本操作的求值规则),这本质上是定义了什么是合法程序,而非检查策略。

理解这些区别有助于我们更清晰地思考编程语言的设计与选择。

137:静态类型与动态类型对比(第一部分)🚀

在本节课中,我们将探讨静态类型检查与动态类型检查各自的优劣。请注意,我们不会得出一个绝对的结论,而是通过分析双方的观点来理解各自的优势与不足。大多数编程语言实际上同时采用静态和动态检查,因此关键在于理解在何时、对何种内容进行检查的利弊。

概述

本节将介绍静态类型与动态类型系统的三个核心对比点:便利性、表达能力以及错误捕获的时机。我们将通过具体的代码示例来阐述这些观点。

便利性对比

首先,我们来看一个支持动态类型系统的常见论点:动态类型系统通常被认为更加便利。

以下是动态类型语言(如Racket)的一个例子:

(define (f y)
  (if (> y 0)
      (+ y y)
      "hi"))

在这个函数中,根据条件,f 可能返回一个数字,也可能返回一个字符串。在Racket中,你可以直接这样做。

相比之下,在静态类型语言(如ML)中,要实现类似的功能,你需要显式地定义一个数据类型来包装这两种可能性:

datatype t = Int of int | Str of string

fun f y = if y > 0 then Int (y + y) else Str "hi"

然后,在使用这个函数的结果时,Racket提供了内置的原语(如 number?)来检查类型,而ML则需要程序员使用模式匹配来提取数据。因此,对于这类程序,动态类型似乎更加方便。

然而,静态类型的支持者会反驳:真正的便利在于,静态类型系统能确保函数接收到的参数已经是正确的类型。例如,一个计算立方值的函数:

fun cube x = x * x * x

在ML中,你可以确信 cube 函数只会被传入整数。而在Racket中,你需要自己添加运行时检查来验证参数类型,否则错误只能在运行时被发现。

表达能力对比

动态类型系统的另一个优势是:静态类型检查有时会拒绝一些逻辑上完全正确的有用程序。

考虑以下ML代码,它尝试定义一个高阶函数:

fun f g = (g 7, g true)

ML的类型系统会拒绝这段代码,因为它无法推断出一个函数 g 能同时接受 intbool 类型的参数。即使逻辑上,如果传入一个合适的多态函数(如 fn x => (x, x)),这是完全可行的。

而在Racket中,相同的代码可以顺利运行:

(define (f g)
  (cons (g 7) (g #t)))

(f (lambda (x) (cons x x)))
; 返回:((7 . 7) (#t . #t))

因此,静态类型检查在这里似乎阻碍了程序的编写。

静态类型的支持者则会指出:Racket之所以能做到这一点,是因为它在底层为所有数据都附加了类型标签,并在每次操作前进行动态检查。在ML中,程序员可以通过自定义数据类型来获得相同的灵活性,同时还能精确控制哪些地方需要类型标签和动态检查,从而在性能和安全性上获得更多控制权。

错误捕获时机对比

支持静态类型检查的一个最强有力的论点是:它能在更早的阶段捕获错误。

在ML中,如果你在编写函数时犯了类型错误,编译器会立即报告。例如:

fun pow1 x y = if y=0 then 1 else x * (pow1 (x, y-1)) (* 错误:应为 currying 调用方式 *)

这段代码会因为调用方式错误而无法通过类型检查。错误在编译阶段就被捕获。

而在Racket中,类似的逻辑错误只有在运行时执行到特定代码路径时才会暴露:

(define (pow1 x)
  (lambda (y)
    (if (= y 0)
        1
        (* x (pow1 x (- y 1)))))) ; 这里 pow1 被错误地传入了两个参数

你需要运行测试才能发现这个错误。

然而,动态类型的支持者会反驳:静态类型检查主要捕获的是那些简单的、容易通过测试发现的类型错误。对于更深层的逻辑错误,静态类型系统也无能为力。例如,下面的ML函数能通过类型检查,但逻辑是错误的:

fun pow2 x y = if y=0 then 1 else x + (pow2 x (y-1)) (* 应为乘法,却错误地使用了加法 *)

调用 pow2 3 4 会得到错误的结果 13。这种逻辑错误,无论是静态还是动态类型系统,都需要通过测试来发现。因此,既然无论如何都需要测试,那么动态类型系统在早期捕获错误的优势就被削弱了。

总结

本节课我们一起探讨了静态类型与动态类型系统的三个核心争议点:

  1. 便利性:动态类型在编写灵活代码时更方便;静态类型通过保证类型安全减少了运行时检查的负担。
  2. 表达能力:动态类型系统允许编写一些静态类型系统会拒绝的、逻辑上有效的程序;静态类型系统则通过赋予程序员对类型标记的精确控制来换取安全性和潜在的性能优势。
  3. 错误捕获:静态类型检查能在编译期提前发现许多类型错误;但双方都同意,对于复杂的逻辑错误,充分的测试都是必不可少的。

这些观点都有其合理性,选择哪种类型系统往往取决于项目需求、团队偏好以及对开发效率与软件可靠性之间的权衡。在下一节中,我们将继续探讨更多的论据。

138:静态类型与动态类型对比(第二部分)🚀

在本节课中,我们将继续探讨静态类型与动态类型各自的优缺点,并深入比较两者。我们将从性能、代码复用、原型开发以及软件维护与演进等多个角度进行分析。

性能考量 ⚡

上一节我们讨论了静态类型与动态类型的基本概念,本节中我们来看看性能方面的考量。

支持静态类型语言的人常认为,静态类型能带来更快的程序执行速度,同时仍能防止类型检查旨在避免的错误。其核心思想不仅在于节省了运行时检查(例如加法运算前检查参数是否为数字)的时间,还在于避免了存储和执行这些检查所需的数据结构带来的空间和时间开销。

在 ML 中,我们可以直接向加法函数传递一个实际的数字。而在 Racket 中,我们必须传递一个包含类型标识字段和实际数值字段的数据结构。这导致了性能上的差异。此外,在我们自己的代码中,我们不需要频繁地进行类型检查,例如使用 number?string? 等谓词。

; Racket 中需要检查类型
(if (number? x)
    (+ x 1)
    (error "not a number"))

(* ML 中类型由系统保证 *)
fun add x y = x + y

然而,动态类型的支持者会对此提出反驳。他们承认,Racket 的定义确实要求进行这些类型测试。但对于性能而言,通常只有程序的一小部分至关重要。语言规范并未要求语言实现必须执行每一项检查。在许多情况下,特别是在那些影响性能的内部循环中,实现可以避免不必要的检查。

请看以下代码示例:

(define (example x)
  (* (+ x x) 4))

根据定义,+ 会检查它的两个参数以确保它们是数字。但在这种情况下,一个更智能的实现可以发现我传递了相同的参数两次,因此只需检查其中一个。同样,当我随后将 x 乘以 4 时,我知道 4 是数字,无需检查;我也知道 x 是数字,因为如果它不是,那么前面的加法就无法成功完成。因此,理论上需要四次检查的代码,在实践中编译器可以很容易地将其减少到一次。

除了依赖编译器优化来消除可能损害性能的类型测试外,在动态类型语言中编写代码时,你无需围绕类型系统进行编码,不必进行冗余检查或编写冗余代码,可以直接实现功能而无需调用构造函数等。这是对性能论点的反驳。

代码复用 🔄

接下来我们讨论代码复用。我们都喜欢尽可能地复用代码和库,以避免功能重复并减少工作量。有人认为动态类型在这方面更优,因为没有严格的类型系统限制,你可以更频繁地调用相同的代码。

假设你有一系列处理 cons 单元的库函数。在 ML 中,由于 cons 单元可以用于任何类型的数据(无论其内容是否为同一类型,无论是列表还是其他元组),你将能够更频繁地复用这些库函数。而在静态类型语言中,即使是我们喜欢使用的基本数据结构,如树、列表、数组和哈希表,为了允许复用,最终也会具有非常复杂的静态类型,有时甚至无法满足我们期望的所有复用需求。

以下是动态类型支持者的论点。静态类型的支持者则会辩称,在现代类型系统中,情况并没有那么糟糕。它们支持足够的代码复用,我们可以为列表、集合、树和表等编写可复用的库。诚然,这些库可能具有复杂的类型,但使用它们不应该复杂,而且我们只需要编写一次库。他们还会认为,如果你对一切都使用 cons 单元,可能会引入很多错误,因为很难跟踪哪些东西实际上是哪种类型。没有那些类型错误信息,你可能会将两个逻辑上不同但恰好都用 cons 单元实现的东西混淆。如果你将它们以错误的顺序传递给某个函数,只会得到奇怪的错误信息。因此,他们认为,如果有一个擅长在你做错时指出问题的类型系统,通过程序运行前获得良好的错误信息,实际上更容易复用代码,也更容易使用提供给你的高级库。

原型开发 🛠️

到目前为止,我们已经讨论了五个论点。但这些都是基于传统的软件开发视角,即我们有一个需要实现的规范,我们编写一些代码,可能进行测试,然后软件就完成了。然而,大多数软件的开发并非如此。实际上,在软件开发的早期阶段,通常你甚至不确定规范应该是什么,你正在进行所谓的“原型开发”,在规范不断演变的过程中尝试各种方案。

那么,让我们从原型开发的角度来考虑静态和动态类型。许多人认为动态类型更适合原型开发。论点是,在应用程序开发的早期,你并不确定应该如何表示数据,需要多少种情况,这些是否是我 sum 类型的所有构造器,或者我是否需要添加更多等等。如果你在使用具有严格静态类型的语言进行编程,它不会让你运行代码,直到你写下所有的情况并且所有部分都达成一致,否则你会得到类型错误信息。而动态方法允许你对你已编写的部分进行原型测试,同时完全清楚你稍后需要回来编写其余部分。

在静态类型的世界里,仅仅为了能够运行你的代码,你最终不得不做出许多不成熟的承诺,编写许多你并不确定会保留的代码,甚至编写一些你还没准备好编写的代码,仅仅因为你想运行部分代码。

静态类型的支持者则说,实际上,原型开发阶段是我最欣赏静态类型系统的时候。当我对需要什么类型的数据、有多少种情况等的想法在不断演变时,有什么比拥有一个类型系统来不仅记录,而且检查我的记录更好的方式呢?当我正在演进代码,仍在构思如何安排事物时,那正是我容易犯错的时候,我可能会在需要整数时传递了字符串。我需要类型检查器来帮助我指导这个过程,并给我提供协助。至于为了能够运行代码的其他部分而不得不编写一堆代码,在实践中并不是什么大问题。如果你有一个函数尚未实现,但你的类型检查器需要它,那么只需将其实现为引发一个异常。在像 ML 这样的语言中,一个总是引发异常的函数可以具有任何类型。同样,对于数据类型中未处理的所有情况,很容易添加一个通配符模式,并让该分支引发异常。这可能容易出错,但并不会比动态检查方法更易出错,后者只是隐式地为你做了这些。

维护与演进 🔄

现在,让我们谈谈在软件编写完成、发布之后,你希望为下一个版本进行更改的情况。我们来看一个动态方法确实更方便的例子。

假设我有一个函数,在版本 1 中,给定一个整数时总是返回一个整数,并且它必须接受一个整数。但现在我想让它也能接受一个字符串,在这种情况下它将返回一个字符串。以下是在 Racket 中的示例:

; 版本 1
(define (f x) (* 2 x))

; 版本 2
(define (f x)
  (cond
    [(number? x) (* 2 x)]
    [(string? x) (string-append x x)]))

这在 Racket 中运行良好,其优点是所有以前能工作的调用者现在都不会被破坏。该函数在所有旧用例中仍然完全像以前一样工作,只是提供了额外的功能。这通常被称为向后兼容的更改。而在 ML 中,这不会很好地工作。如果在版本 1 中,你有一个类型为 int -> int 的函数。那么现在,你将需要一个接受某种 sum 类型并返回相同 sum 类型的函数。这将不是向后兼容的。它会破坏你函数的所有现有用户,因为他们现在必须使用一个构造器来调用你的函数,并对结果进行模式匹配以获取底层的整数。

从这个意义上说,动态检查对于软件演进更为方便。

但是,等等,静态类型的支持者会说,实际上,当我去修改程序时,我非常喜欢那个静态类型系统。也许在那个例子中有点烦人,但当我更改代码中某些东西的类型时,类型检查器非常棒,正是因为它告诉了我所有不再通过类型检查的地方。即使在前一张幻灯片的例子中,它也会给我一个列表,打印出所有我调用 f 的地方,我可以直接进去进行那些更改,并且知道当我的程序再次通过类型检查时,我已经完成了所有修改。

事实上,让我们举一个稍微不同的例子,我们最终改变了某个函数的返回类型。如果我仅仅改变了某个函数的返回类型,Racket 代码会继续顺利编译,而我必须有足够的测试来找出所有调用该函数的地方,并捕获所有运行时错误,因为我对结果做出了错误的假设。而在 ML 中,类型检查器给了我需要的待办事项清单。

ML 中的另一个例子是,假设你有一个数据类型,你需要向其中添加一个新的构造器。你为你的算术表达式语言忘记了一种算术表达式。如果你善于在所有地方使用模式匹配,并且不为最后一种情况使用通配符模式,你将在需要的地方得到关于非穷尽模式匹配的警告,并且你不必担心在添加那个构造器时忘记更改代码的其他部分。

这一切都非常有用。动态类型的支持者会争辩说,那个待办事项清单虽然友好灵活,但却是强制性的,这有点烦人。如果你想先完成一半的待办事项,然后运行一些测试,再完成另一半呢?ML 不喜欢这样,它希望你的整个程序都通过类型检查。但至少它给了你一份在继续之前必须完成的待办事项清单。

总结与思考 💡

至此,我已经给出了七个支持静态检查与动态检查的论点,并尝试尽可能公平地呈现双方观点。

我认为“静态检查总是更好”或“动态检查总是更好”这样的问题过于笼统,没有太大意义。一个更好的问题可能是:对于我正在编写的这个软件,我想静态检查什么(同时处理不可避免的误报),以及我不想静态检查什么(我愿意依赖测试和实际运行软件来检测那些问题)。

在回答这个问题时,存在合理的权衡。我已经花了两个视频的时间,试图给出基于理性、事实的理由和例子,而不是基于编程语言真实概念之外的个人观点。既然双方都有论点,也许理想的情况是语言能同时支持两者,你不必在 ML 和 Racket 之间做出选择。事实上,Racket 正试图通过其 Typed Racket 变体来实现这一点,其中一些文件可以使用类型,而另一些则不能。但我想说,在许多方面,试图提供两全其美的方案仍然是一个开放的研究问题。在设计支持两者的语言时,有些事情并不容易做到。而且,即使两者都提供,也不清楚我们是否真的让问题变得更容易了。程序员在编写库或使用库时,仍然需要决定这是否是我希望进行类型检查以指导我并及早发现错误的事情,还是我想延迟检查的事情。不清楚应该由谁来做这个决定,或者应该使用什么确切的设计标准。

这就是为什么我喜欢将静态与动态类型视为一个经典问题,带有一套经典的权衡,你应该对此有充分的了解,以便在出现的每种情况下都能尝试做出最佳决策。

本节课中,我们一起学习了静态类型与动态类型在性能、代码复用、原型开发以及软件维护与演进方面的对比和权衡。理解这些核心差异和适用场景,将帮助你在未来的编程实践中根据具体需求做出更明智的选择。

139:可选内容 - Eval 与 Quote 😊

在本节课中,我们将要学习 Racket 语言中的 evalquote 这两个概念。eval 允许我们在程序运行时构建并执行代码,而 quote 则提供了一种便捷的方式来构建表示代码的数据结构。我们将通过简单的例子来理解它们的基本用法和工作原理。

什么是 Eval?🤔

eval 是 Racket 以及许多其他编程语言中存在的一个有趣的语言构造。它的核心思想是:在程序运行时,我们可以构建一些数据(例如列表),然后将这些数据视为一个程序并执行它。

在 Racket 中,我们构建的数据通常是包含数字、符号和嵌套列表的列表。eval 函数的作用就是接收这种表示程序语法结构的数据,并将其作为 Racket 代码来运行。这意味着,我们可以在一个 Racket 程序的运行过程中,动态地构建并执行另一个 Racket 程序。

Eval 的实现与“解释型语言”的误解 🛠️

由于程序在运行前无法预知会构建什么样的数据传递给 eval,因此运行时环境中必须保留完整的 Racket 实现。如果 Racket 是用解释器实现的,那么 eval 可以直接使用这个解释器来执行代码。如果 Racket 是用编译器实现的,那么程序就需要附带足够的编译和运行能力。

这导致很多人认为,拥有 eval 功能的语言必须用解释器实现,并称之为“解释型语言”。这种观点有一定道理,但从技术上讲并不完全正确。Racket 虽然因为拥有 eval 而有时被称为解释型语言,但 eval 只是一个可选的特性,其实现并不必然依赖于解释器。

如何使用 Eval?💻

现在让我们看看 eval 的实际用法。我们将通过一个简单的例子来演示。

以下是构建代码数据的函数:

(define (make-some-code-1 y)
  (if y
      (list 'begin (list 'print "hi") (list '+ 4 2))
      (list 'begin (list 'print "bye") (list '+ 5 3))))

这个函数根据参数 y 的真假,返回不同的列表。这些列表看起来就像 Racket 代码。

例如,调用 (make-some-code-1 #t) 会返回列表 (begin (print "hi") (+ 4 2))。这只是一个普通的数据列表。

我们可以使用 eval 来执行这个列表所代表的代码:

(define f (make-some-code-1 #t)) ; f 现在是数据
(eval f) ; 这会打印 "hi",并返回值 6

eval 将列表 f 作为程序执行:先执行 (print "hi") 打印字符串,然后计算 (+ 4 2) 得到结果 6。

我们也可以只执行列表中的一部分:

(eval (cadr f)) ; 这会执行 (print "hi"),只打印 "hi"

但是,如果尝试执行一个不完整的表达式(如 (eval (car f))'begin),则会出错,因为它不是一个合法的程序。

使用 Quote 简化代码构建 ✨

上一节我们看到了如何用 list'(引号)手动构建列表。但像 (list 'begin (list 'print "hi") ...) 这样的写法非常繁琐。

为此,Racket 提供了 quote 这个特殊形式。quote 会将其后的所有内容视为字面的列表和符号,而不是要执行的代码。

比较以下两种写法:

; 繁琐的写法
(list 'begin (list 'print "hi") (list '+ 4 2))

; 使用 quote 的简洁写法
'(begin (print "hi") (+ 4 2))

quote(通常简写为 ')使得编写待 eval 执行的代码变得非常方便。从数学意义上说,quoteeval 是互逆的操作。

quote 的局限性在于,它内部不能进行任何计算。所有内容都会原封不动地变成数据结构的一部分。

Quasiquote 与 Unquote 🔄

如果我们需要在构建代码时嵌入一些动态计算的结果,就需要用到 quasiquote(通常简写为 `)和 unquote(通常简写为 ,)。

quasiquote 类似于 quote,但它允许我们用 unquote 标记出其中需要立即求值的部分。

例如:

(define x 10)
`(begin (print "value is: ") (+ ,x 5)) ; 这会构建列表 (begin (print "value is: ") (+ 10 5))

这里,,x 会在构建列表时被求值为 10,然后结果 10 被放入列表的相应位置。这样,我们就能动态地将变量的值嵌入到代码结构中。

与其他语言的对比 🌍

最后,我们来对比一下 Racket 的 eval 与大多数脚本语言(如 Python、Perl)中 eval 的区别。

在脚本语言中,eval 通常接收一个字符串,字符串的内容是具体的程序代码。eval 需要先解析这个字符串,然后再执行它。

Racket 的方法(接收列表)和脚本语言的方法(接收字符串)各有优劣:

  • 字符串形式可能更方便直接键入代码。
  • 列表形式(得益于 Racket 具体语法和抽象语法的相似性)更便于组合和操作代码片段,因为列表是 Racket 中的一等公民,可以轻松地使用函数进行构建和变换。

脚本语言中也存在类似于 quasiquoteunquote 的概念,例如在字符串中嵌入表达式求值的结果(常称为“字符串插值”)。这体现了同一种思想在不同语言环境下的应用。

总结 📚

本节课我们一起学习了 Racket 中 evalquote 的核心概念。

  • eval 允许程序在运行时将数据结构作为代码执行,实现了元编程能力。
  • quote 提供了一种简洁的语法来构建表示代码的列表数据。
  • quasiquoteunquote 则进一步允许在构建代码时嵌入动态计算的值。

虽然 eval 功能强大,但在实际编程中需要谨慎使用,因为它可能带来安全性和复杂性上的挑战。理解这些概念有助于我们更深入地认识编程语言的表达能力与实现机制。

140:B部分总结与C部分预览

在本视频中,我们将总结B部分的学习内容,并预览C部分将要探讨的主题。我们将回顾已学的函数式编程核心概念,并了解即将学习的面向对象编程及其与函数式编程的对比。

B部分内容回顾

上一节我们介绍了函数式编程的基础,本节中我们来看看B部分涵盖的核心内容。

以下是B部分的主要学习成果:

  • 我们学习了在静态类型语言中进行函数式编程的基础,包括模式匹配和函数闭包。
  • 我们探讨了ML语言特有的模块化和类型推断机制。
  • 我们深入研究了延迟求值,并实现了流(Streams)等结构。
  • 我们掌握了如何编写解释器,甚至实现了一个包含函数闭包的自定义语言。
  • 我们讨论了静态类型与动态类型系统之间的区别。

进入C部分

至此,我们已经完成了B部分的学习。接下来,我们将用几分钟时间说明C部分如何帮助我们完善整个知识体系。

C部分包含第7和第8两个模块,它们将继续聚焦于像Racket这样的动态类型语言,但重点转向与面向对象编程相关的问题,并将其与函数式编程进行比较和对比。

C部分内容预览

在C部分,我们将学习以下内容:

  • 第7模块:我们将从Ruby语言的基础开始,学习面向对象编程的基本概念。此外,我们还会探讨Ruby的一些特有功能,例如其非常接近闭包特性的实现方式、灵活处理数组的方法等。
  • 第8模块:许多概念将在此融会贯通。我们将学习到,面向对象风格和函数式风格在分解大型问题的方式上截然相反,以至于它们成为了审视同一事物的两种优雅但视角对立的方法。我们还将探讨一些更高级的面向对象编程主题,如混入(Mixins)和双重分派(Double Dispatch)。最后,我们将回到静态类型,比较泛型(Generics)与子类型(Subtyping)的区别,从而更全面地理解灵活的类型系统。

为何继续学习C部分

我希望你能继续学习C部分,以完成本课程的核心叙事。有些已有丰富面向对象编程经验的学员可能会认为,C部分对自己的提升有限。我理解这种观点,但C部分的内容可能与你预期的有所不同。

首先,我们将以一种你前所未见的方式来探讨面向对象编程,这种方式与我们研究函数式编程的方法深度类比。通过清晰定义每个组件的含义,以循序渐进的方式学习,同样能为你理解对象、类和方法等已有概念打下更坚实的基础。具体来说,我们将逐步构建一个精确的定义,来解释在对象上调用方法时如何确定要执行的代码,并揭示这实际上比函数闭包相关的规则更为复杂。

其次,关于C部分的两个模块,许多人可能会发现第二个模块比第一个更有吸引力。第一个模块需要自成体系,以确保即使没有接触过对象的学员也能理解。如果你已经熟悉Ruby或类似语言,这部分内容可能显得陈旧。该部分的作业也略有不同,主要是对已有代码进行小幅修改,这种体验更接近工业界或开源项目中的编程实践。

最后,本课程虽然侧重函数式编程,但并非反对面向对象编程。在C部分,我们将给予面向对象编程应有的地位,但同时也会关注一些函数式风格更具优势、或面向对象视角略显笨拙的场景。这样做能让我们通过对比,更深入地理解函数式编程。

C部分将揭示的函数式编程关联

以下是我们在C部分将会看到的、与函数式编程深刻关联的内容:

  • 在Racket中实现OOP:我们将看到如何仅用目前已学的Racket知识,以某种巧妙的方式自行实现面向对象编程。理解这一点能以一种引人入胜的方式解释OOP。
  • FP与OOP的问题分解对比:正如之前提到的,我们将深入思考函数式编程(FP)与面向对象编程(OOP)如何以完全相反的方式分解问题,以及这如何使它们相似多于不同。
  • 课程最终作业(Homework 7):这将是另一个具有挑战性的作业。我们将把一些用ML编写的代码移植到Ruby。但这不是简单的直译,而是要将代码风格彻底转换为完全面向对象的方式。我们将移植的程序是另一个解释器,这次是针对一个几何语言,这将让你看到解释器的概念在常规编程语言之外的应用价值。
  • 回顾类型系统:最后,我们将回顾ML中的类型变量(例如 mapfilter 函数类型中的 'a),并将其与Java、C#等语言中类似的泛型机制,以及非常不同的子类型机制进行比较。从而回到静态类型系统,并理解它们与FP和OOP的关联。

总结

本节课中我们一起学习了B部分的核心总结,并对C部分的内容进行了全面预览。恭喜你完成了课程B部分的大量工作,取得了巨大的进步。我们很快将在C部分的开始再见。

141:欢迎来到C部分 🎉

在本节课中,我们将开始学习编程语言课程的C部分。我们将介绍本部分的学习目标、主要内容结构,以及如何为后续学习做好准备。


概述

编程语言的A部分和B部分已经涵盖了大量内容。C部分将在此基础上,引入一些关键概念,并与之前学过的内容进行对比。本部分将主要使用Ruby作为动态类型、面向对象语言的示例,并与ML和Racket进行关键点的比较。


课程前提与准备

上一节我们介绍了本部分的学习目标,本节中我们来看看学习前的准备工作。

本课程是A部分和B部分的延续。我们将默认学员已掌握A部分和B部分的内容。如果距离完成前两部分课程已有一段时间,建议在必要时回顾之前的内容以巩固基础。

本课程旨在学习编程语言的核心概念,具有一定的挑战性。课程的每个部分都使用了不同的编程语言,这并非偶然。在C部分,我们将主要使用Ruby。

以下是开始学习前需要完成的准备工作:

  • 安装Ruby编程语言。
  • 配置好代码编辑环境。
  • 准备好跟随课程进行文件编辑和编程实践。

尽管不同操作系统和Ruby版本的安装说明可能有所不同,但我们希望这个过程不会太困难。


本部分内容安排

在做好了学习准备之后,本节中我们来看看C部分的具体学习路线。

我们将首先学习Ruby的基础知识及其一些有趣特性,重点聚焦于面向对象编程。然后,我们将对比函数式编程与面向对象编程。接着,我们会学习一些更高级的面向对象特性和设计模式,探索基于类的面向对象编程之外的内容。在课程的最后一周,我们将回归静态类型,学习子类型这一在面向对象环境中非常强大的静态类型概念,并将其与ML中见过的多态类型(泛型)进行对比。

与B部分一样,我们将直接开始深入的学习。


总结

本节课中我们一起学习了编程语言C部分的介绍。我们了解了本部分是前两部分的延续,主要使用Ruby来学习面向对象编程的核心思想,并会与之前学过的函数式编程语言进行对比。接下来,请准备好你的开发环境,让我们开始探索Ruby和面向对象编程的世界。

142:概念概述 🧭

在本节课中,我们将简要回顾A部分和B部分已涵盖的内容,并了解它们与C部分即将学习的内容有何关联。

A部分与B部分回顾 📚

上一节我们介绍了A部分和B部分的核心内容。本节中,我们来看看如何将它们浓缩为几个关键点。

在A部分,我们学习了函数式编程的基础知识。我们学习了静态类型和ML语言,掌握了模式匹配。我们学习了一等函数闭包,以及类型推断、模块等概念,并理解了程序等价性的思想。

(* 示例:ML中的函数定义与模式匹配 *)
fun factorial 0 = 1
  | factorial n = n * factorial (n-1)

在B部分,我们通过使用Racket语言补充了ML的经验。Racket是一种动态类型的函数式语言。除了动态类型的基础知识及其与静态类型的关系外,我们还研究了一些关键范式,例如延迟求值,以及通过解释器函数实现我们自己的语言。我们在B部分中期重点探讨了Eval X函数。

; 示例:Racket中的延迟求值
(define (my-if condition then-clause else-clause)
  (if condition
      (then-clause)
      (else-clause)))

总而言之,这为你奠定了函数式编程的背景。你获得了使用静态类型系统和不使用静态类型系统的关键经验,并熟悉了函数式编程社区中众所周知的数据结构、递归和解释器。

过渡到面向对象编程 🔄

现在,当我们过渡到面向对象编程时,需要学习很多新知识,但我们将利用之前的经验。

在学习Ruby基础知识时,我们会发现许多与ML和Racket并无太大差异的地方。事实证明,Ruby有一种称为“块”的结构,几乎类似于闭包。我们还将看到Ruby是一种非常动态的语言,它的一些动态特性可能比Racket看待事物的方式更加动态。

然后,我们将重点放在面向对象的部分:类和方法。在Ruby程序中,数据总是封装在对象的概念中,对象具有状态和可以调用的方法。因此,我们将使用方法而不是函数。

如果你以前接触过面向对象编程,可能会对C部分的这第一节内容感到不那么兴奋;如果没有,也没关系,所有内容都应该是自包含的。但我们需要通览所有内容,以打下基础。实际上,我们将看到Ruby是一种非常纯粹的面向对象语言,正如我们将在课程第一周解释的那样:一切皆对象

# 示例:Ruby中的类与对象
class Greeter
  def initialize(name)
    @name = name
  end

  def greet
    puts "Hello, #{@name}!"
  end
end

greeter = Greeter.new("World")
greeter.greet # 输出:Hello, World!

C部分后续内容展望 🗺️

这将为C部分的第二部分做好准备。届时,我们将处于有利位置,能够理解如何使用面向对象编程将问题分解为多个部分,以及如何使用函数式编程将问题分解为多个部分。

我们将看到,这些编程方法完全相反,但正因如此,它们实际上并没有太大不同。这将是本课程的一个关键“顿悟”时刻。

然后,我们将在此基础上探讨一些相关的高级主题,例如Ruby的混入方法(它称之为模块),以及面向对象编程中称为双重分派的编程范式。这将是C部分第二周编程作业的重点。

事实证明,正如我们将在下一个视频中更详细地了解的那样,第二周的视频内容较少,但具有挑战性的家庭作业较多。因为这是课程中最后一个编程作业,我希望将许多想法融合在一起。

在最后一周,我们将回到静态类型领域。我们将重点介绍子类型的关键概念,这对于面向对象语言的静态类型非常重要,并将其与我们在ML中看到的泛型多态性进行对比。

总结 ✨

本节课中,我们一起回顾了A部分和B部分的核心内容,并概述了C部分的学习路线。我们有很多内容要学习,有很多关键机会可以将概念融合并进行对比。

因此,即使你觉得第一周的内容比较基础,只是专注于让你熟悉Ruby和对象,请放心,在接下来的两周里,我们将利用这个基础,将许多部分整合在一起,并以连贯的方式总结课程,比较和对比关键思想。

143:课程结构详解 🧭

在本节课中,我们将详细解析C部分的课程结构,并指出它与A、B部分的一些关键差异。

概述

C部分课程将持续三周,包含两次编程作业和一次期末考试。整体结构与之前相似,但在作业形式、技术细节和内容侧重上有所不同。

第一周:熟悉与适应 🔄

上一节我们介绍了C部分的整体框架,本节中我们来看看第一周的具体安排。

第一周的内容看起来会与你之前经历的类似,因为这是课程的第一周,你需要完成一些软件安装工作。

随后,你将照常观看教学视频,并完成一次包含自动评分和同伴互评环节的编程作业。但有两个差异值得注意:

以下是第一周作业的两个主要特点:

  1. 作业风格截然不同:我们主要会提供给你几乎所有的代码。本次作业的智力挑战在于理解这些代码,然后在不改变我们已提供代码的前提下,通过添加额外代码来修改它。作业说明中会详细解释这一点,但我想提前指出,它的感觉会与之前的作业大不相同,在之前的作业中,你主要是基于少量提供的代码,然后自己编写整个程序。
  2. Ruby版本支持:对于Ruby作业,在不同的操作系统上使用不同版本的Ruby,其难易程度相对不同。我们希望支持你使用稍有不同的Ruby版本,这不会影响作业的智力内容。但我们希望自动评分器使用与你相同的版本,以避免出现我们的评分代码与你的代码预期不一致的任何问题。因此,当你提交作业时,会看到版本选择,这可能会带来一点困惑,但希望说明足够清晰,我们可以澄清任何仍然令人困惑的地方。

第二周:核心挑战 🧠

了解了第一周的安排后,接下来我们进入第二周。这一周的视频内容会少很多。

我们曾经将第二周和第三周的内容合并,后来在逻辑合理的地方将其一分为二。尽管视频相对较少,我们仍会引入一些非常关键的概念,这些概念将成为一次更具挑战性的家庭作业的重点。

对于那次编程作业,除了第一周就有的多版本Ruby支持外,还有一个不同点:在该作业中,你需要提交ML代码和Ruby代码以供自动评分。

该作业背后的理念是,将一些ML代码移植到Ruby,并采用面向对象的风格。因此,你需要提交两个不同的文件进行自动评分。然后,在进行同伴互评时,为了简便起见,我们只对Ruby部分进行互评。当你进行到那一步时,也会看到这些操作细节。

第三周:总结与考核 📝

在完成了前两周的学习和作业后,第三周我们将学习剩余的内容。

第三周包含剩余的教学内容,正如我所讨论的,主要是关于子类型化以及比较泛型的子类型化。本周没有编程作业,因为第二周的编程作业足以完成相关练习。

然而,在第三周,我们将进行期末考试。这次考试不仅涵盖当周的材料,实际上将重点考察B部分和C部分的概念。这两个部分都相对较短,大约各有三周左右的材料。就像我们在A部分结束时有一次考试一样,现在我们将对A部分之后的内容进行另一次考试。和往常一样,当你参加考试时,我会提供所有说明、一份模拟考试以及关于该考试的更多信息。

总结

本节课中我们一起学习了C部分“课程结构”的详细安排。

这就是C部分的内容:三件大事,两次编程作业,然后是一次考试。之后,我们就完成了《编程语言》这门课程的学习。

144:Ruby语言介绍 🚀

在本节课中,我们将开始使用Ruby编程语言来学习编程语言的相关概念。本节内容将混合介绍Ruby的实用信息,并阐述我们选择Ruby作为本阶段学习工具的原因。

概述

我们将首先了解Ruby的基本情况、安装方式以及版本选择。接着,我们会探讨Ruby作为一门纯面向对象、动态类型的语言,为何适合用于研究编程语言的核心概念。最后,我们会预览本阶段的作业形式,并运行一个简单的Ruby程序。

Ruby的安装与工具

首先,Ruby的主网站是 RubyLang.org。具体的安装指南可在课程网站上找到,其中针对不同操作系统提供了建议。在课程视频中,我将使用EMX编辑器编写Ruby程序,并鼓励你也这样做。当然,你可以使用任何你喜欢的编辑器,许多编辑器都对Ruby有良好的支持。

关于Ruby版本,需要说明的是,我们所学的核心概念并不依赖于某个特定的Ruby版本。语言本身相当稳定。然而,为了确保在不同操作系统上都能方便安装,课程会支持一个版本范围。软件安装信息中会列出当前支持的版本。请放心,作业代码在任何一个较新的Ruby版本上应该都能正常运行。

为何选择学习Ruby?

上一节我们介绍了Ruby的安装,本节中我们来看看选择Ruby作为学习工具的主要原因。

Ruby是一门纯面向对象的语言。这意味着语言中的所有值都是对象,没有例外。这有助于我们深入研究面向对象编程。

它也是基于类的语言。我们通过定义类来创建对象,每个对象的行为由其所属的类决定。如果你熟悉Java、C#或C++,这个概念是相似的。

Ruby拥有一项称为混入的优秀特性。它有点像Java的接口,也有点像C++的多重继承,但克服了它们各自的一些限制。这将是一个有趣的学习点。

与之前学习的Racket一样,Ruby是一门动态类型语言。这有助于我们在学习面向对象编程时,不被类型系统所干扰。

此外,Ruby还具备其他值得研究的特性,例如便捷的反射支持、动态性、类似ML和Racket的闭包,以及它作为一门脚本语言的便利性。

当然,Ruby还有许多其他流行特性(如强大的字符串处理、Ruby on Rails框架等),但本课程将聚焦于能帮助我们理解编程语言核心概念的那个子集。

本阶段作业预览

在介绍了Ruby的特性之后,我们来看看本阶段的作业有何不同。

本次作业的形式比较特殊。我们将提供一个已经完整实现、约几百行的Ruby程序。你的任务是在不修改原有代码的基础上,对其进行各种功能扩展。这种方式能很好地锻炼阅读和理解现有代码的能力,这是在软件开发中非常常见的任务,也是学习新语言的好方法。

该程序是图形化的,因此安装指南中包含了配置TK图形库的步骤。你不需要深入理解TK,只需确保它能正常运行即可。

第一个Ruby程序

了解了作业形式后,让我们通过一个简单的程序来初步感受Ruby。

以下是一个基础的Ruby程序示例。注释以 # 开头。代码通常组织在类中。puts 是一个内置方法,用于输出字符串。

# 定义一个类
class Hello
  # 定义一个方法(类似于函数)
  def my_first_method
    puts "Hello World"
  end
end

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/uwash-cse341-prog-abc/img/7e5c5bf955ab21f7a69e0879703a340c_1.png)

# 创建类的实例并调用方法
x = Hello.new
x.my_first_method

运行这个程序,你将在终端看到输出 Hello World

Ruby也提供了交互式环境(REPL),称为 irb。你可以在其中直接执行Ruby代码,例如计算 3 + 4,或者使用 load 命令加载并执行Ruby文件。

总结

本节课中,我们一起学习了Ruby编程语言的入门知识。我们了解了其安装与工具选择,探讨了它作为纯面向对象、动态类型语言的特点及其在本课程中的适用性。我们还预览了以阅读和扩展现有代码为核心的作业形式,并运行了第一个Ruby程序。在接下来的章节中,我们将深入Ruby语言的细节。

145:类和对象 🧱

在本节课中,我们将要学习Ruby语言的核心概念:类和对象。理解它们是掌握Ruby的关键。我们将从基本规则开始,然后通过编写代码来展示它们是如何工作的。

概述:Ruby的核心规则

Ruby语言遵循几个核心规则。首先,所有值,即任何表达式计算的结果,都是一个指向对象的引用。其次,对象之间通过调用方法进行通信。方法类似于函数,但属于特定对象。有时我们也说向对象“发送消息”,这与调用其方法是同一回事。

每个对象都拥有自己的私有状态(我们将在下一个视频中详细展示)。此外,每个对象都有一个,类决定了对象的行为。类包含了定义对象方法的重要代码。

如果你接触过其他面向对象编程语言,这些概念可能看起来很相似。但Ruby在这方面更为纯粹:所有东西都是对象,对象只拥有私有状态,而行为则由其类定义。

定义类

首先,我们需要学习如何定义类,因为我们将使用类来创建对象。以下是定义类的基本语法:

class ClassName
  def method_name
    # 方法体
  end
end

关键字 class 后跟类名(必须以大写字母开头),然后是方法定义。方法定义以 def 开头,后跟方法名和可选的参数列表。

让我们来看一个具体的例子。

示例:创建类A

我们创建一个名为 A 的类,并为其定义两个方法。

class A
  def m1
    34
  end

  def m2(x, y)
    z = 7
    if x > y
      false
    else
      x + y * z
    end
  end
end

m1 方法中,我们直接返回数字 34。在 m2 方法中,我们定义了一个局部变量 z,并使用了条件语句。注意,在Ruby中,换行符有时具有语法意义。在这个 if-else 语句中,分支需要放在单独的行上。

现在,我们可以使用这个类来创建对象并调用方法。

a = A.new
puts a.m1        # 输出: 34
puts a.m2(3, 4)  # 输出: 31

调用 A.new 会创建一个属于类 A 的新对象。然后,我们可以使用点符号 . 来调用该对象的方法。例如,a.m2(3, 4) 会执行 m2 方法,由于 3 不大于 4,所以执行 else 分支,计算 3 + 4 * 7,结果为 31

示例:创建类B与理解 self

接下来,我们创建另一个类 B,并引入一个特殊的关键字:self

class B
  def m1
    4
  end

  def m3(x)
    x.abs * 2 + self.m1
  end
end

m3 方法中,我们调用了参数 xabs 方法(求绝对值),然后乘以2,最后加上 self.m1 的结果。

self 是一个特殊的关键字,它总是指向当前正在执行其方法的那个对象。因此,self.m1 意味着调用当前对象自身的 m1 方法。

让我们测试一下:

b = B.new
puts b.m1        # 输出: 4
puts b.m3(5)     # 输出: 14

计算过程:5.abs55 * 2 = 10self.m1 返回 4,所以 10 + 4 = 14

需要注意的是,类 B 的对象没有 m2 方法,类 A 的对象也没有 m3 方法。每个对象的行为完全由其类定义的方法决定。

语法细节回顾

上一节我们介绍了类和对象的基本用法,本节我们来回顾并补充一些重要的语法细节。

以下是关于方法调用和变量的关键点:

  • 方法调用:表达式 e.m 会先计算表达式 e 得到一个对象,然后调用该对象的 m 方法。
  • 参数括号:对于无参数的方法,括号是可选的(例如 a.m1a.m1())。对于带参数的方法,通常需要用括号将参数括起来,并用逗号分隔。虽然有时括号也可省略,但为了清晰,建议始终使用。
  • 局部变量:方法内部可以定义局部变量(如 z = 7)。变量名以小写字母开头。Ruby中的变量不需要预先声明,在赋值时即被创建,其作用域为整个方法体。变量是可变的,它们存储的是对某个对象的引用。

方法链与 self 的返回值

self 不仅可以用来调用方法,其本身作为一个表达式,就代表当前对象。利用这一点,我们可以实现一种称为“方法链”的常见编程风格。

考虑下面的类 C

class C
  def m1
    print “hi “
    self
  end

  def m2
    print “bye “
    self
  end

  def m3
    print “\n”
    self
  end
end

注意,每个方法在执行打印操作后,最后一行都返回 self(即对象自身)。这意味着方法调用后返回的还是同一个对象,因此可以在其基础上继续调用其他方法。

c = C.new
c.m1.m2.m3.m1.m1.m3
# 输出:
# hi bye
# hi hi

这并非特殊的语言特性,仅仅是利用了方法的返回值。因为 c.m1 返回 c 自身,所以我们可以紧接着调用 c.m2,依此类推。这种链式调用在Ruby中是一种常见且优雅的写法。

关于代码格式的说明

在结束本章之前,有两点关于代码格式的说明:

  1. 分号:Ruby语句之间的分号 ; 通常是可选的。当表达式位于不同行时,换行符本身就足以分隔它们。如果要将多个表达式放在同一行,则需要用分号分隔。
  2. 缩进:与某些语言不同,Ruby中的缩进纯粹是为了代码美观和可读性,不会影响程序的语义。你可以自由选择缩进风格。

总结

本节课中,我们一起学习了Ruby中类和对象的基础知识。

我们了解到,是用于定义对象行为的蓝图,通过 def 关键字在其中定义方法。使用 ClassName.new 可以创建该类的一个新对象。对象通过调用方法(使用 . 操作符)来执行操作。

我们认识了特殊关键字 self,它代表当前对象,可用于调用对象自身的其他方法(self.method_name),并且 self. 前缀通常可以省略。我们还看到了如何通过让方法返回 self 来实现流畅的方法链调用。

记住,在Ruby中,一切皆对象,对象的行为由其类决定。这是理解后续更高级Ruby概念的重要基石。

146:对象状态 🧠

在本节课中,我们将学习对象如何拥有自己的状态。我们将探讨什么是对象状态、如何读写状态、以及状态在对象生命周期中的持久性。同时,我们也会了解与对象状态相关的其他概念,如类变量和类方法。


对象状态简介

上一节我们介绍了对象和类的基本概念,本节中我们来看看对象如何拥有自己的状态。

对象的状态从对象被创建时开始存在。每次调用对象的方法时,你都可以使用、修改或添加这个状态。这个状态在对象的整个生命周期中持续存在。重要的是,这个状态只能通过调用对象的方法来访问,它对对象是私有的,只有对象的方法才能访问它。


实例变量:对象状态的载体

理解了背景知识后,我们来详细了解如何实际读写状态。

状态本质上是一个变量的集合,我们称之为实例变量。在许多面向对象编程语言中,这被称为字段。如果你接触过Java、C#或C++,这里讨论的就是字段。

创建一个字段的方法很简单:只需给一个以 @ 字符开头的变量赋值。例如,如果对象的某个方法执行 @foo = 34,那么这个对象的 @foo 实例变量就变成了34。之后,在同一个方法中,或者在后续对同一对象的不同方法调用中,我们可以读取 @foo 并得到34。

核心概念

@foo = 34  # 创建并赋值给实例变量 @foo

我们通过赋值来创建实例变量,无需预先声明它们。但需要小心,因为如果你本意是写 @food 却写成了 @foo,你就创建了一个完全不同的实例变量。你可以拥有任意数量的实例变量,它们在你使用的那一刻就诞生了。


未初始化状态与 Nil 对象

实际上,如果你读取一个从未为该对象赋值过的实例变量,你可能会以为会得到一个“访问未定义实例变量”的错误。但在Ruby中,你会得到一个特殊的对象,称为 Nil 对象。它大致类似于其他语言中的 null,但它本身也是一个对象,我们将在未来详细讨论它。


可变状态与别名

既然不同的对象拥有不同的可变状态,一旦涉及状态修改,我们就必须关注别名问题。在任何编程语言中,只要有可变性,别名就很重要。

以下是Ruby中的别名规则:

  • 当你使用 new 创建一个对象时,你会得到一个全新的、与之前所有对象都不同的对象,并且拥有不同的状态。它不与任何其他对象互为别名,并且在初始化之前没有任何初始状态。
  • 当你将一个变量赋值给另一个变量时,例如 x = y,这就会创建别名。因为 y 持有对某个对象的引用,执行 x = y 后,xy 现在都持有对同一个对象的引用。它们都可以调用该对象的方法,并且一个方法设置或改变的对象的任何私有状态,都可以被该对象的任何其他方法看到,因为 xy 引用的是同一个对象。换句话说,它们互为别名。

实践:定义一个类

让我们定义一个类来实际看看这一切是如何运作的。这里我们先用一些简单的代码来演示,稍后会展示一个更有用的真实例子。

以下是一个类 A 的定义:

class A
  def m1
    @foo = 0
  end

  def m2(x)
    @foo += x
  end

  def foo
    @foo
  end
end
  • m1 方法:调用时,将实例变量 @foo 赋值为0。如果对象已有 @foo,则将其改为0;否则,创建并初始化为0。
  • m2 方法:接受一个参数 x,将 @foo 的当前值加上 x。这里使用了语法糖 +=,其效果与 @foo = @foo + x 相同。
  • foo 方法:简单地查找并返回实例变量 @foo 的值。因为它是方法中的最后一个表达式,所以方法会返回它。

现在,让我们在交互环境(IRB)中加载这个文件并尝试这些方法。

以下是操作示例及其结果:

  1. x = A.newy = A.new 创建了两个不同的对象。
  2. z = x 使得 z 成为 x 的别名,它们指向同一个对象。
  3. 初始时,调用 x.foo 返回 nil,因为 @foo 尚未被赋值。
  4. 调用 x.m2(3) 会导致错误,因为尝试对 nil(未初始化的 @foo)执行 + 操作。
  5. 调用 x.m1@foo 设置为0。
  6. 由于 zx 的别名,调用 z.foo 现在返回0。
  7. y.foo 仍然返回 nil,因为它是另一个独立的对象。
  8. 调用 z.m2(17) 然后 x.m2(14),最后 z.foo 返回31(0+17+14)。
  9. 再次调用 x.m1 会将 @foo 重置为0。
  10. y 调用 y.m1y.m2(7),然后 y.foo 返回7。

这就是实例变量的基本思想:每个对象拥有自己独立的状态。


初始化方法 initialize

现在,让我们扩展一下对实例变量的理解。首先,一个类当然可以有多个实例变量。

但更重要的是,Ruby中有一个具有特殊地位的方法:initialize 方法。它的行为与其他方法类似,但会在创建对象时被自动调用。

我们可以改进之前的类 A,添加一个 initialize 方法:

class A
  def initialize(f=0)
    @foo = f
  end
  # ... 其他 m2, foo 方法保持不变
end

这个方法接受一个参数 f(并提供了默认值0),并用它来初始化 @foo。这是一种更好的风格,因为它确保了对象在创建时就有初始状态,避免了 m2 方法因 @foonil 而报错,或者 foo 方法返回 nil 的情况。

它是如何工作的
在Ruby中,你几乎从不直接调用 initialize。当你调用 A.new 时,如果类 A 定义了 initialize 方法,Ruby会在对象返回之前自动调用它,并将传递给 new 的任何参数都传递给 initialize

例如:

  • q = A.new 会创建一个 @foo 为0的对象(使用了默认参数)。
  • w = A.new(19) 会创建一个 @foo 为19的对象。
  • 随后调用 w.m2(7),再调用 w.foo 将返回26。

initialize 方法因其调用方式而特殊,它非常适合用于建立对象的不变式(即对象必须始终满足的条件)。通常,良好的编程风格是在 initialize 方法中创建并初始化所有实例变量。这是一种约定,在其他语言中,类似的方法常被称为构造函数。但在Ruby中,这只是约定,实际上同一个类的不同实例(虽然不推荐)可以拥有不同的实例变量集合。


类变量、常量与方法

为了与实例变量形成对比,我想向你展示之前未提及的、与类相关的几个概念。

类变量

  • 它们不由每个对象单独拥有,而是由某个特定类的所有实例共享
  • 不同的类拥有不同的类变量。
  • 语法上使用双 @ 符号,例如 @@foo
  • 坦白说,它们并不特别常用,但为了完整性在此介绍。

类常量

  • 在类中定义的、以大写字母开头的标识符。
  • 你不应该修改它们(在当前Ruby版本中修改会收到警告)。
  • 它们可以在类内或类外使用,实际上是公开的(而类变量和实例变量是私有的)。
  • 在类 C 外部,你可以通过 C::FOO 来访问类常量 FOO

类方法

  • 在Java中被称为“静态方法”。
  • 定义语法是在方法名前加上 self.(此处不深入解释原因)。
  • 调用类方法时,不是对某个对象调用,而是直接使用类名:ClassName.method_name(arguments)
  • 类方法不能访问类的任何实例的实例变量,因为它们与任何特定对象分离。它们更像是辅助函数,可以使用类常量和类变量,但不能使用类 C 的特定对象的任何状态。

核心概念

class C
  @@bar = 0                    # 类变量
  DEFAULT_VALUE = 100         # 类常量

  def self.reset_bar          # 类方法定义
    @@bar = 0
  end

  def m2(x)
    @foo = x
    @@bar += 1                # 修改共享的类变量
  end

  def bar
    @@bar                     # 读取共享的类变量
  end
end

让我们看一个例子来理解类变量的“共享”特性:

  1. 创建两个 C 的实例:c1 = C.new, c2 = C.new
  2. 调用 c1.m2(7):设置 c1@foo 为7,并将共享的 @@bar 从0增加到1。
  3. 调用 c2.m2(9):设置 c2@foo 为9,并将共享的 @@bar 从1增加到2。
  4. 调用 c2.bar:返回2,说明 @@bar 被两个对象共享并累加了两次。
  5. 同时,c1.foo 是7,c2.foo 是9,说明它们的实例变量 @foo 是独立的。

总结 🎯

本节课中我们一起学习了对象状态的核心概念。我们了解到,对象的状态通过实例变量(以 @ 开头)来实现,它们在对象的整个生命周期中持续存在,并且是对象私有的。我们探讨了如何使用和修改状态,以及别名对可变状态的影响。

我们还介绍了用于对象初始化的特殊方法 initialize,它能在对象创建时自动调用,是设置对象初始状态的理想位置。

最后,为了对比,我们简要了解了类变量@@ 开头,由类的所有实例共享)、类常量(大写字母开头,不应修改)和类方法self. 定义,在类上调用)。这些概念共同构成了Ruby中对象与类状态管理的基础。

147:可见性详解 🧐

在本节中,我们将深入探讨Ruby程序中哪些部分可以访问和使用其他部分。这是任何编程语言的核心方面。隐藏信息对于模块化和抽象至关重要,这也是我们学习ML模块系统和面向对象编程语言的原因。Ruby提供了多种方式来控制实例变量、方法和类的可见性。


实例变量的私有性 🔒

Ruby有一个我很喜欢的特点:它强调对象状态始终是私有的。这意味着实例变量只能由拥有该实例变量的对象的方法访问,即使是同一类的其他对象也不行。因此,当我们访问或赋值给实例变量时,我们总是写@foo,而不是e.@foo,因为除了self之外,不允许访问任何对象的@foo

为了将对象状态公开可见,我们需要定义自己的方法。这些方法通常被称为getter方法(返回字段内容)和setter方法(更新字段内容)。例如,我们可以这样定义:

def getFoo
  @foo
end

def setFoo(x)
  @foo = x
end

通过这种方式,我们可以通过setFoo方法提供访问权限。


Ruby的约定与语法糖 🍬

Ruby为getter和setter方法提供了内置支持,并且约定了一些命名规则。如果希望为实例变量@foo提供getter方法,只需将方法命名为foo;如果希望提供setter方法,则命名为foo=。Ruby允许方法名以等号结尾,这是setter方法的约定。

此外,Ruby还有一个有趣的语法糖:如果调用一个以等号结尾的方法,可以在方法名和等号之间添加空格。例如:

e.foo = some_expression

这与e.foo=(some_expression)完全相同,只是让setter方法看起来更美观。


简化getter和setter的定义

由于编写getter和setter方法通常需要多行代码,Ruby提供了更简洁的方式。例如,如果希望为实例变量@foo@bar定义getter方法,可以这样写:

attr_reader :foo, :bar

如果希望同时定义getter和setter方法,可以这样写:

attr_accessor :foo

这些简写形式实际上是在定义getter和setter方法,使实例变量对外部世界间接可见。


为什么需要私有对象状态? 🤔

大多数人都认为,要求实例变量对对象私有可以使语言更符合面向对象编程的风格。这样,你可以专注于对象的接口(即可以调用的方法),而不必知道对象的具体实现方式。这样做的好处包括:

  • 抽象和模块化:类的实现可以后期更改,而不会影响客户端代码。
  • 隐藏实现细节:客户端无需知道实例变量如何存储数据。

例如,客户端可能调用celsius_temp=方法,以为这是为实例变量@celsius_temp设置的setter方法,但实际上类内部可能以开尔文温度存储数据,并通过适当转换来实现该方法。


方法的可见性 👁️

Ruby为方法提供了三种不同的可见性规则,你可以为每个方法选择适合的规则:

  1. 私有方法:只能由同一对象的其他方法调用。
  2. 保护方法:只能由同一类或其子类的对象调用。
  3. 公共方法:任何可以访问该对象的人都可以调用。

默认情况下,方法是公共的。这是因为对象的整个目的就是调用其方法。但Ruby提供了多种方式来更改方法的可见性。


更改方法可见性的方式 🔧

在类定义中,你可以使用关键字protectedpublicprivate来更改方法的可见性。例如:

class Foo
  def method1
    # 默认是公共方法
  end

  protected
  def method2
    # 保护方法
  end

  private
  def method3
    # 私有方法
  end

  public
  def method4
    # 公共方法
  end
end

方法的可见性由最近的protectedpublicprivate关键字决定。类开始时默认为公共方法。


私有方法的调用规则 📞

对于私有方法,由于只能由同一对象的方法调用,Ruby强制使用简写形式。例如,你可以直接写method_namemethod_name(args),但不能写self.method_name。这是因为私有方法只能在同一对象内调用,而Ruby通过语法规则强制了这一限制。


总结 📚

在本节中,我们深入探讨了Ruby中的可见性规则。我们学习了实例变量的私有性、getter和setter方法的定义方式、Ruby的约定与语法糖,以及方法的三种可见性规则(私有、保护、公共)。通过这些规则,我们可以更好地控制程序的模块化和抽象,使代码更加健壮和可维护。

148:一个更长的示例 🧮

在本节课中,我们将通过构建一个名为 MyRational 的分数类,来综合运用已学的Ruby知识,并介绍一些新的语言特性。这个类与我们在ML模块系统中见过的分数模块非常相似。

概述

我们将创建一个表示分数的类。该类将始终保持分数为最简形式(例如,3/2而非9/6),并确保分母为正数。我们将实现初始化、转换为字符串、加法运算等方法。通过这个示例,你将看到Ruby中面向对象编程的风格和一些实用的语法特性。

初始化方法

首先,我们定义 MyRational 类及其初始化方法。该方法接收分子参数,分母参数可选,默认值为1,以便创建整数。

class MyRational
  def initialize(num, den=1)
    if den == 0
      raise "分母不能为零"
    elsif den < 0
      @num = -num
      @den = -den
    else
      @num = num
      @den = den
    end
    reduce
  end

初始化方法会检查分母是否为零(抛出错误)或为负数(调整分子分母的符号)。最后,它调用私有的 reduce 方法来化简分数。

转换为字符串

接下来,我们需要让分数对象能够将自己转换为字符串。在Ruby中,约定俗成的方法是定义 to_s

以下是第一种实现方式:

  def to_s
    ans = @num.to_s
    if @den != 1
      ans += "/"
      ans += @den.to_s
    end
    ans
  end

此方法将分子转换为字符串,如果分母不为1,则拼接“/”和分母的字符串形式。

为了展示Ruby的其他特性,这里还有两种实现方式。第二种使用了反向的 if 条件语句:

  def to_s_2
    ans = @num.to_s
    ans += "/" + @den.to_s if @den != 1
    ans
  end

第三种方式使用了字符串插值:

  def to_s_3
    "#{@num}#{"/#{@den}" if @den != 1}"
  end

加法运算

现在,我们来实现分数的加法。首先是一个会改变对象自身状态的“命令式”加法方法 add!

  def add!(r)
    a = r.num
    b = r.den
    c = @num
    d = @den
    @num = (a * d) + (b * c)
    @den = b * d
    reduce
    self
  end

注意,为了获取另一个分数对象 r 的分子和分母,我们需要访问其受保护的方法 numden。此方法最后返回 self(对象自身),以便进行链式调用。

基于 add!,我们可以轻松实现一个“函数式”的加法方法 +,它返回一个新的分数对象,而不改变原对象。

  def +(r)
    ans = MyRational.new(@num, @den)
    ans.add!(r)
    ans
  end

在Ruby中,+ 运算符实际上是调用左边对象的 + 方法。因此,定义此方法后,我们就可以像 r1 + r2 这样使用加法运算符了。

辅助方法与私有方法

为了让 add! 方法能访问其他对象的分子分母,我们需要提供受保护的获取方法。

protected
  def num
    @num
  end
  def den
    @den
  end

最后,是化简分数和计算最大公约数的私有方法。

private
  def reduce
    if @num == 0
      @den = 1
    else
      g = gcd(@num.abs, @den)
      @num = @num / g
      @den = @den / g
    end
  end

  def gcd(x, y)
    if x == y
      x
    elsif x < y
      gcd(x, y-x)
    else
      gcd(y, x)
    end
  end
end

reduce 方法使用递归函数 gcd 来计算最大公约数,并化简分数。

使用示例

在类定义之外,我们可以编写一个方法来创建和使用分数对象。

def use_rat
  r1 = MyRational.new(3, 4)
  r2 = r1 + r1 + MyRational.new(-5, 2)
  puts r2.to_s
  r2.add!(r1).add!(MyRational.new(1, -4))
  puts r2
  puts r2.to_s_2
  puts r2.to_s_3
end

这段代码演示了函数式加法、命令式加法以及不同的字符串输出方式。

总结

本节课中,我们一起学习了如何用Ruby构建一个完整的 MyRational 分数类。我们涵盖了:

  • 类的定义与初始化。
  • 实例变量与方法的定义。
  • 条件判断与错误处理。
  • 多种字符串转换方法的实现。
  • 命令式与函数式方法的区别与实现。
  • 受保护方法与私有方法的作用。
  • 运算符重载(通过定义 + 方法)。
  • 递归方法的编写。

通过这个综合示例,你不仅复习了Ruby的基础,也接触到了其面向对象和灵活语法的强大之处。

149:万物皆对象 🧱

在本节课中,我们将深入探讨 Ruby 语言的核心哲学——“万物皆对象”。我们将理解这句话的确切含义,并学习一些由此衍生的、在 Ruby 中行之有效的编程习惯。

概述

Ruby 语言完全遵循一个原则:每一个表达式的计算结果都是一个值。这造就了一门更小巧、更规则的语言。如果存在例外,比如“数字除外”或“某些特殊的空值除外”,语言就会变得复杂。因此,在 Ruby 中,你可以在任何对象上调用任何方法。当然,如果该对象没有定义这个方法,你会得到一个“方法未定义”的错误。

实际上,情况比这更有趣。当 Ruby 解释器发现一个对象没有某个方法时,它会转而调用该对象的 method_missing 方法。默认情况下,每个类都定义了这个内置的 method_missing 方法,它会打印出我们看到的错误信息。

一切都是方法调用

我们已经看到,Ruby 中的几乎所有操作都是方法调用。例如,加法运算实际上就是发送 + 消息。让我们用数字来演示这一点,因为它们是一个很好的例子。

以下是具体的例子:

  • 我可以写 3 + 4 得到 7。
  • 我也可以写 3.+(4),这是去掉语法糖后的原始形式。
  • 我还可以调用 3.abs,这是求绝对值的方法。
  • 有一个方法叫 nonzero?,除非数字是 0(此时返回 nil),否则它返回数字本身。

当然,我们不必在整数常量上这样做。我们可以用一个变量来保存值,例如 x = -5,然后调用 x.abs 得到 5。

上一节我们介绍了数字的纯面向对象编程,本节中我们来看看另一个特殊的值:nil

对象 nil

在 Ruby 中,nil 用于表示“没有任何数据”。它类似于 ML 的 unit、Java 或 C 系列语言中的 null,但关键区别在于:nil 本身也是一个对象

以下是关于 nil 的一些关键点:

  • nil 定义的方法不多,但它有一个 nil? 方法,该方法对 nil 自身返回 true,其他所有对象(包括空字符串)调用 nil? 都返回 false
  • 在 Ruby 中,nil 对象在布尔上下文中被视为 false。Ruby 中只有两个东西是 false:常量 false 和对象 nil

例如:

y = 4 > 3 ? nil : 42 # 条件为真,所以 y 是 nil
if y
  puts “A”
else
  puts “B” # 会输出 “B”,因为 nil 被视为 false
end

所有代码都是方法

我想强调的是,我们程序中的所有代码都只是方法。一切都是某个类的方法。你通过创建该类的实例并在其上调用方法来执行代码。

你可能会认为顶层方法(定义在任何类之外的方法)有所不同,但事实并非如此。如果你在文件或 REPL 中定义一个顶层方法,它会被添加到 Object 类中。Object 类是所有其他类的超类。

为了便于理解,你可以认为:当你定义一个类时,你会自动获得其所有超类的方法。由于 Object 是所有你定义的类的超类,因此你定义的每个类都拥有 Object 的所有方法(除非你用同名方法覆盖了它们)。所以,那些顶层方法现在只是每个对象的方法。这再次体现了纯粹的面向对象编程思想,是一种更符合 OOP 风格、更契合 Ruby 语言小巧特性的思考方式。

反射与探索式编程

最后,我想强调“反射”这个概念及其在探索式编程中的应用。这些术语听起来很高深,但背后的想法相当简单。

Ruby 中所有对象都定义了一些方法,可以告诉你关于该对象的信息,比如它有哪些方法,它是哪个类的实例。如果你在程序运行时使用这些方法,你就是在进行“反射”——在程序运行过程中了解程序本身的信息。

这在编程中有一些用途,但我们通常会避免使用,因为通常(并非总是)有更好的方法来实现相同的目的。不过,在 REPL 环境中,它对于探索程序、调试或学习非常有用。这不过是对对象进行的方法调用。

以下是探索式编程的例子:

  • 调用 3.methods 会返回一个非常大的符号数组,列出了所有可以在 3 上调用的方法。其中一些与算术有关(如 succ 返回 4,even? 返回 false),另一些则与数字无关,它们只是定义在 Object 类或 3 所属类的其他超类中的方法。
  • 我们也可以查看 nil 的所有方法:nil.methods
  • 利用数组支持减法运算符的特性,我们可以找出 3 有而 nil 没有的方法:3.methods - nil.methods。你会发现结果列表中的方法几乎都与某种算术或数字操作有关。
  • 另一个有用的反射方法是 class。调用 3.class 会返回 Fixnum(或 Integer,取决于 Ruby 版本),表明 3Fixnum 类的一个实例。你可能会好奇:Fixnum 本身是对象吗?它有方法吗?是的。它的类是什么?调用 3.class.class 会返回 Class。所以,连类本身也是对象,它也有自己的类。你甚至可以继续追问 Class 的类是什么(Class.class),你会发现它也是 Class。这虽然可能让人有点晕眩,但却是这种探索式编程的一个有趣例子。

我经常发现在 REPL 中查看对象或类的方法很方便,但之后我通常会带着更多问题去查阅在线文档以了解细节。

总结

本节课中,我们一起学习了 Ruby “万物皆对象”的核心思想。我们了解到所有值都是对象,所有操作都是方法调用,甚至连 nil 和类本身也是对象。我们还探索了如何使用反射方法在运行时了解对象信息,并将其作为探索和调试程序的工具。这让我们对 Ruby 高度一致和纯粹的面向对象设计有了更深刻的理解。

150:类定义是动态的 🧬

在本节课中,我们将学习 Ruby 语言的一个核心特性:动态类定义。我们将看到,在程序运行时,类的定义(例如其方法)可以被修改、添加甚至替换。这体现了 Ruby 作为一门动态语言的强大与灵活,但也带来了程序分析和维护上的挑战。

上一节我们探讨了实例变量的动态性,本节中我们来看看类定义本身如何动态变化。

动态修改类定义

Ruby 允许在程序执行期间的任何时刻,为任何类添加、更改或替换方法。这意味着,即使对象已经创建,其所属类的方法定义发生改变后,该对象的行为也会随之更新。

以下是一个简单的示例,展示了如何为已存在的类添加新方法:

class MyRational
  # 假设这个类已经定义了一些方法,但没有 `double` 方法
end

# 动态地为 MyRational 类添加一个 double 方法
class MyRational
  def double
    self + self  # 调用自身的加法运算
  end
end

执行上述代码后,所有 MyRational 类的实例(包括在添加方法之前创建的实例)都将拥有 double 方法。

动态特性的应用与风险

这种动态性有其便利之处,例如,如果你希望为 Ruby 的内置类(如 StringArray)添加一个实用的辅助方法,你可以直接添加它。

class Fixnum
  def double
    self * 2
  end
end

puts 3.double  # 输出 6

然而,这种能力也伴随着风险。修改核心类的方法可能会产生难以预料的副作用,甚至导致程序崩溃。例如,重写 Fixnum 类的 + 方法可能会破坏 Ruby 解释器(如 IRB)自身的功能,因为它内部也依赖于这些基本运算。

语义影响

动态类定义引出了一个重要的语义问题:当一个对象被创建后,其类的方法定义发生了变化,那么该对象调用该方法时,应该执行旧的实现还是新的实现?

在 Ruby 中,答案是执行新的方法实现。对象的行为始终与其类的当前定义保持一致。这种设计被认为是更实用的语义。

对于那些不支持运行时修改类定义的静态语言,这个问题根本不存在。因此,动态语言在提供灵活性的同时,其语言设计者和实现者必须明确回答这类语义问题,这也可能使得语言实现更复杂。

总结

本节课中我们一起学习了 Ruby 的动态类定义特性。我们了解到:

  1. 可以在运行时为任何类添加或修改方法。
  2. 这种修改会影响该类的所有实例,包括修改前创建的实例。
  3. 此特性虽然强大便捷,但滥用(尤其是修改核心类)会带来风险。
  4. 动态特性引入了独特的语言语义问题,例如方法查找在类定义变更后如何决议。

理解这一特性有助于深入把握 Ruby 面向对象模型的核心——类也是对象,其行为同样可以动态塑造。

151:第10章第8节 鸭子类型 🦆

在本节课中,我们将学习面向对象编程中一个重要的概念——鸭子类型。这是动态类型语言中一个核心且有趣的话题,理解它有助于我们编写更灵活、可复用的代码,同时也要注意其潜在的抽象泄露问题。

任何对动态类型语言中面向对象编程的研究,如果不探讨被称为“鸭子类型”的主题,都是不完整的。

名字的由来 🦆

首先,让我解释一下这个名字的由来。英语中有一个有趣的说法:“如果它走路像鸭子,叫声像鸭子,那么它就是鸭子。”

一个更精确的说法是:如果它叫声像鸭子,走路像鸭子,那么它实际上是不是一只鸭子,对我们当前的目的而言并不重要。

这就是其核心思想。在编程中,当你在编写某个方法时,可能会想:“哦,我需要接收一个Foo类的实例。”但实际上,你只是接收某个参数并调用其上的方法。如果存在某个东西,它走路像Foo,叫声像Foo,但实际上并不是Foo的实例,那么在动态类型语言中,你的代码可能仍然可以工作,并且客户端传递一个并非真正Foo的对象可能是合适的。

鸭子类型的核心思想 💡

上一节我们介绍了鸭子类型的比喻,本节中我们来看看它在编程中的具体体现。

当你采用这种鸭子类型时,你真正坚持的是:除了对象拥有某些方法并且你可以用特定参数调用它们之外,不对对象做任何假设。你会避免使用那些用于测试“你实际上是不是一个Foo”、“你的类是什么”、“你是不是某个特定类的实例”的语言特性(Ruby确实有这些特性)。

人们通常认为,通过采用鸭子类型,你可以获得更高的代码复用率。😡

你之所以能获得更高的代码复用率,是因为你采取了一种非常面向对象的方法:你只关心一个对象能接收什么消息。

鸭子类型的优缺点 ⚖️

然而,这种方法的缺点是它使得代码几乎无法等价替换。它使得用一组不同的方法调用来替换原有调用变得不可能,因为那样一来,曾经看起来像鸭子的东西就不再像鸭子了。

例如,对于数字,x + xx * 2在Ruby中是等价的。但如果人们传递进来的不是数字,并且它们以不同的方式处理x + xx * 2,那么进行这种更改将会导致难以发现或令人困惑的行为。

因此,在鸭子类型的世界里,调用者最终可能会对方法的实现方式做出过多假设,从而失去编程中非常重要的所有抽象优势。

一个具体示例 📐

让我通过幻灯片上的一个例子来说明这一点。

假设存在一个表示平面点的类,它有X坐标和Y坐标。并且假设我们希望能够“镜像”一个点,即更新一个点,将其x坐标变为其相反数,同时保持y坐标不变(这就像照镜子一样)。

面向对象的风格实际上会让mirror_update方法成为Point类的一个方法。但为了举例,假设我只有一个辅助方法,它接收一个点并改变它。代码如下:

def mirror_update(point)
  point.x = point.x * -1
end

自然的想法是看这段代码然后说:“哦,它接收一个点对象,并取反x值,就地更新它。”但这实际上并不是这段代码所做的。更仔细地看,它会说:“它接收任何对实例变量@x有getter和setter方法的东西,然后用@x * -1替换@x。”

所以,如果我有一个不是Point实例,但拥有这些getter和setter方法的对象,那么这段代码大概会对这样的对象做一些有用的事情。

现在,希望至少你们中的一些人会说:“但是等等,Dan,这仍然限制太多了。你假设getter和setter方法实际上是在更新一个x实例变量,而这是对象实现的一部分,作为mirror_update的编写者,这不关我们的事。”因此,一个更精确的描述会说:mirror_update可以接收任何拥有x=x方法的对象,它所做的是:调用x=方法,参数是调用x方法的结果乘以-1。

无论这些东西是否在访问一个实例变量,或者x=方法是否真的更新了什么(虽然一个名为x=的方法不实际更新任何东西是很糟糕的风格,但没有什么能阻止你这样做)。如果你有任何这样的对象,你可以把它传给mirror_update,大概会发生一些有用的事情。😡

为了完整地说明,这仍然不完全正确,因为在这个描述中,你假设*方法实际上是在执行乘法。没有理由一定是这种情况。我会说,这段代码真正的鸭子类型解释是:它接收任何拥有方法x=和方法x的对象,其中调用x方法的结果(即point.x)是一个拥有可以接收-1*方法的对象。

然后,这个方法所做的是:它将调用x的结果发送*消息(参数为-1),然后将该结果发送给x=消息。

这就是鸭子类型。它通常很方便。我不喜欢它的一点是,现在我们方法的文档本质上就是方法体的全部内容。我们对客户端完全没有任何隐藏。我们确切地告诉了他们我们将要做什么,以至于他们本可以自己使用这段代码,而不是依赖我们的方法。😡

总结与权衡 🤔

因此,这样做的好处是,也许mirror_update现在对我们未曾预料到的类也有用了。通过编写代码时不检查我们接收的是Point实例,其他客户端可以以对他们有用的方式复用我们的代码。

但缺点是,如果有人用一个依赖于诸如存在*消息、或x=是一个setter等细节的对象实例来复用我们的代码,那么我们就不能将像point.x * -1这样的代码替换成类似-point.x的东西。如果我们知道我们有一个返回数字的点,并且我们知道在数字上取反和乘以-1是相同的,那么后者是可行的。但这是鸭子类型所不能假设的事情。

所以,对于像mirror_update这样的例子,我认为鸭子类型实际上常常是一种糟糕的风格。可能有一些不完全像点、但适合传递给mirror_update的东西,但我不想传递一些只是碰巧有xx=方法的任意对象。

不过,我必须承认,确实有很好的鸭子类型的例子。如果你拿一些简单的辅助函数来说(虽然这个例子可能太简单以至于不够有说服力),比如一个通过调用x + x来使其参数翻倍的函数:

def double(x)
  x + x
end

这样写的好处是,给定这个函数,我可以传入一个数字(因为数字有+消息),我可以传入一个字符串(然后它会将字符串与自身连接),我甚至可以传入我自己定义的代码(比如我在前面章节定义的MyRational对象),因为我定义了+,所以我可以把这些对象传给这个double函数,让它们被翻倍——即使这段代码的最初编写者当时想的是数字,或者想的是“我可以支持任何有+消息的东西”。

以上就是鸭子类型的优缺点,以及它如何与动态类型语言中的面向对象编程相关。

本节课中我们一起学习了鸭子类型的概念、其核心思想、通过具体示例分析了其优缺点。鸭子类型通过关注对象的行为(方法)而非其具体类型,提高了代码的灵活性,但也可能削弱封装性和抽象性,需要开发者根据具体场景谨慎权衡。

152:数组 🧮

在本节课中,我们将要学习Ruby中最常见的数据结构——数组。我们将了解其基本语法、灵活特性以及如何将其用于多种编程场景。

概述

数组是Ruby程序中最常见的数据结构。它是一种可以容纳任意数量其他对象,并通过数字索引进行访问的容器。与许多其他编程语言相比,Ruby的数组更加灵活和动态,几乎可以用于表示任何类型的数据结构。本节我们将通过大量示例来掌握数组的基本用法。

数组基础

数组本质上是一个可以容纳多个其他对象,并通过数字索引访问的容器。你可以获取第三个、第五个或第零个元素。获取数组a的第i个元素有特殊的语法。

创建与访问

以下是创建和访问数组的基本方法。

  • 你可以使用方括号和逗号分隔的值来创建数组:a = [3, 7, 9, 2]
  • 使用a[i]的语法可以获取数组a的第i个元素。
  • 使用a[i] = e的语法可以设置数组a的第i个元素。

与大多数编程语言一样,数组的索引位置从0开始计数。

边界处理

在Ruby中,数组的边界处理非常灵活。

  • 如果你访问一个不存在的索引,例如a[4],Ruby不会报错,而是返回nil对象。
  • 使用a.size可以获取数组中当前元素的数量。
  • 负索引a[-1]表示从数组末尾开始计数,返回最后一个元素。a[-2]返回倒数第二个元素,依此类推。如果索引超出范围(如a[-5]),同样返回nil

数组的灵活性

Ruby数组的灵活性体现在其动态性和容错性上。几乎任何对数组的操作都不会导致错误,Ruby会提供一个合理的默认行为。

动态修改

数组可以动态地增长和修改。

  • 你可以更新数组元素:a[1] = 6
  • 你甚至可以赋值给一个超出当前边界的索引,例如a[5] = 14。Ruby会自动扩展数组,并用nil填充中间的空位以满足新索引的要求。
  • 数组的大小会随之动态变化,a.size会返回新的长度。

容纳任意类型

由于Ruby是动态类型语言,数组可以容纳任何类型的对象。

  • 你可以在同一个数组中混合存放数字、字符串或任何类的实例:a[3] = "hi"

数组的常用操作

Ruby为数组类定义了大量方法和操作符,使其功能非常强大。

算术运算

数组支持一些直观的算术运算。

  • 加法运算符+用于连接两个数组:c = a + b会返回一个新数组,包含ab中的所有元素。
  • 管道运算符|用于取两个数组的并集并去除重复元素:[1,2] | [2,3] 返回 [1, 2, 3]

动态创建数组

除了字面量方式,还可以动态创建数组。

  • 可以使用Array.new(x)创建一个长度为x的数组,其中x可以是运行时计算的值。
  • 可以传入块来初始化元素,例如Array.new(20) {0}创建一个所有元素都为0的数组,或Array.new(20) {|i| -i}创建一个元素为0到-19的数组。

用数组模拟其他数据结构

鉴于Ruby数组的灵活性,它常常被用来模拟其他数据结构,而无需定义专门的类。

作为元组使用

如果你需要一个固定大小的有序集合(元组),直接使用数组即可。

  • 例如,triple = [false, "hi", 7] 就是一个三元组,可以通过triple[2]访问最后一个元素。

作为栈使用

栈是一种后进先出的数据结构。Ruby数组原生支持栈操作。

  • push方法将元素添加到数组末尾(入栈)。
  • pop方法移除并返回数组的最后一个元素(出栈)。
  • 通过组合使用pushpop,你可以轻松地将数组用作栈。

作为队列使用

队列是一种先进先出的数据结构。同样可以用数组模拟。

  • push方法将元素添加到数组末尾(入队)。
  • shift方法移除并返回数组的第一个元素(出队)。
  • 组合使用pushshift,就实现了一个队列。
  • 此外,unshift方法可以将元素添加到数组开头(插队),shift再将其移除。

数组切片与别名

上一节我们介绍了数组如何模拟多种数据结构,本节我们来看看数组的两个重要特性:切片和对象别名。

数组切片

你可以获取数组的一部分,即切片。

  • 语法a[2, 4]表示从索引2开始,获取4个元素,返回一个新数组[6, 8, 10, 12]
  • 你甚至可以对切片进行赋值,并且赋值的元素数量不必与原切片相同。例如f[2, 4] = [1, 1],这会将原数组中对应部分替换为新的值,并可能导致数组长度发生变化。

对象别名

数组是对象,因此之前讨论的对象别名规则同样适用。

  • 直接赋值d = a会使da指向同一个数组对象,它们是别名。修改a[0]也会影响d[0]
  • 而通过+运算e = a + []会创建一个包含相同元素的新数组,ea不是别名,修改其中一个不会影响另一个。

遍历数组

对数据集合最常见的操作就是遍历所有元素并执行某些操作,例如映射或归约。这将是下一节的重点,但这里先给出一个预览。

你可以使用each方法遍历数组。

[1,2,3,4].each {|i| puts i*i}

这段代码会打印出每个元素的平方。{ |i| ... }是一个块(block),我们将在下一节详细解释其含义。

总结

本节课中我们一起学习了Ruby数组的核心概念。我们了解到:

  1. 数组是一种通过数字索引访问的灵活容器。
  2. Ruby数组具有动态增长、容错性高、可容纳任意类型的特点。
  3. 通过push/popshift/unshift,数组可以方便地用作栈或队列。
  4. 数组支持切片操作,并且需要注意对象别名的行为。
  5. 最后,我们预览了如何使用each方法遍历数组。

总的来说,Ruby数组功能强大且灵活,可以很好地充当元组、列表、栈、队列等角色,是Ruby编程中不可或缺的工具。要了解更多方法,建议查阅官方文档,因为许多常用的通用操作很可能已经内置在标准库中。

153:块(Blocks)📦

在本节课中,我们将要学习Ruby语言中一个独特且强大的特性——块(Blocks)。块是Ruby中实现高阶编程的核心方式,它允许我们将代码片段作为参数传递给方法。虽然与其他语言中的闭包概念相似,但Ruby的块在语法和使用上有其独特之处。通过本节课的学习,你将掌握块的基本语法、用途以及如何在数组操作等常见场景中高效地使用它们。

什么是块?🤔

上一节我们介绍了课程概述,本节中我们来看看块的具体定义。块本质上是可以传递给其他方法的匿名函数。调用方(方法)随后可以调用这个函数并使用它。

与我们在其他语言中学习的函数闭包类似,块可以接受零个、一个、两个或更多参数。并且与闭包一样,块使用词法作用域。当调用方调用块时,我们会在定义块的环境中评估块的主体,而不是在调用块的环境中。

以下是一些展示块语法的例子。与Racket中的lambda或ML中的fn箭头不同,Ruby的块直接放在花括号{}中。

3.times { puts "hello" }
[4,6,8].each { puts "hi" }
[4,6,8].each { |x| puts x }

在第一行,我们传递了一个打印“hello”的块作为参数给3.times方法。块以一种特殊的方式传递,我们稍后会详细讨论。类似地,第二行和第三行展示了如何向数组的.each方法传递块,其中最后一个块接受一个参数x

块的语法与特性🔧

上一节我们了解了块的基本概念,本节中我们来深入探讨其语法和一些独特特性。

一个奇怪的特点是,在任何方法调用中,你都可以传递零个或一个块,但不能传递多个。如果你传递了一个块,调用方可以选择忽略它或报错。反之,如果你没有传递块,调用方甚至可以根据你是否传递块来执行不同的操作。这与普通参数是分开的。普通参数在括号内用逗号分隔传递,而块则在语法上紧邻方法调用放置,要么传递,要么不传递。

块的语法如下:

  • 如果块体是单行表达式,使用花括号 { ... }
  • 如果希望块接收参数,则将参数放在竖线 | | 字符之间,并用逗号分隔。
# 单行块
3.times { puts "hi" }

# 接收一个参数的块
[1,2,3].each { |x| puts x*2 }

# 接收两个参数的块
some_method { |x, y| x + y }

此外,还有第二种语法:使用 do 作为左花括号,end 作为右花括号。这通常是块体为多行时的首选风格,而花括号则多用于单行块。除此之外,两者几乎等价,仅在运算符优先级等方面有细微的语法差异。

# 多行块使用 do...end
[1,2,3].each do |x|
  puts "Processing #{x}"
  puts x * 2
end

块的实际应用举例💡

上一节我们学习了块的语法,本节中我们来看看块在实际编程中为何如此有用。我们已经看到了一个巧妙之处:通过数组的.each方法,我们可以将相同的代码应用到数组的每个元素上。事实上,Ruby的标准库充满了期望接收块的方法。

由于标准库对函数式编程和高阶函数的出色运用,Ruby程序员几乎从不使用显式循环。语言中虽然有循环结构,但几乎没人使用,因为所有需要用循环实现的常见操作,都有更便捷的方式:只需调用标准库中的某个方法并传递一个块给它。

以下是使用数组进行块操作的一系列示例:

# 1. 使用块初始化数组
a = Array.new(5) { |i| (i+1)*4 }
# 结果: [4, 8, 12, 16, 20]

# 2. .each: 对每个元素执行操作(打印)
a.each { |x| puts x*2 }
# 输出: 8, 16, 24, 32, 40

# 3. .map / .collect: 转换数组,生成新数组
b = a.map { |x| x * 2 }
# 结果: [8, 16, 24, 32, 40]

# 4. .any?: 检查是否有元素满足条件
a.any? { |x| x > 7 }   # => true
a.any? { |x| x > 700 } # => false

# 5. .all?: 检查是否所有元素都满足条件
a.all? { |x| x > 7 }   # => false
a.all? { |x| x > -7 }  # => true

# 6. .inject / .reduce: 累积计算(求和)
sum = a.inject(0) { |acc, elt| acc + elt }
# 结果: 60 (4+8+12+16+20)
# 省略初始值,使用第一个元素作为累加器
sum2 = a.inject { |acc, elt| acc + elt }
# 结果: 60

# 7. .select: 过滤数组(类似于filter)
a.select { |x| x > 7 }        # => [8, 12, 16, 20]
a.select { |x| x > 7 && x < 18 } # => [8, 12, 16]

用块替代循环:一个复杂例子🌀

上一节我们看了一些基础的块操作,本节中我们来看一个更复杂的例子,展示即使在你认为需要循环的情况下,也能用块来避免显式循环。

假设我们想打印一个数字三角形图案。在大多数语言中,这需要嵌套循环。在Ruby中,我们可以使用范围(Range)对象的.each方法和块来实现。

def print_triangle(n)
  (0..n).each do |j|
    # 打印缩进
    print "  " * j
    # 内层“循环”,打印数字
    (j..n).each { |k| print k; print " " }
    # 换行
    puts
  end
end

print_triangle(9)

这段代码定义了一个方法print_triangle。它使用范围(0..n),该范围有一个.each方法。我们传递一个多行块(使用do...end)给它。在这个块内部,我们首先打印2*j个空格作为缩进。然后,我们使用另一个范围(j..n)和其.each方法来实现内层“循环”,打印从jn的数字。最后,我们打印一个换行符。

通过调用print_triangle(9),我们得到了一个漂亮的数字三角形网格,而代码中并没有出现传统的forwhile循环字眼,只有方法调用和传递给它们的块。

总结📝

本节课中我们一起学习了Ruby的核心特性——块(Blocks)。我们了解到块是可传递的匿名代码单元,使用词法作用域,并且语法灵活({...}do...end)。我们探索了块在Ruby标准库中的广泛应用,例如使用.each.map.select.inject等方法对集合进行迭代、转换、过滤和归约操作,这让我们能够避免编写显式循环,写出更简洁、更具声明性的代码。最后,我们通过一个打印数字三角形的复杂例子,看到了如何用块和迭代方法优雅地替代传统的嵌套循环。在下一节中,我们将学习如何在自己定义的方法中使用块。

154:使用代码块 🧱

在本节课中,我们将学习如何在Ruby中定义和使用代码块。我们将看到,代码块的使用不仅限于标准库中的方法,我们也可以在自己的方法中接收并调用调用者传递的代码块。

概述

上一节我们介绍了代码块的基本概念。本节中,我们来看看如何在自己的方法中接收并调用代码块。我们将学习如何使用 yield 关键字,以及如何处理代码块的存在与否。

定义接收代码块的方法

在Ruby中,调用者传递代码块时,只需将其放在其他参数之后。代码块的处理方式与其他参数不同。在方法定义方,我们没有一个特定的参数名来接收这个代码块。代码块要么存在,要么不存在。要调用它,我们使用Ruby关键字 yield

yield 的意思是“运行那个可能存在的代码块”。如果你想向代码块传递参数,可以通过 yield 关键字传递。因此,除了 yield 之外,代码块没有其他名称。

以下是一个例子。我们可以定义一个方法 silly,它接收一个参数 a。但在其方法体中,它假设自己被传递了一个代码块,并会使用参数 a42 来调用这个代码块,然后将结果相加。

def silly(a)
  (yield a) + (yield 42)
end

这是方法的定义。我们可以像下面这样调用它。假设 x 是拥有 silly 方法的类的实例,我们可以为 a 传递 5,以及一个代码块。最终,如果计算正确,它将返回 94 或类似的结果。

x.silly(5) { |x| x + 2 }

处理未传递代码块的情况

在这种情况下,如果某个调用者没有传递代码块,当我们尝试调用 yield 时,会得到一个错误。

如果调用者传递了一个期望不同数量参数的代码块,我们不会得到错误。它要么传递了太多参数,要么传递了太少参数,代码块会相应地处理。稍后我会尝试展示这一点。

但这就是你使用代码块的方式。

检查代码块是否存在

如果你的方法想根据是否传递了代码块来做不同的事情,例如,如果没有传递代码块,则执行某种默认行为(就像我们之前看到的 all?any? 方法),那么有一个有用的原语 block_given?

正如你可能猜到的,block_given? 在调用者传递了代码块时求值为 true,否则为 false

但我们通常只是假设传递了代码块,就像上面的 silly 方法一样。或者至少根据我们拥有的其他信息(例如常规参数的内容)来假设。

代码示例

让我展示一些代码。这些例子可能不太令人兴奋,因为我能想到的所有例子都是你实际上想要代码块的情况,否则我们只是在重新实现标准库中定义的东西。

但这里有一个小类 F。当你创建一个对象时,它会设置一个 @max 实例变量。有趣的方法是 count 方法。

class F
  def initialize(max)
    @max = max
  end

  def count(base)
    if base > @max
      raise "base too large"
    elsif yield(base)
      1
    else
      1 + count(base + 1) { |i| yield(i) }
    end
  end

  def silly
    yield(4, 5) + yield(@max, @max)
  end
end

count 方法也隐式地假设它接收一个代码块。如果该代码块在传入 base 时返回 true,那么我们将返回 1。否则,我们希望将 1 加到对 base + 1 的递归 count 调用上,本质上使用相同的代码块。

count 所做的是从 base 开始,计算在代码块返回 true 之前,你需要从 basebase+1base+2base+3 进行多少次尝试。它计算在达到 @max 之前需要多少步才能得到 true

这里奇怪的一点是,在这个递归调用中,我只想传递我收到的同一个代码块。没有直接的方法可以做到这一点,但你可以通过一种在我看来很像不必要的函数包装的方式来实现,但在这里是必要的。我可以做的是,向递归调用传递一个代码块,当这个代码块被调用时,它会调用我收到的那个代码块(也就是我 yield 的那个),并且我只传递相同的参数。这就是 count 的工作原理。

silly 方法更像我在幻灯片上展示的那个例子。它接收一个代码块,可能向其传递两个参数 45,然后再次调用它,传递实例变量 @max(两次)。

运行示例

让我快速展示几个例子,然后我们就可以宣布成功了。

首先,加载文件:

load 'using_blocks.rb'

现在,创建该类的一个实例,并设置一个较大的 max 值,比如 1000

f = F.new(1000)

如果我调用 silly 方法,我需要传递一个代码块。如果我不传递:

f.silly
# => 错误:no block given (yield)

所以,让我们传递一个代码块:

f.silly { |a, b| 2 * a - b }
# => 10003

解释一下 2 * a - b:如果你传入 45,会得到 3。如果你传入 100100,会得到 1000。把它们加在一起,就得到 10003

现在让我们试试 count 方法。思路是从某个基数(比如 10)开始,不断尝试,计算在代码块返回 true 之前需要尝试多少次(从 10 开始,然后是 111213...)。

f.count(10) { |i| i == 34 }
# => 25

这里的小等式意味着我必须从 10 开始,一直增加到 34(包括 34)才能得到 true。因此,这需要 25 次尝试。在这个例子中,你可以把这个代码块看作一个回调,它将被依次传入 10111213... 它完全按照我们的预期工作。

总结

本节课中,我们一起学习了如何在Ruby中使用 yield。我们看到了如何定义自己的方法来接收和调用代码块,如何使用 yield 关键字来执行代码块,以及如何使用 block_given? 来检查代码块是否存在。虽然 yield 的用法相当方便,但也与其他编程语言中的任何东西都不同。这就是调用者如何使用传递给方法的代码块的方式。

155:Proc对象与代码块

在本节课中,我们将要学习Ruby中的Proc对象。我们将探讨Proc对象与代码块的区别,理解“一等表达式”的概念,并通过实例展示如何创建和使用Proc对象。

概述

上一节我们介绍了代码块的基本用法。本节中我们来看看Proc对象。Proc对象是Ruby中将代码块转化为实际对象的方式,它拥有函数闭包的全部能力。我们将通过对比代码块和Proc对象,来理解编程语言中“一等表达式”的含义。

代码块与Proc对象的区别

在Ruby中,代码块(blocks)和Proc对象(procs)是两种不同的概念。代码块不是对象,而Proc对象是Proc类的实例,是真正的对象。

代码块的主要限制在于,当你定义一个方法并接收一个代码块时,你只能通过yield关键字来调用它。你不能返回一个代码块,不能将其存储在对象中,也不能将其放入数组。

以下是将代码块转化为Proc对象的方法:

my_proc = lambda { |x| x * 2 }

一等表达式与二等表达式

在编程语言中,如果一个表达式可以作为计算结果、被函数返回、存储在对象中,并能像数字或其他对象一样传递,我们称其为一等表达式。反之,则为二等表达式。

在Ruby中,代码块是二等表达式,而Proc对象是一等表达式。这个区别是理解两者不同用途的关键。

创建与使用Proc对象

以下是创建Proc对象的一种常见方式。lambdaObject类中的一个方法,它接收一个代码块并返回一个Proc实例。

# 使用lambda方法创建Proc对象
add_one = lambda { |num| num + 1 }

一旦我们拥有了Proc对象,就可以像使用任何其他对象一样使用它,并通过call方法来调用其中封装的代码。

# 调用Proc对象
result = add_one.call(5) # 返回 6

实际应用示例

通常,对于像mapcount这样的操作,使用代码块就足够了,我们不需要一等表达式。

# 使用代码块进行map操作
a = [3, 5, 7, 9]
b = a.map { |x| x + 1 } # b 为 [4, 6, 8, 10]

# 使用代码块进行count操作
count = b.count { |x| x >= 6 } # count 为 3

但是,假设我们想要创建一个闭包数组,每个闭包可以比较其对应的原始数组元素与一个传入的参数。使用代码块无法实现这一点,但使用Proc对象可以。

以下是实现步骤:

  1. 使用lambdamap创建一个Proc对象数组。
  2. 每个Proc对象捕获原始数组对应位置的值。
  3. 通过call方法并传入参数来使用这些闭包。
a = [3, 5, 7, 9]

# 创建Proc对象数组
c = a.map do |element|
  lambda { |y| element >= y }
end

# c现在是一个包含四个Proc对象的数组
# 调用第一个Proc对象,判断3是否大于等于5
c[0].call(5) # 返回 false
# 调用第三个Proc对象,判断7是否大于等于5
c[2].call(5) # 返回 true

# 统计有多少个Proc对象在传入5时返回true
result = c.count { |proc| proc.call(5) } # 返回 3

语言设计思考

Ruby的设计选择引发了一个有趣的思考:为什么Ruby要同时提供方便的二等代码块和功能更全面的一等Proc对象?

这是一个权衡。Ruby使常见情况(如迭代)下的代码块非常方便易用,但代价是当常见用法无法满足需求时(如需要存储闭包),开发者需要学习另一种不同的机制(Proc)。大多数语言只提供一等闭包,并尽力使其方便。Ruby做出了略有不同的选择,这有助于我们理解代码块和Proc对象之间的区别。

总结

本节课中我们一起学习了Ruby中的Proc对象。我们明确了代码块是二等表达式,主要用于便捷的迭代操作;而Proc对象是一等表达式,是实际的Proc类实例,可以作为对象传递和存储,实现了完整的闭包功能。我们掌握了使用lambda方法创建Proc对象,并通过call方法调用它。最后,我们通过创建闭包数组的实例,理解了Proc对象在需要存储和传递闭包场景下的不可替代性,并探讨了Ruby这一设计背后的权衡。

156:哈希与范围

在本节中,我们将讨论Ruby标准库中定义的两个其他类:哈希和范围。它们在许多方面与数组相似,并且在Ruby程序中非常常见,因此值得介绍。但从编程语言概念的角度来看,更有趣的是它们如何与鸭子类型交互,以提供类似于函数式编程和高阶函数优点的功能。

哈希

哈希与数组非常相似,但它的键可以是任何对象。我们可以将数组视为从键到值的映射,其中键是数字。例如,键2返回索引为2的值,键4返回索引为4的值。在哈希中,你同样有键和值,但键可以是任何对象。通常键是字符串或符号,但也可以是任何对象。因此,哈希没有自然的顺序。我们认为数组的元素是有序的,如2、3、4、5。但对于哈希,由于键可以是任何东西,我们放弃了这种顺序,以换取使用任何类型键的灵活性。创建哈希的语法自然也不同。

但一旦我们有了哈希,许多相同的方法仍然适用,包括使用方括号语法获取或设置值。你也可以将哈希视为一个记录,其中字段名可以是任何内容,字段内容也可以是任何内容。因此,我们经常在需要一个包含多个部分的小型数据结构,并且希望为这些部分使用指示性名称时使用哈希。例如,如果你需要向一个方法传递大量配置选项,通常该方法会将这些参数作为单个哈希值的一部分接收。

在继续讨论范围之前,我们先来看一些示例,因为之前有很多描述但没有展示例子。

以下是创建和使用哈希的示例:

# 创建一个空哈希
h1 = {}
# 或者使用 Hash.new 方法
h1 = Hash.new

# 向哈希中添加键值对
h1["a"] = "found a"
h1[false] = "found false"

# 打印哈希
puts h1
# 输出: {"a"=>"found a", false=>"found false"}

# 访问不存在的键返回 nil
puts h1["nonexistent"]  # 输出: nil

# 获取所有键和值
puts h1.keys   # 输出: ["a", false]
puts h1.values # 输出: ["found a", "found false"]

你还可以直接使用特定语法创建哈希:

h2 = {"small" => 1, "medium" => 2, "large" => 3}
puts h2["medium"]  # 输出: 2
puts h2.size       # 输出: 3

哈希上定义了各种迭代器,但每个迭代器接收一对键和值:

h2.each do |key, value|
  puts "#{key}: #{value}"
end
# 输出:
# small: 1
# medium: 2
# large: 3

在Ruby程序中,有时使用符号(如 :foo)作为键比使用字符串(如 "foo")更高效,但两者都很常见。

范围

现在让我们回到幻灯片,讨论范围。范围的行为非常像连续的数字序列,但它们的表示更高效。例如,我可以创建从1到1000000的范围,这非常快。这实际上不是一个包含一百万个元素的巨大数组对象。如果我调用 to_a 方法,它会将范围转换为数组,但范围本身只是一个小对象,表示从1到1000000的连续数字。你可以将其视为具有下界和上界的对象,就像它有两个实例变量一样,而不是一百万个。

许多方法都定义在范围上。例如,如果我想计算从1到100的所有数字之和,我可以调用 inject 方法(这是Ruby中 reduce 的别名):

sum = (1..100).inject(:+)
puts sum  # 输出: 5050

选择合适的数据结构

现在我们有了三种不同的数据结构:数组、哈希和范围。我们可以讨论何时使用哪一种更合适。简而言之,尽可能使用范围,因为它们更高效且表达更清晰。例如,表示从2到10的数字或从变量x到y的数字范围。当数字键使代码难以理解时,或者当你确实需要从一组字符串到其他值的映射时,使用哈希是合适的选择。

鸭子类型与高阶函数

最后,让我们讨论这三种数据结构如何通过鸭子类型和面向对象编程风格,实现类似于高阶函数的功能。例如,假设我定义一个方法 foo,它接收一个参数 a

def foo(a)
  a.count { |x| x * x < 50 }
end

当我定义 foo 时,我实际上将 a 视为一个数组,因此我调用了 count 方法。我想计算在这个数组中,有多少个值 x 满足 x 的平方小于50。如果我使用数组 [3, 5, 7, 9] 调用 foo,我会得到3,因为3、5、7的平方都小于50。

但由于范围也有一个 count 方法,它接受一个块并以相同的方式工作,我同样可以使用范围 3..9 调用 foo

puts foo(3..9)  # 输出: 5

这是因为3、4、5、6、7的平方都小于50,而8和9的平方不小于50。这体现了鸭子类型:我可以使用任何具有 count 方法并能正确处理块的对象调用 foo

从函数式编程的角度来看,这体现了高阶函数迭代器的优点:关注点分离。一部分代码可以实现迭代逻辑,知道如何遍历某些数据;另一部分代码可以使用该迭代器计算有用的结果。在这里,foo 是计算有用结果的部分,而数组类和范围类中的 count 方法知道如何迭代。数组的 count 方法可能逐个遍历数组元素,而范围的 count 方法则不同,它并不实际拥有整个数组,而是从下界到上界遍历数字。这与我在ML中强调的关注点分离相同,现在在面向对象编程的上下文中看到。我总是喜欢看到相同的概念在不同的环境中出现,这就是为什么我想在讨论子类化之前向你展示范围。

总结

在本节课中,我们一起学习了Ruby中的哈希和范围。哈希是一种键值对映射,键可以是任何对象,没有自然顺序,常用于需要命名字段的小型数据结构。范围表示连续的数字序列,表示高效,适用于数字序列操作。我们还探讨了如何根据需求选择合适的数据结构,以及鸭子类型和高阶函数如何在这些数据结构中实现关注点分离,提高代码的灵活性和可重用性。

157:子类化 🧬

在本节中,我们将开始讨论子类化。这是面向对象编程的核心内容,也是本课程的一个主要主题。许多同学可能已经在 Java、C#、C++、Python 等语言中接触过子类化。Ruby 中的概念大体相似,但从编程语言的角度进行仔细研究,特别是 Ruby 作为动态语言的特性,会使一些方面有所不同,并且在许多方面更为简单。

子类的概念

子类化的核心思想是:在定义类时,它总是有一个超类。在类定义中,我们使用 < 符号后跟超类名来指定。之前我们省略了这部分,在 Ruby 中,默认情况相当于写了 < Object,即 Object 是 Ruby 语言默认提供的一个类。

超类的作用是:它会影响你定义的类,使其包含超类中的所有内容。这意味着,任何在超类中定义的方法,也会成为你定义类中的方法,但有一个重要的例外——子类可以重写某些方法。

当重写一个方法时,你只需定义一个同名但内容不同的方法。这样,子类就拥有了超类中的所有内容,同时可以根据类定义进行替换和添加。我们称之为从超类继承方法。我们可以从“父类”那里获得方法,然后通过添加新方法和替换部分继承的方法来进行修改。

关于实例变量的说明

如果你在其他语言中见过子类化,可能会好奇字段或实例变量的处理。在 Ruby 中,这并不适用。正如我们所见,一个类的实例拥有哪些实例变量,并不是类定义的一部分。你只需对实例变量进行赋值,它们就会存在。这在任何对象中都会发生,并且在我们引入子类化后也不会改变。对象在创建之初没有实例变量,每次赋值时才会创建它们。

还需要指出的是,在动态类型语言中,子类化与任何类型系统无关(显然我们没有类型系统)。子类化纯粹是关于类中定义了哪些方法,而这正是类定义的全部目的。

示例:点与彩色点

现在,让我们通过一个示例来具体说明。这里有一个表示平面上点的 Point 类,它包含 x 坐标和 y 坐标。

class Point
  attr_accessor :x, :y

  def initialize(a, b)
    @x = a
    @y = b
  end

  def dist_from_origin
    Math.sqrt(@x * @x + @y * @y)
  end

  def dist_from_origin2
    Math.sqrt(self.x * self.x + self.y * self.y)
  end
end

这个类使用 attr_accessor 快捷方式定义了 x 和 y 的 getter 和 setter 方法。initialize 方法接收两个参数并初始化实例变量。dist_from_origin 方法计算点到原点的距离。dist_from_origin2 方法功能相同,但通过调用 self.xself.y 方法(共四次方法调用)来获取坐标,而不是直接读取实例变量。

接下来,我们定义一个 ColorPoint 类,它是 Point 的子类。

class ColorPoint < Point
  attr_accessor :color

  def initialize(x, y, c=:red)
    super(x, y)
    @color = c
  end
end

ColorPoint 拥有 Point 的一切,并添加了两个方法(color 的 getter 和 setter),同时重写了 initialize 方法。新的 initialize 方法期望接收两个或三个参数。它首先使用 super 关键字调用超类(Point)的 initialize 方法来初始化 x 和 y 实例变量,然后设置颜色字段。

super 是 Ruby 中的一个关键字,它表示“调用我重写的那个超类方法”。如果在这里直接写 initialize,将会导致无限递归。

使用与反射

让我们在 IRB 中使用这些类。

p = Point.new(0, 0)
cp = ColorPoint.new(0, 0, :red)

p.x # => 0
cp.x # => 0
cp.color # => :red
p.color # => NoMethodError: undefined method `color'

我们可以询问任何对象的类,以及类的超类。

p.class # => Point
cp.class # => ColorPoint
ColorPoint.superclass # => Point
Point.superclass # => Object
Object.superclass # => BasicObject
BasicObject.superclass # => nil

Ruby 还提供了方法来检查对象的“类型”关系。

p.is_a?(Point) # => true
cp.is_a?(Point) # => true
cp.is_a?(ColorPoint) # => true
cp.is_a?(Object) # => true

cp.instance_of?(Point) # => false
cp.instance_of?(ColorPoint) # => true

is_a? 方法检查对象是否是某个类或其超类的实例。因此,一个 ColorPoint 的实例 is_a? Point 返回 true。而 instance_of? 方法只检查对象的精确类,不包含超类。

风格与语义说明

需要强调的是,在程序中使用 is_a?instance_of? 这类方法通常不符合良好的 OOP 风格,因为这放弃了鸭子类型(duck typing)的优势。你本应基于对象能做什么(即它有哪些方法)来编写代码,而不是基于它“是”什么。

此外,对于来自其他语言(如 Java)的同学,这里可能会有些混淆。Java 中的 instanceof 关键字类似于 Ruby 的 is_a?,而不是 instance_of?。不同语言有时会用相同的词表示相反的概念。

总结

本节课中,我们一起学习了 Ruby 中的子类化。我们了解到:

  • 类通过 < 符号指定其超类,默认超类是 Object
  • 子类继承超类的所有方法,并可以添加新方法或重写继承的方法。
  • 实例变量的存在与子类化无关,它们由对象在运行时赋值决定。
  • 在动态语言中,子类化是关于方法定义的机制。
  • 我们通过 PointColorPoint 的示例演示了继承、方法重写和 super 关键字的使用。
  • 我们探讨了 is_a?instance_of? 方法的区别,前者考虑继承链,后者只检查精确类。
  • 良好的 OOP 实践鼓励基于行为(方法)而非精确类型进行编程。

158:何时使用子类化 🧩

在本节课中,我们将探讨面向对象设计中一个核心问题:何时使用子类化是良好的编程风格。我们将通过一个简单的“点”与“彩色点”的例子,分析子类化的替代方案,并理解各自的优缺点。

概述

子类化是代码复用的强大工具,但初学者和资深开发者都可能过度使用它。我们将审视三种替代子类化的方法,并解释为何在“点”与“彩色点”的例子中,子类化反而是最合适的选择。


回顾:点与彩色点的例子

上一节我们介绍了子类化的基本概念。本节中,我们来看看具体的例子。

我们有一个 Point 类,它包含 xy 坐标的获取器、设置器以及计算到原点距离的方法。我们想要创建一个非常相似的 ColorPoint 类,它拥有 Point 的所有功能,并额外增加一个 color 属性的获取器和设置器,同时需要修改初始化方法来正确设置颜色。

在这个场景中,子类化工作得很好,它方便地复用了 Point 类的代码。然而,在大型面向对象应用中,子类化往往被过度使用。


替代方案一:直接修改基类

既然我们使用的是动态语言,可以在程序任何地方修改类定义,那么第一个替代方案是:不创建 ColorPoint 类,而是直接向 Point 类添加新功能。

以下是具体做法:

  • 进入 Point 类的定义。
  • 添加 colorcolor= 方法。
  • 修改 initialize 方法,为 color 字段提供一个默认参数。

代码示例:

class Point
  # ... 原有的 x, y 方法 ...

  # 新增 color 相关方法
  attr_accessor :color

  # 修改初始化方法
  def initialize(x, y, color="clear")
    @x = x
    @y = y
    @color = color
  end
end

优点:

  • 所有旧的 Point 实例化和方法调用将继续工作。
  • 实现简单。

缺点:

  • 破坏封装:如果 Point 类来自其他库,修改它可能破坏其内部不变性。
  • 强制所有点拥有颜色:即使许多点对象不需要颜色属性。
  • 非模块化修改:如果多人同时为 Point 类添加不同属性(如 Z 坐标、名称等),对象会变得臃肿,且可能引发不可预见的冲突。

因此,虽然这种方法在简单例子中可行,但它不是一种模块化的设计。


替代方案二:复制粘贴代码

如果我们希望将 ColorPoint 的概念与 Point 完全分离,第二种方案是:不依赖 Point 类,而是通过复制粘贴其方法来创建 ColorPoint 类。

以下是具体做法:

  • 定义一个全新的 ColorPoint 类。
  • Point 类中所有需要的方法(如 x, y, distance_from_origin)的代码复制过来。
  • 添加 color 相关的属性和初始化逻辑。

代码示例:

class ColorPoint
  attr_accessor :x, :y, :color

  def initialize(x, y, color="clear")
    @x = x
    @y = y
    @color = color
  end

  # 复制自 Point 类的方法
  def distance_from_origin
    Math.sqrt(@x * @x + @y * @y)
  end
end

优点:

  • 完全解耦PointColorPoint 是两个独立的类。对 Point 的任何修改都不会影响 ColorPoint

缺点:

  • 没有代码复用:如果 distance_from_origin 方法存在 bug,那么这个 bug 也被复制了。
  • 维护困难:如果未来 Point 类增加了新功能,需要手动复制到 ColorPoint 中才能使用。

这体现了编程中典型的权衡:是复用代码,还是复制代码? 我们通常更倾向于代码复用。


替代方案三:使用组合(包含对象)

第三种方案在面向对象编程中常被忽视,即:不使用子类化,而是让 ColorPoint 的实例在其私有状态中包含一个 Point 实例。

以下是具体做法:

  • 定义 ColorPoint 类,其超类为隐式的 Object
  • 添加 color 的获取器和设置器。
  • 添加一个实例变量(例如 @pt)来持有一个 Point 对象。
  • ColorPoint 的方法中,将 xy 等消息“转发”给内部这个 Point 对象。

代码示例:

class ColorPoint
  attr_accessor :color

  def initialize(x, y, color="clear")
    @pt = Point.new(x, y) # 包含一个 Point 对象
    @color = color
  end

  # 转发方法给内部的 Point 对象
  def x
    @pt.x
  end

  def x=(value)
    @pt.x = value
  end

  # ... 类似地定义 y, y=, distance_from_origin ...
end

优点:

  • 封装实现细节ColorPoint 内部包含一个 Point 的事实被隐藏起来。这允许我们灵活改变,例如在 ColorPoint 中将坐标方法改名为 fbar
  • 更符合“有一个”的关系:从概念上讲,彩色点“有一个”点,而不是“是一个”点。

缺点:

  1. 代码复用不便:需要为每个需要转发的方法编写样板代码。
  2. 类型关系断裂:一个 ColorPoint 实例不是一个 Point 实例。Ruby 的 is_a? 方法会返回 false。这在静态类型语言中影响更大,因为 ColorPoint 无法用在需要 Point 类型的地方。即使在 Ruby 这样的动态语言中,对于“彩色点是一种点”这样的概念,这种设计也是次优的。

因此,对于我们的例子,子类化比组合更合适,因为它准确地表达了“ColorPoint 是一种 Point”的“是一个”关系。


总结

本节课中我们一起学习了何时使用子类化,并通过“点”与“彩色点”的例子分析了三种替代方案:

  1. 修改基类:简单但破坏封装,非模块化。
  2. 复制代码:实现完全分离,但牺牲了代码复用和维护性。
  3. 使用组合:封装性好,符合“有一个”关系,但破坏了“是一个”的类型关系,且代码编写不够便利。

核心结论:当新类(ColorPoint)与基类(Point)本质上是 “是一个” 的关系,并且希望继承所有或大部分行为时,子类化是良好且自然的选择。它提供了直接的代码复用和清晰的类型层次。然而,在更复杂的设计中,如果关系更接近 “有一个”,则应优先考虑组合而非继承,以避免过度使用子类化带来的设计僵化问题。

159:方法重写与动态分派

在本节课中,我们将继续学习子类化,重点探讨两个通过有趣方式重写超类方法的子类。第二个例子尤为关键,因为它将运用动态分派这一核心概念,这是面向对象编程的精髓所在。

概述

我们将通过分析两个子类来深入理解方法重写。第一个例子是ThreeDPoint类,它展示了基本的重写机制。第二个例子是PolarPoint类,它将揭示动态分派的强大之处,即继承的方法在调用self时,会根据对象的实际类型(而非定义该方法的类)来执行相应的方法。

1. 三维点类:一个存在争议的例子

上一节我们介绍了基本的子类化概念。本节中,我们来看看一个具体的子类ThreeDPoint,它重写了超类Point的方法以支持三维坐标。

以下是Point类的定义,它包含x和y坐标的获取器、设置器以及两种计算到原点距离的方法。

class Point
  def initialize(x, y)
    @x = x
    @y = y
  end

  def x
    @x
  end
  def y
    @y
  end
  def x=(new_x)
    @x = new_x
  end
  def y=(new_y)
    @y = new_y
  end

  # 方法一:直接访问实例变量
  def distFromOrigin
    Math.sqrt(@x * @x + @y * @y)
  end
  # 方法二:通过自身的获取器方法访问
  def distFromOrigin2
    Math.sqrt(x * x + y * y)
  end
end

现在,我们定义一个ThreeDPoint子类。它添加了z坐标,并需要重写相关方法来处理三维空间的距离计算。

class ThreeDPoint < Point
  def initialize(x, y, z)
    super(x, y) # 调用超类的initialize来设置x和y
    @z = z
  end

  def z
    @z
  end
  def z=(new_z)
    @z = new_z
  end

  # 重写距离计算方法
  def distFromOrigin
    d = super # 调用超类的distFromOrigin计算xy平面距离
    Math.sqrt(d * d + @z * @z)
  end

  def distFromOrigin2
    d = super # 调用超类的distFromOrigin2
    Math.sqrt(d * d + z * z)
  end
end

关于这个设计存在争议。有人认为三维点不是二维点的特化,它们是不同的概念。然而,这个例子清晰地展示了重写的机制:ThreeDPoint的实例继承了Pointxyx=y=方法,重写了initializedistFromOrigindistFromOrigin2,并新增了zz=方法。

2. 极坐标点类:揭示动态分派

前面的例子展示了重写的基本形式。本节中,我们来看看一个更强大的例子PolarPoint,它将揭示面向对象编程的独特特性——动态分派。

PolarPoint使用极坐标(半径r和角度θ)而非直角坐标(x和y)来表示一个点。因此,它需要重写几乎所有方法。

class PolarPoint < Point
  def initialize(r, theta)
    @r = r
    @theta = theta
  end

  # 重写获取器:根据r和θ计算x和y
  def x
    @r * Math.cos(@theta)
  end
  def y
    @r * Math.sin(@theta)
  end

  # 重写设置器:根据新的x或y值重新计算r和θ
  def x=(new_x)
    old_y = y
    @r = Math.sqrt(new_x * new_x + old_y * old_y)
    @theta = Math.atan2(old_y, new_x)
  end
  def y=(new_y)
    old_x = x
    @r = Math.sqrt(old_x * old_x + new_y * new_y)
    @theta = Math.atan2(new_y, old_x)
  end

  # 重写距离计算:在极坐标中,到原点的距离就是半径r
  def distFromOrigin
    @r
  end

  # distFromOrigin2 方法**不需要**重写!
  # 它继承自Point类,但其内部调用self.x和self.y。
  # 当在PolarPoint实例上调用时,self是PolarPoint对象,因此会调用上面重写的x和y方法。
end

以下是关键点:

  1. 内部表示不同PolarPoint的实例变量是@r@theta,而不是@x@y
  2. 必须重写的方法xyx=y=distFromOrigin必须重写,因为超类中的实现依赖于不存在的@x@y变量,或者计算逻辑完全不同。
  3. 无需重写的方法distFromOrigin2 不需要重写。这是本节课的核心。

动态分派的工作原理

让我们仔细分析为什么distFromOrigin2可以正常工作。以下是它在Point类中的定义:

def distFromOrigin2
  Math.sqrt(x * x + y * y) # 注意:这里调用的是`x`和`y`方法,而不是`@x`和`@y`变量
end

当在一个PolarPoint对象(例如pp = PolarPoint.new(4, Math::PI/4))上调用pp.distFromOrigin2时:

  1. 执行继承自PointdistFromOrigin2方法体。
  2. 当执行到xy时,Ruby会查找当前对象(即self,也就是pp这个PolarPoint实例)的xy方法。
  3. 由于PolarPoint重写了这些方法,因此执行的是PolarPoint#xPolarPoint#y,它们根据@r@theta进行计算。
  4. 最终计算出正确的结果(4.0)。

这个过程就是动态分派:方法调用self.x在运行时根据self的实际类型(PolarPoint)来决定执行哪个方法(PolarPoint#x),而不是根据定义distFromOrigin2的类(Point)。

总结

本节课中我们一起学习了方法重写与动态分派。

  • 我们首先通过ThreeDPoint类了解了方法重写的基本语法和目的,即子类修改或扩展超类行为。
  • 然后,我们通过PolarPoint类深入探讨了动态分派这一核心机制。动态分派是指,当一个方法(如distFromOrigin2)内部调用self.another_method时,具体执行哪个another_method接收者对象self)在运行时的实际类型决定。
  • 正是动态分派使得面向对象编程能够实现强大的多态性:我们可以编写通用的代码(如Point类的方法),而这些代码在操作子类对象时,能自动调用子类提供的特定实现。

理解动态分派是理解面向对象编程如何区别于其他编程范式(如仅使用闭包)的关键。在接下来的课程中,我们将更精确地分析方法查找的语义,以巩固对这一概念的理解。

160:方法查找规则详解 🧠

在本节课中,我们将学习面向对象编程语言(如 Ruby)中方法调用的精确查找规则。我们将深入探讨动态分发的概念,并理解其如何使面向对象编程区别于其他编程范式。

概述

上一节我们介绍了动态分发的基本概念。本节中,我们将精确地定义在面向对象语言中,当看到一个方法调用时,如何决定执行哪段代码。这个过程被称为方法查找,它是理解对象如何工作的核心。

动态分发:面向对象编程的核心

动态分发,也称为后期绑定或虚方法,是面向对象编程最独特的特性。它指的是,当我们在一个对象上调用方法时,实际执行的代码可能位于该对象所属类的某个子类中。这是因为子类可以通过重写来替换父类中的方法。

以下是一个简单的代码示例,展示了动态分发的概念:

class C
  def m1
    self.m2
  end
  def m2
    puts "Method in C"
  end
end

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/uwash-cse341-prog-abc/img/d81f6000bff86aa59580258e74157f00_6.png)

class D < C
  def m2
    puts "Method in D"
  end
end

obj = D.new
obj.m1 # 输出 "Method in D"

在这个例子中,obj.m1 调用的是定义在类 C 中的 m1 方法。然而,m1 方法内部调用了 self.m2。由于 self 绑定的是 D 类的实例,因此查找 m2 方法时从 D 类开始,最终执行了 D 类中定义的 m2 方法。

理解查找规则的重要性

编程语言的语义中,查找规则往往是最重要的部分。在 ML 中,我们通过环境来查找变量;在 Racket 中,我们有多种不同的 let 表达式。在 Ruby 中,变量、实例变量、类变量和方法的查找规则各不相同,尤其是它们都与一个特殊的概念——self(当前对象)——紧密相关。

变量与字段的查找

首先,我们来处理相对简单的查找规则。

在 Ruby 中,始终有一个对象被绑定到 self,可以将其视为当前对象或自身对象。当一个方法执行时,它是某个对象的一部分,该对象就是 self

以下是实例变量和类变量的查找方式:

  • 实例变量:当在方法中看到实例变量(如 @x)时,我们在绑定到 self 的对象中查找 @x。这类似于查找记录(record)的字段。如果找到则返回其值,否则返回 nil
  • 类变量:类变量(如 @@x)的查找方式类似,但不是在 self 对象中查找,而是在 self 对象的类中查找。这就是类的所有实例共享类变量的方式。

方法调用的精确语义 🔍

现在,我们来看更复杂、涉及动态分发的方法查找规则。以下是方法调用的完整语义定义。

假设有一个方法调用表达式:e0.m(e1, ..., en)

  1. 求值参数:首先,在严格求值(eager evaluation)的设置下,我们求值这 n+1 个子表达式(e0en)。这将得到 n+1 个对象:obj0e0 的结果)到 objnen 的结果)。这是一个递归定义,如果任何子表达式涉及其他调用,我们会先完成那些调用。
  2. 确定接收者与类obj0 是特殊的,我们称之为方法的接收者。每个对象都有一个类,设 obj0 的类为 C
  3. 查找方法定义:接下来,我们使用类 C 来查找要执行的代码。查找过程遵循以下规则:
    • 如果类 C 自身定义了方法 m,则选择它。
    • 否则,检查 C 的超类。如果超类定义了方法 m,则选择它。
    • 否则,继续检查超类的超类,依此类推,直到继承链的顶端(如 ObjectBasicObject)。
    • 找到的第一个方法定义就是我们要调用的代码。
    • 如果在整个继承链中都找不到方法 m,则转而调用 method_missing 方法,并遵循相同的递归查找过程。如果连 method_missing 都找不到(实际上 Object 类定义了它),则会触发错误,提示“未定义的方法 m”。
  4. 执行方法体:找到方法定义后,我们按如下方式执行其方法体:
    • 方法的形式参数被绑定到对象 obj1objn
    • 关键步骤:在求值方法体时,self 被绑定到接收者对象 obj0

正是这个将 self 绑定到接收者的规则(上述第4步中的蓝色部分),正确地实现了动态分发。这意味着,在方法体 m 中任何使用 self 的地方,都会使用接收者 obj0 的类。如果 obj0 的类是定义 m 的类的子类,那么当方法体调用其他方法(如 m2)时,查找将从 obj0 的类开始,而不是从定义 m 的类开始。

规则应用与复杂性评述

如果将这个精确的定义应用到之前的 PolarPoint 示例中,你会发现规则能给出完全正确的答案。distanceFromOrigin2 方法定义在 Point 类中,但当其方法体执行时,self 绑定的是一个 PolarPoint 实例,因此内部的方法调用会从 PolarPoint 类开始查找。

需要指出的是,方法调用的这个规则比闭包或普通函数的调用规则更复杂。它需要将 self 作为一个特殊实体,以不同于普通变量的方式处理。这构成了更精细、更复杂的语义。但这并不意味着它更好或更差,只是其设计使得面向对象的核心机制——动态分发——得以实现。

静态类型语言中的额外考量(可选)⚙️

对于了解过 Java、C# 或 C++ 等静态类型面向对象语言的学者,本段介绍的基本规则在这些语言中也是正确的。然而,这些语言还有一个额外的复杂性:静态重载

在 Java 和 C++ 中,一个类可以有多个同名方法,只要它们接受的参数数量或类型不同。因此,仅仅因为子类有一个与超类同名的方法,并不一定意味着重写——只有当参数列表(数量和类型)完全相同时才是重写。而在 Ruby 中,只要方法名相同,就是重写。

所以,在静态类型语言中进行方法调用时,不仅需要找到名为 m 的方法,还需要在多个可能的方法中挑选出“最匹配”的一个。这个匹配规则非常复杂,需要考虑参数类型,有时甚至会出现“平局”而导致类型检查错误。这种静态重载在像 Ruby 这样的动态类型语言中没有意义,因此 Ruby 避免了这部分复杂性。

总结

本节课中,我们一起学习了面向对象语言中方法查找的精确规则。我们明确了动态分发的机制:在方法调用时,从接收者对象的类开始,沿继承链向上查找方法定义;执行方法体时,将 self 绑定到接收者对象。这套规则是面向对象编程区分于其他范式的核心。我们还简要对比了动态类型语言(如 Ruby)和静态类型语言(如 Java)在方法重载上的不同处理方式,理解了静态重载带来的额外复杂性。掌握这些规则,对于深入理解面向对象程序的执行流程至关重要。

161:动态分发与闭包对比 🆚

在本节课中,我们将比较动态分发与闭包的工作原理。这两种机制存在根本性差异,揭示了软件设计和编程语言语义中的一个关键权衡。

概述 📋

我们将通过具体的代码示例,展示动态分发(常见于面向对象语言如Ruby)和闭包(常见于函数式语言如ML)在行为上的核心区别。理解这一区别有助于我们在设计软件时做出更明智的选择。

ML中的闭包示例 🔒

上一节我们介绍了课程目标,本节中我们来看看ML语言如何使用闭包实现函数间的相互调用。

以下是一段使用闭包的ML代码。它定义了两个相互递归的函数 evenodd

fun even x = (
    print "in even\n";
    if x = 0 then true else odd (x - 1)
)

and odd x = (
    print "in odd\n";
    if x = 0 then false else even (x - 1)
)

这段代码执行后,如果我们调用 odd 7,控制台会交替打印 “in odd” 和 “in even”,最终返回 true

现在,假设我们在后面重新定义(遮蔽)了 even 函数:

fun even x = (x mod 2) = 0

这个新版本的 even 使用了更高效的算法。然而,当我们再次调用之前定义的 odd 函数时,它的行为不会改变。它仍然会调用最初定义的那个低效的 even 函数。这是因为闭包在创建时就“关闭”了,它捕获了定义时的词法环境,后续的环境变化不会影响已创建的闭包。

Ruby中的动态分发示例 🎭

上一节我们看到了闭包的“封闭”特性,本节中我们来看看Ruby中的动态分发如何表现出完全不同的行为。

以下是类似的代码,但用Ruby的类和方法实现:

class A
  def even(x)
    puts "in even"
    if x == 0
      true
    else
      self.odd(x - 1)
    end
  end

  def odd(x)
    puts "in odd"
    if x == 0
      false
    else
      self.even(x - 1)
    end
  end
end

如果我们创建 A 的实例并调用 odd(7),同样会看到交替打印,并得到正确结果。

现在,我们创建子类并重写(覆盖)even 方法:

class B < A
  def even(x)
    x % 2 == 0
  end
end

当我们创建 B 的实例并调用从父类 A 继承来的 odd 方法时,行为发生了改变。由于动态分发,odd 方法内部对 self.even 的调用会指向子类 B 中重写的新版本 even。因此,计算会立即完成,效率更高。

然而,动态分发也可能带来问题。考虑另一个子类:

class C < A
  def even(x)
    false
  end
end

创建 C 的实例并调用 odd 方法时,动态分发会导致它调用这个总是返回 false 的错误 even 方法,从而得到错误的结果。

核心权衡:封闭性与可扩展性 ⚖️

上一节我们通过代码看到了两种机制的不同表现,本节我们来总结其背后的核心权衡。

这个对比揭示了一个软件设计中的基本权衡:

  • 闭包(词法作用域)提供了封闭性。一旦函数被定义,其行为就固定了,不受后续环境变化的影响。这使得代码更容易推理,你可以孤立地分析一个函数的行为。
  • 动态分发(子类重写)提供了可扩展性。子类可以通过重写父类方法,来改变继承方法的行为,而无需修改父类的原始代码。这促进了代码复用,但同时也使行为依赖于运行时的具体类型,增加了推理的复杂度。

动态分发是面向对象编程中最具新颖性的特性之一。它既是一个强大的工具(允许不修改原始代码即可扩展行为),也可能是一个脆弱的抽象(因为父类方法的正确性依赖于其调用的其他方法不被恶意或意外地重写)。

总结 🎯

本节课中我们一起学习了动态分发与闭包的核心区别。

  • 在ML等语言中,闭包在创建时关闭,其行为由定义时的词法环境决定,后续变化无法影响它,这保证了行为的稳定性和可预测性。
  • 在Ruby等面向对象语言中,动态分发使得方法调用在运行时根据接收者的实际类型来确定。子类重写方法会影响父类中调用该方法的所有逻辑,这既带来了强大的扩展能力,也引入了行为的不确定性和更复杂的推理需求。

理解这一差异,能帮助我们在设计软件时,根据对代码稳定性、可推理性和可扩展性的不同需求,选择合适的语言特性和设计模式。

162:在 Racket 中手动实现动态分派 🧠

在本节课中,我们将学习动态分派的核心概念。我们将通过手动编写 Racket 代码来模拟面向对象编程中的动态分派机制,而不使用 Racket 内置的类和对象功能。这有助于我们深入理解动态分派的语义,并展示如何用一种语言的构造来实现另一种语言的特性。

概述 📋

动态分派是面向对象编程中的一个关键特性,它允许在运行时根据对象的实际类型来决定调用哪个方法。本节中,我们将通过 Racket 代码手动实现这一机制,从而揭示其背后的工作原理。

对象的结构 🏗️

首先,我们需要定义对象的结构。在 Racket 中,我们可以使用结构体(struct)来表示对象。每个对象包含两个部分:字段列表和方法列表。

(struct object (fields methods))

字段列表是一个可变对(mutable pair)的列表,每个对包含字段名和当前值。方法列表是一个不可变对(immutable pair)的列表,每个对包含方法名和一个函数。这个函数接受一个额外的参数 self,用于引用当前对象。

核心操作函数 ⚙️

上一节我们介绍了对象的基本结构,本节中我们来看看如何操作这些对象。以下是三个核心函数:获取字段、设置字段和发送消息。

获取字段(get)

get 函数用于获取对象中指定字段的当前值。

(define (get obj field)
  (let ([pair (assoc field (object-fields obj))])
    (if pair
        (mcdr pair)
        (error "Field not found"))))

设置字段(set)

set 函数用于更新对象中指定字段的值。

(define (set obj field new-val)
  (let ([pair (assoc field (object-fields obj))])
    (if pair
        (set-mcdr! pair new-val)
        (error "Field not found"))))

发送消息(send)

send 函数是动态分派的关键。它调用对象的方法,并将对象本身作为第一个参数(self)传递给方法。

(define (send obj msg . args)
  (let ([pair (assoc msg (object-methods obj))])
    (if pair
        (apply (mcdr pair) obj args)
        (error "Method not found"))))

创建对象 🛠️

现在我们已经定义了操作对象的基本函数,接下来看看如何创建具体的对象。我们将创建一个表示二维点的对象。

以下是创建点对象的函数 make-point

(define (make-point _x _y)
  (object
   (list (mcons 'x _x)
         (mcons 'y _y))
   (list (cons 'get-x (lambda (self) (get self 'x)))
         (cons 'get-y (lambda (self) (get self 'y)))
         (cons 'set-x (lambda (self new-x) (set self 'x new-x)))
         (cons 'set-y (lambda (self new-y) (set self 'y new-y)))
         (cons 'dist-to-origin
               (lambda (self)
                 (sqrt (+ (expt (send self 'get-x) 2)
                          (expt (send self 'get-y) 2))))))))

动态分派与子类化 🔄

动态分派的真正威力在于支持子类化和方法重写。我们可以创建一个 polar-point 对象,它继承自 point 对象,但使用极坐标(半径和角度)而非直角坐标(x 和 y)来表示位置。

以下是创建极坐标点对象的函数 make-polar-point

(define (make-polar-point r theta)
  (let ([base-obj (make-point 0 0)]) ; 基础点对象,字段值不重要
    (object
     (append (list (mcons 'r r)
                   (mcons 'theta theta))
             (object-fields base-obj))
     (append (list (cons 'get-x (lambda (self)
                                  (* (get self 'r)
                                     (cos (get self 'theta)))))
                   (cons 'get-y (lambda (self)
                                  (* (get self 'r)
                                     (sin (get self 'theta)))))
                   (cons 'set-x (lambda (self new-x)
                                  (let* ([y (send self 'get-y)]
                                         [new-r (sqrt (+ (expt new-x 2) (expt y 2)))]
                                         [new-theta (atan y new-x)])
                                    (set self 'r new-r)
                                    (set self 'theta new-theta))))
                   (cons 'set-y (lambda (self new-y)
                                  (let* ([x (send self 'get-x)]
                                         [new-r (sqrt (+ (expt x 2) (expt new-y 2)))]
                                         [new-theta (atan new-y x)])
                                    (set self 'r new-r)
                                    (set self 'theta new-theta)))))
             (object-methods base-obj)))))

关键点在于,我们通过将重写的方法(如 get-x)放在方法列表的前面来实现覆盖。当 send 函数查找方法时,它会找到列表前面的这个版本。同时,dist-to-origin 方法(定义在基础 point 中)会调用 get-xget-y,由于动态分派,它会调用极坐标点对象中重写后的版本,从而正确计算距离。

类型系统的思考 🤔

我们能够在 Racket 中相对轻松地实现动态分派,部分原因在于 Racket 的动态类型系统没有对此进行限制。相比之下,在 ML 这样的静态类型语言中,由于其类型系统(特别是缺乏子类型)的限制,手动编码实现类似的动态分派会非常困难。这就是为什么像 OCaml 和 F# 这样的 ML 家族语言需要将对象作为语言内置的原生特性来支持。

这个例子也说明,有时类型系统可能会阻碍在不同编程范式之间切换,但它们通常能很好地支持其设计目标内的编程风格。

总结 🎯

本节课中我们一起学习了动态分派的核心机制。我们通过手动编写 Racket 代码,使用结构体、字段列表、方法列表以及一个关键的 self 参数,成功地模拟了面向对象编程中的动态分派和简单继承。这个过程揭示了动态分派的本质:方法调用在运行时根据接收消息的对象来决定执行哪个函数体。虽然这个实现效率不高且不包含完整的类系统,但它清晰地阐释了概念,并展示了编程语言语义之间可以相互实现的有趣可能性。

163:面向对象与函数式分解对比 🧩

在本节课中,我们将学习面向对象编程(OOP)与函数式编程(FP)在程序分解上的核心差异。我们将通过一个具体的例子——为一个小型编程语言实现表达式求值、字符串转换和零值检测功能——来展示这两种范式如何以截然相反的方式组织代码。课程结束时,你将理解这两种方法本质上是同一“操作矩阵”的互补视角。


概述:两种分解方式

随着课程接近尾声,我们仍有许多新知识要学习。但我们会越来越多地在对比已知概念的背景下学习它们。本节课程的开始也不例外。事实上,这是本课程的一大亮点。我们将首先探讨面向对象编程和函数式编程如何将程序分解为更易管理的部分。

在函数式语言中编程时,我们通常的做法是将一个程序分解成更小的函数。每个函数对其参数执行某些操作。

在面向对象编程中,我们则倾向于将程序分解为类。特定类的所有方法都对一种数据(即该类所代表的数据)执行操作。

在接下来的几个部分中,我们将理解这两种方法如此截然相反,以至于它们实际上只是看待同一“操作矩阵”的互补视角。我马上会展示这个矩阵。坦率地说,哪种视角更好很大程度上是个人喜好的问题。但正如我们将在下一节看到的,这不仅仅是喜好的问题。如果你认为你的软件可能以特定方式扩展,那么选择就变得重要。之后,我们将探讨面向对象编程在处理涉及多个不同种类参数的操作时遇到的更多困难。我们会按部就班地讲解。这就是我们的路线图,让我们开始吧。


核心示例:表达式语言

为了理解这个矩阵,我们来看一个解释此问题时常用的经典例子。

假设我们有一个小型编程语言的表达式,正是我们在课程早期学习如何编写解释器时考虑的那种语言。实际上,这里涉及两样东西:表达式的不同变体(即不同的构造函数)。例如,可能有整数常量、加法表达式、取负表达式等等。这些是不同种类的数据。

你还有不同的操作。我们关注的操作是 eval(求值表达式)。但你也可以有其他操作,比如 to_string(将表达式转换为字符串),或者 has_zero(遍历算术表达式,如果其中某处语法上存在常量零则返回 true,不求值,只是寻找零)。你可以有任意数量的其他操作。


操作矩阵的概念

如果从变体和操作的角度来思考,你本质上就得到了一个矩阵,一个二维网格。如下图所示,每一行代表一种数据,每一列代表一种操作。我认为,无论你使用何种编程语言来实现这个软件,都必须决定网格中每个方格(即每个数据与操作的组合)的正确行为。例如,如何将整数转换为字符串?如何求值加法表达式?如何检测 has_zero?等等。

因此,你必须填满这个网格。不同的编程风格只是鼓励你以不同的方式填充这个网格。


函数式(过程式)分解

首先,让我们看看函数式或过程式分解。当我们在像 ML 或 Racket 这样的语言中编写代码时(以 ML 为例),我们可以定义一个数据类型,其中每个变体对应一个构造函数。我们的数据类型定义了网格的行。

然后,在函数式分解中,我们为网格的每一列编写一个函数。我们有一个 eval 函数、一个 to_string 函数和一个 has_zero 函数。在这些函数中,我们使用 case 表达式为每一行(即该列中的每个方格)设置一个分支。这就是我们分解程序的方式。如果多个方格可以用相同的方式实现,你可以使用通配符模式之类的东西。但从根本上说,你是按照“为每个操作(即函数)设置各种分支”来分解的。

让我用一个具体的例子来说明,因为我们在接下来的部分会以此为基础。以下是实现此功能的 ML 代码:

(* 定义数据类型,对应网格的行 *)
datatype exp = Int of int
             | Negate of exp
             | Add of exp * exp

(* 第一列:eval 函数 *)
fun eval (e: exp) =
    case e of
        Int i => i
      | Negate e2 => ~ (eval e2)
      | Add(e1, e2) => (eval e1) + (eval e2)

(* 第二列:to_string 函数 *)
fun to_string (e: exp) =
    case e of
        Int i => Int.toString i
      | Negate e2 => "-(" ^ (to_string e2) ^ ")"
      | Add(e1, e2) => "(" ^ (to_string e1) ^ " + " ^ (to_string e2) ^ ")"

(* 第三列:has_zero 函数 *)
fun has_zero (e: exp) =
    case e of
        Int i => i = 0
      | Negate e2 => has_zero e2
      | Add(e1, e2) => (has_zero e1) orelse (has_zero e2)

这就是我的三列,这就是函数式编程。


面向对象分解

现在让我们看看面向对象编程。在 OOP 风格中,我们会定义一个类来描述“表达式”这个概念。在 Ruby 这样的动态类型语言中,你实际上不必这样做,但我会这样呈现,并将 IntAddNegate 视为该超类的子类。你也可以直接将 IntAddNegate 定义为不继承任何其他类(除了 Object)的类。但让我们将其视为一个类 Exp 及其子类 IntAddNegate

然后,为了填充我们的表格,我们为每一行创建一个类。我们有 Int 类、Add 类和 Negate 类。接着,我们通过让 Int 拥有 eval 方法、to_string 方法和 has_zero 方法来填充该行的条目;Add 拥有相同的三个方法;Negate 也拥有相同的三个方法。这些方法定义了“一个取负表达式如何将自己转换为字符串”等逻辑。

因此,我们本质上是在填充完全相同的表格,只是将代码组织成行而不是列。

正如你所想,我已经在 Ruby 文件中实现了这一点。同样,我有一个 Exp 类,在 Ruby 中其实并不需要它。事实上,我甚至为我的语言中的值(目前只有 Int 表达式)设置了一个单独的超类。这类似于你将在作业中做的事情,所以我保留了它,但在这个例子中有些过度设计。

现在,我为每一行创建一个类。这是我的 Int 类。再往下看,这是我的 Negate 类。继续往下,这是我的 Add 类。

在每个类内部,我填充了该行。例如,对于 Int 类,我有一点关于如何初始化整数的内容(有一个实例变量存储底层的整数本身),然后我确实有 evalto_stringhas_zero 方法。

  • eval 一个整数:直接返回对象本身。这等同于函数式编程中 eval 分支里“整数求值为整个整数”的逻辑。这个对象在被调用 eval 时返回 self,即整个对象。
  • to_string:只需在存储数字的底层实例变量上调用 to_s 方法。
  • has_zero:检查 i == 0

我们表格中的这三个条目(Int 情况下的 evalto_stringhas_zero)完全对应。我们只是把它们放在一起,这样我们就可以在一个地方看到 Int 的所有操作。

Negate 类似。对于 eval,我们递归调用 e.eval(这里我实际上调用了 getter 方法,我也可以直接使用实例变量,这涉及到我们见过的动态分派)。然后假设结果是一个 Int(从代码中调用 .i 可以看出),这会给我一个数字,然后创建一个新的 Int 对象。这就是你求值 Negate 的方式,它完全对应函数式代码中 eval 的这种情况。类似地,我有一个将 Negate 转换为字符串的方法,以及判断 Negate 是否包含零的方法(只需递归查看底层表达式是否包含零)。

最后,Add 的工作方式完全相同。我有一个构造函数来初始化两个实例变量以保存子表达式。我执行递归求值:eval 方法递归地对 e1 发送 eval 消息,递归地对 e2 发送 eval 消息,然后将它们组合起来。同样,这里我只是假设结果是 Int,所以我调用 .i 方法(在静态类型语言中不能这样做,但在这里运行良好)并返回一个新的 Int。这完全对应我们 ML 代码中 eval 的加法情况。类似地,我有一个 to_string 方法和一个 has_zero 方法。

这就是 Ruby 代码。我希望你理解,你将在作业中做类似的事情。可选地,我也有 Java 版本。在这里,因为我们是静态类型语言,你的超类 Exp 确实需要指明每个 Exp 拥有哪些方法,但除此之外是相同的事情:我有一个带有 evalto_stringhas_zero 三个方法的 Int 类,一个带有这些方法的 Negate 类,以及一个带有这些方法的 Add 类。如果你对 Java 感兴趣,可以查看这段代码。


对比与总结

对我来说,这是本课程的一大亮点,只有在你以精确和概念性的编程语言方式学习了函数式编程和面向对象编程之后才能体会到。那就是:FP 和 OOP 经常以完全相反的方式做同样的事情。这是一个关于你希望按行还是按列组织程序的问题。

如果这样表述,我认为可以合理地说是个人喜好的问题,也是一个视角问题,即哪种方式对我来说更自然。如果你正在为编程语言编写解释器,函数式编程的分解方式更自然。这是我思考的方式:我正在求值一个表达式,并且为不同种类的表达式设置了不同的分支。如果我正在编写图形用户界面,我通常发现面向对象编程的方法更合适。我的屏幕上有许多不同的图形元素,对于每种图形元素,我希望将所有关于该图形对象的内容(如它如何响应鼠标点击、它有什么颜色、如果我用鼠标拖动它会发生什么)保持在一起。我发现 OOP 分解更自然。

最后,我想说,从这个角度来看,我们实际上是在讨论代码布局的问题。在一个大型程序中,没有完美的方式来布局你的代码,因为软件有太多的结构,程序不同部分之间有太多的联系。但我们在这些编程语言中编写软件的方式是,我们有行、列和文件,我们只有这么多方式来布局代码。这就是为什么现代开发环境提供了许多以不同方式查看代码的功能。事实上,你可以将现代 IDE 视为一种工具:也许你正在用 OOP 语言编写代码,但如果你说“我知道我的代码是按行布局的,但请为我找到所有 Exp 子类的 has_zero 方法”,你本质上是在要求 IDE 为你找到列,即使你的代码是按行布局的。你可以想象一个用于函数式编程语言的 IDE 做完全相反的事情。

编写像我们的表达式语言这类程序,从根本上说是关于填充一个二维网格。我们有两种编程风格以完全相反的方式来完成它。


本节总结

在本节课中,我们一起学习了面向对象编程与函数式编程在分解程序时的核心对立视角。我们通过一个具体的表达式求值示例,展示了函数式编程如何按“操作”(网格的列)来组织代码,而面向对象编程如何按“数据类型”(网格的行)来组织代码。这两种方式本质上是填充同一张“操作矩阵”的互补方法,选择哪种方式取决于具体问题领域和个人(或团队)的偏好,有时也取决于软件未来可能的扩展方向。理解这一根本区别,有助于我们在设计软件时做出更明智的架构选择。

164:添加操作或变体

在本节课中,我们将学习如何在函数式编程和面向对象编程中扩展一个表达式语言。我们将探讨添加新操作(如“移除负常数”)和添加新数据类型变体(如“乘法”)的难易程度,并理解不同编程范式对软件可扩展性的影响。

概述

上一节我们分别使用面向对象和函数式编程实现了一个表达式语言。本节中,我们将考虑如何扩展这个软件,添加新的功能。具体来说,我们将看到在函数式编程中添加新操作很容易,而添加新数据类型变体则较困难;在面向对象编程中,情况则恰恰相反。

函数式编程中的扩展

在函数式编程中,程序通常围绕数据类型进行分解,并为该类型定义各种操作函数。

添加新操作

以下是添加一个名为 noNegConstants 的新操作的步骤。该操作的目标是预处理表达式,将所有负常数替换为对正数取负的形式。

fun noNegConstants (e : exp) : exp =
    case e of
        Int i => if i < 0 then Negate(Int(~i)) else e
      | Negate e1 => Negate(noNegConstants e1)
      | Add(e1, e2) => Add(noNegConstants e1, noNegConstants e2)

添加这个新函数非常简单。它不会影响任何现有的函数(如 evaltoStringhasZero),因为这些函数完全独立。新函数只是对现有数据类型的一个新操作。

添加新变体

现在,考虑添加一个新的数据类型变体,例如 Mult(乘法)。

datatype exp = Int of int
             | Negate of exp
             | Add of exp * exp
             | Mult of exp * exp (* 新增变体 *)

添加这个新变体后,所有处理 exp 类型的现有函数(如 evaltoStringhasZero,以及我们刚添加的 noNegConstants)都会因为模式匹配不再完备而出现编译错误。在静态类型语言中,类型检查器会明确指出哪些函数需要处理新的 Mult 情况。

因此,我们必须返回并修改每一个现有函数,为 Mult 添加相应的处理分支。这使得在函数式编程中添加新变体变得相对困难。

面向对象编程中的扩展

在面向对象编程中,程序通常围绕操作进行分解,每个操作作为一个方法分布在各个类中。

添加新变体

以下是添加一个名为 Mult 的新变体(新类)的步骤。

class Mult
  attr_reader :e1, :e2
  def initialize(e1, e2)
    @e1 = e1
    @e2 = e2
  end
  def eval
    e1.eval * e2.eval
  end
  def toString
    "(#{e1.toString} * #{e2.toString})"
  end
  def hasZero
    e1.hasZero or e2.hasZero
  end
end

添加这个新类非常容易。由于动态分派机制,现有代码(如 Add 类的 eval 方法调用其子表达式的 eval)可以无缝地与新类 Mult 的实例协同工作,无需任何修改。

添加新操作

现在,考虑添加一个新的操作,例如 noNegConstants。我们需要在现有的所有类(IntNegateAdd,以及新加的 Mult)中都添加这个方法。

class Int
  def noNegConstants
    if @i < 0
      Negate.new(Int.new(-@i))
    else
      self
    end
  end
end

class Add
  def noNegConstants
    Add.new(@e1.noNegConstants, @e2.noNegConstants)
  end
end

class Negate
  def noNegConstants
    Negate.new(@e.noNegConstants)
  end
end

# 同样需要在 Mult 类中添加此方法

在像Java这样的静态类型面向对象语言中,如果我们在超类中声明了 noNegConstants 方法,那么类型检查器会强制所有子类实现它,这为我们提供了与函数式编程中类似的“待办事项”列表。

核心对比与总结

本节课中我们一起学习了在两种编程范式下扩展软件的不同特点。

  • 函数式编程:天然支持添加新操作(新函数),但添加新变体(修改数据类型)较为困难。
  • 面向对象编程:天然支持添加新变体(新子类),但添加新操作(新方法)较为困难。

这个区别源于两者最根本的分解方式:函数式编程按数据类型(列)分解,面向对象编程按操作(行)分解。

扩展思考

预测未来的扩展需求非常困难。如果你预期会频繁添加新操作,函数式分解是更自然的选择;如果预期会频繁添加新数据类型,面向对象分解则更合适。

值得注意的是,过度的可扩展性设计可能会增加代码的理解和维护难度。因此,许多编程语言都提供了限制扩展的机制(如ML的模块隐藏、Java的final关键字),以便在需要时提供更强的封装和推理保证。

最后,存在一些设计模式(如函数式编程中的“其他”用例、面向对象编程中的访问者模式)可以预先规划以支持另一种类型的扩展,但这通常会使初始设计更加复杂。

165:带函数式分解的二元方法

在本节课中,我们将学习如何在一个简单的编程语言解释器中实现加法操作。这个加法操作需要处理多种数据类型(整数、字符串、有理数)之间的任意组合,这构成了一个复杂的“二维网格”问题。我们将看到,使用函数式编程的分解方法可以非常清晰、自然地处理这种复杂性。

到目前为止,我们实现这个“操作-数据类型”二维网格的故事比实际情况要简单。在本节中,我想展示一个非常有趣的复杂情况:当你定义的操作需要处理你正在定义的多种数据类型的多个参数时会发生什么。我马上会展示一个例子。在本节中我们将看到,函数式分解能很好地处理这个问题,而面向对象编程风格要么必须放弃OOP,要么需要更复杂的技巧。我将在下一节展示这两种情况。

为了说明这个问题,让我们用两种新的数据类型来扩展我们的语言:字符串和有理数。但更有趣的是,让我们扩展加法操作,使其能在这些类型的任意组合上工作。如果你将整数与整数、整数与有理数、有理数与有理数相加,那就是数学运算。如果是字符串与字符串相加,那就是字符串连接。如果是字符串与某种数字相加,那么我们将数字转换为字符串,然后进行连接。这就是我们语言的定义。

现在,如果我们想这样做,我们就有了另一个二维网格。它不同于我们之前讨论的那个实现乘法的网格。现在有九种情况。这是一个二元操作,有左操作数和右操作数,你需要为整数、字符串和有理数之间的每一种组合编写代码。在我们的求值函数中,我们将递归地求值两个子表达式。我们的语言现在有三种值:数字、字符串和有理数,而加法适用于所有值的组合。因此,我们称加法为二元方法或二元操作,因为它接受两个属于整数、字符串或有理数(或更一般地说,可能的类型)的东西。理论上你可以接受任意数量,但两个已经足够复杂了。

从这里开始,我将主要展示代码,看看我们如何在函数式程序中做到这一点。

这是我的数据类型定义,与之前相比,我添加了字符串和有理数。我们知道,当我们添加字符串和有理数时,必须去修改所有旧的操作。我已经完成了这项工作,在进入重点之前,让我快速展示一下。

这是求值操作。字符串是值,所以直接返回表达式本身。有理数是值,所以直接返回表达式本身。这是转换为字符串的操作。对于字符串,直接返回底层的字符串。有理数有分子和分母,我们用这里的代码将其转换为字符串。

对于判断是否为零,字符串永远不会是零,所以返回假。对于有理数,如果分子是0则返回零。对于处理负常数,记得这是我们预处理的一部分,我们去掉了所有负常数。对于字符串,我们可以直接返回表达式本身,它没有负常数。对于有理数,我继续通过添加适当的取反参数来移除分子或分母中的任何负数,但这真的不是本节的重点。

本节的重点是加法。

在上面求值函数的加法分支中,让我们看看这个加法情况。和往常一样,我想递归地求值E1和递归地求值E2。但现在,我不希望加法在结果不是整数时引发异常。这就是乘法所做的,看看乘法:递归求值,如果它们都是整数,则通过相乘底层数字来构建一个新的整数,否则引发异常。但对于加法,我们决定整数、有理数或字符串(这是求值函数唯一会返回的三种东西)的任意组合都是可能的。现在我们要把这两个东西加在一起。

我为此创建了一个辅助函数,只是为了将其分离出来,并更清楚地表明我们将使用一个函数来定义这个绿色的网格。但你也可以直接将其作为一个嵌套的case表达式来完成。

所以,上面的add_values函数。它接受两个参数,每个参数可能是整数、字符串或有理数,并将它们加在一起。

最自然的做法是对这对参数进行模式匹配,这样我们就可以在(整数,字符串,有理数)与(整数,字符串,有理数)的笛卡尔积中布局九种情况,即我们网格中的九个位置,并说明在每种情况下该做什么。这是嵌套模式匹配的一个很好的应用。类型检查器不知道这些v不可能是某种非值类型的表达式,所以我确实有这个第10种情况,如果v1或v2不是整数、字符串或有理数的某种组合,则引发异常。

现在我们只需分情况处理。如果我有一个整数i和一个整数j,那么我返回整数i + j。这是我们上一节就有的情况。如果我有一个整数和一个字符串,那么创建一个新的字符串,将i转换为字符串后与s连接。继续,一个整数和一个有理数,我们直接构造有理数(i * k + j) / k,这里没有像之前那样进行约分,但它是加法的一个正确有理数值。如果我有一个字符串和一个整数,那么进行适当的连接。如果我有两个字符串,则进行连接。一个字符串和一个有理数,那么以某种方式将有理性转换为字符串,然后将其连接到前面。

这里有一个我想强调的有趣之处。如果你有一个有理数和一个整数,我完全可以在这里写下代码,实际上,我可以直接粘贴下面的代码,假设我没有在这里使用通配符模式,它也能正常工作。但你知道,当你粘贴代码时,通常有更好的方法。我可以为此写一个辅助函数。但要注意的是,有理数加整数与整数加有理数得到的结果相同,这是一个可交换的操作(用数学术语来说)。我在这里所做的,我认为是合理的风格,就是说我已经在相反的顺序中处理过这种情况了,所以让我们递归地调用add_values,参数顺序为(v2, v1)。当你有一个二元操作时,有很多可交换的情况是很常见的。我发现这是减少代码重复量或必须显式处理的情况数量的一个便捷方法。事实证明,在这个add_values函数中,这是我唯一能这样做的情况。但在其他情况下,甚至在你作业中可能看到的东西里,有更多这种情况适用。

最后,如果你有一个有理数和一个字符串,这很像连接一个字符串和一个有理数,但顺序很重要,所以不能简单地将其视为与上一个情况可交换。因此,对于有理数和字符串,我们进行这种连接。最后,两个有理数a/bc/d,这是产生它们相加结果的基本算术运算。

所以这段代码是九种情况的函数式分解,这是我们实现这个绿色网格的方式。我觉得这非常自然,远比我们将在下一节看到的相同网格的面向对象分解要简洁明了。

本节课中,我们一起学习了如何使用函数式分解来处理一个需要支持多种数据类型(整数、字符串、有理数)任意组合的二元操作(加法)。我们看到了如何通过一个辅助函数和嵌套的模式匹配,清晰、无重复地覆盖所有九种情况,并巧妙地利用操作的交换性来减少代码量。这种方法使得处理这种“二维网格”问题变得直观而高效。

166:双分派(Double Dispatch)🚀

在本节课中,我们将学习如何在面向对象编程中实现一个能处理多种数据类型(如整数、字符串和有理数)的加法操作。我们将重点介绍一种称为“双分派”的编程技巧,它允许我们在不违反面向对象原则的情况下,根据两个操作数的类型动态选择正确的加法实现。


概述

在之前的ML代码中,我们通过一个辅助函数 add_values 和嵌套的模式匹配来处理九种不同的加法情况。现在,我们将把相同的功能移植到Ruby代码中。在面向对象编程中,我们不能简单地使用条件分支来检查类型,而需要利用动态分派机制。这就是“双分派”技巧的用武之地。


添加新数据类型

首先,我们需要在Ruby中添加字符串和有理数这两种新的数据类型。Ruby标准库中已有 StringRational 类,但为了教学目的,我们将创建自己的 MyStringMyRational 类,它们都是 Value 类的子类。

MyString 类

class MyString < Value
  attr_reader :s

  def initialize(s)
    @s = s
  end

  def eval
    self
  end

  def to_s
    @s
  end

  def has_zero?
    false
  end
end

MyRational 类

class MyRational < Value
  attr_reader :num, :den

  def initialize(num, den)
    @num = num
    @den = den
  end

  def eval
    self
  end

  def to_s
    "#{@num}/#{@den}"
  end

  def has_zero?
    @num == 0
  end
end

这些方法的定义与ML代码中的模式匹配分支一一对应,每个方法对应一种数据类型的处理逻辑。


实现加法操作

现在,我们来看看如何实现加法操作。在ML代码中,我们调用 add_values 辅助函数,并传入两个子表达式的求值结果。在面向对象风格中,我们不调用辅助函数,而是发送消息(即调用方法)。

Add 类的 eval 方法中,我们递归地对两个子表达式求值,然后调用第一个值的 add_values 方法,并传入第二个值作为参数。

class Add < Expression
  attr_reader :e1, :e2

  def initialize(e1, e2)
    @e1 = e1
    @e2 = e2
  end

  def eval
    e1.eval.add_values(e2.eval)
  end
end

接下来,我们需要在 IntMyStringMyRational 类中实现 add_values 方法。然而,这里会遇到一个问题:add_values 方法需要根据第二个操作数的类型来决定如何执行加法。


双分派技巧

为了解决上述问题,我们引入“双分派”技巧。其核心思想是:不直接询问第二个操作数的类型,而是调用它的一个方法,并告诉它我们自己的类型。

第一步:添加 add_values 方法

首先,在每个值类中定义 add_values 方法。该方法会调用第二个操作数的特定方法,并传入自身作为参数。

class Int < Value
  def add_values(v)
    v.add_int(self)
  end
end

class MyString < Value
  def add_values(v)
    v.add_string(self)
  end
end

class MyRational < Value
  def add_values(v)
    v.add_rational(self)
  end
end

第二步:实现九种加法情况

现在,我们需要在三个值类中分别实现 add_intadd_stringadd_rational 方法。这样,我们总共有九个方法,对应九种加法情况。

Int 类中的实现

class Int < Value
  def add_int(v)
    Int.new(@i + v.i)
  end

  def add_string(v)
    MyString.new(v.s + @i.to_s)
  end

  def add_rational(v)
    MyRational.new(@i * v.den + v.num, v.den)
  end
end

MyString 类中的实现

class MyString < Value
  def add_int(v)
    MyString.new(@s + v.i.to_s)
  end

  def add_string(v)
    MyString.new(@s + v.s)
  end

  def add_rational(v)
    MyString.new(@s + v.to_s)
  end
end

MyRational 类中的实现

class MyRational < Value
  def add_int(v)
    MyRational.new(@num + v.i * @den, @den)
  end

  def add_string(v)
    MyString.new(v.s + self.to_s)
  end

  def add_rational(v)
    common_den = @den * v.den
    new_num = @num * v.den + v.num * @den
    MyRational.new(new_num, common_den)
  end
end


双分派的工作原理

双分派通过两次动态分派来选择正确的加法实现:

  1. 第一次分派:在 Add 类的 eval 方法中,调用 e1.eval.add_values(e2.eval)。根据 e1.eval 的类型,动态选择 IntMyStringMyRational 中的 add_values 方法。
  2. 第二次分派:在 add_values 方法中,调用第二个操作数的特定方法(如 add_intadd_stringadd_rational),并传入自身作为参数。根据第二个操作数的类型,动态选择对应的方法。

这样,我们通过两次动态分派,覆盖了所有九种加法情况,同时保持了纯粹的面向对象风格。


总结

在本节课中,我们一起学习了如何在面向对象编程中实现一个能处理多种数据类型的加法操作。通过引入“双分派”技巧,我们避免了直接检查类型,而是利用动态分派机制,根据两个操作数的类型选择正确的加法实现。虽然这种方法比ML代码中的嵌套模式匹配更复杂,但它展示了面向对象编程中动态分派的强大能力。

双分派不仅帮助我们理解了动态分派的语义,还为处理更复杂的多态操作提供了一种可行的解决方案。在后续课程中,我们将看到其他语言构造如何简化这一过程。

167:可选内容-多方法 🧩

在本节中,我们将学习面向对象编程中的一个概念:多方法。多方法允许我们根据运行时多个参数的类型动态选择要调用的方法,从而避免上一节在Ruby中看到的、需要手动实现的“双重分派”模式。

多方法的基本概念

上一节我们介绍了在Ruby中实现“双重分派”的繁琐方式。本节中我们来看看一种更优雅的解决方案:多方法。

在支持多方法的编程语言中,我们可以为不同的类定义多个同名方法,每个方法指定其参数的类型。当调用方法时,语言会根据运行时所有参数的实际类型,自动选择最匹配的方法版本。

例如,假设我们有三个类:IntMyStringMyRational,它们之间可以任意组合进行加法运算。我们可以为每个类定义三个同名方法 add_values,分别处理不同类型的参数:

# 伪代码示例,展示多方法的结构
class Int
  def add_values(other: Int)      # 处理 Int + Int
  def add_values(other: MyString) # 处理 Int + MyString
  def add_values(other: MyRational) # 处理 Int + MyRational
end

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/uwash-cse341-prog-abc/img/e8cb8cc8d920caff60b9de368e0199ec_3.png)

class MyString
  def add_values(other: Int)      # 处理 MyString + Int
  def add_values(other: MyString) # 处理 MyString + MyString
  def add_values(other: MyRational) # 处理 MyString + MyRational
end

class MyRational
  def add_values(other: Int)      # 处理 MyRational + Int
  def add_values(other: MyString) # 处理 MyRational + MyString
  def add_values(other: MyRational) # 处理 MyRational + MyRational
end

这样,总共会有9个名为 add_values 的方法。当执行 e1.eval.add_values(e2.eval) 时,语言会根据 e1.evale2.eval运行时类型,自动从9个可能性中选择正确的方法来调用。

这种基于多个参数的运行时类型进行动态分派的方式,被称为多分派多方法

多方法的优势与挑战

多方法的核心思想是扩展了传统的动态分派。它不仅基于点号左侧的接收者对象,还基于所有参数的类型来选择方法。

从一方面看,这增强了面向对象编程的动态性。如果动态分派是OOP的特色,那么多方法就是“更多的动态分派”,使其成为一种更纯粹的OOP结构。

然而,多方法也带来了挑战。当它与子类继承结合时,可能会出现歧义:多个同名方法都可能匹配调用,语言必须明确定义选择“最佳”方法的规则,否则程序员会感到困惑。

为什么Ruby不适合多方法?

尽管多方法很巧妙,但它并不适合添加到Ruby语言中。主要有两个原因:

以下是Ruby与多方法不兼容的关键点:

  1. Ruby没有参数类型声明:多方法要求方法定义时指明其参数期望的类。而Ruby是一门动态类型语言,任何对象都可以传递给任何方法,这与多方法的基础机制相悖。
  2. Ruby不允许同名方法共存:在Ruby中,一个类里不能有多个同名方法。如果重复定义,后定义的会覆盖先定义的。子类中定义与父类同名的方法意味着重写。这是一个简单清晰的规则。而多方法恰恰需要多个同名方法共存。

多方法与静态重载的区别

有些同学可能熟悉Java、C#或C++,这些语言允许在同一个类中存在多个同名方法。但这并不是多方法,而是静态重载

以下是静态重载与多方法的核心区别:

  • 静态重载:在编译时根据参数的静态类型(声明时的类型)来决定调用哪个方法。只有接收者(点号左边的对象)使用运行时类型进行动态分派。
  • 多方法:在运行时根据所有参数的运行时类型(实际类型)来决定调用哪个方法。

静态重载虽然方便,但在我们“两个值相加,每个值可能是Int、MyString或MyRational”的例子中并无帮助。它无法根据运行时的实际类型选择方法,我们仍然需要手动实现双重分派。静态重载仅仅允许我们将方法都命名为 add,但这有时反而会增加混淆。

值得一提的是,C# 4.0引入 dynamic 类型后,可以通过将参数转换为 dynamic 来模拟实现多方法的效果,但这是一种特定于C#的解决方案。

多方法本身并不是一个新概念,在Clojure等许多语言中都有内置支持,只是它尚未像其他OOP概念那样成为主流。

总结

本节课中我们一起学习了多方法的概念。多方法是一种基于多个参数的运行时类型进行动态分派的机制,它可以优雅地解决需要根据多个对象类型组合来选择行为的问题(如我们之前的加法运算例子),从而避免手动编写繁琐的双重分派代码。我们了解了它的工作原理、潜在优势以及与子类化可能产生的歧义。同时,我们也分析了多方法为何与Ruby这样的动态语言不兼容,并厘清了它与Java/C#中“静态重载”的重要区别。多方法展示了面向对象设计中分派机制的另一种可能。

168:多重继承 🧬

在本节课中,我们将要学习多重继承这一概念。多重继承允许一个类拥有多个父类,从而继承多个来源的属性和方法。虽然这个概念在某些编程语言中非常有用,但它也带来了一些复杂的语义问题。我们将通过具体的例子来探讨多重继承的用途、潜在问题以及不同语言处理它的方式。

多重继承的概念

上一节我们介绍了继承、重写和动态分派的基础知识。本节中,我们来看看多重继承。多重继承是指一个类可以拥有多个直接父类。这听起来很强大,因为它允许一个类从多个来源组合功能。

C++ 是最著名的支持多重继承的语言。然而,多重继承会引入一些棘手的语义问题,例如当多个父类定义了同名方法或字段时,子类应该如何继承它们。此外,它也使静态类型检查和语言实现变得更加复杂。

多重继承的实用性

在深入探讨问题之前,我们先确认多重继承是否确实有用。答案是肯定的,在某些场景下它非常实用。

以下是一个简单的例子,说明在单一继承语言(如Ruby)中实现某些功能时可能遇到的限制:

class Point
  attr_accessor :x, :y
  def dist_to_origin
    Math.sqrt(@x*@x + @y*@y)
  end
end

class ColorPoint < Point
  attr_accessor :color
  def darken
    # 使颜色变暗的逻辑
  end
end

class ThreeDPoint < Point
  attr_accessor :z
  def dist_to_origin
    Math.sqrt(@x*@x + @y*@y + @z*@z)
  end
end

现在,如果我们想要一个 ColorThreeDPoint 类,它同时拥有颜色属性和三维坐标。在单一继承中,我们无法直接继承 ColorPointThreeDPoint。我们只能选择其中一个作为父类,然后手动复制另一个类的代码,这违反了DRY(不要重复自己)原则。

在支持多重继承的语言中,我们可以这样写:
class ColorThreeDPoint < ColorPoint, ThreeDPoint

另一个常见的例子是 StudentAthlete 类,它可能希望同时继承 Student 类和 Athlete 类(两者可能都继承自 Person 类)。

术语与结构

在讨论多重继承时,区分“直接子类”和“传递子类”很重要。

  • 直接子类:类A在其定义中明确声明类B为其父类。
  • 传递子类:类A通过一系列继承关系(例如 A -> B -> C)成为类C的子类。

在单一继承中,类层次结构形成一棵。每个类最多有一个父类(根节点除外)。

多重继承则使类层次结构变成一个有向无环图。一个类可以有多个父类,因此在图中可能存在多条从一个类到另一个类的路径。

例如,考虑下面的类图:

    X
   / \
  V   W
   \ / \
    Z   ?
     \ /
      Y

类Y是类X的传递子类,但有两条路径:Y -> V -> X 和 Y -> Z -> W -> X。当V和Z都定义了同名方法 m 时,Y应该继承哪一个?这就产生了歧义。

多重继承带来的问题

多重继承主要带来两个层面的问题:方法冲突和字段(实例变量)冲突。

方法冲突

以下是方法冲突的几种情况:

  1. “菱形问题”:如上面的DAG图所示,当多个父类(V和Z)定义了同名方法 m 时,子类Y应该继承哪个版本?
  2. 使用 super:如果Y想重写方法 m 并调用父类版本,它需要指明调用哪个父类的 super
  3. 覆盖链歧义:假设顶级类X定义了方法 m,Z覆盖了它,而V没有。那么通过路径Y->V->X,Y看到的是X的 m;通过路径Y->Z->W->X,Y看到的是Z的 m。Y最终应该采用哪一个?从Y的角度看,V拥有方法 m(继承自X)这一事实是否重要?

字段冲突

字段冲突的问题更为微妙,因为在实践中,有时需要共享字段,有时则需要分离字段。

  • 需要共享字段的例子:在我们的 ColorThreeDPoint 例子中,Point 类定义了 xy 字段。ColorPointThreeDPoint 都继承自 PointColorThreeDPoint 继承自这两者。我们显然只希望 ColorThreeDPoint 对象拥有一套 xy 坐标,而不是从两个父类各继承一套。这对应于“共享实例变量”的语义。

  • 需要分离字段的例子:考虑一个更生动的例子。假设有基类 Person,它有两个子类:

    • Artist(艺术家):有一个 draw 方法,从pocket(口袋)中取出画笔来画画。
    • Cowboy(牛仔):也有一个 draw 方法,但从pocket中拔出武器。
      现在,ArtistCowboy 类多重继承自 ArtistCowboy。他有两种 draw 行为。为了让这两个方法正确工作,ArtistCowboy 实际上需要两个独立的 pocket 字段:一个用于存放画笔(艺术家的口袋),另一个用于存放武器(牛仔的口袋)。这对应于“复制实例变量”的语义。

正因为存在这两种截然不同的合理需求,像C++这样的语言提供了复杂的机制(如虚继承)来让程序员选择哪种语义。然而,这种复杂性也是许多现代语言(如Java、C#)选择不支持传统多重继承,转而提供更简单的替代方案(如接口、混入)的主要原因。

总结

本节课中我们一起学习了多重继承。我们了解到多重继承允许一个类从多个父类继承功能,这在建模像 ColorThreeDPointStudentAthlete 这样的复合概念时非常有用。然而,它也引入了显著的复杂性,核心问题在于解决方法字段从多个继承路径到达子类时产生的冲突。这些冲突有时需要共享语义,有时又需要复制语义,没有统一的完美解决方案。正是由于这些挑战,许多编程语言选择了不支持经典的多重继承,转而采用了限制更多但更可控的代码复用机制。

169:Mixins详解 🧩

在本节课中,我们将要学习Ruby中的Mixins。这是一种优雅的多重继承替代方案,与其他编程语言中的“特质”非常相似。

什么是Mixins? 🤔

上一节我们介绍了Mixins的基本概念,本节中我们来看看它的具体定义。

Mixin是一个方法集合,且仅包含方法。它不是类,无法实例化,也不能通过new调用获取其实例。它只是一组方法定义。

那么如何使用Mixin呢?在类定义中,你可以包含任意数量的Mixins。你仍然可以拥有一个超类,继承其方法,同时也可以包含Mixins,从而获得它们的所有方法。这就像你通过包含Mixin,手动输入了相同的方法定义,从而避免了复制粘贴。实际上,当你包含一个Mixin时,它只是向某个类添加方法定义。

这种方式允许你用这些方法扩展你的类定义。通过包含一个Mixin,你甚至可以覆盖超类中定义的方法。这非常强大。我们将看到的关键原因是,包含在类中的这些Mixin方法可以使用self。它们作为类定义的一部分,可以调用你在类中定义的其他方法,这正是其核心威力所在。

一个简单示例 🔧

现在我们已经了解了Mixins是什么,让我们通过一个简单的例子来看看它的实际应用。

在Ruby中,我们使用module关键字来定义Mixin。模块也支持命名空间管理,但在这里我们只将其视为Mixins使用。

以下是一个Mixin示例,我将其命名为Doubler,它恰好只定义了一个方法:

module Doubler
  def double
    self + self
  end
end

在这个Mixin中,我们没有定义+方法,只定义了double。我们假设任何包含此Mixin的类都会定义+方法,然后我们将调用这个+方法。

我们可以定义一个类,如下所示:

class Point
  attr_accessor :x, :y

  def initialize(x, y)
    @x = x
    @y = y
  end

  def +(other)
    Point.new(@x + other.x, @y + other.y)
  end

  include Doubler
end

通过包含Doubler,这个类现在拥有了double方法。当我们调用它时,该方法会调用+方法。

例如,如果我们创建一个点p = Point.new(3, 4),那么p.double将返回一个新的点,其坐标为(6, 8)

我们也可以将这个Mixin包含到现有的类中,例如String类:

class String
  include Doubler
end

现在,字符串"hello".double将返回"hellohello"。你可以包含任意多的Mixins,它们也可以包含任意多的方法。

方法查找规则与注意事项 ⚠️

了解了Mixins的基本用法后,我们需要谨慎对待方法查找规则,因为现在我们有更多方式向类添加方法。

Ruby有一套明确的规则来查找方法M。如果多个Mixin定义了同名方法,或者Mixin和类都定义了它,该如何处理?以下是规则:

  1. 首先在对象的类中查找。
  2. 然后在该类包含的任何Mixins中查找。
  3. 接着在超类中查找。
  4. 最后在超类包含的任何Mixins中查找,依此类推。

检查顺序是:先类定义,后Mixins。并且,Mixins的检查顺序是后包含的会覆盖先包含的。

关于实例变量,Mixin方法可以获取或设置实例变量,但这些变量属于常规类的一部分。如果来自两个不同Mixin的方法都试图使用同一个实例变量,它们可能会相互干扰。因此,许多人认为Mixin方法以任何方式访问实例变量是一种不良风格。然而,在某些情况下,这可能是你需要或想要做的。从语义上讲,Mixin方法可以访问它们所属对象的实例变量。

强大的内置Mixins:Comparable 🚀

现在我们已经了解了Mixins的基本规则,让我们看看Ruby中广受喜爱的两个内置Mixin。首先是Comparable

Comparable Mixin定义了<>==!=>=<=等方法。它假设任何包含它的类只定义一样东西:三向比较运算符<=>),有时也称为“太空船运算符”。

太空船运算符接受两个参数,如果左边的小于右边的则返回负数,相等则返回0,如果右边的小于左边的则返回正数。

例如,在数字上:3 <=> 4 返回 -13 <=> 3 返回 03 <=> 2 返回 1

实际上,当你在数字上调用3 < 2返回false时,这是由Comparable Mixin通过定义<来调用太空船运算符实现的。它所做的就是定义<来调用<=>,然后检查结果是否小于0。

让我们为自己的类实现这个功能:

class Name
  attr_accessor :first, :middle, :last

  def initialize(first, last, middle)
    @first = first
    @last = last
    @middle = middle
  end

  def <=>(other)
    # 比较逻辑:先比较姓,再比较名,最后比较中间名
    last_cmp = @last <=> other.last
    return last_cmp unless last_cmp == 0

    first_cmp = @first <=> other.first
    return first_cmp unless first_cmp == 0

    @middle <=> other.middle
  end

  include Comparable
end

通过这一行include Comparable,我们免费获得了所有其他比较运算符(==!=<等),因为它们都通过调用我们定义的<=>运算符来实现。这就是Mixins的强大之处。

另一个核心Mixin:Enumerable 🔄

接下来,让我们看看另一个我认为更酷的核心Mixin:Enumerable

Enumerable定义了许多迭代器和高阶方法(如mapselectcount等),它们都以each为基础。作为包含Enumerable的类实现者,你只需要定义each方法,然后就可以免费获得所有其他方法。

我也有一个示例。我定义了自己的小Range类(当然,实际中不需要,因为Ruby内置了完善的Range):

class MyRange
  include Enumerable

  def initialize(low, high)
    @low = low
    @high = high
  end

  def each
    i = @low
    while i <= @high
      yield i
      i += 1
    end
  end
end

因为我定义了each,所以我可以使用所有其他迭代器。例如,count是在Enumerable Mixin中定义的。我可以计算范围内有多少个奇数:

r1 = MyRange.new(3, 7)
r1.count { |x| x.odd? } # 返回 3 (3, 5, 7)

我还可以使用mapany?等方法。它们都由Enumerable定义,并且都基于each实现。Mixin除了知道不断调用each并向其传递代码块外,并不知道如何遍历我的范围元素,然后它们会对结果进行正确处理。

Mixins的适用场景与限制 ⚖️

通过前面的例子,我们看到了Mixins的强大。但最后需要总结的是,它们并非多重继承的完全替代品。

对于像“3D彩色点”这样的例子,Mixins工作得很好。我可以定义一个Color Mixin,然后让ColorPoint继承Point并包含Color,让ColorPoint3D继承Point3D并包含Color。这样一切都能正常工作,ColorPoint3D将拥有我期望的所有方法和行为。因为我只能有一个超类,但可以包含任意数量的Mixins。

然而,对于“艺术家牛仔”这种场景,Mixins就不那么适用了。如果我想要一个“艺术家牛仔”,它应该拥有艺术家的一切和牛仔的一切。但将“艺术家”或“牛仔”定义为Mixin并不合理。Color作为Mixin或许可以,但“艺术家”和“牛仔”都应该真正是类。如果它们都是类,你就无法同时从两者继承。

总结 📝

本节课中我们一起学习了Ruby中的Mixins。我们了解到:

  • Mixins是什么:它们是方法集合,用于向类添加功能,是多继承的一种优雅替代。
  • 如何使用:通过module定义,在类中使用include包含。
  • 方法查找规则:遵循类 -> Mixins -> 超类 -> 超类的Mixins的顺序,后包含的覆盖先包含的。
  • 强大的内置Mixins
    • Comparable:只需定义<=>运算符,即可免费获得全套比较方法。
    • Enumerable:只需定义each方法,即可免费获得大量迭代和高阶方法。
  • 优势与局限:Mixins非常强大和灵活,可以避免代码重复并优雅地扩展类功能。但它们并非多重继承的完全体,在需要从多个“完整类”(而不仅仅是方法集合)继承的场景下存在限制。

总而言之,Ruby的Mixins是一项非常巧妙的功能,极大地提升了代码的复用性和可读性。

170:接口

在本节课中,我们将要学习面向对象编程中的接口概念。我们将通过与之前学过的多重继承和Ruby风格的混入进行比较,来理解接口的作用和特点。虽然接口主要存在于静态类型语言(如Java、C#)中,而Ruby是动态类型语言,但理解接口有助于我们更全面地把握类型系统和代码设计的理念。

上一节我们介绍了多重继承和混入,本节中我们来看看接口。

静态类型与面向对象编程

首先,我们需要退一步思考:静态类型在面向对象编程中意味着什么?我们之前只在ML中接触过静态类型。

静态类型系统的主要目标之一是防止“方法缺失”错误。我们希望利用类型系统来确保,当我们对一个对象调用方法时,该对象确实定义了该方法。这就是面向对象语言类型检查器的主要工作。

在Java和C#这类语言中,其工作原理如下:

  • 每个定义的类都会引入一个类型,就像在ML中添加数据类型绑定会引入类型一样。
  • 声明方法时,需要指定参数类型和返回类型,就像ML中的函数一样。

例如,以下是用Java语法定义的一个类A:

class A {
    Object m1(Example e, String s) { ... }
    int m2(A a, boolean b, int i) { ... }
}

类A定义了两个方法m1m2。我们省略了方法体(用...表示),因为此处我们只关心方法的类型签名。m1接受一个Example类型和一个String类型的参数,返回一个Object类型。m2接受Abooleanint类型的参数,返回一个int

这些语言中子类型化的工作方式很有趣。我们知道子类化:类C可以是类D的子类。在这种情况下,C同时也是D的子类型。实际上,即使是传递性的子类(如我们在多重继承中学到的),子类也仍然是超类的子类型。

类型检查的规则是:你总是可以使用实际所需类型的子类型。因此,如果你有一个A的子类型对象,你可以将它传递给m2的第一个参数。同样,boolean的子类型可以传递给第二个参数,int的子类型可以传递给第三个参数。任何属于子类型的东西,也同时属于超类型,因此可以传递。

什么是接口

现在,我们来谈谈接口。接口也是类型,但它们不是类。每个类引入一个类型,每个接口也引入一个类型。但是,就像Ruby的混入一样,你不能创建接口的实例对象。

与混入不同的是,你也不能在接口中放置方法的具体实现。接口中唯一能放置的是方法的声明,包括其参数类型和返回类型。

例如,以下是一个接口:

interface Example {
    void m1(int x, int y);
    Object m2(Example e, String s);
}

这个Example接口声明了两个方法m1m2。请注意红色高亮的分号,这里没有方法体。你只是在声明:任何具有此类型的对象都拥有这些方法,并且这些方法接受指定的参数类型并返回指定的结果类型。

类如何实现接口

在Ruby中,一个类可以有一个超类,并包含任意数量的混入模块。在Java和C#中,一个类可以有一个超类,并实现任意数量的接口。

如果一个类声明要实现某个接口,那么它必须实现该接口。它必须(通过显式定义或继承)定义该接口所要求的每一个方法,并且必须使用正确的类型。从这个意义上说,实现多个接口没有问题,你只需要提供所有接口要求的所有方法即可。

当一个类定义实现了某个接口后,该类的类型就成为该接口类型的子类型。

例如,如果类A实现了Example接口,类型检查器会确保它确实做到了。那么,在程序的其他地方,任何需要Example类型的地方,我们都可以传递A类型的对象。这为我们提供了灵活性,可以将A的实例视为具有Example类型的对象。

另一个类B也可以实现Example接口。那么,在像m2方法体这样的代码中(它接受一个Example类型的参数),它并不知道接收到的是A的实例、B的实例还是任何其他实现了Example接口的类的实例。但它确实知道,无论绑定到变量e的是什么,它都拥有Example接口所要求的所有方法,并且这些方法接受正确类型的参数。

接口的作用与局限

接口不提供方法的具体实现,也不提供字段。因此,我们在多重继承中遇到的所有复杂性问题都与接口无关。接口不“给予”你任何东西。如果一个类实现了一个接口,它带给你的只是更多的义务——你必须完成的事情。

接口的作用是提供一个更灵活的类型系统。你现在可以定义一些方法,这些方法接受某个接口类型I的参数,然后向该方法传递任何实现了该接口的对象。你也可以拥有字段(类似于Ruby的实例变量),其类型是接口而不是具体的类。接口是一种类型,它告诉你属于该类型的东西拥有哪些方法。

这里发生的情况是:拥有接口的Java,其类型系统比没有接口的Java灵活得多。它允许我们让程序中两个不同的类实现同一个接口,然后我们就可以将任何一个类的实例传递到任何只需要该接口类型的地方。因此,接口为Java提供了很大的类型系统灵活性。

但这确实是它们提供的全部。因此,在像Ruby这样的动态类型语言中,你永远不会看到接口。我从不期望它们被添加进来,因为你没有需要使其灵活的类型系统。你已经拥有了一种动态类型语言,它甚至比拥有接口的Java还要灵活得多。

动态类型与静态类型是我们研究过的内容。我们知道动态类型更灵活。静态类型在许多方面灵活性较低,但具有其自身的优缺点。接口的核心思想是:从一个静态类型的面向对象语言出发,然后使类型系统更强大,同时仍然远不如Ruby这样的动态类型语言灵活。

总结

本节课中我们一起学习了面向对象编程中的接口概念。我们了解到接口是一种类型,用于在静态类型语言(如Java、C#)中声明对象必须实现的方法签名,而不提供具体实现。类可以实现多个接口,从而成为这些接口类型的子类型,这增加了代码的灵活性和复用性。然而,接口本身不包含实现代码,也不解决多重继承中的字段和方法冲突问题。在动态类型语言如Ruby中,由于类型系统本身非常灵活,接口的概念并不适用。理解接口有助于我们对比不同编程范式的设计思想。

171:可选抽象方法 🧩

在本节课中,我们将要学习面向对象编程中的一个高级概念:抽象方法(在Java和C#中称为抽象方法,在C++中称为纯虚方法)。我们将探讨为何静态类型语言需要这一特性,它如何帮助我们在编译时捕获错误,以及它与高阶函数之间的有趣联系。最后,我们还会解释为何像C++这样的语言不需要单独的“接口”概念。


抽象方法的核心概念

上一节我们介绍了动态类型语言(如Ruby)中方法重写的灵活性。本节中我们来看看在静态类型语言中,如何强制子类必须实现某些方法。

在面向对象设计中,经常会出现这样的情况:一个超类包含了许多对多个子类都有用的通用代码和实例变量,但其中某些部分(例如,图形对象的大小)没有合理的默认值。超类希望并要求所有子类必须覆盖这些方法。

在Ruby这样的动态语言中,我们只能通过注释来提醒程序员。但在静态类型语言(如Java、C#、C++)中,我们希望类型检查器能在编译时就确保子类实现了必要的方法,从而避免运行时错误。

这就是抽象方法(或纯虚方法)的设计初衷。它允许我们在超类中声明一个方法的类型签名(返回类型和参数类型),但不提供具体实现。这会产生两个效果:

  1. 无法创建该超类的实例。
  2. 任何可被实例化的子类都必须按照声明的签名实现该方法。

以下是在Java中声明抽象方法的示例:

abstract class A {
    abstract int m2(int x); // 只有声明,没有方法体
    void m1() {
        int result = this.m2(5); // 调用抽象方法
    }
}

通过这种方式,我们获得了额外的编译时检查,可以在程序运行前就捕获“忘记实现必要方法”这类错误。抽象方法本身并没有增加语言的表达能力,它主要是一种提升代码安全性和可读性的工具。


抽象方法与高阶函数的联系

抽象方法和高阶函数表面上看似乎关联不大,但它们本质上都是向其他代码传递代码的机制。

  • 抽象方法是面向对象的方式。超类中的方法(如 m1)调用了一个它自己未定义的方法(m2)。具体的代码由子类通过重写 m2 来提供,并通过动态派发机制执行。
  • 高阶函数是函数式编程的方式。一个函数(如 F)接收另一个函数(G)作为参数。F 并不知道 G 的具体实现,具体的代码由调用者传入的函数来提供。

它们的共同点是:通用逻辑(在 m1F 中)与可变部分m2G 的实现)分离。在OOP中,可变部分由子类提供;在FP中,则由调用者提供。这两种模式都实现了代码复用与定制的解耦。


为何C++没有“接口”

最后,关于抽象方法还有一个重要的推论:它解释了为什么C++语言没有单独的“接口”(Interface)概念。

接口的主要作用是定义一个必须实现的方法集合,而不提供任何具体实现。在只支持单继承的语言(如Java)中,接口是让一个类拥有多种“类型”的关键机制。

然而,C++支持多重继承。如果一个类中的所有方法都是纯虚方法(即抽象方法),那么这个类本身不包含任何可执行代码,其作用就完全等同于一个接口。其他类可以通过继承这个“全抽象”的类来承诺实现所有方法,从而获得相应的类型。

因此,接口这种语言特性通常只出现在同时满足“静态类型检查”和“只支持单继承”这两个条件的语言中。这就是为什么你在动态类型的Ruby中没有接口,在支持多重继承的C++中也不需要专门的接口语法。


本节课中我们一起学习了抽象方法(纯虚方法)的概念。我们明白了它是静态类型OOP语言用于强制子类实现特定方法、并在编译时进行检查的机制。我们看到了它与高阶函数在“代码传递”思想上的相似之处。最后,我们也理解了在支持多重继承的语言中,抽象类可以完全替代接口的角色。掌握这些概念有助于你更深入地理解不同编程范式的设计思想与实现方式。

172:子类型化入门 🧩

在本节课中,我们将学习子类型化。这是我们首次系统性地探讨这一主题,它也是本课程最后一个主要知识点,将为我们构建编程语言的核心思想奠定最终基础。

我们将全程使用伪代码进行讲解,仅通过幻灯片展示。这是因为我们没有时间引入另一门编程语言,并且即使有时间,实际编程语言中的子类型化实现也远比其核心思想复杂。因此,我们将首先通过幻灯片理解子类型化的核心概念,之后再将其关联到一些重要问题上,例如静态类型面向对象编程语言如何使用子类型化,子类型化如何与我们之前在 ML 中见过的泛型和多态性相关联,以及它们如何互补。事实上,在最后,我们还将看到如何将它们结合,获得超越简单相加的效果。

我们将从头开始,逐步构建所有概念。首先,我们将通过想象一个小型语言来涵盖大部分思想,这个语言将包含函数、加法、算术等。但最关键的是,它将包含记录。记录类似于 ML 中的记录,具有字段和内容。它们很像对象,但只包含实例变量,不包含方法。并且,我们将使这些字段可变。

记录语法与语义 📝

我们需要定义自己的语法,因为我们学过的语言都不完全适用。ML 有记录,但没有子类型化,也没有可变字段。Ruby 是动态类型语言,而本节内容关乎静态类型。因此,我们将设计一种类似 ML 但使用点号访问语法的伪代码语言。

以下是关于记录的三种结构:

1. 记录创建
语法:{f1 = e1, f2 = e2, ..., fn = en}
语义:依次求值每个表达式 e1en,返回一个记录值,其字段 f1fn 分别持有对应表达式的求值结果。

2. 字段访问
语法:e.f
语义:求值表达式 e 得到一个记录值 v,假设该记录拥有字段 f,则检索该字段的内容。如果 e 不是记录或没有字段 f,则会发生错误。我们的类型系统将确保类型检查成功时,这种情况永远不会发生。

3. 字段更新
语法:e1.f = e2
语义:求值 e1 得到一个记录(该记录应拥有字段 f),求值 e2 得到一个值,然后将 e1f 字段内容更新为 e2 的结果。

记录类型与类型规则 🔍

现在,我们为记录定义一种特殊的类型,并给出相应的类型检查规则。

记录类型
语法:{f1: t1, f2: t2, ..., fn: tn}
含义:该类型描述了拥有字段 f1(类型为 t1)、f2(类型为 t2)……直到 fn(类型为 tn)的记录。

以下是三个表达式的类型检查规则:

1. 记录创建的类型规则
如果 e1 具有类型 t1e2 具有类型 t2,……,en 具有类型 tn,那么记录创建表达式 {f1 = e1, f2 = e2, ..., fn = en} 就具有类型 {f1: t1, f2: t2, ..., fn: tn}

2. 字段访问的类型规则
如果表达式 e 具有记录类型 {..., f: t, ...}(即该类型包含字段 f,其类型为 t),那么字段访问表达式 e.f 就具有类型 t

3. 字段更新的类型规则
如果表达式 e1 具有记录类型 {..., f: t, ...},并且表达式 e2 具有类型 t,那么字段更新表达式 e1.f = e2 就具有类型 t(通常返回 e2 的值或 unit 类型,此处简化处理)。

示例与子类型化的动机 💡

让我们通过一个示例来整合以上概念,并引出子类型化的需求。

假设我们有一个函数 distance_from_origin,它接受一个点作为参数:

fun distance_from_origin(p: {x: real, y: real}) -> real {
    return math.sqrt(p.x * p.x + p.y * p.y)
}

我们可以这样调用它:

let point = {x = 3.0, y = 4.0} // 类型为 {x: real, y: real}
distance_from_origin(point) // 返回 5.0

这一切都能正常进行类型检查。

现在,考虑一个略有不同的情况。假设我们有一个颜色点 c

let c = {x = 3.0, y = 4.0, color = "green"} // 类型为 {x: real, y: real, color: string}

我们想用 c 作为参数调用 distance_from_origin 函数:

distance_from_origin(c)

在目前描述的语言规则下,这个调用不应该通过类型检查。因为函数期望的参数类型是 {x: real, y: real},而我们传递的是 {x: real, y: real, color: string}。它们是不同的类型。

然而,我们希望这个调用能够通过类型检查。因为从逻辑上讲,c 拥有函数所需的所有字段(xy),额外的 color 字段并不会影响 distance_from_origin 函数的执行。函数只是忽略了它。

这就引出了子类型化的核心思想:我们希望类型系统更加灵活,允许一个拥有“更多字段”的记录类型,在需要“较少字段”的地方被使用。

引入子类型化 🚀

子类型化提供了一种优雅的方式来实现这种灵活性。其基本思想是:如果某个表达式具有记录类型 {f1: t1, f2: t2, ..., fn: tn, ...}(即包含字段 f1fn 以及其他可能字段),那么它也应该可以被视为具有类型 {f1: t1, f2: t2, ..., fn: tn}(即“忘记”了一些额外字段)。

应用到我们的例子中:

  • 类型 {x: real, y: real, color: string} 应该是类型 {x: real, y: real}子类型
  • 这意味着,任何类型为 {x: real, y: real, color: string} 的值(如变量 c),都可以安全地用在任何期望类型为 {x: real, y: real} 的地方(如函数 distance_from_origin 的参数)。

这不仅解决了我们最初的问题,还带来了更广泛的灵活性。例如,一个只要求参数具有 {color: string} 类型的函数,也可以接受我们的颜色点 c,因为 c 也包含 color 字段。

总结 📚

本节课中,我们一起学习了子类型化的入门知识。我们首先定义了一个包含可变记录的小型伪代码语言,并阐述了其语法、语义和类型规则。接着,我们通过一个具体的代码示例,发现了现有类型系统的局限性:它无法处理“拥有额外信息的记录在忽略这些信息时依然安全可用”的情况。这自然引出了对子类型化的需求。子类型化的核心思想是允许一个类型(子类型)在需要其父类型的地方被使用,对于记录而言,通常意味着子类型拥有父类型的所有字段(可能还有更多),从而保证了类型安全下的灵活性。在接下来的课程中,我们将深入探讨如何形式化地定义和使用子类型关系。

173:子类型关系

在本节课中,我们将为子类型化提供一个非常精确的定义。我们将以一种方式来实现它,这种方式能为我们的类型系统增加极大的灵活性,而无需修改编程语言中已有的任何类型规则,也无需修改课程早期学到的规则。

概述

在上一节中,我们遇到了一个情况:我们希望传递一个包含三个字段的记录给一个只需要其中两个字段的函数,但现有的类型规则不允许这样做。本节中,我们将通过引入子类型关系来解决这个问题,同时确保类型系统的“可靠性”不被破坏。我们将定义子类型关系,并仅向语言中添加一条新的类型规则。

子类型关系与单一规则

编程语言已经拥有许多复杂而精密的类型规则。如果为了支持传递一个包含额外字段的记录,而需要修改所有这些规则,那将非常繁琐。

我们已有的类型规则很好,例如,当你向函数传递参数时,该参数的类型应等于函数参数的类型。这条规则简单明了,易于实现。然而,正是这条规则在上节中阻止了我们传递一个无害的、我们想要传递的值。

因此,我们现在将通过仅向语言中添加两样东西来修复这个问题。

首先,我们将添加一个独立于其他概念的“子类型”概念。在幻灯片上,我将写作 T1 <: T2 来表示 T1 是 T2 的子类型。这不必是语言中的语法,它只是两个类型之间的二元关系。给定两个类型 T1 和 T2,可能一个是另一个的子类型,也可能不是。我们需要像定义整数上的“小于”关系一样,来定义这个关系。

一旦我们有了这个被称为子类型关系的关系,我们对编程语言只需要添加一条规则。这条规则用蓝色标出,它表示:如果某个表达式 e 具有类型 T1,并且 T1T2 的子类型,那么 e 也具有类型 T2。这是我们唯一需要添加的规则,一切都会随之正常工作。

例如,如果 {x: real, y: real, color: string}{x: real, y: real} 的子类型,那么任何具有三个字段类型的值也就具有两个字段的类型。既然它也具有那个超类型,我们就能够将它传递给期望超类型的函数。这样,我们就非常清晰地将概念分离开了。

确保类型系统的可靠性

我们需要谨慎地处理此事。一个常见的误解是,如果你在创造一门新语言,你可以为所欲为。但正如我们在课程中反复学到的,虽然语言设计有很大的灵活性,但如果做错了,语言就不会按你预期的方式工作。例如,动态作用域对于闭包来说就是个糟糕的主意。

类型和子类型规则也是如此,你不能随意使用任何规则,至少在你希望类型系统真正实现其目的时不能。当我们学习静态类型时,我们了解到类型系统的目的是防止语言中的某些操作。对于子类型,其目的是确保永远不会出现一个程序通过了类型检查,但在运行时却试图访问一个记录中不存在的字段。

事实证明,仅凭上一节的类型规则,我们已经拥有了这个属性——我们的系统在防止错误的记录字段访问方面是“可靠”的。现在,当我们添加子类型时,我们希望确保保持这种可靠性。如果我们定义的子类型关系破坏了类型系统的可靠性,那我们就做错了。编程语言领域有句俗语:子类型不是观点问题。你的子类型规则要么破坏了可靠性,要么没有。对我们来说,重要的是它们不能破坏可靠性。

确保不破坏可靠性的关键推理在于“可替换性”原则。如果 T1T2 的子类型,那么我们要求任何 T1 类型的值,都能在所有 T2 类型的值可以使用的地方使用。这对于我们那个包含更多字段的记录例子是成立的。对于超类型 {x: real, y: real},你除了传递它之外,唯一能做的就是获取 x 字段、获取 y 字段、设置 x 字段、设置 y 字段。所有这些操作都可以在 {x: real, y: real, color: string} 类型的值上完成。因此,可替换性原则成立。这就是为什么它是合法的,也是为什么它可以被添加到我们的子类型关系中。

初步定义子类型关系

现在,让我们来初步定义子类型关系。以下规则用于说明一个类型 T1 可以“小于”另一个类型 T2

以下是构成我们初步子类型关系的规则:

  1. 宽度子类型:这是我们已经讨论过的规则。如果一个更“宽”的记录(拥有更多字段)包含了另一个更“瘦”的记录的所有字段名和对应类型,那么这个宽记录就是瘦记录的子类型。本质上,你可以从宽记录中丢弃一些字段。
  2. 排列子类型:这是另一个你可能没想到的规则。假设有两个记录,它们拥有相同的字段和相同的类型,但书写顺序不同。顺序并不重要。因此,我们应该允许一个记录类型成为另一个仅仅重新排列了字段顺序的记录类型的子类型。这显然符合可替换性测试,因为你仍然可以获取和设置所有相同的字段。如果不将此规则纳入子类型关系,许多你希望类型检查通过的程序将无法通过。
  3. 传递性:如果 T1T2 的子类型,且 T2T3 的子类型,那么 T1T3 的子类型。这直接由可替换性推导而来。如果 T1 的值可以当作 T2 的值使用,而 T2 的值可以当作 T3 的值使用,那么 T1 的值也应该可以当作 T3 的值使用。拥有这条规则很好,这样我们其他更有趣的规则就不必一次性涵盖所有情况。
  4. 自反性:这是一个来自离散数学或逻辑学的术语。每个类型都是其自身的子类型,即对于任何类型 TT <: T。目前我们可能并不急需这条规则,但当我们看到其他子类型规则时,特别是即将讨论的函数子类型规则,它将有助于简化其他规则。

总结

本节课我们一起学习了子类型化的核心概念。我们引入了子类型关系 <:,并仅向类型系统中添加了一条新规则:如果 e 具有类型 T1T1 <: T2,则 e 也具有类型 T2

我们初步定义了子类型关系,包含四条规则:

  • 宽度子类型:允许丢弃记录中的字段。
  • 排列子类型:允许改变记录字段的顺序。
  • 传递性:子类型关系可以链式传递。
  • 自反性:每个类型都是自身的子类型。

这些规则共同工作,在保持类型系统可靠性的前提下,极大地增加了代码的灵活性。在接下来的课程中,我们将尝试添加更多规则,并检验是否能以可靠的方式实现。

174:深度子类型化

在本节课中,我们将要学习子类型化关系中的第五个规则——深度子类型化。我们将探讨这个规则的含义,并通过一个具体例子分析为什么在某些情况下引入这个规则可能不是一个好主意。

概述

上一节我们介绍了子类型化关系的四个规则,它们允许我们对记录类型进行字段删除和重新排序。本节中,我们来看看一个潜在的第五个规则,并分析其可能带来的问题。

深度子类型化规则

目前,我们的子类型化规则允许我们获取一个记录类型,删除其中一些字段,并对剩余字段进行重新排序。这是当前规则的全部功能。

以下是一个当前规则无法处理的例子。在这个例子中,我们将使用一些表示圆的记录。一个圆可以通过其圆心和半径来表示。如果圆心使用一个嵌套记录来表示,该记录包含点的X坐标和Y坐标,那么记录类型可能如下所示:

{center: {x: real, y: real}, r: real}

函数 circleY 的参数 c 具有上述类型。这个函数非常简单,它接收一个圆并返回其Y坐标。实现方式是 c.center.y。给定 c 的类型,这个操作永远不会失败。

然而,使用当前的规则,我们似乎无法用以下“球体”值来调用 circleY 函数:

{center: {x: 3.0, y: 4.0, z: 0.0}, r: 1.0}

球体同样具有圆心和半径,但它存在于三维环境中。因此,其圆心字段内的记录包含 xyz 字段。

如果没有类型系统,调用 circleY(sphere) 会正常工作并返回 4.0。但在我们的类型系统中,这只有在 sphere 的类型是 c 的类型的子类型时才有效。我们需要的是以下类型关系成立:

{center: {x: real, y: real, z: real}, r: real} <: {center: {x: real, y: real}, r: real}

宽度子类型化只允许删除记录的整体字段(例如删除 rcenter 字段),但不允许进入一个字段内部并在那里使用子类型化。

因此,我们似乎遗漏了一条规则。让我们添加第五个子类型化规则。这条规则被称为深度子类型化,它更为复杂,因为它包含一个“如果-那么”结构。

深度子类型化规则公式
如果 TA <: TB,那么记录类型 {..., f: TA, ...}{..., f: TB, ...} 的子类型。

这条规则允许我们进入记录类型的某个字段,并将其类型 TA 替换为其子类型 TB。结合宽度子类型化规则,我们现在可以获得球体记录和圆记录之间所需的子类型关系。我们可以使用深度规则进入 center 字段的类型内部,然后使用宽度规则删除该内部记录类型中的 z 字段。

深度子类型化通过允许在记录字段上使用其他规则,使我们的类型系统更强大。这足以让上面的例子中,将 sphere 传递给 circleY 成为可能。这听起来很棒,我们应该把它加入我们的语言,对吗?

深度子类型化的问题

不对。我们的新子类型化规则让期望的例子能通过类型检查固然很好,但在评估一条规则是否是好主意时,不能只看它让哪些程序通过,还必须确保所有不希望出现的程序仍然被禁止。

如果让类型系统更灵活会破坏其健全性——即允许访问不存在的记录字段(例如尝试从一个没有 foo 字段的记录中读取 foo,或从一个没有 z 字段的记录中读取 z),那么这种灵活性就不值得。不幸的是,刚刚展示的深度子类型化规则是不健全的。要证明这一点,只需展示一个例子:在该规则下,程序能通过类型检查,但运行时会发生我们试图预防的坏事。

以下是一个涉及可变性的例子。这次,我们不用 circleY,而是用一个函数 setToOrigin。它接收一个与 circleY 参数类型完全相同的记录 c,并将其圆心移动到原点。它保持半径不变,但将 c.center 更新为 {x: 0.0, y: 0.0}。这是一个合理的操作,因为我们允许记录字段是可变的。

那么,如果我使用我的 sphere 调用这个 setToOrigin 函数会发生什么?它会改变那个球体。实际上,它会以一种使其不再是球体的方式进行改变:它将 center 字段更改为一个不包含 z 字段的记录。

这是一个非常糟糕的情况。在调用 setToOrigin(sphere) 之后,我赋予 sphere 的类型从根本上就是错误的。该类型声称在 center 字段中是一个包含 z 字段的记录,但现在已经不是了。因此,在下一行代码 sphere.center.z 中,虽然它能通过类型检查(因为 sphere 的类型声称它有 z 字段),但实际运行程序时会在此处卡住,因为由于之前调用了 setToOrigin,从 sphere.center 返回的记录中已经没有 z 字段了。

唯一新增的、应该为此负责的规则就是深度子类型化规则。你不能允许记录字段的类型改变为其超类型(将 TA 变为 TB),因为这会让像 setToOrigin 这样的函数将球体变成圆。

总结与启示

故事的寓意是:如果你使用的语言包含记录(或对象,因为对象就是包含一堆字段的东西,比如带有getter和setter的实例变量),那么允许在记录字段上进行子类型化的深度子类型化不健全的。

许多人忘记、从未学过或从未意识到这一点。他们认为语言应该支持这个,但实际上并不支持。如果你有getter和setter,你就不能允许记录中字段的类型改变为其超类型(或子类型)。

但这里有一个巧妙之处:如果字段是不可变的,即采用更函数式的方法,不允许在记录上使用setter,那么深度子类型化实际上是健全的。这是取缔可变性带来的又一个好处。

事实证明,对于记录,你可以在以下三项中选择任意两项:

  1. 允许setter(可变性)。
  2. 允许深度子类型化。
  3. 拥有健全的类型系统。

你可以拥有这三项中的两项,但正如我们在前面的例子中看到的,你不能同时拥有所有三项。因此,如果你想要健全性和深度子类型化,为什么不放弃setter呢?当然,如果你想选择列表中的另外两项组合,那可能是个人的偏好问题。

无论如何,子类型化不是主观意见的问题。如果你确实有setter并且确实想要健全性,那么你就不能拥有我们在本节中展示的深度子类型化规则。

本节课中,我们一起学习了深度子类型化的概念、其潜在的用途,以及当语言支持可变字段时引入该规则会导致类型系统不健全的关键问题。我们看到了一个具体的反例,并理解了在可变性、深度子类型化和类型系统健全性之间只能三者取二的权衡关系。

175:Java与C#中的数组深度子类型化问题 🧩

在本节中,我们将继续探讨深度子类型化,重点分析Java和C#在数组处理上的设计选择。我们将解释为何这种设计在理论上存在问题,以及语言如何通过运行时检查来维持类型安全。

概述

上一节我们介绍了深度子类型化的基本概念。本节中,我们将以Java和C#的数组为例,说明它们如何允许一种理论上不安全的深度子类型化,以及它们如何通过动态检查来弥补这一缺陷,从而保证程序的最终安全性。

数组的深度子类型化问题

在深度子类型化中,记录(或对象)类型遵循一个规则:如果类型T1T2的子类型,那么T1的记录类型也可以是T2的记录类型的子类型。人们可能会自然地认为数组也遵循类似的规则。

公式:如果 T1 <: T2,那么 T1[] 应该是 T2[] 的子类型吗?

实际上,Java和C#允许这种数组子类型化,但从理论角度看,这是不正确的。以下是一个示例,展示了这种设计可能引发的问题。

假设我们有一个类Point,它包含xy字段。另一个类ColorPoint继承自Point,并额外添加了一个color字段。

class Point { int x; int y; }
class ColorPoint extends Point { String color; }

现在,考虑一个接收Point数组的方法:

void m1(Point[] arr) {
    arr[0] = new Point(); // 尝试向数组中存入一个Point对象
}

以下是使用该方法的方式:

ColorPoint[] cparr = new ColorPoint[10];
// 初始化数组,每个元素都是ColorPoint
for (int i=0; i < cparr.length; i++) {
    cparr[i] = new ColorPoint();
}
// 将ColorPoint数组传递给期望Point数组的方法
m1(cparr);
// 尝试访问color字段,但索引0的元素已被替换为Point
cparr[0].color; // 这里会出问题!

这段代码类型检查会通过,但逻辑上存在问题。方法m1接收了一个声明为Point[]但实际是ColorPoint[]的数组,并向其存入了一个Point对象。之后,当代码尝试读取cparr[0].color时,由于该位置现在是一个Point对象(没有color字段),程序本应出错。

Java与C#的解决方案:运行时检查

那么,Java和C#是如何处理这个问题的呢?它们并没有在编译时禁止这种子类型化,而是在运行时加入了安全检查。

具体来说,在Java中,每一次数组赋值操作都会进行额外的运行时类型检查。

代码描述:对于数组更新操作 e1[e2] = e3,即使在编译时类型检查通过(e1T[] 类型,e3T 类型),在运行时,虚拟机还会检查 e3 的实际运行时类型是否是数组 e1 实际元素类型的子类型。如果不是,则会抛出 ArrayStoreException

在上面的例子中,当m1方法执行arr[0] = new Point()时,虽然arr的编译时类型是Point[],但其运行时类型是ColorPoint[]。运行时检查会发现Point不是ColorPoint的子类型,因此会在此处抛出ArrayStoreException。这样,后续读取color字段的错误代码就永远不会执行。

这种设计是在灵活性和安全性之间做出的权衡。在引入泛型之前,这种数组子类型化对于编写通用程序(如排序例程)非常重要。尽管现在有了泛型,但这种设计决策的影响依然存在。

关于null的讨论

除了数组,Java和C#中另一个重要的类型化设计选择是关于null的处理。

在子类型化理论中,一个没有任何字段或方法的类型(类似于空记录)应该是所有其他类型的超类型。然而,在Java和C#中,null的类型被设计为所有对象类型的子类型

公式:在Java/C#中,对于任何对象类型T,都有 null : T 成立。

这意味着null可以赋值给任何对象引用变量。其后果是,任何字段访问或方法调用都可能因为接收者是null而抛出NullPointerException。编译器无法静态地确保某个表达式不为null,因此必须在运行时进行大量检查。

一些语言(如ML)做出了不同的选择。它们没有null,而是使用option类型来明确表示值可能不存在。虽然使用上稍显繁琐,但这样可以静态地区分可能为空的类型和绝不为空的类型,从而完全消除一部分运行时错误。

总结

本节课我们一起学习了Java和C#在类型系统上的两个特殊设计选择:

  1. 数组的协变:它们允许数组进行深度子类型化(T1[] 作为 T2[] 的子类型),这理论上不安全。为了弥补,它们在每次数组更新时进行运行时类型检查,失败则抛出ArrayStoreException
  2. null的类型null被视作所有对象类型的子类型,这带来了便利,但也意味着编译器无法静态防止NullPointerException,必须依赖运行时检查。

这些设计体现了语言在表达能力、便利性和类型安全之间的权衡。理解这些底层机制,有助于我们更深刻地理解这些语言的行为,并写出更健壮的代码。

176:函数子类型化 🧩

在本节课中,我们将要学习函数子类型化。这是整个课程中最反直觉的概念之一,但也是学习编程语言课程的核心内容。我们将探讨函数类型之间何时存在子类型关系,这对于理解高阶函数和面向对象编程中的方法重写至关重要。

函数子类型化概述

上一节我们介绍了记录(Record)的子类型化。本节中我们来看看函数类型的子类型化。首先,我们需要明确一个前提:当调用一个函数时,你可以传递一个该函数期望参数类型的子类型。同样,当函数返回一个结果时,你可以将该结果视为其父类型。这是常规的子类型化,与函数调用本身无关。

更有趣的问题是:一个函数类型本身何时是另一个函数类型的子类型? 这在拥有高阶函数的语言中非常重要。假设一个函数的参数本身也是一个函数,其类型为 T1 -> T2(使用ML语法,表示接受 T1 类型参数并返回 T2 类型结果)。我们能否传递一个 T3 -> T4 类型的函数作为参数?在什么情况下可以?显然,T1 -> T2T3 -> T4 必须以某种方式相关联。我们将尝试找出这种关系。

这不仅对高阶函数语言的子类型化很重要,也直接关系到面向对象语言中的方法重写子类型化。我们先从函数入手来理解,这样会更容易一些。

示例:高阶函数 dist_moved

为了理解函数子类型化,我们从一个不涉及子类型化的高阶函数示例开始。这个函数叫做 dist_moved

(* dist_moved 的类型: ( {x:real, y:real} -> {x:real, y:real} ) -> {x:real, y:real} -> real *)
fun dist_moved f p =
    let val new_point = f p
        val dx = #x new_point - #x p
        val dy = #y new_point - #y p
    in Math.sqrt(dx*dx + dy*dy)
    end

dist_moved 函数接受一个函数 f 和一个点 p。它用点 p 调用函数 f,然后计算点 pf(p) 之间的距离并返回。

  • f 的类型是 {x:real, y:real} -> {x:real, y:real},即接受一个点并返回一个点。
  • p 是一个点 {x:real, y:real}

以下是一个完全正常的调用:

(* flip 函数:将点的 x 和 y 坐标取反 *)
fun flip p = {x = ~(#x p), y = ~(#y p)}
(* flip 的类型: {x:real, y:real} -> {x:real, y:real} *)

val d = dist_moved flip {x=3.0, y=4.0}

这里没有子类型化,因为 flip 的类型与 dist_moved 期望的 f 的类型完全一致。

返回类型的协变(Covariance)

现在,让我们考虑一个不同的调用。dist_moved 的定义不变,但我们想传入一个名为 flip_green 的函数。

(* flip_green 函数:翻转坐标并添加颜色字段 *)
fun flip_green p = {x = ~(#x p), y = ~(#y p), color = "green"}
(* flip_green 的类型: {x:real, y:real} -> {x:real, y:real, color:string} *)

flip_green 的返回类型是 {x:real, y:real, color:string},它比 dist_moved 期望的返回类型 {x:real, y:real} 多了一个 color 字段。

dist_moved 并不期望这个确切的类型。然而,如果我们引入这样一条子类型化规则:如果 T2T4 的子类型,那么 T1 -> T2T1 -> T4 的子类型,那么调用就是合法的。换句话说,我们允许在函数的返回类型中进行子类型化。

一个函数被允许返回比所需更多(即包含额外字段)的结果。 这在术语上称为函数类型在其返回类型上是 协变的(covariant)。协变意味着子类型化的方向相同:如果 T2 <: T4,那么 (T1 -> T2) <: (T1 -> T4)

因此,flip_green 的类型 {x:real, y:real} -> {x:real, y:real, color:string}dist_moved 所期望的类型 {x:real, y:real} -> {x:real, y:real} 的子类型。这个调用是安全的,因为 dist_moved 只使用返回结果的 xy 字段,而 flip_green 返回的记录恰好包含这些字段(并且更多)。

参数类型的逆变(Contravariance)

那么参数类型呢?考虑另一个我们试图传递给 dist_moved 的函数 flip_if_green

(* flip_if_green 函数:仅当点为绿色时才翻转 *)
fun flip_if_green p =
    if #color p = "green"
    then {x = ~(#x p), y = ~(#y p)}
    else {x = #x p, y = #y p}
(* flip_if_green 的类型: {x:real, y:real, color:string} -> {x:real, y:real} *)

flip_if_green 的参数类型是 {x:real, y:real, color:string},它要求参数具有所有三个字段。然而,在 dist_moved 中,我们传递给 f 的参数 p 并没有 color 字段。

(* 这将导致运行时错误!尝试读取不存在的 color 字段 *)
val d_bad = dist_moved flip_if_green {x=3.0, y=4.0} (* 类型检查应失败 *)

如果你尝试运行这段程序,它是不健全的(unsound)。它会尝试读取一个没有 color 字段的记录的 color 字段。因此,我们必须确保这样的代码不能通过类型检查。

所以,我们不能仅仅因为 T3T1 的子类型,就允许 T3 -> T2T1 -> T2 的子类型。我们不能说“假设这个函数不需要所有字段也没关系”。它确实需要所有字段(或者可能需要)。因此,不能从函数的参数中删除字段。我们不应该有这样的类型检查规则。

但令人惊讶的是,反过来是允许的。只要子类型化的方向是相反的,就允许在函数参数类型上进行子类型化。

规则如下:如果 T3T1 的子类型(T3 <: T1),那么 T1 -> T2T3 -> T2 的子类型((T1 -> T2) <: (T3 -> T2)。注意这里的方向是翻转的:参数类型的子类型关系与函数类型的子类型关系方向相反。

这在术语上称为 逆变(contravariance)。它意味着:一个函数可以对其参数做出比所需更少的假设(即,它可以接受一个更“宽泛”、要求更少的参数类型)。

示例:

(* flip_x_y0 函数:只使用 x 坐标,y 坐标固定为0 *)
fun flip_x_y0 p = {x = ~(#x p), y = 0.0}
(* flip_x_y0 的类型: {x:real} -> {x:real, y:real} *)

flip_x_y0 的参数类型是 {x:real},它不要求参数有 y 字段。我们可以将它传递给 dist_moved,因为 dist_moved 期望的函数类型是 {x:real, y:real} -> {x:real, y:real}

这里,{x:real, y:real}{x:real} 的子类型(因为前者拥有后者所有字段且更多)。根据逆变规则,({x:real} -> {x:real, y:real})({x:real, y:real} -> {x:real, y:real}) 的子类型。因此,调用是安全的:dist_moved 会传递一个具有 xy 字段的点给 flip_x_y0,而 flip_x_y0 只使用 x 字段,这完全没问题。

结合协变与逆变

我们可以同时应用这两种思想。以下是最终示例:

(* flip_x_make_green 函数:只使用 x 坐标,返回带颜色的点 *)
fun flip_x_make_green p = {x = ~(#x p), y = 0.0, color = "green"}
(* flip_x_make_green 的类型: {x:real} -> {x:real, y:real, color:string} *)

flip_x_make_green 只要求其参数具有 x 字段,但返回一个具有 xycolor 字段的记录。我们可以将它传递给 dist_moved

val d_final = dist_moved flip_x_make_green {x=3.0, y=4.0} (* 类型检查通过,运行安全 *)

dist_moved 期望的类型是 {x:real, y:real} -> {x:real, y:real}
flip_x_make_green 的类型是 {x:real} -> {x:real, y:real, color:string}

根据函数子类型化的完整规则,这是成立的:

  1. 参数逆变{x:real, y:real} <: {x:real}T3 <: T1
  2. 返回协变{x:real, y:real, color:string} <: {x:real, y:real}T2 <: T4

因此,({x:real} -> {x:real, y:real, color:string}) <: ({x:real, y:real} -> {x:real, y:real}),调用是安全的。

函数子类型化的完整规则

综合以上,函数子类型化的通用规则是:

如果 T3T1 的子类型(T3 <: T1),并且 T2T4 的子类型(T2 <: T4),那么 T1 -> T2T3 -> T4 的子类型((T1 -> T2) <: (T3 -> T4))。

用更专业的术语来说:函数子类型化在其参数上是逆变的,在其返回结果上是协变的

理解这一点对于理解面向对象编程中的子类型化和方法重写的安全性至关重要,我们将在下一节中看到。

总结与强调

本节课中我们一起学习了函数子类型化。这是本课程中最反直觉的概念。当你感到困惑时,可以回顾并研究这些示例,或者创建自己的示例,以确信参数必须是逆变的,否则会破坏类型系统的健全性。

许多聪明人都曾在这个问题上犯过错。我想强调这一点:在你职业生涯的某个时刻,你、你的上司或你的朋友都可能犯这个错误。因此,我希望你至少记住这里有一些“反常”的规则。你可以随时回看这段视频。

最后,记住这个核心结论:函数(和方法)的子类型化在参数上必须是逆变的,这不是你直观上认为的方向。掌握这一点是理解高级类型系统的关键一步。

177:面向对象编程中的子类型化

在本节课中,我们将学习如何将之前学到的子类型化理论应用到面向对象编程中。我们将看到,基于类的面向对象语言(如Java和C#)的静态类型检查,其核心思想正是源于记录和函数的子类型化规则。

子类即子类型

上一节我们介绍了记录和函数的子类型化规则,本节中我们来看看这些规则如何应用于面向对象编程。

在像Java和C#这样的语言中,类名同时也是类型名。如果类C是类D的子类,那么类型C就是类型D的子类型。由于子类型关系具有传递性,一个子类实例可以安全地替代其继承链上任何父类(直至顶层的Object类)的实例。

为了确保这一点,我们必须遵守替换原则:任何子类的实例都可以出现在需要超类实例的地方,而不会导致调用不存在的方法或访问不存在的字段。

将对象视为记录

理解这一点的关键,是将对象本质上视为一种记录。对象包含一组字段(实例变量)和一组方法。我们可以将字段名和方法名看作是记录中的字段名。

驱动子类型化的核心在于:

  • 字段通常是可变的。因此,我们知道在字段上使用深度子类型化是不安全的。
  • 方法通常是不可变的。因此,我们可以在方法上使用子类型化,而方法本身就像函数。

所以,我们之前学习的关于函数参数逆变和返回值协变的规则,正是我们理解子类如何改变方法类型的理论基础。

面向对象语言中的子类型化规则

基于记录子类型化的思想,我们可以为面向对象语言设计一个类型系统。以下是其工作原理:

子类可以添加新的字段和方法。
这对应于宽度子类型化,是安全的。使用子类实例作为超类实例时,代码不会关心对象是否拥有类型未承诺的额外内容。

子类可以重写方法。
重写的方法(类似于函数)必须能在任何使用超类方法的地方被使用。这意味着:

  • 参数类型必须是逆变的:重写方法的参数类型必须是超类方法参数类型的超类型。
  • 返回类型必须是协变的:重写方法的返回类型可以是超类方法返回类型的子类型。

这与深度子类型化相关,因为在Java/C#中,方法一旦定义就不能被更新为另一个完全不同的方法。

实际语言的设计选择

虽然上述理论是基础,但Java和C#等语言在实际设计中做出了一些选择:

  • 使用类型名而非记录类型:它们使用类名或接口名作为类型,而不是写出类似 {x: real, y: real} 的记录类型。这限制了子类型化:只有存在显式继承关系的类,其类型才构成子类型关系,而不能仅仅因为一个类“恰好”拥有另一个类的所有成员就构成子类型。这种限制是安全的。
  • 允许协变的返回类型:子类重写方法时,可以使返回类型更具体(子类型),这是允许的。
  • 不允许逆变的参数类型:这些语言选择不允许通过改变参数类型来“重写”方法。如果你改变了参数类型,就被视为定义了一个全新的重载方法。由于子类可以添加新方法,这不会造成问题。

类与类型的区别

关于面向对象编程,一个重要的概念区分是类型

  • :定义对象的行为。它包含带有具体实现代码的方法定义。我们在Ruby中定义的就是类。
  • 类型:描述对象的接口。它说明一个具有该类型的对象拥有哪些方法,以及这些方法的参数和返回类型是什么。
  • 子类型:描述的是在方法和类型层面上的可替换性。

在大多数静态类型、基于类的面向对象语言(如Java、C#)中,出于便利性,它们选择将这两个概念混合:类名同时作为类型名使用。该类型所描述的内容,就是去类定义中查找所有方法及其参数/返回类型得出的接口。

简而言之:类关乎实现(行为),类型关乎接口(契约)

关于self/this的特殊性

最后,我们来看一个有趣的特殊情况:self(在Ruby中)或this(在Java/C#/C++中)。

如果我们把self看作方法的第一个隐式参数(就像我们在Racket中模拟OOP时做的那样),那么它是一个非常特殊的参数:它被协变地处理,尽管我们刚强调过普通参数必须是逆变的。

考虑这个例子:类A有一个方法m。子类B重写了m。在B的m方法内部,我们知道self是一个B的实例(因此可以访问B独有的字段x),而在A的m方法内部,我们只知道self是一个A的实例。

这看起来像是协变(在子类中,self的类型更具体了),但它为什么是安全的呢?因为self不是一个普通的、可以由调用者随意选择值的参数。它总是被绑定到调用该方法的整个对象上。因此,当B的m方法被执行时,我们确信self就是一个B的实例,所以在这个上下文中假设它拥有B的所有属性是安全的。

总结

本节课中,我们一起学习了如何将严谨的子类型化理论应用于面向对象编程。我们看到,对象可以模型化为记录,方法的子类型化遵循函数的逆变与协变规则。实际语言(如Java、C#)基于此理论构建了类型系统,同时做出了一些实用的设计选择。我们还厘清了类与类型的关键区别,并探讨了self/this参数在子类型化中的特殊处理方式。这些核心概念构成了理解现代静态类型OOP语言类型检查的基础。

178:泛型与子类型对比 🆚

在本节课中,我们将要学习泛型与子类型这两种编程语言特性的对比。我们将探讨它们各自的优势、适用场景以及为何它们是互补的工具,而非相互替代。

为了展示不同方法的互补优势,我们将通过比较ML中看到的类型变量(泛型)与子类型来结束讨论。这与课程中比较函数式分解与面向对象分解、静态类型与动态类型等主题一脉相承。我们将看到,泛型和子类型各自擅长处理不同的问题。

泛型的优势 ✨

上一节我们介绍了泛型的基本概念,本节中我们来看看泛型具体擅长什么。我们将以ML语言为例进行说明。

泛型非常适用于那些组合其他函数的函数类型。例如,我们能够在ML中编写compose函数,并赋予它一个非常优雅的类型签名。该签名表明:它可以接受任何类型为'b -> 'c的函数和任何类型为'a -> 'b的函数,并返回一个类型为'a -> 'c的函数。这些类型变量可以被实例化为我们语言中的任何类型,它精确地描述了compose函数的功能。

泛型同样非常适用于操作泛型集合的函数。例如,列表的length函数可以作用于任何类型的列表。map函数的类型为('a -> 'b) -> 'a list -> 'b list,它不关心列表元素的类型,只要传入的函数参数类型与之匹配即可。

总的来说,泛型在以下情况下表现出色:当你的代码可以适用于任何类型的事物,但某些事物必须是相同类型时。这正是当你看到类型签名中'a'b多次出现时所表达的类型约束。

泛型的优势不仅限于函数式编程,这也是为什么Java和C#等语言也引入了泛型。在这些语言中使用泛型有时会略显笨拙,因为类型推断能力较弱。由于泛型是后来才添加到语言中,并且需要与OOP和对象交互,其语义也常常更复杂。尽管如此,我们甚至在之前的一节可选讲座中看到,如何在Java中实现闭包或模拟闭包的效果,这并不算太糟。因此,Java和C#程序员在所有ML中泛型有用的场景中,也会使用泛型类型。

以下是ML中一个非常简单的泛型Pair类示例,它甚至包含一个swap方法。这个类适用于任何类型的第一组件和第二组件(这里称为x和y)。

(* ML 泛型 Pair 类示例 *)
class pair ['a, 'b] (x: 'a, y: 'b) =
    object
        val first = x
        val second = y
        method swap = new pair ['b, 'a] (y, x)
    end

子类型的误用与泛型的正确选择 ⚠️

在泛型表现出色的上述场景中,你不应该使用子类型。如果你尝试对像Pair这样的容器使用子类型,最终只会处处用错工具。

如果没有泛型,Pair类的字段应该是什么类型?如果你必须为所有Pair指定一个单一类型,那么你可能不得不使用类似Object的类型。这正是Java在拥有泛型之前人们所做的事情,这非常不幸。这本质上是使用了错误的编程语言工具来完成工作,这可能是当你没有合适工具时不得不做的选择。

现在,我们大多数静态类型语言都有了正确的工具(泛型),你应该使用它。在没有泛型的情况下,你最终会将字段(即Pair的组件)声明为Object类型。得益于子类型,当你创建Pair并向字段写入数据时,这没有问题:你想传入一个String?没问题,StringObject的子类型。你想传入一个ColorPoint?没问题,ColorPoint也是Object的子类型。

但是,当你需要将字段取出来时(老实说,拥有一个Pair的唯一目的就是在某个时刻取出其内容),你只知道它的类型是Object。在你能以某种方式确定它具有其他类型之前,你无法对它做任何有用的事情。在Java和C#中,我们通过向下转型来实现这一点。这基本上是一个运行时检查,意思是:“我认为它实际上是一个String。如果我是对的,请给我返回一个String类型的东西;如果我错了,就抛出一个异常。”

由于必须加入这些向下转型操作,我们无法获得静态类型检查的好处。这些检查可能会失败,我们需要支付运行时检查的成本,并且代码会变得更难阅读。因此,与泛型相比,这是一个“三输”的局面。这是因为泛型更适合这类编程任务。

子类型的优势 🎯

子类型在其他方面表现出色。子类型有一些绝佳的用途,有时在更高级的术语中被称为“子类型多态”。

当你有一段代码需要一个行为像Foo的对象,而你有一个拥有比Foo所需功能更多的对象(即Foo的某个子类,它有一堆额外的东西)时,子类型就派上用场了。没问题,能够传入这个子类对象是非常棒的。泛型没有这种概念。泛型是关于“我适用于任何类型”,而子类型是关于“我需要一个Foo,但如果你有一个Foo的子类型,也没问题”。

那么,子类型在哪些场景中出现呢?正如我在本节中反复展示的,有许多简单的例子,比如某个函数需要Point,但你有一个ColorPoint。那个额外的color字段对任何人来说都不是问题。子类型让我们能够捕捉到这种思想,而泛型不能。

另一个经典例子(我认为面向对象编程在这方面非常成功)是在图形用户界面编程中。你有一个超类,其中包含一些基本概念;有一个超类型规定,任何属于此类型的对象都具有在屏幕上显示、响应鼠标点击、调整大小等能力。某些代码可以操作任何具有该类型的对象。但实际传入的对象很可能拥有更多属性,比如颜色、默认字体、各种菜单栏等等。在这种情况下,子类型感觉就是正确的工具:你有只需要某些功能的代码,然后你有一些拥有更多功能的对象。子类型非常适合这种情况。

在缺乏子类型的语言中模拟子类型 🛠️

如果你尝试在像ML这样没有子类型的语言中做类似PointColorPoint这样简单的事情,会令人沮丧。当然,没有什么是不可能的,只要你愿意通过足够多的变通方法,总有一些方式可以实现。但ML确实没有子类型。

以下是一段实际的ML代码:一个计算到原点距离的函数distanceOrigin,它接收一个包含xy字段的记录。distanceOrigin工作得很好,但其类型是{x: real, y: real} -> real。如果你尝试用一个有额外字段的记录来调用它,类型检查就无法通过,因为类型不相等。ML没有子类型,添加子类型会使类型推断更加复杂,所以ML选择不添加它。

因此,你不能用这个包含额外字段的记录来调用distanceOrigin

那么,如果你对变通方法感兴趣(就像Java在拥有泛型之前处理集合的方式一样,只是比较繁琐),你可以怎么做呢?你可以改变distanceOrigin函数,让它根本不直接接收{x: real, y: real},而是接收任何类型'a,但要求调用者传入一个获取x坐标的函数和一个获取y坐标的函数。

如果你这样做,最终会得到一个类型为('a -> real) * ('a -> real) * 'a -> real的函数。让调用者传入如何获取x坐标和y坐标的方法,那么这里的实际值'a可以是任何你想要的东西。如果你的大部分代码只是想用普通的Point来调用这个函数,这会很令人沮丧,因为你必须创建一个辅助函数来提供正确的getXgetY。然而,这段代码实际上可以与ColorPoint一起使用,前提是我们传入适当的getter函数。但是,由于ML缺乏子类型,你实际上必须为PointColorPoint使用不同的getter和setter。在ML中,你确实无法为PointColorPoint复用代码。这是因为子类型(ML所不具备的特性)才是处理此类任务的正确工具。

总结 📝

本节课中我们一起学习了泛型与子类型的对比。我们了解到:

  • 泛型擅长编写可操作于任何类型,但要求类型一致的代码,例如容器类(Pair, List)和高阶函数(compose, map)。其核心思想是代码复用与类型安全
  • 子类型擅长在需要某种类型对象的地方,允许传入拥有更多功能(子类型)的对象,从而实现代码复用与可扩展性,这在GUI编程和类层次结构中非常常见。
  • 两者是互补的工具:泛型关注“适用于所有”,子类型关注“适用于所有及更多”。在错误的场景使用错误的工具(如用子类型实现泛型容器)会导致类型安全丧失、运行时开销和代码复杂度增加。
  • 不同的语言设计选择了不同的特性组合(如ML拥有强大的泛型而无子类型),这影响了某些编程模式的表达方式。

理解每种技术的优势和适用场景,有助于我们在设计和编程时选择最合适的工具。

179:有界多态性

在本节课中,我们将要学习如何将泛型与子类型结合使用,这种结合被称为“有界多态性”。我们将通过一个具体的例子来理解为什么有时单独使用泛型或子类型无法满足需求,以及如何通过有界多态性来解决这个问题。

概述

上一节我们介绍了泛型和子类型各自适用的场景。本节中我们来看看一个需要将它们结合使用的例子。我们将学习如何编写一个方法,它既能处理多种类型(泛型),又能确保这些类型满足特定的约束(子类型),从而实现我们想要的功能。

结合泛型与子类型的动机

假设我们有一个方法,它接收一个点的列表和一个圆,然后返回所有位于圆内的点。在Java中,我们可能会这样定义方法签名:

List<Point> inCircle(List<Point> ps, Point center, double radius)

这个方法很简单:遍历输入的点列表,检查每个点是否在圆内,并将符合条件的点加入结果列表。

现在,如果我们有一个ColorPoint类(它是Point的子类),并且我们有一个List<ColorPoint>,我们自然希望复用上面的inCircle方法来过滤彩色点。然而,Java(和C#)会拒绝这样做。

原因是List<ColorPoint>并不是List<Point>的子类型。这与我们之前学习的“深度子类型化”问题有关。类型检查器无法保证inCircle方法不会向返回的列表中添加非ColorPointPoint对象,或者不会修改输入列表。因此,仅靠子类型化在这里行不通。

尝试仅使用泛型

那么,我们能否仅使用泛型来解决呢?假设我们将方法签名改为完全泛型的:

<T> List<T> inCircle(List<T> ps, Point center, double radius)

这样,我们可以用PointColorPoint来实例化类型参数T。调用者传入List<Point>就得到List<Point>,传入List<ColorPoint>就得到List<ColorPoint>

但这里有一个根本问题:方法体需要访问点的坐标(例如getX(), getY())来判断点是否在圆内。如果T是任意未知类型,方法体就无法调用Point特有的方法。因此,这个方法体无法通过类型检查。

解决方案:有界多态性

我们真正需要的是结合两者的优点:使用泛型来处理不同类型的列表,同时使用子类型来约束这些类型,确保它们具有我们所需的功能。

这就是有界多态性。我们这样定义方法:

<T extends Point> List<T> inCircle(List<T> ps, Point center, double radius)

这个签名的含义是:对于任何是Point子类型的类型TinCircle方法接收一个List<T>,并返回一个List<T>

  • 泛型部分<T>List<T> 允许方法处理Point及其任意子类型的列表。
  • 子类型约束部分extends Point 限制了T只能是Point或其子类型。这确保了在方法体内,我们可以安全地将T类型的元素视为Point来调用相关方法。

现在,调用者可以用PointColorPoint(或其他Point子类)来实例化T,但不能用StringInteger等无关类型。方法体可以正常编写,类型检查也能通过。

以下是该方法的完整Java语法示例:

public static <T extends Point> List<T> inCircle(List<T> ps, Point center, double radius) {
    List<T> result = new ArrayList<>();
    for (T p : ps) {
        if (p.distanceTo(center) <= radius) {
            result.add(p);
        }
    }
    return result;
}

关于Java实现的说明

需要指出的是,由于向后兼容性和实现上的考虑,Java的泛型系统并非完全“纯粹”。在Java中,有可能通过类型转换绕过泛型的静态检查,从而可能导致一些在更严格的泛型系统中不会出现的结果。然而,在正确使用的前提下,Java的泛型、子类型以及它们结合产生的有界多态性,仍然是构建类型安全、可复用代码的强大工具。

总结

本节课中我们一起学习了有界多态性。我们看到了一个需要同时使用泛型和子类型的实际例子——编写一个能处理Point及其子类列表的过滤方法。单独使用子类型或泛型都无法完美解决这个问题。通过将有界多态性(<T extends Point>),我们成功地将两者结合起来:利用泛型实现代码对不同类型(Point, ColorPoint)的复用,同时利用子类型约束确保这些类型具备我们所需的方法和属性。这是现代静态类型语言中一个非常强大且实用的特性。

180:回顾与展望 🎓

在本节课中,我们将回顾整个课程的核心内容与学习目标,总结所学到的关键概念,并展望这些知识如何帮助你在未来的编程生涯中持续成长。


课程内容回顾 📚

上一节我们介绍了课程的整体框架,本节中我们来看看课程的具体学习目标与内容大纲。

课程最初设定的学习目标包括:

  • 准确理解函数式编程与面向对象编程。
  • 培养快速学习新编程语言的能力。
  • 掌握特定的语言概念(如闭包、延迟求值、模式匹配)。
  • 学会评估编程语言的表达力与优雅性。
  • 成为更优秀的程序员,不仅限于课程中使用的语言。

在课程大纲方面,我们共学习了十个部分的内容:

  • 函数式编程与模式匹配。
  • ML风格的类型系统。
  • Ruby的动态类型系统。
  • 实现自己的编程语言。
  • 函数式与面向对象编程的对比。
  • 两种范式下的程序分解方法。
  • 子类型与高级类型系统概念。

编程语言范式网格 🗺️

在探讨了具体内容后,我们再次回顾了本课程的核心分析框架。

以下是本课程覆盖的三种编程范式组合:

  • 静态类型函数式:例如 ML。
  • 动态类型函数式:例如 Racket。
  • 动态类型面向对象:例如 Ruby。

这个框架很好地补充了许多人对静态类型面向对象语言(如 Java 或 C#)的已有认知。但需要强调的是,并非所有语言都能被简单地归入这个网格。例如,Haskell 虽是静态类型函数式语言,但其惰性求值特性使其与 ML 大不相同。此外,还存在像 Prolog 这样的逻辑编程语言,它不属于上述任何类别。


核心概念与亮点 ✨

现在,让我们重点回顾几个贯穿课程的核心概念与亮点。

避免数据可变性的益处

我们多次强调了避免数据突变(即无副作用的编程)的好处:

  1. 简化推理:无需担心程序不同部分的引用别名问题,降低了编写正确、易读、易维护程序的负担。
  2. 增强等价性:在无副作用的前提下,更多的函数或程序可以被视为等价。
  3. 提升效率:若数据不可变,则无需为防止意外修改而进行复制,程序可以更高效、简洁。
  4. 保证类型安全:在可变设置中,深度的子类型化是不健全的,但如果数据不可变,深度子类型化则没有问题。

当然,当程序需要模拟现实世界中的状态更新时,命令式更新是合理且自然的。关键在于不必为程序中的每个小算法都使用它们。

其他关键概念

以下是课程中涉及的其他重要概念:

  • 函数闭包:我们不仅学习了闭包是什么,还看到了它作为强大编程构造的各种惯用法。
  • 数据类型与模式匹配:这是组织程序分支的强大方式,我们在课程最后的作业中对比了 ML 的简洁性与 Ruby 实现的差异。
  • 静态与动态类型:我们探讨了静态类型能实现什么、不能实现什么,以及它如何必然是一种近似——健全的静态语言总会拒绝一些完全正确的程序,但这是为了捕获更多错误而愿意接受的权衡。
  • 子类型与泛型:课程末尾我们对比了这两者,强调应根据实际需求选择正确的工具。
  • 模块化:虽然课程重点介绍了 ML 的模块系统,但模块化是编写大型复杂程序不可或缺的概念,Racket 和 Ruby 也有各自的模块化方法值得学习。

程序的本质与语言的魅力 🌳

从更宏观的视角看,我们学习了如何将程序本身视为(抽象语法树),而非文件中的文本。这在 Part B 中为我们自创的语言编写解释器时至关重要。

回顾最初,我们学习了每个语言构造都有三个要素:

  1. 语法:如何书写它。
  2. 类型规则:什么构成合法程序。
  3. 求值规则:程序执行时该构造的含义。

最令人惊叹的是,像 ML、Racket 和 Ruby 这样的语言,其核心构造集并不庞大,但通过组合这些强大而简单的思想,并借助人类的智慧,我们构建出了令人惊叹的多样化软件世界。这正是软件的全部意义,也是编程语言研究令人着迷之处。


总结与展望 🚀

本节课中我们一起回顾了整个课程的学习旅程。

课程材料已经结束,但我希望为你提供了一个智识框架,帮助你在未来的软件开发生涯中,持续成长,享受学习新语言和编写新程序的乐趣。

随着时间推移,必然会出现新的语言,细节和术语可能会改变。但我们在本课程中涵盖的概念已经历了长时间的考验,并被证明非常成功。我相信,这些概念的核心变体将始终是我们用来编写运行世界的软件语言的基础。能够与你分享这些,令人兴奋,这也是我对编程语言充满热情的原因。

Just。

181:告别与回顾 🎓

在本节课中,我们将一起回顾整个编程语言系列课程的学习历程,并对这段学习体验进行总结与告别。


概述

本节是编程语言系列课程的最后一课。我们将借此机会告别,并反思整个MOOC学习体验。课程讲师将分享他对课程的看法,并对所有参与者的努力表示祝贺与感谢。


课程性质与挑战

上一节我们介绍了本节的目的。本节中,我们来看看讲师对课程本身特点的阐述。

这门课程包含极具挑战性的知识体系。与许多其他MOOC不同,本课程的内容确实对应着一门高难度的课程,属于顶尖计算机科学项目的一部分。它基于我在华盛顿大学多次讲授的一门课程。在那门课程中,学生们首先需要通过激烈的竞争性选拔,才能获得学习此课程及其他课程的机会。他们完成类似的作业,虽然课程本身存在一些差异,并且由于传统大学的小规模和亲身体验性质,他们的经历有所不同,但他们学习的主要是相同的材料,并且基本上是作为全日制学生来完成的。

因此,对于所有在繁忙生活中抽出时间、兼职学习这门课程的你们,我认为这尤其令人印象深刻。我想确保你们知道,我了解这一点。


课程的理念与归属

在计算、学术界以及我职业生涯的几乎所有方面,与你们分享这门课程一直是我的极大热情所在,可以说是近年来最让我感到满足的部分。我希望你们能看出,我对课程内容充满自豪感。

但我不希望这被误解为这门课程中的所有材料,甚至课程中提供的所有资料,在某种意义上是属于我或由我发明的。这只是我解释它的方式,是我思考它的方式,是我选择的措辞。所有的错误都由我承担。

这里有一个古老的观点:站在巨人的肩膀上,我们才能看得更远。在这种情况下,我想明确指出,这些编程语言的核心思想在我之前就已存在,并且被我所在领域的许多同事以及众多开发者所深刻理解。我只是非常荣幸地以某种方式将它们组织并呈现出来。


团队的努力

我还想强调,你们在所有视频中只看到我的脸,这其实是一种误导。是的,我录制了所有视频,并主导了这门课程的创建。但正如我在课程附带的致谢中所指出的,这是一个共同的经历,到目前为止,我得到了数十人的帮助,他们共同完成了许多我独自一人无法完成的工作。因此,这确实是一项团队努力。我想再次指出,所有在此过程中提供帮助的人都值得被感谢。


后续行动与期望

那么,接下来你们可以做什么呢?以下是一些建议:

如果你喜欢这门课程并从中收获良多,我希望你能传播关于这门课程的信息。通过你告诉你的朋友,让更多的人来体验它。

完成上述之后,我希望你能运用本课程中的知识,让世界变得更美好,并在此过程中享受乐趣。因为这才是所有事情真正应该关乎的核心。


总结

本节课中,我们一起回顾了整个编程语言课程的学习旅程。讲师祝贺了所有参与者付出的努力,强调了课程的挑战性与团队协作的本质,并鼓励大家将所学知识用于创造更美好的世界。

最后,再次感谢你们与我一同分享这门我最喜爱的世界级课程。你们付出了巨大的努力,能成为你们学习这段材料时生活的一部分,我感到无比自豪和荣幸。祝贺你们,再见。

posted @ 2026-03-29 09:38  布客飞龙I  阅读(3)  评论(0)    收藏  举报