这个问题是周末在知乎上看到的一个问题,有点意思,俺觉得可以讨论一下。
原问题
[C++ template 为什么不能推导返回值类型?] (http://www.zhihu.com/question/24671324)
补充说明: 例如:
|
|
希望能有透彻一点的解释。如果有什么解决方案(如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 返回值类型推导”,则分为普通函数和模板函数两种情况:
- 当为普通函数时,返回值类型推导是函数体的一部分,发生在函数定义 (function definition) 时。举个栗子,形如 auto foo(int typedArg) { return typedArg; } 的函数,在定义时已可完全确认返回值类型为 int 了。
- 当为模板函数时,返回值类型推导仍为函数体的一部分,但需根据其"是否依赖模板参数类型"来决定发生于定义时还是实例化时。当返回值类型依赖模板参数类型时,情形正如 @vczh 同学举的例子;当返回值类型不依赖模板参数类型时,则退化为 1. 中的普通函数调用情况。
请注意,对于 2. 中提到的 @vczh 同学举的例子,调用方仍需提供模板参数类型,无论是借助编译期推导还是手工填充。
总得来说,“C++14 返回值类型推导” 是一个正向过程,只是语法上的一种简化 (syntax simplification),而语义上与原来的函数完全一致。而题主问到的 “函数模板参数类型推导” 是一个反向过程,在语义上,返回值的类型"接受推导"和"不接受推导"会导致截然不同的函数特化和调用。
好了,辨别清楚了概念,现在我们来正面回答这个题目:
为了尽可能与 C 保持语法和语义上的兼容性,在 C++ 中,对于函数的调用方而言,返回值总是可以忽略的。
也就是说,对于给定的函数
|
|
调用方可以这么写:
|
|
对于模版函数而言,如果依赖返回值做模板的类型推导,就会出现由于调用信息不全导致的二义性。
还是刚才这个例子,我们改为对应的函数模版,
|
|
假如我们允许借助返回值来推导(如下所示)
|
|
那么当调用方像之前的例子那样调的时候,编译器就没办法处理了:
|
|
正如@黄柏炎同学所提到的,函数重载时,情况虽略有不同,导致了语义上的处理稍有不同,但最后也产生了类似的效果。
那么总结一下,一句话结论——“为了与C保持兼容,返回值并非是调用函数时的必要条件,因此函数模版类型推导和函数重载都不能且不应依赖返回值。”
如果你只想了解这个问题本身,那么到刚才的一句话结论就可以结束了。然而,对模板而言,函数返回值与函数签名之间的关系实际上要更复杂一些。咱们刚刚也提到,函数模版类型推导和函数重载,看起来在语法上具有某种形式上的一致性,两者在语义上是有所不同的。如果您感兴趣,可以接着往下读,我们刨根问底一下,看看返回值究竟在函数签名中扮演了什么角色,顺便弄清楚两者究竟有何不同。
先解释一下函数类型 (Function Type) 和函数签名 (Function Signature) 吧。
在 C++ 中,函数类型 (Function Type) 与函数签名 (Function Signature) 是两个完全不同的概念。在我的理解中,前者主要是给程序员用的,通常用来定义函数指针 (形如 void(*)() ) 和函数对象 (形如 std::function<void()>);后者主要是给编译器用的,通常用于重载决议 (Overloading Resolution),模版特化 (Template Specialization) 及相关的类型推导 (Type Deduction),链接时生成独一无二的全局标识 (Name Mangling)。
标准规定 (见 1.3.11 对函数签名的说明和 14.5.5.1 对模版函数特化时签名的补充说明):
- 对于普通函数(非模版函数),函数的签名包括未修饰的函数名 (function name) ,参数类型列表 (parameter type list)和所在类或命名空间名 (class and namespace name)
- 对于类成员函数,函数的签名除了 1 中提到的以外,还包括 cv 修饰符 (const qualifier and volatile qualifier) 和引用修饰符 (ref qualifier)
- 对于函数模板,函数的签名除了 1 和 2 中提到的以外,还包括返回值类型和模板参数列表
- 对于函数模板的特化 (function template specilization),函数的签名除了 1, 2 和 3 中提到的以外,还包括为该特化所匹配的所有模板参数(无论是显式地指定还是通过模板推导隐式地得出)
下面,我们先来挨个看看如何用标准来解释上面的几种行为,再来看看标准为什么对函数的签名做这样的规定。
Q1: 普通的函数重载时发生了什么? A1. 函数的重载决议机制,依赖了函数签名的独特性。标准的 1 和 2 中,并没有提到返回值类型,因此我们可以认为,仅有返回值不同的函数重载是无效的,因为根据标准,它们签名是完全一致的。
例如下面两个函数:
|
|
在函数定义(不用等到调用)的时候就无法通过编译,因为同一个编译单元 (translation unit) 中出现了两个签名一致的函数。
Q2: 函数模板实例化时发生了什么? A2. 根据 3 和 4 可以知道,通过在签名中包含返回值类型和模板参数列表,一个函数模板及其若干特化得到了某种程度上的强类型保证,当所提到的类型不一致时,编译器有机会报出对应的错误。
Q3: 函数模板实例化时,如果触发了类型推导,发生了什么? A3. 当类型信息提供不完全,需要编译器推导时,从 3 可以知道,由于签名中已经包含了所有必要的信息,编译器有能力借助签名本身得知必要的类型信息并进行补全。
Q4: 函数模板实例化时,跟返回值相关的行为是什么? A4. 返回值是签名的一部分,这个事实导致了下面的定义方式成为可能:
|
|
请注意,跟 Q1 中 “定义时就无法通过编译” 不同的是,这两个同名同参的函数的定义是可以通过编译的,因为根据 3 可以知道,返回值是签名的一部分,这两个函数的签名是不同的。但实际使用时,根据我们之前的“一句话结论”中提到的,(为了与C保持兼容,返回值并非是调用函数时的充分必要条件),当真正的调用发生时,编译器有可能缺乏足够的信息去了解返回值的类型,也就不知道该把函数调用决议到哪一个函数定义上去。这个错误理论上来讲可以是一个链接错误,但由于在函数定义的编译阶段已经可以得到了两个不同的函数,那么实际结果是在调用方的编译阶段就可以报出错误了。
Q5: 模板特化和重载决议同时触发时,会发生什么? A5. 喜欢刨根究底的同学肯定会产生这个疑问,这里我们举两个例子:
例子1,这个例子中,我们不仅期望函数模板会自动推导模板参数,而且期望编译器能够选择正确的重载版本去调用
|
|
例子2,这个例子中,我们重载了模版函数和非模板函数,和例子1一样,我们不仅期望 (在必要时) 函数模板会自动推导模板参数,而且期望 (在必要时) 能够选择正确的重载版本去调用:
|
|
这里我就卖个关子,不给出解释了,大家也先不要急着到编译器里去验证,根据我们前面讲述的知识,可以先试着通过思考,回答下面几个问题:
- 这两个例子中的函数,在定义能通过编译吗?调用时能通过编译吗?
- 如果能够运行的话,编译器会做出我们期望的重载决议和类型推导吗?
弄明白了这两个例子,Q5的问题自然也就得到解答了。
好了,通过这一系列的追问,我们总算把相关的行为给解释清楚了。想清楚了上面这些细节,我们也就可以很轻松地认识到标准这么规定的理由,说穿了非常简单,就是两点:
- 始终保证签名的全局唯一性。
- 始终保证同一个模板的本体和其所有的特化,在签名上的相关性。
具体地说,
- 使得函数签名这个机制被用于函数重载的决议成为可能
- 使得函数签名这个机制被用于模板特化时的类型推导成为可能。
嗯,这个问题还是蛮有趣的,不知不觉也讨论了这么多。 应该没有落下什么吧。那么先这样吧,有问题的话再补充。
(全文完)