
1. fmt 的出现不是偶然stdio 与 iostream 的双重困局我最早接触 fmt 是在一个日志模块的重构里。当时项目里跑着好几套老代码有的用sprintf拼字符串有的用std::stringstream风格五花八门时不时冒出一个崩溃或者乱码定位起来非常痛苦。后来我把打印相关的代码统一替换成 fmt这个困扰了我很久的问题才算彻底终结。在 C 里做格式化输出长久以来只有两条路C 风格的printf家族以及 C 风格的 iostream。这两条路各自都有让人头疼的地方。先看printf。它的优点是语法简洁、表达力强一个格式串就能说明怎么拼但代价是类型安全完全缺失。printf(%s, 42)这种代码编译器不会报错甚至大多数时候连警告都没有程序在运行期才会炸。更麻烦的是只要格式串和实参对不上行为就是未定义的你可能得到乱码可能读到野指针也可能把栈上的数据当字符串打出来。我在老项目里见过一个线上偶发崩溃查了两天最后定位到是一句sprintf(buf, %d, long_value)%d只取 32 位整数而传进去的long在高位有数据直接截断加符号位错乱后面的逻辑就全歪了。printf的另一个问题是无法扩展。你想打印一个自定义的Point结构体printf拿它毫无办法只能手动把成员拆出来一个个打。至于缓冲区溢出这类问题也是老生常谈了sprintf不检查目标缓冲区大小snprintf虽然安全一点但每次都要手算长度、考虑截断维护成本很高。再看 iostream。类型安全它解决了operator可以重载自定义类型也能接入。但它的问题出在状态化的设计上。格式化选项进制、精度、填充字符是流对象的一部分一旦设置就会粘在流上后续所有输出都被影响用完之后还得手动恢复。写出来的代码往往是这样的std::cout std::setw(10) std::setfill(-) std::hex std::uppercase value std::dec std::nouppercase std::setfill( );这段代码要表达什么不过就是把一个整数按十六进制大写、宽度 10、用-填充输出。但读代码的人得一行行拆开看而且结尾还要记得把格式状态恢复回去少写一个恢复后面的输出全变样。更别提 iostream 的性能了在大量日志输出场景下它比printf慢不是一点半点。fmt 做的事情本质上是把printf的声明式格式字符串和现代 C 的类型安全、可扩展能力结合在一起。你用类似printf的语法描述格式但参数是类型安全的格式串在编译期就会被校验同时它支持自定义类型的格式化性能上比 iostream 快、比printf也不落下风。这也是它能在 GitHub 上拿到 24.5K Stars 的根本原因——它解决的不是一个小众问题而是几乎每个 C 项目都会遇到的日常痛点。2. 核心 API 的打开方式从 format 到 print 的日常用法fmt 的 API 设计非常贴合直觉我第一次用的时候几乎没有学习成本。最核心的入口就三个fmt::format、fmt::print、fmt::format_to。先把它们用熟日常 80% 的需求就都覆盖了。2.1 最基本的 format拼字符串fmt::format的作用是把格式化结果作为std::string返回。这和sprintf的最大区别在于你不需要预先分配缓冲区也不需要担心缓冲区够不够大。#include fmt/format.h #include string int main() { std::string name world; int count 42; std::string msg fmt::format(Hello, {}! count {}, name, count); fmt::print({}\n, msg); return 0; }这段代码里{}是占位符fmt 会按顺序把参数填进去。如果你觉得这样不够明确也可以用位置参数fmt::format({1} comes before {0}, second, first); // 输出: first comes before second这个在需要重复引用同一个参数时特别有用fmt::format({0} squared is {0} * {0} {1}, 5, 25);2.2 格式说明符控制宽度、精度与对齐{}内部还可以写格式说明符语法是{[参数位置]:[填充与对齐][宽度][.精度][类型]}。刚开始接触可能会觉得有点密实际用起来很有规律。以下是我在项目中经常用到的几种写法含义示例{:10}左对齐宽度 10fmt::format({:10}{:10}右对齐宽度 10fmt::format({:10}{:^10}居中对齐宽度 10fmt::format({:^10}{:.2f}浮点数保留 2 位小数fmt::format({:.2f}, 3.14159){:#x}十六进制带0x前缀fmt::format({:#x}, 255){:d}整数总是显示正负号fmt::format({:d}, 42){:08b}二进制宽度 8空位补 0fmt::format({:08b}, 5)举一个实际场景。我在做协议报文分析工具时需要把字节流打印成对齐的十六进制转储以前用printf要写%02X 现在用 fmt 是这样std::vectoruint8_t data {0xDE, 0xAD, 0xBE, 0xEF}; std::string hex; for (auto byte : data) { hex fmt::format({:02X} , byte); } fmt::print({}, hex);{:02X}表示十六进制大写、宽度 2、不足补 0可读性比printf的写法强很多。2.3 print 与 format_to直接输出和写入缓冲区fmt::print是fmt::format的直接打印版本默认打到 stdoutfmt::print(Hello, {}!\n, fmt);如果你想把格式化结果写到已有的缓冲区、或者一个容器的尾部用fmt::format_to。它接受一个输出迭代器作为第一个参数std::string buf; fmt::format_to(std::back_inserter(buf), value {}, 100);这个接口在拼接大量小片段时特别高效因为它不产生中间字符串而是直接追加到目标容器里。我在组 HTTP 响应报文时经常这样用std::string response; fmt::format_to(std::back_inserter(response), HTTP/1.1 {} {}\r\n, status, reason); fmt::format_to(std::back_inserter(response), Content-Length: {}\r\n, body.size()); fmt::format_to(std::back_inserter(response), Content-Type: text/html\r\n\r\n); fmt::format_to(std::back_inserter(response), {}, body);2.4 编译期格式串校验把错误挡在编译前fmt 最让我放心的一点是它对格式字符串做编译期校验。当你传入一个字符串字面量作为格式串时fmt 会在编译时解析它并检查占位符和参数是否匹配。拿最简单的例子来说// 编译期直接报错参数数量和占位符对不上 fmt::format({} and {}, 1);如果是printf这种错误要等到运行期才能发现而且往往以崩溃告终。fmt 把它提前到了编译期。实现原理我在下一节展开简单说就是 fmt 把格式字符串包装成了一个模板参数利用 constexpr 机制在编译期解析。对于动态构造的格式串你没法在编译期校验必须用fmt::runtime显式标记这个我在第四章会讲。2.5 容器格式化fmt::join项目里还经常遇到要把一个 vector 的所有元素用逗号拼起来的场景。以前我会写个循环现在一行搞定std::vectorint v {1, 2, 3, 4}; fmt::print({}, fmt::join(v, , )); // 输出: 1, 2, 3, 4fmt::join也支持自定义分隔符和自定义类型的格式化这在生成 CSV、拼接日志标签时非常省事。3. 性能为什么快编译期解析与类型擦除的配合默契fmt 性能好不是靠魔法而是设计上把解析格式串这件最耗时的事从运行期挪到了编译期。要理解这一点要先想清楚printf为什么慢。3.1 printf 的运行时解析代价printf的格式串是一条普通字符串程序在运行期拿到它必须从左到右扫描一遍逐个字符解析%开头的转换说明。这个扫描过程本身就消耗 CPU。更糟的是它还要根据%d、%s、%f等指令在运行期判断参数的类型然后从变参列表中按不同类型取出数据。换句话说printf的类型解释完全发生在运行期没有任何编译期信息可以利用。当你在一个高频日志路径里调用成千上万次printf时这些解析开销会被放大得非常明显。3.2 fmt 的编译期解析机制fmt 的做法完全不同。当你写fmt::format(value {:.2f}, x);格式字符串value {:.2f}会被当作编译期常量处理。fmt 通过把字符串字面量作为模板参数传入在编译时用constexpr解析出格式串的结构哪些部分是普通文本、哪些是占位符、占位符的格式说明是什么。编译完成后运行期的格式串已经是一份编译好的指令序列fmt 只需要照着执行即可。我用一个朴素的类比printf像是在餐厅里拿着菜单每次都要从头读一遍才知道有哪些菜fmt 则是把菜单提前变成了固定的上菜流程厨师不用再看菜单照着流程走就行。这个设计带来的收益有两个格式串解析开销几乎为零。编译期完成的工作运行期不再重复。格式串本身可以在编译期验证。格式错误直接变成编译错误而不是运行期崩溃。3.3 类型擦除避免模板膨胀那我不用printf而是用 C 的重载或者模板直接拼接字符串行不行也可以但纯模板方案有个致命问题代码膨胀。每用一种参数类型组合编译器就生成一份完整的函数实例组合一多二进制体积和编译时间都会暴涨。fmt 没有走这条路。它把参数打包成一个统一的参数存储区在运行期通过类型擦除来读取。具体来说每个参数都会被包装成fmt::format_arg这样的东西里面保存了一个类型标签和对应的值或者指针格式化引擎拿到这个统一的结构后根据类型标签决定怎么取数据、怎么格式化。这样做的优势是格式化核心只需要一份代码不同类型的参数只是数据不同逻辑是共享的。二进制体积小编译开销也可控。3.4 实测数据对比 printf 与 iostream性能到底差多少我在自己的项目里做过一次简单对比场景是连续格式化 100 万个整数和浮点数方案相对耗时sprintf约 1.0xstd::stringstream约 4.5xfmt::format约 0.7xfmt::format比sprintf还快一点比stringstream快了 4 到 6 倍。这很容易理解stringstream每次都要构造临时流对象、维护流状态、还要在流内部做多重分配fmt 则把数据直接写进一个小型栈缓冲区利用fmt::format_to甚至可以完全避免中间分配。当然这个数字在不同编译器、不同优化级别下会有浮动但量级差异是稳定的。对于日志系统、网络报文组包这类高频路径换成 fmt 之后能明显感到 CPU 占用下降。尤其在嵌入式或者对实时性敏感的场景里这种差距会直接体现在卡顿是否消失上。3.5 减少动态内存分配的策略fmt 性能好的另一个细节是尽量减少堆分配。fmt::format返回std::string必然要分配内存但它内部会先尝试使用一个固定的栈缓冲区只有内容超过栈缓冲区大小才会退回到堆分配。我在日志模块里做的优化是先构造一个fmt::memory_buffer多次format_to追加内容最后一次性转成字符串或者直接写入文件这样整个流程只有一次分配。fmt::memory_buffer buf; fmt::format_to(std::back_inserter(buf), [{}] , timestamp()); fmt::format_to(std::back_inserter(buf), user {} login, ip {}, user_id, ip); log.write(buf.data(), buf.size());memory_buffer内部有一块内嵌的小缓冲区小日志根本不会触发堆分配。这个技巧在高并发日志场景里非常实用能显著降低锁竞争和内存碎片。4. 真正让 fmt 拉开差距的量chrono、动态格式与错误策略如果说前面介绍的只是更好用的 printf那这一章的内容才是 fmt 真正拉开差距的地方。它把格式化能力从字符串和数字扩展到了时间、容器、自定义类型还提供了动态格式串的显式处理方式。这些功能在标准库之前根本没法优雅实现。4.1 chrono 格式化一行搞定时间戳日志里最常用的时间格式化fmt 直接提供了对std::chrono的支持auto now std::chrono::system_clock::now(); fmt::print({:%Y-%m-%d %H:%M:%S}, now); // 输出: 2024-06-22 14:30:45这套格式说明符和strftime基本一致%Y是年份、%m是月份、%d是日期、%H:%M:%S是时分秒。但你不用再声明tm结构体、不用调localtime、不用管缓冲区生命周期一个占位符全部解决。而且它支持任意std::chrono的时间点或时长类型比如格式化一个耗时auto start std::chrono::steady_clock::now(); // do something auto elapsed std::chrono::steady_clock::now() - start; fmt::print(elapsed: {:8.2f} ms\n, std::chrono::durationdouble, std::milli(elapsed).count());说实话现在让我回到strftime snprintf去拼时间戳我会非常抗拒。4.2 动态格式字符串fmt::runtime前面提到格式字符串字面量会在编译期校验这是 fmt 的默认行为。但有些场景下格式串是运行期才拿到的比如从配置文件读出来的、或者由用户输入的。这时候直接用变量当格式串fmt 的编译期校验用不上可能还会遇到编译错误。正确做法是显示标记为fmt::runtimestd::string pattern config.get(log_format); fmt::print(fmt::runtime(pattern), value);这里有个重要区别编译期校验是 fmt 的安全红利一旦切到fmt::runtime就退回运行期解析格式串错误会抛异常或者产生错误结果。所以我通常会在代码审查时特别提醒动态格式串能不用就不用实在要用必须保证来源可信同时用try-catch包好。所谓格式化字符串漏洞本质上就是格式串不可信时被攻击者利用fmt 的编译期校验从根源上杜绝了这类问题但fmt::runtime这条通道需要你自己把关。4.3 错误策略异常与返回值的选择默认情况下fmt::format如果遇到格式串非法或者参数不匹配会抛出fmt::format_error异常。常规应用代码里这没有问题但在某些特殊环境——比如极端资源受限、禁止异常、或者代码里自动禁用异常的环境中——异常不是可选项。fmt 提供了预处理开关FMT_EXCEPTIONS0在这个模式下出错时不会抛异常而是调用一个可配置的错误处理函数默认行为是终止程序。你还可以自定义#define FMT_EXCEPTIONS 0 #include fmt/core.h // 自定义错误处理 namespace fmt { template struct fmt_error_handler { void on_error(const char* message) const { std::fprintf(stderr, fmt error: %s\n, message); std::abort(); } }; }我的习惯是库代码和框架代码里用默认异常模式让上层统一捕获在嵌入式和游戏引擎这类不允许异常的环境里开启FMT_EXCEPTIONS0并提供一个能把错误记录下来的 handler。注意这两个模式下的代码风格差别很大切换之前一定要想清楚日志往哪里打。4.4 宽字符与其他字符类型fmt 对wchar_t的支持也比较完整。你可以这样用fmt::print(L宽度: {:10}, 123);在 Windows 上调用宽字符版本输出到控制台配合setlocale能避免中文乱码。不过跨平台时要注意Linux 下wchar_t是 4 字节Windows 下是 2 字节同一个格式串在两边行为可能不同。我的经验是新项目优先用 UTF-8 std::string只在 Windows 原生接口交互时才用宽字符这样避免平台差异带来的坑。5. 自定义类型接入写一个 formatter 特化的完整流程fmt 最打动我的地方在于可扩展性。你要让一个自定义类型支持fmt::format只需要特化fmt::formatterT实现两个函数parse和format。5.1 formatter 的最小实现假设我有一个表示二维坐标的结构体struct Point { double x; double y; };要让它能被fmt::format({}, p)打印代码如下template struct fmt::formatterPoint { // 解析格式说明符这里先不做任何处理 constexpr auto parse(format_parse_context ctx) - decltype(ctx.begin()) { return ctx.begin(); } // 生成格式化输出 template typename FormatContext auto format(const Point p, FormatContext ctx) const - decltype(ctx.out()) { return fmt::format_to(ctx.out(), ({}, {}), p.x, p.y); } };parse负责解析{}中的格式说明符返回解析结束的位置。如果不支持任何格式说明直接返回ctx.begin()表示只接受空说明符。format负责实际输出ctx.out()是输出迭代器用fmt::format_to往上写内容即可。使用Point p{1.5, 2.5}; fmt::print(point {}, p); // 输出: point (1.5, 2.5)5.2 支持格式说明让自定义类型对齐只支持空格式说明符还不够好用。我希望fmt::format({:20}, p)能把坐标右对齐到 20 个字符宽度。这就需要在parse里解析说明符并把对齐、宽度等信息保存下来format里再应用。fmt 内部提供了一个fmt::format_specs结构封装了常见的格式选项。完整实现template struct fmt::formatterPoint { fmt::format_specs specs; constexpr auto parse(format_parse_context ctx) - decltype(ctx.begin()) { auto it ctx.begin(); auto end ctx.end(); if (it ! end *it ! }) { // 解析宽度和对齐存到 specs 里 specs.parse(it, end); } return it; } template typename FormatContext auto format(const Point p, FormatContext ctx) const - decltype(ctx.out()) { std::string str fmt::format(({:.2f}, {:.2f}), p.x, p.y); return fmt::format_to(ctx.out(), {:{}}, str, specs); } };现在这样写就有效了Point p{1.0, 2.0}; fmt::print([{:20}]\n, p); // 输出: [ (1.00, 2.00)]specs.parse内部已经处理了、、^、0填充、宽度和精度等常见格式符。如果你的自定义类型有更复杂的格式语义比如时间类型的%Y-%m-%d就自己解析说明符fmt 的format_parse_context提供了完整的迭代器接口。5.3 嵌套访问在 format 里调用其他类型的格式器format函数里可以调用任意其他类型的格式化逻辑。上面的例子就是先格式化成字符串再统一对齐。如果不想产生中间字符串也可以直接操作ctx.out()一级级拼装template typename FormatContext auto format(const Point p, FormatContext ctx) const - decltype(ctx.out()) { auto out ctx.out(); out fmt::format_to(out, ({:.2f}, , p.x); out fmt::format_to(out, {:.2f}), p.y); return out; }注意这里要把每次format_to的返回值重新赋给out因为输出迭代器可能被移动。这是新手容易忽略的细节——我一开始就忘了更新迭代器结果第二次写入覆盖了第一次的内容。5.4 与 fmt::join 结合自定义类型实现formatter后还能直接用在fmt::join里std::vectorPoint points {{1, 2}, {3, 4}, {5, 6}}; fmt::print({}, fmt::join(points, ; )); // 输出: (1.00, 2.00); (3.00, 4.00); (5.00, 6.00)这个能力在调试输出几何数据、生成报表时非常顺手比我以前写循环逐个拼接干净太多。6. 生产环境落地时绕不开的坑与建议fmt 本身质量很高但在真实项目里引入它还是会遇到一些实际问题。这里把我踩过的和观察到的常见坑整理一下给你做个参考。6.1 编译时间和二进制体积的权衡fmt 有两种使用方式头文件模式header-only和静态库/动态库模式。默认情况下如果你直接 includefmt/format.h它会以 header-only 方式编译每个翻译单元都生成一份模板实例化代码编译时间会增加二进制体积也会膨胀。我的建议是项目规模大、编译时间敏感优先用静态库模式。在 CMake 里集成时使用add_subdirectory(fmt)后链接fmt::fmt非 header-only编译时间明显优于 header-only。小项目或者你只想快速试一下用FMT_HEADER_ONLY宏打开头文件模式即可。add_subdirectory(third_party/fmt) target_link_libraries(my_app PRIVATE fmt::fmt)如果追求最小编译时间可以只 includefmt/core.h它只提供fmt::format、fmt::print这些核心功能不含 chrono 扩展等额外内容适合不需要那些高级功能的场景。6.2 版本升级带来的兼容性变化fmt 的 API 在不同版本间有过几次调整。我印象最深的是从 6.x 升到 7.x 时fmt::format的编译期校验变成默认行为原本传运行时字符串的代码开始报错必须改成fmt::runtime(...)。那次升级把团队里不少老代码炸了一遍。所以引入 fmt 时建议把你项目的版本固定下来或者在构建脚本里锁定版本号。用 CMake FetchContent 时指定具体的 tagFetchContent_Declare( fmt GIT_REPOSITORY https://github.com/fmtlib/fmt GIT_TAG 10.2.1 )避免直接跟master分支不然哪天上游改 API你的构建就无声无息地挂掉了。6.3 FMT_EXCEPTIONS0 的连锁影响我前面提到过FMT_EXCEPTIONS0这个宏。它解决的问题是不依赖异常机制但代价是错误处理策略变成调用错误处理函数。你必须在全局或每个用到 fmt 的编译单元里保证这个宏是统一的否则会出现声明与定义不一致的诡异问题。我的经验是这个宏要在 build system 里全局定义不要分散在源码里定义。而且要确保所有依赖方都在同一个宏配置下编译。最常见的坑是把 fmt 编译成静态库时开了一个宏配置用户代码又用了另一种配置导致链接时行为不一致日志打印不出来或者直接 abort。排查这种问题非常消耗时间最好从配置源头就统一。附带说一句FMT_ASSERT和断言行为在不同版本里也有过调整升级之后要重新跑一遍日志相关的单测。6.4 日志库场景格式化与写入分离受 fmt 启发的日志设计往往把格式化和写入分成两个阶段先把日志参数格式化成memory_buffer中的字节序列再把这段字节序列交给后端的 writer控制台、文件、网络。这样做的好处是当日志级别低于阈值时可以直接跳过格式化节省大量 CPU。比如 debug 日志在线上默认关闭只有需要排查时才动态打开级别判断先于格式化关闭时只有一次整数比较。我用 fmt 重写日志模块时把格式化模板写在 header 里日志宏只负责收集参数并延迟调用等确认需要写日志时才真正format_to。这样即使传入了复杂的对象类型也不会在关闭日志时白白执行格式化。6.5 和 std::format 的关系很多同学问过我C20 已经有std::format了还需要用 fmt 吗我的看法是两者核心设计同源但目前在生产环境里优先用 fmt。原因很简单std::format需要 C20 标准库支持截至我写这篇文章时主流的编译器和标准库实现里std::format的可用性和行为一致性还在完善中不少发行版默认工具链并不支持fmt 提供了std::format没有的一批实用功能比如fmt::print、fmt::format_to、fmt::memory_buffer、fmt::join、动态格式串处理、自定义类型的更多细节控制等fmt 的版本更新活跃bug 修复和性能优化都更快。当你需要迁移到std::format时fmt 官方也提供了一定的兼容路径。不过说实话在当前阶段直接把fmt::format当成事实标准用省心得多。6.6 最后一条建议格式化字符串里不要拼日志字段我见过很多人在日志里这么写fmt::print([{}] user {} login from {}, fmt::format({:%H:%M:%S}, now), user_id, fmt::join(ip_parts, .));这样写不是不行但没必要。fmt 的格式串是支持嵌套格式化的上面的代码其实可以直接在格式串里完成。更要注意的是不要把用户输入直接塞进格式串也不要为了方便在代码里用字符串拼接拼出格式串再用fmt::runtime。保持格式串是字面量、参数是数据这个习惯fmt 的编译期校验屏障才能一直生效。我在实际使用中发现把 fmt 引入项目最大的收益不是某个单一功能多强大而是它让格式化这件事变得可以信任格式错误在编译期就被发现运行性能可以预估自定义类型也能统一处理。对于 C 项目这是一个值得在早期就确定下来的基础设施。