索引和范围为访问序列中的单个元素或范围提供了简洁的语法。它们不仅提高了代码的可读性,还减少了手动计算索引的错误风险
本文除了讨论System.Index和System.Range,还尝试实现自定义类型支持Index和Range,最后讨论索引和范围的类型支持和性能问题
首先介绍一下System.Index和System.Range的基本概念
索引 | 说明 |
---|---|
System.Index | 表示从集合(如数组、字符串)开始或结束的索引。特别是对于反向索引(从集合末尾开始计数)非常有用。不支持多维数组操作 |
System.Range | 表示索引范围。不支持多维数组操作 |
System.Index
System.Index主要有两个重要的属性:
- 正向索引:从0开始计数
- 反向索引:从集合的末尾开始计数,用
^
表示。例如,^1
表示集合的最后一个元素,^2
表示倒数第二个元素
在数组中使用
int[] numbers = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
// 使用正向索引
int valueAtIndex3 = numbers[3]; // 3
// 使用反向索引
int lastValue = numbers[^1]; // 9
int secondLastValue = numbers[^2]; // 8
Console.WriteLine(valueAtIndex3); // 输出: 3
Console.WriteLine(lastValue); // 输出: 9
Console.WriteLine(secondLastValue); // 输出: 8
在字符串中使用
string text = "Hello, World!";
// 使用正向索引
char charAtIndex7 = text[7]; // 'W'
// 使用反向索引
char lastChar = text[^1]; // '!'
char secondLastChar = text[^2]; // 'd'
Console.WriteLine(charAtIndex7); // 输出: W
Console.WriteLine(lastChar); // 输出: !
Console.WriteLine(secondLastChar); // 输出: d
自定义集合的枚举器和索引器
using System.Collections;
namespace Demo
{
public class CustomCollection : IEnumerable<int>
{
private List<int> data = new List<int>();
public int this[int index]
{
get { return data[index]; }
set { data[index] = value; }
}
// 实现 GetEnumerator 方法
public IEnumerator<int> GetEnumerator()
{
return data.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
// 添加元素的方法
public void Add(int item)
{
data.Add(item);
}
}
internal class Program
{
static void Main(string[] args)
{
var collection = new CustomCollection();
collection.Add(1);
collection.Add(2);
collection.Add(3);
// 使用索引器访问元素
Console.WriteLine(collection[0]); // 输出: 1
Console.WriteLine(collection[1]); // 输出: 2
Console.WriteLine(collection[2]); // 输出: 3
// 使用 foreach 循环遍历集合
foreach (var item in collection)
{
Console.WriteLine(item); // 输出: 1 2 3
}
}
}
}
索引器的多重重载
你可以在一个类中定义多个索引器,以支持不同类型的索引访问方式
using System.Collections;
namespace Demo
{
public class MultiIndexerCollection
{
private List<int> dataList = new List<int>();
// 通过整数索引访问
public int this[int index]
{
get
{
ValidateIndex(index);
return dataList[index];
}
set
{
EnsureCapacity(index);
dataList[index] = value;
}
}
// 通过字符串索引访问
public int this[string index]
{
get
{
if (int.TryParse(index, out int parsedIndex))
{
ValidateIndex(parsedIndex);
return dataList[parsedIndex];
}
throw new ArgumentException("Invalid index format");
}
set
{
if (int.TryParse(index, out int parsedIndex))
{
EnsureCapacity(parsedIndex);
dataList[parsedIndex] = value;
}
else
{
throw new ArgumentException("Invalid index format");
}
}
}
private void ValidateIndex(int index)
{
if (index < 0 || index >= dataList.Count)
{
throw new ArgumentOutOfRangeException(nameof(index), "Index is out of range");
}
}
private void EnsureCapacity(int index)
{
while (index >= dataList.Count)
{
dataList.Add(0); // 初始化为默认值0
}
}
}
internal class Program
{
static void Main(string[] args)
{
var collection = new MultiIndexerCollection();
collection[0] = 10; // 使用整数索引设置值
collection["1"] = 20; // 使用字符串索引设置值
Console.WriteLine(collection[0]); // 输出: 10
Console.WriteLine(collection["1"]); // 输出: 20
try
{
Console.WriteLine(collection[3]); // 尝试访问未设置的索引
}
catch (ArgumentOutOfRangeException e)
{
Console.WriteLine(e.Message); // 输出: Index is out of range
}
try
{
collection["invalid"] = 30; // 尝试使用无效的字符串索引
}
catch (ArgumentException e)
{
Console.WriteLine(e.Message); // 输出: Invalid index format
}
}
}
}
说明:
- 索引验证:ValidateIndex方法用于确保索引在访问时是有效的。然而,在设置值之前,我们首先应该确保列表的容量足够,即EnsureCapacity方法
- 容量确保:EnsureCapacity方法会在索引超过当前列表容量时,动态扩展列表的容量,避免索引超出范围的错误
System.Range
Range结构可以通过两个索引值来表示一个范围,其中每个索引值都可以是正数或负数。负数表示从集合的尾部开始计数
- Range(int start, int end)表示从start到end(不包括end)的范围
- 使用Index结构(例如
^1
表示倒数第一个元素)
使用数组
int[] numbers = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
// 获取从索引 2 到索引 5(不包含)的子数组
int[] subArray1 = numbers[2..5];
// 获取从索引 0 到索引 3(不包含)的子数组
int[] subArray2 = numbers[..3];
// 获取从索引 5 到数组末尾的子数组
int[] subArray3 = numbers[5..];
// 获取数组的最后两个元素
int[] subArray4 = numbers[^2..];
Console.WriteLine(string.Join(", ", subArray1));// { 2, 3, 4 }
Console.WriteLine(string.Join(", ", subArray2));// { 0, 1, 2 }
Console.WriteLine(string.Join(", ", subArray3));// { 5, 6, 7, 8, 9 }
Console.WriteLine(string.Join(", ", subArray4));// { 8, 9 }
使用字符串
string text = "Hello, World!";
// 获取子字符串 "Hello"
string subText1 = text[..5];
// 获取子字符串 "World"
string subText2 = text[7..12];
// 获取子字符串 "World!"
string subText3 = text[^6..];
Console.WriteLine(subText1);// "Hello"
Console.WriteLine(subText2);// "World"
Console.WriteLine(subText3);// "World!"
隐式范围运算符表达式转换
使用范围运算符表达式语法时,编译器会将开始值和结束值隐式转换为Index,并根据这些值创建新的Range实例。 以下代码显示了范围运算符表达式语法的隐式转换示例及其对应的显式替代方法:
Range implicitRange = 3..^5;
Range explicitRange = new(
start: new Index(value: 3, fromEnd: false),
end: new Index(value: 5, fromEnd: true));
if (implicitRange.Equals(explicitRange))
{
// 输出:The implicit range '3..^5' equals the explicit range '3..^5'
Console.WriteLine(
$"The implicit range '{implicitRange}' equals the explicit range '{explicitRange}'");
}
显式地创建Range和Index对象
int[] numbers = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
Index start = 2; // 索引 2
Index end = 5; // 索引 5(不包含)
Range range = new Range(start, end);
int[] subArray = numbers[range];
Console.WriteLine(string.Join(", ", subArray));// { 2, 3, 4 }
对比Skip和Take方法
使用Range可以使子集操作的代码更加简洁直观和易读
与Skip和Take不同,Range和Index是编译时特性,不需要额外的LINQ查询,因此性能更高
int[] numbers = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
Index start = 2; // 索引 2
Index end = 5; // 索引 5(不包含)
Range range = new Range(start, end);
// 方式1
int[] subArray = numbers[range];
// 方式2
var nums = numbers.Skip(2).Take(3).ToArray();
//获取最后三个元素
// 方式1
var lastNums = numbers[^3..];
// 方式2
var lastNumsSkip = numbers.Skip(numbers.Length - 3).ToArray();
Console.WriteLine(string.Join(", ", lastNums));// { 7, 8, 9 }
Console.WriteLine(string.Join(", ", lastNumsSkip));// { 7, 8, 9 }
Console.WriteLine(string.Join(", ", nums));// { 2, 3, 4 }
Console.WriteLine(string.Join(", ", subArray));// { 2, 3, 4 }
在Span和Memory中的应用
Span和Memory提供一种安全且高效的方法来处理内存中的连续数据块。这些类型适用于性能关键的应用程序,尤其是在需要处理大量数据时。Range和Index对它们同样适用
int[] numbers = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
Span<int> span = numbers;
// 获取从索引 2 到索引 5(不包含)的子 Span
Span<int> subSpan1 = span[2..5];
// 获取 Span 的最后两个元素
Span<int> subSpan2 = span[^2..];
Console.WriteLine(string.Join(", ", subSpan1.ToArray()));// { 2, 3, 4 }
Console.WriteLine(string.Join(", ", subSpan2.ToArray()));// { 8, 9 }
int[] array = { 1, 2, 3, 4, 5, 6 };
// 使用 Span 进行内存操作
Span<int> span = array.AsSpan(1, 4);
// 修改 Span 内的数据
span[0] = 10;
span[3] = 40;
// 访问 Span 内的数据
Console.WriteLine(span[0]); // 输出: 10
Console.WriteLine(span[3]); // 输出: 40
// 原数组也会被修改
Console.WriteLine(array[1]); // 输出: 10
Console.WriteLine(array[4]); // 输出: 40
int[] numbers = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
Memory<int> memory = numbers;
// 获取从索引 2 到索引 5(不包含)的子 Memory
Memory<int> subMemory1 = memory[2..5];
// 获取 Memory 的最后两个元素
Memory<int> subMemory2 = memory[^2..];
Console.WriteLine(string.Join(", ", subMemory1.ToArray()));// { 2, 3, 4 }
Console.WriteLine(string.Join(", ", subMemory2.ToArray()));// { 8, 9 }
int[] array = { 1, 2, 3, 4, 5, 6 };
// 使用 Memory 进行内存操作
Memory<int> memory = array.AsMemory(1, 4);
// 创建 Span 进行操作
Span<int> span = memory.Span;
// 修改 Span 内的数据
span[0] = 20;
span[3] = 50;
// 访问 Memory 内的数据
Console.WriteLine(memory.Span[0]); // 输出: 20
Console.WriteLine(memory.Span[3]); // 输出: 50
// 原数组也会被修改
Console.WriteLine(array[1]); // 输出: 20
Console.WriteLine(array[4]); // 输出: 50
// 数组示例
int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
// 使用 Span<T> 示例
Span<int> numberSpan = numbers.AsSpan();
Span<int> subSpan = numberSpan[2..5]; // 引用相同的后备存储,不会分配新的数组
Console.WriteLine(string.Join(", ", subSpan.ToArray())); // 输出: 3, 4, 5
// 使用 Memory<T> 示例
Memory<int> numberMemory = numbers.AsMemory();
Memory<int> subMemory = numberMemory[2..5]; // 引用相同的后备存储,不会分配新的数组
Console.WriteLine(string.Join(", ", subMemory.ToArray())); // 输出: 3, 4, 5
自定义类型支持Range操作
namespace Demo
{
public class MyCollection
{
private int[] data;
public MyCollection(int[] data)
{
this.data = data;
}
public int[] this[Range range]
{
get
{
var (offset, length) = range.GetOffsetAndLength(data.Length);
int[] result = new int[length];
Array.Copy(data, offset, result, 0, length);
return result;
}
}
public int this[Index index]
{
get
{
int actualIndex = index.GetOffset(data.Length);
return data[actualIndex];
}
}
public void PrintRange(Range range)
{
int[] subArray = this[range];
Console.WriteLine(string.Join(", ", subArray));
}
public void PrintIndex(Index index)
{
int value = this[index];
Console.WriteLine(value);
}
}
internal class Program
{
static void Main(string[] args)
{
MyCollection collection = new MyCollection(new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 });
// 获取从索引 2 到索引 5(不包含)的子数组
int[] subArray1 = collection[2..5];
// 获取数组的最后两个元素
int[] subArray2 = collection[^2..];
// 打印特定范围内的元素
collection.PrintRange(2..5); // 输出: 2, 3, 4
// 打印最后一个元素
collection.PrintIndex(^1); // 输出: 9
Console.WriteLine(string.Join(", ", subArray1));// { 2, 3, 4 }
Console.WriteLine(string.Join(", ", subArray2));// { 8, 9 }
}
}
}
索引和范围的类型支持
索引和范围提供清晰、简洁的语法来访问序列中的单个元素或元素的范围。 索引表达式通常返回序列元素的类型。 范围表达式通常返回与源序列相同的序列类型
若任何类型提供带Index或Range参数的索引器,则该类型可分别显式支持索引或范围。 采用单个Range参数的索引器可能会返回不同的序列类型,如System.Span<T>
使用范围运算符的代码的性能取决于序列操作数的类型
范围运算符的时间复杂度取决于序列类型。 例如,如果序列是string
或数组,则结果是输入中指定部分的副本,因此,时间复杂度为
O
(
N
)
O(N)
O(N)(其中
N
N
N是范围的长度)。另一方面,如果它是System.Span<T>或System.Memory<T>,则结果引用相同的后备存储,这意味着没有副本且操作为
O
(
1
)
O(1)
O(1)
除了时间复杂度外,这还会产生额外的分配和副本,从而影响性能。 在性能敏感的代码中,考虑使用Span<T>
或Memory<T>
作为序列类型,因为不会为其分配范围运算符
参考
- How to use indices and ranges in C# 8.0 | InfoWorld
- Range 结构 (System) | Microsoft Learn
- Span<T> 结构 (System) | Microsoft Learn
- Index 结构 (System) | Microsoft Learn
- 使用索引和范围探索数据范围 - C# | Microsoft Learn