Compartir vía


Mejoras de struct de bajo nivel

Nota:

Este artículo es una especificación de características. La especificación actúa como documento de diseño de la característica. Incluye cambios de especificación propuestos, junto con la información necesaria durante el diseño y el desarrollo de la característica. Estos artículos se publican hasta que se finalizan los cambios de especificación propuestos y se incorporan en la especificación ECMA actual.

Puede haber algunas discrepancias entre la especificación de características y la implementación completada. Esas diferencias se recogen en las notas de la reunión de diseño de lenguaje (LDM) correspondientes.

Puede obtener más información sobre el proceso de adopción de especificaciones de características en el estándar del lenguaje C#, en el artículo sobre especificaciones.

Resumen

Esta propuesta es una recopilación de varias propuestas para mejoras en el rendimiento de struct: campos ref y la capacidad de modificar valores predeterminados de ciclo de vida. El objetivo es un diseño que tiene en cuenta las diversas propuestas para crear un único conjunto de características general para mejoras de struct de bajo nivel.

Nota: Las versiones anteriores de esta especificación utilizaban los términos "ref-safe-to-escape" y "safe-to-escape", que se introdujeron en la especificación de la función Span safety. El comité estándar de ECMA cambió los nombres a "ref-safe-context" y "safe-context", respectivamente. Los valores de safe context se han refinado para utilizar "declaration-block", "function-member" y "caller-context" de forma coherente. Los speclets habían utilizado una redacción diferente para estos términos, y también utilizaban "safe-to-return" como sinónimo de "caller-context". Esta especificación se ha actualizado para usar los términos del estándar C# 7.3.

No todas las características descritas en este documento se han implementado en C# 11. C# 11 incluye:

  1. Campos ref y scoped
  2. [UnscopedRef]

Estas características siguen siendo propuestas abiertas para una versión futura de C#:

  1. Campos ref en ref struct
  2. Tipos restringidos sunset

Motivación

Las versiones anteriores de C# agregaron una serie de características de rendimiento de bajo nivel al lenguaje: ref devuelve, ref struct, punteros de función, etc. Esto permitió a los desarrolladores de .NET escribir código de alto rendimiento mientras continuaban aprovechando las reglas del lenguaje C# para la seguridad de tipos y memoria. También permitió la creación de tipos de rendimiento fundamentales en las bibliotecas de .NET, como Span<T>.

A medida que estas funciones han ido ganando terreno en el ecosistema .NET, los desarrolladores, tanto internos como externos, nos han ido facilitando información sobre los puntos de fricción que aún existen en el ecosistema. Son espacios donde es necesario suprimir código unsafe para llevar a cabo su trabajo o se necesita tiempo de ejecución en casos especiales, como Span<T>.

Actualmente, se resuelve Span<T> mediante el tipo internal ByReference<T> que el tiempo de ejecución maneja eficazmente como un campo ref. Esto aporta la ventaja de los campos ref, pero con la desventaja de que el lenguaje no ofrece ninguna comprobación de seguridad, como se hace con otros usos de ref. Además, solo dotnet/runtime puede usar este tipo como internal, por lo que los terceros no pueden diseñar sus propias primitivas basadas en campos ref. Parte de la motivación de este trabajo consiste en eliminar ByReference<T> y usar los campos ref adecuados en todas las bases de código.

Esta propuesta tiene pensado abordar estos problemas basándose en nuestras características de bajo nivel existentes. En concreto, tiene como objetivo:

  • Se permite que los tipos ref struct declaren campos ref.
  • Permitir que el tiempo de ejecución defina completamente Span<T> mediante el sistema de tipos de C# y elimine tipos de casos especiales como ByReference<T>
  • Permitir que los tipos struct devuelvan ref en sus respectivos campos.
  • Permitir que el tiempo de ejecución elimine los usos de unsafe causados por las limitaciones de las configuraciones predeterminadas de ciclo de vida
  • Permitir la declaración de búferes fixed seguros para tipos administrados y no administrados en struct

Diseño detallado

Las reglas de seguridad de ref struct se definen en el documento de seguridad de span según los términos anteriores. Esas reglas se han incorporado al estándar de C# 7 en §9.7.2 y §16.4.12. Este documento describirá los cambios necesarios en este documento como resultado de esta propuesta. Una vez aceptada como característica aprobada, estos cambios se incorporarán a ese documento.

Una vez completado este diseño, nuestra definición de Span<T> será la siguiente:

readonly ref struct Span<T>
{
    readonly ref T _field;
    readonly int _length;

    // This constructor does not exist today but will be added as a part 
    // of changing Span<T> to have ref fields. It is a convenient, and
    // safe, way to create a length one span over a stack value that today 
    // requires unsafe code.
    public Span(ref T value)
    {
        _field = ref value;
        _length = 1;
    }
}

Habilitar campos ref y scoped

El lenguaje permitirá a los desarrolladores declarar campos ref dentro de un ref struct. Esto puede ser útil, por ejemplo, al encapsular instancias de struct mutable de gran tamaño o al definir tipos de alto rendimiento como Span<T> en bibliotecas además del entorno de ejecución.

ref struct S 
{
    public ref int Value;
}

Se emitirá un campo ref en los metadatos mediante la firma ELEMENT_TYPE_BYREF. Esto no varía de cómo se emiten variables locales ref o argumentos ref. Por ejemplo, ref int _field se emitirá como ELEMENT_TYPE_BYREF ELEMENT_TYPE_I4. Esto nos requerirá actualizar ECMA335 para permitir esta entrada, pero esto debería ser bastante sencillo.

Los desarrolladores pueden seguir inicializando un ref struct con un campo ref mediante la expresión default, en cuyo caso todos los campos ref declarados tendrán el valor null. Cualquier intento de usar estos campos generará una excepción en NullReferenceException.

ref struct S 
{
    public ref int Value;
}

S local = default;
local.Value.ToString(); // throws NullReferenceException

Aunque el lenguaje C# pretende que una ref no puede ser null, esto es correcto en el nivel de tiempo de ejecución y tiene semántica bien definida. Los desarrolladores que incluyan campos ref en sus tipos deben tener en cuenta esta posibilidad y deben evitar todo lo posible que se filtre este detalle en el código que se va a usar. En su lugar, los campos ref deben validarse como valores "no null" usando los asistentes de tiempo de ejecución y generar una excepción cuando se utiliza incorrectamente un struct sin inicializar.

ref struct S1 
{
    private ref int Value;

    public int GetValue()
    {
        if (System.Runtime.CompilerServices.Unsafe.IsNullRef(ref Value))
        {
            throw new InvalidOperationException(...);
        }

        return Value;
    }
}

Un campo ref se puede combinar con modificadores readonly de las siguientes maneras:

  • readonly ref: se trata de un campo que no se puede reasignar fuera de un constructor o métodos de init. No obstante, se puede asignar un valor fuera de esos contextos
  • ref readonly: se trata de un campo que se puede reasignar como ref, pero que nunca se puede asignar a un valor. Así se puede reasignar un parámetro in a un campo ref.
  • readonly ref readonly: una combinación de ref readonly y readonly ref.
ref struct ReadOnlyExample
{
    ref readonly int Field1;
    readonly ref int Field2;
    readonly ref readonly int Field3;

    void Uses(int[] array)
    {
        Field1 = ref array[0];  // Okay
        Field1 = array[0];      // Error: can't assign ref readonly value (value is readonly)
        Field2 = ref array[0];  // Error: can't repoint readonly ref
        Field2 = array[0];      // Okay
        Field3 = ref array[0];  // Error: can't repoint readonly ref
        Field3 = array[0];      // Error: can't assign ref readonly value (value is readonly)
    }
}

Un readonly ref struct requerirá que los campos ref se declaren como readonly ref. No es necesario declararlos como readonly ref readonly. Esto permite que un readonly struct tenga mutaciones indirectas por medio de dicho campo, pero esto no es diferente de un campo readonly que enlaza actualmente con un tipo de referencia (consulte más detalles)

Se emitirá un readonly ref a los metadatos usando la marca initonly, de la misma manera que se haría con cualquier otro campo. Un campo ref readonly será atribuido con System.Runtime.CompilerServices.IsReadOnlyAttribute. Se emitirá un readonly ref readonly con ambos elementos.

Esta característica requiere compatibilidad en tiempo de ejecución y cambios en la especificación ECMA. Por lo tanto, solo se habilitarán cuando la marca de característica correspondiente esté establecida en corelib. El seguimiento del problema de la API exacta se puede consultar aquí https://github.com/dotnet/runtime/issues/64165

El conjunto necesario de cambios en nuestras reglas de contexto seguro para permitir los campos ref es pequeño y específico. Las reglas ya tienen en cuenta los campos ref existentes y que se van a consumir en las API. Los cambios solo deben centrarse en dos aspectos: cómo se crean y cómo se reasignan las ref.

En primer lugar, es necesario actualizar las reglas que establecen los valores ref-safe-context para los campos ref de la siguiente forma:

Una expresión en la forma ref e.Fref-safe-context, como sigue:

  1. Si F es un campo ref, su valor ref-safe-context será el valor safe-context de e.
  2. De lo contrario, si e pertenece a un tipo de referencia, tendrá un valor ref-safe-context de caller-context
  3. Por otro lado, el valor ref-safe-context se toma del valor ref-safe-context de e.

Esto no representa un cambio de regla, ya que las reglas siempre han tenido en cuenta que el estado ref exista en el interior de un ref struct. Este es, de hecho, el modo en que el estado ref en Span<T> siempre ha funcionado, y las reglas de consumo lo tienen en cuenta correctamente. El cambio aquí consiste simplemente en asegurar que los desarrolladores puedan acceder directamente a los campos de ref y garantizar que lo hagan siguiendo las reglas existentes que se aplican implícitamente a Span<T>.

Esto significa que los campos ref se pueden devolver como ref desde un ref struct, pero no los campos normales.

ref struct RS
{
    ref int _refField;
    int _field;

    // Okay: this falls into bullet one above. 
    public ref int Prop1 => ref _refField;

    // Error: This is bullet four above and the ref-safe-context of `this`
    // in a `struct` is function-member.
    public ref int Prop2 => ref _field;
}

Esto puede parecer un error a primera vista, pero se trata de un punto de diseño deliberado. Sin embargo, esta propuesta no crea una nueva regla, sino que reconoce las reglas existentes de Span<T> tal como se han comportado hasta ahora, permitiendo que los desarrolladores declaren su propio estado ref.

Luego, se deben ajustar las reglas para la reasignación de ref en presencia de campos ref. El contexto principal para la reasignación de ref son los constructores de ref struct que almacenan parámetros ref en campos ref. La compatibilidad será más general, pero este es el escenario principal. Para adaptarse a esto, las reglas de reasignación de ref se ajustan para tener en cuenta los campos ref, tal como se indica a continuación:

Reglas de reasignación de ref

El operando izquierdo del operador = ref debe ser una expresión que se enlaza a una variable local ref, un parámetro ref (distinto de this), un parámetro out, o un campo ref.

Para una reasignación de referencia en el formato e1 = ref e2, ambas de las siguientes condiciones deben ser verdaderas:

  1. e2 debe tener contexto seguro para referencias al menos tan grande como el contexto seguro para referencias de e1
  2. e1 debe tener el mismo contexto seguro que e2Nota

Esto significa que el constructor de Span<T> deseado funciona sin ninguna anotación adicional:

readonly ref struct Span<T>
{
    readonly ref T _field;
    readonly int _length;

    public Span(ref T value)
    {
        // Falls into the `x.e1 = ref e2` case, where `x` is the implicit `this`. The 
        // safe-context of `this` is *return-only* and ref-safe-context of `value` is 
        // *caller-context* hence this is legal.
        _field = ref value;
        _length = 1;
    }
}

El cambio en las reglas de reasignación de ref supone que los parámetros ref ahora pueden salir de un método como un campo ref en un valor ref struct. Tal como se describe en la sección de aspectos sobre compatibilidad, esto puede cambiar las reglas de las API existentes en las que nunca se pretendía que los parámetros ref se separen como campo ref. Las reglas de duración de los parámetros se basan únicamente en su declaración no en su uso. Todos los parámetros ref y in tienen valores ref-safe-context de caller-context y, por ello, ahora se pueden devolver mediante ref o un campo ref. Para admitir las API con parámetros ref que pueden ser de escape o no y así restaurar la semántica de del sitio de llamada de C# 10, el lenguaje introducirá anotaciones de ciclo de vida limitado.

Modificador scoped

La palabra clave scoped se usará para restringir la duración de un valor. Se puede aplicar a un ref o a un valor que sea un ref struct y consiga reducir el ciclo de vida ref-safe-context o safe-context, respectivamente, en el function-member. Por ejemplo:

Parámetro o variable local ref-safe-context safe-context
Span<int> s function-member caller-context
scoped Span<int> s function-member function-member
ref Span<int> s caller-context caller-context
scoped ref Span<int> s function-member caller-context

En esta relación, el ref-safe-context de un valor no puede ser más extenso que el safe-context.

Esto permite que las API de C# 11 se anoten de forma que tengan las mismas reglas que C# 10:

Span<int> CreateSpan(scoped ref int parameter)
{
    // Just as with C# 10, the implementation of this method isn't relevant to callers.
}

Span<int> BadUseExamples(int parameter)
{
    // Legal in C# 10 and legal in C# 11 due to scoped ref
    return CreateSpan(ref parameter);

    // Legal in C# 10 and legal in C# 11 due to scoped ref
    int local = 42;
    return CreateSpan(ref local);

    // Legal in C# 10 and legal in C# 11 due to scoped ref
    Span<int> span = stackalloc int[42];
    return CreateSpan(ref span[0]);
}

La anotación scoped también significa que el parámetro this de un struct ahora se puede definir como scoped ref T. Anteriormente tenía que tratarse de manera especial en las reglas como parámetro ref, que tenía reglas de contexto ref-seguro diferentes a las de otros parámetros ref, como se observa en todas las referencias a la inclusión o exclusión del receptor en las reglas de contexto seguro. Ahora se puede expresar como un concepto general en todas las reglas que las simplifican aún más.

La anotación scoped también se puede aplicar a las siguientes ubicaciones:

  • variables locales: esta anotación establece el ciclo de vida como safe-context o ref-safe-context en caso de una variable local ref, respecto del function-member, independientemente del ciclo de vida del inicializador.
Span<int> ScopedLocalExamples()
{
    // Error: `span` has a safe-context of *function-member*. That is true even though the 
    // initializer has a safe-context of *caller-context*. The annotation overrides the 
    // initializer
    scoped Span<int> span = default;
    return span;

    // Okay: the initializer has safe-context of *caller-context* hence so does `span2` 
    // and the return is legal.
    Span<int> span2 = default;
    return span2;

    // The declarations of `span3` and `span4` are functionally identical because the 
    // initializer has a safe-context of *function-member* meaning the `scoped` annotation
    // is effectively implied on `span3`
    Span<int> span3 = stackalloc int[42];
    scoped Span<int> span4 = stackalloc int[42];
}

El resto de usos de scoped en variables locales se describen a continuación.

La anotación scoped no se puede aplicar a ninguna otra ubicación, incluidos los retornos, los campos, los elementos de matriz, etc. Además, aunque scoped tiene impacto cuando se aplica a cualquier ref, in o out solo tiene impacto cuando se aplica a valores que son ref struct. Si se presentan declaraciones como scoped int, esto no tiene ningún impacto porque siempre se puede devolver un ref struct de forma segura. El compilador creará un diagnóstico para estos casos para evitar confusiones para desarrolladores.

Cambiar el comportamiento de los parámetros de out

Para limitar aún más el impacto del cambio de compatibilidad de hacer que los parámetros ref y in se devuelvan como campos ref, el lenguaje cambiará el valor predeterminado ref-safe-context en los parámetros out para que sean function-member. Efectivamente, los parámetros out se declaran como scoped out implícitamente de ahora en adelante. Desde una punto de vista de compatibilidad, esto significa que no pueden ser devueltos por ref:

ref int Sneaky(out int i) 
{
    i = 42;

    // Error: ref-safe-context of out is now function-member
    return ref i;
}

Esto aumentará la flexibilidad de las API que devuelven valores ref struct y tienen parámetros out, ya que ya no es necesario tener en cuenta el parámetro que se captura por referencia. Esto es importante porque es un patrón común en las API de estilo lector:

Span<byte> Read(Span<byte> buffer, out int read)
{
    // .. 
}

Span<byte> Use()
{
    var buffer = new byte[256];

    // If we keep current `out` ref-safe-context this is an error. The language must consider
    // the `read` parameter as returnable as a `ref` field
    //
    // If we change `out` ref-safe-context this is legal. The language does not consider the 
    // `read` parameter to be returnable hence this is safe
    int read;
    return Read(buffer, out read);
}

El lenguaje tampoco considerará que los argumentos pasados al parámetro out se puedan devolver. Considerar la entrada en un parámetro out como que se puede devolver ha sido algo muy confuso para los desarrolladores. Básicamente desvirtúa el propósito de out al obligar a los desarrolladores a tener en cuenta el valor pasado por el llamador que nunca se utiliza, excepto en los lenguajes que no respetan out. En el futuro, los idiomas que admiten ref struct deben asegurarse de que el valor original pasado a un parámetro out nunca se lea.

C# lo logra a través de las reglas de asignación definitivas. Esto cumple con nuestras reglas de contexto seguras de referencia, además de permitir que el código existente asigne y luego devuelva los valores de los parámetros out.

Span<int> StrangeButLegal(out Span<int> span)
{
    span = default;
    return span;
}

Juntos, estos cambios implican que el argumento de un parámetro out no aporta valores de contexto seguro ni de contexto seguro de referencia a las invocaciones de método. Esto reduce significativamente el impacto general de compatibilidad de los campos de ref, y simplifica la forma en que los desarrolladores piensan en out. Un argumento para un parámetro out no contribuye a la devolución, es simplemente una salida.

Inferir safe-context de expresiones de declaración

El safe-context de la variable de una declaración en un argumento out (M(x, out var y)) o deconstrucción ((var x, var y) = M()) es el más limitado de los siguientes:

  • contexto del llamador
  • Si la variable out está marcada con scoped, sería declaration-block (es decir, function-member o algo más limitado).
  • Si el tipo de la variable out es ref struct, se tienen en cuenta todos los argumentos de la invocación contenedora, incluido el receptor:
    • safe-context de cualquier argumento en el que el parámetro correspondiente no sea out y tenga el safe-context de return-only o un valor más extenso
    • ref-safe-context de cualquier argumento en el que el parámetro correspondiente que tenga un ref-safe-context de return-only o un valor más extenso

Consulte también Ejemplos de safe-context inferidos de expresiones de declaración.

Parámetros declarados scoped implícitamente

En general, hay dos ubicaciones de ref que se declaran implícitamente como scoped:

  • this en un método de instancia struct
  • Parámetros out

Las reglas de ref safe context se redactarán en relación con scoped ref y ref. Con fines de contexto seguro ref, un parámetro de in es equivalente a ref y out es equivalente a scoped ref. Tanto in como out solo se mencionarán específicamente cuando sea importante para la semántica de la regla. De lo contrario, solo se consideran ref y scoped ref respectivamente.

Al analizar el ref-safe-context de los argumentos correspondientes a los parámetros in, se generalizarán como ref en la especificación. En el caso de que el argumento sea un lvalue, el ref-safe-context será el del lvalue; si no, será function-member. De nuevo, in solo se mencionará aquí cuando sea importante para la semántica de la regla actual.

Safe context: return-only

En el diseño también se necesita incluir un nuevo safe-context: return-only. Esto es similar a caller-context que se puede devolver pero que solo se puede devolver a través de una declaración return.

Los detalles de return-only representan que es un contexto que es mayor que function-member pero más pequeño que caller-context. Una expresión generada por una declaración return debe ser al menos return-only. Como tal, la mayoría de las reglas existentes se descartan. Por ejemplo, la asignación a un parámetro ref de una expresión con un safe-context de return-only generará un error porque es más pequeño que el safe-context del parámetro ref, que se corresponde con caller-context. La necesidad de este nuevo contexto de escape se explica a continuación.

Hay tres ubicaciones que tienen como predeterminado return-only:

  • Un parámetro ref o in tendrá un ref-safe-context de return-only. Esto se hace en parte para evitar que con ref struct se produzcan problemas inadvertidos de asignación cíclica. Sin embargo, se realiza de forma uniforme para simplificar el modelo, así como para reducir al mínimo los cambios de compatibilidad.
  • Un parámetro out de un ref struct tendrá un safe-context de return-only. Esto permite que la devolución y el valor out sean igualmente expresivos. Esto no viene acompañado del problema de asignación cíclica porque out se declara implícitamente como scoped, por lo que el ref-safe-context sigue siendo más pequeño que el safe-context.
  • Un parámetro this de un constructor struct tendrá un safe-context de return-only. Esto se debe a que se modela como parámetros out.

Cualquier expresión o declaración que devuelva explícitamente el valor de un método o lambda debe tener un safe-context y, si procede, un ref-safe-context de al menos return-only. Esto incluye declaraciones return, miembros con forma de expresión y expresiones lambda.

Del mismo modo, cualquier asignación en un out debe tener un safe-context de al menos return-only. Sin embargo, esto no es un caso especial, esto solo sigue a las reglas de asignación existentes.

Nota: Una expresión cuyo tipo no sea un tipo ref struct siempre tiene un safe-context de caller-context.

Reglas para la invocación de métodos

Las reglas de contexto seguro ref para la invocación de método se actualizarán de varias maneras. El primero es reconocer el impacto que scoped tiene en los argumentos. Para un argumento determinado expr que se pasa al parámetro p:

  1. Si p es scoped ref, expr no contribuirá con ref-safe-context al considerar los argumentos.
  2. Si p es scoped, expr no contribuirá con ref-safe-context al considerar los argumentos.
  3. Si p es out, expr no contribuirá con ref-safe-context o safe-contextconsulte más detalles

La expresión "no contribuye" significa que los argumentos no se tienen en cuenta al calcular el valor ref-safe-context o safe-context de la devolución del método, respectivamente. Esto se debe a que los valores no pueden contribuir a esa duración de vida, ya que la anotación scoped lo impide.

Ahora se pueden simplificar las reglas de invocación de método. El receptor ya no tiene que ser tratado de manera especial; ahora, en el caso de struct, es solo un scoped ref T. Las reglas de los valores deben cambiar para tener en cuenta las devoluciones de campo ref.

El valor final de una invocación de método e1.M(e2, ...), donde M() no devuelve ref-to-ref-struct, tiene un safe-context tomado del valor más limitado de las siguientes:

  1. El caller-context
  2. Cuando el valor devuelto es un ref struct, el safe-context contribuido por todas las expresiones de argumento
  3. Cuando el valor devuelto es un ref struct, el ref-safe-context contribuido por todos los argumentos ref

Si M() devuelve un ref-to-ref-struct, el safe-context es equivalente al safe-context de todos los argumentos que son ref-to-ref-struct. Será un error si hay varios argumentos con diferentes safe-context, ya que los argumentos de método deben coincidir.

Las reglas de llamada de ref se pueden simplificar para:

El valor final de una invocación de método ref e1.M(e2, ...), donde M() no devuelve ref-to-ref-struct, es un ref-safe-context tomado del valor más limitado de los siguientes contextos:

  1. El caller-context
  2. El safe-context contribuido por todas las expresiones de argumento
  3. El ref-safe-context contribuido por todos los argumentos ref

Si M() devuelve ref-to-ref-struct, el ref-safe-context será el valor ref-safe-context más reducido, contribuido por todos los argumentos que sean ref-to-ref-struct.

Esta regla ahora nos permite definir las dos variantes de métodos deseados:

Span<int> CreateWithoutCapture(scoped ref int value)
{
    // Error: value Rule 3 specifies that the safe-context be limited to the ref-safe-context
    // of the ref argument. That is the *function-member* for value hence this is not allowed.
    return new Span<int>(ref value);
}

Span<int> CreateAndCapture(ref int value)
{
    // Okay: value Rule 3 specifies that the safe-context be limited to the ref-safe-context
    // of the ref argument. That is the *caller-context* for value hence this is not allowed.
    return new Span<int>(ref value);
}

Span<int> ComplexScopedRefExample(scoped ref Span<int> span)
{
    // Okay: the safe-context of `span` is *caller-context* hence this is legal.
    return span;

    // Okay: the local `refLocal` has a ref-safe-context of *function-member* and a 
    // safe-context of *caller-context*. In the call below it is passed to a 
    // parameter that is `scoped ref` which means it does not contribute 
    // ref-safe-context. It only contributes its safe-context hence the returned
    // rvalue ends up as safe-context of *caller-context*
    Span<int> local = default;
    ref Span<int> refLocal = ref local;
    return ComplexScopedRefExample(ref refLocal);

    // Error: similar analysis as above but the safe-context of `stackLocal` is 
    // *function-member* hence this is illegal
    Span<int> stackLocal = stackalloc int[42];
    return ComplexScopedRefExample(ref stackLocal);
}

Reglas para inicializadores de objetos

El contexto seguro de una expresión de inicializador de objeto es el más restrictivo de:

  1. El safe-context de la llamada del constructor.
  2. El safe-context y el ref-safe-context de los argumentos en los indexadores de inicializadores de miembros que pueden escapar al receptor.
  3. El safe-context del RHS de las asignaciones en inicializadores de miembros en "métodos set" que no sean de solo lectura (non-readonly) o ref-safe-context en caso de asignación de ref.

Otra manera de dar forma a esto es considerar cualquier argumento como un inicializador de miembro que se pueda asignar al receptor como si fuera un argumento para el constructor. Esto se debe a que el inicializador de miembro es efectivamente una llamada de constructor.

Span<int> heapSpan = default;
Span<int> stackSpan = stackalloc int[42];
var x = new S(ref heapSpan)
{
    Field = stackSpan;
}

// Can be modeled as 
var x = new S(ref heapSpan, stackSpan);

Este modelado es importante porque demuestra que nuestro MAMM debe tener muy en cuenta los inicializadores de miembros. Tenga en cuenta que este caso concreto debe ser ilegal, ya que permite asignar un valor con un contexto seguro más estrecho a uno más amplio.

Los argumentos del método deben coincidir

La presencia de campos ref significa que las reglas relativas a los argumentos del método deben ser actualizadas, ya que un parámetro ref ahora puede almacenarse como un campo en un argumento ref struct para el método. Anteriormente, la regla solo tenía que considerar otro ref struct almacenado como un campo. El impacto de esto se describe en aspectos sobre compatibilidad. La nueva regla es ...

Para cualquier invocación de método e.M(a1, a2, ... aN)

  1. Calcule el safe-context más reducido en:
    • caller-context
    • El safe-context de todos los argumentos
    • El ref-safe-context de todos los argumentos ref cuyos parámetros correspondientes tengan un ref-safe-context de caller-context
  2. Todos los argumentos ref de los tipos ref struct se deben poder asignar mediante un valor con ese safe-context. Este es un caso en el que refno se generaliza para incluir in y out

Para cualquier invocación de método e.M(a1, a2, ... aN)

  1. Calcule el safe-context más reducido en:
    • caller-context
    • El safe-context de todos los argumentos
    • El ref-safe-context de todos los argumentos ref cuyos parámetros correspondientes no sean scoped
  2. Todos los argumentos out de los tipos ref struct se deben poder asignar mediante un valor con ese safe-context.

La presencia de scoped permite a los desarrolladores reducir la fricción que crea esta regla marcando los parámetros que no se devuelven como scoped. Esto elimina los argumentos de (1) en los dos casos anteriores y da mayor flexibilidad a los llamadores.

El impacto de este cambio se analiza más profundamente a continuación. En general, esto permitirá a los desarrolladores hacer que los sitios de llamadas sean más flexibles anotando valores de tipo ref sin escape con scoped.

Varianza del ámbito del parámetro

El modificador scoped y el atributo [UnscopedRef] (consulte más adelante) en los parámetros también afectan a la sobrescritura de objetos, la implementación de interfaces y las reglas de conversión de delegate. La firma de una sobrescritura, la implementación de una interfaz o la conversión de delegate puede:

  • Agregar scoped a un parámetro ref o in
  • Agregar scoped a un parámetro ref struct
  • Quitar [UnscopedRef] de un parámetro out
  • Quitar [UnscopedRef] de un parámetro ref de un tipo ref struct

Cualquier otra diferencia con respecto a scoped o [UnscopedRef] se considera un desajuste.

El compilador notificará un diagnóstico para desajustes de ámbito no seguro entre invalidaciones, implementaciones de interfaz y conversiones de delegados cuando:

  • El método tiene un parámetro ref o out del tipo ref struct con una incompatibilidad al agregar [UnscopedRef] (en lugar de quitar scoped). (En este caso, es posible una asignación cíclica, por lo tanto, no se necesitan otros parámetros).
  • O bien, ambas son verdaderas:
    • El método devuelve un ref struct o devuelve un ref o ref readonly, o bien el método tiene un parámetro ref o out de tipo ref struct.
    • El método tiene al menos un parámetro adicional ref, ino out, o un parámetro de tipo ref struct.

El diagnóstico no se notifica en otros casos porque:

  • Los métodos con estas firmas no pueden capturar las referencias pasadas, por lo que cualquier error de coincidencia de ámbito no es peligroso.
  • Estos incluyen escenarios muy comunes y simples (por ejemplo, parámetros de out sin formato tradicionales que se usan en firmas de método de TryParse) e informar de discrepancias de ámbito simplemente debido a que se utilizan en la versión 11 del lenguaje (y, por lo tanto, el parámetro out tiene un ámbito diferente) sería confuso.

El diagnóstico aparece como un error si las firmas que no coinciden usan las reglas de ref safe context de C#11; de lo contrario, el diagnóstico se visualiza como una advertencia.

La advertencia de discrepancia de ámbito se puede notificar a través de un módulo compilado con las reglas de ref safe context de C#7.2 donde scoped no está disponible. En algunos casos, puede ser necesario suprimir la advertencia si no se puede modificar la otra firma no coincidente.

El modificador scoped y el atributo [UnscopedRef] también tienen los siguientes efectos en las firmas de método:

  • El modificador scoped y el atributo [UnscopedRef] no afectan a la ocultación
  • Las sobrecargas no pueden diferir solo en scoped o [UnscopedRef]

La sección sobre los campos ref y scoped es larga, por lo que se quiere concluir con un breve resumen de los cambios sustanciales propuestos.

  • Un valor que tiene ref-safe-context en el caller-context se puede devolver mediante el campo ref o ref.
  • Un parámetro out tendría un safe-context de function-member.

Notas detalladas:

  • Un campo ref solo se puede declarar dentro de un ref struct
  • Un campo ref no se puede declarar como static, volatile o const
  • Un campo ref no puede tener un tipo ref struct
  • El proceso de generación de ensamblados de referencia debe conservar la presencia de un campo de ref dentro de un ref struct
  • Un readonly ref struct debe declarar sus campos de ref como readonly ref
  • Para los valores by-ref, el modificador de scoped debe aparecer antes de in, outo ref
  • El documento de normas de seguridad de tramos se actualizará como se describe en este documento.
  • Las nuevas reglas de ref safe context se aplicarán cuando
    • La biblioteca principal contiene la marca de características que indica la compatibilidad con los campos de ref.
    • El valor de langversion es 11 o superior.

Sintaxis

13.6.2 Declaraciones de variables locales: se ha agregado 'scoped'?.

local_variable_declaration
    : 'scoped'? local_variable_mode_modifier? local_variable_type local_variable_declarators
    ;

local_variable_mode_modifier
    : 'ref' 'readonly'?
    ;

13.9.4 La declaración for: se ha añadido 'scoped'?indirectamente de local_variable_declaration.

13.9.5 La declaración foreach: se ha añadido 'scoped'?.

foreach_statement
    : 'foreach' '(' 'scoped'? local_variable_type identifier 'in' expression ')'
      embedded_statement
    ;

12.6.2 Listas de argumentos: se ha añadido 'scoped'? para la variable de declaración out.

argument_value
    : expression
    | 'in' variable_reference
    | 'ref' variable_reference
    | 'out' ('scoped'? local_variable_type)? identifier
    ;

12.7 Expresiones de deconstrucción:

[TBD]

15.6.2 Parámetros del método: se ha agregado 'scoped'? a parameter_modifier.

fixed_parameter
    : attributes? parameter_modifier? type identifier default_argument?
    ;

parameter_modifier
    | 'this' 'scoped'? parameter_mode_modifier?
    | 'scoped' parameter_mode_modifier?
    | parameter_mode_modifier
    ;

parameter_mode_modifier
    : 'in'
    | 'ref'
    | 'out'
    ;

20.2 Declaraciones de delegado: se ha añadido 'scoped'?indirectamente de fixed_parameter.

12.19 Expresiones de funciones anónimas: se ha añadido 'scoped'?.

explicit_anonymous_function_parameter
    : 'scoped'? anonymous_function_parameter_modifier? type identifier
    ;

anonymous_function_parameter_modifier
    : 'in'
    | 'ref'
    | 'out'
    ;

Tipos restringidos sunset

El compilador tiene un concepto de un conjunto de "tipos restringidos", que en gran medida no está documentado. A estos tipos se les dio un estado especial porque en C# 1.0 no había ninguna manera de expresar su comportamiento. Cabe destacar el hecho de que los tipos pueden contener referencias en la pila de ejecución. En su lugar, el compilador tenía conocimientos especiales de ellos y restringía su uso a formas que siempre serían seguras: devoluciones no permitidas, no se pueden usar como elementos de matriz, no se pueden usar en genéricos, etc.

Una vez que los campos ref están disponibles y ampliados para admitir ref struct, estos tipos se pueden definir en C# mediante una combinación de campos ref struct y ref correctamente. Por lo tanto, cuando el compilador detecta que un entorno de ejecución admite campos ref, ya no tendrá una noción de tipos restringidos. En su lugar, usará los tipos tal como se definen en el código.

Para admitir esto, nuestras reglas de contexto seguro ref se actualizarán de la siguiente manera:

  • __makeref se tratará como un método con la firma static TypedReference __makeref<T>(ref T value)
  • __refvalue se tratará como un método con la firma static ref T __refvalue<T>(TypedReference tr). La expresión __refvalue(tr, int) usará eficazmente el segundo argumento como parámetro de tipo.
  • __arglist como parámetro tendrá ref-safe-context y safe-context de function-member.
  • __arglist(...) como expresión tendrá ref-safe-context y safe-context de function-member.

Los entornos de ejecución conformes garantizarán que TypedReference, RuntimeArgumentHandle y ArgIterator se definen como ref struct. Además, TypedReference debe verse como un campo ref en un ref struct para cualquier tipo posible (puede almacenar cualquier valor). Esto combinado con las reglas anteriores garantizará que las referencias a la pila no se escapen y superen el ciclo de vida.

Nota: estrictamente hablando, esto es un detalle de implementación del compilador frente a parte del lenguaje. Sin embargo, dada la relación con los campos ref, se incluye en la propuesta de lenguaje por hacerlo más simple.

Aplicar unscoped

Uno de los puntos de fricción más importantes es la incapacidad de devolver campos mediante ref en los miembros de instancia de un struct. Esto significa que los desarrolladores no pueden crear métodos o propiedades que devuelvan ref y deben recurrir a exponer campos directamente. Esto merma la utilidad de las devoluciones de ref en struct, donde a menudo es lo más deseado.

struct S
{
    int _field;

    // Error: this, and hence _field, can't return by ref
    public ref int Prop => ref _field;
}

La justificación de esto es razonable, pero no hay nada inherentemente mal con un struct que escape this por referencia; es solo el valor predeterminado elegido por las reglas de ref safe context.

Para solucionar esto, el lenguaje pondrá lo contrario de la anotación de ciclo de vida scoped integrando un UnscopedRefAttribute. Esto se puede aplicar a cualquier ref y cambiará el contexto seguro de referencia para que sea un nivel más amplio que su valor predeterminado. Por ejemplo:

UnscopedRef aplicado a ref-safe-context original ref-safe-context nuevo
miembros de instancia function-member return-only
in / parámetro ref return-only contexto del llamador
Parámetro out function-member return-only

Al aplicar [UnscopedRef] a un método de instancia de un struct tiene el impacto de modificar el parámetro this implícito. Esto significa que this actúa como un ref no anotado del mismo tipo.

struct S
{
    int field; 

    // Error: `field` has the ref-safe-context of `this` which is *function-member* because 
    // it is a `scoped ref`
    ref int Prop1 => ref field;

    // Okay: `field` has the ref-safe-context of `this` which is *caller-context* because 
    // it is a `ref`
    [UnscopedRef] ref int Prop1 => ref field;
}

La anotación también se puede colocar en parámetros de out para restaurarlos al comportamiento de C# 10.

ref int SneakyOut([UnscopedRef] out int i)
{
    i = 42;
    return ref i;
}

A efectos de las reglas de ref safe context, este [UnscopedRef] out se considera solo un ref. Al igual que in se considera ref en cuanto al ciclo de vida.

No se permitirá la anotación [UnscopedRef] en miembros y constructores init dentro de struct. Esos miembros ya son especiales con respecto a la semántica de ref, ya que ven a los miembros de readonly como mutables. Esto supone que aplicar ref a estos miembros se presenta como un simple ref, y no un ref readonly. Esto se permite dentro del límite de los constructores y init. Si se permite [UnscopedRef], esto haría que dicho ref escapara incorrectamente fuera del constructor y permitiría la mutación después de que se hubiera aplicado la semántica de readonly.

El tipo de atributo tendrá la siguiente definición:

namespace System.Diagnostics.CodeAnalysis
{
    [AttributeUsage(
        AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Parameter,
        AllowMultiple = false,
        Inherited = false)]
    public sealed class UnscopedRefAttribute : Attribute
    {
    }
}

Notas detalladas:

  • Un método de instancia o propiedad anotado con [UnscopedRef] tiene ref-safe-context de this definido en el caller-context.
  • Un miembro anotado con [UnscopedRef] no puede implementar una interfaz.
  • Sería un error usar [UnscopedRef] en
    • Un miembro que no se declara en un struct
    • Un miembro static, un miembro init o constructor en un struct
    • Parámetro marcado scoped
    • Parámetro pasado por valor
    • Parámetro pasado por referencia que no tiene un ámbito implícito

ScopedRefAttribute

Las anotaciones scoped se emitirán en metadatos a través del atributo de tipo System.Runtime.CompilerServices.ScopedRefAttribute. El atributo se corresponderá con el nombre calificado por el espacio de nombres, por lo que la definición no necesita aparecer en ningún ensamblado específico.

El tipo ScopedRefAttribute es solo para el uso del compilador; no se permite en el origen. El compilador sintetiza la declaración de tipo si aún no está incluida en la compilación.

El tipo tendrá la siguiente definición:

namespace System.Runtime.CompilerServices
{
    [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)]
    internal sealed class ScopedRefAttribute : Attribute
    {
    }
}

El compilador emitirá este atributo en el parámetro con la sintaxis scoped. Esto solo se emitirá cuando la sintaxis haga que el valor sea diferente de su estado predeterminado. Por ejemplo, scoped out provocará que no se emita ningún atributo.

RefSafetyRulesAttribute

Son varias las diferencias en las reglas de ref safe context entre C#7.2 y C#11. Cualquiera de estas diferencias podría dar lugar a cambios importantes al volver a compilar con C#11 con referencias compiladas con C#10 o versiones anteriores.

  1. los parámetros ref/in/out no restringidos (unscoped) pueden escapar una invocación de método como un campo ref de un ref struct en C#11, pero no en C#7.2
  2. out los parámetros tienen un ámbito definido implícitamente en C#11 y no tienen un ámbito definido en C#7.2
  3. los parámetros ref / in en tipos ref struct se restringen de forma implícita en C#11 y no se restringen en C#7.2

Para reducir las probabilidades de que se produzcan cambios importantes al volver a compilar con C#11, vamos a actualizar el compilador de C#11 para usar las reglas de ref safe context para la invocación de método que se ajusten a las reglas que se usaron para analizar la declaración del método. Básicamente, al analizar una llamada a un método compilado con un compilador anterior, el compilador de C#11 usará reglas de contexto segura de referencia de C#7.2.

Para habilitarlo, el compilador emitirá un nuevo atributo [module: RefSafetyRules(11)] cuando el módulo se compile con -langversion:11 o superior o se compile con una corlib que contenga la bandera de características para campos ref.

El argumento del atributo indica la versión del idioma de las reglas del contexto seguro de referencia que se usaron cuando se compiló el módulo. Actualmente, la versión está fijada en 11 independientemente de la versión real del lenguaje que se pase al compilador.

La expectativa es que las versiones futuras del compilador actualicen las reglas de contexto seguro ref y emitan atributos con versiones distintas.

Si el compilador carga un módulo que incluye un [module: RefSafetyRules(version)]con un version distinto de 11, el compilador notificará una advertencia para la versión no reconocida si hay llamadas a métodos declarados en ese módulo.

Cuando el compilador de C#11 analiza una llamada de método:

  • Si el módulo que contiene la declaración de método incluye [module: RefSafetyRules(version)], independientemente de version, la llamada al método se analiza con reglas de C#11.
  • Si el módulo que contiene la declaración del método procede del código fuente y se compila con -langversion:11 o con una corlib que contiene la bandera de característica para campos ref, la llamada al método se analiza con las reglas de C#11.
  • Si el módulo que contiene la declaración de método hace referencia a System.Runtime { ver: 7.0 }, la llamada al método se analiza con reglas de C#11. Esta regla es una mitigación temporal para los módulos compilados con versiones preliminares anteriores de C#11 / .NET 7 y se quitarán más adelante.
  • De lo contrario, la llamada al método se analiza con reglas de C#7.2.

Un compilador anterior a C#11 omitirá cualquier RefSafetyRulesAttribute y analizará las llamadas de método solo con reglas de C#7.2.

El RefSafetyRulesAttribute se corresponderá con el nombre calificado por el espacio de nombres, por lo que la definición no necesita aparecer en ningún ensamblado específico.

El tipo RefSafetyRulesAttribute es solo para el uso del compilador; no se permite en el origen. El compilador sintetiza la declaración de tipo si aún no está incluida en la compilación.

namespace System.Runtime.CompilerServices
{
    [AttributeUsage(AttributeTargets.Module, AllowMultiple = false, Inherited = false)]
    internal sealed class RefSafetyRulesAttribute : Attribute
    {
        public RefSafetyRulesAttribute(int version) { Version = version; }
        public readonly int Version;
    }
}

Búferes de tamaño fijo seguros

Los búferes de tamaño fijo seguros no se implementaron en la versión de C# 11. Esta característica se puede implementar en una versión futura de C#.

El lenguaje relajará las restricciones en matrices de tamaño fijo de forma que se puedan declarar en código seguro y el tipo de elemento se puede administrar o no administrar. Esto hará que los tipos como los siguientes sean correctos:

internal struct CharBuffer
{
    internal char Data[128];
}

Estas declaraciones, al igual que sus equivalentes unsafe, definirán una secuencia de elementos N en el tipo contenedor. Se puede acceder a estos miembros con un indexador y también se pueden convertir en instancias de Span<T> y ReadOnlySpan<T>.

Cuando se indexa en un búfer de fixed de tipo T, se debe tener en cuenta el estado readonly del contenedor. Si el contenedor es readonly, el indexador devuelve ref readonly T; de lo contrario, devuelve ref T.

El acceso a un búfer de fixed sin un indexador no tiene ningún tipo natural; sin embargo, se puede convertir a tipos de Span<T>. En caso de que el contenedor sea readonly, el búfer se podrá convertir implícitamente en ReadOnlySpan<T>, o si no, puede convertirse implícitamente en Span<T> o ReadOnlySpan<T> (la conversión de Span<T> se considera mejor).

La instancia de Span<T> resultante tendrá una longitud igual al tamaño declarado en el búfer de fixed. El contexto seguro del valor devuelto será igual al contexto seguro del contenedor, tal como ocurriría si se accedieran a los datos de respaldo como un campo.

En cada declaración de fixed en un tipo donde el tipo de elemento es T, el lenguaje generará un método get solo indexador correspondiente cuyo tipo de valor devuelto sea ref T. El indexador se anotará con el atributo [UnscopedRef], ya que la implementación devolverá campos del tipo que se declare. La accesibilidad del miembro se corresponderá con la accesibilidad en el campo fixed.

Por ejemplo, la firma del indexador para CharBuffer.Data será la siguiente:

[UnscopedRef] internal ref char DataIndexer(int index) => ...;

Si el índice facilitado está fuera de los límites declarados de la matriz fixed, se producirá la excepción IndexOutOfRangeException. En caso de que se proporcione un valor constante, se reemplazará por una referencia directa al elemento adecuado. A menos que la constante esté fuera de los límites declarados, en cuyo caso se produciría un error en tiempo de compilación.

También se generará un descriptor de acceso designado en cada búfer fixed, que incluirá las operaciones de get y set por valor. Esto significa que los búferes fixed se parecerán más a la semántica de matriz existente al contar con un descriptor de acceso de ref, así como con operaciones get y set de tipo byval (por valor). Esto significa que los compiladores tendrán la misma flexibilidad al emitir código que consume los búferes fixed como al consumir matrices. Esto debería facilitar la ejecución de operaciones como await en búferes fixed.

Esto también tiene la ventaja adicional de los búferes fixed sean más fáciles de consumir en otros lenguajes. Los indexadores con nombre son una característica que ha existido desde la versión 1.0 de .NET. Incluso los lenguajes que no pueden emitir directamente un indexador con nombre, normalmente pueden consumirlos (C# es realmente un buen ejemplo de esto).

El almacenamiento de respaldo para el búfer se generará mediante el atributo [InlineArray]. Se trata de un mecanismo discutido en problema 12320 que permite específicamente declarar eficazmente una secuencia de campos del mismo tipo. Este problema en particular todavía está bajo discusión activa, y se espera que la implementación de esta funcionalidad siga dependiendo de cómo se desarrolle esa discusión.

Inicializadores con valores ref en expresiones new y with

En la sección 12.8.17.3 Inicializadores de objeto, hemos actualizado la gramática a:

initializer_value
    : 'ref' expression // added
    | expression
    | object_or_collection_initializer
    ;

En la sección sobre la expresión with, modificamos la gramática por lo siguiente:

member_initializer
    : identifier '=' 'ref' expression // added
    | identifier '=' expression
    ;

El operando izquierdo de la asignación debe ser una expresión que se vincule a un campo ref.
El operando derecho debe ser una expresión que genere un lvalue que designa un valor del mismo tipo que el operando izquierdo.

Introducimos una regla similar a la reasignación local de ref:
Si el operando izquierdo es una referencia que se puede escribir (es decir, designa algo distinto de un campo de ref readonly), el operando derecho debe ser un valor lvalue que se pueda escribir.

Las reglas de escape para las invocaciones de los constructores permanecen:

Una expresión new que invoca a un constructor cumple las mismas reglas que una invocación de método que se considera que devuelve el tipo que se va a construir.

Es decir, las reglas de invocación de método modificadas anteriormente:

Un rvalue derivado de una invocación de método e1.M(e2, ...) tiene safe-context en el más reducido de los contextos siguientes:

  1. El caller-context
  2. El safe-context contribuido por todas las expresiones de argumento
  3. Cuando el valor devuelto es un ref struct, el ref-safe-context contribuido por todos los argumentos ref

Para una expresión de new con inicializadores, las expresiones inicializadoras cuentan como argumentos (contribuyen a su contexto seguro ) y las expresiones inicializadoras ref cuentan como argumentos ref (contribuyen a su contexto seguro de referencia ), de forma recursiva.

Cambios en el contexto no seguro

Los tipos de puntero (sección 23.3) se extienden para permitir tipos administrados como tipo de referencia. Estos tipos de puntero se escriben como un tipo administrado seguido de un token *. Esto genera una advertencia.

El operador address-of (sección 23.6.5) se relaja para aceptar una variable con un tipo administrado como su operando.

La declaración fixed (sección 23.7) se relaja para aceptar fixed_pointer_initializer, que es la dirección de una variable de tipo administrado T o que es una expresión de un array_type con elementos de un tipo administrado T.

El inicializador de asignación de pila (sección 12.8.22) se ha relajado de manera similar.

Consideraciones

Hay consideraciones que deben tenerse en cuenta en otras partes de la pila de desarrollo al evaluar esta característica.

Consideraciones de compatibilidad

El desafío de esta propuesta son las implicaciones de compatibilidad que este diseño tiene para nuestras reglas de seguridad existentes o §9.7.2. Aunque esas reglas admiten totalmente el concepto de que un ref struct tenga campos ref, no lo permiten en las API, aparte de stackalloc, para capturar el estado ref que hace referencia a la pila. Las reglas de ref safe context tienen la premisa rígida, o §16.4.12.8, de que no existe un constructor del formulario Span(ref T value). Esto significa que las reglas de seguridad no tienen en cuenta que un parámetro de ref se pueda escapar como un campo ref, por lo que permite código como el siguiente.

Span<int> CreateSpanOfInt()
{
    // This is legal according to the 7.2 span rules because they do not account
    // for a constructor in the form Span(ref T value) existing. 
    int local = 42;
    return new Span<int>(ref local);
}

De hecho, hay tres maneras de que un parámetro ref se escape desde una invocación de método:

  1. Mediante la devolución de un valor
  2. Mediante la devolución de ref
  3. Mediante el campo ref en ref struct, que se devuelve o se pasa como parámetro ref / out

Las reglas existentes solo tienen en cuenta (1) y (2). No se aplican en (3), por lo que los elementos no contemplados, como las variables locales de devolución como campos ref, no se contabilizan. Este diseño debe cambiar las reglas que se deben aplicar (3). Esto tendrá un impacto pequeño en la compatibilidad con las API existentes. En concreto, afectará a las API que tienen las siguientes propiedades.

  • Tener un ref struct en la firma
    • Donde el ref struct es un tipo de valor devuelto, ref o parámetro out
    • Tiene un parámetro de in o ref adicional, excepto el receptor

En C# 10, los llamadores de dichas API no tenían que considerar la entrada de estado de ref para que la API se pudiera capturar como un campo ref. Esto permitió que existieran varios patrones de manera segura en C# 10, pero que no serán seguros en C# 11 debido a la posibilidad de que ref escape como un campo ref. Por ejemplo:

Span<int> CreateSpan(ref int parameter)
{
    // The implementation of this method is irrelevant when considering the lifetime of the 
    // returned Span<T>. The ref safe context rules only look at the method signature, not the 
    // implementation. In C# 10 ref fields didn't exist hence there was no way for `parameter`
    // to escape by ref in this method
}

Span<int> BadUseExamples(int parameter)
{
    // Legal in C# 10 but would be illegal with ref fields
    return CreateSpan(ref parameter);

    // Legal in C# 10 but would be illegal with ref fields
    int local = 42;
    return CreateSpan(ref local);

    // Legal in C# 10 but would be illegal with ref fields
    Span<int> span = stackalloc int[42];
    return CreateSpan(ref span[0]);
}

Se espera que el impacto de esta ruptura de compatibilidad sea muy pequeño. La estructura de la API afectada tiene poco sentido en ausencia de campos ref, por lo que es poco probable que los clientes hayan creado muchos de ellos. Los experimentos que ejecutan herramientas para detectar esta forma de API en los repositorios existentes respaldan esa afirmación. El único repositorio con elementos significativos de esta forma es dotnet/runtime y eso se debe a que este repositorio puede crear campos ref a través del tipo intrínseco ByReference<T>.

Aun así, el diseño debe tener en cuenta estas API existentes porque expresa un patrón válido, simplemente no uno común. Por lo tanto, el diseño debe proporcionar a los desarrolladores las herramientas para restaurar las reglas de duración existentes al actualizar a C# 10. En concreto, debe incluir mecanismos que permitan a los desarrolladores anotar parámetros ref que se no puedan escapar mediante los campos ref o ref. Esto permite a los clientes definir las API en C# 11 que tienen las mismas reglas de sitio de llamada de C# 10.

Ensamblados de referencia

Un ensamblado de referencia en una compilación mediante las características descritas en esta propuesta debe mantener los elementos que transmiten la información de ref safe context. Esto significa que todos los atributos de anotación de ciclo de vida deben permanecer en su posición original. Cualquier intento de reemplazarlos u omitirlos puede provocar ensamblados de referencia no válidos.

La representación de campos ref implica más matices. Lo ideal es que un campo de ref aparezca en un ensamblado de referencia como lo haría cualquier otro campo. Sin embargo, un campo ref representa un cambio en el formato de metadatos y que puede causar problemas con cadenas de herramientas que no se actualizan para comprender este cambio de metadatos. Un ejemplo concreto es C++/CLI, que probablemente producirá un error si consume un campo de ref. Por lo tanto, sería ventajoso poder omitir los campos ref de los ensamblados de referencia en las bibliotecas centrales.

Un campo ref por sí mismo no tiene ningún impacto en las reglas de contexto seguro de referencia. Como ejemplo concreto, considere que cambiar la definición de Span<T> existente para usar un campo ref no tiene ningún impacto en el consumo. Por lo tanto, el ref se puede omitir de forma segura. Sin embargo, un campo ref sí tiene otros impactos en el consumo que deben mantenerse:

  • Un ref struct que tiene un campo de ref nunca se considera unmanaged
  • El tipo del campo ref afecta a las reglas de expansión genéricas infinitas. Por lo tanto, si el tipo de un campo de ref contiene un parámetro de tipo que debe conservarse

Dadas estas reglas, se trata de una transformación de ensamblado de referencia válida para un ref struct:

// Impl assembly 
ref struct S<T>
{
    ref T _field;
}

// Ref assembly 
ref struct S<T>
{
    object _o; // force managed 
    T _f; // maintain generic expansion protections
}

anotaciones

Los tiempos de vida se expresan de forma más natural mediante tipos. Los ciclos de vida de un programa determinado son seguros cuando se comprueba el tipo de ciclo de vida. Aunque la sintaxis de C# agrega implícitamente duraciones a los valores, hay un sistema de tipos subyacente que describe las reglas fundamentales aquí. A menudo, es más fácil analizar la implicación de los cambios en el diseño en términos de estas reglas, por lo que se incluyen aquí por motivos de discusión.

Tenga en cuenta que esto no pretende estar totalmente documentado. La documentación de cada comportamiento no es un objetivo aquí. En su lugar, está diseñado para establecer un conocimiento general y un lenguaje común por el cual el modelo, y los posibles cambios en él, pueden ser discutidos.

Normalmente no es necesario hablar directamente sobre los tipos de tiempo de vida. Las excepciones son espacios donde los ciclos de vida pueden variar en función de determinados sitios de "creación de instancias". Se trata de un tipo de polimorfismo y llamamos a estas distintas duraciones "duraciones genéricas", representadas como parámetros genéricos. C# no aporta la sintaxis para expresar genéricos de ciclo de vida, por lo que definimos una "traducción" implícita de C# a un lenguaje reducido ampliado que incluye parámetros genéricos explícitos.

En los ejemplos siguientes se utilizan ciclos de vida designados. La sintaxis $a hace referencia a una duración denominada a. Es un ciclo de vida que no tiene ningún significado por sí mismo, pero se puede relacionar con otros a través de la sintaxis de where $a : $b. Esto establece que $a se puede convertir a $b. Esto puede ayudar a que se vea como establecer que $a es un ciclo de vida al menos tan largo como $b.

Hay algunas duraciones predefinidas para mayor comodidad y brevedad a continuación:

  • $heap: esta es el ciclo de vida de cualquier valor que exista en el montón. Está disponible en todos los contextos y signaturas de método.
  • $local: esta es la duración de cualquier valor que exista en la pila de métodos. Es, de hecho, un marcador de posición de nombre para function-member. Se define implícitamente en métodos y puede aparecer en firmas de métodos, excepto en cualquier posición de salida.
  • $ro: marcador de posición de nombre para return-only
  • $cm: marcador de posición de nombre para caller-context

Hay algunas relaciones predefinidas entre tiempos de vida:

  • where $heap : $a para todos los ciclos de vida $a
  • where $cm : $ro
  • where $x : $local para todos los ciclos de vida predefinidos. Los ciclos de vida definidos por el usuario no tienen ninguna relación con "local" a menos que se definan explícitamente.

Las variables de duración cuando se definen en tipos pueden ser invariables o covariantes. Se expresan con la misma sintaxis que los parámetros genéricos:

// $this is covariant
// $a is invariant
ref struct S<out $this, $a> 

El parámetro de ciclo de vida $this en las definiciones de tipo no viene predefinido, pero tiene algunas reglas asociadas cuando se define:

  • Debe ser el primer parámetro de vida útil.
  • Debe ser covariante: out $this.
  • La vida útil de los campos de ref debe ser convertible a $this
  • El ciclo de vida $this de todos los campos que no son ref debe ser $heap o $this.

El ciclo de vida de un ref se expresa facilitando un argumento de ciclo de vida en ref. Por ejemplo, un ref que haga referencia al montón se expresa como ref<$heap>.

Al definir un constructor en el modelo, el nombre new se usará para el método. Es necesario tener una lista de parámetros para el valor devuelto, así como los argumentos del constructor. Esto es necesario para expresar la relación entre las entradas del constructor y el valor construido. En lugar de tener Span<$a><$ro> el modelo usará Span<$a> new<$ro> en su lugar. El tipo de this en el constructor, incluidos los ciclos de vida, será el valor devuelto definido.

Las reglas básicas para el ciclo de vida se definen así:

  • Todos los ciclos de vida se expresan sintácticamente como argumentos genéricos, que vienen antes de los argumentos de tipo. Esto es cierto para las duraciones predefinidas, excepto $heap y $local.
  • Todos los tipos T que no son un ref struct implícitamente tienen un ciclo de vida de T<$heap>. Esto es implícito, no es necesario escribir int<$heap> en cada ejemplo.
  • Para un campo de ref definido como ref<$l0> T<$l1, $l2, ... $ln>:
    • Todos los tiempos de vida $l1 hasta $ln deben ser invariantes.
    • La duración de vida de $l0 debe ser convertible a $this
  • Para un ref definido como ref<$a> T<$b, ...>, $b debe convertirse en $a
  • La ref de una variable tiene una duración definida por:
    • Para un ref local, parámetro, campo o retorno de tipo ref<$a> T, la duración es $a
    • $heap para todos los tipos de referencia y campos de tipos de referencia
    • $local para cualquier otra cosa
  • Una asignación o devolución es correcto cuando la conversión de tipos subyacente es correcto
  • Los ciclos de vida de expresiones se pueden hacer explícitas mediante anotaciones de difusión:
    • (T<$a> expr) el ciclo de vida del valor es explícitamente $a para T<...>
    • ref<$a> (T<$b>)expr el ciclo de vida del valor es $b para T<...> y el ciclo de vida de ref es $a.

A efecto de las reglas de los ciclos de vida, un ref se considera parte del tipo de la expresión con fines de conversión. Se representa lógicamente convirtiendo ref<$a> T<...> en ref<$a, T<...>> donde $a es covariante y T es invariable.

A continuación, vamos a definir las reglas que nos permiten asignar la sintaxis de C# al modelo subyacente.

Para que sea más breve, un tipo que no tiene parámetros de ciclo de vida explícitos tratados como si hubiera un out $this definido y aplicado en todos los campos del tipo. Un tipo con un campo ref debe definir parámetros de duración explícitos.

Estas reglas existen para respaldar nuestra invariante existente de que T se puede asignar a scoped T para todos los tipos. Esto se deriva en T<$a, ...> que se puede asignar a T<$local, ...> en todos los ciclos de vida que se pueden convertir en $local. Además, admite otros elementos, como poder asignar Span<T> desde el montón a los de la pila. Esto excluye los tipos en los que los campos tienen duraciones diferentes para los valores que no son ref, pero esa es la realidad de C# en la actualidad. Si se quisiera cambiar esto, se debería elaborar un cambio significativo de las reglas de C#.

El tipo de this para un tipo S<out $this, ...> dentro de un método de instancia se define implícitamente como el siguiente:

  • Para el método de instancia normal: ref<$local> S<$cm, ...>
  • Para método de instancia anotado con [UnscopedRef]: ref<$ro> S<$cm, ...>

La falta de un parámetro this explícito fuerza las reglas implícitas aquí. En el caso de ejemplos complejos y discusiones, considere la posibilidad de escribir como un método static y convertir this un parámetro explícito.

ref struct S<out $this>
{
    // Implicit this can make discussion confusing 
    void M<$ro, $cm>(ref<$ro> S<$cm> s) {  }

    // Rewrite as explicit this to simplify discussion
    static void M<$ro, $cm>(ref<$local> S<$cm> this, ref<$ro> S<$cm> s) { }
}

La sintaxis del método de C# se asigna al modelo de las maneras siguientes:

  • los parámetros ref tienen un ciclo de vida de ref de $ro
  • los parámetros de tipo ref struct tienen un ciclo de vida de $cm
  • las devoluciones de ref tienen un ciclo de vida de ref de $ro
  • los valores de tipo ref struct tienen un ciclo de vida de valor de $ro
  • scoped en un parámetro o ref cambia la duración de referencia a $local

Dado que vamos a explorar un ejemplo sencillo que muestra el modelo aquí:

ref int M1(ref int i) => ...

// Maps to the following. 

ref<$ro> int Identity<$ro>(ref<$ro> int i)
{
    // okay: has ref lifetime $ro which is equal to $ro
    return ref i;

    // okay: has ref lifetime $heap which convertible $ro
    int[] array = new int[42];
    return ref array[0];

    // error: has ref lifetime $local which has no conversion to $a hence 
    // it's illegal
    int local = 42;
    return ref local;
}

Ahora vamos a examinar el mismo ejemplo con un ref struct:

ref struct S
{
    ref int Field;

    S(ref int f)
    {
        Field = ref f;
    }
}

S M2(ref int i, S span1, scoped S span2) => ...

// Maps to 

ref struct S<out $this>
{
    // Implicitly 
    ref<$this> int Field;

    S<$ro> new<$ro>(ref<$ro> int f)
    {
        Field = ref f;
    }
}

S<$ro> M2<$ro>(
    ref<$ro> int i,
    S<$ro> span1)
    S<$local> span2)
{
    // okay: types match exactly
    return span1;

    // error: has lifetime $local which has no conversion to $ro
    return span2;

    // okay: type S<$heap> has a conversion to S<$ro> because $heap has a
    // conversion to $ro and the first lifetime parameter of S<> is covariant
    return default(S<$heap>)

    // okay: the ref lifetime of ref $i is $ro so this is just an 
    // identity conversion
    S<$ro> local = new S<$ro>(ref $i);
    return local;

    int[] array = new int[42];
    // okay: S<$heap> is convertible to S<$ro>
    return new S<$heap>(ref<$heap> array[0]);

    // okay: the parameter of the ctor is $ro ref int and the argument is $heap ref int. These 
    // are convertible.
    return new S<$ro>(ref<$heap> array[0]);

    // error: has ref lifetime $local which has no conversion to $a hence 
    // it's illegal
    int local = 42;
    return ref local;
}

A continuación, veamos cómo esto ayuda con el problema de autoasignación cíclica:

ref struct S
{
    int field;
    ref int refField;

    static void SelfAssign(ref S s)
    {
        s.refField = ref s.field;
    }
}

// Maps to 

ref struct S<out $this>
{
    int field;
    ref<$this> int refField;

    static void SelfAssign<$ro, $cm>(ref<$ro> S<$cm> s)
    {
        // error: the types work out here to ref<$cm> int = ref<$ro> int and that is 
        // illegal as $ro has no conversion to $cm (the relationship is the other direction)
        s.refField = ref<$ro> s.field;
    }
}

A continuación, veamos cómo esto ayuda con el problema del parámetro de captura absurdo:

ref struct S
{
    ref int refField;

    void Use(ref int parameter)
    {
        // error: this needs to be an error else every call to this.Use(ref local) would fail 
        // because compiler would assume the `ref` was captured by ref.
        this.refField = ref parameter;
    }
}

// Maps to 

ref struct S<out $this>
{
    ref<$this> int refField;
    
    // Using static form of this method signature so the type of this is explicit. 
    static void Use<$ro, $cm>(ref<$local> S<$cm> @this, ref<$ro> int parameter)
    {
        // error: the types here are:
        //  - refField is ref<$cm> int
        //  - ref parameter is ref<$ro> int
        // That means the RHS is not convertible to the LHS ($ro is not covertible to $cm) and 
        // hence this reassignment is illegal
        @this.refField = ref<$ro> parameter;
    }
}

Problemas abiertos

Cambiar el diseño para evitar interrupciones de compatibilidad

Este diseño propone varias interrupciones de compatibilidad con nuestras reglas de contexto ref-safe existentes. Aunque se cree que las interrupciones tienen un impacto mínimo, se dio una consideración significativa a un diseño que no tuviera cambios disruptivos.

El diseño que preserva la compatibilidad era significativamente más complejo que este. Para conservar la compatibilidad, los campos ref necesitan ciclos de vida distintos para poder devolver a través del campo ref y el campo ref. Básicamente, nos pide que realicemos un seguimiento de ref-field-safe-context en todos los parámetros de un método. Esto debe calcularse para todas las expresiones y realizarse un seguimiento en todos los valores, prácticamente en todas partes donde se haga actualmente un seguimiento de ref-safe-context.

Además, este valor tiene relaciones con ref-safe-context. Por ejemplo, no tiene sentido que un valor pueda ser devuelto como un campo de ref, pero no directamente como un campo de ref. Esto se debe a que los campos ref ya se pueden devolver trivialmente mediante ref (el estadoref en un ref struct se puede devolver mediante ref incluso cuando el valor que lo contiene no puede). Por lo tanto, las reglas necesitan un ajuste constante para asegurarse de que estos valores son razonables entre sí.

También significa que el lenguaje necesita sintaxis para representar parámetros ref que se pueden devolver de tres maneras diferentes: por campo ref, por ref y por valor. El valor predeterminado se puede devolver mediante ref. En el futuro, sin embargo, se prevé que la devolución sea más natural, sobre todo, cuando intervienen ref struct, mediante el campo ref o ref. Esto significa que las nuevas API requieren que una anotación de sintaxis adicional sea correcta de forma predeterminada. Esto no es deseable.

Sin embargo, estos cambios de compatibilidad afectarán a los métodos que tienen las siguientes propiedades:

  • Tienen un Span<T> o ref struct
    • Donde el ref struct es un tipo de valor devuelto, ref o parámetro out
    • Tiene un parámetro de in o ref adicional, excepto el receptor

Para comprender el impacto, resulta útil dividir las API en categorías:

  1. Se pretende que los consumidores tengan en cuenta que ref se captura como un campo ref. Un claro ejemplo son los constructores de Span(ref T value)
  2. No se pretende que los consumidores tengan en cuenta que ref se captura como un campo ref. Aunque se dividen en dos categorías
    1. API no seguras. Se trata de API dentro de los tipos Unsafe y MemoryMarshal, de las cuales MemoryMarshal.CreateSpan es la más destacada. Estas API capturan el ref de forma no segura, pero también se sabe que son API no seguras.
    2. API seguras. Estas son API que toman parámetros ref para mejorar la eficiencia, pero no se capturan realmente en ninguna parte. Algunos ejemplos son pequeños, pero uno es AsnDecoder.ReadEnumeratedBytes

Este cambio beneficia principalmente al punto (1) mencionado anteriormente. Se espera que constituyan la mayoría de las API que toman un ref y devuelven un ref struct en el futuro. Los cambios afectan negativamente a (2.1) y a (2.2), ya que rompen la semántica de llamada existente, dado que cambian las reglas de los ciclos de vida.

En su mayoría, las API de la categoría (2.1), las crea Microsoft o desarrolladores que más se benefician de los campos ref (los Tanner del mundo). Es razonable suponer que esta clase de desarrolladores sería favorable a un impuesto de compatibilidad sobre la actualización a C# 11 en forma de algunas anotaciones para conservar la semántica existente si se proporcionaran campos ref a cambio.

Las API de la categoría (2.2) son el problema más importante. Se desconoce cuántas de estas API existen y no está claro si serían más o menos frecuentes en el código de terceros. Lo que se espera es que haya una cantidad muy pequeña de ellos, especialmente si consideramos el cambio de compatibilidad en out. Hasta ahora, las búsquedas han revelado un número muy pequeño de estos que existen en la superficie de public. Este es un patrón difícil de buscar, ya que requiere análisis semántico. Antes de adoptar este cambio, se necesitaría un enfoque basado en herramientas para comprobar las suposiciones que afectan a un pequeño número de casos conocidos.

En ambos casos de la categoría (2), la solución es sencilla. Los parámetros de ref que no quieran considerarse capturables deben agregar scoped al ref. En (2.1), es probable que esto también obligue al desarrollador a usar Unsafe o MemoryMarshal, pero eso se espera para las API de estilo no seguro.

Lo ideal es que el lenguaje pudiera reducir el impacto de los cambios silenciosos más importantes, generando una advertencia cuando una API acusa un problema de funcionamiento. Sería un método que toma un ref, devuelve ref struct, pero no captura realmente el ref en el ref struct. El compilador podría emitir un diagnóstico en ese caso, informando a los desarrolladores que ref deberían anotarse como scoped ref en su lugar.

Decisión Este diseño es viable, pero la característica final es más difícil de usar, hasta el punto de que se tomó la decisión de aceptar el cambio de compatibilidad.

Decisión El compilador proporcionará una advertencia cuando un método cumpla los criterios, pero no capture el parámetro ref como un campo ref. Esto debería advertir adecuadamente a los clientes en la actualización sobre los posibles problemas que podrían estar creando.

Palabras clave frente a atributos

Este diseño requiere el uso de atributos para anotar las nuevas reglas de ciclos de vida. Esto también podría haberse hecho fácilmente con palabras clave contextuales. Por ejemplo, [DoesNotEscape] podría asignarse a scoped. Sin embargo, las palabras clave, incluso las contextuales, por lo general deben cumplir un listón muy alto para su inclusión. Ocupan un espacio valioso en el lenguaje y son elementos más destacados del lenguaje. Esta característica, aunque valiosa, va a servir a una minoría de desarrolladores de C#.

A primera vista, eso parecería favorecer no usar palabras clave, pero hay dos puntos importantes a tener en cuenta:

  1. Las anotaciones afectarán a la semántica del programa. Que los atributos afecten a la semántica del programa es una línea que C# se resiste a cruzar y no está claro si esta es la característica que debería justificar que el lenguaje dé ese paso.
  2. Los desarrolladores más proclives a usar esta función confluyen en gran medida con los desarrolladores que usan punteros de función. Esa característica, aunque también la usan una minoría de desarrolladores, garantizaba una nueva sintaxis y esa decisión todavía se considera sólida.

Esto significa que se debe tener en cuenta la sintaxis.

Un boceto aproximado de la sintaxis sería:

  • [RefDoesNotEscape] se asigna a scoped ref
  • [DoesNotEscape] se asigna a scoped
  • [RefDoesEscape] se asigna a unscoped

Decisión Utilizar sintaxis para scoped y scoped ref; utilizar el atributo en unscoped.

Permitir variables locales de búferes fijos (fixed)

Este diseño permite búferes fixed seguros que pueden admitir cualquier tipo. Una posible extensión aquí es permitir que estos búferes fixed se declaren como variables locales. Esto permitiría reemplazar varias operaciones de stackalloc existentes por un búfer de fixed. También expandiría el conjunto de escenarios en los que podríamos tener asignaciones en estilo de pila, ya que stackalloc está limitado a los tipos de elementos no administrados; sin embargo, los búferes de fixed no lo están.

class FixedBufferLocals
{
    void Example()
    {
        Span<int> span = stackalloc int[42];
        int buffer[42];
    }
}

Esto se mantiene unido, pero requiere que extendamos la sintaxis para variables locales un poco. No está claro si merece o no la pena la complejidad adicional. Podríamos decidir que no por ahora y recuperarlo más adelante si se demuestra suficiente necesidad.

Ejemplo de dónde sería beneficioso: https://github.com/dotnet/runtime/pull/34149

Decisión Se pospone esto por ahora

Usar o no usar modreqs

Es necesario tomar una decisión sobre si los métodos marcados con nuevos atributos de ciclo de vida se deberían o no traducir a modreq al emitirlos. Habría efectivamente una asignación por partes entre anotaciones y modreq si se usara este enfoque.

La justificación para agregar un modreq es que los atributos cambian la semántica de las reglas de "ref safe context". Solo los lenguajes que entienden esta semántica deben llamar a los métodos en cuestión. Además, cuando se aplica a casos de OHI, los ciclos de vida pasan a ser algo obligado que todos los métodos derivados deben implementar. Si hay anotaciones sin modreq, esto puede provocar situaciones en que se cargan cadenas de métodos virtual con anotaciones de ciclo de vida en conflicto (puede ocurrir si solo se compila una parte de la cadena virtual y otras no).

La operación inicial de "ref-safe-context" no utilizaba modreq, sino que se fundamentaba en los lenguajes y el marco de trabajo para que se entendiera. Al mismo tiempo, todos los elementos que contribuyen a las reglas de "ref safe context" son una parte sólida de la firma del método: ref, in, ref struct, etc. Por lo tanto, cualquier cambio en las reglas existentes de un método ya da como resultado un cambio binario en la firma. Para que las nuevas anotaciones de ciclo de vida tengan el mismo impacto, hay que aplicar modreq.

La preocupación es si esto es excesivo o no. Tiene el efecto negativo de que hacer las firmas más flexibles, por ejemplo, al agregar [DoesNotEscape] a un parámetro, resultará en un cambio en la compatibilidad binaria. Esa compensación significa que, con el tiempo, es probable que plataformas de trabajo como BCL no puedan relajar sus firmas. Podría mitigarse hasta cierto punto adoptando un enfoque similar al que usa el lenguaje con los parámetros in y aplicando solamente modreq en posiciones virtuales.

Decisión No usar modreq en los metadatos. La diferencia entre out y ref no es modreq, pero ahora tienen valores de contexto ref seguros diferentes. No hay ninguna ventaja real para aplicar aquí parcialmente las reglas con modreq.

Permitir búferes fijos multidimensionales

¿Debe ampliarse el diseño de los buffers de fixed para incluir matrices de estilo multidimensionales? Básicamente, permite declaraciones como las siguientes:

struct Dimensions
{
    int array[42, 13];
}

Decisión No permitir por ahora

Alteración de scoped

El repositorio en tiempo de ejecución tiene varias APIs no públicas que capturan los parámetros ref como campos ref. No son seguros porque no se realiza un seguimiento de la duración del valor resultante. Por ejemplo, el constructor Span<T>(ref T value, int length).

Probablemente, la mayoría de estas API optarán por tener un seguimiento del ciclo de vida adecuado en la devolución que se puede logar con solo actualizar a C# 11. Sin embargo, algunos querrán mantener la semántica actual de no realizar el seguimiento del valor devuelto porque la intención es que no sea seguro. Los ejemplos más importantes son MemoryMarshal.CreateSpan y MemoryMarshal.CreateReadOnlySpan. Esto se logrará marcando los parámetros como scoped.

Esto significa que el tiempo de ejecución necesita un patrón establecido para quitar de forma no segura scoped de un parámetro:

  1. Unsafe.AsRef<T>(in T value) podría expandir su propósito existente cambiando a scoped in T value. Esto permitiría quitar in y scoped de parámetros. Luego se convierte en el método universal "eliminar ref safety".
  2. Introduzca un nuevo método cuyo propósito completo es quitar scoped: ref T Unsafe.AsUnscoped<T>(scoped in T value). Esto también elimina in porque, si no lo hiciera, los elementos de llamada seguirían necesitando una combinación de llamadas a métodos para "eliminar ref safety" y en este momento es probable que la solución existente sea suficiente.

¿Se puede dejar como unscoped de forma predeterminada?

El diseño solo tiene dos ubicaciones que son scoped por defecto.

  • this es scoped ref
  • out es scoped ref

La decisión de usar out se basa en reducir significativamente la carga de compatibilidad de los campos ref y al mismo tiempo supone un valor predeterminado más natural. Permite a los desarrolladores pensar realmente en out como datos que solo fluyen hacia afuera. En cambio, si es ref, las reglas deben considerar el flujo de datos en ambas direcciones. Esto conduce a una confusión significativa del desarrollador.

La decisión de usar this no es deseable porque significa que un struct no puede devolver un campo mediante ref. Este es un escenario importante para desarrolladores de alto rendimiento y el atributo [UnscopedRef] se agregó básicamente para este escenario.

Las palabras clave tienen un estándar alto y añadirla para un único escenario es sospechoso. Se consideró la posibilidad de si podríamos evitar por completo esta palabra clave haciendo que this sea simplemente ref por defecto y no scoped ref. Todos los miembros que necesitan que this sea scoped ref pueden hacerlo marcando el método scoped (como se puede marcar un método readonly para crear un readonly ref hoy).

En un struct normal, este cambio es principalmente positivo, ya que solo presenta problemas de compatibilidad cuando un miembro tiene un valor de devolución ref. Hay muy pocos de estos métodos y una herramienta podría detectarlos y convertirlos en miembros scoped rápidamente.

En un ref struct este cambio presenta problemas de compatibilidad significativamente mayores. Tenga en cuenta lo siguiente.

ref struct Sneaky
{
    int Field;
    ref int RefField;

    public void SelfAssign()
    {
        // This pattern of ref reassign to fields on this inside instance methods would now
        // completely legal.
        RefField = ref Field;
    }

    static Sneaky UseExample()
    {
        Sneaky local = default;

        // Error: this is illegal, and must be illegal, by our existing rules as the 
        // ref-safe-context of local is now an input into method arguments must match. 
        local.SelfAssign();

        // This would be dangerous as local now has a dangerous `ref` but the above 
        // prevents us from getting here.
        return local;
    }
}

Básicamente, significaría que todas las invocaciones de método de instancia en variables locales mutablesref struct no estarían permitidas a menos que la variable local se marcara como scoped. Las reglas deben tener presente el caso en que los campos se han reasignado con ref a otros campos en this. Un readonly ref struct no tiene este problema porque la naturaleza del readonly impide la reasignación de ref. Aun así, esto sería un cambio importante de compatibilidad retroactiva, ya que afectaría prácticamente a cada ref struct mutable existente.

Aunque un readonly ref struct sigue siendo problemático cuando se expande para tener campos ref en ref struct. Permite abordar el mismo problema básico al simplemente mover la captura al valor del campo ref:

readonly ref struct ReadOnlySneaky
{
    readonly int Field;
    readonly ref ReadOnlySpan<int> Span;

    public void SelfAssign()
    {
        // Instance method captures a ref to itself
        Span = new ReadOnlySpan<int>(ref Field, 1);
    }
}

Se consideró la idea de que this tuviera diferentes valores predeterminados en función del tipo de struct o miembro. Por ejemplo:

  • this como ref: struct, readonly ref struct o readonly member
  • this como scoped ref: ref struct o readonly ref struct con el campo ref en ref struct

Esto minimiza las interrupciones de compatibilidad y maximiza la flexibilidad, pero a costa de complicar la experiencia para los clientes. Tampoco soluciona del todo el problema, porque las siguientes características, como los búferes fixed seguros, requieren que un ref struct mutable tenga devoluciones de ref para los campos que no funcionan por sí solos con este diseño, ya que pasaría a la categoría scoped ref.

Decisión Mantener this como scoped ref. Esto significa que los ejemplos astutos anteriores producen errores del compilador.

Campos ref en struct ref

Esta característica abre un nuevo conjunto de reglas de contexto de referencia segura porque permite que un campo de ref haga referencia a un ref struct. Esta naturaleza genérica de ByReference<T> significaba que hasta ahora el entorno de ejecución no podía tener dicha construcción. Como resultado, todas nuestras reglas se escriben bajo la suposición de que esto no es posible. En gran medida, la característica de campo ref no consiste en crear nuevas reglas, sino codificar las reglas existentes en nuestro sistema. Para permitir campos ref en ref struct se necesita codificar nuevas reglas porque hay varios escenarios nuevos que se deben tener en cuenta.

La primera consideración es que un readonly ref ahora puede almacenar el estado ref. Por ejemplo:

readonly ref struct Container
{
    readonly ref Span<int> Span;

    void Store(Span<int> span)
    {
        Span = span;
    }
}

Esto implica que, al considerar los argumentos de un método, estos deben coincidir con las reglas; debemos tener en cuenta que readonly ref T es un resultado potencial del método cuando potencialmente T tiene un campo ref en un ref struct.

El segundo problema es que el idioma debe tener en cuenta un nuevo tipo de contexto seguro: ref-field-safe-context. Todos los ref struct que contienen transitivamente un campo ref tienen otro ámbito de escape que representa los valores de los campos ref. En el caso de varios campos de ref, se pueden rastrear colectivamente como si fuera un solo valor. El valor predeterminado para esto con respecto a los parámetros es caller-context.

ref struct Nested
{
    ref Span<int> Span;
}

Span<int> M(ref Nested nested) => nested.Span;

Este valor no está relacionado con el contexto seguro del contenedor; es decir, a medida que el contexto del contenedor se vuelve más pequeño, no tiene ningún impacto en el ref-field-safe-context de los valores de campo ref. Además, el ref-field-safe-context nunca puede ser más pequeño que el safe-context del contenedor.

ref struct Nested
{
    ref Span<int> Span;
}

void M(ref Nested nested)
{
    scoped ref Nested refLocal = ref nested;

    // the ref-field-safe-context of local is still *caller-context* which means the following
    // is illegal
    refLocal.Span = stackalloc int[42];

    scoped Nested valLocal = nested;

    // the ref-field-safe-context of local is still *caller-context* which means the following
    // is still illegal
    valLocal.Span = stackalloc int[42];
}

Básicamente, este ref-field-safe-context siempre ha existido. Hasta ahora, los campos ref solo podían apuntar a struct normales, por lo que se contraían fácilmente en caller-context. Para ayudar a los campos ref a ref struct, nuestras reglas existentes deben actualizarse para tener en cuenta este nuevo ref-safe-context.

En tercer lugar, es necesario actualizar las reglas de reasignación de referencias para asegurarse de que no infringimos el contexto de campo de referencia para los valores. Básicamente, x.e1 = ref e2, donde el tipo de e1 es un ref struct, el ref-field-safe-context debe ser igual.

Estos problemas tienen solución. El equipo del compilador ha esbozado algunas versiones de estas reglas y en gran medida se quedan fuera de nuestro análisis existente. El problema es que no hay código de consumo para estas reglas que ayude a demostrar la exactitud y la facilidad de uso. Esto hace que dudemos a la hora de integrar compatibilidad por el miedo a que elijamos valores predeterminados incorrectos y dejemos de hacer uso del tiempo de ejecución cuando esto da ventaja. Esta preocupación es especialmente fuerte porque es probable que .NET 8 nos empuje hacia esta dirección con allow T: ref struct y Span<Span<T>>. Las reglas se escribirían mejor si se llevan a cabo junto con el código de consumo.

Decisión Retrasar el uso del campo ref en ref struct hasta .NET 8, donde se dan situaciones que ayudarán a definir las reglas correspondientes. Esto no se ha implementado a partir de .NET 9

¿Qué hará C# 11.0?

No es necesario implementar las características descritas en este documento en un solo paso. En su lugar, se pueden implementar en fases en varias versiones de distintos idiomas en las siguientes categorías:

  1. Campos ref y scoped
  2. [UnscopedRef]
  3. Campos ref en ref struct
  4. Tipos restringidos sunset
  5. búferes de tamaño fijo

Lo que se implementa en cada versión es solo un ejercicio de determinación del ámbito.

Decisión Solo (1) y (2) se implementaron en C# 11.0. El resto se considerará en versiones futuras de C#.

Consideraciones futuras

Anotaciones de ciclo de vida avanzada

Las anotaciones de ciclo de vida de esta propuesta están limitadas porque permiten a los desarrolladores modificar el comportamiento predeterminado de escape o no escape de los valores. Esto agrega flexibilidad eficaz a nuestro modelo, pero no cambia radicalmente el conjunto de relaciones que se pueden expresar. En el núcleo, el modelo de C# sigue siendo binario de forma eficaz: ¿se puede devolver o no un valor?

Esto permite comprender las relaciones de duración limitadas. Por ejemplo, un valor que no se puede devolver desde un método tiene una duración menor que uno que se puede devolver desde un método. Sin embargo, no hay ninguna manera de describir la relación de duración entre los valores que se pueden devolver desde un método. En concreto, no hay ninguna manera de decir que un valor tiene una duración mayor que el otro una vez establecido, ambos se pueden devolver desde un método. El siguiente paso de nuestra evolución del ciclo sería permitir que se describan estas relaciones.

Otros métodos, como Rust, permiten expresar este tipo de relación y, por tanto, pueden implementar operaciones de estilo scoped más complejas. Nuestro lenguaje podría beneficiarse de forma similar si se incluyera dicha característica. Por el momento, no hay ninguna presión que lo motive, pero si la hubiera en el futuro, nuestro modelo scoped podría ampliarse para incluirlo de forma bastante sencilla.

A cada scoped se le podría asignar una duración con nombre agregando un argumento de estilo genérico a la sintaxis. Por ejemplo, scoped<'a> es un valor que tiene una duración 'a. Posteriormente, se podrían usar restricciones como where para describir las relaciones entre estas vidas.

void M(scoped<'a> ref MyStruct s, scoped<'b> Span<int> span)
  where 'b >= 'a
{
    s.Span = span;
}

Este método define dos ciclos de vida 'a y 'b y su relación, concretamente que 'b es mayor que 'a. Esto permite que el objeto callsite tenga reglas más granulares sobre cómo los valores se pueden pasar de forma segura a métodos frente a las reglas más gruesas que se presentan hoy en día.

Issues

Todas las cuestiones siguientes están relacionadas con esta propuesta:

Propuestas

Las siguientes propuestas están relacionadas con esta propuesta:

Ejemplos existentes

Utf8JsonReader

Este fragmento de código determinado requiere 'unsafe' debido a problemas con el paso de un Span<T> que se puede alojar en la pila en un método de instancia de un ref struct. Aunque este parámetro no se capture, el lenguaje debe asumir que está presente y, por tanto, provoca fricción innecesaria en este contexto.

Utf8JsonWriter

Este fragmento de código quiere mutar un parámetro mediante el escape de elementos de los datos. Los datos de escape se pueden asignar en la pila para mejorar la eficiencia. Aunque no se escape el parámetro , el compilador le asigna un safe-context de fuera del método contenedor porque es un parámetro. Esto significa que para usar la asignación de pila, la implementación debe usar unsafe con el fin de volver a asignar al parámetro después de escapar los datos.

Ejemplos

ReadOnlySpan<T>

public readonly ref struct ReadOnlySpan<T>
{
    readonly ref readonly T _value;
    readonly int _length;

    public ReadOnlySpan(in T value)
    {
        _value = ref value;
        _length = 1;
    }
}

Lista frugal (FrugalList)

struct FrugalList<T>
{
    private T _item0;
    private T _item1;
    private T _item2;

    public int Count = 3;

    public FrugalList(){}

    public ref T this[int index]
    {
        [UnscopedRef] get
        {
            switch (index)
            {
                case 0: return ref _item0;
                case 1: return ref _item1;
                case 2: return ref _item2;
                default: throw null;
            }
        }
    }
}

Ejemplos y notas

A continuación encontrará una serie de ejemplos que demuestran cómo y por qué las reglas funcionan como lo hacen. Se incluyen varios ejemplos que muestran comportamientos peligrosos y cómo las reglas impiden que se produzcan. Es importante tenerlas en cuenta al realizar ajustes en la propuesta.

Reasignación de ref y sitios de llamada

Demostración de cómo la reasignación de referencias y la invocación de métodos funcionan juntas.

ref struct RS
{
    ref int _refField;

    public ref int Prop => ref _refField;

    public RS(int[] array)
    {
        _refField = ref array[0];
    }

    public RS(ref int i)
    {
        _refField = ref i;
    }

    public RS CreateRS() => ...;

    public ref int M1(RS rs)
    {
        // The call site arguments for Prop contribute here:
        //   - `rs` contributes no ref-safe-context as the corresponding parameter, 
        //      which is `this`, is `scoped ref`
        //   - `rs` contribute safe-context of *caller-context*
        // 
        // This is an lvalue invocation and the arguments contribute only safe-context 
        // values of *caller-context*. That means `local1` has ref-safe-context of 
        // *caller-context*
        ref int local1 = ref rs.Prop;

        // Okay: this is legal because `local` has ref-safe-context of *caller-context*
        return ref local1;

        // The arguments contribute here:
        //   - `this` contributes no ref-safe-context as the corresponding parameter
        //     is `scoped ref`
        //   - `this` contributes safe-context of *caller-context*
        //
        // This is an rvalue invocation and following those rules the safe-context of 
        // `local2` will be *caller-context*
        RS local2 = CreateRS();

        // Okay: this follows the same analysis as `ref rs.Prop` above
        return ref local2.Prop;

        // The arguments contribute here:
        //   - `local3` contributes ref-safe-context of *function-member*
        //   - `local3` contributes safe-context of *caller-context*
        // 
        // This is an rvalue invocation which returns a `ref struct` and following those 
        // rules the safe-context of `local4` will be *function-member*
        int local3 = 42;
        var local4 = new RS(ref local3);

        // Error: 
        // The arguments contribute here:
        //   - `local4` contributes no ref-safe-context as the corresponding parameter
        //     is `scoped ref`
        //   - `local4` contributes safe-context of *function-member*
        // 
        // This is an lvalue invocation and following those rules the ref-safe-context 
        // of the return is *function-member*
        return ref local4.Prop;
    }
}

Reasignación de ref y escapes no seguros

Es posible que el motivo de la siguiente línea en las reglas de reasignación de ref no sea tan obvio a primera vista:

e1 debe tener el mismo contexto seguro que e2

Esto se debe a que la vida útil de los valores a los que apuntan las ubicaciones ref es invariable. EL direccionamiento indirecto nos impide permitir cualquier tipo de variación aquí, incluso para ciclos de vida más cortos. Si se permite la reducción entonces se permite el uso del siguiente código no seguro:

void Example(ref Span<int> p)
{
    Span<int> local = stackalloc int[42];
    ref Span<int> refLocal = ref local;

    // Error:
    // The safe-context of refLocal is narrower than p. For a non-ref reassignment 
    // this would be allowed as its safe to assign wider lifetimes to narrower ones.
    // In the case of ref reassignment though this rule prevents it as the 
    // safe-context values are different.
    refLocal = ref p;

    // If it were allowed this would be legal as the safe-context of refLocal
    // is *caller-context* and that is satisfied by stackalloc. At the same time
    // it would be assigning through p and escaping the stackalloc to the calling
    // method
    // 
    // This is equivalent of saying p = stackalloc int[13]!!! 
    refLocal = stackalloc int[13];
}

Para un caso de ref en un tipo que no sea ref struct, esta regla se cumple fácilmente, ya que todos los valores tienen el mismo safe-context. Esta regla solo entra en juego cuando el valor es un ref struct.

Este modo de uso de ref también será importante en un futuro en el que permitamos los campos ref en ref struct.

variables locales tipo scoped

El uso de scoped en las variables locales será especialmente útil para los patrones de código que asignan condicionalmente valores con diferentes contextos seguros a las variables locales. Significa que el código ya no necesita depender de estrategias de inicialización como = stackalloc byte[0] para definir un safe-context local, sino que ahora se puede usar scoped.

// Old way 
// Span<byte> span = stackalloc byte[0];
// New way 
scoped Span<byte> span;
int len = ...;
if (len < MaxStackLen)
{
    span = stackalloc byte[len];
}
else
{
    span = new byte[len];
}

Este patrón aparece con frecuencia en código de bajo nivel. Cuando el ref struct implicado es Span<T> se puede usar el truco anterior. Sin embargo, no es aplicable a otros tipos de ref struct y puede dar lugar a que el código de bajo nivel necesite recurrir a unsafe para evitar la incapacidad de especificar correctamente la duración.

valores de parámetros tipo scoped

Una fuente de fricción repetida en el código de bajo nivel es que el escape predeterminado para los parámetros es permisivo. Son safe-context en el caller-context. Este es un valor predeterminado razonable porque se alinea con los patrones de codificación de .NET en su conjunto. Aunque en el código de bajo nivel hay un mayor uso de ref struct y este valor predeterminado puede causar fricción con otras partes de las reglas de contexto de seguridad ref.

El punto de fricción principal se produce debido a que los argumentos del método deben coincidir con la regla. Esta regla suele entrar en juego con métodos de instancia en ref struct donde al menos un parámetro es también un ref struct. Se trata de un patrón común en código de bajo nivel, donde los tipos ref struct suelen aprovechar los parámetros Span<T> en sus métodos. Por ejemplo, se producirá en cualquier ref struct de estilo de escritura que use Span<T> para pasar búferes.

Esta regla existe para evitar escenarios como los siguientes:

ref struct RS
{
    Span<int> _field;
    void Set(Span<int> p)
    {
        _field = p;
    }

    static void DangerousCode(ref RS p)
    {
        Span<int> span = stackalloc int[] { 42 };

        // Error: if allowed this would let the method return a reference to 
        // the stack
        p.Set(span);
    }
}

Básicamente, esta regla existe porque el lenguaje debe asumir que todas las entradas de un método escapan en el safe-context máximo permitido. Cuando hay parámetros ref o out, incluidos los receptores, es posible que las entradas se escapen como campos de esos valores de ref (como sucede en RS.Set anterior).

No obstante, en la práctica hay muchos métodos de este tipo que pasan ref struct como parámetros que nunca pretenden capturarlos en la salida. Es solo un valor que se usa dentro del método actual. Por ejemplo:

ref struct JsonReader
{
    Span<char> _buffer;
    int _position;

    internal bool TextEquals(ReadOnlySpan<char> text)
    {
        var current = _buffer.Slice(_position, text.Length);
        return current == text;
    }
}

class C
{
    static void M(ref JsonReader reader)
    {
        Span<char> span = stackalloc char[4];
        span[0] = 'd';
        span[1] = 'o';
        span[2] = 'g';

        // Error: The safe-context of `span` is function-member 
        // while `reader` is outside function-member hence this fails
        // by the above rule.
        if (reader.TextEquals(span))
        {
            ...
        }
    }
}

Para ajustarse a este código de bajo nivel, se recurre a estrategias de unsafe para engañar al compilador sobre el ciclo de vida del ref struct. Esto reduce significativamente la propuesta de valor de ref struct ya que están destinados a ser un medio para evitar unsafe mientras que se continúa escribiendo código de alto rendimiento.

Aquí es donde scoped es una herramienta eficaz en parámetros ref struct, ya que no eliminar la posibilidad de que se devuelva del método según la nueva regla de correspondencia obligatoria de argumentos de método. Un parámetro ref struct que se consume, pero nunca se devuelve, se puede etiquetar como scoped para que los sitios de llamadas sean más flexibles.

ref struct JsonReader
{
    Span<char> _buffer;
    int _position;

    internal bool TextEquals(scoped ReadOnlySpan<char> text)
    {
        var current = _buffer.Slice(_position, text.Length);
        return current == text;
    }
}

class C
{
    static void M(ref JsonReader reader)
    {
        Span<char> span = stackalloc char[4];
        span[0] = 'd';
        span[1] = 'o';
        span[2] = 'g';

        // Okay: the compiler never considers `span` as capturable here hence it doesn't
        // contribute to the method arguments must match rule
        if (reader.TextEquals(span))
        {
            ...
        }
    }
}

Prevención de una asignación de ref complicada de mutación de readonly

Cuando un ref se lleva a un campo readonly en un constructor o miembro init, el tipo es ref y no ref readonly. Este es un comportamiento de larga duración que permite código como el siguiente:

struct S
{
    readonly int i; 

    public S(string s)
    {
        M(ref i);
    }

    static void M(ref int i) { }
}

Esto supone un posible problema si tal ref pudiera almacenarse en un campo ref del mismo tipo. Permitiría la mutación directa de un readonly struct a partir de un miembro de la instancia:

readonly ref struct S
{ 
    readonly int i; 
    readonly ref int r; 
    public S()
    {
        i = 0;
        // Error: `i` has a narrower scope than `r`
        r = ref i;
    }

    public void Oops()
    {
        r++;
    }
}

Sin embargo, la propuesta lo impide porque infringe las reglas de contexto seguro ref. Tenga en cuenta lo siguiente.

  • El ref-safe-context de this es function-member y safe-context es caller-context. Ambos son lo normal para this en un miembro struct.
  • El ref-safe-context de i es function-member. Esto se deriva de las reglas de ciclos de vida de los campos. Regla 4 específica.

En esta fase, la línea r = ref i no está permitida según las reglas de reasignación de ref.

Estas reglas no estaban pensadas para evitar este comportamiento, pero lo hacen como efecto secundario. Es importante tener esto en cuenta para cualquier actualización futura de reglas para evaluar el impacto en escenarios como este.

Asignación cíclica absurda

Un aspecto con el que este diseño tiene dificultades es la libertad con la que se puede devolver un ref a través de un método. Lo que la mayoría de desarrolladores esperan de forma intuitiva es permitir que todos los ref se devuelvan tan libremente como los valores normales. Sin embargo, permite casos patológicos que el compilador debe tener en cuenta al calcular la seguridad de referencia. Tenga en cuenta lo siguiente.

ref struct S
{
    int field;
    ref int refField;

    static void SelfAssign(ref S s)
    {
        // Error: s.field can only escape the current method through a return statement
        s.refField = ref s.field;
    }
}

No es un patrón de código que esperamos que los desarrolladores usen. Sin embargo, cuando se puede devolver un ref con el mismo ciclo de vida que un valor, se permite según las reglas. El compilador debe tener en cuenta todos los casos correctos al evaluar una llamada al método y esto hace que dichas API sean efectivamente inutilizables.

void M(ref S s)
{
    ...
}

void Usage()
{
    // safe-context to caller-context
    S local = default; 

    // Error: compiler is forced to assume the worst and concludes a self assignment
    // is possible here and must issue an error.
    M(ref local);
}

Para que estas API se puedan usar, el compilador garantiza que el ciclo de vida ref de un parámetro ref sea menor que el ciclo de vida de las referencias en el valor del parámetro asociado. Esta es la razón para tener ref-safe-context en ref para que ref struct sea return-only y out sea caller-context. Esto evita la asignación cíclica debido a la diferencia en las duraciones.

Tenga en cuenta que [UnscopedRef]transforma el ref-safe-context de cualquier ref a valores ref struct en caller-context y, por tanto, permite la asignación cíclica y fuerza un uso viral de [UnscopedRef] en la cadena de llamada:

S F()
{
    S local = new();
    // Error: self assignment possible inside `S.M`.
    S.M(ref local);
    return local;
}

ref struct S
{
    int field;
    ref int refField;

    public static void M([UnscopedRef] ref S s)
    {
        // Allowed: s has both safe-context and ref-safe-context of caller-context
        s.refField = ref s.field;
    }
}

Del mismo modo, [UnscopedRef] out permite una asignación cíclica porque el parámetro tiene tanto safe-context y ref-safe-context de return-only.

Es útil transformar [UnscopedRef] ref un caller-context cuando el tipo no es un ref struct (tenga en cuenta que se pretende simplificar las reglas para que no distingan entre refs en structs que sean y no sean ref):

int x = 1;
F(ref x).RefField = 2;
Console.WriteLine(x); // prints 2

static S F([UnscopedRef] ref int x)
{
    S local = new();
    local.M(ref x);
    return local;
}

ref struct S
{
    public ref int RefField;

    public void M([UnscopedRef] ref int data)
    {
        RefField = ref data;
    }
}

En términos de anotaciones avanzadas, el diseño de [UnscopedRef] crea lo siguiente:

ref struct S { }

// C# code
S Create1(ref S p)
S Create2([UnscopedRef] ref S p)

// Annotation equivalent
scoped<'b> S Create1(scoped<'a> ref scoped<'b> S)
scoped<'a> S Create2(scoped<'a> ref scoped<'b> S)
  where 'b >= 'a

readonly no puede ser profundo en campos ref

Veamos el siguiente código de ejemplo:

ref struct S
{
    ref int Field;

    readonly void Method()
    {
        // Legal or illegal?
        Field = 42;
    }
}

Al diseñar las reglas para los campos ref en instancias readonly en un vacío, las reglas se pueden diseñar legítimamente de manera que lo anterior esté permitido o no. Básicamente, readonly puede ser profundo de forma legítima a través de un campo ref o solo puede aplicarse en el ref. Si se aplica solo en el ref, esto impide la reasignación de ref, pero permite la asignación normal que cambia el valor al que se hace referencia.

Este diseño no existe en un vacío. Más bien, crea reglas para los tipos que ya tienen campos ref. El más destacado de estos, Span<T>, ya tiene una fuerte dependencia de que readonly no sea profundo en este caso. La aplicación principal se basa en asignar el campo ref a través de una instancia de readonly.

readonly ref struct SpanOfOne
{
    readonly ref int Field;

    public ref int this[int index]
    {
        get
        {
            if (index != 1)
                throw new Exception();
            return ref Field;
        }
    }
}

Esto significa que debemos elegir la interpretación superficial de readonly.

Constructores de modelos

Aquí se plantea una pregunta sutil sobre el diseño: ¿Cómo se modela los cuerpos de los constructores para ref safety? Básicamente, ¿cómo se analiza el siguiente constructor?

ref struct S
{
    ref int field;

    public S(ref int f)
    {
        field = ref f;
    }
}

Existen dos enfoques:

  1. Modelo como método static donde this es una variable local donde el safe-context es calle-context
  2. Modelar como un método de static donde this es un parámetro out.

Además, un constructor debe cumplir los siguientes invariables:

  1. Asegúrese de que los parámetros ref puedan capturarse como campos ref.
  2. Asegúrese de que los campos ref de this no se escapen a través de parámetros ref. Esto afectaría a la asignación de ref complicada.

La intención es elegir el formato que satisfaga nuestras invariables sin necesidad de introducir ninguna regla especial para constructores. Dado que el mejor modelo para constructores es considerar this como un parámetro out. La condición return only del out nos permite abarcar todas las invariables anteriores sin mayúsculas y minúsculas especiales:

public static void ctor(out S @this, ref int f)
{
    // The ref-safe-context of `ref f` is *return-only* which is also the 
    // safe-context of `this.field` hence this assignment is allowed
    @this.field = ref f;
}

Los argumentos del método deben coincidir

La regla de que los argumentos del método deben coincidir es una fuente común de confusión para los desarrolladores. Es una regla que tiene una serie de casos especiales que son difíciles de entender a menos que esté familiarizado con el razonamiento subyacente a la regla. Para comprender mejor las razones de la regla, simplificaremos contexto seguro de referencia y contexto seguro a simplemente contexto.

Los métodos pueden devolver con bastante libertad el estado que se les pasa como parámetros. Básicamente, se puede devolver cualquier estado accesible que no tenga ámbito ("unscoped") (incluido el devuelto por ref). Esto se puede devolver directamente a través de una instrucción return o indirectamente a través de la asignación a un valor de ref.

Las devoluciones directas no suponen muchos problemas para ref safety. El compilador simplemente debe examinar todas las entradas que se pueden devolver a un método y, a continuación, restringir eficazmente el valor devuelto para que sea el contexto mínimo de la entrada. Ese valor devuelto pasa por el procesamiento normal.

Los retornos indirectos suponen un problema importante porque todos los ref son una entrada y salida del método. Estas salidas ya tienen un contexto conocido de . El compilador no puede deducir otros nuevos, tiene que considerarlos en su nivel actual. Esto significa que el compilador tiene que examinar cada ref que se puede asignar en el método llamado, evaluar el context y luego verificar que ningún valor devuelto del método tiene un context más pequeño que ref. Si existe algún caso así, entonces la llamada al método tendrá que ser ilegal ya que podría violar la seguridad ref.

La verificación de que los argumentos del método deben coincidir es el proceso mediante el cual el compilador asegura esta comprobación de seguridad.

Una forma diferente de evaluar esto, que a menudo es más fácil de considerar para los desarrolladores, es hacer el siguiente ejercicio:

  1. Examinar la definición del método para identificar todos los lugares donde se puede devolver el estado indirectamente: a. Parámetros de ref mutables que apuntan a ref struct b. Parámetros ref mutables con campos ref de ref asignables c. Parámetros ref asignables o campos ref que apuntan a ref struct (se puede plantear de forma recurrente)
  2. Examine el sitio de llamada a. Identificar los contextos que se alinean con las ubicaciones identificadas anteriormente b. Identificar los contextos de todas las entradas del método que se pueden devolver (no se alinean con los parámetros de scoped).

Si cualquier valor de 2.b es menor que 2.a, la llamada al método debe ser incorrecto. Echemos un vistazo a algunos ejemplos para ilustrar las reglas:

ref struct R { }

class Program
{
    static void F0(ref R a, scoped ref R b) => throw null;

    static void F1(ref R x, scoped R y)
    {
        F0(ref x, ref y);
    }
}

Al examinar la llamada a F0, pasamos a revisar (1) y (2). Los parámetros con potencial para la devolución indirecta son a y b, ya que ambos se pueden asignar directamente. Los argumentos que se alinean con esos parámetros son:

  • a que se asigna a x con el context de caller-context
  • b que se asigna a y con el context de function-member

El conjunto de entradas devueltas en el método son

  • x con escape-scope de caller-context
  • ref x con escape-scope de caller-context
  • y con escape-scope de function-member

El valor ref y no se puede devolver, ya que se asigna a un scoped ref por lo tanto, no se considera una entrada. Sin embargo, dado que hay al menos una entrada con un escape scope (argumento y) que una de las salida (argumento x), la llamada al método no está permitida.

Una variación diferente es la siguiente:

ref struct R { }

class Program
{
    static void F0(ref R a, ref int b) => throw null;

    static void F1(ref R x)
    {
        int y = 42;
        F0(ref x, ref y);
    }
}

De nuevo, los parámetros con potencial para la devolución indirecta son a y b, ya que ambos se pueden asignar directamente. Pero b se puede excluir porque no apunta a un ref struct, por lo tanto, no se puede usar para almacenar el ref estado. Por lo tanto, tenemos:

  • a que se asigna a x con el context de caller-context

El conjunto de entradas devueltas en el método son:

  • x con context de caller-context
  • ref x con context de caller-context
  • ref y con context de function-member

Dado que hay al menos una entrada con un escape scope (argumento ref y) que una de las salida (argumento x), la llamada al método no está permitida.

Esta es la lógica que la regla de que los argumentos del método deben coincidir está intentando abarcar. Esto tiene más alcance, ya que contempla a scoped como una forma de desechar entradas y a readonly como una forma de descartar ref como salida (no se puede asignar a un readonly ref, por lo que no puede ser una fuente de salida). Estos casos especiales agregan complejidad a las reglas, pero lo hacen para beneficio del desarrollador. El compilador busca quitar todas las entradas y salidas que sabe que no pueden contribuir al resultado para proporcionar a los desarrolladores máxima flexibilidad al llamar a un miembro. Al igual que la resolución de sobrecargas, merece la pena hacer que nuestras reglas sean más complejas cuando crea más flexibilidad para los consumidores.

Ejemplos de safe-context inferidos de expresiones de declaración

Relacionado con Inferir safe-context de expresiones de declaración.

ref struct RS
{
    public RS(ref int x) { } // assumed to be able to capture 'x'

    static void M0(RS input, out RS output) => output = input;

    static void M1()
    {
        var i = 0;
        var rs1 = new RS(ref i); // safe-context of 'rs1' is function-member
        M0(rs1, out var rs2); // safe-context of 'rs2' is function-member
    }

    static void M2(RS rs1)
    {
        M0(rs1, out var rs2); // safe-context of 'rs2' is function-member
    }

    static void M3(RS rs1)
    {
        M0(rs1, out scoped var rs2); // 'scoped' modifier forces safe-context of 'rs2' to the current local context (function-member or narrower).
    }
}

Tenga en cuenta que el contexto local que resulta del modificador scoped es el más restringido que se podría usar para la variable. Ser aún más restringido significaría que la expresión hace referencia a variables que solo se declaran en un contexto más limitado que la propia expresión.