C# 8.0本质论
上QQ阅读APP看书,第一时间看更新

2.2 更多基本类型

迄今为止只讨论了基本数值类型。C#还包括其他一些类型:bool、char和string。

2.2.1 布尔类型

另一个C#基元类型是布尔(Boolean)或条件类型bool。它在条件语句和表达式中表示真或假。允许的值包括关键字true和false。bool的BCL名称是System.Boolean。例如,为了在不区分大小写的前提下比较两个字符串,可以调用string.Compare()方法并传递bool字面值true,如代码清单2.10所示。

代码清单2.10 不区分大小写比较两个字符串

本例在不区分大小写的前提下比较变量option的内容和字面值/Help,结果赋给comparison。

虽然理论上一个二进制位足以容纳一个布尔类型的值,但bool实际大小是一个字节。

2.2.2 字符类型

字符类型char表示16位字符,取值范围对应于Unicode字符集。从技术上说,char的大小和16位无符号整数(ushort)相同,后者取值范围是0~65 535。但char是C#的特有类型,在代码中要单独对待。

char的BCL名称是System.Char。

初学者主题:Unicode标准

Unicode是一个国际性标准,用来表示大多数语言中的字符。它使得计算机系统可以构建本地化应用程序,更加方便地显示不同语言文化的语言和特色字符。

高级主题:16位不足以表示所有Unicode字符

令人遗憾的是,不是所有Unicode字符都能用一个16位char表示。刚开始提出Unicode的概念时,它的设计者以为16位已经足够。但随着支持的语言越来越多,才发现当初的假定是错误的。结果是,一些Unicode字符要由一对称为“代理项”的char构成,总共32位。

输入char字面值需要将字符放到一对单引号中,比如'A'。所有键盘字符都可这样输入,包括字母、数字以及特殊符号。

有的字符不能直接插入源代码,需进行特殊处理。首先输入反斜杠(\)前缀,再跟随一个特殊字符代码。反斜杠和特殊字符代码统称为转义序列(escape sequence)。例如,\n代表换行符,而\t代表制表符。由于反斜杠标志转义序列开始,所以要用\\表示反斜杠字符。

代码清单2.11输出用\'表示的一个单引号。

代码清单2.11 使用转义序列显示单引号

表2.4总结了转义序列以及字符的Unicode编码。

表2.4 转义字符

可用Unicode编码表示任何字符。为此,请为Unicode值附加\u前缀。可用十六进制记数法表示Unicode字符。例如,字母A的十六进制值是0x41,代码清单2.12使用Unicode字符显示笑脸符号(:)),输出2.8展示了结果。

代码清单2.12 使用Unicode编码显示笑脸符号

输出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.13 用字符\n插入换行符

输出2.9

双引号要用转义序列输出,否则会被用于定义字符串开始与结束。

C#允许在字符串前使用@符号,指明转义序列不被处理。结果是一个逐字字符串字面值(verbatim string literal),它不仅将反斜杠当作普通字符,还会逐字解释所有空白字符。例如,代码清单2.14的三角形会在控制台上原样输出,其中包括反斜杠、换行符和缩进。输出2.10展示了结果。

不使用@字符,这些代码甚至无法通过编译。事实上,即便将形状变成正方形,避免使用反斜杠,代码仍然不能通过编译,因为不能将换行符直接插入不以@符号开头的字符串中。

代码清单2.14 使用逐字字符串字面值来显示三角形

输出2.10

以@开头的字符串唯一支持的转义序列是"",代表一个双引号,不会终止字符串。

语言对比:C++——在编译时连接字符串

和C++不同,C#不自动连接字符串字面值。例如,不能像下面这样指定字符串字面值:

必须用+操作符连接(但如果编译器能在编译时计算结果,最终的CIL代码将包含连接好的字符串)。

假如同一字符串字面值在程序集中多次出现,编译器在程序集中只定义字符串一次,且所有变量都指向它。这样一来,假如在代码中多处插入包含大量字符的同一个字符串字面值,最终的程序集只反映其中一个的大小。

字符串插值

如第1章所述,从C# 6.0起,字符串可用插值技术嵌入表达式。语法是在字符串前添加$符号,并在字符串中用一对大括号嵌入表达式。例如:

其中,firstName和lastName是引用了变量的简单表达式。

注意逐字和插值可组合使用,但要先指定$,再指定@(或者在C# 8.0开头的@$"..."),例如:

由于是逐字字符串,所以按字符串的样子分两行输出。在大括号中换行则起不到换行效果:

上述代码在一行中输出字符串内容。注意此时仍需@符号,否则无法编译。

高级主题:理解字符串插值的内部工作原理

字符串插值是调用string.Format()方法的语法糖。例如以下语句:

会被转换成以下形式的C#代码:

这就和复合字符串一样实现了某种程度的本地化支持,而且不会因为字符串造成编译后代码注入。

字符串方法

和System.Console类型相似,string类型也提供了几个方法来格式化、连接和比较字符串。

表2.5中的Format()方法具有与Console.Write()和Console.WriteLine()方法相似的行为。区别在于,string.Format()不是在控制台窗口中显示结果,而是返回结果。当然,有了字符串插值后,用到string.Format()的机会减少了很多(本地化时还是用得着)。但在幕后,字符串插值编译成CIL后都会转换为调用string.Concat()和string.Format()来处理字符串字面值。

表2.5 string的静态方法

表2.5列出的都是静态方法。这意味着为了调用方法,需在方法名(例如Concate)之前附加方法所在类型的名称(例如string)。但string类还有一些实例方法。实例方法不以类型名作为前缀,而是以变量名(或者对实例的其他引用)作为前缀。表2.6列出了部分实例方法和例子。

表2.6 string的实例方法

高级主题:using和using static指令

之前调用静态方法需附加命名空间和类型名前缀。例如在调用System.Console.WriteLine时,虽然调用的方法是WriteLine(),且当前上下文无其他同名方法,但仍然必须附加命名空间(System)和类型名(Console)前缀。可利用C# 6.0新增的using static指令避免这些前缀,如代码清单2.15所示。

代码清单2.15 using static指令

using static指令需添加到文件顶部[1]。每次使用System.Console类的成员,都不再需要添加System.Console前缀。相反,直接使用其中的方法名即可。注意该指令只支持静态方法和属性,不支持实例成员。

类似地,using指令用于省略命名空间前缀(例如System)。和using static不同,using作用于它所在的整个文件(或命名空间),而非仅作用于静态成员。使用using指令,不管在实例化时,在调用静态方法时,还是在使用C# 6.0新增的nameof操作符时,都可省略对命名空间的引用。

字符串格式化

无论使用string.Format()还是C# 6.0字符串插值来构造复杂格式的字符串,都可以通过一组覆盖面广且复杂的格式化模式组合来显示数字、日期、时间、时间段等。例如,给定decimal类型的price变量,则string.Format("{0,20:C2}", price)和等价的插值字符串$"{price,20:C2}"都使用默认的货币格式化规则将decimal值转换成字符串。即添加本地货币符号,小数点后四舍五入保留两位,整个字符串在20个字符的宽度内右对齐。因篇幅有限,无法详细讨论所有可能的格式字符串,请在MSDN文档中查阅“composite formatting”(组合格式化)(http://itl.tc/CompositeFormatting)获取字符串格式化的完整说明。

要在插值或格式化的字符串中添加实际的左右大括号,可连写两个大括号来表示。例如,插值字符串$"{{{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("\r\n")而非System.Console.Write("\n")。总之,要依赖System.WriteLine()和System.Environment.NewLine而不是\n来确保跨平台兼容。

设计规范

·要依赖System.WriteLine()和System.Environment.NewLine而不是\n来确保跨平台兼容。

高级主题:C#属性

下一节提到的Length成员实际不是方法,因为调用时没有使用圆括号。Length是string的属性(property),C#语法允许像访问成员变量(在C#中称为字段)那样访问属性。换言之,属性定义了称为赋值方法(setter)和取值方法(getter)的特殊方法,但用字段语法访问那些方法。

研究属性的底层CIL实现,发现它编译成两个方法:set_<PropertyName>和get_<PropertyName>。但这两个方法不能直接从C#代码中访问,只能通过C#属性构造来访问。第6章更详细地讨论了属性。

字符串长度

判断字符串长度可以使用string的Length成员。该成员是只读属性。不能设置,调用时也不需要任何参数。代码清单2.16演示了如何使用Length属性,输出2.11是结果。

代码清单2.16 使用string的Length成员

输出2.11

字符串长度不能直接设置,它是根据字符串中的字符数计算得到的。此外,字符串长度不能更改,因为字符串不可变

字符串不可变

string类型的一个关键特征是它不可变(immutable)。可为string变量赋一个全新的值,但出于性能考虑,没有提供修改现有字符串内容的机制。所以,不可能在同一个内存位置将字符串中的字母全部转换为大写。只能在其他内存位置新建字符串,让它成为旧字符串大写字母版本,旧字符串在这个过程中不会被修改。代码清单2.17展示了一个例子。

代码清单2.17 错误,string不可变

输出2.12展示了结果。

输出2.12

从表面上看,text.ToUpper()似乎应该将text中的字符转换成大写。但由于string类型不可变,所以text.ToUpper()不会进行这样的修改。相反,text.ToUpper()会返回新字符串,它需要保存到变量中,或直接传给System.Console.WriteLine()。代码清单2.18给出了纠正后的代码,输出2.13是结果。

代码清单2.18 正确的字符串处理

输出2.13

如忘记字符串不可变的特点,很容易会在使用其他字符串方法时犯下和代码清单2.17相似的错误。

要真正更改text中的值,将ToUpper()的返回值赋回给text即可。如下例所示:

如有大量字符串需要修改,比如要经历多个步骤来构造一个长字符串,可考虑使用System.Text.StringBuilder类型而不是string。StringBuilder包含Append()、AppendFormat()、Insert()、Remove()和Replace()等方法。虽然string也提供了其中一些方法,但两者关键的区别在于,在StringBuilder上,这些方法会修改StringBuilder本身中的数据,而不是返回新字符串。

2.2.4 null和void

与类型有关的另外两个关键字是null和void。null值表明变量不引用任何有效的对象[2]。void表示无类型,或者没有任何值。

null

null也可以用作“文字”的一种类型,表明变量为“空”,不指向任何位置。将一个变量设为null,会明确地将其设置为“空”(即不指向任何数据)。事实上,甚至可以检查引用是否为空。

将null赋给引用类型的变量和根本不赋值是不一样的概念。换言之,赋值了null的变量已设置,而未赋值的变量未设置。使用未赋值的变量会造成编译时错误。

将null值赋给string变量和为变量赋值""也是不一样的概念。null意味着变量无任何值,而""意味着变量有一个称为“空白字符串”的值。这种区分相当有用。例如,编程逻辑可将为null的homePhoneNumber解释成“家庭电话未知”,将为""的homePhoneNumber解释成“无家庭电话”。

高级主题:可空修饰符

声明一个变量时,在其名称后面加一个问号,则表示该变量可以被设置为null。这便是可空修饰符。代码清单2.19演示了使用可空修饰符声明一个整型变量,并为其设置null值。

代码清单2.19 将null赋给整型变量

到目前为止,我们已经介绍了C#里的很多数据类型。但是在C# 2.0引入可空修饰符之前,前面提到过的任何数据类型都无法被设置为null,唯一的例外是string。这是因为string是引用类型,而其他的都是值类型。关于值类型和引用类型的更多知识将在第3章详细讲述。

此外,在C# 8.0之前,可空修饰符不能用于引用类型(比如string)变量的声明中。这是因为引用类型变量默认可被赋值为null,所以可空修饰符对于引用类型变量是多余的。

高级主题:可空引用类型

在C# 8.0之前,因为引用类型变量默认可被赋值为null,所以那时没有“可空引用类型”的概念。然而从C# 8.0开始,这一默认行为变为了可配置行为。声明引用类型变量时可以使用可空修饰符,将变量声明为可空;或者不使用该修饰符,将变量默认地声明为不可复制为null。这样一来,在C# 8.0里便有了“可空引用类型”的概念。当这一概念被启用时,将没有可空修饰符的变量设置为null将会产生警告信息。

目前我们已经介绍过唯一引用类型为string。若要声明一个可空的string变量,可以使用类似“string? homeNumber=null;”的写法。

在C# 8.0或后续版本中,若要启用“可空引用类型”的概念,需要在声明可空引用类型变量之前的任意位置放置“#nullable enable”语句。

名为void的“类型”

有时C#语法要求指定数据类型但不传递任何数据。例如,假定方法无返回值,C#就允许在数据类型的位置放一个void关键字。HelloWorld程序(代码清单1.1)的Main方法声明就是一个例子。在返回类型的位置使用void意味着方法不返回任何数据,同时告诉编译器不要指望会有一个值。void本质上不是数据类型,它只是指出没有数据类型这一事实。

语言对比:C++

无论是C++还是C#,void都有两个含义:标记方法不返回任何数据,以及代表指向未知类型的存储位置的一个指针。C++程序经常使用void**这样的指针类型。C#也可用相同的语法表示指向未知类型的存储位置的指针。但这种用法在C#中比较罕见,一般仅在需要与非托管代码库进行互操作时才会用到。

语言对比:Visual Basic——返回void相当于定义子程序

在Visual Basic中,与C#的“返回void”等价的是定义子程序(Sub/End Sub)而非返回值的函数。

[1] 放在命名空间声明之前。

[2] 英文单词null的含义为“空”,因此本书将它的衍生词nullable译作“可空”,例如将nullable reference type译作“可空引用类型”。——译者注