Как склеить список строк в CSV в C#: разбор под капотом

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: Почему склейка строк — это не просто "сложить"

Представьте: вы пишете веб-сервис на C#, который принимает список тегов от пользователя и должен отправить их в базу данных одной строкой через запятую. Или вы формируете CSV-отчёт для экспорта. Или логируете набор идентификаторов. Знакомая ситуация? Вопрос "Creating a comma separated list from IList or IEnumerable" на Stack Overflow собрал тысячи просмотров — и неспроста.

Казалось бы, что может быть проще? Взяли список строк, прошлись циклом, добавили запятые. Но на практике эта задача таит в себе несколько подводных камней: от лишних запятых в конце до катастрофического падения производительности на больших данных. В этой статье мы не просто покажем готовые решения, а заглянем под капот каждого из них. Вы узнаете, как работают 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 (                

Блоги

Рекомендаций по книгам