范围

注意

本文是特性规范。 此规范是功能的设计文档。 它包括建议的规范变更,以及功能设计和开发过程中所需的信息。 这些文章将持续发布,直至建议的规范变更最终确定并纳入当前的 ECMA 规范。

功能规范与已完成的实现之间可能存在一些差异。 那些差异已记录在恰当的 语文设计会议 (LDM) 说明中

可以在有关规范的文章中了解更多有关将功能规范子块纳入 C# 语言标准的过程。

支持者问题:https://github.com/dotnet/csharplang/issues/185

总结

此功能旨在提供两个新运算符,这些运算符允许构造 System.IndexSystem.Range 对象,并在运行时使用这些对象对集合编制索引/进行切片。

概述

已知类型和成员

若要使用 System.IndexSystem.Range 的新语法形式,可能需要新的已知类型和成员,具体取决于使用哪种语法形式。

若要使用“hat”运算符 (^),需要满足以下条件:

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

若要在数组元素访问中使用 System.Index 类型作为参数,需要以下成员:

int System.Index.GetOffset(int length);

..System.Range 语法需要 System.Range 类型以及下列一个或多个成员:

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

.. 语法允许任意一个、两个或没有参数出现。 无论参数数量如何,Range 构造函数都始终足以使用 Range 语法。 但是,如果存在其他任何成员,并且缺少一个或多个 .. 参数,则可替换相应的成员。

最后,对于类型为 System.Range 的值在数组元素访问表达式中的使用,必须具备以下成员:

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

System.Index

C# 无法从末尾为集合编制索引,但大多数索引器都使用“从头开始”的概念,或执行“length - i”表达式。 我们引入了一个新的索引表达式,表示“从末尾”。 此功能将引入新的一元前缀“hat”运算符。 其单一操作数必须可转换为 System.Int32。 它将被降低到相应的 System.Index 工厂方法调用中。

我们使用以下附加语法形式来增强 unary_expression 的语法:

unary_expression
    : '^' unary_expression
    ;

我们将其称为从末尾开始索引运算符。 预定义的从末尾开始索引运算符如下:

System.Index operator ^(int fromEnd);

此运算符的行为仅为大于或等于零的输入值定义。

示例:

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# 没有通过语法来访问集合的“范围”或“切片”的方法。 通常,用户被迫实现复杂的结构来筛选/操作内存切片,或求助于 LINQ 方法(如 list.Skip(5).Take(2))。 随着 System.Span<T> 和其他类似类型的引入,在语言/运行时层面更深入地支持此类操作,并统一接口,变得更加重要。

该语言将引入新的范围运算符 x..y。 它是一个二元中缀运算符,可接受两个表达式。 任一操作数均可省略(示例如下),并且它们必须可转换为 System.Index。 它将被简化为相应的 System.Range 工厂方法调用中。

我们将 multiplicative_expression 的 C# 语法规则替换为以下内容(以便引入新的优先级别):

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

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

范围运算符的所有形式具有相同的优先级。 该新组的优先级低于一元运算符,高于乘法算术运算符

我们将 .. 运算符称为范围运算符。 可以将内置范围运算符大致理解为对应于以下形式的内置运算符调用:

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

示例:

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]

此外,System.Index 应从 System.Int32 隐式转换,以避免需要在多维签名上重载混合整数和索引。

向现有库类型添加索引和范围支持

隐式索引支持

该语言将为满足以下条件的类型提供一个实例索引器成员,该成员具有 Index 类型的单个参数:

  • 类型为 Countable。
  • 该类型具有可访问的实例索引器,该索引器采用单个 int 作为参数。
  • 该类型没有可访问的实例索引器,该索引器采用 Index 作为第一个参数。 Index 必须是唯一参数,其余参数必须是可选的。

如果一种类型具有名为 的属性,并且该属性拥有可访问的 getter 和返回类型 Length,那么此类型为 int。 该语言可以使用此属性在表达式处将 Index 类型的表达式转换为 int 类型,根本无需使用 Index 类型。 如果同时存在 LengthCount,则首选 Length。 以后为简单起见,建议将使用名称 Length 来表示 CountLength

对于这类类型,语言会将其视为具有一个形式为 T this[Index index] 的索引器成员,其中 T 是基于 int 的索引器的返回类型,并包括任何 ref 样式的注释。 新成员将具有与 get 索引器访问权限匹配的相同 setint 成员。

Index类型的参数转换为int,然后发起对基于int的索引器的调用,以实现新索引器。 让我们以 receiver[expr] 为例进行讨论。 将按如下所示将 expr 转换为 int

  • 当参数的格式为 ^expr2expr2 的类型是 int时,它将被转换为 receiver.Length - expr2
  • 否则,它将转换为 expr.GetOffset(receiver.Length)

无论具体的转换策略如何,评估顺序都应等效于以下顺序:

  1. receiver 已评估;
  2. expr 已评估;
  3. 如果需要,对 length 进行评估;
  4. 调用基于 int 的索引器。

这样,开发人员就可以在现有类型上使用 Index 功能,而无需修改。 例如:

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

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

receiverLength 表达式将进行适当处理,以确保任何副作用仅执行一次。 例如:

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

此代码将打印“获取长度 3”。

隐式范围支持

该语言将为满足以下条件的类型提供一个实例索引器成员,该成员具有 Range 类型的单个参数:

  • 类型为 Countable。
  • 该类型具有一个名为 Slice 的可访问成员,该成员具有两个 int 类型的参数。
  • 该类型没有实例索引器,该索引器采用单个 Range 作为初始参数。 Range 必须是唯一参数,其余参数必须是可选的。

对于此类类型,该语言的绑定方式如同存在一个形式为 T this[Range range] 的索引器成员,其中 TSlice 方法的返回类型,且包含任何 ref 样式的注释。 新成员还将拥有与 Slice 一样的可访问性功能。

当基于 Range 的索引器绑定到名为 receiver 的表达式时,它将通过将 Range 表达式转换为两个值来降低该索引器,然后传递给 Slice 方法。 让我们以 receiver[expr] 为例进行讨论。

Slice 的第一个参数将通过以下方式转换范围类型化表达式来获取:

  • expr 的形式为 expr1..expr2(其中 expr2 可以省略),并且 expr1 的类型为 int,则它将作为 expr1 发出。
  • expr 的形式为 ^expr1..expr2(其中 expr2 可以省略),则它将作为 receiver.Length - expr1 发出。
  • expr 的形式为 ..expr2(其中 expr2 可以省略),则它将作为 0 发出。
  • 否则,它将被作为 expr.Start.GetOffset(receiver.Length) 发出。

此值将在计算第二个 Slice 参数时重复使用。 这样做时,将其称为 startSlice 的第二个参数将通过以下方式转换范围类型化表达式来获取:

  • expr 的形式为 expr1..expr2(其中 expr1 可以省略),并且 expr2 的类型为 int,则它将作为 expr2 - start 发出。
  • expr 的形式为 expr1..^expr2(其中 expr1 可以省略),则它将作为 (receiver.Length - expr2) - start 发出。
  • expr 的形式为 expr1..(其中 expr1 可以省略),则它将作为 receiver.Length - start 发出。
  • 否则,它将被作为 expr.End.GetOffset(receiver.Length) - start 发出。

无论具体的转换策略如何,评估顺序都应等效于以下顺序:

  1. receiver 已评估;
  2. expr 已评估;
  3. 如果需要,对 length 进行评估;
  4. 调用 Slice 方法。

receiverexprlength 表达式将进行适当处理,以确保任何副作用仅执行一次。 例如:

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

此代码将打印“获取长度 2”。

该语言将对以下已知类型进行特殊处理:

  • string:将使用方法 Substring 而不是 Slice
  • array:将使用方法 System.Runtime.CompilerServices.RuntimeHelpers.GetSubArray 而不是 Slice

替代方案

新运算符(^..)属于语法糖。 该功能可以通过显式调用 System.IndexSystem.Range 工厂方法来实现,但这将导致更多的样板代码,且体验将不直观。

IL 表示形式

这两个运算符将被简化为常规的索引器/方法调用,在后续编译器层中不会有变化。

运行时行为

  • 编译器可以针对内置类型(如数组和字符串)优化索引器,并将索引编制简化为适当的现有方法。
  • 如果使用负值构造,System.Index 将会引发。
  • ^0 不会引发,而是转换为提供给它的集合/可枚举对象的长度。
  • Range.All 在语义上等同于 0..^0,并且可以解构为这些索引。

需要考虑的事项

根据 ICollection 检测可索引对象

此行为的灵感来源于“集合初始化器”(collection initializers)。 使用类型的结构来传达它已选择加入功能。 对于集合初始化器类型,可以通过实现接口 IEnumerable(非泛型)来选择启用该功能。

此建议最初要求类型实现 ICollection 才能被视为可索引。 不过,这需要进行多种特殊处理:

  • ref struct:这些类型无法实现接口,但 Span<T> 等类型非常适合支持索引/范围。
  • string:不实现 ICollection,添加该接口的开销很大。

这意味着,为了支持关键类型,已经需要特殊大小写处理。 string 的特殊处理不太有趣,因为语言在其他领域(foreach 降序, 常量等)也这样处理。ref struct 的特殊处理更令人担心,因为它对整个类型类别进行特殊处理。 如果它们只有一个名为 Count 且返回类型为 int 的属性,就会被标记为可索引类型。

经过考虑,对设计进行了标准化,规定任何返回类型为 intCount / Length 属性的类型都是可索引的。 这会消除所有特殊处理,即使对于 string 和数组也是如此。

仅检测计数

检测属性名称 CountLength 确实稍微增加了设计的复杂性。 仅选取一种进行标准化是不够的,因为这最终会排除大量类型:

  • 使用 Length:几乎排除 System.Collections 和子命名空间中的每个集合。 这些倾向于派生自 ICollection,因此首选 Count 而不是长度。
  • 使用 Count:排除 string、数组、Span<T> 和大多数基于 ref struct 的类型

在初次检测可索引类型时增加的复杂性,相比在其他方面带来的简化效果,显得微不足道。

选择“切片”作为名称

选择了名称 Slice,因为它是 .NET 中切片样式操作事实上的标准名称。 从 netcoreapp2.1 开始,所有跨度样式类型都使用名称 Slice 进行切片操作。 在 netcoreapp2.1 之前,实际上没有可以作为参考的切片示例。 List<T>ArraySegment<T>SortedList<T> 等类型非常适合切片,但在添加类型时这一概念并不存在。

因此,鉴于 Slice 是唯一的示例,它被选作名称。

索引目标类型转换

在索引器表达式中查看 Index 转换的另一种方法是作为目标类型转换。 语言将目标类型转换分配给 return_type this[Index],而不是像表单 int 中有成员那样进行绑定。

此概念可以通用化为 Countable 类型上的所有成员访问。 每当一个类型为 Index 的表达式用作实例成员调用的参数,并且接收方是一个 Countable 对象时,该表达式都会将目标类型转换为 int。 适用于此转换的成员调用包括方法、索引器、属性、扩展方法等 ... 只有构造函数被排除在外,因为它们没有接收器。

对于任何类型为 Index 的表达式,目标类型转换将按如下方式实现。 让我们以 receiver[expr] 为例进行讨论:

  • expr 的形式为 ^expr2expr2 的类型为 int 时,它将转换为 receiver.Length - expr2
  • 否则,它将转换为 expr.GetOffset(receiver.Length)

receiverLength 表达式将进行适当处理,以确保任何副作用仅执行一次。 例如:

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

此代码将打印“获取长度 3”。

此功能对具有表示索引的参数的任何成员都很有用。 例如,List<T>.InsertAt。 这也有可能引起混淆,因为语言不能提供任何关于表达式是否用于编制索引的指导。 它所能做的就是在调用 Countable 类型的成员时将任何 Index 表达式转换为 int

限制:

  • 仅当类型为 Index 的表达式直接是成员的参数时,此转换才适用。 它不适用于任何嵌套表达式。

实施过程中做出的决定

  • 模式中的所有成员都必须是实例成员
  • 如果找到的 Length 方法的返回类型错误,请继续查找 Count
  • 用于索引模式的索引器必须恰好只有一个 int 参数
  • 用于 Range 模式的 Slice 方法必须正好具有两个 int 参数
  • 寻找模式成员时,我们寻找的是原始定义,而不是构造出的成员。

设计会议