MIT 6.001 Structure And Interpretation Of Computer Programs(SICP)学习笔记(三)

Lecture 5 Data Abstraction

        在lecture 5中,我们将继续构建抽象的主题。到目前为止,我们一直在关注过程抽象:在过程中捕获通用计算模式的想法,并将该计算的细节与其他计算中概念的使用隔离开来。今天我们将讨论一个补充问题,即如何将信息或数据组合成抽象结构。

        我们将看到:我们可以将数据如何粘合在一起的细节与在某些计算中将聚合数据结构用作primitive分开。我们还将看到,我们用来操作数据结构元素的过程通常具有模仿数据结构的固有结构,我们将使用这个想法来帮助我们设计Data Abstraction及其相关过程。

        回顾一下到目前为止用于捕获计算的procedural abstraction的想法。该想法是采用一种通用的计算模式,然后通过使用一组参数将其形式化来捕获该模式,这些参数指定模式中发生变化的部分,同时将模式保留在过程体内。这封装了与 lambda 对象内的模式相关的计算。一旦我们在 lambda 中抽象了该计算,我们就可以使用我们的定义表达式给它一个名称,然后通过引用该名称将整个事物视为primitive,并使用它而不必担心 lambda 中的细节。

正数平方根计算

        代码如下:

\\顶层程序
(define try 
    (lambda (guess x) (if (good-enuf? guess x) guess (try (improve guess x)x)))    
)
\\更新程序
(define improve (lambda (guess x) (average guess (/ x guess))))
\\求平均程序
(define average (lambda (a b) (/ (+ a b) 2)))
\\判定结束程序
(define good-enuf? (lambda (guess x) (<( abs(- (square guess) x)) 0.01)))
\\主程序
(define sqrt (lambda x (try 1 x)))

        try检查猜测猜测是否足够接近。如果是,它只是返回该猜测的值。如果不是,那么它会再次尝试,并进行新的猜测。而具体的其他辅助程序将在try运行过程中不断被调用。

        如上图所示,我们可以将所有程序的集合视作一个过程的宇宙。每个过程都有不同的计算过程,而用户只需要引用其名称就能够使用。其中average,sqrt等过程可以用于其他问题的计算,但是比如good-enuf?和try是特定于平方根计算的辅助程序,所以我们会想要这二者只能被sqrt调用。

        进一步讲,就是,我们想要将good-enuf?和try二者抽象于sqrt过程中,这样能够给客户一个集成度更高的程序而且也防止了对于good-enuf?,try的外部调用(即外部不可见)。

        请注意下面的代码是如何实现的。

         可以看到,我们在lambda里面定义了good-enuf?、improve、sqrt-iter的具体计算过程,他们现在是internal procedure。只能在lambda内部被引用,如果有外部引用,会得到一个未绑定变量错误unbound variable error。evaluation规则规定,在引用sqrt时,其主体是(sqrt-iter 1.0),然后会evaluate其中的内部定义。

        事实上,我们可以通过在最外层 lambda 的边界周围画一个框来强调这一点。显然,这个边界恰好涵盖了我想要的黑盒抽象。这被称为块结构block structure,可以在教科书中找到更详细的讨论。

        从本质上讲,这意味着 sqrt 只包含属于它的内部过程,并根据用户期望的契约进行行为,而用户不知道这些过程如何完成此契约。这提供了另一种从其他抽象中抽象并隔离特定过程的方法。

        下面是对这一小节的总结

 Data Abstraction

        为了更好的理解为什么需要data abstraction?我们将简单回顾下如何使用language elements组成不同层次的结构。

        在atomic级别,我们有primitives以及对应的built-in procedures。通过combination的方法能够将他们组合成对应的combination表达式而这些表达式本身能够作为其他表达式的元素。最常见的一种就是程序调用procedure application。最后还有一种abstraction的方法——捕获对应的elements并将其视为primitive的方法。第一种abstraction方法是define,可以为元素命名,以便只使用其名称,从而抑制对象使用中的细节。这一特点在与第二种abstraction方法——lambda联合使用的时候尤其明显。通用的计算模式可以概括为单个过程,该过程涵盖了该想法对适当值的每种可能的应用。当与为该过程命名的能力相结合时,我们产生了在我们的语言中创建一个重要循环的能力:我们现在可以创建过程,命名它们,从而将它们视为语言的原始元素。高级语言的整体目标是让我们以这种方式抑制不必要的细节,同时专注于使用过程抽象来支持一些更复杂的计算设计。

        下面,我们将概括抽象的概念,以包括那些关注数据而不是过程的抽象。因此,我们将讨论如何创建复合数据对象,并且我们将研究与这些数据结构的操作相关的标准过程。我们将看到数据抽象反映了过程抽象的许多属性,因此我们将复合数据的思想概括为数据抽象,以补充我们的过程抽象。

        到目前为止,本课程在Scheme 中看到的几乎所有内容都围绕着数字和与数字相关的计算。这在一定程度上是有意为之的,因为我们希望专注于程序抽象的想法,而不是陷入其他细节的泥潭。然而,显然存在以下问题:用其他元素来思考比只是使用数字更容易,并且这些元素的各个部分需要粘合在一起和拆开,同时保留更大单元的概念。

cons pair(“二元列表”)

        因此,我们的目标是创建一种方法来获取原始数据元素,将它们粘合在一起,然后将结果视为primitive。当然,我们需要一种“解粘合”的方法,以取回组成部分。如何理解粘合这一做法呢?简单来说,我们想要它有与数字相同的属性:我们可以对它们应用过程,我们可以使用procedure生成它们的新版本,并且我们可以创建将它们作为更简单元素包含在内的表达式。

        当我们将事物“粘合”在一起时,最重要的一点是制定与该过程相关的协议contract。这意味着我们并不真正关心如何将事物粘合在一起的细节,只要我们有办法在需要时收回各个部分即可。这意味着“glue”和“unglue”齐头并进,保证了复合单元创建后,我们总能取回我们前面使用的各个部分。

        理想情况下,我们希望将事物粘合在一起的过程具有封闭性——即无论我们通过将事物粘合到复合结构中得到什么,都可以将其视为原语,以便它可以作为另一个粘合操作的输入。并非所有创建复合数据的方法都具有此属性,但其中最好的方法都具有此属性,并且如果结果本身可以是相同复合数据构造过程的primitive,则我们说它们在创建复合对象的操作下是封闭的。

        Scheme 将事物粘合在一起的基本方法称为 cons,是构造函数的缩写,几乎所有其他创建复合数据对象的方法都基于 cons。

        Cons 是一个接受两个表达式作为输入的过程。它依次评估每个值,然后将这些值粘合在一起形成称为“对(pair)”的值。请注意,实际的pair对象是通过评估cons之后返回的值。 cons 对的两个部分称为 car 和 cdr,如果我们将这些名称的过程应用于一对,我们将返回创建该对时评估的参数值。

        请注意,cons、car 和 cdr 之间有一个契约,其中 cons 以某种任意方式将事物粘合在一起,最重要的是,例如,当 car 应用于该对象时,它会返回我们开始的内容和。请注意,我们可以将一对视为一个单元,也就是说,构建了一对后,我们可以将其视为基元,并在可能使用任何其他基元的任何地方使用它。因此,我们可以将一对作为输入传递给其他一些数据抽象,例如另一对。

         上图中,使用cons创造了一个pair,名为P,然后其中的<x-exp>、<y-exp>分别对应的值是x-val和y-val。(我的理解是和数组类似,其中第一个元素是x-val而第二个是y-yal,使用(car <P>)返回P这一个pair的第一个元素的值,同理(cdr <P>)对应第二个元素。

         我们可以根据一对的抽象来形式化我们刚刚看到的内容。这个抽象有几个标准部分。

        首先,它有一个构造函数(constructor),用于创建此抽象的实例。构造函数有一种契约,其中对象 A 和 B 粘合在一起以构造一个新对象,称为 Pair,内部有两个部分。

        其次,它有一些选择器或访问器(accessors)来取出片段。请注意合约如何指定构造函数和选择器之间的交互,无论放在一起的任何内容都可以使用适当的选择器将其拉开。

        通常,数据抽象还会有一个谓词,这里称为pair?(predicate)。它的作用是接收任何对象,如果该对象是pair类型则返回true。这使我们能够测试对象的类型,以便我们知道是否将特定的选择器应用于该对象。

        那么我们如何使用对的想法来帮助我们创建计算实体呢?为了说明这一点,让我们使用如下图几张图的示例。假设我们使用适当的构造函数构造几个点,然后将这些点粘合在一起形成一个线段。

        下面这个例子比较难懂,大家可以用草稿纸也自己写一下,方便记忆和理解。(反正我是看了好一会儿才理通顺)

        定义了make-point函数,它返回一个pair(正好对应坐标(x,y))。point-x、point-y则是分别返回变量point的x,y坐标。make-seg返回一个pair,包含pt1,pt2(值得注意的是,根据定义p1、p2本来就是pair)。start-point返回对应变量seg中的car元素(第一个元素)。

        下面是简单的画点,画出了p1、p2两个点,然后使用meke seg将p1和p2连接成线段(返回一个pair,元素是p1、p2)。然后定义了stretch-point用于将点进行拉伸(位移)。这里插一句,cons的pair被打印出来的形式就是以(car,cdr)的形式被打印出来的。

         下面则是继续定义了一个拉伸线段的函数stretch-seg,调用strentch-point拉伸对应点之后连成线段。而seg-length则是测量长度,使用\sqrt{(x1-x2)^{^{2}}+(y1-y2)^{2}))}计算。

         这些结构恰好是由 cons pair构建的,但从代码设计者的角度来看,我们仅依赖于点和段的构造函数和选择器的契约。

        现在,假设我们决定要获取一组点(可能在相邻点之间定义了线段)并操纵这些点组。例如,图形可以定义为一组有序点,每对连续点之间都有线段。我们可能想要拉伸整个组,或者旋转它,或者对其做其他事情。

        我们如何将这些东西组合在一起?好吧,一种可能性就是使用一堆cons pair,如下所示。尽管这是一种将事物粘合在一起的完全合理的方式,但它却很难操纵。假设我们想拉伸所有这些点?我们必须编写代码,将car和 cdr 的正确集合组合在一起,以取出各个部分,对它们执行计算,然后将它们再次粘合在一起。这将是极大的痛苦!如果我们有一种更方便、更传统的方式将一组事物粘合在一起,那就更好了,幸运的是我们做到了。

List

        Scheme 还有一种将任意对象集粘合在一起的原始方法,称为列表,它是一个数据对象,其中包含任意数量的有序元素。

        当然,我们可以通过将一组事物组合在一起来制作一个列表,使用我们需要的任意对。但将列表视为基本结构要方便得多,下面是我们如何更正式地定义这种结构。列表是一系列对,具有以下属性。列表中一对的car 部分保存列表的下一个元素。列表中一对的 cdr 部分保存着指向列表其余部分的指针。我们还需要告诉我们何时到达列表的末尾,并且我们有一个特殊的符号 nil,它表示列表中不再有对即结尾位置。

        另一种说法是列表是以空列表结尾的对的序列。在该视图下,我们看到列表在 cons 和 cdr 的操作下是封闭的。要看到这一点,请注意,如果给定一个以空列表结尾的对序列,并且我们将任何内容添加到该序列上,我们将得到另一个以空列表结尾的对序列,因此是一个列表。

        类似地,如果我们获取以空列表结尾的对序列的 cdr,我们会得到以空列表结尾的较小的对序列,因此是一个列表。封闭的这个属性表明我们可以将列表用作其他列表中的原语。

        唯一的有欺骗性的就是当我尝试获取空列表的 cdr 时会发生什么,这一结果取决于Scheme的实现,因为在某些情况下它是一个错误,而对于其他Scheme它是空列表。在考虑封闭性时,后一种观点很好,因为它保留了列表的 cdr 是列表的概念。

        为了形象化列表这种新的传统收集元素的方式,我们使用框和指针表示法。首先,cons 对由一对方框表示。第一个框包含指向 cons 第一个参数值的指针,第二个框包含指向 cons 第二个参数值的指针。该pair还有一个指向它的指针,该指针是通过计算 cons 表达式返回的值,并表示实际的对。(如下图)

         列表仅由一系列对或框组成,我们通常将其绘制为水平线。每个框的 car 元素指向序列的一个元素,每个框的 cdr 元素指向列表的下一pair。空列表由最后一个pair的cdr 框中的对角线(nil)指示。

        可以看出,该列表很像一个骨架。 cdrs 定义了骨架的脊柱,悬挂在car上的是肋骨,其中包含元素。还要注意此可视化如何清楚地定义列表的封闭属性,因为获取列表的 cdr 为我们提供了结束空列表的新框序列。

        要检查某物是否是列表,我们需要两件事。首先,我们有一个谓词,null?检查对象是否为空列表(null?<z>)。然后,要检查一个结构是否是一个列表,我们可以使用pair?查看结构是否是成对的序列。实际上,我们确实应该检查序列是否以空列表结尾,但通常我们只是检查第一个元素是否是一个pair,或者由 cons 组成的元素。

        由于我们已经从 cons 对中构建了列表,因此我们可以使用 car 和 cdr 来取出碎片。但就本lecture而言,我们将通过为列表定义特殊的选择器和构造函数,将列表上的操作与pair上的操作分开,如图所示。因此,我们有一种方法来获取列表的第一个和其余元素,并将新元素放在列表的前面。请注意这些操作如何从 cons、car 和 cdr 的实现中继承必要的闭包属性。

创建列表的方法(common pattern 1 —— cons'ing up a list)

        下面是创建新的列表的方法,它能生成两点之间的数字序列(或列表)。此过程中有一个漂亮的递归调用。要生成这样的列表,代码将 from 的值cons或glue到递归调用enumerate-interval上。这将事情简化为同一问题的更简单版本,当只有一个空列表时终止。(博主的理解是,cons将from和返回的列表组成pair,而这个返回的列表里的结构也是如此。然后整个cons和列表的特点一样,属性也一样,所以可以视为列表?详见下图草稿。)

        如果这个过程在较小的问题上正确工作,那么邻接操作一定会返回一个新列表,因为它将一个元素粘合到一个列表上,并且通过闭包,这也是一个列表。这种归纳推理使我们能够推断出该过程正确地创建了正确类型的数据结构。

         下面是在这个示例上运行的替代模型的踪迹。请注意 if 子句如何将其展开为一个元素的相邻元素,并使用不同的参数对同一过程进行递归调用。由于我们必须先获取子表达式的值才能应用(adjoin)邻接操作,因此我们再次扩展递归调用,创建另一个延迟的邻接操作。我们继续这样做,直到我们了解基本情况,返回空列表的值。请注意,现在我们得到一个将创建一个列表的表达式,一个以空列表的特殊符号结尾的序列。

         现在准备计算最里面的表达式,它实际上创建了一个 cons pair——绘制了该pair以表明这是 adjoin 返回的值。请注意它如何具有正确的列表形式。下一步的新创建的 cons 对的 cdr 指针指向第二个参数的值,即先前创建的列表。这会导致这种结构。打印结果如上图所示,这是列表的打印形式。请注意创建对的顺序,并注意与此列表的创建相关的一组延迟操作。

common pattern 2 —— cdring down a list

        除了创建列表外,我们还有遍历列表的方法,cdring down列表。下面是一个简单例子,它查找列表的第 n 个元素,按照惯例,列表序号从 0 开始。请注意我们如何使用列表的递归属性来执行此操作。如果我们的索引是 0,那么我们想要第一个元素。否则,根据列表的封闭性,我们知道列表的第 n 个元素将是列表其余部分的第 n-1 个元素。因此,我们可以递归地将其简化为同一问题的更简单版本。(请注意,first是car的name而rest是cdr的name)

        对于所示示例,我们首先检查 n 是否为0。因为它不是,所以我们获取 joe 指向的列表,通过从第一个框的 cdr 部分中取出指针来提取该列表的其余部分,并在该结构上调用 list-ref ,并递减 n。递归调用直到 n=0,因此我们返回元素 3。

Further Pattern

        下图中的过程是使用相同类型的递归推理来计算列表中元素的数量。列表的长度定义为比列表其余部分的长度多 1,根据闭包属性,列表的其余部分也是列表。基本情况是空列表,其长度为零。然后我们可以将这两种想法放在一起,将一个列表向下删除,同时将一个新列表作为返回值。这是一个创建列表副本的示例。

        留意形式。如果给定一个空列表,我们只需返回一个空列表即可。如果没有,那么我们使用列表的递归属性。我们将输入列表的第一个元素连接到通过复制列表的其余部分得到的任何内容上。但是通过封闭性,输入列表的rest部分是一个列表,因此通过归纳保证复制可以返回一个列表。通过我们刚才看到的,我们看到 copy 将以完全相同的顺序创建列表的副本。

       假设我们想要将两个列表粘合在一起形成一个长列表,如上图示例所示。让我们使用相同的策略,在名为append 的便捷过程中实现。对于base case,请注意,如果第一个列表中没有任何内容,我们就只需要直接返回list2。否则,我们将使用上面的copy算法。我们将第一个列表的第一个元素与list1剩余部分和list2应用append后的列表连接起来。使用相同的封闭性,我们看到这将创建一个列表,然后 adjoin 会将第一个元素放在这个新列表的前面。

        (请注意使用列表的递归属性来构建此过程的方法以及想法,特别是请注意该过程的递归结构如何很好地模仿数据对象的递归结构。)

Common Pattern#3: Transforming a List

        因此,现在我们可以将这些想法用于处理更复杂的结构。先用list把一组点(p1-p9)组合成一个列表。然后,我们可以很容易地编写一个过程来扩展整个list,通过构建列表的奇妙递归性质。如下图。

        请注意,我们如何将拉伸组的问题细分为拉伸点的操作(使用适用于点的程序),然后将其添加到拉伸组的其余部分所得到的结果中。由于列表的递归性质,我们知道列表的其余部分是列表,因此我们可以使用归纳法得出结论,应用于较小集合的拉伸组将返回一个新的group,因此将新元素连接到前面显然会给我们返回一个group。

        如果我们想找到组的中点(或质心),我们可以将之前构建的片段放在一起,如下所示。

        Add-x和add-y的结构与我们前面的例子非常相似:它们只是沿着列表向下cdr,在它们遍历list时收集信息。每一个都将得到一个组中所有点的x和y值的总和。为了找到中点,我们需要得到x和y的平均值,所以我们需要知道组中有多少个元素,这是使用length得到的。我们可以结合这些信息在组的中间创建一个新的点。

        请注意新的格式let。您可以在教科书中找到详细信息,但只要将此视为第一组表达式(x-sum、y-sum和how-many)中的每个名称都绑定到这些名称后面的表达式的值就足够了。然后,在let表达式的范围内,这些名称只是这些值的本地名称,并被替换,就像我们在标准替换模型中一样。

        总结一下:我们已经看到,语言通常提供了将数据元素分组到结构中的传统方式,这里要么是成对的(cons pair),要么是任意长的集合(list)。与这些传统结构相关的是对它们进行操作的方法,并且这些程序通常具有模拟该结构的形式。例如,在将列表转换成列表的过程中,我们看到递归步骤通常包括使用选择器(selector)取出列表的各个部分,对每个部分进行操作,然后使用构造函数将各个部分重新组装成一个列表。

        这种形式意味着我们用来推理递归过程的归纳证明在这里也适用。我们经常可以推断出我们的过程和它们相关的数据结构的属性,依靠的是这样一个事实:归纳起来,过程在较小的数据结构上正确地操作。

        因此,让我们后退一步看问题,检查一下到目前为止我们已经构建了什么。我们基本上建立了一个数据抽象的层次结构,每一个都是由更简单的数据抽象构成的。底部是pairs,我们有一个来自Scheme的基本契约,关于cons、car和cdr如何作为数据抽象进行交互。最重要的是,我们建立了list,但是用户可以利用列表也是pairs的这一事实。事实上,我们通过使用car和cdr作为list的选择器直接做到了这一点。最重要的是,我们刚刚建立了groups,但是用户可以利用组是由列表的列表构成的这一知识。

         本质上,我们已经建立了一组抽象障碍,但是其中数据结构的实现细节与该结构的使用只是微弱地分离。这意味着我们需要依靠用户在对数据结构应用过程时表现出的纪律性——即他们自觉地不要使用直接利用结构实现过程。所以有时候,我们最好在结构之间设置强大的抽象障碍stronger abstraction,从而不允许用户利用底层的表示。

        换句话说,我们试图分离出具有不同用法的数据结构,使我们能够将这些结构视为组织数据的方式,但我们允许用户跨越这些结构之间的障碍,以获得底层实现。如果我们决定让这些障碍变得更强,从而保护使用抽象的人不受底层表示的影响,会发生什么呢?

 rigorous data abstraction(严格数据抽象)and abstraction barrier

        严格数据抽象,举个例子,对于pairs而言,我们希望的效果就是有一个构造器来把各个部分glue在一起,然后有一个选择器来返回我们先前glue的各个部分。最重要的是,我们将在构造函数和选择器之间有一个契约,这样无论我们用构造函数glue什么,我们都可以使用适当的选择器返回。        

        所有这些都被我们称之为抽象屏障(abstraction barrier)的东西从实际实现中分离出来。可以把它想象成一堵墙,把抽象的使用和抽象的实现分开。这意味着抽象的用户可以自由地编写操作结构的过程,仅仅依赖于构造器和选择器,而不需要知道结构实际上是如何构成的。

        现在,让我们用一个更广泛的例子来说明这一点——将一个抽象的实现细节与该抽象的使用分离开来。

        要考虑的例子是有理数和对有理数的简单算术运算。

构造如上图:

1.构造函数make-rat,它接受两个整数作为输入,并产生一个rational,带有某种我们没有指定的内部表示。我们需要选择器来取出理性的碎片。

2.选择器函数,numer、denom,都是接收一个有理数输出一个整数。我们能够将选择器函数应用于任何以constructor函数创建的实例来获得一个正确的结果。

3.协议contract,规定了如何使用选择器函数和构造函数以及对应的部分。

4.layered operation和Abstraction Barrier

        现在让我们考虑实际构建一个rationals的实现。我们如何着手构建满足上一张幻灯片中定义的契约的实现,同时提供我们设计的所有部分?

        下图是一个简单的实现。我们可以使用对作为我们理性的基本表示。利用这一点,我们可以很容易地使用cons、car和cdr来构造rationals的构造函数和选择器。虽然这看起来是一种构建基本原理的显而易见的方式,但是这里有一个重要的观点。特别是,我们可以通过继承cons、car和cdr之间的契约来执行make-rat、numer和denom之间所需的契约。与此同时,通过依赖独立的构造函数和选择器,我们将用户从实现细节中屏蔽出来,我们将很快看到为什么这很重要。

Lecture 6 Types and Higher Order Procedures

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值