Популярная задача — определить, состоит ли строка только из числовых символов. Например, нужно проверить, что пользователь правильно ввел номер телефона, индекс или ИНН организации. Сделать это можно несколькими способами, которые отличаются своей эффективностью. Давайте рассмотрим самые популярные из них.

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 |         - |
Regex vs Compiled Regex
Regex vs Compiled Regex

Преимущество использования скомпилированных выражений довольно наглядно. Так же с каждой новой версией .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);

Давайте проверим, насколько хорош этот метод с точки зрения производительности:

| 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 |

И сравним этот способ с предыдущими решениями.

Regex vs char.IsDigit
Regex vs char.IsDigit

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

Так же, обратите внимание, что каждый вызов такого метода приводит к выделению какого-то количества дополнительной памяти. Это значение складывается из 2-х составляющих:

  1. Создание лямбда-выражения в параметре метода All(c => char.IsDigit(c))

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

LINQ vs for-loop
LINQ vs for-loop

Что такое число?

Казалось бы, мы нашли оптимальное решение для проверки строки на наличие только чисел. Но предлагаю вам попробовать угадать, что выведет следующий код:

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 для проверки символов. В противном случае лучше написать проверку самому.

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


  1. Gromilo
    07.08.2025 07:57

    За char.IsAsciiDigit спасибо.

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


    1. srogatnev Автор
      07.08.2025 07:57

      Я не скажу, что есть какие-то проблемы производительности в этом месте. А даже если они и есть, то это далеко не самая критичная часть кода. Это скорее любопытство и попытки лучше разобраться, как это работает. Ну и по возможности, где-то сделать лучше, если это дёшево: например, добавить опцию компиляции регулярного выражения или использовать source generator.


  1. OwDafuq
    07.08.2025 07:57

    Попробуйте еще это:

    [Benchmark] public bool IsAllDigit() { var length = str.Length; var span = str.AsSpan(); for (var i = 0; i &lt; length; i++) { if ((uint)(span[i] - '0') &gt; 9) { return false; } } return true; }

    Бенчмарк показывает, что мой метод быстрее, по крайней мере на моей машине

    (извините, не совсем умею на хабре вставлять куски кода правильно)


    1. srogatnev Автор
      07.08.2025 07:57

      Действительно, можно попробовать такое решение. У меня получились такие результаты:

      | Method                  | Runtime            | Mean      | Median    | Allocated |
      |------------------------ |------------------- |----------:|----------:|----------:|
      | IsAllDigit              | .NET Framework 4.8 | 6.3097 ns | 6.2725 ns |         - |
      | IsAllDigit              | .NET Core 3.1      | 4.4434 ns | 4.4404 ns |         - |
      | IsAllDigit              | .NET 6.0           | 4.4595 ns | 4.4046 ns |         - |
      | IsAllDigit              | .NET 9.0           | 3.4871 ns | 3.5038 ns |         - |

      Можно сказать, что он сопоставим с методом IsAsciiDigit и лишь в .NET 9 появляется преимущество.


  1. Erty_Hackward
    07.08.2025 07:57

    return value.AsSpan().IndexOfAnyExceptInRange('0', '9') == -1;


  1. VBDUnit
    07.08.2025 07:57

    Для интереса попробовал на интринсиках (.NET9/Release):

    | Method                             | Mean              | Error          | StdDev         |
    |----------------------------------- |------------------:|---------------:|---------------:|
    | LEN_12_only_digits                 |          1.668 ns |      0.0088 ns |      0.0083 ns |
    | LEN_12_NOT_only_digits             |          1.099 ns |      0.0062 ns |      0.0055 ns |
    | LEN_256_000_000_only_digits        | 10,137,419.231 ns | 77,538.9046 ns | 64,748.4971 ns |
    | LEN_256_000_000_NOT_only_digits    |  9,201,213.951 ns | 92,295.2075 ns | 81,817.2974 ns |
    • Строки в 12 символов за 1.1 — 1.7 нс

    • Строки в 256 миллионов символов — 10–11 мс

    public static bool IsAllDigits([NotNull] string str)
    {
        unsafe
        {
            fixed (char* begin = str)
            {
                char* ptr = begin;
                var charsInVector = Vector<ushort>.Count;
                if (str.Length >= charsInVector) //Если строка достаточно длинная для векторов
                {
                    //Вычисляем конец чтобы поместилось целое число векторов
                    char* endVector = begin + str.Length / charsInVector * charsInVector;
    
                    var min = new Vector<ushort>((ushort)'0'); //Вектор состоящий из '0'
                    var max = new Vector<ushort>((ushort)'9'); //Вектор состоящий из '9'
    
                    //Идея простая: берем пачки по N символов
                    //загоняем в диапазон от '0' до '9'
                    //и смотрим есть ли изменения - если есть, значит это были не цифры
                    while (ptr < endVector)
                    {
                        var v = Vector.Load((ushort*)ptr); //Загружаем 16 символов в регистр 
                        var vClamped = Vector.ClampNative(v, min, max); //Загоняем в диапазон от '0' до '9'
    
                        if (!Vector.EqualsAll(v, vClamped)) //Если что-то изменилось то там были не только цифры
                            return false;
    
                        ptr += charsInVector; //Идем дальше
                    }
                }
                char* end = begin + str.Length;
    
                //Если осталось хотя бы 8 символов - добиваем 128-битной инструкцией
                if (end - ptr >= Vector128<ushort>.Count)
                {
                    var min128 = Vector128.Create((ushort)'0');
                    var max128 = Vector128.Create((ushort)'9');
                    var v = Vector128.Load<ushort>((ushort*)ptr);
                    if (!Vector128.EqualsAll(Vector128.Clamp(v, min128, max128), v))
                        return false;
                    else
                        ptr += Vector128<ushort>.Count;
                }
               
                //Далее анролл по 4
                char* end4 = begin + (str.Length & ~3);
                while (ptr < end4)
                {
                    if (unchecked((uint)(*ptr - '0')) > 9u) return false; ptr++;
                    if (unchecked((uint)(*ptr - '0')) > 9u) return false; ptr++;
                    if (unchecked((uint)(*ptr - '0')) > 9u) return false; ptr++;
                    if (unchecked((uint)(*ptr - '0')) > 9u) return false; ptr++;
                }
    
                //Потом обычным циклом
                while (ptr < end)
                {
                    if (unchecked((uint)(*ptr - '0')) > 9u)
                        return false;
                    ptr++;
                }
            }
        }
        return true;
    }
    Использование AVX512 прироста не дало
    | Method                             | Mean              | Error          | StdDev         |
    |----------------------------------- |------------------:|---------------:|---------------:|
    | LEN_12_only_digits512              |          1.676 ns |      0.0078 ns |      0.0073 ns |
    | LEN_12_NOT_only_digits512          |          1.101 ns |      0.0063 ns |      0.0059 ns |
    | LEN_256_000_000_only_digits512     | 10,133,206.250 ns | 77,341.0857 ns | 68,560.8580 ns |
    | LEN_256_000_000_NOT_only_digits512 |  9,085,707.091 ns | 53,953.1981 ns | 45,053.3640 ns |
      public static bool IsAllDigits512([NotNull] string str)
      {
          unsafe
          {
              fixed (char* begin = str)
              {
                  char* ptr = begin;
                  var charsInVector512 = Vector512<ushort>.Count;
                  //Основная тушка с векторами
                  if (str.Length >= charsInVector512) //Если строка достаточно длинная для векторов
                  {
                      //Вычисляем конец чтобы поместилось целое число векторов
                      char* endVector512 = begin + str.Length / charsInVector512 * charsInVector512;
    
                      var min = Vector512.Create((ushort)'0'); //Вектор состоящий из '0'
                      var max = Vector512.Create((ushort)'9'); //Вектор состоящий из '9'
    
                      //Идея простая: берем пачки по N символов
                      //загоняем в диапазон от '0' до '9'
                      //и смотрим есть ли изменения - если есть, значит это были не цифры
                      while (ptr < endVector512)
                      {
                          var v = Vector512.Load((ushort*)ptr); //Загружаем 16 символов в регистр 
                          var vClamped = Vector512.ClampNative(v, min, max); //Загоняем в диапазон от '0' до '9'
    
                          if (!Vector512.EqualsAll(v, vClamped)) //Если что-то изменилось то там были не только цифры
                              return false;
    
                          ptr += charsInVector512; //Идем дальше
                      }
                  }
    
                  char* end = begin + str.Length;
    
                  //Если осталось хотя бы 8 символов - добиваем 128-битной инструкцией
                  if (end - ptr >= Vector128<ushort>.Count)
                  {
                      var min128 = Vector128.Create((ushort)'0');
                      var max128 = Vector128.Create((ushort)'9');
                      var v = Vector128.Load<ushort>((ushort*)ptr);
                      if (!Vector128.EqualsAll(Vector128.Clamp(v, min128, max128), v))
                          return false;
                      else
                          ptr += Vector128<ushort>.Count;
                  }
    
                  //Далее анролл по 4
                  char* end4 = begin + (str.Length & ~3);
                  while (ptr < end4)
                  {
                      if (unchecked((uint)(*ptr - '0')) > 9u) return false; ptr++;
                      if (unchecked((uint)(*ptr - '0')) > 9u) return false; ptr++;
                      if (unchecked((uint)(*ptr - '0')) > 9u) return false; ptr++;
                      if (unchecked((uint)(*ptr - '0')) > 9u) return false; ptr++;
                  }
    
                  //Потом обычным циклом
                  while (ptr < end)
                  {
                      if (unchecked((uint)(*ptr - '0')) > 9u)
                          return false;
                      ptr++;
                  }
              }
          }
          return true;
      }
    Код бенчмарка
     public class Benchmark
     {
         static int randomIndex = 0;
         static string generateBigRandomString(bool onlyDigits, int count)
         {
             if (count < 1024)
                 throw new NotSupportedException();
             var rnd = new Random(Interlocked.Increment(ref randomIndex));
             var b = new StringBuilder(count);
             var beginNoDigits = count / 10 * 9;
             for (int i = 0; i < count; i++)
                 b.Append((char)rnd.Next('0' + 0, '9' + 1));
    
             if (!onlyDigits)
             {
                 for (int i = 0; i < 10; i++)
                 {
                     var index = rnd.Next(beginNoDigits, count);
                     b[index] = (char)rnd.Next('a' + 0, 'z' + 1);
                 }
             }
             return b.ToString();
         }
         string small_only_digits, small;
         string big_only_digits, big;
         [GlobalSetup]
         public void Setup()
         {
             small_only_digits = "123456732890";
             small = "123456732a90";
             big_only_digits = generateBigRandomString(true, 256_000_000);
             big = generateBigRandomString(false, 256_000_000);
         }
    
         [Benchmark] public bool LEN_12_only_digits() => TurboIsDigitChecker.IsAllDigits(small_only_digits);
         [Benchmark] public bool LEN_12_NOT_only_digits() => TurboIsDigitChecker.IsAllDigits(small);
         [Benchmark] public bool LEN_256_000_000_only_digits() => TurboIsDigitChecker.IsAllDigits(big_only_digits);
         [Benchmark] public bool LEN_256_000_000_NOT_only_digits() => TurboIsDigitChecker.IsAllDigits(big);
    
         [Benchmark] public bool LEN_12_only_digits512() => TurboIsDigitChecker.IsAllDigits512(small_only_digits);
         [Benchmark] public bool LEN_12_NOT_only_digits512() => TurboIsDigitChecker.IsAllDigits512(small);
         [Benchmark] public bool LEN_256_000_000_only_digits512() => TurboIsDigitChecker.IsAllDigits512(big_only_digits);
         [Benchmark] public bool LEN_256_000_000_NOT_only_digits512() => TurboIsDigitChecker.IsAllDigits512(big);
     }

    Дальше можно в зависимости от фактического размера данных затачиваться:

    • Под короткие строки — тогда выкидываем тушу с векторами и делаем ступенчатую обработку (по типу блока с Vector128, который под тушкой векторов), то есть сначала один раз без цикла проверяем 512, потом 256, потом 128 бит, потом анролл, потом обычный цикл

    • Под длинные строки — тогда можно перед тушкой с векторами поставить проверку по 1 цифре, которая подравняет указатель памяти до кратности размеру вектора, и далее, в тушке векторов, делать не Load, а LoadAligned, что немного быстрее. Ну и можно поиграться со способом проверки — не Clamp/Equals, а какие‑нибудь хитрые трюки с масками