Gerarchie chiuse

Questione prioritaria: https://github.com/dotnet/csharplang/issues/9499

Sommario

Consentire la dichiarazione closeddi una classe . Ciò impedisce che le classi derivate direttamente vengano dichiarate in un assembly diverso:

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

Poiché tutte le classi derivate vengono dichiarate nell'assembly della classe chiusa, è possibile concludere un'espressione che switch copre tutti questi elementi per "esaurire" la classe chiusa. Non è necessario fornire un caso predefinito per evitare avvisi.

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

Motivazione

Molti tipi di classe non sono destinati ad essere estesi da nessuno, ma il linguaggio non fornisce alcun modo per esprimere tale finalità, e non sorvegliarlo. Per i consumer della classe ciò significa che nessun set di classi derivate verrà considerato "esaurimento" della classe base e un'espressione switch deve includere un caso catch-all per evitare avvisi.

Le classi chiuse offrono un modo per indicare che un set di classi derivate è completo e consentire l'utilizzo del codice per l'esaustività nelle espressioni switch.

Progettazione dettagliata

Sintassi

Consenti closed come modificatore nelle classi. Una closed classe è implicitamente astratta. Pertanto, non può avere anche un sealed modificatore o static .

Si tratta di un errore per usare in modo esplicito un abstract modificatore in una closed classe.

Una classe derivata da una classe chiusa non viene chiusa a meno che non venga dichiarata in modo esplicito.

Restrizione dello stesso assembly

Se una classe in un assembly viene dichiarata closed , si tratta di un errore di derivazione diretta da essa in un altro assembly:

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

La stessa restrizione si applica ai moduli. Un sottotipo di un closed tipo deve trovarsi all'interno dello stesso modulo del tipo di base.

Restrizione dei parametri di tipo

Se una classe generica deriva direttamente da una classe chiusa, tutti i relativi parametri di tipo devono essere usati nella specifica della classe base:

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

Questa regola consiste nel garantire che sia presente una singola istanza generica del tipo derivato che "esaurisce" una determinata istanza generica del tipo di base chiuso.

Nota: Questa regola potrebbe non essere sufficiente se si consentono interfacce chiuse a un certo punto, perché a) le classi possono implementare più istanze generiche della stessa interfaccia e b) i parametri del tipo di interfaccia possono essere co-o controvarianti. A questo punto, è necessario perfezionare la regola per continuare a garantire che sia presente una sola istanza generica di un determinato tipo derivato per ogni creazione di un'istanza generica di un tipo di base chiuso.

Esaustività nei commutatori

Un'espressione switch che gestisce tutti i discendenti diretti di una classe chiusa verrà considerata esaurita. Ciò significa che alcuni avvisi di non esaustività non verranno più forniti:

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

D'altra parte questo significa anche che può essere un errore per la classe base chiusa che si verifica come caso dopo tutti i relativi discendenti diretti:

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

Nota: Potrebbero non esistere classi derivate valide per determinate istanze generiche di una classe base chiusa. Un commutatore esaustivo deve specificare solo i casi per i tipi derivati effettivamente possibili.

Per esempio:

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

Per C<string>, ad esempio, non esiste alcuna istanza corrispondente di D2<...>e non è necessario specificare alcun caso D2<...> in un'opzione:

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

Esaustività quando non è possibile usare un sottotipo

Se un sottotipo non è valido in un sito di utilizzo specifico, a causa di violazioni di vincoli, violazioni dell'accessibilità o altri motivi, non è possibile esaurire l'opzione tramite sottotipi.

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

Questo vale anche quando un sottotipo generico non è parlabile e la relativa applicabilità può dipendere dalla sostituzione dell'argomento di tipo finale.

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

I vincoli di sottotipo non influiscono sull'esaustività

Il linguaggio non affina la determinazione della possibilità di un sottotipo in base ai vincoli sui parametri di tipo nella definizione del tipo di base e del sottotipo.

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

Ad esempio, le espressioni switch precedenti, non analizzare la costruzione D2<X> con precisione sufficiente, per rendersi conto che tutti i possibili X vincoli violano .U2 Pertanto, presuppone che alcuni D2<X> sia possibile e chiede all'utente di gestirlo esaurendo il tipo di base.

Esaustività quando non esistono sottotipi

Quando una classe chiusa non ha sottotipi, un passaggio vuoto su di esso non è considerato esaustivo.

Osservazioni: si presuppone che questo sia uno "stato intermedio" nel codice normale. L'autore eseguirà probabilmente una modifica per dichiarare un sottotipo in questo scenario. Questo comportamento equivale a un "nonrk", nonostante "tutti i 0 sottotipi gestiti", la lingua chiede comunque all'utente di gestire il tipo di base.

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

Esaustività dei parametri di tipo vincolati al tipo chiuso

Un parametro di tipo vincolato a una classe chiusa viene considerato analogamente a una classe chiusa ai fini dei controlli di completezza.

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

Determinazione dei sottotipi di una classe chiusa

L'esaustività dei commutatori sui tipi di classe chiusi è determinata dal controllo se l'opzione è esaustiva rispetto al set di sottotipi del tipo di classe chiusa di input.

Il set di sottotipi S di una classe chiusa viene determinato nel modo seguente:

  1. Per un determinato tipo Cchiuso , lasciare C₀ che sia la relativa definizione originale.
  2. Per ogni dichiarazione S₀ di sottotipo il cui tipo di base ha una definizione C₀originale , determinare se esiste una costruzione S con tipo di Cbase .
  3. S Se tale esiste, viene incluso nel set di sottotipi.

Convertibilità dell'interfaccia delle classi chiuse

Una classe chiusa ha una gerarchia sealed, se tutti i relativi sottotipi sono sealed o hanno una gerarchia sealed. Ovvero, tutte le classi nella gerarchia espansa sono sealed o chiuse.

Quando una classe chiusa ha una gerarchia sealed, viene introdotta una restrizione di convertibilità dell'interfaccia . In questo modo si impedisce di tentare una conversione al tipo di interfaccia, che potrebbe non riuscire mai.

Questa restrizione è simile alla conversione esplicita dei riferimenti da un tipo di classe sealed a un tipo di interfaccia. Vedere conversioni di riferimento esplicite di §10.3.5.

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

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

Si determina se la conversione esplicita dei riferimenti da C a I esiste, raccogliendo in modo ricorsivo il set di interfacce implementate da C e i relativi sottotipi. Se il set di interfacce include Ie C non implementa I, la conversione di riferimento esplicita esiste da C a I. Nel caso in cui C implementi I, è invece disponibile una conversione di riferimento implicita.

Abbassamento

Le classi chiuse vengono generate con un IsClosedType attributo, per consentire loro di essere riconosciute da un compilatore che utilizza.

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

Blocco della sottotipizzazione da altri linguaggi/compilatori

Le classi chiuse non devono essere ereditate da linguaggi che non supportano classi chiuse. Questa operazione viene eseguita aggiungendo [CompilerFeatureRequired("ClosedClasses")] a tutti i costruttori di classi chiuse.

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

Metadati "view" di C1:

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

Si noti che, a differenza della funzionalità "membri obbligatori", un elemento ObsoleteAttribute non viene generato oltre a CompilerFeatureRequiredAttribute. Solo quest'ultimo viene generato.

Multiple CompilerFeatureRequiredAttributes

In uno scenario simile al seguente, il compilatore genererà un oggetto separato CompilerFeatureRequiredper ogni funzionalità necessaria rilevante per il simbolo:

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

Svantaggi

  • Può trattarsi di una modifica che causa un'interruzione per aggiungere un closed modificatore a una classe esistente o per aggiungere una classe derivata aggiuntiva da una classe chiusa. Prima di pubblicare una classe chiusa, l'autore deve prendere in considerazione il contratto a lungo termine che implica con i relativi consumer.

Alternative

  • Invece di un nuovo closed modificatore, è possibile designare una classe chiusa con un [Closed] attributo .
  • L'ambito di in cui i discendenti sono consentiti può essere limitato ulteriormente a un file (anche se ciò non avrebbe un sacco di precedenti in C#) o all'interno del corpo della classe chiusa come classi nidificate.
  • Il set chiuso di discendenti consentiti può essere assegnato come elenco anziché implicito da dove si verificano le dichiarazioni. Ciò consentirebbe l'inclusione di classi in altri assembly.

Funzionalità facoltative

  • È anche possibile chiudere le interfacce. Le regole sarebbero molto simili.

Domande aperte

N/A