Stängda hierarkier

Champion-fråga: https://github.com/dotnet/csharplang/issues/9499

Sammanfattning

Tillåt att en klass deklareras closed. Detta förhindrar att direkt härledda klasser deklareras i en annan sammansättning:

// 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

Eftersom alla härledda klasser deklareras i den stängda klassens sammansättning kan ett konsumtionsuttryck switch som täcker dem alla avslutas med att "avga" den stängda klassen . Det behöver inte ange ett standardfall för att undvika varningar.

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

Motivation

Många klasstyper är inte avsedda att utökas av någon annan än deras författare, men språket ger inget sätt att uttrycka den avsikten, för att inte tala om att skydda mot att det händer. För användare av klassen innebär det att ingen uppsättning härledda klasser anses "uttömma" basklassen, och ett växeluttryck måste innehålla ett catch-all-fall för att undvika varningar.

Stängda klasser är ett sätt att ange att en uppsättning härledda klasser är kompletta och tillåter användning av kod att förlita sig på den för fullständighet i switch-uttryck.

Detaljerad design

Syntax

Tillåt closed som en modifierare för klasser. En closed klass är implicit abstrakt. Därför kan den inte också ha en sealed eller static modifierare.

Det är ett fel att uttryckligen använda en abstract modifierare i en closed klass.

En klass som härleds från en sluten klass stängs inte i sig själv om den inte uttryckligen deklareras vara det.

Begränsning av samma sammansättning

Om en klass i en sammansättning deklareras closed är det ett fel att direkt härleda från den i en annan sammansättning:

// 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

Samma begränsning gäller för moduler. En undertyp av en closed typ måste finnas i samma modul som bastypen.

Typparameterbegränsning

Om en generisk klass direkt härleds från en sluten klass måste alla dess typparametrar användas i basklassspecifikationen:

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

Den här regeln är till för att säkerställa att det finns en enda allmän instansiering av den härledda typen som "uttömmer" en given allmän instansiering av den stängda bastypen.

Obs! Den här regeln kanske inte räcker om vi tillåter slutna gränssnitt någon gång, eftersom a) klasser kan implementera flera generiska instansier av samma gränssnitt, och b) gränssnittstypparametrar kan vara co- eller contravariant. Då behöver vi förfina regeln för att säkerställa att det bara finns en allmän instansiering av en viss härledd typ per allmän instansiering av en sluten bastyp.

Fullständighet i växlar

Ett switch uttryck som hanterar alla direkta underordnade till en sluten klass anses ha uttömt den klassen. Det innebär att vissa icke-uttömmande varningar inte längre kommer att ges:

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

Å andra sidan innebär det också att det kan vara ett fel för den stängda basklassen att inträffa som ett fall efter alla dess direkta underordnade:

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

Observera: Det kanske inte finns giltiga härledda klasser för vissa generiska instansieringar av en sluten basklass. En fullständig växel behöver bara ange fall för härledda typer som faktiskt är möjliga.

Ett exempel:

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

För C<string>, till exempel, finns det ingen motsvarande instansiering av D2<...>, och inget fall för D2<...> behöver anges i en växel:

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

Fullständighet när en undertyp inte kan användas

Om en undertyp inte är giltig på en viss användningsplats, på grund av begränsningsöverträdelser, tillgänglighetsöverträdelser eller andra orsaker, går det inte att uttömma växeln via undertyper.

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.
        };
}

Detta gäller även när en generisk undertyp inte kan tala och dess tillämplighet kan bero på den slutliga typen av argumentersättning.

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.
        };
}

Undertypsbegränsningar påverkar inte fullständigheten

Språket förfinar inte bestämningen av om en undertyp är möjlig baserat på begränsningar för typparametrar i bastypen och undertypsdefinitionen.

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
        };
    }
}

Till exempel ovanstående växeluttryck, analysera inte konstruktionen D2<X>tillräckligt exakt för att inse att alla möjliga X bryter mot begränsningarna U2för . Därför förutsätter det att vissa D2<X> är möjliga och ber användaren att hantera det genom att uttömma bastypen.

Fullständighet när det inte finns några undertyper

När en stängd klass inte har några undertyper anses en tom växling över den inte vara fullständig.

Anmärkningar: Detta antas vara ett "mellanliggande tillstånd" i normal kod. Författaren kommer troligen att göra en ändring för att deklarera en undertyp i det här scenariot. Det här beteendet är en "egenhet" – trots att "alla 0 undertyper hanteras" ber språket fortfarande användaren att hantera bastypen.

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
        };
}

Fullständighet av typparametrar som är begränsade till sluten typ

En typparameter som är begränsad till en sluten klass behandlas på samma sätt som en sluten klass för fullständighetskontroller.

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
        };
}

Fastställa undertyper för en sluten klass

Fullständighet av växlar över stängda klasstyper bestäms genom att kontrollera om växeln är fullständig över uppsättningen undertyper av den stängda indataklasstypen.

Uppsättningen undertyper för en sluten klass bestäms S på följande sätt:

  1. För en viss sluten typ Cska vi C₀ vara dess ursprungliga definition.
  2. För varje undertypsdeklaration S₀ vars bastyp har den ursprungliga definitionen C₀avgör du om det finns en konstruktion S som har bastypen C.
  3. Om en S sådan finns inkluderas den i uppsättningen med undertyper.

Gränssnittskonverterbarhet för stängda klasser

En sluten klass sägs ha en förseglad hierarki, om alla dess undertyper antingen är förseglade eller har en förseglad hierarki. Det vill: alla klasser i den expanderade hierarkin är antingen förseglade eller stängda.

När en stängd klass har en förseglad hierarki introduceras en begränsning för gränssnittskonverterbarhet . Detta förhindrar försök till konvertering till gränssnittstyp, vilket aldrig skulle kunna lyckas.

Den här begränsningen liknar explicit referenskonvertering från en förseglad klasstyp till gränssnittstyp. Se §10.3.5 Explicita referenskonverteringar.

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

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

Vi avgör om den explicita referenskonverteringen från C till finns genom att I rekursivt samla in den uppsättning gränssnitt som implementeras av C och dess undertyper. Om uppsättningen med gränssnitt innehåller I, och C inte implementerar I, finns den explicita referenskonverteringen från C till I. (I det fall som C implementerar Iär en implicit referenskonvertering tillgänglig i stället.)

Sänka

Stängda klasser genereras med ett IsClosedType attribut så att de kan identifieras av en förbrukande kompilator.

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

Blockera undertypning från andra språk/kompilatorer

Stängda klasser får inte ärvas från språk som inte stöder stängda klasser. Detta uppnås genom att lägga [CompilerFeatureRequired("ClosedClasses")] till alla konstruktorer i stängda klasser.

// 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"
}

Metadatavy för C1:

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

Observera att till skillnad från funktionen "nödvändiga medlemmar" genereras inte en ObsoleteAttribute utöver CompilerFeatureRequiredAttribute. Endast det senare genereras.

Flera CompilerFeatureRequiredAttributes

I ett scenario som följande genererar kompilatorn en separat CompilerFeatureRequired, för varje nödvändig funktion som är relevant för symbolen:

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() { }
}

Nackdelar

  • Det kan vara en icke-bakåtkompatibel ändring att lägga till en closed modifierare i en befintlig klass eller att lägga till ytterligare en härledd klass från en stängd klass. Innan du publicerar en sluten klass måste författaren överväga det långsiktiga kontrakt som det innebär med sina konsumenter.

Alternatives

  • I stället för en ny closed modifierare kan en stängd klass utses med ett [Closed] attribut.
  • Omfånget för var underordnade tillåts kan begränsas ytterligare till en fil (även om det inte skulle ha mycket prejudikat i C#) eller inuti brödtexten i den stängda klassen som kapslade klasser.
  • Den stängda uppsättningen tillåtna underordnade kan anges som en lista i stället för underförstått av var deklarationer inträffar. Detta skulle göra det möjligt att inkludera klasser i andra sammansättningar.

Valfria funktioner

  • Gränssnitt kan också tillåtas att stängas. Reglerna skulle vara mycket lika.

Öppna frågor

N/A