ネコのために鐘は鳴る

寺院に住み着くパソコ〇好き

(C#) C# 10でも ref field したい

小ネタです。

参照フィールドとは

C# 11 から ref field という機能が使えるようになる予定です。(マージはされていますが、本記事執筆時点では正式リリースはされていません)

ref 参照を ref 参照のまま ref struct のフィールドとして保持できるものです。

public ref struct Foo
{
    public ref int Value;
}

この機能を使うと嬉しいのは超ハイパフォーマンスでエクストリームな C# を書く人ぐらいなのですが、実は見えないところでその恩恵は受けています。 代表的なのが Span<T> でその内部構造を参照フィールドを使って書くと以下のようになっています。

public readonly ref struct Span<T>
{
    private readonly ref T _reference;
    private readonly int _length;
}

ref T _reference の部分は機能的には T* _reference と同じですが、ポインタは unsafe 限定で fixed な unmanaged 型にしか使えません。.NET の制約としてアンマネージド参照だけでなくマネージド参照を同列に扱うことができる ref field の方がより汎用的と言えます。 (これはあくまでコンパイラやランタイムから見た参照の種類の話で、メモリ上での値は全てただのアドレス値にすぎません。)

.NET6 (C# 10) 現在ではまだ ref field は使えないため、Span<T> は今は以下のような別の書き方で実装されています。

public readonly ref struct Span<T>
{
    private readonly ByReference<T> _reference;
    private readonly int _length;
}

ByReference<T> はランタイム内部でのみ使える特殊な型で、マネージド参照とポインタを同列に扱える参照を表す型です。今まではこの ByReference<T> による特殊対応によって参照フィールドを実現していたのですが、.NET 内部でしか使えずユーザーは使えませんでした。 (これも T*ref T と同じくメモリ上での表現は単なるアドレス値です。)

C# 11 から入る予定の ref field は、この ByReference<T> を一般化してユーザーからも使えるようにしたものです。

参照フィールドを疑似的に C# 10 以前でも使いたい

参照フィールドは C# 11 からしか使えないのですが、Span<T> を使うと疑似的に C# 10 以前でも使えます。

C# 11 なら本当はこう書きたい

// C# 11 なら本当はこう書きたい
public ref struct MyStruct
{
    private ref int _value;
    public MyStruct(ref int value)
    {
        _value = ref value;
    }
    public void SetValue(int newValue)
    {
        if(Unsafe.IsNullRef(ref _value) == false) {
            // コンストラクタで渡された参照先の値が変更される
            _value = newValue;
        }
    }
}

C# 10 で疑似的に実現した ref field

// C# 10 で疑似的に実現した ref field
public ref struct MyStruct
{
    private Span<int> _dummySpan;
    private ref int Value => ref MemoryMarshal.GetReference<int>(_dummySpan);

    public MyStruct(ref int value)
    {
        _dummySpan = MemoryMarshal.CreateSpan<int>(ref value, 1);
    }
    public void SetValue(int newValue)
    {
        if(Unsafe.IsNullRef(ref Value) == false) {
            // コンストラクタで渡された参照先の値が変更される
            Value = newValue;
        }
    }
}

ref フィールド は ByReference<T> と同一のものであり、ByReference<T> はユーザーは使えないことが唯一の問題点でした。しかし、ByReference<T> を内包する Span<T> はユーザーが使えます。つまり、Span<T>ByReference<T> の代わりに使えば疑似的に ref フィールドが使えるのです。

上記の例ではコンストラクタで MemoryMarshal.CreateSpan<T>(ref T, int) によって ref 参照から直接 Span<T> を作っています。また、Span<T> 内の参照は MemoryMarshal.GetReference<T>(Span<T>) によって取得できます。(ref _dummySpan[0] でも取得できますが、インデクサ経由だとインデックスの範囲チェックが無駄に入ります。)

SetValue メソッドで Unsafe.IsNullRef(ref Value) によって null チェックをしているのは、default(MyStruct) の場合に ref int の参照自体が null になっているため、Value に値をセットしようとしたときに null 参照で例外が出ます。値型しか登場していないのに null 参照で例外が発生するのは通常の C# ではありえないため気持ち悪いですが、アンセーフなことをしているが故の弊害です。

この C# 10で疑似的に作った ref field の欠点は、構造体のメモリ上のサイズが4バイト無駄に大きいことです。本物の ref field を使った場合、上記の MyStruct のサイズはアドレス一つ分なので64ビット環境では8バイトですが、C#10 の偽物の場合 int Span<T>.Length が4バイトあるため12バイトです。