云计算百科
云计算领域专业知识百科平台

Straße 和 Strasse 到底算不算同一家:14 行名单在 5 种「忽略大小写」口径下的重复数

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}

事情起因很普通:一份多语言供应商名单要合并去重,再统一成 Title Case 显示在后台下拉框里。名单不大,14 行,德文、土耳其文、希腊文、连字各有一点。第一版代码只有一行——new Set(rows.map(s => s.toLowerCase())),跑出来是 9 条;有人觉得太多,改成 toUpperCase(),变成 5 条;换成 Intl.Collator 做比较,7 条;什么都不做的原样去重,14 条。

同一份输入、同一个「忽略大小写」的说法,五种实现给出四个不同的答案,而且没有一种报错。这不是实现质量差别,而是「忽略大小写」这四个字在 Unicode 里本来就不是一件事:

  • 大小写映射不是 1:1:32 个样本里 7 个 转换后码元数会变,ß 1→2、ffi 1→3、İ 小写化 1→2;
  • 映射还依赖位置:希腊文词尾 sigma 会让同一个大写字母 Σ 在词中落成 σ、在词尾落成 ς;
  • 映射依赖 locale:'I'.toLocaleLowerCase('tr') 得到的是 ı,不是 i;
  • 映射对表外的字符直接不存在:34 个样本里 6 个 大小写双向都不变,非 BMP 字母用 charAt(0) 那套首字母大写惯用法会整步失效。

这篇文章把四条各自做成实测,最后给一份能直接抄的折叠键实现、一份逐码点的首字母大写实现,和一张「大小写转换前该问的 12 个问题」。

一、先摊开这份名单和五种口径的结果

14 行名单(就是上面那批,含刻意的重复):

straße STRASSE Straße strasse
İstanbul ISTANBUL istanbul ıstanbul
Σ aaa σ aaa ς aaa
file FILE file

五种「忽略大小写」的实现与它们的唯一数:

口径实现要点唯一数
原样 不做任何折叠 14
lower 键 s.toLowerCase() 9
Collator Intl.Collator('und', {sensitivity:'accent'}) 两两比较 7
upper 键 s.toUpperCase() 5
手工安全折叠 小写化后再补 ß→ss、İ/ı→i、连字展开、ς→σ 5

逐条明细(lower 键与手工折叠键都印出来,upper 键因为只剩 5 类没单独列):

"straße" lower="straße" fold="strasse"
"STRASSE" lower="strasse" fold="strasse"
"Straße" lower="straße" fold="strasse"
"strasse" lower="strasse" fold="strasse"
"İstanbul" lower="i̇stanbul" fold="i̇stanbul"
"ISTANBUL" lower="istanbul" fold="istanbul"
"istanbul" lower="istanbul" fold="istanbul"
"ıstanbul" lower="ıstanbul" fold="istanbul"
"Σ aaa" lower="σ aaa" fold="σ aaa"
"σ aaa" lower="σ aaa" fold="σ aaa"
"ς aaa" lower="ς aaa" fold="σ aaa"
"file" lower="file" fold="file"
"FILE" lower="file" fold="file"
"file" lower="file" fold="file"

四个数各自漏在哪里,正好对应后面四节:

  • lower 键 = 9:straße / Straße / STRASSE / strasse 四种写法被折成两种(ß 小写化还是 ß,STRASSE 小写化成 strasse),四种土耳其 i 写法被折成三种(İstanbul 小写化后是 i 加上组合上句点,与 istanbul 不相等),三种 sigma 写法折成两种(ς 不受 toLowerCase 影响),file 那组折成两种(连字小写化还是连字)。
  • upper 键 = 5:反过来,ß、fi 大写化成 SS、FI 之后与拉丁拼写完全一致,Σ/σ/ς 全并成 Σ。但 İstanbul 与 ISTANBUL 在 upper 键下仍然不同(İ 大写化是它自己),ıstanbul 与 istanbul 也不同。
  • Collator = 7:它能认 Σ/σ/ς 与 fi/FI 的等价,却仍然把 ß 与 ss 判成不同(accent 档给的是 1 或 −1,不是 0)。
  • 手工折叠 = 5:这档不是「更聪明」,而是明确定义了业务上的等同关系——把连字展开、把 ß 换成 ss、把两种土耳其 i 归到 i、把词尾 sigma 归到 σ。它是唯一需要你去写出来的那一个。

顺带一条实测:14 对组合里,「lower 键是否相等」与「upper 键是否相等」这两个判定本身就有 4 对不一致(straße/STRASSE、Straße/strasse、ffl/FFL、file/FILE 是 lower 不同而 upper 相同)。所以不存在一个「标准的大小写不敏感相等」,只有选定的口径。

二、大小写映射不是 1:1:码元数会变的 7 个样本

String.prototype.toUpperCase/toLowerCase 走的是 Unicode 的全映射(full case mapping),一个字符可以映射成多个字符。32 个样本里长度会变的 7 个,逐条实测(码元数 / UTF-8 字节数):

输入原大写化小写化说明
ß 1 / 2 B 2 / 2 B = SS 1 / 2 B 小写没有对应单字符的大写形式,只能展开
fi 1 / 3 B 2 / 2 B = FI 1 / 3 B 码元变多、字节反而变少
ſt 1 / 3 B 2 / 2 B = ST 1 / 3 B 同上
ffi 1 / 3 B 3 / 3 B = FFI 1 / 3 B 一个字符炸成三个
İ 1 / 2 B 1 / 2 B 2 / 3 B = i + U+0307 小写化时新增一个组合符
ΐ 1 / 2 B 3 / 6 B = Ι + U+0308 + U+0301 1 / 2 B 希腊文带分音符重音,展开成三码元
ʼn 1 / 2 B 2 / 3 B = U+02BC + N 1 / 2 B 前面那个是撇号,不是字母

三件事要从这两张表里拿稳:

  • 长度公式在字符层就不成立。任何「转换前后长度守恒」的假设、任何按 input.length 预留缓冲区、按码元下标切片的做法,在这里都会错位。上面 İ 那行是典型:小写化把 1 个码元变成 2 个、2 字节变成 3 字节。
  • 码元数与字节数可以反向变化。fi 是 U+FB01,UTF-8 占 3 字节;大写化后的 FI 是两个 ASCII,占 2 字节。按字节算「变短了」,按码元算「变长了」——这两套数字在对齐、截断、存 varchar(n) 时是不同约束。
  • 大小写化不等于规范化。é 这类预组合字符的映射是 1→1;而分解式输入(A + 组合重音)实测只做基础字母的映射,组合符原样保留:A\\u0300 小写化成 a\\u0300(2 码元不变),I\\u0307 小写化成 i\\u0307(2 码元),不会顺手合成一个预组合字符。也就是说,a\\u0300 与 à 经过大小写化仍然不相等——想合它们得另走 normalize('NFC')。
  • 同一页工具上也能量到这两条:İstanbul 8 码元 / 9 字节,小写化后 9 码元 / 10 字节;straße 6 码元 / 7 字节,大写化后 7 码元 / 7 字节(多一个码元、字节不变)。

    三、有大小写但没有映射的字符:6 个双向惰性样本

    映射表里没有条目时,转换是静默的空操作——不报错、不提示、结果与输入逐字节相同。34 个样本里 toUpperCase 与 toLowerCase 双向都不变的有 6 个:

    𝐀 数学粗体大写字母(U+1D400,2 码元)
    № 序号标志
    ㍿ 日文方头字「株式会社」
    1 数字
    中 汉字
    ᵫ 小型字母 u(U+1D6B)

    同一批里「大写化不变」的有 18 个、「小写化不变」的有 20 个。这个不对称值得记一下:像 Ω、Ⱥ、Ŋ、Œ、ẞ、Σ、ΟΔΟΣ 这些已经大写的串,大写化是空操作,但小写化有效;反过来 ß、fi、æ、ʒ、dž 小写化是空操作,大写化有效。所以「跑了大写化之后字符串没变」有两种完全不同的含义:它本来就全大写,或者它压根没有映射。 只看输出长度判断「转换成功」会同时踩中这两种。

    𝐀 那一行尤其容易看走眼:它在 Unicode 里视觉上确实有粗体小写对应(数学字母表符号区段),但 JavaScript 的映射表不为数学字母表符号定义大小写,实测 '𝐚'.toUpperCase() === '𝐚'、'𝐀'.toLowerCase() === '𝐀' 都成立。同理 № 里的 N、㍿ 里的汉字,任何大小写操作都是空。

    四、位置敏感:同一个字母,词中和词尾不是同一个字符

    希腊文的大写 sigma 小写化时,结果取决于它在词里的位置(Unicode 的 Final_Sigma 特殊映射)。6 组实测,右边是输出码点:

    "Σ" → "σ" [03C3]
    "ΑΣΒ" → "ασβ" [03B1 03C3 03B2] ← 词中:普通 σ
    "ΟΔΟΣ" → "οδος" [03BF 03B4 03BF 03C2] ← 词尾:终形式 ς
    "ΣΣΣ" → "σσς" [03C3 03C3 03C2] ← 一串同字母,末位仍换成 ς
    "ςΣς" → "ςσς" [03C2 03C3 03C2] ← 已经是终形式的保持
    "ΑΒΣ ΓΔ" → "αβς γδ" [03B1 03B2 03C2 0020 03B3 03B4] ← 词尾判定连着空白分词

    对做键值折叠的人来说,这意味着:σ 与 ς 是两个不同的码点,toLowerCase 不会把它们并起来(它只处理大写→小写方向)。名单里 σ aaa、ς aaa 两种写法在小写键下算两条,在大写键下算一条(都升成 Σ);想两向都稳,就得显式做折叠(ς → σ)。而 ΣΣΣ 小写化会得到 σσς,再拿它去和原串比「是不是幂等」,你会以为函数不稳定——其实是映射本身带上下文。

    另一个同源的位置敏感映射是 Β… 不,更常见的坑是 Dž 这类标题式连字(U+01C5 是「大写 D + 小写 z」的单一码位):它的大写化是 DŽ、小写化是 dž,三个不同码位,而 Dz 两个码位的大写化是 DZ。实测 Džurad 小写化得到 džurad(5 码元),大写化得到 DŽURAD——两个方向都保持 5 码元,但落到的码位与 DZ 系完全不同,折叠时需要按码位处理而不是按「看起来是什么」。

    五、locale 敏感:i 家族在不同语言里不是同一个字母

    土耳其语和阿塞拜疆语有两对 i:带点的 İ/i 与不带的 I/ı,它们的映射与默认口径交叉。实测:

    输入toLowerCase()toLocaleLowerCase('tr')toUpperCase()toLocaleUpperCase('az')
    ISTANBUL istanbul ıstanbul ISTANBUL ISTANBUL
    İstanbul i̇stanbul(i + U+0307) istanbul İSTANBUL İSTANBUL
    i i i I İ
    I i ı I I
    fileID fileid fileıd FILEID FİLEID
    INFO info ınfo INFO INFO

    三条口径结论:

  • toLowerCase / toUpperCase 与 locale 无关,这是规范里的硬约定,上表第四列与第一列在同一台机器上一致。所以用它们做折叠键,同一份输入在哪台机器上都得到同一个串——这是好事,也是「默认口径」和「土耳其口径」会分家的根源。
  • toLocaleLowerCase 不传参时跟随实现解析出的默认 locale,不是跟随你代码里的假设。实测这台机器 Intl.DateTimeFormat().resolvedOptions().locale 是 zh-CN,把环境变量改成 LC_ALL=tr_TR.UTF-8 也没能把它挪走(三档环境变量测下来解析结果恒为 zh-CN)。换句话说:跨环境时你无法靠本机复现,也无法靠环境变量控制;只有显式传 locale 才是确定的。
  • 真正的事故形态是混用:服务端用 JS toLowerCase() 折出的键是 istanbul,而浏览器里 CSS text-transform: lowercase 或某个 ICU 组件按用户 locale 折出 ıstanbul;fileID 这类驼峰字段名被 toLocaleLowerCase('tr') 折成 fileıd 之后,查不到列名、匹配不到 key、生成的文件名在大小写不敏感文件系统上对不上——同一段代码,用户系统语言一变就变。业务里需要「按用户语言折」的场景(例如给土耳其用户展示全小写标题)要显式传 locale,并且绝不要用 locale 版函数生成任何键、ID、路径。
  • 六、非 BMP:charAt(0) 那套首字母大写会整步失效

    「取首字符大写 + 其余小写」是 Title Case 的常见写法。问题在于 JavaScript 字符串是 UTF-16 码元序列,charAt(0) 取到的可能是半个字符。实测下表两版:「码元版」= s.charAt(0).toUpperCase() + s.slice(1).toLowerCase(),「码点版」= 先用 Array.from 切成码点数组、再做同样两步。

    "𐐨𐐴𐐨"(Deseret,U+10428 U+10434 U+10428) 码元6 码点3
    全串 upper = "𐐀𐐌𐐀"(U+10400 U+1040C U+10400,正确)
    码元版 = "𐐨𐐴𐐨"(原样,首字母没大写) 码点版 = "𐐀𐐴𐐨"(正确)
    "𞤢𞥁"(Adlam,U+1E922 U+1E941) 码元4 码点2
    全串 upper = "𞤀𞤟"(U+1E900 U+1E91F,正确)
    码元版 = "𞤢𞥁"(原样) 码点版 = "𞤀𞥁"(U+1E900 U+1E941,首字母大写生效)
    "𐖜"(Vithkuqi,U+1059C) 码元2 码点1
    码元版 = "𐖜"(原样) 码点版 = "𐕵"(U+10575,正确)
    "x😀y" 码元4 码点3 码元版="X😀y" 码点版="X😀y"(一致)
    "Džurad" 码元5 码点5 码元版="DŽurad" 码点版="DŽurad"(一致,但都不是期望的 Džurad)
    "ffi" 码元1 码点1 码元版="FFI" 码点版="FFI"(码元数 1→3)

    6 个非 BMP / 特殊样本里 3 个 两版结果不同,全都是「码点版对、码元版什么都不做」。机制很直白:首字符是代理对时,charAt(0) 拿到前代理(孤立代理码元没有任何大小写映射),slice(1) 从后代理开始(同样没有映射),于是整步静默跳过。

    好消息是它不会把字符串弄坏——x😀y 这类 emoji 不在下标 0 时,两个码元都保持原样,字节数不变(4 码元 / 6 字节原样)。坏消息是它「什么都没做」的样子,和「这个字符本来就不需要大写」完全一样(见第三节那 6 个惰性字符)。判定方法只有一个:拿码点版的输出与码元版比对,不一致就说明码元版漏了。

    顺带两条相关口径,实测在同一批样本上分叉:

    • 「什么算小写/大写」:/[A-Z]/ 对 ΩÀßDžİ𝐀 命中 0 个,/\\p{Lu}/u 命中 3 个。用 ASCII 字符类做「首字母是否已大写」的判断,对带音标的字母一律给出「不是大写」的结论。
    • 驼峰边界的识别:只看「ASCII 小写紧跟 ASCII 大写」时,13 条样本有 6 条 与按 \\p{Ll}\\p{Lu} 识别的段数不同(àB、āB、ßS、éE、ñA、üU 在前者眼里是一个词,在后者眼里是两个)。

    七、字符之外的坑:空白口径与「看不见但算内容」

    大小写工具几乎总有「空输入不处理」的守卫,而 trim() 与 /s+/ 认定的空白范围比肉眼宽:

    被判成空(实测 6 类,直接不处理):
    ""(空串)、" "(3 个空格)、"\\t\\t"(2 个制表符)、"\\n"、
    "\\uFEFF"(BOM,3 字节)、"\\u3000"(全角空格,3 字节)
    不被判成空、原样进输出的:
    "\\u200B"(零宽空格)→ 既不被 trim() 认空白,也不被 /\\s+/ 切分,输出仍是 1 码元 / 3 字节

    后三类(BOM、全角空格、换行)屏幕上看着「有东西」,守卫却按空处理;反过来零宽空格这种「屏幕上什么都没有」的字符会一路活到输出。粘帖带 BOM、从 Excel 复制带全角空格,是最常见的两种「为什么它说我没输入」。

    另一类是同一页面里的两套空白口径:直接大写化/小写化两个模式不做任何 trim 或合并空白," hello world "(17 字节)输出还是 17 字节、首尾空格原样保留;而依赖切词的那几个模式会 trim 并合并连续空白,同一输入只剩 10–11 字节(camelCase/PascalCase 10 字节,Title Case/snake_case/kebab-case 11 字节)。复制粘贴一轮之后首尾空格消失,不是你点的按钮不同,是两个模式走的路径不同。 同理 aAb 里的全角 A(U+FF21)也在映射表内:实测「首字母大写」模式得到 Aab——首字符 ASCII a 被大写化,中间的全角 A 被小写化成了全角 a。映射表覆盖的不只是 ASCII 字母,只是各分支是否调用它不一致。

    规模上没有任何护栏:200000 字符的输入实测 11 ms 完成、切出 40001 个词,没有长度上限、没有 try/catch。所以「粘贴一整个日志文件」这件事不会失败,只会安静地按上面的规则处理掉。

    八、可抄实现

    折叠键:先 NFC,再小写化,再补上「映射不会自动做的等价」。这是本文那张 14 行名单唯一能给出「按业务等同」结果的做法,也是它和 toUpperCase() 版(凑巧也 5 条)的区别——它明确,可解释、可增删。

    const LIGATURES = {
    '\\uFB00': 'ff', '\\uFB01': 'fi', '\\uFB02': 'fl',
    '\\uFB03': 'ffi', '\\uFB04': 'ffl', '\\uFB05': 'st', '\\uFB06': 'ft',
    };

    // 业务折叠键:确定、locale 无关、跨端可复现
    function caseFoldKey(s) {
    return s
    .normalize('NFC') // 先把分解式合成,避免 à 与 a+U+0300 分家
    .toLowerCase() // 默认(locale 无关)口径的大写→小写
    .replace(/[\\uFB00-\\uFB06]/g, m => LIGATURES[m]) // 连字展开:toLowerCase 不展开已经是小写的连字
    .replace(/\\u00DF/g, 'ss') // ß → ss(小写化不会替你做)
    .replace(/[\\u0130\\u0131]/g, 'i') // İ ı → i:跨语言实体名的常见分家点
    .replace(/\\u03C2/g, '\\u03C3') // 词尾 ς → σ
    .normalize('NFC'); // 替换之后再合成一次,防止替换引入分解式
    }

    首字母大写:按码点走,别按下标。

    function titleCase(firstUpperRest) {
    const cps = Array.from(firstUpperRest); // 码点数组,代理对不会被切开
    if (cps.length === 0) return '';
    const head = cps[0].toUpperCase(); // 无映射时返回原样,不会损坏
    const tail = cps.slice(1).join('').toLowerCase();
    return head + tail;
    }
    // 需要真正「词」级的 Title Case 时,用 Intl.Segmenter 按字素切:
    // 'á'(a+U+0301) 是 1 个字素、2 个码元;'🇨🇳' 是 1 个字素、4 个码元

    自检函数:把「静默空操作」和「长度变化」都量出来,用来在上线前知道这批数据里有多少条会白跑。

    function caseAudit(rows) {
    const rep = { lenChanged: 0, inertUpper: 0, inertLower: 0, localeRisk: 0, nonBmp: 0 };
    const risky = /[\\u0130\\u0131Ii]/; // i 家族:locale 敏感区
    for (const s of rows) {
    if (s.toUpperCase().length !== s.length || s.toLowerCase().length !== s.length) rep.lenChanged++;
    if (s.toUpperCase() === s) rep.inertUpper++;
    if (s.toLowerCase() === s) rep.inertLower++;
    if (risky.test(s)) rep.localeRisk++;
    if ([…s].some(c => c.codePointAt(0) > 0xFFFF)) rep.nonBmp++;
    }
    return rep;
    }

    在本文那份 14 行名单上跑 caseAudit,实测落点是 lenChanged=4(straße、Straße、İstanbul、file 这四行会因大小写化改变码元数——前三行分别靠 ß→SS 与 İ→i+U+0307,第四行靠 fi→FI)、inertUpper=3、inertLower=8、localeRisk=6(四行土耳其写法,加上 FILE、file——判定式里只要出现 I 或 i 就算风险行,取向是宁多勿少)、nonBmp=0。结论是这批数据的主要风险既不在长度也不在非 BMP,而在折叠键与 locale;lenChanged=4 比直觉的 1 行多出一倍,因为多数人会忘记 straße 这类串在大写化方向也会变长。

    九、对照现场

    这些分歧我是一边在 大小写转换 上手填、一边把它的转换函数逐字抽出来在 Node 里复核的。它把「7 种模式」平铺在同一屏,好处是能一次看到同一份输入在不同口径下的输出并排摆着:İstanbul 那行在小写化模式下多出的组合符、aAb 那行里被顺手小写化的全角字母、BOM 或全角空格被判成空输入时那种「什么都没发生」的样子,都是肉眼可比的。要注意的是它只给结果、不给判据:这一页的 Title Case 会把词内其余字母一律小写化,所以 HTTPServer 之类的写法在这里从来不是「原样保留首字母大写」的语义;那部分属于切词与往返行为的话题,本文的焦点是字符层的映射本身,不重复展开。

    验收清单

  • 别问「忽略大小写怎么做」,先问「哪几种写法算同一条」:同一份 14 行名单,原样 14、小写键 9、Collator 7、大写键 5、显式折叠 5,四种答案都不报错。把口径写成函数并留在代码里,不要留在注释里。
  • 折叠键必须先 normalize('NFC') 再大小写化,替换完再合成一次;à 与 a+U+0300 经过 toLowerCase 仍然不相等(实测只映射基础字母,组合符原样)。
  • 不要指望 toLowerCase 展开连字或替换 ß:已经是小写的 fi、ß 走小写化是空操作,必须显式列进折叠表(ff fi fl ffi ffl ſt st、ß→ss、ς→σ、İ/ı→i)。
  • 任何按「转换前后长度守恒」写的代码都要重查:32 个样本里 7 个长度会变,方向有增(ΐ 1→3 码元、İ 小写化 1→2)也有减(ffi 小写化 3→1),且 fi 的码元数与字节数变化方向相反。数据库列宽、缓冲区、按码元下标的切片都属于这一类。
  • 判定「转换是否生效」不要用「输出等于输入」当证据:34 个样本里 6 个双向惰性(𝐀 № ㍿ 1 中 ᵫ),「本来就全大写」与「根本没有映射」输出完全同形;要断言,请断言码点层面的映射存在性。
  • 位置敏感映射要写进测试:ΟΔΟΣ→οδος(词尾 ς)、ΣΣΣ→σσς、ΑΒΣ ΓΔ→αβς γδ,并知道「小写化一遍再比对」这种幂等断言在它们身上会假失败。
  • 键、ID、文件名、路径一律用 locale 无关的 toLowerCase/toUpperCase;toLocale* 只在展示层用,且必须显式传 locale。实测 'I'.toLocaleLowerCase('tr')=ı、'i'.toLocaleUpperCase('az')=İ、fileID→fileıd;不传参时跟随实现解析的默认 locale,本机三种环境变量都没能改动它,说明这条路无法在本地复现,只能显式指定。
  • 首字母大写用码点或字素,不用 charAt(0) + slice(1):实测 Deseret、Adlam、Vithkuqi 三组非 BMP 小写词的首字母在码元版里整步没做(输出与输入同形),码点版正确;全串 toUpperCase() 也正确。
  • 大小写相关的正则用 Unicode 属性类:/[A-Z]/ 对 ΩÀßDžİ𝐀 命中 0 个,/\\p{Lu}/u 命中 3 个;「什么算小写紧跟大写」在 13 条样本上有 6 条两版不一致。
  • 「空输入」的判据要显式写清:trim() 认 BOM(U+FEFF)与全角空格(U+3000)为空白,所以「看得到字符」的输入会被判空;反之零宽空格 U+200B 不被 trim 也不被 /\\s+/ 切,会一路活到输出。两者都要单独测。
  • 同一屏里的不同模式可能走不同分支:直接大小写化不 trim、不合并空白(17 字节原样带首尾空格),依赖切词的模式会 trim 并合并(同一输入剩 10–11 字节)。跨模式复制粘贴前先确认走的是哪一路。
  • 大输入没有护栏:20 万字符级(实测 200000 字符 11 ms、40001 个词)不会失败也不会提示,所以「跑得快」不等于「按你想要的规则跑」;批量数据上线前用第八节那个 caseAudit 出一份分布,再决定折叠键。
  • 赞(0)
    未经允许不得转载:网硕互联帮助中心 » Straße 和 Strasse 到底算不算同一家:14 行名单在 5 种「忽略大小写」口径下的重复数
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!