C#中的字符串基准 – 重构速度/可维护性

我在自己的时间里一直在修补小函数,试图找到重构它们的方法(我最近阅读了Martin Fowler的书“ 重构:改进现有代码的设计” )。 我在更新它附近的代码库的另一部分时发现了以下函数MakeNiceString() ,它看起来像是一个很好的候选人。 事实上,没有真正的理由来替换它,但是它足够小并且做了一些小的事情,因此很容易遵循,但仍然可以获得“良好”的体验。

 private static string MakeNiceString(string str) { char[] ca = str.ToCharArray(); string result = null; int i = 0; result += System.Convert.ToString(ca[0]); for (i = 1; i <= ca.Length - 1; i++) { if (!(char.IsLower(ca[i]))) { result += " "; } result += System.Convert.ToString(ca[i]); } return result; } static string SplitCamelCase(string str) { string[] temp = Regex.Split(str, @"(?<!^)(?=[AZ])"); string result = String.Join(" ", temp); return result; } 

第一个函数MakeNiceString()是我在工作中更新的一些代码中找到的函数。 该函数的目的是将ThisIsAString转换为This Is A String 。 它在代码中的六个位置使用,并且在整个方案中非常微不足道。

我将第二个函数纯粹作为学术练习构建,以确定使用正则表达式是否需要更长时间。

嗯,结果如下:

有10次迭代:

 MakeNiceString占用了2649个刻度
 SplitCamelCase获得2502个刻度

然而,它在长途运输中发生了巨大的变化:

10,000次迭代:

 MakeNiceString占用121625个刻度
 SplitCamelCase获得了443001个刻度

重构MakeNiceString()

重构MakeNiceString()的过程始于简单地删除正在发生的转换。 这样做会产生以下结果:

 MakeNiceString占用124716个刻度
 ImprovedMakeNiceString占用118486

这是重构#1之后的代码:

 private static string ImprovedMakeNiceString(string str) { //Removed Convert.ToString() char[] ca = str.ToCharArray(); string result = null; int i = 0; result += ca[0]; for (i = 1; i <= ca.Length - 1; i++) { if (!(char.IsLower(ca[i]))) { result += " "; } result += ca[i]; } return result; } 

重构#2 – 使用StringBuilder

我的第二个任务是使用StringBuilder而不是String 。 由于String是不可变的,因此在整个循环中创建了不必要的副本。 使用它的基准如下,代码如下:

 static string RefactoredMakeNiceString(string str) { char[] ca = str.ToCharArray(); StringBuilder sb = new StringBuilder((str.Length * 5 / 4)); int i = 0; sb.Append(ca[0]); for (i = 1; i <= ca.Length - 1; i++) { if (!(char.IsLower(ca[i]))) { sb.Append(" "); } sb.Append(ca[i]); } return sb.ToString(); } 

这导致以下基准:

 MakeNiceString Took:124497 Ticks // Original
 SplitCamelCase Took:464459 Ticks // Regex
 ImprovedMakeNiceString取得:117369 Ticks //删除转换
 RefactoredMakeNiceString Took:38542 Ticks //使用StringBuilder

for循环更改for foreach循环会导致以下基准测试结果:

 static string RefactoredForEachMakeNiceString(string str) { char[] ca = str.ToCharArray(); StringBuilder sb1 = new StringBuilder((str.Length * 5 / 4)); sb1.Append(ca[0]); foreach (char c in ca) { if (!(char.IsLower(c))) { sb1.Append(" "); } sb1.Append(c); } return sb1.ToString(); } 
 RefactoredForEachMakeNiceString取得:45163蜱虫

正如您所看到的那样,维护方面, foreach循环将是最容易维护并具有“最干净”的外观。 它比for循环略慢,但更容易遵循。

替代重构:使用编译的正则Regex

在循环开始之前我把Regex移到了正确的位置,希望因为它只编译一次,所以它会执行得更快。 我发现的(我确定我在某处有一个错误)是不会发生的,就像它应该:

 static void runTest5() { Regex rg = new Regex(@"(?<!^)(?=[AZ])", RegexOptions.Compiled); for (int i = 0; i < 10000; i++) { CompiledRegex(rg, myString); } } static string CompiledRegex(Regex regex, string str) { string result = null; Regex rg1 = regex; string[] temp = rg1.Split(str); result = String.Join(" ", temp); return result; } 

最终基准结果:

 MakeNiceString获得139363分
 SplitCamelCase获得489174分
 ImprovedMakeNiceString取得115478蜱虫
 RefactoredMakeNiceString取得38819蜱虫
 RefactoredForEachMakeNiceString取得了44700个蜱虫
 CompiledRegex取得227021蜱虫

或者,如果您更喜欢毫秒:

 MakeNiceString花了38毫秒
 SplitCamelCase花了123毫秒
 ImprovedMakeNiceString花了33毫秒
 RefactoredMakeNiceString花了11毫秒
 RefactoredForEachMakeNiceString花了12毫秒
 CompiledRegex花了63毫秒

所以增加的百分比是:

 MakeNiceString 38 ms基线
 SplitCamelCase 123 ms慢223%
 ImprovedMakeNiceString 33 ms快13.15%
 RefactoredMakeNiceString 11 ms更快71.05%
 RefactoredForEachMakeNiceString 12 ms更快68.42%
 CompiledRegex 63毫秒65.79%慢

(请检查我的数学)

最后,我将使用RefactoredForEachMakeNiceString()替换那里的内容,而我正在使用它,我将把它重命名为有用的东西,比如SplitStringOnUpperCase

基准测试:

要进行基准测试,我只需为每个方法调用调用一个新的Stopwatch

  string myString = "ThisIsAUpperCaseString"; Stopwatch sw = new Stopwatch(); sw.Start(); runTest(); sw.Stop(); static void runTest() { for (int i = 0; i < 10000; i++) { MakeNiceString(myString); } } 

问题

  • 是什么导致这些function在“长期”中变得如此不同,以及
  • 如何改进此functiona)更易于维护或b)运行更快?
  • 我如何对这些进行内存基准测试以查看哪些内存使用较少?

感谢您迄今为止的回复。 我已经插入了@Jon Skeet提出的所有建议,并希望得到关于我所提出的更新问题的反馈意见。

注意 :这个问题是为了探索在C#中重构字符串处理函数的方法。 我as is复制/粘贴了第一个代码。 我很清楚你可以在第一个方法中删除System.Convert.ToString() ,我就是这么做的。 如果有人知道删除System.Convert.ToString()有任何影响,那么知道也会有所帮助。

1)使用StringBuilder,最好设置合理的初始容量(例如字符串长度* 5/4,每四个字符允许一个额外的空格)。

2)尝试使用foreach循环而不是for循环 – 它可能更简单

3)您不需要首先将字符串转换为字符数组 – foreach将在字符串上工作,或使用索引器。

4)不要在任何地方进行额外的字符串转换 – 调用Convert.ToString(char)然后附加该字符串是没有意义的; 不需要单个字符串

5)对于第二个选项,只需在方法之外构建一次正则表达式。 尝试使用RegexOptions.Compiled。

编辑:好的,完整的基准测试结果。 我已经尝试了一些其他的东西,并且还使用相当多的迭代执行代码以获得更准确的结果。 这只能在Eee PC上运行,所以毫无疑问它会在“真正的”PC上运行得更快,但我怀疑广泛的结果是合适的。 首先是代码:

 using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Reflection; using System.Text; using System.Text.RegularExpressions; class Benchmark { const string TestData = "ThisIsAUpperCaseString"; const string ValidResult = "This Is A Upper Case String"; const int Iterations = 1000000; static void Main(string[] args) { Test(BenchmarkOverhead); Test(MakeNiceString); Test(ImprovedMakeNiceString); Test(RefactoredMakeNiceString); Test(MakeNiceStringWithStringIndexer); Test(MakeNiceStringWithForeach); Test(MakeNiceStringWithForeachAndLinqSkip); Test(MakeNiceStringWithForeachAndCustomSkip); Test(SplitCamelCase); Test(SplitCamelCaseCachedRegex); Test(SplitCamelCaseCompiledRegex); } static void Test(Func function) { Console.Write("{0}... ", function.Method.Name); Stopwatch sw = Stopwatch.StartNew(); for (int i=0; i < Iterations; i++) { string result = function(TestData); if (result.Length != ValidResult.Length) { throw new Exception("Bad result: " + result); } } sw.Stop(); Console.WriteLine(" {0}ms", sw.ElapsedMilliseconds); GC.Collect(); } private static string BenchmarkOverhead(string str) { return ValidResult; } private static string MakeNiceString(string str) { char[] ca = str.ToCharArray(); string result = null; int i = 0; result += System.Convert.ToString(ca[0]); for (i = 1; i <= ca.Length - 1; i++) { if (!(char.IsLower(ca[i]))) { result += " "; } result += System.Convert.ToString(ca[i]); } return result; } private static string ImprovedMakeNiceString(string str) { //Removed Convert.ToString() char[] ca = str.ToCharArray(); string result = null; int i = 0; result += ca[0]; for (i = 1; i <= ca.Length - 1; i++) { if (!(char.IsLower(ca[i]))) { result += " "; } result += ca[i]; } return result; } private static string RefactoredMakeNiceString(string str) { char[] ca = str.ToCharArray(); StringBuilder sb = new StringBuilder((str.Length * 5 / 4)); int i = 0; sb.Append(ca[0]); for (i = 1; i <= ca.Length - 1; i++) { if (!(char.IsLower(ca[i]))) { sb.Append(" "); } sb.Append(ca[i]); } return sb.ToString(); } private static string MakeNiceStringWithStringIndexer(string str) { StringBuilder sb = new StringBuilder((str.Length * 5 / 4)); sb.Append(str[0]); for (int i = 1; i < str.Length; i++) { char c = str[i]; if (!(char.IsLower(c))) { sb.Append(" "); } sb.Append(c); } return sb.ToString(); } private static string MakeNiceStringWithForeach(string str) { StringBuilder sb = new StringBuilder(str.Length * 5 / 4); bool first = true; foreach (char c in str) { if (!first && char.IsUpper(c)) { sb.Append(" "); } sb.Append(c); first = false; } return sb.ToString(); } private static string MakeNiceStringWithForeachAndLinqSkip(string str) { StringBuilder sb = new StringBuilder(str.Length * 5 / 4); sb.Append(str[0]); foreach (char c in str.Skip(1)) { if (char.IsUpper(c)) { sb.Append(" "); } sb.Append(c); } return sb.ToString(); } private static string MakeNiceStringWithForeachAndCustomSkip(string str) { StringBuilder sb = new StringBuilder(str.Length * 5 / 4); sb.Append(str[0]); foreach (char c in new SkipEnumerable(str, 1)) { if (char.IsUpper(c)) { sb.Append(" "); } sb.Append(c); } return sb.ToString(); } private static string SplitCamelCase(string str) { string[] temp = Regex.Split(str, @"(? : IEnumerable { private readonly IEnumerable original; private readonly int skip; public SkipEnumerable(IEnumerable original, int skip) { this.original = original; this.skip = skip; } public IEnumerator GetEnumerator() { IEnumerator ret = original.GetEnumerator(); for (int i=0; i < skip; i++) { ret.MoveNext(); } return ret; } IEnumerator IEnumerable.GetEnumerator() { return GetEnumerator(); } } } 

结果如下:

 BenchmarkOverhead... 22ms MakeNiceString... 10062ms ImprovedMakeNiceString... 12367ms RefactoredMakeNiceString... 3489ms MakeNiceStringWithStringIndexer... 3115ms MakeNiceStringWithForeach... 3292ms MakeNiceStringWithForeachAndLinqSkip... 5702ms MakeNiceStringWithForeachAndCustomSkip... 4490ms SplitCamelCase... 68267ms SplitCamelCaseCachedRegex... 52529ms SplitCamelCaseCompiledRegex... 26806ms 

正如您所看到的,字符串索引器版本是赢家 - 它也是非常简单的代码。

希望这会有所帮助......不要忘记,肯定会有其他我没有想过的选择!

您可能希望尝试将Regex对象实例化为类成员,并在创建时使用RegexOptions.Compiled选项。

目前,您正在使用Regex的静态Split成员,并且不会缓存正则表达式。 使用实例成员对象而不是静态方法应该可以进一步提高性能(从长远来看)。

尝试重构,以便在第二种方法中用于拆分字符串的正则表达式存储在静态方法中,并使用RegexOptions.Compiled选项构建。 有关此内容的更多信息: http : //msdn.microsoft.com/en-us/library/8zbs0h2f.aspx 。

我没有测试这个理论,但我想每次重新创建正则表达式都会非常费时。

这是对ctacke对Jon Skeet答案的评论的回应(评论的时间不长)

我一直认为foreach因为必须使用迭代器而比for循环更慢。

实际上,不,在这种情况下,foreach会更快。 索引访问是检查边界的(即,我在循环中检查三次在范围内:一次在for()中,一次在两个ca [i]中),这使得for循环比foreach慢。

如果C#编译器检测到特定语法:

  for(i = 0; i < ca.Length; i++) 

然后它将执行临时优化,删除内部绑定检查,使for()循环更快。 但是,因为在这里我们必须将ca [0]视为特殊情况(为了防止输出上的前导空格),我们无法触发该优化。

我知道他们对RegEx的看法,用它来解决问题,现在你有两个问题,但我仍然是粉丝,只是为了笑,这里是RegEx版本。 RegEx,带有一点启动,易于阅读,代码更少,并且可以让您轻松捕捉其他分隔符(就像我使用逗号一样)。

  s1 = MakeNiceString( "LookOut,Momma,There'sAWhiteBoatComingUpTheRiver" ) ); private string MakeNiceString( string input ) { StringBuilder sb = new StringBuilder( input ); int Incrementer = 0; MatchCollection mc; const string SPACE = " "; mc = Regex.Matches( input, "[AZ|,]" ); foreach ( Match m in mc ) { if ( m.Index > 0 ) { sb.Insert( m.Index + Incrementer, SPACE ); Incrementer++; } } return sb.ToString().TrimEnd(); } 

我的第一次重构是将方法的名称更改为更具描述性的名称。 MakeNiceString imo不是一个能告诉我这个方法做什么的名字。

PascalCaseToSentence怎么样? 不喜欢这个名字,但它比MakeNiceString更好。

使用StringBuilder而不是连接。 每个连接都在创建一个新的字符串实例并丢弃旧的。

这是一个稍微优化的版本。 我已经从以前的海报中获得了建议,但也以块方式附加到字符串构建器。 这可能允许字符串构建器一次复制4个字节,具体取决于字的大小。 我也删除了字符串分配,只需用str.length替换它。

  static string RefactoredMakeNiceString2(string str) { char[] ca = str.ToCharArray(); StringBuilder sb = new StringBuilder(str.Length); int start = 0; for (int i = 0; i < ca.Length; i++) { if (char.IsUpper(ca[i]) && i != 0) { sb.Append(ca, start, i - start); sb.Append(' '); start = i; } } sb.Append(ca, start, ca.Length - start); return sb.ToString(); } 

您的解决方案的正则表达式版本在结果上与原始代码不同。 也许代码的较大上下文避免了它们不同的区域。 原始代码将为不是小写字符的任何内容添加空格。 例如, "This\tIsATest"将在原始版本中变为"This \t Is A Test" ,但是"This\t Is A Test"与Regex版本相同。

 (? 

你想要的模式是否更接近,但即便如此,它仍然忽略了i18n的问题。 以下模式应该注意:

 (? 

在C#(。Net,真的)当你追加一个字符串时,后台会发生一些事情。 现在,我忘记了细节,但它是这样的:

字符串A = B + C;

A + = D; A + = E;

// …冲洗重复10,000次迭代

对于上面的每一行 ,.NET将:1)为A分配一些新内存.2)将字符串B复制到新内存中。 3)扩展内存以保持C. 4)将字符串C附加到A.

字符串A越长,花费的时间就越长。 除此之外,您执行此操作的次数越多,A获得的时间越长,所需的时间就越长。

但是,使用StringBuilder,您不会分配新内存,因此您可以跳过该问题。

如果你说 :

 StringBuilder A = new StringBuilder(); A.Append(B); A.Append(C); // .. rinse/repeat for 10,000 times... string sA = A.ToString(); 

StringBuilder(编辑:固定描述)在内存中有一个字符串。 它不需要为每个添加的子字符串重新分配整个字符串。 发出ToString()时,字符串已经以正确的格式附加。

一次拍摄而不是一个需要更长时间的循环。

我希望这有助于回答为什么花费的时间少得多。