为什么在Swift字符串中像👩‍👩‍👩‍👦这样的表情符号字符被如此奇怪地对待?


538

字符👩‍👩‍👧‍👦(有两个女人,一个女孩和一个男孩的家庭)的编码如下:

U+1F469 WOMAN
‍U+200D ZWJ
U+1F469 WOMAN
U+200D ZWJ
U+1F467 GIRL
U+200D ZWJ
U+1F466 BOY

因此,它的编码非常有趣;单元测试的理想目标。但是,Swift似乎不知道如何处理它。这就是我的意思:

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

因此,斯威夫特说,它包含了自己(好)和一个男孩(好!)。但随后它说它不包含女人,女孩或零宽度细木工。这里发生了什么事?为什么Swift知道其中包含一个男孩,却没有一个女人或女孩?我能理解它是否被视为单个字符,并且仅识别出它包含自身,但事实是它只有一个子组件而没有其他组件令我感到困惑。

如果使用,则不会改变"👩".characters.first!


更令人困惑的是:

let manual = "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}"
Array(manual.characters) // ["👩‍", "👩‍", "👧‍", "👦"]

即使我将ZWJ放置在其中,它们也没有反映在字符数组中。随之而来的是一点点的告诉:

manual.contains("👩") // false
manual.contains("👧") // false
manual.contains("👦") // true

所以我在字符数组上也得到了相同的行为……这非常令人讨厌,因为我知道该数组的外观。

如果我使用,也不会改变"👩".characters.first!



1
评论不作进一步讨论;此对话已转移至聊天
马丁·皮特斯

1
在Swift 4中修复。"👩‍👩‍👧‍👦".contains("\u{200D}")仍返回false,不确定是错误还是功能。
凯文(Kevin)

4
kes Unicode破坏了文本。它将纯文本转换为标记语言。
Boann

6
@Boann是的,不是。。。进行了很多这样的更改,以使诸如Hangul Jamo(255个代码点)之类的en / decode并不是绝对的噩梦,就像汉字(13,108个代码点)和中国表意文字(199,528个代码点)那样。当然,它比SO注释所允许的长度更复杂,更有趣,所以我鼓励您自己检查一下:D
Ben Leggiero

Answers:


401

这与String类型在Swift中的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

47
@MartinR根据当前的UTR29(Unicode 9.0),它扩展的字素簇(规则GB10和GB11),但是Swift显然使用了较旧的版本。显然,修复该问题是该语言第4版的目标,因此将来会更改此行为。
Michael Homer

9
@MichaelHomer:显然已被固定,"👩‍👩‍👧‍👦".count计算结果为1与当前的Xcode 9β和夫特4.
马丁- [R

5
哇。太好了 但是现在我回想起过去的日子,那时我遇到的最糟糕的问题是字符串是否使用C或Pascal样式编码。
Owen Godfrey

2
我知道为什么Unicode标准可能需要支持这一点,但是,伙计,这是一个过度设计的混乱,如果有的话:/
恢复莫妮卡,

108

第一个问题是您使用contains(Swift的String不是a Collection)与Foundation搭桥,所以这是NSString行为,我不认为它可以像Swift一样强大地处理Emoji。也就是说,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的观点。我在注意到他回答之前就贴了此贴。


WTH并ZWJ代表什么?
LinusGeffarth

2
零宽度细木工
Rob Napier

据说Swift 4中的@RobNapier String被改回了集合类型。这完全不会影响您的答案吗?
Ben Leggiero

75

似乎Swift认为a 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将扩展的字素簇分组为单个字符。例如,使用hangul字符(这两个词组合在一起使韩语单词为“ one” :)한

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

由于三个代码点被分组为一个字符簇,因此无法找到。同样,\u{1F469}\u{200D}WOMAN ZWJ)是一个群集,它充当一个字符。


19

其他答案讨论了Swift的作用,但没有详细说明原因。

您期望“Å”等于“Å”吗?我希望你会。

其中一个是带有组合器的字母,另一个是单个组成的字符。您可以向基本字符添加许多不同的组合器,而人类仍然会认为它是单个字符。为了解决这种差异,创建了字素概念来表示人们会认为字符的形式,而不考虑所使用的代码点。

现在,短信服务已经组合字符成图形表情符号多年:) →  🙂。因此,将各种表情符号添加到了Unicode中。
这些服务也开始将表情符号合并为复合表情符号。
当然,没有合理的方法将所有可能的组合编码为单独的代码点,因此Unicode联合会决定扩展字素的概念以包含这些复合字符。

"👩‍👩‍👧‍👦"如果您尝试在字素级别使用它,那么归结为什么应该被视为一个“字素簇”,就像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

我认为使用.containsSwift可以使此操作变得更容易,但这并不意味着没有其他事情会变得更加困难。

例如,在此级别上工作可使在复合字符中间不小心分割字符串变得容易得多。


您无意中要问的是,为什么这种较高级别的表示不能像较低级别的表示那样起作用。答案当然是,这不应该。

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


4
您在最后一个示例行中迷失了我;什么rotorgrep在这里做?那是1-$l什么?
Ben Leggiero

4
术语“字素”至少有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,第三个子列表以yield开头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

18

Swift 4.0更新

SE-0163中所述,String在Swift 4更新中获得了很多修订。此演示使用两个表情符号表示两个不同的结构。两者都结合了一系列表情符号。

👍🏽是两个表情符号的组合,👍并且🏽

👩‍👩‍👧‍👦是四个表情符号的组合,连接了零宽度的细木工。格式为👩‍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中,joiner不会被忽略,并与它前面的表情符号组合在一起。因此,当您检查是否"👩‍👩‍👧‍👦"包含前三个成分表情符号时,结果将为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

0

表情符号很像unicode标准,看似复杂。肤色,性别,工作,人群,零宽度的连接符序列,标志(2个字符的unicode)和其他并发症会使表情符号解析变得混乱。一棵圣诞树,一片披萨或一堆便便都可以用一个Unicode代码点表示。更不用说引入新表情符号时,iOS支持和表情符号发布之间会有延迟。以及不同版本的iOS支持不同版本的unicode标准的事实。

TL; DR。我已经研究了这些功能,并开源了一个库(我是JKEmoji的作者)来帮助解析带有表情符号的字符串。它使解析变得简单:

print("I love these emojis 👩‍👩‍👧‍👦💪🏾🧥👧🏿🌈".emojiCount)

5

通过按常规刷新最新的unicode版本(最近为12.0)的所有已识别表情符号的本地数据库,并通过查看正在运行的OS版本中被识别为有效表情符号的内容对它们进行交叉引用。无法识别的表情符号字符。

注意

以前的答案由于在没有明确说明我是作者的情况下宣传我的图书馆而被删除。我再次确认这一点。


2
虽然您的图书馆给我留下了深刻的印象,而且我看到它通常与当前主题有什么关系,但我看不出这与问题有直接关系
Ben Leggiero
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.