Почему символы эмодзи, такие как 👩‍👩‍👧‍👦, так странно воспринимаются в строках Swift?


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
Комментарии не для расширенного обсуждения; этот разговор был перемещен в чат .
Мартин Питерс

1
Исправлено в Swift 4. По- "👩‍👩‍👧‍👦".contains("\u{200D}")прежнему возвращает false, не уверен, что это ошибка или функция.
Кевин

4
Хлоп. Юникод имеет испорченный текст. Он превратил простой текст в язык разметки.
Boann

6
@Boann да и нет ... множество этих изменений было внесено для того, чтобы en / декодирование вещей, таких как Hangul Jamo (255 кодовых точек), не было абсолютным кошмаром, как это было для кандзи (13,108 кодовых точек) и китайских идеографов (199,528 кодовых точек). Конечно, это более сложно и интересно, чем позволяет длина комментария SO, поэтому я призываю вас проверить это самостоятельно: 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(_:)метода он работает так, как вы ожидаете. Так как вы не сравниваете смайлики, содержащие соединения нулевой ширины, метод не найдет совпадения ни для одного, кроме последнего символа.

Более подробно, если вы создадите a, 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

Первая проблема в том, что вы соединяетесь с Foundation contains(Swift's Stringне a Collection), так что это NSStringповедение, которое, я не думаю, обрабатывает составленные Emoji так же мощно, как Swift. Тем не менее, 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", "👦"]. Это делает все немного более понятным. 👩 не является участником этого списка (это «WZWJ»), но 👦 является участником.

Проблема в том, что Characterэто «кластер графем», который объединяет вещи (например, присоединяет ZWJ). То, что вы действительно ищете, это скаляр Unicode. И это работает именно так, как вы ожидаете:

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

Похоже, что Swift считает a 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

Другие ответы обсуждают, что делает 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

Я предполагаю, что .containsв Swift это проще, но это не значит, что нет других вещей, которые становятся более сложными.

Работа на этом уровне значительно упрощает случайное разбиение строки, например, на середину составного символа.


То, что вы непреднамеренно спрашиваете, почему это представление более высокого уровня не работает, как представление более низкого уровня. Ответ, конечно, не должен.

Если вы спрашиваете себя « почему это должно быть так сложно », ответ, конечно же, « люди ».


4
Вы потеряли меня в последней строке примера; что делать rotorи grepделать здесь? А что есть 1-$l?
Бен Легжеро

4
Термин "графема", по крайней мере, 50 лет. Юникод ввел его в стандарт, потому что они уже использовали термин «персонаж» для обозначения чего-то совершенно отличного от того, что обычно считают персонажем. Я могу прочитать то, что вы написали, как соответствующее этому, но подозреваю, что у других может возникнуть неправильное впечатление, отсюда и этот (надеюсь, уточняющий) комментарий.
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). Он пытается сопоставить каждый элемент с его аргументом соответствия (в этом случае@match ). В .Boolто возвращается Trueтогда и только тогда grepпроизводит хотя бы один матч.
raiph

18

Swift 4.0 обновление

Строка получила много изменений в обновлении 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метод игнорирует столяр нулевой ширины в смайликах. Таким образом, он возвращает true для любого из четырех компонентов emoji "👩‍👩‍👧‍👦"и возвращает false, если вы проверяете на присоединение. Тем не менее, в 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

Emojis, как и стандарт Unicode, обманчиво сложен. Тон кожи, роды, задания, группы людей, последовательности соединения нулевой ширины, флаги (2-символьный юникод) и другие сложности могут сделать анализ смайликов беспорядочным. Рождественская елка, кусок пиццы или куча кормы могут быть представлены одной кодовой точкой Unicode. Не говоря уже о том, что при появлении новых смайликов между поддержкой iOS и выпуском смайликов возникает задержка. Это и тот факт, что разные версии iOS поддерживают разные версии стандарта Unicode.

TL; DR. Я работал над этими функциями и открыл библиотеку, которую я являюсь автором для JKEmoji, чтобы помочь разобрать строки с эмодзи. Это делает анализ так же просто, как:

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

5

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

НОТА

Предыдущий ответ был удален для рекламы моей библиотеки без четкого указания, что я являюсь автором. Я подтверждаю это снова.


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