带你读《C# 7.0本质论》之三:更多数据类型-阿里云开发者社区

开发者社区> 华章出版社> 正文

带你读《C# 7.0本质论》之三:更多数据类型

简介: 作为历年来深受各层次开发人员欢迎的C#权威指南,本书讨论了从C#3.0到7.0的最重要的C#特性,强调了现代编程模式,可帮助读者编写简洁、强大、健壮、安全和易于维护的C#代码。世界级C#专家Mark Michaelis对语言进行了全面而深入的探讨,提供了对关键C#7.0增强、C#7.0和.NET Core/.NET Standard的配合使用以及跨平台编译的专业论述。

点击查看第一章
点击查看第二章

第3章

更多数据类型

image.png

第2章讨论了所有C#预定义类型,简单提到了引用类型和值类型的区别。本章继续讨论数据类型,深入解释类型划分。
此外,本章还要讨论将数据元素合并成元组的细节,这是C# 7.0引入的一个功能。最后讨论如何将数据分组到称为数组的集合中。首先深入理解值类型和引用类型。

3.1 类型的划分

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

3.1.1 值类型

除了string,本书目前讲到的所有预定义类型都是值类型。值类型直接包含值。换言之,变量引用的位置就是内存中实际存储值的位置。因此,将一个值赋给变量1,再将变量1赋给变量2,会在变量2的位置创建值的拷贝,而不是引用变量1的位置。这进一步造成更改变量1的值不会影响变量2的值。图3.1对此进行了演示。number1引用内存中的特定位置,该位置包含值42。将number1的值赋给number2之后,两个变量都包含值42。但修改其中任何一个值都不会影响另一个值。

image.png

类似地,将值类型的实例传给Console.WriteLine()这样的方法也会生成内存拷贝。在方法内部对参数值进行的任何修改都不会影响调用函数中的原始值。由于值类型需要创建内存拷贝,因此定义时不要让它们占用太多内存(通常应该小于16字节)。

3.1.2 引用类型

相反,引用类型的变量存储对数据存储位置的引用,而不是直接存储数据。要去那个位置才能找到真正的数据。所以为了访问数据,“运行时”要先从变量中读取内存位置,再“跳转”到包含数据的内存位置。为引用类型的变量分配实际数据的内存区域称为堆(heap),如图3.2所示。
引用类型不像值类型那样要求创建数据的内存拷贝,所以拷贝引用类型的实例比拷贝大的值类型实例更高效。将引用类型的变量赋给另一个引用类型的变量,只会拷贝引用而不需要拷贝所引用的数据。事实上,每个引用总是系统的“原生大小”:32位系统拷贝32位引用,64位系统拷贝64位引用,以此类推。显然,拷贝对一个大数据块的引用,比拷贝整个数据块快得多。
由于引用类型只拷贝对数据的引用,所以两个不同的变量可引用相同的数据。如两个变量引用同一个对象,利用一个变量更改对象的字段,用另一个对象访问字段将看到更改结果。无论赋值还是方法调用都会如此。因此,如果在方法内部更改引用类型的数据,控制返回调用者之后,将看到更改后的结果。有鉴于此,如对象在逻辑上是固定大小、不可变的值,就考虑定义成值类型。如逻辑上是可引用、可变的东西,就考虑定义成引用类型。
除了string和自定义类(如Program),本书目前讲到的所有类型都是值类型。但大多数类型都是引用类型。虽然偶尔需要自定义的值类型,但更多的还是自定义的引用类型。

image.png

3.2 可空修饰符

一般不能将null值赋给值类型。这是因为根据定义,值类型不能包含引用,即使是对“什么都没有(nothing)”的引用。但在值本来就缺失的时候,这也会带来问题。例如在指定计数的时候,如计数未知,那么应该输入什么?一个可能的解决方案是指定特殊值,比如-1或int.MaxValue,但这些都是有效整数。我们倒希望能将null赋给该变量,因为null不是有效整数。
为声明能存储null的变量,要使用可空修饰符?。代码清单3.1演示了自C# 2.0引入的这个功能。

image.png
image.png

将null赋给值类型,这在数据库编程中尤其有用。在数据表中,经常出现值类型的列允许为空的情况。除非允许包含null值,否则在C#代码中检索这些列并将它们的值赋给对应字段会出问题。可空修饰符能妥善解决该问题。
隐式类型的局部变量
C# 3.0新增上下文关键字var来声明隐式类型的局部变量。声明变量时,如果能用确定类型的表达式初始化它,C# 3.0及以后的版本就允许变量的数据类型为“隐式的”,无须显式声明,如代码清单3.2所示。

image.png

上述代码清单和代码清单2.18相比有两处不同。首先,不显式声明为string类型,而是声明为var。最终的CIL代码没有区别。但var告诉编译器根据声明时所赋的值(System.Console.ReadLine())来推断数据类型。
其次,text和uppercase变量都在声明时初始化。不这样做会造成编译时错误。如前所述,编译器判断初始化表达式的数据类型并相应地声明变量,就好像程序员显式指定了类型。
虽然允许用var取代显式数据类型,但在数据类型已知的情况下最好不要用var。例如,还是应该将text和uppercase声明为string。这不仅可使代码更易理解,还相当于你亲自确认了等号右侧表达式返回的是你希望的数据类型。使用var变量时,右侧数据类型应显而易见,否则应避免用var声明变量。

image.png

image.png

高级主题:匿名类型
C# 3.0添加var的真正目的是支持匿名类型。匿名类型是在方法内部动态声明的数据类型,而不是通过显式的类定义来声明,如代码清单3.3所示。(第15章会深入讨论匿名类型。)

image.png

输出3.1展示了结果。
输出3.1

image.png

代码清单3.3演示了如何将匿名类型的值赋给隐式类型(var)局部变量。C# 3.0支持连接(关联)数据类型或将特定类型的大小缩减至更少数据元素,所以才配合设计了这种操作。但自从C# 7.0引入元组语法后,匿名类型几乎就用不着了。

3.3 元组

有时需要合并数据元素。例如,2017年全球最贫穷的国家是首都位于Lilongwe(利隆圭)的Malawi(马拉维),人均GDP 为226.50美元。利用目前讲过的编程构造,可将上述每个数据元素存储到单独的变量中,但它们相互无关联。换言之,看不出226.50和Malawi有什么联系。为解决该问题,第一个方案是在变量名中使用统一的后缀或前缀,第二个方案是将所有数据合并到一个字符串中,但缺点是需要解析字符串才能处理单独的数据元素。
C# 7.0提供了第三个方案:元组(tuple),允许在一个语句中完成所有变量的赋值,如下所示:

image.png

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

image.png
image.png

前四个例子虽然右侧是元组,但左侧仍然是单独的变量,只是用元组语法一起赋值。在这种语法中,两个或更多元素以逗号分隔,放到一对圆括号中进行组合。(我使用“元组语法”一词是因为编译器为左侧生成的基础数据类型技术上说并非元组。)结果是虽然右侧的值合并成元组,但在向左侧赋值的过程中,元组已被解构为它的组成部分。例2左边被赋值的变量是事先声明好的,但例1、3和4的变量是在元组语法中声明的。由于只是声明变量,所以命名和大小写应遵循第1章的设计规范,例如有一条是“要为局部变量使用camelCase风格命名。”
虽然隐式类型(var)在例4中用元组语法平均分配给每个变量声明,但这里的var绝不可以替换成显式类型(如string)。元组宗旨是允许每一项都有不同数据类型,所以为每一项都指定同一个显式类型名称跟这个宗旨冲突(即使类型真的一样,编译器也不允许指定显式类型)。
例5在左侧声明一个元组,将右侧的元组赋给它。注意元组含具名项,随后可引用这些名称来获取右侧元组中的值。这正是能在System.Console.WriteLine语句中使用countryInfo.Name、countryInfo.Capital和countryInfo.GdpPerCapita语法的原因。在左侧声明元组造成多个变量组合到单个元组变量(countryInfo)中。然后可利用元组变量来访问其组成部分。如第4章所述,这样的设计允许将该元组变量传给其他方法。那些方法能轻松访问元组中的项。
前面说过,用元组语法定义的变量应遵守camelCase大小写规则。但该规则并未得到彻底贯彻。有人提倡当元组的行为和参数相似时(类似于元组语法出现之前用于返回多个值的out参数),这些名称应使用参数命名规则。
另一个方案是PascalCase大小写,这是类型成员(属性、函数和公共字段,参见第5章和第6章的讨论)的命名规范。个人强烈推荐PascalCase规范,从而和C#/.NET成员标识符的大小写规范一致。但由于这并不是被广泛接受的规范,所以我在设计规范“考虑为所有元组项名称使用PascalCase大小写风格”中使用“考虑”而非“要”一词,

image.png

例6提供和例5一样的功能,只是右侧元组使用了具名元组项,左侧使用了隐式类型声明。但元组项名称会传入隐式类型变量,所以WriteLine语句仍可使用它们。当然,左侧可使用和右侧不同的元组项名称。C#编译器允许这样做但会显示警告,指出右侧元组项名称会被忽略,因为此时左侧的优先。不指定元组项名称,被赋值的元组变量中的单独元素仍可访问,只是名称是Item1,Item2,...,如例7所示。事实上,即便提供了自定义名称,ItemX名称始终都能使用,如例8所示。但在使用Visual Studio这样的IDE工具时,ItemX属性不会出现在“智能感知”的下拉列表中。这是好事,因为自己提供的名称理论上应该更好。如例9所示,可用下划线丢弃部分元组项的赋值,这称为弃元(discard)。
例10展示的元组项名称推断功能是自C# 7.1引入的。如本例所示,元组项名称可根据变量名(甚至属性名)来推断。
元组是在对象中封装数据的轻量级方案,有点像你用来装杂货的购物袋。和稍后讨论的数组不同,元组项的数据类型可以不一样,没有限制,只是它们由编译器决定,不能在运行时改变。另外,元组项数量也是在编译时硬编码好的。最后,不能为元组添加自定义行为(扩展方法不在此列)。如需和封装数据关联的行为,应使用面向对象编程并定义一个类,具体在第6章讲述。
高级主题:System.ValueTuple<...>类型
在表3.1的示例中,C#为赋值操作符右侧的所有元组实例生成的代码都基于一组泛型值类型(结构),例如System.ValueTuple。类似地,同一组System.ValueTuple<...>泛型值类型用于从例5开始的左侧数据类型。元组类型唯一包含的方法是跟比较和相等性测试有关的方法,这符合预期。
既然自定义元组项名称及其类型没有包含在System.ValueTuple<...>定义中,为什么每个自定义元组项名称都好像是System.ValueTuple<...>类型的成员,并能以成员的形式访问呢?让人(尤其是那些熟悉匿名类型实现的人)惊讶的是,编译器根本没有为那些和自定义名称对应的“成员”生成底层CIL代码,但从C#的角度看,又似乎存在这样的成员。
对于表3.1的所有具名元组例子,编译器在元组剩下的作用域中显然知道那些名称。事实上,编译器(和IDE)正是依赖该作用域通过项的名称来访问它们。换言之,编译器查找元组声明中的项名称,并允许代码访问还在作用域中的项。也正是因为这一点,IDE的“智能感知”不显示底层的ItemX成员。它们会被忽略,替换成显式命名的项。
编译器能判断作用域中的元组项名称,这一点还好理解,但如果元组要对外公开,比如作为另一个程序集中的一个方法的参数或返回值使用(另一个程序集可能看不到你的源代码),那么会发生什么?其实对于作为API(公共或私有)一部分的所有元组,编译器都会以“特性”(attribute)的形式将元组项名称添加到成员元数据中。例如,代码清单3.4展示了编译器为以下方法生成的CIL代码的C#形式:

image.png

另外要注意,如显式使用System.ValueTuple<...>类型,C#就不允许使用自定义的元组项名称。所以表3.1的例8如果将var替换成该类型,编译器会警告所有项的名称被忽略。
下面总结了和System.ValueTuple<...>有关的其他注意事项:

  • 共有8个泛型System.ValueTuple<...>。前7个最大支持七元组。第8个是System.ValueTuple,可为最后一个类型参数指定另一个ValueTuple,从而支持n元组。例如,编译器自动为8个参数的元组生成System.ValueTuple>作为底层实现类型。System.Value的存在只是为了补全,很少使用,因为C#元组语法要求至少两项。
  • 有一个非泛型System.ValueTuple类型作为元组工厂使用,提供了和所有ValueTuple元数对应的Create()方法。C# 7.0以后基本用不着Create()方法,因为像var t1 = ("Inigo Montoya", 42)这样的元组字面值实在太好用了。

C#程序员实际编程时完全可以忽略System.ValueTuple和System.ValueTuple。
还有一个元组类型是Microsoft .NET Framework 4.5引入的System.Tuple<...>。当时是想把它打造成核心元组实现。但在C#中引入元组语法时才意识到值类型更佳,所以量身定制了System.ValueTuple<...>,它在所有情况下都代替了System.Tuple<...>(除非要向后兼容依赖System.Tuple<...>的遗留API)。

3.4 数组

第1章没有提到的一种特殊的变量声明就是数组声明。利用数组声明,可在单个变量中存储同一种类型的多个数据项,而且可利用索引来单独访问这些数据项。C#的数组索引从零开始,所以我们说C#数组基于零。
初学者主题:数组
可用数组变量声明同类型多个数据项的集合。每一项都用名为索引的整数值进行唯一性标识。C#数组的第一个数据项使用索引0访问。由于索引基于零,应确保最大索引值比数组中的数据项总数小1。
初学者可将索引想象成偏移量。第一项距数组开头的偏移量是0,第二项偏移量是1,以此类推。
数组是几乎所有编程语言的基本组成部分,所有开发人员都应学习。虽然C#编程经常用到数组,初学者也确实应该掌握,但大多数程序现在都用泛型集合类型而非数组来存储数据集合。如只是为了熟悉数组的实例化和赋值,可略读下一节。表3.2列出了要注意的重点。泛型集合在第15章详细讲述。
此外,3.4.5节还会讲到数组的一些特点。

image.png
image.png

3.4.1 数组的声明

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

image.png

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

image.png

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

image.png

代码清单3.6定义了一个二维数组。第一维对应从左到右的单元格,第二维对应从上到下的单元格。可用更多逗号定义更多维,数组总维数等于逗号数加1。注意某一维上的元素数量不是变量声明的一部分。这是在创建(实例化)数组并为每个元素分配内存空间时指定的。

3.4.2 数组实例化和赋值

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

image.png

列表第一项成为数组的第一个元素,第二项成为第二个,以此类推。我们用大括号定义数组字面值。
只有在同一个语句中声明并赋值,才能使用代码清单3.7的赋值语法。声明后在其他地方赋值则需使用new关键字,如代码清单3.8所示。

image.png

自C# 3.0起不必在new后指定数组类型(string)。编译器能根据初始化列表中的数据类型推断数组类型。但方括号仍不可缺少。
C#支持将new关键字作为声明语句的一部分,所以可以像代码清单3.9那样在声明时赋值。

new关键字的作用是指示“运行时”为数据类型分配内存,即指示它实例化数据类型(本例是数组)。
数组赋值时只要使用了new关键字,就可在方括号内指定数组大小,如代码清单3.10所示。

image.png

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

image.png

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

  • 引用类型(比如string)初始化为null;
  • 数值类型初始化为0;
  • bool初始化为false;
  • char初始化为0。

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

image.png

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

image.png

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

image.png

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

image.png

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

image.png

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

image.png

本例初始化棋盘并显式指定每一维的大小。new表达式除了指定大小,还提供了数组的字面值。bool[,,]类型的字面值被分解成两个bool[,]类型的二维数组(大小均为3×3)。每个二维数组都由三个bool数组(大小为3)构成。
如前所述,多维数组(这种普通多维数组也称为“矩形数组”)每一维的大小必须一致。还可定义交错数组(jagged array),也就是由数组构成的数组。交错数组的语法稍微有别于多维数组,而且交错数组不需要具有一致的大小。所以,可以像代码清单3.17那样初始化交错数组。

image.png

交错数组不用逗号标识新维。相反,交错数组定义由数组构成的数组。代码清单3.17在int[]后添加[],表明数组元素是int[]类型的数组。
注意,交错数组要求为内部的每个数组都创建数组实例。这个例子使用new实例化交错数组的内部元素。遗失这个实例化部分会造成编译时错误。

3.4.3 数组的使用

使用方括号(称为数组访问符)访问数组元素。为获取第一个元素,要指定0作为索引。代码清单3.18将languages变量中的第5个元素(索引4)的值存储到变量language中。

image.png

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

image.png
image.png

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

image.png

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

image.png

1. 长度
像代码清单3.22那样获取数组长度。

image.png

数组长度固定,除非重新创建数组,否则不能随便更改。此外,越过数组的边界(或长度)会造成“运行时”报错。用无效索引(指向的元素不存在)来访问(检索或者赋值)数组时就会发生这种情况。例如在代码清单3.23中,用数组长度作为索引来访问数组就会出错。

image.png

image.png

一个好的实践是用Length取代硬编码的数组大小。例如,代码清单3.24修改了上个代码清单,在索引中使用了Length(减1获得最后一个元素的索引)。

image.png

为避免越界,应使用长度检查来验证数组长度大于0。访问数组最后一项时,要像代码清单3.24那样使用Length-1而不是硬编码的值。
Length返回数组中元素的总数。因此,如果你有一个多维数组,比如大小为2×3×3的bool cells[,,]数组,那么Length会返回元素总数18。
对于交错数组,Length返回外部数组的元素数—交错数组是“数组构成的数组”,所以Length只作用于外部数组,只统计它的元素数(也就是具体由多少个数组构成),而不管各内部数组共包含了多少个元素。
2. 更多数组方法
数组提供了更多方法来操作数组中的元素,其中包括Sort()、BinarySearch()、Reverse()和Clear()等,如代码清单3.25所示。

image.png
image.png

输出3.2展示了结果。
输出3.2

image.png

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

  • 使用BinarySearch()方法前要先对数组进行排序。如果值不按升序排序,会返回不正确的索引。目标元素不存在会返回负值,在这种情况下,可应用按位求补运算符~index返回比目标元素大的第一个元素的索引(如果有的话)。
  • Clear()方法不删除数组元素,不将长度设为零。数组大小固定,不能修改。所以Clear()方法将每个元素都设为其默认值(false、0或null)。这解释了在调用Clear()之后输出数组时,Console.WriteLine()为什么会创建一个空行。

image.png

3. 数组实例方法
类似于字符串,数组也有不从数据类型而是从变量访问的实例成员。Length就是一个例子,它通过数组变量来访问,而非通过类。其他常用实例成员还有GetLength()、Rank和Clone()。
获取特定维的长度不是用Length属性,而是用数组的GetLength()实例方法,调用时需指定返回哪一维的长度,如代码清单3.26所示。

image.png

结果如输出3.3所示。
输出3.3

image.png

输出2,这是第一维的元素个数。
还可访问数组的Rank成员获取整个数组的维数。例如,cells.Rank返回3。
将一个数组变量赋给另一个默认只拷贝数组引用,而不是数组中单独的元素。要创建数组的全新拷贝需使用数组的Clone()方法。该方法返回数组拷贝,修改新数组不会影响原始数组。

3.4.4 字符串作为数组使用

访问string类型的变量类似于访问字符数组。例如,可调用palindrome[3]获取palindrome字符串的第4个字符。注意由于字符串不可变,所以不能向字符串中的特定位置赋值。所以,对于palindrome字符串来说,在C#中不允许string,palindrome[3]='a'这样的写法。代码清单3.27使用数组访问符判断命令行上的参数是不是选项(选项的第一个字符是短划线)。

image.png

上述代码使用了要在第4章讲述的if语句。注意第一个数组访问符[]获取字符串数组args的第一个元素,第二个数组访问符则获取该字符串的第一个字符。上述代码等价于代码清单3.28。

image.png

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

image.png
image.png

输出3.4展示了结果。
输出3.4

image.png

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

3.4.5 常见数组错误

前面描述了三种不同类型的数组:一维、多维和交错。一些规则和特点约束着数组的声明和使用。表3.3总结了一些常见错误,有助于巩固对这些规则的了解。阅读时最好先看“常见错误”一栏的代码(先不要看错误说明和改正后的代码),看自己是否能发现错误,检查你对数组及其语法的理解。

image.png
image.png

3.5 小结

本章首先讨论了两种不同的类型:值类型和引用类型。它们是C#程序员必须理解的基本概念,虽然读代码时可能看不太出来,但它们改变了类型的底层机制。
讨论数组前先讨论了两种语言构造。首先讨论了C# 2.0引入的可空修饰符(?),它允许值类型存储空值。然后讨论了元组,并介绍如何用C# 7.0引入的新语法处理元组,同时不必显式地和底层数据类型打交道。
最后讨论了C#数组语法,并介绍了各种数组处理方式。许多开发者刚开始不容易熟练掌握这些语法。所以提供了一个常见错误列表,专门列出与数组编码有关的错误。
下一章讨论表达式和控制流程语句。本章最后出现过几次的if语句会一并讨论。

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

分享:

华章出版社

官方博客
官网链接