Roslyn-анализатор для преобразования кода из Regex в новый Regex Source Generator

от автора

Source generators (генераторы исходного кода) — это часть платформы Roslyn, которая появилась в .NET 5. Они позволяют анализировать существующий код и создавать новые файлы с исходным кодом, которые в свою очередь могут добавляться в процесс компиляции.

В .NET 7 появилась новая функиональность для регулярных выражений, которая позволяет генерировать исходный код для проверки регулярного выражения во время компиляции с помощью специального source generator. Генерация исходного кода во время компиляции, а не во время выполнения, имеет несколько преимуществ:

  • Ускоряется первый вызов regex — потому что для него не нужно анализировать регулярное выражение и генерировать код для его выполнения в рантайме.

  • Во время компиляции можно потратить больше времени на оптимизацию кода регулярного выражения, поэтому код максимально оптимизирован. Сейчас (в .NET 7 Preview 3) при использовании regex source generator результирующий код совпадает с тем, который генерируется для регулярных выражений с флагом RegexOptions.Compiled, но в будущем это поведение может измениться.

  • Для платформ, которые не позволяют генерировать код в рантайме, таких как iOS, можно добиться максимальной производительности регулярных выражений.

  • Исходный код становится более читаемым в сравнении с использованием Regex.IsMatch(value, pattern) потому что метод проверки выражения будет иметь осмысленное понятное имя.

  • Сгенерированный код содержит комментарии, которые описывают, чему соответствует регулярное выражение. Это поможет понять и лучше разобраться, что делает regex, даже если вы не знаете какую-то часть синтаксиса регулярных выражений.

  • В случае self-contained application, когда .net runtime и библиотеки упаковываются в результирующее приложение, упаковка получится более компактной потому что не будет содержать кода для парсинга регулярных выражений и генерации кода для них.

  • Можно дебажить код при необходимости!

  • Можно узнать о хороших приемах оптимизации, читая сгенерированный код (но об этом будет в самом конце статьи).

Для генерации кода все параметры регулярного выражения (regex pattern, опции и таймаут) должны быть константными.

public static bool IsLowercase(string value) {     // ✔️ pattern задан константой     // => Регулярное выражение может быть преобразовано в использование source generator     var lowercaseLettersRegex = new Regex("[a-z]+");     return lowercaseLettersRegex.IsMatch("abc"); }  public static bool IsLowercase(string value) {     // ✔️ pattern, опции и таймаут заданы константой     // => Регулярное выражение может быть преобразовано в использование source generator     return Regex.IsMatch(value, "[a-z]+", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1)); }  public static bool Match(string value, string pattern) {     // ❌ pattern неизвестен на этапе компиляции и задается параметром      // => Невозможно использовать source generator     return Regex.IsMatch(value, pattern); }

Чтобы конвертировать регулярное выражение в применение source generator вам нужно создать вместо него partial-метод, помеченный атрибутом [RegexGenerator]. Тип, в котором используется регулярное выражение тоже нужно будет пометить как partial:

// Source Generator сгенерирует код метода во время компиляции [RegexGenerator("^[a-z]+$", RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 1000)] private static partial Regex LowercaseLettersRegex();  public static bool IsLowercase(string value) {     return LowercaseLettersRegex().IsMatch(value); }

Сгенерированный код можно посмотреть в partial-классе через Solution explorer или перейти к нему командой «Go to definition»:

Автоматическое преобразование Regex в Source Generator

NuGet-пакет Meziantou.Analyzer содержит анализатор для поиска регулярных выражений, которые могут быть преобразованы в Source Generator, и позволяет легко конвертировать существующие Regex в partail-метод с аннотацией [RegexGenerator]. Достаточно добавить пакет в проект:

dotnet add package Meziantou.Analyzer

Правило MA0110 сообщит о всех регулярных выражениях, для которых можно сгенерировать код на этапе компиляции. Анализатор предоставляет действие для преобразования кода (code fix) из Regex в генератор.

Статус поддержки

  • Использовать regex source generator и Meziantou.Analyzer можно с .NET 7 (начиная с Preview 1) и C# 11.

  • Rider частично поддерживает C# 11 с версии 2022.1 EAP — код при компиляции генерируется, к нему можно перейти через Go to definition, но сгенерированный файл не отображается в дереве решений.

  •  Visual Studio 17.2 Preview 1 и более поздние версии поддерживают .NET 7 и C# 11.

Ложка дёгтя — пример сгенерированного кода

По описанию regex source generator из исходной статьи — это отличная фича не только для производительности и уменьшения размера self-contained application, но и для упрощения чтения и дебага сложных регулярных выражений. На сколько же лаконичным получается сгенерированный код? Давайте посмотрим на примере поиска номера телефона в строке:

[RegexGenerator(@"(\+7|7|8)?[\s\-]?\(?[489][0-9]{2}\)?[\s\-]?[0-9]{3}[\s\-]?[0-9]{2}[\s\-]?[0-9]{2}"] private partial Regex RussianPhoneNumberRegex();  public string? FindPhoneNumber(string text) {     var match = RussianPhoneNumberRegex().Match(text);     return match.Success ? match.Value : null; }

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

362 строки сгенерированного кода регулярного выражения
// <auto-generated/> #nullable enable #pragma warning disable CS0162 // Unreachable code #pragma warning disable CS0164 // Unreferenced label #pragma warning disable CS0219 // Variable assigned but never used  namespace RegexGeneratorExample {     partial class RegexContainer     {         [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Text.RegularExpressions.Generator", "7.0.6.17504")]         private partial global::System.Text.RegularExpressions.Regex RussianPhoneNumberRegex() => global::System.Text.RegularExpressions.Generated.__2b701bf8.RussianPhoneNumberRegex_0.Instance;     } }  namespace System.Text.RegularExpressions.Generated {     using System;     using System.CodeDom.Compiler;     using System.Collections;     using System.ComponentModel;     using System.Globalization;     using System.Runtime.CompilerServices;     using System.Text.RegularExpressions;     using System.Threading;      [GeneratedCodeAttribute("System.Text.RegularExpressions.Generator", "7.0.6.17504")]     [EditorBrowsable(EditorBrowsableState.Never)]     internal static class __2b701bf8     {         /// <summary>Custom <see cref="Regex"/>-derived type for the RussianPhoneNumberRegex method.</summary>         internal sealed class RussianPhoneNumberRegex_0 : Regex         {             /// <summary>Cached, thread-safe singleton instance.</summary>             internal static readonly RussianPhoneNumberRegex_0 Instance = new();                      /// <summary>Initializes the instance.</summary>             private RussianPhoneNumberRegex_0()             {                 base.pattern = "(\\+7|7|8)?[\\s\\-]?\\(?[489][0-9]{2}\\)?[\\s\\-]?[0-9]{3}[\\s\\-]?[0-9]{2}[\\s\\-]?[0-9]{2}";                 base.roptions = RegexOptions.CultureInvariant;                 base.internalMatchTimeout = TimeSpan.FromMilliseconds(1000);                 base.factory = new RunnerFactory();                 base.capsize = 2;             }                      /// <summary>Provides a factory for creating <see cref="RegexRunner"/> instances to be used by methods on <see cref="Regex"/>.</summary>             private sealed class RunnerFactory : RegexRunnerFactory             {                 /// <summary>Creates an instance of a <see cref="RegexRunner"/> used by methods on <see cref="Regex"/>.</summary>                 protected override RegexRunner CreateInstance() => new Runner();                              /// <summary>Provides the runner that contains the custom logic implementing the specified regular expression.</summary>                 private sealed class Runner : RegexRunner                 {                     // Description:                     // ○ Optional (greedy).                     //     ○ 1st capture group.                     //         ○ Match with 2 alternative expressions.                     //             ○ Match the string "+7".                     //             ○ Match a character in the set [78].                     // ○ Match a character in the set [-\s] atomically, optionally.                     // ○ Match '(' atomically, optionally.                     // ○ Match a character in the set [489].                     // ○ Match '0' through '9' exactly 2 times.                     // ○ Match ')' atomically, optionally.                     // ○ Match a character in the set [-\s] atomically, optionally.                     // ○ Match '0' through '9' exactly 3 times.                     // ○ Match a character in the set [-\s] atomically, optionally.                     // ○ Match '0' through '9' exactly 2 times.                     // ○ Match a character in the set [-\s] atomically, optionally.                     // ○ Match '0' through '9' exactly 2 times.                                  /// <summary>Scan the <paramref name="inputSpan"/> starting from base.runtextstart for the next match.</summary>                     /// <param name="inputSpan">The text being scanned by the regular expression.</param>                     protected override void Scan(ReadOnlySpan<char> inputSpan)                     {                         // Search until we can't find a valid starting position, we find a match, or we reach the end of the input.                         while (TryFindNextPossibleStartingPosition(inputSpan))                         {                             base.CheckTimeout();                             if (TryMatchAtCurrentPosition(inputSpan) || base.runtextpos == inputSpan.Length)                             {                                 return;                             }                                                          base.runtextpos++;                         }                     }                                  /// <summary>Search <paramref name="inputSpan"/> starting from base.runtextpos for the next location a match could possibly start.</summary>                     /// <param name="inputSpan">The text being scanned by the regular expression.</param>                     /// <returns>true if a possible match was found; false if no more matches are possible.</returns>                     private bool TryFindNextPossibleStartingPosition(ReadOnlySpan<char> inputSpan)                     {                         int pos = base.runtextpos;                         char ch;                                                  // Validate that enough room remains in the input to match.                         // Any possible match is at least 10 characters.                         if (pos <= inputSpan.Length - 10)                         {                             // The pattern begins with a character in the set [(+-47-9\s].                             // Find the next occurrence. If it can't be found, there's no match.                             ReadOnlySpan<char> span = inputSpan.Slice(pos);                             for (int i = 0; i < span.Length; i++)                             {                                 if (((ch = span[i]) < 128 ? ("㸀\0⤁ΐ\0\0\0\0"[ch >> 4] & (1 << (ch & 0xF))) != 0 : RegexRunner.CharInClass((char)ch, "\0\n\u0001()+,-.457:d")))                                 {                                     base.runtextpos = pos + i;                                     return true;                                 }                             }                         }                                                  // No match found.                         base.runtextpos = inputSpan.Length;                         return false;                     }                                  /// <summary>Determine whether <paramref name="inputSpan"/> at base.runtextpos is a match for the regular expression.</summary>                     /// <param name="inputSpan">The text being scanned by the regular expression.</param>                     /// <returns>true if the regular expression matches at the current position; otherwise, false.</returns>                     private bool TryMatchAtCurrentPosition(ReadOnlySpan<char> inputSpan)                     {                         int pos = base.runtextpos;                         int matchStart = pos;                         int loopTimeoutCounter = 0;                         char ch;                         int loop_iteration = 0, loop_starting_pos = 0;                         int stackpos = 0;                         ReadOnlySpan<char> slice = inputSpan.Slice(pos);                                                  // Optional (greedy).                         //{                             loop_iteration = 0;                             loop_starting_pos = pos;                                                          LoopBody:                             if (++loopTimeoutCounter == 2048)                             {                                 loopTimeoutCounter = 0;                                 base.CheckTimeout();                             }                                                          Utilities.StackPush3(ref base.runstack!, ref stackpos, base.Crawlpos(), loop_starting_pos, pos);                                                          loop_starting_pos = pos;                             loop_iteration++;                                                          // 1st capture group.                             //{                                 int capture_starting_pos = pos;                                                                  // Match with 2 alternative expressions.                                 //{                                     if (slice.IsEmpty)                                     {                                         goto LoopIterationNoMatch;                                     }                                                                          switch (slice[0])                                     {                                         case '+':                                             // Match '7'.                                             if ((uint)slice.Length < 2 || slice[1] != '7')                                             {                                                 goto LoopIterationNoMatch;                                             }                                                                                          pos += 2;                                             slice = inputSpan.Slice(pos);                                             break;                                                                                      case '7' or '8':                                             pos++;                                             slice = inputSpan.Slice(pos);                                             break;                                                                                      default:                                             goto LoopIterationNoMatch;                                     }                                 //}                                                                  base.Capture(1, capture_starting_pos, pos);                             //}                                                          if (pos != loop_starting_pos && loop_iteration == 0)                             {                                 goto LoopBody;                             }                             goto LoopEnd;                                                          LoopIterationNoMatch:                             loop_iteration--;                             if (loop_iteration < 0)                             {                                 UncaptureUntil(0);                                 return false; // The input didn't match.                             }                             Utilities.StackPop2(base.runstack, ref stackpos, out pos, out loop_starting_pos);                             UncaptureUntil(base.runstack![--stackpos]);                             slice = inputSpan.Slice(pos);                             LoopEnd:;                         //}                                                  // Match a character in the set [-\s] atomically, optionally.                         {                             if (!slice.IsEmpty && ((ch = slice[0]) < 128 ? ("㸀\0 \0\0\0\0\0"[ch >> 4] & (1 << (ch & 0xF))) != 0 : RegexRunner.CharInClass((char)ch, "\0\u0002\u0001-.d")))                             {                                 slice = slice.Slice(1);                                 pos++;                             }                         }                                                  // Match '(' atomically, optionally.                         {                             if (!slice.IsEmpty && slice[0] == '(')                             {                                 slice = slice.Slice(1);                                 pos++;                             }                         }                                                  if ((uint)slice.Length < 3 ||                             (((ch = slice[0]) != '4') & (ch != '8') & (ch != '9')) || // Match a character in the set [489].                             (((uint)slice[1]) - '0' > (uint)('9' - '0')) || // Match '0' through '9' exactly 2 times.                             (((uint)slice[2]) - '0' > (uint)('9' - '0')))                         {                             goto LoopIterationNoMatch;                         }                                                  // Match ')' atomically, optionally.                         {                             if ((uint)slice.Length > (uint)3 && slice[3] == ')')                             {                                 slice = slice.Slice(1);                                 pos++;                             }                         }                                                  // Match a character in the set [-\s] atomically, optionally.                         {                             if ((uint)slice.Length > (uint)3 && ((ch = slice[3]) < 128 ? ("㸀\0 \0\0\0\0\0"[ch >> 4] & (1 << (ch & 0xF))) != 0 : RegexRunner.CharInClass((char)ch, "\0\u0002\u0001-.d")))                             {                                 slice = slice.Slice(1);                                 pos++;                             }                         }                                                  // Match '0' through '9' exactly 3 times.                         {                             if ((uint)slice.Length < 6 ||                                 (((uint)slice[3]) - '0' > (uint)('9' - '0')) ||                                 (((uint)slice[4]) - '0' > (uint)('9' - '0')) ||                                 (((uint)slice[5]) - '0' > (uint)('9' - '0')))                             {                                 goto LoopIterationNoMatch;                             }                         }                                                  // Match a character in the set [-\s] atomically, optionally.                         {                             if ((uint)slice.Length > (uint)6 && ((ch = slice[6]) < 128 ? ("㸀\0 \0\0\0\0\0"[ch >> 4] & (1 << (ch & 0xF))) != 0 : RegexRunner.CharInClass((char)ch, "\0\u0002\u0001-.d")))                             {                                 slice = slice.Slice(1);                                 pos++;                             }                         }                                                  // Match '0' through '9' exactly 2 times.                         {                             if ((uint)slice.Length < 8 ||                                 (((uint)slice[6]) - '0' > (uint)('9' - '0')) ||                                 (((uint)slice[7]) - '0' > (uint)('9' - '0')))                             {                                 goto LoopIterationNoMatch;                             }                         }                                                  // Match a character in the set [-\s] atomically, optionally.                         {                             if ((uint)slice.Length > (uint)8 && ((ch = slice[8]) < 128 ? ("㸀\0 \0\0\0\0\0"[ch >> 4] & (1 << (ch & 0xF))) != 0 : RegexRunner.CharInClass((char)ch, "\0\u0002\u0001-.d")))                             {                                 slice = slice.Slice(1);                                 pos++;                             }                         }                                                  // Match '0' through '9' exactly 2 times.                         {                             if ((uint)slice.Length < 10 ||                                 (((uint)slice[8]) - '0' > (uint)('9' - '0')) ||                                 (((uint)slice[9]) - '0' > (uint)('9' - '0')))                             {                                 goto LoopIterationNoMatch;                             }                         }                                                  // The input matched.                         pos += 10;                         base.runtextpos = pos;                         base.Capture(0, matchStart, pos);                         return true;                                                  // <summary>Undo captures until it reaches the specified capture position.</summary>                         [MethodImpl(MethodImplOptions.AggressiveInlining)]                         void UncaptureUntil(int capturePosition)                         {                             while (base.Crawlpos() > capturePosition)                             {                                 base.Uncapture();                             }                         }                     }                 }             }          }                  private static class Utilities         {             // <summary>Pushes 3 values onto the backtracking stack.</summary>             [MethodImpl(MethodImplOptions.AggressiveInlining)]             internal static void StackPush3(ref int[] stack, ref int pos, int arg0, int arg1, int arg2)             {                 // If there's space available for all 3 values, store them.                 int[] s = stack;                 int p = pos;                 if ((uint)(p + 2) < (uint)s.Length)                 {                     s[p] = arg0;                     s[p + 1] = arg1;                     s[p + 2] = arg2;                     pos += 3;                     return;                 }                              // Otherwise, resize the stack to make room and try again.                 WithResize(ref stack, ref pos, arg0, arg1, arg2);                              // <summary>Resize the backtracking stack array and push 3 values onto the stack.</summary>                 [MethodImpl(MethodImplOptions.NoInlining)]                 static void WithResize(ref int[] stack, ref int pos, int arg0, int arg1, int arg2)                 {                     Array.Resize(ref stack, (pos + 2) * 2);                     StackPush3(ref stack, ref pos, arg0, arg1, arg2);                 }             }                          // <summary>Pops 2 values from the backtracking stack.</summary>             [MethodImpl(MethodImplOptions.AggressiveInlining)]             internal static void StackPop2(int[] stack, ref int pos, out int arg0, out int arg1)             {                 arg0 = stack[--pos];                 arg1 = stack[--pos];             }                      }     } } 


ссылка на оригинал статьи https://habr.com/ru/post/661089/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *