条款11: 为需要动态分配内存的类声明一个拷贝构造函数和一个赋值操作符
|
class
String {
public
:
String(
const
char
*value);
~String();
...
// 没有拷贝构造函数和operator=
private
:
char
*data;
};
String::String(
const
char
*value)
{
if
(value) {
data =
new
char
[
strlen
(value) + 1];
strcpy
(data, value);
}
else
{
data =
new
char
[1];
*data =
'\0'
;
}
}
|
>没有赋值操作符和拷贝构造函数, 可能造成不良后果;
String a(
"Hello"
);
String b(
"World"
);
|
a: data——> "Hello\0"; b: data——> "World\0";
对象a的内部是一个指向包含字符串"Hello"的内存的指针, 对象b则指向一个包含字符串"World"的内存的指针;
如果进行赋值: b = a;
C++会生成并且调用缺省的operator=操作符; 缺省操作符会执行从a的成员到b的成员逐个的赋值操作, 对指针a.data和b.data来说是逐位拷贝;
结果a和b的data都指向了"Hello"的内存;
出现的问题
1)b原来指向的内存将无法删除(泄漏); 2) a和b包含的指针指向同一个字符串, 删除其中一个指针, 其析构函数就会删除掉另一个指针还指向的那块内存;
String a(
"Hello"
);
// 定义并构造 a
{
// 开一个新的生存空间
String b(
"World"
);
// 定义并构造 b
...
b = a;
// 执行 operator=, 丢失 b 的内存
}
// 离开生存空间, 调用b 的析构函数
String c = a;
// c.data 的值不能确定! a.data 已被删除
|
> String c = a的操作也是按位拷贝, 调用了默认生成的拷贝构造函数;
拷贝构造时没有内存泄漏, 因为c.data还没初始化, 当c被a初始化, 指向同一个data, 那么data会在c销毁时删除一次, a销毁时也删除一次;
Note 一般很少对对象进行传值调用;
void
doNothing(String localString) {}
String s =
"The Truth Is Out There"
;
doNothing(s);
|
>传递的String必须从s通过拷贝构造函数进行初始化, 这样localString会有一个s内的指针的拷贝, 当函数执行完毕, localString出栈, 调用析构, s将包含一个指向已经被删除的内存的指针;
Note 用delete 删除一个已经被删除的指针, 其结果是不可预测的.
解决方案: 只要类里有指针, 就要写自己的拷贝构造函数和赋值操作符函数; 拷贝那些被指向的数据结构, 使每个对象有自己的拷贝;
或者采用引用计数的机制去跟踪当前有多少个对象指向某个数据结构. 引用计数的方法复杂, 要求构造和析构函数内部做更多的工作; 但在某些情况下, 可以大量节省内存, 提高速度;(节约new和delete)
Note 对于有些类, 实现拷贝构造和赋值操作符非常麻烦, 而且确信程序中不用做拷贝和赋值操作时, 可以将这些函数声明为private, 只声明, 不用定义; 这样就没人可以调用它们, 也防止编译器自动生成;
例子中的String类中new的地方都使用了[], 配套使用new和delete时要注意使用相同的形式; 只有new用了[], delete才要用[];
条款 12: 尽量使用初始化而不要在构造函数里赋值
template
<
class
T>
class
NamedPtr {
public
:
NamedPtr(
const
string& initName, T *initPtr);
...
private
:
string name;
T *ptr;
}
|
为了避免指针成员对象在拷贝和赋值操作时引起指针混乱, NamedPtr必须实现这些函数:
1) 初始化列表
template
<
class
T>
NamedPtr<T>::NamedPtr(
const
string& initName, T *initPtr )
: name(initName), ptr(initPtr)
{}
|
2) 构造函数内赋值
template
<
class
T>
NamedPtr<T>::NamedPtr(
const
string& initName, T *initPtr)
{
name = initName;
ptr = initPtr;
}
|
两种方法有重大不同;
从实际应用的角度, const和引用数据必须使用初始化, 无法赋值;
如果想让NamedPtr<T>对象不可改变名字或指针成员, 就必须将成员声明为const:
template
<
class
T>
class
NamedPtr {
public
:
NamedPtr(
const
string& initName, T *initPtr);
...
private
:
const
string name;
T *
const
ptr;
};
|
>类的定义必须使用成员初始化列表, 因为const成员只能被初始化, 不能被赋值;
如果NamedPtr<T>对象包含一个名字的引用, 需要在构造函数的初始化列表对这个引用进行初始化; 将名字声明为const string& name, 这样名字成员在类外可以被修改, 在内部是只读;
template
<
class
T>
class
NamedPtr {
public
:
NamedPtr(
const
string& initName, T *initPtr);
...
private
:
const
string& name;
// 必须通过成员初始化列表进行初始化
T *
const
ptr;
// 必须通过成员初始化列表进行初始化
};
|
>即使没有const和引用成员, 使用初始化列表的效率比构造函数赋值也要好; 初始化列表只有一个string成员函数被调用, 而构造函数赋值将有两个调用;
对象的创建分两步:
1) 数据成员初始化; 2) 执行被调用的构造函数体类的操作;
对于有基类的对象, 基类的成员初始化和构造函数体执行发生在派生类之前;
对于NamedPtr类, string对象name的构造函数总是在程序执行到NamedPtr的构造函数之前就已经调用了;
如果NamedPtr没有为name指定初始化参数, string的缺省构造函数会被调用, 当NamedPtr构造函数对name赋值时, 会调用operator=函数, 这样就是两次对string的成员函数的调用: 缺省构造函数和赋值;
如果用成员初始化列表来指定name使用initName初始化, name就会通过拷贝构造函数以一个函数调用的代价被初始化;
即使是简单的string类型, 随着类的变大变复杂, 构造函数也会更加庞大, 那么不必要的函数调用会带来越来越高的代价; 使用初始化列表, 不但可以满足const和引用成员初始化要求, 还可以优化初始化数据的效率;
成员初始化列表初始化的方法总是合法的, 效率更好, 简化了对类的维护(当某个成员被修改成const或引用类型, 不必修改代码);
有一种情况下, 对类数据成员复制比初始化合理: 当有大量固定类型的数据成员要在每个构造函数里以相同的方式初始化的时候;
class
ManyDataMbrs {
public
:
// 缺省构造函数
ManyDataMbrs();
// 拷贝构造函数
ManyDataMbrs(
const
ManyDataMbrs& x);
private
:
int
a, b, c, d, e, f, g, h;
double
i, j, k, l, m;
};
|
>对每个构造函数, 都要写冗长的初始化代码:
ManyDataMbrs::ManyDataMbrs(
/*parameter*/
)
: a(1), b(1), c(1), d(1), e(1), f(1), g(1), h(1), i(0),
j(0), k(0), l(0), m(0)
{ ... }
|
这样容易遗漏, 而且不易维护;
可以将成员初始化列表用private的初始化函数来代替(非const, 非引用成员)
class
ManyDataMbrs {
public
:
//缺省构造函数
ManyDataMbrs();
// 拷贝构造函数
ManyDataMbrs(
const
ManyDataMbrs& x);
private
:
int
a, b, c, d, e, f, g, h;
double
i, j, k, l, m;
void
init();
// 用于初始化数据成员
};
void
ManyDataMbrs::init()
{
a = b = c = d = e = f = g = h = 1;
i = j = k = l = m = 0;
}
ManyDataMbrs::ManyDataMbrs(
/*parameter*/
)
{
init();
//...
}
|
Note static类成员永远不会再构造函数初始化; 静态成员在程序运行过程中只初始化一次;