どうもKutoです。
この記事はQiita Advent Calender2025 C#の21日目の記事です。
私は普段Unityのゲーム開発でC#を用いているのですが、最近プロパティの仕様で ん? となることがあったので共有したいと思います。
プロパティとは?
まずプロパティってなんだっけという人のために簡単に例だけ示すと、以下のようなやつですね。
public int Hoge { get; private set; }
上記のようにgetとsetでアクセス修飾子を変えることが出来、setはprivateだけどgetはpublicで~といった場面で重宝します。
またプロパティは以下のように書くこともできます。
int hoge;
public int Hoge => hoge;
こう書くことでhogeというprivate変数のgetterを簡単に作ることが出来るんですね。
今回のパターン
ここまでの知識はちゃんと持っていたのですが、今回以下のようなコードを書いたときに ん? となってしまいました。
public Klass Hoge => new();
自分の中ではこの形式で書くプロパティは以下と同値であるという認識でした。
// これと同じ
public Klass Hoge { get; } = new();
// これとも同じ
Klass hoge = new();
public Klass Hoge => hoge;
しかし実際にコードを動かしてみたところ、どうもプロパティHogeにアクセスする度にコンストラクタが呼ばれている。
なんで? ということで実際にプロパティがどのように処理されているのかを確認してみました。
プロパティのIL
こんなときはILを見るに限ります。
実際に上記のようなコードを書き、ILを確認してみることにしました。
自分はエディタとしてRiderを使用しているので、標準機能として作成したC#コードのILを確認することが出来ます。
早速実行してみようということで、以下のようなテストコードを書いてみました
public class Klass
{
int bar;
}
public class Program
{
public Klass Hoge1 => new();
public Klass Hoge2 { get; } = new();
Klass hoge3 = new();
public Klass Hoge3 => hoge3;
}
書いたらビルドを通しまして、早速確認へ。
まずこちらは本題とずれますが、class KlassのILを見てみましょう。
// Type: Klass
// Assembly: ModelRepository, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
// MVID: E0D5AF20-4F6D-4452-9816-83B8038E2B73
// Location: C:\hoge\Unity\hogehoge\Temp\Bin\Debug\ModelRepository\ModelRepository.dll
// Sequence point data and variable names from c:\hoge\unity\hogehoge\temp\bin\debug\modelrepository\modelrepository.pdb
.class public auto ansi beforefieldinit
Klass
extends [netstandard]System.Object
{
.field private int32 bar
.method public hidebysig specialname rtspecialname instance void
.ctor() cil managed
{
.maxstack 8
IL_0000: ldarg.0 // this
IL_0001: call instance void [netstandard]System.Object::.ctor()
IL_0006: nop
IL_0007: ret
} // end of method Klass::.ctor
} // end of class Klass
ふむ。全く読めませんが、変数barとデフォルトのコンストラクタが定義されているみたいですね。
この中身で何をやっているのかも解読してみたい気がしますが、本筋とそれ過ぎるのでここは次に進むことにしましょう。
以下がclass ProgramのILになります。
// Type: Program
// Assembly: ModelRepository, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
// MVID: E0D5AF20-4F6D-4452-9816-83B8038E2B73
// Location: C:\hoge\Unity\hogehoge\Temp\Bin\Debug\ModelRepository\ModelRepository.dll
// Sequence point data and variable names from c:\hoge\unity\hogehoge\temp\bin\debug\modelrepository\modelrepository.pdb
.class public auto ansi beforefieldinit
Program
extends [netstandard]System.Object
{
.field private initonly class Klass '<Hoge2>k__BackingField'
.custom instance void [netstandard]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()
= (01 00 00 00 )
.custom instance void [netstandard]System.Diagnostics.DebuggerBrowsableAttribute::.ctor(valuetype [netstandard]System.Diagnostics.DebuggerBrowsableState)
= (01 00 00 00 00 00 00 00 ) // ........
// int32(0) // 0x00000000
.field private class Klass hoge3
.method public hidebysig specialname instance class Klass
get_Hoge1() cil managed
{
.maxstack 8
// [88 27 - 88 32]
IL_0000: newobj instance void Klass::.ctor()
IL_0005: ret
} // end of method Program::get_Hoge1
.method public hidebysig specialname instance class Klass
get_Hoge2() cil managed
{
.custom instance void [netstandard]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()
= (01 00 00 00 )
.maxstack 8
// [90 26 - 90 30]
IL_0000: ldarg.0 // this
IL_0001: ldfld class Klass Program::'<Hoge2>k__BackingField'
IL_0006: ret
} // end of method Program::get_Hoge2
.method public hidebysig specialname instance class Klass
get_Hoge3() cil managed
{
.maxstack 8
// [93 27 - 93 32]
IL_0000: ldarg.0 // this
IL_0001: ldfld class Klass Program::hoge3
IL_0006: ret
} // end of method Program::get_Hoge3
.method public hidebysig specialname rtspecialname instance void
.ctor() cil managed
{
.maxstack 8
// [90 35 - 90 40]
IL_0000: ldarg.0 // this
IL_0001: newobj instance void Klass::.ctor()
IL_0006: stfld class Klass Program::'<Hoge2>k__BackingField'
// [92 5 - 92 25]
IL_000b: ldarg.0 // this
IL_000c: newobj instance void Klass::.ctor()
IL_0011: stfld class Klass Program::hoge3
IL_0016: ldarg.0 // this
IL_0017: call instance void [netstandard]System.Object::.ctor()
IL_001c: nop
IL_001d: ret
} // end of method Program::.ctor
.property instance class Klass Hoge1()
{
.get instance class Klass Program::get_Hoge1()
} // end of property Program::Hoge1
.property instance class Klass Hoge2()
{
.get instance class Klass Program::get_Hoge2()
} // end of property Program::Hoge2
.property instance class Klass Hoge3()
{
.get instance class Klass Program::get_Hoge3()
} // end of property Program::Hoge3
} // end of class Program
ちょっと長いですね。見づらいので、元のC#に対応してそうなところをかいつまんでそれぞれ並べてみましょう。
パターン1
public Klass Hoge1 => new();
.method public hidebysig specialname instance class Klass
get_Hoge1() cil managed
{
.maxstack 8
// [88 27 - 88 32]
IL_0000: newobj instance void Klass::.ctor()
IL_0005: ret
} // end of method Program::get_Hoge1
.property instance class Klass Hoge1()
{
.get instance class Klass Program::get_Hoge1()
} // end of property Program::Hoge1
パターン2
public Klass Hoge2 { get; } = new();
.field private initonly class Klass '<Hoge2>k__BackingField'
.custom instance void [netstandard]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()
= (01 00 00 00 )
.custom instance void [netstandard]System.Diagnostics.DebuggerBrowsableAttribute::.ctor(valuetype [netstandard]System.Diagnostics.DebuggerBrowsableState)
= (01 00 00 00 00 00 00 00 ) // ........
// int32(0) // 0x00000000
.method public hidebysig specialname instance class Klass
get_Hoge2() cil managed
{
.custom instance void [netstandard]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()
= (01 00 00 00 )
.maxstack 8
// [90 26 - 90 30]
IL_0000: ldarg.0 // this
IL_0001: ldfld class Klass Program::'<Hoge2>k__BackingField'
IL_0006: ret
} // end of method Program::get_Hoge2
.property instance class Klass Hoge2()
{
.get instance class Klass Program::get_Hoge2()
} // end of property Program::Hoge2
パターン3
Klass hoge3 = new();
public Klass Hoge3 => hoge3;
.field private class Klass hoge3
.method public hidebysig specialname instance class Klass
get_Hoge3() cil managed
{
.maxstack 8
// [93 27 - 93 32]
IL_0000: ldarg.0 // this
IL_0001: ldfld class Klass Program::hoge3
IL_0006: ret
} // end of method Program::get_Hoge3
.property instance class Klass Hoge3()
{
.get instance class Klass Program::get_Hoge3()
} // end of property Program::Hoge3
コンストラクタ
後々の話で言及しているので、一応コンストラクタも示しておきます。
.method public hidebysig specialname rtspecialname instance void
.ctor() cil managed
{
.maxstack 8
// [90 35 - 90 40]
IL_0000: ldarg.0 // this
IL_0001: newobj instance void Klass::.ctor()
IL_0006: stfld class Klass Program::'<Hoge2>k__BackingField'
// [92 5 - 92 25]
IL_000b: ldarg.0 // this
IL_000c: newobj instance void Klass::.ctor()
IL_0011: stfld class Klass Program::hoge3
IL_0016: ldarg.0 // this
IL_0017: call instance void [netstandard]System.Object::.ctor()
IL_001c: nop
IL_001d: ret
} // end of method Program::.ctor
全部ILが違う
結果として見ると全部ILが違いますね。
正直結果としては意外で、実行結果からパターン1が他と異なることは知っていたのですがパターン2と3でも異なるとは思いませんでした。
さてこのまま解説していくと少し分かりにくいと思うので、IL言語をC#でおなじようなニュアンスのものに書き直したいと思います。
パターン1
public Klass Hoge1 => new();
public Klass get_Hoge1()
{
return new Klass();
}
パターン2
public Klass Hoge2 { get; } = new();
readonly Klass hoge2;
public Klass get_Hoge2()
{
return hoge2;
}
// コンストラクタ
public Program()
{
hoge2 = new Klass();
}
パターン3
Klass hoge3 = new();
public Klass Hoge3 => hoge3;
Klass hoge3;
public Klass get_Hoge3()
{
return hoge3;
}
// コンストラクタ
public Program()
{
hoge3 = new Klass();
}
ここまでするとかなり分かりやすいですね。
今回のようにパターン1で書いた場合、内部的には上記のようなコードと同値の処理を行います。
よってプロパティにアクセスするたびにnew Klass()が呼ばれ、新規のインスタンスがreturnされてしまいます。
他の書き方では内部的にプロパティに対応するprivate変数が生じるため、インスタンスが一意に保たれるんですね。
またパターン2とパターン3でILが異なる理由ですが、これはパターン2ではC#上で明示的にprivate変数hoge2を作成していないためです。
C#上で存在せず編集することは出来ないことを明記するために、通常のprivate変数とは異なった記法で書かれているみたいですね。
C#に分かりやすく直したときにreadonlyが付いていたと思いますが、そこら辺の処理が入っているようです。
まとめ
今回はC#におけるプロパティの内部処理についてまとめてみました。
自分は=>を使った形は単なる省略形だと思っていたのですが、実際には違うことが分かりました。
よく使っている言語なので、こういう細かい仕様についても認識していきたいですね。
