JavaRush /Курси /Swift SELF /String ↔ Data через UTF‑8 та помилки під час перетворення...

String ↔ Data через UTF‑8 та помилки під час перетворення

Swift SELF
Рівень 57 , Лекція 1
Відкрита

1. Іноді потрібно перетворювати String у Data і назад

Коли ми пишемо програми, легко потрапити в ілюзію: «рядок — це текст, отже він і зберігається як текст». Але реальність прозаїчніша: на диску, у мережі й у памʼяті майже завжди лежать байти. А «текст» зʼявляється лише тоді, коли ми домовилися, як саме інтерпретувати ці байти.

Уявімо це так: Data — це коробка з деталями LEGO, а String — зібрана модель. Із коробки можна зібрати корабель, замок або навіть щось космічне… але лише якщо у вас є інструкція. У програмуванні таку інструкцію називають кодуванням (encoding).

У нашому курсі ми фіксуємо просте правило: коли говоримо «текст», майже завжди маємо на увазі UTF‑8. Це сучасний стандарт де-факто: і для файлів, і для мережевих API, і для логів, і для вихідного коду. Крім того, у Swift, починаючи з версії 5, модель рядків тісно пов’язана з ефективною роботою з UTF‑8.

Кодування і UTF‑8

Кодування — це домовленість: «які байти означають які символи». І тут важливо не переплутати два різні «підрахунки».

String.count рахує символи (точніше, Character, тобто «графеми, видимі користувачу»). А Data.count рахує байти. Це різні виміри. Один символ може займати 1, 2, 3 або 4 байти… і це часом ламає очікування новачків приблизно так само, як перший рахунок за мобільний зв’язок у роумінгу.

UTF‑8 зручний тим, що для ASCII-символів (латинські літери, цифри, базові знаки) він економний: найчастіше 1 байт на символ. А для «багатших» символів Unicode (кирилиця, японські кани, емодзі) потрібно більше байтів. Це нормально й очікувано; просто треба перестати думати, що символ = байт.

Важливо пам’ятати: навіть рядки, які виглядають однаково, на рівні байтів можуть відрізнятися за розміром і структурою, тому що UTF‑8 — кодування змінної довжини.

2. StringData: кодуємо текст у байти

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

import Foundation

let text = "Hello"
let data = text.data(using: .utf8)

print(data as Any)         // Optional(5 байтів)
print(data?.count as Any)  // Optional(5)

Після першої спроби зазвичай виникає питання: чому data повертає Optional, а не просто Data?

Відповідь проста: це контракт API. Метод data(using:) повертає Optional, тому що теоретично кодування може не вдатися. У реальному житті з .utf8 для звичайного Swift-рядка це майже завжди спрацьовує, але Swift привчає нас: не робіть вигляд, що помилки неможливі.

І це добра звичка: коли починається робота із зовнішніми джерелами — файлами, мережею, чужими даними — «майже завжди» раптово перетворюється на «періодично, але саме в п’ятницю ввечері».

Безпечний стиль: if let замість !

У навчальних задачах хочеться написати text.data(using: .utf8)! і забути. Але це саме той випадок, коли Swift каже: «ви фактично взяли на себе контракт, що nil не буде. Якщо ж він з’явиться, я вас підведу».

Давайте зробимо правильно: акуратно отримаємо Data.

import Foundation

let title = "Майстер і Маргарита"

if let bytes = title.data(using: .utf8) {
    print(bytes.count) // наприклад, 31 (залежить від символів)
    print(Array(bytes.prefix(8)))
} else {
    print("Не вдалося закодувати рядок у UTF-8")
}

Тут важливо зрозуміти: навіть якщо ви очікуєте, що nil не буде, ви все одно пишете гілку else. Це як пристебнути ремінь в автомобілі: ви не плануєте аварію, ви плануєте життя.

Швидке значення за замовчуванням через ?? і чому воно небезпечне

Іноді вам не потрібно розрізняти «не вдалося» і «вдалося, але порожнє», і ви хочете значення за замовчуванням:

import Foundation

let input = "OK"
let bytes = input.data(using: .utf8) ?? Data()

print(bytes.count) // 2

Але тут важливий нюанс: Data() — це порожні байти. Якщо ви використовуєте такий дефолт під час запису файла, ви можете випадково записати порожній результат і навіть не помітити цього. Тому ?? Data() підходить для демо-коду й швидких утиліт, але для зберігання даних частіше краще явно обробити nil і показати помилку.

3. DataString: декодуємо байти в текст

Тепер зворотна операція: у нас є байти, і ми хочемо отримати рядок. Наприклад, ми прочитали файл як Data, а потім хочемо вивести частину вмісту як текст.

Декодування виглядає так:

import Foundation

let bytes = Data([72, 105, 33]) // "Hi!"
let text = String(data: bytes, encoding: .utf8)

print(text as Any) // Optional("Hi!")

І знову Optional. Але тут Optional уже не «про всяк випадок», а тому що ситуація справді може бути поганою: не кожна послідовність байтів є коректним UTF‑8.

Приклад «битих» байтів

Зробімо невеликий приклад, який показує типовий випадок: байти не є валідним UTF‑8, і рядок не вдається відновити.

import Foundation

let invalid = Data([255]) // 0xFF
let text = String(data: invalid, encoding: .utf8)

print(text ?? "<некоректний UTF-8>") // <некоректний UTF-8>

Ключова думка: Data не зобов’язана бути текстом. Data — це просто байти. Якщо ви намагаєтеся інтерпретувати будь-які бінарні дані як UTF‑8, ви отримаєте nil, і це правильна поведінка.

У Swift-екосистемі дуже важлива ідея безпечного декодування: якщо послідовність байтів не відповідає очікуваному кодуванню, коректніше відмовитися від побудови рядка, ніж мовчки «полагодити» байти й отримати сміття.

4. Де саме ламається конвертація

Здається, що «битий UTF‑8» — це щось екзотичне. Але на практиці проблеми виникають не лише через зловмисника, а й через цілком буденні речі: неправильне кодування файла, обрізаний шматок даних, читання не того файла, змішування текстового й бінарного форматів або просто помилку в логіці «взяли перші N байтів».

Обрізали рядок по байтах — зламали UTF‑8

UTF‑8 — кодування змінної довжини. Це означає, що один символ може займати кілька байтів. Якщо ви візьмете Data.prefix(1) або prefix(2) від рядка, у якому перший символ займає 2 байти, ви можете відрізати половину символа, і під час декодування отримаєте nil.

Покажімо на прикладі кирилиці, де часто по 2 байти на літеру в UTF‑8:

import Foundation

let text = "Я" // зазвичай 2 байти в UTF-8
let data = text.data(using: .utf8) ?? Data()

let cut = data.prefix(1) // відрізали половину UTF-8-послідовності
let decoded = String(data: cut, encoding: .utf8)

print(Array(data))           // наприклад, [208, 175]
print(decoded ?? "<пошкоджено>") // <пошкоджено>

Це дуже часта логічна помилка: людина бачить Data, думає «ну це ж майже масив байтів», ріже його як хоче — і потім дивується, чому не вдається отримати текст. Насправді все чесно: ви спробували зібрати символ із половини деталей.

5. String.count проти Data.count

Багато багів у застосунках виникають не через складні алгоритми, а через неправильну метрику. Якщо десь ви обмежуєте довжину введення, рахуючи «символи», а насправді сховище або протокол обмежує «байти», ви отримаєте дивні ефекти на не-ASCII тексті.

Порівняймо на простих прикладах:

import Foundation

let a = "Hello"
let b = "Привіт"

print(a.count)                     // 5
print(a.data(using: .utf8)!.count) // 5

print(b.count)                     // 6
print(b.data(using: .utf8)!.count) // 12 (часто 2 байти на букву)

Зверніть увагу: тут використовується !, але лише тому, що приклад навчальний і ми впевнені, що .utf8 спрацює. У реальному коді для зовнішніх даних так робити не можна.

Для візуального закріплення можна пам’ятати просту таблицю:

Рядок String.count (символи) Байти UTF‑8 (Data.count)
"A"
1 1
"Я"
1 2
"🙂"
1 4

Так, емодзі часто «дорожчі» за кількістю байтів. Усмішка в тексті безкоштовна, а в пам’яті — майже як міні‑Int.

6. Міні‑утиліти для CLI: суворе перетворення

Важливо не просто зрозуміти API, а й вбудувати це у звичний стиль нашого навчального застосунку (CLI). До цього ми вже писали текстові файли через String.write(...). Іноді цього достатньо. Але бувають ситуації, коли потрібно працювати з байтами безпосередньо, а текст — лише частина даних.

Уявімо, що наш LibraryCLI пише діагностичний «сирий» файл: кожен рядок журналу — це UTF‑8-байти, що закінчуються символом нового рядка \n. Ми хочемо:

  1. перетворити рядок повідомлення на Data
  2. дописати байти \n
  3. записати або дописати це у файл

Допоміжна функція encodeUTF8Line(_:)

Щоб не повторювати if let ... else ... щоразу, зручно винести це в невелику допоміжну функцію:

import Foundation

func encodeUTF8Line(_ text: String) -> Data? {
    guard var data = text.data(using: .utf8) else { return nil }
    data.append(10) // символ нового рядка в ASCII/UTF-8
    return data
}

Тут одразу дві ідеї. По-перше, ми повертаємо Data?, тому що конвертація може не вдатися — це контракт. По-друге, додаємо байт нового рядка як число 10.

Використання допоміжної функції

import Foundation

let message = "Додано книгу: Dune"

if let line = encodeUTF8Line(message) {
    print(Array(line.suffix(3))) // наприклад, [110, 101, 10] -> "ne\n"
} else {
    print("Неможливо закодувати лог-повідомлення в UTF-8")
}

Так, це не запис у файл — але на рівні цієї лекції важливіша чиста конвертація та розуміння Optional. Запис Data у файл і читання Data з файла — наступна частина, де ці байти вже реально полетять на диск.

«Мʼяке» декодування для діагностики

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

import Foundation

func decodeUTF8(_ data: Data) -> String {
    return String(data: data, encoding: .utf8) ?? "<не UTF-8-текст>"
}

Використання:

import Foundation

print(decodeUTF8(Data([72, 105, 33]))) // Hi!
print(decodeUTF8(Data([255])))         // <не UTF-8-текст>

Це хороший патерн для діагностичного виводу, але поганий — для бізнес-логіки, де важливо розрізняти справжні помилки. У справжній обробці даних краще повертати Optional або помилку, а не «рядок-заглушку», тому що заглушка може просочитися далі в програму і несподівано стати «справжньою назвою книги».

7. Схема потоку даних і Optional

Корисно звести все в одну картинку, щоб мозок перестав сприймати Optional як «Swift знущається», а почав сприймати його як чесний контракт: «операція може не вдатися».

flowchart LR
    A[Рядок] -->|"кодування: data(using: .utf8)"| B[Data?]
    B -->|запис або читання байтів| C[Data]
    C -->|"декодування: String(data:encoding: .utf8)"| D[Рядок?]

Сенс простий: на межі «текст ↔ байти» ми або обробляємо nil, або явно беремо на себе контракт ! (що майже завжди погана ідея для зовнішніх даних).

8. Типові помилки під час StringData і UTF‑8

Помилка №1: використовувати ! під час декодування String(data:encoding:)!.
Це виглядає як «ну я ж знаю, що там текст», але реальність часто доводить протилежне: файл може виявитися порожнім, бінарним, обрізаним або в іншому кодуванні. У підсумку програма падає в найнесподіванішому місці — під час спроби показати користувачу повідомлення. Набагато надійніше зробити if let/guard let і показати зрозуміле повідомлення про помилку.

Помилка №2: вважати, що Data.count і String.count — це одне й те саме.
Новачки часто вводять обмеження на кшталт «назва книги до 20 символів», а потім несподівано отримують проблеми на кирилиці або емодзі, тому що реальне обмеження сховища або протоколу — у байтах. Якщо ліміт у байтах, перевіряти потрібно data(using: .utf8)?.count, а не text.count.

Помилка №3: різати Data по байтах і очікувати, що це завжди коректний UTF‑8.
Обрізання prefix(N) виглядає безневинно, але UTF‑8 змінної довжини: можна відрізати «половину символа», і декодування поверне nil. Для діагностики це нормально — ви побачите "<пошкоджено>". Але для логіки «читаємо перші N байтів як рядок» це джерело дивних багів.

Помилка №4: намагатися інтерпретувати будь-який файл як текст.
Після теми рядкових файлів хочеться все читати як String, але щойно ви стикаєтеся з бінарними форматами, зображеннями, архівами або просто не тим файлом, декодування перетворюється на nil. Правильна звичка: спочатку вирішуємо, що саме перед нами — текст чи байти. Якщо байти, працюємо з Data, а рядок створюємо лише там, де це справді UTF‑8-текст.

Помилка №5: використовувати ?? Data() як «універсальний рятувальний круг» під час кодування.
Порожній Data() — це не «безпечно», а «ми мовчки втратили дані». Для діагностики такий дефолт іноді годиться, але для запису у файли чи сховище краще явно обробляти nil і не продовжувати операцію. Інакше ви створите порожній або пошкоджений результат, а потім довго шукатимете, хто вкрав ваші дані. Спойлер: це був ваш ?? Data().

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ