新年第一篇,好久没写Modern C++主题了,这次来说说C++20的格式化库。

该标准库来自开源库fmtlib,作者为Victor Zverovich,提案为P0645R10。

目前为止,仍旧只有MSVC16.10+对该库支持稍微完整,因此可以先使用 fmtlib

格式化函数

C++20提供了三个格式化函数,std::format()std::format_to()std::format_to_n()

通过一个简单的例子来了解其用法:

// format
std::cout << std::format("HAPPY NYE {} EVERYONE!", 2022) << '\n';

// format_to
std::string buffer;
std::format_to(
    std::back_inserter(buffer),
    "HAPPY NYE {} EVERYONE!", 2022
);
std::cout << buffer << '\n';

// format_to_n
buffer.clear();
std::format_to_n(
    std::back_inserter(buffer), 6,
    "HAPPY NYE {} EVERYONE!", 2022
);
std::cout << buffer << '\n';

输出如下:

HAPPY NYE 2022 EVERYONE!
HAPPY NYE 2022 EVERYONE!
HAPPY

format系列函数都包含一个格式化串参数,其中用 {} 表示占位,具体参数在该参数之后依次指定。

std::format 会返回一个 std::string,所以可以通过 cout 直接输出格式化之后的字符串。

std::format_tostd::format_to_n 则需要指定格式化之后字符串的输出位置,后者还需指定截取的字符长度。

例子中指定了输出位置为 std::string,截取长度为 6,所以有了如上输出。

std::formatstd::format_to 内部则使用了 std::vformatstd::vformat_to,实现如下:

template <class... _Types>
string format(const string_view _Fmt, const _Types&... _Args) {
    return vformat(_Fmt, make_format_args(_Args...));
}

template <output_iterator<const char&> _OutputIt, class... _Types>
_OutputIt format_to(_OutputIt _Out, const string_view _Fmt, const _Types&... _Args) {
    _Fmt_iterator_buffer<_OutputIt, char> _Buf(move(_Out));
    vformat_to(_Fmt_it{_Buf}, _Fmt, make_format_args(_Args...));
    return _Buf._Out();
}

因而在前者无法使用的情况下,可以使用后者代替前者。[见后文]

格式化语法规范

可以在格式串参数占位符 {} 中指定更多的规则,以产生更强大的字符串格式化能力,本节展示一些常用的语法。

总的语法规范官方是这样写的:

fill-and-align(optional) sign(optional) #(optional) 0(optional) width(optional) precision(optional) L(optional) type(optional)

因此,本节就按照这个顺序分成几点来介绍。

基本语法

上节的例子还可以这样写:

std::cout << std::format("{} {} {} {}!\n", "HAPPY", "NYE", 2022, "EVERYONE");
std::cout << std::format("{} {} {} {}!\n", "HAPPY", "NYE", 2022, "EVERYONE", "unused");
std::cout << std::format("{2} {1} {3} {0}!\n", "EVERYONE", "NYE", "HAPPY", 2022);

此处有几个注意点。首先,面对不同类型,占位符无需指定具体的类型,会自动识别。其次,若实际参数个数多于占位符个数,则会忽略多余的参数。最后,默认参数 ID 是从0依次增加,可以通过显式指定参数 ID 来改变默认的参数顺序。

填充与对齐

其实这个语法很简单,<>^ 三个符号分别表示左对齐、右对齐和居中对齐。整数和浮点数默认是右对齐,非整数和浮点数默认是左对齐。

看如下例子:

int NYE = 2022;
std::cout << std::format("{:10}", NYE) << '\n';
std::cout << std::format("{:10}", ":)") << '\n';
std::cout << std::format("{:*<10}", ":)") << '\n';
std::cout << std::format("{:*>10}", ":)") << '\n';
std::cout << std::format("{:*^10}", ":)") << '\n';
std::cout << std::format("{:10}", true) << '\n';

将会输出:

      2022
:)
:)********
********:)
****:)****
true

其中,用 : 表示后面的是可选参数,10 表示宽度,* 表示填充的字符。是不是感觉有点像是在写正则表达式了呀哈哈~

sign、# 和 0

这三个可选规则是针对数值的。

sign 用于指定正负数的符号,+ 指定在格式化后正数前面加 + 号,- 指定负数前面加 - 号。如果是空格,则格式化后,正数前面会留个空格,负数前面则是 - 号。

#可以指定一些可替换的形式,主要是针对进制数的,如指定十六进制,则格式化后会在数值前面加 0x,二进制加 0b

0 则会在数值前面加 0,如 123 可能会变成 00123

例子如下:

std::cout << std::format("{0:},{0:+},{0:-},{0: }", NYE) << '\n';
std::cout << std::format("{0:},{0:+},{0:-},{0: }", -NYE) << '\n';

std::cout << std::format("{:#010d}", NYE) << '\n'; // 十进制
std::cout << std::format("{:#010b}", NYE) << '\n'; // 二进制
std::cout << std::format("{:#010o}", NYE) << '\n'; // 八进制
std::cout << std::format("{:#010x}", NYE) << '\n'; // 十六进制
std::cout << std::format("{:<010}", NYE) << '\n';  // 指定对齐,则补0忽略

将会输出:

2022,+2022,2022, 2022
-2022,-2022,-2022,-2022
0000002022
0b11111100110
0000003746
0x000007e6
2022

值得一提的是,对齐与补 0 不能共存,当同时指定时,补 0 将会被忽略。

宽度与精度

宽度与精度主要是针对浮点数的,直接看例子:

float NYED = 20.22f;
std::cout << std::format("{:10f}\n", NYED);
std::cout << std::format("{:{}f}\n", NYED, 10);
std::cout << std::format("{:.5f}\n", NYED);
std::cout << std::format("{:.{}f}\n", NYED, 5);
std::cout << std::format("{:10.5f}\n", NYED);
std::cout << std::format("{:{}.{}f}\n", NYED, 10, 5);

输出如下:

 20.219999
 20.219999
20.22000
20.22000
  20.22000
  20.22000

例子中的 10 是指定的宽度,.5 表示精度。可以直接在格式串中指定,也可以通过一个称为「内嵌替换域」的方式在参数后面指定,语法就是再格式串内容再嵌入 {}

自定义类型

std::format 并不支持所有类型的格式化操作,如何为其增加新的类型?便需要借助自定义类型。

自定义类型需要偏特化 std::formatter,然后重写 parse()format() 函数。

简而言之,自定义类型需要完成两部分工作,一是解析规则,二是格式输出。

规则就是前面写的 {:} 此类语法,由于需要自己编写解析函数,所以其实可以自定义规则。格式输出就是自己决定自定义类型输出的形式,自己指定输出哪些成员变量,添加、替换或删除哪些字符等等。

这里将提供的例子来自于 fmtlib 的示例,我将它用 C++20 标准的写法进行了改写。用此示例,是因为这个例子逻辑清晰,结构简明,很适合用来学习。

示例代码如下:

struct Point {
    double x, y;
};

template<>
struct std::formatter<Point> {
    constexpr auto parse(format_parse_context& ctx) {
        auto it = ctx.begin(), end = ctx.end();
        if (it != end && (*it == 'f' || *it == 'e')) presentation = *it++;
        if (it != end && *it != '}') throw std::format_error("invalid format");
        return it;
    }

    template<typename FormatContext>
    auto format(const Point& p, FormatContext& ctx) {
        return presentation == 'f'
            ? std::format_to(ctx.out(), "({:.1f}, {:.1f})", p.x, p.y)
            : std::format_to(ctx.out(), "({:.1e}, {:.1e})", p.x, p.y);
    }

    char presentation = 'f';
};

这个代码完全正确,但MSVC编译不过,会报:C2039"resize": 不是 "std::_Fmt_buffer<char>"的成员 错误,这是MSVC的BUG,目前还没有修复。但是,可以通过使用 std::vformat_to 来代替 std::format_to,从而避免该错误。于是将 format() 实现更改如下:

template<typename FormatContext>
auto format(const Point& p, FormatContext& ctx) {
    return presentation == 'f'
        ? std::vformat_to(ctx.out(), "({:.1f}, {:.1f})", std::make_format_args(p.x, p.y))
        : std::vformat_to(ctx.out(), "({:.1e}, {:.1e})", std::make_format_args(p.x, p.y));
}

现在来说 parse 函数,在这里解析规则。由于 Point 是浮点数,所以这里自定义规则为浮点表示和科学计数法表示两种形式。也就是说,规则可以为 {:f}{:e}

parse_parse_context 是解析的上下文语境,其 begin() 指向 {: 之后的字符,end() 指向 }。我们需要完成的工作就是解析其间的自定义规则。

在例子中,正确的规则只能是 {:f}{:e},因此判断了第一个字符是否为其中之一。迭代器向后走一位,就是 },如果不是则表示规则错误,于是抛出异常。

format() 的工作就是根据解析出来的规则,使用 std::vformat_to 将自定义类型欲输出内容输出到 FormatContext 中。这样就可以格式化自定义类型的输出形式。

完成上述操作,现在便可以使用 std::format 格式化自定义类型:

Point x{ 1, 2 };
std::cout << std::format("{:f}\n", x);
std::cout << std::format("{:e}\n", x);

输出将为:

(1.0, 2.0)
(1.0e+00, 2.0e+00)

通过这种方式,你可以为任何自定义类型编写合适的格式化形式。

总结

本篇介绍了 C++20 格式化库的基本使用方式,这个东西其实非常强大,能够以强大的语法规则轻松实现各种各样的格式化形式,也可以为自定义类型装配格式化功能,可以说是 C++20 中比较常用的一个组件。

Leave a Reply

Your email address will not be published. Required fields are marked *

You can use the Markdown in the comment form.