在这一讲中,我们将学习对类使用new和delete以及如何处理由于使用动态内存而引起的一些微妙的问题,这些都将影响构造函数和析构函数的设计以及运算符的重载。
这就是我们在讲类的时候为啥先讲构造函数、再讲运算符的重载、再到现在的类和动态内存分配的理由。知识点是逐步搭上去的,不能一步登天,唯有脚踏实地才能有所收获。
从这篇文章起,我将不再详细地clone知识点了,而是大道至简深入浅出化整为零的表达!
我们接下来以一个流水账的形式讲完第一个知识点——对类使用动态内存分配以及由此引发的一系列问题及解决方法。
我们知道动态分配内存是在程序运行时(而不是编译时)确定诸如使用多少内存等问题,这样我们可以根据程序的需要(而不是一系列的存储类型规则)来使用内存。正是由于动态分配内存的这些优点,我们将把它应用到类成员中。但在类中使用new和delete运算符来动态控制内存将导致许多新的问题,我们必须了解这些问题是什么,怎么解决!
【知识补充】
静态类成员:类成员,静态的;无论创建多少对象,程序都只创建一个静态类成员副本(所有类对象共享同一个静态成员);在类声明文件中声明,在方法文件中初始化。
我们接下来设计一个StringBad类,这么做的原因是通过这个例子挖掘出问题出现的原因,同时也学学静态类成员的使用方法。
这个类的成员包含一个字符串指针str和一个表示字符串长度的值len。
为了深入了解问题出现的原因,我们将通过调用构造函数和析构函数时显示一些对挖掘问题有用的信息(话语组织的不好,往下读就行)。
我们先来看类声明文件:
// 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
在这个声明中,我们使用char指针(而不是char数组)来表示姓名,这意味着类声明没有为字符串本身分配存储空间,而是在构造函数中使用new来为字符串分配空间,这避免了在类声明中预先定义字符串的长度。除此,我们还将num_strings成员声明为静态存储类,于是我们即使创建10个StringBad对象,也只有1个由所有对象共享的num_strings成员,这对于我们记录创建的对象数目很有帮助。
接下来我们将在类方法实现文件中演示如何使用指针和静态成员:
// 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 "; // 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 "; // FYI } StringBad::~StringBad() // necessary destructor { cout << """ << str << "" object deleted, "; // FYI --num_strings; // required cout << num_strings << " left "; // FYI delete [] str; // required } std::ostream & operator<<(std::ostream & os, const StringBad & st) { os << st.str; return os; }
我们是在方法实现文件中初始化静态成员变量,这是因为:(1)声明描述了如何分配内存,但并不分配内存;(2)静态成员是单独存储的,而不是对象的组成部分。此外,我们要注意的是,初始化语句指出了类型,并使用了作用域运算符,但没有使用关键字static。
笔记:初始化是在方法文件中,而不是类声明文件中进行的,这是因为类声明位于头文件中,程序可能将头文件包括在其他几个文件中。如果在头文件进行初始化,将出现多个初始化语句副本,从而引发错误。对于不能在类声明中初始化的静态数据成员的一种例外情况是,静态数据成员为const整型或枚举型。
回到方法文件中,我们注意到每个构造函数都包含表达式num_strings++,这确保程序每创建一个新对象,共享变量num_strings的值都将增加1,从而记录String对象的总数。另外,析构函数包含表达式--num_strings,因此String类也将跟踪对象被删除的情况,从而使num_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 "; // For Your Information }
类成员str是一个指针,因此构造函数必须提供内存来存储字符串。初始化对象时,可以给构造函数传递一个字符串指针:String boston("Boston");
构造函数必须分配足够的内存来存储字符串,然后将字符串复制到内存中。
下面我将介绍这个过程:
- 使用strlen()函数计算字符串的长度,并对len成员进行初始化。
- 使用new分配足够的空间来保存字符串,然后将新内存的地址赋给str成员。
- 构造函数使用strcpy()将传递的字符串复制到新的内存中,并更新对象计数。
- 构造函数显示当前的对象数目和当前对象中存储的字符串。
笔记:要彻底理解,必须知道字符串并不保存在对象中,而是单独保存在堆内存中,对象仅保存了指出到哪里去查找字符串的信息。
因此,这样的做法是错的:str = s;,这只保存了地址,而没有创建字符串副本。
然后我们再看析构函数:
StringBad::~StringBad() // necessary destructor { cout << """ << str << "" object deleted, "; // FYI --num_strings; // required cout << num_strings << " left "; // FYI delete [] str; // required }
我们之前学析构函数时讲的是对象过期后将自动被调用,可在其内部编一些输出语句。而我们这里的析构函数包含了动态分配内存至关重要的语句——delete语句,我们为何要在析构函数内使用delete运算符呢?
我们知道创建对象时总会调用构造函数(本例中构造函数将分配内存),创建的StringBad对象的str成员将指向new分配的内存,当StringBad对象过期时,str指针也将过期,但是str指向的内存仍被分配,这块内存只有delete能释放。因此在析构函数中使用delete语句可确保对象过期时,由构造函数使用new分配的内存被释放。
笔记:删除对象可以释放对象本身的内存,但并不能自动释放属于对象成员的指针指向的内存。
最后我们来看主程序:
// 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. "; 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: "; StringBad sailor = sports; cout << "sailor: " << sailor << endl; cout << "Assign one object to another: "; StringBad knot; knot = headline1; cout << "knot: " << knot << endl; cout << "Exiting the block. "; } cout << "End of main() "; // std::cin.get(); return 0; } void callme1(StringBad & rsb) { cout << "String passed by reference: "; cout << " "" << rsb << "" "; } void callme2(StringBad sb) { cout << "String passed by value: "; cout << " "" << sb << "" "; }
该程序演示了StringBad的构造函数和析构函数何时运行及如何运行。该程序将对象声明放在一个内部代码块中,因为析构函数将在定义对象的代码块执行完毕时调用。如果不这样做,析构函数将在main()函数执行完毕时调用,导致我们无法在执行窗口关闭前看到析构函数显示的消息。
在程序输出窗口发生了几个错误:
- 对象计数num_strings的值最后为负数(正确结果应该是0,因为构造函数和析构函数的个数应相同);
- 字符串内容出现乱码。
出现问题我们就来分析原因,并找出解决方法。
首先是计数异常,每个对象被构造和析构一次,因此调用构造函数的次数应当与析构函数的调用次数相同。现在结果表明程序使用了另外一种构造函数(不是类定义声明并定义的那两个构造函数)创建了两个对象。
我们来看下面的代码:
StringBad sailor = sports;
这条语句表示使用sports对象来初始化sailor对象,这将不使用我们声明并定义的那两个构造函数。
这种形式的初始化等效于下面的语句:
StringBad sailor = StringBad(sports); //constructor using sports
因为sports的类型为StringBad,因此相应的构造函数原型应该如下:
StringBad(const StringBad &);
上述构造函数成为复制构造函数,它创建对象的一个副本。
正是由于使用复制构造函数创建了对象,而它不知道需要更新静态变量num_strings,因此计数会发生异常。
我们接下来就好好认识一下复制构造函数,然后找出解决问题的方法。
复制构造函数:
- 用于将一个对象复制到新创建的对象中;(也就是说,它用于初始化过程中(包括按值传递参数),而不是常规的赋值过程中)
- 它接受一个指向类对象的常量引用作为参数;
Class_name(const Class_name &);
- 需要知道两点:何时调用和有何功能。
何时调用复制构造函数:
新建一个对象并将其初始化为同类现有对象时。这在很多情况下都可能发生,最常见的情况是将新对象显示地初始化为现有的对象。
例如,假设motto是一个StringBad对象,则下面4种声明都将调用复制构造函数:
StringBad ditto(motto); //调用StringBad(const StringBad &) StringBad metoo = motto; //调用StringBad(const StringBad &) StringBad also = StringBad(motto); //调用StringBad(const StringBad &) StringBad * pStringBad = new StringBad(motto); //调用StringBad(const StringBad &)
其中中间的两种声明可能会使用复制构造函数直接创建metoo和also,也可能使用复制构造函数生成的一个临时对象,然后将临时对象的内容赋给metoo和also,这取决于具体的实现。最后一种声明使用motto初始化一个匿名对象,并将新对象的地址赋给pstring指针。
每当程序生成了对象副本时,编译器都将使用复制构造函数。具体地说,当函数按值传递对象或函数返回对象时,都将使用复制构造函数。记住,按值传递意味着创建原始变量的一个副本。编译器生成临时对象时,也将使用复制构造函数。例如,将3个Vector对象相加时,编译器可能生成临时的Vector对象来保存中间结果。何时生成临时对象随编译器而异,但无论是哪种编译器,当按值传递和返回对象时,都将调用复制构造函数。
由于按值传递对象将调用复制构造函数,因此应该按引用传递对象。这样可以节省调用构造函数的时间以及存储新对象的空间。
默认的复制构造函数的功能:
默认的复制构造函数会逐个复制非静态成员(成员复制也称为浅复制),复制的是成员的值。
下述语句:
StringBad sailor = sports;
与下面的代码等效(只是由于私有成员是无法访问的,因此这些代码不能通过编译):
StringBad sailor; sailor.str = sports.str; sailor.len = sports.len;
如果成员本身就是类对象,则将使用这个类的复制构造函数来复制成员对象。静态函数不受影响,因为它们属于整个类,而不是各个对象。
认识完复制构造函数,我们再回到StringBad看看怎么解决问题!!
程序的输出表明,析构函数的调用次数比构造函数的调用次数多2,原因可能是程序确实使用默认的复制构造函数另外创建了两个对象。当callme2()被调用时,复制构造函数被用来初始化callme2()的形参,还被用来将对象sailor初始化为对象sports。默认的复制构造函数不说明其行为,因此它不指出创建过程,也不增加计数器num_strings的值。但析构函数更新了计数,并且在任何对象过期时都将被调用,而不管对象是如何被创建的。这是一个问题,因为这意味着程序无法准确地记录对象计数。解决方法是提供一个对计数进行更新的显式复制构造函数:
StringBad::StringBad(const String & s) { num_strings++; ... //重要的stuff to go here }
笔记:如果类中包含这样的静态数据成员,即其值将在新对象被创建时发生变化,则应该提供一个显式复制构造函数来处理计数问题。
我们还记得程序输出中第二个错误是字符串内容出现乱码。
我们上面指出了,隐式复制构造函数的功能相当于:
sailor.str = sport.str;
这里复制的不是字符串,而是一个指向字符串的指针。也就是说,将sailor初始化为sports后,得到的是两个指向同一个字符串的指针。当operator<<()函数使用指针来显示字符串时,这并不会出现问题。但当析构函数被调用时,这将引发问题。析构函数StringBad释放str指针指向的内存,因此释放sailor的效果如下:
delete [] sailor.str; //删除ditto.str指向的字符串
sailor.str指针指向“Spinish Leaves Bowl for Dollars”,因为它被赋值为sports.str,而sports.str指向的正是上述字符串。所以delete语句将释放字符串“Spinish Leaves Bowl for Dollars”占用的内存。
然后,释放sports的效果如下:
delete [] sports.str; //effect is undefined
sports.str指向的内存已经被sailor的析构函数释放,这将导致不确定的、可能有害的后果。这个正是程序输出中字符串内容出现乱码的原因。
笔记:试图释放内存两次可能导致程序异常终止。
下面我们谈一谈解决方法:
解决类设计中这种问题的方法是进行深度复制。也就是说,复制构造函数应当复制字符串并将副本的地址赋给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 "; }
必须定义复制构造函数的原因在于,一些类成员是使用new初始化的、指向数据的指针,而不是数据本身。
笔记:如果类中包含了使用new初始化的指针成员,应当定义一个复制构造函数,以复制指向的数据,而不是指针,这被称为深度复制。复制的另一种形式(成员复制或浅复制)只是复制指针值。浅复制仅浅浅地复制指针信息,而不会深入“挖掘”以复制指针引用的结构。
当然,StringBad还有其他问题——赋值运算符。这个问题我就不展开了,可以自己看书P436页。
当我们解决了上面的问题后,便可以对StringBad类进行修订,将它重命名为String了。
首先,我们需添加上面介绍的复制构造函数和赋值运算符,使类能够正确管理类对象使用的内存。
其次,通过上面详细的分析,我们已经知道了对象何时被创建和释放,因此可以让类构造函数和析构函数保持沉默,不再在每次被调用时都显示消息。
另外,也不用再监视构造函数的工作情况,因此可以简化默认构造函数,使之创建一个空字符串。
接下来,我们可以在类中添加一些新功能,而我们接下来就要看一下这些新功能具体是怎么实现的。
我们将要添加的方法:
int length() const { return len; } friend bool operator<(const String &st1, const String &st2); friend bool operator>(const String &st1, const String &st2); friend bool operator==(const String &st1, const String &st2); friend bool 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] = ' '; //defalut string }
分配内存使用“str = new char[1];”而不是“str = new char;”,两种方式分配的内存量相同,区别在于前者与类析构函数兼容,而后者不兼容。
析构函数中包含如下代码:
delete [] str;
delete[]与使用new[]初始化的指针和空指针都兼容。因此对于下述代码:
str = new char[1]; str[0] = ' ';
可修改为:
str = 0; //将str设置为指向空指针
对于以其他方式初始化的指针,使用delete []时,结果将是不确定的:
char words[15] = "bad idea"; char *p1 = words; char *p2 = new char; char *p3; delete [] p1; //no delete [] p2; //no delete [] p3; //no
C++11空指针
在C++98中,字面值0有两个含义:可以表示数字值零,也可以表示空指针,这使得阅读程序的人和编译器难以区分。有些程序员使用(void*)0来标识空指针(空指针本身的内部表示可能不是零),还有些程序员使用NULL,这是一个表示空指针的C语言宏。C++11提供了更好的解决方案:引入新关键字nullptr,用于表示空指针。
str = nullptr; //C++11新添的空指针表示,等价于str = 0;
【比较成员函数】
我们添加了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++中,两个中括号组成中括号运算符,可以使用方法operator[]()来重载该运算符。注意:对于中括号运算符,一个操作数位于第一个中括号的前面,另一个操作数位于两个中括号之间。因此,对于表达式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) { return str[i]; }
有了上述定以后,语句:cout<<opera[4]; 将被转换为:cout<<opera.operator[4];,返回值是opera.str[4](字符M)。
将返回类型声明为char &,便可以给特定元素赋值。例如,可以编写这样的代码:
String means("might"); means[0] = 'r';
第二条语句将被转换为一个重载运算符调用:
means.operator[][0] = 'r';
这里将r赋给方法的返回值,而函数返回的是指向means.str[0]的引用,因此上述代码等同于:
means.str[0] = 'r';
代码的最后一行访问的是私有数据,但由于operator[]()是类的一个方法,因此能够修改数组的内容。最终的结果是“might”被改为“right”。
假设有下面的常量对象:
const String answer("fruit");
如果只有上述operator[]()定义,则下面的代码将出错:
cout<<answer[1];
原因是answer是常量,而上述方法无法确保不修改数据(实际上,有时该方法的工作就是修改数据,因此无法确保不修改数据)。
但在重载时,C++将区分常量与非常量函数的特征标,因此可以提供另一个仅供const String对象使用的operator[]()版本:
const char & String::operator[](int i) const { return str[i]; }
有了上述定义后,就可以读/写常规String对象了;而对于const String对象,则只能读取其数据:
String text("Once upon a time"); const String answer("fruit"); cout<<text[1]; //ok cout<<answer[1]; //ok cin>>text[1]; //ok cin>>answer[1]; //compile_time error
【静态成员函数】
我们之前一直讲静态类成员,原来成员不仅有数据成员,还有函数成员。也就是说,可以将成员函数声明为静态的(函数声明必须包含关键字static,但如果函数定义是独立的,则其中不能包含关键字static)。
静态成员函数将带来两个后果:
- 不能通过对象调用静态成员函数;(实际上,静态成员函数甚至不能使用this指针,如果静态成员函数是在公有部分声明的,则可以使用类名和作用域解析运算符来调用它)例如,可以给String类添加一个名为HowMany()的静态成员函数,方法是在类声明中添加如下原型/定义:
static int HowMany() { return num_strings; }
调用它的方式如下:
int count = String::HowMany(); //调用静态成员函数
- 由于静态成员函数不与特定的对象相关联,因此只能使用静态数据成员。例如,静态方法HowMany()不能访问str和len。
【进一步重载赋值运算符】
考虑一个问题:假设我们要将常规字符串复制到String对象中。
例如:假设使用getline()读取了一个字符串,并要将这个字符串放置到String对象中。
前面定义的类方法让我们能够这样编写代码:
String name; char temp[40]; cin.getline(temp,40); name = temp; //使用构造函数 转换类型
这并不是一种理想的解决方案。我们来回顾一下最后一条语句是怎样工作的:
- 程序使用构造函数String(const char *)来创建一个临时String对象,其中包含temp中的字符串副本;(之前讲过,只有一个参数的构造函数被用作转换函数)
- 后面的程序实现中将使用String & String::operator=(const String &)函数将临时对象中的信息复制到name对象中;
- 程序调用析构函数~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指向的内存,并为新字符串分配足够的内存。
下面是String类的声明、实现及程序:
1 // string1.h -- fixed and augmented string class definition 2 3 #ifndef STRING1_H_ 4 #define STRING1_H_ 5 #include <iostream> 6 using std::ostream; 7 using std::istream; 8 9 class String 10 { 11 private: 12 char * str; // pointer to string 13 int len; // length of string 14 static int num_strings; // number of objects 15 static const int CINLIM = 80; // cin input limit 16 public: 17 // constructors and other methods 18 String(const char * s); // constructor 19 String(); // default constructor 20 String(const String &); // copy constructor 21 ~String(); // destructor 22 int length () const { return len; } 23 // overloaded operator methods 24 String & operator=(const String &); 25 String & operator=(const char *); 26 char & operator[](int i); 27 const char & operator[](int i) const; 28 // overloaded operator friends 29 friend bool operator<(const String &st, const String &st2); 30 friend bool operator>(const String &st1, const String &st2); 31 friend bool operator==(const String &st, const String &st2); 32 friend ostream & operator<<(ostream & os, const String & st); 33 friend istream & operator>>(istream & is, String & st); 34 // static function 35 static int HowMany(); 36 }; 37 #endif
1 // string1.cpp -- String class methods 2 #include <cstring> // string.h for some 3 #include "string1.h" // includes <iostream> 4 using std::cin; 5 using std::cout; 6 7 // initializing static class member 8 9 int String::num_strings = 0; 10 11 // static method 12 int String::HowMany() 13 { 14 return num_strings; 15 } 16 17 // class methods 18 String::String(const char * s) // construct String from C string 19 { 20 len = std::strlen(s); // set size 21 str = new char[len + 1]; // allot storage 22 std::strcpy(str, s); // initialize pointer 23 num_strings++; // set object count 24 } 25 26 String::String() // default constructor 27 { 28 len = 4; 29 str = new char[1]; 30 str[0] = '