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
Tip

對值參數用 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
例外:universal reference 可能更省

若呼叫端傳入的不是 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;
};
呼叫鏈會累積 move 成本

addName 以值傳遞呼叫 validateName,後者又以值傳遞呼叫第三個函式……每層「只多一次便宜的 move」會沿呼叫鏈累加成無法忽視的開銷。對效能極致要求的軟體,即使便宜的 move 也應避免;by-reference 傳遞不會有這種累積。

Construction 複製 vs Assignment 複製:成本可能天差地遠

addNameconstruction 複製參數(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,證明值傳遞夠快後才採用。

Slicing problem(切割問題)

值傳遞與效能無關的致命傷: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 是否安全 安全:參數是獨立物件且為最後一次使用