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];
}
}
}
}