學習目標
- 掌握結構化綁定(structured bindings):從 pair/tuple/struct/array 解構,理解拷貝 vs 參考的差異
- 學會
if/switch帶初始化語句,把變數作用域限制在分支內 - 理解
if constexpr的編譯期分支,並用於樣板程式設計 - 熟練
std::optional(has_value/value/value_or與bad_optional_access陷阱) - 熟練
std::variant(holds_alternative/get/get_if/visit與 overloaded 模式) - 認識
std::any(any_cast)及其取捨 - 掌握
std::string_view(非擁有、懸垂陷阱、無 null 終止保證) - 操作
std::filesystem(path/exists/iterate/create) - 使用折疊表達式(fold expressions)簡化可變參數樣板
- 使用類別樣板引數推導(CTAD)省略樣板參數
- 善用屬性
[[nodiscard]]/[[maybe_unused]]/[[fallthrough]] - 認識 C++17 巢狀命名空間語法與 inline 變數
為什麼要學 C++17?WHY
C++17 不是革命性的大改版,而是一系列「讓日常程式碼更安全、更簡潔」的實用改進。它解決了許多長年以來只能靠慣用法(idiom)或樣板雜技繞過的痛點:
- 多值回傳不必再記
std::get<0>、.first/.second→ 結構化綁定 - 「可能沒有值」不必再用魔術數字
-1、nullptr表示 →std::optional - 型別安全的「多選一」不必再用 C 風格
union+ tag →std::variant - 函式收字串參數不必在
const std::string&和const char*多載間糾結 →std::string_view - 樣板分支不必再寫 SFINAE 天書 →
if constexpr - 跨平台檔案操作不必再依賴 Boost 或平台 API →
std::filesystem
本章逐一深入這些特性,並標出實務上的陷阱。所有範例皆可用 g++ -std=c++17 -Wall 編譯。
1. 結構化綁定(Structured Bindings)
C++17 允許用一個宣告同時把一個物件的多個成員「綁定」到具名變數,大幅提升可讀性。
1.1 搭配 std::pair 和 std::tuple
#include <tuple>
#include <cstdlib>
auto [q, r] = std::div(17, 5); // q=3, r=2(div_t 的兩個成員)
auto [name, age, score] = std::make_tuple(std::string("Alice"), 20, 95.5);
// name="Alice", age=20, score=95.5
預期輸出(示意):17 = 3 ... 2、Alice 20 95.5
過去要這樣寫:auto t = ...; auto name = std::get<0>(t); auto age = std::get<1>(t);——又臭又長。
1.2 搭配 struct
只要 struct 的非靜態資料成員都是公開的,就能解構:
struct Point { double x, y, z; };
Point p{1.0, 2.0, 3.0};
auto [x, y, z] = p; // x=1.0, y=2.0, z=3.0
綁定的名稱按成員宣告順序對應,與成員名稱無關(你可以取任何新名字)。
1.3 搭配 std::map 迭代(最常見的應用)
#include <map>
std::map<std::string, int> scores = {{"Alice", 95}, {"Bob", 87}};
for (const auto& [name, score] : scores) {
std::cout << name << ": " << score << "\n";
}
預期輸出(std::map 依鍵排序):
Alice: 95
Bob: 87
比起以前要寫 it->first、it->second,語意清楚太多了。
1.4 搭配原生陣列
int arr[] = {10, 20, 30};
auto [a, b, c] = arr; // a=10, b=20, c=30
綁定數量必須恰好等於陣列長度。
1.5 拷貝 vs 參考(重要陷阱!)
auto [...] 的修飾詞作用在「隱藏的整體物件」上,決定是拷貝還是參考:
Student s{"Bob", 21, 3.85};
auto [n1, a1, g1] = s; // 拷貝:改 g1 不影響 s
auto& [n2, a2, g2] = s; // 參考:改 g2 會改 s.gpa
const auto& [n3, a3, g3] = s; // 唯讀參考:零拷貝又不可改(推薦給只讀迴圈)
g2 = 4.0; // s.gpa 變成 4.0
// g1 = 4.0; 只改了拷貝,s 不變
效能陷阱:對
std::map<std::string, BigObject>用for (auto [k, v] : m)會整份拷貝每個元素!只讀時請用for (const auto& [k, v] : m)。
2. if / switch 帶初始化語句
C++17 允許在 if/switch 條件前放一段初始化,讓臨時變數的作用域精確限制在分支內,避免外洩污染。
// if (初始化; 條件)
if (auto it = myMap.find(key); it != myMap.end()) {
use(it->second);
}
// it 在此已不可見 ── 不會污染後續程式碼
// switch (初始化; 條件)
switch (int code = getStatus(); code) {
case 200: /* ... */ break;
default: /* ... */ break;
}
這特別適合「先取得、再檢查」的慣用法。搭配鎖也很實用:
if (std::lock_guard lock(mtx); ready) { // lock 只活在 if 區塊
doWork();
} // lock 在此自動釋放
WHY:以前你得在 if 外宣告 it,它的生命週期被迫拉長,既佔用名稱又容易被誤用。現在作用域與邏輯一致,更安全。
3. if constexpr — 編譯期分支
if constexpr 在編譯期求值條件,不成立的分支會被整段丟棄(不會被實例化、不會編譯)。這對樣板至關重要。
#include <type_traits>
template <typename T>
auto process(T t) {
if constexpr (std::is_integral_v<T>) {
return t * 2; // T 為整數時,只有這段被編譯
} else if constexpr (std::is_floating_point_v<T>) {
return t + 0.5; // T 為浮點時,只有這段被編譯
} else {
return t; // 其他型別
}
}
關鍵差異:一般 if 要求所有分支都能通過編譯,即使執行時走不到;if constexpr 則允許「走不到的分支根本不合法」。例如:
template <typename T>
void printSize(const T& x) {
if constexpr (std::is_same_v<T, std::string>) {
std::cout << x.size(); // 只有 T=string 才會編譯這行
} else {
std::cout << x; // T 沒有 .size() 時走這裡,不會報錯
}
}
若把上面的 if constexpr 換成普通 if,當 T=int 時 x.size() 仍會被編譯而報錯。if constexpr 因此取代了許多 SFINAE / tag dispatch 的場景,讓樣板程式碼直觀許多。
4. std::optional
std::optional<T> 表示「可能有值,也可能沒有值」,取代過去用 -1、nullptr、特殊旗標表示「無效/查無」的脆弱做法。
4.1 基本操作
#include <optional>
std::optional<int> findIndex(const std::vector<int>& v, int target) {
for (size_t i = 0; i < v.size(); ++i)
if (v[i] == target) return static_cast<int>(i);
return std::nullopt; // 沒找到
}
auto r = findIndex(data, 42);
if (r.has_value()) std::cout << "index = " << *r << "\n"; // *r 或 r.value()
if (r) std::cout << "index = " << r.value(); // 可直接當 bool
int idx = r.value_or(-1); // 沒值就回傳 -1
| 操作 | 意義 |
|---|---|
has_value() / operator bool |
是否有值 |
value() |
取值;無值時丟 std::bad_optional_access |
operator* / operator-> |
取值;無值時為未定義行為(UB) |
value_or(fallback) |
有值回傳值,無值回傳 fallback(最安全) |
reset() |
清空為無值 |
4.2 陷阱:別對空的 optional 呼叫 value()!
std::optional<int> empty;
// int x = empty.value(); // ❌ 丟出 std::bad_optional_access
// int y = *empty; // ❌ 未定義行為(可能讀到垃圾)
int z = empty.value_or(0); // ✅ 安全,z = 0
最佳實踐:取值前先檢查(
if (opt)),或直接用value_or提供預設值。能用value_or就別冒險用value()。
5. std::variant
std::variant<Types...> 是型別安全的聯合體:同一時間只持有其中一個型別的值,且永遠知道自己現在持有哪一個。它取代了 C 風格「union + 手動 tag」的危險寫法。
5.1 基本操作
#include <variant>
std::variant<int, double, std::string> v;
v = 42; // 持有 int
v = 3.14; // 改持有 double
v = std::string("hello"); // 改持有 string
if (std::holds_alternative<std::string>(v)) // 型別檢查
std::cout << std::get<std::string>(v); // 依型別取值
// get_if:失敗回傳 nullptr,不丟例外(安全取值)
if (auto* p = std::get_if<std::string>(&v))
std::cout << *p;
| 操作 | 意義 |
|---|---|
holds_alternative<T>(v) |
目前是否持有 T |
get<T>(v) / get<I>(v) |
取值;型別不符丟 std::bad_variant_access |
get_if<T>(&v) |
取指標;型別不符回傳 nullptr(安全) |
v.index() |
目前持有第幾個替代型別(0 起算) |
std::visit(visitor, v) |
對當前持有值套用訪問者 |
5.2 std::visit 與 overloaded 模式
std::visit 對 variant「當前持有的型別」分派處理。常見寫法是泛型 lambda + if constexpr:
std::visit([](auto&& arg) {
using T = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<T, int>)
std::cout << "int: " << arg << "\n";
else if constexpr (std::is_same_v<T, std::string>)
std::cout << "string: " << arg << "\n";
else
std::cout << "other: " << arg << "\n";
}, v);
更優雅的是 overloaded 模式——把多個 lambda 合成一個多載集合:
// 通用樣板:把一堆 lambda 的 operator() 全部繼承過來
template <class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template <class... Ts> overloaded(Ts...) -> overloaded<Ts...>; // CTAD 推導指引
std::visit(overloaded{
[](int i) { std::cout << "int: " << i << "\n"; },
[](double d) { std::cout << "double: " << d << "\n"; },
[](const std::string& s) { std::cout << "string: " << s << "\n"; },
}, v);
這個模式同時用到了「可變參數繼承」「fold 形式的 using」與「CTAD 推導指引」,是 C++17 的經典慣用法。
6. std::any
std::any 可存放任意型別的值,是三者中最靈活、也最缺乏型別安全的:
#include <any>
std::any a = 42;
a = std::string("hello");
a = 3.14;
double d = std::any_cast<double>(a); // 取值需明確指定型別
// 型別不符 → 丟 std::bad_any_cast
if (auto* p = std::any_cast<int>(&a)) // 指標版:失敗回 nullptr
std::cout << *p;
選擇指引:能用
std::optional就不用std::variant;能用std::variant(型別集合已知且有限)就不用std::any。std::any多半用在外掛系統、型別擦除(type erasure)等真的無法預知型別的場景。
| 工具 | 適用 | 型別安全 |
|---|---|---|
std::optional<T> |
「有或沒有」一個已知型別 | 高 |
std::variant<Ts...> |
「有限幾種型別」之一 | 高 |
std::any |
「任意型別」 | 低(執行期才知道) |
7. std::string_view
std::string_view 是一個不擁有資料的字串「檢視」:內部只存一個指標 + 長度,提供零拷貝的唯讀存取。它讓函式參數既能接 const char* 又能接 std::string,而完全不複製。
#include <string_view>
void print(std::string_view sv) { std::cout << sv << "\n"; }
print("Hello"); // const char* → string_view(零拷貝)
print(std::string("World")); // std::string → string_view(零拷貝)
std::string_view sv = "Hello, World!";
auto sub = sv.substr(0, 5); // "Hello",仍是 string_view,不配置記憶體
7.1 陷阱一:懸垂(dangling)—— 底層字串必須活得比 view 久
string_view 不持有資料,一旦底層字串消失,view 就指向無效記憶體:
std::string_view bad() {
std::string local = "temp";
return local; // ❌ local 在函式結束被銷毀 → 回傳懸垂 view
}
std::string_view sv = std::string("hi") + "!"; // ❌ 暫存 string 立刻銷毀 → 懸垂
最佳實踐:把
string_view當「短命的函式參數」用,不要儲存為類別成員或回傳指向區域/暫存字串的 view(除非你能保證生命週期)。
7.2 陷阱二:不保證以 null 結尾
string_view 只是「指標 + 長度」,sv.substr() 或 remove_prefix 後,資料未必以 '\0' 結尾。因此不可把 sv.data() 直接傳給需要 C 字串的 API(如 printf("%s", sv.data())、std::fopen):
std::string_view sv = "Hello, World!";
auto hello = sv.substr(0, 5); // 指向 "Hello, World!" 的前 5 字元
// printf("%s\n", hello.data()); // ❌ 會印出 "Hello, World!"(讀到 view 範圍外!)
std::string s{hello}; // ✅ 需要 C 字串時,先轉成 std::string
8. std::filesystem
C++17 把 Boost.Filesystem 納入標準,提供跨平台的檔案系統操作,標頭為 <filesystem>。慣例上會取別名 namespace fs = std::filesystem;。
#include <filesystem>
namespace fs = std::filesystem;
fs::path p = "/home/user/report.txt";
std::cout << p.filename() << "\n"; // report.txt
std::cout << p.stem() << "\n"; // report
std::cout << p.extension() << "\n"; // .txt
fs::path combined = fs::path("/home/user") / "data" / "x.csv"; // 用 / 拼接
if (fs::exists("test.txt"))
std::cout << fs::file_size("test.txt") << " bytes\n";
fs::create_directories("a/b/c"); // 建立多層目錄
for (const auto& e : fs::directory_iterator(".")) // 巡覽單層
std::cout << e.path() << "\n";
for (const auto& e : fs::recursive_directory_iterator(".")) // 遞迴巡覽
std::cout << e.path() << "\n";
| 功能 | API |
|---|---|
| 路徑解析 | path::filename / stem / extension / parent_path |
| 存在性與型別 | exists / is_regular_file / is_directory |
| 大小 | file_size |
| 建立 | create_directory / create_directories |
| 複製/改名/刪除 | copy_file / rename / remove / remove_all |
| 巡覽 | directory_iterator / recursive_directory_iterator |
陷阱:多數 filesystem 函式在失敗時會丟例外
fs::filesystem_error。若不想用例外,可呼叫接受std::error_code&的多載版本(如fs::file_size(path, ec)),失敗時設定ec而不丟例外。另注意 Windows 用\分隔、Unix 用/,fs::path已幫你處理可攜性。
9. 折疊表達式(Fold Expressions)
折疊表達式讓可變參數樣板(variadic template)能用簡潔語法對「參數包」做運算,免去過去的遞迴展開。
template <typename... Args>
auto sum(Args... args) {
return (... + args); // 一元左折疊:((a1 + a2) + a3) + ...
}
sum(1, 2, 3, 4); // 10
template <typename... Args>
void printAll(const Args&... args) {
((std::cout << args << ' '), ...); // 用逗號運算子折疊,逐一輸出
std::cout << '\n';
}
四種形式:
| 形式 | 語法 | 展開 |
|---|---|---|
| 一元左折疊 | (... op pack) |
((a1 op a2) op a3) op ... |
| 一元右折疊 | (pack op ...) |
a1 op (a2 op (a3 op ...)) |
| 二元左折疊 | (init op ... op pack) |
((init op a1) op a2) op ... |
| 二元右折疊 | (pack op ... op init) |
a1 op (a2 op (... op init)) |
二元形式提供初始值,能正確處理空參數包:(0 + ... + args) 在沒有引數時回傳 0。
10. 類別樣板引數推導(CTAD)
C++17 起,編譯器能從建構子引數推導類別樣板參數,不必再手動寫出完整型別。
// C++14:必須寫出型別
std::pair<int, double> p1(1, 2.5);
std::vector<int> v1{1, 2, 3};
// C++17:CTAD 自動推導
std::pair p2(1, 2.5); // → std::pair<int, double>
std::vector v2{1, 2, 3}; // → std::vector<int>
std::tuple t(1, 2.0, "hi"); // → std::tuple<int, double, const char*>
std::lock_guard lock(mtx); // → std::lock_guard<std::mutex>
必要時可自訂推導指引(deduction guide)(見 §5.2 的 overloaded)。注意 CTAD 是「全有或全無」——不能只指定部分樣板參數。
11. 屬性(Attributes)
C++17 標準化了三個實用屬性,用來向編譯器表達意圖、換取更好的警告。
11.1 [[nodiscard]] — 回傳值不可忽略
[[nodiscard]] int computeResult() { return 42; }
computeResult(); // ⚠️ 編譯器警告:忽略了 [[nodiscard]] 回傳值
適合用在「忽略回傳值幾乎一定是 bug」的函式:錯誤碼、empty()(很多人誤以為是 clear)、工廠函式、std::async 的 future 等。也能標在型別上:struct [[nodiscard]] Error {...};。
11.2 [[maybe_unused]] — 抑制未使用警告
[[maybe_unused]] int debugLevel = 3; // 不會觸發未使用警告
void f([[maybe_unused]] int reserved, int x); // 預留參數
常用於:只在 assert/除錯組建用到的變數、預留但尚未使用的參數。
11.3 [[fallthrough]] — 明示 switch 貫穿
switch (n) {
case 1:
doSomething();
[[fallthrough]]; // 明確表示「故意」貫穿到下一個 case
case 2:
doMore();
break;
}
開啟 -Wimplicit-fallthrough 時,沒有 break 又沒標 [[fallthrough]] 的貫穿會被警告(因為那常是忘了 break 的 bug)。[[fallthrough]] 告訴編譯器與讀者「我是故意的」。
12. C++17 巢狀命名空間與 inline 變數
12.1 巢狀命名空間語法
// C++17 之前
namespace A { namespace B { namespace C { void f(); }}}
// C++17
namespace A::B::C { void f(); }
(完整命名空間主題見 Ch19。)
12.2 inline 變數(簡介)
C++17 允許 inline 變數,讓「在標頭定義全域/靜態變數」不再違反 ODR——多個 TU 引入同一份定義時,連結器合而為一:
// config.h
struct Config {
static inline int version = 17; // ✅ 可直接在標頭內定義初始化
};
inline int g_globalCounter = 0; // ✅ 標頭內定義全域變數不再連結錯誤
這在「純標頭函式庫(header-only library)」中特別有用。
重點整理
| 特性 | 用途 | 標頭檔 |
|---|---|---|
| 結構化綁定 | 解構 pair/tuple/struct/陣列 | — |
| if/switch 初始化 | 限制變數作用域於分支內 | — |
if constexpr |
編譯期分支,丟棄不成立分支 | <type_traits> |
std::optional<T> |
「有值或無值」 | <optional> |
std::variant<Ts...> |
型別安全的聯合體 | <variant> |
std::any |
存放任意型別 | <any> |
std::string_view |
零拷貝唯讀字串檢視 | <string_view> |
std::filesystem |
跨平台檔案系統操作 | <filesystem> |
| 折疊表達式 | 簡化可變參數展開 | — |
| CTAD | 省略類別樣板參數 | — |
[[nodiscard]] |
回傳值不可忽略 | — |
[[maybe_unused]] |
抑制未使用警告 | — |
[[fallthrough]] |
明示 switch 貫穿 | — |
巢狀命名空間 A::B::C |
簡化巢狀宣告 | — |
| inline 變數 | 標頭內定義變數不違反 ODR | — |
優先順序口訣:optional > variant > any(能用前者就不用後者)。
常見錯誤與陷阱
- 結構化綁定誤拷貝:
for (auto [k, v] : bigMap)會整份拷貝每個元素;只讀請用for (const auto& [k, v] : bigMap)。 - 對空 optional 呼叫
value():丟std::bad_optional_access;用*opt取值更危險(無值時為 UB)。先檢查或用value_or。 std::get型別/索引不符:對 variant 取錯型別會丟std::bad_variant_access;不確定時用get_if(回傳nullptr)。std::any_cast型別不符:值版本丟std::bad_any_cast;指標版本回傳nullptr。string_view懸垂:底層字串(尤其暫存std::string、區域變數)被銷毀後,view 變懸垂參考。別存成類別成員、別回傳指向區域字串的 view。string_view當 C 字串用:substr/remove_prefix後不保證 null 結尾,不可直接把data()餵給printf("%s")或 C API;先轉std::string。if constexpr條件非編譯期常數:條件必須在編譯期可求值(如 type traits);用執行期變數會編譯失敗。- 誤用普通
if取代if constexpr:普通if要求所有分支都能編譯,樣板中走不到但不合法的分支仍會報錯。 - filesystem 未處理例外:多數操作失敗會丟
fs::filesystem_error;不想用例外時改用error_code多載。 - filesystem 路徑可攜性:別手動拼
"a/b"或"a\\b",用fs::path的/運算子。 [[nodiscard]]不會阻止編譯:它只產生警告;要當錯誤需搭配-Werror。- CTAD 不能部分指定:要嘛全推導(
std::pair p(1, 2.5)),要嘛全寫出;不能只給一半樣板參數。
練習題
練習 1:結構化綁定回傳多值(基礎)
寫一個函式 std::tuple<bool, std::string, int> processRequest(int id),回傳「是否成功、訊息、錯誤碼」。在 main() 用結構化綁定接收並印出。再用 const auto& [ok, msg, code] 解構一個 struct Result,比較兩者寫法。
- 提示:
return {true, "OK", 0};可直接建構 tuple;解構名稱按宣告順序對應,與成員名無關。
練習 2:std::optional 安全除法(基礎)
實作 std::optional<double> safeDivide(double a, double b),除數為零時回傳 std::nullopt。在 main() 分別用 has_value()/*、以及 value_or(0.0) 兩種方式處理結果,並故意示範(用註解標明)為什麼不該對空 optional 呼叫 value()。
- 提示:先
if (auto r = safeDivide(...))檢查再取值;value_or最安全。
練習 3:if constexpr 通用 toString(中級)
寫樣板函式 std::string toString(const T& value),用 if constexpr 對整數、浮點數、std::string、const char*、bool 分別採用不同轉換方式(其餘型別回傳 "unknown")。用至少 5 種不同型別呼叫驗證。
- 提示:善用
<type_traits>的std::is_integral_v、std::is_floating_point_v、std::is_same_v;用std::decay_t<T>去除參考/const 再比較。
練習 4:std::variant + visit 計算器(中級)
定義 using CalcResult = std::variant<double, std::string>;,寫 CalcResult calculate(double a, char op, double b):正常運算回傳 double,除以零或未知運算子回傳錯誤字串。用 std::visit(搭配 if constexpr 或 overloaded 模式)印出每筆運算結果。至少測試 + - * / 與除以零。
- 提示:overloaded 模式需要
template<class...Ts> struct overloaded:Ts...{using Ts::operator()...;};與對應推導指引。
練習 5:std::filesystem 列出 .cpp 檔(中級)
寫程式接受一個目錄路徑(預設為當前目錄),列出其中所有 .cpp 檔的檔名與大小(bytes)。用 directory_iterator 巡覽,以 path.extension() == ".cpp" 過濾,並用 error_code 多載處理「目錄不存在」的情況而不讓程式崩潰。
- 提示:
fs::file_size(p)取大小;過濾時記得只對is_regular_file的項目取大小。
練習 6:string_view 字串工具(中級)
實作三個以 std::string_view 為參數的函式:bool startsWith(sv, prefix)、bool endsWith(sv, suffix)、size_t countChar(sv, ch)。驗證同一組函式能同時接受 const char*、std::string、std::string_view。在註解中說明一個會造成懸垂 view 的錯誤寫法。
- 提示:
sv.substr(0, n)取前綴、sv.substr(sv.size()-n)取後綴;別回傳指向區域std::string的 view。
練習 7:折疊表達式 + CTAD(挑戰)
寫可變參數樣板 auto average(Args... args) 用折疊表達式計算平均值(提示:總和用折疊、個數用 sizeof...(args))。另寫 bool allEqual(Args... args) 用折疊判斷是否全部相等。最後示範用 CTAD 建立 std::tuple 與 std::pair 並用結構化綁定解構,串起本章多個特性。
- 提示:
(... + args)求總和;sizeof...(Args)取參數個數;平均要轉成浮點數避免整數除法;allEqual可用(... && (args == first))之類的折疊(先取第一個值)。
對應程式碼檔案
| 檔案 | 內容 |
|---|---|
| structured_bindings.cpp | 結構化綁定完整範例(pair/tuple/struct/map/陣列/拷貝 vs 參考) |
| optional_variant_any.cpp | optional / variant / any 完整範例(含 visit、get_if、配置系統) |
| if_init_statements.cpp | if/switch 初始化、if constexpr、屬性、折疊表達式、string_view |
| filesystem.cpp | std::filesystem 完整範例(path/巡覽/建立/複製/error_code) |