前言
标题里的「函数」加了引号,这个引号很关键:C++ 标准库里没有std::string::format这个成员函数。std::string没有format,<string>里也没有一个叫format的自由函数。很多从 C#、Java、Python 转过来的开发者会下意识地写s.format(...)或String.Format(...),然后在编译错误里困惑很久。这类 API 是那些语言的标准库提供的,C++ 直到 C++20 才引入std::format,而且它是<format>里的自由函数,不是std::string的成员。
那 C++ 里怎么格式化字符串?历史上主要有三条路:C 风格的printf/snprintf、C++ 流式的std::ostringstream、以及第三方库(最著名的是 fmt)。C++20 之后多了第四条:标准化的std::format,它的设计直接参考了 fmt 库。本文按「需求 → 各方案写法 → 安全性 → 选型」讲清这四条路,重点指出那些编译能过但行为未定义的坑。
需要提前说明:本文不比较运行速度,也不给任何倍数。格式化性能高度依赖实现、类型、locale 和优化级别,没有统一测量就不能下结论。文中的代码按标准语义书写,本机没有工具链,不声称任何执行结果。
一、需求拆解:格式化到底要做什么
一个字符串格式化设施要解决四件事:
- 拼接:把多个不同类型的值放进一个字符串。
- 格式控制:宽度、对齐、精度、进制、填充字符。
- 类型安全:参数类型与占位符不匹配时,最好在编译期报错,退而求其次在运行期明确失败。
- 本地化:数字千分位、小数点符号等随 locale 变化。
printf家族解决了 1 和 2,但在 3 上很弱:格式串是运行期字符串,编译器只能在「字面量 + 启用了格式检查」时给出警告,类型不匹配的后果是未定义行为。ostringstream解决了 1、3、4,但格式控制靠流操纵器,写起来冗长,而且流的状态(精度、填充、进制)会残留。std::format试图同时满足四项,并用编译期格式串检查解决 3。
| 方案 | 类型安全 | 格式控制 | 需要标准 | 主要缺点 |
|---|
std::ostringstream | 强(运算符重载) | 中(操纵器) | C++98 | 冗长、状态残留 |
snprintf | 弱(可变参数) | 强 | C++11 | 类型不匹配即 UB |
std::format | 强(编译期检查) | 强 | C++20 | 编译器支持不一 |
| fmt 库 | 强 | 强 | 第三方 | 需要引入依赖 |
二、流式写法:std::ostringstream
最「纯 C++」的方式是把值写进std::ostringstream,再取str()。
// 适用于 C++17
#include <iomanip>
#include <iostream>
#include <sstream>
#include <string>
int main() {
std::ostringstream oss;
oss << "name=" << "widget"
<< " count=" << 42
<< " price=" << std::fixed << std::setprecision(2) << 9.5;
std::string line = oss.str();
std::cout << line << '\n';
// 宽度与填充只影响下一次输出
std::ostringstream table;
table << std::setw(8) << std::left << "id"
<< std::setw(8) << std::right << "qty" << '\n';
table << std::setw(8) << std::left << 7
<< std::setw(8) << std::right << 120 << '\n';
std::cout << table.str();
return 0;
}优点是类型安全:operator<<对每种类型都有重载,写错了是编译错误而不是未定义行为;std::string可以直接流进去,不需要.c_str()。缺点也很明显:格式和内容混在一起,一个长句子要拆成十几个<<;std::fixed、std::setprecision是粘性的,设置一次会影响后续所有浮点输出,必须记得复位;std::setw则是一次性的,只作用于下一个输出项。这种「有的粘、有的不粘」的规则是初学者最容易混淆的地方。
三、C 风格:snprintf与两段式取长
snprintf的签名是int snprintf(char* buffer, std::size_t size, const char* format, ...),返回「假如缓冲区足够大,本应写入的字符数」(不含结尾的'\0')。这个返回值设计让「先问长度、再分配、再写入」成为可能。
// 适用于 C++17
#include <cstdarg>
#include <cstdio>
#include <string>
#include <vector>
std::string formatString(const char* fmt, ...) {
va_list args;
va_start(args, fmt);
const int needed = std::vsnprintf(nullptr, 0, fmt, args);
va_end(args);
if (needed < 0) {
return std::string(); // 编码错误等,返回空串
}
std::vector<char> buffer(static_cast<std::size_t>(needed) + 1, '\0');
va_start(args, fmt);
std::vsnprintf(buffer.data(), buffer.size(), fmt, args);
va_end(args);
return std::string(buffer.data(), static_cast<std::size_t>(needed));
}这段代码用了<cstdarg>的va_list、va_start、va_end和std::vsnprintf,头文件需要补上<cstdarg>。两个要点:
vsnprintf(nullptr, 0, fmt, args)是标准允许的用法,用于查询所需长度,不会写入任何字节。- 缓冲区大小是
needed + 1,多出的一个字节留给结尾的'\0';返回的std::string只取needed个字符,不把'\0'算进长度。
这个封装有一个无法在函数内部解决的根本问题:可变参数没有类型信息。调用者写formatString("%d", 3.5)时,3.5被当作double传入,而%d期望int,这是未定义行为,编译器通常只给一个-Wformat警告(如果格式串是字面量且启用了警告),运行时可能打印出毫无意义的值。同理,%s必须配const char*,传std::string是 UB,必须写s.c_str();size_t要用%zu,long long要用%lld,写错就是 UB。
C++11 起还有std::snprintf(在<cstdio>中),它比sprintf安全,因为一定会带上缓冲区大小;sprintf无法知道边界,应当完全避免。
四、C++20 的std::format
std::format定义在<format>中,需要 C++20 及以上。它的基本形式是:
// 需要 C++20
#include <format>
#include <iostream>
#include <string>
int main() {
std::string s1 = std::format("{} + {} = {}", 1, 2, 3);
std::string s2 = std::format("{0}-{0}-{1}", "a", "b"); // 位置参数
std::string s3 = std::format("{:>8.2f}", 3.14159); // 右对齐、宽 8、两位小数
std::string s4 = std::format("{:#x}", 255); // 0xff
std::cout << s1 << '\n' << s2 << '\n' << s3 << '\n' << s4 << '\n';
return 0;
}要点:
{}是自动编号的占位符,{0}、{1}是手动编号,两者不能混用(混用是编译期错误)。- 冒号后面是格式说明:
>右对齐、<左对齐、^居中,数字是宽度,.2f是定点两位小数,#x是带前缀的十六进制。 - 参数类型由模板推导,不需要
%d/%s这类说明符去猜测,std::string可以直接传。 - 格式串是字面量时,C++20 的
std::format_string会在编译期检查占位符数量与类型;用运行期字符串则需要std::vformat,检查退到运行期。
std::format返回std::string。如果要直接写入缓冲区或输出迭代器,用std::format_to:
// 需要 C++20
#include <format>
#include <string>
std::string build() {
std::string out;
std::format_to(std::back_inserter(out), "{}:{}", "k", 1);
return out;
}还有一个变体std::format_to_n,限制最多写入 n 个字符,适合固定大小的缓冲区。
支持情况必须自己确认。std::format是 C++20 里落地较晚的部分:GCC 从 13 起提供<format>,MSVC 从 19.29(VS 2019 16.10)起提供,libc++ 的支持更晚且随版本变化。项目里使用前应查编译器的支持表,或退回 fmt 库。不要因为「标准是 C++20」就假设所有 C++20 编译器都有。
五、第三方库 fmt
fmt 是一个广泛使用的格式化库,std::format的提案大量参考了它。它的接口与std::format高度相似:
// 需要 fmt 库(版本差异请以官方文档为准)
#include <fmt/core.h>
#include <string>
std::string s = fmt::format("{} + {} = {}", 1, 2, 3);具体头文件路径、命名空间和编译方式随版本变化(有 header-only 与编译库两种用法),CMake 集成方式也分fmt::fmt与fmt::fmt-header-only两个目标。这些细节必须以所用版本的官方文档为准,不要照抄过时的教程。它的价值在于:在老编译器上也能获得编译期格式检查与std::format风格的语法,并且可以平滑迁移到标准版本。
六、自己封装一个类型安全的 format
在 C++17 下,如果想避开printf的类型陷阱又不想引入依赖,可以用可变参数模板加流来实现。最简单的是折叠表达式拼接:
// 适用于 C++17
#include <sstream>
#include <string>
#include <utility>
template <class... Args>
std::string concat(Args&&... args) {
std::ostringstream oss;
(oss << ... << std::forward<Args>(args)); // C++17 折叠表达式
return oss.str();
}
// 用法:concat("x=", 1, ", y=", 2.5)它类型安全、没有格式串,但也不提供宽度、精度、对齐这些控制。要支持{}占位符和格式说明,就需要自己写解析器,工作量不小,通常不值得——直接用 fmt 或升级到 C++20 更划算。这里给出折叠表达式版本,是为了说明「类型安全」和「格式控制」可以分开取舍:很多日志场景只需要前者。
常见坑点
- ❌ 写
std::string s; s.format("{}", x);或String::Format(...)。
✅ C++ 没有这个成员;C++20 用std::format,之前用ostringstream或snprintf。
- ❌
snprintf(buf, size, "%s", std::string("x")),把std::string传给%s。
✅ 传s.c_str();这是未定义行为,不是「可能少几个字符」。
- ❌
snprintf的缓冲区按返回值分配却忘了+1,结尾'\0'写到界外。
✅ 分配needed + 1字节,或直接用std::string管理。
- ❌ 把用户输入直接当格式串:
printf(userInput)。
✅ 格式串必须是可信的字面量,用户数据作为参数传入("%s"或{})。否则是格式串漏洞。
- ❌
%d配size_t、%f配int,靠「看起来差不多」蒙混。
✅ 严格匹配:%zu配size_t,%lld配long long;或改用类型安全的方案。
- ❌ 以为
std::format在所有 C++20 编译器上都能用。
✅ 查编译器支持表;GCC 13、MSVC 19.29 起提供,libc++ 更晚。
- ❌
ostringstream设置std::fixed后不复位,后续整数/浮点输出全被影响。
✅ 每个流对象独立使用,或显式oss.unsetf(std::ios::floatfield)复位。
- ❌ 保存
std::string::c_str()的指针,原串修改或销毁后继续使用。
✅ 需要长期持有时拷贝成std::string;c_str()只在原串不变期间有效。
总结
| 方案 | 写法 | 类型检查 | 适用场景 |
|---|
ostringstream | oss << a << b | 编译期 | C++17 及以前,格式简单 |
snprintf | "%d %s" | 弱,运行期 UB 风险 | 需要精确控制、与 C 接口对接 |
std::format | "{} {}" | 字面量编译期检查 | C++20 及以上新项目 |
| fmt 库 | fmt::format("{}", x) | 编译期 | 老编译器、需要稳定行为 |
| 折叠表达式 | (oss << ... << args) | 编译期 | 只拼接、不需要格式控制 |
「好用的 format 函数」在 C++ 里不是一个函数,而是一组取舍。如果项目能上 C++20 且工具链支持,std::format是首选:语法短、类型安全、格式表达力强。如果还在 C++17,ostringstream最稳,snprintf只在与 C 代码交界处使用,并且务必守住「格式串可信、类型严格匹配、缓冲区带长度」三条底线。