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

4.1 操作符

第2章学习了预定义数据类型,本节学习如何将操作符应用于这些数据类型来执行各种计算。例如,可对声明好的变量执行计算。

初学者主题:操作符

操作符对称为操作数的值(或变量)执行数学或逻辑运算或操作来生成新值(称为结果)。例如,代码清单4.1有两个操作数4和2,它们被减法操作符(-)组合到一起,结果赋给变量difference。

代码清单4.1 简单的操作符例子

通常将操作符划分为三大类:一元、二元和三元,分别对应着需要一个、两个和三个操作数的操作符。此外,有些操作符以符号的形式呈现,例如+、-、?.或者??等,而另一些操作符则为关键词,例如default和is。本节讨论最基本的一元和二元操作符,三元操作符将在本章后面简略介绍。

4.1.1 一元正负操作符

有时需要改变数值的正负号。这时一元负操作符(-)就能派上用场。例如,代码清单4.2将当前的美国国债金额变成负值,指明这是欠款。

代码清单4.2 指定负值[1]

使用一元负操作符等价于从零减去操作数。一元正操作符(+)对值几乎[2]没有影响。它在C#语言中是多余的,只是出于对称性的考虑才加进来。

4.1.2 二元算术操作符

二元操作符要求两个操作数。C#为二元操作符使用中缀记号法:操作符在左右操作数之间。除赋值之外的每个二元操作符的结果必须以某种方式使用(例如作为另一个表达式的操作数)。

语言对比:C++——仅有操作符的语句

和上面提到的规则相反,C++甚至允许像4+5;这样的二元表达式作为独立语句使用。在C#中,只有赋值、调用、递增、递减、await和对象创建表达式才能作为独立语句使用。

代码清单4.3是使用二元操作符(更准确地说是二元算术操作符)的例子。算术操作符的每一边都有一个操作数,计算结果赋给一个变量。除了二元减法操作符(-),其他二元算术操作符还有加法(+)、除法(/)、乘法(*)和取余操作符(%,有时也称为取模操作符)。

代码清单4.3 使用二元操作符

输出4.1展示了结果。

输出4.1

在突出显示的赋值语句中,除法和取余操作先于赋值发生。操作符的执行顺序取决于它们的优先级结合性。迄今为止用过的操作符的优先级如下:

1.*、/和%具有最高优先级。

2.+和-具有较低优先级。

3.=在6个操作符中优先级最低。

所以上例中的语句行为符合预期,除法和取余先于赋值进行。

如忘记对二元操作符的结果进行赋值,会出现如输出4.2所示的编译错误。

输出4.2

初学者主题:圆括号、结合性、优先级和求值

包含多个操作符的表达式可能让人分不清楚每个操作符的操作数。例如在表达式x+y*z中,很明显表达式x是+操作符的操作数,z是*操作符的操作数。但y是+还是*的操作数?

圆括号清楚地将操作数与操作符关联。如希望y是被加数,可以写为(x+y)*z。如希望是被乘数,可以写为x+(y*z)。

但包含多个操作符的表达式不一定非要添加圆括号。编译器能根据结合性和优先级判断执行顺序。结合性决定相似操作符的执行顺序,优先级决定不相似操作符的执行顺序。

二元操作符可以“左结合”或“右结合”,具体取决于“位于中间”的表达式是从属于左边的操作符,还是从属于右边的操作符。例如,a-b-c被判定为(a-b)-c,而不是a-(b-c)。这是因为减法操作符为“左结合”。C#的大多数操作符都是左结合的,赋值操作符右结合。

对于不相似操作符,要根据操作符优先级决定位于中间的操作数从属于哪一边。例如,乘法优先级高于加法,所以表达式x+y*z求值为x+(y*z)而不是(x+y)*z。

但通常好的实践是坚持用圆括号增强代码可读性,即使这样“多余”。例如在执行摄氏-华氏温度换算时,(c*9.0/5.0)+32.0比c*9.0/5.0+32.0更易读,即使完全可以省略圆括号。

很明显,相邻的两个操作符,高优先级的先于低优先级的执行。例如x+y*z是先乘后加,乘法结果是加法操作符的右操作数。但要注意,优先级和结合性只影响操作符自身的执行顺序,不影响操作数的求值顺序。

在C#中,操作数总是从左向右求值。在包含三个方法调用的表达式中,比如A()+B()*C(),首先求值A(),然后B(),然后C(),然后乘法操作符决定乘积,最后加法操作符决定和。不能因为C()是乘法操作数,A()是加法操作数,就认为C()先于A()发生。

设计规范

·要用圆括号增加代码的易读性,尤其是在操作符优先级不是让人一目了然的时候。

语言对比:C++——操作数求值顺序

和上述规则相反,C++规范允许不同的实现自行选择操作数求值顺序。对于A()+B()*C()这样的表达式,不同的C++编译器可选择以不同顺序求值函数调用,只要乘积是某个被加数即可。例如,可以选择先求值B(),再A(),再C(),再乘法,最后加法。

将加法操作符用于字符串

操作符也可用于非数值类型。例如,可用加法操作符连接两个或更多字符串,如代码清单4.4所示。

代码清单4.4 将二元操作符应用于非数值类型

输出4.3展示了结果。

输出4.3

由于不同语言文化的语句结构迥异,所以开发者注意不要对准备本地化的字符串使用加法操作符。类似地,虽然可用C# 6.0的字符串插值技术在字符串中嵌入表达式,但其他语言的本地化仍然要求将字符串移至某个资源文件,这使字符串插值没了用武之地。在这种情况下,复合格式化更理想。

设计规范

·要在字符串可能会本地化时用复合格式化而不是加法操作符来连接字符串。

在算术运算中使用字符

第2章介绍char类型时提到,虽然char类型存储的是字符而不是数字,但它是“整数的类型”(意味着基于整数)。可以和其他整型一起参与算术运算。但不是基于存储的字符来解释char类型的值,而是基于它的基础值。例如,数字3用Unicode值0x33(十六进制)表示,换算成十进制值是51。数字4用Unicode值0x34表示,或十进制52。如代码清单4.5所示,3和4相加获得十六进制值0x67,即十进制103,等价于字母g。

代码清单4.5 将加法操作符应用于char数据类型

输出4.4展示了结果。

输出4.4

可利用char类型的这个特点判断两个字符相距多远。例如,字母f与字母c有3个字符的距离。为获得这个值,可以用字母f减去字母c,如代码清单4.6所示。

代码清单4.6 判断两个字符之间的“距离”

输出4.5展示了结果。

输出4.5

浮点类型的特殊性

浮点类型float和double有一些特殊性,比如它们处理精度的方式。本节通过一些实例帮助认识浮点类型的特殊性。

float具有7位精度,能容纳值1 234 567和值0.123 456 7。但这两个float值相加的结果会被取整为1 234 567,因为小数部分超过了float能容纳的7位有效数字。这种类型的取整有时是致命的,尤其是在执行重复性计算或检查相等性的时候(参见稍后的“高级主题:浮点类型造成非预期的不相等”)。

二进制浮点类型内部存储二进制分数而不是十进制分数。所以一次简单的赋值就可能引发精度问题,例如double number=140.6F。140.6的准确值是分数703/5,但分母不是2的整数次幂,所以无法用二进制浮点数准确表示。实际分母是用float的16位有效数字能表示的最接近的一个值。

由于double能容纳比float更精确的值,所以C#编译器实际将该表达式求值为double number=140.600006103516,这是最接近140.6F的二进制分数,但表示成double比140.6稍大。

设计规范

·避免在需要准确的十进制小数算术运算时使用二进制浮点类型,应使用decimal浮点类型。

高级主题:浮点类型造成非预期的不相等

比较两个值是否相等,浮点类型的不准确性可能造成严重后果。有时本应相等的值被错误地判断为不相等,如代码清单4.7所示。

代码清单4.7 浮点类型的不准确性造成非预期的不相等

输出4.6展示了结果。

输出4.6

Assert()方法在实参求值为false时提醒开发者“断言失败”[3]。但上述代码中的所有Assert()语句都求值为true。所以,虽然值理论上应该相等,但由于浮点数的不准确性,它们被错误地判断为不相等。

设计规范

·避免将二进制浮点类型用于相等性条件式。要么判断两个值之差是否在容差范围之内,要么使用decimal类型。

浮点类型还有其他特殊性。例如,整数除以零理论上应出错。int和decimal等数据类型确实会如此。但float和double允许结果是特殊值,如代码清单4.8和输出4.7所示。

代码清单4.8 浮点数被零除的结果是NaN

输出4.7

数学中的特定算术运算是未定义的,例如0除以它自己。在C#中,浮点0除以0会得到“Not a Number”(非数字)。打印这样的一个数实际输出的就是NaN。类似地,获取负数的平方根(System.Math.Sqrt(-1))也会得到NaN。

浮点数可能溢出边界。例如,float的上边界约为3.4×1038。一旦溢出,结果数就会存储为“正无穷大”(∞)。类似地,float的下边界是–3.4×1038,溢出会得到“负无穷大”(–∞)。代码清单4.9分别生成正负无穷大,输出4.8展示了结果。

代码清单4.9 溢出float值边界

输出4.8

进一步研究浮点数,发现它能包含非常接近零但实际不是零的值。如值超过float或double类型的阈值,值可能表示成“负零”或者“正零”,具体取决于数是负还是正,并在输出中表示成-0或者0。

4.1.3 复合赋值操作符

第1章讨论了简单的赋值操作符(=),它将操作符右边的值赋给左边的变量。复合赋值操作符将常见的二元操作符与赋值操作符结合。以代码清单4.10为例。

代码清单4.10 常见的递增计算

在上述赋值运算中,首先计算x+2,结果赋回x。由于这种形式的运算相当普遍,所以专门有一个操作符集成了计算和赋值。+=操作符使左边的变量递增右边的值,如代码清单4.11所示。

代码清单4.11 使用+=操作符

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

还有其他复合赋值操作符提供了类似的功能。赋值操作符还可以和减法、乘法、除法和取余操作符合并,如代码清单4.12所示。

代码清单4.12 其他复合赋值操作符

4.1.4 递增和递减操作符

C#提供了特殊的一元操作符来实现计数器的递增和递减。递增操作符(++)每次使一个变量递增1。所以,代码清单4.13每行代码的作用都一样。

代码清单4.13 递增操作符

类似地,递减操作符(--)使变量递减1。所以,代码清单4.14每行代码的作用都一样。

代码清单4.14 递减操作符

初学者主题:循环中的递减示例

递增和递减操作符在循环(比如稍后要讲到的while循环)中经常用到。例如,代码清单4.15使用递减操作符逆向遍历字母表的每个字母。

代码清单4.15 降序显示每个字母的ASCII值

输出4.9展示了结果。

输出4.9

递增和递减操作符用于控制特定操作的执行次数。本例还要注意递减操作符可以应用于字符(char)数据类型。只要数据类型支持“下一个值”和“上一个值”的概念,就适合使用递增和递减操作符。

以前说过,赋值操作符首先计算要赋的值,再执行赋值。赋值操作符的结果是所赋的值。递增和递减操作符与此相似。也是计算要赋的值,执行赋值,再返回结果值。所以赋值操作符可以和递增或递减操作符一起使用。但如果不仔细,可能得到令人困惑的结果。如代码清单4.16和输出4.10所示。

代码清单4.16 使用后缀递增操作符

输出4.10

赋给result的是count递增前的值。递增或递减操作符的位置决定了所赋的值是操作数计算之前还是之后的值。如希望result的值是递增/递减后的结果,需要将操作符放在想递增/递减的变量之前,如代码清单4.17所示。

代码清单4.17 使用前缀递增操作符

输出4.11展示了代码清单4.17的结果。

输出4.11

本例的递增操作符出现在操作数之前,所以表达式生成的结果是递增后赋给变量的值。假定count为123,那么++count将124赋给count,生成的结果是124。相反,后缀形式count++将124赋给count,生成的结果是递增前count所容纳的值,即123。无论后缀还是前缀形式,变量count都会在表达式的结果生成之前递增,区别在于结果选择哪个值。代码清单4.18和输出4.12展示了前缀和后缀操作符在行为上的差异。

代码清单4.18 对比前缀和后缀递增操作符

输出4.12

在代码清单4.18中,递增和递减操作符相对于操作数的位置影响了表达式的结果。前缀操作符的结果是变量递增/递减之后的值,而后缀操作符的结果是变量递增/递减之前的值。在语句中使用这些操作符应该小心。若心存疑虑,最好独立使用这些操作符(自成一个语句)。这样不仅代码更易读,还可保证不犯错。

语言对比:C++——由实现定义的行为

以前说过,C++的不同实现可任意选择表达式中的操作数的求值顺序,而C#总是从左向右。类似地,在C++中实现递增和递减时,可按任何顺序执行递增和递减。例如在C++中,对于M(x++, x++)这样的调用,假定x初值是1,那么既可以调用M(1,2),也可以调用M(2,1),具体由编译器决定。C#则总是调用M(1,2),因为C#做出了两点保证:第一,传给调用的实参总是从左向右计算;第二,总是先将已递增的值赋给变量,再使用表达式的值。这两点C++都不保证。

设计规范

·避免递增和递减操作符的让人迷惑的用法。

·在C、C++和C#之间移植使用了递增和递减操作符的代码要小心。C和C++的实现遵循的不一定是和C#相同的规则。

高级主题:线程安全的递增和递减

虽然递增和递减操作符简化了代码,但两者执行的都不是原子级别的运算。在操作符执行期间,可能发生线程上下文切换,造成竞争条件。可用lock语句防止出现竞争条件。但对于简单递增和递减运算,一个代价没有那么高的替代方案是使用由System.Threading.Interlocked类提供的线程安全方法Increment()和Decrement()。这两个方法依赖处理器的功能来执行快速和线程安全的递增和递减运算(详情参见第19章)。

4.1.5 常量表达式和常量符号

第3章讨论了字面值,或者说直接嵌入代码的值。可用操作符将多个字面值合并到常量表达式中。根据定义,常量表达式是C#编译器能在编译时求值的表达式(而不是在运行时才能求值),因为其完全由常量操作数构成。然后,可用常量表达式初始化常量符号,从而为常量值分配名称(类似于局部变量为存储位置分配名称)。例如,可用常量表达式计算一天中的秒数,结果赋给一个常量符号,并在其他表达式中使用该符号。

代码清单4.19中的const关键字的作用就是声明常量符号。由于常量和“变量”相反(“常”意味着“不可变”),以后在代码中任何修改它的企图都会造成编译时错误。

代码清单4.19 声明常量

注意赋给secondsPerWeek的也是常量表达式。表达式中所有操作数都是常量,编译器能确定结果。

设计规范

·不要用常量表示将来可能改变的任何值。π和金原子的质子数是常量。金价、公司名和程序版本号则应该是变量。

[1] 这是2020年7月1日的美国国债数据,数据来自www.treasurydirect.gov。

[2] 一元+操作符定义为获取int、unit、long、ulong、float、double和decimal类型(以及这些类型的可空版本)的操作数。作用于其他类型(例如short)时,操作数会相应地转换为上述某个类型。

[3] 为了使上述代码顺利编译,请添加using System.Diagnostics;指令。——译者注