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

А самое интересное — обратите внимание на потребление памяти. Всего 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 |

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

domix32
28.11.2025 10:18То есть LINQ интерфейсы не умеют в ленивые вычисления? Казалось бы от примера с буфером where.to_array отличаться не должен на уровне оптимизаций и аллокация должна случиться собственно только при создании массива.
NeoCode
Если бы стояла задача преобразовать строковое представление числа с пробелами внутри непосредственно в число, то я написал бы специальные функции, которые это делают прямо на входных данных вообще без переаллокаций.