Avatar ·

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

Привет! Я новичок в Swift и пытаюсь разобраться с обработкой строк, содержащих эмодзи. Столкнулся с очень странным поведением на примере семейного эмодзи 👩‍👩‍👧‍👦 (две женщины, девочка и мальчик). В Xcode я написал простой код: ```swift let family = "👩‍👩‍👧‍👦" print("Количество символов: \(family.count)") print("UTF-8 представление: \(Array(family.utf8))") print("Первый символ: \(family.first!)") ``` И получил вывод: ``` Количество символов: 1 UTF-8 представление: [240, 159, 145, 169, 226, 128, 141, 240, 159, 145, 169, 226, 128, 141, 240, 159, 145, 167, 226, 128, 141, 240, 159, 145, 166] Первый символ: 👩 ``` Я ожидал, что `count` покажет 4 (ведь на картинке четыре человека), но вижу 1. При этом `first` возвращает только первый элемент, а не всё семейство. Почему так происходит? Как правильно работать с такими составными эмодзи? В чём разница между `count`, `utf8.count` и `unicodeScalars.count`? Заранее спасибо за помощь! <<>>

Понимание Unicode в Swift: графемные кластеры и составные эмодзи

Вы столкнулись с фундаментальной особенностью работы Swift со строками — графемными кластерами (grapheme clusters). Swift обрабатывает строки на уровне "воспринимаемых пользователем символов", а не отдельных кодовых точек Unicode. Это сделано для того, чтобы строки вели себя интуитивно понятно для людей, но иногда приводит к неожиданностям.

Почему ваш эмодзи считается одним символом?

Эмодзи 👩‍👩‍👧‍👦 — это не один символ, а последовательность из семи кодовых точек Unicode, объединённых с помощью нулевого соединителя (Zero Width Joiner, U+200D). Визуально это выглядит как одна картинка, но технически это:

👩 (U+1F469) + ZWJ (U+200D) + 👩 (U+1F469) + ZWJ (U+200D) + 👧 (U+1F467) + ZWJ (U+200D) + 👦 (U+1F466)

Swift рассматривает эту последовательность как один расширенный графемный кластер (extended grapheme cluster). Поэтому family.count возвращает 1 — это один "человеческий символ".

Как получить отдельные компоненты?

Чтобы разобрать составной эмодзи на части, нужно работать с разными уровнями представления строки:

let family = "👩‍👩‍👧‍👦"

// 1. Свойство count — количество графемных кластеров (воспринимаемых символов)
print("Графемные кластеры: \(family.count)") // 1

// 2. unicodeScalars — отдельные кодовые точки Unicode
print("Кодовые точки: \(family.unicodeScalars.count)") // 7
for scalar in family.unicodeScalars {
    print(scalar, scalar.value)
}

// 3. utf8.count — количество байт в UTF-8
print("Байт в UTF-8: \(family.utf8.count)") // 25

// 4. utf16.count — количество 16-битных единиц (как в NSString)
print("16-битных единиц: \(family.utf16.count)") // 11

Практические рекомендации для работы с эмодзи

  • Используйте .count для подсчёта видимых символов — это то, что видит пользователь.
  • Для итерации по символам используйте for char in string — вы получите каждый графемный кластер.
  • Для доступа к отдельным компонентам (например, чтобы проверить, есть ли в эмодзи определённый элемент) работайте с unicodeScalars.
  • Будьте осторожны с индексами: string[0] не работает. Используйте string.index(string.startIndex, offsetBy: n).

Пример: проверка, содержит ли строка определённый эмодзи-компонент

func containsWoman(in emoji: String) -> Bool {
    // Женщина: U+1F469
    return emoji.unicodeScalars.contains(UnicodeScalar(0x1F469)!)
}

let family = "👩‍👩‍👧‍👦"
print(containsWoman(in: family)) // true

let single = "👦"
print(containsWoman(in: single)) // false

Почему Swift выбрал такой подход?

Это сделано для корректной работы с разными языками. Например, в корейском буквы объединяются в слоги, а в некоторых европейских языках используются комбинируемые диакритические знаки (например, "é" может быть как одним, так и двумя символами). Swift гарантирует, что .count всегда отражает то, что видит пользователь.

Если вам нужно работать с сырыми байтами (например, для отправки по сети), используйте .utf8 или .utf16. Для сравнения строк на равенство используйте обычный == — он учитывает графемные кластеры.

<<>>

Альтернативный взгляд: работа с NSString для обратной совместимости

Если вы пришли из Objective-C или работаете с кодом, который ожидает поведение как в NSString, помните: Swift-строки мостуются к NSString. В отличие от Swift, NSString.length возвращает количество 16-битных единиц UTF-16, а не графемных кластеров:

import Foundation

let family = "👩‍👩‍👧‍👦"
let nsString = family as NSString
print("NSString length: \(nsString.length)") // 11 — не то, что вы ожидаете!

Это может вызвать ошибки, если вы смешиваете Swift и Objective-C API. Всегда используйте String.count для пользовательского интерфейса и String.utf16.count только если вам нужно точное соответствие с Objective-C.

Дополнительный совет: визуализация структуры эмодзи

Для отладки полезно увидеть, из каких частей состоит составной эмодзи. Используйте этот код:

func describeEmoji(_ emoji: String) {
    print("Символ: \(emoji)")
    print("Графем: \(emoji.count)")
    print("Unicode скаляры:")
    for scalar in emoji.unicodeScalars {
        let isZWJ = scalar.value == 0x200D
        print("  \(isZWJ ? "🔗 ZWJ" : "\(scalar) (U+\(String(format: "%04X", scalar.value)))")")
    }
}

describeEmoji("👩‍👩‍👧‍👦")

Этот подход помогает понять, как Swift интерпретирует строку, и избежать ошибок при обработке пользовательского ввода, содержащего эмодзи.