类方法
类的构造函数应提供类成员的值。由于在这个例子中,队列最初是空的,因此队首和队尾指针都设置为NULL(0或nullptr),并将 items 设置为 0。另外,还应将队列的最大长度 qsize 设置为构造函数参数 qs 的值。下面的实现方法无法正常运行:
Queue::Queue(int qs){
front = rear = NULL;
items = 0;
qsize = qs; // not acceptable
}
原因在于 qsize 是常量,所以可以对它进行初始化,但不能给它赋值。**从概念上说,调用构造函数时,对象将在括号中的代码执行之前被创建。**因此,调用 Queue(int qs) 构造函数将导致程序首先给 4 个成员变量分配内存。然后,程序流程进入到括号中,使用常规的赋值方式将值存储到内存中。因此,对于 const 数据成员,必须在执行到构造函数体之前,即创建对象时进行初始化。C++ 提供了一种特殊的语法来完成上述工作,它叫做初始化成员列表(member initializer list)。成员初始化列表由逗号分隔的初始化列表组成(前面带冒号)。它位于参数列表的右括号之后、函数体左括号之前。如果数据成员的名称为 mdata,并需要将它初始化为 val,则初始化其为 mdata(val),使用这种表示法,可以这样编写 Queue 的构造函数:
Queue::Queue(int qs) : qsize(qs) { // initialize qsize to qs
front = rear = NULL;
items = 0;
}
通常,初值可以是常量或构造函数的参数列表中的参数。
这种方法并不限于初始化常量,可以将 Queue 构造函数写成如下所示:
Queue::Queue(int qs) : qsize(qs), front(NULL), rear(NULL), items(0){
}
只有构造函数可以使用这种初始化列表语法。如上所示,对于const类成员,必须使用这种语法。另外,对于被声明为引用的类成员,也必须使用这种语法:
class Agency{...};
class Agent{
private:
Agency & belong; // must be initializer list to initialize
...
};
Agent::Agent(Agence & a) : belong(a){...}
这是因为引用与 const 数据类似,只能在被创建时进行初始化。对于简单数据成员(例如 front 和 items),使用成员初始化列表和在函数体中使用赋值没有什么区别。对于本身就是类对象的成员来说,使用成员初始化列表的效率更高。
成员初始化列表的语法
如果 Classy 是一个类,而 mem1、mem2 和 mem3 都是这个类的数据成员,则类构造函数可以使用如下的语法来初始化数据成员:
Classy::Classy(int n, int m) : mem1(n), mem2(0), mem3(n*m+2){
// ...
}
上述代码将 mem1 初始化为 n,将mem2初始化为0,将mem3初始化为n*m+2。从概念上说,这些初始化工作是在对象创建时完成的,此时还未执行括号中的任何代码。请注意以下几点:
- 这种格式只能用于构造函数;
- 必须使用这种格式来初始化非静态const数据成员(至少在C++11之前是这样的);
- 必须用这种格式来初始化引用数据成员
数据成员被初始化的顺序与它们出现在类声明中的顺序相同,与初始化器中的排列顺序无关。
警告:不能将成员初始化列表语法用于构造函数之外的其他类方法。
成员初始化列表使用的括号方式也可用于常规初始化。也就是说,如果愿意,可以将下述代码:
int games = 162;
double talk = 2.71828;
替换为:
int games(162);
double talk(2.71828);
这使得初始化内置类型就像初始化类对象一样。
C++11 的类内初始化
C++ 11 允许您以更直观的方式进行初始化:
class Classy{
int mem1 = 10; // in-class initializaton
const int mem2 = 20; // in-class initialization
// ...
};
这与在构造函数中使用成员初始化列表等价:
Classy::Classy():mem1(10),mem2(20) { ... }
成员 mem1 和 mem2 将分别被初始化为 10 和 20,除非调用了使用成员初始化列表的构造函数,在这种情况下,实际列表将覆盖这些默认初始值:
Classy::Classy(int n) : mem1(n) { ... }
在这里,构造函数将使用 n 来初始化 mem1,但 mem2 仍被设置为 20.
isempty()、isfull() 和 queuecount() 的代码都非常简单。如果 items 为 0,则队列是空的;如果items等于 qsize,则队列是满的。要知道队列中的项目数,只需返回 items 的值。
将项目添加到队尾(入队)比较麻烦,下面是一种方法:
bool Queue::enqueue(const Item & item){
if (isfull()) return false;
Node * add = new Node;
add->item = item;
add -> next = NULL;
items++;
if (front == NULL)
front = add;
else
rear->next = add;
rear = add;
return true;
}
- 如果队列已满,则结束(在这里的实现中,队列的最大长度由用户通过构造函数指定)
- 创建一个新节点。如果new无法创建新节点,它将引发异常,这个主题将在第15章介绍,最终的结果是,除非提供了处理异常的代码,否则程序将终止。
- 在节点中放入正确的值。在这个例子中,代码将 Item 值复制到节点的数据部分,并将节点的 next 指针设置为 NULL( 0 或 C++11新增的nullptr)。这样就为将节点作为队列中的最后一个项目做好了准备。
- 将项目技术(items)加1.
- 将节点附加到队尾。这包括两个部分。首先,将节点与列表中的另一个节点连接起来。这是通过将当前队尾节点的next指针指向新的队尾节点来完成的。第二部分是将Queue的成员指针rear设置为指向新节点,使队列可以直接访问最后一个节点。如果队列为空,则还必须将 front 指针设置成指向新节点(如果只有一个节点,则它既是队首节点,也是队尾节点)。
删除队首项目(出队)也需要多个步骤才能完成。下面是一种方式:
bool Queue::dequeue(Item & item){
if ( front == NULL) return false;
item = front -> item;
items--;
Node * temp = front;
front = front->next;
delete temp;
if(items == 0)
rear = NULL
}
总之,需要经过下面几个阶段:
-
如果队列为空,则结束。
-
将队列的第一个项目提供给调用函数,这是通过将当前 front 节点中的数据部分复制到传递给方法的引用变量中来实现.
-
将项目计数(items)减1.
-
保存 front 节点的位置,供以后删除
-
让节点出队。这是通过将 Queue 成员指针 front 设置成指向下一个节点来完成的,该节点的位置由 front->next 提供
-
为节省内存,删除以前的第一个节点
-
如果链表为空,则将rear设置为NULL
注意,第4步是必不可少的,这是因为第5步将删除关于先前第一个节点位置的信息。
是否需要其他方法呢?类构造函数没有使用 new,所以乍一看,好像不用理会由于在构造函数中使用 new 给类带来的特殊要求。当然,这种印象是错误的,因为向队列中添加对象将调用 new 来创建新的节点。通过删除节点的方式,dequeue() 方法确实可以清除节点,但这并不能保证队列在到期时为空。因此,类需要一个显式析构函数——该函数删除剩余的所有节点。下面是一种实现,它从链表头开始,依次删除其中的每个节点:
Queue::~Queue(){
Node * temp;
while ( front != NULL) { // while queue is not yet empty
temp = front;
front = front->next;
delete temp;
}
}
使用 new 的类需要包含显式复制构造函数和执行深度复制的赋值运算符,这个例子也是如此吗?首先要回答的问题是,默认的成员复制是否合适?答案是否定的。复制 Queue 对象的成员将生成一个新的对象,该对象指向链表原来的头和尾。因此,将项目添加到复制的 Queue 对象中,将修改共享的链表。这样做将造成非常严重的后果。更糟的是,只有副本的尾指针得到了更新,从原始对象的角度看,这将损坏链表。显然,要克隆或复制队列,必须提供复制构造函数和执行深度复制的赋值构造函数。
当然,这提出了这样一个问题:为什么要赋值队列呢?也许是希望在模拟的不同阶段保存队列的瞬像,也可能是希望为两个不同的策略提供相同的输入。实际上,拥有拆分队列的操作时非常有用的,超市在开设额外的收款台时经常这样做。同样,也可能希望将两个队列结合成一个或者截短一个队列。
但假设这里的模拟不实现上述功能。难道不能忽略这些问题,而使用已有的方法吗?当然可以。然而,在将来的某个时候,可能需要再次使用队列且需要复制。另外,您可能会忘记没有为复制提供恰当的代码。在这种情况下,程序将能编译和运行,但结果却是混乱的,甚至会崩溃。因此,最好还是提供复制构造函数和赋值运算符,尽管目前并不需要它们。
幸运的是,有一种小小的技巧可以避免这些额外的工作,并确保程序不会崩溃。这就是将所需的方法定义为伪私有方法:
class Queue{
private:
Queue(const Queue & q) : qsize(0) { } // preemptive defiition
Queue & operator=(const Queue & q) { return *this; }
// ...
};
这样做有两个作用:第一,它避免了本来将自动生成的默认方法定义。第二,因为这些方法是私有的,所以不能被广泛使用。也就是说,如果 nip 和 tuck 是 Queue 对象,则编译器就不允许这样做:
Queue snick(nip); // not allowed
tuck = nip; // not allowed
所以,与其将来面对无法预料的运行故障,不如得到一个易于跟踪的编译错误,指出这些方法是不可访问的。另外,在定义其对象不允许被复制的类时,这种方法也很有用。
C++11 提供了另一种禁用方法的方式——使用关键字 delete,这将在第18章介绍。
还有没有其他影响需要注意呢?当然有。当对象被按值传递(或返回)时,复制构造函数将被调用。然而,如果遵循优先采用按引用传递对象的惯例,将不会有任何问题。另外,复制构造函数还被用于创建其他的临时对象,但 Queue 定义中并没有导致创建临时对象的操作,例如重载加法运算符。