
做 code review 时我常碰到一类现象:代码逻辑没错,编译零 warning,跑起来也正确,但一上 benchmark,慢得让人皱眉。仔细一查,性能全被拷贝吃掉了——无意的参数复制、容器扩容时的批量搬运、返回值多余的对象拷贝。改起来其实很容易,加一个 &、一个 std::move、一个 reserve,总共不到 10 个字符的改动,却能让速度快出好几倍。
麻烦的是,这些拷贝不会报错,也不会给出任何警告,只是默默吃掉 CPU 周期和内存带宽。你不专门去查,根本发现不了。
这篇文章整理了 7 个最常见、也最能立刻见效的减少拷贝的写法,按“参数传递→容器操作→返回值→设计模式”四层递进,每个对比都带着代码,看完就能上手改。
一、函数参数:const 引用是基本功,但还不够
先看最基础的一条——函数参数传递。
// ❌ 值传递:每次调用都拷贝整个string
void processName(std::string name) {
if (name.size() > 100) return;
// ... 使用name
}
// ✅ const引用:零拷贝
void processName(const std::string& name) {
if (name.size() > 100) return;
// ... 使用name
}
这个道理谁都知道。可你真的在所有该用 const& 的地方都加上了吗?事实上 std::string 值传递的开销取决于长度:短字符串落在 SSO(Small String Optimization)阈值内(libstdc++ 是 15 字节,libc++ 是 22 字节),不走堆分配,但也得经历栈上内存拷贝和析构;超过 SSO 阈值的长度就得吃一遍堆内存分配、memcpy 和析构释放三件套。要是循环里高频调用,这个开销会被放大几万倍。
不过 const& 也不是万能药。如果函数内部需要保存参数的副本,情况就不一样了:
class Logger {
std::vector<std::string> logs_;
// ❌ const引用 + 内部拷贝:这里还是会拷贝一次
void addLog(const std::string& msg) {
logs_.push_back(msg); // push_back里拷贝
}
// ✅ 值传递 + move:对左值拷贝一次,对右值零拷贝
void addLog(std::string msg) {
logs_.push_back(std::move(msg));
}
};
// 调用时的区别:
logger.addLog("hello"); // 字面量→构造临时string→move进vector,1次构造0次拷贝
logger.addLog(existingString); // 拷贝构造msg→move进vector,1次拷贝(和const&版一样)
logger.addLog(std::move(temp)); // move构造msg→move进vector,0次拷贝
核心思想是:函数内部无论如何都需要一份拷贝的话,就让调用方决定是拷贝还是移动。 传入右值时会走移动构造,整个链路零拷贝,这比写两个重载(const& 版本加 && 版本)简洁得多。
string_view:只读场景的终极方案
C++17 的 std::string_view 解决了一个 const std::string& 的盲区:
// 用const string&接收字面量,会隐式构造一个临时string
void check(const std::string& s);
check("hello world"); // 偷偷构造了一个临时std::string!
// 用string_view,零开销
void check(std::string_view s);
check("hello world"); // 直接指向字面量的内存,零拷贝零分配
string_view 本质上就是一个 {const char*, size_t} 轻量包装,16 字节,拷贝它跟拷贝一个指针差不多。所有“只读取不修改不保存”的字符串参数场景都适用。
但有个坑必须留心:它不拥有底层数据。原始字符串一旦销毁,string_view 就成了悬垂引用。别把它存进成员变量或者跨作用域传递,除非你能保证底层数据活得比它久。
二、容器操作:push_back 和 insert 不是唯一选择
emplace_back vs push_back
struct User {
std::string name;
int age;
User(std::string n, int a) : name(std::move(n)), age(a) {}
};
std::vector<User> users;
// ❌ push_back:先构造临时User,再移动进vector
users.push_back(User("Alice", 30));
// ✅ emplace_back:直接在vector内存里原地构造
users.emplace_back("Alice", 30);
emplace_back 用完美转发把参数直接传给构造函数,对象直接在vector的内存里构造出来,跳过了临时对象的创建和销毁。构造成本高的类型(有 string 成员、需要堆分配的),差距就比较明显了。
不过也别神话 emplace_back——对于 vector<int> 或者传入右值的 vector<string>,它和 push_back 的性能差距趋近于零,因为移动一个 int 没有任何开销,移动一个 string 也就是几个指针交换的事。emplace_back 真正省的是中间临时对象那一步。
reserve 预分配
这是改动最小、效果最猛的优化之一。
std::vector<std::string> results;
// ❌ 不预分配:vector每次扩容都把所有元素搬到新内存
for (int i = 0; i < 10000; i++) {
results.push_back(generateString());
}
// ✅ 预分配:一次分配够,不再扩容
results.reserve(10000);
for (int i = 0; i < 10000; i++) {
results.push_back(generateString());
}
vector 的扩容策略因实现而异——GCC 的 libstdc++ 是 2 倍增长,Clang 的 libc++ 是 1.5 倍增长。以 2 倍增长计算,加入 1 万个元素大概要扩容 14 次(1.5 倍增长则需要更多)。每次扩容都要把所有已有元素搬到新内存——更糟的是,如果元素的移动构造函数没标 noexcept,std::move_if_noexcept 会让 vector 退化为拷贝而不是移动,开销直接翻倍。
一个 reserve 就能把这 14 次扩容全干掉。元素数量已知或者能估个大概的场景,没有任何理由不加 reserve。
容器间搬运:move 整个容器
// ❌ 拷贝整个vector
std::vector<std::string> dest = source; // 拷贝所有元素
// ✅ source之后不用了?move它
std::vector<std::string> dest = std::move(source); // 只转移三个指针
std::move 一个 vector 的成本是 O(1)——移动构造只是把源对象的三个内部指针(begin、end、capacity_end)转移过来,然后将源对象置空。拷贝一个装了 1 万个 string 的 vector 要分配内存、挨个拷贝 1 万个 string 对象,差距就是几个纳秒和几百微秒的区别。
move 之后 source 处于“合法但未指定”的状态,别再读它的内容,只能重新赋值或者析构。
三、返回值:别画蛇添足
不少人为了“优化”返回值,写出这种代码:
// ❌ 画蛇添足:std::move反而阻止了编译器优化
std::string buildMessage() {
std::string result = "prefix_";
result += generateContent();
return std::move(result); // 别这样!
}
// ✅ 直接返回局部变量
std::string buildMessage() {
std::string result = "prefix_";
result += generateContent();
return result; // 编译器自动应用NRVO,零拷贝
}
这里有个反直觉的事:return 里加 std::move 不仅没优化,反而可能更慢。
原因在于编译器有一个叫 NRVO(Named Return Value Optimization)的优化机制——函数返回局部变量时,编译器会直接在调用方的内存空间里构造这个对象,压根不存在“函数内部拷贝到函数外部”这回事。一旦你加了 std::move,编译器反而没法用 NRVO 了,只能退而求其次做一次移动构造。
NRVO 会在什么时候失效?多个 return 路径返回不同的局部变量时,编译器通常搞不定:
std::string getMessage(bool flag) {
std::string a = "hello";
std::string b = "world";
if (flag) return a; // 编译器不知道给a还是b分配调用方的空间
else return b; // 于是放弃NRVO
}
但即使 NRVO 失效了,C++ 标准也保证编译器会自动走移动构造而不是拷贝构造,所以你还是不需要手动加 std::move。
一句话:返回局部变量时,什么都不做就是最优的。 编译器比你聪明。
C++17 还引入了 guaranteed copy elision——返回纯右值(prvalue)的场景,拷贝和移动操作从语言层面就被消除了,哪怕类型没有拷贝构造函数也能编译过:
// C++17: Widget既不能拷贝也不能移动,照样编译
Widget createWidget() {
return Widget(42); // guaranteed copy elision
}
Widget w = createWidget(); // 直接在w的位置上构造
四、range-for 循环:一个 & 的差距
这可能是最低级也是改起来最快的错误:
std::vector<std::string> names = getNames();
// ❌ 值拷贝:每次迭代都拷贝一个string
for (auto name : names) {
std::cout << name << "\n";
}
// ✅ const引用:零拷贝
for (const auto& name : names) {
std::cout << name << "\n";
}
一个 & 差多少?vector 里 1 万个长字符串的话,值拷贝版本走 1 万次堆分配和释放,const 引用版本额外开销是零。实测差距通常在 3 到 8 倍之间,具体看字符串长度和内存分配器。
例外情况很少——只有你明确需要在循环体里改副本(不影响原容器)的时候,才会用值拷贝。对于 class 类型(string、自定义结构体等),const auto& 应该是默认写法。对于 int、double 这些基本类型,值拷贝直接放寄存器,引用反而多一次间接访问,auto 可能更快——不过差距极小,编译器通常能优化掉。建议的习惯是:class 类型一律 const auto&,基本类型两者皆可。
五、lambda 捕获:小心隐式拷贝
lambda 值捕获是隐式拷贝的高发区:
std::string bigData(10000, 'x'); // 10KB字符串
// ❌ 值捕获:拷贝了整个bigData进lambda
auto func = [bigData]() {
std::cout << bigData.size() << "\n";
};
// ✅ 引用捕获:零拷贝(注意生命周期)
auto func = [&bigData]() {
std::cout << bigData.size() << "\n";
};
值捕获 [bigData] 会把整个 string 拷贝进 lambda 对象,10KB 数据走一遍堆分配加 memcpy。当前作用域内同步用的话,引用捕获 [&bigData] 就够了。
如果 lambda 要在异步场景使用(丢给线程池、存进回调队列),引用捕获就有悬垂引用的风险了。这时候用 C++14 的初始化捕获来 move:
// ✅ move捕获:所有权转移,零拷贝
auto func = [data = std::move(bigData)]() {
std::cout << data.size() << "\n";
};
// bigData已被move走
判断规则:同步调用用引用捕获,异步使用用 move 捕获,只有确实需要副本时才值捕获。
六、swap 替代赋值:大对象交换的 O(1) 方案
需要交换两个大对象的内容?
std::vector<int> a(100000), b(100000);
// ❌ 手动三步交换:拷贝30万个int
std::vector<int> temp = a;
a = b;
b = temp;
// ✅ swap:O(1),只交换内部指针
std::swap(a, b);
std::swap 对标准容器做了特化,内部就是交换几个指针和 size 计数器。手动三步交换要拷贝 30 万个 int,差距是百倍量级。
swap 还有个高级用法——释放容器内存:
std::vector<int> v(100000);
v.clear(); // 清空了元素,但capacity还是100000
std::vector<int>().swap(v); // 空vector和v交换,真正释放内存
七、设计层面:让拷贝在架构上就不会发生
前面 6 个技巧都是在编码层面减少拷贝。但釜底抽薪的做法是从设计上就堵死拷贝的可能性。
禁止拷贝
管理独占资源的类,从源头禁掉拷贝:
class Connection {
public:
Connection(const Connection&) = delete;
Connection& operator=(const Connection&) = delete;
Connection(Connection&&) = default;
Connection& operator=(Connection&&) = default;
};
编译期就拦截所有无意义的拷贝,比运行时排查性能问题高效多了。
返回引用而不是值
频繁访问的成员变量,返回 const 引用:
class Config {
std::map<std::string, std::string> settings_;
// ❌ 每次调用复制整个map
std::map<std::string, std::string> getSettings() { return settings_; }
// ✅ 零拷贝
const std::map<std::string, std::string>& getSettings() const { return settings_; }
};
C++20 std::span:数组参数的零拷贝传递
std::span 是传递连续内存区间的最佳方式:
// ❌ 和vector绑定,不接受C数组
void process(const std::vector<int>& data);
// ✅ 接受任何连续内存,零拷贝
void process(std::span<const int> data);
std::vector<int> v = {1, 2, 3};
int arr[] = {4, 5, 6};
process(v); // OK
process(arr); // 也OK
process({v.data() + 1, 2}); // 子范围也行
跟 string_view 一个理念——不拥有数据,只是一个轻量视图。
速查表
| 场景 |
❌ 有拷贝 |
✅ 更优写法 |
| 只读参数 |
f(string s) |
f(const string& s) 或 f(string_view s) |
| 需要保存的参数 |
f(const string& s) + push_back(s) |
f(string s) + push_back(move(s))(右值零拷贝) |
| 容器添加元素 |
push_back(T(...)) |
emplace_back(...)(省临时对象) |
| 已知容器大小 |
直接 push_back |
先 reserve 再 push_back |
| 返回局部变量 |
return std::move(x) |
return x(让 NRVO 生效) |
| range-for 循环 |
for (auto x : vec) |
for (const auto& x : vec) |
| lambda 捕获大对象 |
[bigObj] |
[&bigObj] 或 [o = move(bigObj)] |
| 交换两个容器 |
三步赋值交换 |
std::swap(a, b) |
| 传递数组 |
const vector<T>& |
std::span<const T>(C++20) |
这 7 个技巧不难,难的是养成习惯。一个管用的办法是 code review 的时候专门盯拷贝——看到值传递就问一句“这里需要拷贝吗”,看到 range-for 没有 & 就标个 comment。盯两周,这些写法就成肌肉记忆了。更多 C++ 性能优化探讨,欢迎常来云栈社区看看。