C++之memcpy导致的深拷贝问题分析

 更新时间:2025年09月18日 09:33:10   作者:一枝小雨  
使用memcpy拷贝vector中自定义类型元素(如string)时,仅复制指针值导致悬空指针,引发未定义行为,而循环赋值通过调用赋值运算符实现深拷贝,正确复制对象内容,标准库vector通过类型特质区分类型,对非平凡类型调用构造/析值函数,避免此类问题

代码与讲解承接上文:C++之vector深度剖析及模拟实现

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;
        }
}

问题分析

  • memcpy是内存的二进制格式拷贝,将一段内存空间中内容原封不动的拷贝到另外一段内存空间中
  • 如果拷贝的是自定义类型的元素,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;
        }
}

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

  • 调用了赋值运算符:对于每个元素,都会调用 string::operator=,这是一个深拷贝操作
  • 创建独立副本:每个新 string 对象都会分配自己的内存并复制字符串内容
  • 避免悬空指针:新旧 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│     字符串数据(也在堆上,但不同位置)
    └─────┘   └─────┘   └─────┘   └─────┘

关键点分解

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

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

  • char* _str(指针,通常4或8字节)
  • size_t _size(通常4或8字节)
  • size_t _capacity(通常4或8字节)

可能的其他成员变量

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

为什么循环赋值有效

现在让我们看看循环:

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

对于每次迭代:

  • _start[i]获取第i个string对象
  • tmp[i]获取新数组中第i个位置(此时可能是一个未初始化的string对象)
  • 调用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),它会调用拷贝构造函数或赋值运算符。

总结

以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。

相关文章

  • C语言通过三种方法实现属于你的通讯录

    C语言通过三种方法实现属于你的通讯录

    本文将实现一个通讯录,来实现人员的增删插改功能。文中通过三种形式来实现用户的增删插改,其实也就是一点点的优化版本,从静态的实现,到动态的实现,最后以文件的形式来完成,请大家和我一起往下看吧
    2022-11-11
  • C++ opencv ffmpeg图片序列化实现代码解析

    C++ opencv ffmpeg图片序列化实现代码解析

    这篇文章主要介绍了C++ opencv ffmpeg图片序列化实现代码解析,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下
    2020-08-08
  • 减少C++代码编译时间的简单方法(必看篇)

    减少C++代码编译时间的简单方法(必看篇)

    下面小编就为大家带来一篇减少C++代码编译时间的简单方法(必看篇)。小编觉得挺不错的,现在就分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2017-01-01
  • 使用C/C++语言生成一个随机迷宫游戏

    使用C/C++语言生成一个随机迷宫游戏

    迷宫相信大家都走过,主要是考验你的逻辑思维。今天小编使用C语言生成一个随机迷宫游戏,具体实现代码,大家通过本文学习吧
    2016-12-12
  • C语言实现洗牌与发牌游戏

    C语言实现洗牌与发牌游戏

    这篇文章主要为大家详细介绍了C语言洗牌与发牌游戏,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2020-12-12
  • C语言 socketpair用法案例讲解

    C语言 socketpair用法案例讲解

    这篇文章主要介绍了C语言 socketpair用法案例讲解,本篇文章通过简要的案例,讲解了该项技术的了解与使用,以下就是详细内容,需要的朋友可以参考下
    2021-08-08
  • Qt实现自定义验证码输入框控件的方法

    Qt实现自定义验证码输入框控件的方法

    本文主要介绍了Qt实现自定义验证码输入框控件的方法,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2022-04-04
  • C++实现猜牌小游戏

    C++实现猜牌小游戏

    这篇文章主要为大家详细介绍了C++实现猜牌小游戏,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2020-07-07
  • C++实现屏幕截图

    C++实现屏幕截图

    这篇文章主要为大家详细介绍了C++实现屏幕截图功能,截图自动保存为png格式文件,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2018-05-05
  • 基于C++实现TCP聊天室功能

    基于C++实现TCP聊天室功能

    这篇文章主要为大家详细介绍了基于C++实现TCP聊天室功能,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2021-07-07

最新评论