Générateurs de sources d’expressions régulières .NET
Une expression régulière, ou regex, est une chaîne qui permet à un développeur d’exprimer un modèle recherché, ce qui en fait un moyen courant de rechercher du texte et d’extraire les résultats en tant que sous-ensemble de la chaîne recherchée. Dans .NET, l’espace de noms System.Text.RegularExpressions
est utilisé pour définir des instances et méthodes statiques Regex, et trouver une correspondance avec des modèles définis par l’utilisateur. Dans cet article, vous allez apprendre à utiliser la génération de sources pour générer des instances Regex
afin d’optimiser les performances.
Remarque
Si possible, utilisez des expressions régulières générées par la source au lieu de compiler des expressions régulières avec l’option RegexOptions.Compiled. Grâce à la génération de source, votre application peut démarrer plus vite, s’exécuter plus rapidement et mieux se prêter au découpage. Pour savoir quand la génération de source est possible, consultez Quand l’utiliser.
Expressions régulières compilées
Lorsque vous écrivez new Regex("somepattern")
, certaines choses se produisent. Le modèle spécifié est analysé, à la fois pour garantir la validité du modèle et pour le transformer en une arborescence interne qui représente le regex analysé. L’arborescence est ensuite optimisée de différentes manières, transformant ainsi le modèle en une variation fonctionnellement équivalente qui peut être exécutée plus efficacement. L’arborescence est écrite sous une forme qui peut être interprétée comme une série d’opcodes et d’opérandes qui fournissent des instructions au moteur de l’interpréteur regex sur la façon d’effectuer la correspondance. Lorsqu’une correspondance est effectuée, l’interpréteur parcourt tout simplement ces instructions et les traite par rapport au texte d’entrée. Lors de l’instanciation d’une nouvelle instance Regex
ou de l’appel de l’une des méthodes statiques sur Regex
, l’interpréteur est le moteur par défaut utilisé.
Lorsque vous spécifiez RegexOptions.Compiled, le même travail au moment de la construction est effectué. Les instructions résultantes sont davantage transformées par le compilateur basé sur l’émission de réflexion en instructions en langage intermédiaire qui sont écrites en quelques objets DynamicMethod. Lorsqu’une correspondance est effectuée, ces méthodes DynamicMethod
sont appelées. Ce langage intermédiaire fonctionne fondamentalement comme l’interprète, à l’exception qu’il est spécialisé pour le modèle exact traité. Par exemple, si le modèle contient [ac]
, l’interpréteur voit un opcode qui indique « faire correspondre le caractère d’entrée à la position actuelle par rapport au jeu spécifié dans cette description de jeu ». Alors que le langage intermédiaire compilé contient du code qui indique effectivement « faire correspondre le caractère d’entrée à la position actuelle par rapport à 'a'
ou à 'c'
». Cette casse spéciale et la possibilité d’effectuer des optimisations en fonction de la connaissance du modèle sont quelques-unes des principales raisons pour lesquelles spécifier RegexOptions.Compiled
génère un débit avec une correspondance beaucoup plus rapide que l’interpréteur.
Il existe plusieurs inconvénients à RegexOptions.Compiled
. Le plus important est que sa construction est coûteuse. Non seulement les coûts payés sont les mêmes que pour l’interpréteur, mais il doit ensuite compiler cette arborescence RegexNode
résultante et générer des opcodes/opérandes en langage intermédiaire, ce qui ajoute des dépenses non négligeables. Le langage intermédiaire généré doit en outre être compilé par JIT lors de la première utilisation, ce qui entraîne encore plus de dépenses au démarrage. RegexOptions.Compiled
représente un compromis fondamental entre les surcharges sur la première utilisation et les surcharges sur chaque utilisation ultérieure. L’utilisation de System.Reflection.Emit empêche également l’utilisation de RegexOptions.Compiled
dans certains environnements ; certains systèmes d’exploitation ne permettent pas l’exécution du code généré dynamiquement, et sur ces systèmes, Compiled
devient une opération non efficace.
Génération de la source
.NET 7 a introduit un nouveau générateur de source RegexGenerator
. Un générateur source est un composant qui se connecte au compilateur et augmente l’unité de compilation avec du code source supplémentaire. Le kit de développement logiciel (SDK) .NET (version 7 et ultérieure) inclut un générateur source qui reconnaît l’attribut GeneratedRegexAttribute sur une méthode partielle qui retourne Regex
. Le générateur de sources fournit une implémentation de cette méthode qui contient toute la logique pour Regex
. Par exemple, vous avez peut-être déjà écrit du code comme suit :
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
}
}
Pour utiliser le générateur source, vous réécrivez le code précédent comme suit :
[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
}
}
Conseil
L’indicateur RegexOptions.Compiled
est ignoré par le générateur source, il n’est donc pas nécessaire dans la version générée par la source.
L’implémentation générée de AbcOrDefGeneratedRegex()
met en cache de la même façon une instance Regex
singleton, de sorte qu’aucune mise en cache supplémentaire n’est nécessaire pour consommer du code.
L’image suivante est une capture d’écran de l’instance mise en cache générée par la source, internal
dans la sous-classe Regex
émise par le générateur de source :
Mais comme on peut le voir, il ne s’agit pas seulement de taper new Regex(...)
. Au lieu de cela, le générateur de sources émet en tant que code C# une implémentation dérivée de Regex
personnalisée avec une logique similaire à ce que RegexOptions.Compiled
émet en langage intermédiaire. Vous bénéficiez de tous les avantages (voire plus) en matière de performances de débit de RegexOptions.Compiled
et des avantages de démarrage de Regex.CompileToAssembly
, mais sans la complexité de CompileToAssembly
. La source émise fait partie de votre projet, ce qui signifie qu’elle est facilement visible et débogable.
Conseil
Dans Visual Studio, cliquez avec le bouton droit sur votre déclaration de méthode partielle et sélectionnez Atteindre la définition. Vous pouvez également sélectionner le nœud de projet dans Explorateur de solutions, puis développer Dépendances>Analyzers>System.Text.RegularExpressions.Generator>System.Text.RegularExpressions.Generator.RegexGenerator>RegexGenerator.g.cs pour voir le code C# généré à partir de ce générateur d’expressions régulières.
Vous pouvez définir des points d’arrêt dans celui-ci, vous pouvez effectuer un pas à pas et vous pouvez l’utiliser comme outil d’apprentissage pour comprendre la façon exacte dont le moteur regex traite votre modèle avec votre entrée. Le générateur génère même des commentaires XML (triple barre oblique) pour rendre l’expression compréhensible en un coup d’œil et à l’endroit où elle est utilisée.
À l’intérieur des fichiers générés par la source
Avec .NET 7, le générateur de sources et RegexCompiler
ont été presque entièrement réécrits, modifiant fondamentalement la structure du code généré. Cette approche a été étendue pour gérer toutes les constructions (avec une mise en garde). Par ailleurs, tant RegexCompiler
que le générateur de sources continuent de mapper la plupart du temps de un-à-un (1:1) l’un avec l’autre, en suivant la nouvelle approche. Considérez la sortie du générateur de sources pour l’une des fonctions principales de l’expression 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;
}
L’objectif du code généré par la source est d’être compréhensible, avec une structure facile à suivre, avec des commentaires expliquant ce qui est fait à chaque étape, et en général avec le code émis selon le principe directeur selon lequel le générateur doit émettre du code comme si un humain l’avait écrit. Même lorsque le retour sur trace est impliqué, la structure du retour sur trace fait partie de la structure du code, plutôt que de s’appuyer sur une pile pour indiquer où aller ensuite. Par exemple, voici le code de la même fonction de mise en correspondance générée lorsque l’expression est [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;
}
Vous pouvez voir la structure du retour sur trace dans le code, avec une étiquette CharLoopBacktrack
émise pour l’endroit où effectuer un retour en arrière et une instruction goto
utilisée pour accéder à cet emplacement lorsqu’une partie ultérieure du regex échoue.
Si vous examinez le code implémentant RegexCompiler
et le générateur de sources, ils seront extrêmement similaires : méthodes nommées de la même façon, structure d’appel similaire et même commentaires similaires tout au long de l’implémentation. Ils aboutissent en grande partie à un code identique, bien que l’un soit en langage intermédiaire et l’autre en C#. Bien sûr, le compilateur C# est alors responsable de la traduction de C# en langage intermédiaire, de sorte que le langage intermédiaire résultant dans les deux cas ne sera probablement pas identique. Le générateur de sources s’appuie sur cela dans différents cas, en tirant parti du fait que le compilateur C# optimisera davantage différentes constructions C#. Il existe quelques éléments spécifiques qui font que le générateur de sources produira donc plus de code de mise en correspondance optimisé que ne le fait RegexCompiler
. Par exemple, dans l’un des exemples précédents, vous pouvez voir le générateur de sources qui émet une instruction switch, avec une branche pour 'a'
et une autre branche pour 'b'
. Étant donné que le compilateur C# est très bon pour optimiser les instructions switch, avec plusieurs stratégies à sa disposition pour savoir comment le faire efficacement, le générateur de sources a une optimisation spéciale que RegexCompiler
ne fait pas. Pour les alternances, le générateur de sources examine toutes les branches et, s’il peut prouver que chaque branche commence par un caractère de départ différent, il émet une instruction switch sur ce premier caractère et évite de placer un code de retour sur trace pour cette alternance.
Voici un exemple un peu plus complexe de cela. Les alternances sont analysées de façon plus poussée afin de déterminer s’il est possible de les refactoriser de manière à les rendre plus facilement optimisables par les moteurs de retour sur trace, ce qui simplifiera le code généré par la source. L’une de ces optimisations prend en charge l’extraction de préfixes communs à partir de branches, et si l’alternance est atomique, de telle sorte que le classement n’a pas d’importance, réorganiser les branches pour permettre une extraction plus importante de ce type. Vous pouvez voir l’impact de cela pour le modèle de jour de la semaine suivant Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday
, qui produit une fonction de mise en correspondance comme ceci :
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;
}
En même temps, le générateur de sources a d’autres problèmes à résoudre qui n’existent tout simplement pas lors de la génération d’une sortie en langage intermédiaire directement. Si vous regardez quelques exemples de code plus haut, vous pouvez voir des accolades aux commentaires quelque peu étranges. Ce n’est pas une erreur. Le générateur de sources reconnaît que, si ces accolades n’ont pas été commentées, la structure du retour sur trace repose sur le saut de l’extérieur de l’étendue vers une étiquette définie à l’intérieur de cette étendue ; une telle étiquette ne serait pas visible pour un tel goto
et le code ne serait pas compilé. Par conséquent, le générateur de sources doit éviter qu’il y ait une étendue au beau milieu. Dans certains cas, il commente simplement l’étendue comme cela a été fait ici. Dans d’autres cas où cela n’est pas possible, il peut parfois éviter les constructions qui nécessitent des étendues (comme un bloc à plusieurs instructions if
) si cela est problématique.
Le générateur de sources gère tout ce que RegexCompiler
gère, à une exception près. Comme pour la gestion de RegexOptions.IgnoreCase
, les implémentations utilisent à présent une table de casse pour générer des jeux au moment de la construction, et la façon dont la mise en correspondance de référence arrière IgnoreCase
doit consulter cette table de casse. Cette table est interne à System.Text.RegularExpressions.dll
, et pour l’instant, au moins, le code externe à cet assembly (y compris le code émis par le générateur de sources) n’y a pas accès. Cela rend la gestion des références arrières IgnoreCase
complexe dans le générateur de sources et elles ne sont pas prises en charge. Il s’agit de la seule construction non prise en charge par le générateur de sources pris en charge par RegexCompiler
. Si vous essayez d’utiliser un modèle contenant l’un de ces modèles (ce qui est rare), le générateur de sources n’émettra pas d’implémentation personnalisée et reviendra à la mise en cache d’une instance Regex
régulière :
En outre, ni le générateur de sources ni RegexCompiler
ne prend en charge le nouveau RegexOptions.NonBacktracking
. Si vous spécifiez RegexOptions.Compiled | RegexOptions.NonBacktracking
, l’indicateur Compiled
sera simplement ignoré, et si vous spécifiez NonBacktracking
dans le générateur de sources, il revient également à la mise en cache d’une instance régulière Regex
.
Quand utiliser cette fonctionnalité ?
Si vous pouvez utiliser le générateur de sources, faites-le. Si vous utilisez Regex
aujourd’hui en C# avec des arguments connus au moment de la compilation, et surtout si vous utilisez déjà RegexOptions.Compiled
(car le regex a été identifié comme une zone réactive qui bénéficierait d’un débit plus rapide), vous devez préférer utiliser le générateur de sources. Le générateur de sources offre à votre regex les avantages suivants :
- Tous les avantages en matière de débit de
RegexOptions.Compiled
. - Les avantages au démarrage (à savoir ne pas avoir à effectuer l’analyse) et la compilation regex au moment de l’exécution.
- Option d’utilisation de la compilation Ahead-of-time avec le code généré pour le regex.
- Débogage optimisé et meilleure compréhension du regex.
- La possibilité de réduire la taille de votre application rognée en réduisant les grandes étendues de code associées à
RegexCompiler
(et potentiellement même l’émission de réflexion elle-même).
Lorsqu’il est utilisé avec une option comme RegexOptions.NonBacktracking
pour laquelle le générateur de sources ne peut pas générer d’implémentation personnalisée, il émet toujours des commentaires de mise en cache et XML qui décrivent l’implémentation, ce qui a de la valeur. Le principal inconvénient du générateur de sources est qu’il émet du code supplémentaire dans votre assembly, ce qui permet d’augmenter la taille. Plus il y a d’expressions régulières dans votre application et plus elles sont volumineuses, plus le code émis est volumineux. Dans certaines situations, le générateur de sources peut être inutile, tout comme RegexOptions.Compiled
. Par exemple, si vous avez un regex qui n’est nécessaire que rarement et pour lequel le débit n’a pas d’importance, il peut être plus utile de s’appuyer simplement sur l’interpréteur pour cette utilisation sporadique.
Important
.NET 7 inclut un analyseur qui identifie l’utilisation de Regex
qui peut être convertie en générateur de sources, et un correcteur qui effectue la conversion pour vous :