在这一讲中,我们将学习对类使用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\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;
}
我们是在方法实现文件中初始化静态成员变量,这是因为:(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\n"; // 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\n"; // 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.\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";
// std::cin.get();
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的构造函数和析构函数何时运行及如何运行。该程序将对象声明放在一个内部代码块中,因为析构函数将在定义对象的代码块执行完毕时调用。如果不这样做,析构函数将在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\n";
}
必须定义复制构造函数的原因在于,一些类成员是使用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] = '\0'; //defalut string
}
分配内存使用“str = new char[1];”而不是“str = new char;”,两种方式分配的内存量相同,区别在于前者与类析构函数兼容,而后者不兼容。
析构函数中包含如下代码:
delete [] str;
delete[]与使用new[]初始化的指针和空指针都兼容。因此对于下述代码:
str = new char[1];
str[0] = '\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] = '\0'; // default string 31 num_strings++; 32 } 33 34 String::String(const String & st) 35 { 36 num_strings++; // handle static member update 37 len = st.len; // same length 38 str = new char [len + 1]; // allot space 39 std::strcpy(str, st.str); // copy string to new location 40 } 41 42 String::~String() // necessary destructor 43 { 44 --num_strings; // required 45 delete [] str; // required 46 } 47 48 // overloaded operator methods 49 50 // assign a String to a String 51 String & String::operator=(const String & st) 52 { 53 if (this == &st) 54 return *this; 55 delete [] str; 56 len = st.len; 57 str = new char[len + 1]; 58 std::strcpy(str, st.str); 59 return *this; 60 } 61 62 // assign a C string to a String 63 String & String::operator=(const char * s) 64 { 65 delete [] str; 66 len = std::strlen(s); 67 str = new char[len + 1]; 68 std::strcpy(str, s); 69 return *this; 70 } 71 72 // read-write char access for non-const String 73 char & String::operator[](int i) 74 { 75 return str[i]; 76 } 77 78 // read-only char access for const String 79 const char & String::operator[](int i) const 80 { 81 return str[i]; 82 } 83 84 // overloaded operator friends 85 86 bool operator<(const String &st1, const String &st2) 87 { 88 return (std::strcmp(st1.str, st2.str) < 0); 89 } 90 91 bool operator>(const String &st1, const String &st2) 92 { 93 return st2 < st1; 94 } 95 96 bool operator==(const String &st1, const String &st2) 97 { 98 return (std::strcmp(st1.str, st2.str) == 0); 99 } 100 101 // simple String output 102 ostream & operator<<(ostream & os, const String & st) 103 { 104 os << st.str; 105 return os; 106 } 107 108 // quick and dirty String input 109 istream & operator>>(istream & is, String & st) 110 { 111 char temp[String::CINLIM]; 112 is.get(temp, String::CINLIM); 113 if (is) 114 st = temp; 115 while (is && is.get() != '\n') 116 continue; 117 return is; 118 }
1 // sayings1.cpp -- using expanded String class 2 // compile with string1.cpp 3 #include <iostream> 4 #include "string1.h" 5 const int ArSize = 10; 6 const int MaxLen =81; 7 int main() 8 { 9 using std::cout; 10 using std::cin; 11 using std::endl; 12 String name; 13 cout <<"Hi, what's your name?\n>> "; 14 cin >> name; 15 16 cout << name << ", please enter up to " << ArSize 17 << " short sayings <empty line to quit>:\n"; 18 String sayings[ArSize]; // array of objects 19 char temp[MaxLen]; // temporary string storage 20 int i; 21 for (i = 0; i < ArSize; i++) 22 { 23 cout << i+1 << ": "; 24 cin.get(temp, MaxLen); 25 while (cin && cin.get() != '\n') 26 continue; 27 if (!cin || temp[0] == '\0') // empty line? 28 break; // i not incremented 29 else 30 sayings[i] = temp; // overloaded assignment 31 } 32 int total = i; // total # of lines read 33 34 if ( total > 0) 35 { 36 cout << "Here are your sayings:\n"; 37 for (i = 0; i < total; i++) 38 cout << sayings[i][0] << ": " << sayings[i] << endl; 39 40 int shortest = 0; 41 int first = 0; 42 for (i = 1; i < total; i++) 43 { 44 if (sayings[i].length() < sayings[shortest].length()) 45 shortest = i; 46 if (sayings[i] < sayings[first]) 47 first = i; 48 } 49 cout << "Shortest saying:\n" << sayings[shortest] << endl;; 50 cout << "First alphabetically:\n" << sayings[first] << endl; 51 cout << "This program used "<< String::HowMany() 52 << " String objects. Bye.\n"; 53 } 54 else 55 cout << "No input! Bye.\n"; 56 // keep window open 57 /* if (!cin) 58 cin.clear(); 59 while (cin.get() != '\n') 60 continue; */ 61 return 0; 62 }
接下来我们温习一下在构造函数中使用new时应注意的事项:
从上面的分析,我们知道使用new初始化对象的指针成员时必须特别小心:
- 如果在构造函数中使用new来初始化指针成员,则应在析构函数中使用delete;
- new和delete必须相互兼容,即new必须对应delete,new[]对应delete[];
- 如果有多个构造函数,由于它们都必须与(只有一个的)析构函数兼容,则同时使用new或new[]取决于析构函数如何使用delete。
- 应定义一个复制构造函数,通过深度复制将一个对象初始化为另一个对象:具体地说,复制构造函数应分配足够的空间来存储复制的数据,并复制数据,而不仅仅是数据的地址。此外,还应该更新所有受影响的静态类成员。
String::String(const String & st) { num_strings++; //如果需要的话,处理静态成员 len = st.len; //与被拷贝的字符串相同的长度 str = new char[len+1]; //分配空间 std::strcpy(str,st.str); }
- 定义一个赋值运算符,通过深度复制将一个对象复制给另一个对象。通常,该类方法与下面类似:
String & String::operator=(const String & st) { if(this == &st) return this; delete [] str; len = st.len; str = new char[len+1]; std::strcpy(str,st.str); return *this; }
具体地说,该方法应完成这些操作:检查自我赋值的情况,释放成员指针以前指向的内存,复制数据(而不仅仅是数据的地址),并返回一个指向调用对象的引用。
补充一点:包含类成员的类的逐成员复制
假设类成员的类型为String类或标准string类:
class Magazine
{
private:
String title;
string publisher;
...
};
String和string都使用动态内存分配,但这不意味着需要为Magazine类编写复制构造函数和赋值运算符,至少对这个类本身来说不需要。默认的逐成员复制和赋值行为有一定的智能。如果我们将一个Magazine对象复制或赋值给另一个Magazine对象,逐成员复制将使用成员类型定义的复制构造函数和赋值运算符。也就是说,复制成员title时,将使用String的复制构造函数。然而,如果Magazine类因其他成员需要定义复制构造函数和赋值运算符,情况将更复杂,此时这些函数必须显式地调用String和string的复制构造函数和赋值运算符,这将在下一讲中介绍。
除了对上面使用new时应注意的事项进行总结外,我们还要总结有关返回对象的说明:
我们知道,当成员函数或独立的函数返回对象时,可以返回:
- 指向对象的引用
- 指向对象的const引用
- 指向const对象
【返回指向const对象的引用】
使用const引用的常见原因是旨在提高效率,但对于何时可以采用这种方式存在一些限制。如果函数返回(通过调用对象的方法或将对象作为参数)传递给它的对象,可以通过返回引用来提高其效率。
例如,假设要编写函数Max(),它返回两个Vector对象中较大的一个,其中Vector是上一讲中开发的一个类:
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;
}
(1)返回对象将调用构造函数,而返回引用不会,因此第二个版本效率更高;(2)引用指向的对象应该在调用函数执行时存在,这个例子中,引用指向force1或force2,它们都是在调用函数中定义的,因此满足这种条件;(3)v1和v2都被声明为const引用,因此返回类型必须为const,这样才匹配。
【返回指向非const对象的引用】
两种常见的返回非const对象的情形是:重载赋值运算符以及重载与cout一起使用的<<运算符。前者旨在提高效率,而后者必须这么做。
operator=()的返回值用于连续赋值:
String s1("Good stuff");
String s2, s3;
s3 = s2 = s1;
在上述代码中,s2.operator=()的返回值被赋给s3。为此,返回String对象或String对象的引用都是可行的,但与Vector示例中一样,通过使用引用,可避免该函数调用String的复制构造函数来创建一个新的String对象。在这个例子中,返回类型不是const,因为方法operator=()返回一个指向s2的引用,可以对其进行修改。
operator<<()的返回值用于串接输出:
String s1("Good stuff");
cout<<s1<<"is coming!";
在上述代码中,operator<<(cout,s1)的返回值成为一个用于显示字符串“is coming”的对象。返回类型必须是ostream &,而不能仅仅是ostream。如果使用返回类型是ostream,将要求调用ostream类的复制构造函数,而ostream没有公有的复制构造函数。幸运的是,返回一个指向cout的引用不会带来任何问题,因为cout已经在调用函数的作用域内。
【返回对象】
如果被返回的对象是被调用函数中的局部变量,则不应按引用方式返回它,因为在被调用函数执行完毕时,局部对象将调用其析构函数。因此,当控制权回到调用函数时,引用指向的对象将不再存在。在这种情况下,应返回对象而不是引用。通常,被重载的算术运算符属于这一类。
请看下述示例,它再次使用了Vector类:
Vector force1(50,60);
Vector force2(10,70);
Vector net;
net = force1 + force2;
返回的不是force1,也不是force2,force1和force2在这个过程中应该保持不变。因此,返回值不能是指向在调用函数中已经存在的对象的引用。相反,在Vevtor::operator+()中计算得到的两个矢量的和被存储在一个新的临时对象中,该函数也不应返回指向该临时对象的引用,而应该返回实际的Vector对象,而不是引用:
Vector Vector::operator+(const Vector & b) const
{
return Vector(x + b.x, y + b.y);
}
在这种情况下,存在调用复制构造函数来创建被返回的对象的开销,然而这是无法避免的。
在上述示例中,构造函数调用Vector(x+b.x, y+b.y)创建一个方法operator+()能够访问的对象;而返回语句引发的对复制构造函数的隐式调用创建一个调用程序能够访问的对象。
【返回const对象】
前面的Vector::operator+()定义有一个奇异的属性,它旨在让我们能够以下面的方式使用它:
net = force1 + force2;
然而,这种定义也允许我们这样使用它:
force1 + force2 = net;
cout<<(force1+force2=net).magval()<<endl;
这里提出了三个问题。为何编写这样的语句?这些语句为何可行?这些语句有何功能?
首先,没有要编写这种语句的合理理由,但并非所有代码都是合理的。即使是程序员也会犯错。
例如,为Vector类定义operator==()时,我们可能错误地输入这样的代码:
if(force1 + force2 = net)
而不是:
if(force1 + force2 == net)
其次,这种代码之所以可行,是因为复制构造函数将创建一个临时对象来表示返回值。因此,在前面的代码中,表达式force1+force2的结果为一个临时对象。在语句1中,该临时对象被赋给net;在语句2和3中,net被赋给该临时对象。
第三,使用完临时对象后,将把它丢弃。例如,对于语句2,程序计算force1和force2之和,将结果复制到临时返回对象中,再用net的内容覆盖临时对象的内容,然后将该临时对象丢弃。原来的矢量全都保持不变。语句3显示临时对象的长度,然后将其删除。
如果担心这种行为可能引发的误用和滥用,有一种简单的解决方案:将返回类型声明为const Vector。例如,如果Vector::operator+()的返回类型被声明为const Vector,则语句1仍然合法,但语句2和语句3将是非法的。
笔记:如果方法或函数要返回局部对象,则应返回对象,而不是指向对象的引用。在这种情况下,将使用复制构造函数来生成返回的对象。如果方法或函数要返回一个没有公有复制构造函数的类(如ostream类)的对象,它必须返回一个指向这种对象的引用。最后,有些方法和函数(如重载的赋值运算符)可以返回对象,也可以返回指向对象的引用,在这种情况下,应首选引用,因为其效率更高。
下面应该是谈使用指向对象的指针。
这部分知识点等项目遇到知识点需求时再来填坑。(p451)
最后,我们在进一步了解类后,准备将这部分知识用于解决编程问题。
我们将通过定义一个类来加强对类的掌握——包括类的接口与实现。
这部分由于篇幅较长,我们暂不在博客上板书,时间有限!
温故而知新
1.假设String类有如下私有成员:
class String
{
private:
char*str; //points to string allocated by new
//...
};
a.下述默认构造函数有什么问题?
String::String(){}
b.下述构造函数有什么问题?
String::String(const char* s)
{
str=s;
len=strlen(s);
}
c.下述构造函数有什么问题?
String::String(const char*a)
{
strcpy(str,s);
len=strlen(s);
}
答:
a的问题在于,char*str由于需要通过new来分配内存,因此构造函数、复制构造函数、析构函数,都有对应的new或者delete。而这里至少需要声明是空指针,才可以,否则在遇见delete时,会提示出错。
b的问题在于str不能直接指向传递的字符串s。这可能导致多个对象指向同一个内存地址,或者指向一个由new分配的内存地址但这个地址之后又被delete释放(因此使用该对象会出错)。而len这行代码应该放在最先。
c的问题在于,应该先用len=strlen(s),然后str=new char[len+1];,然后再使用strcpy()函数。
2.如果您定义了一个类,其指针成员是使用new初始化的,请指出可能出现的3个问题,以及如何纠正这些问题。
答:
可能①:只有一个构造函数使用了new来分配,其他比如默认构造函数、复制构造函数、构造函数未使用new来分配内存。
解决:应同时都使用。
可能②:析构函数没有使用delete释放内存。
解决:析构函数应该使用delete释放内存。
可能③:未自定义默认赋值运算符(面向对象的)。
解决:赋值运算符应自定义,且使用new来分配内存,strcpy()函数来拷贝字符串内容。
可能④:指针直接指向某作为参数的字符串。
解决:指针应该指向由new分配,由strcpy()拷贝后的内存地址。
3.如果没有显式的提供类方法,编译器将自动生成哪些类方法?请描述这些隐式生成的函数的行为。
答:
①默认构造函数。无赋值,单纯初始化各个数据成员;
②析构函数。无任何操作;
③复制构造函数。按值将被复制的对象传递给被初始化的对象;
④赋值运算符(把一个对象赋值给另一个对象的)。逐成员赋值传递;
⑤地址运算符。(返回this指针的值)
4.找出并改正下述类声明中的错误:
class nifty
{
//data
char personality[];
int talents;
//methods
nifty();
nifty(char*s);
ostream & operator<<(ostream&os, nifty&n);
}
nifty:nifty()
{
personality=NULL;
talents=0;
}
nifty:nifty(char*s)
{
personality = new char [strlen(s)];
personality =s;
talents = 0;
}
ostream & nifty:operator<<(ostream & os, nifty &n)
{
os<<n;
}
答:
错误0:类名首字母一般大写,但不是强制规定。
错误①:看上下文,char personality[]应该想要声明的是char指针,而不是一个不确定字符数的字符串,因此应该用char * personality; char指针和字符串占用的内存空间是不一样的
错误②:公共部分没有写public,在类方法和数据之间加上。
错误③:ostream&那个函数,应该是友元函数,应该在之前加上friend
错误④:作用域解析运算符是“::”,而不是“:”,两个类方法都应该加上。
错误⑤:nifty::nifty(char*s)看意思是要复制指针的字符串给自己。一是new分配的空间少1,二是没有正确使用拷贝,三不太确定talents要初始化为什么,貌似是要初始化为0。因此修改为:
nifty::nifty(char * s)
{
personality = new char[strlen(s)+1];
strcpy(personality, s);
talents=0;
}
错误⑥友元函数无需加作用域解析运算符,函数内部写错。应该改为:
ostream & operator<<(ostream & os, nifty & n)
{
os<<n.personality <<"," <<n.talents;
}
错误⑦类定义结束后没有分号
错误⑧nifty:nifty(char*s)一般写成nifty::nifty(const char *s)
错误⑨ostream & nifty:operator<<(ostream & os, nifty &n)
一般写为:ostream & nifty:operator<<(ostream & os, const nifty &n)——加上cosnt
5.对于下面的类声明:
class Golfer
{
private:
char * fullname; // points to string containing golfer's name
int games; // holds number of golf games played
int * scores; // points to first element of array of golf scores
public:
Golfer();
Golfer(const char * name, int g=0);
//creates empty dynamic array of g elements if g > 0
Golfer(const Golfer & g);
~Golfer();
};
a.下面各条语句将调用哪些类方法
Golfer nacy; //#1
Golfer lulu("Little Lulu"); //#2
Golfer roy("Roy Hobbs",12); //#3
Golfer * par = new Golfer; //#4
Golfer next = lulu; //#5
Golfer hazzard = "Weed Thwacker"; //#6
*par = nancy; //#7
nancy = "Nancy Putter"; //#8
b.很明显,类需要有另外几个方法才能更有用,但是,类需要哪些方法才能防止数据被损坏呢?
答:
a.
1#将调用默认构造函数Golfer()
2#将调用构造函数Golfer(const char * name, int g=0);
3#将调用构造函数Golfer(const char * name, int g=0);
4#将调用默认构造函数Golfer();
5#将调用复制构造函数Golfer(const Golfer & g);
6#将调用构造函数Golfer(const char * name, int g=0);
7#将什么也不调用,单纯将指针指向类对象。
7#将调用赋值运算符(隐式生成的),我没注意到par指针在4#声明了
8#将调用构造函数Golfer(const char * name, int g=0);和隐式生成的赋值运算符
b.
为了防止数据损坏,有以下方法:
①防止拷贝,将赋值运算符(面向对象拷贝给对象的)/复制构造函数,放在私有部分;
②自定义赋值运算符/复制构造函数,让其在复制的时候,避免按值传递,让其通过new、strcpy()函数等方式拷贝数据。