第12章 类和动态内存分配
<c++ primer plus>第六版
12 类和动态内存分配
12.1 动态内存和类
12.1.1 示例和静态类成员
//以下两行代码等价
// 都是使用一个对象来初始化新对象,
// 调用的构造函数为: StringBad(const StringBad &);
StringBad sailor = sports;
StringBad sailor = StringBad(sports);
12.1.2 特殊成员函数
有些成员函数是自动定义的, c++自动提供的成员函数有:
- 默认构造函数(如果没有定义).
- 默认析构函数(如果没有定义).
- 复制构造函数(如果没有定义).
- 赋值运算符(如果没有定义).
- 地址运算符(如果没有定义).
如果类的构造函数中用到静态成员或使用动态内存分配, 则隐式的复制构造函数 和 隐式的赋值运算符 会引起一系列问题.
- 默认构造函数:
1.1 定义一个类Klunk, 且没提供任何构造函数, 则编译器将提供如下默认构造函数
Klunk::Klunk() {} //默认构造函数, 不接收任何参数, 也不执行任何操作.
Klunk lunk; //该语句会调用默认构造函数.
```cpp
1.2 如果定义了构造函数, 则编译器将不会定义默认构造函数, 如果需要不带参数的构造函数, 需要自己定义.
```cpp
Klunk::Klunk() //定义不带参数的构造函数
{
klunk_ct = 0;
}
1.3 带参数的构造函数也可以是默认构造函数, 只要所有参数都有默认值.
Klunk (int n=0)
{
klunk_ct = n;
}
但是只能有一个默认构造函数, 如下两个默认构造函数有二义性, 当用户使用Klunk bus语句时, 将匹配两个构造函数, 会报错.
Klunk () { klunk_ct = 0; }
Klunk (int n=0) { klunk_ct = n; }
- 复制构造函数: 将一个对象复制到新创建的对象中. 它用于初始化过程中, 而不是常规赋值过程中.
2.1 复制构造函数原型:
ClassName(const ClassName &) //接受一个指向对象的常量引用作为参数.
2.2 何时调用复制构造函数
新建一个对象, 并将其初始化为同类现有对象时, 将调用复制构造函数.
假设motto是StringBad的对象, 则下面4语句要调用复制构造函数
StringBad ditto(motto);
StringBad metoo = motto;
StringBad also = StringBad(motto);
StringBad * pStringBad = new StringBad(motto);
每当程序生成了对象副本时, 编译器都将使用复制构造函数: 函数按值传递对象, 函数返回对象.
2.4 默认复制构造函数的功能:
默认的复制构造函数逐个复制非静态成员(成员复制也称为浅复制), 复制的成员的值. 静态函数不受影响, 因为它们不属于各个对象.
比如:
cpp StringBad sailor = sports,
等价于:
cpp StringBad sailor; sailor.str = sports.str; sailor.len = sports.len;
12.1.4. 赋值运算符
c++允许类对象赋值, 这是通过自动为类重载赋值运算符实现的.
ClassName & ClassName::operator=(const ClassName &);
它接受一个指向类对象的引用, 并返回一个指向类对象的引用.
- 赋值运算符的功能 以及 何时使用它
将已有的对象赋值给另一个对象时, 将使用重载的赋值运算符.
StringBad headline1("Celery Stalks at Midnight");
StringBad knot;
knot = headline1; //将调用赋值运算符
注意: 初始化对象时, 并不一定会使用赋值运算符:
StringBad metoo = knot; //将调用复制构造函数(实现时可能分两步: 1. 使用复制构造函数创建一个临时对象, 然后调用赋值运算符将临时对象复制到新对象).
所以: 初始化总是会调用复制构造函数, 而使用=运算符时也可能调用赋值运算符.
与复制构造函数相似, 赋值运算符的隐式实现也对成员进行逐个复制. 如果成员本身就是类对象, 则程序将使用为这个类定义的赋值运算符来复制该成员. 静态数据不受影响.
12.2 改进后的新String类
12.3 在构造函数中使用new时应注意的事项
使用new初始化对象的指针时要特别小心:
- 如果在构造函数中使用new来初始化指针成员, 则应在析构函数中使用delete;
- new和delete必须相互兼容: new对应delete, new[]对应delete[];
- 如果有多个构造函数, 则必须以相同的方式使用new(要么都带中括号, 要么都不带). 因为只有一个析构函数.
- 应该定义一个复制构造函数, 通过深度复制将一个对象初始化为另一个对象.
复制构造函数应该分配足够的空间来存储复制的数据, 并复制数据, 而不仅仅是数据的地址. - 应该定义一个赋值运算符, 通过深度复制将一个对象复制给另一个对象.
12.4 有关返回对象的说明
当成员函数或独立函数返回对象时, 有几种返回方式:
- 返回指向对象的引用;
- 返回指向对象的const引用;
- 返回对象;
- 返回const对象;
12.4.1 返回指向const对象的引用
返回const引用的主要目的是提高效率.
Vector force1(50, 60);
Vector force2(10, 70);
Vector max;
max = Max(force1, force2);
其中Max函数的以下两种实现方法都可行:
//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) //第一个const表示返回值是const
{
if (v1.magval()>v2.magval())
return v1;
else
return v2;
}
注意:
- 返回对象将调用复制构造函数, 而返回引用则不会. 所以version 2所做的工作更少, 效率更高.
- 引用指向的对象应该在调用函数执行时存在.
- 函数参数v1和v2都被声名为const引用, 而函数返回v1或v2, 所以返回类型也必须为const, 这样才匹配.
12.4.2 返回指向非const对象的引用
有两种常见的情形要返回非const对象(前者旨在提高效率, 后者必须这样做):
- 重载赋值运算符;
- 重载与cout一起使用的<<运算符;
operator=()的返回值用于连续赋值:
String s1("Good Stuff");
String s2, s3;
s3 = s2 = s1;
这里s2.operator=()的返回值被赋值给s3, 返回对象或返回引用都可行, 但返回引用可避免调用String的复制构造函数来创建一个新的String对象.
operator<<()的返回值用于串接输出:
String s1("Good Stuff");
cout << s1 << " is coming!";
这里operator<<(cout, s1)的返回值成为一个用于显示字符串" is coming!"的对象.
返回类型必须是ostream &, 而不能是ostream. 如果返回ostream, 将会调用ostream类的复制构造函数, 但ostream没有公有的复制构造函数.
12.4.3 返回对象
注意: 如果被返回的对象是被调用函数中的局部变量, 则不能按引用的方式返回它. 因为函数执行完后局部变量将调用其析构函数, 引用指向的对象将不再存在.
即: 返回局部变量时, 应该返回对象, 而不是返回引用.
通常, 被重载的算术运算符属于这一类.
例如:
Vector force1(50, 60);
Vector force2(10, 70);
Vector net;
net = force1 + force2;
返回的不是force1也不是force2, 因此返回值不能是调用函数时已经存在的对象的引用, 而是新的临时对象.
Vector Vector::operator+(const Vector &b) const //最后一个const表示是const函数, 不能修改类成员
{
return Vector(x+b.x, y+b.y);
}
这时, 存在调用复制构造函数(用来创建被返回的对象)的开销, 然而这是无法避免的.
12.4.4 返回const对象
以如下3个语句为例:
net = force1 + force2; //1, 将两个对象相加, 赋值给第三个对象.
force1 + force2 = net; //2, 将第三个对象赋值给两个对象相加.
cout << (force1 + force2 = net).magval() << endl; //3, 在2的基础上再调用对象方法.
其中第2/3条语句比较奇怪, 提三个问题:
-
为何编写这样的语句?
没有要编写这种语句的理由, 但并非所有代码都是合理的. -
这些语句为何可行?
因为表达式force1+force2的结果为一个临时对象(复制构造函数将创建一个临时对象来表示返回值).
在语句1中, 将该临时对象赋值给net.
在语句2和3中, 将net赋值给该临时对象. -
这些语句有何功能?
使用完临时对象后, 将把它丢弃.
比如语句2, 程序计算force1与force2之和, 将结果复制到临时变量中, 再后net的内容覆盖临时对象的内容, 然后将该临时对象丢弃, 原来的矢量全都保持不变.
比如语句3, 程序显示临时对象的长度, 然后将其删除.
如果担心force1 + force2 = net这种语句可能引发的误用和滥用(比如在条件判断语句中将force1+force2==net误写为force1+force2=net),
有一种简单的解决方案: 将返回类型声明为const Vector, 则由于语句2和语句3都有对临时对象的赋值操作, 所以这两语句是非法的.
总结:
- 如果方法或函数要返回局部对象, 则应该返回对象, 而不是指向对象的引用(因为局部对象在函数结束后就不存在了).
- 返回对象时, 将使用复制构造函数来生成返回的对象.
- 如果要返回一个没有公有复制构造函数的函数的类(如ostream类)的对象, 它必须返回指向这种对象的引用.
- 有些方法或函数(如重载的赋值运算符), 既可以返回对象也可以返回指向对象的引用, 这时应首选引用, 因为其效率更高.
12.5 使用指向对象的指针
如果: ClassName是类, value的类型为TypeName, 则如下语句:
ClassName * pclass = new ClassName(value); //声明一个指向对象的指针, 将调用构造函数ClassName(TypeName);
如下初始化方式:
ClassName *ptr = new ClassName; //将调用默认构造函数
12.5.1 再谈new和delete
在构造函数中使用new为对象分配存储空间, 在析构函数中使用delete来释放这些内存.
String * favorite = new String(sayings[choice]);
注意: 这是为对象分配内存, 而不是为要存储的字符串分配内存. 也就是说分配的内存情况为:
- 保存字符串地址的str指针的内存,
- len成员的内存,
- 不给num_string成员分配内存, 因为它是静态成员, 它独立于对象被保存.
创建对象时将调用构造函数, 在构造函数中才会分配用于保存字符串的内存, 并将字符串的地址赋值给str.
当程序不再需要该对象时, 使用delete删除它.
程序删除对象时, 将只释放用于保存str指针和len成员的空间, 并不释放str指向的内存.
释放str指向的内存的任务由析构函数来完成.
在下述情况下, 将调用析构函数:
- 如果对象是动态变量, 当执行完定义该对象的程序块时, 将调用该对象的析构函数.
- 如果对象是静态变量(外部, 静态, 静态外部, 来自名称空间), 则在结束时, 将调用该对象的析构函数.
- 如果对象是用new创建的, 则仅当显式地使用delete删除对象时, 才会调用该对象的析构函数.
12.5.2 指针和对象小结
使用对象指针时, 要注意几点:
- 使用常规表示法来声明指向对象的指针 : String * glamour;
- 将指针初始化为指向已有的对象 : String * first = &sayings[0];
- 使用new来初始化指针(将创建一个新对象) : String * favor = new String(sayings[choice]);
-
- 对类使用new, 将调用类构造函数初始化新建对象: String * gleep = new String; //调用默认构造函数
- String * glop = new String("My my my"); //调用相应参数类型的构造函数
- 通过 指针-> 运算符来访问类方法 : if (sayings[i].length() < shorted->length())
- 对对象指针使用解除引用运算符(*)来获得对象 : if (sayings[i] < *first)
12.5.3 再谈定位new运算符
定位new运算符的作用: 在分配内存时能够指定内存位置.
#include <iostream>
#include <string>
#include <sstream>
#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 << "construct : " << words << endl;
}
~JustTesting()
{
cout << "destroy : " << words << endl;
}
void Show() const
{
cout << words << ", " << number << endl;
}
string to_str()
{
string str;
stringstream ss;
ss << number;
ss >> str;
return words + ", " + str;
}
};
int main()
{
char * buffer = new char[BUF]; // get a block of memory
JustTesting *pc1, *pc2;
pc1 = new (buffer) JustTesting; // place object in buffer, 创建一个512字节的内存缓冲区
pc2 = new JustTesting("Heap2", 20); // place object on heap
cout << endl;
cout << "Memory block addresses:" << endl;
cout << " buffer: " << (void *)buffer << endl;
cout << " heap : " << pc2 << endl;
cout << endl;
cout << "Memory contents:" << endl;
cout << " " << pc1 << ": " << pc1->to_str() << endl;
cout << " " << pc2 << ": " << pc2->to_str() << endl;
cout << endl;
JustTesting *pc3, *pc4;
pc3 = new (buffer) JustTesting("Bad Idea", 6); //会覆盖pc1对应的内存单元
pc4 = new JustTesting("Heap4", 10);
cout << "Memory contents:" << endl;
cout << " " << pc3 << ": " << pc3->to_str() << endl;
cout << " " << pc4 << ": " << pc4->to_str() << endl;
cout << endl;
delete pc2; //会调用析构函数
delete pc4; //会调用析构函数
delete [] buffer; //不会调用析构函数
cout << "Done" << endl;
return 0;
}
教训1:
pc1和pc3对应的缓冲区内存单元相同, 会引发问题, 所以需要提供位于缓冲区的两个地址, 比如:
pc1 = new (buffer) JustTesting;
pc3 = new (buffer + sizeof(JustTesting)) JustTesting("Better Idea", 6);
教训2:
如果使用定位new运算符来为对象分配内存, 必须确保其析构函数被调用.
在堆中创建的对象可以使用delete pc2, 但缓冲区中的不能使用delete pc1.
因为delete可以与常规new运算符配合使用, 但不能与定位new运算符配合使用.
delete [] buffer释放了使用常规new运算符分配的整个内存块, 但它没有为定位new运算符在该内存块中创建的对象调用析构函数, 需要显式地调用:
pc3->~JustTesting();
pc1->~JustTesting();
12.7 队列模拟
队列是一种抽象的数据类型(Abstract Data Type, ADT), 可以存储有序的项目序列.
队列: 在队尾添加项目, 在队首删除项目(FIFO).
栈 : 在同一端进行添加和删除(LIFO).
本节定义一个Queue类(第16单将介绍标准模板库类queue).
12.7.1 队列类
- Queue类接口:
class Queue
{
enum {Q_SIZE=10};
private:
//to be developed later
public:
Queue(int qs=Q_SIZE); //构造函数, 指定队列长度, 创建一个空队列
~Queue(); //析构函数
bool isempty() const; //常函数, 队列是否为空
bool isfull() const; //常函数, 队列是否为满
int queuecount() const; //?
bool enqueue(const Item &item); //给队列添加项目, 可以使用typedef来定义Item(见第14章类模板)
bool dequeue(Item &item); //给队列删除项目
}
Queue line1; //一个队列, 最多10个项目(默认值)
Queue line1(20);//一个队列, 最多20个项目
- Queue类的实现
- 如何表示队列数据:
一种方法是使用new动态分配一个数组, 但数组不适合队列操作.
一种方法是使用链表, 每个节点都包含两个信息: 项目信息和下一节点的指针.
struct Node
{
Item item; //存储在node中的数据
struct Node * next; //指向下一个Node的指针
};
- 单向链表, 每个节点都只包含一个指向下一个节点的指针, 最后一个节点的指针设置为NULL/nullptr.
- 让Queue类的一个数据成员指向链表第一个元素, 用于跟踪链表.
- 让Queue类的一个数据成员指向链表最后一个元素, 方便将新项目添加到队尾.
- 让Queue类的数据成员来跟踪队列可存储的最大项目数以及的项目数.
class Queue
{
private:
struct Node //在class中嵌套struct声明, 使Node的作用域为整个class, 不与其它class或全局声明冲突.
{
Item item;
struct Node * next;
};
enum {Q_SIZE = 10};
Node * front; //指向队列Queue的头
Node * rear ; //指向队列Queue的尾
int items; //队列Queue中当前项目数
const int qsize; //队列Queue的最大项目数
...
public:
...
};
- 类方法
Queue::Queue(int qs) //构造函数, 队列开始是空的
{
front = rear = NULL; //队首队尾设置为NULL
items = 0; //项目数为0
qsize = qs; //最大长度从函数参数qs获取(这行代码有问题, 见后描述).
}
上述代码有个问题, qsize是常量, 只能对它初始化(在执行函数体之前, 即创建对象时进行初始化), 不能给它赋值.
c++提供了特殊语法来应对const赋值的操作, 它叫做成员初始化列表(member initializer list).
示例修改如下:
Queue::Queue(int qs): qsize(qs) //构造函数, 带成员初始化列表
{
front = rear = NULL; //队首队尾设置为NULL
items = 0; //项目数为0
}
通常:
1) 初始化对象: 可以是const成员, 也可以是非const成员,
2) 初始化值 : 可以是参数列表中的参数, 也可以是常量(NULL/0等)
3) 只有构造函数可以使用这种初始化列表语法.
4) 对于const成员, 必须使用这种初始化列表语法;
5) 对于被声明为引用的类成员, 也必须使用这种语法(因为引用也只能在创建时进行初始化);
6) 数据成员被初始化的顺序必须与它们出现在类声明的中的顺序相同, 与初始化器中的排列顺序无关.
//构造函数, 带成员初始化列表,
//初始化对象: 可以是const成员, 也可以是非const成员,
//初始化值 : 可以是参数列表中的参数, 也可以是常量(NULL/0等)
Queue::Queue(int qs): qsize(qs), front(NULL), rear(NULL), items(0)
{
}
//引用类型的成员必须在初始化列表中初始化
class Agency{...};
class Agent
{
private:
Agency & belong; //一个引用, 必须在初始化列表中初始化.
};
Agent::Agent(Agency &a): belong(a){...} //在初始化列表中初始化.
成员初始化列表的语法:
Classy是一个类, mem1/mem2/mem3是这个类的成员,
Classy::Classy(int m, int n): mem1(m), mem2(0), mem3(m*n+2)
{
...
}
c++11可以在类内初始化, 但优先级比初始化列表低.
class Classy
{
int mem1 = 10;
const int mem2 = 20;
};