Dlaczego znaki emoji, takie jak 👩‍👩‍👧‍👦, są tak dziwnie traktowane w ciągach Swift?

539

Postać 👩‍👩‍👧‍👦 (rodzina z dwiema kobietami, jedną dziewczynką i jednym chłopcem) jest zakodowana jako taka:

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

Jest więc bardzo ciekawie zakodowany; idealny cel do testu jednostkowego. Jednak wydaje się, że Swift nie wie, jak to leczyć. Oto co mam na myśli:

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

Więc Swift mówi, że zawiera siebie (dobrze) i chłopca (dobrze!). Ale potem mówi, że nie zawiera kobiety, dziewczyny ani stolarki o zerowej szerokości. Co tu się dzieje? Dlaczego Swift wie, że zawiera chłopca, ale nie kobietę ani dziewczynkę? Zrozumiałbym, gdyby traktował go jako pojedynczą postać i rozpoznał tylko, że zawiera się w sobie, ale fakt, że dostał jeden podskładnik, a żadnych innych mnie nie zaskakuje.

Nie zmienia się to, jeśli używam czegoś takiego "👩".characters.first!.


Jeszcze bardziej kłopotliwe jest to:

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

Mimo że umieściłem tam ZWJ, nie są one odzwierciedlone w tablicy znaków. To, co nastąpiło później, było trochę mówiące:

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

Mam takie samo zachowanie z tablicą znaków ... co jest wyjątkowo denerwujące, ponieważ wiem, jak wygląda tablica.

To też się nie zmienia, jeśli użyję czegoś takiego "👩".characters.first!.

Ben Leggiero
źródło
1
Komentarze nie są przeznaczone do rozszerzonej dyskusji; ta rozmowa została przeniesiona do czatu .
Martijn Pieters
1
Naprawiono w Swift 4. "👩‍👩‍👧‍👦".contains("\u{200D}")nadal zwraca false, nie jestem pewien, czy to błąd, czy funkcja.
Kevin
4
Yikes. Unicode zniszczył tekst. Został przekształcony zwykły tekst w język znaczników.
Boann
6
@Boann tak i nie ... wprowadzono wiele tych zmian, aby en / dekodowanie takich rzeczy jak Hangul Jamo (255 punktów kodowych) nie było absolutnym koszmarem, jak to miało miejsce w przypadku Kanji (13 108 punktów kodowych) i chińskich ideografów (199 528 punktów kodowych). Oczywiście jest to bardziej skomplikowane i interesujące niż długość komentarza SO może na to pozwolić, więc zachęcam do samodzielnego sprawdzenia: D
Ben Leggiero

Odpowiedzi:

401

Ma to związek z tym, jak Stringtyp działa w Swift i jak contains(_:)działa metoda.

„👩‍👩‍👧‍👦” to tak zwana sekwencja emoji, która jest renderowana jako jeden widoczny znak w ciągu. Sekwencja składa się z Characterobiektów, a jednocześnie składa się z UnicodeScalarobiektów.

Jeśli sprawdzisz liczbę znaków ciągu, zobaczysz, że składa się on z czterech znaków, a jeśli sprawdzisz liczbę skalarną Unicode, wyświetli się inny wynik:

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

Teraz, gdy przeanalizujesz znaki i wydrukujesz je, zobaczysz coś, co wydaje się normalne, ale w rzeczywistości trzy pierwsze znaki zawierają zarówno emoji, jak i łącznik o zerowej szerokości 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"]

Jak widać, tylko ostatni znak nie zawiera łącznika o zerowej szerokości, więc przy użyciu tej contains(_:)metody działa tak, jak można się spodziewać. Ponieważ nie porównujesz z emoji zawierającymi łączniki o zerowej szerokości, metoda nie znajdzie dopasowania dla żadnego oprócz ostatniego znaku.

Aby rozwinąć tę kwestię, jeśli utworzysz Stringkompozycję składającą się ze znaku emoji kończącego się łącznikiem o zerowej szerokości i przekażesz ją do contains(_:)metody, to również oceni to false. Ma to związek z contains(_:)byciem dokładnie takim samym jak range(of:) != nil, który próbuje znaleźć dokładne dopasowanie do podanego argumentu. Ponieważ znaki kończące się łącznikiem o zerowej szerokości tworzą niekompletną sekwencję, metoda próbuje znaleźć dopasowanie dla argumentu, łącząc znaki kończące się łącznikami o zerowej szerokości w pełną sekwencję. Oznacza to, że metoda nigdy nie znajdzie dopasowania, jeśli:

  1. argument kończy się łącznikiem o zerowej szerokości, oraz
  2. ciąg do analizy nie zawiera niekompletnej sekwencji (tzn. kończy się łącznikiem o zerowej szerokości i nie następuje po nim zgodny znak).

Aby zademonstrować:

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

Ponieważ jednak porównanie tylko patrzy w przyszłość, można znaleźć kilka innych pełnych sekwencji w ciągu, pracując wstecz:

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

Najłatwiejszym rozwiązaniem byłoby zapewnienie konkretnej opcji porównania z range(of:options:range:locale:)metodą. Opcja String.CompareOptions.literalwykonuje porównanie na podstawie dokładnej równoważności znak po znaku . Na marginesie, co oznaczało po znaku Oto nie Swift Character, ale UTF-16 reprezentacji obu instancji i porównania String - jednak, ponieważ Stringnie pozwala zniekształcone UTF-16, to jest w zasadzie równoważne porównując skalarne Unicode reprezentacja.

Tutaj przeciążyłem Foundationmetodę, więc jeśli potrzebujesz oryginalnej, zmień nazwę tej lub innej:

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

Teraz metoda działa tak, jak „powinna” z każdym znakiem, nawet z niekompletnymi sekwencjami:

s.contains("👩")          // true
s.contains("👩\u{200d}")  // true
s.contains("\u{200d}")    // true
xoudini
źródło
47
@MartinR Zgodnie z obecnym UTR29 (Unicode 9.0) jest to rozszerzony klaster grafemowy ( reguły GB10 i GB11 ), ale Swift wyraźnie używa starszej wersji. Najwyraźniej naprawienie tego jest celem wersji 4 języka , więc to zachowanie zmieni się w przyszłości.
Michael Homer
9
@MichaelHomer: Najwyraźniej zostało to naprawione, "👩‍👩‍👧‍👦".countocenia 1na obecną wersję Xcode 9 beta i Swift 4.
Martin R
5
Łał. To jest doskonałe. Ale teraz robię się nostalgiczny za dawnych czasów, kiedy najgorszym problemem z łańcuchami jest to, czy używają kodowania w stylu C czy Pascal.
Owen Godfrey
2
Rozumiem, dlaczego standard Unicode może być w stanie to obsługiwać, ale stary, to jest przerośnięty bałagan, jeśli w ogóle: /
Przywróć Monikę
109

Pierwszym problemem jest to, że łączysz się z Foundation contains(Swift's Stringnie jest a Collection), więc jest to NSStringzachowanie, które nie wydaje mi się, że obsługuje skomponowane Emoji tak silnie jak Swift. To powiedziawszy, wydaje mi się, że Swift wdraża teraz Unicode 8, co również wymagało korekty wokół tej sytuacji w Unicode 10 (więc to wszystko może się zmienić, gdy implementują Unicode 10; nie zastanawiałem się, czy to zrobi, czy nie).

Aby uprościć sprawę, pozbądźmy się Fundacji i użyj Swift, który zapewnia bardziej wyraźne widoki. Zaczniemy od postaci:

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

OK. Tego się spodziewaliśmy. Ale to kłamstwo. Zobaczmy, jakie naprawdę są te postacie.

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

Ach… Więc to jest ["👩ZWJ", "👩ZWJ", "👧ZWJ", "👦"]. To sprawia, że ​​wszystko jest trochę bardziej jasne. 👩 nie jest członkiem tej listy (jest to „👩ZWJ”), ale 👦 jest członkiem.

Problem polega na tym, że Characterjest to „klaster grafemiczny”, który tworzy rzeczy razem (np. Dołączanie ZWJ). To, czego tak naprawdę szukasz, to skalar unicode. I to działa dokładnie tak, jak się spodziewasz:

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

I oczywiście możemy również poszukać rzeczywistej postaci, która się tam znajduje:

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

(To bardzo powiela punkty Bena Leggiero. Wysłałem to, zanim zauważyłem, że odpowiedział. Pozostawiając na wypadek, gdyby ktokolwiek był bardziej zrozumiały.)

Rob Napier
źródło
Wth oznacza ZWJ?
LinusGeffarth
2
Stolarz o zerowej szerokości
Rob Napier
@RobNapier w Swift 4, Stringrzekomo został zmieniony z powrotem na typ kolekcji. Czy to w ogóle wpływa na twoją odpowiedź?
Ben Leggiero,
Nie. To właśnie zmieniło takie rzeczy jak indeksowanie. Nie zmieniło to działania Postaci.
Rob Napier
75

Wygląda na to, że Swift uważa ZWJrozszerzoną grupę grafemów z postacią bezpośrednio poprzedzającą. Widzimy to podczas mapowania tablicy znaków na ich unicodeScalars:

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

Spowoduje to wydrukowanie następującego pliku z 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}"

Ponadto .containsgrupy rozszerzyły klastry grafemów w jedną postać. Na przykład, biorąc HANGUL znaków , oraz (które składają się na koreańskiej słowo „jeden”: 한):

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

Nie można tego znaleźć, ponieważ trzy punkty kodowe są zgrupowane w jeden klaster, który działa jak jeden znak. Podobnie \u{1F469}\u{200D}( WOMAN ZWJ) to jeden klaster, który działa jak jeden znak.

Ben Leggiero
źródło
19

Pozostałe odpowiedzi omawiają to, co robi Swift, ale nie zawierają szczegółowych informacji na temat tego, dlaczego.

Czy spodziewasz się, że „Å” będzie równe „Å”? Oczekuję, że tak.

Jedna z nich to litera z łącznikiem, druga to pojedynczy złożony znak. Możesz dodać wiele różnych kombinacji do postaci podstawowej, a człowiek nadal uważa ją za pojedynczą postać. Aby poradzić sobie z tego rodzaju rozbieżnościami, stworzono koncepcję grafemu, która reprezentuje to, co człowiek uważa za postać, niezależnie od użytych współrzędnych kodowych.

Teraz usługi SMS-ów od lat łączą znaki w graficzne emoji :) →  🙂. Do Unicode dodano więc różne emoji.
Usługi te zaczęły także łączyć emoji razem w emoji kompozytowe.
Oczywiście nie ma rozsądnego sposobu na zakodowanie wszystkich możliwych kombinacji w poszczególnych punktach kodowych, więc Konsorcjum Unicode postanowiło rozwinąć koncepcję grafemów, aby objąć te złożone znaki.

Sprowadza się to "👩‍👩‍👧‍👦"do pojedynczego „klastra grafemowego”, jeśli próbujesz z nim pracować na poziomie grafemowym, jak domyślnie robi Swift.

Jeśli chcesz sprawdzić, czy zawiera "👦"to część, powinieneś zejść na niższy poziom.


Nie znam składni Swift, więc oto Perl 6, który ma podobny poziom obsługi Unicode.
(Perl 6 obsługuje Unicode w wersji 9, więc mogą wystąpić rozbieżności)

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

Zejdźmy o poziom

# 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

Zejście na ten poziom może jednak utrudnić niektóre rzeczy.

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

Zakładam, że .containsw Swift to ułatwia, ale to nie znaczy, że nie ma innych rzeczy, które stałyby się trudniejsze.

Praca na tym poziomie znacznie ułatwia na przykład przypadkowe podzielenie łańcucha w środku złożonego znaku.


Nieumyślnie pytasz, dlaczego ta reprezentacja wyższego poziomu nie działa tak jak reprezentacja niższego poziomu. Odpowiedź brzmi oczywiście, że nie powinna.

Jeśli zadajesz sobie pytanie „ dlaczego to musi być takie skomplikowane ”, odpowiedź brzmi oczywiście „ ludzie ”.

Brad Gilbert
źródło
4
Zgubiłeś mnie w swoim ostatnim przykładzie; co rotori grepzrobić tutaj? A co to jest 1-$l?
Ben Leggiero
4
Termin „grafem” ma co najmniej 50 lat. Unicode wprowadził go do standardu, ponieważ już używał terminu „postać”, aby oznaczać coś zupełnie innego niż to, co zwykle uważa się za postać. Mogę przeczytać to, co napisałeś, jako zgodne z tym, ale podejrzewam, że inni mogą mieć złe wrażenie, stąd ten (mam nadzieję, że wyjaśnienie) komentarz.
raiph
2
@BenLeggiero pierwsze rotor. Kod say (1,2,3,4,5,6).rotor(3)daje ((1 2 3) (4 5 6)). To lista list każdej długości 3. say (1,2,3,4,5,6).rotor(3=>-2)daje to samo, z wyjątkiem tego, że druga podlista zaczyna się 2od 4, a trzecia z 3, i tak dalej, daje ((1 2 3) (2 3 4) (3 4 5) (4 5 6)). Jeśli @matchzawiera, "👩‍👩‍👧‍👦".ordsto kod @ Brada tworzy tylko jedną podlistę, więc =>1-$lbit jest nieistotny (nieużywany). Jest to istotne tylko wtedy, gdy @matchjest krótsze niż @components.
raiph
1
greppróbuje dopasować każdy element w jego wywoływaczu (w tym przypadku listę podlist @components). Próbuje dopasować każdy element do argumentu matcher (w tym przypadku, @match). Na .Boolczym wraca TrueIFF grepprodukuje przynajmniej jeden mecz.
raiph
18

Aktualizacja Swift 4.0

Ciąg otrzymał wiele poprawek w aktualizacji Swift 4, jak udokumentowano w SE-0163 . W tym pokazie używane są dwa emoji reprezentujące dwie różne struktury. Oba są połączone z sekwencją emoji.

👍🏽to połączenie dwóch emoji 👍i🏽

👩‍👩‍👧‍👦to kombinacja czterech emoji z podłączonym łącznikiem o zerowej szerokości. Format to👩‍joiner👩‍joiner👧‍joiner👦

1. Liczy się

W Swift 4.0 emoji jest liczone jako klaster grafemów. Każde emoji jest liczone jako 1. countWłaściwość jest również bezpośrednio dostępna dla ciągu. Możesz więc bezpośrednio tak to nazwać.

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

Tablica znaków ciągu jest również liczona jako klastry grafem w Swift 4.0, więc oba poniższe kody drukują 1. Te dwa emoji są przykładami sekwencji emoji, w których kilka emoji jest łączonych razem z łącznikiem o zerowej szerokości lub \u{200d}między nimi. W swift 3.0 tablica znaków takiego łańcucha oddziela każde emoji i daje tablicę z wieloma elementami (emoji). Łącznik jest w tym procesie ignorowany. Jednak w Swift 4.0 tablica znaków traktuje wszystkie emoji jako jeden element. Tak więc, dla każdego emoji zawsze będzie 1.

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

unicodeScalars pozostaje niezmieniony w Swift 4. Zapewnia unikalne znaki Unicode w podanym ciągu.

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

2. Zawiera

W Swift 4.0 containsmetoda ignoruje łącznik o zerowej szerokości w emoji. Zwraca więc wartość true dla dowolnego z czterech składników emoji "👩‍👩‍👧‍👦"i zwraca wartość false, jeśli zaznaczysz stolarkę. Jednak w Swift 3.0 łącznik nie jest ignorowany i jest łączony z emoji przed nim. Więc gdy sprawdzisz, czy "👩‍👩‍👧‍👦"zawiera pierwsze trzy komponenty emoji, wynik będzie fałszywy

"👍🏽".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
źródło
0

Emoji, podobnie jak standard Unicode, są oszukańczo skomplikowane. Odcienie skóry, płeć, praca, grupy ludzi, sekwencje łączenia zerowej szerokości, flagi (Unicode 2 znaków) i inne komplikacje mogą powodować, że parsowanie emoji jest nieporządne. Choinkę, kawałek pizzy lub kupę kupy można przedstawić za pomocą pojedynczego punktu kodu Unicode. Nie wspominając o tym, że po wprowadzeniu nowych emoji istnieje opóźnienie między obsługą iOS a wydaniem emoji. To i fakt, że różne wersje iOS obsługują różne wersje standardu Unicode.

TL; DR. Pracowałem nad tymi funkcjami i otworzyłem bibliotekę. Jestem autorem JKEmoji, który pomaga parsować ciągi znaków za pomocą emoji. Dzięki temu parsowanie jest tak proste jak:

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

5

Odbywa się to poprzez rutynowe odświeżanie lokalnej bazy danych wszystkich rozpoznawanych emoji od najnowszej wersji Unicode (od 12.0 od niedawna) i odsyłanie ich do tego, co jest rozpoznawane jako prawidłowe emoji w uruchomionej wersji systemu operacyjnego, poprzez przeglądanie mapy bitowej nierozpoznany znak emoji.

UWAGA

Poprzednia odpowiedź została usunięta za reklamowanie mojej biblioteki bez wyraźnego stwierdzenia, że ​​jestem autorem. Ponownie to potwierdzam.

Joe
źródło
2
Chociaż jestem pod wrażeniem twojej biblioteki i widzę, jak ogólnie jest ona związana z omawianym tematem, nie widzę, w jaki sposób ma to bezpośredni związek z pytaniem
Ben Leggiero