您希望下个月的早餐、午餐和晚餐吃些什么?在第三天的晚餐喝多少盎司的牛奶?在第15天的早餐中需要在谷类食品添加多少葡萄干?
如果您与大多数人一样,就会等到进餐时再做决定。C++在分配内存时
采取的部分策略与此相同,让程序在运行时决定内存分配,而不是在编译时决定。这样,可根据程序的需要,而不是根据一系列严格的存储类型规则来使用内存。
C++使用new和 delete运算符来动态控制内存。
遗憾的是,在类中使用这些运算符将导致许多新的编程问题。
在这种情况下,析构函数将是必不可少的,而不再是可有可无的。
有时候,还必须重载赋值运算符,以保证程序正常运行。
下面来看一看这些问题
我们已经有一段时间没有使用new和 delete了,所以这里使用一个小程序来复习它们。这个程序使用了一个新的存储类型:静态类成员。首先设计一个 StringBad类,然后设计一个功能稍强的 String类(本书前面介绍过C++标准 string类,第16章将更深入地讨论它:而本章的 StringBad和 String类将介绍这个类的底层结构,提供这种友好的接口涉及大量的编程技术)。
StringBad和 String类对象将包含一个字符串指针和一个表示字符串长度的值。这里使用 StringBad和String类,主要是为了深入了解new、 delete i和静态类成员的工作原理。因此,构造函数和析构函数调用时将显示一些消息,以便您能够按照提示来完成操作。
另外,将省略一些有用的成员和友元函数,如重载的++和>>运算符以及转换函数,以简化类接口。程序清单12.1列出了这个类的声明。
为什么将它命名为 StringBad呢?这是为了表示提醒, StringBad是一个还没有开发好的示例。这是使用动态内存分配来开发类的第一步,它正确地完成了一些显而易见的工作,
例如,它在构造函数和析构函数中正确地使用了new和 delete。
它其实不会执行有害的操作,但省略了一些有益的功能,这些功能是必需的,但却不是显而易见的。通过说明这个类存在的问题,有助于在稍后将它转换为一个功能更强的 String类时,理解和牢记所做的一些并不明显的修改
程序清单12.1 StringBad.h
/*
author:梦悦foundation
公众号:梦悦foundation
可以在公众号获得源码和详细的图文笔记
*/
// StringBad.h -- flawed string class definition
#include
#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
对这个声明,需要注意的有两点。
首先,它使用char指针(而不是char数组)来表示姓名。
这意味着类声明没有为字符串本身分配存储空间,而是在构造函数中使用new来为字符串分配空间。
这避免了在类声明中预先定义字符串的长度,其次,将 num strings成员声明为静态存储类。静态类成员有一个特点:无论创建了多少对象,程序都只创建一个静态类变量副本。也就是说,类的所有对象共享同一个静态成员,就像家中的电话可供全体家庭成员共享一样。
假设创建了10个 StringBad对象,将有10个str成员和10个len成员,但只有一个共享的 num _strings成员(参见图12.1)。
这对于所有类对象都具有相同值的类私有数据是非常方便的。
例如,num_strings成员可以记录所创建的对象数目
随便说一句,程序清单12.1使用 num_strings成员,只是为了方便说明静态数据成员,并指出潜在的编程问题,字符串类通常并不需要这样的成员。
来看一看程序清单12.2中的类方法实现,它演示了如何使用指针和静态成员。
程序清单 12.2 StringBad.cpp
在这里插入代码片
首先,请注意程序清单12.2中的下面一条语句:
int StringBad::num strings=0;
这条语句将静态成员 num strings的值初始化为零。
请注意,不能在类声明中初始化静态成员变量,这是因为声明描述了如何分配内存,但并不分配内存。
您可以使用这种格式来创建对象,从而分配和初始化内存。
对于静态类成员,可以在类声明之外使用单独的语句来进行初始化,这是因为静态类成员是单独存储的,而不是对象的组成部分。
请注意,初始化语句指出了类型,并使用了作用域运算符,但没有使用关键字 static初始化是在方法文件中,而不是在类声明文件中进行的,这是因为类声明位于头文件中,程序可能将头文件包括在其他几个文件中。如果在头文件中进行初始化,将出现多个初始化语句副本,从而引发错误。
对于不能在类声明中初始化静态数据成员的一种例外情况(见第10章)是,静态数据成员为static const 整型或枚举型
**注意:**静态数据成员在类声明中声明,在包含类方法的文件中初始化。初始化时使用作用域运算符来指出静态成员所属的类。但如果静态成员是static const 整型或枚举型 ,则可以在类声明中初始化。
下面的文章有详细的介绍!
P12-c++对象和类-05类作用域详细介绍,详细的例子演示!
接下来,注意到每个构造函数都包含表达式 num_strings++,这确保程序每创建一个新对象,共享变量num_strings的值都将增加1,从而记录 String对象的总数。
另外,析构函数包含表达式–num strings,因此String类也将跟踪对象被删除的情况,从而使 num_string成员的值是最新的。
现在来看程序清单12.2中的第一个构造函数,它使用一个常规C字符串来初始化 String对象:
// 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
}
类成员str是一个指针,因此构造函数必须提供内存来存储字符串。
初始化对象时,可以给构造函数传递一个字符串指针:
StringBad boston("Boston");
构造函数必须分配足够的内存来存储字符串,然后将字符串复制到内存中。
下面介绍其中的每一个步骤。
首先,使用 strlen函数计算字符串的长度,并对len成员进行初始化。接着,使用new分配足够的空间来保存字符串,然后将新内存的地址赋给str成员。( strlen返回字符串长度,但不包括末尾的空字符,因此构造函数将len加1,使分配的内存能够存储包含空字符的字符串。)
接着,构造函数使用 strc
py将传递的字符串复制到新的内存中,并更新对象计数。
最后,构造函数显示当前的对象数目和当前对象中存储的字符串,以助于掌握程序运行情况。稍后故意使 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 7程序中摘录出来的,演示了 StringBad的构造函数和析构函数何时运行及如何运行。
该程序将对象声明放在一个内部代码块中,因为析构函数将在定义对象的代码块执行完毕时调用。如果不这样做,析构函数将在 main()函数执行完毕时调用,导致您无法在执行窗口关闭前看到析构函数显示的消息。请务必将程序清单12.2和程序清单12.3一起编译。
程序清单12.3 vegnews.cpp
在这里插入代码片
运行的结果:
meng-yue@ubuntu:~/MengYue/c++/class_dynamic_memory/01$ ./vegnews
Starting an inner block.
1: "headline1" object created
2: "headline2" object created
3: "sports" object created
headline1: headline1
headline2: headline2
sports: sports
String passed by reference:
"headline1"
headline1: headline1
String passed by value:
"headline2"
"headline2" object deleted, 2 left
headline2:
Initialize one object to another:
sailor: sports
Assign one object to another:
3: "C++" default object created
knot: headline1
Exiting the block.
"headline1" object deleted, 2 left
"sports" object deleted, 1 left
"▒▒U▒%V" object deleted, 0 left
free(): double free detected in tcache 2
Aborted (core dumped)
meng-yue@ubuntu:~/MengYue/c++/class_dynamic_memory/01$
输出中出现的各种非标准字符随系统而异,这些字符表明, StringBad类名副其实(是一个糟糕的类)。
另一种迹象是对象计数为负。
在使用较新的编译器和操作系统的机器上运行时,该程序通常会在显示有关还有-1个对象的信息之前中断,而有些这样的机器将报告通用保护错误(GPF)。
GPF表明程序试图访问禁止它访问的内存单元,这是另一种糟糕的信号。
程序说明
程序清单12.3中的程序开始时还是正常的,但逐渐变得异常,最终导致了灾难性结果。首先来看正常的部分。构造函数指出自己创建了3个 StringBad对象,并为这些对象进行了编号,然后程序使用重载运算符>>列出了这些对象:
Starting an inner block.
1: "headline1" object created
2: "headline2" object created
3: "sports" object created
headline1: headline1
headline2: headline2
sports: sports
然后,程序将 headline1传递给 callme1()函数,并在调用后重新显示 headline1。
代码如下
callme1(headline1);
cout << "headline1: " << headline1 << endl;
下面是运行结果
String passed by reference:
"headline1"
headline1: headline1
这部分代码看起来也正常,但随后程序执行了如下代码
callme2(headline2);
cout << "headline2: " << headline2 << endl;
这里, callme2()按值(而不是按引用)传递 headline2,结果表明这是一个严重的问题!
String passed by value:
"headline2"
"headline2" object deleted, 2 left
headline2:
首先,将 headline2作为函数参数来传递从而导致析构函数被调用。
其次,虽然按值传递可以防止原始参数被修改,但实际上函数已使原始字符串无法识别,导致显示一些非标准字符(显示的文本取决于内存中包含的内容)
思考:是谁的析构函数被调用了! 应该是形参 sb在函数执行完被析构了!
void callme2(StringBad sb)
{
cout << "String passed by value:\n";
cout << " \"" << sb << "\"\n";
}
我们在析构函数里面加一点打印,把this的地址打印出来。
StringBad1.cpp
cout << "\"" << str << "\" object deleted, " << ", this:" << this; // FYI
并且使用一个新的 use_StringBad.cpp来演示,不影响前面的那个代码分析逻辑和结果!新的代码在callme2()当中也把sb的地址打印出来!
use_StringBad.cpp
/*
author:梦悦foundation
公众号:梦悦foundation
可以在公众号获得源码和详细的图文笔记
*/
#include
using std::cout;
#include "StringBad.h"
void callme1(StringBad &); // pass by reference
void callme2(StringBad); // pass by value
int main()
{
using std::endl;
{
StringBad headline1("headline1");
//复制构造函数调用的是哪一个类对象?是headline1 还是 sb的
callme2(headline1);//这个地方是值传递,生成了StringBad 的临时对象,会使用默认的复制构造函数,析构的时候
cout << "headline1: " << headline1 << endl;
}
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" << ", &sb:" << &sb;
cout << " \"" << sb << "\"\n";
}
编译运行结果:
可以看到sb的 地址是 "0x7fff54b64a60"
, 也确实是这个地址的对象被析构掉了。
meng-yue@ubuntu:~/MengYue/c++/class_dynamic_memory/01$ g++ -o use_StringBad StringBad1.cpp use_StringBad.cpp
meng-yue@ubuntu:~/MengYue/c++/class_dynamic_memory/01$ ./use_StringBad
1: "headline1" object created
String passed by value:
, &sb:0x7fff54b64a60 "headline1"
"headline1" object deleted, , this:0x7fff54b64a60, 0 left
headline1:
"" object deleted, , this:0x7fff54b64a50, -1 left
free(): double free detected in tcache 2
Aborted (core dumped)
meng-yue@ubuntu:~/MengYue/c++/class_dynamic_memory/01$
不过这个例子最后会 调用 sb的复制构造函数,进行值的传递, 因为这个改起来验证相对麻烦, 所以可以看Demo4.cpp 这个例子
请看输出结果,在为每一个创建的对象自动调用析构函数时,情况更糟糕:
Exiting the block.
"headline1" object deleted, 2 left
"sports" object deleted, 1 left
"" object deleted, 0 left
*** glibc detected *** ./vegnews: double free or corruption (fasttop): 0x0856f028 ***
因为自动存储对象被删除的顺序与创建顺序相反,所以最先删除的3个对象是 knots、 sailor和 sports。
类定义声明并定义了两个构造函数(这两个构造函数都使 num_strings递增),但结果表明程序使用了3个构造函数。例如,请看下面的代码
StringBad sailor = sports;
这使用的是哪个构造函数呢?不是默认构造函数,也不是参数为 const char*的构造函数。记住,这种形式的初始化等效于下面的语句:
StringBad sailor =StringBad(sports); //constructor using sports
因为 sports的类型为 StringBad,因此相应的构造函数原型应该如下:
StringBad(const StringBad &);
当您使用一个对象来初始化另一个对象时,编译器将自动生成上述构造函数(称为复制构造函数,因为它创建对象的一个副本)。
自动生成的构造函数不知道需要更新静态变量 num_strings,因此会将计数方案搞乱。
实际上,这个例子说明的所有问题都是由编译器自动生成的成员函数引起的,下面介绍这个主题。
StringBad类的问题是由特殊成员函数引起的。
这些成员函数是自动定义的,就 StringBad而言,这些函数的行为与类设计不符。具体地说,C++自动提供了下面这些成员函数:
更准确地说,编译器将生成上述最后三个函数的定义一如果程序使用对象的方式要求这样做。
例如,
如果您将一个对象赋给另一个对象,编译器将提供赋值运算符的定义。
结果表明, StringBad类中的问题是由隐式复制构造函数和隐式赋值运算符引起的。
隐式地址运算符返回调用对象的地址(即this指针的值)。
这与我们的初衷是一致的,在此不详细讨论该成员函数。默认析构函数不执行任何操作,因此这里也不讨论,但需要指出的是,这个类已经提供默认构造函数。
至于其他成员函数还需要进一步讨论。
C++ 11提供了另外两个特殊成员函数:移动构造函数( move constructor)和移动赋值运算符(move assignment operator),这将在第18章讨论。
如果没有提供任何构造函数,C++将创建默认构造函数。例如,假如定义了一个 Klunk类,但没有提供任何构造函数,则编译器将提供下述默认构造函数
Klunk::Klunk() //implicit default constructor
也就是说,编译器将提供一个不接受任何参数,也不执行任何操作的构造函数(默认的默认构造函数),这是因为创建对象时总是会调用构造函数
Klunk lunk: // invokes default constructor
默认构造函数使lunk类似于一个常规的自动变量,也就是说,它的值在初始化时是未知的.
如果定义了构造函数,C++将不会定义默认构造函数。如果希望在创建对象时不显式地对它进行初始化,则必须显式地定义默认构造函数。这种构造函数没有任何参数,但可以使用它来设置特定的值:
Klunk::Klunk()//explicit default constructor
{
klunk_ct=0;
}
带参数的构造函数也可以是默认构造函数,只要所有参数都有默认值。例如, Klunk类可以包含下述内联构造函数:
Klunk(int n=0)
{
klunk_ct =n;
}
但只能有一个默认构造函数。也就是说,不能这样做:
Klunk::Klunk()// constructor #1
{
klunk_ct=0;
}
Klunk(int n=0) // ambiguous constructor #2
{
klunk_ct =n;
}
这为何有二义性呢?请看下面两个声明:
Klunk kar(10); //clearly matches Klunt(int n)
Klunk bus //could match either constructor
第二个声明既与构造函数#1(没有参数)匹配,也与构造函数#2(使用默认参数0)匹配。
这将导致编译器发出一条错误消息。
复制构造函数用于将一个对象复制到新创建的对象中。也就是说,它用于初始化过程中(包括按值传递参数),而不是常规的赋值过程中。
类的复制构造函数原型通常如下:
Class_name(const Class_name &);
它接受一个指向类对象的常量引用作为参数。例如, StringBad类的复制构造函数的原型如下
StringBad(const StringBad &);
对于复制构造函数,需要知道两点:何时调用和有何功能。
新建一个对象并将其初始化为同类现有对象时,复制构造函数都将被调用。
这在很多情况下都可能发生,最常见的情况是将新对象显式地初始化为现有的对象。
例如,假设motto是一个 StringBad对象,则下面4种声明都将调用复制构造函数:
StringBad ditto(motto); // calls StringBad(const StringBad &)
StringBad metoo = motto: //calls StringBad(const StringBad &)
StringBad also =StringBad(motto);// calls StringBad(const StringBad &)
StringBad *pStringBad =new StringBad(motto);// calls StringBad(const StringBad &)
其中中间的2种声明可能会使用复制构造函数直接创建metoo和also,也可能使用复制构造函数生成一个临时对象,然后将临时对象的内容赋给 metoo和also,这取决于具体的实现。
最后一种声明使用 motto初始化一个匿名对象,并将新对象的地址赋给 pStringBad指针。
每当程序生成了对象副本时,编译器都将使用复制构造函数。具体地说,当函数按值传递对象(如程序清单12.3中的 callme2() )或函数返回对象时,都将使用复制构造函数。
记住,按值传递意味着创建原始变量的个副本。
编译器生成临时对象时,也将使用复制构造函数。
例如,将3个 Vector对象相加时,编译器可能生成临时的 Vector对象来保存中间结果。何时生成临时对象随编译器而异,但无论是哪种编译器,当按值传递和返回对象时,都将调用复制构造函数。
具体地说,程序清单12.3中的函数调用将调用下面的复制构造函数:
callme2(headline2);
程序使用复制构造函数初始化 sb——callme2()函数的 StringBad类型形参。
由于按值传递对象将调用复制构造函数,因此应该按引用传递对象。这样可以节省调用构造函数的时间以及存储新对象的空间。
默认的复制构造函数逐个复制非静态成员(成员复制也称为浅复制),复制的是成员的值。在程序清单12.3中,下述语句
StringBad sailor = sports;
与下面的代码等效(只是由于私有成员是无法访问的,因此这些代码不能通过编译):
StringBad sailor;
sailor.str = sports.str;
sailor.len = sports.len;
如果成员本身就是类对象,则将使用这个类的复制构造函数来复制成员对象。
静态函数(如 num_strings)不受影响,因为它们属于整个类,而不是各个对象。图12.2说明了隐式复制构造函数执行的操作。
现在介绍程序清单12.3的两个异常之处(假设输出为该程序清单后面列出的)。
首先,程序的输出表明,析构函数的调用次数比构造函数的调用次数多2,原因可能是程序确实使用默认的复制构造函数另外创建了两个对象。当 callme2()被调用时,复制构造函数被用来初始化 calme2()的形参,还被用来将对象 sailor初始化为对象 sports。
默认的复制构造函数不说明其行为,因此它不指出创建过程,也不增加计数器 num_strings的值。但析构函数更新了计数,并且在任何对象过期时都将被调用,而不管对象是如何被创建的。
这是一个问题,因为这意味着程序无法准确地记录对象计数。
解决办法是提供一个对计数进行更新的显式复制构造函数:
StringBad::StringBad(const StringBad &)
{
num strings++;
// important stuff to go here
}
提示:如果类中包含这样的静态数据成员,即其值将在新对象被创建时发生变化,则应该提供一个显式复制构造函数来处理计数问题。
第二个异常之处更微妙,也更危险,其症状之一是字符串内容出现乱码:
headline2: Dio
原因在于隐式复制构造函数是按值进行复制的。例如,对于程序清单12.3,隐式复制构造函数的功能相当于:
sailor.str = sports.str;
这里复制的并不是字符串,而是一个指向字符串的指针。也就是说,将 sailor初始化为 sports后,得到的是两个指向同一个字符串的指针。
当 operator<<()函数使用指针来显示字符串时,这并不会出现问题。但当析构函数被调用时,这将引发问题。
析构函数 StringBad释放str指针指向的内存,因此释放 sailor的效果如下:
delete [] sailor.str; //delete the string that ditto str points to
sailor. str指针指向"sports " , 因为它被赋值为 sports.str,而 sports.str指向的正是上述字符串。
所以 delete语句将释放字符串"sports"
占用的内存。
然后,释放 sports的效果如下
delete [] sports.str; // effect is undefined
sports.str指向的内存已经被 sailor的析构函数释放,这将导致不确定的、可能有害的后果。程序清单12.3中的程序生成受损的字符串,这通常是内存管理不善的表现。
另一个症状是,试图释放内存两次可能导致程序异常终止。例如, Microsoft Visual C++2010(调试模式)显示一个错误消息窗口,指出“ Debug Assertion Failed!”:而在 Linux中,g++4.4.1显示消息“ double free or corruption”并终止程序运行。其他系统可能提供不同的消息,甚至不提供任何消息,但程序中的错误是相同的
解决类设计中这种问题的方法是进行深度复制( deep copy)。也就是说,复制构造函数应当复制字符串并将副本的地址赋给str成员,而不仅仅是复制字符串地址。这样每个对象都有自己的字符串,而不是引用另一个对象的字符串。
调用析构函数时都将释放不同的字符串,而不会试图去释放已经被释放的字符串。可以这样编写 String的复制构造函数:
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"; // For Your Information
}
必须定义复制构造函数的原因在于,一些类成员是使用new初始化的、指向数据的指针,而不是数据本身。图12.3说明了深度复制。
警告:如果类中包含了使用new初始化的指针成员,应当定义一个复制构造函数,以复制指向的数据,而不是指针,这被称为深度复制。复制的另一种形式(成员复制或浅复制)只是复制指针值。浅复制仅浅浅地复制指针信息,而不会深入“挖掘”以复制指针引用的结构
并不是程序清单12.3的所有问题都可以归咎于默认的复制构造函数,还需要看一看默认的赋值运算符ANSI C允许结构赋值,而C++允许类对象赋值,这是通过自动为类重载赋值运算符实现的。这种运算符的原型如下
Class_name & Class_name::operator=(const Class_name &);
它接受一个类对象引用参数并且返回一个指向类对象的引用。
例如, StringBad类的赋值运算符的原型如下
StringBad & StrinBad::operator=(const StringBad &);
1.赋值运算符的功能以及何时使用它
将已有的对象赋给另一个对象时,将使用重载的赋值运算符:
StringBad headline1("headline1");
StringBad knot;
knot = headline1; //assignment operator= invoked
初始化对象时,并不一定会使用赋值运算符:
StringBad metoo =knot; //use copy constructor, possibly assignment, too
这里, metoo是一个新创建的对象,被初始化为knot的值,因此使用复制构造函数。然而,正如前面指出的,实现时也可能分两步来处理这条语句:使用复制构造函数创建一个临时对象,然后通过赋值将临时对象的值复制到新对象中。
这就是说,初始化总是会调用复制构造函数,而使用=运算符时也可能调用赋值运算符。
与复制构造函数相似,赋值运算符的隐式实现也对成员进行逐个复制。如果成员本身就是类对象,则程序将使用为这个类定义的赋值运算符来复制该成员,但静态数据成员不受影响。
2. 赋值的问题出在哪里
程序清单12.3将 headline1赋给knot
knot= headline1;
为knot调用析构函数时,将显示下面的消息:
"headline1" object deleted, 2 left
为 headline1调用析构函数时,显示如下消息(有些实现方式在此之前就异常终止了):
M-i"object deleted, -2 left
出现的问题与隐式复制构造函数相同:数据受损。这也是成员复制的问题,即导致 headline1.str和knot.str在指向相同的地址。
因此,当对knot调用析构函数时,将删除字符串headline1
;当对 headline1调用析构函数时,将试图删除前面已经刑除的字符串。
正如前面指出的,试图删除已经删除的数据导致的结果是不确定的,因此可能改变内存中的内容,导致程序异常终止。
对于由于默认赋值运算符不合适而导致的问题,解决办法是提供赋值运算符(进行深度复制)定义。其实现与复制构造函数相似,但也有一些差别。
通过返回一个对象,函数可以像常规赋值操作那样,连续进行赋值,即如果S0、S和S2都是 StringBad对象,则可以编写这样的代码:
S0=S1=S2;
使用函数表示法时,上述代码为:
S0.operator=(S1.operator=(S2));
因此,S1.operator=(S2)的返回值是函数S0.operator=()的参数。
因为返回值是一个指向 StringBad对象的引用,因此参数类型是正确的。
下面的代码说明了如何为 StringBad类编写赋值运算符
SrringBad & StringBad::operator=(const StringBad &)
{
if (this = &st) { //object assigned to itself
return this;
}
delete [] str; //释放旧的字符串
len = st.len;
str = new char[len+1];
std::strcpy(str, st.str);
reutrn *this;
}
代码首先检查自我复制,这是通过查看赋值运算符右边的地址(&st)是否与接收对象(this)的地址相同来完成的。如果相同,程序将返回*this
,然后结束。
第10章介绍过,赋值运算符是只能由类成员函数重载的运算符之一
如果地址不同,函数将释放str指向的内存,这是因为稍后将把一个新字符串的地址赋给str。如果不首先使用 delete运算符,则上述字符串将保留在内存中。由于程序中不再包含指向该字符串的指针,因此这些内存被浪费掉。
接下来的操作与复制构造函数相似,即为新字符串分配足够的内存空间,然后将赋值运算符右边的对象中的字符串复制到新的内存单元中
上述操作完成后,程序返回*this并结束。
赋值操作并不创建新的对象,因此不需要调整静态数据成员 num_strings的值。
将前面介绍的复制构造函数和赋值运算符添加到 StringBad类中后,所有的问题都解决了。例如,下面是在完成上述修改后,程序输出的最后几行:
有了更丰富的知识后,可以对 StringBad类进行修订,将它重命名为 String了。
首先,添加前面介绍过的复制构造函数和赋值运算符,使类能够正确管理类対象使用的内存。
接下来,可以在类中添加一些新功能。 String类应该包含标准字符串函数库 cstring的所有功能,才会比较有用,但这里只添加足以说明其工作原理的功能(注意, String类只是一个用作说明的示例,而C++标准 string类的内容丰富得多)。具体地说,将添加以下方法:
int length () const( { return len; }
friend bool operator<(const String &st, const String &st2);
friend bool operator>(const String &stl, const String &st2);
friend bool operator==(const String &st, const String &st2);
friend operator>>(istream is, String & st);
char & operator[](int i);
const char & operator [](int i) const;
static int HowMany();
第一个新方法返回被存储的字符串的长度。
接下来的3个友元函数能够对字符串进行比较。
operator>>()函数提供了简单的输入功能;
两个operator[]
()函数提供了以数组表示法访问字符串中各个字符的功能。静态类方法 HowMany()将补充静态类数据成员 num_strings。下面来看一看具体情况。
请注意新的默认构造函数,它与下面类似
String::String ()
{
len=0;
str =new char[1];
str[0] = '\0'; // default string
}
您可能会问,为什么代码为:
str=new char [1];
而不是:
str = new char:
上面两种方式分配的内存量相同,区别在于前者与类析构函数兼容,而后者不兼容。析构函数中包含
如下代码:
delete [] str;
delete[]与使用new[]初始化的指针和空指针都兼容。因此对于下述代码
str new char[1];
str[0]='\0';//default string
可修改为:
str=0; // sets str to the null pointer
对于以其他方式初始化的指针,使用 delete[]时,结果将是不确定的
char words [15] ="bad idea";
char * p1= words;
char * p2 = new char;
char *p3
delete [] p1; // undefined, so don't do it
delete [] p2; //undefined, so don't do it
delete [] p3; // undefined, so don't do it
C++11空指针
在C++98中,字面值0有两个含义:可以表示数字值零,也可以表示空指针,这使得读程序的人和编峄器难以区分。
有些程序员使用(void*)0来标识空指针(空指针本身的内部表示可能不是零),还有些程序员使用NULL,这是一个表示空指针的C语言宏。
C++11提供了更好的解决方案:引入新关键字nullptr,用于表示空指针。您仍可像以前一样使用0否则大量现有的代码将非法,但建议您使用 nullptr:
str=nullptr; //C++11 null pointer notation
在 String类中,执行比较操作的方法有3个。
如果按字母顺序(更准确地说,按照机器排序序列),第一个字符串在第二个字符串之前,则 operator<()函数返回true。
要实现字符串比较函数,最简单的方法是
使用标准的 strcmp函数,如果依照字母顺序,第一个参数位于第二个参数之前,则该函数返回一个负值:如果两个字符串相同,则返回0;如果第一个参数位于第二个参数之后,则返回一个正值。因此,可以这样使用 strcmp():
bool operator<(const String &st1, const String &st2)
{
if (std::strcmp(st1.str, st2.str) < 0)
return true;
else
return false;
}
因为内置的<运算符返回的是一个布尔值,所以可以将代码进一步简化为:
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字符串进行比较。
例如,假设 answer是 String对象,则下面的代码:
if ("love"==answer)
将被转换为:
if (operator==("love", answer))
然后,编译器将使用某个构造函数将代码转换为:
if (operator==(String("love"), answer))
这与原型是相匹配的。
对于标准C-风格字符串来说,可以使用中括号来访问其中的字符:
char city[40] ="Amsterdam";
cout << city[0] << endl; //display the letter A
在C+中,两个中括号组成一个运算符中括号运算符,可以使用方法 operato来重载该运算符。
通常,二元C运算符(带两个操作数)位于两个操作数之间,例如2+5。但对于中括号运算符,一个操作数位于第一个中括号的前面,另一个操作数位于两个中括号之间。因此,在表达式city[0]中,city
是第一个操作数,[]
是运算符,0
是第二个操作数。
假设 opera是一个 String对象:
String opera("The Magic Flute");
则对于表达式opera[4]
,C++将査找名称和特征标与此相同的方法:
String::operator[](int i);
如果找到匹配的原型,编译器将使用下面的函数调用来替代表达式 opera[4]:
opera.operator[](4)
opera对象调用该方法,数组下标4成为该函数的参数。
下面是该方法的简单实现:
char & String::operator[](int i)
{
reutnr str[i];
}
有了上述定义后,语句:
cout << opera[4];
将被转换为
cout << opera.operator[](4) ;
返回值是 opera.str[4]
(字符M)。由此,公有方法可以访问私有数据。
将返回类型声明为char &,便可以给特定元素赋值。例如,可以编写这样的代码:
String means(“might”);
means[0] = 'r';
第二条语句将被转换为一个重载运算符函数调用:
means.operator[] = 'r';
这里将r赋给方法的返回值,而函数返回的是指向 means.str[0]的引用,因此上述代码等同于下面的代码
means.str[0] = 'r';
代码的最后一行访问的是私有数据,但由于 operator[J()是类的一个方法,因此能够修改数组的内容最终的结果是“ might”被改为“ight"”
假设有下面的常量对象
const String answer("futile");
如果只有上述 operator定义,则下面的代码将出错:
cout << answer [1]; / compile-time error
原因是 answer是常量,而上述方法无法确保不修改数据(实际上,有时该方法的工作就是修改数据,因此无法确保不修改数据)。
但在重载时,C++将区分常量和非常量函数的特征标,因此可以提供另一个仅供 const String对象使用的 operator[]()
版本:
// for use with const String objects
const char String::operator[](int i) const
{
return str[i];
}
有了上述定义后,就可以 读/写 常规 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
可以将成员函数声明为静态的(函数声明必须包含关键字 static,但如果函数定义是独立的,则其中不能包含关键字 static), 这样做有两个重要的后果。
首先,不能通过对象调用静态成员函数;实际上,静态成员函数甚至不能使用this指针。
如果静态成员函数是在公有部分声明的,则可以使用类名和作用域解析运算符来调用它。
例如,可以给 String类添加一个名为 Howmany()的静态成员函数,方法是在类声明中添加如下原型/定义:
static int Howmany() { return num_strings };
调用它的方式如下:
int count = String::Howmany(); //invoking a static member function
其次,由于静态成员函数不与特定的对象相关联,因此只能使用静态数据成员。
例如,静态方法Howmany()可以访问静态成员 num_strings,但不能访问str和len。
介绍针对 String类的程序清单之前,先来考虑另一个问题。
假设要将常规字符串复制到 String对象中。
例如,假设使用 getline读取了一个字符串,并要将这个字符串放置到 String对象中,前面定义的类方法让您能够这样编写代码:
string name;
char temp[40];
cin.getline(temp, 40);
name = temp; //use constructor to convert type
但如果经常需要这样做,这将不是一种理想的解决方案。
为解释其原因,先来回顾一下最后一条语句是怎样工作的。
String& String::operator=( const String&)
函数将临时对象中的信息复制到name对象中。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.4列出了修订后的类声明。
除了前面提到过的修改之外,这里还定义了一个 CINLIM常量,用于实现 operator>>()
.
程序清单12.4 String.h
/*
author:梦悦foundation
公众号:梦悦foundation
可以在公众号获得源码和详细的图文笔记
*/
// String.h -- fixed and augmented string class definition
#ifndef STRING_H_
#define STRING_H_
#include
using std::ostream;
using std::istream;
class String
{
private:
char * str; // pointer to string
int len; // length of string
static int num_strings; // number of objects
static const int CINLIM = 80; // cin input limit
public:
// constructors and other methods
String(const char * s); // constructor
String(); // default constructor
String(const String &); // copy constructor
~String(); // destructor
int length () const { return len; }
// overloaded operator methods
String & operator=(const String &);
String & operator=(const char *);
char & operator[](int i);
const char & operator[](int i) const;
// overloaded operator friends
friend bool operator<(const String &st, const String &st2);
friend bool operator>(const String &st1, const String &st2);
friend bool operator==(const String &st, const String &st2);
friend ostream & operator<<(ostream & os, const String & st);
friend istream & operator>>(istream & is, String & st);
// static function
static int HowMany();
};
#endif
程序清单12.5给出了修订后的方法定义。
程序清单12.5 String.cpp
重载>>运算符提供了一种将键盘输入行读入到 String对象中的简单方法。
它假定输入的字符数不多于String::CINLIM
的字符数,并丢弃多余的字符。
在 if 条件下,如果由于某种原因(如到达文件尾或get (char *,int) 读取的是一个空行)导致输入失败, istream对象的值将置为 false
程序清单12.6通过一个小程序来使用这个类,该程序允许输入几个字符串。
程序首先提示用户输入,然后将用户输入的字符串存储到 String对象中,并显示它们,最后指出哪个字符串最短、哪个字符串按字母顺序排在最前面。
程序清单12.6 sayings1.cpp
注意: 较早的get(char*,int)版本在读取空行后,返回的值不为 false。然而,对于这些版本来说,如果读取了一个空行, 则字符串中第一个字符将是一个空字符。
这个示例使用了下述代码:
if (!cin I temp [0]==1\0) // empty line?
break
//i not incremented
如果实现遵循了最新的C++标准,则f语句中的第一个条件将检測到空行,第二个条件用于旧版本实
现中检测空行。
程序清单12.6中程序要求用户输入至多10条谚语。每条谚语都被读到一个临时字符数组,然后
被复制到 String对象中。如果用户输入空行, break语句将终止输入循环。显示用户的输入后,程序
使用成员函数 length和 operator<()来确定最短的字符串以及按字母顺序排列在最前面的字符串。程
序还使用下标运算符()提取每条谚语的第一个字符,并将其放在该谚语的最前面。下面是运行
情况
至此,您知道使用new初始化对象的指针成员时必须特别小心。
具体地说,应当这样做
NULL、0还是 nullptr: 以前,空指针可以用0或NULL(在很多头文件中,NUL是一个被定义为0的符号常量)来表示。C程序员通常使用NULL而不是0,以指出这是一个指针,就像使用0’而不是0来表示空字符,以指出这是一个字符一样。然而,C++传统上更喜欢用简单的0,而不是等价的NUL。但正如前面指出的,C++11提供了关键字 nullptr,这是一种更好的选择。
String::String(const String &st)
{
num_strings++; // handle static member update if necessary
len= st.len; // same length as copied string
str =new char [len+ 1]; / allot space
std::strcpy(str, st.str); // copy string to new location
}
具体地说,复制构造函数应分配足够的空间来存储复制的数据,并复制数据,而不仅仅是数据的地址。另外,还应该更新所有受影响的静态类成员。
String & String::operator=(const String & 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
eturn *this; // return reference to invoking object
}
具体地说,该方法应完成这些操作:检査自我赋值的情况,释放成员指针以前指向的内存,复制数据而不仅仅是数据的地址,并返回一个指向调用对象的引用。
下面的摘要包含了两个不正确的示例(指出什么是不应当做的)以及一个良好的构造函数示例:
第一个坏的示例
String::String ()
{
str = "default string"; //oops, no new []
len std::strlen(st);
}
第二个坏的示例
String::String(const char * s)
{
en =std::strlen(s);
str =new char; // oops, no []
std::strcpy(str, s); //oops, no room
}
良好的示例
String::String(const String & st) // construct String from C string
{
len = st.len; // set size
str = new char[len + 1]; // allot storage
std::strcpy(str, st.str); // initialize pointer
}
第一个构造函数没有使用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不带中括号,这与另一个构造函数的正确格式不一致。
第三个构造函数是正确的。
最后,下面的析构函数无法与前面的构造函数正常地协同工作:
String::String ()
{
delete str // oops, should be delete [] str;
}
该析构函数未能正确地使用 delete。由于构造函数创建的是一个字符数组,因此析构函数应该删除一个数组。
假设类成员的类型为 String类或标准 string类:
class Magazine
{
private:
String title;
string publisher;
};
String和 string都使用动态内存分配,这是否意味着需要为 Magazine类编写复制构造函数和赋值运算符?不,至少对这个类本身来说不需要。
默认的逐成员复制和赋值行为有一定的智能。如果您将一个 Magazine对象复制或赋值给另ー个 Magazine对象,逐成员复制将使用成员类型定义的复制构造函数和赋值运算符。
也就是说,复制成员title时,将使用 String的复制构造函数,而将成员title赋给另一个 Magazine
对象时,将使用 String的赋值运算符,依此类推。然而,如果 Magazine类因其他成员需要定义复制构造函数和赋值运算符,情况将更复杂;在这种情况下,这些函数必须显式地调用 String和 string的复制构造函数和赋值运算符,这将在第13章介绍。