PAYATHON 2026

如何避免扩展 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”条件。

备注:内容仅供参考。