次の方法で共有


init 専用セッター

メモ

この記事は機能仕様についてです。 仕様は、機能の設計ドキュメントとして使用できます。 これには、提案された仕様の変更および機能の設計と開発時に必要な情報が含まれます。 これらの記事は、提案された仕様の変更が決定され、現在の ECMA 仕様に組み込まれるまで公開されます。

機能の仕様と行われた実装では、いくつかの違いがあることがあります。 これらの違いは、関連する言語設計ミーティング (LDM) メモに取り上げられています。

機能仕様を C# 言語標準に導入するプロセスの詳細については、仕様に関する記事を参照してください。

チャンピオンの課題: https://github.com/dotnet/csharplang/issues/39

まとめ

この提案では、init のみのプロパティとインデクサーの概念を C# に追加します。 これらのプロパティとインデクサーは、オブジェクトの作成時に設定できますが、オブジェクトの作成が完了した後にのみ、効果的に get になります。 これにより、C# での不変モデルの柔軟性が大幅に高まります。

目的

C# で変更できないデータを構築するための基になるメカニズムは、1.0 以降変更されていません。 これらは次のままになります。

  1. フィールドを readonly として宣言。
  2. get アクセサーのみを持つプロパティの宣言。

これらのメカニズムは、変更できないデータの構築を許可する場合に効果的ですが、型の定型コードにコストを追加し、オブジェクト初期化子やコレクション初期化子などの機能からそのような型をオプトインします。 つまり、開発者は使いやすさと不変性のどちらかを選択する必要があります。

Point のような単純な不変オブジェクトでは、型を宣言する場合と同じように、構築をサポートするために 2 倍のボイラー プレート コードが必要です。 タイプが大きくなるほど、このボイラープレートのコストも大きくなります。

struct Point
{
    public int X { get; }
    public int Y { get; }

    public Point(int x, int y)
    {
        this.X = x;
        this.Y = y;
    }
}

init アクセサーは、構築中に呼び出し元がメンバーを変更できるようにすることで、不変オブジェクトの柔軟性を高めます。 つまり、オブジェクトの不変プロパティはオブジェクト初期化子に参加できるため、型内のすべてのコンストラクタ ボイラープレートが不要になります。 Point 型がシンプルになりました。

struct Point
{
    public int X { get; init; }
    public int Y { get; init; }
}

その後、コンシューマーはオブジェクト初期化子を使用してオブジェクトを作成できます

var p = new Point() { X = 42, Y = 13 };

詳細な設計

init アクセサー

init only プロパティ (またはインデクサー) は、set アクセサーの代わりに init アクセサーを使用して宣言されます。

class Student
{
    public string FirstName { get; init; }
    public string LastName { get; init; }
}

init アクセサーを含むインスタンス プロパティは、ローカル関数またはラムダの場合を除き、次の状況下で設定可能と見なされます。

  • オブジェクト初期化子の間
  • with 式初期化子の間
  • 包含型または派生型に属するインスタンス コンストラクターの内部で、this または base において
  • this または base 上の任意のプロパティの init アクセサー内
  • 名前付きパラメーターを使用した属性使用法内

このドキュメントでは、init アクセサーが設定可能な上記の時間をまとめて、オブジェクトの構築フェーズと呼びます。

つまり、Student クラスは次の方法で使用できます。

var s = new Student()
{
    FirstName = "Jared",
    LastName = "Parosns",
};
s.LastName = "Parsons"; // Error: LastName is not settable

init アクセサーが設定可能な場合のルールは、型階層間で拡張されます。 メンバーがアクセス可能で、オブジェクトが構築フェーズにある場合、メンバーは設定可能です。 これにより、次のことが特に可能になります。

class Base
{
    public bool Value { get; init; }
}

class Derived : Base
{
    public Derived()
    {
        // Not allowed with get only properties but allowed with init
        Value = true;
    }
}

class Consumption
{
    void Example()
    {
        var d = new Derived() { Value = true };
    }
}

init アクセサーが呼び出された時点で、インスタンスは未対応の構築段階であることが分かります。 そのため、init アクセサーは、通常の set アクセサーが実行できることに加えて、次のアクションを実行できます。

  1. this または base で使用可能な他の init アクセサーを呼び出す
  2. this を使用して、同じ型で宣言された readonly フィールドを割り当てる
class Complex
{
    readonly int Field1;
    int Field2;
    int Prop1 { get; init; }
    int Prop2
    {
        get => 42;
        init
        {
            Field1 = 13; // okay
            Field2 = 13; // okay
            Prop1 = 13; // okay
        }
    }
}

init アクセサーから readonly フィールドを割り当てる機能は、アクセサーと同じ型で宣言されているフィールドに限定されます。 基本型の readonly フィールドの割り当てには使用できません。 このルールにより、型の作成者は、その型の変更可能性の動作を制御できます。 init を利用したくない開発者は、これを選択した他の種類の影響を受けません。

class Base
{
    internal readonly int Field;
    internal int Property
    {
        get => Field;
        init => Field = value; // Okay
    }

    internal int OtherProperty { get; init; }
}

class Derived : Base
{
    internal readonly int DerivedField;
    internal int DerivedProperty
    {
        get => DerivedField;
        init
        {
            DerivedField = 42;  // Okay
            Property = 0;       // Okay
            Field = 13;         // Error Field is readonly
        }
    }

    public Derived()
    {
        Property = 42;  // Okay 
        Field = 13;     // Error Field is readonly
    }
}

仮想プロパティで init を使用する場合は、すべてのオーバーライドも initとしてマークする必要があります。 同様に、単純な setinitでオーバーライドすることはできません。

class Base
{
    public virtual int Property { get; init; }
}

class C1 : Base
{
    public override int Property { get; init; }
}

class C2 : Base
{
    // Error: Property must have init to override Base.Property
    public override int Property { get; set; }
}

interface 宣言は、次のパターンを使用して init スタイルの初期化に参加することもできます。

interface IPerson
{
    string Name { get; init; }
}

class Init
{
    void M<T>() where T : IPerson, new()
    {
        var local = new T()
        {
            Name = "Jared"
        };
        local.Name = "Jraed"; // Error
    }
}

この機能の制限事項:

  • init アクセサーは、インスタンス プロパティでのみ使用できます
  • プロパティに init アクセサーと set アクセサーの両方を含めることはできません
  • ベースに init がある場合は、すべてのプロパティのオーバーライドに init が必要です。 このルールは、インターフェイスの実装にも適用されます。

Readonly 構造体

init アクセサー (自動実装アクセサーと手動実装アクセサーの両方) は、readonly struct のプロパティと readonly プロパティで許可されます。 init アクセサーは、readonly 型でも非readonlyreadonly 型でも、自分自身を struct としてマークすることは許可されていません。

readonly struct ReadonlyStruct1
{
    public int Prop1 { get; init; } // Allowed
}

struct ReadonlyStruct2
{
    public readonly int Prop2 { get; init; } // Allowed

    public int Prop3 { get; readonly init; } // Error
}

メタデータ エンコード

プロパティ init アクセサーは、戻り値の型が IsExternalInit の modreq でマークされた標準の set アクセサーとして出力されます。 これは、次の定義を持つ新しい型です。

namespace System.Runtime.CompilerServices
{
    public sealed class IsExternalInit
    {
    }
}

コンパイラは型を完全な名前で識別します。 コア ライブラリに表示する要件はありません。 この名前で複数の型がある場合、コンパイラは次の順序でタイブレークを行います。

  1. コンパイル中のプロジェクトで定義されたもの
  2. corelib で定義されたもの

これらのいずれかが存在しない場合は、型の不明瞭なエラーが表示されます。

IsExternalInit の設計は、この号 でさらに詳しく説明されています。

質問

重大な変更

この機能がどのようにエンコードされるのかに関する主な軸のひとつは次の問いに帰結します。

initsetに置き換えるのはバイナリ破壊的変更か?

initset に置き換え、プロパティを完全に書き込み可能にすることは、非仮想プロパティのソースの破壊的変更になることはありません。 プロパティを記述できるシナリオ一式を拡張するだけです。 問題になっている唯一の挙動は、これがバイナリ破壊的変更のままであるかどうかです。

initからsetへの変更をソースおよびバイナリ互換にするためには、modreqと属性に関する決定を下すことが必要になります。これは、modreqをソリューションとして除外するためです。 一方、これが興味深いものではないとみなされた場合、modreq 対属性の判断はあまり意味がなくなります。

解決方法 このシナリオは LDM では説得力があるとは見なされません。

Modreqs と属性

init プロパティ アクセサーの出力戦略では、メタデータ中に出力するときに属性または modreqs を使用するかを選択する必要があります。 これらは考慮する必要がある異なるトレードオフを持っています。

modreq 宣言を使用してプロパティ セット アクセサーに注釈を付けるということは、CLI 準拠のコンパイラが modreq を認識しない限り、アクセサーを無視することを意味します。 init を認識しているコンパイラだけがメンバーを読み取るということです。 init を認識していないコンパイラは、set アクセサーを無視するため、プロパティが誤って読み取り/書き込みされません。

modreq の欠点は、initset アクセサーのバイナリ署名の一部になることです。 init を追加または削除すると、アプリケーションのバイナリ互換性が損なわれます。

属性を使用して set アクセサーに注釈を付けるということは、属性を理解しているコンパイラだけがアクセスを制限することを認識することを意味します。 init を認識しているコンパイラは、それを単純に読み取り/書き込み プロパティとみなし、アクセスを許可します。

これは一見、バイナリーの互換性を犠牲にして安全性を高めるかどうかを選択するように見えます。 少し掘り下げてみると、追加の安全性は見た目通りではないことがわかります。 次の状況では保護されません。

  1. public メンバーに対するリフレクション
  2. dynamic の使用
  3. modreqs を認識しないコンパイラ

また、.NET 5 の IL 検証ルールを完了すると、init がそれらのルールの 1 つになることも考慮する必要があります。 つまり、検証可能な IL を出力するコンパイラを検証するだけで、追加の適用が得られます。

.NET (C#、F#、VB) の主言語はすべて、これらの init アクセサーを認識するように更新されます。 したがって、ここでの唯一の現実的なシナリオは、C# 9 コンパイラが init プロパティを出力し、C# 8、VB 15 ... C# 8 などの古いツールセットで表示される場合です。 これは、バイナリの互換性を考慮し、比較検討する必要があるトレードオフです。

注意 この説明は、主にメンバーにのみ適用され、フィールドには適用されません。 init フィールドは LDM によって拒否される一方で、modreq と属性は、会議の余地があります。 フィールドの init 機能は、readonlyの既存の制限を緩和します。 つまり、readonly + 属性としてフィールドを出力した場合、古いコンパイラがフィールドを誤って使用するリスクはありません。これは、既に readonly が認識されているためです。 したがって、ここで modreq を使用しても、追加の保護は追加されません。

解決策 この機能では、modreq を使用して プロパティ init setter をエンコードします。 説得力のある要因として次が挙げられます (特定の順序はありません)。

  • 古いコンパイラが init セマンティクスに違反するのを防ぐ
  • virtual 宣言内の init または interface 追加または削除することは、ソースとバイナリ両方にとって破壊的変更です。

init の削除をバイナリ互換の変更とするための重要なサポートもなかったことから、modreq の使用を選択することが簡単でした。

init と initonly

LDM 会議中に重要な考慮事項を取得した 3 つの構文形式がありました。

// 1. Use init 
int Option1 { get; init; }
// 2. Use init set
int Option2 { get; init set; }
// 3. Use initonly
int Option3 { get; initonly; }

解決策 LDM には圧倒的に好まれる構文はありませんでした。

将来の一般的な機能として init メンバーを実現できるかどうかに構文の選択が与える影響が重要な注目を集めた点の一つでした。 オプション 1 を選択すると、将来、init スタイルの get メソッドを持つプロパティを定義することが困難になります。 最終的に、将来において一般的な init メンバーと共に進めていくことが決定された場合、init をプロパティ アクセサー リストの修飾子として使用し、さらに init setの省略形とすることができると決定されました。 基本的に、次の 2 つの宣言は同じになります。

int Property1 { get; init; }
int Property1 { get; init set; }

プロパティ アクセサー リストのスタンドアロン アクセサーとして init を使用して前進することを決定しました。

失敗した init に対する警告

次のシナリオで考えてみましょう。 型は、コンストラクターで設定されていない init メンバーのみを宣言します。 オブジェクトを構築するコードが値の初期化に失敗した場合、警告を受け取る必要がありますか?

その時点で、フィールドが設定されることは永遠にないことが明白であり、そのため、privateデータを初期化できなかった場合の警告と多くの共通点があります。 したがって、警告には外見的に何らかの値がありますか?

ただし、この警告には大きな欠点があります。

  1. これは、readonlyinitに変更する互換性の話を複雑にします。
  2. 呼び出し元が初期化する必要があるメンバーを示すために、追加のメタデータを保持する必要があります。

さらに、オブジェクト作成者に特定のフィールドに関する警告/エラーを強制する全体的なシナリオに価値があると考えられる場合、これは一般的な機能として理にかなっている可能性があります。 init メンバーのみに限定する必要はありません。

解決策 init フィールドとプロパティの使用に関する警告は発生しません。

LDM は、必要なフィールドとプロパティの考え方についてより広範な議論をしたいと考えています。 その結果、init メンバーと検証に関する立場を再考する場合があります。

フィールド修飾子として init を許可する

init がプロパティ アクセサーとして機能するのと同じ方法で、フィールドの指定として機能し、init プロパティと同様の動作を提供することもできます。 これにより、型、派生型、またはオブジェクト初期化子によって構築が完了する前にフィールドを割り当てることができます。

class Student
{
    public init string FirstName;
    public init string LastName;
}

var s = new Student()
{
    FirstName = "Jarde",
    LastName = "Parsons",
}

s.FirstName = "Jared"; // Error FirstName is readonly

メタデータでは、これらのフィールドは readonly フィールドと同じ方法でマークされますが、追加の属性または modreq を使用すると init スタイル フィールドであることを示すことができます。

決議 LDMは、この提案が妥当であると同意しましたが、全体的にシナリオは特性から逸脱していると感じました。 ここでは、init プロパティのみを使用することが決定されました。 これは、init プロパティがプロパティの宣言型の readonly フィールドを変更できるため、適切なレベルの柔軟性を保つことができます。 これは、シナリオを正当化する重要な顧客フィードバックがある場合に再検討されます。

型修飾子として init を許可する

readonly 修飾子を struct に適用してすべてのフィールドを readonlyとして自動的に宣言するのと同じ方法で、init のみの修飾子を struct または class に宣言して、すべてのフィールドを自動的に initとしてマークできます。 これは、次の 2 つの型宣言が同等であることを意味します。

struct Point
{
    public init int X;
    public init int Y;
}

// vs. 

init struct Point
{
    public int X;
    public int Y;
}

解決策 この機能はかわいすぎるため、ベースとなっている readonly struct 機能と競合します。 readonly struct 機能は、フィールド、メソッドなど、すべてのメンバーに readonly を適用するという点で簡単です。init struct 機能はプロパティにのみ適用されます。 これは実際には、ユーザーが混乱する結果となります。

init が型の特定の側面でのみ有効であることを考えると、型修飾子として保持するという考え方をやめました。

考慮事項

互換性

init 機能は、既存の get のみのプロパティと互換性を持つよう設計されています。 具体的には、現在は get のみのプロパティに対して、より柔軟なオブジェクト作成のセマンティクスを持たせるために完全に追加される変更を意味しています。

たとえば、次の型について考えます。

class Name
{
    public string First { get; }
    public string Last { get; }

    public Name(string first, string last)
    {
        First = first;
        Last = last;
    }
}

これらのプロパティに init を追加することは、非破壊的変更です。

class Name
{
    public string First { get; init; }
    public string Last { get; init; }

    public Name(string first, string last)
    {
        First = first;
        Last = last;
    }
}

IL 検証

.NET Core が IL 検証の再実装を決定した場合、init メンバーを考慮するようにルールを調整する必要があります。 これは、readonly データへの非変更アクセスのルール変更に含める必要があります。

IL 検証ルールは、次の 2 つに分割する必要があります。

  1. initのメンバーがreadonlyフィールドを設定できるようにします。
  2. init メンバーを法的に呼び出すことができるタイミングを決定する。

1 つ目は、既存のルールを簡単に調整することです。 IL 検証ツールは init メンバーを認識するように学習でき、そこから必要なのは、そのようなメンバーの thisreadonly フィールドを設定可能であると考慮することだけです。

2 番目のルールはより複雑です。 オブジェクト初期化子の単純なケースでは、ルールは簡単です。 new 式の結果がまだスタック上にある場合は、init メンバーを法的に呼び出せます。 つまり、値がローカル、配列要素、またはフィールドに格納されるまで、または別のメソッドに引数として渡されるまで、init メンバーを呼び出すことができます。 これにより、new 式の結果が (this以外の) 名前付き識別子に発行されると、init メンバーを法的に呼び出せなくなります。

より複雑なケースは、initメンバーとオブジェクト初期化子およびawaitを混在する場合です。 これにより、新しく作成されたオブジェクトがステート マシンに一時的にホイストされ、フィールドに配置される場合があります。

var student = new Student() 
{
    Name = await SomeMethod()
};

ここでは、Name の一湿気が発生する前に、フィールドとしてステート マシンにnew Student() の結果をホイストします。 コンパイラは、IL 検証ツールがユーザーがアクセスできないため、initの意図されたセマンティクスに違反していないことを把握するように、このようなホイスト フィールドをマークする必要があります。

init メンバー

init 修飾子は、すべてのインスタンス メンバーに適用するように拡張できます。 これにより、オブジェクトの構築中に init の概念が一般化され、構築プロセスに参加できるヘルパー メソッドを型が宣言して、init フィールドとプロパティを初期化できるようにします。

このようなメンバーには、init アクセサーがこの設計で行うすべての制限があります。 しかし、この必要性には疑問が残り、言語の将来のバージョンでは互換性のある方法でこれを安全に追加することができます。

3 つのアクセサーを生成する

init プロパティの潜在的な実装の 1 つとして、initsetから完全に分離することが挙げられます。 つまり、プロパティには、getsetinit の 3 つのアクセサーが存在する場合があります。

これには、バイナリの互換性を維持しながら、modreq を使用して正確性を適用できるという潜在的な利点があります。 実装は大まかに次のようになります。

  1. set がある場合、init アクセサーは常に出力されます。 開発者が定義していない場合、それは単に setへの参照です。
  2. オブジェクト初期化子のプロパティ一式は、常に init (存在する場合) を使用しますが、存在しない場合は set にフォールバックします。

つまり、開発者は常にプロパティから init を安全に削除できます。

この設計の欠点は、init があるときに set出力される場合にのみ有用であることです。 言語は、init が過去に削除されたかどうかを把握できず、それを想定する必要があるため、init を常に出力する必要があります。 これにより、メタデータの大幅な拡張が発生するため、この時点では、単純に互換性のコストに釣り合いません。