我是否在削弱StringBuilder的效率?

52

我已经开始使用StringBuilder来代替直接拼接字符串,但它似乎缺少一个关键的方法。因此,我编写了一个扩展方法:

public void Append(this StringBuilder stringBuilder, params string[] args)
{
    foreach (string arg in args)
        stringBuilder.Append(arg);
}

这将以下一团糟:

StringBuilder sb = new StringBuilder();
...
sb.Append(SettingNode);
sb.Append(KeyAttribute);
sb.Append(setting.Name);

变成了这样:

sb.Append(SettingNode, KeyAttribute, setting.Name);

我可以使用 sb.AppendFormat("{0}{1}{2}",...,但这似乎更不可取,而且更难读懂。我的扩展方法是一个好方法吗,还是它会削弱 StringBuilder 的优点?我并不想过早地优化任何东西,因为我的方法更多是关于可读性而非速度,但我也想知道我没有在自己的脚上开枪。


7
需要指出的是,除非你已经有一个StringBuilder对象,否则直接字符串拼接会更快。例如:string s = "orig" + SettingNode + KeyAttribute + setting.Name; - hemp
@Hemp: 只是稍微的改动。它可以去掉 Append 调用,其他的都应该相同。当然,这是假设没有其他的 appends。 - Devon_C_Miller
@Hemp:关于字符串 s = string.Concat("orig", SettingNode, KeyAttribute, setting.Name),我认为这比使用“+”更快。 - Daniel Lo Nigro
@Daniel15:如果它们不相同,那么速度可能会更快。C#编译器会对字符串连接做出优化。请参见:http://bit.ly/94TRFG - hemp
10个回答

69

我认为你的扩展程序没有问题。如果它对你有效,那就很好。

我个人更喜欢:

sb.Append(SettingNode)
  .Append(KeyAttribute)
  .Append(setting.Name);

24
这样做吧!如果他们编写代码返回“this”,那么这完全是设计者的意图。 - Merlyn Morgan-Graham
2
@kenny 你是在问那种风格叫什么吗?据我所知,它被称为“流畅接口”。 - Maxim Zaslavsky
我更喜欢“流畅”的词汇!请修改 ;) - kenny

32

这类问题通常可以通过一个简单的测试案例来回答。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Diagnostics;

namespace SBTest
{
    class Program
    {
        private const int ITERATIONS = 1000000;

        private static void Main(string[] args)
        {
            Test1();
            Test2();
            Test3();
        }

        private static void Test1()
        {
            var sw = Stopwatch.StartNew();
            var sb = new StringBuilder();

            for (var i = 0; i < ITERATIONS; i++)
            {
                sb.Append("TEST" + i.ToString("00000"),
                          "TEST" + (i + 1).ToString("00000"),
                          "TEST" + (i + 2).ToString("00000"));
            }

            sw.Stop();
            Console.WriteLine("Testing Append() extension method...");
            Console.WriteLine("--------------------------------------------");
            Console.WriteLine("Test 1 iterations: {0:n0}", ITERATIONS);
            Console.WriteLine("Test 1 milliseconds: {0:n0}", sw.ElapsedMilliseconds);
            Console.WriteLine("Test 1 output length: {0:n0}", sb.Length);
            Console.WriteLine("");
        }

        private static void Test2()
        {
            var sw = Stopwatch.StartNew();
            var sb = new StringBuilder();

            for (var i = 0; i < ITERATIONS; i++)
            {
                sb.Append("TEST" + i.ToString("00000"));
                sb.Append("TEST" + (i+1).ToString("00000"));
                sb.Append("TEST" + (i+2).ToString("00000"));
            }

            sw.Stop();    
            Console.WriteLine("Testing multiple calls to Append() built-in method...");
            Console.WriteLine("--------------------------------------------");
            Console.WriteLine("Test 2 iterations: {0:n0}", ITERATIONS);
            Console.WriteLine("Test 2 milliseconds: {0:n0}", sw.ElapsedMilliseconds);
            Console.WriteLine("Test 2 output length: {0:n0}", sb.Length);
            Console.WriteLine("");
        }

        private static void Test3()
        {
            var sw = Stopwatch.StartNew();
            var sb = new StringBuilder();

            for (var i = 0; i < ITERATIONS; i++)
            {
                sb.AppendFormat("{0}{1}{2}",
                    "TEST" + i.ToString("00000"),
                    "TEST" + (i + 1).ToString("00000"),
                    "TEST" + (i + 2).ToString("00000"));
            }

            sw.Stop();
            Console.WriteLine("Testing AppendFormat() built-in method...");
            Console.WriteLine("--------------------------------------------");            
            Console.WriteLine("Test 3 iterations: {0:n0}", ITERATIONS);
            Console.WriteLine("Test 3 milliseconds: {0:n0}", sw.ElapsedMilliseconds);
            Console.WriteLine("Test 3 output length: {0:n0}", sb.Length);
            Console.WriteLine("");
        }
    }

    public static class SBExtentions
    {
        public static void Append(this StringBuilder sb, params string[] args)
        {
            foreach (var arg in args)
                sb.Append(arg);
        }
    }
}
在我的电脑上,输出结果为:
Testing Append() extension method...
--------------------------------------------
Test 1 iterations: 1,000,000
Test 1 milliseconds: 1,080
Test 1 output length: 29,700,006

Testing multiple calls to Append() built-in method...
--------------------------------------------
Test 2 iterations: 1,000,000
Test 2 milliseconds: 1,001
Test 2 output length: 29,700,006

Testing AppendFormat() built-in method...
--------------------------------------------
Test 3 iterations: 1,000,000
Test 3 milliseconds: 1,124
Test 3 output length: 29,700,006

你的扩展方法只比Append()方法略慢一点,比AppendFormat()方法稍快一些,但在所有3种情况下,差异都微不足道,不值得担心。因此,如果你的扩展方法可以提高代码的可读性,就使用它吧!


2
我觉得每次追加三个 "TEST" + i.ToString("00000") 会比将其附加到 StringBuilder 中所需的时间更长,因此最好使用常量字符串运行,以获得更好的结果。 - dlras2
+1 我之前的评论只是在挑剔,但是没错,这是一个好的测试! - dlras2
@Daniel,我刚才只是在创建动态字符串,以避免由于字符串内部化带来的任何优化。不确定它是否真正影响了测试,但我想可以使用常量运行测试进行双重检查。 :P - Chris
@Chris - 关于常量池的观点很好,我没有想到过。 - dlras2

9

创建额外的数组会带来一些开销,但我认为它不会太大。你需要进行测量。

如果创建字符串数组的开销很大,你可以通过提供多个重载来减轻它 - 一个用于两个参数,一个用于三个参数,一个用于四个参数等等...这样只有当你达到更高数量的参数(例如六个或七个)时,才需要创建数组。重载将如下所示:

public void Append(this builder, string item1, string item2)
{
    builder.Append(item1);
    builder.Append(item2);
}

public void Append(this builder, string item1, string item2, string item3)
{
    builder.Append(item1);
    builder.Append(item2);
    builder.Append(item3);
}

public void Append(this builder, string item1, string item2,
                   string item3, string item4)
{
    builder.Append(item1);
    builder.Append(item2);
    builder.Append(item3);
    builder.Append(item4);
}

// etc

然后使用params进行最后一次重载,例如:

public void Append(this builder, string item1, string item2,
                   string item3, string item4, params string[] otherItems)
{
    builder.Append(item1);
    builder.Append(item2);
    builder.Append(item3);
    builder.Append(item4);
    foreach (string item in otherItems)
    {
        builder.Append(item);
    }
}

我肯定这些(或者只是你原来的扩展方法)比使用 AppendFormat 更快 - 毕竟它需要解析格式字符串。

请注意,我没有让这些重载方法伪递归地调用彼此 - 我猜测它们会被内联,但如果它们没有被内联,建立新的堆栈帧等开销可能会变得显著。(如果我们到了这一步,我们假设数组的开销很大。)


Visual Studio似乎想要使用params string[] args而不是string arg0,string arg1等。我是否必须使用string arg0,string arg1,...,params string[] moreArgs才能使其正常工作? - dlras2
3
使用多个参数的重载函数的目的是避免每次使用带有params参数的方法时所创建的数组开销。这可能会显得有些过度,但这涉及到性能问题,因此才会有这样的回答。(我在一开始就说了这不会有太大的额外开销。)至于让一个调用另一个——我猜如果它正在被内联处理的话,那应该不会有什么问题,但否则每次都会产生一个额外的堆栈帧。 - Jon Skeet
@Daniel:你的评论不太清楚,但是没错——一开始你会得到一个带有各种“string”参数的方法调用,以及一个最后使用的params string[] args,只有在你有很多参数时才会使用。但正如我在答案开头所说的那样,我怀疑这里无论如何都没有太多的开销。我将编辑我的答案以使其更清晰。 - Jon Skeet
@Jon - 抱歉重新打开这个老问题,但我开始使用System.Array对象并注意到它既有GetValue(Int32[])又有GetValue(Int32, Int32, Int32)(以及其他方法)。Visual Studio似乎更喜欢非params方法。如何让编译器优先选择一个方法而不是另一个方法? - dlras2
@Daniel:如果你想显式地使用接受数组的重载函数,那就创建一个数组吧 :) 不过一般来说,不这样做会更好性能。 - Jon Skeet
显示剩余2条评论

3

除了一点额外的开销,我个人认为它没有任何问题。明显更易读。只要您传递合理数量的参数,我就不认为有问题。


2

就清晰度而言,您的扩展程序还可以。

如果您从未有超过5或6个项目,最好只使用.append(x).append(y).append(z)格式。

如果您正在处理成千上万个项目,则仅使用StringBuilder本身会带来性能提升。此外,每次调用该方法时都会创建数组。

因此,如果您是为了清晰度而这样做,那么可以。如果您是为了效率而这样做,那么您可能走错了道路。


1
我不会说你正在削弱它的效率,但当有更有效的方法可用时,你可能正在做一些低效的事情。AppendFormat 是我认为你想要的。如果经常使用的 {0}{1}{2} 字符串太丑陋了,我倾向于将我的格式字符串放在常量中,这样看起来与你的扩展差不多。
sb.AppendFormat(SETTING_FORMAT, var1, var2, var3);

1
我不知道AppendFormat更有效率。它确实只调用了 Append 一次,但底层可能是在做 StringBuildinger.Append(String.Format(...)),这会多出一个字符串的实例化。当然,无论哪种方式都只是猜测,直到有人反汇编并查看IL实际上是什么样子。 - STW
@STW: 我不确定,但我认为一个string.format可能比三个sb.Appends更有效率。我不确定。 - Jimmy Hoffa
我倾向于使用AppendFormat,特别是当字符串要显示给用户时;如果需要重新格式化字符串(例如用于本地化),则无需更改代码。 - Eric Brown
任何使用格式字符串的东西都必须对格式字符串进行一些运行时解析。这种方式不可能比多次追加更有效率。 - bruceboughton

1
潜在的速度可能更快,因为它执行最多一个重新分配/复制步骤,适用于许多附加操作。
public void Append(this StringBuilder stringBuilder, params string[] args)
{
    int required = stringBuilder.Length;
    foreach (string arg in args)
        required += arg.Length;
    if (stringBuilder.Capacity < required)
        stringBuilder.Capacity = required;
    foreach (string arg in args)
        stringBuilder.Append(arg);
}

整洁,但是一些时间证明会更好。 - Blorgbeard

1

我最近没有测试过,但在过去,StringBuilder 实际上比普通字符串拼接("this " + "that")慢,直到你进行大约 7 次拼接。

如果这是不在循环中发生的字符串拼接,您可能需要考虑是否应该使用 StringBuilder。(在循环中,我开始担心普通字符串拼接的分配问题,因为字符串是不可变的。)


0

Chris,

这个Jon Skeet的回答(第二个答案)的启发下,我略微改写了你的代码。 基本上,我添加了TestRunner方法,它运行传入的函数并报告经过的时间,消除了一些冗余的代码。 我不是自鸣得意,而是作为自己的编程练习。 我希望这会有所帮助。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Diagnostics;

namespace SBTest
{
  class Program
  {
    private static void Main(string[] args)
    {
      // JIT everything
      AppendTest(1);
      AppendFormatTest(1);

      int iterations = 1000000;

      // Run Tests
      TestRunner(AppendTest, iterations);
      TestRunner(AppendFormatTest, iterations);

      Console.ReadLine();
    }

    private static void TestRunner(Func<int, long> action, int iterations)
    {
      GC.Collect();

      var sw = Stopwatch.StartNew();
      long length = action(iterations);
      sw.Stop();

      Console.WriteLine("--------------------- {0} -----------------------", action.Method.Name);
      Console.WriteLine("iterations: {0:n0}", iterations);
      Console.WriteLine("milliseconds: {0:n0}", sw.ElapsedMilliseconds);
      Console.WriteLine("output length: {0:n0}", length);
      Console.WriteLine("");
    }

    private static long AppendTest(int iterations)
    {
      var sb = new StringBuilder();

      for (var i = 0; i < iterations; i++)
      {
        sb.Append("TEST" + i.ToString("00000"),
                  "TEST" + (i + 1).ToString("00000"),
                  "TEST" + (i + 2).ToString("00000"));
      }

      return sb.Length;
    }

    private static long AppendFormatTest(int iterations)
    {
      var sb = new StringBuilder();

      for (var i = 0; i < iterations; i++)
      {
        sb.AppendFormat("{0}{1}{2}",
            "TEST" + i.ToString("00000"),
            "TEST" + (i + 1).ToString("00000"),
            "TEST" + (i + 2).ToString("00000"));
      }

      return sb.Length;
    }
  }

  public static class SBExtentions
  {
    public static void Append(this StringBuilder sb, params string[] args)
    {
      foreach (var arg in args)
        sb.Append(arg);
    }
  }
}

这是输出结果:

--------------------- AppendTest -----------------------
iterations: 1,000,000
milliseconds: 1,274
output length: 29,700,006

--------------------- AppendFormatTest -----------------------
iterations: 1,000,000
milliseconds: 1,381
output length: 29,700,006

0

最终问题在于哪种方法会导致更少的字符串创建。我有一种感觉,使用扩展将导致更高的字符串计数而不是使用字符串格式。但性能可能不会有太大差异。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接