· 22 мин 👁 1.7k Продвинутый

Собеседование C#: строки, интернирование, StringBuilder и Span

Иммутабельность, интернирование, конкатенация в цикле, StringBuilder, Span<char> и потокобезопасность — всё, на чём плавают на собесах.

csharpstringstringbuilderspanинтернированиесобеседование
Содержание

Строки выглядят простыми ровно до того момента, пока интервьюер не начинает задавать уточняющие вопросы. Почему конкатенация в цикле убивает производительность? Что именно хранит интернированная строка? Почему 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 == btrue: == для строк переопределён, сравнивает содержимое.
  • a == ctrue: содержимое одинаковое.
  • 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 == bfalse (== для string — ordinal по умолчанию, разные байты). StringComparison.Ordinalне ноль (разные кодовые точки). StringComparison.CurrentCulture0 на большинстве культур: оба представления визуально одинаковы, культурное сравнение учитывает Unicode normalization. Для пользовательского интерфейса — CurrentCulture. Для ключей словарей, API, хранения — Ordinal.


Шпаргалка

ТемаТипичная ошибкаПравда
string потокобезопасностьНужен lockИммутабельна — безопасна для чтения из любого потока
string.InternЭкономит памятьСтроки в пуле живут вечно — риск утечки
GetHashCode строкиДетерминированРандомизирован между запусками в .NET Core+
+ vs StringBuilderStringBuilder всегда лучше+ для 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() почти всегда плохая идея.