文章目录
- 第12章 类和动态内存分配
- 12.1 动态内存和类
- 12.1.1 复习示例和静态类成员
- 12.1.2 特殊成员函数
- 12.1.2.1 默认构造函数
- 12.1.2.2 复制构造函数
- 12.1.2.3 何时调用复制构造函数?
- 12.1.2.4 默认的复制构造函数
- 12.1.3 赋值运算符
- 12.2 改进后的新String类
- 12.2.1 修订后的默认构造函数
- 12.2.2 比较成员函数
- 12.2.3 使用中括号表示法访问字符
- 12.2.4 静态类成员函数
- 12.2.5 进一步重载赋值运算符
- 12.3 在构造函数中使用new时应注意的事项
- 12.4 有关返回对象的说明
- 12.4.1 返回指向const对象的引用
- 12.4.2 返回指向非const对象的引用
- 12.4.3 返回对象
- 12.4.4 返回const对象
- 12.5 使用指向对象的指针
- 12.5.1 再谈new和delete
- 12.5.2 指针和对象小结
- 12.5.3 再谈定位new运算符
- 12.6 复习各种技术
- 12.6.1 重载<<运算符
- 12.6.2 转换函数
- 12.6.3 其构造函数使用new的类
- 12.7 队列模拟问题
- 12.7.1 ATM问题
- 12.7.2 队列类
- 12.7.3 Queue类的接口
- 12.7.4 **Queue类的实现**
- 12.7.5 是否需要其他函数?
- 12.7.6 Customer类
- queue.h
- queue.cpp
- 12.7.7 ATM模拟
- main.cpp
- 12.8 总结
第12章 类和动态内存分配
12.1 动态内存和类
通常,最好是在程序运行时(而不是编译时)确定诸如使用多少内存等问题。
例如,写一个字符数组去保存人名,如何确定数组的长度?通常的C++方法是,在类构造函数中使用new运算符在程序运行时分配所需的内存。为此,通常的方法是使用string类,它将为您处理内存管理细节。
C++使用new和delete运算符来动态控制内存,让程序在运行时决定内存分配,而不是在编译时决定。
C++在分配内存时采取的部分策略:让程序在运行时决定内存分配,而不是在编译时决定。这样可根据程序的需要,而不是根据一系列严格的存储类型规则来使用内存。C++使用new和delete运算符来动态控制内存。遗憾的是如果在在类中使用这些运算符将导致许多新的编程问题。在这种情况下,析构函数将是必不可少的,而不再是可有可无的。
12.1.1 复习示例和静态类成员
类中静态成员的特点是:无论创建了多少对象,程序都只创建一个静态类变量副本。也就是说,类的所有对象共享同一个静态成员,就像家中的电话可被全体家庭使用一样。但是注意,不能在类声明中初始化静态成员变量,这是因为类声明描述了如何分配内存,但并不分配内存。对于静态类成员,可以在类声明之外使用单独的语句来进行初始化,这是因为静态类成员是单独存储的,而不是对象的组成部分。在类外进行静态成员的初始化时,不要使用static。
初始化是在方法文件中,而不是在类声明文件中进行的,这是因为类声明位于头文件中,程序可能将头文件包括在其他几个文件中。如果在头文件中进行初始化,将出现多个初始化语句副本,从而引发错误。
但是一种例外情况(见第10章)是,静态数据成员为整型const或枚举型const。
#include<iostream>
using namespace std;
class stu{
public:
static int num;//注意这里不能赋值,但是加了const以后可以赋值
};
int stu::num = 10;//注意这里不能加static
int main(){
cout<<stu::num<<endl;
return 0;
}
这一章还是不如侯捷视频里讲的好,建议回顾一下当时的视频。
初始化是在方法文件中,而不是在类声明文件中进行的,这是因为类声明位于头文件中,程序可能将头文件包括在其他几个文件中。如果在头文件中进行初始化,将出现多个初始化语句副本,从而引发错误。
new和delete,new[]和delete[]必须是成对出现的才规范。
StringBad sailor = sports;//这种形式的初始化等效于下面的语句
StringBad sailor = StringBad(sports);//因为sports类型为StringBad,因此相应的构造函数原型应该如下:
StringBad(const StringBad & sports);
当您使用一个对象来初始化另一个对象时,编译器将自动生成上述构造函数(称为复制构造函数,因为它创建对象的一个副本)。自动生成的构造函数不知道需要更新静态变量num_string,因此会将计数方案搞乱。实际上,这个例子说明的所有问题都是由编译器自动生成的成员函数引起的。从这个角度来看,编译器有时候自动生成的成员函数实际上可能带来一些坏处。
12.1.2 特殊成员函数
C++自动提供了以下函数:
- 默认构造函数,如果没有定义构造函数;
- 默认析构函数,如果没有定义;
- 复制构造函数,如果没有定义;
- 赋值运算符,如果没有定义;
- 地址运算符,如果没有定义。
更准确的说,如果程序中使用对象的方式要求其这样做,编译器将生成上述最后三个函数的定义。
C++11提供了另外两个特殊成员函数:移动构造函数(moveconstructor)和移动赋值运算符(move assignment operator)。
12.1.2.1 默认构造函数
如果没有提供任何构造函数,C++将创建默认构造函数。如果定义了构造函数,C++将不会定义默认构造函数。默认构造函数使得被定义的对象相当于一个常规的自动变量,也就是说,它的值在初始化的时候是未知的。
带参数的构造函数也可以是默认构造函数,只要所有参数都有默认值。
A(){a = 0};
A(int n = 0){a = 0};//这两个不能同时存在。
12.1.2.2 复制构造函数
**复制构造函数用于将一个对象复制到新创建的对象中。**也就是说,它用于初始化过程中(包括按值传递参数),而不是常规的赋值过程中。类的复制构造函数原型通常如下:
classname (const classname&);
对于复制构造函数,需要知道两点:何时调用和有何功能。
12.1.2.3 何时调用复制构造函数?
新建一个对象并将其初始化为同类现有对象时,复制构造函数都将被调用。
这在很多情况下都可能发生,最常见的情况是将新对象显式地初始化为现有的对象。例如,假设motto是一个StringBad对象,则下面4种声明都将调用复制构造函数:
StringBad ditto(motto);
StringBad metto = motto;
StringBad also = StringBad(motto);
String* pStringBad = new StringBad(motto);//用一个已有的对象去创建一个刚刚声明的对象
其中中间的2种声明可能会使用复制构造函数直接创建metoo和also,也可能使用复制构造函数生成一个临时对象,然后将临时对象的内容赋给metoo和also,这取决于具体的实现。最后一种声明使用motto初始化一个匿名对象,并将新对象的地址赋给pstring指针。
每当程序生成了对象副本时,编译器都将使用复制构造函数。
具体地说,当函数按值传递对象(如程序清单12.3中的callme2())或函数返回对象时,都将使用复制构造函数。记住,按值传递意味着创建原始变量的一个副本。编译器生成临时对象时,也将使用复制构造函数。
由于按值传递对象将调用复制构造函数,因此应该按引用传递对象。这样可以节省调用构造函数的时间以及存储新对象的空间。
12.1.2.4 默认的复制构造函数
默认的复制构造函数逐个复制非静态成员(成员复制也称为浅复制),复制的是成员的值。
StringBad sailor = sports;//相当于以下操作,但是由于私有成员无法访问,因此这些代码是无法通过编译的
StringBad sailor;
sailor.str = sports.str;
sailor.len = sports.len;
在定义类的时候,C++默认生成的复制构造函数常常会引发各种问题。而解决方法之一是:定义一个显式复制构造函数以解决问题。必须定义复制构造函数的原因在于,一些类成员是使用new初始化的、指向数据的指针,而不是数据本身。图12.3说明了深度复制。
定义一个显式复制构造函数以解决问题
解决类设计中这种问题的方法是进行深度复制(deep copy)。也就是说,复制构造函数应当复制字符串并将副本的地址赋给str成员,而不仅仅是复制字符串地址。
如果类中包含了使用new初始化的指针成员,应当定义一个复制构造函数,以复制指向的数据,而不是指针,这被称为深度复制。复制的另一种形式(成员复制或浅复制)只是复制指针值。浅复制仅浅浅地复制指针信息,而不会深入“挖掘”以复制指针引用的结构。这样每个对象都有自己的字符串,而不是引用另一个对象的字符串。调用析构函数时都将释放不同的字符串,而不会试图去释放已经被释放的字符串。
必须定义复制构造函数的原因在于,一些类成员是使用new初始化的、指向数据的指针,而不是数据本身。
如果类中包含了使用new初始化的指针成员,应当定义一个复制构造函数,以复制指向的数据,而不是指针,这被称为深度复制。复制的另一种形式(成员复制或浅复制)只是复制指针值。浅复制仅浅浅地复制指针信息,而不会深入“挖掘”以复制指针引用的结构。
12.1.3 赋值运算符
C++允许类对象赋值,这是通过自动为类重载赋值运算符实现的。这种运算符的原型如下:
classname & classname::operator = (const classname&);//它接受并返回一个指向类对象的引用。
将已有的对象赋给另一个对象时,将使用重载的赋值运算符:
StringBad headline("Celery");
StringBad knot;
knot = headline;//调用赋值运算符
初始化对象时,并不一定会使用赋值运算符:
StringBad metoo = knot;//调用的是复制构造函数,也有可能调用赋值运算符。
这里,metoo是一个新创建的对象,被初始化为knot的值,因此使用复制构造函数。
然而,正如前面说的,实现时也可能分两步来处理这条语句:使用复制构造函数创建一个临时对象,然后通过赋值将临时对象的值复制到新对象中。这就是说,初始化总是会调用复制构造函数,而使用=运算符时也可能调用赋值运算符。
赋值的问题出在哪里?
和前面一样,容易出现两个指针指向同一处地址的问题,先用一个指针去删除一处内容,再用另一个指针去删除一个已经删除的内容,出现的问题是不确定的,因此可能改变内存中的内容,导致程序异常终止。
如何解决赋值的问题?
对于由于默认赋值运算符不合适而导致的问题,解决办法是提供赋值运算符(进行深度复制)定义。其实现与复制构造函数相似,但也有一些差别。
- 由于目标对象可能引用了以前分配的数据,所以函数应使用delete[]来释放这些数据。
- 函数应当避免将对象赋给自身;否则,给对象重新赋值前,释放内存操作可能删除对象的内容。
- 函数返回一个指向调用对象的引用。
通过返回一个对象,函数可以像常规赋值操作那样,连续进行赋值,即如果S0、S1和S2都是StringBad对象,则可以编写这样的代码:
S0 = S1 = S2;//相当于
S0.operator = (S1.operator=(S2));
StringBad & StringBad::operator=(const StringBad & st){
if(this == &st){
return *this;
}
delete [] str;
str = new char[len+1];
std::strcpy(str,st.str);
return *this;
}
代码首先检查自我复制,这是通过查看赋值运算符右边的地址(&s)是否与接收对象(this)的地址相同来完成的。如果相同,程序将返回*this,然后结束。赋值运算符是只能由类成员函数重载的运算符之一。
如果地址不同,函数将释放str指向的内存,这是因为稍后将把一个新字符串的地址赋给str。如果不首先使用delete运算符,则上述字符串将保留在内存中。由于程序中不再包含指向该字符串的指针,因此这些内存被浪费掉。
接下来的操作与复制构造函数相似,即为新字符串分配足够的内存空间,然后将赋值运算符右边的对象中的字符串复制到新的内存单元中。
上述操作完成后,程序返回*this并结束。
12.2 改进后的新String类
静态函数:static 返回值类型 类型名(形参列表)。静态函数不可用跨文件使用。调用静态函数没有压栈弹栈这些开销,效率比较高。在其他文件中允许使用同名函数。所以静态方法一般都是在头文件中声明和定义是在一起的。
12.2.1 修订后的默认构造函数
String::String(){
len = 0;
str = new char[1];
str[0] = '\0';
}
为什么不是new char?两种方式分配的内存量相同,区别在于前者与类析构函数兼容,而后者不兼容。析构函数中包含如下代码:
delete [] str;//delete[]与使用new[]初始化的指针和空指针都兼容。
//因此下面的代码:
str = new char[1];
str[0] = '\0';//可以修改为
str = 0;
C++11中的空指针:
在C++98中,字面值0有两个含义:可以表示数字值零,也可以表示空指针,这使得阅读程序的人和编译器难以区分。有些程序员使用(void *) 0来标识空指针(空指针本身的内部表示可能不是零),还有些程序员使用NULL,这是一个表示空指针的C语言宏。
C++11提供了更好的解决方案:引入新关键字nullptr,用于表示空指针。您仍可像以前一样使用0——否则大量现有的代码将非法,但建议您使用nullptr:str = nullptr;
12.2.2 比较成员函数
strcmp()函数:第一个参数位于第二个参数之前,则该函数返回一个负值;如果两个字符串相同,则返回0;如果第一个参数位于第二个参数之后,则返回一个正值。
12.2.3 使用中括号表示法访问字符
对于中括号运算符,一个操作数位于第一个中括号的前面,另一个操作数位于两个中括号之间。因此,在表达式city[0]中,city是第一个操作数,[]是运算符,0是第二个操作数。
char & string::operator[](int i){
return str[i];
}
12.2.4 静态类成员函数
可以将成员函数声明为静态的(函数声明必须包含关键字static,但如果函数定义是独立的,则其中不能包含关键字static),这样做有两个重要的后果。
首先,不能通过对象调用静态成员函数;实际上,静态成员函数甚至不能使用this指针。如果静态成员函数是在公有部分声明的,则可以使用类名和作用域解析运算符来调用它。
其次,由于静态成员函数不与特定的对象相关联,因此只能使用静态数据成员。同样,也可以使用静态成员函数设置类级(classwide)标记,以控制某些类接口的行为。
静态成员函数 | 普通成员函数 | |
---|---|---|
所有对象共享 | Y | Y |
隐含this指针 | N | Y |
访问普通成员变量(函数) | N | Y |
访问静态成员变量(函数) | Y | Y |
通过类名直接调用 | Y | N |
通过对象名直接调用 | Y | Y |
12.2.5 进一步重载赋值运算符
String name;
char temp[40];
cin.getline(temp,40);
name = temp;
如果经常这样做,这将不是一种理想的解决方案。先来回顾一下最后一条语句是怎样工作的。
*1.程序使用构造函数String(const char )来创建一个临时String对象,其中包含temp中的字符串副本。第11章介绍过,只有一个参数的构造函数被用作转换函数。
2.使用String &String::operator=(const String &)函数将临时对象中的信息复制到name对象中。
3.程序调用析构函数~String()删除临时对象。
为了提高效率,最简单的方法是重载赋值运算符,使之能够直接使用常规字符串,这样就不用创建和删除临时对象了。下面是一种可能的实现:
String & string::operator=(const char* s){
delete [] str;
len = std::strlen(s);
str = new char[len+1];
std::strcpy(str,s);
return *this;
}
一般来讲,必须释放str指向的内存,并为新字符串分配足够的内存。
12.3 在构造函数中使用new时应注意的事项
如果在构造函数中使用new来初始化指针成员,则应在析构函数中使用delete。
new和delete必须相互兼容。new对应于delete,new[ ]对应于delete[]。
如果有多个构造函数,则必须以相同的方式使用new,要么都带中括号,要么都不带。因为只有一个析构函数,所有的构造函数都必须与它兼容。然而,可以在一个构造函数中使用new初始化指针,而在另一个构造函数中将指针初始化为空(0或C++11中的nullptr),这是因为delete(无论是带中括号还是不带中括号)可以用于空指针。
NULL、0还是nullptr:以前,空指针可以用0或NULL(在很多头文件中,NULL是一个被定义为0的符号常量)来表示。C程序员通常使用NULL而不是0,以指出这是一个指针,就像使用‘\0’而不是0来表示空字符,以指出这是一个字符一样。然而,C++传统上更喜欢用简单的0,而不是等价的NULL。但正如前面指出的,C++11提供了关键字nullptr,这是一种更好的选择。
应定义一个复制构造函数,通过深度复制将一个对象初始化为另一个对象。通常,这种构造函数与下面类似:
String::String(const string & st){
num_strings++;
len = st.len;
str = new char [len+1];
std::strcpy(str,st.str);
}
具体地说,复制构造函数应分配足够的空间来存储复制的数据,并复制数据,而不仅仅是数据的地址。另外,还应该更新所有受影响的静态类成员。
应当定义一个赋值运算符,通过深度复制将一个对象复制给另一个对象。通常,该类方法与下面类似:
String & String::operator=(const String & st){
if(this == &st){
return *this;
}
delete [] str;
len = st.len;
str = new char[len+1];
std::strcpy(str,st.str);
return *this;
}
具体地说,该方法应完成这些操作:检查自我赋值的情况,释放成员指针以前指向的内存,复制数据而不仅仅是数据的地址,并返回一个指向调用对象的引用。
12.4 有关返回对象的说明
12.4.1 返回指向const对象的引用
使用const&的常见原因是用于提高效率。如果函数返回(通过调用对象的方法或将对象作为参数)传递给它的对象,可以通过返回引用来提高其效率。
假定现在有一个返回对象的拷贝构造函数,有一个使用const&的拷贝构造函数:
- 首先,返回对象将调用复制构造函数,而返回引用不会。因此,第二个版本所做的工作更少,效率更高。
- 其次,引用指向的对象应该在调用函数执行时存在。
- 最后,v1和v2都被声明为const引用,因此返回类型必须为const,这样才匹配。
12.4.2 返回指向非const对象的引用
通过使用引用,可以避免该函数调用复制构造函数来创建一个新的对象,从而提高效率。
12.4.3 返回对象
如果被返回的对象是被调用函数中的局部变量,则不应按引用方式返回它,因为在被调用函数执行完毕时,局部对象将调用其析构函数。
12.4.4 返回const对象
如果方法或函数要返回局部对象,则应返回对象,而不是指向对象的引用。在这种情况下,将使用复制构造函数来生成返回的对象。
12.5 使用指向对象的指针
使用new初始化对象:
通常,如果Class_name是类,value的类型为Type_name,则下面的语句:
class_name * pclass = new class_name(value);
将调用如下构造函数:
class_name(Type_name);
这里可能还有一些琐碎的转换,例如:
class_name(const Type_name &);
如果不存在二义性,则将发生由原型匹配导致的转换(如从int到double)。下面的初始化方式将调用默认构造函数:
class_name * ptr = new class_name;
12.5.1 再谈new和delete
在下述情况下析构函数将被调用:
如果对象是动态变量,则当执行完定义该对象的程序块时,将调用该对象的析构函数。
如果对象是静态变量,则在程序结束时将调用对象的析构函数。
如果对象是用new创建的,则仅当您显式使用delete删除对象时,其析构函数才会被调用。
12.5.2 指针和对象小结
使用常规表示法来声明指向对象的指针:String * glamour;
可以将指针初始化为指向已有的对象:String * first = &sayings[0];
可以使用new来初始化指针,这将创建一个新的对象:
String *favorite = new String(sayings[choice]);
对类使用new将调用相应的类构造函数来初始化新创建的对象:
String * gleep = new String;
String * glop = new String("my");
String * favorite = new String(sayings[choice]);
使用new创建对象:
可以使用->运算符通过指针访问类方法:
if(sayings[i].lenth())<shortest->lenth())
可以对对象指针应用解除引用运算符(*)来获得对象:
if(sayings[i]<*first){
first = &sayings[i];
}
12.5.3 再谈定位new运算符
程序员必须负责监管用定位new运算符用从中使用的缓冲区内存单元。要使用不同的内存单元,程序员需要提供两个位于缓冲区的不同地址,并确保这两个内存单元不重叠。
pc1 = new (buffer)JustTesting;
pc3 = new (buffer + sizeof(JustTing)) JustTesting("Better Idea",6);
如果使用定位new运算符来为对象分配内存,必须确保其析构函数被调用。但如何确保呢?对于在堆中创建的对象,可以这样做:
delete pc2; //delete object pointed to by pc2
但不能像下面这样做:
delete pc1; //delete object pointed to by pc1?NO!
delete pc3; //delete object pointed to by pc3?NO!
原因在于delete可与常规new运算符配合使用,但不能与定位new运算符配合使用。
例如指针pc3没有收到new运算符返回的地址,因此delete pc3将导致运行阶段错误。
char * buffer = new char[BUF];
pc1 = new(buffer)JustTesting;//这种运行到最后,程序并不会调用JustTesting的析构函数。
这种问题的解决方案是,显式地为使用定位new运算符创建的对象调用析构函数。正常情况下将自动调用析构函数,这是需要显式调用析构函数的少数几种情形之一。显式地调用析构函数时,必须指定要销毁的对象。由于有指向对象的指针,因此可以使用这些指针:
pc3->JustTesting();
pc1->JustTesting();
需要注意的一点是正确的删除顺序。对于使用定位new运算符创建的对象,应以与创建顺序相反的顺序进行删除。原因在于,晚创建的对象可能依赖于早创建的对象。另外,仅当所有对象都被销毁后,才能释放用于存储这些对象的缓冲区。
12.6 复习各种技术
12.6.1 重载<<运算符
要重新定义 << 运算符,以便将它和cout一起用来显示对象的内容,请定义下面的友元运算符函数:
ostream & operator << (ostream & os, const c_name & obj){
os<<...;
return os;
}
12.6.2 转换函数
要将单个值转换为类类型,需要创建原型如下所示的类构造函数:
c_name (type_name value);//其中c_name为类名,type_name是要转换的类型的名称。
要将类转换为其他类型,需要创建原型如下所示的类成员函数:
operator type_name();//虽然该函数没有声明返回类型,但应返回所需类型的值。
使用转换函数时要小心。可以在声明构造函数时使用关键字explicit,以防止它被用于隐式转换。
12.6.3 其构造函数使用new的类
如果类使用new运算符来分配类成员指向的内存,在设计时应采取一些预防措施:
对于指向的内存是由new分配的所有类成员,都应在类的析构函数中对其使用delete,该运算符将释放分配的内存。
如果析构函数通过对指针类成员使用delete来释放内存,则每个构造函数都应当使用new来初始化指针,或将它设置为空指针。
构造函数中要么使用new [],要么使用new,而不能混用。如果构造函数使用的是new[],则析构函数应使用delete [];如果构造函数使用的是new,则析构函数应使用delete。
应定义一个分配内存(而不是将指针指向已有内存)的复制构造函数。这样程序将能够将类对象初始化为另一个类对象。这种构造函数的原型通常如下:
className(const className &)
应定义一个重载赋值运算符的类成员函数,其函数定义如下(其中c_pointer是c_name的类成员,类型为指向type_name的指针)。下面的示例假设使用new []来初始化变量c_pointer:
String & String::operator=(const String & st){
if(this == &st){
return *this;
}
delete [] str;
len = st.len;
str = new char[len+1];
std::strcpy(str,st.str);
return *this;
}
12.7 队列模拟问题
12.7.1 ATM问题
Heather银行打算在Food Heap超市开设一个自动柜员机(ATM)。Food Heap超市的管理者担心排队等待使用ATM的人流会干扰超市的交通,希望限制排队等待的人数。Heather银行希望对顾客排队等待的时间进行估测。要编写一个程序来模拟这种情况,让超市的管理者可以了解ATM可能造成的影响。
Heather银行的代表介绍:通常,三分之一的顾客只需要一分钟便可获得服务,三分之一的顾客需要两分钟,另外三分之一的顾客需要三分钟。另外,顾客到达的时间是随机的,但每个小时使用自动柜员机的顾客数量相当稳定。
设计一个类表示顾客,模拟顾客和队列之间的交互。
12.7.2 队列类
队列类的特征:
- 队列存储有序的项目序列;
- 队列所能容纳的项目数有一定的限制;
- 应当能够创建空队列;
- 应当能够检查队列是否为空;
- 应当能够检查队列是否是满的;
- 应当能够在队尾添加项目;
- 应当能够从队首删除项目;
- 应当能够确定队列中项目数。
12.7.3 Queue类的接口
从以上类的特征来判断如何定义Queue类的公共接口:
class Queue{
enum{Q_SIZE = 10};
public:
Queue(int qs = Q_SIZE);
~Queue();
bool isEmpty()const;
bool isFull()const;
int queueCount()const;
bool enqueue(const Item &item);
bool dequeue(Item &item);
}
12.7.4 Queue类的实现
确定接口后,便可以实现它。首先,需要确定如何表示队列数据。一种方法是使用new动态分配一个数组,它包含所需的元素数。然而,对于队列操作而言,数组并不太合适。例如,删除数组的第一个元素后,需要将余下的所有元素向前移动一位;否则需要作一些更费力的工作,如将数组视为是循环的。然而,链表能够很好地满足队列的要求。链表由节点序列构成。每一个节点中都包含要保存到链表中的信息以及一个指向下一个节点的指针。对于这里的队列来说,数据部分都是一个Item类型的值,因此可以使用下面的结构来表示节点:
struct Node{
Item item;
struct Node * next;
}
要跟踪链表,必须知道第一个节点的地址。可以让Queue类的一个数据成员指向链表的起始位置。具体地说,这是所需要的全部信息,有了这种信息后,就可以沿节点链找到任何节点。然而,由于队列总是将新项目
添加到队尾,因此包含一个指向最后一个节点的数据成员将非常方便(参见图12.9)。此外,还可以使用数据成员来跟踪队列可存储的最大项目数以及当前的项目数。所以,类声明的私有部分与下面类似:
class Queue{
private:
struct Node{ Item item; struct Node * next;};
enum(Q_SIZE = 10);
NODE * front;
NODE * rear;
int items;
const int qsize;
}
只有构造函数可以使用这种初始化列表语法。对于非静态const类成员,则必须使用这种语法。对于被声明为引用的类成员,也必须使用这种语法。这是因为引用与const数据类似,只能在被创建时进行初始化。
数据成员被初始化的顺序与它们出现在类声明中的顺序相同,与初始化器中的排列顺序无关。(这个观点存疑)
isempty()、isfull()、queuecount()的代码都非常简单。如果items为0,则队列为空;如果items等于qsize,则队列为满。要知道队列中的项目数,只需返回items的值。
将项目添加到队尾(入队)比较麻烦:
bool Queue::enqueue(const Item & item)
{
if (isfull())
return false;
Node * add = new Node; //创建节点
//如果失败,new将抛出std::bad_alloc的异常
add->item = item; //设置节点指针
add->next = NULL;
items++;
if (front == NULL) //如果队列为空
front = add; //将元素放在队首
else
rear->next = add; //否则放在队尾
rear = add; //设置尾指针指向新节点
return true;
}
方法需要经过下面几个阶段:
1)如果队列已满,则结束。
2)创建一个新节点。如果new无法创建新节点,它将引发异常,除非提供了处理异常的代码,否则程序终止。
3)在节点中放入正确的值。代码将Item值复制到节点的数据部分,并将节点的next指针设置为NULL。这样为将节点作为队列中的最后一个项目做好了准备。
4)将项目计数(items)加1。
5)将节点附加到队尾。这包含两个部分。首先,将节点与列表中的另一个节点连接起来。这是通过将当前队尾节点的next指针指向新的队尾节点来完成的。第二部分是将Queue的成员指针rear设置为指向新节点,使队列可以直接访问最后一个节点。如果队列为空,则还必须将front指针设置成指向新节点(如果只有一个节点,则它既是队首节点,也是队尾节点)。
删除队首项目(出队)也需要多个步骤完成:
bool Queue::dequeue(Item & item)
{
if (front == NULL)
return false;
item = front->item; //将队首元素赋值给item
items--;
Node * temp = front; //保存队首元素位置
front = front->next; //将front设置成指向下一个节点
delete temp; //删除以前的第一个节点
if (items == 0)
rear = NULL;
return true;
}
需要经过下面几个阶段:
1)如果队列为空,则结束
2)将队列的第一个项目提供给调用函数,这是通过将当前front节点中的数据部分复制到传递给方法的引用变量中来实现。
3)将项目计数(items)减1。
4)保存front节点的位置,供以后删除。
5)让节点出队。这是通过将Queue成员指针front设置成指向下一个节点来完成的,该节点的位置由front->next提供。
6)为节省内存,删除以前的第一个节点。
7)如果链表为空,则将rear设置为NULL。
第4步是必不可少的,因为第5步将删除关于先前第一个节点位置的信息。
12.7.5 是否需要其他函数?
类构造函数没有使用new,所以乍一看,好像不用理会由于在构造函数中使用new给类带来的特殊要求。因为向队列中添加对象将调用new来创建新的节点。通过删除节点的方式,dequeue()方法确实可以清除节点,但这并不能保证队列在到期为空。因此,类需要一个显式析构函数——该函数删除剩余的所有节点。
下面一种实现,它从链表头部开始,依次删除其中的每个节点:
Queue::~Queue()
{
Node * temp;
while (front != NULL) //确定queue不为空
{
temp = front; //保存前一个元素
front = front->next; //重置下一个元素
delete temp; //删除前一个元素
}
}
使用new的类通常需要包含显式复制构造函数和执行深度复制的赋值运算符,这个例子也是如此吗?
首先要回答的问题是,默认的成员复制是否合适?答案是否定的。
复制Queue对象的成员将生成一个新的对象,该对象指向链表原来的头和尾。因此,将项目添加到复制的Queue对象中,将修改共享的链表。这样做将造成非常严重的后果。更糟的是,只有副本的尾指针得到了更新,从原始对象的角度看,这将损坏链表。显然,要克隆或复制队列,必须提供复制构造函数和执行深度复制的赋值构造函数。
当然,这提出了这样一个问题:为什么要复制队列呢?也许是希望在模拟的不同阶段保存队列的瞬像,也可能是希望为两个不同的策略提供相同的输入。实际上,拥有拆分队列的操作是非常有用的,超市在开设额外的收款台时经常这样做。同样,也可能希望将两个队列结合成一个或者截短一个队列。
但假设这里的模拟不实现上述功能。难道不能忽略这些问题,而使用已有的方法吗?当然可以。然而,在将来的某个时候,可能需要再次使用队列且需要复制。另外,您可能会忘记没有为复制提供适当的代码。在这种情况下,程序将能编译和运行,但结果却是混乱的,甚至会崩溃。因此,最好还是提供复制构造函数和赋值运算符,尽管目前并不需要它们。
幸运的是,有一种小小的技巧可以避免这些额外的工作,并确保程序不会崩溃。这就是将所需的方法定义为伪私有方法:
class Queue
{
private:
Queue(const Queue &q) : qsize(0) { } //先发制人的定义
Queue & operator=(const Queue &q) { return *this; }
...
};
这样做有两个作用:
1)它避免了本来将自动生成的默认方法定义。
2)因为这些方法是私有的,所以不能被广泛使用。
如果nip和tuck是Queue对象,则编译器就不允许这样做:
Queue snick(nip); //错误,不被允许
tuck = nip; //错误,不被允许
C++11提供了另一种禁用方法的方式——使用关键字delete。
当对象被按值传递(或返回)时,复制构造函数将被调用。如果遵循优先采用按引用传递对象的惯例,将不会有任何问题。复制构造函数还被用于创建其他的临时对象,但Queue定义中并没有导致创建临时对象的操作,例如重载加法运算符。
12.7.6 Customer类
接下来需要设计客户类。通常,ATM客户有很多属性,例如姓名、账户和账户结余。然而,这里的模拟需要使用的唯一一个属性是客户何时进入队列以及客户交易所需的时间。当模拟生成新客户时,程序将创建一个新的客户对象,并在其中存储客户的到达时间以及一个随机生成的交易时间。当客户到达队首时,程序将记录此时的时间,并将其与进入队列的时间相减,得到客户的等候时间。下面的代码演示了如何定义和实现Customer类:
class Customer
{
private:
long arrive; //顾客到达时间
int processtime; //顾客进行时间
public:
Customer() { arrive = processtime = 0; }
void set(long when);
long when() const { return arrive; }
int ptime() const { return processtime; }
};
void Customer::set(long when)
{
processtime = std::rand() % 3 + 1;
arrive = when;
}
默认构造函数创建一个空客户。set()成员函数将到达时间设置为参数,并将处理时间设置为1~3中的随机值。
queue.h
#ifndef QUEUE_H_
#define QUEUE_H_
//这个队列包含Customer元素
class Customer
{
private:
long arrive; //顾客到达时间
int processtime; //顾客进行时间
public:
Customer() { arrive = processtime = 0; }
void set(long when);
long when() const { return arrive; }
int ptime() const { return processtime; }
};
typedef Customer Item;
class Queue
{
private:
//类中嵌套结构声明
struct Node { Item item; struct Node * next; };
enum { Q_SIZE = 10 };
//私有成员
Node * front; //队首指针
Node * rear; //队尾指针
int items; //队列中当前元素个数
const int qsize; //队列中最大元素个数
//避免本来自动生成的默认方法定义
Queue(const Queue &q) : qsize(0) { }
Queue & operator=(const Queue &q) { return *this; }
public:
Queue(int qs = Q_SIZE); //创建一个qs大小队列
~Queue();
bool isempty() const;
bool isfull() const;
int queuecount() const;
bool enqueue(const Item &item); //在队尾添加元素
bool dequeue(Item &item); //在队首删除元素
};
#endif // !QUEUE_H_
queue.cpp
#include "queue.h"
#include <cstdlib>
Queue::Queue(int qs) : qsize(qs)
{
front = rear = NULL;
items = 0;
}
Queue::~Queue()
{
Node * temp;
while (front != NULL) //确定queue不为空
{
temp = front; //保存前一个元素
front = front->next; //重置下一个元素
delete temp; //删除前一个元素
}
}
bool Queue::isempty() const
{
return items == 0;
}
bool Queue::isfull() const
{
return items == qsize;
}
int Queue::queuecount() const
{
return items;
}
//入队
bool Queue::enqueue(const Item & item)
{
if (isfull())
return false;
Node * add = new Node; //创建节点
//如果失败,new将抛出std::bad_alloc的异常
add->item = item; //设置节点指针
add->next = NULL;
items++;
if (front == NULL) //如果队列为空
front = add; //将元素放在队首
else
rear->next = add; //否则放在队尾
rear = add; //设置尾指针指向新节点
return true;
}
//出队
bool Queue::dequeue(Item & item)
{
if (front == NULL)
return false;
item = front->item; //将队首元素赋值给item
items--;
Node * temp = front; //保存队首元素位置
front = front->next; //将front设置成指向下一个节点
delete temp; //删除以前的第一个节点
if (items == 0)
rear = NULL;
return true;
}
//设置处理时间为1~3的随机值
void Customer::set(long when)
{
processtime = std::rand() % 3 + 1;
arrive = when;
}
12.7.7 ATM模拟
现在已经拥有模拟ATM所需的工具。程序允许用户输入3个数:队列的最大长度、程序模拟的持续时间(单位为小时)以及平均每个小时的客户数。程序将使用循环——每次循环代表一分钟。在每分钟的循环中,程序将完成下面的工作:
1)判断是否来了新的客户。如果来了,并且此时队列未满,则将它添加到队列中,否则拒绝客户入队。
2)如果没有客户在进行交易,则选取队列的第一个客户。确定该客户的已等待时间,并将wait_time计数器设置为新客户所需的处理时间。
3)如果客户正在处理中,则将wait_time计数器减1。
4)记录各种数据,如获得服务的客户数目、被拒绝的客户数目、排队等候的累计时间以及累积的队列长度等。
当模拟循环结束时,程序将报告各种统计结果。
程序如何确定是否有新的客户到来:假设平均每小时有10名客户到达,则相当于每6分钟有一名客户。程序将计算这个值,并将它保存在min_per_cust变量中。然而,刚好每6分钟来一名客户不太现实,我们真正希望的是一个更随机的过程——但平均每6分钟来一个客户。
程序使用下面的函数来确定是否在循环期间有客户到来:
bool newcustomer(double x)
{
return (std::rand() * x / RAND_MAX < 1);
}
其工作原理如下:值RAND_MAX是在cstdlib文件中定义的,是rand()函数可能返回的最大值(0是最小值)。假设客户到达的平均间隔时间x为6,则rand()*x/RAND_MAX的值将位于0到6中间。具体地说,平均每隔6次,这个值会有1次小于1.然而,这个函数可能会导致客户到达的时间间隔有时为1分钟,有时为20分钟。这种方法虽然很笨拙,但是可使实际情况不同于有规则地每6分钟到来一个客户。如果客户到达的平均时间间隔少于1分钟,则上述方法将无效,但模拟并不是针对这种情况设计的。如果确实需要处理这种情况,最好提高时间分辨率,比如每次循环代表10秒钟。
main.cpp
长时间运行该模拟程序,可以知道长期的平均值;短时间运行该模拟程序,将只能知道短期的变化。
#include <iostream>
#include <cstdlib>
#include <ctime>
#include "queue.h"
const int MIN_PER_HR = 60;
bool newcustomer(double x);
int main()
{
using std::cin;
using std::cout;
using std::endl;
using std::ios_base;
std::srand(std::time(0)); //随机初始化
cout << "Case Study: Bank of Heather Automatic Teller\n";
cout << "Enter maximum size of queue: ";
int qs;
cin >> qs;
Queue line(qs); //队列中能装下的人数
cout << "Enter the number of simulation hours: ";
int hours; //模拟小时
cin >> hours;
//模拟将每分钟运行 1 个周期
long cyclelimit = MIN_PER_HR * hours;
cout << "Enter the average number of customer per hour: ";
double perhour; //每小时顾客到达平均个数
cin >> perhour;
double min_per_cust; //平均间隔时间
min_per_cust = MIN_PER_HR;
Item temp; //创建一个customer对象
long turnaways = 0; //队满,拒绝入队
long customers = 0; //加入队列
long served = 0; //
long sum_line = 0; //排队等候累积的队列长度
int wait_time = 0; //等待ATM空闲时间
long line_wait = 0; //排队等候累积的时间
//运行这个模拟
for (int cycle = 0; cycle < cyclelimit; cycle++)
{
if (newcustomer(min_per_cust))
{
if (line.isfull())
turnaways++;
else
{
customers++;
temp.set(cycle); //cycle = time of arrival
line.enqueue(temp); //加入新顾客
}
}
if (wait_time <= 0 && !line.isempty())
{
line.dequeue(temp); //下一个顾客
wait_time = temp.ptime(); //等待时间
line_wait += cycle - temp.when();
served++;
}
if (wait_time > 0)
wait_time--;
sum_line += line.queuecount();
}
//打印结果
if (customers > 0)
{
cout << "customers accepted: " << customers << endl;
cout << " customers served: " << served << endl;
cout << " turnaways: " << turnaways << endl;
cout << "average queue size: ";
cout.precision(2);
cout.setf(ios_base::fixed, ios_base::floatfield);
cout << (double)sum_line / cyclelimit << endl;
cout << " average wait time: "
<< (double)line_wait / served << " minutes\n";
}
else
cout << "No customers!\n";
cout << "Done!\n";
return 0;
}
//x = 客户到达的平均间隔时间
bool newcustomer(double x)
{
return (std::rand() * x / RAND_MAX < 1); //如果顾客到达的平均时间间隔少于1分钟,则返回真
}
12.8 总结
本章介绍了定义和使用类的许多重要方面。其中的一些方面是非常微妙甚至很难理解的概念。如果其中的某些概念对于您来说过于复杂,也不用害怕——这些问题对于大多数C++的初学者来说都是很难的。通常,对于诸如复制构造函数等概念,都是在由于忽略它们而遇到了麻烦后逐步理解的。本章介绍的一些内容乍看起来非常难以理解,但是随着经验越来越丰富,对其理解也将越透彻。
在类构造函数中,可以使用new为数据分配内存,然后将内存地址赋给类成员。这样,类便可以处理长度不同的字符串,而不用在类设计时提前固定数组的长度。在类构造函数中使用new,也可能在对象过期时引发问题。如果对象包含成员指针,同时它指向的内存是由new分配的,则释放用于保存对象的内存并不会自动释放对象成员指针指向的内存。因此在类构造函数中使用new类来分配内存时,应在类析构函数中使用delete来释放分配的内存。这样,当对象过期时,将自动释放其指针成员指向的内存。
如果对象包含指向new分配的内存的指针成员,则将一个对象初始化为另一个对象,或将一个对象赋给另一个对象时,也会出现问题。在默认情况下,C++逐个对成员进行初始化和赋值,这意味着被初始化或被赋值的对象的成员将与原始对象完全相同。如果原始对象的成员指向一个数据块,则副本成员将指向同一个数据块。当程序最终删除这两个对象时,类的析构函数将试图删除同一个内存数据块两次,这将出错。解决方法是:定义一个特殊的复制构造函数来重新定义初始化,并重载赋值运算符。在上述任何一种情况下,新的定义都将创建指向数据的副本,并使新对象指向这些副本。这样,旧对象和新对象都将引用独立的、相同的数据,而不会重叠。由于同样的原因,必须定义赋值运算符。对于每一种情况,最终目的都是执行深度复制,也就是说,复制实际的数据,而不仅仅是复制指向数据的指针。
对象的存储持续性为自动或外部时,在它不再存在时将自动调用其析构函数。如果使用new运算符为对象分配内存,并将其地址赋给一个指针,则当您将delete用于该指针时将自动为对象调用析构函数。然而,如果使用定位new运算符(而不是常规new运算符)为类对象分配内存,则必须负责显式地为该对象调用析构函数,方法是使用指向该对象的指针调用析构函数方法。C++允许在类中包含结构、类和枚举定义。这些嵌套类型的作用域为整个类,这意味着它们被局限于类中,不会与其他地方定义的同名结构、类和枚举发生冲突。
C++为类构造函数提供了一种可用来初始化数据成员的特殊语法。这种语法包括冒号和由逗号分隔的初始化列表,被放在构造函数参数的右括号后,函数体的左括号之前。每一个初始化器都由被初始化的成员的名称和包含初始值的括号组成。从概念上来说,这些初始化操作是在对象创建时进行的,此时函数体中的语句还没有执行。语法如下:
queue(int qs) : qsize(qs),item(0),front(NULL),rear(NULL){}
如果数据成员是非静态const成员或引用,则必须采用这种格式,但可将C++11新增的类内初始化用于非静态const成员。