C# struct 引数

2020/8/19

はじめに

C# 言語仕様としても、Unity(ゲームエンジンの方)等の C# を用いた開発環境においても、struct の存在感は増しています。が、class に慣れてしまっていると、癖があって、変なパフォーマンスボトルネックを知らずにつくってしまいがちなのも事実です。ここでは引数にフォーカスして考察します。

動作検証

値渡し vs 参照渡し

コード

参照渡しは ref に加えて in も含めますが、out は割愛します。

using System;

class Program
{
    struct S
    {
        public int I;
        public void SetI(int i)
        {
            Console.WriteLine($"{nameof(SetI)}でフィールド変更前: {I}");
            I = i;
            Console.WriteLine($"{nameof(SetI)}でフィールド変更後: {I}");
        }
    }

    static void _arg()
    {
        var s = new S
        {
            I = 1,
        };
        Console.WriteLine($"[値渡し][呼び出し元]呼び出し前: {s.I}");
        _setField(s);
        Console.WriteLine($"[値渡し][呼び出し元]フィールド変更呼び出し後: {s.I}");
        _assign(s);
        Console.WriteLine($"[値渡し][呼び出し元]代入呼び出し後: {s.I}");
    }
    static void _setField(S s)
    {
        s.I = 10;
        Console.WriteLine($"[値渡し][呼び出し先]フィールド変更後: {s.I}");
    }
    static void _assign(S s)
    {
        s = new S
        {
            I = 100,
        };
        Console.WriteLine($"[値渡し][呼び出し先]代入後: {s.I}");
    }

    static void _argRef()
    {
        var s = new S
        {
            I = 1,
        };
        Console.WriteLine($"[参照渡し ref][呼び出し元]呼び出し前: {s.I}");
        _setFieldRef(ref s);
        Console.WriteLine($"[参照渡し ref][呼び出し元]フィールド変更呼び出し後: {s.I}");
        _assignRef(ref s);
        Console.WriteLine($"[参照渡し ref][呼び出し元]代入呼び出し後: {s.I}");
    }
    static void _setFieldRef(ref S s)
    {
        s.I = 10;
        Console.WriteLine($"[参照渡し ref][呼び出し先]フィールド変更後: {s.I}");
    }
    static void _assignRef(ref S s)
    {
        s = new S
        {
            I = 100,
        };
        Console.WriteLine($"[参照渡し ref][呼び出し先]代入後: {s.I}");
    }

    static void _argIn()
    {
        var s = new S
        {
            I = 1,
        };
        Console.WriteLine($"[参照渡し in][呼び出し元]呼び出し前: {s.I}");
        _setFieldIn(s);
        Console.WriteLine($"[参照渡し in][呼び出し元]フィールド変更呼び出し後: {s.I}");
        _assignIn(s);
        Console.WriteLine($"[参照渡し in][呼び出し元]代入呼び出し後: {s.I}");
    }
    static void _setFieldIn(in S s)
    {
        // 直接フィールドにセットはコンパイルエラーなのでメソッドを通す。
//      s.I = 10;
        s.SetI(10);
        Console.WriteLine($"[参照渡し in][呼び出し先]フィールド変更後: {s.I}");
    }
    static void _assignIn(in S s)
    {
        // 代入はコンパイルエラー。
//      s = new S
//      {
//          I = 100,
//      };
//      Console.WriteLine($"[参照渡し in][呼び出し先]代入後: {s.I}");
    }

    static void Main(string[] args)
    {
        _arg();
        _argRef();
        _argIn();
    }
}

結果

[値渡し][呼び出し元]呼び出し前: 1
[値渡し][呼び出し先]フィールド変更後: 10
[値渡し][呼び出し元]フィールド変更呼び出し後: 1
[値渡し][呼び出し先]代入後: 100
[値渡し][呼び出し元]代入呼び出し後: 1
[参照渡し ref][呼び出し元]呼び出し前: 1
[参照渡し ref][呼び出し先]フィールド変更後: 10
[参照渡し ref][呼び出し元]フィールド変更呼び出し後: 10
[参照渡し ref][呼び出し先]代入後: 100
[参照渡し ref][呼び出し元]代入呼び出し後: 100
[参照渡し in][呼び出し元]呼び出し前: 1
SetIでフィールド変更前: 1
SetIでフィールド変更後: 10
[参照渡し in][呼び出し先]フィールド変更後: 1
[参照渡し in][呼び出し元]フィールド変更呼び出し後: 1
[参照渡し in][呼び出し元]代入呼び出し後: 1

まあ、そうですよね、という結果ではあるんですが、in だけ少々怪しい挙動をします。 フィールド変更も代入もコンパイルエラーになるし、フィールドの値が常に呼び出し元と同じ値であるのはもちろん良いんですが、メソッドを通すとフィールド変更処理は走らせること自体は可能なようで、処理的には結局コピー(使い捨て)が作られているようですね。

IDisposable を実装した struct 引数問題

あと、class 感覚で気軽に IDisposable な struct を ref で参照渡ししようとするとコンパイルエラーや warn メッセージに遭遇したりします。

    struct DisposableStruct : IDisposable
    {
        public void Dispose()
        {
        }
    }

    static void _argDisposableRef()
    {
        // 下記コンパイルエラー。
        // Cannot use 'disposable' as a ref or out value because it is a 'using variable'
//      using(var disposable = new DisposableStruct())
//      {
//          _disposableFuncRef(ref disposable);
//      }
        var disposable = new DisposableStruct();
        using(disposable)
        {
            // 下記 warn メッセージ。
            // Possibly incorrect assignment to local 'disposable' which is the argument to a using or lock statement. The Dispose call or unlocking will happen on the original value of the local.
            _funcDisposableRef(ref disposable);
        }
    }
    static void _funcDisposableRef(ref DisposableStruct disposable)
    {
    }

前述のコピーを作らない保証があれば in で渡しましょうで綺麗に解決なんですが、その保証なんて無いことの方が多いでしょうし、かと言って割り切って値渡しをしてしまうと、呼び出し元の(using により間接的に呼ばれる)Dispose 処理時に不整合が起こらない保証も無いでしょうし・・・。

もはや上記の warn メッセージを甘んじて受け入れるしか無い気もしています。 (using を使わずに手動!というのも本質的じゃ無いでしょうしね。)