Почему символы эмодзи, такие как , так странно воспринимаются в строках 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!
,
5 ответов
Это связано с тем, как 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
, который пытается найти точное соответствие заданному аргументу. Поскольку символы, заканчивающиеся объединителем нулевой ширины, образуют неполную последовательность, метод пытается найти соответствие для аргумента, комбинируя символы, заканчивающиеся объединителями нулевой ширины, в полную последовательность. Это означает, что метод никогда не найдет соответствия, если:
- аргумент заканчивается соединителем нулевой ширины, и
- строка для анализа не содержит неполной последовательности (то есть, заканчивающейся соединителем нулевой ширины и не сопровождаемым совместимым символом).
Показывать:
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
выполняет сравнение по точной посимвольной эквивалентности. В качестве примечания, под характером здесь подразумевается не Свифт 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
Первая проблема заключается в том, что вы соединяетесь с Фондом contains
(Свифт String
это не Collection
), так что это NSString
поведение, которое я не считаю, сочиняет эмодзи так же мощно, как Свифт. Тем не менее, 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
(Это сильно дублирует баллы Бена Легжеро. Я опубликовал это, прежде чем заметил, что он ответил. Уходя, если кому-то это станет понятнее.)
Кажется, что Свифт считает 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
) является одним кластером, который действует как один символ.
Обновление Swift 4.0
Строка получает много ревизий в обновлении swift 4, как описано в SE-0163. Для этой демонстрации используются два смайлика, представляющие две разные структуры. Оба в сочетании с последовательностью смайликов.
это комбинация двух смайликов,
а также
это комбинация из четырех смайликов, с присоединенным соединителем нулевой ширины. Формат joinerjoinerjoiner
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
Метод игнорирует столяр нулевой ширины в смайликах. Таким образом, он возвращает истину для любого из четырех компонентов 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
Другие ответы обсуждают, что делает 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 это проще, но это не значит, что нет других вещей, которые становятся более сложными.
Работа на этом уровне значительно упрощает случайное разбиение строки, например, на середину составного символа.
То, что вы непреднамеренно спрашиваете, почему это представление более высокого уровня не работает, как представление более низкого уровня. Ответ, конечно, не должен.
Если вы спрашиваете себя "почему это должно быть так сложно", ответ, конечно, "люди".
Emojis, так же, как стандарт Unicode, обманчиво сложен. Тон кожи, роды, задания, группы людей, последовательности соединения нулевой ширины, флаги (2-символьный юникод) и другие сложности могут сделать анализ смайликов беспорядочным. Рождественская елка, кусок пиццы или куча кормы могут быть представлены одной кодовой точкой Unicode. Не говоря уже о том, что при появлении новых смайликов между поддержкой iOS и выпуском смайликов возникает задержка. Это и тот факт, что разные версии iOS поддерживают разные версии стандарта Unicode.
TL; DR. Я работал над этими функциями и открыл библиотеку, которую я являюсь автором для JKEmoji, чтобы помочь разобрать строки с эмодзи. Это делает анализ так же просто, как:
print("I love these emojis ".emojiCount)
5
Это достигается путем регулярного обновления локальной базы данных всех распознанных смайликов начиная с последней версии юникода (с недавнего времени 12.0) и перекрестной ссылки на них с тем, что распознается как действительный смайлик в работающей версии ОС, путем просмотра растрового представления непризнанный персонаж смайликов.
НОТА
Предыдущий ответ был удален для рекламы моей библиотеки без четкого указания, что я являюсь автором. Я подтверждаю это снова.