1. 示例类
提供一个精简版的复数类和一个精简版的字符串类用来演示。
示例类中函数均取自C++标准库。
复数类:
#include <iostream>
#include <thread>
#include <map>
using namespace std;
//复数类
class Complex
{
public:
/*
提供默认构造函数,采用初始化列表方式,构造函数执行有两个执行阶段
1. 初始化阶段 2.函数体内部赋值阶段(按执行时间排序)
在给成员变量进行赋值时最好采用初始化列表的方式,而不是采用构造函数体内部赋值方式
Complex(double re = 0, double im = 0)
{
m_re = re; m_im = im; //这样的方式当然也可行,但是浪费了一次初始化值的机会。
};
如果采用构造函数体内部方式相当于错过了初始化阶段。
*/
Complex(double re = 0, double im = 0) :m_re(re), m_im(im) {};
Complex& operator+=(const Complex& r)
{
m_re += r.m_re;
m_im += r.m_im;
return *this;
}
/*
* 对于函数体内部不需要更改数据的函数,最好在后面加上const。
* 例如const Complex c这种使用场景,如果函数real()、imag()不加const
* 编译器在看到const Complex c时一定认为c是不会改变数据的,但是调用real()/imag()函数时没有const
* 编译器又以为要修改数据,这个是不好的做法。
*/
double real() const { return m_re; }
double imag() const { return m_im; }
//对于所有的数据,最好采用private保护起来,如果外部需要访问数据提供外部接口即可。
private:
double m_re;
double m_im;
};
/*
对于重载的操作符,可以采用成员函数的方式也可以采用全局函数的方式。
此处采用全局函数的考量是因为如果采用成员函数,那么只能计算Complex类之间相加。
但实际上复数可以和实数加,实数并不属于Complex类,所以如果设计为成员函数operator+,那么功能会被有所限制
*/
Complex operator+(const Complex& x, const Complex& y)//复数+复数
{
return Complex(x.real() + y.real(), x.imag() + y.imag());
}
Complex operator+(const Complex& x, const double y)//复数+实数
{
return Complex(x.real() + y, x.imag());
}
Complex operator+(const double x, const Complex& y)//实数+复数
{
return Complex(x + y.real(), y.imag());
}
ostream& operator<<(ostream& os, const Complex& l)
{
os << '(' << l.real() << "," << l.imag() << ")";
return os;
}
字符串类:
#include <cstring>
#include <iostream>
using namespace std;
class String
{
public:
String(const char* cstr=0);
String(const String& str);
String& operator=(const String& str);
~String();
char* get_c_str() const { return m_data; }
private:
char* m_data;
};
//inline只是对编译器的一个建议,希望编译器可以将这个函数设置为内联函数,实际上会不会设置取决于编译器。
inline String::String(const char* cstr)
{
if (cstr) {
m_data = new char[strlen(cstr)+1];
strcpy(m_data, cstr);
}
else {
m_data = new char[1];
*m_data = '\0';
}
}
inline String::~String()
{
delete[] m_data;
}
inline String& String::operator=(const String& str)
{
if (this == &str)
return *this;
delete[] m_data;
m_data = new char[ strlen(str.m_data) + 1 ];
strcpy(m_data, str.m_data);
return *this;
}
inline String::String(const String& str)
{
m_data = new char[ strlen(str.m_data) + 1 ];
strcpy(m_data, str.m_data);
}
ostream& operator<<(ostream& os, const String& str)
{
os << str.get_c_str();
return os;
}
2. new/delete
定义一个对象时,一般会将内存分配在栈区、堆区,用来控制对象存在的生命周期。
还可以使用static关键字或者将对象定义为全局变量来控制生命周期。
static关键字:变量生命周期在作用域结束之后仍然存在,直到整个程序结束。- 全局对象:变量声明周期在整个程序结束之后才结束,作用域是整个程序。其生命周期比
main()更早。
2.1 new内存泄漏
下面这块代码就会造成内存泄漏。指针变量p离开作用域后,指针p的生命结束,作用域外再也看不到p,也没有机会delete p,此时其所指向的堆区空间无法使用delete进行释放,这块内存将会泄漏。
{
Complex* p = new Complex;
}
正确实例:
{
Complex* p = new Complex;
...
delete p;
}
2.2 new
使用new关键字时,会先分配内存,然后调用类的构造函数。
Complex* p = new Complex(1,2);
编译器会将上行代码转化为:
void *mem = operator new( sizeof(Complex) ); //分配内存
Complex* pc = static_cast<Complex*>(mem);//类型转换
pc->Complex::Complex(1,2);//调用Complex类的构造函数
operator new()是C++内部一个名字特殊的函数,其内部调用的就是malloc(n)函数,来分配内存的。
Complex::Complex(1,2)函数的参数列表中实际上隐藏着this指针,谁调用Complex::Complex()函数谁就是this指针,所以实际调用方式为Complex::Complex(pc,1,2);,此时pc就是this指针。
2.3 delete
使用delete关键字时,会先释放内存,然后调用类的析构函数。
String* ps = new String("123");
...
delete ps;
编译器会将上面代码转化为:
String::~String(ps);//析构函数
operator delete(ps);//释放内存
operator delete()是C++内部一个名字特殊的函数,其内部调用的就是free(ps)函数,来释放内存的。
对于String类来说,其大小本身只是一个指针m_data大小,指向一个字符串。
2.4 内存分配过程分析
使用Complex* p = new Complex(1,2);创建一个复数,会分配一个内存空间,大小为8个字节(两个double,按原来double为4个字节时分析)。
在调试模式下,并不是真正得到8个字节,得到的字节数如下图左侧所示,每一格为4个字节,灰色的上下两部分为Debug的头和尾,砖红色的为cookie,用来标记这块内存的分配范围一级是否已经分配出去。在VC下,每次编译器所分配的内存区块都会是16的倍数,所以new一个复数实际上分配的内存大小为:8+(32+4)+(4*2)+12=64个字节。
- 8为
Complex大小; - (32+4)为
Debug头尾大小; - (4*2)为cookie大小
- 12(图中pad部分)为补充16倍数添加的大小。
所以一个复数在Debug模式下实际上会分配64个字节大小的内存区块。
在release模式下,分配的内存区块如下图右侧所示。当然,对于分配的内存区块来说,一定是16倍数的,只是release模式下内存大小刚好已经是16的倍数了,所以不再需要pad部分。
为什么分配的内存区块一定要是16的倍数呢?
操作系统利于16进制的最后一个16进制位来标记这块内存是给出去了还是还回来了。16的倍数转换为16进制后,最后一个16进制位就一定是0,才可以用来作为标记。
cookie值为0x00000041,如果没有给出去值为0x00000040,给出后值为0x00000041。

同理,String* ps = new String("123");也是如此。

上面只是讨论了分配一个复数类或一个字符串类的内存模型,那么当分配多个的情况呢,如下所示。例如分配一个复数数组的内存空间。
我们都知道分配内存时,使用Complex* p = new Complex[3]来分配3个复数对象的内存空间,释放内存时需要使用delete[] p来释放这块内存,为什么需要加上[]呢。
除去Debug头尾、补充16倍数的pad块、cookie块,已经此时内存模型比上面多了4个字节,是来记录数组个数的。3个复数,这四个字节里就存放的是3。(VC编译器是这样做的,其他编译器不保证)。
所以在Debug模式下,实际分配的内存大小为(83)+(32+4)+(42)+4+8=80个字节。
- (8*3)为数据大小,1一个复数有两个double为8个字节,一个3个复数;
- (32+4)为
Debug头尾大小; - (4*2)为上下cookie大小;
- 4为存放数组个数的大小;
- 8为pad块大小。
在release模式下,实际分配内存大小除去Debug头尾后为(83)+(42)+4=36个字节。
2.5 array new内存泄漏
通过上面的例子,已经知道了动态分配数组的内存模型。上面也谈到需要使用delete[] p来释放数组,那么假如忘记加[]会发生什么情况呢。此处使用String类来做演示,如图所示。
实际上没有添加[]造成了内存泄漏,但泄漏的并不是String类的内存空间,因为已经有cookie来标记了操作系统给出去的整个内存区块范围,所以仍然能够正常回收。但是因为少加了[],会少调用两次析构函数,而析构函数中会去释放指针指向的字符串空间,所以此时实际上是指向的字符串空间没有被释放造成的内存泄漏,即图中?!标记的地方。
总结:
Complex类与String类的区别是一个类内部没有指针;另外一个类内部有指针,并且指针指向其他的内存空间。
所以,当使用Complex* p = new Complex[3]分配一个复数数组时,因为Complex类内并没有指针指向另外的内存,所以其实不使用delete[] p也不会造成内存泄漏,但是为了养成一个良好的编程习惯,array new还是一定要搭配array delete来使用。