C++中有哪些看似高效实则低效的代码?
C++中有哪些看似高效实则低效的代码?
总结几个我在项目里踩过的、或者在code review里见过的典型案例。每一个写的时候都觉得自己在做"优化",结果跑benchmark才发现是负优化。
1. 到处用 std::move —— "我移动了所以更快"
很多人学了移动语义之后,恨不得每个变量都move一下。最常见的写法:
std::string getName() {
std::string name = "hello";
return std::move(name); // 看起来很"高效"
}
写这行代码的人心里想的是:"我用move避免了拷贝,性能更好。"
但实际上这是在帮倒忙。C++标准规定,函数返回局部变量的时候,编译器会自动做NRVO(Named Return Value Optimization),直接在调用方的内存位置构造对象,连移动都不需要。你加了std::move之后,反而破坏了NRVO的条件,编译器不得不退化成一次移动构造。
移动构造std::string虽然比拷贝快,但跟NRVO的零开销比起来,还是多了一次指针赋值和一次源对象置空的操作。
类似的还有:
void process(std::vector<int> data) { // 值传递
internal_store = std::move(data); // 移动进成员变量
}
// 调用的时候
std::vector<int> v = {1, 2, 3, 4, 5};
process(std::move(v)); // 看起来全程零拷贝
这个写法在v确实需要"交出去"的场景下没问题。但我见过有人连临时值都要多写一个move:
process(std::move(std::vector<int>{1, 2, 3})); // 多余的move
对临时值(右值)调std::move没有任何意义,它本身就是右值,会自动匹配移动构造。多写一个move不会更快,只会让代码更难读。
2. 手写循环代替标准算法 —— "我比编译器聪明"
// "我手写的肯定比库函数快"
bool found = false;
for (int i = 0; i < vec.size(); ++i) {
if (vec[i] == target) {
found = true;
break;
}
}
vs.
bool found = std::find(vec.begin(), vec.end(), target) != vec.end();
很多人直觉上觉得手写循环"更直接",std::find还要搞迭代器那套间接层,肯定有额外开销。
实际上,现代编译器对标准算法的优化比你手写的循环更激进。std::find在libstdc++和libc++的实现里针对连续内存容器做了SIMD优化(特别是对char和int这种基础类型),一次比较多个元素。你手写的逐元素比较,在数据量大的时候反而更慢。
我做过一次benchmark,10万个int里找一个不存在的元素,std::find比手写循环快了约30%(gcc -O2,x86-64)。原因就是libstdc++的std::find对int类型做了4路展开。
更隐蔽的例子是排序:
// "快排我自己写肯定没问题"
void myQuickSort(int* arr, int left, int right) {
if (left >= right) return;
int pivot = arr[(left + right) / 2];
// ... 经典partition实现
}
教科书上的快排实现,在很多corner case上性能是不好的:已排序数组退化到O(n²)、小数组的递归开销大于直接插入排序、pivot选取策略不够好。std::sort的实现(通常是IntroSort)在这些地方都做了精心处理——大数组用快排、小数组切换到插入排序、递归深度超过阈值自动退化到堆排序。你要自己写出同等质量的排序,工作量不小。
3. 用 std::unordered_map 替换 std::map —— "哈希表肯定比红黑树快"
这个可能是最常见的"优化直觉"了。面试八股文都告诉你:哈希表O(1)查找,红黑树O(log n)查找,所以哈希表更快。
但在实际代码里,std::unordered_map在很多场景下比std::map更慢。
元素数量少的时候(几十到几百个),红黑树通常更快。 因为std::unordered_map有哈希计算的开销,而且它的内存布局对cache不友好——每个bucket是一个链表(或类似结构),链表节点在内存中是散列分布的,遍历的时候cache miss率很高。std::map的红黑树虽然也是指针链接的节点,但节点是按key有序排列的,在某些访问模式下反而更cache-friendly。
我在一个项目里做过实测:一个只有50个元素的字符串到int的映射,std::map的查找速度比std::unordered_map快15%左右。原因是50个元素的红黑树高度只有5-6层,每次查找最多5-6次比较,而std::unordered_map要先算一次std::hash<std::string>(对长字符串来说开销不小),然后再去bucket里找。
更坑的是std::unordered_map的rehash。 当元素数量超过load_factor * bucket_count的时候,会触发rehash,重新分配bucket数组并把所有元素重新插入。这个操作是O(n)的,如果你事先没有reserve,在频繁插入的场景下会被rehash反复惩罚。很多人用unordered_map替换map的时候根本没想过这个问题。
还有一个反直觉的场景:如果你需要按key有序遍历(这在实际业务里很常见),std::map本身就是有序的直接遍历就行,std::unordered_map你得先把元素取出来排序,额外付出O(n log n)的代价。
4. 过度用 reserve 和 shrink_to_fit —— "我要精确控制内存"
std::vector<int> v;
v.reserve(1000); // 合理
// 用完之后
v.clear();
v.shrink_to_fit(); // "我要释放内存"
// ... 过一会儿又要用了
v.reserve(1000); // 重新分配
reserve本身是个好东西,在你知道大致需要多少空间的时候可以避免多次扩容。但我见过有人在循环里反复clear + shrink_to_fit + reserve,觉得这样"精确控制了内存"。
实际上,shrink_to_fit会释放当前的内存并重新分配一块更小的(或者不处理,标准说的是"非绑定请求"),然后你下一次reserve又分配回来。这一释放一分配的开销远大于让vector保持原来的capacity。如果你的vector反复使用,clear之后直接复用就好,不要去shrink_to_fit。
类似的还有std::string的预优化:
std::string result;
result.reserve(10000);
for (const auto& s : strings) {
result += s; // 看起来很合理
}
这个reserve确实有用,但很多人不知道的是,如果strings里每个字符串都很短(比如不超过22个字符),而且连接之后的总长度也不大的话,SSO(Small String Optimization)已经帮你避免了堆分配。你的reserve(10000)反而强制在堆上分配了10000字节,击败了SSO。
5. std::shared_ptr 到处传 —— "智能指针总比裸指针安全"
void process(std::shared_ptr<Widget> widget) {
widget->doSomething();
}
// 调用
auto w = std::make_shared<Widget>();
process(w);
如果process函数只是读取widget而不需要共享所有权,传shared_ptr是一种浪费。每次拷贝shared_ptr都要对引用计数做原子加减操作。原子操作在x86上大概是普通操作的10-20倍开销,在多核场景下还会引起cache line的bouncing。
正确的做法是:
void process(const Widget& widget) { // 不需要所有权就传引用
widget.doSomething();
}
只在需要延长对象生命周期或者共享所有权的时候才传shared_ptr。我在一个项目里做过profiling,把几个热路径上的shared_ptr参数改成const&之后,那段代码的执行时间减少了8%。原子引用计数的开销在高频调用场景下是能看到的。
还有一个相关的坑:std::make_shared虽然比new + shared_ptr更快(一次内存分配而不是两次),但它会让对象和控制块分配在同一块内存上。这意味着即使shared_ptr都释放了,只要还有weak_ptr存在,对象占用的内存就不会被归还。如果对象很大而weak_ptr的生命周期很长,这个内存浪费可能是不可接受的。
6. 用 std::endl 代替 '\n' —— "endl更规范"
for (int i = 0; i < 100000; ++i) {
std::cout << data[i] << std::endl; // 看起来没什么问题
}
std::endl做了两件事:输出一个换行符,然后flush缓冲区。在循环里用std::endl,意味着每输出一行都要flush一次,这会触发系统调用,性能影响很大。
改成'\n':
for (int i = 0; i < 100000; ++i) {
std::cout << data[i] << '\n';
}
我测过一次,10万行输出,std::endl版本比'\n'版本慢了大约5倍。原因就是flush的系统调用开销太大了。
这个问题老生常谈了,但我在code review里还是经常见到,很多人就是习惯了用endl。
7. 过度使用 inline —— "内联肯定更快"
inline int computeScore(const Player& p) {
// 30行复杂计算
// ...
return score;
}
很多人以为写了inline编译器就一定会内联这个函数,从而消除函数调用的开销。
首先,inline关键字在现代C++里主要是告诉链接器"这个函数可以在多个翻译单元中定义"(One Definition Rule相关),跟是否内联关系不大。编译器有自己的内联决策,一个30行的函数,不管你写不写inline,编译器大概率都不会内联它。
其次,强制内联大函数可能适得其反。内联会增加代码体积(code bloat),导致指令缓存(I-cache)的命中率下降。对于在循环中被频繁调用的小函数,内联是好事;对于大函数,内联导致的I-cache miss可能比函数调用的开销还大。
GCC和Clang提供了__attribute__((always_inline))来强制内联,但你几乎不应该用它。让编译器自己决定就好,它在-O2下的内联决策通常比你手动指定更靠谱。
8. std::list —— "链表增删O(1)肯定快"
std::list<int> data; // "我需要频繁插入删除,链表最合适"
for (int i = 0; i < 100000; ++i) {
data.push_back(i);
}
教科书告诉你链表的插入删除是O(1),数组是O(n),所以需要频繁插入删除的时候应该用链表。
但在现代CPU上,std::list几乎在所有场景下都比std::vector慢。原因很简单:cache。
std::vector的元素在内存中是连续排列的,CPU的prefetcher可以预测你要访问的下一块内存,提前加载到cache。std::list的每个节点都是单独new出来的,散落在堆的各个角落,每次访问下一个节点几乎都是一次cache miss。
在现代硬件上,一次cache miss大概60-100个CPU周期,而一次cache hit只需要3-4个周期。链表的O(1)插入在算法复杂度上赢了,但在常数因子上输得很惨。
Bjarne Stroustrup本人做过一个著名的benchmark:在一个包含随机整数的容器中间插入元素并保持有序。std::vector(需要memmove)在数据量达到几十万之前都比std::list快。因为vector的memmove是连续内存操作,被cache和SIMD优化得很好,而list找到插入位置的过程中产生的cache miss足以抵消memmove的开销。
除非你的元素非常大(移动成本高)、需要保证迭代器不失效、或者需要在已知位置做高频插入删除,否则默认用std::vector几乎总是更好的选择。
总结
这些案例有一个共同特点:写的人是基于算法复杂度分析或者语言特性的教科书知识来做"优化"的,但忽略了现代硬件的实际行为——CPU cache、分支预测、SIMD指令、原子操作开销。
在现代C++开发中,"看起来高效"的代码往往是在跟编译器和硬件做对抗。编译器比你想象的聪明得多,硬件的行为也比算法教科书描述的复杂得多。
最可靠的做法还是那句老话:先写正确、清晰的代码,然后用profiler找到真正的瓶颈,再做有针对性的优化。凭直觉"优化"出来的代码,有不小的概率是在减速。
