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


原问题

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

Comments
Write a Comment

Tags

随笔   游戏开发   Programming   C/C++   优化   Unity   C++   知乎   游戏设计   比特币   Unity3D   区块链   软件开发   Bitcoin   引擎设计   系统架构   Production   idtech   中国文化   加密货币   项目管理   游戏评论   资源管理   资源流水线   效率   道德经   网络   方法论   模板编程   Blockchain   Lua   Blockchain Computing   Oculus   GDC   渲染   VR   PerfAssist   Unity MemoryProfiler   BCH   经济学   信息过载   行业报告   字体   Productivity   图形   网络编程   Dice   协程   EMC   Premake   测试   中间件   Game Engine   新手引导   区块链游戏   Methodology   CI   命令行解析   goroutine   ndk   Ethereum   nanomsg   自动化   Scripting   摘录   Debugging   同步技术   cppcon   C++模板   DOOM3   技术评估   Unity GC   C++11   学习方法   Surface Pro 3   Engine Evaluation   CRT   文化   笔记   golang   图形编程   多线程   ETH   Bitcoin Cash   cppcon14   Bitcoin SV   Visual Studio   Unity Coroutine   跨语言可变参数列表   团队协作   货币   Deployment   Visual Assist   工程改进   Michael Abrash   exp   开放世界   量子计算   域名   虚拟现实   系统重构   slua   遮挡剔除   完美转发   协作式调度   Modern C++   类型推导   Memory Debugging   个人成长   小故事   BTC   暴雪   产品   历史   错误处理   Unity Profiler   MOD  

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