为什么在Swift字符串中,像‍‍‍这样的表情符号字符被处理得如此奇怪?

584

字符 ‍‍‍(包含两个女性、一个女孩和一个男孩的家庭)的编码如下:

U+1F469女人
‍U+200DZWJ
U+1F469 女人
U+200D ZWJ
U+1F467女孩
U+200D ZWJ
U+1F466男孩

这段代码编码非常有趣,是单元测试的完美目标。然而,Swift似乎不知道如何处理它。我是这个意思:

"‍‍‍".contains("‍‍‍") // true
"‍‍‍".contains("") // false
"‍‍‍".contains("\u{200D}") // false
"‍‍‍".contains("") // false
"‍‍‍".contains("") // true

所以,Swift 说它包含自身(好)和一个男孩(好!)。但是它接着说它不包含女人、女孩或零宽连接器。这里发生了什么?为什么 Swift 知道它包含男孩而不是女人或女孩? 如果它将其视为单个字符并且只识别它包含自身,我可以理解,但事实上它只获取了一个子组件,没有其他子组件使我感到困惑。

如果我使用类似 "".characters.first! 的东西,这种情况不会改变。


更令人困惑的是这个:
let manual = "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}"
Array(manual.characters) // ["‍", "‍", "‍", ""]

尽管我在那里放置了零宽连字符,但它们并没有反映在字符数组中。接下来发生的事情有点说明:

manual.contains("") // false
manual.contains("") // false
manual.contains("") // true

所以我使用字符数组时得到了相同的行为...这非常令人恼火,因为我知道数组的样子。

如果我使用类似于"".characters.first!这样的东西,这也不会改变。


1
评论不适合进行长时间的讨论;此对话已被移至聊天室 - Martijn Pieters
1
在Swift 4中已经修复了这个问题。"‍‍‍".contains("\u{200D}")现在会返回true,这是一个修复bug而非feature的结果。 - Kevin
5
哎呀,Unicode 已经毁了纯文本。它把纯文本变成了一种标记语言。 - Boann
10
@Boann 是和不是……很多这些改变是为了使像韩文字母(255个代码点)这样的编码/解码工作不像对于汉字(13,108个代码点)和中文表意文字(199,528个代码点)那样成为一场绝对的噩梦。当然,情况比一个SO评论的长度更加复杂和有趣,所以我鼓励你自己去了解一下:D - Ky -
显示剩余2条评论
6个回答

439

这与Swift中String类型的工作方式以及contains(_:)方法的工作方式有关。

'‍‍‍ '是已知为表情符号序列,它在字符串中被呈现为一个可见字符。该序列由Character对象组成,同时也由UnicodeScalar对象组成。

如果您检查字符串的字符计数,您将看到它由四个字符组成,而如果您检查Unicode标量计数,则会显示不同的结果:

print("‍‍‍".characters.count)     // 4
print("‍‍‍".unicodeScalars.count) // 7

现在,如果你解析这些字符并打印它们,你会看到似乎是普通的字符,但事实上前三个字符包含一个表情符号以及一个零宽连接器在它们的 UnicodeScalarView 中:

for char in "‍‍‍".characters {
    print(char)

    let scalars = String(char).unicodeScalars.map({ String($0.value, radix: 16) })
    print(scalars)
}

// ‍
// ["1f469", "200d"]
// ‍
// ["1f469", "200d"]
// ‍
// ["1f467", "200d"]
// 
// ["1f466"]

正如您所见,只有最后一个字符不包含零宽连接器,因此使用contains(_:)方法时,它会按照您的期望工作。由于您不是与包含零宽连接器的表情符号进行比较,因此该方法将无法找到除最后一个字符以外的匹配项。

更具体地说,如果您创建了一个以零宽连接器结尾的表情符号字符组成的String,并将其传递给contains(_:)方法,它也将计算为false。这涉及到contains(_:)range(of:) != nil完全相同,它尝试查找给定参数的精确匹配项。由于以零宽连接器结尾的字符形成了不完整的序列,该方法在将以零宽连接器结尾的字符组合成完整序列时尝试找到参数的匹配项。这意味着如果:

  1. 参数以零宽连接器结尾,并且
  2. 要解析的字符串不包含不完整序列(即以零宽连接器结尾且未跟随兼容字符)。

演示如下:

let s = "\u{1f469}\u{200d}\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}" // ‍‍‍

s.range(of: "\u{1f469}\u{200d}") != nil                            // false
s.range(of: "\u{1f469}\u{200d}\u{1f469}") != nil                   // false

然而,由于比较只向前查看,因此您可以通过向后查找,在字符串中找到其他几个完整序列:

s.range(of: "\u{1f466}") != nil                                    // true
s.range(of: "\u{1f467}\u{200d}\u{1f466}") != nil                   // true
s.range(of: "\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}") != nil  // true

// Same as the above:
s.contains("\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}")          // true
最简单的解决方案是为range(of:options:range:locale:)方法提供一个特定的比较选项。选项String.CompareOptions.literal精确逐字符等效性上执行比较。顺便提一下,在这里所指的字符并不是Swift Character,而是实例和比较字符串的UTF-16表示形式 - 然而,由于String不允许畸形的UTF-16,因此这基本上相当于比较Unicode标量表示。
在这里,我已经重载了Foundation方法,所以如果您需要原始方法,请将此方法重命名或其他操作:
extension String {
    func contains(_ string: String) -> Bool {
        return self.range(of: string, options: String.CompareOptions.literal) != nil
    }
}
现在该方法能够像应该一样处理每个字符,即使是不完整的序列。
s.contains("")          // true
s.contains("\u{200d}")  // true
s.contains("\u{200d}")    // true

48
根据当前的UTR29(Unicode 9.0)规范,它是一个扩展字形簇(规则GB10和GB11),但Swift明显使用较旧的版本。显然,修复这个问题是语言第四版的目标,因此这种行为将在未来发生改变。 - Michael Homer
9
据@MichaelHomer称,这个问题已经得到了解决。在当前的Xcode 9 beta和Swift 4中,"‍‍‍".count的值为1。 - Martin R
9
哇,这太棒了。但现在我开始怀念过去,当时我遇到字符串的最大问题只是它们使用的是C风格还是Pascal风格的编码。 - Owen Godfrey
6
我理解Unicode标准为什么需要支持这个,但是,天呐,这个实现过于工程化,如果说有什么的话 :/ - Kuba hasn't forgotten Monica
4
正确的东西没有被过度设计。 - gnasher729
1
嗨。我认为自从这个答案被给出以来,有些事情已经改变了。我在 Xcode 13 beta playground 上尝试了 "‍‍‍".contains(""),现在它返回 false - Ravi Tripathi

116

第一个问题是你正在使用contains与Foundation桥接(Swift的String不是Collection),因此这是NSString的行为,我认为它处理组合表情符号的能力不如 Swift。话虽如此,我认为Swift现在正在实现Unicode 8,在Unicode 10中也需要修订这种情况(因此当他们实现Unicode 10时,这可能会全部改变;我还没有深入研究是否会发生这种情况)。

为了简化事情,让我们摆脱Foundation并使用Swift,它提供更明确的视图。我们将从字符开始:

"‍‍‍".characters.forEach { print($0) }
‍
‍
‍

好的,这就是我们所预期的。但这是个谎言。让我们看看这些字符的真正含义。

"‍‍‍".characters.forEach { print(String($0).unicodeScalars.map{$0}) }
["\u{0001F469}", "\u{200D}"]
["\u{0001F469}", "\u{200D}"]
["\u{0001F467}", "\u{200D}"]
["\u{0001F466}"]

啊...所以它是["ZWJ", "ZWJ", "ZWJ", ""]。这让一切变得更清晰了。不是这个列表的成员(它是“ZWJ”),但是 Character 是一个“字形群集”,它将事物组合在一起(例如附加 ZWJ)。你真正要搜索的是 Unicode 标量。而且它就像你期望的那样工作:

"‍‍‍".unicodeScalars.contains("") // true
"‍‍‍".unicodeScalars.contains("\u{200D}") // true
"‍‍‍".unicodeScalars.contains("") // true
"‍‍‍".unicodeScalars.contains("") // true

当然,我们还可以查找实际存在的字符:

"‍‍‍".characters.contains("\u{200D}") // true

(这部分内容与Ben Leggiero的回答非常相似。在发布之前我没有注意到他已经回答了。保留此回答以防对任何人更明确。)


1
ZWJ 代表什么? - LinusGeffarth
4
零宽连字符(Zero Width Joiner)。 - Rob Napier
@RobNapier 在 Swift 4 中,据说 String 被改回了集合类型。这是否会影响您的答案? - Ky -
不,那只是改变了下标的方式。它并没有改变字符的工作方式。 - Rob Napier

78

看起来 Swift 将 ZWJ 视为紧随其前的字符所组成的扩展字形簇。通过将字符数组映射到它们的 unicodeScalars,我们可以看到这一点:

Array(manual.characters).map { $0.description.unicodeScalars }

这会从LLDB中打印出以下内容:

4 elements
  ▿ 0 : StringUnicodeScalarView("‍")
    - 0 : "\u{0001F469}"
    - 1 : "\u{200D}"1 : StringUnicodeScalarView("‍")
    - 0 : "\u{0001F469}"
    - 1 : "\u{200D}"2 : StringUnicodeScalarView("‍")
    - 0 : "\u{0001F467}"
    - 1 : "\u{200D}"3 : StringUnicodeScalarView("")
    - 0 : "\u{0001F466}"

此外,.contains 方法会将扩展字形簇(grouped extended grapheme clusters)视为单个字符。例如,取包含韩文字符 (它们合在一起组成了韩文单词“一”的字形: 한)的字符串:

"\u{1112}\u{1161}\u{11AB}".contains("\u{1112}") // false

这可能无法找到,因为三个代码点被分组成一个群集,作为一个字符。同样,\u{1F469}\u{200D}WOMANZWJ)是一个群集,作为一个字符。


23

Swift 4.0 更新

在 Swift 4 更新中,字符串 String 进行了很多修订,详见SE-0163。 对于此演示,使用了两个表情符号来代表不同的结构。两种结构都是由一系列表情符号组合而成。

是由两个表情符号,,组合而成的。

‍‍‍ 是由四个表情符号组成,并且连接了零宽连字符。格式为:‍joiner‍joiner‍joiner

1. 计数

在 Swift 4.0 中,表情符号被视为一个字形簇来计数。每个单独的表情符号均视为1。字符串的 count 属性也可以直接使用。因此,您可以像这样直接调用它:

"".count  // 1. Not available on swift 3
"‍‍‍".count  // 1. Not available on swift 3

在Swift 4.0中,字符串的字符数组也被视为字形群集,因此以下两个代码都会打印出1。这两个表情符号是表情符号序列的示例,其中多个表情符号通过一个或零个宽度连字符\u{200d}组合在一起。在Swift 3.0中,这种字符串的字符数组将每个表情符号分开,并导致具有多个元素(表情符号)的数组。在此过程中,连接器被忽略。但是,在Swift 4.0中,字符数组将所有表情符号视为一个整体。所以任何表情符号的数量始终为1。

"".characters.count  // 1. In swift 3, this prints 2
"‍‍‍".characters.count  // 1. In swift 3, this prints 4

unicodeScalars 在 Swift 4 中保持不变。它提供了给定字符串中独特的 Unicode 字符。

"".unicodeScalars.count  // 2. Combination of two emoji
"‍‍‍".unicodeScalars.count  // 7. Combination of four emoji with joiner between them

2. 包含

在 Swift 4.0 中,contains 方法忽略表情符号中的零宽连接符。因此,如果你检查其中任意四个组成部分的表情符号"‍‍‍",它会返回 true,并且如果你检查连接器时,它会返回 false。然而,在 Swift 3.0 中,连接器不被忽略,而是与其前面的表情符号合并。所以当你检查"‍‍‍"是否包含前三个组件表情符号时,结果将为 false。

"".contains("")       // true
"".contains("")        // true
"‍‍‍".contains("‍‍‍")       // true
"‍‍‍".contains("")       // true. In swift 3, this prints false
"‍‍‍".contains("\u{200D}") // false
"‍‍‍".contains("")       // true. In swift 3, this prints false
"‍‍‍".contains("")       // true

21
其他答案讨论了Swift做了什么,但并没有详细说明为什么。 你是否希望“Å”等于“Å”? 我想你会这样认为。 其中一个是带有组合符号的字母,另一个是单个组合字符。 您可以向基字符添加许多不同的组合符号,并且人类仍将其视为单个字符。 为了处理这种差异,创建了概念图形来表示人类无论使用哪些代码点都将考虑字符。
现在,短信服务已经将字符合并为图形表情符号多年 :) 😊。因此,各种表情符号被添加到Unicode中。
这些服务还开始将表情符号组合成复合表情符号。
当然,无法以合理的方式对所有可能的组合进行编码,因此Unicode Consoritum决定扩展图形的概念,以涵盖这些复合字符。
这归结为如果您要像Swift默认情况下那样处理图形,则应将"‍‍‍"视为单个“图形群集”。 如果您想检查它是否包含""作为其中的一部分,则应降级到较低级别。
我不知道Swift语法,因此这里提供了一些Perl 6,它对Unicode具有类似的支持级别。 (Perl 6支持Unicode版本9,因此可能存在差异)
say "\c[family: woman woman girl boy]" eq "‍‍‍"; # True

# .contains is a Str method only, in Perl 6
say "‍‍‍".contains("‍‍‍")    # True
say "‍‍‍".contains("");        # False
say "‍‍‍".contains("\x[200D]");  # False

# comb with no arguments splits a Str into graphemes
my @graphemes = "‍‍‍".comb;
say @graphemes.elems;                # 1

让我们降低一个层次

# look at it as a list of NFC codepoints
my @components := "‍‍‍".NFC;
say @components.elems;                     # 7

say @components.grep("".ord).Bool;       # True
say @components.grep("\x[200D]".ord).Bool; # True
say @components.grep(0x200D).Bool;         # True

不过,下到这个级别可能会使某些事情变得更加困难。

my @match = "‍‍‍".ords;
my $l = @match.elems;
say @components.rotor( $l => 1-$l ).grep(@match).Bool; # True

我假设在Swift中使用.contains可以使这变得更容易,但这并不意味着没有其他事情会变得更加困难。

在这个级别上工作很容易意外地在复合字符的中间分开字符串。


你无意中询问的是为什么这种更高层次的表示方式不能像较低层次的表示方式一样工作。答案当然是它本来就不应该这样。

如果你问自己“为什么这必须如此复杂”,答案当然是“人类”。


4
你的最后一个例子让我没看懂,rotorgrep在这里是做什么的?还有1-$l是什么意思? - Ky -
4
“Grapheme” 这个词至少有50年的历史。Unicode 把它引入了标准中,因为他们已经使用“字符”这个术语来表示与人们通常所想的字符相当不同的东西。我可以看出你写的内容与此一致,但我怀疑其他人可能会产生错误印象,因此写下这篇(希望能澄清)的评论。 - raiph
2
@BenLeggiero 首先是 rotor。代码 say (1,2,3,4,5,6).rotor(3) 会产生 ((1 2 3) (4 5 6)),这是一个列表的列表,每个列表长度为 3say (1,2,3,4,5,6).rotor(3=>-2) 产生相同的结果,除了第二个子列表以 2 开头而不是 4,第三个子列表以 3 开头,以此类推,得到 ((1 2 3) (2 3 4) (3 4 5) (4 5 6))。如果 @match 包含 "‍‍‍".ords,那么 @Brad 的代码只创建一个子列表,因此 =>1-$l 部分无关紧要(未使用)。它只在 @match@components 短时才相关。 - raiph
1
grep 尝试匹配其调用者中的每个元素(在本例中为 @components 的子列表)。它尝试将每个元素与其匹配器参数(在本例中为 @match)进行匹配。然后,.Bool 返回 True,当且仅当 grep 产生至少一次匹配时。 - raiph

-1

表情符号(Emojis)与Unicode标准一样,看似简单却实际上非常复杂。肤色、性别、职业、人群、零宽连接器序列、国旗(2个字符的Unicode)以及其他复杂因素都可能使得表情符号解析变得混乱。圣诞树、比萨片或一堆便便都可以用单个Unicode代码点来表示。更不用说当新的表情符号被引入时,iOS支持和表情符号发布之间会有延迟。此外,不同版本的iOS支持不同版本的Unicode标准。

简而言之。我已经在这些功能上工作过,并开源了一个库JKEmoji,我是它的作者,以帮助解析带有表情符号的字符串。它使解析变得容易:

print("I love these emojis ‍‍‍".emojiCount)

5

它通过定期刷新本地数据库中最新的unicode版本(最近是12.0)中所有已识别的表情符号,并通过查看未识别表情符号字符的位图表示,将其与正在运行的操作系统版本中被认为是有效的表情符号进行交叉参考来实现这一点。
注意:
之前的回答因为未清楚声明我是作者而被删除。我再次声明。

4
尽管我对你的图书馆印象深刻,也看到它与主题有一般联系,但我不明白它如何直接与问题相关。 - Ky -

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接