🤖

C#: structのフィールドをreadonly参照する方法およびref readonly について

2024/07/03に公開

はじめに

C# においてメンバ変数を参照として公開したいことがあります。
よくあるのが、データサイズの大きな構造体などを readonly参照で公開することです。

第一の理由はImmutable であることを保証するためです。書き換えできてしまうと複雑性が増すので、誤って書き換えできないように、readonly 参照を公開したいのです。
第二の理由は大きな構造体のコピーを避けたいからです。データサイズが大きいとコピーだけで帯域を取ってしまいますので、性能が下がります。

というわけでメンバ変数を readonly 参照で公開したいのですが、いかんせん難しいときがありますので、本稿でいい感じの方法をまとめます。

classフィールドへのreadonly参照を返す

class のフィールドはそのまま ref return できます。
フィールドが値型だろうが参照型だろうがref return できますが、専ら値型をref return することでしょう。

using System.Numerics;

public class Foo
{
    // なんかフィールド
    private Vector3 _position;

    // コピーを返す
    public Vector3 CopyPosition => _position;
    // フィールドの参照を返す
    public ref Vector3 RefPosition => ref _position;
    // フィールドの参照をreadonlyで返す
    public ref readonly Vector3 RefReadOnlyPosition => ref _position;

    // メソッドもプロパティも差は特にない
    public ref readonly Vector3 RefReadOnlyPositionFunc()
    {
        return ref _position;
    }

    // class にreadonly関数はない
    //public readonly ref readonly Vector3 ConstRefPosition => ref _position;
}

// 使い方はこんな感じ
public static void Main()
{
    Foo foo = new Foo();
    Vector3 copy1 = foo.CopyPosition; // ただのコピー
    Vector3 copy2 = foo.RefPosition; // ref return をそのまま受けたらコピーになる
    ref Vector3 refMutable = ref foo.RefPosition; // ref 修飾で refで受けられる
    refMutable.X = 100; // 書き換えることができてしまうのであまり使わない

    // 読み取り専用参照でコピーせずに受け取る
    ref readonly Vector3 refReadOnly = ref foo.RefReadOnlyPosition;
    //refReadOnly.X = 100; // readonly参照は書き換えられないのでコンパイルエラーとなり安全

    // ref readonly return は ref readonly修飾しないと受け取れない. コンパイルエラーとなり安全
    //ref Vector3 refReadOnly = ref foo.RefReadOnlyPosition;
}

特に難しいことはありません、教科書通りです。

ただし自動プロパティの ref return はダメ

混同しやすい点として、自動プロパティは ref return をサポートしないことに注意です。
自動プロパティは実体をバッキングフィールドへと隠します。
getter と setter はただの関数として定義されるため バッキングフィールドへのref アクセスは不可能となるのです。

class Foo
{
    // 自動プロパティじゃだめ
    public Vector3 Position{ get; private set;}

    // 自動プロパティは ref return できません
    // バッキングフィールドの名前がわからないのでアクセスできない...
    //public ref Vector3 RefPosition => ref Position;
}

structフィールドへのreadonly参照を返す

struct型の場合は readonly 参照を返すのは少し大変です。
まず class のように フィールドを直接参照として返すことはできません。

struct DisplaySettings
{
    public int width;
    public int height;
}

struct AudioSettings
{
    public int Volume;
}

// 大きいデータ型が子のデータ型を参照で公開したいとする
struct UserSettings
{
    private DisplaySettings _displaySettings;
    private AudioSettings _audioSettings;

    // コンパイルエラー
    // struct は this や memberへの参照を返すことができない
    //public ref readonly DisplaySettings RefDisplaySettings => ref _displaySettings;
    //public ref readonly AudioSettings RefAudioSettings => ref _audioSettings;
}

困りました。

解法1: public フィールドにする

実はstructのpublic フィールドへのアクセスはそのままメンバ変数アドレスへのアクセスとなるため、ref でやり取りできます。

struct UserSettings
{
    // フィールドとするのがポイント. 自動プロパティじゃだめ
    public DisplaySettings DisplaySettings;
    public AudioSettings AudioSettings;
}

// 使い方はこんな感じ
public static void Main()
{
    UserSettings settings = new();
    DisplaySettings copy = settings.DisplaySettings; // これはただのコピー

    // public フィールドは直接 ref できる
    ref DisplaySettings refer = ref settings.DisplaySettings;
    // ref readonlyで受け取ることもできる
    ref readonly DisplaySettings referReadOnly = ref settings.DisplaySettings;
}

ただし、この方法はカプセル化もなにもあったもんじゃありませんし、ユーザー側で好き勝手書き換えることができてしまいます。ref readonly で渡すことを強制できません。

解法2: internalフィールドにして拡張メソッドを使う

in thisおよびref this を使えばアドレスでわたってくる参照を取り扱うことができます。
同一モジュール内の拡張メソッドならばinternalなアクセスを行うことができます。
さらに in this 拡張メソッドの場合、readonly修飾されていない関数を実行しようとするとコンパイルエラーにしてくれます。
防御的コピーを防ぐことができてお得です。

public struct UserSettings
{
    internal DisplaySettings DisplaySettings;
    internal AudioSettings AudioSettings;
}
public static class UserSettingsExtensions
{
    // in this による ref readonly return な関数
    // 値を一切書き換えないことが保証される
    public static ref readonly DisplaySettings RefDisplaySettings(in this UserSettings userSettings)
    {
        return ref userSettings.DisplaySettings;
    }

    // ref this における ref return な関数
    // 書き換えできてしまうのであまり使い道はない
    // これを使うぐらいなら最初からpublic フィールドにすればいいから
    public static ref DisplaySettings RefMutableDisplaySettings(ref this UserSettings userSettings)
    {
        return ref userSettings.DisplaySettings;
    }
}

// 使い方はこんな感じ
public static void Main()
{
    UserSettings settings = new();
    ref readonly var referReadOnly = ref settings.RefDisplaySettings();
}

public が internal になったぶん少しマシになった気がします。

解法3: readonly struct にする

最強です。 record struct でもいいです。
書き換え不可能なので常に参照として受け取ってよくなりました。
プロパティに包む必要もなく、そのままフィールドを公開していいです。

public readonly struct UserSettings
{
    public readonly DisplaySettings DisplaySettings;
    public readonly AudioSettings AudioSettings;
}

// 使い方はこんな感じ
public static void Main()
{
    UserSettings settings = new();
    // readonly struct は ref readonly でしか受けられない
    ref readonly var displaySettings = ref settings.DisplaySettings;
}

弱点としては書き換えられないことです。
Settingsのような構造体は書き換えてなんぼなので不向きですね。
一応常に新しい値をnew して使う、ということは可能ですが少々面倒です。

解法4: ref struct な viewを介在させる

ここでいうview とはデータに対するのアクセサーのことです。
C++ の string_view みたいなやつです。
ref struct もしくは readonly ref struct を定義しその構造体を介してアクセスさせることで自身のprivate フィールドへ合法的にアクセスさせることができます。

using System;
using System.Runtime.InteropServices;

// 参照としてアクセスするためのビュー
 public ref struct DisplaySettingsView
{
    private Span<DisplaySettings> _span;

    // 書き換え可能な参照
    public readonly ref DisplaySettings AsRef => ref _span[0];

    // 読み取り専用な参照
    public readonly ref readonly DisplaySettings AsRefReadOnly => ref _span[0];
                
    public DisplaySettingsView(Span<DisplaySettings> span)
    {
        _span = span;
    }
}

public struct UserSettings
{
    private DisplaySettings _displaySettings;

    public DisplaySettingsView GetView()
    {
        // 自身のフィールドを長さ1のスパンとして定義するのがポイント
        // ref span[0] でそのフィールドに合法的にアクセスできる
        Span<DisplaySettings> span = MemoryMarshal.CreateSpan(ref _displaySettings, 1);
        return new DisplaySettingsView(span);
    }
}

// 使い方はこんな感じ
public static void Main()
{
    UserSettings settings = new();
    DisplaySettingsView view = settings.GetView(); //ビューを返す
    ref readonly var displaySettings = ref view.AsRefReadOnly;
}

ビューを受け取った時点ではコピーは走りません。ref structのインスタンス自体のstack割り当ては発生しますが、微々たるものでしょう。
このようにview 経由でstruct のprivate field を参照することができました。
少々黒魔術感がありますが、カプセル化を壊すことなく、安全なコードになっている点で評価高いと思います。

ref readonly だけを使うのが推奨です。ref を返して書き換えさせてしまうとバグります。ref を返すぐらいなら public フィールドの方がマシってもんです。

まとめ

  1. まずは素直にclass にして ref readonly T型で返すことを検討する
  2. readonly struct でいいなら、public fieldとして定義する
  3. ref struct でビューを用意する
  4. 拡張メソッドで頑張る

Discussion