Недавно мы разбирали популярную задачу — проверяли строку на наличие цифр. Ещё одна популярная задача при работе со строками — удалить из них пробельные символы. Можно представить, что нам нужно очистить пользовательский ввод: удалить пробелы в начале и конце строк в имени или удалить пробелы из телефонного номера. .NET предоставляет нам несколько возможностей для решения этой задачи, давайте рассмотрим самые популярные и попробуем найти наиболее эффективные. Заодно проверим, какие изменения произошли в новой версии .NET 10.

Для нашего примера будем считать пробельными символами сам пробел ( ), табуляцию (\t) и перевод строки (\n). На самом деле, их больше, но для нашей задачи вначале ограничимся только этими как самыми популярными.

Для бенчмарка я буду использовать самые новые и одни из самых старых версий фреймворка. Вряд ли выберут .NET Framework 4.8 для старта нового проекта, но интересно увидеть, как развивалась платформа во времени и что можно получить с переходом на новые версии.

Replace

Начнём с самого простого метода — Replace. Он может заменить нам все вхождения какого-то символа или подстроки на другой символ или подстроку. Для наших целей мы можем заменить пробельные символы на пустую строку (string.Empty). Но есть большой недостаток — этот метод не может заменить все пробельные символы сразу, поэтому мы вынуждены вызывать его для каждого пробельного символа:

private static string Replace(string s)
{
    return s.Replace(" ", string.Empty).Replace("\t", string.Empty).Replace("\n", string.Empty);
}

Посмотрим, как этот метод ведёт себя в разных версиях .NET. Будем проверять на небольшой строке: " \tString \t with \n\n\t\t whitespaces \t".

Полный код бенчмарка и все результаты вы найдёте в конце статьи.

| Method           | Runtime            |        Mean |      Median | Allocated |
|------------------|--------------------|------------:|------------:|----------:|
| Replace          | .NET Framework 4.8 | 230.9093 ns | 227.7122 ns |     233 B |
| Replace          | .NET Core 3.1      | 158.6589 ns | 158.1439 ns |     216 B |
| Replace          | .NET 8.0           | 178.9700 ns | 177.7641 ns |     216 B |
| Replace          | .NET 10.0          | 157.4850 ns | 156.3763 ns |     216 B |

Если посмотреть на размер итоговой строки, то мы тратим примерно в 3 раза больше памяти. Очевидно, это из-за того, что мы 3 раза вызываем метод Replace(), который создаёт новую строку. Из неё мы опять удаляем символы, что опять создаёт новую строку. И мы видим незначительное улучшение с каждой новой версией .NET.

И если в версии .NET 4.8 реализация метода Replace() находится внутри Common Language Runtime (CLR):

[SecuritySafeCritical]
[MethodImpl(MethodImplOptions.InternalCall)]
private extern string ReplaceInternal(string oldValue, string newValue);

То уже начиная с .NET Core 3.1 используется собственная реализация на указателях, а позже — с использованием типа Span и векторных инструкций.

К недостатку этого метода можно отнести то, что нужно явно перечислить все пробельные символы. А их, на самом деле, не так мало. Каждый новый символ — это лишний вызов метода Replace.

Наверняка должны быть более оптимальные решения.

Regex

Регулярные выражения — универсальный способ решения для многих задач. И здесь он тоже применим. Как я говорил в предыдущем методе, мы не можем с помощью метода Replace() заменить все пробельные символы. А вот с помощью регулярных выражений можем:

private static readonly Regex EmptySpacesCompiled = new Regex(@"\s+", RegexOptions.Compiled);

private static string RegexCompiled(string s)
{
    return EmptySpacesCompiled.Replace(s, string.Empty);
}

private static readonly Regex EmptySpacesGenerated = GenerateRegex();

[GeneratedRegex(@"\s+")]
private static partial Regex GenerateRegex();

private static string RegexGenerated(string s)
{
    return EmptySpacesGenerated.Replace(s, string.Empty);
}

\s - специальное выражение, которое объединяет широкий набор пробельных символов, в том числе \t\n, . Поэтому расширим задачу и дальше переходим к более общему варианту, где под пробельными понимаются все символы, которые char.IsWhiteSpace или \s считают пробельными. Таким образом, мы не пропустим ничего, как в случае с методом Replace.

Мы уже знаем по предыдущей статье, что опция Compiled положительно сказывается на производительности, будем указывать её. Заодно проверим, как работает GeneratedRegex в этом случае.

| Method         | Runtime            |        Mean |      Median | Allocated |
|----------------|--------------------|------------:|------------:|----------:|
| RegexCompiled  | .NET Framework 4.8 | 872.0395 ns | 856.0633 ns |    1115 B |
| RegexGenerated | .NET Framework 4.8 |         N/A |         N/A |       N/A |
| RegexCompiled  | .NET Core 3.1      | 709.4504 ns | 696.5562 ns |     896 B |
| RegexGenerated | .NET Core 3.1      |         N/A |         N/A |       N/A |
| RegexCompiled  | .NET 8.0           | 202.2732 ns | 202.0966 ns |      64 B |
| RegexGenerated | .NET 8.0           | 191.2238 ns | 188.5849 ns |      64 B |
| RegexCompiled  | .NET 10.0          | 183.2023 ns | 181.1374 ns |      64 B |
| RegexGenerated | .NET 10.0          | 138.8752 ns | 138.8915 ns |      64 B |

Если в старых версиях фреймворка это решение не выдерживает сравнения даже с примитивным Replace(), то в 8-й и 10-й версии мы видим значительное улучшение.

Сравнение регулярных выражений и Replace
Сравнение регулярных выражений и Replace

А самое интересное — обратите внимание на потребление памяти. Всего 64 байта — итоговая строка. Это минимум, который можно получить в этом случае. Дело в том, что в новых версиях .NET Regex собирает итоговую строку из «кусочков» исходной без использования промежуточных состояний. За счёт этого удаётся избежать дополнительной аллокации.

Удивительно, насколько оптимизированными стали регулярные выражения — мы видим кратный рост производительности и уменьшение использования памяти.

Но попробуем поискать более быстрый способ очистки строк.

Split + Concat

Еще один из способов удалить все пробелы — выделить из строки только непрерывные последовательности не пробельных символов, а потом собрать их в новую строку.

Это можно сделать с помощью метода Split:

private static string SplitConcat(string s)
{
    var parts = s.Split((char[])null, StringSplitOptions.RemoveEmptyEntries);
    return string.Concat(parts);
}

Передавая null в качестве первого параметра мы как раз хотим разделить нашу исходную строку по пробельным символам с использованием метода char.IsWhiteSpace. Этот метод работает с более широким набором пробельных символов, чем те, что мы описывали вначале. И указываем опцию RemoveEmptyEntries , чтобы в результирующем массиве parts не оказалось пустых строк.

Проведём бенчмарк и сравним с предыдущими результатами. Для наглядности оставим только последние версии фреймворка.

| Method         | Runtime            |        Mean |      Median | Allocated |
|----------------|--------------------|------------:|------------:|----------:|
| Replace        | .NET 8.0           | 178.9700 ns | 177.7641 ns |     216 B |
| RegexCompiled  | .NET 8.0           | 202.2732 ns | 202.0966 ns |      64 B |
| RegexGenerated | .NET 8.0           | 191.2238 ns | 188.5849 ns |      64 B |
| SplitConcat    | .NET 8.0           |  97.6117 ns |  97.6992 ns |     376 B |
| Replace        | .NET 10.0          | 157.4850 ns | 156.3763 ns |     216 B |
| RegexCompiled  | .NET 10.0          | 183.2023 ns | 181.1374 ns |      64 B |
| RegexGenerated | .NET 10.0          | 138.8752 ns | 138.8915 ns |      64 B |
| SplitConcat    | .NET 10.0          |  90.2844 ns |  90.6640 ns |     376 B |
Сравнение метода Split+Concat
Сравнение метода Split+Concat

Пока этот способ — самый производительный во всех фреймворках. Но, к сожалению, он потребляет неприлично много памяти. Даже больше, чем трёхкратный вызов метода Replace(). И большая часть этих затрат приходится на вызов метода Split(). Как мы уже знаем, результирующая строка занимает всего 64 байта, а значит 312 байт мы тратим на массив parts. Это большой симптом того, что мы выбрали некорректный способ для реализации задачи. Ведь действительно, сам по себе массив parts нам не нужен. Это только промежуточный результат, который мы потом должны объединить в строку, а сам массив отбросить.

А если разделить строку сразу на символы и отбросить пробельные?

private string Concat(string s)
{
    return string.Concat(s.Where(c => !char.IsWhiteSpace(c)));
}

Сравним 2 этих метода:

| Method           | Runtime            | Mean        | Median      | Allocated |
|----------------- |------------------- |------------:|------------:|----------:|
| SplitConcat      | .NET Framework 4.8 | 164.1033 ns | 161.6289 ns |     610 B |
| Concat           | .NET Framework 4.8 | 540.6659 ns | 540.8523 ns |     834 B |
| SplitConcat      | .NET Core 3.1      | 122.7668 ns | 120.7053 ns |     376 B |
| Concat           | .NET Core 3.1      | 342.1899 ns | 344.6257 ns |     152 B |
| SplitConcat      | .NET 8.0           |  97.6117 ns |  97.6992 ns |     376 B |
| Concat           | .NET 8.0           | 110.4472 ns | 110.3518 ns |     152 B |
| SplitConcat      | .NET 10.0          |  90.2844 ns |  90.6640 ns |     376 B |
| Concat           | .NET 10.0          | 100.3079 ns |  99.9509 ns |     152 B |

Только в последних версиях .NET этот метод приближается по производительности и выигрывает по памяти.

Еще больше LINQ

Мы можем ускориться и по возможности не создавать лишних объектов. Еще один популярный вариант:

private static string Linq(string s)
{
    return new string(s.Where(c => !char.IsWhiteSpace(c)).ToArray());
}

Берём из нашей исходной строки только не пробельные символы и создаём из них новую строку.

| Method           | Runtime            |        Mean |      Median | Allocated |
|------------------|--------------------|------------:|------------:|----------:|
| Linq             | .NET Framework 4.8 | 455.1868 ns | 457.0586 ns |     449 B |
| Linq             | .NET Core 3.1      | 331.2851 ns | 331.8324 ns |     448 B |
| Linq             | .NET 8.0           | 166.4657 ns | 165.9890 ns |     448 B |
| Linq             | .NET 10.0          |  70.9343 ns |  71.1085 ns |     192 B |

В общей сводной таблице он есть, но в рекомендациях не рассматривается из‑за аллокаций. На это влияет необходимость создания какого-то промежуточного массива для символов без пробелов.

Array buffer

Если нам всё равно нужно создавать какой-то массив, сделаем это сами заранее. Подготовим массив символов длиной исходной строки, заполним его символами без пробелов и создадим из него новую строку:

private static string Buffer(string s)
{
    var buffer = new char[s.Length];
    var index = 0;

    foreach (var c in s)
    {
        if (!char.IsWhiteSpace(c))
        {
            buffer[index++] = c;
        }
    }

    return new string(buffer, 0, index);
}

И посмотрим на результаты:

| Method           | Runtime            |        Mean |      Median | Allocated |
|------------------|--------------------|------------:|------------:|----------:|
| Buffer           | .NET Framework 4.8 |  72.8121 ns |  73.0219 ns |     168 B |
| Buffer           | .NET Core 3.1      |  56.3518 ns |  55.7937 ns |     160 B |
| Buffer           | .NET 8.0           |  38.5226 ns |  38.5226 ns |     160 B |
| Buffer           | .NET 10.0          |  31.0592 ns |  31.1676 ns |     160 B |

Выглядит впечатляюще — лучший метод по производительности на сегодняшний момент:

А главное, мы видим стабильное потребление памяти: только затраты на буфер и новую строку.

Но есть ещё один трюк, который нам поможет улучшить результаты.

Stackalloc array buffer

Как мы знаем, такие типы данных, как массивы, хранятся в управляемой куче (как раз эти данные попадают в метрику Allocated в бенчмарках). И доступ к этим данным несколько медленнее, чем к тем, что хранятся на стеке.

Обычно мы не управляем тем, где будут размещаться данные, но в .NET есть специальное выражение stackalloc, с помощью него можно выделить блок памяти прямо на стеке:

private static unsafe string StackallocBuffer(string s)
{
    var buffer = stackalloc char[s.Length];
    var index = 0;

    foreach (var c in s)
    {
        if (!char.IsWhiteSpace(c))
        {
            buffer[index++] = c;
        }
    }

    return new string(buffer, 0, index);
}

И запустим бенчмарк:

| Method           | Runtime            |        Mean |      Median | Allocated |
|------------------|--------------------|------------:|------------:|----------:|
| StackallocBuffer | .NET Framework 4.8 |  72.5095 ns |  70.6909 ns |      72 B |
| StackallocBuffer | .NET Core 3.1      |  50.3178 ns |  49.4475 ns |      64 B |
| StackallocBuffer | .NET 8.0           |  32.9556 ns |  32.7873 ns |      64 B |
| StackallocBuffer | .NET 10.0          |  26.7175 ns |  26.4192 ns |      64 B |

Теперь наш временный массив buffer создаётся не в общей куче, от чего получается ещё небольшая прибавка в производительности и минимальное потребление памяти.

Но нужно учитывать, что размер стека ограничен и очень большие массивы создать не получится - получим StackOverflowException. Размер стека по-умолчанию равен 1 МБ и нужно учитывать это ограничение. Для коротких строк (десятки-сотни символов) это безопасно, для потенциально больших строк лучше оставить вариант с кучей.

Заключение

Как обычно, мы рассмотрели самые популярные способы очистки строк от пробельных символов. Есть ещё экзотические, не указанные в этой статье. И, наверняка, есть ряд экстремальных, которыми поделятся в комментариях :)

Все они дают одинаковый результат, но используют разные механизмы. Если эта операция — частотная в вашем сценарии, то посмотрите, насколько оптимальный вариант вы используете. И обратите внимание на прогресс в новых версиях .NET.

Ещё в общем бенчмарке есть вариант с использованием StringBuilder, но фактически он очень похож на решение с Buffer — также проходит строку один раз и накапливает символы без пробелов во внутренний буфер, поэтому подробно я про него не пишу, но можно посмотреть на результаты в таблице.

Рекомендации:

  • Если для вас важна простота решения и его читабельность, то подходят разные варианты Regex и метод Replace. Но учтите, что в методе Replace вам нужно явно перечислить то, от чего вы хотите очистить строку.

  • Для «горячих» мест используйте Buffer или StackallocBuffer в зависимости от ограничений на длину строки. Обычно это какие-то парсеры, логирование, нормализация входящих запросов и тому подобное.

  • Для старых версий фреймворка избегайте использования регулярных выражений для простых шаблонов — это будет не эффективно.

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

Полный код бенчмарка. Нажмите, чтобы развернуть.
using System;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;

namespace WhitespacesBenchmark
{
    [MemoryDiagnoser]
    [SimpleJob(RuntimeMoniker.Net10_0)]
    [SimpleJob(RuntimeMoniker.Net80)]
    [SimpleJob(RuntimeMoniker.NetCoreApp31)]
    [SimpleJob(RuntimeMoniker.Net48)]
    [HideColumns("Job", "Error", "StdDev", "Gen0")]
    public partial class RemoveWhitespacesBenchmark
    {
        private const string Value = " \tString  \t with \n\n\t\t whitespaces \t";

        [Benchmark]
        public string Replace()
        {
            return Replace(Value);
        }

        private static string Replace(string s)
        {
            return s.Replace(" ", string.Empty).Replace("\t", string.Empty).Replace("\n", string.Empty);
        }

        [Benchmark]
        public string SplitConcat()
        {
            return SplitConcat(Value);
        }

        private static string SplitConcat(string s)
        {
            var parts = s.Split((char[])null, StringSplitOptions.RemoveEmptyEntries);
            return string.Concat(parts);
        }
        
        [Benchmark]
        public string Concat()
        {
            return Concat(Value);
        }

        private string Concat(string s)
        {
            return string.Concat(s.Where(c => !char.IsWhiteSpace(c)));
        }

        [Benchmark]
        public string Linq()
        {
            return Linq(Value);
        }

        private static string Linq(string s)
        {
            return new string(s.Where(c => !char.IsWhiteSpace(c)).ToArray());
        }

        private static readonly Regex EmptySpacesCompiled = new Regex(@"[\t \n]+", RegexOptions.Compiled);

#if NET7_0_OR_GREATER
        private static readonly Regex EmptySpacesGenerated = GenerateRegex();

        [GeneratedRegex(@"[\t \n]+")]
        private static partial Regex GenerateRegex();
#endif

        [Benchmark]
        public string RegexCompiled()
        {
            return RegexCompiled(Value);
        }

        private static string RegexCompiled(string s)
        {
            return EmptySpacesCompiled.Replace(s, string.Empty);
        }

        [Benchmark]
        public string RegexGenerated()
        {
            return RegexGenerated(Value);
        }

        private static string RegexGenerated(string s)
        {
#if NET7_0_OR_GREATER
            return EmptySpacesGenerated.Replace(s, string.Empty);
#else
            return s;
#endif
        }

        [Benchmark]
        public string StringBuilder()
        {
            return StringBuilder(Value);
        }

        private static string StringBuilder(string s)
        {
            var sb = new StringBuilder(s.Length);

            foreach (var c in s)
            {
                if (!char.IsWhiteSpace(c))
                {
                    sb.Append(c);
                }
            }

            return sb.ToString();
        }

        [Benchmark]
        public string Buffer()
        {
            return Buffer(Value);
        }

        private static string Buffer(string s)
        {
            var buffer = new char[s.Length];
            var index = 0;

            foreach (var c in s)
            {
                if (!char.IsWhiteSpace(c))
                {
                    buffer[index++] = c;
                }
            }

            return new string(buffer, 0, index);
        }

        [Benchmark]
        public string StackallocBuffer()
        {
            return StackallocBuffer(Value);
        }

        private static unsafe string StackallocBuffer(string s)
        {
            var buffer = stackalloc char[s.Length];
            var index = 0;

            foreach (var c in s)
            {
                if (!char.IsWhiteSpace(c))
                {
                    buffer[index++] = c;
                }
            }

            return new string(buffer, 0, index);
        }
    }
}
Все результаты бенчмарков. Нажмите, чтобы развернуть.
BenchmarkDotNet v0.15.6, Windows 10 (10.0.19045.6456/22H2/2022Update)
AMD Ryzen 7 7840H with Radeon 780M Graphics 3.80GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK 10.0.100
  [Host]             : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v4
  .NET 10.0          : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v4
  .NET 8.0           : .NET 8.0.22 (8.0.22, 8.0.2225.52707), X64 RyuJIT x86-64-v4
  .NET Core 3.1      : .NET Core 3.1.32 (3.1.32, 4.700.22.55902), X64 RyuJIT VectorSize=256
  .NET Framework 4.8 : .NET Framework 4.8.1 (4.8.9310.0), X64 RyuJIT VectorSize=256


| Method           | Runtime            | Mean        | Median      | Allocated |
|----------------- |------------------- |------------:|------------:|----------:|
| Replace          | .NET Framework 4.8 | 230.9093 ns | 227.7122 ns |     233 B |
| SplitConcat      | .NET Framework 4.8 | 164.1033 ns | 161.6289 ns |     610 B |
| Concat           | .NET Framework 4.8 | 540.6659 ns | 540.8523 ns |     834 B |
| Linq             | .NET Framework 4.8 | 455.1868 ns | 457.0586 ns |     449 B |
| RegexCompiled    | .NET Framework 4.8 | 872.0395 ns | 856.0633 ns |    1115 B |
| RegexGenerated   | .NET Framework 4.8 |         N/A |         N/A |       N/A |
| StringBuilder    | .NET Framework 4.8 | 115.0854 ns | 115.5498 ns |     217 B |
| Buffer           | .NET Framework 4.8 |  72.8121 ns |  73.0219 ns |     168 B |
| StackallocBuffer | .NET Framework 4.8 |  72.5095 ns |  70.6909 ns |      72 B |
| Replace          | .NET Core 3.1      | 158.6589 ns | 158.1439 ns |     216 B |
| SplitConcat      | .NET Core 3.1      | 122.7668 ns | 120.7053 ns |     376 B |
| Concat           | .NET Core 3.1      | 342.1899 ns | 344.6257 ns |     152 B |
| Linq             | .NET Core 3.1      | 331.2851 ns | 331.8324 ns |     448 B |
| RegexCompiled    | .NET Core 3.1      | 709.4504 ns | 696.5562 ns |     896 B |
| RegexGenerated   | .NET Core 3.1      |         N/A |         N/A |       N/A |
| StringBuilder    | .NET Core 3.1      |  88.3582 ns |  88.6698 ns |     208 B |
| Buffer           | .NET Core 3.1      |  56.3518 ns |  55.7937 ns |     160 B |
| StackallocBuffer | .NET Core 3.1      |  50.3178 ns |  49.4475 ns |      64 B |
| Replace          | .NET 8.0           | 178.9700 ns | 177.7641 ns |     216 B |
| SplitConcat      | .NET 8.0           |  97.6117 ns |  97.6992 ns |     376 B |
| Concat           | .NET 8.0           | 110.4472 ns | 110.3518 ns |     152 B |
| Linq             | .NET 8.0           | 166.4657 ns | 165.9890 ns |     448 B |
| RegexCompiled    | .NET 8.0           | 202.2732 ns | 202.0966 ns |      64 B |
| RegexGenerated   | .NET 8.0           | 191.2238 ns | 188.5849 ns |      64 B |
| StringBuilder    | .NET 8.0           |  50.1718 ns |  50.4215 ns |     208 B |
| Buffer           | .NET 8.0           |  38.5226 ns |  38.5226 ns |     160 B |
| StackallocBuffer | .NET 8.0           |  32.9556 ns |  32.7873 ns |      64 B |
| Replace          | .NET 10.0          | 157.4850 ns | 156.3763 ns |     216 B |
| SplitConcat      | .NET 10.0          |  90.2844 ns |  90.6640 ns |     376 B |
| Concat           | .NET 10.0          | 100.3079 ns |  99.9509 ns |     152 B |
| Linq             | .NET 10.0          |  70.9343 ns |  71.1085 ns |     192 B |
| RegexCompiled    | .NET 10.0          | 183.2023 ns | 181.1374 ns |      64 B |
| RegexGenerated   | .NET 10.0          | 138.8752 ns | 138.8915 ns |      64 B |
| StringBuilder    | .NET 10.0          |  37.4249 ns |  37.6158 ns |     208 B |
| Buffer           | .NET 10.0          |  31.0592 ns |  31.1676 ns |     160 B |
| StackallocBuffer | .NET 10.0          |  26.7175 ns |  26.4192 ns |      64 B |

Комментарии (21)


  1. NeoCode
    28.11.2025 10:18

    Если бы стояла задача преобразовать строковое представление числа с пробелами внутри непосредственно в число, то я написал бы специальные функции, которые это делают прямо на входных данных вообще без переаллокаций.


  1. domix32
    28.11.2025 10:18

    То есть LINQ интерфейсы не умеют в ленивые вычисления? Казалось бы от примера с буфером where.to_array отличаться не должен на уровне оптимизаций и аллокация должна случиться собственно только при создании массива.


    1. srogatnev Автор
      28.11.2025 10:18

      LINQ умеет в ленивые вычисления. Метод Where - ленивый, а вот уже ToArray() - материализация коллекции. Т.к. конструктор строки требует массив, то приходится его вызывать.


      1. domix32
        28.11.2025 10:18

        Я понимаю, что ToArray аллоцирует массив, но с учётом размера строки не похоже что он единственный кто виноват. ArrayBuffer и Linq на NET 10 отличаются на 30 байт - откуда они берутся совершенно непонятно. Where по идее не должен иметь какого-то сложного состояния. Неужели релокации?


        1. srogatnev Автор
          28.11.2025 10:18

          Еще виновата лямбда c => !char.IsWhiteSpace(c).
          Она разворачивается в не самую простую конструкцию и тоже занимает немного места. Поэтому в критичных местах LINQ является проблемой.


        1. monco83
          28.11.2025 10:18

          В примере с буфером размер строки известен, в примере с ToArray используется итератор, ToArray в данном случае не может делать предположений о конечном размере коллекции.

          P.S. Если бы ToArray к ICollection применялся, тогда другое дело, вот что у метода ToArray() под капотом.

          if (source is ICollection<TSource> collection)
          {
              return ICollectionToArray(collection);
          }
          
          private static TSource[] ICollectionToArray<TSource>(ICollection<TSource> collection)
          {
              int count = collection.Count;
              if (count != 0)
              {
                  var result = new TSource[count];
                  collection.CopyTo(result, 0);
                  return result;
              }
          
              return [];
          }



          1. domix32
            28.11.2025 10:18

            Ну, то есть таки на переаллокациях набегает, когда размер контейнера расширяется под неизвестный размер? С другой стороны размер строки не сказать чтобы большой чтобы вызвать много переаллокаций


            1. monco83
              28.11.2025 10:18

              Чтобы точно сказать надо глубже в код погружаться, на, да, предполагаю, что тут есть место переаллокациям, как при работе с List.


  1. viruseg
    28.11.2025 10:18

    Вместо unsafe в StackallocBuffer лучше использовать Span, но пропустить инициализацию нулями с помощью атрибута SkipLocalsInit. Производительность будет точно такая же.

    [SkipLocalsInit]
    static string SpanBuffer(string s)
    {
        Span<char> buffer = stackalloc char[s.Length];
        var index = 0;
    
        foreach (var c in s)
        {
            if (!char.IsWhiteSpace(c))
            {
                buffer[index++] = c;
            }
        }
    
        return new string(buffer[..index]);
    }


    1. Nagg
      28.11.2025 10:18

      вы же видите что у вас Error примерно равен разнице результатов?


      1. viruseg
        28.11.2025 10:18

        Так, а я разве сказал, что через Span будет лучше/хуже? Я сказал, что будет то же самое, но без unsafe.


        1. Nagg
          28.11.2025 10:18

          SkipLocalsInit требует AllowUnsafeBlock, ваш код всё ещё unsafe.


          1. viruseg
            28.11.2025 10:18

            Но не требует у метода unsafe, и в коде не будет указателей char* buffer. Правда, нужно разжёвывать смысл моего сообщения?


            1. Nagg
              28.11.2025 10:18

              Указатели не единственное что является unsafe кодом в .NET. На данный момент это так, в .NET 11/12 это изменится и много текущих API cтанут unsafe + Span который инициализируется мусором со стека (т.е. с SkipLocalsInit) станет требовать unsafe блок, вы можете прочитать этот тут: https://github.com/dotnet/csharplang/blob/main/proposals/unsafe-evolution.md#stack-allocation

              The stackalloc_expression is used within a member that has SkipLocalsInitAttribute applied.

              Но мне кажется это и так очевидно что переменные инициализируемые мусором со стека воняют unsafe кодом.


              1. viruseg
                28.11.2025 10:18

                Span вместе с SkipLocalsInit всё ещё безопаснее, чем сырой указатель. Инициализации нулями не будет в обоих случаях. Поэтому, возвращаясь к изначальному сообщению, что не так с предложенным мной вариантом?

                вы же видите что у вас Error примерно равен разнице результатов?

                Вы сначала докопались к тому, чего не было в моём сообщении, а теперь доказываете то, что и так очевидно. Ради чего вы это делаете?


                1. Nagg
                  28.11.2025 10:18

                  Признаю я не дочитал до конца и мне показалось что вы советовали это как оптимизацию, извиняюсь. Но тем не менее SkipLocalsInit штука которую лучше никак не рекламировать, потому что это хороший такой способ прострелить себе ноги. безопасный C# всегда инициализирует абсолютно все нулями и лучше это так и оставить.


                  1. viruseg
                    28.11.2025 10:18

                    На всякий случай копирую сюда метод, предложенный в статье, в котором есть сырые указатели и точно такая же не инициализированная нулями память.

                    private static unsafe string StackallocBuffer(string s)
                    {
                        var buffer = stackalloc char[s.Length];
                        var index = 0;
                    
                        foreach (var c in s)
                        {
                            if (!char.IsWhiteSpace(c))
                            {
                                buffer[index++] = c;
                            }
                        }
                    
                        return new string(buffer, 0, index);
                    }

                    Именно его я предложил заменить. Т.е. я буквально сделал предложенный в статье код менее опасным, заменив указатели на Span. И при этом сохранил производительность метода на том же уровне.


                    1. Nagg
                      28.11.2025 10:18

                      Я бы предложил такой:

                      private static string StackallocBuffer(string s)
                      {
                          var buffer = s.Length <= 64 ? stackalloc char[64] : new char[s.Length];
                          var index = 0;
                          foreach (var c in s)
                          {
                              if (!char.IsWhiteSpace(c))
                                  buffer[index++] = c;
                          }
                          return new string(buffer[..index]);
                      }
                      

                      Здесь нет ничего небезопасного, он не взорвется с StackOverflow на большой строке и const-sized стекаллок проинициализируется нулями примерно 2 инструкциями (через avx512 zmm) + как показывает практика, большинств строк - маленькие, да и не надо боятся эфемерных GC аллокаций, они не создают проблем для гц.


                      1. viruseg
                        28.11.2025 10:18

                        Бояться отсутствия инициализации нулями там, где совершенно точно, как в этом методе, не будет чтения из неинициализированного массива, - это какой-то карго-культ боязни unsafe.

                        У меня нет avx512 под рукой, но на avx2 это 11% разницы. Бесплатное повышение производительности с нулевыми рисками на дороге не валяется. Хотя нет, в этом случае как раз валяется.


  1. monco83
    28.11.2025 10:18

    1. Вариант со stackalloc не для всякого размера строки подойдёт из-за StackOverflowException,хорошо бы про такие вещи упоминать.

    2. Аллокацию памяти можно свести до нуля, если ввести флаг hasWhitespaces и устанавливать его в цикле при встрече пробельного символа. Тогда в случае hasWhitespaces=false можно возвращать исходную строку не создавая новую. Но осмысленность такой оптимизации зависит от контекста: как часто в рассматриваемом сценарии будут встречаться строки без пробельных символов и готовы ли вы за отсутствие аллокации в некоторых случаях платить расходом нескольких дополнительных тактов процессора в случае каждом.


    1. srogatnev Автор
      28.11.2025 10:18

      Действительно, важное замечание про размер стэка. Куда-то потерялся абзац про это, вернул его. Спасибо.