一、痛点引入:C 风格格式化为何令人头疼
C++ 中使用 printf 系列函数已有数十年历史,但它们的问题足够写一本"血泪史":
// 类型不匹配 → 未定义行为
printf("Value: %d", 3.14); // 编译通过,运行时崩溃
// 参数数量不匹配 → 栈损坏
printf("Name: %s, Age: %d", "Alice"); // 缺少参数,读取栈上垃圾
// 格式符与参数类型对不上 → 安全漏洞
printf(user_input); // 格式字符串攻击的温床
核心矛盾在于:printf 的类型检查完全依赖运行时解析格式字符串,编译器无法介入。iostream 的类型安全有了,但语法冗长、不可组合、国际化困难、性能也未必最优:
// iostream 的冗长噩梦
std::cout << std::setprecision(3) << std::fixed << std::setw(10)
<< std::setfill('0') << value << std::endl;
Python 的 f-string、Rust 的 format! 宏都已实现"编译时检查 + 运行时高效"的范式,C++ 社区需要一个现代化的答案——{fmt} 正是这个答案。
二、{fmt} 核心优势(为何选择它)
| 维度 | printf | iostream | {fmt} / C++20 std::format |
| 类型安全 | 否(运行时解析) | 是 | 是(编译期检查) |
| 编译期格式校验 | 否 | — | 是 |
| 可扩展性 | 难(需修改 printf 实现) | 需重载 << | 特化 formatter 即可 |
| 性能 | 快 | 慢(虚函数/本地化/同步) | 通常比 printf 更快 |
| 语法简洁性 | 一般 | 差 | 极佳(类 Python f-string) |
| 二进制体积 | 小 | 大 | 可控(header-only 可选) |
| Unicode 支持 | 弱 | 弱 | 原生 UTF-8 支持 |
| 编译速度 | 快 | 慢(模板膨胀) | 良好(概念约束减少实例化) |
三、使用场景
3.1 日志系统
{fmt} 是 spdlog 的底层引擎,几乎所有现代 C++ 日志库都基于它:
spdlog::info("User {} logged in from {}", username, ip_address);
3.2 序列化与协议构造
JSON/Protobuf/自定义协议的字符串化,需要高性能、类型安全的格式化:
std::string json = fmt::format(
R"({{"id":{},"name":"{}","score":{:.2f}}})",
user_id, user_name, score
);
3.3 嵌入式系统
头文件模式(FMT_HEADER_ONLY)无需链接,适合资源受限环境:
#define FMT_HEADER_ONLY
#include <fmt/format.h>
char buf[128];
fmt::format_to(buf, "Sensor {}: {:.1f}C", id, temp);
3.4 国际化(i18n)
配合 gettext 等方案实现运行时语言切换:
// 编译期检查格式,运行时可替换文本
fmt::print(fmt::runtime(_("Hello, {}!")), name);
3.5 编译期字符串验证(C++20)
// 编译期就能捕获格式错误
constexpr auto msg = std::format("{} + {} = {}", 1, 2, 3);
// 格式字符串错误 → 编译失败,而非运行时炸掉
// std::format("{:d}", "hello"); // ❌ 编译错误!
四、底层实现原理
4.1 整体架构
4.2 编译期格式字符串解析(核心机制)
这是 {fmt} 最精妙的设计。从 C++20 开始,consteval 使得格式字符串可以在编译期被完整解析:
// fmt/core.h 简化示意
template <typename... Args>
struct format_string {
// ⭐ consteval: 强制编译期求值
template <typename T>
consteval format_string(const T& str) {
auto data = str; // 字符串字面量
// 编译期解析每一个 {} 占位符
parse_format_string(data);
}
};
// 编译期遍历字符串,解析格式占位符
consteval void parse_format_string(string_view fmt) {
for (size_t i = 0; i < fmt.size(); ++i) {
if (fmt[i] == '{') {
// 提取格式参数 ID / 宽度 / 精度 / 类型说明符
// 与对应的 Args 进行类型匹配检查
parse_replacement_field(fmt, i);
}
}
}
关键点:consteval 保证格式字符串在编译期被完整解析。如果格式字符串中有任何语法错误或参数类型不匹配,编译器会直接报错——而不是等到运行时。
4.3 自定义类型的 formatter 特化
{fmt} 的类型扩展机制通过 formatter<T> 模板特化实现:
// 内置整型 formatter 简化实现
template <>
struct formatter<int> {
// 编译期解析格式规格(如 {:04x} → 十六进制、宽度 4、填充 0)
constexpr auto parse(format_parse_context& ctx) {
auto it = ctx.begin();
// 解析填充字符
if (*it == '0') { spec_.fill = '0'; ++it; }
// 解析宽度
while (isdigit(*it)) { spec_.width = spec_.width * 10 + (*it - '0'); ++it; }
// 解析类型说明符
if (*it != '}') { spec_.type = *it; ++it; }
return it;
}
// 运行时格式化
auto format(int value, format_context& ctx) const {
char buffer[32];
// 根据 spec_ 将整数值格式化为字符串
auto result = format_int_to_buffer(value, spec_, buffer);
return ctx.out(); // 写入输出缓冲区
}
format_specs spec_;
};
4.4 参数类型安全检查
{fmt} 通过模板参数推导(CTAD)与 static_assert 实现编译期类型校验:
template <typename... Args>
auto format(format_string<Args...> fmt, Args&&... args) -> std::string {
// ⬆ 格式字符串的模板参数 Args... 约束了参数类型
// 如果传入的参数与格式字符串要求的类型不匹配,编译期直接报错
}
示例:
fmt::format("{:d}", "hello");
// ❌ 编译错误: static assertion failed:
// "Cannot format an argument. You might be missing a formatter specialization."
4.5 高性能的秘密
{fmt} 的性能优势来自几个关键设计决策:
| 优化策略 | 说明 | 效果 |
| 编译期格式解析 | 运行时无需解析格式字符串 | 零格式解析开销 |
| 写时复制输出缓冲区 | fmt::memory_buffer 避免频繁分配 | 减少内存分配 |
| 内联整数格式化 | format_int 使用查表法 itoa | 比 snprintf 快 3~5x |
| SBO (Small Buffer Optimization) | 小字符串栈上分配 | 避免堆分配 |
| 编译期分支消除 | if constexpr + 概念约束 | 减少死代码 |
五、实践指南
5.1 快速上手
安装(vcpkg / Conan / CMake FetchContent 三选一):
# vcpkg
vcpkg install fmt
# CMake FetchContent
include(FetchContent)
FetchContent_Declare(
fmt
GIT_REPOSITORY https://github.com/fmtlib/fmt.git
GIT_TAG 11.0.0
)
FetchContent_MakeAvailable(fmt)
target_link_libraries(my_project PRIVATE fmt::fmt)
基础用法:
#include <fmt/format.h>
#include <fmt/ranges.h> // 容器格式化支持
#include <fmt/chrono.h> // 时间格式化支持
std::string s = fmt::format("The answer is {}.", 42);
// → "The answer is 42."
fmt::print("Hello, {}!\n", "world");
// → 输出 "Hello, world!" 到 stdout
5.2 格式规格速查
// 对齐与填充
fmt::format("{:*<30}", "left"); // "left**************************"
fmt::format("{:*>30}", "right"); // "*************************right"
fmt::format("{:*^30}", "center"); // "************center************"
// 数字格式化
fmt::format("{:d}", 42); // "42" 十进制
fmt::format("{:#x}", 42); // "0x2a" 十六进制带前缀
fmt::format("{:#o}", 42); // "052" 八进制带前缀
fmt::format("{:010d}", 42); // "0000000042" 零填充
// 浮点数
fmt::format("{:.2f}", 3.14159); // "3.14" 保留 2 位小数
fmt::format("{:e}", 1000.0); // "1.000000e+03" 科学计数法
// 字符串截断
fmt::format("{:.5}", "Hello World"); // "Hello" 截取前 5 字符
5.3 自定义类型格式化
struct Point {
double x, y;
};
// 只需特化 formatter
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(), "({:.2f}, {:.2f})", p.x, p.y);
}
};
// 使用
Point p{3.14159, 2.71828};
fmt::print("Point: {}\n", p);
// → "Point: (3.14, 2.72)"
5.4 输出到自定义缓冲区
// 输出到 std::string
std::string s = fmt::format("Formatted: {}", value);
// 输出到已有缓冲区的末尾
std::string buffer;
buffer.reserve(256);
fmt::format_to(std::back_inserter(buffer), "Appended: {}\n", 42);
// 输出到 char 数组(适合嵌入式)
char buf[64];
auto result = fmt::format_to_n(buf, sizeof(buf), "Temp: {:.1f}C", 36.5);
// result.out - buf = 实际写入的字符数
// 输出到文件
auto file = fopen("output.txt", "w");
fmt::print(file, "Writing {} to file\n", "data");
fclose(file);
5.5 命名参数
// C++20 起支持命名参数
using fmt::arg;
fmt::print("User {name} logged in from {ip}",
arg("name", "Alice"), arg("ip", "192.168.1.1"));
// → "User Alice logged in from 192.168.1.1"
5.6 容器的格式化
#include <fmt/ranges.h>
std::vector<int> v = {1, 2, 3, 4, 5};
fmt::print("Vector: {}\n", v); // → "Vector: [1, 2, 3, 4, 5]"
std::map<std::string, int> m = {{"a", 1}, {"b", 2}};
fmt::print("Map: {}\n", m); // → "Map: {("a", 1), ("b", 2)}"
六、综合可运行示例
#include <fmt/format.h>
#include <fmt/chrono.h>
#include <fmt/ranges.h>
#include <fmt/color.h>
#include <vector>
#include <string>
#include <chrono>
// 自定义类型
struct Sensor {
std::string name;
double value;
std::string unit;
};
template <>
struct fmt::formatter<Sensor> {
constexpr auto parse(fmt::format_parse_context& ctx) {
return ctx.begin();
}
auto format(const Sensor& s, fmt::format_context& ctx) const {
return fmt::format_to(ctx.out(), "{}: {:.2f}{}", s.name, s.value, s.unit);
}
};
int main() {
// 1. 日志场景:带级别和颜色
auto now = std::chrono::system_clock::now();
fmt::print(fmt::fg(fmt::color::green),
"[{:%Y-%m-%d %H:%M:%S}] INFO ", now);
fmt::print("Server started on port {}\n", 8080);
// 2. 传感器数据格式化(自定义类型)
std::vector<Sensor> sensors = {
{"CPU Temp", 72.5, "°C"},
{"Memory", 45.8, "%"},
{"Disk IO", 123.456, "MB/s"}
};
fmt::print(fmt::fg(fmt::color::cyan), "\n--- Sensor Report ---\n");
for (const auto& s : sensors) {
fmt::print(" {}\n", s);
}
// 3. 表格对齐
fmt::print(fmt::fg(fmt::color::yellow), "\n--- Scoreboard ---\n");
fmt::print("{:*^40}\n", "");
fmt::print("{:<15} {:>10} {:>10}\n", "Player", "Score", "Rank");
fmt::print("{:*^40}\n", "");
struct Player { std::string name; int score; int rank; };
std::vector<Player> players = {
{"Alice", 9850, 1}, {"Bob", 8720, 2}, {"Charlie", 7650, 3}
};
for (const auto& p : players) {
fmt::print("{:<15} {:>10} {:>10}\n", p.name, p.score, p.rank);
}
fmt::print("{:*^40}\n", "");
// 4. 编译期格式校验(C++20)
// 取消下面注释会导致编译错误:
// fmt::format("{:d}", "not a number");
// 5. 高性能批量格式化
fmt::memory_buffer buf;
for (int i = 0; i < 1000; ++i) {
fmt::format_to(std::back_inserter(buf),
"Record {}: {}\n", i, sensors[i % 3]);
}
// buf 中的数据可一次写入文件或网络
return 0;
}
七、性能实测
以下为 fmt::format 与 sprintf、std::ostringstream 在格式化整型、浮点、字符串混合场景下的性能对比(100 万次操作,GCC 13,-O3):
| 方法 | 耗时 (ms) | 吞吐量 | 相对 printf |
| sprintf | 452 | 2.21M ops/s | 1.00x |
| std::ostringstream | 1847 | 0.54M ops/s | 0.24x |
| fmt::format | 278 | 3.60M ops/s | 1.63x |
| fmt::format_to (预分配) | 201 | 4.98M ops/s | 2.25x |
| std::format (libstdc++14) | 312 | 3.21M ops/s | 1.45x |
数据来源:{fmt} 官方 benchmark,实际性能因编译器/平台/格式化复杂度而异。
关键结论:
- fmt::format_to 配合预分配缓冲区可达 printf 的 2.25 倍性能
- std::ostringstream 性能最低(虚函数调用 + locale + 多次动态分配)
- 即时分配的 fmt::format 也比 printf 快 63%
为什么比 printf 快?
- 编译期格式解析:printf 在运行时逐字符解析 %d、%f 等格式符,而 {fmt} 在编译期完成
- 查表法整数转换:{fmt} 的 format_int 使用预计算查找表替代除法和取模,对整数格式化加速显著
- 减少缓冲区拷贝:输出直接写入目标缓冲区,避免中间 std::string 构造
八、常见陷阱与避坑指南
陷阱 1:格式字符串不是字面量
// ❌ 运行时字符串无法享受编译期校验
std::string fmt_str = "Hello, {}!";
fmt::format(fmt::runtime(fmt_str), name); // 显式标记为 runtime
// 或者
fmt::format(fmt_str, name); // C++23 前可能编译失败
陷阱 2:大括号转义
// ❌ 错误:孤立的 '{' 或 '}'
// fmt::format("{", 1); // 编译错误
// ✅ 正确:使用 {{ 和 }} 输出字面大括号
fmt::format("JSON: {{\"key\": {}}}", 42);
// → "JSON: {"key": 42}"
陷阱 3:参数数量不匹配
// ❌ 编译错误(C++20 consteval 检查)
// fmt::format("{} + {} = {}", 1, 2);
// ✅ 正确
fmt::format("{} + {} = {}", 1, 2, 3); // → "1 + 2 = 3"
陷阱 4:宽字符字符串混用
// ❌ wstring 需要 wchar_t 版本
// fmt::format("{}", L"wide string"); // 编译错误
// ✅ 使用 fmt::format(L"{}", L"wide string");
陷阱 5:chrono 类型需要 include
// ❌ 忘记包含 fmt/chrono.h
// auto t = std::chrono::system_clock::now();
// fmt::format("{:%Y-%m-%d}", t); // 编译错误
// ✅ 正确
#include <fmt/chrono.h>
九、决策速查表
| 使用场景 | 推荐方案 | 原因 |
| 新项目日常格式化 | std::format (C++20+) | 标准库,零依赖 |
| 需要 C++17/14 兼容 | {fmt} 库 | 向后兼容至 C++11 |
| 日志系统 | {fmt} + spdlog | 成熟生态,高性能 |
| 嵌入式 / 资源受限 | FMT_HEADER_ONLY 模式 | 无链接依赖 |
| 格式化到文件 | fmt::print(file, ...) | 直接写入 FILE* |
| 批量格式化到缓冲区 | fmt::format_to + fmt::memory_buffer | 最小分配次数 |
| 容器 / 范围格式化 | fmt/ranges.h | 开箱即用 |
| 自定义类型格式化 | 特化 fmt::formatter<T> | 标准化扩展点 |
| 需要编译期格式校验 | fmt::format + 字面量 | consteval 保障 |
十、总结与 FAQ
核心要点
- {fmt} 将格式字符串解析从运行时前移到编译期,实现了类型安全 + 高性能的统一
- 其设计被 C++20 标准库采纳为 std::format,成为未来十年 C++ 字符串格式化的基础设施
- 通过特化 formatter<T> 即可为任意自定义类型添加格式化支持,扩展极其简单
- 性能比 printf 快 60%+,比 iostream 快 6~8x
- 开源(MIT 许可证),GitHub 26k+ stars,被 spdlog / Catch2 / folly 等数千项目使用
FAQ
Q: C++20 已有 std::format,还需要用 {fmt} 吗?
A: 如果项目使用 C++20 且编译器完全支持 std::format(GCC 13+ / Clang 17+ / MSVC 19.31+),直接使用标准库即可。但 {fmt} 提供额外的彩色终端输出(fmt/color.h)、命名参数、fmt::memory_buffer 等标准库尚未覆盖的特性。如果需向后兼容 C++17/14,{fmt} 是最佳选择。
Q: {fmt} 对编译速度的影响?
A: 头文件模式(FMT_HEADER_ONLY)会增加编译时间约 15-25%。编译为库模式(默认)可以显著减少增量编译成本,建议非嵌入式项目使用库模式。
Q: 如何为已有 printf 风格代码迁移?
A: 推荐渐进式迁移:新代码全部使用 fmt::format,旧代码逐步重构。{fmt} 提供 fmt::printf 作为过渡方案:
// 过渡期可用(但不推荐,无类型检查)
fmt::printf("Old style: %s, %d\n", "hello", 42);

283

被折叠的 条评论
为什么被折叠?



