C++ 模板参数推导问题小记

↑↑↑↑↑↑↑↑

问题代码

最近遇到一个模板参数推导的问题,代码如下:
代码

复制代码
template<typename T>
using scalar = std::enable_if_t<std::is_arithmetic_v<T>, T>;
template<typename T>
void foo(scalar<T> val)
{
  ...
}
foo(5);

这是我突发奇想写出来的,模板别名 scalar 限制函数参数为数值类型,可以在多处复用,这个代码无法通过编译,编译器提示没有匹配的函数调用。

代码很简单,看起来也没什么不妥,为什么出错了?在我询问了几个常用的 AI 编程助手没有得到满意的解答后(AI 的回答放在文章最后一节),我查阅了一些资料终于弄清了原因。

问题分析

正常的模板实例化过程中,编译器结合模板形参模式和实例化时提供的参数类型,确定一个或一组模板实参类型,将这些实参替换到形参后能够形成与实例化参数相匹配的参数列表。

在 foo(5) 这一调用中,形参是 scalar<T>, 实例化参数是 int 类型,编译器需要确定一个类型 T,使得scalar<T> 匹配 int。

我们来理一下这个过程:

我们会说,这不是一眼就能看出 T 就是 int 嘛,std::enable_if<std::is_arithmetic<T>::value, T>::type成功实例化的结果就是 T 本身。但是站在编译器的角度来看,可不能这样下定论,有些情况下,这部分可能并不是一个可以反推出固定类型的模板,举一个最简单的例子:
代码

复制代码
template<typename T>
struct wrapper
{
  using type = int;
};
template<typename T>
using scalar_confused = typename wrapper<T>::type;
template<typename T>
void foo_confused(scalar_confused<T> val)
{
  ...
}
foo_confused(5);

这个模板 wrapper 无论用什么类型实例化都能取到 int,也就是说在反推 T 时无法确定一个唯一的类型,这对于编译器来说是无法处理的,于是它实例化不出任何 foo_confused 的实例。

其实 C++ 标准已经对这类问题作出了说明,官方的命名是非推导上下文(non-deduced context):

我们代码的问题就是上图指出的这种情况,如果模板参数只出现在嵌套名称说明符内(即 :: 符号左边的部分),编译器将不会尝试从实例化参数中推导该模板参数,只能使用已经推导出的或显式指定的参数类型。

StackOverflow 上这篇文章(What is a non deduced context?)还有它提到的一些链接把这个概念讲的很清楚。

如果把 scalar 直接展开到使用位置,我们的代码等价于:
代码

复制代码
template<typename T>
void foo(typename std::enable_if<std::is_arithmetic<T>::value, T>::type val)
{
  ...
}

这样看来问题就清晰了,我们拐了一个弯创造了一个非推导上下文。编译器在解析到这一步时,就已经拒绝后续的推导了,后面我们关于反推的分析实际都没有发生。

如何解决

问题找到了,那么应该如何解决呢?把推导移到模板参数列表里面,让它在模板参数替换时先推导出来,后面再引用行不行:
代码

复制代码
template<typename T, typename S = std::enable_if_t<std::is_arithmetic_v<T>, T>>
using scalar = S;

可惜还是不行,而且增加了一层间接,编译器仍旧会失败在相同的位置:

其实解决方法很简单,别名模板只将 scalar<T> 展开为 T,限制条件独立出来作为一个模板参数用于排除不满足条件的实例化类型:
代码

复制代码
template<typename T, typename = std::enable_if_t<std::is_arithmetic_v<T>>>
using scalar = T;
template<typename T>
void foo(scalar<T> val)
{
  ...
}
foo(5);

现在调用 foo(5) 时,模板参数推导过程变为:

现在的逻辑变为,任何 scalar<T> 都是 T,但是只有当 T 是算术类型时,scalar<T> 才有效。

有人可能会问,为什么要编写一个这样的模板,而不是直接限制 foo 的参数类型:
代码

复制代码
template<typename T, typename = std::enable_if_t<std::is_arithmetic_v<T>>>
void foo(T val)
{
  ...
}

原因前面已经说过,为了复用,scalar<T> 比那一长串检测更简洁。这也是 C++20 concept 的低级实现版本:
代码

复制代码
template<typename T>
concept scalar = std::is_arithmetic_v<T>;
template<scalar S>
void foo(S val)
{
  ...
}

其他考量

我在另一篇笔记 限制模板实参类型 中提到过,使用静态断言,可在发生编译错误时提供可读性更高的错误提示,我们的这个例子恰好很符合这一情况:
代码

复制代码
template<typename T>
struct arithmetic_guard
{
  static_assert(std::is_arithmetic_v<T>, "instantiation requires arithmetic type");

  using type = T;
}
template<typename T, typename = typename arithmetic_guard<T>::type>
using scalar = T;

改造之后的 scalar 模板如果使用非算术类型进行实例化,就会在编译时指出需要算术类型。

但是静态断言版本存在一个缺点,就是和 SFINAE 不兼容,假如我们想使用 scalar 来编写一个这样的模板:
代码

复制代码
template<typename T>
auto selected_type_impl(int) -> decltype(std::declval<scalar<T>>(), 0.0);
template
   
int selected_type_impl(...);
template
    
using selected_type = decltype(selected_type_impl<T>(0));

如果 scalar 使用静态断言版本实现,那么我们使用非算术类型实例化 selected_type 时,得到的不是一个 int 类型,而是编译错误。因为 SFINAE 的发生时机是在模板参数替换阶段,将判断从模板参数列表移入 static_assert 内的后果就是任何 selected_type_impl 版本都会进行实例化而不会被静默移除,不符合条件的版本将在这一过程中抛出错误。在实际编码时,可根据具体需求选择合适的实现版本。

一些想法

C++ 语言在不断尝试简化模板元编程,C++26 会将静态反射加入语言标准,届时程序的元信息可以直接获取,而不是通过编写七弯八绕的模板来"套出"这些信息。

但是复杂性不是模板元编程的缺陷,相反它能容纳更多的可能性。优秀的模板库在缺乏编译器支持的年代解决问题的思路,很多令人拍案叫绝,成为经典用法甚至推动了语言标准的发展,为更高阶的功能实现奠定基础。

研究并掌握这些复杂巧妙的实现,运用它们在现实问题之前逢山开路遇水搭桥,不断磨炼我们的思维,而不是对它们望而却步。这样在面对语言标准提供的新特性时,我们才能敏锐察觉到它们的设计意图,善于恰当地加以运用,而不是浅尝辄止。

问题总结

思绪飘忽说了一些废话,回到代码的问题上,其实是自己对模板推导规则了解太浅,臆造出一个看似可行的实现,一厢情愿地认为编译器会如此工作。以后还须多多看书和实践,增加知识储备。

AI 有什么表现

出于好奇,我拿这个问题问 AI,看它们能否分析出来,以下是问题的结果。

DeepSeek 的推理能力比较不错,而且完全免费,使用它的深度思考模式提问,得到的结论是两个调用都没有问题:

微软 Edge 自带的免费版 Copilot 作为日常代码问题咨询以及闲聊对象很方便,ThinkDeeper 模式下,它很确定两种都能正确编译:

Claude 生成代码的能力非常强,广受好评,使用它的 concise 模式回答这个问题,它认为两个都不能通过编译:

号称地表最强的 Grok 经过仔细分析后,也没能得出正确的结论:

这三个都没有完全分析正确,这让我有一点意外。清除聊天上下文后拿相同的问题再提问,它们每次几乎都会给出不一样的结论,偶尔能正确地预测。持续聊天并引导它们分析问题,它们中很少能够准确说出问题出在非推导上下文这个点上。

这很难让人完全放心的将代码完全交由 AI 编写,目前来看,使用它们咨询一些编码问题,从中得到启发并亲自确认或者深入研究才是比较稳妥的做法。