學習目標
讀完本章,你將能夠:
- 理解
auto的三種推導情境(by value、auto&、auto&&)及其與const、參考的交互 - 看穿
auto與大括號初始化(std::initializer_list)的陷阱 - 掌握
decltype的推導規則,並區分decltype(name)與decltype(expression)的關鍵差異 - 學會使用
decltype(auto)進行回傳型別的完美轉發 - 理解尾端回傳型別(trailing return type)的語法與應用場景
- 連結
auto推導與模板型別推導的共通規則 - 掌握結構化繫結(structured bindings)的型別推導
- 在「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 引入 auto 與 decltype,讓編譯器從初始化表達式或運算式中推導型別:
// 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 最有效的方式,是把它對應到模板推導的三種「參數形式」。給定 auto、auto&、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 有兩套規則,差別在於括號內是「單純的名字」還是「運算式」:
decltype(name):若括號內是未加額外括號的識別字(或成員存取),結果就是該實體宣告時的型別。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,當回傳型別取決於參數的運算結果時,傳統語法寫不出來——因為在寫回傳型別的位置時,參數 a、b 還沒被宣告。尾端回傳型別把回傳型別「移到參數之後」,於是可以引用參數:
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)
結構化繫結讓你一次把 pair、tuple、陣列或結構體的成員「拆解」成具名變數:
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::string;auto 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避免型別名稱的多餘重複("Useautoto 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會隱藏關鍵型別資訊(特別是代理型別、數值精度)時,明確寫出型別。
十、常見錯誤與陷阱(擴充)
-
auto丟棄參考,造成意外的副本cpp int x = 0; int& r = x; auto a = r; // int(副本!)修改 a 不會動到 x a = 5; // x 仍是 0想保留參考要用auto&。 -
decltype((x))是參考:多一層括號把「名字」變「運算式」,結果從int變int&。 -
auto與std::vector<bool>的代理型別cpp std::vector<bool> flags = {true, false}; auto f = flags[0]; // 不是 bool!是 std::vector<bool>::reference 代理物件 bool g = flags[0]; // 正確:強制求值為 bool代理物件可能在底層位元被改動後變成懸空,行為難以預期。遇到回傳代理型別的 API,請明確寫出目標型別。 -
decltype(auto)回傳區域變數的參考cpp decltype(auto) bad() { int x = 0; return (x); } // 回傳 int& → 懸空參考! -
auto與大括號:auto x = {1, 2, 3};推導為std::initializer_list<int>,而非陣列或vector。 -
auto s = "hello";是const char*:不是std::string。需要字串請寫std::string s = "hello";或使用auto s = "hello"s;(<string>的字面值運算子)。 -
頂層 vs 底層 const 混淆:
auto只丟「頂層」const。const int* p複製成auto q得到const int*(底層 const 保留),但int* const p複製後變成int*(頂層 const 被丟)。 -
以為
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.cpp的type_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_assert 或 type_name 驗證 decltype((x)) 確實是 int&。
提示:回顧 decltype(name) 與 decltype(expression) 兩套規則;具名變數加括號後是左值運算式。
練習 3(中級):尾端回傳型別的泛型 multiply
使用尾端回傳型別實作泛型函式模板 multiply,使其能正確推導兩個不同型別相乘的回傳型別。驗證 multiply(3, 4.5) 得到 double、multiply(2, 3) 得到 int。
提示:回傳型別用 -> decltype(a * b);在參數之後才能引用 a、b。
練習 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)。觀察值類別如何在轉發中被保留。