拷贝构造:传值传参的连锁反应,与隐式函数的三个局限
我的github:(https://github.com/xcx55/ubuntu-linux-project)
感谢各位大佬参观我的github!!
六大默认成员函数的第三个:拷贝构造。它回答两个问题------用已有对象创建新对象时,成员怎么复制?以及为什么它有一个全 C++ 独一份的硬性规定:第一个参数必须是引用。
一、拷贝构造是什么
定义一句话:如果一个构造函数的第一个参数是自身类类型的引用,且其余参数都有默认值,它就是拷贝构造------拷贝构造是一个特殊的构造函数。
cpp
Date(Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
用法就是拿一个已存在的对象去构造新对象:
cpp
Date d1(2030, 1, 1);
Date d2(d1); // 拷贝构造:以 d1 为模板造出 d2
普通构造函数是"按你给的方式初始化",拷贝构造是"按另一个对象的样子初始化"。它也是构造函数,所以同样受那套规矩管辖:隐式的拷贝构造永远有;显式一出,隐式就没。
二、为什么第一个参数必须是引用------无穷递归
这是拷贝构造最经典的面试题。假设允许传值:
cpp
Date(Date d) // 错误写法
推演一遍调用链就明白了:
Date d2(d1)要调拷贝构造;- 调用函数得先把实参 d1 传值传参给形参 d;
- 传值传参本身就是一次拷贝------自定义类型传值传参要先用拷贝构造初始化形参;
- 于是又要调一次拷贝构造,又要传值传参,又要拷贝......
每次要调用拷贝构造函数之前要先传值传参,传值传参是一种拷贝,又形成一个新的拷贝构造------无穷递归,编译器根本不给你这个机会,直接报错。
解法:第一个参数用引用 。引用传参不产生拷贝(别名直接绑定本体),递归链条在第一步就断了。所以这条不是"建议",是语法强制:拷贝构造的第一个参数必须是引用 (习惯上加 const:Date(const Date& d),因为拷贝不该改动被拷贝者)。
顺带理清:传值传参的底层分工
这套递归推演里藏着一条通用规则:传值传参怎么拷,取决于参数类型。
- 内置类型(int、float、double、指针......):传值传参就是直接字节对字节的拷贝,没有构造函数什么事;
- 自定义类型 :传值传参要先对形参进行初始化,而这个初始化就是由拷贝构造函数完成的------"传值传参是以拷贝构造函数完成的"。
所以引用不只是救了拷贝构造,也是所有自定义类型传参的第一原则:能用引用传参就用引用,别让编译器白白多走一次拷贝构造。
三、浅拷贝:编译器白送的版本,只做了一半
拷贝构造有隐式版本。它做的事是浅拷贝:把实参对象的成员变量逐个字节赋给新对象。
对 Date 这种纯 int 成员的类,浅拷贝完全够用------每个成员独立,怎么抄都不会错。
但对 Stack 这种成员里有指针的类,浅拷贝就是事故:
cpp
Stack st1;
Stack st2(st1); // 隐式浅拷贝:st2._a = st1._a,两个指针指向同一块空间
浅拷贝只抄了指针的值 ,没抄指针指向的空间------st1 和 st2 的 _a 指向同一块内存。接着析构时按栈帧从高地址到低地址的顺序清理,先析构 st2 再析构 st1,同一块空间被 free 两次------这就是 double free,程序直接崩。
笔记里那句"这样会析构两次,这就是因为值(浅拷贝)导致的,空间指针不变",把因果说得很准:问题不在析构,在拷贝那一刻就埋下了。
四、隐式函数的三个局限
把构造、拷贝构造、析构三个的"编译器白送版本"放一起看,笔记里总结得很干净:
- 构造函数------隐式版只能做无意义的初始化(内置类型成员根本不管),基本上都要自己写显式的重载;
- 拷贝构造------隐式版只会浅拷贝,成员里有指针就必须自己写深拷贝;
- 析构函数------隐式版管不了动态内存(它不知道哪些资源要你手动清理的策略)。
编译器确实想让这三个函数都"白送好用",但各有各的局限。于是学习它们就回到开头那两个角度:默认行为是什么?不满足就自己写。浅拷贝改深拷贝的具体写法,Stack 实现那篇会专门展开。
串一下这条线:拷贝构造 = 特殊的构造函数(第一个参数必须是自身类型的引用)→ 强制引用是因为传值传参要先拷贝、拷贝又要传值传参的无穷递归 → 传值传参对自定义类型就是调拷贝构造 → 隐式拷贝构造是浅拷贝 → 浅拷贝 + 指针成员 = 析构两次。到这里,六大函数的前三个都齐了,下一篇写第四个也是最难的一个:赋值运算符重载。