[C++学习笔记] 第 9 章 类和动态内存分配

第 9 章 类和动态内存分配

9.1 动态内存和类

​ C++ 使用 newdelete 来动态控制内存。在类中动态分配内存时,析构函数将变得必不可少。有时候,还必须重载赋值运算符,以保证程序正常运行。

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++ 自动提供了下面这些成员函数:

  • 默认构造函数,如果没有定义构造函数
  • 默认析构函数,如果没有定义
  • 复制构造函数,如果没有定义
  • 赋值运算符,如果没有定义
  • 地址运算符,如果没有定义
  1. 默认构造函数

    如果没有提供任何构造函数,C++ 将创建默认构造函数,该构造函数不接受任何参数,也不执行任何操作,在创建对象时会使对象内的类成员值是未知的。

    如果定义了构造函数,C++ 将不会定义默认构造函数。如果希望在创建对象时不显式地对它进行初始化,则必须显式地定义默认构造函数。这种构造函数没有任何参数,但可以使用它来设置特定的值。

    带参数的构造函数也可以是默认构造函数,只要所有参数都有默认值。注意,只能有一个默认构造函数。

  2. 复制构造函数

    复制构造函数用于将一个对象复制到新创建的对象中,其原型通常如下:

    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;。这里复制的并不是字符串,而是一个指向字符串的指针。也就是说,这会造成 sailorsports 都指向同一片内存,当其中一个对象 a 的析构函数被调用时,另一个对象 b 指向的内存也会被释放。当再次使用 b 对象时,有可能会使用已经释放过的内存,从而导致问题。此外,b 的析构函数调用时,将会导致同一片内存被释放两次,这可能会导致程序异常终止。

  1. 定义一个显式复制构造函数以解决问题

    解决这种问题的方法是进行深复制。也就是说,复制构造函数应当复制字符串并将副本的地址赋给 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 &);
  1. 赋值运算符的功能以及何时使用它

    将已有的对象赋值给另一个对象时,将使用重载的赋值运算符:

    StringBad headline1("Celery Stalks at Midnight");
    ...
    StringBad knot;
    knot = headline1;
    

    初始化对象时,并不一定会使用赋值运算符:

    StringBad metoo = knot;
    

    这里 metoo 是一个新创建的对象,被初始化为 knot 值,因此使用复制构造函数。然而,具体实现也可能分两步来处理这条语句:使用复制构造函数创建一个临时对象,然后通过赋值将临时对象的值复制到新对象中。这就是说,初始化总是会调用复制构造函数,而使用 = 运算符时也允许调用赋值运算符。

    与复制构造函数类似,赋值运算符的隐式实现也对成员进行逐个复制(潜复制)。如果成员本身就是类对象,则程序将使用为这个类定义的赋值运算符来复制该成员,但静态成员不受影响。

  2. 赋值的问题出在哪里

    以下面程序为例:

    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;
}

​ 假设 answerString 对象,则代码 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,但不能访问 strlen

9.2.5 进一步重载赋值运算符

​ 考虑以下代码:

String name;
char temp[40];
cin.getline(temp, 40);
name = temp;

​ 上述代码是这样工作的:

  1. 程序使用构造函数 String (const char *) 来创建一个临时的 String 对象,其中包含 temp 中的字符串副本。

  2. 程序将使用 operator=(const String &) 函数将临时对象中的信息复制到 name 中。

  3. 程序调用析构函数删除临时对象。

    因为这里是赋值,而不是初始化,因此使用的不是构造函数,造成这样的工作方式。为提高效率,最简单的方法就是重载赋值运算符,使之能够直接使用常规字符串,这样就不用创建和删除临时对象了:

    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

  • newdelete 必须相互兼容。new 对应于 deletenew[] 对应于 delete[]

  • 如果有多个构造函数,则必须使用相同的方式使用 new,要么都带中括号,要么都不带。因为只有一个析构函数,所有的构造函数都必须与它兼容。然而,可以在一个构造函数中使用 new 初始化指针,而在另一个构造函数中将指针初始化为空( 0 0 0nullptr ),这是因为 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);	//返回引用

​ 这里有三点需要说明。首先返回对象将调用复制构造函数,而返回引用不会。其次,引用指向的对象应该在调用函数执行时存在。最后,v1v2 都被声明为 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,但并没有为 pc1pc3 调用析构函数,这样可能会导致内存泄漏。解决方案是,显式地为使用定位 new 运算符创建的对象调用析构函数:

pc3->~JustTesing();
pc1->~JustTesing();
delete[] buffer;

​ 正常情况下析构函数会自动调用,此处是一个例外,这是需要显式调用析构函数的少数几种情形之一。需要注意的一点是正确的删除顺序。对于使用定位 new 运算符创建的对象,应按照与创建对象相反的顺序进行删除,原因在于晚创建的对象可能依赖于早创建的对象。另外,仅当所有对象都被销毁后,才能释放用于存储这些对象的缓冲区:

总结:

  1. 对于普通的 new,直接使用 delete 释放内存即可;
  2. 对于定位 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 数据成员
  • 必须用这种格式来初始化引用数据成员

​ 数据成员被初始化的顺序与它们出现在类声明中的顺序相同,与初始化器中的排列顺序无关。

  • 4
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值