【C++三方组件】fmt:C++字符串格式化的前世今生
【摘要】:printf 类型不安全,iostream 又慢又绕;fmt 用
{}加变参模板给出答案,好到 C++20 收编为std::format、C++23 收编为std::print。How 实测基础与规格、具名参数、容器与 chrono、format_to、与 std::format 同源对照(逐字节一致)、四方案性能自测(fmt 最快)。Why 拆三问:凭什么比 printf 安全又快、进了标准为什么还要 fmt、v12 移除fmt::localtime说明什么。【关键词】:fmt、格式化、std::format、类型安全、编译期检查
【版本基准】:{fmt} 12.2.0(MIT)|C++20(对照实验)|文中输出均为 g++ 13.1 实测
1. What:好到被标准收编
老问题:把变量拼进字符串,你只有两个都不称职的选项------printf 快但类型不安全(%d 配字符串,编译过、运行崩);iostream 类型安全但冗长、慢、还拖着 locale。{fmt} 的作者 Victor Zverovich 2012 年起给出第三个选项:{} 占位符 + 变参模板,安全、快、可扩展。
这个设计好到什么程度?标准委员会直接收编 :C++20 的 std::format 以 {fmt} 的语法为蓝本(作者本人参与提案 P0645),C++23 又收编了 std::print。于是今天的格局是:{fmt} 是先行者与超集,std::format 是标准化子集------本篇的一个实测亮点,就是同一格式串在两边跑出逐字节一致的输出。
2. 项目接入
json
// vcpkg:vcpkg.json
{ "dependencies": [ "fmt" ] }
cmake
find_package(fmt CONFIG REQUIRED)
target_link_libraries(app PRIVATE fmt::fmt)
还有一条零构建的路:{fmt} 支持纯头文件模式 ,#define FMT_HEADER_ONLY 1 后只需 include 路径,不链任何库(本篇全部示例即此模式)。快速原型与教学场景,这是最快的路径。
3. 核心概念:格式字符串是编译期接口
fmt 的世界观一句话:格式字符串不是运行期数据,是编译期接口 。fmt::print("{:>8.2f}", x) 里的规格在编译期被解析校验------类型对不上、规格写错,直接编译错误,而不是运行期的 UB 或崩溃。三个支柱:
| 支柱 | 含义 |
|---|---|
{} 占位 + 规格语法 |
{0} 位置、{:>10} 对齐宽度、{:.2f} 精度 |
| 变参模板转发 | 类型信息全程保留------printf 的 % 猜类型消失 |
formatter<T> 特化 |
任何自定义类型都能接入这套语法(可扩展性) |
对照一下三种时代的写法,气质立现:
cpp
printf("%s v%d.%d\n", name, major, minor); // 靠人肉对表
std::cout << name << " v" << major << "."; // 串珠子
fmt::print("{} v{}.{}\n", name, major, minor); // 声明式
4. How:六组 API(实测)
基础与规格------对齐、宽度、精度、符号补零、动态宽度一应俱全:
cpp
fmt::print("{0} {1} {0}\n", "abra", "cadabra");
fmt::print("[{:>10}]\n", "right"); // 右对齐宽 10
fmt::print("[{:.2f}]\n", 3.14159); // 精度
fmt::print("[{:+08.3f}]\n", 42.5); // 符号+补零
fmt::print("[{:{}}]\n", "dyn", 8); // 动态宽度
实测输出:
text
abra cadabra abra
[ right]
[3.14]
[+042.500]
[dyn ]
具名参数------配置化日志的救星:
cpp
fmt::print("host={host} port={port}\n",
fmt::arg("host", "127.0.0.1"),
fmt::arg("port", 8080));
扩展头 ------容器与时间直接格式化(fmt/ranges.h、fmt/chrono.h):
cpp
std::vector<int> v{1, 2, 3};
std::map<std::string, int> m{{"a", 1}, {"b", 2}};
fmt::print("vec={} map={}\n", v, m);
using namespace std::chrono_literals;
fmt::print("dur={}\n", 9'876'543ms);
// 实测:vec=[1, 2, 3] map={"a": 1, "b": 2}
// dur=9876543ms
自定义类型 ------formatter<T> 特化把自家类型接进同一套语法:
cpp
struct Point { int x, y; };
template <> struct fmt::formatter<Point> {
constexpr auto parse(fmt::format_parse_context& ctx) {
return ctx.begin(); // 不消费任何规格
}
auto format(const Point& p,
fmt::format_context& ctx) const {
return fmt::format_to(ctx.out(),
"({}, {})", p.x, p.y);
}
};
fmt::print("{}\n", Point{3, 4}); // 实测:(3, 4)
// fmt::print("{:>12}\n", Point{3, 4});
// ❌ 编译错误:parse 未声明接受规格------
// 编译期校验连自定义类型都管(实测踩到)
format_to 家族------写入调用者的缓冲,跳过 std::string 分配:
cpp
char buf[64];
auto it = fmt::format_to(buf, "{}!", "fmt");
*it = '\0'; // 返回结束位置
auto r = fmt::format_to_n(buf, 5, "{}", 1234567);
// r.size = 7:被截断也会告知完整长度
与 std::format 同源对照(本篇的核心实验):
cpp
std::string a = fmt::format("{:>8.2f}%", 97.5);
std::string b = std::format("{:>8.2f}%", 97.5);
// 实测:fmt= 97.50% std= 97.50% same=1
性能自测 (20 万次 "i={} x={:.3f}",g++ 13.1 -O2):
| 方案 | 耗时 | 相对 |
|---|---|---|
| fmt::format | 32.4 ms | 1.0× |
| std::format | 45.8 ms | 1.4× |
| snprintf | 69.1 ms | 2.1× |
| ostringstream | 224.5 ms | 6.9× |
fmt 快于 snprintf 本身就是反直觉的实据;对 ostringstream 是数量级优势。
5. Why:三个追问
① 凭什么比 printf 又安全又快? 安全来自编译期:类型由模板推导,规格由 consteval 解析(C++20 下 fmt::print("{:d}", "str") 直接编译失败);快来自三件事------默认与 locale 无关 (printf/iostream 的小数点、千分位随全局 locale 漂移,fmt 的输出跨机器恒定,这本身就是确定性哲学的一部分)、浮点用 Dragonbox 最短表示算法(官方口径快于 printf 家族,12.2 起默认开全量查找表再提速 10~25%)、输出直写缓冲。安全与快不矛盾,都来自「把工作前移到编译期、把不确定性挡在运行期外」。
② 进了标准为什么还要 fmt? 因为标准收编的是子集 :颜色输出(fmt/color.h)、printf 兼容层、文件输出(fmt/os.h)、更宽的编译器矩阵(C++11 起步,std::format 要 C++20,GCC 13 / MSVC 2019 19.29 才齐)都还在 fmt 这边。决策很简单:项目能上 C++20 且只需要基础能力,用 std::format;其余情况(C++17、要颜色/兼容层/极致性能)用 fmt------两者语法同源,迁移近乎零成本。
③ v12 移除 fmt::localtime 说明什么? 本篇实测亲身踩到:按旧教程写 fmt::localtime(now),v12 直接编译错误------时区相关 API 已移除,推荐 std::localtime。教训有两条:fmt 的版本迁移成本真实存在(破坏性变更会清退旧 API,跟随 ChangeLog 升级);「标准库有的,库就让位」是它的一贯哲学------这也是先行者与标准共存的长期姿态。
6. 坑与最佳实践(实测依据)
- 版本敏感 :跨大版本升级先读
ChangeLog.md(v12 连核心头都从core.h改成了base.h)。 - C++17 下的编译期检查 要显式
FMT_STRING("...");C++20 起字面量自动检查,别再裹一层。 - 热路径用
format_to+ 复用缓冲 (如fmt::memory_buffer),跳过字符串分配。 - 自定义类型 走
fmt::formatter<T>特化,别重载运算符老三样。 - header-only 与编译库二选一:全工程统一,混用会双份符号。
- 编码立场 :fmt 把字符串按字节透传,UTF-8 内容原样输出、无需转码;宽字符走
wchar_t重载。跨编码环境的坑在下一篇 utfcpp 展开。
7. 选型对比
| {fmt} | std::format(C++20+) | printf 家族 | iostream | |
|---|---|---|---|---|
| 类型安全 | ✅ 编译期 | ✅ 编译期 | ❌ | ✅ |
| 性能 | 最快(自测) | 次之 | 中 | 最慢 |
| 标准/依赖 | 三方(MIT) | 标准库 | 标准库 | 标准库 |
| 特性 | 颜色/chrono/ranges 超集 | 基础 | 弱 | 弱 |
一句话:有 fmt 可用就用 fmt,标准锁定 C++20+ 且要求零依赖时用 std::format;printf 留给 C 接口边界,iostream 建议功成身退。
8. 延伸与联动
- 官方文档 fmt.dev------API 与语法权威,
ChangeLog.md是升级必读; - cppreference 的 std::format 页面可直接对照本篇 §4 的同源实验;
- 文本处理部分继续:下一篇 RE2------「性能可预测」在正则世界的化身,一个把安全做成核心卖点的引擎。〔关联 第 11 篇〕
参考 :fmtlib/fmt 12.2.0(MIT)。文中格式化输出、std::format 对照(same=1)、性能自测(g++ 13.1 -O2,20 万次)均为本机实测;Dragonbox 提速口径引自官方 ChangeLog。