C++基础——拷贝构造函数,析构函数,堆,栈,内存管理

complex类回顾

#ifndef __COMPLEX__ //名字自己定义
#define __COMPLEX__
#include <iostream>
using namespace std;
class complex
{
public:
    complex(double r = 0, double i = 0) : re(r), im(i)
    {
    }
    complex& operator += (const complex&);
    double real () const {return re; }
    double imag () const {return im; }


private:
    double re, im;

    //do assigment plus
    friend complex& __doapl (complex*, const complex&);
};
//class本体之外
inline complex& __doapl (complex* ths, const complex& r)
{
    ths->re += r.re;
    ths->im += r.im;
    return *ths;
}
//operator后面有没有空格无所谓
inline complex& complex::operator += (const complex& r)
{
    return __doapl (this, r);//把收到的两个参数原封不动地传给__doapl
}
//左边和右边的输入参数都不会被改变,因此都设为const
//1. 复数+复数
//返回的是local object,所以return by value
inline complex operator + (const complex& x, const complex& y)
{
    return complex (real (x) + real (y),
                    imag (x) + imag (y));//创建一个临时对象,里头可以给初值也可以不给
}
//2. 复数+实数
operator + (const complex& x, double y)
{
    return complex (real(x) + y, imag (x));
}
//3. 实数+复数
operator + (double x, const complex& y)
{
    return complex (x + real(y), imag (y));
}
//重载<<,os会被改变,但是x不会被改变,所以x前面要加const
ostream& operator << (ostream& os, const complex& x)
{
    return os << '(' << real (x) << ',' << imag (x) << ')';
}
#endif

使用者可能按如下代码使用complex,需要对<<进行操作符重载,同时要注意符号的连续使用,如下面第四行。

complex c1(9,8);
cout << c1;
//c1 << cout 应该避免这种情况
cout << c1 << endl;

知识要点:

  1. 运算符重载有两种选择:(1).设计成成员函数,(2).设计成非成员函数
  2. 友元函数可以直接取得实部re和虚部im
  3. 重载+=,有一个左边和右边的参数,另外由于是成员函数,所以有一个隐藏的输入参数*this作为左边的参数,只须显式地传入右边的参数即可。由于右边的这个参数是不会被更改的,所以我们要加const.
  4. 返回的参数如果不是local object就可以返回引用。比如operator +=
  5. class本体之外的函数可以加上inline,不过inline只是一个建议,具体是否会变成inline函数还是要看编译器
  6. 声明的时候函数的输入参数可以不写类型名
  7. 为什么要把+设计成非成员函数呢?因为+有很多形式,如果设计成成员函数,很多动作就会受限
  8. 非成员函数也可以重载
  9. coutostream的一个对象

个人疑问:

  1. 友元函数是否一定要写在private下面?
    友元函数相关博客 主要看2.2友元函数的位置
  2. real(x) 和 imag(y)函数为什么可以直接调用?

设计函数的时候需要考虑的方面:

  1. 成员函数还是非成员函数
  2. 确定输入参数
  3. 确定返回参数

拷贝构造函数

class的两个经典分类

  • Class without pointer member(s)
    比如complex类
  • Class with pointer member(s)
    比如string类

文件:string.h 这是一个简易的string版本,标准库太过复杂

#ifndef __MYSTRING__
#define __MYSTRING__
class String
{
public:
    String(const char* cstr = 0);
    String(const String& str);//拷贝构造,深拷贝,编译器默认是浅拷贝,Big three之一
    String& operator=(const String& str)//拷贝赋值,big three之一
    ~String();//析构函数,big three之一
    char* get_c_str() const {return m_data};//定义在类声明之中的成员函数将自动地成为内联函数
private:
    char* m_data;//实现动态存储的效果
};
//String::function(...)
inline String::String(const char* sctr = 0)
{
    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(const String& str)
{
    m_data = new char[strlen(str.m_data) + 1];
    strcpy(m_data, str.m_data);//直接取另一个object的pointer,兄弟之间互为friend
}
//析构函数用于清理class中分配的堆内存
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];//再分配和str.m_data一样大的堆内存空间
    strcpy(m_data, str.m_data);//拷贝
    return *this;//返回
}
//Global-function(...)
#endif

测试程序 文件:string-test.cpp

int main()
{
    String s1();//无初值,涉及构造函数
    String s2("hello");//有初值,涉及构造函数
    String s3(s1);//拷贝,s3第一次重现
    cout << s3 << endl;//<<重载
    s3 = s2;//赋值,拷贝,注意此时s3不是第一次出现
    cout << s3 << endl;
    //以下艾为拷贝构造函数和拷贝赋值的调用
    String s1("Hello");
    String s2(s1);
    String s2 = s1;//这句和上一句的意义是完全一样的
}

知识点:

  1. 只要类带着指针就必须写出拷贝构造函数拷贝赋值函数
  2. Big Three:拷贝构造(copy constructor),拷贝赋值(copy assignment operator),析构函数(destructor)
  3. 同一个class的不同的对象互为friend
  4. String s2(s1)和String s2 = s1意义完全相同
  5. 拷贝赋值:先清空,然后分配指定的空间,最后拷贝
  6. 拷贝赋值时不要忘了检测自我赋值,如果没有检测,就是报错。因为中间有清空再赋值操作。
  7. 定义在类声明之中的成员函数将自动地成为内联函数,关于inline的详细信息请参考博文inline内联函数(声明前加inline还是定义前加inline)

问题:

  1. 拷贝赋值时可以对目标对象进行修改么?目标对象和输入对象不是同一个对象?但是输入对象是const,这样的话,对目标对象进行修改的时候是否会报错?

#output函数 即重载<<运算符。 需要注意的是,重载<<运算符的函数必须是全局函数,即非成员函数,因为如果是成员函数,默认对象在<<的左边,就会出现s1 << cout的奇怪表达式,显然这是不可接受的。 文件string.h

include <iostream>
ostream& operator<<(ostream& os, const String& str)
{
    os << str.get_c_str();
    return os;
}

测试代码

String s1("hello");
cout << s1;

所谓的栈(stack)和所谓的堆(heap)

Stack, 是存在于某作用域(scope)的一块内存空间(memory space)。例如当你调用函数时,函数本身即形成一个stack用来放置它所接收的参数,以及返回地址。
在函数本体(function body)内声明的任何变量,其所使用的内存块都取自上述stack。 Heap, 或谓之System heap, 是指由操作系统提供的一块global内存空间,程序可动态分配(dynamic allocated)从某种获得若干区块(blocks)。

示例代码

请看下面的代码。

class Complex { ... };
...
//下面这个对象在大括号外
Complex c3(1,2);
//下面的对象都在大括号内
{
Complex c1(1,2);//local object,c1所占用的空间来自stack
Complex* p = new Complex(3);
delete p;//delete的作用对象是指针
//Complex(3)是个临时对象,其所占用的空间是以new自heap动态分配而得,并由p指向。
static Complex c2 (1,2);
}

注:一旦动态获得的内存,就有责任手动释放(delete)它。

stack object的生命周期

上述代码中c1便是所谓stack object, 其生命在作用域(scope)结束之际结束。
这种作用域内的object, 又称为auto object, 因为它会被自动清理。

static local object的生命周期

c2便是所谓的static object,其生命在作用域(scope)结束之后仍然存在,直到整个程序结束。 问题:static local object存放在哪里?

global objects的生命周期

c3便是所谓的global object,其生命在整个程序结束之后才结束。你也可以把它视为一种static object,其作用是整个程序

存在或消失应该要联想到这个对象的构造函数和析构函数什么时候被调用

heap objects的生命期

p所指的便是heap object,其生命周期在它被deleted之际结束。 如果不delete就会发生内存泄露(memory leak),因为当作用域结束,p所指的heap object仍然存在,但是指针p的生命周期已经结束了,也就是说指针本身是存在stack中的,因此作用域之外再也看不到p也就没有机会delete p了。

new的用法

class Complex
{
public:
	Complex(...) {...}
private:
	double m_real;
	double m_imag;
}
...
{
	Complex* c = new Complex(1,2);
}

上面这段代码在编译器中转化为三个步骤,如下面代码所示:

Complex *pc;
//第一步
void* mem = operator new(sizeof(Complex));//分配堆内存
//第二步
pc = static_cast<Complex*>(mem);//转型
//第三步
pc->Complex::Complex(1,2);//调用构造函数

详解:

  1. 第一步里面调用了malloc(n),这是C语言里面分配内存的函数。拿到两个double所占的内存(即8个byte)。operator new是C++里面的一个特殊的函数。
  2. 第二步只是把第一步的指针进行转型。
  3. 第三步中出现的构造函数实质上是Complex::Complex(pc,1,2),也就是说,构造函数有一个默认的输入参数就是指向类指针pc,即所谓的this指针。

delete的用法

class String
{
public:
	~String()
	{ delete[] m_data; }
	...
private:
	char* m_data;
};
...
{
	String* ps = new String("hello");
	...
	delete ps;
}

编译器将delete操作转化为两个动作。

//第一步
String::~String(ps); // 析构函数
//第二步
operator delete(ps); // 释放内存

详解:

  1. 第一步中调用析构函数,把字符串里面的动态分配的内存释放掉。至于Stringchar*成员只是一个指针,存于栈中,一旦对象生命周期结束,就会被释放掉。
  2. 第二步中的operator delete是C++中的一个特殊函数,可以查到它的源代码。这个函数内部调用了free(ps)。这个时候才把字符串指针释放。
    补充:
  3. 如果类中没有指针成员,就不需要去写析构函数

动态分配所得的内存块(memory block)

对象内存的调试模式

当你使用new的时候,在VC的调试模式下给 Complex对象分配内存,除了两个double所占的8个字节之外,还有其他字节需要分配.实际给Complex对象分配的内存是: 8+(32+4)+(4*2)=52->64 其中8是对象本身占的内存,在调试模式下对象所在的内存的上下地址分别还会有324个字节,在整个内存区段的两端各有一个cookie,每个cookie占有4个字节。 但是VC分配的内存的总大小一定是16的倍数,这是有原因的,但是现在没办法提。所以取最靠近并且大于5216的倍数就是64。补加的内存都放在尾部cookie的前面。

对象内存分配的非调试模式

同样的操作在realise模式下只须在对象所占的8个字节前后再加两个cookie就可以了。所以一共占用16个字节。

补充:

  1. 关于cookie:
    上下cookie的主要作用是记录整一块内存的大小。由于将来系统回收的时候,如果你只给它一个指针,编译器是无法知道回收的内存应该是多大的,所以mallocfree商量好在每个分配的内存块的头尾都加上cookie, cookie内部记录的是所分配内存的大小,比如VC调试模式下的Complex所占的内存是64个字节,那么在十六进制下,cookie上的内容应该是00000041。当然64的十六进制应该是40,为什么这里是41呢?原来最后一位用10来表示是给出去还是收回来。这里是分配内存,对操作系统而言就是给出去,所以用1表示。

问题:

  1. 对于内存分配,cookie的最后一位什么时候会成为0呢?

动态分配所得的array

回顾前面拷贝构造函数的代码

//拷贝构造函数
inline String::String(const String& str)
{
    m_data = new char[strlen(str.m_data) + 1];
    strcpy(m_data, str.m_data);//直接取另一个object的pointer,兄弟之间互为friend
}
//析构函数用于清理class中分配的堆内存
inline String::~String()
{
    delete[] m_data;
}

new array一定要搭配delete[],否则会造成内存泄露。 现在考虑如下代码:

String* p = new String[3];
...
delete[] p;//唤起三次dtor

分配的总的内存大小是:(8*3)+(32+4)+(4*2)+4=72->80。注意最后的+4就是在三个对象所占的连续内存前面加上4个字节用于存储对象的个数,此处即为3

array new一定要搭配array delete(delete[]) delete[]中的[]的作用是告诉编译器我要删除的是一个数组,编译器就会针对每个对象调用一次析构函数,上面的例子即调用了3次 String的析构函数。如果没有写中括号,编译器以为只有一个对象,所以只会调用第一个对象的析构函数,第二个和第三个编译器是不知道的,所以第二个对象和第三个对象只会把char*指针删掉,但是却没有释放指针所指向的内存。所以如果是String对象,因为没有指针成员,所以即使没有[]也是没有问题的,但是最好对于数组都写上[],确保万无一失。

String类的设计思路

接口设计

#ifndef __string_h__
#define __string_h__
class String
{
public:
	String(const char* cstr = 0);//2
	String(const String& str);//3
	String& operator = (const String& str);//3,4
	~String();//3
	char* get_c_str() const {return m_data;}

private:
	char* m_data;//1
};
  1. 首先需要一个字符数组,但是因为大小不定,所以用字符指针char* m_data,将来要放多大的数组,用new(即malloc)的方式去动态分配一块内存。
  2. 创建构造函数,首先让它接受一个字符指针,由于不回去改变传进来的指针,所以加一个const。同时也给它一个默认值0
  3. 当我们看到class里面有指针的时候,就要想到big three,即拷贝构造函数拷贝赋值析构函数
  4. 返回是否是reference要看返回的对象是不是一个local object,如果不是就可以返回reference。
  5. 其它辅助函数,直接在头文件定义,即为inline函数,由于不改变数据,所以在函数的后面加上const

函数设计

inline String::String(const char* cstr = 0)//1
{
	if (cstr) {
		m_data = new chart[strlen(cstr)+1];
		strcpy(m_data, cstr);
	}
	else {
		m_data = new char[1];
		*m_data = '\0',
	}
}
inline String::~String()//2
{
	delete[] m_data;
}
inline String::String(const String& str)//3
{
	m_data = new char[strlen(str.m_data) + 1];
	strcpy(m_data, str.m_data);
}
inline String& String::operator=(const String& str)//4
{
	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;//返回对象的值,但是接收端用reference
}
  1. 定义构造函数,并include所需要的库。最后做成inline函数
  2. 定义析构函数。如果分配了空间或者开了窗口或者开了文件都需要析构函数去释放。最后也做成inline函数。
  3. 定义拷贝构造函数。
  4. 定义拷贝赋值函数。先写函数名,再写输入参数,最后写返回类型。为了适应=的连用,所以需要加入返回类型。这里需要注意的是:传出去的人不必知道接收端用什么形式接收。不管是接收端是by value还是by reference都是可以的。最后需要去检查自我赋值,所以要加if判断语句。不去排除自我赋值现象是会产生错误的

代码下载

complex代码下载

打赏还是要有的,万一有人打赏呢!