参照戻り値と参照ローカル変数

Ver. 7

C# 7から、戻り値とローカル変数でも参照渡しを使えるようになりました。 書き方はほぼ参照引数と同じです。 戻り値の型の前、値を渡す側、値を受ける側それぞれにref修飾子を付けます。

例として、配列のi番目の要素を参照で返してみましょう。以下のようになります。

using System;

class Program
{
    static void Main()
    {
        var x = new[] { -1, -1, -1, -1, -1 };

        for (int i = 0; i < x.Length; i++)
        {
            // 戻り値を書き換えてる
            // 実際書き換わってるのは参照先の配列 x
            Ref(x, i) = i;
        }

        // ↑のループで書き換えたので、結果は 0, 1, 2, 3, 4
        Console.WriteLine(string.Join(", ", x));
    }

    // 配列の i 番目の要素を参照
    static ref int Ref(int[] array, int i) => ref array[i];
}
0, 1, 2, 3, 4

また、ローカル変数に対しても、ref修飾子を付けることで参照渡しができます。

using System;

class Program
{
    static void Main()
    {
        var a = 10;

         ref var b = ref a; // 参照ローカル変数。宣言側にも、値を渡す側にも ref

        var c = b;         // これは普通に値渡し(コピー)。この時点の a の値 = 10 が入る
        ref var d = ref b; // さらに参照渡しで、結局 a を参照

        d = 1; // d = b = a を書き換え

        ref var e = ref Ref(ref c); // 参照戻り値越しに、c を参照
        var f = Ref(ref c);         // これは結局、値渡し(コピー)

        ++e;   // e = c を +1。元が10なので、11に
        f = 0; // f は普通に値渡しで作った新しい変数なので他に影響なし

        // 結果は 1, 1, 11, 1, 11, 0
        // a, b, d が同じ場所を参照してて 1
        // 同上、c, e が 11
        // f が 0
        Console.WriteLine(string.Join(", ", a, b, c, d, e, f));
    }

    // 引数を素通し
    static ref int Ref(ref int x) => ref x;
}
1, 1, 11, 1, 11, 0

refだらけになってしまいますが、渡す側、受け取る側の両側にref修飾子が必要なのは参照引数と同様です。 元の変数がどこか遠くの知らない場所で書き換えられるかもしれないというのはそれなりに危険なことなので、あえて面倒な構文になっています。

上記の例でも、参照引数を参照戻り値で返して、それをさらに参照ローカル変数で受け取るものもあります。 ここだけ抜き出すと以下のような感じです。

static void Main()
{
    var x = 10;
    ref var y = ref Ref(ref x);
    y = 0; // y は巡り巡って x を参照。x も 0 に

    Console.WriteLine($"{x}, {y}"); // 0, 0
}

static ref int Ref(ref int x) => ref x;

これで、下図のような状態になっています。これくらい単純な例でも、結局どこが書き換わるのかそこそこわかりづらくなるので注意が必要です。

参照引数を参照戻り値で返して、参照ローカル変数で受ける

参照戻り値で返せるもの

参照渡しをするときに気を付けないといけないのは、参照をたどった先の大元が消えしまっている可能性があることです。 C#の参照渡しでは、そうならないように、参照できるものを制限しています。

(他のプログラミング言語では、参照渡しが必ずしも安全でなかったり(不正なメモリ操作につながる)、逆に参照渡しの機能を提供していないものもあります。 .NETも、ILのレベルでは安全でない参照もできたりします。 C#は、コンパイラーが厳しめにチェックして、安全でない参照ができないようにしています。)

  • 通常のメソッドの参照引数は常に安全です
    • なので、これはC# 1.0の頃から認められています
    • 非同期メソッドイテレーターでは安全性を保障できないので、これらのタイプのメソッドでは参照引数を認めていません
  • 参照戻り値の場合、返しても安全かどうかを判定して、安全でない可能性があるならコンパイル エラーになります
    • 参照引数は参照戻り値で返せます
    • 通常の引数やローカル変数は返せません
    • 参照ローカル変数などを挟んで、多段に参照している場合、コードをたどって大元が安全かどうかまで調べます

例えば、以下のようなコードは、強調表示しているところがコンパイル エラーになります。

// 参照引数は参照戻り値で返せる
private static ref int Success1(ref int x) => ref x;

// 値渡しの引数はダメ
private static ref int Error1(int x) => ref x;

// ローカル変数はダメ
private static ref int Error2()
{
    var x = int.Parse(Console.ReadLine());
    return ref x;
}

// 多段の場合も元をたどって出所を調べてくれる
private static ref int Success1(ref int x, ref int y)
{
    ref int r1 = ref x;
    ref int r2 = ref y;
    ref int r3 = ref Max(ref r1, ref r2);

    // r3 は出所をたどると引数の x か y の参照
    // x も y も参照引数なので大丈夫
    return ref r3;
}

private static ref int Error1(ref int x, int y)
{
    ref int r1 = ref x;
    ref int r2 = ref y;
    ref int r3 = ref Max(ref r1, ref r2);

    // y が値渡しなのでダメ
    return ref r3;
}

private static ref int Error2(ref int x)
{
    var y = int.Parse(Console.ReadLine());
    ref int r1 = ref x;
    ref int r2 = ref y;
    ref int r3 = ref Max(ref r1, ref r2);

    // y がローカル変数なのでダメ
    return ref r3;
}

C# 7では、コンパイラーが賢くなって、この「大元をたどって調べる」という仕事ができるようになったので、参照戻り値や参照ローカル変数が使えるようになったということです。

ただし、C# 7でも、あくまでメソッド内で完結できる範囲でしか「たどって調べる」ということができません。 例えば、以下のようなコードはコンパイルできません。

// あまり意味のないメソッドなものの…
// 第1引数しか参照しない
static ref int X(ref int x, ref int y) => ref x;

static ref int Y(ref int x)
{
    int local = 1;

    // X の中身まで追えば、実のところ local は参照していないものの、そこまでは追えない
    // あくまで、「local を参照で渡してしまった以上、X の戻り値に local が含まれている可能性あり」と判定する
    // 結果的に、このコードはコンパイル エラーになる
    return ref X(ref x, ref local);
}

このコードは、もし仮に、XYの中で展開してしまえば、ローカル変数localの参照を戻り値として返さないということがわかるんですが、 コンパイラーはそこまでは追ってくれません。 (こういうXの中身次第で変わる挙動を認めてしまうと、Xの変更の影響がX利用側(この例の場合Y)に及び過ぎるため問題があります。 「追ってくれない」というより、意図的に「追わない」という面もあります。)

構造体のフィールドの参照(戻り値にできない)

C# コンパイラーが行う「参照戻り値に返して安全かどうか」の判定で、 1つ注意が必要な点があります。 構造体の場合、フィールドの参照を返せません。 例えば、以下のコードはコンパイル エラーになります。

struct Struct
{
    int _v;
    public ref int Value => ref _v; // ダメ
}

class Class
{
    int _v;
    public ref int Value => ref _v; // クラスの場合はOK
}

ちなみに、エラーになるのは構造体のフィールドの参照を直接返している場合だけです。 以下のように、フィールドを介していても、参照型の中の参照を返すことはできます。

struct ArrayOffset<T>
{
    T[] _array;
    int _offset;
    public ArrayOffset(T[] array, int offset) => (_array, _offset) = (array, offset);

    // フィールドの参照を直接返しているわけではなく、
    // 配列 T[] (参照型)の中の参照を返しているのでOK
    public ref T this[int i] => ref _array[i + _offset];
}

構造体内では、フィールドの読み書きのために、実はthisが参照扱いになっています。 そのせいで、「大元をたどって参照を返せるかどうかを調べる」という作業が難しく、 結局「構造体はフィールドの参照(thisが絡む参照)を返せない」という制限を掛けたそうです。

この仕様は、少し詳しい人であれば何か釈然としないものがあるかもしれません。 例えば以下のように、拡張メソッド的に(静的メソッドで)書けば似たようなことが実現できます。

struct Struct
{
    internal int _v;

    // ↓これはダメ(なのでコメントアウト)
    // public ref int V() => ref _v;
}

static class Extensions
{
    // Struct.V() と、実のところやっていることは同じ
    // (構造体内では、this は参照扱いになっている)
    // Struct.V() ではダメなのに、同じことを静的メソッドでやるとできる
    public static ref int V(ref Struct @this) => ref @this._v;
}

実のところ、「thisが参照扱いになっている」というのはこのコードと似たような状態で、 このコードが許されるのに通常のメソッドでは許されないというのは少し不思議です。

正確には、「以下の2つのうちどちらか片方を選ぶ必要があり、前者を選んだ」ということだそうです。

  • 構造体はフィールドの参照を返せない(C# 7で選んだ仕様)
  • 構造体の関数メンバーを呼ぶ際には、常にthis参照が引数として渡っている前提で安全性を調べる(選ばなかった仕様)

要するに、以下の例の、Okメソッドのようなものを認めるためには前者の仕様が必要です。

struct ArrayOffset<T>
{
    // 拡張メソッドから参照するために internal
    internal T[] _array;
    internal int _offset;
    public ArrayOffset(T[] array, int offset) => (_array, _offset) = (array, offset);

    // OK
    public ref T this[int i] => ref _array[i + _offset];
}

static class Extensions
{
    // ArrayOffset のインデクサーと同じことを静的メソッドで書く
    public static ref T Get<T>(ref ArrayOffset<T> @this, int i) => ref @this._array[i + @this._offset];
}

class Program
{
    static ref int Ok()
    {
        // a はローカル変数なので、こいつが絡む参照は戻り値にしてはいけない
        var a = new ArrayOffset<int>(new[] { 1, 2, 3 }, 1);

        // 構造体の関数メンバーはフィールドの参照を返さないという仕様なので、
        // この ref には a 絡みの参照は絶対にない
        return ref a[1];
    }

    static ref int Ng()
    {
        // 同上、a 絡みの参照は返せない
        var a = new ArrayOffset<int>(new[] { 1, 2, 3 }, 1);

        // a が参照引数にわたっている以上、Get の戻り値には a 絡みの参照が含まれる可能性がある
        // コンパイル エラーになる
        return ref Extensions.Get(ref a, 1);
    }
}

あと、以下のように、ジェネリクス絡みの問題を避けるためにもこの仕様を選ぶ必要があったそうです。

using System;

interface IReference
{
    ref int Value { get; }
}

class ReferenceClass : IReference
{
    int _value;
    public ref int Value => ref _value;
}

struct ReferenceStruct : IReference
{
    int _value;
    public ref int Value => ref _value; // 認められていない。もし認めると…
}

class Program
{
    static void Main()
    {
        ref var r = ref X<ReferenceClass>();
        r = 1;
        Console.WriteLine(1);
    }

    static ref int X<T>()
        where T : IReference, new()
    {
        var x = new T();
        return ref x.Value; // T が構造体だと、返してはいけないはずの参照が返る
    }
}

更新履歴

ブログ