Avatar ·

Почему \d в C# работает медленнее, чем [0-9], и как это исправить?

Привет, коллеги! Я пишу парсер логов на C# с использованием регулярных выражений. Заметил странное поведение: когда я использую \d для поиска цифр, производительность падает в несколько раз по сравнению с [0-9]. Вот пример кода, который я тестирую: ```csharp using System; using System.Diagnostics; using System.Text.RegularExpressions; class Program { static void Main() { string input = string.Concat(Enumerable.Repeat("1234567890", 100000)); var sw = Stopwatch.StartNew(); for (int i = 0; i < 1000; i++) { Regex.Match(input, @"\d+"); } sw.Stop(); Console.WriteLine($"\\d: {sw.ElapsedMilliseconds} ms"); sw.Restart(); for (int i = 0; i < 1000; i++) { Regex.Match(input, @"[0-9]+"); } sw.Stop(); Console.WriteLine($"[0-9]: {sw.ElapsedMilliseconds} ms"); } } ``` Результаты меня удивили: \d работает на 30-40% медленнее! Я думал, что \d — это просто синтаксический сахар для [0-9]. Почему так происходит? Может, я что-то упускаю в настройках Regex? Или это особенность реализации в .NET? Буду благодарен за объяснение и рекомендации, как правильно выбирать между этими вариантами. <<>>

Почему \d медленнее [0-9] в C#?

Ключевая причина — различие в семантике этих токенов в .NET. Вопреки распространённому мнению, \d не эквивалентен [0-9] в C#. \d соответствует любой цифре из категории Юникода Nd (Number, Decimal Digit), которая включает не только арабские цифры 0-9, но и цифры из других систем счисления: арабо-индийские, восточно-арабские, некоторые китайские иероглифы и т.д. В то время как [0-9] — это строгий класс, ограниченный ASCII-символами от '0' до '9'.

Влияние на производительность

Разница в скорости обусловлена двумя факторами:

  • Проверка категории Юникода: Для \d движок Regex должен для каждого символа выполнить дополнительную операцию — проверить, принадлежит ли символ категории Nd. Это требует обращения к таблицам Юникода, что значительно дороже простого сравнения диапазона ASCII.
  • Оптимизация JIT: Класс [0-9] — это простой диапазон, который JIT-компилятор может легко оптимизировать в несколько ассемблерных инструкций. Для \d такая оптимизация невозможна из-за сложной логики проверки.

Как это исправить?

Есть несколько решений в зависимости от ваших потребностей:

1. Используйте [0-9] если работаете только с ASCII

Если вы точно знаете, что входные данные содержат только латинские цифры, используйте [0-9]. Это самый быстрый и предсказуемый вариант.

// Быстро и безопасно для ASCII
string pattern = @"[0-9]+";

2. Включите режим ECMAScript

В .NET можно переключить движок Regex в режим совместимости с ECMAScript. В этом режиме \d становится эквивалентен [0-9] и работает с той же скоростью.

// Включаем ECMAScript через опцию
var regex = new Regex(@"\d+", RegexOptions.ECMAScript);
// Или через inline-флаг (?-n)
var match = Regex.Match(input, @"(?n)\d+");

3. Используйте скомпилированные регулярные выражения

Для многократного использования одного и того же шаблона создавайте экземпляр Regex с опцией RegexOptions.Compiled. Это ускорит выполнение за счёт предварительной компиляции в MSIL.

// Компилируем регулярку один раз
var regex = new Regex(@"\d+", RegexOptions.Compiled);
for (int i = 0; i < 1000; i++)
{
    regex.Match(input);
}

Практический пример с замерами

Вот полный тест, демонстрирующий разницу:

using System;
using System.Diagnostics;
using System.Text.RegularExpressions;

class PerformanceTest
{
    static void Main()
    {
        string input = new string('9', 1000000);
        int iterations = 5000;
        
        // Тест 1: \d без опций
        var sw = Stopwatch.StartNew();
        for (int i = 0; i < iterations; i++)
            Regex.Match(input, @"\d+");
        Console.WriteLine($"\\d: {sw.ElapsedMilliseconds} ms");
        
        // Тест 2: [0-9]
        sw.Restart();
        for (int i = 0; i < iterations; i++)
            Regex.Match(input, @"[0-9]+");
        Console.WriteLine($"[0-9]: {sw.ElapsedMilliseconds} ms");
        
        // Тест 3: \d с ECMAScript
        sw.Restart();
        for (int i = 0; i < iterations; i++)
            Regex.Match(input, @"\d+", RegexOptions.ECMAScript);
        Console.WriteLine($"\\d (ECMAScript): {sw.ElapsedMilliseconds} ms");
        
        // Тест 4: скомпилированный \d
        var compiledRegex = new Regex(@"\d+", RegexOptions.Compiled);
        sw.Restart();
        for (int i = 0; i < iterations; i++)
            compiledRegex.Match(input);
        Console.WriteLine($"\\d (Compiled): {sw.ElapsedMilliseconds} ms");
    }
}

Итоговая рекомендация

  • Если данные гарантированно ASCII — используйте [0-9]. Это самый быстрый вариант.
  • Если нужна поддержка Юникода — используйте \d с опцией RegexOptions.Compiled для многократного использования.
  • Если нужна максимальная скорость и совместимость с другими языками — включите RegexOptions.ECMAScript, но помните, что это отключает некоторые возможности .NET Regex.

Всегда профилируйте свой конкретный сценарий, так как разница может варьироваться в зависимости от размера входных данных и сложности шаблона.

<<>>

Альтернативный подход: использование Span и ручной парсинг

Если производительность критична и вы работаете с большими объёмами данных, рассмотрите возможность отказа от регулярных выражений в пользу ручного парсинга с использованием ReadOnlySpan и MemoryExtensions. Это может дать выигрыш в 5-10 раз по сравнению с любым вариантом Regex.

using System;

public static class DigitParser
{
    public static ReadOnlySpan ExtractDigits(ReadOnlySpan input)
    {
        int start = -1;
        for (int i = 0; i < input.Length; i++)
        {
            if (input[i] >= '0' && input[i] <= '9')
            {
                if (start == -1) start = i;
            }
            else if (start != -1)
            {
                return input.Slice(start, i - start);
            }
        }
        return start == -1 ? ReadOnlySpan.Empty : input.Slice(start);
    }
}

// Использование:
string data = "abc123def";
var digits = DigitParser.ExtractDigits(data.AsSpan());
Console.WriteLine(digits.ToString()); // "123"

Этот подход не только быстрее, но и позволяет избежать накладных расходов на создание объектов Match и Group. Однако он требует больше кода и менее гибок для сложных шаблонов. Используйте его для простых задач извлечения цифр, где скорость важнее универсальности.