C++ 拷贝赋值运算符

如果两个同类型的对象 stu1 和 stu2 在前面都已经创建好了,当我们单独写下一行 stu2 = stu1; 时,程序并不会重新创建 stu2,而是拿 stu1 的数据去覆盖 stu2 原有的数据。

这种 “把一个已有对象的数据赋给另一个已存在对象” 的操作叫做 “拷贝赋值”,在 C++ 类中专门由特殊成员函数 “拷贝赋值运算符(Copy Assignment Operator)” 负责完成。

C++ 拷贝赋值运算符的语法

在 C++ 中,拷贝赋值运算符本质上是通过运算符重载的方式重载了赋值号(=),因此它的函数名固定写为 operator=。

当我们对两个已存在的对象执行 stu2 = stu1; 时,编译器会把它当作 stu2 调用成员函数来处理。函数内部由 this 指针指向等号左边的 stu2,等号右边的 stu1 则作为实参传给形参 other。

语法:

class 类名 {
public:
    类名& operator=(const 类名& other) {
        if (this != &other) {
            // 将 other 的成员依次赋给当前对象
        }
        return *this;
    }
};

说明:

编写拷贝赋值运算符时,我们需要重点掌握以下 3 个部分:

  • 形参 const 类名& other:代表等号右边作为数据来源的对象。使用 const 引用既能省去传参时的复制开销,又能保证右边对象的数据不会被意外修改。
  • 自赋值检查 if (this != &other):this 指向等号左边的当前对象,&other 表示获取右边对象的内存地址,注意这里的 & 是取地址符而不是引用符号。加上这层判断是为了防止出现 stu1 = stu1; 自己赋给自己的情况。
  • 返回值 return *this:解引用 *this 得到的就是当前对象本身。将返回类型设为引用 类名& 并返回 *this,不需要复制副本就能把赋值后的当前对象直接返回,从而支持 stu3 = stu2 = stu1; 连续赋值。

对于只包含普通成员的类,if (this != &other) 可以省去自己覆盖自己的一次无用功。而当类内部直接管理动态内存时,这层检查更是防止误删自身数据的必要步骤,我们会在下一节讲深拷贝时具体演示。

示例 1:使用拷贝赋值运算符

#include <iostream>
#include <string>

class Student {
private:
    std::string name;
    int score;

public:
    Student(const std::string& n, int s)
        : name(n), score(s) {}

    // 定义拷贝赋值运算符
    Student& operator=(const Student& other) {
        std::cout << "调用拷贝赋值运算符\n";
        if (this != &other) {
            name = other.name;
            score = other.score;
        }
        return *this;
    }

    void show() const {
        std::cout << name << ",成绩:" << score << "\n";
    }
};

int main() {
    Student stu1("小明", 95);
    Student stu2("Lucy", 80);

    std::cout << "赋值前 stu2:";
    stu2.show();

    stu2 = stu1;    // 将 stu1 赋给已存在的 stu2

    std::cout << "赋值后 stu2:";
    stu2.show();
    return 0;
}

运行结果如下。

赋值前 stu2:Lucy,成绩:80
调用拷贝赋值运算符
赋值后 stu2:小明,成绩:95

分析:

在执行 stu2 = stu1; 之前,stu2 已经是一个创建好的对象,保存着初始数据 “Lucy,成绩:80”。执行赋值时,对于这个例子来说,下面 2 种方式是等价的:

// 方式 1(推荐)
stu2 = stu1;

// 方式 2
stu2.operator=(stu1);

在实际开发中,我们都会直接使用方式 1 的等号写法,更加直观好懂。如果不小心写出了 stu1 = stu1;,由于左边的 this 与右边的 &other 地址相同,if (this != &other) 不成立,程序就会直接跳过赋值并返回 *this。

下面我们再来看一个例子,验证一下 return *this; 是如何支持连续赋值的。

示例 2:对象的连续赋值

#include <iostream>
#include <string>

class Student {
private:
    std::string name;
    int score;

public:
    Student(const std::string& n, int s)
        : name(n), score(s) {}

    Student& operator=(const Student& other) {
        if (this != &other) {
            name = other.name;
            score = other.score;
        }
        return *this;
    }

    void show() const {
        std::cout << name << ",成绩:" << score << "\n";
    }
};

int main() {
    Student stu1("小明", 95);
    Student stu2("Lucy", 80);
    Student stu3("Jack", 70);

    stu3 = stu2 = stu1;    // 连续赋值

    stu2.show();
    stu3.show();
    return 0;
}

运行结果如下。

小明,成绩:95
小明,成绩:95

分析:

赋值运算符(=)遵循 “从右往左” 的结合顺序。当执行 stu3 = stu2 = stu1; 时,程序会先计算右边的 stu2 = stu1,再把返回结果赋给左边的 stu3,按先后顺序拆开就相当于下面两步:

stu2.operator=(stu1);    // 更新并返回 stu2 的引用
stu3.operator=(stu2);    // 再拿 stu2 给 stu3 赋值

第一步执行完后,stu2 的数据更新为 “小明,成绩:95”,并通过 return *this; 把更新后的 stu2 本身返回出来。第二步紧接着拿这个返回的 stu2 继续给 stu3 赋值。

那如果把 operator= 的返回类型写成 void 行不行呢?如果只写单次赋值 stu2 = stu1;,返回 void 也能编译通过。

但一旦写出 stu3 = stu2 = stu1;,由于第一步没有返回对象,第二步拿不到实参就会直接报错。为了让自定义类像 int 基本类型一样支持连续赋值,我们统一将返回类型写成 类名& 并在末尾返回 *this。

拷贝构造与拷贝赋值的区别

初学这一章时,小伙伴们最容易搞混的就是 Student stu2 = stu1; 和 stu2 = stu1;。因为两行代码中间都有一个等号(=),很多初学者会误以为只要看到等号就是调用 operator=。

其实区分它们只需要看一点:等号左边的目标对象,在这一行代码之前是否已经存在。如果在当前行才声明创建,调用的就是上一节学过的拷贝构造函数。如果之前早就创建好了,调用的就是拷贝赋值运算符。它们之间的区别如下表所示。

拷贝构造与拷贝赋值对比
对比维度 拷贝构造函数 拷贝赋值运算符
等号左边的对象 此前还不存在,正在新建对象 此前已经创建完成
主要作用 使用已有对象初始化新对象 使用已有对象覆盖目标对象的旧数据
常见写法 Student b = a; 或 Student b(a); 已定义 b 后,执行 b = a;
函数声明格式 类名(const 类名& other) 类名& operator=(const 类名& other)

下面我们把拷贝构造函数和拷贝赋值运算符同时写进 Student 类中,运行代码看看每一行分别触发了哪个函数。

示例 3:对比拷贝构造与拷贝赋值

#include <iostream>
#include <string>

class Student {
private:
    std::string name;
    int score;

public:
    Student(const std::string& n, int s)
        : name(n), score(s) {}

    // 拷贝构造函数
    Student(const Student& other)
        : name(other.name), score(other.score) {
        std::cout << "调用拷贝构造函数\n";
    }

    // 拷贝赋值运算符
    Student& operator=(const Student& other) {
        std::cout << "调用拷贝赋值运算符\n";
        if (this != &other) {
            name = other.name;
            score = other.score;
        }
        return *this;
    }
};

int main() {
    Student a("小明", 95);
    Student b = a;    // b 在当前行新建,调用拷贝构造

    Student c("Lucy", 80);
    c = a;            // c 已存在,调用拷贝赋值运算符
    return 0;
}

运行结果如下。

调用拷贝构造函数
调用拷贝赋值运算符

分析:

执行 Student b = a; 时,最左边带有类型名 Student,表示正在声明并创建新对象 b,因此触发拷贝构造函数。而在执行 c = a; 时,对象 c 在上一行就已经创建完毕了,这一行只是修改已有对象 c 的数据,因此触发拷贝赋值运算符。

提示 平时写代码时,如果创建新对象时已经拿到了来源对象 a,推荐直接写成 Student b = a; 一步完成初始化,省去先定义空对象再执行 b = a; 覆盖数据的多余开销。

默认拷贝赋值与 = default / = delete

如果在类中没有显式定义拷贝赋值运算符,C++ 编译器同样会自动提供一个公有的 “默认拷贝赋值运算符”,按照成员声明顺序,把来源对象的各个成员依次赋值给目标对象的同名成员。

比如对于包含 width 和 height 两个整型成员的 Rect 矩形类,编译器自动生成的默认拷贝赋值运算符,效果就相当于下面这段代码:

// 编译器自动生成的默认拷贝赋值运算符
Rect& operator=(const Rect& other) {
    width = other.width;
    height = other.height;
    return *this;
}

和拷贝构造函数一样,我们也可以使用 = default 显式保留默认拷贝赋值运算符。

如果某个类代表网络监听端口这类不可复制的独占资源,在实际开发中我们通常会把拷贝构造函数和拷贝赋值运算符成对设为 = delete,同时禁止 “复制创建新对象” 和 “互相覆盖已有对象”。请看下面例子。

示例 4:默认拷贝赋值与禁止拷贝

#include <iostream>

// 矩形尺寸类:保留默认拷贝赋值
class Rect {
private:
    int width;
    int height;

public:
    Rect(int w, int h) : width(w), height(h) {}

    // 显式保留默认拷贝赋值
    Rect& operator=(const Rect& other) = default;

    void show() const {
        std::cout << width << " x " << height << "\n";
    }
};

// 独占端口类:成对禁用拷贝构造与拷贝赋值
class ServerPort {
private:
    int port;

public:
    ServerPort(int p) : port(p) {}

    ServerPort(const ServerPort& other) = delete;
    ServerPort& operator=(const ServerPort& other) = delete;

    void show() const {
        std::cout << "监听端口:" << port << "\n";
    }
};

int main() {
    Rect r1(800, 600);
    Rect r2(300, 200);
    r2 = r1;    // 调用默认拷贝赋值运算符
    r2.show();

    ServerPort p1(8080);
    ServerPort p2(9000);
    p1.show();
    p2.show();
    return 0;
}

运行结果如下。

800 x 600
监听端口:8080
监听端口:9000

分析:

在这个例子中,Rect 类通过 = default 保留了默认拷贝赋值运算符,执行 r2 = r1; 时会把 r1 的宽高依次赋给 r2,将尺寸从 300 x 200 更新为 800 x 600。

而 ServerPort 类把拷贝构造函数和 operator= 成对设为了 = delete。一旦我们在代码中写出 ServerPort p3 = p1; 尝试复制新对象,或者写出 p2 = p1; 尝试覆盖已有对象,编译器都会在编译阶段直接报错拦截。

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号