🔷 第十章 类和对象
📄 一句话概括

复制构造函数="拿一个已有的对象,造一个新对象"时自动跑的那个构造函数。 你不写,编译器就免费送一个——它做的活叫 "浅拷贝":把每个成员逐个复制过去。成员是 int、double、char[] 这类"值"时,浅拷贝完全够用;成员是指针时,浅拷贝只复制"地址"不复制"内容"——于是两个对象指着同一块内存,这就是后面一堆麻烦(改一个动另一个、重复释放)的病根。实例02 演示的就是"自己写一个复制构造函数"。

📝 本节和前面几节的关系

它是 10.2 的第三个形态(另两个是默认构造和带参构造)。用到的旧知识:7.4 指针(为什么指针成员必须特殊对待)、7.5.1 数组名是首地址(数组成员的复制会发生什么)、8.3.1 结构体传值(其实是同一个机制在结构体上的表现——那时它安全,现在遇到指针就不安全了)。这一篇的结论会在后面讲动态内存时被再次请出来,现在务必把"浅拷贝的边界"想清楚。

一、什么时候会自动调用它

只要"用一个对象去造另一个对象",它就会跑:

写法 说明
Point b(a); 用 a 初始化 b
Point b = a; 也是复制构造(不是赋值!),别被 = 骗了
void f(Point p); f(a); 传值调用——形参 p 是 a 的副本
Point f() { Point t; return t; } 按值返回——返回值是临时副本(很多编译器会优化掉,但机制上会调用)
Point arr[3] = { a, b, c }; 数组初始化时

关键区分:Point b = a;(初始化)会调复制构造;而 b = a;(已经存在的 b 被赋值)不会——那调的是赋值运算符(后面章节的内容)。看"b 是不是新造的" 就能区分。

二、编译器免费送的那个:浅拷贝

你不写复制构造函数时,编译器生成的版本干这件事:

把每个成员,逐个复制过去。

class Point {
private:
    int x, y;
    // 编译器偷偷塞进来一个:
    // Point(const Point &other) : x(other.x), y(other.y) { }
};

对"纯值成员",这完全够用:

Point a(3, 4);
Point b(a);            // b.x = 3, b.y = 4
b.set(9, 9);           // 改 b
a.show();              // a 还是 (3, 4) —— 各是各的,互不干扰 ✅

因为 x、y 是 int,复制的是"值本身",不是"地址"。

但成员是指针时,事情就变了:

class Holder {
private:
    int *data;          // ← 指针成员
public:
    Holder(int v) { data = new int(v); }        // 在堆上申请
    // 编译器送的复制构造:data = other.data;  ← 只复制了"地址"!
};

看图理解:

浅拷贝之后
a.data 0x1000
b.data 0x1000 ← 同一个地址!

于是:

记住这句话:成员里有"自己申请的资源"(指针)时,编译器送的浅拷贝就是个雷。 这时候必须自己写复制构造函数。**(怎么安全地"复制内容"——也就是"深拷贝"——要用动态内存,会在后面章节展开;本篇先讲清"为什么要写"和"怎么写一个对浅拷贝的安全版本"。)

三、参数为什么必须是引用

复制构造函数的"标准长相"是这样的:

类名(const 类名 &other) { ... }

这里的 & 是必须的,不是可选的。原因:

// 假如写成传值:
Point(Point other) { ... }         // ❌

这个参数 other 自己就是一个 Point 对象——而"造 other 需要调用复制构造函数"……于是无限递归:要造副本,先得造副本,再得造副本……编译器直接禁止这种写法,会报:

error: copy constructor must pass its first argument by reference

至于 const(不是语法强制,但是强烈建议):

写法 后果
Point(Point &other) 能编译,但不能用 const 对象来复制(const Point 传给非 const 引用会被拒)
Point(const Point &other) 标准写法:既能复制普通对象,也能复制 const 对象,还保证不会改到原件

四、怎么"自己写一个":给指针成员做真复制

本篇给出的安全做法:自己申请一块新内存,把内容复制过去(而不是复制地址)。

class Holder {
private:
    int *data;

public:
    Holder(int v) {
        data = new int(v);                              // 自己申请一块
    }

    // ★ 复制构造函数:内容另存一份,而不是"再指一遍"
    Holder(const Holder &other) {
        data = new int(*other.data);                    // 新开一块,把值拷进去
        cout << "复制构造函数被调用" << endl;
    }

    ~Holder() {                                          // 10.3 会讲:对象消亡时跑
        delete data;                                     // 谁申请的谁释放
        cout << "析构:释放了 " << data << endl;
    }

    void set(int v) { *data = v; }
    int  get()      { return *data; }
    int* addr()     { return data; }                     // 只为观察地址
};

对比一下两种写法:

编译器送的浅拷贝 自己写的真复制
data 里存什么 原件的地址 一块新内存的地址
改 b 会影响 a 吗 会 不会
析构时会怎样 同一块内存释放两次 → 崩溃 各释放各的,安全

一句话记住:"没写复制构造函数 + 有指针成员"=埋了一颗雷。 判据就一条:我的类里有没有"指向自己申请资源的指针"? 有,就必须自己管复制。

五、实例02:使用复制构造函数

目标:写一个带指针成员的类,让它自己写复制构造函数,并验证复制出来的对象是真正独立的。

#include <iostream>
using namespace std;

class Holder {
private:
    int *data;
    int  tag;                       // 一个普通成员,用来区分是谁

public:
    // ① 带参构造函数
    Holder(int v, int t) : data(new int(v)), tag(t) {
        cout << "构造 #" << tag << ":data 地址 " << data << ",值 " << *data << endl;
    }

    // ② ★ 复制构造函数(实例02 的核心)
    Holder(const Holder &other) : data(new int(*other.data)), tag(other.tag + 100) {
        cout << "复制构造(源 #" << other.tag << " → 新 #" << tag << "):"
             << "新地址 " << data << ",值 " << *data << endl;
    }

    // ③ 析构函数(10.3 专讲,这里先让它帮忙观察)
    ~Holder() {
        cout << "析构 #" << tag << ":释放 " << data << endl;
        delete data;
    }

    void set(int v) { *data = v; }
    int  get() const { return *data; }
    int  address() const { return (int)data; }   // 转成整数方便打印比较
};

int main() {
    cout << "--- 造原件 ---" << endl;
    Holder a(10, 1);

    cout << "--- 用 a 造副本 b ---" << endl;
    Holder b(a);                          // ★ 走复制构造函数

    cout << "--- 各自的值和地址 ---" << endl;
    cout << "a: 地址 " << a.address() << ",值 " << a.get() << endl;
    cout << "b: 地址 " << b.address() << ",值 " << b.get() << endl;

    cout << "--- 改 b,看 a 有没有变 ---" << endl;
    b.set(999);
    cout << "a 的值 = " << a.get() << "(应为 10,没被影响)" << endl;
    cout << "b 的值 = " << b.get() << "(应为 999)" << endl;

    cout << "--- 程序结束,看析构顺序 ---" << endl;
    return 0;
}

看到什么算成功(地址数字各机器不同,关键是两组地址不相等、且没有重复释放):

--- 造原件 ---
构造 #1:data 地址 0x1f1a20,值 10
--- 用 a 造副本 b ---
复制构造(源 #1 → 新 #101):新地址 0x1f1a40,值 10
--- 各自的值和地址 ---
a: 地址 6609440,值 10
b: 地址 6609472,值 10
--- 改 b,看 a 有没有变 ---
a 的值 = 10(应为 10,没被影响)
b 的值 = 999(应为 999)
--- 程序结束,看析构顺序 ---
析构 #101:释放 0x1f1a40
析构 #1:释放 0x1f1a20

读结果四句话:

  1. "复制构造"那句在 Holder b(a); 处出现——证明它自动执行了(我们从没手动调过它)。
  2. 两个地址不相等(0x1f1a20 vs 0x1f1a40)——b 有自己的一块内存,这才叫真复制。
  3. 改 b 不影响 a(10 还在)——两个对象彻底独立。
  4. 两次析构、两个不同地址——没有重复释放,程序平静结束。

把复制构造函数删掉试试(把 ② 整段注释掉),会看到什么:

构造 #1:data 地址 0x1f1a20,值 10
a: 地址 6609440,值 10
b: 地址 6609440,值 10   ← 注意:地址和 a 一模一样!
a 的值 = 999(应为 10,没被影响)   ← ❌ a 被改坏了!
b 的值 = 999
析构 #1:释放 0x1f1a20
析构 #1:释放 0x1f1a20   ← ❌ 同一块内存释放了两次

注意最后两行:同一地址被释放两次——这就是"重复释放",真机上通常直接崩溃。这就是不写复制构造函数的代价,一次就够记住。

30 秒实验:在 main 里再加一行 Holder c = a;(注意是 =,一样调复制构造),看输出里又出现一次"复制构造"。Holder c = a; 和 Holder c(a); 效果完全相同——= 在这里只是"初始化的写法",不是赋值。这个区别(初始化 vs 赋值)值得专门记一笔。

六、什么时候必须自己写复制构造函数

类里有什么 需要自己写吗
只有 int / double / char[] / string 等值成员 不用,编译器送的就对
有指向自己申请内存的指针 必须写(否则浅拷贝埋雷)
有指针成员但"不拥有"它(只是借用,如指向外部数组) 通常不用(因为不负责释放)
有文件句柄、网络连接等资源 必须想清楚(复制一个句柄往往没有意义)
想禁止复制 有办法:把复制构造函数声明为私有、或(C++11 起)写成 = delete

"想禁止复制"的写法(很实用):

class Singleton {
private:
    Singleton(const Singleton &) = delete;              // C++11:明确禁止复制
    Singleton& operator=(const Singleton &) = delete;
public:
    Singleton() { }
};

再复制就会直接报错:use of deleted function 'Singleton::Singleton(const Singleton&)'。这个报错信息很友好,一眼就懂。

七、常见误区

  1. "Point b = a; 是赋值"——不是。它是初始化(b 是新造的),走的是复制构造函数;b = a;(b 已存在)才是赋值,走赋值运算符。看对象是不是新造的,就能分清。
  2. "编译器送的复制构造函数一定安全"——只在"没有需要自己管理的资源"时才安全。成员里有指针(且指向自己申请的内存)时,浅拷贝就是雷(第五节的两组输出,一个天一个地)。
  3. "复制构造函数参数写 Point other 也行"——不行,会无限递归。参数必须是引用(标准写法带 const)。
  4. "值传递的对象参数不调复制构造"——调。void f(Point p) 这种传值调用,每次调用都会造一个副本(6.2.2 讲过"值被复制",现在你知道这份复制是谁干的了)。这也是"传大对象要用 const &"的性能理由(8.3)。
  5. "写了复制构造函数,构造函数就算写全了"——不是。默认构造、带参构造、复制构造是三件独立的事,各自可能都需要(10.2 讲过:写了任何一个,编译器就不再送默认构造函数)。还有第三件:10.3 的析构函数。
自测三问(点开对答案) 1. 复制构造函数什么时候被自动调用?举三种情形。——**用一个对象初始化另一个对象**时:① `Point b(a);` 或 `Point b = a;`;② **传值调用** `f(a)`(形参是副本);③ **按值返回**对象、以及用对象初始化数组元素时。**注意 `b = a;`(b 已存在)不算——那是赋值运算。** 2. 编译器送的复制构造函数做了什么?什么时候不够用?——做**浅拷贝**:**把每个成员逐个复制**。成员都是值类型(`int`/`double`/字符数组)时够用;**成员是指针(指向自己申请的内存)时不够用**——浅拷贝只复制地址,**两个对象共用一块内存**,会出"改一个动另一个"和"重复释放"两个大问题。 3. 复制构造函数的参数为什么必须是引用?为什么建议加 `const`?——**必须引用**:写成传值会**无限递归**(造副本本身又要造副本),编译器直接报错。**建议 `const`**:这样**既能复制 `const` 对象,也保证不会改到原件**。

相关笔记