C++20 Text Formatting/fmtlib 适配问题小记

2023-03-06 11:54:07 浏览数 (1)

前言

C 20 正式发布已经有一段时间了。其中 Text Formatting 是一个我个人比较感兴趣的新组件。它主要是解决了之前字符串格式化库 ( printf 系 ) 的效率问题和运行时安全的问题。 并且新的格式设置的形式也比较友好。相关规范和用法可以参见:

  • http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p0645r10.html#format.formatter
  • http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p2216r3.html
  • https://en.cppreference.com/w/cpp/utility/format

在C 20能够普及以前,我们可以用 fmtlib 这个库来完成一样的功能。实际上我们项目组已经在这么做了。在使用 fmtlib 之前,比如我们按玩家打日志大概是这种形式。

代码语言:javascript复制
WLOGDEBUG("player %s(%u:%llu) int %d string %s string_view %s from server %llx", 
          user.get_open_id().c_str(), user.get_zone_id(),
          static_cast<unsigned long long>(user.get_user_id()),
          123, str.c_str(), str_v.data(),
          static_cast<unsigned long long>(server_inst_id));

之所以使用 static_cast<unsigned long long>(...) 是因为这是在跨平台跨编译器开启 -Wall/W4 的情况下,对 uint64_t 执行printf系接口报warning最简单的方法。

在使用 fmtlib 之后,我们按玩家打印日志可以变成:

代码语言:javascript复制
FWLOGDEBUG("int {} string {} string_view {} from server {:#x}", 
          user, 123, str.c_str(), str_v.data(), server_inst_id);

是不是简洁多了?

于此同时,我们的构建系统改成了会检测编译环境是否支持 C 20 Text Formatting ,在支持的情况下使用 C 20 Text Formatting ,在不支持的情况下使用 fmtlib 。 但是因为目前各大编译器和STL实现中,C 20 Text Formatting 还处于experiment阶段。并且 C 20 Text Formatting 和 fmtlib 多多少少还是有一些不同的地方。 我们跨平台上就踩了一些坑,特此记录一下以便互相交流。

枚举类型的差异

首先是对枚举类型处理的差异。如果没有自定义 formatter ,在 fmtlib 里是能够自动转换成整数类型的输出的,但是(至少是 MSVC)的 C 20 Text Formatting 实现里是不会自动转换的,我翻了一下ISO文档好像是没看到对是否隐式转换的说明。这里会造成一处适配上的问题。比如一些小伙伴习惯用的编译器不支持 C 20 Text Formatting 而fallback到了使用 fmtlib 实现的时候,可能会忘记这个手动转换。那么切到某些编译环境上使用 C 20 Text Formatting 的时候可能会编译不过,需要再适配一次。

Visual Studio 2019 version 16.10(MSVC 1929)的BUG

Visual Studio 2019 version 16.10(MSVC 1929)的第一个版本的实现中 format_to_n 实现有个BUG。里面某一层调用本该用它内部的 _Count()_Size() 接口。但是用了 size() 。会导致编译不过。

当时版本的代码已经找不到了,并且最新版本已经修复了这个问题。碰到同类问题的童鞋们可能直接更新VS版本就行了。

Visual Studio 2019 version 16.11 的变化

我们项目组的构建系统使用的是cmake,在cmake 3.20以前,是不支持检测设置 CXX_STANDARD23 的。在我们早期用的cmake版本中,最大支持的是C 20的检测,所以最大版本号写的就是 set(CXX_STANDARD 20)

这种情况下,同时在 Visual Studio 2019 version 16.10 之前,VS还没有 /std:c 20 选项,所以cmake会把C 标准设为 /std:c latest 。但是 Visual Studio 2019 version 16.11 开始有了一些变化。相关的信息如下:

  • https://github.com/microsoft/STL/issues/1814
  • https://github.com/microsoft/STL/issues/1814#issuecomment-845572895

简单地说就是 MSVC 对一些C 20特性的实现还没有进入ABI稳定期,所以在使用 /std:c 20 时并不会启用 C 20 里的一些内容,包括但不限于Text Formatting,Ranges等等。 所以我们现在一方面是对高版本的cmake开到了 set(CXX_STANDARD 23) ,这样在 MSVC 下又会使用到 /std:c latest 。另一方面针对MSVC的这种情况,在构建系统中对 C 20 Text Formatting 的检测脚本做了适配。

fmtlib 8.0

fmtlib 8.0 的接口变化

fmtlib 其实有一个比直接用 C 20 Text Formatting 更好的地方。就在在GCC和Clang下,fmtlib 可以编译期验证输入格式字符串的合法性。 但是这个特性在 fmtlib 8.0 开始有一个很大的变化。

首先,是各类format接口变成了这种形式。

代码语言:javascript复制
template <typename... T>
FMT_INLINE auto format(format_string<T...> fmt, T&&... args) -> std::string {
  return vformat(fmt, fmt::make_format_args(args...));
}

可以看到格式字符串现在是一个 format_string 对象。那么这个 format_string 对象是啥样呢?

代码语言:javascript复制
#if FMT_GCC_VERSION && FMT_GCC_VERSION < 409
// Workaround broken conversion on older gcc.
template <typename... Args> using format_string = string_view;
template <typename S> auto runtime(const S& s) -> basic_string_view<char_t<S>> {
  return s;
}
#else
template <typename... Args>
using format_string = basic_format_string<char, type_identity_t<Args>...>;
// Creates a runtime format string.
template <typename S> auto runtime(const S& s) -> basic_runtime<char_t<S>> {
  return {{s}};
}
#endif

可以看到,在GCC 4.9以上或其他编译器,format_string 是其内部实现的 basic_format_string 这个结构。那么我们再来看看 basic_format_string

代码语言:javascript复制
template <typename Char, typename... Args> class basic_format_string {
 private:
  basic_string_view<Char> str_;

 public:
  template <typename S,
            FMT_ENABLE_IF(
                std::is_convertible<const S&, basic_string_view<Char>>::value)>
  FMT_CONSTEVAL basic_format_string(const S& s) : str_(s) {
    static_assert(
        detail::count<
            (std::is_base_of<detail::view, remove_reference_t<Args>>::value &&
             std::is_reference<Args>::value)...>() == 0,
        "passing views as lvalues is disallowed");
#ifdef FMT_HAS_CONSTEVAL
    if constexpr (detail::count_named_args<Args...>() == 0) {
      using checker = detail::format_string_checker<Char, detail::error_handler,
                                                    remove_cvref_t<Args>...>;
      detail::parse_format_string<true>(str_, checker(s, {}));
    }
#else
    detail::check_format_string<Args...>(s);
#endif
  }
  basic_format_string(basic_runtime<Char> r) : str_(r.str) {}

  FMT_INLINE operator basic_string_view<Char>() const { return str_; }
};

这里的重点就来了。我们能看到, basic_format_string 的构造函数加上了 FMT_CONSTEVAL ,其实就是 consteval 关键字。它的含义是可在编译期求值(注意和 const 关键字区分开来,一个函数和类型申明可以是 consteval 但不是 const 的)。再加上 format 接口的申明是 format_string ( 我们关注它是 basic_format_string 的情况。),所以它接受一次隐式类型转换。即,调用 format 的时候格式字符串参数只要能隐式类型转换到 basic_format_string (注意要求编译期转换)就可以。这样我们就可以使用类似 fmt::format("Hello {}", "world") 这种写法。因为 "Hello {}" 是编译期常量。

但是在实际使用中,我们时而会对 format 接口包装一层,增加一下自己的处理。比如我们的日志接口:

代码语言:javascript复制
template <class FMT, class... TARGS>
void format_log(const caller_info_t &caller, FMT &&fmt_text, TARGS &&...args) {
  // 我们会在这里处理额外的调用者信息处理和使用自己的缓冲区
  // 然后调用 std::format_to_n(...) 或 fmt::format_to_n(...)
}

那么在这种情况下,格式字符串的类型是通用引用(Universal Reference) FMT&& 。在使用 format_log(caller, "Hello {}", "world") 时。FMT 会被推断为 const char[9]& 。然后传给 fmt::format_to_n(...) 。这时候对 fmt::format_to_n(...) 的调用其实就不再是编译期可以求值的 constexpr 了(因为上层的函数签名没有这个保证)。如果直接把 std::forward<FMT>(fmt_text) 转发给 fmt::format_to_n(...) ,就会报出类似下面这种错误。

代码语言:javascript复制
/usr/include/fmt/core.h:2835:17: note: ‘consteval fmt::v8::basic_format_string<Char, Args>::basic_format_string(const S&) [with S = fmt::v8::basic_format_string<char, test_custom_object_for_log_formatter&>; typename std::enable_if<std::is_convertible<const S&, fmt::v8::basic_string_view<Char> >::value, int>::type <anonymous> = 0; Char = char; Args = {test_custom_object_for_log_formatter}]’ is not usable as a ‘constexpr’ function because:
 2835 |   FMT_CONSTEVAL basic_format_string(const S& s) : str_(s) {
      |                 ^~~~~~~~~~~~~~~~~~~
/usr/include/fmt/core.h:2835:51: error: call to non-‘constexpr’ function ‘fmt::v8::basic_format_string<Char, Args>::operator fmt::v8::basic_string_view<Char>() const [with Char = char; Args = {test_custom_object_for_log_formatter&}]’
 2835 |   FMT_CONSTEVAL basic_format_string(const S& s) : str_(s) {
      |                                                   ^~~~~~~
/usr/include/fmt/core.h:2853:14: note: ‘fmt::v8::basic_format_string<Char, Args>::operator fmt::v8::basic_string_view<Char>() const [with Char = char; Args = {test_custom_object_for_log_formatter&}]’ declared here
 2853 |   FMT_INLINE operator basic_string_view<Char>() const { return str_; }
      |              ^~~~~~~~

我们也不能用类似 basic_format_string 的实现方式包一层数据结构然后申明构造函数为 constexpr 。因为C 规定隐式类型转换只能执行一层,如果我们包一层,就会影响API的易用度。

解决方法其实也比较简单,我们得关注 fmtlib 的内部实现和类型,参数直接传入 basic_format_string 就行了,比如函数签名改成这样:

代码语言:javascript复制
template <class... TARGS>
void format_log(const caller_info_t &caller, fmt::string_view<TARGS...> fmt_text, TARGS &&...args) {
  // 我们会在这里处理额外的调用者信息处理和使用自己的缓冲区
  // 然后调用 std::format_to_n(...) 或 fmt::format_to_n(...)
}

fmtlib 8.0.1 的一处BUG

在 fmtlib 8.0.0-8.0.1 版本有一个小BUG,也和我们上面的调用形式相关。其实按我们上面的改法,使用的时候也是编译不过的。 但是同样的形式,我们使用 fmt::format(...)fmt::format_to(...) 是没问题的,仅仅是 fmt::format_to_n(...) 会报错,为什么呢?我们可以先来回顾一下 fmt::format(...) 的声明:

代码语言:javascript复制
template <typename... T>
FMT_INLINE auto format(format_string<T...> fmt, T&&... args) -> std::string {
  return vformat(fmt, fmt::make_format_args(args...));
}

再来看看 fmt::format_to_n(...) 的申明:

代码语言:javascript复制
template <typename OutputIt, typename... T,
          FMT_ENABLE_IF(detail::is_output_iterator<OutputIt, char>::value)>
FMT_INLINE auto format_to_n(OutputIt out, size_t n, format_string<T...> fmt,
                            const T&... args) -> format_to_n_result<OutputIt> {
  return vformat_to_n(out, n, fmt, fmt::make_format_args(args...));
}

看出来没?关键就在于 fmt::format(...) 里后面的参数是 通用引用(Universal Reference)fmt::format_to_n(...)常量引用 。 并且在这个版本里 fmt::make_format_args(...) 的接口声明如下:

代码语言:javascript复制
template <typename Context = format_context, typename... Args>
constexpr auto make_format_args(const Args&... args)
    -> format_arg_store<Context, Args...> {
  return {args...};
}

这样,在 fmt::format(...)fmt::format_to(...) 的实现中,最终传入的 format_arg_store<Context, T...> 类型和解析参数的时候的 format_string<T...>T... 类型是不一样的。 然后就会通过 SFINAE 机制去尝试所有可能的类型转换,最后失败出现编译错误。

这个问题我已经提了 Issue 和 PR 了。目前已经合入了,估计下个版本就会包含进去。

在当前 fmtlib 的主干版本里,fmt::make_format_args(...) 已经改成了: template <typename Context = format_context, typename... Args> constexpr auto make_format_args(Args&&... args) -> format_arg_store<Context, remove_cvref_t<Args>...> { return {std::forward<Args>(args)...}; } 其实也从侧面解决了这个问题。

写在最后

无论是 C 20 Text Formatting ,还是 fmtlib 8.0都属于比较新兴的事物。各类实现也都还不是非常完善,多多少少会碰到一些问题。欢迎有兴趣同踩过坑的小伙伴们提出意见互相交流。

0 人点赞