Avatar ·

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

Всем привет! Я пишу iOS-приложение, которое обрабатывает тексты с эмодзи. И столкнулся со странным поведением Swift при работе с многосоставными эмодзи, особенно с семьями и флагами. Например, у меня есть строка `let family = "👩‍👩‍👧‍👦"`. Я ожидаю, что `family.count` вернёт 1 (ведь это один эмодзи!), но вместо этого я получаю 7. А если попробовать получить первый символ через `family.first`, то возвращается не весь эмодзи, а какой-то странный символ. Вот код, который меня смутил: ```swift let family = "👩‍👩‍👧‍👦" print(family.count) // 7 print(family.utf8.count) // 25 print(family.utf16.count) // 11 print(family.unicodeScalars.count) // 7 print(family.first) // Optional("👩") ``` Я ожидал, что count будет 1, а first вернёт целый эмодзи. Почему Swift считает эти символы по-разному? Как правильно работать с такими эмодзи в Swift? Особенно меня интересует, как безопасно обрезать строки, содержащие такие составные эмодзи, чтобы не разорвать их на части. Пожалуйста, объясните простым языком, что тут происходит и как правильно обрабатывать такие случаи. Заранее спасибо! <<>>

Понимание Unicode и составных эмодзи в Swift

Вы столкнулись с фундаментальной особенностью Unicode, которую Swift отражает очень честно. Давайте разберёмся, что на самом деле представляет собой эмодзи 👩‍👩‍👧‍👦.

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

👩 (U+1F469) - женщина
‍ (U+200D)   - соединитель (ZWJ)
👩 (U+1F469) - женщина
‍ (U+200D)   - соединитель (ZWJ)
👧 (U+1F467) - девочка
‍ (U+200D)   - соединитель (ZWJ)
👦 (U+1F466) - мальчик

Когда вы видите family.count == 7, Swift сообщает вам количество Unicode-скаляров в строке. Это технически правильное значение, но не то, что вы ожидаете с точки зрения пользователя.

Что такое «символ» для пользователя?

В Swift есть понятие Extended Grapheme Cluster (расширенный кластер графемы) — это то, что пользователь воспринимает как один символ. Для вашего эмодзи это один кластер. Чтобы работать с ними, используйте свойства count у самой строки (оно считает именно кластеры) и метод first через индекс:

let family = "👩‍👩‍👧‍👦"
print(family.count)          // 1 - это количество кластеров!
print(family.first)          // Optional("👩‍👩‍👧‍👦") - весь кластер

// Правильный способ получить первый символ:
if let firstChar = family.first {
    print(firstChar)         // 👩‍👩‍👧‍👦
}

// Перебор по кластерам:
for char in family {
    print(char)              // 👩‍👩‍👧‍👦
}

Но подождите! Выше я сказал, что family.count == 7, а теперь говорю, что family.count == 1. В чём подвох? Всё дело в том, что count у String считает именно кластеры графем, а unicodeScalars.count считает скаляры. Проверьте:

print(family.unicodeScalars.count) // 7 - это скаляры
print(family.count)                // 1 - это кластеры

Как безопасно обрезать строки с эмодзи?

Проблема обрезки возникает, когда вы используете prefix() или suffix() с количеством скаляров, а не кластеров. Вот правильный подход:

let text = "Привет 👩‍👩‍👧‍👦 мир! 🇷🇺"
// Плохо: обрезаем по скалярам
let badTruncation = String(text.unicodeScalars.prefix(10))
print(badTruncation) // "Привет 👩" - эмодзи разорван!

// Хорошо: обрезаем по кластерам
let goodTruncation = text.prefix(10)
print(goodTruncation) // "Привет 👩‍👩‍👧‍👦 м" - эмодзи цел!

// Если нужно обрезать до определённой длины в байтах (например, для API):
func safeTruncate(_ string: String, maxBytes: Int) -> String {
    var result = ""
    var currentBytes = 0
    for char in string {
        let charBytes = String(char).utf8.count
        guard currentBytes + charBytes <= maxBytes else { break }
        result.append(char)
        currentBytes += charBytes
    }
    return result
}

print(safeTruncate(text, maxBytes: 20)) // "Привет 👩‍👩‍👩‍👧‍👦"

Практические советы

  • Всегда используйте count у строки, а не у unicodeScalars, если хотите считать «человеческие» символы.
  • Для итерации используйте for char in string — это гарантирует, что вы получите целые кластеры.
  • Для индексации используйте String.Index, а не целые числа. Например: text[text.index(text.startIndex, offsetBy: 5)].
  • Будьте осторожны с флагами (🇷🇺 состоит из 2 скаляров) и эмодзи с тоном кожи (🧑🏻 — из 2 скаляров).

Помните: Swift выбрал честное представление Unicode в ущерб простоте. Это делает язык более надёжным при работе с интернациональными текстами, но требует понимания основ Unicode.

<<>>

Альтернативный подход: использование NSString и Cocoa-методов

Если вам нужно быстрое решение без глубокого погружения в Unicode, можно использовать мосты к NSString и его методы, которые работают на уровне UTF-16. Однако помните, что это не решает проблему полностью — некоторые эмодзи всё ещё могут быть разорваны.

let family = "👩‍👩‍👧‍👦"
let nsString = family as NSString

// Количество "символов" по версии NSString (UTF-16)
print(nsString.length) // 11

// Получение подстроки (может разорвать эмодзи!)
let substring = nsString.substring(with: NSRange(location: 0, length: 5))
print(substring) // "👩‍👩" - разорвано!

// Более безопасный способ через enumerateSubstrings:
var safeChars: [String] = []
nsString.enumerateSubstrings(in: NSRange(location: 0, length: nsString.length),
                             options: .byComposedCharacterSequences) { substring, _, _, _ in
    if let sub = substring { safeChars.append(sub) }
}
print(safeChars) // ["👩‍👩‍👧‍👦"] - один элемент

Этот подход может быть полезен при работе с legacy кодом или библиотеками, которые ожидают NSString. Но для нового кода я рекомендую использовать нативные Swift-методы, описанные в первом ответе — они более безопасны и производительны.