C++ template 為什麼不能推導返回值類型?

例如:

template&
T value()
{
// for expample
T* ptr = static_cast&(_ptr);
return *ptr;
}

希望能有透徹一點的解釋。如果有什麼解決方案(如C++11和boost的一些高級用法),也希望能一併回答出來。

謝謝。


討論之前先說一下,結合上面的補充說明來看,這個問法其實有一點點瑕疵,這也導致了大家在回答時的一些誤會。題主這麼問,是因為 C++ 確實提供了函數模板的參數類型推導(通過調用方提供的信息,自動推斷並填充到模板參數,從而避免用戶手動指明模板參數)。

這樣看,這個問題的正確提法應該是這樣的:

「C++ template 為什麼不能像典型的參數類型推導那樣,通過判斷調用方提供的返回值類型,將其自動填充到模板參數,從而避免用戶手動指明模板參數?」

-------------------------------------

看了一下現有的回答,發現大家在 「類型推導」 (Type Deduction) 上,其實沒有在說同一件事,所以我覺得有必要先澄清一下這個概念。

@Milo Yip 同學在 @黃柏炎 同學答案的評論中提到:「並不是從調用方推導。C++14可以靠return的類型推導。」

那麼 "C++14 返回值類型推導" 和題主問到的 "函數模板參數類型推導" 是一碼事嗎?

答案是否定的。我這裡詳細說明一下。

題主的例子中,所謂 "推導" 指的是編譯器在某些情況下,可以根據調用方提供的信息來補全用戶未提供的模板參數,是模板實例化 (template instantiation) 的一個步驟,發生的時機是在函數模版的調用時(invoke time of function template)。也就是說,當需要的時候,每次模版函數的調用,均會 (根據調用方提供的信息) 觸發一次潛在的模板參數類型推導。

而 @空明流轉 , @vczh@Milo Yip 等同學在答案或評論中提到的 "C++14 返回值類型推導",則分為普通函數和模板函數兩種情況:

1. 當為普通函數時,返回值類型推導是函數體的一部分,發生在函數定義 (function definition) 時。舉個栗子,形如 auto foo(int typedArg) { return typedArg; } 的函數,在定義時已可完全確認返回值類型為 int 了。

2. 當為模板函數時,返回值類型推導仍為函數體的一部分,但需根據其"是否依賴模板參數類型"來決定發生於定義時還是實例化時。當返回值類型依賴模板參數類型時,情形正如 @vczh 同學舉的例子;當返回值類型不依賴模板參數類型時,則退化為 1. 中的普通函數調用情況。

請注意,對於 2. 中提到的 @vczh 同學舉的例子,調用方仍需提供模板參數類型,無論是藉助編譯期推導還是手工填充。

總得來說,"C++14 返回值類型推導" 是一個正向過程,只是語法上的一種簡化 (syntax simplification),而語義上與原來的函數完全一致。而題主問到的 "函數模板參數類型推導" 是一個反向過程,在語義上,返回值的類型"接受推導"和"不接受推導"會導致截然不同的函數特化和調用。

-------------------------------------

好了,辨別清楚了概念,現在我們來正面回答這個題目:

為了儘可能與 C 保持語法和語義上的兼容性,在 C++ 中,對於函數的調用方而言,返回值總是可以忽略的。

也就是說,對於給定的函數

int foo()
{
return 0;
}

調用方可以這麼寫:

foo(); // 忽略返回值

對於模版函數而言,如果依賴返回值做模板的類型推導,就會出現由於調用信息不全導致的二義性。

還是剛才這個例子,我們改為對應的函數模版,

template &
T foo()
{
return T(0);
}

假如我們允許藉助返回值來推導(如下所示)

int a = foo(); // 特化為 foo&()
double b = foo(); // 特化為 foo&()

那麼當調用方像之前的例子那樣調的時候,編譯器就沒辦法處理了:

foo(); // 報錯,因為缺乏足夠信息做模板實例化

正如 @黃柏炎 同學所提到的,函數重載時,情況雖略有不同,導致了語義上的處理稍有不同,但最後也產生了類似的效果。

那麼總結一下,一句話結論——「為了與C保持兼容,返回值並非是調用函數時的必要條件,因此函數模版類型推導和函數重載都不能且不應依賴返回值。」

-------------------------------------

如果你只想了解這個問題本身,那麼到剛才的一句話結論就可以結束了。然而,對模板而言,函數返回值與函數簽名之間的關係實際上要更複雜一些。咱們剛剛也提到,函數模版類型推導和函數重載,看起來在語法上具有某種形式上的一致性,兩者在語義上是有所不同的。如果您感興趣,可以接著往下讀,我們刨根問底一下,看看返回值究竟在函數簽名中扮演了什麼角色,順便弄清楚兩者究竟有何不同。

-------------------------------------

先解釋一下函數類型 (Function Type) 和函數簽名 (Function Signature) 吧。

在 C++ 中,函數類型 (Function Type) 與函數簽名 (Function Signature) 是兩個完全不同的概念。在我的理解中,前者主要是給程序員用的,通常用來定義函數指針 (形如 void(*)() ) 和函數對象 (形如 std::function&);後者主要是給編譯器用的,通常用於重載決議 (Overloading Resolution),模版特化 (Template Specialization) 及相關的類型推導 (Type Deduction),鏈接時生成獨一無二的全局標識 (Name Mangling)。

標準規定 (見 1.3.11 對函數簽名的說明和 14.5.5.1 對模版函數特化時簽名的補充說明):

  1. 對於普通函數(非模版函數),函數的簽名包括未修飾的函數名 (function name) ,參數類型列表 (parameter type list)和所在類或命名空間名 (class and namespace name)
  2. 對於類成員函數,函數的簽名除了 1 中提到的以外,還包括 cv 修飾符 (const qualifier and volatile qualifier) 和引用修飾符 (ref qualifier)
  3. 對於函數模板,函數的簽名除了 1 和 2 中提到的以外,還包括返回值類型和模板參數列表
  4. 對於函數模板的特化 (function template specilization),函數的簽名除了 1, 2 和 3 中提到的以外,還包括為該特化所匹配的所有模板參數(無論是顯式地指定還是通過模板推導隱式地得出)

-------------------------------------

下面,我們先來挨個看看如何用標準來解釋上面的幾種行為,再來看看標準為什麼對函數的簽名做這樣的規定。

Q1: 普通的函數重載時發生了什麼?

A1. 函數的重載決議機制,依賴了函數簽名的獨特性。標準的 1 和 2 中,並沒有提到返回值類型,因此我們可以認為,僅有返回值不同的函數重載是無效的,因為根據標準,它們簽名是完全一致的。

例如下面兩個函數:

void bar() {}
int bar() { return 0; }

在函數定義(不用等到調用)的時候就無法通過編譯,因為同一個編譯單元 (translation unit) 中出現了兩個簽名一致的函數。

Q2: 函數模板實例化時發生了什麼?

A2. 根據 3 和 4 可以知道,通過在簽名中包含返回值類型和模板參數列表,一個函數模板及其若干特化得到了某種程度上的強類型保證,當所提到的類型不一致時,編譯器有機會報出對應的錯誤。

Q3: 函數模板實例化時,如果觸發了類型推導,發生了什麼?

A3. 當類型信息提供不完全,需要編譯器推導時,從 3 可以知道,由於簽名中已經包含了所有必要的信息,編譯器有能力藉助簽名本身得知必要的類型信息並進行補全。

Q4: 函數模板實例化時,跟返回值相關的行為是什麼?

A4. 返回值是簽名的一部分,這個事實導致了下面的定義方式成為可能:

template& int f() { return 0; }
template& double f() { return 0.0; }

請注意,跟 Q1 中 "定義時就無法通過編譯" 不同的是,這兩個同名同參的函數的定義是可以通過編譯的,因為根據 3 可以知道,返回值是簽名的一部分,這兩個函數的簽名是不同的。但實際使用時,根據我們之前的「一句話結論」中提到的,(為了與C保持兼容,返回值並非是調用函數時的充分必要條件),當真正的調用發生時,編譯器有可能缺乏足夠的信息去了解返回值的類型,也就不知道該把函數調用決議到哪一個函數定義上去。這個錯誤理論上來講可以是一個鏈接錯誤,但由於在函數定義的編譯階段已經可以得到了兩個不同的函數,那麼實際結果是在調用方的編譯階段就可以報出錯誤了。

Q5: 模板特化和重載決議同時觸發時,會發生什麼?

A5. 喜歡刨根究底的同學肯定會產生這個疑問,這裡我們舉兩個例子:

例子1,這個例子中,我們不僅期望函數模板會自動推導模板參數,而且期望編譯器能夠選擇正確的重載版本去調用

template&
int f(T)
{
return 1;
}

template&
int f(T*)
{
return 2;
}

int main()
{
std::cout &<&< f(0) &<&< std::endl; std::cout &<&< f((int*)0) &<&< std::endl; }

例子2,這個例子中,我們重載了模版函數和非模板函數,和例子1一樣,我們不僅期望 (在必要時) 函數模板會自動推導模板參數,而且期望 (在必要時) 能夠選擇正確的重載版本去調用:

#include &
#include &

template&
std::string f(T)
{
return "Template";
}

std::string f(int)
{
return "Nontemplate";
}

int main()
{
int x = 7;
std::cout &<&< f(x) &<&< std::endl; }

這裡我就賣個關子,不給出解釋了,大家也先不要急著到編譯器里去驗證,根據我們前面講述的知識,可以先試著通過思考,回答下面幾個問題:

  1. 這兩個例子中的函數,在定義能通過編譯嗎?調用時能通過編譯嗎?
  2. 如果能夠運行的話,編譯器會做出我們期望的重載決議和類型推導嗎?

弄明白了這兩個例子,Q5的問題自然也就得到解答了。

-------------------------------------

好了,通過這一系列的追問,我們總算把相關的行為給解釋清楚了。想清楚了上面這些細節,我們也就可以很輕鬆地認識到標準這麼規定的理由,說穿了非常簡單,就是兩點:

  1. 始終保證簽名的全局唯一性。
  2. 始終保證同一個模板的本體和其所有的特化,在簽名上的相關性。

具體地說,

  • 條目1使得函數簽名這個機制被用於函數重載的決議成為可能
  • 條目2使得函數簽名這個機制被用於模板特化時的類型推導成為可能

-------------------------------------

嗯,這個問題還是蠻有趣的,不知不覺也討論了這麼多。

應該沒有落下什麼吧。那麼先這樣吧,有問題的話再補充。

[ 注 ]

本文同時發在我的 blog (由於對 markdown 的支持,在那裡或許可獲得更好的閱讀體驗)

[知乎] C++ template 為什麼不能推導返回值類型?


C++14 可以:

template& auto foo(A a, B b) { return a + b; }

要讓返回值參與「重載解析」,需要一點「奇技淫巧」,具體請參考:怎樣讓C++函數重載時連返回值類型也加入重載決議?


關於return type自動推導的問題,請參見C++14。只能說C++11沒來得及做這個部分。在有函數體的時候編譯器是有能力推導返回值結果的。


T沒被參數用到的一般要寫成這樣才能自動推導,譬如說

template&
auto Add(Fuck fuck, Shit shit)-&>decltype(fuck+shit)
{
return fuck + shit;
}


我想到一個類似的問題:函數重載的時候,為什麼不能重載返回類型不一樣的函數,例如,下面是非法的:

int dosth(int p1);
bool dosth(int p1);

我的理解是,在你真正調用這個函數的時候,你可以不接受返回值:

dosth(10);

此時,編譯器根本無從推導到底該用哪個函數。

模板函數返回值推導同理。


你的寫法肯定是錯的。你的 T 是推導不出來,因為 C++ 中的模板函數,模板參數是由函數參數來推導的,而不是函數的返回值。而你的函數參數是空的。

你這種寫法只有外部調用的時候,顯式指定模板參數類型才能通過,比如這樣:

int i = value&();

但我想這不是你本意吧?


#include &

using namespace std;

template &
T function() {
return T();
}

int main() {
cout &<&< function&() &<&< endl; }

這樣可以。

但是不知道是不是和用的VS 2013有關係。


推薦閱讀:

什麼編譯器優化技術可以把FP語言里的sum [1..n]的效率優化到C語言的水平?
即時編譯器與解釋器的區別?
設計類Python編譯器時如何處理tab和space縮進?
為什麼所有的教科書中都不贊成手寫自底向上的語法分析器?
學好c++,是不是最好研究下其編譯器?因為感覺c++的編譯器做了很多僅從語言前端看不出的工作。?

TAG:編程語言 | C | 編譯原理 | 編譯器 | 模板C |