C++ 字符串复制:栈堆之间的 "数据搬家" 大戏
一、字符串复制就像 "搬家":栈是地址牌,堆是仓库
想象你有一个仓库(堆)里放着一箱子文件(字符串数据),箱子上有个地址牌(栈上的指针)。复制字符串就像搬家:
- 深拷贝:雇个搬家公司,把箱子里的文件全抄一份,放进新仓库,再做个新地址牌。
- 写时复制:先共用一个仓库,地址牌指向同一个地方,等有人要改文件了再抄一份。
C++ 里复制字符串时,栈和堆的变化就像这场搬家大戏,每个步骤都藏着内存的小秘密。今天咱们就当回 "内存侦探",一步步拆解!
二、主角登场:std::string 的 "复制工具箱"
C++ 的std::string是个 "智能箱子",里面藏着三个关键信息(都在栈上):
- 指向堆内存的指针(_M_p):仓库地址
- 字符串长度(_M_len):箱子里有多少文件
- 容量(_M_cap):箱子能装多少文件
复制时,这三个信息和堆里的 "文件" 都会发生变化,咱们逐个看。
案例 1:深拷贝(现代编译器主流)—— 彻底的搬家
代码(string_copy.cpp):
cpp
运行
#include <iostream>
#include <string>
int main() {
// 原始字符串:栈上有地址牌,堆里有数据
std::string src = "hello";
std::cout << "原始字符串(src):\n";
std::cout << " 栈地址:" << &src << "\n";
std::cout << " 堆地址(数据):" << static_cast<void*>(src.data()) << "\n";
std::cout << " 长度:" << src.size() << ", 容量:" << src.capacity() << "\n\n";
// 拷贝构造:深拷贝
std::string dest = src; // 核心操作
std::cout << "复制后(dest):\n";
std::cout << " 栈地址:" << &dest << "\n"; // 新栈地址
std::cout << " 堆地址(数据):" << static_cast<void*>(dest.data()) << "\n"; // 新堆地址
std::cout << " 长度:" << dest.size() << ", 容量:" << dest.capacity() << "\n";
return 0;
}
编译运行:
bash
g++ string_copy.cpp -o demo && ./demo # Linux/macOS
# Windows: g++ string_copy.cpp -o demo.exe && demo.exe
输出:
plaintext
原始字符串(src):
栈地址:0x7ffd9a5b9a70
堆地址(数据):0x55f8d9a52b60
长度:5, 容量:5
复制后(dest):
栈地址:0x7ffd9a5b9a90 # 栈上新建了string对象
堆地址(数据):0x55f8d9a52ba0 # 堆上新建了数据
长度:5, 容量:5
内存变化 step by step:
- 原始字符串(src):栈:src 占一块内存(比如 0x7ffd9a5b9a70),里面存三个值:指针:0x55f8d9a52b60(指向堆)长度:5("hello" 有 5 个字符)容量:5(刚好装下)堆:0x55f8d9a52b60 处存着 h e l l o \0(6 字节,含结束符)。
- 复制过程(dest = src):栈:新建 dest 对象(地址 0x7ffd9a5b9a90),复制 src 的长度和容量(都是 5)。堆:新分配一块内存(0x55f8d9a52ba0),把 src 堆里的 h e l l o \0 完整复制过来。栈上的 dest 指针更新为新堆地址 0x55f8d9a52ba0。
- 最终状态:栈上两个独立对象,堆上两份相同数据 —— 改 dest 完全不影响 src(就像两家各有一箱子相同文件)。
案例 2:写时复制(COW,旧编译器)—— 先合租再分家
老版本 GCC(比如 4.8)会用 "写时复制" 优化,刚开始不真复制,等修改时才动手。
代码:
cpp
运行
#include <iostream>
#include <string>
int main() {
std::string src = "shared";
std::string dest = src; // 先不真复制
std::cout << "复制后(未修改):\n";
std::cout << " src堆地址:" << static_cast<void*>(src.data()) << "\n";
std::cout << " dest堆地址:" << static_cast<void*>(dest.data()) << "\n"; // 地址相同
// 修改dest,触发真正复制
dest += " modified";
std::cout << "\n修改dest后:\n";
std::cout << " src堆地址:" << static_cast<void*>(src.data()) << "\n";
std::cout << " dest堆地址:" << static_cast<void*>(dest.data()) << "\n"; // 地址不同
return 0;
}
输出(旧编译器):
plaintext
复制后(未修改):
src堆地址:0x55f8d9a52b60
dest堆地址:0x55f8d9a52b60 # 共用堆内存
修改dest后:
src堆地址:0x55f8d9a52b60
dest堆地址:0x55f8d9a52ba0 # 分家了,dest有了新堆内存
内存变化 step by step:
- 复制后(未修改):栈:src 和 dest 是两个对象,但指针都指向同一堆地址 0x55f8d9a52b60。堆:数据只有一份,但多了个 "引用计数"(记录有 2 个对象在用)。
- 修改 dest 时:堆:检测到引用计数 > 1,给 dest 新分配堆内存 0x55f8d9a52ba0,复制原数据并添加 " modified"。栈:dest 的指针更新为新堆地址,引用计数减为 1(只剩 src 在用旧地址)。
就像两个人先合租一个仓库,其中一个要重新装修时,就自己租个新仓库并复制东西 —— 前期省空间,后期不打扰。
案例 3:C 风格字符串复制(strcpy)—— 手动搬家
C 语言的strcpy更原始,全程手动操作,栈堆变化更 "赤裸"。
代码:
cpp
运行
#include <iostream>
#include <cstring>
int main() {
// 源字符串(常量区)
const char* src = "manual copy";
std::cout << "源字符串(常量区)地址:" << static_cast<void*>(const_cast<char*>(src)) << "\n";
// 栈上的目标指针(还没指向有效内存)
char* dest;
// 1. 计算长度(确定要搬多少东西)
size_t len = std::strlen(src);
std::cout << "字符串长度:" << len << "字节\n";
// 2. 堆上分配内存(租新仓库)
dest = new char[len + 1]; // +1留结束符'\0'
std::cout << "目标堆地址:" << static_cast<void*>(dest) << "\n";
// 3. 复制数据(手动搬家)
std::strcpy(dest, src);
std::cout << "复制后内容:" << dest << "\n";
// 4. 手动释放(退租仓库)
delete[] dest;
return 0;
}
输出:
plaintext
源字符串(常量区)地址:0x55f8d9a3e0c0
字符串长度:10字节
目标堆地址:0x55f8d9a52b60
复制后内容:manual copy
内存变化 step by step:
- 源字符串:常量区:"manual copy\0" 存在只读内存(0x55f8d9a3e0c0)。栈:src 指针指向这个地址。
- 复制过程:栈:dest 指针先无意义,计算长度 len=10 后,new char[11] 分配堆内存 0x55f8d9a52b60,dest 指向这里。堆:strcpy 逐字节把常量区的数据复制到 0x55f8d9a52b60,最后加 '\0'。
- 风险点:如果忘了 delete[] dest,堆内存就永远 "租" 着不还(内存泄漏)—— 就像搬家后不退租,白白浪费钱。
三、三种复制方式的栈堆变化对比表
复制方式 | 栈变化 | 堆变化 | 典型场景 |
std::string 深拷贝 | 新建对象,复制长度 / 容量,指针指向新堆地址 | 新分配内存,复制全部数据 | 现代 C++(C++11 及以后) |
std::string COW | 新建对象,复制长度 / 容量,指针指向原堆地址 | 初始共享,修改时才新分配并复制 | 旧编译器(如 GCC 4.8 前) |
strcpy(C 风格) | 指针从无效变为指向新堆地址 | 手动分配内存,逐字节复制 | 兼容 C 代码 |
四、避坑指南:别让内存 "搬家" 变 "翻车"
- 深拷贝的性能坑:频繁深拷贝大字符串会浪费内存和时间(就像天天搬大箱子)。解决:用std::move转移所有权(直接把仓库地址牌给别人,自己不用了)。
- cpp
- 运行
- std::string a = "very long string..."; std::string b = std::move(a); // 转移所有权,a变空,无堆复制
- COW 的多线程坑:多线程同时修改共享字符串,可能导致复制混乱(就像两人同时装修合租仓库)。现代编译器淘汰 COW 就是因为这个。
- strcpy 的越界坑:目标内存不够大时,strcpy会写到界外(就像搬家时东西堆到邻居家)。解决:用strncpy(dest, src, max_len)限制长度。
总结:复制的本质是 "数据主权转移"
C++ 字符串复制时,栈和堆的变化围绕一个核心:数据的主权归属。
- 深拷贝:立刻创建独立主权,栈堆都独立。
- COW:暂时共享主权,修改时再分割。
- C 风格:手动管理主权,容易出纰漏。
理解了栈上的 "地址牌" 和堆里的 "数据仓库" 如何配合,你就能轻松掌控字符串复制的内存奥秘~
标题
- 《C++ 字符串复制:栈堆内存的 "搬家" 全过程解析》
- 《从深拷贝到 COW:C++ 字符串复制的栈堆变化明细》
简介
本文用 "搬家" 的趣味类比,详细解析 C++ 字符串复制时栈和堆的变化过程。通过std::string的深拷贝、写时复制(COW)以及 C 风格strcpy的案例,展示栈上指针 / 长度 / 容量与堆上数据的具体变化,对比不同复制方式的内存行为与适用场景,附完整代码和编译步骤,帮你彻底理解字符串复制的底层逻辑。
关键词
#C++ #字符串复制 #栈堆变化 #深拷贝 #内存管理