Geschlossene Hierarchien

Champion Issue: https://github.com/dotnet/csharplang/issues/9499

Zusammenfassung

Zulassen, dass eine Klasse deklariert closedwird. Dadurch wird verhindert, dass direkt abgeleitete Klassen in einer anderen Assembly deklariert werden:

// Assembly 1
public closed record class GateState;
public record class Closed : GateState;
public record class Open(float Percent) : GateState;

// Assembly 2
public record class Locked : GateState; // ERROR - 'GateState' is a closed class

Da alle abgeleiteten Klassen in der Assembly der geschlossenen Klasse deklariert werden, kann ein verbrauchender switch Ausdruck, der alle erfasst, auf "Ausschöpfung" der geschlossenen Klasse abgeschlossen werden . Es muss kein Standardfall bereitgestellt werden, um Warnungen zu vermeiden.

// Assembly 3
GateState state = ...;
string description = state switch
{
    Closed => "closed",
    Open(var percent) => $"{percent}% open"
    // No warning about missing cases
}; 

Motivation

Viele Klassentypen sollen nicht von jedem erweitert werden, sondern von ihren Autoren, aber die Sprache bietet keine Möglichkeit, diese Absicht auszudrücken, lassen Sie sich allein davor schützen. Für Consumer der Klasse bedeutet dies, dass keine Gruppe abgeleiteter Klassen als "Ausschöpfung" der Basisklasse betrachtet wird, und ein Switchausdruck muss einen Catch-All-Case enthalten, um Warnungen zu vermeiden.

Geschlossene Klassen bieten eine Möglichkeit, anzugeben, dass eine Reihe abgeleiteter Klassen abgeschlossen ist, und die Verwendung von Code zur Erschöpfenheit in Switch-Ausdrücken ermöglichen.

Detailliertes Design

Syntax

Als Modifizierer für Klassen zulassen closed . Eine closed Klasse ist implizit abstrakt. Daher kann es nicht auch einen sealed Modifizierer oder static einen Modifizierer haben.

Fehler beim expliziten Verwenden eines abstract Modifizierers für eine closed Klasse.

Eine klasse, die von einer geschlossenen Klasse abgeleitet wird, wird nicht selbst geschlossen, es sei denn, sie wurde explizit deklariert.

Identische Assemblyeinschränkung

Wenn eine Klasse in einer Assembly deklariert closed wird, handelt es sich um einen Fehler, der direkt von der Klasse in einer anderen Assembly abgeleitet werden soll:

// Assembly 1
public closed class CC { ... } 
public class CO : CC { ... }     // Ok, same assembly

// Assembly 2
public class C1 : CC { ... }     // Error, 'CC' is closed and in a different assembly
public class C2 : CO { ... }     // Ok, 'CO' is not closed

Die gleiche Einschränkung gilt für Module. Ein Untertyp eines closed Typs muss sich innerhalb desselben Moduls wie der Basistyp befinden.

Typparametereinschränkung

Wenn eine generische Klasse direkt von einer geschlossenen Klasse abgeleitet wird, müssen alle zugehörigen Typparameter in der Basisklassenspezifikation verwendet werden:

closed class C<T> { ... }
class D1<U> : C<U> { ... }   // Ok, 'U' is used in base class
class D2<V> : C<V[]> { ... } // Ok, 'V' is used in base class
class D3<W> : C<int> { ... } // Error, 'W' is not used in base class

Diese Regel besteht darin, sicherzustellen, dass eine einzelne generische Instanziierung des abgeleiteten Typs vorhanden ist, die eine bestimmte generische Instanziierung des geschlossenen Basistyps ausschließt.

Hinweis: Diese Regel reicht möglicherweise nicht aus, wenn geschlossene Schnittstellen zu einem bestimmten Zeitpunkt zugelassen werden, da a) Klassen mehrere generische Instanziierungen derselben Schnittstelle implementieren können, und b) Schnittstellentypparameter können ko- oder kontravariant sein. An diesem Punkt müssen wir die Regel verfeinern, um weiterhin sicherzustellen, dass nur eine generische Instanziierung eines bestimmten abgeleiteten Typs pro generische Instanziierung eines geschlossenen Basistyps vorhanden ist.

Erschöpfend in Schaltern

Ein switch Ausdruck, der alle direkten Nachfolger einer geschlossenen Klasse behandelt, wird als erschöpfend betrachtet. Dies bedeutet, dass einige nicht erschöpfende Warnungen nicht mehr gegeben werden:

CC cc = ...;
_ = cc switch
{
    CO co => ...,
    // No warning about non-exhaustive switch
};

Auf der anderen Seite bedeutet dies auch, dass es sich bei der geschlossenen Basisklasse um einen Fehler handeln kann, der als Fall nach allen direkten Nachfolgern auftritt:

_ = cc switch
{
    CO co => ...,
    CC cc => ..., // Error, case cannot be reached
};

Hinweis: Für bestimmte generische Instanziationen einer geschlossenen Basisklasse sind möglicherweise keine gültigen abgeleiteten Klassen vorhanden. Ein vollständiger Switch muss nur Fälle für abgeleitete Typen angeben, die tatsächlich möglich sind.

Beispiel:

closed class C<T> { ... }
class D1<U> : C<U> { ... }
class D2<V> : C<V[]> { ... }

Für C<string>beispielsweise gibt es keine entsprechende Instanziierung von D2<...>, und es muss kein Fall D2<...> für einen Schalter gegeben werden:

C<string> cs = ...;
_ = cs switch
{
    D1<string> d1 => ...,
    // No need for a 'D2<...>' case - no instantiation corresponds to 'C<string>'
}

Erschöpfend, wenn ein Untertyp nicht verwendet werden kann

Wenn ein Untertyp auf einer bestimmten Nutzungswebsite aufgrund von Einschränkungen, Barrierefreiheitsverletzungen oder anderen Gründen nicht gültig ist, ist es nicht möglich, den Schalter über Untertypen zu erschöpfen.

closed class C;
class D1 : C;
class Container
{
    protected class D2 : C;
}

class Program
{
    int M(C c)
        => c switch
        {
            D1 => 1,
            // warning: switch is non-exhaustive. Pattern 'C' is not handled.
        };
}

Dies gilt auch, wenn ein generischer Untertyp nicht sprechen kann, und seine Anwendbarkeit kann von der endgültigen Typargumentersetzung abhängen.

closed class C<T> { ... }
class D1<U> : C<U> { ... }
class D2<V> : C<V[]> { ... }

class Program
{
    int M<X>(C<X> c)
        => c switch
        {
            D1<X> => 1,
            // warning: switch is non-exhaustive. Pattern 'C' is not handled.
        };
}

Untertypeinschränkungen wirken sich nicht auf Erschöpfendheit aus

Die Sprache verfeinern nicht die Bestimmung, ob ein Untertyp auf der Grundlage von Einschränkungen für Typparameter in der Basistyp- und Untertypdefinition möglich ist.

closed class C<T>;
class D1<U1> : C<U1>;
class D2<U2> : C<U2> where U2 : struct;

class Program
{
    int M1<X>(C<X> c) where X : class
    {
        // warning: switch is not exhaustive. Pattern 'C<X>' is not handled.
        return c switch
        {
            D1<X> => 1,
        };
    }

    int M2<X>(C<X> c) where X : class
    {
        return c switch
        {
            D1<X> => 1,
            C<X> => 2, // ok
        };
    }
}

Beispielsweise analysieren die oben genannten Schalterausdrücke die Konstruktion D2<X> nicht genau genug, um zu erkennen, dass alle möglichen X Einschränkungen U2verletzen. Daher wird davon ausgegangen, dass einige D2<X> möglich sind, und fordert den Benutzer auf, ihn durch Erschöpfung des Basistyps zu behandeln.

Erschöpfend, wenn keine Untertypen vorhanden sind

Wenn eine geschlossene Klasse keine Untertypen aufweist, wird ein leerer Schalter nicht als erschöpfend betrachtet.

Hinweise: Dies wird als "Zwischenzustand" im normalen Code angenommen. Der Autor nimmt wahrscheinlich eine Änderung vor, um einen Untertyp in diesem Szenario zu deklarieren. Dieses Verhalten ergibt sich aus einer "Quirk"- obwohl "alle 0 Untertypen behandelt werden", fordert die Sprache den Benutzer dennoch auf, den Basistyp zu behandeln.

closed class C;

class Program
{
    int M1(C c)
        // warning: switch is not exhaustive.
        => c switch
        {
        };

    int M2(C c)
        => c switch
        {
            C => 1, // ok
        };
}

Erschöpfende Typparameter, die auf einen geschlossenen Typ beschränkt sind

Ein typparameter, der auf eine geschlossene Klasse beschränkt ist, wird als geschlossene Klasse für Erschöpfendeprüfungen behandelt.

closed class C;
class D1 : C;
class D2 : C;

class Program
{
    int M1<X>(X x) where X : C
        => x switch
        {
            D1 => 1,
            D2 => 2,
        };

    int M2<X>(X x) where X : C
        => x switch
        {
            D1 => 1,
            D2 => 2,
            C => 3, // error: 'C' is subsumed by the previous cases
        };
}

Ermitteln von Untertypen einer geschlossenen Klasse

Die Erschöpfendheit von Schaltern über geschlossene Klassentypen wird bestimmt, indem überprüft wird, ob der Schalter über den Satz von Untertypen des geschlossenen Eingabeklassentyps erschöpfend ist.

Der Satz von Untertypen S einer geschlossenen Klasse wird wie folgt bestimmt:

  1. Lassen Sie uns C₀ für einen bestimmten geschlossenen Typ Cseine ursprüngliche Definition sein.
  2. Ermitteln Sie für jede Untertypdeklaration S₀ , deren Basistyp die ursprüngliche Definition C₀aufweist, ob eine Konstruktion S vorhanden ist, die den Basistyp Caufweist.
  3. Wenn eine S solche vorhanden ist, ist sie in der Gruppe von Untertypen enthalten.

Schnittstellenkonvertierbarkeit geschlossener Klassen

Eine geschlossene Klasse soll eine versiegelte Hierarchie aufweisen, wenn alle Untertypen entweder versiegelt sind oder eine versiegelte Hierarchie haben. Das heißt, alle Klassen in der erweiterten Hierarchie sind entweder versiegelt oder geschlossen.

Wenn eine geschlossene Klasse über eine versiegelte Hierarchie verfügt, wird eine Schnittstellenkonvertierbarkeitseinschränkung eingeführt. Dadurch wird verhindert, dass eine Konvertierung in den Schnittstellentyp versucht wird, was möglicherweise nie erfolgreich war.

Diese Einschränkung ähnelt der expliziten Verweiskonvertierung von einem versiegelten Klassentyp in den Schnittstellentyp. Siehe §10.3.5 Explizite Referenzkonvertierungen.

var c = new C();
var i = (I)c; // error

closed class C { }
sealed class D1 : C { }
sealed class D2 : C { }
interface I { }

Wir bestimmen, ob die explizite Verweiskonvertierung vorhanden CI ist, indem rekursiv die Gruppe von Schnittstellen gesammelt wird, die von C und ihren Untertypen implementiert werden. Wenn der Satz von Schnittstellen enthält Iund nicht implementiert Iwird, ist die explizite Verweiskonvertierung von C zu I.C (In dem Fall, das C implementiert wird I, ist stattdessen eine implizite Verweiskonvertierung verfügbar.)

Reduzierung

Geschlossene Klassen werden mit einem IsClosedType Attribut generiert, damit sie von einem verbrauchenden Compiler erkannt werden können.

namespace System.Runtime.CompilerServices
{
    [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
    public sealed class IsClosedTypeAttribute : Attribute { }
}

Blockieren von Untertypisierungen aus anderen Sprachen/Compilern

Geschlossene Klassen dürfen nicht von Sprachen geerbt werden, die geschlossene Klassen nicht unterstützen. Dazu fügen Sie alle Konstruktoren geschlossener Klassen hinzu [CompilerFeatureRequired("ClosedClasses")] .

// Authoring assembly, built with .NET 10 SDK
closed class C1
{
    public C1() { }
    public C1(int param) { }
}

// Consuming assembly, built with .NET 8 SDK
class C2 : C1
{
    public C2() { } // error: 'C1.C1()' requires compiler feature "ClosedClasses"
    public C2() : base(42) { } // error: 'C1.C1(int)' requires compiler feature "ClosedClasses"
}

Metadaten-Ansicht von C1:

[IsClosedType]
class C1
{
    [CompilerFeatureRequired("ClosedClasses")]
    public C1() { }
    [CompilerFeatureRequired("ClosedClasses")]
    public C1(int param) { }
}

Beachten Sie, dass im Gegensatz zum Feature "erforderliche Member" ein ObsoleteAttribute nicht zusätzlich zum CompilerFeatureRequiredAttribute ausgegeben wird. Nur letzteres wird ausgegeben.

Multiple CompilerFeatureRequiredAttributes

In einem Szenario wie dem folgenden gibt der Compiler für jedes erforderliche Feature, das für das Symbol relevant ist, ein separates CompilerFeatureRequiredFeature aus:

closed class C1
{
    public C() { }
    public required string P { get; set; }
}

// Metadata:
class C1
{
    [Obsolete("Types with required members are not supported in this version of your compiler")]
    [CompilerFeatureRequired("RequiredMembers")]
    [CompilerFeatureRequired("ClosedClasses")]
    public C1() { }
}

Nachteile

  • Es kann sich um eine bahnbrechende Änderung handeln, um einer vorhandenen Klasse einen closed Modifizierer hinzuzufügen oder eine zusätzliche abgeleitete Klasse aus einer geschlossenen Klasse hinzuzufügen. Vor der Veröffentlichung einer geschlossenen Klasse muss der Autor den langfristigen Vertrag berücksichtigen, den er mit seinen Verbrauchern impliziert.

Alternatives

  • Anstelle eines neuen closed Modifizierers könnte eine geschlossene Klasse mit einem [Closed] Attribut festgelegt werden.
  • Der Bereich, in dem Nachfolger zulässig sind, könnte weiter auf eine Datei beschränkt werden (obwohl dies in C# nicht viel Vorgänger hat) oder innerhalb des Textkörpers der geschlossenen Klasse als geschachtelte Klassen.
  • Der geschlossene Satz zulässiger Nachfolger kann als Liste anstelle von impliziten Deklarationen angegeben werden. Dies würde die Einbeziehung von Klassen in andere Assemblys ermöglichen.

Optionale Funktionen

  • Schnittstellen können auch geschlossen werden. Die Regeln wären sehr ähnlich.

Offene Fragen

N/A