问题引入
最近,我在用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次调用拷贝构造函数→执行函数,试图拷贝obj1
试图拷贝obj1→第2次调用拷贝构造函数
第2次调用拷贝构造函数→执行函数,试图拷贝obj1
试图拷贝obj1→第3次调用拷贝构造函数
第3次调用拷贝构造函数→试图拷贝obj1
... ...陷入无限递归循环
第n次调用拷贝构造函数→栈爆!💥
这个过程中调用了无数次拷贝构造函数,每调用一次,编译器就会分配一块分配新栈帧(大小为:拷贝构造函数的参数所占内存大小),最后导致栈溢出,还好,编译器根本不允许我们调用传参方法的拷贝构造函数。这不是单纯性能问题,是根本不可行。
- 纯逻辑推理:
如果想要调用拷贝构造函数→需要先拷贝obj1作为other
如果想要拷贝obj1→需要调用拷贝构造函数
如果想要调用拷贝构造函数→需要先拷贝obj1
如果想要拷贝obj1→需要调用拷贝构造函数
...陷入无限递归,永远都不会发生任何事情,包括栈爆,也不会发生。
这个过程根本没有产生栈帧,因为没有发生任何一次调用,每句话的开头都是“如果想”,还好,编译器根本不允许我们发生这个递归循环。所以这件事情也是逻辑必然——不能通过拷贝对象来定义如何拷贝对象。
现实现象
讲完了两种逻辑推理,我们明白了不仅仅是物理上会导致无限递归循环从而导致栈爆,从逻辑上来看,存在定义自指矛盾,陷入无限递归循环从而什么也不会发生和执行。那么基于现实,发生了什么:
先看小实验
class Test {
public:
Test(int) {} // 普通构造
Test(const Test other) {} // 传值的"拷贝构造"(实际编译报错)
};
int main() {
Test a(1);
Test b = a; // 这里触发
}
报错:
现代 C++ 编译器(C++11+)看到 Test(Test other) 不会把它识别为拷贝构造函数,而是当作普通构造函数。但由于Test 不能隐式转 Test 用于拷贝初始化,参数类型不匹配,编译器会报错 invalid constructor 或 no 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);
根据这三个缺陷,
- 语义混乱
- 语法丑陋
- 空指针风险
结论是:操作上可以传指针,但远远不如传引用。