如何让 template 找到 concept-constrained template 的函数定义?
结论
要让 foo 正确调用受 concept 约束的 bar,这个重载的声明必须在 foo 定义之前可见,所选模板的定义也必须可用于实例化。最直接的做法是把受约束的 bar 定义放进头文件,写在 foo 前面。
原代码有两个问题:
- 无约束的
bar声明和受std::integral约束的bar定义是两个不同的函数模板。 foo<int>中的调用无法通过普通名称查找找到后面才声明的重载。
取消 bar(5) 的注释后,即使链接成功,这两个问题也依然存在,不能依赖这种行为。
为什么已有定义,仍然找不到 bar
头文件中的声明是:
template <typename T>
void bar(T value);
源文件中的定义是:
template <std::integral T>
void bar(T value)
{
std::println("bar(integral[{}])", value);
}
后者增加了约束,形成了另一个重载。它没有补全前面的声明,也不是前一个模板的特化。判断函数模板是否为同一个实体,不能只看替换参数后是否都得到 void(int),还要考虑约束是否满足相关声明的等价性要求。C++ 工作草案:函数模板重载
foo 的定义是:
template <typename T>
void foo(T value)
{
bar<T>(value);
}
这个调用依赖模板参数,遵循两阶段名称查找规则:
- 普通名称查找考虑
foo定义处可见的声明,此时只有无约束的bar。 - 实例化时可以通过 ADL(实参依赖查找)补充候选,但不会重新搜索调用位置之前的全部全局重载。
Microsoft 的两阶段名称查找文档给出了类似示例:在模板定义之后才出现、又无法通过 ADL 找到的重载,不会参与该调用的重载决议。Microsoft:两阶段名称查找
调用 foo(6) 时,实参类型是 int。基本类型 int 没有关联命名空间,因此 ADL 无法找到源文件中后声明的 bar。C++ 工作草案:ADL
因此,foo<int> 引用的是无约束模板的 bar<int>。程序没有提供这个模板的定义,便会出现未解析的外部符号。仅把调用改成 bar(value),也无法解决声明顺序的问题。
为什么加上 bar(5) 就能运行
main 直接调用 bar(5) 时,两个重载都已可见。对于 int,重载决议会选中受 std::integral 约束的版本,这次调用会触发它的隐式实例化。
bar(5) 触发的是隐式实例化,显式实例化则使用这样的语法:
template void bar<int>(int);
直接调用不会改变 foo<int> 内部的名称查找结果。按标准规则,那里引用的仍然是无约束模板的实例。
题述的链接成功现象,符合两个不同模板的实例生成了相同链接符号的情况:直接调用使受约束版本生成了函数体,链接器随后用它解析了原本缺失的引用。这是从实现层面给出的一种解释。要确认具体构建中的原因,需要检查目标文件的修饰名,仅凭输出还不能确定。
因此,额外调用一次 bar,或者直接给原代码添加显式实例化,都不能作为通用修复。应先修正声明与查找关系。
推荐实现:把受约束重载放在 foo 前面
删除原来只有声明的无约束版本,把需要的重载定义放入头文件:
// my.hpp
#pragma once
#include <concepts>
#include <print>
template <std::integral T>
void bar(T value)
{
std::println("bar(integral[{}])", value);
}
template <std::floating_point T>
void bar(T value)
{
std::println("bar(floating_point[{}])", value);
}
template <typename T>
void foo(T value)
{
bar(value);
}
// my.cpp
#include "my.hpp"
int main()
{
foo(6);
foo(6.5);
}
输出应为:
bar(integral[6])
bar(floating_point[6.5])
这里使用 bar(value),由实参推导模板参数,也便于支持普通函数重载。如果只考虑函数模板,保留 bar<T>(value) 也可以。修复依赖的是声明在 foo 定义处可见。
使用时还需注意:
- 使用
std::integral和std::floating_point时,应直接包含<concepts>,不要依赖其他头文件间接引入。 - concepts 属于 C++20;示例中的
std::println需要 C++23 及相应的标准库支持。 - 在普通头文件使用方式下,若希望任意受支持类型都能自动实例化,通常需要让模板定义随头文件可见。也可以把实现放进
.tpp等文件,再由头文件包含。 - 对于不支持的类型,可以不提供兜底重载,让调用在编译期报错。不要留下一个没有定义的通用声明,否则错误可能拖到链接阶段。
根据扩展需求选择模式
类型分组由库统一维护
使用上面的受约束重载即可。先声明所有应参与普通查找的重载,再定义 foo。
如果各类型分支都由同一处代码维护,也可以在一个模板的函数体中使用 if constexpr,按 concept 分支处理,省去维护多个重载声明顺序的工作。
允许用户为自己的类型提供实现
可以在自定义类型所在的命名空间中定义 bar,让 foo 通过非限定调用使用 ADL。
例如,在前述头文件之外增加:
namespace app {
struct Value {};
void bar(Value)
{
// 对 app::Value 执行自定义操作
}
} // namespace app
int main()
{
foo(app::Value{});
}
app::Value 的关联命名空间是 app,因此 ADL 可以找到 app::bar。这里应保留 bar(value);写成 bar<T>(value) 会排除这个普通函数重载,写成 ::bar(value) 则会禁止 ADL。
这种扩展方式适用于具有关联命名空间的自定义类型,无法让 int 自动关联到后来添加的全局重载。
必须把实现留在 .cpp
如果支持的类型集合固定,可以在头文件中声明正确的受约束模板,并在实现文件中显式实例化:
// my.hpp
#pragma once
#include <concepts>
template <std::integral T>
void bar(T value);
extern template void bar<int>(int);
template <typename T>
void foo(T value)
{
bar(value);
}
// my.cpp
#include "my.hpp"
#include <print>
template <std::integral T>
void bar(T value)
{
std::println("bar(integral[{}])", value);
}
template void bar<int>(int);
int main()
{
foo(6);
}
这里的声明和定义使用相同的约束,显式实例化提供了 foo<int> 所需的函数。extern template 是显式实例化声明,函数定义之后的 template void bar<int>(int); 是显式实例化定义。C++ 工作草案:显式实例化
这种写法需要为每种受支持的类型分别提供实例。若希望 long、unsigned 等类型也能随调用自动获得实现,把定义放在头文件中更合适。
备注:内容仅供参考。
