前言

在这一章里将介绍动态内存和类、在构造函数中使用new时应注意的事、使用指向对象的指针以及复习前面的重要内容。这是类系列的第三部分。

  1. (其一):对象与类
  2. (其二):使用类
  3. (其三):类和动态内存分配
  4. (其四):类继承

动态内存和类

类中的动态内存

我们平时建立数组的时候,更多的情况是往大的内存上设计,这很有可能会造成内存浪费。我们在之前的学习中知道了C语言可以通过malloc/calloc动态分配内存,而C++中则是使用new和delete运算符动态控制。然而,在类中使用这些运算符将导致许多新的编程问题。在这种情况下,析构函数将是必不可少的,而不再是可有可无的。有时候,还必须重载赋值运算符,以保证程序正常运行。

我们通过一个例子引入,我们建立一个StringBad类,里面包括了一个字符串指针和字符串的长度。

#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指针,表示名字,在之后讲使用new动态分配;num_strings成员可以记录所创建的对象数目,它是一个静态存储类变量,即所有的对象共用一个num_strings成员,它们拥有相同的值。静态存储类成员与其他类成员的关系如图:

类方法如下:

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

我们在开头的就初始化了num_strings,不能在类声明中初始化静态成员变量,这是因为声明描述了如何分配内存,但并不分配内存。但如果静态成员是整型或枚举型const,则可以在类声明中初始化。

构造函数中,首先,使用strlen()函数计算字符串的长度,并对len成员进行初始化。接着,使用new分配足够的空间来保存字符串,然后将新内存的地址赋给str成员。此时生成的内存是char[len+1],因为len没有计算结尾的'\0',故需要+1.接着,构造函数使用strcpy()将传递的字符串复制到新的内存中,并更新对象计数。最后输出。

对象中str只是指针,字符串并不保存在对象中,字符串单独保存在堆内存中,对象仅保存了指出到哪里去查找字符串的信息。

析构函数中处理对象过期的事情,当StringBad对象过期时,str指针也将过期。但str指向的内存仍被分配,所以需要使用delete将其释放。删除对象可以释放对象本身占用的内存,但并不能自动释放属于对象成员的指针指向的内存(即能自动释放指针的指针,而不是指针)。因此,必须使用析构函数。在析构函数中使用delete语句可确保对象过期时,由构造函数使用new分配的内存被释放

注意:如果使用new[](包括中括号)来分配内存,则应使用delete[](包括中括号)来释放内存。

我们在函数调用的时候,若要参数是对象,应该使用引用。若不使用引用,会导致析构函数被调用,虽然按值传递可以防止原始参数被修改,但实际上函数已使原始字符串无法识别,导致显示一些非标准字符。


默认成员函数及其改进

因此我们发现这个stringbad类还是有很多缺陷的,很容易导致数据错乱。我们首先需要知道,C++为类自动提供了一些成员函数:

  • 默认构造函数,如果没有定义构造函数;
  • 默认析构函数,如果没有定义;
  • 复制构造函数,如果没有定义;//当使用一个对象来初始化另一个对象时,编译器将自动生成复制构造函数:StringBad(const StringBad &);
  • 赋值运算符,如果没有定义;
  • 地址运算符,如果没有定义。//如this指针

在C++11中提供了另外两个特殊成员函数:移动构造函数(move constructor)和移动赋值运算符(move assignment operator),这里不详细描述了。

接下来在说明默认构造函数和复制构造函数的时候,会顺带复习一下之前的内容。


默认构造函数

如果没有提供任何构造函数,C++将创建默认构造函数。如定义了一个StringBad类,但没有提供任何构造函数,则编译器会提供默认的构造函数,它是一个不接受任何参数,也不执行任何操作的构造函数

StringBad::StringBad() { }

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

StringBad::StringBad() 
{
    str="C++";
    len=3;
}

当然,带参数的构造函数也可以是默认构造函数,但是要求所有参数都有默认值。例如:

StringBad::StringBad(char s[]="C++",int l=3) //带有缺省值
{
    strcpy(str,s);
    len=l;
}

在初始化的时候就可以这么写,例如:StringBad title("hello",5);

上面有两种默认构造函数的形式,但只能有一个默认构造函数。当然这只是默认的构造函数而已,但是并不是说构造函数只能有一个。

为类定义了构造函数后,就必须为它提供默认构造函数。如果提供了非默认构造函数,但没有提供默认构造函数,则对象声明将出错。(因为自动的默认构造函数只在没有构造函数的时候提供)

那么就是说,我们在使用构造函数的时候,一般需要定义一个非默认构造函数和一个自己的默认构造函数。


复制构造函数

复制构造函数用于将一个对象复制到新创建的对象中。也就是说,它用于初始化过程中(包括按值传递参数),而不是常规的赋值过程中。类的复制构造函数原型通常如下:

Class_Name(const Class_Name &);

它接受一个指向类对象的常量引用作为参数。

新建一个对象并将其初始化为同类现有对象时,复制构造函数都将被调用。最常见的情况是将新对象显式地初始化为现有的对象。例如,假设motto是一个StringBad对象,则下面4种声明都将调用复制构造函数:

StringBad ditto(motto);
StringBad metoo=motto;
StringBad metoos=StringBad(motto);
StringBad *pst=new StringBad(motto);

其中前三个都是对象到对象的赋值,它们都会默认调用复制构造函数:StringBad(const StringBad &);

最后一种则是,使用motto初始化一个匿名对象,并将新对象的地址赋给pst指针。

每当程序生成了对象副本时,编译器都将使用复制构造函数。

具体地说,当函数按值传递对象或函数返回对象时,都将使用复制构造函数。按值传递意味着创建原始变量的一个副本。编译器生成临时对象时,也将使用复制构造函数。

此时的第二第三句,它也可以解释为创建了一个临时对象,并把临时对象赋值给metoo和metoos。由于按值传递对象将调用复制构造函数,因此应该按引用传递对象。这样可以节省调用构造函数的时间以及存储新对象的空间。

默认的复制构造函数逐个复制非静态成员(成员复制也称为浅复制或浅拷贝),复制的是成员的值。下图解释了浅拷贝的过程:

但是我们看到静态类变量没有被更新,解决办法是提供一个对计数进行更新的显式复制构造函数。即如果类中包含这样的静态数据成员,即其值将在新对象被创建时发生变化,则应该提供一个显式复制构造函数来处理计数问题。

当然我们考虑到还会出现的问题,例如在复制的时候是拷贝复制,str成员里存的是指针,拷贝后新的对象也是得到了原对象指向的地址,得到的是两个指向同一个字符串的指针。当析构函数函数使用的时候,将释放指向字符串的内存,当释放完后再次释放同一个位置的时候,就容易出错

解决类设计中这种问题的方法是进行深度复制(deep copy)。即复制构造函数应当复制字符串并将副本的地址赋给str成员,而不仅仅是复制字符串地址。这样每个对象都有自己的字符串,而不是引用另一个对象的字符串。调用析构函数时都将释放不同的字符串,而不会试图去释放已经被释放的字符串。

例如可以这么自定义:

StringBad::StringBad(const StringBad &st) 
{
    num_strings++;
    len=st.len;
    str=new char[len+1];//生成新的字符串空间
    std::strcpy(str,st.str);//拷贝新的字符串到新的空间
}

所以我们处理类设计的时候,一些类成员是使用new初始化的、指向数据的指针,而不是数据本身时,必须自定义复制构造函数。下图解释了深度拷贝的过程:


赋值运算符

C++允许类对象赋值,是通过自动为类重载赋值运算符实现的。这种自动赋值运算符的原型如下:

Class_Name & Class_Name::operator=(const Class_Name &);

它接受并返回一个指向类对象的引用。将已有的对象赋给另一个对象时,将使用重载的赋值运算符。与复制构造函数相似,赋值运算符的隐式实现也对成员进行逐个复制。

同样的也会出现深度拷贝前的这个问题,它们也许指向的是同一个字符串,同理解决方式也是进行深度拷贝。

通过返回一个对象,函数可以像常规赋值操作那样,连续进行赋值,例如:s1=s2=s3;

因为返回值是一个指向StringBad对象的引用,此时相当于重载了“=”号,所以可以成立。

我们修改后例如:

StringBad & StringBad::operator=(const StringBad &st) //例如a=b;
{
    if(this==&st)
        return *this;//如果a和b是同一个对象,即自我拷贝,则什么都不做
    delete [] str;//释放自身a的字符串
    len=st.len;
    str=new char[len+1];//新开一个空间
    std::strcpy(str,st.str);
    return *this;//返回a,this指针指向的是a
}

如果不首先使用delete运算符,则上述字符串将保留在内存中。接下来的操作与复制构造函数相似,即为新字符串分配足够的内存空间,然后将赋值运算符右边的对象中的字符串复制到新的内存单元中。赋值操作并不创建新的对象,因此不需要调整静态数据成员num_strings的值。

当然我们最后还有一个地方需要修改,就是那个默认的构造函数,我们开始默认赋值为C++,那么我们其实可以默认为空比较符合我们的习惯,但此时需要注意new的时候的写法:

str=new char[1];

为什么不用str=new char;呢,因为这个语句最后删除的时候是用delete,而前面那种写法是delete [],为了和其他的统一,我们需要写成带有中括号的形式。

或者我们可以直接写成str=0;str=nullptr;,即赋值为空指针,在delete时都可以对空指针进行释放。


在构造函数中使用new时应注意的事

通过前面的例子,我们知道了使用new初始化对象的指针成员时必须特别小心。即

  • 如果在构造函数中使用new来初始化指针成员,则应在析构函数中使用delete。
  • new和delete必须相互兼容。new对应于delete,new[ ]对应于delete[ ]。
  • 如果有多个构造函数,则必须以相同的方式使用new,要么都带中括号,要么都不带。因为只有一个析构函数,所有的构造函数都必须与它兼容。然而,可以在一个构造函数中使用new初始化指针,而在另一个构造函数中将指针初始化为空(0或C++11中的nullptr),这是因为delete(无论是带中括号还是不带中括号)可以用于空指针。
  • 应定义一个复制构造函数,通过深度复制将一个对象初始化为另一个对象。具体地说,复制构造函数应分配足够的空间来存储复制的数据,并复制数据,而不仅仅是数据的地址。另外,还应该更新所有受影响的静态类成员。
  • 应当定义一个赋值运算符,通过深度复制将一个对象复制给另一个对象。即:检查自我赋值的情况,释放成员指针以前指向的内存,复制数据而不仅仅是数据的地址,并返回一个指向调用对象的引用。

有关返回对象的说明

当成员函数或独立的函数返回对象时,有几种返回方式可供选择。可以返回指向对象的引用、指向对象的const引用或const对象。

返回指向const对象的引用

使用const引用的常见原因是旨在提高效率,但对于何时可以采用这种方式存在一些限制。如果函数返回(通过调用对象的方法或将对象作为参数)传递给它的对象,可以通过返回引用来提高其效率

即例如下面两种方法:

class_name Max(const class_name &a, const class_name &b)
{
    return (a.value>b.value)?a:b;
}
const class_name & Max(const class_name &a, const class_name &b)
{
    return (a.value>b.value)?a:b;
}

首先,返回对象将调用复制构造函数,而返回引用不会。第一种返回的是对象,第二种返回的是引用。还有就是a和b都被声明为const引用,因此返回类型必须为const。一般我们会使用效率更高的第二种方法。


返回指向非const对象的引用

两种常见的返回非const对象情形是,重载赋值运算符以及重载与cout一起使用的<<运算符。前者这样做旨在提高效率,而后者必须这样做。

在赋值运算符中返回类型不是const,因为方法operator=()返回一个指向原对象的引用,可以对其进行修改。

而在<<运算符中,返回类型必须是ostream &,而不能仅仅是ostream,也不是const ostream &。

返回变量

如果被返回的对象是被调用函数中的局部变量,则不应按引用方式返回它。


指向对象的指针

我们在遍历数组的时候,时常会用到指向对象的指针,我们可以使用new来为整个对象分配内存,例如:

String * fav=new string(saying[i]);

这不是为要存储的字符串分配内存,而是为对象分配内存。也就是说,为保存字符串地址的str指针和len成员分配内存(程序并没有给num_string成员分配内存,这是因为num_string成员是静态成员,它独立于对象被保存)。创建对象将调用构造函数,后者分配用于保存字符串的内存,并将字符串的地址赋给str。然后,当程序不再需要该对象时,使用delete删除它。

当然除了这种特殊的写法,我们还可以用最简单的方法,例如:string * gstr=new string;,还可以将指针初始化为指向已有的对象:string * first=&saying[0];还有刚才的,可以使用new来初始化指针,这将创建一个新的对象:string *fav=new string(saying[i]); 或 string *last=new string("last");


总结

至此,我们在这一章学习了很多技术,在这里复习一下构造函数使用new的类:

  • 对于指向的内存是由new分配的所有类成员,都应在类的析构函数中对其使用delete,该运算符将释放分配的内存。
  • 如果析构函数通过对指针类成员使用delete来释放内存,则每个构造函数都应当使用new来初始化指针,或将它设置为空指针。
  • 构造函数中要么使用new [],要么使用new,而不能混用。如果构造函数使用的是new[],则析构函数应使用delete [];如果构造函数使用的是new,则析构函数应使用delete。
  • 应定义一个分配内存(而不是将指针指向已有内存)的复制构造函数。这样程序将能够将类对象初始化为另一个类对象。这种构造函数的原型通常如下:Class_Name(const Class_Name &)
  • 应定义一个重载赋值运算符的类成员函数,其函数定义如下(其中c_pointer是c_name的类成员,类型为指向type_name的指针)。

后记

好了,至此C++类专题中第三节就结束了。下一章 类继承 见~

Reference:《C++ Primer Plus 6th》第12章

这里的一切都有始有终,却能容纳所有的不期而遇和久别重逢。
最后更新于 2024-01-14