Compartilhar via


Intervalos

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 .

Problema do especialista: https://github.com/dotnet/csharplang/issues/185

Resumo

Este recurso introduz dois novos operadores que permitem construir objetos System.Index e System.Range e usá-los para indexar/fatiar coleções em runtime.

Visão geral

Tipos e membros bem conhecidos

Para usar os novos formatos sintáticos para System.Index e System.Range, podem ser necessários novos tipos e membros bem conhecidos, dependendo dos formatos sintáticos a serem usados.

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

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

Para usar o tipo System.Index como argumento em um acesso de elemento de matriz, o seguinte membro é obrigatório:

int System.Index.GetOffset(int length);

A sintaxe .. para System.Range exigirá o tipo System.Range, além de 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 de seus argumentos esteja ausente. Independentemente do número de argumentos, o construtor Range é sempre suficiente para usar a sintaxe Range. No entanto, se um dos outros membros estiver presente e um ou mais dos argumentos .. estiverem ausentes, o membro apropriado poderá ser substituído.

Por fim, para que um valor do tipo System.Range seja usado em uma expressão de acesso de elemento 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 a maioria dos indexadores usam a noção "desde o início" ou fazem uma expressão "length - i". Apresentamos uma nova expressão Index que significa "a partir do final". O recurso introduz um novo operador de prefixo unário chamado "hat". Seu único operando deve ser conversível para System.Int32. Ele será reduzido para a chamada de método de fábrica System.Index apropriada.

Aumentamos a gramática para unary_expression com o seguinte formulário de sintaxe adicional:

unary_expression
    : '^' unary_expression
    ;

Damos a ele o nome de operador de índice a partir do final. Os operadores predefinidos de índice a partir do final são os seguintes:

System.Index operator ^(int fromEnd);

O comportamento desse 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 uma 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 recorrem 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 com suporte em um nível mais profundo na linguagem/no runtime e ter a interface unificada.

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

Substituímos as regras da linguagem 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 têm a mesma precedência. Esse novo grupo de precedência é inferior aos operadores unários e superior aos operadores aritméticos de multiplicação.

Chamamos o operador .. de operador de intervalo. O operador de intervalo embutido pode ser aproximadamente entendido como correspondente à invocação de um operador embutido desta forma:

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 para evitar a necessidade de sobrecarregar a combinação de inteiros e índices em assinaturas multidimensionais.

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

Suporte para índice implícito

A linguagem fornecerá um membro indexador de instância com um único parâmetro do tipo Index para tipos que atendem aos seguintes critérios:

  • O tipo é Contável.
  • 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 usa um Index como primeiro parâmetro. Index deve ser o único parâmetro ou os parâmetros restantes devem ser opcionais.

Um tipo será Contável se tiver uma propriedade nomeada Length ou Count com um getter acessível e um tipo de retorno int. A linguagem pode usar essa 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. Caso Length e Count estejam presentes, Length será preferencial. Para simplificar, daqui para frente a proposta usará o nome Length para representar Count ou Length.

Para esses tipos, a linguagem se comportará como se existisse um membro indexador na forma 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 da mesma maneira que o indexador int.

O novo indexador será implementado convertendo o argumento do tipo Index em 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 para int ocorrerá da seguinte maneira:

  • Quando o argumento estiver na forma ^expr2 e o tipo de expr2 for int, ele será convertido em receiver.Length - expr2.
  • Do contrário, será convertido como expr.GetOffset(receiver.Length).

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

  1. receiver é avaliado;
  2. expr é avaliado;
  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 fazer modificações. 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 despejadas conforme apropriado para 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);
    }
}

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

Suporte a intervalos implícitos

A linguagem fornecerá um membro indexador de instância com um único parâmetro do tipo Range para tipos que atendem aos seguintes critérios:

  • O tipo é Contável.
  • 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 usa um único Range como primeiro parâmetro. Range deve ser o único parâmetro ou os parâmetros restantes devem ser opcionais.

Para esses tipos, a linguagem será vinculada como se houvesse um membro indexador na forma de T this[Range range], em que T é o tipo de retorno do método Slice, incluindo qualquer anotação de estilo ref. O novo membro também terá acessibilidade semelhante à de Slice.

Quando o indexador baseado em Range estiver associado a uma expressão chamada receiver, será reduzido convertendo a expressão Range em dois valores que, então, serã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 de tipo de intervalo da seguinte maneira:

  • Quando expr for da forma expr1..expr2 (em que expr2 pode ser omitido) e expr1 tiver o tipo int, ele será emitido como expr1.
  • Quando expr estiver na forma ^expr1..expr2 (em que expr2 pode ser omitido), será emitido como receiver.Length - expr1.
  • Quando expr estiver na forma ..expr2 (em que expr2 pode ser omitido), será emitido como 0.
  • Do contrário, ele será emitido como expr.Start.GetOffset(receiver.Length).

Esse valor será novamente utilizado no cálculo do segundo argumento Slice. Ao fazer isso, ele será referido como start. O segundo argumento de Slice será obtido ao converter a expressão de tipo de intervalo da seguinte maneira:

  • Quando expr for da forma expr1..expr2 (em que expr1 pode ser omitido) e expr2 tiver o tipo int, ele será emitido como expr2 - start.
  • Quando expr estiver na forma expr1..^expr2 (em que expr1 pode ser omitido), será emitido como (receiver.Length - expr2) - start.
  • Quando expr estiver na forma expr1.. (em que expr1 pode ser omitido), será emitido como receiver.Length - start.
  • Do contrário, ele 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 ao seguinte:

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

As expressões receiver, expr e length serão despejadas conforme apropriado para 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);
    }
}

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

A linguagem diferenciará os seguintes tipos conhecidos:

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

Alternativas

Os novos operadores (^ e ..) são simplificações sintáticas. A funcionalidade pode ser implementada por chamadas explícitas para métodos de fábrica System.Index e System.Range, mas resultará em uma quantidade bem maior de código clichê e a experiência não será intuitiva.

Representação IL

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

Comportamento em tempo de execução

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

Considerações

Detecção de 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 indicar que ele ativou um recurso. No caso de inicializadores de coleção, os tipos podem optar pelo recurso implementando a interface IEnumerable (não genérica).

Inicialmente, essa proposta exigia que os tipos implementassem ICollection para se qualificarem como indexáveis. Mas para isso eram necessários inúmeros casos especiais:

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

Isso significa que é necessário já lidar com casos especiais para tipos de chaves. O tratamento especial de string é menos interessante, pois a linguagem faz isso em outras áreas (redução de foreach, constantes etc.). O tratamento especial de ref struct é mais preocupante, pois lida de maneira especial com uma classe inteira de tipos. Eles serão rotulados como Indexáveis se tiverem simplesmente uma propriedade chamada Count com um tipo de retorno de int.

Após consideração, o design foi normalizado para informar que qualquer tipo que tem uma propriedade Count / Length com um tipo de retorno de int é indexável. Isso remove toda a capitalização especial, mesmo para string e matrizes.

Detectar contagem justa

Detectar nos nomes de propriedades Count ou Length complica um pouco o design. No entanto, escolher apenas um para padronizar não é suficiente, pois acaba excluindo muitos tipos:

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

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

Escolha de "Slice" como nome

O nome Slice foi escolhido por ser o nome padrão de fato para operações de estilo de fatia no .NET. A partir de netcoreapp2.1, todos os tipos de estruturas span usam o nome Slice para operações de fatiamento. Antes do netcoreapp2.1, realmente não há exemplos de fatiamento disponíveis. Tipos como List<T>, ArraySegment<T>, SortedList<T> teriam sido ideais para fatiamento, mas o conceito não existia quando os tipos foram adicionados.

Então, como Slice era o único exemplo, ele foi escolhido como o nome.

Conversão de tipo de destino de índice

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

Esse conceito pode ser generalizado para todo o acesso de membro em tipos Contáveis. Sempre que uma expressão com tipo Index for usada como argumento para uma invocação de membro de instância e o receptor for Contável, a expressão terá uma conversão de tipo de destino para int. As invocações de membros aplicáveis a essa conversão incluem métodos, indexadores, propriedades, métodos de extensão, etc... Somente os construtores são excluídos porque não possuem um receptor.

A conversão de tipo de destino será implementada conforme a seguir para qualquer expressão que tem um tipo de Index. Para fins de discussão, vamos usar o exemplo de receiver[expr]:

  • Quando expr é do formato ^expr2 e o tipo expr2 é int, será traduzido para receiver.Length - expr2.
  • Do contrário, será convertido como expr.GetOffset(receiver.Length).

As expressões receiver e Length serão despejadas conforme apropriado para 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);
    }
}

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

Esse recurso seria benéfico para qualquer membro que tivesse um parâmetro que representasse um índice. Por exemplo, List<T>.InsertAt. Ele também tem o potencial de confundir, pois a linguagem não pode orientar sobre se uma expressão deve ou não ser usada para indexação. Tudo que ele pode fazer é converter qualquer expressão Index em int ao invocar um membro em um tipo contável.

Restrições:

  • Essa conversão só é aplicável quando a expressão com o tipo Index é diretamente um argumento para o membro. Isso não se aplica a expressões aninhadas.

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 por 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 do tipo int.
  • Ao procurar os membros do padrão, procuramos definições originais, não membros construídos

Reuniões de design