Generatory źródeł wyrażeń regularnych platformy .NET
Wyrażenie regularne lub wyrażenie regularne to ciąg, który umożliwia deweloperowi wyrażenie szukanego wzorca, dzięki czemu jest to typowy sposób wyszukiwania tekstu i wyodrębniania wyników jako podzestawu z wyszukiwanego ciągu. Na platformie .NET System.Text.RegularExpressions
przestrzeń nazw służy do definiowania Regex wystąpień i metod statycznych oraz dopasowywania ich do wzorców zdefiniowanych przez użytkownika. W tym artykule dowiesz się, jak używać generowania źródła do generowania Regex
wystąpień w celu optymalizacji wydajności.
Uwaga
Jeśli to możliwe, użyj wyrażeń regularnych generowanych przez źródło zamiast kompilowania wyrażeń regularnych przy użyciu RegexOptions.Compiled opcji . Generowanie źródła może pomóc aplikacji szybciej rozpocząć pracę, szybciej uruchomić i być bardziej przycinane. Aby dowiedzieć się, kiedy jest możliwe generowanie źródła, zobacz Kiedy go używać.
Skompilowane wyrażenia regularne
Gdy piszesz new Regex("somepattern")
, zdarza się kilka rzeczy. Określony wzorzec jest analizowany, zarówno w celu zapewnienia ważności wzorca, jak i przekształcenia go w drzewo wewnętrzne reprezentujące przeanalizowane wyrażenie regularne. Drzewo jest następnie optymalizowane na różne sposoby, przekształcając wzorzec w równoważną funkcjonalnie odmianę, która może być wydajniej wykonywana. Drzewo jest zapisywane w formularzu, który może być interpretowany jako seria operacji i operandów, które dostarczają instrukcje dla aparatu interpretera wyrażeń regularnych na temat sposobu dopasowania. Po wykonaniu dopasowania interpreter po prostu przechodzi przez te instrukcje, przetwarzając je względem tekstu wejściowego. Podczas tworzenia wystąpienia nowego Regex
wystąpienia lub wywoływania jednej z metod statycznych w interpreterze Regex
jest używany domyślny aparat.
Po określeniu parametru RegexOptions.Compiledwszystkie te same prace budowlane są wykonywane. Wynikowe instrukcje są dalej przekształcane przez kompilator oparty na odbiciu do instrukcji IL, które są zapisywane w kilku DynamicMethod obiektach. Po wykonaniu dopasowania te DynamicMethod
metody są wywoływane. To IL zasadniczo robi dokładnie to, co zrobiłby interpreter, z wyjątkiem wyspecjalizowanego do dokładnego przetwarzania wzorca. Jeśli na przykład wzorzec zawiera [ac]
, interpreter zobaczy kod opcode z informacją "dopasuj znak wejściowy w bieżącej pozycji względem zestawu określonego w tym opisie zestawu". Podczas gdy skompilowany il będzie zawierać kod, który skutecznie mówi: "dopasuj znak wejściowy w bieżącej pozycji względem 'a'
lub 'c'
". Ta specjalna wielkość liter i możliwość przeprowadzania optymalizacji na podstawie wiedzy o wzorcu są jednymi z głównych powodów, dla których określenie zapewnia znacznie szybsze dopasowywanie RegexOptions.Compiled
przepływności niż interpreter.
Istnieje kilka wad do RegexOptions.Compiled
. Najbardziej wpływ ma to na to, że jest kosztowna konstrukcja. Nie tylko wszystkie te same koszty są wypłacane jako interpreter, ale następnie muszą skompilować wynikowe RegexNode
drzewo i wygenerować kody operacyjne/operandy do IL, co dodaje nietrywialne wydatki. Wygenerowany il musi być dodatkowo kompilowany w trybie JIT przy pierwszym użyciu, co prowadzi do jeszcze większego kosztu podczas uruchamiania. RegexOptions.Compiled
reprezentuje podstawową kompromis między narzutami na pierwsze użycie i narzutami na każde kolejne użycie. Zastosowanie System.Reflection.Emit tej metody hamuje również użycie RegexOptions.Compiled
w niektórych środowiskach. Niektóre systemy operacyjne nie zezwalają na dynamiczne generowanie kodu do wykonania, a w takich systemach Compiled
staje się nieoperacyjny.
Generowanie źródła
Platforma .NET 7 wprowadziła nowy RegexGenerator
generator źródeł. Generator źródła to składnik, który podłącza się do kompilatora i rozszerza jednostkę kompilacji przy użyciu dodatkowego kodu źródłowego. Zestaw .NET SDK (wersja 7 lub nowsza) zawiera generator źródła, który rozpoznaje GeneratedRegexAttribute atrybut w metodzie częściowej, która zwraca Regex
wartość . Generator źródła udostępnia implementację tej metody, która zawiera całą logikę dla klasy Regex
. Na przykład wcześniej można było napisać kod podobny do następującego:
private static readonly Regex s_abcOrDefGeneratedRegex =
new(pattern: "abc|def",
options: RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static void EvaluateText(string text)
{
if (s_abcOrDefGeneratedRegex.IsMatch(text))
{
// Take action with matching text
}
}
Aby użyć generatora źródłowego, zapisz ponownie poprzedni kod w następujący sposób:
[GeneratedRegex("abc|def", RegexOptions.IgnoreCase, "en-US")]
private static partial Regex AbcOrDefGeneratedRegex();
private static void EvaluateText(string text)
{
if (AbcOrDefGeneratedRegex().IsMatch(text))
{
// Take action with matching text
}
}
Napiwek
Flaga RegexOptions.Compiled
jest ignorowana przez generator źródła, dlatego nie jest potrzebna w wersji wygenerowanej przez źródło.
Wygenerowana implementacja AbcOrDefGeneratedRegex()
podobnie buforuje pojedyncze Regex
wystąpienie, więc do użycia kodu nie jest potrzebne żadne dodatkowe buforowanie.
Na poniższej ilustracji przedstawiono przechwytywanie ekranu wystąpienia wygenerowanego w pamięci podręcznej źródła do Regex
podklasy emitowanej internal
przez generator źródłowy:
Ale jak widać, to nie tylko robi new Regex(...)
. Zamiast tego generator źródła emituje jako kod C# niestandardową Regex
implementację pochodną z logiką podobną do tego, co RegexOptions.Compiled
emituje w il. Uzyskasz wszystkie korzyści z RegexOptions.Compiled
wydajności przepływności (w rzeczywistości więcej) i korzyści Regex.CompileToAssembly
z uruchamiania programu , ale bez złożoności programu CompileToAssembly
. Źródło, które jest emitowane, jest częścią projektu, co oznacza, że można go również łatwo wyświetlać i debugować.
Napiwek
W programie Visual Studio kliknij prawym przyciskiem myszy deklarację metody częściowej i wybierz polecenie Przejdź do definicji. Alternatywnie wybierz węzeł projektu w Eksplorator rozwiązań, a następnie rozwiń węzeł Dependencies>Analyzers>System.Text.RegularExpressions.Generator>System.Text.RegularExpressions.Generator.Generator.RegexGenerator>RegexGenerator.g.cs, aby wyświetlić wygenerowany kod języka C# z tego generatora wyrażeń regularnych.
Możesz ustawić w nim punkty przerwania, możesz przejść przez nie i użyć go jako narzędzia szkoleniowego, aby dokładnie zrozumieć, jak aparat wyrażeń regularnych przetwarza wzorzec z danymi wejściowymi. Generator generuje nawet komentarze potrójnego ukośnika (XML), aby ułatwić zrozumienie wyrażenia w skrócie i miejsce jego użycia.
Wewnątrz plików wygenerowanych przez źródło
W przypadku platformy .NET 7 zarówno generator źródła, jak i RegexCompiler
prawie całkowicie przepisano, zasadniczo zmieniając strukturę wygenerowanego kodu. To podejście zostało rozszerzone w celu obsługi wszystkich konstrukcji (z jednym zastrzeżeniem), a zarówno RegexCompiler
generator źródła, jak i generator źródła nadal mapuje przede wszystkim 1:1 ze sobą, postępując zgodnie z nowym podejściem. Rozważ wyjście generatora źródłowego dla jednej z podstawowych funkcji z abc|def
wyrażenia:
private bool TryMatchAtCurrentPosition(ReadOnlySpan<char> inputSpan)
{
int pos = base.runtextpos;
int matchStart = pos;
ReadOnlySpan<char> slice = inputSpan.Slice(pos);
// Match with 2 alternative expressions, atomically.
{
if (slice.IsEmpty)
{
return false; // The input didn't match.
}
switch (slice[0])
{
case 'A' or 'a':
if ((uint)slice.Length < 3 ||
!slice.Slice(1).StartsWith("bc", StringComparison.OrdinalIgnoreCase)) // Match the string "bc" (ordinal case-insensitive)
{
return false; // The input didn't match.
}
pos += 3;
slice = inputSpan.Slice(pos);
break;
case 'D' or 'd':
if ((uint)slice.Length < 3 ||
!slice.Slice(1).StartsWith("ef", StringComparison.OrdinalIgnoreCase)) // Match the string "ef" (ordinal case-insensitive)
{
return false; // The input didn't match.
}
pos += 3;
slice = inputSpan.Slice(pos);
break;
default:
return false; // The input didn't match.
}
}
// The input matched.
base.runtextpos = pos;
base.Capture(0, matchStart, pos);
return true;
}
Celem kodu generowanego przez źródło jest zrozumienie, z łatwą do naśladowania strukturą, z komentarzami wyjaśnianymi, co robi się w każdym kroku, a ogólnie z kodem emitowanym zgodnie z zasadą przewodnią, że generator powinien emitować kod tak, jakby człowiek go napisał. Nawet w przypadku wystąpienia wycofywania struktura wycofywania staje się częścią struktury kodu, a nie polegania na stosie, aby wskazać, gdzie przejść dalej. Na przykład oto kod dla tej samej wygenerowanej funkcji dopasowania, gdy wyrażenie to [ab]*[bc]
:
private bool TryMatchAtCurrentPosition(ReadOnlySpan<char> inputSpan)
{
int pos = base.runtextpos;
int matchStart = pos;
int charloop_starting_pos = 0, charloop_ending_pos = 0;
ReadOnlySpan<char> slice = inputSpan.Slice(pos);
// Match a character in the set [ABab] greedily any number of times.
//{
charloop_starting_pos = pos;
int iteration = slice.IndexOfAnyExcept(Utilities.s_ascii_600000006000000);
if (iteration < 0)
{
iteration = slice.Length;
}
slice = slice.Slice(iteration);
pos += iteration;
charloop_ending_pos = pos;
goto CharLoopEnd;
CharLoopBacktrack:
if (Utilities.s_hasTimeout)
{
base.CheckTimeout();
}
if (charloop_starting_pos >= charloop_ending_pos ||
(charloop_ending_pos = inputSpan.Slice(charloop_starting_pos, charloop_ending_pos - charloop_starting_pos).LastIndexOfAny(Utilities.s_ascii_C0000000C000000)) < 0)
{
return false; // The input didn't match.
}
charloop_ending_pos += charloop_starting_pos;
pos = charloop_ending_pos;
slice = inputSpan.Slice(pos);
CharLoopEnd:
//}
// Advance the next matching position.
if (base.runtextpos < pos)
{
base.runtextpos = pos;
}
// Match a character in the set [BCbc].
if (slice.IsEmpty || ((uint)((slice[0] | 0x20) - 'b') > (uint)('c' - 'b')))
{
goto CharLoopBacktrack;
}
// The input matched.
pos++;
base.runtextpos = pos;
base.Capture(0, matchStart, pos);
return true;
}
Możesz zobaczyć strukturę wycofywania w kodzie z etykietą emitowaną CharLoopBacktrack
dla miejsca powrotu do i użytą goto
do przejścia do tej lokalizacji, gdy kolejna część wyrażenia regularnego zakończy się niepowodzeniem.
Jeśli przyjrzysz się implementacji RegexCompiler
kodu i generatorowi źródła, będą one wyglądać bardzo podobnie: podobnie nazwane metody, podobną strukturę wywołań, a nawet podobne komentarze w całej implementacji. W większości przypadków powodują one identyczny kod, choć jeden w IL i jeden w języku C#. Oczywiście kompilator języka C# jest wówczas odpowiedzialny za tłumaczenie języka C# na IL, więc wynikowe il w obu przypadkach prawdopodobnie nie będą identyczne. Generator źródła opiera się na tym w różnych przypadkach, korzystając z faktu, że kompilator języka C# będzie dodatkowo optymalizować różne konstrukcje języka C#. Istnieje kilka konkretnych rzeczy, które generator źródła generuje w ten sposób bardziej zoptymalizowany kod pasujący niż .RegexCompiler
Na przykład w jednym z poprzednich przykładów można zobaczyć generator źródła emitujący instrukcję switch z jedną gałęzią dla 'a'
i inną gałęzią dla 'b'
elementu . Ponieważ kompilator języka C# jest bardzo dobry w optymalizowaniu instrukcji przełącznika, z wieloma strategiami umożliwiającymi efektywne wykonywanie tych czynności, generator źródła ma specjalną optymalizację, która RegexCompiler
nie jest. W przypadku przemienności generator źródła analizuje wszystkie gałęzie i jeśli może udowodnić, że każda gałąź zaczyna się od innego znaku początkowego, emituje instrukcję switch dla tego pierwszego znaku i unika wyprowadzania dowolnego kodu wycofywania dla tej zmiany.
Oto nieco bardziej skomplikowany przykład tego. Zmiany są bardziej intensywnie analizowane w celu określenia, czy można je refaktoryzować w sposób, który ułatwi ich optymalizację przez aparaty wycofywania i doprowadzi to do prostszego kodu generowanego przez źródło. Jedna z takich optymalizacji obsługuje wyodrębnianie typowych prefiksów z gałęzi, a jeśli zmiana jest niepodzielna, tak aby kolejność nie ma znaczenia, zmień kolejność gałęzi, aby umożliwić bardziej takie wyodrębnianie. Możesz zobaczyć wpływ tego dla następującego wzorca Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday
dni powszedniego, który generuje zgodną funkcję w następujący sposób:
private bool TryMatchAtCurrentPosition(ReadOnlySpan<char> inputSpan)
{
int pos = base.runtextpos;
int matchStart = pos;
char ch;
ReadOnlySpan<char> slice = inputSpan.Slice(pos);
// Match with 6 alternative expressions, atomically.
{
int alternation_starting_pos = pos;
// Branch 0
{
if ((uint)slice.Length < 6 ||
!slice.StartsWith("monday", StringComparison.OrdinalIgnoreCase)) // Match the string "monday" (ordinal case-insensitive)
{
goto AlternationBranch;
}
pos += 6;
slice = inputSpan.Slice(pos);
goto AlternationMatch;
AlternationBranch:
pos = alternation_starting_pos;
slice = inputSpan.Slice(pos);
}
// Branch 1
{
if ((uint)slice.Length < 7 ||
!slice.StartsWith("tuesday", StringComparison.OrdinalIgnoreCase)) // Match the string "tuesday" (ordinal case-insensitive)
{
goto AlternationBranch1;
}
pos += 7;
slice = inputSpan.Slice(pos);
goto AlternationMatch;
AlternationBranch1:
pos = alternation_starting_pos;
slice = inputSpan.Slice(pos);
}
// Branch 2
{
if ((uint)slice.Length < 9 ||
!slice.StartsWith("wednesday", StringComparison.OrdinalIgnoreCase)) // Match the string "wednesday" (ordinal case-insensitive)
{
goto AlternationBranch2;
}
pos += 9;
slice = inputSpan.Slice(pos);
goto AlternationMatch;
AlternationBranch2:
pos = alternation_starting_pos;
slice = inputSpan.Slice(pos);
}
// Branch 3
{
if ((uint)slice.Length < 8 ||
!slice.StartsWith("thursday", StringComparison.OrdinalIgnoreCase)) // Match the string "thursday" (ordinal case-insensitive)
{
goto AlternationBranch3;
}
pos += 8;
slice = inputSpan.Slice(pos);
goto AlternationMatch;
AlternationBranch3:
pos = alternation_starting_pos;
slice = inputSpan.Slice(pos);
}
// Branch 4
{
if ((uint)slice.Length < 6 ||
!slice.StartsWith("fr", StringComparison.OrdinalIgnoreCase) || // Match the string "fr" (ordinal case-insensitive)
((((ch = slice[2]) | 0x20) != 'i') & (ch != 'İ')) || // Match a character in the set [Ii\u0130].
!slice.Slice(3).StartsWith("day", StringComparison.OrdinalIgnoreCase)) // Match the string "day" (ordinal case-insensitive)
{
goto AlternationBranch4;
}
pos += 6;
slice = inputSpan.Slice(pos);
goto AlternationMatch;
AlternationBranch4:
pos = alternation_starting_pos;
slice = inputSpan.Slice(pos);
}
// Branch 5
{
// Match a character in the set [Ss].
if (slice.IsEmpty || ((slice[0] | 0x20) != 's'))
{
return false; // The input didn't match.
}
// Match with 2 alternative expressions, atomically.
{
if ((uint)slice.Length < 2)
{
return false; // The input didn't match.
}
switch (slice[1])
{
case 'A' or 'a':
if ((uint)slice.Length < 8 ||
!slice.Slice(2).StartsWith("turday", StringComparison.OrdinalIgnoreCase)) // Match the string "turday" (ordinal case-insensitive)
{
return false; // The input didn't match.
}
pos += 8;
slice = inputSpan.Slice(pos);
break;
case 'U' or 'u':
if ((uint)slice.Length < 6 ||
!slice.Slice(2).StartsWith("nday", StringComparison.OrdinalIgnoreCase)) // Match the string "nday" (ordinal case-insensitive)
{
return false; // The input didn't match.
}
pos += 6;
slice = inputSpan.Slice(pos);
break;
default:
return false; // The input didn't match.
}
}
}
AlternationMatch:;
}
// The input matched.
base.runtextpos = pos;
base.Capture(0, matchStart, pos);
return true;
}
W tym samym czasie generator źródła ma inne problemy, aby zmagać się z tym, że po prostu nie istnieją podczas bezpośredniego wyprowadzania danych do IL. Jeśli spojrzysz wstecz na kilka przykładów kodu, zobaczysz, że niektóre nawiasy klamrowe nieco dziwnie skomentowane. To nie jest błąd. Generator źródła rozpoznaje, że jeśli te nawiasy klamrowe nie zostały skomentowane, struktura wycofywania polega na przechodzeniu poza zakres do etykiety zdefiniowanej w tym zakresie; taka etykieta nie byłaby widoczna dla takiego goto
obiektu, a kompilowanie kodu zakończy się niepowodzeniem. W związku z tym generator źródła musi unikać bycia zakresem w ten sposób. W niektórych przypadkach po prostu oznaczy zakres jako komentarz w tym miejscu. W innych przypadkach, gdy nie jest to możliwe, czasami może unikać konstrukcji wymagających zakresów (takich jak blok z wieloma instrukcjami if
), jeśli tak byłoby problematyczne.
Generator źródła obsługuje wszystkie RegexCompiler
obsługiwane elementy z jednym wyjątkiem. Podobnie jak w przypadku obsługi RegexOptions.IgnoreCase
, implementacje używają teraz tabeli wielkości liter do generowania zestawów w czasie budowy i sposobu IgnoreCase
dopasowania wstecznego musi skonsultować się z tabelą wielkości liter. Ta tabela jest wewnętrzna dla System.Text.RegularExpressions.dll
elementu , a na razie co najmniej kod zewnętrzny dla tego zestawu (w tym kod emitowany przez generator źródła) nie ma dostępu do niego. Dzięki temu obsługa IgnoreCase
backreferences jest wyzwaniem w generatorze źródłowym i nie są one obsługiwane. Jest to jedna konstrukcja nieobsługiwana przez generator źródła obsługiwany przez RegexCompiler
program . Jeśli spróbujesz użyć wzorca, który ma jeden z tych (co jest rzadkie), generator źródła nie emituje niestandardowej implementacji i zamiast tego powróci do buforowania zwykłego Regex
wystąpienia:
Ponadto ani generator źródła nie RegexCompiler
obsługuje nowego RegexOptions.NonBacktracking
elementu . Jeśli określisz RegexOptions.Compiled | RegexOptions.NonBacktracking
wartość , flaga Compiled
będzie po prostu ignorowana, a jeśli określisz NonBacktracking
generator źródła, będzie ona podobnie wracać do buforowania zwykłego Regex
wystąpienia.
Zastosowanie
Ogólne wskazówki są następujące, jeśli możesz użyć generatora źródłowego, użyj go. Jeśli używasz Regex
dzisiaj w języku C# z argumentami znanymi w czasie kompilacji, a zwłaszcza jeśli już używasz RegexOptions.Compiled
(ponieważ wyrażenie regularne zostało zidentyfikowane jako punkt gorąca, które skorzystałoby z szybszej przepływności), wolisz użyć generatora źródłowego. Generator źródła daje regex następujące korzyści:
- Wszystkie korzyści płynące z przepływności .
RegexOptions.Compiled
- Korzyści wynikające z uruchamiania nie muszą wykonywać wszystkich analiz wyrażeń regularnych, analizy i kompilacji w czasie wykonywania.
- Opcja użycia kompilacji przed czasem z kodem wygenerowanym dla wyrażenia regularnego.
- Lepsza możliwość debugowania i zrozumienie wyrażenia regularnego.
- Możliwość zmniejszenia rozmiaru przyciętej aplikacji przez przycinanie dużych pokosów kodu skojarzonego z
RegexCompiler
(a potencjalnie nawet odbicie emituje się).
W przypadku użycia z opcją podobną RegexOptions.NonBacktracking
do tego, dla której generator źródła nie może wygenerować implementacji niestandardowej, nadal będzie emitować buforowanie i komentarze XML opisujące implementację, co czyni go cennym. Głównym minusem generatora źródła jest to, że emituje dodatkowy kod do zestawu, więc istnieje potencjał zwiększenia rozmiaru. Im więcej wyrażeń regularnych w aplikacji i im większe, tym więcej kodu będzie dla nich emitowane. W niektórych sytuacjach, podobnie jak RegexOptions.Compiled
może być niepotrzebne, więc też może być generator źródła. Jeśli na przykład masz wyrażenie regularne, które jest potrzebne tylko rzadko i dla którego przepływności nie ma znaczenia, może być bardziej korzystne, aby po prostu polegać na interpreterze dla tego sporadycznego użycia.
Ważne
Platforma .NET 7 zawiera analizator , który identyfikuje użycie Regex
tego elementu, który może zostać przekonwertowany na generator źródła, oraz moduł naprawiający, który wykonuje konwersję: