Популярная задача — определить, состоит ли строка только из числовых символов. Например, нужно проверить, что пользователь правильно ввел номер телефона, индекс или ИНН организации. Сделать это можно несколькими способами, которые отличаются своей эффективностью. Давайте рассмотрим самые популярные из них.
Regex
Наверное, это наиболее часто встречающийся способ. И действительно, что может быть проще использования регулярного выражения вида ^[0-9]*$
(или ^\d*$
)?
Ниже представлена наивная реализация проверки с помощью регулярного выражения:
Regex regex = new Regex("^[0-9]*$");
var value = "123456789000";
var isValid = regex.IsMatch(value);
Возможно, вы уже видите здесь проблему. Такая реализация годится только для одноразового запуска. В промышленном коде, где вы проверяете сотни тысяч строк, такое решение будет не эффективным.
.NET предоставляет возможность скомпилировать регулярное выражение во время выполнения при вызове конструктора, для это нужно использовать опцию RegexOptions.Compiled
:
Regex regex = new Regex("^[0-9]*$", RegexOptions.Compiled);
var value = "123456789000";
var isValid = regex.IsMatch(value);
При вызове конструктора с этой опцией будет сгенерирован IL-код, который будет вызываться через DynamicMethod
внутри Regex.IsMatch
, что будет быстрее, чем обычная обработка регулярного выражения.
Минусом же будет более долгое создание объекта Regex
за счет затрат времени на компиляцию в рантайме, но это быстро окупается при многократном использовании.
Давайте сравним производительность двух вариантов.
Код бенчмарка. Нажмите, чтобы развернуть.
[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net90)]
[SimpleJob(RuntimeMoniker.Net60)]
[SimpleJob(RuntimeMoniker.NetCoreApp31)]
[SimpleJob(RuntimeMoniker.Net48)]
[HideColumns("Job", "Error", "StdDev", "Gen0")]
public partial class DigitsBenchmarks
{
private static string value = "123456789000";
private static readonly Regex regex = new Regex("^[0-9]*$");
private static readonly Regex compiledRegex = new Regex("^[0-9]*$", RegexOptions.Compiled);
[Benchmark]
public bool Regex()
{
return regex.IsMatch(value);
}
[Benchmark]
public bool CompiledRegex()
{
return compiledRegex.IsMatch(value);
}
}
Результаты:
Method | Runtime | Mean | Median | Allocated |
---|---|---|---|---|
Regex | .NET Framework 4.8 | 165.4417 ns | 166.2537 ns | - |
CompiledRegex | .NET Framework 4.8 | 115.9377 ns | 115.9720 ns | - |
Regex | .NET Core 3.1 | 118.1540 ns | 118.1887 ns | - |
CompiledRegex | .NET Core 3.1 | 89.7392 ns | 89.6514 ns | - |
Regex | .NET 6.0 | 57.8247 ns | 57.8031 ns | - |
CompiledRegex | .NET 6.0 | 21.2952 ns | 21.2616 ns | - |
Regex | .NET 9.0 | 47.2579 ns | 47.3506 ns | - |
CompiledRegex | .NET 9.0 | 24.2419 ns | 24.2547 ns | - |
Преимущество использования скомпилированных выражений довольно наглядно. Так же с каждой новой версией .NET виден вклад разработчиков в производительность. Это еще один аргумент в пользу обновления на современные версии фреймворка.
Regex source generators
Повторюсь, что компиляция регулярных выражений имеет один недостаток — создание объекта Regex
в рантайме будет занимать какое-то время. Можно ли от этого избавиться?
Начиная с .NET 7 такая возможность появляется благодаря генераторам кода. Строго говоря, они появились в .NET 5, но решение для регулярных выражений было реализовано только в седьмой версии.
Генераторы кода позволяют создавать C#-код на этапе компиляции. А значит его можно просматривать и дебажить так, словно это ваш собственный код.
И регулярные выражения можно превратить в C#-код на этапе компиляции! В .NET для этого реализован специальный атрибут GeneratedRegex
:
namespace DigitBenchmark
{
public partial class DigitsBenchmarks
{
private static readonly Regex generatedRegex = GenerateRegex();
[GeneratedRegex("^[0-9]*$")]
private static partial Regex GenerateRegex();
}
}
Давайте разберемся, что здесь происходит.
Во-первых, нам нужно пометить наш класс DigitsBenchmarks
как partial
, т.к. часть сгенерированного кода для этого класса будет находиться в другом файле.
Дальше нам нужно создать partial
-метод, который будет возвращать объект типа Regex
и пометить его атрибутом GeneratedRegex
с указанием шаблона регулярного выражения.
Опцию RegexOptions.Compiled
указывать не нужно, она будет проигнорирована. Далее поймете почему.
Реализация метода GenerateRegex
будет находиться в другом файле. Его можно найти в проекте и посмотреть исходный код:
namespace DigitBenchmark
{
partial class DigitsBenchmarks
{
/// <remarks>
/// Pattern:<br/>
/// <code>^[0-9]*$</code><br/>
/// Explanation:<br/>
/// <code>
/// ○ Match if at the beginning of the string.<br/>
/// ○ Match a character in the set [0-9] atomically any number of times.<br/>
/// ○ Match if at the end of the string or if before an ending newline.<br/>
/// </code>
/// </remarks>
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Text.RegularExpressions.Generator", "8.0.12.21506")]
private static partial global::System.Text.RegularExpressions.Regex GenerateRegex() => global::System.Text.RegularExpressions.Generated.GenerateRegex_0.Instance;
}
}
Как видите, автоматически был создан файл с тем же классом и содержащий реализацию нашего метода по генерации регулярного выражения.
А дальше мы можем пользоваться этим объектом Regex
как обычно. Так как это настоящий C#-код, то генерировать в рантайме ничего не нужно, поэтому и указывать опцию RegexOptions.Compiled
нет смысла.
Какое преимущество мы от этого получим? В моих бенчмарках нет версий .NET 7 и 8, сравним производительность по последней на данный момент:
Method | Runtime | Mean | Median | Allocated |
---|---|---|---|---|
Regex | .NET 9.0 | 47.2579 ns | 47.3506 ns | - |
CompiledRegex | .NET 9.0 | 24.2419 ns | 24.2547 ns | - |
GeneratedRegex | .NET 9.0 | 17.2548 ns | 17.2603 ns | - |
Видим, что время сократилось почти на 30%! Компилятор имеет намного больше возможностей для оптимизации исходного кода на этапе компиляции, чем в рантайме.
char.IsDigit
Еще один популярный способ — использование статического метода char.IsDigit
в сочетании с LINQ-методом All
:
var value = "123456789000";
var isValid = value.All(char.IsDigit);
Давайте проверим, насколько хорош этот метод с точки зрения производительности.
Код бенчмарка. Нажмите, чтобы развернуть.
[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net90)]
[SimpleJob(RuntimeMoniker.Net60)]
[SimpleJob(RuntimeMoniker.NetCoreApp31)]
[SimpleJob(RuntimeMoniker.Net48)]
[HideColumns("Job", "Error", "StdDev", "Gen0")]
public partial class DigitsBenchmarks
{
[Benchmark]
public bool LinqCharIsDigit()
{
return value.All(char.IsDigit);
}
}
Результаты:
Method | Runtime | Mean | Median | Allocated |
---|---|---|---|---|
LinqCharIsDigit | .NET Framework 4.8 | 92.1679 ns | 92.2549 ns | 96 B |
LinqCharIsDigit | .NET Core 3.1 | 72.0987 ns | 72.6419 ns | 96 B |
LinqCharIsDigit | .NET 6.0 | 74.2609 ns | 74.4256 ns | 96 B |
LinqCharIsDigit | .NET 9.0 | 31.0294 ns | 31.0501 ns | 32 B |
И сравним этот способ с предыдущими решениями.
Если в старых версиях фреймворка проверки через LINQ и метод IsDigit
имеют преимущество над регулярными выражениями, то позже мы видим, что такая реализация начинает проигрывать.
Так же, обратите внимание, что каждый вызов такого метода приводит к выделению какого-то количества дополнительной памяти. Это значение складывается из 2-х составляющих:
- Создание лямбда-выражения в параметре метода
All(c => char.IsDigit(c))
. - Создание итератора внутри метода
All
.
Примечательно, что в версии .NET 9 выделяется в 3 раза меньше памяти.
До .NET 9 метод All
был очень простым и состоял из цикла foreach
с условием:
public static bool All<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate)
{
//...
foreach (TSource source1 in source)
{
if (!predicate(source1))
return false;
}
return true;
}
Но в версии .NET 9 была добавлена важная оптимизация:
public static bool All<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate)
{
//...
ReadOnlySpan<TSource> span;
if (source.TryGetSpan<TSource>(out span))
{
ReadOnlySpan<TSource> readOnlySpan = span;
for (int index = 0; index < readOnlySpan.Length; ++index)
{
TSource source1 = readOnlySpan[index];
if (!predicate(source1))
return false;
}
}
else
{
foreach (TSource source2 in source)
{
if (!predicate(source2))
return false;
}
}
return true;
}
Вместо безусловного цикла foreach
метод All
пробует получить из источника ReadOnlySpan
- безопасный для чтения непрерывный блок памяти.
И дальше используется простой цикл for
, который не приводит к созданию итератора. Тем самым уменьшая количество дополнительной памяти.
Полностью избавиться от этого можно переписав метод All
на обычный цикл:
public bool ForIsDigit()
{
for (var i = 0; i < value.Length; i++)
{
if (!char.IsDigit(value[i]))
return false;
}
return true;
}
Помимо отсутствия лишнего memory-traffic данное решение является очень быстрым.
Что такое число?
Казалось бы, мы нашли оптимальное решение для проверки строки на наличие только чисел. Но предлагаю вам попробовать угадать, что выведет следующий код:
Console.WriteLine(char.IsDigit('0'));
Console.WriteLine(char.IsDigit('a'));
Console.WriteLine(char.IsDigit('٨'));
Console.WriteLine(char.IsDigit('৯'));
Нажмите, чтобы узнать ответ.
Console.WriteLine(char.IsDigit('0')); //True
Console.WriteLine(char.IsDigit('a')); //False
Console.WriteLine(char.IsDigit('٨')); //True
Console.WriteLine(char.IsDigit('৯')); //True
Думаю, вы удивлены результатом. Но в этом нет ничего необычного, метод IsDigit
считает числами не только привычные нам символы из множества 0-9
, но и все остальные символы, которые в кодировке Unicode относятся к числам. А их на самом деле много.
Это может быть проблемой, если вы опираетесь на такую проверку в своём бизнес-коде.
Думаю, это послужило причиной появления нового метода char.IsAsciiDigit
начиная с .NET 7. Вот он уже действительно проверяет только символы из множества 0-9
.
Его реализация очень похожа на ручную проверку каждого символа в цикле, давай сравним оба варианта:
[Benchmark]
public bool ForCompare()
{
for (var i = 0; i < value.Length; i++)
{
if (value[i] < '0' || value[i] > '9')
return false;
}
return true;
}
[Benchmark]
public bool ForIsAsciiDigit()
{
for (var i = 0; i < value.Length; i++)
{
if (!char.IsAsciiDigit(value[i]))
return false;
}
return true;
}
Method | Runtime | Mean | Median | Allocated |
---|---|---|---|---|
ForCompare | .NET 9.0 | 4.8587 ns | 4.8656 ns | - |
ForIsAsciiDigit | .NET 9.0 | 4.7515 ns | 4.4976 ns | - |
Оба метода показывают эквивалентные результаты.
Заключение
Мы рассмотрели несколько разных способов проверить строку, состоит ли она только из чисел или нет. И важно понимать особенности работы некоторых из них, так как помимо банальных проблем производительности можно получить ошибки в бизнес-логике, если недостаточно качественно проверять входные данные.
Рекомендации:
- Если вы пишете свои приложения под .NET 7 или выше, то используйте сгенерированные регулярные выражения. В противном случае указывайте опцию
RegexOptions.Compiled
. - Если вы пишете свои приложения под .NET 7 используйте метод
char.IsAsciiDigit
для проверки символов. В противном случае лучше написать проверку самому.
Ссылки
Полный код бенчмарка. Нажмите, чтобы развернуть.
using System.Linq;
using System.Text.RegularExpressions;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;
namespace DigitBenchmark
{
[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net90)]
[SimpleJob(RuntimeMoniker.Net60)]
[SimpleJob(RuntimeMoniker.NetCoreApp31)]
[SimpleJob(RuntimeMoniker.Net48)]
[HideColumns("Job", "Error", "StdDev", "Gen0")]
public partial class DigitsBenchmarks
{
private static string value = "123456789000";
private static readonly Regex regex = new Regex("^[0-9]*$");
private static readonly Regex compiledRegex = new Regex("^[0-9]*$", RegexOptions.Compiled);
private static readonly Regex generatedRegex = GenerateRegex();
[GeneratedRegex("^[0-9]*$")]
private static partial Regex GenerateRegex();
[Benchmark]
public bool Regex()
{
return regex.IsMatch(value);
}
[Benchmark]
public bool CompiledRegex()
{
return compiledRegex.IsMatch(value);
}
[Benchmark]
public bool GeneratedRegex()
{
return generatedRegex.IsMatch(value);
}
[Benchmark]
public bool LinqCharIsDigit()
{
return value.All(char.IsDigit);
}
[Benchmark]
public bool ForCompare()
{
for (var i = 0; i < value.Length; i++)
{
if (value[i] < '0' || value[i] > '9')
return false;
}
return true;
}
[Benchmark]
public bool ForIsDigit()
{
for (var i = 0; i < value.Length; i++)
{
if (!char.IsDigit(value[i]))
return false;
}
return true;
}
[Benchmark]
public bool ForIsAsciiDigit()
{
for (var i = 0; i < value.Length; i++)
{
if (!char.IsAsciiDigit(value[i]))
return false;
}
return true;
}
[Benchmark]
public bool LinqCharIsAsciiDigit()
{
return value.All(char.IsAsciiDigit);
}
}
}
Все результаты бенчмарков. Нажмите, чтобы развернуть.
BenchmarkDotNet v0.15.0, Windows 10 (10.0.19045.5917/22H2/2022Update)
AMD Ryzen 7 7840H with Radeon 780M Graphics 3.80GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK 9.0.204
[Host] : .NET 9.0.5 (9.0.525.21509), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
.NET 6.0 : .NET 6.0.36 (6.0.3624.51421), X64 RyuJIT AVX2
.NET 9.0 : .NET 9.0.5 (9.0.525.21509), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
.NET Core 3.1 : .NET Core 3.1.32 (CoreCLR 4.700.22.55902, CoreFX 4.700.22.56512), X64 RyuJIT AVX2
.NET Framework 4.8 : .NET Framework 4.8.1 (4.8.9310.0), X64 RyuJIT VectorSize=256
Method | Runtime | Mean | Median | Allocated |
---|---|---|---|---|
Regex | .NET Framework 4.8 | 165.4417 ns | 166.2537 ns | - |
CompiledRegex | .NET Framework 4.8 | 115.9377 ns | 115.9720 ns | - |
GeneratedRegex | .NET Framework 4.8 | N/A | N/A | - |
LinqCharIsDigit | .NET Framework 4.8 | 92.1679 ns | 92.2549 ns | 96 B |
ForCompare | .NET Framework 4.8 | 4.8703 ns | 4.8602 ns | - |
ForIsDigit | .NET Framework 4.8 | 8.3920 ns | 8.3952 ns | - |
ForIsAsciiDigit | .NET Framework 4.8 | N/A | N/A | - |
LinqCharIsAsciiDigit | .NET Framework 4.8 | N/A | N/A | - |
Regex | .NET Core 3.1 | 118.1540 ns | 118.1887 ns | - |
CompiledRegex | .NET Core 3.1 | 89.7392 ns | 89.6514 ns | - |
GeneratedRegex | .NET Core 3.1 | N/A | N/A | - |
LinqCharIsDigit | .NET Core 3.1 | 72.0987 ns | 72.6419 ns | 96 B |
ForCompare | .NET Core 3.1 | 5.3070 ns | 5.3077 ns | - |
ForIsDigit | .NET Core 3.1 | 9.0998 ns | 9.1106 ns | - |
ForIsAsciiDigit | .NET Core 3.1 | N/A | N/A | - |
LinqCharIsAsciiDigit | .NET Core 3.1 | N/A | N/A | - |
Regex | .NET 6.0 | 57.8247 ns | 57.8031 ns | - |
CompiledRegex | .NET 6.0 | 21.2952 ns | 21.2616 ns | - |
GeneratedRegex | .NET 6.0 | N/A | N/A | - |
LinqCharIsDigit | .NET 6.0 | 74.2609 ns | 74.4256 ns | 96 B |
ForCompare | .NET 6.0 | 5.6198 ns | 5.5922 ns | - |
ForIsDigit | .NET 6.0 | 10.3160 ns | 10.1789 ns | - |
ForIsAsciiDigit | .NET 6.0 | N/A | N/A | - |
LinqCharIsAsciiDigit | .NET 6.0 | N/A | N/A | - |
Regex | .NET 9.0 | 47.2579 ns | 47.3506 ns | - |
CompiledRegex | .NET 9.0 | 24.2419 ns | 24.2547 ns | - |
GeneratedRegex | .NET 9.0 | 17.2548 ns | 17.2603 ns | - |
LinqCharIsDigit | .NET 9.0 | 31.0294 ns | 31.0501 ns | 32 B |
ForCompare | .NET 9.0 | 4.8587 ns | 4.8656 ns | - |
ForIsDigit | .NET 9.0 | 7.1207 ns | 7.7792 ns | - |
ForIsAsciiDigit | .NET 9.0 | 4.7515 ns | 4.4976 ns | - |
LinqCharIsAsciiDigit | .NET 9.0 | 32.6861 ns | 32.5483 ns | 32 B |
- Отметка
N/A
ставится для методов, которые не существуют в данной версии фреймворка.