0

0

C++对象拷贝构造与内存分配关系

P粉602998670

P粉602998670

发布时间:2025-09-21 15:27:01

|

208人浏览过

|

来源于php中文网

原创

对象拷贝时若含指针,默认浅拷贝会导致多对象共享同一内存,引发双重释放或数据污染;深拷贝通过自定义拷贝构造函数与赋值运算符,为新对象分配独立内存并复制内容,避免资源冲突;C++11移动语义进一步优化,以右值引用实现资源“窃取”,转移而非复制内存,提升性能。

c++对象拷贝构造与内存分配关系

C++中,对象拷贝构造与内存分配的关系,说白了,就是当你复制一个对象时,它内部的数据,尤其是那些在堆上动态分配的资源,究竟是跟着新对象一起“复制”一份全新的内存,还是仅仅让新旧对象共享同一块内存地址。这听起来有点抽象,但它直接决定了你的程序会不会出现内存泄露、双重释放,甚至莫名其其妙的崩溃。在我看来,理解这一点是掌握C++资源管理的关键一步,它远比你想象的要复杂,也更有趣。

当一个C++对象被拷贝时,无论是通过拷贝构造函数还是拷贝赋值运算符,其核心就在于如何处理这个对象所拥有的资源。默认情况下,C++编译器会为我们生成一个“成员逐一拷贝”的版本。对于那些基本类型(如int、double)或者不含指针的复合类型,这通常没什么问题,因为每个成员都会被直接复制一份,新旧对象各自拥有自己的数据。

然而,一旦你的类中包含了指向动态分配内存的指针(比如

char*
指向一个堆上的字符串,或者
int*
指向一个整数数组),问题就来了。默认的成员逐一拷贝,只会把指针本身的值(也就是内存地址)复制给新对象。这意味着,新旧两个对象内部的指针,都指向了堆上的同一块内存。这就是所谓的“浅拷贝”。

浅拷贝的后果是灾难性的。当其中一个对象被销毁时,它的析构函数会释放这块共享的内存。而当另一个对象也被销毁时,它的析构函数会试图去释放一块已经被释放过的内存,这就会导致“双重释放”(double-free)错误,程序轻则崩溃,重则产生难以追踪的内存腐败。更别提,一个对象对共享内存的修改,会悄无声息地影响到另一个对象,这显然不是我们期望的“拷贝”。

立即学习C++免费学习笔记(深入)”;

为了解决这个问题,我们需要实现“深拷贝”。深拷贝的核心在于,当复制一个对象时,如果它拥有动态分配的资源,我们不仅要复制对象本身,还要为这些动态资源在堆上重新分配一块全新的内存,并将旧内存中的内容复制到新内存中。这样,新旧对象就各自拥有了独立的资源,互不影响。这通常涉及到自定义拷贝构造函数和拷贝赋值运算符,在其中显式地进行内存分配(

new
)和内容复制。

为什么默认拷贝构造函数有时会引发内存问题?

这几乎是所有C++初学者都会遇到的一个坑。简单来说,默认拷贝构造函数执行的是“位拷贝”或者说“浅拷贝”。对于非指针成员,它会按位复制,这没毛病。但对于指针成员,它只会复制指针变量本身存储的地址值,而不会去复制指针所指向的那块内存区域的内容。

想象一下,你有一个

MyString
类,内部有个
char* data
成员,它指向了堆上的一段字符串。当你写下
MyString s2 = s1;
时,如果使用的是默认拷贝构造函数,那么
s1.data
s2.data
会指向同一块堆内存。

现在,

s1
s2
都认为自己“拥有”这块内存。当
s1
的生命周期结束,它的析构函数会调用
delete[] data;
来释放这块内存。一切看起来很正常。然而,当
s2
的生命周期也结束时,它的析构函数同样会调用
delete[] data;
。此时,它试图释放的内存地址,已经被
s1
释放过了!这就是典型的“双重释放”错误。程序很可能会因此崩溃,或者出现不可预测的行为。

此外,如果

s1
s2
中的任何一个修改了
data
指向的内容,另一个对象也会受到影响,因为它们操作的是同一块内存。这显然违背了“拷贝”的语义——我们期望拷贝后的对象是独立的。这种情况下,默认拷贝构造函数的设计哲学(效率优先,假设用户会处理复杂资源)就与实际需求产生了冲突。

如何正确实现深拷贝以避免资源泄露和悬空指针?

正确实现深拷贝,关键在于“重新分配”和“复制内容”。它要求我们手动编写拷贝构造函数和拷贝赋值运算符,来处理类中动态分配的资源。

海螺语音
海螺语音

海螺AI推出的AI语音生成工具,支持多种语种、情绪和效果。

下载

我们以一个简单的

MyString
类为例,它内部管理一个
char*
类型的动态字符串:

#include 
#include  // For strlen, strcpy

class MyString {
public:
    char* data;
    size_t length;

    // 构造函数
    MyString(const char* str = "") : length(strlen(str)) {
        data = new char[length + 1]; // +1 for null terminator
        strcpy(data, str);
        std::cout << "Constructor: " << data << std::endl;
    }

    // 析构函数:释放动态分配的内存
    ~MyString() {
        if (data) {
            std::cout << "Destructor: " << data << std::endl;
            delete[] data;
            data = nullptr; // Good practice to nullify
        }
    }

    // 深拷贝构造函数
    MyString(const MyString& other) : length(other.length) {
        data = new char[length + 1]; // 1. 为新对象分配新的内存
        strcpy(data, other.data);    // 2. 复制内容
        std::cout << "Deep Copy Constructor: " << data << std::endl;
    }

    // 深拷贝赋值运算符
    MyString& operator=(const MyString& other) {
        if (this == &other) { // 处理自我赋值,避免删除自己的资源
            return *this;
        }

        // 1. 释放旧资源
        delete[] data;

        // 2. 为新对象分配新的内存
        length = other.length;
        data = new char[length + 1];

        // 3. 复制内容
        strcpy(data, other.data);

        std::cout << "Deep Copy Assignment: " << data << std::endl;
        return *this;
    }

    // 打印字符串
    void print() const {
        std::cout << "String: " << data << std::endl;
    }
};

int main() {
    MyString s1("Hello, C++");
    MyString s2 = s1; // 调用深拷贝构造函数
    MyString s3;
    s3 = s1;          // 调用深拷贝赋值运算符

    s1.print();
    s2.print();
    s3.print();

    // 尝试修改s1,看是否影响s2和s3
    // 这里为了简化,不提供修改接口,但如果提供,它们将是独立的。
    // 例如,如果s1内部修改了data指向的内存,s2和s3不会受影响。

    return 0;
}

在这个例子中:

  1. 拷贝构造函数
    MyString(const MyString& other)
    :它接收一个常量引用作为参数。在函数体内部,我们首先为
    data
    成员重新分配一块足够大的内存,然后使用
    strcpy
    other.data
    指向的内容复制到新分配的内存中。这样,
    s2
    就拥有了与
    s1
    完全独立的一份“Hello, C++”副本。
  2. 拷贝赋值运算符
    operator=(const MyString& other)
    :这个稍微复杂一些。它需要先检查是否是自我赋值(
    this == &other
    ),以防止在释放旧资源时把源对象的资源也删掉。接着,它会释放当前对象(
    this
    )旧的
    data
    内存,然后重新分配内存,并复制
    other
    的内容。最后返回
    *this
    以便链式赋值。

通过这种方式,我们确保了每个

MyString
对象在拷贝后都拥有自己独立的内存资源,从而避免了双重释放和悬空指针的问题。这就是所谓的“Rule of Three”(如果你定义了析构函数、拷贝构造函数、拷贝赋值运算符中的任何一个,就应该定义全部三个),在C++11后扩展为“Rule of Five”(增加了移动构造函数和移动赋值运算符)。

C++11后的移动语义如何影响对象拷贝和内存管理?

C++11引入的移动语义(Move Semantics)是对传统拷贝行为的一次重大优化,尤其是在处理大型对象或含有动态资源的对象时。它并没有取代拷贝,而是提供了一种更高效的资源转移方式,而不是复制。

我们知道,深拷贝涉及到内存的重新分配和内容的复制,这对于大型对象来说开销是很大的。想象一下,一个函数返回一个

MyString
对象,或者将一个
MyString
对象传递给另一个函数,如果每次都进行深拷贝,性能会很差。在这些场景下,源对象往往是一个即将被销毁的临时对象,它的资源我们不再需要,与其深拷贝,不如直接“偷”过来。

移动语义就是为了解决这个问题。它通过移动构造函数

MyString(MyString&& other)
)和移动赋值运算符
MyString& operator=(MyString&& other)
)来实现。这里的
&&
表示右值引用,它通常绑定到临时对象或即将被销毁的对象。

在移动构造函数中,我们不再为新对象分配内存,也不再复制内容。相反,我们直接将源对象(

other
)的资源(比如
data
指针)“偷”过来,赋给新对象,然后将源对象的指针置为
nullptr
,这样源对象在销毁时就不会错误地释放被“偷走”的资源了。

// 移动构造函数
MyString(MyString&& other) noexcept : data(nullptr), length(0) { // 初始化为安全状态
    data = other.data;      // 1. 窃取资源
    length = other.length;

    other.data = nullptr;   // 2. 将源对象的资源指针置空
    other.length = 0;       // 避免源对象析构时释放资源
    std::cout << "Move Constructor: " << data << std::endl;
}

// 移动赋值运算符
MyString& operator=(MyString&& other) noexcept {
    if (this == &other) {
        return *this;
    }

    delete[] data; // 释放当前对象的旧资源

    data = other.data;      // 1. 窃取资源
    length = other.length;

    other.data = nullptr;   // 2. 将源对象的资源指针置空
    other.length = 0;       // 避免源对象析构时释放资源
    std::cout << "Move Assignment: " << data << std::endl;
    return *this;
}

通过移动语义,内存管理变得更加高效:

  • 拷贝构造函数:仍然用于需要独立副本的场景,它会进行新的内存分配和内容复制。
  • 移动构造函数:用于资源所有权转移的场景,它不会分配新内存,只是简单地将指针从一个对象转移到另一个对象,并将源对象的指针清空。这大大减少了不必要的内存分配和数据复制,提升了性能。

在现代C++编程中,我们倾向于遵循“Rule of Zero”或“Rule of Five”。“Rule of Zero”意味着如果可能,尽量使用

std::unique_ptr
std::shared_ptr
等智能指针或标准库容器来管理资源,让它们自动处理拷贝和移动,从而避免手动编写这些特殊成员函数。如果必须手动管理资源,那么就应该完整实现Rule of Five,确保拷贝、移动和析构都得到妥善处理。

相关专题

更多
java基础知识汇总
java基础知识汇总

java基础知识有Java的历史和特点、Java的开发环境、Java的基本数据类型、变量和常量、运算符和表达式、控制语句、数组和字符串等等知识点。想要知道更多关于java基础知识的朋友,请阅读本专题下面的的有关文章,欢迎大家来php中文网学习。

1427

2023.10.24

java基础知识汇总
java基础知识汇总

java基础知识有Java的历史和特点、Java的开发环境、Java的基本数据类型、变量和常量、运算符和表达式、控制语句、数组和字符串等等知识点。想要知道更多关于java基础知识的朋友,请阅读本专题下面的的有关文章,欢迎大家来php中文网学习。

1427

2023.10.24

Go语言中的运算符有哪些
Go语言中的运算符有哪些

Go语言中的运算符有:1、加法运算符;2、减法运算符;3、乘法运算符;4、除法运算符;5、取余运算符;6、比较运算符;7、位运算符;8、按位与运算符;9、按位或运算符;10、按位异或运算符等等。本专题为大家提供相关的文章、下载、课程内容,供大家免费下载体验。

221

2024.02.23

php三元运算符用法
php三元运算符用法

本专题整合了php三元运算符相关教程,阅读专题下面的文章了解更多详细内容。

69

2025.10.17

c语言const用法
c语言const用法

const是关键字,可以用于声明常量、函数参数中的const修饰符、const修饰函数返回值、const修饰指针。详细介绍:1、声明常量,const关键字可用于声明常量,常量的值在程序运行期间不可修改,常量可以是基本数据类型,如整数、浮点数、字符等,也可是自定义的数据类型;2、函数参数中的const修饰符,const关键字可用于函数的参数中,表示该参数在函数内部不可修改等等。

515

2023.09.20

js 字符串转数组
js 字符串转数组

js字符串转数组的方法:1、使用“split()”方法;2、使用“Array.from()”方法;3、使用for循环遍历;4、使用“Array.split()”方法。本专题为大家提供js字符串转数组的相关的文章、下载、课程内容,供大家免费下载体验。

246

2023.08.03

js截取字符串的方法
js截取字符串的方法

js截取字符串的方法有substring()方法、substr()方法、slice()方法、split()方法和slice()方法。本专题为大家提供字符串相关的文章、下载、课程内容,供大家免费下载体验。

202

2023.09.04

java基础知识汇总
java基础知识汇总

java基础知识有Java的历史和特点、Java的开发环境、Java的基本数据类型、变量和常量、运算符和表达式、控制语句、数组和字符串等等知识点。想要知道更多关于java基础知识的朋友,请阅读本专题下面的的有关文章,欢迎大家来php中文网学习。

1427

2023.10.24

苹果官网入口直接访问
苹果官网入口直接访问

苹果官网直接访问入口是https://www.apple.com/cn/,该页面具备0.8秒首屏渲染、HTTP/3与Brotli加速、WebP+AVIF双格式图片、免登录浏览全参数等特性。本专题为大家提供相关的文章、下载、课程内容,供大家免费下载体验。

6

2025.12.24

热门下载

更多
网站特效
/
网站源码
/
网站素材
/
前端模板

精品课程

更多
相关推荐
/
热门推荐
/
最新课程
Go 教程
Go 教程

共32课时 | 2.9万人学习

Go语言实战之 GraphQL
Go语言实战之 GraphQL

共10课时 | 0.8万人学习

关于我们 免责申明 举报中心 意见反馈 讲师合作 广告合作 最新更新
php中文网:公益在线php培训,帮助PHP学习者快速成长!
关注服务号 技术交流群
PHP中文网订阅号
每天精选资源文章推送

Copyright 2014-2025 https://www.php.cn/ All Rights Reserved | php.cn | 湘ICP备2023035733号