學習目標

  • 掌握結構化綁定(structured bindings):從 pair/tuple/struct/array 解構,理解拷貝 vs 參考的差異
  • 學會 if/switch 帶初始化語句,把變數作用域限制在分支內
  • 理解 if constexpr 的編譯期分支,並用於樣板程式設計
  • 熟練 std::optionalhas_value/value/value_orbad_optional_access 陷阱)
  • 熟練 std::variantholds_alternative/get/get_if/visit 與 overloaded 模式)
  • 認識 std::anyany_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結構化綁定
  • 「可能沒有值」不必再用魔術數字 -1nullptr 表示 → 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::pairstd::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 ... 2Alice 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->firstit->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=intx.size() 仍會被編譯而報錯。if constexpr 因此取代了許多 SFINAE / tag dispatch 的場景,讓樣板程式碼直觀許多。


4. std::optional

std::optional<T> 表示「可能有值,也可能沒有值」,取代過去用 -1nullptr、特殊旗標表示「無效/查無」的脆弱做法。

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::anystd::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(能用前者就不用後者)。


常見錯誤與陷阱

  1. 結構化綁定誤拷貝for (auto [k, v] : bigMap) 會整份拷貝每個元素;只讀請用 for (const auto& [k, v] : bigMap)
  2. 對空 optional 呼叫 value():丟 std::bad_optional_access;用 *opt 取值更危險(無值時為 UB)。先檢查或用 value_or
  3. std::get 型別/索引不符:對 variant 取錯型別會丟 std::bad_variant_access;不確定時用 get_if(回傳 nullptr)。
  4. std::any_cast 型別不符:值版本丟 std::bad_any_cast;指標版本回傳 nullptr
  5. string_view 懸垂:底層字串(尤其暫存 std::string、區域變數)被銷毀後,view 變懸垂參考。別存成類別成員、別回傳指向區域字串的 view。
  6. string_view 當 C 字串用substr/remove_prefix 後不保證 null 結尾,不可直接把 data() 餵給 printf("%s") 或 C API;先轉 std::string
  7. if constexpr 條件非編譯期常數:條件必須在編譯期可求值(如 type traits);用執行期變數會編譯失敗。
  8. 誤用普通 if 取代 if constexpr:普通 if 要求所有分支都能編譯,樣板中走不到但不合法的分支仍會報錯。
  9. filesystem 未處理例外:多數操作失敗會丟 fs::filesystem_error;不想用例外時改用 error_code 多載。
  10. filesystem 路徑可攜性:別手動拼 "a/b""a\\b",用 fs::path/ 運算子。
  11. [[nodiscard]] 不會阻止編譯:它只產生警告;要當錯誤需搭配 -Werror
  12. 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::stringconst char*bool 分別採用不同轉換方式(其餘型別回傳 "unknown")。用至少 5 種不同型別呼叫驗證。

  • 提示:善用 <type_traits>std::is_integral_vstd::is_floating_point_vstd::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::stringstd::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::tuplestd::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)