C++風の構文(cout << "..." << endl
)で無駄なアロケーションを回避しつつ、内部バッファーの再利用まで自動で行ってくれる文字列操作用のユーティリティーです。
なんと既存のコードに CStr.COUT
と ENDL
(省略可)を追加するだけでメモリ使用量が約40%減ります。
CStr
= C/C++ String
var str = CStr.COUT + "Value: " + 310 + CStr.ENDL;
// ^^^^^^^^^ 起動式 ^^^^^^^^^^ 文字列への明示的変換 ※ENDLだけど改行は追加されない
// 型付けされた変数やメンバーヘの代入なら末端の ENDL は不要
MyApp.UserFullName = CStr.COUT + User.FamilyName + ' ' + User.FirstName;
// ^^^^^^^^^ 既存のコードにイニシエーターを足すだけ!
// 型ごとにフォーマット指定可(ただし全ての COUT で共用。空文字が一番処理速度が速い)
CStr.FloatFormat = "#,0.000";
ある程度構文のカスタマイズも可能 👇
global using static CppStringSyntax; // C# 10.0
public static class CppStringSyntax { public static CStr.Handler @new => ... }
// CStr の利用が許される環境ならこのくらいやって良い
var s = @new + "Hello, world." + @string; // 文字列に変換
var c = @new + "Hello, world." + @char; // ReadOnlySpan<char> に変換
// auto とか C++ っぽさある
string s = @auto + "Hello, world." + 310;
wotakuro さんの 👇 に触発されてガッと作りました!!
Handler
構造体
CStr.COUT
は Handler
型を返します。
この Handler
構造体は+
演算子を実装しており、数値型などの構造体に対しては TryFormat(...)
メソッドを使用して無駄なアロケーションを回避しながら、内部バッファーを利用して文字列を構築していくことが出来ます。
(最新の DefaultInterpolatedStringHandler
と同じ処理速度&省メモリ)
そして ENDL
または CHUNK
を加算することで次の COUT
呼び出し時に内部バッファーが再利用されます。何もしないと Handler 構造体の解放と共にバッファーは GC に回収されます。
ReadOnlySpan<char>
の取得
今風っぽく ReadOnlySpan<char>
への変換も可能です。
// 末端に CHUNK を追加すると Chunk(ReadOnlySpan<char>)に変換
using var chunk = CStr.COUT + "Value: " + 310 + CStr.CHUNK; // using するとバッファーを再利用
DoSomething(chunk.Span);
※スレッド内で複数同時に使用した場合
スレッド内で複数の Handler
型を同時に利用可能ですが、バッファーはスレッド毎に一つのみ再利用され余ったバッファーは GC に回収されます。
var str = CStr.COUT + "String!!"; // ENDL(ToString)無し
var other = CStr.COUT + "Other!!"; // --> new char[CStr.InitialCapacity] を新たに確保
_ = str.ToString(); // バッファーを再利用に回す
_ = other.ToString(); // 複数のバッファーが回ってきた場合は大きい方を残し他は GC 待機列へ
内部バッファー解放後の挙動
構造体はバッファー解放後に操作すると NullReferenceException
を投げます。
が、エラーを意図的に起こすのは結構難しいです。
// 👇 だと問題なし ※ ENDL で文字列になっているので "Test" + "NoError" しているだけ
var test = CStr.COUT + "Test" + CStr.ENDL + "NoError";
// エラーを起こすにはこうする
CStr.Handler error = CStr.COUT + "Test";
_ = error.ToString();
_ = error.ToString(); // 複数回 ToString しても問題ないが
_ = error + "Error!!"; // 追加を試みるとエラー
ChunkRecycler
構造体
ReadOnlySpan<char>
取得時にバッファーの寿命を管理する Disposable
な ref
構造体です。
この構造体を破棄すると内部バッファーはプールに戻ります。破棄しなかった場合はバッファーは GC に回収されます。
using (var chunk = CStr.COUT + "Value: " + 310 + CStr.CHUNK)
{
var span = chunk.Span;
// 配列はプールに戻る
}
// using しないなら、、、
var chunk = CStr.COUT + "Value: " + 310 + CStr.CHUNK;
// 明示的に配列をプールに戻す必要あり
chunk.Dispose();
構文のカスタマイズ
専用の静的クラスを作ればある程度カスタマイズできます。@ + "Hello, world" + $
とか実現したいなー。
public static class CppStringSyntax
{
public static CStr.Handler @new
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get => CStr.COUT;
}
public static readonly CStr.StringTerminator @string = CStr.ENDL;
public static readonly CStr.ChunkTerminator @char = CStr.CHUNK;
}
// 👇
using static MyApp.CppStringSyntax;
var s = @new + "Hello, world." + @string;
var c = @new + "Hello, world." + @char;
パフォーマンス比較
- 秒間60フレーム想定
- ループ内で毎回10文字程度の文字列を生成→プロパティーに設定→(ヘルパー等使っていれば)破棄
-
Str_Int_*
:Prefix + IntVal
-
Str_Int_Str_*
:Prefix + IntVal + Suffix
-
*_CStr
:Result = CStr.COUT + Prefix...;
-
*_SysStr
:Result = Prefix...;
-
*_SbReuse
: 概ねココにある通りのヘルパーで数値型の変換にTryFormat
を使用 -
*_Interpolate
:Result = $"{Prefix}{IntVal}";
処理速度の倍率だけに着目するとかなり問題があるように感じるが、あくまでナノ秒レベルの話なので気にする必要はなさそう。重要なのは .NET 8 環境で DefaultInterpolatedStringHandler
と同等の性能を達成している点。C# 的に最適な実装として間違ってないっぽい。
毎フレーム弄る GUI のラベル数を乗ずると考えればメモリ使用量の削減には相当な効果があるだろうし、細かい不要インスタンスが大量に出ては回収される状況は GC のスパイクを招くので、メモリ消費以外の意味でも恩恵は大きい(ハズ)
省メモリを達成しつつナノ秒レベルの処理速度を(やたらと速い) ただ、プロパティへの文字列割り当ての度に複数行 ナノ秒レベルの速度は上がるが各クラスが専用のバッファーを抱えることになるので明らかにマイナスの方がデカい。・・・
SysStr
と同等にする方法もあって、それは各クラスが自身専用の StringBuilder
を抱えて SbReuse
の要領で文字列を構築するっていう手法。AppendFormatFast
を書いて ToString -> Length = 0
を実行しなければならない上に得られる結果が約 1/60 マイクロ秒の最適化。
.NET 8
全体的に高速化されて差も縮まっている。しかし String.Concat
はなんでこんなに速いんだ?
Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
---|---|---|---|---|---|
Str_Int__x60_CStr | .NET 8.0 | 1,980.6 ns | 1.13 | 2.81 KB | 0.60 |
Str_Int__x60_SysStr | .NET 8.0 | 866.1 ns | 0.49 | 4.69 KB | 1.00 |
Str_Int__x60_SbReuse | .NET 8.0 | 2,091.7 ns | 1.19 | 2.81 KB | 0.60 |
Str_Int__x60_Interpolate | .NET 8.0 | 1,898.8 ns | 1.08 | 2.81 KB | 0.60 |
- | |||||
Str_Int_Str__x60_CStr | .NET 8.0 | 2,131.9 ns | 1.21 | 3.28 KB | 0.70 |
Str_Int_Str__x60_SysStr | .NET 8.0 | 1,142.2 ns | 0.65 | 5.16 KB | 1.10 |
Str_Int_Str__x60_SbReuse | .NET 8.0 | 2,225.7 ns | 1.26 | 3.28 KB | 0.70 |
Str_Int_Str__x60_Interpolate | .NET 8.0 | 2,180.8 ns | 1.24 | 3.28 KB | 0.70 |
.NET Core 2.1(Unity 想定)
処理時間が 2.5 倍になってるけども、そもそも他の処理に引っ張られてフレームレートは落ちるのでメモリ消費が少ない方が良いでしょう。ナノ秒レベルだし。
Method | Runtime | Mean | Ratio | Allocated | Alloc Ratio |
---|---|---|---|---|---|
Str_Int__x60_CStr | Core 2.1 | 4,236.2 ns | 2.41 | 2.81 KB | 0.60 |
Str_Int__x60_SysStr | Core 2.1 | 1,760.4 ns | 1.00 | 4.69 KB | 1.00 |
Str_Int__x60_SbReuse | Core 2.1 | 3,777.9 ns | 2.15 | 2.81 KB | 0.60 |
Str_Int__x60_Interpolate | Core 2.1 | 5,638.7 ns | 3.20 | 4.22 KB | 0.90 |
- | |||||
Str_Int_Str__x60_CStr | Core 2.1 | 4,495.9 ns | 2.55 | 3.28 KB | 0.70 |
Str_Int_Str__x60_SysStr | Core 2.1 | 1,942.7 ns | 1.10 | 5.16 KB | 1.10 |
Str_Int_Str__x60_SbReuse | Core 2.1 | 4,296.4 ns | 2.44 | 3.28 KB | 0.70 |
Str_Int_Str__x60_Interpolate | Core 2.1 | 7,124.6 ns | 4.05 | 4.69 KB | 1.00 |
ソースコード
おわりに
演算子は <
>
が良かったけどペアがある演算子は戻り値の型が揃っていなければならず、、、そして C++ と同じ <<
>>
演算子も C# 9.0 では定義できず。
// ENDL を「入力」すると文字列に
var str = CStr.COUT + "Value: " + 310 < CStr.ENDL;
// CHUNK に「出力」すると ReadOnlySpan<char> に
var chunk = CStr.COUT + "Value: " + 310 > CStr.CHUNK;
が、結果的には全てを+演算子にまとめて強い型付けを行ったことで、間違った構文は全てコンパイルエラーになるので逆に良かったのかも。
--
Unity に関して言えば uGUI とか TextMesh Pro の内部バッファーを Span<char>
で取り出して直接書き込む、みたいなことが出来るようにならないと根本的な解決にはならなそうだったり、翻訳とか対応していくと結局こういうユーティリティーは登場の機会が無かったり、ですが。
以上です。お疲れ様でした。