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),它会调用拷贝构造函数或赋值运算符。

总结

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

相关文章

  • OpenCV边缘提取算法流程的实现(附DEMO)

    OpenCV边缘提取算法流程的实现(附DEMO)

    本文主要介绍了OpenCV边缘提取算法流程的实现,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2022-08-08
  • C++二维数组螺旋加密信息

    C++二维数组螺旋加密信息

    大家好,本篇文章主要讲的是C++二维数组螺旋加密信息,感兴趣的同学赶快来看一看吧,对你有帮助的话记得收藏一下,方便下次浏览
    2021-12-12
  • 一文搞懂C语言中的文件操作

    一文搞懂C语言中的文件操作

    文件操作想必大家掌握的并不熟练,确实因为我们用的并不多,而本节内容能够让大家初步认识文件操作,从文件认识到文件使用,让我们对c语言文件操作有个初步的了解
    2022-11-11
  • C++详细讲解模拟实现位图和布隆过滤器的方法

    C++详细讲解模拟实现位图和布隆过滤器的方法

    位图(bitset)是一种常用的数据结构,常用在给一个很大范围的数,判断其中的一个数是不是在其中。在索引、数据压缩方面有很大的应用。布隆过滤器是由布隆提出的,它实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中
    2022-06-06
  • C++中静态存储区与栈以及堆的区别详解

    C++中静态存储区与栈以及堆的区别详解

    本篇文章是对C++中静态存储区与栈以及堆的区别进行了详细的分析介绍,需要的朋友参考下
    2013-05-05
  • C语言嵌入informix基础入门示例讲解

    C语言嵌入informix基础入门示例讲解

    这篇文章主要介绍了C语言嵌入informix基础方法,大家参考使用
    2013-11-11
  • 详解c++ 继承

    详解c++ 继承

    这篇文章主要介绍了c++ 继承的相关资料,文中讲解非常细致,代码帮助大家更好的理解和学习,感兴趣的朋友可以了解下
    2020-07-07
  • C语言Tinyhttpd服务器源码剖析

    C语言Tinyhttpd服务器源码剖析

    这篇文章主要为大家介绍了C语言Tinyhttpd服务器源码剖析,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2023-09-09
  • OpenCV绘制正多边形的方法

    OpenCV绘制正多边形的方法

    这篇文章主要为大家详细介绍了OpenCV绘制正多边形的方法,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2019-01-01
  • 老程序员教你一天时间完成C++俄罗斯方块游戏

    老程序员教你一天时间完成C++俄罗斯方块游戏

    俄罗斯方块游戏大家应该非常熟悉,非常经典的一款游戏,本文来详细讲解下俄罗斯方块游戏的制作过程,赶紧来看下吧!希望能给你带来帮助
    2021-08-08

最新评论