使用前缀、后缀和分隔符连接字符串的最快方法

14

更新

根据Mr Cheese的回答,似乎

public static string Join<T>(string separator, IEnumerable<T> values)

string.Join方法的重载版本通过使用StringBuilderCache类获得其优势。

有没有人对这个说法的正确性或原因有任何反馈?

我能否编写自己的重载版本呢?

public static string Join<T>(
    string separator,
    string prefix,
    string suffix,
    IEnumerable<T> values)

使用StringBuilderCache类的函数是什么?


提交我的回答后,我陷入了一些分析中,以确定哪个答案的性能最佳。

我编写了以下代码,它位于控制台Program类中,用于测试我的想法。

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

class Program
{
    static void Main()
    {
        const string delimiter = ",";
        const string prefix = "[";
        const string suffix = "]";
        const int iterations = 1000000;

        var sequence = Enumerable.Range(1, 10).ToList();

        Func<IEnumerable<int>, string, string, string, string>[] joiners =
            {
                Build,
                JoinFormat,
                JoinConcat
            };

        // Warmup
        foreach (var j in joiners)
        {
            Measure(j, sequence, delimiter, prefix, suffix, 5);
        }

        // Check
        foreach (var j in joiners)
        {
            Console.WriteLine(
                "{0} output:\"{1}\"",
                j.Method.Name,
                j(sequence, delimiter, prefix, suffix));
        }

        foreach (var result in joiners.Select(j => new
                {
                    j.Method.Name,
                    Ms = Measure(
                        j,
                        sequence,
                        delimiter,
                        prefix,
                        suffix,
                        iterations)
                }))
        {
            Console.WriteLine("{0} time = {1}ms", result.Name, result.Ms);
        }

        Console.ReadKey();
    }

    private static long Measure<T>(
        Func<IEnumerable<T>, string, string, string, string> func,
        ICollection<T> source,
        string delimiter,
        string prefix,
        string suffix,
        int iterations)
    {
        var stopwatch = new Stopwatch();

        stopwatch.Start();
        for (var i = 0; i < iterations; i++)
        {
            func(source, delimiter, prefix, suffix);
        }

        stopwatch.Stop();

        return stopwatch.ElapsedMilliseconds;
    }

    private static string JoinFormat<T>(
        IEnumerable<T> source,
        string delimiter,
        string prefix,
        string suffix)
    {
        return string.Format(
            "{0}{1}{2}",
            prefix,
            string.Join(delimiter, source),
            suffix);
    }

    private static string JoinConcat<T>(
        IEnumerable<T> source,
        string delimiter,
        string prefix,
        string suffix)
    {
        return string.Concat(
            prefix,
            string.Join(delimiter, source),
            suffix);
    }

    private static string Build<T>(
        IEnumerable<T> source,
        string delimiter,
        string prefix,
        string suffix)
    {
        var builder = new StringBuilder();
        builder = builder.Append(prefix);

        using (var e = source.GetEnumerator())
        {
            if (e.MoveNext())
            {
                builder.Append(e.Current);
            }

            while (e.MoveNext())
            {
                builder.Append(delimiter);
                builder.Append(e.Current);
            }
        }

        builder.Append(suffix);
        return builder.ToString();
    }
}

在命令行中以发布配置和优化模式运行代码,我得到了以下输出:

...

生成时间=1555毫秒

JoinFormat 时间=1715毫秒

JoinConcat 时间=1452毫秒

对我来说唯一的惊喜是 Join-Format 组合最慢。考虑了这个答案之后,这就显得有些合理了。string.Join 的输出由 string.Format 中的外部 StringBuilder 处理,这种方法固有的延迟让人不解。

经过思考,我仍然不太明白为什么 string.Join 可以更快。我已经阅读了有关它使用 FastAllocateString() 的资料,但不明白如何在调用 sequence 的每个成员的 ToString() 方法之前准确地预分配缓冲区。为什么 Join-Concat 组合更快呢?

一旦我明白了这一点,是否可能编写自己的 unsafe string Join 函数,接受额外的 prefix 和 suffix 参数,比“安全”的替代方案表现更好。

我尝试了几次,虽然它们可以工作,但并没有更快。


请注意,使用 string.Concat 等同于仅使用 + 运算符:prefix + string.Join(delimiter, source) + suffix - Jon Skeet
1
在启动计时器之前,我还会调用func(source, delimiter, prefix, suffix);一次(以避免JIT问题)。 - L.B
@JonSkeet 根据要求进行了编辑。代码已经转录,如有任何错误请见谅。 - Jodrell
@L.B 这就是 Main()\\Warmup 的目的。 - Jodrell
我建议不要计算时间,而是计算时钟周期。有时Windows会调节处理器的速度,这会导致错误的结果。计算时钟周期可以减少这种影响。 - John Alexiou
显示剩余3条评论
4个回答

4
为了回答你最初的问题,我认为答案在(令人惊叹的)Reflector工具中。你正在使用IEnumerable对象集合,这也导致String.Join方法中相同类型的重载被调用。有趣的是,这个函数与你的Build函数非常相似,因为它枚举了集合并使用了一个字符串构建器,这意味着它不需要提前知道所有字符串的长度。
public static string Join<T>(string separator, IEnumerable<T> values)
{

    if (values == null)
    {
        throw new ArgumentNullException("values");
    }
    if (separator == null)
    {
        separator = Empty;
    }
    using (IEnumerator<T> enumerator = values.GetEnumerator())
    {
        if (!enumerator.MoveNext())
        {
            return Empty;
        }
        StringBuilder sb = StringBuilderCache.Acquire(0x10);
        if (enumerator.Current != null)
        {
            string str = enumerator.Current.ToString();
            if (str != null)
            {
                sb.Append(str);
            }
        }
        while (enumerator.MoveNext())
        {
            sb.Append(separator);
            if (enumerator.Current != null)
            {
                string str2 = enumerator.Current.ToString();
                if (str2 != null)
                {
                    sb.Append(str2);
                }
            }
        }
        return StringBuilderCache.GetStringAndRelease(sb);
    }
}

似乎是在处理缓存的StringBuilders,我并不完全理解,但这可能是由于内部优化而导致速度更快。由于我正在使用笔记本电脑,所以在电源管理状态发生变化之前,我可能会被抓住,在包含“BuildCheat”方法(避免字符串构建器缓冲区容量加倍)的代码中重新运行了一次,时间与String.Join(IEnumerable)非常接近(也在调试器外部运行)。
构建时间= 1264毫秒
JoinFormat = 1282毫秒
JoinConcat = 1108毫秒
BuildCheat = 1166毫秒
private static string BuildCheat<T>(
    IEnumerable<T> source,
    string delimiter,
    string prefix,
    string suffix)
{
    var builder = new StringBuilder(32);
    builder = builder.Append(prefix);

    using (var e = source.GetEnumerator())
    {
        if (e.MoveNext())
        {
            builder.Append(e.Current);
        }

        while (e.MoveNext())
        {
            builder.Append(delimiter);
            builder.Append(e.Current);
        }
    }

    builder.Append(suffix);
    return builder.ToString();
}

你问题的最后一部分提到了FastAllocateString的使用,但是可以看到,在传递IEnumerable的重载方法中并没有调用它,只有在直接处理字符串时才会调用它。在创建最终输出之前,它确实会循环遍历字符串数组以计算它们的长度总和。

public static unsafe string Join(string separator, string[] value, int startIndex, int count)
{
    if (value == null)
    {
        throw new ArgumentNullException("value");
    }
    if (startIndex < 0)
    {
        throw new ArgumentOutOfRangeException("startIndex", Environment.GetResourceString("ArgumentOutOfRange_StartIndex"));
    }
    if (count < 0)
    {
        throw new ArgumentOutOfRangeException("count", Environment.GetResourceString("ArgumentOutOfRange_NegativeCount"));
    }
    if (startIndex > (value.Length - count))
    {
        throw new ArgumentOutOfRangeException("startIndex", Environment.GetResourceString("ArgumentOutOfRange_IndexCountBuffer"));
    }
    if (separator == null)
    {
        separator = Empty;
    }
    if (count == 0)
    {
        return Empty;
    }
    int length = 0;
    int num2 = (startIndex + count) - 1;
    for (int i = startIndex; i <= num2; i++)
    {
        if (value[i] != null)
        {
            length += value[i].Length;
        }
    }
    length += (count - 1) * separator.Length;
    if ((length < 0) || ((length + 1) < 0))
    {
        throw new OutOfMemoryException();
    }
    if (length == 0)
    {
        return Empty;
    }
    string str = FastAllocateString(length);
    fixed (char* chRef = &str.m_firstChar)
    {
        UnSafeCharBuffer buffer = new UnSafeCharBuffer(chRef, length);
        buffer.AppendString(value[startIndex]);
        for (int j = startIndex + 1; j <= num2; j++)
        {
            buffer.AppendString(separator);
            buffer.AppendString(value[j]);
        }
    }
    return str;
}

出于兴趣,我修改了您的程序,不使用泛型,并使JoinFormat和JoinConcat接受简单的字符串数组(无法轻松更改Build,因为它使用枚举器),因此String.Join使用上述其他实现。结果非常令人印象深刻:

JoinFormat时间 = 386ms

JoinConcat时间 = 226ms

也许您可以找到一种解决方案,既能充分利用快速的字符串数组,又能使用泛型输入...


我试了一下!我将IEnumerable改成了IList,以便使用带有.Count的数组,并且在输入越长时更具可扩展性。使用输入1到10,JoinConcat=1175ms,BuildBetter=1187ms。使用输入1000000000到1000000010:JoinConcat=1539ms,BuildBetter=1427ms。private static string BuildBetter( IList source, string delimiter, string prefix, string suffix) { string[] values = new string[source.Count]; for (int i = 0; i < source.Count; i++) { values[i] = source[i].ToString(); } return string.Concat(prefix, string.Join(delimiter, values), suffix); } - Mr Cheese
2
只是让你知道,你不需要使用反编译工具(如Reflector)来获取.NET框架源代码。你可以在这里获取用于编译发布的原始源代码http://referencesource.microsoft.com/netframework.aspx。它包括注释和原始变量名称,这可以使阅读更容易。(请注意,使用Internet Explorer下载源代码。参见此答案 - Christopher Currens

1
为了提供一些额外的信息,我已经在我的笔记本电脑(Core i7-2620M)上使用VS 2012运行了上面的代码,并查看了4.0和4.5框架之间是否有任何变化。第一次运行编译针对.Net Framework 4.0,然后是4.5。 框架4.0 构建时间= 1516ms
JoinFormat时间= 1407ms
JoinConcat时间= 1238ms 框架4.5 构建时间= 1421ms
JoinFormat时间= 1374ms
JoinConcat时间= 1223ms
很高兴看到新框架似乎更快,但奇怪的是我无法复制您原始结果中JoinFormat的低速性能。您能提供有关您的构建环境和硬件的详细信息吗?

1
有趣,它在Virtual Xeon 5160上使用.NET 4.0。 - Jodrell
稍微离题一下,由于生成器内部缓冲区的容量翻倍,Build的时间有些偏差。它没有特定的容量初始化,因此默认为16个字符,但输出21个字符,这意味着它已经进行了一次翻倍操作。它不再是一个通用解决方案,而是用于在此演示代码中消除此异常,只需按以下方式初始化生成器: `var builder = new StringBuilder(32);`这给出了BuildCheat的时间,大致处于JoinFormat和JoinConcat之间。 - Mr Cheese

-1

3
在哪里以及为什么?我不明白那会如何有所帮助。 - Jon Skeet
while (e.MoveNext())中,将2个Append指令替换为builder.AppendFormat("{0}{1}", delimiter, e.Current);是否有帮助呢?在方法private static string Build<T>内。 - Rui Jarimba
我个人会怀疑 - AppendFormat 代码将不得不在每次迭代中解析格式字符串,别忘了。不过你可以试试 :) - Jon Skeet
@RuiJarimba,如果你尝试使用我提供的代码,你会发现它更慢。我得到的时间是2501ms,而原来的时间是1856ms。如果你也有不同的结果,那就很有趣了。 - Jodrell

-3
最简单的解决方法(为字符串添加前缀和后缀):
string[] SelectedValues = { "a", "b", "c" };
string seperatedValues = string.Join("\n- ", SelectedValues);
seperatedValues = "- " + seperatedValues;

输出:
- a
- b
- c

您可以使用字符串构建器


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