Lesson 0007C · 拷贝控制

① 程序设计基础 — 指针进阶课 2

上节课(0007B):栈 / 堆、new / delete、悬空指针。指针难管,是因为“一块内存可能被好几个名字指着”。
这节课:把这个麻烦搬进类里。当类里有个 new 出来的指针,拷贝对象时到底复制了什么?析构时会不会删两次?答案就是深浅拷贝和三法则。
三句话速览
  1. 默认拷贝只照抄指针地址,这叫浅拷贝。
  2. 两个对象指同一块内存,析构时就会删两次。
  3. 有 new 成员就自己写三个函数,别用默认。

1. 赋值和初始化会“复制”对象

int a = 10;
int b = a;      // b 是 a 的副本,改 b 不影响 a

string s1 = "hello";
string s2 = s1; // s2 是副本,各自管自己的内存

struct Point { int x, y; };
Point p1 = {1, 2};
Point p2 = p1;  // 逐个成员复制:p2.x=1, p2.y=2

对于没有指针成员的类,编译器自动生成的“逐成员复制”完全够用。问题出在类里有指针的时候。

为什么传值这么慢? void f(string s) 一调用就复制整个字符串。改成 void f(const string& s) 只传引用,不复制 —— 这就是 0007 里说的“引用传大对象”。

2. 浅拷贝会出事

class IntArray {
public:
    int* data;              // 指向堆上的数组
    int size;

    IntArray(int n) : size(n) { data = new int[n](); }  // 全初始化为 0
    ~IntArray() { delete[] data; }
};

int main() {
    IntArray a(5);
    a.data[0] = 100;

    IntArray b = a;         // 默认拷贝构造:只复制指针
    cout << b.data[0];     // 100 —— b.data 和 a.data 是同一块内存!
}

这就是浅拷贝:只复制了指针的值(地址),没有复制指针指向的那块数据。两个对象共用一块堆内存。

问题:main 结束时先析构 b,delete[] b.data 把内存还了;再析构 a,又 delete[] a.data 同一个地址 —— 重复释放,程序崩溃。
根因:谁 new 的,谁 delete。现在两个人以为自己都该 delete 同一块内存。

3. 拷贝构造函数:初始化时怎么复制

class IntArray {
public:
    int* data;
    int size;

    IntArray(const IntArray& other) {          // 拷贝构造
        size = other.size;
        data = new int[size];                  // 自己申请一块新内存
        for (int i = 0; i < size; i++)
            data[i] = other.data[i];           // 逐个复制内容(深拷贝)
    }
};
拷贝构造的坑
① 必须用 const IntArray&:按值传参会无限递归(复制形参又要调用拷贝构造);
② 什么时候调用:IntArray b = a;、f(a)(按值传参)、return a;;
③ 把 data 指向的内容也复制一份,才叫深拷贝;只复制指针就是浅拷贝。

4. 赋值运算符:已经存在的对象怎么复制

class IntArray {
public:
    int* data;
    int size;

    IntArray& operator=(const IntArray& other) {
        if (this == &other) return *this;      // ① 自己给自己赋值
        delete[] data;                          // ② 先释放自己原有的内存
        size = other.size;                      // ③ 再深拷贝
        data = new int[size];
        for (int i = 0; i < size; i++)
            data[i] = other.data[i];
        return *this;                           // ④ 支持链式赋值 a = b = c
    }
};
赋值运算符的坑
① 自赋值:a = a 时如果不先判断 this == &other,就会把自己的数据删掉再复制自己;
② 顺序:先 delete[] 再 new;反过来如果 new 失败,对象就处于半破坏状态;
③ 要返回引用:IntArray& 加 return *this;,否则 a = b = c 链式赋值不成立。

5. 三法则 Rule of Three

只要类里管理了裸指针(new 出来的),下面三个函数就必须一起写:

函数什么时候被调用你该做什么
析构函数 ~IntArray()对象销毁时释放你 new 的内存
拷贝构造 IntArray(const IntArray&)用对象初始化另一个对象深拷贝,申请自己的内存
拷贝赋值 operator=两个已有对象之间赋值自赋值判断 + 先释放旧内存再深拷贝
为什么要三个一起? 只写析构 → 浅拷贝后重复释放;只写拷贝构造 → 赋值时还是浅拷贝。
更省事的办法:成员用 vector<int> 或 string 代替裸指针。它们自己实现了三法则,你什么都不用写 —— 这也是为什么标准库容器比裸数组好。

6. 完整示例:能用会跑的 IntArray

class IntArray {
private:
    int* data;
    int size;

public:
    IntArray(int n) : size(n) { data = new int[n](); }

    IntArray(const IntArray& other) : size(other.size) {
        data = new int[size];
        for (int i = 0; i < size; i++) data[i] = other.data[i];
    }

    IntArray& operator=(const IntArray& other) {
        if (this == &other) return *this;
        delete[] data;
        size = other.size;
        data = new int[size];
        for (int i = 0; i < size; i++) data[i] = other.data[i];
        return *this;
    }

    ~IntArray() { delete[] data; }

    int& operator[](int i) { return data[i]; }
    int getSize() const { return size; }
};

下面用它跑一遍:

int main() {
    IntArray a(3);
    a[0] = 10; a[1] = 20; a[2] = 30;

    IntArray b = a;        // 拷贝构造:深拷贝
    b[0] = 99;             // 改 b 不影响 a

    IntArray c(1);
    c = a;                 // 拷贝赋值:深拷贝
    c[1] = 77;

    cout << a[0] << " " << b[0] << " " << c[1];
    // 输出:10 99 77 —— 三个对象互不干扰
}

7. 坑点清单

8. 小测

Q1. 类里有 new 出来的指针成员,用默认拷贝会怎样?
Q2. 下面哪个是拷贝构造函数?
Q3. “三法则”指哪三个函数?
Q4. operator= 里为什么要先判断 this == &other?

9. 动手练习

题目 1:手写三法则
写一个 MyString 类,内部用 char* data; 存字符串。
要求实现:构造函数(new char[...])、析构函数、拷贝构造、拷贝赋值。
测试:MyString a("hello"); MyString b = a; b.data[0] = 'H'; 打印两者,确认互不影响。
题目 2(选做):换成 vector 试一次
把题目 1 的 char* data 换成 vector<char>,然后把三法则全删掉。
看看程序是否依然正确 —— 体会“数据交给标准库管”有多省心。
卡住时的自查顺序
  1. 崩在哪一行?如果是析构里的 delete[],先怀疑浅拷贝留下的重复指针。
  2. 拷贝构造的参数是不是写成了按值传参?改成 const 类名&。
  3. operator= 里的顺序对不对:判断自赋值 → delete[] → new → 逐个复制 → return *this。
  4. 还不对:把三法则里的每个函数第一行都加一句 cout << "copy ctor";,看看到底哪个函数被调用了、调了几次。

本课小结

下一课预告
0008 STL 算法与关联容器 —— 手写排序、手写去重、手写查找,到这里可以停一停了。标准库把 sort、find、set、map 都写好了,一行调用就能用;这一课还会顺带讲清楚“迭代器”到底是什么东西。

← 上一课:指针与内存模型 · 课程对照表

视频 黑马 C++ 核心编程:对象特性(构造函数、拷贝构造、深拷贝与浅拷贝)

书籍 C++ Primer Plus 第 12 章(动态内存与类)、第 13 章(拷贝控制)