当前位置:嗨网首页>书籍在线阅读

03-复习示例和静态类成员

  
选择背景色: 黄橙 洋红 淡粉 水蓝 草绿 白色 选择字体: 宋体 黑体 微软雅黑 楷体 选择字体大小: 恢复默认

12.1.1 复习示例和静态类成员

我们已经有一段时间没有使用new和delete了,所以这里使用一个小程序来复习它们。这个程序使用了一个新的存储类型:静态类成员。首先设计一个StringBad类,然后设计一个功能稍强的String类(本书前面介绍过C++标准string类,第16章将更深入地讨论它;而本章的StringBad和String类将介绍这个类的底层结构,提供这种友好的接口涉及大量的编程技术)。

StringBad和String类对象将包含一个字符串指针和一个表示字符串长度的值。这里使用StringBad和String类,主要是为了深入了解new、delete和静态类成员的工作原理。因此,构造函数和析构函数调用时将显示一些消息,以便您能够按照提示来完成操作。另外,将省略一些有用的成员和友元函数,如重载的++和>>运算符以及转换函数,以简化类接口(但本章的复习题将要求您添加这些函数)。程序清单12.1列出了这个类的声明。

为什么将它命名为StringBad呢?这是为了表示提醒,StringBad是一个还没有开发好的示例。这是使用动态内存分配来开发类的第一步,它正确地完成了一些显而易见的工作,例如,它在构造函数和析构函数中正确地使用了new和delete。它其实不会执行有害的操作,但省略了一些有益的功能,这些功能是必需的,但却不是显而易见的。通过说明这个类存在的问题,有助于在稍后将它转换为一个功能更强的String类时,理解和牢记所做的一些并不明显的修改。

程序清单12.1 strngbad.h

// strngbad.h -- flawed string class definition
#include <iostream>
#ifndef STRNGBAD_H_
#define STRNGBAD_H_
class StringBad
{
private:
    char * str;                // pointer to string
    int len;                   // length of string
    static int num_strings;    // number of objects
public:
    StringBad(const char * s); // constructor
    StringBad();               // default constructor
    ~StringBad();              // destructor
// friend function
    friend std::ostream & operator<<(std::ostream & os,
                        const StringBad & st);
};
#endif

为何将这个类命名为StringBad呢?这旨在告诉您,这是一个不太完整的类。它是使用动态内存分配来开发类的第一个阶段,正确地完成了一些显而易见的工作,例如,在构造函数和析构函数中正确地使用了new和delete。这个类并没有什么错误,但忽略了一些不明显却必不可少的东西。通过了解这个类存在的问题,将有助于您理解并记住后面将其转换为功能更强大的String类时,所做的不明显的修改。

对这个声明,需要注意的有两点。首先,它使用char指针(而不是char数组)来表示姓名。这意味着类声明没有为字符串本身分配存储空间,而是在构造函数中使用new来为字符串分配空间。这避免了在类声明中预先定义字符串的长度。

其次,将num_strings成员声明为静态存储类。静态类成员有一个特点:无论创建了多少对象,程序都只创建一个静态类变量副本。也就是说,类的所有对象共享同一个静态成员,就像家中的电话可供全体家庭成员共享一样。假设创建了10个StringBad对象,将有10个str成员和10个len成员,但只有一个共享的num_strings成员(参见图12.1)。这对于所有类对象都具有相同值的类私有数据是非常方便的。例如,num_strings成员可以记录所创建的对象数目。

随便说一句,程序清单12.1使用num_strings成员,只是为了方便说明静态数据成员,并指出潜在的编程问题,字符串类通常并不需要这样的成员。

来看一看程序清单12.2中的类方法实现,它演示了如何使用指针和静态成员。

66.png

图12.1 静态数据成员

程序清单12.2 strngbad.cpp

// strngbad.cpp -- StringBad class methods
#include <cstring>               // string.h for some
#include "strngbad.h"
using std::cout;
// initializing static class member
int StringBad::num_strings = 0;
// class methods
// construct StringBad from C string
StringBad::StringBad(const char * s)
{
    len = std::strlen(s);          // set size
    str = new char[len + 1];       // allot storage
    std::strcpy(str, s);           // initialize pointer
    num_strings++;                 // set object count
    cout << num_strings << ": \"" << str
         << "\" object created\n"; // For Your Information
}
StringBad::StringBad()             // default constructor
{
    len = 4;
    str = new char[4];
    std::strcpy(str, "C++");       // default string
    num_strings++;
    cout << num_strings << ": \"" << str
         << "\" default object created\n";         // FYI
}
StringBad::~StringBad()                            // necessary destructor
{
    cout << "\"" << str << "\" object deleted, ";  // FYI
    --num_strings;                                 // required
    cout << num_strings << " left\n";              // FYI
    delete [] str;                                 // required
}
std::ostream & operator<<(std::ostream & os, const StringBad & st)
{
    os << st.str;
    return os;
}

首先,请注意程序清单12.2中的下面一条语句:

int StringBad::num_strings = 0;

这条语句将静态成员num_strings的值初始化为零。请注意,不能在类声明中初始化静态成员变量,这是因为声明描述了如何分配内存,但并不分配内存。您可以使用这种格式来创建对象,从而分配和初始化内存。对于静态类成员,可以在类声明之外使用单独的语句来进行初始化,这是因为静态类成员是单独存储的,而不是对象的组成部分。请注意,初始化语句指出了类型,并使用了作用域运算符,但没有使用关键字static。

初始化是在方法文件中,而不是在类声明文件中进行的,这是因为类声明位于头文件中,程序可能将头文件包括在其他几个文件中。如果在头文件中进行初始化,将出现多个初始化语句副本,从而引发错误。

对于不能在类声明中初始化静态数据成员的一种例外情况(见第10章)是,静态数据成员为const整数类型或枚举型。

注意: 静态数据成员在类声明中声明,在包含类方法的文件中初始化。初始化时使用作用域运算符来指出静态成员所属的类。但如果静态成员是const整数类型或枚举型,则可以在类声明中初始化。

接下来,注意到每个构造函数都包含表达式num_strings++,这确保程序每创建一个新对象,共享变量num_strings的值都将增加1,从而记录String对象的总数。另外,析构函数包含表达式--num_strings,因此String类也将跟踪对象被删除的情况,从而使num_string成员的值是最新的。

现在来看程序清单12.2中的第一个构造函数,它使用一个常规C字符串来初始化String对象:

StringBad::StringBad(const char * s)
{
    len = std::strlen(s);     // set size
    str = new char[len + 1];  // allot storage
    std::strcpy(str, s);      // initialize pointer
    num_strings++;            // set object count
    cout << num_strings << ": \"" << str
    << "\" object created\n"; // For Your Information
}

类成员str是一个指针,因此构造函数必须提供内存来存储字符串。初始化对象时,可以给构造函数传递一个字符串指针:

String boston("Boston");

构造函数必须分配足够的内存来存储字符串,然后将字符串复制到内存中。下面介绍其中的每一个步骤。

首先,使用strlen()函数计算字符串的长度,并对len成员进行初始化。接着,使用new分配足够的空间来保存字符串,然后将新内存的地址赋给str成员。(strlen()返回字符串长度,但不包括末尾的空字符,因此构造函数将len加1,使分配的内存能够存储包含空字符的字符串。)

接着,构造函数使用strcpy()将传递的字符串复制到新的内存中,并更新对象计数。最后,构造函数显示当前的对象数目和当前对象中存储的字符串,以助于掌握程序运行情况。稍后故意使Stringbad出错时,该特性将派上用场。

要理解这种方法,必须知道字符串并不保存在对象中。字符串单独保存在堆内存中,对象仅保存了指出到哪里去查找字符串的信息。

不能这样做:

str = s; // not the way to go

这只保存了地址,而没有创建字符串副本。

默认构造函数与此相似,但它提供了一个默认字符串:“C++”。

析构函数中包含了示例中对处理类来说最重要的东西:

StringBad::~StringBad()                             // necessary destructor
{
    cout << "\"" << str << "\" object deleted, ";   // FYI
    --num_strings;                                  // required
    cout << num_strings << " left\n";               // FYI
    delete [] str;                                  // required
}

该析构函数首先指出自己何时被调用。这部分包含了丰富的信息,但并不是必不可少的。然而,delete语句却是至关重要的。str成员指向new分配的内存。当StringBad对象过期时,str指针也将过期。但str指向的内存仍被分配,除非使用delete将其释放。删除对象可以释放对象本身占用的内存,但并不能自动释放属于对象成员的指针指向的内存。因此,必须使用析构函数。在析构函数中使用delete语句可确保对象过期时,由构造函数使用new分配的内存被释放。

警告: 在构造函数中使用new来分配内存时,必须在相应的析构函数中使用delete来释放内存。如果使用new[](包括中括号)来分配内存,则应使用delete[](包括中括号)来释放内存。

程序清单12.3是从处于开发阶段的Daily Vegetable程序中摘录出来的,演示了StringBad的构造函数和析构函数何时运行及如何运行。该程序将对象声明放在一个内部代码块中,因为析构函数将在定义对象的代码块执行完毕时调用。如果不这样做,析构函数将在main()函数执行完毕时调用,导致您无法在执行窗口关闭前看到析构函数显示的消息。请务必将程序清单12.2和程序清单12.3一起编译。

程序清单12.3 vegnews.cpp

// vegnews.cpp -- using new and delete with classes
// compile with strngbad.cpp
#include <iostream>
using std::cout;
#include "strngbad.h"
void callme1(StringBad &); // pass by reference
void callme2(StringBad); // pass by value
int main()
{
    using std::endl;
    {
        cout << "Starting an inner block.\n";
        StringBad headline1("Celery Stalks at Midnight");
        StringBad headline2("Lettuce Prey");
        StringBad sports("Spinach Leaves Bowl for Dollars");
        cout << "headline1: " << headline1 << endl;
        cout << "headline2: " << headline2 << endl;
        cout << "sports: " << sports << endl;
        callme1(headline1);
        cout << "headline1: " << headline1 << endl;
        callme2(headline2);
        cout << "headline2: " << headline2 << endl;
        cout << "Initialize one object to another:\n";
        StringBad sailor = sports;
        cout << "sailor: " << sailor << endl;
        cout << "Assign one object to another:\n";
        StringBad knot;
        knot = headline1;
        cout << "knot: " << knot << endl;
        cout << "Exiting the block.\n";
    }
    cout << "End of main()\n";
    return 0;
}
void callme1(StringBad & rsb)
{
    cout << "String passed by reference:\n";
    cout << " \"" << rsb << "\"\n";
}
void callme2(StringBad sb)
{
    cout << "String passed by value:\n";
    cout << " \"" << sb << "\"\n";
}

注意: StringBad的第一个版本有许多故意留下的缺陷,这些缺陷使得输出是不确定的。例如,有些编译器无法编译它。虽然输出的具体内容有所差别,但基本问题和解决方法(稍后将介绍)是相同的。

下面是使用Borland C++5.5命令行编译器进行编译时,该程序的输出:

Starting an inner block.
1: "Celery Stalks at Midnight" object created
2: "Lettuce Prey" object created
3: "Spinach Leaves Bowl for Dollars" object created
headline1: Celery Stalks at Midnight
headline2: Lettuce Prey
sports: Spinach Leaves Bowl for Dollars
String passed by reference:
    "Celery Stalks at Midnight"
headline1: Celery Stalks at Midnight
String passed by value:
    "Lettuce Prey"
"Lettuce Prey" object deleted, 2 left
headline2: Dû°
Initialize one object to another:
sailor: Spinach Leaves Bowl for Dollars
Assign one object to another:
3: "C++" default object created
knot: Celery Stalks at Midnight
Exiting the block.
"Celery Stalks at Midnight" object deleted, 2 left
"Spinach Leaves Bowl for Dollars" object deleted, 1 left
"Spinach Leaves Bowl for Doll8" object deleted, 0 left
"@g" object deleted, -1 left
"-|" object deleted, -2 left
End of main()

输出中出现的各种非标准字符随系统而异,这些字符表明,StringBad类名副其实(是一个糟糕的类)。另一种迹象是对象计数为负。在使用较新的编译器和操作系统的机器上运行时,该程序通常会在显示有关还有−1个对象的信息之前中断,而有些这样的机器将报告通用保护错误(GPF)。GPF表明程序试图访问禁止它访问的内存单元,这是另一种糟糕的信号。

程序说明

程序清单12.3中的程序开始时还是正常的,但逐渐变得异常,最终导致了灾难性结果。首先来看正常的部分。构造函数指出自己创建了3个StringBad对象,并为这些对象进行了编号,然后程序使用重载运算符>>列出了这些对象:

1: "Celery Stalks at Midnight" object created
2: "Lettuce Prey" object created
3: "Spinach Leaves Bowl for Dollars" object created
headline1: Celery Stalks at Midnight
headline2: Lettuce Prey
sports: Spinach Leaves Bowl for Dollars

然后,程序将headline1传递给callme1()函数,并在调用后重新显示headline1。代码如下:

callme1(headline1);
cout << "headline1: " << headline1 << endl;

下面是运行结果:

String passed by reference:
    "Celery Stalks at Midnight"
headline1: Celery Stalks at Midnight

这部分代码看起来也正常。

但随后程序执行了如下代码:

callme2(headline2);
cout << "headline2: " << headline2 << endl;

这里,callme2()按值(而不是按引用)传递headline2,结果表明这是一个严重的问题!

String passed by value:
    "Lettuce Prey"
"Lettuce Prey" object deleted, 2 left
headline2: Dû°

首先,将headline2作为函数参数来传递从而导致析构函数被调用。其次,虽然按值传递可以防止原始参数被修改,但实际上函数已使原始字符串无法识别,导致显示一些非标准字符(显示的文本取决于内存中包含的内容)。

请看输出结果,在为每一个创建的对象自动调用析构函数时,情况更糟糕:

"Celery Stalks at Midnight" object deleted, 2 left
"Spinach Leaves Bowl for Dollars" object deleted, 1 left
"Spinach Leaves Bowl for Doll8" object deleted, 0 left
"@g" object deleted, -1 left
"-|" object deleted, -2 left
End of main()

因为自动存储对象被删除的顺序与创建顺序相反,所以最先删除的3个对象是knots、sailor和sport。删除knots和sailor时是正常的,但在删除sport时,Dollars变成了Doll8。对于sport,程序只使用它来初始化sailor,但这种操作修改了sport。最后被删除的两个对象(headline2和headline1)已经无法识别。这些字符串在被删除之前,有些操作将它们搞乱了。另外,计数也很奇怪,如何会余下−2个对象呢?

实际上,计数异常是一条线索。因为每个对象被构造和析构一次,因此调用构造函数的次数应当与析构函数的调用次数相同。对象计数(num_strings)递减的次数比递增次数多2,这表明使用了不将num_string递增的构造函数创建了两个对象。类定义声明并定义了两个构造函数(这两个构造函数都使num_string递增),但结果表明程序使用了3个构造函数。例如,请看下面的代码:

StringBad sailor = sports;

这使用的是哪个构造函数呢?不是默认构造函数,也不是参数为const char *的构造函数。记住,这种形式的初始化等效于下面的语句:

StringBad sailor = StringBad(sports); //constructor using sports

因为sports的类型为StringBad,因此相应的构造函数原型应该如下:

StringBad(const StringBad &);

当您使用一个对象来初始化另一个对象时,编译器将自动生成上述构造函数(称为复制构造函数,因为它创建对象的一个副本)。自动生成的构造函数不知道需要更新静态变量num_string,因此会将计数方案搞乱。实际上,这个例子说明的所有问题都是由编译器自动生成的成员函数引起的,下面介绍这个主题。