uloc_getParent("en_GB") 返回 "en",CLDR 里 en-GB 的父级却是 en-001
同一个词 parent,在两处指的不是同一件事。ICU4C 的 uloc_getParent 把 en_GB 截成 en;cldr-json 的 parentLocales.json 里 en-GB 的父级写着 en-001。前者是字符串操作,后者是语言数据。这两套语义混在一起看,就会出现两种现象:补了词条却看不到,或者 zh-Hant 莫名其妙落到简体数据上。
uloc_getParent 只做截断,不读 CLDR 表
ICU4C 文档(https://unicode-org.github.io/icu-docs/apidoc/released/icu4c/uloc_8h.html)对 uloc_getParent 的描述是:"Truncate the locale ID string to get the parent locale ID. Copies the part of the string before the last underscore. The parent locale ID will be an empty string if there is no underscore, or if there is only one underscore at localeID[0]."
它完全不知道 CLDR 的 parentLocales 数据。下面这段可以直接编译:cc parent.c $(pkg-config --cflags --libs icu-uc)。
#include
#include
int main(void) {
const char *tags[] = {"en_GB", "en_AT", "es_MX", "zh_Hant", "pt_AO", "sr_Latn"};
char buf[64];
UErrorCode err = U_ZERO_ERROR;
for (int i = 0; i %s\n", tags[i], buf[0] ? buf : "(root)");
}
return 0;
}
输出是纯截断:en_GB -> en、es_MX -> es、zh_Hant -> zh、pt_AO -> pt、sr_Latn -> sr,和 CLDR 的 en-001 / es-419 / und / pt-PT / root 全部对不上。
CLDR 侧:显式父级 + 非默认 script 回 root
权威来源是 cldr-json 的 cldr-core/supplemental/parentLocales.json(https://github.com/unicode-cldr/cldr-json/blob/main/cldr-json/cldr-core/supplemental/parentLocales.json),5.8 KB 左右。CLDR 48 里能看到 en-GB -> en-001、en-150 -> en-001、es-MX -> es-419、pt-AO -> pt-PT、zh-Hant -> und 这些映射。
ICU 的 cldr-to-icu 工具里,SupplementalData.java(https://github.com/unicode-org/icu/blob/main/cldr-to-icu/src/main/java/org/unicode/icu/tool/cldrtoicu/SupplementalData.java)的 javadoc 写明父级计算比简单截断复杂,原因有二:CLDR 数据里有显式父级;去掉非默认 script 子标签后父级是 root。给的例子是 en_GB -> en_001 -> en -> root、en_Cyrl_RU -> en_Cyrl -> root。它还提供 getExplicitParentLocaleOf(),专门只取数据里显式写出的那个父级。
CLDR 45 把 parentLocales 拆成按 component 分表
CLDR-17336(PR https://github.com/unicode-org/cldr/pull/3504,2024-02,随 CLDR 45 发布)的改动点:
common/dtd/ldmlSupplemental.dtd:parentLocales 的 component 属性从单值 NMTOKEN 改成多值 NMTOKENS,允许取值 segmentations、collations、plurals、grammaticalFeatures。- parentLocale 元素新增 localeRules 属性,取值 nonlikelyScript。
supplementalData.xml里原来那串「直接继承 root」的超长 locale 列表,改成用 nonlikelyScript 规则表达;同时去掉重复项,新增 plurals 与 grammaticalFeatures 分量。- 工具侧:
SupplementalDataInfo里的 parentLocales 变成 map of maps,按 component 分段;LocaleIDParser的 boolean ignoreParentLocale 换成 enum component。
迁移补丁 PR https://github.com/unicode-org/cldr/pull/3534(2024-02-27 合并)把 localeRules 改成独立属性,locale 属性恢复成只放列表;v44 的 locales 列表在这一版被恢复保留。
CLDR 45 发布说明(https://cldr.unicode.org/downloads/cldr-45)原文:"In Parent Locales, made substantial changes to the way that parentLocales work, including a new attribute for algorithmic handling of inheritance that avoids needing a long (and fragile) list of language-script codes to skip when falling back to root. That list was retained for migration, but will be withdrawn in the future." Migration 第 1 条要求实现支持 nonlikelyScript,并重申 v44 那张直接继承 root 的列表 "retained for this release, but will disappear in the future"。
对消费方来说,后果很具体:只读 parentLocale.locales 显式列表的老代码,在旧列表撤掉后拿不到 zh-Hant / sr-Latn 这类 locale 的父级信息,会退化成截断式 zh-Hant -> zh,也就是拿到简体数据。
加新 locale 时官方怎么定父级
https://cldr.unicode.org/development/adding-locales 写明:父级能靠截断子标签得到,就不需要 parentLocale 信息(大多数情况如此);但该 locale 用的是这门语言的非默认 script 时,父级必须写 root,例如 zh_Hant 与 sr_Latn 必须回 root,而不是回 zh / sr(那是另一种 script)。en_GB 继承 en_001 而不是 en;nb 继承 no 而不是 root。
业务框架自造链,和 CLDR 表对不上
Keycloak 的 LocaleUtil(https://github.com/keycloak/keycloak/blob/main/services/src/main/java/org/keycloak/services/util/LocaleUtil.java)里,getParentLocale 是 variant -> country -> language -> realm 默认 locale -> en 的截断式回退,getApplicableLocales("de-CH") 返回 de-CH、de、en。CLDR 表里有 de-CH -> de-DE 这类显式父级,但这条链不会去读。
Java 的 Locale#getParent() 同样是结构性截断(去 variant、去 country、去 script),不看 CLDR 表;ResourceBundle 的默认 candidate locale 生成也走这套。Java 侧要 CLDR 语义,只能自己读表,或者用 ICU 的 CLDR 数据。
排查顺序
- 先确认问的是哪个 parent:数据父级(parentLocales.json),还是 API 父级(
uloc_getParent/Locale#getParent/ 框架自己的 fallback)。 - 把三条实际返回都打出来对比:
uloc_getParent、Locale#getParent、ResourceBundle 的 candidate locales。 - 读表,用
Object.keys打出所有分量块名,不要凭记忆假设 plurals / grammaticalFeatures 分量一定存在,CLDR 版本不同会变。 - 如果是「补了词条却看不到」,先确认词条落在链的哪一层:en-001(80+ 地区)还是 en(含美式)。
对照脚本(Node + cldr-core)
const core = require('cldr-core/supplemental/parentLocales.json');
const pl = core.supplemental.parentLocales;
// 1) 这个 CLDR 版本到底输出了哪些分量
console.log(Object.keys(pl));
// 2) 主数据表(大多数实现只看这一张)
const main = pl.parentLocale;
const expect = main['en-GB']; // 'en-001'
const truncated = 'en-GB'.split('-').slice(0, -1).join('-');
console.log({ cldrParent: expect, truncation: truncated }); // 两者不同就是 bug 面
// 3) nonlikelyScript 规则:lang-Script 且 script 非该语言默认 script 时,父级是 root
const rules = pl._localeRules?.parentLocale?.nonlikelyScript; // 打印出来确认是 'root'
console.log('nonlikelyScript =', rules);
// 4) 找出所有「显式父级 != 截断结果」的 locale,这就是必须读表的地方
const diverged = Object.entries(main)
.filter(([loc, parent]) => parent !== loc.split('-').slice(0, -1).join('-'));
console.log(diverged.length, diverged.slice(0, 5));
版本分界
| 版本 | parentLocales 结构 | 消费方要注意 |
|---|---|---|
| CLDR 44 及以前 | 只有一套 main 表(外加 collations),root 继承靠一长串 locales 列表 | 读 locales 数组能拿到 zh-Hant / sr-Latn 的 root 父级 |
| CLDR 45 起 | component 可多值(segmentations / collations / plurals / grammaticalFeatures),新增 localeRules=nonlikelyScript;旧列表保留供迁移 | 需要实现 nonlikelyScript 算法;只读 locales 数组会逐渐失效 |
| 未来版本 | 旧 root 继承列表撤销 | 不支持 nonlikelyScript 的实现会把 zh-Hant 落到 zh |
升级自查清单
- 代码里有没有硬编码的 root 继承列表(zh_Hant、sr_Latn、bs_Cyrl…)?改成按 likelySubtags 判断 script 是否非默认。
- 有没有区分 main 与 collations 两套表?排序链不是主数据链。
- 有没有把框架的
Locale#getParent/uloc_getParent当成 CLDR 父级用? - CLDR 升级前后,把 diverged 列表做成 golden file diff,看哪条映射变了。
- 未解点:plurals 与 grammaticalFeatures 分量在 cldr-json 里实际输出成什么样,用
Object.keys打出来确认,不要照抄假设。