《拷贝构造函数:为什么非要&引用?传值和传指针不行吗》

0 阅读5分钟

问题引入

最近,我在用RAII重构我的项目,了解到了拷贝构造函数的禁用,于是我想,给函数传参有三种方式,传值、传引用、传指针。

为什么拷贝构造函数一定是传引用,传值、传指针不行吗?

先说结论

Myclass(Myclass other):传值,会导致无限递归直到栈溢出。 之所以发生无限递归现象,是函数调用目的与前提条件的冲突。

Myclass(Myclass* other):传指针,操作上可以,但远远不如传引用。

传值导致无限递归的2种逻辑推理

当我们调用拷贝构造函数Myclass(const Myclass &other)时,我们想创造一个obj2与obj1完全相同的对象,可以理解为要执行Myclass obj2=obj1;

试着把拷贝构造函数写成MYclass(const Myclass other),直接传值。

理想情况来说,我们顺利成功调用拷贝构造函数,这个过程:先创建一个形参other,把obj1这个实参对象复制给形参other,再把other这个形参对象传给obj2,完成拷贝传值。

  1. 基于允许调用 传值拷贝构造函数的物理逻辑推理:

第1次调用拷贝构造函数→执行函数,试图拷贝obj1

试图拷贝obj1→第2次调用拷贝构造函数

第2次调用拷贝构造函数→执行函数,试图拷贝obj1

试图拷贝obj1→第3次调用拷贝构造函数

第3次调用拷贝构造函数→试图拷贝obj1

... ...陷入无限递归循环

第n次调用拷贝构造函数→栈爆!💥

这个过程中调用了无数次拷贝构造函数,每调用一次,编译器就会分配一块分配新栈帧(大小为:拷贝构造函数的参数所占内存大小),最后导致栈溢出,还好,编译器根本不允许我们调用传参方法的拷贝构造函数。这不是单纯性能问题,是根本不可行。

  1. 纯逻辑推理:

如果想要调用拷贝构造函数→需要先拷贝obj1作为other

如果想要拷贝obj1→需要调用拷贝构造函数

如果想要调用拷贝构造函数→需要先拷贝obj1

如果想要拷贝obj1→需要调用拷贝构造函数

...陷入无限递归,永远都不会发生任何事情,包括栈爆,也不会发生。

这个过程根本没有产生栈帧,因为没有发生任何一次调用,每句话的开头都是“如果想”,还好,编译器根本不允许我们发生这个递归循环。所以这件事情也是逻辑必然——不能通过拷贝对象来定义如何拷贝对象。

现实现象

讲完了两种逻辑推理,我们明白了不仅仅是物理上会导致无限递归循环从而导致栈爆,从逻辑上来看,存在定义自指矛盾,陷入无限递归循环从而什么也不会发生和执行。那么基于现实,发生了什么:

先看小实验

class Test {
public:
    Test(int) {}           // 普通构造
    Test(const Test other) {}    // 传值的"拷贝构造"(实际编译报错)
};

int main() {
    Test a(1);
    Test b = a;           // 这里触发
}

报错:

image.png

现代 C++ 编译器(C++11+)看到 Test(Test other) 不会把它识别为拷贝构造函数,而是当作普通构造函数。但由于Test 不能隐式转 Test 用于拷贝初始化,参数类型不匹配,编译器会报错 invalid constructorno matching function(旧编译器)。

GCC 内部流程:

源代码 test.cpp

[预处理器] 处理 #include, #define 等

[词法分析] 拆成 token:class, Test, {, public, :, ...

[语法分析] 构建语法树,识别出类定义、函数声明

[语义分析] ⭐检查类型、作用域、合法性 ←在这里报错

[代码生成] 生成机器码(如果前面没报错)

[链接] 链接库文件,生成可执行文件(如果前面没报错)

所以,从现实上,C++标准明确规定拷贝构造函数首参必须是引用类型(T&/const T&),编译器会直接报错invalid constructor,根本编译不过,不会陷入任何一种递归循环。

问题本质

之所以发生无限递归现象,是函数调用目的与前提条件的冲突:

如果是传值方法,拷贝构造函数目的是获取某个对象的值借此创建相同新对象,然而想要达成这一目的,前提条件也是获取某个对象的值,这就导致了无限递归循环,栈爆是随之而来的后果。所以必须用引用(另一种获取对象的值的方法)来打破这个无限递归循环——引用是对象的别名,不需要复制对象本身,只需要绑定到已有对象。

我们明白了不能传值。物理上会导致无限递归循环从而导致栈爆,逻辑上来看,存在定义自指矛盾,问题无解。从现实看,编译器根本不认为这是拷贝构造函数,认为这是一个出错的构造函数并报错。

问题延伸

为什么不能传指针?指针也可以避免拷贝获取某个对象的值啊?

// 1. 语义混乱
class Test2 {
Test(int) {}
Test(const Test* other){}   // 拷贝构造?还是普通构造?
};

// 2. 语法丑陋
Test a(1);
Test b(&a);         //像构造函数传参,而且显然不如Test b = a来得自然

// 3. 空指针风险
Test(nullptr);

根据这三个缺陷,

  1. 语义混乱
  2. 语法丑陋
  3. 空指针风险

结论是:操作上可以传指针,但远远不如传引用。