
写 C 的老开发者基本都有一段从 printf 到 iostream 再到五花八门格式化方案的记忆。我过去处理日志、导出配置文本、拼写 SQL 参数占位符全靠在 snprintf 周围打补丁。直到某次把底层日志模块整层重写顺手换成 C fmt库 做格式化才发现自己以前踩的坑有一大半根本没必要。这篇不算官方文档复读是我从实际迁移、性能对比、生产环境排障里整理出来的 C fmt库 实战笔记重点讲清楚它凭什么成为更高效率的新选择、核心 API 怎么用、性能到底行不行、哪些坑最容易踩以及和 C20/23 标准 format 该怎么取舍。适合正在纠结要不要引入第三方格式化库的人也适合已经引入但想摸清底层逻辑的人。1. 折腾多年后我为什么把 snprintf 和 ostringstream 都请出了日志模块1.1 snprintf 的类型盲区与缓冲区噩梦老代码里最常见的日志行十有八九是这样的char buf[256]; snprintf(buf, sizeof(buf), id%d code%04x cost%.2f, id, code, cost);乍看没有任何问题但只要有一天id从 int 改成 uint64_t或者code的计算结果换了个类型%d 和实际参数对不上轻则打出乱码重则在某些平台上直接段错误。printf 家族的所有格式化都是运行时解析格式串和参数类型之间没有任何联系编译器只能干瞪眼。我在一个模拟项目X 里排查过诡异的线上日志某一行的设备标识偶尔显示成负数最后发现是 %u 和参数实际类型对不上这类不受约束对应关系引发的未定义行为可以安静潜伏很久。缓冲区更是常年隐患。snprintf 虽然把目标大小传进函数能避免溢出但截断时并不会告诉调用方内容被扔了一半。日志管道分析接到的数据经常无端多出奇怪的换行就是因为缓冲区太小把多行日志截在中间改大缓冲区又心疼栈上空间。这类问题不是细心一点就能消灭的只要格式串和参数类型系统不发生关联问题就会持续产生。后来我统计过日志模块里因为 format 串和参数不匹配修过的 bug占了该模块全部 bug 的接近一半。1.2 ostringstream 不是不行是太啰嗦后来很多人转向 ostringstream就是因为它类型安全不管 int、double、string 还是自定义对象只要重载了operator就能串起来不会出现 sprintf 那种类型错配。可代价是语法太啰嗦std::ostringstream oss; oss id id code std::hex std::setw(4) std::setfill(0) code cost std::fixed std::setprecision(2) cost; std::string log oss.str();为了把 id、code、cost 拼成一行日志要写一长串中间想控制对齐或者精度得插入 setw、setprecision、hex 这些流操纵符而且这些操纵符有粘性一旦设置了还会影响后面所有插入。我代码里没少因为 setfill 忘了复位导致表格对齐错乱。更别说日志要加个时间前缀和线程号时整段代码完全是噪音。慢也是个现实问题。ostringstream 内部通过流缓冲区不断构造临时对象分配次数远高于其他方案。在我本地的压测里同样一条日志用 ostringstream 拼接耗时比 snprintf 高出一倍还多高频日志路径上这就是白白烧 CPU。注意我没有说 ostringstream 一无是处在写少量、类型复杂的代码时它仍然顺手但把它当成通用日志格式化工具来用成本确实偏高。1.3 fmt 改了什么玩法用 fmt 之后等于把格式字符串语法和类型安全合并成一件事fmt::format({:04d}, 255)写起来和 Python 的 format 很像但背后会在编译期对字面量格式串做解析和校验。参数类型不匹配、花括号数量不对会在编译阶段直接报错而不是运行到线上才爆雷。对于我这种被 printf 坑过很多次的人来说这已经足够成为把日志模块翻新一版的核心理由。更关键的是它的输出结果和 ostringstream 一样是标准字符串不需要继续维护 char 缓冲区的大小也不会有截断后静默丢数据的风险。真正上手之后你会发现从格式化这个动作里省下的心智可以全部投入到日志内容的组织上这个体验层面的差别很难从文档里看出来。2. 高频 API 和格式化语法一次看懂五个入口2.1 五个函数先说清楚fmt 库的 API 表面上看不止一个但只要你掌握了下面五个入口绝大多数格式化需求都能覆盖#include fmt/format.h #include fmt/core.h // 返回 std::string std::string s1 fmt::format({} 和 {}, 1, 2); // 直接输出到标准输出 fmt::print(Hello, {}!\n, world); // 写到容器的尾迭代器 std::vectorchar v; fmt::format_to(std::back_inserter(v), {} {}, a, 1); // 限定写入长度适合固定缓冲区 char buf[16]; auto r fmt::format_to_n(buf, sizeof(buf), {:8.2f}, 3.14159); // 只算格式化后的长度不落地 size_t sz fmt::formatted_size({} {}, 1, 2);我的习惯是需要返回字符串时优先fmt::format只是打日志、扫一眼结果时用fmt::print性能敏感的循环里用fmt::format_to配合复用缓冲对接老代码里的固定 char 数组时用fmt::format_to_n既不会溢出又能在写完后检查r.size判断有没有被截断。formatted_size看着鸡肋但你在估算某条消息是否超出协议最大长度时能省一次真实分配。2.2 替换字段的完整语法fmt 的占位符语法比 printf 直观得多总结起来就是可选序号、可选名字、冒号后面跟格式说明符。// 序号同一个参数多次引用 fmt::print({0} {1} {0}\n, a, b); // 命名参数可读性更强 fmt::print({name} 的分数 {score}\n, fmt::arg(name, 小明), fmt::arg(score, 95)); // 对齐与宽度 fmt::print([{:8}] 左对齐[:{:8}] 居中[:{:^8}]\n, x, x, x); // 精度与进制 fmt::print({:.3e} 十六进制 {:#06x}\n, 3.14159, 255);格式说明符的完整规则可以查表但日常记住这几类就够用说明符示例含义{:d}/{:x}/{:b}十进制 / 十六进制 / 二进制{:10}/{:10}/{:^10}右对齐 / 左对齐 / 居中宽度 10{:.2f}/{:.3e}保留两位小数 / 科学计数法保留三位{:04d}宽度 4左侧补 0{:%Y-%m-%d}chrono 时间格式化{:L}用 locale 方式输出分组符号我第一次用{:8}对齐日志列时第一反应是终于不用记住 setw 和 setfill 的先后顺序了。把格式说明符写在占位符内部修改格式时改动局部、不影响相邻输出这比流操纵符那种全局状态的设计安全太多。2.3 自定义结构体的格式化很多项目里自定义类型格式化都是靠to_string()或者重载operator临时拼一个字符串传进去这等于又绕回了先拼好再交给格式化的老路。fmt 的正规做法是特化fmt::formatter#include fmt/format.h struct Point { int x; int y; }; template struct fmt::formatterPoint { constexpr auto parse(format_parse_context ctx) { return ctx.begin(); } auto format(const Point p, format_context ctx) const { return fmt::format_to(ctx.out(), ({}, {}), p.x, p.y); } }; std::string s fmt::format(point {}, Point{1, 2});这样Point就能直接用作格式化参数并且仍然享受类型安全。实际项目中解析 Json 节点、错误码、网络地址这类对象我都建议用 formatter 特化而不是到处手动拼接字符串。改一次全项目所有fmt::format调用点都能生效这种收益随着项目变大越来越明显。3. 性能不是玄学一千万次格式化的实验数据3.1 我的测试方案与相对耗时网上关于 fmt 性能的讨论很多但大部分是官方 benchmark 截图。我在自己的模拟项目X 里做了一组朴素对照循环一千万次格式化同样的日志行id{} code{:04x} cost{:.2f}分别用 snprintf、ostringstream、fmt::format运行时字符串、fmt::format编译期字符串四种方案编译器开 -O2取多次运行中的稳定值。方案相对耗时越小越快snprintf 组装日志行1.0std::ostringstream 拼接2.6fmt::format 运行时字符串1.3fmt::format 编译期字符串0.9先强调一句这个相对值依赖参数类型和格式复杂度换台机器、换一轮数据结果会变但趋势是稳定的。fmt 即便走运行时字符串路径也比 ostringstream 快一倍左右一旦格式串在编译期可见甚至可以反超 snprintf。这打破了不少人底层 printf 一定最快的固有印象。3.2 编译期字符串省掉了哪段运行时开销为什么编译期字符串能快半个身位核心原因在于 snprintf 必须在运行时逐字符解析 %d、%04x 之类的转换说明然后才进入真正的类型转换fmt 的编译期路径格式串在编译时就被拆成一段段指令运行时直接按既定顺序取出参数、执行转换省掉的是整个解析循环。你可以把它理解成printf 每次进场都现场研究菜谱再炒菜fmt 的编译期路径是后厨早就把流程刻烟吸肺了客人点单后直接开炒。格式串越长、字段越多这个差距越明显。// C17 阶段可以显式声明编译期字面量 std::string a fmt::format(FMT_STRING({} {:04x}), 1, 255); // 如果编译器支持 C20fmt::format 的字面量默认就走编译期校验 std::string b fmt::format({} {:04x}, 1, 255);3.3 三个让格式化更快更稳的配置姿势第一把能写成字面量的格式串全部写成字面量。配置来的格式串改用fmt::runtime显式包一层避免误走编译期解析导致编译失败字面量则尽量都不加runtime让编译器替你做检查。第二循环里别反复构造字符串。比如批量导出记录时可以复用同一个std::string对象用fmt::format_to(std::back_inserter(str), ...)向它追加。每次fmt::format的返回值都是新分配的堆内存循环次数一上来分配器的压力不可忽略。第三日志模块里先判断级别再格式化。这是最容易被忽略的LOG(INFO) fmt::format(...)里哪怕日志级别不输出格式化动作也已经执行了。正确的姿势是先判断当前级别是否开启再决定要不要调用格式化函数。这个优化跟 fmt 本身无关但能省下比任何库级优化都多的 CPU。4. 装进项目之前集成方式与版本管理经验4.1 Header-only 和编译库怎么选fmt 两种模式都很成熟Header-only 模式只要在包含头文件之前定义FMT_HEADER_ONLY老项目最省事编译模式则要先把库构建出来再用链接器绑上。维度Header-only编译模式接入成本低复制头文件即可需要构建/安装依赖编译耗时每个 TU 都编译一遍 fmt 代码较慢只编译一次全项目编译更省二进制体积可能多处内联稍大更小更聚合ODR 风险稍高低我的建议很简单小工具、单文件项目用 Header-only正经的多模块工程一定要用编译模式。你大概率不想每次全量编译都等 fmt 的模板代码在几百个 TU 里滚一遍。4.2 CMake 里的两种接法用 CMake 的项目两种标准接法分别是源码子目录和系统包查找# 方式一把 fmt 源码放在 third_party/fmt然后加子目录 add_subdirectory(third_party/fmt) target_link_libraries(your_target PRIVATE fmt::fmt) # 方式二系统已安装 fmt 库用 find_package find_package(fmt REQUIRED) target_link_libraries(your_target PRIVATE fmt::fmt)如果你的项目里有大量 C 代码依赖 fmt我强烈建议把版本固定住不要直接拉最新主干。fmt 的 API 演进速度不慢隔几个大版本就会出现破坏性变化今天写fmt::format的代码明天可能因为默认编译期检查策略调整在 CI 里突然编译失败。固定版本之后升级的节奏由你控制而不是由上游偷偷决定。4.3 版本升级时的三件必做事升级 fmt 版本时我固定做三件事。第一先看变更日志重点找 format 语法解析行为的变化这类改动往往是静默的第二全局编译一下把编译期报出的所有格式串错误全部修掉千万不要用#pragma去屏蔽第三跑一遍日志相关的回归测试确认异常输出、宽字符、时长格式化这些容易受实现影响的场景没变。经验之谈一次升级不要跨太多大版本尤其是你手上有大量自定义 formatter 的时候。小步升级出问题容易定位一次跨两三年的版本报错信息和旧代码的对应关系会让人抓狂。5. 生产环境里那些看似正常的格式化坑5.1 花括号转义和嵌套坑fmt 的占位符用{}包着所以字面量里的花括号需要转义这几乎是最经典的入门坑// 想打印一个字面量 {} fmt::print({}: {}\n, 错误, 500); // 正常 fmt::print({} \n, {}); // 运行时/编译期直接报错 fmt::print({{}} \n, {}); // 双写花括号才能输出字面量 {}在动态拼接格式串的场景下花括号数量很难用肉眼数对我见过因为少写一个花括号导致整条配置解析失败报错信息还不是特别直观。我的习惯是凡是格式串里需要带大括号的先单独写一条测试用例验证转义结果再接入正式逻辑。5.2 宽字符、locale 与跨平台编码fmt 默认不启用 locale这个设计我很喜欢同一个格式串在不同机器上输出稳定不会因为系统 locale 变化导致小数点和千分位垮掉。如果你需要带千分符用{:L}主动开启。真正容易踩的是宽字符输出Windows 控制台下用fmt::print输出中文字符时如果代码页不是 UTF-8会出现乱码。这时候要么先把控制台代码页切到 UTF-8要么用fmt::to_wstring或fmt::format(L...)这类宽字符入口。宽字符和窄字符混用时不要隐式转换fmt::format({} {}, LA, B)这种混写很可能编译不过或者输出不是你想要的。编码问题的通用排查思路是确认源文件编码、确认格式串字面量编码、确认输出通道编码三者一致才能保证结果可控。5.3 并发日志下的线程安全备忘fmt 本身的格式化操作是线程安全的每个调用不共享可变全局状态这一点比strtok那类函数安全得多。但注意fmt::print输出到标准输出时多个线程同时调用并不能保证输出内容不交错。实际日志系统里最终写出文件或标准输出前还是要用互斥锁串行化完整日志行最好把格式化结果先放进std::string锁内再写一次避免锁粒度太粗拖慢整个系统。另一个建议是日志线程从线程池里复用时尽量复用各自线程的字符串缓冲。这样既减少分配次数也避免了每条日志都走一遍fmt::format返回临时对象的路径。5.4 把 fmt 塞进旧日志接口的黏合层老项目里日志接口大多长这样Log(level, format, ...)且带着 C 风格变参。想逐步替换比较稳的做法是包一层薄壳#include fmt/core.h namespace mylog { template typename... Args std::string Format(std::string_view fmt_str, const Args... args) { // 格式串可能来自拼接或配置明确告诉 fmt 按运行时字符串处理 return fmt::format(fmt::runtime(fmt_str), args...); } } // namespace mylog注意这里必须用fmt::runtime包一层。fmt 较新版本在 C20 下默认把fmt::format的字符串参数当做编译期字面量来校验传入一个非字面量变量会导致编译失败fmt::runtime就是用来显式告知这个格式串是运行时动态的请跳过编译期检查。我认识的不少同事第一次升级 fmt 后碰到这种编译报错第一反应都是库坏了其实只是 API 语义变了。6. 已经站在 C20/23 门口std::format 迁移笔记6.1 fmt 和 std::format 到底差在哪随着 C20 标准把std::format纳入语言很多人开始问那我是不是可以直接抛弃 fmt 了我的答案是看场景。fmt 和标准 format 的语法同源迁移成本很低但它们现阶段并不同等。维度fmt 库std::format可用性C11/14/17 项目都能用需要 C20且不同标准库实现差异大编译期校验字面量或 FMT_STRING 触发对字面量内置输出函数fmt::print / fmt::printlnstd::print 需要 C23支持不统一扩展能力join、styled、自定义 formatter 生态成熟标准演进更保守版本自由度可控可固定跟随编译器/标准库版本如果你的项目还停留在 C17用 fmt 是合理选择语法跟未来标准保持兼容如果已经全面 C20并且标准库实现成熟那std::format足够覆盖大部分日常需求。6.2 迁移时编译器最先拦下来的三类改动第一类是 include 和命名空间fmt/format.h变formatfmt::变std::这属于机械替换。第二类是函数形态fmt::print到标准库没有完全对应std::print在不少编译器实现里还没补齐迁移前先确认目标环境的支持程度。第三类是错误行为的细微差别fmt 的format_error异常类型和std::format_error名称不同catch 逻辑要跟着改运行时格式串由fmt::runtime包一层改为std::vformat配合std::make_format_args这是我迁移时最容易被编译器提醒的部分。#include format namespace mylog { template typename... Args std::string Format(std::string_view fmt_str, const Args... args) { return std::vformat(fmt_str, std::make_format_args(args...)); } } // namespace mylog6.3 我的双轨并行策略在实际项目里我并没有做某天把 fmt 全部删除的大动作而是维持一个统一格式化外壳内部实现随语言标准切换。这样上层调用完全不感知底层换了库风险被限制在一个很小的模块内。把这套思路总结成一句项目代码能保持整洁往往不是因为用了多高级的库而是把用来格式化的代码收敛在一个接口后面。如果你正打算在两个方案之间选一个我给的建议是先小范围用 fmt 跑通一个高频日志模块体会编译期校验、自定义 formatter、性能调优这些环节再根据手头编译器的标准库支持度决定要不要切std::format。fmt 给我带来的最大价值不是比别人快一点而是让格式化这个动作从小心翼翼地拼字符串变成编译期就帮我兜好底的安全设施这种踏实感经历过 printf 类型错配的人都懂。