1. Вступ
Ми вже розуміємо, що якими б розумними не були компʼютери, самі по собі вони не знають, що таке літера «A» або символ «⌘». Вони розуміють лише нулі та одиниці, а для декодування символів, зрозумілих людині, їм потрібен «перекладач» — кодування.
Історія кодувань — це історія компромісів і еволюції. Спочатку все було просто, далі — складніше, а потім, нарешті, зʼявився більш-менш універсальний стандарт. Пройдімося цією хронологією.
Початок історії
Почнімо з витоків. У попередній лекції ми вже згадували пращура текстових кодувань — ASCII (вимовляється «аскі»). Це розшифровується як American Standard Code for Information Interchange, тобто американський стандартний код для обміну інформацією. Вже з назви зрозуміло, для кого його створювали і чому він «американський».
ASCII розробили у 1960-х роках, і він став першим широко використовуваним стандартом кодування символів. Це набір із 128 символів:
- Латинські літери (прописні та малі): A-Z, a-z
- Цифри: 0-9
- Розділові знаки: .,!?"' тощо.
- Деякі керівні символи: перехід на новий рядок, табуляція тощо.
Кожен із цих 128 символів кодували одним байтом, використовуючи лише 7 біт із 8 доступних (найстарший біт зазвичай лишався вільним або використовувався для перевірки помилок). Це дуже компактно й ефективно для англійської мови.
Приклад:
Символ 'A' у ASCII кодується як байт 0x41 (у двійковому вигляді 01000001)
Символ '!' у ASCII кодується як байт 0x21 (у двійковому вигляді 00100001)
Обмеження:
Головне обмеження ASCII очевидне: він орієнтований на англійську мову. Якщо ви захочете написати щось німецькою («Grüße») або китайською, ASCII не допоможе. У його таблиці просто немає цих символів. Це призвело до появи безлічі так званих кодових сторінок (Code Pages), які намагалися розширити 128 символів ASCII до 256, використовуючи той самий восьмий біт. Наприклад, для кирилиці існували кодові сторінки CP1251 (Windows Cyrillic), KOI8-R та інші. Але проблема в тому, що ці кодові сторінки були несумісні одна з одною: один і той самий байт у різних кодових сторінках міг означати зовсім різні символи! Справжня Вавилонська вежа.
Практичне використання сьогодні:
Чистий формат ASCII нині рідко застосовують для загальних текстових файлів, хіба що для дуже специфічних потреб або в старих системах. Однак його спадщина жива: багато сучасних кодувань, як ми побачимо, зворотно сумісні з ASCII.
Спробуймо записати й прочитати щось у ASCII, а потім додамо кириличні літери, щоб побачити, що вийде.
Створімо новий консольний проєкт у JetBrains Rider і назвімо його, скажімо, FileEncodingExplorer.
using System;
using System.IO;
using System.Text;
string file = "ascii.txt";
string asciiText = "Hello, world!";
string cyrillicText = "Привіт, світ!";
// Запис у ASCII
using var writer = new StreamWriter(file, false, Encoding.ASCII);
writer.WriteLine(asciiText);
writer.WriteLine(cyrillicText);
// Читання з ASCII
using var reader = new StreamReader(file, Encoding.ASCII);
string content = reader.ReadToEnd();
Console.WriteLine("Вміст файлу:");
Console.WriteLine(content);
Console.WriteLine("\nКириличні літери замінилися на '?', тому що ASCII не підтримує кирилицю!");
Коли ви запустите цей код, побачите, що англійська частина тексту читається нормально, а кирилична перетвориться на знаки питання (?) або інші «невідомі» символи. Це тому, що Encoding.ASCII не знає, як перетворити кириличні символи в байти, і просто замінює їх на щось «безпечне» (зазвичай ?), або ж байти, що відповідають кириличним літерам у якомусь іншому кодуванні, інтерпретуються ASCII як інші символи. У нашому випадку StreamWriter примусово замінює символи, яких немає в ASCII, на ? під час запису. Це наочно показує, чому важливо використовувати правильне кодування!
2. UTF-8: король інтернету та гнучкості
Переходимо до одного з найважливіших і, мабуть, найпопулярнішого сьогодні кодування — UTF-8. Це те кодування, на якому працює більша частина інтернету, Linux-системи та більшість сучасних застосунків.
Що це таке?
UTF-8 (Unicode Transformation Format — 8-bit) — це кодування Unicode, яке розв’язує проблему неефективності UTF-16 для англомовного тексту. UTF-8 — кодування змінної довжини із дуже вдалим підходом:
- Символи, які є звичайними ASCII-символами (коди від 0 до 127), кодуються одним байтом. І найкраще: ці байти абсолютно ідентичні їх представленню в ASCII! Це означає, що UTF-8 зворотно сумісний з ASCII.
- Інші символи кодуються від 2 до 4 байтів:
- Кирилиця — зазвичай 2 байти.
- Багато європейських символів із діакритикою, арабська, іврит, грецька — 2 байти.
- Китайські/японські/корейські ієрогліфи — часто 3 байти.
- Рідкісні символи та деякі емодзі — 4 байти.
Приклади байтового подання в UTF-8:
- Символ 'A' (ASCII): 01000001 (1 байт)
- Символ 'я' (кирилична літера): 11010001 10111111 (2 байти)
- Символ '€' (євро): 11100010 10000010 10101100 (3 байти)
- Символ '😂' (емодзі): 11110000 10011111 10011000 10000010 (4 байти)
Чому UTF-8 — король?
- Ефективність: дуже компактний для тексту, що містить багато ASCII-символів (що характерно для англійської, коду програм, конфігураційних файлів).
- Зворотна сумісність з ASCII: якщо ви читаєте UTF-8-файл, що містить тільки ASCII-символи, можете прочитати його як ASCII — і все працюватиме!
- Відсутність BOM (зазвичай): на відміну від UTF-16, UTF-8 зазвичай не використовує BOM. Якщо він і трапляється (наприклад, EF BB BF), то це необовʼязкова особливість, яка інколи створює проблеми (наприклад, під час парсингу деяких форматів або в скриптах Linux).
Мінуси:
- Змінна довжина символів може ускладнювати деякі операції (наприклад, перехід до N-го символу без повного сканування), але в C# це не проблема: string працює з Unicode-символами незалежно від кодування файлу.
Практичне використання:
- веб-сторінки (HTML, CSS, JavaScript),
- API (JSON, XML),
- конфігураційні файли,
- вихідний код більшості мов програмування,
- ОС сімейства Linux/Unix.
Запишімо й прочитаймо файл у UTF-8.
string file = "utf8.txt";
string text = "Hello, світ! 😀 €";
// Запис у UTF-8 (за замовчуванням без BOM)
File.WriteAllText(file, text, Encoding.UTF8);
// Читання з UTF-8
string readText = File.ReadAllText(file, Encoding.UTF8);
Console.WriteLine(readText); // Усе читається правильно!
Коли запустите цей код і порівняєте розміри файлів, побачите, що utf8.txt для змішаного тексту зазвичай менший, ніж файл у UTF-16, а якби текст був лише англійською, його розмір був би співмірний з ASCII.
3. UTF-16: Unicode для всіх, майже
Проблема «Вавилонської вежі» кодових сторінок стала справжнім головним болем для розробників, особливо коли програми стали глобальними. Потрібне було універсальне рішення. І воно зʼявилося — Unicode. Unicode — це не кодування саме по собі, а величезна таблиця, у якій кожному відомому символу присвоєно унікальний числовий код (code point).
Що це таке?
UTF-16 (Unicode Transformation Format — 16-bit) — кодування, яке спочатку припускало, що всі символи Unicode кодуватимуться двома байтами (16 бітами).
- Більшість символів (BMP, до 65535) кодується 2 байтами.
- Для символів поза BMP використовують сурогатні пари — 4 байти. Тобто UTF-16 теж змінної довжини, але його частіше сприймають як 2 байти на символ.
Порядок байтів (Endianness) і BOM:
- Big-Endian (BE): старший байт іде першим.
- Little-Endian (LE): молодший байт іде першим.
- Щоб програма-читач зрозуміла порядок, на початку файлу часто ставлять BOM (Byte Order Mark):
- для UTF-16 LE: FF FE
- для UTF-16 BE: FE FF
Переваги:
- Підтримує переважну більшість символів світу.
- Просто працювати з символами в межах BMP (фіксована довжина 2 байти).
Недоліки:
- Неефективний для англомовного тексту: кожен ASCII-символ займає 2 байти.
- Наявність BOM може викликати проблеми, якщо читач його не очікує.
Практичне використання:
UTF-16 широко використовують усередині Windows і, наприклад, у Java — для внутрішнього подання рядків. Текстові файли Блокнота Windows з кирилицею часто зберігаються як UTF-16 LE з BOM.
string file = "utf16.txt";
string text = "Hello, світ! 👋";
// Запис у UTF-16 (за замовчуванням Little-Endian, з BOM)
File.WriteAllText(file, text, Encoding.Unicode);
// Читання з UTF-16
string readText = File.ReadAllText(file, Encoding.Unicode);
Console.WriteLine(readText); // Усе читається правильно!
Console.WriteLine($"Розмір файлу: {new FileInfo(file).Length} байт");
Після запуску ви побачите, що всі символи відображаються правильно. Файли з англійським текстом у UTF-16 займають приблизно вдвічі більше місця, ніж у ASCII або UTF-8 (для ASCII-діапазону).
4. Зведена таблиця порівняння кодувань
Щоб упорядкувати наші знання, зведімо ключові характеристики в одну таблицю.
| Кодування | Мінімальна кількість байтів на символ | Максимальна кількість байтів на символ | Сумісність з ASCII (пряма) | Використовує BOM (за замовчуванням у .NET) | Приклади застосування |
|---|---|---|---|---|---|
| ASCII | 1 | 1 | Повна | Ні | Старі системи, дуже прості текстові дані, внутрішні протоколи |
| UTF-16 | 2 | 4 | Ні | Так (Encoding.Unicode) | Внутрішнє подання рядків у Windows, Java; текстові файли Windows |
| UTF-8 | 1 | 4 | Повна | Ні (Encoding.UTF8 у .NET 5+); Так (Encoding.UTF8 у .NET Framework) | Веб (HTML, JSON), конфігураційні файли, вихідний код, Linux/Unix |
Невеличка примітка щодо Encoding.UTF8 у .NET:
Історично в .NET Framework Encoding.UTF8 за замовчуванням додавав BOM. У сучасному .NET (Core/5+) поведінка змінилася: за замовчуванням BOM не додають. Якщо він потрібен, використовуйте new UTF8Encoding(true).
5. Як вказати кодування в C#
Як ви вже могли помітити в прикладах, щоб сказати StreamReader або StreamWriter, який «словник» використовувати, ми передаємо їм обʼєкт класу System.Text.Encoding.
System.Text.Encoding надає готові варіанти:
- Encoding.ASCII: для роботи з ASCII.
- Encoding.Unicode: UTF-16 LE (з BOM).
- Encoding.UTF8: UTF-8 (без BOM за замовчуванням у сучасному .NET).
Інші кодування доступні через Encoding.GetEncoding (наприклад, "windows-1251", "koi8-r"), але зосередьмося зараз на Unicode.
// Запис у UTF-8
using var writer = new StreamWriter("my_file.txt", false, Encoding.UTF8);
writer.WriteLine("Якийсь текст.");
// Читання з UTF-16
using var reader = new StreamReader("another_file.txt", Encoding.Unicode);
string content = reader.ReadToEnd();
Console.WriteLine(content);
Ось і весь секрет! StreamReader і StreamWriter беруть на себе всю роботу з перетворення символів у байти й назад, використовуючи правила обраного кодування.
6. Проблеми з кодуваннями: «Кракозябри»
Ми вже спостерігали «кракозябри», коли намагалися записати український текст у ASCII. Але що, як ви записали файл в одному кодуванні, а читаєте в іншому? Ось де починаються справжні веселощі!
Уявіть, що лист написано українською і збережено у UTF-8, а ваш клієнт вирішує прочитати його як CP1251. Послідовності байтів буде інтерпретовано некоректно, і замість «Привіт, світ!» ви отримаєте «кракозябри» (англ. mojibake).
Причина одна: невідповідність кодувань під час запису й читання. Завжди використовуйте одне й те саме кодування на обох етапах, якщо тільки ви не займаєтеся свідомим перекодуванням.
string file = "mismatch.txt";
string ukrainianText = "Привіт, світ!";
// Запис у UTF-8 (правильно!)
File.WriteAllText(file, ukrainianText, Encoding.UTF8);
// Неправильне читання: намагаємося читати UTF-8 файл як ASCII
string readAsAscii = File.ReadAllText(file, Encoding.ASCII);
Console.WriteLine($"Оригінал: {ukrainianText}");
Console.WriteLine($"Читання як ASCII: {readAsAscii}"); // Тут і будуть "кракозябри"!
Запустіть програму — і побачите, як кирилиця перетворюється на знаки питання або інші беззмістовні символи. У наступному уроці поговоримо про те, як працювати з кодуваннями гнучкіше й уникати подібних проблем, а також як зрозуміти, яке кодування має файл, який ви читаєте.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ