C# 中的值类型、引用类型、元组和数组

C# 中的基本数据类型和数据类型转换 中讨论了所有 C# 预定义类型,简单提到了引用类型和值类型的区别。本文继续讨论数据类型,深入解释类型划分。

此外,本文还要讨论将数据元素合并成元组的细节,这是 C# 7.0 引入的一个功能。最后讨论如何将数据分组到称为数组的集合中。

首先深入理解值类型和引用类型。

一、类型的划分

一个类型要么是值类型,要么是引用类型。区别在于拷贝方式:值类型的数据总是拷贝值;而引用类型的数据总是拷贝引用。

1.1 值类型

除了 string,目前讲到的所有预定义类型都是值类型。值类型直接包含值。换言之,变量引用的位置就是内存中实际存储值的位置。

因此,将一个值赋给变量 1,再将变量 1 赋给变量 2,会在变量 2 的位置创建值的拷贝,而不是引用变量 1 的位置。这进一步造成更改变量 1 的值不会影响变量 2 的值。

图 1 对此进行了演示。number1 引用内存中的特定位置,该位置包含值 42。将 number1 的值赋给 number2 之后,两个变量都包含值 42。但修改其中任何一个值都不会影响另一个值。

值类型的实例直接包含数据

图 1 值类型的实例直接包含数据

类似地,将值类型的实例传给 Console.WriteLine() 这样的方法也会生成内存拷贝。在方法内部对参数值进行的任何修改都不会影响调用函数中的原始值。

由于值类型需要创建内存拷贝,因此定义时不要让它们占用太多内存(通常应该小于 16 字节)。

1.2 引用类型

相反,引用类型的变量存储对数据存储位置的引用,而不是直接存储数据。要去那个位置才能找到真正的数据。

所以为了访问数据,“运行时”要先从变量中读取内存位置,再“跳转”到包含数据的内存位置。“运行时”的这种操作称为“解引用”。

为引用类型的变量分配实际数据的内存区域称为(heap),如图 2 所示。

引用类型的实例指向堆

图 2 引用类型的实例指向堆

引用类型不像值类型那样要求创建数据的内存拷贝,所以拷贝引用类型的实例比拷贝大的值类型实例更高效。将引用类型的变量赋给另一个引用类型的变量,只会拷贝引用而不需要拷贝所引用的数据。

事实上,每个引用总是处理器的“原生大小”:32 位处理器拷贝 32 位引用,64 位处理器拷贝 64 位引用,以此类推。显然,拷贝对一个大数据块的引用要比拷贝整个数据块快得多。

由于引用类型只拷贝对数据的引用,所以两个不同的变量可引用相同的数据。

如果两个变量引用同一个对象,则当通过一个变量更改了对象的内部数据时,可以通过另一个变量看到对象内部数据的变化。无论赋值还是方法调用都会如此。

因此,如果在方法内部更改引用类型的数据,控制返回调用者之后,将看到更改后的结果。

有鉴于此,如果对象在逻辑上是固定大小、不可变的值,就考虑定义成值类型。如果逻辑上是可引用、可变的东西,就考虑定义成引用类型。

除了 string 和自定义类(如 Program),目前讲到的所有类型都是值类型。但大多数类型都是引用类型。虽然偶尔需要自定义的值类型,但更多的还是自定义的引用类型。

二、将变量声明为可空

将一个变量的值设置为空往往非常实用。例如,当指定一个数量值时,如果数量未知或者用户未提供数值,那么应该输入什么呢?

一个可能的办法是指定特殊值,比如 -1int.MaxValue,但这些毕竟都是有效的整数值,有时很难分辨一个特殊值意味着真实数值或是无效值。

因此,更好的解决方案是允许将变量赋值为 null,以便区分真实数值和无效值。

此外,null 值对于数据库编程来说尤为重要,因为很多数据库都允许字段的值为 null。如果不允许将变量赋值为 null,则在读取数据库记录时便会产生很多问题。

你可以将类型声明为可空或不可空,这意味着可以使用可空修饰符将类型声明为允许或不允许空值。(从 C# 2.0 开始允许将值类型变量声明为可空,从 C# 8.0 开始,引用类型变量也可以声明为可空。)

为声明一个能被赋值为 null 的变量,要使用可空修饰符:?。例如,int? number=null 将声明一个可空的 int 型变量,并将其值初始化为 null

需要注意的是,使用可空变量时也存在一些陷阱,需要开发者更加小心谨慎。

2.1 对 null 值引用类型变量进行解引用

支持将变量赋值为 null 是一件好坏参半的事:这样做本来非常有意义,可惜其缺点也不容忽视。

虽然将 null 赋值给一个变量,或者作为参数去调用一个方法并不会直接产生问题,但是如果对一个值为 null 的引用类型变量进行解引用(例如调用其方法),则会引发 System.NullReferenceException 异常——例如,调用 text.GetType(),当 text 值为 null 时,该异常便会发生。

在产品级的代码中如果发生了 System.NullReferenceException 异常,则是一个无可否认的 bug,因为这个异常通常意味着程序员在调用方法之前忘记了检查 null 值。

更糟糕的是,对 null 值的检查依赖于程序员能够意识到一个变量的值可能为 null,而这种意识显然非常不可靠,因此,一个更好的方案是在默认情况下不允许将变量赋值为 null,而若想要赋值为 null,则必须用可空修饰符进行显式声明。

这种显式声明的一个暗含的意义是:如果程序员主动声明一个变量可以被赋值为 null,则他便需要对可能出现的 null 值担负更多的责任。

到目前为止,我们尚未讨论用于检查 null 值的操作符和语句。后面的“高级主题:检查 null 值”将会介绍一些简要方法。

2.2 可空值类型

一个值类型变量存储的是一个实际的值,而不是一个引用,并且值类型变量本质上也不应该拥有 null 值。

尽管如此,在实际中,当我们调用一个值类型变量的方法或者访问其属性时,仍然认为是在对该值类型变量进行解引用。

虽然技术上不太正确,但是当人们谈论解引用时,普遍并不在意一个变量是值类型还是引用类型。

2.3 可空引用类型

在 C# 8.0 之前,所有引用类型变量都可以被赋值为 null。但这一规则导致了大量 bug 的产生。

这是因为避免 null 值异常需要程序员能够预见到一个变量可能为 null,从而在程序中编写保护性代码,但实际中这种预见性很难做到万无一失。

此外,引用类型变量默认为可空,且其初始值默认为 null 也使得这一问题变得更糟。

例如,在代码清单 2 中有一个名为 text 的引用型局部变量,其值尚未初始化,如果此时对其进行解引用,编译器会报告错误“use of unassigned local variable ’text’”(使用未赋值的局部变量’text’)。

为了解决这个编译错误,最简单的办法是在声明变量时将其初始化为 null,而不是为其寻找一个更合理的值。

但这样一来,程序员便有可能掉进陷阱中:为了简单地解决编译错误,声明一个变量并将其初始化为 null,并且寄希望于该变量被真正使用之前能够被幸运地设置一个有效的值,然而这种期待有可能会落空。

代码清单 2 对未赋值的变量进行解引用

1
2
3
4
5
6
7
8
public static void Main()
{
    string? text;
    // ...

    // Compile Error: Use of unassigned local variable 'text'
    System.Console.WriteLine(text.Length);
}

总之,引用型变量默认可以被赋值为空,是造成 System.NullReferenceException 异常的罪魁祸首。而编译器的赋值检查则很容易将程序员引入歧途,除非他们特别小心谨慎地编程才能躲避陷阱。

为了显著地改善这种情况,C# 团队在 C# 8.0 中将可空性概念同样赋予了引用类型变量,即所谓的可空引用类型。

至此,引用类型变量和值类型变量都可以声明时被指定为可空或者不可空。在 C# 8.0 中,声明任何类型的变量时,默认都为不可空。

不幸的是,支持使用可空修饰符声明引用类型,并将不使用空修饰符的引用类型声明默认为不可空,这对从早期版本的 C# 升级的代码有重大影响。

考虑到 C# 7.0 和更早版本支持将 null 赋值给所有引用类型声明(即 string text=null),所有代码都会在 C# 8.0 中编译失败吗?

确保兼容旧代码对于 C# 团队来说非常重要,因此 C# 在默认情况下并不支持引用类型的可空性特性。要想启用此特性,需要使用 #nullable 语句,或者在项目属性配置中启用该特性。

首先,可以在程序代码中使用下面语句来启用引用类型的可空性特性:

1
#nullable enable

该语句在 #nullable 后面输入三个可选值:enabledisablerestorerestore 的作用是将可空性设置恢复为项目全局设置的值。

前面的代码清单 2 演示了使用 #nullable 语句来启用该特性的例子。正是该语句使得编译器不再会因为 string? Text; 语句而发出警告。

启用引用类型的可空性特性的另一个方法是在项目属性中添加设置。该特性默认为不启用。如果要启用它,可以找到项目的 .csproj 文件,并加入代码清单 3 中的设置。

代码清单 3 通过修改 .csproj 文件在项目全局范围内启用可空性特性

1
2
3
4
5
6
7
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp3.0</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>

也可以在 dotnet 命令行参数中通过 /p 参数设置该特性是否启用:

1
dotnet build /p:Nullable=enable

该命令行参数会取代项目代码中所有对可空性特性的设置。

三、隐式类型的局部变量

C# 3.0 新增上下文关键字 var 来声明隐式类型的局部变量

声明变量时,如果能用确定类型的表达式初始化它,C# 3.0 及以后的版本就允许变量的数据类型为“隐式的”,无须显式声明,如代码清单 4 所示。

代码清单 4 字符串处理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public static void Main()
{
    System.Console.Write("Enter text: ");
    var text = System.Console.ReadLine();

    // Return a new string in uppercase
    var uppercase = text.ToUpper();

    System.Console.WriteLine(uppercase);
}

不显式声明为 string 类型,而是声明为 var。最终的 CIL 代码没有区别。但 var 告诉编译器根据声明时所赋的值(System.Console.ReadLine())来推断数据类型。

其次,textuppercase 变量都在声明时初始化。不这样做会造成编译时错误。如前所述,编译器判断初始化表达式的数据类型并相应地声明变量,就好像程序员显式指定了类型。

虽然允许用 var 取代显式数据类型,但在数据类型不是很明显的情况下最好不要用 var

例如,还是应该将 textuppercase 声明为 string

这不仅可使代码更易理解,还相当于你亲自确认了等号右侧表达式返回的是你希望的数据类型。

使用 var 变量时,右侧数据类型应显而易见,否则应避免用 var 声明变量。

四、元组

有时需要合并数据元素。

例如,2019 年全球最贫穷的国家是首都位于 Juba(朱巴)的南苏丹,人均 GDP 为 275.18 美元。利用目前讲过的编程构造,可将上述每个数据元素存储到单独的变量中,但它们相互无关联。

换言之,看不出 275.18 和南苏丹有什么联系。

为解决该问题,第一个方案是在变量名中使用统一的后缀或前缀,第二个方案是将所有数据合并到一个字符串中,但缺点是需要解析字符串才能处理单独的数据元素。

C# 7.0 提供了第三个方案:元组(tuple)。元组允许在一条语句中完成对所有变量的赋值,如下所示:

1
(string country, string capital, double gdpPerCapita) = ("South Sudan", "Juba", 275.18);

表 1 总结了元组的其他语法形式。

表 1 元组声明和赋值的示例代码

元组声明和赋值的示例代码

前四个例子虽然右侧是元组,但左侧仍然是单独的变量,只是用元组语法一起赋值。

在这种语法中,两个或更多元素以逗号分隔,放到一对圆括号中进行组合。(我使用“元组语法”一词是因为编译器为左侧生成的基础数据类型技术上说并非元组。)

结果是虽然右侧的值合并成元组,但在向左侧赋值的过程中,元组已被解构为它的组成部分。

例 2 左边被赋值的变量是事先声明好的,但例 1、3 和 4 的变量是在元组语法中声明的。

由于只是声明变量,所以命名和大小写应遵循 听说你想学习 C#?那就从 HelloWorld 程序开始吧 中的的设计规范,例如有一条是“要为局部变量使用 camelCase 风格命名。”

虽然隐式类型(var)在例 4 中用元组语法平均分配给每个变量声明,但这里的 var 绝不可以替换成显式类型(如 string)。

元组宗旨是允许每一项都有不同数据类型,所以为每一项都指定同一个显式类型名称跟这个宗旨冲突(即使类型真的一样,编译器也不允许指定显式类型)。

例 5 在左侧声明一个元组,将右侧的元组赋给它。

注意元组含具名项,随后可引用这些名称来获取右侧元组中的值。

这正是能在 System.Console.WriteLine 语句中使用 countryInfo.NamecountryInfo.CapitalcountryInfo.GdpPerCapita 语法的原因。

在左侧声明元组造成多个变量组合到单个元组变量(countryInfo)中。然后可利用元组变量来访问其组成部分。

前面说过,用元组语法定义的变量应遵守 camelCase 大小写规则。但该规则并未得到彻底贯彻。

有人提倡当元组的行为和参数相似时(类似于元组语法出现之前用于返回多个值的 out 参数),这些名称应使用参数命名规则。

另一个方案是 PascalCase 大小写,这是类型成员(属性、函数和公共字段)的命名规范。

个人强烈推荐 PascalCase 规范,从而和 C#/.NET 成员标识符的大小写规范一致。

但由于这并不是被广泛接受的规范,所以我在设计规范“考虑为所有元组项名称使用 PascalCase 大小写风格”中使用“考虑”而非“要”一词,

例 6 提供和例 5 一样的功能,只是右侧元组使用了具名元组项,左侧使用了隐式类型声明。

但元组项名称会传入隐式类型变量,所以 WriteLine 语句仍可使用它们。当然,左侧可使用和右侧不同的元组项名称。

C# 编译器允许这样做但会显示警告,指出右侧元组项名称会被忽略,因为此时左侧的优先。

不指定元组项名称,被赋值的元组变量中的单独元素仍可访问,只是名称是 Item1Item2、……,如例 7 所示。

事实上,即便提供了自定义名称,ItemX 名称始终都能使用,如例 8 所示。但在使用 Visual Studio 这样的 IDE 工具时,ItemX 属性不会出现在“智能感知”的下拉列表中。

这是好事,因为自己提供的名称理论上应该更好。

如例 9 所示,可用下划线丢弃部分元组项的赋值,这称为弃元(discard)。

例 10 展示的元组项名称推断功能是自 C# 7.1 引入的。如本例所示,元组项名称可根据变量名(甚至属性名)来推断。

元组是在对象中封装数据的轻量级方案,有点像你用来装杂货的购物袋。

和稍后讨论的数组不同,元组项的数据类型可以不一样,没有限制 1,只是它们由编译器决定,不能在运行时改变。

另外,元组项数量也是在编译时硬编码好的。

最后,不能为元组添加自定义行为(扩展方法不在此列)。如果需要和封装数据关联的行为,则应使用面向对象编程并定义一个类。

五、数组

听说你想学习 C#?那就从 HelloWorld 程序开始吧 没有提到的一种特殊的变量声明就是数组声明。

利用数组声明,可在单个变量中存储同一种类型的多个数据项,而且可利用索引来单独访问这些数据项。C# 的数组索引从零开始,所以我们说 C# 数组基于零。

数组是几乎所有编程语言的基本组成部分,所有开发者都应学习。

虽然 C# 编程经常用到数组,初学者也确实应该掌握,但大多数程序现在都用泛型集合类型而非数组来存储数据集合。

如果只是为了熟悉数组的实例化和赋值,可略读下一节。表 2 列出了要注意的重点。

此外,5.5 节还会讲到数组的一些特点。

表 2 数组的重点

数组的重点

5.1 数组的声明

C# 用方括号声明数组变量。首先指定数组元素的类型,后跟一对方括号,再输入变量名。代码清单 7 声明字符串数组变量 languages

代码清单 7 声明数组

1
string[] languages;

显然,数组声明的第一部分标识了数组中存储的元素的类型。作为声明的一部分,方括号指定了数组的(rank),或者说维数。本例声明一维数组。类型和维数构成了 languages 变量的数据类型。

代码清单 7 定义的是一维数组。方括号中的逗号用于定义额外的维。例如,代码清单 8 为井字棋(tic-tac-toe)棋盘定义了一个二维数组。

代码清单 8 声明二维数组

1
2
3
4
5
6
7
8
9
public static void Main()
{
    //    |   |
    // ---+---+---
    //    |   |
    // ---+---+---
    //    |   |
    int[,] cells;
}

代码清单 8 定义了一个二维数组。第一维对应从左到右的单元格,第二维对应从上到下的单元格。可用更多逗号定义更多维,数组总维数等于逗号数加 1

注意,某一维上的元素数量不是变量声明的一部分。这是在创建(实例化)数组并为每个元素分配内存空间时指定的。

5.2 数组实例化和赋值

声明数组后,可在一对大括号中使用以逗号分隔的数据项列表来填充它的值。代码清单 9 声明一个字符串数组,将一对大括号中的 9 种语言名称赋给它。

代码清单 9 声明数组的同时赋值

1
2
3
4
5
6
public static void Main()
{
    string[] languages = { "C#", "COBOL", "Java",
        "C++", "TypeScript", "Visual Basic",
        "Python", "Lisp", "JavaScript" };
}

列表第一项成为数组的第一个元素,第二项成为第二个,以此类推。我们用大括号定义数组字面值。

只有在同一条语句中声明并赋值,才能使用代码清单 9 的赋值语法。声明后在其他地方赋值则需使用 new 关键字,如代码清单 10 所示。

代码清单 10 声明数组后再赋值

1
2
3
4
5
6
7
public static void Main()
{
    string[] languages;
    languages = new string[] { "C#", "COBOL", "Java",
        "C++", "TypeScript", "Visual Basic",
        "Python", "Lisp", "JavaScript" };
}

自 C# 3.0 起不必在 new 后指定数组类型(string)。编译器能根据初始化列表中的数据类型推断数组类型。但方括号仍不可缺少。

C# 支持将 new 关键字作为声明语句的一部分,所以可以像代码清单 11 那样在声明时赋值。

代码清单 11 声明数组时用 new 赋值

1
2
3
4
5
6
7
public static void Main()
{
    string[] languages = new string[] {
        "C#", "COBOL", "Java",
        "C++", "TypeScript", "Visual Basic",
        "Python", "Lisp", "JavaScript" };
}

new 关键字的作用是指示“运行时”为数据类型分配内存,即指示它实例化数据类型(本例是数组)。

数组赋值时只要使用了 new 关键字,就可在方括号内指定数组大小,如代码清单 12 所示。

代码清单 12 声明数组时用 new 关键字赋值并指定数组大小

1
2
3
4
5
6
7
public static void Main()
{
    string[] languages = new string[9] {
        "C#", "COBOL", "Java",
        "C++", "TypeScript", "Visual Basic",
        "Python", "Lisp", "JavaScript" };
}

定的数组大小必须和大括号中的元素数量匹配。另外,也可分配数组但不提供初始值,如代码清单 13 所示。

代码清单 13 分配数组但不提供初始值

1
string[] languages = new string[9];

分配数组但不指定初始值,“运行时”会将每个数组元素初始化为它们的默认值,如下所示:

  • 引用类型,不论是否为可空(比如 string 或者 string?),都初始化为 null

  • 可空的值类型初始化为 null

  • 不可空的值类型初始化为 0

  • bool 初始化为 false

  • char 初始化为 \0

非基元值类型以递归方式初始化,每个字段都被初始化为默认值。所以,其实并不需要在使用数组前初始化它的所有元素。

由于数组大小不需要作为变量声明的一部分,所以可以在运行时指定数组大小。例如,代码清单 14 根据在 Console.ReadLine() 调用中用户指定的大小创建数组。

代码清单 14 在运行时确定数组大小

1
2
3
4
5
6
7
8
public static void Main()
{
    string[] groceryList;
    System.Console.Write("How many items on the list? ");
    int size = int.Parse(System.Console.ReadLine());
    groceryList = new string[size];
    // ...
}

C# 以类似的方式处理多维数组。每一维的大小以逗号分隔。代码清单 15 初始化一个没有开始走棋的井字棋棋盘。

代码清单 15 声明二维数组

1
int[,] cells = new int[3,3];

还可以像代码清单 16 那样,将井字棋棋盘初始化成特定的棋子布局。

代码清单 16 初始化二维整数数组

1
2
3
4
5
int[,] cells = {
    {1, 0, 2},
    {1, 2, 0},
    {1, 2, 1}
};

数组包含三个 int[] 类型的元素,每个元素大小一样(本例中凑巧也是 3)。注意每个 int[] 元素的大小必须完全一样。也就是说,像代码清单 17 那样的声明是无效的。

代码清单 17 大小不一致的多维数组会造成错误

1
2
3
4
5
6
7
8
// ERROR: Each dimension must be consistently sized

int[,] cells = {
    {1, 0, 2, 0},
    {1, 2, 0},
    {1, 2},
    {1}
};

表示棋盘并不需要在每个位置都使用整数。另一个办法是为每个玩家都单独提供虚拟棋盘,每个棋盘都包含一个 bool 来指出玩家选择的位置。代码清单 18 对应于一个三维棋盘。

代码清单 18 初始化三维数组

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public static void Main()
{
    bool[, ,] cells;
    cells = new bool[2, 3, 3]
    {
        // Player 1 moves
        {                          //  X |   |
            {true, false, false},  // ---+---+---
            {true, false, false},  //  X |   |
            {true, false, true}    // ---+---+---
        },                         //  X |   | X
        // Player 2 moves
        {                          //    |   | O
            {false, false, true},  // ---+---+---
            {false, true, false},  //    | O |
            {false, true, true}    // ---+---+---
        }                          //    | O |
    };
}

本例初始化棋盘并显式指定每一维的大小。new 表达式除了指定大小,还提供了数组的字面值。bool[,,] 类型的字面值被分解成两个 bool[,] 类型的二维数组(大小均为 3×3)。

每个二维数组都由三个 bool 数组(大小为 3)构成。

如前所述,多维数组(这种普通多维数组也称为“矩形数组”)每一维的大小必须一致。

还可定义交错数组(jagged array),也就是由数组构成的数组。交错数组的语法稍微有别于多维数组,而且交错数组不需要具有一致的大小。

所以,可以像代码清单 19 那样初始化交错数组。

代码清单 19 初始化交错数组

1
2
3
4
5
6
int[][] cells = {
    new int[] {1, 0, 2, 0},
    new int[] {1, 2, 0},
    new int[] {1, 2},
    new int[] {1}
};

交错数组不用逗号标识新维。相反,交错数组定义由数组构成的数组。代码清单 19 在 int[] 后添加 [],表明数组元素是 int[] 类型的数组。

注意,交错数组要求为内部的每个数组都创建数组实例。这个例子使用 new 实例化交错数组的内部元素。遗失这个实例化部分会造成编译时错误。

5.3 数组的使用

使用方括号(称为数组访问符)访问数组元素。为获取第一个元素,要指定 0 作为索引。

代码清单 20 将 languages 变量中的第 5 个元素(索引 4)的值存储到变量 language 中。

代码清单 20 声明并访问数组

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public static void Main()
{
    string[] languages = new string[] {
        "C#", "COBOL", "Java",
        "C++", "TypeScript", "Visual Basic",
        "Python", "Lisp", "JavaScript"};
        // Retrieve fifth item in languages array (TypeScript)
        string language = languages[4];
        // Write �TypeScript�
        System.Console.WriteLine(language);
        // Retrieve second item from the end (Python)
        language = languages[^3];
        // Write �Python�
        System.Console.WriteLine(language);
}

从 C# 8.0 开始,你可以使用相对于末尾元素的索引来访问数组,该操作需要用到反向索引操作符(index from end operator),有时也称作 ^ 操作符或者“帽子操作符”。

以代码清单 20 中的数组 languages 为例。索引 ^1 访问的是数组最后一个元素,索引 ^9 访问的是第一个元素,而索引 ^3 则访问倒数第三个元素,即“Python”。

既然索引 ^1 代表数组中最后一个元素,那么索引 ^0 则代表了最后一个元素的下一个位置。

类似地,当不带反向操作符的正向索引值等于数组长度时(比如上例中的 languages 数组的长度 9),也代表最后一个元素的下一个位置。

由于该位置上没有元素,因此无法访问该位置。此外,索引值也不允许为负数。

在数组索引的问题上,C# 的规则看起来有些不统一。正向索引用从 0 开始计数,而反向索引则从 ^1 开始。

C# 团队规定正向索引从 0 开始是为了与它所基于的前辈编程语言(C、C++、Java 等)保持一致,而反向索引的概念在那些前辈语言中并不存在,因此 C# 选择了类似 Python 的方式,即从 ^1 开始。

但与 Python 不同的是,C# 团队规定用 ^ 操作符来标记反向索引,而不是 Python 中的负数,这是为了区别于旧版 C# 中的集合索引(集合不是数组,集合索引可以为负数),从而保持向上兼容。

此外,^ 操作符能够更好地支持区间索引,这个概念将在本文后面介绍。

对于习惯于索引从 0 开始的人来说,反向索引的用法也可以这样记忆:既然对于正向索引来说,数组的末尾元素为“数组长度 -1”,次末尾元素为“数组长度 -2”,那么反向索引就是减号后面的那个正整数。

数组中同一个元素的正向索引值和反向索引值之和总是等于数组长度。

最后要注意:^ 操作符后面不局限于使用字面量数字,也可以使用任何返回正整数的表达式,例如,languages[^languages.Length] 可以访问数组的首元素。

还可用方括号语法将数据存储到数组中。代码清单 21 交换了"C++“和"Java"的顺序。

代码清单 21 交换数组中不同位置的数据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public static void Main()
{
    string[] languages = new string[] {
        "C#", "COBOL", "Java",
        "C++", "TypeScript", "Visual Basic",
        "Python", "Lisp", "JavaScript" };
    // Save "C++" to variable called language
    string language = languages[3];
    // Assign "Java" to the C++ position
    languages[3] = languages[2];
    // Assign language to location of "Java"
    languages[2] = language;
}

多维数组的元素用每一个维的索引来标识,如代码清单 22 所示。

代码清单 22 初始化二维整数数组

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public static void Main()
{
    int[,] cells = {
        {1, 0, 2},
        {0, 2, 0},
        {1, 2, 1}
    };
    // Set the winning tic-tac-toe move to be player 1
    cells[1, 0] = 1;
}

交错数组元素的赋值稍有不同,这是因为它必须与交错数组的声明一致。第一个索引指定“由数组构成的数组”中的一个数组。第二个索引指定是该数组中的哪一项(参见代码清单 23)。

代码清单 23 声明交错数组

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public static void Main()
{
    int[][] cells = {
        new int[] {1, 0, 2},
        new int[] {0, 2, 0},
        new int[] {1, 2, 1}
    };

    cells[1][0] = 1;
    // ...
}

像代码清单 24 那样获取数组长度。

代码清单 24 获取数组长度

1
2
3
4
5
6
7
public static void Main()
{
    string[] languages = new string[9];
    // ...
    System.Console.WriteLine(
        $"There are {languages.Length} languages in the array.");
}

数组长度固定,除非重新创建数组,否则不能随便更改。

此外,越过数组的边界(或长度)会造成“运行时”报错。用无效索引(指向的元素不存在)来访问(检索或者赋值)数组时就会发生这种情况。

例如在代码清单 25 中,用数组长度作为索引来访问数组就会出错。

代码清单 25 访问数组越界会抛出异常

1
2
3
4
5
6
7
8
public static void Main()
{
    string[] languages = new string[9];
    // ...
    // RUNTIME ERROR: index out of bounds - should
    // be 8 for the last element
    languages[4] = languages[9];
}

在 C# 8.0 中,使用 ^0 访问数组也会遇到同样问题:既然 ^1 是末尾元素,那么 ^0 就是末尾元素的下一个位置,该元素并不存在。

为避免越界,应使用长度检查来验证数组长度大于 0。访问数组最后一项时,使用 ^1(C# 8.0 开始)或 Length-1 而不是硬编码的值。

例如,代码清单 26 修改了上个代码清单,在索引中使用了 Length(减 1 获得最后一个元素的索引)。

代码清单 26 在数组索引中使用 Length-1

1
2
3
4
5
6
public static void Main()
{
    string[] languages = new string[9];
    // ...
    languages[4] = languages[languages.Length - 1];
}

当然,上面代码中访问数组前没有检查数组元素是否为 null。在实际开发中,应当进行检查。

Length 返回数组中元素的总数。因此,如果你有一个多维数组,比如大小为 2×3×3 的 bool cells[,,] 数组,那么 Length 会返回元素总数 18。

对于交错数组,Length 返回外部数组的元素数。因为交错数组是“数组构成的数组”,所以 Length 只作用于外部数组,只统计它的元素数(也就是具体由多少个数组构成),而不管各内部数组共包含了多少个元素。

5.3.1 区间

C# 8.0 为数组提供了一个新的访问方法:数组切片。

简单地说,数组切片就是将原数组中特定长度的一段连续元素提取出来形成新数组。我们将数组中一段连续的元素称为区间,用区间操作符 .. 表示。

在使用时,可将区间操作符写在两个索引值(包括反向索引)之间来表示区间,其中两个索引值为可选项。代码清单 27 展示了区间操作符的应用示例。

代码清单 27 区间操作符的应用示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public static void Main()
{
    string[] languages = new string[] {
        "C#", "COBOL", "Java",
        "C++", "TypeScript", "Swift",
        "Python", "Lisp", "JavaScript"};

    System.Console.WriteLine([email protected]"  0..3: {
        string.Join(", ", languages[0..3])  // C#, COBOL, Java
    }");
    System.Console.WriteLine([email protected]"^3..^0: {
        string.Join(", ", languages[^3..^0]) // Python, Lisp, JavaScript
    }");
    System.Console.WriteLine([email protected]" 3..^3: {
        string.Join(", ", languages[3..^3]) // C++, TypeScript, Swift
    }");
    System.Console.WriteLine([email protected]"  ..^6: {
        string.Join(", ", languages[..^6])  // C#, COBOL, Java
    }");
    System.Console.WriteLine([email protected]"   6..: {
        string.Join(", ", languages[6..])  // Python, Lisp, JavaScript
    }");
    System.Console.WriteLine([email protected]"    ..: {
        // C#, COBOL, Java, C++, TypeScript, Swift, Python, Lisp, JavaScript
        string.Join(", ", languages[..])  // Python, Lisp, JavaScript
    }");
    System.Console.WriteLine([email protected]"    ..: {
        // C#, COBOL, Java, C++, TypeScript, Swift, Python, Lisp, JavaScript
        string.Join(", ", languages[0..^0])  // Python, Lisp, JavaScript
    }");
}

区间操作符有一个非常重要的概念,即它所代表的区间为半闭半开区间。

写在它左侧的索引所代表的元素被包含在区间内,而写在右侧的索引所代表的元素则不被包含。

因此代码清单 27 中的区间 0..3 所代表的区间为从第 0 号元素开始的 3 个元素,而第 4 个元素(由于正向索引从 0 开始计数,因此索引值 3 代表第 4 个元素)则不包含在该区间内。

上面代码中的第二个区间为 ^3..^0 则从数组中提取最后 3 个元素。在这里 ^0 不会造成问题,同样是因为 ^0 作为区间操作符右侧的索引值,其所代表的元素不被包含在区间内。

区间操作符两侧的区间开始索引和截止索引都不是必须写出的。

如果只写了开始索引,则表示从该索引开始到末尾元素的区间;如果只写了截止索引,则表示从首元素开始到该索引为止(不含)的区间;如果两个索引均未写出,则等同于整个数组,即 0..^0

上面代码清单 27 里的第 4 到第 6 个例子展示了这种写法。

最后值得一提的是,在 .NET/C# 中,索引和区间类型为一等类型。它们的应用不局限于访问数组。下面的高级主题将具体介绍。

5.3.2 更多数组方法

数组提供了更多方法来操作数组中的元素,其中包括 Sort()BinarySearch()Reverse()Clear() 等,如代码清单 28 所示。

代码清单 28 更多数组方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public static void Main()
{
    string[] languages = new string[]{
        "C#", "COBOL", "Java",
        "C++", "TypeScript", "Swift",
        "Python", "Lisp", "JavaScript"};

    System.Array.Sort(languages);

    string searchString = "COBOL";
    int index = System.Array.BinarySearch(
        languages, searchString);
    System.Console.WriteLine(
        "The wave of the future, "
        + $"{ searchString }, is at index { index }.");

    System.Console.WriteLine();
    System.Console.WriteLine(
        $"{ "First Element",-20 }\t{ "Last Element",-20 }");
    System.Console.WriteLine(
        $"{ "-------------",-20 }\t{ "------------",-20 }");
    System.Console.WriteLine(
            $"{ languages[0],-20 }\t{ languages[^1],-20 }");
    System.Array.Reverse(languages);
    System.Console.WriteLine(
            $"{ languages[0],-20 }\t{ languages[^1],-20 }");
    // Note this does not remove all items from the array
    // Rather it sets each item to the type's default value
    System.Array.Clear(languages, 0, languages.Length);
    System.Console.WriteLine(
            $"{ languages[0],-20 }\t{ languages[^1],-20 }");
    System.Console.WriteLine(
        $"After clearing, the array size is: { languages.Length }");
}

输出 2 展示了结果。

输出 2

1
2
3
4
5
6
7
8
The wave of the future, COBOL, is at index 2.

First Element           Last Element
-------------           ------------
C#                      TypeScript
TypeScript              C#

After clearing, the array size is: 9

这些方法通过 System.Array 类提供。大多数都一目了然,但注意以下两点:

  • 使用 BinarySearch() 方法前要先对数组进行排序。如果值不按升序排序,会返回不正确的索引。目标元素不存在会返回负值,在这种情况下,可应用按位求补运算符 ~index 返回比目标元素大的第一个元素的索引(如果有的话)。

  • Clear() 方法不删除数组元素,不将长度设为零。数组大小固定,不能修改。所以 Clear() 方法将每个元素都设为其默认值(null0false)。这解释了在调用 Clear() 之后输出数组时,Console.WriteLine() 为什么会创建一个空行。

5.3.3 数组实例成员

类似于字符串,数组也有不从数据类型而是从变量访问的实例成员。Length 就是一个例子,它通过数组变量来访问,而非通过类。其他常用实例成员还有 GetLength()RankClone()

获取特定维的长度不是用 Length 属性,而是用数组的 GetLength() 实例方法,调用时需指定返回哪一维的长度,如代码清单 29 所示。

代码清单 29 获取特定维的大小

1
2
3
4
5
6
7
public static void Main()
{
    bool[, ,] cells;
    cells = new bool[2, 3, 3];
    System.Console.WriteLine(cells.GetLength(0));  // Displays 2
    System.Console.WriteLine(cells.Rank);  // Displays 3
}

结果如输出 3 所示。

输出 3

1
2
2
3

输出 2,这是第一维的元素个数。

还可访问数组的 Rank 成员获取整个数组的维数。例如,cells.Rank 返回 3(见代码清单 29)。

将一个数组变量赋给另一个默认只拷贝数组引用,而不是数组中单独的元素。要创建数组的全新拷贝需使用数组的 Clone() 方法。该方法返回数组拷贝,修改新数组不会影响原始数组。

5.4 字符串作为数组使用

访问 string 类型的变量类似于访问字符数组。例如,可调用 palindrome[3] 获取 palindrome 字符串的第 4 个字符。

注意由于字符串不可变,所以不能向字符串中的特定位置赋值。所以,对于 palindrome 字符串来说,在 C# 中不允许,palindrome[3]='a' 这样的写法。

代码清单 30 使用数组访问符判断命令行上的参数是不是选项(选项的第一个字符是短划线)。

代码清单 30 查找命令行选项

1
2
3
4
5
6
7
8
public static void Main(string[] args)
{
    // ...
    if(args[0][0] == '-')
    {
        // This parameter is an option
    }
}
注意,第一个数组访问符 `[]` 获取字符串数组 `args` 的第一个元素,第二个数组访问符则获取该字符串的第一个字符。

上述代码等价于代码清单 31。

代码清单 31 查找命令行选项(简化版)

1
2
3
4
5
6
7
8
9
public static void Main(string[] args)
{
    // ...
    string arg = args[0];
    if(arg[0] == '-')
    {
        // This parameter is an option
    }
}

不仅可用数组访问符单独访问字符串中的字符,还可使用字符串的 ToCharArray() 方法将整个字符串作为字符数组返回,再用 System.Array.Reverse() 方法反转数组中的元素,如代码清单 32 所示,该程序判断字符串是不是回文。

代码清单 32 反转字符串

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public static void Main()
{
    string reverse, palindrome;
    char[] temp;

    System.Console.Write("Enter a palindrome: ");
    palindrome = System.Console.ReadLine();

    // Remove spaces and convert to lowercase
    reverse = palindrome.Replace(" ", "");
    reverse = reverse.ToLower();

    // Convert to an array
    temp = reverse.ToCharArray();

    // Reverse the array
    System.Array.Reverse(temp);

    // Convert the array back to a string and
    // check if reverse string is the same
    if(reverse == new string(temp))
    {
        System.Console.WriteLine(
            $"\"{palindrome}\" is a palindrome.");
    }
    else
    {
        System.Console.WriteLine(
            $"\"{palindrome}\" is NOT a palindrome.");
    }
}

输出 4 展示了结果。

输出 4

1
Enter a palindrome: "NeverOddOrEven" is a palindrome.

这个例子使用 new 关键字根据反转好的字符数组创建新字符串。

5.5 常见数组错误

前面描述了三种不同类型的数组:一维、多维和交错。一些规则和特点约束着数组的声明和使用。

表 3 总结了一些常见错误,有助于巩固对这些规则的了解。

阅读时最好先看“常见错误”一栏的代码(先不要看错误说明和改正后的代码),看自己是否能发现错误,检查你对数组及其语法的理解。

表 3 常见数组编程错误

常见数组编程错误

六、小结

本文首先讨论了两种不同的类型:值类型和引用类型。它们是 C# 程序员必须理解的基本概念,虽然读代码时可能看不太出来,但它们改变了类型的底层机制。

讨论数组前先讨论了两种语言构造。首先讨论了 C# 的可空修饰符(?),从 C# 2.0 开始,值类型变量允许被声明为可空,并从 C# 8.0 开始,引用类型变量也允许被声明为可空。

可空修饰符允许程序员明确指定一个变量的可空性。具体来说,可空修饰符让值类型变量的值可以为空,并可用于决定引用类型变量是否接受空值。

然后讨论了元组,并介绍如何用 C# 7.0 引入的新语法处理元组,同时不必显式地和底层数据类型打交道。

最后讨论了 C# 数组语法,并介绍了各种数组处理方式。许多开发者刚开始不容易熟练掌握这些语法。所以提供了一个常见错误列表,专门列出与数组编码有关的错误。

(完)


  1. 数据类型不可以为指针。 ↩︎