エフアンダーバー

個人開発の記録

【C#】 readonly と struct と

ふと、readonlyな構造体に対して状態を変更するメソッドを呼び出したらどうなるんだろうかと疑問に思ったので試してみました。

また調べているときにみつけた構造体のreadonlyなフィールドに関する話についても記しておきます。

readonlyな構造体メンバ

検証コードと出力

とりあえず検証用にクラスを定義。

using System;

public class ReadOnlyStruct
{
    public struct Counter
    {
        public int _value;

        public int Value => _value;

        public int Increment() => ++_value;
    }

    private Counter counter;

    private readonly Counter readonly_counter;

    public void Print()
    {
        Console.WriteLine($"--- counter ---");
        Console.WriteLine($"Increment(): {counter.Increment()}");
        Console.WriteLine($"Value: {counter.Value}");
        Console.WriteLine($"_value: {counter._value}");
        Console.WriteLine();

        Console.WriteLine($"--- readonly_counter ---");
        Console.WriteLine($"Increment(): {readonly_counter.Increment()}");
        Console.WriteLine($"Value: {readonly_counter.Value}");
        Console.WriteLine($"_value: {readonly_counter._value}");
        Console.WriteLine();
    }
}

適当にインスタンスをつくって、Printメソッドを実行すると以下の出力が得られます。

--- counter ---
Increment(): 1
Value: 1
_value: 1

--- readonly_counter ---
Increment(): 1
Value: 0
_value: 0

何故か、readonlyな方はインクリメントした値が保存されていません。

ILコードを確認

このままでは何が起きているのかよくわからないのでILのコードで確認してみます。

とりあえず、そのままだと読みづらいのでPrintメソッドのC#コードを簡素化。

public void Print()
{
    int v;

    counter.Increment();
    v = counter.Value;
    v = counter._value;

    readonly_counter.Increment();
    v = readonly_counter.Value;
    v = readonly_counter._value;
}

最適化を有効にしてコンパイルした結果のPrintメソッドのILコードは以下の通り(クラス名が変わってますが特に関係ないのでスルーの方向で)。

.method public hidebysig instance void  Print() cil managed
{
  // コード サイズ       69 (0x45)
  .maxstack  1
  .locals init ([0] valuetype ReadOnlyStructSimple/Counter V_0)
  IL_0000:  ldarg.0
  IL_0001:  ldflda     valuetype ReadOnlyStructSimple/Counter ReadOnlyStructSimple::counter
  IL_0006:  call       instance int32 ReadOnlyStructSimple/Counter::Increment()
  IL_000b:  pop
  IL_000c:  ldarg.0
  IL_000d:  ldflda     valuetype ReadOnlyStructSimple/Counter ReadOnlyStructSimple::counter
  IL_0012:  call       instance int32 ReadOnlyStructSimple/Counter::get_Value()
  IL_0017:  pop
  IL_0018:  ldarg.0
  IL_0019:  ldfld      valuetype ReadOnlyStructSimple/Counter ReadOnlyStructSimple::counter
  IL_001e:  pop
  IL_001f:  ldarg.0
  IL_0020:  ldfld      valuetype ReadOnlyStructSimple/Counter ReadOnlyStructSimple::readonly_counter
  IL_0025:  stloc.0
  IL_0026:  ldloca.s   V_0
  IL_0028:  call       instance int32 ReadOnlyStructSimple/Counter::Increment()
  IL_002d:  pop
  IL_002e:  ldarg.0
  IL_002f:  ldfld      valuetype ReadOnlyStructSimple/Counter ReadOnlyStructSimple::readonly_counter
  IL_0034:  stloc.0
  IL_0035:  ldloca.s   V_0
  IL_0037:  call       instance int32 ReadOnlyStructSimple/Counter::get_Value()
  IL_003c:  pop
  IL_003d:  ldarg.0
  IL_003e:  ldfld      valuetype ReadOnlyStructSimple/Counter ReadOnlyStructSimple::readonly_counter
  IL_0043:  pop
  IL_0044:  ret
} // end of method ReadOnlyStructSimple::Print

readonlyでない方のIncrementメソッド呼び出し部分は以下の通りで、C#の記述にシンプルに対応しています。

  IL_0000:  ldarg.0
  IL_0001:  ldflda     valuetype ReadOnlyStructSimple/Counter ReadOnlyStructSimple::counter
  IL_0006:  call       instance int32 ReadOnlyStructSimple/Counter::Increment()
  IL_000b:  pop

一方、readonlyな方のIncrementメソッド呼び出し部分は以下のようになっています。

  IL_001f:  ldarg.0
  IL_0020:  ldfld      valuetype ReadOnlyStructSimple/Counter ReadOnlyStructSimple::readonly_counter
  IL_0025:  stloc.0
  IL_0026:  ldloca.s   V_0
  IL_0028:  call       instance int32 ReadOnlyStructSimple/Counter::Increment()
  IL_002d:  pop

これをC#に再翻訳するとこんな感じ。

Counter V_0 = this.readonly_counter;
V_0.Increment();

つまり、値を一度複製してから複製した値に対してメソッド呼び出しを行っています。 このような処理へと変換することでreadonlyなフィールドの値が書き換わることを防いでいるようです。

Valueプロパティ参照(getter呼び出し)の場合も同様な処理が行われることから、 getterであるかどうかや、内部で状態を変更しているかどうかとは無関係に、 メソッド呼び出し時に値が複製されるようです。 一方で、状態が変更されないことが自明であるフィールド参照に関してはreadonlyであるかどうかに関わりなく、値は複製されないようです。

ちなみにstaticな場合でも同様の処理がなされます。

readonlyな構造体 "の" メンバ

"readonly"と"struct"で検索していたところ、 構造体内にreadonlyなフィールドを定義した場合、 直感に反した書き換えが起こることがあるよ、という記事をみかけたので自分なりに記しておきます。

ということで、まずはコード。

using System;

public class ReadOnlyFieldInStruct
{
    public struct Struct
    {
        public readonly int _value;

        public Struct(int value) { this._value = value; }

        public void SetValue(int value) => this = new Struct(value);
    }

    public unsafe void Print()
    {
        Struct s = new Struct(10);

        Console.WriteLine($"{s._value} ({(IntPtr)(&s)})");

        s = new Struct(20);

        Console.WriteLine($"{s._value} ({(IntPtr)(&s)})");

        s.SetValue(30);

        Console.WriteLine($"{s._value} ({(IntPtr)(&s)})");
    }
}

そして、出力。

10 (103345560)
20 (103345560)
30 (103345560)

readonlyなフィールドが書き換わっているような感じがしないでしょうか?
構造体はいくらそのフィールドをreadonlyにしても、 構造体ごと置き換えることができてしまうため、 過信は禁物のようです。

また、他にreadonlyなフィールドが書き換えられるケースとしてStructLayoutを利用した場合があるそうです。

using System;
using System.Runtime.InteropServices;

public class ReadOnlyFieldInUnion
{
    [StructLayout(LayoutKind.Explicit)]
    public struct Struct
    {
        [FieldOffset(0)]
        public readonly int _value;

        [FieldOffset(0)]
        public int _mutable;

        public Struct(int value) { this._value = this._mutable = value; }
    }

    public unsafe void Print()
    {
        Struct s = new Struct(10);

        Console.WriteLine($"{s._value} ({(int)&s})");

        s._mutable = 20;

        Console.WriteLine($"{s._value} ({(int)&s})");
    }
}

まあ、こんなもの書く方が悪いと思いますが・・・。

ちなみにこの方法の場合はクラスでもできます。

まとめ

  • readonlyな構造体に対するメソッド呼び出し・プロパティ参照は複製された値に対して行われる。
  • 構造体におけるフィールドのreadonlyを過信してはいけない。

おわりに

カテゴリーを設定しようとして初めて気づいたのですが、本ブログ初のC#に関する記事だったみたいです。 最初にC#とUnityに関するブログみたいなことを書いたような気がするのですが、 C#関連は他のサイトの情報が充実しすぎていて書くことがなかったんですよね・・・。 本記事も他のサイトの内容をまとめ直したような内容ですが、 今後はそんな感じでC#の記事も書いていくかもしれません。



執筆時のC#のバージョン: C# 6 (.NET Framework 4.6.1)