Собеседование C#: строки, интернирование, StringBuilder и Span
Иммутабельность, интернирование, конкатенация в цикле, StringBuilder, Span<char> и потокобезопасность — всё, на чём плавают на собесах.
Содержание
Строки выглядят простыми ровно до того момента, пока интервьюер не начинает задавать уточняющие вопросы. Почему конкатенация в цикле убивает производительность? Что именно хранит интернированная строка? Почему Span<char> быстрее, но опасен в многопоточке? Разбираем.
string: иммутабельность и её последствия
Что такое string в C#
string — reference type, живёт в куче, управляется GC. Но ведёт себя местами как value type — потому что иммутабельна: любая операция, которая «изменяет» строку, создаёт новый объект.
string s = "hello";
s.Replace('h', 'H'); // не изменяет s
Console.WriteLine(s); // "hello"
s = s.Replace('h', 'H'); // создаёт новый объект, s теперь указывает на него
Console.WriteLine(s); // "Hello"
Вопрос: сколько объектов в куче?
string a = "hello";
string b = a;
b = b.ToUpper();
Console.WriteLine(a); // ?
Console.WriteLine(b); // ?
Ловушка: string — reference type, кандидат думает a тоже изменится.
Правильный ответ: a — "hello", b — "HELLO". В куче два объекта. ToUpper() создаёт новую строку, b начинает указывать на неё. a не затронута — иммутабельность гарантирует это.
Вопрос: == vs ReferenceEquals для строк
string a = "hello";
string b = "hello";
string c = new string(new char[] { 'h','e','l','l','o' });
string d = string.Copy(a); // устарело в .NET 5+, но ещё встречается
Console.WriteLine(a == b); // ?
Console.WriteLine(a == c); // ?
Console.WriteLine(object.ReferenceEquals(a, b)); // ?
Console.WriteLine(object.ReferenceEquals(a, c)); // ?
Ловушка: кандидат путает равенство значений и ссылок.
Правильный ответ:
a == b— true:==для строк переопределён, сравнивает содержимое.a == c— true: содержимое одинаковое.ReferenceEquals(a, b)— true: оба литерала интернированы, один объект в пуле.ReferenceEquals(a, c)— false:new string(...)создаёт новый объект в куче, минуя пул.
Вопрос: потокобезопасность string
Можно ли читать одну строку из нескольких потоков без синхронизации?
Правильный ответ: да, абсолютно безопасно. Иммутабельность — естественная защита от гонок. Строку нельзя изменить, поэтому читать её одновременно из любого количества потоков — безопасно без lock и volatile. Это одно из главных преимуществ иммутабельных типов в concurrent-коде.
Интернирование строк
Как работает пул интернирования
CLR поддерживает таблицу интернированных строк — по одному экземпляру для каждого уникального значения. Строковые литералы в коде интернируются автоматически на этапе JIT-компиляции.
string a = "hello"; // из пула
string b = "hello"; // та же запись в пуле
object.ReferenceEquals(a, b); // true
Динамически созданные строки — не интернируются автоматически:
string c = new string('a', 5); // "aaaaa" — в куче, не в пуле
string d = string.Intern(c); // теперь в пуле
string e = "aaaaa"; // из пула
object.ReferenceEquals(d, e); // true
object.ReferenceEquals(c, e); // false — c всё ещё старый объект
Вопрос: когда интернирование вредит
Ловушка: кандидат думает, что string.Intern всегда экономит память.
Правильный ответ: пул интернирования — часть кучи, которая не собирается GC. Если интернировать динамически создаваемые строки (например, user input), они будут жить до конца процесса. В веб-приложении это утечка памяти:
// опасно: каждый уникальный userId живёт вечно
string key = string.Intern($"user:{userId}");
Интернирование полезно для строк-констант, которые сравниваются очень часто. Для всего остального — обычное ==.
Вопрос: IsInterned
string a = "test";
string b = new string(new char[]{'t','e','s','t'});
Console.WriteLine(string.IsInterned(a) != null); // ?
Console.WriteLine(string.IsInterned(b) != null); // ?
Правильный ответ: первый — true ("test" как литерал интернирован). Второй — true, потому что "test" уже есть в пуле (добавлен литералом), и IsInterned находит его там. Но если бы литерала не было — false. IsInterned проверяет, есть ли строка в пуле, но не добавляет её туда.
Вопрос: ConditionalWeakTable vs интернирование
Для чего нужна ConditionalWeakTable применительно к строкам?
Правильный ответ: когда нужно ассоциировать данные со строкой без удержания её в памяти. string.Intern держит строку вечно. ConditionalWeakTable использует weak references — строка соберётся GC, когда на неё нет других ссылок. Полезно для кэширования метаданных строк без утечек.
Конкатенация и аллокации
Вопрос: что не так с этим кодом?
string result = "";
for (int i = 0; i < 10_000; i++) {
result += i.ToString();
}
Ловушка: кандидат говорит «работает медленно» — но не может объяснить почему.
Правильный ответ: каждая итерация создаёт новый объект string в куче: копирует предыдущую строку + добавляет новый фрагмент. На 10 000 итерациях — 10 000 объектов, суммарно O(n²) копирований символов. GC собирает мусор волнами. Для 10k итераций по одному символу — ~50 МБ лишних аллокаций.
Вопрос: когда + нормально
string s = "Hello, " + name + "!";
Правильный ответ: нормально. Компилятор оптимизирует конкатенацию из 2–4 литералов/переменных в один вызов string.Concat, который аллоцирует ровно один результирующий объект. Проблема — только в цикле или при большом числе операндов.
Вопрос: string.Concat vs string.Join vs интерполяция
string a = string.Concat("a", "b", "c");
string b = string.Join(", ", new[]{"x","y","z"});
string c = $"Hello, {name}!";
string d = "Hello, " + name + "!";
Правильный ответ:
string.Concat— один вызов, одна аллокация. Эффективно.string.Join— одна аллокация результата + перебор коллекции. Эффективно для массивов.- Интерполяция
$""— компилятор преобразует вstring.Format(старые версии) илиDefaultInterpolatedStringHandler(C# 10+). В C# 10+ — без лишних аллокаций при простых случаях. +из нескольких частей — компилятор разворачивает вstring.Concat. Для 2–4 частей — нормально.
StringBuilder
Как работает StringBuilder
StringBuilder хранит внутренний char[] буфер с запасом. Операции добавления работают в этот буфер без создания промежуточных строк. Когда буфер заполняется — увеличивается вдвое (как List<T>). ToString() в конце создаёт ровно одну строку.
var sb = new StringBuilder(capacity: 256); // задать начальный размер — хорошая практика
for (int i = 0; i < 10_000; i++) {
sb.Append(i);
sb.Append(", ");
}
string result = sb.ToString();
Вопрос: StringBuilder потокобезопасен?
Ловушка: кандидат думает — раз иммутабельные строки безопасны, StringBuilder тоже.
Правильный ответ: нет. StringBuilder — мутабельный объект с внутренним состоянием. Параллельные вызовы Append из разных потоков приведут к гонке: порча внутреннего буфера, IndexOutOfRangeException или corrupt данные. Для concurrent-записи нужен lock или отдельный StringBuilder на каждый поток с финальным string.Concat.
Вопрос: когда StringBuilder хуже +
// вариант A
string s = "Hello, " + name + "!";
// вариант B
var sb = new StringBuilder();
sb.Append("Hello, ");
sb.Append(name);
sb.Append("!");
string s = sb.ToString();
Ловушка: кандидат говорит «B всегда лучше, StringBuilder эффективнее».
Правильный ответ: вариант A лучше. StringBuilder — объект в куче, его создание и ToString() стоят аллокаций. Для малого числа конкатенаций компилятор оптимизирует + в один string.Concat — одна аллокация против двух (StringBuilder + результат). StringBuilder оправдан от ~10+ конкатенаций или в цикле.
Вопрос: цепочка вызовов StringBuilder
var sb = new StringBuilder()
.Append("Hello")
.Append(", ")
.Append("World")
.AppendLine("!");
Почему это работает?
Правильный ответ: каждый метод Append возвращает this — ссылку на тот же StringBuilder. Это паттерн Fluent Interface / Method Chaining. Никаких дополнительных объектов не создаётся.
Вопрос: StringBuilder и интерполяция
var sb = new StringBuilder();
sb.Append($"Hello, {name}! You have {count} messages.");
Есть ли проблема?
Правильный ответ: да. $"..." создаёт промежуточную строку до передачи в Append. Смысл StringBuilder теряется. Правильно:
sb.Append("Hello, ")
.Append(name)
.Append("! You have ")
.Append(count)
.Append(" messages.");
Или в C# 10+ использовать AppendInterpolatedStringHandler — он встроен в перегрузку Append и устраняет промежуточную строку:
sb.Append($"Hello, {name}! You have {count} messages."); // C# 10+ без промежуточной строки
Span<char> и работа со строками без аллокаций
Что такое Span<T> применительно к строкам
Span<char> — окно в существующую память: строку, массив или стек. Не копирует данные, не аллоцирует. ReadOnlySpan<char> — то же, но только для чтения.
string s = "Hello, World!";
ReadOnlySpan<char> span = s.AsSpan();
ReadOnlySpan<char> slice = span.Slice(7, 5); // "World" — без новой строки
Вопрос: почему AsSpan быстрее Substring
string s = "2024-01-15";
// старый способ — создаёт новую строку
string year = s.Substring(0, 4);
int y = int.Parse(year);
// новый способ — без аллокации
int y2 = int.Parse(s.AsSpan(0, 4));
Правильный ответ: Substring создаёт новый объект string в куче — аллокация + копирование 4 символов. AsSpan(0, 4) создаёт ReadOnlySpan<char> — структуру из двух полей (указатель + длина) на стеке, данные не копируются. int.Parse имеет перегрузку для ReadOnlySpan<char>. Итого: ноль аллокаций в куче.
Вопрос: stackalloc и Span
Span<char> buffer = stackalloc char[128];
int written = 0;
foreach (char c in "Hello, World!") {
buffer[written++] = char.ToUpper(c);
}
string result = new string(buffer.Slice(0, written));
Что здесь происходит?
Правильный ответ: stackalloc char[128] аллоцирует 128 символов прямо на стеке — нет обращения к куче, нет GC. Трансформация происходит in-place в этом буфере. Единственная аллокация в куче — финальный new string(...). Паттерн используется в парсерах, форматировщиках, сетевом коде — везде, где важна минимальная нагрузка на GC.
Вопрос: Span<char> и многопоточность
Span<char> span = stackalloc char[10];
span[0] = 'H';
Task.Run(() => {
span[0] = 'X'; // ?
});
Ловушка: кандидат думает — Span на стеке, значит изолирован.
Правильный ответ: ошибка компиляции. Span<T> — ref struct, её нельзя захватить в лямбду (замыкание создаёт heap-объект, ref struct туда не попадает). Компилятор это запрещает.
Но если Span указывает на массив в куче:
char[] array = new char[10];
Span<char> span = array.AsSpan();
// Thread 1:
span[0] = 'H';
// Thread 2:
span[0] = 'X';
Здесь нет защиты. Span — просто окно в память. Два Span, указывающих на один массив из разных потоков, будут конкурировать за одни и те же байты. Гонка данных, результат непредсказуем. Span не потокобезопасен, если несколько Span указывают на перекрывающуюся память.
Вопрос: ReadOnlySpan и потокобезопасность
string s = "hello";
ReadOnlySpan<char> span = s.AsSpan();
// читаем из нескольких потоков:
Parallel.For(0, 1000, i => {
char c = span[i % span.Length];
});
Правильный ответ: безопасно. ReadOnlySpan<char> — только чтение. Строка иммутабельна. Несколько потоков могут одновременно читать через ReadOnlySpan без синхронизации — данные не изменяются. Но передать ReadOnlySpan между потоками через поле класса нельзя — ref struct запрещает хранение в классе. Передача происходит только через параметры методов в пределах одного потока.
Вопрос: Memory<T> vs Span<T>
async Task ProcessAsync(Memory<char> memory) {
await Task.Delay(100);
memory.Span[0] = 'X'; // ok?
}
async Task ProcessAsync(Span<char> span) { // скомпилируется?
await Task.Delay(100);
}
Правильный ответ: Span<char> в async — ошибка компиляции (ref struct нельзя в async state machine). Memory<char> — обычная struct, не ref struct, можно в async. memory.Span создаёт Span локально при обращении — это ок. Правило: Span для синхронного кода, Memory для async.
Вопрос: пересекающиеся Span и запись
char[] array = { 'a', 'b', 'c', 'd', 'e' };
Span<char> s1 = array.AsSpan(0, 3); // a, b, c
Span<char> s2 = array.AsSpan(1, 3); // b, c, d
s1[1] = 'Z';
Console.WriteLine(s2[0]); // ?
Ловушка: кандидат думает, что Span изолированы.
Правильный ответ: 'Z'. s1[1] и s2[0] — это один и тот же байт в массиве (индекс 1). Span не копирует данные — это просто разные окна в одну и ту же память. Изменение через один Span немедленно видно через другой. В многопоточном коде это гонка данных.
Дополнительные вопросы
Вопрос: unsafe модификация строки
string s = "hello";
unsafe {
fixed (char* p = s) {
p[0] = 'H';
}
}
Console.WriteLine(s); // ?
Правильный ответ: "Hello" — строка изменилась. unsafe + fixed позволяет получить указатель на внутренний буфер строки и записать туда. Иммутабельность — контракт уровня языка, не железа. Нарушать его через unsafe — можно, но это неопределённое поведение: если строка интернирована, вы изменили литерал в пуле, и все другие ссылки на "hello" в программе теперь видят "Hello". Классический способ сломать всё.
Вопрос: string и GetHashCode между запусками
// запуск 1:
Console.WriteLine("hello".GetHashCode()); // например, 1234567
// запуск 2:
Console.WriteLine("hello".GetHashCode()); // ?
Ловушка: кандидат думает — хэш строки детерминирован.
Правильный ответ: в .NET Core и .NET 5+ хэш строк рандомизирован между запусками (String Hash Randomization, включена по умолчанию). Одна и та же строка даёт разный хэш в разных запусках процесса. Это защита от hash collision атак (HashDoS). Следствие: нельзя сохранять GetHashCode строки в БД или файл и рассчитывать на воспроизводимость. Для детерминированного хэша — SHA256 или кастомная реализация.
Вопрос: string.Format и культура
double price = 1234.56;
string s1 = $"{price}";
string s2 = price.ToString();
string s3 = string.Format("{0}", price);
Одинаковы ли результаты на машине с Russian locale?
Правильный ответ: нет. Все три используют CultureInfo.CurrentCulture. В русской локали десятичный разделитель — запятая: "1234,56". На английской — точка: "1234.56". Если строка пойдёт в API, JSON или БД — это баг. Правильно:
string s = price.ToString(CultureInfo.InvariantCulture); // всегда точка
string s = $"{price:0.00}"; // тоже зависит от культуры!
string s = price.ToString("0.00", CultureInfo.InvariantCulture); // правильно
Вопрос: char и string
char c = 'A';
string s = c + c + c; // ?
string s2 = "" + c + c + c; // ?
Ловушка: кандидат думает оба варианта дадут "AAA".
Правильный ответ: s — “ÃÃÔ или числовой мусор — нет. Точнее: char + char в C# — это сложение int (char неявно конвертируется в int). 'A' + 'A' + 'A' = 65 + 65 + 65 = 195. Присваивание string s = 195 — ошибка компиляции. На самом деле код не скомпилируется: нет неявного преобразования int → string. s2 — "AAA": первый операнд "" — string, дальше + работает как конкатенация строк.
Вопрос: string.Empty vs ""
string a = "";
string b = string.Empty;
object.ReferenceEquals(a, b); // ?
Правильный ответ: true. string.Empty — это статическое поле, которое хранит "". Литерал "" интернируется и указывает на тот же объект. Разницы в производительности нет — это вопрос читаемости и стиля.
Вопрос: CompareOrdinal vs Compare
string a = "café";
string b = "cafe\u0301"; // e + combining accent
Console.WriteLine(a == b); // ?
Console.WriteLine(string.Compare(a, b, StringComparison.Ordinal)); // ?
Console.WriteLine(string.Compare(a, b, StringComparison.CurrentCulture)); // ?
Правильный ответ: a == b — false (== для string — ordinal по умолчанию, разные байты). StringComparison.Ordinal — не ноль (разные кодовые точки). StringComparison.CurrentCulture — 0 на большинстве культур: оба представления визуально одинаковы, культурное сравнение учитывает Unicode normalization. Для пользовательского интерфейса — CurrentCulture. Для ключей словарей, API, хранения — Ordinal.
Шпаргалка
| Тема | Типичная ошибка | Правда |
|---|---|---|
| string потокобезопасность | Нужен lock | Иммутабельна — безопасна для чтения из любого потока |
| string.Intern | Экономит память | Строки в пуле живут вечно — риск утечки |
| GetHashCode строки | Детерминирован | Рандомизирован между запусками в .NET Core+ |
+ vs StringBuilder | StringBuilder всегда лучше | + для 2–4 частей эффективнее — один Concat |
$"" в StringBuilder.Append | Без аллокаций | Создаёт промежуточную строку (кроме C# 10+) |
| Span и многопоточность | Изолирован | Span на общий массив — гонка данных |
| Span в async | Можно | ref struct — ошибка компиляции |
| Пересекающиеся Span | Независимы | Одна память — изменения видны через оба Span |
char + char | Конкатенация | Сложение int — 'A' + 'A' = 130 |
| Культура в ToString | Инвариантна | Зависит от CultureInfo.CurrentCulture |
Следующий шаг: C# на собеседовании: память и GC — поколения, LOH, finalizer queue и почему GC.Collect() почти всегда плохая идея.