Compartir vía


Rangos

Nota

Este artículo es una especificación de características. La especificación actúa como documento de diseño de la característica. Incluye cambios de especificación propuestos, junto con la información necesaria durante el diseño y el desarrollo de la característica. Estos artículos se publican hasta que se finalizan los cambios de especificación propuestos e se incorporan en la especificación ECMA actual.

Puede haber algunas discrepancias entre la especificación de características y la implementación completada. Esas diferencias se recogen en las notas de la reunión de diseño de lenguaje (LDM) correspondientes.

Puede obtener más información sobre el proceso de adopción de especificaciones de características en el estándar del lenguaje C# en el artículo sobre las especificaciones de .

Resumen

Esta característica consiste en entregar dos nuevos operadores que permiten construir objetos System.Index y System.Range, y usarlos para indexar o segmentar colecciones en tiempo de ejecución.

Visión general

Tipos y miembros conocidos

Para usar los nuevos formularios sintácticos para System.Index y System.Range, pueden ser necesarios nuevos tipos y miembros conocidos, dependiendo de qué formas sintácticas se usen.

Para usar el operador "hat" (^), se requiere lo siguiente:

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

Para usar el tipo System.Index como argumento en un acceso de elemento de matriz, se requiere el siguiente miembro:

int System.Index.GetOffset(int length);

La sintaxis .. para System.Range requerirá el tipo System.Range, así como uno o varios de los miembros siguientes:

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

La sintaxis .. permite que uno, ambos o ninguno de sus argumentos esté ausente. Independientemente del número de argumentos, el constructor Range siempre es suficiente para usar la sintaxis Range. Sin embargo, si cualquiera de los demás miembros está presente y faltan uno o varios de los argumentos de .., el miembro adecuado podría ser sustituido.

Por último, para que un valor de tipo System.Range se use en una expresión de acceso de elemento de matriz, el siguiente miembro debe estar presente:

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

System.Index

C# no tiene forma de indexar una colección desde el final, sino que la mayoría de los indizadores usan la noción "from start" o realizan una expresión "length - i". Presentamos una nueva expresión Index que significa "desde el final". La característica introducirá un nuevo operador unario de prefijo "hat". Su operando único debe ser convertible a System.Int32. Será dirigida a la llamada adecuada del método de fábrica System.Index.

Aumentamos la gramática para unary_expression con la siguiente forma de sintaxis adicional:

unary_expression
    : '^' unary_expression
    ;

Lo llamamos operador de índice desde el final. Los operadores de índice desde el final predefinidos son los siguientes:

System.Index operator ^(int fromEnd);

El comportamiento de este operador solo se define para los valores de entrada mayores o iguales que cero.

Ejemplos:

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# no tiene ninguna manera sintáctica de acceder a "intervalos" o "segmentos" de colecciones. Normalmente, los usuarios se ven obligados a implementar estructuras complejas para filtrar o operar en segmentos de memoria, o recurrir a métodos LINQ como list.Skip(5).Take(2). Con la adición de System.Span<T> y otros tipos similares, es más importante tener este tipo de operación compatible con un nivel más profundo en el lenguaje o tiempo de ejecución, y tener la interfaz unificada.

El lenguaje introducirá un nuevo operador de intervalo x..y. Es un operador de infijo binario que acepta dos expresiones. Se puede omitir cualquiera de los operandos (ejemplos a continuación) y deben ser convertibles a System.Index. Será dirigida a la llamada adecuada del método de fábrica System.Range.

Reemplazamos las reglas gramaticales de C# para multiplicative_expression por las siguientes (para introducir un nuevo nivel de precedencia):

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 las formas del operador de intervalo tienen la misma prioridad. Este nuevo grupo de precedencia es inferior que los operadores unarios y superior a los operadores aritméticos multiplicativos .

Denominamos al operador .. como el operador de rango . El operador de intervalo integrado se puede entender aproximadamente para corresponder a la invocación de un operador integrado de este formato:

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

Ejemplos:

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]

Además, System.Index debe tener una conversión implícita de System.Int32con el fin de evitar la necesidad de sobrecargar al mezclar enteros e índices sobre firmas multidimensionales.

Adición de compatibilidad con índices y intervalos a los tipos de biblioteca existentes

Compatibilidad con índices implícitos

El lenguaje proporcionará un miembro de indexador de instancia con un único parámetro de tipo Index para los tipos que cumplen los siguientes criterios:

  • El tipo es Countable.
  • El tipo tiene un indexador de instancia accesible que toma un único int como argumento.
  • El tipo no tiene un indexador de instancia accesible que toma un Index como primer parámetro. El Index debe ser el único parámetro o los parámetros restantes deben ser opcionales.

Un tipo es Countable si tiene una propiedad denominada Length o Count con un captador accesible y un tipo de valor devuelto de int. El lenguaje puede hacer uso de esta propiedad para convertir una expresión de tipo Index en un int en el punto de la expresión sin necesidad de usar el tipo Index en absoluto. En caso de que Length y Count estén presentes, se prefiere Length. Para simplificar en el futuro, la propuesta usará el nombre Length para representar Count o Length.

Para estos tipos, el lenguaje actuará como si hubiera un miembro de indexador con formato T this[Index index] donde T es el tipo de valor devuelto del indexador basado en int, incluidas las anotaciones de estilo ref. El nuevo miembro tendrá los mismos get y set miembros con la misma accesibilidad que el indexador int.

El nuevo indexador se implementará convirtiendo el argumento de tipo Index en un int y emitiendo una llamada al indexador basado en int. Para fines de discusión, vamos a usar el ejemplo de receiver[expr]. La conversión de expr a int se producirá de la siguiente manera:

  • Cuando el argumento tiene el formato ^expr2 y el tipo de expr2 es int, se traducirá a receiver.Length - expr2.
  • De lo contrario, se traducirá como expr.GetOffset(receiver.Length).

Independientemente de la estrategia de conversión específica, el orden de evaluación debe ser equivalente a lo siguiente:

  1. receiver se evalúa;
  2. expr se evalúa;
  3. length se evaluará, si es necesario;
  4. se invoca el indexador basado en int.

Esto permite a los desarrolladores usar la característica Index en tipos existentes sin necesidad de modificación. Por ejemplo:

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

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

Las expresiones receiver y Length se guardarán en memoria según sea necesario para garantizar que los efectos secundarios solo se ejecuten una vez. Por ejemplo:

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".

Compatibilidad con intervalos implícitos

El lenguaje proporcionará un miembro de indexador de instancia con un único parámetro de tipo Range para los tipos que cumplen los siguientes criterios:

  • El tipo es Countable.
  • El tipo tiene un miembro accesible denominado Slice que tiene dos parámetros de tipo int.
  • El tipo no tiene un indexador de instancia que toma un único Range como primer parámetro. El Range debe ser el único parámetro o los parámetros restantes deben ser opcionales.

Para estos tipos, el lenguaje enlazará como si hubiera un miembro de indexador con formato T this[Range range] donde T es el tipo de valor devuelto del método Slice, incluidas las anotaciones de estilo ref. El nuevo miembro también tendrá accesibilidad equivalente con Slice.

Cuando el indexador basado en Range está enlazado en una expresión denominada receiver, se reducirá convirtiendo la expresión de Range en dos valores que luego se pasan al método Slice. Para fines de discusión, vamos a usar el ejemplo de receiver[expr].

El primer argumento de Slice se obtendrá convirtiendo la expresión con tipo de intervalo de la siguiente manera:

  • Cuando expr tiene el formato expr1..expr2 (donde se puede omitir expr2) y expr1 tiene el tipo int, se emitirá como expr1.
  • Cuando expr es del formulario ^expr1..expr2 (donde se puede omitir expr2), se emitirá como receiver.Length - expr1.
  • Cuando expr es del formulario ..expr2 (donde se puede omitir expr2), se emitirá como 0.
  • De lo contrario, se emitirá como expr.Start.GetOffset(receiver.Length).

Este valor se volverá a usar en el cálculo del segundo argumento Slice. Al hacerlo, se denominará start. El segundo argumento de Slice se obtendrá convirtiendo la expresión con tipo de intervalo de la siguiente manera:

  • Cuando expr tiene el formato expr1..expr2 (donde se puede omitir expr1) y expr2 tiene el tipo int, se emitirá como expr2 - start.
  • Cuando expr es del formulario expr1..^expr2 (donde se puede omitir expr1), se emitirá como (receiver.Length - expr2) - start.
  • Cuando expr es del formulario expr1.. (donde se puede omitir expr1), se emitirá como receiver.Length - start.
  • De lo contrario, se emitirá como expr.End.GetOffset(receiver.Length) - start.

Independientemente de la estrategia de conversión específica, el orden de evaluación debe ser equivalente a lo siguiente:

  1. receiver se evalúa;
  2. expr se evalúa;
  3. length se evaluará, si es necesario;
  4. se invoca el método Slice.

Las expresiones receiver, expr y length se guardarán en memoria según sea necesario para garantizar que los efectos secundarios solo se ejecuten una vez. Por ejemplo:

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".

El idioma tratará específicamente los siguientes tipos conocidos:

  • string: el método Substring se usará en lugar de Slice.
  • array: el método System.Runtime.CompilerServices.RuntimeHelpers.GetSubArray se usará en lugar de Slice.

Alternativas

Los nuevos operadores (^ y ..) son azúcar sintáctica. La funcionalidad se puede implementar mediante llamadas explícitas a los métodos de fábrica System.Index y System.Range, pero dará lugar a un código mucho más repetitivo, y la experiencia será poco intuitiva.

Representación de IL

Estos dos operadores se reducirán a las llamadas normales de indexador o método, sin cambios en las capas posteriores del compilador.

Comportamiento en tiempo de ejecución

  • El compilador puede optimizar los indexadores para tipos integrados, como matrices y cadenas, y reducir la indexación a los métodos existentes adecuados.
  • System.Index dará un error si se construye con un valor negativo.
  • ^0 no se inicia, pero se traduce a la longitud de la colección o enumerable a la que se proporciona.
  • Range.All es semánticamente equivalente a 0..^0y se puede descontruir en estos índices.

Consideraciones

Detectar indexable basado en ICollection

La inspiración de este comportamiento fueron los inicializadores de colección. Usar la estructura de un tipo para transmitir que ha optado por una característica. En el caso de los inicializadores de colección, los tipos pueden optar por la característica implementando la interfaz IEnumerable (no genérica).

Esta propuesta requería inicialmente que los tipos implementaran ICollection para ser considerados Indexable. Sin embargo, eso requería una serie de casos especiales:

  • ref struct: Aún no pueden implementar interfaces; sin embargo, tipos como Span<T> son ideales para la compatibilidad con índices o intervalos.
  • string: no implementa ICollection y agregar esa interfaz tiene un gran costo.

Esto significa que para admitir tipos clave ya es necesario un caso especial. El manejo especial de string es menos interesante, ya que el lenguaje lo hace en otras áreas (conversión a minúsculas deforeach, constantes, etc.). El manejo especial de ref struct es más preocupante, ya que es un manejo especial de toda una clase de tipos. Se etiquetan como Indexable si simplemente tienen una propiedad denominada Count con un tipo de valor devuelto de int.

Después de considerar el diseño se normalizó para decir que cualquier tipo que tenga una propiedad Count / Length con un tipo de valor devuelto de int es Indexable. Esto elimina todo el tratamiento especial, incluso para string y matrices.

Detección de recuento justo

La detección en los nombres de propiedad Count o Length sí complica un poco el diseño. Elegir solo uno para estandarizar aunque no es suficiente, ya que termina excluyendo un gran número de tipos:

  • Usar Length: prácticamente excluye todas las colecciones en System.Collections y sus subespacios de nombres. Los que tienden a derivar de ICollection y, por tanto, prefieren Count sobre la longitud.
  • Usar Count: excluye string, matrices, Span<T> y la mayoría de los tipos basados en ref struct

La complicación adicional de la detección inicial de tipos indexables es superada por su simplificación en otros aspectos.

Elección de Slice como nombre

El nombre Slice fue elegido ya que es el nombre estándar de facto para las operaciones de estilo de corte en .NET. A partir de netcoreapp2.1, todos los tipos de estilo de 'span' usan el nombre Slice para las operaciones de segmentación. Antes de netcoreapp2.1, realmente no hay ejemplos de segmentación para buscar un ejemplo. Los tipos como List<T>, ArraySegment<T>, SortedList<T> habrían sido ideales para la segmentación, pero el concepto no existía cuando se agregaron tipos.

Por lo tanto, siendo Slice el único ejemplo, se eligió como nombre.

Conversión de tipos de destino de índice

Otra manera de ver la transformación Index en una expresión de indexador es como una conversión de tipo de destino. En lugar de enlazar como si hubiera un miembro con el formato return_type this[Index], el idioma asigna en su lugar una conversión con tipo de destino a int.

Este concepto se podría generalizar a todos los accesos de miembros en tipos contables. Cada vez que una expresión con tipo Index se usa como argumento para una invocación de miembro de instancia y el receptor es Countable, la expresión tendrá una conversión de tipo de destino en int. Las invocaciones de miembro aplicables a esta conversión incluyen métodos, indexadores, propiedades, métodos de extensión, etc. Solo se excluyen los constructores, ya que no tienen receptor.

La conversión de tipos de destino se implementará de la siguiente manera para cualquier expresión que tenga un tipo de Index. Con fines de discusión se puede usar el ejemplo de receiver[expr]:

  • Cuando expr es de la forma ^expr2 y el tipo de expr2 es int, se traducirá a receiver.Length - expr2.
  • De lo contrario, se traducirá como expr.GetOffset(receiver.Length).

Las expresiones receiver y Length se guardarán en memoria según sea necesario para garantizar que los efectos secundarios solo se ejecuten una vez. Por ejemplo:

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á "Longitud obtenida: 3".

Esta característica sería beneficiosa para cualquier miembro que tuviera un parámetro que representase un índice. Por ejemplo, List<T>.InsertAt. Esto también tiene la posibilidad de confusión, ya que el lenguaje no puede proporcionar instrucciones sobre si una expresión está pensada o no para la indexación. Todo lo que puede hacer es convertir cualquier expresión de Index en int al invocar un miembro en un tipo countable.

Restricciones:

  • Esta conversión solo es aplicable cuando la expresión con tipo Index es directamente un argumento para el miembro. No se aplicaría a ninguna expresión anidada.

Decisiones tomadas durante la implementación

  • Todos los miembros del patrón deben ser miembros de instancia
  • Si se encuentra un método Length, pero tiene el tipo de valor devuelto incorrecto, continúe buscando Count.
  • El indexador usado para el patrón Index debe tener exactamente un parámetro int.
  • El método Slice usado para el patrón Range debe tener exactamente dos parámetros int.
  • Al buscar los miembros de patrón, buscamos definiciones originales, no miembros construidos

Reuniones de diseño