[知乎回答] C++ template 为什么不能推导返回值类型?

这个问题是周末在知乎上看到的一个问题,有点意思,俺觉得可以讨论一下。


原问题

C++ template 为什么不能推导返回值类型?

补充说明:
例如:

template<typename T>
T value()
{
    // for expample
    T* ptr = static_cast<T*>(_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 <typename T>
T foo()
{
    return T(0);
}

假如我们允许借助返回值来推导(如下所示)

int a = foo();      // 特化为 foo<int>()
double b = foo();   // 特化为 foo<double>()

那么当调用方像之前的例子那样调的时候,编译器就没办法处理了:

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<typename T> int f() { return 0; }
template<typename T> double f() { return 0.0; }

请注意,跟 Q1 中 "定义时就无法通过编译" 不同的是,这两个同名同参的函数的定义是可以通过编译的,因为根据 3 可以知道,返回值是签名的一部分,这两个函数的签名是不同的。但实际使用时,根据我们之前的“一句话结论”中提到的,(为了与C保持兼容,返回值并非是调用函数时的充分必要条件),当真正的调用发生时,编译器有可能缺乏足够的信息去了解返回值的类型,也就不知道该把函数调用决议到哪一个函数定义上去。这个错误理论上来讲可以是一个链接错误,但由于在函数定义的编译阶段已经可以得到了两个不同的函数,那么实际结果是在调用方的编译阶段就可以报出错误了。

Q5: 模板特化和重载决议同时触发时,会发生什么?
A5. 喜欢刨根究底的同学肯定会产生这个疑问,这里我们举两个例子:

例子1,这个例子中,我们不仅期望函数模板会自动推导模板参数,而且期望编译器能够选择正确的重载版本去调用

template<typename T> 
int f(T) 
{ 
    return 1; 
} 

template<typename T> 
int f(T*) 
{ 
    return 2; 
} 

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

例子2,这个例子中,我们重载了模版函数和非模板函数,和例子1一样,我们不仅期望 (在必要时) 函数模板会自动推导模板参数,而且期望 (在必要时) 能够选择正确的重载版本去调用:

#include <string> 
#include <iostream> 

template<typename T> 
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. 使得函数签名这个机制被用于模板特化时的类型推导成为可能。

嗯,这个问题还是蛮有趣的,不知不觉也讨论了这么多。
应该没有落下什么吧。那么先这样吧,有问题的话再补充。

[完]
Gu Lu
[2014-08-04]

知识共享许可协议
本作品由Gu Lu创作,采用知识共享Attribution-NonCommercial-NoDerivatives 4.0 国际许可协议进行许可。