为什么表情符号像👩‍👩‍👧‍👦在迅捷的琴弦中被如此奇怪地对待?

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

< p > # EYZ0 # EYZ2, < br > # EYZ0 # EYZ2, < br > # EYZ0 # EYZ1, < br > # EYZ0 # EYZ1, < br > # EYZ0 # EYZ2, < br > # EYZ0 # EYZ1, < br > # EYZ0 # EYZ2 < / p >

所以它的编码非常有趣;这是单元测试的完美目标。然而,斯威夫特似乎不知道如何治疗它。我的意思是:

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

所以,斯威夫特说它包含了自己(好)和一个男孩(好!)但它接着说,它不包含一个女人,女孩,或零宽度木工。这里发生了什么?为什么斯威夫特知道里面是男孩而不是女人或女孩?我可以理解,如果它把它作为一个单独的字符,只识别它包含自己,但事实是它只有一个子组件,没有其他的让我困惑。

如果我使用"👩".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!这样的东西,这也不会改变。

36202 次浏览

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将扩展的字素簇分组为单个字符。例如,以韩文字符(它们组合在一起就形成了韩语单词“一”:한):

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

这无法找到,因为三个代码点被分组到一个集群中,充当一个字符。类似地,\u{1F469}\u{200D} (WOMAN ZWJ)是一个集群,充当一个字符。

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

“👩‍👩‍👧‍👦”就是所谓的表情符号序列,它被呈现为字符串中的一个可见字符。该序列由Character对象组成,同时由UnicodeScalar对象组成。

如果你检查字符串的字符计数,你会看到它由四个字符组成,而如果你检查unicode标量计数,它会显示给你一个不同的结果:

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

现在,如果你解析字符并打印它们,你会看到看起来像普通字符,但实际上前三个字符既包含一个表情符号,也包含一个零宽度的joiner 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,它由一个以零宽度joiner结尾的表情符号字符组成,并将其传递给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

第一个问题是你用contains来桥接基础(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的观点。在注意到他回复之前,我把这条消息发了出去。离开,以防有人看得更清楚。)

其他的回答都是关于霉霉做了什么,但没有详细说明原因。

你认为“Å”等于“Å”吗?我想你会的。

其中一种是带有组合符的字母,另一种是单个组合字符。你可以给一个基本字符添加许多不同的组合符,但人们仍然会认为它是一个单一字符。为了处理这种差异,人们创造了字素的概念,以表示不管使用的码位是什么,人们对字符的看法。

现在的短信服务已经将字符组合成图形表情符号很多年了。所以各种各样的表情符号被添加到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

我认为Swift中的.contains会让这变得更容易,但这并不意味着没有其他事情会变得更困难。

例如,在这个级别上工作可以更容易地意外地在复合字符中间分割字符串。


你无意中问的是为什么这个高级表示法不能像低级表示法那样工作。答案当然是,它不应该。

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

Swift 4.0更新

字符串在Swift 4更新中收到了许多修订,如se - 0163中所记录的。 这个演示中使用了两个表情符号,代表两种不同的结构。

👍🏽👍🏽两个表情符号的组合

👩‍👩‍👧‍👦是四个表情符号的组合,用零宽度的细木工连接。格式为👩‍joiner👩‍joiner👧‍joiner👦

1. 计数

在Swift 4.0中,表情符号被算作字素簇。每一个表情都被算作1。count属性也可以直接用于string。你可以这样直接调用它。

"👍🏽".count  // 1. Not available on swift 3
"👩‍👩‍👧‍👦".count  // 1. Not available on swift 3

字符串的字符数组在Swift 4.0中也被算作字素簇,因此下面的两个代码都输出1。这两个表情符号是表情符号序列的例子,其中几个表情符号被组合在一起,它们之间有或没有零宽度的joiner \u{200d}。在swift 3.0中,这种字符串的字符数组将每个表情符号分离出来,并生成一个包含多个元素的数组(emoji)。在此过程中忽略细木工。然而,在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,如果检查joiner,则返回false。然而,在Swift 3.0中,joiner并没有被忽略,而是与它前面的表情符号结合在一起。因此,当您检查"👩‍👩‍👧‍👦"是否包含前三个组件表情符号时,结果将为假

"👍🏽".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

表情符号,就像统一码标准一样,看似复杂。肤色、性别、工作、人群、零宽度拼接序列、标志(2个字符unicode)和其他复杂因素都会使表情符号解析变得混乱。圣诞树、一片披萨或一堆便便都可以用单一的Unicode码位表示。更不用说,当新的表情符号被引入时,iOS支持和表情符号发布之间会有一个延迟。另外,不同版本的iOS支持不同版本的unicode标准。

我曾致力于这些功能,并开放了一个库,我是JKEmoji的作者,以帮助用表情符号解析字符串。它使解析变得简单:

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

5

它通过定期刷新最新unicode版本(12.0最近)的所有已识别的表情符号的本地数据库,并通过查看未识别的表情符号字符的位图表示,将它们与运行的OS版本中被识别为有效的表情符号进行交叉引用。

# EYZ0

之前的一个答案被删除了,因为它为我的图书馆做广告,没有明确说明我是作者。我再次承认这一点。