【C++】memcpy导致的深拷贝问题

发布于:2025-09-07 ⋅ 阅读:(16) ⋅ 点赞:(0)

代码与讲解承接上文:【C++】vector 深度剖析及模拟实现-CSDN博客

memcpy:更深一层次的深浅拷贝问题

/* 自定义类型 */
void test_vector5()
{
        vector<string> v;
        v.push_back("11111111111111111111111111111");
        v.push_back("22222222222222222222222222222");
        v.push_back("33333333333333333333333333333");
        v.push_back("44444444444444444444444444444");

        for (auto e : v)
        {
                cout << e << " ";
        }
        cout << endl;
}

打印结果是 3 和 4 没有问题,但是 1 和 2 都是乱码。是扩容时出现了问题。

void reserve(size_t n)
{
        // 提前算好size,不然后续会改变 _start 位置,就算不了size了
        size_t sz = size();
        if (n > capacity())
        {
                T* tmp = new T[n];
                if (_start)        //防止第一次进来_start为空,memcpy出错
                {
                        memcpy(tmp, _start, sizeof(T) * sz);
                        delete[] _start;/* 这里出现了问题 */
                }
                _start = tmp;
                _finish = tmp + sz;
                _endofstorage = tmp + n;
        }
}

问题分析:

  1. memcpy是内存的二进制格式拷贝,将一段内存空间中内容原封不动的拷贝到另外一段内存空间中

  2. 如果拷贝的是自定义类型的元素,memcpy既高效又不会出错,但如果拷贝的是自定义类型元素,并且自定义类型元素中涉及到资源管理时,就会出错,因为memcpy的拷贝实际是浅拷贝。

问题根源:memcpy 的浅拷贝特性

memcpy 函数执行的是逐字节的浅拷贝,它只是简单地将内存中的字节从一个位置复制到另一个位置,而不会调用任何构造函数或赋值运算符。

对于 vector<string> 这种情况:

  • 每个 string 对象内部包含指向实际字符串数据的指针

  • 使用 memcpy 时,只是复制了这些指针值,而不是指针指向的实际字符串数据

  • 当原 vector 被销毁时,原 string 对象会调用析构函数释放它们指向的内存

  • 但新 vector 中的 string 对象仍然指向已被释放的内存区域,导致悬空指针

  • 访问这些悬空指针指向的内存就是未定义行为,表现为乱码或程序崩溃

解决方案:使用循环赋值实现深拷贝

当将扩容代码改为:

void reserve(size_t n)
{
        // 提前算好size,不然后续会改变 _start 位置,就算不了size了
        size_t sz = size();
        if (n > capacity())
        {
                T* tmp = new T[n];
                if (_start)        //防止第一次进来_start为空,memcpy出错
                {
                        // memcpy(tmp, _start, sizeof(T) * sz);        拷贝自定义类型时会导致浅拷贝问题
                        for (size_t i = 0; i < sz; ++i)
                        {
                                tmp[i] = _start[i];
                        }
                        delete[] _start;
                }
                _start = tmp;
                _finish = tmp + sz;
                _endofstorage = tmp + n;
        }
}

这里发生了以下关键变化:

  1. 调用了赋值运算符:对于每个元素,都会调用 string::operator=,这是一个深拷贝操作

  2. 创建独立副本:每个新 string 对象都会分配自己的内存并复制字符串内容

  3. 避免悬空指针:新旧 vector 中的 string 对象指向不同的内存区域,互不影响

Vector 的内存布局

当你创建一个vector<string>时,内存布局是这样的:

_vector 对象本身:
_start    -> [string对象1][string对象2][string对象3][string对象4]...
_finish   -> 指向最后一个元素的下一个位置
_endofstorage -> 指向分配的内存块的末尾

关键点是:vector 存储的是 string 对象本身,而不是指向 string 对象的指针。这些 string 对象在内存中是连续存储的。

“Vector 存储的是 string 对象本身”的含义

(下图中的string类成员是假设出来的,实际成员可能不一样,但内存布局是一样的)

_start[0] 这个内存位置存储的是:
[ char* _str | size_t _size | size_t _capacity | ...其他成员 ]

更详细的内存结构图说明:

 Vector内存布局 (栈上或堆上)
┌─────────────────────────────────────────────────────────────┐
│  _start指针  │ 指向vector内部数组的起始位置                 │
├─────────────────────────────────────────────────────────────┤
│  _finish指针 │ 指向最后一个元素的下一个位置                 │
├─────────────────────────────────────────────────────────────┤
│_endofstorage指针│ 指向分配的内存块的末尾                    │
└─────────────────────────────────────────────────────────────┘
    ↓
    ┌─────────┬─────────┬─────────┬─────────┐ ← vector内部数组(在堆上)
    │ string0 │ string1 │ string2 │ string3 │
    └─────────┴─────────┴─────────┴─────────┘
        │         │         │         │
        │         │         │         │
        ▼         ▼         ▼         ▼
    ┌─────┐   ┌─────┐   ┌─────┐   ┌─────┐   ← 每个string对象的_str成员指向的
    │"1111│   │"2222│   │"3333│   │"4444│     字符串数据(也在堆上,但不同位置)
    └─────┘   └─────┘   └─────┘   └─────┘

关键点分解:

  1. vector的对象数组:当你创建vector<string> v(4)时,vector会在堆上分配一块足够大的连续内存,用来存放4个完整的string对象。

  2. 每个string对象:这块内存中的每个"格子"都包含一个完整的string对象,包括:

    1. char* _str(指针,通常4或8字节)

    2. size_t _size(通常4或8字节)

    3. size_t _capacity(通常4或8字节)

    4. 可能的其他成员变量

  3. 字符串数据:每个string对象的_str成员指向另一块堆内存,那里存储着实际的字符串内容("1111", "2222"等)。

为什么循环赋值有效

现在让我们看看循环:

for (size_t i = 0; i < sz; ++i)
{
    tmp[i] = _start[i];
}

对于每次迭代:

  1. _start[i]获取第i个string对象

  2. tmp[i]获取新数组中第i个位置(此时可能是一个未初始化的string对象)

  3. 调用string的赋值运算符string::operator=,将右侧string的内容复制到左侧string

重要的是:这不是简单的内存拷贝,而是调用了string类的赋值运算符,它会进行深拷贝 - 分配新的内存并复制字符串内容。

重新理解拷贝问题

现在我们就能明白为什么memcpy有问题而循环赋值正确了:

  • memcpy:只复制了vector数组内存块(包含string对象的成员变量),包括复制了_str指针值。结果是新旧vector中的string对象指向相同的字符串数据内存。

  • 循环赋值:tmp[i] = _start[i]调用了string的赋值运算符,这个运算符会:

    • 释放tmp[i]原有资源(如果有)

    • 为新的字符串数据分配内存

    • 复制字符串内容

    • 更新size和capacity成员

一个很好的验证方式

我们可以添加一些调试输出来验证这个理解:

void test_debug() {
    vector<string> v;
    v.push_back("dfb");
    v.push_back("asdf ds akjfhksdhfkhasdfkhskdfhk");
    v.push_back("12bbbbbb6161rtb616t1b6r1t6516161bbb");
    v.push_back("646asdg56as6dg65s16551agsd");
    
    cout << "Address of vector array: " << (void*)v.begin() << endl;
    for (int i = 0; i < v.size(); i++) {
        cout << "Address of string object " << i << ": " << (void*)&v[i] << endl;
        cout << "Address of string data " << i << ":   " << (void*)v[i].c_str() << endl;
        cout << "Sizeof(string): " << sizeof(string) << endl;
    }
}

这个代码会显示string对象本身是连续存储的,但每个string对象指向的字符串数据在不同的内存地址。

为什么标准库vector没有这个问题

        标准库的 std::vector 使用了一种叫做"类型特质(type traits)"的技术,能够识别类型是否是"平凡可拷贝(trivially copyable)"的。对于平凡可拷贝的类型(如基本数据类型、简单结构体),它使用 memcpy 等高效方法;对于非平凡类型(如 string),它会调用拷贝构造函数或赋值运算符。


网站公告

今日签到

点亮在社区的每一天
去签到