# C++基础12-类和动态内存分配

本文是《C++ Primer Plus》的笔记,本文中的案例均自己实践过,如需转发请在转发开头贴上原文地址,谢谢!

本节将讨论关于对类使用new和delete关键字分配内存时的具体问题。用new来分配内存和之前我们定义数组时固定大小的方式不一样的一点是它是运行时动态分配的,加入我们定义了100个char大小的数组空间,而我们实际只用了60个,那么会造成浪费,而如果实际要用120个,会造成达不到需求。我们用new的话则是在运行时根据实际需要来申请内存空间。

# 问题的产生

我们仿照C++中的string类来做个实例:

StringM.h 的文件内容:

#pragma once
class StringM
{
private:
    char* str;
    int len;
    int id;
    static int string_nums;
    static int newStringnum;
    static int destroyNoneStringNums;

public:
    StringM();
    StringM(const char * ch);
    ~StringM();
    friend std::ostream & operator<<(std::ostream & op,const StringM & myStr);
};

StringM.cpp 的文件内容:

#include "pch.h"
#include "StringM.h"

int StringM::string_nums = 0;
int StringM::destroyNoneStringNums = 0;
int StringM::newStringnum = 0;

StringM::StringM():StringM("this is a initted c++ string")
{
    cout << "none parameter constructor is called!" << endl;
}

StringM::StringM(const char* ch)
{
    len = std::strlen(ch);
//cout << len << endl;
    str = new char[len + 1];
    strcpy_s(str,len+1,ch);
    id = ++newStringnum;

    cout << str << ": string object is created! it have "<< ++string_nums << " strings. It's id is :" << id << ";It's id is:"<<this<< endl;
}


StringM::~StringM()
{
    cout << str << ": string object is deleted! it left " << --string_nums << " strings. It's id is :" << id << " ;It's address: " << this <<". It's length is: "<< len<<endl;
    //delete[] str;
    len = 0;
}

std::ostream& operator<<(std::ostream& op, const StringM& myStr)
{
    // TODO: 在此处插入 return 语句
    op << myStr.str << endl;
    return op;
}

main 函数文件:

#include "StringM.h"

void printStringMByRef(StringM & str) {
    cout << "String passed by ref:\n" << str << endl;
}

void printStringMByValue(StringM str) {
    cout << "String passed by value:\n" << str << endl;
}

int main(int argc,char *argv[])
{
    {
        StringM str1("str1");
        StringM str2("str2");
        StringM str3;
        StringM str4();

        printStringMByRef(str1);
        printStringMByValue(str1);

        cout << str1 << endl;
        StringM str5 = str1; //将str1的成员变量复制过去,但是并没有增加string_nums
        StringM str6;
        str6 = str1;
        cout << "inner block ended!" << endl;
    }
    cout << "main function ended!" << endl;
}

程序输出:

str1: string object is created! it have 1 strings. It's id is :1;It's id is:009AF740
str2: string object is created! it have 2 strings. It's id is :2;It's id is:009AF72C
this is a initted c++ string: string object is created! it have 3 strings. It's id is :3;It's id is:009AF718
none parameter constructor is called!
String passed by ref:
str1

String passed by value:
str1

str1: string object is deleted! it left 2 strings. It's id is :1 ;It's address: 009AF60C. It's length is: 4
str1

this is a initted c++ string: string object is created! it have 3 strings. It's id is :4;It's id is:009AF6F0
none parameter constructor is called!
inner block ended!
str1: string object is deleted! it left 2 strings. It's id is :1 ;It's address: 009AF6F0. It's length is: 4
str1: string object is deleted! it left 1 strings. It's id is :1 ;It's address: 009AF704. It's length is: 4
this is a initted c++ string: string object is deleted! it left 0 strings. It's id is :3 ;It's address: 009AF718. It's length is: 28
str2: string object is deleted! it left -1 strings. It's id is :2 ;It's address: 009AF72C. It's length is: 4
str1: string object is deleted! it left -2 strings. It's id is :1 ;It's address: 009AF740. It's length is: 4
main function ended!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117

上面的很多程序超出了我们的预期,我们来分析下:

  1. 静态变量是类的多个实例对象所共享的,而不是单个对象所有。
  2. 注意静态成员变量的初始化方式,并没有加static关键字,类型 类名::静态变量=value,不能在类声明中初始化静态成员变量,因为声明是描述如何分配内存,但是并不实际分配内存,如果静态数据成员变量是整形或者枚举const,则可以在类声明中初始化。初始化是在实现文件中而不是在声明文件中,因为声明文件会多次包含,可能会被多次初始化,可能会出现错误。
  3. 类中str指针指向new分配的地址空间,new分配的空间在堆空间中(heap),和栈空间(stack)中的自动变量不一样,如果在程序运行期间,我们不释放new所分配的空间,那么它会一直存在直到程序结束被操作系统回收,所以我们在构造函数中使用new分配了堆空间,需要在析构函数中释放它。栈区的空间的分配是连续的,当声明自动变量时,程序会在栈区末尾分配内存。而堆区的内存是分散的,通过指针链接起来的。
  4. C++中创建无参类对象时,带括号和不带括号的区别:带括号是不调用任何构造函数,只是声明了一个类变量而已,不带括号是调用不带参数或者有默认值得构造函数。
  5. 如何判断一个指针指向的内存是否释放?注意delete只能释放分配的内存空间,对原本的指针不能置空,将指针释放空间后,将指针赋值为NULL或nullptr是必要的,下次判断指针指向的内存是否释放则看这个指针是否为NULL或者nullptr即可(C++98中,字面值0可以表示零和空指针,使得程序难以阅读,有的人使用(void *)0,有的人使用NULL,C++11提供了nullptr,建议使用nullptr),如果不这样做那么该指正将会成为野指针。如果把delete[] str;取消注释,因为printStringMByValue中创建的临时StringM对象和str5,str6中的str都指向str1中的str分配的空间,当临时变量销毁时,也就是地址空间为009AF60C的对象销毁时,str1,str5,str6中的str都将成为野指针,当程序结束销毁对象时,因为这三个对象中的str指向的内存已被释放,再次释放它们时会报错。
  6. 按照类的定义,创建一个对象时,string_nums会自增,销毁一个对象时,string_nums会自减,最后程序运行完string_nums的值应该是0,为什么会是-2呢?这是因为str5和printStringMByValue临时StringM对象创建时未能调用构造函数使string_nums自增,但是当销毁对象时却都使string_nums自减,所以最后string_nums的值是-2。
  7. 根据对象的地址发现,越早创建的对象,越到后面销毁。

# 特殊成员函数

从上面的str5的定义发现,它并没有调用构造函数,根据之前的类的初始化规则,要想调用构造函数,我们需要定义下面的构造函数:

StringM(const StringM &)

当没有上面的构造函数时,编译器会自动生成构造函数且不报错,具体地说,C++默认提供下面这些特殊成员函数,也就是说用户没有定义也会默认提供:

  1. 默认构造函数
  2. 默认析构函数
  3. 复制构造函数
  4. 赋值运算符
  5. 地址运算符

在上面的第6个问题中,就是由于默认复制构造函数和赋值运算符引起的,我们需要按照我们预期的方式重新定义它们。我们先来看下每种函数:

# 默认构造函数

注意只能有一个默认构造函数,不能出现二义的情况:

testClass(){

}
testClass(int n=0){
}

testClass temp1;
1
2
3
4
5
6
7

当创建对象temp1调用构造函数时会出现多于一种的选择,这是不允许的,会出现对重载函数的调用不明确的错误。

# 复制构造函数

复制构造函数用于将一个对象复制到新创建的对象中。它是用于初始化过程中(包括按值传递参数),而不是常规的赋值过程中。类的复制构造函数原型通常如下:

Class_Name(const ClassName &);

默认的复制构造函数逐个复制非静态成员,复制的是成员的值,成员复制也称为浅复制。我们之前的str5,str6和临时StringM对象都是浅复制,这三个对象中的str都指向同一块内存空间,如果一个对象调用析构释放这块内存,其他对象中的str再使用这块内存就会出错。windows中可能会出现"Debug Assertion Failed!",而linux中可能会出现"double free or corruption"。

解决这种情况是进行深度复制(deep copy),也就是说复制构造函数应该为新对象重新开辟内存空间,将字符串复制到该空间,再让指针指向这块空间,调用析构函数时都是释放本对象所管理的字符串所在内存空间,不会影响其他对象。

综上,如果类中使用了new开辟的堆内存,那么应该定义一个复制构造函数,以复制指针指向的数据而不仅仅是指针,这被称为深度复制。这和上面的浅复制(只复制指针的值)不一样,它会关心指针所指向的结构。

让我们来解决上面的问题:

StringM.h 文件的内容:
StringM(const StringM& target);

StringM.cpp 文件的内容:

方法一:
StringM::StringM(const StringM& target):StringM(target.str)
{
    cout << "string object is copied!" << endl;
}

方法二:
StringM::StringM(const StringM& target)
{
    len = std::strlen(target.str);
    str = new char[len + 1];
    strcpy_s(str, len + 1, target.str);
    id = ++newStringnum;
    cout << str << ": string object is copied! it have " << ++string_nums << " strings. It's id is :" << id << ";It's id is:" << this << endl;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

# 赋值运算符

上面的场景会复制构造函数,那么如果对类对象赋值则会调用赋值运算符,这种运算符的原型如下:

Class_name & Class_name::operator=(const Class_name &)

何时会调用赋值运算符呢?看下面的场景

场景1:
StringM str1;
str1=str2;

场景2:
StringM str1=str2;
1
2
3
4
5
6

场景1会调用赋值运算符,场景2会调用复制构造函数。与复制构造函数相似,赋值运算符的隐式实现也对成员进行逐个复制。如果成员本身就是类对象,则程序将使用为这个类定义的赋值运算符来复制该成员。

我们来解决上面的问题,与复制构造函数不同的是,自身的对象不应该赋值给自身,没有意义。函数返回一个指向调用对象的引用。

我们为上面的StringM类添加一个赋值运算符,然后再验证下哪些情况使用赋值运算符,哪些情况使用复制构造函数。

StringM.h 的文件添加:
StringM & operator=(const StringM & st);

StringM.cpp 的文件添加:
StringM & StringM::operator=(const StringM & st)
{
    if (this == &st) {
        cout << "directly return if a object assigned by itself!" << endl;
        return *this;
    }
    cout << "StringM operator = is executed!" << endl;
    return *this;
}

在上面例子中main函数末尾添加:
    cout << "------------test copy constructor!!!------------" << endl;
    StringM str7(str2);
    StringM str8 = str2;
    StringM str9 = StringM(str2);
    StringM * str10 = new StringM(str2);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

通过上面的验证发现最后str7至str10都是调用复制构造函数,包括上面的printStringMByValue,将str1传给形参时也是一样,这就说明了复制构造函数基本是在创建一个新的对象并且初始化时调用的,而str6=str1调用了赋值运算符,这就说明当对象已存在,要被赋值时调用赋值运算符。注意上面的输出最后是保留了一个对象,这个对象就是str10,因为它是在堆上创建的对象。

# 改进StringM类

StringM.h 中添加:
    friend bool operator<(const StringM &str1, const StringM &str2);
    friend bool operator>(const StringM &str1, const StringM &str2);
    friend bool operator==(const StringM &str1, const StringM &str2);
    char & operator[](int i);
    const char & operator[](int i) const;
    StringM & operator=(const char * ch);

StringM.cpp 文件中

将原来的无参构造函数修改为:

StringM::StringM()
{
    len = 0;
    str = new char[1];
    str[0] = '\0';
    //str = new char;
    //str = '\0';
    //char str1[15]="hi,world";
    //char * str2= str1;
    //注释中定义的方式都与delete[]不兼容。
    cout << "none parameter constructor is called!" << endl;
}

或者修改为:

StringM::StringM():StringM("")
{
    cout << "none parameter constructor is called!" << endl;
}

将下面的代码行添加至末尾:

bool operator<(const StringM & str1, const StringM & str2)
{
    return strcmp(str1.str,str2.str)<0;
}

bool operator>(const StringM & str1, const StringM & str2)
{
    return strcmp(str1.str,str2.str)>0;
}

bool operator==(const StringM & str1, const StringM & str2)
{
    return strcmp(str1.str, str2.str) == 0;
}

char & StringM::operator[](int i)
{
    return str[i];
}

const char & StringM::operator[](int i) const
{
    return str[i];
}

StringM & StringM::operator=(const char * ch)
{
    delete[] str;
    len = std::strlen(ch);
    str = new char[len + 1];
    strcpy_s(str, len + 1, ch);
    return *this;
}

main 函数中修改为:

int main(int argc,char *argv[])
{
    {
        StringM str1;
        StringM str2("hello");
        StringM str3("gl");
        StringM str4("hello");
        const StringM str5("hehe");
        if (str2 < str3) {
            cout << "str2 is lower than str3" << endl;
        }
        if (str2 == str4) {
            cout << "they are equal!" << endl;
        }
        if ("abc" < str2) {
            cout << "abc is lower than str2" << endl;
        }
        str4 = "hi";
        //如果不重载常量字符串版本的赋值运算符,"hi"会被先转换成StringM对象,再进行赋值运算。
        cout << str4 << endl;
        //如果没有这个StringM & StringM::operator=(const char * ch),那么它就是先使用"hi"来创建一个对象,然后再赋值给str4。然后再删除,如果这是一个很常用的动作,会很消耗性能。
        //下面这两种展示了将操作符[]只读不能写的方法。
        str2[3] = 'g';
        //str5[1] = 'j';
        cout << str2 << endl;

        cout << "inner block ended!" << endl;
    }
    cout << "main function ended!" << endl;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101

注意几点:

  1. 默认构造函数有两种修改方式,注意不使用第一种方式里面的注释行是因为delete[]和new[]兼容,而以其他方式初始化的指针使用delete[]结果都是不确定的。
  2. 使用中括号会使某个字符修改,所以提供了一套const版本,保证不能修改某个字符。
  3. 如果不重载常量字符串版本的赋值运算符,则会先转换再进行赋值,浪费性能。

# 构造函数中使用new时

前面已经提过这个问题,再总结下:

  1. 构造函数中使用new来初始化指针成员,则在析构函数中使用delete。
  2. new和delete兼容,new[]和delete[]兼容。如果有多个构造函数,必须以相同的方式使用new,因为只有一个析构函数,它要与所有的构造函数中的new兼容。
  3. 应该定义一个复制构造函数和赋值运算符,深度复制指针指向的内存空间。
  4. 当这个类成为另一个类的成员时,就没有必要再在另一个类中定义复制构造函数和赋值运算符。

# 返回对象的说明

  1. 返回const对象引用,返回对象将调用复制构造函数,返回对象的引用不会,且效率更高。返回引用的对象在调用函数时就该存在,这样不是返回局部对象。一般参数应该是const对象。
  2. 返回非const对象引用,比如重载赋值运算符以及cout的<<运算符。前者是需要进行链式计算,比如s3=s2=s1,而后者是因为ostream没有公有复制构造函数,只能返回引用。
  3. 当返回对象是局部变量时,必须返回对象本身而不是引用,虽然存在调用复制构造函数来创建被返回的对象的开销,但是不能避免。这种返回的对象可能会产生一些歧义,比如f1+f2=f3。如果想避免这种情况那就使用const限定符。

# 使用指向对象的指针

从上面的str10发现使用new创建对象是在堆内存上创建的,不使用new则是在栈内存空间上创建,在栈上不需要手动释放,析构函数会自动执行,而在堆上申请的只有调用delete才执行析构函数,如果程序退出没有使用delete释放这部分空间,会造成内存泄露,在第9节<内存模型和命名空间>中提过定位new运算符,在本节我们结合类的声明周期再来看下定位new运算符。

main 函数修改如下:
int main(int argc,char *argv[])
{
    {
        char * buffer = new char[512];
        StringM * str1 = new (buffer) StringM("This is str1!");
        StringM * str2 = new StringM("This is str2!");
        StringM * str3 = new (buffer) StringM("This is str3!");
        StringM * str4 = new StringM("This is str4!");
        StringM * str5 = new (buffer + sizeof(StringM)) StringM("This is str5!");
        delete str1;
        delete str2;
        //delete str3;
        delete str4;
        //delete str5;

        //str3->~StringM();
        str5->~StringM();
        delete[] buffer;
        cout << "inner block ended!" << endl;
    }
    cout << "main function ended!" << endl;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

上面的代码是不能正常运行的,str2,str4的创建和销毁时没有问题的,str1销毁的时候显示的是str3的信息,这个可以理解,它正常运行过去后,str5再销毁就出现了问题,delete和定位new运算符并不是兼容的,会使程序变的不稳定,我们把delete str1注释掉,使用str3->~StringM();这个就能正常运行。所以在这种情况下,不要使用delete来释放定位new运算符创建的对象,而要显式的调用析构函数。释放了buffer中的对象,再最后释放buffer的空间。