C 到 C++ 迁移手册(二)
四、数据抽象
C++ 是一个提高生产力的工具。否则,你为什么要努力(这是一种努力,不管我们试图进行转换是多么容易)从某种你已经知道并能有效使用的语言转换到一种新的语言,在这种语言中,你会有一段时间效率较低,直到你掌握它为止?这是因为你已经确信,通过使用这个新工具,你将获得巨大的收益。
用计算机编程的术语来说,生产率意味着更少的人可以在更短的时间内编写更复杂、更令人印象深刻的程序。在选择语言的时候,当然还有其他的问题,比如效率(语言的本质会导致速度变慢和代码膨胀吗?)、安全性(这种语言是否有助于你确保你的程序总是按照你的计划运行,它是否优雅地处理错误?),和维护(这种语言是否有助于您创建易于理解、修改和扩展的代码?).这些肯定是本书将要探讨的重要因素。
但是原始生产力意味着以前需要你们三个人一周才能完成的程序现在只需要你们一个人一两天就能完成。这涉及到经济学的几个层面。你很高兴,因为你得到了来自生产某种东西的动力;你的客户(或老板)很高兴,因为产品生产得更快,用的人更少;顾客很高兴,因为他们得到的产品更便宜。大幅提高生产率的唯一方法是利用他人的代码。换句话说,就是使用图书馆。
库只是一堆别人编写并打包在一起的代码。通常,最小的包是一个扩展名为。和一个或多个头文件来告诉你的编译器库中有什么。链接器知道如何搜索库文件并提取适当的编译代码。但这只是提供图书馆的一种方式。在跨越许多架构的平台上,比如 Linux/Unix,交付库的唯一明智的方式通常是使用源代码,因此可以在新的目标上重新配置和重新编译。
因此,库可能是提高生产率的最重要的方法,C++ 的主要设计目标之一是使库的使用更容易。这意味着在 C 中使用库有些困难。理解这个因素会让你对 C++ 的设计有一个初步的了解,从而对如何使用它有一个初步的了解。
一个类似 ?? 的小图书馆
一个库通常从一个函数集合开始,但是如果你使用过第三方的 C 库,你就会知道通常不止这些,因为生命不仅仅是行为、动作和函数。还有特征(蓝色,磅数,纹理,亮度),用数据表示。而当你开始处理 C 中的一组特征时,把它们堆在一起变成一个struct是非常方便的,尤其是当你想在你的问题空间中表示不止一个类似的东西的时候。然后你可以为每一件事做这个struct的变量。
因此,大多数 C 库都有一组struct和一组作用于这些struct的函数。作为这种系统的一个例子,考虑一个行为像数组的编程工具,但是它的大小可以在运行时创建时确定。姑且称之为CStash。尽管它是用 C++ 写的,但它的风格和你用 C 写的一样,正如你在清单 4-1 中看到的。
清单 4-1 。CStash
//: C04:CLib.h
// Header file for a C-like library
// An array-like entity created at runtime
typedef struct CStashTag {
int size; // Size of each space
int quantity; // Number of storage spaces
int next; // Next empty space
// Dynamically allocated array of bytes:
unsigned char* storage;
} CStash;
void initialize(CStash* s, int size);
void cleanup(CStash* s);
int add(CStash* s, const void* element);
void* fetch(CStash* s, int index);
int count(CStash* s);
void inflate(CStash* s, int increase);
///:∼
像CStashTag 这样的标签名通常用于struct,以防您需要引用其内部的struct。例如,当创建一个链表 ( 你的链表中的每个元素都包含一个指向下一个元素的指针),你需要一个指向下一个struct变量的指针,所以你需要一种方法在struct体中识别那个指针的类型。同样,你几乎会普遍地看到清单 4-1 中所示的typedef对应于 C 库中的每个struct。这样做是为了让你可以把struct当作一个新类型,并像这样定义struct的变量:
CStash A, B, C;
storage指针是一个unsigned char*。一个unsigned char是一个 C 编译器支持的最小存储块,尽管在一些机器上它可以和最大的一样大。它依赖于实现,但通常只有一个字节长。你可能会认为因为CStash被设计用来保存任何类型的变量,所以在这里使用void*会更合适。但是,我们的目的不是将这种存储视为某种未知类型的块,而是视为连续字节的块。
实现文件(的源代码,如果你从商业上购买一个库,你可能得不到它;你可能只会得到一个编译过的 obj或者lib或者dll等等。)如清单 4-2 所示。
清单 4-2 。实现文件的源代码
//: C04:CLib.cpp {O}
// Implementation of example C-like library
// Declare structure and functions:
#include "CLib.h"
#include <iostream>
#include <cassert>
using namespace std;
// Quantity of elements to add
// when increasing storage:
Const int increment = 100;
void initialize(CStash* s, int sz) {
s->size = sz;
s->quantity = 0;
s->storage = 0;
s->next = 0;
}
int add(CStash* s, const void* element) {
if(s->next >= s->quantity) //Enough space left?
inflate(s, increment);
// Copy element into storage,
// starting at next empty space:
int startBytes = s->next * s->size;
unsigned char* e = (unsigned char*)element;
for(int i = 0; i < s->size; i++)
s->next++;
return(s->next - 1); // Index number
}
void* fetch(CStash* s, int index) {
// Check index boundaries:
assert(0 <= index);
if(index >= s->next)
return 0; // To indicate the end
// Produce pointer to desired element:
return &(s->storage[index * s->size]);
}
int count(CStash* s) {
return s->next; // Elements in CStash
}
void inflate(CStash* s, int increase) {
assert(increase > 0);
int newQuantity = s->quantity + increase;
int newBytes = newQuantity * s->size;
int oldBytes = s->quantity * s->size;
unsigned char* b = new unsigned char[newBytes];
for(int i = 0; i < oldBytes; i++)
b[i] = s->storage[i]; // Copy old to new
delete [](s->storage); // Old storage
s->storage = b; // Point to new memory
s->quantity = newQuantity;
}
void cleanup(CStash* s) {
if(s->storage != 0) {
cout << "freeing storage" << endl;
delete []s->storage;
}
} ///:∼
initialize( )通过将内部变量设置为适当的值,为structCStash执行必要的设置。最初,storage指针被设置为零—没有初始存储被分配。
add( )函数将一个元素插入到CStash的下一个可用位置。首先,它检查是否还有剩余的可用空间。如果没有,它将使用inflate( )功能扩展存储空间,这将在后面描述。
因为编译器不知道被存储变量的具体类型(函数得到的只是一个void*),所以不能只进行赋值,这当然是方便的事情。相反,您必须逐字节复制变量。执行复制的最直接方式是使用数组索引。通常,storage中已经有数据字节,这由next的值表示。从右字节偏移量开始,next乘以每个元素的大小(字节中的*)产生startBytes。然后将参数element转换为unsigned char ,这样就可以逐字节寻址并复制到可用的storage空间中。next递增,以指示下一个可用的存储器,以及存储该值的“索引号”,以便通过fetch( )使用该索引号检索该值。
fetch( )检查以查看索引没有越界,然后返回使用index参数计算的所需变量的地址。由于index表示要偏移到CStash中的元素数量,所以必须乘以每个元素占用的字节数,以产生以字节为单位的数值偏移量。当使用数组索引将这个偏移量用于索引到storage时,您不会得到地址,而是得到地址处的字节。要生成地址,必须使用地址操作符&。
对于一个经验丰富的 C 程序员来说,可能乍一看有点奇怪。似乎要经历很多麻烦去做一些手工可能会容易得多的事情。例如,如果您有一个名为intStash的structCStash,那么通过说intStash.next而不是进行一个函数调用(这有开销),比如count(&intStash),来找出它有多少个元素似乎要简单得多。然而,如果您想改变CStash的内部表示,从而改变计数的计算方式,函数调用接口提供了必要的灵活性。但是遗憾的是,大多数程序员不会费心去找出你对这个库的“更好”的设计。他们会查看struct并直接获取next值,甚至可能在未经您允许的情况下更改next。要是有什么方法能让库设计者更好地控制这样的事情就好了!
动态存储分配
你永远不知道一个CStash可能需要的最大存储量,所以storage指向的内存是从堆中分配的。堆是一大块内存,用于在运行时分配较小的内存块。当你在编写程序时不知道你需要的内存大小时,你可以使用堆。也就是说,只有在运行时,你才会发现你需要空间来容纳 200 个Airplane变量,而不是 20 个。在标准 C 中,动态内存分配函数包括malloc( )、calloc( )、realloc( )、和、free( )。然而,C++ 有一种更复杂(尽管使用起来更简单)的动态内存方法,通过关键字new和delete集成到语言中,而不是库调用。
inflate( ) 函数使用new为CStash获取更大的空间。在这种情况下,你只会扩展内存而不会收缩,并且assert( )会保证一个负数不会作为increase值传递给inflate( )。可以保存的新元素数( inflate( )完成后的*)计算为newQuantity,乘以每个元素的字节数得到newBytes,这将是分配中的字节数。为了让您知道要从旧位置复制多少字节,oldBytes是使用旧的quantity计算的。*
实际的存储分配发生在 new-expression 中,它是涉及new关键字的表达式,比如:
new unsigned char[newBytes];
新表达式的一般形式是
new Type;
其中
描述你想要在堆上分配的变量的类型。在这种情况下,您需要一个长度为newBytes的unsigned char数组,所以它显示为Type。你也可以这样分配像一个int一样简单的东西
new int;
虽然很少这样做,但你可以看到形式是一致的。
一个 new-expression 返回一个指向您所请求的精确类型的对象的指针。所以,如果你说new Type,你会得到一个指向Type的指针。如果你说new int,你会得到一个指向int的指针。如果你想要一个new unsigned char数组,你得到一个指向该数组第一个元素的指针。编译器将确保您将 new-expression 的返回值赋给正确类型的指针。
当然,任何时候你请求内存的时候,如果没有更多的内存,请求都有可能失败。正如您将了解到的,如果内存分配操作不成功,C++ 会有一些机制发挥作用。
一旦新存储器被分配,旧存储器中的数据必须被复制到新存储器中;这也是通过数组索引实现的,在一个循环中一次复制一个字节。在数据被复制后,旧的存储空间必须被释放,以便程序的其他部分在需要新的存储空间时可以使用它。delete关键字是new的补充,必须应用它来释放任何用new分配的存储空间(如果您忘记使用delete,该存储空间仍然不可用,如果这种所谓的内存泄漏足够频繁,您将耗尽内存)。此外,当你删除一个数组时,有一个特殊的语法。就好像你必须提醒编译器,这个指针不只是指向一个对象,而是指向一个对象数组:你在要删除的指针前面放一组空的方括号,比如:
delete []myArray;
一旦旧存储器被删除,指向新存储器的指针可以被分配给storage指针,数量被调整,并且inflate( )已经完成它的工作。
注意,堆管理器相当原始。它给你大量的记忆,并在你使用它们的时候收回它们。没有用于堆压缩的固有工具,堆压缩压缩堆以提供更大的空闲块。如果一个程序分配并释放堆存储一段时间,你可能会得到一个碎片堆,它有很多空闲内存,但是没有足够大的块来分配你此刻正在寻找的大小。堆压缩器使程序变得复杂,因为它四处移动内存块,所以你的指针不会保留它们正确的值。有些操作环境有内置的堆压缩,但是它们要求你使用特殊的内存句柄 ( 可以临时转换成指针,在锁定内存之后,堆压缩器就不能移动它)来代替指针。您也可以构建自己的堆压缩方案,但这不是一项轻松的任务。
当您在编译时在堆栈上创建变量时,编译器会自动创建并释放该变量的存储。编译器确切地知道需要多少存储空间,并且因为作用域,它知道变量的生命周期。然而,使用动态内存分配,编译器不知道你将需要多少存储,和它不知道那个存储的生命周期。也就是说,存储不会自动清理。因此,您负责使用delete来释放存储,这告诉堆管理器下次调用new时可以使用存储。在库中发生这种情况的逻辑位置是在cleanup( )函数中,因为所有的收尾工作都是在那里完成的。
为了测试这个库,创建了两个CStash es。第一个保存int个,第二个保存 80 个char个的数组;参见清单 4-3 。
清单 4-3 。用两个 CStashes 测试类 C 库
//: C04:CLibTest.cpp
//{L} CLib
#include "CLib.h" // To be INCLUDED from Header FILE above
#include <fstream>
#include <iostream>
#include <string>
#include <cassert>
using namespace std;
int main() {
// Define variables at the beginning
// of the block, as in C:
CStashintStash, stringStash;
int i;
char* cp;
ifstream in;
string line;
const int bufsize = 80;
// Now remember to initialize the variables:
initialize(&intStash, sizeof(int));
for(i = 0; i < 100; i++)
add(&intStash, &i);
for(i = 0; i < count(&intStash); i++)
cout << "fetch(&intStash, " << i << ") = "
<< *(int*)fetch(&intStash, i)
<< endl;
// Holds 80-character strings:
initialize(&stringStash, sizeof(char)*bufsize);
in.open("CLibTest.cpp");
assert(in);
while(getline(in, line))
add(&stringStash, line.c_str());
i = 0;
while((cp = (char*)fetch(&stringStash, i++))!=0)
cout << "fetch(&stringStash, " << i << ") = "
<< cp << endl;
cleanup(&intStash);
cleanup(&stringStash);
} ///:∼
按照 C 要求的形式,所有的变量都是在main( )作用域的开始处创建的。当然,您必须记得稍后通过调用initialize( )来初始化块中的CStash变量。库的一个问题是,你必须小心地向用户传达初始化和清理功能的重要性。如果不调用这些函数,会有很多麻烦。不幸的是,用户并不总是想知道初始化和清理是否是强制性的。他们知道他们想要完成什么,他们不在乎你上蹿下跳地说,“嘿,等等,你必须先做这个”一些用户甚至自己初始化结构的元素。在 C 中肯定没有任何机制可以阻止它(更多伏笔)。
intStash是用整数填充的,而stringStash是用字符数组填充的。这些字符数组是通过打开源代码文件CLibTest.cpp,并将其中的行读入一个名为line的string,然后使用成员函数c_str( )产生一个指向line字符表示的指针。
加载每个Stash后,会显示出来。使用for循环打印intStash,该循环使用count( )建立其极限。stringStash上印有while,当fetch( )归零表示出界时爆发。
你还会注意到一个额外的演员阵容
cp = (char*)fetch(&stringStash,i++)
这是由于 C++ 中更严格的类型检查,不允许简单地将一个void*赋给任何其他类型( C 允许这样)。
错误的猜测
在我们研究创建一个 C 库的一般问题之前,还有一个更重要的问题你应该理解。请注意,CLib.h头文件必须包含在任何引用CStash的文件中,因为编译器甚至无法猜测该结构是什么样子。但是,它可以猜测一个函数是什么样子的;这听起来像是一个特性,但结果却是一个主要的缺陷。
尽管你应该总是通过包含一个头文件来声明函数,但是函数声明在 C 中并不是必需的。在 C 中(但在 C++ 中不是)调用一个你没有声明的函数是可能的。一个好的编译器会警告你可能应该先声明一个函数,但是 C 语言标准并没有强制这样做。这是一种危险的做法,因为 C 编译器可以假设你用int参数调用的函数有一个包含int的参数列表,即使它实际上可能包含一个float。正如您将看到的,这可能会产生很难发现的错误。
每个单独的 C 实现文件(扩展名为.c)都是一个翻译单元。也就是说,编译器在每个翻译单元上单独运行,运行时只感知那个单元。因此,你通过包含头文件提供的任何信息都非常重要,因为它决定了编译器对你程序其余部分的理解。头文件中的声明特别重要,因为无论在哪里包含头文件,编译器都知道该做什么。例如,如果你在一个头文件中有一个声明叫做void func(float),编译器知道如果你用一个整数参数调用那个函数,它应该在传递参数时把int转换成float(这被称为提升)。如果没有声明, C 编译器会简单地假设一个函数func(int)存在,它不会进行提升,错误的数据会悄悄地传入func( )。
对于每个翻译单元,编译器创建一个扩展名为.o或.obj或类似的目标文件。这些目标文件以及必要的启动代码必须由链接器收集到可执行程序中。在链接过程中,必须解析所有外部引用。比如在CLibTest.cpp中,像initialize( )、fetch( )这样的函数被声明(也就是编译器被告知它们的样子)和使用,但是没有被定义。它们在CLib.cpp的其他地方有定义。因此,CLib.cpp中的调用是外部引用。当链接器把所有的目标文件放在一起时,它必须获取未解析的外部引用,并找到它们实际引用的地址。这些地址被放入可执行程序中以替换外部引用。
重要的是要认识到,在 C 中,链接器搜索的外部引用只是函数名,通常在它们前面有一个下划线。所以链接器所要做的就是匹配调用它的函数名和目标文件中的函数体,这就完成了。如果你不小心进行了一个被编译器解释为func(int)的调用,并且在其他一些目标文件中有一个func(float)的函数体,链接器会在一个地方看到_func,在另一个地方看到_func,它会认为一切正常。调用位置的func( )将把一个int推到堆栈上,func( )函数体将期望一个float在堆栈上。如果函数只读取值,不写入值,就不会炸栈。事实上,它从堆栈中读取的float值甚至可能有某种意义。这更糟糕,因为更难找到漏洞。
怎么了?
我们的适应能力非常强,甚至在我们不应该适应的情况下。CStash库的风格一直是 C 程序员的主食,但是如果你观察它一段时间,你可能会注意到它相当于*。。。尴尬的*。当你使用它时,你必须把这个结构的地址传递给库中的每一个函数。当阅读代码时,库的机制与函数调用的含义混淆了,当您试图理解发生了什么时,这是令人困惑的。
然而,在 C 中使用库的最大障碍之一是名称冲突的问题。 C 有一个单一的函数命名空间;也就是说,当链接器查找函数名时,它在单个主列表中查找。此外,当编译器处理翻译单元时,它只能处理具有给定名称的单个函数。
现在假设您决定从两个不同的供应商那里购买两个库,每个库都有一个必须初始化和清理的结构。两家厂商都认为initialize( )和cleanup( )是好名字。如果在一个翻译单元中包含了它们的头文件,那么 C 编译器会做什么呢?幸运的是, C 给出了一个错误,告诉你在声明函数的两个不同的参数列表中有一个类型不匹配。但是即使你不把它们包含在同一个翻译单元里,链接器还是会有问题。一个好的链接器会检测到名字冲突,但是有些链接器会按照你在链表中给它们的顺序搜索目标文件列表,然后取它们找到的第一个函数名。
注意这甚至可以被认为是一个特性,因为它允许你用自己的版本替换一个库函数。
无论哪种情况,都不能使用两个包含同名函数的 C 库。为了解决这个问题,C 库供应商通常会在所有函数名的开头加上一系列独特的字符。所以initialize( )和cleanup( )可能会变成CStash_initialize( )和CStash_cleanup( )。这是一件合乎逻辑的事情,因为它用函数的名字“装饰”了函数所处理的struct的名字。
现在是时候迈出用 C++ 创建类的第一步了。struct中的变量名不会与全局变量名冲突。那么,当这些函数在特定的struct上运行时,为什么不在函数名中利用这一点呢?也就是说,为什么不让函数成为struct的成员呢?
基本对象
第一步就是这样。C++ 函数可以作为“成员函数”放在struct s 中清单 4-4 显示了将CStash的 C 版本转换为 C++ Stash后的样子。
清单 4-4 。将类 C 库转换为 C++
//: C04:CppLib.h
struct Stash {
int size; // Size of each space
int quantity; // Number of storage spaces
int next; // Next empty space
// Dynamically allocated array of bytes:
unsigned char* storage;
// Functions!
void initialize(int size);
void cleanup();
int add(const void* element);
void* fetch(int index);
int count();
void inflate(int increase);
}; ///:∼
首先,注意这里没有typedef。C++ 编译器并不要求你创建一个typedef,而是将该结构的名称转换成程序的新类型名称(就像int、char、float和double、都是类型名称)。
所有的数据成员和以前完全一样,但是现在函数在struct的主体里面。此外,注意来自库的 C 版本的第一个参数已经被移除。在 C++ 中,编译器不会强迫你将结构的地址作为第一个参数传递给所有操作该结构的函数,而是秘密地为你做这件事。现在,函数的唯一参数是函数做什么,而不是函数运行的机制。
重要的是要认识到函数代码实际上与库的 C 版本是一样的。参数的数量是相同的(即使你看不到传入的结构地址,它仍然在那里),并且每个函数只有一个函数体。也就是说,仅仅因为你说
Stash A, B, C;
并不意味着每个变量都有不同的add( )函数。
因此,生成的代码与您为库的 C 版本编写的代码几乎相同。有趣的是,这包括您可能会为产生Stash_initialize( )、Stash_cleanup( )等而做的“名称修饰”。当函数名在struct中时,编译器会有效地做同样的事情。因此,Stash结构内部的initialize( )不会与任何其他结构内部名为initialize( )的函数冲突,甚至不会与名为initialize( )的全局函数冲突。大多数情况下,您不必担心函数名的修饰——您使用的是未修饰的名称。但是有时候你确实需要能够指定这个initialize( )属于structStash,而不属于任何其他的struct。特别是,当你定义函数时,你需要完全指定它是哪一个。为了完成这个完整的规范,C++ 有一个操作符(:: ) ,叫做作用域解析操作符(这样命名是因为名字现在可以在不同的作用域中——在全局作用域中的*,或者在*作用域中的struct)。比如你要指定initialize( ),属于Stash,你就说Stash::initialize(int size)。您可以在清单 4-5 中的函数定义中看到作用域解析操作符是如何使用的。
清单 4-5 。在函数定义中使用范围解析运算符
//: C04:CppLib.cpp {O}
// C library converted to C++
// Declare structure and functions:
#include "CppLib.h" // To be INCLUDED from Header FILE above
#include <iostream>
#include <cassert>
using namespace std;
// Quantity of elements to add
// when increasing storage:
const int increment = 100;
void Stash::initialize(int sz) {
size = sz;
quantity = 0;
storage = 0;
next = 0;
}
int Stash::add(const void* element) {
if(next >= quantity) // Enough space left?
inflate(increment);
// Copy element into storage,
// starting at next empty space:
int startBytes = next * size;
unsigned char* e = (unsigned char*)element;
for(int i = 0; i < size; i++)
storage[startBytes + i] = e[i];
next++;
return(next - 1); // Index number
}
void* Stash::fetch(int index) {
// Check index boundaries:
assert(0 <= index);
if(index >= next)
return 0; // To indicate the end
// Produce pointer to desired element:
return &(storage[index * size]);
}
int Stash::count() {
return next; // Number of elements in CStash
}
void Stash::inflate(int increase) {
assert(increase > 0);
int newQuantity = quantity + increase;
int newBytes = newQuantity * size;
int oldBytes = quantity * size;
unsigned char* b = new unsigned char[newBytes];
for(int i = 0; i < oldBytes; i++)
b[i] = storage[i]; // Copy old to new
delete []storage; // Old storage
storage = b; // Point to new memory
quantity = newQuantity;
}
void Stash::cleanup() {
if(storage != 0) {
cout << "freeing storage" << endl;
delete []storage;
}
} ///:∼
C 和 C++ 还有几个不同的地方。首先,头文件中的声明是编译器所需要的*。在 C++ 中,不先声明函数就不能调用它。否则,编译器将发出一条错误消息。这是确保函数调用在调用点和定义点之间保持一致的重要方法。通过强制您在调用之前声明函数,C++ 编译器实际上确保了您将通过包含头文件来执行此声明。如果在定义函数的地方也包含相同的头文件,编译器会检查以确保头文件中的声明和函数定义匹配。这意味着头文件成为函数声明的有效存储库,并确保在项目的所有翻译单元中一致地使用函数。*
*当然,全局函数仍然可以在每个定义和使用它们的地方手工声明。但是,在定义或使用结构之前,必须先声明结构,而将结构定义放在头文件中是最方便的地方,除非您有意将其隐藏在文件中。
注意这太乏味了,变得不太可能。
你可以看到所有的成员函数看起来几乎和它们是 C 函数时一样,除了作用域解析和库的 C 版本的第一个参数不再是显式的。当然,它仍然存在,因为函数必须能够处理特定的struct变量。但是请注意,在成员函数中,成员选择也消失了!因此,你应该说size = sz;,而不是说s–>size = sz;,这样就省去了冗长的s–>,它对你正在做的事情没有任何意义。C++ 编译器显然是在为你做这件事。实际上,它采用“秘密”的第一个参数(您之前手动传入的结构的地址)并在您引用一个struct的数据成员时应用成员选择器。这意味着只要你在另一个struct的成员函数中,你就可以通过简单地给出它的名字来引用任何成员(包括另一个成员函数)。编译器将在查找该名称的全局版本之前搜索局部结构的名称。您会发现,这个特性意味着您的代码不仅更容易编写,而且更容易阅读。
但是,如果出于某种原因,您希望能够得到结构的地址,该怎么办呢?在库的 C 版本中,这很简单,因为每个函数的第一个参数是一个名为s的CStash*。在 C++ 中,事情更加一致。有一个特殊的关键字,叫做this,它产生了struct的地址。它相当于库的 C 版本中的“s”。所以你可以回复到 C 风格,说
this->size = Size;
编译器生成的代码是完全一样的,所以你不需要以这样的方式使用this;偶尔,你会看到代码中到处都有人明确地使用this->,但是它并没有增加任何代码的含义,并且经常表明一个没有经验的程序员。通常,你不会经常使用this,但当你需要它的时候,它就在那里(本书后面的一些例子会用到 this)。
还有最后一项要提。在 C 中,你可以像这样给任何其他指针赋值void*
int i = 10;
void* p = &i; // OK in both C and C++
int* ip = vp; // Only acceptable in C
编译器不会有任何抱怨。但在 C++ 中,这种语句是不允许的。为什么呢?因为 C 对类型信息不是那么讲究,所以它允许你把一个未指定类型的指针赋给一个指定类型的指针。C++ 却不是这样。在 C++ 中,类型是至关重要的,当有任何违反类型信息的情况时,编译器就会停止工作。这一点一直很重要,但在 C++ 中尤其重要,因为在struct中有成员函数。如果在 C++ 中可以不受惩罚地传递指向struct的指针,那么您最终可能会为一个struct调用一个成员函数,而这个函数在逻辑上对于那个struct来说并不存在!这是一个真正的灾难。因此,虽然 C++ 允许将任何类型的指针分配给一个void*(这是void*,的初衷,它需要足够大以容纳一个指向任何类型的指针),但它将而不是允许将一个void指针分配给任何其他类型的指针。总是需要强制转换来告诉读者和编译器,你确实想把它当作目标类型。
这带来了一个有趣的问题。C++ 的一个重要目标是编译尽可能多的现有 C 代码,以便轻松过渡到新语言。然而,这并不意味着 C 允许的任何代码在 C++ 中都将被自动允许。C 编译器让你逃脱了许多危险和容易出错的事情。
注我们会在本书的过程中看到它们。
对于这些情况,C++ 编译器会生成警告和错误。这往往是一个优势,而不是一个障碍。事实上,在很多情况下,你试图在 C 语言中运行一个错误,只是找不到它,但只要你用 C++ 重新编译程序,编译器就会指出问题!在 C 中,你会经常发现你可以让程序编译,但是之后你必须让它工作。在 C++ 中,当程序正确编译时,也经常起作用!这是因为这种语言对类型的要求更加严格。
在清单 4-6 中的测试程序中使用 C++ 版本的Stash的方式中,你可以看到许多新事物。
清单 4-6 。使用 C++ 版本的 CStash
//: C04:CppLibTest.cpp
//{L} CppLib
// Test of C++ library
#include "CppLib.h"
#include "../require.h" // To be INCLUDED from Header FILE in Chapter 3
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
int main() {
Stash intStash;
intStash.initialize(sizeof(int));
for(int i = 0; i < 100; i++)
intStash.add(&i);
for(int j = 0; j < intStash.count(); j++)
cout << "intStash.fetch(" << j << ") = "
<< *(int*)intStash.fetch(j)
<< endl;
// Holds 80-character strings:
Stash stringStash;
const int bufsize = 80;
stringStash.initialize(sizeof(char) * bufsize);
ifstream in("CppLibTest.cpp");
assure(in, "CppLibTest.cpp");
string line;
while(getline(in, line))
stringStash.add(line.c_str());
int k = 0;
char* cp;
while((cp =(char*)stringStash.fetch(k++)) != 0)
cout << "stringStash.fetch(" << k << ") = "
<< cp << endl;
intStash.cleanup();
stringStash.cleanup();
} ///:∼
你会注意到的一件事是,所有的变量都是“动态”定义的(“??”,如前一章中所介绍的)。也就是说,它们被定义在作用域的任何一点,而不是被限制在作用域的开始——就像在 C 中一样。
代码与CLibTest.cpp非常相似,但是当一个成员函数被调用时,调用发生在变量名称前面的. 的成员选择操作符。这是一种方便的语法,因为它模拟了结构的数据成员的选择。不同的是,这是一个函数成员,所以它有一个参数列表。
当然,编译器实际上生成的调用看起来更像原始的 C 库函数。因此,考虑到名字修饰和this的传递,C++ 函数调用intStash.initialize(sizeof(int), 100)就变成了类似于Stash_initialize(&intStash, sizeof(int), 100)的东西。如果你想知道幕后发生了什么,请记住 AT & T 最初的 C++ 编译器cfront产生了 C 代码作为其输出,然后由底层的 C 编译器编译。这种方法意味着cfront可以快速移植到任何装有 C 编译器的机器上,它有助于快速传播 C++ 编译器技术。但是因为 C++ 编译器必须生成 *C,*你知道一定有某种方法在 C 中表示 C++ 语法。)
注意有些编译器还是允许你产生 C 代码的。
与ClibTest.cpp相比还有一个变化,那就是引入了require.h头文件。这是为这本书创建的一个头文件,用来执行比assert( )提供的更复杂的错误检查。它包含几个函数,包括这里使用的用于文件的assure( ), 。该函数检查文件是否已经成功打开,如果没有,它向标准错误报告文件无法打开(因此它需要文件名作为第二个参数)并退出程序。require.h函数将在整本书中使用,特别是为了确保有正确数量的命令行参数以及文件被正确打开。require.h函数取代了重复和令人分心的错误检查代码,但是它们提供了本质上有用的错误信息。
什么是物体?
现在您已经看到了一个初始示例,是时候后退一步,看看一些术语了。将函数引入结构的行为是 C++ 添加到 C 的根源,它引入了一种新的思考结构的方式:作为概念。在 C 中,struct是数据的集合,一种打包数据的方式,这样你就可以在一个丛中处理它。但是除了方便编程之外,很难把它想成别的东西。对这些结构起作用的功能在别处。然而,有了包中的函数,结构变成了一个新的生物,能够描述两种特性(就像 aC所做的)和行为。对象的概念——一个能够记住和动作的独立有界实体——出现了。
在 C++ 中,对象只是一个变量,最纯粹的定义是“一个存储区域”(这是一种更具体的说法,即一个对象必须有一个唯一的标识符,在 C++ 的情况下是一个唯一的内存地址)。它是一个可以存储数据的地方,这意味着还可以对这些数据执行操作。
不幸的是,当谈到这些术语时,不同语言之间并不完全一致,尽管它们已经被广泛接受。你有时也会遇到关于什么是面向对象语言的不同意见,尽管现在看来这已经很好地解决了。有些语言是基于对象的,这意味着它们拥有像你目前所见的 C++ 带函数结构这样的对象。然而,这只是面向对象语言的一部分,停止在数据结构中封装函数的语言是基于对象的,而不是面向对象的。
抽象数据类型化
用函数打包数据的能力允许您创建新的数据类型。这通常被称为封装。一个已有的数据类型可能有几个数据打包在一起。例如,float有一个指数、一个尾数和一个符号位。你可以告诉它做一些事情:添加到另一个float或者添加到一个int,等等。它有特征和行为。
Stash的定义创建了一个新的数据类型。可以add( )``fetch( )inflate( )。你通过说Stash s来创造一个,就像你通过说float f来创造一个float一样。一个Stash也有特点和行为。尽管它的行为像一个真实的内置数据类型,但我们称它为抽象数据类型*,也许是因为它允许我们将概念从问题空间抽象到解决方案空间。此外,C++ 编译器将其视为一种新的数据类型,如果您说某个函数需要一个Stash,编译器会确保您将一个Stash传递给该函数。因此,抽象数据类型(有时称为用户定义类型)与内置类型的类型检查级别相同。*
但是,您可以立即看到对对象执行操作的方式有所不同。你说object.memberFunction(arglist)。这就是“为对象调用成员函数”但是在面向对象的说法中,这也被称为“向对象发送消息”所以对于一个Stash s,语句s.add(&i)向s发送一条消息,说“add( )这是给你自己的。”事实上,面向对象编程可以用一句话来概括:向对象发送消息。实际上,这就是你所做的一切——创建一堆对象并向它们发送消息。当然,诀窍是弄清楚你的对象和消息是什么*,但是一旦你完成了这个,C++ 中的实现就出奇的简单。
对象详细信息
研讨会上经常出现的一个问题是,“一个物体有多大,长什么样?”答案是“关于你对一个 C struct的期望。”事实上, C 编译器为 C struct(没有 C++ 修饰)生成的代码通常看起来与 C++ 编译器生成的代码完全相同。这让那些依赖于代码中的大小和布局细节的程序员感到放心,因为某些原因,他们直接访问结构字节而不是使用标识符(依赖于结构的特定大小和布局是不可移植的活动)。
一个 struct的大小是其所有成员大小的总和。有时候编译器布局一个struct的时候,会额外增加字节,让边界出来的很整齐;这可以提高执行效率。您可以使用sizeof操作符来确定struct的大小。清单 4-7 包含了一个小例子。
清单 4-7 。使用 sizeof 运算符查找结构的大小
//: C04:Sizeof.cpp
// Sizes of structs
#include "CLib.h"
#include "CppLib.h"
#include <iostream>
using namespace std;
struct A {
int i[100];
};
struct B {
void f();
};
void B::f() {}
int main() {
cout << "sizeof struct A = " << sizeof(A)
<< " bytes" << endl;
cout << "sizeof struct B = " << sizeof(B)
<< " bytes" << endl;
cout << "sizeof CStash in C = "
<< sizeof(CStash) << " bytes" << endl;
cout << "sizeof Stash in C++ = "
<< sizeof(Stash) << " bytes" << endl;
} ///:∼
第一个 print 语句产生 200,因为每个int占用两个字节。
注意在你的电脑上你可能会得到不同的结果。
struct B有点反常,因为它是一个没有数据成员的struct。在 C 中,这是非法的,但是在 C++ 中我们需要创建一个struct的选项,它的唯一任务是限定函数名的范围,所以这是允许的。然而,第二个 print 语句产生的结果是一个有点令人惊讶的非零值。在该语言的早期版本中,大小为零,但是当您创建这样的对象时,会出现一种尴尬的情况:它们与直接在它们之后创建的对象具有相同的地址,因此没有区别。对象的一个基本规则是每个对象必须有一个唯一的地址,所以没有数据成员的结构总是有一些最小的非零大小。
最后两条sizeof语句向您展示了 C++ 中结构的大小与 C 中等价版本的大小相同。C++ 尽量不增加任何不必要的开销。
头文件礼仪
当您创建一个包含成员函数的struct时,您正在创建一个新的数据类型。一般来说,您希望自己和他人可以轻松访问这种类型。此外,您希望将接口(声明)与实现(成员函数的定义)分开,这样就可以在不强制重新编译整个系统的情况下更改实现。通过将新类型的声明放在头文件中,可以达到这个目的。
对于大多数初学 C 的人来说,头文件是个谜。很多 C 的书都不强调,编译器也不强制函数声明,所以大部分时候看起来是可选的,除非声明了结构。在 C++ 中,头文件的使用变得非常清楚。对于简单的程序开发来说,它们实际上是强制性的,你可以在它们中放入非常具体的信息:声明。头文件告诉编译器你的库中有什么。即使您只拥有头文件以及目标文件或库文件,也可以使用该库;您不需要。cpp文件。头文件是存储接口规范的地方。
虽然编译器没有强制要求,但是在 C 中构建大型项目的最佳方法是使用库,将相关的函数收集到同一个对象模块或库中,并使用头文件保存函数的所有声明。您可以将任何函数放入一个 C 库中,但是 C++ 抽象数据类型通过它们对struct中数据的公共访问来确定关联的函数。任何成员函数都必须在struct声明中声明;你不能把它放在别处。函数库的使用在 C 中被鼓励,在 C++ 中被制度化。
头文件的重要性
当使用库中的函数时, C 允许你忽略头文件,直接手工声明函数。在过去,人们有时会通过避免打开和包含文件的任务来加快编译器的速度(这通常不是现代编译器的问题)。例如,这里有一个极其懒惰的 C 函数printf( )(来自<stdio.h>)的声明:
printf(...)
省略号指定了一个变量参数列表,它说printf( )有一些参数,每个参数都有一个类型,但是忽略它;只要接受你看到的任何论点。通过使用这种声明,可以暂停对参数的所有错误检查。
这种做法可能会导致微妙的问题。如果你手工声明函数,在一个文件中你可能会出错。由于编译器只看到你在那个文件中的手写声明,它也许能够适应你的错误。程序将会正确地链接,但是在那个文件中函数的使用将会出错。这是一个很难发现的错误,使用头文件很容易避免。
如果将所有的函数声明放在一个头文件中,并在使用函数和定义函数的地方都包含这个头文件,就可以确保整个系统中声明的一致性。您还可以通过在定义文件中包含头文件来确保声明和定义相匹配。
如果在 C++ 的头文件中声明了一个struct,你必须包括头文件中所有使用struct和定义struct成员函数的地方。如果你试图调用一个常规函数,或者调用或定义一个成员函数,而没有先声明它,C++ 编译器会给出一个错误消息。通过强制正确使用头文件,该语言确保了库中的一致性,并通过强制在任何地方使用相同的接口来减少错误。
标题是你和你的库的用户之间的合同。契约描述了您的数据结构,并声明了函数调用的参数和返回值。它说,“这就是我的图书馆所做的。”用户需要这些信息中的一部分来开发应用程序,编译器需要所有这些信息来生成正确的代码。struct的用户简单地包含头文件,创建该struct的对象(实例),并链接到对象模块或库*(即编译后的代码)。*
编译器通过要求您在使用所有结构和函数之前声明它们来强制执行契约,对于成员函数,则是在定义它们之前声明。因此,您必须将声明放在头文件中,并在定义成员函数的文件和使用成员函数的文件中包含头文件。因为整个系统包含一个描述库的头文件,所以编译器可以确保一致性并防止错误。
为了正确组织代码并编写有效的头文件,您必须了解某些问题。第一个问题是关于你能把什么放进头文件。基本规则是“仅声明”(即,仅向编译器提供信息,但不通过生成代码或创建变量来分配存储)。这是因为头文件通常包含在一个项目的几个翻译单元中,如果一个标识符的存储分配在多个位置,链接器将出现多重定义错误。
注意这是 C++ 的一个定义规则:你可以任意多次声明事物,但是每个事物只能有一个实际的定义。
这条规则并不完全严格。如果您在头文件中定义了一个“文件静态”(只在文件中可见)的变量,那么在整个项目中将会有该数据的多个实例,但是链接器不会有冲突。基本上,你不想在头文件中做任何会在链接时引起歧义的事情。
多重声明问题
第二个头文件问题是这样的:当你在头文件中放入一个struct声明时,这个文件有可能在一个复杂的程序中被多次包含。Iostreams 就是一个很好的例子。任何时候一个struct做 I/O,它可能包括一个iostream头。如果您正在处理的cpp文件使用了不止一种struct ( 通常为每一种包含一个头文件),那么您将冒不止一次包含<iostream>头文件并重新声明iostream的风险。
编译器认为结构的重新声明(包括struct s 和class es)是错误的,因为它允许你对不同的类型使用相同的名字。为了防止在包含多个头文件时出现这种错误,您需要使用预处理器在头文件中构建一些智能(像 <iostream>这样的标准 C++ 头文件已经拥有这种“智能”)。
只要两个声明匹配,C 和 C++ 都允许你重新声明一个函数,但是都不允许重新声明一个结构。在 C++ 中,这条规则尤其重要,因为如果编译器允许你重新声明一个结构,而两个声明不同,它会使用哪一个?
在 C++ 中,重新声明的问题经常出现,因为每个数据类型(结构和函数)通常都有自己的头文件,如果你想创建另一个使用第一个头文件的数据类型,你必须在另一个头文件中包含一个头文件。无论如何?在您的项目中,您可能会包括几个包含相同头文件的文件。在单次编译期间,编译器可以多次看到同一个头文件。除非你做些什么,编译器会看到你的结构的重新声明,并报告一个编译时错误。要解决这个问题,您需要对预处理器有更多的了解。
预处理器指令:#define,#ifdef,#endif
预处理指令#define可以用来创建编译时标志。您有两种选择:您可以简单地告诉预处理器标志已经定义,而不指定值,就像
#define FLAG
或者你可以给它一个值(这是典型的 C 语言定义常量的方式),比如
#define PI 3.14159
在这两种情况下,预处理器现在都可以测试标签,看它是否已经被定义。
#ifdef FLAG
这将产生一个真实的结果,#ifdef之后的代码将被包含在发送给编译器的包中。当预处理器遇到语句时,这种包含停止
#endif
或者
#endif // FLAG
同一行的#endif后面的任何非注释都是非法的,尽管有些编译器可能接受它。#ifdef / #endif对可以相互嵌套。
#define的补码是#undef(“未定义”的简称),这会让使用同一个变量的#ifdef语句产生错误的结果。#undef也会导致预处理器停止使用宏。#ifdef的补码是#ifndef,如果标签没有被定义,它将产生一个 true(这是我们将在头文件中使用的)。
在 C 预处理器中还有其他有用的特性。你应该检查你的当地文件以获得完整的一套。
头文件的标准
在每个包含结构的头文件中,您应该首先检查这个头文件是否已经包含在这个特定的cpp文件中。通过测试一个预处理器标志可以做到这一点。如果没有设置这个标志,那么这个文件就没有被包含,你应该设置这个标志(这样这个结构就不能被重新声明)并声明这个结构。如果设置了标志,则该类型已经被声明,因此您应该忽略声明它的代码。下面是头文件的样子:
#ifndef HEADER_FLAG
#define HEADER_FLAG
// Type declaration here...
#endif // HEADER_FLAG
如您所见,第一次包含头文件时,头文件的内容(包括您的类型声明)将被预处理器包含。所有随后被包含在单个编译单元中的时候,类型声明都将被忽略。名称 HEADER_FLAG 可以是任何唯一的名称,但是要遵循的一个可靠标准是将头文件的名称大写,并用下划线代替句点(前导下划线,但是,为系统名称保留)。清单 4-8 显示了一个例子。
清单 4-8 。防止重新定义的简单标题
//: C04:Simple.h
// Simple header that prevents redefinition
#ifndef SIMPLE_H
#define SIMPLE_H
struct Simple {
int i,j,k;
initialize() { i = j = k = 0; }
};
#endif // SIMPLE_H ///:∼
虽然#endif后面的SIMPLE_H被注释掉,因此被预处理器忽略,但它对文档很有用。
这些防止多重包含的预处理语句通常被称为include guard。
标题中的名称空间
你会注意到使用指令出现在本书几乎所有的cpp文件中,通常以的形式出现
using namespace std;
由于std是包围整个标准 C++ 库的名称空间,这个特殊的using指令允许标准 C++ 库中的名称被无限制地使用。然而,你几乎不会在头文件中看到using指令(至少,不会在作用域之外)。原因是using指令消除了对该特定名称空间的保护,并且这种影响持续到当前编译单元结束。如果你把一个using指令(在一个作用域之外)放在一个头文件中,这意味着任何包含这个头文件的文件都会失去名称空间保护,这通常意味着其他头文件。因此,如果您开始将using指令放在头文件中,实际上很容易“关闭”任何地方的名称空间,从而抵消名称空间的有益效果。
简而言之,不要把using指令放在头文件中。
在项目中使用标题
当用 C++ 构建一个项目时,你通常会通过把许多不同的类型(数据结构和相关的函数)放在一起来创建它。你通常将每种类型或每组相关类型的声明放在一个单独的头文件中,然后在一个翻译单元中为该类型定义函数。使用该类型时,必须包含头文件才能正确执行声明。
有时在本书中会遵循这种模式,但是更多的情况下例子会非常小,所以所有的东西——结构声明、函数定义和main( )函数——都可能出现在一个文件中。但是,请记住,在实践中,您可能希望使用单独的文件和头文件。
嵌套结构
将数据和函数名从全局名称空间中取出的便利扩展到了结构中。您可以将一个结构嵌套在另一个结构中,从而将关联的元素放在一起。声明语法是您所期望的,正如您在清单 4-9 中看到的,它将下推堆栈实现为一个简单的链表,因此它*“从不”*耗尽内存。
清单 4-9 。嵌套结构
//: C04:Stack.h
// Nested struct in linked list
#ifndef STACK_H
#define STACK_H
struct Stack {
struct Link {
void* data;
Link* next;
void initialize(void* dat, Link* nxt);
}* head;
void initialize();
void push(void* dat);
void* peek();
void* pop();
void cleanup();
};
#endif // STACK_H ///:∼
嵌套的struct被称为Link,它包含一个指向列表中下一个Link的指针和一个指向存储在Link中的数据的指针。如果next指针为零,意味着你在列表的末尾。
注意,head指针是在struct Link的声明之后定义的,而不是一个单独的定义Link* head。这是一个来自 C 的语法,但是它强调了结构声明后分号的重要性;分号表示该结构类型定义的逗号分隔列表的结尾。
注通常列表是空的。
嵌套结构有自己的initialize( )函数,就像到目前为止出现的所有结构一样,以确保正确的初始化。 Stack有一个initialize( )和cleanup( )函数,还有push( ),它获取一个指向您希望存储的数据的指针(它假设这个数据已经被分配到堆上),还有pop( ),它从Stack的顶部返回data指针并移除顶部元素。(当你 pop( ) 一个元素,你就负责销毁data指向的对象。)函数peek( )也从顶部元素返回data指针,但是它将顶部元素留在Stack上。
清单 4-10 包含成员函数的定义。
清单 4-10 。包含成员函数定义的嵌套链表
//: C04:Stack.cpp {O}
// Linked list with nesting
// Includes definitions of member functions
#include "Stack.h" // To be INCLUDED from Header FILE above
#include "../require.h"
using namespace std;
void
Stack::Link::initialize(void* dat, Link* nxt) {
data = dat;
next = nxt;
}
void Stack::initialize() { head = 0; }
void Stack::push(void* dat) {
Link* newLink = new Link;
newLink->initialize(dat, head);
head = newLink;
}
void* Stack::peek() {
require(head != 0, "Stack empty");
return head->data;
}
void* Stack::pop() {
if(head == 0) return 0;
void* result = head->data;
Link* oldHead = head;
head = head->next;
delete oldHead;
return result;
}
void Stack::cleanup() {
require(head == 0, "Stack not empty");
} ///:∼
第一个定义特别有趣,因为它向您展示了如何定义嵌套结构的成员。您只需使用额外级别的范围解析来指定封闭的struct的名称。Stack::Link::initialize( )获取参数并将其分配给其成员。
Stack::initialize( ) 将head设置为零,因此对象知道它有一个空列表。
Stack::push( )获取参数,该参数是指向您想要跟踪的变量的指针,并将它推送到Stack。首先,它使用new为它将在顶部插入的Link分配存储空间。然后它调用Link的initialize( )函数给Link的成员分配合适的值。注意next指针被分配给当前的head;然后head被分配给新的Link指针。这有效地将Link推到了列表的顶部。
Stack::pop( )在Stack的当前顶端捕获data指针;然后向下移动head指针并删除Stack的旧顶部,最后返回捕获的指针。当pop( )移除最后一个元素时,则head再次变为零,意味着Stack为空。
实际上并不做任何清理工作。相反,它建立了一个严格的策略,即您(使用这个 Stack *对象的客户端程序员)*负责弹出这个Stack中的所有元素并删除它们。如果Stack不为空,则require( )用于指示出现了编程错误。
为什么Stack析构函数不能对客户端程序员没有pop( )的所有对象负责?问题是,Stack拿着void指针,你会在 第十三章 中了解到,为void*调用delete并不能很好地解决问题。谁对内存负责的主题甚至不是那么简单,你会在后面的章节中看到。
清单 4-11 包含了一个测试Stack的例子。
清单 4-11 。测试堆栈
//: C04:StackTest.cpp
//{L} Stack
//{T} StackTest.cpp
// Test of nested linked list
#include "Stack.h"
#include "../require.h"
#include<fstream>
#include<iostream>
#include<string>
using namespace std;
int main(int argc, char* argv[]) {
requireArgs(argc, 1); // File name is argument
ifstream in(argv[1]);
assure(in, argv[1]);
Stack textlines;
textlines.initialize();
string line;
// Read file and store lines in the Stack:
while(getline(in, line))
textlines.push(new string(line));
// Pop the lines from the Stack and print them:
string* s;
while((s = (string*)textlines.pop()) != 0) {
cout << *s << endl;
delete s;
}
textlines.cleanup();
} ///:∼
这类似于前面的例子,但是它将文件中的行(作为 string 指针)推到Stack上,然后将它们弹出,这导致文件以相反的顺序打印出来。注意,pop( )成员函数返回一个void*,在使用它之前,必须将其转换回一个string*。为了打印string,指针被解引用。
当textlines被填充时,通过制作一个new string(line),为每个push( )克隆line的内容。从 new-expression 返回的值是一个指向新的string的指针,该指针被创建并从line复制信息。如果您只是简单地将line的地址传递给push( ),那么您将得到一个充满相同地址的Stack,所有地址都指向line。文件名取自命令行。为了保证在命令行上有足够的参数,您会看到在require.h头文件中使用的第二个函数:requireArgs( ),它将argc与所需的参数数量进行比较,并打印一条适当的错误消息,如果没有足够的参数,就退出程序。
全局范围分辨率
作用域解析操作符让您摆脱编译器默认选择的名称(“最近”的名称)不是您想要的情况。例如,假设您有一个带有局部标识符a的结构,并且您想从成员函数内部选择一个全局标识符a。编译器默认选择本地的,所以你必须告诉它不这样做。当您想要使用范围解析指定全局名称时,可以使用前面不带任何内容的运算符。清单 4-12 显示了变量和函数的全局范围解析。
清单 4-12 。全局范围分辨率
//: C04:Scoperes.cpp
// Global scope resolution for a variable
// As well as a function
int a;
void f() {}
struct S {
int a;
void f();
};
void S::f() {
::f(); // Would be recursive otherwise!
::a++; // Select the global a
a--; // The a at struct scope
}
int main() { S s; f(); } ///:∼
在S::f( )中没有范围解析,编译器将默认选择f( )和a的成员版本。
审查会议
- 在这一章中,你学习了 C++ 的基本“变化”:你可以在结构中放置函数。这种新类型的结构被称为抽象数据类型,使用这种结构创建的变量被称为该类型的对象,或实例。
- 调用一个对象的成员函数叫做向该对象发送消息。在面向对象编程中的主要动作是向对象发送消息。
- 虽然将数据和函数打包在一起对于代码组织来说是一个很大的好处,并且使库的使用更容易,因为它通过隐藏名字来防止名字冲突,但是你还可以做更多的事情来使 C++ 编程更安全。
- 在下一章,你将学习如何保护一个结构的一些成员,这样只有你才能操纵它们。
- 这在结构的用户可以更改的内容和只有程序员可以更改的内容之间建立了一个清晰的界限。**
五、隐藏实现
虽然 C 是世界上最受欢迎和广泛使用的编程语言之一,但 C++ 的发明是由一个主要的编程因素促成的:日益增加的复杂性。多年来,计算机程序变得越来越大,越来越复杂。即使 C 语言是一种优秀的编程语言,它也有其局限性。在 C 中,一旦一个程序从 20,000 行代码超过 100,000 行代码,它就变得难以管理,难以从整体上把握。C++ 的目的就是打破这个壁垒。C++ 的基本本质在于允许程序员理解、领会和管理更复杂和更大的程序。
C++ 从 C 中吸取了最好的想法,并将它们与几个新概念结合起来。结果是一种不同的组织你的程序的方式。在 C 中,一个程序是围绕着它的代码组织的(例如,“发生了什么?”)而在 C++ 中,程序是围绕其数据组织的(例如,“谁受到了影响?”).用 C 编写的程序是由其函数定义的,任何函数都可以对程序使用的任何类型的数据进行操作。在 C++ 中,程序是围绕数据组织的,基本前提是数据控制对代码的访问。因此,您定义了数据和允许对该数据进行操作的例程,而数据类型精确地定义了什么样的操作适用于该数据。
为了支持这一面向对象编程的基本原则,C++ 具有封装的特性,因此它可以将代码和它所处理的数据绑定在一起,保护它们免受外部干扰和误用。通过以这种方式链接代码和数据,创建了一个对象。因此,对象是支持封装的设备。
在一个对象中,代码/数据或两者都可以是该对象的private/public或protected,这只有在继承对象的情况下才起作用。我们将在本章中讨论这个访问控制和更多(比如类);继承的主题将在后面的章节中讨论。
在前一章中,我们已经讨论了通过 C++ 尽可能多地使用现有的 C 代码和库来提高生产率的必要性。一个典型的 C 库包含一个struct和一些作用于该struct的相关函数。到目前为止,您已经看到了 C++ 如何获取概念上与相关联的函数,并通过将函数声明放在struct的范围内,改变调用struct函数的方式,消除将结构地址作为第一个参数的传递,并向程序添加新的类型名(,这样您就不必为* struct 标签创建类型集)来使它们真正与*相关联。**
这都是方便的;它帮助你组织你的代码,使它更容易写和读。然而,当在 C++ 中使库更容易时,还有其他重要的问题,尤其是安全和控制的问题。本章着眼于结构中的边界问题。
设定限值
在任何关系中,重要的是要有各方都尊重的界限。当您创建一个库时,您与使用该库构建应用程序或另一个库的客户端程序员建立了关系。
在 a C struct中,就像在 C 中的大多数事情一样,没有规则。客户端程序员可以用那个struct做任何他们想做的事情,并且没有办法强制任何特定的行为。例如,即使你在上一章看到了名为initialize( )和cleanup( )的函数的重要性,客户程序员也可以选择不调用这些函数。
注我们将在下一章探讨更好的方法。
即使你真的希望客户端程序员不要直接操纵你的struct的一些成员,在 C 中也没有办法阻止它。对这个世界来说一切都是赤裸裸的。
控制对成员的访问有两个原因。第一是让客户程序员不要接触他们不应该接触的工具——这些工具是数据类型的内部机制所必需的,但不是客户程序员解决特定问题所需的接口的一部分。这实际上是对客户程序员的一种服务,因为他们可以很容易地看到对他们来说什么是重要的,什么是可以忽略的。
访问控制的第二个原因是允许库设计者改变结构的内部工作,而不用担心它会如何影响客户程序员。在上一章的Stack示例中,为了提高速度,您可能希望以大块的方式分配存储,而不是每次添加元素时都创建新的存储。如果接口和实现被清楚地分离和保护,您可以完成这一点,并且只需要客户端程序员重新链接。
C++ 访问控制
C++ 引入了三个新的关键字来设置结构中的边界:public、private和protected。它们的用法和含义非常简单。这些访问说明符 只在一个结构声明中使用,它们改变所有跟在它们后面的声明的边界。无论何时使用访问说明符,后面都必须跟一个冒号。
Public表示所有人都可以使用下面的所有成员声明。public成员就像struct成员。例如,清单 5-1 中的struct 声明是相同的。
清单 5-1 。C++ 的 public 就像 C 的 struct 一样
//: C05:Public.cpp
// Uses identical struct declarations
struct A {
int i;
char j;
float f;
void func();
};
void A::func() {}
struct B {
public:
int i;
char j;
float f;
void func();
};
void B::func() {}
int main() {
A a; B b;
a.i = b.i = 1;
a.j = b.j = 'c';
a.f = b.f = 3.14159;
a.func();
b.func();
} ///:∼
另一方面,private关键字意味着除了你——该类型的创建者——之外,没有人可以访问该类型的函数成员。private是你和客户端程序员之间的一堵砖墙;如果有人试图访问一个private成员,他们会得到一个编译时错误。在清单 5-1 的struct B中,您可能想要隐藏部分表示(即数据成员),只有您可以访问;你可以在清单 5-2 中看到这一点。
清单 5-2 。私有访问说明符
//: C05:Private.cpp
// Setting the Boundary
// and Hiding Portions of the Representation
struct B {
private:
char j;
float f;
public:
int i;
void func();
};
void B::func() {
i = 0;
j = '0';
f = 0.0;
};
int main() {
B b;
b.i = 1; // OK, public
//! b.j = '1'; // Illegal, private
//! b.f = 1.0; // Illegal, private
} ///:∼
虽然func( )可以访问B的任何成员(因为func( )是B的成员,因此自动授予其权限),但是像main( )这样的普通全局函数却不能。当然,其他结构的成员函数也不能。只有在结构声明(“契约”)中明确说明的函数才能访问private成员。
访问说明符没有规定的顺序,它们可能会出现多次。它们影响在它们之后和下一个访问说明符之前声明的所有成员。
另一个访问说明符:protected
最后一个访问说明符是protected。protected的行为就像private,除了一个我们现在不能谈论的例外:“继承的”结构(不能访问 private 成员)被授予访问protected成员的权限。这将在第十四章引入继承时变得更加清楚。出于当前目的,考虑protected就像private一样。
朋友
如果您想显式授予对一个不是当前结组合员的函数的访问权限,该怎么办?这是通过在结构声明中声明一个friend 函数来实现的。重要的是,friend声明出现在结构声明中,因为您(和编译器)必须能够阅读结构声明,并看到关于该数据类型的大小和行为的每一条规则。在任何关系中,一个非常重要的规则是“谁可以访问我的私有实现?”
该类控制哪些代码可以访问其成员。如果你不是一个friend,没有神奇的方法从外面“闯入”;你不能声明一个新类,然后说:“你好,我是Blah的朋友!”期待看到Blah的private和protected成员。
可以将一个全局函数声明为friend,也可以将另一个结构的成员函数,甚至整个结构声明为friend。清单 5-3 显示了一个例子。
清单 5-3 。宣布成为朋友
//: C05:Friend.cpp
// Friend allows special access
// Declaration (incomplete type specification):
struct X;
struct Y {
void f(X*);
};
struct X { // Definition
private:
int i;
public:
void initialize();
friend void g(X*, int); // Global friend
friend void Y::f(X*); // Struct member friend
friend struct Z; // Entire struct is a friend
friend void h();
};
void X::initialize() {
i = 0;
}
void g(X* x, int i) {
x->i = i;
}
void Y::f(X* x) {
x->i = 47;
}
struct Z {
private:
int j;
public:
void initialize();
void g(X* x);
};
void Z::initialize() {
j = 99;
}
void Z::g(X* x) {
x->i += j;
}
void h() {
X x;
x.i = 100; // Direct data manipulation
}
int main() {
X x;
Z z;
z.g(&x);
} ///:∼
struct Y有一个成员函数f( ),它将修改一个X类型的对象。这是一个有点难的问题,因为 C++ 编译器要求你在引用它之前声明所有的东西,所以struct Y必须在它的成员Y::f(X*)在struct X中被声明为朋友之前声明。但是要声明Y::f(X*),必须先声明struct X!
下面是解决方案。注意,Y::f(X*)接受了一个X对象的地址。这很重要,因为编译器总是知道如何传递地址,不管传递的对象是什么,地址的大小都是固定的,即使它没有关于类型大小的完整信息。然而,如果你试图传递整个对象,编译器必须看到X的整个结构定义,才能知道它的大小和如何传递,然后才允许你声明一个像Y::g(X)这样的函数。
通过传递一个X的地址,编译器允许你在声明Y::f(X*)之前做一个X的不完整类型规范。这在《宣言》中已经实现。
struct X;
这个声明简单地告诉编译器有一个以这个名字命名的struct,所以只要你不需要比名字更多的知识,就可以引用它。
现在,在struct X中,函数Y::f(X*)可以被声明为friend没有问题。如果你试图在编译器看到Y的完整规范之前声明它,它会给你一个错误。这是一个安全特性,用于确保一致性和消除错误。
注意另外两个friend函数。第一个将一个普通的全局函数g( )声明为一个friend。但是g( )以前没有在全局范围内声明过!事实证明,friend可以以这种方式同时声明函数和并赋予其friend状态。这延伸到整个结构,例如
friend struct Z;
是对Z的不完整的类型规范,它给出了整个结构friend的状态。
嵌套的朋友
嵌套一个结构并不会自动赋予它对private成员的访问权。要完成这个,你必须遵循一个特定的形式:首先,声明(而不定义)嵌套结构,然后声明为friend,最后定义结构。结构定义必须与friend声明分开,否则它会被编译器视为非成员。清单 5-4 显示了一个例子。
清单 5-4 。嵌套的朋友
//: C05:NestFriend.cpp
// Demonstrates Nested friends
#include <iostream>
#include <cstring> // memset()
using namespace std;
const int sz = 20;
struct Holder {
private:
int a[sz];
public:
void initialize();
struct Pointer;
friend struct Pointer;
struct Pointer {
private:
Holder* h;
int* p;
public:
void initialize(Holder* h);
// Move around in the array:
void next();
void previous();
void top();
void end();
// Access values:
int read();
void set(int i);
};
};
void Holder::initialize() {
memset(a, 0, sz * sizeof(int));
}
void Holder::Pointer::initialize(Holder* rv) {
h = rv;
p = rv->a;
}
void Holder::Pointer::next() {
if(p < &(h->a[sz - 1])) p++;
}
void Holder::Pointer::previous() {
if(p > &(h->a[0])) p--;
}
void Holder::Pointer::top() {
p = &(h->a[0]);
}
void Holder::Pointer::end() {
p = &(h->a[sz - 1]);
}
int Holder::Pointer::read() {
return *p;
}
void Holder::Pointer::set(int i) {
*p = i;
}
int main() {
Holder h;
Holder::Pointer hp, hp2;
int i;
h.initialize();
hp.initialize(&h);
hp2.initialize(&h);
for(i = 0; i < sz; i++) {
hp.set(i);
hp.next();
}
hp.top();
hp2.end();
for(i = 0; i < sz; i++) {
cout << "hp = " << hp.read()
<< ", hp2 = " << hp2.read() << endl;
hp.next();
hp2.previous();
}
} ///:∼
一旦Pointer被声明,它就被授权访问Holder的私有成员,方法是
friend struct Pointer;
struct Holder包含一个int的数组,Pointer允许你访问它们。因为Pointer与Holder有很强的关联,所以让它成为Holder的成员结构是明智的。但是因为Pointer是一个独立于Holder的类,你可以在main( )中创建一个以上的类,并用它们来选择数组的不同部分。Pointer是一个结构,而不是一个原始的 C 指针,所以你可以保证它总是安全地指向Holder内部。
标准的 C 库函数memset( )(在<cstring>中)在清单 5-4 的程序中使用是为了方便。对于起始地址之后的n字节(n是第三个参数),它将从特定地址(第一个参数)开始的所有内存设置为特定值(第二个参数)。当然,你可以简单地使用一个循环来遍历所有的内存,但是memset( )是可用的,经过了良好的测试(所以你不太可能引入错误),并且可能比你手工编码更有效。
它是纯净的吗?
类定义给了你一个审计线索,所以你可以通过查看类来发现哪些函数有权限修改类的私有部分。如果一个函数是一个friend,这意味着它不是一个成员,但是你无论如何都要允许修改私有数据,并且它必须在类定义中列出,这样每个人都可以看到它是一个特权函数。
C++ 是一种混合面向对象的语言,而不是一种纯粹的语言,添加friend是为了避开突然出现的实际问题。指出这使得语言不那么“纯粹”是很好的,因为 C++ 被设计成实用的 ??,而不是渴望抽象的理想。
对象布局
第四章声明了为 C 编译器编写的struct和后来用 C++ 编译的struct将保持不变。这主要指的是struct的对象布局——也就是说,单个变量的存储位于为对象分配的内存中。如果 C++ 编译器改变了 C struct s 的布局,那么您编写的任何 C 代码,如果不恰当地利用了struct中变量位置的知识,都将崩溃。
然而,当您开始使用访问说明符时,您已经完全进入了 C++ 领域,事情发生了一些变化。在一个特定的访问块(一组由访问说明符分隔的声明)中,变量保证是连续布局的,就像在 C 中一样。但是,访问块可能不会按照您声明它们的顺序出现在对象中。尽管编译器会通常完全按照您看到的方式来布置块,但这并没有什么规则,因为特定的机器架构和/或操作环境可能会明确支持private和protected,这可能需要将这些块放在特殊的内存位置。语言规范不想限制这种优势。
访问说明符是结构的一部分,不影响从结构中创建的对象。在程序运行之前,所有的访问规范信息都会消失;通常这发生在编译期间。在一个正在运行的程序中,对象成为“存储区域”,仅此而已。如果你真的想,你可以打破所有规则,直接访问内存,就像你在 C 里做的那样。C++ 不是为了防止你做不明智的事情;它只是为你提供了一个更容易、更令人满意的选择。
一般来说,在编写程序时,依赖任何特定于实现的东西都不是一个好主意。当您必须有特定于实现的依赖项时,将它们封装在一个结构中,以便任何移植更改都集中在一个地方。
上课了
访问控制通常被称为实现隐藏。在结构中包含函数(通常被称为封装)会产生具有特征和行为的数据类型,但是访问控制会在该数据类型中设置边界——有两个重要原因。首先是确定客户端程序员能使用什么,不能使用什么。您可以将您的内部机制构建到结构中,而不用担心客户端程序员会认为这些机制是他们应该使用的接口的一部分。
这直接导致了第二个原因,即将接口从实现中分离出来。如果该结构在一组程序中使用,但是客户端程序员除了向public接口发送消息之外什么也不能做,那么您可以修改任何属于private的东西,而不需要修改他们的代码。
封装和访问控制加在一起,发明了比C更多的东西。我们现在处于面向对象编程的世界,在这里,一个结构描述一类对象,就像你描述一类鱼或一类鸟一样:属于这个类的任何对象都将共享这些特征和行为。这就是结构声明的含义,它描述了这种类型的所有对象的外观和行为。
在最初的 OOP 语言 Simula-67 中,关键字class用于描述一种新的数据类型。这显然启发了 Stroustrup(C++ 语言的首席设计师)为 c++ 选择了相同的关键字,以强调这是整个语言的焦点:创建新的数据类型,而不仅仅是带有函数的C。这当然看起来像是一个新关键字的充分理由。
然而,在 C++ 中使用class几乎是一个不必要的关键字。它与struct关键字完全相同,除了一点:class默认为private,而struct默认为public。清单 5-5 显示了产生相同结果的两个结构。
清单 5-5 。比较结构和类
//: C05:Class.cpp
// Similarity of struct and class
struct A {
private:
int i, j, k;
public:
int f();
void g();
};
int A::f() {
return(i + j + k);
}
void A::g() {
i = j = k = 0;
}
// Identical results are produced with:
class B {
int i, j, k;
public:
int f();
void g();
};
int B::f() {
return(i + j + k);
}
void B::g() {
i = j = k = 0;
}
int main() {
A a;
B b;
a.f(); a.g();
b.f(); b.g();
} ///:∼
class是 C++ 中基本的 OOP 概念。这是本书中而不是将被设置为粗体的关键词之一——随着一个词像“类”一样频繁地重复,它变得令人讨厌。“向类的转变是如此重要,以至于 C++ 设计者们倾向于将struct完全抛弃,但是向后兼容 C 的需要不允许这样做。
许多人更喜欢创建更像struct-而不是 class- 的类的风格,因为您通过从public元素开始覆盖了类的默认到private行为,比如:
class X {
public:
void interface_function();
private:
void private_function();
int internal_representation;
};
这背后的逻辑是,读者首先看到感兴趣的成员更有意义,然后他们可以忽略任何写有private的内容。事实上,所有其他成员都必须在类中声明的唯一原因是,编译器知道对象有多大,可以正确地分配它们,这样可以保证一致性。
然而,本书中的示例将把private成员放在第一位,就像这样:
class X {
void private_function();
int internal_representation;
public:
void interface_function();
};
有些人甚至不厌其烦地修饰自己的私人名字,就像这样:
class Y {
public:
void f();
private:
int mX; // "Self-decorated" name
};
因为mX已经隐藏在Y的范围内,所以m(对于“成员”)是不必要的。然而,在具有许多全局变量的项目中(这是您应该努力避免的,但在现有项目中有时是不可避免的),能够在成员函数定义中区分哪些数据是全局的,哪些数据是成员是有用的。
修改存储以使用访问控制
从 第四章 中提取例子并修改它们以使用类和访问控制是有意义的。请注意,接口的客户端程序员部分现在是如何被清楚地区分的,所以客户端程序员不可能意外地操作了他们不应该操作的类的一部分。参见清单 5-6 。
清单 5-6 。更新存储以使用访问控制
//: C05:Stash.h
// Converted to use access control
#ifndef STASH_H
#define STASH_H
class Stash {
int size; // Size of each space
int quantity; // Number of storage spaces
int next; // Next empty space
// Dynamically allocated array of bytes:
unsigned char* storage;
void inflate(int increase);
public:
void initialize(int size);
void cleanup();
int add(void* element);
void* fetch(int index);
int count();
};
#endif // STASH_H ///:∼
inflate( )函数被设为private,因为它只被add( )函数使用,因此是底层实现的一部分,而不是接口。这意味着,在以后的某个时候,您可以更改底层实现来使用不同的系统进行内存管理。
除了包含文件的名称之外,头是本例中唯一更改的内容。实现文件和测试文件是相同的。
修改堆栈以使用访问控制
作为第二个例子,清单 5-7 显示了Stack变成了一个类。现在嵌套的数据结构是private,这很好,因为它确保了客户端程序员既不必查看它,也不必依赖于Stack的内部表示。
清单 5-7 。将堆栈转换为类
//: C05:Stack2.h
// Nested structs via linked list
#ifndef STACK2_H
#define STACK2_H
class Stack {
struct Link {
void* data;
Link* next;
void initialize(void* dat, Link* nxt);
}* head;
public:
void initialize();
void push(void* dat);
void* peek();
void* pop();
void cleanup();
};
#endif // STACK2_H ///:∼
和以前一样,实现没有改变,所以这里不再重复。测试也是一样的。唯一改变的是类接口的健壮性。访问控制的真正价值是防止您在开发过程中越界。事实上,编译器是唯一知道类成员保护级别的东西。没有将访问控制信息分解到成员名称中,并传递给链接器。所有的保护检查都是由编译器完成的;它在运行时消失了。
请注意,呈现给客户端程序员的界面现在是真正的下推堆栈。它碰巧被实现为一个链表,但是你可以改变它,而不影响客户端程序员与之交互的内容,或者(更重要的是)一行客户端代码。
处理类别
C++ 中的访问控制允许你把接口和实现分开,但是实现隐藏只是部分的。为了正确地创建和操作对象,编译器仍然必须看到对象所有部分的声明。您可以想象一种编程语言,它只需要对象的公共接口,并允许隐藏私有实现,但是 C++ 尽可能静态地(在编译时)执行类型检查。这意味着如果出现错误,您将尽早了解。这也意味着你的程序更有效率。然而,包含私有实现有两个影响:实现是可见的,即使您不容易访问它,并且它可能导致不必要的重新编译。
隐藏实现
一些项目不能让他们的实现对客户程序员可见。它可能会在库头文件中显示公司不想让竞争对手知道的战略信息。例如,您可能正在处理一个安全性成为问题的系统,例如加密算法,并且您不想在头文件中暴露任何可能帮助人们破解代码的线索。或者你可能把你的库放在一个“敌对”的环境中,程序员无论如何都会使用指针和类型转换直接访问私有组件。在所有这些情况下,将实际结构编译在实现文件中而不是在头文件中公开是很有价值的。
减少重新编译
如果一个文件被接触(即被修改)或者如果它所依赖的另一个文件(即一个包含的头文件)被接触,您的编程环境中的项目管理器将导致该文件的重新编译。这意味着,无论何时对一个类进行更改,无论是对公共接口还是私有成员声明,都将强制对包含该头文件的任何内容进行重新编译。对于一个处于早期阶段的大型项目来说,这可能非常笨拙,因为底层的实现可能会经常改变;如果项目非常大,编译的时间会阻碍快速周转。
解决这一问题的技术有时被称为处理类——除了一个指针,即*微笑,关于实现的一切都消失了。*指针指的是一个结构,其定义和所有成员函数定义都在实现文件中。因此,只要接口不变,头文件就不会受到影响。实现可以随意更改,只需要重新编译实现文件,并与项目重新链接。
清单 5-8 包含了一个演示该技术的简单例子。头文件只包含公共接口和一个不完全指定的类的指针。
清单 5-8 。处理类别
//: C05:Handle.h
// Handle classes header file
#ifndef HANDLE_H
#define HANDLE_H
class Handle {
struct Hire; // Class declaration only
Hire* smile;
public:
void initialize();
void cleanup();
int read();
void change(int);
};
#endif // HANDLE_H ///:∼
这是客户端程序员能够看到的全部内容。这条线
struct Hire;
是不完整的类型规范或类声明(类定义包括类的主体)。它告诉编译器Hire是一个结构名,但是它没有给出关于struct的任何细节。这些信息只够创建一个指向struct的指针;在提供结构体之前,您不能创建对象。在这种技术中,结构体隐藏在实现文件中(参见清单 5-9 )。
清单 5-9 。
//: C05:Handle.cpp {O}
// Handle implementation
#include "Handle.h" // To be INCLUDED from Header FILE above
#include "../require.h" // To be INCLUDED from Header FILE in *Chapter 3*
// Define Handle's implementation:
struct Handle::Hire {
int i;
};
void Handle::initialize() {
smile = new Hire;
smile->i = 0;
}
void Handle::cleanup() {
delete smile;
}
int Handle::read() {
return smile->i;
}
void Handle::change(int x) {
smile->i = x;
} ///:∼
Hire是一个嵌套结构,因此必须使用范围解析来定义,例如:
struct Handle::Hire {
在Handle::initialize( )中,存储被分配给Hire结构,而在Handle::cleanup( )中,该存储被释放。这个存储用来代替您通常放入类的private部分的所有数据元素。当你编译Handle.cpp时,这个结构定义隐藏在目标文件中,没有人能看到它。如果你改变了Hire的元素,唯一需要重新编译的文件是Handle.cpp,因为头文件没有被改动。
Handle的使用类似于 any class 的使用:包含头部、创建对象和发送消息(参见清单 5-10 )。
清单 5-10 。使用 Handle 类
//: C05:UseHandle.cpp
//{L} Handle
// Use the Handle class
#include "Handle.h"
int main() {
Handle u;
u.initialize();
u.read();
u.change(1);
u.cleanup();
} ///:∼
客户端程序员唯一可以访问的是公共接口,所以只要实现是唯一改变的,文件就永远不需要重新编译。因此,尽管这不是完美的实现隐藏,但这是一个很大的改进。
审查会议
- C++ 中的访问控制为类的创建者提供了有价值的控制。该类的用户可以清楚地看到他们可以使用什么,忽略什么。然而,更重要的是确保没有客户端程序员变得依赖于类的底层实现的任何部分。如果你作为类的创建者知道这一点,你可以改变的底层实现,因为没有的客户端程序员会因为不能访问类的这一部分而受到影响。
- 当你有能力改变底层实现时,你不仅可以在以后的某个时间改进你的设计,而且你也有犯错误的自由。无论你计划和设计得多么仔细,你都会犯错误。知道犯这些错误是相对安全的意味着你会更有实验性,你会学得更快,你会更快地完成你的项目。
- 一个类的公共接口是客户程序员所看到的,所以这是类在分析和设计过程中得到“正确”的最重要的部分。但即使这样,你也有一些改变的余地。如果你第一次没有得到正确的接口,你可以添加更多的功能,只要你不删除任何客户程序员已经在他们的代码中使用的功能。*
六、初始化和清理
第四章通过将一个典型的 C 库的所有分散组件封装到一个结构中(一种抽象数据类型,从现在开始称为类,对库的使用做了重大改进。
这不仅提供了库组件的单一统一入口点,而且还隐藏了类名中的函数名。在 第五章 中,介绍了访问控制(实现隐藏 )。这为类设计者提供了一种建立明确界限的方法,以确定允许客户端程序员操作什么,什么是不允许的。这意味着数据类型操作的内部机制是在类的设计者的控制和判断之下,客户程序员很清楚他们可以并且应该注意哪些成员。
封装和访问控制一起在提高库的易用性方面迈出了重要的一步。他们提供的“新数据类型的概念在某些方面比来自 C 的现有内置数据类型要好。C++ 编译器现在可以为该数据类型提供类型检查保证,从而确保使用该数据类型时的安全级别。
然而,说到安全,编译器能为我们做的比 C 提供的多得多。在这一章和以后的章节中,你将会看到 C++ 中设计的附加特性,这些特性使你程序中的错误几乎跳出来抓住你,有时甚至在你编译程序之前,但通常是以编译器警告和错误的形式。出于这个原因,您很快就会习惯这种听起来不太可能的情况,即编译的 C++ 程序通常第一次就能正确运行。
其中两个安全问题是初始化和清理。当程序员忘记初始化或清理变量时,很大一部分 C 错误就发生了。对于 C 库来说,尤其是这样,当客户程序员不知道如何初始化一个struct,或者甚至不知道他们必须如何初始化。
注意库通常不包含初始化函数,所以客户端程序员被迫手工初始化
struct。
清理是一个特殊的问题,因为 C 程序员习惯于在完成后忘记变量,所以库的struct可能需要的任何清理经常被错过。
在 C++ 中,初始化和清理的概念对于方便库的使用和消除当客户端程序员忘记执行这些活动时出现的许多微妙的错误是必不可少的。本章分析了 C++ 中有助于保证正确初始化和清理的特性。
用构造器保证初始化
前面定义的Stash和Stack类都有一个名为initialize()的函数,它的名字暗示了在以任何其他方式使用对象之前应该调用它。不幸的是,这意味着客户端程序员必须确保正确的初始化。客户端程序员在匆忙让您的惊人的库解决他们的问题时,很容易错过初始化这样的细节。在 C++ 中,初始化太重要了,不能留给客户端程序员。类设计者可以通过提供一个叫做构造器的特殊函数来保证每个对象的初始化。如果一个类有一个构造器,编译器会在一个对象被创建的时候,在客户程序员得到这个对象之前,自动调用这个构造器。客户端程序员甚至不能选择构造器调用;它由编译器在定义对象时执行。
下一个挑战是如何命名这个函数。有两个问题。首先,您使用的任何名称都有可能与您希望用作该类成员的名称发生冲突。第二是因为编译器负责调用构造器,所以它必须总是知道要调用哪个函数。C++ 设计者选择的解决方案似乎是最简单和最符合逻辑的:构造器的名称与类名相同。初始化时自动调用这样的函数是有意义的。
下面是一个带有构造器的简单类:
class X {
int i;
public:
X(); // Constructor
};
现在,当一个对象被定义时,比如:
void f() {
X a;
// ...
}
同样的事情发生,就好像a是一个int;为该对象分配存储空间。但是当程序到达定义了a的序列点(执行点)时,构造器被自动调用。也就是说,编译器在定义的时候悄悄地为对象a插入对X::X()的调用。像任何成员函数一样,构造器的第一个(" secret" )参数是this指针——调用它的对象的地址。然而,在构造器的情况下,this指向一个未初始化的内存块,正确初始化这个内存是构造器的工作。
像任何函数一样,构造器可以有参数,允许您指定如何创建对象,赋予它初始化值,等等。构造器参数为您提供了一种方法来保证对象的所有部分都被初始化为适当的值。例如,如果一个名为Tree的类有一个构造器,它采用一个整数参数来表示树的高度,那么您必须创建一个树对象,如下所示:
Tree t(12); // 12-foot tree
如果Tree(int)是你唯一的构造器,编译器不会让你用其他方式创建对象。
注意我们将在下一章看到多重构造器和调用构造器的不同方式。
这就是构造器的全部内容。这是一个特别命名的函数,在对象创建时,编译器会自动为每个对象调用它。尽管它很简单,但它非常有价值,因为它消除了一大类问题,使代码更容易编写和阅读。例如,在前面的代码片段中,您看不到对某个initialize()函数的显式函数调用,该函数在概念上与定义是分开的。在 C++ 中,定义和初始化是统一的概念——不能缺一不可。
构造器和析构函数都是非常不常见的函数类型:它们没有返回值。这与void返回值明显不同,在后者中,函数不返回任何内容,但您仍然可以选择将它设置为其他内容。构造器和析构函数不返回任何东西,你没有选择。将一个对象带入和带出程序的行为是特殊的,像出生和死亡,编译器总是自己调用函数,以确保它们发生。如果有返回值,并且您可以选择自己的返回值,编译器就必须知道如何处理返回值,否则客户端程序员就必须显式调用构造器和析构函数,这就消除了它们的安全性。
用析构函数保证清理
作为一名 C 程序员,你经常会想到初始化的重要性,但是很少会想到清理。毕竟清理一个int需要做什么?忘了它吧。然而,对于库来说,一旦你使用完一个对象,仅仅让它“??”释放“??”就不那么安全了。如果它修改了一些硬件,或者在屏幕上放了一些东西,或者在堆上分配了存储空间,那该怎么办呢?如果你只是忘记了它,你的对象就永远不会在离开这个世界时达到终结。在 C++ 中,清理和初始化一样重要,因此用析构函数来保证。
析构函数的语法类似于构造器的语法:类名用作函数名。但是,析构函数通过前导波浪符号(∼)与构造器区分开来。此外,析构函数从来没有任何参数,因为析构从来不需要任何选项。下面是析构函数的声明:
class Y {
public:
∼Y();
};
当对象超出范围时,编译器会自动调用析构函数。您可以通过对象的定义点看到构造器在哪里被调用,但是析构函数调用的唯一证据是对象周围范围的右括号。然而析构函数仍然被调用,即使你使用goto跳出一个作用域。(goto仍然存在于 C++ 中,是为了向后兼容 C ,以备不时之需。)你应该注意到,由标准的 C 库函数setjmp()和longjmp()实现的非局部 goto ,不会导致析构函数被调用。
注意这是规范,即使你的编译器没有这样实现。依赖规范中没有的特性意味着你的代码是不可移植的。
清单 6-1 展示了到目前为止你所看到的构造器和析构函数的特性。
清单 6-1 。构造器和析构函数
//: C06:Constructor1.cpp
// Demonstrates features of constructors & destructors
#include <iostream>
using namespace std;
class Tree {
int height;
public:
Tree(int initialHeight); // Constructor
∼Tree(); // Destructor
void grow(int years);
void printsize();
};
Tree::Tree(int initialHeight) {
height = initialHeight;
}
Tree::∼Tree() {
cout << "inside Tree destructor" << endl;
printsize();
}
void Tree::grow(int years) {
height += years;
}
void Tree::printsize() {
cout << "Tree height is " << height << endl;
}
int main() {
cout << "before opening brace" << endl;
{
Tree t(12);
cout << "after Tree creation" << endl;
t.printsize();
t.grow(4);
cout << "before closing brace" << endl;
}
cout << "after closing brace" << endl;
} ///:∼
下面是这个程序的输出:
before opening brace
after Tree creation
Tree height is 12
before closing brace
inside Tree destructor
Tree height is 16
after closing brace
您可以看到析构函数在包围它的作用域的右括号处被自动调用。
消除定义块
在 C 中,你必须在一个程序块的开始定义所有的变量,在左括号之后。这在编程语言中并不少见,给出的理由通常是“??”良好的编程风格同时,每次需要一个新的变量时,返回到块的开头似乎不太方便。此外,当变量定义接近其使用点时,代码可读性更好。
也许这些争论是文体上的。然而,在 C++ 中,强制在作用域的开始定义所有对象有一个严重的问题。如果构造器存在,则必须在创建对象时调用它。但是,如果构造器有一个或多个初始化参数,你怎么知道在作用域的开始会有初始化信息呢?在一般的编程情况下,你不会。因为 C 没有private的概念,所以这种定义和初始化的分离是没有问题的。然而,C++ 保证当一个对象被创建时,它同时被初始化。这可以确保没有未初始化的对象在系统中运行。 C 不在乎;事实上, C 鼓励了这种做法,它要求你在必须拥有初始化信息之前,在一个块的开始定义变量。
一般来说,在获得构造器的初始化信息之前,C++ 不允许创建对象。正因为如此,如果你必须在作用域的开始定义变量,这种语言是不可行的。事实上,这种语言的风格似乎鼓励对一个对象的定义尽可能接近它的使用点。在 C++ 中,任何适用于“对象”的规则都会自动引用内置类型的对象。这意味着任何内置类型的类对象或变量也可以在作用域中的任何点定义。这也意味着您可以等到有了变量的信息后再定义它,这样您就可以同时定义和初始化了。参见清单 6-2 中的示例。
清单 6-2 。在任何地方定义变量
//: C06:DefineInitialize.cpp
// Demonstrates that you can define variables anywhere
#include "../require.h" // To be INCLUDED from Header FILE in *Chapter 3*
#include <iostream>
#include <string>
using namespace std;
class G {
int i;
public:
G(int ii);
};
G::G(int ii) { i = ii; }
int main() {
cout << "initialization value? ";
int retval = 0;
cin >> retval;
require(retval != 0);
int y = retval + 3;
G g(y);
} ///:∼
你可以看到一些代码被执行;然后retval被定义、初始化,并用于捕获用户输入;然后定义y和g。另一方面, C 不允许在除了作用域开头的任何地方定义变量。
一般来说,您应该在尽可能靠近变量使用点的地方定义变量,并且在定义变量时总是初始化它们。
注意这是对内置类型的风格建议,初始化是可选的。
这是一个安全问题。通过减少变量在该范围内可用的时间,您就减少了它在该范围的其他部分被误用的机会。此外,可读性也得到了提高,因为读者不必为了知道变量的类型而来回跳转到范围的开头。
对于循环
在 C++ 中,你经常会看到在for表达式中定义了一个for循环计数器,比如:
for(int j = 0; j < 100; j++) {
cout << "j = " << j << endl;
}
for(int i = 0; i < 100; i++)
cout << "i = " << i << endl;
上面的语句是重要的特例,给新 C++ 程序员造成了困惑。
变量i和j直接在for表达式中定义(,这在 C 中是做不到的)。然后它们可用于for回路。这是一个非常方便的语法,因为上下文消除了关于i和j的目的的所有疑问,所以为了清晰起见,你不需要使用像i_loop_counter这样笨拙的名字。
然而,如果你期望变量i和j的生命期超出for循环的范围,可能会导致一些混乱——它们没有超出。
第三章 指出while和switch语句也允许在它们的控制表达式中定义对象,尽管这种用法似乎远不如for循环重要。
注意隐藏封闭范围内变量的局部变量。通常,对嵌套变量和该作用域的全局变量使用相同的名称会引起混淆,并且容易出错。
较小的望远镜是好设计的标志,至少对我来说是这样。如果你的一个功能有几个页面,也许你想用这个功能做太多的事情。更细粒度的函数不仅更有用,而且也更容易发现 bug。
存储分配
现在,变量可以在作用域中的任何点定义,所以看起来变量的存储可能直到它的定义点才被定义。实际上,编译器更有可能遵循 C 中的惯例,在一个作用域的左括号处为该作用域分配所有存储空间。这无关紧要,因为作为一名程序员,在它被定义之前,你不能访问它。虽然存储是在块的开始分配的,但是构造器调用直到定义对象的序列点才发生,因为标识符直到那时才可用。编译器甚至检查以确保你没有把对象定义(和构造器调用)放在序列点只能有条件地通过它的地方,比如在一个switch语句中或者一个goto可以跳过它的地方。取消注释清单 6-3 中的语句会产生一个警告或错误。
清单 6-3 。C++ 中不允许跳过构造器
//: C06:Nojump.cpp
// Demonstrates that you can't jump past constructors in C++
class X {
public:
X();
};
X::X() {}
void f(int i) {
if(i < 10) {
//! goto jump1; // Error: goto bypasses init
}
X x1; // Constructor called here
jump1:
switch(i) {
case 1 :
X x2; // Constructor called here
break;
//! case 2 : // Error: case bypasses init
X x3; // Constructor called here
break;
}
}
int main() {
f(9);
f(11);
}///:∼
在这段代码中,goto和switch都有可能跳过调用构造器的序列点。即使构造器没有被调用,这个对象也会在作用域内,所以编译器会给出一个错误消息。这再次保证了一个对象不能被创建,除非它也被初始化。
当然,这里讨论的所有存储分配都发生在堆栈上。编译器通过向下移动堆栈指针来分配存储空间(一个相对术语,它可能表示实际堆栈指针值的增加或减少,这取决于您的计算机)。也可以使用new在堆上分配对象,这将在第十三章的中进一步探讨。
用构造器和析构函数存放
前几章的例子有明显的映射到构造器和析构函数的函数:initialize()和cleanup()。清单 6-4 显示了使用构造器和析构函数的Stash头。
清单 6-4 。使用构造器和析构函数隐藏头
//: C06:Stash2.h
// Demonstrates Stash header file with constructors & destructors
#ifndef STASH2_H
#define STASH2_H
class Stash {
int size; // Size of each space
int quantity; // Number of storage spaces
int next; // Next empty space
// Dynamically allocated array of bytes:
unsigned char* storage;
void inflate(int increase);
public:
Stash(int size);
∼Stash();
int add(void* element);
void* fetch(int index);
int count();
};
#endif // STASH2_H //
/:∼
唯一改变的成员函数定义是initialize()和cleanup(),它们被一个构造器和析构函数所取代(参见清单 6-5 )。
清单 6-5 。用构造器&析构函数实现 Stash
//: C06:Stash2.cpp {O}
// Demonstrates implementation of Stash
// with constructors & destructors
#include "Stash2.h" // To be INCLUDED from Header FILE above
#include "../require.h"
#include <iostream>
#include <cassert>
using namespace std;
const int increment = 100;
Stash::Stash(int sz) {
size = sz;
quantity = 0;
storage = 0;
next = 0;
}
int Stash::add(void* element) {
if(next >= quantity) // Enough space left?
inflate(increment);
// Copy element into storage,
// starting at next empty space:
int startBytes = (next * size);
unsigned char* e = (unsigned char*)element;
for(int i = 0; i < size; i++)
storage[startBytes + i] = e[i];
next++;
return(next - 1); // Index number
}
void* Stash::fetch(int index) {
require(0 <= index, "Stash::fetch (-)index");
if(index >= next)
return 0; // To indicate the end
// Produce pointer to desired element:
return &(storage[index * size]);
}
int Stash::count() {
return next; // Number of elements in CStash
}
void Stash::inflate(int increase) {
require(increase > 0,
"Stash::inflate zero or negative increase");
int newQuantity = (quantity + increase);
int newBytes = (newQuantity * size);
int oldBytes = (quantity * size);
unsigned char* b = new unsigned char[newBytes];
for(int i = 0; i < oldBytes; i++)
b[i] = storage[i]; // Copy old to new
delete [](storage); // Old storage
storage = b; // Point to new memory
quantity = newQuantity;
}
Stash::∼Stash() {
if(storage != 0) {
cout << "freeing storage" << endl;
delete []storage;
}
} ///:∼
你可以看到require.h函数被用来监视程序员的错误,而不是assert()。失败的assert()的输出没有require.h功能的输出有用。
因为inflate()是私有的,所以require()可能失败的唯一原因是其他成员函数之一意外地传递了一个不正确的值给inflate()。如果您确定这不可能发生,您可以考虑移除require(),但是您可能要记住,在类稳定之前,总有可能会有新的代码被添加到类中,从而导致错误。require()的成本很低(并且可以使用预处理器自动移除)并且代码健壮性的价值很高。
注意清单 6-6 中Stash对象的定义是如何在需要它们之前出现的,以及初始化是如何作为构造器参数列表中定义的一部分出现的。
清单 6-6 。测试存储(带构造器&析构函数)
//: C06:Stash2Test.cpp
//{L} Stash2
// Demonstrates testing of Stash
// (with constructors & destructors)
#include "Stash2.h"
#include "../require.h"
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
int main() {
Stash intStash(sizeof(int));
for(int i = 0; i < 100; i++)
int Stash.add(&i);
for(int j = 0; j < intStash.count(); j++)
cout << "intStash.fetch(" << j << ") = "
<< *(int*) intStash.fetch(j)
<< endl;
const int bufsize = 80;
Stash stringStash(sizeof(char) * bufsize);
ifstream in("Stash2Test.cpp");
assure(in, " Stash2Test.cpp");
string line;
while(getline(in, line))
stringStash.add((char*)line.c_str());
int k = 0;
char* cp;
while((cp = (char*)stringStash.fetch(k++))!=0)
cout << "stringStash.fetch(" << k << ") = "
<< cp << endl;
} ///:∼
还要注意cleanup()调用是如何被消除的,但是当intStash和stringStash超出范围时,析构函数仍然会被自动调用。
在Stash例子中需要注意的一点是:我非常小心地只使用内置类型;也就是那些没有析构函数的。如果你试图将类对象复制到Stash中,你会遇到各种各样的问题,而且它不会正常工作。标准 C++ 库实际上可以将对象的正确副本复制到它的容器中,但是这是一个相当混乱和复杂的过程。在下面的Stack例子中(清单 6-7 ,你会看到指针被用来回避这个问题。
带有构造器和析构函数的堆栈
用构造器和析构函数重新实现链表(在Stack内部)显示了构造器和析构函数如何灵活地与new和delete一起工作。清单 6-7 包含了修改后的头文件。
清单 6-7 。带有构造器/析构函数的堆栈
//: C06:Stack3.h
// Demonstrates the modified header file
#ifndef STACK3_H
#define STACK3_H
class Stack {
struct Link {
void* data;
Link* next;
Link(void* dat, Link* nxt);
∼Link();
}* head;
public:
Stack();
∼Stack();
void push(void* dat);
void* peek();
void* pop();
};
#endif // STACK3_H ///:∼
不仅Stack有构造器和析构函数,嵌套的struct Link也有,正如你在清单 6-8 中看到的。
清单 6-8 。用构造器/析构函数实现堆栈
//: C06:Stack3.cpp {O}
// Demonstrates implementation of Stack
// with constructors/destructors
#include "Stack3.h" // To be INCLUDED from Header FILE above
#include "../require.h"
using namespace std;
Stack::Link::Link(void* dat, Link* nxt) {
data = dat;
next = nxt;
}
Stack::Link::∼Link() { }
Stack::Stack() { head = 0; }
void Stack::push(void* dat) {
head = new Link(dat, head);
}
void* Stack::peek() {
require(head != 0, "Stack empty");
return head->data;
}
void* Stack::pop() {
if(head == 0) return 0;
void* result = head->data;
Link* oldHead = head;
head = head->next;
delete oldHead;
return result;
}
Stack::∼Stack() {
require(head == 0, "Stack not empty");
} ///:∼
Link::Link()构造器简单地初始化了data和next指针,所以在Stack::push()中的行
head = new Link(dat, head);
不仅分配了一个新的链接(使用关键字 new创建动态对象,在第四章中介绍),而且它还巧妙地初始化了那个链接的指针。
你可能想知道为什么Link的析构函数不做任何事情——特别是,为什么它不做delete的data指针?有两个问题。在第四章的中,引入了Stack,指出如果一个void指针指向一个对象,你就不能正确地delete(断言将在第十三章中被证明)。但是另外,如果Link析构函数删除了data指针,pop()最终会返回一个指向被删除对象的指针,这肯定是一个 bug。这有时被称为所有权的问题:Link和Stack只保存指针,但不负责清理它们。这意味着你必须非常小心,你知道谁是负责人。例如,如果你不pop()和delete所有Stack上的指针,它们不会被Stack的析构函数自动清除。这可能是一个棘手的问题,并导致内存泄漏,所以知道谁负责清理对象可以决定一个成功的程序和一个有错误的程序之间的区别;这就是为什么如果Stack对象在销毁时不是空的,那么Stack::∼Stack()会打印一条错误消息。
因为Link对象的分配和清理隐藏在Stack中——这是底层实现的一部分——你看不到它在测试程序中发生,尽管你负责删除从pop()返回的指针。参见清单 6-9 。
清单 6-9 。测试堆栈(带有构造器/析构函数)
//: C06:Stack3Test.cpp
//{L} Stack3
//{T} Stack3Test.cpp
// Demonstrates testing of Stack
// (with constructors/destructors)
#include "Stack3.h"
#include "../require.h"
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
int main(int argc, char* argv[]) {
requireArgs(argc, 1); // File name is argument
ifstream in(argv[1]);
assure(in, argv[1]);
Stack textlines;
string line;
// Read file and store lines in the stack:
while(getline(in, line))
textlines.push(new string(line));
// Pop the lines from the stack and print them:
string* s;
while((s = (string*) textlines.pop()) != 0) {
cout << *s << endl;
delete s;
}
} ///:∼
在这种情况下,textlines中的所有行都被弹出并删除,但如果没有,您会得到一条require()消息,这意味着存在内存泄漏。
聚合初始化
一个集合就像它听起来的那样:一堆聚集在一起的东西。该定义包括混合类型的集合,如struct s 和class es。数组是单一类型的集合。
初始化聚合可能容易出错且繁琐。在 C++ 中,称为聚合初始化的东西使它更加安全。当你创建一个聚集的对象时,你所要做的就是赋值,初始化将由编译器负责。这种赋值有几种形式,取决于您正在处理的聚合类型,但是在所有情况下,赋值中的元素都必须用花括号括起来。对于内置类型的数组,这非常简单。
int a[5] = { 1, 2, 3, 4, 5 };
如果你试图给出比数组元素更多的初始化器,编译器会给出一个错误信息。但是如果你给更少的初始值,比如:,会发生什么呢
int b[6] = {0};
这里,编译器将对第一个数组元素使用第一个初始化器,然后对所有没有初始化器的元素使用零。注意,如果你定义了一个没有初始化列表的数组,这种初始化行为不会发生。因此,上面的表达式是一种简洁的将数组初始化为零的方法,不需要使用for循环,也没有任何一个减一错误的可能性。(取决于编译器,它也可能比for循环更高效。)
数组的第二种简写方式是自动计数,让编译器根据初始化器的数量来确定数组的大小,比如:
int c[] = { 1, 2, 3, 4 };
现在,如果你决定向数组中添加另一个元素,你只需添加另一个初始化器。如果您可以设置您的代码,使其只需要在一个地方进行更改,那么您就减少了修改过程中出错的机会。但是如何确定数组的大小呢?表达式(sizeof () / sizeof (*c)) ( 整个数组的大小除以第一个元素的大小)的作用是,如果数组的大小发生变化,它不需要改变,例如:
for(int i = 0; i < (sizeof (c) / sizeof (*c)); i++)
c[i]++;
因为结构也是聚合,所以它们可以用类似的方式初始化。因为一个 C 样式struct有它的所有成员public,它们可以被直接赋值,比如:
struct X {
int i;
float f;
char c;
};
X x1 = { 1, 2.2, 'c' };
如果有这样的对象数组,可以通过对每个对象使用一组嵌套的花括号来初始化它们,例如:
X x2[3] = { {1, 1.1, 'a'}, {2, 2.2, 'b'} };
这里,第三个对象被初始化为零。
如果任何一个数据成员是private ( ,这是在 C++ 中设计良好的类的典型情况),或者即使一切都是public,但是有一个构造器,事情就不同了。在上面的例子中,初始化器被直接分配给集合的元素,但是构造器是一种通过正式接口强制初始化的方式。这里,必须调用构造器来执行初始化。所以如果你有一个看起来像是的struct
struct Y {
float f;
int i;
Y(int a);
};
您必须指示构造器调用。最好的方法是显式的,比如:
Y y1[] = { Y(1), Y(2), Y(3) };
您得到三个对象和三个构造器调用。任何时候你有一个构造器,不管是有所有成员public的struct还是有数据成员private的class,所有的初始化都必须通过构造器,即使你使用的是聚合初始化。
清单 6-10 显示了第二个显示多个构造器参数的例子。
清单 6-10 。使用多个构造器参数(带聚合初始化)
//: C06:Multiarg.cpp
// Demonstrates use of multiple constructor arguments
// (with aggregate initialization)
#include <iostream>
using namespace std;
class Z {
int i, j;
public:
Z(int ii, int jj);
void print();
};
Z::Z(int ii, int jj) {
i = ii;
j = jj;
}
void Z::print() {
cout << "i = " << i << ", j = " << j << endl;
}
int main() {
Z zz[] = { Z(1,2), Z(3,4), Z(5,6), Z(7,8) };
for(int i = 0; i < (sizeof (zz) / sizeof (*zz)); i++)
zz[i].print();
} ///:∼
注意,看起来像是为数组中的每个对象调用了一个显式的构造器。
默认构造器
默认构造器是一个可以不带参数调用的函数。默认的构造器被用来创建一个“普通对象”,但是当编译器被告知创建一个对象但是没有给出任何细节的时候也很重要。例如,如果你取先前定义的struct Y并在如下定义中使用它:
Y y2[2] = { Y(1) };
编译器会抱怨找不到默认的构造器。数组中的第二个对象希望创建时没有参数,这就是编译器寻找默认构造器的地方。事实上,如果您简单地定义一组Y对象,例如:
Y y3[7];
编译器会抱怨,因为它必须有一个默认的构造器来初始化数组中的每个对象。
如果像这样创建一个单独的对象,也会出现同样的问题:
Y y4;
记住,如果你有一个构造器,编译器会确保构造总是发生,不管情况如何。
默认的构造器是如此重要的,以至于如果(只有如果)一个结构(struct 或 class)没有构造器,编译器会自动为你创建一个。所以清单 6-11 中的代码是有效的。
清单 6-11 。生成自动默认构造器
//: C06:AutoDefaultConstructor.cpp
// Demonstrates automatically-generated default constructor
class V {
int i; // private
}; // No constructor
int main() {
V v, v2[10];
}
///:∼
然而,如果定义了任何构造器,并且没有默认的构造器,那么上面的V实例将会产生编译时错误。
您可能认为编译器合成的构造器应该进行一些智能初始化,比如将对象的所有内存设置为零。但这并不会— 增加额外的开销,但不在程序员的控制之内。如果你想把内存初始化为零,你必须自己写默认的构造器。
虽然编译器会为你创建一个默认的构造器,但是编译器合成的构造器的行为很少是你想要的。您应该将此功能视为安全网,但要谨慎使用。一般来说,你应该显式定义你的构造器,不要让编译器替你做。
审查会议
- C++ 提供的看似复杂的机制应该给你一个强烈的暗示,告诉你在语言中初始化和清理的重要性。
- C++ 设计者对 CC的生产率的第一个观察是,很大一部分编程问题是由变量的不正确初始化引起的。这种类型的错误很难发现,类似的问题也适用于不适当的清理。
- 因为构造器和析构函数允许您“保证”正确的初始化和清理(编译器不允许在没有正确的构造器和析构函数调用的情况下创建和销毁对象),所以您可以获得完全的控制和安全。
- 聚合初始化也以类似的方式包含在内——它防止你使用内置类型的聚合犯典型的初始化错误,并使你的代码更加简洁。
- 在 C++ 中,编码过程中的安全性是一个大问题。初始化和清理是其中重要的一部分,但是随着这本书的进展,你也会看到其他的安全问题。