为什么表情符号像👩👩👧👦在Swift中被如此奇怪地对待?

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

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

所以这是非常有趣的编码; unit testing的完美目标。 不过,Swift似乎并不知道如何对待它。 这是我的意思:

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

所以,斯威夫特说,它包含自己(好)和一个男孩(好!)。 但是,它说它不包含一个女人,女孩,或零宽度木匠。 这里发生了什么事? 为什么Swift知道它包含一个男孩,但不是一个女人或女孩? 我能理解它是否把它当作一个单一的字符,只承认它包含它自己,但是它只有一个子组件,而没有其他的组件让我感到困惑。

如果我使用"👩".characters.first!等东西,这不会改变"👩".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!

这与Swift中Stringtypes的工作方式有关,以及contains(_:)方法如何工作。

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

如果你检查string的字符数,你会发现它是由四个字符组成,而如果你检查unicode标量计数,它会显示出不同的结果:

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

现在,如果你parsing字符并打印它们,你会看到什么看起来像正常的字符,但实际上,三个第一个字符在它们的UnicodeScalarView同时包含一个表情符号和一个零宽度的连接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(_:)方法时,它可以像你期望的那样工作。 由于您不是与包含零宽度的join者的表情符号进行比较,所以除了最后一个字符外,该方法不会find任何匹配。

为了扩大这个范围,如果创build一个由以零宽度连接符结尾的表情符号组成的String ,并将其传递给contains(_:)方法,则它也将计算为false 。 这与contains(_:)range(of:) != nil完全相同,它试图find给定参数的精确匹配。 由于以零宽度连接符结尾的字符形成不完整序列,所以该方法尝试在将以零宽度连接符结尾的字符组合成完整序列时,为该参数find匹配。 这意味着在下列情况下该方法不会find匹配:

  1. 参数以一个零宽度的细木工结束,并且
  2. 要parsing的string不包含不完整的序列(即以零宽度连接符结尾,而不是后跟兼容字符)。

展示:

 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 

但是,由于只比较前面的内容,因此可以通过反向工作在string中find其他几个完整序列:

 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 

最简单的解决scheme是为range(of:options:range:locale:)方法提供一个特定的比较选项。 选项String.CompareOptions.literal执行一个确切的逐字符等价的比较 。 作为一个侧面说明,这里的字符的含义不是 Swift Character ,而是实例和比较string的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基础(斯威夫特的String不是一个Collection )桥接,所以这是NSString行为,我不相信处理组成表情符号作为有力的Swift。 也就是说,我认为Swift现在正在实现Unicode 8,它也需要在Unicode 10中对这种情况进行修订(所以在实现Unicode 10时这一切都会改变;我还没有深究它是否会)。

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

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

好。 这就是我们所期望的。 但是这是一个谎言。 让我们来看看这些angular色究竟是什么。

 "👩‍👩‍👧‍👦".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 

当然,我们也可以看看那里的实际angular色:

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

(这和Ben Leggiero的观点非常相似,我注意到他已经回答了,留下,以防万一)。

看起来Swift认为ZWJ是一个扩展的字形组合,其字符就在它之前。 在将字符数组映射到unicodeScalars时,我们可以看到这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组将字形集群扩展为单个字符。 例如,使用字符 (结合使韩语单词“one”: 한 ):

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

这找不到因为三个码点被分组到一个字符中。 同样,( WOMAN ZWJ )是一个集群,它作为一个字符。

其他的答案讨论了Swift的做法,但是为什么没有详细说明。

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

其中之一是一个组合字母,另一个是一个单一的组成字符。 您可以将许多不同的合成器添加到基本字符,而人类仍然会将其视为单个字符。 为了处理这种差异,创build了一个字形的概念来表示一个人会认为一个字符是什么,而不pipe使用的是哪个编码点。

现在短信服务已经将字符组合成graphics表情符号多年了:)🙂 。 所以各种表情符号被添加到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中包含容易,但这并不意味着没有其他事情变得更加困难。

例如,在这个级别上工作使得在一个复合字符中间意外地分割一个string变得更容易。


你无意中问的是为什么这个更高层次的代表不能像低层代表那样工作。 答案当然是不应该的。

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

Swift 4.0更新

string在swift 4更新中接收大量修订,如SE-0163中所述 。 代表两种不同结构的演示文稿使用了两个表情符号。 两者都结合了一系列的表情符号。

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

👩‍👩‍👧‍👦是四个表情符号的组合,用零连接它们。 格式是👩‍joiner👩‍joiner👧‍joiner👦

1.计数

在迅速4.0。 表情符号被视为字素集群。 每个表情符号都被计为1. count属性也可以直接用于string。 所以你可以直接调用它。

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

string的字符数组在swift 4.0中也被计算为字素集群,因此下面的两个代码都会打印出1.这两个表情符号是表情符号序列的例子,其中几个表情符号被组合在一起,有或没有零宽度的连接器\u{200d}它们之间。 在swift 3.0中,这样的string的字符数组分隔出每个表情符号并产生一个包含多个元素(表情符号)的数组。 在这个过程中木匠被忽略。 然而,在4.0版本中,字符数组将所有表情符号视为一个整体。 所以任何表情符号的总是1。

 "👍🏽".characters.count // 1. In swift 3, this prints 2 "👩‍👩‍👧‍👦".characters.count // 1. In swift 3, this prints 4 

unicodeScalars在swift中保持不变4.它在给定的string中提供唯一的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。 但是,在3.0版本中,木匠不会被忽略,并与前面的表情符号结合在一起。 所以当你检查"👩‍👩‍👧‍👦"包含前三个表情符号时,结果将是错误的

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