概要
Ver. 10
C# 10.0 で、補完文字列(interpolated string)のコンパイル結果に変更が掛かって、 これまでよりもかなり高速化されました。 詳細は気にせず単に高速化の恩恵だけを受けたい場合、 言語バージョン、SDK バージョンを C# 10.0/.NET 6.0 にアップデートして再コンパイルするだけで速くなります。
一方、本項では、 C# 9.0 までの補間文字列の問題点と、 C# 10.0 から補間文字列がどのように展開されるかについて説明します。 仕組みがわかれば、補間文字列の解釈を結構自由にカスタマイズすることができます。
サンプル コード: InterpolatedStrings
C# 9.0 までの補間文字列
例えば以下のようなコードがあったとします。
static string m(int a, int b, int c, int d) => $"{a}.{b}.{c}.{d}";
C# 9.0 までは、このコードは以下のように展開されていました。
static string m(int a, int b, int c, int d) => string.Format("{0}.{1}.{2}.{3}", a, b, c, d);
要は string.Format
メソッド呼び出しへの展開でした。
ちなみに、ここで呼ばれている Format
メソッドは以下のようなオーバーロードです。
public static string Format(string format, params object?[] args)
この展開方法では以下のようなコストがどうしても避けられず、用途によっては使うのがためらわれていました。
params
を介していて、new object[4]
のコストが発生するobject
を介していて、int
などの値を渡すとボックス化 のコストが発生する- (ログレベルの変更などで)実際には文字列を全く使わない状況でも必ず文字列インスタンスが作られる
Span
構造体を渡せない
そこで、C# 10.0 では以下のように、AppendLiteral
, AppendFormatted
メソッドを何度も呼び出す方針に変更されました。
DefaultInterpolatedStringHandler handler = new DefaultInterpolatedStringHandler(3, 4);
handler.AppendFormatted(a);
handler.AppendLiteral(".");
handler.AppendFormatted(b);
handler.AppendLiteral(".");
handler.AppendFormatted(c);
handler.AppendLiteral(".");
handler.AppendFormatted(d);
string s = handler.ToStringAndClear();
ハンドラー パターン
前述の通り、C# 10.0 からは補間文字列($""
)をAppendFormatted
やAppendLiteral
メソッドに展開します。
これはパターン ベースになっていて、
所定のパターンを満たしていればどんな型であっても可能です。
まず、以下の条件を満たす型を補完文字列ハンドラー (interpolated string handler)と呼びます。 (以下、このページ内では単に「ハンドラー型」と呼びます。)
InterpolatedStringHandler
属性(System.Runtime.CompilerServices
名前空間)が付いている-
最低限、以下の引数を持つコンストラクターを持つ
int literalLength
: 補間文字列のリテラル部分($""
の中から{}
を除いた部分)の文字列長int formattedCount
:{}
(interpolation hole: 補間穴)の個数- 追加で、
out bool
なアウト引数を持てる InterpolatedStringHandlerArgument
属性と組み合わせ得て、追加で任意の引数を足せる
-
リテラル部分を書き込むための
AppendLiteral(string)
メソッドを持つvoid
かbool
戻り値(後述)
-
{}
の部分を書き込むための `AppendFormatted(T)' メソッドを持つvoid
かbool
戻り値(後述)- 追加で
int alignment
引数(フォーマット時の幅指定)を持てる - 追加で
string format
引数(フォーマット指定文字列)を持てる
最低ライン必要なメンバーをそろえた型を作ると以下のようになります。
(本当に「コンパイルが通る」レベルで、中身が何もないので Dummy
という名前にしてあります。)
[System.Runtime.CompilerServices.InterpolatedStringHandler]
public struct DummyHandler
{
public DummyHandler(int literalLength, int formattedCount) { }
public void AppendLiteral(string s) { }
public void AppendFormatted<T>(T x) { }
}
ハンドラー型への直接代入
まず、補間文字列をハンドラー型に直接渡す場合、
コンストラクター、AppendLiteral
、AppendFormatted
メソッドの呼び出しに展開されます。
例えば以下のようなコードがあるとき、
void m(int a, int b)
{
DummyHandler h = $"{a} / {b}";
}
以下のように展開されます。
void m(int a, int b)
{
DummyHandler temp = new(3, 2);
temp.AppendFormatted(a);
temp.AppendLiteral(" / ");
temp.AppendFormatted(b);
DummyHandler h = temp;
}
string への代入
string
型は特殊で、補完文字列を string
型に渡す場合、
以下のような展開が行われます。
-
DefaultInterpolatedStringHandler
型(System.Runtime.CompilerServices
名前空間)が利用可能な場合- まず、この型に対する代入処理と同様に
AppendLiteral
、AppendFormatted
メソッドを呼び出す - 最後に
DefaultInterpolatedStringHandler.ToStringAndClear
メソッドを呼んで文字列化する
- まず、この型に対する代入処理と同様に
- 利用できない場合、
string.Format
に展開する(C# 9.0 までの挙動と同じ)
DefaultInterpolatedStringHandler
型が存在するならほとんどの場合はこれを利用可能です。
そして、この型は .NET 6.0 からは標準ライブラリに入っています。
例えば以下のようなコードを書いて .NET 6.0 向けにコンパイルした場合、
string m(int a, int b) => $"{a} / {b}";
以下のように展開されます。
(DefaultInterpolatedStringHandler
型への代入の展開結果 + ToStringAndClear
呼び出しみたいなコードになります。)
string m(int a, int b)
{
DefaultInterpolatedStringHandler h = new(3, 2);
h.AppendFormatted(a);
h.AppendLiteral(" / ");
h.AppendFormatted(b);
return h.ToStringAndClear();
}
DefaultInterpolatedStringHandler
型自体は存在するのに補間文字列として利用できない状況は、
補完穴({}
)の中に await
を含む場合などです。
DefaultInterpolatedStringHandler
型は ref 構造体なので、await
と共存できません。
例えば以下のようなコードを書くと string.Format
に展開されます。
async Task<string> m(Task<int> a) => $"result: {await a}";
ちなみに、DefaultInterpolatedStringHandler
型は標準ライブラリ中のものでなくても構いません。
もし .NET 5.0 以前をターゲットにした場合でも同様の最適化が掛かって欲しいなら、
DefaultInterpolatedStringHandler
型を移植すれば可能です。
.NET 6.0 にしかない機能をちらほら使っているので 5.0 以前への移植は多少面倒ですが、できなくはないレベルかと思います。
AppendFormatted メソッドのオーバーロード
ハンドラー型を作る際、AppendFormatted
メソッドはいくつオーバーロードがあっても構いません。
よく使いそうなのは、ジェネリック型引数として使えない ReadOnlySpan<char>
や、
その他最適化のために具象型を直接受け取りたい場合(string
など)用のオーバーロードなどです。
DummyHandler h = $"{123}, {"abc"}, {stackalloc char[1]}";
[System.Runtime.CompilerServices.InterpolatedStringHandler]
public struct DummyHandler
{
public DummyHandler(int literalLength, int formattedCount) { }
public void AppendLiteral(string s) => Console.WriteLine("(literal)");
public void AppendFormatted<T>(T x) => Console.WriteLine("ジェネリック版");
public void AppendFormatted(string x) => Console.WriteLine("string 版");
public void AppendFormatted(ReadOnlySpan<char> x) => Console.WriteLine("ReadOnlySpan 版");
}
ジェネリック版
(literal)
string 版
(literal)
ReadOnlySpan 版
書式指定
補間文字列の {}
の中では書式指定ができます。
(ハンドラー型が使える状況下で)書式指定した場合、AppendFormatted
メソッドの第2、第3引数に書式が渡ります。
例えば以下のようなコードを書いた場合、
string m(int a, int b, int c) => $"({a, 8:X}) ({b:X}) ({c,4})";
以下のように展開されます。
string m(int a, int b, int c)
{
DefaultInterpolatedStringHandler h = new(8, 3);
h.AppendLiteral("(");
h.AppendFormatted(a, 8, "X");
h.AppendLiteral(") (");
h.AppendFormatted(b, "X");
h.AppendLiteral(") (");
h.AppendFormatted(c, 4);
h.AppendLiteral(")");
return h.ToStringAndClear();
}
ハンドラー型を自作する場合、AppendFormatted
メソッドの引数は、
以下のようにオーバーロードをいくつか用意しても構いませんし、
public void AppendFormatted<T>(T x) { }
public void AppendFormatted<T>(T x, int alignment) { }
public void AppendFormatted<T>(T x, string format) { }
public void AppendFormatted<T>(T x, int alignment, string format) { }
以下のようにオプション引数で1つのメソッドにまとめても構いません。
public void AppendFormatted<T>(T x, int? alignment = null, string? format = null) { }
bool 戻り値
ハンドラー型のコンストラクターでは第3引数に out bool
を、
AppendLiteral
、AppendFormatted
メソッドでは戻り値として bool
を返すことができます。
この場合、false が返ってきたら処理を途中で打ち切るようなコードに展開されます。
例えば以下のようなハンドラー型があったとします。
[InterpolatedStringHandler]
public struct DummyHandler
{
public DummyHandler(int literalLength, int formattedCount, out bool result) => result = true;
public bool AppendLiteral(string s) => true;
public bool AppendFormatted<T>(T x) => true;
}
このハンドラー型に対して、例えば以下のように補間文字列を渡した場合、
DummyHandler m(int a, int b, int c, int d) => $"{a}.{b}.{c}.{d}";
以下のような展開結果になります。
DummyHandler m(int a, int b, int c, int d)
{
DummyHandler h = new(3, 4, out var result);
if (result
&& h.AppendFormatted(a)
&& h.AppendLiteral(".")
&& h.AppendFormatted(b)
&& h.AppendLiteral(".")
&& h.AppendFormatted(c)
&& h.AppendLiteral("."))
h.AppendFormatted(d);
return h;
}
これを使って、例えば、「一定文字数を超えたらそこで処理を打ち切り」とか、
「ログ レベル的に全く文字列化処理が必要ない場合、 AppendLiteral
/AppendFormatted
を一切呼ばない」とかができます。
InterpolatedStringHandlerArgument 属性
InterpolatedStringHandlerArgument
属性(System.Runtime.CompilerServices
名前空間)を使って、
ハンドラー型のコンストラクターに追加の引数を渡すことができます。
例えば以下のような使い方をします。
(実際、DefaultInterpolatedStringHandler
がそういう使い方をしています。)
- カルチャー指定して文字列を作りたいとき用に、引数で
IFormatProvider
を渡す - 文字列を作る際に使うバッファーとして外から
Span<char>
を渡す
これを使うためにはまず、以下のようにコンストラクターに追加の引数を持ったハンドラー型を作ります。
using System.Runtime.CompilerServices;
[InterpolatedStringHandler]
public ref struct DummyHandler
{
public DummyHandler(int literalLength, int formattedCount) : this(literalLength, formattedCount, null, default) { }
// 追加の引数持ち
public DummyHandler(int literalLength, int formattedCount, IFormatProvider? provider)
: this(literalLength, formattedCount, provider, default) { }
public DummyHandler(int literalLength, int formattedCount, IFormatProvider? provider, Span<char> initialBuffer)
// 以下略
}
次に、以下のように、InterpolatedStringHandlerArgument
属性を使って、メソッドの引数とハンドラー型のコンストラクター引数の結び付けるメソッドを書きます。
public class Formatter
{
// 追加の引数なし。
public static void Format(DummyHandler handler)
// 省略
// provider を追加。
public static void Format(
IFormatProvider provider,
[InterpolatedStringHandlerArgument("provider")] DummyHandler handler)
=> Format(handler);
// provider と initialBuffer を追加。
public static void Format(
IFormatProvider provider, Span<char> initialBuffer,
[InterpolatedStringHandlerArgument("provider", "initialBuffer")] DummyHandler handler)
=> Format(handler);
}
そしてこれらのメソッドを呼ぶと、ハンドラー型に追加の引数が渡るようになります。
using System.Globalization;
// Format(DummyHandler) を呼んでて、
// new DummyHandler(5, 2) が作られる。
Formatter.Format($"abc {1} {2}");
// Format(IFormatProvider, DummyHandler) を呼んでて、
// new DummyHandler(5, 2, CultureInfo.InvariantCulture) が作られる。
Formatter.Format(CultureInfo.InvariantCulture, $"abc {1} {2}");
// Format(IFormatProvider, Span<char>, DummyHandler) を呼んでて、
// new DummyHandler(5, 2, CultureInfo.InvariantCulture, stackalloc char[128]) が作られる。
Formatter.Format(CultureInfo.InvariantCulture, stackalloc char[128], $"abc {1} {2}");
オーバーロード解決
C# 10.0 でハンドラー型の仕様が追加され、 C# 9.0 まででも FormattableString の仕様があるので、 補間文字列を受け取る候補となるメソッドを3つ同時に定義できます。
public static void M(DefaultInterpolatedStringHandler _) => Console.WriteLine("handler");
public static void M(string _) => Console.WriteLine("string");
public static void M(IFormattable _) => Console.WriteLine("formattable");
こういう状況では、ハンドラー型 > string
型 > FormattableString
(ハンドラー型が一番呼ばれやすい) という優先順位になります。
// ハンドラー型最優先。
M($"{1}"); // handler
// ただの文字列の場合は string に行く。
M("abc"); // string
// ちょっと混乱しそうなのが、const になる場合に限り、 $ がついてても string 行き。
M($""); // string
M($"abc {"abc"} abc"); // string
// もちろん、キャストしてしまえば任意に呼び分け可能。
M($"{1}"); // handler
M((string)$"{1}"); // string
M((IFormattable)$"{1}"); // formattable
string
型が真ん中なのがちょっと不思議な仕様ですが、
これは FormattableString のときの反省からです。
FormattableString を優先してほしいのに優先してもらえなくて困るので、
RawString
みたいな「string
型を覆った別の型」を1段挟むことで無理やり FormattableString 優先になるようにする手法が知られていました。
ハンドラー型では同じ轍を踏まないよう、最初からハンドラー型優先になっています。
ちなみに、ハンドラーの条件を満たす型が複数あって、 それでオーバーロードした場合、オーバーロード解決できません。
public static void Caller()
{
// 優先度は付かないので不明瞭エラーを起こす。
M($"");
// 明示的にキャストすれば呼び分け可能。
M((Handler1)$"");
M((Handler2)$"");
}
public static void M(Handler1 _) => Console.WriteLine("Handler1");
public static void M(Handler2 _) => Console.WriteLine("Handler2");
.NET 6.0 で追加された API
ここまで補間文字列ハンドラーの説明してきましたが、 実際のところ、ハンドラー型を自作することは少ないでしょう。 一方で、標準ライブラリ中に存在するハンドラー型(を使っているメソッド)を使うことで、 補間文字列のパフォーマンス改善によって間接的な利益になる場面は多々あると思います。
C# 10.0 と同時に出た .NET 6.0 ではハンドラー型や、それを使ったメソッドがいくつか追加されています。 本項では最後に、.NET 6.0 で追加されたいくつかのメソッドを紹介して終わりにしたいと思います。
string.Create
string.Create
に以下の2つのオーバーロードが追加されています。
Create(IFormatProvider, DefaultInterpolatedStringHandler)
Create(IFormatProvider, Span<Char>, DefaultInterpolatedStringHandler)
「InterpolatedStringHandlerArgument 属性」で例に挙げた通り、カルチャー指定で文字列補間するための引数と、初期バッファーを渡すための引数です。
カルチャー指定
C# の補間文字列はカルチャー依存で、何も指定しないと CurrentCulture
が使われます。
その結果、手元の環境で実行すると日本式のフォーマットになるけど、
サーバー上で実行すると米国式のフォーマットになったりすることがあります。
using System.Globalization;
// サンプルなので明示的に指定。
// 手元の環境が ja-jp カルチャーだとして…
Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("ja-jp");
// 日本式。
// yyyy/MM/dd hh:mm:ss
Console.WriteLine($"{DateTime.Now}");
// 一方、サーバーとかで別カルチャーだったりすると…
// (最近、データ量削減のために「CurrentCulture が常に InvariantCulture」みたいなモードがあったりする。)
Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture;
// .NET の InvariantCulture は Invariant (不変)と言いつつ、米国基準。
// MM/dd/yyyy hh:mm:ss
Console.WriteLine($"{DateTime.Now}");
2021/09/23 22:39:39
09/23/2021 22:39:39
CurrentCulture
依存が怖いなら、string.Create
メソッドを使ってカルチャーを明示します。
using System.Globalization;
// どこか日本でも Invariant でもない適当なカルチャー。
Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("fr-fr");
// これは CurrentCulture 依存。
Console.WriteLine($"{DateTime.Now}");
// string.Create を使ってカルチャーを明示すれば CurrentCulture 依存はなくなる。
Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $"{DateTime.Now}"));
23/09/2021 22:39:39
09/23/2021 22:39:39
ちなみにサンプル コードでは、以下のようなハンドラー型を提供していたりします。
Invariant
: 常にInvariantCulture
で文字列補間する型Iso8601
: 常にInvariantCulture
を使いつつ、日付だけは MM/dd/yyyy を許さず、ISO 8601 形式で文字列補間する型
初期バッファー指定
冒頭での説明通り、C# 10.0 で再コンパイルするだけで文字列補間は高速化されます。
ただ、パフォーマンスを求めるのであれば、素の $""
を使うよりも、
string.Create
で初期バッファーを与える方がいいです。
特に、補間結果の文字数がある程度わかっている場合には初期バッファーの指定でパフォーマンスが劇的に改善することがあります。
例えばサンプル コードのベンチマークでは以下のようなもののパフォーマンス比較を行っています。
- OldStyle: C# 9.0 までの展開結果である
string.Format
を使ったコード - Improved: C# 10.0 の文字列補間に任せる(
DefaultInterpolatedStringHandler
が使われる) - InitialBuffer:
string.Create(_currentCulture, stackalloc char[InitialBufferSize], $"{a}.{b}.{c}.{d}")
で初期バッファー指定
手元の環境でベンチマーク計測した結果、これらは以下のような実行結果になりました。
Method | Mean | Error | StdDev | Gen 0 | Allocated |
---|---|---|---|---|---|
OldStyle | 978.2 us | 0.97 us | 0.76 us | 228.5156 | 1,875 KB |
Improved | 530.8 us | 0.77 us | 0.64 us | 46.8750 | 391 KB |
InitialBuffer | 377.2 us | 0.73 us | 0.61 us | 47.3633 | 391 KB |
StringBuilder.Append
これまで StringBuilder
(System.Text
名前空間)に対して
builder.Append($"{1} {2} {3}");
みたいなコードを書くと、
一度 string.Format
で文字列インスタンスを作った上で、それを Append
していました。
一方、C# 10.0/.NET 6.0 では、Append(AppendInterpolatedStringHandler)
というオーバーロードが追加されています。
このオーバーロードを呼ぶと、
builder.Append($"{1} {2} {3}");
を、以下のようなコードとそん色ないパフォーマンスで呼ぶことができます。
builder.Append(1);
builder.Append(" ");
builder.Append(2);
builder.Append(" ");
builder.Append(3);
MemoryExtensions.TryWrite
MemoryExtensions
(System
名前空間)に TryWrite
と言う名前で、
Span<char>
バッファーに直接書き込みするメソッドも追加されています。
string.Create
の場合は最終的に必ず1個は new string()
が発生しますが、
MemoryExtensions.TryWrite
なら完全にアロケーションなしで文字列補間ができます。
バッファー管理がちょっと大変ですが、一応、最速を目指すならこのメソッドを使うことになります。
void m(int a,int b,int c,int d)
{
Span<char> buffer = stackalloc char[128];
buffer.TryWrite($"{a}.{b}.{c}.{d}", out var charsWritten);
// デモ用なので ToString しちゃってるけども…
// 工夫次第ではこの ToString 負担も避けれる。
Console.WriteLine(buffer[..charsWritten].ToString());
}
Debug.Assert
Debug.Assert
(System.Diagnostics
名前空間)にハンドラー型を受け取るオーバーロードが増えています。
このオーバーロードを使うと、condition
引数が false
の時だけ AppendLiteral
/AppendFormatted
を呼び出します。
using System.Diagnostics;
Debug.Assert(true, $@"condition が true な限り、Append は全く呼ばれない。
(Assert の condition はバグがない限り true になっている想定でコードを書く物なので、めったに通らない。)
なので重たい処理を書いても割かし平気。
{DateTime.Now}
{Environment.StackTrace}
{Environment.UserName}
");