Строка из чисел

Published on Saturday 7 June 2025

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

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 & 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);

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

Код бенчмарка. Нажмите, чтобы развернуть.
[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

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

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

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

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

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 ставится для методов, которые не существуют в данной версии фреймворка.