|
编译 | bluemin
编辑丨陈彩娴
1
抽象
计算思维以设计问题的抽象模型为中心,应用计算步骤和高效算法解决问题——这一概念不仅服务于计算机科学(CS),而且逐渐渗透到科学和日常生活中。
「抽象」(Abstraction)是计算思维的核心,也是本文的主题。「抽象」一直是计算机科学的重要概念,在向广大受众教授计算机知识时,对计算思维的强调更是突显了抽象的重要性。
在计算机科学中,抽象并不局限于物理现实,因此我们发现有用的抽象无处不在,例如「量子力学」。它有一种衍生的计算抽象,叫「量子电路」,从物理概念开始,催化出用于模拟的编程语言,以及利用其独特功能的理论算法,有望在大型机器上实现。
计算机科学中的「抽象」往往包含以下内容:
- 数据模型包含一种或多种类型的数据以及数据之间可能存在的关系。例如,无向图可以描述为由节点和边组成,每条边连接两个节点。
- 某些编程语言不进行数据操作。这可能是一种传统的编程语言,也可能只进行一些特定的操作。这种语言总是有一个正式的语义——关于程序如何影响数据的规范。
因此,每个抽象模型都允许我们设计算法,以特定的方式操作数据。
我们的目标是设计「优质」、具有多项优势的抽象模型。在设计解决方案时,抽象的难易程度是一项重要指标。例如,我们将在 3.1 节讨论关系模型如何导致数据库使用频率的激增。生成的算法还有其他性能指标,例如串行或并行机器上的运行时间。同样,我们倾向易于实现的抽象。最后,一些抽象提供了一种简单的方法来衡量算法的效率(因为对于传统编程语言,我们可以估计程序运行时间的上界),而其他抽象则要求我们即使是近似讨论算法的效率,也要先在较低层级进行实现。
1.1 编译
有些抽象的层次太高,无法提供有意义的性能指标。因此,高级抽象的操作可能需要在较低的层级上实现。
实际上,在逐渐接近机器本身的层次上,可能存在多个抽象层次。如图1所示,高级抽象(抽象1)的操作可以由较低级别的抽象(抽象2)实现,而较低级别的抽象又可以由更低级别的抽象(图中未显示)实现。有一些有趣的抽象层次将我们从高级程序带到机器指令、物理硬件、逻辑门、晶体管,最后到电子。不过,我们只关注更高的层次。
图1. 抽象层和算法层
使用抽象1的操作的算法被编译为较低级别的抽象2中的算法。在本文中,我们使用的是普遍意义上的术语编译器,不仅仅是《龙书》中重点介绍的编程语言的常规编译器,还会使用将一个抽象的程序转换为另一个程序的算法,这大概属于较低级别的抽象。因此,在某些情况下,编译过程很简单,较高级别的每个操作都被较低级别的一个或多个特定操作所取代。在其他情况下,尤其是从传统语言(比如C语言)到机器级语言编译时,翻译算法非常复杂。还有其他的一些情况,例如当高级抽象使用强大的代数运算(如线性代数或关系代数)时,优化是至关重要的,因为原始编译通常会导致算法比通过优化编译生成的算法多花费几个数量级的时间。
这可能是因为抽象2与机器层次太接近,因此具备有意义的性能指标。如果是这样,抽象1可以继承这些指标,为抽象1中编写的算法提供优质概念。但是高级抽象通常可以在几个不同的低级抽象中实现。每个低级抽象都可能提供一个完全不同的运行时间或其他度量的概念,因此在高层次上必然包含不同的算法优度概念。
1.2 抽象的分类法
我们至少可以确定四种不同类型的抽象,可以根据它们的预期目标进行划分。在构成本文主体的讨论中,我们将给出相应的例子并探讨它们的相互作用。
1.2.1. 基本抽象
与所有抽象一样,基本抽象由数据模型和操作组成。这些抽象通常被认为是在面向对象编程语言中实现的抽象数据类型。但是基本抽象没有操作的具体实现,也没有表示数据的特定数据结构。人们也可以将这些抽象比作 Java 中的接口,但与接口不同的是,这些抽象对它们的操作具有预期的含义,而不仅仅表示操作的名称。
研究基本抽象实际上有两个截然不同的目的。在某些情况下,它们代表了值得单独研究的常见操作,并且有多种实现方法。例如,我们在 1.4 节讨论字典(一个包含插入、删除和查找操作的集合)。这种类型的其他示例包括堆栈、队列、优先级队列,以及许多其他抽象。
其他抽象非常广泛,可以支持应用程序的大型组件。常见的例子包括各种各样的树和图,例如有向图、无向图、有标签图和无标签图。
这些抽象具有广泛的操作集,可以通过各种方式组合。但是,这些操作本身并不是图灵完备的。相反,它们被假定嵌入在图灵完备的语言中,并构建了使用该模型的算法。例如,在图抽象中,我们可能有一个操作,例如「查找相邻节点」。在这个抽象之外,我们可以假设有一种编程语言允许在所有相邻节点上进行迭代。这个操作的实现和图本身的表示都没有具体说明,因此我们没有运行时间的具体概念。我们可以将这些抽象与面向对象编程语言中的典型类及其方法进行类比。区别在于类的方法在底层编程语言中有特定的实现。同样,我们可以将诸如编程语言库或 TeX 包之类的东西视为这种类型的抽象。
1.2.2 抽象实现
这些表示实现的方法,可能是一个或多个基本抽象的实现。这些语言本身并不是图灵完备语言,通常可以被编译成几种不同的机器模型,例如,串行或并行机器,或者采用主内存或辅助内存的模型。每一个机器模型都提供了运行时间的概念,可以将其转换为抽象实现的运行时间,然后转换为支持的基本抽象的运行时间。
这一类型还包括自动机,如确定性或非确定性有限自动机(见第2.1.1和2.1.3节)和移位归约解析器(见第2.2.1节)。
1.2.3 声明性抽象
抽象最重要的用途之一是培养一种编程风格,只需说明想做什么,而不是如何去做。因此,我们发现许多不同的抽象,包括一个数据模型和一种比传统语言更高级的编程语言;这些语言通常是某种代数。例如正则表达式(将在第2.1节中讨论)和关系代数(将在第3节中提到)。上下文无关文法(第2.2节)尽管不是严格意义上的代数,也是这类抽象的另一个例子。
这些抽象的特点是它们的编译需要高度优化。对于传统语言,好的优化可以使其在机器上的运行时间加快两倍,而对于这些抽象,好实现和坏实现的运行时间之间可能存在数量级差异。另一个特点是声明性抽象的编程语言不是图灵完备的。任何图灵完备语言的不可判定性属性都将排除优化器的存在。优化器可以有效地、全面地处理程序想要做的事情,而无需被告知如何做。
1.2.4 计算抽象
与抽象实现相比,这些抽象接近于物理实现的机器。也就是说,没有人会仅仅为了形成一个抽象实现而构建一台机器,但通常会实现计算抽象或易于转换的东西。因此计算抽象提供了有意义的性能指标,即使它们不是100%准确。
你可能熟悉许多计算抽象,因为它们包括所有通用编程语言以及机器指令集。这种类型的其他抽象更具理论性,例如随机存取存储器(RAM)模型或并行随机存取存储器(PRAM)模型。在这里,我们将在 1.7 节讨论一个强调二级存储作用的传统机器模型。我们还将讨论并行计算的抽象:3.5 节中的批量同步和 3.6 节中的映射规约模型(MapReduce)。
虽然许多计算抽象与传统计算机有关,但也有一些例外。图灵机就是其中之一,还有一些甚至不是图灵完备的,但在计算机科学中发挥着重要作用。例如,在克劳德·香农的硕士论文之后,布尔电路和布尔代数是计算科学最早使用的抽象概念之一,而量子电路抽象则是最新的概念。
1.3 对抽象空间的探索
为了解抽象链的本质及其关系,接下来看一个基本抽象的常见示例:字典。
字典是抽象的一个常见示例,它具有许多替代实现,并说明了随着高级抽象被编译为低级抽象而暴露出的一些问题。字典的数据模型包括以下内容:
- 一个全集 U。
- 全集 U 的子集 S,初始化时,S为空。
字典的「编程语言」由以下三种操作的直线序列组成:
- 插入(x):如果U的元素x不在集合S中,则将其插入集合S中,即 S: = S ∪ {x}。
- 删除(x):从集合S中去除元素x,S:= S – {x}。
- 查找(x):如果元素x在集合S中返回真,否则为假。
例如,字典可用于描述编译器中符号表的行为。U将是编程语言的可能标识符集。当编译器扫描程序时,S将是一组标识符,在程序中的每个点上都有定义好的含义。然而对于符号表,需要将数据附加到每个标识符上,例如它定义的数据类型和出现的嵌套块的级别(以便我们可以区分具有相同名称的标识符)。当编译器找到一个声明时,它会将声明的标识符插入集合S。当它到达程序或函数的末尾时,它会删除与该程序块关联的标识符。在程序中使用标识符时,编译器会查找该标识符并检索其类型和其他必要信息。
请注意,字典的编程语言相当简单,不具备图灵机的功能,也没有真正的算法设计概念,因为「程序」只是反映其他进程正在做什么。同样,也没有真正的运行时间概念,因为不清楚每个操作需要多长时间。我们可以将每个操作定义为占用单位时间,但由于我们无法控制「程序」的长度,因此这个运行时间也没有意义。
1.4 字典的实现
字典可以使用许多不同的抽象方法来实现。链表应该是大家非常熟悉的抽象实现,其数据模型包括以下内容:
- 单元格包含值(某个全集U的成员)和指向另一个单元格的链接(可能为空)。
- 标头,简单命名为指向单元格的链接(可能为空)。
假设读者熟悉可以执行的典型操作,例如创建单元格或标头、从列表中插入和删除单元格以及返回包含在指定单元格中的数据。可以通过创建集合 S 中所有元素的链表来实现字典。将三个字典操作编译为列表操作很简单。
如果假设链表是在计算机的 RAM 模型中实现的,那么我们就有了一个现实的运行时间概念。我们可以为列表单元格上的每个基本操作分配一个时间单位,因为在 RAM 上,每个操作都需要恒定的时间。这一观察结果让我们将运行时间的RAM概念提升到运行时间的链表概念,然后提升到字典级别。但这不是个好消息,平均而言,我们必须至少走到列表的一半,通常一直到最后,才能实现任何字典操作。因此,单个字典操作的运行时间与当时集合 S 的大小成正比。
另一种易于理解的实现字典的抽象类的方法是使用搜索树。当三个字典操作的算法保持树平衡时,例如AVL 树或红黑树,每个操作的运行时间与操作时集合 S 的大小是对数关系。但是通常首选的实现字典的抽象是哈希表。
1.5 哈希抽象
哈希的数据模型包括以下内容:
- 全集 U。
- 哈希桶数 B,从 0 到 B-1 编号。
- 从 U 到 {0,1,…,B–1} 的哈希函数 h。每个哈希桶 b 是全集 U 的元素 x 的子集,使得 h(x)=b。
通常的操作是计算h(x),其中x是U的一个成员,并在编号为 h(x) 的哈希桶中插入、删除或查找 x。例如,哈希表的插入操作将表示为 h-insert (x, b),其中 b = h(x)。哈希程序包括交替计算一些 h(x),然后对 x 和哈希桶 h(x) 执行三个操作中的某一个。
将字典程序编译成哈希程序很简单。例如,字典操作insert (x) 被翻译成b: = h (x); h-insert (x, b)。
哈希与机器的距离有些远,我们无法直接使用它来确定运行时间。存在的问题是,哈希法相当独特,因为最坏情况下的性能,即集合中的所有元素都在同一个哈希桶中,比我们对所有可能的哈希函数进行平均时的平均情况要差得多。为简单起见,应该正确地假设,在平均情况下几乎所有的哈希桶都包含接近平均数的元素,即S/B。但即使同意只讨论平均情况,仍然不知道对一个元素和哈希桶的每个操作需要多长时间。
本质上,每个哈希桶本身就是一个小型字典,所以我们必须决定如何实现它的操作。如果 S 的大小保持在 B 的数量级,我们可以使用哈希桶的链表实现,并期望每个操作在 RAM 或真实机器上平均花费 O(1) 时间。但是,如果 S 比 B 大得多,则表示哈希桶的列表的平均长度为 O (S/B)。这仍然比每个操作的时间复杂度为O (S) 要好。然而,当 S 太大而无法放入主存时,RAM 模型不再适用,我们就需要考虑另一种计算抽象。
1.6 二级存储抽象
作为 RAM 计算抽象的替代方案,在 O(1) 时间内可以访问任何数据片段,我们可以在多个级别引入访问局部性。我们将只讨论一个具有基于磁盘的辅助内存的抽象,其中大数据块(比如64KB)作为一个整体在磁盘和主存之间移动,且必须在主存中读取或写入数据。与在主存中对数据本身进行的典型操作的成本相比,在主存和辅助内存之间移动数据块的成本高昂。因此,在这种新模型中,将运行时间简单地视为磁盘I/O的数量是合理的,即一个数据块从辅助内存移动到主存的次数,反之亦然。
在底层机器的二级存储模型中,实现哈希表的最佳方法与使用 RAM 模型的首选方法有些不同。特别是,每个哈希桶将由一个或多个完整的磁盘块组成。为了利用局部性,希望典型的哈希桶由尽可能少的磁盘块组成,但希望尽可能使这些磁盘块充满。因此,假设主存能够容纳全集中的M个元素,而磁盘块能够容纳P个这样的元素。然后希望哈希桶的数量 B 为 B = M/P,那么就可以在主存中为每个哈希桶保存一个磁盘块,并且该磁盘块可能会近乎充满。
随着集合S的大小增加,我们使用磁盘块的链表来表示每个哈希桶,只有第一个哈希桶在主存中。最坏的情况下,这三个字典操作需要检查单个哈希桶中的所有磁盘块。因此,平均而言,预计每个操作的磁盘I/O数为O(S/BP),因为S的元素将在B个哈希桶中大致平均分配,将单个哈希桶的元素每隔P个划分成一组,放入一个磁盘块中。由于B=M/P,每个操作的运行时间为O(S/M)。
2
编译器的抽象
现代编译器将翻译过程细化为多个阶段,每个阶段将源程序的一种表示形式转换为另一种语义等价的表示形式,通常处于较低的抽象级别。编译器中的阶段通常包括词法分析、句法分析、语义分析、中间代码生成、代码优化和目标代码生成。所有阶段共享的符号表用于收集和提供有关源程序中各种结构的信息。前四个阶段通常称为编译器的前端,后两个阶段称为后端。
编译器实现的进展涉及许多重要的抽象。我们将具体讨论三种这样的抽象:正则表达式、上下文无关文法和流图。前两个是带有有趣优化故事的声明性抽象。第三个虽然不是声明性的,但也带来了有趣的实现挑战。
2.1 正则表达式和句法分析
句法分析是编译器的第一个阶段,它将源程序读取为一个字符序列,并将其映射为一个称为标记的符号序列,然后传递到下一个阶段,即语法分析器。
例2.1 如果源程序包含语句:华氏温度=摄氏度*1.8+32,句法分析器可以将该语句映射为七个标记的序列:<id><=><id><*><real><+><int> 。这里id是任何程序变量或标识符的标记,运算符=、*、和+本身就是标记,这两个常量分别被转换为标记real和int。
编译器构造方面的一大进步是创建了句法分析的生成器,一个像Lex这样的程序,将标记的描述作为输入,生成一个程序,将源程序分解为标记,并返回与源程序对应的标记序列。使Lex得以应用的抽象是正则表达式。像Lex这样使用正则表达式抽象的系统使用了许多有用的速记,使编写正则表达式更为简单,但不会更改可以在此抽象中定义的字符串集。
例2.2 在某些编程语言中,作为合法标识符的字符串集可以定义如下:
letter = [a-zA-Z]
digit = [0-9]
id = letter (letter+digit)*
在这个简写法中,像a-z这样的表达式表示 a 到 z 之间带有ASCII 码的单字符串的并集。因此字母的正则表达式在最初的三个运算符集合中:
a+b+...+z+A+B+...+Z
类似地定义数字,然后将标记<id>的字符串集定义为字母后跟0个或多个字母和/或数字串组成的字符串。
2.1.1 Lex程序产生之前:书目检索
从理论研究中可以很好地理解,正则表达式抽象可以编译成几种抽象实现之一,例如确定性或非确定性有限自动机(NFA和DFA)。然而,当需要解决实际问题时,仍有一些技术有待突破。
贝尔实验室在首次尝试自动搜索相关文献时采取了一个有趣的步骤:他们在磁带上保存了整个贝尔实验室图书馆的标题,并且开发了软件来获取关键字列表、找到包含这些关键字的文档。然而,当给定一长串关键字时,搜索速度很慢,因为它每搜索一个关键字就会遍历一次磁带。
Aho-Corasick算法对此做了改进,与单独搜索每个关键字不同,关键字列表被视为包含任何关键字出现的所有字符串集的正则表达式,即:
请注意,点是「任何字符」的扩展名。该表达式被转换为确定性有限自动机。无论涉及多少关键字,都可以在磁带上进行一次传递。每个标题由有限自动机检查一次,以查看是否在其中找到了任何关键字。
2.1.2 句法分析的生成器设计
本质上,Lex之类的句法分析的生成器与第2.1.1节体现的思想异曲同工。为每个标记编写正则表达式,然后对这些表达式应用联合运算符。该表达式被转换为确定性有限自动机,读取字符,直到找到与标记匹配的字符串前缀,然后删除从输入中读取的字符,将该标记添加到输出流中,并重复该过程。
还有一些额外的考虑,因为与关键字不同,标记之间可能存在一些复杂的交互。例如,虽然看起来像一个标识符,但它实际上是一个用于程序中控制流的关键字。因此,当看到这个字符序列时,词法分析器必须返回标记<while>,并非<id>。在 Lex 中,正则表达式在其输入文件中列出的顺序打破了诸如此类的歧义,因此所要做的就是在标识符之前列出关键字,确保关键字被正确区分,而不是被当作标识符。另一个问题是某些标记可以是另一个标记的前缀。如果输入的下一个字符是 =,我们不希望将 < 识别为标记。相反,我们希望将 <= 识别为标记。为了避免这样的错误,句法分析器被设计为一直读取,只要它所看到的内容被有限自动机接受为合法标记。
2.1.3 DFA的惰性评估
还有一种可以使用正则表达式抽象来提高算法的运行时间的优化方法——惰性评估。
你可能熟悉将正则表达式转换为确定性有限自动机的标准方法。正则表达式首先通过 McNaughton-Yamada 的算法转换为非确定性有限自动机。这种转换很简单,如果正则表达式的长度为 n,则生成最多具有 2n 个状态的 NFA。将NFA转换为DFA时,开始困难重重,这需要Rabin-Scott子集构造。在最坏的情况下,这种构造可以将具有2n个状态的NFA转换为具有
个状态的DFA,这实际上是不通的。在实践中,最坏的情况发生的概率很小。
然而,在正则表达式的其他应用中,可能并且确实会出现接近最坏情况的情形。grep 是最早的 UNIX 命令之一,代表「获取正则表达式并打印」。该命令将接受一个字符串并确定它是否具有给定正则表达式语言的子字符串。最简单的实现是将正则表达式转换为 NFA,然后再转换为 DFA,让 DFA 读取字符串。当DFA较大时,将NFA转换为DFA比扫描字符串要耗费更多的时间。
但是,当正则表达式仅用于一次扫描字符串时,有更有效的方法来实现命令,例如 grep。Ken Thompson 的第一篇研究论文表明,与其将小型 NFA 转换为大型 DFA,不如直接模拟 NFA 更有效。也就是说,读取字符串的 NFA 通常在读取每个字符后处于一组状态中。因此,只需在每个字符之后跟踪这些 NFA 状态,并在读取下一个字符时,从前一组状态构建该字符可到达的状态集。
通过 NFA 到 DFA 的惰性转换可以实现更高的效率。也就是说,每次读取一个字符的输入字符串,然后将到目前为止所读取的前缀实际产生的 NFA 状态集制成表格。这些 NFA 状态集对应于 DFA 状态,因此我们只构建处理此特定输入字符串所需的 DFA 转换表部分。如果给定正则表达式的 DFA 不太大,完成处理字符串之前将构建大部分或全部的DFA,会获得直接使用 DFA 的好处,而不是在字符串的每个字符后构造NFA状态集。但是如果DFA比字符串大,大部分的DFA永远不会被构造,所以我们会充分利用这两种情况。这项改进是在名为 egrep 的 grep 版本中实现的。
图2. 表达式 a + b * c 的语法树
2.2 上下文无关文法和语法分析
编译器的第二个阶段,语法分析器或「解析器」将词法分析器生成的标记序列映射为树状表示,从而明确标记序列中的语法结构。一种典型的表示是语法树,其中每个内部节点代表某个结构,该节点的子节点代表该结构的组件。
例2.3 语法分析器可以将标记序列 a+b*c 映射成如图2所示的语法树。这里,E代表一个表达式。操作数a、b和c本身就是表达式。但b*c也是一个表达式,由运算符标记*和两个表达式b和c组成。在根部,我们看到另一个表达式,这个表达式使用运算符+和两个操作数表达式a和b*c。
遵守有关运算符优先级的许多约定很重要。通常,乘法优先于加法,这就是为什么语法树在加a之前将b乘以c,而不是先将a和b相加。
给定编程语言所需的语法树结构通常由声明性抽象定义,即上下文无关文法(context free grammar,CFG),希望读者熟悉此概念。CFG 是称为产生式规则的集合,提供了从其他句法类别和终端(句法分析器生成的标记)构造各种语法类别(如表达式或语句)的方法。例如,如果 E 表示该语言的良构表达式的语法类别,那么我们可能会找到如下规则:
,这意味着一种构造表达式的方法是在两个较小的表达式之间放置一个加号。
2.2.1 LR(k)语法分析
在20世纪60年代,有一系列关于如何从CFG构造高效语法分析器的提议。人们认识到,对于通用编程语言,只要语法具有某些属性,就可以对程序进行一次从左到右的扫描,而无需回溯,并根据该语言的语法构建语法树。
有些决定很棘手。例如,在处理表达式a+b*c时,仅读取a+b后,必须决定是否将表达式a和b与加号组合成更大的表达式。如果向前看一个标记并看到*,就会知道把a和b结合起来是不正确的,但必须继续前进,最终把b和c结合起来。只有在此基础上,把a和表达式b*c结合起来才是正确的。
这种语法分析方式称为「移位-归约解析」。在扫描输入时,每一步都需决定是通过将下一个输入标记推入堆栈来移动它还是对堆栈顶部的符号进行归约。归约时,归约的符号必须在CFG的右侧。这些符号出栈后被替换到同一产生式的左侧。此外,为产生式左侧的符号创建语法树节点。它的子节点是刚刚出栈的符号对应的树根。如果一个标记出栈,它的树只是一个节点,但如果一个语法类别出栈,那么它的树就是之前为堆栈上的符号构造的树。
Don Knuth提出了LR(k)语法分析,适用于最普遍的语法类别,对输入进行单次从左到右扫描,使用移位-归约范式并查看输入前面的最多k个符号后可以正确解析。这项工作似乎解决了语法分析器应该如何构造的问题。然而,并非每个CFG,甚至每个典型编程语言的CFG,都满足成为任何 k 的 LR(k) 文法所必需的条件。虽然普通编程语言似乎确实有LR(1)语法,即仅使用输入上的一个先行符号就可以进行移位-归约分析的语法,但这些语法的设计相当复杂,通常比直观需要的语法类别多出一个数量级。
2.2.2 Yacc语法分析的生成器
因此,在 Knuth 的论文之后,有几次尝试寻找使用 LR(1) 解析的方法,但要使其适用于更简单的 CFG。我们受到普林斯顿大学的一位研究生 Al Korenjak 的启发,他的论文是关于压缩 LR(1) 解析器的方法。我们茅塞顿开,对于通用语言,可以从一个非LR(1)的语法开始,仍然为该语法构建一个从左向右的移位-归约解析器。当语法不是LR(1)形式时,在某些情况下,我们也可以使用两种不同的产生式进行归约和移位或只进行归约。但是我们可以通过考虑运算符的优先级并在输入中向前看一个标记来解决实际情况中的歧义。
例2.4 考虑例2.3中所提及的情况。在处理输入a+b*c的a+b之后,堆栈的顶部将有E+E,其中a和b之前都被简化为表达式。存在产生式 E → E + E,可以将 E + E 归约成一个 E,并用标签 E 和子式 E、+ 和 E 构建解析树的一个节点。但是 * 优先级高于+, 我们看到 * 作为下一个输入符号,这说明将 * 移到堆栈上是正确的。稍后,我们也移动 c 并将 c 归约为表达式 E。此时堆栈顶部有 E + E * E。我们正确地将前三个符号归约成 E,留下 E + E。现在,将这些符号归约成 E 是正确的,因为没有任何内容输入(或者还有其他不属于表达式部分的输入,例如结束语句的分号)。通过这种方式,我们将生成如图 2 所示的语法树。
我们在贝尔实验室的同事 Steve ohnson 采纳了这个想法并实现了一个名为 Yacc的语法分析的生成器。为了帮助解决移位和归约操作之间的歧义,或者两个不同产生式的归约之间的歧义,Yacc 根据产生式出现的顺序进行判断。在两个产生式都可以归约的情况下,无论哪个产生式首先出现都是首选的。为了解决移位和归约之间的冲突,假设在 Yacc 输入文件中首先出现的运算符优先。
Yacc很快成为了编译器实现的重要工具,编译器不仅指传统编程语言的编译器,而且包含许多用途更有限的“小众语言”的编译器。与 Lex 一起,Yacc 提供了一种试验新语言句法结构设计的简单方法。这两种工具贯穿学术界整个学期的编译器课程,学生在课程中设计并实现一种新的领域特定编程语言。
3
大规模数据抽象
我们需要几种新的抽象来考虑最大的可用数据集和可用于操作它们的算法。第1.6节的二级存储模型很重要,但也存在其他表示各种形式的并行和分布式计算的抽象。我们将在这里概述最相关的抽象。
3.1 数据的关系模型
首先,Codd 的关系模型已被证明是处理大规模数据的核心。简而言之,数据被组织为表或关系的集合,其中两个示例如图 3 所示。左侧是一个名为 Cities 的关系,它有两列:City 和 State。关系的模式是它的名称和列名列表,在本例中为 Cities (City, State)。关系本身是表格中一组行数据或元组。例如,(Toronto, Ontario)是关系 Cities 的其中一行记录。第二种关系称为States,它有名为 State、Country 和 Pop(该州的人口,以百万计)的列。
图3. 两种关系:Cities (City, State) and States (State, Country, Pop)。
为关系模型选择编程语言是一件趣事。Codd 可以将关系模型视为嵌入在通用语言中的基本抽象,如树或图。关系语言的操作是简单的导航步骤,例如「在给定的行和列中查找值」或「给定一行,查找下一行」。事实上,早期的数据库抽象,例如网络和层次模型,正是采用这种方法。但Codd的观点是一种声明性的抽象,随着编程语言的发展,这种选择一直在跟进,有助于使关系模型成为数据库管理的主要方法。
在最初的表述中,关系模型的编程语言被认为是非递归的一阶逻辑,或者等价于五种代数运算的集合,即并集、差集、选择、投影和连接,称为关系代数。最后三种运算可能比较生疏,定义如下:
- 选择:在关系R的列名上采用条件C,并返回满足条件C的R行。例如,如果将条件「Country=India」应用于图3中的关系状态,会得到一个新的关系,它的列名为State、Country和Pop,但只包含第二行和第六行状态。
- 投影:为一个关系获取一组列名,并生成一个具有相同行集的新关系,但只包含获取的列。
- 连接:接受两个关系和一个涉及两个关系的列名的条件 C,并通过以下方式生成一个新关系:1)考虑到每一对行,每个关系中的某两行;2)如果这两行中的值满足条件 C,则将两行合并后添加到结果关系中。
3.2 SQL抽象
关系模型提出后不久,编程语言SQL的开发就向前迈出了一大步。在最初的表述中,SQL仍然不是图灵完备语言。然而,它确实支持比原始关系模型更多的功能。底层数据模型支持集合和包,同一行可以出现多次,还可以根据一列或多列的值对关系中的行进行排序。除了前面描述的关系代数操作符之外,SQL还支持分组和聚合,允许程序员根据一个或多个属性中的值对关系的行进行分组,然后对每组中一列或多列的值进行聚合,例如求和或求平均值。
例 3.2 考虑图 3 中的关系 States。我们可以按 Country 列的值对行进行分组,然后对每个国家/地区的各州人口求和。结果表如图 4 所示。
图 4. 按Country分组并对 Pop 求和。
随着SQL的发展,更多的功能被纳入标准,包括编写递归程序的能力,以及在通用编程语言中调用代码的能力。因此,原则上,SQL现在是图灵完备的。但绝大多数SQL程序都没有使用使其图灵完备的特性,所以在实践中,仍然有可能以一种利用许多优化机会的方式编译SQL,而这种优化就是我们所说的声明性抽象。
3.3 SQL编译
用 SQL 编写的程序通常被编译成低级语言,例如 C语言。C 代码大量使用库函数,例如执行选择或连接等操作。SQL编译的早期阶段(词法分析和句法分析等)与任何通用语言的编译阶段相似。SQL与规范的不同之处在于代码优化阶段(通常称为查询优化)。回想一下,诸如 C 这类语言的优化必须满足在各处保存机器指令,因此将速度提高两倍是一个较好的优化结果。但是SQL和关系模型的操作通常比机器指令强大得多。例如,语法树的一个操作符可以连接两个巨大的关系。
因此,与C程序或其同类程序相比,SQL程序由相对较少的步骤组成,但如果按原样实现,这些步骤可能会花费大量时间。因此,SQL 的编译器通常会几乎穷尽搜索等效的语法树,从而减少几个数量级的执行时间。即使花费与SQL程序大小成指数关系的时间来优化一个只执行一次的程序也是有意义的,因为这个程序通常会在较大的关系上执行。
3.4 分布式计算抽象
多年来,人们已经认识到单处理器的能力正在达到极限。为了处理越来越大的数据集,有必要开发使用多台独立机器的算法。许多引发我们思考的分布式计算算法的抽象已经实现,并且正在被重点使用。
总的来说,这些抽象有一些共同的特点:
- 它们的数据模型是传统编程语言的模型,但数据分布在许多不同的任务中,我们称之为「计算节点」。实际上,多个任务可能在同一个处理器上执行,但将这些任务视为处理器本身便于分析问题。
- 程序也用常规语言编写,但同一程序可以在各个节点上同时运行。
- 有一种设备可供节点与其他节点通信。这种通信分阶段进行,并与计算阶段交替进行。
这类抽象有几个不同的性能指标值得关注。显而易见的一点是并行执行所有节点上涉及的程序所需的挂钟时间。但有时,瓶颈在于节点之间通信所需的时间,尤其当需要在节点之间共享大量数据时。第三个运行时间问题是算法的轮数(一个计算阶段后接一个通信阶段)。
3.5 批量同步抽象
Valiant 的批量同步模型是一种流行的抽象,我们不再详细讨论。该模型最近在 Google 的 Pregel 系统的计算集群环境中得到普及,并已经拥有了许多类似的实现。
在批量同步模型中,计算节点可以被视为完整图的节点。在初始化阶段,每个节点对其本地数据执行初始化程序,从而为其他特定节点生成一些消息。当所有的计算完成后,所有的消息都被传送到目的地。在第二轮中,所有节点对其传入消息和本地数据执行「主」程序,这可能会导致生成额外的消息。计算结束后,这些消息被传送到它们的目的地,第三轮开始,主程序再次在新传入的消息上执行。这种计算和消息传递的交替继续进行,直到在某一轮中不再生成消息。
3.6 映射归约抽象
映射归约是一种抽象,已被证明是一种非常强大的工具,可用于创建并行程序,而无需程序员明确考虑并行性。谷歌的Jeff Dean 等人最初在Hadoop上实现,最近在Spark上的实现也推广开来。此外,该模型能够轻松支持通常花费时间最多的关系模型操作:连接和分组/聚合,以及对大规模数据的许多其他重要操作。
映射归约的数据模型是一组键值对。然而,这种意义上的「键」通常不是唯一的;它们只是成对的第一个组成部分。映射归约中的程序是用一些传统的编程语言编写的,每个映射归约作业都有两个关联的程序,不足为奇,它们分别称为「映射」和「归约」。作业的输入是一组键值对。映射程序被编写为应用于单个键值对,并生成任意数量的键值对作为其输出。输出对的数据类型通常与输入对的类型不同。由于映射独立地应用于每个键值对,所以我们可以创建许多任务,称为「映射器」,每个任务都会获取输入对的一个子集,并将映射程序应用于每个键值对。因此,映射程序可以使用尽可能多的处理器并行执行。
映射器完成工作后,通信阶段会获取应用于所有输入对的映射的所有输出,并根据键对它们进行排序。也就是说,输出键值对的整个集合被组织成归约器,每个归约器都是一个键,比如x,以及所有相关值的列表,也就是y的列表,这样就有了一个输出对(x,y)。然后我们在每个归约器上执行归约程序。由于每个归约器都独立于其他归约器,我们可以将归约器组织成任务,并在不同的处理器上运行每个任务。整个作业的输出是由每个归约器生成的键值对集。
4
量子计算
近期,全世界对量子计算和量子编程语言兴致勃勃。量子计算特别有趣,因为量子编程语言中的计算模型与经典编程语言中的计算模型大相径庭。
故事从量子力学开始,量子力学是20世纪初期发展起来的物理学基本理论,它描述了原子和亚原子粒子尺度上的自然物理性质。我们将介绍量子力学的基本假设,根据这些假设可以推导出量子力学的所有定律。从这些假设出发,我们可以导出量子电路的抽象,这是量子编程语言的基本计算模型之一。
4.1 量子力学的假设
复线性代数和希尔伯特空间(具有内积的复向量空间)通常用于描述量子力学的假设。Nielsen和Chuang的著作《量子计算与量子信息:十周年纪念版》是学习这门学科的重要参考书籍。首先,让我们回顾一下在假设中使用的复线性代数的一些基本定义。将运算符视为作用于向量的复数矩阵会对理解很有帮助。矩阵U的厄米特共轭形式为U,代表矩阵U的共轭转置,即先取U的转置,再对每个值的复数部分求反。
酉算子的概念是量子力学的核心。如果UU = /,则运算符U具有幺正性,其中/ 是恒等式。这意味着每个酉变换的作用都是可逆的。可逆意味着可恢复原状,也就是说,我们可以根据输出重构输入。如果U = U,则称算子U为厄米特算子,厄米特算子是自伴算子。
假设1:孤立物理系统的状态空间可以用希尔伯特空间来建模。系统的状态完全由状态空间中的单位向量描述。
假设 1 允许我们将量子比特定义为二维状态空间中的单位向量。量子比特是经典计算中比特(0或1)的量子计算模拟。如果向量
和
用作二维希尔伯特空间的正交基,则该空间中的任意状态向量
可以写成
或
。其中α和β是复数。因为
是单位向量,故
。
量子比特
表现出一种称为叠加态的量子力学的固有现象。与经典计算中的比特总是0或1不同,在α和β未知的情况下,不能说量子比特
肯定处于状态
或肯定处于状态
。我们只能说它是这两种状态的某种组合。
假设2:封闭量子系统的状态从一个时刻到另一个时刻的演化可以用酉算子来描述。
有一种使用薛定谔方程来表述假设2的等效方法。但是,我们在这里只考虑酉公式,因为它自然地引出了量子电路计算模型。
假设3:为了从封闭的量子系统中获取信息,我们可以对系统进行测量。以某种概率返回测量结果。可能结果的概率之和为 1。测量会改变量子系统的状态。
我们不会深入探讨假设3的细节,但出于讨论的目的,我们可以将单个量子比特
的测量视为厄米特算子,它以
的概率返回结果0,以
的概率返回结果1。回想一下,因为
是单位向量,故
。测量将状态向量坍缩至二维希尔伯特空间的两个基向量之一。我们注意到,海森堡著名的量子力学不确定性原理可以根据复线性代数规则和假设1-3推导出来。
第四个假设展示了当我们组合物理系统时,复合物理系统的状态空间的维数如何增长。
假设4:复合物理系统的状态空间是组成物理系统的状态空间的张量积。
假设 4 表明,如果我们将单个量子比特添加到物理系统,其状态空间的维度会加倍。因此,如果我们组合n个单量子比特系统,通过取n个单量子比特系统的状态空间的张量积,得到一个状态空间维度是
的复合系统。状态空间的这种指数式增长使得在经典计算机上模拟大型量子系统的行为将困难重重。
4.2 量子电路
从量子力学的四个假设出发,我们可以导出一个称为「量子电路」的计算模型,这是量子编程语言的基本抽象。量子电路由量子门和量子线路组成。它们类似于经典计算中的布尔电路,但有几个重要的区别。将量子门视为复数的正交矩阵,并将其输出视为通过将矩阵应用于输入向量而获得的向量,这对于分析很有帮助。
1)单量子比特门
单量子比特门有一条通向门的线路和一条引出门的线路。输入线路将一个量子比特
馈送到量子门。该量子门将酉变换U应用于传入的量子比特
,并将输出的量子比特
传送到输出线路上。
在经典的布尔电路中,只有一个非平凡的单位逻辑门,即布尔非门。在量子电路中,二维复希尔伯特空间中的任何酉变换都可以是单量子比特的量子门。这里介绍两个重要的单量子比特门。
例 4.1 量子非门,通常表示为X,将量子比特
映射为量子比特
。从根本上说,它翻转了二维希尔伯特空间中表示量子比特的向量系数。注意
以及
。
量子非门X可以用
矩阵表示:
例 4.2 量子哈达玛门表示为H,将量子比特
映射成量子比特:
请注意恒等运算符HH = I。
量子哈达玛门H可用
矩阵表示:
还有许多其他有用的单量子比特的量子门。
2)多量子比特门
多量子比特的量子门具有通向门的n条输入线路和从门发出的n条输出线路。该逻辑门由一个酉算子U组成,可以用一个
的复数矩阵表示,该矩阵的行和列是正交的。
例4.3 受控非门(简称CNOT)是一个非常有用的双量子比特门。它有两条输入线和两条输出线,一条称为控制线,另一条称为目标线。开关作用的动作如下:如果控制线的输入量子比特为
,则目标线上的量子比特将不变地通过;如果传入的控制量子比特为
,则翻转目标量子比特。在这两种情况下,控制线的量子比特都不会发生改变。如果
表示为
(量子比特
和
的张量积),那么我们可以将CNOT 门的作用描述如下::
3)电路
量子电路是量子计算和量子编程语言的基础计算模型,是由线、量子门和测量门组成的非循环图。因为量子电路是非循环的,所以不存在回路或反馈。由于逻辑或不是酉运算符,所以线路连接在一起的地方不存在扇入。此外,在量子力学中,不可能复制未知的量子态(不可克隆定理),因此也不可能进行扇出。
测量门将一条线路作为输入,在状态
中引入单个量子比特,并产生一个概率经典比特作为输出,以
的概率取值为0或以
的概率取值为1。
我们用一个例子来结束量子电路的讨论,这个例子阐释了量子计算的一个不同寻常的特性:纠缠。
图5 根据输入|00\rangle生成EPR状态的量子电路
例4.4 如图5所示,考虑一个具有两条输入线路x和y的量子电路。x线路连接到哈达玛门,哈达玛门的输出成为CNOT门的控制线。y线路是CNOT门的目标线路。我们将其称为 EPR 量子电路,以Einstein, Podolsky和Rosen名字的首字母命名,他们指出了该电路输出状态的奇怪特性。以下是该电路对两个输入量子比特
的四个值的变换:
可以将量子电路的操作描述为状态向量的序列,这些状态向量展示了在应用每一级门之后量子系统的状态。对于图5,将各阶段获得的状态向量总结如下:
1)H门之前:
2)在H 门之后CNOT门之前:
3)CNOT门之后:
复合量子系统的状态不能写成其组成系统状态的张量积,这称之为纠缠态。可以看出上面的 EPR 输出状态是纠缠的。不存在两个单量子比特状态
和
使得下式成立。
纠缠在量子计算中的作用至关重要,但纠缠的物理现象对物理学家来说仍然是一个谜。事实上,爱因斯坦称其为“超距离的幽灵效应”。
4.3 量子算法
量子计算设备很可能被用作由经典计算机控制的辅助设备。量子计算机程序通常表示为经典计算和量子算法的混合体。量子算法经常呈现为具有以下结构的量子电路:
1) 电路开始时将所有输入量子位设置为特定状态,通常为
。
2)电路处于叠加状态。
3)电路通过幺正门作用于这种叠加。
4)通过测量门将经典比特(0 和 1)作为输出返回到控制的经典计算机,对电路中的量子比特进行测量。
量子计算在 1994 年迎来了飞跃式发展,当时贝尔实验室的Peter Shor发表了一种在混合经典计算机/量子计算机上分解n位整数的算法,其时间复杂度为
。即使今日,也没有可以用多项式时间在经典计算机上分解整数的算法。
Shor利用经典数论将整数分解问题简化为寻序问题。求序问题如下:给定正整数x和N,其中x<N 且x互质于N,求最小正整数r,使得
。整数r被称为N中x的阶数。例如,21中5的阶数是6,因为要使
成立,6是最小的正整数。
Shor设计了一种量子算法,用多项式数量的量子门来解决寻序问题。目前还没有已知的算法可以在多项式时间内解决经典计算机上的寻序问题。
量子算法通常使用传统计算机算法中没有的特殊技术。例如,Shor的算法使用量子傅里叶变换作为其寻序计算的一部分。
5
未来方向
抽象对计算机科学的许多领域产生了相当大的影响。关于计算机科学中的抽象故事还有更多的论文。以下是一些理论研究者可能会感兴趣并且具有实际意义的方向。
5.1 量子未来
量子计算仍然是一个刚刚起步的领域。虽然量子电路可以将任意单一算子近似到任何期望的精度,但今天的量子门计算机只有50到100个可用的量子位。此外,实用的量子算法屈指可数,因此在量子计算的硬件和算法领域都需要做更多的工作来克服这些限制。
在理论上,许多悬而未决的问题也仍然存在。例如,如果我们可以证明不能在多项式时间内在经典计算机上分解整数的问题,那么我们将有一个量子计算机比经典计算机更快地解决问题的示例。这只是许多尚未解决的深层理论问题之一。你可能会希望向 Aaronson 咨询量子抽象中的算法挑战列表。
目前研究人员已经开发了许多全栈量子计算编程语言。哥伦比亚大学的博士生 Krysta Svore 表明,第 2 节中讨论的编译器架构可以与纠错结合到量子计算设计工具的分层软件架构中。毕业后,她加入了微软研究院,在那里她和她的同事随后开发了 Q# 量子编程语言,它是微软量子开发工具包的一部分。
5.2 计算机系统和硬件的抽象
映射归约和其他针对特定类型计算平台(本例中为计算集群)的高级抽象的成功表明,其他平台可能也有类似的抽象。例如,目前人们对无服务器计算很感兴趣,其中数据仅保存在文件系统中,并且通过在短时间内租用一台或多台服务器来完成计算。
在较小的规模上,专用硬件是一种增长趋势,并且很可能在加速对大规模数据执行重要算法方面发挥越来越重要的作用。你可能听说过图形处理单元(GPU)和现场可编程门阵列(FPGA)。Plasticine 是设计的另一种用于支持高通信带宽和并行性的芯片,可能很快就会上市。拥有与这些体系结构相匹配的高级抽象将行之有效,因为使用这些抽象编写的算法可以利用一种或多种芯片类型编译成高效的实现。
5.3 抽象分类法
多年来,人们发明了与编程语言处理相关的强大抽象,帮助编译器设计领域从一门艺术转变为一门科学。但最后的论文还没有写完。扩展我们在 1.2 节中抽象的基本分类法以涵盖更多编程语言和编译器领域,甚至更多的计算机科学领域,这将大有裨益。与连续运行的系统(如操作系统、网络和互联网)相关的抽象自然会包含在内。
此外,我们希望通过数据结构课程中组织的讲座,大家能认识到分类法的强大远不止如此。我们更希望研究是什么让一种抽象比另一种更有用。例如,我们在 3.1 节中提到关系模型如何自然地成为声明性抽象,而以前的数据库模型不适合 SQL 等语言,这为高阶编程的出现奠定了条件。类似地,正则表达式似乎非常适合描述编程语言标记和其他有趣的字符串集,而等价的表示法,例如 Chomsky 的 type-3 语法(CFG 的一种特殊情况)在句法分析等应用程序中从未发现太多用途。可能自然会问:“为什么会这样?”
一个有趣的新领域是使用机器学习来创建使用数据而不是用某种编程语言编写的源程序的软件应用程序。从某种意义上说,机器学习是一种不涉及传统编译的软件创建方式。可以指导使用机器学习有效创建强大应用程序的抽象将受益匪浅。
原文链接:
https://cacm.acm.org/magazines/2022/2/258231-abstractions-their-algorithms-and-their-compilers/fulltext
雷峰网 |
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
×
|