邁向現代 C++練習題 (Practice - Moving to Modern C++)
Related Concepts
- 04-Moving-To-Modern-Cpp/01-Braced-Initialization
- 04-Moving-To-Modern-Cpp/02-Nullptr
- 04-Moving-To-Modern-Cpp/03-Alias-Declarations
- 04-Moving-To-Modern-Cpp/04-Scoped-Enums
- 04-Moving-To-Modern-Cpp/05-Deleted-Functions
- 04-Moving-To-Modern-Cpp/06-Override
- 04-Moving-To-Modern-Cpp/07-Const-Iterators
- 04-Moving-To-Modern-Cpp/08-Noexcept
- 04-Moving-To-Modern-Cpp/09-Constexpr
- 04-Moving-To-Modern-Cpp/10-Const-Member-Functions-Thread-Safety
- 04-Moving-To-Modern-Cpp/11-Special-Member-Function-Generation
| 關鍵字 | 答案 |
|---|---|
Widget w(); |
most vexing parse:宣告函式;預設建構請寫 Widget w{}; |
{} 遇上 std::initializer_list 建構子 |
強烈優先匹配;需 narrowing → 編譯錯誤,不 fallback |
vector<int> v(10, 20) vs v{10, 20} |
10 個值為 20 的元素 vs 兩個元素 10、20 |
f(0) / f(NULL) 遇整數與指標 overload |
呼叫整數版,永不呼叫 f(void*) |
nullptr 的型別 |
std::nullptr_t,隱式轉換為所有 raw pointer 型別 |
| typedef vs using 的決定性差異 | alias declaration 可模板化(alias template) |
模板內 X<T>::type 報錯 |
dependent type → 需 typename;alias template 免加 |
enum class 前向宣告 |
永遠可以(預設底層 int);unscoped 需指定底層型別 |
| 禁止呼叫某函式(C++11 首選) | = delete:public、編譯期報錯、任何函式皆可刪 |
| 覆寫「沒生效」卻能編譯 | 六大覆寫條件之一不符 → 加 override 讓編譯器把關 |
| 迭代但不修改元素 | const_iterator(cbegin/cend 取得) |
| vector 擴容以 move 取代 copy 的條件 | 元素 move ctor 為 noexcept |
const 變數當 std::array 大小 |
不保證可行;需要編譯期常量 → constexpr |
const 成員函式 + mutable 快取 + 多執行緒 |
data race → UB;單一變數 std::atomic、多變數一單位 std::mutex |
| 使用者宣告 destructor 之後 | move 不生成(退回 copy);以 = default 明確補回 |
Question 1 - 空大括號與 most vexing parse [recall]
Widget同時擁有預設建構子與Widgetinitializer_list<int>)建構子,請說明Widget w1;、Widget w2{};、Widget w3(;各自的意義,以及要如何傳入「空的 initializer_list」。
w1 與 w2{} 都呼叫預設建構子——空大括號的意義是「沒有引數」,不是「空的 std::initializer_list」。
w3() 是 most vexing parse:宣告一個回傳 Widget 的函式,不是物件(函式參數列不能用 {} 宣告,故 {} 免疫此問題)。
想傳空 list,必須把空大括號變成建構子引數:Widget w4({}); 或 Widget w5{{}};。
Question 2 - std::initializer_list 建構子的劫持 [recall]
std::vector<int> v1(10, 20);與std::vector<int> v2{10, 20};的內容各是什麼?背後是哪條 overload resolution 規則在作用?
v1 是 10 個元素、每個值 20(非 initializer_list 建構子);v2 是兩個元素 10、20(initializer_list 建構子)。
規則:只要有任何辦法把 {} 中的引數轉換成 std::initializer_list 的元素型別,編譯器就強烈優先選 initializer_list 建構子——即使其他建構子匹配更佳,連 copy/move 建構都可能被劫持;若匹配上但需要 narrowing conversion,直接編譯錯誤而非 fallback。
只有引數完全無法轉換成元素型別(如 int → std::string)時,才回到正常 overload resolution。
Question 3 - 0、NULL 與 nullptr 的 overload 命運 [recall]
已有三個 overload:
void f(int); void f(bool); void f(void*);,請說明f(0)、f(NULL)、f(nullptr)各自的呼叫結果與原因。
f(0) 呼叫 f(int)——0 的本質是 int,只有在非指標不可的語境才 fallback 成 null pointer。
f(NULL) 可能編譯失敗(若 NULL 是 0L,long→int、long→bool、0L→void* 三種轉換同樣好而歧義),通常呼叫 f(int),絕不會呼叫 f(void*)。
f(nullptr) 呼叫 f(void*)——nullptr 的型別是 std::nullptr_t,可隱式轉換為所有 raw pointer 型別,且無法被視為整數。配套守則:避免同時 overload 整數與指標型別。
Question 4 - lockAndCall 模板的編譯結果 [application]
以下三個呼叫哪些能編譯?請以 template type deduction 解釋各自的結果。
int f1shared_ptr<Widget> spw;
double f2unique_ptr<Widget> upw;
bool f3(Widget* pw);
template<typename FuncType, typename MuxType, typename PtrType>
decltype(auto) lockAndCall(FuncType func, MuxType& mutex, PtrType ptr)
{
MuxGuard g(mutex);
return func(ptr); // 鎖住 → 呼叫 → 解鎖
}
auto r1 = lockAndCall(f1, f1m, 0); // ?
auto r2 = lockAndCall(f2, f2m, NULL); // ?
auto r3 = lockAndCall(f3, f3m, nullptr); // ?
r1、r2 編譯失敗;r3 成功。
0 被推導為 int、NULL 被推導為某整數型別——deduction 推導出的是它們的「真實型別」而非 fallback 的指標意義,int 無法轉成 std::shared_ptr<Widget> / std::unique_ptr<Widget> → 型別錯誤。
nullptr 被推導為 std::nullptr_t,可隱式轉換為 Widget* → 通過。這是「想表達 null pointer 一律用 nullptr」最有力的理由:直接呼叫 f1(0) 合法(語境明確要求指標),但經 template 轉手即失效。
Question 5 - alias template 的決定性優勢 [recall]
typedef與 alias declaration(using X = ...;)做的事完全相同,那 Meyers 偏好 alias declaration 的決定性理由是什麼?在模板內使用兩種寫法各長什麼樣?
決定性理由是模板化能力:alias declaration 可模板化成 alias template;typedef 只能巢狀塞進模板化 struct、以 MyAllocList<T>::type 存取(函式指標可讀性只是次要理由)。
模板內:巢狀 typedef 是 dependent type(可能有特化讓 ::type 不是型別),必須寫 typename MyAllocList<T>::type;alias template 是 non-dependent type,直接寫 MyAllocList<T>——typename 不需要也不允許。
同一演進:C++11 type traits 的 std::transformation<T>::type → C++14 alias template 版 std::transformation_t<T>。
Question 6 - enum 的前向宣告與底層型別 [recall]
enum Color;與enum class Status;哪個能編譯?unscoped enum 要怎樣才能前向宣告?兩種 enum 的預設底層型別各是什麼?前向宣告的最大好處為何?
enum Color; 編譯錯誤;enum class Status; 合法——scoped enum 永遠可前向宣告,因為底層型別永遠已知(預設 int)。
unscoped enum 沒有預設底層型別(編譯器針對每個 enum 自選),但指定底層型別後也能前向宣告:enum Color: std::uint8_t;。
最大好處是降低編譯依賴:enum 定義改動(如新增列舉子)時,只 include 前向宣告的檔案不必重編,避免全系統重新編譯。
Question 7 - = delete 對決 private 未定義 [recall]
相較於 C++98「宣告為 private 且不定義」的手法,C++11 的
= delete有哪三大優勢?deleted 函式為何慣例上宣告為 public?
三大優勢:(1) 一律編譯期報錯——member/friend 誤用 private 未定義版本要到 link 期才失敗;(2) 任何函式都能刪——non-member 函式(如 bool isLucky(double) = delete; 擋掉 double 與 float)與 template instantiation(如 processPointer<void> / <char>),private 只能用於 member functions;(3) 模板特化必須寫在 namespace scope、不能有不同存取層級,private 手法對 member template 特化完全失效。
宣告 public 的原因:C++ 先檢查存取權限、後檢查 deleted 狀態,private 的 deleted 函式在部分編譯器只報「函式是 private」,public 才能得到準確的錯誤訊息。
Question 8 - 找出沒有生效的覆寫 [application]
以下程式碼完全合法,卻沒有任何一個覆寫成立——請逐一指出原因,並說明全部加上
override後會發生什麼事。
class Base {
public:
virtual void mf1() const;
virtual void mf2(int x);
virtual void mf3() &;
void mf4() const;
};
class Derived : public Base {
public:
virtual void mf1(); // ?
virtual void mf2(unsigned int x); // ?
virtual void mf3() &&; // ?
void mf4() const; // ?
};
mf1:base 是 const、derived 不是 → constness 不同;mf2:int vs unsigned int → 參數型別不同;mf3:& vs && → reference qualifier 不同(C++11 新增的覆寫條件);mf4:base 端根本不是 virtual。
四個函式都只是「同名新函式」,透過 Base 介面呼叫到的仍是 base 版本,且編譯器可能毫無警告(Meyers 實測兩款編譯器全警告開啟下無怨言)。
全部加上 override 後四個都變成編譯錯誤——這正是想要的效果:想覆寫卻沒覆寫成功時立即現形,也讓「修改 base virtual 簽章」的影響範圍可用重新編譯量化。
Question 9 - const_iterator 的實用化 [recall]
C++11 做了哪兩件事讓 const_iterator 從「幾乎沒人用」變得實用?寫最大化泛型的程式碼時該用哪種 begin?C++11 有 non-member
cbegin嗎?
兩件事:(1) 成員函式 cbegin/cend 對 non-const 容器也回傳 const_iterator;(2) insert/erase 等指定位置的成員函式改為接受 const_iterator。
最大化泛型程式碼應用 non-member 版本 begin(container)/cbegin(container)(內建陣列與只有 free function 介面的第三方庫沒有成員版本)。
C++11 沒有 non-member cbegin(標準化疏漏,只加了 begin/end),C++14 才補齊;C++11 可自製——對 reference-to-const 參數呼叫 std::begin,const 容器的 begin 自然回傳 const_iterator。
另注意:const_iterator 無法可攜地轉回 iterator,任何 cast 都不行。
Question 10 - noexcept 的優化空間與適用範圍 [recall]
noexcept與 C++98 的throw()在「例外洩漏時的行為」與優化空間上差在哪?為什麼大多數函式反而不該宣告 noexcept?哪些函式預設就隱含 noexcept?
noexcept:stack 只是可能回溯(possibly unwound)後終止——優化器不必維持 runtime stack 的可回溯狀態、不必保證物件依建構反序解構 → 最可優化;throw()(已棄用)必定回溯到呼叫端,與無例外規格同樣較不可優化。
大多數函式是 exception-neutral:自己不丟例外,但被呼叫者的例外要「過境」傳往上層 handler → 永遠不該 noexcept;也不要扭曲實作(例外改錯誤碼)硬換 noexcept。
解構子與記憶體釋放函式(operator delete / delete[])預設隱含 noexcept(唯一例外:某資料成員的解構子明確宣告 noexcept(false))。noexcept 是介面的一部分,屬長期承諾。
Question 11 - vector 擴容為何不敢 move [analysis]
Widget已自訂 move constructor 但沒有宣告 noexcept,std::vector<Widget>在push_back觸發擴容搬移元素時會用 move 還是 copy?請分析背後的例外安全考量與標準庫的檢查機制。
會用 copy。C++98 的 push_back 以逐一複製提供 strong exception safety guarantee(複製途中丟例外,原 vector 不變);若改用 move,第 n+1 個元素 move 時丟例外,已被搬走的 n 個元素無法安全復原(move 回去也可能再丟例外),保證即遭破壞。
因此標準庫採「move if you can, but copy if you must」:只有 move 操作已知不丟例外時才以 move 取代 copy。檢查鏈:push_back → std::move_if_noexcept → std::is_nothrow_move_constructible type trait → 由 move ctor 是否宣告 noexcept(或 throw())決定。
結論:替 Widget 的 move ctor 加上 noexcept,擴容才會真正 move——這是「為 move 操作宣告 noexcept」最強烈的理由;std::vector::reserve、std::deque::insert 等同理。
Question 12 - const 不等於 constexpr [recall]
int sz;之後寫const auto arraySize = sz; std::array<int, arraySize> data;能否編譯?constexpr 物件與 constexpr 函式的保證各是什麼?
不能編譯。const 只保證唯讀,不保證值在編譯期已知(可用執行期值初始化);std::array 大小要求編譯期常量,只能靠 constexpr。
constexpr 物件:必為 const,且以編譯期已知的值初始化,可放唯讀記憶體、當陣列大小 / template 引數 / enumerator 值。
constexpr 函式:雙模式——引數全為編譯期常量時產生編譯期結果;任一引數為執行期值時退化為普通函式執行期計算。記法:所有 constexpr 物件都是 const,反之不成立;且不能假設 constexpr 函式的結果是 const 或編譯期已知——這是特性非缺陷。
Question 13 - 快取兩個變數的同步策略 [application]
你要在 const 成員函式
magicValue()內快取昂貴計算結果,需要cachedValue與cacheValid兩個成員。同事建議用一對std::atomic避免 mutex 的開銷——請評估這個提議並給出正確做法。
mutable std::atomic<bool> cacheValid{ false };
mutable std::atomic<int> cachedValue; // 這樣夠嗎?
不夠,應改用 std::mutex。一對 atomic 無論寫入順序都有問題:先寫 cachedValue 再設 cacheValid → 其他執行緒在旗標設定前看到 false,重複執行昂貴計算;先設 cacheValid 再寫 cachedValue → 其他執行緒看到 true 卻讀到尚未賦值的錯誤值(更糟)。
準則:單一變數/記憶體位置 → std::atomic;兩個以上需作為一個單位操作 → std::mutex(以 std::lock_guard 涵蓋整段「檢查—計算—寫入」)。
附帶注意:mutex 須宣告 mutable(lock/unlock 是 non-const 操作);且 std::mutex 與 std::atomic 都是 move-only,加入後類別失去可複製性。
Question 14 - 加了 logging 解構子之後變慢 [analysis]
StringTable(內含std::map<int, std::string>)原本沒有宣告任何 copy/move/destructor;後來為了 logging 加入建構子與解構子,程式照樣編譯、測試全過,效能卻大幅下降——請分析原因並給出修正。
class StringTable {
public:
StringTable() { makeLogEntry("Creating StringTable object"); }
~StringTable() { makeLogEntry("Destroying StringTable object"); } // 問題在這
private:
std::map<int, std::string> values;
};
原因:move 生成的三條件是「無使用者宣告的 copy 操作、無 move 操作、無 destructor」——加入解構子後 move 操作不再生成,但 copy 操作照舊生成(僅標記 deprecated)。所有「move」StringTable 的程式碼依然合法:move 請求經 overload resolution 退回 copy,實際在複製底層的 std::map<int, std::string>,可能比 move 慢幾個數量級——因為語意正確、只是變慢,編譯與功能測試都抓不到。
修正:以 = default 明確宣告 move 與 copy 操作(StringTable(StringTable&&) = default; 等四個),意圖清楚且不受日後加 dtor 影響。多型基底類別的標準寫法同理:virtual ~Base() = default; + 四個 copy/move = default。
延伸:宣告 move 會使 copy 被 = delete;兩個 copy 操作彼此獨立、兩個 move 操作彼此不獨立;成員函式模板永遠不抑制 SMF 生成。
| 模式 | 一句話結論 |
|---|---|
Item 7:() vs {} |
{} 最通用、禁 narrowing、免疫 most vexing parse,但會被 initializer_list 建構子劫持 |
| Item 8:null pointer | 一律 nullptr;template deduction 讓 0/NULL 現出整數原形 |
| Item 9:型別同義詞 | 需要模板化只有 alias template 辦得到;免 ::type、免 typename |
| Item 10:enum | enum class 不洩漏名稱、無隱含轉換、永遠可前向宣告(預設底層 int) |
| Item 11:禁止呼叫 | public = delete:編譯期報錯,non-member 與模板特化都能刪 |
| Item 12:覆寫 | 意圖覆寫就寫 override;六大條件缺一即是不相干的新函式 |
| Item 13:迭代器 | 不修改就用 const_iterator;泛型程式碼用 non-member begin/cbegin(C++14 補齊) |
| Item 14:noexcept | 介面承諾;move/swap 價值最高;多數函式 exception-neutral 不該宣告 |
| Item 15:constexpr | 需要編譯期常量只能靠 constexpr;constexpr 函式編譯期/執行期雙模式 |
| Item 16:const 與執行緒 | const 成員函式須 thread safe:單一變數 std::atomic、多變數一單位 std::mutex |
| Item 17:SMF 生成 | move 生成三條件(無 copy、無 move、無 dtor);行為正確就 = default 明確化 |