ChatGPT解决这个技术问题 Extra ChatGPT

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

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

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

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

"👩‍👩‍👧‍👦".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! 之类的东西,这也不会改变。

评论不用于扩展讨论;此对话已moved to chat
已在 Swift 4 中修复。"👩‍👩‍👧‍👦".contains("\u{200D}") 仍然返回 false,不确定这是错误还是功能。
哎呀。 Unicode 破坏了文本。它将纯文本转换为标记语言。
@Boann 是的,不是的......很多这些变化都是为了使像 Hangul Jamo(255 个代码点)这样的编码/解码内容不像汉字(13,108 个代码点)和汉字(199,528 个代码点)那样成为绝对的噩梦。当然,它比 SO 评论的长度更复杂和有趣,所以我鼓励你自己检查一下:D

x
xoudini

这与 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 完全相同,后者试图找到与给定参数的完全匹配。由于以零宽度连接符结尾的字符形成不完整的序列,因此该方法尝试找到参数的匹配项,同时将以零宽度连接符结尾的字符组合成完整序列。这意味着如果出现以下情况,该方法将永远找不到匹配项:

参数以零宽度连接符结尾,并且要解析的字符串不包含不完整的序列(即以零宽度连接符结尾并且后面没有兼容字符)。

展示:

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

@MartinR 根据当前的 UTR29(Unicode 9.0),它扩展的字素簇(rules GB10 and GB11),但 Swift 显然使用的是旧版本。显然是 fixing that is a goal for version 4 of the language,所以这种行为将来会改变。
@MichaelHomer:显然,这已得到修复,在当前的 Xcode 9 beta 和 Swift 4 中,"👩‍👩‍👧‍👦".count 的计算结果为 1
哇。这是极好的。但现在我开始怀念过去的日子,那时我遇到的字符串最严重的问题是它们是使用 C 还是 Pascal 风格的编码。
我理解为什么 Unicode 标准可能需要支持这一点,但是伙计,这是一个过度设计的混乱,如果有的话:/
正确没有过度设计。
R
Rob Napier

第一个问题是您使用 contains 桥接到 Foundation(Swift 的 String 不是 Collection),所以这是 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 的观点。我在注意到他已经回答之前发布了这个。离开以防万一任何人都更清楚。)


ZWJ 代表什么?
零宽度连接器
@RobNapier 在 Swift 4 中,String 据称已改回集合类型。这会影响你的答案吗?
不,这只是改变了下标之类的东西。它并没有改变 Characters 的工作方式。
K
Ky.

似乎 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) 是一个簇,它充当一个字符。


B
Brad Gilbert

其他答案讨论了 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

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

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

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

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


你在最后一个示例行中失去了我; rotorgrep 在这里做什么? 1-$l 是什么?
“字形”一词至少有 50 年的历史。 Unicode 将其引入标准是因为他们已经使用术语“字符”来表示与人们通常认为的字符完全不同的东西。我可以阅读您所写的内容与此一致,但怀疑其他人可能会产生错误的印象,因此(希望澄清)评论。
@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 时才有意义。
grep 尝试匹配其调用者中的每个元素(在本例中为 @components 的子列表列表)。它尝试将每个元素与其匹配器参数(在本例中为 @match)进行匹配。如果 grep 产生至少一个匹配项,则 .Bool 返回 True
N
Nilanshu Jaiswal

斯威夫特 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。这两个 emoji 是 emoji 序列的示例,其中几个 emoji 组合在一起,它们之间有或没有零宽度连接符 \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 并没有被忽略,而是与前面的 emoji 组合在一起。因此,当您检查 "👩‍👩‍👧‍👦" 是否包含前三个组件 emoji 时,结果将为 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

J
Joe

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

TL;DR. 我致力于这些功能并开源了一个库,我是 JKEmoji 的作者,以帮助解析带有表情符号的字符串。它使解析变得如此简单:

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

5

它通过从最新的 unicode 版本(最近的 12.0)定期刷新所有已识别表情符号的本地数据库并通过查看位图将它们与正在运行的操作系统版本中被识别为有效表情符号的内容进行交叉引用来实现这一点无法识别的表情符号字符的表示。

笔记

先前的答案因宣传我的图书馆而被删除,但没有明确说明我是作者。我再次承认这一点。


虽然您的图书馆给我留下了深刻的印象,并且我看到它通常与手头的主题相关,但我看不出这与问题有何直接关系