本章内容包括:对类成员使用动态内存分配;隐式和显式复制构造函数;隐式和显式重载赋值运算符;在构造函数中使用new所必须完成的工作;使用静态类成员;将定位new运算符用于对象;使用指向对象的指针;实现队列抽象数据类型(ADT)。
12.1 动态内存和类
C++在分配内存时采取的策略是让程序在运行时决定内存分配,而不是在编译时再做决定。C++使用new和delete运算符来动态控制内存。遗憾的是,在类中使用这些运算符就会出现许多新的编程问题。在这种情况下,析构函数将是必不可少的。
- 让程序在运行时决定内存分配,而不是在编译时决定。
- C++使用new和delete运算符来动态控制内存,在类中使用这些运算符,析构函数将是必不可少的。
- 类对象定义的字符串并不保存在对象中。字符串单独保存在堆内存中,对象仅保存了指出到哪里去查找字符串的信息。
- 在构造函数中使用new来分配内存时,必须在相应的析构函数中使用delete来释放内存。如果使用new[](包括中括号)来分配内存,则应使用delete[](包括中括号)来释放内存。
12.1.1 复习示例和静态类成员
上述代码需要注意的有两点,私有成员中使用了char指针,而不是char数组来表示姓名。这意味着类声明没有为字符串本身分配存储空间,而是在构造函数中使用new来为字符串分配空间。这避免了在类声明中预先定义字符串的长度。
其次,将num_strings成员声明为静态存储类。静态存储类有一个特点:无论创建了多少对象,程序都只创建一个静态类变量副本。
下面解释一下程序清单12.2,
int StringBad::num_strings = 0;// 初始化静态变量,用于记录创建的类数量
不能在类声明中初始化静态成员变量,这是因为声明描述了如何分配内存,但并不分配内存。
对于静态类成员,可以在类声明之外使用单独的语句来进行初始化,这是因为静态类成员是单独存储的,而不是对象的组成部分。
注意,初始化部分指出了类型,并使用了作用域运算符,但是没有使用关键字static。
静态数据成员在类声明中声明,在包含类方法的文件中初始化。初始化时使用作用域运算符来指出静态成员所属的类。但如果静态成员是const整数类型或枚举型,则可以在类声明中初始化。
说明一下为什么不要在类声明中初始化,这是因为类声明位于头文件中,这个头文件可能在多个文件中引用,那么就会出现多个初始化语句副本,也就是重复了,就会引发错误。
StringBad::StringBad(const char * s)
{
// str = s; // 这只保存了地址,而没有创建字符串副本。
len = std::strlen(s); // 不包括末尾的空字符
str = new char[len+1]; // 使用new分配足够的空间来保存字符串,然后将新内存的地址赋给str成员。
std::strcpy(str, s);
num_strings++;
cout << num_strings << " : \"" << str << "\" object created.\n";
}
String boston(“Boston”);//可以这么用,字符串就是地址
看一下这个非默认构造函数,首先strlen()计算字符串的长度,但不包括末尾的空字符,所以需要将len+1。
strcpy()函数是将后者复制到前者。
这种方法,字符串并不保存在对象中,字符串单独保存在堆内存中。
最后析构函数,当对象过期时,str指针也将过期。但str指向的内存仍被分配,除非使用delete释放。删除对象可以释放对象本身占用的内存,但并不能自动释放属于对象成员的指针指向的内存。因此,必须要使用析构函数。在析构函数中使用delete语句可确保对象过期时,由构造函数使用new分配的内存被释放。
由于缺陷比较多,所以可能运行输出结果是不确定的。输出中出现的各种非标准字符随系统而异。
程序执行到这里应该还是没有问题的(也许只是看起来);
callme1(headline1);
当执行到这里就会出现明显的错误了,callme2()按值(而不是按引用)传递headline2。
callme2(headline2); // 复制构造函数被用来初始化 callme2()的形参
将headline2作为函数参数来传递从而导致析构函数被调用。其次,虽然按值传递可以防止原始参数被修改,但实际上函数已使原始字符串无法识别,导致显示一些非标准字符(显示的文本取决于内存中包含的内容)。
上图的倒数5行的内容。
自动存储对象被删除的顺序与创建顺序相反,所以最先删除的3个对象是knots,sailor和sport。
删除knots和sailor还是正常的,但在删除sport时,Dollars变成了Doll8。
然后对象计数为-2也很奇怪。构造函数和析构函数的次数应该是相同的,这里说明哪里使用了构造函数(不是自己写的两个构造函数)创建对象使num_string没有加1,但是析构函数却减1了。
看下面这行代码使用的是哪个构造函数呢?不是默认的也不是那个带参数的。
StringBad sailor = sports; // 复制构造函数,
//上面等效于下面的语句
StringBad sailor = StringBad(sports);
因为sports的类型是StringBad,因此相应的构造函数原型应该是:
StringBad(const StringBad & s); // 复制构造函数
当使用一个对象来初始化另一个对象时,编译器将自动生成上述构造函数(称为复制构造函数,因为它创建对象的一个副本)。就是因为这里,自动生成的构造函数不知道更新num_string,因此最后计数搞乱。
12.1.2 特殊成员函数
C++自动提供了下面这些成员函数:
- 默认构造函数,如果没有定义构造函数;
- 默认析构函数,如果没有定义;
- 复制构造函数,如果没有定义;
- 赋值运算符,如果没有定义; (将一个对象赋给另一个对象)
- 地址运算符,如果没有定义。隐式地址运算符返回调用对象的地址(即this指针的值)。
结果表明,StringBad类中的问题是由隐式复制构造函数和隐式赋值运算符引起的。
c++11提供另外两个特殊成员函数:移动构造函数和移动赋值运算符。
1.默认构造函数
如果没有提供任何构造函数,C++将创建默认构造函数。这个默认构造函数是一个不接受任何参数,也不执行任何操作的构造函数,(默认的默认构造函数)。
假如Klunk是一个类。
Klunk::Klunk() { };
如果定义了构造函数,C++将不会定义默认构造函数。如果希望在创建对象时不显式地对它进行初始化,则必须显式地定义默认构造函数。这种构造函数没有任何参数,但可以使用它来设定特定的值。
Klunk::Klunk()
{
klunk_ct = 0;
……
}
带参数的构造函数也可以是默认构造函数,只要所有参数都有默认值。
Klunk (int n = 0) { klunk_ct = n;}
但只能有一个默认构造函数。
2.复制构造函数
复制构造函数用于将一个对象复制到新创建的对象中。
例如:StringBad类的复制构造函数原型:StringBad(const StringBad &);
3.何时调用复制构造函数
新建一个对象并将其初始化为同类现有对象时,复制构造函数都将被调用。最常见的情况是将新对象显式地初始化为现有地对象。
// 以下四种情况都将调用复制构造函数,假设motto是一个StringBad对象
StringBad ditto(motto);
StringBad metoo = motto; // 可能直接创建,也可能生成一个临时对象
StringBad also = StringBad(motto); // 可能直接创建,也可能生成一个临时对象
StringBad * pStringBad = new StringBad(motto); // 初始化一个匿名对象,并将新对象的地址赋给pstring指针。
每当程序生成了对象副本时,编译器都将使用复制构造函数。(当函数按值传递对象或函数返回对象时,都将使用复制构造函数。)记住,按值传递意味着创建原始变量的一个副本。编译器生成临时对象时,也将使用复制构造函数。
何时生成临时对象随编译器而异,但无论哪种编译器,当按值传递和返回对象时,都将调用复制构造函数。
由于按值传递对象将调用复制构造函数,因此应该按引用传递对象。这样可以节省调用构造函数的时间以及存储新对象的空间。
4.默认的复制构造函数的功能
默认的复制构造函数逐个复制非静态成员(成员复制也称为浅复制),复制的是成员的值。
StringBad sailor = sports;
//实际等效于下述程序
StringBad sailor;
sailor.str = sports.str;
sailor.len = sports.lem;
//这里只是进行说明问题,实际我们知道私有成员是无法直接访问的。
如果成员本身就是类对象,则将使用这个类的复制构造函数来复制成员对象。静态函数(如num_strings)不受影响,因为它们属于整个类,而不是各个对象。
下图说明了隐式复制构造函数执行的操作。
12.1.3 回到StringBad:复制构造函数的哪里出了问题
现在继续回到程序清单12.3(在12.1.1小节里),我们知道这个程序有两个问题,第一个是根据输出结果表明的,析构函数的调用次数比构造函数的调用次数多2,原因可能是程序确实使用默认的复制构造函数另外创建了两个对象。
那么是哪两个地方呢?第一个是callme2()被调用时,复制构造函数被用来初始化它的形参;第二个是将对象sailor初始化为对象sports。
上面两个复制构造函数不更新计数,但是析构函数更新了计数,在任何对象过期时都将被调用,而不管对象是如何被创建的。
无法准确计数,解决办法是提供一个对计数进行更新的显式复制构造函数:
// StringBad类的显式复制函数
StringBad::StringBad(const StringBad & s)
{
num_strings++; // 静态数据成员
...// important stuff to go here
}
如果类中包含这样的静态数据成员,即其值将在新对象被创建时发生变化,则应该提供一个显式复制构造函数来处理计数问题。
第二个异常之处,输出结果字符串内容出现乱码。
原因在于隐式复制构造函数是按值传递的。程序清单12.3中,隐式复制构造函数的功能相当于:
sailor.str = aport.str;
这里复制的不是字符串而是一个指向字符串的指针。也就是说,当sailor初始化为sports后,得到的是两个指向同一个字符串的指针。紧接着打印字符串不会有问题,但是析构函数被调用时就会引发问题。析构函数StringBad释放str指针指向的内存,释放sailor的效果如下:
delete [] sailor.str;
同样释放sports 的效果如下:
delete [] sports.str;
这里注意了,sports.str指向的内存已经被sailor的析构函数释放,这将导致不确定的、可能有害的后果。例如程序清单12.3打印输出的结果,这通常是内存管理不善的表现。
最后,试图释放内存两次可能导致程序异常终止。
1. 定义一个显式复制构造函数以解决问题
如果类中包含了使用new初始化的指针成员,应当定义一个复制构造函数,以复制指向的数据,而不是指针,这被称为深度复制。(对应的浅复制,前面说明过)。
复制的另一种形式(成员复制或浅复制)只是复制指针值。浅复制仅浅浅地复制指针信息,而不会深入“挖掘”以复制指针引用的结构。
这么编写复制构造函数;
StringBad::StringBad( const StringBad & st)
{
num_strings++;
len = st.len;
str = new char [len + 1];
std::strcpy(str, st.str);
cout << num_strings << ":\"" << str
<< "\" object created\n";
必须定义复制构造函数的原因在于,一些类成员是使用new初始化的、指向数据的指针,而不是数据本身。
12.1.4 StringBad 的其它问题:赋值运算符
接受并返回一个指向类对象的引用;StringBad类的赋值运算符的原型如下:
StringBad & StringBad::operator=(const StringBad &);
1.赋值运算符的功能以及何时使用它
- 将已有的对象赋给另一个对象时,将使用重载的赋值运算符。
- 初始化对象时,并不一定会使用赋值运算符 (也可能使用复制构造函数创建一个临时对象,然后通过赋值将临时对象的值复制到新对象中。 StringBad metoo = knot;
- 与复制构造函数相似,赋值运算符的隐式实现也对成员进行逐个复制。
- 如果成员本身就是类对象,则程序将使用为这个类定义的赋值运算符来复制该成员,但静态数据成员不受影响。
2.赋值的问题出现在哪里
knot = headline1;
出现的问题与隐式复制构造函数相同:数据受损。headline1.str与knot.str指向相同的地址。当对knot调用析构函数时,就已经删除了字符串,所以headline1再调用析构函数时,就是试图删除已经删除的字符串。这个上一小节也说明过。
3.解决赋值的问题
提供赋值运算的定义(进行深度复制)即可。
- 由于目标对象可能引用了以前分配的数据,所以函数应使用delete[ ]来释放这些数据。
- 函数应当避免将对象赋给自身;否则,给对象重新赋值前,释放内存操作可能删除对象的内容。
- 函数返回一个指向调用对象的引用。
//可以进行连续赋值,假如s1,s2,s3都是StringBad对象
s1 = s2 = s3;
下面代码是为了说明如何为StringBad类编写赋值运算符:
// 赋值操作并不创建新的对象,因此不需要调整静态数据成员num_strings的值。
StringBad & StringBad::operator=(const StringBad & st)
{
if (this == &st) // object assigned to itself
return *this; // all done
delete [] str; // free old string
len = st.len;
str = new char [len + 1]; // get space for new string
std::strcpy(str, st.str); // copy the string
return *this; // return reference to invoking object
}
12.2 改进后的新String类
12.2.1 修订后的默认构造函数
这里说明了一下空指针;
- delete[]与使用new[]初始化的指针和空指针都兼容。
- 在C++98中,字面值0有两个含义:可以表示数字值零,也可以表示空指针。
- C++11引入新关键字nullptr,用于表示空指针。
char *str;
str = nullptr; // 空指针
str = 0; // 空指针
str = NULL; // 空指针
12.2.2 比较成员函数
要实现字符串比较函数,最简单的方法是使用标准的trcmp()函数:
- 如果依照字母顺序,第一个参数位于第二个参数之前,则该函数返回一个负值;
- 如果两个字符串相同,则返回0;
- 如果第一个参数位于第二个参数之后,则返回一个正值。
bool operator<(const String &st1, const String &st2)
{
return (std::strcmp(st1.str, st2.str) < 0);
}
bool operator>(const String &st1, const String &st2)
{
return st2 < st1;
}
bool operator==(const String &st1, const String &st2)
{
return (std::strcmp(st1.str, st2.str) == 0);
}
将比较函数作为友元,有助于将String对象与常规的C字符串进行比较。
12.2.3 使用中括号表示法访问字符
对于中括号运算符,一个操作数位于第一个中括号的前面,另一个操作数位于两个中括号之间。
//方法实现
char & String::operator[](int i)
{
return str[i];
}
// 调用
String opera("The Magic Flute");
cout << opera[4]; // cout << opera.operator[](4);
// 将返回类型声明为char &,便可以给特定元素赋值。
String means("might");
means[0] = 'r'; // means.operator[](0) = 'r';
means.str[0] = 'r';
// 提供另一个仅供const String对象使用的operator版本:
const char & String::operator[](int i) const
{
return str[i];
}
// 有了const版本可以读/写常规String对象,对于const String对象,则只能读取其数据:
String text("Once upon a time");
const String answer("futile");
cout << text[1]; // ok, uses non-const version of operator[]()
cout << answer[1]; // ok, uses const version of operator[]()
cin >> text[1]; // ok, uses non-const version of operator[]()
//cin >> answer[1]; // compile-time error
12.2.4 静态类成员函数
- 可以将成员函数声明为静态的(函数声明必须包含关键字static,但如果函数定义是独立的,则其中不能包含关键字static)
- 首先,不能通过对象调用静态成员函数;实际上,静态成员函数甚至不能使用this指针。
- 如果静态成员函数是在公有部分声明的,则可以使用类名和作用域解析运算符来调用它。如下面的代码。
- 由于静态成员函数不与特定的对象相关联,因此只能使用类中的静态数据成员。
// 在String类声明中添加如下原型
static int HowMany() { return num_strings; }
// 调用
int count = String::HowMany(); // invoking a static member function
12.2.5 进一步重载赋值运算符
重载赋值运算符,使之能够直接将常规字符串复制到String对象中。这样就不用创建和删除临时对象了。
//一种可能的实现方式
String & String::operator=(const char * s)
{
delete [] str;//为什么上来就释放内存,这是由于没有指向该字符串的指针,造成内存浪费
len = std::strlen(s);
str = new char[len + 1];
std::strcpy(str, s);
return *this;
}
C和C++: 自己学习的历程,分享给大家! - Gitee.com
程序说明:
// quick and dirty String input
// 重载>>运算符提供了一种将键盘输入行读入到String对象中的简单方法。
// 它假定输入的字符数不多于String::CINLIM的字符数,并丢弃多余的字符。
// 在if条件下,如果由于某种原因
// (如到达文件尾或get(char *, int)读取的是一个空行)导致输入失败,istream对象的值将置为 false。
// 程序首先提示用户输入,然后将用户输入的字符串存储到 String对象中,并显示它们,
// 最后指出哪个字符串最短、哪个字符串按 字母顺序排在最前面。
12.3 在构造函数中使用new时应注意的事项
- 如果在构造函数中使用new来初始化指针成员,则应在析构函数中使用delete。
- new和delete必须相互兼容。new对应于delete,new[ ]对应于delete[ ]。
- 如果有多个构造函数,则必须以相同的方式使用new,要么都带中括号,要么都不带。因为只有一个析构函数,所有的构造函数都必须与它兼容。然而,可以在一个构造函数中使用new初始化指针,而在另一个构造函数中将指针初始化为空(0或C++11中的nullptr),这是因为delete(无论是带中括号还是不带中括号)可以用于空指针。
- 应定义一个复制构造函数,通过深度复制将一个对象初始化为另一个对象。具体地说,复制构造函数应分配足够的空间来存储复制的数据,并复制数据,而不仅仅是数据的地址。另外,还应该更新所有受影响的静态类成员。
- 应当定义一个赋值运算符,通过深度复制将一个对象复制给另一个对象。具体地说,该方法应完成这些操作:检查自我赋值的情况,释放成员指针以前指向的内存,复制数据而不仅仅是数据的地址,并返回一个指向调用对象的引用。
12.3.1 应该和不应该
看下面的几个示例;
String::String()
{
str = “default string”;
len = std::strlen(str);
}
String::String(const char * s)
{
len = std::strlen(s);
str = new char;
std::strcpy(str,s);
}
String::String(const String & st)
{
len = st.len;
str = new char[len + 1];
std::strcpy(str.st.str);
}
第一个构造函数没有使用new来初始化str。对默认对象调用析构函数时,析构函数使用delete来释放str。对不是使用new初始化的指针使用delete时,结果将是不确定的,并可能是有害的。修改为下面任意一种都是可以的。
String::String()
{
len = 0;
str = new char[1]; // uses new with []
str[0] = '\0';
}
String::String()
{
len = 0;
str = 0; // or, with C++11, str = nullptr;
}
String::String()
{
static const char * s = "C++"; // initialized just once
len = std::strlen(s);
str = new char[len + 1]; // uses new with []
std::strcpy(str, s);
}
第二个构造函数虽然使用了new,但分配的内存量是不正确的。new返回的内存块只能保存一个字符。试图将过长的字符串复制到该内存单元中,将导致内存问题。还有就是这里使用的new不带中括号。
第三个构造函数则是正确的。
最后看下下面给出的析构函数也是错误的,要注意前面所说的new的注意事项第一条。
String::~String()
{
delete str;
}
该析构函数是未能正确的使用delete,构造函数创建的是一个字符数组,析构函数删除的确实是一个字符。
12.3.2 包含类成员的类的逐成员复制
假设类成员的类型为String类或标准string类:
- 如果您将一个 Magazine对象复制或赋值给另一个Magazine对象,逐成员复制将使用成员类型定义的复制构造函数和赋值运算符。
- 如果Magazine类因其他成员需要定义复制构造函数和赋值运算符,情况将更复杂;在这种情况下,这些函数必须显式地调用String和string的复制构造函数和赋值运算符。
class Magazine
{
private:
String title;
string publisher;
...
};
12.4 有关返回对象的说明
当成员函数或独立的函数返回对象时:可以返回指向对象的引用、指向对象的const引用或const对象。
- 如果方法或函数要返回局部对象,则应返回对象,而不是指向对象的引用。在这种情况下,将使用复制构造函数来生成返回的对象。
- 如果方法或函数要返回一个没有公有复制构造函数的类(如ostream类)的对象,它必须返回一个指向这种对象的引用。
- 最后,有些方法和函数(如重载的赋值运算符)可以返回对象,也可以返回指向对象的引用,在这种情况下,应首选引用,因为其效率更高。
12.4.1 返回指向const对象的引用
如果函数返回(通过调用对象的方法或将对象作为参数)传递给它的对象,可以通过返回引用来提高其效率。
- 首先,返回对象将调用复制构造函数,而返回引用不会。因此,第二个版本所做的工作更少,效率更高。
- 其次,引用指向的对象应该在调用函数执行时存在。
- 第三,版本二v1和v2都被声明为const引用,因此返回类型必须为const,这样才匹配。
// 例如,假设要编写函数Max(),它返回两个Vector对象中较大的一个,
// 其中Vector 是第11章开发的一个类。
Vector force1(50,60);
Vector force2(10,70);
Vector max;
max = Max(force1, force2);
// version 1
Vector Max(const Vector & v1, const Vector & v2)
{
if (v1.magval() > v2.magval())
return v1;
else
return v2;
}
// version 2
const Vector & Max(const Vector & v1, const Vector & v2)
{
if (v1.magval() > v2.magval())
return v1;
else
return v2;
}
12.4.2 返回指向非const对象的引用
两种常见的返回非const对象情形是,重载赋值运算符=以及重载与cout一起使用的<<运算符。前者这样做旨在提高效率,而后者必须这样做。
operator=()的返回值用于连续赋值;由于返回类型不是const,则可以对其进行修改;
Operator<<()的返回值用于串接输出:返回类型必须是ostream &,而不能仅仅是ostream。如果使用返回类型ostream,将要求调用ostream类的复制构造函数,而ostream没有公有的复制构造函数。幸运的是,返回一个指向cout的引用不会带来任何问题,因为cout已经在调用函数的作用域内。
12.4.3 返回对象
如果被返回的对象是被调用函数中的局部变量,则不应按引用方式返回它,因为在被调用函数执行完毕时,局部对象将调用其析构函数。 因此,当控制权回到调用函数时,引用指向的对象将不再存在。在这种情况下,应返回对象而不是引用。通常,被重载的算术运算符(例如+)属于这一类。
构造函数调用Vector(x + b.x,y + b.y)创建一个方法operator+()能够访问的对象;
而返回语句引发的对复制构造函数的隐式调用创建一个调用程序能够访问的对象。
Vector force1(50,60);
Vector force2(10,70);
Vector net;
net = force1 + force2;
// 返回的不是force1,也不是force2,force1和force2在这个过程中应该保持不变。
// 因此,返回值不能是指向在调用函数中已经存在的对象的引用。
// 在Vector::operator+( )中计算得到的两个矢量的和被存储在一个新的临时对象中,
// 该函数也不应返回指向该临时对象的引用,
// 而应该返回实际的Vector对象,而不是引用。
Vector Vector::operator+(const Vector & b) const
{
return Vector(x + b.x, y + b.y);
}
在这种情况下,存在调用复制构造函数来创建被返回的对象的开销,但是这是无法避免的。
12.4.4 返回const对象
上述返回对象的+运算符重载函数可以这样使用:
net = force1 + force2; // 1: three Vector objects
force1 + force2 = net; // 2: dyslectic programming
cout << (force1 + force2 = net).magval() << endl; // 3: demented programming
- 复制构造函数将创建一个临时对象来表示返回值。因此,表达式force1 + force2的结果为一个临时对象。在语句1中,该临时对象被赋给net;在语句2和3中,net被赋给该临时对象。
- 使用完临时对象后,将把它丢弃。例如,对于语句2,程序计算force1和force2之和,将结果复制到临时返回对象中,再用net的内容覆盖临时对象的内容,然后将该临时对象丢弃。原来的矢量全都保持不变。语句3显示临时对象的长度,然后将其删除。
- 如果Vector::operator+()的返回类型被声明为const Vector,则语句1仍然合法,但语句2和语句3将是非法的。
12.5 使用指向对象的指针
如果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;
// compile with string1.cpp
// 最初,shortest指针指向数组中的第一个对象。
// 每当程序找到比指向的字符串更短的对象时,就把shortest重新设置为指向该对象。
// 同样,first指针跟踪按字母顺序排在最前面的字符串。
// 这两个指针并不创建新的对象,而只是指向已有的对象。因此,这些指针并不要求使用new来分配内存。
12.5.1 再谈new和delete
String * favorite = new String(sayings[choice]);
这不是为要存储的字符串分配内存,而是为对象分配内存;也就是说,为保存字符串地址的str指针和len成员分配内存(程序并没有给num_string成员分配内存,这是因为num_string成员是静态成员,它独立于对象被保存)。创建对象将调用构造函数,后者分配用于保存字符串的内存,并将字符串的地址赋给str。然后,当程序不再需要该对象时,使用delete删除它。对象是单个的,因此,程序使用不带中括号的delete。与前面介绍的相同,这将只释放用于保存str指针和len成员的空间,并不释放str指向的内存,而该任务将由析构函数来完成。
在下述情况下析构函数将被调用(参见图12.4):
- 如果对象是动态变量,则当执行完定义该对象的程序块时,将调用该对象的析构函数。因此,在程序清单12.3中,执行完main()时,将调用headline[0]和headline[1]的析构函数;执行完callme1( )时,将调用grub的析构函数。
- 如果对象是静态变量(外部、静态、静态外部或来自名称空间),则在程序结束时将调用对象的析构函数。这就是程序清单12.3中sports对象所发生的情况。
- 如果对象是用new创建的,则仅当您显式使用delete删除对象时,其析构函数才会被调用。
12.5.2 指针和对象小结
12.5.3 再谈定位new运算符
定位new运算符让您能够在分配内存时能够指定内存位置。
以下程序使用了定位new运算符和常规new运算符给对象分配内存。code_c++/C++ Primer Plus(第6版)/Chapter 12/placenew1.cpp · Kite/C和C++ - 码云 - 开源中国 (gitee.com)
// 使用了定位new运算符和常规new运算符给对象分配内存.
// 该程序使用new运算符创建了一个512字节的内存缓冲区,
// 然后使用new运算符在堆中创建两个JustTesting对象,
// 并试图使用定位new运算符在内存缓冲区中创建两个JustTesting对象。
// 最后,它使用delete来释放使用new分配的内存。
输出结果中内存地址格式和值可能不同是因为系统的原因。
在使用定位new运算符时存在两个问题:
下面是第一个问题:
首先,在创建第二个对象时,定位new运算符使用一个新对象来覆盖用于第一个对象的内存单元。显然,如果类动态地为其成员分配内存,这将引发问题。
其次,将delete用于pc2和pc4时,将自动调用为pc2和pc4指向的对象调用析构函数;然而,将delete[]用于buffer时,不会为使用定位new运算符创建的对象调用析构函数。
解决方法:
程序员必须负责管用定位new运算符用从中使用的缓冲区内存单元。
要使用不同的内存单元,程序员需要提供两个位于缓冲区的不同地址,并确保这两个内存单元不重叠。
// 其中指针pc3相对于pc1的偏移量为JustTesting对象的大小。
pc1 = new (buffer) JustTesting;
pc3 = new (buffer + sizeof (JustTesting)) JustTesting("Better Idea", 6);
第二个问题是,如果使用定位new运算符来为对象分配内存,必须确保其析构函数被调用。
//可以
delete pc2;
//不可以
deleet pc1;
delete pc3;
原因是delete可与常规new运算符配合使用,但不能与定位new运算符配合使用。
指针pc1指向的地址与 buffer相同,但buffer是使用new []初始化的,因此必须使用delete [ ]而不 是delete来释放。即使buffer是使用new而不是new []初始化的,delete pc1 也将释放buffer,而不是pc1。这是因为new/delete系统知道已分配的512 字节块buffer,但对定位new运算符对该内存块做了何种处理一无所知。
delete [] buffer;释放使用常规new运算符分配的整个内存块,但它没有为定位new运算符在该内存块中创建的对象调用析构函数。
解决方法:显式地为使用定位new运算符创建的对象调用析构函数。显式地调用析构函数时,必须指定要销毁的对象。由于有指向对象的指针,因此可以使用这些指针:
pc3->~JustTesting(); // destroy object pointed to by pc3
pc1->~JustTesting(); // destroy object pointed to by pc1
对定位new运算符使用的内存单元进行管理,加入到合适的delete和显式析构函数调用。
对于使用定位new运算符创建的对象,应以与创建顺序相反的顺序进行删除。原因在于,晚创建的对象可能依赖于早创建的对象。另外,仅当所有对象都被销毁后,才能释放用于存储这些对象的缓冲区。
下面是改进后的程序;
code_c++/C++ Primer Plus(第6版)/Chapter 12/placenew2.cpp · Kite/C和C++ - 码云 - 开源中国 (gitee.com)
12.6 复习各种技术
12.6.1 重载 << 运算符
要重新定义 << 运算符,以便将它和cout一起用来显示对象的内容,请定义下面的友元运算符函数:
ostream & operator<<(ostream & os, const c_name & obj)
{
os << ... ; // display object contents
return os;
}
其中c_name是类名。如果该类提供了能够返回所需内容的公有方法,则可在运算符函数中使用这些方法,这样便不用将它们设置为友元函数了。
12.6.2 转换函数
- 要将单个值转换为类类型,需要创建原型如下所示的类构造函数:
c_name(type_name value);
- 其中c_name为类名,type_name是要转换的类型的名称。
- 要将类转换为其他类型,需要创建原型如下所示的类成员函数:
operator type_name();
虽然该函数没有声明返回类型,但应返回所需类型的值。 - 使用转换函数时要小心。可以在声明构造函数时使用关键字explicit,以防止它被用于隐式转换。
12.6.3 其构造函数使用new的类
- 对于指向的内存是由new分配的所有类成员,都应在类的析构函数中对其使用delete,该运算符将释放分配的内存。
- 如果析构函数通过对指针类成员使用delete来释放内存,则每个构造函数都应当使用new来初始化指针,或将它设置为空指针。
- 构造函数中要么使用new [],要么使用new,而不能混用。如果构造函数使用的是new[],则析构函数应使用delete [];如果构造函数使用的是new,则析构函数应使用delete。
- 应定义一个分配内存(而不是将指针指向已有内存)的复制构造函数。这样程序将能够将类对象初始化为另一个类对象。原型通常如下:
className(const className &)
- 应定义一个重载赋值运算符的类成员函数,其函数定义如下(其中c_pointer是c_name的类成员,类型为指向type_name的指针)。下面的示例假设使用new []来初始化变量c_pointer:
c_name & c_name::operator=(const c_name & cn)
{
if (this == & cn)
return *this; // done if self-assignment
delete [] c_pointer;
// set size number of type_name units to be copied
c_pointer = new type_name[size];
// then copy data pointed to by cn.c_pointer to
// location pointed to by c_pointer
...
return *this;
}
12.7 队列模拟
队列是一种抽象的数据类型(Abstract Data Type,ADT),可以存储有序的项目序列。
新项目被添加在队尾,并可以删除队首的项目。先进先出(FIFO)。
例子:Heather银行希望对顾客排队等待的时间进行估测。
通常,三分之一的顾客只需要一分钟便可获得服务,三分之一的顾客需要两分钟,另外三分之一的顾客需要三分钟。另外,顾客到达的时间是随机的,但每个小时使用自动柜员机的顾客数量相当稳定。工程的另外两项任务是:设计一个表示顾客的类;编写一个程序来模拟顾客和队列之间的交互。
12.7.1 队列类
定义一个Queue类的特征:
- 队列存储有序的项目序列;
- 队列所能容纳的项目数有一定的限制;
- 应当能够创建空队列;
- 应当能够检查队列是否为空;
- 应当能够检查队列是否是满的;
- 应当能够在队尾添加项目;
- 应当能够从队首删除项目;
- 应当能够确定队列中项目数。
设计类时,需要开发公有接口和私有实现。
1.Queue 类的接口
从队列的特征可知,Queue类的公有接口应该如下:
class Queue
{
enum {Q_SIZE = 10};
private:
// private representation to be developed later
public:
Queue(int qs = Q_SIZE); // create queue with a qs limit
~Queue();
bool isempty() const;
bool isfull() const;
int queuecount() const; // 返回队列中节点的个数
bool enqueue(const Item &item); // 入队
bool dequeue(Item &item); // 出队
};
构造函数创建一个空队列。默认情况下,队列最多可存储10个项目,但是可以用显式初始化参数覆盖该默认值;
Queue line1;
Queue line2(20);
2. Queue 类的实现
确定接口后,便可以实现它。首先,需要确定如何表示队列数据。一种方法是使用new动态分配一个数组,它包含所需的元素数。然而,对于队列操作而言,数组并不太合适。例如,删除数组的第一个元素后,需要将余下的所有元素向前移动一位;否则需要做一些更费力的工作,如将数组视为是循环的。然而,链表能够很好的满足队列的需求。
链表由结点序列构成。每一个结点中都包含要保存到链表中的信息以及一个指向下一个结点的指针。
struct Node
{
Item item; // 当前节点的数据信息
struct Node * next; // 指向下一个节点的位置
};
看下图;
单向链表,每个节点都只包含一个指向其他节点的指针。
知道第一个节点的地址后,就可以沿指针找到后面的每一个节点。
通常,链表最后一个节点中的指针被设置为NULL(或0),以指出后面没有节点了。值得注意的是,在c++11中,应使用新增的关键字nullpr。
为了方便,我们需要一个指向第一个结点的数据成员,一个指向尾部结点的数据成员,还有可存储的最大项目数以及当前的项目数。
则类声明的私有部分与下面类似;
typedef Customer Item; // Customer 是一个类
class Queue
{
private:
// class scope definitions
// Node is a nested structure definition local to this class
struct Node { Item item; struct Node * next;};
enum {Q_SIZE = 10};
// private class members
Node * front; // pointer to front of Queue头
Node * rear; // pointer to rear of Queue尾
int items; // current number of items in Queue当前
const int qsize; // maximum number of items in Queue最大
...
public:
//...
};
嵌套结构和类
在类声明中声明的结构、类或枚举被称为是被嵌套在类中,其作用域为整个类。这种声明不会创建数据对象,而只是指定了可以在类中使用的类型。如果声明是在类的私有部分进行的,则只能在这个类使用被声明的类型;如果声明是在公有部分进行的,则可以从类的外部通过作用域解析运算符使用被声明的类型。例如,如果Node是在Queue类的公有部分声明的,则可以在类的外面声明Queue::Node类型的变量。
3.类方法
const数据成员可以对它进行初始化,但不能给它赋值。从概念上讲,调用构造函数时,对象将在括号中的代码执行之前被创建。因此,对于const数据成员,必须在执行到构造函数体之前,即创建对象时进行初始化。c++提供了一种特殊的语法来完成上述工作,它叫做成员初始化列表。成员初始化列表由逗号分隔的初始化列表组成(前面带冒号)。它位于参数列表的右括号之后,函数体左括号之前。
通常,初值可以是常量或构造函数的参数列表中的参数。这种方法并不限于初始化常量。
// 队列最初是空的,因此队首和队尾指针都设置为NULL(0或nullptr),
// 并将items设置为0。另外,还应将队列的最大长度qsize设置为构造函数参数qs的值。
Queue::Queue(int qs) : qsize(qs) // initialize qsize to qs
{
front = rear = NULL;
items = 0;
}
// 初值可以是常量或构造函数的参数列表中的参数。
Queue::Queue(int qs) : qsize(qs), front(NULL), rear(NULL), items(0)
{}
只有构造函数可以使用这种初始化列表语法。对于const类成员,必须使用这种语法。对于被声明为引用的类成员,也必须使用这种语法。
class Agency {………………};
class Agent
{
private:
Agency & belong;
……
};
Agent::Agent(Agency & 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;
const int mem2 = 20;
···//
};
//上述与在构造函数中使用成员初始化列表等价;
Classy::Classy() : mem1(10),mem2(20) {···}
将项目添加到队尾(入队):
bool Queue::enqueue(const Item & item)
{
if (isfull())
return false;
Node * add = new Node; // create node
// on failure, new throws std::bad_alloc exception
add->item = item; // set node pointers
add->next = NULL; // or nullptr;
items++;
if (front == NULL) // if queue is empty,
front = add; // place item at front
else
rear->next = add; // else place at rear
rear = add; // have rear point to new node
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; // set item to first item in queue
items--;
Node * temp = front; // save location of first item
front = front->next; // reset front to next item
delete temp; // delete former first item
if (items == 0)
rear = NULL;
return true;
}
总之,方法要经过以下几个阶段;
1、如果队列为空,则结束。
2、将队列的第一个项目提供给调用函数,这是通过将当前front结点中的数据部分复制到传递给方法的引用变量中来实现的。
3、将项目计数(items)减1。
4、保存front结点的位置,供以后删除。
5、让结点出队。这是通过将Queue成员指针front设置成指向下一个结点来完成的,改结点的位置由front->next提供。
6、为节省内存,删除以前的第一个结点。
7、如果链表为空,则将rear设置为NULL(在这个例子中,将front指针设置成front->next后,它已经是NULL了)。同样,可使用0或者nullptr。
第4步是必不可少的,这是因为第5步将删除关于先前第一个结点位置的信息。
4. 是否需要其它类方法
向队列中添加对象将调用new来创建新的节点。通过删除节点的方式,dequeue( )方法确实可以清除节点,但这并不能保证队列在到期时为空(队列不为空的时候解散队列)。因此,类需要一个显式析构函数——该函数删除剩余的所有节点。
Queue::~Queue()
{
Node * temp;
while (front != NULL) // while queue is not yet empty
{
temp = front; // save address of front item
front = front->next;// reset pointer to next item
delete temp; // delete former front
}
}
使用new的类通常需要包含显式复制构造函数和执行深度复制的赋值运算符。
要克隆或复制队列,必须提供复制构造函数和执行深度复制的赋值构造函数。
如果没有提供复制构造函数,但是未来也许需要队列复制,在这种情况下运行的结果将是混乱的,甚至会崩溃。所以最好提供复制构造函数和赋值运算符,尽管现在不需要。
下面有一种小技巧可以避免,就是将所需的方法定义为伪私有方法;
class Queue
{
private:
Queue (const Queue & q): qsize(0) {}
Queue & operator= (const Queue & q) {return *this;}
···//
};
这样做有两个作用:第一,它避免了本来将自动生成的默认方法定义。第二,因为这些方法是私有的,所以不能被广泛使用。
//如果nip和tuck是Queue对象,下面是不允许的
Queue snick(nip);
tuck = nip;
c++11提供了另外一种禁用方法的方式----使用关键字delete。
12.7.2 Customer类
客户何时进入队列以及客户交易所需的时间。
当模拟生成新客户时,程序将创建一个新的客户对象,并在其中存储客户的到达时间以及一个随机生成的交易时间。当客户到达队首时,程序将记录此时的时间,并将其与进入队列的时间相减,得到客户的等候时间。
class Customer
{
private:
long arrive; // arrival time for customer
int processtime; // processing time for customer
public:
Customer() { arrive = processtime = 0; } // 默认构造函数创建一个空客户。
void set(long when);
long when() const { return arrive; }
int ptime() const { return processtime; }
};
// set()成员函数将到达时间设置为参数,并将处理时间设置为1~3中的一个随机值。
void Customer::set(long when)
{
processtime = std::rand() % 3 + 1;
arrive = when;
}
程序清单12.10和12.11都在下面;
code_c++/C++ Primer Plus(第6版)/Chapter 12/queue.h · Kite/C和C++ - 码云 - 开源中国 (gitee.com)
12.7.3 ATM模拟
程序允许用户输入3个数:队列的最大长度、程序模拟的持续时间(单位为小时)以及平均每小时的客户数。程序将使用循环——每次循环代表一分钟。在每分钟的循环中,程序将完成下面的工作:
- 判断是否来了新的客户。如果来了,并且此时队列未满,则将它添加到队列中,否则拒绝客户入队。
- 如果没有客户在进行交易,则选取队列的第一个客户。确定该客户的已等候时间,并将wait_time计数器设置为新客户所需的处理时间。
- 如果客户正在处理中,则将wait_time计数器减1。
- 记录各种数据,如获得服务的客户数目、被拒绝的客户数目、排队等候的累积时间以及累积的队列长度等。
12.8 总结
- 在类构造函数中,可以使用new为数据分配内存,然后将内存地址赋给类成员。这样,类便可以处理长度不同的字符串,而不用在类设计时提前固定数组的长度。在类构造函数中使用new,也可能在对象过期时引发问题。如果对象包含成员指针,同时它指向的内存是由new分配的,则释放用于保存对象的内存并不会自动释放对象成员指针指向的内存。因此在类构造函数中使用new类来分配内存时,应在类析构函数中使用delete来释放分配的内存。这样,当对象过期时,将自动释放其指针成员指向的内存。
- 如果对象包含指向new分配的内存的指针成员,则将一个对象初始化为另一个对象,或将一个对象赋给另一个对象时,也会出现问题。在默认情况下,C++逐个对成员进行初始化和赋值,这意味着被初始化或被赋值的对象的成员将与原始对象完全相同。如果原始对象的成员指向一个数据块,则副本成员将指向同一个数据块。当程序最终删除这两个对象时,类的析构函数将试图删除同一个内存数据块两次,这将出错。解决方法是:定义一个特殊的复制构造函数来重新定义初始化,并重载赋值运算符。在上述任何一种情况下,新的定义都将创建指向数据的副本,并使新对象指向这些副本。这样,旧对象和新对象都将引用独立的、相同的数据,而不会重叠。由于同样的原因,必须定义赋值运算符。对于每一种情况,最终目的都是执行深度复制,也就是说,复制实际的数据,而不仅仅是复制指向数据的指针。
- 对象的存储持续性为自动或外部时,在它不再存在时将自动调用其析构函数。如果使用new运算符为对象分配内存,并将其地址赋给一个指针,则当您将delete用于该指针时将自动为对象调用析构函数。然而,如果使用定位new运算符(而不是常规new运算符)为类对象分配内存,则必须负责显式地为该对象调用析构函数,方法是使用指向该对象的指针调用析构函数方法。C++允许在类中包含结构、类和枚举定义。这些嵌套类型的作用域为整个类,这意味着它们被局限于类中,不会与其他地方定义的同名结构、类和枚举发生冲突。
- C++为类构造函数提供了一种可用来初始化数据成员的特殊语法。这种语法包括冒号和由逗号分隔的初始化列表,被放在构造函数参数的右括号后,函数体的左括号之前。每一个初始化器都由被初始化的成员的名称和包含初始值的括号组成。从概念上来说,这些初始化操作是在对象创建时进行的,此时函数体中的语句还没有执行。语法如下:
queue(int qs) : qsize(qs), items(0), front(NULL), rear(NULL) { }
如果数据成员是非静态const成员或引用,则必须采用这种格式,但可将C++11新增的类内初始化用于非静态const成员。 - C++11允许类内初始化,即在类定义中进行初始化:
这与使用成员初始化列表等价。然而,使用成员初始化列表的构造函数将覆盖相应的类内初始化。
class Queue
{
private:
...
Node * front = NULL;
enum {Q_SIZE = 10};
Node * rear = NULL;
int items = 0;
const int qsize = Q_SIZE;
...
};