第4章 数据抽象
C++是一个能提高生产效率的工具。为什么我们要努力(不管我们试图做的转变多么容易,还是需要努力)使我们从已经熟悉且生产效率高的某种语言转到另一种新的语言上?而且使用这种新语言,我们会在确实掌握它之前的一段时间内降低生产效率。这是因为我们确信:通过使用新工具将会得到更大的好处。
用编程术语来讲,生产效率提高意味着较少的人能够在较少的时间内完成更复杂和更重要的程序。当然,选择语言时确实还有其他问题,例如运行效率(该语言的本质会引起运行速度减慢和代码臃肿吗?)、安全性(该语言能有助于确信我们的程序做我们计划的事情并具有很强的纠错能力吗?)、可维护性(该语言能帮助我们创建易理解、易修改和易扩展的代码吗?)。这些都是本书要介绍的重要因素。
简单地讲,提高生产效率,意味着本应当花费三个人一星期的程序,现在只需要花费一个人一两天的时间。这会涉及经济学的多层次问题。生产效率提高了,我们很高兴,因为我们正在建造的东西其功能将会更强;我们的客户(或老板)很高兴,因为产品生产又快,用人又少;我们的顾客很高兴,因为他们得到的产品更便宜。而大幅度提高生产效率的惟一办法就是使用其他人的代码,即是去使用库。
库只是他人已经写好的一些代码,按某种方式包装在一起。通常,最小的包是带有扩展名(如lib)的文件和向编译器声明库中有什么的一个或多个头文件。连接器知道如何在库文件中搜索和提取相应的已编译的代码。但是,这只是提供库的一种方法。在跨越多种体系结构的平台(例如Linux/Unix)上,通常,提供库的最明智的方法是使用源代码,这样它就能在新的目标机上被重新配置和编译。
所以,库大概是改进生产效率的最重要的方法。C++的主要设计目标之一就是使库使用起来更加容易。这种说法暗示,在C中使用库有一些难度。理解这个因素将使我们对C++设计有一个初步的了解,并因而对如何使用它有更深入的认识。
4.1 一个袖珍C库
一个库通常以一组函数开始,但是,已经用过第三方C库的程序员知道,通常还有比行为、动作和函数更多的东西。有一些特性(颜色、重量、纹理、亮度),它们都由数据表示。在C语言中,当处理一组特性时,可以方便地把它们放在一起,形成一个struct。特别是,如果我们想表示问题空间中的多个类似的东西时,可以对每件东西创建这个struct的一个变量。
这样,在大多数C库中都有一组struct和一组作用在这些struct之上的函数。现在看一个这样的例子。假设有一个编程工具,当创建时,它的表现像一个数组,但它的长度能在运行时建立。我称它为CStash。虽然它是用C++写的,但是它有C语言的风格:
像CStashTag这样的标签名一般用于需要在struct内部引用自身的情况。例如,如果创建一个链表(链表中的每个元素包含一个指向下一个元素的指针),这样就需要指向下一个struct变量的指针,所以需要一种方法,能辨别这个struct内部的指针的类型。在C库中,几乎总是可以在如上所示的每个struct体中看到typedef。这样做使得能把这个struct作为一个新类型处理,并且可以定义这个struct的变量,例如:
storage指针是一个unsigned char。这是C编译器支持的最小的存储单位,尽管在某些机器上它可能与最大的一般大,这依赖于具体实现,但一般占一个字节长。人们可能认为,因为CStash被设计用于存放任何类型的变量,所以void在这里应当更合适。然而,我们的目的并不是把它当做某个未知类型的块处理,而是作为连续的字节块。
这个实现文件的源代码(如果购买一个商品化的库,可能得到的只是编译好的obj或lib或dll等)如下:
initialize()通过设置内部变量为适当的值。完成对struct CStash的必要设置。最初,设置storage指针为零,表示不分配初始存储。
add()函数在CStash的下一个可用位置上插入一个元素。首先,它检查是否有可用空间,如果没有,它就用后面介绍的inflate()函数扩展存储空间。
因为编译器并不知道存放的特定变量的类型(函数返回的都是void),所以不能只做赋值,虽然这的确是很方便的事情。我们必须一个字节一个字节地拷贝这个变量,完成这项拷贝任务的最简单的方法是使用数组下标。典型的情况是,在storage中已经存放有数据字节,由next的值指明。为了从正确的字节偏移开始,next必须乘上每个元素的长度(按字节),产生startBytes,然后,参数element转换为一个unsigned char,所以这就能一个字节接着一个字节地寻址,拷贝进可用的storage存储空间中。增加后的next指向下一个可用的存储块,fetch()能用指向这个数值存放点的“下标数”重新得到这个值。
fetch()首先看index是否越界,如果没有越界,返回所希望的变量地址,地址采用index参数计算。因为index指出了相对于CStash的偏移元素数,所以必须乘上每个单元拥有的字节数,产生按字节计算的偏移量。当此偏移用于计算使用数组下标的storage的下标时,得到的不是地址,而是处于这个地址上的字节。为了产生地址,必须使用地址操作符&。
对于有经验的C程序员,count()乍看上去可能有点奇怪,它好像是自找麻烦,做手工很容易做的事情。例如,如果有一个struct CStash,称为intStash,那么通过使用intStash.next查明它已经有多少个元素,这种方法似乎更直接,而不是去做count(&intStash)函数调用(它有更多的花费)。但是,如果想改变CStash的内部表示和计数计算的方法,那么这个函数调用接口就具有必要的灵活性。并且,很多程序员不会为找出库的“更好”的设计而操心。如果他们能着眼于struct和直接取next的值,那么就有可能不经允许而改变next。是不是能有一些方法使得库设计者能更好地控制像这样的问题呢?(是的,这是可预见的。)
4.1.1 动态存储分配
我们不可能预先知道一个CSatsh需要的最大存储量是多少,所以从堆(heap)中分配由storage指向的内存。堆是很大的内存块,用以在运行时分配一些小的存储空间。在写程序时,如果还不知道所需内存的大小,就可以使用堆。这样,可以直到运行时才知道需要存放200个Airplane的空间,而不只是20个。在标准C中,动态内存分配函数包括malloc()、calloc()、realloc()和free()。然而,C++不是采用库调用方法,而是采用更高级的方法,即被集成进这个语言中的动态存储分配,使用关键字new和delete。
inflate()函数使用new为CStash得到更大的空间块。在这种情况下,只扩展内存而不缩小它,assert()保证不把负数传给inflate()作为increase的值。能够存储的新元素数(inflate()完成后)由计算newQuantity,再乘以每个元素的字节数得到newBytes,这是分配的字节数。因此,可以知道从旧的位置拷贝多少字节,oldBytes用旧的quantity计算。
实际的存储分配出现在new表达式中,它是包含new关键字的表达式:
new表达式的一般形式是:
new Type;
其中Type表示希望在堆上分配的变量的类型。在这种情况下,我们希望一个长度为newBytes的unsigned char数组,这就是作为Type出现的变量。还可以分配简单类型的变量,例如int,表示为:
虽然很少这样做,但这可以使得形式一致。
new表达式返回指向所请求的准确类型对象的指针,因此,如果声称new Type,返回的是指向Type的指针。如果声称new int,返回指向一个int的指针。如果希望new unsigned char数组,返回的是指向这个数的第一个元素的指针。编译器确保把这个new表达式的返回值赋给一个正确类型的指针。
当然,任何时候申请内存都有可能失败,例如存储单元用完,正如我们看到的,C++有判断是否内存分配不成功的机制。
一旦分配了新内存块,旧内存块中的数据必须拷贝进这个新内存块,这又是通过数组下标完成的,在循环中一次拷贝一个字节。数据被拷贝以后,必须释放老的内存块,以便程序的其他部分在需要新内存块时使用。delete关键字是new的对应关键字,任何由new分配的内存块必须用delete释放(如果忘记了使用delete,这个内存块就不能用了,这称为内存泄漏(memory leak))。泄漏到一定程度,内存就耗尽了。另外,释放数组有特殊的语法形式,也就是必须提醒编译器,注意这是指向对象数组的指针,而不是仅仅指向一个对象的指针。该语法形式是在被释放的指针前面加一对空方括号:
一旦释放了旧的内存块,指向这个新内存块的指针就可以赋给storage指针,再调整数量,inflate就完成了任务。
注意,堆管理器是相当简单的,它给出一块内存,而当用delete释放时又把它收回。这里没有提供能压缩堆获得较大的空闲块的堆压缩内部工具。如果程序反复分配和释放堆存储,最终将会产生大量的空闲内存碎片,但却没有足够大的块能分配所需要的内存。堆压缩器使程序更复杂,因为要前后移动内存块,所以指针应保持正确的值。一些操作环境有内置的堆压缩器,但是,要求使用特殊的内存句柄(handle)(它能临时转换为指针,锁定内存后堆压缩器就不能移动它了)。
当编译时,如果在栈上创建一个变量,那么这个变量的存储单元由编译器自动开辟和释放。编译器准确地知道需要多少存储容量,根据这个变量的活动范围知道这个变量的生命期。而对动态内存分配,编译器不知道需要多少存储单元,不知道它们的生命期,不能自动清除。因此,程序员应负责用delete释放这块存储,delete告诉堆管理器,这个存储可以被下一次调用的new重用。在这个库里合理的方法是使用cleanup()函数,它做所有关闭的事情。
为了测试这个库,让我们创建两个CStash。第一个存放int,第二个存放由80个char组成的数组:
按照C语言的要求,所有的变量都在main()范围的开头定义。当然,必须在这个程序块的稍后通过调用initialize()对CStash初始化。C库的问题之一是必须向用户认真地说明初始化和清除函数的重要性,如果这些函数未被调用,就会出现许多问题。遗憾的是,用户不总是记得初始化和清除是必须的。他们只知道他们想完成什么,并不关心我们反复说的:“喂,等一等,您必须首先做这件事”。一些用户甚至认为初始化这些元素是自动完成的。在C中,的确没有机制能防止这种情况的发生(只有预示)。
intStash存放整型,stringStash存放字符数组。这些字符数组是通过打开源代码文件CLibTest.cpp和从中把这些行读到被称为line的string中形成的,然后使用成员函数c_str()产生一个指向line字符的指针。
装载了这两个Stash之后,可以显示它们。intStash的打印用了一个for循环,用count()确定它的限度。stringStash的打印用一个while语句,如果fetch()返回零则表示打印越界,这时跳出循环。
还应当注意到下面的类型转换:
这是因为C++有严格的类型检查,它不允许直接向其他类型赋void*(C允许)。