第十二章 类和动态内存分配
本章内容包括:
- 对类成员使用动态内存分配
- 隐式和显式复制构造函数
- 隐式和显式重载重载赋值运算符
- 在构造函数中使用new所必须完成的工作
- 使用静态类成员
- 将定位new运算符用于对象
- 使用指向对象的指针
- 实现队列抽象数据类型
动态内存和类:
C++使用new和delete运算符来动态控制内存。遗憾的是,在类中使用这些运算符将导致许多新的编程问题。在这种情况下,析构函数是必不可少的。有时候,还必须重载赋值运算符,以保证程序正常运行。
复习示例和静态类成员:
stringbad.h
#ifndef D1_STRINGBAD_H
#define D1_STRINGBAD_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);
};
#endif //D1_STRINGBAD_H
stringbad.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 = 3;
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;
}
首先:
int stringbad::num_strings = 0;
这条语句将静态成员num_strings的值初始化为零。注意,不能在类声明中初始化静态变量,这是因为声明中描述了如何分配内存,但并不分配内存。对于静态成员变量,可以在类声明之外使用单独的语句来进行初始化,这是因为静态类成员是单独存储的,而不是对象的组成部分。
初始化是在方法文件中,而不是在类声明文件中进行的,这是因为类声明位于头文件中,如果头文件被多个文件包含,变量就会被多次初始化,引发错误。
对于不能在类声明中初始静态数据成员的一种例外情况是const
static const int num_strings = 12;
main.cpp
#include <iostream>
#include "stringbad.h"
using std::cout;
void callme1(stringbad &);
void callme2(stringbad);
int main(){
using std::endl;
{
cout << "Starting an inner block.\n";
stringbad headline1("Celerty Stalks at Midnight");
stringbad headline2("Lettuce Prey");
stringbad sports("Spinach Leaves Boel 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";
}
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";
}
结果
Starting an inner block.
1: "Celerty Stalks at Midnight" object created
2: "Lettuce Prey" object created
3: "Spinach Leaves Boel for Dollars" object created
headline1: Celerty Stalks at Midnight
headline2: Lettuce Prey
sports: Spinach Leaves Boel for Dollars
String passed by reference:
"Celerty Stalks at Midnight"
headline1: Celerty Stalks at Midnight
String passed by value:
"Lettuce Prey"
"Lettuce Prey" object deleted, 2 left (在这之后异常了)
headline2:
Initialize one object to another:
sailor: Spinach Leaves Boel for Dollars
Assign one object to another:
3: "C++" default object created
knot: Celerty Stalks at Midnight
Exiting the block.
"Celerty Stalks at Midnight" object deleted, 2 left
"Spinach Leaves Boel for Dollars" object deleted, 1 left
" �" object deleted, 0 left
*** Error in `/home/luslin/c++/d1/cmake-build-debug/d1': double free or corruption (fasttop): 0x00000000011a9080 ***
在callme2(headline2)时,按值传递headline2,结果表明,delete [] 两次删除一个内存区域。
3: “C++” default object created 这句话表明 stringbad sailor = sports; 这句话调用了stringbad::stringbad()
特殊成员函数:
stringbad 类的问题是由特殊的成员函数引起的,这些成员函数是自动定义的。就stringbad而言,这些函数的行为与类设计不符。具体来说,C++中,提供了下面这些成员函数:
- 默认够构造函数,如果没有定义构造函数。
- 默认析构函数,如果没有定义。
- 复制构造函数,如果没有定义。
- 赋值运算符,如果没有定义。
- 地址运算符,如果没有定义。
更准确地说,编译器将生成上述最后三个函数的定义。例如,如果将一个对象赋值给另一个对象,编译器将提供赋值运算符的定义。
结果表明,stringbad类中的问题是由隐式复制构造函数和隐式复制构造函数和隐式赋值运算符引起的。
隐式地址运算符返回调用对象的地址。
默认构造函数:
如果没有提供任何构造函数,c++将创建默认构造函数。例如,定义了一个Klunk类,但没有提供任何构造函数,则编译器提供下面默认构造函数:
Klunk::Klunk(){}
编译器提供了一个不接受任何参数,也不执行任何操作的构造函数。这是因为创建对象时总会调用构造函数。
默认构造函数使Klunk类似于一个常规的自动变量,也就是说,它的值在初始化时是未知的。
如果定义了构造函数,C++将不会定义默认构造函数。如果希望在创建对象时不显式地对它进行初始化,则必须显式地定义构造函数。这种构造函数没有参数。
Klunk::Klunk(){ ct = 0;}
带参数的构造函数也可以是默认构造函数,只要所有参数都有默认值
Klunk(int n = 12){ ct = n;}
但,只能有一个默认构造函数。防止二义性
Klunk(){ct=0;}
Klunk(int n =0) {ct = n;}
不可同时存在
复制构造函数:
复制构造函数用于将一个对象复制到新创建的对象中。也就是说,它用于初始化过程中(包括按值传递),而不是常规的赋值过程中,类的复制构造原型如下:
class_name(const class_name &);
它接受一个指向类对象的常量引用作为参考
何时调用(motto是一个stringbad对象):
- stringbad ditto(motto);
- stringbad * pstringbad = new stringbad(motto)
- stringbad metoo = motto;
- stringbad also = stringbad(motto)
其中,后两种声明可能会使用复制构造函数直接创建metoo和also,也可能使用复制构造函数生成一个临时对象,然后将临时对象的内容赋给metoo 和also,这取决于具体实现。第二种使用motto初始化一个匿名对象,并将新对象的地址赋给pstringbad 指针。
每当程序生成对象副本时,编译器都将使用赋值构造函数。具体地说,当函数按值传递对象或返回对象。记住,按值传递意味着创建原始变量的一个副本。编译器生成临时对象时,也将使用复制构造函数。例如,将3个Vector对象相加时,编译器可能生成临时的Vector对象来保存中间结果。何时生成临时对象随编译器而异。
由于按值传递对象将调用复制构造函数,因此应该按引用传递对象。这样可以节省调用构造函数时间及存储新对象的空间
默认的复制构造函数功能:
默认的构造函数逐个复制非静态成员(成员复制也称为浅复制),复制的是成员的值。在上面的程序中,下面的语句
stringbad sailor = sports;
与
stringbad sailor;
sailor.str = sports.str;
sailor.len = sports.len;
是等效的
如果成员本身就是类对象,则将使用这个类的复制构造函数来复制该成员对象。
在上面的程序中,由于复制构造函数将地址复制,是浅拷贝,导致对象释放时调用了两次delete[],导致失败。
可以定义一个复制构造函数来解决这个问题
stringbad::stringbad(const stringbad &sd) {
num_strings++;
str = new char[sd.len + 1];
std::strcpy(str,sd.str);
len = sd.len;
cout << num_strings << ": \"" << str << "\" object copy created\n";
}
其他问题:赋值运算符:
并不是程序中所有问题都可以归咎于默认的复制构造函数,还有默认赋值运算符。默认原如下:
class_name & class_name::operator=(const class_name &)
在上面程序中加上
headline1 = headline2;
也会导致运行失败。原因是默认赋值运算符使用的也是浅拷贝,导致同一地址被释放两次
解决赋值问题:
- 由于目标对象可能引用了以前分配的数据,所以函数应使用delete[]先释放这些数据。
- 函数应当避免将对象赋给自身;否则,给对象重新赋值前,释放内存操作可能删除对象的内容。
- 函数返回一个指向调用对象的引用
因此,赋值运算符函数可以这样:
stringbad & stringbad::operator=(const stringbad & st) {
if (this == &st){
return *this;
}
delete[] str;
len = st.len;
str = new char[len + 1];
strcpy(str,st.str);
return *this;
}
静态成员函数:
可以将成员函数声明为静态的(函数声明必须包含关键字static,但如果函数定义是独立的,则其中不能包含关键字static),这样有两个重要后果:
- 不能通过对象调用静态成员函数;实际上,静态成员函数没有this指针。如果静态成员函数是在公有部分声明的,则可以使用类名和作用域解析运算符来调用它。
- 静态成员函数不与特定的对象关联,只能使用静态数据成员。
进一步重载赋值运算符:
假设要将常规字符串复制到string对象中。例如,假设使用getline()读取了一个字符串,并要将这个字符串放置到stringbad 对象中,前面的定义的类方法可以让我们这样写
stringbad name;
char temp[40];
cin.getline(temp,40);
name = temp;
但如果经常这么做,这将不是一个理想的解决方案,因为最后一条语句这样执行:
- 使用构造函数stringbad(const char *s) 创建一个临时对象
- 使用stringbad & operator=(const stringbad &st) 赋值对象
- 调用析构函数删除临时对象
为了减去创建和删除临时对象,可以定义赋值char * s的赋值函数
stringbad & stringbad::operator=(char *s) {
delete[] str;
len = std::strlen(s);
str = new char[len + 1];
strcpy(str,s);
return *this;
}
还可以实现operator>>()重载来直接读到对象中
改进后的String类:
String.h
#ifndef D1_STRING_H
#define D1_STRING_H
#include <iostream>
class String {
private:
char * str;
int len;
static int nums_strings;
static const int CINLIM = 80;
public:
String(const char *s);
String();
String(const String &s);
~String();
int length() const { return len; };
String &operator=(const char *s);
String &operator=(const String & s);
char &operator[](int n);
const char &operator[](int n) const;
friend std::ostream &operator<<(std::ostream &os, const String &string);
friend bool operator<(const String &s1,const String &s2);
friend bool operator>(const String &s1,const String &s2);
friend bool operator==(const String &s1,const String &s2);
friend std::istream &operator>>(std::istream &is, String &s);
static int HowMany();
};
#endif //D1_STRING_H
String.cpp
#include "String.h"
#include <cstring>
int String::nums_strings = 0;
int String::HowMany() {
return nums_strings;
}
String::String(const char *s) {
len = strlen(s);
str = new char[len+1];
strcpy(str,s);
nums_strings++;
}
String::String() {
len = 0;
str = new char[1];
str[0] = '\0';
nums_strings++;
}
String::String(const String &s) {
len = s.len;
str = new char[len+1];
strcpy(str,s.str);
nums_strings++;
}
String::~String() {
--nums_strings;
delete [] str;
}
String &String::operator=(const char *s) {
len = strlen(s);
delete[] str;
str = new char[len+1];
strcpy(str,s);
return *this;
}
String &String::operator=(const String &s) {
if (*this == s){
return *this;
}
len = s.len;
delete[] str;
str = new char[len+1];
strcpy(str,s.str);
return *this;
}
char &String::operator[](int n) {
return str[n];
}
const char &String::operator[](int n) const {
return str[n];
}
std::ostream &operator<<(std::ostream &os, const String &s) {
os << s.str;
return os;
}
bool operator<(const String &s1, const String &s2) {
return (strcmp(s1.str,s2.str) < 0);
}
bool operator>(const String &s1, const String &s2) {
return s2 < s1;
}
bool operator==(const String &s1, const String &s2) {
return (strcmp(s1.str,s2.str) == 0);
}
std::istream &operator>>(std::istream &is, String &s) {
char temp[String::CINLIM];
is.get(temp,String::CINLIM);
if (is){
s = temp;
}
while (is && is.get() != '\n') continue;
return is;
}
main.cpp
#include <iostream>
#include "String.h"
const int ArSize = 10;
const int MaxLen = 81;
int main(){
using std::cout;
using std::cin;
using std::endl;
String name;
cout << "Hi, what's your name?\n";
cin >> name;
cout << name << ",please enter up to " << ArSize << " short sayings<empty line to quit>:\n";
String saying[ArSize];
char temp[MaxLen];
int i;
for (i =0;i<ArSize;i++){
cout << i + 1 << ":";
cin.get(temp,MaxLen);
while (cin && cin.get() != '\n') continue;
if (!cin || temp[0] == '\0') {
break;
} else {
saying[i] = temp;
}
}
int total = i;
if (total > 0){
cout << "Here are your sayings:\n";
for (i=0;i<total;i++){
cout << saying[i][0] << ": " << saying[i] << endl;
}
int shortest = 0;
int first = 0;
for (i=1;i<total;i++){
if (saying[i].length() < saying[shortest].length()) shortest = i;
if (saying[i] < saying[first]) first = i;
}
cout << "Shortest saying: " << saying[shortest] << endl;
cout << "First alphabetically: " << saying[first] << endl;
cout << "This program used: " << String::HowMany();
}
return 0;
}
在构造函数中使用new时应注意的事项:
使用new初始化对象的指针成员时必须特别小心:
- 如果在构造函数中使用new来初始化指针成员,应该在析构函数中使用delete。
- new和delete必须相互兼容。new对应于delete,new[] 对应于delete[]。
- 如果有多个构造函数,则必须使用相同的方式使用new,要么都带中括号,要么都不带。因为只有一个析构函数,所有的构造函数都必须与它兼容。然而,可以在一个构造函数中使用new初始化指针,而在另一个构造函数中将指针初始化为空,这是因为delete(或delete[])都可用于空指针。
- 应定义一个复制构造函数,通过深度复制将一个对象初始化为另一个对象。
- 应当定义一个赋值运算符,通过深度复制将一个对象复制给另一个对象。
应该与不该:
下面包含了两个不正确的示例以及一个良好的构造函数示例:
String::String(){
str = "default string"; // err:no new[]
len = std::strlen(str);
}
String::String(const char *s){
len = std::strlen(str);
str = new char; // err: no new[]
std::strcpy(str,s); // err: no room
}
String::String(const char *s){
len = std::strlen(str);
str = new char[len+1];
std::strcpy(str,s);
}
包含类成员的类逐成员复制:
class Magazine{
private:
String title;
string publisher;
...
}
String 和 string 类都使用动态内存分配,这是否意味着需要为Magazine类编写复制构造函数和赋值运算符?不,至少对这个类来说是不需要的默认的逐成员复制和赋值行为有一定的智能。如果将一个Magazine类对象复制或赋值给另一个Magazine类对象,逐成员复制将使用成员类型定义的复制或赋值函数。
有关返回对象的说明:
当成员函数或独立的函数返回对象时,有几种返回方式可供选择。可以返回指向对象的引用、指向对象的const引用或const对象
返回指向const对象的引用:
使用const引用的常见原因是旨在提高效率,但对于何时可以采用这种方式存在一些限制。如果函数返回(通过调用对象的方法或将对象作为参数)传递给它的对象,可以通过返回引用来提高其效率。例如
Vector force1(50,60);
Vector force2(10,70);
Vector max;
max = Max(forc1,forc2);
下面两种实现都是可行的:
Vector Max(const Vector &v1,const Vector &v2){
if (v1.magval() > v2.maagval()){
return v1;
} else {
return v2;
}
}
const Vector & Max(const Vector &v1,const Vector &v2){
if (v1.magval() > v2.maagval()){
return v1;
} else {
return v2;
}
}
首先,返回对象对象调用复构造函数,而返回引用不会。因此,第二个版本做的工作更少,效率更高。其次,引用指向force1或force2,它们都是在调用函数中定义的,因此满足这种条件。第三,v1,v2都被声明为const引用,因此返回类型必须为const,这样才匹配。
返回指向非const对象的引用:
两种常见的返回非const对象情形是,重载赋值运算符以及重载与cout一起使用的<<运算符。前者这样旨在提高效率,而后者必须这样做。
返回对象:
如果返回的对象是被调用函数中的局部变量,则不应按引用的方式返回它,因为在被调用函数执行完毕时,局部对象将调用其析构函数。因此,当控制权回到调用函数时,引用指向的对象将不再存在
使用指向对象的指针:
C++程序经常使用指向对象的指针,改写上面main.cpp中使用数组索引获取最长和首字母最小的方式,改为使用两个指针指向:
int total = i;
if (total > 0){
cout << "Here are your sayings:\n";
for (i=0;i<total;i++){
cout << saying[i][0] << ": " << saying[i] << endl;
}
String * shortest = &saying[0];
String * first = &saying[0];
for (i=1;i<total;i++){
if (saying[i].length() < shortest->length()) shortest = &saying[i];
if (saying[i] < *first ) first = &saying[i];
}
cout << "Shortest saying: " << *shortest << endl;
cout << "First alphabetically: " << *first << endl;
cout << "This program used: " << String::HowMany();
}
指针和对象小结:
-
使用常规表示法来声明指向对象的指针:
String * glamor;
-
可以将指针初始化指向已有对象:
String * first = &saying[0];
-
可以使用new来初始化指针,这将创建一个新的对象
String * favorite = new String(saying[0])
-
对类使用new将调用相应的类构造函数来初始化新创建的对象
String *gleep = new String; // default constructor String *glop = new String("12")
-
可以使用->运算符通过指针访问类方法
-
可以通过对对象指针应用解除引用运算符*来获取对象。
再谈定位new运算符:
定位运算符能够在分配内存时指定内存位置。
placenew1.cpp
#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);
cout << "Memory block address:\n" << "buffer: " << (void *)buffer << " heap: " << pc2 << endl;
cout << "Memorry contents:\n";
cout << pc1 << ": "; pc1->show();
cout << pc2 << ": "; pc2->show();
JustTesting *pc3, *pc4;
pc3 = new(buffer)JustTesting("Bad Idea", 6);
pc4 = new JustTesting("Heap2",10);
cout << "Memorry contents:\n";
cout << pc3 << ": "; pc3->show();
cout << pc4 << ": "; pc4->show();
delete pc2;
delete pc4;
delete[] buffer;
cout << "Done\n";
return 0;
}
结果
Just Testing constructed
Heap1 constructed
Memory block address:
buffer: 0x224ec20 heap: 0x224f240
Memorry contents:
0x224ec20: Just Testing, 0
0x224f240: Heap1, 20
Bad Idea constructed
Heap2 constructed
Memorry contents:
0x224ec20: Bad Idea, 6
0x224f270: Heap2, 10
Heap1 destroyed
Heap2 destroyed
Done
程序在使用定位符时存在两个问题,首先,在创建第二个对象时,定位new运算符使用一个新对象来覆盖用于第一对象的内存单元。显然,如果类动态地为其成员分配内存,这将引发问题。
其次,delete 用于pc2和pc4时,将自动调用为pc2和pc4指向的对象调用析构函数;然而,将delete[]用于buffer时,不会为使用定位new运算符创建的对象调用析构函数。
在buffer中使用不同的内存单元。程序员需要提供两个位于缓冲区不同的地址,并确保两个内存单元不会重叠。例如,可以这样定义
pc3 = new(buffer + sizeof(*pc1))JustTesting("Bad Idea", 6);
第二个教训是,如果使用定位new运算符来为对象分配内存,必须保证其析构函数被调用。但如何确保呢?对于在堆中创建的对象,可以:delete pc2 但是不能这样做 delete pc1; delete pc3;
原因在于delete可与常规new运算符配合使用,但不能与定位new运算符配合使用。例如,指针pc3没有收到new运算符返回的地址,因此delete pc3将导致运行阶段错误。在另一方面,指针pc1指向的地址与buffer相同,但buffer是使用new[]初始化的,因此必须使用delete[]而不是delete来释放。即使buffer是使用new[]而不是new初始化的,delete pc1也将释放buffer,而不是pc1。这是因为new/delete系统知道已分配的512字节块buffer,但对定位new运算符对该内存块做了何种处理一无所知。
delete[] buffer释放了使用常规new运算符分配的整个内存块,但它没有为定位new运算符在该内存块中创建的对象调用析构函数,解决方法是,显式地调用析构函数
pc3->~JustTesting();
pc1->~JustTesting();
需要注意的是删除顺序,对于使用定位new运算符创建的对象,应以与创建顺序相反的顺序进行删除。原因在于,晚创建的对象可能依赖于早创建的对象。另外,仅当所有对象销毁后才能释放用于存储这些对象的缓存区。
队列模拟:
Queue.h
#ifndef D1_QUEUE_H
#define D1_QUEUE_H
template <class Item>
class Queue {
enum {Q_SIZE = 10};
struct Node {Item item; struct Node *next;};
Node *front;
Node *end;
int items;
const int qsize;
public:
Queue(const Queue &queue);
Queue &operator=(const Queue &queue);
Queue(int qs= Q_SIZE);
~Queue();
bool isempty() const;
bool isfull() const;
int queuecount() const;
bool add(const Item &item);
bool get(Item &item);
};
template<class Item>
bool Queue<Item>::get(Item &item) {
if (front == nullptr) return false;
item = front->item;
items--;
Node * temp = front;
front = front->next;
delete temp;
if (items == 0) end = nullptr;
return true;
}
template<class Item>
bool Queue<Item>::add(const Item &item) {
if (isfull()) return false;
Node *node = new Node;
node->item = item;
node->next = nullptr;
items++;
if (front == nullptr){
front = node;
} else {
end->next = node;
}
end = node;
return true;
}
template<class Item>
int Queue<Item>::queuecount() const {
return items;
}
template<class Item>
bool Queue<Item>::isfull() const {
return items == qsize;
}
template<class Item>
bool Queue<Item>::isempty() const {
return items == 0;
}
template<class Item>
Queue<Item>::~Queue() {
Node * temp;
while (front != nullptr){
temp = front;
front = front->next;
delete temp;
}
}
template<class Item>
Queue<Item>::Queue(int qs) :qsize(qs){
front = end = nullptr;
items = 0;
}
template<class Item>
Queue<Item>::Queue(const Queue &queue) :qsize(queue.qsize) {
front = end = nullptr;
items = 0;
Node *temp = queue.front;
while (temp != nullptr){
this->add(temp->item);
temp = temp->next;
}
}
template<class Item>
Queue<Item> &Queue<Item>::operator=(const Queue &queue) {
delete front;
delete end;
front = end = nullptr;
items = 0;
Node *temp = queue.front;
while (temp != nullptr){
this->add(temp->item);
temp = temp->next;
}
}
#endif //D1_QUEUE_H