在 C# 8.0 中引入的索引(Index)和范围(Range)特性,为集合元素的访问提供了更简洁、直观的语法。无论是数组、列表还是字符串,这些特性都能大幅简化获取元素或子序列的代码,使开发者能够更专注于业务逻辑而非边界计算。本文将全面解析索引和范围的工作原理、使用方法及实战技巧,帮助你彻底掌握这一现代 C# 特性。
传统上,C# 通过从零开始的整数下标访问集合元素,如array[0]
表示第一个元素。索引特性则引入了两种新的索引方式:从开头计数的索引和从末尾计数的索引,极大地增强了集合访问的灵活性。
^n
形式(反向索引)对于长度为Length
的集合,^n
等价于Length - n
,这一转换由编译器自动完成:
int[] numbers = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
// 正向索引(传统方式)
Console.WriteLine(numbers[0]); // 0(第一个元素)
Console.WriteLine(numbers[3]); // 3(第四个元素)
// 反向索引(新特性)
Console.WriteLine(numbers[^1]); // 9(最后一个元素,等价于numbers[9])
Console.WriteLine(numbers[^4]); // 6(倒数第四个元素,等价于numbers[6])
需要注意的是,反向索引^0
表示集合末尾之后的位置(等同于numbers.Length
),这在范围操作中非常有用,但直接访问会抛出IndexOutOfRangeException
:
// 以下代码会抛出异常
// Console.WriteLine(numbers[^0]); // 错误:索引超出范围
除了通过^
运算符创建索引,还可以直接使用Index
结构的静态方法:
// 创建正向索引(从开头计数)
Index index1 = Index.FromStart(2);
// 创建反向索引(从末尾计数)
Index index2 = Index.FromEnd(3);
int[] arr = { 10, 20, 30, 40, 50 };
Console.WriteLine(arr[index1]); // 30(等同于arr[2])
Console.WriteLine(arr[index2]); // 30(等同于arr[^3],即arr[2])
Index
结构还提供了IsFromEnd
属性,用于判断索引是正向还是反向:
Console.WriteLine(index1.IsFromEnd); // False
Console.WriteLine(index2.IsFromEnd); // True
范围(Range)特性允许通过指定起始和结束位置来获取集合的子序列,替代了传统的Substring
、Array.Copy
等方法,使代码更简洁易读。
范围由两个索引组成,使用..
运算符分隔,基本语法为start..end
,表示包含start
索引对应的元素,不包含end
索引对应的元素(左闭右开区间)。
..
(等价于0..^0
)表示整个集合start..
(从 start 到末尾)或..end
(从开头到 end)int[] numbers = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
// 获取索引1到4之间的元素(包含1,不包含4)
int[] slice1 = numbers[1..4]; // 结果:{1, 2, 3}
// 获取从开头到索引3的元素
int[] slice2 = numbers[..3]; // 结果:{0, 1, 2}
// 获取从索引6到末尾的元素
int[] slice3 = numbers[6..]; // 结果:{6, 7, 8, 9}
// 获取从倒数第4个到倒数第1个的元素
int[] slice4 = numbers[^4..^1]; // 结果:{6, 7, 8}
// 获取整个数组
int[] slice5 = numbers[..]; // 结果:{0,1,2,3,4,5,6,7,8,9}
范围操作的结果是原集合的切片而非副本(对于数组等引用类型),这意味着修改切片元素会影响原集合:
int[] original = { 10, 20, 30, 40 };
int[] slice = original[1..3]; // {20, 30}
slice[0] = 200;
Console.WriteLine(original[1]); // 输出200(原数组被修改)
与Index
类似,Range
也可以通过结构的构造函数显式创建:
// 创建范围(从索引1到索引4)
Range range = new Range(1, 4);
int[] numbers = { 0, 1, 2, 3, 4, 5 };
int[] slice = numbers[range]; // {1, 2, 3}
Range
结构提供了Start
和End
属性,用于获取范围的起始和结束索引:
Console.WriteLine(range.Start); // 1
Console.WriteLine(range.End); // 4
索引和范围特性并非只适用于数组,它们可以与任何实现了IEnumerable
的集合类型配合使用,包括字符串、列表、Span 等。
字符串是 char 类型的集合,索引和范围可以简化子字符串的获取:
string text = "Hello, World!";
// 获取单个字符
char first = text[0]; // 'H'
char last = text[^1]; // '!'
// 获取子字符串
string greeting = text[..5]; // "Hello"
string world = text[7..^1]; // "World"
string middle = text[3..8]; // "lo, W"
string entire = text[..]; // "Hello, World!"
与Substring
方法相比,范围语法更直观,无需计算长度:
// 传统方式
string sub1 = text.Substring(7, 5); // "World"
// 范围方式(更简洁)
string sub2 = text[7..12]; // "World"
List
同样支持索引和范围操作,但需要注意范围操作返回的是List
的切片(新列表)而非视图:
var fruits = new List<string> { "apple", "banana", "cherry", "date", "elderberry" };
// 索引访问
string first = fruits[0]; // "apple"
string last = fruits[^1]; // "elderberry"
// 范围操作
var middleFruits = fruits[1..4]; // {"banana", "cherry", "date"}
// 修改切片不会影响原列表(与数组不同)
middleFruits[0] = "blueberry";
Console.WriteLine(fruits[1]); // 仍为"banana"
对于高性能场景,Span
和ReadOnlySpan
与索引和范围的配合尤为高效,因为它们操作的是内存视图而非副本:
int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
// 创建Span
Span<int> span = numbers.AsSpan();
// 范围操作(零分配)
Span<int> slice = span[2..^2]; // {3,4,5,6,7}
// 修改Span会影响原数组
slice[0] = 30;
Console.WriteLine(numbers[2]); // 30
在处理大型数据集时,这种零分配的特性可以显著提升性能。
需要注意的是,索引和范围目前仅支持一维数组,多维数组和交错数组的支持有限:
int[,] matrix = { {1, 2}, {3, 4} };
// 以下代码无法编译
// int value = matrix[^1, ^1];
int[][] jagged = { new[] {1,2}, new[] {3,4} };
// 交错数组的一维可以使用
int value = jagged[^1][^1]; // 4(合法)
掌握索引和范围的高级用法,可以进一步提升代码质量和开发效率,尤其在处理复杂集合操作时。
可以将范围和索引结合使用,实现更复杂的子序列提取:
int[][] jaggedArray = {
new[] { 1, 2, 3, 4 },
new[] { 5, 6, 7, 8 },
new[] { 9, 10, 11, 12 }
};
// 获取第二个数组的中间两个元素
int[] result = jaggedArray[1][1..3]; // {6,7}
// 获取最后两个数组的前三个元素
var slice = jaggedArray[^2..].Select(arr => arr[..3]);
// 结果:{ {5,6,7}, {9,10,11} }
索引和范围可以与 LINQ 方法无缝协作,增强集合处理能力:
var numbers = Enumerable.Range(1, 10).ToList(); // 1-10
// 结合Where筛选
var evenInRange = numbers[3..8].Where(n => n % 2 == 0); // {4,6,8}
// 结合OrderBy排序
var sortedSlice = numbers[^5..].OrderByDescending(n => n); // {10,9,8,7,6}
要使自定义集合支持索引和范围,需要实现相应的接口:
IEnumerable
接口(基本要求)this[Index]
索引器以支持索引访问this[Range]
索引器以支持范围操作示例实现:
public class MyCollection<T> : IEnumerable<T>
{
private readonly T[] _items;
public MyCollection(T[] items) => _items = items;
// 支持索引访问
public T this[Index index] => _items[index];
// 支持范围操作
public MyCollection<T> this[Range range]
{
get
{
var (start, length) = range.GetOffsetAndLength(_items.Length);
var slice = new T[length];
Array.Copy(_items, start, slice, 0, length);
return new MyCollection<T>(slice);
}
}
// 实现IEnumerable接口
public IEnumerator<T> GetEnumerator() => ((IEnumerable<T>)_items).GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => _items.GetEnumerator();
}
// 使用自定义集合
var coll = new MyCollection<int>(new[] {1,2,3,4,5});
Console.WriteLine(coll[^2]); // 4
var subColl = coll[1..4]; // {2,3,4}
虽然索引和范围语法简洁,但在性能敏感场景需要注意:
Span
性能对比示例:
// 高性能:零分配
Span<char> charSpan = stackalloc char[100];
// 填充数据...
var slice = charSpan[10..20];
// 有分配:创建新字符串
string longString = new string('a', 1000);
for (int i = 0; i < 1000; i++)
{
var sub = longString[i..(i+10)]; // 每次迭代都分配新字符串
}
在使用索引和范围时,一些常见的陷阱和限制需要特别注意,以避免错误和性能问题。
与传统索引一样,使用超出集合范围的索引会抛出IndexOutOfRangeException
:
int[] numbers = {1,2,3};
// 以下代码都会抛出异常
try
{
int val1 = numbers[3]; // 正向越界
int val2 = numbers[^4]; // 反向越界
}
catch (IndexOutOfRangeException ex)
{
Console.WriteLine(ex.Message);
}
使用前应确保索引在有效范围内,可通过集合的Length
或Count
属性验证:
int index = 5;
if (index >= 0 && index < numbers.Length)
{
// 安全访问
}
int reverseIndex = 3;
if (reverseIndex >= 0 && reverseIndex <= numbers.Length)
{
// 安全使用反向索引 numbers[^reverseIndex]
}
当范围的起始索引大于等于结束索引时,会返回空集合而非抛出异常:
int[] numbers = {1,2,3,4};
// 以下范围都返回空数组
int[] empty1 = numbers[2..1]; // 起始>结束
int[] empty2 = numbers[3..3]; // 起始=结束
int[] empty3 = numbers[^1..^2]; // 反向范围无效
这种特性在处理动态范围时很有用,可以避免额外的边界检查:
int start = 5;
int end = 3;
// 无需检查start和end的大小关系
var result = numbers[start..end]; // 自动返回空
虽然索引和范围提供了新的语法,但它们与现有 API 完全兼容,可以混合使用:
string text = "C# Index and Range";
// 混合使用Substring和范围
int endIndex = text.IndexOf(' ');
string firstWord = text[..endIndex]; // "C#"
// 混合使用LINQ和索引
var numbers = Enumerable.Range(1, 10).ToList();
int lastEven = numbers.Where(n => n % 2 == 0).Last()[^1]; // 10
对于值类型数组,范围操作返回的切片是原数组的副本;对于引用类型数组,切片包含的是原对象的引用:
// 值类型示例
int[] ints = {1,2,3};
int[] intSlice = ints[1..3];
intSlice[0] = 20;
Console.WriteLine(ints[1]); // 2(原数组不受影响)
// 引用类型示例
object[] objs = {new object(), new object()};
object[] objSlice = objs[..];
objSlice[0] = new object();
Console.WriteLine(objs[0] == objSlice[0]); // False(仅替换了切片中的引用)
索引和范围特性为 C# 开发者提供了更现代、更简洁的集合访问方式,合理使用可以显著提升代码的可读性和开发效率。
[start..end]
比Substring
、Array.Copy
等更直观[^n]
替代[Length - n]
,避免手动计算Span
使用范围可以避免内存分配this[Index]
和this[Range]
索引器,提升 API 友好性list[pageSize*(page-1)..pageSize*page]
)尽管索引和范围非常强大,但它们并非适用于所有场景:
Where
方法for
循环可能更高效C# 的索引和范围特性代表了语言向更简洁、更表达性方向的发展。通过本文的学习,你应该能够在实际开发中灵活运用这些特性,编写更清晰、更易维护的代码。无论是简单的数组访问还是复杂的集合操作,索引和范围都能成为你的得力工具,让集合处理变得前所未有的简单。