第 9 章 类和动态内存分配
文章目录
9.1 动态内存和类
C++ 使用 new
和 delete
来动态控制内存。在类中动态分配内存时,析构函数将变得必不可少。有时候,还必须重载赋值运算符,以保证程序正常运行。
9.1.1 静态类成员
首先看下面的类定义:
strngbad.h
#include <iostream>
class StringBad
{
private:
char* str;
int len;
static int num_strings;
public:
StringBad(const char* s);
StringBad();
~StringBad();
friend std::ostream& operator<<(std::ostream& os, const StringBad& st);
};
strngbad.cpp
#include "StringBad.h"
#include <cstring>
using std::cout;
int StringBad::num_strings = 0;
StringBad::StringBad(const char* s)
{
len = std::strlen(s);
str = new char[len + 1];
std::strcpy(str, s);
num_strings++;
cout << num_strings << ": \"" << str << "\" object created\n";
}
StringBad::StringBad()
{
len = 4;
str = new char[4];
std::strcpy(str, "C++");
num_strings++;
cout << num_strings << ": \"" << str << "\" default object created\n";
}
StringBad::~StringBad()
{
cout << "\"" << str << "\" object deleted, ";
num_strings--;
cout << num_strings << " left\n";
delete[] str;
}
std::ostream& operator<<(std::ostream& os, const StringBad& st)
{
os << st.str;
return os;
}
该类定义中,成员 num_strings
声明为静态变量。静态类成员有一个特点:无论创建了多少对象,程序都只创建一个静态类变量副本。也就是说,类的所有对象共享同一个静态成员。
而在类的实现中,语句 int StringBad::num_strings = 0;
将静态类成员初始化为
0
0
0。请注意,不能在类声明中初始化静态成员变量,这是因为声明描述了如何分配内存,但并不分配内存。对于静态类成员,可以在类声明之外使用单独的语句来进行初始化,这是因为静态类成员是单独存储的,而不是对象的组成部分。请注意,初始化语句指出了类型,并使用了作用域解析运算符,但没有使用关键字 static
。此外,初始化是在类的实现文件中,而不是在类声明中进行的。这是因为类声明位于头文件中,程序可能将头文件包括在其他几个文件中,如果在类声明中进行初始化,将会出现多个初始化语句副本,从而引发错误。
对于不能在类声明中初始化静态数据成员的例外情况:静态数据成员为 const
整数类型或枚举型。
注意:静态数据成员在类声明中声明,在包含类方法的文件中初始化。初始化时使用作用域解析运算符来指出静态成员所属的类。但如果静态成员是
const
整数类型或枚举类型,则可以在类声明中初始化。
下面是一个使用该类的程序,该程序存在异常:
#include <iostream>
#include "StringBad.h"
using namespace std;
void callme1(StringBad& a)
{
cout << "String passed by reference:\n";
cout << " \"" << a << "\"\n";
}
void callme2(StringBad a)
{
cout << "String passed by value:\n";
cout << " \"" << a << "\"\n";
}
int main()
{
cout << "Starting an inner block.\n";
StringBad a1("a"), a2("b"), a3("c");
StringBad a4 = a1;
}
//输出如下:
Starting an inner block.
1: "a" object created
2: "b" object created
3: "c" object created
"a" object deleted, 2 left
"c" object deleted, 1 left
"b" object deleted, 0 left
"葺葺葺" object deleted, -1 left
程序崩溃
为什么会出现乱码和余下
−
1
-1
−1 个对象呢?实际上,是代码 StringBad a4 = a1;
引起的。这使用的构造函数并不是我们已经定义的构造函数,而是编译器自动生成的构造函数,称为复制构造函数,其原型为 StringBad(const StringBad&);
。当使用一个对象来初始化另一个对象时,编译器将自动生成复制构造函数,而自动生成的复制构造函数不知道要更新静态变量 num_string
,因此会将计数方案搞乱。而乱码也是由这个问题引起的,下面介绍这一主题。
9.1.2 特殊成员函数
StringBad
类的问题是由特殊成员函数引起的,这些函数是自动定义的。就 StringBad
而言,这些函数的行为与类设计不符。具体地说,C++ 自动提供了下面这些成员函数:
- 默认构造函数,如果没有定义构造函数
- 默认析构函数,如果没有定义
- 复制构造函数,如果没有定义
- 赋值运算符,如果没有定义
- 地址运算符,如果没有定义
-
默认构造函数
如果没有提供任何构造函数,C++ 将创建默认构造函数,该构造函数不接受任何参数,也不执行任何操作,在创建对象时会使对象内的类成员值是未知的。
如果定义了构造函数,C++ 将不会定义默认构造函数。如果希望在创建对象时不显式地对它进行初始化,则必须显式地定义默认构造函数。这种构造函数没有任何参数,但可以使用它来设置特定的值。
带参数的构造函数也可以是默认构造函数,只要所有参数都有默认值。注意,只能有一个默认构造函数。
-
复制构造函数
复制构造函数用于将一个对象复制到新创建的对象中,其原型通常如下:
class_name(const class_name &);
对于复制构造函数,需要知道两点:何时调用和有何功能。
-
何时调用
新建一个对象并将其初始化为同类现有对象时,复制构造函数将被调用。例如,假设
motto
是一个StringBad
对象,则下面 4 4 4 种声明都将调用复制构造函数:StringBad ditto(motto); StringBad metto = motto; StringBad also = StringBad(motto); StringBad * pStringBad = new StringBad(motto);
每当程序生成了对象副本时,编译器都将使用复制构造函数。具体地说,当函数按值传递对象或函数返回对象时,都将使用复制构造函数。由于按值传递对象将调用复制构造函数,因此应该按引用传递对象,这样可以节省调用构造函数的时间以及存储新对象的空间。
-
有何功能
默认的复制构造函数逐个复制非静态成员(成员赋值也称为潜复制),复制的是成员的值。在上述程序中,语句
StringBad sailor = sports;
与下面代码等效:StringBad sailor; sailor.str = sports.str; sailor.len = sports.len;
如果成员本身就是类对象,则将使用这个类的复制构造函数来复制成员对象。静态成员不受影响,因为它们属于整个类,而不是各个对象。
-
9.1.3 回到 StringBad
:复制构造函数的哪里出了问题
回到程序的异常之处。第一处异常是余下
−
1
-1
−1 个对象,这是因为默认的复制构造函数不会增加 num_strings
的值,但析构函数更新了计数,并且在任何对象过期时都将被调用,而不管对象是如何被创建的。解决办法是提供一个对计数进行更新的显式复制构造函数:
StringBad::StringBad(const StringBad& s)
{
num_strings++;
...
}
第二处异常更难以发现,且更加危险,造成打印出乱码。原因在于自动创建的复制构造函数是按值进行复制的,相当于 sailor.str = sports.str;
。这里复制的并不是字符串,而是一个指向字符串的指针。也就是说,这会造成 sailor
和 sports
都指向同一片内存,当其中一个对象 a
的析构函数被调用时,另一个对象 b
指向的内存也会被释放。当再次使用 b
对象时,有可能会使用已经释放过的内存,从而导致问题。此外,b
的析构函数调用时,将会导致同一片内存被释放两次,这可能会导致程序异常终止。
-
定义一个显式复制构造函数以解决问题
解决这种问题的方法是进行深复制。也就是说,复制构造函数应当复制字符串并将副本的地址赋给
str
成员,而不仅仅是复制字符串地址。这样每个对象都有自己的字符串,而不是引用另一个对象的字符串。调用析构函数时将释放不同的字符串,而不会试图去释放已经被释放的字符串。例如:StringBad::StringBad(const StringBad& s) { num_strings++; len = s.len; str = new char[len + 1]; std::strcpy(str, s.str); cout << num_strings << ": \"" << str << "\" object created\n"; }
警告:如果类种包含了使用
new
初始化的指针成员,应当定义一个复制构造函数,以复制指向的数据,而不是指针,这被称为深度复制。复制的另一种形式(成员赋值或浅复制)只是复制指针值。潜复制仅浅浅地复制指针信息,而不会深入“挖掘”以复制指针引用的结构。
9.1.4 StringBad
的其他问题:赋值运算符
并不是所有的问题都可以归咎于默认的复制构造函数,还需要看一看默认的赋值运算符。C 允许结构赋值,而 C++ 允许类对象赋值,这是通过自动为类重载赋值运算符实现的,其原型如下,它接受并返回一个指向类对象的引用:
class_name & class_name::operator=(const class_name &);
-
赋值运算符的功能以及何时使用它
将已有的对象赋值给另一个对象时,将使用重载的赋值运算符:
StringBad headline1("Celery Stalks at Midnight"); ... StringBad knot; knot = headline1;
初始化对象时,并不一定会使用赋值运算符:
StringBad metoo = knot;
这里
metoo
是一个新创建的对象,被初始化为knot
值,因此使用复制构造函数。然而,具体实现也可能分两步来处理这条语句:使用复制构造函数创建一个临时对象,然后通过赋值将临时对象的值复制到新对象中。这就是说,初始化总是会调用复制构造函数,而使用=
运算符时也允许调用赋值运算符。与复制构造函数类似,赋值运算符的隐式实现也对成员进行逐个复制(潜复制)。如果成员本身就是类对象,则程序将使用为这个类定义的赋值运算符来复制该成员,但静态成员不受影响。
-
赋值的问题出在哪里
以下面程序为例:
StringBad headline1("Celery Stalks at Midnight"); StringBad knot; knot = headline1; cout << "knot: " << knot << endl; //输出如下: Starting an inner block. 1: "Celery Stalks at Midnight" object created 2: "C++" default object created knot: Celery Stalks at Midnight "Celery Stalks at Midnight" object deleted, 1 left "葺葺葺葺葺葺葺葺葺葺葺葺葺葺葺O" object deleted, 0 left 程序崩溃
出现的问题与隐式复制构造函数相同:数据受损。这也是潜复制导致的问题,解决的办法是显式提供赋值运算符定义,进行深复制。其实现与复制构造函数相似,但有一些区别:
-
由于目标对象可能引用了以前分配的数据,所以函数应该使用
delete[]
来释放这些数据 -
函数应当避免将对象赋给自身;否则给对象重新赋值前,释放内存操作可能会删除对象的内容
-
函数返回一个指向调用对象的引用
通过返回一个对象,函数可以像常规赋值操作那样,进行连续赋值,例如:
s0 = s1 = s2;
,对应函数代码s0.operator=(s1.operator=(s2))
。下面代码说明了如何为StringBad
类编写赋值运算符:
StringBad& StringBad::operator=(const StringBad& st) { if (this == &st) return *this; delete[] str; len = st.len; str = new char[len + 1]; std::strcpy(str, st.str); return *this; }
-
9.2 改进后的新 String
类
有了之前介绍的知识后,可以对 StringBad
类进行修订。
9.2.1 修订后的默认构造函数
String::String()
{
len = 0;
str = new char[1];
str[0] = '\0';
}
上述代码使用 str = new char[1]
而不是 str = new char
,区别在于前者与析构函数中的 delete [] str
兼容,而后者不兼容。
9.2.2 比较成员函数
和 C++ 定义的 string
类一样,我们可以重载 >、<、==
等运算符进行字符串之间的比较,注意将比较函数定义为友元,有助于将 String
对象与常规的 C 字符串进行比较。例如:
bool operator==(const String& st1, const String& st2)
{
return std::strcpy(st1.str, st2.str) == 0;
}
假设 answer
是 String
对象,则代码 if("love" == answer)
将被转换为 if(operator==("love", answer))
,然后 "love"
将被转换为 String("love")
,从而完成比较。
9.2.3 使用中括号表示法访问字符
对于标准 C 风格字符串,可以使用 []
来访问其中的字符。对于我们定义的 String
类,可以重载 []
以实现该功能:
char& String::operator[](int i)
{
return str[i];
}
将返回类型声明为 char &
,便可以给特定元素赋值,例如:
String means("might");
means[0] = 'r'; //将might修改成right
但仍有一个问题,假设有以下常量对象,则下面代码将报错:
const String answer("futile");
cout << answer[1];
原因是 answer
是常量,而重载的 []
无法确保不修改数据。但在重载时,C++ 将区分常量和非常量函数的特征标,因此可以提供一个仅供 const String
对象使用的 operaotr[]()
版本:
const char& String::operator[](int i) const
{
return str[i];
}
9.2.4 静态成员函数
可以将成员函数声明为静态的,这样做有两个重要的后果:
-
首先,不能通过对象调用静态成员函数;实际上,静态成员函数甚至不能使用
this
指针。如果静态成员函数是在公有部分声明的,则可以使用类名和作用域解析运算符来调用它。例如,可以给String
类添加一个名为HowMany()
的静态成员函数,方法是在类声明中添加如下定义:static int HowMany() { return num_strings; }
调用它的方式如下:
int count = String::HowMany();
-
其次,由于静态成员函数不与特定的对象相关联,因此只能使用静态数据成员。例如,静态方法
HowMany()
可以访问静态成员num_strings
,但不能访问str
和len
。
9.2.5 进一步重载赋值运算符
考虑以下代码:
String name;
char temp[40];
cin.getline(temp, 40);
name = temp;
上述代码是这样工作的:
-
程序使用构造函数
String (const char *)
来创建一个临时的String
对象,其中包含temp
中的字符串副本。 -
程序将使用
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; }
9.3 在构造函数中使用 new
时应注意的事项
根据以上内容可知,使用 new
初始化对象的指针成员必须特别小心。具体地说,应当这样做:
-
如果在构造函数中使用
new
来初始化指针成员,则应在析构函数中使用delete
-
new
和delete
必须相互兼容。new
对应于delete
,new[]
对应于delete[]
-
如果有多个构造函数,则必须使用相同的方式使用
new
,要么都带中括号,要么都不带。因为只有一个析构函数,所有的构造函数都必须与它兼容。然而,可以在一个构造函数中使用new
初始化指针,而在另一个构造函数中将指针初始化为空( 0 0 0 或nullptr
),这是因为delete
(无论带不带中括号)都可以用于空指针。 -
应定义一个复制构造函数,通过深度复制将一个对象初始化为另一个对象。通常,这种构造函数与下面类似:
String::String(const String& s) { num_strings++; len = s.len; str = new char[len + 1]; std::strcpy(str, s.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; }
9.4 有关返回对象的说明
9.4.1 返回指向 const
对象的引用
使用 const
引用的常见原因是为了提高效率。如果函数返回传递给它的对象,则可以通过返回引用来提高效率。例如:
Vector Max(const Vector& v1, const Vector& v2); //返回对象
const vector& Max(const Vector& v1, const Vector& v2); //返回引用
这里有三点需要说明。首先返回对象将调用复制构造函数,而返回引用不会。其次,引用指向的对象应该在调用函数执行时存在。最后,v1
和 v2
都被声明为 const
引用,因此返回类型必须为 const
,这样才能匹配。
9.4.2 返回指向非 const
对象的引用
两种常见的返回非 const
对象情形是,重载赋值运算符以及重载与 cout
一起使用的 <<
运算符。
operator=()
返回该类型是为了可以连续赋值,且提高效率。
operator<<()
返回该类型是为了可以串接输出。
9.4.3 返回对象
如果被返回的对象是被调用函数中的局部变量,则不应该按引用方式返回它,因为在被调用函数执行完毕时,局部对象将调用其析构函数。因此,当控制权回到调用函数时,引用指向的对象将不再存在。
9.4.4 返回 const
对象
假设重载了 +
运算符:Vector operator+(const Vector&)
,则我们可以按以下方式调用:
net = force1 + force2; #1
然而,也可以按照以下方式调用:
force1 + force2 = net; #2
这种代码之所以可行,是因为复制构造函数将创建一个临时对象来表示返回值。因此,在前面的代码中,表达式 force1 + force2
的结果为一个临时对象。在语句
1
1
1 中,该对象被赋给 net
;在语句
2
2
2 中,net
被赋给该临时对象。使用完该临时对象后,将把它丢弃。
如果担心这种行为可能引发误用和滥用,有一种简单的解决方案:将返回类型声明为 const
。这样语句
1
1
1 仍合法,而语句
2
2
2 是非法的。
9.4.5 总结
总之,如果方法或函数要返回局部对象,则应返回对象,而不是指向对象的引用。在这种情况下,将使用复制构造函数来生成返回的对象。如果方法或函数要返回一个没有公有复制构造函数的类(如 ostream
类)的对象,它必须返回一个指向这种对象的引用。最后,有些方法和函数(如重载的赋值运算符)可以返回对象,也可以返回指向对象的引用,在这种情况下,应首选引用,因为其效率更高。
9.5 使用指向对象的指针
9.5.1 指向对象的指针
C++ 程序经常使用指向对象的指针。例如:
String * a = new String;
使用对象指针时,可以使用 ->
运算符来访问类方法,例如:int len = a->length();
。
9.5.2 再谈定位 new
运算符
考虑以下程序:
#include <iostream>
#include <string>
#include <new>
using namespace std;
const int BUF = 512;
class JustTesting
{
private:
string words;
int number;
public:
JustTesting(const string& s="Just Testing", int n=0)
{ words = s; number = n; cout << words << " constructed\n"; }
~JustTesting() { cout << words << " destroyed\n"; }
void Show() const { cout << words << ", " << number << endl; }
};
int main()
{
char* buffer = new char[BUF];
JustTesting* pc1, * pc2;
pc1 = new(buffer) JustTesting;
pc2 = new JustTesting("Heap1", 20);
pc1->Show();
pc2->Show();
JustTesting* pc3, * pc4;
pc3 = new(buffer) JustTesting("Bad Idea", 6);
pc4 = new JustTesting("Heap2", 10);
pc3->Show();
pc4->Show();
delete pc2;
delete pc4;
delete[] buffer;
}
//输出如下
Just Testing constructed
Heap1 constructed
Just Testing, 0
Heap1, 20
Bad Idea constructed
Heap2 constructed
Bad Idea, 6
Heap2, 10
Heap1 destroyed
Heap2 destroyed
上面程序中,将 delete []
用于 buffer
不会为使用定位 new
运算符创建的对象调用析构函数。不仅如此,按下面:
delete pc1;
delete pc3;
这样做将导致运行阶段错误,原因在于 delete
可与常规 new
运算符配合使用,但不能与定位 new
配合使用。使用 delete[] buffer
确实释放了 buffer
,但并没有为 pc1
和 pc3
调用析构函数,这样可能会导致内存泄漏。解决方案是,显式地为使用定位 new
运算符创建的对象调用析构函数:
pc3->~JustTesing();
pc1->~JustTesing();
delete[] buffer;
正常情况下析构函数会自动调用,此处是一个例外,这是需要显式调用析构函数的少数几种情形之一。需要注意的一点是正确的删除顺序。对于使用定位 new
运算符创建的对象,应按照与创建对象相反的顺序进行删除,原因在于晚创建的对象可能依赖于早创建的对象。另外,仅当所有对象都被销毁后,才能释放用于存储这些对象的缓冲区:
总结:
- 对于普通的
new
,直接使用delete
释放内存即可;- 对于定位
new
,需显式调用析构函数。
9.6 成员初始化列表
考虑一个队列的类:
#define Item int
class Queue
{
private:
struct Node { Item item; struct Node* next; };
enum { Q_SIZE = 10 };
Node* front, * rear;
int items;
const int qsize;
public:
Queue(int qs = Q_SIZE);
~Queue();
bool isempty() const;
bool isfull() const;
int queuecount() const;
bool enqueue(const It)
};
其中,Queue
的构造函数如下:
Queue::Queue(int qs)
{
front = rear = NULL;
items = 0;
qsize = qs; //error!
}
上述代码将不能通过编译!问题在于 qsize
是常量,只能对它进行初始化,但不能给它赋值。对于常量,C++ 提供了一种特殊的语法来完成上述工作,它叫作成员初始化列表。成员初始化列表由逗号分割的初始化列表组成,它位于参数列表的右括号之后、函数体左括号之前,例如,Queue
的构造函数应该如下:
Queue::Queue(int qs): qsize(qs)
{
front = rear = NULL;
items = 0;
}
如果 Classy
是一个类,而 mem1、mem2、mem3
都是其数据成员,则类构造函数可以使用如下的语法来初始化数据成员:
Classy::Classy(int n, int m): mem1(n), mem2(0), mem3(n*m + 2)
{
...
}
从概念上说,这些初始化工作是在对象创建时完成的,此时还未执行括号中的任何代码。请注意以下几点:
- 这种格式只能用于构造函数
- 必须用这种格式来初始化非静态
const
数据成员 - 必须用这种格式来初始化引用数据成员
数据成员被初始化的顺序与它们出现在类声明中的顺序相同,与初始化器中的排列顺序无关。