将IEnumerable<char>转换为字符串的最佳方法是什么?

58

为什么不能在string上使用流畅的语言?

例如:

var x = "asdf1234";
var y = new string(x.TakeWhile(char.IsLetter).ToArray());

有没有更好的方法将IEnumerable<char>转换为string

这是我做的一个测试:

class Program
{
  static string input = "asdf1234";
  static void Main()
  {
    Console.WriteLine("1000 times:");
    RunTest(1000, input);
    Console.WriteLine("10000 times:");
    RunTest(10000,input);
    Console.WriteLine("100000 times:");
    RunTest(100000, input);
    Console.WriteLine("100000 times:");
    RunTest(100000, "ffff57467");


    Console.ReadKey();

  }

  static void RunTest( int times, string input)
  {

    Stopwatch sw = new Stopwatch();

    sw.Start();
    for (int i = 0; i < times; i++)
    {
      string output = new string(input.TakeWhile(char.IsLetter).ToArray());
    }
    sw.Stop();
    var first = sw.ElapsedTicks;

    sw.Restart();
    for (int i = 0; i < times; i++)
    {
      string output = Regex.Match(input, @"^[A-Z]+", 
        RegexOptions.IgnoreCase).Value;
    }
    sw.Stop();
    var second = sw.ElapsedTicks;

    var regex = new Regex(@"^[A-Z]+", 
      RegexOptions.IgnoreCase);
    sw.Restart();
    for (int i = 0; i < times; i++)
    {
      var output = regex.Match(input).Value;
    }
    sw.Stop();
    var third = sw.ElapsedTicks;

    double percent = (first + second + third) / 100;
    double p1 = ( first / percent)/  100;
    double p2 = (second / percent )/100;
    double p3 = (third / percent  )/100;


    Console.WriteLine("TakeWhile took {0} ({1:P2}).,", first, p1);
    Console.WriteLine("Regex took {0}, ({1:P2})." , second,p2);
    Console.WriteLine("Preinstantiated Regex took {0}, ({1:P2}).", third,p3);
    Console.WriteLine();
  }
}

结果:

1000 times:
TakeWhile took 11217 (62.32%).,
Regex took 5044, (28.02%).
Preinstantiated Regex took 1741, (9.67%).

10000 times:
TakeWhile took 9210 (14.78%).,
Regex took 32461, (52.10%).
Preinstantiated Regex took 20669, (33.18%).

100000 times:
TakeWhile took 74945 (13.10%).,
Regex took 324520, (56.70%).
Preinstantiated Regex took 172913, (30.21%).

100000 times:
TakeWhile took 74511 (13.77%).,
Regex took 297760, (55.03%).
Preinstantiated Regex took 168911, (31.22%).

结论:我在怀疑该如何选择更好的方法,我想我会选择TakeWhile函数,它只会在第一次运行时最慢。

无论如何,我的问题是是否有任何方法通过限制TakeWhile函数的结果来优化性能。


2
请解释一下您所说的“最好”是什么意思:速度最快?内存占用最少?最易于理解? - LukeH
@LukeH 我已经决定选择fastests了。我的问题是是否有比 new string(x.TakeWhile(p).ToArray) 更好的方法? - Shimmy Weitzhandler
2
@LukeH:可能要取消删除你的解决方案:它比我的快得多。 - BrokenGlass
所有这些答案都引出了一个问题 - 为什么 System.Linq.Enumerable 中的 IEnumerable<char>.ToString() 没有被重写。 - Dave
@Dave,你不能使用扩展方法覆盖基本函数。然而,我希望看到的是在string构造函数中有一个重载,它接受一个IEnumerable<char>链接 - Shimmy Weitzhandler
8个回答

55

IEnumerable<char> 转换为 string,尝试如下方法:

string.Concat(x.TakeWhile(char.IsLetter));

我猜string.Concat在内部使用了StringBuilder。如果没有的话,那就非常奇怪了。因此,这个解决方案应该也能够表现得非常好。 - Stefan Paul Noack
仅限于 .Net 4.0。即使您自己编写了 3.5 版本的 TakeWhile,那么 string.Concat(IEnumerable<char>) 也不会按照您的预期执行。 - Dylan Nicholson

30

为.Net Core 2.1版本进行编辑

针对.Net Core 2.1版本的测试,我得到了如下结果:

1000000次“Concat”迭代花费了842毫秒。

1000000次“new String”迭代花费了1009毫秒。

1000000次“sb”迭代花费了902毫秒。

简而言之,如果你使用的是.Net Core 2.1或更高版本,Concat是最好的选择。


我已经将这个问题作为另一个问题的主题,但越来越多地,它成为了这个问题的直接答案。

我已经对将IEnumerable<char>转换为string的3种简单方法进行了性能测试,这些方法是:

new string

return new string(charSequence.ToArray());

连接函数

return string.Concat(charSequence)

StringBuilder

var sb = new StringBuilder();
foreach (var c in charSequence)
{
    sb.Append(c);
}

return sb.ToString();

根据链接问题中详细的我的测试结果,在对"一些相当小的测试数据"进行1000000次迭代后,我得到了如下结果:

1000000次迭代的"Concat"花费了1597毫秒。

1000000次迭代的"new string"花费了869毫秒。

1000000次迭代的"StringBuilder"花费了748毫秒。

这提示我没有充分的理由使用string.Concat来完成此任务。如果你想要简单性,使用新字符串方法;如果你想要性能,使用StringBuilder方法。

我必须说,实际上所有这些方法都可以正常工作,这可能只是过度优化。


3
我愿意牺牲121毫秒的时间,使用new string替代写三行额外代码来使用StringBuilder。#cleanCode。 - RBT
1
你的 MS Blog Post 链接指向了你的 Stack Overflow 问题,而不是博客文章。 - NetMage

15

假设你主要是想提高性能,那么像这样的代码应该比你所展示的任何例子都要快得多:

string x = "asdf1234";
string y = x.LeadingLettersOnly();

// ...

public static class StringExtensions
{
    public static string LeadingLettersOnly(this string source)
    {
        if (source == null)
            throw new ArgumentNullException("source");

        if (source.Length == 0)
            return source;

        char[] buffer = new char[source.Length];
        int bufferIndex = 0;

        for (int sourceIndex = 0; sourceIndex < source.Length; sourceIndex++)
        {
            char c = source[sourceIndex];

            if (!char.IsLetter(c))
                break;

            buffer[bufferIndex++] = c;
        }
        return new string(buffer, 0, bufferIndex);
    }
}

嗯,我刚注意到你只需要字符串开头的字母,这种情况下,我期望BrokenGlass的答案是最快的。(再次声明,我没有实际进行基准测试来确认。) - LukeH
2
+1 预分配缓冲区可能是使其更快的原因,但这只是一个猜测 - 有限的测试显示它比使用 Substring() 更快。 - BrokenGlass

13

为什么不能在字符串上使用流畅语言?

是可以的。你在问题本身中就使用了它:

var y = new string(x.TakeWhile(char.IsLetter).ToArray());

没有更好的方法将 IEnumerable<char> 转换为字符串吗?

(我的假设是:)

框架没有这样的构造函数,因为字符串是不可变的,所以您必须遍历枚举两次,才能为字符串预分配内存。这并不总是一个选项,特别是如果输入是流。

唯一的解决方案是先推到支持数组或 StringBuilder,并在输入增长时重新分配。对于像字符串这样低级别的事情来说,这可能应该被认为是太隐藏了的机制。它还会把性能问题推到字符串类中,因为它鼓励人们使用一个不能尽可能快的机制。

这些问题可以通过要求用户使用 ToArray 扩展方法轻松解决。

正如其他人指出的那样,如果编写支持代码并将该支持代码包装在扩展方法中以获得清洁的接口,就可以实现您想要的目标(性能和表达式代码)。


1
顺便说一下,让它“流畅”的最好方法是,我添加到我的扩展库中一个 Join 重载,它接受一个 IEnumerable<char> 并返回 string - Shimmy Weitzhandler
9
匿名的点踩者不会有任何帮助。请说明你的理由,我会回应你的关注。 - Merlyn Morgan-Graham

9
你往往可以获得更好的性能表现。但是这会给你带来什么好处呢?除非这真的是应用程序的瓶颈,并且您已经测量过了,我会坚持使用 Linq TakeWhile() 版本:它是最可读和可维护的解决方案,这也是大多数应用程序所需要的。
如果你真的在寻找原始性能,你可以手动进行转换——在我的测试中,以下方法比 TakeWhile() 快了 4 倍以上(具体取决于输入字符串的长度)。但除非必要,否则我不会个人使用它。
int j = 0;
for (; j < input.Length; j++)
{
    if (!char.IsLetter(input[j]))
        break;
}
string output = input.Substring(0, j);

3
对于重复使用的情况,将其封装到一个助手方法中并没有什么问题。例如source.LeadingLettersOnly()会比new string(source.TakeWhile(char.IsLetter).ToArray())更易读,我的看法是这样的。 - LukeH
1
@LukeH:你的解决方案更快 - 请取消删除! - BrokenGlass
该函数的作用是将搜索查询与数千(100000)个字符串的前几个字符进行比较,因此性能才是最重要的。 - Shimmy Weitzhandler
@BrokenGlass:好的,我已经取消删除了。我还没有运行任何基准测试,但我很惊讶我的比你的快。我猜你的需要两个循环,首先是显式的循环,然后在Substring内部再有一个循环(尽管我会认为Substring会使用一些本地代码来尽可能快地复制所需数据)。 - LukeH
@LukeH: 那一行更易读了,但是支持代码并不更易读。我可能要为扩展方法编写许多单元测试,而Linq则只需进行代码审查。 - Merlyn Morgan-Graham
@Merlyn:没错,但是这些单元测试只需要编写一次。显然,如果我不需要性能,那么每次我都会选择LINQ版本,但OP强调他们的主要需求是性能。 - LukeH

7
return new string(foo.Select(x => x).ToArray());

2

我在LINQPad 7(dotnet 6.0.1)中进行了一些测试,使用了BenchmarkDotNet:

方法 平均值 误差 标准差
StringFromArray 76.35 微秒 1.482 微秒 1.522 微秒
StringConcat 100.93 微秒 0.675 微秒 0.631 微秒
StringBuilder 100.52 微秒 0.963 微秒 0.901 微秒
StringBuilderAggregate 116.80 微秒 1.714 微秒 1.519 微秒

测试代码:

void Main() => BenchmarkRunner.Run<CharsToString>();

public class CharsToString {
    private const int N = 10000;
    private readonly char[] data = new char[N];

    public CharsToString() {
        var random = new Random(42);
        for (var i = 0; i < data.Length; i++) {
            data[i] = (char)random.Next(0, 256);
        }
    }

    [Benchmark]
    public string StringFromArray()
        => new string(data.Where(char.IsLetterOrDigit).ToArray());

    [Benchmark]
    public string StringConcat()
        => string.Concat(data.Where(char.IsLetterOrDigit));

    [Benchmark]
    public string StringBuilder() {
        var sb = new StringBuilder();
        
        foreach (var c in data.Where(char.IsLetterOrDigit))
            sb.Append(c);
        
        return sb.ToString();
    }

    [Benchmark]
    public string StringBuilderAggregate() => data
        .Where(char.IsLetterOrDigit)
        .Aggregate(new StringBuilder(), (sb, c) => sb.Append(c))
        .ToString();
}

1
这篇答案旨在综合已提供的优秀答案中的以下方面:
  1. 易读性
  2. 未来证明/易于重构
  3. 快速
为此,使用了一个扩展方法 IEnumerable<char>
public static string Join(this IEnumerable<char> chars)
{
#if NETCOREAPP2_1_OR_GREATER
    return String.Concat(chars);
#else
    var sb = new System.Text.StringBuilder();
    foreach (var c in chars)
    {
        sb.Append(c);
    }

    return sb.ToString();
#endif
}

这包含了所有的基础知识。
  1. 它非常易读:

    var y = x.TakeWhile(char.IsLetter).Join();

  2. 如果将来有更好的方法,只需更改一个代码块即可更新所有转换。

  3. 它支持基于当前正在编译的.NET版本的最佳执行实现。


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