Quellgeneratoren für reguläre .NET-Ausdrücke
Ein regulärer Ausdruck (RegEx) ist eine Zeichenfolge, die es einem Entwickler ermöglicht, ein gesuchtes Muster auszudrücken. Daher werden reguläre Ausdrücke häufig verwendet, um Text zu durchsuchen und Ergebnisse als Teilmenge aus der gesuchten Zeichenfolge zu extrahieren. In .NET wird der Namespace System.Text.RegularExpressions
verwendet, um Regex-Instanzen und statische Methoden zu definieren und mit benutzerdefinierten Mustern abzugleichen. In diesem Artikel erfahren Sie, wie Sie mithilfe der Quellgenerierung Regex
-Instanzen generieren, um die Leistung zu optimieren.
Hinweis
Verwenden Sie nach Möglichkeit die quellgenerierten regulären Ausdrücke, anstatt reguläre Ausdrücke mithilfe der RegexOptions.Compiled-Option zu kompilieren. Die Quellgenerierung kann dazu beitragen, dass Ihre App schneller gestartet wird, schneller ausgeführt wird und besser gekürzt werden kann. Informationen dazu, wann die Quellgenerierung möglich ist, finden Sie unter Verwendung.
Kompilierte reguläre Ausdrücke
Wenn Sie new Regex("somepattern")
schreiben, passieren mehrere Dinge: Das angegebene Muster wird analysiert, um die Gültigkeit des Musters sicherzustellen und um es in eine interne Struktur zu transformieren, die den analysierten regulären Ausdruck darstellt. Die Struktur wird dann auf verschiedene Arten optimiert. Hierzu wird das Muster in eine funktional äquivalente Variante umgewandelt, die effizienter ausgeführt werden kann. Die Struktur wird in ein Format geschrieben, das als eine Reihe von Opcodes und Operanden interpretiert werden kann, um der Interpreter-Engine für reguläre Ausdrücke mitzuteilen, wie der Abgleich erfolgen soll. Wenn ein Abgleich durchgeführt wird, werden diese Anweisungen einfach vom Interpreter durchlaufen und für den Eingabetext verarbeitet. Wenn Sie eine neue Regex
-Instanz instanziieren oder eine der statischen Methoden für Regex
aufrufen, wird standardmäßig die Interpreter-Engine verwendet.
Wenn Sie RegexOptions.Compiled angeben, werden die gleichen Schritte zur Erstellungszeit ausgeführt. Die resultierenden Anweisungen werden vom reflektionsausgabebasierten Compiler zu Zwischenspracheanweisungen weitertransformiert und dann in einige DynamicMethod-Objekte geschrieben. Wenn eine Übereinstimmung ausgeführt wird, werden diese DynamicMethod
-Methoden aufgerufen. Diese Zwischensprache tut im Wesentlichen genau das, was der Interpreter tut, nur eben speziell für das exakte Muster, das verarbeitet wird. Wenn das Muster z. B. [ac]
enthält, würde der Interpreter einen Opcode sehen, der besagt: „entspricht dem Eingabezeichen an der aktuellen Position mit dem in dieser Satzbeschreibung angegebenen Satz“. Während die kompilierte Zwischensprache Code enthalten würde, der effektiv besagt: „entspricht dem Eingabezeichen an der aktuellen Position gegenüber 'a'
oder 'c'
“. Dieser Spezialfall und die Möglichkeit, Optimierungen basierend auf der Kenntnis des Musters durchzuführen, sind zwei der Hauptgründe dafür, dass sich durch die Angabe von RegexOptions.Compiled
im Vergleich zum Interpreter eine viel höhere Abgleichsgeschwindigkeit und somit ein höherer Durchsatz erzielen lässt.
RegexOptions.Compiled
hat mehrere Nachteile. Am meisten Auswirkungen hat, dass die Konstruktion kostspielig ist. Es fällt nicht nur der gleiche Aufwand an wie beim Interpreter, es müssen auch noch die resultierende RegexNode
-Struktur und die generierten Opcodes/Operanden in Zwischensprache kompiliert werden, was einen nicht unerheblichen Mehraufwand bedeutet. Die generierte Zwischensprache muss außerdem bei der ersten Verwendung JIT-kompiliert werden, was den Aufwand beim Start noch weiter erhöht. RegexOptions.Compiled
stellt einen grundlegenden Kompromiss zwischen dem Aufwand bei der ersten Verwendung und dem Aufwand bei jeder nachfolgenden Verwendung dar. Die Verwendung von System.Reflection.Emit hemmt auch die Verwendung von RegexOptions.Compiled
in bestimmten Umgebungen. Einige Betriebssysteme lassen die Ausführung von dynamisch generiertem Code nicht zu, und auf solchen Systemen kann Compiled
nicht verwendet werden.
Quellengenerierung
.NET 7 hat einen neuen RegexGenerator
-Quellgenerator eingeführt. Ein Quellgenerator ist eine Komponente, die an den Compiler ansteckt und die Kompilierungseinheit mit zusätzlichem Quellcode erweitert. Das .NET SDK (Version 7 und höher) enthält einen Quellgenerator, der das Attribut GeneratedRegexAttribute für eine partielle Methode erkennt, die Regex
zurückgibt. Der Quellgenerator stellt eine Implementierung dieser Methode bereit, welche die gesamte Logik für Regex
beinhaltet. Beispielsweise könnten Sie zuvor Code wie folgt geschrieben haben:
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
}
}
Um den Quellgenerator zu verwenden, schreiben Sie den vorherigen Code wie folgt um:
[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
}
}
Tipp
Das Flag RegexOptions.Compiled
wird vom Quellgenerator ignoriert, sodass es in der quellgenerierten Version nicht benötigt wird.
Die generierte Implementierung von AbcOrDefGeneratedRegex()
speichert auf ähnliche Weise eine Singleton-Regex
-Instanz zwischen, sodass keine zusätzliche Zwischenspeicherung erforderlich ist, um Code zu nutzen.
Die folgende Abbildung ist eine Bildschirmaufnahme der zwischengespeicherten Quellinstanz, internal
für die Regex
Unterklasse, die der Quellgenerator ausgibt:
Aber wie Sie sehen, wird nicht nur new Regex(...)
ausgeführt. Vielmehr wird vom Quellgenerator eine benutzerdefinierte, von Regex
abgeleitete Implementierung als C#-Code mit einer ähnlichen Logik ausgegeben wie von RegexOptions.Compiled
in der Zwischensprache. Sie erhalten alle Durchsatzleistungsvorteile von RegexOptions.Compiled
(tatsächlich sogar noch mehr) sowie die Startvorteile von Regex.CompileToAssembly
, aber ohne die Komplexität von CompileToAssembly
. Die ausgegebene Quelle ist Teil Ihres Projekts, was bedeutet, dass sie auch mühelos angezeigt und debuggt werden kann.
Tipp
Klicken Sie in Visual Studio mit der rechten Maustaste auf die Deklaration Ihrer partiellen Methode, und wählen Sie Zu Definition wechseln aus. Alternativ können Sie auch im Projektmappen-Explorer den Projektknoten auswählen und dann Abhängigkeiten>Analysetools>System.Text.RegularExpressions.Generator>System.Text.RegularExpressions.Generator.RegexGenerator>RegexGenerator.g.cs erweitern, um den generierten C#-Code dieses RegEx-Generators anzuzeigen.
Sie können darin Breakpoints festlegen, sie schrittweise durchlaufen und sie als Lerntool nutzen, um genau nachzuvollziehen, wie die Engine für reguläre Ausdrücke Ihr Muster mit Ihrer Eingabe verarbeitet. Der Generator generiert sogar XML-Kommentare mit drei Schrägstrichen, um den Ausdruck und seine Verwendung auf einen Blick verständlich zu machen.
In den quellgenerierten Dateien
Mit .NET 7 wurden sowohl der Quellgenerator als auch RegexCompiler
fast vollständig neu geschrieben, wodurch sich die Struktur des generierten Codes grundlegend geändert hat. Dieser Ansatz wurde erweitert, um alle Konstrukte zu behandeln (mit einer Einschränkung), und RegexCompiler
sowie der Quellgenerator sind auch bei dem neuen Ansatz immer noch größtenteils identisch. Betrachten Sie die Ausgabe des Quellgenerators für eine der primären Funktionen aus dem Ausdruck abc|def
:
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;
}
Das Ziel des quellgenerierten Codes besteht darin, verständlich zu sein – mit einer gut nachvollziehbaren Struktur, mit Kommentaren, die die Vorgänge in den einzelnen Schritten erklären, und im Allgemeinen mit Code, der so ausgeben wird, als hätte ihn ein Mensch geschrieben. Selbst im Falle einer Rückverfolgung wird die Struktur der Rückverfolgung Teil der Codestruktur, anstatt sich bei der Angabe des nächsten Sprungziels auf einen Stapel zu verlassen. Hier sehen Sie beispielsweise den Code für die gleiche generierte Abgleichsfunktion, wenn der Ausdruck [ab]*[bc]
lautet:
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;
}
Sie sehen die Rückverfolgungsstruktur im Code, mit der ausgegebenen Bezeichnung CharLoopBacktrack
für das Ziel der Rückverfolgung und mit goto
, um zu dieser Stelle zu springen, wenn ein nachfolgender Teil des regulären Ausdrucks nicht erfolgreich ist.
Der Code für die Implementierung von RegexCompiler
und der Quellcodegenerator sehen sehr ähnlich aus: ähnlich benannte Methoden, ähnliche Aufrufstruktur und sogar ähnliche Kommentare in der gesamten Implementierung. Sie führen größtenteils zu identischem Code, wenn auch einmal in Zwischensprache und einmal in C#. Natürlich ist der C#-Compiler dann für die Übersetzung von C# in Zwischensprache verantwortlich, sodass die resultierende Zwischensprache in den beiden Fällen wahrscheinlich nicht identisch ist. Der Quellgenerator verlässt sich in verschiedenen Fällen darauf und nutzt die Tatsache, dass der C#-Compiler verschiedene C#-Konstrukte weiter optimiert. Daher gibt es einige spezifische Dinge, für die der Quellgenerator einen besser optimierten Abgleichscode erzeugt als RegexCompiler
. In einem der vorherigen Beispiele sehen Sie beispielsweise, dass der Quellgenerator eine Anweisung vom Typ „switch“ mit einer Verzweigung für 'a'
und einer anderen Verzweigung für 'b'
ausgibt. Da der C#-Compiler derartige Anweisungen sehr gut optimieren kann und über mehrere Strategien für eine effiziente Optimierung verfügt, profitiert der Quellgenerator von einer speziellen Optimierung, die RegexCompiler
nicht zur Verfügung steht. Bei Alternierungen betrachtet der Quellgenerator alle Verzweigungen. Kann er nicht nachweisen, dass jede Verzweigung mit einem anderen Startzeichen beginnt, gibt er für dieses erste Zeichen eine Anweisung vom Typ „switch“ aus und vermeidet die Ausgabe von Rückverfolgungscode für diese Alternierung.
Hier sehen Sie ein etwas komplizierteres Beispiel dafür. Alternierungen werden ausführlicher analysiert, um zu ermitteln, ob sie so umgestaltet werden können, dass sie durch die Rückverfolgungs-Engines leichter optimiert werden können, was zu einfacherem quellgeneriertem Code führt. Eine dieser Optimierungen unterstützt das Extrahieren allgemeiner Präfixe aus Branches, und falls die Alternierung atomisch ist, sodass die Reihenfolge keine Rolle spielt, auch die Neuanordnung von Verzweigungen, um weitere derartige Extraktionen zu ermöglichen. Die Auswirkungen davon sind beim Wochentagsmuster Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday
erkennbar, das eine Abgleichsfunktion wie die folgende erzeugt:
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;
}
Der Quellgenerator hat allerdings mit anderen Problemen zu kämpfen, die bei der direkten Ausgabe in Zwischensprache einfach nicht auftreten. In einem der Codebeispiele weiter oben finden Sie ein paar Klammern, die etwas seltsam auskommentiert sind. Das ist kein Fehler. Der Quellgenerator erkennt, dass die Struktur der Rückverfolgung vorsieht, von außerhalb des Bereichs zu einer innerhalb des Bereichs definierten Bezeichnung zu springen, wenn diese Klammern nicht auskommentiert wären. Eine solche Bezeichnung wäre für ein derartiges goto
-Element nicht sichtbar, und der Code könnte nicht kompiliert werden. Daher muss der Quellgenerator vermeiden, dass ein Bereich im Weg ist. In einigen Fällen wird der Bereich wie hier einfach auskommentiert. Sollte das nicht möglich sein, werden manchmal Konstrukte vermieden, die Bereiche erfordern (z. B. ein Block mit mehreren Anweisungen vom Typ if
), wenn dies problematisch wäre.
Der Quellgenerator behandelt alles, was auch von RegexCompiler
behandelt wird – mit einer Ausnahme: Genau wie bei der Behandlung von RegexOptions.IgnoreCase
verwenden die Implementierungen jetzt eine Tabelle für die Groß- und Kleinschreibung, um Gruppen zur Erstellungszeit zu generieren, und geben an, wie diese Groß- und Kleinschreibungstabelle bei IgnoreCase
-Rückverweisabgleichen herangezogen werden muss. Bei dieser Tabelle handelt es sich um eine interne Tabelle für System.Text.RegularExpressions.dll
, und zumindest im Moment hat der externe Code für diese Assembly (einschließlich Code, der vom Quellgenerator ausgegeben wird) keinen Zugriff darauf. Das macht die Behandlung von IgnoreCase
-Rückverweisen im Quellgenerator zu einer Herausforderung, und sie werden nicht unterstützt. Dies ist das einzige Konstrukt, das von RegexCompiler
, aber nicht vom Quellgenerator unterstützt wird. Wenn Sie versuchen, ein Muster mit einem dieser Elemente zu verwenden (was selten ist), gibt der Quellgenerator keine benutzerdefinierte Implementierung aus und speichert stattdessen eine reguläre Regex
-Instanz zwischen:
Das neue Element RegexOptions.NonBacktracking
wird zudem weder von RegexCompiler
noch vom Quellgenerator unterstützt. Wenn Sie RegexOptions.Compiled | RegexOptions.NonBacktracking
angeben, wird das Flag Compiled
einfach ignoriert, und wenn Sie NonBacktracking
für den Quellgenerator angeben, wird auf ähnliche Weise eine reguläre Regex
-Instanz zwischengespeichert.
Einsatzgebiete
Die allgemeine Empfehlung lautet: Wenn Sie den Quellgenerator verwenden können, verwenden Sie ihn. Wenn Sie aktuell Regex
in C# mit zur Kompilierzeit bekannten Argumenten verwenden, empfiehlt sich die Verwendung des Quellgenerators – insbesondere, wenn Sie bereits RegexOptions.Compiled
verwenden (da der reguläre Ausdruck als Hotspot identifiziert wurde, der von einem höheren Durchsatz profitieren würde). Der Quellgenerator bietet folgende Vorteile für Ihre regulären Ausdrücke:
- Alle Durchsatzvorteile von
RegexOptions.Compiled
- Die Vorteile beim Start (also dass nicht die gesamten Analysen für die regulären Ausdrücke sowie die Kompilierung zur Laufzeit durchgeführt werden müssen)
- Die Option, die Vorabkompilierung mit dem Code zu verwenden, der für den regulären Ausdruck generiert wurde
- Bessere Debugging-Fähigkeit und besseres Verständnis des regulären Ausdrucks
- Die Möglichkeit, Ihre gekürzte App zu verkleinern, indem Sie große Codeteile kürzen, die mit
RegexCompiler
zusammenhängen (und möglicherweise sogar die Reflektionsausgabe selbst)
Bei Verwendung mit einer Option wie RegexOptions.NonBacktracking
, für die der Quellgenerator keine benutzerdefinierte Implementierung generieren kann, werden trotzdem Zwischenspeicherung und XML-Kommentare ausgegeben, die die Implementierung beschreiben, um einen Mehrwert zu generieren Der größte Nachteil des Quellgenerators ist, dass er zusätzlichen Code an Ihre Assembly ausgibt, was potenziell zu einer Vergrößerung führt. Je mehr reguläre Ausdrücke sich in Ihrer App befinden und je größer sie sind, desto mehr Code wird für sie ausgegeben. In einigen Situationen ist der Quellgenerator genau wie RegexOptions.Compiled
möglicherweise auch unnötig. Wenn Sie beispielsweise über einen regulären Ausdruck verfügen, der nur selten benötigt wird und bei dem der Durchsatz keine Rolle spielt, kann es vorteilhafter sein, sich für diese sporadische Verwendung nur auf den Interpreter zu verlassen.
Wichtig
.NET 7 enthält ein Analysetool, das die Verwendung von Regex
identifiziert, die in den Quellgenerator konvertiert werden kann, sowie eine Korrekturregel, die die Konvertierung für Sie durchführt: