判断字符串首字符是不是大写,是 Java 里那种"一眼看上去三行代码就能收工、真动手发现处处是坑"的活。Character.isUpperCase(s.charAt(0))这行代码看着人畜无害,可它同时把 null、空串、UTF-16 代理对、Unicode 大小写分类、Locale 敏感性这五件事都藏在了背后。我前后在不同项目里被这个需求绊过好几次:一次是昵称校验把中文用户全拦了,一次是常量命名检查脚本把Dž开头的测试数据判成了不合规,还有一次是导出报表的首字母大写函数在高并发下被正则拖慢。这些问题的共同点是——需求本身没写清楚"大写"到底指什么,而实现者默认了[A-Z]。
这篇东西把我对"Java 判断字符串首字符大写(uppercase、capital)"的全部理解和盘托出:四类容易混淆的业务语义、四套基础写法及各自的前提条件、Unicode 与 Locale 的隐藏陷阱、从判断延伸到转换时的实现取舍、可以抄作业的工具类与校验注解,最后是常见问题速查表和实打实的避坑经验。适合刚被这类校验卡住的新手,也适合想把自己工具库那块老代码清一遍的老手。
1. 先把需求拆明白:首字符"大写"到底有几种判法
1.1 四类语义,写法完全不同
同一个"判断首字符大写",在不同业务里含义天差地别,先对号入座再谈实现,能省掉后面 80% 的返工。
语义 A:首字符是大写字母。典型场景是英文名的首字母校验、错误码规范检查、驼峰类名检查。要求严格:H通过,h不通过,1、_、中都不通过。这就是Character.isUpperCase的语义。
语义 B:首字符不是小写字母。场景是"宽松版"——只要不是小写开头就算过关,数字、符号、汉字都放行。用在日志标签、渠道编码这类不强制字母的地方。它是语义 A 的补集写法,很容易和 A 混淆,上线前一定要跟产品确认清楚。
语义 C:整串符合"词首大写"格式。也就是常说的 Capitalized / Title Case:第一个字符大写,后面的字符要么小写、要么非字母。英文标题格式化、姓名显示、导出的表头美化都属于这一档。它比 A 多了一层"后续字符不能出现大写"的约束,HELLO在语义 C 下是不合格的。
语义 D:首字符能做大写化。判断的是"这个字符有没有大写形式",Character.isLetter一类的宽松判断。用在"格式化前先探一下要不要动"的场景。
提示:这四种语义没有对错,只有业务匹配度。落到接口文档上时,一定写成"首字符必须为 Unicode 大写字母(例如 A、Ä、Dž),数字与汉字视为不合规",别只写"首字母大写"四个字。
1.2 三个层面的"字符":这是所有坑的源头
Java 的String内部是 UTF-16 编码的char数组,一个char是两个字节的"格子"。绝大多数常用字符占一个格子,但一部分字符(比如某些古文字、部分数学符号、少量表情符号)要占两个格子,这在 Unicode 里叫增补平面字符,两个格子在 Java 里叫代理对。
第一个坑就出在这里:s.charAt(0)取的是一个char,也就是一个格子。如果首字符是占两个格子的字符,charAt(0)拿到的是代理对的前半个,Character.isUpperCase对它返回false。而真值其实是大写字母,判断结果直接反了。更隐蔽的是s.length():一个占两个格子的字符,length()返回 2,你写if (s.length() == 1)想判断"只有一个字符",结果对某些字符会误判。
往上一层是码点(code point),一个码点对应一个完整字符(严格说是 Unicode 标量值)。String.codePointAt(0)返回的就是码点,配合Character.isUpperCase(int)这个重载,代理对问题自然消失。所以只要涉及"首字符",用codePointAt(0)是默认正确的起手式。
再往上一层是字形簇(grapheme cluster),也就是人眼看到的一个"字"。带组合附加符号的字符属于这一层,比如e加一个组合重音符,两个码点在屏幕上显示成一个字。判断首字符大写时一般不需要拆到这一层,但如果你的业务是"按用户看到的字符切分摘要",就得用java.text.BreakIterator了。这一层在日常校验里很少碰到,知道有这回事就够。
1.3 语义与 API 的对应关系
| 判定语义 | 推荐 API | 对中文的结果 | 对1A的结果 | 对Ä的结果 |
|---|---|---|---|---|
| A. 是大写字母 | Character.isUpperCase(cp) | false | false | true |
| A+ 含标题大写 | isUpperCase(cp) || isTitleCase(cp) | false | false | true |
| B. 不是小写字母 | !Character.isLowerCase(cp) | true | true | true |
| C. 整串词首大写 | 首字符大写 + 后续无大写 | false | false | 视后续而定 |
| D. 可大写化 | Character.isLetter(cp) | true | false | true |
这张表建议直接贴到需求评审的文档里。我见过太多争论其实不是技术分歧,而是两个人对"大写"的语义定义压根不一样。
2. 四种基础写法:从 charAt 到 codePointAt
2.1 写法一:charAt(0) 加 isUpperCase
public static boolean isFirstUpperV1(String s) { return s != null && !s.isEmpty() && Character.isUpperCase(s.charAt(0)); }这行代码能跑,但它依赖三个前提,缺一个就出错:非 null、非空串、首字符落在基本平面内。前两个前提靠短路求值保住了,第三个前提没人管。如果你的输入只来自内部枚举、固定前缀的编码、ASCII 用户名,这个写法完全够用,而且开销最小——charAt是一次数组下标访问,isUpperCase(char)内部是一次类型查表,整段基本是纳秒量级。
但只要你把这段代码放到"接收外部输入"的位置,问题就来了。我接手过一段用户昵称校验,用的就是这个写法,结果上线后一批用户反馈"名字明明是字母开头的也过不了"。排查下来是几个用增补平面字母命名的账号,charAt(0)拿到代理字符,判定为 false,直接拦掉。
2.2 写法二:codePointAt(0),把代理对补齐
public static boolean isFirstUpperV2(String s) { if (s == null || s.isEmpty()) { return false; } int cp = s.codePointAt(0); return Character.isUpperCase(cp); }String.codePointAt(int)会自己判断这个位置是不是代理对的高位,是的话把两个char合成一个码点返回,不是就返回当前char的值。所以它对基本平面和增补平面一视同仁,代价是多一次分支判断,可以忽略。
如果你的入参类型是CharSequence(比如StringBuilder、某些框架的包装类型),别急着toString(),直接用Character.codePointAt(CharSequence, int)这个重载,少一次拷贝:
public static boolean startsWithUppercase(CharSequence s) { if (s == null || s.length() == 0) { return false; } int cp = Character.codePointAt(s, 0); return Character.isUpperCase(cp) || Character.isTitleCase(cp); }这里我顺手把isTitleCase也加上了,原因在 3.2 节展开。Character.codePointAt(CharSequence, int)从 Java 5 就有了,不用考虑版本兼容。
2.3 写法三:正则表达式,好用但别乱用
// 只认 ASCII 大写字母,很多老代码就是这么写的 private static final Pattern ASCII_UPPER = Pattern.compile("^[A-Z]"); // Unicode 语义,注意 Lu 不包含 Lt(标题大写) private static final Pattern UNICODE_UPPER = Pattern.compile("^[\\p{Lu}\\p{Lt}]"); // 整串词首大写 private static final Pattern TITLE_WORD = Pattern.compile("^[\\p{Lu}\\p{Lt}][^\\p{Lu}\\p{Lt}]*$");用正则有三点必须注意。第一,[A-Z]是 ASCII 语义,Ä、Å、带重音的字母全部不通过,如果你的数据里有欧洲语言的名字,这就是个定时炸弹。第二,\p{Lu}只匹配大写字母类,不包含Lt(标题大写字母类),Dž这种字符会漏掉,所以得写成[\p{Lu}\p{Lt}]。第三,Pattern一定要预编译成静态常量,Pattern.compile是微秒级操作,matcher().find()匹配是纳秒级,在循环里现编译正则,性能会差出两三个数量级,这个差距在批量校验十万条数据时非常明显。
另外注意matches()和find()的区别:matches()要求整个输入都匹配,^是多余的;find()是找子串,必须写^并且加matcher.lookingAt()或锚定。我见过写成matcher.matches()却把 pattern 写成"^[A-Z].*"的,结果对含换行的字符串行为跟预期不同——.默认不匹配换行,得加Pattern.DOTALL。
2.4 写法四:先转换再比对,以及它为什么必然翻车
网上流传最广的一段"判断首字母大写"代码长这样:
// 不推荐 public static boolean isFirstUpperV4(String s) { if (s == null || s.isEmpty()) return false; String head = s.substring(0, 1); return head.equals(head.toUpperCase()); }逻辑听起来很顺:转成大写之后跟原来一样,那原来不就是大写嘛。但这段代码在三个地方会给出错误答案。
第一,没有大小写概念的字符会返回 true。"中".toUpperCase()返回的还是"中",equals成立,于是汉字开头的字符串被判定为"首字母大写"。这是最经典的误判,凡是中文用户占比高的系统,这个 bug 几乎必现。
第二,数字和符号也返回 true。"1abc"、"_key"、" hello"全部通过,因为对它们做大小写转换等于什么都没做。
第三,长度可能变化。德语里的ß转大写是SS,两个字符;某些连字也有类似行为。如果你的代码后面还要按下标切分,长度一变就全乱了。
结论很干脆:判断就用判断的 API,别用"转换后比对"绕路。这条经验我在代码评审里说过不下十次,每次都能从老项目里翻出几处。
3. Unicode 与 Locale:真正让人踩坑的地方
3.1 汉字、数字、符号开头时怎么办
这是业务上最容易吵起来的地方。Java 的Character.isUpperCase对汉字返回false(汉字属于OTHER_LETTER,没有大小写概念),对全角数字、下划线、空格同样返回false。也就是说,一个严格按语义 A 实现的校验,会把"张三"、"123abc"、"_internal"这类输入全部拦在门外。
我的处理原则是:校验规则要和业务能接受的输入集对齐,而不是和实现方便对齐。具体分三档。
第一档,纯英文标识符场景(类名、常量名、错误码):严格用语义 A,非字母开头直接拒,同时把规则写进错误提示,让调用方知道原因。
第二档,多语言昵称/姓名场景:改成"首字符要么是大写字母,要么不是字母"的宽松规则,也就是语义 B 的变体。中文、日文、韩文用户能正常通过,纯小写英文开头依然被拦。
第三档,格式化展示场景:不管校验,先做转换,见第 4 节。
// 宽松版:非字母开头视为通过,字母开头必须大写 public static boolean isValidDisplayName(String s) { if (s == null || s.isBlank()) return false; int cp = s.codePointAt(0); return !Character.isLetter(cp) || Character.isUpperCase(cp) || Character.isTitleCase(cp); }isBlank()是 Java 11 才有的方法,如果你的工程还停在 Java 8,换成s.trim().isEmpty(),但要注意trim()只处理码点小于等于U+0020的字符,全角空格处理不掉,更稳的写法是s.codePoints().allMatch(Character::isWhitespace)。
3.2 标题大写字母:isUpperCase 会漏掉的那一类
Unicode 的大写相关分类其实有两个:Lu(Uppercase Letter)和Lt(Titlecase Letter)。Lt这一类字符在 Java 里要用Character.isTitleCase判断,isUpperCase对它返回false。
哪些字符属于Lt?主要是几个由两个拉丁字母合成的连字,比如Dž(D 加 ž 的合体)、Lj、Nj、Dz。它们有大小写三态:全大写、词首大写、全小写。词首大写那一个就是Lt。
System.out.println(Character.isUpperCase('\u01C5')); // false System.out.println(Character.isTitleCase('\u01C5')); // true System.out.println(Character.toUpperCase('\u01C5')); // DŽ(全大写) System.out.println(Character.toTitleCase('\u01C5')); // Dž(自身)实际业务里遇到Lt的概率极低,但一旦遇到,判定结果直接错。我的做法是在工具方法里统一写成isUpperCase(cp) || isTitleCase(cp),多一次方法调用,成本可以忽略,换来的是语义完整。
顺带提一句Character.toTitleCase和Character.toUpperCase的区别:对普通字符两者等价,对连字来说前者给词首形式,后者给全大写形式。做"首字母大写"转换时用toTitleCase更贴合语义,但对ß这种需要用字符串级映射才能正确转换的字符,toTitleCase只能返回原字符(因为结果长度变了,单字符装不下),所以转换还得走String.toUpperCase。
3.3 Locale 敏感性:同一个字符,不同的"大写"
String.toUpperCase()无参版本用的是 JVM 默认 Locale,而这个默认值会跟着运行环境的语言设置走。对于某些语言环境,拉丁字母i的大写映射不是I,而是带点的İ。这意味着同一段代码在开发机和服务器上可能给出不同结果——开发机默认是英文环境,容器镜像里可能是别的语言设置,这种问题排查起来非常费劲。
解决办法就一句话:凡是涉及程序内部标识符、协议字段、缓存 key、比较逻辑的大小写转换,一律传Locale.ROOT。
String a = "istanbul".substring(0, 1).toUpperCase(Locale.ROOT); // I String b = "istanbul".substring(0, 1).toUpperCase(); // 取决于 JVM 默认 Locale还有一处容易被忽略:equalsIgnoreCase。它的行为在不同版本和不同字符上并不完全等价于"统一转大写再比",历史上出过针对个别字符的差异修正。做严格的大小写无关比较时,我用regionMatches(true, ...)或者显式转Locale.ROOT再比,行为更稳定可预期。
最后是长度变化这个雷。ß转Locale.ROOT大写得到SS,fi这样的连字也有多对一或多对多的映射。所以任何"先转换再按下标取字符"的代码都是不安全的,转换必须放在最后一步,输出即用。
4. 从判断到转换:首字母大写的实现与性能取舍
4.1 经典写法:预取首字符长度,再做拼接
判断只读不写,转换要产出新字符串,两者设计思路不一样。转换的正确起点是先确定"首字符占几个char",再切分:
public static String capitalize(String s) { if (s == null || s.isEmpty()) { return s; } int firstCp = s.codePointAt(0); int headLen = Character.charCount(firstCp); String head = s.substring(0, headLen); String tail = s.substring(headLen); return head.toUpperCase(Locale.ROOT) + tail; }Character.charCount(cp)返回这个码点占几个char,基本平面返回 1,增补平面返回 2。有了它,切分位置就永远正确,不会把代理对劈成两半。
注意head.toUpperCase(Locale.ROOT)的结果长度可能是 2(ß→SS),这没关系,因为它是拼接的前缀,不影响后面的tail。真正不能做的是反过来:先转大写再按下标切,那就全乱了。
另外,如果业务语义是"词首大写、其余小写",后面还得跟一次tail.toLowerCase(Locale.ROOT)。这一条我建议单独做成一个方法,别塞进capitalize里——capitalize在很多语言的标准库里都是"只动首字母",混在一起会让调用方困惑。
4.2 另外两种实现:char 数组与码点流
追求极致性能时会用数组原地改:
public static String capitalizeByArray(String s) { if (s == null || s.isEmpty()) { return s; } char[] arr = s.toCharArray(); arr[0] = Character.toUpperCase(arr[0]); return new String(arr); }它确实快,少了substring和字符串拼接的两次对象分配。但它不支持增补平面字符:arr[0]是代理对的半个字符,toUpperCase直接返回原值,等于什么都没做。所以这段代码只适合输入被严格限定在 ASCII 或基本平面内的场景,比如内部生成的短编码。用之前问自己一句:这个方法的入参有没有可能来自用户输入?有的话就别用。
还有一种写法用codePoints()流:
public static String capitalizeByStream(String s) { if (s == null || s.isEmpty()) { return s; } int[] cps = s.codePoints().toArray(); cps[0] = Character.toTitleCase(cps[0]); return new String(cps, 0, cps.length); }语义最干净,toTitleCase也贴合"词首大写"的本意。代价是codePoints()会创建一个流对象、一次装箱拆箱(虽然IntStream是特化的,但仍有对象开销),对短字符串来说不划算。我在批处理几十万条数据的场景里测过,流式写法比 4.1 的写法慢一倍以上,日常接口调用完全无感,放在大循环里就能看出来。
4.3 性能量级与选型建议
以下是我在本机用粗略基准跑出来的数量级参考,只用于判断"哪个方案贵一个档位",不构成精确指标,你的机器和 JDK 版本不同结果会变。
| 方案 | 单次开销量级 | 支持增补平面 | 支持 Lt | 建议用途 |
|---|---|---|---|---|
charAt(0)+isUpperCase(char) | 纳秒级,最省 | 否 | 否 | 内部 ASCII 标识符 |
codePointAt(0)+isUpperCase(int) | 纳秒级,略高 | 是 | 否 | 通用判断默认选它 |
加isTitleCase | 纳秒级 | 是 | 是 | 工具库统一实现 |
预编译正则find() | 纳秒级偏高 | 是 | 需手写Lt | 规则多、要拼装时 |
| 现编译正则 | 微秒级 | 是 | 视写法 | 严禁出现在循环里 |
substring+toUpperCase转换 | 微秒级(两次分配) | 是 | 是 | 通用转换 |
char[]原地改 | 纳秒到微秒 | 否 | 否 | 输入可确认是基本平面 |
codePoints()流 | 微秒级偏高 | 是 | 是 | 追求语义清晰、非热点 |
选型思路很简单:判断路径全部走codePointAt+isUpperCase || isTitleCase,这是唯一能覆盖全部边界、成本又可忽略的组合;转换路径走substring切分 +toUpperCase(Locale.ROOT),短字符串两次分配无所谓;只有在被 profiler 明确指出是热点的批量处理里,才考虑char[]或者自建缓冲区优化。
还有一个容易忽略的点:String.substring在 JDK 7 之后是复制实现,不再共享底层数组。这意味着对超长字符串取首字符,也要复制一小段(实际只复制你要的那一段,substring(0, 1)只复制一个 char)。如果你的输入是几十 KB 的报文,而只需要判断首字符,那就别substring,直接codePointAt判就完了——判断和转换是两件事,别为了"顺便拿到首字符"付出多余的内存。
5. 工程化封装:工具类、校验注解与单元测试
5.1 一个语义明确的工具类
散落在各个 Service 里的首字母判断代码,最后一定会不一致。我的做法是收敛到一个工具类里,语义用方法名区分清楚,不给调用方猜的机会:
public final class TextCase { private TextCase() { } /** 语义A:首字符是大写字母或标题大写;null、空串、空白开头一律 false */ public static boolean startsWithUppercase(CharSequence s) { return startsWithUppercase(s, false); } /** 语义A的宽松版:跳过前导空白再判断;其余同上 */ public static boolean startsWithUppercaseIgnoringBlank(CharSequence s) { return startsWithUppercase(s, true); } private static boolean startsWithUppercase(CharSequence s, boolean skipBlank) { if (s == null) { return false; } int i = 0; while (i < s.length()) { int cp = Character.codePointAt(s, i); if (skipBlank && (Character.isWhitespace(cp) || Character.isSpaceChar(cp))) { i += Character.charCount(cp); continue; } return Character.isUpperCase(cp) || Character.isTitleCase(cp); } return false; } /** 语义C:整串词首大写,后续不得出现大写字母 */ public static boolean isTitleCaseWord(CharSequence s) { if (s == null || s.length() == 0) { return false; } int first = Character.codePointAt(s, 0); if (!Character.isUpperCase(first) && !Character.isTitleCase(first)) { return false; } int i = Character.charCount(first); while (i < s.length()) { int cp = Character.codePointAt(s, i); if (Character.isUpperCase(cp) || Character.isTitleCase(cp)) { return false; } i += Character.charCount(cp); } return true; } /** 转换:只把首字符转大写,其余原样保留 */ public static String capitalize(String s) { if (s == null || s.isEmpty()) { return s; } int headLen = Character.charCount(s.codePointAt(0)); return s.substring(0, headLen).toUpperCase(Locale.ROOT) + s.substring(headLen); } /** 转换:词首大写 + 其余小写 */ public static String toTitleCase(String s) { if (s == null || s.isEmpty()) { return s; } int headLen = Character.charCount(s.codePointAt(0)); return s.substring(0, headLen).toUpperCase(Locale.ROOT) + s.substring(headLen).toLowerCase(Locale.ROOT); } }几个设计决定说明一下。null 一律返回 false 而不是抛异常,因为校验场景里 null 属于"不合规",交给上游的@NotBlank去报更精确的错误信息;如果你的场景里 null 表示"未设置",那就在调用前用Objects.requireNonNull挡掉,别让工具类承担这个语义。跳过空白单独做一个方法,而不是塞个布尔参数给外部调用,避免调用方不知道这个参数在控制什么。capitalize不做toLowerCase,理由前面说过,和多数语言标准库保持一致。
5.2 把规则落到接口层:自定义校验注解
校验逻辑写在 Service 里,最麻烦的是"错误信息不统一"和"新人忘了调"。用 Bean Validation 的自定义约束把规则前移到入参上,代码干净很多:
@Documented @Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = UppercaseStartValidator.class) public @interface UppercaseStart { String message() default "必须以大写字母开头"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; boolean skipBlank() default false; } public class UppercaseStartValidator implements ConstraintValidator<UppercaseStart, CharSequence> { private boolean skipBlank; @Override public void initialize(UppercaseStart anno) { this.skipBlank = anno.skipBlank(); } @Override public boolean isValid(CharSequence value, ConstraintValidatorContext ctx) { if (value == null) { return true; // null 交由 @NotBlank 处理,避免重复报错 } return skipBlank ? TextCase.startsWithUppercaseIgnoringBlank(value) : TextCase.startsWithUppercase(value); } }用法就很直白了:
public class CodeRequest { @NotBlank(message = "编码不能为空") @UppercaseStart(message = "编码必须以大写字母开头,例如 A1001") private String code; }这里的两个细节值得说。一是isValid对 null 返回 true,这是 Bean Validation 的惯例,空值校验交给@NotBlank、@NotNull,否则一个 null 会同时触发两条错误信息,前端展示很难看。二是错误信息里给示例,"必须以大写字母开头,例如 A1001"比"格式错误"有用得多,能省掉大量工单。
如果你们的校验框架不是 Bean Validation 而是自研的,把上面isValid的那三行原样搬过去就行,核心逻辑没依赖任何框架特性。
5.3 把边界固化成参数化测试
这类工具方法最大的风险不是"写错",而是"以后被人改错"。测试用例就是防改错的护栏,用参数化测试把边界一次性铺开:
class TextCaseTest { @ParameterizedTest(name = "[{index}] \"{0}\" 期望 {1}") @MethodSource("uppercaseCases") void testStartsWithUppercase(String input, boolean expected) { assertEquals(expected, TextCase.startsWithUppercase(input)); } static Stream<Arguments> uppercaseCases() { return Stream.of( Arguments.of("Hello", true), Arguments.of("H", true), Arguments.of("hello", false), Arguments.of("", false), Arguments.of((String) null, false), Arguments.of("1Hello", false), Arguments.of("_key", false), Arguments.of(" hello", false), Arguments.of("\u00C4rger", true), // Ä,基本平面带音标 Arguments.of("\u01C5abc", true), // Dž,标题大写 Lt Arguments.of("\uD801\uDC00abc", true), // U+10400,增补平面大写 Arguments.of("\uD801\uDC28abc", false) // U+10428,同族小写 ); } }注意Arguments.of((String) null, false)里的强制类型转换:Arguments.of是可变参数方法,直接写null编译器无法确定是"一个 null 元素"还是"整个数组为 null",必须加类型转换。这个坑我调过十分钟。
再补一组转换的用例,重点验证两件事:其余字符不被误伤、长度变化不引发越界。
| 输入 | capitalize结果 | 验证点 |
|---|---|---|
hello | Hello | 基本路径 |
hELLO | HELLO | 只动首字符,不擅自降级 |
ßeta | SSeta | 长度变化,不越界 |
džungla | DŽungla | 首字符增补映射 |
中文 | 中文 | 无大小写概念,原样返回 |
"" | "" | 空串短路 |
" hi" | " hi" | 首字符是空格,不跳过 |
ßeta这一条特别值钱:很多人写转换时默认"长度不变",一旦遇到这类字符就数组越界或者切错位置。把它写进测试里,以后谁改都能第一时间发现。
6. 常见问题排查实录
6.1 问题速查表
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 字母开头的字符串被判 false | 首字符是增补平面,用了charAt(0) | 换codePointAt(0)+isUpperCase(int) |
| 中文开头被判 true | 用了"转换后 equals 比对"的写法 | 换Character.isUpperCase判断 |
| 数字、下划线开头被判 true | 同上,或用了!isLowerCase语义 | 确认业务语义,必要时加isLetter前置 |
| 开发能过、线上不过 | 默认 Locale 不同导致转换结果不同 | 转换统一传Locale.ROOT |
Dž开头被判 false | 只判了Lu,漏了Lt | 加Character.isTitleCase |
| 正则版性能骤降 | Pattern.compile写在方法体内 | 提成静态常量 |
| 首字符处理完出现乱码方框 | 把代理对切开了,留下半个字符 | 用Character.charCount算切分长度 |
s.length() == 1判断"单字符"失效 | 增补平面字符占两个char | 改用s.codePointCount(0, s.length()) == 1 |
| 全角空格开头的串没被跳过 | trim()只处理U+0020及以下 | 用strip()(Java 11+)或自己按码点判isWhitespace |
| 转换后字符串长度和预期不符 | ß、连字的多对多映射 | 转换放最后一步,不依赖转换前后长度一致 |
6.2 几个我踩过的坑和固定下来的做法
坑一:把"判断"和"校验"混在一起。早期我写过isValidName这种方法,里面既判非空、又判长度、又判首字母,后来业务规则一改,调用方到处受影响。现在我的原则是:工具类只回答一个封闭的技术问题(首字符是不是大写),业务规则(多长、能不能有空格、允许哪些字符)放在校验注解或独立的领域校验类里,两层分开,改动不会互相污染。
坑二:在工具方法里"贴心地"吞掉异常。有段时间我在capitalize里加了个 try-catch,任何异常都返回原串。结果一个上游传了畸形字符串的 bug 被藏了两个月,直到报表数据全错才被发现。现在工具类只做两件事:正常路径返回确定结果,非法入参(null)按文档返回,绝不做"兜底返回原值"这种自作主张的事。真需要兜底的在调用侧显式写。
坑三:拿length()当字符数用。这个错误在"限制输入 20 个字"的需求里特别常见。String.length()返回的是 UTF-16 代码单元数量,不是用户眼里的字数,也不是码点数。要按"用户看到的字"限制长度,正确写法是s.codePointCount(0, s.length());要按字形簇算,就得用BreakIterator。我现在的习惯是:只要变量名叫charCount、wordCount,就在旁边加一行注释说明计量单位,省得半年后自己都记不清。
坑四:为了"统一风格"给所有转换加toLowerCase。一开始觉得capitalize就该输出"首大写其余小写",后来发现英文缩写被毁得面目全非:iPhone变Iphone,URLParser变Urlparser。现在的做法是提供两个方法,capitalize只动首字符,toTitleCase才做全量降级,让调用方按需选。API 语义分得越细,被误用的概率越低。
坑五:低估了测试数据的价值。我现在的习惯是把上面那张边界表固化成一个测试资源文件,每次改工具类都跑一遍。表里的字符看起来古怪(Dž、ß、代理对),但正是这些古怪数据在真实环境里救过场——某个海外市场的用户名字里就是有这些字符,没有测试用例的话,问题一定是在生产环境暴露。
最后分享一个我一直在用的判断顺序,简单但省事:先问语义(A/B/C/D 哪一个),再看输入来源(内部生成还是外部输入),最后才选 API。语义定了,API 基本就唯一了;输入来源决定了要不要处理代理对和空白;两步都跳过直接写charAt(0)的,多半会在某个凌晨被叫起来改代码。