String.format 替换踩坑记:从遇坑、读源码到手写实现

String.format 替换踩坑记:从遇坑、读源码到手写实现

改需求时在模板里多加了一个 %s、多传了一个参数,结果最后一个占位符还是用了旧值。查下来才知道:占位符按出现顺序跟参数一一对应,多出来的参数 JDK 直接不用,也不报错。所以这是典型的「对底层约定不清楚」导致的隐藏 Bug,只有在你新增占位符又新增参数、却没改顺序的时候才会踩到。

这篇就按踩坑 → 把契约和源码捋一遍 → 自己写一版严格校验的替换工具,顺带给出修法和以后怎么防。


一、问题与定性

1.1 现象

ini 复制代码
// 原始
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 格式说明符长什么样

css 复制代码
%[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 入口代码

typescript 复制代码
// 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 示例实现(严格校验个数)

arduino 复制代码
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 直接不用;把这条约定记牢,复杂模板用显式索引,这类坑就能少踩。

本文使用 markdown.com.cn 排版

相关推荐
不光头强2 小时前
手写tomcat
java·tomcat
寻见9032 小时前
救命!Spring Boot 凭什么火?从道法术器讲透,新手也能一键上手
java·spring boot·java ee
jinanmichael2 小时前
【SQL】掌握SQL查询技巧:数据分组与排序
java·jvm·sql
彭于晏Yan2 小时前
SpringBoot如何调用节假日API
java·spring boot·后端
jianfeng_zhu2 小时前
用java解决空心金字塔的问题
java·开发语言·python
寻见9032 小时前
告别只会 CRUD!Spring 核心原理吃透,这一篇就够了(Java 程序员必藏)
java·后端·spring
Moe4882 小时前
基于 AOP 与 Redisson 的分布式锁实现:自动加锁、解锁与 SpEL 参数解析
java·后端·架构
敲代码的嘎仔2 小时前
Java后端开发——Redis面试题汇总
java·开发语言·redis·学习·缓存·面试·职场和发展
啦啦啦_99992 小时前
4. AI面试题之 Prompt
java·prompt