C++ template不能推导返回值类型是因为 C++ 确实提供了函数模板的参数类型推导(通过调用方提供的信息,自动推断并填充到模板参数,从而避免用户手动指明模板参数)。
一、C++ template不能推导返回值类型的原因
C++ template不能推导返回值类型是因为 C++ 确实提供了函数模板的参数类型推导(通过调用方提供的信息,自动推断并填充到模板参数,从而避免用户手动指明模板参数)。
为了尽可能与 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<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 中,并没有提到返回值类型,因此我们可以认为,仅有返回值不同的函数重载是无效的,因为根据标准,它们签名是完全一致的。
例如下面两个函数:
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;
}
这里我就卖个关子,不给出解释了,大家也先不要急着到编译器里去验证,根据我们前面讲述的知识,可以先试着通过思考,回答下面几个问题:
- 这两个例子中的函数,在定义能通过编译吗?调用时能通过编译吗?
- 如果能够运行的话,编译器会做出我们期望的重载决议和类型推导吗?
弄明白了这两个例子,Q5的问题自然也就得到解答了。
————————————-
好了,通过这一系列的追问,我们总算把相关的行为给解释清楚了。想清楚了上面这些细节,我们也就可以很轻松地认识到标准这么规定的理由,说穿了非常简单,就是两点:
- 始终保证签名的全局少数性。
- 始终保证同一个模板的本体和其所有的特化,在签名上的相关性。
具体地说,
- 条目1使得函数签名这个机制被用于函数重载的决议成为可能
- 条目2使得函数签名这个机制被用于模板特化时的类型推导成为可能
延伸阅读:
二、C++工作原理
C++语言的程序开发环境,为了方便测试,将调试环境做成了解释型。即开发过程中,以解释型的逐条语句执行方式来进行调试,以编译型的脱离开发环境而启动运行的方式来生成程序最终的执行代码。
开发C++应用程序,需要经过编写源程序、编译、连接程序生成可执行程序、运行程序四个步骤。生成程序是指将源码(C++语句)转换成一个可以运行的应用程序的过程。如果程序编写正确,那么通常只需按一个功能键,即可完成该过程。
名列前茅步对程序进行编译,这需要用到编译器(compiler)。编译器将C++语句转换成机器码(也称为目标码);如果该步骤成功执行,下一步就是对程序进行链接,这需要用到链接器(linker)。链接器将编译获得机器码与C++库中的代码进行合并。C++库包含了执行某些常见任务的函数(“函数”是子程序的另一种称呼)。例如,一个C++库中包含标准的平方根函数sqrt,所以不必亲自计算平方根。C++库中还包含一些子程序,它们把数据发送到显示器,并知道如何读写硬盘上的数据文件。