學習目標

讀完本章,你將能夠:

  1. 理解 auto 的三種推導情境(by value、auto&auto&&)及其與 const、參考的交互
  2. 看穿 auto 與大括號初始化(std::initializer_list)的陷阱
  3. 掌握 decltype 的推導規則,並區分 decltype(name)decltype(expression) 的關鍵差異
  4. 學會使用 decltype(auto) 進行回傳型別的完美轉發
  5. 理解尾端回傳型別(trailing return type)的語法與應用場景
  6. 連結 auto 推導與模板型別推導的共通規則
  7. 掌握結構化繫結(structured bindings)的型別推導
  8. 在「Almost Always Auto(AAA)」的爭論中做出有依據的取捨

一、為什麼需要型別推導?

在 C++98 時代,每個變數都必須寫出完整型別。當泛型程式與標準函式庫日益複雜,型別名稱也變得又臭又長:

// C++98:宣告一個迭代器
std::map<std::string, std::vector<int>>::const_iterator it = myMap.begin();

這行程式碼有幾個問題:

  • 冗長:型別名稱比實際邏輯還長,掩蓋了「我只是想要一個迭代器」的意圖。
  • 重複begin() 的回傳型別其實已經決定一切,我們卻得手動再寫一次。
  • 易錯:若把 const_iterator 寫成 iterator,或型別不完全相符,會觸發隱式轉換,產生臨時物件,甚至效能問題。
  • 難維護:一旦 myMap 的型別改變,這行宣告也得跟著改。

C++11 引入 autodecltype,讓編譯器從初始化表達式或運算式中推導型別:

// C++11 起
auto it = myMap.begin();   // 編譯器自動推導出正確的迭代器型別

核心心法:型別推導不是「讓編譯器猜」,而是把「型別早已被表達式決定」這件事說清楚,避免人為重複與錯誤。理解推導規則,你才能預測 auto 究竟會給你什麼型別。

C++ 中有三大型別推導機制,它們共用同一套底層規則:

機制 引入版本 用途
模板型別推導 C++98 函式模板參數 T 的推導
auto C++11 變數、回傳值、lambda 參數
decltype C++11 取得運算式「宣告型別」

二、auto 型別推導

基本規則

auto 的推導規則與模板參數推導幾乎相同(唯一例外是大括號初始化,稍後說明)。編譯器根據初始化表達式推斷變數型別。

auto x = 42;                       // int
auto d = 3.14;                     // double
auto s = "hello";                  // const char*(注意:不是 std::string!)
auto str = std::string("hello");   // std::string

預期輸出(搭配對應程式 auto_decltype.cpp 的型別印出):

auto i = 42;            -> int
auto d = 3.14;          -> double
auto s = "hello";       -> char const*
auto str = string(...); -> std::string

auto 推導的三種情境

理解 auto 最有效的方式,是把它對應到模板推導的三種「參數形式」。給定 autoauto&auto&&,推導行為完全不同:

┌─────────────────────────────────────────────────────────────┐
│  情境一:auto x = expr;     (by value,依值)                │
│     → 丟棄參考(reference)                                   │
│     → 丟棄頂層 const / volatile                              │
│                                                              │
│  情境二:auto& x = expr;    (依參考)                        │
│     → 保留 const(底層與頂層都保留)                          │
│     → 不會丟棄參考性質                                        │
│                                                              │
│  情境三:auto&& x = expr;   (轉發參考 / forwarding ref)     │
│     → 左值 → 左值參考(T&)                                  │
│     → 右值 → 右值參考(T&&)                                 │
└─────────────────────────────────────────────────────────────┘

情境一:auto(依值)會丟棄參考與頂層 const

重要規則:auto(無 &)會忽略頂層 const(top-level const)與參考(reference)。

因為你要的是一份「副本」,副本本來就可以自由修改,所以來源是否為 const、是否為參考都無關緊要。

const int ci = 42;
auto a = ci;          // int(忽略頂層 const,a 是可修改的副本)
a = 100;              // OK

int x = 42;
int& rx = x;
auto b = rx;          // int(忽略參考,b 是 x 的副本,與 x 無關)
b = 100;              // 不影響 x

const int& crx = x;
auto c = crx;         // int(同時丟棄 const 與 &)

但要小心:丟棄的只是頂層 const底層 const(例如指標所指向之物的 const)會被保留:

const int* p = &ci;
auto q = p;           // const int*(指標本身是頂層、被複製;
                      // 但「指向 const int」這個底層 const 保留)

情境二:auto& 保留參考與 const

重要規則:auto& 保留參考語意,且不會丟棄 const。

int x = 42;
int& rx = x;
auto& b = rx;         // int&(b 參考到 x)
b = 100;              // 會改變 x!

const int ci = 42;
auto& d = ci;         // const int&(const 必須保留,否則就能透過 d 改 const 物件了)
// d = 100;           // 編譯錯誤

情境三:auto&& 是轉發參考(forwarding reference)

auto&& 並非「右值參考」,而是轉發參考(也稱萬能參考,universal reference)。它依初始化器的「值類別」(value category)決定最終型別,背後是參考摺疊(reference collapsing)規則:

int x = 42;
const int cx = 100;

auto&& a = x;            // int&        (x 是左值 → 左值參考)
auto&& b = 42;           // int&&       (42 是右值 → 右值參考)
auto&& c = cx;           // const int&  (cx 是 const 左值)
auto&& d = std::move(x); // int&&       (std::move 產生右值)

參考摺疊規則(編譯器內部):

推導出的形式 摺疊結果
T& & T&
T& && T&
T&& & T&
T&& && T&&

口訣:只要有一個是 &,結果就是 &;只有「全是 &&」才是 &&

auto&& 最常見於 range-based for 迴圈,能正確處理任何容器(包含回傳代理物件的容器,如 std::vector<bool>)而不產生不必要的複製:

std::vector<std::string> words = {"hello", "world"};
for (auto&& w : words) {     // w 是 std::string&
    w += "!";                // 可修改原始元素
}

auto 與大括號初始化:最重要的例外

這是 auto 與模板推導唯一不同之處,也是極易踩的雷:

auto a = 42;            // int
auto b = {42};         // std::initializer_list<int>  ← 注意!
auto c = {1, 2, 3};    // std::initializer_list<int>
auto d{42};            // int(C++17 起;C++11/14 為 initializer_list<int>)
// auto e = {1, 2.0};  // 編譯錯誤:元素型別不一致,無法推導

對比模板推導:模板對 {...} 完全無法推導

template <typename T> void f(T param);
// f({1, 2, 3});       // 編譯錯誤:模板無法從大括號推導 T

template <typename T> void g(std::initializer_list<T> param);
g({1, 2, 3});          // OK:T 推導為 int

建議:除非你真的要建立 initializer_list,否則初始化時優先使用 = 搭配明確值(auto x = 42;),避免大括號帶來的意外。C++ Core Guidelines ES.23 也提醒:偏好 {} 初始化以避免窄化,但 auto x = {...} 是個著名例外。

auto 與迭代器、lambda

std::vector<int> vec = {1, 2, 3};
auto it = vec.begin();             // std::vector<int>::iterator
auto cit = vec.cbegin();           // std::vector<int>::const_iterator

auto add = [](int a, int b) { return a + b; };
// lambda 的型別是編譯器產生的匿名閉包型別,「只能」用 auto 接收

三、模板型別推導與 auto 的對應

理解 auto 最快的捷徑:把 auto 想成模板參數 T,把變數宣告的形式想成函式參數的形式。

template <typename T> void f(T param);     // 對應 auto x
template <typename T> void f(T& param);    // 對應 auto& x
template <typename T> void f(T&& param);   // 對應 auto&& x
宣告 對應模板簽名 推導行為
auto x = expr; f(T param) 丟棄參考與頂層 const
auto& x = expr; f(T& param) 保留 const,維持參考
auto&& x = expr; f(T&& param) 轉發參考,依值類別

唯一差異前面已提過:auto{...} 推導為 std::initializer_list,模板則直接拒絕。掌握這個對應關係,你就同時學會了兩套推導。


四、decltype 型別推導

基本規則

decltype 取得運算式的「宣告型別」(declared type),完整保留所有修飾(含 const 與參考)。它不像 auto 會丟東西。

int x = 42;
const int cx = x;
const int& rcx = x;

decltype(x)    a;     // int
decltype(cx)   b;     // const int
decltype(rcx)  c = x; // const int&

關鍵:decltype(name) vs decltype(expression),括號有差!

decltype 有兩套規則,差別在於括號內是「單純的名字」還是「運算式」:

  1. decltype(name):若括號內是未加額外括號的識別字(或成員存取),結果就是該實體宣告時的型別
  2. decltype(expression):若括號內是其他運算式,則依運算式的值類別: - 運算式為左值(lvalue)→ 結果為 T& - 運算式為將亡值(xvalue)→ 結果為 T&& - 運算式為純右值(prvalue)→ 結果為 T

(name)(多包一層括號)會被視為運算式,且具名變數是左值,因此:

int x = 42;
decltype(x)    a;       // int   ← name 規則:x 的宣告型別
decltype((x))  b = a;   // int&  ← expression 規則:(x) 是左值 → int&

這是 C++ 中最經典的陷阱之一decltype(x)decltype((x)) 結果不同!多一層括號就從「值」變成「參考」。

更多運算式範例:

int a = 1, b = 2;
decltype(a + b)   r1;       // int   (加法產生純右值)
decltype(a += b)  r2 = a;   // int&  (複合賦值回傳左值參考)
bool cond = true;
decltype(cond ? a : b) r3 = a; // int& (兩個左值的條件運算式是左值)

decltype 與函式回傳值

int  foo();
int& bar();

decltype(foo()) a;       // int   (foo() 回傳純右值)
decltype(bar()) b = a;   // int&  (bar() 回傳左值參考)

五、decltype(auto)

decltype(auto)(C++14)結合了 auto 的便利與 decltype 的精確:它讓你用 auto 的語法佔位,但採用 decltype 的規則來推導,而非 auto 的「丟棄修飾」規則。

int x = 42;
auto           a1 = x;     // int   (auto 規則)
decltype(auto) a2 = x;     // int   (decltype(name) 規則)
decltype(auto) a3 = (x);   // int&  (decltype(expression) 規則!加括號就成參考)

主要用途:函式回傳型別的完美轉發

考慮一個泛型存取函式,它要回傳容器 operator[] 的結果。vector<int>::operator[] 回傳 int&,我們希望呼叫端能透過回傳值修改容器:

// 用 auto:回傳值會丟棄參考 → 變成副本,無法修改原容器!
template <typename Container>
auto access_bad(Container& c, std::size_t i) {
    return c[i];           // 回傳 int(副本)
}

// 用 decltype(auto):忠實保留 operator[] 的回傳型別(int&)
template <typename Container>
decltype(auto) access_good(Container& c, std::size_t i) {
    return c[i];           // 回傳 int&
}

std::vector<int> v = {10, 20, 30};
access_good(v, 0) = 999;    // OK:v[0] 變成 999
// access_bad(v, 0) = 999;  // 編譯錯誤:對右值(副本)賦值

預期效果:

access_good 修改後:v[0] = 999
access_bad  無法修改(回傳的是副本)

陷阱decltype(auto) 加上「多餘的括號」會回傳懸空參考! cpp decltype(auto) f() { int local = 0; return (local); // 回傳 int&,參考到已銷毀的區域變數 → 未定義行為! } 應寫成 return local;(回傳 int)。


六、尾端回傳型別(Trailing Return Type)

語法

auto func(int a, int b) -> int {
    return a + b;
}

等價於 int func(int a, int b)auto 在此只是佔位,真正的回傳型別寫在 -> 之後。

為什麼需要它?回傳型別依賴參數時

在 C++11,當回傳型別取決於參數的運算結果時,傳統語法寫不出來——因為在寫回傳型別的位置時,參數 ab 還沒被宣告。尾端回傳型別把回傳型別「移到參數之後」,於是可以引用參數:

template <typename T, typename U>
auto add(T a, U b) -> decltype(a + b) {   // 此處 a、b 已可見
    return a + b;
}

auto r1 = add(1, 2.5);   // double(int + double)
auto r2 = add(1L, 2);    // long

C++14 起可直接 auto add(T a, U b) { return a + b; } 讓編譯器推導,但尾端回傳型別在需要明確指定(例如使用 decltype 精確控制參考性質)時仍不可取代。

讓複雜回傳型別更易讀

// 不使用尾端回傳型別:型別在最前面,讀者得先消化一長串才看到函式名
std::map<std::string, std::vector<int>>::iterator
findInMap(std::map<std::string, std::vector<int>>& m, const std::string& key);

// 使用尾端回傳型別:先看到函式名與參數,回傳型別放在後面
auto findInMap(std::map<std::string, std::vector<int>>& m, const std::string& key)
    -> std::map<std::string, std::vector<int>>::iterator;

成員函式的鏈式呼叫

尾端回傳型別也讓 Calculator& add(...) 這類鏈式介面的意圖更清晰:

class Calculator {
    double result_ = 0.0;
public:
    auto add(double v) -> Calculator& { result_ += v; return *this; }
    auto get() const -> double { return result_; }
};

Calculator c;
double r = c.add(10).add(5).get();   // 15

七、結構化繫結的型別推導(C++17)

結構化繫結讓你一次把 pairtuple、陣列或結構體的成員「拆解」成具名變數:

std::pair<int, double> getPair() { return {42, 3.14}; }

auto [x, y] = getPair();          // x: int, y: double(副本)
auto& [rx, ry] = somePair;        // rx, ry 為參考,可修改 somePair
const auto& [cx, cy] = somePair;  // 唯讀參考,零複製

最實用的場景是遍歷 std::map

std::map<std::string, int> scores = {{"Alice", 95}, {"Bob", 87}};
for (const auto& [name, score] : scores) {
    std::cout << name << ": " << score << "\n";
}

推導細節auto/auto&/const auto& 套用在「整個被拆解的物件」上,而非個別成員。各繫結名稱的型別由各成員型別決定。注意結構化繫結的名稱並非真正的參考變數(對 tuple 而言它們是別名),但用起來如同變數。

預期輸出:

Alice: 95
Bob: 87

八、何時使用 auto?AAA 爭論(Almost Always Auto)

由 Herb Sutter 提倡的 AAA(Almost Always Auto) 主張:幾乎總是使用 auto。但社群對此有正反兩面,了解雙方論點才能做出合理取捨。

支持 AAA 的論點

  • 強制初始化auto x; 無法編譯,逼你初始化變數,杜絕未初始化變數的 bug。
  • 避免隱式窄化與意外轉換cpp std::vector<int> v; int s1 = v.size(); // 隱式轉換 size_t → int,可能警告或溢位 auto s2 = v.size(); // 正確得到 size_t
  • 抗重構:底層型別改變時,auto 變數自動跟著變,不需逐行修改。
  • 可讀性(型別冗長時)auto it = m.begin(); 遠勝完整迭代器型別。
  • 必要時的唯一選擇:lambda、某些泛型情境只能用 auto

反對/謹慎使用的論點

  • 可讀性(型別不明顯時)auto w = create(); 讀者看不出 w 是什麼,需跳到 create 的定義。
  • 代理型別陷阱auto b = vec<bool>[i]; 得到的是代理物件,不是 bool(見下節)。
  • 意外的型別auto s = "hi";const char* 而非 std::stringauto x = expr / 2; 的型別可能出乎意料。
  • 介面契約:公開 API 的回傳值有時刻意寫明型別,以表達設計意圖。

務實準則

建議使用 auto 建議寫明型別
型別明顯:auto p = std::make_unique<Widget>(); 型別不明顯:Widget w = factory();
型別冗長:auto it = myMap.begin(); 需要特定型別:double d = 42;(而非 int)
Lambda:auto fn = [](int x){ return x*2; }; 避開代理型別:bool b = flags[5];
避免隱式轉換:auto sz = v.size(); 想強調介面契約:int count = ...;

小結:AAA 不是教條。原則是「讓型別由表達式決定、避免重複與意外轉換」;但當寫明型別能提升可讀性或表達意圖時,就大方寫出來。


九、最佳實踐(對照 C++ Core Guidelines)

  • ES.11:使用 auto 避免型別名稱的多餘重複("Use auto to avoid redundant repetition of type names")。
  • ES.20:永遠初始化物件。auto 天生強制初始化,正好契合此原則。
  • Per.19 / ES.23:偏好以 {} 初始化避免窄化——但記得 auto x = {...} 會變成 initializer_list,需用 auto x = value; 或明確型別。
  • F.43 / ES.65:絕不回傳或保留指向區域變數的參考。使用 decltype(auto) 回傳時,務必避免 return (local); 這種會回傳懸空參考的寫法。
  • 泛型轉發:在泛型程式中以 auto&& 接收、以 decltype(auto) 回傳,才能忠實保留呼叫端的值類別與參考性質。
  • 可讀性優先:當 auto 會隱藏關鍵型別資訊(特別是代理型別、數值精度)時,明確寫出型別。

十、常見錯誤與陷阱(擴充)

  1. auto 丟棄參考,造成意外的副本 cpp int x = 0; int& r = x; auto a = r; // int(副本!)修改 a 不會動到 x a = 5; // x 仍是 0 想保留參考要用 auto&

  2. decltype((x)) 是參考:多一層括號把「名字」變「運算式」,結果從 intint&

  3. autostd::vector<bool> 的代理型別 cpp std::vector<bool> flags = {true, false}; auto f = flags[0]; // 不是 bool!是 std::vector<bool>::reference 代理物件 bool g = flags[0]; // 正確:強制求值為 bool 代理物件可能在底層位元被改動後變成懸空,行為難以預期。遇到回傳代理型別的 API,請明確寫出目標型別。

  4. decltype(auto) 回傳區域變數的參考 cpp decltype(auto) bad() { int x = 0; return (x); } // 回傳 int& → 懸空參考!

  5. auto 與大括號auto x = {1, 2, 3}; 推導為 std::initializer_list<int>,而非陣列或 vector

  6. auto s = "hello";const char*:不是 std::string。需要字串請寫 std::string s = "hello"; 或使用 auto s = "hello"s;<string> 的字面值運算子)。

  7. 頂層 vs 底層 const 混淆auto 只丟「頂層」const。const int* p 複製成 auto q 得到 const int*(底層 const 保留),但 int* const p 複製後變成 int*(頂層 const 被丟)。

  8. 以為 auto&& 是右值參考:它是轉發參考,能綁左值也能綁右值,型別依初始化器決定。


重點整理

特性 丟棄頂層 const 丟棄參考 {...} 備註
auto initializer_list 與模板依值推導相同
auto& 保留 N/A 明確要求參考、保留 const
auto&& 轉發 N/A 轉發參考,依值類別
decltype(name) 取宣告型別,完整保留
decltype(expr) 左值→T& (x) 視為運算式
decltype(auto) 依 decltype 依 decltype 佔位 + decltype 規則
模板 T 拒絕推導 auto 的孿生兄弟

關鍵差異: 1. auto 像模板依值推導:丟棄頂層 const 與參考。 2. decltype 忠實保留所有型別修飾。 3. decltype 對「加括號的運算式」會推導為參考。 4. decltype(auto) 結合兩者:auto 的便利 + decltype 的精確,用於回傳型別轉發。


練習題

難度標示:基礎 / 中級 / 挑戰。建議先自行推導答案,再用 auto_decltype.cpptype_name<decltype(...)>() 驗證。

練習 1(基礎):寫出 auto 推導型別

寫出以下每行 auto 變數推導出的型別,並說明理由:

const int x = 10;
int y = 20;
int& ry = y;

auto a = x;     // ?
auto& b = x;    // ?
auto&& c = x;   // ?
auto&& d = 42;  // ?
auto e = ry;    // ?
auto f = {1, 2, 3}; // ?

提示:依值的 auto 丟棄頂層 const 與參考;auto& 保留 const;auto&& 看初始化器是左值還右值;大括號是特例。


練習 2(基礎):decltype 括號之謎

解釋 decltype(x)decltype((x)) 為何結果不同,並寫一段程式用 static_asserttype_name 驗證 decltype((x)) 確實是 int&

提示:回顧 decltype(name)decltype(expression) 兩套規則;具名變數加括號後是左值運算式。


練習 3(中級):尾端回傳型別的泛型 multiply

使用尾端回傳型別實作泛型函式模板 multiply,使其能正確推導兩個不同型別相乘的回傳型別。驗證 multiply(3, 4.5) 得到 doublemultiply(2, 3) 得到 int

提示:回傳型別用 -> decltype(a * b);在參數之後才能引用 ab


練習 4(中級):const-correct 的容器存取

寫一個函式模板 at(Container& c, std::size_t i),用 decltype(auto) 回傳元素,使呼叫端能透過回傳值修改 std::vector<int> 的元素,但對 const std::vector<int> 則回傳唯讀參考。用程式驗證可寫與唯讀兩種情況。

提示decltype(auto) 會保留 operator[] 的回傳型別(T&const T&);提供 Container&const Container& 兩個多載,或讓單一模板自然依 c 的 const 性質運作。


練習 5(中級):結構化繫結遍歷

給定 std::map<std::string, std::vector<int>>,使用結構化繫結與 range-based for 印出每個鍵以及對應 vector 的元素個數。請分別示範 auto [k, v](副本)與 const auto& [k, v](零複製)兩種寫法,並說明效能差異。

提示:map 的元素是 std::pair<const Key, Value>;副本版本會複製整個 vector,參考版本不會。


練習 6(挑戰):手寫 forward 與完美轉發

不使用 <utility>std::forward,自行實作一個 my_forward,並寫一個工廠函式 make_widget,以 auto&& 接收參數、用 my_forward 轉發給 Widget 的建構子。驗證左值與右值分別觸發複製建構與移動建構。

提示:轉發參考 auto&& / T&& 搭配參考摺疊;my_forward<T> 的本質是 static_cast<T&&>(arg)。觀察值類別如何在轉發中被保留。


對應程式碼