Jerarquías cerradas

Problema planteado por el experto: https://github.com/dotnet/csharplang/issues/9499

Resumen

Permitir que una clase se declare closed. Esto impide que las clases derivadas directamente se declaren en un ensamblado 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

Dado que todas las clases derivadas se declaran en el ensamblado de la clase cerrada, una expresión de switch consumo que cubre todas ellas se puede concluir para "agotar" la clase cerrada, no es necesario proporcionar un caso predeterminado para evitar advertencias.

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

Motivación

Muchos tipos de clase no están diseñados para ser extendidos por nadie, sino por sus autores, pero el lenguaje no proporciona ninguna manera de expresar esa intención, y mucho menos protegerse contra ello. En el caso de los consumidores de la clase , esto significa que no se considerará ningún conjunto de clases derivadas para "agotar" la clase base y una expresión switch debe incluir un caso catch-all para evitar advertencias.

Las clases cerradas proporcionan una manera de indicar que se ha completado un conjunto de clases derivadas y permitir el consumo de código para confiar en eso para la exhaustiva en las expresiones de conmutador.

Diseño detallado

Syntax

Permitir closed como modificador en las clases. Una closed clase es implícitamente abstracta. Por lo tanto, tampoco puede tener un sealed modificador o static .

Es un error usar explícitamente un abstract modificador en una closed clase .

Una clase derivada de una clase cerrada no se cierra a menos que se declare explícitamente.

Restricción del mismo ensamblado

Si se declara closed una clase en un ensamblado, se produce un error para derivar directamente de ella en otro ensamblado:

// 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 misma restricción se aplica a los módulos. Un subtipo de un closed tipo debe encontrarse dentro del mismo módulo que el tipo base.

Restricción de parámetros de tipo

Si una clase genérica deriva directamente de una clase cerrada, todos sus parámetros de tipo deben usarse en la especificación de clase 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 regla es asegurarse de que hay una instancia genérica única del tipo derivado que "agota" una instancia genérica determinada del tipo base cerrado.

Nota: Esta regla puede no ser suficiente si se permiten interfaces cerradas en algún momento, ya que las clases a) pueden implementar varias instancias genéricas de la misma interfaz y b) los parámetros de tipo de interfaz pueden ser co-o contravariantes. En este momento, tendríamos que refinar la regla para seguir asegurándose de que solo hay una instancia genérica de un tipo derivado determinado por creación de instancias genéricas de un tipo base cerrado.

Exhaustiva en modificadores

Una switch expresión que controla todos los descendientes directos de una clase cerrada se considerará que ha agotado esa clase. Esto significa que ya no se proporcionarán algunas advertencias de no exhaustivas:

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

Por otro lado, esto también significa que puede ser un error para que la clase base cerrada se produzca como un caso después de todos sus descendientes directos:

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

Nota: Es posible que no existan clases derivadas válidas para determinadas instancias genéricas de una clase base cerrada. Un modificador exhaustivo solo necesita especificar casos para los tipos derivados que son realmente posibles.

Por ejemplo:

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

Por C<string>ejemplo, no hay ninguna instancia correspondiente de D2<...>y no es necesario proporcionar ningún caso D2<...> en un modificador:

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

Exhaustividad cuando no se puede usar un subtipo

Si un subtipo no es válido en un sitio de uso determinado, debido a infracciones de restricciones, infracciones de accesibilidad u otros motivos, no es posible agotar el modificador a través 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.
        };
}

Esto también se aplica cuando no se puede hablar de un subtipo genérico y su aplicabilidad puede depender de la sustitución del 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.
        };
}

Las restricciones de subtipo no afectan a la exhaustiva

El lenguaje no refina la determinación de si un subtipo es posible en función de las restricciones de los parámetros de tipo en la definición de tipo base y 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 ejemplo, las expresiones de modificador anteriores, no analizan la construcción D2<X> con precisión suficiente, para darse cuenta de que todas las posibles X infracciones de restricciones de U2. Por lo tanto, supone que algunos D2<X> son posibles y pide al usuario que lo controle al agotar el tipo base.

Exhaustividad cuando no existen subtipos

Cuando una clase cerrada no tiene subtipos, un modificador vacío no se considera exhaustivo.

Observaciones: se supone que es un "estado intermedio" en el código normal. Lo más probable es que el autor realice un cambio para declarar un subtipo en este escenario. Este comportamiento equivale a una "peculiaridad", a pesar de "todos los 0 subtipos que se administran", el lenguaje pide al usuario que controle el 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
        };
}

Exhaustiva de los parámetros de tipo restringidos al tipo cerrado

Un parámetro de tipo restringido a una clase cerrada se trata de forma similar como una clase cerrada para fines de comprobaciones de exhaustivas.

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

Determinación de subtipos de una clase cerrada

La exhaustividad de los conmutadores sobre tipos de clase cerrados se determina comprobando si el modificador es exhaustivo sobre el conjunto de subtipos del tipo de clase cerrada de entrada.

El conjunto de subtipos S de una clase cerrada se determina de la siguiente manera:

  1. Para un tipo Ccerrado determinado, deje que C₀ sea su definición original.
  2. Para cada declaración S₀ de subtipo cuyo tipo base tiene definición C₀original, determine si existe una construcción S que tiene el tipo Cbase .
  3. Si existe, S se incluye en el conjunto de subtipos.

Convertibilidad de interfaz de clases cerradas

Se dice que una clase cerrada tiene una jerarquía sellada, si todos sus subtipos están sellados o tienen una jerarquía sellada. Es decir, todas las clases de la jerarquía expandida están selladas o cerradas.

Cuando una clase cerrada tiene una jerarquía sellada, se introduce una restricción de convertibilidad de interfaz . Esto evita que se intente realizar una conversión al tipo de interfaz, que nunca se pudo realizar correctamente.

Esta restricción es similar a la conversión de referencia explícita de un tipo de clase sellado al tipo de interfaz. Consulte §10.3.5 Conversiones de referencia 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 si existe la conversión de referencia explícita de C a I , mediante la recopilación recursiva del conjunto de interfaces implementadas por C y sus subtipos. Si el conjunto de interfaces incluye Iy C no implementa I, la conversión de referencia explícita existe de C a I. (En caso de que C implemente I, hay disponible una conversión de referencia implícita en su lugar).

Reducción

Las clases cerradas se generan con un IsClosedType atributo para permitir que un compilador que los reconozca.

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

Bloqueo de subtipos de otros lenguajes o compiladores

Las clases cerradas no se heredan de los idiomas que no admiten clases cerradas. Esto se logra agregando [CompilerFeatureRequired("ClosedClasses")] a todos los constructores de clases cerradas.

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

Metadatos "vista" de C1:

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

Tenga en cuenta que, a diferencia de la característica "miembros necesarios", no se emite un elemento ObsoleteAttribute además de CompilerFeatureRequiredAttribute. Solo se emite la última.

Multiple CompilerFeatureRequiredAttributes

En un escenario como el siguiente, el compilador emitirá un elemento independiente CompilerFeatureRequiredpara cada característica necesaria que sea relevante para el 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() { }
}

Inconvenientes

  • Puede ser un cambio importante para agregar un closed modificador a una clase existente o para agregar una clase derivada adicional de una clase cerrada. Antes de publicar una clase cerrada, el autor debe tener en cuenta el contrato a largo plazo que implica con sus consumidores.

Alternatives

  • En lugar de un nuevo closed modificador, se podría designar una clase cerrada con un [Closed] atributo .
  • El ámbito de donde se permiten descendientes se puede restringir más a un archivo (aunque eso no tendría mucho precedente en C#) o dentro del cuerpo de la clase cerrada como clases anidadas.
  • El conjunto cerrado de descendientes permitidos se podría dar como una lista en lugar de implícita por dónde se producen las declaraciones. Esto permitiría la inclusión de clases en otros ensamblados.

Características opcionales

  • También se podría permitir que las interfaces se cierren. Las reglas serían muy similares.

Preguntas abiertas

N/A