原文:Split a string into lines without any allocation
本文所指的不创建对象,主要是指不在堆上创建新对象。
将字符串分成几行是很常见的。您可以这样写:
var str = "Nickname: meziantournName: Gerald Barre";
var lines = str.Split(new [] { 'r', 'n' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
// - 创建一个数组对象并为每一行创建一个 string 对象
// - Split 方法会创建新的对象
}
上面的代码为每一行创建一个字符串并分配一个数组,这样会创建了很多新对象。并且即使您只需要字符串的前面几行,它也会拆分整个字符串。或者您也可以使用 StringReader :
var reader = new StringReader(str);
string line;
while ((line = reader.ReadLine()) != null)
{
// - 每行创建一个 string 对象
// - ReadLine 方法会创建新的对象
}
但是 StringReader 仍然会为读取到的每一行创建一个字符串对象。
在最新的 .NET 中,可以利用 ReadOnlySpan<char> 避免在处理字符串时使用额外的内存。我的目标是实现一个不会创建新对象的方法 SplitLines ,具体到使用:
foreach (ReadOnlySpan<char> line in str.SplitLines())
{
// 这里不创建任何新对象
}
在开始写代码之前,先了解一些重要的C#/ .NET概念。
使代码与 foreach 运算符一起工作
foreach 关键字不仅可以枚举实现了 IEnumerable<T> 接口的对象,而且可以在满足以下条件的任何类型的实例上进行枚举
- 具有 public 无参数 GetEnumerator 方法,其返回类型为类,结构或接口类型,
- 该 GetEnumerator 方法的返回类型具有 public Current 属性,并且 public 无参数 MoveNext 方法的返回类型为 Boolean 。
var enumerable = new List<int> { 1, 2, 3 };
foreach(var item in enumerable)
{
Console.WriteLine(item);
}
//foreach 运算符其实会被编译器重写成以下形式
var enumerator = enumerable.GetEnumerator();
while (enumerator.MoveNext())
{
var item = enumerator.Current;
Console.WriteLine(item);
}
这意味着可以使用 GetEnumerator 方法可以返回具有 bool MoveNext() 方法和 Current 属性的结构(struct)。这样可以避免创建对象。
注意:在某些情况下,编译器可能会以更有效的方式重写foreach。例如,如果您枚举的对象的类型为 array / string / Span<T> / ReadOnlySpan<T>,则使用for循环重写foreach。
Span<T> 和 ReadOnlySpan<T>
这两个类型提供了任意连续内存区域的类型安全和内存安全的值类型表示。此类型可以表示任何类似数组(array,string,ArraySegment<T>)的类型,并且有用和数组相同的优化,所以性能很好。Span<T> 对于创建类似数组的对象的切片非常有用。对于 string 来说这和Substring
类似但是使用Span<T>
(struct) 并不会在堆上创建任何新对象。
ReadOnlySpan<int> array = new int[]{1,2,3,4,5,6};
ReadOnlySpan<int> slice = array[1..^1]; // [2,3,4,5]
ReadOnlySpan<char> str = "meziantou";
ReadOnlySpan<char> substring = str[0..3]; // mez
在这个例子中,ReadOnlySpan<char>
将允许我们使用 Slice 方法逐行对字符串进行分行。不像Substring
,Slice 不会在堆上创建新的字符串。
ref 结构
Span<T>
和ReadOnlySpan<T>
是ref struct
。ref 结构类型的实例在栈上分配。编译器会在尝试执行在堆上分配结构的操作时报错。例如:
- ref 结构不能是类或包含非引用结构的字段
这意味着我们将要实现的枚举类(enumerator)必须是包含以下ReadOnlySpan<char&
amp;gt;这个 ref 结构字段 - ref 结构无法实现接口
希望你不需要实现与 foreach 运算符兼容的接口 - ref 结构不能装箱到
System.ValueType
或System.Object
foreach 运算符不会强制转换迭代的值,但这个对我们不照成影响。
请注意,MSDN[1] 说明了其他限制,但这些限制不适用于本文的代码。
实现 SplitLines
现在我们已经了解 foreach 运算符和 ref 结构,让我们看一下代码:
using System;
public static class StringExtensions
{
public static LineSplitEnumerator SplitLines(this string str)
{
//因为 LineSplitEnumerator 是值类型,所以这里没有在堆上创建对象
return new LineSplitEnumerator(str.AsSpan());
}
//必须是一个 ref 结构并且包含字段 ReadOnlySpan<char>
public ref struct LineSplitEnumerator
{
private ReadOnlySpan<char> _str;
public LineSplitEnumerator(ReadOnlySpan<char> str)
{
_str = str;
Current = default;
}
//需要和 foreach 运算符兼容的方法
public LineSplitEnumerator GetEnumerator() => this;
public bool MoveNext()
{
var span = _str;
if (span.Length == 0) // 已经达到字符串的末端
return false;
var index = span.IndexOfAny('r', 'n');
if (index == -1) // 这个字符串仅包含一行
{
_str = ReadOnlySpan<char>.Empty; // 剩下的字符串是空的
Current = new LineSplitEntry(span, ReadOnlySpan<char>.Empty);
return true;
}
if (index < span.Length - 1 && span[index] == 'r')
{
// 尝试处理当 n 紧跟着 r 的情况
var next = span[index + 1];
if (next == 'n')
{
Current = new LineSplitEntry(span.Slice(0, index), span.Slice(index, 2));
_str = span.Slice(index + 2);
return true;
}
}
Current = new LineSplitEntry(span.Slice(0, index), span.Slice(index, 1));
_str = span.Slice(index + 1);
return true;
}
public LineSplitEntry Current { get; private set; }
}
public readonly ref struct LineSplitEntry
{
public LineSplitEntry(ReadOnlySpan<char> line, ReadOnlySpan<char> separator)
{
Line = line;
Separator = separator;
}
public ReadOnlySpan<char> Line { get; }
public ReadOnlySpan<char> Separator { get; }
// This method allow to deconstruct the type, so you can write any of the following code
// foreach (var entry in str.SplitLines()) { _ = entry.Line; }
// foreach (var (line, endOfLine) in str.SplitLines()) { _ = line; }
// https://docs.microsoft.com/en-us/dotnet/csharp/deconstruct#deconstructing-user-defined-types
public void Deconstruct(out ReadOnlySpan<char> line, out ReadOnlySpan<char> separator)
{
line = Line;
separator = Separator;
}
// This method allow to implicitly cast the type into a ReadOnlySpan<char>, so you can write the following code
// foreach (ReadOnlySpan<char> entry in str.SplitLines())
public static implicit operator ReadOnlySpan<char>(LineSplitEntry entry) => entry.Line;
}
}
这是使用SplitLines()
方法的一些示例:
var str = "Nickname: meziantournName: Gerald Barre";
foreach (ReadOnlySpan<char> line in str.SplitLines())
{
Console.WriteLine(line);
}
foreach (var (line, endOfLine) in str.SplitLines())
{
Console.Write(line);
Console.Write(endOfLine);
}
foreach (var lineEntry in str.SplitLines())
{
Console.Write(lineEntry.Line);
Console.Write(lineEntry.EndOfLine);
}
测试跑分
让我们使用 BenchmarkDotNet 创建一个测试用例:
[MemoryDiagnoser]
public class SplitLinesBenchmark
{
private const string Data = "Nickname: meziantournFirstName: GeraldnLastName: Barre";
[Benchmark]
public void StringReader()
{
var reader = new StringReader(Data);
string line;
while ((line = reader.ReadLine()) != null)
{
}
}
[Benchmark]
public void Split()
{
foreach (var line in Data.Split(new char[] { 'r', 'n' }, StringSplitOptions.RemoveEmptyEntries))
{
}
}
[Benchmark]
public void Span()
{
foreach (ReadOnlySpan<char> item in Data.SplitLines())
{
}
}
}
public class Program
{
public static void Main(string[] args) => BenchmarkRunner.Run<SplitLinesBenchmark>();
}
不仅执行效率更高,而且根本不在堆上创建任何对象
参考
- ^Structure types (C# reference) https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/struct#ref-struct