← Back to Blog
C++PerformanceMemory

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 分配。

cpp
// 簡化的概念模型(非實際實作)
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 bytes32
Clang libc++22 bytes24
MSVC STL15 bytes32

記憶體佈局圖

=== 短字串(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。

cpp
#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> vs vector<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 是否生效。

cpp
#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,讓效能變差。

cpp
// ❌ 破壞 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 變數

cpp
// ❌ NRVO 失敗:編譯器不知道要在 caller 空間建構 a 還是 b
Obj make(bool flag) {
    Obj a, b;
    if (flag) return a;
    return b;
}

2. 回傳函數參數

cpp
// ❌ NRVO 失敗:參數的生命週期由 caller 管理,不在 callee 控制範圍
Obj process(Obj input) {
    // ... 做一些處理
    return input;    // input 是參數,不是 local variable
}

3. return std::move(x)

cpp
// ❌ NRVO 失敗:std::move 改變了表達式的 value category
Obj make() {
    Obj o;
    return std::move(o);   // 變成 rvalue → NRVO 條件不成立
}

4. 三元運算子中的不同變數

cpp
// ❌ NRVO 失敗:和多 return 路徑同理
Obj make(bool flag) {
    Obj a, b;
    return flag ? a : b;
}

C++17 強制 Copy Elision

C++17 規定:回傳臨時物件(prvalue)時,copy elision 是強制的, 不是優化,而是語言保證。

cpp
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 ElisionHidden pointer,直接在 caller 空間建構不要寫 return std::move(x)

核心觀念

不要和編譯器搶工作 - 最好的優化是你什麼都不做,讓編譯器幫你做。

本文是「C++ 背後的秘密優化」系列的上篇。

下篇:Struct Padding、Vtable 與 Smart Pointer →