スキップしてメイン コンテンツに移動

[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拡張メソッド)

コメント

このブログの人気の投稿

Stellaris 2.0.1 植民スパムプレイ

さっそくですが、友達との3日がかりのマルチプレイ終わりました。

銀河設定は中サイズ(600)、リング2、中盤の危機75年早く、終盤の危機100年早く、技術・伝統コスト0.5倍、難易度普通って感じでした。

そして、自分のプレイングは機械帝国植民スパム、友達は内向き牧歌でした。
最終的な国力としては機械帝国植民スパムのほうが強く、序盤から中盤は内向き牧歌のほうが強かったです。

機械帝国植民スパム はい、前回も言いましたが2.0から植民スパムゲーです。2.0.2からは前哨地維持費がかかるようになり更なる植民スパムゲーにもなりそうです。
植民スパムするからには、まぁロボットか機械帝国って感じなんですが、今回はゲーム進行が速い設定なので機械製造速度アップにガン振り帝国にしました。生産はエネルギー重視で、直轄地に鉱物惑星・研究惑星があるような感じにしました。
それでも、機械帝国は序盤の成長が遅く、鉱物を大量に消費するので、周りの帝国との不可侵条約はかかせません。直轄地6惑星(母星・エネルギー、エネルギー、研究*3、鉱物)の発展が終われば、今度はセクターの開発です。 まぁ、セクターの開発なんか物資さえ送り込めば勝手にやってくれるのでいいですが、エネルギー重視の生産なので鉱物の面倒は見てやらないとダメです。
帝国所有惑星が20ぐらいになれば、まぁ中銀河(サイズ600)なら敵なしですかね。あとは軍拡するだけです。
中盤の危機と終盤の危機 前述の通り危機はかなり早く発生するようにしたのですが、なんと中盤の危機がいっこうに発生しませんでした。発生したのは終盤の危機に立ち向かってるとき…おい()
終盤の危機は機械帝国プレイだったのでもちろんのようにコンティンジェンシー、中盤の危機は機械の反乱でした。
中盤の危機に関しては機械帝国プレイなので自分はどうでもよかったのですが、終盤の危機はがんばらないとまずかったです。コンティンジェンシーなので機械の生産-40%補正がつくので、スペシャルプロジェクトで解除(自分の研究力500ぐらいでは50か月ぐらいでした)しない限り国力が持ちません。
コンティンジェンシーは艦隊戦力が300k+100k*2の集団が4つほど湧いて100k艦隊は時間とともに増え続けるので急がないといけませんが、解除さえすればあまり余る国力で殴り続けるだけでしたね。終盤の危機の間に…

Stellaris ver2.0 Apocalypse プレイ感想

タイトルの通り大型アプデ後での友人とのマルチプレイでのプレイ感想です。

変更内容に関してはこちらのサイトで日本語訳された内容が公開されています(本当に助かりました)
Simulationian.com - 「Stellaris」開発日記#105――2.0「チェリイ」パッチノート

使用した帝国について 細かい設定は覚えていませんが(確認するのもめんどくさい)、有機生命体で狂信権威・平和主義で国是にアップデート内容の生まれた生命(Life-Seeded)を選択しています。 狂信権威について アップデートにより帝国の領域を増やすには星系ごとに影響力(1回限りに支払い)を使い前哨地を建てていく必要がありました。そこで狂信権威を選択したのですが、プレイスタイルが悪いのか、よく奴隷が不満を抱えストライキ?を起こしてしまいました。これに関しては防衛設備を地上に建て防衛軍を増やすことで対処できますが、タイルを無駄に消費している感じがすごいあります。 影響力が毎月4増えるのは領域を増やすのに好都合でしたが、Life-Seededとの相性が微妙でした(後述)。 Life-Seededについて 自分の種族がガイア型惑星にしか住めなくなるが、母星がガイア型25でスタートします。使用感想としては初期ブーストはいいかな?って感じです。ただ、アップデート後の環境ではプレイスタイルを頑張らなければ弱いかなと。その理由としては、まずアップデート後は研究コスト補正にPOP数が関係なくなり領域星系数が関係あるようになりました。つまり領域が大きければ大きいほどコストが高くなるということ。星系の資源はプレイヤーではどうしようもならない固定資源のようなものですから、それに頼って領域を大きくし過ぎると研究コストがすごいことになってしまいます。植民地を確保して賄おうにもガイア型にしか住めないのでかなり増やしにくいです。 ガイア型にしか住めないというのは、有機生命体であってロボットなら他のタイプの惑星に植民できるというのであれば、Life-Seededはロボットプレイをするしかないのかもしれないですが、これに関しては未検証です。
プレイスタイルについて 今回自分がとったプレイスタイルは要塞でガン守りキメるガチ芋戦略です。アップデートで要塞関連が変わり結構使えるものになったらしいので。 防衛プラットフォームを上限まで…

if(flag == true)はありなのか?なしなのか?

最近バズってるようなので便乗
ちなみに元ネタはこちらだとおもいます→qiita - Javaではif (flag == true)というコードを書いてはいけない

※真偽値としてのネーミングとしてflagはナシだろ~~wという話はナシでお願いします
※以下flag変数は真偽値とします

結論から言うと純粋な真偽値の場合はif(flag == true)、if(flag == false)はナシです

まず、最初に前提を整理します
if文は真偽値(true or false)で判定しなければならないJavaではbooleanKotlinではBooleanC#ではbool 話の簡素化のために整えておかないといけないこともあります JavaではBooleanのことは考えない(理由は省略)(ボクシング次第では考えていいかも)KotlinではBoolean?のことは考えないnullableな場合はtrue or false or nullなのでC#ではbool?のことは考えないnullableな場合はtrue or false or nullなので
前提を整理し終わったので、if(flag == true)はアリなのかナシなのか考えていきましょう。 おそらくif(flag == true)論争では2つのパターン(==演算子で比較するかどうか)に派閥分けされていますが、ここでは3つのパターンに分けます
1つ目のパターン:==演算子で比較する// true比較の場合 if(flag == true) // false比較の場合 if(flag == false) はい、もっともナシなパターンです(理由は後述)。

ナシと言われる理由は前提を振り返ればわかりますがif文の中では最終的に真偽値であればよいのですがflagはそもそも真偽値です。わざわざtrueと==比較して真偽値にする必要はありません。わざわざ==比較することは理論上はパフォーマンスが落ちます。

このパターンがなくならない理由は可読性がまぁまぁいいことです。
冗長であろうがパフォーマンスが理論上悪かろうが可読性があれば正義です(そういう考えもできる)。

自分も昔はこのパターンな書き方を多用していましたが後述の理由によって今はしません(少なくとも意識してはしてないはず)。

2つ目のパターン:否定演算子!を使う// true比較の…