wiprog

C#, .NET, Scala ... について勉強したことのメモ

C# の ref まとめ

C#7.2 までの参照渡し関係のまとめです。

C# 7 系で参照渡しの扱いが強化されて種類も増えました。 上手につかうとサイズの大きい値型のコピーを避けられるのでまとめてみました。 動作をきちんと理解するために C# to C# の変換をしたコードや IL をのせています。

予備知識 - defensive copy, readonly struct

defensive copy - 防衛的なコピー

readonly 指定された値型は値が変化しないことを保証するために、コンパイラが値を防衛的にコピーしている場合がある。

defensive copy が発生するのは下記の場合に、後述する readonly struct ではないふつうの struct を使用しているとき

  • readonly フィールドとして構造体を持っている場合
  • readonly な参照渡しで構造体を返すとき

こんな構造体があった場合

public struct Point
{
    public double X;
    public double Y;
    
    // フィールドを書き換えるメソッド
    public void Set(double x, double y)
    {
        X = x;
        Y = y;
    }
}
readonly フィールドでない場合

たとえば、このようなクラスでは防御的コピーは発生しない(readonly でないので、構造体のフィールドを書き換えることに制限はない)

public static class MyClass
{
    // readonly でないフィールド
    private static Point s_origin = default;
    
    public static void Sample()
    {
        // フィールド書き換え
        s_origin.Set(1, 1);
    
        // 実際に書き換わっている
        Console.WriteLine($"X: {s_origin.X}, Y: {s_origin.Y}");
    }
}

IL を見ると、こんな感じ

MyClass.Sample:
IL_0000:  nop 
   
; s_origin の「アドレス」をスタックに push     
IL_0001:  ldsflda     MyClass.s_origin

; set メソッドの呼び出し
IL_0006:  ldc.r8      00 00 00 00 00 00 F0 3F 
IL_000F:  ldc.r8      00 00 00 00 00 00 F0 3F 
IL_0018:  call        Point.Set
IL_001D:  nop         
readonly フィールドの場合
public static class MyClass
{
    // 原点の座標を何度も使うので readonly フィールドにもつ
    private static readonly Point s_origin = default;
    
    public static void Sample()
    {
        // 構造体のフィールドは readonly を受け継ぐので書き換えできない
        // s_origin.X = 1;
        
        // フィールドを書き換えるかもしれないメソッドは呼べるように見える
        s_origin.Set(1, 1);
        
        // 実際には書き換わっていない
        Console.WriteLine($"X: {s_origin.X}, Y: {s_origin.Y}");
    }
}

s_origin.Set() メソッド読んでもフィールドが書き換わっていないが、 これは(フィールドを変更しているかもしれない)メソッド呼び出しを許容しつつ、readonly であることを保証するために、いったん s_origin をコピーしてから、そのコピーに対してメソッドを呼ぶため。

「メソッドの中でなにも書き換えていない」ことは呼び出し側から知るすべがないので、実際にコピーが必要かどうかにかかわらず常にコピーが発生する。 readonly なフィールドや readonly な参照 (in 引数) を使用するときは注意が必要。

IL をみると、ローカル変数に値をコピーしてからメソッドを読んでいる

MyClass.Sample:
IL_0000:  nop      
; 値をローカル変数にコピー   
IL_0001:  ldsfld     MyClass.s_origin
IL_0006:  stloc.0     

; ローカル変数のアドレスをスタックに push
IL_0007:  ldloca.s    00 

; ローカル変数にたいして Set() を呼ぶ
IL_0009:  ldc.r8      00 00 00 00 00 00 F0 3F 
IL_0012:  ldc.r8      00 00 00 00 00 00 F0 3F 
IL_001B:  call        Point.Set
IL_0020:  nop         

readonly struct

防衛的なコピーは、下記のようにすべてのフィールドを readonly にしていても発生する。

struct NoReadOnlyPoint
{
    // X, Y は readonly
    public readonly double X;
    public readonly double Y;

    // フィールドや this の書き換えを行うメソッドは持たないが、
    // 呼び出し側からはフィールドの書き換えを行っていないことを知るすべがないため、
    // readonly な NoReadOnlyPoint のインスタンスに対して Hoge() を呼ぶと、常に defensive copy が発生する
    public void Hoge()
    {
        // ...
    }
}

下記のように readonly struct とすることによって、フィールドの書き換えが起こらないことを保証でき、 defensive copy を避けられる

readonly struct ReadOnlyPoint
{
    // readonly なフィールドのみ許容される
    // get-only プロパティも、結局 readonly フィールドを生成するので許容
    public readonly double X;
    public readonly double Y;

    // フィールドや this の書き換えを行うメソッドは持てないので、
    // 呼び出し側で defensive copy の必要がない
    public void Hoge()
    {
        // ...
    }
}

呼び出し例

public static class MyClass
{
    // 原点の座標を何度も使うので readonly フィールドにもつ
    private static readonly NoReadOnlyPoint s_noReadonlyOrigin = default;
    private static readonly ReadOnlyPoint s_readonlyOrigin = default;

    public static void Sample()
    {
        // 防衛的なコピーが発生
        s_noReadonlyOrigin.Hoge();
        
        // 防衛的なコピーが発生しない
        s_readonlyOrigin.Hoge();
    }
}

IL

MyClass.Sample:
IL_0000:  nop         

// readonly struct でない場合はコピーが発生
IL_0001:  ldsfld      MyClass.s_noReadonlyOrigin
IL_0006:  stloc.0     
IL_0007:  ldloca.s    00 
IL_0009:  call        NoReadOnlyPoint.Hoge
IL_000E:  nop         

// readonly struct の場合はコピーが発生しない
IL_000F:  ldsflda     MyClass.s_readonlyOrigin
IL_0014:  call        ReadOnlyPoint.Hoge
IL_0019:  nop         
IL_001A:  ret    

生成される C# コード

readonly struct にした構造体には、コンパイラが [IsReadOnly] 属性をつける

[IsReadOnly]
private struct ReadOnlyPoint
{
    public readonly double X;
    public readonly double Y;
    public void Hoge()
    {
    }
}

この属性によって readonly struct かどうかの判定をおこなうようだ

参照渡しの種類一覧

種類 使う場所 書き換え C#のバージョン
ref parameters 引数 o 1.0?
out parameters 引数 o 1.0?
in parameters 引数 x 7.2
ref returns 戻り値 o 7.0
ref returns (readonly) 戻り値 x 7.2
ref locals ローカル変数 o 7.0
ref locals (readonly) ローカル変数 x 7.2

参照引数

ref parameters

読み書き両方できる参照渡しの引数。 渡す前にかならず初期化が必要

x と y を交換するメソッドの例:

void Main()
{
    // 必ず初期化しておく
    int x = 1;
    int y = 2;

    Swap(ref x, ref y); // x: 2, y: 1
}

public static void Swap<T>(ref T x, ref T y)
{
    T tmp = x;
    x = y;
    y = tmp;
}

out parameters

出力用の参照引数。 渡す前に初期化が不要、 C# 7 では out-var で変数の宣言と同時に受け取れる

void Main()
{
    var list = new List<int>() { 1, 2, 3, 4 };
    
    // 出力引数のうけとり
    // C#7 からは 受けとりと同時に変数の宣言が可能
    if (TryGetAt(list, 1, out var elem))
    {
        Console.WriteLine(elem);
    }
    else
    {
        Console.WriteLine("not found");
    }
}

// IList<T> の指定したインデックスの値を返す
private static bool TryGetAt<T>(IList<T> list, int index, out T elem)
{
    if (list.Count > index)
    {
        // out 引数に結果を入れる
        elem = list[index];
        return true;
    }
    else
    {
        // out 引数は必ず初期化しなければならない
        elem = default;
        return false;
    }
}

in parameters

読み取り専用の参照渡し引数。 値渡しで発生する構造体のコピーを避けつつ、 ref で参照渡ししたときの書き換えのリスクもなくす。 ただし、予備知識に書いたとおり、 readonly struct でない値型を受け取ったときに、プロパティやメソッドの呼び出しを行うと無条件にコピーが発生するので注意。

例:

using System;

public class Program
{
    static void F(in int x)
    {
        // 読み取り可能
        Console.WriteLine(x);

        // 書き換えようとするとコンパイル エラー
        x = 2;
    }

    // 補足: in 引数はオプションにもできる
    static void G(in int x = 1)
    {
    }

    static void Main()
    {
        int x = 1;

        // ref 引数と違って修飾不要
        F(x);

        // 明示的に in と付けてもいい
        F(in x);

        // リテラルに対しても呼べる
        F(10);

        // 右辺値(式の計算結果)に対しても呼べる
        int y = 2;
        F(x + y);

        // in のオプション引数を省略した呼び出し
        G();
    }
}

コンパイル後は結局は ref に変換される

using System;
using System.Runtime.CompilerServices;
public class Program
{
    // [IsReadOnly] がついた ref になる
    private static void F([IsReadOnly] ref int x)
    {
        // 読み取り可能
        Console.WriteLine(x);
    }

    private static void G([IsReadOnly] ref int x = 1)
    {
    }

    private static void Main()
    {
        // in で修飾してもしなくても結局ただの ref になる
        int num = 1;
        Program.F(ref num);
        Program.F(ref num);

        // リテラルに対しての呼び出しは ローカル変数が作られて、その参照が渡される
        int num2 = 10;
        Program.F(ref num2);

        // 式の計算結果に対して呼ぶ場合は先に式の計算結果をローカル変数に入れておいて、その参照が渡される
        int num3 = 2;
        num2 = num + num3;
        Program.F(ref num2);

        // オプション引数を省略した場合は、デフォルト値の参照が渡される
        num2 = 1;
        Program.G(ref num2);
    }
}

(サンプルコードはこちらから引用させていただきました)

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

C# 7 から、戻り値とローカル変数にも参照渡しが使えるようになった。 C# 7.2 からは readonly な参照を返すこともできる

readonly でない ref returns では、readonly なフィールドを返すことはできない(書き換えられるため) readonly な ref returns では、 readonly なフィールドを返せる。

using System;
public class Program
{
    public void Sample()
    {
        var user = new User("hanako");
        
        // 書き換えできる参照
        ref var mutableId = ref user.MutableId;
        mutableId = Guid.NewGuid();
        
        // 書き換えできない参照
        ref readonly var immutableId = ref user.ImmutableId;
        // immutableId = Guid.NewGuid(); // 代入できない
        
        // これは値渡し
        var idValue = user.Id;
        var idValue2 = user.MutableId;
        var idValue3 = user.ImmutableId;
    }
}

public class User
{
    private Guid _id;
    public string Name { get; }
    
    // これは値渡し
    public Guid Id => _id;
    
    // 書き換えできる参照を返す
    public ref Guid MutableId => ref _id;
    
    // readonly な参照を返す
    public ref readonly Guid ImmutableId => ref _id;
    
    public User(string name)
    {
        _id = Guid.NewGuid();
        Name = name;
    }
}

コンパイルすると、 readonly でも readonly でなくてもどちらも同じコードになる (IsReadOnlyAttribute がつく)。 ポインタをつかった unsafe コードが生成される。

using System;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Security;
using System.Security.Permissions;

[assembly: AssemblyVersion("0.0.0.0")]
[assembly: Debuggable(DebuggableAttribute.DebuggingModes.Default | DebuggableAttribute.DebuggingModes.DisableOptimizations | DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints | DebuggableAttribute.DebuggingModes.EnableEditAndContinue)]
[assembly: CompilationRelaxations(8)]
[assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)]
[assembly: SecurityPermission(SecurityAction.RequestMinimum, SkipVerification = true)]
[module: UnverifiableCode]
public class Program
{
    public unsafe void Sample()
    {
        User user = new User("hanako");
        Guid* mutableId = user.MutableId;
        *mutableId = Guid.NewGuid();
        Guid* immutableId = user.ImmutableId;
        Guid id = user.Id;
        Guid guid = *user.MutableId;
        Guid guid2 = *user.ImmutableId;
    }
}
public class User
{
    private Guid _id;

    [DebuggerBrowsable(DebuggerBrowsableState.Never), CompilerGenerated]
    private readonly string <Name>k__BackingField;

    public string Name
    {
        [CompilerGenerated]
        get
        {
            return this.<Name>k__BackingField;
        }
    }

    public Guid Id
    {
        get
        {
            return this._id;
        }
    }

    public unsafe Guid* MutableId
    {
        get
        {
            return ref this._id;
        }
    }

    [IsReadOnly]
    public unsafe Guid* ImmutableId
    {
        [return: IsReadOnly]
        get
        {
            return ref this._id;
        }
    }

    public User(string name)
    {
        this._id = Guid.NewGuid();
        this.<Name>k__BackingField = name;
    }
}

参考

関連: Span<T>