面試陷阱題 (Exam Traps)
Purpose
本頁彙整《Effective Modern C++》各章最容易在面試與考題中答錯的陷阱。每個陷阱以摺疊 callout 呈現:先自我測試「我會不會踩」,再展開對照「陷阱是什麼、為何易錯、正確做法」,並連回對應筆記深入複習。
導論 (Introduction)
Trap: 看到
Widget&& rhs 就說 rhs 是 rvalue
- 陷阱:把參數的型別(rvalue reference)與其value category 混為一談。
- 為何易錯:
&&的字面直覺太強;但 rhs 具名、可取位址,本身是 lvalue。 - 正確做法:所有函式 parameter 都是 lvalue;函式內要轉交搬移語意必須寫
std::move(rhs)。 - → 術語與慣例
Trap: 以為 signature 包含 noexcept、constexpr 或函式名
- 陷阱:誤把函式宣告的所有修飾都算進 signature。
- 為何易錯:
noexcept、constexpr確實是介面的一部分(呼叫端會依賴),直覺上像 signature。 - 正確做法:本書定義的 signature 只含參數型別與回傳型別(如
bool(const Widget&));且官方標準定義與 Meyers 定義不同,官方有時不含回傳型別。 - → 術語與慣例
Trap: 以為所有 smart pointer 都能用
* 和 -> 解參考
- 陷阱:把「smart pointer 行為像指標」一般化到
std::weak_ptr。 - 為何易錯:unique_ptr / shared_ptr 都支援解參考,容易以偏概全。
- 正確做法:
std::weak_ptr不提供解參考運算子,必須先以lock()取得std::shared_ptr才能存取物件。 - → std::weak_ptr 用法
型別推導 (Deducing Types)
Trap: 以為 by-value 推導會剝除所有 const
- 陷阱:
f(T param)傳入const char* const時誤答T = char*。 - 為何易錯:「pass-by-value 忽略 const」的口訣只講了一半。
- 正確做法:只有頂層 const(指標本身)被忽略,指向物的 const 保留——正確答案是
const char*。 - → 模板型別推導
Trap:
auto v = { 27 }; 以為推導出 int
- 陷阱:auto 遇到 braced initializer 一律推導為
std::initializer_list,這是 auto 與 template 推導的唯一差異。 - 為何易錯:習慣 uniform initialization 到處用
{},忘了 auto 的特例;且元素型別不一致(如{ 1, 2, 3.0 })連推導都失敗。 - 正確做法:auto 變數要精確型別就用
=搭配非大括號初始器;並記得函式的 auto 回傳型別與 generic lambda 參數採 template 推導,return { 1, 2, 3 };無法編譯。 - → auto 型別推導
Trap: decltype(auto) 函式中寫
return (x); 而非 return x;
- 陷阱:
(x)是 lvalue 運算式而非名稱,decltype((x))推導為int&。 - 為何易錯:多一對括號在其他語境完全無害,這裡卻改變回傳型別。
- 正確做法:回傳區域變數時寫
return x;,否則函式回傳指向已銷毀區域變數的 reference,直接 undefined behavior。 - → decltype 規則
Trap: 以為
typeid(x).name() 印出的就是真正型別
- 陷阱:
std::type_info::name依規格強制以 by-value 規則處理型別——先剝 reference-ness,再剝頂層 const/volatile。 - 為何易錯:三大編譯器輸出一致,看起來很權威,實際對 cvr 修飾型別必然失真(這不是編譯器 bug)。
- 正確做法:編譯期用未定義模板
TD<decltype(x)>觸發錯誤訊息;執行期用 Boost.TypeIndex 的type_id_with_cvr。 - → 檢視推導型別
auto (auto)
Trap:
for (const std::pair<std::string,int>& p : m) 遍歷 map 看似零複製
- 陷阱:map 元素真正型別是
pair<const std::string,int>,型別不符使編譯器每圈複製出暫時物件再讓 p 綁定。 - 為何易錯:程式照常編譯執行,結果正確,只有效能與
&p懸空指標問題悄悄發生。 - 正確做法:用
const auto&,直接綁定容器內元素。 - → 優先使用 auto
; 以為推導出 bool
- 陷阱:
std::vector<bool>::operator[]回傳隱形 proxystd::vector<bool>::reference,auto 忠實推導出 proxy。 - 為何易錯:proxy 內含指向暫時 vector 的指標,語句結束後懸空——之後使用即 undefined behavior,且 auto 本身「沒推導錯」。
- 正確做法:explicitly typed initializer idiom:
auto highPriority = static_cast<bool>(features(w)[5]);,保留 auto 的所有優點。 - → 顯式型別初始器慣用法
Trap: 以為用 std::function 持有 lambda 與 auto 等價
- 陷阱:
std::function是固定大小的模板實體,裝不下 closure 時會 heap 配置,且間接呼叫抑制 inlining。 - 為何易錯:兩者語法上都「存了一個 lambda」,行為差異藏在實作層。
- 正確做法:用 auto 持有 closure——型別即 closure 本身,最小、最快、可 inline。
- → 優先使用 auto
邁向現代 C++ (Moving to Modern C++)
Trap: 以為 {} 的 initializer_list 匹配失敗(narrowing)會回退其他建構子
- 陷阱:只要引數「有任何辦法」轉成 list 元素型別,
{}就鎖定std::initializer_list建構子(連 copy/move 都可被劫持);需 narrowing 時直接拒絕編譯。 - 為何易錯:一般 overload resolution 會選最佳匹配,initializer_list 的「強力劫持」違反直覺。
- 正確做法:記住經典案例
vector<int> v(10,20)(10 個 20)vsv{10,20}(2 個元素);模板內建立物件時明辨()與{}語意差異。 - → 大括號初始化
Trap:
f(0) 遇到 f(int) 與 f(void*) 兩個 overload 時以為呼叫指標版
- 陷阱:0 的型別是 int,overload resolution 直接匹配
f(int),絕不會呼叫f(void*)。 - 為何易錯:「0 是 null pointer」只是語境不得已時的 fallback;經過 template 包一層更慘——推導出 int 後連編譯都失敗。
- 正確做法:表達 null pointer 一律用
nullptr(型別std::nullptr_t,可隱式轉換為所有 raw pointer 型別)。 - → nullptr 宣告
Trap: derived class 函式簽章與 base virtual 差一點點仍合法編譯
- 陷阱:少 const、
intvsunsigned int、&vs&&——這不是覆寫而是宣告同名新函式,透過 base 介面呼叫到的仍是 base 版本。 - 為何易錯:Meyers 實測部分編譯器開全警告也不提示,功能測試可能照樣通過。
- 正確做法:所有意圖覆寫的函式一律加
override,把錯誤變成編譯錯誤,還能反向揪出 base 端忘記 virtual 的問題。 - → override 宣告
Trap: 以為加了 move 操作,vector 擴容就會自動用 move 加速
- 陷阱:move ctor 未宣告
noexcept時,push_back/reserve為保住 strong exception safety guarantee 仍退回 copy。 - 為何易錯:「move if you can, but copy if you must」的判斷條件藏在
std::move_if_noexcept裡,程式碼上看不出來。 - 正確做法:確定不拋例外的 move 操作、swap 宣告
noexcept;但多數函式屬 exception-neutral,不該硬套。 - → noexcept 宣告
Trap: 以為 const 成員函式天生 thread safe
- 陷阱:mutable 快取成員讓「概念上唯讀」的 const 函式實際寫入資料成員,兩執行緒同時呼叫即 data race → undefined behavior。
- 為何易錯:「const = 唯讀 = 安全」的推論忽略了 mutable;用一對 std::atomic 旗標做快取也會因寫入順序出錯。
- 正確做法:單一變數用
std::atomic;兩個以上變數需一體操作時用mutable std::mutex。 - → const 成員函式的執行緒安全
Trap: 加上 logging destructor 後效能突然掉了幾個數量級
- 陷阱:使用者宣告 destructor 會抑制 move 操作生成,但 copy 照舊——所有「move」該物件的程式碼照樣編譯、照樣通過測試,實際卻在複製底層容器。
- 為何易錯:功能完全正確,只有效能默默劣化,profiler 才抓得到。
- 正確做法:宣告 dtor 的類別以
= default明確補齊 copy 與 move 四操作;記住 move 生成三條件(未宣告任何 copy、move、dtor)。 - → 特殊成員函式生成
Trap: 用 const 變數指定 std::array 大小或 template 引數
- 陷阱:const 只保證唯讀,不保證編譯期已知(可用執行期值初始化)。
- 為何易錯:「const 常數」的日常說法混淆了唯讀與編譯期常量兩個概念。
- 正確做法:需要 compile-time constant 的語境(array 大小、template 引數、enumerator)一律用
constexpr。 - → constexpr 用法
智慧指標 (Smart Pointers)
Trap: 用同一個 raw pointer 變數建構兩個 std::shared_ptr
- 陷阱:每次都建立新的 control block,兩個 reference count 各自歸零,物件被 delete 兩次 → undefined behavior。
- 為何易錯:程式碼看起來只是「兩個指標指向同一物件」,control block 的重複完全隱形。
- 正確做法:直接傳
new的結果給第一個 shared_ptr,其餘一律從既有 shared_ptr 複製;成員函式內把this交給 shared_ptr 要用std::enable_shared_from_this+shared_from_this()。 - → std::shared_ptr 用法
Trap: 先呼叫
wpw.expired() 確認後再存取物件
- 陷阱:檢查與存取分離引入 race condition——兩步之間其他執行緒可能銷毀最後一個 shared_ptr。
- 為何易錯:單執行緒思維下「先檢查再用」是好習慣,並行下卻是 UB 來源。
- 正確做法:一律用原子操作:
wpw.lock()(expired 回傳 null shared_ptr)或 shared_ptr 建構子(expired 擲出std::bad_weak_ptr)。 - → std::weak_ptr 用法
Trap:
processWidgetshared_ptr<Widget>(new Widget), computePriority() 以為不會漏資源
- 陷阱:編譯器可把
computePriority()排程在new Widget與 shared_ptr 建構子之間;它一拋例外,已 new 出的物件無人接管,直接洩漏。 - 為何易錯:「都用了 smart pointer 還會漏?」——漏洞在引數求值順序,不在指標本身。
- 正確做法:用
std::make_shared/std::make_unique;不能用 make 時以獨立語句先建構,再std::move傳入。 - → make_unique 與 make_shared
Trap: 以為 make_shared 的大物件在最後一個 shared_ptr 銷毀時就釋放記憶體
- 陷阱:make_shared 把物件與 control block 放在同一塊記憶體,只要 weak count > 0(還有 weak_ptr 存在)整塊都不能釋放。
- 為何易錯:物件解構與記憶體釋放的時間點是分離的,直覺上以為同時發生。
- 正確做法:大物件搭配長壽 weak_ptr 時改用直接
new(物件與 control block 分開配置)。 - → make_unique 與 make_shared
Trap: Pimpl 類別在 header 內寫
Widget(Widget&&) = default;
- 陷阱:in-class
= default等同於在 header 定義,而 move/dtor 的生成碼需要銷毀pImpl,觸發 default deleter 對 incomplete type 的 static_assert。 - 為何易錯:「= default 只是宣告」是常見誤解;錯誤訊息又指向 sizeof/delete,難以回溯到 = default 的位置。
- 正確做法:特殊成員函式「header 宣告、實作檔(Impl 定義之後)= default」;此儀式僅 unique_ptr 需要,shared_ptr 因 type-erased deleter 免除但語意不對。
- → Pimpl Idiom 用法
Move 語意與完美轉發 (Move Semantics and Perfect Forwarding)
Trap: 對 const 物件套用 std::move 以為會觸發 move
- 陷阱:
std::move(text)產生 const rvalue,move ctor 的非 constT&&無法綁定,反而 copy ctor 的const T&可以——程式照常編譯執行,實際是複製。 - 為何易錯:無警告、無錯誤,效能問題極難察覺。
- 正確做法:想被搬移的物件不要宣告 const;記住 std::move 唯一保證是轉型為 rvalue,不保證搬移。
- → std::move 與 std::forward
Trap: 以為模板裡的 T&& 一定是 universal reference
- 陷阱:
vector<T>::push_back(T&& x)的 T 在實體化時已固定,呼叫時沒有型別推導,是純 rvalue reference。 - 為何易錯:universal reference 的成立條件有兩個——發生型別推導 + 形式精確為
type&&——const T&&、vector<T>&&都不算。 - 正確做法:對照
emplace_back(Args&&... args):Args 每次呼叫都要推導,才是 universal reference;auto&&亦同。 - → Universal Reference 判定
Trap:
return std::move(localVar); 想「幫」編譯器最佳化
- 陷阱:
std::move(w)回傳的是參考而非區域物件本身,不符 RVO 條件,反而把零成本的 copy elision 降級為一次強制 move。 - 為何易錯:「多加 move 總沒錯」的直覺;且標準規定 RVO 未執行時編譯器本就必須隱含視為 rvalue。
- 正確做法:以值回傳區域變數直接
return w;;std::move / std::forward 只用在 rvalue reference / universal reference 參數的最後一次使用。 - → move 與 forward 的使用時機
Trap:
auto cloneOfP(p); 以為呼叫 copy constructor
- 陷阱:p 是 non-const lvalue 時,perfect-forwarding ctor 實例化出
Person(Person&),比需要加 const 的 copy ctor 更佳匹配。 - 為何易錯:universal reference 函式是 C++ 最貪婪的函式,幾乎對任何型別都是 exact match;繼承時 derived 的 copy/move ctor 也會被 base 的 forwarding ctor 攔截。
- 正確做法:避免對 universal reference 重載;建構子場合用
std::enable_if+!is_base_of約束模板。 - → 避免對 Universal Reference 重載
Trap: 對舊 C++98 型別呼叫 std::move 以為會變快
- 陷阱:型別宣告了 copy 操作或 destructor 就不會自動生成 move;rvalue 綁定到
const T&默默呼叫 copy。 - 為何易錯:編譯器不給任何警告;且「有 move 也未必用」——未宣告 noexcept 時 vector 擴容仍退回 copy,std::array、SSO 短字串的 move 也不比 copy 快。
- 正確做法:泛型程式碼悲觀假設 move「不存在、不便宜、不會被用」;已知型別直接查證。
- → Move 操作的現實
Trap:
emplace_back({1,2,3}) 或對 make 函式傳 {} 以為像直接呼叫一樣成功
- 陷阱:emplace 系列與 make 函式都是 variadic 完美轉發函式,braced initializer 對模板參數是 non-deduced context,無法編譯。
- 為何易錯:直接呼叫
f({1,2,3})可以,包一層轉發就失敗,語法看起來一模一樣。 - 正確做法:先
auto il = {1,2,3};(推導為std::initializer_list)再轉發;同屬五大失敗案例的還有 0/NULL、僅宣告的整數 static const 成員、多載函式名、bitfields。 - → 完美轉發失敗案例
Lambda 運算式 (Lambda Expressions)
會複製 data member
- 陷阱:捕獲只作用於 non-static 區域變數與參數;
[=]實際複製的是 this 指標,物件析構後 closure 持有懸空指標。 - 為何易錯:lambda body 裡寫
divisor看起來就像捕獲了成員,實際是this->divisor;用智慧指標管理物件也防不了。 - 正確做法:C++14 用 init capture
[divisor = divisor];C++11 先複製到區域變數再捕獲。另記:static 變數不會被捕獲,[=]下仍是 by-reference 行為。 - → 避免預設捕獲模式
兩個 pw 是同一個變數
- 陷阱:
=左側是 closure class 的新資料成員,右側是外部區域變數,分屬不同 scope。 - 為何易錯:同名遮蔽讓兩者看起來是一體;且 move 之後外部 pw 為 moved-from 狀態,不可再依賴其值。
- 正確做法:理解 init capture 的雙 scope 設計;lambda 本體內的 pw 一律指 closure 成員。
- → Init Capture 用法
(auto x){ normalize(x); } 以為能保留值類別
- 陷阱:具名參數 x 是 lvalue,即使呼叫端傳 rvalue,normalize 也永遠收到 lvalue。
- 為何易錯:lambda 內沒有模板參數 T 可寫
std::forward<T>,很多人因此放棄轉發。 - 正確做法:參數宣告為
auto&&,轉發寫std::forward<decltype(x)>(x)——正確性由 reference collapsing 保證。 - → decltype 與 auto&& 轉發
Trap:
std::bind(setAlarm, now() + 1h, _1, 30s) 看似延遲一小時後響
- 陷阱:傳給 std::bind 的運算式在 bind 當下立即求值並存入 bind object,鬧鐘實際在「呼叫 bind 後」一小時就響。
- 為何易錯:lambda 把運算式寫在 body 內是呼叫時才求值,兩種寫法外觀等價、語意迥異;bind 的儲存(一律以值)與呼叫(一律以參考)規則在程式碼上也無跡可循。
- 正確做法:一律優先 lambda——求值時機明確、重載自動解析、易 inline;C++14 起 std::bind 無任何合理用例。
- → Lambda vs std::bind 選擇
並行 API (The Concurrency API)
Trap: 以為 std::async(f) 一定會在另一條執行緒執行 f
- 陷阱:預設 launch policy 是
async | deferred的 OR 組合——f 可能被 deferred 到呼叫 get/wait 的執行緒同步執行,沒人呼叫 get/wait 則永不執行。 - 為何易錯:開發與測試時負載低、任務多被排為 async,
while (fut.wait_for(100ms) != ready)這類迴圈到正式環境高負載下才永不終止(deferred 永遠回傳future_status::deferred)。 - 正確做法:先以
wait_for(0s)探測是否 deferred;任務必須併發時明確傳std::launch::async。 - → 指定 std::launch::async
Trap: 以為「執行緒已跑完」的 std::thread 可以安全解構
- 陷阱:run to completion 的 std::thread 物件仍是 joinable,解構 joinable thread 直接
std::terminate。 - 為何易錯:底層執行緒結束與 std::thread 物件狀態是兩回事;例外路徑上更容易漏掉 join。
- 正確做法:用 RAII 類別(如 ThreadRAII)保證所有離開路徑(return、break、例外)上 thread 都已 join/detach;對 unjoinable thread 呼叫 join/detach 也是 UB,解構子須先
joinable()檢查。 - → 所有路徑上 unjoinable
Trap: 把「來自 std::async 的 future 解構會阻塞」當成完整規則
- 陷阱:解構阻塞需三條件並立:shared state 來自 std::async + policy 為
std::launch::async+ 是最後一個指涉的 future;deferred 任務的最後一個 future 解構只是讓任務永遠不跑。 - 為何易錯:future 的 API 無法查詢 shared state 是否來自 std::async,持有 future 的容器或類別解構時可能意外阻塞。
- 正確做法:對比記憶:joinable std::thread 解構 → terminate;future 解構 → 永不終止程式,最多隱含 join 阻塞;packaged_task 的 future 必為正常行為。
- → Thread Handle 解構行為
Trap: 以為 promise/future 通道可重複通知事件
- 陷阱:
std::promise只能 set 一次,promise–future 是 one-shot 機制,且 shared state 通常在 heap 上配置。 - 為何易錯:它免 mutex、免 spurious wakeup、真正阻塞,優點太吸引人而忽略限制。
- 正確做法:可重複通知用 condvar(注意需 lambda 驗證事件防虛假喚醒)或 flag;一次性事件才用
std::promise<void>+future<void>.wait()。 - → void futures 事件通訊
Trap: 以為 C++ 的 volatile 像 Java/C# 一樣能用於執行緒同步
- 陷阱:C++ 的 volatile 既不保證原子性也不限制重排,多執行緒同時讀寫即 data race → UB(兩執行緒各
++一次 volatile int 結果可能是 1)。 - 為何易錯:跨語言經驗誤導;Java/C# 的 volatile 語意接近 C++ 的
std::atomic。 - 正確做法:並行用
std::atomic;volatile 專用於特殊記憶體(memory-mapped I/O,禁止優化 redundant loads / dead stores);另記auto y = x;會丟棄 volatile 修飾。 - → std::atomic vs volatile 選擇
微調 (Tweaks)
Trap: setter 以 assignment 複製值參數,以為與 const T& overload 成本相同
- 陷阱:lvalue 引數觸發 copy construct(配置記憶體)+ move assign(釋放舊記憶體),成本可能比 const T& overload 高數個數量級。
- 為何易錯:「值傳遞只多一次 move」的分析只適用於建構參數的情境,assignment 情境的記憶體配置被忽略。
- 正確做法:pass by value 需四條件並立(copyable + cheap to move + always copied + 個案評估);採有罪推定,先證明夠快再用;base class 參數絕不可值傳遞(slicing)。
- → 值傳遞
Trap:
ptrs.emplace_back(new Widget, killWidget)
- 陷阱:emplace 先配置容器 node,配置拋例外時 raw pointer 尚無管理者 → 資源洩漏;push_back 會先建暫時 shared_ptr 反而例外安全。
- 為何易錯:「emplacement 比 insertion 快」的印象讓人無差別替換,忽略資源管理物件的例外安全。
- 正確做法:先在獨立敘述建立 shared_ptr/unique_ptr,再
push_backmove(spw)。 - → Emplacement 用法
Trap:
regexes.emplace_back(nullptr) 竟然可以編譯
- 陷阱:emplacement 用 direct initialization,允許呼叫 explicit 建構子——編譯通過但執行是 UB;
push_back(nullptr)用 copy initialization,會被編譯期擋下。 - 為何易錯:insertion 與 emplacement 對 explicit 建構子的初始化語意不同,這差異幾乎沒人記得。
- 正確做法:emplacement 傳入的引數要以「假如手寫 direct initialization 是否合理」檢視;效能上也應 benchmark 而非假設 emplace 必快。
- → Emplacement 用法