C++20: std::format
3/7/2021
Author's Profile Avatar
在 C++20 之前,C++ 的格式化字符串相关设施一直饱受诟病。具体来说,在不依赖第三方库的情况下,有两种方式实现格式化字符串:
  • 使用从 C 语言沿袭过来的 sprintf 以及相关函数;
  • 使用 C++ 的 std::stringstream 以及流相关设施。
然而,这两种方式都有明显的缺点。
对于第一种方式,即使用 sprintf 等函数进行格式化字符串,首先只能使用 C 风格字符串作为格式化结果,这意味着程序员需要手工管理存放字符串的内存空间;其次,sprintf 只能对有限的几种内置类型(例如整数、浮点数和 C 风格字符串等) 进行格式化,不支持也无法拓展支持格式化其他类型的数据;最后一点是 printf 系列函数一直存在的安全性问题,因为这些函数不是类型安全的。
对于第二种方式,不论是从语法上还是从本质上都与直接拼接字符串没有任何区别。在需要大规模进行字符串格式化的场景下,这种方式代码冗长、语法噪音极多,很不直观。
在 C++20 以前,除了 C/C++ 之外,几乎任意一个合格的现代命令式编程语言都在其标准库中实现有完备的格式化字符串设施。终于,在 C++ 社区长时间的呼吁声中,C++20 标准中加入了较为完备的格式化字符串支持,其核心函数为 std::format。C++20 引入的格式化字符串在形式上借(chao)鉴(xi)了 Python 的格式化字符串形式,因此熟悉 Python 的同学能够很快地开始上手使用 C++20 的格式化字符串设施。
注意:截止本文发布时间(2021年3月7日),所有主流的 C++ 编译器均还没有提供对 std::format 的支持。这也是目前为止唯一一个还没有被任何主流 C++ 编译器支持的 C++20 新特性。

std::format

std::format 是一个函数模板,其原型如下:
template <typename ...Args>
std::string format(std::string_view fmt, const Args&... args);
format 的第一个参数是格式串,剩余的参数是需要格式化的数据对象。在格式化串中,与 Python 类似,可以使用 {} 占位符表示输出串的对应位置由对应的数据对象格式化串进行替代。例如:
std::string name1("Alice");
std::string name2("Bob");
std::format("hello, {} and {}!", name1, name2);  // hello, Alice and Bob!
{} 占位符中可以有一定的带有语法结构的内容,用于控制字符串格式化中的数据对象选择、格式化参数等信息。占位符的语法如下:
placeholder := '{' [index][':' format-spec] '}'
其中,index 是一个整数,用于选择使用哪一个数据对象进行格式化;format-spec 是数据对象类型相关的格式化参数,可以为任意字符串。有关 format-spec 以及如何为自定义类型指定格式化方法的内容将在下一节介绍。
使用 index 可以使多个占位符引用同一个数据对象:
std::string name("Alice");
std::format("hello, {0} and {0}!", name);  // hello, Alice and Alice!
也可以使占位符在格式串中出现的顺序与数据对象在参数中的数据不同:
std::string name1("Alice");
std::string name2("Bob");
std::format("hello, {1} and {0}!", name1, name2);  // hello, Bob and Alice!
需要注意到的是,在格式串中,所有的占位符要么都有 index 参数,要么都没有 index 参数。当所有的占位符都没有 index 参数时,占位符和数据对象将按照先后顺序一一匹配。如果某些占位符有 index 参数而另一些占位符没有 index 参数,则 std::format 函数会在运行时抛出 std::format_error 异常。

std::formatter

std::format 可以支持对自定义的数据类型进行格式化。为做到这一点,C++20 格式化字符串库提供了一个类模板 std::formatte<T, CharT> 专门负责根据格式占位符中的 format-spec 对某一个特定类型 T 的数据进行格式化操作。为了使 std::format 支持对自定义类型进行格式化,只需要定义一个 std::formatter 的特化模板,并实现一定的成员函数即可。STL 中已经为我们定义了整数、浮点数和字符串的 std::formatter 特化模板,因此我们可以直接对这些类型进行格式化。
当特化 std::formatter 模板时, 实现方需要在特化模板类中提供两个实例成员函数供 std::format 进行调用:parse 以及 format 函数。
parse 实例成员函数需要有如下的原型:
typename std::basic_format_parse_context<CharT>::iterator
parse(std::basic_­format_­parse_­context<CharT> pc);
作为函数参数的 pc 对象包含 beginend 两个成员实例函数,分别返回指向 format-spec 字符串头尾的迭代器。parse 函数应该解析 format-spec,并将解析结果存储在 this 对象中,准备后续的数据格式化操作。parse 函数应该尽可能地解析 format-spec,并返回一个迭代器指向 format-spec 的末尾表示解析完毕或者返回一个迭代器指向第一个无法被解析的字符。当 format-spec 不能被成功解析时,parse 可以抛出一个 std::format_error 异常表示错误。
STL 为整数、浮点数和字符串实现的 std::formatter 特化模板能够解析的 format-specpythonformat-spec 一致。有关这些基本类型的 format-spec 的格式信息,请参考 python 的相关文档
format 实例成员函数需要有如下的原型:
typename std::basic_format_context<OutputIt, CharT>::iterator
format(const T& value, std::basic_format_context<OutputIt, CharT> fc);
其中,第一个参数为需要格式化的对象;作为第二个参数的 fc 对象包含一个 out 成员实例函数,该函数返回一个 output iterator 作为格式化操作的输出目标。format 函数应该按照 this 中保存的格式化信息(通过之前的一个 parse 调用解析出),对第一个参数对象进行格式化,并将结果通过 fc.out() 进行输出。输出完毕后,format 应该返回一个迭代器指向输出位置的结尾。

Example

本节将给出一个例子。具体地,我们将定义一个 Fraction 类用于表示一个分数:
struct Fraction {
  explicit Fraction(int dividend, int divider) noexcept
    : dividend(dividend), divider(divider)
  { }

  int dividend;
  int divider;
}; // struct Fraction
我们将通过特化 std::formatter 类模板的方法,定义 Fraction 类的格式化字符串输出。用户可以选择两种方式输出一个 Fraction 类对象:通过 v 格式限定符输出分数的小数表示,或者通过 f 格式限定符输出分数表示。如果用户没有指定格式限定符,那么应该输出分数表示。用例如下:
Fraction frac { 3, 5 };
std::format("{:v}", frac);  // Returns "0.6"
std::format("{:f}", frac);  // Returns "3/5"
std::format("{}", frac);    // Returns "3/5"
首先定义 std::formatter 特化类模板:
namespace std {

template <>
class formatter<Fraction> {
public:
  explicit formatter() noexcept
    : _fmt(OutputFormat::Fractional)
  { }

  typename std::basic_format_parse_context<char>::iterator
  parse(std::basic_­format_­parse_­context<char> pc);

  template <typename OutputIt>
  typename std::basic_format_context<OutputIt, char>::iterator
  format(const Fraction& value, std::basic_format_context<OutputIt, char> fc) const noexcept;

private:
  enum class OutputFormat {
    Value,
    Fractional,
  };

  OutputFormat _fmt;
}; // class formatter<Fraction>

} // namespace std
然后实现 parse 函数:
template <>
typename std::basic_format_parse_context<char>::iterator
formatter<Fraction>::parse(std::basic_­format_­parse_­context<char> pc) {
  if (pc.begin() == pc.end() || *pc.begin() == '}') {
    _fmt = OutputFormat::Fractional;
    return pc.end();
  }
  switch (*pc.begin()) {
    case 'v':
      _fmt = OutputFormat::Value;
      break;
    case 'f':
      _fmt = OutputFormat::Fractional;
      break;
    default:
      throw std::format_error("Invalid format specification");
  }
  return pc.begin() + 1;
}
最后实现 format 函数:
template <>
template <typename OutputIt>
typename std::basic_format_context<OutputIt, char>::iterator
formatter<Fraction>::format(
    const Fraction& value,
    std::basic_format_context<OutputIt, char> fc) const noexcept {
  std::string valueString;
  switch (_fmt) {
    case OutputFormat::Value: {
      auto value = static_cast<double>(value.dividend) / value.divider;
      valueString = std::format("{}", value);
      break;
    }
    case OutputFormat::Fractional:
      valueString = std::format("{}/{}", value.dividend, value.divider);
      break;
  }

  auto output = fc.out();
  for (auto ch : valueString) {
    *output++ = ch;
  }

  return output;
}