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

时间:2009-01-23 14:33:38

标签: c# string refactoring

我在自己的时间里一直在修补小函数,试图找到重构它们的方法(我最近阅读了Martin Fowler的书 Refactoring: Improving the Design of Existing Code )。我在更新它附近的代码库的另一部分时找到了以下函数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, @"(?<!^)(?=[A-Z])");
        string result = String.Join(" ", temp);
        return result;
    }

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

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

嗯,结果如下:

有10次迭代:

MakeNiceString took 2649 ticks
SplitCamelCase took 2502 ticks

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

10,000次迭代:

MakeNiceString took 121625 ticks
SplitCamelCase took 443001 ticks

重构MakeNiceString()

  

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

MakeNiceString took 124716 ticks
ImprovedMakeNiceString took 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 Took:   117369 Ticks   //Remove Conversion
RefactoredMakeNiceString Took:  38542 Ticks   //Using StringBuilder

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    Took:  45163 Ticks

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

替代重构:使用编译Regex

我在循环开始之前将正则表达式移到了右边,希望因为它只编译一次,它会执行得更快。我发现的(我确定我在某个地方有一个错误)是不会发生这样的事情:

static void runTest5()
        {
            Regex rg = new Regex(@"(?<!^)(?=[A-Z])", 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 Took                   139363 Ticks
SplitCamelCase Took                   489174 Ticks
ImprovedMakeNiceString Took           115478 Ticks
RefactoredMakeNiceString Took          38819 Ticks
RefactoredForEachMakeNiceString Took   44700 Ticks
CompiledRegex Took                    227021 Ticks

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

MakeNiceString Took                  38 ms
SplitCamelCase Took                 123 ms
ImprovedMakeNiceString Took          33 ms
RefactoredMakeNiceString Took        11 ms
RefactoredForEachMakeNiceString Took 12 ms
CompiledRegex Took                   63 ms

所以百分比增益是:

MakeNiceString                   38 ms   Baseline
SplitCamelCase                  123 ms   223% slower
ImprovedMakeNiceString           33 ms   13.15% faster
RefactoredMakeNiceString         11 ms   71.05% faster
RefactoredForEachMakeNiceString  12 ms   68.42% faster
CompiledRegex                    63 ms   65.79% slower

(请检查我的数学)

最后,我将用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);
            }


        }

问题

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

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

  

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

10 个答案:

答案 0 :(得分:17)

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<string,string> 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<char>(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, @"(?<!^)(?=[A-Z])");
        string result = String.Join(" ", temp);
        return result;
    }

    private static readonly Regex CachedRegex = new Regex("(?<!^)(?=[A-Z])");    
    private static string SplitCamelCaseCachedRegex(string str)
    {
        string[] temp = CachedRegex.Split(str);
        string result = String.Join(" ", temp);
        return result;
    }

    private static readonly Regex CompiledRegex =
        new Regex("(?<!^)(?=[A-Z])", RegexOptions.Compiled);    
    private static string SplitCamelCaseCompiledRegex(string str)
    {
        string[] temp = CompiledRegex.Split(str);
        string result = String.Join(" ", temp);
        return result;
    }

    private class SkipEnumerable<T> : IEnumerable<T>
    {
        private readonly IEnumerable<T> original;
        private readonly int skip;

        public SkipEnumerable(IEnumerable<T> original, int skip)
        {
            this.original = original;
            this.skip = skip;
        }

        public IEnumerator<T> GetEnumerator()
        {
            IEnumerator<T> 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

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

希望这会有所帮助......不要忘记,我一直没想到其他选择!

答案 1 :(得分:3)

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

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

答案 2 :(得分:2)

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

答案 3 :(得分:2)

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

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

答案 4 :(得分:2)

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

  

我一直以为foreach很漂亮   众所周知,比一个慢   循环,因为它必须使用迭代器。

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

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

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

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

答案 5 :(得分:2)

我知道他们对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, "[A-Z|,]" );

       foreach ( Match m in mc )
       {
           if ( m.Index > 0 )
           {
               sb.Insert( m.Index + Incrementer, SPACE );
               Incrementer++;
           }
       }

       return sb.ToString().TrimEnd();
   }

答案 6 :(得分:2)

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

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

答案 7 :(得分:1)

这是一个稍微优化的版本。我从以前的海报中获取了建议,但也以块方式附加到字符串构建器。这可能允许字符串构建器一次复制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();
    }

答案 8 :(得分:0)

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

(?<!^)(?=[^a-z])

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

(?<!^)(?=\P{Ll})

答案 9 :(得分:-2)

在C#中(.Net,真的)当你追加一个字符串时,后台会发生一些事情。现在,我忘记了具体细节,但它类似于:

string A = B + C;

A += D; A += E;

// ... rinse-repeat for 10,000 iterations

对于上面的每一行,.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 A = new StringBuilder(); A.Append(B); A.Append(C); // .. rinse/repeat for 10,000 times... string sA = A.ToString();

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

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

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