文章目录
前言
在这一章里将介绍动态内存和类、在构造函数中使用new时应注意的事、使用指向对象的指针以及复习前面的重要内容。这是类系列的第三部分。
动态内存和类
类中的动态内存
我们平时建立数组的时候,更多的情况是往大的内存上设计,这很有可能会造成内存浪费。我们在之前的学习中知道了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章
Comments NOTHING