Строки: иммутабельность, StringBuilder, интерполяция, сравнение

↑ 1.3 C♯: основы языка · ← Предыдущая · → Следующая

Обязательно~5 мин чтенияУровень: junior

Зачем это на собесе

строки везде: логи, SQL, JSON, отчёты. Вопросы: почему string иммутабельна, когда StringBuilder, что такое интернирование, как правильно сравнивать строки.

Объяснение

Иммутабельность

string — ссылочный тип, но неизменяемый: любая «модификация» создаёт новую строку.

var s = "hello";
var upper = s.ToUpper();   // новая строка, s не изменилась
s += " world";             // новая строка, старая станет мусором

Плюсы: потокобезопасность, безопасные ключи словарей, возможность интернирования. Минус: конкатенация в цикле создаёт много мусора.

StringBuilder

// плохо: O(n²) копирований, тысячи временных строк
string csv = "";
foreach (var r in rows) csv += $"{r.Id};{r.Name}\n";
 
// хорошо: изменяемый буфер
var sb = new StringBuilder(capacity: rows.Count * 32);
foreach (var r in rows) sb.Append(r.Id).Append(';').AppendLine(r.Name);
var result = sb.ToString();

Правило: для нескольких конкатенаций хватает + или интерполяции (компилятор оптимизирует), в циклах — StringBuilder или string.Join.

Интерполяция

var msg = $"Пациент {patient.Name}, возраст {age}, сумма {total:N2}";
var raw = $"""
    SELECT * FROM "Patients" WHERE "Id" = '{id}'   -- raw string literal, кавычки без экранирования
    """;
var path = @"C:\logs\app.log";                       // verbatim: \ без экранирования

С .NET 6 интерполяция использует DefaultInterpolatedStringHandler — без лишних аллокаций. Для логирования не используйте интерполяцию, используйте шаблоны (_log.LogInformation("Patient {PatientId} loaded", id)) — структурированные логи и нет затрат, если уровень выключен.

Warning

Никогда не подставляйте значения в SQL интерполяцией строк — SQL-инъекция. Только параметры. (Исключение — FromSqlInterpolated в EF Core, который сам превращает интерполяцию в параметры.)

Сравнение строк

string.Equals(a, b, StringComparison.Ordinal)            // побайтово, быстро — для кодов, ключей, путей
string.Equals(a, b, StringComparison.OrdinalIgnoreCase)  // без учёта регистра, для технических значений
string.Equals(a, b, StringComparison.CurrentCulture)     // лингвистически — для отображения и сортировки
a == b                                                    // Ordinal, с учётом регистра
"ё".Equals("е", StringComparison.CurrentCulture)         // зависит от культуры
var dict = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);

ToLower() для сравнения — лишняя аллокация и ловушки культур (турецкая i). Используйте StringComparison.

Интернирование

Литералы одинакового содержания в сборке указывают на один объект (пул строк). string.Intern(s) — поместить в пул вручную (редко нужно). Поэтому (object)"abc" == (object)"abc" обычно true, а для строк, собранных в рантайме, — false.

Полезные API

string.IsNullOrWhiteSpace(s)
s.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
string.Join(", ", names)
s.Contains("abc", StringComparison.OrdinalIgnoreCase)
s.AsSpan().Slice(0, 5)            // подстрока без аллокации
string.Create(len, state, (span, st) => { /* заполнить */ })

Нюансы и подводные камни

  • string.Empty и "" — одно и то же (интернированный литерал). Разница только стилистическая.
  • Строки длиннее ~42 500 символов (85 000 байт) попадают в Large Object Heap.
  • Substring создаёт новую строку. Для парсинга без аллокаций — ReadOnlySpan<char>.
  • Split на больших текстах аллоцирует массив и строки. Для CSV — специализированные парсеры (CsvHelper) или Span.
  • Сравнение пароля или токена через == уязвимо к timing-атакам → CryptographicOperations.FixedTimeEquals.

Тестирование

[Theory]
[InlineData("Иванов", "иванов", true)]
[InlineData("ABC", "abd", false)]
public void Compares_ignoring_case(string a, string b, bool expected) =>
    string.Equals(a, b, StringComparison.OrdinalIgnoreCase).Should().Be(expected);

Вопросы с ответами

Связанные темы