类与深拷贝与浅拷贝测试

测试构建类中的成员变量该类的指针,所出现的问题,与解决过程。

编译器 GCC lastest x64

系统 Linux Ubuntu22LTS

错误示例一

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
class A
{
int a;
A* test;
public:
A(){
a = 0;
test = new A();
};
A(int num) {
a = num;
this->test = new A();
};
~A(){
if(test != nullptr)
delete test;
};
};
int main(){

A b(10);
A a = b;
return 0;
}

在执行简单的拷贝时,我们使用编译器默认提供的拷贝构造函数。

编译的时候,确实没问题啊,但是一运行就开始报错了。返回一个段错误

众所周知段错误通常是因为访问了非法的内存而导致的。

不过这个问题的根本原因并不是调用了原本的构造函数。而是:在类的初始化构造函数中初始化了该类的指针。

由于new会调用构造函数,在构造函数中,又会在调用一次构造函数。而构造函数中又初始化了…….如此这般递归套娃,无限调用导致内存溢出?最终访问了非法的内存。

我们将初始化为空指针,心想这些构造函数总不能出问题了吧。

接着我们来到了错误示例二

错误示例二

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
#include "stdio.h"
class A {
public:
int a;
A* test;

A() {
a = 0;
test = nullptr;
};
A(int num) {
a = num;
this->test = new A();

};
~A() {
if (test != nullptr) {
delete test;
test = nullptr;
}
};

};
int main() {
A b;
A a = b;
printf("%d\n", b.a);
return 0;
}

出错问题这下定位到了析构函数上。可以发现堆栈调用了两次析构函数,但是第二次析构出现的段错误。

我们都知道编译器在类中默认提供一个拷贝构造。A(const A& ) ,当你没有定义时,就会提供,该默认拷贝构造实际就是一个简单的浅拷贝

所有值复制一遍给新的实例,指针成员变量也是同样的地址, 在析构时候呢,当然就会出现多次析构同一个内存对象问题。

析构顺序,当然是先进后出,第一次a先执行依次析构函数,由于test指针中的地址与a是相等的,都指向同一块内存,析构的时候,先把指向的内存给先析构了。到了b再来执行析构函数的时候由于test 的值还是原来的地址值,就会在调用依次析构delete, 二次delete同一个内存肯定是要出事的。

所以解决方法就是:我们主动提供一个拷贝构造函数

实现一个深拷贝

1
2
3
4
5
  A(const A& a) {
this->test = new A();
//这里的复制内存内容的操作省略,可以memcpy也可以什么都不做。
this->a = a.a;
}

这下不报错了,可以正常运行。

额外测试

我们都知道,编译器除了提供一个默认的拷贝构造以外,C+11之后还会默认提供一个operator=(const A&)函数(其实也是一个简单的浅拷贝!)。所以我们来测试一个简单的函数然回值为类

1
2
3
A func() {
return A(5);
}

我们在main函数调用

1
2
A a;
func() = a;

乍一眼, 可能会觉得奇怪,函数的返回值不是一个空么,不是一个妥妥的左值,为啥还能编译通过呢

可以观察到编译器给出了一个提示:

1
2
warning: implicitly-declared ‘constexpr A& A::operator=(const A&)’ is deprecated [-Wdeprecated-copy]
36 | func() = a;

可以发现确实编译器提供了一个返回引用的重载函数,所以左值又变右值了。

但是这下我们运行该程序,析构函数又报错了,而且观察堆栈,发现居然调用了三次析构函数,这是怎么回事捏,让我们来解析一下

1
func() = a;

到底发生了啥。

首先先调用 了一个有参构造,说明,先执行的是函数。(栈上)

然后又调用了一个无参构造,是有参构造里的成员的变量的无参初始化(堆上)。然后 将a 复制给这个匿名对象。

注意:

当这条语句执行完毕,跳到下一条语句,执行析构,先析构栈上的,然后再析构 匿名对象指针指向的内存地址,此时该地址是a 对象的地址。

所以,当最后要析构(第三次析构)a对象了里的地址时,就又出现了二次delete!

解决方案:

所以我们需要在主动自己提供一个operator=函数,将

1
2
3
4
5
6
 A& operator=(const A& s) {
this.a = s.a;
this->test = new A();
//这里的复制内存内容的操作省略,可以memcpy也可以什么都不做。

}

重新编译运行没有问题!这下就解决了重载= 的浅拷贝的问题。

tip:在类中成员变量如果有指针,必须保证用户提供正确的重载= 操作符函数 和 拷贝构造函数!

最后问题

额外测试中似乎已经给出完美答案了,但是实际上,我在继续测试的时候发现,利用内存泄露检测工具valgrind 发现了内存泄露,这是怎么一回事呢?

1
valgrind --tool=memcheck --log-file=mem.log --leak-check=full ./outDebug

定位发现,居然是func函数中构造的临时对象中申请的内存没有释放。

1
func() = a;

func()中明确了先调用其中的a的构造有参构造函数,而有参构造函数中又new了一次调用的无参构造。

然后又调用了一次重载运算符,由于我们提供了修改的重载运算符,所以这次会再次分配了一次内存,指向了新的地址,之前的地址就再也没有关联所以,自动析构的时候就会无视该地址,最终导致内存溢出!。

问题根源如此,解决方式已然清晰明了。

再修改重载=运算符函数

1
2
3
4
5
6
7
8
9
10
11
12
13
A& operator=(const A& s) {
printf("调用重载=函数 mythis %p \n", this);
if(this == &s){
return *this;
}
this->a = s.a;
if (this->test != nullptr) {
delete this->test;
}
this->test = new A();
//这里的复制内存内容的操作省略,可以memcpy也可以什么都不做。
return *this;
}

最后我们再进行一次编译运行内存检测:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
==66394== Memcheck, a memory error detector
==66394== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==66394== Using Valgrind-3.18.1 and LibVEX; rerun with -h for copyright info
==66394== Command: ./outDebug /usr/bin/env /bin/sh /tmp/Microsoft-MIEngine-Cmd-sn4tmhv3.qov
==66394== Parent PID: 21040
==66394==
==66394==
==66394== HEAP SUMMARY:
==66394== in use at exit: 0 bytes in 0 blocks
==66394== total heap usage: 5 allocs, 5 frees, 73,776 bytes allocated
==66394==
==66394== All heap blocks were freed -- no leaks are possible
==66394==
==66394== For lists of detected and suppressed errors, rerun with: -s
==66394== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

再无错误,问题结束。