Удаляем пробелы из строки

Рассматриваем разные подходы к удалению пробельных символов из строк C# - сравнение методов.
Published on Thursday 20 November 2025

Еще одна популярная задача при работе со строками — удалить из них пробельные символы. Можно представить, что нам нужно очистить пользовательский ввод (удалить пробелы вначале и конце строк в имени) или минифицировать json-объект. .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.

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

| 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-й версии мы видим значительное улучшение.

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

Сравнение методов Replace и 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

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

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 |

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

Производительность метода Buffer

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

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

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.
  • Для "горячих" мест используйте 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 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(@"\s+", RegexOptions.Compiled);

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

        [GeneratedRegex(@"\s+")]
        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 |
| 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 |
| 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 |
| 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 |
| 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 |
  • Отметка N/A ставится для методов, которые не существуют в данной версии фреймворка.