Partilhar via


Gamas

Observação

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

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

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

Questão campeã: https://github.com/dotnet/csharplang/issues/185

Resumo

Esta funcionalidade consiste em apresentar dois novos operadores para construir objetos System.Index e System.Range e utilizá-los para indexar/cortar coleções durante a execução.

Visão geral

Tipos e membros bem conhecidos

Para usar as novas formas sintáticas para System.Index e System.Range, novos tipos e membros bem conhecidos podem ser necessários, dependendo de quais formas sintáticas são usadas.

Para usar o operador "chapéu" (^), o seguinte é necessário

namespace System
{
    public readonly struct Index
    {
        public Index(int value, bool fromEnd);
    }
}

Para usar o tipo System.Index como um argumento em um acesso a elementos de matriz, é necessário o seguinte membro:

int System.Index.GetOffset(int length);

A sintaxe .. para System.Range exigirá o tipo System.Range, bem como um ou mais dos seguintes membros:

namespace System
{
    public readonly struct Range
    {
        public Range(System.Index start, System.Index end);
        public static Range StartAt(System.Index start);
        public static Range EndAt(System.Index end);
        public static Range All { get; }
    }
}

A sintaxe .. permite que um, ambos, ou nenhum dos seus argumentos esteja ausente. Independentemente do número de argumentos, o construtor Range é sempre suficiente para usar a sintaxe Range. No entanto, se algum dos outros membros estiver presente e faltar um ou mais dos .. argumentos, o membro adequado pode ser substituído.

Finalmente, para que um valor do tipo System.Range seja usado em uma expressão de acesso a elementos de matriz, o seguinte membro deve estar presente:

namespace System.Runtime.CompilerServices
{
    public static class RuntimeHelpers
    {
        public static T[] GetSubArray<T>(T[] array, System.Range range);
    }
}

System.Index

C# não tem como indexar uma coleção a partir do final, mas sim a maioria dos indexadores usam a noção "desde o início", ou fazem uma expressão "length - i". Introduzimos uma nova expressão de índice que significa "a partir do final". O recurso introduzirá um novo operador de prefixo unário "chapéu". Seu operando único deve ser conversível para System.Int32. Ele será baixado para a chamada de método de fábrica System.Index apropriada.

Aumentamos a gramática para unary_expression com a seguinte forma de sintaxe adicional:

unary_expression
    : '^' unary_expression
    ;

Chamamos isto de índice do operador final. O índice predefinido dos operadores finais é o seguinte:

System.Index operator ^(int fromEnd);

O comportamento deste operador só é definido para valores de entrada maiores ou iguais a zero.

Exemplos:

var array = new int[] { 1, 2, 3, 4, 5 };
var thirdItem = array[2];    // array[2]
var lastItem = array[^1];    // array[new Index(1, fromEnd: true)]

System.Range

C# não tem nenhuma maneira sintática de acessar "intervalos" ou "fatias" de coleções. Normalmente, os usuários são forçados a implementar estruturas complexas para filtrar/operar em fatias de memória, ou recorrer a métodos LINQ como list.Skip(5).Take(2). Com a adição de System.Span<T> e outros tipos semelhantes, torna-se mais importante ter esse tipo de operação suportada em um nível mais profundo na linguagem/tempo de execução, e ter a interface unificada.

A linguagem introduzirá um novo operador de gama x..y. É um operador de infix binário que aceita duas expressões. Cada operando pode ser omitido (veja os exemplos abaixo), e têm que ser convertíveis para System.Index. Ele será reduzido para a chamada de método de fábrica System.Range apropriada.

Substituímos as regras gramaticais do C# para multiplicative_expression pelo seguinte (para introduzir um novo nível de precedência):

range_expression
    : unary_expression
    | range_expression? '..' range_expression?
    ;

multiplicative_expression
    : range_expression
    | multiplicative_expression '*' range_expression
    | multiplicative_expression '/' range_expression
    | multiplicative_expression '%' range_expression
    ;

Todas as formas do operador de intervalo de têm a mesma precedência. Este novo grupo de precedência é menor do que os operadores unários e maior do que os operadores aritméticos multiplicativos .

Chamamos o operador .. de operador de faixa . O operador de intervalo embutido pode ser entendido aproximadamente como correspondendo à invocação de um operador interno deste formulário:

System.Range operator ..(Index start = 0, Index end = ^0);

Exemplos:

var array = new int[] { 1, 2, 3, 4, 5 };
var slice1 = array[2..^3];    // array[new Range(2, new Index(3, fromEnd: true))]
var slice2 = array[..^3];     // array[Range.EndAt(new Index(3, fromEnd: true))]
var slice3 = array[2..];      // array[Range.StartAt(2)]
var slice4 = array[..];       // array[Range.All]

Além disso, System.Index deve ter uma conversão implícita de System.Int32, de modo a evitar a necessidade de sobrecarregar a combinação de inteiros e índices em assinaturas multidimensionais.

Adicionando suporte a índice e intervalo a tipos de biblioteca existentes

Suporte a índice implícito

O idioma fornecerá a um membro do indexador de instância um único parâmetro do tipo Index para tipos que atendam aos seguintes critérios:

  • O tipo é Countable.
  • O tipo tem um indexador de instância acessível que usa um único int como argumento.
  • O tipo não tem um indexador de instância acessível que toma um Index como o primeiro parâmetro. O Index deve ser o único parâmetro ou os parâmetros restantes devem ser opcionais.

Um tipo é Countable se tiver uma propriedade chamada Length ou Count com um método get acessível e um tipo de retorno de int. A linguagem pode fazer uso dessa propriedade para converter uma expressão do tipo Index em um int no ponto da expressão sem a necessidade de usar o tipo Index de todo. Caso Length e Count estejam presentes, Length será preferível. Para simplificar, no futuro, a proposta utilizará o nome Length para representar Count ou Length.

Para esses tipos, a linguagem agirá como se houvesse um membro indexador do formulário T this[Index index] onde T é o tipo de retorno do indexador baseado em int, incluindo quaisquer anotações de estilo ref. O novo membro terá os mesmos membros get e set com acessibilidade correspondente ao indexador int.

O novo indexador será implementado convertendo o argumento do tipo Index em um int e emitindo uma chamada para o indexador baseado em int. Para fins de discussão, vamos usar o exemplo de receiver[expr]. A conversão de expr em int ocorrerá da seguinte forma:

  • Quando o argumento é da forma ^expr2 e o tipo de expr2 é int, será traduzido para receiver.Length - expr2.
  • Caso contrário, será traduzido como expr.GetOffset(receiver.Length).

Independentemente da estratégia de conversão específica, a ordem de avaliação deve ser equivalente à seguinte:

  1. receiver é avaliada;
  2. expr é avaliada;
  3. length é avaliado, se necessário;
  4. O indexador baseado em int é invocado.

Isso permite que os desenvolvedores usem o recurso Index em tipos existentes sem a necessidade de modificação. Por exemplo:

List<char> list = ...;
var value = list[^1];

// Gets translated to
var value = list[list.Count - 1];

As expressões receiver e Length serão derramadas conforme apropriado para garantir que quaisquer efeitos colaterais sejam executados apenas uma vez. Por exemplo:

class Collection {
    private int[] _array = new[] { 1, 2, 3 };

    public int Length {
        get {
            Console.Write("Length ");
            return _array.Length;
        }
    }

    public int this[int index] => _array[index];
}

class SideEffect {
    Collection Get() {
        Console.Write("Get ");
        return new Collection();
    }

    void Use() {
        int i = Get()[^1];
        Console.WriteLine(i);
    }
}

Este código imprimirá "Get Length 3".

Suporte implícito a intervalos

O idioma fornecerá a um membro do indexador de instância um único parâmetro do tipo Range para tipos que atendam aos seguintes critérios:

  • O tipo é Countable.
  • O tipo tem um membro acessível chamado Slice que tem dois parâmetros do tipo int.
  • O tipo não tem um indexador de instância que toma um único Range como o primeiro parâmetro. O Range deve ser o único parâmetro ou os parâmetros restantes devem ser opcionais.

Para esses tipos, o idioma será vinculado como se houvesse um membro indexador do formulário T this[Range range] onde T é o tipo de retorno do método Slice, incluindo quaisquer anotações de estilo ref. O novo membro também terá acessibilidade equivalente a Slice.

Quando o indexador baseado em Range é vinculado em uma expressão chamada receiver, ele será reduzido convertendo a expressão Range em dois valores que são passados para o método Slice. Para fins de discussão, vamos usar o exemplo de receiver[expr].

O primeiro argumento de Slice será obtido ao converter a expressão do intervalo digitada da seguinte maneira:

  • Quando expr é da forma expr1..expr2 (onde expr2 pode ser omitido) e expr1 tem tipo int, então ele será emitido como expr1.
  • Quando expr é da forma ^expr1..expr2 (onde expr2 pode ser omitido), então ele será emitido como receiver.Length - expr1.
  • Quando expr é da forma ..expr2 (onde expr2 pode ser omitido), então ele será emitido como 0.
  • Caso contrário, será emitido como expr.Start.GetOffset(receiver.Length).

Este valor será reutilizado no cálculo do segundo argumento Slice. Ao fazê-lo, será referido como start. O segundo argumento de Slice será obtido convertendo a expressão digitada do intervalo da seguinte maneira:

  • Quando expr é da forma expr1..expr2 (onde expr1 pode ser omitido) e expr2 tem tipo int, então ele será emitido como expr2 - start.
  • Quando expr é da forma expr1..^expr2 (onde expr1 pode ser omitido), então ele será emitido como (receiver.Length - expr2) - start.
  • Quando expr é da forma expr1.. (onde expr1 pode ser omitido), então ele será emitido como receiver.Length - start.
  • Caso contrário, será emitido como expr.End.GetOffset(receiver.Length) - start.

Independentemente da estratégia de conversão específica, a ordem de avaliação deve ser equivalente à seguinte:

  1. receiver é avaliada;
  2. expr é avaliada;
  3. length é avaliado, se necessário;
  4. O método Slice é invocado.

As expressões receiver, expre length serão derramadas conforme apropriado para garantir que quaisquer efeitos colaterais sejam executados apenas uma vez. Por exemplo:

class Collection {
    private int[] _array = new[] { 1, 2, 3 };

    public int Length {
        get {
            Console.Write("Length ");
            return _array.Length;
        }
    }

    public int[] Slice(int start, int length) {
        var slice = new int[length];
        Array.Copy(_array, start, slice, 0, length);
        return slice;
    }
}

class SideEffect {
    Collection Get() {
        Console.Write("Get ");
        return new Collection();
    }

    void Use() {
        var array = Get()[0..2];
        Console.WriteLine(array.Length);
    }
}

Este código imprimirá "Get Length 2".

A língua tratará de forma especial os seguintes tipos conhecidos:

  • string: o método Substring será usado em vez de Slice.
  • array: o método System.Runtime.CompilerServices.RuntimeHelpers.GetSubArray será usado em vez de Slice.

Alternativas

Os novos operadores (^ e ..) são açúcar sintático. A funcionalidade pode ser implementada por chamadas explícitas para System.Index e System.Range métodos de fábrica, mas resultará em muito mais código clichê e a experiência será pouco intuitiva.

Representação da IL

Esses dois operadores serão reduzidos para chamadas regulares de indexador/método, sem alteração nas camadas subsequentes do compilador.

Comportamento de tempo de execução

  • O compilador pode otimizar indexadores para tipos internos, como matrizes e cadeias de caracteres, e reduzir a indexação para os métodos existentes apropriados.
  • System.Index lançará se construído com um valor negativo.
  • ^0 não lança, mas traduz-se no comprimento da coleção/enumerável para o qual é fornecido.
  • Range.All é semanticamente equivalente a 0..^0, e pode ser desconstruída para esses índices.

Considerações

Detetar indexável com base em ICollection

A inspiração para esse comportamento foram os inicializadores de coleção. Usando a estrutura de um tipo para transmitir que ele optou por um recurso. No caso de inicializadores de coleção, os tipos podem optar pelo recurso implementando a interface IEnumerable (não genérica).

Inicialmente, esta proposta exigia que os tipos implementassem ICollection para poderem ser considerados indexáveis. No entanto, isso exigiu uma série de casos especiais:

  • ref struct: estes não podem implementar interfaces, mas tipos como Span<T> são ideais para suporte de índice / intervalo.
  • string: não implementa ICollection e adicionar essa interface tem um grande custo.

Isto significa que, para suportar os principais tipos, já é necessário um invólucro especial. O tratamento especial de string é menos interessante, pois a linguagem faz isso noutras áreas (rebaixamento deforeach, constantes, etc...). O tratamento especial de ref struct é mais preocupante, pois é um tratamento especial para uma classe inteira de tipos. Eles são rotulados como indexáveis se simplesmente tiverem uma propriedade chamada Count com um tipo de retorno de int.

Após consideração, o design foi normalizado para dizer que qualquer tipo que tenha uma propriedade Count / Length com um tipo de retorno de int é indexável. Isso remove todo o invólucro especial, mesmo para string e matrizes.

Detetar apenas a contagem

Detetar nos nomes de propriedade Count ou Length complica um pouco o design. Escolher apenas um para padronizar, no entanto, não é suficiente, pois acaba excluindo um grande número de tipos:

  • Use Length: exclui praticamente todas as coleções em System.Collections e sub-namespaces. Estes tendem a derivar de ICollection e, portanto, preferem Count em detrimento do comprimento.
  • Use Count: exclui string, matrizes, Span<T> e a maioria dos tipos baseados em ref struct

A complicação extra na deteção inicial de tipos indexáveis é superada por sua simplificação em outros aspetos.

Escolha de Slice como nome

O nome Slice foi escolhido porque é o nome padrão tácitamente para operações de corte no .NET. A partir de netcoreapp2.1, todos os tipos de estilo span usam o nome Slice para operações de fatiamento. Antes do netcoreapp2.1 realmente não há exemplos de fatiamento para procurar um exemplo. Tipos como List<T>, ArraySegment<T>, SortedList<T> teriam sido ideais para o fatiamento, mas o conceito não existia quando os tipos foram adicionados.

Assim, Slice sendo o único exemplo, foi escolhido como nome.

Conversão do tipo de destino do índice

Outra maneira de exibir a transformação Index em uma expressão de indexador é como uma conversão de tipo de destino. Em vez de vincular como se houvesse um membro do formulário return_type this[Index], a linguagem atribui uma conversão orientada ao tipo de destino para int.

Este conceito pode ser generalizado para todos os membros que acessam os tipos Countable. Sempre que uma expressão com o tipo Index for usada como argumento para uma invocação de membro da instância e o recetor for Countable, a expressão terá uma conversão de tipo de destino para int. As invocações de membro aplicáveis para esta conversão incluem métodos, indexadores, propriedades, métodos de extensão, etc ... Apenas os construtores são excluídos, pois não têm recetor.

A conversão de tipo de destino será implementada da seguinte forma para qualquer expressão que tenha um tipo de Index. Para fins de discussão, vamos usar o exemplo de receiver[expr]:

  • Quando expr for da forma ^expr2 e o tipo de expr2 for int, será traduzido para receiver.Length - expr2.
  • Caso contrário, será traduzido como expr.GetOffset(receiver.Length).

As expressões receiver e Length serão derramadas conforme apropriado para garantir que quaisquer efeitos colaterais sejam executados apenas uma vez. Por exemplo:

class Collection {
    private int[] _array = new[] { 1, 2, 3 };

    public int Length {
        get {
            Console.Write("Length ");
            return _array.Length;
        }
    }

    public int GetAt(int index) => _array[index];
}

class SideEffect {
    Collection Get() {
        Console.Write("Get ");
        return new Collection();
    }

    void Use() {
        int i = Get().GetAt(^1);
        Console.WriteLine(i);
    }
}

Este código imprimirá "Get Length 3".

Este recurso seria benéfico para qualquer membro que tivesse um parâmetro que representasse um índice. Por exemplo, List<T>.InsertAt. Isso também tem o potencial de confusão, já que a linguagem não pode dar nenhuma orientação sobre se uma expressão deve ou não ser indexada. Tudo o que ele pode fazer é converter qualquer expressão Index em int ao invocar um membro em um tipo Countable.

Restrições:

  • Esta conversão só é aplicável quando a expressão com o tipo Index é diretamente um argumento para o membro. Não se aplicaria a nenhuma expressão aninhada.

Decisões tomadas durante a implementação

  • Todos os membros no padrão devem ser membros da instância
  • Se um método Length for encontrado, mas tiver o tipo de retorno errado, continue procurando pelo Count
  • O indexador usado para o padrão Index deve ter exatamente um parâmetro int
  • O método Slice usado para o padrão Range deve ter exatamente dois parâmetros inteiros
  • Ao procurar os membros do padrão, procuramos definições originais, não membros construídos

Reuniões de design