第2章
数据类型
以第1章的HelloWorld程序为基础,你对C#语言、它的结构、基本语法以及如何编写最简单的程序有了初步理解。本章讨论基本C#类型,继续巩固C#的基础知识。
本书到目前为止只用过少量内建数据类型,而且只是一笔带过。C#有大量类型,而且可合并类型来创建新类型。但C#有几种类型非常简单,是其他所有类型的基础,它们称为预定义类型(predefined type)或基元类型(primitive type)。C#语言的基元类型包括八种整数类型、两种用于科学计算的二进制浮点类型、一种用于金融计算的十进制浮点类型、一种布尔类型以及一种字符类型。本章将探讨这些基元数据类型,并更深入地研究string类型。
2.1 基本数值类型
C#基本数值类型都有关键字与之关联,包括整数类型、浮点类型以及decimal类型。decimal是特殊的浮点类型,能存储大数字而无表示错误。
2.1.1 整数类型
C#有八种整型,可选择最恰当的一种来存储数据以避免浪费资源。表2.1总结了每种整型。
表2.1(以及表2.2和表2.3)专门有一列给出了每种类型的完整名称,本章稍后会讲述后缀问题。C#所有基元类型都有短名称和完整名称。完整名称对应BCL(基类库)中的类型名称。该名称在所有语言中都相同,对程序集中的类型进行了唯一性标识。由于基元数据类型是其他类型的基础,所以C#为基元数据类型的完整名称提供了短名称(或称为缩写)。其实从编译器的角度看,两种名称完全一样,最终都生成相同的代码。事实上,检查最终生成的CIL代码,根本看不出源代码具体使用的名称。
C#支持完整BCL名称和关键字,造成开发人员对在什么时候用什么犯难。不要时而用这个,时而用那个,最好坚持用一种。C#开发人员一般用C#关键字。例如,用int而不是System.Int32,用string而不是System.String(甚至不要用String这种简化形式)。
坚持一致可能和其他设计规范冲突。例如,虽然规范说要用C#关键字取代BCL名称,但有时需维护公司遗留下来的风格相反的文件(或文件库)。这时只能维持原风格,而不是强行引入新风格,造成和原来的约定不一致。但话又说回来,如原有“风格”实际是不好的编码实践,有可能造成bug,严重妨碍维护,还是应尽量全盘纠正问题。
2.1.2 浮点类型(float和double)
浮点数精度可变。除非用分数表示时,分母恰好是2的整数次幂,否则用二进制浮点类型无法准确表示该数。将浮点变量设为0.1,很容易表示成0.099 999 999 999 999 999或者0.100 000 000 000 000 000 1(或者其他非常接近0.1的数)。另外,像阿伏伽德罗常数这样非常大的数字(6.02×1023),即使误差为108,结果仍然非常接近6.02×1023,因为原始数字实在是太大了。根据定义,浮点数的精度与它所代表的数字的大小成正比。准确地说,浮点数精度由有效数位的个数决定,而不是由一个固定值(比如±0.01)决定。
C#支持表2.2所示的两个浮点数类型。
为了方便理解,二进制数被转换成十进制数。如表2.2所示,二进制数位被转换成15个十进制数位,余数构成第16个十进制数位。具体地说,1.7×10307~1×10308的数只有15个有效数位。但1×10308~1.7×10308的数有16个。decimal类型的有效数位范围与此相似。
2.1.3 decimal类型
C#还提供了128位精度的十进制浮点类型(参见表2.3)。它适合大而精确的计算,尤其是金融计算。
和浮点数不同,decimal类型保证范围内的所有十进制数都是精确的。所以,对于decimal类型来说,0.1就是0.1,而不是近似值。不过,虽然decimal类型具有比浮点类型更高的精度,但它的范围较小。所以,从浮点类型转换为decimal类型可能发生溢出错误。此外,decimal的计算速度稍慢(虽然差别不大以至于完全可以忽略)。
2.1.4 字面值
字面值(literal value)表示源代码中的固定值。例如,假定希望用System.Console. WriteLine()输出整数值42和double值1.618 034(黄金分割比例),可以使用如代码清单2.1所示的代码。
输出2.1展示了代码清单2.1的结果。
默认情况下,输入带小数点的字面值,编译器自动把它解释成double类型。相反,整数值(没有小数点)通常默认为int,前提是该值不是太大,以至于无法用int来存储。如果值太大,编译器会把它解释成long。此外,C#编译器允许向非int的数值类型赋值,前提是字面值对于目标数据类型来说合法。例如,short s = 42和byte b = 77都是允许的。但这一点仅对字面值成立。不使用额外的语法,b = s就是非法的,具体参见2.6节。
前面说过C#有许多数值类型。在代码清单2.2中,一个字面值被直接放到C#代码中。由于带小数点的值默认为double类型,所以如输出2.2所示,结果是1.61803398874989(最后一个数字5丢失了),这符合我们预期的double值的精度。
输出2.2
要显示具有完整精度的数字,必须将字面值显式声明为decimal类型,这是通过追加一个M(或者m)来实现的,如代码清单2.3和输出2.3所示。
输出2.3
代码清单2.3的输出符合预期:1.618033988749895。注意d表示double,之所以用m表示decimal,是因为这种数据类型经常用于货币(monetary)计算。
还可以使用F和D作为后缀,将字面值分别显式声明为float或者double。对于整数数据类型,相应后缀是U、L、LU和UL。整数字面值的类型是像下面这样确定的:
- 无后缀的数值字面值按以下顺序解析成能存储该值的第一个数据类型:int,uint,long,ulong。
- 后缀U的数值字面值按以下顺序解析成能存储该值的第一个数据类型:uint,ulong。
- 后缀L的数值字面值按以下顺序解析成能存储该值的第一个数据类型:long,ulong。
- 如后缀是UL或LU,就解析成ulong类型。
注意字面值的后缀不区分大小写。但一般推荐大写,避免出现小写字母l和数字1不好区分的情况。
有时数字很大,很难辨认。为解决可读性问题,C# 7.0新增了对数字分隔符的支持。如代码清单2.4所示,可在书写数值字面值的时候用下划线(_)分隔。
本例将数字转换成千分位,但只是为了好看,C#不要求这样。可在数字第一位和最后一位之间的任何位置添加分隔符。事实上,还可以连写多个下划线。
有时可考虑使用指数记数法,避免在小数点前后写许多个0。指数记数法要求使用e或E中缀,在中缀字母后面添加正整数或者负整数,并在字面值最后添加恰当的数据类型后缀。例如,可将阿伏伽德罗常数作为float输出,如代码清单2.5和输出2.4所示。
输出2.4
前面讨论数值字面值的时候只使用了十进制值。C#还允许指定十六进制值。为值附加0x前缀,再添加希望使用的十六进制数字,如代码清单2.6所示。
输出2.5展示了结果。
输出2.5
注意,代码输出的仍然是42,而不是0x002A。
从C# 7.0起可将数字表示成二进制值,如代码清单2.7所示。
语法和十六进制语法相似,只是使用0b前缀(允许大写B)。参考第4章的初学者主题“位和字节”了解二进制记数法以及二进制和十进制之间的转换。注意从C# 7.2起,数字分隔符可以放到代表十六进制的x或者代表二进制的b后面(称为前导数字分隔符)。
2.2 更多基本类型
迄今为止只讨论了基本数值类型。C#还包括其他一些类型:bool、char和string。
2.2.1 布尔类型(bool)
另一个C#基元类型是布尔(Boolean)或条件类型bool。它在条件语句和表达式中表示真或假。允许的值包括关键字true和false。bool的BCL名称是System.Boolean。例如,为了在不区分大小写的前提下比较两个字符串,可以调用string.Compare()方法并传递bool字面值true,如代码清单2.10所示。
本例在不区分大小写的前提下比较变量option的内容和字面值/Help,结果赋给comparison。
虽然理论上一个二进制位足以容纳一个布尔类型的值,但bool实际大小是一个字节。
2.2.2 字符类型(char)
字符类型char表示16位字符,取值范围对应于Unicode字符集。从技术上说,char的大小和16位无符号整数(ushort)相同,后者取值范围是0~65 535。但char是C#的特有类型,在代码中要单独对待。
char的BCL名称是System.Char。
输入char字面值需要将字符放到一对单引号中,比如'A'。所有键盘字符都可这样输入,包括字母、数字以及特殊符号。
有的字符不能直接插入源代码,需进行特殊处理。首先输入反斜杠()前缀,再跟随一个特殊字符代码。反斜杠和特殊字符代码统称为转义序列(escape sequence)。例如,n代表换行符,而t代表制表符。由于反斜杠标志转义序列开始,所以要用\表示反斜杠字符。
代码清单2.11输出用'表示的一个单引号。
表2.4总结了转义序列以及字符的Unicode编码。
可用Unicode编码表示任何字符。为此,请为Unicode值附加u前缀。可用十六进制记数法表示Unicode字符。例如,字母A的十六进制值是0x41,代码清单2.12使用Unicode字符显示笑脸符号(:)),输出2.8展示了结果。
输出2.8
2.2.3 字符串
零或多个字符的有限序列称为字符串。C#的基本字符串类型是string,BCL名称是System.String。对于已熟悉了其他语言的开发者,string的一些特点或许会出乎预料。除了第1章讨论的字符串字面值格式,还允许使用逐字前缀@,允许用$前缀进行字符串插值。最后,string是一种“不可变”类型。
- 字面值
为了将字面值字符串输入代码,要将文本放入双引号(")内,就像HelloWorld程序中那样。字符串由字符构成,所以转义序列可嵌入字符串内。
例如,代码清单2.13显示两行文本。但这里没有使用System.Console.WriteLine(),而是使用System.Console.Write()来输出换行符n。输出2.9展示了结果。
输出2.9
双引号要用转义序列输出,否则会被用于定义字符串开始与结束。
C#允许在字符串前使用@符号,指明转义序列不被处理。结果是一个逐字字符串字面值(verbatim string literal),它不仅将反斜杠当作普通字符,还会逐字解释所有空白字符。例如,代码清单2.14的三角形会在控制台上原样输出,其中包括反斜杠、换行符和缩进。输出2.10展示了结果。
不使用@字符,这些代码甚至无法通过编译。事实上,即便将形状变成正方形,避免使用反斜杠,代码仍然不能通过编译,因为不能将换行符直接插入不以@符号开头的字符串中。
输出2.10
以@开头的字符串唯一支持的转义序列是"",代表一个双引号,不会终止字符串。
假如同一字符串字面值在程序集中多次出现,编译器在程序集中只定义字符串一次,且所有变量都指向它。这样一来,假如在代码中多处插入包含大量字符的同一个字符串字面值,最终的程序集只反映其中一个的大小。
- 字符串插值
如第1章所述,从C# 6.0起,字符串可用插值技术嵌入表达式。语法是在字符串前添加$符号,并在字符串中用一对大括号嵌入表达式。例如:
其中,firstName和lastName是引用了变量的简单表达式。注意逐字和插值可组合使用,但要先指定$,再指定@,例如:
由于是逐字字符串,所以按字符串的样子分两行输出。在大括号中换行则起不到换行效果:
上述代码在一行中输出字符串内容。注意此时仍需@符号,否则无法编译。
- 字符串方法
和System.Console类型相似,string类型也提供了几个方法来格式化、连接和比较字符串。
表2.5中的Format()方法具有与Console.Write()和Console.WriteLine()方法相似的行为。区别在于,string.Format()不是在控制台窗口中显示结果,而是返回结果。当然,有了字符串插值后,用到string.Format()的机会减少了很多(本地化时还是用得着)。但在幕后,字符串插值编译成CIL后都会使用string.Format()。
表2.5列出的都是静态方法。这意味着为了调用方法,需在方法名(例如concate)之前附加方法所在类型的名称(例如string)。但string类还有一些实例方法。实例方法不以类型名作为前缀,而是以变量名(或者对实例的其他引用)作为前缀。表2.6列出了部分实例方法和例子。
- 字符串格式化
无论使用string.Format()还是C# 6.0字符串插值来构造复杂格式的字符串,都可通过一组覆盖面广和复杂的格式化模式来显示数字、日期、时间、时间段等。例如,给定decimal类型的price变量,则string.Format("{0,20:C2}", price)或等价的插值字符串$"{price,20:C2}"都使用默认的货币格式化规则将decimal值转换成字符串。即添加本地货币符号,小数点后四舍五入保留两位,整个字符串在20个字符的宽度内右对齐(要左对齐就为20添加负号。另外,宽度不够只好超出)。因篇幅有限,无法详细讨论所有可能的格式字符串,请在MSDN文档中查阅string.Format()获取格式字符串的完整列表。
要在插值或格式化的字符串中添加实际的左右大括号,可连写两个大括号来表示。例如,插值字符串$"{{ {price:C2} }}"可生成字符串"{ $1,234.56 }"。
- 换行符
输出换行所需的字符由操作系统决定。Microsoft Windows的换行符是r和n这两个字符的组合,UNIX则是单个n。为消除平台之间的不一致,一个办法是使用System.Console.WriteLine()自动输出空行。为确保跨平台兼容性,可用System.Environment.NewLine代表换行符。换言之,System.Console.WriteLine("Hello World")和System.Console.Write("Hello World" + System.Environment.NewLine)等价。注意在Windows上,System.WriteLine()和System.Console.Write(System.Environment.NewLine)等价于System.Console.Write("rn")而非System.Console.Write("n")。总之,要依赖System.WriteLine()和System.Environment.NewLine而不是n来确保跨平台兼容。
- 字符串长度
判断字符串长度可以使用string的Length成员。该成员是只读属性。不能设置,调用时也不需要任何参数。代码清单2.16演示了如何使用Length属性,输出2.11是结果。
输出2.11
字符串长度不能直接设置,它是根据字符串中的字符数计算得到的。此外,字符串长度不能更改,因为字符串不可变。
- 字符串不可变
string类型的一个关键特征是它不可变(immutable)。可为string变量赋一个全新的值,但出于性能考虑,没有提供修改现有字符串内容的机制。所以,不可能在同一个内存位置将字符串中的字母全部转换为大写。只能在其他内存位置新建字符串,让它成为旧字符串大写字母版本,旧字符串在这个过程中不会被修改,如果没人引用它,会被垃圾回收。代码清单2.17展示了一个例子。
输出2.12展示了结果。
输出2.12
从表面上看,text.ToUpper()似乎应该将text中的字符转换成大写。但由于string类型不可变,所以text.ToUpper()不会进行这样的修改。相反,text.ToUpper()会返回新字符串,它需要保存到变量中,或直接传给System.Console.WriteLine()。代码清单2.18给出了纠正后的代码,输出2.13是结果。
输出2.13
如忘记字符串不可变的特点,很容易会在使用其他字符串方法时犯下和代码清单2.17相似的错误。
要真正更改text中的值,将ToUpper()的返回值赋回给text即可。如下例所示:
- System.Text.StringBuilder
如有大量字符串需要修改,比如要经历多个步骤来构造一个长字符串,可考虑使用System.Text.StringBuilder类型而不是string。StringBuilder包含Append()、AppendFormat()、Insert()、Remove()和Replace()等方法。虽然string也提供了其中一些方法,但两者关键的区别在于,在StringBuilder上,这些方法会修改StringBuilder本身中的数据,而不是返回新字符串。
2.3 null和void
与类型有关的另外两个关键字是null和void。null值表明变量不引用任何有效的对象。void表示无类型,或者没有任何值。
2.3.1 null
null可直接赋给字符串变量,表明变量为“空”,不指向任何位置。只能将null赋给引用类型、指针类型和可空值类型。目前只讲了string这一种引用类型,第6章将详细讨论类(类是引用类型)。现在只需知道引用类型的变量包含的只是对实际数据所在位置的一个引用,而不是直接包含实际数据。将变量设为null,会显式设置引用,使其不指向任何位置(空)。事实上,甚至可以检查引用是否为空。代码清单2.19演示了如何将null赋给string变量。
将null赋给引用类型的变量和根本不赋值是不一样的概念。换言之,赋值了null的变量已设置,而未赋值的变量未设置。使用未赋值的变量会造成编译时错误。
将null值赋给string变量和为变量赋值""也是不一样的概念。null意味着变量无任何值,而""意味着变量有一个称为“空白字符串”的值。这种区分相当有用。例如,编程逻辑可将为null的homePhoneNumber解释成“家庭电话未知”,将为""的homePhoneNumber解释成“无家庭电话”。
2.3.2 void
有时C#语法要求指定数据类型但不传递任何数据。例如,假定方法无返回值,C#就允许在数据类型的位置放一个void关键字。HelloWorld程序的Main方法声明就是一个例子。在返回类型的位置使用void意味着方法不返回任何数据,同时告诉编译器不要指望会有一个值。void本质上不是数据类型,它只是指出没有数据类型这一事实。
2.4 数据类型转换
考虑到各种CLI实现预定义了大量类型,加上代码也能定义无限数量的类型,所以类型之间的相互转换至关重要。会造成转换的最常见操作就是转型或强制类型转换(casting)。
考虑将long值转换成int的情形。long类型能容纳的最大值是9 223 372 036 854 775 808,int则是2 147 483 647。所以转换时可能丢失数据—long值可能大于int能容纳的最大值。有可能造成数据丢失或引发异常(因为转换失败)的任何转换都需要执行显式转型。相反,不会丢失数据,而且不会引发异常(无论操作数的类型是什么)的任何转换都可以进行隐式转型。
2.4.1 显式转型
C#允许用转型操作符执行转型。通过在圆括号中指定希望变量转换成的类型,表明你已确认在发生显式转型时可能丢失精度和数据,或者可能造成异常。代码清单2.20将一个long转换成int,而且显式告诉系统尝试这个操作。
程序员使用转型操作符告诉编译器:“相信我,我知道自己正在干什么。我知道值能适应目标类型。”只有程序员像这样做出明确选择,编译器才允许转换。但这也可能只是程序员“一厢情愿”。执行显式转换时,如数据未能成功转换,“运行时”还是会引发异常。所以,要由程序员负责确保数据成功转换,或提供错误处理代码来处理转换不成功的情况。
转型操作符不是万能药,它不能将一种类型任意转换为其他类型。编译器仍会检查转型操作的有效性。例如,long不能转换成bool。因为没有定义这种转换,所以编译器不允许。
2.4.2 隐式转型
有些情况下,比如从int类型转换成long类型时,不会发生精度的丢失,而且值不会发生根本性的改变,所以代码只需指定赋值操作符,转换将隐式地发生。换言之,编译器判断这样的转换能正常完成。代码清单2.24直接使用赋值操作符实现从int到long的转换。
如果愿意,在允许隐式转型的时候也可强制添加转型操作符,如代码清单2.25所示。
2.4.3 不使用转型操作符的类型转换
由于未定义从字符串到数值类型的转换,因此需要使用像Parse()这样的方法。每个数值数据类型都包含一个Parse()方法,允许将字符串转换成对应的数值类型。如代码清单2.26所示。
还可利用特殊类型System.Convert将一种类型转换成另一种。如代码清单2.27所示。
但System.Convert只支持少量类型,且不可扩展,允许从bool、char、sbyte、short、int、long、ushort、uint、ulong、float、double、decimal、DateTime和string转换到这些类型中的任何一种。
此外,所有类型都支持ToString()方法,可用它提供类型的字符串表示。代码清单2.28演示了如何使用该方法,输出2.17展示了结果。
输出2.17
大多数类型的ToString()方法只是返回数据类型的名称,而不是数据的字符串表示。只有在类型显式实现了ToString()的前提下才会返回字符串表示。最后要注意,完全可以编写自定义的转换方法,“运行时”的许多类都存在这样的方法。
高级主题:TryParse()
从C# 2.0(.NET 2.0)起,所有基元数值类型都包含静态TryParse()方法。该方法与Parse()非常相似,只是转换失败不是引发异常,而是返回false,如代码清单2.29所示。
输出2.18展示了结果。
输出2.18
上述代码从输入字符串解析到的值通过out参数(本例是number)返回。
注意从C# 7.0起不用先声明只准备作为out参数使用的变量。代码清单2.30展示了修改后的代码。
注意先写out再写数据类型。这样定义的number变量只有if语句内部的作用域,在外部不可用。
Parse()和TryParse()的关键区别在于,如转换失败,TryParse()不会引发异常。string到数值类型的转换是否成功,往往要取决于输入文本的用户。用户完全可能输入无法成功解析的数据。使用TryParse()而不是Parse(),就可以避免在这种情况下引发异常(由于预见到用户会输入无效数据,所以要想办法避免引发异常)。
2.5 小结
即使是有经验的程序员,也要注意C#引入的几个新编程构造。例如,本章探讨了用于精确金融计算的decimal类型。此外,本章还提到布尔类型bool不会隐式转换成整数,防止在条件表达式中误用赋值操作符。C#其他与众不同的地方还包括:允许用@定义逐字字符串,强迫字符串忽略转义字符;字符串插值,可在字符串中嵌入表达式;C#的string数据类型不可变。
下一章继续讨论数据类型。要讨论值类型和引用类型,还要讨论如何将数据元素组合成元组和数组。