Partilhar via


Melhorias na estrutura de baixo nível

Observação

Este artigo é uma especificação de recurso. A especificação serve como o documento de design para o recurso. Ele inclui mudanças de especificação propostas, juntamente com as informações necessárias durante o design e desenvolvimento do recurso. Estes artigos são publicados até que as alterações de especificações propostas sejam finalizadas e incorporadas na especificação ECMA atual.

Pode haver algumas discrepâncias entre a especificação do recurso e a implementação concluída. Essas diferenças são capturadas nas notas pertinentes da Language Design Meeting (LDM).

Você pode saber mais sobre o processo de adoção de especificações de recursos no padrão de linguagem C# no artigo sobre as especificações .

Resumo

Esta proposta é um conjunto de várias propostas diferentes para struct melhorias de desempenho: campos ref e a capacidade de substituir as predefinições de ciclo de vida. O objetivo é um projeto que considera as várias propostas para criar um único conjunto de recursos integrados, visando melhorias em struct de baixa complexidade.

Nota: As versões anteriores desta especificação usavam os termos "ref-safe-to-escape" e "safe-to-escape", que foram introduzidos na especificação de recursos de segurança Span. O comité padrão ECMA alterou os nomes para "ref-safe-context" e "safe-context", respectivamente. Os valores do contexto seguro foram refinados para usar o "declaration-block", o "function-member" e o "caller-context" de forma consistente. Os speclets usaram formulações diferentes para estas expressões, e também empregaram "safe-to-return" como sinônimo de "caller-context". Este speclet foi atualizado para usar os termos no padrão C# 7.3.

Nem todos os recursos descritos neste documento foram implementados no C# 11. O C# 11 inclui:

  1. ref campos e scoped
  2. [UnscopedRef]

Esses recursos são propostas abertas para uma versão futura do C#.

  1. ref campos para ref struct
  2. Tipos restritos do pôr-do-sol

Motivação

Versões anteriores do C# adicionaram uma série de recursos de desempenho de baixo nível para a linguagem: retornos de ref, ref struct, ponteiros de função, etc. ... Isso permitiu que os desenvolvedores do .NET escrevessem código de alto desempenho enquanto continuavam a aproveitar as regras da linguagem C# para segurança de tipo e memória. Ele também permitiu a criação de tipos de desempenho fundamentais nas bibliotecas .NET, como Span<T>.

À medida que esses recursos ganharam força no ecossistema .NET, os desenvolvedores internos e externos, têm nos fornecido informações sobre os pontos de atrito restantes no ecossistema. Lugares onde ainda precisam reverter para o código unsafe para fazer o seu trabalho, ou exigem que o tempo de execução trate tipos de casos especiais como Span<T>.

Hoje, Span<T> é realizado usando internal do tipo ByReference<T>, que o tempo de execução efetivamente trata como um campo ref. Isso oferece o benefício dos campos de ref, mas com a desvantagem de que a linguagem não faculta verificação de segurança para esses, como faz para outros usos de ref. Além disso, apenas dotnet/runtime pode usar este tipo, pois é internal, então os terceiros não podem projetar os seus próprios primitivos com base em campos ref. Parte da motivação para este trabalho é remover ByReference<T> e usar campos de ref adequados em todas as bases de código.

A presente proposta pretende abordar estas questões com base nas nossas características de baixo nível existentes. Especificamente visa:

  • Permitir que tipos ref struct declarem campos ref.
  • Permita que o tempo de execução defina totalmente Span<T> usando o sistema de tipos do C# e remova o tipo de caso especial, tal como ByReference<T>
  • Permitir que struct tipos retornem ref para seus campos.
  • Permitir que o tempo de execução remova usos unsafe causados por limitações de padrões vitalícios
  • Permitir a declaração de buffers de fixed seguros para tipos gerenciados e não gerenciados no struct

Projeto de Execução

As regras de segurança ref struct estão definidas no documento de segurança span utilizando os termos anteriores. Essas regras foram incorporadas ao padrão C# 7 no §9.7.2 e §16.4.12. O presente documento descreverá as alterações necessárias à presente proposta. Uma vez aceites como funcionalidade aprovada, estas alterações serão incorporadas nesse documento.

Uma vez concluído este projeto, a nossa definição de Span<T> será a seguinte:

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

Fornecer campos de referência e de âmbito

A linguagem permitirá que os desenvolvedores declarem campos ref dentro de um ref struct. Isso pode ser útil, por exemplo, ao encapsular grandes instâncias de struct mutáveis ou ao definir tipos de alto desempenho, como Span<T> em bibliotecas além do tempo de execução.

ref struct S 
{
    public ref int Value;
}

Um campo ref será emitido em metadados usando a assinatura ELEMENT_TYPE_BYREF. Isso não é diferente de como emitimos variáveis locais ref ou argumentos ref. Por exemplo, ref int _field será emitido como ELEMENT_TYPE_BYREF ELEMENT_TYPE_I4. Isso exigirá que atualizemos ECMA335 para permitir essa entrada, mas isso deve ser bastante simples.

Os desenvolvedores podem continuar a inicializar um ref struct com um campo ref usando a expressão default, caso em que todos os campos ref declarados terão o valor null. Qualquer tentativa de usar esses campos resultará em um NullReferenceException sendo lançado.

ref struct S 
{
    public ref int Value;
}

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

Enquanto a linguagem C# finge que um ref não pode ser null isso é legal no nível de tempo de execução e tem semântica bem definida. Os desenvolvedores que introduzem campos ref em seus tipos precisam estar cientes dessa possibilidade e devem ser fortemente desencorajados de vazar esse detalhe para consumir código. Em vez disso, os campos devem ser validados como não nulos usando os auxiliares de tempo de execução com , e deve ser lançado um erro quando um não inicializado é usado incorretamente.

ref struct S1 
{
    private ref int Value;

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

        return Value;
    }
}

Um campo ref pode ser combinado com os modificadores readonly das seguintes maneiras:

  • readonly ref: Este é um campo que não pode ser ref reatribuído fora de um construtor ou init métodos. Pode ser atribuído valor mesmo fora desses contextos.
  • ref readonly: este é um campo que pode ser reatribuído como referência, mas nunca pode ter um valor atribuído. Desta forma, um parâmetro in pode ser reatribuído ref a um campo ref.
  • readonly ref readonly: uma combinação de ref readonly e 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)
    }
}

Será necessário que um readonly ref struct exija que os campos ref sejam declarados como readonly ref. Não é necessário que sejam declarados readonly ref readonly. Isso permite que um readonly struct sofra mutações indiretas através de tal campo, mas isso não se difere de um campo readonly que atualmente aponta para um tipo de referência (mais detalhes)

Um readonly ref será emitido para metadados usando o sinalizador initonly, igual a qualquer outro campo. Um campo ref readonly será atribuído a System.Runtime.CompilerServices.IsReadOnlyAttribute. Um readonly ref readonly será emitido com ambos os itens.

Esse recurso requer suporte de tempo de execução e alterações na especificação ECMA. Como tal, estes só serão ativados quando o sinalizador de recurso correspondente estiver definido em corelib. O problema em rastrear a API exata é monitorado aqui https://github.com/dotnet/runtime/issues/64165

O conjunto de alterações às nossas regras de contexto seguro necessárias para permitir campos ref é pequeno e específico. As regras já contabilizam ref campos existentes e que estão sendo consumidos a partir de APIs. As alterações devem centrar-se apenas em dois aspetos: a forma como são criadas e como são reatribuídas.

Primeiro, as regras que estabelecem valores de ref-safe-context para campos precisam ser atualizadas para ref campos da seguinte maneira:

Uma expressão na forma ref e.Fref-safe-context da seguinte forma:

  1. Se F é um campo ref, o seu ref-safe-context é o de contexto seguro de e.
  2. Caso contrário, se e for de um tipo de referência, ele terá ref-safe-context de de contexto do chamador
  3. Caso contrário, o seu ref-safe-context é retirado do ref-safe-context de e.

Isso não representa uma mudança de regra, pois as regras sempre levaram em conta a existência do estado ref dentro de um ref struct. De facto, é assim que o estado ref em Span<T> sempre funcionou e as regras de consumo explicam corretamente isso. A alteração visa permitir que os desenvolvedores acedam diretamente aos campos ref e garantir que o façam seguindo as mesmas regras existentes implicitamente aplicadas a Span<T>.

Isso significa, no entanto, que ref campos podem ser retornados como ref de um ref struct mas os campos normais não podem.

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

Isso pode parecer um erro à primeira vista, mas este é um ponto de design deliberado. Mais uma vez, porém, esta não é uma nova regra que está a ser criada por esta proposta, mas sim reconhecer as regras existentes Span<T> comportadas até agora que os desenvolvedores podem declarar seu próprio estado ref.

Em seguida, as regras para reatribuição de ref precisam ser ajustadas para a presença de ref campos. O principal cenário para a reatribuição de ref ocorre quando os construtores ref struct armazenam os parâmetros ref nos campos ref. O apoio será mais geral, mas este é o cenário central. Para dar suporte a isso, as regras para reatribuição de ref serão ajustadas para considerar os campos ref da seguinte forma:

Regras de reatribuição de referências

O operando esquerdo do operador = ref deve ser uma expressão que se liga a uma variável local ref, um parâmetro ref (diferente de this), um parâmetro out, ou um campo ref.

Para uma reatribuição de ref na forma e1 = ref e2 ambas as seguintes condições devem ser verdadeiras:

  1. e2 deve ter o ref-safe-context pelo menos tão grande quanto o ref-safe-context de e1
  2. e1 deve ter o mesmo contexto seguro que e2Nota

Isso significa que o construtor de Span<T> desejado funciona sem qualquer anotação extra:

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

A alteração das regras de reatribuição de ref significa que os parâmetros ref agora podem escapar de um método como um campo ref em um valor ref struct. Conforme discutido na seção sobre as considerações de compatibilidade, isso pode alterar as regras para APIs existentes que nunca tiveram a intenção de que os parâmetros ref fossem expostos como um campo ref. As regras de tempo de vida para os parâmetros baseiam-se apenas na sua declaração e não na sua utilização. Todos os parâmetros ref e in têm ref-safe-context de de contexto do chamador e, portanto, agora podem ser retornados por ref ou um campo ref. Para dar suporte a APIs com parâmetros ref que podem escapar ou não escapar e, assim, restaurar a semântica de chamada do C# 10, a linguagem introduzirá anotações de tempo de vida limitado.

scoped modificador

A palavra-chave scoped será usada para restringir o tempo de vida de um valor. Ele pode ser aplicado a um ou a um valor que seja um e tenha o impacto de restringir o contexto ref-seguro ou a duração de vida do contexto seguro, respectivamente, ao membro de função. Por exemplo:

Parâmetro ou Local ref-safe-context contexto seguro
Span<int> s membro de função contexto do chamador
scoped Span<int> s membro de função membro de função
ref Span<int> s contexto do chamador contexto do chamador
scoped ref Span<int> s membro de função contexto do chamador

Nesta relação, o contexto de referência de segurança de um valor nunca pode ser mais amplo que o contexto seguro .

Isso permite que as APIs em C# 11 sejam anotadas de modo que tenham as mesmas regras que o 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]);
}

A anotação scoped também significa que o parâmetro this de um struct agora pode ser definido como scoped ref T. Anteriormente, tinha que ser tratado como caso especial nas regras como parâmetro ref, que tinha diferentes regras de ref-safe-context em comparação com outros parâmetros ref (veja todas as referências para incluir ou excluir o destinatário nas regras de contexto seguro). Agora, pode ser expresso como um conceito geral em todas as regras, o que as simplifica ainda mais.

A anotação scoped também pode ser aplicada aos seguintes locais:

  • locais: Esta anotação define o tempo de vida como de contexto seguro, ou de contexto ref-safe, no caso de um ref local, para um de membro da função, independentemente do tempo de vida do 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];
}

Outros usos para scoped em locais são discutidos abaixo.

A anotação scoped não pode ser aplicada a qualquer outro local, incluindo retornos, campos, elementos de matriz, etc ... Além disso, embora scoped tenha impacto quando aplicado a qualquer ref, in ou out só tem impacto quando aplicado a valores que são ref struct. Declarações como scoped int não têm impacto, porque algo que não seja ref struct é sempre seguro para devolver. O compilador criará um diagnóstico para esses casos para evitar confusão do desenvolvedor.

Alterar o comportamento dos parâmetros out

Para limitar ainda mais o impacto da alteração de compat de tornar os parâmetros e retornáveis como campos , o idioma alterará o valor de padrão ref-safe-context para parâmetros a serem de membro da função. Efetivamente, out parâmetros serão implicitamente scoped out a partir de agora. Do ponto de vista da compatibilidade, isto significa que não podem ser processados por ref:

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

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

Isso aumentará a flexibilidade das APIs que retornam valores de ref struct e têm parâmetros out porque não precisa mais considerar o parâmetro que está sendo capturado por referência. Isso é importante porque é um padrão comum em APIs de estilo leitor:

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

A linguagem também não considerará mais que os argumentos passados para um parâmetro out sejam retornáveis. Tratar a entrada de um parâmetro out como retornável foi extremamente confuso para os desenvolvedores. Isso essencialmente subverte a intenção de out, forçando os desenvolvedores a considerar o valor passado pelo chamador, que nunca é usado, exceto em linguagens que não respeitam out. No futuro, os idiomas que suportam ref struct devem garantir que o valor original passado para um parâmetro out nunca seja lido.

O C# consegue isso por meio de suas regras de atribuição definidas. Isso tanto alcança as nossas regras de contexto seguro de referência, quanto permite que o código existente atribua e depois retorne valores de parâmetros out.

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

Juntas, essas alterações significam que o argumento para um parâmetro out não contribui com valores de contexto-seguro ou de contexto-de-referência-seguro para invocações de método. Isso reduz significativamente o impacto geral na compatibilidade dos campos de ref, bem como simplifica a forma como os programadores pensam sobre out. Um argumento para um parâmetro out não contribui para o retorno, é simplesmente uma saída.

Inferir de contexto seguro de expressões de declaração

O contexto seguro de uma variável declarada de um argumento () ou desconstrução () é o mais estreito das seguintes:

  • contexto do chamador
  • Se a variável out estiver marcada scoped, então bloco de declaração (ou seja, membro da função ou mais estreito).
  • Se o tipo da variável for ref struct, considere todos os argumentos para a invocação que a contém, incluindo o receptor:
    • de qualquer argumento com contexto seguro, onde o seu parâmetro correspondente não seja e que tenha de contexto seguro de retorno apenas ,, ou mais amplo
    • ref-safe-context de qualquer argumento em que seu parâmetro correspondente tenha ref-safe-context de somente de retorno ou mais ampla

Veja também Exemplos de seguro de contexto inferido de expressões de declaração.

Parâmetros scoped implicitamente

Em geral, existem dois locais ref que são implicitamente declarados como scoped:

  • this num método de instância struct
  • out parâmetros

As regras de contexto seguro para ref serão escritas em termos de scoped ref e ref. Para fins de contexto seguro ref, um parâmetro in é equivalente a ref e out é equivalente a scoped ref. Tanto in como out só serão mencionados especificamente quando for importante para a semântica da regra. Caso contrário, são apenas considerados ref e scoped ref, respectivamente.

Ao discutir o ref-safe-context dos argumentos que correspondem aos parâmetros in, eles serão generalizados como argumentos ref na especificação. No caso de o argumento ser um lvalue, então o ref-safe-context é o do lvalue, caso contrário, é membro de função . Mais uma vezin só será chamado aqui quando for importante para a semântica da regra atual.

Contexto seguro somente de retorno

O design também exige a introdução de um novo contexto seguro: apenas de retorno. Isso é semelhante a de contexto do chamador, na medida em que pode ser retornado, mas só pode ser retornado por meio de uma instrução return.

Os detalhes de apenas retorno são que é um contexto que é maior do que o membro da função , mas menor do que o contexto do chamador . Uma expressão fornecida a uma declaração return deve ser, pelo menos, apenas de retorno. Como tal, a maioria das regras existentes caduca. Por exemplo, a atribuição a um parâmetro de uma expressão com um de contexto seguro de somente de retorno falhará porque é menor do que o de contexto seguro do parâmetro , que é de contexto do chamador. A necessidade deste novo contexto de fuga será discutida abaixo.

Há três locais que usam como padrão somente retorno:

  • Um parâmetro ref ou in terá um ref-safe-context de retorno somente . Isso é feito em parte para ref struct evitar problemas de atribuição cíclica bobos . É feito uniformemente, no entanto, para simplificar o modelo e minimizar as alterações de compatibilidade.
  • Um parâmetro out para um ref struct terá de contexto seguro de somente de retorno. Isso permite que o retorno e a out sejam igualmente expressivos. Isso não tem o problema bobo de atribuição cíclica porque out é, implicitamente, scoped, então o contexto seguro de referência ainda é menor do que o contexto seguro .
  • Um parâmetro para um construtor terá um de contexto seguro de somente de retorno. Devido ao facto de ser modelado como parâmetros out, isso cai.

Qualquer expressão ou instrução que retorne explicitamente um valor de um método ou lambda deve ter um safe-contexte, se aplicável, um ref-safe-context, de pelo menos return-only. Isso inclui declarações return, membros com corpo de expressão e expressões lambda.

Da mesma forma, qualquer atribuição a um out deve ter uma de contexto seguro de pelo menos somente de retorno. No entanto, este não é um caso especial, apenas decorre das regras de atribuição existentes.

Nota: Uma expressão cujo tipo não é um tipo ref struct tem sempre um contexto seguro de contexto do chamador.

Regras para invocação de método

As regras de contexto seguro ref para invocação de método serão atualizadas de várias maneiras. A primeira é reconhecendo o impacto que scoped tem nos argumentos. Para um determinado argumento expr que é passado para o parâmetro p:

  1. Se p é scoped ref, então expr não contribui para ref-safe-context ao considerar argumentos.
  2. Se p é scoped então expr não contribui de contexto seguro ao considerar argumentos.
  3. Se p for outexpr não contribuirá ref-safe-context ou safe-contextmais detalhes

A expressão "não contribui" significa que os argumentos simplesmente não são considerados ao calcular o valor de ref-safe-context ou de safe-context do retorno do método, respectivamente. Isso deve-se ao facto de os valores não poderem contribuir para essa vida útil, uma vez que a anotação scoped o impede.

As regras de invocação do método agora podem ser simplificadas. O recetor já não precisa de tratamento especial, no caso de struct é agora apenas um scoped ref T. As regras de valor precisam ser mudadas para ter em consideração os retornos do campo ref.

Um valor resultante de uma invocação de método e1.M(e2, ...), em que M() não retorna ref-to-ref-struct, tem um contexto seguro extraído do mais estreito dos seguintes:

  1. O contexto do chamador
  2. Quando o valor de retorno é ref struct, o contexto seguro e é contribuído por todas as expressões de argumento.
  3. Quando o retorno é um ref struct o ref-safe-context contribuído por todos os argumentos ref

Se retornar ref-to-ref-struct, o de contexto seguro é o mesmo que o de contexto seguro de todos os argumentos que são ref-to-ref-struct. É um erro se houver vários argumentos com diferentes de contexto seguro devido a argumentos de método devem corresponder.

As regras de chamada ref podem ser simplificadas para:

Um valor resultante de uma invocação de método ref e1.M(e2, ...), onde M() não retorna ref-to-ref-struct, é ref-safe-context o mais estreito dos seguintes contextos:

  1. O contexto do chamador
  2. O contexto seguro contribuído por todas as expressões de argumento
  3. O ref-safe-context contribuído por todos os argumentos ref

Se retornar ref-to-ref-struct, o ref-safe-context é o mais estreito ref-safe-context contribuído por todos os argumentos que são ref-to-ref-struct.

Esta regra agora nos permite definir as duas variantes dos métodos desejados:

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

Regras para inicializadores de objeto

O contexto seguro da expressão inicializadora de um objeto é o mais restrito de:

  1. O contexto seguro da chamada do construtor.
  2. O contexto seguro e o contexto seguro de referência de argumentos para inicializadores de membros em indexadores que possam escapar para o recetor.
  3. O safe-context do RHS de atribuições em inicializadores de membros para setters não readonly ou contexto ref-safe no caso de atribuição de ref.

Outra maneira de modelar isso é pensar em qualquer argumento para um inicializador de membro que possa ser atribuído ao recetor como sendo um argumento para o construtor. Isso ocorre porque o inicializador de membro é efetivamente uma chamada de construtor.

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

Essa modelagem é importante porque demonstra que nossos MAMM precisam especialmente levar em conta os inicializadores de membros. Considere que este caso em particular deve ser ilegal, pois permite que um valor com um contexto seguro mais restrito seja atribuído a um contexto mais amplo.

Os argumentos do método devem corresponder

A presença de campos ref significa que as regras em torno dos argumentos do método devem corresponder à necessidade de ser atualizada, pois um parâmetro ref agora pode ser armazenado como um campo em um argumento ref struct para o método. Anteriormente, a regra só tinha que considerar outro ref struct sendo armazenado como um campo. O impacto disso é discutido em nas considerações de compatibilidade. A nova regra é...

Para qualquer invocação de método e.M(a1, a2, ... aN)

  1. Calcule o contexto seguro mais estreito a partir de:
    • contexto do chamador
    • O contexto seguro de todos os argumentos
    • O ref-safe-context de todos os argumentos ref cujos parâmetros correspondentes têm um ref-safe-context de caller-context
  2. Todos os argumentos ref de tipos ref struct devem poder ser atribuídos a um valor dentro desse contexto seguro . Este é um caso em que refnão generaliza para incluir in e out

Para qualquer invocação de método e.M(a1, a2, ... aN)

  1. Calcule o contexto seguro mais estreito a partir de:
    • contexto do chamador
    • O contexto seguro de todos os argumentos
    • O ref-safe-context de todos os argumentos "ref" cujos parâmetros correspondentes não são scoped
  2. Todos os argumentos out de tipos ref struct devem poder ser atribuídos a um valor dentro desse contexto seguro .

A presença de scoped permite que os desenvolvedores reduzam o atrito que essa regra cria marcando parâmetros que não são retornados como scoped. Isto remove os argumentos deles de (1) em ambos os casos acima e proporciona maior flexibilidade aos chamadores.

O impacto desta mudança é discutido mais profundamente abaixo. No geral, isso permitirá que os desenvolvedores tornem os sites de chamadas mais flexíveis, anotando valores ref-like que não escapem com scoped.

Variação do escopo do parâmetro

O modificador scoped e o atributo [UnscopedRef] (veja abaixo) nos parâmetros também afetam a nossa sobrescrição de objetos, implementação de interface e as regras de conversão delegate. A assinatura para uma substituição, implementação de interface ou uma conversão delegate pode:

  • Adicionar scoped a um parâmetro ref ou in
  • Adicionar scoped a um parâmetro ref struct
  • Remover [UnscopedRef] de um parâmetro out
  • Remover [UnscopedRef] de um parâmetro de ref de um tipo ref struct

Qualquer outra diferença em relação a scoped ou [UnscopedRef] é considerada uma incompatibilidade.

O compilador relatará um diagnóstico para incompatibilidades de escopo inseguras em substituições, implementações de interface e conversões delegadas quando:

  • O método retorna um ref struct ou retorna um ref ou ref readonly, ou o método tem um parâmetro ref ou out de tipo ref struct, e
  • O método tem pelo menos um parâmetro adicional ref, inou out, ou um parâmetro de tipo ref struct.

Estas regras ignoram os parâmetros this porque os métodos de instância ref struct não podem ser usados para sobreposições, implementações de interface ou conversões de delegados.

O diagnóstico é relatado como um erro se as assinaturas incompatíveis estiverem usando regras de contexto seguro ref C#11; caso contrário, o diagnóstico é um aviso .

O aviso de incompatibilidade de escopo pode ser reportado num módulo compilado com as regras de contexto seguro da ref C#7.2, onde scoped não está disponível. Em alguns desses casos, pode ser necessário suprimir o aviso se a outra assinatura incompatível não puder ser modificada.

O modificador scoped e o atributo [UnscopedRef] também têm os seguintes efeitos nas assinaturas do método:

  • O modificador scoped e o atributo [UnscopedRef] não afetam a ocultação
  • As sobrecargas não podem diferir apenas em scoped ou [UnscopedRef]

A seção sobre ref campo e scoped é longa, por isso queria fechar com um breve resumo das mudanças de quebra propostas:

  • Um valor que tenha ref-safe-context para o de contexto do chamador é retornável por ou campo.
  • Um parâmetro teria uma de contexto seguro de de membro da função.

Notas detalhadas:

  • Um campo ref só pode ser declarado dentro de um ref struct
  • Um campo ref não pode ser declarado static, volatile ou const
  • Um campo ref não pode ter um tipo ref struct
  • O processo de geração do conjunto de referência deve preservar a presença de um campo ref dentro de um ref struct
  • Um readonly ref struct deve declarar os seus campos ref como readonly ref
  • Para valores by-ref, o modificador scoped deve aparecer antes de in, outou ref
  • O documento de regras de segurança de span será atualizado conforme descrito neste documento
  • As novas regras de contexto seguro de referência entrarão em vigor quando
    • A biblioteca principal contém o sinalizador de recurso que indica suporte para campos ref
    • O valor langversion é 11 ou superior

Sintaxe

13.6.2 Declarações de variáveis locais: adicionado 'scoped'?.

local_variable_declaration
    : 'scoped'? local_variable_mode_modifier? local_variable_type local_variable_declarators
    ;

local_variable_mode_modifier
    : 'ref' 'readonly'?
    ;

13.9.4 A declaração for: adicionado 'scoped'?indiretamente de local_variable_declaration.

13.9.5 A declaração foreach: adicionado 'scoped'?.

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

12.6.2 Listas de argumentos: adicionado 'scoped'? à variável de declaração out.

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

12.7 Expressões de desconstrução:

[TBD]

15.6.2 Parâmetros do método: adicionado 'scoped'? ao 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 Declarações de delegado: adicionado 'scoped'?indiretamente de fixed_parameter.

12.19 Expressões anónimas de função: foram adicionadas 'scoped'?.

explicit_anonymous_function_parameter
    : 'scoped'? anonymous_function_parameter_modifier? type identifier
    ;

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

Tipos restritos do pôr-do-sol

O compilador tem um conceito de um conjunto de "tipos restritos" que é em grande parte não documentado. Esses tipos receberam um status especial porque no C# 1.0 não havia uma maneira de propósito geral para expressar seu comportamento. Mais notavelmente o fato de que os tipos podem conter referências à pilha de execução. Em vez disso, o compilador tinha um conhecimento especial deles e restringiu seu uso a maneiras que sempre seriam seguras: retornos não permitidos, não podem usar como elementos de matriz, não podem usar em genéricos, etc ...

Quando ref campos estiverem disponíveis e estendidos para suportar ref struct esses tipos poderão ser definidos corretamente em C# usando uma combinação de campos ref struct e ref. Portanto, quando o compilador deteta que um tempo de execução suporta campos ref, ele não terá mais uma noção de tipos restritos. Em vez disso, ele usará os tipos conforme eles são definidos no código.

Para apoiar isso, nossas regras de contexto seguro de referência serão atualizadas da seguinte forma:

  • __makeref será tratada como um método com a assinatura static TypedReference __makeref<T>(ref T value)
  • __refvalue será tratada como um método com a assinatura static ref T __refvalue<T>(TypedReference tr). A expressão __refvalue(tr, int) usará efetivamente o segundo argumento como o parâmetro type.
  • __arglist enquanto parâmetro terá um ref-safe-context e um safe-context do membro da função .
  • __arglist(...) como expressão terá um ref-safe-context e safe-context de membro da função.

Os tempos de execução conformes garantirão que TypedReference, RuntimeArgumentHandle e ArgIterator sejam definidos como ref struct. Além disso, TypedReference deve ser visto como tendo um campo ref para um ref struct para qualquer tipo possível (pode armazenar qualquer valor). Isso combinado com as regras acima garantirá que as referências à pilha não escapem além de sua vida útil.

Nota: estritamente falando, este é um detalhe de implementação do compilador vs. parte da linguagem. Mas, dada a relação com os campos ref, está a ser incluído na proposta de linguagem por uma questão de simplicidade.

Fornecer sem restrições de escopo

Um dos pontos de atrito mais notáveis é a incapacidade de retornar campos por ref em membros de instância de um struct. Isso significa que os desenvolvedores não podem criar métodos/propriedades que retornam ref, tendo que expor diretamente os campos. Isso reduz a utilidade dos retornos de ref em struct, em que é muitas vezes o mais desejado.

struct S
{
    int _field;

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

A lógica para este padrão é razoável, mas não há nada inerentemente errado com um struct escapando this por referência, é simplesmente o padrão escolhido pelas regras de contexto seguro ref.

Para corrigir isto, a linguagem fornecerá o oposto da anotação de duração scoped, ao suportar um UnscopedRefAttribute. Isso pode ser aplicado a qualquer ref e mudará o ref-safe-context para ser um nível mais amplo do que seu padrão. Por exemplo:

UnscopedRef aplicado a Original ref-safe-context Novo contexto seguro de referência
Membro da instância membro de função somente retorno
in / ref parâmetro somente retorno contexto do chamador
Parâmetro out membro de função somente retorno

Ao aplicar [UnscopedRef] a um método de instância de um struct ele tem o impacto de modificar o parâmetro this implícito. Isso significa que this atua como um ref não anotado do mesmo 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;
}

A anotação também pode ser colocada em parâmetros out para restaurá-los ao comportamento C# 10.

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

Para efeitos de regras de contexto seguro de referência, tal [UnscopedRef] out é considerado simplesmente um ref. Semelhante a como in é considerado ref para fins de vida.

A anotação [UnscopedRef] não será permitida em membros init e construtores dentro de struct. Esses membros já são especiais em relação à semântica ref, pois veem os membros readonly como mutáveis. Isso significa que levar ref a esses membros se apresenta como uma simples ref, não ref readonly. Isso é permitido dentro do limite de construtores e init. Permitir [UnscopedRef] possibilitaria que tal ref escapasse incorretamente para fora do construtor e permitisse a mutação após a semântica de readonly ter ocorrido.

O tipo de atributo terá a seguinte definição:

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

Notas detalhadas:

  • Um método ou propriedade de instância anotado com [UnscopedRef] tem ref-safe-context de this configurado para o contexto do chamador .
  • Um membro anotado com [UnscopedRef] não pode implementar uma interface.
  • É um erro usar [UnscopedRef] em
    • Um membro que não é declarado num struct
    • Um membro static, membro init ou construtor num struct
    • Um parâmetro marcado scoped
    • Um parâmetro passado por valor
    • Um parâmetro passado por referência que não tem escopo implícito

ScopedRefAttribute

As anotações scoped serão emitidas em metadados pelo atributo de tipo System.Runtime.CompilerServices.ScopedRefAttribute. O atributo será correspondido pelo nome qualificado pelo namespace, de modo que a definição não precise aparecer em nenhum assembly específico.

O tipo ScopedRefAttribute é apenas para uso do compilador - não é permitido no código-fonte. A declaração de tipo é sintetizada pelo compilador se ainda não estiver incluída na compilação.

O tipo terá a seguinte definição:

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

O compilador emitirá esse atributo no parâmetro com sintaxe scoped. Isso só será emitido quando a sintaxe fizer com que o valor seja diferente de seu estado padrão. Por exemplo, scoped out fará com que nenhum atributo seja emitido.

RefSafetyRulesAttribute

Existem várias diferenças nas regras de contexto ref safe entre C#7.2 e C#11. Qualquer uma dessas diferenças pode resultar em alterações de quebra ao recompilar com C#11 em relação a referências compiladas com C#10 ou anterior.

  1. parâmetros sem escopo de ref/in/out podem escapar de uma invocação de método como um campo ref em um ref struct no C#11, mas não no C#7.2
  2. out parâmetros têm escopo implícito em C#11 e não têm escopo em C#7.2
  3. ref / in parâmetros para ref struct tipos têm escopo implícito em C#11 e não têm um escopo definido em C#7.2

Para reduzir a chance de alterações disruptivas ao recompilar com C#11, atualizaremos o compilador C#11 para usar as regras de contexto seguro ref para a invocação de métodos que correspondam às regras que foram usadas para analisar a declaração de método. Essencialmente, ao analisar uma chamada para um método compilado com um compilador mais antigo, o compilador C#11 usará regras de contexto seguro ref C#7.2.

Para habilitar isso, o compilador emitirá um novo atributo [module: RefSafetyRules(11)] quando o módulo for compilado com -langversion:11 ou superior ou compilado com um corlib contendo o sinalizador de recurso para campos ref.

O argumento para o atributo indica a versão de idioma das regras de contexto seguro ref usadas quando o módulo foi compilado. A versão está atualmente corrigida em 11 independentemente da versão de idioma real passada para o compilador.

A expectativa é que as futuras versões do compilador atualizem as regras de contexto ref safe e emitam atributos com versões distintas.

Se o compilador carregar um módulo que inclui um [module: RefSafetyRules(version)]com um version diferente de 11, ele emitirá um aviso para a versão não reconhecida caso haja chamadas para métodos declarados nesse módulo.

Quando o compilador C#11 analisa uma chamada de método:

  • Se o módulo que contém a declaração de método incluir [module: RefSafetyRules(version)], independentemente de version, a chamada de método será analisada com regras C#11.
  • Se o módulo que contém a declaração de método for da fonte e compilado com -langversion:11 ou com um corlib que contém o sinalizador de funcionalidade para os campos de ref, a chamada do método é analisada de acordo com as regras do C#11.
  • Se o módulo que contém a declaração de método referencia System.Runtime { ver: 7.0 }, a chamada de método é analisada com regras C#11. Esta regra é uma atenuação temporária para módulos compilados com visualizações anteriores do C#11 / .NET 7 e será removida posteriormente.
  • Caso contrário, a chamada de método é analisada com regras C#7.2.

Um compilador pré-C#11 ignorará qualquer RefSafetyRulesAttribute e analisará chamadas de método apenas com regras C#7.2.

O RefSafetyRulesAttribute será correspondido pelo nome qualificado do namespace, para que a definição não precise aparecer em nenhum assembly específico.

O tipo RefSafetyRulesAttribute é apenas para uso do compilador - não é permitido no código-fonte. A declaração de tipo é sintetizada pelo compilador se ainda não estiver incluída na compilação.

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

Buffers de tamanho fixo seguro

Buffers seguros de tamanho fixo não foram entregues em C# 11. Esse recurso pode ser implementado em uma versão futura do C#.

A linguagem relaxará as restrições em matrizes de tamanho fixo, de modo que elas possam ser declaradas em código seguro e o tipo de elemento possa ser gerenciado ou não gerenciado. Isso tornará legais tipos como os seguintes:

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

Essas declarações, assim como as suas contrapartes unsafe, definirão uma sequência de elementos N no tipo que contém. Esses membros podem ser acessados com um indexador e também podem ser convertidos em instâncias Span<T> e ReadOnlySpan<T>.

Ao indexar em um buffer de fixed do tipo T o estado readonly do contêiner deve ser levado em consideração. Se o contêiner estiver readonly, o indexador retornará ref readonly T senão ele retornará ref T.

Aceder a um buffer de fixed sem um indexador não tem tipo natural; no entanto, é conversível para tipos Span<T>. No caso de o recipiente ser readonly o buffer é implicitamente conversível em ReadOnlySpan<T>, caso contrário, ele pode implicitamente converter em Span<T> ou ReadOnlySpan<T> (a conversão Span<T> é considerada melhor).

A instância Span<T> resultante terá um comprimento igual ao tamanho declarado no buffer de fixed. O de contexto seguro do valor retornado será igual ao de contexto seguro do contêiner, assim como seria se os dados de backup fossem acessados como um campo.

Para cada declaração fixed em um tipo em que o tipo de elemento é T a linguagem gerará um método indexador correspondente get somente cujo tipo de retorno é ref T. O indexador será anotado com o atributo [UnscopedRef], uma vez que a implementação irá retornar campos do tipo que declara. A acessibilidade do membro corresponderá à acessibilidade no campo fixed.

Por exemplo, a assinatura do indexador para CharBuffer.Data será a seguinte:

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

Se o índice fornecido estiver fora dos limites declarados da matriz fixed, uma IndexOutOfRangeException será lançada. No caso de ser fornecido um valor constante, este será substituído por uma referência direta ao elemento adequado. A menos que a constante esteja fora dos limites declarados, caso em que ocorreria um erro de tempo de compilação.

Também será gerado um acessor nomeado para cada buffer de fixed que oferece operações de valor get e set. Isso significa que os buffers de fixed se assemelharão mais à semântica de matriz existente por terem um acessador de ref, bem como as operações byval de get e set. Isso significa que os compiladores terão a mesma flexibilidade ao emitir código consumindo buffers de fixed como ao consumir matrizes. Isso deve tornar operações como await sobre fixed buffers mais fáceis de emitir.

Isso tem também a vantagem de tornar os buffers fixed mais fáceis de serem utilizados por outras linguagens. Indexadores nomeados é um recurso que existe desde a versão 1.0 do .NET. Mesmo linguagens que não podem emitir diretamente um indexador nomeado geralmente podem consumi-los (C# é realmente um bom exemplo disso).

O armazenamento de backup para o buffer será gerado usando o atributo [InlineArray]. Trata-se de um mecanismo discutido na edição questão 12320, que permite especificamente declarar eficientemente a sequência de campos do mesmo tipo. Esta questão em particular ainda está em discussão ativa, e a expectativa é que a implementação desta funcionalidade ocorrerá conforme o desenrolar dessa discussão.

Inicializadores com valores ref em expressões new e with

Na seção 12.8.17.3 Inicializadores de objeto, atualizamos a gramática para:

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

Na seção da expressão with, a gramática foi atualizada para:

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

O operando esquerdo da atribuição deve ser uma expressão que se liga a um campo ref.
O operando direito deve ser uma expressão que resulte em um valor lvalue designando um valor do mesmo tipo que o operando esquerdo.

Adicionamos uma regra semelhante a reatribuição local ref:
Se o operando esquerdo é uma ref gravável (ou seja, designa qualquer coisa diferente de um campo ref readonly), então o operando direito deve ser um lvalue gravável.

As regras de escape para as invocações do construtor permanecem:

Uma expressão new que invoca um construtor obedece às mesmas regras que uma invocação de método que é considerada para retornar o tipo que está sendo construído.

Ou seja, as regras de invocação do método atualizadas acima:

Um rvalue resultante de uma invocação de método e1.M(e2, ...) tem de contexto seguro a partir do menor dos seguintes contextos:

  1. O contexto do chamador
  2. O contexto seguro contribuído por todas as expressões de argumento
  3. Quando o retorno é um ref struct então contexto ref-safe- contribuído por todos os argumentos ref

Para uma expressão new com inicializadores, as expressões inicializadoras contam como argumentos (elas contribuem com o seu contexto seguro) e as expressões inicializadoras ref contam como argumentos de ref (elas contribuem com o seu contexto seguro de referência), recursivamente.

Alterações em contexto inseguro

Os tipos de ponteiro (seção 23.3) são estendidos para permitir tipos gerenciados como tipo referente. Esses tipos de ponteiro são escritos como um tipo gerenciado seguido por um token *. Eles produzem um aviso.

O endereço do operador (seção 23.6.5) é relaxado para aceitar uma variável com um tipo gerenciado como seu operando.

A instrução fixed (secção23.7) foi flexibilizada para aceitar um fixed_pointer_initializer que seja o endereço de uma variável de tipo gerido T ou que seja uma expressão de um tipo_array com elementos de um tipo gerido T.

O inicializador de alocação de pilha (seção 12.8.22) é igualmente relaxado.

Considerações

Há considerações que outras partes da pilha de desenvolvimento devem considerar ao avaliar esse recurso.

Considerações sobre Compatibilidade

O desafio da presente proposta prende-se com as implicações de compatibilidade que esta conceção tem para as nossas atuais regras de segurança , ou §9.7.2. Embora essas regras suportem totalmente o conceito de um ref struct ter ref campos, elas não permitem que APIs além de stackalloccapturem ref estado que faz referência à pilha. As regras de contexto seguro ref têm uma suposição , ou §16.4.12.8 de que não existe um construtor da forma Span(ref T value). Isso significa que as regras de segurança não levam em conta que um parâmetro ref pode escapar como um campo ref, permitindo assim código como o seguinte.

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

Efetivamente, há três maneiras de um parâmetro ref escapar de uma invocação de método:

  1. Por retorno de valor
  2. Por ref regresso
  3. Por ref campo no ref struct que é retornado ou passado como parâmetro ref / out

As regras existentes apenas têm em conta os pontos 1 e 2. Eles não contabilizam (3), e, portanto, lacunas como o retorno dos locais como campos ref não são contabilizadas. Este desenho ou modelo deve alterar as regras para ter em conta (3). Isso terá um pequeno impacto na compatibilidade das APIs existentes. Especificamente, isso afetará as APIs que têm as seguintes propriedades.

  • Tenha um ref struct na assinatura
    • Onde o ref struct é um tipo de retorno, ref ou parâmetro out
    • Tem um parâmetro adicional in ou ref excluindo o recetor

Em C# 10, os chamadores dessas APIs nunca tiveram que considerar que a entrada de estado ref para a API podia ser capturada como um campo ref. Isso permitiu para que vários padrões pudessem existir, com segurança em C# 10, que não seriam seguros em C# 11 devido à capacidade do estado ref escapar como um campo ref. Por exemplo:

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

Prevê-se que o impacto desta quebra de compatibilidade seja muito reduzido. A forma da API impactada fez pouco sentido na ausência de campos ref, portanto, é improvável que os clientes tenham criado muitos deles. Experimentos executando ferramentas de deteção dessa forma de API em repositórios existentes corroboram essa afirmação. O único repositório com contagens significativas dessa forma é dotnet/runtime e isso ocorre porque esse repositório pode criar campos de ref através do tipo intrínseco ByReference<T>.

Mesmo assim, o design deve levar em conta tais APIs existentes porque expressa um padrão válido, mas não comum. Portanto, o design deve dar aos desenvolvedores as ferramentas para restaurar as regras de vida existentes ao atualizar para C# 10. Especificamente, ele deve fornecer mecanismos que permitam aos desenvolvedores anotar ref parâmetros como incapazes de escapar por ref ou ref campo. Isso permite que os clientes definam APIs em C# 11 que tenham as mesmas regras de site de chamada do C# 10.

Assembléias de referência

Um conjunto de referência para uma compilação utilizando as características descritas na presente proposta deve manter os elementos que transmitem informações de contexto seguras de referência. Isso significa que todos os atributos de anotação vitalícia devem ser preservados em sua posição original. Qualquer tentativa de substituí-los ou omiti-los pode resultar em conjuntos de referência inválidos.

Representar campos ref é mais nuanceado. Idealmente, um campo ref apareceria em um conjunto de referência, assim como qualquer outro campo. No entanto, um campo ref representa uma alteração no formato de metadados e isso pode causar problemas com cadeias de ferramentas que não são atualizadas para entender essa alteração de metadados. Um exemplo concreto é C++/CLI, que provavelmente apresentará um erro se consumir um campo ref. Portanto, é vantajoso se os campos ref puderem ser omitidos de assemblies de referência nas nossas bibliotecas principais.

Um campo ref por si só não tem impacto nas regras de contexto seguro de referência. Como exemplo concreto, considere que inverter a definição de Span<T> existente para usar um campo ref não tem impacto no consumo. Por isso, a própria ref pode ser omitida com segurança. No entanto, um campo ref tem outros impactos no consumo que devem ser preservados:

  • Uma ref struct que tenha um campo ref nunca é considerada unmanaged
  • O tipo de campo ref afeta infinitas regras de expansão genéricas. Portanto, se o tipo de um campo ref contém um parâmetro de tipo que deve ser preservado

Dadas essas regras, aqui está uma transformação de montagem de referência válida para um 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
}

Anotações

O tempo de vida é expresso mais naturalmente usando tipos. A vida útil de um determinado programa é segura quando os tipos de vida útil são verificados. Embora a sintaxe do C# implicitamente adicione tempos de vida aos valores, há um sistema de tipos subjacente que descreve as regras fundamentais aqui. Muitas vezes, é mais fácil discutir a implicação de mudanças no design em termos dessas regras, então elas são incluídas aqui para fins de discussão.

Note que esta não se destina a ser uma documentação 100% completa. Documentar cada comportamento não é um objetivo aqui. Em vez disso, destina-se a estabelecer um entendimento geral e uma linguagem comum pela qual o modelo, e as possíveis mudanças nele, possam ser discutidos.

Normalmente, não é necessário falar diretamente sobre tipos de duração. As exceções são locais onde o tempo de vida pode variar com base em sites de "instanciação" específicos. Este é um tipo de polimorfismo e chamamos essas vidas variáveis de "vidas genéricas", representadas como parâmetros genéricos. C# não fornece sintaxe para expressar genéricos vitalícios, então definimos uma "tradução" implícita de C# para uma linguagem reduzida expandida que contém parâmetros genéricos explícitos.

Os exemplos abaixo fazem uso de tempos de vida nomeados. A sintaxe $a refere-se a uma vida útil chamada a. É uma vida que não tem significado por si só, mas pode ser dada uma relação com outras vidas através da sintaxe where $a : $b. Isto estabelece que $a é convertível em $b. Pode ser útil pensar nisso como estabelecer que $a é uma duração pelo menos tão longa quanto $b.

Existem algumas durações predefinidas para conveniência e brevidade abaixo:

  • $heap: este é o tempo de vida de qualquer valor que exista no montículo. Está disponível em todos os contextos e assinaturas de método.
  • $local: Este é o tempo de vida de qualquer valor que existe na pilha de métodos. É efetivamente um espaço reservado de nome paramembro da função . Ele é implicitamente definido em métodos e pode aparecer em assinaturas de método, exceto para qualquer posição de saída.
  • $ro: nome placeholder para apenas retornar
  • $cm: espaço reservado para nome no contexto do chamador

Existem algumas relações predefinidas entre vidas:

  • where $heap : $a para todas as durações de vida $a
  • where $cm : $ro
  • where $x : $local para todos os tempos de vida predefinidos. Os tempos de vida definidos pelo usuário não têm relação com o local, a menos que explicitamente definidos.

As variáveis do tempo de vida, quando definidas em tipos, podem ser invariantes ou covariantes. Estes são expressos usando a mesma sintaxe que os parâmetros genéricos:

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

O parâmetro de tempo de vida $this nas definições de tipo não é predefinido, mas tem algumas regras associadas a ele quando é definido:

  • Deve ser o primeiro parâmetro de tempo de vida.
  • Deve ser covariante: out $this.
  • O tempo de vida dos campos de ref deve ser convertível em $this
  • O tempo de vida $this de todos os campos não de referência deve ser $heap ou $this.

O tempo de vida de uma referência é expresso fornecendo um argumento de tempo de vida à referência. Por exemplo, um ref que se refere à pilha é expresso como ref<$heap>.

Ao definir um construtor no modelo, o nome new será usado para o método. É necessário ter uma lista de parâmetros para o valor retornado, bem como os argumentos do construtor. Isso é necessário para expressar a relação entre as entradas do construtor e o valor construído. Em vez de ter Span<$a><$ro> o modelo usará Span<$a> new<$ro> em vez disso. O tipo de this no construtor, incluindo tempo de vida, será definido como o valor de retorno.

As regras básicas para a vida útil são definidas como:

  • Todos os tempos de vida são expressos sintaticamente como argumentos genéricos, vindo antes dos argumentos de tipo. Isso é verdade para vidas predefinidas, exceto $heap e $local.
  • Todos os tipos T que não são um ref struct implicitamente têm vida útil de T<$heap>. Isso está implícito, não há necessidade de escrever int<$heap> em cada amostra.
  • Para um campo ref definido como ref<$l0> T<$l1, $l2, ... $ln>:
    • Todas as durações de vida $l1 até $ln devem ser invariáveis.
    • O tempo de vida do $l0 deve ser convertível em $this
  • Para um ref definido como ref<$a> T<$b, ...>, $b deve ser conversível em $a
  • O ref de uma variável tem um tempo de vida definido por:
    • Para um ref local, parâmetro, campo ou retorno do tipo ref<$a> T o tempo de vida é $a
    • $heap para todos os tipos de referência e campos de tipos de referência
    • $local para todo o resto
  • Uma cessão ou devolução é legal quando a conversão de tipo subjacente é legal
  • O tempo de vida das expressões pode ser explicitado usando anotações de elenco:
    • (T<$a> expr) o tempo de vida do valor é explicitamente $a para T<...>
    • ref<$a> (T<$b>)expr o tempo de vida do valor é $b no caso de T<...> e o tempo de vida de referência é $a.

Para fins de regras de tempo de vida, um ref é considerado parte do tipo da expressão para efeitos de conversões. É logicamente representado pela conversão de ref<$a> T<...> em ref<$a, T<...>> onde $a é covariante e T é invariante.

Em seguida, vamos definir as regras que nos permitem mapear a sintaxe C# para o modelo subjacente.

Por uma questão de brevidade, um tipo que não tem parâmetros explícitos de tempo de vida é tratado como se houvesse out $this definido e aplicado a todos os campos do tipo. Um tipo com um campo ref deve definir parâmetros explícitos de tempo de vida.

Estas regras existem para suportar a nossa invariante existente que T pode ser atribuída a scoped T para todos os tipos. Isso significa que T<$a, ...> pode ser atribuído a T<$local, ...> para todos os períodos de vida conhecidos por serem conversíveis para $local. Além disso, isso suporta outros itens, como poder atribuir Span<T> do heap para aqueles na pilha. Isso exclui tipos em que os campos têm tempos de vida diferentes para valores não ref, mas essa é a realidade do C# hoje. Alterar isso exigiria uma mudança significativa nas regras do C# que precisariam ser mapeadas.

O tipo de this para um tipo S<out $this, ...> dentro de um método de instância é implicitamente definido da seguinte forma:

  • Para o método de instância normal: ref<$local> S<$cm, ...>
  • Por exemplo, método anotado com [UnscopedRef]: ref<$ro> S<$cm, ...>

A falta de um parâmetro this explícito força as regras implícitas aqui. Para exemplos e discussões complexas, considere a escrita como um método static e torne this um 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) { }
}

A sintaxe do método C# corresponde ao modelo das seguintes maneiras:

  • Os parâmetros de ref têm um tempo de vida de referência de $ro
  • parâmetros do tipo ref struct têm esta vida útil de $cm
  • As devoluções ref têm uma duração ref de $ro
  • Os retornos do tipo ref struct têm um tempo de vida de $ro como valor
  • scoped em um parâmetro ou ref altera o tempo de vida da ref para ser $local

Dado isso, vamos explorar um exemplo simples que demonstra o modelo aqui:

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

Agora vamos explorar o mesmo exemplo usando um 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;
}

Em seguida, vamos ver como isso ajuda com o problema de autoatribuição 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;
    }
}

Em seguida, vamos ver como isso ajuda com o problema do parâmetro de captura bobo:

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

Questões em aberto

Altere o design para evitar quebras de compatibilidade

Este design propõe várias quebras de compatibilidade com as nossas regras de ref-safe-context existentes. Apesar de se acreditar que as alterações sejam minimamente impactantes, foi dada uma consideração significativa a um design sem mudanças que causassem descontinuidades.

O design de preservação da compatibilidade, no entanto, era significativamente mais complexo do que este aqui. A fim de preservar os campos ref de compat, é necessário que tenham tempos de vida distintos para a capacidade de retornar por campo ref e por campo ref. Essencialmente, requer que forneçamos rastreamento de de contexto ref-field-safe-para todos os parâmetros para um método. Isso precisa ser calculado para todas as expressões e rastreado em todos os valores, praticamente em todos os lugares onde o contexto ref-safe de é rastreado hoje.

Além disso, este valor tem relações a ref-safe-context. Por exemplo, não faz sentido ter um valor que pode ser retornado como um campo ref, mas não diretamente como ref. Isso ocorre porque ref campos podem ser retornados trivialmente por ref já (ref estado em um ref struct pode ser retornado por ref mesmo quando o valor que contém não pode). Por conseguinte, as regras necessitam ainda de um ajustamento constante para garantir que estes valores são sensatos uns em relação aos outros.

Também significa que a linguagem precisa de sintaxe para representar ref parâmetros que podem ser retornados de três maneiras diferentes: por ref campo, por ref e por valor. O padrão é retornável por ref. No futuro, porém, espera-se que o retorno mais natural, especialmente quando ref struct estão envolvidos, seja pelo campo ref ou ref. Isso significa que as novas APIs exigem uma anotação de sintaxe extra para estarem corretas por padrão. Isto é indesejável.

Essas alterações de compatibilidade, no entanto, afetarão os métodos que têm as seguintes propriedades:

  • Tenha um Span<T> ou ref struct
    • Onde o ref struct é um tipo de retorno, ref ou parâmetro out
    • Tem um parâmetro adicional in ou ref (excluindo o recetor)

Para entender o impacto, é útil dividir as APIs em categorias:

  1. Queremos que os consumidores considerem ref capturado como um campo ref. O exemplo principal são os construtores Span(ref T value)
  2. Não se quer que os consumidores considerem ref sendo capturado como um campo ref. Estes, porém, dividem-se em duas categorias:
    1. APIs inseguras. Estes são APIs dentro dos tipos Unsafe e MemoryMarshal, dos quais MemoryMarshal.CreateSpan é o mais proeminente. Essas APIs capturam o ref de forma insegura, mas também são conhecidas por serem APIs inseguras.
    2. APIs seguras. Estas são APIs que aceitam ref parâmetros para eficiência, mas não é realmente registado em lugar nenhum. Os exemplos são pequenos, mas um é AsnDecoder.ReadEnumeratedBytes

Esta alteração beneficia principalmente (1) acima. Espera-se que eles constituam a maioria das APIs que tomam um ref e retornam um ref struct no futuro. As alterações impactam negativamente (2.1) e (2.2), pois quebram a semântica da chamada existente devido à mudança das regras de ciclo de vida.

As APIs na categoria (2.1) são, no entanto, em grande parte criadas pela Microsoft ou por desenvolvedores que mais se beneficiam de campos ref (os 'Tanners' do mundo). É razoável supor que essa classe de desenvolvedores estaria disposta a aceitar um imposto de compatibilidade na atualização para C# 11 para manter a semântica existente na forma de algumas anotações, se lhes fossem fornecidos campos ref em retorno.

As APIs na categoria (2.2) são o maior problema. Não se sabe quantas APIs existem e não está claro se elas seriam mais ou menos frequentes no código de terceiros. A expectativa é que haja um número muito pequeno deles, especialmente se considerarmos a quebra de compatibilidade em out. As pesquisas efetuadas até à data revelaram um número muito reduzido destes existentes na superfície public. Este é um padrão difícil de pesquisar, pois requer análise semântica. Antes de proceder a esta alteração, seria necessária uma abordagem baseada em ferramentas para verificar os pressupostos em torno desta situação, com impacto num pequeno número de casos conhecidos.

Para ambos os casos na categoria (2), embora a correção seja simples. Os parâmetros ref que não querem ser considerados capturáveis devem adicionar scoped ao ref. Em (2.1), isso provavelmente também forçará o desenvolvedor a usar Unsafe ou MemoryMarshal mas isso é esperado para APIs de estilo inseguro.

Idealmente, a linguagem poderia reduzir o impacto de alterações de quebra silenciosas emitindo um aviso quando uma API cai silenciosamente no comportamento problemático. Esse seria um método que leva um ref, retorna ref struct, mas não efetivamente captura o ref no ref struct. O compilador pode emitir um diagnóstico nesse caso, informando aos desenvolvedores que tais ref devem ser anotados como scoped ref em vez disso.

Decisão Este design pode ser alcançado, mas o recurso resultante é mais difícil de usar a tal ponto que foi tomada a decisão de fazer a quebra de compatibilidade.

Decisão O compilador fornecerá um aviso quando um método atender aos critérios, mas não capturar o parâmetro ref como um campo ref. Isso deve avisar adequadamente os clientes durante a atualização sobre os possíveis problemas que estão a criar.

Palavras-chave vs. atributos

Este design requer o uso de atributos para anotar as novas regras de ciclo de vida. Isso também poderia ter sido feito com a mesma facilidade com palavras-chave contextuais. Por exemplo, [DoesNotEscape] poderia corresponder a scoped. No entanto, as palavras-chave, mesmo as contextuais, geralmente devem atender a uma barra muito alta para a inclusão. Eles ocupam um espaço linguístico valioso e são elementos mais destacados na língua. Esse recurso, embora valioso, servirá a uma minoria de desenvolvedores de C#.

Superficialmente, isso parece favorecer o não uso de palavras-chave, mas há dois pontos importantes a considerar:

  1. As anotações afetarão a semântica do programa. Ter atributos que impactam a semântica do programa é uma linha que C# reluta em cruzar e não é claro se este é o recurso que deve justificar a linguagem a dar esse passo.
  2. Os programadores mais propensos a usar este recurso têm uma forte interseção com o grupo de programadores que usam apontadores de função. Esse recurso, embora também usado por uma minoria de desenvolvedores, garantiu uma nova sintaxe e essa decisão ainda é vista como sólida.

Em conjunto, isto significa que a sintaxe deve ser considerada.

Um esboço aproximado da sintaxe seria:

  • [RefDoesNotEscape] mapeia para scoped ref
  • [DoesNotEscape] mapeia para scoped
  • [RefDoesEscape] mapeia para unscoped

Decisão Utilizar sintaxe para scoped e scoped ref; use atributo para unscoped.

Permitir locais de buffer fixo

Este design disponibiliza buffers de fixed seguros que podem suportar qualquer tipo. Uma extensão possível aqui é permitir que esses buffers de fixed sejam declarados como variáveis locais. Isto permitiria substituir uma série de operações de stackalloc existentes por um buffer de fixed. Também expandiria o conjunto de cenários em que poderíamos ter alocações tipo stack, uma vez que stackalloc é limitado a tipos de elementos não geridos, enquanto os buffers fixed não são.

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

Isso se mantém, mas exige que estendamos um pouco a sintaxe para as variáveis locais. Não está claro se isso vale ou não a pena a complexidade extra. É possível que possamos decidir não por agora e trazer de volta mais tarde, se for demonstrada a necessidade suficiente.

Exemplo de onde isso seria benéfico: https://github.com/dotnet/runtime/pull/34149

Decisão adiar isto por enquanto

Para usar modreqs ou não

Uma decisão precisa ser tomada se os métodos marcados com novos atributos de vida útil devem ou não se traduzir em modreq na emissão. Haveria efetivamente um mapeamento de 1:1 entre anotações e modreq se essa abordagem fosse adotada.

A lógica para adicionar um modreq é que os atributos alteram a semântica das regras de contexto seguro ref. Apenas as línguas que compreendem estas semânticas devem pôr em causa os métodos em questão. Além disso, quando aplicados a cenários OHI, os ciclos de vida tornam-se um contrato que todos os métodos derivados devem implementar. A existência de anotações sem modreq pode levar a situações em que cadeias de método virtual com anotações de tempo de vida conflitantes são carregadas (pode ocorrer se apenas uma parte da cadeia virtual for compilada e a outra não).

O trabalho inicial de contexto seguro não utilizou o modreq, mas baseou-se nas linguagens e no framework para compreender. Ao mesmo tempo, embora todos os elementos que contribuem para as regras seguras de contexto 'ref' sejam uma parte forte da assinatura do método: ref, in, ref struct, etc... Portanto, qualquer alteração nas regras existentes de um método já resulta numa alteração binária na assinatura. Para dar às novas anotações permanentes o mesmo impacto, elas precisarão de aplicação modreq.

A preocupação é se isso é ou não exagero. Tem o efeito negativo de que tornar as assinaturas mais flexíveis, por exemplo, adicionando [DoesNotEscape] a um parâmetro, resultará em uma alteração de compatibilidade binária. Essa troca significa que, com o tempo, estruturas como a BCL provavelmente não serão capazes de relaxar essas assinaturas. Poderia ser mitigado até certo ponto adotando uma abordagem semelhante à que a linguagem utiliza com os parâmetros in e aplicando modreq apenas em posições virtuais.

Decisão Não utilizar modreq em metadados. A diferença entre out e ref não é modreq mas eles agora têm diferentes valores de contexto ref safe. Não há nenhum benefício real em cumprir parcialmente as regras com modreq aqui.

Permitir buffers fixos multidimensionais

O projeto dos buffers de fixed deve ser estendido para incluir matrizes de estilo multidimensionais? Essencialmente permitindo declarações como as seguintes:

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

Decisão Não permitir por enquanto

Violando o escopo

O repositório runtime tem várias APIs não públicas que capturam parâmetros ref como campos ref. Estes não são seguros porque o tempo de vida do valor resultante não é rastreado. Por exemplo, o construtor Span<T>(ref T value, int length).

A maioria dessas APIs provavelmente escolherá implementar um monitoramento adequado do tempo de vida no valor retornado, algo que poderá ser alcançado simplesmente ao atualizar para o C# 11. Alguns, no entanto, vão querer manter sua semântica atual de não rastrear o valor de retorno porque toda a sua intenção é ser inseguro. Os exemplos mais notáveis são MemoryMarshal.CreateSpan e MemoryMarshal.CreateReadOnlySpan. Isto será conseguido marcando os parâmetros como scoped.

Isso significa que o tempo de execução precisa de um padrão estabelecido para remover inseguramente scoped de um parâmetro:

  1. Unsafe.AsRef<T>(in T value) poderia expandir seu propósito atual mudando para scoped in T value. Isso permitiria remover in e scoped dos parâmetros. Em seguida, torna-se o método universal de "remover referência de segurança"
  2. Introduza um novo método cuja finalidade é remover scoped: ref T Unsafe.AsUnscoped<T>(scoped in T value). Isso também elimina in porque, caso contrário, os utilizadores ainda precisariam de uma combinação de chamadas de método para "remover a segurança de referência", altura em que a solução existente provavelmente será suficiente.

Sem escopo por padrão?

O design tem apenas dois locais que são scoped por padrão:

  • this é scoped ref
  • out é scoped ref

A decisão sobre out é reduzir significativamente a carga de compatibilidade dos campos de ref e, ao mesmo tempo, é um padrão mais natural. Ele permite que os desenvolvedores pensem em out como dados fluindo apenas para fora, enquanto, se for ref, então as regras devem considerar os dados fluindo em ambas as direções. Isso leva a uma confusão significativa do desenvolvedor.

A decisão sobre this é indesejável porque significa que um struct não pode retornar um campo por ref. Este é um cenário importante para desenvolvedores de alto desempenho e o atributo [UnscopedRef] foi adicionado essencialmente para este cenário.

Palavras-chave têm critérios elevados, e adicioná-las para um único cenário levanta suspeitas. Como tal, pensou-se se poderíamos evitar esta palavra-chave fazendo com que this simplesmente ref por defeito e não scoped ref. Todos os membros que precisam this para serem scoped ref podem fazê-lo assim, marcando o método scoped (da mesma forma que um método pode ser marcado readonly para criar um readonly ref atualmente).

Em um struct normal, esta é principalmente uma mudança positiva, pois só introduz problemas de compatibilidade quando um membro tem um retorno ref. Existem muito poucos desses métodos e uma ferramenta poderia detetá-los e convertê-los em scoped membros rapidamente.

Num ref struct, esta alteração introduz problemas significativamente maiores de compatibilidade. Considere o seguinte:

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

Essencialmente, isso significaria que todas as invocações de método de instância em locais mutáveis seriam ilegais, a menos que o local esteja marcado como . As regras têm de considerar o caso em que os campos foram reatribuídos a outros campos em this. Um readonly ref struct não tem esse problema porque a natureza readonly impede a reatribuição de ref. Ainda assim, isto seria uma mudança significativa que rompe a compatibilidade com versões anteriores, pois impactaria praticamente todos os ref structmutáveis existentes.

Um readonly ref struct, no entanto, ainda é problemático quando expandimos para incluir campos do ref até ao ref struct. Ele permite o mesmo problema básico, apenas movendo a captura para o valor do 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);
    }
}

Pensou-se na ideia de fazer com que this tivesse diferentes padrões com base no tipo de struct ou membro. Por exemplo:

  • this como ref: struct, readonly ref struct ou readonly member
  • this como scoped ref: ref struct ou readonly ref struct com campo ref para ref struct

Isso minimiza as quebras de compatibilidade e maximiza a flexibilidade, mas complica a situação para os clientes. Também não resolve totalmente o problema porque recursos futuros, como buffers de fixed seguros, exigem que um ref struct mutável tenha retornos ref para campos que não funcionam apenas com esse design, pois se enquadrariam na categoria scoped ref.

Decisão manter this como scoped ref. Isso significa que os exemplos sorrateiros anteriores produzem erros do compilador.

campos ref para ref struct

Esse recurso abre um novo conjunto de regras de contexto de referência segura porque permite que um campo ref se refira a um ref struct. Essa natureza genérica de ByReference<T> significava que até agora o ambiente de execução não poderia ter tal estrutura. Como resultado, todas as nossas regras são escritas sob o pressuposto de que isso não é possível. A característica do campo ref não se trata, em grande parte, de fazer novas regras, mas de codificar as regras existentes no nosso sistema. Permitir que campos de ref a ref struct exige que codifiquemos novas regras, porque há vários cenários novos a serem considerados.

A primeira é que um readonly ref agora é capaz de armazenar um estado ref. Por exemplo:

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

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

Isso significa que, ao considerar os argumentos de método que devem corresponder às regras, devemos perceber que readonly ref T é a saída potencial do método quando T potencialmente possui um campo ref para um ref struct.

A segunda questão é que a linguagem deve considerar um novo tipo de contexto seguro: ref-field-safe-context. Todos os ref struct que contêm transitivamente um campo ref têm outro escopo de escape que representa o(s) valor(es) no(s) campo(s) ref. No caso de vários campos ref, eles podem ser rastreados coletivamente como um único valor. O valor padrão para isto para parâmetros é do contexto do chamador.

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

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

Esse valor não está relacionado ao contexto seguro do contêiner; isto é, à medida que o contexto do contêiner fica menor, isso não afeta o ref-field-safe-context dos valores de camporef. Além disso, o ref-field-safe-context nunca pode ser menor do que o safe-context do container.

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

Este relacionado com o contexto sempre existiu, essencialmente. Até agora, campos ref só podiam apontar para normais struct; portanto, era trivial colapsar para contexto de chamada. Para apoiar ref campos a ref struct, as nossas regras existentes precisam de ser atualizadas para ter em conta este novo ref-safe-contexto .

Em terceiro lugar, as regras de reatribuição de ref precisam ser atualizadas para garantir que não violemos o contexto do campo de referência e para os valores. Essencialmente, para x.e1 = ref e2, em que o tipo de e1 é um ref struct, o ref-field-safe-context deve ser igual.

Estes problemas são muito solúveis. A equipa do compilador esboçou algumas versões dessas regras e elas resultam em grande parte da nossa análise existente. O problema é que não há código de consumo para tais regras que ajude a provar a correção e usabilidade. Isso nos torna muito hesitantes em adicionar suporte por causa do medo de escolher padrões errados e voltar o tempo de execução para o canto de usabilidade quando ele tirar proveito disso. Essa preocupação é particularmente forte porque o .NET 8 provavelmente nos empurra nessa direção com allow T: ref struct e Span<Span<T>>. As regras seriam melhor escritas se fosse elaboradas juntamente com o código de consumo.

Decisão Atrasar a permissão para que ref campo ref struct até ao .NET 8, onde existam cenários que ajudarão a orientar as regras em torno desses cenários. Isso não foi implementado a partir do .NET 9

O que fará o C# 11.0?

As funcionalidades descritas neste documento não precisam ser implementadas de uma só vez. Em vez disso, eles podem ser implementados em fases em várias versões de idioma nos seguintes buckets:

  1. ref campos e scoped
  2. [UnscopedRef]
  3. ref campos para ref struct
  4. Tipos restritos do pôr-do-sol
  5. buffers de tamanho fixo

O que é implementado em que versão é apenas um exercício de delimitação.

Decisão Apenas (1) e (2) fizeram C# 11.0. O restante será considerado em versões futuras do C#.

Considerações futuras

Anotações avançadas de ciclo de vida

As anotações de ciclo de vida nesta proposta são limitadas, pois permitem que os desenvolvedores alterem o comportamento padrão de escaparem ou não escaparem de valores. Isso adiciona uma flexibilidade poderosa ao nosso modelo, mas não muda radicalmente o conjunto de relações que podem ser expressas. No núcleo, o modelo C# ainda é efetivamente binário: um valor pode ser retornado ou não?

Isso permite que relações limitadas ao longo da vida sejam compreendidas. Por exemplo, um valor que não pode ser retornado de um método tem um tempo de vida menor do que um que pode ser retornado de um método. No entanto, não há como descrever a relação de tempo de vida entre os valores que podem ser retornados de um método. Especificamente, não há como dizer que um valor tem uma vida útil maior do que o outro, uma vez estabelecido, ambos podem ser retornados de um método. O próximo passo na nossa evolução ao longo da vida seria permitir que tais relações fossem descritas.

Outros métodos, como Rust, permitem que esse tipo de relação seja expressa e, portanto, podem implementar operações de estilo scoped mais complexas. A nossa língua poderia igualmente beneficiar se tal característica fosse incluída. De momento, não existe qualquer pressão motivadora para o fazer, mas, se houver no futuro, o nosso modelo de scoped poderá ser alargado de modo a incluí-lo de uma forma bastante simples.

A cada scoped pode ser atribuído um tempo de vida nomeado adicionando um argumento de estilo genérico à sintaxe. Por exemplo, scoped<'a> é um valor que tem tempo de vida 'a. Restrições como where poderiam então ser usadas para descrever as relações entre essas vidas.

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

Este método define dois tempos de vida 'a e 'b e sua relação, especificamente que 'b é maior que 'a. Isso permite que o callsite tenha regras mais granulares sobre como os valores podem ser passados com segurança para métodos, em contraste com as regras mais gerais atualmente usadas.

Questões

Todas as seguintes questões estão relacionadas com a presente proposta:

Propostas

As seguintes propostas estão relacionadas com a presente proposta:

Amostras existentes

Utf8JsonReader

Esse trecho em particular requer insegurança porque tem problemas com a passagem de um Span<T> que pode ser alocado para um método de instância em um ref struct. Mesmo que este parâmetro não seja capturado, a linguagem deve assumir que é e, portanto, desnecessariamente causa atrito aqui.

Utf8JsonWriter

Esse trecho deseja mutar um parâmetro escapando de elementos dos dados. Os dados escapados podem ser alocados em pilha para eficiência. Mesmo que o parâmetro não seja escapado, o compilador atribui-lhe um contexto seguro externo ao método envolvente, porque é um parâmetro. Isso significa que, para usar a alocação de pilha, a implementação deve usar unsafe para atribuir de volta ao parâmetro depois de escapar dos dados.

Amostras divertidas

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 de frugalidade

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

Exemplos e notas

Abaixo está um conjunto de exemplos que demonstram como e por que as regras funcionam da maneira que funcionam. Estão incluídos vários exemplos que mostram comportamentos perigosos e como as regras os impedem de acontecer. É importante ter isso em mente ao fazer ajustes na proposta.

Reatribuição de referência e pontos de chamada

Demonstrar como a reatribuição ref e a invocação de método funcionam em conjunto.

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

Reatribuição de ref e fugas inseguras

A razão para a linha seguinte nas regras de reatribuição de referência pode não ser óbvia à primeira vista:

deve ter o mesmo contexto seguro de que

Isso ocorre porque o tempo de vida útil dos valores apontados pelas localizações ref é invariante. A indireção impede-nos de permitir qualquer tipo de variância aqui, mesmo para tempos de vida mais curtos. Se o estreitamento for permitido, ele abrirá o seguinte código inseguro:

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 uma transição de ref para não ref struct, esta regra é facilmente satisfeita, pois todos os valores têm o mesmo contexto seguro . Esta regra só realmente se aplica quando o valor é um ref struct.

Este comportamento de ref também será importante num futuro em que permitimos que ref campos ref struct.

Locais com escopo

O uso de scoped em variáveis locais será particularmente útil para padrões de código que atribuem condicionalmente valores com diferentes contextos seguros de às variáveis locais. Isso significa que o código não precisa mais depender de truques de inicialização como = stackalloc byte[0] para definir um contexto local seguro , mas agora pode simplesmente 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 padrão surge frequentemente em código de baixo nível. Quando o ref struct envolvido é Span<T> o truque acima pode ser usado. No entanto, não é aplicável a outros tipos de ref struct e pode resultar na necessidade de código de baixo nível recorrer a unsafe para contornar a incapacidade de especificar corretamente o tempo de vida.

Valores de parâmetros com escopo

A permissividade do escape padrão para parâmetros é uma fonte de atrito frequente em código de baixo nível. Eles são de contexto seguro para o contexto do chamador . Este é um padrão sensato porque se alinha com os padrões de codificação do .NET como um todo. No código de baixo nível, porém, há um uso maior de ref struct e esse padrão pode causar atrito com outras partes das regras de contexto seguro "ref".

O principal ponto de atrito ocorre porque, no método , os argumentos devem corresponder à regra. Esta regra geralmente entra em jogo com métodos de instância em ref struct onde pelo menos um parâmetro também é um ref struct. Este é um padrão comum em código de baixo nível, em que os tipos de ref struct geralmente aproveitam os parâmetros de Span<T> em seus métodos. Por exemplo, ocorrerá em qualquer estilo de escrita ref struct que utilize Span<T> para transmitir buffers.

Esta regra existe para evitar cenários como os seguintes:

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

Essencialmente, essa regra existe porque a linguagem deve assumir que todas as entradas para um método escapam ao máximo permitido contexto seguro. Quando há ref ou out parâmetros, incluindo os recetores, é possível que as entradas escapem como campos desses valores ref (como acontece em RS.Set acima).

Na prática, porém, existem muitos desses métodos que passam ref struct como parâmetros que nunca pretendem capturá-los na saída. É apenas um valor que é usado dentro do método atual. Por exemplo:

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

A fim de contornar este código de baixo nível irá recorrer a unsafe truques para mentir para o compilador sobre o tempo de vida de sua ref struct. Isso reduz significativamente a proposta de valor dos ref struct pois eles devem ser um meio de evitar unsafe enquanto continuam a escrever código de alto desempenho.

É aqui que scoped é uma ferramenta eficaz em parâmetros ref struct porque os remove da consideração como sendo retornados do método de acordo com o atualizado os argumentos do método devem corresponder à regra. Um parâmetro ref struct que é consumido, mas nunca retornado, pode ser rotulado como scoped para tornar os sites de chamadas mais flexíveis.

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

Impedindo atribuições complexas de referências devido a mutações de leitura única

Quando um ref é levado para um campo readonly em um construtor ou membro init, o tipo ref não é ref readonly. Este é um comportamento de longa data que permite códigos como os seguintes:

struct S
{
    readonly int i; 

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

    static void M(ref int i) { }
}

Isso, no entanto, representa um problema potencial se tal ref pudesse ser armazenado em um campo ref do mesmo tipo. Isso permitiria a mutação direta de um readonly struct de um membro da instância:

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

A proposta impede que isso aconteça porque viola as regras de contexto seguras de referência. Considere o seguinte:

  • O contexto ref-safe-de do this é de membro da função e o contexto seguro de é de contexto do chamador. Ambos são padrão para this em um membro struct.
  • O ref-safe-context do i é membro da função. Isto decorre das regras de duração do campo . Especificamente a regra 4.

Nesse ponto, a linha r = ref i é ilegal devido às regras de reatribuição de referência .

Estas regras não se destinavam a evitar este comportamento, mas fazem-no como um efeito secundário. É importante ter isso em mente para qualquer atualização futura de regras para avaliar o impacto em cenários como este.

Atribuição cíclica tola

Um aspeto com que este design se debateu é a liberdade com que um ref pode ser devolvido a partir de um método. Permitir que todos os ref sejam retornados tão livremente quanto os valores normais é provavelmente o que a maioria dos desenvolvedores espera intuitivamente. No entanto, permite cenários patológicos que o compilador deve considerar ao calcular a segurança de referência. Considere o seguinte:

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

Este não é um padrão de código que esperamos que qualquer desenvolvedor use. No entanto, quando um ref pode ser devolvido com a mesma duração de vida que um valor, é legal de acordo com as regras. O compilador deve considerar todos os casos legais ao avaliar uma chamada de método e isso leva a que tais APIs sejam efetivamente inutilizáveis.

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 tornar essas APIs utilizáveis, o compilador garante que o tempo de vida da ref para um parâmetro ref seja menor do que o tempo de vida de quaisquer referências no valor do parâmetro associado. Esta é a lógica para ter de contexto ref-safe para refref struct ser somente de retorno e out ser de contexto do chamador. Isso evita a atribuição cíclica por causa da diferença nos tempos de vida.

Observe que [UnscopedRef]promove o ref-safe-context de quaisquer ref para ref struct valores no contexto do chamador e assim permite a atribuição cíclica e força um uso viral de [UnscopedRef] na cadeia de chamadas:

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

Da mesma forma[UnscopedRef] out permite uma atribuição cíclica porque o parâmetro tem de contexto seguro e de contexto ref-safe de somente de retorno.

Promover [UnscopedRef] ref para de contexto do chamador é útil quando o tipo não é um ref struct (observe que queremos manter as regras simples para que elas não distingam entre refs to ref vs non-ref structs):

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

Em termos de anotações avançadas, o design [UnscopedRef] cria o seguinte:

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 não pode ser profundo em campos de referência

Considere o exemplo de código abaixo:

ref struct S
{
    ref int Field;

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

Ao conceber as regras para campos ref em instâncias readonly em teoria, as regras podem ser concebidas de forma válida de modo que isso seja legal ou ilegal. Essencialmente, readonly pode validamente ser profundo através de um campo ref ou pode aplicar-se apenas ao ref. Aplicar apenas ao ref impede a reatribuição ref, mas permite a atribuição normal que altera o valor referido.

Este design não existe no vácuo, porém, ele está projetando regras para tipos que já efetivamente têm ref campos. O mais destacado dos quais, Span<T>, já tem uma forte dependência de readonly não ser tão profundo aqui. Seu cenário principal é a capacidade de atribuir ao campo ref por meio de uma instância 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;
        }
    }
}

Isto significa que temos de escolher a interpretação superficial de readonly.

Construtores de modelagem

Uma questão sutil de design é: Como as carrocerias dos construtores são modeladas para segurança de referência? Como é que o seguinte construtor é analisado, essencialmente?

ref struct S
{
    ref int field;

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

Existem aproximadamente duas abordagens:

  1. Modelo como um método static em que this é um local onde o seu contexto seguro é o contexto do chamador
  2. Modelo como um método static onde this é um parâmetro out.

Além disso, um construtor deve atender aos seguintes invariantes:

  1. Assegure-se de que os parâmetros ref possam ser capturados como campos ref.
  2. Certifique-se de que os campos de ref em this não possam ser escapados através dos parâmetros ref. Isso violaria a atribuição de referência complicada .

A intenção é escolher a forma que satisfaça os nossos invariantes sem introduzir quaisquer regras especiais para construtores. Dado que o melhor modelo para construtores é ver this como um parâmetro out. O retorno apenas natureza do out nos permite satisfazer todos os invariantes acima sem qualquer invólucro especial:

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

Os argumentos do método devem corresponder

A regra de correspondência dos argumentos do método é uma fonte comum de confusão para os desenvolvedores. É uma regra que tem uma série de casos especiais que são difíceis de entender, a menos que você esteja familiarizado com o raciocínio por trás da regra. Para melhor compreender as razões da regra, simplificaremos ref-safe-context e safe-context para simplesmente contexto.

Os métodos podem muito liberalmente retornar o estado passado a eles como parâmetros. Essencialmente, qualquer estado alcançável que não tenha escopo pode ser retornado (incluindo a possibilidade de retorno por ref). Isso pode ser retornado diretamente por meio de uma instrução return ou indiretamente, atribuindo um valor ref.

As devoluções diretas não representam muitos problemas para a segurança da ref. O compilador simplesmente precisa olhar para todas as entradas retornáveis para um método e, em seguida, ele efetivamente restringe o valor de retorno para ser o mínimo contexto da entrada. Esse valor de retorno passa então pelo processamento normal.

Os retornos indiretos representam um problema significativo porque todas as ref são entradas e saídas para o método. Essas saídas já têm um contexto conhecido . O compilador não pode inferir novos, ele tem que considerá-los em seu nível atual. Isso significa que o compilador tem que examinar cada ref que é atribuível no método chamado, avaliar o seu contexto , e, em seguida, verificar se nenhuma entrada que possa ser retornada para o método tem um contexto menor do que esse ref. Se houver algum desses casos, a chamada de método deve ser inválida porque pode violar a segurança ref.

Os argumentos do método devem corresponder é o processo pelo qual o compilador afirma essa verificação de segurança.

Uma maneira diferente de avaliar isso, que muitas vezes é mais fácil para os desenvolvedores considerarem, é fazer o seguinte exercício:

  1. Observe a definição do método para identificar todos os lugares onde o estado pode ser retornado indiretamente: a. Parâmetros ref mutáveis que apontam para ref struct b. Parâmetros ref mutáveis com campos de ref atribuíveis por referência c. Parâmetros ref atribuíveis ou campos ref que apontam para ref struct (considerar recursivamente)
  2. Consulte o ponto de chamada a. Identifique os contextos que se alinham com os locais identificados acima b. Identificar os contextos de todas as entradas do método que são retornáveis (não alinham com parâmetros scoped)

Se qualquer valor em 2.b for menor que 2.a, a chamada de método deve ser ilegal. Vejamos alguns exemplos para ilustrar as regras:

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

Olhando para a chamada de F0, vamos analisar (1) e (2). Os parâmetros com potencial de retorno indireto são a e b pois ambos podem ser atribuídos diretamente. Os argumentos que se alinham com esses parâmetros são:

  • a que mapeia para x que tem contexto de contexto do chamador
  • b que mapeia para y com o contexto de função-membro

O conjunto de entradas retornáveis para o método são

  • x com escopo de escape de do contexto do chamador
  • ref x com escopo de escape de do contexto do chamador
  • y com de escopo de escape de membro da função

O valor ref y não é retornável, uma vez que mapeia para um scoped ref portanto, não é considerado uma entrada. Mas dado que há pelo menos uma entrada com um escopo de escape de menor (argumentoy) do que uma das saídas (argumentox), a chamada de método é ilegal.

Uma variação diferente é a seguinte:

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

Mais uma vez, os parâmetros com potencial de retorno indireto são a e b pois ambos podem ser atribuídos diretamente. Mas b pode ser excluído porque não aponta para um ref struct, portanto, não pode ser usado para armazenar ref estado. Assim, temos:

  • a que mapeia para x que tem contexto de contexto do chamador

O conjunto de entradas retornáveis para o método são:

  • x com o contexto do contexto do chamador
  • ref x com o contexto do contexto do chamador
  • ref y com contexto da função-membro

Dado que há pelo menos uma entrada com um escopo de escape de menor (argumentoref y) do que uma das saídas (argumentox), a chamada de método é ilegal.

Esta é a lógica que a regra de correspondência dos argumentos do método está a tentar englobar. Ele vai além, pois considera tanto scoped como uma maneira de não considerar entradas e readonly como uma maneira de remover ref como uma saída (não se pode atribuir a um readonly ref, então não pode ser uma fonte de saída). Esses casos especiais adicionam complexidade às regras, mas isso é feito para o benefício do desenvolvedor. O compilador procura remover todas as entradas e saídas que sabe que não podem contribuir para o resultado para dar aos desenvolvedores a máxima flexibilidade ao chamar um membro. Tal como a resolução de sobrecargas, vale a pena o esforço para tornar as nossas regras mais complexas quando cria mais flexibilidade para os consumidores.

Exemplos de contexto seguro inferido de expressões de declaração

Relacionado com Infer o contexto seguro das expressões de declaração.

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

Observe que o contexto local que resulta do modificador scoped é o mais estreito que poderia ser usado para a variável - ser mais estreito significaria que a expressão se refere a variáveis que são declaradas apenas em um contexto mais restrito do que a expressão.