String.format 替换踩坑记:从遇坑、读源码到手写实现
改需求时在模板里多加了一个 %s、多传了一个参数,结果最后一个占位符还是用了旧值。查下来才知道:占位符按出现顺序跟参数一一对应,多出来的参数 JDK 直接不用,也不报错。所以这是典型的「对底层约定不清楚」导致的隐藏 Bug,只有在你新增占位符又新增参数、却没改顺序的时候才会踩到。
这篇就按踩坑 → 把契约和源码捋一遍 → 自己写一版严格校验的替换工具,顺带给出修法和以后怎么防。
一、问题与定性
1.1 现象
// 原始
String a = "%s爱%s的%s";
String b = String.format(a, "我", "中国", "天安门", "2025");
// b = "我爱中国的天安门"
// 需求变更:末尾加「今天是某某年」
String a = "%s爱%s的%s,今天是%s年";
String b = String.format(a, "我", "中国", "天安门", "2025", "2026");
// 期望:"今天是2026年" → 实际:"今天是2025年"
多传了 "2026",最后一个 %s 仍用的是第 4 个参数 "2025"。
1.2 架构定性:隐藏 Bug
| 维度 | 说明 |
|---|---|
| 根因 | 编码者对「占位符与参数如何绑定」的底层契约不了解,按错误心智模型(如「最后一个占位符用最后一个参数」)写代码。 |
| 为何是 Bug | 业务期望与实现行为不一致,且结果错误;只是未抛异常,容易被误以为「没问题」。 |
| 为何隐蔽 | 触发需特定条件:在原有模板上只增加占位符与参数、未调整顺序或未用显式索引;多出的参数被静默忽略,单测若不覆盖「参数个数 ≠ 占位符个数」则难以发现。 |
根因就是没搞清「占位符和参数是怎么绑的」。
二、设计契约:占位符与参数如何绑定
2.1 契约内容
- 绑定规则:格式串中的每个转换说明符(如
%s、%d)按出现顺序与参数列表中的参数一一对应;若说明符带显式参数索引%n$,则使用第 n 个参数(n 从 1 开始)。 - 多出的参数:不参与任何替换,静默忽略,不报错。
- 少参数:会按说明符取参时越界,抛出异常。
因此:占位符个数决定会用几个参数;参数可多传,多出的不参与。
2.2 格式说明符长什么样
%[argument_index$][flags][width][.precision]conversion
- 无
argument_index(如%s)→ 使用隐式顺序:当前说明符对应「下一个」参数,从左到右递增。 - 有
argument_index(如%3$s)→ 使用第 3 个参数,且可重复使用同一参数。
2.3 语义图:谁对应谁
谁跟谁绑、多出来的参数用不上。
三、实现架构:契约在哪一层被执行
光知道契约还不够,得知道在代码里是哪一层在执行这条约定、为啥多传了也不会报错。
3.1 整体调用链(架构图)
3.2 各层职责与「隐藏 Bug」的对应关系
| 层级 | 职责 | 与「多传参数被忽略」的关系 |
|---|---|---|
| String.format | 仅转发:new Formatter() → format() → toString() | 不校验 args 个数与格式串是否匹配,多传不会报错。 |
| Formatter.format | 驱动 parse + 遍历输出 | 参数消耗完全由「格式串解析结果」决定,与 args.length 无关。 |
| parse(format) | 将格式串拆成 FixedString 与 Conversion 序列 | Conversion 个数 = 会消耗的参数个数;多出的 args 从未被引用。 |
| 绑定 | 每个 Conversion:有 n$ 用 args[n-1],否则用 args[lastIndex++] | 隐式顺序严格按说明符出现顺序递增;lastIndex 不会跳到「最后一个参数」。 |
| 输出 | 写入同一 Appendable,最后 toString() | 结果只反映「被选中的那几位参数」,不会反映多传的部分。 |
所以:契约是在 parse 出说明符 + 按说明符取参绑定 这两步里执行的;JDK 压根不认为「多传参数」是错,所以不会在这里做校验,多出来的参数自然没人用。
3.3 入口代码
// String.java
public static String format(String format, Object... args) {
return new Formatter().format(format, args).toString();
}
String 只负责把活交给 Formatter,真正决定用哪几个参数的是 Formatter 里 parse 出来的那串说明符和遍历时的取参逻辑。
3.4 底层用到的设计模式
这条链路上用到的几种模式,自己写类似工具时可以照着分层。
| 设计模式 | 在 format 链路中的体现 |
|---|---|
| 解释器(Interpreter) | JDK 文档明确写:Formatter 是 “An interpreter for printf-style format strings”。格式串(如 "%s爱%s的%s")可视为一种小语言,Formatter 负责解析(parse 出 FixedString、Conversion)并执行(按说明符取参、格式化、写入)。这就是典型的「给定一种语言 + 语法表示,用解释器解释句子」的 Interpreter 模式。 |
| 策略(Strategy) | 不同转换类型(%s、%d、%f 等)各有一套格式化逻辑;Formattable 接口让任意类通过 formatTo() 自己决定怎么被格式化。加新类型或新格式不用动 Formatter 主流程。 |
| 建造者(Builder) | 结果不是一次性拼好,而是通过 Appendable(如 StringBuilder)逐步 append 出来,按步骤构建最终字符串,符合建造者模式。Formatter 依赖 Appendable 接口而非具体实现,可把结果写到字符串、流、文件等,这是依赖倒置(面向接口编程)的体现,是设计原则而非单独的设计模式。 |
| 模板方法(Template Method) | 整体流程固定:parse 格式串 → 遍历每个 FormatString → 若是固定串则原样输出,若是 Conversion 则取参并格式化后输出。骨架不变,其中「如何格式化一个参数」按 conversion 类型分支,可视为模板方法中的可变步骤。 |
解释器负责「把格式串当小语言来解析执行」;策略体现在不同转换类型(以及 Formattable)各干各的格式化;结果往 Appendable 里 append、依赖接口不依赖具体类,是建造者 + 依赖倒置;整体流程「parse → 遍历 → 固定串直接写、转换符取参再写」是模板方法。
四、解决方案
4.1 立刻能改的两种写法
- 改参数顺序:让「年」就是第 4 个参数。
String.format(a, "我", "中国", "天安门", "2026") - 用显式索引(更稳):模板里写清楚第几个占位符用第几个参数,后面改模板也不容易错位。
String a = "%1$s爱%2$s的%3$s,今天是%5$s年";
String.format(a, "我", "中国", "天安门", "2025", "2026");
4.2 以后怎么少踩坑
模板一复杂(占位符多、或者多人改),尽量统一用 %n$ 显式索引,别靠「第几个参数对应第几个 %s」的隐式顺序。Code Review 时看到 format,顺带看一眼占位符和参数是不是真的一一对应、有没有多传了以为会用到其实没用的。单测可以加一两条「参数比占位符多 / 少」的用例,要么验证 JDK 行为,要么逼着你封装一层做严格校验。
五、手写一版:自研「%#」替换工具
搞清契约和源码之后,如果你希望「占位符和参数个数对不上就报错」、而不是像 JDK 一样静默忽略,可以自己写一个简单的模板替换,比如用 %# 当占位符。
5.1 设计取舍
| 维度 | JDK Formatter | 自研 %# 示例 |
|---|---|---|
| 占位符 | %s、%d 等,语法丰富 |
%#,仅做顺序替换 |
| 多传/少传参数 | 多传静默忽略,少传抛异常 | 个数不一致就抛异常,问题在调用处就能暴露 |
5.2 示例实现(严格校验个数)
public final class SimpleFormat {
private static final String PLACEHOLDER = "%#";
/**
* 占位符 %# 按顺序替换为 args;占位符数量与 args 数量必须一致,否则抛异常。
*/
public static String format(String template, Object... args) {
if (template == null) return null;
int idx = 0;
int start = 0;
StringBuilder sb = new StringBuilder();
while (true) {
int pos = template.indexOf(PLACEHOLDER, start);
if (pos == -1) {
sb.append(template, start, template.length());
break;
}
sb.append(template, start, pos);
if (idx >= args.length)
throw new IllegalArgumentException("占位符数量(" + (idx + 1) + ") 超过参数数量(" + args.length + ")");
sb.append(args[idx] != null ? args[idx].toString() : "null");
idx++;
start = pos + PLACEHOLDER.length();
}
if (idx != args.length)
throw new IllegalArgumentException("参数数量(" + args.length + ") 超过占位符数量(" + idx + ")");
return sb.toString();
}
}
六、收个尾
用 API 前先把契约搞清楚(比如「按顺序消耗、多传不报错」),别凭直觉猜。模板一复杂就用 %n$ 把索引写死,少依赖隐式顺序,review 和后续改需求都轻松。真要严格一点,就封装一层或用自己的替换工具,占位符和参数对不上直接抛异常,问题会暴露得更早。
占位符按出现顺序绑参数,多出来的 JDK 直接不用;把这条约定记牢,复杂模板用显式索引,这类坑就能少踩。