鷲ノ巣

C# とか PowerShell とか。当ブログの記事は(特に公開直後は)頻繁に改定される場合があることをご了承ください。

Improvement Interpolated Strings 完全に理解した

2021年11月リリース予定と思われる C# 10.0 に搭載予定の機能の一つである「Improvement Interpolated Strings」について、完全に理解したので記事にしてみます。
同機能の詳細な仕様は GitHub にあります。
github.com

そもそも Interpolated Strings とは

復習から。

Interpolated Strings*1 とは、C# 6.0 から導入された機能で、以下のように書けるというものです。

$"Hello, {name}!!"

上記の結果は、変数 name の中身が "Joe" だったとすると、 "Hello, Joe!!" という文字列になります。

C# 5.0 以前では、こういうことをするためには string.Format メソッドを使って、以下のように書く必要がありました。

string.Format("Hello, {0}!!", name);

このくらいなら結果を予測するのも簡単ですが、書式文字列が長くなったり、引数が多くなったりすると、可読性が下がる傾向にありました。Interpolated Strings は、その点で直感的です。

なお、C# 6.0 までの Interpolated Strings は、内部的には string.Format メソッドの呼び出しに変換されています。

Interpolated Strings の欠点

Interpolated Strings というか、string.Format メソッドの欠点なのですが。

string.Format のメソッド シグネチャは、以下のようになっています。
この他に IFormatProvider を引数に取るオーバーロードもありますが、ここでは省略します。

public static string Format(string format, object? arg0);
public static string Format(string format, object? arg0, object? arg1);
public static string Format(string format, object? arg0, object? arg1, object? arg2);
public static string Format(string format, params object?[] args);

ちなみに、機能的には最後の配列で渡すものだけがあれば十分なのですが、この場合、内部的に配列のインスタンスを作成して渡すことになるので、その分だけ、パフォーマンス上のデメリットがあります。そのため、よく使う 1 ~ 3 引数に関しては個別にオーバーロードが用意されているわけです。

それはそれとして。

引数の型が object であるため、int などの値型を渡す場合、ボクシングが発生します。これもパフォーマンス上のデメリットになります。

以下のようなジェネリック版を導入すれば、ボクシングは避けられますが*2、この調子でオーバーロードを増やしていくと、組み合わせ爆発を招いてしまいます。たとえば前述のように、IFormatProvider を取るメソッドも必要なので、以下の 3 つだけでなく、実際には 6 つのオーバーロードを追加する必要があります。

public static string Format<T0>(string format, T0? arg0);
public static string Format<T0, T1>(string format, T0? arg0, T1? arg1);
public static string Format<T0, T1, T2>(string format, T0? arg0, T1? arg1, T2? arg2);

それでも、4 引数以上の場合は、依然として object 型にボクシングされることが避けられません。

加えて、Span<T> 構造体などの ref 構造体ジェネリクスの型引数として使うことができないという制約があります。そのため、ジェネリック引数を増やすというのは根本的な解決にはなりません。

ログ出力用途

ログ出力は用途の代表例の一つとして挙げられています。

たとえば、以下のようなシグネチャのメソッドでログを出力するとしましょう。

void Log(LogLevel level, string message);

使うときにはこのようにしますよね。

Log(LogLevel.Debug, $"parameter: {param}");

ところでログ出力というのは、ログ レベルによって出したり出さなかったりします。大抵の場合、運用環境では LogLevel.Information 以上の場合にのみ出力するというようになっているでしょう。

しかしこの場合、Debug レベルのログは実際には出力しないにもかかわらず、$"parameter: {param}" の部分は処理されますから、そのために、無駄なメモリ確保が行われるわけです。昨今の C# では、こうした不必要なメモリ アロケーションを極力減らそうとしています。

このため、標準的なロギングの仕組みである ILogger インターフェイスには IsEnabled メソッドがあって、出力メソッドを呼ぶ前に、そのメソッドを呼ぶべきかどうかを判定する機能があります。
このような機能を使って注意深くコーディングすれば無駄なメモリ アロケーションを避けることはできるのですが、うっかり忘れやすいですし、コードも煩雑になります。

新しい Interpolated Strings の使い方

新しい Interpolated Strings では、ハンドラと呼ばれる型を使って文字列を組み立てます。そのため、まずはハンドラの型を定義する必要があります。

ハンドラは、InterpolatedStringHandler 属性をつけたクラスまたは構造体です。

最小限のハンドラは以下のようなものになります。

[InterpolatedStringHandler]
struct Handler
{
    public Handler(
        int literalLength,
        int formattedCount)
    {
    }

    public void AppendLiteral(string s)
    {
        // 実装は省略
    }

    public void AppendFormatted<T>(T t)
    {
        // 実装は省略
    }
}

上記のハンドラには AppendLiteral メソッドと AppendFormatted メソッドがありますが、以降、これらをまとめて Append 系メソッドと呼ぶことにします。

そして、このようなハンドラを取るメソッドを定義します。引数には InterpolatedStringHandlerArgument 属性をつけます。

class Logger
{
    public void Log(
        [InterpolatedStringHandlerArgument] in Handler handler)
    {
        // ログ出力する
    }
}

このメソッドを呼び出す際に、Interpolated Strings が使えます。

var logger = new Logger();
logger.Log($"parameter: {param}");

こうするとどうなるかというと、コンパイラによって、以下のように翻訳されます。

var logger = new Logger();
var handler = new Handler(11, 1);
handler.AppendLiteral("parameter: ");
handler.AppendFormatted(param);
logger.Log(handler);

ハンドラのコンストラクタは、少なくとも int 型の引数を 2 つ取ります。コンストラクタの引数の名前は任意です。
最初の引数は、この Interpolated String 中のリテラル部分の長さの合計です。2 つ目の引数は、この Interpolated String 内にある値が置換される箇所({} で囲まれた箇所)の数になります。

続いて、 Interpolated String 中にリテラル部分が現れるたびに AppendLiteral メソッド、置換部分が現れるたびに AppendFormatted メソッドが呼ばれます。
最終的に Log メソッドの中では、Handler 構造体の内容を文字列化してログ出力することになります。

念のため言っておくと、AppendFormatted メソッド中で、値を List<object?> なんかに保持してしまうと、ボクシングを避けるという目的を逸脱してしまうので注意してください。

ハンドラを使うかどうかを示す

ハンドラのコンストラクタは様々な引数を取ることができます。
ここでは、ハンドラの使用の有無を示す例を紹介します。

ハンドラのコンストラクタ引数リストの末尾に out bool 型の引数を追加します。

[InterpolatedStringHandler]
struct Handler
{
    public Handler(
        int literalLength,
        int formattedCount,
        out bool handlerUsed)
    {
        this.HandlerUsed = handlerUsed = true;
    }

    internal bool HandlerUsed { get; }

    // Append 系メソッドは省略
}

これがあると、翻訳結果はこうなります。

var logger = new Logger();
var handler = new Handler(11, 1, out var handlerUsed);
if (handlerUsed)
{
    handler.AppendLiteral("parameter: ");
    handler.AppendFormatted(param);
}

logger.Log(handler);

つまり、このハンドラを使わない(handlerUsed 引数に false が返された)場合には、無用な Append 系メソッドが呼び出されず、効率化されるわけです。
ただし、Log メソッドの呼び出しまで自動的に省かれることはありませんので、Log メソッドの中では、先ほど追加したプロパティを見て、ログ出力をするかどうかを決める必要があります。

class Log
{
    public void Log(
        [InterpolatedStringHandlerArgument] in Handler handler)
    {
        if (!handler.HandlerUsed)
        {
            return;
        }

        // ログ出力する
    }
}

任意の引数の追加

ハンドラのコンストラクタは、Log メソッドの任意の引数を受け取ることもできます。この場合、引数名を InterpolatedStringHandlerArgument 属性の引数に追加します。
たとえば、値を文字列化する際の書式を指定するために IFormatProvider インターフェイスを受け取りたいとします。
この場合、以下のようにします。

class Log
{
    public void Log(
        IFormatProvider? provider,
        [InterpolatedStringHandlerArgument("provider")] in Handler handler)
    {
        // ログ出力する
    }
}

[InterpolatedStringHandler]
struct Handler
{
    public Handler(
        int literalLength,
        int formattedCount,
        IFormatProvider? provider)
    {
        // 実装は省略
    }

    // Append 系メソッドは省略
}

これに対して、以下のように呼び出すとしましょう。

var logger = new Logger()
logger.Log(CultureInfo.CurrentCulture, $"parameter: {param}");

これは、以下のように翻訳されます。

var logger = new Logger();
var provider = CultureInfo.CurrentCulture;
var handler = new Handler(11, 1, provider);
handler.AppendLiteral("parameter: ");
handler.AppendFormatted(param);
logger.Log(provider, handler);

Log メソッドの引数とハンドラのコンストラクタの引数は同じ型でなければなりません(同じ名前である必要はありません)。これらには、同じインスタンスが渡されます。
もし Log メソッドの引数に ref / in / out 等の修飾子がついている場合は、コンストラクタの引数にも同じ修飾子が必要です。
また、複数の引数を渡す場合、ハンドラのコンストラクタの引数の並びと、InterpolatedStringHandlerArgument 属性の引数の並びは、同じ順番である必要があります。

Log メソッドの引数の並びと InterpolatedStringHandlerArgument 属性の引数の並びは、同じである必要はありません。

ハンドラに渡さない引数

Log メソッドが受け取るけれども、ハンドラに渡さない引数は、InterpolatedStringHandlerArgument 属性に追加しません。
たとえば、provider 引数をハンドラに渡さない場合は、以下のようにします。

class Log
{
    public void Log(
        IFormatProvider? provider,
        [InterpolatedStringHandlerArgument] in Handler handler)
    {
        // ログ出力する
    }
}

[InterpolatedStringHandler]
struct Handler
{
    public Handler(
        int literalLength,
        int formattedCount)
    {
        // 実装は省略
    }

    // Append 系メソッドは省略
}

引数の順序

Log メソッドを呼び出す際には、名前付き引数を使うことができますが、ハンドラに渡す引数は、ハンドラ引数よりも前に置かなければなりません。ハンドラ引数以外の引数は任意の順番に並べても構いません。
また、ハンドラに渡さない引数は、ハンドラ引数よりも後においても構いません。

// x と y はハンドラに渡す。z は渡さない。
void Log(
    int x,
    string y,
    bool z,
    [InterpolatedStringHandlerArgument("x", "y")] in Handler handler)
{
}

// ✅OK(宣言通りの並び順)
Log(x: 0, y: "", z: true, handler: $"{0}");

// ❌NG(ハンドラに渡す y が handler より後にある)
Log(x: 0, handler: $"{0}", y: "", z: true);

// ✅OK(ハンドラに渡す引数は handler より前にあれば入れ替えてもよい)
Log(y: "", x: 0, z: true, $"{0}");

// ✅OK(ハンドラに渡さない引数は handler より後にあってもよい)
Log(x: 0, y: "", handler: $"{0}", z: true);

また、メソッドを宣言する際は、ハンドラに渡す引数をハンドラ引数よりも後に置くことはできますが、警告が出ます。

// 書くことはできるが警告が出る
void Log(
    [InterpolatedStringHandlerArgument("x")] in Handler handler,
    int x)
{
}

こう書くと、前述の制限により、名前付き引数を使わないと Log メソッドを呼び出すことができなくなります。

レシーバ引数の追加

ハンドラを引数に取るインスタンス メソッドが宣言されている型をレシーバ型*3と呼ぶことにします。この例では Log メソッドを持つ Logger クラスがレシーバ型です。

ハンドラのコンストラクタは、レシーバ型の引数を受け取ることができます。

以下の例では、定数 MinimumLogLevel で出力すべきログ レベルの下限が定義されているものとし、ロガーのログレベルがその値以上である場合に、このハンドラを使うようにしています。

[InterpolatedStringHandler]
struct Handler
{
    public Handler(
        int literalLength,
        int formattedCount,
        Logger logger,
        out bool handlerUsed)
    {
        this.HandlerUsed = handlerUsed = (logger.LogLevel >= MinimumLogLevel);
    }

    internal bool HandlerUsed { get; }

    // Append 系メソッドは省略
}

ハンドラのコンストラクタがレシーバ型の引数を受け取る場合、InterpolatedStringHandlerArgument 属性の引数に空文字列("")を追加する必要があります。

class Logger
{
    public void Log(
        [InterpolatedStringHandlerArgument("")] in Handler handler)
    {
        if (!handler.HandlerUsed)
        {
            return;
        }

        // ログ出力する
    }
}

この場合、Log メソッドはインスタンス メソッドでなければなりません。静的メソッドにはレシーバ型は存在しません。

翻訳結果は以下のようになります。

var logger = new Logger();
var handler = new Handler(11, 1, logger, out var handlerUsed);
if (handlerUsed)
{
    handler.AppendLiteral("parameter: ");
    handler.AppendFormatted(param);
}

logger.Log(handler);

オブジェクト指向にちょっと詳しい方であれば、インスタンス メソッドの this というのは、暗黙に渡される第 0 引数のようなものという話を聞いたことがあるかもしれません。
InterpolatedStringHandlerArgument 属性に与える引数は、Log メソッドが受け取る引数のうち、ハンドラのコンストラクタに渡す引数名を指定しています。
空文字列というのは、Log メソッドの暗黙の this 引数を表す特殊な値だということです。*4

拡張メソッドでの使用

拡張メソッドでも使うことができます。
この場合、ハンドラに Logger 型の引数を渡したい場合でも、Log メソッドにとって Logger 型はレシーバ型ではありません。
そのため、logger は任意の引数として InterpolatedStringHandlerArgument 属性に引数名を追加する必要があります。

static class LoggerExtensions
{
    public static void Log(
        this Logger logger,
        IFormatProvider? provider,
        [InterpolatedStringHandlerArgument("logger", "provider")] in Handler handler)
    {
        // ログ出力する
    }
}

複雑なハンドラ コンストラク

上記のコンストラクタ引数に関連する機能は全て組み合わせることが可能です。

class Log
{
    public void Log(
        IFormatProvider? provider,
        [InterpolatedStringHandlerArgument("", "provider")] in Handler handler)
    {
        // ログ出力する
    }
}

[InterpolatedStringHandler]
struct Handler
{
    public Handler(
        int literalLength,
        int formattedCount,
        Logger logger,
        IFormatProvider? provider,
        out bool handlerUsed)
    {
        // 実装は省略
    }

    // Append 系メソッドは省略
}

繰り返しになりますが、InterpolatedStringHandlerArgument 属性の引数は、ハンドラのコンストラクタ引数と同じ順序で並べる必要があります。レシーバ型に対応する位置には空文字列を渡します。

bool 値を返す Append 系メソッド

Append 系メソッドは bool 型の戻り値を返すこともできます。すべての Append 系メソッドで戻り値の型は統一されている必要があり、void 型か bool 型のいずれかである必要があります。

public bool AppendLiteral(string s)
{
    return true;
}

public bool AppendFormatted<T>(T t)
{
    return true;
}

この場合、翻訳結果は以下のようになります。

var logger = new Logger();
var handler = new Handler(11, 1);
if (handler.AppendLiteral("parameter: "))
{
    handler.AppendFormatted(param);
}

logger.Log(handler);

つまり、1 つの Interpolated String について、それぞれ複数回の Append 系メソッドが呼ばれますが、その中のいずれかが false を返すと、それ以降の Append 系メソッドは呼ばれないということです。

これは例えば、出力できる文字列長に制限があって、それ以上追加できないような場合に使ったりします。

ただし、やはり Log メソッドの呼び出しまでは自動的には省かれません。このため、たとえば出力内容が長すぎたということを Log メソッド内で知る必要がある場合は、いずれかの Append 系メソッドが false を返したか否かということを、ハンドラ内で覚えておく必要があるでしょう。具体例は煩雑になってしまうため割愛します。

Append 系メソッドのオーバーロード

Append 系メソッドは様々な追加引数を取ることができます。

アラインメント

アラインメントは、値を文字列化する際に確保する幅と、値の割り付け方法を指示します。
アラインメントを指定するには、値の後にカンマに続けて整数値を書きます。値を文字列化する際には、最低限、アラインメント値(の絶対値)だけの幅が確保され、余白には半角スペースが埋められます。
また、アラインメント値が 0 以上の場合、値は右揃えになります(左側に空白が詰められます)。アラインメント値が負の数の場合、値は左揃えになります(右側に空白が詰められます)。

言葉で言うだけでは分かりづらいので例を出しましょう。

以下のようなコードを実行したとすると

Console.WriteLine("0123456789");

Console.Write($"{'A', 5}");
Console.WriteLine("B");

Console.Write($"{'A', -5}");
Console.WriteLine("B");

出力結果はこうなります。

0123456789
    AB
A    B

最初の行はアラインメントとして 5 を指定しているので、A を出力する際に 5 文字分の幅が確保され、値はその中で右揃えにされて出力されます。
2 行目はアラインメントとして -5 を指定しているので、A を出力する際に 5 文字分の幅が確保され、値はその中で左揃えにされて出力されます。
出力したい文字列長よりもアラインメントで指定された幅の方が小さい場合、アラインメントは無視されます。

新しい Interpolated Strings でアラインメントを指定するには、AppendFormatted メソッドに追加の引数が必要です。この引数名は alignment でなければならないようです。

public void AppendFormatted<T>(T t, int alignment)
{
    // 実装は省略
}

書式指定文字列

こちらは(アラインメントよりも)使ったことがある人も多いでしょう。
たとえば、整数値を 16 進数で出力したりする場合に使います。
Interpolated Strings で使う場合、値の後にコロンに続けて書式指定文字列を書きます。

// 16 進数で "0A" と出力される
Console.WriteLine($"{10:X2}");

新しい Interpolated Strings で書式指定文字列を指定するには、AppendFormatted メソッドに追加の引数が必要です。この引数名は format でなければならないようです。

public void AppendFormatted<T>(T t, string? format)
{
    // 実装は省略
}

AppendFormatted メソッドのオーバーロード

アラインメントと書式指定文字列は組み合わせることもできます。このため、すべてのパターンに対応するには、4 通りの AppendFormatted メソッドのオーバーロードが必要です。

public void AppendFormatted<T>(T t)
{
    // 実装は省略
}

public void AppendFormatted<T>(T t, int alignment)
{
    // 実装は省略
}

public void AppendFormatted<T>(T t, string? format)
{
    // 実装は省略
}

public void AppendFormatted<T>(T t, int alignment, string? format)
{
    // 実装は省略
}

なお、アラインメントと書式指定文字列の両方に対応する 3 引数のメソッドでは、どちらの引数を先にすることもできます。

// これでも可
public void AppendFormatted<T>(T t, string? format, int alignment)
{
    // 実装は省略
}

両方定義するとエラーになります。

アラインメントと書式指定文字列の詳細に関しては string.Format メソッドのドキュメントを参照してください。

特化メソッド

特定の型に特化した非ジェネリックなメソッドを定義することもできます。

public void AppendFormatted(ReadOnlySpan<char> span)
{
    // 実装は省略
}

ReadOnlySpan<T> のような ref 構造体は、ジェネリクスの型引数 T としても、object としても扱えないので、これは用意しておいた方がよさそうですね。

既定値を持つ引数

Append 系メソッドには既定値を持つ引数を追加することができます。
CallerFilePath 属性などを使う場合に便利です。

public void AppendLiteral(
    string s,
    [CallerFilePath] string filePath = "",
    [CallerLineNumber] int lineNumber = 0)
{
    // 実装は省略
}

その他に可能なシグネチャ

アラインメントや書式指定文字列にはデフォルト引数を使用することもできます。
こうすると 4 つのオーバーロードを用意する必要がありません。

// AppendFormatted はこれだけでもいける
public void AppendFormatted<T>(T t, int alignment = 0, string? format = null)
{
    // 実装は省略
}

何なら、型の互換性があればいいので、以下のようなコードでも動きます。
しかし、この例は、値型をボクシングしたくないという当初の動機から外れてしまっているので、本末転倒ではあります。

// AppendFormatted はこれだけでもいける
public void AppendFormatted(object? t, int alignment = 0, string? format = null)
{
    // 実装は省略
}

ただ、以下のような利用法にも対応するのであれば、上記のようなオーバーロードも必要になりますね。

Log($"{null}");

AppendFormatted メソッドにどのようなオーバーロードを用意するかは、実装のシンプルさや、アラインメント等が指定される頻度、実行時のパフォーマンス等を考慮して、総合的に決める必要があります。
実際、設計チームの Design Note を見ると、どういうメソッド セットを用意するかについて、驚くほど詳細な検討が行われています。一読の価値ありです。

拡張メソッドによる定義

Append 系メソッドをハンドラに対する拡張メソッドとして定義することはできません。

自動翻訳されない場合

引数が Interpolated String でない場合は翻訳が行われません。

Log("hello");

上記のような場合では、Log(string) というシグネチャのメソッドがないとエラーになります。

もしくは、ハンドラに文字列からの暗黙変換演算子を定義することもできます。

[InterpolatedStringHandler]
struct Handler
{
    public static implicit operator Handler(string? s)
    {
        var handler = new Handler(s?.Length ?? 0, 0);
        
        if (s is not null)
        {
            handler.AppendLiteral(s);
        }
        
        return handler;
    }
}

なお、Log(string)Log(Handler) の両方のメソッドがある場合は、コンパイル時に内容を確定可能な文字列になる場合は Log(string) が、そうでない場合は Log(Handler) が呼ばれます。
補足すると、C# 10.0 から、{} の中が const string である場合は、コンパイル時に確定可能な文字列として扱われます。

void Log(string s)
{
    Console.WriteLine("string");
}

void Log([InterpolatedStringHandlerArgument] in Handler handler)
{
    Console.WriteLine("handler");
}

Log(""); // string
Log($""); // string
Log($"{""}"); // string

const string c = "c";
Log($"{c}"); // string

Log($"{1}"); // handler

Log(string) しかない場合については、次節を参照してください。

既定のハンドラ

.NET 6.0 から、DefaultInterpolatedStringHandler 構造体が定義されています。

カスタムのハンドラを定義する代わりに、この型を使うこともできます。
この場合、ToStringAndClear メソッドを呼び出すことで文字列化することができます。
細かい制御が必要ないのであれば、これを使ってお手軽に済ませるのもよいかもしれません。

ToStringAndClear メソッドはその名の通り、文字列化してから、ハンドラの内部状態をクリアします。そのため、このメソッドは、1 つのハンドラ インスタンスにつき一度だけ呼び出す必要があります。ToStringAndClear メソッドを呼び出した後は、もうそのハンドラを触ってはいけません。

あまりないことだとは思いますが、1 つのハンドラ インスタンスを複数回文字列化したい場合は、ToString メソッドを使うことができます。こちらはハンドラの内部状態をクリアしません。

ただし、できれば 1 回は ToStringAndClear メソッドを呼んだ方が良いでしょう。Interpolated Strings を繰り返し使用する場合にパフォーマンスの向上が見込めます。
例外が発生して呼べなかったような場合でも、ヤバいメモリリークが発生するようなことはないので大丈夫です(例外に関する詳細は後述します)。

string への自動変換

Interpolated String を string 型で受け取る場合には、以下のような特殊な変換がかかります。

// 変換前
var x = $"foo{1}bar"; // x は string
// 変換後
DefaultInterpolatedStringHandler handler = new DefaultInterpolatedStringHandler(6, 1);
handler.AppendLiteral("foo");
handler.AppendFormatted(1);
handler.AppendLiteral("bar");
string s1 = handler.ToStringAndClear();

例外が発生し得る場合

以下のようなコードを書くことができます。

var x = $"{Throw()}";

int Throw()
{
    throw new Exception();
}

このコードは以下のように変換されますね。

DefaultInterpolatedStringHandler handler = new DefaultInterpolatedStringHandler(0, 1);
handler.AppendFormatted(Throw());
string s = handler.ToStringAndClear();

int Throw()
{
    throw new Exception();
}

これは handler.ToStringAndClear() メソッドが呼ばれる前に例外が発生してしまうため、DefaultInterpolatedStringHandler コンストラクタの内部で確保されたバッファが回収されません。
ただ、この場合でもバッファは GC に回収されるので、大きな問題はありません。ハンドラを使わない従来の挙動より悪くなることは無いと言っていいでしょう。

自分でハンドラを書く場合、このような例外が発生しないことを保証することはできません。
また、ハンドラが IDisposable インターフェイス等を実装していても、コンパイラが扱いを変えてくれることはありません。
そのため、ハンドラが使用するリソースを迅速かつ確実に後始末する手段は存在しません。ハンドラが使用するリソースは、最悪の場合でも GC で回収されるようにしておく必要があります。

非同期メソッドでの使用

DefaultInterpolatedStringHandler は内部に Span<char> を持っているため、ref 構造体として宣言されています。
だからといって、ハンドラが非同期メソッド内で使えないということはありません。*5

async Task Foo()
{
    var x = await SomeAsyncFunction();
    Log($"{x}"); // 使える
}

ただ、以下のように書くと、string.Format メソッドの呼び出しに展開されてしまうので要注意です。

async Task Foo()
{
    Log($"{await SomeAsyncFunction()}");
}

なお、ref 構造体ではない自前のハンドラを作れば、上記のような書き方もできます。

おまけ:構造化ロギングでの使用

構造化ロギングというのは、ログを文字列化された行としてではなく、JSON のような構造化されたデータとして出力するというロギング手法です。構造化ロギングを使用する場合、一般的に、ログは名前と値のペアとして記録されます。

たとえば、以下のようなコードから

Log($"parameter: {foo}");

以下のような出力を得たい、とか。

{
    "message": "parameter: 1",
    "parameters": {
        "foo": 1
    }
}

こういう場合に "foo" という名前が欲しい場合はどうしたらいいでしょうか。
一例としては、やはり C# 10.0 の新機能である CallerArgumentExpression 属性を使うことが考えられます。

以下のようにすると、引数 expression に "foo" が渡されます。

public void AppendFormatted<T>(
    T t,
    [CallerArgumentExpression("t")] string expression = "")
{
    // 実装は省略
}

ただ、CallerArgumentExpression 属性によって得られる値は、必ずしも構造化ログのキーとして使用するのに適切なものばかりではありません。*6
たとえば以下のようなコードでは、expression には "1" とか "3 + 5" といった文字列が渡されます。

Log($"parameter: {1}");
Log($"parameter: {3 + 5}");

別の案としては、明示的に名前を与えてしまうという手も考えられます。こんな感じで。

Log($"parameter: {("Foo", 1)}");

受け取る側はこんな感じになるでしょうか。

public void AppendFormatted<T>(
    (string name, T value) x)
{
    // 実装は省略
}

おわりに

string.Format メソッドを使った実装はお手軽ではありますが、Span<T> 等を使って最適化した実装の方が効率的な場合もあるでしょう。
Interpolated String を string 型で受け取っているメソッドがループ内で頻回に呼び出される場合などは、ハンドラを実装するとパフォーマンスの向上が見込めるかもしれません。
ただし、string.Format メソッドと同等の処理を再実装することになるので、複雑さやバグの可能性とのトレードオフである点には注意してください。
既定の DefaultInterpolatedStringHandler を使うと、お手軽さとパフォーマンスのバランスがいいかもしれません。

*1:日本語では補完文字列などとも呼ばれます

*2:この案は実際に提案されています

*3:仕様書中でこのように呼ばれています。メソッド呼び出しをメッセージ パッシングとして考えると、メソッドを持つ型はメッセージを受け取る立場になるので、このように呼んでいるのだと思います。

*4:過去には InterpolatedStringHandlerArgument 属性に "this" という文字列を与えるという仕様提案もありました。

*5:詳細は省略しますが、ハンドラ変数の寿命が await を跨がないからです。

*6:JSON のキーとしては仕様上 OK かもしれませんが、使いやすくはなさそうです。