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. String → Data: кодуємо текст у байти
Коли ми хочемо перетворити рядок на байти, наприклад щоб записати його у файл як бінарні дані, надіслати мережею або просто скласти в 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. Data → String: декодуємо байти в текст
Тепер зворотна операція: у нас є байти, і ми хочемо отримати рядок. Наприклад, ми прочитали файл як 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) |
|---|---|---|
|
1 | 1 |
|
1 | 2 |
|
1 | 4 |
Так, емодзі часто «дорожчі» за кількістю байтів. Усмішка в тексті безкоштовна, а в пам’яті — майже як міні‑Int.
6. Міні‑утиліти для CLI: суворе перетворення
Важливо не просто зрозуміти API, а й вбудувати це у звичний стиль нашого навчального застосунку (CLI). До цього ми вже писали текстові файли через String.write(...). Іноді цього достатньо. Але бувають ситуації, коли потрібно працювати з байтами безпосередньо, а текст — лише частина даних.
Уявімо, що наш LibraryCLI пише діагностичний «сирий» файл: кожен рядок журналу — це UTF‑8-байти, що закінчуються символом нового рядка \n. Ми хочемо:
- перетворити рядок повідомлення на Data
- дописати байти \n
- записати або дописати це у файл
Допоміжна функція 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. Типові помилки під час String ↔ Data і 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().
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ