但是,从性能角度来看,正则表达式在这几年间并没有得到太多关注。
在 2006 年的 .NET Framework 2.0 中变动了其缓存策略。
.NET Core 2.0 在 RegexOptions.Compiled 之后看到了这个实现的到来(在 .NET Core 1.x 中,RegexOptions.Compiled 选项是一个 nop)。
.NET Core 3.0 受益于 Regex 内部的一些内部更新,以在某些情形下利用 Span<T> 提高内存利用率。
在此过程中,一些非常受欢迎的社区贡献改进了目标区域,例如 dotnet/corefx#32899,它减少了利用表达式 RegexOptions.Compiled | RegexOptions.IgnoreCase 时对CultureInfo.CurrentCulture 的访问。
但除此之外,履行很大程度上还是在15年前。

对付 .NET 5(本周发布了 Preview 2),我们已对 Regex 引擎进行了一些重大改进。
在我们考试测验过的许多表达式中,这些变动常日会使吞吐量提高3到6倍,在某些情形下乃至会提高更多。
在本文中,我将逐步先容 .NET 5 中 System.Text.RegularExpressions 进行的许多变动。
这些变动对我们自己的利用产生了可衡量的影响,我们希望这些改进将带来可衡量的胜利在您的库和运用中。

Regex内部知识#

要理解所做的某些变动,理解一些Regex内部知识很有帮助。

dynamictwainjspNET 5 中的正则引擎机能改良 Vue.js

Regex布局函数完成所有事情,以采取正则表达式模式并准备对其进行匹配输入:

RegexParser。
该模式被送入内部RegexParser类型,该类型理解正则表达式语法并将其解析为节点树。
例如,表达式a|bcd被转换为具有两个子节点的“替代” RegexNode,一个子节点表示单个字符a,另一个子节点表示“多个” bcd。
解析器还对树进行优化,将一棵树转换为另一个等效树,以供应更有效的表示和/或可以更高效地实行该树。
RegexWriter。
节点树不是实行匹配的空想表示,因此解析器的输出将馈送到内部RegexWriter类,该类会写出一系列紧凑的操作码,以表示实行匹配的指令。
这种类型的名称是“ writer”,由于它“写”出了操作码。
其他引擎常日将其称为“编译”,但是 .NET 引擎利用不同的术语,由于它保留了“编译”术语,用于 MSIL 的可选编译。
RegexCompiler(可选)。
如果未指定RegexOptions.Compiled选项,则内部RegexInterpreter类稍后在匹配时利用RegexWriter输出的操作码来阐明/实行实行匹配的指令,并且在Regex布局过程中不须要任何操作。
但是,如果指定了RegexOptions.Compiled,则布局函数将获取先前输出的资产,并将其供应给内部RegexCompiler类。
然后,RegexCompiler利用反射发射天生MSIL,该MSIL表示阐明程序将要实行的事情,但专门针对此特定表达式。
例如,当与模式中的字符“ c”匹配时,阐明器将须要从变量中加载比较值,而编译器会将“ c”硬编码为天生的IL中的常量。

一旦布局了正则表达式,就可以通过IsMatch,Match,Matches,Replace和Split等实例方法将其用于匹配(Match返回Match工具,该工具公开了NextMatch方法,该方法可以迭代匹配并延迟打算) 。
这些操作终极以“扫描”循环(某些其他引擎将其称为“传输”循环)结束,该循环实质上实行以下操作:

Copywhile (FindFirstChar()){ Go(); if (_match != null) return _match; _pos++;}return null;

_pos是我们在输入中所处确当前位置。
virtual FindFirstChar从_pos开始,并在输入文本中查找正则表达式可能匹配的第一位;这并不是实行完全引擎,而是尽可能高效地进行搜索,以找到值得运行完全引擎的位置。
FindFirstChar可以最大程度地减少误报,并且找到有效位置的速率越快,表达式的处理速率就越快。
如果找不到得当的出发点,则可能没有任何匹配,因此我们完成了。
如果找到了一个好的出发点,它将更新_pos,然后通过调用virtual Go来在找到的位置实行引擎。
如果Go找不到匹配项,我们会碰到当前位置并重新开始,但是如果Go找到匹配项,它将存储匹配信息并返回该数据。
显然,实行Go的速率也越快越好。

所有这些逻辑都在公共RegexRunner基类中。
RegexInterpreter派生自RegexRunner,并用阐明正则表达式的实现覆盖FindFirstChar和Go,这由RegexWriter天生的操作码表示。
RegexCompiler利用DynamicMethods天生两种方法,一种用于FindFirstChar,另一种用于Go。
委托是从这些创建的、从RegexRunner派生的另一种类型调用。

.NET 5的改进#

在本文的别的部分中,我们将逐步先容针对 .NET 5 中的 Regex 进行的各种优化。
这不是详尽的清单,但它突出了一些最具影响力的变动。

CharInClass#

正则表达式支持“字符类”,它们定义了输入字符该当或不应该匹配的字符集,以便将该位置视为匹配字符。
字符类用方括号表示。
这里有些例子:

[abc] 匹配“ a”,“ b”或“ c”。
[^\n] 匹配换行符以外的任何字符。
(除非指定了 RegexOptions.Singleline,否则这是您在表达式中利用的确切字符类。
)[a-cx-z] 匹配“ a”,“ b”,“ c”,“ x”,“ y”或“ z”。
[\d\s\p{IsGreek}] 匹配任何Unicode数字,空格或希腊字符。
(与大多数其他正则表达式引擎比较,这是一个有趣的差异。
例如,在其他引擎中,默认情形下,\d常日映射到[0-9],您可以选择加入,而不是映射到所有Unicode数字,即[\p{Nd}],而在.NET中,您默认情形下会利用后者,并利用 RegexOptions.ECMAScript 选择退出。

当将包含字符类的模式通报给Regex布局函数时,RegexParser的事情之一便是将该字符类转换为可以在运行时更轻松地查询的字符。
解析器利用内部RegexCharClass类型来解析字符类,并从实质上提取三件事(还有更多东西,但这对付本次谈论就足够了):

模式是否被否定匹配字符范围的排序集匹配字符的Unicode类别的排序集

这是所有实现的详细信息,但是该信息然后保留在字符串中,该字符串可以通报给受保护的 RegexRunner.CharInClass 方法,以确定字符类中是否包含给定的Char。

在.NET 5之前,每一次须要将一个字符与一个字符类进行匹配时,它将调用该CharInClass方法。
然后,CharInClass对范围进行二进制搜索,以确定指定字符是否存储在一个字符中;如果不存储,则获取目标字符的Unicode种别,并对Unicode种别进行线性搜索,以查看是否匹配。
因此,对付^\d$之类的表达式(断言它在行的开头,然后匹配任意数量的Unicode数字,然后断言在行的末端),假设输入了1000位数字,这加起来将对CharInClass进行1000次调用。

在 .NET 5 中,我们现在更加聪明地做到了这一点,尤其是在利用RegexOptions.Compiled时,常日,只要开拓职员非常关心Regex的吞吐量,就可以利用它。
一种办理方案是,对付每个字符类,掩护一个查找表,该表将输入字符映射到有关该字符是否在类中的是/反对议。
虽然我们可以这样做,但是System.Char是一个16位的值,这意味着每个字符一个位,我们须要为每个字符类利用8K查找表,并且这还要累加起来。
取而代之的是,我们首先考试测验利用平台中的现有功能或通过大略的数学运算来快速进行匹配,以处理一些常见情形。
例如,对付\d,我们现在不天生对RegexRunner.CharInClass(ch, charClassString) 的调用,而是仅天生对 char.IsDigit(ch)的调用。
IsDigit已经利用查找表进行了优化,可以内联,并且性能非常好。
类似地,对付\s,我们现在天生对char.IsWhitespace(ch)的调用。
对付仅包含几个字符的大略字符类,我们将天生直接比较,例如对付[az],我们将天生等价于(ch =='a') | (ch =='z')。
对付仅包含单个范围的大略字符类,我们将通过一次减法和比较来天生检讨,例如[a-z]导致(uint)ch-'a'<= 26,而 [^ 0-9] 导致 !((uint)c-'0'<= 10)。
我们还将分外情形下的其他常见规范;例如,如果全体字符类都是一个Unicode种别,我们将仅天生对char.GetUnicodeInfo(也具有快速查找表)的调用,然后进行比较,例如[\p{Lu}]变为char.GetUnicodeInfo(c)== UnicodeCategory.UppercaseLetter。

当然,只管涵盖了许多常见情形,但当然并不能涵盖所有情形。
而且,由于我们不想为每个字符类天生8K查找表,并不虞味着我们根本无法天生查找表。
相反,如果我们没有碰着这些常见情形之一,那么我们确实会天生一个查找表,但仅针对ASCII,它只须要16个字节(128位),并且考虑到正则表达式中的范例输入,这每每是一个很好的折衷方案基于方案。
由于我们利用DynamicMethod天生方法,因此我们不随意马虎将附加数据存储在程序集的静态数据部分中,但是我们可以做的便是利用常量字符串作为数据存储; MSIL具有用于加载常量字符串的操作码,并且反射发射对天生此类指令具有良好的支持。
因此,对付每个查找表,我们只需创建所需的8个字符的字符串,用不透明的位图数据添补它,然后在IL中用ldstr吐出。
然后我们可以像对待其他任何位图一样对待它,例如为了确定给定的字符是否匹配,我们天生以下内容:

Copybool result = ch < 128 ? (lookup[c >> 4] & (1 << (c & 0xF))) != 0 : NonAsciiFallback;

换句话说,我们利用字符的高三位选择查找表字符串中的第0至第7个字符,然后利用低四位作为该位置16位值的索引; 如果是1,则表示匹配,如果不是,则表示没有匹配。
对付大于即是128的字符,我们须要一个回退,根据对字符类进行的一些剖析,回退可能是各种各样的事情。
最糟糕的情形是,回退只是对RegexRunner.CharInClass的调用,否则我们会做得更好。
例如,很常见的是,我们可以从输入模式中得知所有可能的匹配项均小于<128,在这种情形下,我们根本不须要回退,例如 对付字符类[0-9a-fA-F](又称十六进制),我们将天生以下内容:

Copybool result = ch < 128 && (lookup[c >> 4] & (1 << (c & 0xF))) != 0;

相反,我们可以确定127以上的每个字符都将去匹配。
例如,字符类[^aeiou](除ASCII小写元音外的所有字符)将产生与以下代码等效的代码:

Copybool result = ch >= 128 || (lookup[c >> 4] & (1 << (c & 0xF))) != 0;

等等。

以上都是针对RegexOptions.Compiled,但阐明表达式并不会被冷落。
对付阐明表达式,我们当前会天生一个类似的查找表,但是我们这样做是很

这样做的终极结果可能是频繁评估字符类的表达式的吞吐量显著提高。
例如,这是一个微基准测试,可将ASCII字母和数字与具有62个此类值的输入进行匹配:

Copyusing BenchmarkDotNet.Attributes;using BenchmarkDotNet.Running;using System.Text.RegularExpressions;public class Program{ static void Main(string[] args) => BenchmarkSwitcher.FromAssemblies(new[] { typeof(Program).Assembly }).Run(args); private Regex _regex = new Regex("[a-zA-Z0-9]", RegexOptions.Compiled); [Benchmark] public bool IsMatch() => _regex.IsMatch("abcdefghijklmnopqrstuvwxyz123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ");}

这是我的项目文件:

Copy<project Sdk="Microsoft.NET.Sdk"> <propertygroup> <langversion>preview</langversion> <outputtype>Exe</outputtype> <targetframeworks>netcoreapp5.0;netcoreapp3.1</targetframeworks> </propertygroup> <itemgroup> <packagereference Include="benchmarkdotnet" Version="0.12.0.1229"></packagereference> </itemgroup></project>

在我的打算机上,我有两个目录,一个包含.NET Core 3.1,一个包含.NET 5的内部版本(此处标记为master,由于它是dotnet/runtime的master分支的内部版本)。
当我实行以上操作针对两个版本运行基准测试:

Copydotnet run -c Release -f netcoreapp3.1 --filter --corerun d:\coreclrtest\netcore31\corerun.exe d:\coreclrtest\master\corerun.exe

我得到了以下结果:

MethodToolchainMeanErrorStdDevRatioIsMatch\master\corerun.exe102.3 ns1.33 ns1.24 ns0.17IsMatch\netcore31\corerun.exe585.7 ns2.80 ns2.49 ns1.00

开拓职员可能会写的代码天生器#

如前所述,当RegexOptions.Compiled与Regex一起利用时,我们利用反射发射为其天生两种方法,一种实现FindFirstChar,另一种实现Go。
为了支持回溯,Go终极包含了很多常日不须要的代码。
天生代码的办法常日包括不必要的字段读取和写入,导致检讨JIT无法肃清的边界等。
在 .NET 5 中,我们改进了为许多表达式天生的代码。

考虑表达式@"a\sb",它匹配一个'a',任何Unicode空格和一个'b'。
以前,反编译为Go发出的IL看起来像这样:

Copypublic override void Go(){ string runtext = base.runtext; int runtextstart = base.runtextstart; int runtextbeg = base.runtextbeg; int runtextend = base.runtextend; int num = runtextpos; int[] runtrack = base.runtrack; int runtrackpos = base.runtrackpos; int[] runstack = base.runstack; int runstackpos = base.runstackpos; CheckTimeout(); runtrack[--runtrackpos] = num; runtrack[--runtrackpos] = 0; CheckTimeout(); runstack[--runstackpos] = num; runtrack[--runtrackpos] = 1; CheckTimeout(); if (num < runtextend && runtext[num++] == 'a') { CheckTimeout(); if (num < runtextend && RegexRunner.CharInClass(runtext[num++], "\0\0\u0001d")) { CheckTimeout(); if (num < runtextend && runtext[num++] == 'b') { CheckTimeout(); int num2 = runstack[runstackpos++]; Capture(0, num2, num); runtrack[--runtrackpos] = num2; runtrack[--runtrackpos] = 2; goto IL_0131; } } } while (true) { base.runtrackpos = runtrackpos; base.runstackpos = runstackpos; EnsureStorage(); runtrackpos = base.runtrackpos; runstackpos = base.runstackpos; runtrack = base.runtrack; runstack = base.runstack; switch (runtrack[runtrackpos++]) { case 1: CheckTimeout(); runstackpos++; continue; case 2: CheckTimeout(); runstack[--runstackpos] = runtrack[runtrackpos++]; Uncapture(); continue; } break; } CheckTimeout(); num = runtrack[runtrackpos++]; goto IL_0131; IL_0131: CheckTimeout(); runtextpos = num;}

那里有很多东西,须要斜视和搜索才能将实现的核心看作方法的中间几行。
现在在.NET 5中,相同的表达式导致天生以下代码:

Copyprotected override void Go(){ string runtext = base.runtext; int runtextend = base.runtextend; int runtextpos; int start = runtextpos = base.runtextpos; ReadOnlySpan<char> readOnlySpan = runtext.AsSpan(runtextpos, runtextend - runtextpos); if (0u < (uint)readOnlySpan.Length && readOnlySpan[0] == 'a' && 1u < (uint)readOnlySpan.Length && char.IsWhiteSpace(readOnlySpan[1]) && 2u < (uint)readOnlySpan.Length && readOnlySpan[2] == 'b') { Capture(0, start, base.runtextpos = runtextpos + 3); }}

如果您像我一样,则可以注目着眼睛看第一个版本,但是如果您看到第二个版本,则可以真正阅读并理解它的功能。
除了易于理解和易于调试之外,它还减少了实行的代码,肃清了边界检讨,减少了对字段和数组的读写等方面的事情。
终极的结果是它的实行速率也快得多。
(这里还有进一步改进的可能性,例如删除两个长度检讨,可能会重新排序一些检讨,但总的来说,它比以前有了很大的改进。

向量化的基于 Span 的搜索#

正则表达式都是关于搜索内容的。
结果,我们常常创造自己正在运行循环以探求各种事物。
例如,考虑表达式 hello.world。
以前,如果要反编译我们在Go方法中天生的用于匹配.的代码,则该代码类似于以下内容:

Copywhile (--num3 > 0){ if (runtext[num++] == '\n') { num--; break; }}

换句话说,我们将手动遍历输入文本字符串,逐个字符地查找 \n(请记住,默认情形下,.表示“ \n以外的任何内容”,因此.表示“匹配所有内容,直到找到\n” )。
但是,.NET早已拥有完备实行此类搜索的方法,例如IndexOf,并且从最新版本开始,IndexOf是矢量化的,因此它可以同时比较多个字符,而不仅仅是单独查看每个字符。
现在,在.NET 5中,我们不再像上面那样天生代码,而是得到如下代码:

Copynum2 = runtext.AsSpan(runtextpos, num).IndexOf('\n');

利用IndexOf而不是天生我们自己的循环,则意味着对Regex中的此类搜索进行隐式矢量化,并且对此类实现的任何改进也都应归于此。
这也意味着天生的代码更大略。
可以用这样的基准测试来查看其影响:

Copyusing BenchmarkDotNet.Attributes;using BenchmarkDotNet.Running;using System.Text.RegularExpressions;public class Program{ static void Main(string[] args) => BenchmarkSwitcher.FromAssemblies(new[] { typeof(Program).Assembly }).Run(args); private Regex _regex = new Regex("hello.world", RegexOptions.Compiled); [Benchmark] public bool IsMatch() => _regex.IsMatch("hello. this is a test to see if it's able to find something more quickly in the world.");}

纵然输入的字符串不是特殊大,也会产生可衡量的影响:

MethodToolchainMeanErrorStdDevRatioIsMatch\master\corerun.exe71.03 ns0.308 ns0.257 ns0.47IsMatch\netcore31\corerun.exe149.80 ns0.913 ns0.809 ns1.00

IndexOfAny终极还是.NET 5实现中的主要工具,尤其是对付FindFirstChar的实现。
.NET Regex实现利用的现有优化之一是对可以开始表达式的所有可能字符进行剖析。
天生一个字符类,然后FindFirstChar利用该字符类对可能开始匹配的下一个位置天生搜索。
这可以通过查看表达式([ab]cd|ef [g-i])jklm的天生代码的反编译版本来看到。
与该表达式的有效匹配只能以'a','b'或'e'开头,因此优化器天生一个字符类[abe],FindFirstChar然后利用:

Copypublic override bool FindFirstChar(){ int num = runtextpos; string runtext = base.runtext; int num2 = runtextend - num; if (num2 > 0) { int result; while (true) { num2--; if (!RegexRunner.CharInClass(runtext[num++], "\0\u0004\0acef")) { if (num2 <= 0) { result = 0; break; } continue; } num--; result = 1; break; } runtextpos = num; return (byte)result != 0; } return false;}

这里须要把稳的几件事:

正如前面所谈论的,我们可以看到每个字符都是通过CharInClass求值的。
我们可以看到通报给CharInClass的字符串是该类的内部可搜索表示(第一个字符表示没有取反,第二个字符表示有四个用于表示范围的字符,第三个字符表示没有Unicode种别) ,然后接下来的四个字符代表两个范围,分别包含下限和上限。
我们可以看到我们分别评估每个字符,而不是能够一起评估多个字符。
我们只看第一个字符,如果匹配,我们退出以许可引擎完备实行Go。

在.NET 5 Preview 2中,我们现在天生此代码:

Copyprotected override bool FindFirstChar(){ int runtextpos = base.runtextpos; int runtextend = base.runtextend; if (runtextpos <= runtextend - 7) { ReadOnlySpan<char> readOnlySpan = runtext.AsSpan(runtextpos, runtextend - runtextpos); for (int num = 0; num < readOnlySpan.Length - 2; num++) { int num2 = readOnlySpan.Slice(num).IndexOfAny('a', 'b', 'e'); num = num2 + num; if (num2 < 0 || readOnlySpan.Length - 2 <= num) { break; } int num3 = readOnlySpan[num + 1]; if ((num3 == 'c') | (num3 == 'f')) { num3 = readOnlySpan[num + 2]; if (num3 < 128 && ("\0\0\0\0\0\0ΐ\0"[num3 >> 4] & (1 << (num3 & 0xF))) != 0) { base.runtextpos = runtextpos + num; return true; } } } } base.runtextpos = runtextend; return false;}

这里要把稳一些有趣的事情:

现在,我们利用IndexOfAny搜索三个目标字符。
IndexOfAny是矢量化的,因此它可以利用SIMD指令一次比较多个字符,并且我们为进一步优化IndexOfAny所做的任何未来改进都将隐式归于此类FindFirstChar实现。
如果IndexOfAny找到匹配项,我们不但是立即返回以给Go机会实行。
相反,我们对接下来的几个字符进行快速检讨,以增加这实际上是匹配项的可能性。
在原始表达式中,您可以看到可能与第二个字符匹配的唯一值是'c'和'f',因此该实现对这些字符进行了快速比较检讨。
您会看到第三个字符必须与'd'或[g-i]匹配,因此该实现将这些字符组合到单个字符类[dg-i]中,然后利用位图对其进行评估。
后两个字符检讨都突出了我们现在为字符类发出的改进的代码天生。

我们可以在这样的测试中看到这种潜在的影响:

Copyusing BenchmarkDotNet.Attributes;using BenchmarkDotNet.Running;using System;using System.Linq;using System.Text.RegularExpressions; public class Program{ static void Main(string[] args) => BenchmarkSwitcher.FromAssemblies(new[] { typeof(Program).Assembly }).Run(args); private static Random s_rand = new Random(42); private Regex _regex = new Regex("([ab]cd|ef[g-i])jklm", RegexOptions.Compiled); private string _input = string.Concat(Enumerable.Range(0, 1000).Select(_ => (char)('a' + s_rand.Next(26)))); [Benchmark] public bool IsMatch() => _regex.IsMatch(_input);}

在我的机器上会产生以下结果:

MethodToolchainMeanErrorStdDevRatioIsMatch\master\corerun.exe1.084 us0.0068 us0.0061 us0.08IsMatch\netcore31\corerun.exe14.235 us0.0620 us0.0550 us1.00

先前的代码差异也突出了另一个有趣的改进,特殊是旧代码的int num2 = runtextend-num;`` if(num2> 0)和新代码的if(runtextpos <= runtextend-7)之间的差异。

如前所述,RegexParser将输入模式解析为节点树,然后对其进行剖析和优化。
.NET 5包括各种新的剖析,有些大略,有些更繁芜。
较大略的示例之一是解析器现在将对表达式进行快速扫描,以确定是否必须有最小输入长度才能匹配输入。
考虑一下表达式[0-9]{3}-[0-9]{2}-[0-9]{4},该表达式可用于匹配美国的社会保险号(三个ASCII数字,破折号,两个ASCII数字,一个破折号,四个ASCII数字)。
我们可以很随意马虎地看到,此模式的任何有效匹配都至少须要11个字符;如果为我们供应了10个或更少的输入,或者如果我们在输入末端找到10个字符以内却没有找到匹配项,那么我们可能会立即使匹配项失落败而无需进一步进行,由于这是不可能的匹配。

Copyusing BenchmarkDotNet.Attributes;using BenchmarkDotNet.Diagnosers;using BenchmarkDotNet.Running;using System.Text.RegularExpressions;public class Program{ static void Main(string[] args) => BenchmarkSwitcher.FromAssemblies(new[] { typeof(Program).Assembly }).Run(args); private readonly Regex _regex = new Regex("[0-9]{3}-[0-9]{2}-[0-9]{4}", RegexOptions.Compiled); [Benchmark] public bool IsMatch() => _regex.IsMatch("123-45-678");}

MethodToolchainMeanErrorStdDevRatioIsMatch\master\corerun.exe19.39 ns0.148 ns0.139 ns0.04IsMatch\netcore31\corerun.exe459.86 ns1.893 ns1.771 ns1.00

回溯肃清#

.NET Regex实现当前利用回溯引擎。
这种实现可以支持基于DFA的引擎无法轻松或有效地支持的各种功能,例如反向引用,并且在内存利用率以及常见情形下的吞吐量方面都非常高效。
但是,回溯有一个很大的缺陷,那便是可能导致退化的情形,即匹配在输入长度上花费了指数韶光。
这便是.NET Regex类公开设置超时的功能的缘故原由,因此失落控匹配可能会被非常中断。

.NET文档供应了更多详细信息,但可以这样说,开拓职员可以编写正则表达式,而不会受到过多的回溯。
一种方法是采取“原子组”,该原子组见告引擎,一旦组匹配,实现就不得回溯到它,常日在这种回溯不会带来好处的情形下利用。
考虑与输入aaaa匹配的示例表达式a+b:

Go引擎开​​始匹配a+。
此操作是贪婪的,因此它匹配第一个a,然后匹配aa,然后匹配aaa,然后匹配aaaa。
然后,它会显示在输入的末端。
没有b匹配,因此引擎回溯1,而a+现在匹配aaa。
仍旧没有b匹配,因此引擎回溯1,而a+现在匹配aa。
仍旧没有b匹配,因此引擎回溯1,而a+现在匹配a。
仍旧没有b可以匹配,而a+至少须要1个a,因此匹配失落败。

但是,所有这些回溯都被证明是不必要的。
a+不能匹配b可以匹配的东西,因此在这里进行大量的回溯是不会有成果的。
看到这一点,开拓职员可以改用表达式(?>a+)b。
(?>和)是原子组的开始和结束,它表示一旦该组匹配并且引擎经由该组,则它一定不能回溯到该组中。
然后,利用我们之前针对aaaa进行匹配的示例,则将发生这种情形:

Go引擎开​​始匹配 a+。
此操作是贪婪的,因此它匹配第一个a,然后匹配aa,然后匹配 aaa,然后匹配 aaaa。
然后,它会显示在输入的末端。
没有匹配的b,因此匹配失落败。

简短得多,这只是一个大略的示例。
因此,开拓职员可以自己进行此剖析并找得手动插入原子组的位置,但是,实际上,有多少开拓职员认为这样做或花费韶光呢?

相反,.NET 5现在将正则表达式作为节点树优化阶段的一部分进行剖析,在创造原子组不会产生语义差异但可以帮助避免回溯的地方添加原子组。
例如:

a+b将变成(?>a+)b1,由于没有任何a+可以“回馈”与b相匹配的内容

\d+\s将变成(?>\d+)(?>\s),由于没有任何可以匹配\d的东西也可以匹配\s,并且\s在表达式的末端。

a([xyz]|hello)将变为(?>a)([xyz]|hello),由于在成功匹配中,a可以随着x,y,z或h,并且没有与任何这些重叠。

这只是.NET 5现在将实行的树重写的一个示例。
它将进行其他重写,部分目的是肃清回溯。
例如,现在它将合并彼此相邻的各种形式的循环。
考虑退化的例子aaaaaaab。
在.NET 5中,现在将其重写为功能上等效的ab,然后根据前面的谈论将其进一步重写为(?>a)b。
这将潜在的非常昂贵的实行转换为具有线性实行韶光的实行。
由于我们正在处理不同的算法繁芜性,因此显示示例基准险些没故意义,但是无论如何我还是会这样做,只是为了好玩:

Copyusing BenchmarkDotNet.Attributes;using BenchmarkDotNet.Running;using System.Text.RegularExpressions; public class Program{ static void Main(string[] args) => BenchmarkSwitcher.FromAssemblies(new[] { typeof(Program).Assembly }).Run(args); private Regex _regex = new Regex("aaaaaaab", RegexOptions.Compiled); [Benchmark] public bool IsMatch() => _regex.IsMatch("aaaaaaaaaaaaaaaaaaaaa");}

MethodToolchainMeanErrorStdDevRatioIsMatch\master\corerun.exe379.2 ns2.52 ns2.36 ns0.000IsMatch\netcore31\corerun.exe22,367,426.9 ns123,981.09 ns115,971.99 ns1.000

回溯减少不仅限于循环。
轮换表示回溯的另一个来源,由于实现办法的匹配办法与您手动匹配时的办法类似:考试测验一个轮换分支并连续进行,如果匹配失落败,请返回并考试测验下一个分支,依此类推。
因此,减少交替产生的回溯也是有用的。

现在实行的此类重写之一与交替前缀分解有关。
考虑针对文本什么是表达式(?:this|that)的表达式。
引擎将匹配内容,然后考试测验与此匹配。
它不会匹配,因此它将回溯并考试测验与此匹配。
但是交替的两个分支都以th开头。
如果我们将其打消在外,然后将表达式重写为th(?:is|at),则现在可以避免回溯。
引擎将匹配,然后考试测验将th与它匹配,然后失落败,仅此而已。

这种优化还终极使更多文本暴露给FindFirstChar利用的现有优化。
如果模式的开头有多个固定字符,则FindFirstChar将利用Boyer-Moore实现在输入字符串中查找该文本。
暴露给Boyer-Moore算法的模式越大,在快速找到匹配并最小化将导致FindFirstChar退出到Go引擎的误报中所能做的越好。
通过从这种交替中拉出文本,在这种情形下,我们增加了Boyer-Moore可用的文本量。

作为另一个干系示例,.NET 5现在创造纵然开拓职员未指定也可以隐式锚定表达式的情形,这也有助于肃清回溯。
考虑用hello匹配abcdefghijk。
该实现将从位置0开始,并在该位置打算表达式。
这样做会将全体字符串abcdefghijk与.匹配,然后从那里回溯以考试测验匹配hello,这将无法完成。
引擎将使匹配失落败,然后我们将升至下一个位置。
然后,引擎将把字符串bcdefghijk的别的部分与.进行匹配,然后从那里回溯以考试测验匹配hello,这将再次失落败。
等等。
在这里不雅观察到的是,通过碰到下一个位置进行的重试常日不会成功,并且表达式可以隐式地锚定为仅在行的开头匹配。
然后,FindFirstChar可以跳过可能不匹配的位置,并避免在这些位置考试测验进行引擎匹配。

Copyusing BenchmarkDotNet.Attributes;using BenchmarkDotNet.Diagnosers;using BenchmarkDotNet.Running;using System.Text.RegularExpressions;public class Program{ static void Main(string[] args) => BenchmarkSwitcher.FromAssemblies(new[] { typeof(Program).Assembly }).Run(args); private readonly Regex _regex = new Regex(@".text", RegexOptions.Compiled); [Benchmark] public bool IsMatch() => _regex.IsMatch("This is a test.\nDoes it match this?\nWhat about this text?");}

MethodToolchainMeanErrorStdDevRatioIsMatch\master\corerun.exe644.1 ns3.63 ns3.39 ns0.21IsMatch\netcore31\corerun.exe3,024.9 ns22.66 ns20.09 ns1.00

(只是为了清楚起见,许多正则表达式仍将在 .NET 5 中采取回溯,因此开拓职员仍旧须要谨慎运行不可信的正则表达式。

Regex. 静态方法和并发#

Regex类同时公开实例方法和静态方法。
静态方法紧张是为了方便起见,由于它们仍旧须要在Regex实例上利用和操作。
每次利用这些静态方法之一时,该实现都可以实例化一个新的Regex并经历完全的解析/优化/代码天生例程,但是在某些情形下,这将摧残浪费蹂躏大量的韶光和空间。
相反,Regex会保留最近利用的Regex工具的缓存,并按使它们唯一的所有内容(例如,模式,RegexOptions乃至在CurrentCulture下(由于这可能会影响IgnoreCase匹配)。
此缓存的大小受到限定,以Regex.CacheSize为上限,因此该实现采取了最近最少利用的(LRU)缓存:当缓存已满并且须要添加另一个Regex时,实现将丢弃最近最少利用的项。
缓存。

实现这种LRU缓存的一种大略方法是利用链接列表:每次访问某项时,它都会从列表中删除并重新添加到最前面。
但是,这种方法有一个很大的缺陷,尤其是在并发天下中:同步。
如果每次读取实际上都是一个突变,则我们须要确保并发读取(并发突变)不会毁坏列表。
这样的列表正是.NET早期版本所采取的列表,并且利用了全局锁来保护它。
在.NET Core 2.1中,社区成员提交的一项不错的变动通过许可访问最近利用的无锁项在某些情形下对此进行了改进,从而提高了通过静态利用相同Regex的事情负载的吞吐量和可伸缩性。
方法反复。
但是,对付其他情形,实现仍旧锁定在每种用法上。

通过查看诸如Concurrency Visualizer之类的工具,可以看到此锁定的影响,该工具是Visual Studio的扩展,可在其扩展程序库中利用。
通过在剖析器下运行这样的示例运用程序:

Copyusing System.Text.RegularExpressions;using System.Threading.Tasks; class Program{ static void Main() { Parallel.Invoke( () => { while (true) Regex.IsMatch("abc", "^abc$"); }, () => { while (true) Regex.IsMatch("def", "^def$"); }, () => { while (true) Regex.IsMatch("ghi", "^ghi$"); }, () => { while (true) Regex.IsMatch("jkl", "^jkl$"); }); }}

我们可以看到这样的图像:

每行都是一个线程,它是此Parallel.Invoke的一部分。
绿色区域是线程实际实行代码的韶光。
黄色区域表示操作系统已抢占该线程的缘故原由,由于该线程须要内核运行另一个线程。
赤色区域表示线程被阻挡等待某物。
在这种情形下,所有赤色是由于线程正在等待Regex缓存中的共享全局锁。

在.NET 5中,图片看起来像这样:

把稳,没有更多的赤色部分。
这是由于缓存已被重写为完备无锁的读取; 唯一得到锁的韶光是将新的Regex添加到缓存中,但是纵然发生这种情形,其他线程也可以连续从缓存中读取实例并利用它们。
这意味着,只要为运用程序及其常规利用的Regex静态方法精确调度Regex.CacheSize的大小,此类访问将不再招致它们过去的延迟。
到本日为止,该值默认为15,但是该属性具有设置器,因此可以对其进行变动以更好地知足运用程序的需求。

静态方法的分配也得到了改进,方法是精确地变动缓存内容,从而避免分配不必要的包装工具。
我们可以通过上一个示例的修正版本看到这一点:

Copyusing System.Text.RegularExpressions;using System.Threading.Tasks;class Program{ static void Main() { Parallel.Invoke( () => { for (int i = 0; i < 10_000; i++) Regex.IsMatch("abc", "^abc$"); }, () => { for (int i = 0; i < 10_000; i++) Regex.IsMatch("def", "^def$"); }, () => { for (int i = 0; i < 10_000; i++) Regex.IsMatch("ghi", "^ghi$"); }, () => { for (int i = 0; i < 10_000; i++) Regex.IsMatch("jkl", "^jkl$"); }); }}

利用Visual Studio中的.NET工具分配跟踪工具运行它。
左边是.NET Core 3.1,右边是.NET 5 Preview 2:

特殊要把稳的是,左侧包含40,000个分配的行,而右侧只有4个。

其他开销减少#

我们已经先容了.NET 5中对正则表达式进行的一些关键改进,但该列表绝不是完全的。
到处都有一些较小的优化清单,只管我们不能在这里列举所有的优化清单,但我们可以逐步先容更多。

在某些地方,我们已经采取了前面谈论过的矢量化形式。
例如,当利用RegexOptions.Compiled且该模式包含一个字符串字符串时,编译器将分别检讨每个字符。
如果查看诸如abcd之类的表达式的反编译代码,就会看到以下内容:

Copyif (4 <= runtextend - runtextpos && runtext[runtextpos] == 'a' && runtext[runtextpos + 1] == 'b' && runtext[runtextpos + 2] == 'c' && runtext[runtextpos + 3] == 'd')

在.NET 5中,当利用DynamicMethod创建编译后的代码时,我们现在考试测验比较Int64值(在64位系统上,或在32位系统上比较Int32),而不是比较单个字符。
这意味着对付上一个示例,我们现在改为天生与此类似的代码:

Copyif (3u < (uint)readOnlySpan.Length && (long)readOnlySpan._pointer == 28147922879250529L)

(我说“类似”,由于我们无法在C#中表示天生的确切IL,这与利用Unsafe类型的成员更加同等。
)我们这里不必担心字节顺序问题,由于天生用于比较的Int64/Int32值的代码与加载用于比较的输入值的同一台打算机(乃至在同一进程中)发生。

另一个示例是先前在先前天生的代码示例中实际显示的内容,但已被粉饰。
在比较@"a\sb"表达式的输出时,您可能之前已经把稳到,以前的代码包含对CheckTimeout()的调用,但是新代码没有。
此CheckTimeout()函数用于检讨我们的实行韶光是否超过了Regex布局时供应给其的超市价所许可的韶光。
但是,在没有供应超时的情形下利用的默认超时是“无限”,因此“无限”是非常常见的值。
由于我们永久不会超过无限超时,因此当我们为RegexOptions.Compiled正则表达式编译代码时,我们会检讨超时,如果是无限超时,则跳过天生这些CheckTimeout()调用。

在其他地方也存在类似的优化。
例如,默认情形下,Regex实行区分大小写的比较。
仅在指定RegexOptions.IgnoreCase的情形下(或者表达式本身包含实行不区分大小写的匹配的指令)才利用不区分大小写的比较,并且仅当利用不区分大小写的比较时,我们才须要访问CultureInfo.CurrentCulture以确定如何进行比较。
此外,如果指定了RegexOptions.InvariantCulture,则我们也无需访问CultureInfo.CurrentCulture,由于它将永久不会利用。
所有这些意味着,如果我们证明不再须要它,则可以避免天生访问CultureInfo.CurrentCulture的代码。
最主要的是,我们可以通过发出对char.ToLowerInvariant而不是char.ToLower(CultureInfo.InvariantCulture)的调用来使RegexOptions.InvariantCulture更快,尤其是由于.NET 5中ToLowerInvariant也得到了改进(还有另一个示例,个中将Regex变动为利用其他框架功能时,只要我们改进这些已利用的功能,它就会隐式受益。

另一个有趣的变动是Regex.Replace和Regex.Split。
这些方法被实现为对Regex.Match的封装,将其功能分层。
但是,这意味着每次找到匹配项时,我们都将退出扫描循环,逐步遍历抽象的各个层次,在匹配项上实行事情,然后调回引擎,以精确的办法进行事情返回到扫描循环,依此类推。
最主要的是,每个匹配项都须要创建一个新的Match工具。
现在在.NET 5中,这些方法在内部利用了一个专用的基于回调的循环,这使我们能够勾留在严格的扫描循环中,并一遍又一各处重用同一个Match工具(如果公开公开,这是不屈安的,但是可以作为内部履行细节来完成)。
在实现“更换”中利用的内存管理也已调度为专注于跟踪要更换或不更换的输入区域,而不是跟踪每个单独的字符。
这样做的终极结果可能对吞吐量和内存分配都产生相称大的影响,尤其是对付输入量非常长且更换次数很少的输入。

Copyusing BenchmarkDotNet.Attributes;using BenchmarkDotNet.Running;using System.Linq;using System.Text.RegularExpressions; [MemoryDiagnoser]public class Program{ static void Main(string[] args) => BenchmarkSwitcher.FromAssemblies(new[] { typeof(Program).Assembly }).Run(args); private Regex _regex = new Regex("a", RegexOptions.Compiled); private string _input = string.Concat(Enumerable.Repeat("abcdefghijklmnopqrstuvwxyz", 1_000_000)); [Benchmark] public string Replace() => _regex.Replace(_input, "A");}

MethodToolchainMeanErrorStdDevRatioGen 0Gen 1Gen 2AllocatedReplace\master\corerun.exe93.79 ms1.120 ms0.935 ms0.45–––81.59 MBReplace\netcore31\corerun.exe209.59 ms3.654 ms3.418 ms1.0033666.6667666.6667666.6667371.96 MB

看看效果#

所有这些结合在一起,可以在各种基准上产生明显更好的性能。
为相识释这一点,我在网上搜索了正则表达式基准并进行了几次测试。

mariomka/regex-benchmark的基准测试已经具有C#版本,因此大略地编译和运行这很随意马虎:

Copyusing System;using System.IO;using System.Text.RegularExpressions;using System.Diagnostics;class Benchmark{ static void Main(string[] args) { if (args.Length != 1) { Console.WriteLine("Usage: benchmark <filename>"); Environment.Exit(1); } StreamReader reader = new System.IO.StreamReader(args[0]); string data = reader.ReadToEnd(); // Email Benchmark.Measure(data, @"[\w\.+-]+@[\w\.-]+\.[\w\.-]+"); // URI Benchmark.Measure(data, @"[\w]+://[^/\s?#]+[^\s?#]+(?:\?[^\s#])?(?:#[^\s])?"); // IP Benchmark.Measure(data, @"(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9])"); } static void Measure(string data, string pattern) { Stopwatch stopwatch = Stopwatch.StartNew(); MatchCollection matches = Regex.Matches(data, pattern, RegexOptions.Compiled); int count = matches.Count; stopwatch.Stop(); Console.WriteLine(stopwatch.Elapsed.TotalMilliseconds.ToString("G", System.Globalization.CultureInfo.InvariantCulture) + " - " + count); }}

在我的机器上,这是利用.NET Core 3.1的掌握台输出:

Copy966.9274 - 92746.3963 - 530165.6778 - 5

以及利用.NET 5的掌握台输出:

Copy274.3515 - 92159.3629 - 530115.6075 - 5

破折号前的数字是实行韶光,破折号后的数字是答案(因此,第二个数字保持不变是一件好事)。
实行韶光急剧低落:分别提高了3.5倍,4.6倍和4.2倍!

我还找到了 https://zherczeg.github.io/sljit/regex_perf.html,它具有各种基准,但没有C#版本。
我将其转换为Benchmark.NET测试:

Copyusing BenchmarkDotNet.Attributes;using BenchmarkDotNet.Running;using System.IO;using System.Text.RegularExpressions; [MemoryDiagnoser]public class Program{ static void Main(string[] args) => BenchmarkSwitcher.FromAssemblies(new[] { typeof(Program).Assembly }).Run(args); private static string s_input = File.ReadAllText(@"d:\mtent12.txt"); private Regex _regex; [GlobalSetup] public void Setup() => _regex = new Regex(Pattern, RegexOptions.Compiled); [Params( @"Twain", @"(?i)Twain", @"[a-z]shing", @"Huck[a-zA-Z]+|Saw[a-zA-Z]+", @"\b\w+nn\b", @"[a-q][^u-z]{13}x", @"Tom|Sawyer|Huckleberry|Finn", @"(?i)Tom|Sawyer|Huckleberry|Finn", @".{0,2}(Tom|Sawyer|Huckleberry|Finn)", @".{2,4}(Tom|Sawyer|Huckleberry|Finn)", @"Tom.{10,25}river|river.{10,25}Tom", @"[a-zA-Z]+ing", @"\s[a-zA-Z]{0,12}ing\s", @"([A-Za-z]awyer|[A-Za-z]inn)\s" )] public string Pattern { get; set; } [Benchmark] public bool IsMatch() => _regex.IsMatch(s_input);}

并对照该页面供应的大约20MB文本文件输入运行它,得到以下结果:

MethodToolchainPatternMeanRatioIsMatch\master\corerun.exe(?i)T(…)Finn [31]12,703.08 ns0.32IsMatch\netcore31\corerun.exe(?i)T(…)Finn [31]40,207.12 ns1.00IsMatch\master\corerun.exe(?i)Twain159.81 ns0.84IsMatch\netcore31\corerun.exe(?i)Twain189.49 ns1.00IsMatch\master\corerun.exe([A-Z(…)nn)\s [29]6,903,345.70 ns0.10IsMatch\netcore31\corerun.exe([A-Z(…)nn)\s [29]67,388,775.83 ns1.00IsMatch\master\corerun.exe.{0,2(…)Finn) [35]1,311,160.79 ns0.68IsMatch\netcore31\corerun.exe.{0,2(…)Finn) [35]1,942,021.93 ns1.00IsMatch\master\corerun.exe.{2,4(…)Finn) [35]1,202,730.97 ns0.67IsMatch\netcore31\corerun.exe.{2,4(…)Finn) [35]1,790,485.74 ns1.00IsMatch\master\corerun.exeHuck[(…)A-Z]+ [26]282,030.24 ns0.01IsMatch\netcore31\corerun.exeHuck[(…)A-Z]+ [26]19,908,290.62 ns1.00IsMatch\master\corerun.exeTom.{(…)5}Tom [33]8,817,983.04 ns0.09IsMatch\netcore31\corerun.exeTom.{(…)5}Tom [33]94,075,640.48 ns1.00IsMatch\master\corerun.exeTomS(…)Finn [27]39,214.62 ns0.14IsMatch\netcore31\corerun.exeTomS(…)Finn [27]281,452.38 ns1.00IsMatch\master\corerun.exeTwain64.44 ns0.77IsMatch\netcore31\corerun.exeTwain83.61 ns1.00IsMatch\master\corerun.exe[a-q][^u-z]{13}x1,695.15 ns0.09IsMatch\netcore31\corerun.exe[a-q][^u-z]{13}x19,412.31 ns1.00IsMatch\master\corerun.exe[a-zA-Z]+ing3,042.12 ns0.31IsMatch\netcore31\corerun.exe[a-zA-Z]+ing9,896.25 ns1.00IsMatch\master\corerun.exe[a-z]shing28,212.30 ns0.24IsMatch\netcore31\corerun.exe[a-z]shing117,954.06 ns1.00IsMatch\master\corerun.exe\b\w+nn\b32,278,974.55 ns0.21IsMatch\netcore31\corerun.exe\b\w+nn\b152,395,335.00 ns1.00IsMatch\master\corerun.exe\s[a-(…)ing\s [21]1,181.86 ns0.23IsMatch\netcore31\corerun.exe\s[a-(…)ing\s [21]5,161.79 ns1.00

这些比例中的一些非常有趣。

另一个是“The Computer Language Benchmarks Game”中的“ regex-redux”基准。
在dotnet/performance回购中利用了此实现,因此我运行了该代码:

MethodToolchainoptionsMeanErrorStdDevMedianMinMaxRatioRatioSDGen 0Gen 1Gen 2AllocatedRegexRedux_5\master\corerun.exeCompiled7.941 ms0.0661 ms0.0619 ms7.965 ms7.782 ms8.009 ms0.300.01–––2.67 MBRegexRedux_5\netcore31\corerun.exeCompiled26.311 ms0.5058 ms0.4731 ms26.368 ms25.310 ms27.198 ms1.000.001571.4286––12.19 MB

因此,在此基准上,.NET 5的吞吐量是.NET Core 3.1的3.3倍。

呼吁社区行动#

我们希望您的反馈和贡献有多种办法。

下载.NET 5 Preview 2并利用正则表达式进行考试测验。
您看到可衡量的收益了吗?如果是这样,请见告我们。
如果没有,也请见告我们,以便我们共同努力,为您最有代价的表达办法改进效果。

是否有对您很主要的特定正则表达式?如果是这样,请与我们分享;我们很乐意利用来自您的真实正则表达式,您的输入数据以及相应的预期结果来扩展我们的测试套件,以帮助确保在对我们进行进一步改进时,不会退回对您而言主要的事情代码库。
实际上,我们欢迎PR到dotnet/runtime来以这种办法扩展测试套件。
您可以看到,除了成千上万个综合测试用例之外,Regex测试套件还包含大量示例,这些示例来自文档,教程和实际运用程序。
如果您认为该当在此处添加表达式,请提交PR。
作为性能改进的一部分,我们已经变动了很多代码,只管我们一贯在努力进行验证,但是肯定会漏入一些缺点。
您对自己的主要表达的反馈将有助于您实现这一目标!

与 .NET 5中已经完成的事情一样,我们还列出了可以探索的其他已知事情的清单,这些事情已编入dotnet/runtime#1349。
我们将在这里欢迎其他建议,更欢迎在此处概述的一些想法的实际原型设计或产品化(通过适当的性能审查,测试等)。
一些示例:

改进自动添加原子组的循环。
如本文所述,我们现在自动在多个位置插入原子组,我们可以检测到它们可能有助于减少回溯,同时保持语义相同。
我们知道,但是,我们的剖析存在一些空缺,补充这些空缺非常好。
例如,该实现现在将ab+c变动为(?>a)(?>b+)c,由于它将看到b+不会供应任何可以匹配c的东西,而a不会给出可以匹配b的任何东西(b+表示必须至少有一个b)。
但是,纵然后者得当,表达式abc也会转换为a(?>b)c而不是(?>a)(?>b)c。
这里的问题是,我们目前仅查看序列中的下一个节点,并且b可能匹配零项,这意味着a之后的下一个节点可能是c,而我们目前的眼力并不那么远。
改进原子基团自动交替添加的功能。
根据对交替的剖析,我们可以做更多的事情来将交替自动升级为原子。
例如,给定类似(Bonjour|Hello), .的表达式,我们知道,如果Bonjour匹配,则Hello也不可能匹配,因此可以将这种更换设置为原子的。
改进IndexOfAny的向量化。
如本文所述,我们现在尽可能利用内置函数,这样对这些表达式的改进也将使Regex受益(除了利用它们的所有其他事情负载)。
现在,我们在某些正则表达式中对IndexOfAny的依赖度很高,以至于它可以代表处理的很大一部分,例如在前面显示的“ regex redux”基准上,约有30%的韶光花费在IndexOfAny上。
这里有机会改进此功能,从而也改进Regex。
这由 dotnet/runtime#25023 单独引入。
制作DFA实现原型。
.NET正则表达式支持的某些方面很难利用基于DFA的正则表达式引擎来完成,但是某些操作该当是可以实现的,而不必担心。
例如,Regex.IsMatch不必关心捕获语义(.NET在捕获方面有一些额外的功能,这使其比其他实现更具寻衅性),因此,如果该表达式不包含诸如反向引用之类的问题布局,或环顾四周,对付IsMatch,我们可以探索利用基于DFA的引擎,并且有可能随着韶光的推移而得到更广泛的利用。
改进测试。
如果您对测试的兴趣超过对履行的兴趣,那么在这里也须要做一些有代价的事情。
我们的代码覆盖率已经很高,但是仍旧存在差距。
插入这些代码(并可能在该过程中找到无效代码)将很有帮助。
查找并合并其他经由适当容许的测试套件以供应更多涵盖各种表达式的内容也很有代价。