Чому персонажі смайлів типу 👩‍👩‍👧‍👦 так дивно трактуються в рядках Свіфта?


540

Характер 👩‍👩‍👧‍👦 (сім'я з двома жінками, однією дівчиною та одним хлопчиком) закодований як такий:

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!.



1
Коментарі не для розширеного обговорення; ця розмова була переміщена до чату .
Martijn Pieters

1
Виправлено у Swift 4. "👩‍👩‍👧‍👦".contains("\u{200D}")як і раніше повертає помилку, не впевнений, що це помилка чи функція.
Кевін

4
Yikes. Unicode зруйнував текст. Це звичайний текст перетворюється на мову розмітки.
Боан

6
@Boann так і ні ... багато цих змін було внесено, щоб зробити en / декодування таких речей, як Хангул Джамо (255 кодових точок), не був абсолютним кошмаром, як це було для Канджі (13,108 точкових точок) та китайських Ideographs (199,528 кодових точок). Звичайно, це складніше і цікавіше, ніж може дозволити тривалість коментаря ЗО, тому я закликаю вас перевірити це самостійно: D
Бен Леджіеро

Відповіді:


402

Це пов'язано з тим, як Stringпрацює тип Swift та як contains(_:)працює метод.

'👩‍👩‍👧‍👦' називається послідовністю смайликів, яка відображається як один видимий символ у рядку. Послідовність складається з Characterпредметів, і в той же час вона складається з UnicodeScalarпредметів.

Якщо ви перевірите кількість символів рядка, ви побачите, що він складається з чотирьох символів, тоді як якщо ви перевірите скалярний підрахунок унікоду, він покаже вам інший результат:

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, який намагається знайти точну відповідність даному аргументу. Оскільки символи, що закінчуються приєднувачем нульової ширини, утворюють неповну послідовність, метод намагається знайти відповідність аргументу, поєднуючи символи, що закінчуються нульовою шириною приєднувачів, у повну послідовність. Це означає, що метод ніколи не знайде відповідність, якщо:

  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

47
@MartinR Згідно з діючими UTR29 (Unicode 9.0), то є розширений кластер графеми ( правила GB10 та GB11 ), але Swift явно використовує стару версію. Мабуть, фіксація цієї мети для версії 4 мови , тому така поведінка зміниться в майбутньому.
Майкл Гомер

9
@MichaelHomer: Мабуть, це було виправлено, "👩‍👩‍👧‍👦".countоцінюється 1з поточною бета-версією Xcode 9 та Swift 4.
Martin R

5
Ого. Це чудово. Але зараз я ностальгую за старими часами, коли найгірша проблема, з якою у мене виникли рядки, - це те, чи вони використовують кодування у стилі C або Pascal.
Оуен Годфрі

2
Я розумію, чому стандарт Unicode може знадобитися для цього, але людину, це непорозуміння, якщо що завгодно: /
Моніку

110

Перша проблема полягає в тому, що ви наближаєтесь до Фонду contains(Свіфт - Stringце не Collection), так цеNSString поведінка, яку я не вірю, що ручки складені Емоджі так само сильно, як і Свіфт. Однак, Swift, я вважаю, зараз реалізує Unicode 8, що також потребує перегляду навколо цієї ситуації в Unicode 10 (тому це може змінитися, коли вони реалізують Unicode 10; я не розглядав, буде він чи ні).

Щоб спростити річ, давайте позбудемося фонду і скористаємося 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). Те, що ви насправді шукаєте, - скаляр однокодового. І це працює саме так, як ви очікували:

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

І звичайно, ми також можемо шукати реального персонажа, який є там:

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

(Це сильно копіює точки Бен Леджієро. Я розмістив це, перш ніж помітив, що він відповів. Залишаючи, якщо комусь зрозуміліше.)


Чого ZWJстоїть?
LinusGeffarth

2
Столяр нульової ширини
Роб Нап'єр

@RobNapier у Swift 4 Stringнібито було змінено назад на тип колекції. Це взагалі впливає на вашу відповідь?
Бен Леджієро

Ні. Це просто змінило речі, такі як підписка. Це не змінило, як працюють персонажі.
Роб Нап'єр

75

Схоже, Свіфт вважає 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) - це один кластер, який виступає як один символ.


19

Інші відповіді обговорюють те, що робить Свіфт, але не вникайте в деталі про те, чому.

Чи очікуєте ви, що "Å" дорівнює "Å"? Я очікую, що ти би.

Один з них - лист із комбайнером, інший - єдиним складеним символом. До базового символу можна додати безліч різних комбінаторів, і людина все одно вважатиме його одним символом. Для усунення подібних розбіжностей було створено поняття графеми, щоб представити, що людина вважатиме символом незалежно від використаних точок коду.

Тепер служби текстових повідомлень роками поєднують символи у графічні емодзі :) →  🙂. Тож до 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

Я припускаю, що .containsв Swift це робить простіше, але це не означає, що немає інших речей, які стають складнішими.

Робота на цьому рівні значно полегшує випадкове розбиття рядка посередині складеного символу.


Те, що ви необережно запитуєте, - це чому представлення вищого рівня не працює так, як було б представлення нижчого рівня. Відповідь, звичайно, не повинна.

Якщо ви запитуєте себе " чому це має бути таким складним ", відповідь, звичайно, " люди ".


4
Ви втратили мене на останньому прикладі рядка; що робити rotorі grepробити тут? А що таке 1-$l?
Бен Леджієро

4
Терміну "графема" принаймні 50 років. Unicode представив його до стандарту, оскільки вони вже вживали термін "персонаж", щоб означати щось зовсім інше, ніж те, що зазвичай вважають персонажем. Я можу прочитати те, що ви написали як таке, що відповідає цьому, але підозрюючи, що інші можуть скласти неправильне враження, звідси це (сподіваюся, уточнююче) коментар.
raiph

2
@BenLeggiero По перше, rotor. Код say (1,2,3,4,5,6).rotor(3)дає ((1 2 3) (4 5 6)). Це список списків, кожна довжина 3. say (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тоді код @ Брэда, створює лише один підпис, тому =>1-$lбіт не має значення (не використовується). Це актуально лише у випадку, якщо @matchвоно коротше @components.
raiph

1
grepнамагається відповідати кожному елементу в його інканданті (у цьому випадку - список підспісів @components). Він намагається співставити кожен елемент з його аргументом matcher (в даному випадку @match). В .Boolтой повертається Trueтоді і тільки тоді grepвиробляє хоча б один матч.
raiph

18

Швидке оновлення 4.0

String отримав багато змін у оновлення Swift 4, як це зафіксовано в SE-0163 . Для цієї демонстрації використовуються два емоджи, що представляють дві різні структури. Обидва поєднуються з послідовністю смайликів.

👍🏽- це поєднання двох емоджи 👍та🏽

👩‍👩‍👧‍👦являє собою комбінацію чотирьох емоджи, з підключеним столярною шириною нуля. Формат є👩‍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. Ці два емоджи - приклади послідовностей смайлів, де кілька смайлів поєднуються разом із або без нуля ширини \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метод ігнорує приєднувач нульової ширини в смайликах. Таким чином, він повертає істину для будь-якого з чотирьох компонентів смайликів "👩‍👩‍👧‍👦"і повертає помилкове значення, якщо ви перевіряєте на столяр. Однак у Swift 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

0

Емоджи, як і стандарт унікоду, оманливо складні. Тони шкіри, стать, робочі місця, групи людей, послідовність з’єднання нульової ширини, прапорці (2 символи) та інші ускладнення можуть зробити розбір емоджи безладним. Різдвяна ялинка, шматочок піци або купка пупа можуть бути представлені однією кодовою точкою Unicode. Не кажучи вже про те, що при впровадженні нових емоджи є затримка між підтримкою iOS та випуском смайликів. Це і той факт, що різні версії iOS підтримують різні версії стандарту unicode.

TL; DR. Я працював над цими можливостями і відкрив бібліотеку, я є автором JKEmoji, щоб допомогти розібрати рядки з емоджи. Це робить розбір таким же простим, як:

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

5

Це робиться шляхом регулярного оновлення локальної бази даних усіх розпізнаних емоджи на останній версії unicode ( 12.0 від недавно) та перехресних посилань на те, що визнано допустимим емоджи у запущеній версії ОС, переглядаючи растрове представлення невпізнаний персонаж смайлів.

ПРИМІТКА

Попередня відповідь була видалена за рекламу моєї бібліотеки, не чітко вказуючи, що я є автором. Я знову це визнаю.


2
Хоча мене вражає ваша бібліотека, і я бачу, як це взагалі пов’язано з розглянутою темою, я не бачу, як це безпосередньо стосується питання
Бен Леджіеро
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.