电子说
立功教授数年之心血之作《程序设计与数据结构》以及《面向AMetal框架与接口的编程(上)》,书本内容公开后,在电子行业掀起一片学习热潮。经周立功教授授权,本公众号特对《程序设计与数据结构》一书内容进行连载,愿共勉之。
第二章为程序设计技术,本文为2.4.1 不完全类型和2.4.2 抽象数据类型。
>>> 2.4.1 不完全类型
不完全类型是指“函数之外、类型的大小不能被确定的类型”,结构体标记的声明就是一个不完全类型的典型示例。比如:
此时,struct _TypeA和struct _TypeB是互相引用的,虽然无论先声明哪一边都很麻烦,但可以先通过声明结构体标记回避以上问题。比如:
当使用typedef声明结构体类型时,比如:
由于TypeB类型的标记被声明时,还不知道它的内容,因此无法确定它的大小,这样的类型就被称为不完全类型。因为不能确定大小,所以不能将不完全类型变成数组,也不能将其作为结构体的成员,或声明为变量。但如果声明为指针,则可以使用不完全类型。在后续定义struct _TypeB的内容时,TypeB就不是不完全类型了。
通常在“.h”头文件中声明不包含任何实现细节的结构体,然后在“.c”实现文件中定义与数据结构的特定实现配合使用的函数。数据结构的用户可以看到声明和函数原型,但实现会隐藏在“.c”文件中。只有使用数据结构所需要的信息会对用户可见,如果太多的内部信息可见,用户可能会使用这些信息从而产生依赖。一旦内部结构发生变化,则用户代码可能就会失效。不完全类型是因为编译器看不见“.c”文件中的实际定义,它只能看到_demoB结构体的类型定义,而看不见结构体的实现细节。
下面将以数组为例介绍不完全类型的使用,尽管可以使用数组保存元素,由于数组的大小是固定的,因此数组并不会存储它的大小,而且也不会检查下标是否越界。通常用一个指向数组的指针pBuffer和记录数组元素个数的值count代替数组。其实现如下(IA_array.c):
为了防止用户直接访问结构体的成员,通常将结构体移到实现代码中(IA_array.c)隐藏起来,然后使用不完全的类型在接口中(IA_array.h)声明一个IntArray处理相应的数据。虽然不完全类型描述了对象,但缺少对象大小所需的信息。比如:
其告诉编译器_IntArray是一个结构体标记,却没有描述结构体的成员,因此编译器没有足够的信息确定结构体的大小,其意图是不完全类型将会在程序的其它地方将信息补充完整。不完全类型的使用是受限的,因为编译器不知道它的大小,所以不能在接口中用它声明变量:
但可以在接口中(IA_array.h)定义一个指针类型引用不完全类型:
在这里,仅仅声明了它的存在,而没有做别的任何事情。对于用户来说,看到的只是IA_array.h,而对_IntArray的构造或实现却一无所知。
当将IntArray定义为一个指向struct _IntArray结构体类型时,即可声明IntArray*类型的变量,将其作为函数参数进行传递。即:
尽管此时还没有定义IntArray,但指针的大小始终相同,且不依赖于它指向的对象。即便在不知道结构体本身细节的前提下,编译器同样允许处理指向结构体的指针,这就解释了为什么C允许这种行为。虽然这个结构体是一个不完全类型,但在实现代码中信息变得完整,因此该结构体的成员依赖于实现方法。
虽然数组和指针有区别,但C语言不会区分它们,C对数组提供的支持只是为了便于内存管理和指针运算,最好的证明莫过于括号运算符居然有交换性。当在结构体内创建一个数组时,为了避免直接对数据进行访问,将通过接口函数和对象交互,详见程序清单 2.30。
程序清单 2.30 访问数组元素和大小接口(IntArray.h)
为了说明这些函数构成了IntArray对象的接口,并防止函数名和其它对象的接口冲突,于是在每个函数名前使用了前缀IA_,且每个函数都有一个IntArray*型的对象作为参数,这个参数就是函数将要操作的对象。
IA_init()初始化使数组处于空元素的状态,IA_cleanup()释放在数组生存期中分配给用户的内存。剩余的函数是控制对数组中数据的访问,IA_setSize()设置了数组中的元素个数,且为这些元素分配存储空间,IA_getSize()返回当前数组中的元素个数。IA_setElem()和IA_getElem()用于访问单个数据元素,其具体实现详见程序清单 2.31。
程序清单 2.31 访问数组元素和大小接口的实现(IntArray.c)
其中,IA_setSize()用于改变数组大小,首先释放原有的元素,然后保存新的元素,且为新元素分配存储空间。当然,也可以优化进一步代码,比如,只有数组大小增加时才重新分配空间。IA_getSize()访问给定下标所指的元素,让IntArray检查下标是否在界内。
由此可见,IntArray的实现是由两部分组成的,即保存对象信息的数据和构成对象接口的函数,其使用范例程序详见程序清单 2.32。
程序清单 2.32 使用IntArray.h范例程序
>>> 2.4.2 抽象数据类型
1. 栈的实现
假设需要一个字符栈,且栈的大小是固定的,即可使用数组保存栈中的元素,然后指定一个计数器表明栈中元素的数量。其数据结构定义如下:
由于调用者并不能直接访问底层,因此在向栈中压入元素之前,必须先创建一个栈。其函数原型为:
由于刚开始时栈为空,暂时还没有元素存储到数组elements[0]中,因此只要将数组的下标置为0,即可创建一个空栈。即:
其调用形式如下:
当向栈中压入一个新的元素时,将元素存储在数组接下来的空间中,并计数递增。其函数原型为:
也就是说,当top的值加1时,则将新的元素值value入栈。即:
当弹出元素时,计数递减并返回栈顶元素。其函数原型如下:
也就是说,当top的值减1时,则删除栈顶结点,返回该结点的值。比如:
除了这些基本的操作之外,经常还需要知道栈所包含的元素数量,以及栈是空还是满,这些函数的原型为:
显然,只要返回栈顶值就知道栈中存储了多少个元素,而当stack->top为0时,说明栈为空;当stack->top大于等于MAXSIZE时,说明栈已满。
实际上,当定义了一个结构体指针变量stack后,(stack->top)就成为了一个变量,即可通过stack->top与stack->elements[stack->top++]分别实现对stack的各成员的访问。显然程序暴露了“数组和下标”这一内部结构,且无法阻止用户使用stack指针变量直接访问结构体的成员。比如:
由于对直接访问top和elements,因此用户有可能破坏栈中的数据。如果其内部实现发生变化,也必须对程序进行相应的修改。如果程序规模很大,则修改的工作量也很大,因此很多时候明明知道通过重构能够改善程序,也会因工作量太大而不愿意改变具体的实现。
由此可见,上述栈的实现方法不仅暴露了栈的数据结构,而且仅有1个栈。如果需要多个栈时,怎么办?一种方法是编写多个名字不同功能相同的函数,这样就会出现多段处理完全相同的代码。为了解决这个问题,抽象的方法是将栈中的数据结构隐藏到实现代码中。
2. 建立抽象
虽然标准C提供了类似int、char、float、double这样不可分割的原子数据类型,但如果需要表示任意大的整数,显然原子数据类型无能为力。此时,创建一种新的整数类型势在必行,而这种新的数据类型便是一种抽象数据类型ADT(Abstract Data Type)。
设计一个基于Stack的抽象数据类型,我们应该从哪里开始呢?一个不错的方法是用一句话来描述。这种描述应该尽可能地抽象,尽量不要涉及数据的内部结构,要简单到谁都能够理解它,因此可以描述“栈(Stack)是一个可以在同一个位置上插入(push)和删除(pop)数据(value)的存储器,该位置是存储器的末端,即栈顶(top)”。该定义既未说明栈中存储什么数据,也未指定是用数组、结构体还是其它数据形式存储数据,而且也没有规定用什么方式实现操作,这些细节都留给实现去完成。
关于栈的详细描述如下:
类型名:Stack
类型属性:可以存储有序的数据(value)
类型操作:创建栈(newStack)和销毁栈(freeStack),从栈顶添加数据(push)和从栈顶删除数据(pop),确定栈是否为空(stackIsEmpty),确定栈是否已满(stackIsFull),返回栈中元素的个数(getStackDepth),读取栈中任何位置的元素(getStackElement)。
也就是说,在向栈中添加元素之前,必须先创建一个栈。当不再使用内存时,必须销毁栈。对栈的基本操作有push(进栈)和pop(出栈),前者相当于插入,后者相当于删除最后插入的元素。对空栈进行的pop,认为是栈ADT的错误。另一方面,当运行push时空间用尽是一个实现错误,但不是ADT错误。
3. 建立接口
(1)隔离变化
为了防止用户直接访问top和elements而破坏栈中的数据,根据以往的经验,可以使用使用依赖倒置原则。将保存在结构体中栈的实现所需要的数据结构隐藏在“.c”文件中,将处理数据的接口包含在“.h”文件中,用户将无法看到栈的数据结构在底层是如何实现的。
虽然可以将一个数组看作是具有固定小的,但是内置数组并不会存储它的大小,而且也不会检查下标是否越界。通常将一个指向数组的指针data和记录数组元素个数的值numData存储栈的最大容量,以及记录栈顶元素的位置top进行打包,将栈的数据结构隐藏在“.c”文件中。即:
对于用户来说,现在只能通过“.h”文件中的接口操作栈。尽管此时还没有定义stackCDT,由于指针的大小始终相同,且不依赖于它指向的对象。即便在不知道结构体本身细节的前提下,编译器同样允许处理指向结构体的指针,因此可以定义一个指针类型引用不完全类型,将stackADT定义为一个指向stackCDT *结构体类型。比如:
虽然这个结构是一个不完全类型,但在实现栈的文件中信息变得完整,因此该结构的成员依赖于栈的实现方法。stackADT结构体类型的变量定义如下:
由于一个stack1指向一个存储单元,即一个存储单元代表一个栈,因此你想要多少个栈就有多少个栈。比如:
显而易见,stackADT是代表stack1、stack2、stack3等所有具体栈的总称的抽象数据类型,stack1、stack2和stack3分别指向不同的栈。因此只要将stack1、stack2和stack3作为实参传递给相应的函数,即可访问与之相应的栈。而抽象的方法是在栈的实现代码和使用栈的代码之间添加一个函数层。比如:
通常将称为函数上下文的stackADT类型的stack作为函数的第一个参数,这个参数就是函数将要操作的对象。它代表指向当前对象(栈)的指针,用于请求对象对自身执行某些操作。而结构体的成员变量就是通过stack指针找到自己所属的对象的,其引用方式如下:
由此可见,用户仅通过接口函数与栈交互,而不是直接访问它的数据。
(2)操作方法
创建栈
由于用户完全不知道底层是如何表示的,因此必须提供一个用于创建一个新stackADT的函数,且将它返回给用户。用于创建一个新的抽象类型的值的函数名称以new开始,以强调动态分配。其函数原型如下:
前置条件:stackADT被定义为一个指向结构体的指针,该结构体包含top和numData。一旦知道最大容量,则该栈即可被动态确定。创建一个具有给定最大值MAXSIZE的栈,其分别是为stackCDT结构体分配空间和长度为MAXSIZE的数组分配空间。同时将top初始化为0,并将numData置为最大值MAXSIZE。
后置条件:返回栈。
其调用形式如下:
销毁栈
当接口定义了一个分配新的抽象类型的值的函数时,通常还要为接口提供一个用于释放用户不再使用的栈的动态内存的函数。其函数原型如下:
前置条件:stack指向之前创建的栈;
后置条件:释放动态分配的所有内存,即先释放栈的数组,然后释放栈的结构。
其调用形式如下:
从栈顶添加据(进栈)
当用户向栈顶添加一个数据时,就是将该值存储在内部的数据结构中。即通过在容器的顶端插入元素实现push,其函数原型如下:
前置条件:stack指向之前创建的栈,value是待压入栈顶的数据;
后置条件:如果栈不满,将value放在栈顶,该函数返回true,否则栈不变,该函数返回false。
其调用形式如下:
从栈顶删除数据(出栈)
当用户弹出栈元素时,就是将存储的值返回给用户。即通过删除容器顶端的元素实现pop,其函数原型如下:
前置条件:stack指向之前创建的栈,pValue为指向存储返回值变量的指针;
后置条件:如果栈不空,将栈顶的值拷贝到*pValue,删除栈顶的值,该函数返回true,如果删除前栈为空,栈不变,该函数返回false。
其调用形式如下:
判断栈是否为空
判断栈是否为空的函数原型如下:
前置条件:stack指向之前创建的栈;
后置条件:如果栈为空则返回true,否则返回false。
其调用形式如下:
判断栈是否已满
判断栈是否已满的函数原型如下:
前置条件:stack指向之前创建的栈;
后置条件:如果栈已满则返回true,否则返回false。
其调用形式如下:
确定栈中元素的个数
确定栈中元素的个数的函数原型如下:
前置条件:stack指向之前创建的栈;
后置条件:返回栈中元素的个数。
其调用形式如下:
读取栈中任何位置的元素
读取栈栈任意位置元素的函数原型如下:
前置条件:stack指向之前创建的栈,index为索引值,表示返回栈中某个位置的元素, pValue为指向存储返回值变量的指针;
后置条件:如果index大于top,该函数返回false,反之将index位置的值拷贝到*pValue,该函数返回true。
其调用形式如下:
由于数组的下标是从0开始的,当index为0时,则getStackElemnt(stack, 0, &temp)返回栈顶的元素,getStackElemnt(stack, 1, &temp)返回接下来的那个元素,依此类推。
封装时,头文件中只放最小的接口函数声明,且内部函数都要加上static关键字。抽象栈的接口详见程序清单 2.33,接口揭示了栈的数据类型和用户在操作栈时需要的各种功能,这些功能实现了抽象栈类型的基本操作。
程序清单 2.33 抽象栈接口(stack.h)
这些函数共同创建了接口,每个函数都以stackADT作为它的第一个参数。当声明了函数接口后,即可实现相应的接口。
4. 实现接口
由于数组的长度在编译时就已经确定了,无法在运行时动态地调整。但有些应用在编译时并不知道应该分配多大的内存空间才能满足要求,因此可以根据需要使用动态内存“在运行时”为它分配内存空间。和任何接口一样,实现Stack.h接口需要编写一个模块Stack.c,它提供了抽象类型的输出函数和表示细节的代码,详见程序清单 2.34。
程序清单 2.34 抽象栈的实现(Stack.c)
表面上看起来getStackDepth()函数只有一行代码,也许有人会说,为何不直接使用“stack->top;”代替该函数呢?如果用户在程序中使用top,那么程序将依赖于stackADT表示的具体结构,而使用该函数的好处是为用户和实现之间提供了隔离层。由于维护代码是软件工程生命周期中的一个重要步骤,因此要尽量做好随时修改的准备。
当然,上述程序还是不能创建两种数据类型不同的栈,最常见的方法是使用void *作为数据类型,这样就可以压入和弹出任意类型的指针了。这里不再详细描述,将留给读者自己实现。但使用void *作为数据类型的最大缺点是不能进行错误检测,存放void *数据的栈允许各种类型的指针共存,因此无法检测由压入错误的指针类型而导致的错误。
5. 使用接口
实际上使用栈的人并不关心栈是如何实现的,即使要改变栈的内部实现方式,也不用对使用栈的程序做任何修改。将整数推入栈,然后再打印输出的范例程序详见程序清单 2.35。
程序清单 2.35 使用栈接口的范例程序
综上所述,Stack栈的接口分为两部分,其一是描述如何表示数据,其二是描述实现ADT操作的函数,因此必须先提供存储数据的方法。设计一个结构体,在“.h”接口中定义栈的抽象数据类型stackADT,在“.c”实现中定义栈的具体类型stackCDT。其次必须提供管理该数据的函数(方法),通过函数原型隐藏它们的底层实现。只要保留它们的接口不变,对于任何抽象都可以改变它的实现。实际上,当引入一个抽象数据类型stackADT时,就是在使用依赖倒置原则,将保存在结构体中栈的实现所需要的数据和处理数据的接口彻底分离,因为stackADT没有暴露它的细节,用户依赖于satcADT抽象,而不是细节。
显然,抽象数据类型可利用已经存在的原子数据类型构造新的结构,用已经实现的操作组合新的操作。对于ADT,用户程序除了通过接口中提到的那些操作之外,并不访问任何数据值。数据的表示和实现操作的函数都在接口的实现里面,与用户完全分离。抽象的接口隐臧不相关的细节,用户不能通过接口看到方法的实现,将注意力集中在本质特征上,将程序员从关心程序如何实现的细节上得到解放。对于任何抽象来说,只要保持接口不变,我们可以根据需要改变其实现方式。
全部0条评论
快来发表一下你的评论吧 !