用C#迭代字符串中单个字符的最快方法是什么
本文关键字:方法 是什么 字符 单个 迭代 字符串 | 更新日期: 2023-09-27 18:19:54
标题就是问题。以下是我试图通过研究来回答的问题。但我不相信我不知情的研究,所以我仍然提出了一个问题(在C#中迭代字符串中单个字符的最快方法是什么?)。
有时,我想一个接一个地循环字符串中的字符,比如在解析嵌套令牌时——这是正则表达式无法完成的。我想知道迭代字符串中各个字符的最快方法是什么,尤其是非常大的字符串。
我自己做了很多测试,结果如下。然而,有很多读者对.NET CLR和C#编译器有更深入的了解,所以我不知道我是否遗漏了一些明显的东西,或者我的测试代码中是否犯了错误。因此,我恳请大家作出集体回应。如果有人了解字符串索引器的实际工作方式,那将非常有帮助。(这是一个被编译成其他幕后功能的C#语言功能吗?还是CLR内置的功能?)。
使用流的第一个方法直接取自线程接受的答案:如何从字符串生成流?
测试
longString
是一个9910万个字符的字符串,由89份C#语言规范的纯文本版本组成。显示的结果是20次迭代。如果有"启动"时间(例如方法#3中隐式创建的数组的第一次迭代),我会单独测试,例如在第一次迭代后中断循环。
结果
根据我的测试,使用ToCharArray()方法将字符串缓存在char数组中是迭代整个字符串最快的方法。ToCharArray()方法是一项前期开销,后续对单个字符的访问速度略快于内置的索引访问器。
milliseconds
---------------------------------
Method Startup Iteration Total StdDev
------------------------------ ------- --------- ----- ------
1 index accessor 0 602 602 3
2 explicit convert ToCharArray 165 410 582 3
3 foreach (c in string.ToCharArray)168 455 623 3
4 StringReader 0 1150 1150 25
5 StreamWriter => Stream 405 1940 2345 20
6 GetBytes() => StreamReader 385 2065 2450 35
7 GetBytes() => BinaryReader 385 5465 5850 80
8 foreach (c in string) 0 960 960 4
更新:根据@Eric的评论,以下是在更正常的1.1M字符串(C#规范的一个副本)上进行100次迭代的结果。索引器和char数组仍然是最快的,其次是foreach(字符串中的char),然后是流方法。
milliseconds
---------------------------------
Method Startup Iteration Total StdDev
------------------------------ ------- --------- ----- ------
1 index accessor 0 6.6 6.6 0.11
2 explicit convert ToCharArray 2.4 5.0 7.4 0.30
3 for(c in string.ToCharArray) 2.4 4.7 7.1 0.33
4 StringReader 0 14.0 14.0 1.21
5 StreamWriter => Stream 5.3 21.8 27.1 0.46
6 GetBytes() => StreamReader 4.4 23.6 28.0 0.65
7 GetBytes() => BinaryReader 5.0 61.8 66.8 0.79
8 foreach (c in string) 0 10.3 10.3 0.11
使用的代码(单独测试;为简洁起见,显示在一起)
//1 index accessor
int strLength = longString.Length;
for (int i = 0; i < strLength; i++) { c = longString[i]; }
//2 explicit convert ToCharArray
int strLength = longString.Length;
char[] charArray = longString.ToCharArray();
for (int i = 0; i < strLength; i++) { c = charArray[i]; }
//3 for(c in string.ToCharArray)
foreach (char c in longString.ToCharArray()) { }
//4 use StringReader
int strLength = longString.Length;
StringReader sr = new StringReader(longString);
for (int i = 0; i < strLength; i++) { c = Convert.ToChar(sr.Read()); }
//5 StreamWriter => StreamReader
int strLength = longString.Length;
MemoryStream stream = new MemoryStream();
StreamWriter writer = new StreamWriter(stream);
writer.Write(longString);
writer.Flush();
stream.Position = 0;
StreamReader str = new StreamReader(stream);
while (stream.Position < strLength) { c = Convert.ToChar(str.Read()); }
//6 GetBytes() => StreamReader
int strLength = longString.Length;
MemoryStream stream = new MemoryStream(Encoding.Unicode.GetBytes(longString));
StreamReader str = new StreamReader(stream);
while (stream.Position < strLength) { c = Convert.ToChar(str.Read()); }
//7 GetBytes() => BinaryReader
int strLength = longString.Length;
MemoryStream stream = new MemoryStream(Encoding.Unicode.GetBytes(longString));
BinaryReader br = new BinaryReader(stream, Encoding.Unicode);
while (stream.Position < strLength) { c = br.ReadChar(); }
//8 foreach (c in string)
foreach (char c in longString) { }
接受的答案:
我解释了@CodeInChaos和Ben的笔记如下:
fixed (char* pString = longString) {
char* pChar = pString;
for (int i = 0; i < strLength; i++) {
c = *pChar ;
pChar++;
}
}
在短字符串上执行100次迭代是4.4ms,<0.1 ms st dev.
有没有理由不包括foreach
?
foreach (char c in text)
{
...
}
顺便问一下,这真的会成为您的性能瓶颈吗?迭代本身占总运行时间的比例是多少?
这种人工测试非常危险。值得注意的是,代码的//2和//3版本从未实际索引过字符串。抖动优化器只是丢弃代码,因为根本没有使用c变量。您只是在测量for()循环需要多长时间。除非查看生成的机器代码,否则您无法真正看到这一点。
将其更改为c += longString[i];
以强制使用数组索引器。
这当然是无稽之谈。仅配置文件真实代码。
TL;DR:简单的foreach
是迭代字符串的最快方法
对于人们来说:时代变了!
使用最新的.NET 64位JIT,不安全的版本实际上是最慢的
以下是BenchmarkDotNet的基准实现。从中,我得到了以下结果:
Method | Mean | Error | StdDev |
---------------- |----------:|----------:|----------:|
Indexing | 5.9712 us | 0.8738 us | 0.3116 us |
IndexingOnArray | 8.2907 us | 0.8208 us | 0.2927 us |
ForEachOnArray | 8.1919 us | 0.6505 us | 0.1690 us |
ForEach | 5.6946 us | 0.0648 us | 0.0231 us |
Unsafe | 7.2952 us | 1.1050 us | 0.3941 us |
有趣的是那些在数组副本上不起作用的。这表明索引和foreach
在性能上非常相似,相差5%,foreach
更快。使用unsafe
实际上比使用foreach
慢28%。
在过去,unsafe
可能是最快的选择,但JIT一直在变得更快、更智能。
作为参考,基准代码:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Horology;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;
namespace StringIterationBenchmark
{
public class StringIteration
{
public static void Main(string[] args)
{
var config = new ManualConfig();
config.Add(DefaultConfig.Instance);
config.Add(Job.Default
.WithLaunchCount(1)
.WithIterationTime(TimeInterval.FromMilliseconds(500))
.WithWarmupCount(3)
.WithTargetCount(6)
);
BenchmarkRunner.Run<StringIteration>(config);
}
private readonly string _longString = BuildLongString();
private static string BuildLongString()
{
var sb = new StringBuilder();
var random = new Random();
while (sb.Length < 10000)
{
char c = (char)random.Next(char.MaxValue);
if (!Char.IsControl(c))
sb.Append(c);
}
return sb.ToString();
}
[Benchmark]
public char Indexing()
{
char c = ''0';
var longString = _longString;
int strLength = longString.Length;
for (int i = 0; i < strLength; i++)
{
c |= longString[i];
}
return c;
}
[Benchmark]
public char IndexingOnArray()
{
char c = ''0';
var longString = _longString;
int strLength = longString.Length;
char[] charArray = longString.ToCharArray();
for (int i = 0; i < strLength; i++)
{
c |= charArray[i];
}
return c;
}
[Benchmark]
public char ForEachOnArray()
{
char c = ''0';
var longString = _longString;
foreach (char item in longString.ToCharArray())
{
c |= item;
}
return c;
}
[Benchmark]
public char ForEach()
{
char c = ''0';
var longString = _longString;
foreach (char item in longString)
{
c |= item;
}
return c;
}
[Benchmark]
public unsafe char Unsafe()
{
char c = ''0';
var longString = _longString;
int strLength = longString.Length;
fixed (char* p = longString)
{
var p1 = p;
for (int i = 0; i < strLength; i++)
{
c |= *p1;
p1++;
}
}
return c;
}
}
}
该代码与提供的代码相比有一些小的更改。从原始字符串中检索到的字符是|
-ed,并返回变量,然后返回值。原因是我们实际上需要对结果做些什么。否则,如果我们只是在字符串上迭代,比如:
//8 foreach (c in string)
foreach (char c in longString) { }
JIT可以自由地删除它,因为它可以推断出您实际上并没有观察到迭代的结果。通过|
处理数组中的字符并返回,BenchmarkDotNet将确保JIT无法执行此优化。
最快的答案是使用C++/CLI:如何:访问系统中的字符::字符串
这种方法使用指针算术对字符串中的字符进行迭代。没有副本,没有隐式范围检查,也没有每个元素的函数调用。
通过编写PtrToStringChars
的不安全的C#版本,很可能从C#获得(几乎不需要固定C++/CLI)相同的性能。
类似于:
unsafe char* PtrToStringContent(string s, out GCHandle pin)
{
pin = GCHandle.Alloc(s, GCHandleType.Pinned);
return (char*)pin.AddrOfPinnedObject().Add(System.Runtime.CompilerServices.RuntimeHelpers.OffsetToStringData).ToPointer();
}
请记得稍后致电GCHandle.Free
。
CodeInChaos的评论指出,C#为此提供了语法糖:
fixed(char* pch = s) { ... }
如果速度真的很重要,for
比foreach
快
for (int i = 0; i < text.Length; i++) {
char ch = text[i];
...
}
如果微优化对您来说非常重要,那么尝试一下。(为了简单起见,我假设输入字符串的长度是8的倍数)
unsafe void LoopString()
{
fixed (char* p = longString)
{
char c1,c2,c3,c4;
Int64 len = longString.Length;
Int64* lptr = (Int64*)p;
Int64 l;
for (int i = 0; i < len; i+=8)
{
l = *lptr;
c1 = (char)(l & 0xffff);
c2 = (char)(l >> 16);
c3 = (char)(l >> 32);
c4 = (char)(l >> 48);
lptr++;
}
}
}
只是开玩笑,永远不要使用这个代码:)