C++ 背後的秘密優化(上):SSO 與 Copy Elision
2026-03-31
你以為 std::string 一定會 new?你以為 return 一定會複製? 編譯器在背後做的事比你想的還多 - 這篇帶你看兩個最常見的隱藏優化。
Item 1 - SSO:std::string 不一定用 heap
大部分人對 std::string 的心智模型是:建構時 new 一塊 heap 記憶體,解構時 delete。但如果字串很短,標準庫實作會把內容直接塞在 string 物件本身裡面 - 完全不碰 heap。這就是 Small String Optimization(SSO)。
SSO 的運作機制
核心想法是用 union:string 物件內部有一塊固定大小的 buffer。 當字串長度小於這個 buffer 時,內容直接存在 stack 上的物件裡;超過了才去 heap 分配。
// 簡化的概念模型(非實際實作)
class string {
union {
struct { // 長字串模式
char* ptr; // 指向 heap
size_t size;
size_t capacity;
} long_;
struct { // 短字串模式(SSO)
char buf[24]; // 內嵌 buffer
uint8_t remaining; // 剩餘可用空間
} short_;
};
};各編譯器的 SSO 閾值
| 實作 | SSO 閾值(含 null terminator) | sizeof(string) |
|---|---|---|
| GCC libstdc++ | 15 bytes | 32 |
| Clang libc++ | 22 bytes | 24 |
| MSVC STL | 15 bytes | 32 |
記憶體佈局圖
=== 短字串(SSO 模式)===
std::string s = "hello";
┌─────────────────────────────────┐
│ string 物件(stack 上,32 bytes) │
│ ┌─────────────────────────────┐ │
│ │ h │ e │ l │ l │ o │\0│ │ │ │ ← 直接存在物件內部
│ │ (剩餘空間未使用) │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────┘
✅ 不碰 heap,不呼叫 allocator
=== 長字串(heap 模式)===
std::string s = "this is a somewhat longer string for testing";
┌─────────────────────────────────┐
│ string 物件(stack 上,32 bytes) │
│ ┌─────────────────────────────┐ │
│ │ ptr ──────────────────────┐ │ │
│ │ size = 45 │ │ │
│ │ capacity = 64 │ │ │
│ └───────────────────────────┘ │ │
└──────────────────────────────┘ │ │
↓
┌────────────────────────┐
│ heap 記憶體 │
│ "this is a somewhat..." │
└────────────────────────┘
❌ 需要 malloc + free如何驗證 SSO?
比較 string 物件的位址和其內部 data 指標的距離 - 如果 data 指向物件本身的範圍內, 就是 SSO。
#include <iostream>
#include <string>
void check_sso(const std::string& s) {
const void* obj_addr = &s;
const void* data_addr = s.data();
// 如果 data 指標落在物件本身的記憶體範圍內 → SSO
auto diff = reinterpret_cast<uintptr_t>(data_addr)
- reinterpret_cast<uintptr_t>(obj_addr);
bool is_sso = diff < sizeof(std::string);
std::cout << """ << s << "" (" << s.size() << " bytes): "
<< (is_sso ? "SSO ✅" : "Heap ❌") << std::endl;
}
int main() {
check_sso("hello"); // SSO ✅
check_sso("12345678901234"); // SSO ✅ (14 chars, < 15)
check_sso("this is a somewhat longer string"); // Heap ❌
check_sso(""); // SSO ✅
}為什麼 std::vector 沒有 SSO?
你可能會想:既然 string 可以做 SSO,為什麼 vector 不做 Small Vector Optimization?
sizeof(std::vector)= 24 bytes(pointer + size + capacity)sizeof(std::string)= 32 bytes(多出的 8 bytes 正是 SSO buffer 的空間)- string 的使用場景中短字串佔絕大多數(key、name、label...),值得多花 8 bytes
- vector 的元素型別不固定(
vector<int>vsvector<Widget>), 很難決定 buffer 要多大
Boost 和 LLVM 提供了 small_vector,讓你自己指定 inline capacity。 標準庫選擇不做這件事,是一個刻意的設計取捨。效能影響與陷阱
SSO 在短字串密集的場景下效果顯著 - 例如 JSON parsing、symbol table、config 處理。但有一個需要注意的地方:效能懸崖(performance cliff)。
字串長度: 10 12 14 15 16 18 20
──────────────────────────────────
GCC: SSO SSO SSO SSO heap heap heap
↑
效能懸崖:多 1 byte 就觸發 heap allocation如果你的 key 長度剛好在閾值附近,微小的長度變化會導致不成比例的效能差異。 了解你的編譯器的 SSO 閾值,在設計 key 命名時可以有意識地控制長度。
Quiz
下列哪一個字串會觸發 heap allocation?(假設 GCC libstdc++,SSO 閾值 15 bytes)
- A. std::string a = "hello";
- B. std::string b = "this is a somewhat longer string for testing";
- C. std::string c = "";
- D. std::string d = "12345678901234";
Show Answer
答案:B
A 是 5 bytes、C 是 0 bytes、D 是 14 bytes - 都在 SSO 閾值(15 bytes)以內。B 遠超 15 bytes,必須走 heap allocation。
Item 2 - Copy Elision / NRVO:省掉看不見的複製
當函數 return 一個物件時,直覺上你會想:函數內部建構一個 local 物件,return 時複製(或搬移)到外面。但編譯器其實可以把這次複製完全省略 - 這就是 Copy Elision,其中最常見的形式叫 NRVO(Named Return Value Optimization)。
Hidden Pointer 機制
編譯器在背後偷偷改了函數的呼叫方式:caller 把 return value 的目標位址當作隱藏參數傳給 callee,callee 直接在那個位址上建構物件。
=== 你寫的 code ===
std::vector<int> make_vec() {
std::vector<int> v = {1, 2, 3};
return v;
}
auto result = make_vec();
=== 編譯器實際做的事(概念模型)===
void make_vec(std::vector<int>* __result) { // ← hidden pointer
new (__result) std::vector<int>{1, 2, 3}; // ← 直接在 caller 的空間建構
// 不需要複製!不需要搬移!
}
std::vector<int> result; // ← 預留空間
make_vec(&result); // ← 把位址傳進去NRVO 的結果:物件只建構一次,直接建構在最終目的地。 沒有 copy constructor、沒有 move constructor、沒有臨時物件。
用自訂 struct 驗證
透過在 constructor / destructor 加上 print,可以清楚看到 NRVO 是否生效。
#include <iostream>
struct Obj {
Obj() { std::cout << "建構\n"; }
Obj(const Obj&) { std::cout << "複製建構\n"; }
Obj(Obj&&) { std::cout << "搬移建構\n"; }
~Obj() { std::cout << "解構\n"; }
};
Obj make() {
Obj o;
return o; // NRVO:直接在 caller 空間建構
}
int main() {
Obj x = make();
}
// 輸出(NRVO 生效時):
// 建構 ← 只有一次!
// 解構 ← 只有一次!return std::move(v) - 最常見的反模式
很多人以為加上 std::move 會更快。恰恰相反:它會破壞 NRVO,讓效能變差。
// ❌ 破壞 NRVO!
std::vector<int> make_vec() {
std::vector<int> v = {1, 2, 3};
return std::move(v); // 編譯器無法做 NRVO → 強制 move construct
}
// ✅ 正確做法:直接 return
std::vector<int> make_vec() {
std::vector<int> v = {1, 2, 3};
return v; // 編譯器做 NRVO → 零複製零搬移
}為什麼?
return v;→ 編譯器看到 named local variable,啟動 NRVO → 零成本return std::move(v);→ 表達式型別變成 rvalue reference, NRVO 條件不成立 → 退化成 move construction
return std::move(x)幾乎永遠是錯的。 直接return x;讓編譯器幫你決定 - 它比你聰明。
NRVO 失敗的情況
NRVO 不是萬能的。以下幾種情況編譯器無法做 NRVO:
1. 多個 return 路徑使用不同 local 變數
// ❌ NRVO 失敗:編譯器不知道要在 caller 空間建構 a 還是 b
Obj make(bool flag) {
Obj a, b;
if (flag) return a;
return b;
}2. 回傳函數參數
// ❌ NRVO 失敗:參數的生命週期由 caller 管理,不在 callee 控制範圍
Obj process(Obj input) {
// ... 做一些處理
return input; // input 是參數,不是 local variable
}3. return std::move(x)
// ❌ NRVO 失敗:std::move 改變了表達式的 value category
Obj make() {
Obj o;
return std::move(o); // 變成 rvalue → NRVO 條件不成立
}4. 三元運算子中的不同變數
// ❌ NRVO 失敗:和多 return 路徑同理
Obj make(bool flag) {
Obj a, b;
return flag ? a : b;
}C++17 強制 Copy Elision
C++17 規定:回傳臨時物件(prvalue)時,copy elision 是強制的, 不是優化,而是語言保證。
Obj make() {
return Obj{}; // C++17 起:保證不會複製或搬移
}
// 等價於直接在 caller 空間建構 Obj{}
Obj x = make(); // 保證只有一次建構注意區分:回傳臨時物件(C++17 強制省略)vs 回傳 named local variable(NRVO,非強制但幾乎所有編譯器都做)。
正確 vs 錯誤模式速查
| 寫法 | NRVO / Copy Elision | 實際成本 |
|---|---|---|
return v; | NRVO | 零(直接建構在 caller) |
return Obj{}; | C++17 強制省略 | 零(語言保證) |
return std::move(v); | NRVO 失敗 | move construction |
return {a, b, c}; | C++17 強制省略 | 零(braced-init-list) |
return flag ? a : b; | NRVO 失敗 | copy 或 move |
Quiz
下列哪個 return 方式最快?
- A. Obj f1() { Obj v; return v; }
- B. Obj f2() { Obj v; return std::move(v); }
- C. Obj f3() { return Obj{}; }
- D. std::vector<int> f4() { return {1, 2, 3}; }
Show Answer
答案:A、C、D 都是最快的(零複製),B 最慢
A 觸發 NRVO - 零成本。C 是回傳臨時物件(C++17 強制 copy elision)- 零成本。D 是 braced-init-list,同樣享受 copy elision - 零成本。B 使用 std::move 破壞了 NRVO,強制執行一次 move construction,反而最慢。
總結
| 優化 | 機制 | 你需要做的事 |
|---|---|---|
SSO | 短字串存在物件內部,不碰 heap | 了解閾值、控制 key 長度 |
NRVO / Copy Elision | Hidden pointer,直接在 caller 空間建構 | 不要寫 return std::move(x) |
核心觀念
不要和編譯器搶工作 - 最好的優化是你什麼都不做,讓編譯器幫你做。
本文是「C++ 背後的秘密優化」系列的上篇。
下篇:Struct Padding、Vtable 與 Smart Pointer →