Введение: Почему склейка строк — это не просто "сложить"
Представьте: вы пишете веб-сервис на C#, который принимает список тегов от пользователя и должен отправить их в базу данных одной строкой через запятую. Или вы формируете CSV-отчёт для экспорта. Или логируете набор идентификаторов. Знакомая ситуация? Вопрос "Creating a comma separated list from IList
Казалось бы, что может быть проще? Взяли список строк, прошлись циклом, добавили запятые. Но на практике эта задача таит в себе несколько подводных камней: от лишних запятых в конце до катастрофического падения производительности на больших данных. В этой статье мы не просто покажем готовые решения, а заглянем под капот каждого из них. Вы узнаете, как работают string.Join, StringBuilder и даже LINQ Aggregate, и научитесь выбирать правильный инструмент для своей задачи.
Поехали!
Проблема: Простой запрос, неочевидные сложности
Допустим, у нас есть коллекция строк:
IEnumerable<string> fruits = new List<string> { "Apple", "Banana", "Cherry", "Date" };
Мы хотим получить: "Apple,Banana,Cherry,Date". Первое, что приходит в голову новичку — написать цикл:
string result = "";
bool first = true;
foreach (var fruit in fruits)
{
if (!first)
result += ",";
result += fruit;
first = false;
}
Этот код работает, но делает это ужасно. Почему? Потому что строки в C# — иммутабельны. Каждая операция += создаёт новую строку в памяти, копируя туда содержимое предыдущей. На 4 элементах это незаметно, но на 10 000 строках вы получите квадратичную сложность O(n²) и гигантское потребление памяти. Под капотом — тысячи аллокаций и сборок мусора.
Очевидно, нужен более умный подход. И тут на сцену выходят главные герои.
Метод 1: string.Join — король производительности
Самый правильный и производительный способ — использовать статический метод string.Join:
string csv = string.Join(",", fruits);
Console.WriteLine(csv); // Apple,Banana,Cherry,Date
Одна строка кода — и дело сделано. Давайте заглянем под капот string.Join (в .NET Core / .NET 5+).
Что происходит внутри?
- Подсчёт длины: Метод сначала проходит по всей коллекции (если это
IEnumerable<T>), чтобы узнать общее количество символов. Если коллекция — массив илиIList, он сразу получает длину черезCount. - Выделение буфера: Выделяется один большой массив символов (
char[]) нужного размера: сумма длин всех строк + (количество строк - 1) * длина разделителя. - Копирование: Все строки и разделители копируются в этот буфер без единой лишней аллокации.
- Создание строки: Из буфера создаётся одна финальная строка.
Итог: одна аллокация памяти (на итоговую строку) + аллокация временного буфера. Никаких промежуточных строк. Сложность — строго O(n).
Для IList<string> метод работает ещё быстрее, так как может сразу получить Count и длину каждого элемента через индексатор, не вызывая GetEnumerator.
Метод 2: StringBuilder — старый друг
До появления оптимизированного string.Join (или если вы застряли на старой версии .NET Framework) стандартным решением был StringBuilder:
var sb = new StringBuilder();
bool first = true;
foreach (var fruit in fruits)
{
if (!first)
sb.Append(',');
sb.Append(fruit);
first = false;
}
string csv = sb.ToString();
Альтернативный, более элегантный вариант с AppendJoin (доступен с .NET Core 2.1+):
var sb = new StringBuilder();
sb.AppendJoin(',', fruits);
string csv = sb.ToString();
Под капотом StringBuilder
StringBuilder хранит внутренний буфер (char[]). Когда вы вызываете Append, он копирует данные в этот буфер. Если буфер переполняется, он удваивает свой размер и копирует все старые данные в новый массив. Это лучше, чем конкатенация строк (аллокаций всё ещё мало — логарифмическое количество), но хуже, чем string.Join, который заранее знает точный размер.
Когда использовать? Если вам нужно собрать строку в несколько этапов (например, в цикле с условиями), StringBuilder — ваш выбор. Для одноразовой склейки списка — предпочтите string.Join.
Метод 3: LINQ Aggregate — элегантность в ущерб производительности
Любители функционального стиля часто предлагают такой код:
string csv = fruits.Aggregate((current, next) => current + "," + next);
Выглядит красиво и лаконично. Но никогда так не делайте на реальных данных.
Под капотом Aggregate
Этот метод внутри работает как цикл с конкатенацией строк. Каждая итерация создаёт новую строку. На 1000 элементах вы получите 999 промежуточных строк, которые тут же отправляются в сборщик мусора. Это самый медленный и прожорливый способ.
Исключение: если у вас фиксированное и очень маленькое количество элементов (2–5), разница будет незаметна. Но привычка — вторая натура, поэтому лучше сразу приучить себя к string.Join.
Метод 4: Кастомный ToCsv с Span<T> (для хардкорщиков)
Если вы пишете высоконагруженный сервис и хотите выжать максимум производительности, можно написать свой метод с использованием Span<T> и stackalloc:
public static string ToCsvSpan(this IEnumerable<string> source)
{
if (source is IList<string> list)
{
int totalLen = list.Count - 1; // запятые
foreach (var s in list)
totalLen += s.Length;
Span<char> buffer = totalLen < 1024
? stackalloc char[totalLen]
: new char[totalLen];
int pos = 0;
for (int i = 0; i < list.Count; i++)
{
if (