Hierarquias Fechadas

Edição campeã: https://github.com/dotnet/csharplang/issues/9499

Resumo

Permitir que uma classe seja declarada closed. Isto impede que classes derivadas diretamente sejam declaradas numa assembleia 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 na assembly da classe fechada, uma expressão consumidora switch que cubra todas elas pode ser concluída para "esgotar" a classe fechada – não é necessário fornecer um caso por defeito 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 classes não são destinados a ser estendidos por ninguém além dos seus autores, mas a linguagem não oferece forma de expressar essa intenção, quanto mais de proteger contra o seu acontecimento. Para os consumidores da classe, isto significa que nenhum conjunto de classes derivadas será considerado como "esgotando" a classe base, e uma expressão switch precisa de incluir um caso genérico para evitar avisos.

As classes fechadas fornecem uma forma de indicar que um conjunto de classes derivadas está completo e permitem que o código consumidor dependa disso para exaustividade nas expressões dos switches.

Design Detalhado

Sintaxe

Permitir closed como modificador nas classes. Uma closed classe é implicitamente abstrata. Assim, também não pode ter um sealed modificador de ou static .

É um erro usar explicitamente um abstract modificador numa closed classe.

Uma classe derivada de uma classe fechada não é ela própria fechada a menos que seja explicitamente declarada como tal.

Restrição do mesmo conjunto

Se uma classe numa assembleia for declarada closed , então é um erro derivar diretamente dela noutra assembleia:

// 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 aplica-se aos módulos. Um subtipo de um closed tipo deve estar localizado dentro do mesmo módulo que o tipo base.

Restrição de parâmetros de tipo

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

Esta regra serve para garantir que existe uma única instância genérica do tipo derivado que "esgote" uma dada instância genérica do tipo base fechada.

Nota: Esta regra pode não ser suficiente se permitirmos interfaces fechadas em algum momento, porque a) as classes podem implementar múltiplas instâncias genéricas da mesma interface, e b) os parâmetros do tipo de interface podem ser co- ou contravariantes. Nesse ponto, teríamos de refinar a regra para continuar a garantir que só existe uma instância genérica de um dado tipo derivado por instância genérica de um tipo base fechado.

Exaustividade em interruptores

Uma switch expressão que trate de todos os descendentes diretos de uma classe fechada será considerada como tendo esgotado essa classe. Isto significa que alguns avisos de não exaustividade deixarão de ser dados:

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

Por outro lado, isto também significa que pode ser um erro para a classe base fechada ocorrer como um caso após todos os seus descendentes diretos:

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

Nota: Podem não existir classes derivadas válidas para certas instâncias genéricas de uma classe base fechada. Um switch exaustivo só precisa de 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[]> { ... }

Para C<string>, por exemplo, não existe uma instância correspondente de D2<...>, e nenhum caso para D2<...> precisa de ser dado num comutador:

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

Exaustividade quando um subtipo não pode ser usado

Se um subtipo não for válido num determinado local de uso, devido a violações de restrições, acessibilidade ou outras razões, então não é possível esgotar o switch através dos 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.
        };
}

Isto também se aplica quando um subtipo genérico não é falável, e a sua aplicabilidade pode depender da substituição final do argumento do tipo.

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

As restrições de subtipo não afetam a exaustividade

A linguagem não refina a determinação de se um subtipo é possível com base em restrições sobre parâmetros de tipo na definição de tipo base e 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 troca acima não analisam a construção D2<X>com precisão suficiente, para perceber que todas as possíveis X violações de restrições de U2. Portanto, assume que algum D2<X> é possível e pede ao utilizador que o trate esgotando o tipo base.

Exaustividade quando não existem subtipos

Quando uma classe fechada não tem subtipos, uma troca vazia sobre ela não é considerada exaustiva.

Observações: Isto é assumido como um "estado intermédio" no código normal. O autor provavelmente fará uma alteração para declarar um subtipo neste cenário. Este comportamento equivale a uma "peculiaridade" — apesar de "todos os subtipos 0 serem tratados", a linguagem ainda pede ao utilizador para tratar do 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
        };
}

Exaustividade dos parâmetros de tipo restritos ao tipo fechado

Um parâmetro de tipo restrito a uma classe fechada é tratado de forma semelhante como uma classe fechada para efeitos de verificação de exaustividade.

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

Determinação de subtipos de uma classe fechada

A exaustividade das comutações sobre tipos de classe fechada é determinada verificando se a comutaçã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 forma:

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

Convertibilidade de interface de classes fechadas

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

Quando uma classe fechada tem uma hierarquia selada, então é introduzida uma restrição de conversibilidade na interface . Isto impede uma tentativa de conversão para tipo de interface, que nunca poderia ter sucesso.

Esta restrição é semelhante em natureza à conversão explícita de referência de um tipo de classe selada para um tipo de interface. Ver §10.3.5 Conversões de referências explícitas.

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 explícita de referência de C para I existe, reunindo recursivamente o conjunto de interfaces implementadas por C e os seus subtipos. Se o conjunto de interfaces inclui I, e C não implementa I, então a conversão explícita de referência existe de C para I. (No caso de implementar CI, então está disponível uma conversão implícita de referência.)

Rebaixamento

As classes fechadas são geradas com um IsClosedType atributo, para permitir que sejam reconhecidas por um compilador consumidor.

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

Bloqueio de subtipagem de outras linguagens/compiladores

As classes fechadas não devem ser herdadas de linguagens que não suportam classes fechadas. Isto é conseguido somando [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"
}

"Vista" de metadados de C1:

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

Note que, ao contrário da funcionalidade "membros obrigatórios", um ObsoleteAttribute não é emitido além do CompilerFeatureRequiredAttribute. Apenas esta última é emitida.

Múltiplos Atributos de FuncionalidadeRequerida do Compilador

Num cenário como o seguinte, o compilador emitirá um , separado CompilerFeatureRequiredpara cada característica necessária que seja 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 decisiva adicionar um closed modificador a uma classe existente, ou adicionar uma classe derivada adicional a partir de uma classe fechada. Antes de publicar uma aula fechada, o autor deve considerar o contrato de longo prazo que implica com os seus consumidores.

Alternatives

  • Em vez de um novo closed modificador, uma classe fechada podia ser designada com um [Closed] atributo.
  • O âmbito onde os descendentes são permitidos poderia ser restringido ainda mais a um ficheiro (embora isso não tivesse muitos precedentes em C#) ou ao interior do corpo da classe fechada como classes aninhadas.
  • O conjunto fechado dos descendentes permitidos poderia ser dado como uma lista em vez de implícito pelo local onde ocorrem declarações. Isto permitiria a inclusão de classes noutras assembleias.

Funcionalidades opcionais

  • As interfaces também podiam ser permitidas para serem fechadas. As regras seriam muito semelhantes.

Perguntas abertas

N/A