はじめに
今回はstring.Create
を用いて高速にstring
を生成する方法を紹介したいと思います。
概要
string.FastAllocateString
というinternalなメソッドが.NETにはあり、これを利用することで高速に文字列領域を確保することができます。
通常のオブジェクトと同じようにオブジェクトヘッダを持ちヒープ領域に確保され、同様に、原則new stringのみで生成可能です。StringBuilder.ToStringやEncoding.GetStringなども、最終的にnew stringを呼んで、新たな文字列を確保しています。
正確には、一部の.NET Framework内部のメソッドは String.FastAllocateString(int length)というinternalメソッドで確保された文字列領域に直接書き込みを行っています。このメソッドは外部に公開されていませんが、.NET Standard 2.1 ではString.Create(int length, TState state, SpanAction action)メソッドが追加され、これを呼ぶことで、新規の文字列領域に直接書き込みができます。
ZString – Unity/.NET CoreにおけるゼロアロケーションのC#文字列生成 | Cygames Engineers' Blog
string.FastAllocateString
をpublicにしてくれないかというIssueもあったりするのですが、string.Create
を通して利用してくれとのことでした。
Making this public would encourage mutating an immutable type. It's internal for a good reason. I would not want to make this public. string.Create was exposed an alternative.
Make string.FastAllocateString public · Issue #36989 · dotnet/runtime · GitHub
今回はそのstring.Create
の使い方について紹介します。
使い方
public static string Create<TState> (int length, TState state, System.Buffers.SpanAction<char,TState> action);
String.Create メソッド (System) | Microsoft Learn
引数で指定した長さの新しい文字列を確保し、作成後に指定したコールバックを使用してそれを初期化します。
// length: 最初に文字列の長さを固定する(↓だとSpan<char> bufferの長さを10に固定) // state: ローカル変数をキャプチャしないように、ローカル変数を利用したい場合はここで渡す(↓だとstateが"Add Char : {0}"になる) // action: 確保したメモリ領域を指すSpan<char> と 上記stateで確保した値 を利用した文字列の初期化処理 string text = string.Create(10, "Add Char : {0}", (buffer, state) => { for (var i = 0; i < 10; i++) { // int -> charにする buffer[i] = (char)('0' + i); // stateには"Add Char : "が格納されている // Add Char : 0 // ... // Add Char : 9 Console.WriteLine(state, i); } }); // 0123456789 Console.WriteLine(text);
string.Create
の内部実装を見るとより処理の流れが掴めるかもしれません。
/// <summary>Creates a new string with a specific length and initializes it after creation by using the specified callback.</summary> /// <param name="length">The length of the string to create.</param> /// <param name="state">The element to pass to <paramref name="action" />.</param> /// <param name="action">A callback to initialize the string.</param> /// <typeparam name="TState">The type of the element to pass to <paramref name="action" />.</typeparam> /// <returns>The created string.</returns> public static string Create<TState>(int length, TState state, SpanAction<char, TState> action) { if (action == null) ThrowHelper.ThrowArgumentNullException(ExceptionArgument.action); if (length <= 0) { if (length == 0) return string.Empty; ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.length); } string str = string.FastAllocateString(length); action(new Span<char>(ref str.GetRawStringData(), length), state); return str; }
ちなみに
string.Create
には以下のオーバーロードがありますが、こちらはDefaultInterpolatedStringHandler
にカルチャー指定するのに利用したりします。
public static string Create (IFormatProvider? provider, Span<char> initialBuffer, ref System.Runtime.CompilerServices.DefaultInterpolatedStringHandler handler); public static string Create (IFormatProvider? provider, ref System.Runtime.CompilerServices.DefaultInterpolatedStringHandler handler);
C# の補間文字列はカルチャー依存で、何も指定しないと CurrentCulture が使われます。 その結果、手元の環境で実行すると日本式のフォーマットになるけど、 サーバー上で実行すると米国式のフォーマットになったりすることがあります。
var en = CultureInfo.GetCultureInfo("en-US"); var jp = CultureInfo.GetCultureInfo("ja-JP"); // Data: 5/12/2024 8:42:38 PM _ = string.Create(en, $"Data: {DateTime.Now}"); // Data: 2024/05/12 20:42:39 _ = string.Create(jp, $"Data: {DateTime.Now}");
実験
例えばstackalloc
でメモリを確保して、そこに書き込み、最後にnew string
をしてみます。
[MemoryDiagnoser] public class SimpleTest { [Benchmark] public string StringCreate() { // length: 最初に文字列の長さを固定する(↓だとSpan<char>の長さを10に固定) // state: ローカル変数をキャプチャしないように、ローカル変数を利用したい場合はここで渡す(↓だと特にローカル変数をキャプチャしないので利用しない) // action: 確保したメモリ領域を指すSpan<char> と 上記stateで確保した変数の値 を利用した文字列の初期化処理 return string.Create(10, 0, (buffer, _) => { for (var i = 0; i < 10; i++) { // int -> charにする buffer[i] = (char)('0' + i); } }); } [Benchmark] public string StackAlloc() { Span<char> text = stackalloc char[10]; for (var i = 0; i < 10; i++) { text[i] = (char)('0' + i); } return new string(text); } }
Method | Mean | Error | StdDev | Gen0 | Allocated |
---|---|---|---|---|---|
StringCreate | 6.930 ns | 0.0766 ns | 0.0717 ns | 0.0057 | 48 B |
StackAlloc | 8.844 ns | 0.0674 ns | 0.0630 ns | 0.0057 | 48 B |
stackalloc
した方が一見早そうに思えますが、string.Create
の方が高速です。
追記
そういえばキャッシュしたchar[]
でnew string
した方が早いんじゃないかみたいな説もありますが、それでもstring.Create
の方が早いです。
private readonly char[] _cache = new char[10]; [Benchmark] public string Cache() { for (var i = 0; i < 10; i++) { // int -> charにする _cache[i] = (char)('0' + i); } return new string(_cache); }
Method | Mean | Error | StdDev | Gen0 | Allocated |
---|---|---|---|---|---|
Cache | 8.659 ns | 0.0324 ns | 0.0253 ns | 0.0057 | 48 B |
StringCreate | 7.088 ns | 0.1444 ns | 0.1280 ns | 0.0057 | 48 B |
StackAlloc | 9.228 ns | 0.1559 ns | 0.1302 ns | 0.0057 | 48 B |
注意点
string.Create
で確保したメモリ領域はゼロ埋めはされておらず、ランダムな文字列になっているので必ず全て代入するようにしてください。
The initial content of the destination span passed to action is undefined. Therefore, it is the delegate's responsibility to ensure that every element of the span is assigned. Otherwise, the resulting string could contain random characters.
// DeepL翻訳
アクションに渡されるデスティネーション・スパンの初期コンテンツは未定義です。従って、スパンの全ての要素が代入されることを確実にするのは、デリゲートの責任です。さもなければ、結果の文字列にはランダムな文字が含まれる可能性があります。