Warum werden Emoji-Zeichen wie 👩‍👩‍👧‍👦 in Swift-Strings so seltsam behandelt?

540

Das Zeichen 👩‍👩‍👧‍👦 (Familie mit zwei Frauen, einem Mädchen und einem Jungen) ist als solches codiert:

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

Es ist also sehr interessant codiert; das perfekte Ziel für einen Unit-Test. Swift scheint jedoch nicht zu wissen, wie er damit umgehen soll. Folgendes meine ich:

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

Also sagt Swift, dass es sich selbst (gut) und einen Jungen (gut!) Enthält. Aber es heißt dann, dass es keine Frau, kein Mädchen oder keinen Tischler mit einer Breite von Null enthält. Was passiert hier? Warum weiß Swift, dass es einen Jungen enthält, aber keine Frau oder kein Mädchen? Ich könnte verstehen, wenn es es als ein einzelnes Zeichen behandelt und nur erkennt, dass es sich selbst enthält, aber die Tatsache, dass es eine Unterkomponente hat und keine anderen, verwirrt mich.

Das ändert sich nicht, wenn ich so etwas benutze "👩".characters.first!.


Noch verwirrender ist Folgendes:

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

Obwohl ich die ZWJs dort platziert habe, spiegeln sie sich nicht im Zeichenarray wider. Was folgte, war ein wenig aussagekräftig:

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

Ich habe also das gleiche Verhalten mit dem Zeichenarray ... was äußerst ärgerlich ist, da ich weiß, wie das Array aussieht.

Das ändert sich auch nicht, wenn ich so etwas benutze "👩".characters.first!.

Ben Leggiero
quelle
1
Kommentare sind nicht für eine ausführliche Diskussion gedacht. Dieses Gespräch wurde in den Chat verschoben .
Martijn Pieters
1
In Swift 4 behoben. Gibt "👩‍👩‍👧‍👦".contains("\u{200D}")immer noch false zurück, nicht sicher, ob dies ein Fehler oder eine Funktion ist.
Kevin
4
Huch. Unicode hat Text ruiniert. Es wird einfacher Text in eine Auszeichnungssprache umgewandelt.
Boann
6
@Boann ja und nein ... viele dieser Änderungen wurden vorgenommen, um Dinge wie Hangul Jamo (255 Codepunkte) nicht zu einem absoluten Albtraum zu machen, wie es für Kanji (13.108 Codepunkte) und chinesische Ideogramme (199.528 Codepunkte) war. Natürlich ist es komplizierter und interessanter, als es die Länge eines SO-Kommentars zulässt. Ich
empfehle

Antworten:

402

Dies hängt damit zusammen, wie der StringTyp in Swift funktioniert und wie die contains(_:)Methode funktioniert.

Das '👩‍👩‍👧‍👦' ist eine sogenannte Emoji-Sequenz, die als ein sichtbares Zeichen in einer Zeichenfolge gerendert wird. Die Sequenz besteht aus CharacterObjekten und gleichzeitig aus UnicodeScalarObjekten.

Wenn Sie die Zeichenanzahl der Zeichenfolge überprüfen, sehen Sie, dass sie aus vier Zeichen besteht. Wenn Sie die Anzahl der Unicode-Skalare überprüfen, wird ein anderes Ergebnis angezeigt:

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

Wenn Sie nun die Zeichen analysieren und ausdrucken, sehen Sie, was wie normale Zeichen aussieht. Tatsächlich enthalten die drei ersten Zeichen jedoch sowohl einen Emoji- als auch einen Joiner mit einer Breite von Null in ihren 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"]

Wie Sie sehen können, enthält nur das letzte Zeichen keinen Joiner mit der Breite Null. Wenn Sie die contains(_:)Methode verwenden, funktioniert sie also wie erwartet. Da Sie nicht mit Emoji vergleichen, die Joiner mit einer Breite von Null enthalten, findet die Methode nur für das letzte Zeichen eine Übereinstimmung.

Wenn Sie Stringein Emoji-Zeichen erstellen, das mit einem Joiner mit der Breite Null endet, und es an die contains(_:)Methode übergeben, wird es ebenfalls ausgewertet false. Dies hat damit zu tun, contains(_:)dass es genau dasselbe ist wie range(of:) != nil, was versucht, eine genaue Übereinstimmung mit dem gegebenen Argument zu finden. Da Zeichen, die mit einem Joiner mit der Breite Null enden, eine unvollständige Sequenz bilden, versucht die Methode, eine Übereinstimmung für das Argument zu finden, während Zeichen, die mit Joinern mit der Breite Null enden, zu einer vollständigen Sequenz kombiniert werden. Dies bedeutet, dass die Methode niemals eine Übereinstimmung findet, wenn:

  1. Das Argument endet mit einem Joiner mit der Breite Null und
  2. Die zu analysierende Zeichenfolge enthält keine unvollständige Sequenz (dh sie endet mit einem Joiner mit der Breite Null und wird nicht von einem kompatiblen Zeichen gefolgt).

Demonstrieren:

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

Da der Vergleich jedoch nur nach vorne schaut, können Sie mehrere andere vollständige Sequenzen innerhalb der Zeichenfolge finden, indem Sie rückwärts arbeiten:

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

Die einfachste Lösung wäre, eine bestimmte Vergleichsoption für die range(of:options:range:locale:)Methode bereitzustellen . Die Option String.CompareOptions.literalführt den Vergleich mit einer genauen Zeichen-für-Zeichen-Äquivalenz durch . Nebenbei bemerkt, was hier mit Zeichen gemeint ist, ist nicht der Swift Character, sondern die UTF-16-Darstellung sowohl der Instanz als auch der Vergleichszeichenfolge. Da Stringjedoch keine fehlerhafte UTF-16 zulässig ist, entspricht dies im Wesentlichen dem Vergleich des Unicode-Skalars Darstellung.

Hier habe ich die FoundationMethode überladen. Wenn Sie also die ursprüngliche Methode benötigen, benennen Sie diese oder etwas anderes um:

extension String {
    func contains(_ string: String) -> Bool {
        return self.range(of: string, options: String.CompareOptions.literal) != nil
    }
}

Jetzt funktioniert die Methode mit jedem Zeichen so, wie es "sollte", auch bei unvollständigen Sequenzen:

s.contains("👩")          // true
s.contains("👩\u{200d}")  // true
s.contains("\u{200d}")    // true
Xoudini
quelle
47
@MartinR Laut dem aktuellen UTR29 (Unicode 9.0) handelt es sich um einen erweiterten Graphemcluster ( Regeln GB10 und GB11 ), aber Swift verwendet eindeutig eine ältere Version. Anscheinend ist dies ein Ziel für Version 4 der Sprache , daher wird sich dieses Verhalten in Zukunft ändern.
Michael Homer
9
@ MichaelHomer: Anscheinend wurde das behoben, "👩‍👩‍👧‍👦".countbewertet 1mit der aktuellen Xcode 9 Beta und Swift 4.
Martin R
5
Beeindruckend. Das ist ausgezeichnet. Aber jetzt werde ich nostalgisch für die alten Zeiten, als das schlimmste Problem, das ich mit Strings hatte, darin besteht, ob sie C- oder Pascal-Codierungen verwenden.
Owen Godfrey
2
Ich verstehe, warum der Unicode-Standard dies möglicherweise unterstützen muss, aber Mann, dies ist ein überentwickeltes Durcheinander, wenn überhaupt: /
Stellen Sie Monica
110

Das erste Problem ist, dass Sie eine Brücke zur Foundation schlagen contains(Swifts Stringist kein a Collection). Dies ist also ein NSStringVerhalten, von dem ich glaube, dass es Emoji nicht so kraftvoll handhabt wie Swift. Ich glaube jedoch, dass Swift derzeit Unicode 8 implementiert, was auch eine Überarbeitung dieser Situation in Unicode 10 erforderlich machte (dies kann sich also ändern, wenn Unicode 10 implementiert wird; ich habe nicht untersucht, ob dies der Fall ist oder nicht).

Lassen Sie uns zur Vereinfachung Foundation loswerden und Swift verwenden, das explizitere Ansichten bietet. Wir beginnen mit Charakteren:

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

OK. Das haben wir erwartet. Aber es ist eine Lüge. Mal sehen, was diese Charaktere wirklich sind.

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

Ah ... also ist es ["👩ZWJ", "👩ZWJ", "👧ZWJ", "👦"]. Das macht alles etwas klarer. 👩 ist kein Mitglied dieser Liste (es ist "👩ZWJ"), aber 👦 ist Mitglied.

Das Problem ist, dass Characteres sich um einen "Graphemcluster" handelt, der Dinge zusammensetzt (wie das Anhängen des ZWJ). Was Sie wirklich suchen, ist ein Unicode-Skalar. Und das funktioniert genau so, wie Sie es erwarten:

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

Und natürlich können wir auch nach dem tatsächlichen Charakter suchen, der sich darin befindet:

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

(Dies dupliziert stark Ben Leggieros Punkte. Ich habe dies gepostet, bevor ich bemerkte, dass er geantwortet hatte.

Rob Napier
quelle
Wofür steht ZWJ?
LinusGeffarth
2
Zero Width Joiner
Rob Napier
@RobNapier in Swift 4 Stringwurde angeblich wieder in einen Sammlungstyp geändert. Beeinflusst das Ihre Antwort überhaupt?
Ben Leggiero
Nein, das hat nur Dinge wie das Abonnieren verändert. Es hat nichts daran geändert, wie Charaktere funktionieren.
Rob Napier
75

Es scheint, dass Swift a ZWJals erweiterten Graphemcluster mit dem Zeichen unmittelbar davor betrachtet. Wir können dies sehen, wenn wir das Array von Zeichen auf Folgendes zuordnen unicodeScalars:

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

Dies druckt Folgendes aus der 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}"

Außerdem .containsgruppieren Gruppen erweiterte Graphemcluster zu einem einzigen Zeichen. Zum Beispiel nimmt die Hangul - Zeichen , und (der das koreanische Wort für „eins“ machen kombinieren: 한):

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

Dies konnte nicht gefunden werden, da die drei Codepunkte in einem Cluster zusammengefasst sind, der als ein Zeichen fungiert. In ähnlicher Weise ist \u{1F469}\u{200D}( WOMAN ZWJ) ein Cluster, der als ein Zeichen fungiert.

Ben Leggiero
quelle
19

In den anderen Antworten wird erläutert, was Swift tut, aber es wird nicht näher darauf eingegangen, warum.

Erwarten Sie, dass "Å" gleich "Å" ist? Ich gehe davon aus, dass du es tun würdest.

Einer davon ist ein Buchstabe mit einem Kombinierer, der andere ist ein einzelnes zusammengesetztes Zeichen. Sie können einem Basischarakter viele verschiedene Kombinierer hinzufügen, und ein Mensch würde ihn immer noch als einen einzelnen Charakter betrachten. Um mit dieser Art von Diskrepanz umzugehen, wurde das Konzept eines Graphems erstellt, um darzustellen, was ein Mensch unabhängig von den verwendeten Codepunkten als Zeichen betrachten würde.

Seit Jahren kombinieren SMS-Dienste Zeichen seit Jahren zu grafischen Emoji :) →  🙂. So wurden Unicode verschiedene Emoji hinzugefügt.
Diese Dienste haben auch begonnen, Emoji zu zusammengesetzten Emoji zu kombinieren.
Es gibt natürlich keine vernünftige Möglichkeit, alle möglichen Kombinationen in einzelne Codepunkte zu kodieren. Daher hat das Unicode-Konsortium beschlossen, das Konzept der Grapheme zu erweitern, um diese zusammengesetzten Zeichen zu erfassen.

Worauf es ankommt, "👩‍👩‍👧‍👦"sollte als einzelner "Graphemcluster" betrachtet werden, wenn Sie versuchen, auf Graphemebene damit zu arbeiten, wie dies Swift standardmäßig tut.

Wenn Sie überprüfen möchten, ob es "👦"einen Teil davon enthält , sollten Sie auf eine niedrigere Ebene gehen.


Ich kenne die Swift-Syntax nicht, daher hier einige Perl 6, die Unicode ähnlich unterstützen.
(Perl 6 unterstützt Unicode Version 9, daher kann es zu Unstimmigkeiten kommen.)

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

Lass uns ein Level runter gehen

# 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

Ein Abstieg auf dieses Niveau kann jedoch einige Dinge schwieriger machen.

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

Ich gehe davon aus, dass .containsSwift dies einfacher macht, aber das bedeutet nicht, dass es keine anderen Dinge gibt, die schwieriger werden.

Wenn Sie auf dieser Ebene arbeiten, ist es viel einfacher, beispielsweise versehentlich eine Zeichenfolge in der Mitte eines zusammengesetzten Zeichens zu teilen.


Was Sie versehentlich fragen, ist, warum diese Darstellung auf höherer Ebene nicht so funktioniert wie eine Darstellung auf niedrigerer Ebene. Die Antwort ist natürlich, es soll nicht.

Wenn Sie sich fragen, warum das so kompliziert sein muss , lautet die Antwort natürlich „ Menschen “.

Brad Gilbert
quelle
4
Du hast mich in deiner letzten Beispielzeile verloren; was machen rotorund grepmachen hier? Und was ist 1-$l?
Ben Leggiero
4
Der Begriff "Graphem" ist mindestens 50 Jahre alt. Unicode führte es in den Standard ein, weil sie den Begriff "Zeichen" bereits verwendet hatten, um etwas ganz anderes zu bedeuten als das, was man normalerweise als Zeichen betrachtet. Ich kann lesen, was Sie geschrieben haben, um damit übereinzustimmen, aber ich vermute, dass andere den falschen Eindruck bekommen, daher dieser (hoffentlich klärende) Kommentar.
Raiph
2
@ BenLeggiero Zuerst , rotor. Der Code say (1,2,3,4,5,6).rotor(3)ergibt ((1 2 3) (4 5 6)). Das ist eine Liste von Listen, jede Länge 3. say (1,2,3,4,5,6).rotor(3=>-2)ergibt dasselbe, außer dass die zweite Unterliste 2eher mit als 4die dritte mit usw. beginnt 3und ergibt ((1 2 3) (2 3 4) (3 4 5) (4 5 6)). Wenn @matchenthält "👩‍👩‍👧‍👦".ordsdann @ Brads Code erstellt nur einen sublist, so dass das =>1-$lBit ist nicht relevant (nicht verwendet). Es ist nur relevant, wenn @matches kürzer als ist @components.
Raiph
1
grepversucht, jedes Element in seinem Invokanten abzugleichen (in diesem Fall eine Liste von Unterlisten von @components). Es wird versucht, jedes Element mit seinem Matcher-Argument abzugleichen (in diesem Fall @match). Das gibt .Booldann zurück, Truewenn das grepmindestens eine Übereinstimmung erzeugt.
Raiph
18

Schnelles 4.0-Update

String hat im Swift 4-Update viele Revisionen erhalten, wie in SE-0163 dokumentiert . Für diese Demo werden zwei Emoji verwendet, die zwei verschiedene Strukturen darstellen. Beide werden mit einer Folge von Emoji kombiniert.

👍🏽ist die Kombination von zwei Emoji 👍und🏽

👩‍👩‍👧‍👦ist die Kombination von vier Emoji mit angeschlossenem Joiner mit einer Breite von Null. Das Format ist👩‍joiner👩‍joiner👧‍joiner👦

1. Zählt

In Swift 4.0 wird Emoji als Graphemcluster gezählt. Jedes einzelne Emoji wird als 1 gezählt. Die countEigenschaft ist auch direkt für Zeichenfolgen verfügbar. Sie können es also direkt so nennen.

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

Das Zeichenarray einer Zeichenfolge wird in Swift 4.0 auch als Graphemcluster gezählt, sodass beide folgenden Codes 1 ausgeben. Diese beiden Emoji sind Beispiele für Emoji-Sequenzen, bei denen mehrere Emoji mit oder ohne Joiner mit der Breite Null kombiniert \u{200d}werden. In Swift 3.0 trennt das Zeichenarray einer solchen Zeichenfolge jedes Emoji und führt zu einem Array mit mehreren Elementen (Emoji). Der Joiner wird dabei ignoriert. In Swift 4.0 sieht das Zeichenarray jedoch alle Emoji als ein Stück. Das von jedem Emoji wird also immer 1 sein.

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

unicodeScalars bleibt in Swift 4 unverändert. Es enthält die eindeutigen Unicode-Zeichen in der angegebenen Zeichenfolge.

"👍🏽".unicodeScalars.count  // 2. Combination of two emoji
"👩‍👩‍👧‍👦".unicodeScalars.count  // 7. Combination of four emoji with joiner between them

2. Enthält

In Swift 4.0 containsignoriert die Methode den Joiner mit der Breite Null in Emoji. Es gibt also true für eine der vier Emoji-Komponenten von zurück "👩‍👩‍👧‍👦"und false, wenn Sie nach dem Joiner suchen. In Swift 3.0 wird der Joiner jedoch nicht ignoriert und mit dem davor stehenden Emoji kombiniert. Wenn Sie also überprüfen, ob "👩‍👩‍👧‍👦"die ersten drei Komponenten Emoji enthalten, ist das Ergebnis falsch

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

Emojis sind ähnlich wie der Unicode-Standard täuschend kompliziert. Hauttöne, Geschlechter, Jobs, Personengruppen, Joiner-Sequenzen mit einer Breite von Null, Flaggen (Unicode mit 2 Zeichen) und andere Komplikationen können das Parsen von Emoji unübersichtlich machen. Ein Weihnachtsbaum, ein Stück Pizza oder ein Haufen Kacke können alle mit einem einzigen Unicode-Codepunkt dargestellt werden. Ganz zu schweigen davon, dass es bei der Einführung neuer Emojis zu einer Verzögerung zwischen der iOS-Unterstützung und der Emoji-Veröffentlichung kommt. Das und die Tatsache, dass verschiedene Versionen von iOS verschiedene Versionen des Unicode-Standards unterstützen.

TL; DR. Ich habe an diesen Funktionen gearbeitet und eine Bibliothek eröffnet. Ich bin der Autor von JKEmoji , um das Parsen von Strings mit Emojis zu unterstützen. Es macht das Parsen so einfach wie:

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

5

Dazu wird routinemäßig eine lokale Datenbank aller erkannten Emojis ab der neuesten Unicode-Version (ab 12.0 ) aktualisiert und anhand der Bitmap-Darstellung von mit den in der laufenden Betriebssystemversion als gültig erkannten Emojis verglichen ein nicht erkannter Emoji-Charakter.

HINWEIS

Eine vorherige Antwort wurde gelöscht, um für meine Bibliothek zu werben, ohne eindeutig anzugeben, dass ich der Autor bin. Ich erkenne das wieder an.

Joe
quelle
2
Obwohl ich von Ihrer Bibliothek beeindruckt bin und sehe, wie sie im Allgemeinen mit dem
vorliegenden