Compartilhar via


Melhorias de 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. Ela inclui alterações de especificação propostas, juntamente com as informações necessárias durante o design e o desenvolvimento do recurso. Esses artigos são publicados até que as alterações de especificação propostas sejam finalizadas e incorporadas na especificação ECMA atual.

Pode haver algumas divergências entre a especificação do recurso e a implementação concluída. Essas diferenças são capturadas nas notas pertinentes da reunião de design de idioma (LDM).

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

Resumo

Esta proposta é uma combinação de várias propostas diferentes para melhorias de desempenho do struct: campos ref e a capacidade de substituir os padrões de tempo de vida. A meta é um design que leva em conta as várias propostas para criar um único conjunto abrangente de recursos para melhorias de struct de baixo nível.

Observação: versões anteriores desta especificação usavam os termos "ref-safe-to-escape" e "safe-to-escape", que foram introduzidos na especificação do recurso de segurança de intervalo na seção . O comitê padrão ECMA alterou os nomes para "ref-safe-context" e "safe-context", respectivamente. Os valores do contexto seguro foram refinados para utilizar "declaration-block", "function-member" e "caller-context" consistentemente. Os speclets haviam usado frases diferentes para esses termos e também usaram "safe-to-return" como sinônimo de "caller-context". Esta especificação foi atualizada para usar os termos do padrão C# 7.3.

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

  1. Campos ref e scoped
  2. [UnscopedRef]

Esses recursos continuam sendo propostas abertas para uma versão futura do C#:

  1. Campos ref e ref struct
  2. Tipos restritos de Sunset

Motivação

Versões anteriores do C# adicionaram uma série de recursos de desempenho de baixo nível à linguagem: retornos ref, ref struct, ponteiros de função etc. ... Esses recursos permitiram que os desenvolvedores do .NET escrevessem código de alto desempenho, enquanto continuavam a aproveitar as regras de linguagem do C# para segurança de tipo e memória. 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, tanto internos quanto externos, têm nos fornecido informações sobre os pontos de conflito restantes no ecossistema. Locais onde eles ainda precisam recorrer ao código unsafe para concluir o trabalho, ou que exigem que o runtime lide com tipos de casos especiais, como Span<T>.

Atualmente, o Span<T> é feito usando o tipo internal ByReference<T> que o runtime efetivamente trata como um campo de ref. Isso fornece o benefício dos campos ref, mas com a desvantagem de que a linguagem não fornece verificação de segurança para esses campos, como fornece para outros casos de uso de ref. Além disso, apenas dotnet/runtime pode usar esse tipo, pois é internal; portanto, terceiros não podem criar seus próprios primitivos com base em campos ref. Parte da motivação de para este trabalho é remover ByReference<T> e usar campos apropriados de ref em todas as bases de código.

Esta proposta planeja abordar esses problemas aproveitando nossas funcionalidades de baixo nível existentes. Especificamente, o objetivo é:

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

Projeto detalhado

As regras de segurança de ref struct são definidas no documento de segurança no intervalo de usando os termos anteriores. Essas regras foram incorporadas ao padrão C# 7 em §9.7.2 e §16.4.12. Este documento descreverá as alterações necessárias como resultado desta proposta. Depois de serem aceitas como um recurso aprovado, essas alterações serão incorporadas a esse documento.

Quando este projeto estiver concluído, nossa definição 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 ref e escopo

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 struct mutáveis ​​ou 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 é igual à forma como emitimos locais ref ou argumentos ref. Por exemplo, ref int _field será emitido como o ELEMENT_TYPE_BYREF ELEMENT_TYPE_I4. Isso exigirá que atualizemos o 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 declarados ref terão o valor null. Qualquer tentativa de usar esses campos resultará na geração de uma NullReferenceException.

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 devem estar cientes dessa possibilidade e são fortemente desencorajados a vazar esse detalhe para o código de consumo. Em vez disso, campos ref devem ser validados como não nulos usando os auxiliares de runtime e e lançando uma exceção quando um struct 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 modificadores readonly das seguintes maneiras:

  • readonly ref: este é um campo que não pode ser reatribuído fora de um construtor ou métodos init. Isso pode ser um valor atribuído fora desses contextos
  • ref readonly: este é um campo que pode ser reatribuído, mas não pode ser atribuído a um valor em nenhum momento. É assim que um parâmetro in pode ser reatribuído 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)
    }
}

Um readonly ref struct exigirá que campos ref sejam declarados readonly ref. Não há nenhum requisito de que eles sejam declarados readonly ref readonly. Isso permite que um readonly struct tenha mutações indiretas por meio desse campo, mas isso não é diferente de um campo readonly que aponta para um tipo de referência atualmente (mais detalhes)

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

Este recurso requer suporte de tempo de execução e alterações na especificação ECMA. Como tal, eles só serão habilitados quando o sinalizador de recurso correspondente for definido em corelib. O problema que está rastreando a API exata é rastreado aqui https://github.com/dotnet/runtime/issues/64165

O conjunto de alterações em nossas regras de contexto seguro necessárias para permitir os campos ref é pequeno e direcionado. As regras já consideram os campos ref existentes que estão sendo consumidos pelas APIs. As mudanças precisam se concentrar em apenas dois aspectos: como elas são criadas e como elas são reatribuídas.

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

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

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

Isso não representa uma alteração nas regras, já que elas sempre consideraram a existência do estado ref dentro de um ref struct. Na verdade, é assim que o estado ref em Span<T> sempre funcionou e as regras de consumo consideram isso corretamente. A alteração aqui é apenas para permitir que os desenvolvedores acessem os campos ref diretamente e garantir que isso seja feito de acordo com as regras existentes implicitamente aplicadas a Span<T>.

Isso significa que campos ref podem ser retornados como ref de ref struct, mas 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 é um ponto de design deliberado. Porém, esta não é uma nova regra criada por esta proposta; em vez disso, ela está reconhecendo as regras existentes às quais o Span<T> já se conformou, agora que os desenvolvedores podem declarar seu próprio estado ref.

Em seguida, as regras de reatribuição ref precisam ser ajustadas para a presença de campos ref. O cenário principal para reatribuição de ref é construtores ref struct com armazenamento de parâmetros ref em campos ref. O suporte será mais geral, mas este é o cenário principal. Para oferecer suporte a isso, as regras para reatribuição de ref serão ajustadas para considerar os campos ref da seguinte maneira:

Regras de reatribuição de referência

O operando esquerdo do operador = ref deve ser uma expressão que se vincula 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 no formato e1 = ref e2, ambas as condições a seguir devem ser verdadeiras:

  1. e2 deve ter ref-safe-context pelo menos do mesmo tamanho de ref-safe-context de e1
  2. e1 deve ter o mesmo contexto seguro que e2Nota

Isso significa que o construtor desejado Span<T> funciona sem nenhuma 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;
    }
}

Agora os parâmetros ref podem escapar de um método como um campo ref em um valor ref struct com a alteração nas regras de reatribuição de ref. Conforme abordado na seção de considerações de compatibilidade, isso pode alterar as regras para APIs existentes que nunca se propuseram a permitir que os parâmetros ref escapassem como um campo ref. As regras de tempo de vida para parâmetros são baseadas somente em sua declaração, não em seu uso. Todos os parâmetros ref e in têm ref-safe-context de caller-context e podem portanto ser retornados pelo ref ou por um campo ref. Para dar suporte a APIs que têm parâmetros ref que podem ou não escapar e, portanto, restaurar a semântica do site de chamada do C# 10, a linguagem introduzirá anotações de tempo de vida limitadas.

Modificador scoped

A palavra-chave scoped será usado para restringir o tempo de vida de um valor. Ele pode ser aplicado a um ref ou a um valor que seja um ref struct e tem o impacto de restringir o tempo de vida de ref-safe-context ou safe-context respectivamente ao membro de função . Por exemplo:

Parâmetro ou local ref-safe-context safe-context
Span<int> s function-member caller-context
scoped Span<int> s function-member function-member
ref Span<int> s caller-context caller-context
scoped ref Span<int> s function-member caller-context

Nessa relação, o ref-safe-context de um valor nunca pode ser mais amplo que o safe-context.

Isso permite que as APIs em C# 11 sejam anotadas de forma que tenham as mesmas regras do 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, era necessário criar exceções específicas nas regras para o parâmetro ref, que tinha regras de contexto seguro de referência diferentes dos outros parâmetros ref (consulte todas as referências sobre incluir ou excluir o receptor nas regras de contexto seguro). Agora ele pode ser expresso como um conceito geral em todas as regras, o que as torna ainda mais simples.

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

  • locais: essa anotação define o tempo de vida como safe-context, ou ref-safe-context no caso de um local ref, para function-member 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 abordados abaixo.

A anotação scoped não pode ser aplicada a nenhum 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. O fato de ter declarações como scoped int não tem impacto porque uma não ref struct é sempre segura de retornar. 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 compatibilidade de tornar os parâmetros ref e in retornáveis como campos ref, a linguagem mudará o valor padrão de ref-safe-context para que os parâmetros out sejam function-member. Os parâmetros de out são implicitamente scoped out daqui para frente. Do ponto de vista de compatibilidade, isso significa que não podem ser retornados 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 ref struct e têm parâmetros out, pois não será mais necessário considerar o parâmetro capturado por referência. Isso é importante porque é um padrão comum em APIs de estilo de 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 argumentos passados ​​para um parâmetro out como retornáveis. O tratamento da entrada em um parâmetro out como retornável foi algo extremamente confuso para os desenvolvedores. Isso basicamente subverte a intenção de out ao forçar os desenvolvedores a considerarem o valor passado pelo chamador, que nunca é usado, exceto em linguagens que não respeitam o out. No futuro, idiomas que dão suporte a ref struct devem garantir que o valor original passado para um parâmetro out nunca seja lido.

C# consegue isso por meio de suas regras de atribuição definidas. Além de cumprir as nossas regras de ref safe context, isso também permite o código existente que primeiro atribui e, então, retorna os 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 para valores de safe-context ou ref-safe-context em invocações de método. Isso reduz significativamente o impacto geral de compatibilidade dos campos de ref, além de simplificar a forma como os desenvolvedores pensam em out. O argumento de um parâmetro out não contribui para o retorno; é simplesmente uma saída.

Inferir safe-context de expressões de declaração

O safe-context de uma variável de declaração de um argumento out (M(x, out var y)) ou desconstrução ((var x, var y) = M()) é o mais estreito dos seguintes:

  • contexto do chamador
  • Se a variável out estiver marcada como scoped, será necessário um bloco de declaração (ou seja, function-member ou narrower).
  • se o tipo da variável out for ref struct, considere todos os argumentos na invocação que o contém, incluindo o receptor:
    • safe-context de qualquer argumento em que seu parâmetro correspondente não seja out e tenha safe-context de return-only ou mais amplo
    • ref-safe-context de qualquer argumento em que seu parâmetro correspondente tenha ref-safe-context de return-only ou mais amplo

Confira também Exemplos de safe-context inferido de expressões de declaração.

Implicitamente, parâmetros scoped

No geral, há dois ref locais que são declarados implicitamente como scoped:

  • this em um método de instância struct
  • Parâmetros out

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

Ao discutir o ref-safe-context de argumentos que correspondem a parâmetros in, eles serão generalizados como argumentos ref na especificação. Caso o argumento seja um lvalue, o ref-safe-context será aquele do lvalue, caso contrário, será function-member. Novamente in só será destacado aqui quando for importante para a semântica da regra atual.

Return-only safe-context

O design também requer a introdução de um novo safe-context: return-only. Isso é semelhante ao contexto de chamada , pois pode ser retornado, mas só pode ser retornado por meio de uma declaração return.

Os detalhes de return-only consistem em um contexto maior que function-member, mas menor que caller-context. Uma expressão fornecida a uma instrução return deve ser pelo menos return-only. Dessa forma, a maioria das regras existentes tornam-se inválidas. Por exemplo, a atribuição em um parâmetro ref a partir de uma expressão com um safe-context de return-only falhará porque é menor que o refsafe-context do parâmetro, que é caller-context. A necessidade desse novo contexto de escape será abordada a seguir.

Há três locais cujo padrão é return-only:

  • Um parâmetro ref ou in terá um ref-safe-context de return-only. Isso acontece, em partes, para que ref struct evite problemas triviais de atribuição cíclica. No entanto, isso é feito de maneira uniforme para simplificar o modelo, bem como minimizar as alterações de compatibilidade.
  • Um parâmetro out para um ref struct terá safe-context de return-only. Isso permite que o retorno e out sejam igualmente expressivos. Isso não tem o problema trivial de atribuição cíclica, pois out é implicitamente scoped para que o ref-safe-context permaneça menor que o safe-context.
  • Um parâmetro this para um construtor struct terá um safe-context de return-only. Isso se torna inválido porque é modelado como parâmetros out.

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

Da mesma forma, qualquer atribuição a um out deve ter um safe-context de pelo menos return-only. Mas este não é um caso especial, apenas decorre das regras de atribuição existentes.

Observação: uma expressão cujo tipo não seja ref struct sempre possui um contexto seguro safe-context de caller-context.

Regras para invocação de métodos

As regras de contexto de seguro de referência para invocação de métodos serão atualizadas de diversas maneiras. A primeira é reconhecer o impacto que scoped tem nos argumentos. Para determinado argumento expr que é passado para o parâmetro p:

  1. Se p for scoped ref, expr não contribuirá para ref-safe-context ao considerar argumentos.
  2. Se p for scoped, expr não contribuirá para safe-context ao considerar argumentos.
  3. Se p for out, expr não contribuirá para 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 ref-safe-context ou safe-context do retorno de método, respectivamente. Isso ocorre porque os valores não podem contribuir para esse tempo de vida, pois a anotação scoped impede isso.

As regras de invocação de métodos agora podem ser simplificadas. O receptor não precisa mais ser tratado como um caso especial; no caso de struct, agora é simplesmente um scoped ref T. As regras de valor precisam ser alteradas para considerar os retornos do campo ref.

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

  1. O caller-context
  2. Quando o retorno é um ref struct, o safe-context tem contribuição de todas as expressões de argumento.
  3. Quando o retorno é um ref struct, o ref-safe-context tem contribuição de todos os argumentos ref

Se M() retornar o ref-to-ref-struct, o de contexto seguro será 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 safe-context porque os argumentos de método devem ser correspondentes.

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 e é o ref-safe-context mais estreito dos seguintes contextos:

  1. O caller-context
  2. O safe-context com contribuição de todas as expressões de argumento
  3. O ref-safe-context com contribuição de todos os argumentos ref

Se M() de fato retornar ref-to-ref-struct, o ref-safe-context será o ref-safe-context mais estreito com contribuição de todos os argumentos que são ref-to-ref-struct.

Essa 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 safe-context de uma expressão de inicializador de objeto é o mais estreito entre:

  1. O safe-context da chamada do construtor.
  2. O safe-context e ref-safe-context de argumentos para indexadores de inicialização de membros que podem escapar para o receptor.
  3. O safe-context do RHS de atribuições em inicializadores membros para setters que não são somente leitura ou ref-safe-context no caso de atribuição de ref.

Outra maneira de modelar isso é pensar em qualquer argumento para um inicializador membro que possa ser atribuído ao receptor como sendo um argumento para o construtor. Isso ocorre porque o inicializador de membro é basicamente 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 nosso MAMM precisa levar em conta principalmente os inicializadores de membros. Considere que esse caso específico precisa ser ilegal, pois permite que um valor com um safe-context mais restrito seja atribuído a um maior.

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 e precisam ser atualizadas, 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ó precisava considerar outro ref struct armazenado como um campo. O impacto disso é discutido nas considerações de compatibilidade em . A nova regra é...

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

  1. Calcule o safe-context mais estreito de:
    • caller-context
    • O safe-context de todos os argumentos
    • O ref-safe-context de todos os argumentos ref cujos parâmetros correspondentes têm um contexto ref-safe ref-safe-context de caller-context.
  2. Todos os argumentos ref de tipos ref struct devem ser atribuíveis por um valor com esse safe-context. Este é um caso em que refnão generaliza para incluir in e out

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

  1. Calcule o safe-context mais estreito de:
    • caller-context
    • O safe-context de todos os argumentos
    • O ref-safe-context de todos os argumentos ref cujos parâmetros correspondentes não sejam scoped
  2. Todos os argumentos out de tipos ref struct devem ser atribuíveis por um valor com esse safe-context.

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. Isso remove seus argumentos de (1) em ambos os casos acima e fornece maior flexibilidade para os chamadores.

O impacto dessa alteração é 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 escapam com scoped.

Variação do escopo do parâmetro

O modificador scoped e o atributo [UnscopedRef] (consulte abaixo) sobre os parâmetros também afetam a substituição de nosso objeto, a implementação da interface e as regras de conversão do delegate. A assinatura para uma substituição, implementação de interface ou conversão de 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 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 entre substituições, implementações de interface e conversões de delegado quando:

  • O método tem um parâmetro ref ou out de tipo ref struct com uma incompatibilidade devido à adição de [UnscopedRef] (não remoção de scoped). (Nesse caso, uma atribuição cíclica boba é possível, portanto, nenhum outro parâmetro é necessário.)
  • Ou ambos são verdadeiros:
    • 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 do tipo ref struct.
    • O método tem pelo menos um parâmetro adicional ref, inou out, ou um parâmetro do tipo ref struct.

O diagnóstico não é relatado em outros casos porque:

  • Os métodos com tais assinaturas não podem capturar as referências passadas; portanto, qualquer incompatibilidade de escopo não é perigosa.
  • Eles incluem cenários muito comuns e simples (por exemplo, parâmetros simples e antigos de out usados em assinaturas de método TryParse) e relatar discrepâncias de escopo apenas porque são usados na versão 11 da linguagem (e, portanto, o parâmetro out é escopado de forma diferente) seria confuso.

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

O aviso de incompatibilidade com escopo pode ser relatado em um módulo compilado com as regras de contexto de segurança de referência do C#7.2 onde scoped não está disponível. Em alguns 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 ser diferentes apenas em scoped ou [UnscopedRef]

A seção sobre o campo ref e scoped é longa, então gostaríamos de encerrar com um breve resumo das alterações significativas propostas:

  • Um valor que tem de contexto ref-safe para o de contexto de chamador é retornável por ref ou ref campo.
  • Um parâmetro out teria um safe-context de function-member.

Anotações 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 que seja ref struct
  • O processo de geração de montagem de referência deve preservar a presença de um campo de ref dentro de um ref struct
  • Um readonly ref struct deve declarar seus campos ref como readonly ref
  • Para valores by-ref, o modificador scoped deve aparecer antes de in, out ou ref
  • O documento de regras de segurança de abrangência será atualizado conforme descrito neste documento
  • As novas regras ref safe context estarão em vigor quando qualquer uma das duas condições for atendida
    • A biblioteca principal contém o sinalizador de recurso que indica suporte para campos ref
    • O valor de langversion é 11 ou superior

Sintaxe

13.6.2 Declarações de variáveis ​​locais: adição de '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 for instrução: adicionada 'scoped'?indiretamente de local_variable_declaration.

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

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

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

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

12.7 Expressões de desconstrução:

[TBD]

Parâmetros do método 15.6.2: adicionado 'scoped'? a parameter_modifier.

fixed_parameter
    : attributes? parameter_modifier? type identifier default_argument?
    ;

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

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

20.2 Declarações de delegado: adicionadas 'scoped'?indiretamente de fixed_parameter.

12.19 Expressões de função anônima: adicionadas 'scoped'?.

explicit_anonymous_function_parameter
    : 'scoped'? anonymous_function_parameter_modifier? type identifier
    ;

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

Tipos restritos de Sunset

O compilador tem um conceito de conjunto de "tipos restritos" que não é amplamente documentado. Esses tipos receberam um status especial porque no C# 1.0 não havia uma maneira geral de 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 conhecimento especial sobre eles e restringiu seu uso a formas que sempre seriam seguras: retornos não permitidos, não podem ser usados ​​como elementos de matriz, não podem ser usados ​​em genéricos etc.

Uma vez que os campos ref estejam disponíveis e estendidos para oferecer suporte, os tipos ref struct podem ser definidos corretamente em C# usando uma combinação de campos ref struct e ref. Portanto, quando o compilador detecta que um runtime dá suporte a 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 oferecer suporte a isso, nossas regras de contexto de seguro de referência serão atualizadas da seguinte maneira:

  • __makeref será tratado como um método com a assinatura static TypedReference __makeref<T>(ref T value)
  • __refvalue será tratado 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 de tipo.
  • __arglist como um parâmetro terá um ref-safe-context e safe-context de function-member.
  • __arglist(...) como uma expressão terá um ref-safe-context e safe-context de function-member.

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

Observação: estritamente falando, este é um detalhe de implementação do compilador e não parte da linguagem. No entanto, dada a relação com campos ref, ela está sendo incluída na proposta de linguagem de programação por simplicidade.

Disponibilizar sem 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 retornem ref e precisam expor campos diretamente. Isso reduz a utilidade dos retornos de ref em struct, onde geralmente são os mais desejados.

struct S
{
    int _field;

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

A lógica para esse padrão é razoável, mas não há nada automaticamente errado com um struct de escape this por referência; ele é simplesmente o padrão escolhido pelas regras de contexto ref safe.

Para corrigir isso, a linguagem fornecerá o contrário da anotação de tempo de vida scoped ao dar suporte a um UnscopedRefAttribute. Isso pode ser aplicado a qualquer ref e fará com que o ref-safe-context seja um nível mais amplo do que o padrão. Por exemplo:

UnscopedRef aplicado a ref-safe-context original Novo ref-safe-context
membro da instância function-member return-only
Parâmetro in / ref return-only contexto do chamador
Parâmetro out function-member return-only

Ao aplicar [UnscopedRef] a um método de instância de um struct, tem o impacto de modificar o parâmetro de this implícito. Isso significa que this age 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 do C# 10.

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

Para fins das regras de contexto ref safe, tal [UnscopedRef] out é considerado simplesmente um ref. Semelhante à forma como in é considerado ref para propósitos de vida útil.

A anotação [UnscopedRef] não será permitida em membros e construtores init dentro de struct. Esses membros já são especiais com relação à semântica do ref, pois veem membros do readonly como mutáveis. Isso significa que levar ref a esses membros torna-se um simples ref, não ref readonly. Isso é permitido dentro do limite dos construtores e init. Permitir [UnscopedRef] faria com que este ref escapasse incorretamente do construtor e permitisse mutação após a semântica 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
    {
    }
}

Anotações detalhadas:

  • Um método de instância ou propriedade anotada com [UnscopedRef] tem ref-safe-context de this definido como o caller-context.
  • Um membro anotado com [UnscopedRef] não pode implementar uma interface.
  • É um erro usar [UnscopedRef] em
    • Um membro que não foi declarado em um struct
    • Um membro static, init ou construtor em um 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 por meio do atributo de tipo System.Runtime.CompilerServices.ScopedRefAttribute. O atributo será correspondido pelo nome qualificado do namespace, de modo que a definição não precise aparecer em nenhuma montagem específica.

O tipo ScopedRefAttribute é somente para uso do compilador; ele não é permitido na origem. 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 a sintaxe scoped. Isso só será emitido quando a sintaxe fizer com que o valor seja diferente do seu estado padrão. Por exemplo, scoped out não fará com que nenhum atributo seja emitido.

RefSafetyRulesAttribute

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

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

Para reduzir a chance de alterações significativas ao recompilar com o C#11, atualizaremos o compilador C#11 para usar as regras ref safe context para invocação de método que correspondem às regras que foram usadas para analisar a declaração de método. Basicamente, ao analisar uma chamada para um método compilado com um compilador mais antigo, o compilador C#11 usará as regras de contexto de seguro de referência do 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 quando for compilado com um corlib que contém o sinalizador de recurso para campos ref.

O argumento para o atributo indica a versão do idioma das regras de contexto ref safe e usadas quando o módulo foi compilado. No momento, a versão é corrigida em 11, independentemente da versão de idioma real passada para o compilador.

A expectativa é que versões futuras do compilador atualizem as regras de contexto de seguro de referência 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, o compilador relatará um aviso para a versão não reconhecida se houver chamadas a 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 do método for de código-fonte e compilado com -langversion:11 ou com um corlib contendo o flag de recurso para campos ref, a chamada de método será analisada com as regras do C#11.
  • Se o módulo que contém a declaração de método fizer referência a System.Runtime { ver: 7.0 }, a chamada de método será analisada com regras C#11. Essa regra é uma mitigação temporária para módulos compilados com versões anteriores do C#11 / .NET 7 e será removida posteriormente.
  • Caso contrário, a chamada do método é analisada com as regras do 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, de modo que a definição não precise aparecer em nenhuma montagem específica.

O tipo RefSafetyRulesAttribute é somente para uso do compilador; ele não é permitido na origem. 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 seguros

Buffers seguros de tamanho fixo não foram entregues no 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 para 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 suas contrapartes unsafe, definirão uma sequência de elementos N no tipo que as 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 fixed do tipo T, o estado readonly do contêiner deve ser levado em conta. Se o contêiner for readonly, o indexador retornará ref readonly T, senão retornará ref T.

O acesso a um buffer de fixed sem um indexador não possui um tipo natural; no entanto, ele é conversível para tipos Span<T>. No caso do contêiner ser readonly, o buffer é implicitamente conversível em ReadOnlySpan<T>; caso contrário, ele pode ser convertido implicitamente em Span<T> ou ReadOnlySpan<T> (a conversão Span<T> é considerada melhor).

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

Para cada declaração de fixed em um tipo em que o tipo de elemento é T, a linguagem gerará um método indexador correspondente somente get, cujo tipo de retorno é ref T. O indexador será anotado com o atributo [UnscopedRef], pois a implementação retornará os campos do tipo declarado. 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, um an IndexOutOfRangeException será gerado. Caso um valor constante seja fornecido, ele será substituído por uma referência direta ao elemento apropriado. A menos que a constante esteja fora dos limites declarados, caso em que ocorreria um erro de compilação.

Também haverá um acessador nomeado gerado para cada buffer fixed que fornece operações de get e set por valor. Isso significa que os buffers fixed serão mais parecidos com a semântica de matriz existente, tendo um acessador ref, bem como operações byval get e set. Isso significa que os compiladores terão a mesma flexibilidade ao emitir código que consome buffers fixed como ao consumir arrays. Isso deve facilitar a emissão de operações como await em buffers fixed.

Isso também tem o benefício adicional de tornar mais fácil o consumo dos buffers de fixed a partir de outros idiomas. Indexadores nomeados são 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# é, na verdade, um bom exemplo disso).

O armazenamento de backup para o buffer será gerado usando o atributo [InlineArray]. Esse é um mecanismo abordado na edição 12320, que permite declarar eficientemente uma sequência de campos do mesmo tipo. Essa questão em particular ainda está em discussão ativa e a expectativa é que a implementação desse recurso será conforme o andamento 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 para a expressão with, atualizamos a gramática para:

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

O operando esquerdo da atribuição deve ser uma expressão que se vincula a um campo ref.
O operando à direita deve ser uma expressão que produza um lvalue designando um valor do mesmo tipo que o operando esquerdo.

Adicionamos uma regra semelhante para a reatribuição local ref:
Se o operando esquerdo for um ref gravável (ou seja, ele designa qualquer coisa que não seja um campo ref readonly), o operando à direita deve ser um lvalue gravável.

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

Uma expressão new que invoca um construtor segue as mesmas regras de uma invocação de método que retorna 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 safe-context do menor dentre os seguintes contextos:

  1. O caller-context
  2. O safe-context com contribuição de todas as expressões de argumento
  3. Quando o retorno é um ref struct, o ref-safe-context tem contribuição de todos os argumentos ref

Para uma expressão new com inicializadores, as expressões inicializadoras contam como argumentos (contribuem com seus safe-context) e as expressões inicializadoras ref contam como argumentos ref (contribuem com seus ref-safe-context), recursivamente.

Alterações no contexto sem segurança

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

O operador address-of (seção 23.6.5) foi flexibilizado para permitir uma variável com um tipo gerenciado como seu operando.

A instrução fixed (seção 23.7) é flexibilizada para aceitar fixed_pointer_initializer que é o endereço de uma variável de tipo gerenciado T ou que é uma expressão de um array_type com elementos de um tipo gerenciado T.

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

Considerações

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

Considerações sobre compatibilidade

O desafio nesta proposta são as implicações de compatibilidade que este design tem para as nossas regras de segurança de abrangência existentes, ou §9.7.2. Embora essas regras ofereçam suporte total ao conceito de um ref struct que tenha campos ref, elas não permitem que APIs que não sejam stackalloc capturem o estado ref que se refere à pilha. As regras ref safe context tem uma suposição forte, ou §16.4.12.8 que um construtor da forma Span(ref T value) não existe. Isso significa que as regras de segurança não consideram um parâmetro ref ser capaz de 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. Retorno por valor
  2. Retorno por ref
  3. Pelo campo ref em ref struct que é retornado ou passado como parâmetro ref / out

As regras existentes só levam em conta (1) e (2). Eles não consideram (3) e, portanto, lacunas como o retorno de locais como campos ref não são contabilizadas. Esse design deve alterar as regras levar em conta (3). Isso terá um pequeno impacto na compatibilidade das APIs existentes. Especificamente, isso afetará APIs que tenham as seguintes propriedades.

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

Em C# 10, os usuários dessas APIs nunca precisaram considerar que a entrada de estado da API ref poderia ser capturada como um campo ref. Isso permitiu que vários padrões existissem de forma segura no C# 10, mas que não serão seguros no C# 11 devido à capacidade do estado ref de 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]);
}

Espera-se que o impacto dessa quebra de compatibilidade seja muito pequeno. A forma de API afetada fazia pouco sentido na ausência de campos ref; portanto, é improvável que os clientes criem muitos deles. Experimentos que usam ferramentas para identificar essa estrutura de API em repositórios existentes confirmam essa afirmação. O único repositório com contagens significativas dessa forma é o dotnet/runtime, e isso ocorre porque esse repositório pode criar campos ref por meio do tipo intrínseco ByReference<T>.

Mesmo assim, o design deve levar em conta a existência dessas APIs porque elas expressam um padrão válido, mas não um padrão comum. Portanto, o design deve fornecer aos desenvolvedores as ferramentas para restaurar as regras de tempo de vida existentes ao atualizar para o C# 10. Especificamente, ele deve fornecer mecanismos que permitam aos desenvolvedores anotar parâmetros ref como incapazes de escapar pelo campo ref ou ref. Isso permite que os clientes definam APIs no C# 11 que possuem as mesmas regras de local de chamada do C# 10.

Assemblies de Referência

Uma montagem de referência para uma compilação usando funcionalidades descritas nesta proposta deve manter os elementos que transmitem informações de ref safe context. Isso significa que todos os atributos de anotação de ciclo de vida devem ser preservados em sua posição original. Qualquer tentativa de substituição ou omissão deles pode resultar em montagens de referência inválidas.

Representar campos ref tem mais nuances. O ideal é que um campo ref seria exibido em uma montagem 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 é o C++/CLI que provavelmente gerará erro se consumir um campo ref. Portanto, será vantajoso se os campos ref puderem ser omitidos das montagens de referência nas nossas bibliotecas principais.

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

  • A ref struct que tem um campo ref nunca é considerado unmanaged
  • O tipo do campo ref afeta regras de expansão genéricas infinitas. Portanto, se o tipo de um campo ref contiver um parâmetro de tipo que deve ser preservado

Considerando essas regras, veja em seguida uma transformação válida de montagem de referência 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

Os tempos de vida são mais naturalmente expressos usando tipos. Os tempos de vida de um determinado programa são seguros quando os tipos de tempo de vida são verificados. Embora a sintaxe do C# adicione implicitamente tempos de vida aos valores, há um sistema de tipos subjacente que descreve as regras fundamentais aqui. Geralmente é mais fácil discutir a implicação das mudanças no design em relação a essas regras; por isso, elas são incluídas aqui para fins de discussão.

Observe que essa documentação não se destina a ser 100% completa. Documentar cada comportamento não é um objetivo aqui. Em vez disso, destina-se a estabelecer uma compreensão geral e uma linguagem comum por meio da qual o modelo e possíveis alterações nele possam ser discutidos.

Normalmente, não é necessário falar diretamente sobre tipos de tempo de vida. As exceções são pontos onde os tempos de vida podem variar com base em locais específicos de "instanciação". Este é um tipo de polimorfismo e chamamos esses tempos de vida variáveis ​​de "tempos de vida genéricos", representados como parâmetros genéricos. O C# não fornece sintaxe para expressar genéricos de tempo de vida, portanto, 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 usam tempos de vida nomeados. A sintaxe $a refere-se a um tempo de vida denominado a. É uma vida que não tem significado por si só, mas pode receber uma relação com outras vidas por meio da sintaxe where $a : $b. Isso estabelece que $a é conversível para $b. Pode ajudar a pensar nisso como estabelecer que $a é uma vida útil pelo menos tão longa quanto $b.

Há alguns tempos de vida predefinidos para conveniência e brevidade abaixo:

  • $heap: esse é o tempo de vida de qualquer valor que exista no heap. Ele está disponível em todos os contextos e assinaturas de método.
  • $local: este é o tempo de vida de qualquer valor que exista na pilha de métodos. Ele é basicamente um substituto de nome para function-member. Ele é definido implicitamente em métodos e pode aparecer em assinaturas de métodos, exceto para posições de saída.
  • $ro: espaço reservado para nome para return only
  • $cm: espaço reservado para o nome de caller-context

Existem algumas relações predefinidas entre tempos de vida:

  • where $heap : $a para todos os tempos 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 sejam definidos explicitamente.

Variáveis ​​de tempo de vida, quando definidas em tipos, podem ser invariantes ou covariantes. Eles são expressos usando a mesma sintaxe dos parâmetros genéricos:

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

O parâmetro de tempo de vida $this em 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 ref deve ser conversível em $this
  • O tempo de vida $this de todos os campos que não são ref deve ser $heap ou $this.

O tempo de vida de uma ref é expresso fornecendo um argumento de tempo de vida à ref. Por exemplo, um ref que se refere ao heap é 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>. O tipo de this no construtor, incluindo tempos de vida, será o valor retornado definido.

As regras básicas para o tempo de vida são definidas como:

  • Todos os tempos de vida são expressos sintaticamente como argumentos genéricos, vindo antes dos argumentos de tipo. Isso é verdadeiro para tempos de vida predefinidos, exceto $heap e $local.
  • Todos os tipos T que não são um ref struct implicitamente têm tempo de vida de T<$heap>. Isso é implícito, não é necessário escrever int<$heap> em cada amostra.
  • Para um campo ref definido como ref<$l0> T<$l1, $l2, ... $ln>:
    • Todos os ciclos de vida de $l1 a $ln devem ser invariantes.
    • O tempo de vida de $l0 deve ser conversí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 local ref, 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 atribuição ou retorno é legal quando a conversão de tipo subjacente é legal
  • Os tempos de vida das expressões podem ser explicitados usando anotações de conversão:
    • (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 para T<...> e o tempo de vida da referência é $a.

Para os propósitos das regras de tempo de vida de uma expressão, um ref é considerado parte essencial do tipo da expressão para fins de conversões. É representado logicamente 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 do C# para o modelo subjacente.

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

Essas regras existem para dar suporte à nossa invariável existente que T pode ser atribuída a scoped T para todos os tipos. Isso faz com que T<$a, ...> seja atribuível a T<$local, ...> para qualquer tempo de vida que possa ser convertido em $local. Além disso, isso dá suporte a outros itens, como a possibilidade de atribuir Span<T> do heap àqueles na pilha. Isso exclui tipos em que os campos têm tempos de vida diferentes para valores que não são referência, mas essa é a realidade do C# hoje. Mudar 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 como o seguinte:

  • 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 complexos, considere escrever como um método static e tornar 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# se relaciona com o modelo da seguinte maneira:

  • Os parâmetros ref têm um tempo de vida de ref de $ro
  • parâmetros do tipo ref struct têm um tempo de vida de $cm
  • retornos de ref têm uma vida útil de ref de $ro
  • retornos de tipo ref struct têm um tempo de vida de valor de $ro
  • scoped em um parâmetro ou ref altera o tempo de vida de referência para ser $local

Considerando que 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 no 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 bobo do parâmetro de captura:

ref struct S
{
    ref int refField;

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

// Maps to 

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

Problemas Abertos

Alterar o design para evitar quebras de compatibilidade

Este design propõe várias quebras de compatibilidade com nossas regras existentes de ref-safe-context. Embora se acredite que as quebras tenham impacto mínimo, foi dada uma consideração significativa a um design que não tivesse alterações significativas.

O design de preservação de compatibilidade, porém, era significativamente mais complexo do que este. Para preservar a compatibilidade, os campos ref precisam ter tempos de vida diferentes para permitir o retorno pelo campo ref e pelo campo ref. Basicamente, isso exige que forneçamos acompanhamento de ref-field-safe-context para todos os parâmetros para um método. Isso precisa ser calculado para todas as expressões e rastreado em todos os valores em quase todos os lugares onde o ref-safe-context é acompanhado hoje.

Além disso, esse valor tem relações com o contexto ref-safe . Por exemplo, não faz sentido ter um valor que pode ser retornado como um campo ref, mas não diretamente como um ref. Isso ocorre porque campos ref já podem ser devolvidos trivialmente por ref (o estadoref em um ref struct pode ser devolvido por ref mesmo quando o valor contido não pode). Portanto, as regras precisam de ajustes constantes para garantir que esses valores sejam adequados em relação uns aos outros.

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

No entanto, essas mudanças de compatibilidade impactarão métodos que tenham as seguintes propriedades:

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

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

  1. Deseja que os consumidores considerem ref ao ser capturado como um campo de ref. O exemplo principal são os construtores Span(ref T value)
  2. Não deseja que os consumidores considerem ref ao ser capturado como um campo de ref. No entanto, eles dividem-se em duas categorias
    1. APIs não seguras. Estas são APIs 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 como APIs não seguras.
    2. APIs seguras. Estas são APIs que levam parâmetros ref para eficiência, mas isso não é capturado de fato em nenhum lugar. Os exemplos são pequenos, mas um é AsnDecoder.ReadEnumeratedBytes

Esta mudança beneficia principalmente (1) acima. Espera-se que elas constituam a maioria das APIs que recebem um ref e retornam um ref struct daqui para frente. As alterações afetam negativamente (2.1) e (2.2), pois quebra a semântica de chamada existente porque as regras de tempo de vida mudam.

Porém, as APIs na categoria (2.1) são em grande parte criadas pela Microsoft ou por desenvolvedores que mais têm a ganhar com os 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 o C# 11, sob a forma de algumas anotações para preservar a semântica existente, caso campos ref fossem fornecidos em troca.

As APIs na categoria (2.2) são o maior problema. Não se sabe quantas dessas APIs existem e não está claro se elas seriam mais ou menos frequentes em códigos de terceiros. A expectativa é que haja um número muito pequeno deles, principalmente se adotarmos a quebra de compatibilidade em out. Pesquisas até agora revelaram um número muito pequeno desses existentes na superfície public. Esse é um padrão difícil de procurar, pois requer análise semântica. Antes de adotar essa mudança, seria necessária uma abordagem baseada em ferramentas para verificar as suposições sobre o impacto disso em um pequeno número de casos conhecidos.

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

O ideal é que o idioma reduza o impacto de alterações silenciosas de interrupção emitindo um aviso quando uma API silenciosamente se enquadra no comportamento problemático. Esse seria um método que usa um ref, retorna ref struct, mas não captura ref em ref struct. O compilador poderia emitir um diagnóstico nesse caso informando aos desenvolvedores que ref deve ser anotado como scoped ref.

Decisão Este design pode ser alcançado, mas o recurso resultante torna-se mais difícil de usar, a ponto de decidirmos pela quebra de compatibilidade.

Decisão O compilador fornecerá um aviso quando um método atender aos critérios, mas não captura o parâmetro ref como um campo ref. Isso deve alertar de forma adequada os clientes, durante a atualização, sobre os possíveis problemas que eles estão criando.

Palavras-chave versus atributos

Este design requer o uso de atributos para anotar as novas regras de tempo de vida. Isso também poderia ter sido feito facilmente com palavras-chave contextuais. Por exemplo, [DoesNotEscape] poderia mapear para scoped. Contudo, palavras-chave, mesmo as contextuais, geralmente devem atender a um padrão muito alto para inclusão. Eles ocupam espaço valioso no idioma e são partes mais proeminentes do idioma. Embora valioso, esse recurso servirá a uma minoria de desenvolvedores C#.

Na superfície, isso parece favorecer não usar palavras-chave, mas há dois pontos importantes a serem considerados:

  1. As anotações afetarão a semântica do programa. Ter atributos que impactam a semântica do programa é uma linha que o C# reluta em cruzar e não está claro se essa é a característica que deve justificar a linguagem tomar essa decisão.
  2. Os desenvolvedores mais propensos a usar esse recurso coincidem fortemente com o grupo de desenvolvedores que utilizam ponteiros de função. Embora também usado por uma minoria de desenvolvedores, esse recurso justificava uma nova sintaxe e essa decisão ainda é vista como sensata.

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

Um esboço da sintaxe seria:

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

Decisão Usar sintaxe para scoped e scoped ref; usar atributo para unscoped.

Permitir locais de buffer fixos

Esse design permite buffers de fixed seguros que podem suportar qualquer tipo. Uma possível extensão aqui é permitir que tais buffers fixed sejam declarados como variáveis ​​locais. Isso permitiria que diversas operações existentes stackalloc fossem substituídas por um buffer fixed. Isso também expandiria o conjunto de cenários em que poderíamos ter alocação no estilo de pilha, já que stackalloc está limitado a tipos de elementos não gerenciados, enquanto os buffers de fixed não estão.

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

Isso é sustentável, mas exige que estendamos um pouco a sintaxe para os elementos locais. Não está claro se isso vale ou não a complexidade extra. Talvez decidamos não fazer isso por enquanto e retornar depois se houver necessidade.

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

Decisão adiar isso por enquanto

Usar modreqs ou não usar?

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

A justificativa para adicionar um modreq é que os atributos alteram a semântica das regras de contexto seguro de referência. Somente linguagens que entendem essas semânticas devem chamar os métodos em questão. Além disso, quando aplicado a cenários de OHI, os ciclos de vida se tornam um contrato que todos os métodos derivados devem implementar. A existência das anotações sem modreq pode levar a situações em que sequências de métodos virtual com anotações de tempo de vida conflitantes são carregadas (pode ocorrer se somente uma parte da sequência virtual for compilada e a outra não).

O trabalho ref safe context inicial não usou modreq mas, em vez disso, contou com linguagens e a estrutura para entender. Ao mesmo tempo, todos os elementos que contribuem para as regras de contexto seguro de referência são uma parte importante da assinatura do método: ref, in, ref struct etc. Portanto, qualquer alteração nas regras existentes de um método já resulta em uma alteração binária na assinatura. Para que as novas anotações de tempo de vida tenham o mesmo impacto, será necessária a aplicação modreq.

A preocupação é se isso é exagero ou não. O impacto negativo é que tornar as assinaturas mais flexíveis, por exemplo, ao adicionar [DoesNotEscape] a um parâmetro, resultará em uma alteração de compatibilidade binária. Essa compensação significa que, ao longo do tempo, estruturas como a BCL provavelmente não serão capazes de flexibilizar essas assinaturas. Isso pode ser mitigado até certo ponto adotando-se uma abordagem que a linguagem usa com parâmetros in, aplicando apenas modreq em posições virtuais.

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

Permitir buffers fixos multidimensionais

O design para buffers fixed deve ser estendido para incluir matrizes multidimensionais de estilo? Permitir essencialmente declarações como as seguintes:

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

Decisão Não permitir por enquanto

Violando o escopo

O repositório de runtime tem várias APIs não públicas que capturam parâmetros ref como campos ref. Elas não são seguras 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 optará por ter um rastreamento do tempo de vida adequado para o retorno, o que será alcançado simplesmente ao atualizar para C# 11. Porém, alguns vão querer manter sua semântica atual de não rastrear o valor retornado porque a intenção deles é justamente ser insegura. Os exemplos mais notáveis são MemoryMarshal.CreateSpan e MemoryMarshal.CreateReadOnlySpan. Isso será alcançado marcando os parâmetros como scoped.

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

  1. Unsafe.AsRef<T>(in T value) poderia expandir seu propósito existente mudando para scoped in T value. Isso permitiria remover in e scoped de parâmetros. Em seguida, ele se torna o método universal de "remover a segurança de ref"
  2. Introduzir um novo método cujo único propósito é remover scoped: ref T Unsafe.AsUnscoped<T>(scoped in T value). Isso também remove in porque, se não o removesse, os usuários ainda precisariam de uma combinação de chamadas de método para "remover a segurança de ref", ponto em que a solução existente provavelmente seria suficiente.

Removeu o 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 consideravelmente a carga de compatibilidade dos campos de ref, ao mesmo tempo em que estabelece um padrão mais natural. Isso permite que os desenvolvedores realmente pensem em out como dados que fluem apenas para fora, enquanto, no caso de ser ref, as regras devem considerar os dados fluindo em ambas as direções. Isso leva a uma confusão significativa para o 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 ele.

Palavras-chave exigem altos padrões, e adicioná-las para um único cenário é questionável. Pensou-se se poderíamos evitar essa palavra-chave, transformando this em ref por padrão, em vez de scoped ref. Todos os membros que precisam que this seja scoped ref podem fazer isso ao marcar o método scoped (assim como um método pode ser marcado readonly para criar um readonly ref hoje).

Em um struct normal, trata-se principalmente de uma mudança positiva, pois só introduz problemas de compatibilidade quando algum membro possui um retorno ref. Há pouquíssimos desses métodos e uma ferramenta pode localizá-los e convertê-los em membros scoped rapidamente.

Em um ref struct, essa alteração introduz problemas de compatibilidade significativamente maiores. 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;
    }
}

Basicamente, isso significa que todas as invocações de método de instância em locais mutáveisref struct seriam ilegais, a menos que o local fosse marcado como scoped. As regras devem 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 do readonly impede a reatribuição de ref. Ainda assim, isso representaria uma alteração considerável que quebra a compatibilidade retroativa, pois afetaria praticamente todos os ref struct mutáveis existentes.

Um readonly ref struct, porém, ainda é problemático quando expandimos para incluir campos ref até ref struct. 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);
    }
}

Foi considerada a ideia de que this tivesse padrões diferentes baseados 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 quebras de compatibilidade e maximiza a flexibilidade, mas ao custo de complicar a história para os clientes. Ele também não resolve totalmente o problema porque os 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 ele se enquadraria na categoria scoped ref.

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

campos ref para estrutura ref

Este recurso abre um novo conjunto de regras de contexto seguro de referência porque permite que um campo ref se refira a um valor ref struct. Essa natureza genérica de ByReference<T> significava que até agora o tempo de execução não poderia ter tal constructo. Como resultado, todas as nossas regras são escritas sob a suposição de que isso não é possível. O recurso de campo ref não é em grande parte sobre criar regras, mas codificar as regras existentes em nosso sistema. Permitir campos ref a ref struct requerem que codifiquemos novas regras, já que há vários novos cenários a serem considerados.

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

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

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

Isso significa que, ao pensar em argumentos de método, devemos considerar as regras que determinam que readonly ref T seja uma saída potencial do método quando T potencialmente tem 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 um outro escopo de escape que representa os valores nos campo ref. No caso de vários campos ref, eles podem ser rastreados coletivamente como um único valor. O valor padrão para esses parâmetros é caller-context.

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; ou seja, à medida que o contexto do contêiner diminui, isso não afeta o contexto seguro do campo de referência dos valores de campo ref. Além disso, o ref-field-safe-context nunca pode ser menor do que o safe-context do contêiner.

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 ref-field-safe-context basicamente sempre existiu. Até agora, os campos ref só podiam apontar para struct normais, portanto, foram trivialmente ocultos no contexto de chamador caller-context. Para dar suporte a campos ref para ref struct, nossas regras existentes precisam ser atualizadas para levar em conta este novo ref-safe-context.

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

Esses problemas são muito solucionáveis. A equipe do compilador esboçou algumas versões dessas regras e elas, em grande parte, são derivadas de nossa análise existente. O problema é que não há código de consumo para tais regras que ajude a provar sua correção e usabilidade. Isso nos torna muito hesitantes em adicionar suporte devido ao medo de escolher padrões errados e levar o tempo de execução para uma situação de comprometimento da usabilidade quando isso for utilizado. 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 redigidas se fossem feitas em conjunto com o código de consumo.

Decisão Atrasar a permissão do campo ref ref struct até o .NET 8, em que temos cenários que ajudarão a conduzir as regras em torno desses cenários. Isso não foi implementado no .NET 9

O que tornará o C# 11.0?

Os recursos descritos neste documento não precisam ser implementados de uma só vez. Em vez disso, eles podem ser implementados em fases em vários lançamentos de idiomas nos seguintes grupos:

  1. Campos ref e scoped
  2. [UnscopedRef]
  3. Campos ref e ref struct
  4. Tipos restritos de Sunset
  5. buffers de tamanho fixo

O que é implementado em qual versão é apenas um exercício de escopo.

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

Considerações futuras

Anotações avançadas de tempo vida

As anotações de tempo de vida nesta proposta são limitadas, pois permitem que os desenvolvedores alterem o comportamento de escape/não escape dos valores. Isso adiciona uma flexibilidade poderosa ao nosso modelo, mas não altera radicalmente o conjunto de relacionamentos que podem ser expressos. No fundo, o modelo C# ainda é efetivamente binário: um valor pode ser retornado ou não?

Isso permite que relacionamentos limitados ao longo da vida sejam compreendidos. 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. Não há como descrever a relação de tempo de vida entre valores que podem ser retornados de um método. Especificamente, não há como dizer que um valor tem um tempo de vida maior que o outro, uma vez que fica estabelecido que ambos podem ser retornados de um método. A próxima etapa na evolução da nossa vida seria permitir que tais relacionamentos fossem descritos.

Outros métodos, como Rust, permitem que esse tipo de relacionamento seja expresso e, portanto, podem implementar operações de estilo scoped mais complexas. Nossa linguagem também poderia se beneficiar se tal recurso fosse incluído. No momento, não há nenhuma pressão para fazer isso, mas, se houver no futuro, nosso modelo scoped poderá ser expandido para incluí-lo de uma forma bastante direta.

Cada scoped pode receber 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 podem ser usadas para descrever as relações entre esses tempos de vida.

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

Esse método define dois tempos de vida 'a e 'b e sua relação, especificamente 'b é maior que 'a. Isso permite que o ponto de chamada tenha regras mais granulares sobre como os valores podem ser passados com segurança para métodos, em comparação com as regras mais genéricas presentes hoje.

Problemas

As seguintes questões estão todas relacionadas a esta proposta:

Propostas

As seguintes propostas estão relacionadas a esta proposta:

Exemplos existentes

Utf8JsonReader

Esse snippet específico requer "unsafe" porque apresenta problemas com a passagem de uma Span<T> que pode ser alocada na pilha para um método de instância em um ref struct. Mesmo que esse parâmetro não seja capturado, a linguagem deve assumir que sim e, portanto, causa atrito desnecessário aqui.

Utf8JsonWriter

Este snippet deseja modificar um parâmetro fazendo escape dos elementos dos dados. Os dados com escape podem ser alocados na pilha para eficiência. Embora o parâmetro não tenha escapado, o compilador atribui a ele um safe-context, fora do método delimitador, 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.

Exemplos

ReadOnlySpan<T>

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

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

Lista frugal

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 anotações

Abaixo há 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 impedem que eles aconteçam. É importante ter isso em mente ao fazer ajustes na proposta.

Reatribuição de ref e locais de chamada

Demonstrando como a reatribuição de ref e invocação de método funcionam juntos.

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 escapes não seguros

O motivo da seguinte linha nas regras de reatribuição de ref pode não ser óbvio à primeira vista:

e1 deve ter o mesmo contexto seguro que e2

Isso ocorre porque a vida útil dos valores apontados pelas localizações ref é invariável. A indireção nos impede de permitir qualquer tipo de variância aqui, até mesmo para tempos de vida mais restritos. Se o estreitamento for permitido, o seguinte código inseguro será aberto:

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, essa regra é satisfeita trivialmente, pois todos os valores têm o mesmo safe-context. Essa regra realmente só se aplica quando o valor é ref struct.

Esse comportamento de ref também será importante em um futuro no qual permitiremos campos ref para ref struct.

locais com escopo

O uso de scoped em variáveis locais será especialmente útil para padrões de código que atribuem condicionalmente valores com diferentes safe-context to locals para locais. Isso significa que o código não precisa mais contar com truques de inicialização como = stackalloc byte[0] para definir safe-context, e 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];
}

Esse padrão aparece frequentemente em código de baixo nível. Quando o ref struct envolvido é Span<T>, o truque acima pode ser usado. No entanto, isso não é aplicável a outros tipos ref struct e pode fazer com que o código de baixo nível precise recorrer a unsafe para contornar a incapacidade de especificar corretamente o tempo de vida.

valores de parâmetro com escopo

Uma fonte de atrito repetido em códigos de baixo nível é que o mecanismo de escape padrão para parâmetros é permissivo. Eles são safe-context ao caller-context. Este é um padrão sensato porque está de acordo com os padrões de codificação do .NET como um todo. No código de baixo nível, embora o ref struct seja mais usado, este padrão de uso pode causar conflito com outras partes das regras ref safe context.

O ponto de atrito principal ocorre porque os argumentos do método devem atender à 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, onde os tipos ref struct geralmente aproveitam parâmetros Span<T> em seus métodos. Por exemplo, ocorrerá em qualquer estilo de escrita ref struct que use Span<T> para passar 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 de um método escapam para o safe-context máximo permitido. Quando há parâmetros ref ou out, incluindo os receptores, é 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 mas não têm a intenção de capturá-los na saída. É apenas um valor 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))
        {
            ...
        }
    }
}

Como solução, esse código de baixo nível recorrerá a truques unsafe para enganar o compilador sobre o tempo de vida de seus ref struct. Isso reduz consideravelmente a proposta de valor de ref struct, pois eles devem ser um meio de evitar unsafe enquanto continuam a escrever código de alto desempenho.

É aí que scoped torna-se uma ferramenta eficaz em parâmetros de ref struct porque os remove da consideração como sendo retornados do método de acordo com os argumentos de método atualizados 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))
        {
            ...
        }
    }
}

Impedir a complicada atribuição de ref de mutação somente leitura

Quando um ref é levado para um campo readonly em um construtor ou membro init, o tipo ref não é ref readonly. Esse é um comportamento antigo que permite código como o seguinte:

struct S
{
    readonly int i; 

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

    static void M(ref int i) { }
}

No entanto, isso representa um possível problema, se esse ref puder ser armazenado em um campo ref do mesmo tipo. Isso permitiria a mutação direta de um readonly struct a partir de um membro de 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 isso porque viola as regras de contexto seguro de referência. Considere o seguinte:

  • O ref-safe-context de this é function-member e safe-context é caller-context. Ambos são padrão para this em um membro struct.
  • O ref-safe-context de i é function-member. Isso sai das regras de tempo de vida de campo. Especificamente a regra 4.

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

Essas regras não foram criadas para impedir esse comportamento, mas sim como um efeito colateral. É importante ter isso em mente em qualquer atualização futura de regra para avaliar o impacto em cenários como esse.

Atribuição cíclica simples

Um aspecto com o qual esse design teve dificuldade é o quão livremente um ref pode ser retornado 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, ele 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 retornado com o mesmo tempo de vida que um valor, isso está de acordo com as regras. O compilador deve considerar todos os casos legais ao avaliar uma chamada de método, o que faz com que essas APIs fiquem 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 ref de um parâmetro ref seja menor que o tempo de vida de quaisquer referências no valor do parâmetro associado. Essa é a lógica de ter ref-safe-context para ref como ref struct sendo return-only e out sendo caller-context. Isso impede a atribuição cíclica devido à diferença nos tempos de vida.

Observe que [UnscopedRef]promove o ref-safe-context de qualquer ref to ref struct valor para caller-context e, portanto, permite a atribuição cíclica e força o uso viral de [UnscopedRef] acima da cadeia de chamada:

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 safe-context e ref-safe-context de return-only.

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

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

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

ref struct S
{
    public ref int RefField;

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

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 através de campos ref

Considere o exemplo de código abaixo:

ref struct S
{
    ref int Field;

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

Ao projetar as regras dos campos ref em instâncias de readonly isoladamente, as regras podem ser criadas de forma válida, de modo que o mencionado acima seja permitido ou proibido. Basicamente, readonly pode ser validamente profundo através de um campo ref ou pode se aplicar somente ao ref. A aplicação somente ao ref impede a reatribuição de ref, mas permite a atribuição normal que altera o valor referenciado.

Porém, esse design não existe em um vácuo e é um projeto de regras para tipos que já têm efetivamente campos ref. E o mais proeminente deles, o Span<T>, já possui uma forte dependência de que o readonly não seja 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;
        }
    }
}

Isso significa que devemos escolher a interpretação superficial de readonly.

Construtores de modelagem

Uma questão sutil de design é: como os corpos dos construtores são modelados para garantir a segurança de ref? Basicamente, como o construtor a seguir é analisado?

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 seu safe-context é caller-context
  2. Modele como um método static em que this é um parâmetro out.

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

  1. Verifique se os parâmetros ref podem ser capturados como campos ref.
  2. Verifique se os campos ref de this não podem ser acessados através de parâmetros ref. Isso violaria a atribuição complicada de ref.

A intenção é escolher a forma que satisfaça nossas invariantes sem introduzir nenhuma regra especial para construtores. Considerando que o melhor modelo para construtores é exibir this como um parâmetro out. A natureza return only do out nos permite satisfazer todos os invariantes acima sem qualquer tratamento especial de casos:

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 que os argumentos do método devem corresponder é 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 entender melhor as razões da regra, simplificaremos contexto de referência segura e contexto seguro para simplesmente contexto.

Os métodos podem retornar facilmente o estado passado a eles como parâmetros. Basicamente, qualquer estado acessível que não tenha escopo pode ser retornado (incluindo ser retornado por ref). Isso pode ser retornado diretamente por meio de uma instrução return ou indiretamente através da atribuição de um valor em uma instrução ref.

Os retornos diretos não representam muitos problemas para a segurança de referência. O compilador simplesmente precisa analisar todas as entradas retornáveis ​​de um método e então efetivamente restringe o valor de retorno para ser o contexto mínimo da entrada. Esse valor de retorno então passa pelo processamento normal.

Os retornos indiretos representam um problema significativo porque todos os ref são uma entrada e uma saída para o método. Essas saídas já têm um context conhecido. O compilador não pode inferir novos, ele tem que considerá-los em seu nível atual. Isso significa que o compilador precisa examinar cada ref que seja atribuível no método chamado, avaliar seu contexto , e, em seguida, verificar se nenhuma entrada de resultado para o método tem um context menor do que isso ref. Se houver algum caso desse tipo, a chamada do método deve ser ilegal, pois pode violar as normas de 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 geralmente é mais fácil para os desenvolvedores considerarem, é fazer o seguinte exercício:

  1. Observe a definição do método e identifique todos os lugares onde o estado pode ser retornado indiretamente: a. Parâmetros mutáveis ref que apontam para ref struct b. Parâmetros ref mutáveis com campos ref atribuíveis por ref c. Parâmetros ref atribuíveis ou campos ref que apontam para ref struct (considerar recursivamente)
  2. Examine o local de chamadas a. Identifique os contextos que se alinham com os locais identificados acima b. Identifique os contextos de todas as entradas no método que são retornáveis e não correspondem aos parâmetros scoped.

Se qualquer valor em 2.b for menor que 2.a, a chamada do 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);
    }
}

Ao olhar para a chamada para 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 e tem contexto context de caller-context
  • b que mapeia para y, que possui context de function-member

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

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

O valor ref y não é retornável, pois é mapeado para um scoped ref; portanto, não é considerado uma entrada. Mas considerando que há pelo menos uma entrada com um escopo de escape menor (argumento y) do que uma das saídas (argumento x), 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. Contudo, b pode ser excluído porque não aponta para um ref struct; portanto, não pode ser usado para armazenar o estado ref. Assim, temos:

  • a que mapeia para x e tem contexto context de caller-context

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

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

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

Essa é a lógica que a regra de correspondência dos argumentos do método está tentando abranger. Ele vai além, pois considera scoped como uma maneira de remover entradas da consideração e readonly como uma maneira de remover ref como uma saída (não pode atribuir a uma 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 busca remover todas as entradas e saídas que ele sabe que não podem contribuir para o resultado, de modo a oferecer aos desenvolvedores o máximo de flexibilidade ao chamar um membro. Assim como a resolução de sobrecarga, vale a pena o esforço de tornar nossas regras mais complexas quando isso cria mais flexibilidade para os consumidores.

Exemplos de safe-context de expressões de declaração

Relacionado ao Infer safe-context de 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 restrito que poderia ser usado para a variável. Ser mais restrito pode significar que a expressão se refere a variáveis declaradas somente em um contexto mais restrito que a expressão.