以前すこし話に触れた構造体の防衛的コピーについて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();
ではインスタンス変数writableStruct
のStruct#Method
が呼び出されています。writableStruct
は値が変わることがあるインスタンス変数ですのでここでの呼び出し時には防衛的コピーは発生しません。
しかし、readOnlyStruct.Method();
ではreadonlyなインスタンス変数readOnlyStruct
のStruct#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_000f
でStruct#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拡張メソッド)