【C# 12 候補】半自動プロパティ
今日は半自動プロパティの話。
約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 キーワード」優先で、
プロパティ スコープ フィールドはやるとしてもその後ということになっています。
