Compartir a través de


Permitir ref e unsafe en métodos de iteradores y asincrónicos

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 e 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 las especificaciones de .

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

Resumen

Unifique el comportamiento entre iteradores y métodos asincrónicos. Específicamente:

  • Permitan usar elementos locales ref,/,ref struct y bloques unsafe en iteradores y métodos asincrónicos, siempre que se utilicen en segmentos de código sin yield ni await.
  • Advertir sobre yield dentro de lock.

Motivación

No es necesario prohibir las variables locales ref/ref struct y los bloques de unsafe en métodos asincrónicos o de iteradores si no se usan en yield o await, porque no es necesario elevarlos.

async void M()
{
    await ...;
    ref int x = ...; // error previously, proposed to be allowed
    x.ToString();
    await ...;
    // x.ToString(); // still error
}

Cambios importantes

No hay cambios importantes en la especificación del lenguaje, pero hay un cambio importante en la implementación de Roslyn (debido a una infracción de especificación).

Roslyn infringe la parte de la especificación que indica que los iteradores introducen un contexto seguro (§13.3.1). Por ejemplo, si hay un unsafe class con un método de iterador que contiene una función local, esa función local hereda el contexto no seguro de la clase , aunque debería haber estado en un contexto seguro por la especificación debido al método iterador. De hecho, todo el método del iterador heredó el contexto no seguro en Roslyn, simplemente no se permitía usar ninguna construcción no segura en iteradores. En LangVersion >= 13, los iteradores introducirán correctamente un contexto seguro porque queremos permitir construcciones no seguras en iteradores.

unsafe class C // unsafe context
{
    System.Collections.Generic.IEnumerable<int> M() // an iterator
    {
        yield return 1;
        local();
        async void local()
        {
            int* p = null; // allowed in C# 12; error in C# 13 (breaking change)
            await Task.Yield(); // error in C# 12, allowed in C# 13
        }
    }
}

Nota:

  • El problema se puede solucionar simplemente agregando el modificador unsafe a la función local.
  • Esto no afecta a las expresiones lambda porque "heredan" el "contexto de iterador" y, por lo tanto, era imposible usar construcciones no seguras dentro de ellas.

Diseño detallado

Los siguientes cambios están vinculados a LangVersion, es decir, C# 12 y versiones inferiores seguirán prohibiendo los locales tipo ref y los bloques unsafe en métodos asincrónicos e iteradores, y C# 13 levantará estas restricciones, como se describe a continuación. Sin embargo, las aclaraciones de las especificaciones que coinciden con la implementación actual de Roslyn deberían mantenerse en todas las LangVersions.

§13.3.1 Bloques > General:

Un bloque de que contiene una o varias instrucciones yield (§13.15) se denomina bloque iterador, incluso si esas instrucciones yield solo se incluyen indirectamente en bloques anidados (excepto las lambdas anidadas y las funciones locales).

[...]

Es un error en tiempo de compilación que un bloque de iterador contenga un contexto inseguro (§23.2). Un bloque de iterador siempre define un contexto seguro, incluso cuando su declaración está anidada en un contexto no seguro. El bloque de iterador usado para implementar un iterador (§15.14) siempre define un contexto seguro, incluso cuando la declaración del iterador está anidada en un contexto no seguro.

A partir de esta especificación también se muestra lo siguiente:

  • Si una declaración de iterador está marcada con el modificador unsafe, la firma se encuentra en un ámbito no seguro, pero el bloque de iterador usado para implementar ese iterador sigue definiendo un ámbito seguro.
  • El descriptor de acceso set de una propiedad o indexador de iterador (es decir, su descriptor de acceso get se implementa a través de un bloque de iterador) "hereda" su ámbito de seguro/no seguro de la declaración.
  • Esto no afecta a las declaraciones parciales sin implementación, ya que solo son firmas y no pueden tener un cuerpo de iterador.

Tenga en cuenta que en C# 12 es un error tener un método de iterador marcado con el modificador unsafe, pero eso se permite en C# 13 debido al cambio de especificación.

Por ejemplo:

using System.Collections.Generic;
using System.Threading.Tasks;

class A : System.Attribute { }
unsafe partial class C1
{ // unsafe context
    [/* unsafe context */ A]
    IEnumerable<int> M1(
        /* unsafe context */ int*[] x)
    { // safe context (this is the iterator block implementing the iterator)
        yield return 1;
    }
    IEnumerable<int> M2()
    { // safe context (this is the iterator block implementing the iterator)
        unsafe
        { // unsafe context
            { // unsafe context (this is *not* the block implementing the iterator)
                yield return 1; // error: `yield return` in unsafe context
            }
        }
    }
    [/* unsafe context */ A]
    unsafe IEnumerable<int> M3(
        /* unsafe context */ int*[] x)
    { // safe context
        yield return 1;
    }
    [/* unsafe context */ A]
    IEnumerable<int> this[
        /* unsafe context */ int*[] x]
    { // unsafe context
        get
        { // safe context
            yield return 1;
        }
        set { /* unsafe context */ }
    }
    [/* unsafe context */ A]
    unsafe IEnumerable<int> this[
        /* unsafe context */ long*[] x]
    { // unsafe context (the iterator declaration is unsafe)
        get
        { // safe context
            yield return 1;
        }
        set { /* unsafe context */ }
    }
    IEnumerable<int> M4()
    {
        yield return 1;
        var lam1 = async () =>
        { // safe context
          // spec violation: in Roslyn, this is an unsafe context in LangVersion 12 and lower
            await Task.Yield(); // error in C# 12, allowed in C# 13
            int* p = null; // error in both C# 12 and C# 13 (unsafe in iterator)
        };
        unsafe
        {
            var lam2 = () =>
            { // unsafe context, lambda cannot be an iterator
                yield return 1; // error: yield cannot be used in lambda
            };
        }
        async void local()
        { // safe context
          // spec violation: in Roslyn, this is an unsafe context in LangVersion 12 and lower
            await Task.Yield(); // error in C# 12, allowed in C# 13
            int* p = null; // allowed in C# 12, error in C# 13 (breaking change in Roslyn)
        }
        local();
    }
    public partial IEnumerable<int> M5() // unsafe context (inherits from parent)
    { // safe context
        yield return 1;
    }
}
partial class C1
{
    public partial IEnumerable<int> M5(); // safe context (inherits from parent)
}
class C2
{ // safe context
    [/* unsafe context */ A]
    unsafe IEnumerable<int> M(
        /* unsafe context */ int*[] x)
    { // safe context
        yield return 1;
    }
    unsafe IEnumerable<int> this[
        /* unsafe context */ int*[] x]
    { // unsafe context
        get
        { // safe context
            yield return 1;
        }
        set { /* unsafe context */ }
    }
}

§13.6.2.4 Declaraciones de variables locales ref:

Es un error en tiempo de compilación declarar una variable local ref, o una variable de tipo ref struct, dentro de un método declarado con el method_modifierasync o dentro de un iterador (§15.14).Es un error en tiempo de compilación declarar y usar (incluso implícitamente en código sintetizado por el compilador) una variable local ref, o una variable de tipo ref struct entre expresiones await o instrucciones yield return. Más concretamente, el error se controla mediante el siguiente mecanismo: después de una expresión await (§12.9.8) o una instrucción yield return (§13.15), todas las variables locales ref y variables de tipo ref struct en el ámbito se consideran definitivamente sin asignar (§9.4).

Tenga en cuenta que este error no se reduce a una advertencia en contextos unsafe, como algunos otros errores de seguridad de referencia. Esto se debe a que estas variables locales similares a ref no se pueden manipular en contextos de unsafe sin depender de los detalles de implementación de cómo funciona la reescritura de la máquina de estado, por lo que este error queda fuera de los límites de lo que queremos degradar a advertencias en contextos de unsafe.

§15.14.1 Iteradores > General:

Cuando un miembro de función se implementa mediante un bloque de iterador, es un error de tiempo de compilación que la lista de parámetros formales del miembro de función especifique cualquier parámetro in, ref readonly, out o ref, o un parámetro de tipo ref struct o de tipo puntero.

No se necesita ningún cambio en la especificación para permitir los bloques unsafe que no contienen awaiten métodos asincrónicos, ya que la especificación nunca ha prohibido los bloques unsafe en métodos asincrónicos. Sin embargo, la especificación nunca debió haber permitido await dentro de bloques unsafe (ya había prohibido yield en unsafe en §13.3.1 como se mencionó anteriormente), por lo que proponemos el siguiente cambio en la especificación:

§15.15.1 Funciones asincrónicas > General:

Es un error en tiempo de compilación que la lista de parámetros formales de una función asincrónica especifique cualquier parámetro in, out, o ref, o cualquier parámetro de tipo ref struct.

Es un error en tiempo de compilación que un contexto no seguro (§23.2) contenga una expresión await (§12.9.8) o una instrucción yield return (§13.15).

§23.6.5 El operador address-of:

Se notificará un error en tiempo de compilación si se toma una dirección de un parámetro o variable local en un iterador.

Actualmente, tomar la dirección de una variable local o de un parámetro en un método asincrónico es una advertencia de la ola de advertencias de C# 12.


Tenga en cuenta que más construcciones pueden funcionar gracias al ref permitido dentro de segmentos sin await y yield en métodos asincrónicos o de iteradores, aunque no se necesite un cambio de especificación específico para ellas, ya que todo procede de los cambios de especificación mencionados.

using System.Threading.Tasks;

ref struct R
{
    public ref int Current { get { ... }};
    public bool MoveNext() => false;
    public void Dispose() { }
}
class C
{
    public R GetEnumerator() => new R();
    async void M()
    {
        await Task.Yield();
        using (new R()) { } // allowed under this proposal
        foreach (var x in new C()) { } // allowed under this proposal
        foreach (ref int x in new C()) { } // allowed under this proposal
        lock (new System.Threading.Lock()) { } // allowed under this proposal
        await Task.Yield();
    }
}

Alternativas

  • ref / Las variables locales ref struct solo se pueden permitir en bloques (§13.3.1) que no contienen await/yield:

    // error always since `x` is declared/used both before and after `await`
    {
        ref int x = ...;
        await Task.Yield();
        x.ToString();
    }
    // allowed as proposed (`x` does not need to be hoisted as it is not used after `await`)
    // but alternatively could be an error (`await` in the same block)
    {
        ref int x = ...;
        x.ToString();
        await Task.Yield();
    }
    
  • yield return dentro de lock podría ser un error, similar a await dentro de lock) o una advertencia de tipo oleada, pero eso sería un cambio importante: https://github.com/dotnet/roslyn/issues/72443. Tenga en cuenta que el nuevo Lockbasado en objetos lock notifica errores en tiempo de compilación para yield returns en su cuerpo, ya que dicha instrucción lock es equivalente a using en un ref struct que no permite yield returns en su cuerpo.

  • Las variables dentro de los métodos asincrónicos o iteradores no deben ser "fijas", sino "movibles" si deben moverse a campos de la máquina de estado (de forma similar a las variables capturadas). Tenga en cuenta que se trata de un error preexistente en la especificación independiente del resto de la propuesta porque siempre se permitieron los bloques unsafe dentro de los métodos async. Actualmente, hay una advertencia para esto en la oleada de advertencia de C# 12 y considerarlo en un error sería un cambio importante.

    §23.4 Variables fijas y movibles:

    En términos precisos, una variable fija es una de las siguientes:

    • una variable resultante de un simple_name (§12.8.4) que hace referencia a una variable local, parámetro de valor o matriz de parámetros, a menos que una función anónima capture la variable (§12.19.6.2) o una función local (§13.6.4) o la variable debe elevarse como parte de un método asincrónico (§15.15) o de iterador (§15.14).
    • [...]
    • Actualmente, tenemos una advertencia existente en la oleada de advertencias de C# 12 para el operador address-of en métodos asíncronos y un error propuesto para el operador address-of en iteradores notificados para LangVersion 13+ (no es necesario notificarlo en versiones anteriores porque era imposible usar código no seguro en iteradores). Podríamos relajar ambas para que se apliquen únicamente a las variables que realmente se elevan, y no a todas las variables locales y parámetros.

    • Podría ser posible usar fixed para obtener la dirección de una variable elevada o capturada, aunque el hecho de que estas sean campos es un detalle de implementación; por lo tanto, en otras implementaciones, podría no ser posible usar fixed con ellas. Tenga en cuenta que solo proponemos considerar también variables elevadas como "movibles", pero las variables capturadas ya eran "movibles" y fixed no estaba permitido para ellas.

  • Podríamos permitir await/yield dentro de unsafe excepto dentro de instrucciones fixed (el compilador no puede anclar variables a través de los límites del método). Esto podría dar lugar a un comportamiento inesperado, por ejemplo, alrededor de stackalloc como se describe en el siguiente apartado anidado. De lo contrario, la elevación de punteros se admite incluso hoy en día en algunos escenarios (hay un ejemplo a continuación relacionado con punteros como argumentos), por lo que no debe haber otras restricciones para permitirlo.

    • Podríamos no permitir la variante no segura de stackalloc en métodos asincrónicos o de iteradores, ya que el búfer asignado a la pila no se encuentra en instrucciones await/yield. No parece necesario porque, por diseño, el código inseguro no impide el "uso después de liberar". Tenga en cuenta que también podríamos permitir stackalloc no seguro siempre que no se use en await/yield, pero analizarlo podría ser difícil (el puntero resultante se puede pasar en cualquier variable de puntero). O podríamos requerir que sea fixed en métodos asincrónicos/iteradores. Esto desaconseja usarlo en await/yield pero no coincidiría con la semántica de fixed porque la expresión stackalloc no es un valor desplazable. (Tenga en cuenta que no sería imposible usar el resultado de stackalloc en await/yield de forma similar, ya que actualmente puede guardar cualquier puntero fixed en otra variable de puntero y usarlo fuera del bloque fixed).
  • Se podría permitir que iteradores y métodos asincrónicos tengan parámetros de puntero. Deberían elevarse, pero eso no debería ser un problema, ya que la elevación de punteros se admite incluso hoy, por ejemplo:

    unsafe public void* M(void* p)
    {
        var d = () => p;
        return d();
    }
    
  • La propuesta mantiene actualmente (y amplía o aclara) la especificación preexistente de que los métodos iteradores comienzan un contexto seguro incluso si se encuentran en un contexto no seguro. Por ejemplo, un método de iterador no es un contexto no seguro aunque se defina en una clase que tenga el modificador unsafe. Como alternativa, podríamos hacer que los iteradores "hereden" el modificador unsafe de la misma manera que otros métodos hacen.

    • Ventaja: elimina la complejidad de la especificación y la implementación.
    • Ventaja: alinea iteradores con métodos asincrónicos (una de las motivaciones de la característica).
    • Desventaja: los iteradores dentro de clases no seguras no podían contener instrucciones yield return, estos iteradores tendrían que definirse en una declaración de clase parcial independiente sin el modificador unsafe.
    • Desventaja: esto sería un cambio importante en LangVersion=13 (se permiten iteradores en clases no seguras en C# 12).
  • En vez de que un iterador defina un contexto seguro solo para el cuerpo, toda la firma podría ser un contexto seguro. Esto es incoherente con el resto del lenguaje, ya que normalmente los cuerpos no afectan a las declaraciones, pero aquí una declaración sería segura o insegura dependiendo de si el cuerpo es un iterador o no. También sería un cambio importante en LangVersion=13, ya que, en C# 12, las firmas de los iteradores no son seguras (pueden incluir parámetros de matrices de punteros, por ejemplo).

  • Aplicar el modificador unsafe a un iterador:

    • Podría afectar tanto al cuerpo como a la firma. Estos iteradores no serían muy útiles, ya que sus cuerpos no seguros no podían contener yield returns; solo podían tener yield breaks.
    • Podría ser un error en LangVersion >= 13, al igual que en LangVersion <= 12, porque no es muy útil tener un miembro iterador no seguro, ya que solo permite tener parámetros de matriz de punteros o establecedores no seguros sin un bloque adicional de código no seguro. Pero los argumentos de puntero normales se podrían permitir en el futuro.
  • Cambio importante de Roslyn:

    • Podríamos conservar el comportamiento actual (e incluso modificar la especificación para que coincida con ella), por ejemplo, introduciendo el contexto seguro en el método iterador, pero volviendo al contexto no seguro en la función local.
    • O podríamos romper todas las versiones de Lang, no solo la 13 y las más recientes.
    • También es posible simplificar más drásticamente las reglas al hacer que los iteradores hereden contexto no seguro como lo hacen todos los demás métodos. Se ha explicado anteriormente. Puede realizarse en todas las LangVersions o solo para LangVersion >= 13.

Reuniones de diseño

  • 03-06-2024: revisión posterior a la implementación del speclet