UTF-8 リテラル

Ver. 11

C# 11 で、"abc"u8 みたいに、文字列リテラルの後ろに u8 接尾辞を付けることで、UTF-8 な byte 列を文字列リテラルの形で書けるようになりました。

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

UTF-8 リテラル(UTF-8 literal)、もしくは語尾を取って u8リテラル(u8 literal)と呼びます。 ちなみに、UTF-8 リテラルの型は ReadOnlySpan<byte> になります。 (var による型推論も使えます。)

var hex = "0123456789ABCDEF"u8;
Console.WriteLine(hex is ReadOnlySpan<byte>); // 「常に true」警告が出る

補足: C# と UTF-8

UTF-8 のリテラルの話をもう少し掘り下げる前に、C# における文字コードの話を少し補足しておきます。

時代背景

今となっては、文字コードと言えばほぼ Unicode で、 その他の文字コードは互換性のために残っていると言っても過言ではないと思います。 Unicode に関する話は昔、Build Insider に寄稿したことがあるのでそちらも参照してください。

また、Unicode でも、符号化方式として、主に UTF-8 と UTF-16 という形式があります。 2000年代頃から徐々に UTF-8 の方が主流になってきています。

ただ、C# くらいの世代(2000年発表、2002年正式リリース)のプログラミング言語では、 結構昔の文字コードを引きずっていますし、 UTF-16 が主流になると思われていた時代の名残りが大きいです。

そのため、C# の文字(char)や文字列(string)は UTF-16 前提で、16ビット整数になっています。 (同じような方針になってしまっているプログラミング言語に Java や JavaScript があります。)

Console.WriteLine(sizeof(char)); // 16

ところが、時代は UTF-8 一色になりました。 それにそもそも、プログラムの中で文字列操作する際にはほとんど ASCII コードに収まる文字しか使わない場面も多いです。 (UTF-8 は ASCII コードと完全互換です。 一方で、UTF-16 の場合は「1バイトを2バイトに引き延ばす」みたいな変換処理が必要で、この負担が案外大きいです。)

その結果、ここ数年、C# で「文字が UTF-16」というのが結構な負担になっていました。

byte でやりくり

この文字コード問題に対して、一時、 Utf8String みたいな名前で UTF-8 な型を追加しようか何て話もありました。 しかし、その方向性だと、stringUtf8String の2重管理がしんどい(これだけ string 前提で .NET エコシステムが確立された状況で追加は無理だろう)という雰囲気になっています。

そうこうしているうちに、「生 byte 列で UTF-8 を扱う」と言うのが .NET エコシステム内でデファクトスタンダード化してしまいました(今ここ)。 例えば System.Text.Unicode 名前空間中のメソッドは以下のような感じになっています。

using System.Buffers;

namespace System.Text.Unicode;

public static class Utf8
{
    public static OperationStatus FromUtf16(
        ReadOnlySpan<char> source, Span<byte> destination, out int charsRead, out int bytesWritten,
        bool replaceInvalidSequences = true, bool isFinalBlock = true);

    public static OperationStatus ToUtf16(
        ReadOnlySpan<byte> source, Span<char> destination, out int bytesRead, out int charsWritten,
        bool replaceInvalidSequences = true, bool isFinalBlock = true);
}

Span<byte>ReadOnlySpan<byte> で UTF-8 文字列を扱っています。

C# 10 までの課題: 文字列リテラルの byte 配列化

一応、Span<byte> で UTF-8 文字列を扱えるとはいえ、 問題は文字列リテラルです。 "true" とか " HTTP/1.0\r\n" とか、 UTF-8 文字列 (ほとんどの場合、ASCII 文字列)を定数でプログラム中に埋め込みたい場面は結構あります。

今だと以下のように byte 定数を並べた配列を new byte[] するしか方法がありません。

ReadOnlySpan<byte> _true = new byte[] { (byte)'t', (byte)'r', (byte)'u', (byte)'e' };
ReadOnlySpan<byte> _false = new byte[] { (byte)'f', (byte)'a', (byte)'l', (byte)'s', (byte)'e' };
ReadOnlySpan<byte> _null = new byte[] { (byte)'n', (byte)'u', (byte)'l', (byte)'l' };

一応、これ、最適化はされて new byte[] のヒープ アロケーションは発生せず、 直接 DLL 中のデータ領域からデータが読まれます。

とはいえ明らかに煩雑で、true などの文字列から上記のような byte 配列を生成してもらいたくなります。 その結果、C# 11 で UTF-8 リテラルが入ることになりました。

UTF-8 リテラルの利用例

.NET の標準ライブラリ中のコードにも、前述のような「本当は文字列リテラルとして埋め込みたいのに仕方がなく new byte[] にしていた」というものが山ほどありました。 C# 11 化に伴い、大量のコードが UTF-8 リテラル化されています。 以下のような Pull Request が出ています。

これらの中には、例えば以下のような文字列が含まれています。

// HTTP のステータス コード
var ok = "200"u8;
var notFound = "404"u8;

// CR LF
var eol = "\r\n"u8;

// 既知の型名
var boolName = "Boolean"u8;
var byteName = "Byte"u8;
var in32Name = "Int32"u8;

// 変換用テーブル
var base64Table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"u8;
var base32Table = "abcdefghijklmnopqrstuvwxyz012345"u8;
var hexTable = "0123456789ABCDEF"u8;

// Culture 名
var cultureNames = // 一部抜粋
    "en-us"u8 +
    "fr-fr"u8 +
    "it-it"u8; // 以下略

UTF-8 リテラルの詳細

とうことで、改めて UTF-8 リテラルの話に戻りましょう。

本節冒頭で書いた通り、文字列リテラルの後ろに u8 接尾辞を付けることで UTF-8 リテラルになり、ReadOnlySpan<byte> を得ることができます。

ReadOnlySpan<byte> s = "abc"u8;

ちなみに、初期案としては、u8 接尾辞がなしの通常の文字列リテラルも、 ターゲット型を見て自動的に UTF-8 リテラルに変換する話も出ていましたが、 オーバーロード解決がうまくいかず、没になりました。

// 初期案では OK だった(今はエラー)。
byte[] s1 = "abc";
ReadOnlySpan<byte> s2 = "abc";

// u8 接尾辞ありで、byte[] への変換も元々は認めてた(今はエラー)。
byte[] s3 = "abc"u8;

UTF-8 リテラルの展開結果

UTF-8 リテラルは、その文字列を UTF-8 として符号化した byte 列に展開されます。 例えば、前述の "abc"u8 は、以下のようなコードとほぼ同じ意味になります。

ReadOnlySpan<byte> s = new byte[] { 97, 98, 99 };

この手のコードは、C# コンパイラーによって、以下のようなコードに最適化されます。

byte* p = DLL中のデータが格納されている領域へのポインター;
ReadOnlySpan<byte> s = new ReadOnlySpan<byte>(p, 3);

ちなみに、最近の .NET は Span<T>, ReadOnlySpan<T> に対する最適化が結構よく掛かって、 例えば、"abc"u8.Length は JIT 時に単なる 3 に展開されたりします。

+ での結合

UTF-8 リテラル同士は + 演算子で結合できます。 例えば、以下の2変数には同じ結果が代入されます。

var singleLine = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"u8;

var concatenated = 
    "ABCDEFGHIJKLMNOPQRSTUVWXYZ"u8 +
    "abcdefghijklmnopqrstuvwxyz"u8 +
    "0123456789"u8 +
    "+/"u8;

これは、UTF-8 リテラルに対する特殊対応で、 一般の ReadOnlySpan<byte> に対しては + 結合はできません。

ReadOnlySpan<byte> abc = new byte[] { 97, 98, 99 };
ReadOnlySpan<byte> def = new byte[] { 100, 101, 102 };

var s1 = abc + def; // エラー。
var s2 = abc + "def"u8; // 片方が u8 リテラルでもダメ。エラー。

注意: 非 const

(少なくとも C# 11 時点では) UTF-8 リテラルは const 扱いにはなりません。 const しか書けない場所で使うとエラーになります。 具体的には、例えば、switchis に使えません。

// これは OK。
bool str(string x) => x is "abc";

// C# 11 で、これは OK になった。
bool charSpan(ReadOnlySpan<char> x) => x is "abc";

// これはダメ。
bool u8(ReadOnlySpan<byte> x) => x is "abc"u8;

// ちなみに、同じく C# 11 で入ったリスト パターンで、こんな風には書ける(つらい)。
bool listPattern(ReadOnlySpan<byte> x) => x is [ 97, 98, 99 ];

UTF-8 生文字列

生文字列リテラルとの組み合わせもできます。 この場合も、""" の後ろに u8 接尾辞を付けます。

var utf8Json = """
    {
      "id": 123,
      "name": "abc",
      "flag": true
    }
    """u8;

結果が UTF-8 符号化された ReadOnlySpan<byte> になる以外は生文字列リテラルと同じです。

一方で、(少なくとも C# 11 では) 文字列補間との併用はできません。

var x = 123;
var y = "abc";

// これは OK。
var s = $"id: {x}, name: {y}";

// これはダメ。
var u8 = $"id: {x}, name: {y}"u8;

注意: 不正な Unicode 文字

UTF-8 リテラルでは、UTF-8 にしたときに不正になるものはコンパイル エラーになります。

「UTF-8 リテラルでは」という前置きがあるのは、 C# の string は UTF-16 として不正なものを受け付けてしまうからです。 (この辺りも時代の影響で、昔は今よりも Unicode の扱いがかなり緩かったです。)

具体的には「サロゲート ペアの片割れ」みたいなやつで、 現代的にはこういう「片割れ」を残すのはよくないと言われていますが、 C# の charstring は受け付けます。

// サロゲート ペアの片割れだけの文字列。
// 現代的にはエラーにしたい。C# ができた頃にはそんなにうるさく言われなかった。
var highSurrogate = "\uD801";

// ちなみに、 System.Text.Encoding では不正な Unicode 文字列を ? (U+FFFD) に置き換える処理あり。

// C# でいうところの Unicode は UTF-16 のこと。
var utf16 = System.Text.Encoding.Unicode;

// 一度符号化して、複号すると…
var encoded = utf16.GetBytes(highSurrogate);
var decoded = utf16.GetString(encoded);

// U+FFFD に置き換わってる。
// この文字は replacement character と言って、
// 不正な文字を残さないために、認識できなかった文字を置き換えるための文字。
foreach (var c in decoded)
{
    Console.WriteLine($"{c}: {(int)c:X}");
}

ですが、C# 11 の時代(2022年)に生まれた UTF-8 リテラルは、 ちゃんと不正な文字列をはじきます。

// UTF-8 リテラルの場合は「サロゲート ペアの片割れ」を受け付けない。
// コンパイル エラーを起こす。
var highSurrogate = "\uD801"u8;

ちなみに、以下のように、最終的に有効な Unicode 文字列になるものであればちゃんとコンパイルできます。

var surrogatePair = "\uD801\uDE00"u8;

一方で、以下のように「+ で結合すれば最終的には有効になるはずの2つの UTF-8 リテラル」みたいなものはコンパイル エラーになります。

var surrogatePair =
    "\uD801"u8 +
    "\uDE00"u8;

更新履歴

ブログ