如何避免扩展 std::formatter 时的冲突?concepts 能否在重新求值时返回不同结果?
结论
不能让 std::formattable<T, wchar_t> 随编译过程从 false 变成 true,再利用这个变化为同一个 T 补上 std::formatter。C++20、C++23、C++26 都没有提供这种“截至当前位置”的 formatter 查询机制。
这种做法有两个问题:
- 用
!std::formattable<T, wchar_t>约束std::formatter<T, wchar_t>的偏特化,会形成循环依赖。 - 对相同的原子约束和相同的模板实参,如果约束满足结果在程序不同位置发生变化,程序就是 ill-formed, no diagnostic required,即程序不合法,但实现不必诊断。标准草案的原子约束规则
维护库时,可以为自己控制的具体类型提供明确的特化,把通用的 ToString 兜底放在专用包装类型上。如果需要“已有 formatter 优先”,应在库自己的格式化入口中选择。
为什么 !std::formattable 会形成循环
std::formattable 检查相应的 std::formatter 是否满足格式化要求,并不会忽略当前候选偏特化、只检查其他 formatter。formattable 的定义
因此,下面的定义无法完成所需的判断:
template <typename T>
requires (!std::formattable<T, wchar_t> && IToString<T>)
struct std::formatter<T, wchar_t> : ToStringFormatter<T> {};
编译器要确定 std::formatter<T, wchar_t>,就必须检查这个偏特化的约束。检查约束需要判断 std::formattable<T, wchar_t>,而后者又要求确定同一个 std::formatter<T, wchar_t>。
把 IToString<T> 放在前面,可以利用约束的短路规则跳过不满足它的类型,但需要兜底的类型仍然会遇到这个循环。
Visual Studio 的 C7608 出现在这种无效的约束求值过程中。其他实现可能报告递归约束、约束依赖自身等错误,诊断文字不一定相同。
concepts 不能用来观察类型能力的变化
concept 可以对不同类型得到不同结果,但对于相同的原子约束和模板实参,不能要求其满足结果随着声明增加而改变。
例如:
template <typename T>
concept HasMember = requires {
typename T::member;
};
struct X;
static_assert(!HasMember<X>);
struct X {
using member = int;
};
static_assert(HasMember<X>); // 不能依赖这里重新检查后变成 true
这个程序试图让同一个约束先不满足、后满足,违反了上述规则。编译器可能报告约束结果发生变化,也可能沿用先前结果,导致最后的断言失败。任何一种编译表现都不能作为可移植机制来依赖。
提前查询 formattable、保存结果,再声明特化,也无法可靠地绕过这个问题。查询本身可能已经触发 formatter 的实例化,后补的特化还要遵守特化声明必须在相关使用处可达等规则。显式特化的声明与实例化顺序要求
去掉否定检查,也不代表一定会歧义
下面的写法避免了自我查询:
template <IToString T>
struct std::formatter<T, wchar_t> : ToStringFormatter<T> {};
但它没有表达“其他 formatter 优先”。
类型同时满足 IToString 和 formattable,并不必然产生歧义。已有的显式特化可以直接适用;多个偏特化之间,也可能通过偏序选出更特化的一个。
当多个偏特化都匹配,而偏序规则无法确定唯一的最佳匹配时,才会发生歧义。编译器不会因为一个候选来自标准库、另一个被设计为兜底,就自动为它们安排优先级。偏特化的偏序规则
因此,宽泛的 IToString 偏特化容易与 ranges 格式化发生冲突。
先限制直接特化的范围
向 std 添加标准库类模板特化,通常要求该声明依赖至少一个程序定义类型,并满足原模板的要求;标准明确禁止的情况除外。namespace std 的扩展规则
因此,不能仅因为旧版标准库尚未提供 formatter,就直接为 std::thread::id、std::filesystem::path 或 std::optional<int> 添加自己的 formatter。这些类型不满足上述一般授权条件。
对于库自己定义的具体类型,可以复用已有实现:
template <>
struct std::formatter<mylib::Widget, wchar_t>
: mylib::ToStringFormatter<mylib::Widget> {};
前提是 ToStringFormatter 已正确实现所需的 parse、format 等接口。类型族也可以使用范围明确的偏特化,但仍需处理它与标准 ranges 等偏特化同时匹配的情况。
仅凭类型具有 ToString(),无法保证这种特化合法,也无法保证没有其他候选参与匹配。
通用兜底使用专用包装类型
使用包装类型可以复用现有的 ToStringFormatter<T>,同时避免为原类型 T 增加一个宽泛的 formatter 候选。
下面假设已有的 IToString 和 ToStringFormatter 位于 mylib,且后者能够通过 const 成员函数格式化 const T&:
#include <format>
namespace mylib {
template <typename T>
struct as_text_ref {
const T& value;
};
template <typename T>
as_text_ref<T> as_text(const T& value)
{
return {value};
}
} // namespace mylib
template <mylib::IToString T>
struct std::formatter<mylib::as_text_ref<T>, wchar_t>
: mylib::ToStringFormatter<T> {
template <typename FormatContext>
auto format(mylib::as_text_ref<T> arg,
FormatContext& ctx) const
{
return mylib::ToStringFormatter<T>::format(
arg.value, ctx);
}
};
使用时:
auto text = std::format(L"{}", mylib::as_text(value));
这里特化的是 formatter<as_text_ref<T>, wchar_t>。包装类型自身没有 range 接口,因此即使被包装的 T 是 range,包装类型也不会匹配标准的 range formatter。
只要库的 ToString API 支持,也可以用同样的方式包装标准库类型。
使用这个包装器时,需要留意两个细节:
- 原问题中的
IToString用T t检查调用,而包装器持有const T&。concept 应检查实际使用的调用形式,避免非const调用通过检查后,在格式化时失败。 - 包装器保存的是引用,其生命周期不应超过被引用对象。若要长期保存,应改用拥有值的包装器。
“已有 formatter 优先”放到自己的入口中
使用包装器后,可以等原类型的 formatter 集合确定,再选择格式化方式,无需改变这个集合。
在提供 std::formattable 的 C++23 及后续实现中,可以写:
#include <format>
#include <string>
namespace mylib {
template <typename T>
std::wstring format_one(const T& value)
{
if constexpr (std::formattable<const T&, wchar_t>) {
return std::format(L"{}", value);
} else {
return std::format(L"{}", as_text(value));
}
}
} // namespace mylib
这个入口要求参数要么已有可用的 formatter,要么支持包装器所需的 ToString 调用。
兜底分支使用另一个类型的 formatter,没有改变 formatter<T, wchar_t>,因此检查不会依赖自身。相关 formatter 声明仍应在调用前可见。
C++20 没有标准的 std::formattable。跨版本代码可以始终显式使用包装器,或在库内单独实现能力检测。仅检查 std::formatter<T, CharT> 是否存在、是否为完整类型,无法证明它可用于格式化,因为不受支持的类型也可能对应一个禁用的 formatter。formatter 的启用与禁用规则
自定义 range 可以明确关闭标准的 range 格式化
如果库自己定义的某个 range 必须使用 ToString 输出,在支持 C++23 ranges 格式化的标准库中,可以为它明确设置策略:
template <>
inline constexpr std::range_format
std::format_kind<mylib::MyRange> =
std::range_format::disabled;
随后再提供自己的 formatter。
这项定制只适用于满足 ranges::input_range、没有 cv 限定的程序定义类型,并且必须在相关实例化之前声明。它会关闭标准的 range 格式化路径,你提供的自定义 formatter 仍然可用。format_kind 的定制规则
跨标准版本时,应根据实现提供的功能测试宏控制这段代码,例如检查 __cpp_lib_format_ranges。fmt::formatter 与 std::formatter 属于不同的定制体系,不能假设它们使用同一套宏或扩展规则。
版本适配代码负责判断设施是否可用,类型自身的策略负责决定输出形式。这样就无需维护一个覆盖所有类型和实现的“尚无 formatter”条件。
备注:内容仅供参考。
