今日は半自動プロパティの話。
約1年前にも書いてる通り、場合によっては C# 11 で入っていたかもしれないものです。
需要はそれなりに高いんですが、 案外課題があって結局スケジュール的に11からははずれ、「その後どうなったの?」とか思われていそうな機能です。 (12候補としては結構有力。)
半自動プロパティの話自体は去年度にしているので、 今日書くのはその「課題」をつらつらと。
半自動プロパティ概要
去年の繰り返しになるので概要のみ。
要は、手動で書く通常のプロパティ(以下、手動プロパティ)と自動プロパティの中間で、
バッキング フィールドのアクセスに field
というキーワードを使おうというものです。
class A { // 手動プロパティ (manual property) // (と、自前で用意したフィールド)。 // こういう、プロパティからほぼ素通しで値を記録しているフィールドを「バッキング フィールド」(backing field)という。 private int _x; public int X { get => _x; set => _x = value; } // 自動プロパティ (auto-property)。 // 前述の X とほぼ一緒。 // バッキング フィールドの自動生成。 public int Y { get; set; } // 【C# 12 候補】 半自動プロパティ (semi-auto-property)。 // バッキング フィールドは自動生成。 // 全自動の方と違って、バッキング フィールドの使い方は自由にできる。 // field キーワードでバッキング フィールドを読み書き。 public int Z { get => field; set => field = value; } }
field の “キーワード性”
半自動プロパティに類する提案は他にもありつつも、
現状はとりあえず「field
キーワード」案で話が進んでいます。
キーワード追加。
ところが、この世に出ている C# コードの中には「field
という名前のフィールドや変数」がそれなりにあって(オープンソースになっているコードとかを検索すると相当量出てくるそうで)、さすがに「field
を文脈抜きに無条件にキーワード扱い」とかやるのは、破壊的変更としては許容できるレベルを超えていて、現実的ではないです。
field
という単語は、最近提案されている新機能の中では断トツで(record
や required
すら霞むくらい)影響力が大きいかもしれません。
一方で、文脈キーワードの仕様はなかなかに複雑になりがちで、 今、ちょっと単純化したいという話もあるくらいです。 そんな中、半自動プロパティでは早速苦戦しそうな雰囲気。
半自動プロパティの field
は、極限まで突き詰めて「有効な時だけキーワード扱い」をやろうとすると var
とか record
とかよりもだいぶ難しいみたいです。
一例として挙がっているのは以下のようなコード。
unsafe struct S { object Prop { get { S s = new(); // このステートメントは「構造体 S が unmanaged のときだけ有効」 // 言い換えると、「構造体 S が参照型のフィールドを持たないときだけ有効」 // (C# 11 からは警告のみになったものの、元々はエラー。) var ptr = &s; // field が「S とは無関係な定数とか」だと &s が有効。 // ところが、field がキーワードで、バッキング フィールドが自動的に作られると &s が無効になる。 // 「&s が無効にならないようにこれは認めない」みたいなことまでやるのは解析が「循環」してしまう。 return field; } } }
なのであんまり正確にやるのはやめておいた方がいいとして、 簡素化した案でいうと以下のようなものがあります。
- セマンティクスを見るのは「
field
という名前のクラスと、field
という名前のフィールドがあるかどうか」だけ - あとは、構文的にだけ解析して、「スコープ内に
field
という名前の識別子がいるかどうか」で判定
簡素化するために「スコープを無視して解析」みたいな案もあるみたいなんですが、 結局は、以下のように「スコープも考慮に入れる」、「内側のスコープやローカル関数でのシャドーイングは認める」という予定だそうです。
object Prop { get { { // この field は {} 内でだけ有効。 int field = 1; } // このフィールドは m の内側でだけ有効。 static void m(int field) { } // {} とかローカル関数の外側には "field" がいないので、 // ここの field はキーワード。 return field; } }
というのも、同スコープ内の解析に限っても、それなりに解析が大変そうな文法がいくつかあって、「労力は変わらない」とのこと。
class C { int Prop { get { var x = (field: 1, 2); // タプル要素名 var y = new { field = 1 }; // 匿名型のプロパティ var z = new Foo() { field = 1 }; // オブジェクト初期化子でのフィールド/プロパティ参照 if (x is { field: 1 }) { } // プロパティ パターンでのフィールド/プロパティ参照 // 上記の field はいずれも、field という名前の変数が新たに導入されたりはしない。 // このスコープ内に "field" はいないので、ここの field はキーワードでいいはず。 return field; } } } class Foo { public int field; }
初期化子の挙動
C# の構造体には「すべてのフィールドを初期化しきるまで関数メンバー(メソッドやプロパティ)を呼べない」という仕様がありました。 (ただし、C# 11 で緩和されました。)
struct S { int _x; public void M() { } public S() { // C# 10 まではコンパイル エラーになってた。 M(); // _x の初期化より前 _x = 0; } }
そんな中、C# 6 で get-only プロパティの導入とともに、 「コンストラクター内での自動プロパティへの代入は、それのバッキング フィールドへの直接代入への最適化を認める」という仕様も入っています。
struct Point { public int X { get; private set; } public Point(int x) { // C# 5.0まではエラーに。 X = x; // これを認めるために、X = x の部分は「Xのバッキングフィールド = x」に展開される。 } }
その流れで、プロパティ初期化子も「バッキング フィールドへの代入に展開」されます。 例えば以下のようなコードを書いたとします。
struct S { public int X { get; private set; } = 1; public S() { } } record struct R(int X) { public int X { get; private set; } = X; }
このコードは、以下のようなコードとほぼ同じ挙動になります。
struct S { private int _x; public int X { get => _x; private set => _x = value; } public S() { _x = 1; // X = 1 ではなくて、_x = 1 } } struct R { private int _x; public int X { get => _x; private set => _x = value; } public R(int X) { _x = X; // this.X = X ではなくて、_x = 1 } }
という背景の中、半自動プロパティの場合はどうしようかという問題があります。
例えば以下のようなコードを認めたいんですが、
じゃあ、初期化時に OnXChanged
は呼ばれるのかどうか。
struct S { // 流れ的にはこういうプロパティ初期化子も認めたい。 public int X { get => field; private set { field = value; OnXChanged(); } } = 1; public S() { } public void OnXChanged() { Console.WriteLine("何か副作用起こす"); } }
C# 11 での変更前は「自動プロパティと同様にせざるを得ない」と言われていました。
つまるところ、プロパティ初期化子はバッキング フィールドへの直代入に展開されて、
結果的に、OnXChanged
は呼ばれないということになります。
C# 11 でこの要件は必然ではなくなったわけですが、
それでも「自動プロパティと同様」の仕様(OnXChanged
は呼ばれない)になりそうな雰囲気です。
override
override したときの挙動をどうしようかという問題もあります。 というのも、例えば以下のコードを考えます。
class Base { // 自動プロパティなので、バッキング フィールドが作られる。 public virtual int Prop { get; set; } } class Derived : Base { // override してる時点で Base.Prop とは別物。 // それをまた自動プロパティにすると、Base.Prop のものとは別に追加でバッキング フィールドができる。 public override int Prop { get; set; } }
自動プロパティの作るバッキング フィールドは Base
と Derived
で独立しています。
さらに、virtual なプロパティは「get
だけ override」みたいなことができます。
var x = new Derived { Prop = 2 }; // set は base.Prop のものがそのまま呼ばれる。 Console.WriteLine(x.Prop); // get は Derived.Prop が呼ばれて、4 になる。 class Base { public virtual int Prop { get; set; } } class Derived : Base { // get だけ override して、base のものの二乗を返す。 public override int Prop { get => base.Prop * base.Prop; } }
そんな中、半自動プロパティでの override はどうしよう?という話になります。
var x = new Derived { Prop = 2 }; Console.WriteLine(x.Prop); class Base { public virtual int Prop { get; set; } } class Derived : Base { // get だけ override して(全)自動プロパティというのはできない。 // じゃあ、get だけ "半"自動プロパティは? // これは Base.Prop とは別のバッキング フィールドになる? public override int Prop { get => field * field; } }
これはさすがにどう転んでもわかりにくいので、 いっそのこと、「半自動プロパティでの override はすべてのアクセサー(get/set 両方)の override が必須」とするそうです。
nullability
半自動プロパティの導入の動機の1つに遅延初期化、 すなわち、以下のようなコードを書きたいというものがあります。
public class LazyInit { public string Value => field ??= ComputeValue(); private static string ComputeValue() { /*...*/ } }
この用途の場合、バッキング フィールドの型は string?
であるべきなんですよね。
ところが、現状は「半自動プロパティから作られるバッキング フィールドの型はプロパティの型と同じ」という仕様なので、string
になります。
参照型に関しては元から ?
の有無はフロー解析の差だけなのでそこまで問題ではないんですが、
値型の場合は困ります。
public class LazyInit { // field も int なので、 ?? が意味をなさない。 public int Value => field ??= ComputeValue(); private static int ComputeValue() { /*...*/ } }
これは、「field
キーワード」路線でやる以上は解決しようがなさそうで、
それとは別に「プロパティ スコープ フィールド」(半自動プロパティと同じ要件に対する別案)が必要かもしれません。
とはいえ、とりあえず「field
キーワード」優先で、
プロパティ スコープ フィールドはやるとしてもその後ということになっています。