运算符(一):算术运算符
Part 1 运算符是什么?
为了简化使用 C# 程序,C# 使用一些类似于数学符号的东西来表示一些运算,进而使得代码更具有可读性。
可读性(Readability)是一种神奇的玩意儿。当我们使用英语单词的时候,我们会觉得更好看懂代码,毕竟翻译过来就是实际的意思了;使用符号反而不一定能明白。但是,从小学到大,数学符号就一直陪伴我们,以至于我们使用运算符号反而比一些单词要更好看懂,所以,运算符是一个不可或缺的、表达复杂逻辑的一种东西。
C# 里提供了众多的运算符,只有你想不到的,没有它做不到的。大体上 C# 把运算符分成如下若干类:
- 算术运算符(
+
、-
、*
、/
、%
)和正负号(+
、-
); - 自增自减运算符(
++
、--
); - 比较运算符(
>
、>=
、<
、<=
、==
、!=
); - 条件运算符(
?:
); - 逻辑运算符(
&&
、||
、&
、|
、^
、!
)和逻辑元运算符(true
、false
); - 位运算符(
&
、|
、^
、~
、>>
、<<
); - 赋值运算符(
=
、+=
、-=
、*=
、/=
、%=
、&=
、|=
、^=
、>>=
、<<=
); - 溢出校验运算符(
checked
、unchecked
); - 强制转换运算符(
(T)
)。
按照顺序,我们一个一个来讲解。
为了保证调理清晰,我们按照运算符的类型进行挨个讲解,一种运算符用一篇文章来介绍。虽然内容可能没那么多,但是也不必放在一起,这样文章太长了。
另外,C# 还有一些别的运算符号,比如
a.prop
、a->prop
、a[i]
、*p
、&p
、is
、as
等等。这些运算符具有特殊用途和用法,且并不一定代表一种“运算”,因此我们不单独介绍它们,而是通过贯穿教程的方式对这些内容进行讲解;还有一些在之前的内容也说过了,所以就不用多说了。
Part 2 没啥可讲的?我不信
算术运算符是运算符里最基础、最容易学习的一种运算符类型。它不需要专业编程知识就可以掌握,因为它确实长得很像是数学运算符。
using System;
class Program
{
static void Main()
{
// 算术运算符。
// 加减乘和数学上的 +、-、* 是一致的操作和行为。
{
int a = 10;
int b = 60;
int c = a * b;
Console.WriteLine(c);
}
// 除法运算符“/”。
{
// 除法运算是不会变更数据类型的。
// 除法运算由于不改变计算类型,因此两个整数的除法运算相当于整除运算。
// 这个整除运算和 Visual Basic 语言的 \ 符号是一个意思。
int a = 10;
int b = 3;
int c = a / b;
Console.WriteLine(c);
// 两个小数(浮点数)的除法运算就和数学上的除法(➗)是类似的了。
float f = 10;
float g = 3;
float h = f / g;
Console.WriteLine(h);
}
// 取模运算符“%”。
{
// 整数的取模运算:表示获取整数除法下的余数部分。
int a = 10;
int b = 3;
int c = a % b;
Console.WriteLine(c);
// 如果被除数和除数里被除数是负数,结果才是负数;否则的所有情况都是正的。
int d = 10;
int d2 = -10;
int e = -3;
// 被除数
// ↓ 除数
// | ↓
int f = d % e;
Console.WriteLine(f);
int f2 = d2 % e;
Console.WriteLine(f2);
// 浮点数的取模运算。
// 假设运算 x / y 得到表达式:x / y = z...w,并且 z 是整数。
// 则 x % y 的结果为 w。
float x = 13.3F;
float y = 6.3F;
float z = x % y; // 13.3 / 6.3 = 2...0.7,所以 z = 0.7
Console.WriteLine(z);
}
}
}
下面我们来针对上述给的这个例子作介绍。我们先不必考虑除法运算,剩下的 +
、-
、*
均和数学运算的算法没有区别。
2-1 整数取模运算
最后一个叫做取模运算(%
),它虽然写成数学的百分号,但实际上是除法运算。不过取模运算是将数据进行整除,然后取出余数作为结果的一种运算模型。它等价于下面的这个数学公式:
如果我们有式子
则有数学表达式
数学运算符 等价于 C# 里的取模运算符 %
,故上述式子也可以表示为
举个例子:
int a = 17, b = 3;
int c = a % b;
我们可以轻松地得到,17 除以 3 的结果是 5,余数为 2,因此 c
的结果是 2。
另请注意,当取模运算符
%
的左侧这个数我们称为被取模数。整数取模运算一定用的是取模运算的两个计算数值的绝对值。比如 -13 % 7 得当成 13 % 7 进行计算。而结果的正负一定取决于被取模数。换句话说,只有被取模数是负数,结果才会是负数;否则怎么着,结果都是正的。比如-13 % -7
的结果一定是-(13 % 7)
,即 -6。
2-2 整数除法运算
前面我们说到了取模运算,如果说取模运算是取出余数的话,整数除法运算就刚好取的是商。还是看到前面的式子 (1)。这个式子里的 是这个除法式子的商,所以整数除法运算的结果就是这个 。
C# 里,如果除号 /
两侧的数字全部是整数的话,这个除号就应当看作是一个整数除法运算。因此,对照前面的例子,3 / 4
的结果应该就是 0,因为 3 根本不够被 4 除。如果是 17 / 3
呢?那就是 5 了。
2-3 浮点数除法运算
我们稍微换一下顺序。这次浮点数我们先说除法运算,然后再来讲解取模运算。
当我们依旧使用符号 /
,但左右两侧包含浮点数类型的数据的时候,我们就不能套用整数的除法运算了。因为浮点数是包含小数部分的,因此整体的结果就一定会是一个小数。
C# 里约定,当除号 /
的左右两侧数值里,至少有一个是 float
、double
和 decimal
的其一的话,我们就认为除法式子里包含小数。此时,就必须照着浮点数除法运算的规则(就是这一部分的内容)来计算。浮点数除法和数学除法没有区别,并不是带余除法。比如说
double result = ((double)3) / 4;
由于 3 和 4 均是整数,因此我们无法得到小数结果。我们必须得到 0.75 的话,就需要把至少其中一边改成小数,才能得到小数结果。从这则示例里可以看到,我们将 3 强制转换为 double
类型,然后得到一个浮点数除法运算的结果 0.75,最后赋值给 result
变量。
稍微注意一下的是,小括号没有必要写这么多。按照 C# 的习惯,就算你不打括号,C# 也知道这里
(double)3 / 4
是看成((double)3) / 4
而不是(double)(3 / 4)
的。那么,它们有什么区别呢?如果是前者,那么式子就好比是
3.0 / 4
一样。我们将 3 认为是 3.0。虽然 3 和 3.0 在数学上没区别,但是 C# 里因为字面量类型的问题,3 是int
类型(整数),而 3.0 是double
类型(浮点数),因此类型影响着整个运算是整数除法还是浮点数除法。而如果是后者,式子就相当于是把
3 / 4
的结果得到后,才转换成double
。前文说到,如果除号的两侧没有浮点数的话,那么结果就一定是按整数除法来计算的。那么3 / 4
的结果就必然为 0,故式子就好比是(double)0
,即 0.0,没有意义的转换。因此,强制转换运算符仅和它旁边这个数值进行“结合”。
这就是浮点数的除法。
2-4 浮点数取模运算
C# 里除了可以对两个整数进行取模运算,还可以对浮点数计算。不过概念稍微和整数的取模运算有一点不一样,但它和数学上的余数运算是一样的算法。
比如 13.3 % 6.3
。我们只需要找到一个合适的商,得到 2 这个结果;那么商乘以除数可以得到 12.6 这个结果;那么余数自然就是 13.3 - 12.6 = 0.7。
Part 3 浮点数运算的精度问题
我们尝试看一下如下的计算:
float f = 0.1F, g = 0.2F;
Console.WriteLine("{0}", f + g); // 0.3
我们可以得到的结果是 0.3。但我们将 float
改成 double
:
double f = 0.1F, g = 0.2F;
Console.WriteLine("{0}", f + g); // 0.30000000447034836
即使我们去掉 F
字面量后缀
double f = 0.1, g = 0.2;
Console.WriteLine("{0}", f + g); // 0.30000000000000004
你都不会得到 0.3 这个结果。实际上,后面两个的结果的小数位很后面依然有非 0 的数据信息。这是因为什么呢?
这并非是 C# 的计算错误,而是一个“预期的”结果。这我们得回到 IEEE 754 规范上来。这个规范规定和约定,数据需要先转换为二进制的科学计数法表示(即 的格式)。这个式子里,由于一个十进制数要想表达成这个格式,且内存是有限大小的,因此我们无法准备表达一个数值信息,因而会丢失精度。
因此,在存储和赋值的时候,f
和 g
就已经不是真正的 0.1 和 0.2 了,因此结果必然不是真正的 0.3。因此,我们一定要注意浮点数的精度问题。
如果需要避免精度错误,我们需要用 decimal
类型。
decimal f = 0.1M, g = 0.2M;
Console.WriteLine("{0}", f + g);
这样输出的结果就肯定是 0.3 了,且不会出现精度的问题。
下面我们来思考一个问题。为什么第一个例子(
float
类型运算)里,结果依旧是 0.3 呢?精度会导致数据不准确,但为什么答案依旧是准确的 0.3 呢?答案是因为,0.1 和 0.2 在存储的时候,误差其实很小,以至于比
float
类型的最低精度还要小,所以算出来看起来没有问题,但是实际上答案应当和第二个例子里显示的结果一致(只是误差比精度还小,因此后续的部分被舍弃了),因为等于 0.300000… 的后面就全部为 0 了,自然显示的时候就把全部的小数部分的 0 全给省略了。
Part 4 数据类型不同时,运算符运算机制
using System;
class Program
{
static void Main()
{
// 数据类型不同的时候的运算符运算机制和行为。
// 整数:sbyte, byte, short, ushort, int, uint, long, ulong
// 浮点数:float, double, decimal
{
// 如果两个数据类型不同的数进行运算,结果取的是
// 这两个数据类型里表示数据较为宽泛的那一个。
int a = 10;
double b = 20;
double c = a * b;
Console.WriteLine(c);
}
{
// 类型提升:如果两个数据的类型不一样,但表示的数据宽度一样,
// 这样两种数据类型的数字参与运算,结果就会提升它自己原本的数据类型。
// 提升规则:
// sbyte 和 byte:int
// short 和 ushort:int
// int 和 uint:long
// long 和 ulong:编译器报错
uint a = 10;
int b = 20;
Console.WriteLine(a + b);
}
}
}
Part 5 字符串的加法
由于 C# 里有一种新的数据类型(字符串),这是 C 语言里没有的数据类型,因此它有特殊的运算规则:字符串的加法。
字符串的加法说白了,就是字符串的拼接。
using System;
class Program
{
static void Main()
{
// 字符串的加法运算:字符串的加法相当于拼接字符串。
// 字符串的拼接操作至少要求 + 运算符的左右两侧至少有一个是字符串类型,
// 即 string 的变量或字面量。
// 另外,字符串的拼接操作始终是不满足交换律的。
string s = "Hello";
string t = "world";
char u = ',';
string result = s + u + t;
Console.WriteLine(result);
// 不过,我们一般不建议使用者把 + 左侧的这个变量作为非字符串类型的变量
// 来作为字符串拼接操作。
// 因为,用户可能会因为 + 运算符而优先去以为是一个数字的基本操作(加减乘除运算),
// 而忽略掉字符串的拼接操作。
int i = 30;
string z = "hello";
string result2 = i + z;
Console.WriteLine(result2);
}
}
我们通过加法运算符 +
来拼接两个字符串。s + t
将字符串拼接起来,因此输出的内容就是 Hello, world.
。
Part 6 总结
本节我们学习了基本的五个运算符。一定要注意浮点数和整数对除法运算的不同行为。稍微注意一下,字符串的加法运算。
运算符(二):自增自减运算符
之前我们说到了基本的算术运算符,今天我们来说一下自增自减运算符。自增自减运算符是专门针对于一个变量本身进行增大和减小操作的运算符。
另外顺带说明一下,自增自减运算符是这两个运算符的统称:自增运算符和自减运算符。
Part 1 前缀和后缀运算
在这之前,我们先说一个概念。前缀运算符(Prefix Operator)是指,运算符放在变量的左边的行为;后缀运算符(Suffix Operator)则相反:放在变量的右侧的行为。
C# 里的自增运算符写成 ++
、自减运算符写成 --
。这两个运算符既可充当前缀运算符,也可以充当后缀运算符。用法是这样的:
// Usage 1.
a++;
++a;
a--;
--a;
// Usage 2.
int a, b = 4;
a = b++;
比如这个例子里,a++
里的 ++
放在变量后,所以称为后缀运算符;++a
的 ++
放在变量前,所以称为前缀运算符。
和之前的算术运算符不同,算术运算符并不能像这样单独使用,而必须依赖于赋值行为。因为算术运算符将两个数值关联起来,进行运算;然后得到的结果还必须得赋值给另外一个变量,否则这个数值结果就“找不到”了;而自增自减运算符并不一样,它可以单独使用。下面我们来挨个进行说明。
Part 2 前缀自增自减运算
前缀自增运算和前缀自减运算的行为类似,因此放在一起说明。
int a, b = 4;
a = ++b;
首先,a
是没有赋值的状态,而 b
初始数值是 4。第二句话,a = ++b
表示,将 ++b
的结果赋值给 a
。
下面说一下 ++
和 --
的逻辑。
++
:将紧挨着的这个变量先自增一个单位大小,一般用于整数数值(小数也可以自增一个单位,不过一般用不上);--
:将紧挨着的这个变量先自减一个单位大小(同上,小数也可以用,但一般用于整数)。
如果把 ++
作为前缀运算,那么自增运算过程必须先执行;当自增完成后,自增完毕后的结果会提供给语句的别处使用;--
是同理的,只是把自增操作改成自减操作。
这句话意思就是,a = ++b
里会用到 b
的结果的地方除了 ++
(自增)以外,还有 =
(赋值)。按照逻辑,由于是前缀运算,因此变量 b
会因为优先执行自增运算的关系,将原始的数值 4 变为 5。接着,++b
整体的结果也是这个 5,于是将 5 赋值给变量 a
。整体就是这个逻辑。所以,前缀自增运算符包含两个语义:
- 将变量本身自增一个单位;
- 表达式整体也和算术运算符一样,有一个结果。这个结果就是变量增大后的结果。
同理,--
一样的。
int a, b = 4;
a = --b;
和前面完全一样的思维,只是把自增改成自减。显然,b
会变为 3,然后得到 --b
整体的结果也是 3。最后把 3 赋值给 a
,完成操作。
Part 3 后缀自增自减运算
麻烦的事情来了。前缀运算看起来运算很简单,不过后缀运算就麻烦了。++
和 --
是可以作为后缀运算出现的,因此更不好理解的是这里。
++
:优先将变量本身的数值丢给别处使用;当当前语句的别处全部用完这个变量的数值后,变量再增大一个单位;--
:同理,只是改成减去一个单位。
我们看一下这个例子。
int a, b = 4;
a = b++;
这个例子里,a = b++
里使用 b
的地方有两处:=
(赋值)和 ++
(自增)。由于是后缀运算,因此变量 b
本身会把数值先提取出来提供给别处使用。这里所说的“别处”,当然就指的是“除了自增运算之外的别的用到 b
变量”的地方了。显然,除了自增以外,只有赋值在用 b
。因此,a = b++
等效于直接先赋值:a = b
,然后 b
增大一个单位。因此,a
在获取数值的时候,还是 b
原本的数值 4,而不是 5;但 a = b++
执行完毕后,b
会自增一个单位,从 4 变为 5。
道理也不难。将 a = b++
改成 a = b--
是一样的道理,只是把自增改成自减。因此 a
结果依旧是 4,只是 b
从原本的数值 4 变成 3。
Part 4 将自增自减运算单独作为语句使用
前文里我们还说到了一种用法:a++;
,即单独把自增自减运算符当成语句来用。这样的操作和前文的前缀和后缀操作是一样的,只是特殊之处在于,“别处使用变量”的行为就没有了:整个语句唯一的操作就是在自增(或自减)变量数值。
如果单独作为语句使用,++a
和 a++
完全没有区别(行为上就是单纯为了变量本身能增大或减小一个单位)。同理,--a
和 a--
也是一样。
可能你学过一些汇编语言,C 语言里
++a
和a++
作为语句的时候,在汇编执行上有一点点不同(所以有些人会告诉你优化性能的时候,请优先使用前缀的自增自减运算,而不是后缀的;但是实际上,通过编译器优化,它们都会变成完全一样的东西。这一点在 C# 里,不论是前缀还是后缀,都是完全一样的:只要++a
和a++
作为语句单独出现的话,不论在哪里,它们都是一样的东西。请务必记住这一点。虽然考试不考,但是一定不要在这种地方上钻牛角尖。
另外,a++;
完全等价于这么一句话:a = a + 1;
。这句话未免有些奇怪,在初学 C 语言的时候,我们就对这个左右完全不相等的表达式头疼。实际上,这个等号是赋值行为,因此实际上就是把 a
的数值增大一个单位之后,得到的 a + 1
的结果,再重新赋值给 a
自身的过程;同理,a--;
等价于 a = a - 1;
。
Part 5 混用自增自减运算
5-1 复杂用例
有些时候,为了简化代码的书写,我们可能会使用自增自减运算符。但是,有可能多个自增自减运算符会同时出现在一个语句里。
int a = 3, b = 4;
int c = (a++) + (++b);
Console.WriteLine("{0}, {1}, {2}", a, b, c);
思考一下,这个例子的输出结果是多少。答案是 8,因为有一个自增运算符是作后缀的,所以变量会先提供别处使用。整个表达式里,使用 a
变量的地方有 +
和 ++
两处。那么“别处”自然就指的是这里的 +
了。即使我们打了括号,但是因为出现在变量的后面,所以 ++
也不会提前执行。这一点在初学是非常让人头疼的。
接着,a
既然提供使用,那么原始数值是 3。
然后,++b
的 ++
是前缀运算,因此会先将 b
增大一个单位后,再提供给别处使用。b
从 4 改成 5。b
自增完成后,提供给别处使用(这里还是只有 +
在用 b
)。综合前面的 a
,我们可以发现,实际上 c
赋值的时候,a + b
的结果其实是 3 + 5
而不是别的,因此 c
的结果是 8。
不过,当我们走到第 3 行代码的时候,a
和 b
变量已经通过原始的自增操作完成了增大一个单位的过程,因此 a
是 4、b
是 5,c
是 8,也因此,输出结果是 4, 5, 8
。
混用自增自减运算有时候是可以简化代码,但有时候会复杂化代码的理解逻辑,因此,谨慎使用类似前面这个例子这样的混用模式。
5-2 千万别混用同一个变量的自增自减
像是前面这个例子逻辑还算清晰。如果这样呢:
int a = 3;
int b = (a++) + (++a);
Console.WriteLine("{0}, {1}", a, b);
我估计专业学过 C# 的朋友也没几个看得懂这个例子的计算,以及结果输出。想听我告诉你答案吗?
很遗憾。这个例子我不会告诉你运算结果。我们并不推荐混用同变量的自增自减运算,因为它是一种典型的“故意混乱代码逻辑”的用法。
从人类的理解角度来说,我们肯定会优先计算 ++a
。但自增后的 a
会不会影响到左边的 a++
的 a
呢?按道理是会的,毕竟是同一个 a
;然后得到 b
的结果 4 + 4(前面这个是后缀运算,因此先用于加法运算,之后才自增)。所以答案是 a = 5、b = 8。
但是!你真的觉得这个是人应该写出来的程序吗?这种反人类的例子还是不要出现在你的代码里。
VS 的结果就是 5 和 8。
呃……我刚还说不告诉你结果,结果这里还推了一遍结果,还讲了一遍……
Part 6 总结
这一节的内容我们学到了自增和自减运算。自增自减运算都有如下两个语义行为:
- 把变量的增大(或减小)一个单位大小;
- 将变量的结果当作自增自减运算符这个表达式的数值,提供给当前语句的别处使用。
但我们说过,混用会引起代码逻辑的混乱,因此不建议混用自增自减运算。最不建议的就是同变量的自增自减。
运算符(三):比较、逻辑和条件运算符
下面我们介绍一下比较运算符、逻辑运算符和条件运算符。这三种运算符需要一起讲,因为它们之间是存在关联和关系的。
Part 1 六种比较运算符
先别被标题吓死了。六种比较运算符实际上就是我们经常在数学上使用的大于、大于等于、小于、小于等于、等于和不等于这六种比较符号。在 C# 里,和 C 语言一致,分别写成 >
、>=
、<
、<=
、==
和 !=
。特别需要说明的是,等号需要用两个等号表达,因为单个等号已经用来表达赋值过程了。为了避免赋值过程和比较过程使用同一个符号产生语义冲突,比较相等就多用了一次等号符号。
int a = 10, b = 7;
bool condition1 = a > b; // true
bool condition2 = a < b; // false
bool condition3 = a >= b; // true
bool condition4 = a <= b; // false
bool condition5 = a == b; // false
bool condition6 = a != b; // true
实际上也没什么好说的。它实际上就是在比较数据的大小。将比较结果(条件是否正确)以一个 bool
结果反馈和赋值给左侧变量。
Part 2 八种逻辑运算符
C# 里有四种逻辑运算,分别是且(&&
)、或(||
)、贪婪且(&
)、贪婪或(|
)、且元(false
)、或元(true
)、异或(^
)和逻辑取反(!
)。我相信你肯定只知道逻辑且、或、非(逻辑取反),最多多一个逻辑异或运算。其它四个估计你完全不知道。这也没关系,我们慢慢来梳理它们。
2-1 逻辑且、或运算
我们使用 &&
连接两个 bool
类型的变量,用来表达和表示两个条件必须都成立的时候,才成立。
bool a = 10 > 7, b = 3 < 6;
bool condition = a && b;
这个 condition
的值是 true
还是 false
呢?显然是 true
,因为两个条件 a
和 b
全都是正确的表达式,因此相当于在算 true && true
,那当然结果就是 true
了。
||
和 &&
是对称的运算。只有当连接起来的两个条件都不成立的时候,才不成立。其它情况都成立。
bool a = 10 > 7, b = 3 < 6;
bool condition = a || b;
显然,两个都是 true
的结果。只有两个都是 false
的时候,结果才是 false
。因此这个例子的 condition
结果依旧是 true
。
2-2 贪婪逻辑运算以及短路现象
要说清楚贪婪逻辑运算,必须要先说一下逻辑且和逻辑或本身存在的一种短路现象,它分逻辑且和逻辑或两种运算来解释:
- 逻辑且运算(
&&
):由于两个条件里,有一个为false
的时候,就已经可以确定表达式结果,因此如果当左侧结果就是false
的时候,右侧结果不论是普通表达式,还是带有运算过程的表达式,都不会被得到执行; - 逻辑或运算(
||
):由于两个条件里,有一个为true
的时候,就已经可以确定表达式结果,因此如果当左侧结果就是true
的时候,右侧结果不论是普通表达式,还是带有运算过程的表达式,都不会被得到执行。
可以从内容里看出,它们的差距就在 true
和 false
这个词不一样。下面我们来说一下这个到底是什么意思。
// Exemplar 1.
int a = 1, b = 4;
bool condition = --a <= 0 || --b <= 0;
Console.WriteLine("{0}, {1}, {2}", a, b, condition);
// Exemplar 2.
int a = 1, b = 4;
bool condition = --a <= 0 | --b <= 0;
Console.WriteLine("{0}, {1}, {2}", a, b, condition);
可以看到,其实两个例子的结果分别是 0, 4, true
和 0, 3, true
。非贪婪运算在计算 --a <= 0
的时候,发现 --
是前缀运算,因此先把 a
减去一个单位,使得 1 变为 0;然后 --a
的结果就是此时 a
的数值(即 0);最后,--a <= 0
就一定是成立的,因此整个表达式的结果是 true
。
因为使用的是逻辑或运算,因此左侧条件已经可以确定整个表达式 --a <= 0 || --b <= 0
的结果了,那么,--b <= 0
就不必计算了,即使 --b
会影响 b
的结果,此时的 b
照样不会减去 1 个单位,因此 a
变成 0,b
没有任何改动,且最终的结果是 true
。
贪婪逻辑或运算说白了就是没有短路现象的逻辑或运算。不管左边是不是能确定表达式结果,右侧的表达式依旧需要运算。那么,结果自然就是 0, 3, true
了(虽然我们知道,就算 b
变成 3,--b <= 0
也是 false
的;但是因为 true
或一个不管什么东西的结果都是 true
)。
同样地,逻辑且和贪婪逻辑且运算是一样的道理。
int a = 1, b = 4;
bool condition = --a != 0 & --b != 0;
Console.WriteLine("{0}, {1}, {2}", a, b, condition);
思考一下,这个例子里的结果分别是多少,以及把贪婪逻辑且运算改成逻辑且运算后,结果又是多少。
2-3 逻辑异或运算
异或运算类似于我们逻辑上理解的“要么……要么……”。如果别人告诉你,要么苹果要么香蕉的时候,如果你都选择了,或者都不要的话,这两种情况都是不成立的。因为要么表示里面的东西你必须选,但是只能选一个。异或运算就是这么一个逻辑。
int a = 10, b = 3;
bool condition = a > 10 ^ b >= 3;
由于 a > 10
的结果是 false
,而 b >= 3
的结果是 true
。按照逻辑异或运算的规则,因为有且仅有一个表达式结果为 true
,因此 condition
的结果就是 false ^ true = true
。
最后,我们还剩下逻辑元运算两个(
true
和false
)。这一点我们先放一边,我们先讲条件运算。
2-4 逻辑取反运算
逻辑取反运算比起前面的就要简单很多了。逻辑取反就是把对改成错、把错改成对的过程。
bool condition = !(a > b);
如果 a > b
条件为真(即 true
的话),那么 condition
的结果就为 false
。
请注意,!
运算符仅用来表达一个 bool
结果的取反,因此必须要在表达式整体上打括号后,然后前面追加 !
。如果写成 !a > b
的话,C# 会产生一个编译器错误,告诉你 !a
是无法取反的,因为 a
是一个数。
Part 3 条件运算符
条件运算符(Conditional Operator)是一个需要三个表达式才能操作的运算符,它用 ?
和 :
作为分隔,问号的前面写条件(一个 bool
类型的变量),然后 ?
和 :
中间写这个条件成立的时候的数值,:
后写条件不成立的时候的数值。两个数值必须是同一种数据类型的。
举个例子:
int a = 3, c;
c = a > 10 ? 30 : 40;
这表示,当 a > 10
条件成立的时候,我们认为 30 就是 c
的数值,否则 c
为 40。
Part 4 逻辑元运算
坐稳了。这一节的难度有点大,而且很考察各位的逻辑推理能力。千万不要掉以轻心。
逻辑元运算(Meta Logical Operator)一共有两个(true
和 false
)。不要以为 true
和 false
只能表示一个条件为真和假,它还有别的意思:当成一个运算符,记作 true(表达式)
和 false(表达式)
。
逻辑元运算将 &&
和 &
,还有 ||
和 |
关联起来。公式大概是这样的:
a && b
等价于false(a) ? a : a & b
;a || b
等价于true(a) ? a : a | b
。
比如 a && b
。我们先运算 false(a)
的结果,如果这个结果是 true
,我们就直接把 a
作为 a && b
的结果;否则,就取 a & b
整个表达式的结果。
不好理解?行,我们拿驾车的例子给大家介绍一下逻辑元运算。假设我们开着一辆车向前行驶。遇到红绿灯路口需要过马路。那么,如果我们将车辆油箱里的油还剩下多少分成三种状态:
- 红色:没油了;
- 黄色:有一点油,但是要提出警告,因为可能过不了马路;
- 绿色:有充足的油前进。
我们把红绿灯的状态也表示成三种情况:
- 红色:红灯无法前进;
- 黄色:黄灯;
- 绿色:绿灯可以前进。
那么,假设我们把“带有红色、黄色、绿色”这三种状态的逻辑称为“状态逻辑”。那么,状态逻辑的运算应该如何呢?
- 如果两个条件里只要有一个是红色的话,我们就断定结果状态一定是红色的;
- 如果两个条件里两个都是黄色的话,我们也认为最终状态也是红色的;
- 如果两个条件有一个黄色的话,我们就认为结果状态是黄色的;
- 如果前面都不成立,则现在只剩下绿色了,因此我们认为其它情况下的结果状态都是绿色的。
那么,我们可以具象化逻辑:定义一个“状态逻辑”的变量 canGo
,表示是否可以前行。它取决于“油箱是否有油”和“前面红绿灯是否可以允许前进”两个因素,因此我们需要把这两个条件用逻辑且连接起来:
Status canGo = fuelStatus && signalStatus;
其中,
fuelStatus
是油箱有没有油的状态(有油,有油但是少、没有油);signalStatus
是红绿灯是否允许我们前进的状态(红灯、黄灯和绿灯)。
那么问题来了。这个运算到底怎么计算呢?这个时候我们就需要设计 true
和 false
这两个逻辑元运算的过程了。
true(状态)
:状态是绿色的时候,结果为true
,否则为false
;false(状态)
:状态是黄色或红色的时候,结果为true
,否则为false
。
很好。我们最后带入公式,看看是否逻辑正常:fuelStatus && signalStatus
用逻辑元运算来表达的话,可以展开成 false(fuelStatus) ? fuelStatus : fuelStatus & signalStatus
。
这个逻辑就很好理解了。如果油箱状态是红色(或黄色)的时候,我们就可以直接认为 canGo
直接是红色(或黄色)了:因为既然油箱都没油了,那我们自然就不可能前进(这一点就是表达式里体现到的短路现象)。但是,如果油箱的油还多的话,我们这个时候就肯定要看红绿灯是否允许我们前行,因此,我们还要看 signalStatus
的结果才行。
那么,为什么不是直接看 signalStatus
的状态作为结果,而是 fuelStatus & signalStatus
呢?因为,我们不能仅通过信号灯的状态就确定我们是否可以继续通行,因此需要看的是油箱和信号灯两个状态才可以断言结果。你换个角度想一想 bool
里 &&
和 &
,就可以想通这个道理了。
这就称为逻辑元运算。逻辑元运算决定了条件是否成立的底层逻辑,而 &&
和 ||
可以认为是用贪婪运算和逻辑元运算共同构成的一个复杂表达式。
逻辑元运算是唯一一个无法直接使用的运算符;换句话说,你无法使用代码书写的方式来使用这个运算符。比如说你这么写代码:
~~false(condition)~~
,这个写法就是不行的。它的出现实际上是为了辅助&
运算符(贪婪逻辑且运算)和|
运算符(贪婪逻辑或运算)提供帮助,构成&&
和||
的中间运算过程。正是因为它是中间运算过程,因此难度比套用公式还要难理解一些。
Part 5 总结
本节内容难度可能有点大,考察各位对逻辑的理解。比较运算其实没有什么可以说的,主要就是逻辑运算比较不好理解,短路现象、元运算等等。
运算符(四):位运算符
位运算符是一种专门操作比特位的运算符。这种运算符对于自然理解来说不容易,但是对于计算机处理层面来说,会有非常方便、快捷的地方。
位运算一共有六个:分别是位与运算 &
、位或运算 |
、位取反运算 ~
、位异或运算 ^
、位左移运算 <<
和位右移运算 >>
。
为了引导你学习比特位处理,我们先来学习一下整数在内存里的表达。
实际上,小数(浮点数)也有二进制表达的逻辑。因为它没有比特位的相关处理,因此我们不在这里作过多介绍。如果需要学习的话,请参考 IEEE 754 规范的相关内容。警告:该规范的学习难度较大。
Part 1 整数的内存表达方式
在早期发明计算机的时候,我们拥有一种万能的转换逻辑将人类能理解的十进制数值改成二进制数值。为了帮助理解,我们先讲正整数,然后讲负整数。它们的处理是不太一样的。
1-1 正整数的表达
我们将一个十进制数以二进制(只用 0 和 1 两个数码)来表示。为了保证表达的唯一性,采用的办法是这样的:
- 将一个十进制数不断向下除以 2,并一直往下写整数除法运算的结果,并在右侧对应写上除法的余数(0 或 1);
- 然后将表达出来的余数序列,从下往上倒着书写。书写的结果就是二进制数值了。
来看一个例子:47。我们要表达 47 这个数的二进制表达,那么就不断除以 2。
比如 47。47 除以 2 必然余数为 1,因此写在 47 的右边;而下面写上 47 除以 2 的整除结果(23)。接着,拿 23 去除以 2,右侧写 23 除以 2 的余数,下面写 23 除以 2 的商。以此类推,直到最下面的这个数字小于 2(1 或 0)的时候停止计算。
当然,因为对 2 取模运算(就是取余数)的话,只有奇数余数为 1,偶数余数为 0,因此你可以直接这么去记忆规则:当数字是偶数的时候,直接在右边写 0;否则就是奇数,那么右边就写 1。
最后,我们从最下面的 1 开始往上倒着读序列:101111,这个数字就是 47 的二进制结果。
相反地、我们如果需要将二进制转换回十进制数值的话,就把二进制数写出来之后,将每一个位置上的数字乘以权重(Weight),然后加起来。
我们还是拿 47 的二进制来说明。
%7D%20%3D%20101111%7B(2)%7D%0A#card=math&code=47%7B%2810%29%7D%20%3D%20101111_%7B%282%29%7D%0A&id=s8MkT)
我们在每一个数位的下方写上 0 到 5(从右往左写)。
然后,把标记了 1 的地方全部记作 2 的 n 次方。最后,把它们全加起来。
这样就得到了结果。
这里每一个数位都称为一个比特位(Bit),也称为比特或者位。
1-2 负整数的表达,以及补码的引入
正整数是最基础的表达过程。但是负整数有点不一样。在二进制的处理的过程之中,为了尽量使用较少的工具完成较多的任务,计算机科学家考虑使用加法器来计算减法。举个例子,我们想要完成 5 - 3 的任务,那么只需要改成 5 + (-3) 就可以了。这里的 -3 就是用负整数的表达就可以。然后,直接使用加法的算法,将 5 和 -3 两个数字加起来。
科学家最开始考虑的是用原码表达整数。原码和前面介绍的十进制转换成二进制后的结果基本上一样。就多了一个规则:如果这个数字是负数,那么就将最高位的比特从 0 改成 1。比如说 3 的话,我们就可以将最高位从 0 改成 1。最高位在哪里呢?这里就牵扯到了一个概念:数据类型占内存多大的问题。
一般来说,sbyte
占 1 个字节(8 个比特)、short
占 2 个字节(16 个比特)、int
占 4 个字节(32 个比特),而 long
占 8 个字节(64 个比特)。这四个类型都是带符号的类型,即除了正整数以外,还可以表示负整数。
按照一般的道理来说,假如这个数据类型是 sbyte
的话,那么我们就需要用到 8 个连续的比特来表达一个整数。当这个数字是负数的时候,最高位改成 1,其它比特位则依旧是二进制的普通表达。
这样就可以对应到之前的文章内容。8 个比特的话,一个比特表示符号,那么剩下的自然就只有 7 个比特了。7 个比特通过 0 和 1 的排列组合,一共能表达 128 种不同的数字(7 个数位,每一个数位能表示 0 和 1 两种情况,所以组合起来就是 2 的 7 次方,即 128 种结果);正是因为这个原因,外带一个符号位,所以
sbyte
的范围是 -128 到 127。你可能会问我:“欸,不对啊,这 -128 哪里来的;还有,为什么正整数只到 127,128 哪里去了”。这个问题我们不在这里说明。等我把这一点内容说完了,这个 -128 你自然就知道怎么来的了。
那么,-3 可以表达为 10000011
:最高位的 1 表示这个数是负数,而后面 7 个位置 0000011 刚好是 3 的二进制表达,所以这个数字是 -3。这个 -3 的二进制表示称为原码形式。
问题来了。如果我们直接带入 5 和 -3 的原码计算加法,会得到什么结果呢?
%5C%5C%0A%2B%20%26%20%5Cunderline%7B1000%5C%200011%7D%20%26%20(-3)%5C%5C%0A%26%201000%5C%201000%20%26%20(-8)%0A%5Cend%7Bmatrix%7D%0A#card=math&code=%5Cbegin%7Bmatrix%7D%0A%26%200000%5C%200101%20%26%20%285%29%5C%5C%0A%2B%20%26%20%5Cunderline%7B1000%5C%200011%7D%20%26%20%28-3%29%5C%5C%0A%26%201000%5C%201000%20%26%20%28-8%29%0A%5Cend%7Bmatrix%7D%0A&id=OpfQH)
是的,按照十进制类似的加法运算,我们得到的结果是 -8 而不是正确结果 2。问题出在负数的表达上,因为正整数的计算肯定没有问题,但是负整数就会出现运算问题,因为表达本身就不正确。
显然,负数的数据要和正数的数据是互补的,才能使得计算过程能够正常进行。因此,科学家发明了反码和补码的概念。科学家笃定了,补码形式一定能让负数变成可带入加法器运算的特殊表达形式。
补码是将原码的非符号位全部取反,然后再这个基础上再自增一个单位,得到的结果。比如 -3,我们要经过如下的一番运算,才能得到补码表达:
%7D%5C%5C%0A%5Cdownarrow%20%5Ctext%7B%E5%8F%96%E5%8F%8D%7D%5C%5C%0A1%5Ctextcolor%7Bpink%7D%7B111%5C%201100%7D%5Cquad%20%5Ctext%7B(3%20%E7%9A%84%E5%8F%8D%E7%A0%81)%7D%5C%5C%0A%5Cdownarrow%20%5Ctext%7B%E5%8A%A0%E4%B8%8A%201%7D%5C%5C%0A1111%5C%20110%5Ctextcolor%7Bpink%7D%7B1%7D%5Cquad%20%5Ctext%7B(3%20%E7%9A%84%E8%A1%A5%E7%A0%81)%7D%0A%5Cend%7Bmatrix%7D%0A#card=math&code=%5Cbegin%7Bmatrix%7D%0A1000%5C%200011%5Cquad%20%5Ctext%7B%283%20%E7%9A%84%E5%8E%9F%E7%A0%81%29%7D%5C%5C%0A%5Cdownarrow%20%5Ctext%7B%E5%8F%96%E5%8F%8D%7D%5C%5C%0A1%5Ctextcolor%7Bpink%7D%7B111%5C%201100%7D%5Cquad%20%5Ctext%7B%283%20%E7%9A%84%E5%8F%8D%E7%A0%81%29%7D%5C%5C%0A%5Cdownarrow%20%5Ctext%7B%E5%8A%A0%E4%B8%8A%201%7D%5C%5C%0A1111%5C%20110%5Ctextcolor%7Bpink%7D%7B1%7D%5Cquad%20%5Ctext%7B%283%20%E7%9A%84%E8%A1%A5%E7%A0%81%29%7D%0A%5Cend%7Bmatrix%7D%0A&id=ajq0I)
可以看出,这种操作过程是不会动符号位的,包括取反过程和 + 1 过程,都是跟符号位没有关系的(它从右边操作数据)。因此,就算是我们无法从补码本身断言数据是多少,我们也能确定数据的符号:只用看最高位就可以了。
另外,这种转换机制是可逆的。换句话说,如果运算结果本身是负数的话,那么就逆向进行转换,把二进制结果先减去 1,然后再取反非符号位,就可以还原会负数的原码表示了。这样,我们就可以看出原始数据是多少了。
我们把补码提取出来,参与刚才的加法运算:
%20%26%5C%5C%0A%2B%20%26%20%5Cunderline%7B%5Ctextcolor%7Bpink%7D01111%5C%201101%7D%20%26%20(-3)%20%26%5C%5C%0A%26%20%5Ctextcolor%7Bred%7D10000%5C%200010%20%26%20(2)%20%26%20%5Ctext%7B%E6%9C%80%E9%AB%98%E4%BD%8D%E8%88%8D%E5%8E%BB%7D%0A%5Cend%7Bmatrix%7D%0A#card=math&code=%5Cbegin%7Bmatrix%7D%0A%26%20%5Ctextcolor%7Bpink%7D00000%5C%200101%20%26%20%285%29%20%26%5C%5C%0A%2B%20%26%20%5Cunderline%7B%5Ctextcolor%7Bpink%7D01111%5C%201101%7D%20%26%20%28-3%29%20%26%5C%5C%0A%26%20%5Ctextcolor%7Bred%7D10000%5C%200010%20%26%20%282%29%20%26%20%5Ctext%7B%E6%9C%80%E9%AB%98%E4%BD%8D%E8%88%8D%E5%8E%BB%7D%0A%5Cend%7Bmatrix%7D%0A&id=NiBuJ)
就有这么巧。5 + (-3) 结果恰好等于 2。稍微注意一下最高位的 1 的进位逻辑。由于我们拿 sbyte
类型举例,因此只占据 8 个比特的空间。如果进位运算到第 9 个比特的时候,超出了这个类型的存储范围,因此就算是进位,也会被系统自动舍去。因此,实际上真正有意义的数据只有 0000 0010
。而这个数据的最高位是 0,也就是说它是一共正整数,故直接读取数值信息,就是 2 了。
是的,科学家发明的补码就是为了解决让负数也可以参与加法器的加法运算过程的问题。当然,除了解决这个问题,还有一个问题是,0 的原码里,+0 和 -0 是两个表达。一个是 0000 0000
,而另外一个是 1000 0000
。这样显然不行啊。于是,后者(-0)就使用补码来读取数据:
%7D%5C%5C%0A%5Cdownarrow%20%5Ctext%7B%E5%87%8F%E5%8E%BB%201%7D%5C%5C%0A0111%5C%201111%5Cquad%20%5Ctext%7B(-0%20%E7%9A%84%E5%8F%8D%E7%A0%81)%7D%5C%5C%0A%5Cdownarrow%20%5Ctext%7B%E5%8F%96%E5%8F%8D%7D%5C%5C%0A0000%5C%200000%5Cquad%20%5Ctext%7B(-0%20%E7%9A%84%E5%8E%9F%E7%A0%81)%7D%0A%5Cend%7Bmatrix%7D%0A#card=math&code=%5Cbegin%7Bmatrix%7D%0A1000%5C%200000%5Cquad%20%5Ctext%7B%28-0%20%E7%9A%84%E8%A1%A5%E7%A0%81%29%7D%5C%5C%0A%5Cdownarrow%20%5Ctext%7B%E5%87%8F%E5%8E%BB%201%7D%5C%5C%0A0111%5C%201111%5Cquad%20%5Ctext%7B%28-0%20%E7%9A%84%E5%8F%8D%E7%A0%81%29%7D%5C%5C%0A%5Cdownarrow%20%5Ctext%7B%E5%8F%96%E5%8F%8D%7D%5C%5C%0A0000%5C%200000%5Cquad%20%5Ctext%7B%28-0%20%E7%9A%84%E5%8E%9F%E7%A0%81%29%7D%0A%5Cend%7Bmatrix%7D%0A&id=l2TTq)
在变回去后,1000 0000
就成了 0000 0000
了,所以 -0 和 0 就是一样的数据了,确实很巧妙。
由于
1000 0000
转反码的时候需要先减去 1,而后面全 0 的关系,只能从符号位去减,因而数据成了0111 1111
。
在计算机里,1000 0000
被特殊处理,由于符号位是 1,因此只能读作负数,故这个数值就是 -128。
好了。我们解释了补码的问题,下面我们可以来看一下位运算了。
Part 2 位与运算
位与运算将两个数字对应的比特位作位与运算处理。它的操作和逻辑且运算是差不多的。我们把 0 当成 false
、1 当成 true
来理解:两个比特位在参与运算的时候,如果都是 1 才是 1,其它的情况都是 0。
我们使用 a & b
来表示把两个数字使用位与运算。它和贪婪逻辑且运算用的是一样的符号,但是贪婪逻辑且运算符的两侧都是 bool
类型的数值,而这里的 a
和 b
则是整数类型。
举个例子。我们将 5 和 -3 进行位与运算。运算过程如下:
%5C%5C%0A%5Ctext%7B%5C%26%7D%20%26%20%5Cunderline%7B1111%5C%201101%7D%20%26%20(-3)%5C%5C%0A%26%200000%5C%200101%20%26%20(5)%0A%5Cend%7Bmatrix%7D%0A#card=math&code=%5Cbegin%7Bmatrix%7D%0A%26%200000%5C%200101%20%26%20%285%29%5C%5C%0A%5Ctext%7B%5C%26%7D%20%26%20%5Cunderline%7B1111%5C%201101%7D%20%26%20%28-3%29%5C%5C%0A%26%200000%5C%200101%20%26%20%285%29%0A%5Cend%7Bmatrix%7D%0A&id=p114k)
我们可以看到 5 & -3
的结果依旧是 5,因为上下对应位置上的比特位参与运算的时候,只有从右开始数的第 1、3 个比特位结果是 1,其它地方都是 0。
Part 3 位或运算
位或运算和位与运算差不多,也和位与运算的过程是对称的:只有两边都是 0 的时候,结果是 0,否则是 1。
我们依旧拿 5 和 -3 举例子。
%5C%5C%0A%5Ctext%7B%7C%7D%20%26%20%5Cunderline%7B1111%5C%201101%7D%20%26%20(-3)%5C%5C%0A%26%201111%5C%201101%20%26%20(-3)%0A%5Cend%7Bmatrix%7D%0A#card=math&code=%5Cbegin%7Bmatrix%7D%0A%26%200000%5C%200101%20%26%20%285%29%5C%5C%0A%5Ctext%7B%7C%7D%20%26%20%5Cunderline%7B1111%5C%201101%7D%20%26%20%28-3%29%5C%5C%0A%26%201111%5C%201101%20%26%20%28-3%29%0A%5Cend%7Bmatrix%7D%0A&id=RlSv9)
可以看到,这个结果是 -3。
它使用符号 |
来表示。
Part 4 位异或运算
位异或运算和逻辑异或运算是一样的。我们依旧把 1 当成 true
、0 当成 false
。当两个比特参与运算的时候,当且仅当两个比特位相同的时候(都是 0 或者都是 1),结果是 0;否则是 1。
%5C%5C%0A%5Cwedge%20%26%20%5Cunderline%7B1111%5C%201101%7D%20%26%20(-3)%5C%5C%0A%26%201111%5C%201000%20%26%20(-8)%0A%5Cend%7Bmatrix%7D%0A#card=math&code=%5Cbegin%7Bmatrix%7D%0A%26%200000%5C%200101%20%26%20%285%29%5C%5C%0A%5Cwedge%20%26%20%5Cunderline%7B1111%5C%201101%7D%20%26%20%28-3%29%5C%5C%0A%26%201111%5C%201000%20%26%20%28-8%29%0A%5Cend%7Bmatrix%7D%0A&id=YUHbI)
这个结果是 -8。至于最后的结果,你可以参考前面的补码转回原码的模式,来得到结果。
它使用符号 ^
来表示。
Part 5 位取反运算
位取反运算和“原码取反”的过程基本一样,但是位取反的逻辑甚至会把每一个比特位取反,包括符号位。不过和逻辑取反运算类似,它只针对于一个数字进行运算,而不是两个数字一起参与运算。
%5C%5C%0A%26%200000%5C%200010%20%26%20(2)%0A%5Cend%7Bmatrix%7D%0A#card=math&code=%5Cbegin%7Bmatrix%7D%0A%5Ctextasciitilde%20%26%20%5Cunderline%7B1111%5C%201101%7D%20%26%20%28-3%29%5C%5C%0A%26%200000%5C%200010%20%26%20%282%29%0A%5Cend%7Bmatrix%7D%0A&id=AO3mU)
不过稍微不一样的地方是,位取反运算并不是用感叹号,而是 ~
符号:比如说 ~(-3)
的结果就是 2。当然,这个 -3 的括号可以不要,即 ~-3
,只是这么写可能初学的时候不是很好看。
Part 6 位左移运算
位左移运算写成 <<
,表示将数值的比特位直接往左移动若干位置;右边移动出去的部分补充 0。比如 3 << 4
的话,将 3 写成二进制就是 0000 0011
。<< 4
表示往左边移动 4 个比特,然后右侧补充 0,就变成了 0000 0011 0000
。显然,我们拿 sbyte
类型举例,高 4 个位置的比特位会超出存储范围,因此会被舍弃掉,故结果是 0011 0000
,即十进制的 48,故 3 << 4
的结果就是 48。
这里可以记住一个结论。由于比特位是完整左移的,再加上右侧全部自动补充 0 来填补位置,所以实际上这个数据是被扩大了 2 的次幂这么多倍数。举个例子,
3 << 4
就应该和 的结果是一样的。实际上一看,确实是的:。
Part 7 位右移运算
同理,位右移运算写成 >>
,即将所有比特位往右边移动若干位置;然后左侧多出来的位置补充 0 占位。比如 47 >> 3
这个数值等于多少呢?47 写成二进制是 0010 1111
,往右移动 3 个位置就变成了 0000 0101 111
。最后面的三个位置上的 1 由于超出了表达范围,因此被舍去,因此数据变成了 0000 0101
,这个数值是 5,因此 47 >> 3
的结果是 5。
同样地,我们可以发现位右移运算也有类似的结论。往右移动比特位会将低比特位丢弃,而原始数据被缩小,因此实际上数据是被缩小了 2 的次幂这么多倍数。举个例子,
47 >> 3
就应该和 是一样的。注意,外围的 里,这个符号叫向下取整。因为数值本身是整数,而除法会使得数据变为小数,因此需要取整运算。
Part 8 总结
本文给大家介绍了位运算操作。这些操作可能不容易理解,对于我们以后来说,很少用到。如果我们会用到它们,我们在后面的文章会再次说明。
运算符(五):复合赋值运算符
复合赋值运算符(Compound Operator)是一种简化运算符调用过程的新型运算符。它不需要你单独学习新的语法,只要你掌握了原始的运算符规则,复合赋值运算符就相当于是一并掌握了。
复合赋值运算符针对于需要两个数值才能操作的运算符才有。比如说 +
、-
、%
、>>
等等。这些运算符都可以使用复合赋值运算符来简化调用:a op a = b
可以简化成 a op= b
。其中的 op
是一个运算符号。
Part 1 经典的 a = a + 1 问题
入门初学赋值语句的时候,经常会看不懂 a = a + 1
类似的赋值过程。等号左侧的变量表示赋值方,右侧是表达式,是一个结果。等号的左右两侧虽然都有 a
,但是实际上右侧的 a
只起到了取值的作用,而左侧的 a
起到的则是一个代号,表示赋值结果要到 a
变量里来,而不是赋值给别的变量上去。
正是因为如此,所有左右两侧可以完全使用同一个变量来赋值。a = a + 1
实际上就是把 a
原始的结果增大一个单位之后,将结果重新赋值给 a
的过程。这样理解的话,显然在语法语义上、逻辑上都是完全说得通的,也不产生错误。
Part 2 复合赋值运算符的简化过程
实际上,前文给的 a = a + 1
可以使用复合赋值运算符来简化使用:a += 1
。复合赋值运算符的类似模式还有很多,比如说 a = a >> 3
可以改写成 a >>= 3
、a = a * 5
可以改写成 a *= 5
、a = a % (b * 7)
可以改成 a %= b * 7
等等。
所以说,语法上基本上不用多说。稍微注意一下的是,只有如下的符号才有复合赋值运算符的配套写法。
- 数值加法运算、字符串拼接运算
+
- 减法运算
-
- 乘法运算
*
- 除法运算
/
- 取模运算
%
- 贪婪逻辑且运算、位与运算
&
- 贪婪逻辑或运算、位或运算
|
- 逻辑异或运算、位异或运算
^
- 位左移运算
<<
- 位右移运算
>>
就这么一些。基本上都有,对吧。不过,&&
和 ||
就没有。比如说 a = a && b
就不能写成 ~~a &&= b~~
,因为 C# 本身是没有这种复合赋值的。
Part 3 怎么理解复合赋值运算
从人类的角度来说,复合赋值运算并不单单是一种简化写法。为了简化理解,我们没有必要从代码简化这个层面去绕一下。
思考一下,我们将数值本身增大(或者缩小),然后赋值回去,可以怎么理解呢?是的,累计。比如说 a += 3
就可以理解成“a
再增大 3 个单位”;a *= 5
表示“a
扩大 5 倍”。所以,它是一种累计的过程。这么理解就轻松一些了:从原有数据的基础上修改得到的新数值。
Part 4 a = a + 1
、a += 1
和 a++
有啥区别?
下面我们来说一下,a = a + 1
、a += 1
和 a++
三者的区别。
实质上,三者没有语义上的区别(它们都是增大一个单位),如果 a++
直接写成语句的话(即 a++;
这样的,当然也可以包含 ++a;
这样的东西,它俩之前说过是没区别的),那么 a++
、a = a + 1
和 a += 1
三者都可以拿来比较。
不过说实在的,你完全可以认为,a++
是 a += 1
的简写,a += 1
又是 a = a + 1
的简写。而从性能分析上讲,它们依旧没有区别。但我们更建议使用具体化格式来代替广泛化格式。通用写法可以涵盖更多的格式,但它可能在处理过程之中和具体化的写法要复杂一点。当然,从书写上,简单的更香一些,不是吗?
Part 5 串联赋值
赋值运算符和 ++
、--
运算符具有相同的功效。除了本身的行为以外,它自身(整个表达式)也是可以体现出一个数值的。举个例子:
int a, b, c;
a = b = c = 3;
在第 2 行里,我们串联了多个赋值语句。按照赋值的方向(从右到左)来看,这个表达式等价于 a = (b = (c = 3))
,即先将 c = 3
赋值完成后,将 c
的结果赋值给 b
;最后,又把 b
的结果赋值给 a
,以此达到串联赋值的效果。这里 c = 3
整个表达式的结果就是 3,即 c
赋值的结果。
这个用法还可以用于串联复合赋值运算。
int a = 1, b = 2, c = 3, d;
d = a *= b += c;
表达式 d = a *= b += c
是一个串联赋值。按照赋值运算符的过程,这个表达式等价于 d = (a *= (b += c))
。b
的原始数值是 2,那么 b += c
的结果就是将 b
改成 5(2 + 3);然后,整个表达式 b += c
的结果就是 5,然后带入到 a *= ...
之中,即 a *= 5
。而 a
此时是 1,因此 a
被改成 5。接着,a *= ...
整个表达式的结果也是 5,并最后赋值给 d
,即执行的是 d = ...
整个语句。
所以,d
最后是 5 这个数值。
Part 6 总结
本节我们学到了复合赋值运算符的赋值过程,而且重新回顾了赋值的过程。另外,复合赋值和普通赋值过程都带有一个结果,这个结果可以提供给别处使用。
运算符(六):溢出校验运算符
Part 1 溢出和数据处理
1-1 溢出的概念
C# 里出现了一种新的处理过程,叫做溢出校验(Overflow Checking)。在数据类型的内容里,我们说到,任何一种数据类型都是具有有限的存储空间的。既然空间有限,那么自然数据存储就是有限的。比如说 byte
,最大只能到 255。如果我们有这样的一种情况:
byte a = 255;
a++;
Console.WriteLine(a);
我们尝试对 a
继续增大一个单位,会如何呢?这个情况被称为溢出(Overflow)。但是很遗憾的是,在 C 语言里,数据溢出是未定义行为(Undefined Behavior,简称 UB)。未定义行为这个概念不好理解,不过说白了就是,不同的编译器对这句话的理解不同。具象化示例:比如说有三个人,它们看这句话会产生三种不同的意思。那么这句话就称为未定义的,它执行的行为就称为未定义行为。
那么,说这个溢出是未定义行为,是什么意思呢?就是说,有可能这个编译器会对溢出情况这么做,但别的编译器可能那么做,总之做法不同。既然没有统一的处理模式,那么代码上就是不严谨的。因此,C# 将这种溢出完善了。
在 C# 里,我们有两种处理模式:
- 没有溢出校验:将数值从这个数据类型的最大值直接改成这个数据的最小值,就认为它增大了一个单位;
- 启用溢出校验:当溢出的时候,直接产生一个运行错误。
大多数时候,直接产生运行错误是没必要的,因为我们都用不到这种“边界处理”。但 C# 仍旧提供了这种行为。比如说我写了一个计算器的程序。如果数据在计算的时候超出了数据能表示的范围,我们肯定希望用户可以看到“数据超出计算范围”的信息。如果选择前者处理模型的话,由于它不会产生错误信息,因此我们根本不可能从代码层面实现“让用户看到运算超出范围”的结果。在这种情况下,我们可能会考虑选择后者。
1-2 没有溢出校验的处理,为什么会从最大值改成最小值?
C# 采用了一种容易电脑处理的机制。我们拿 byte
类型举例。byte
只需要 8 个比特位,如果是 255 的话,这个数值一定是 1111 1111
。当给这个数值增大一个单位的时候,数字会变成 1 0000 0000
。但是很显然,byte
类型只能使用 8 个比特位,那么超出来的最高位的 1 就必须得舍弃。因此这个数值就变成了 0000 0000
;而这个数字就是 0。这不就是从最大改成最小了嘛。
所以,对于一个整数类型来说,如果对这个数据类型的最大值再增大一个单位,就会将这个数字变成这个数据类型的最小值;反之的话,如果这个数字是这个数据类型的最小值,那么减小一个单位,自然就变成最大值了:因为减 1 的话不够减,就会疯狂从高位借位,然后就全部比特位都得借,因此就变成了最大值了。
在说完前面的内容之后,下面我们来说一下如何使用这个溢出校验的功能。
Part 2 启用溢出校验
启用溢出校验是通过项目配置文件来启用的。为了学习简单,我们使用较为简单的配置方式:使用界面。
首先,我们找到解决方案资源管理器,单击项目。
在点击之后,会弹出设置页面。当然,你依然可以在选中项目后,点击右键,选择“Properties”(属性),进入设置页面。
在进入配置页面后,找到“Build”(生成)选项卡下,右下角的“Advanced…”。
最后选择启用溢出检查就可以了。
这样就完成了启用溢出校验。
顺带一提。如果没有这里的这些个步骤的话,整个项目里的代码是不会检查溢出的。换句话说,即使产生了溢出,也不会产生错误,而是使用默认的计算逻辑(就前面那个,最大变最小,最小变最大的那个);如果启用了的话,整个项目的所有加减乘除模这些会改动变动数据数值的过程都会在运算结果的过程期间校验是不是会溢出。如果溢出就自动产生错误,让程序崩溃。
当然,产生这个错误让程序崩溃的过程是可以在将来得到处理的,换句话说,我们以后会学到一种处理逻辑,会“捕获”这种错误信息,然后避免程序崩溃,并在这种错误发生后,给用户提示对应的错误信息,防止程序闪退。这种过程现在我们说不到,因此我们这里先卖个关子。
Part 3 checked
和 unchecked
关键字
下面我们的重头戏就来了。我们使用 checked
关键字或 unchecked
关键字专门用来控制代码是否需要校验溢出。前文我们介绍了如何配置项目启用校验溢出的过程,这里我们来实际使用一下。
先来说一下功能:
checked
关键字:用于一个表达式或一个代码块,表示这个表达式或者代码段落都启用溢出检查。如果前文我们配置溢出的过程已经完成,那么项目里面就不必使用这个关键字;unchecked
关键字:用于一个表达式或一个代码块,表示这个表达式或代码段落不启用溢出检查。如果前文我们配置溢出的过程没有做的话,那么项目默认就是不检查的,因此我们不用使用这个关键字。
我们先来试试写这么一段代码:
using System;
namespace TestProject
{
internal class Program
{
private static void Main()
{
checked
{
byte a = 255;
Console.WriteLine(++a);
}
}
}
}
或者,如果觉得这么写很丑,你可以这么写:
using System;
namespace TestProject
{
internal class Program
{
private static void Main()
{
byte a = 255;
Console.WriteLine(checked(++a));
}
}
}
这两种写法都是没有问题的。前者这样写的话,大括号里面的部分,只要参与运算的地方,都会检查溢出;而后面这种,就只对 ++a
检查是否溢出了。当然,实际上我们可以看到,就算写成前面那样,我们也知道里面就只有 ++a
需要校验是否溢出,因此这两个写法是等价的。
当然,如果你配置了项目溢出检查的话,
checked
是可以不用的。这一点前面已经说过了。
如果我们尝试运行程序。我们确实看到了程序崩溃,并产生了一个错误:
如果不检查溢出,我们可以使用 unchecked
关键字来避免检查。
using System;
namespace TestProject
{
internal class Program
{
private static void Main()
{
byte a = 255;
Console.WriteLine(unchecked(++a));
}
}
}
这样的话,你就可以得到 0 这个输出结果,并且没有错误信息。
Part 4 总结
本节内容我们学习到了 checked
和 unchecked
关键字的用法。用来标记一个表达式(或一个代码段),来表示这个表达式(或代码段)是否启用溢出检查。checked
是启用,而 unchecked
则是不启用。
说句实话,初学没有必要搞很清楚,能懂个大概就可以了。