Підрозділ 11.1
Рядки та клас String
Вводить рядки та клас String: створення рядків, конструктори, доступ до символів через індексатор, перебір, порівняння за значенням і перелік основних методів.
11.1. Рядки та клас String
Значна частина завдань у розробці медичних застосунків пов'язана з обробкою текстових даних: парсинг діагнозів за МКХ-10, перевірка формату номера телефону пацієнта, побудова звітів, пошук у клінічних нотатках. У мові C# рядкові значення представляє тип string, а вся функціональність роботи з ним зосереджена в класі System.String. Власне string — це псевдонім (alias) класу String; обидва записи еквівалентні і компілятор не робить між ними різниці. Прийнято використовувати string (з малої літери) в оголошеннях змінних, а String (з великої) — при зверненні до статичних членів: String.IsNullOrEmpty, String.Format.
Об'єкти String зберігають текст як послідовність символів Unicode у кодуванні UTF-16. Це означає, що кожен символ займає 2 або 4 байти — залежно від того, чи потрапляє він у базову багатомовну площину Unicode (BMP) чи виходить за її межі. Більшість символів кирилиці, латиниці, цифр і знаків пунктуації кодуються двома байтами. Максимальний розмір об'єкта String у пам'яті — близько 2 ГБ або приблизно 1 мільярд символів — цей ліміт рідко досягається на практиці, однак він важливий для розуміння масштабованості при обробці великих текстових файлів.
Незмінність рядків (immutability)
Найважливіша властивість string у C# — незмінність (immutability). Після того як об'єкт рядка створено і поміщено у heap, його вміст не може бути змінений жодним засобом. Це не обмеження платформи — це свідоме архітектурне рішення: незмінність дозволяє безпечно передавати рядки між потоками без синхронізації, кешувати їх хеш-коди, використовувати в словниках як ключі.
Наслідок цього рішення: кожен метод класу String, який «змінює» рядок — ToUpper, Replace, Trim, конкатенація через + — насправді не змінює оригінал, а створює новий об'єкт у heap і повертає посилання на нього. Оригінальна змінна продовжує вказувати на незмінений об'єкт:
string diagnosis = "Гіпертензія артеріальна";
string step1 = diagnosis.ToLower(); // новий об'єкт у heap
string step2 = step1 + " есенціальна"; // ще один новий об'єкт
string step3 = step2.ToUpper(); // ще один
Console.WriteLine(diagnosis); // "Гіпертензія артеріальна" — не змінився!На діаграмі нижче видно: змінні на стеку зберігають посилання на різні об'єкти в heap. Оригінал залишається незачепленим — і це є фундаментальною особливістю, яку необхідно розуміти перед тим як працювати з будь-якими рядковими операціями.

Практичний наслідок: якщо у циклі виконується багато операцій конкатенації, кожна ітерація виділяє нову пам'ять і копіює весь попередній вміст. При N ітераціях загальна кількість скопійованих символів складає 1+2+3+...+N = N(N+1)/2, тобто складність — O(N²). Для таких сценаріїв існує StringBuilder (розд. 11.4), який працює з мутабельним буфером і не копіює вміст при кожному додаванні.
String interning
Компілятор C# застосовує оптимізацію, яку називають string interning (інтернування рядків): однакові рядкові літерали, які зустрічаються в коді, компілюються в один спільний об'єкт у спеціальному пулі рядків (string intern pool). Цей пул зберігається у керованій купі (managed heap) і не підлягає збиранню сміттям протягом усього часу роботи програми.
Завдяки інтернуванню оператор == для рядків порівнює значення (вміст), а не посилання — і дає очікуваний результат навіть тоді, коли технічно обидва рядки є різними об'єктами:
string a = "I10";
string b = "I10";
Console.WriteLine(a == b); // true — порівняння значень
Console.WriteLine(ReferenceEquals(a, b)); // true — один об'єкт у пулі (literal interning)
string c = new string(new char[] { 'I', '1', '0' });
Console.WriteLine(a == c); // true — значення однакові
Console.WriteLine(ReferenceEquals(a, c)); // false — c створено через new, поза пуломРядки, створені динамічно (через конкатенацію, new string(...), читання з файлу тощо), за замовчуванням не потрапляють у пул. Метод string.Intern(s) дозволяє вручну помістити рядок у пул, щоб наступні порівняння через ReferenceEquals давали true. Але на практиці для перевірки рівності рядків завжди достатньо == — він коректно порівнює значення незалежно від того, де зберігається об'єкт.
Важливо також розуміти, що перевантажений оператор == для рядків — це не той самий оператор, що для об'єктів. Для більшості класів == перевіряє посилання; для string він перевизначений і порівнює саме вміст. Тому "hello" == new string(new char[]{'h','e','l','l','o'}) дає true, хоча технічно це два різних об'єкти.
Тип char
Кожен елемент рядка — це значення типу char. Тип char — це значущий тип (struct) розміром 2 байти, що представляє один Unicode-символ у кодуванні UTF-16. На відміну від string, char не є посилальним типом і не розміщується в heap окремим об'єктом — він зберігається безпосередньо у полі або на стеку.
char letter = 'А'; // одинарні лапки — обов'язково
string word = "Артеріальна"; // подвійні лапкиВідмінність між char і string принципова: 'A' — це число 65 (код символу), а "A" — це об'єкт у heap з одним символом. Конвертування між ними виконується явно: char c = "A"[0] або string s = c.ToString().
Клас char містить статичні методи для класифікації символів — вони широко використовуються при покрокому аналізі рядків, наприклад, для перевірки форматів ідентифікаторів:
| Метод | Що перевіряє |
|---|---|
char.IsLetter(c) |
Чи є символ літерою (будь-якого алфавіту) |
char.IsDigit(c) |
Чи є символ десятковою цифрою |
char.IsLetterOrDigit(c) |
Літера або цифра |
char.IsWhiteSpace(c) |
Пробіл, табуляція, перенос рядка |
char.IsUpper(c) |
Верхній регістр |
char.IsLower(c) |
Нижній регістр |
char.IsPunctuation(c) |
Знак пунктуації |
char.ToUpper(c) |
Перетворити на верхній регістр |
char.ToLower(c) |
Перетворити на нижній регістр |
Створення рядків
Рядок можна створити кількома способами. Найчастіше використовується рядковий літерал, проте конструктори класу String відкривають додаткові можливості:
// Рядковий літерал — найпоширеніший спосіб
string diagnosis = "Гіпертензія артеріальна";
// Конструктор: повторити символ N разів — корисно для роздільників
string separator = new string('-', 40); // "----------------------------------------"
// Конструктор: із масиву символів — корисно при побудові рядка посимвольно
string code = new string(new char[] { 'I', '1', '0' }); // "I10"
// Конструктор: частина масиву (startIndex, count) — витягує підпослідовність
string sub = new string(new char[] { 'I', '1', '0', '.', '9' }, 0, 3); // "I10"
// Порожній рядок — два еквівалентні способи
string empty1 = "";
string empty2 = string.Empty; // string.Empty — рекомендований варіант, не виділяє нових об'єктівВарто окремо зупинитися на string.Empty. Це статичне поле, що завжди посилається на єдиний порожній рядок у пулі. Записи "" і string.Empty еквівалентні з погляду значення, але явне використання string.Empty підкреслює намір коду й є більш виразним стилістично.
Рядок як масив символів
Клас String реалізує інтерфейс IEnumerable<char>, тому рядок можна перебирати як послідовність символів у циклі foreach. Крім того, визначено індексатор для доступу до окремих символів за позицією:
string icd = "I10.9";
char first = icd[0]; // 'I'
int length = icd.Length; // 5
// Перебір через for з індексом
for (int i = 0; i < icd.Length; i++)
Console.Write(icd[i] + " "); // I 1 0 . 9
// Перебір через foreach — без доступу до індексу
foreach (char c in icd)
Console.Write(c + " "); // I 1 0 . 9Ключова відмінність від масиву: індексатор рядка доступний тільки для читання — спроба присвоїти icd[0] = 'X' призведе до помилки компіляції. Це ще раз підкреслює незмінність рядка на рівні мови. Якщо потрібно отримати масив символів для редагування — використовується метод ToCharArray(), який повертає копію символів у вигляді звичайного масиву char[], після роботи з яким можна знову створити рядок через конструктор new string(chars).
Властивість Length повертає кількість об'єктів char у рядку. Для більшості символів це еквівалентно кількості видимих символів, але для деяких рідкісних Unicode-символів (так звані сурогатні пари), які кодуються двома char, значення Length може бути більшим, ніж кількість гліфів.
Перевірка на порожній рядок
Ситуація, коли рядок не містить корисного значення, зустрічається постійно: незаповнені поля форми, відсутні дані з бази, порожні рядки при парсингу. Для таких перевірок клас String надає два статичні методи:
string.IsNullOrEmpty(s) // true якщо s == null або s == ""
string.IsNullOrWhiteSpace(s) // true якщо s == null, "" або " " (тільки пробіли)IsNullOrWhiteSpace є кращим вибором у більшості випадків: він захищає не лише від null і порожнього рядка, а й від рядків, що складаються виключно з пробільних символів (' ', '\t', '\n'). Такі рядки технічно не є порожніми з точки зору IsNullOrEmpty, але з ділової логіки — це відсутність значення. Наприклад, якщо лікар ввів у поле діагнозу кілька пробілів, це та сама ситуація, що й порожнє поле:
void PrintDiagnosis(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
Console.WriteLine("Діагноз не вказано");
return;
}
Console.WriteLine($"Діагноз: {text.Trim()}");
}Зверніть увагу: параметр позначено як string? (nullable string) — це C# 8+ функціональність, яка на рівні компілятора попереджає, що значення може бути null. Методи IsNullOrEmpty і IsNullOrWhiteSpace коректно обробляють null без виключень.
Порівняння рядків
На відміну від більшості класів, оператор == для рядків перевизначений і порівнює значення (вміст), а не посилання. Це дає природну семантику: два рядки з однаковим текстом вважаються рівними.
Однак оператор == за замовчуванням є чутливим до регістру. Для порівняння без урахування регістру використовується метод string.Equals з параметром StringComparison:
string d1 = "Гіпертензія";
string d2 = "гіпертензія";
Console.WriteLine(d1 == d2); // false — різний регістр
Console.WriteLine(string.Equals(d1, d2, StringComparison.OrdinalIgnoreCase)); // trueПерерахування StringComparison надає кілька режимів порівняння. Для медичних ідентифікаторів та кодів найбільш підходить OrdinalIgnoreCase — він порівнює символи за їхніми байтовими (Unicode code point) значеннями без урахування регістру і без прив'язки до локалі системи. Це важливо: режими CurrentCulture і InvariantCulture можуть давати різні результати залежно від регіональних налаштувань ОС, що небажано для алгоритмів обробки стандартизованих кодів.
Перелік основних методів
Клас String містить десятки методів. Нижче — найбільш уживані у повсякденній розробці:
| Метод | Що робить |
|---|---|
Contains(s) |
Чи містить рядок підрядок |
StartsWith(s) / EndsWith(s) |
Чи починається / закінчується підрядком |
IndexOf(s) / LastIndexOf(s) |
Індекс першого / останнього входження |
Replace(old, new) |
Замінити всі входження |
Split(separator) |
Розбити на масив підрядків |
Substring(start, length) |
Витягти підрядок |
Trim() / TrimStart() / TrimEnd() |
Видалити пробіли |
ToUpper() / ToLower() |
Зміна регістру |
Insert(index, value) |
Вставити підрядок |
Remove(start, count) |
Видалити символи |
PadLeft(n) / PadRight(n) |
Доповнити пробілами до ширини n |
ToCharArray() |
Отримати копію символів як char[] |
Equals(s, comparison) |
Порівняти з урахуванням режиму |
Compare(s1, s2) |
Лексикографічне порівняння |
Кожен з цих методів розглядається детально в розділі 11.2.
Рядки у клінічному контексті — runnable приклад
Базові операції зі рядками на прикладі даних медичної картки:
Символьний аналіз рядка — runnable приклад
Перевірка формату коду МКХ-10 через char-методи та IsNullOrWhiteSpace: