今日のは、C# 言語機能としては否決されたものの、ほぼ同等のものがライブラリと JIT 時最適化で実現されたという話になります。

ちなみに今日のこの話は .NET 8 の頃の話で、「そういえば去年書いてなかった」ネタになります。

UTF-8 リテラルがあるなら

C# 11 で UTF-8 リテラルが入って、 C# プログラム中に UTF-8 なバイト列を ReadOnlySpan<byte> で直接埋め込めるようになりました。

ReadOnlySpan<byte> hex = "0123456789ABCDEF"u8;

割かし何度も書いてたりはしますが、 もう今となっては世の中の多くの文字列が UTF-8 でやり取りされています。 .NET の string が UTF-16 ベースなので、UTF-16 ⇔ UTF-8 の変換がそれなりのコストになっていたりします。

そこで、いろんなものを直接 UTF-8 で書き込んだりするようになりました。 今、 .NET のプリミティブ型にも IUtf8SpanFormattable インターフェイスとかが実装されていて、UTF-8 文字列化を直接できるようになっています。

そうなると欲しくなるのが UTF-8 に直接書き込める文字列補間。 以下のようなことをできるといいなぁという要望があります。

static byte[] Format(int x, int y) => $"(X: {x:X2}, Y: {y:X2})"u8;

要は、文字列補間の $"" にも u8 を付けて、直接 UTF-8 を書き込むというもの。

この提案、一瞬本気で検討はされてたんですけども:

結論がどうなったかというと、「普通の文字列補間を使って、パフォーマンスを落とさずに UTF-8 補間できるように JIT 最適化したからいいや」という感じで、C# 言語機能は不要ということになりました。

Utf8.TryWrite

前節の UTF-8 文字列補間(案)に類することをやるために、 .NET 8 移行、以下のような書き方ができます。

using System.Text.Unicode;

static byte[] Format(int x, int y)
{
    var temp = (stackalloc byte[64]);

    Utf8.TryWrite(temp, $"(X: {x:X2}, Y: {y:X2})", out var written);

    return temp[..written].ToArray();
}

Utf8 クラスの TryWrite メソッドを使って直接 UTF-8 なバイト列を作れます。

ただ、文字列補間の部分は普通の $"" で書きます。 そして、この文字列補間の展開結果は以下の通り。

using System.Text.Unicode;

static byte[] Format(int x, int y)
{
    var temp = (stackalloc byte[64]);

    Utf8.TryWriteInterpolatedStringHandler handler = new(9, 2, temp, out var shouldAppend);

    if (shouldAppend
        && handler.AppendLiteral("X: ")
        && handler.AppendFormatted(x, "X2")
        && handler.AppendLiteral(", Y: ")
        )  handler.AppendFormatted(y, "X2");

    Utf8.TryWrite(temp, ref handler, out var written);

    return temp[..written].ToArray();
}

定数文字列の最適化

前節、UTF-8 補間に、普通の文字列補間構文を使っているため、 AppendLiteral("X: ") とかで、普通の文字列リテラル(UTF-16)が発生しています。 当然ここで「そんなことしたら UTF-16 から UTF-8 への変換コストが発生したりするんじゃないか?」という話になるんですが、 そこが今日の本題。 .NET 8 移行、定数文字列の最適化がものすごい進んだみたいでして。 結果、この変換コストはほとんど発生しないそうです。

AppendLiteral の中身はこんな感じ:

内部的に ReadUtf8 ってのを呼んでるんですけども、これはこちら:

説明文に、

Same as Encoding.UTF8.TryGetBytes, except with refs, returning the number of bytes written (or -1 if the operation fails), and optimized for a constant input.

(Encoding.UTF8.TryGetBytes とほとんど一緒で、違いは、ref を使っていて書き込んだバイト数を返すのと、定数入力に対して最適化される。)

と書かれていますし、Intrinsic 属性が付いています。 この属性が付いているメソッドは「JIT が特別な最適化したものに置き換える」というマーカーでして、 定数文字列の UTF-16 → UTF-8 変換は JIT がやっちゃいます。 この最適化の Pull Request は以下の通り:

これのおかげで、 AppendLiteral("X: ") のコストがほぼ AppendLiteral("X: "u8) (みたいな ReadOnlySpan<byte> 引数オーバーロードを足すの)と同レベルになるそうで。

$""u8 要る?

そこで本題に戻りまして、UTF-8 文字列補間用の構文、すなわち、$""u8 みたいなものは必要かどうか。

ここまでで説明したように、Utf8.TryWrite(temp, $"(X: {x:X2}, Y: {y:X2})", out var written); みたいに普通の文字列補間(C# のコンパイル結果的には UTF-16)で書いても、JIT 時最適化で UTF-16 から UTF-8 の変換コストがかなり低コストになっています。

この事実を踏まえて、改めて C# コンパイラー チーム内で検討した結果、 backlog (未処理・未完成品、C# チーム用語的には「よっぽど斬新なアイディアが出てこない限りやらない」)行きになりました。

まあ、確かに、Utf8.TryWrite で事足りた感じはあります。