Abstract
本章主要要介绍如何使用 动态内存 来创建和使用类,涉及到 constructor 和 destructor 相应编写技巧。
- 特殊成员函数
- 类返回值
- 类指针 this
- 初始化列表
1. 特殊成员函数
1.1 An example
In the Book, using 一个例子来引出需要解决和注意的问题,我将这个例子进行了简化,同时也会在 Blog 中展示在汇编层面的代码是什么样的。
代码由 12.1.h 和 12.1.cpp 两个文件 consist of,较为简单,唯一值得一说的便是在 Chapter 10 中介绍过的,静态成员,即通过 static 关键字进行修饰的变量,该变量为 class 中所有的对象所共用,存储在全局变量区。
- 12.1.h
#ifndef CHAPTER_12_1_H
#define CHAPTER_12_1_H
#include <iostream>
class StringBad {
// save the pointer of the string which will use new to allocate memory
char* str;
// length of the string
int len;
// a counter to save the number of string, use for debug
static int nNumStr;
public:
StringBad(const char* s);
StringBad();
~StringBad();
friend std::ostream& operator<<(std::ostream&, const StringBad&);
};
#endif // !12_1_H
- 12.1.cpp
#include "12.1.h"
using std::cout;
// initial the static member in the class StringBad
int StringBad::nNumStr = 0;
StringBad::StringBad(const char* s) {
len = strlen(s);
str = new char[len + 1];
strcpy(str, s);
nNumStr++;
cout << "Create String:\n" << "\t" << str << " nNumber is " << nNumStr << ".\n";
return;
}
StringBad::StringBad() {
len = 3;
str = new char[len + 1];
strcpy(str, "C++");
nNumStr++;
cout << "Create Default string. " << "nNumber is " << nNumStr << ".\n";
return;
}
StringBad::~StringBad() {
cout << "Destruct String:\n" << "\t\"" << str << "\"";
delete[] str;
nNumStr--;
cout << " nNumStr is " << nNumStr << ".\n";
return;
}
std::ostream& operator<<(std::ostream& os, const StringBad& sbStr) {
os << sbStr.str << "\n";
return os;
}
void TransByRef(StringBad& sbStr) {
cout << "String Transfer by Reference:\n";
cout << "\t" << sbStr;
return;
}
void TransByVal(StringBad sbStr) {
cout << "String Transfer by Value:\n";
cout << "\t" << sbStr;
return;
}
int main() {
// initial two StringBads
StringBad sbA("Naruto");
StringBad sbB("Sasuke");
TransByRef(sbA);
TransByVal(sbA);
StringBad sbC;
sbC = sbB;
cout << "sbA is " << sbA << "\n";
cout << "sbB is " << sbB << "\n";
cout << "sbC is " << sbC << "\n";
return 0;
}
Below is 上面代码的运行结果,可以看到在调用完 TransByVal 函数之后,运行了一次 ~StringBad 析构函数。将 sbA 中的指向的字符串释放了。
后面 sbC 通过默认构造函数进行创建时可能申请到了 sbA 一开始使用的内存导致后面在输出 sbA 时的内容变为了 unexpected 默认字符串。
最后是运行结束后进行析构函数的调用,当调用到释放 sbB 时就出现了异常导致程序终止(为什么是 sbB 因为编译器按照栈的方式添加的析构函数调用)。
- sbA unexpected 问题
TransByVal 函数直接传递参数,会在栈上生成一个临时变量,相当于有一个构造函数完成了这个工作。
Before TransByVal 函数返回,就需要调用相应的析构函数来将这个变量进行释放。由于传递参数时采用的是 shallow copy 所以,会将 sbA.str 指向的内存释放,导致后续 sbA.str 变为一个 unexpected 的默认字符串,变为默认字符串只是一个巧合,与系统的内存分配策略相关。
- 析构异常
main 函数结束前会将栈中存储的 StringBad 进行析构,也就由 compiler 添加 calling 析构函数 implicitly。上面执行到 sbB 时发生异常,导致程序终止,这个异常产生的原因同样是 shallow copy。由于前面的复制操作sbC = sbB
导致 when 析构 sbC 时就将 sbB.str 指向的内存释放了。
1.2 问题归因
StringBad 类的问题是由特殊成员函数引起的,这些成员函数是自动定义的,as for StringBad,函数的 behavior 与类的 design 不符。C++ 自动提供了 below 成员函数 when using without definition。
- default constructor
- default destructor
- copy constructor
- assign operator(=)
- address operator (&)
上述 example 中出现的问题是由 implicit 复制构造函数 和 赋值运算符 引起的。
1.3 解决方案
定义 copy constructor 和 assign operator explicitly,并在其中使用 deep copy 取代 shallow copy。
- 复制构造函数
复制构造函数 的编写没有什么太多需要强调的,就将 class 中需要使用 new 或 new[] 的成员也需要使用相应的操作申请内存。
一般函数 Prototype 为 ClassName(const ClassName&);。
StringBad::StringBad(const StringBad& sbStr) {
len = sbStr.len;
// allocate memory for new string
str = new char[len + 1];
// copy the string to new memory complete deep copy
strcpy(str, sbStr.str);
nNumStr++;
cout << "Copy constructor create. " << "nNumber is " << nNumStr << ".\n";
return;
}
- 赋值运算符
编写 赋值运算符 重载时有一些注意步骤。
- 首先需要排除掉给自身赋值的操作。
- 接着原先通过 new 或 new[] 申请的内存进行释放。
- 最后进行 deep copy。
- 函数的返回值也必须是 ClassName& 这样才能使得连续赋值可行,即 A = B = C。
StringBad& StringBad::operator=(const StringBad& sbStr) {
// 1. check if assign to self
if (this == &sbStr)
return *this;
// 2. release the previous memory
delete[] str;
// 3. deepcopy
len = sbStr.len;
// allocate memory for new string
str = new char[len + 1];
// copy the string to new memory complete deep copy
strcpy(str, sbStr.str);
nNumStr++;
cout << "Assign operator create. " << "nNumber is " << nNumStr << ".\n";
return *this;
}
添加上面两个函数的 explicit 定义后,重新运行程序,之前的问题都解决了。
1.4 编程技巧
Through 上面的例子抛砖引玉,可以得到这样的 Programing Principle 当定义 customed 类中用到了 new 或 new[] 进行动态内存分配时,It’s that 你 have to 自定义 copy constructor 和 assign operator lest 出现运行错误。
- copy constructor
//
ClassName::ClassName(const ClassName& clsArg) {
//deep copy the content
...
}
- assign operator
ClassName& ClassName::operator=(const ClassName& clsArg) {
//1. check if assign to self
if(this == &clsArg)
return *this;
//2. release the previous memory
...
//3. deep coyp the content
...
}
- 静态成员函数
最后提一点关于 静态成员函数,which is 用来访问 class 中的静态成员,if it’s public。静态成员函数 无法访问每个对象的特定数据,only for static 成员。
例子中可以定义这样的静态成员函数来访问 private 的 nNumStr。
class StringBad {
...
static int nNumStr
...
public:
...
static int HowMany() {return nNumStr;}
...
};
// use like this
int nNum = StringBad::HowMany();
-
preemptive definition
在某些情况下,你在类中使用了 new 运算符,但是又不 want 麻烦的去定义 copy constructor 和 assign operator,because 暂时用不到。But when error occurs,你又 forget 了没有进行相应的定义,此时编译器也不会给出任何提示,你就在一片骂声中开始 debug 了。为了防止这种情况的出现,让编译器在能够给出相应的报错信息,我们可以采用一个伪 private 的方式解决。
class ClassA {
private:
//preemptive definition
ClassA (const ClassA& cls) {}
ClassA& operator=(const ClassA& cls) { return *this;}
};
使用上面的方式进行定义后下面的语句就是非法的,编译器就会给出报错。
ClassA cls1, cl2;
ClassA cls3(cls1); //can't use the private method outside object
cls1 = cls2; //can't assign
当然这种方案并不是完美的,只是权宜之计。当使用 Value 进行传参时将使用 copy constructor 创建临时变量,此时如果是成员函数,则编译会顺利通过,但如果遵守 Reference 的原则这个也是可以尽量避免的。
2. 类返回值
函数返回一个类的 Object,存在以下几种情况:
- Reference
- const Reference
- Value
- const Value
考虑到按值返回的方式只能返回一个临时对象,当其完成后续操作后就会被调用 destructor 被释放掉,所以对其进行修改是没有意义,甚至会因为其能够被修改而导致一些逻辑错误没有被即使发现,所以在返回对象时最好不要返回 non-const 的 Value 值。
2.1 返回 non-const/const Reference
对于返回值为对象引用的函数,返回的引用必须在函数调用前就已经存在了,不能返回在 callee 中的局部变量。返回引用时会不触发 copy constructor。
2.2 返回 const Value
Assume that 这里有一个类,它重载了 operator+,并且其返回值为 non-const 类型的 Value。那么你在编写代码时可能会将比较运算符 == 错误的写成了赋值运算符 = ,此时你会编译器能够正常的编译而不会给出任何 error 信息。
这是因为返回的是一个临时的 non-const 变量,可以进行赋值操作。
if(clsA + clsB = clsC) // you write the operator carelessly
if(clsA + clsB == clsC)
当如果将返回值限定为 const,则clsA + clsB = clsC
操作就是错误的,编译器将无法正常编译。
3. 类指针 this
这里 fishwheel 只想提一下有关定位 new 运算符相关的内容,只需要记住一句话,定位 new 需要程序员自行管理内存,释放内存时需要调用析构函数 explicitly,而不是通过 delete implicitly 调用。
4. 初始化列表
初始化列表(member initializer list),是 C++ 构造函数中特有的初始化方式,only 能在 constructor 中使用。当遇到 非静态 const 变量和 Reference 变量时 have to 使用该方式进行初始化。
class ClassA {
const int nNum; //non-static const var
ClassB& rclsB; //reference var
public:
ClassA(ClassB& rcls) : nNum(0), rclsB(rcls) {
}
};
member initializer list 是在变量创建是完成的,也就是在函数体 { } 中的代码执行之前就已经完成了。
C++ 11 标准中提供了另一种初始化变量的方式,类内初始化。即在类的声明中添加了初始化值。
class ClassA {
int a = 1;
int b = 2;
public:
ClassA() {}
}
Unarchived Verify
- 验证临时变量的析构时机。
使用 1.1 的例子中的 StringBad 类,将字符串 “What hell” 赋值给 sbA,由于存在 StringBad(const char* s)
构造函数,此时会作为 转换函数 创建一个临时的对象,然后再将其赋值给 sbA。
int main() {
StringBad sbA;
sbA = "What hell";
StringBad sbB("good"), sbC("very");
cout << sbA << "\n";
cout << sbB << "\n";
cout << sbC << "\n";
return 0;
}
运行结果如下所示:
在 IDA 中可以看到,确实是创建了临时变量,并通过临时变量将值赋值给了 sbA,当赋值完毕之后 calling 析构函数 immediately。