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:
- Campos
ref
escoped
[UnscopedRef]
Esses recursos continuam sendo propostas abertas para uma versão futura do C#:
- Campos
ref
eref struct
- 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 camposref
. - Permitir que o runtime defina totalmente
Span<T>
usando o sistema de tipos C# e remova o tipo de caso especial comoByReference<T>
- Permitir que os tipos
struct
retornemref
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 nostruct
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étodosinit
. 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âmetroin
pode ser reatribuído a um camporef
.-
readonly ref readonly
: uma combinação deref readonly
ereadonly 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.F
ref-safe-context da seguinte maneira:
- Se
F
for um camporef
, seu ref-safe-context será o safe-context dee
.- Caso contrário, se
e
for de um tipo de referência, ele terá ref-safe-context de caller-context- 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:
e2
deve ter ref-safe-context pelo menos do mesmo tamanho de ref-safe-context dee1
e1
deve ter o mesmo contexto seguro quee2
Nota
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
- safe-context de qualquer argumento em que seu parâmetro correspondente não seja
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ânciastruct
- 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 ref
safe-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
ouin
terá um ref-safe-context de return-only. Isso acontece, em partes, para queref 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 umref struct
terá safe-context de return-only. Isso permite que o retorno eout
sejam igualmente expressivos. Isso não tem o problema trivial de atribuição cíclica, poisout
é implicitamentescoped
para que o ref-safe-context permaneça menor que o safe-context. - Um parâmetro
this
para um construtorstruct
terá um safe-context de return-only. Isso se torna inválido porque é modelado como parâmetrosout
.
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
:
- Se
p
forscoped ref
,expr
não contribuirá para ref-safe-context ao considerar argumentos.- Se
p
forscoped
,expr
não contribuirá para safe-context ao considerar argumentos.- Se
p
forout
,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, ...)
, ondeM()
não retorna ref-to-ref-struct e tem um safe-context obtido do mais estreito dos seguintes:
- O caller-context
- Quando o retorno é um
ref struct
, o safe-context tem contribuição de todas as expressões de argumento.- Quando o retorno é um
ref struct
, o ref-safe-context tem contribuição de todos os argumentosref
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, ...)
, ondeM()
não retorna ref-to-ref-struct e é o ref-safe-context mais estreito dos seguintes contextos:
- O caller-context
- O safe-context com contribuição de todas as expressões de argumento
- 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:
- O safe-context da chamada do construtor.
- O safe-context e ref-safe-context de argumentos para indexadores de inicialização de membros que podem escapar para o receptor.
- 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)
- 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.
- Todos os argumentos
ref
de tiposref struct
devem ser atribuíveis por um valor com esse safe-context. Este é um caso em queref
não generaliza para incluirin
eout
Para invocação de qualquer método
e.M(a1, a2, ... aN)
- 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
- Todos os argumentos
out
de tiposref 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âmetroref
ouin
- Adicionar
scoped
a um parâmetroref struct
- Remover
[UnscopedRef]
de um parâmetroout
- Remover
[UnscopedRef]
de um parâmetroref
de um tiporef 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
ouout
de tiporef struct
com uma incompatibilidade devido à adição de[UnscopedRef]
(não remoção descoped
). (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 umref
ouref readonly
, ou o método tem um parâmetroref
ouout
do tiporef struct
. - O método tem pelo menos um parâmetro adicional
ref
,in
ouout
, ou um parâmetro do tiporef struct
.
- O método retorna um
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étodoTryParse
) e relatar discrepâncias de escopo apenas porque são usados na versão 11 da linguagem (e, portanto, o parâmetroout
é 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
ouref
campo. - Um parâmetro
out
teria um safe-context de function-member.
Anotações detalhadas:
- Um campo
ref
só pode ser declarado dentro de umref struct
- Um campo
ref
não pode ser declaradostatic
,volatile
ouconst
- Um campo
ref
não pode ter um tipo que sejaref struct
- O processo de geração de montagem de referência deve preservar a presença de um campo de
ref
dentro de umref struct
- Um
readonly ref struct
deve declarar seus camposref
comoreadonly ref
- Para valores by-ref, o modificador
scoped
deve aparecer antes dein
,out
ouref
- 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
- A biblioteca principal contém o sinalizador de recurso que indica suporte para campos
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 assinaturastatic TypedReference __makeref<T>(ref T value)
-
__refvalue
será tratado como um método com a assinaturastatic 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 dethis
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 umstruct
- 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
- Um membro que não foi declarado em um
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.
- parâmetros
ref
/in
/out
sem escopo podem escapar de uma invocação de método como camporef
de umref struct
no C#11, não no C#7.2 out
parâmetros têm escopo implícito no C#11 e não têm escopo no C#7.2ref
/in
parâmetros para tipos deref 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 deversion
, 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 camposref
, 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:
- O caller-context
- O safe-context com contribuição de todas as expressões de argumento
- Quando o retorno é um
ref struct
, o ref-safe-context tem contribuição de todos os argumentosref
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:
- Retorno por valor
- Retorno por
ref
- Pelo campo
ref
emref struct
que é retornado ou passado como parâmetroref
/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âmetroref
ouout
- Tem um parâmetro
in
ouref
adicional, excluindo o receptor
- Onde o
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 camporef
nunca é consideradounmanaged
- O tipo do campo
ref
afeta regras de expansão genéricas infinitas. Portanto, se o tipo de um camporef
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 umref struct
implicitamente têm tempo de vida deT<$heap>
. Isso é implícito, não é necessário escreverint<$heap>
em cada amostra. - Para um campo
ref
definido comoref<$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
- Todos os ciclos de vida de
- Para um
ref
definido comoref<$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 tiporef<$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
- Para um local
- 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
paraT<...>
ref<$a> (T<$b>)expr
o tempo de vida do valor é$b
paraT<...>
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 ouref
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>
ouref struct
- Onde o
ref struct
é um tipo de retorno, parâmetroref
ouout
- Tem um parâmetro
in
ouref
adicional (excluindo o receptor)
- Onde o
Para entender o impacto, é útil dividir as APIs em categorias:
- Deseja que os consumidores considerem
ref
ao ser capturado como um campo deref
. O exemplo principal são os construtoresSpan(ref T value)
- Não deseja que os consumidores considerem
ref
ao ser capturado como um campo deref
. No entanto, eles dividem-se em duas categorias- APIs não seguras. Estas são APIs dos tipos
Unsafe
eMemoryMarshal
, dos quaisMemoryMarshal.CreateSpan
é o mais proeminente. Essas APIs capturam oref
de forma insegura, mas também são conhecidas como APIs não seguras. - 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
- APIs não seguras. Estas são APIs dos tipos
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:
- 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.
- 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 parascoped ref
[DoesNotEscape]
mapeia parascoped
[RefDoesEscape]
mapeia paraunscoped
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:
-
Unsafe.AsRef<T>(in T value)
poderia expandir seu propósito existente mudando parascoped in T value
. Isso permitiria removerin
escoped
de parâmetros. Em seguida, ele se torna o método universal de "remover a segurança de ref" - Introduzir um novo método cujo único propósito é remover
scoped
:ref T Unsafe.AsUnscoped<T>(scoped in T value)
. Isso também removein
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
comoref
:struct
,readonly ref struct
oureadonly member
this
comoscoped ref
:ref struct
oureadonly ref struct
com camporef
pararef 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:
- Campos
ref
escoped
[UnscopedRef]
- Campos
ref
eref struct
- Tipos restritos de Sunset
- 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.
Informações relacionadas
Problemas
As seguintes questões estão todas relacionadas a esta proposta:
- https://github.com/dotnet/csharplang/issues/1130
- https://github.com/dotnet/csharplang/issues/1147
- https://github.com/dotnet/csharplang/issues/992
- https://github.com/dotnet/csharplang/issues/1314
- https://github.com/dotnet/csharplang/issues/2208
- https://github.com/dotnet/runtime/issues/32060
- https://github.com/dotnet/runtime/issues/61135
- https://github.com/dotnet/csharplang/discussions/78
Propostas
As seguintes propostas estão relacionadas a esta proposta:
Exemplos existentes
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.
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 quee2
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 parathis
em um membrostruct
. - 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:
- Modelo como um método
static
, em quethis
é um local onde seu safe-context é caller-context - Modele como um método
static
em quethis
é um parâmetroout
.
Além disso, um construtor deve atender às seguintes invariantes:
- Verifique se os parâmetros
ref
podem ser capturados como camposref
. - Verifique se os campos
ref
dethis
não podem ser acessados através de parâmetrosref
. 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:
- 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 pararef struct
b. Parâmetrosref
mutáveis com camposref
atribuíveis por ref c. Parâmetrosref
atribuíveis ou camposref
que apontam pararef struct
(considerar recursivamente) - 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 parax
e tem contexto context de caller-contextb
que mapeia paray
, 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 parax
e tem contexto context de caller-context
O conjunto de entradas retornáveis para o método são:
x
com context de caller-contextref x
com context de caller-contextref 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.
C# feature specifications