滅入るんるん

何か書きます

【C#】構造体の防衛的コピーについて

以前すこし話に触れた構造体の防衛的コピーについてCIL的に見ていこうという記事です。

以前触れた記事: [C#]Big Size Structが値コピーでつらいならin引数で値コピーしなければいいじゃない!! < それ本当?

防衛的コピーとは

構造体の値がreadonlyではない場合に、その構造体を保持している変数にreadonly制約があるとその変数の構造体のメソッドを呼び出すと構造体の値が変わっていないことが保証されないので、防衛的に構造体の値をコピーしてreadonly制約な変数の値が変わらないようにするというコンパイラーの親切な行いのことを言います。

ちょっとわかりにくいので例を:

struct Struct 
{
    public void Method(){}
}

class Class
{
    private readonly Struct readOnlyStruct = default;
    private Struct writableStruct = default;
    
    public void Method()
    {
        readOnlyStruct.Method();
        writableStruct.Method();
    }
}

Struct#Methodを呼んでいるClass#Methodに着目してみましょう。

writableStruct.Method();ではインスタンス変数writableStructStruct#Methodが呼び出されています。writableStructは値が変わることがあるインスタンス変数ですのでここでの呼び出し時には防衛的コピーは発生しません。

しかし、readOnlyStruct.Method();ではreadonlyなインスタンス変数readOnlyStructStruct#Methodが呼び出されています。readOnlyStructは値が変わってほしくありませんが、Struct#Methodの呼び出しによって値が変わらないという保証がありません。そこで、防衛的に値をコピーして、readOnlyStructの値が変わらないように(コンパイラーが)しているのです。

防衛的コピーの例

readonlyインスタンス変数の場合

struct Struct 
{
    public void Method(){}
}

class Class
{
    private readonly Struct readOnlyStruct = default;
    private Struct writableStruct = default;
    
    public void Method()
    {
        readOnlyStruct.Method();
        writableStruct.Method();
    }
}

先ほどの全く同一なC#コードをSharpLabでCILにデコンパイルしてみましょう。

.class private auto ansi '<Module>'
{
} // end of class <Module>

.class private sequential ansi sealed beforefieldinit Struct
    extends [mscorlib]System.ValueType
{
    .pack 0
    .size 1

    // Methods
    .method public hidebysig 
        instance void Method () cil managed 
    {
        // Method begins at RVA 0x2050
        // Code size 1 (0x1)
        .maxstack 8

        IL_0000: ret
    } // end of method Struct::Method

} // end of class Struct

.class private auto ansi beforefieldinit Class
    extends [mscorlib]System.Object
{
    // Fields
    .field private initonly valuetype Struct readOnlyStruct
    .field private valuetype Struct writableStruct

    // Methods
    .method public hidebysig 
        instance void Method () cil managed 
    {
        // Method begins at RVA 0x2054
        // Code size 26 (0x1a)
        .maxstack 1
        .locals init (
            [0] valuetype Struct
        )

        IL_0000: ldarg.0
        IL_0001: ldfld valuetype Struct Class::readOnlyStruct
        IL_0006: stloc.0
        IL_0007: ldloca.s 0
        IL_0009: call instance void Struct::Method()
        IL_000e: ldarg.0
        IL_000f: ldflda valuetype Struct Class::writableStruct
        IL_0014: call instance void Struct::Method()
        IL_0019: ret
    } // end of method Class::Method

    .method public hidebysig specialname rtspecialname 
        instance void .ctor () cil managed 
    {
        // Method begins at RVA 0x207a
        // Code size 7 (0x7)
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: call instance void [mscorlib]System.Object::.ctor()
        IL_0006: ret
    } // end of method Class::.ctor

} // end of class Class

非常に長いですね。
肝心なのはClassクラスのMethodメソッドです。

    .method public hidebysig 
        instance void Method () cil managed 
    {
        // Method begins at RVA 0x2054
        // Code size 26 (0x1a)
        .maxstack 1
        .locals init (
            [0] valuetype Struct
        )

        IL_0000: ldarg.0
        IL_0001: ldfld valuetype Struct Class::readOnlyStruct
        IL_0006: stloc.0
        IL_0007: ldloca.s 0
        IL_0009: call instance void Struct::Method()
        IL_000e: ldarg.0
        IL_000f: ldflda valuetype Struct Class::writableStruct
        IL_0014: call instance void Struct::Method()
        IL_0019: ret
    } // end of method Class::Method

上のほうから順に説明していくと.locals initによってメソッド内のローカル変数を宣言しています。

IL_0000:によってthisをスタックに読み込み、IL_0001:によってthisのフィールドreadOnlyStructをスタックに読み込んでいます。
IL_0006:によってスタックに読み込んだreadOnlyStructの値をローカル変数に格納しています。ここが防衛的コピーが行われている箇所です。
そのあとは、IL_0007:でローカル変数のアドレスをスタックしIL_0009:Methodメソッドを呼び出しています。

一方で、writableStructのほうでは、IL_000e:thisをスタックに読み込み、IL_000fで直接thisのフィールドのwriatlbeStructのアドレスをスタックに読み込んでいます。そのあとMethodメソッドを呼び出すのは同じです。

これが、防衛的コピーをしているときとしていないときの差です。

また、例ではclassのフィールドとしましたが、structのフィールドでも同様のようです。

in引数の場合

readonly制約は他にもあります。C# 7.2で追加されたin引数(ref readonly)はreadonly制約のある参照です。

struct Struct 
{
    public void Method(){}
}

class Class
{   
    public void Method(in Struct readOnlyStruct, ref Struct writableStruct)
    {
        readOnlyStruct.Method();
        writableStruct.Method();
    }
}

たとえば、このようなC#コードはCILにすると以下のようになります。

.class private auto ansi '<Module>'
{
} // end of class <Module>

.class private sequential ansi sealed beforefieldinit Struct
    extends [mscorlib]System.ValueType
{
    .pack 0
    .size 1

    // Methods
    .method public hidebysig 
        instance void Method () cil managed 
    {
        // Method begins at RVA 0x2050
        // Code size 1 (0x1)
        .maxstack 8

        IL_0000: ret
    } // end of method Struct::Method

} // end of class Struct

.class private auto ansi beforefieldinit Class
    extends [mscorlib]System.Object
{
    // Methods
    .method public hidebysig 
        instance void Method (
            [in] valuetype Struct& readOnlyStruct,
            valuetype Struct& writableStruct
        ) cil managed 
    {
        .param [1]
        .custom instance void [mscorlib]System.Runtime.CompilerServices.IsReadOnlyAttribute::.ctor() = (
            01 00 00 00
        )
        // Method begins at RVA 0x2054
        // Code size 21 (0x15)
        .maxstack 1
        .locals init (
            [0] valuetype Struct
        )

        IL_0000: ldarg.1
        IL_0001: ldobj Struct
        IL_0006: stloc.0
        IL_0007: ldloca.s 0
        IL_0009: call instance void Struct::Method()
        IL_000e: ldarg.2
        IL_000f: call instance void Struct::Method()
        IL_0014: ret
    } // end of method Class::Method

    .method public hidebysig specialname rtspecialname 
        instance void .ctor () cil managed 
    {
        // Method begins at RVA 0x2075
        // Code size 7 (0x7)
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: call instance void [mscorlib]System.Object::.ctor()
        IL_0006: ret
    } // end of method Class::.ctor

} // end of class Class

またもや、肝心なのはClassクラスのMethodメソッドです。

    .method public hidebysig 
        instance void Method (
            [in] valuetype Struct& readOnlyStruct,
            valuetype Struct& writableStruct
        ) cil managed 
    {
        .param [1]
        .custom instance void [mscorlib]System.Runtime.CompilerServices.IsReadOnlyAttribute::.ctor() = (
            01 00 00 00
        )
        // Method begins at RVA 0x2054
        // Code size 21 (0x15)
        .maxstack 1
        .locals init (
            [0] valuetype Struct
        )

        IL_0000: ldarg.1
        IL_0001: ldobj Struct
        IL_0006: stloc.0
        IL_0007: ldloca.s 0
        IL_0009: call instance void Struct::Method()
        IL_000e: ldarg.2
        IL_000f: call instance void Struct::Method()
        IL_0014: ret
    } // end of method Class::Method

in引数制約にいろいろと付与されていることがわかりますね。

IL_0000:では引数1のreadOnlyStructのアドレスをスタックに読み込んでいます。引数1なのは引数0にはthisがあるためです。IL_0001:でスタックにあるreadOnlyStructのアドレスから実際の値を読み込みIL_0006:でローカル変数に格納しています。IL_0007:でローカル変数のアドレスを読み込み、IL_0009:Struct#Methodを呼び出しています。

一方で、writableStructではIL_000e:で引数2のwritableStructのアドレスをスタックに読み込みIL_000fStruct#Methodを呼び出しています。

防衛的コピーをしていると結構変わりますね。

防衛的コピーを防ぐ方法

ref structにする

C# 7.2で追加されたref structで一部の防衛的コピーを防ぐことができます。ref structというのは言い換えればstackonly structです。つまり、ヒープには置けないstructです。

ここまで言えば察しのいい方は気づくかもしれませんが、ヒープには置けないstructということはclassのフィールドになりません。

ということで、classのreadonlyインスタンス変数の場合の防衛的コピーを防ぐことができます。

が、これだけでは不十分ですしこの方法はref structの副次的な効果です。

readonly structにする

防衛的コピーを防ぐにはC# 7.2で追加されたreadonly structしかありません。
readonly structではフィールドはすべてreadonlyである必要がある一方で、メソッドを呼び出しても値が変わらないという保証ができます。

すると防衛的コピーをする必要がなくなるので、コンパイラーは防衛的コピーをしなくなります。

防衛的コピーは防いだほうがいいのか?

この答えは明確で、防げるなら防いだほうがいいです。
防いだら防ぐほどコンパイラーによる最適化に期待ができるからです。

また、C# 7.2でin引数が追加されましたが、読み込み専用の参照渡しでパフォーマンス上げれるじゃんと考えてin引数を安易に多用してしまうと防衛的コピーを発生させまくってしまい、ref引数よりパフォーマンスが落ちるという残念な結果が起きることもあり得るかと思います。

readonly structにすることで、防衛的コピーを防げる場合なら防いだほうがいいというのが明らかであります。

ただ、readonly structにすると構造体の状態を変化させることが難しくなるので全部が全部readonlyにしなければいけないというわけでもないでしょう。

まとめ

  • readonly制約のある場所では構造体は防衛的コピーをしてしまうことがある
  • 防衛的コピーはreadonly structで防げる
  • 防衛的コピーは防げるなら防いだほうがいい

長くなってしまったので、防衛的コピーを防いだ結果のCILは省いてしまいましたが、気になる方がいればSharpLabあたりで試してみるといいかもです。
readonly structにすると値を変えれなくなりプログラム構造上、自分の首を絞めるようなことになってしまいますが、回避しようと思えばできる話はそのうちしたいかなと思っています。(キーワード:構造体の中に参照, ref拡張メソッド)