前言
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_STANDARD
到 23
的。在我们早期用的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
对象是啥样呢?
#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
。
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
接口包装一层,增加一下自己的处理。比如我们的日志接口:
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(...)
,就会报出类似下面这种错误。
/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
就行了,比如函数签名改成这样:
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(...)
的声明:
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(...)
的申明:
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(...)
的接口声明如下:
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都属于比较新兴的事物。各类实现也都还不是非常完善,多多少少会碰到一些问题。欢迎有兴趣同踩过坑的小伙伴们提出意见互相交流。