Item 41:對可複製、移動便宜且總是被複製的參數考慮值傳遞 (Consider Pass by Value for Copyable Parameters That Are Cheap to Move and Always Copied)
Overview Table
| 面向 | 重點 |
|---|---|
| 適用條件(四項全滿足才考慮) | copyable(可複製)+ cheap to move(移動便宜)+ always copied(總是被複製)+ 只是「consider」(仍需評估) |
| 相對成本 | 比 by-reference 方案(overloading / universal reference)多一次 move(lvalue:1 copy + 1 move;rvalue:2 moves) |
| 優點 | 只寫一個函式(原始碼與 object code 都只有一份)、避開 universal reference 的怪異失敗與錯誤訊息 |
| Construction vs Assignment | 參數以 assignment 複製時,值傳遞可能引發額外的記憶體配置/釋放,成本遠超一次 move |
| Slicing | 值傳遞會切割 (slice) 衍生類別物件,base class 參數型別不適用值傳遞 |
| Move-only 型別 | 不適用:直接寫單一 rvalue reference overload 即可(值傳遞反而多一次 move) |
三種實作方式:Overloading、Universal Reference、Pass by Value
以 Widget::addName 為例,目標是「lvalue 複製、rvalue 移動」:
// Approach 1: Overloading(多載)—— 兩個函式,維護成本加倍
class Widget {
public:
void addName(const std::string& newName) // 接 lvalue:複製
{ names.push_back(newName); }
void addNamestring&& newName // 接 rvalue:移動
{ names.push_backmove(newName); }
private:
std::vector<std::string> names;
};
// Approach 2: Universal Reference(見 Item 24)—— 一份原始碼,
// 但必須放在標頭檔、可能實體化出多個函式、錯誤訊息嚇人
class Widget {
public:
template<typename T>
void addName(T&& newName)
{ names.push_backforward<T>(newName); } // 見 Item 25
};
// Approach 3: Pass by Value(值傳遞)—— 一個函式解決一切
class Widget {
public:
void addNamestring newName // lvalue 或 rvalue 皆可
{ names.push_backmove(newName); } // newName 是獨立物件,
}; // 且為最後一次使用 → 可安全 move
對值參數用 std::move 是安全的,因為 (1) newName 是與呼叫端引數完全獨立的物件;(2) 這是函式內最後一次使用它。
C++98 中值傳遞一律 copy construct;C++11 起,lvalue 引數觸發 copy construction、rvalue 引數觸發 move construction——這正是值傳遞在現代 C++ 翻身的原因。
成本會計:值傳遞恆多一次 move
呼叫端引數 參數 newName 的建構 函式內
────────── ──────────────────── ──────────────────
lvalue (name) ──► copy construction ──► std::move(newName)
→ move 進 names
rvalue (name+"J") ──► move construction ──► std::move(newName)
→ move 進 names
| 方案 | 傳入 lvalue | 傳入 rvalue | 主要缺點 |
|---|---|---|---|
| Overloading | 1 copy | 1 move | 兩個函式要宣告、實作、文件化、維護 |
| Universal reference | 1 copy | 1 move | 標頭檔膨脹、實體化多份、Item 30 失敗案例、錯誤訊息難懂 |
| Pass by value | 1 copy + 1 move | 2 moves | 恆比 by-reference 方案多一次 move |
若呼叫端傳入的不是 std::string(如字串字面值),universal reference 會將其直接轉發給 std::string 建構子,可能零次 copy/move(見 Item 25)——此時 by-value 與 overloading 都比不上它。上表假設呼叫端一律傳 std::string。
標題的四個限定詞,缺一不可
| 限定詞 | 理由 |
|---|---|
| Consider(只是考慮) | 值傳遞恆有額外成本,且還有下述隱藏開銷,需個案評估 |
| copyable(可複製) | move-only 型別不需要 lvalue overload(copy ctor 已 delete),單一 rvalue reference 版本即可;值傳遞反而把 1 move 變 2 moves |
| cheap to move(移動便宜) | 多的那次 move 若昂貴,就等同做了不必要的 copy——這正是 C++98 禁止值傳遞的初衷 |
| always copied(總是被複製) | 若函式條件式複製(如先檢查長度),不符合條件時值傳遞仍白白付出參數的建構+解構成本 |
// move-only 反例:std::unique_ptr 的 setter 只需一個 rvalue overload
class Widget {
public:
void setPtrstring>&& ptr
{ p = std::move(ptr); } // 總成本:1 move
// 若改成值傳遞 void setPtrstring> ptr
// → 參數 move construct + move assign = 2 moves(成本加倍)
private:
std::unique_ptr<std::string> p;
};
若 addName 以值傳遞呼叫 validateName,後者又以值傳遞呼叫第三個函式……每層「只多一次便宜的 move」會沿呼叫鏈累加成無法忽視的開銷。對效能極致要求的軟體,即使便宜的 move 也應避免;by-reference 傳遞不會有這種累積。
Construction 複製 vs Assignment 複製:成本可能天差地遠
addName 用 construction 複製參數(push_back 到新元素),前述分析即完整。但若函式用 assignment 複製參數,情況複雜得多:
class Password {
public:
explicit Passwordstring pwd // 值傳遞 + construction:OK
: textmove(pwd) {}
void changeTostring newPwd // 值傳遞 + assignment:可能爆炸
{ text = std::move(newPwd); }
private:
std::string text;
};
std::string newPassword = "Beware the Jabberwock";
p.changeTo(newPassword); // lvalue → newPwd copy construct(配置記憶體)
// → move assign 給 text(釋放 text 舊記憶體)
// = 一次配置 + 一次釋放!
// 若用 const std::string& overload:text = newPwd 可直接重用
// text 既有容量(text.capacity() >= newPwd.size() 時),零配置零釋放
值傳遞在此多付的是動態記憶體配置+釋放,成本可能比一次 std::string move 高出數個數量級。此問題主要出現在 lvalue 引數(真正的 copy 才需要配置記憶體;rvalue 幾乎都靠 move 解決)。
Assignment 複製的額外成本取決於:傳入型別是否用動態記憶體、lvalue/rvalue 比例、目標記憶體是否 ≥ 來源、以及 std::string 是否採用 SSO(small string optimization,見 Item 29)且值是否放得進 SSO buffer。實務建議採「有罪推定 (guilty until proven innocent)」:先用 overloading 或 universal reference,證明值傳遞夠快後才採用。
值傳遞與效能無關的致命傷:base class 型別的值參數會把傳入的衍生類別物件切成 base 部分。
class Widget { /* ... */ };
class SpecialWidget : public Widget { /* ... */ };
void processWidget(Widget w); // 值傳遞:有 slicing 問題
SpecialWidget sw;
processWidget(sw); // 函式只看到 Widget,不是 SpecialWidget!
因此base class 參數型別通常不適合值傳遞——這也是 C++98 值傳遞名聲不佳的原因之一。
Exam/Test Patterns
| 情境關鍵字 | 答案 |
|---|---|
| lvalue 複製、rvalue 移動,只想寫一個函式 | 值傳遞 + 函式內 std::move(param) |
| 值傳遞 vs by-reference 的成本差 | 恆多一次 move(lvalue:1 copy + 1 move;rvalue:2 moves) |
move-only 型別(如 std::unique_ptr)的 setter |
單一 rvalue reference overload;值傳遞會 1 move 變 2 moves |
| 函式先檢查條件才複製參數 | 不符「always copied」→ 值傳遞白付建構/解構成本,改用 by-reference |
| 參數用 assignment 複製(如 setter 覆寫既有成員) | 值傳遞可能多付記憶體配置/釋放,成本遠超一次 move;採有罪推定 |
| 多層函式都值傳遞 | move 成本沿呼叫鏈累積,效能敏感軟體應改 by-reference |
| base class 型別參數 + 值傳遞 | Slicing problem:衍生類別特性被切掉 |
| universal reference 方案的缺點 | 需放標頭檔、實體化多份、Item 30 轉發失敗案例、錯誤訊息難懂 |
對值參數呼叫 std::move 是否安全 |
安全:參數是獨立物件且為最後一次使用 |
Related Notes
- 09-Tweaks/02-Emplacement — 同章 Item 42:另一個「consider」級技巧,避免暫時物件的建構成本
- 06-Move-Semantics-And-Perfect-Forwarding/07-Move-Operation-Realities — Item 29:move 不一定便宜(SSO 等),是「cheap to move」前提的依據
- 06-Move-Semantics-And-Perfect-Forwarding/03-Move-Vs-Forward-Usage — Item 25:
std::move用於 rvalue reference、std::forward用於 universal reference 的原則 - 06-Move-Semantics-And-Perfect-Forwarding/02-Universal-References — Item 24:Approach 2 所用的 universal reference 本質
- 06-Move-Semantics-And-Perfect-Forwarding/08-Perfect-Forwarding-Failure-Cases — Item 30:universal reference 方案的轉發失敗案例
- 05-Smart-Pointers/01-Unique-Ptr — Item 18:move-only 型別代表,本 Item「copyable」限定詞的反例