目次

概要

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 からは補間文字列($"")をAppendFormattedAppendLiteralメソッドに展開します。 これはパターン ベースになっていて、 所定のパターンを満たしていればどんな型であっても可能です。

まず、以下の条件を満たす型を補完文字列ハンドラー (interpolated string handler)と呼びます。 (以下、このページ内では単に「ハンドラー型」と呼びます。)

  • InterpolatedStringHandler 属性(System.Runtime.CompilerServices名前空間)が付いている
  • 最低限、以下の引数を持つコンストラクターを持つ
    • int literalLength: 補間文字列のリテラル部分($"" の中から {} を除いた部分)の文字列長
    • int formattedCount: {} (interpolation hole: 補間穴)の個数
    • 追加で、out bool なアウト引数を持てる
    • InterpolatedStringHandlerArgument 属性と組み合わせ得て、追加で任意の引数を足せる
  • リテラル部分を書き込むための AppendLiteral(string) メソッドを持つ
    • voidbool 戻り値(後述)
  • {} の部分を書き込むための `AppendFormatted(T)' メソッドを持つ
    • voidbool 戻り値(後述)
    • 追加で 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) { }
}

ハンドラー型への直接代入

まず、補間文字列をハンドラー型に直接渡す場合、 コンストラクター、AppendLiteralAppendFormatted メソッドの呼び出しに展開されます。

例えば以下のようなコードがあるとき、

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 名前空間)が利用可能な場合
    • まず、この型に対する代入処理と同様に AppendLiteralAppendFormatted メソッドを呼び出す
    • 最後に 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 を、 AppendLiteralAppendFormatted メソッドでは戻り値として 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つのオーバーロードが追加されています。

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}
");

更新履歴

ブログ