Hierarquias Fechadas

Problema do especialista: https://github.com/dotnet/csharplang/issues/9499

Resumo

Permitir que uma classe seja declarada closed. Isso impede que classes derivadas diretamente sejam declaradas em um assembly diferente:

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

Como todas as classes derivadas são declaradas no assembly da classe fechada, uma expressão de switch consumo que abrange todas elas pode ser concluída para "esgotar" a classe fechada - ela não precisa fornecer um caso padrão para evitar avisos.

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

Motivação

Muitos tipos de classe não se destinam a ser estendidos por ninguém além de seus autores, mas a linguagem não fornece nenhuma maneira de expressar essa intenção, muito menos se proteger contra isso acontecer. Para os consumidores da classe, isso significa que nenhum conjunto de classes derivadas será considerado para "esgotar" a classe base e uma expressão de comutador precisa incluir um caso catch-all para evitar avisos.

Classes fechadas fornecem uma maneira de indicar que um conjunto de classes derivadas está concluído e permitir que o código de consumo dependa disso para esgotamento em expressões de comutador.

Design detalhado

Sintaxe

Permitir closed como modificador em classes. Uma closed classe é implicitamente abstrata. Portanto, ele também não pode ter um modificador ou static um sealed modificador.

É um erro usar explicitamente um abstract modificador em uma closed classe.

Uma classe derivada de uma classe fechada não é fechada, a menos que explicitamente declarada como sendo.

Restrição do mesmo assembly

Se uma classe em um assembly for declarada closed , será um erro derivar diretamente dela em outro 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

A mesma restrição se aplica a módulos. Um subtipo de um closed tipo deve estar localizado no mesmo módulo que o tipo base.

Restrição de parâmetro de tipo

Se uma classe genérica deriva diretamente de uma classe fechada, todos os parâmetros de tipo devem ser usados na especificação de 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

Essa regra é garantir que haja uma única instanciação genérica do tipo derivado que "esgota" uma determinada instanciação genérica do tipo base fechado.

Nota: Essa regra pode não ser suficiente se permitirmos interfaces fechadas em algum momento, porque a) classes podem implementar várias instanciações genéricas da mesma interface e os parâmetros de tipo de interface b) podem ser co-ou contravariantes. Nesse ponto, precisamos refinar a regra para continuar a garantir que haja apenas uma instanciação genérica de um determinado tipo derivado por instanciação genérica de um tipo de base fechado.

Esgotamento em comutadores

Uma switch expressão que manipula todos os descendentes diretos de uma classe fechada será considerada como se tivesse esgotado essa classe. Isso significa que alguns avisos de não esgotamento não serão mais dados:

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

Por outro lado, isso também significa que pode ser um erro para a classe base fechada ocorrer como um caso depois de todos os seus descendentes diretos:

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

Observação: Pode não existir classes derivadas válidas para determinadas instanciações genéricas de uma classe base fechada. Um comutador exaustivo só precisa especificar casos para tipos derivados que são realmente possíveis.

Por exemplo:

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

Por C<string>exemplo, não há nenhuma instanciação correspondente de D2<...>, e nenhum caso precisa D2<...> ser dado em um comutador:

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

Esgotamento quando um subtipo não pode ser usado

Se um subtipo não for válido em um determinado site de uso, devido a violações de restrição, violações de acessibilidade ou outros motivos, não será possível esgotar a opção por meio de subtipos.

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

Isso também se aplica quando um subtipo genérico não é falavel e sua aplicabilidade pode depender da substituição de argumento de tipo final.

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

Restrições de subtipo não afetam o esgotamento

O idioma não refina a determinação de se um subtipo é possível com base em restrições em parâmetros de tipo no tipo base e na definição de subtipo.

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

Por exemplo, as expressões de comutador acima não analisam a construção com precisãoD2<X> suficiente para perceber que todas as restrições possíveis X de violação .U2 Portanto, ele pressupõe que alguns D2<X> sejam possíveis e solicita ao usuário que o manipule esgotando o tipo base.

Esgotamento quando não existem subtipos

Quando uma classe fechada não tem subtipos, um comutador vazio sobre ela não é considerado exaustivo.

Comentários: Supõe-se que este seja um "estado intermediário" no código normal. O autor provavelmente fará uma alteração para declarar um subtipo neste cenário. Esse comportamento equivale a uma "peculiaridade"– apesar de "todos os 0 subtipos serem tratados", o idioma ainda pede ao usuário para manipular o tipo 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
        };
}

Esgotamento dos parâmetros de tipo restritos ao tipo fechado

Um parâmetro de tipo restrito a uma classe fechada é tratado da mesma forma como uma classe fechada para fins de verificações de esgotamento.

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

Determinando subtipos de uma classe fechada

O esgotamento dos comutadores sobre tipos de classe fechados é determinado verificando se a opção é exaustiva sobre o conjunto de subtipos do tipo de classe fechada de entrada.

O conjunto de subtipos S de uma classe fechada é determinado da seguinte maneira:

  1. Para um determinado tipo Cfechado, que seja C₀ sua definição original.
  2. Para cada declaração S₀ de subtipo cujo tipo base tem definição C₀original, determine se existe uma construção S que tenha o tipo Cbase.
  3. Se tal S existir, ele será incluído no conjunto de subtipos.

Conversibilidade de interface de classes fechadas

Diz-se que uma classe fechada tem uma hierarquia selada, se todos os seus subtipos estiverem lacrados ou tiverem uma hierarquia selada. Ou seja, todas as classes na hierarquia expandida são lacradas ou fechadas.

Quando uma classe fechada tem uma hierarquia lacrada, uma restrição de conversibilidade de interface é introduzida. Isso impede a tentativa de conversão em um tipo de interface, que nunca poderia ter êxito.

Essa restrição é semelhante, por natureza, à conversão de referência explícita de um tipo de classe lacrado em um tipo de interface. Consulte as conversões de referência explícitas do §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 { }

Determinamos se a conversão de referência explícita de C para I existe, reunindo recursivamente o conjunto de interfaces implementadas por C e seus subtipos. Se o conjunto de interfaces incluir Ie não implementarI, a conversão de referência explícita existirá de C para I.C (No caso que C implementa I, uma conversão de referência implícita está disponível em vez disso.)

Abaixamento

Classes fechadas são geradas com um IsClosedType atributo, para permitir que elas sejam reconhecidas por um compilador de consumo.

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

Bloquear subtipagem de outros idiomas/compiladores

Classes fechadas não devem ser herdadas de idiomas que não dão suporte a classes fechadas. Isso é feito adicionando [CompilerFeatureRequired("ClosedClasses")] a todos os construtores de classes fechadas.

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

Metadados "view" de C1:

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

Observe que, ao contrário do recurso "membros necessários", um ObsoleteAttribute não é emitido além do CompilerFeatureRequiredAttribute. Somente o último é emitido.

Multiple CompilerFeatureRequiredAttributes

Em um cenário como o seguinte, o compilador emitirá um recurso separado CompilerFeatureRequiredpara cada recurso necessário relevante para o símbolo:

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

Desvantagens

  • Pode ser uma alteração significativa adicionar um closed modificador a uma classe existente ou adicionar uma classe derivada adicional de uma classe fechada. Antes de publicar uma classe fechada, o autor precisa considerar o contrato de longo prazo que implica com seus consumidores.

Alternatives

  • Em vez de um novo closed modificador, uma classe fechada pode ser designada com um [Closed] atributo.
  • O escopo de onde os descendentes são permitidos pode ser restringido ainda mais a um arquivo (embora isso não tenha muito precedente em C#) ou para dentro do corpo da classe fechada como classes aninhadas.
  • O conjunto fechado de descendentes permitidos poderia ser dado como uma lista em vez de implícito pelo local em que as declarações ocorrem. Isso permitiria a inclusão de classes em outros assemblies.

Recursos opcionais

  • As interfaces também podem ser fechadas. As regras seriam muito semelhantes.

Perguntas abertas

N/A